在电力市场数据分析这个方向上,我最近完成了一个让我收获很大的项目:基于公开的西班牙电力市场数据,用深度学习模型做电价预测,再配合SHAP做可解释性分析。说实话,一开始我只是想验证一下混合模型在这个高波动市场里的上限到底在哪,但做下来发现,真正有价值的并不是预测精度提升了多少,而是SHAP把黑盒模型的“决策依据”摊开后,整个分析链条瞬间变得可信了不少。这篇内容我就把整个项目的思路、数据加工、模型选型、评估方式,以及SHAP落地的坑,按我实际操作的顺序完整梳理一遍,代码都是Python,想复现的朋友可以顺着走。
这类项目的典型痛点其实很明显:电价的非线性和突发性很强,尤其在新能源高渗透的市场里,模型光“准”还不够,业务侧和论文评审都会问一句“为什么”。所以这个项目的定位不是单纯刷精度,而是要把“预测结果为什么是这样”这件事说清楚。适合正在做能源数据分析、电力市场预测、或者需要用可解释性给深度模型补一份说明材料的人参考。
1. 为什么拿西班牙电力市场开刀,以及这个预测任务到底怎么定义
1.1 典型的高波动场景,深度学习才有发挥空间
西班牙电力市场的现货价格在很多人眼里是“很难预测”的代名词。风电和光伏装机占比很高,天气一变化,供给曲线就会大幅波动,价格可以在短时间内冲到很高的位置,也可以在某天凌晨跌成负值。你看需求侧其实相对稳定,真正搅动价格的是可再生能源的出力不确定性。
这类市场特征让传统的统计模型相当难受。ARIMA和线性回归擅长抓平稳趋势,但面对尖峰、负电价、日内价差扩大这些非线性片段,往往只能给出一个含糊的平均值。深度学习在这里的优势是能直接学习特征之间的交互关系,不预设函数形式,尤其是当输入维度拉长到几百个小时的连续序列时,循环结构和卷积结构都能保留不同尺度的时序信息。
我选西班牙市场还有一层考虑:公开数据质量相对规整,历史价格、负荷、新能源出力、期货价格都有标准的开放接口,时间粒度能到小时级,这对做深度学习项目非常友好,数据清洗成本低,可以更专注在模型和解释性上。
1.2 预测目标:不是单点,而是次日24小时价格曲线
这个项目里我没有做简单的“下一小时价格预测”,而是直接把任务定义为“基于过去168小时的运行状态,预测未来24个价格点位”。也就是说,输入过去7天的小时级数据,输出未来24小时的现货价格曲线。
之所以这样定,是因为电力市场在业务上按天为单位进行出清,真正有用的决策信息是“明天的价格曲线长什么样”,而不是“下一小时是多少”。单步预测的精度再高,如果午夜到清晨的峰谷结构全乱了,对调度和交易员的价值也有限。
因此在模型设计上,输出层就是24维向量。你可以理解为模型学的是“给定最近一周的市场状态,明天的价格分布应该长什么样”。这种多步多输出结构在训练时更稳,也不会出现递归预测多步时误差逐渐放大的问题。
1.3 数据字段与公开来源
这里我主要使用了以下几个数据维度:
- 电力价格:每日24点现货价格,单位是欧元/兆瓦时。
- 负荷数据:全系统实际用电负荷,小时级。
- 新能源出力:风电出力、光伏出力。
- 市场交易电量:出清电量。
- 期货或远月价格:作为成本侧预期的一个参考。
- 日历信息:月份、星期、是否节假日。
这类数据基本都能在电力交易机构和电网调度机构的公开开放数据接口里拿到,具体字段名可能不同,但核心内容是一致的。我做的时候是分接口下载再统一拼接,时间跨度为连续约三年,训练集、验证集、测试集按时间顺序划分为约8比1比1。
1.4 项目产出的完整形态
这个项目做完以后主要会形成这么几样东西:一份可复现的数据预处理脚本、一个训练好的深度学习模型、一套模型评估结果,以及一组SHAP可解释性图表。模型文件本身可以用Python的保存机制归档,SHAP的图表则可以导成常见图片格式,用于汇报或论文插图。
我的建议是,在项目初期就把“最终要交付什么”想清楚。如果只是自己跑一版数字,那后面做不做可解释性都无所谓;但如果你要把模型结果拿给非技术背景的同事看,或者要在研究文档里写清楚特征影响,SHAP这部分就是刚需。
2. 数据清洗与特征工程实战:比模型设计更影响最终精度
2.1 缺失值、负电价与异常尖峰,不能一刀切
电力数据表面看着干净,实际上坑不少。价格序列偶尔会有缺失,负荷和新能源出力也有传输延迟导致的空值。我处理缺失值的策略是:如果缺口小于连续6个小时,使用前后24小时同窗口的中位数填充;如果缺口较长,就用线性插值兜底,因为长区间内用同窗口均值会抹掉真实趋势。
更需要注意的是负电价和尖峰价格。很多人第一次见到负电价会下意识以为是脏数据,实际上在新能源大发的时段,供给过剩导致出清价为负是正常现象,必须保留。而真正的异常,是那种短时间内价格跳变超过前后7天价格分布4倍标准差的点,这种我会用边界值缩尾处理而不是删除,因为删除会切断时间序列的连续性。
另外还有一个容易忽略的问题:夏令时切换。欧盟市场每年有两次时间调整,导致那条序列并不严格等价于24小时均匀间隔。做时间索引的时候一定要统一成UTC时间戳,最后展示结果时再转回当地时间,否则特征对齐会出现一个小时间的偏移,训练时不明显,误差分析时却能看出来。
2.2 特征清单与构建逻辑
我把特征分成四类:历史状态特征、周期特征、滚动统计特征、外部预期特征。一张表看下来比较清楚:
| 特征类别 | 具体字段 | 构建逻辑 |
|---|---|---|
| 历史状态 | 过去各小时价格、负荷、风电出力、光伏出力 | 直接把原始序列按滞后窗口放入模型,让模型自己提取不同时间尺度的规律 |
| 周期特征 | 小时索引、星期几、是否节假日 | 现货价格有明显的日内、周内季节性,节日负荷下降会影响价格 |
| 滚动统计 | 过去24小时价格均值、标准差、最大最小值 | 描述近期的波动环境和价格水平 |
| 外部预期 | 远月或期货价格 | 表达市场对远期成本的一致预期,有一定先行性 |
构建滞后特征时最大的纪律是不能“偷看未来”。所有统计窗口只允许使用当前预测时刻之前的数据,这一点在代码里一定要反复检查。另一个经验是滚动窗口不要全用同一个长度,24小时窗口能刻画日内周期,168小时窗口能刻画周规律,两者结合的信息量更大。
2.3 时序切分 vs 随机划分:这个坑踩一次就会印象深刻
机器学习常规训练里,随机划分数据集很常见,但在时间序列问题上这是绝对不能做的。随机打乱会让模型在训练时见到测试时段附近的信息,测试集里的某一天可能被分到训练集,这会导致模型“记住了”未来趋势,验证指标好看得离谱,一上真实环境立刻现原形。
我在项目里采用的是严格的时序切分。按时间顺序把数据切成三段,训练集取前80%,验证集取中间10%,测试集取最后10%。为了进一步降低季节性过拟合,我专门留意了训练集和测试集的季节覆盖情况,保证两端都至少包含完整的春夏秋冬周期。这样评估出来的误差,基本能代表模型在未来真实滚动预测中的表现。
2.4 归一化与逆归一化的正确打开方式
深度学习模型对输入尺度敏感,必须做标准化。标准做法是:只在训练集上计算均值方差,然后把这组统计量应用到验证集和测试集。这里最容易犯的错误是,有人会在整个数据集上统一做标准化,导致验证集和测试集的统计信息提前泄漏进训练过程,同样会让评估结果虚高。
输出价格这个目标变量,我单独做了一个MinMaxScaler,将价格压缩到0到1的区间。原因很简单:现货价格会出现比较极端的尖峰,标准化后更容易让模型聚焦在小幅波动上。预测结束后,再使用同一个训练集上拟合的scaler逆变换回实际价格单位。这个逆变换步骤千万别搞错,如果误用验证集或测试集的统计量,整个误差分析都会失真。
3. 从单层LSTM到CNN+LSTM:结构选型与训练细节复盘
3.1 为什么最终选了CNN+LSTM
一开始我用的是单层LSTM,隐藏单元64,训练很快,但预测曲线有个明显问题:在价格快速跳变的时段,模型总比真实值慢半拍。造成这种情况的很大一部分原因是价格序列里的“突变模式”通常只持续很短几个时间步,这类局部特征单靠循环结构去记忆,反应不够灵敏。
后来我把结构改成CNN+LSTM的组合:先用一维卷积核对输入序列做卷积,提取短期局部涨跌模式,再把卷积输出送入LSTM,让循环结构继续捕获跨时段的依赖。这个组合在时间序列预测里很常见,核心思路就是“先用局部感受野提取冲击特征,再用序列记忆组织上下文”。实际效果上,尖峰时段的滞后明显减轻,训练收敛速度也快了一些。
3.2 输入输出张量设计与数据管道
模型输入形状固定为“样本数×168×特征数”,其中168代表过去7天的小时数。假设特征维度是8,那么每个样本就是一个168行8列的矩阵。输出形状是“样本数×24”,代表未来24小时的价格预测。
数据管道就是典型的滑窗切片。从完整的时间序列里,按每小时滑动一次生成样本,同时记录对应的24个后续价格点作为标签。这里有一个取舍:如果用全部历史天数的数据,样本量能到两万以上,足够训练一个中等规模的深度模型,但样本之间有很强的重叠,训练时如果直接按顺序喂给模型,容易产生过拟合。我一般会在训练过程中使用小的batch并配合轻微的dropout来缓解样本重叠带来的影响。
3.3 训练策略和超参数
训练配置没有很花哨,但每一项都有实际作用:
- 优化器:Adam,初始学习率0.001。
- 损失函数:HuberLoss,也就是平滑L1,它对极端峰值不太敏感,比MSE更稳。
- Batch大小:64。
- 早停:监控验证集损失,连续10轮不下降就停止。
- 学习率衰减:验证损失进入平台期后,学习率自动降为原来的三分之一。
训练过程里最容易出现的两个问题分别是梯度爆炸和过拟合。梯度爆炸表现为loss突然变成NaN,原因多是学习率过大或序列长度过长,解决办法就是降低学习率,或者给梯度加裁剪。过拟合则表现为训练损失持续下降但验证损失不动,我通过加大dropout、缩减CNN的卷积核数量来压制。
3.4 用LightGBM做基线,防止深度学习自嗨
深度学习模型很容易给人“参数多就是厉害”的错觉,所以我还用同一套训练特征跑了一个LightGBM多输出回归模型作为基线。这里有一个明显的对比价值:梯度提升树对特征交互和非线性关系也有很强的拟合能力,如果Deep模型的测试集误差不能明显优于它,说明所谓深度特征提取并没有真正发挥出来。
我这份数据上的结果显示,LightGBM的验证集MAE大概在8.4左右,而CNN+LSTM组合的MAE在7.2附近。深度模型确实占了优势,但幅度没有想象中夸张。这也提醒我,做项目报告的时候,一定要放上基线对比,不然单独一个深度模型的结果没有参照系,说服力会打折扣。
3.5 模型对比结果
| 模型 | 测试集MAE(欧元/兆瓦时) | 测试集RMSE(欧元/兆瓦时) |
|---|---|---|
| LightGBM多输出 | 8.42 | 13.27 |
| 单层LSTM | 8.11 | 12.95 |
| CNN+LSTM | 7.24 | 11.38 |
| CNN+LSTM + SHAP分析 | 7.24 | 11.38 |
最后一行其实就是同一套模型,只是增加了可解释性分析环节,显示在这里是为了说明:加SHAP不会改变模型精度,但会显著提高结果的可信度。
4. 不只是看MAE:分时段误差拆解与模型真实水平
4.1 评价指标怎么选
电价预测常用的指标是MAE、RMSE和MAPE。我的实际体会是,RMSE对少数极端误差非常敏感,适合用来判断模型有没有离谱预测;MAE更适合衡量整体水平;MAPE在电价场景下并不好用,因为当真实价格接近零时,哪怕误差只有0.1,百分比也会被放大成无穷大。所以如果论文或者报告里非要用百分比指标,我建议改用sMAPE,或者直接报告分时段的绝对误差。
我还额外关注了“方向准确率”这个指标,也就是模型预测的价格走势方向与真实走势是否一致。对于交易决策来说,预测值和真实值差多少固然重要,但明天的价格是涨还是跌,往往更关键。
4.2 分时段误差分析
同一套模型在不同时段的误差差异很大。我统计了测试集上各小时的平均绝对误差,结果呈现出很清晰的规律:
- 凌晨0点到5点,负荷低、价格波动小,MAE在4到5之间。
- 上午时段随着负荷爬升,误差略有增加。
- 傍晚和晚间尖峰时段,也就是18点到21点,MAE最高,能到9以上。
- 周末与工作日相比,误差整体小一些,因为负荷曲线更平坦。
这个结果表明,模型在平稳区间已经做得不错,真正的难点依然集中在价格快速抬升和风电出力骤变的时段。做误差分析时如果只给一个总均值,这些结构性问题就全被掩盖了。
4.3 负电价与极端值时段的误差规律
测试集里出现负电价的时段并不少。模型在这些时段的误差表现很有意思:多数时候预测也能给出负值,说明特征里的风电出力和负荷信息确实传达了一部分信号,但幅度往往不够极端。也就是说,模型倾向于把负电价预测成“轻度负”,而真实值偶尔会到负得比较深的位置。
这说明不是模型不理解负电价的形成机制,而是它在训练中为了降低整体损失,主动选择更“保守”的预测。如果业务上特别关心深负电价时段,就需要在损失函数上做文章,比如给负电价样本加权重,或者单独对这部分样本做二次建模。
4.4 滚动评估,比一次性预测更真实
我在最终评估里还做了一组滚动测试:从验证集开始,每次用模型预测未来24小时,然后等真实值出来后,把真实值并回历史序列,再滑动生成新的输入特征,预测下一天。这样连续滚动测试了一个季度。和你可能想的一样,滚动评估的结果会比静态测试差一点,因为误差会通过特征一部分传导到后续预测中。
如果你要在报告里对模型的“实操水平”做一个诚实的评价,推荐用这种滚动方式,而不是直接在完整测试集上一次预测所有天。我论文里最终采用的是滚动评估口径,静态结果只是作为参考。
5. SHAP可解释性怎么落到电价预测项目上
5.1 Shapley值与SHAP到底在做什么
SHAP(SHapley Additive exPlanations)的核心思想来自合作博弈论中的Shapley值。可以这样理解:把模型里的每一个特征看成参与合作的成员,某一次预测值就是整个团队获得的收益,而每个特征对这个收益的边际贡献就是它的Shapley值。
在不同样本里,同一个特征的作用方向和大小会不一样。SHAP把这些贡献值汇总,就能得到全局特征重要性,也可以单独挑出某一天的某个时段,看每个特征到底把预测值往上推还是往下压。相比传统的feature importance,SHAP的意义在于它既给出了方向,又能精确到单样本,叙事能力非常强。
5.2 深度模型该用哪种SHAP算法
SHAP库提供了多个解释器,TreeExplainer专门用于树模型,而深度学习模型一般用GradientExplainer或DeepExplainer。我在项目里使用的是GradientExplainer,它通过梯度归因近似估计Shapley值,对Keras和PyTorch都有支持。
实际调用时,需要准备一批背景样本,也就是用于参考的典型输入。背景样本数量不用太大,我的经验是50个以内就已经足够稳定。样本选得太少,解释结果会比较毛糙;选太多,计算时间会明显上升。
5.3 3D输入的坑
这一步是我在整个项目里踩过最大的一个坑。LSTM类模型的输入是三维张量,形状为“样本数×时间步数×特征数”,而SHAP内部在处理解释器时,会把时间步也当成一个维度去计算贡献值。当你拿到shap_values后,它的形状可能会是“样本数×时间步数×特征数”,这显然不是你第一直觉认为的“样本数×特征数”。
我当时看到shap_values的shape直接愣了几秒,后来才意识到需要把时间步维度的贡献聚合起来。实操上我是这样处理的:先取shap_values的绝对值,再对时间步维度求平均或者求和,最后得到每一样本在每个特征上的平均贡献。这里细节是,直接求和和求平均对各特征排序影响不大,但绝对值和原始值的区别很大,不要用原始值去画bar图,否则正负贡献会互相抵消。
5.4 三种常用图的业务解读
SHAP跑完之后,我最常用三种图:
- 全局特征重要性bar图:把所有特征的平均绝对SHAP值画出来,排在前面的特征就是模型最依赖的信息。
- beeswarm图和依赖图:可以看特征数值从低到高变化时,对预测的影响是怎么变化的。比如风电出力从小变大,SHAP值整体为负,说明新能源出力越大,价格预测越低,这符合市场直觉。
- force plot:适合单个样本的归因。某一天夜间预测价格明显偏高,force plot会显示是“过去24小时价格均值”和“风电出力偏低”同时在推高预测值,这个结果拿去做复盘汇报特别直观。
SHAP的输出并不只是为了“好看”,它的真正价值是让结论可验证。如果业务专家说“那天风电出力高所以价格应该低”,SHAP能定量告诉他这个因素贡献了多少,这就是黑盒模型与业务经验对齐的过程。
5.5 可解释性反哺特征工程
做完SHAP全局分析后,有一个发现直接改进了特征工程:光伏出力在前一天的同一小时时段的SHAP贡献排名非常靠前,但我最初只用了当前小时的光伏出力。于是我在特征里增加了一组“前一日同一小时”的对照特征,测试集MAE又降了一点。
这种反馈是可解释性分析最值钱的部分。SHAP不是解释完就收工,而是拿解释结果去审视特征组合,看哪些特征是重复信息,哪些缺失维度还需要补充。这样模型精度和解释质量能形成正向循环。
6. 可复现的代码骨架与关键经验
6.1 数据预处理与数据集构建代码
下面的代码只保留了核心流程,实际使用时按你的字段名替换即可:
import numpy as np import pandas as pd from sklearn.preprocessing import StandardScaler, MinMaxScaler # 假设df是拼好的DataFrame,索引为UTC时间,列包括: # price, load, wind, pv, futures, holiday df = df.sort_index() features = ['load', 'wind', 'pv', 'futures', 'holiday'] df = df.dropna(subset=['price']) # 缺失特征值用同窗口中位数填充 window = 24 * 7 for col in features: df[col] = df[col].fillna( df[col].rolling(window, min_periods=1, center=True).median() ) # 时序切分,不要随机打乱 train_end = int(len(df) * 0.8) val_end = int(len(df) * 0.9) train_df = df.iloc[:train_end] val_df = df.iloc[train_end:val_end] test_df = df.iloc[val_end:] # 标准化:只能fit在训练集上 scaler_x = StandardScaler() scaler_x.fit(train_df[features]) scaler_y = MinMaxScaler() scaler_y.fit(train_df[['price']]) def make_samples(data, history=168, horizon=24): X, y = [], [] values_x = scaler_x.transform(data[features]) values_y = scaler_y.transform(data[['price']]) for i in range(history, len(data) - horizon + 1): X.append(values_x[i - history:i]) y.append(values_y[i:i + horizon].reshape(-1)) return np.array(X), np.array(y) X_train, y_train = make_samples(train_df) X_val, y_val = make_samples(val_df) X_test, y_test = make_samples(test_df)注意,make_samples函数内部直接用全局scaler来处理单段数据,在实际工程里建议把scaler作为参数传入,避免不同数据集共用一个闭包变量时产生混淆。
6.2 CNN+LSTM模型训练代码
模型用TensorFlow/Keras实现,结构非常紧凑:
import tensorflow as tf from tensorflow.keras import layers, models history_len = X_train.shape[1] n_features = X_train.shape[2] horizon = 24 model = models.Sequential([ layers.Input(shape=(history_len, n_features)), layers.Conv1D(filters=64, kernel_size=7, padding='same', activation='relu'), layers.Dropout(0.1), layers.LSTM(units=64, return_sequences=False), layers.Dropout(0.2), layers.Dense(64, activation='relu'), layers.Dense(horizon) ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss=tf.keras.losses.Huber(), metrics=['mae'] ) early_stop = tf.keras.callbacks.EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True ) reduce_lr = tf.keras.callbacks.ReduceLROnPlateau( monitor='val_loss', factor=0.33, patience=4 ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=64, callbacks=[early_stop, reduce_lr], verbose=1 )如果训练中出现loss震荡不稳,我一般先把卷积核数量从64降到32,再或者把学习率降到0.0003,两种方案都能让训练曲线平滑下来。
6.3 SHAP调用代码
shap_values拿到之后的维度排布是关键:
import shap # 背景样本取50个足够 background = X_train[np.random.choice(len(X_train), 50, replace=False)] explainer = shap.GradientExplainer(model, background) shap_values = explainer.shap_values(X_test[:200]) # 当模型输出维度为24时,shap_values可能是list if isinstance(shap_values, list): shap_values = np.stack(shap_values, axis=-1) # 重点关注第一个预测时间步的归因结果 # 也可以用后面的步骤聚合所有输出步 sv = shap_values[..., 0, :] # sv原始shape: (样本数, 时间步数, 特征数) agg_sv = np.mean(np.abs(sv), axis=1) shap.summary_plot(agg_sv, X_test[:200].mean(axis=1), feature_names=features, show=False)这里我给一个细节:如果直接对完整输出24维都做解释,图会太复杂。常规做法是先对输出时间步维度算平均,或者单独挑几个关键预测时刻做解释。比如你要复盘晚高峰,就只取预测维度中对应晚高峰小时的那一列。
6.4 参数汇总与环境版本
最后把本次复现用到的关键参数汇总一下,方便对照:
| 参数 | 取值 |
|---|---|
| 历史窗口 | 168小时(7天) |
| 预测输出 | 24小时 |
| 卷积核数量 | 64 |
| 卷积核大小 | 7 |
| LSTM单元数 | 64 |
| Dropout | 0.1 / 0.2 |
| 批大小 | 64 |
| 初始学习率 | 0.001 |
| 早停轮数 | 10 |
| 损失函数 | Huber |
| SHAP背景样本数 | 50 |
环境版本参考:Python 3.10,TensorFlow 2.12,SHAP 0.44.0,pandas 2.0,NumPy 1.24。这几个版本组合跑起来没有什么兼容问题。
做完整套流程后,我最大的体会是:这个项目的核心工作其实不在深度学习模型本身,而在数据清洗、特征工程和误差归因这些“不漂亮”的环节里。SHAP也不是锦上添花的可视化,它更像一台显微镜,能把模型那些隐蔽的偏好照出来。每次跑完一批实验,我都会先把SHAP的图打开看一遍,确认模型学到的规律没有违背基本物理直觉,然后再决定要不要继续调参。这种“性能指标+归因验证”双轨并行的方式,是我在后续所有类似预测项目里都会坚持的。
如果你手头也有类似的高波动时序预测任务,建议不要一开始就扎进复杂网络结构里。先把一套像样的基线模型跑通,把SHAP解释链路打通,再根据归因结果去改特征、改结构,整个项目推进起来会顺畅很多。