news 2026/10/5 5:23:14

VMD-CNN-BiLSTM-Attention负荷预测实战:从数据预处理到模型训练

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
VMD-CNN-BiLSTM-Attention负荷预测实战:从数据预处理到模型训练

简介:面向电力负荷预测与毕业设计场景的 Python 实现方案,围绕 VMD-CNN-BiLSTM-Attention 混合模型,涵盖数据分解、特征提取、时序建模与注意力机制融合等关键环节。压缩包共15个文件,大小2.86MB,包含多个 Python 模型脚本、Jupyter Notebook 调试记录、备份文件、电力负荷数据集及说明文档,兼顾可运行代码与过程笔记,便于对照学习和二次开发。已有119人学习下载。资源提供 VMD 分解数据保存脚本、四折交叉验证 Notebook、多种对比模型(如 VMD-CNN-LSTM、VMD-CNN-BiGRU)等,既可直接用于负荷预测实验,也能帮助理解模型组合与参数调优思路。资源来源于网络分享,仅供学习交流,使用前可先阅读说明文档确认数据格式与运行依赖,对正在完成毕业设计或开展短期预测研究的读者具有较高参考价值。

1. 负荷预测为什么绕不开 VMD-CNN-BiLSTM-Attention 这个组合

电力负荷数据天生不稳定:有日周期、周周期,还有节假日、温度突变带来的非平稳波动。单靠 LSTM 硬训,模型容易把周期信息记住,却在尖峰时段彻底失准。VMD-CNN-BiLSTM-Attention 这个组合,本质是把"分解、提特征、建模时序、聚焦重点"四件事拆给四个模块做:VMD 先把非平稳负荷拆成若干个相对平稳的分量,CNN 抓局部形态,BiLSTM 从两个方向读时序依赖,Attention 再从长序列里挑出真正影响预测结果的关键时步。组合模型在短中期负荷预测里,通常比单一 LSTM 在 MAPE 上再降 3~8 个点,但它最大的问题不是原理,而是在 Python 里把四个模块拼起来时,数据和形状的坑远比模型本身多。这篇文章就把全流程拆开讲清楚,适合正在做负荷预测论文、平台原型,或者想用它当基线模型的工程师。

2. 动手前先定好数据协议:切分、归一化与样本构造

2.1 数据清洗先做三件事

不管用哪个公开负荷数据集,还是自己从现场取数,第一件事永远是先把时间序列整理干净。我一般按三个步骤过一遍:先按时间戳升序排列并去重,重复的时间戳按记录取均值或直接删掉;再处理缺失值,短缺失用线性插值,长缺失不要瞎补,直接截掉整段;最后处理异常值,比如负荷突然跌到 0 又跳回正常值,这种多数是采集端问题,用"超过中位数 3 倍绝对偏差"的规则替换成前后均值。

清洗时有个分寸:宁缺毋滥。不要用平滑滤波把负荷曲线磨得太光,VMD 后面还要做分解,过度平滑会把真正的尖峰信息抹掉,模型学到的就是一个"低通滤波后的世界"。清洗的目的是让数据可用,不是让数据好看。

2.2 切分和归一化:时间序列的"泄漏"红线

时间序列数据切分不能随机打乱。正确做法是按时间顺序切成训练集、验证集、测试集三段,比例建议 70%/15%/15%。注意,验证集一定要从训练集之后切,测试集必须是时间上最靠后的那段。如果你随机抽样切分,模型等于提前"看到"了未来的统计分布,测试指标会好看得离谱,上线后马上翻车。

归一化是个重灾区。很多人在整个数据集上 fit 一个 MinMaxScaler,再切分训练集测试集,这种做法有严重的数据泄漏。测试集的最大值最小值已经参与训练,模型相当于提前知道了未来量纲,验证集和测试集的指标都会偏乐观。正确做法只有一种:

from sklearn.preprocessing import MinMaxScaler # 只允许在训练段上 fit scaler = MinMaxScaler(feature_range=(0, 1)) train_scaled = scaler.fit_transform(train[["load"]].values) # 验证集、测试集用同一套参数 transform val_scaled = scaler.transform(val[["load"]].values) test_scaled = scaler.transform(test[["load"]].values)

这里fit_transform只出现在训练集上,transform对验证集、测试集生效,保证三段数据用完全相同的缩放参数。这样做还有一个额外好处:上线时你只需要保存这一个 scaler 对象,新来的实时数据用 joblib 或者 pickle 加载后 transform 即可。

2.3 滑动窗口构造样本:序列切成模型能吃的形状

负荷预测的标准做法是把连续时间序列切成"输入窗口 + 预测目标"的样本对。假设你的数据是 15 分钟一条,那么一天就是 96 个点。输入窗口取 96,预测未来 1 个点,这就是一个典型样本。窗口滑动步长不要设成 1,否则相邻样本高度重合,训练集冗余很大,我一般取 step=6,也就是每 6 个时间点生成一个样本。

import torch from torch.utils.data import Dataset class LoadDataset(Dataset): def __init__(self, data, seq_len=96, horizon=1, step=6): self.seq_len = seq_len self.horizon = horizon self.data = data # 所有合法窗口的起点索引 self.indices = list(range(0, len(data) - seq_len - horizon + 1, step)) def __len__(self): return len(self.indices) def __getitem__(self, idx): i = self.indices[idx] x = self.data[i : i + self.seq_len] # 输入特征,形状 [seq_len, feat] y = self.data[i + self.seq_len : i + self.seq_len + self.horizon, 0] # 目标负荷 return torch.tensor(x, dtype=torch.float32), torch.tensor(y, dtype=torch.float32)

这段代码的核心是indices列表。step=6控制样本密度,seq_len=96控制模型能看到多长的历史,horizon=1先做单步预测。注意y取的是第 0 列,也就是负荷本身,因为后续要做 VMD 分解,输入特征会从单列变成多列,但预测目标始终只有一个负荷维度。

3. VMD 分解:把非平稳负荷拆成 K 个可预测的分量

3.1 VMD 的原理与 K、alpha 两个关键参数

变分模态分解(VMD)做的事情,是把一段原始信号分解成 K 个带限模态分量。它的目标函数同时做了两件事:每个模态围绕自己的中心频率保持窄带,同时所有模态加起来要能重构回原始信号。这比 EMD 的递归筛选稳定得多,不会因为一个小的噪声就导致模态完全变形。

两个参数需要重点理解。K 是模态个数,设小了会欠分解,多个频率成分挤在一个模态里,模型还是在学非平稳序列;设大了会过分解,出现两个中心频率几乎重合的模态,模型被迫去拟合两条高度相关的曲线,白白浪费参数。alpha 是带宽惩罚因子,alpha 越大,各模态带宽越窄,中心频率分得越开。对电力负荷这种有日周期和周周期的信号,我一般从 K=5、alpha=2000 起步,再看中心频率分布微调。

3.2 用 vmdpy 在 Python 里跑分解

Python 生态里 VMD 的可用实现不多,最常见的是 vmdpy 这个库,它封装的是原作者释放的 MATLAB 算法逻辑,接口很稳定。

import numpy as np from vmdpy import VMD # load_scaled 是归一化后的一维负荷序列,形状 [N] K = 5 alpha = 2000 tau = 0 # 对偶上升步长,0 表示让算法自动决定 DC = 0 # 是否保留直流分量,负荷数据一般设为 0 init = 1 # 中心频率初始化方式,1 表示均匀初始化 tol = 1e-7 # 收敛容差 u, u_hat, omega = VMD(load_scaled, alpha, tau, K, DC, init, tol) print("u.shape:", u.shape) # 期望输出 (K, N) print("最后一个迭代步的中心频率:\n", omega[-1])

