简介:面向丹麦电力市场价格预测任务,这份资源基于PyTorch与LSTM构建了未来24小时电价预测模型,适合电力交易从业者、能源数据分析人员及具备一定深度学习基础的学习者。项目整合历史电价、供需与时间特征,完成从数据预处理、模型搭建、训练调优到结果评估的完整流程,为理解LSTM在电价序列建模中的实际应用提供了可运行的参考实现。压缩包共23个文件,体积395KB,核心为6个Python脚本和4个CSV数据集,包含数据处理、模型定义与主程序;另附训练好的model_weights.pth权重、预测结果图表及readme说明,便于直接验证与二次开发。当前已有1030人学习下载,内容覆盖模型结构、特征工程、训练评估与预测输出等关键环节。通过对照代码与丹麦2023年真实电价数据,可快速复现预测流程,并在此基础上扩展特征或改进网络结构,为电力市场决策提供量化支持。
1. 电价序列为什么难预测:非平稳、负电价与「均值回复 + 尖峰」的双重性格
午夜大风天,丹麦的风电电价经常跌破零——发电商倒贴钱请你用电;傍晚六点,电价又可能冲到平均价的五倍。这个「预测未来24小时电价」的场景,在 PyTorch + LSTM 的时间序列方案里被反复尝试,却很少有人讲清楚:为什么同样把历史特征和价格喂进网络,有时候模型表现比直觉还准,有时候又翻车得莫名其妙。电价不是一条能靠外推画出来的曲线,它是供需在现货市场撮合后的博弈结果:风有多大、天有多冷、星期几,共同决定了每个小时的价格。普通回归模型把每个小时当独立样本,丢掉时间结构,在非平稳、带负值、有极端尖峰的序列上几乎必然跑偏。搞懂 LSTM 在这里的工作机制,关键不是调 PyTorch 的 API,而是看门控究竟怎么在几十个小时的跨度里记住「昨天同时刻」、又迅速遗忘风暴带来的异常尖峰——这正是预测能不能真正落地的分水岭。
2. 数据与特征工程:为什么电价预测不是纯时间序列问题,而是供需耦合回归
2.1 从出清机制推导特征来源
很多人在拿到电价数据后第一反应是「把过去 24 小时价格丢给 LSTM,让它自己学」。这个直觉对股票、流量这类纯内生序列勉强成立,但对电力现货价格基本无效。原因在定价机制:北欧电力市场的日前交易在每天中午截止,所有发电方和用电方提交次日 24 小时的报价曲线,系统按供需交叉点统一出清。也就是说,t 时刻的电价不是由 t-1 时刻价格决定的,而是由 t 时刻的供需关系决定的。历史价格只是这场博弈的影子,外生变量才是真正的驱动。
丹麦市场的奇妙之处在于供给侧结构:风电装机占比接近一半,而风电的边际成本几乎为零。大风天夜里负荷低,风电场报零价甚至负价抢发,系统出清价被整体压低,极端时直接为负。要预测这种价格,特征至少得拆成四组看:
| 特征组 | 典型字段 | 对应机制 |
|---|---|---|
| 供给侧 | 风速预报、风电出力预报 | 边际报价曲线被零成本电源压低 |
| 需求侧 | 负荷预报、气温、供暖指数 | 北欧建筑电采暖占比高,低温直接拉高需求 |
| 制度侧 | 日历特征:小时、星期、节假日 | 工作日与周末负荷差可达 20%,节假日形状完全不同 |
| 价格侧 | 滞后电价、滚动均值、滚动标准差 | 短期均值回复性与市场情绪的波动程度 |
这四组里,日历特征常被当成「凑维度」的填数,实际它约束能力最强。同一时刻、同一天气条件下,周一的电价和周六的电价可能差出一倍多,因为工商业负荷直接变化。丹麦的公共假日还会造成邻国互联输电的联动变化,只用小时和星期还不太够,得把「是否节假日」以及「假日前一天下午」(很多人提前下班导致傍晚高峰提前)这些制度性信号也做进去。
2.2 滞后特征与滑动窗口的代码落法
时间序列预测的样本组织方式很简单,但细节决定成败。第一步是把原始小时电价表转成带滞后特征的宽表,注意每个滞后都必须是「当时的已知量」:
def make_lag_features(df, price_col="price", lags=(1, 2, 24, 25, 48, 168)): """构造滞后特征。lags=24表示昨天同一时刻,lags=168表示上周同一时刻""" df = df.copy() for h in lags: df[f"lag_{h}h"] = df[price_col].shift(h) # 关键点:先shift(1)再做rolling,保证统计量只用当前时刻之前的数据 df["roll24_mean"] = df[price_col].shift(1).rolling(24).mean() df["roll24_std"] = df[price_col].shift(1).rolling(24).std() df["roll168_mean"] = df[price_col].shift(1).rolling(168).mean() return df.dropna()滞后阶数为什么偏要选 24、168 这种「整点」?因为电价有极强的周期性锚点:昨天下午两点的供需关系,与今天下午两点的供需关系最相似;上周三傍晚的负荷曲线,与本周三傍晚也最相似。小滞后如 lag_1、lag_2 抓的是惯性,大滞后抓的是周期性骨骼。两者都不够时,滚动均值作为「局部水平」补充单点滞后的不稳定性——电价常有单小时突刺,直接拿 lag_1 当特征会把噪声当信号,而过去 24 小时均值能刻画「市场当前处于高价位还是低价位」这个状态。滚动标准差则刻画波动状态,大风天和雷雨天的 24 小时波动率特征明显不同。
做完滞后特征,下一步是滑窗组织 LSTM 输入。窗口取多长?经典选择是 72:
def make_sequences(feat_df, target_col="price", T=72): """把宽表切成 [batch, T, features] 的序列样本,标签是未来24小时电价""" X, y = [], [] feat_data = feat_df.values target_data = feat_df[target_col].values for i in range(T, len(feat_df) - 24): X.append(feat_data[i - T:i, :]) y.append(target_data[i:i + 24]) # 训练目标就是业务口径:未来24h return torch.tensor(np.array(X), dtype=torch.float32), \ torch.tensor(np.array(y), dtype=torch.float32)窗口 72 代表看过去三天。太短(24)学不到周模式,太长(168)会引入大量与预测日距离过远的信息,反向传播路径也变长,反而稀释了「昨天同时刻」的权重。标签取 i 到 i+24,和「每天早上推送次日 24 小时预测曲线」的业务需求严格对齐——模型的输出天生就是未来 24 个点的定价曲线,而不是滚动地预测下一个点。
2.3 时间对齐:最容易踩的外生特征「未来泄漏」
外生特征有一个容易被新手无视的时间对齐问题,但几乎所有生产级电价预测的翻车事故都栽在这里:你拿到的风速、负荷预测,发布时间和生效时间是错位的。欧洲现货市场的中午十二点竞价截止后,次日的风电出力预报才被确定下来;换句话说,预测次日凌晨 3 点电价时,你能用的只是当天中午生成的风速预报「报数值」,而不是次日凌晨 3 点实测风塔数据。如果把实测风速(事后数据)当特征喂给模型,等于把未来答案提前放进输入,训练时验证集 MAE 会低到让人觉得已经拿到印钞机,一上线立刻现原形。
处理办法只有一条:建立每个特征字段的「发布时间戳」,任何特征在 t 时刻的可用版本必须是发布时间早于 t 的表单。常见的实现思路是,对风速预报这类政府电网公开的历史文件,按发布批次对齐到对应竞价日上午 12:00 的索引上,而不是对齐到预报目标时刻。这样模型学到的是「预报发布时信息集」到价格的映射,而不是「事后事实」到价格的映射。
2.4 竞价区差异:先做区还是混着做
丹麦电网分东西两个竞价区,西部风电多、常负价,东部火电和互联线路占比更高、价格形态偏传统。如果直接把两个区的价格序列拼在一起训练,模型会被「同区不同分布」搞糊涂。我一般先按区分别统计归一化参数,再决定是训练两个独立模型还是在特征里加区编码。数据量不大时,加区编码共享一个 LSTM 更稳;两区各训一个模型虽然灵活,但样本量减半容易过拟合。
3. LSTM 的门控机制:让网络学会「记住昨天、忘掉风暴」
3.1 普通 RNN 为什么学不动电价
如果把 LSTM 当黑匣子用,只看得到「哇,比朴素 RNN 效果好」,就永远没法理解模型在预测失败时偏向哪种失败。要理解 LSTM,先从它要解决的问题讲起。
朴素 RNN 的隐状态更新是 h_t = tanh(Wx_t + Uh_{t-1}),信息从第 t 步传到第 t+k 步,中间要连续乘以矩阵 U 的 Jacobi 多次。这个连乘带来的结果是:特征值小于 1 的部分指数衰减,大于 1 的部分指数爆炸。对电价序列,跨天的依赖要传 24 步、跨周要传 168 步,反向传播路径这么长,梯度基本衰减到零,普通 RNN 的实际有效记忆只能覆盖最近几个时刻——这恰好是电价序列信息量最低的一段,因为最近几小时只有惯性,没有周期性骨架。
LSTM 的改进思路不是把连乘「变短」,而是修一条可以无损穿行的旁路。1997 年 Hochreiter 与 Schmidhuber 提出的核心设计——细胞状态(cell state)作为「传送带」,配合门控决定每步写入什么、丢弃什么——本质上是给梯度一个行为可控的高速通道。我一般这么理解:细胞状态是模型的长期账本,三个门就是做账复核的人;序列信号决定了什么人、在什么时候、往账本里记多少。
3.2 三门一候选,拆开看公式
PyTorch 的 nn.LSTM 把公式封装好了,但为了看清楚机制,我通常先手写一遍单步 Cell。下面这份代码就是 nn.LSTM 内核对单个时间步的实际计算逻辑:
import torch import torch.nn as nn class LSTMCellStep(nn.Module): def __init__(self, input_dim, hidden_dim): super().__init__() # 一次性算出4组门向量,后面按块切分 self.W = nn.Linear(input_dim, 4 * hidden_dim, bias=False) # 输入到门 self.U = nn.Linear(hidden_dim, 4 * hidden_dim, bias=False) # 隐状态到门 self.b = nn.Parameter(torch.zeros(4 * hidden_dim)) def forward(self, x_t, h_prev, c_prev): gates = self.W(x_t) + self.U(h_prev) + self.b i, f, g, o = gates.chunk(4, dim=-1) # 输入门 i、遗忘门 f、输出门 o 走 sigmoid,候选 g 走 tanh i = torch.sigmoid(i) f = torch.sigmoid(f) g = torch.tanh(g) o = torch.sigmoid(o) # 细胞状态更新:遗忘旧记忆 * 写入新记忆 c_next = f * c_prev + i * g h_next = o * torch.tanh(c_next) return h_next, c_next门控机制落到电价场景,每个门的「人格」就非常清楚了:
- 遗忘门 f决定昨天的电价记忆保留多少。市场平稳的日子,f 趋近 1,细胞状态沿时间轴稳定传递「昨天这个时段大约什么价位」;一场风暴让风电出力剧变,市场形态发生结构性改变时,网络会学到把 f 压低,把旧局面清掉,避免「昨天长这样所以今天也长这样」的惯性误判。这是 LSTM 对电价这种 regime-switching 序列最关键的适应能力。
- 输入门 i 和候选 g决定写入多少新信息。傍晚负荷爬坡、价格进入高峰时,当天实际的供需信号被写入细胞状态,形成对旧记忆的修正。
- 输出门 o决定当前时刻向外暴露多少记忆给下一层的全连接头,用于预测未来 24 小时。它相当于「汇报口径」:有些记忆只存着备查(比如凌晨的低价状态),不必每步都大张旗鼓影响输出。
梯度方面值得注意:c_next 对 c_prev 的导数中,主干路径系数是 f(遗忘门),其余项是相加结构。这就在数学上规避了 RNN 连乘的指数衰减——只要遗忘门没被学成 0,梯度就能沿细胞状态这条通路平稳回传几十步。这也是 LSTM 在 72 窗口甚至 168 窗口下依旧训练得动的原因。
3.3 多层、隐藏宽度与遗忘门偏置
层数和宽度怎么定?电价数据通常只有两三年小时粒度,样本量两万上下,这个规模撑不起 4 层以上 256 宽度的深网络。实践中最稳的区间是 1~2 层、hidden 32~128。第一层学原始时序形态(日内双峰),第二层在上面学更高层的状态迁移(比如「连续多大风后价格会整体下沉」)。宽度超过 128 在这样规模的数据上基本进入过拟合区,验证集 MAE 不降反升。
还有一个容易被搜包抄的细节:LSTM 初始化时遗忘门偏置常被设为 1 或正值。因为新模型初始化时门值接近 0.5,意味着每步衰减一半,72 步窗口传下来早期信息早就归零。偏置设为 1,让模型从「默认记住」起步,再让门自己学办什么时候掉头关闭,是 Goyal 等人在 2017 年前后总结出的一个实践惯例。极少数实现里会在 forward 里对 f 加上一个常数偏置,正是冲着这个去的。
4. 训练与 24 小时多步预测:归一化、损失函数与推理策略的落地细节
4.1 归一化:按区、按需、防泄漏
时间序列模型的输入缩放不能求助 sklearn 的全数据 MinMaxScaler。至少两个原因:一是训练时你不应该看到未来数据统计量,二是电价范围常态下 -100~60 欧/兆瓦时,一套全局 MinMax 会被少数极端尖峰拉伸,留出来的特征空间大部分被无关的分布间隙占据。我一般按竞价区分别做 z-score:均值方差只从训练集拟合,验证集和测试集用同一套参数变换。负电价在这个流程里不需要特殊处理,z-score 天然兼容负值,比 MinMax 直接把负值压到 0~1 里更合理。反归一化时把输出乘回训练集标准差再加回均值,评估指标必须在还原后的原始单位里算,否则「归一化空间 MAE=0.03」这种数字不具备任何业务含义。
4.2 三种多步预测策略:直接多输出、递归滚动与 Seq2Seq
预测未来 24 小时,架构上可以先想清楚三个候选方案,它们对误差的传播机制完全不同。
直接多输出(Direct Multi-Step)是 PyTorch 里最省事的做法:LSTM 编码历史窗口,最后的隐状态接一个输出维度为 24 的全连接层,一次前向直接吐出 24 个价格。不累积误差,训练简单,缺点是模型要一次学 24 个输出之间的相关性,数据量小时远端几小时的预测会趋向均值回归——因为网络发现「报个平庸的数,总体损失反而低」。
递归滚动(Recursive)每次只预测下一小时,把预测价格拼接回输入继续预测下一步。优点是模型小、输出步长任意;缺点很致命:误差被递归喂给自己,前几步的小偏差会在 6~12 步后放大成明显漂移。训练阶段可以做一个缓解动作——教师强制(teacher forcing),即下一步输入用真实价格,推理时再用自身输出;但训练与推理的输入分布不一致,这个 gap 正是递归方案 24 小时预测翻车的根源。
Seq2Seq用编码器读历史、解码器逐步生成未来。解码器每一步的输入,在训练时用真实值(teacher forcing),推理时用上一步自身输出。它和递归方案的区别在于:解码器输入处有专门训练好的嵌入与注意力,模型见过「自己输出接自己的输出」的过程,略好于裸递归。加不加注意力看数据量,两万样本加 attention 收益有限。
| 方案 | 误差累积 | 训练复杂度 | 输出长度灵活性 | 电价场景适用性 |
|---|---|---|---|---|
| 直接多输出 | 无 | 低 | 固定 | 高:业务口径固定 24h |
| 递归滚动 | 显著 | 低 | 任意 | 中:短于 6h 尚可,24h 风险高 |
| Seq2Seq | 有但可控 | 中 | 任意 | 中高:需配合教师强制退火 |
PyTorch 里直接多输出方案的训练循环和推理注意点如下:
class PriceLSTM(nn.Module): def __init__(self, input_dim, hidden_dim=64, num_layers=2, out_steps=24): super().__init__() self.lstm = nn.LSTM(input_dim, hidden_dim, num_layers, batch_first=True) self.head = nn.Linear(hidden_dim, out_steps) # 24小时直接输出 def forward(self, x): _, (h, _) = self.lstm(x) # 取最后一层最后时刻的隐状态 return self.head(h[-1]) # h: [num_layers, B, hidden] for epoch in range(EPOCHS): for x, y in loader: opt.zero_grad() y_pred = model(x) # [B, 24] loss = huber_loss(y_pred, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 防尖峰样本甩飞权重 opt.step()两点值得展开。第一,为什么取h[-1]而不是 concat 所有层?因为预测目标是未来 24 小时不变的长度,最后一层隐状态已经整合了低层的时序特征,concat 中间层只在小网络里带来边际收益。第二,梯度裁剪在电价预测这种有极端尖峰标签的任务里不是可选项而是必需项。MSE 或 Huber 在尖峰样本(价格 500 欧)上产生的梯度,比其他小时大两个数量级,不裁剪的话一个 batch 就能把训练轨迹带偏。
递归滚动方案的 PyTorch 实现就是在单步模型中加一个循环:模型每步输出 1 个值,与已知特征拼接成下一步输入,重复 24 次。这里面有个容易被忽视的坑:拼接时滚动窗口里的滞后特征必须同步更新,否则每一步喂入的都是同一批旧滞后值,模型进入「睁眼瞎」状态。
4.3 损失函数:MAE、MSE 还是 Huber
损失函数直接决定了模型对尖峰的态度。MSE 平方惩罚让模型把概率质量放在「大多数普通小时」上,尖峰小时贡献的梯度极大,模型被迫压缩预测幅度来降低整体平方误差——表现就是预测曲线比真实曲线明显「胖不起来」。MAE 梯度恒定,模型会倾向于输出条件中位数,对尖峰的位置更忠实,但训练后期收敛慢,loss 在尖峰样本上容易抖动。Huber 在两者之间取了一个折中:误差小于 δ 时按平方走、收敛平滑;大于 δ 时按线性走、削弱单个尖峰的支配力。经验上电价预测首选 Huber,δ 取训练集价格标准差的 1~2 倍。评估阶段同时看 MAE、RMSE 与「峰值时段单独 MAE」,三个指标一起决定模型能不能用于实际的日内价格风险管理。
5. 常见问题与排查:归一化泄漏、时间泄漏与多步累积误差
这一章是从真实项目里踩坑踩出来的清单。每一条我都按「现象 → 原因 → 解决」记录,遇到类似症状可以直接对照排查。
5.1 验证集 MAE 高得离谱,但训练集曲线拟合得很好
现象:训练 loss 一路走低,predict 训练段曲线几乎贴合真实价格,验证段却一塌糊涂。
原因:模型过拟合了训练段的噪声,但更常见的是没有做早停,或者数据本身存在「时间段漂移」——电价分布随季节和风电装机变化,训练末期与验证期的价格水平已经完全不在一个量级。
解决:固定验证集为时间上严格靠后的连续区间,用验证集 MAE 做早停(patience 设为 5~10 个 epoch),并观察每个 epoch 验证 loss 是否出现先降后升的 U 型拐点。如果拐点出现得过早,考虑缩小 hidden 宽度或增加 dropout。
5.2 用随机 K 折切分后,模型预测尖峰位置神准
现象:某次实验里模型在验证集上准确预报出了一个罕见尖峰的具体时刻和幅度,好得让人不敢相信。
原因:随机 K 折把未来样本切进了训练集。电价尖峰通常是「事件性」的(设备检修叠加高负荷),同一天前后几个小时的强相关性导致训练数据里已经包含了目标小时附近的信息。
解决:时间序列任务必须用时间顺序切分,比如前 80% 训练、后 20% 验证,或者用 Purged K-Fold,在切分边界留出清洗间隔(如 24 小时),确保训练集与验证集没有任何时间重叠。
5.3 递归滚动预测到第 6 小时,曲线开始发散甚至是常数
现象:采用单步 LSTM 递归滚动 24 小时,前 2~3 步还像样,到第 6 步之后价格曲线逐渐变成一条水平线或开始震荡发散。
原因:分布漂移。训练时每一步输入都来自真实历史序列,推理时输入混入了模型自身的预测误差,输入的分布与训练分布错位;同时滚动窗口中滞后特征是模型自己的输出,误差被反复放大。深夜到凌晨这段通常是模型最「没底气」的时刻,直接退化成均值回归输出。
解决:优先切到直接多输出模型,从机制上彻底消除累积误差;如果必须用递归方案,训练时做 schedule sampling——以概率 p 用模型自身输出替换真实值喂进下一步,p 随训练进程从 0 线性升到 0.2~0.3,让网络见过自己的「口音」。另外检查滚动窗口的滞后特征刷新逻辑,很多「常数输出」其实只是因为特征拼接时滞后列没有随步进更新。
5.4 预测曲线的峰值永远比真实峰值矮一截
现象:曲线整体走势对,但傍晚高峰总被拉低,低谷被抬高,看起来被「磨平」了。
原因:损失函数在均值与尖峰之间的取舍失衡。MSE 对尖峰惩罚过大,模型输出的条件均值被普通时段主导;尖峰属于小概率大偏差事件,对期望损失的贡献在平方误差下被系统性地「过度惩罚」。
解决:换 Huber 或 MAE;或在 loss 里给高峰小时(当地时间 17~20 点)加权,权重 1.5~2 倍;同时注意数据清洗时不要把高价样本当异常剔掉——电价尖峰通常是真实市场事件不是脏数据,删掉尖峰等于删掉模型最需要学习的极端行为。
5.5 MAPE 爆出几百万,模型「不可用」
现象:用平均绝对百分比误差评估,数值大到离谱,甚至出现除以 0 报错。
原因:电价大量落在 0 附近,负电价和成交价几欧元的时段让 MAPE 的分母趋近于 0。MAPE 对「绝对值小但相对误差大」的样本给出极端惩罚,而这类样本在丹麦市场上是日常不是异常。
解决:改用 sMAPE(对称平均绝对百分比误差),或者只在价格绝对值大于某个阈值(比如 5 欧/兆瓦时)的样本上计算 MAPE,业务上叫 wMAPE。评估指标要贴合业务关注的误差形态——交易员真正关心的是「高峰时段偏差几欧」,不是「凌晨负电价时刻偏差百分之几百」。
5.6 模型换了竞价区后性能骤降,同样的代码换个数据就不 work
现象:同一套 LSTM 配置在西部竞价区训练效果不错,切到东部竞价区验证 MAE 直接翻倍。
原因:两个竞价区的价格分布与驱动机制不同——风电比例、互联线路容量、负荷结构都不一样,共享归一化参数或共享一套固定滞后集合会压制分布差异。
解决:按竞价区分别拟合归一化统计量、分别选窗口长度;数据分析阶段先对比两个区的价格分布和自相关图,再决定是独立建模还是共享网络加区编码。
6. 设计取舍:多输出 24 步与递归滚动,以及 LSTM 和树模型的现实边界
前面几章把 PyTorch + LSTM 的机制拆到了门级,最后一章想聊一个更现实的问题:同一个「未来 24 小时预测」目标,多输出模型、递归滚动、Seq2Seq,以及彻底绕开深度学习的梯度提升树方案,该怎么选。我的经验判断是,先看业务口径是否固定,再看数据量级。
如果业务就是每天早晨固定推送一条 24 小时曲线(现货市场的标准动作),直接多输出是最稳的架构——误差不累积,推理路径短,部署简单。递归滚动只值得留给「输出步长经常变化、前端要能随时问未来 3 小时或 40 小时」的交互式场景,而且必须接受误差随步长放大的现实。数据量在两万样本以内时,Seq2Seq 的注意力机制带来的收益很小,反而多了 teacher forcing 调度这类超参要操心;数据量上了十万,序列模型的长程依赖优势才开始兑现。
另一个被低估的对手是梯度提升树。把 24 个小时的滞后特征、日历特征、预报特征拉平成 24 行的宽表,LightGBM 也能做多步回归,而且在小数据上经常不输 LSTM,训练时间还少一个量级。LSTM 真正的护城河不是精度,而是在线更新能力——树模型更新要重新训练,LSTM 可以加载权重接着用一个 batch 微调;以及窗口语义——LSTM 把时间顺序直接编码进结构里,不需要手工构造复杂的交互滞后特征。我的习惯是:任何电价项目都先做一版梯度提升树当基线,再用 LSTM 升级,最后要求 LSTM 在验证集 MAE 上至少比基线低 5% 才采纳,否则就承认数据规模撑不起深度模型。
这种「先基线、再升级、用业务指标验收」的流程,是我踩过「训练损失漂亮、上线全灭」的坑之后总结出来的。从那以后我每次启动电价预测实验,都强制自己先花 15 分钟把数据时间轴、特征发布时间和验证集切分画成一张图再动手搭模型——这 15 分钟挡掉的无效训练,比任何花哨网络结构都多。希望帮到你。
本文还有配套的精品资源,点击获取