☰
LSTM电力价格预测实战:特征工程与数据泄露避坑指南
2026/10/16 6:49:58 网站建设 项目流程

简介:PyTorch结合LSTM预测丹麦电力未来24小时电价的开源项目,适合电力市场分析、时间序列建模初学者及进阶者参考。资源针对电价高波动、非线性特征,演示从数据预处理、特征工程到LSTM网络搭建、训练与评估的完整流程,并提供seq2seq、LSTMF等不同实现文件。压缩包共23个文件、约395KB,其中含6个Python脚本(含LSTM.py、main.py等主程序)、4个CSV数据文件(历史电力价格及预测结果)、2个训练模型权重(model_weights.pth)、2个PNG可视化图表,以及项目配置、说明文档等辅助文件。已有1030人学习下载。Python脚本覆盖数据清洗、训练入口与模型定义,便于二次开发。内容覆盖历史电价与外部特征构造、MSE损失与Adam优化器设置、RMSE/MAE评估、模型持久化等关键环节,配套readme.txt说明运行方式,便于对照复现实验,适合从事能源交易或智能电网预测的开发者快速上手。

1. 电价预测的最大难点不在 LSTM,而在特征里藏着的“未来数据”

做丹麦电力电价预测之前,我以为是模型不够强,后来才发现 PyTorch + LSTM 只占三成工作量,剩下七成都在数据清洗、滞后特征构造和防止数据泄露上面。丹麦风电渗透率常年超过 50%,现货市场经常出现负电价,传统统计模型会被这种冲击直接打崩,而 LSTM 能不能扛住,要看喂给它的特征是否干净。这篇笔记适合想做电力市场、负荷预测或时间序列方向的人,也适合拿来替换掉公司里那套纯靠调参的统计基线。下面从数据准备开始,一步步复现整套流程。

2. 数据准备与特征工程:先把时间戳对齐,再谈模型效果

2.1 原始数据字段与缺失值处理

丹麦电价公开数据通常包含小时级时间戳和当日现货电价,部分数据集还会附带该区域的风电出力和系统负荷。结构上一般是一行一个小时,字段包含时间、电价、风电、负荷这类列。拿到手第一件事不是建模,而是先把时间序列排整齐。

import pandas as pd import numpy as np df = pd.read_csv("denmark_electricity.csv") df["HourDK"] = pd.to_datetime(df["HourDK"]) df = df.set_index("HourDK").sort_index() # 只看关键字段,缺失严重的列直接丢掉 cols = ["SpotPriceDKK", "WindPowerMW", "PowerConsumptionMW"] df = df[cols].copy() df = df.dropna(subset=["SpotPriceDKK"]) df[cols] = df[cols].interpolate(method="linear", limit=8) df = df[df.index.duplicated() == False]

逻辑说明:interpolate用线性插值填补短时间段的缺失值,limit=8限制最多连续补 8 个小时,避免长段缺失时插值插出虚拟曲线。电力数据一般只有零星缺失,超过 8 小时的连续缺失建议直接删掉那一整段。sort_index必须做,否则后面按行号做 lag 操作时会串位。

参数说明:SpotPriceDKK是丹麦克朗计价的现货电价,WindPowerMW是风电出力,PowerConsumptionMW是系统负荷。如果数据集里没有负荷字段,可以用区域间交换功率近似,效果会差一点,但模型结构不用改。

2.2 滞后特征、滚动均值与时间编码

LSTM 本身只能看到窗口内的历史值,但电价有明显的日周期和周周期,这些规律要靠特征显式提供给模型。我一般会构造三组特征:滞后值、滚动统计量、时间标识。

df["lag_24"] = df["SpotPriceDKK"].shift(24) df["lag_168"] = df["SpotPriceDKK"].shift(168) df["rolling_mean_24"] = df["SpotPriceDKK"].rolling(24).mean() df["rolling_mean_168"] = df["SpotPriceDKK"].rolling(168).mean() df["hour"] = df.index.hour df["dayofweek"] = df.index.dayofweek df["wind_lag_24"] = df["WindPowerMW"].shift(24)

逻辑说明:lag_24是前一天同一时刻的价格,lag_168是上周同一时刻的价格,这两个特征是电价日周期和周周期的直接映射。rolling_mean_24和rolling_mean_168提供短期和中期价格中枢,相当于给模型一个“现在价格处于什么水平”的参照系。

