做回归预测时,CNN-GRU 是经常被选用的组合结构,而 SHAP 值分析则是让这个结果具备解释性的有效补充。这篇博文就围绕 CNN-GRU 回归预测的代码实现,从数据准备、模型搭建到 SHAP 分析,把每个环节的关键细节拆开讲。
我默认你已经有 Python 基础,并且用 PyTorch 做训练。如果你之前只跑过全连接网络或者单用 LSTM,这篇文章会告诉你卷积层和 GRU 怎么衔接、训练时哪些参数最容易翻车、SHAP 解释到底怎么落地,而不是只给一段看起来很复杂的代码。
先说结论:这类模型能跑通不难,难的是数据构造对不上、维度顺序搞错、训练波动大、SHAP 分析时输入格式不匹配。下面按实际落地顺序逐步拆解。
1. 先搞清楚 CNN-GRU 和 SHAP 到底解决什么问题
1.1 CNN-GRU 回归预测的核心场景
CNN-GRU 是把卷积神经网络和门控循环单元组合起来处理序列数据。卷积层的作用是提取局部特征,GRU 的作用是捕捉序列上的时间依赖。两者结合后,常用于多变量时间序列回归预测,比如根据过去多个时刻的传感器读数预测下一时刻的数值,或者根据一组连续特征预测某个目标值。
回归预测的关键是输出是一个连续值,不是分类标签。所以模型最后一层通常不需要 softmax,而是接一个全连接层,直接输出一个单值。损失函数一般用均方误差 MSE 或平均绝对误差 MAE。
这个结构适合什么场景?简单说,当你的数据在时间窗口内存在局部模式,同时整体走势又依赖较长历史时,CNN-GRU 会比纯 CNN 或纯 GRU 更有优势。例如工业设备状态预测、负荷预测、流量估计、环境监测这类任务。
1.2 SHAP 值分析在回归预测里的实际价值
SHAP 是一种基于博弈论的特征归因方法。它计算每个特征对预测结果贡献了多少,数值为正表示该特征把预测值往上推,数值为负表示往下压,绝对值大小表示影响程度的大小。
在回归预测中,SHAP 能回答三类问题:
- 哪些特征对预测结果影响最大。
- 某个具体样本中,是哪些输入时刻或特征导致预测值偏高或偏低。
- 特征与预测结果之间是正相关还是负相关,是否存在非线性关系。
这对于模型调优和数据理解都有用。比如你发现某个传感器特征在 SHAP 重要性排序中常年排第一,那后续做特征筛选、模型简化、异常检测都可以优先关注这个特征。
1.3 不要把 SHAP 当成万能解释工具
SHAP 看起来很直观,但使用时有边界。它给出的是模型层面的归因结果,不是因果推断。也就是说,SHAP 告诉你模型更依赖哪些特征,不告诉你现实世界中改变某个特征是否真的会导致结果变化。
另外,SHAP 计算的是“在该模型的决策逻辑下”的贡献,不是“真实数据规律”的贡献。如果模型本身泛化能力差,SHAP 分析再漂亮也没有业务价值。所以顺序一定是先保证模型训练正常、评估指标稳定,再去做可解释性分析。
2. 环境准备和数据组织
2.1 依赖安装和版本选择
复现这类代码,最基础的环境包括 Python、NumPy、Pandas、Matplotlib、PyTorch、SHAP。如果安装不顺利,优先处理 PyTorch 和 SHAP 的兼容问题。
pip install numpy pandas matplotlib torch shap如果机器有 GPU,先确认 PyTorch 的 CUDA 版本是否和显卡驱动匹配。如果只是学习,CPU 环境也能跑通,只是训练会慢一些。SHAP 分析时,数据量大的话也会比较耗时,建议先用小批数据测试。
这里不建议盲目安装最新版 PyTorch 或 SHAP。不同版本的接口变化会影响代码写法,尤其是 SHAP 的某些 explainer 接口在不同版本里差异较大。锁定项目依赖版本并记录到 requirements.txt 是更规范的做法。
2.2 数据结构设计:滑动窗口和标签对齐
CNN-GRU 输入通常是三维张量,形状为(样本数, 时间步长, 特征数)。这意味着训练数据不能直接使用一维数组,必须先把原始序列转换成滑动窗口样本。
假设你有一组按时间排序的序列数据,长度为 N,特征数为 F,窗口长度为 L,那么可以生成 N-L 个样本。每个样本包含过去 L 个时刻的所有特征,标签是第 L+1 时刻的目标值。
下面是一个通用示例:
import numpy as np def create_sequences(data, seq_len, target_col=-1): X, y = [], [] for i in range(len(data) - seq_len): X.append(data[i:i + seq_len]) y.append(data[i + seq_len, target_col]) return np.array(X), np.array(y)这里的target_col是目标值所在的列索引。如果目标本身就是序列中的一个特征,这个写法最方便。如果目标是另一个序列,需要单独对齐时间戳。
2.3 输入输出格式对模型效果的影响
三维张量里的特征排列顺序需要和模型里的维度处理保持统一。PyTorch 中 GRU 默认输入是(batch, seq_len, features),但 Conv1d 默认输入是(batch, channels, seq_len),所以在进入卷积层之前通常要做一次 permute。
这种维度转换是新手最容易忽略的地方。很多人报错说张量维度不匹配,不是模型写错,而是少了 permute 或 squeeze。
建议在数据生成后先打印一次形状:
print("X shape:", X.shape) print("y shape:", y.shape)确认X.shape[1]是时间步长,X.shape[2]是特征数,再去写模型。
2.4 数据标准化和数据集划分
回归预测里,特征尺度差异大是常见问题。某些特征数值在几百,某些特征数值在 0.001,如果不做标准化,梯度更新会非常不稳定。常见做法是使用 StandardScaler 或 MinMaxScaler。
标准化时要注意:只能用训练集统计量去转换训练集、验证集和测试集,不能直接对整份数据做 fit,否则会造成数据泄露。也就是说,先拆分,再缩放。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X.reshape(-1, X.shape[-1])).reshape(X.shape)验证集和测试集的缩放要用同一个 scaler,不能在验证集上重新 fit。否则会高估模型在真实未知数据上的表现。
3. CNN-GRU 回归模型的网络结构设计
3.1 基础结构:卷积层、GRU 层、全连接层
一个比较常见且稳定的 CNN-GRU 回归结构是这样的:
- 输入经过一维卷积层,把特征维度映射成通道维度。
- 激活函数,比如 ReLU。
- 可选的池化层,用于降低序列长度。
- 调整维度顺序后输入 GRU。
- 取 GRU 最后一个时间步的输出。
- 经过全连接层输出单值。
卷积核大小决定局部窗口有多大,GRU 隐藏层大小决定记忆容量,全连接层负责把 GRU 输出映射成回归值。
3.2 PyTorch 示例代码和参数说明
下面是一个可运行的示例结构,重点是让大家理解维度流转。
import torch import torch.nn as nn class CNNGRURegressor(nn.Module): def __init__(self, n_features, hidden_size=64, num_layers=2, kernel_size=3, dropout=0.2): super().__init__() self.conv1 = nn.Conv1d( in_channels=n_features, out_channels=32, kernel_size=kernel_size, padding=kernel_size // 2 ) self.relu = nn.ReLU() self.pool = nn.MaxPool1d(kernel_size=2) self.gru = nn.GRU( input_size=32, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0 ) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): # x: (batch, seq_len, n_features) x = x.permute(0, 2, 1) # x: (batch, n_features, seq_len) x = self.conv1(x) x = self.relu(x) x = self.pool(x) # x: (batch, channels, new_seq_len) x = x.permute(0, 2, 1) # x: (batch, new_seq_len, channels) out, _ = self.gru(x) # 取最后一个时间步输出 out = out[:, -1, :] out = self.fc(out) return out.squeeze(-1)几个参数的含义:
hidden_size:GRU 隐藏状态维度,越大记忆容量越大,但参数量也越多。num_layers:GRU 层数。层数多可以增强模型表达能力,但更容易过拟合。kernel_size:卷积核大小。3 表示每次看相邻 3 个时刻的局部模式。dropout:Dropout 比例,用于缓解过拟合。注意单层 GRU 时 PyTorch 的 dropout 参数不生效。
3.3 训练配置和常见超参数选择
回归预测通常选择 MSE 作为损失函数。优化器常用 Adam,初始学习率可以从 1e-3 开始尝试。训练轮数需要根据数据量判断,一般先跑 100 轮左右看曲线走向。
model = CNNGRURegressor( n_features=X.shape[2], hidden_size=64, num_layers=2, kernel_size=3, dropout=0.2 ) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)batch_size 的选择会影响训练稳定性和内存占用。小数据集可以用 32 或 64,大数据集可以 128 或 256。不要一上来就把 batch_size 开到最大,先确认普通配置能收敛,再根据资源情况调整。
3.4 训练过程监控和模型保存
训练时不仅看 loss,还要看验证集指标。每轮训练后计算验证集 MSE、MAE、R2,并把结果记录成列表。训练结束后保留验证集指标最好的一轮模型,而不是最后一轮模型。
best_val_loss = float('inf') for epoch in range(epochs): model.train() # 训练循环省略 model.eval() val_loss = compute_validation_loss(model, val_loader) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pt')保存模型后再写一个读取逻辑,便于后续做 SHAP 分析时载入训练好的权重。
4. 模型评估与基础调优
4.1 回归指标选择:MAE、MSE、RMSE、R2
分类任务看准确率,回归任务通常看这几个指标:
| 指标 | 说明 | 使用场景 |
|---|---|---|
| MAE | 平均绝对误差,误差单位与原始数据一致 | 更关注误差大小可解释性 |
| MSE | 均方误差,放大较大误差 | 优化时常用,但对异常值敏感 |
| RMSE | MSE 开根号,误差单位还原 | 和原始数据量纲一致 |
| R2 | 决定系数,越接近 1 越强 | 判断模型整体拟合优度 |
实际使用时,我一般同时看 MAE 和 R2。MAE 告诉平均偏离多少,R2 告诉模型相对均值回归提升了多少。R2 很低时,先检查数据是否有明显趋势,或者输入特征是否足够。
4.2 从训练曲线判断模型状态
训练曲线是排查问题最直接的工具。画两条曲线:训练 loss 和验证 loss。
如果训练 loss 不断下降,验证 loss 上升,说明过拟合。此时可以增加 dropout、减小模型容量、增加数据量,或者早停。
如果训练 loss 和验证 loss 都降不下去,可能是学习率不合适、数据标准化有问题、特征信息不足,或者模型结构过于简单。
如果 loss 出现剧烈震荡,可能是 batch_size 太小、学习率太大,或者数据里存在较多异常值。
注意:不要只看某一步的 loss 高低,要看整体趋势。训练初期的微小波动很正常,如果前 20 轮完全没有下降趋势,才需要调整配置。
4.3 调参顺序和实验记录
调参要有一个顺序,不能同时改很多参数。我建议按这个顺序来:
- 先保证小样本过拟合。比如取 100 条样本训练,看训练 loss 能否降到很低。
- 再逐步增加数据量,观察验证集表现。
- 调节学习率,找到训练稳定且收敛速度合适的范围。
- 调节 batch_size,兼顾内存和梯度稳定性。
- 最后调结构参数,比如卷积核大小、GRU 隐藏层维度、层数。
每次实验都要记录参数、随机种子、训练轮数、验证集指标。时间久了你会发现,实验记录比模型代码更难整理,但价值更高。
4.4 常见过拟合和欠拟合处理
过拟合的典型信号是验证集指标远差于训练集指标。处理办法有限制模型复杂度、增大 dropout、增加训练数据、引入正则化、早停。
欠拟合的典型信号是训练集和验证集指标都低。这时需要增加模型容量、调整特征、降低正则化强度,或者换更优的超参数。
有些情况不是欠拟合也不是过拟合,而是数据划分问题。比如训练集和验证集时间跨度不一致,或者验证集包含未来信息。时间序列预测中,最好按时间顺序切分训练集和测试集,而不是随机切分。
5. SHAP 值分析与代码实现
5.1 SHAP 的几种 explainer 选择
SHAP 库提供了多种解释器,适配不同模型类型:
| 解释器 | 适用模型 | 说明 |
|---|---|---|
| KernelExplainer | 任意模型 | 通用但慢,适合小数据 |
| TreeExplainer | 树模型 | 快,但不适用于神经网络 |
| GradientExplainer | PyTorch 和 TensorFlow 模型 | 基于梯度,适合神经网络 |
| DeepExplainer | 深度学习模型 | 较慢,新版本可能不推荐 |
对于 CNN-GRU 这类 PyTorch 模型,我推荐使用 GradientExplainer。它通过梯度信息计算特征贡献,速度上可以接受,且不需要把所有特征置换式地计算一遍。
5.2 基于 GradientExplainer 的 SHAP 计算示例
假设已经加载了训练好的模型,原始输入数据为X_test,我们需要先选取一部分背景数据作为解释基线。
import shap import numpy as np # 转成 PyTorch tensor device = next(model.parameters()).device background = torch.FloatTensor(X_train[:100]).to(device) sample = torch.FloatTensor(X_test[:10]).to(device) model.eval() explainer = shap.GradientExplainer(model, background) shap_values = explainer.shap_values(sample)结果可能是一个数组,也可能是一个包含多个数组的列表。具体取决于模型输出维度和 shap 版本。打印一下shap_values的形状:
print(np.array(shap_values).shape)如果模型输出是单值,shap_values通常对应每个输入样本、每个时间步、每个特征的重要性分数。
这里要注意:SHAP 输入的形状必须和模型输入完全一致,否则会报维度错误。使用之前先确认sample.shape和模型 forward 里接收的 shape 一致。
5.3 特征重要性、依赖图和交互作用怎么看
SHAP 分析通常看三张图。
第一张是 summary plot,展示所有特征对预测的贡献方向:
shap.summary_plot(shap_values[0], sample.cpu().numpy().reshape(sample.shape[0], -1))不过对于三维输入,summary plot 的展示方式需要处理。可以先把三维输入压缩成二维,或者选择某一维度做展示。更常见的是把特征按“时间步 + 特征名”展开,这样每个位置都作为独立特征解释。
第二张是 bar plot,看特征平均绝对 SHAP 值大小:
shap.summary_plot(shap_values[0], sample.cpu().numpy(), plot_type="bar")第三张是 dependence plot,分析单个特征和目标输出之间的关系。这个图能揭示非线性关系,也是 SHAP 最有价值的部分。
5.4 SHAP 分析结果的落地判断
SHAP 分析做完后,不能只看图漂亮,还要判断结果是否可靠。
一个判断标准是:重要特征和业务直觉是否一致。如果发现某个完全无关的特征排第一,可能输入数据有泄露、标准化方式有问题,或者模型过拟合。
另一个判断标准是:SHAP 值是否稳定。可以换几组背景数据、换几个样本,看特征排序是否剧烈变化。如果不稳定,说明模型对局部输入过于敏感,泛化能力可能不强。
注意:SHAP 计算的是模型内部的归因,不是因果。业务决策时,不能直接认为 SHAP 值大的特征就是决定性因素。
6. 完整代码框架和关键坑点
6.1 最小可运行的训练和解释流程
为了不迷失在代码细节里,我建议把整个流程拆成 5 个模块:
- 数据读取与滑动窗口构建。
- 数据集划分与标准化。
- 模型定义与训练。
- 模型评估与可视化。
- SHAP 解释分析。
每个模块单独测试通过后,再组合成完整脚本。这样出现问题容易定位。
完整框架大概是:
# 1. 数据准备阶段 X, y = create_sequences(data, seq_len=24) # 2. 划分训练验证测试集 split1 = int(len(X) * 0.7) split2 = int(len(X) * 0.85) X_train, y_train = X[:split1], y[:split1] X_val, y_val = X[split1:split2], y[split1:split2] X_test, y_test = X[split2:], y[split2:] # 3. 标准化 scaler = StandardScaler() X_train = scaler.fit_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) # 4. 训练模型 model = CNNGRURegressor(n_features=X_train.shape[2]) # 5. SHAP 分析 background = torch.FloatTensor(X_val[:100]) sample = torch.FloatTensor(X_test[:10]) explainer = shap.GradientExplainer(model, background) shap_values = explainer.shap_values(sample)6.2 批量实验时的日志和结果记录
如果你要对不同参数做多组对比实验,建议把训练过程封装成函数,返回指标和模型路径。
运行时记录以下信息:
- 随机种子。
- 数据集划分比例。
- 滑动窗口长度。
- 模型结构参数。
- 学习率和 batch_size。
- 训练轮数和验证集最优轮数。
- 最终 MAE、RMSE、R2。
保存文件名可以包含时间戳和关键参数,避免覆盖。
torch.save(model.state_dict(), f'cnn_gru_{seq_len}_{hidden_size}_{lr}.pt')日志文件比控制台输出更可靠。批量跑十几个实验时,控制台记录很容易丢失,用 Python 的 logging 模块或手动写入 CSV 都可以。
6.3 常见错误和排查顺序
这类代码最常见的报错集中在三个方面:数据维度、标准化、模型输入输出。
如果出现维度错误,先按顺序排查:
- 打印 X_train、y_train、模型输入输出的 shape。
- 确认 X_train 是
(batch, seq_len, features)。 - 确认 Conv1d 和 GRU 之间做了维度交换。
- 确认池化层没有把序列长度压缩成 0。
如果训练 loss 不下降:
- 确认损失函数是不是回归用的 MSE 或 MAE。
- 确认学习率是不是过大或过小。
- 确认标准化有没有把目标值也转换合理。
- 先取小样本过拟合,排除代码逻辑问题。
如果 SHAP 报错:
- 确认模型处于 eval 模式。
- 确认输入是 Tensor 且 dtype 为 float32 或 float64。
- 确认背景数据和预测数据都在同一个设备上。
- 如果报 shape 错误,检查输入三维形状和模型 forward 期望形状。
6.4 性能提升和边界思考
CNN-GRU 不是万能的,它有自己的适用边界:
- 数据量很小的时候,复杂模型可能不如简单线性回归。
- 数据没有明显时序依赖时,GRU 部分不会发挥太大作用。
- 特征维度过高且噪声大时,先做特征筛选可能更有效。
- SHAP 在数据量和特征维度增加后,计算时间也会明显上升。
如果训练速度太慢,可以考虑优化方向:减少 GRU 层数、降低隐藏层维度、使用更小卷积核、对数据做降采样、或者只用一部分样本验证逻辑。先跑通流程,再追求大规模训练,是更稳妥的方式。
我个人更建议先把单任务跑稳,再考虑批量和接口。这个方案真正落地时,最该盯住的不是功能列表,而是输入格式、资源占用和失败重试。
踩过几次之后我发现,很多问题不是工具能力不够,而是前置环境和输入材料没有处理干净。对于 CNN-GRU 和 SHAP 的组合,把数据窗口、维度顺序和标准化方式这三件事做好,后面大部分坑都能避免。