ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

时间序列预测模型选型与调参:从BP、SVM到CNN+LSTM

时间序列预测模型选型与调参:从BP、SVM到CNN+LSTM

简介:一套面向时间序列预测学习者的综合资源包,整合BP神经网络、CNN、LSTM、GRU、RNN、SVM与CNN+LSTM等主流模型,帮助读者在统一框架下对照经典机器学习与深度学习方法,系统掌握预测建模全流程。压缩包共25个文件,以9个Python脚本为执行主体,配合10个CSV、3个Excel数据文件以及txt/json/pkl辅助配置,数据与代码分离便于直接运行和调参,整体仅5.08MB,轻量实用。目前已有1176人学习下载,适合正在做课程设计、论文实验或入门进阶的时间序列学习者。资源按模型分目录组织,每个模型均配有可执行脚本与配套数据集,可快速复现BP、LSTM、GRU、SVM等模型的训练与预测,并通过CNN+LSTM混合案例理解特征提取与时序建模的融合思路;同时附带数据读取与预处理脚本,有利于对比不同模型在同一数据上的表现,提升实际选型与调参能力。

1. 时间序列预测:先搞清这八种模型谁该打头阵

时间序列预测学习,一上来就把 BP、CNN、LSTM、GRU、RNN、SVM 外加 CNN+LSTM 全摆出来,很容易让人以为多跑几个模型就能出好结果。真实情况是:如果不先把数据窗口、归一化和评估方式定死,这些模型的差距会被噪声淹没,谁赢都只是偶然。接下来这套路径能直接照做:先用 sklearn 把 BP、SVM 跑成基线,再用 PyTorch 从 RNN 推到 LSTM、GRU,最后用一维 CNN 给 LSTM 当特征提取器。它适合卡在“模型都会调但预测不涨”的从业者,也帮你判断在小样本、强噪声的序列上,到底哪个模型值得投入。

2. 数据与基线:BP、SVM 是先把预测问题做对的最快路径

2.1 滑动窗口与归一化:所有模型的通用入口

时间序列预测和普通回归最大的不同,是样本之间不能打乱。预测 t+1 时刻的值,只能用 t 时刻之前的信息,不能用 t 之后的。所以无论用哪个模型,第一步都一样:把一条长序列切成一段段窗口,每个窗口的前 window 个时刻当输入,后 horizon 个时刻当标签。

import numpy as np from sklearn.preprocessing import MinMaxScaler def make_samples(series, window=24, horizon=1, stride=1): X, y = [], [] for i in range(0, len(series) - window - horizon + 1, stride): X.append(series[i:i + window]) y.append(series[i + window:i + window + horizon]) return np.array(X), np.array(y) # 假设 series 是一维 numpy 数组,按时间顺序排列 train_raw, valid_raw = series[:800], series[800:1100] scaler = MinMaxScaler() # 只让训练段参与拟合,验证段只做 transform,避免未来信息混入 train_arr = scaler.fit_transform(train_raw.reshape(-1, 1)).ravel() valid_arr = scaler.transform(valid_raw.reshape(-1, 1)).ravel() X_train, y_train = make_samples(train_arr, window=24, horizon=1) X_valid, y_valid = make_samples(valid_arr, window=24, horizon=1)

make_samples 返回的 X 是二维数组,形状为 (样本数, window),BP 和 SVM 直接用;后面 LSTM 需要转成三维 (样本数, window, 特征数)。y 的形状是 (样本数, horizon)。窗口大小和预测步长是这里最核心的两个参数,直接决定所有模型看到的输入范围。

参数作用常见取值
window回看长度,决定模型能看到多久之前的信息无周期取 24~48;有日周期取 24,有周周期取 168
horizon要预测的未来多少步由业务决定,多步预测建议直接构造多输出
stride相邻样本起点间隔训练取 1 或 2;推理必须为 1
数据划分训练/验证/测试的时间顺序6:2:2 或 7:1.5:1.5

注意:时序数据的验证集必须晚于训练集结束时间。随机切分等于把未来信息泄露给模型,验证集指标再好看,上线即原形毕露。

归一化要放在切窗之前,scaler 只由训练段拟合。为什么这样做:MinMaxScaler 会把序列缩到 [0,1],它依赖序列全局最大值和最小值,如果验证段的最大值也被写进归一化范围,模型相当于提前读到了未来的尺度。序列里如果有极端尖峰,用 RobustScaler 比 MinMaxScaler 更稳,否则一个突刺就能把后续所有值压到接近零。

