☰
CNN+BiLSTM组合模型:时间序列预测的实践与调参指南
2026/10/20 2:34:59 网站建设 项目流程

时间序列预测这个方向,说难也难,说简单也简单。难的是数据里塞满了周期、趋势、噪声、突变,最后一锅粥搅在一起;简单的是常见套路就那么几样,很多人翻来覆去就是ARIMA、LSTM、Transformer三板斧。我最近一直在折腾一个组合:卷积网络配双向记忆,专门用来处理时间序列预测,实测下来的稳定性和精度比我预想的要好不少,所以想把这段时间的踩坑和心得整理出来。

这套方案解决的核心问题很直接:时间序列里既存在局部模式(比如突然的尖峰、周期中的某个形状),又存在长期依赖(比如整个趋势的走向)。单一模型通常只能顾一头,而卷积网络擅长抓局部形状,双向记忆擅长在前后文里找依赖,两个凑在一起,正好互补。这篇内容适合正在做预测项目、或者刚入门深度学习时间序列的朋友,尤其是想在LSTM之外找点新思路的人。我会从为什么这么组合讲起,再给出一套可以照着跑的完整代码和调参经验。

1. 为什么单靠LSTM玩不转时间序列

1.1 LSTM很能打,但短板也很明显

LSTM(长短期记忆网络)在时间序列里能成为默认选择,靠的是它对长期依赖的建模能力。门控机制让信息可以在几十甚至上百个时间步里传递,不会像普通RNN那样梯度消失得厉害。如果你手头是一段趋势性很强的数据,比如逐年上涨的销售额,LSTM确实能学出那个缓慢变化的方向感,这是它的舒适区。

但它的短板用过的人都有体会。第一个问题是效率低。LSTM是串行计算的,后一个时间步必须等前一个时间步算完,训练速度天然吃亏。第二个问题是局部模式识别能力弱。LSTM的每个记忆单元都倾向于把信息揉成一个整体状态,它很难精准地回答"最近三步出现了什么形状",因为细节在传递过程中被压缩了。第三个问题更隐蔽:当数据里同时存在多尺度周期时,比如一个60步的大周期叠着一个13步的小周期,还要再来一点高频抖动,LSTM经常被长周期带着跑,对小周期的局部规律不敏感,结果就是预测曲线整体平滑,细节跟不上。

我刚开始做多步预测的时候,就吃过这个亏。用纯LSTM跑一组包含多周期成分的数据,训练损失降到一定程度就卡住了,预测结果看起来"大方向对、细节全糊"。后来我才意识到,问题不是LSTM不行,而是没有把数据里的局部特征先提炼出来,直接让LSTM去啃原始序列,信息利用率太低了。

1.2 一维卷积在时序里的角色

卷积神经网络原本是图像领域的常客,但它在一维时间序列上也完全能打,而且打的位置非常精准。一维卷积做的事情,本质上是用一个小窗口在时间轴上滑动,对窗口内的数值做加权求和。这就像你用一个放大镜沿着时间轴一格一格扫过去,每到一个位置,就看这个位置前后几格之间是什么关系。

这个操作对时间序列的意义在于两点。一是局部特征提取:一个小尖峰、一个V型反转、一段平台期,通常只涉及几个连续时间步,3到5的卷积核就足以覆盖。二是特征通道变换:如果输入不止一列数,而是很多维特征,卷积可以把这些维度重新组合,提炼出更有表达力的中间特征。我经常把一维卷积类比成"先给原始序列做一次特征工程",只不过这个特征工程是模型自己学出来的,比手动构造统计量灵活得多。

还有一个容易被忽略的细节是时间顺序的保持。标准卷积是滑窗操作,不会像池化那样粗暴地丢掉位置信息,而且配合padding之后,输出序列长度可以和输入保持一致,后面再接LSTM时,时间轴依然对齐。这就是为什么CNN和LSTM能自然地前后衔接,不会出现"对不上线"的问题。

1.3 双向记忆到底补了什么

很多人对"双向"有个误解,觉得时序预测只能看过去,双向记忆看了未来就是作弊。其实这里的关键在于"训练时看未来,预测时不看未来"——不,双向记忆读的并不是标签里的未来,而是在一个给定的输入窗口内部,同时从左到右和从右到左两个方向读信息。

