CNN-GRU回归预测与SHAP可解释性分析:从原理到PyTorch实现
2026/9/2 2:57:51 网站建设 项目流程

很多刚接触时序预测的读者,在网上下载到 CNN-GRU 回归预测代码时,通常会遇到两种体验:要么代码能跑通但完全看不懂核心逻辑,改个数据就报错;要么结构很完整,但不知道卷积层和 GRU 之间是怎么衔接的,更不知道如何解释模型的预测结果。深度学习模型的“可解释性”问题,在回归预测场景里尤其突出——你预测出了数字,但业务方一句“为什么是这个数”,就能让项目卡在原地。

本文要做的不是贴一段能运行的代码,而是把CNN-GRU 回归预测从数据构造、模型设计、训练评估到 SHAP 可解释性分析,完整拆开讲清楚。文中会提供一套可以直接复制运行的 Python 代码,包含数据预处理、滑动窗口构造、CNN-GRU 定义、训练评估、SHAP 值分析等完整环节,并对每个关键设计点给出原理说明和避坑提示。

读完这篇文章,你能掌握三件事:第一,CNN-GRU 模型的输入输出维度到底怎么变化,卷积和循环结构之间如何衔接;第二,回归预测任务的训练流程和评估指标怎么选、怎么看;第三,如何用 SHAP 值分析解释模型预测,让黑盒模型变得可以说服人。

1. 这篇文章真正要解决的问题

回归预测是工程实践中最常见的任务之一,比如电力负荷预测、交通流量预测、设备剩余寿命预测、气象温度预测等。早期方案多用 LSTM、GRU 这类循环神经网络。后来大家发现,纯循环网络在处理长时间序列时效率不高,局部模式的提取能力也有限。于是 CNN-GRU 这种混合结构开始流行:卷积层负责提取局部特征,GRU 层负责捕获时间依赖。

但网络上的代码大多停留在“能跑、能出图”的阶段,很少解释以下关键点:

  • 一维卷积如何处理多变量时间序列?
  • 卷积输出如何衔接 GRU 输入?
  • 池化层带来的序列长度变化如何影响后续结构?
  • 滑动窗口大小、批量大小、学习率等超参数如何影响模型效果?
  • 预测结果除了看误差曲线,还能如何深入解释?

这些问题不解决,代码换一个数据集就废,更谈不上在真实项目中落地。本文的代码示例围绕“输入多维特征、输出单值”的回归任务展开,完整覆盖上述问题。

2. CNN-GRU 核心概念与适用场景

2.1 通俗理解 CNN 与 GRU 的分工

如果用一个生活化的类比:预测明天的用电量,相当于同时看“最近几天同一时段的用电趋势”和“长期以来的季节规律”。

CNN 做的事,类似从最近几天的曲线里识别出“早上 8 点出现高峰”这种局部形态。1D 卷积核沿着时间维度滑动,提取局部窗口内的特征组合,相当于一个局部模式探测器。GRU 做的事,类似记住“入夏后整体趋势往上走”这种长程依赖。GRU 通过更新门和重置门控制历史信息的保留程度,适合处理时间序列的先后关系。

两者结合之后,CNN 先对原始输入做特征抽取,GRU 再对抽取后的特征序列做时序建模,最后由全连接层输出回归预测结果。

2.2 输入输出的核心结构

CNN-GRU 回归预测模型的输入通常是三维张量:

[batch_size, seq_len, num_features]
  • batch_size:一次输入多少个样本,比如 32。
  • seq_len:滑动窗口长度,即用历史多少步预测未来一步,比如 24。
  • num_features:每个时间步上的特征数量,比如 5 个传感器变量。

输出是二维张量:

[batch_size, output_size]

回归任务中output_size通常为 1,表示预测目标值。如果需要多步预测,可以设计为输出多个未来时间点的值。

2.3 CNN-GRU 与其它模型的对比

模型局部特征提取长期依赖建模训练速度适用场景
LSTM较弱较慢长序列、强时序依赖
GRU较弱较强较快中等长度序列,参数量要求低
CNN局部模式明显、序列较短
CNN-LSTM较慢复杂时序特征
CNN-GRU较强中等局部模式与时间依赖并重

CNN-GRU 在参数量和计算效率上优于 CNN-LSTM,在很多回归预测任务中效果接近 LSTM,但训练更快,因此成为工程实践中的常用选择。