u是分解出的模态分量,每一行是一条长度为 N 的 IMF 曲线;u_hat是对应的频谱;omega是每轮迭代的中心频率轨迹,omega[-1]就是最终收敛的中心频率。分解完成后,第一件事不是接模型,而是先验证重构误差:

recon = u.sum(axis=0) recon_error = np.max(np.abs(recon - load_scaled)) print("最大重构误差:", recon_error)

正常量级应该在 1e-6 以下。如果重构误差偏大,先检查输入序列是否有 NaN,再看 tau 是否被手动设成了奇怪的值。重构没问题,说明分解环节是可信的,数据已经变成了 K 条更平稳的分量,接下来才轮到模型出场。

3.3 怎么判断 K 选没选对

只看重构误差远远不够,因为 K=1 也能重构得很好。核心判断方法是看中心频率的分布。VMD 迭代完成后,打印omega[-1],如果是一个单调递增的序列,比如 [0.03, 0.08, 0.15, 0.28, 0.62],说明 K=5 是合理的,各模态在频域上分得开。如果发现其中两个中心频率的差小于 0.02,基本可以判定发生过分解,把 K 减一再跑一轮。

还有一个比较省事的方法:把每个模态的曲线画出来,如果某个模态的波形看起来和另一个几乎一样,只是幅值差个倍数,那就是 K 设大了。负荷数据本身具有强周期性,一般 K 在 4~8 之间就能覆盖。

3.4 分解结果怎么接进 CNN-BiLSTM-Attention

很多实现是"每个模态单独用一个模型预测,再把结果加起来",这种做法准确率确实可以,但训练开销直接变成 K 倍。工程上更常用的方案,是把 K 条模态分量堆叠成多通道特征,喂给一个模型,让模型自己学会融合这些分量。具体到数据形状,原序列是[N, 1],VMD 分解后转置成[N, K],即每个时间步上有 K 个特征。这样滑动窗口切出来的样本 x 形状变成[seq_len, K],CNN 的输入通道数也变成 K。也就是说,第 2 章的 Dataset 不需要改逻辑,只需要把喂进去的data从单列变成 K 列即可。

4. 模型主体:CNN、BiLSTM 与 Attention 的搭建和参数选择

4.1 CNN 卷积层:用局部感受野抓负荷突变

为什么前置一层 CNN?因为 BiLSTM 对逐点的时序变化不敏感,而负荷序列里有很多短时局部特征,比如晚高峰突然爬升、空调负荷集中启动。一维卷积通过滑动窗口能提取这些局部形态,参数量又小,不会给整个模型增加多少训练压力。对负荷这种 96 点窗口,卷积核大小取 7 比较合适,padding=3 可以让卷积输出长度和输入一致。输出通道一般取 64,太少了特征表达不够,太多了后面 BiLSTM 的计算量会明显上浮。

4.2 BiLSTM:双向读取时序依赖

CNN 提取完局部特征后,每个时间步变成一个 64 维向量,整个序列变成了[batch, seq_len, 64]。BiLSTM 在这个序列上做时序建模。双向的含义是:第 t 个时间步的输出,同时由 t 之前的正向状态和 t 之后的反向状态拼接而成。对负荷预测来说,某个时刻的负荷不仅依赖过去几个小时的变化趋势,也依赖未来一段时间的变化斜率(比如晚高峰是从哪个点开始拐头的),单向 LSTM 只能看到过去,双向能同时利用两侧信息。

hidden_size 我一般取 64,层数取 2。再加到 128 或 3 层,收益通常不明显,但训练时间会涨一截。这里有个容易踩的维度坑:BiLSTM 的最终输出特征维度是hidden_size * 2,因为双向两个方向各输出一份,后续 Attention 和全连接层都必须拿这个维度去设计。

4.3 Attention 加权:把注意力放在关键时刻

BiLSTM 输出了seq_len个时间步的状态,但并非每个时间步对最终预测同等重要。Attention 的作用,是给每个时间步学一个权重,最后把所有时间步的状态做加权求和,得到一个"浓缩了关键信息"的向量。这里用的是最简洁的加性注意力:把每个时间步的2*hidden维状态过一层线性映射,打出一个分数,再对seq_len维做 softmax,得到归一化权重。