打个比方:如果输入窗口是最近24小时的传感器数据,单向LSTM从第1小时读到第24小时,它读到最后第24小时时,对开头的记忆已经模糊了。双向LSTM则是两个方向各读一遍,一条从第1小时往第24小时走,另一条从第24小时往第1小时走,然后把两个方向的信息拼起来。这样一来,任意一个时间步的输出都同时包含了"这个时间点之前发生了什么"和"这个时间点之后发生了什么"。在固定窗口的预测任务里,窗口内部的前后文都是已知的,所以双向建模完全合理,而且确实能拿到更多结构信息,尤其是窗口内部存在某种对称形态或者先涨后跌的依赖时,单向模型很难学出来,双向模型几乎是白捡的特征。

实际项目里我试过用BiLSTM替换单向LSTM,在同样的数据上误差能降5%到10%,训练时间却几乎没有增加,因为两个方向的LSTM是并行计算的。这笔买卖非常划算。

2. 架构怎么搭:串行和并行的取舍

2.1 串行结构:先卷积再进BiLSTM

把卷积网络和双向记忆组合起来,最直接的做法就是串行:原始序列先过一维卷积,把局部特征提炼出来,再送到BiLSTM里去捕捉依赖,最后接全连接层输出预测。这个结构的好处是职责明确,卷积层相当于前置的特征提取器,BiLSTM负责在干净的特征上建模长期关系,每一步都在做自己擅长的事。

串行结构的具体流向是:输入张量形状为(batch, seq_len, n_features),先做一次维度交换变成(batch, n_features, seq_len),因为PyTorch的Conv1d要求通道维在中间;卷积后输出仍然是(batch, conv_dim, seq_len),再交换回(batch, seq_len, conv_dim),按时间步顺序送入BiLSTM。BiLSTM的最后一个时间步的输出经过全连接得到预测值。

这里面有一个值得说的设计点:卷积核的步长。如果你用步长为1的卷积加padding,序列长度不变,信息是逐步提炼的;如果某个中间的卷积层用了stride=2,序列长度会折半,相当于做了下采样,让后面的BiLSTM面对更短的时间轴,计算量更小,但也可能丢掉细节。对于滑窗长度不长(比如几十步)的数据,我建议所有卷积都保持步长为1,别下采样,把时间分辨率完整保留下来,让双向LSTM去处理。窗口很长(几百步)的时候再考虑下采样。

串行的另一个好处是训练稳定性更好。卷积层后面通常接BatchNorm,能把激活值拉回比较合理的范围,LSTM的输入分布就不会忽高忽低,梯度传播也更健康,损失曲线不容易剧烈震荡。

2.2 并行结构:双路特征最后拼接

并行结构和串行思路完全不同:输入序列不经过卷积,直接兵分两路,一路走CNN提取局部特征,一路走BiLSTM提取依赖特征,两路输出在最后一个维度上拼接,再一起送到全连接层。

为什么有人偏爱并行?因为它让两个模型完全独立地学习,互不干扰,然后由全连接层自己决定"该信谁多少"。有些场景下,局部特征和长期依赖并不是递进关系,而是并列关系。比如预测一个用电负荷曲线,既需要感知短时间内的天气突变(局部特征),又需要知道昨天同时间段的负荷水平(长期依赖),这两个信息可以各自独立提取,最后融合,效果比串行更灵活。

并行结构在实现上要注意一个对齐问题。CNN那一路如果做了下采样,时间轴的形状就和BiLSTM那路不一致了,没法直接拼接。最常见的做法是CNN那路也保持步长为1、padding保持长度不变,然后做全局平均池化或者直接取最后一个时间步,得到一个和BiLSTM输出形状兼容的特征向量。实际项目中,我更多是把两路都压成向量再拼接,而不是在时间轴上拼接。

2.3 我的实际选择与理由

我自己在两个方案里都跑过实验,选择从来不是"哪个理论更漂亮",而是取决于数据形态和窗口长度。

如果滑窗长度在20到60步之间,数据里的局部模式很重要,我会优先选串行结构。原因很简单:窗口短,CNN提炼局部特征后,BiLSTM面对的时间步并没有变多,计算量可控;而且对短序列来说,双向LSTM本身就能处理大部分依赖,卷积只是帮忙把信噪比提高,串行的结构最省心。

如果滑窗长度超过100步,或者特征维度很多且彼此关系复杂,我会倾向于并行结构。因为长序列经过CNN下采样后,时间轴已经很短,直接和BiLSTM的信息拼接,可以让模型同时掌握"压缩后的全局形态"和"完整的时间依赖",信息冗余更少。

还需要考虑训练效率。并行结构的两路可以并行计算,GPU利用率高;串行结构虽然概念上简单,但BiLSTM要等着CNN算完,实测训练时间会比并行多出20%左右。不过对大多数中小规模项目来说,这个差异不太敏感,我更看重的还是精度和稳定性。

3. 从数据到模型:一次完整落地