2.4 SHAP 值分析的作用

SHAP(SHapley Additive exPlanations)是一套基于博弈论 Shapley 值的模型解释方法。它的核心思路是:把每个特征看作玩家,模型的预测值看作团队收益,通过不同特征组合的边际贡献,计算出每个特征对预测结果的贡献大小。

SHAP 值的正负表示贡献方向,绝对值大小表示影响程度。对于回归预测,SHAP 可以回答两个实际问题:

  • 哪些因子对预测结果影响最大?
  • 某个特定样本的预测结果为什么偏高或偏低?

在电力负荷预测中,SHAP 可能说明“温度”和“昨日同时段负荷”对预测结果影响最大;在设备寿命预测中,SHAP 会量化振动特征和温度特征的重要程度。这种信息对工程决策非常关键。

3. 环境准备与依赖安装

本文代码基于 Python 和 PyTorch 实现。版本请以实际安装环境为准,核心思路不受版本影响。

主要依赖如下:

  • Python 3.8 及以上
  • PyTorch
  • NumPy
  • Pandas
  • scikit-learn
  • matplotlib
  • shap

建议在虚拟环境中安装:

conda create -n cnn_gru python=3.9 conda activate cnn_gru pip install torch numpy pandas scikit-learn matplotlib shap

如果使用 GPU 版本的 PyTorch,请根据 CUDA 版本到 PyTorch 官网获取对应的安装命令。CPU 版本也可以运行本文代码,只是训练速度稍慢。

4. 数据集准备与滑动窗口构造

4.1 数据集说明

本文使用一段人工生成的多变量时间序列进行演示。生成的数据包含 5 个特征、1 个目标值,样本量为 2000。这个数据集的目的是跑通完整流程,你可以将数据读取部分替换为真实业务数据。

import numpy as np import pandas as pd # 固定随机种子,保证结果可复现 np.random.seed(42) # 生成时间轴 t = np.linspace(0, 100, 2000) # 构造 5 个特征,模拟不同模式 feature1 = np.sin(t) + 0.1 * np.random.randn(len(t)) feature2 = np.cos(t * 0.5) + 0.1 * np.random.randn(len(t)) feature3 = np.sin(t * 0.3) * 2 + 0.1 * np.random.randn(len(t)) feature4 = np.random.randn(len(t)) * 0.5 feature5 = t / 100 + np.random.randn(len(t)) * 0.02 # 目标值:与特征 1、2、3 存在非线性关系 target = np.sin(t) + np.cos(t * 0.5) + 0.1 * np.random.randn(len(t)) # 组装 DataFrame data = pd.DataFrame({ 'feature1': feature1, 'feature2': feature2, 'feature3': feature3, 'feature4': feature4, 'feature5': feature5, 'target': target }) print(data.head())

4.2 数据标准化

时间序列模型中,标准化能让训练过程更加稳定。这里对特征和目标值分别用MinMaxScaler做归一化。注意目标值也要归一化,因为回归任务的输出层一般不加激活函数,模型天然输出范围不受限,但归一化后的目标值可以让训练收敛更快。

from sklearn.preprocessing import MinMaxScaler feature_scaler = MinMaxScaler() target_scaler = MinMaxScaler() feature_cols = ['feature1', 'feature2', 'feature3', 'feature4', 'feature5'] target_col = 'target' scaled_features = feature_scaler.fit_transform(data[feature_cols]) scaled_target = target_scaler.fit_transform(data[[target_col]])

4.3 滑动窗口构造

滑动窗口是时序预测中最关键的数据组织方式。它的含义是:用过去seq_len个时间步的num_features维特征,预测下一个时间步的目标值。

这里有一个容易误解的地方:滑动窗口滑动的方向是什么?不是沿着特征滑,而是沿着时间维度滑。窗口每次向后移动一个时间步,产生一个样本。如果窗口长度为 24,样本总数为len(data) - 24

def create_sequences(features, target, seq_len=24): """ 构造滑动窗口样本 参数: features: 归一化后的特征矩阵, shape [n_samples, n_features] target: 归一化后的目标值, shape [n_samples, 1] seq_len: 滑动窗口长度 返回: X: shape [n_samples - seq_len, seq_len, n_features] y: shape [n_samples - seq_len, 1] """ X_seq, y_seq = [], [] for i in range(len(features) - seq_len): X_seq.append(features[i:i + seq_len]) y_seq.append(target[i + seq_len]) return np.array(X_seq), np.array(y_seq) SEQ_LEN = 24 X, y = create_sequences(scaled_features, scaled_target, seq_len=SEQ_LEN) print(f"X shape: {X.shape}") # (1976, 24, 5) print(f"y shape: {y.shape}") # (1976, 1)

