简介:本资源面向从事时间序列预测的机器学习学习者与工程人员,提供一套基于Python与TensorFlow实现的CNN-GRU混合深度学习算法,将卷积神经网络提取局部特征的能力与门控循环单元建模时序依赖的优势结合,可用于风电功率、电力负荷等场景的预测任务。压缩包共8个文件,约4.92MB,包含py主程序、xlsx与csv格式数据集、pdf与md使用说明、txt依赖清单等,结构清晰便于快速上手。代码中文注释详尽,支持单输入单步、单输入多步、多输入单步、多输入多步四种预测模式,并集成MSE、RMSE、R2、MAE、MAPE多个评估指标,便于从不同角度衡量模型精度。附带测试数据集可直接替换为自己的数据,快速验证算法在特定领域的应用效果。目前已有67人学习下载,适合希望掌握CNN-GRU时序预测实现细节并落地应用的读者参考。
1. 时序预测翻车三次后,我为什么押注 CNN-GRU 这套组合
销量、流量、设备振动、能耗曲线——只要数据带时间戳,老板就想让你预测下一段。我最早用 LSTM 硬扛,单变量还行,一上多变量、一加外部特征,验证集 loss 就开始玄学震荡。后来换成 CNN-GRU 混合结构,才把「局部突变」和「长程依赖」这两件事同时按住。这篇不讲空理论,讲的是用 Python + TensorFlow 把 CNN-GRU 时序预测从数据到推理完整跑通:窗口怎么切、卷积核怎么设、GRU 单元数怎么定、训练不收敛时先看哪里。适合已经会点 Python、被 LSTM 效果卡住、想找一个能复现方案的工程师。读完你能拿到一套可改参数、可换数据、可上生产的代码骨架。
2. CNN-GRU 到底谁在干活:先拆结构再谈调参
2.1 卷积层不是用来提图像的,它在提「时间片段模式」
很多人第一次看到 CNN 做时序会愣一下:卷积神经网络不是搞图像的么?其实一维卷积在时序上的作用和图像里一模一样——滑动窗口内做加权求和,提取局部模式。区别只是图像是二维滑,时序是一维滑。
假设输入序列长度 24,特征数 5,经过一层Conv1D(filters=32, kernel_size=3),它每次看连续 3 个时间步,学的是「这 3 步里特征怎么联动」。比如温度骤降伴随电流上升这种局部组合,卷积核能把它压成一个高激活值。这比 GRU 直接吃原始序列更省事,因为 GRU 不用再从零学局部模式,它只管在卷积抽出的高层特征上建长程关系。
这里有个容易忽略的点:卷积的汇聚层(池化)在时序里要慎用。MaxPooling1D(pool_size=2)会把序列长度砍半,确实降参数量,但也会把突变点的位置信息抹掉。如果你的任务对「第几个时间步发生异常」敏感,比如故障预警,池化层要么不用,要么用AveragePooling1D并配合padding='same'保住长度。我一般默认不加池化,靠strides=1和padding='same'保持时间分辨率。
2.2 GRU 比 LSTM 少一个门,为什么反而更适合叠在 CNN 后面
门控循环神经网络的核心是「记住该记的,忘掉该忘的」。LSTM 有三个门:输入门、遗忘门、输出门;GRU 只有两个:重置门和更新门。少一个门意味着参数少约 25%,训练更快,在小数据集上过拟合风险更低。
叠在 CNN 后面时,GRU 的输入已经不是原始序列,而是卷积抽出的特征图,序列长度可能还是 24,但每个时间步的向量维度从 5 变成了 32。这时候 GRU 要学的是「这些高层特征在时间上怎么演化」。因为 CNN 已经做了局部平滑,GRU 面对的噪声更少,用 GRU 而不是 LSTM 通常够用,而且反向传播路径更短,梯度消失问题在 24 到 72 步的窗口里基本可控。
选型上我的经验是:序列长度小于 50、特征数小于 20,GRU 优先;序列超过 200 步、且依赖跨度很大,再考虑 LSTM 或加注意力。CNN-GRU 这个组合的定位就是「中等长度、多变量、有局部突变」的时序,比如日销量、小时级流量、设备传感器。
2.3 用 TensorFlow 搭一个最小可跑结构
先确认环境。TensorFlow 2.x 在 Python 3.8 到 3.11 上最稳,安装命令就一行,但 GPU 版本要对应 CUDA,装错了会静默回退 CPU,训练慢十倍。
# 创建独立环境,避免和系统包打架 python -m venv tf_env source tf_env/bin/activate # Windows 用 tf_env\Scripts\activate # 装 CPU 版先跑通,确认代码没问题再换 GPU pip install tensorflow==2.15.0 numpy pandas scikit-learn matplotlib装完验证:
import tensorflow as tf print(tf.__version__) print(tf.config.list_physical_devices('GPU')) # 空列表说明在用 CPU接下来搭模型。输入形状是(窗口长度, 特征数),比如(24, 5)。
import tensorflow as tf from tensorflow.keras import layers, models def build_cnn_gru(window_size, n_features, gru_units=64): model = models.Sequential([ # 一维卷积:32 个卷积核,每次看 3 个时间步,same 保持长度不变 layers.Conv1D(filters=32, kernel_size=3, padding='same', activation='relu', input_shape=(window_size, n_features)), # 可选池化:如果任务对位置敏感,注释掉这行 layers.MaxPooling1D(pool_size=2), # GRU 层:return_sequences=False 表示只取最后一个时间步的输出 layers.GRU(gru_units, return_sequences=False), layers.Dropout(0.2), # 全连接输出:预测 1 个值 layers.Dense(1) ]) model.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss='mse', metrics=['mae']) return model model = build_cnn_gru(window_size=24, n_features=5) model.summary()逻辑说明:Conv1D的filters=32是输出通道数,kernel_size=3是每次看 3 步,padding='same'保证输出长度还是 24。MaxPooling1D(pool_size=2)把长度从 24 压到 12,参数量降了,但如果你预测的是「第几小时出问题」,这层会害你。GRU(64)的 64 是隐藏单元数,return_sequences=False只输出最后一步,适合做单点预测;如果要预测未来多步,改成True再接TimeDistributed(Dense(1))。
参数怎么改:窗口 24 适合小时级日周期,窗口 7 适合日级周周期。gru_units从 32 起调,数据量小于 1 万条别超过 128。Dropout(0.2)是防过拟合的后悔药,验证 loss 比训练 loss 高很多时加到 0.3 到 0.4。
3. 从 CSV 到模型输入:窗口切分和归一化的四个关键参数
3.1 滑动窗口怎么切,切错了模型永远学不会
时序预测的输入不是原始表格,而是「用过去 N 步预测下一步」的样本对。假设你有 1000 条按时间排序的记录,窗口 24,那能切出 976 个样本,每个样本 X 是 24×5,y 是第 25 步的目标值。
import numpy as np import pandas as pd def make_windows(data, target_col, window_size): """ data: 二维数组 (n_samples, n_features),已归一化 target_col: 目标列在 data 中的索引 window_size: 回看步数 """ X, y = [], [] for i in range(len(data) - window_size): X.append(data[i:i+window_size, :]) # 过去 window_size 步所有特征 y.append(data[i+window_size, target_col]) # 下一步的目标值 return np.array(X), np.array(y) # 假设 df 已按时间排序,target 是 'sales' feature_cols = ['sales', 'price', 'promo', 'temp', 'dayofweek'] values = df[feature_cols].values.astype('float32') X, y = make_windows(values, target_col=0, window_size=24) print(X.shape, y.shape) # (976, 24, 5) (976,)逻辑说明:循环从i=0到len(data)-window_size-1,每次取连续 24 行作为 X,取第 25 行的目标列作为 y。这里最容易翻车的是数据没按时间排序,CSV 里顺序乱了,切出来的窗口就是随机拼接,模型学到的全是噪声。切之前一定df = df.sort_values('timestamp').reset_index(drop=True)。
参数说明:window_size决定模型能看多远。日销量数据用 7 或 14,小时流量用 24 或 48。窗口太小,周期信息进不来;窗口太大,参数量涨、训练慢,而且早期时间步对预测的贡献被稀释。我一般先用 24 跑一版,看验证集 MAE,再试 12 和 48 对比。
3.2 归一化必须在切窗口之前做,且只能用训练集统计量
这是血泪经验:如果你先切窗口再对每个窗口单独归一化,每个样本的缩放比例不一样,模型会疯。正确顺序是——先按时间划分训练集和测试集,用训练集的均值和方差归一化全部数据,再切窗口。
from sklearn.preprocessing import StandardScaler # 按时间前 80% 做训练,后 20% 做测试,不能随机打乱 split = int(len(values) * 0.8) train_raw, test_raw = values[:split], values[split:] scaler = StandardScaler() train_scaled = scaler.fit_transform(train_raw) # 只在训练集上 fit test_scaled = scaler.transform(test_raw) # 测试集用同样的参数 transform # 再分别切窗口 X_train, y_train = make_windows(train_scaled, target_col=0, window_size=24) X_test, y_test = make_windows(test_scaled, target_col=0, window_size=24)逻辑说明:fit_transform只在训练集上调用,测试集只transform。如果测试集也fit,等于提前知道了未来数据的分布,评估结果虚高,上线就崩。这个坑在量化交易和销量预测里特别常见,很多人本地 MAE 很低,一上真实数据就废。
参数说明:StandardScaler做的是减均值除标准差,适合近似正态的特征。如果特征里有明显长尾(比如促销日的销量尖峰),改用RobustScaler,它用中位数和四分位距,对异常值更稳。目标列要不要归一化?要。否则 loss 尺度太大,学习率不好设。预测完记得用scaler.inverse_transform还原。
3.3 训练时的三个必调参数和早停策略
模型搭好、数据切好,model.fit里这几个参数决定你能不能在一小时内看到结果。
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau callbacks = [ # 验证 loss 连续 10 轮不降就停,并恢复最优权重 EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True), # 验证 loss 平台期时把学习率砍半 ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6) ] history = model.fit( X_train, y_train, validation_split=0.2, # 从训练集尾部再切 20% 做验证 epochs=100, batch_size=32, callbacks=callbacks, verbose=1 )逻辑说明:validation_split=0.2是从训练集尾部切,不是随机切,保证验证集也是时间上靠后的数据,更接近真实预测场景。EarlyStopping的patience=10表示连续 10 轮验证 loss 没改善就停,restore_best_weights=True把权重回滚到最优那轮,避免最后几轮过拟合。ReduceLROnPlateau是学习率衰减,loss 卡住时自动降学习率,比手动调省事。
参数说明:batch_size32 是默认起点,数据少于 5000 条用 16,大于 5 万条用 64 或 128。epochs=100配合早停,实际可能 30 轮就停了。学习率初始1e-3,如果 loss 一开始就 NaN,降到1e-4。训练完画 loss 曲线:
import matplotlib.pyplot as plt plt.plot(history.history['loss'], label='train') plt.plot(history.history['val_loss'], label='val') plt.legend(); plt.show()训练 loss 降、验证 loss 先降后升,是过拟合,加 Dropout 或减 GRU 单元。两条都降但很慢,是学习率太小或窗口没选对。两条都震荡,检查数据里有没有没处理的缺失值或异常值。
4. 避坑与排查:CNN-GRU 时序预测最常见的五个翻车现场
4.1 现象:训练 loss 正常下降,验证 loss 从第一轮就高得离谱
原因:数据泄漏。最常见的是归一化时用了全量数据fit,或者切窗口时训练集和测试集有重叠。另一个隐蔽原因是特征里包含了未来信息,比如用「当天总销量」去预测「当天某时段销量」。
解决:把归一化改成只在训练集fit。检查特征列,任何在预测时刻还拿不到的值都要删掉或滞后。切窗口时训练集和测试集之间留一个window_size的间隔,避免最后一个训练窗口和第一个测试窗口共享时间步。
4.2 现象:预测曲线比真实曲线平滑很多,尖峰全被抹掉
原因:池化层把突变点平均掉了,或者损失函数用 MSE 导致模型偏向预测均值。MSE 对大误差惩罚重,模型学乖了,干脆输出中间值,尖峰不预测就不会有大误差。
解决:去掉MaxPooling1D,或者改成AveragePooling1D并减小 pool_size。损失函数换成Huber或MAE,对异常值没那么敏感。如果尖峰是业务重点,还可以对尖峰样本加权,或者在输入里显式加入「是否促销」「是否节假日」这类标志特征。
4.3 现象:验证集 MAE 很低,但上线后预测值滞后真实值一整天
原因:窗口切分时用了shift但方向搞反,或者目标列对齐错了。常见错误是y取的是data[i+window_size]但特征里已经包含了这一步的信息。
解决:打印几个样本对,人工核对。X[0]的最后一行时间戳,应该正好是y[0]时间戳的前一步。如果数据是小时级,确认没有跨天错位。滞后一整天通常是时区或日期解析问题,pd.to_datetime之后检查df['timestamp'].diff().value_counts(),确保间隔均匀。
4.4 现象:GPU 显存够但训练速度极慢,nvidia-smi显示 GPU 利用率接近零
原因:TensorFlow 没找到 GPU,静默回退 CPU。或者数据管道在 CPU 上成了瓶颈,GPU 一直在等数据。
解决:先跑tf.config.list_physical_devices('GPU')确认。如果是空列表,检查 CUDA 和 cuDNN 版本是否匹配 TensorFlow 版本。数据管道方面,把model.fit的输入从 NumPy 数组换成tf.data.Dataset,加.batch(32).prefetch(tf.data.AUTOTUNE),让数据加载和 GPU 计算重叠。
4.5 现象:换一批新数据重新训练,同样的参数效果差很多
原因:不同数据集的量纲、周期长度、噪声水平不一样。窗口 24 对小时数据合适,对日数据就太短。GRU 单元数 64 对 1 万条数据合适,对 1000 条就过拟合。
解决:把窗口长度、GRU 单元数、学习率做成可配置参数,新数据集先跑一轮网格搜索。我一般固定窗口为「一个明显周期」的长度,比如日数据用 7,小时数据用 24,然后只调 GRU 单元数和 Dropout。数据量小于 2000 条时,GRU 单元数降到 16 或 32,Dropout 加到 0.3。
5. 多步预测与生产落地的两个进阶技巧
单点预测只能告诉你「下一步是多少」,但业务往往要未来 7 天或 24 小时。最直接的做法是改模型输出:GRU(return_sequences=True)接TimeDistributed(Dense(1)),一次输出多个时间步。但这样训练时目标变成了序列,loss 要对应改。
def build_multi_step(window_size, n_features, horizon, gru_units=64): model = models.Sequential([ layers.Conv1D(32, 3, padding='same', activation='relu', input_shape=(window_size, n_features)), layers.GRU(gru_units, return_sequences=True), layers.Dropout(0.2), # TimeDistributed 对每个时间步独立做 Dense layers.TimeDistributed(layers.Dense(1)), # 输出形状 (batch, window_size, 1),取最后 horizon 步 layers.Lambda(lambda x: x[:, -horizon:, :]) ]) model.compile(optimizer='adam', loss='mse', metrics=['mae']) return model逻辑说明:return_sequences=True让 GRU 输出每个时间步的隐藏状态,TimeDistributed(Dense(1))对每个时间步做一次全连接,输出形状是(batch, window_size, 1)。Lambda层取最后horizon步作为多步预测结果。目标y也要从单值变成形状(batch, horizon)的序列。
参数说明:horizon是预测步数,7 天预测就设 7。注意window_size要大于horizon,否则 Lambda 取不到那么多步。多步预测的误差会累积,评估时每一步单独算 MAE,不要只看平均。
另一个技巧是残差连接。在 CNN 输出和 GRU 输出之间加一条跳跃连接,把原始窗口的最后一个时间步直接拼到 GRU 输出上。这样模型至少能学到「预测值约等于当前值」这个基线,对平稳序列效果提升明显。
inputs = layers.Input(shape=(window_size, n_features)) x = layers.Conv1D(32, 3, padding='same', activation='relu')(inputs) x = layers.GRU(64)(x) # 取输入最后一个时间步,和 GRU 输出拼接 last_step = layers.Lambda(lambda t: t[:, -1, :])(inputs) x = layers.Concatenate()([x, last_step]) outputs = layers.Dense(1)(x) model = models.Model(inputs, outputs)这个结构在销量预测里我常用,因为很多品类的销量今天和昨天差不多,残差连接让模型不用从零学这个基线。验证时对比加与不加的 MAE,通常能降 5% 到 15%。
最后说个习惯:每次改完结构或参数,先在一个小样本上跑 5 轮,确认 loss 在降、形状没错,再上全量数据。我早期图省事直接全量跑,结果形状对不上白等半小时。现在固定先model.fit(X_train[:200], y_train[:200], epochs=5)过一遍,没问题再放开。希望帮到你。
本文还有配套的精品资源,点击获取