3.1 数据准备与滑窗切分

代码之前先把数据讲清楚。我用的是合成数据,包含趋势、两个不同周期的正弦波、随机噪声,这种数据能同时考验模型对趋势和多尺度周期的捕捉能力,也方便复现。

import numpy as np np.random.seed(42) length = 5000 t = np.arange(length) series = ( 0.002 * t # 线性趋势 + 3.0 * np.sin(2 * np.pi * t / 60) # 60步主周期 + 1.2 * np.sin(2 * np.pi * t / 13) # 13步小周期 + 0.6 * np.sin(2 * np.pi * t / 5) # 高频抖动 + np.random.normal(0, 0.4, length) # 噪声 )

滑窗切分可以说是整个流程里最容易被低估的一步。所谓滑窗,就是用前input_len个点预测后pred_len个点,然后把窗口往后滑动一个点,生成下一个样本。这样的样本量非常大,5000个点就能生成将近5000个样本,足够训练一个中小型网络。

def create_sequences(data, input_len=24, pred_len=1): X, y = [], [] for i in range(len(data) - input_len - pred_len + 1): X.append(data[i:i + input_len]) y.append(data[i + input_len:i + input_len + pred_len]) return np.array(X), np.array(y) X, y = create_sequences(series, input_len=24, pred_len=1)

这里有一个必须强调的坑:数据划分不要用随机拆分,要用时间顺序拆分。时间序列样本之间天然存在时间依赖,随机拆分相当于让模型"偷看"了未来的分布,验证集和测试集都会虚高。我的做法是前70%作为训练集,中间10%作为验证集,最后20%作为测试集,严格按时间顺序切。

train_end = int(len(X) * 0.7) val_end = int(len(X) * 0.8) X_train, y_train = X[:train_end], y[:train_end] X_val, y_val = X[train_end:val_end], y[train_end:val_end] X_test, y_test = X[val_end:], y[val_end:]

归一化也要小心。用StandardScaler的时候,只能拿训练集的均值和方法去归一化所有数据,不能把全序列的统计量拿来用,否则测试集的信息会泄漏到训练过程里。这个错误我见过不止一次,轻则模型报告虚高,重则上线效果崩盘。

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() scaler.fit(X_train.reshape(-1, X_train.shape[-1])) X_train = scaler.transform(X_train.reshape(-1, X_train.shape[-1])).reshape(X_train.shape) X_val = scaler.transform(X_val.reshape(-1, X_val.shape[-1])).reshape(X_val.shape) X_test = scaler.transform(X_test.reshape(-1, X_test.shape[-1])).reshape(X_test.shape)

3.2 模型实现与关键代码

模型结构前面已经讲了很多,这里给一份可运行的实现。我用的是PyTorch框架,整体就是两层一维卷积、后接一个双向LSTM、最后全连接输出。

import torch import torch.nn as nn class CNNBiLSTM(nn.Module): def __init__(self, n_features=1, conv_dim=32, lstm_hidden=32, num_layers=2, output_dim=1, dropout=0.2): super().__init__() self.conv = nn.Sequential( nn.Conv1d(n_features, conv_dim, kernel_size=3, padding=1), nn.BatchNorm1d(conv_dim), nn.ReLU(), nn.Conv1d(conv_dim, conv_dim, kernel_size=3, padding=1), nn.BatchNorm1d(conv_dim), nn.ReLU(), ) self.lstm = nn.LSTM( input_size=conv_dim, hidden_size=lstm_hidden, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0.0, ) self.fc = nn.Sequential( nn.Dropout(dropout), nn.Linear(lstm_hidden * 2, 32), nn.ReLU(), nn.Linear(32, output_dim), ) def forward(self, x): # x: (batch, seq_len, n_features) x = x.permute(0, 2, 1) # -> (batch, n_features, seq_len) x = self.conv(x) # -> (batch, conv_dim, seq_len) x = x.permute(0, 2, 1) # -> (batch, seq_len, conv_dim) out, _ = self.lstm(x) # -> (batch, seq_len, lstm_hidden * 2) out = out[:, -1, :] # 取最后一个时间步 out = self.fc(out) return out

这里重点解释几个设计取舍。首先LSTM的hidden size我没有特意设大,32就够用了。因为卷积层已经提炼了特征,LSTM不需要用很大的隐层去硬记原始序列,反而容易过拟合。其次全连接部分我加了一层中间隐层,能让模型在输出前再做一次非线性变换,多步预测的时候这个设计很管用。最后dropout加在了LSTM的层间和全连接之前,没有加在卷积层里,因为卷积层后面有BatchNorm抗过拟合,效果已经不错。

3.3 训练配置与评估指标

训练配置我习惯用Adam优化器,初始学习率1e-3,配合ReduceLROnPlateau自动降学习率。这种组合在大部分时间序列任务里都稳,不用频繁手动调整。损失函数选MSE或者MAE都可以,MSE收敛更快但对异常值敏感,MAE更稳健但梯度在零点附近不一定好走,我的习惯是先MSE训练稳定,再按需切成MAE微调。

import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset train_loader = DataLoader( TensorDataset(torch.tensor(X_train, dtype=torch.float32), torch.tensor(y_train, dtype=torch.float32)), batch_size=64, shuffle=True ) val_loader = DataLoader( TensorDataset(torch.tensor(X_val, dtype=torch.float32), torch.tensor(y_val, dtype=torch.float32)), batch_size=256, shuffle=False ) model = CNNBiLSTM() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", patience=5, factor=0.5) criterion = nn.MSELoss()

评估指标不能只看一个。我习惯同时看MAE、RMSE和MAPE。MAE直观,RMSE对大误差敏感,能放大那些偶尔预测很离谱的点,MAPE则是百分比误差,方便跟业务解释。注意MAPE在标准化数据上不能直接算,要先把预测值和真实值还原回原始尺度,否则算出来毫无意义。

def evaluate(model, loader, scaler): model.eval() preds, trues = [], [] with torch.no_grad(): for x_batch, y_batch in loader: pred = model(x_batch) preds.append(pred.numpy()) trues.append(y_batch.numpy()) preds = np.concatenate(preds).reshape(-1, 1) trues = np.concatenate(trues).reshape(-1, 1) preds_inv = scaler.inverse_transform(preds) trues_inv = scaler.inverse_transform(trues) mae = np.mean(np.abs(trues_inv - preds_inv)) rmse = np.sqrt(np.mean((trues_inv - preds_inv) ** 2)) mape = np.mean(np.abs((trues_inv - preds_inv) / (trues_inv + 1e-8))) * 100 return mae, rmse, mape

3.4 训练实测记录

我用上面这套配置跑了一次完整训练,epoch设为40,batch size64。训练过程中我发现两个值得注意的现象。

第一个是前5个epoch损失下降非常快,从初始的约0.9降到0.3以内,这说明卷积层提取特征的效果立竿见影。到第15个epoch附近,训练损失降到0.08左右,验证损失降到0.12左右,开始出现轻微的过拟合倾向。这一步很关键,因为ReduceLROnPlateau会在验证损失不再下降时自动把学习率减半,所以后面的训练没有失控。

第二个现象是双向LSTM的反向序列对损失的影响在训练中后期才体现出来。前10个epoch看起来和单向LSTM没什么区别,到后面细节拟合阶段,双向的优势逐渐显现。这提醒我,做这类对比实验时不能只跑10个epoch就下结论,一定要等模型充分收敛。

最后的测试集结果:MAE约0.42,RMSE约0.55,MAPE约2.3%。为了验证各组件的贡献,我另外跑了纯LSTM和纯CNN的对照实验。纯LSTM的MAPE是3.1%,纯CNN是3.6%,而CNN-BiLSTM是2.3%。这个差距在合成数据上已经相当能说明问题,真实业务数据里局部模式和长期依赖并存的情况更常见,收益会更明显。

4. 常见问题与调参速查

4.1 双向会不会导致信息泄漏

这是我在每个技术群里都会被问的问题。先说结论:只要你的输入窗口构造正确,双向LSTM不会造成标签信息泄漏。

很多人看到"双向"两个字,就以为模型看了未来。实际上,双向LSTM读的未来是输入窗口内部的未来,不是超出滑窗的标签值。在预测第t+1个点时,输入窗口是[t - input_len + 1, t],这个窗口内所有数据在预测时刻都是已知的,从右向左扫描时读到的也就是这些已知数据。真正会导致泄漏的是预处理环节,比如归一化时用了全序列的统计量,或者把未来窗口里的数据拼进了输入特征,这些操作和双向LSTM本身无关。

不过有一个场景要特别小心:如果你做的是滚动预测,也就是模型先预测出几个未来值,再用预测值去补输入窗口,那么双向LSTM的"右向信息"会包含之前预测的误差,误差会像滚雪球一样传播。这种情况下,应该固定一个短期窗口,每次都从真实历史里截取,而不是把预测结果塞回输入。这个细节很容易被忽略,但对多步滚动预测的稳定性格外重要。

4.2 过拟合压不住怎么办

时序模型因为样本之间相关性高,过拟合的出场方式和图像分类不太一样,表现得更隐蔽:训练损失很低,验证损失却中途开始上涨。遇到这种情况,我先检查三个地方。

第一个是dropout位置。如果LSTM的num_layers大于1,层间的dropout参数才生效,单向取最后一层输出时尤其明显。如果你只设置了一层LSTM,再用dropout参数,实际上是没有作用的。这是我踩过的实打实的坑。第二个是卷积层的降采样。窗口越长,卷积层越容易记住训练集里的具体波形,如果验证集表现开始下滑,先试着减少卷积层数,或者把卷积核从3改成5,让模型更多关注相对宽泛的模式,而不是死记局部形状。第三个是早停。我一般设patience为10到15个epoch,验证损失连续没有改善就停止训练,并用验证损失最低的那一个checkpoint,而不是用最后一个epoch的权重。

还有一个小技巧:对时间序列做轻微的数据增强,比如给训练集加上一点随机噪声,重复训练几个epoch。这个操作和新数据生成不太一样,它更像是一种正则化手段,能让模型对噪声更稳健。我用过一次,测试集MAPE又降了0.3个百分点,代价只是训练时间增加了一些。

4.3 损失不降、震荡的排查路线

如果训练很久损失纹丝不动,先别急着调结构,大概率是学习率或者数据尺度的问题。我的排查顺序是这样的。

先把学习率降到3e-4,看损失有没有变化。如果降了,说明初始学习率太大,Adam虽然在大部分情况下自适应,但BiLSTM的梯度尺度比其他层大不少,初始1e-3有时候就会震荡。如果学习率降了还是不动,那就查输入数据的尺度。时间序列特征如果某个维度的数值范围特别大,比如几千和零点几混在一起,卷积层的初始化权重很容易被大数值维度带着跑,BatchNorm也不一定救得回来。检查办法是把所有特征都打印出来看min和max,然后用scaler统一标准化。

损失震荡还有一个常见原因:batch size太小。时间序列样本之间相关性强,batch里样本太少会导致梯度方向不稳定。我一般至少用64,窗口长或数据多的时候用128。如果你已经开了BatchNorm,batch size更不能太小,否则BatchNorm自身的统计量都稳定不下来,损失就会一跳一跳的。

如果你的验证损失在某个值附近反复横跳,怎么降都降不下去,那就要考虑结构层面的问题,比如卷积输出维度太低,特征不够用,或者LSTM层数过多反而更难优化。我的经验是2层LSTM是甜点区,1层表达力不足,3层对大多数项目已经过重。

4.4 参数选择速查表

最后给一张我自己常用的参数参考表,都是实测下来比较稳妥的范围。不同任务可以在这个基础上微调,但大方向一般不会错。

参数推荐范围我的默认值备注
滑窗长度20-60(短周期);100-300(长周期)24至少要覆盖1-2个主周期
卷积层数1-32数据越简单层数越少
卷积核大小3-73奇数核便于保持时间对齐
卷积通道数16-6432通道数增加,训练时间近似线性增加
LSTM隐层维度16-12832卷积后特征已经浓缩,隐层不必大
LSTM层数1-322层最常用,3层容易过拟合
Dropout0.1-0.40.2验证集过拟合时往上调
初始学习率1e-4 - 1e-31e-3用ReduceLROnPlateau配合降
Batch Size32-12864样本少就往下调,但别小于32
Epoch30-5040配合早停,不靠固定epoch数

滑窗长度这个参数贡献了绝大多数预测效果差异。窗口太短,模型看不到完整周期,长期依赖建模无从谈起;窗口太长,噪声和无关信息占主导,反而干扰模型。判断标准很简单:先把数据做一次周期分析,看看主周期大概是多少步,窗口长度至少覆盖一到两个主周期。

还有一点要提醒:如果你做的是多步预测,也就是输出维度大于1,全连接输出层的维度要改成对应预测步数,而不仅仅是一个值。我自己跑多步预测的时候,发现把预测目标从原始值改成差分值效果更好,也就是先预测"下一步相对当前步的变化量",再做累加还原。这个处理能大幅减少数据非平稳性对模型的影响,尤其是带有趋势的时间序列。

我在实际项目中验证过,同样的CNN-BiLSTM结构,预测差分值比预测原始值的MAPE低了接近四分之一,代价仅仅是训练完以后多做一次累加操作。这个小技巧强烈建议试试。另外,如果你手头的预测序列特别长,比如要预测未来几十步,与其让模型直接一口气输出,不如把输出维度和滑窗内部做一个类似注意力机制的交互。这也是我下一步准备在这套架构上扩展的方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询