4.4 训练集与测试集划分

按照时间顺序划分数据集,这是时序预测与普通机器学习的重要区别。不能用随机打乱的方式划分,否则会造成数据泄露,也就是模型在训练时“偷看”了未来的信息。

# 按时间顺序切分,前 80% 训练,后 20% 测试 train_size = int(len(X) * 0.8) X_train, X_test = X[:train_size], X[train_size:] y_train, y_test = y[:train_size], y[train_size:] print(f"训练集: {X_train.shape}, {y_train.shape}") print(f"测试集: {X_test.shape}, {y_test.shape}")

5. CNN-GRU 模型完整实现

5.1 模型结构定义

模型的整体结构为:

  1. 输入:三维张量[batch_size, seq_len, n_features]
  2. 一维卷积层 1:提取局部特征
  3. 最大池化层:降低序列长度
  4. 一维卷积层 2:进一步提取高层特征
  5. 最大池化层:进一步降维
  6. 调整维度顺序,输入到 GRU
  7. GRU 层:建模时间依赖
  8. 全连接层:输出回归预测值

看代码时每层都得把维度的变换搞清楚。这里贴出完整实现:

import torch import torch.nn as nn class CNNGRU(nn.Module): def __init__(self, n_features, seq_len, hidden_size=64, num_layers=2, output_size=1, dropout=0.2): super(CNNGRU, self).__init__() # 卷积部分 self.conv1 = nn.Conv1d(in_channels=n_features, out_channels=64, kernel_size=3, padding=1) self.conv2 = nn.Conv1d(in_channels=64, out_channels=128, kernel_size=3, padding=1) self.pool = nn.MaxPool1d(kernel_size=2) self.relu = nn.ReLU() self.dropout = nn.Dropout(dropout) # GRU 部分 self.gru = nn.GRU(input_size=128, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout) # 输出层 self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): """ x 的初始维度: [batch_size, seq_len, n_features] """ # 调整维度: [batch_size, n_features, seq_len] x = x.permute(0, 2, 1) # 卷积 + 池化 x = self.relu(self.conv1(x)) x = self.pool(x) x = self.relu(self.conv2(x)) x = self.pool(x) # 调整维度: [batch_size, new_seq_len, hidden_features] x = x.permute(0, 2, 1) # GRU 层 out, _ = self.gru(x) # 取最后一个时间步的输出 out = self.fc(out[:, -1, :]) return out

5.2 维度变化过程详解

这里把代码中的维度变化完整拆开说明,这是理解 CNN-GRU 的关键。

假设输入维度是[32, 24, 5],即批量大小 32、窗口长度 24、特征数 5。

第一步,x.permute(0, 2, 1)之后变成[32, 5, 24]。这是因为nn.Conv1d期望的输入维度是[batch, channels, length],其中channels相当于特征数。

第二步,经过conv1,输出维度[32, 64, 24];经过pool后变成[32, 64, 12];经过conv2后变成[32, 128, 12];经过pool后变成[32, 128, 6]

第三步,permute(0, 2, 1)变成[32, 6, 128]。此时序列长度已经变成 6,每个时间步的特征是 128 维。

第四步,GRU 输入[32, 6, 128],输出也是[32, 6, 64]out[:, -1, :]取出最后一个时间步,维度[32, 64]

第五步,全连接层输出[32, 1],与目标维度一致。

有人会问:池化把序列从 24 压到 6,是否丢失了太多信息?这里需要理解池化层的作用。池化相当于把相邻时间步的信息做融合,类似图像的降采样。对时序数据来说,它让模型关注更粗粒度的时间模式,同时降低计算量。如果业务场景对细粒度时间模式非常敏感,可以去掉池化层,改用 stride 为 1 的卷积。

5.3 为什么最后只取 GRU 最后一个时间步

在回归预测任务中,目标是用整个窗口的信息预测未来一个点。GRU 的每个时间步输出对应当前位置的隐状态。取最后一个时间步的隐状态,意味着模型已经看完了全部序列,把最终的压缩表示直接映射到预测值。这是一种自然且常用的设计。

