LSTM电力价格预测实战:特征工程与数据泄露避坑指南 简介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(methodlinear, limit8) df df[df.index.duplicated() False]逻辑说明interpolate用线性插值填补短时间段的缺失值limit8限制最多连续补 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_len48): 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_len48表示用过去两天的数据预测下一点这个长度对电价来说是够用的。改到 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_size64, num_layers2, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout 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_firstTrue让输入形状为(batch_size, seq_len, input_size)在 PyTorch 里这样写更直观。参数说明hidden_size64在小时级电价数据上足够如果数据量很大可以提到 128。num_layers2是当前时序任务里性价比最高的配置三层以上的提升很有限而且训练时间接近翻倍。dropout0.2只作用于层与层之间单层 LSTM 时这里必须设 0否则 PyTorch 会直接报错。3.2 DataLoader 与训练配置模型定义好之后要把滑窗生成的 NumPy 数组包成 DataLoader。批次大小和时间顺序都要设计好电价序列样本之间有依赖关系所以训练时不能像图像分类那样做随机 shuffle。from torch.utils.data import TensorDataset, DataLoader tr_dataset TensorDataset(torch.tensor(train_X, dtypetorch.float32), torch.tensor(train_Y, dtypetorch.float32)) val_dataset TensorDataset(torch.tensor(val_X, dtypetorch.float32), torch.tensor(val_Y, dtypetorch.float32)) BATCH_SIZE 64 tr_loader DataLoader(tr_dataset, batch_sizeBATCH_SIZE, shuffleFalse, drop_lastTrue) val_loader DataLoader(val_dataset, batch_sizeBATCH_SIZE, shuffleFalse)逻辑说明shuffleFalse是刻意为之。电价序列前后样本高度相关随机打乱会让模型在训练时看到未来片段的统计信息验证集表现虚高真实预测效果却会打折扣。drop_lastTrue把最后不足一个批次的数据丢掉避免形状不一致带来计算问题。参数说明batch_size64是相对稳妥的起点。太大容易让训练震荡太小收敛不稳。电价数据量一般是数万小时64 的批次大小能让每个 epoch 迭代次数适中。如果显存紧张降到 32 也行但要同步把学习率调小一点。3.3 损失函数与训练循环损失函数的选择在这里很关键。MSELoss 对异常尖峰敏感电价偶尔出现几十倍的极端值会把梯度带偏。HuberLoss 在误差较小时接近 MSE、误差较大时接近 MAE对尖峰更宽容我一般优先选它。criterion nn.HuberLoss(delta1.0) optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-5) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5, verboseTrue ) 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 不下降时把学习率减半比固定学习率更省心。参数说明delta1.0控制 HuberLoss 从 MSE 切换到 MAE 的阈值。归一化之后的价格在 0 到 1 之间delta 取 1.0 意味着大部分时间误差都走 MAE 分支对尖峰更稳。weight_decay1e-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(fMAE: {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, steps24): 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)], dim0) return predictions逻辑说明current_seq保存最近 48 小时的完整特征窗口。每预测出下一小时价格就把这个值放到窗口末尾同时丢弃窗口第一个历史点。下一轮预测时模型看到的价格序列里包含了它自己刚才的预测值相当于模型在和自己对话。这种做法实现简单但误差会随着步数增加逐渐累积。参数说明steps24对应未来 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 算出来是几千甚至无穷大模型输出倾向于把所有价格都预测成接近 0MAE 低但完全没抓住峰谷形态。原因负电价和零电价让 MAPE 的分母为 0 或负数这个指标在丹麦市场根本不适用。更隐蔽的是如果损失函数用了 MSE负电价样本会给模型传递“输出接近 0 就能降低平均误差”的错误信号模型会主动收敛到低波动区间。解决换用 MAE、RMSE 和 bias 组合评估放弃 MAPE。损失函数用 HuberLossdelta 设 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_idx0, seq_len48): 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 是否逐级递增如果递增得不成比例先回头查特征泄露再检查训练模式。这个排查顺序帮我省下过不少调参时间希望帮到你。本文还有配套的精品资源点击获取