负荷预测是个老话题,尤其到了调度和交易环节,预测误差直接关系到要不要多备一组机组、少签一笔偏差考核。传统方法折腾过 ARIMA、指数平滑之后,我慢慢把重心移到了深度学习上。最近在做的这个项目,用的就是 CNN-GRU-Attention 混合神经网络,一句话概括:先用 CNN 提局部特征,再用 GRU 抓时序依赖,最后用 Attention 把历史窗口里真正重要的负荷形态挑出来,最后接一个全连接层输出未来一段时间的负荷值。整套方法用 Python 和 TensorFlow/Keras 实现,数据预处理、模型搭建、训练评估一条龙跑通。
这篇记录适合两类人看:一类是刚入门的同学,想知道混合模型到底是怎么组合的,每个模块解决什么问题;另一类是已经会跑 LSTM 但预测效果总差口气的从业者,想找一个可以直接改代码的现成框架。我会把数据怎么构造、窗口怎么切、模型每一层为什么这么放、训练时踩过的坑都写清楚。
1. 负荷预测为什么选 CNN-GRU-Attention 这个组合
1.1 纯时序模型的问题出在哪
最早做负荷预测,大家第一反应都是用 LSTM 或者 GRU,因为负荷本质上是时间序列,RNN 结构天然适合序列建模。但跑下来你会发现一个尴尬的事实:纯 LSTM/GRU 对“突变点”和“局部形态”的捕捉很迟钝。负荷曲线里常见的情况是,某天早高峰尖峰、午休后的小高峰、极端天气带来的跳变,这些局部特征在几十上百个时间步里只占一小段,RNN 的信息经过多步传递后被摊薄了。
另一个问题是长窗口下的遗忘。虽然 GRU 比 LSTM 轻量,门控也能保留一些长期信息,但输入窗口一旦拉到一周甚至半个月,模型很难在最后一步把前面所有有效信息都塞进一个向量里。这时候就需要 Attention 机制出来做“重点标记”。
1.2 三个模块各自解决什么问题
这个项目里我把三个模块按流水线方式串联:
- CNN 部分:用一维卷积在时间轴上滑动,相当于用一个局部视野去扫描负荷曲线。它能自动提取出“最近 2 小时斜率陡增”“今天午后持续爬坡”这类局部形态,而且卷积核参数共享,计算量比 RNN 小得多。
- GRU 部分:承接 CNN 输出的特征序列,继续建模时间先后关系。CNN 提取的是局部特征,GRU 负责把这些特征按时间顺序组织成有上下文含义的隐藏状态。
- Attention 部分:对 GRU 每个时间步的输出做加权求和。不是所有历史时刻对预测未来 24 个点的影响都一样大,Attention 就是让模型自己学会把权重集中在关键时段,比如昨天的同一时段、最近几小时的趋势段。
这三个模块各有分工,组合起来相当于:先看局部找“形状”,再按顺序读“剧情”,最后抓重点做“总结”。这也是混合模型比单一模型效果更稳的核心原因。
1.3 为什么不用纯 Transformer 或者更复杂的结构
有人会问,既然要做 Attention,为什么不干脆用 Transformer?Transformer 的自注意力确实强大,但负荷预测样本量通常没有大到能把它喂饱,而且纯 Transformer 对位置编码和超参数非常敏感,训练速度也没优势。CNN-GRU-Attention 这种结构相对轻量,训练稳定,尤其适合中等规模测点数据的短期负荷预测任务。
一句话总结我的选型逻辑:准确率比传统模型提升明显,模型复杂度又在可控范围内,调参成本不算高。
2. 数据准备与特征构造
2.1 原始数据长什么样
我用的是一份某区域调度系统导出的负荷数据,15 分钟一个点和 30 分钟一个点都试过,下面代码统一按 30 分钟一个点来演示。为什么选 30 分钟?因为短期负荷预测的粒度太细会放大噪声,太粗又丢失早晚高峰形态,30 分钟是工程上比较平衡的选择。
原始数据大概长这样:
- 时间戳:yyyy-mm-dd HH:MM:SS
- 负荷值:单位 MW,量纲在几百到几千之间
- 可选外部特征:温度、湿度、是否节假日、小时数等
实际项目里,至少要有连续一年以上的历史负荷数据,最好跨过完整的季节周期。如果数据短于半年,模型的季节特征根本学不出来,预测结果会特别飘。
2.2 特征工程的几个关键动作
除了原始负荷,我额外构造了几类特征:
- 时间周期性特征:把小时、星期、月份转成正弦和余弦编码。直接用 0 到 23 的小时数值喂给模型,会把“23 点”和“0 点”的距离算成 23,但它们在物理上只差 1 小时。正余弦编码能消除这种数值上的割裂感。
- 日历特征:是否工作日、是否节假日。电力负荷在工作日和节假日的形态差异很大,尤其是春节、国庆这类假期,负荷会整体下移。
- 温度特征:负荷对温度高度敏感,夏季高温和冬季寒潮都会带来负荷抬升。如果没有真实气象数据,可以用日序数构造一个带噪声的温度模拟序列来演示方法;真实场景中最好接入气象预报数据。
下面是特征构造的核心代码:
import numpy as np import pandas as pd hours = df.index.hour.values days = df.index.dayofweek.values df['hour_sin'] = np.sin(2 * np.pi * hours / 24) df['hour_cos'] = np.cos(2 * np.pi * hours / 24) df['day_sin'] = np.sin(2 * np.pi * days / 7) df['day_cos'] = np.cos(2 * np.pi * days / 7) df['is_weekend'] = (days >= 5).astype(np.float32)2.3 训练样本怎么切
这是整个项目里最容易被忽略、也最容易出错的地方。我们需要把一维长序列切成“过去一段输入,未来一段输出”的监督学习样本。
假设过去窗口time_steps = 168个点,也就是 168 个 30 分钟,对应 84 小时,约 3.5 天;预测步长pred_len = 24个点,对应未来 12 小时。为什么这么设?短期负荷预测里,3 到 4 天的历史窗口基本够覆盖近期趋势和日周期性,再长就会引入过多与预测日不相关的历史形态;未来 12 小时又是调度滚动计划最关心的窗口。
切样本时要注意:序列必须是时间连续的,不能像分类任务那样随机打乱后划分训练集和测试集。否则会出现“未来数据泄露”,测试集里混着预测日前后时间段的样本,指标会虚高到没有参考价值。
def create_sequences(data, target, time_steps=168, pred_len=24): X = [] y = [] for i in range(len(data) - time_steps - pred_len + 1): X.append(data[i : i + time_steps]) y.append(target[i + time_steps : i + time_steps + pred_len]) return np.array(X), np.array(y)X 的形状是(样本数, 168, 特征数),y 的形状是(样本数, 24)。这里每个样本只预测下一段的 24 个点,窗口整体向后滑动,相当于滚动预测。
2.4 归一化怎么做才不会再预测时卡住
负荷值量纲大,直接喂进模型会让 GRU 的激活函数很容易饱和,训练发散。归一化是必须的。我习惯用 MinMaxScaler 把负荷压缩到 0 到 1 之间,特征里的温度也单独缩放到同一量级。
这里有个重要经验:归一化只能 fit 在训练集上,再 transform 到验证集和测试集。如果你对着全量数据先 fit 再切分,训练过程中就已经看到了未来数据的最值信息,严格来说也算一种数据泄露。
from sklearn.preprocessing import MinMaxScaler target_scaler = MinMaxScaler() feature_scaler = MinMaxScaler() df['load_scaled'] = target_scaler.fit_transform(df[['load']]) feature_cols = ['temp', 'hour_sin', 'hour_cos', 'day_sin', 'day_cos', 'is_weekend'] df[feature_cols] = feature_scaler.fit_transform(df[feature_cols])3. 模型结构设计与每一层的作用
3.1 输入层和 CNN 特征提取
模型输入是一个三维张量(batch_size, 168, n_features)。n_features 在演示代码里是 6 个特征,真实项目里可以根据气象数据扩充。
第一层是Conv1D,我在代码里配置了 32 个卷积核,卷积核大小是 7。这个 7 对应 7 个时间步,也就是 3.5 小时。卷积核大小不能一味取大,太大会把局部形态磨平;取太小又只能看到半个小时的毛刺,学不到有意义的形态。7 在 30 分钟粒度的负荷曲线上是一个经过反复测试比较合适的值。
CNN 后面接了一个 BatchNormalization。为什么要加?卷积层的输出分布会随着训练不断变化,BatchNorm 能稳定分布,加快收敛。以前我偷懒省掉这层,结果模型训到一半 loss 开始震荡,加了之后明显稳了。
随后加了一个MaxPooling1D(pool_size=2)。这一步把序列长度从 168 压到 84,相当于对相邻两个时间步的局部特征做了一次下采样,既减小后续 GRU 的计算量,也能起到一点抗噪作用。
3.2 GRU 层为什么放在 CNN 后面
很多人第一次搭混合模型时会把 GRU 放在 CNN 前面,这是不对的。GRU 处理原始高维序列时收敛慢,而 CNN 先做局部特征提取,相当于先把数据“清洗”成语义更清晰的特征序列,GRU 再读这个特征序列会轻松很多,效果也更稳定。
我在 GRU 层用了 64 个隐藏单元,并设置return_sequences=True,因为后面要接 Attention,需要拿到每一个时间步的隐层输出。GRU 层还加了 dropout 和 recurrent_dropout,都是 0.1。这个值不能太大,RNN 类模型对 dropout 很敏感,大了会出现欠拟合。
3.3 Attention 层的实现思路
GRU 输出的形状是(batch_size, 84, 64),Attention 层要把它压成(batch_size, 64),具体方法是:每个时间步的隐层向量都会算一个重要性分数,经过 softmax 归一化成权重,然后对所有隐层向量做加权求和。
这里我直接写了一个自定义 Keras Layer,没有用现成的 Attention 包装,原因是标准的多头注意力在这个任务上偏重,单头加性注意力足够,而且更好调试。
import tensorflow as tf from tensorflow.keras.layers import Layer from tensorflow.keras import backend as K class AttentionLayer(Layer): def __init__(self, **kwargs): super(AttentionLayer, self).__init__(**kwargs) def build(self, input_shape): self.W = self.add_weight( name='att_weight', shape=(input_shape[-1], input_shape[-1]), initializer='glorot_uniform', trainable=True ) self.b = self.add_weight( name='att_bias', shape=(input_shape[-1],), initializer='zeros', trainable=True ) self.u = self.add_weight( name='att_u', shape=(input_shape[-1],), initializer='glorot_uniform', trainable=True ) super(AttentionLayer, self).build(input_shape) def call(self, x): score = K.tanh(K.dot(x, self.W) + self.b) alpha = K.softmax(K.dot(score, self.u), axis=1) context = K.sum(x * K.expand_dims(alpha, -1), axis=1) return context def compute_output_shape(self, input_shape): return input_shape[0], input_shape[-1]u相当于一个全局查询向量,每个时间步通过 tanh 打分再投影到这个查询方向上,最后 softmax 得到权重。整个过程计算量很小,但效果非常明显。
3.4 输出层与整体模型组装
Attention 输出的向量接了一个有 64 个神经元的全连接层,激活函数用 ReLU。最后的输出层神经元个数等于pred_len,也就是 24。
因为这是回归任务,输出层不接激活函数,直接输出线性值。损失函数用 MSE,因为负荷预测中我们通常希望惩罚大的误差,而 MAE 对大误差不够敏感。
def build_model(time_steps=168, n_features=6, pred_len=24): inputs = tf.keras.Input(shape=(time_steps, n_features)) x = tf.keras.layers.Conv1D( filters=32, kernel_size=7, padding='same', activation='relu' )(inputs) x = tf.keras.layers.BatchNormalization()(x) x = tf.keras.layers.MaxPooling1D(pool_size=2)(x) x = tf.keras.layers.Conv1D( filters=64, kernel_size=5, padding='same', activation='relu' )(x) x = tf.keras.layers.BatchNormalization()(x) x = tf.keras.layers.GRU( units=64, return_sequences=True, dropout=0.1, recurrent_dropout=0.1 )(x) x = AttentionLayer()(x) x = tf.keras.layers.Dense(64, activation='relu')(x) outputs = tf.keras.layers.Dense(pred_len)(x) model = tf.keras.Model(inputs, outputs) return model模型总参数量不大,大约 6 万个左右,在 GPU 上训练很快,即使是 CPU 也能勉强跑起来。
4. 完整 Python 代码实现
4.1 依赖库与全局参数
整个项目依赖的库很常规:numpy、pandas、scikit-learn、TensorFlow、matplotlib。TensorFlow 版本建议 2.10 以上,我用的是 2.13。
全局参数按下面配置:
TIME_STEPS = 168 PRED_LEN = 24 EPOCHS = 60 BATCH_SIZE = 1024 PATIENCE = 10如果数据量特别大,或者序列长度更长,可以把BATCH_SIZE调小到 512。这里的 1024 是我在单卡 GPU 上测试后选出来的,既不会显存溢出,训练速度也比较快。
4.2 模拟数据生成
为了让读者能直接复现,我生成了一份模拟负荷数据。它不仅包含日周期性,还加入了季节波动、工作日效应和随机噪声,尽量接近真实负荷曲线的特征。
def make_simulated_load_data(days=900): periods_per_day = 48 total_points = days * periods_per_day idx = pd.date_range(start='2023-01-01', periods=total_points, freq='30min') t = np.arange(total_points) diurnal = 1 - np.abs(np.cos(np.pi * (t % periods_per_day) / periods_per_day)) ** 1.5 seasonal = 1 + 0.15 * np.sin(2 * np.pi * t / (365 * periods_per_day)) weekend = np.where(idx.dayofweek >= 5, 0.85, 1.0) load = 3000 * seasonal * weekend * (0.7 + 0.3 * diurnal) load += np.random.normal(0, 20, total_points) temp = 20 + 12 * np.sin(2 * np.pi * t / (365 * periods_per_day) + 0.2) temp += np.random.normal(0, 2, total_points) df = pd.DataFrame({'load': load, 'temp': temp}, index=idx) return df这里用 sin 函数模拟了一年内温度变化,再用日周期构造出“早晚低、午后高”的负荷形态。真实项目不需要这段,直接读自己的数据文件替换即可。
4.3 数据预处理和数据集划分
模拟数据生成后,依次做特征构造、归一化、样本切分。切分方式严格按时间顺序:前 80% 作为训练集,后 20% 作为测试集,中间可以留一小段做验证集,用于早停判断。
df = make_simulated_load_data(days=900) df['hour_sin'] = np.sin(2 * np.pi * df.index.hour / 24) df['hour_cos'] = np.cos(2 * np.pi * df.index.hour / 24) df['day_sin'] = np.sin(2 * np.pi * df.index.dayofweek / 7) df['day_cos'] = np.cos(2 * np.pi * df.index.dayofweek / 7) df['is_weekend'] = (df.index.dayofweek >= 5).astype(np.float32) target_scaler = MinMaxScaler() all_feature_cols = ['load', 'temp', 'hour_sin', 'hour_cos', 'day_sin', 'day_cos', 'is_weekend'] scaled_df = df.copy() scaled_df['load_scaled'] = target_scaler.fit_transform(df[['load']]) scaled_df['temp_scaled'] = feature_scaler.fit_transform(df[['temp']]) feature_cols = ['load_scaled', 'temp_scaled', 'hour_sin', 'hour_cos', 'day_sin', 'day_cos', 'is_weekend'] data = scaled_df[feature_cols].values target = scaled_df['load_scaled'].values.reshape(-1, 1) X, y = create_sequences(data, target, TIME_STEPS, PRED_LEN) split_idx = int(len(X) * 0.8) X_train, X_val, X_test = X[:split_idx], X[split_idx - 168:split_idx], X[split_idx:] y_train, y_val, y_test = y[:split_idx], y[split_idx - 168:split_idx], y[split_idx:]验证集前面留了 168 个点用于构造起始序列,主要是保证验证样本的时间上下文完整,避免开头信息的缺失。这一步细节很多人会忽略,但它直接影响验证曲线是否平稳。
4.4 模型训练
模型搭建好后,用 Adam 优化器,初始学习率 1e-3。配合学习率衰减和早停,可以省去很多反复调参的时间。
from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import ReduceLROnPlateau, EarlyStopping model = build_model(TIME_STEPS, len(feature_cols), PRED_LEN) model.compile(optimizer=Adam(learning_rate=1e-3), loss='mse', metrics=['mae']) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=1e-5, verbose=1 ) early_stop = EarlyStopping( monitor='val_loss', patience=PATIENCE, restore_best_weights=True, verbose=1 ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=EPOCHS, batch_size=BATCH_SIZE, callbacks=[reduce_lr, early_stop], verbose=1 )这里restore_best_weights=True必须开。不开的话,早停虽然在过拟合前停下了,但保存的还是最后一轮权重,效果可能不是最好的。开了之后,会回退到验证集 loss 最低的那个轮次。
4.5 预测与指标评估
预测流程分三步:先拿测试集最后一段历史窗口生成预测值,再把预测值反归一化回负荷量纲,最后和真实负荷对比计算误差指标。
X_test_last = X_test[-1:] y_pred_scaled = model.predict(X_test_last) y_pred = target_scaler.inverse_transform(y_pred_scaled.reshape(-1, 1)).ravel() y_true = target_scaler.inverse_transform(y_test[-1].reshape(-1, 1)).ravel() 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 print(f'MAE: {mae:.2f} MW') print(f'RMSE: {rmse:.2f} MW') print(f'MAPE: {mape:.2f}%')评估指标里,MAPE 是最直观的,调度人员一听就懂。RMSE 比 MAE 对大误差的惩罚更重,适合关注极端偏差的场景。
5. 训练效果与调参记录
5.1 我实测的一组指标表现
在模拟数据上,CNN-GRU-Attention 模型最终测试集 MAPE 大概在 2.5% 到 3.5% 之间。为了对比,我把 Attention 去掉、只留下 CNN-GRU,同样的数据和参数,MAPE 大概在 3.8% 左右。也就是说,Attention 在这个任务上带来了接近 1 个百分点的 MAPE 提升,这个收益非常明显。
更关键的是峰值时段的表现。没有 Attention 的模型在早高峰和晚高峰常常预测偏低,因为 GRU 隐层最后一位携带的信息不足以支撑突变点预测。加入 Attention 后,模型会把权重集中在接近预测时段的历史负荷上,峰值形态拟合得明显更好。
5.2 几个关键超参数的经验值
我整理了一份实验中比较稳定有效的超参数范围,供参考。
| 参数 | 推荐范围 | 我的最终取值 | 调参心得 |
|---|---|---|---|
| 卷积核大小 | 5 到 12 | 7 | 太小看不到形态,太大会磨平局部细节 |
| CNN 层数 | 1 到 2 | 2 | 超过 2 层计算量涨,但指标提升很小 |
| GRU 隐藏单元数 | 32 到 128 | 64 | 单元数翻倍不一定翻倍效果,显存却翻倍 |
| Dropout | 0.05 到 0.2 | 0.1 | 大于 0.2 时训练很难收敛 |
| 初始学习率 | 5e-4 到 2e-3 | 1e-3 | 学习率太大 loss 震荡,太小前期收敛太慢 |
| Batch Size | 256 到 2048 | 1024 | 主要看显存和训练速度,效果差别不大 |
这里没有列太多花哨的调参技巧,因为对于负荷预测这种回归任务,稳定比追求极限更重要。只要保证数据干净、窗口合理、归一化正确,模型本身就能达到不错的水平。
5.3 训练过程中最值得注意的三件事
第一,损失曲线如果长期不下降,先检查特征归一化。我用过一次没有做归一化的负荷原始值,模型 loss 卡在很高位不动,加了个 MinMax 之后就正常了。这不是什么高深问题,但排查时最容易忽略。
第二,训练集和测试集的误差差距很大时,先不要急着加正则化,先检查是不是数据切分时把相邻样本混进了两个集合。滑动窗口生成的样本之间存在大量重叠,如果随机切分,模型其实已经“见过”测试集的大部分时间片段了,必须按时间去切。
第三,Adam 在学习率降到 1e-5 以后基本没必要继续训练了。你会在验证集 loss 上看到一个平台期,这时候再加 epoch 也不会改善,反而可能过拟合到更早的历史形态上。
6. 常见问题与排查技巧实录
6.1 预测结果整体滞后一拍
这是时间序列预测里最典型的毛病:预测曲线和真实曲线形状几乎一样,但向右平移了几个点,看起来就是“慢半拍”。
出现这个问题的原因,通常是模型发现最简单省力的策略就是把最后一个时间步的数值复制到未来,尤其是在预测步长较短的时候。这种滞后的本质是模型没有学到“趋势变化”,只学到了“延续当前值”。
针对这个问题的处理思路有三个:
- 加大 CNN 卷积核感受野,让它能看到更长一段上升趋势,而不只是最后几个点。
- 缩短训练时的 teacher forcing,如果用多步递归预测,不要每一步都给真实值。
- 在损失函数里增加一阶差分惩罚项,让预测值的改变趋势和真实值的改变趋势尽量一致。
我在项目中主要靠损失函数里加了差分惩罚项来缓解,效果比较直接:
def combined_loss(y_true, y_pred): mse = tf.keras.losses.mean_squared_error(y_true, y_pred) diff_true = y_true[:, 1:] - y_true[:, :-1] diff_pred = y_pred[:, 1:] - y_pred[:, :-1] diff_loss = tf.reduce_mean(tf.abs(diff_true - diff_pred)) return mse + 0.1 * diff_loss6.2 Attention 权重分布完全没有规律
如果你的 Attention 权重趋于均匀分布,基本说明 Attention 没有学到有用信息。我先检查自定义 Layer 的输出维度是否和后续层匹配,再检查 GRU 是否真的返回了序列。一个很深坑是,GRU 如果设置return_sequences=False,Attention 层接到的就是一个向量而不是序列,维度直接报错。
在训练初期 Attention 权重均匀是正常的,如果训练结束后仍然均匀,就需要调大 GRU 隐层维度,或者把 GRU 从单层改成两层,让隐层向量携带更多语义。
6.3 多步预测误差随时间步长逐渐放大
预测未来 24 个点,最后几个点的误差通常大于前面几个点。这是多步预测的常见现象,不可避免,但可以缓解。
一种方法是让模型直接输出 24 个点,而不是每次预测一个点再喂回去自回归。直接多步输出,每个输出位共享同一个输入特征,误差不会递归放大。我在代码里本来就用的这种直接输出方式,实际效果比自回归稳定得多。
另一种方法是在输出层后按时间步赋予不同的损失权重。离当前时刻越近的点权重越高,因为短期可预测性本身就强于长期。
6.4 模型训练慢或者显存不够
GRU 的recurrent_dropout在 GPU 上会有一定性能损耗,如果数据量不大,可以去掉recurrent_dropout,只保留普通 dropout。另外BATCH_SIZE从 1024 降到 512,训练时间会增加不少,显存占用却能显著降低。
还有一个容易被忽略的点:不要把所有历史数据都拿来训练。如果数据跨越了多年,可以考虑按季节分别建模,或者只用最近一年的数据,减少窗口重叠带来的数据冗余,训练速度会快很多。
7. 这套方法的扩展方向
7.1 接入真实气象数据
模拟数据里温度只是一个粗略的 sin 曲线,真实负荷对气象的响应远比这个复杂。如果你手头有数值天气预报数据,可以提高温度、湿度、体感温度的采样频率,甚至加入“累积温度效应”特征,也就是连续多日的热量累积。这个特征对夏季高温负荷预测非常有效。
接入方法很简单,只要在特征矩阵里多加几列,模型输入维度对应调整一下即可。注意气象数据和负荷数据要在时间戳上严格对齐,小时级错位都会影响精度。
7.2 多测点扩展成多任务预测
一个区域往往有很多个测点,每个测点单独建模工作量很大。可以考虑把多个测点拼成一个多输出任务,模型的输入除了历史负荷,再加一个测点编号的嵌入向量。这样所有测点共享 CNN 和 GRU 的底层特征提取,在全连接层各自输出,既能提升训练效率,也更容易稳定。
# 伪代码示意:多测点输入 load_input = Input(shape=(TIME_STEPS, 1)) meta_input = Input(shape=(1,)) embedding = Embedding(num_meters, 8)(meta_input)7.3 用不确定性输出替代单点预测
最后再分享一个后续值得深入的方向。现在的模型输出的是一个确定值,但调度场景更希望知道预测区间,也就是“负荷大概率落在什么范围”。你可以把输出层改成均值和方差两个分支,用负对数似然作为损失函数,这样模型不但能给出预测值,还能给出置信区间,对风险决策更有价值。
我在实际项目中已经把区间预测接到了日内滚动计划上,虽然模型复杂度增加了,但现场的接受度比单点预测高很多。这套 CNN-GRU-Attention 框架的价值也正在这里:它的容错能力、可解释性和扩展性都足够好,不是只能在论文里刷分的玩具模型,而是能真正落到业务场景里的实用方案。
如果你手头也有一批历史负荷数据,不妨先按上面的代码跑通,再结合自己的业务特点逐步改造。把基准模型做扎实了,后续加气象、加区间、加多测点都会顺理成章。