比起直接取最后一个时间步的输出,Attention 的好处是让梯度从输出端直接流向那些关键时步,不会因为序列过长而把信息"冲淡"到后面的时间步里。

4.4 组合模型的完整 PyTorch 实现

把上面几块拼起来,就是一个可运行的模型定义:

import torch.nn as nn import torch.nn.functional as F class VMD_CNN_BiLSTM_Attention(nn.Module): def __init__(self, in_channels, seq_len, lstm_hidden=64, lstm_layers=2, horizon=1): super().__init__() self.conv = nn.Conv1d(in_channels, 64, kernel_size=7, padding=3) self.bilstm = nn.LSTM( input_size=64, hidden_size=lstm_hidden, num_layers=lstm_layers, batch_first=True, bidirectional=True ) self.attn = nn.Linear(lstm_hidden * 2, 1) self.fc = nn.Linear(lstm_hidden * 2, horizon) def forward(self, x): # x: [batch, seq_len, in_channels] x = x.permute(0, 2, 1) # [batch, in_channels, seq_len] x = F.relu(self.conv(x)) x = x.permute(0, 2, 1) # [batch, seq_len, 64] out, _ = self.bilstm(x) # [batch, seq_len, 2*hidden] attn_w = torch.softmax(self.attn(out), dim=1) # [batch, seq_len, 1] ctx = (out * attn_w).sum(dim=1) # [batch, 2*hidden] return self.fc(ctx)

几个关键点说明:第一,Conv1d默认在最后一维做卷积,所以输入要先permute成[batch, channels, seq_len];第二,padding=3配合kernel_size=7能保证卷积后序列长度不变,否则后面 BiLSTM 的时间步数会对不上;第三,Attention 权重在dim=1上 softmax,也就是每个样本内部、所有时间步之间做归一化,而不是跨样本归一;第四,ctx的维度是2*hidden,最后用一层全连接映射到horizon。整个模型参数量在 20 万到 30 万之间,CPU 上也能跑。

5. 训练、调参与五个翻车现场

5.1 训练循环与梯度裁剪

模型搭建完,训练循环本身不长,但有两个细节必须写进去:梯度裁剪和验证集早停。BiLSTM 在长序列上很容易梯度爆炸,典型表现是 loss 突然变成 NaN。梯度裁剪能直接掐掉这种风险:

import torch.optim as optim model = VMD_CNN_BiLSTM_Attention(in_channels=5, seq_len=96, horizon=1) criterion = nn.L1Loss() # MAE 作为训练目标,对尖峰不那么敏感 optimizer = optim.Adam(model.parameters(), lr=3e-4) for epoch in range(100): model.train() train_loss = 0.0 for x, y in train_loader: optimizer.zero_grad() pred = model(x).squeeze(-1) loss = criterion(pred, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss += loss.item() model.eval() val_loss = 0.0 with torch.no_grad(): for x, y in val_loader: pred = model(x).squeeze(-1) val_loss += criterion(pred, y).item() print(f"epoch {epoch:02d} | train_mae {train_loss / len(train_loader):.4f} | val_mae {val_loss / len(val_loader):.4f}")

clip_grad_norm_的max_norm=1.0表示把整个参数梯度向量的 L2 范数限制在 1.0 以内,超过就等比缩放。这个值不需要精细调,设在 0.5 到 5.0 之间都能起到保护作用。训练时盯验证集 loss,连续 10 个 epoch 不降就停,保存验证集 loss 最低的那份权重。不要等训练结束才选模型,那是事后诸葛亮。

5.2 三个必调参数:学习率、batch_size 与 seq_len