参数说明:24 和 168 分别对应小时数。如果数据不是小时级,需要换算成对应周期的步数,比如 15 分钟数据就是 96 和 672。hour和dayofweek作为数值特征传入时,LSTM 能学到日内和一周内的波动规律。注意shift之后会产生 NaN,这些行后面要统一按时间序切掉,不能留到滑窗里。

2.3 归一化与滑窗样本生成

归一化是所有 LSTM 训练里必须做的一步,但顺序有讲究:先切分,再 fit scaler,否则验证集的信息会偷偷跑进训练集。这一点后面避坑章节还会展开。

from sklearn.preprocessing import MinMaxScaler feature_cols = ["WindPowerMW", "PowerConsumptionMW", "lag_24", "lag_168", "rolling_mean_24", "rolling_mean_168", "hour", "dayofweek"] target_col = "SpotPriceDKK" df_clean = df[feature_cols + [target_col]].dropna() train_df = df_clean.iloc[:int(len(df_clean) * 0.8)] val_df = df_clean.iloc[int(len(df_clean) * 0.8):int(len(df_clean) * 0.9)] test_df = df_clean.iloc[int(len(df_clean) * 0.9):] scaler_x = MinMaxScaler() scaler_y = MinMaxScaler() train_x = scaler_x.fit_transform(train_df[feature_cols]) val_x = scaler_x.transform(val_df[feature_cols]) test_x = scaler_x.transform(test_df[feature_cols]) train_y = scaler_y.fit_transform(train_df[[target_col]]) val_y = scaler_y.transform(val_df[[target_col]]) test_y = scaler_y.transform(test_df[[target_col]])

逻辑说明:scaler_x只在训练集上 fit,验证集和测试集直接transform,这样能保证模型在验证阶段看到的数值范围与真实上线时一致。价格单独用scaler_y归一化,目的是预测完成后反归一化时只还原价格这一列,不受其他特征尺度干扰。

参数说明:MinMaxScaler 会把数据压到 0 到 1 之间,对 LSTM 的收敛速度帮助很大。如果数据里有明显的极端尖峰,可以改成 RobustScaler,它对异常值更稳。切分比例 8:1:1 是时间序列任务里比较常用的分配,但必须按时间顺序切,不能随机 shuffle。

def make_sequences(x, y, seq_len=48): X, Y = [], [] for i in range(seq_len, len(x)): X.append(x[i - seq_len:i]) Y.append(y[i]) return np.array(X), np.array(Y) SEQ_LEN = 48 train_X, train_Y = make_sequences(train_x, train_y, SEQ_LEN) val_X, val_Y = make_sequences(val_x, val_y, SEQ_LEN) test_X, test_Y = make_sequences(test_x, test_y, SEQ_LEN) print(train_X.shape, train_Y.shape)

逻辑说明:make_sequences把连续 48 小时的特征窗口作为输入,第 48 小时之后的价格作为预测目标。滑窗按步长 1 滑动,样本之间有大量重叠,这是时序预测的常见做法,会让训练样本数量扩增几十倍。

参数说明:seq_len=48表示用过去两天的数据预测下一点,这个长度对电价来说是够用的。改到 72 或 96 一般不会带来明显提升,反而会缩短可用的样本数。如果想预测 24 小时后的价格,就把Y改成y[i + 23],但后面多步预测我们会用递归方式来做,单步目标保持为“下一小时价格”。

3. PyTorch-LSTM 模型搭建:两层结构比一层稳得多

3.1 网络结构与参数量选择

电价预测任务的输入是多维特征序列,输出是单个数值,属于典型的序列回归问题。单层 LSTM 对电价这种强周期信号也能拟合,但对日内尖峰的响应会比较迟钝,我一般用两层 LSTM,第二层在第一层的隐藏状态之上继续提取更高阶的模式。

import torch import torch.nn as nn class PriceLSTM(nn.Module): def __init__(self, input_size, hidden_size=64, num_layers=2, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, ) self.head = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(0.1), nn.Linear(32, 1), ) def forward(self, x): out, _ = self.lstm(x) return self.head(out[:, -1, :])

逻辑说明:out[:, -1, :]取的是 LSTM 最后一个时间步的隐藏状态,它编码了整个输入窗口的信息。接一个两层全连接头把 64 维映射到 1 维价格。batch_first=True让输入形状为(batch_size, seq_len, input_size),在 PyTorch 里这样写更直观。