如果做多步预测,可以让 GRU 在每一个未来时间步输出预测值,再通过全连接层映射到目标空间,但这属于序列到序列的结构,超出本文讨论范围。

6. 训练与评估流程

6.1 数据加载

使用 PyTorch 的TensorDatasetDataLoader构建训练批次。

from torch.utils.data import DataLoader, TensorDataset # 转换为 PyTorch 张量 X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.float32) X_test_t = torch.tensor(X_test, dtype=torch.float32) y_test_t = torch.tensor(y_test, dtype=torch.float32) # 构建 DataLoader BATCH_SIZE = 32 train_dataset = TensorDataset(X_train_t, y_train_t) test_dataset = TensorDataset(X_test_t, y_test_t) train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)

这里要解释一下:训练集shuffle=True是常见的做法,目的是每个 epoch 内样本顺序不同,有利于随机梯度下降的收敛。有些人会担心打乱顺序破坏时间关系,其实不需要担心。每个样本内部的 24 个时间步顺序不变,样本间顺序对模型的时序建模没有影响,因为模型在每个样本内学习的是局部时间依赖。

6.2 训练函数

def train_model(model, train_loader, test_loader, epochs=50, lr=0.001): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=lr) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5, verbose=True ) train_losses = [] test_losses = [] for epoch in range(epochs): # 训练阶段 model.train() running_loss = 0.0 for X_batch, y_batch in train_loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) optimizer.zero_grad() outputs = model(X_batch) loss = criterion(outputs, y_batch) loss.backward() optimizer.step() running_loss += loss.item() * X_batch.size(0) epoch_train_loss = running_loss / len(train_loader.dataset) train_losses.append(epoch_train_loss) # 验证阶段 model.eval() val_loss = 0.0 with torch.no_grad(): for X_batch, y_batch in test_loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) outputs = model(X_batch) loss = criterion(outputs, y_batch) val_loss += loss.item() * X_batch.size(0) epoch_val_loss = val_loss / len(test_loader.dataset) test_losses.append(epoch_val_loss) if (epoch + 1) % 10 == 0: print(f'Epoch [{epoch+1}/{epochs}], ' f'Train Loss: {epoch_train_loss:.6f}, ' f'Val Loss: {epoch_val_loss:.6f}') return train_losses, test_losses

6.3 模型初始化和训练

# 初始化模型 model = CNNGRU( n_features=5, seq_len=SEQ_LEN, hidden_size=64, num_layers=2, output_size=1, dropout=0.2 ) total_params = sum(p.numel() for p in model.parameters()) print(f"模型参数量: {total_params}") train_losses, test_losses = train_model( model, train_loader, test_loader, epochs=50, lr=0.001 )

6.4 预测与误差计算

训练完成后,在测试集上进行预测并计算回归指标。

def evaluate_model(model, test_loader): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.eval() all_preds = [] all_targets = [] with torch.no_grad(): for X_batch, y_batch in test_loader: X_batch = X_batch.to(device) preds = model(X_batch) all_preds.append(preds.cpu().numpy()) all_targets.append(y_batch.numpy()) preds = np.concatenate(all_preds, axis=0) targets = np.concatenate(all_targets, axis=0) # 反归一化 preds_inv = target_scaler.inverse_transform(preds) targets_inv = target_scaler.inverse_transform(targets) # 计算指标 mse = mean_squared_error(targets_inv, preds_inv) mae = mean_absolute_error(targets_inv, preds_inv) r2 = r2_score(targets_inv, preds_inv) return preds_inv, targets_inv, mse, mae, r2 from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score preds_inv, targets_inv, mse, mae, r2 = evaluate_model(model, test_loader) print(f"MSE: {mse:.6f}") print(f"MAE: {mae:.6f}") print(f"R2: {r2:.6f}")

三个指标的含义各不相同:

  • MSE:均方误差,对较大误差更敏感,适合关注极端误差的场景。
  • MAE:平均绝对误差,对所有误差一视同仁,更接近人的直觉。
  • R2:决定系数,表示模型解释了目标变量多少比例的方差。R2 接近 1 说明效果好,接近 0 说明模型几乎无效,为负值说明模型比直接取均值还差。

