
简介这是一份面向电力交易从业者、能源数据分析师及深度学习研究者的丹麦电力市场电价预测项目聚焦未来24小时电价回归任务。项目以PyTorch为框架完整实现了基于LSTM的时间序列建模流程涵盖历史特征构造、数据预处理、模型训练与评估等关键环节适合已有Python基础并希望掌握RNN系模型在真实电价场景中落地的读者。压缩包共23个文件体积约395KB包含6个Python脚本负责数据探索、模型定义与训练入口等、4个CSV数据文件含2023年丹麦电价及预测结果、2个PNG结果图和1个已训练权重文件model_weights.pth项目配置与说明文档也一并收录目录结构直观清晰。目前已有1032人学习下载。通过代码可快速复现电价预测流程理解LSTM如何处理历史价格与特征序列的长期依赖关系并借鉴其数据分析和预测可视化的实现思路为后续开展其他电力市场时序预测提供可扩展的工程参考。1. 项目定位一份能直接跑出未来 24 小时丹麦电价的 PyTorch LSTM 工程做丹麦电力电价预测这个项目之前我一直觉得电价序列是种玄学白天能冲到上百欧每兆瓦时大风天又可能跌到零附近甚至负数。直到把这个 PyTorch LSTM 工程完整跑通看到预测曲线和真实电价贴在一起我才确认——电价虽然带尖峰和负值但日内双峰、周末低谷这类周期性是能靠 LSTM 学出来的。这个项目输入过去 168 小时的历史价格、时间特征和少量气象特征直接输出未来 24 小时的逐时电价。技术栈很纯粹PyTorch 负责模型训练和推理LSTM 负责序列建模pandas 做特征工程训练完自动出对比图。适合刚学完 LSTM 想找完整时序工程练手的人也适合做储能调度、电力交易、需要把电价预测落成代码的开发者。跑通后整条链路是闭环的改改配置就能换地区、换序列。2. 数据与特征工程电价序列的周期性、归一化与滑窗切分2.1 丹麦电价数据长什么样打开项目里的 CSV第一眼是几万行小时级记录。每一行是一个小时核心字段是 timestamp 和 price电价单位 EUR/MWh后面跟着若干特征列temperature气温、wind_speed风速、consumption系统负荷。这些特征列不是每个小时都有完整值传感器故障或节假日会导致空行第一步要先按时间排序、补缺失值。电价序列有三个肉眼可见的规律。一是日内双峰早上八九点和晚上六七点各有一个用电高峰价格跟着抬。二是周内差异周末的用电结构和工作日完全不同价格整体低一截。三是气象驱动冬天气温低、负荷高风电大发时价格被压下去甚至转负。这三条规律决定了 LSTM 的输入不能只给价格——时间特征必须有气象特征最好有。如果只喂裸价格序列模型能学到日内波动但学不到「为什么今天比昨天便宜」的原因。2.2 时间戳怎么变成特征时间戳本身不能直接进模型我一般拆出 hour、dayofweek、month 三个字段再做 sin/cos 周期编码。这样做的原因是如果把 hour 当成 023 的普通数值模型会误以为 23 点和 0 点离得很远实际上它们只隔一小时。sin/cos 编码把小时映射到单位圆上距离就对了。import pandas as pd import numpy as np def build_features(df): df df.copy() dt pd.to_datetime(df[timestamp]) df[hour] dt.dt.hour df[dayofweek] dt.dt.dayofweek df[month] dt.dt.month # 周期编码把 hour / dayofweek 映射到单位圆 df[hour_sin] np.sin(2 * np.pi * df[hour] / 24) df[hour_cos] np.cos(2 * np.pi * df[hour] / 24) df[dow_sin] np.sin(2 * np.pi * df[dayofweek] / 7) df[dow_cos] np.cos(2 * np.pi * df[dayofweek] / 7) feature_cols [ price, hour_sin, hour_cos, dow_sin, dow_cos, temperature, wind_speed, consumption ] return df, feature_cols说明一下 feature_cols 的设计price 放在第一列是为了后续反归一化方便取索引hour_sin/hour_cos 一组表达日内周期dow_sin/dow_cos 一组表达周内周期temperature、wind_speed、consumption 是外生变量归一化后直接用。month 字段没进特征列表是因为训练数据通常只覆盖一两年月周期的样本太少LSTM 学不出稳定的年周期加了反而增加过拟合风险。2.3 归一化、滑窗切分与 Dataset 封装电价数值范围波动很大尖峰能到几百平时几十直接喂给 LSTM 会让梯度震荡。常见做法是所有特征统一做 MinMaxScaler把数值压到 01。这里有个必须注意的坑scaler 只能用训练集 fit验证集和测试集只做 transform否则验证集的信息混进训练后面评估全是虚高。from sklearn.preprocessing import MinMaxScaler # 按时间顺序切分随机打乱 数据泄漏 train_len int(len(df) * 0.8) val_len int(len(df) * 0.1) train_df df.iloc[:train_len] val_df df.iloc[train_len: train_len val_len] test_df df.iloc[train_len val_len:] scaler MinMaxScaler() train_scaled scaler.fit_transform(train_df[feature_cols]) val_scaled scaler.transform(val_df[feature_cols]) test_scaled scaler.transform(test_df[feature_cols])注意时间序列切分必须按顺序8:1:1 只是习惯。测试集永远是最后一段模拟「用过去预测未来」的真实场景。切分完成后把二维数组切成样本每个样本是连续 168 个小时的特征矩阵标签是紧接着的 24 个小时的电价。这就是滑窗窗口每次往后挪一小时。from torch.utils.data import Dataset class PriceDataset(Dataset): def __init__(self, data, seq_len168, pred_len24): self.data data # (N, n_features) self.seq_len seq_len self.pred_len pred_len def __len__(self): return len(self.data) - self.seq_len - self.pred_len 1 def __getitem__(self, idx): x self.data[idx: idx self.seq_len] # 过去 168 小时特征 y self.data[idx self.seq_len: idx self.seq_len self.pred_len, 0] # 未来 24 小时电价 return (torch.tensor(x, dtypetorch.float32), torch.tensor(y, dtypetorch.float32))y 取第二维的第一个元素是因为 feature_cols 里 price 排在第 0 位。如果后面调整了特征顺序记得同步改这里的索引或者干脆在类外面传一个 price_col 参数。seq_len 选 168 小时而不是 24 小时是因为要让模型看到完整的周周期——周末和工作日的电价形态差异很大只看 24 小时学不到这个对比。3. 核心代码拆解LSTM 模型、训练循环与反归一化链路3.1 模型结构两层 LSTM 接一个全连接这个项目的模型不复杂核心是把一个 (batch, 168, n_features) 的输入序列压缩成 (batch, 24) 的预测输出。LSTM 层负责在时间维度上逐步编码最后一层全连接负责把最后一个时间步的隐藏状态映射到 24 个电价预测值。import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size, hidden_size64, num_layers2, output_size24): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue ) self.dropout nn.Dropout(0.2) self.fc nn.Linear(hidden_size, output_size) def forward(self, x): # x: (batch, seq_len, input_size) out, _ self.lstm(x) # out: (batch, seq_len, hidden_size)取最后一个时间步 last out[:, -1, :] last self.dropout(last) return self.fc(last)关键参数梳理一下参数取值说明input_size8对应 feature_cols 的特征维度hidden_size64隐藏状态维度再大收益有限且训练变慢num_layers2两层 LSTM 足够捕捉周内模式三层以上易过拟合output_size24未来 24 小时的逐时电价batch_firstTrue输入维度是 (batch, seq, features)数据组织更直观dropout 放在全连接前只在训练时生效目的是防止模型把训练集的尖峰背下来。input_size 不需要手动算训练脚本里直接用len(feature_cols)传进去这样改了特征列表也不会忘改模型。3.2 训练循环损失、优化器、梯度裁剪训练部分用的是最常见的配置MSELoss 做损失Adam 做优化器初始学习率 1e-3验证集 loss 连续 5 个 epoch 不降就把学习率砍半。电价预测是回归任务MSE 会放大尖峰处的误差让模型优先拟合大波动这正是我们想要的——电价预测最怕的就是尖峰时刻完全没反应。梯度裁剪值得单独提一句LSTM 在长序列上容易梯度爆炸clip 到 1.0 之后训练稳定非常多。def evaluate(model, loader, criterion): model.eval() total_loss, n 0.0, 0 with torch.no_grad(): for x, y in loader: loss criterion(model(x), y) total_loss loss.item() * x.size(0) n x.size(0) return total_loss / n model LSTMPredictor(input_sizelen(feature_cols)) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5 ) best_val_loss float(inf) for epoch in range(60): model.train() epoch_loss 0.0 for x_batch, y_batch in train_loader: optimizer.zero_grad() pred model(x_batch) loss criterion(pred, y_batch) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() epoch_loss loss.item() * x_batch.size(0) val_loss evaluate(model, val_loader, criterion) scheduler.step(val_loss) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_model.pt) print(fepoch {epoch:02d} | ftrain {epoch_loss / len(train_loader):.4f} | fval {val_loss:.4f})train_loader和val_loader的构造在项目里是常规写法DataLoader(train_dataset, batch_size64, shuffleTrue)。shuffleTrue 这里值得多解释一句虽然原始序列按时间排列但滑窗生成的大量样本之间没有严格的前后依赖shuffle 能让每个 batch 覆盖不同的季节和时段收敛更快。但验证集和测试集必须 shuffleFalse保持时间顺序。ReduceLROnPlateau接收的是验证集 loss而不是每个 batch 的 loss。如果接成训练 loss它永远在降学习率永远不会调整等于白挂。这是我第一次用这个调度器时踩过的坑后来固定成「每个 epoch 结束跑一遍验证集再喂给 scheduler」的节奏。3.3 预测与反归一化把 01 的数还原成真实电价模型输出的是归一化后的电价要还原成 EUR/MWh 才能看。price 是 feature_cols 的第 0 列所以从 scaler 里取第 0 列的最小值和最大值做线性逆变换。model.load_state_dict(torch.load(best_model.pt)) model.eval() pred_list, true_list [], [] with torch.no_grad(): for i in range(len(test_dataset)): x, y test_dataset[i] pred model(x.unsqueeze(0)) # (1, 168, 8) - (1, 24) pred_list.append(pred.squeeze(0).numpy()) true_list.append(y.numpy()) pred_scaled np.array(pred_list) # (N, 24) true_scaled np.array(true_list) # (N, 24) # 反归一化price 列是 scaler 的第 0 列 price_min scaler.data_min_[0] price_max scaler.data_max_[0] pred_price pred_scaled * (price_max - price_min) price_min true_price true_scaled * (price_max - price_min) price_min反归一化是新手最容易写错的地方。有些人直接把整个特征矩阵逆变换回来再取第 0 列这样也能出数但白算了一大堆无关特征而且当特征数量一多索引很容易错位。项目里这个写法只对 price 一列做逆变换干净也快。x.unsqueeze(0)是给单条样本补一个 batch 维度因为模型输入要求三维 (batch, seq_len, features)。跑完之后把 pred_price 和 true_price 按时间轴画在一起一眼就能看出模型对高峰和低谷的跟随程度。项目里这一步用 Matplotlib横轴是 24 个小时纵轴是电价两条线叠着画截图直接能当汇报材料。4. 运行指南从空环境到跑出第一条预测曲线4.1 环境准备与依赖安装项目依赖集中在深度学习那一套没有冷门库。我在干净的 Python 3.9 环境里装过一次几分钟能完成。conda create -n price_lstm python3.9 conda activate price_lstm pip install torch numpy pandas scikit-learn matplotlibtorch 用 CPU 版本就够了。这个项目的数据量是几万个小时级样本CPU 上跑 60 个 epoch 大概几十分钟GPU 反而要处理显存拷贝的额外开销。scikit-learn 只用了 MinMaxScaler没有它也可以手写归一化但有现成的组件不必重复造轮子。装完先验证环境import torch; print(torch.__version__)能打出版本号就算过了。4.2 数据准备与训练命令项目目录结构很清楚price_lstm/ ├── data/ │ └── electricity_prices.csv ├── src/ │ ├── config.py # 列名映射、路径、超参数 │ ├── data_prepare.py # 特征工程 滑窗切分 │ ├── model.py # LSTM 模型定义 │ ├── train.py # 训练入口 │ └── predict.py # 预测 画图 └── output/ ├── best_model.pt └── prediction.png把丹麦电价 CSV 放进 data/ 目录确认列名是 timestamp、price、temperature、wind_speed、consumption。如果实际列名不一样在 src/config.py 里改列名映射。config.py 里主要调整三块列名对应关系、seq_len 和 pred_len、以及归一化方式改完不用动模型代码。cd price_lstm python src/train.py --data data/electricity_prices.csv --epochs 60train.py 跑完会在 output/ 下生成 best_model.pt 和训练曲线图日志打印每个 epoch 的训练 loss 和验证 loss。接着跑预测脚本python src/predict.py --model output/best_model.pt \ --data data/electricity_prices.csvpredict.py 会选取测试集最后一段做预测画出未来 24 小时的预测值与真实值对比图保存为 output/prediction.png。第一次跑完先看那张图比看任何指标都直观——曲线跟住了再谈调参曲线是横着的说明数据或模型有硬伤。4.3 怎么判断预测效果算好电价预测没有绝对标准我一般看两组指标。一是 MAE平均绝对误差单位 EUR/MWh。丹麦现货电价均值通常在 3080 欧元区间MAE 在 510 以内算能用的水平。二是 RMSE它对尖峰更敏感——如果 MAE 不错但 RMSE 很高说明模型对极端尖峰完全没反应这在电力场景里是要命的。项目里 evaluate 函数打印的是平均 loss想看 MAE 可以加一段计算def evaluate_with_mae(model, loader): model.eval() total_mae, n 0.0, 0 with torch.no_grad(): for x, y in loader: pred model(x) total_mae torch.abs(pred - y).sum().item() n y.numel() return total_mae / n还有一条经验多预测几组不同日期别只看一组。这个项目测试集在最后 10% 时间段内逐小时滚动出多个 24 小时窗口。我会挑工作日、周末、大风天各看一组预测图确认模型不是只在某一种天气模式下有效。如果可视化脚本只画一组我会临时改一下索引多看几组再下结论。5. 避坑与排查训练电价序列时最常见的 5 个坑5.1 坑一loss 不降反升甚至出现 NaN现象训练日志里 loss 第一轮正常下降后面突然跳动最后变成 nan。 原因最常见的是学习率太大加上数据没有归一化。电价原始数值动辄几百梯度一乘就爆炸。另一个原因是序列里有缺失值填充成的 00 在 MinMaxScaler 处理后变成一个离群值反向传播时把权重拉飞。 解决先确认所有特征都已经过 MinMaxScaler且缺失值用前后向填充而不是补 0再把初始学习率降到 1e-3 以下加上梯度裁剪。我一般把这两件事一起做改完重新训练95% 的 NaN 都能消失。5.2 坑二预测曲线是一条「平移的昨天电价」现象预测曲线和真实曲线形状几乎一样但整体向右平移了 24 小时等于模型把昨天的电价抄了一遍。 原因这是滑窗序列预测最典型的翻车现场。模型发现最简单的策略就是「输出约等于窗口最后一个时刻的值」因为电价相邻小时高度相关这样搞 loss 也不会太差。尤其当窗口只有 24 小时、特征里又没有星期信息时模型根本没必要学周期。 解决把窗口拉长到 168 小时7 天确保输入里能看到完整周周期同时检查 feature_cols 里有没有 dow_sin/dow_cos没有就加上。如果还平移检查是不是切分时把相邻样本混进了训练集——时间序列一旦随机打乱就会造成这种假学习。5.3 坑三验证集效果虚高测试集直接翻车现象验证集 MAE 很漂亮一换到测试集就差一倍或者训练时验证 loss 一直降但预测图看起来完全不对。 原因数据泄漏。最常见的两个来源一是 MinMaxScaler 用全量数据 fit验证集和测试集的统计量混了进去二是切分前对 DataFrame 做了随机打乱导致模型提前见过「未来」的样本。 解决严格按时间顺序切分8:1:1 固定顺序scaler 只 fit 训练集验证集和测试集只 transform。用 sklearn 的 train_test_split 时记得传 shuffleFalse或者像我一样干脆手动切片从根上避免手滑。5.4 坑四训练慢到怀疑人生现象60 个 epoch 在 CPU 上跑了几个小时还在第二个 epoch 打转。 原因seq_len168、hidden_size64、两层 LSTM这个组合在 CPU 上本身就吃力如果 DataLoader 还设了 num_workers0数据加载和计算串行进一步拖慢。 解决先把 batch_size 从 64 降到 32每次前反向的计算量小一半跑完一个 epoch 确认收敛方向再说再把 DataLoader 的 num_workers 设成 4 或 8让数据加载并行。还嫌慢就把 seq_len 降到 120对周周期性影响不大训练时间能省约 30%。5.5 坑五反归一化后出现离谱负电价或尖峰被削平现象预测出来的电价出现 -20 欧元这种离谱负值或者真实 200 欧的尖峰预测出来只有 80 欧。 原因MinMaxScaler 对离群值敏感。一个 300 欧的尖峰把整个价格区间拉宽正常价格的归一化值被压到 0.1 以下LSTM 在低数值区间里很难做精细区分。MSE 损失又把尖峰当成必须拟合的重点模型为了压低尖峰处的 loss把其他时段都带歪了。 解决归一化前先对电价做分位数裁剪比如把 99% 分位以上的值截到 99% 分位的值砍掉极端离群点、保留形状再归一化。损失函数可以换成 Huber loss它对尖峰的惩罚温和一些不会让一两个尖峰主导整个训练方向。这两招合起来负电价和削峰问题基本能同时缓解。6. 二次开发把 24 小时点预测改成滚动预测并接入更多特征6.1 改造一单段预测改成滚动预测现在模型一次输出 24 个小时适合「早晨起来看一遍明天」。但如果做储能调度你想要的可能是「每过一个小时用最新数据重新预测未来 24 小时」。这个改造不需要动模型结构只改预测逻辑每次只取输出的第一个值拼回输入序列尾部丢掉最旧的一个时间步再预测下一个小时。这就是滚动预测。def rolling_forecast(model, init_seq, steps24): model.eval() seq init_seq.clone() # (1, seq_len, n_features) results [] with torch.no_grad(): for _ in range(steps): pred model(seq) # (1, 24) next_price pred[0, 0] # 只取第一个预测值 results.append(next_price.item()) # 用预测值回填 price 列拼回序列尾部丢掉最旧一步 new_step seq[:, -1, :].clone() new_step[:, 0] next_price seq torch.cat([seq[:, 1:, :], new_step.unsqueeze(1)], dim1) return results这段代码的一个默认前提是除价格外的其他特征在滚动过程中保持不变。如果 temperature、wind_speed 是历史量这么填没问题如果它们是预报量就得用真实的逐小时预报数据替换不能拿上一次的预测值顶替。6.2 改造二接入气象预报特征丹麦电价受风电影响大大风天价格被压到很低甚至负值这是纯历史价格序列学不到的信息。常见做法是每天拉一次未来 24 小时的气温、风速预报作为额外特征拼进 feature_cols。注意用的是「预报值」而不是「实况值」——预测时必须用当下能拿到的数据否则又变成数据泄漏。我一般会改 build_features让它在预测阶段从预报接口读数据训练阶段用对应的历史实况值两套数据源在列名上对齐即可模型代码一行不用动。6.3 验证方法换数据集先画分布再训练二次开发完验证不是只看一次 loss。我会按这个顺序走先画出新数据集的价格分布和时段曲线确认周期性还在再跑一个 10 个 epoch 的短训练看 loss 是否单调下降最后看三组预测图工作日、周末、极端天气横向对比改造前后的 MAE。这三步都过了才敢说改造有效否则宁可退回原来的模型。从那以后我每次接手一个新的电价或负荷数据集都会强制自己先走一遍「画分布 → 定归一化 → 看残差」的流程再开始调模型。这个习惯帮我绕开了很多表面上 loss 很低、实际预测不能用的坑。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。