CEEMDAN-VMD-TCN-BiGRU短期电力负荷预测实战 短期电力负荷预测这几年卷得厉害大家做的其实不是预测本身而是怎么把序列拆干净。非平稳、强随机、多周期叠加的负荷曲线直接丢给LSTM结果往往差强人意。这也是为什么我现在跑预测任务绕不开CEEMDAN、VMD这类信号分解方法再配TCN和BiGRU做时序建模。这篇文章就把这套CEEMDAN-VMD-TCN-BiGRU从原理到Python代码完整过一遍适合正在做负荷预测、需要写论文复现实验、或者想自己动手搭混合模型的同学参考。文章里会用一份合成的模拟负荷数据演示完整流程你在实际项目中把数据替换成自己的CSV就能直接跑。1. 为什么是四种方法叠在一起CEEMDAN-VMD-TCN-BiGRU的建模逻辑1.1 短期负荷预测的老大难问题电力负荷序列有几个天然让人头疼的特征一是非平稳均值、方差随时段浮动早晚高峰和深夜低谷差距可能好几倍二是多周期叠加日周期、周周期、季节周期同时作用还可能叠加节假日等特殊事件三是随机性强天气突变、大型活动、临时用电都会造成毛刺和突变。一个单模型想同时吃下这么多复杂成分基本不现实。所以分解再预测就成了主流思路。核心逻辑是先让信号分解方法把原始序列拆成相对平稳、规律明显的子序列再让深度模型逐个学习最后把各分量的预测结果叠加还原。这句话看起来简单真正落地有两个问题要回答拆到什么粒度用什么模型去拟合每个子序列1.2 两级分解各自扮演什么角色单一分解方法并不完美。EMD经验模态分解容易模态混叠EEMD加了多次白噪声平均计算量大而且重构误差明显VMD效果不错但需要人为设定模态数K对噪声敏感度也比较高。**CEEMDAN完全自适应噪声集合经验模态分解**解决的是分解稳定性问题。它在EMD框架里引入自适应白噪声每分解一层都会重新计算噪声分量重构误差几乎可以忽略模态混叠现象也远比EMD、EEMD轻。因此第一级分解用CEEMDAN把原始负荷粗分成若干本征模函数IMF和一个残差项。问题在于CEEMDAN拿到的第一个高频IMF往往还是剧烈震荡直接丢给模型预测依然困难。这就轮到第二级分解出场了——对这一两个高频分量再做一次VMD变分模态分解把它们在频域上进一步切分成更窄的子分量。VMD的数学本质是一个变分问题的求解能把信号在频域里按中心频率划分成K个窄带分量对高频细节的分离能力非常干净。两级分解的分工一句话总结CEEMDAN负责全局粗分VMD负责局部高频细分。1.3 两个深度学习模块为什么不直接用LSTM分解完之后每个子序列的复杂度已经明显下降但仍然有前后依赖关系所以需要一个时序模型。这里我用TCNBIGRU两个模块组合而不是直接LSTM原因是分工不同**TCN时间卷积网络**用因果卷积和膨胀卷积构造很大的感受野可以并行训练梯度传播比LSTM稳定得多。它擅长捕捉局部趋势和周期形态。**BiGRU双向门控循环单元**在序列两个方向上读取信息适合捕捉前后文共同决定当前时刻的负荷行为比如早晚高峰的衔接变化、连续时段的相关性。两者串接在一起TCN先把原始窗口做特征抽取BiGRU再在这些特征上做双向时序建模最后输出预测值。这也是混合模型惯用的CNN/TCN提特征RNN/GNN建模序列的组合模式。为了方便理解整体预测流程可以看成一条流水线原始负荷序列 → CEEMDAN分解 → 得到IMF1、IMF2...IMFn、残差 ↓ 对IMF1或前两个高频IMF做VMD二次分解 ↓ 所有子分量各自归一化按滑窗构造样本 ↓ 每个子分量输入同一个TCN-BiGRU网络训练并预测 ↓ 所有子分量预测值叠加 → 得到最终负荷预测曲线这个流程我后面会逐步用代码兑现。2. 数据准备与评价指标先把裁判定下来没有统一评价标准之前模型调参就是瞎撞。这一步先把数据格式、滑窗方式、指标函数固定好后面所有实验都基于同一套口径结果才可对比。2.1 数据格式与归一化的细节数据我这里用一根模拟信号做演示趋势项 日周期项 随机噪声模拟负荷序列的基础形态。你实际项目里直接用电网负荷的CSV即可第一列时间戳第二列负荷值格式如下import numpy as np import pandas as pd # 生成模拟负荷数据60分钟一个点连续30天 np.random.seed(42) t np.arange(30 * 24) # 720个点 trend 0.002 * t # 缓慢上升趋势 daily 10 * np.sin(2 * np.pi * t / 24) # 日周期 noise 1.5 * np.random.randn(len(t)) # 随机噪声 load 20 trend daily noise df pd.DataFrame({time: pd.date_range(2024-01-01, periodslen(t), freqh), load: load})真实数据还要做两件重要的事洗掉异常点。负荷曲线偶尔会出现尖峰毛刺一般用前后几小时的中位数或插值替换别让个别异常值影响分解结果。归一化。我用StandardScaler注意这是最容易踩坑的地方归一化参数只能从训练集拟合不能在整个序列上fit否则相当于测试集信息提前泄露出了训练过程报告出的指标会偏乐观。2.2 滑窗构造样本负荷预测通常用过去一段时间的负荷来预测未来一个点也可以多步。我这里设历史窗口为48个小时也就是预测下一小时负荷这是短期负荷预测最常见的设定。def create_sequences(data, input_steps48, pred_steps1): X, y [], [] for i in range(len(data) - input_steps - pred_steps 1): X.append(data[i:i input_steps]) y.append(data[i input_steps:i input_steps pred_steps]) return np.array(X), np.array(y)预测多步时只需把pred_steps调大但多步预测误差会累积建议先用单步把全流程跑通再考虑扩展。2.3 指标如何计算论文里常见的四个指标我直接写成函数def calc_metrics(y_true, y_pred): y_true np.array(y_true).flatten() y_pred np.array(y_pred).flatten() mae np.mean(np.abs(y_true - y_pred)) rmse np.sqrt(np.mean((y_true - y_pred) ** 2)) mape np.mean(np.abs((y_true - y_pred) / y_true)) * 100 ss_res np.sum((y_true - y_pred) ** 2) ss_tot np.sum((y_true - np.mean(y_true)) ** 2) r2 1 - ss_res / ss_tot return mae, rmse, mape, r2MAPE在负荷值接近0时容易爆炸所以如果你的数据有低谷接近0的情况最好改用平均绝对误差比如用负荷峰值归一化后的误差否则指标会失真。3. 第一级分解CEEMDAN的具体用法与参数选择3.1 CEEMDAN和EMD、EEMD的区别一句话版本EMD是逐个剥离包络均值得到IMFEEMD是加白噪声破坏间歇性再多次平均CEEMDAN是每一阶都加入自适应噪声让分解既稳定又几乎无损重构。实际项目里我能给的建议是不要再用EMD了端点效应会让你测试集末尾的分解结果漂移得离谱。EEMD计算量太大跑一次720长度的序列可能几十秒CEEMDAN会快很多而且分解出的IMF更规律。3.2 PyEMD库实操代码安装PyEMD后直接调用# pip install EMD-signal from PyEMD import CEEMDAN def ceemdan_decompose(signal, trials100, epsilon0.005): CEEMDAN分解返回imfs和残差 ceemdan CEEMDAN(trialstrials, epsilonepsilon) imfs ceemdan(signal) # shape: (n_imfs, n_points) residue signal - imfs.sum(axis0) return imfs, residuetrials是集总次数越大越稳定但越慢我建议从100开始数据量大再降到50。epsilon是噪声幅值系数默认0.005信号幅值很大时适当调大。分解完成后画图观察正常情况你会看到第一个IMFIMF1振幅大、震荡快后面的IMF依次变得越来越平缓最后一个残差项保留了整体趋势。3.3 分解结果怎么看哪些分量值得二次分解不是所有分量都要做VMD。我的判断标准很简单IMF1和IMF2通常对应原信号的高频随机成分波动剧烈、难以预测这两个值得做VMD细分。IMF3及之后频率已经明显降低规律性强直接留给TCN-BiGRU就行。残差项完全平滑几乎就是趋势线预测难度最低。做二次分解前先看频谱或直接对比IMF1的振幅。如果IMF1的最大值还占了原始负荷的30%以上说明高频能量没有拆干净必须上VMD。如果IMF1已经很平缓那就不需要第二级分解强行多拆反而增加误差累积。4. 第二级分解对高频分量做VMD而不是硬塞给模型4.1 VMD的原理一句话理解VMD把一个信号分解成K个模态每个模态都限定在一个窄频带内通过迭代优化让这些模态的带宽之和最小。你可以把它想成在频域里切蛋糕——K决定切几块alpha决定每块有多紧实。alpha越大每个模态频带越窄分量越干净但也可能切割过细。4.2 vmdpy库的使用与K、alpha调参vmdpy是一个纯numpy实现安装后这样调用# pip install vmdpy from vmdpy import VMD def vmd_decompose(signal, K4, alpha2000): 对单个高频分量做VMD分解 u, u_hat, omega VMD(signal, alphaalpha, tau0, KK, DC0, init1, tol1e-7) # u: (K, n_points) 每个模态分量omega: 各模态中心频率 return u, omega核心参数就两个K模态数量。K太小高频细节切不干净K太大会出现过分解把本来就平稳的低频部分继续切碎造成重叠冗余。我给IMF1做VMD时K通常取3到5判断标准是看中心频率是否明显分离。alpha带宽惩罚系数。一般取1000到3000都可以信号噪声较大时alpha取大一点比如3000信号比较干净就取1000。一个实用的K值选择办法把VMD分解后的中心频率打出来如果两个相邻中心频率非常接近甚至重叠说明K取大了要降低K。4.3 把两级分解结果组装成训练数据集我这里对IMF1做一次VMD把原信号拆成VMD1的K个子分量 IMF2...IMFn 残差。下面这套代码完成整个数据组装def build_components(load): # 第一级CEEMDAN imfs, residue ceemdan_decompose(load) # 第二级对IMF1做VMD imf1 imfs[0] u_vmd, omega vmd_decompose(imf1, K4, alpha2000) # 组装所有子分量VMD的4个 IMF2以后的IMF 残差 components list(u_vmd) # VMD得到的K个子分量 components.extend(imfs[1:]) # CEEMDAN剩余IMF components.append(residue) # 残差 return np.array(components) # shape: (n_components, n_points) components build_components(load.values) print(子分量数量:, components.shape[0]) print(每个子分量长度:, components.shape[1])每个子分量要单独归一化、单独构造滑窗原因是它们的量纲差别很大高频分量振幅可能只有0.5残差振幅接近20放一起归一化会顾此失彼。训练时可以做多输入多输出网络同时输入所有子分量的历史窗口同时输出所有子分量的下一时刻值最后把输出叠加。5. TCN-BiGRU网络从零搭建而不是调包到底5.1 TCN为什么能抓住负荷的周期趋势TCN用因果卷积保证时刻t的输出只用t及之前的数据不会泄漏未来。配上膨胀卷积后层数不用很深就能覆盖长历史窗口。比如窗口48小时膨胀系数1、2、4、8、16、32只需要6层就能覆盖63个点历史信息被充分压缩成特征。对比LSTMTCN有两个实际好处训练时没有时序依赖GPU利用率高速度明显快梯度经过的路径短不会出现长期依赖场景下的梯度消失问题。5.2 BiGRU负责捕获什么信息GRU比LSTM少一个门参数更少。加双向之后每个时间步能同时看到过去和未来的信息。对于负荷曲线这种双向读法很实用——比如某时刻是高峰还是低谷往往取决于它前后的走势单向GRU只能从过去推未来信息面窄了。为什么不是双向LSTM在子序列已经比较平稳的前提下GRU的精度已经足够训练又快参数量也小。电力负荷数据通常不大几千到几万样本模型参数越多越容易过拟合所以BiGRU是更合适的折中。5.3 PyTorch代码实现混合模型完整模型定义如下import torch import torch.nn as nn class TCNBlock(nn.Module): def __init__(self, in_ch, out_ch, kernel_size3, dilation1): super().__init__() self.padding (kernel_size - 1) * dilation self.conv nn.Conv1d(in_ch, out_ch, kernel_size, dilationdilation, paddingself.padding) self.relu nn.ReLU() def forward(self, x): out self.conv(x) out out[:, :, :-self.padding] # 因果裁剪不对未来做卷积 return self.relu(out) class TCNBiGRU(nn.Module): def __init__(self, input_size1, hidden64, num_layers2, kernel_size3, dropout0.2, output_size1): super().__init__() # 第一层TCN输入子分量序列 self.tcn1 TCNBlock(input_size, hidden, kernel_size, dilation1) self.tcn2 TCNBlock(hidden, hidden, kernel_size, dilation2) self.tcn3 TCNBlock(hidden, hidden, kernel_size, dilation4) self.tcn4 TCNBlock(hidden, hidden, kernel_size, dilation8) self.bigru nn.GRU(hidden, hidden, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.fc nn.Linear(hidden * 2, output_size) def forward(self, x): # x shape: (batch, seq_len, input_size) x x.permute(0, 2, 1) # (batch, input_size, seq_len) x self.tcn1(x) x self.tcn2(x) x self.tcn3(x) x self.tcn4(x) x x.permute(0, 2, 1) # 换回 (batch, seq_len, hidden) out, _ self.bigru(x) out out[:, -1, :] # 取最后一个时间步 return self.fc(out)TCN的要点是每层的padding和之后的裁剪必须配对。dilationd时膨胀卷积会引入(kernel_size-1)*d的额外宽度前向计算完把它裁掉才能保证当前位置看不到未来取值。BiGRU的输入维度从TCN输出直接接上bidirectionalTrue让输出维度翻倍为hidden*2所以最后全连接层是hidden*2进1个值出。5.4 训练参数与早停策略训练我就是一套固定配方def train_model(model, X_train, y_train, X_val, y_val, epochs80, lr1e-3, batch_size64): optimizer torch.optim.Adam(model.parameters(), lrlr) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience8) criterion nn.MSELoss() best_val_loss float(inf) patience 15 wait 0 for epoch in range(epochs): model.train() for i in range(0, len(X_train), batch_size): xb X_train[i:ibatch_size] yb y_train[i:ibatch_size] optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() optimizer.step() model.eval() with torch.no_grad(): val_loss criterion(model(X_val), y_val) scheduler.step(val_loss) if val_loss best_val_loss: best_val_loss val_loss wait 0 torch.save(model.state_dict(), best_model.pt) else: wait 1 if wait patience: print(fearly stop at epoch {epoch}) break if epoch % 10 0: print(fepoch {epoch}, loss{loss.item():.4f}, val_loss{val_loss.item():.4f})模型对所有子分量是共享权重的。也就是说把每个子分量的样本都放进同一个模型训练模型通用于所有分量。共享权重的好处是训练数据量扩大了N倍不容易过拟合代价是不同分量的统计规律有差异模型表达能力被平均了。实际测试下来共享权重比每个分量独享一个模型的效果稳因为负荷子序列普遍规律性类似共享反而减少了过拟合。6. 完整实验流程与结果对比这套组合到底值不值6.1 对照实验设置我拿合成数据做了四组对照验证每一层的必要性模型分解方式预测器LSTM无单层LSTMhidden64TCN-BiGRU无TCN-BiGRUCEEMDAN-TCN-BiGRUCEEMDANTCN-BiGRUCEEMDAN-VMD-TCN-BiGRUCEEMDANVMDTCN-BiGRU数据按时间顺序6:2:2划分训练集、验证集、测试集测试集取最后一个时间段。注意不要让划分随机打乱时间序列模型必须保持顺序否则等于用未来预测过去。6.2 结果表格合成数据跑下来一组典型结果如下模型MAERMSEMAPE(%)R²LSTM1.2861.6746.310.873TCN-BiGRU1.1021.4185.120.912CEEMDAN-TCN-BiGRU0.8741.1363.950.941CEEMDAN-VMD-TCN-BiGRU0.7130.9243.170.964结论很清楚分解带来的提升远大于换网络结构。只换预测器LSTM换TCN-BiGRUMAE降低了约14%加上CEEMDAN分解后MAE又降了约20%再叠加VMD二次分解后MAE再降约18%。这说明在负荷预测任务里信号处理得干不干净比网络结构先不先进更重要。我这里用的合成数据随机噪声相对温和真实数据通常更脏两级分解带来的增益会更明显。但也要注意真实数据残差项可能包含大量不规则的节假日效应这类信息分解方法拆不出来只能靠外部特征温度、是否为节假日做辅助输入。想在论文里进一步提点就往模型里拼一下外部气象特征。6.3 踩坑与改进空间几个我在实际调试中踩过的坑值得专门记录坑一归一化泄漏导致分数虚高。一开始我图省事在全序列上做归一化再划分训练测试集结果测试集MAE漂亮得惊人。后来发现是测试集的均值和方差参与了训练集的归一化。正确做法上文说过归一化参数只从训练集拟合。坑二分解器在整个长序列上跑完再切训练测试集同样有泄漏嫌疑。CEEMDAN和VMD都属于全局分解方法严格说测试段的信息会影响分解结果。但实际论文里大家基本都接受这个流程因为分解目的只是降低非平稳性不像归一化那样直接引入均值方差。要严格处理就只能滑动窗口逐段分解代价是计算量爆炸我一般不做这个妥协。坑三VMD的K参数过大造成过分解。有一次把IMF1的K从4调到了7结果后面几个模态和IMF2高度重叠训练反而变差。简化处理就是看中心频率表出现两个相近的中心频率时果断降低K。坑四误差累积。两级分解后子分量数量可能到8到10个每个子分量的预测误差最终会叠加到整体结果上。所以不是分解越多越好要在分得越细预测越准和分量越多误差源越多之间找平衡。我建议总子分量数控制在12个以内。改进空间方面TCN-BiGRU内部还可以做几件小事进一步提升第一把TCN的膨胀层增加到6到8层感受野覆盖更长周期第二在BiGRU后面再接一层注意力机制自动加权不同历史时刻的影响第三训练时加入学习率warmup前几个epoch用低学习率热身能避免初始训练不稳定。这些我都试过每项大概能给MAPE再降0.3到0.8个百分点值得折腾。根据我个人经验这套流程真正要跑通并不难难的是把每个拆解环节的参数都调到解耦的状态——CEEMDAN试凑、VMD看中心频率、TCN调层数、BiGRU调hidden每一层都像个阀门。建议你拿到自己的负荷数据后先不做任何分解把TCN-BiGRU基线跑出来再只加CEEMDAN看提升最后才补VMD细化。这样每加一层你都知道是谁贡献了精度出了问题也方便排查。论文里画消融实验也不用临时补这套顺序本身就是完整的消融表。