简介:基于 TensorFlow 框架实现的 CNN-BiLSTM-Attention 组合时序预测模型,面向需要进行时间序列建模的研究人员、数据挖掘学习者及工业应用开发者。模型融合卷积神经网络的特征提取能力、双向长短期记忆网络的时序依赖建模能力与注意力机制的关键信息聚焦能力,可支持单输入单步、单输入多步、多输入单步、多输入多步四种预测模式,适用于风电功率预测、电力负荷预测、股票分析、设备故障预测等场景。压缩包内共8个文件,包含完整Python源码、使用说明文档(PDF及Markdown)、训练与测试数据集(Excel及CSV)、依赖库清单等,包体约4.93MB,结构紧凑,下载后可直接替换数据运行。代码附带清晰中文注释,并内置MSE、RMSE、R2、MAE、MAPE五项评估指标,便于从多角度衡量预测效果。该资源已有93人学习,适合希望快速构建并验证组合深度学习时序预测模型的入门及进阶用户。
1. CNN-BiLSTM-Attention 时序预测:单用 LSTM 不够,组合模型才是落地常态
做时间序列预测的人,大概率都经历过这个场景:单用 LSTM 跑出来的结果,要么滞后严重,要么在拐点处直接钝掉。这不是你参数没调好,而是单一循环结构对长序列的局部特征提取能力天生偏弱。用 TensorFlow 把 CNN 和 BiLSTM 串起来,再在输出端加一层 Attention 做时序加权,效果往往比单模型高一截,尤其在风电功率、负荷、流量这类多变量长序列场景里。这个组合思路不玄乎,就是让 CNN 抓局部模式、BiLSTM 扫双向时序依赖、Attention 决定哪些时间步该重点看。这篇笔记把数据预处理、模型搭建、训练参数和踩过的坑一次说清,适合已经会跑 LSTM 但想进一步提点的从业者,也适合准备拿组合模型做毕设或竞赛的入门者。
2. 模型结构拆解:为什么是 CNN 在前、BiLSTM 居中、Attention 收尾
2.1 CNN 层:用卷积核做局部特征提取和降噪
时序预测里用 CNN,通常是一维卷积 Conv1D。卷积核在时间轴上滑动,每个位置和周围若干时间步做加权求和。这个操作本质上是在做局部模板匹配,对于风电功率的阵风尖峰、流量的突发脉冲这类短时模式,卷积核能直接把它们框出来,不需要像纯 LSTM 那样靠遗忘门慢慢学。
CNN 层在这里有两个实际作用。第一个是降噪,卷积操作的感受野有限,高频毛刺会被卷积核平滑掉一部分,相当于内置了一个可学习的低通滤波器。第二个是降维,MaxPooling 或 AveragePooling 把时间步压缩,后续 BiLSTM 需要处理的序列长度大幅缩短,训练速度和显存占用都会改善。
我一般会把 Conv1D 的卷积核尺寸 kernel_size 设在 3 到 7 之间。3 表示只看前后各一个点,适合高频波动数据;7 表示看前后三个点,适合小时级或天级数据。注意卷积不会改变时间步数量,真正把序列压短的是后面的池化层,所以不要指望靠 Conv1D 本身减序列长度。
from tensorflow.keras.layers import Conv1D, MaxPooling1D # 输入形状: (batch_size, window_size, n_features) cnn_out = Conv1D(filters=64, kernel_size=3, padding='same', activation='relu')(inputs) cnn_out = MaxPooling1D(pool_size=2)(cnn_out)这里 filters=64 表示学习 64 个不同的局部模式模板;padding='same' 保证卷积后时间步数量不变,池化后再减半。如果你用的是流速、功率这类波动数据,pool_size=2 通常够用,池化太大容易把短时特征也抹掉。
2.2 BiLSTM 层:双向扫描序列依赖关系
LSTM 的问题在于它只能按时间正序读,每个时间步只能看到过去,看不到未来。预测任务里当前时刻的状态往往同时受前后文影响,比如负荷数据在工作日早上呈现「前一天同时段 + 当天趋势」的叠加,如果只看正向扫描,模型很难把这种双向模式同时编码进去。
BiLSTM 拆成两个方向的 LSTM 并行跑,正向那个读原始序列,反向那个把序列倒过来读,两个方向的隐状态在每步拼接。关键点在于,BiLSTM 输出的是每个时间步的隐状态序列,而不是最后一个时间步的状态,所以 return_sequences 必须设为 True,否则 Attention 无法逐时间步加权。
from tensorflow.keras.layers import Bidirectional, LSTM # 输入形状: (batch_size, reduced_window_size, filters * 2) bilstm_out = Bidirectional( LSTM(units=64, return_sequences=True, dropout=0.2) )(cnn_out)units=64 表示每个方向保留 64 维隐状态,拼接后是 128 维。dropout=0.2 是循环层内部概率丢弃,只对输入步生效,不会破坏跨步的状态传递。在实际项目中,这个 dropouot 参数能显著缓解过拟合,尤其是训练样本只有几千条的时候,不要设成 0。
2.3 Attention 层:给关键时间步分配权重
Attention 解决的问题是长序列的「记忆稀释」。BiLSTM 输出的是整条隐状态序列,但预测目标往往只由其中少数几个时间步主导。传统做法是取最后一步或全局平均池化,这两个操作都会把关键步的贡献冲淡。
我在工程上更倾向于用自定义的加性注意力,而不是直接调 Keras 的 Attention 层,原因是 Keras 内置 Attention 要求 query 和 value 两组输入,在 Sequential API 里不好接,很多人在这个位置卡住报错。常见做法是先把 BiLSTM 的隐状态过一层 Dense 算出每个时间步的分数,再对所有分数做 softmax,得到权重后加权求和,输出一个上下文向量。
import tensorflow as tf from tensorflow.keras.layers import Dense # bilstm_out shape: (batch_size, reduced_window_size, 128) score = Dense(1, activation='tanh')(bilstm_out) # 每步一个分数 weights = tf.nn.softmax(score, axis=1) # 按时间步归一化 context = tf.reduce_sum(weights * bilstm_out, axis=1) # 加权求和Dense(1) 把每个时间步的 128 维隐状态压成一个标量分数;softmax 沿时间轴归一化,保证权重之和为 1;最后加权求和得到上下文向量。这个向量本质上是对整条序列做了「重点摘要」,比 GlobalAveragePooling 多了一个可学习的筛选过程。
2.4 三层结构的形状流转表
写代码最容易出错的不是模型设计,而是张量形状在每一层之间的衔接。我把典型参数下的形状变化列出来,方便你对照检查。
| 层 | 输出形状 | 说明 |
|---|---|---|
| 输入层 | (None, 24, 5) | window_size=24,特征数=5 |
| Conv1D(filters=64, kernel_size=3) | (None, 24, 64) | padding='same',时间步不变 |
| MaxPooling1D(pool_size=2) | (None, 12, 64) | 时间步减半 |
| Bidirectional(LSTM(64, return_sequences=True)) | (None, 12, 128) | 正向 64 + 反向 64 |
| Attention(自定义) | (None, 128) | 加权求和后只剩特征维 |
| Dense(1) | (None, 1) | 输出单步预测值 |
None 是 batch 维度,自动适配。很多报错都出在 MaxPooling 后时间步变成奇数导致后续计算异常这一点上,window_size 尽量选偶数,或者用 padding 保证池化时序列能被整除。
3. 数据预处理与样本构建:滑窗、归一化和时间划分
3.1 滑窗机制:把原始序列切成监督学习样本
时序预测不能直接把整条序列丢给模型,需要把数据切成输入窗口和目标窗口的配对。滑窗的核心参数是 window_size 和 pred_len,前者决定模型看多长的历史,后者决定预测未来几步。
滑窗过程其实就是在序列上平移一个固定长度的窗口,窗口每平移一步,生成一个样本。步长 stride 默认是 1,如果你觉得样本之间相关性太高,可以把 stride 设大一点,比如 2 或 3,但这样会减少训练样本量,在小数据集上反而容易过拟合,我一般只在数据量超过 5 万条时才考虑加大步长。
import numpy as np def create_sequences(data, window_size=24, pred_len=1): X, y = [], [] for i in range(len(data) - window_size - pred_len + 1): X.append(data[i:i + window_size]) y.append(data[i + window_size:i + window_size + pred_len]) return np.array(X), np.array(y) # data shape: (samples, n_features) # X shape: (samples, window_size, n_features) # y shape: (samples, pred_len)代码里循环的终止条件是 len(data) - window_size - pred_len + 1,这个边界不要改错,否则会数组越界或漏掉尾部样本。pred_len 大于 1 时 y 是一个多步序列,模型输出层的神经元数量需要和后端 Dense 层对应,不然 shape 对不上。
具体做法是,pred_len=1 时做的是单步预测,这类任务最简单,但现实业务里真正有用的是未来 3 到 24 步的预测。做多步预测有两个方向,一个是把 pred_len 加大直接一次输出多步,另一个是保持单步模型反复滚动预测。前者训练简单但误差会累加,后者更稳但要小心输入窗口的更新方式。
3.2 归一化:为什么必须用 MinMaxScaler
时序数据的不同特征量纲差异往往很大,风速是 0 到 20 的数值,功率是 0 到 2000 的数值,如果直接喂给模型,数值大的特征会主导梯度更新,CNN 卷积核和 LSTM 门控的权重都会被带偏。
MinMaxScaler 是时序预测里最常用的归一化方法,把所有特征压缩到 0 到 1 区间。相比 StandardScaler(标准化为零均值单位方差),MinMax 保留了原始数据的分布形状,反归一化时也更直观,预测值直接乘回去加回去就还原了。
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) # 只对训练集 fit,验证集和测试集用同一套参数 transform train_scaled = scaler.fit_transform(train_raw.reshape(-1, 1)) test_scaled = scaler.transform(test_raw.reshape(-1, 1))这里有一个必须强调的纪律:scaler 只能 fit 在训练集上。如果把全量数据拿去 fit,测试集的信息就提前泄漏到了训练阶段,验证时指标会虚高,部署到线上立刻现原形。fit_transform 和 transform 的区别就在这一步,前者先求最大最小值再变换,后者只做变换不重新计算。
3.3 时间序列划分:不能随机打乱
分类任务里随机打乱数据是标准操作,时序预测里绝对不行。打乱之后,训练集里会出现未来时刻的数据,模型在验证时能看到「过去」,测试结果会乐观到离谱。
标准做法是按时间顺序三分:前 70% 做训练,中间 15% 做验证,最后 15% 做测试。如果你的数据存在明显的周期性(比如日周期、周周期),划分时还要注意每个子集都要覆盖完整的周期,否则模型只见过周一的数据,到周五就不认识了。
n = len(scaled_data) train_end = int(n * 0.7) val_end = int(n * 0.85) train_data = scaled_data[:train_end] val_data = scaled_data[train_end - window_size:val_end] # 多切一段窗口做回看 test_data = scaled_data[val_end - window_size:]第二个细节很多人都忽略:划分验证集和测试集时,要从起点往前多切一个 window_size 的长度。原因是构建滑窗样本时,第一个样本需要前 window_size 个历史点,如果不补这一段,验证集和测试集的第一批样本就构造不出来,数据量白白浪费了。
4. 模型构建与训练:TensorFlow 函数式 API 完整实现
4.1 函数式 API 搭建三层组合模型
模型的搭建我推荐用函数式 API,而不是 Sequential。原因在于 Attention 部分是自定义结构,需要把 BiLSTM 的输出张量分叉成两路,一路算权重、一路做加权求和,这在 Sequential 里没法用 add 方式直接堆出来。
函数式 API 的写法是先把输入层定义成张量,然后一层层传下去,每层接收上一层返回的张量,最后用 Model 把输入输出绑定起来。这样写虽然比 Sequential 多几行,但结构清晰,每一层的输入输出都能随时打印 shape 检查。
import tensorflow as tf from tensorflow.keras.layers import ( Input, Conv1D, MaxPooling1D, Bidirectional, LSTM, Dense, Dropout, Flatten ) from tensorflow.keras.models import Model def build_cnn_bilstm_attention(window_size, n_features, lstm_units=64, pred_len=1): inputs = Input(shape=(window_size, n_features)) # 第一段: CNN 局部特征提取 x = Conv1D(filters=64, kernel_size=3, padding='same', activation='relu')(inputs) x = MaxPooling1D(pool_size=2)(x) x = Dropout(0.2)(x) # 第二段: BiLSTM 双向时序依赖 x = Bidirectional(LSTM(lstm_units, return_sequences=True, dropout=0.2))(x) # 第三段: 自定义 Attention 加权 score = Dense(1, activation='tanh')(x) weights = tf.nn.softmax(score, axis=1) x = tf.reduce_sum(weights * x, axis=1) # 输出层 outputs = Dense(pred_len)(x) model = Model(inputs, outputs) return model model = build_cnn_bilstm_attention(window_size=24, n_features=5, lstm_units=64, pred_len=1) model.summary()这段代码里需要注意几个参数:lstm_units=64 控制 BiLSTM 的容量,调大能提高拟合能力但也会明显变慢;pred_len=1 时输出层是一个神经元,pred_len=24 时输出层是 24 个神经元,对应未来 24 步的预测值。Dropout 在 CNN 和 BiLSTM 之间各加了一层,防止参数过多导致过拟合。
4.2 编译设置:优化器、学习率与损失函数
时序回归任务默认用 Adam 优化器,这一点基本没有争议。Adam 对学习率的自适应调整能力很强,起步阶段不容易发散,但对学习率初始值仍然敏感。
model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss='mse', metrics=['mae'] )learning_rate=1e-3 适合大多数场景,但有两个例外:如果训练时 loss 震荡严重,降到 1e-4 或 1e-5 会稳定很多;如果数据量很小(几百条),1e-4 起步更稳妥。损失函数用 mse 而不是 mae,原因是 mse 对大误差的惩罚更强,模型会更重视那些偏离较大的预测点,这对风电功率这类波动大的数据尤其重要。metrics 里的 mae 只是给人看的,方便在训练日志里直观评估。
4.3 早停与学习率衰减:防止训练后期震荡
时序模型的训练曲线往往不是单调下降的。前几十个 epoch 快速下降,之后进入平台期,偶尔还会反弹。如果不设早停,训练到某个临界点后模型开始在验证集上变差。
EarlyStopping 的核心逻辑是盯住验证集 loss,连续 patience 个 epoch 没有刷新最优值就停止训练。ReduceLROnPlateau 则是等 loss 平了之后把学习率降一半,让优化器在更小的步长下继续精调权重。这两个回调搭配使用,基本不需要手动盯训练过程。
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau early_stop = EarlyStopping( monitor='val_loss', patience=15, restore_best_weights=True ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6 ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=32, callbacks=[early_stop, reduce_lr], verbose=1 )patience 的设置在 10 到 20 之间合理,太小会在 loss 短暂平台期就停下,太大则训练时间失控,我一般配合 epochs=100 取 15。restore_best_weights=True 这个参数很重要,如果不设,停到的是最后一步的权重,而不是验证集最优那一步的权重,预测效果会差不少。batch_size=32 是常规值,数据量大时可以加到 64 或 128,注意显存占用会随 batch 大小线性增加。
4.4 预测与反归一化:还原到真实数值
训练完成后的预测流程和训练时有三个关键差异。第一,predict 阶段模型不需要 y 标签,只需要输入窗口。第二,预测结果是在归一化空间里的,必须用之前保存的 scaler 做 inverse_transform 才能得到真实量纲的数值。第三,如果做了多步滚动预测,每一步需要用前一步的预测值拼接出新的输入窗口,这一步最容易出错。
# 单步预测: 直接用最后一段窗口 last_window = X_test[-1] # shape: (24, 5) last_window = last_window.reshape(1, 24, 5) pred_scaled = model.predict(last_window) # 反归一化: 还原到真实数值 pred_real = scaler.inverse_transform(pred_scaled.reshape(-1, 1))这里要特别注意 reshape 的维度。模型训练时输入的 shape 是 (样本数, window_size, 特征数),预测时的输入也必须保持三维,即使只有一个样本也要变成 (1, 24, 5)。反归一化时,pred_scaled 是二维 (1, 1),需要 reshape 成 (1, 1) 才能传给 scaler,三维的 shape 直接传会报维度错误。
实际业务里更常用的多步滚动预测写法是先取最后的窗口,每次预测一个点,然后把预测值拼到窗口末尾、丢掉窗口第一个点,这样窗口始终保持 window_size 长度,循环执行 pred_len 次就能得到未来多步的预测序列。
5. 避坑指南:时序预测组合模型的五个高发雷区
5.1 归一化泄漏导致验证虚高
现象:训练集和验证集的表现都很好,RMSE 低得惊人,但模型部署到线上后预测值严重偏离真实范围。
原因:写代码时图省事,把全部数据拼在一起调用了 scaler.fit_transform,验证集和测试集的统计信息提前进入了全局的最大最小值。模型在训练时已经间接见过验证集的数据分布范围,测试自然准。
解决:严格分割数据后,只对训练集执行 fit,验证集和测试集统一用同一个 scaler 执行 transform。用 sklearn 管道 Pipeline 把数据预处理和模型封装在一起,可以强制避免这个问题,我后续做项目都强制走这一步。
5.2 随机切分训练集导致时间泄漏
现象:验证集 loss 很低,但训练曲线严重震荡,测试集上预测曲线整体右移,看起来像模型的预测比真实值滞后了几个时间步。
原因:使用了 train_test_split 默认的随机切分,未来数据混进了训练集。模型学到的是「答案就在附近」,而不是真正的时序规律,一旦遇到真实序列就只会复读上一步的值。
解决:按时间顺序手动切分,用索引切片而不是随机函数。切分时注意前面提到的 window_size 回看长度,否则每组数据集最前面的一段样本会因历史不足而丢失。
5.3 滑动窗口边界算错导致样本数缩水
现象:数据集有几万条,但 create_sequences 之后只剩下几百个样本,训练效果奇差。
原因:循环终止条件写成了 len(data) - window_size,忽略了 pred_len 的占位。每个样本不仅需要 window_size 个历史点,还需要 pred_len 个目标点,两项都要从总长度里扣除。
解决:终止条件要写成 len(data) - window_size - pred_len + 1。写成代码后先打印 X.shape 和 y.shape 验证,再正式进入训练,这一步可以省掉大量排错时间。
5.4 Keras Attention 层直接加进 Sequential 报错
现象:执行 model.add(Attention()) 时抛出异常,提示 Attention 需要两个输入张量或者 shape 不匹配。
原因:Keras 内置的多头注意力层设计给 encoder-decoder 架构使用,需要显式传入 query 和 value 两组张量,而 Sequential API 只能提供单一输入流,语义上不匹配。
解决:改用函数式 API 自定义加性注意力,用 Dense 计算分数,再用 softmax 归一化后加权求和。我在 2.3 节给的代码可以直接复用,也可以把这段封装成一个自定义层方便复用。
5.5 多步滚动预测时窗口更新错误
现象:多步预测曲线在前几步很准,后面越来越偏,最终漂移到完全不合理的数值范围。
原因:每一步预测完后,把预测值拼进窗口的逻辑写错了。常见错误是忘记删掉窗口最前面那个旧点,导致窗口长度越来越长,模型输入 shape 不匹配直接报错;或者删除位置不对,把新预测值顶掉了。
解决:用 collections.deque 管理窗口,maxlen=window_size,append 新预测值时,deque 自动弹出最旧的值,窗口长度恒定。代码可以这样写:
from collections import deque import numpy as np window = deque(X_test[-1], maxlen=window_size) # 初始化为最后一段历史 preds = [] for _ in range(pred_len): arr = np.array(window).reshape(1, window_size, n_features) p = model.predict(arr, verbose=0)[0, 0] preds.append(p) window.append(p) # maxlen 自动丢弃最旧数据这段代码里 deque 的 maxlen 是窗口长度,append 新预测值 p 时自动弹出最左侧的历史点,窗口始终是最后 window_size 个值。使用这个写法后,哪怕预测 24 步也不会出现 shape 错误。
6. 模型效果验证与参数调优:从训练曲线到残差分析
模型跑通只是第一步,真正决定项目能不能交付的是验证和调优的细致程度。训练结束后,第一时间画两条曲线,一条是训练集和验证集的 loss 变化,另一条是测试集上预测值与真实值的对比。loss 曲线如果出现验证集在某个 epoch 后持续高于训练集,说明模型开始过拟合,早停应该在那个点截住。观察对比图时,不要只看整体曲线贴不贴合,要重点看拐点和极值点附近的表现,这些位置往往暴露模型的问题。
调参方向我习惯按敏感度排序,优先动 window_size,其次动 lstm_units,最后才动 CNN 的 filters。window_size 决定模型看多长的历史,对于日周期数据至少取一个完整周期,比如 24 小时数据就取 window_size=24,如果数据存在 7 天周期则取 168。lstm_units 从 32 起步,每翻一倍看一次验证集 loss,涨到 128 后就不再往上提,因为收益会递减而训练时间成倍增加。CNN filters 通常保持 32 到 64 之间,太大容易把高频噪声也学进去,导致验证集震荡。
残差分析是很多人忽略的最后一步。预测值与真实值做差后,如果残差序列在零轴附近随机波动,模型已经学到了主要规律。如果残差呈现明显的周期性,比如每天都固定在某个时段偏高,说明模型漏掉了某个周期性特征,这时需要回到特征工程检查是否需要加入时间戳、星期几、节假日这些外生变量。
从那以后,我每次做完时序预测都会强制走一遍这套验证流程:归一化泄漏检查、时间顺序切分确认、滑窗样本量打印、残差曲线目检。这套组合模型本身不复杂,真正拉开差距的就是这些细节,希望帮到你。
本文还有配套的精品资源,点击获取