第一个是学习率。用 Adam 的话,默认 1e-3 偏激进,我一般从 3e-4 起步。如果训练 loss 前几个 epoch 下降很猛但验证集抖动厉害,马上把学习率降到 1e-4;如果 loss 几乎不动,先别调结构,把学习率提到 1e-3 试试看,学习率对收敛速度的影响比模型宽度大得多。

第二个是 batch_size。负荷数据训练集通常就几万条样本,batch_size 取 64 或 128 都行。数据量少于一万条时,batch_size 尽量别超过 64,否则每个 batch 的梯度方向太平均,模型收敛得慢且容易陷入局部平缓区。

第三个是 seq_len。这个参数必须和数据的采样周期对齐。15 分钟粒度取 96(一天),小时粒度取 24(一天)或 168(一周)。如果数据有明确的周周期性,直接拉长到一周的跨度通常比强行堆层数更有效。

5.3 五个翻车现场与排查路径

翻车 1:测试集最后一天的前几个点预测误差突然放大。现象是训练集、验证集表现都不错,滚动预测一到最后一段就开始飘。原因是 VMD 分解基于频谱计算,在序列两端会产生边界效应,模态分量在首尾出现小幅摆动。解决方法是分解前对序列两端做对称延拓,预测完再裁掉延拓段;如果只是做离线实验,更省事的办法是直接丢弃序列开头和结尾各 1~2 个采样点,别让边界脏数据进测试集。

翻车 2:K 取大了,两个中心频率几乎重合。现象是打印omega[-1]发现有两个值非常接近,模型训练速度明显变慢。原因是过分解把同一频带拆成了两个模态,模型花了大量参数拟合两条几乎一样的曲线。解决方法是把 K 减一重新分解,直到中心频率之间有明显间隔。

翻车 3:训练到一半 loss 变成 NaN。现象是 loss 曲线先正常下降,突然某一步直接变成 NaN,之后所有参数更新都失效。原因是 BiLSTM 这种循环结构对梯度幅度很敏感,多层叠加后梯度呈指数级放大。解决方法是先确认已经加了梯度裁剪;如果还出 NaN,把学习率降到 1e-4,再把归一化检查一遍,确保输入里没有 NaN 或 inf。

翻车 4:测试集指标离谱地好,一到别的时段数据就废。现象是同一份数据上验证集 MAPE 很低,换一段新数据预测结果一塌糊涂。绝大多数情况不是模型好,而是数据泄漏。检查一下归一化 scaler 是不是在整个数据集上 fit 过,或者切分时是否混入了重叠窗口。时间序列切分没有随机性,一旦出现"验证集异常好"的错觉,先怀疑数据流程,别急着怀疑模型。

翻车 5:预测值几乎是一条平线,等于训练集均值。现象是预测曲线的波动幅度很小,整体贴着均值走。原因有两个方向:一是目标直接回归绝对负荷,模型发现"输出均值"这个策略在 MAE 上损失已经够小,就懒得学细节了;二是 VMD 分解后低频分量占主导,高频分量被模型忽略。解决方法是把预测目标改成差分序列,比如预测t时刻与t-1时刻的差值,叠加上一步真实值作为最终输出,模型学起来会容易很多。

6. 向前一步:多步预测、基线与可复现的验证习惯

6.1 从单步滚动走向直接多步预测

单步模型滚动预测未来 24 个点,误差会随步数累积,越往后越不可信。工程上更推荐直接多步预测:把输出维度改成 horizon,一次预测未来一段序列。改动极小,把模型定义里的horizon设为 24,fc输出维度自动对应调整,训练时y也换成 24 个点的序列。代价是输出层的拟合难度变大,但避免了误差累积带来的"预测越来越平滑"问题。如果训练后长时间步的预测值偏保守,可以把损失函数换成 MAE+MSE 加权,或者单独提高后几步损失的权重。

6.2 验证时把工作日和休息日分开统计