参数说明:hidden_size=64在小时级电价数据上足够,如果数据量很大可以提到 128。num_layers=2是当前时序任务里性价比最高的配置,三层以上的提升很有限,而且训练时间接近翻倍。dropout=0.2只作用于层与层之间,单层 LSTM 时这里必须设 0,否则 PyTorch 会直接报错。

3.2 DataLoader 与训练配置

模型定义好之后,要把滑窗生成的 NumPy 数组包成 DataLoader。批次大小和时间顺序都要设计好,电价序列样本之间有依赖关系,所以训练时不能像图像分类那样做随机 shuffle。

from torch.utils.data import TensorDataset, DataLoader tr_dataset = TensorDataset(torch.tensor(train_X, dtype=torch.float32), torch.tensor(train_Y, dtype=torch.float32)) val_dataset = TensorDataset(torch.tensor(val_X, dtype=torch.float32), torch.tensor(val_Y, dtype=torch.float32)) BATCH_SIZE = 64 tr_loader = DataLoader(tr_dataset, batch_size=BATCH_SIZE, shuffle=False, drop_last=True) val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False)

逻辑说明:shuffle=False是刻意为之。电价序列前后样本高度相关,随机打乱会让模型在训练时看到未来片段的统计信息,验证集表现虚高,真实预测效果却会打折扣。drop_last=True把最后不足一个批次的数据丢掉,避免形状不一致带来计算问题。

参数说明:batch_size=64是相对稳妥的起点。太大容易让训练震荡,太小收敛不稳。电价数据量一般是数万小时,64 的批次大小能让每个 epoch 迭代次数适中。如果显存紧张,降到 32 也行,但要同步把学习率调小一点。

3.3 损失函数与训练循环

损失函数的选择在这里很关键。MSELoss 对异常尖峰敏感,电价偶尔出现几十倍的极端值,会把梯度带偏。HuberLoss 在误差较小时接近 MSE、误差较大时接近 MAE,对尖峰更宽容,我一般优先选它。

criterion = nn.HuberLoss(delta=1.0) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=5, verbose=True ) best_val_loss = float("inf") for epoch in range(80): model.train() train_losses = [] for xb, yb in tr_loader: optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() train_losses.append(loss.item()) model.eval() val_losses = [] with torch.no_grad(): for xb, yb in val_loader: pred = model(xb) loss = criterion(pred, yb) val_losses.append(loss.item()) avg_val_loss = np.mean(val_losses) scheduler.step(avg_val_loss) if avg_val_loss < best_val_loss: best_val_loss = avg_val_loss torch.save(model.state_dict(), "best_price_lstm.pt")

逻辑说明:clip_grad_norm_把梯度范数限制在 1.0 以内,这是 LSTM 训练里的默认操作,否则梯度在时间步反向传播时容易指数级放大。ReduceLROnPlateau在验证损失连续 5 个 epoch 不下降时把学习率减半,比固定学习率更省心。

参数说明:delta=1.0控制 HuberLoss 从 MSE 切换到 MAE 的阈值。归一化之后的价格在 0 到 1 之间,delta 取 1.0 意味着大部分时间误差都走 MAE 分支,对尖峰更稳。weight_decay=1e-5是 L2 正则,防止全连接层过拟合。80 个 epoch 配合早停逻辑已经足够,再多会开始记住训练集中的噪声。

4. 训练评估与 24 小时递归预测:从损失函数到预测曲线

4.1 评估指标:MAPE 在负电价面前会失效

模型训练完,第一步是看验证集上的单步预测误差。这里要特别注意一个坑:MAPE(平均绝对百分比误差)在丹麦电价场景下几乎不可用,因为现货价格经常为 0 甚至为负,MAPE 的分母一为 0,误差就直接变成无穷大。

from sklearn.metrics import mean_absolute_error, mean_squared_error preds = [] trues = [] model.eval() with torch.no_grad(): for xb, yb in val_loader: pred = model(xb) preds.append(pred.cpu().numpy()) trues.append(yb.cpu().numpy()) pred_arr = scaler_y.inverse_transform(np.concatenate(preds)) true_arr = scaler_y.inverse_transform(np.concatenate(trues)) mae = mean_absolute_error(true_arr, pred_arr) rmse = np.sqrt(mean_squared_error(true_arr, pred_arr)) bias = np.mean(pred_arr - true_arr) print(f"MAE: {mae:.2f} DKK/MWh, RMSE: {rmse:.2f} DKK/MWh, Bias: {bias:.2f}")

