简介:面向电气领域预测任务的深度学习项目资源包,以Python语言实现卷积神经网络、门控循环单元与注意力机制融合的混合模型,适用于电力需求预测、设备故障诊断等典型时序场景。压缩包共8个文件,包括4个文本说明(模型介绍、依赖包版本等)、2个Python脚本(模型搭建与训练代码)和2个数据文件(含示例电力负荷数据),整体仅1.24MB,结构简洁,易于下载与复现。目前已有109人学习,适合电气工程、数据科学及深度学习入门者参考。资源提供了从数据预处理、特征提取、模型构建到结果评估的完整流程,同时包含注意力权重分析相关说明,可帮助读者理解模型如何聚焦关键输入。借助清晰的目录与依赖清单,读者也可以将模型迁移至其他回归或预测任务,是一份兼顾学习与实战的电气预测代码包。
1. 051cnn-gru-attention 是什么:一套把时序预测拆成三步走的 Python 程序
拿到一个叫051cnn-gru-attention(预测 Python程序).zip的压缩包,不用急着解压看代码,光看名字就知道这套程序是干什么的:用 CNN+GRU+Attention 三层结构做时间序列预测。我在电力负荷预测项目里用过同款组合,短期负荷曲线里既有周期性又有突发性,单靠 LSTM 容易把突发当噪声滤掉,而 CNN 先提局部形态、GRU 再学前后依赖、Attention 最后把关键时间步挑出来,正好压得住这类数据。这套代码适合手里有历史负荷或温度序列、想快速跑出一个可用预测结果的工程师,哪怕你不打算上生产,也可以拿它当基线模型去对比更复杂的方案。下面的内容我按"原理拆解、环境与数据、模型实现、踩坑记录、结果验证"这个顺序展开,让你从解压 zip 到跑出可信预测,每一步都知道自己在干什么。
2. 拆开 051cnn-gru-attention:CNN、GRU、注意力分别解决什么问题
很多第一次拿到这套代码的人会直接翻模型文件,然后被三个模块的拼接绕晕。我的建议是先别碰代码,把三个组件在时序预测里各自扮演的角色搞清楚,后面调参才有方向。这个模型名字不是随便拼的,它对应的是三段串行处理:先卷积,再循环,最后注意力加权。
2.1 CNN 卷积:从滑动窗口里先提局部特征
时间序列和图像不一样,没有空间上的上下左右,但有一个滑动窗口里的"局部形态"。比如电力负荷,早上 8 点到 9 点这一段通常是快速爬升,9 点到 11 点高位震荡,这些形态在窗口里表现为特定的数值组合。CNN 的一维卷积就是专门抓这种局部模式的:卷积核在时间轴上滑动,每个位置计算一次加权和,输出就是这一小段序列的响应强度。
在常见实现里,输入形状是(batch, seq_len, features),经过一维卷积后变成(batch, seq_len, channels)。后面的 GRU 不关心你原来有几个特征,它只关心每个时间步上"浓缩过的信息"是什么。所以 CNN 在这里还有一个作用,就是把原始的多维特征压缩成更适合 GRU 处理的表示,降低 GRU 的输入维度。如果你有多个外部特征,比如温度、湿度、是否节假日,最常见的做法是拼在 feature 维上,让卷积层同时扫描多个变量的局部关系。
这里有个容易踩的细节:一维卷积的核大小(kernel_size)选 3 还是 5,影响的是"看多宽的局部"。核太小,只能看到相邻两三个点,抓不住完整的爬升段;核太大,局部特征又会被抹平。我用下来kernel_size=3、连续两层卷积是比较稳的起点,第一层把特征维度扩大,第二层再压缩回去。另外一定要给卷积加 padding,否则序列长度会越卷越短,后面接 GRU 时维度对不上,这种报错查起来很费时间。
2.2 GRU 门控循环单元:把时间顺序吃进去
CNN 看完局部形态之后,GRU 负责把这些形态按时间顺序串起来。GRU 是 LSTM 的轻量版,只有重置门和更新门,参数少、训练快,在序列长度中等(几十到几百步)的预测任务里,效果和 LSTM 差不多,但显存占用更友好。如果你只是做单变量负荷预测,GRU 和 LSTM 的精度差异通常在 0.5% 以内,而 GRU 的训练时间能省 20% 到 30%。
GRU 的核心行为是:每个时间步输入当前时刻的向量和前一步的隐藏状态,输出新的隐藏状态。这个隐藏状态可以理解为模型对"到目前为止发生了什么"的压缩记忆。重置门决定要不要忘掉之前的记忆去接受新信息,更新门决定新信息占多大比重。对负荷预测来说,这意味着模型能学到"昨天同一时刻的负荷形态"和"前几个小时的变化趋势"对当前预测的影响。
在模型代码里,你会看到nn.GRU(input_size, hidden_size, num_layers, batch_first=True)。注意batch_first=True这个参数,它让输入输出都是(batch, seq_len, hidden),否则你后面拼 Attention 的时候每步都要转置,容易把维度搞混。num_layers一般设 1 到 2 层,堆太多层在小数据集上只会过拟合,不会带来精度提升。还有一点,GRU 的初始隐藏状态默认是全零,这在小数据集上够用;如果你知道序列开头有很强的先验信息,可以手动传入初始状态,但绝大多数场景不需要。
2.3 Attention 注意力:决定看哪几个时间步
GRU 的隐藏状态会记住整个序列,但最后的隐藏状态其实是"压缩过全程"的信息,具体到"明天早上 9 点的负荷更依赖前 24 小时哪个时段",它说不清楚。Attention 机制解决的就是这个问题:给每个时间步的隐藏状态算一个权重,让模型在预测时重点看权重高的那几步。
常见做法是加性注意力:把 GRU 每个时间步的输出和一个可学习的查询向量做匹配,算出未归一化的分数,再用 softmax 转成权重,最后对所有时间步的输出做加权求和。这个加权结果作为"带注意力的上下文向量",送到全连接层做最终预测。加性注意力比乘性注意力在隐藏维度较大时更稳,因为乘性注意力在维度高时容易让 softmax 进入饱和区,梯度变很小。
这套程序里的 Attention 通常不需要自己从头写,但你要理解权重矩阵的形状:GRU 输出形状是(batch, seq_len, hidden_size),注意力分数形状是(batch, seq_len, 1),加权求和后变成(batch, hidden_size)。维度对不上是这里最常见的报错,后面第 5 章会专门讲怎么排查。另外,注意力权重是可以打印出来的,把权重画成热力图,能看到模型在预测每个点的时候重点关注了哪些历史时刻。这个可视化在给业务方解释模型时非常好用,比张嘴说"深度学习模型是个黑匣子"强得多。
需要特别提醒的是:Attention 不是万能的,它只是在 GRU 之后做一次"重点回顾"。如果序列里全是噪声,注意力权重学出来也会很平均,这时候它并不会比直接用最后一个隐藏状态好多少。所以不要指望加了这个模块就一定涨点,它真正擅长的是让模型在长序列里找到关键时间步。
3. 落地第一步:目录、环境和数据预处理
原理搞清楚了,现在开始动真格的。这一章的目标是让你把 zip 解开之后,能对照目录结构知道每个文件是干嘛的,并且把环境配好、把数据送进模型之前的预处理做对。很多项目死在预处理上而不是模型上,这句话我反复跟同事强调过。
3.1 zip 包里该有什么:从数据到预测脚本的目录结构
我经手过的这类"预测 Python 程序"压缩包,绝大多数目录结构长这样:
051cnn-gru-attention/ ├── data/ │ ├── load.csv # 原始负荷或温度序列 │ └── preprocess.py # 滑窗、归一化、数据集划分 ├── models/ │ ├── cnn_gru_attention.py # 模型定义 │ └── __init__.py ├── config.py # 所有超参数集中管理 ├── train.py # 训练入口 ├── predict.py # 加载权重做预测 ├── utils.py # 指标计算、绘图等工具 └── requirements.txtdata/load.csv是原始数据,通常第一列是时间戳,第二列是负荷值;preprocess.py负责把 CSV 读进来、处理缺失值、构造滑窗样本;train.py是训练入口,读 config、构造数据、初始化模型、跑 epoch;predict.py负责加载训练好的权重文件做预测并输出结果;utils.py里放的是指标计算和画图函数。这个结构不是标准,但足够清晰,建议你拿到任何代码包之后先按这个思路画一张文件职责图,再动手改代码,会省很多事。
注意看config.py。很多人拿到代码第一件事是改模型结构,结果发现训练半天不收敛,最后定位到是学习率写死在模型文件里。我的习惯是:所有超参数必须集中在 config 里,包括 seq_len、pred_len、batch_size、lr、epochs、dropout、hidden_size,这样每次跑实验只需要改一个文件,方便留痕。如果你拿到的 zip 没有config.py,我建议你新建一个,哪怕只是把参数从 train.py 里挪出来,后面调参会省很多事。
3.2 环境准备:用 conda 建环境而不是裸装 python
这套代码依赖 PyTorch、pandas、numpy、matplotlib、scikit-learn。我不建议直接在基础 python 环境里 pip install,因为 torch 的 CUDA 版本和 NumPy 版本冲突是家常便饭,装完跑起来全是玄学报错。常见做法是先用 conda 建一个独立环境。
conda create -n tsforecast python=3.10 -y conda activate tsforecast pip install pandas numpy matplotlib scikit-learn pip install torch --index-url https://download.pytorch.org/whl/cpu如果你机器上有 NVIDIA 显卡并且装好了 CUDA,把最后一行换成对应的 cu118 或 cu121 版本就好。先说明一下:python=3.10不是唯一选择,但你用 python 3.9 到 3.11 之间基本都能跑通;太新的版本可能遇到某个依赖包还没出对应 wheel 的情况。装完之后用python -c "import torch; print(torch.__version__)"验证一下,能打印版本号就说明环境没问题。
这里有个很多人忽略的点:torch 的 CPU 版和 GPU 版行为在数值上略有差异,同一个随机种子跑出来的结果不完全一样。所以如果你要对比实验结果,要么统一用 GPU,要么统一用 CPU,别混着跑。另外,如果你在公司内网,pip 下载超时,可以临时换一个镜像源,用-i参数指定镜像地址。这种网络问题不属于代码问题,但会卡住你一下午,提前备好镜像源能省很多时间。
3.3 滑窗与归一化:时序预测最容易写错的一段代码
数据处理是整个流程里最不起眼、也最容易出问题的一步。时序预测和数据挖掘不一样,不能用train_test_split随机打乱数据,否则就是典型的数据泄露,后面第 5 章会细说。正确做法是按时间顺序切分:前 80% 训练,中间 10% 验证,最后 10% 测试。
滑窗构造样本的代码我一般这样写:
import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler def create_sequences(data, seq_len, pred_len): X, y = [], [] for i in range(len(data) - seq_len - pred_len + 1): X.append(data[i:i+seq_len]) y.append(data[i+seq_len:i+seq_len+pred_len]) return np.array(X), np.array(y) # 读取原始数据,假设只有一列 'load' df = pd.read_csv('data/load.csv', parse_dates=['date']) values = df['load'].values.reshape(-1, 1) # 先按时间顺序切分,再分别做归一化 train_size = int(len(values) * 0.8) val_size = int(len(values) * 0.1) train_raw = values[:train_size] val_raw = values[train_size:train_size+val_size] test_raw = values[train_size+val_size:] scaler = StandardScaler() train_scaled = scaler.fit_transform(train_raw) val_scaled = scaler.transform(val_raw) test_scaled = scaler.transform(test_raw) seq_len, pred_len = 48, 1 X_train, y_train = create_sequences(train_scaled, seq_len, pred_len) X_val, y_val = create_sequences(val_scaled, seq_len, pred_len) X_test, y_test = create_sequences(test_scaled, seq_len, pred_len)这段代码的逻辑说明:create_sequences用滑窗把一维序列切成(样本数, seq_len, 1)的输入和(样本数, pred_len)的目标。注意归一化只对训练集调用fit_transform,验证集和测试集只用transform。原因在于fit会计算均值和方差,如果用测试集参与计算,等于把测试集的统计信息泄露给了模型。
参数说明:seq_len 是回溯窗口长度,短期负荷预测常用 24 或 48(小时),pred_len 是预测步长,单点预测设 1,多步预测设 12 或 24。窗口越大,样本数越少,训练越慢,但模型能看到更长的历史依赖。这里没有硬性标准,一般先按业务周期定:预测未来 1 小时,至少要回看过去 24 小时到 48 小时。
还要处理缺失值和异常值。CSV 里可能出现某几个小时没有数据,最省事的办法是df['load'].interpolate(method='linear'),用前后值线性插值补上;如果缺失超过连续 6 小时,我会直接丢掉这一段,因为插值出来的长段假数据会让模型学到错误的形态。异常值用分位数过滤:低于 1% 分位或高于 99% 分位的点,先可视化确认不是真实尖峰再去掉或替换成中位数。
这整段其实也是你日常用 python 做数据分析与可视化时的标准动作:读 CSV、清洗缺失值、归一化、构造特征矩阵。唯一要格外小心的是"先划分再归一化"这个顺序,我刚入行时在这上面翻过车,后面会展开讲。另外,滑窗的步长默认是 1,相邻两个样本有大量重叠,这会增加训练集的冗余度,但能提供更多样本。如果样本量已经很大,可以给create_sequences加一个step参数,比如step=6,每 6 小时取一个窗口,减少计算量。
4. 用 PyTorch 实现 CNN-GRU-Attention 预测模型
预处理完,数据形状是(样本数, seq_len, features)。这一章给出一个能直接跑起来的最小模型实现,包含模型定义、训练循环和一个参数参考表。我不会贴一个几百行的完整工程,而是把核心结构讲清楚,让你拿到任何一份类似代码都能看懂它在拼什么。
4.1 模型结构:卷积层、GRU 层、注意力层怎么拼
模型类按"CNN 提特征 → GRU 学时序 → Attention 加权 → 全连接输出"的顺序拼接。核心注意点是卷积之后怎么和 GRU 对齐维度。
import torch import torch.nn as nn import torch.nn.functional as F class CNN_GRU_Attention(nn.Module): def __init__(self, input_size, hidden_size, num_layers, dropout, seq_len, pred_len): super().__init__() self.seq_len = seq_len self.pred_len = pred_len # 一层一维卷积:把 input_size 映射到 hidden_size self.conv1 = nn.Conv1d(input_size, hidden_size, kernel_size=3, padding=1) self.conv2 = nn.Conv1d(hidden_size, hidden_size, kernel_size=3, padding=1) self.gru = nn.GRU(hidden_size, hidden_size, num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0) self.attn = nn.Linear(hidden_size, 1) self.fc = nn.Linear(hidden_size, pred_len) def forward(self, x): # x: (batch, seq_len, input_size) -> 转成 (batch, input_size, seq_len) 做卷积 x = x.permute(0, 2, 1) x = F.relu(self.conv1(x)) x = F.relu(self.conv2(x)) # 再转回 (batch, seq_len, hidden_size) 给 GRU x = x.permute(0, 2, 1) gru_out, _ = self.gru(x) # (batch, seq_len, hidden_size) attn_scores = self.attn(gru_out).squeeze(-1) # (batch, seq_len) attn_weights = F.softmax(attn_scores, dim=1) # 对时间步做归一化 context = torch.bmm(attn_weights.unsqueeze(1), gru_out).squeeze(1) # (batch, hidden_size) return self.fc(context)逻辑说明:permute是因为 PyTorch 的Conv1d期望输入形状是(batch, channels, length),而我们滑窗出来的数据是(batch, length, features),所以要先交换后两维。卷积做完再交换回来,进入 GRU 时保持(batch, seq_len, hidden_size)。注意力部分用Linear(hidden_size, 1)对每个时间步打分,softmax在时间步维度归一化,最后用torch.bmm做加权求和。这里torch.bmm是批量矩阵乘法,能把(batch, 1, seq_len)的权重和(batch, seq_len, hidden_size)的输出乘成(batch, 1, hidden_size)。
参数说明:kernel_size=3加padding=1可以保证卷积不改变序列长度,这样 GRU 拿到的 seq_len 和输入一致。如果不加 padding,序列会越卷越短,最后和注意力权重对不上形状。这是最容易被忽略的维度问题。如果你想用更宽的卷积核,比如 5,padding 要同步改成 2,计算公式是padding = (kernel_size - 1) // 2。
还有一种常见的注意力变体是拿 GRU 最后一个时间步的隐藏状态作为查询向量,和所有时间步的输出做点积或加性匹配。这种写法的好处是查询向量是动态的、依赖数据内容的,注意力权重的可解释性更强。代码实现上就是把self.attn = nn.Linear(hidden_size, 1)改成self.attn = nn.Linear(hidden_size * 2, 1),然后把最后一个时间步gru_out[:, -1, :]拼到每个时间步上再打分。两种方式我都试过,在不大的数据集上差异很小,建议先用最简单的版本跑通。
4.2 训练循环:一批数据怎么喂进去、损失怎么算
训练循环本身不复杂,但要注意几个时序预测特有的细节:每个 batch 的输入输出要正确切片,验证集要在每个 epoch 结束后评估一次,并保存验证损失最小的权重。
import torch.optim as optim from torch.utils.data import TensorDataset, DataLoader train_dataset = TensorDataset(torch.FloatTensor(X_train), torch.FloatTensor(y_train)) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) model = CNN_GRU_Attention(input_size=1, hidden_size=64, num_layers=2, dropout=0.2, seq_len=48, pred_len=1) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.MSELoss() epochs = 50 best_val_loss = float('inf') for epoch in range(epochs): model.train() train_loss = 0.0 for x_batch, y_batch in train_loader: optimizer.zero_grad() pred = model(x_batch) loss = criterion(pred, y_batch) loss.backward() optimizer.step() train_loss += loss.item() * x_batch.size(0) train_loss /= len(train_dataset) # 验证 model.eval() with torch.no_grad(): val_pred = model(torch.FloatTensor(X_val)) val_loss = criterion(val_pred, torch.FloatTensor(y_val)).item() if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pt') if (epoch + 1) % 10 == 0: print(f'Epoch {epoch+1}/{epochs}, train_loss={train_loss:.6f}, val_loss={val_loss:.6f}')逻辑说明:DataLoader的shuffle=True只有在样本是独立滑窗时才合理。因为滑窗之间有一定重叠,相邻样本来自几乎相同的时间段,强行 shuffle 会引入重叠信息,但这是可接受的,模型并没有看到测试集。真正不能做的是把训练和测试的原始数据混在一起再滑窗。
保存权重的时机我放在验证损失最小的 epoch,而不是最后一个 epoch。原因很简单:训练后期容易过拟合,最后一轮的权重往往不是泛化最好的。这个习惯在真实项目里能帮你省下大量重训时间。如果你想做得更细,可以加一个patience计数器:连续 10 个 epoch 验证损失没有下降就提前停止训练。这个机制叫 early stopping,在代码里就是一个简单的 if 判断,但能防止你挂机跑一夜后回来发现最后一轮已经过拟合了。
损失函数的选择也要看场景。MSE 对大误差的惩罚更重,会让模型更努力地拟合极端尖峰,这在负荷预测里有时会导致均值偏移;HuberLoss 在误差小时表现为 MSE、误差大时表现为 MAE,对异常值更鲁棒。如果数据里有突变尖峰,我会用nn.SmoothL1Loss(beta=1.0)代替nn.MSELoss,你可以把两者都跑一遍对比验证损失再决定。
4.3 必调参数表:seq_len、hidden_size、dropout 这些值怎么定
超参数不可能一次调对,但可以先从一组保守值起步,再按验证损失调。下面是我在负荷预测场景里常用的初始值和调整思路。
| 参数 | 初始值 | 调整方向 |
|---|---|---|
| seq_len | 48 | 数据周期按小时计就取 24/48,按分钟计可以放大到 96 |
| pred_len | 1 | 多步预测按业务需求设,12/24 起步 |
| hidden_size | 64 | 样本量小就用 32,样本量大可以用 128 |
| num_layers | 2 | 超过 3 层在小数据集上基本必过拟合 |
| dropout | 0.2 | 过拟合加大到 0.4,欠拟合减到 0.1 |
| batch_size | 64 | 显存不够就减半,不要低于 16 |
| lr | 1e-3 | 损失震荡就降一半,长时间不降可以考虑 5e-4 |
| kernel_size | 3 | 想在 CNN 阶段看更宽的模式可以试 5 |
参数说明:hidden_size决定了 GRU 和注意力上下文向量的维度,它不是越大越好。我曾经把 hidden_size 从 64 提到 128,验证损失不降反升,因为样本量只有几千条,模型容量太大直接记住了训练集的细节。调参的核心原则是:先让模型在训练集上过拟合,再通过 dropout 和早停往回收。如果你训练集损失都降不下去,那问题不在正则化,而在学习率或数据预处理。
判断过拟合有一个很朴素的方法:把训练损失和验证损失画在同一张图上,训练损失持续下降、验证损失在某个点开始回升,那个转折点就是最佳 epoch。很多人一上来就盯着测试集调参,这是错的,测试集只能最终评估一次,否则你调参调的就是测试集上的过拟合。验证集存在的意义就是让你放心地反复试参数。
5. 训练与预测中的 5 个常见坑:现象、原因、解决
这部分是我觉得比模型代码更值钱的内容。这些坑我在多个项目里踩过,也帮同事排查过,每一条都按"现象→原因→解决"写清楚。
5.1 数据泄露:随机划分训练集导致预测结果虚高
现象:训练损失降得很漂亮,测试集上 MAPE 只有 1% 左右,但一上真实数据就完全不准,预测曲线像平移了一小段。
原因:这是最常见的错误。如果直接用train_test_split(X, test_size=0.2, random_state=42)随机打乱,训练集和测试集里会出现"同一时间段被劈开"的样本。滑窗本来就是滑动构造的,相邻样本高度重叠,随机划分等于让模型在测试时见过几乎一样的输入,测试指标自然虚高。这不是模型强,是数据泄露。
解决:严格按时间顺序划分,先切原始序列,再分别滑窗。也就是第 3 章里train_raw, val_raw, test_raw那段代码的逻辑。另外,归一化也必须先 fit 训练集再 transform 验证集和测试集。检查自己有没有踩这个坑,最快的方法是打印训练集和测试集的时间范围,看有没有交错。我再加一个保险:把训练集最后一个样本的结束时间和测试集第一个样本的开始时间打出来,如果测试集开始时间小于训练集结束时间,那一定泄露了。
5.2 维度对不上:GRU 输出和注意力权重形状冲突
现象:forward 跑到注意力那一步报mat1 and mat2 shapes cannot be multiplied,或者The size of tensor a must match the size of tensor b。
原因:维度对不上的根源通常是 Conv1d 改变了序列长度,或者 GRU 没有加batch_first=True。比如你设了kernel_size=3, padding=0,输入 seq_len=48,卷积后变成 46,注意力权重是按 46 算的,后面全连接却按 48 的预期来接,自然报错。
解决:排查顺序我一般这样走。第一步,看卷积层有没有padding=1,没有就补上,保证输出长度等于输入长度。第二步,确认 GRU 构造时写了batch_first=True,这样gru_out的形状才是(batch, seq_len, hidden_size)。第三步,在 forward 里临时加一行print(x.shape),把每个阶段的形状打出来,看到哪一步变了就能定位。这个问题在跑第一个 batch 时最容易暴露,所以调试习惯很重要,第 6 章会专门讲。
5.3 反归一化丢失:预测值整体偏小一个数量级
现象:模型训练、验证都在正常范围,但导出预测结果之后,数值全都在 0 到 1 之间,或者整体比真实值小了一个数量级。
原因:训练时输入做了 StandardScaler 归一化,模型输出的是"归一化后的预测值"。如果预测代码里忘了调用scaler.inverse_transform,你拿到的就是标准正态空间里的数字,不是真实负荷值。这个问题还常见于多步预测:pred_len>1 时输出形状是(batch, pred_len),反归一化也要对应 reshape。
解决:预测脚本里必须持有训练时那个 scaler 对象(或者把 scaler 的mean_和scale_一起保存到 npz 文件),预测完成后调用scaler.inverse_transform(pred.reshape(-1, 1))再 reshape 回(batch, pred_len)。注意不能用测试集重新 fit 一个 scaler,那样均值和方差就变了。这条我列在必查清单第一位,因为错误从数值上很难发现,只有画出真实值和预测值对比图才会露馅。多步预测时的标准写法是这样:
pred = model(torch.FloatTensor(X_test)) # (batch, pred_len) pred_np = pred.detach().numpy() pred_inv = scaler.inverse_transform(pred_np.reshape(-1, 1)).reshape(pred_np.shape)逻辑说明:reshape(-1, 1)是为了满足inverse_transform对输入形状的要求,它要求列数和 fit 时一致。单特征时 fit 的输入是(n, 1),所以反归一化也要是(n, 1),处理完再恢复成(batch, pred_len)。如果你的数据是多变量,scaler 会对每一列分别存均值和方差,反归一化时要把对应的列取出来,不要整块操作。
5.4 NaN 和梯度爆炸:学习率过大带来的玄学故障
现象:训练到某个 epoch 后loss突然变成nan,或者损失值在几个 epoch 内从 0.01 跳到 1e10。排错时发现数据没有缺失值,网络结构也正常。
原因:最常见的两个原因。一个是学习率过大,Adam 在 1e-3 以上的学习率配合深层 GRU 很容易让梯度爆炸,尤其在数据没有归一化或数据里有极端尖峰时。另一个是数据预处理时把缺失值填充成 0,而 0 进过卷积和 GRU 后产生的梯度响应异常。这类问题之所以叫玄学故障,是因为它不报错,只在 loss 曲线上体现。
解决:先把学习率降到 1e-4 重新跑,如果还炸,就在optimizer.step()之前加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。同时检查np.isnan(values).any(),确认没有脏数据。我习惯在训练循环里写一个判断:如果torch.isnan(loss),直接停止训练并打印当前 epoch,这样能立刻定位是哪个阶段炸的。下面这段是带梯度裁剪和 NaN 检查的最小改动:
for x_batch, y_batch in train_loader: optimizer.zero_grad() pred = model(x_batch) loss = criterion(pred, y_batch) if torch.isnan(loss): raise RuntimeError(f'NaN loss at epoch {epoch}') loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()逻辑说明:梯度裁剪的作用是把梯度向量的范数限制在 1.0 以内,超过的部分等比缩放。它不会改变梯度的方向,只限制步长,所以不会影响收敛方向。max_norm设 1.0 是比较保守的值,设太大会失效,设太小会让训练变慢,一般从 1.0 开始调。
5.5 换台机器跑不出同样结果:随机种子没固定
现象:在同一份代码、同一份数据下,换一台机器或者重启内核后重新训练,验证损失和指标差很多,甚至预测曲线走势都变了。
原因:PyTorch 在 CPU、GPU 和 cuDNN 三个层面都有随机源。没有设置随机种子的话,每个 batch 的权重初始化、数据打乱顺序、卷积算法的选择都是随机的。如果只固定了np.random.seed而没固定torch.manual_seed,结果当然不可复现。还有一个隐藏因素:DataLoader 的shuffle=True依赖 Python 的 random 模块,所以三个种子都要固定。
解决:在训练脚本最开头固定所有随机源:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False逻辑说明:cudnn.deterministic=True会强制 cuDNN 使用确定性的卷积算法,代价是运行速度略微下降。benchmark=False则是禁止它在第一次运行时根据输入形状自动选择最快的算法,否则不同机器可能选到不同算法,数值会有一点点差异。对于预测任务,可复现性比那一点速度重要得多。注意即便如此,CPU 和 GPU 之间的结果也无法完全一致,跨设备对比时要有心理准备。
6. 让预测结果可信:评估指标、反归一化与一个小习惯
这一章短一点,但都是真正影响你交付判断的东西。模型跑通只是第一步,怎么让结果经得起推敲才是关键。
6.1 三个评估指标:MAE、RMSE、MAPE 够用了
训练损失用 MSE 是为了梯度好算,但给业务方汇报时,MSE 的量纲是"平方",人很难理解。我一般计算三个指标:MAE(平均绝对误差)、RMSE(均方根误差)、MAPE(平均绝对百分比误差)。MAPE 最直观,但要注意真实值接近 0 的时候它会爆炸,所以负荷预测里要过滤掉接近 0 的时段再算。代码很简单:
from sklearn.metrics import mean_absolute_error, mean_squared_error mae = mean_absolute_error(y_true, y_pred) rmse = mean_squared_error(y_true, y_pred, squared=False) mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100y_true和y_pred都必须是反归一化之后的真实物理量纲,用归一化后的数值算指标没有意义。RMSE 比 MAE 对大误差更敏感,如果 RMSE 明显大于 MAE,说明存在少数几个预测得很差的时间点,这时候可以画误差分布图找找是不是某些尖峰时段系统性预测失败。
6.2 预测后的反归一化
第 5 章提过这个问题,这里给一个完整的最小示例:训练用StandardScaler拟合训练集,预测时加载保存好的 scaler 参数做inverse_transform。多步预测时尤其小心:pred形状是(batch, pred_len),反归一化前先 reshape 成(batch*pred_len, 1),反归一化后再 reshape 回去,顺序不能反。我已经吃过一次亏,当时多步预测结果全部错位,就是因为直接对二维数组做了 inverse_transform,sklearn 默认按列处理,把时间步的顺序搞乱了。
6.3 调试小习惯:先跑一个 batch
我最后想分享一个很多项目里救过我的习惯:训练全量数据之前,先取一个 batch 跑一次前向和反向,确认形状都能对上、loss 能正常下降,再开完整训练。
sample_x, sample_y = next(iter(train_loader)) with torch.no_grad(): sample_pred = model(sample_x) print(sample_pred.shape, sample_y.shape) loss = criterion(sample_pred, sample_y) loss.backward() print(loss.item())这段代码能在 5 秒内暴露 90% 的维度错误和数据类型错误,而不是等训练跑了半小时后才在某个 epoch 报错。我自己的习惯是任何新模型结构都先走这个流程,确认没错了再挂机跑实验。这套 051cnn-gru-attention 程序里,最值得你先跑的就是这一段。希望这些经验能帮你在自己的预测项目里少走几个弯路,也祝你能把这份代码真正跑成自己的基线模型。
本文还有配套的精品资源,点击获取