负荷预测里,工作日和休息日的曲线形态差异很大,混合统计会把指标平均得"看起来还行"。我一般的做法是写一个按星期几分组的评估脚本,分别算工作日、周末、节假日的 MAPE 和峰值时段 MAPE。如果模型在工作日表现正常,但周末误差显著放大,问题通常出在特征侧,也就是模型没有有效感知"今天是星期几"。解决办法是在输入特征里加上 one-hot 的星期特征和是否节假日特征,这些特征作为额外通道拼到 VMD 分量后面,CNN 会自己学会区分。

6.3 固定随机种子、先跑基线再上组合

给模型做复现时,第一件事是固定随机种子。PyTorch 里需要同时固定 Python、NumPy、PyTorch 三处:

import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42) torch.backends.cudnn.deterministic = True

cudnn.deterministic = True这行容易被漏掉,不设的话卷积在 GPU 上的实现仍然有随机性,每次推理结果都会略有差异。除了复现,还要养成先跑基线的习惯:用同一个数据流程先训一个纯 LSTM 模型,再逐步加 CNN、BiLSTM、Attention,每一步单独记录 MAPE 的增量。这既能看到每个模块的真实贡献,也能在论文或项目汇报里给出可信的对比依据,不然你没法解释"为什么这个组合比单个 LSTM 好"。

我自己的习惯是每换一个数据集,先把基线跑通,再把 VMD 单独替换成 EMD 比较一次,确认分解方式的收益真实存在后再接入完整模型。这样一轮下来,代码和结论都不会心虚。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 5:22:50

车型识别系统实战:从分类网络选型到部署迭代

简介:面向车辆检测与识别初学者的一套 VC 工程实现,完整演示从图像载入到车型识别的全流程。系统基于 MFC 框架,通过载入背景与前景图像进行差分,依次执行二值化、开运算、去噪和填充等操作完成车辆前景提取,再借助轮廓…

作者头像 李华
网站建设 2026/10/5 5:22:23

FreeRTOS-Plus-CLI实战:嵌入式串口调试的命令行利器

产品正在现场跑着,突然偶发死机,手里只有一个串口调试助手和一台不能随便停机的设备。这时候你没法加打印重新烧录,也没法把调试器捅到板子上复现问题,唯一能做的就是对着串口发呆。这种场景我经历过不止一次,后来把 F…

作者头像 李华
网站建设 2026/10/5 5:22:22

FreeRTOS-Plus-CLI实战:从移植到调试命令的高效实现

FreeRTOS-Plus-CLI 这个组件,我最早接触是两三年前做一个小型车载网关设备的时候。那会儿固件里堆了一堆调试用的全局变量、临时开关宏、断点打桩代码,改一次配置就得重新编译烧录,一天下来三分之一的开发时间都耗在反复拆壳连线上了。后来在…

作者头像 李华
网站建设 2026/10/5 5:22:13

故障录波识图全攻略:从波形特征到故障定位的工程实战

上个月处理了一起35kV出线跳闸,班组在现场转了两圈,愣是没找到明显故障点,几个值班员围着保护屏讨论“到底哪儿出了问题”。我把故障录波调出来,波形一展开,事情一下就清楚了:B相电压在故障瞬间塌到接近零&…

作者头像 李华
网站建设 2026/10/5 5:21:54

基于Python+OpenCV的指纹识别完整实现与调参指南

简介:面向毕业设计、期末大作业与课程设计等典型场景,这份基于Python和OpenCV开发的指纹识别系统,提供了源代码、说明文档与结果截图齐备的完整项目方案。资源包共四个文件,包括一份带详细注释的Python源码、一份Word格式的设计手…

作者头像 李华
网站建设 2026/10/5 5:21:43

开源Agent框架OpenShell实战:核心机制、部署与踩坑记录

最近开源社区里冒出一个叫 OpenShell 的项目,趋势榜上热度涨得很快。第一眼以为又是个套了层壳的 AI 聊天仓库,点进去才发现是 Agent 方向的框架,而且它把“让模型自己拆任务、调工具、看结果、再决定下一步”这件事做得很完整。我前后折腾了…

作者头像 李华