2.2 用 sklearn 搭 BP 与 SVM:拿一组敢信服的对照值

很多人一上来就训 LSTM,却从没跑过基线,最后连“模型到底有没有用”都说不清。先花三分钟把 BP 和 SVM 跑出来,后面所有深度模型都有了参照物。BP 在 scikit-learn 里对应 MLPRegressor,本质是用反向传播训练的多层感知机;SVM 用 RBF 核的 SVR,在小样本上经常能打赢复杂模型。

from sklearn.neural_network import MLPRegressor from sklearn.svm import SVR from sklearn.metrics import mean_absolute_percentage_error def train_predict(model, X_train, y_train, X_valid, scaler_y): model.fit(X_train, y_train.ravel()) pred = model.predict(X_valid).reshape(-1, 1) return scaler_y.inverse_transform(pred).ravel() bp = MLPRegressor(hidden_layer_sizes=(64, 32), max_iter=500, random_state=42) svm = SVR(kernel='rbf', C=10, gamma='scale', epsilon=0.05) pred_bp = train_predict(bp, X_train, y_train, X_valid, scaler) pred_svm = train_predict(svm, X_train, y_train, X_valid, scaler) mape_bp = mean_absolute_percentage_error(valid_raw[24:], pred_bp) mape_svm = mean_absolute_percentage_error(valid_raw[24:], pred_svm) print("BP MAPE:", mape_bp, "SVM MAPE:", mape_svm)

MLPRegressor 的 hidden_layer_sizes=(64, 32) 表示两层隐藏层,神经元分别是 64 和 32。max_iter=500 对几千条样本基本够;如果没收敛,优先把 max_iter 提到 1000,而不是加宽网络,数据量不变时模型变大只会换来更强的过拟合。SVR 的 C 控制误差容忍度,从 1 改到 10 对时序噪声更友好;gamma='scale' 会让 sklearn 按特征数自动缩放,作为起点比手填一个数稳妥。

评估前必须把预测值逆变换回原始尺度。在 [0,1] 区间算 MAPE,误差会被缩放比例放大或压缩,和业务对不上账。这两个数字要记牢:后面 LSTM、GRU、CNN+LSTM 的验证 MAPE 若是打不过它,先别怀疑深度模型不够强,回头检查数据处理和窗口设置,绝大多数问题出在那儿而不是模型。

3. 从 RNN 到 CNN+LSTM:深度模型的逐级推进与必调参数

3.1 RNN、LSTM、GRU:一个类改一行,门控差在哪

RNN 是最原始的循环结构,每个时刻把当前输入和上一个隐状态混合后传下去;LSTM 在它基础上加了输入门、遗忘门、输出门,让网络自己决定记什么、忘什么;GRU 把三个门合并成更新门和重置门,参数少了三分之一,在小样本时序上通常比 LSTM 更稳。RNN 在序列一长时梯度消失明显,基本只当对照组用。

import torch import torch.nn as nn class SequenceModel(nn.Module): def __init__(self, input_size, hidden_size, num_layers, model_type='lstm', dropout=0.0): super().__init__() rnn_cls = {'rnn': nn.RNN, 'lstm': nn.LSTM, 'gru': nn.GRU}[model_type] self.rnn = rnn_cls(input_size, hidden_size, num_layers, batch_first=True, dropout=dropout) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): out, _ = self.rnn(x) return self.fc(out[:, -1, :])

输入 x 的形状是 (batch, seq_len, input_size),batch_first=True 让数据维度顺序符合直觉。out 是每个时刻的隐状态序列,取 out[:, -1, :] 即最后一个时刻的隐状态,过一层全连接得到预测值。切换 RNN、LSTM、GRU 只需要改 model_type 这个参数,其余代码不动,这样对比才公平。