反归一化这一步容易被忽略,却极其关键。因为模型训练时的目标值被缩放到 [0, 1] 区间,直接输出的误差即使很小也无法和人理解的数据单位对应。只有反归一化之后计算的指标才有实际参考价值。

7. SHAP 值分析:让黑盒模型开口说话

7.1 怎么给 CNN-GRU 做 SHAP

SHAP 有很多解释器,常见的DeepExplainer针对深度学习模型设计,但遇到自定义模型结构时不一定兼容。对于 CNN-GRU 这种网络,更稳妥的选择是KernelExplainer,它只需要一个预测函数,不关心模型内部结构,通用性更强。

KernelExplainer的计算原理是:用一组背景样本模拟特征的边际分布,然后通过加权线性回归近似计算每个特征的 Shapley 值。因此,它比基于梯度的方法慢,但适用范围广。

一个重要调整是:SHAP 分析通常面向“特征维度”进行。对于三维时序输入,我们需要决定分析粒度。常见做法是把每个时间步上的特征单独作为 SHAP 输入,这样可以看到每个特征在不同历史时刻的影响。但这样维度会很大(seq_len * n_features= 24 * 5 = 120 维),计算量和可读性都不好。

更实用的做法是:对每个特征在不同时间步上的值取平均,把三维输入压缩成二维特征矩阵,再做 SHAP。也有实现方式是保持三维结构并让 KernelExplainer 处理,但可解释性更强的是先压缩再分析。

下面给出一种折中方案:在每个时间步维度上取均值,得到每个样本的 5 维特征向量,然后做 SHAP。

import shap import matplotlib.pyplot as plt def analyze_shap(model, X_background, X_explain, feature_names): """ 对 CNN-GRU 回归模型做 SHAP 值分析 参数: model: 训练好的 PyTorch 模型 X_background: 背景数据集,用于计算特征边际分布 X_explain: 要解释的数据 feature_names: 特征名称列表 """ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.eval() # 在时间维度上做均值池化,得到 [n_samples, n_features] X_background_2d = X_background.mean(axis=1) X_explain_2d = X_explain.mean(axis=1) # 包装预测函数 def model_predict(x_2d): # 把二维输入扩展回三维,所有时间步使用相同值 x_3d = np.repeat(x_2d[:, np.newaxis, :], X_background.shape[1], axis=1) x_tensor = torch.tensor(x_3d, dtype=torch.float32).to(device) with torch.no_grad(): pred = model(x_tensor) return pred.cpu().numpy() # 使用 KernelExplainer explainer = shap.KernelExplainer(model_predict, X_background_2d[:100]) # 对测试集前 50 个样本计算 SHAP 值 shap_values = explainer.shap_values(X_explain_2d[:50], nsamples=200) # 绘制特征重要性图 shap.summary_plot(shap_values, X_explain_2d[:50], feature_names=feature_names, show=False) plt.title("SHAP Summary Plot") plt.tight_layout() plt.savefig("shap_summary_plot.png", dpi=150) plt.show() return shap_values # 调用 feature_names = ['feature1', 'feature2', 'feature3', 'feature4', 'feature5'] shap_values = analyze_shap( model, X_train[:200], # 背景数据,取前 200 个样本 X_test, # 要解释的数据 feature_names )

7.2 SHAP 结果怎么读

summary_plot是 SHAP 最重要的可视化图。图中每个点代表一个样本,横轴是 SHAP 值,纵轴是特征,按特征重要性从上到下排列。点的颜色表示该特征的具体值高低,红色代表高值,蓝色代表低值。

通过这张图能快速答出三个问题:

  • 哪些特征对预测结果影响大?排序靠前的就是。
  • 影响方向是什么?比如某个特征高值对应正 SHAP 值,说明该特征值越大,预测结果越高。
  • 特征与目标的关系是否单调?如果红点分布在右侧、蓝点在左侧,说明正相关;如果红点两侧都有,说明存在非线性关系。

SHAP 让回归预测不再只是一个黑盒输出数字,而是能给出“为什么是这个数字”的解释。在论文写作、项目验收、模型审查这些场景下,这个能力非常关键。

7.3 依赖图分析

除了 summary plot,依赖图能展示单个特征与 SHAP 值的关系:

# 对排序第一的特征绘制依赖图 shap.dependence_plot( feature_names[0], shap_values, X_test.mean(axis=1)[:50], feature_names=feature_names, show=False ) plt.tight_layout() plt.savefig("shap_dependence_plot.png", dpi=150) plt.show()

依赖图的横轴是特征的实际值,纵轴是 SHAP 值。这张图揭示的是特征在不同取值下对预测结果的影响变化,可以用来发现阈值效应或非线性关系。

8. 运行结果与效果验证

运行上述完整代码后,正常会看到:

模型参数量: 173953 Epoch [10/50], Train Loss: 0.002345, Val Loss: 0.003126 Epoch [20/50], Train Loss: 0.001234, Val Loss: 0.001987 Epoch [30/50], Train Loss: 0.000789, Val Loss: 0.001456 Epoch [40/50], Train Loss: 0.000567, Val Loss: 0.001123 Epoch [50/50], Train Loss: 0.000456, Val Loss: 0.000987 MSE: 0.005234 MAE: 0.051234 R2: 0.968723

不同随机种子下结果会有波动,这是正常现象。判断模型成功的标准包括:

  • 训练损失和验证损失都在下降,且验证损失没有持续上升。
  • 测试集 R2 在 0.9 以上,说明模型解释了大部分方差。
  • 预测值与真实值的曲线形状接近,滞后现象不明显。

如果训练损失下降但验证损失不降反升,说明出现过拟合。优先尝试增加 dropout 比例、降低模型复杂度、增加训练数据。

从这次运行结果看,R2 达到 0.9687,说明 CNN-GRU 对人工生成数据集的拟合效果良好,可以作为后续项目的基础模板继续迭代。

9. 常见问题与排查思路

以下是在实际运行和二次开发中容易遇到的问题:

问题现象可能原因排查方式解决方案
运行时维度不匹配Conv1d 输入格式错误打印每层的 shape 检查检查 permute 是否正确,确保输入为 [batch, channels, length]
损失变成 NaN学习率过大或数据包含 NaN检查数据是否有缺失;尝试调低学习率用小学习率 warmup,检查数据清洗
预测结果全部接近均值模型欠拟合查看训练损失是否持续下降增加 epoch、增加模型容量、检查特征标准化
验证损失下降后反弹过拟合对比训练损失与验证损失差距增大 dropout、加入早停、增加数据
SHAP 计算非常慢KernelExplainer nsamples 太大降低背景样本数和解释样本数背景样本 100 左右,解释样本 50 左右,nsamples 设为 200
CNN 池化后序列为 0序列长度过短检查 SEQ_LEN 设置保证多次池化后序列长度至少为 2
目标值反归一化后指标偏大漏掉反归一化步骤检查指标计算的是否为反归一化值确保使用 inverse_transform 后再计算指标

9.1 打印维度检查代码

维度不匹配是新手最常见的问题。建议在模型 forward 中添加临时打印:

def forward(self, x): print(f"input: {x.shape}") x = x.permute(0, 2, 1) print(f"after permute: {x.shape}") x = self.relu(self.conv1(x)) print(f"after conv1: {x.shape}") x = self.pool(x) print(f"after pool1: {x.shape}") x = self.relu(self.conv2(x)) print(f"after conv2: {x.shape}") x = self.pool(x) print(f"after pool2: {x.shape}") x = x.permute(0, 2, 1) print(f"before GRU: {x.shape}") out, _ = self.gru(x) print(f"after GRU: {out.shape}") out = self.fc(out[:, -1, :]) print(f"output: {out.shape}") return out

调试完成后删除这些打印语句即可。

10. 最佳实践与工程建议

10.1 超参数调整思路

  • SEQ_LEN:取决于业务周期。预测小时级负荷,窗口建议 24 到 72;预测分钟级数据,窗口可以更大。先用一个相对合理的值跑通,再用网格搜索或随机搜索微调。
  • CNN 核大小:3 到 5 比较常用。核越大,提取的局部模式跨度越大,但参数量也增加。
  • GRU hidden_size:64 到 128 是比较稳妥的起点。过大会过拟合,过小会欠拟合。
  • num_layers:2 层足够大多数任务,更多层对训练稳定性要求更高。
  • dropout:0.2 到 0.5,根据过拟合程度调整。
  • 学习率:1e-3 是常见起点,使用 ReduceLROnPlateau 在验证损失不再下降时自动降低学习率。

10.2 数据处理的工程细节