逻辑说明:scaler_y.inverse_transform把归一化后的价格还原成真实电价,这样算出来的 MAE 和 RMSE 才有物理意义。bias是带符号的平均误差,正数说明模型整体高估电价,负数说明低估,这个指标比 MAPE 实用得多。

参数说明:丹麦电价正常区间在 -100 到 1000 DKK/MWh 之间,MAE 在几十到一百多是常见水平。如果 MAE 值明显偏高,先检查是不是归一化后没有正确反变换,这个环节是新手最容易翻车的地方。

4.2 递归预测:用模型自己生成未来 24 小时

模型本身只做单步预测,也就是预测“下一个小时”。但需求是预测未来 24 小时,做法是把预测值当作真实值喂回输入窗口,不断滚动迭代。

def recursive_24h_predict(model, input_seq, steps=24): model.eval() current_seq = input_seq.clone() predictions = [] with torch.no_grad(): for _ in range(steps): pred = model(current_seq.unsqueeze(0)) pred_val = pred.item() predictions.append(pred_val) # 将预测值放在目标价格列,其余特征保持原样或更新 new_row = current_seq[-1].clone() target_col_idx = 0 # 价格列在特征中的索引需要按实际顺序调整 new_row[target_col_idx] = pred_val current_seq = torch.cat([current_seq[1:], new_row.unsqueeze(0)], dim=0) return predictions

逻辑说明:current_seq保存最近 48 小时的完整特征窗口。每预测出下一小时价格,就把这个值放到窗口末尾,同时丢弃窗口第一个历史点。下一轮预测时,模型看到的价格序列里包含了它自己刚才的预测值,相当于模型在和自己对话。这种做法实现简单,但误差会随着步数增加逐渐累积。

参数说明:steps=24对应未来 24 小时。这个函数里的target_col_idx必须和前面滑窗时特征列的顺序严格一致,否则会把预测价格写到错误的特征列里。我的习惯是把价格列放在第一个位置,减少出错概率。

4.3 预测结果验证:滞后效应一眼就能看出来

递归预测做完之后,把预测曲线和真实曲线画在同一张图里,观察三个时间段:第 1 到 6 小时、第 6 到 12 小时、第 12 到 24 小时。我见过最多的失败模式是:前 6 小时拟合得很好,越往后曲线越平滑,最后变成一条接近均值的直线,峰谷全部消失。

如果遇到这种情况,先别急着调 LSTM 结构,做两件事。第一,检查预测值是不是在反复迭代中落到了某个固定值区间,如果是,大概率是特征里包含了未来信息,模型学到的是“今天的价格预测明天,明天的价格其实是今天的抄近路”。第二,统计每个预测步长的误差,误差从第 6 小时开始线性增长,说明递归反馈在正常起作用;如果误差从一开始就非常大,说明单步模型本身就没训练好。

5. 避坑排查:泄漏、夏令时和负电价,一次说清

5.1 数据泄露:把“当天真实值”当成了特征

现象:训练集损失很低,验证集曲线拟合得也很漂亮,但一上真实数据(模拟未来场景)预测值就完全失灵,误差比随机猜还离谱。

原因:做特征工程时用了包含未来信息的列。最典型的是把“当天实际风电出力”作为预测第 24 小时电价的特征,但实际应用时第 24 小时的风电出力还没有发生,这个特征在部署时根本取不到。另一个常见变体是:对全量数据做fit_transform,再切分训练和验证集,导致验证集的归一化范围泄露进模型。

解决:特征列逐个盘一遍,问自己“如果现在要预测明天 20 点电价,这个特征在那个时刻能不能拿到”。风电和负荷这类外部特征,必须使用预测值或滞后值,不能用真实值。归一化必须先切分再 fit,这个顺序写进代码规范里。

5.2 夏令时造成的时间错位

现象:lag_24取前一天同时刻电价时,发现某些日期错位,日内周期性特征在特定月份突然失效,模型预测误差在 3 月和 10 月明显上升。

原因:丹麦实行夏令时切换,每年有两天是 23 小时或 25 小时。如果按自然日的小时数去shift(24),跨过夏令时切换点时会把相邻两小时的数据错位,整个周期特征从这里开始串位。

解决:训练数据统一按 UTC 时间存储,特征工程完成后需要展示或分析时再转回本地时间。另一种做法是直接用行号位移,因为数据集本身就是小时级连续行,shift(24)实际上表示“往前数 24 行”,这样不受时钟跳变影响,只要确保sort_index先执行过。

5.3 负电价让 MAPE 崩溃、损失函数跑偏