# X_train 是上一章的 2D 数组,这里转成 3D: (样本数, 窗口长度, 特征数) X_train_t = torch.tensor(X_train.reshape(-1, 24, 1), dtype=torch.float32) y_train_t = torch.tensor(y_train.reshape(-1, 1), dtype=torch.float32) X_valid_t = torch.tensor(X_valid.reshape(-1, 24, 1), dtype=torch.float32) y_valid_t = torch.tensor(y_valid.reshape(-1, 1), dtype=torch.float32) def train_loop(model, X_train_t, y_train_t, X_valid_t, y_valid_t, epochs=100): optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.MSELoss() for epoch in range(epochs): model.train() optimizer.zero_grad() pred = model(X_train_t) loss = loss_fn(pred, y_train_t) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() if epoch % 20 == 0: model.eval() with torch.no_grad(): val_pred = model(X_valid_t) val_loss = loss_fn(val_pred, y_valid_t) print(epoch, loss.item(), val_loss.item()) model = SequenceModel(input_size=1, hidden_size=64, num_layers=2, model_type='gru') train_loop(model, X_train_t, y_train_t, X_valid_t, y_valid_t, epochs=100)

hidden_size=64 是起步值,样本量小于 5000 时 32 往往更稳,调大 hidden_size 等于给模型更多参数去背噪声。num_layers=2 能建模更复杂的层级时序特征,三层以上在小数据上是负担。dropout 只有 num_layers>1 时才在循环层之间生效,单层模型设了也白设。clip_grad_norm_ 的 max_norm=1.0 是 LSTM 训练的后悔药,验证 loss 突然跳高时加上它通常能压住梯度爆炸。GRU 和 LSTM 在多数序列上差距很小,GRU 更快;如果 GRU 和 LSTM 都明显优于 RNN,说明序列确实有可利用的时序依赖,值得继续往下走。

3.2 一维 CNN 与 CNN+LSTM:卷积管局部特征,循环管长依赖

CNN 在时序预测里不是来替代 RNN 的,而是做局部特征提取。一维卷积核在序列上滑动,吸收几个相邻时刻组成的局部形态,再用池化把序列长度缩短;缩短后的序列交给 LSTM,循环层只处理更紧凑的高层特征,训练更快、过拟合更少。这也是标题里 CNN+LSTM 混合结构最通用的做法。

class CnnLstm(nn.Module): def __init__(self, in_channels=1, cnn_out=32, kernel_size=3, pool_size=2, hidden_size=64, num_layers=1): super().__init__() self.conv = nn.Sequential( nn.Conv1d(in_channels, cnn_out, kernel_size=kernel_size, padding=1), nn.ReLU(), nn.MaxPool1d(pool_size), ) self.lstm = nn.LSTM(cnn_out, hidden_size, num_layers, batch_first=True) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): # x: (batch, seq_len, 1) -> (batch, 1, seq_len) x = x.permute(0, 2, 1) x = self.conv(x) # 回到 LSTM 需要的 (batch, new_seq_len, cnn_out) x = x.permute(0, 2, 1) out, _ = self.lstm(x) return self.fc(out[:, -1, :])

Conv1d 要求输入是 (batch, channel, seq_len),所以先对 x 做 permute;卷积和池化完再 permute 回 LSTM 需要的 (batch, new_seq_len, cnn_out)。kernel_size=3 + padding=1 让卷积前后序列长度不变;pool_size=2 把序列长度减半,LSTM 处理短序列更快。window 长度要选能被 pool_size 整除的数,否则池化后长度向下取整,LSTM 输入维度对不上,这是 CNN+LSTM 里最隐蔽的维度报错来源。

cnn_out 默认 32,一般从 16 试到 64 足够;kernel_size 不要一下给到 7,在 24 这种短序列上,7 的卷积核几乎等价于对整段做全局平均,高频细节全被抹平。混合结构的值不值得加,判断标准是:先用 GRU 跑出对照 MAPE,再把 CnnLstm 放同一训练循环里跑;如果 MAPE 没有变低,说明 LSTM 已经学到局部特征,加卷积只是增加训练成本。

3.3 一张表定选型:从数据量、平稳性和算力出发

深度模型不是越多越好,选型要按数据特点来。以我目前的做法,这套判断顺序基本能覆盖多数单变量预测场景:

数据条件首选模型理由
样本不足 1000 条SVR 或 BP深度模型在小样本上极易过拟合
有明显周期性GRU门控够用,训练比 LSTM 快
序列长且非平稳LSTM三套门控在长依赖上保留信息更稳
局部突刺、阶跃信号多CNN+LSTM卷积先抓局部形态,再用循环层学依赖
需要快速迭代验证BP 或 SVM秒级训练,先确认数据可预测性

选型不是非此即彼。我一般先用 SVM 确认序列存在可预测性,再让 GRU 和 CNN+LSTM 去抢深度模型的入场券。如果 GRU 已经能压住基线,就往数据质量、特征工程方向投入;只有 GRU 明显不够时,才值得把 CNN+LSTM 这类混合结构加进来。这个顺序能省掉大量在模型结构上反复横跳的时间。

