☰
PyTorch+LSTM实战:丹麦未来24小时电价预测工程解析
2026/10/20 2:32:01 网站建设 项目流程

简介:这是一份面向电力交易从业者、能源数据分析师及深度学习研究者的丹麦电力市场电价预测项目,聚焦未来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 当成 0~23 的普通数值,模型会误以为 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,把数值压到 0~1。这里有个必须注意的坑: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_len=168, pred_len=24): 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, dtype=torch.float32), torch.tensor(y, dtype=torch.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_size=64, num_layers=2, output_size=24): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) 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_size=len(feature_cols)) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5 ) 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_norm=1.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(f"epoch {epoch:02d} | " f"train {epoch_loss / len(train_loader):.4f} | " f"val {val_loss:.4f}")

train_loader和val_loader的构造在项目里是常规写法:DataLoader(train_dataset, batch_size=64, shuffle=True)。shuffle=True 这里值得多解释一句:虽然原始序列按时间排列,但滑窗生成的大量样本之间没有严格的前后依赖,shuffle 能让每个 batch 覆盖不同的季节和时段,收敛更快。但验证集和测试集必须 shuffle=False,保持时间顺序。

ReduceLROnPlateau接收的是验证集 loss,而不是每个 batch 的 loss。如果接成训练 loss,它永远在降,学习率永远不会调整,等于白挂。这是我第一次用这个调度器时踩过的坑,后来固定成「每个 epoch 结束跑一遍验证集再喂给 scheduler」的节奏。

3.3 预测与反归一化:把 0~1 的数还原成真实电价

模型输出的是归一化后的电价,要还原成 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 python=3.9 conda activate price_lstm pip install torch numpy pandas scikit-learn matplotlib

torch 用 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 60

train.py 跑完会在 output/ 下生成 best_model.pt 和训练曲线图,日志打印每个 epoch 的训练 loss 和验证 loss。接着跑预测脚本:

python src/predict.py --model output/best_model.pt \ --data data/electricity_prices.csv

predict.py 会选取测试集最后一段做预测,画出未来 24 小时的预测值与真实值对比图,保存为 output/prediction.png。第一次跑完先看那张图,比看任何指标都直观——曲线跟住了,再谈调参;曲线是横着的,说明数据或模型有硬伤。

4.3 怎么判断预测效果算好

电价预测没有绝对标准,我一般看两组指标。一是 MAE(平均绝对误差),单位 EUR/MWh。丹麦现货电价均值通常在 30~80 欧元区间,MAE 在 5~10 以内算能用的水平。二是 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。 原因:最常见的是学习率太大加上数据没有归一化。电价原始数值动辄几百,梯度一乘就爆炸。另一个原因是序列里有缺失值填充成的 0,0 在 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 时记得传 shuffle=False,或者像我一样干脆手动切片,从根上避免手滑。

5.4 坑四:训练慢到怀疑人生

现象:60 个 epoch 在 CPU 上跑了几个小时,还在第二个 epoch 打转。 原因:seq_len=168、hidden_size=64、两层 LSTM,这个组合在 CPU 上本身就吃力;如果 DataLoader 还设了 num_workers=0,数据加载和计算串行,进一步拖慢。 解决:先把 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, steps=24): 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)], dim=1) return results

这段代码的一个默认前提是:除价格外的其他特征在滚动过程中保持不变。如果 temperature、wind_speed 是历史量,这么填没问题;如果它们是预报量,就得用真实的逐小时预报数据替换,不能拿上一次的预测值顶替。

6.2 改造二:接入气象预报特征

丹麦电价受风电影响大,大风天价格被压到很低甚至负值,这是纯历史价格序列学不到的信息。常见做法是每天拉一次未来 24 小时的气温、风速预报,作为额外特征拼进 feature_cols。注意用的是「预报值」而不是「实况值」——预测时必须用当下能拿到的数据,否则又变成数据泄漏。我一般会改 build_features,让它在预测阶段从预报接口读数据,训练阶段用对应的历史实况值,两套数据源在列名上对齐即可,模型代码一行不用动。

6.3 验证方法:换数据集先画分布再训练

二次开发完,验证不是只看一次 loss。我会按这个顺序走:先画出新数据集的价格分布和时段曲线,确认周期性还在;再跑一个 10 个 epoch 的短训练,看 loss 是否单调下降;最后看三组预测图(工作日、周末、极端天气),横向对比改造前后的 MAE。这三步都过了,才敢说改造有效,否则宁可退回原来的模型。

从那以后,我每次接手一个新的电价或负荷数据集,都会强制自己先走一遍「画分布 → 定归一化 → 看残差」的流程,再开始调模型。这个习惯帮我绕开了很多表面上 loss 很低、实际预测不能用的坑。希望帮到你。

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

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

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

立即咨询