现象:验证集上 MAPE 算出来是几千甚至无穷大,模型输出倾向于把所有价格都预测成接近 0,MAE 低但完全没抓住峰谷形态。

原因:负电价和零电价让 MAPE 的分母为 0 或负数,这个指标在丹麦市场根本不适用。更隐蔽的是,如果损失函数用了 MSE,负电价样本会给模型传递“输出接近 0 就能降低平均误差”的错误信号,模型会主动收敛到低波动区间。

解决:换用 MAE、RMSE 和 bias 组合评估,放弃 MAPE。损失函数用 HuberLoss,delta 设 1.0,让负电价样本的梯度贡献可控。如果数据集里负电价占比过高,可以考虑单独统计正负电价时段的误差,不要一刀切。

5.4 24 步递归预测后期变成“平直线”

现象:递归预测到第 12 小时以后,预测曲线慢慢变成一条接近电价均值的水平线,日内峰值完全消失。

原因:递归预测时每次把预测值当真实值喂回去,误差被 LSTM 的非线性映射反复压缩。序列内逐渐丢失高频波动信息,最后输出趋近训练集中价格的平均水平,这是一种信号衰减。

解决:最简单有效的做法是限制递归步数,把 24 小时切成 4 段,每 6 小时重新用真实值校正一次输入窗口。如果必须一次预测 24 小时,尝试在后端加一个辅助输出层,让模型直接决策每个步长的权重,而不是一步步纯递归迭代。更系统的方案会在下一章细讲。

5.5 训练损失持续抖动,验证集评分数小时不变

现象:训练损失曲线像锯齿一样大幅波动,80 个 epoch 跑完验证误差几乎没有下降趋势。

原因:学习率设置偏高,或者没有做梯度裁剪,LSTM 在时间步反传过程中梯度范数爆炸后又被 AdamW 自动调整,导致参数在最优解附近反复横跳。另外验证集没有做归一化还原比较,也可能把正确下降趋势掩盖掉。

解决:把学习率降到 5e-4,加clip_grad_norm_(model.parameters(), 1.0),两者配合后训练曲线通常会明显平滑。验证时每 5 个 epoch 打印一次反归一化后的 MAE,直接看物理单位的误差变化,而不是看归一化空间的 loss。

6. 进阶:用 scheduled sampling 压住递归误差累积

递归预测误差累积的根源是:训练时模型每一步都吃真实值,推理时却要吃自己生成的预测值,两者分布不一致。解决办法是让模型在训练阶段就逐渐适应“被喂预测值”的状态,这就是 scheduled sampling。

import random def train_with_scheduled_sampling(model, loader, optimizer, criterion, epoch, total_epochs, target_col_idx=0, seq_len=48): model.train() # 训练初期 80% 概率用真实值,后期逐步降低到 20% real_prob = 0.8 * (1 - epoch / total_epochs) + 0.2 for xb, yb in loader: optimizer.zero_grad() # 只在训练时对序列做部分替换 if random.random() < real_prob: pred = model(xb) else: with torch.no_grad(): pred_first = model(xb[:, :seq_len - 1, :]) # 用前47步预测第48步 xb_aug = xb.clone() xb_aug[:, -1, target_col_idx] = pred_first[:, -1] pred = model(xb_aug) loss = criterion(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step()

逻辑说明:训练早期real_prob接近 1.0,模型绝大多数时候看到的是真实历史序列,能快速学会基础映射。随着 epoch 增加,real_prob逐渐下降,模型中后期开始频繁看到“前 47 步真实值 + 第 48 步模型自己的预测值”,推理时的分布偏移被提前暴露给模型,让它学着纠正自己的误差。

参数说明:这里简化了每一步都替换的实现,只替换最后一步。工程上可以每步都用预测值重建序列,实现会更复杂,但效果提升有限。我自己的实践是:先训练 40 个 epoch 普通监督学习,再切到 scheduled sampling 微调 20 个 epoch,比全程开启收敛更稳。另外别忘了target_col_idx依然要和特征列顺序保持一致,这个参数在两种训练模式里都用同一个值。

有一次我把这个过程大幅简化,直接用纯递归做 24 小时预测,结果在验证集上第 18 小时之后的曲线已经看不出任何峰谷。从那以后我每次做多步预测,都强制先跑一遍步长误差分析,看第 6、12、24 小时的 MAE 是否逐级递增;如果递增得不成比例,先回头查特征泄露,再检查训练模式。这个排查顺序帮我省下过不少调参时间,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询