七种模型时序预测对比:LSTM为何输给BP? 简介这份资源面向希望系统入门时间序列预测的开发者与学习者围绕同一预测任务横向对比多种经典模型帮助理解不同网络结构在时序建模上的差异与适用场景。包内共25个文件以9个Python脚本为核心配合10个CSV与3个XLSX数据集另有txt、json、pkl等辅助文件压缩包约5.08MB涵盖BP神经网络、CNN、LSTM、GRU、RNN、SVM以及CNNLSTM混合模型等实现数据涉及太阳黑子月度序列与上车训练测试数据可直接运行对照。已有1177人学习下载说明该案例在入门实践中具有一定参考价值。读者可借此掌握数据读取与预处理、模型搭建、训练预测的完整流程并通过多模型横向比较积累调参经验与排错思路适合作为课程设计、毕设或自学练手的实践素材。1. 七个模型跑同一份时序数据为什么你的 LSTM 可能输给一个 BP同一份带周期性的传感器数据有人用 LSTM 跑出 0.08 的 MAPE有人换成 BP 神经网络反而拿到 0.06这不是玄学是窗口构造和归一化方式在作怪。时间序列预测学习这条路上BP、CNN、LSTM、GRU、RNN、SVM、CNNLSTM 这七种模型经常被放在一起比较但真正决定成败的往往不是模型本身而是你把序列切成什么形状喂进去。这篇笔记面向已经会用 Python 处理数据、想把这七种模型在同一套流程里跑通并做横向对比的从业者。我会按「数据窗口怎么切 → 每个模型怎么搭 → 参数怎么调 → 哪里容易翻车」的顺序讲所有代码都是可以直接复制运行的最小实现数据集用一份模拟的周期性时序你换成自己的 CSV 只需要改列名。2. 先把时序问题改造成监督学习窗口、归一化与数据集划分2.1 滑动窗口到底怎么切切错了后面全白搭时间序列预测的核心动作只有一个把一维序列变成「输入-输出」样本对。假设你有序列[x1, x2, x3, x4, x5]用过去 3 个点预测下一个点就得到([x1,x2,x3], x4)和([x2,x3,x4], x5)两个样本。这个「过去 3 个点」就是窗口长度也叫 lookback。窗口太短模型看不到一个完整周期窗口太长样本数骤减且噪声被放大。我一般会先画自相关图ACF看序列在第几个滞后点相关性明显回落把那个位置作为窗口长度的起点再在验证集上微调。import numpy as np import pandas as pd def make_windows(series, lookback24, horizon1): series: 一维 numpy 数组 lookback: 用过去多少个时间步作为输入 horizon: 预测未来第几步 返回 X shape(样本数, lookback), y shape(样本数,) X, y [], [] for i in range(len(series) - lookback - horizon 1): X.append(series[i : i lookback]) y.append(series[i lookback horizon - 1]) return np.array(X), np.array(y) # 模拟一份带日周期和噪声的序列 np.random.seed(42) t np.arange(0, 2000) series 10 3 * np.sin(2 * np.pi * t / 24) np.random.normal(0, 0.5, len(t)) X, y make_windows(series, lookback24, horizon1) print(X.shape, y.shape) # (1975, 24) (1975,)这段代码里lookback24对应一个完整周期horizon1表示单步预测。如果你要做多步预测把horizon改成 6 或 12y的取值位置会相应后移。注意循环上界是len(series) - lookback - horizon 1少减一个 1 就会越界这是最常见的索引错误。2.2 归一化必须在划分训练测试集之后做很多人习惯先对整条序列做 MinMax 归一化再切窗口这会造成信息泄漏测试集的极值参与了训练阶段的缩放验证指标会虚高。正确顺序是先用训练集拟合 scaler再 transform 测试集。对于时序数据还要保证不打乱顺序训练集取前 70%验证集取中间 15%测试集取最后 15%。from sklearn.preprocessing import MinMaxScaler n len(series) train_end int(n * 0.7) val_end int(n * 0.85) scaler MinMaxScaler() train_scaled scaler.fit_transform(series[:train_end].reshape(-1, 1)).flatten() val_scaled scaler.transform(series[train_end:val_end].reshape(-1, 1)).flatten() test_scaled scaler.transform(series[val_end:].reshape(-1, 1)).flatten() X_train, y_train make_windows(train_scaled, 24, 1) X_val, y_val make_windows(val_scaled, 24, 1) X_test, y_test make_windows(test_scaled, 24, 1)fit_transform只出现在训练集上验证和测试一律用transform。预测结束后用scaler.inverse_transform还原到原始量纲再算 MAPE 或 RMSE否则你比较的是归一化空间的误差没有业务意义。2.3 七种模型对输入形状的要求不一样这是把多个模型放在一起跑时最容易乱的地方。BP 和 SVM 吃的是二维表(样本数, 特征数)所以窗口要展平RNN、LSTM、GRU 吃的是三维张量(样本数, 时间步, 特征数)CNN 做一维卷积时也吃三维但通道维在最后CNNLSTM 则是先卷积提局部特征再送进 LSTM。下面这张表是我实际跑通后整理的形状对照照着改就不会报维度错误。模型输入形状关键 reshape 操作BP(样本, lookback)无需变形SVM(样本, lookback)无需变形RNN/LSTM/GRU(样本, lookback, 1)X.reshape(-1, lookback, 1)CNN(样本, lookback, 1)同上CNNLSTM(样本, lookback, 1)同上卷积后保持时间维提示如果你的特征不止一列比如同时有温度、湿度、压力把最后一维从 1 改成特征数所有模型的输入形状同步调整。3. 从 BP 到 SVM先把非序列模型跑成基线3.1 BP 神经网络做时序预测展平窗口就够了BP 网络本质是全连接层堆叠它没有时间概念把窗口展平成一维向量后每个时间步就是一个普通特征。优点是训练快、调参少适合作为基线。缺点是它无法区分「第 1 个时间步」和「第 24 个时间步」的先后关系所以对强周期序列往往不如循环网络。我一般用两层隐藏层第一层 64 个神经元第二层 32 个激活函数用 ReLU输出层线性。import torch import torch.nn as nn class BPNet(nn.Module): def __init__(self, input_dim): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1) # 回归任务输出 1 维 ) def forward(self, x): return self.net(x) # 训练时把窗口展平 X_train_bp torch.tensor(X_train, dtypetorch.float32) y_train_bp torch.tensor(y_train, dtypetorch.float32).view(-1, 1) model BPNet(input_dim24) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3)input_dim24必须和lookback一致改了窗口长度这里要同步改。lr1e-3是 Adam 的常用起点如果 loss 震荡就降到 5e-4如果下降太慢就升到 3e-3。训练轮数我一般设 200配合早停验证 loss 连续 20 轮不降就停避免过拟合。3.2 SVM 做回归要选对核函数和惩罚系数SVM 用于回归时叫 SVR它通过核函数把窗口映射到高维空间找回归面。线性核适合近似线性的序列RBF 核适合非线性但样本量不大的场景。SVR 对特征尺度极其敏感所以前面的归一化不能省。惩罚系数 C 越大越容易过拟合epsilon 控制容忍带宽度太小会让支持向量过多、训练变慢。from sklearn.svm import SVR from sklearn.metrics import mean_absolute_percentage_error svr SVR(kernelrbf, C10.0, epsilon0.01, gammascale) svr.fit(X_train, y_train) pred_val svr.predict(X_val) mape mean_absolute_percentage_error(y_val, pred_val) print(fSVR 验证集 MAPE: {mape:.4f})C10和epsilon0.01是我在归一化到 [0,1] 的数据上常用的组合。如果 MAPE 偏高先把C在[1, 10, 100]里试一遍再调gamma。SVR 的短板是样本量超过一万后训练时间明显上升这时候它更适合当基线而不是主力。3.3 基线模型的价值在于告诉你「问题有多难」BP 和 SVM 跑完之后你会得到两个基准数字。如果后面的 LSTM 只比它们好一点点那说明这份数据的时序依赖并不强上复杂模型性价比不高。如果 LSTM 明显更好说明窗口内的顺序信息确实被利用了。我习惯把这两个基线结果记在实验日志里后面每换一个模型都对照着看避免被单次波动的指标骗到。4. 循环家族逐个拆RNN、LSTM、GRU 的搭建与参数4.1 普通 RNN 的梯度问题在长窗口下会暴露RNN 的隐藏状态按时间步递归传递理论上能记住任意长的历史但实际训练时梯度会沿时间步连乘窗口超过 20 步就容易梯度消失或爆炸。它的代码结构最简单适合用来验证数据管道是否通畅不适合直接上生产。class RNNNet(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers1): super().__init__() self.rnn nn.RNN(input_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, 1) def forward(self, x): # x: (batch, seq_len, input_size) out, _ self.rnn(x) return self.fc(out[:, -1, :]) # 取最后一个时间步 X_train_rnn torch.tensor(X_train, dtypetorch.float32).unsqueeze(-1)batch_firstTrue让输入形状变成(batch, seq_len, input_size)不加这个参数就得手动转置。out[:, -1, :]取最后一个时间步的隐藏状态因为回归任务只需要一个输出。hidden_size64是起点序列复杂度高可以加到 128但要注意参数量随之翻倍。4.2 LSTM 的三个门控到底在控制什么LSTM 比 RNN 多了输入门、遗忘门、输出门遗忘门决定上一时刻的细胞状态保留多少输入门决定当前信息写入多少输出门决定隐藏状态输出多少。这套机制让它在窗口较长时更稳定。搭建时num_layers可以设 2 层堆叠但层数越多越容易过拟合小数据集上 1 层往往够用。class LSTMNet(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2, dropout0.2): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.fc nn.Linear(hidden_size, 1) def forward(self, x): out, (h, c) self.lstm(x) return self.fc(out[:, -1, :])dropout0.2只在num_layers 1时生效这是 PyTorch 的行为单层 LSTM 加 dropout 不会报错但也不起作用。hidden_size和num_layers是两个主要调节旋钮我一般先固定层数为 1 调 hidden_size再考虑加层。4.3 GRU 用两个门换更少参数什么时候选它GRU 把 LSTM 的三个门合并成重置门和更新门参数量少约四分之一训练速度更快。在中小规模时序上GRU 和 LSTM 的精度差距通常很小但 GRU 更不容易过拟合。如果你的数据量在几千条以内我会优先试 GRU。class GRUNet(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers1): super().__init__() self.gru nn.GRU(input_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, 1) def forward(self, x): out, _ self.gru(x) return self.fc(out[:, -1, :])三个循环网络的训练循环可以共用一套代码只是把模型类换掉。优化器统一用 Adam学习率 1e-3batch size 设 32 或 64。判断是否收敛看验证集 loss不要只看训练 loss。5. CNN 与 CNNLSTM卷积提局部特征循环抓长期依赖5.1 一维卷积在时序上提取的是什么一维卷积核在时间轴上滑动每个核学习一种局部波形模式比如上升沿、下降沿、短周期波动。它比循环网络训练快得多且对局部突变敏感。做时序预测时卷积核大小一般取 3、5、7层数 1 到 2 层就够再多会过度平滑。class CNNNet(nn.Module): def __init__(self, input_size1, channels32, kernel_size3): super().__init__() self.conv nn.Sequential( nn.Conv1d(input_size, channels, kernel_size, paddingkernel_size//2), nn.ReLU(), nn.AdaptiveAvgPool1d(1) # 把时间维压成 1 ) self.fc nn.Linear(channels, 1) def forward(self, x): # x: (batch, seq_len, input_size) - (batch, input_size, seq_len) x x.permute(0, 2, 1) x self.conv(x).squeeze(-1) return self.fc(x)paddingkernel_size//2保证卷积后时间维长度不变AdaptiveAvgPool1d(1)把整条时间轴压成一个向量。注意输入要先permute成(batch, 通道, 时间步)因为 Conv1d 要求通道维在中间。这一步顺序搞反是 CNN 报错的头号原因。5.2 CNNLSTM 的分工先局部后全局CNNLSTM 的思路是让卷积层先把窗口内的局部模式压缩成更紧凑的特征序列再交给 LSTM 建模长期依赖。它适合窗口较长、局部波动又有长期趋势的序列。卷积层不要堆太多一层就够否则时间维被压得太短LSTM 没东西可学。class CNNLSTMNet(nn.Module): def __init__(self, input_size1, channels32, kernel_size3, hidden_size64): super().__init__() self.conv nn.Conv1d(input_size, channels, kernel_size, paddingkernel_size//2) self.relu nn.ReLU() self.lstm nn.LSTM(channels, hidden_size, batch_firstTrue) self.fc nn.Linear(hidden_size, 1) def forward(self, x): x x.permute(0, 2, 1) # (batch, 1, seq_len) x self.relu(self.conv(x)) # (batch, channels, seq_len) x x.permute(0, 2, 1) # (batch, seq_len, channels) out, _ self.lstm(x) return self.fc(out[:, -1, :])两次permute是这段代码的关键卷积前把通道维提前卷积后把时间维提前还给 LSTM。channels32控制卷积输出特征数它同时是 LSTM 的input_size改一个另一个要同步改。5.3 七个模型统一训练循环与指标对比把模型类定义好之后训练循环可以写成通用函数传入模型、数据加载器和优化器即可。指标统一用 MAPE 和 RMSE都在还原量纲后计算。def train_model(model, X_tr, y_tr, X_va, y_va, epochs200, lr1e-3, patience20): X_tr torch.tensor(X_tr, dtypetorch.float32) y_tr torch.tensor(y_tr, dtypetorch.float32).view(-1, 1) X_va torch.tensor(X_va, dtypetorch.float32) y_va torch.tensor(y_va, dtypetorch.float32).view(-1, 1) optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.MSELoss() best_loss, wait float(inf), 0 for epoch in range(epochs): model.train() optimizer.zero_grad() loss criterion(model(X_tr), y_tr) loss.backward() optimizer.step() model.eval() with torch.no_grad(): val_loss criterion(model(X_va), y_va).item() if val_loss best_loss: best_loss, wait val_loss, 0 else: wait 1 if wait patience: break return modelpatience20是早停耐心值验证 loss 连续 20 轮不降就停。view(-1, 1)把标签变成列向量和模型输出形状对齐。这个循环对 BP、RNN、LSTM、GRU、CNN、CNNLSTM 都适用SVM 用 sklearn 的 fit 单独跑。6. 避坑与排查七个模型混跑时最容易翻车的五件事6.1 现象LSTM 验证 loss 一直不降训练 loss 却很低原因通常是窗口切分时训练集和验证集之间有重叠或者归一化用了全量数据。解决方法是检查make_windows的调用是否在各自子序列上独立进行并确认 scaler 只在训练集上 fit。我遇到过把整条序列先归一化再切分的情况验证指标好看得离谱上线后直接崩。6.2 现象CNN 报错 expected 3D input got 2D原因是 Conv1d 要求输入是(batch, 通道, 时间步)而窗口数据默认是(batch, 时间步)。解决方法是先unsqueeze(-1)补通道维再permute(0, 2, 1)把通道换到中间。这两个操作顺序不能反反了形状对不上。6.3 现象SVM 训练时间随样本量暴涨原因是 RBF 核的计算复杂度随样本数平方增长。解决方法是先对窗口做降采样或主成分分析或者改用线性核。如果样本超过两万条SVR 就不适合当主力了把它当基线即可。6.4 现象GRU 和 LSTM 结果几乎一样不知道该选哪个这是正常现象两者在多数任务上精度接近。选择依据应该是训练时间和参数量数据量小选 GRU数据量大且对精度要求高选 LSTM。不要为了零点几个百分点的 MAPE 反复换模型先把窗口长度和归一化调好收益更大。6.5 现象所有模型在测试集上都比验证集差一截原因是测试集在时间上更靠后分布可能发生了漂移。解决方法是检查测试集区间的均值和方差是否和训练集接近如果差异大考虑用滚动预测或在线更新。时序预测没有后悔药分布漂移只能靠持续监控来发现。7. 把窗口长度当成超参数来搜一个能省半天时间的做法七个模型搭完之后真正拉开差距的往往不是模型结构而是lookback这个数。我一般会写一层循环把lookback在[12, 24, 48, 72]里遍历每个值下用同一个模型跑一遍验证集记录 MAPE。这样一轮下来你能看到误差随窗口长度的变化曲线通常存在一个明显的最低点。下面这段代码就是干这个的。results {} for lb in [12, 24, 48, 72]: X_tr, y_tr make_windows(train_scaled, lb, 1) X_va, y_va make_windows(val_scaled, lb, 1) model LSTMNet(input_size1, hidden_size64, num_layers1) model train_model(model, X_tr, y_tr, X_va, y_va) model.eval() with torch.no_grad(): pred model(torch.tensor(X_va, dtypetorch.float32)).numpy().flatten() pred_inv scaler.inverse_transform(pred.reshape(-1, 1)).flatten() y_inv scaler.inverse_transform(y_va.reshape(-1, 1)).flatten() results[lb] mean_absolute_percentage_error(y_inv, pred_inv) print(results)跑完你会得到类似{12: 0.092, 24: 0.061, 48: 0.058, 72: 0.074}的结果说明这份数据的最佳窗口在 24 到 48 之间。注意每次换lookback都要重新调用make_windows不能复用之前的数组。另外scaler.inverse_transform要求输入是二维所以先reshape(-1, 1)再展平。还有一个我踩过的坑窗口长度改变后训练样本数会变早停的耐心值也要相应调整。样本少的时候patience20可能还没等到收敛就停了我一般把耐心值设成总轮数的十分之一。最后别忽略最简单的那条基线——把上一个时间点的值直接当预测值如果复杂模型跑不赢这个朴素基线说明数据里的时序依赖很弱该回头检查特征工程而不是继续加层数。希望帮到你。本文还有配套的精品资源点击获取