4. 避坑实录:时序预测里最常翻车的五个细节

4.1 数据切分与归一化:这一步错了,神仙模型也救不回来

坑1:随机切分导致成绩虚高

现象:用 train_test_split 默认参数随机切数据,验证集 MAPE 只有 3%,模型上线后误差飙到 30%。原因:随机切分把未来的点混进了训练集,模型等于开卷考试;上线后数据按时间顺序到来,能力立刻露馅。解决:所有时序切分按时间顺序,sklearn 里设 shuffle=False;更稳妥的做法是先按索引位置切好 train/valid/test,再送进模型。

坑2:归一化偷看未来尺度

现象:预测曲线和真实曲线趋势贴合,但整体偏平,波峰波谷都砍掉一截。原因:scaler 在整条序列上 fit,验证集的最大最小值已经被写进归一化范围,模型等于提前读到了未来的统计量,逆变换回原尺度后误差被整体压缩,看起来不错,实际不可复现。解决:scaler 只用训练段 fit,验证段和测试段只 transform,顺序就是第 2 章代码里的样子。一个简单自查方法:打印验证集的极值,如果超出训练集的极值范围,逆归一化后预测被“削顶”几乎是必然的。

坑3:多步预测直接炸掉

现象:单步预测误差很小,一旦滚动预测第三步、第五步就开始跑偏,甚至发散。原因:训练阶段每个预测步都喂真实历史值,模型学会了依赖正确答案;推理时只能把自己预测的值喂回去,误差一步传染一步。解决:多步预测别在循环里反复调用单步模型,让输出层一次吐出 horizon 个值;或者训练时做 scheduled sampling,按概率把预测值当输入喂给模型。

4.2 训练与评估结果:黑匣子也有规律可循

坑4:LSTM 不收敛或出现 NaN

现象:同一份数据上,RNN 的 loss 正常下降,LSTM 的 loss 卡住不动,偶尔直接变成 NaN。原因:LSTM 参数比 RNN 多,梯度在门控路径上更容易被放大到失控;输入序列没做归一化时尤其明显。解决:按顺序排查三件事:先确认输入在 [0,1] 范围;再加上 clip_grad_norm_ 把梯度范数压到 1.0;最后把学习率从 1e-3 降到 5e-4 重新跑。这三步能解决绝大多数不收敛和 NaN。

坑5:验证集被反复翻牌子

现象:模型在验证集上早停后继续调参,验证集指标在涨,最终测试集反而变差;同一套参数换一段数据跑,结果波动极大。原因:验证集被反复使用后已经变成“训练的一部分”,模型对它的拟合被当作真实能力。解决:保留一段完全没用过的测试集,只在最后做一次评估;平时只看验证集调参,不要测完测试集又回头改参数再测。测试集只准用一次,用完就封存。

5. 进阶:多尺度窗口集成与序列分解是提升精度的最后两步

一个模型调到极限之后,还想提升精度,我一般不再加层数,而是从两个方向走:多尺度窗口集成和序列分解。多尺度窗口的思路是,单一回看窗口很难同时覆盖短期惯性和周期规律,那就用 window=24、48、168 分别训三个同结构模型,让它们各自负责短、中、长三个尺度,预测结果取中位数或均值。

pred_24 = model_24.predict(x_24) pred_48 = model_48.predict(x_48) pred_168 = model_168.predict(x_168) pred_final = np.median(np.stack([pred_24, pred_48, pred_168]), axis=1)

序列分解的思路是,用 STL 把序列拆成趋势、季节、残差三部分,趋势项用线性外推,季节项按周期重复,残差项交给模型去抓非规律波动,最后把三段预测加回一起。这样 LSTM 不用同时扛住三种模式,角色单一,精度和稳定性反而更高。

最后一件事是验证方式要滚动起来。把数据按时间切成多段,逐段做训练和验证,记录每段 MAPE 的均值和方差。只跑某一个时间段得好结果,大概率是运气,滚动验证的结果才敢拿去跟业务拍板。

我早期做负荷预测,一股脑上 LSTM,结果被只有几百条样本的 SVM 基线压着打。后来养成的习惯是先把 BP、SVM 基线跑干净,再让深度模型去挑战基线,最后用窗口集成和分解兜底,返工次数少了很多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进