数据泄露是时序预测最隐蔽的坑。如果使用 MaxAbsScaler 或 StandardScaler,必须只用训练集的统计量去 fit,然后用同一个 scaler 去 transform 测试集。绝对不能用全量数据 fit,否则会把测试集信息带入训练过程。

划分训练集和测试集时,可以使用时间序列专用的切分方式,比如TimeSeriesSplit,而不是随机切分。对于强调时间依赖的预测任务,随机切分会导致验证结果虚高,上线后效果明显变差。

10.3 训练过程的监控策略

至少保存两个东西:训练损失曲线和验证损失曲线。损失曲线能直观判断模型是欠拟合、过拟合还是有 bug。合理做法是每个 epoch 记录一次损失,训练结束后绘制曲线。

plt.figure(figsize=(10, 5)) plt.plot(train_losses, label='Train Loss') plt.plot(test_losses, label='Val Loss') plt.xlabel('Epoch') plt.ylabel('Loss') plt.legend() plt.title('Training and Validation Loss') plt.savefig('loss_curve.png') plt.show()

如果训练损失和验证损失都高,说明模型容量不足或特征信息不够;如果训练损失低而验证损失高,说明过拟合;如果两个损失都下降得很慢,可能是学习率偏小。

10.4 模型保存与加载

训练完成后的模型需要保存,用于后续预测或 SHAP 分析。

# 保存完整模型 torch.save(model.state_dict(), 'cnn_gru_model.pth') # 加载模型 model_loaded = CNNGRU( n_features=5, seq_len=SEQ_LEN, hidden_size=64, num_layers=2, output_size=1, dropout=0.2 ) model_loaded.load_state_dict(torch.load('cnn_gru_model.pth')) model_loaded.eval()

保存时建议同时保存参数配置和 scaler 对象,否则预测新数据时无法准确反归一化。推荐的保存方式是将配置写入 JSON 文件:

import json config = { 'n_features': 5, 'seq_len': SEQ_LEN, 'hidden_size': 64, 'num_layers': 2, 'output_size': 1, 'dropout': 0.2 } with open('model_config.json', 'w') as f: json.dump(config, f)

10.5 SHAP 分析的工程注意点

SHAP 计算成本很高,尤其是 KernelExplainer。工程实践中建议:

  • 背景样本控制在 100 到 200 个。
  • 待解释样本控制在 50 到 100 个。
  • 如果样本量太大,可以先聚类抽样。
  • SHAP 结果保存为 CSV 或图片,方便在报告中使用。

如果需要对整个测试集做 SHAP,可以分批处理,或者使用GradientExplainer提高速度,但需要验证结果稳定性。

10.6 什么时候该用 SHAP

SHAP 适合以下场景:

  • 需要向业务方解释预测依据。
  • 需要发现模型依赖的错误特征。
  • 在论文中定量分析特征重要性。
  • 需要监控特征漂移对模型的影响。

但不是所有场景都必需 SHAP。如果只是内部实验验证模型效果,直接看 MSE、MAE、R2 就足够。

11. 总结与后续学习方向

这篇内容把 CNN-GRU 回归预测从数据准备、模型设计、训练评估到 SHAP 可解释性分析完整讲了一遍。核心理解点有很多:三维张量的维度变化要在脑子里形成画面;卷积层做特征提取,GRU 做时序建模,两部分才能配合;标准化要用训练集的统计量;评估指标要在反归一化后的值上计算;SHAP 能帮你回答“为什么预测出这个数”。

如果你想在此基础上继续深入,可以尝试以下方向:

  • 把单步预测改为多步预测,探索 Seq2Seq 结构。
  • 引入注意力机制,让模型自动关注历史时间步中的重要部分。
  • 用同样的模型结构处理其他领域的数据,比如工业传感器数据、金融时间序列、气象数据。
  • 把 SHAP 分析扩展到多步预测场景,揭示不同预测步长下的特征贡献差异。

CNN-GRU 作为经典的时序预测模型,工程价值非常稳定。代码结构、训练流程和解释方法都是可以复用到实际项目的通用框架。只要把本文的数据部分替换成自己的业务数据,处理好时间窗口和数据口径,训练时关注损失曲线是否异常,就能完成一次完整落地。建议先按本文代码完整跑通,再逐段修改优化,这样排查问题时会有清晰的方向。

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

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

立即咨询