基于PyTorch LSTM的电价预测实战:未来24小时曲线构建解析
简介面向电力市场分析与时间序列预测学习者的PyTorchLSTM电价预测项目目标是根据丹麦市场历史特征与价格滚动预测未来24小时电价。项目完整覆盖数据清洗、特征构建、趋势与周期性分析、LSTM模型搭建、训练评估等流程适合具有Python基础、希望实践深度学习时序建模的开发者。包内共23个文件核心为6个Python脚本含主程序main.py、LSTM模型定义、seq2seq配置、4个CSV真实历史数据含丹麦2023年价格与特征、1个pth模型权重文件以及结果展示图片和readme说明压缩包仅395KB轻量易用。已有1030人学习下载。除可直接运行的预测管线外还提供价格与特征关系可视化图并示范了MSE损失、Adam优化、早停等训练细节以及RMSE/MAE评估方法可帮助读者理解电价高波动与非线性场景下的建模要点快速迁移到其他电力市场预测任务。1. 电价曲线预测用 PyTorch LSTM 看穿未来 24 小时晚上八点后电价一路走低次日凌晨往往跌到全天最低寒潮一来午后电价又能冲到平日的三倍。这个项目做的事就是基于历史电价和负荷、天气等特征用 PyTorch 搭一个 LSTM 回归模型预测未来 24 小时的逐小时电价曲线。整套代码把数据清洗、特征编码、模型训练、滚动预测和评估串成了一条完整链路拿到手就能对某欧洲国家的公开小时级电力数据跑出一版结果。适合正在做时间序列预测、能源数据分析或者想用 PyTorch 做回归而不是分类任务的开发者也适合拿来做毕业设计的基线模型。它不依赖昂贵的商业数据特征列和模型结构都能按自己的数据改。这份项目源码最有价值的部分不是训练脚本本身而是数据预处理和特征窗口的设计。2. 项目拆解与技术选型为什么 LSTM 能处理逐小时电价序列2.1 业务定位这不是普通回归问题逐小时电价和股票价格很像但不是白噪声。它有明显的日内周期性早高峰和晚高峰各出现一次有天气敏感性温度、风速影响供暖和风电出力还有历史记忆过去几天的价格会影响需求侧响应。直接拿当前时刻的天气特征去拟合当前价格模型只能学到均值真正影响预测质量的是时间顺序本身。这就是为什么这份代码选 LSTM 而不是普通全连接网络。从数学上看预测未来 24 个点等价于学习一个映射输入过去 N 小时的特征序列和价格序列输出未来 24 小时的价格序列。简单实现可以直接让最后一层输出 24 个神经元更复杂的做法是序列到序列seq2seq先编码再解码。这个项目采用前一种多步直接输出理由是未来 24 小时和多步自回归相比训练更稳定、误差不会在推理时逐点累积而且从工程现场的角度一天一条预测曲线够用。项目源码里对任务的边界也做了收敛它不预测实时电价波动也不做概率区间而是回归未来 24 个整点小时的均价。输入窗口取过去 72 小时也就是三天。这个窗口不是随便定的太短捕捉不到周内价格模式太长会把很久以前的市场状态也带进来反而干扰对明天尖峰时刻的判断。代码中所有时间窗口参数都集中在配置区方便二次调整。2.2 为什么是 PyTorch LSTMTransformer 在长序列上确实更强但电价按小时记录一年最多 8760 条对 Transformer 来说样本量很少容易过拟合LSTM 的时序归纳偏置更契合这种中等长度的序列。PyTorch 的 nn.LSTM 封装好了 CUDNN 加速batch_first 接口直观且能随时把中间隐状态拿出来调试。对于这种中小规模时序任务LSTM 是性价比最高的选择。这个项目的 LSTM 不是标准论文里的复杂变体而是工程化的三层结构embedding 层做特征投影LSTM 层提取时间依赖全连接输出头直接映射到 24 个未来时刻。代码里把 dropout 放在了 LSTM 多层堆叠之间训练时关闭部分神经元的连接减少对某些小时特征的过拟合。预测时 dropout 自动关闭推理结果更稳定。PyTorch 还有一个实际好处DataLoader 和 TensorDataset 可以省去手写滑动窗口的样板代码。项目里在构建数据集时直接返回了 (seq_len, feature_size) 的张量训练循环里不需要再处理变长序列也避开了 pack_padded_sequence 这种容易翻车的 API。2.3 整体处理流程整个预测流程可以拆成六个步骤读取原始 CSV、按小时重采样、清洗缺失值和异常电价、构造特征矩阵、切分训练验证集、训练并输出未来 24 小时曲线。每一步都在独立模块里方便单独替换。第一个可运行的步骤是数据预处理运行后生成一份清洗后的 parquet 文件后续特征工程和训练脚本都依赖这份中间结果这样调试模型时不用反复读原始大文件。# 项目根目录结构 price_forecast/ ├── data/ │ ├── raw/elspot_history.csv │ └── processed/clean_data.parquet ├── src/ │ ├── config.py │ ├── data_preprocess.py │ ├── build_features.py │ ├── dataset.py │ ├── model.py │ ├── train.py │ └── predict.py ├── notebooks/ │ └── 01_eda.ipynb ├── requirements.txt └── README.mdconfig.py 集中放所有超参数seq_len72、pred_len24、learning_rate1e-3、batch_size128、hidden_size64、num_layers2。之所以把参数集中是因为训练过程中需要反复比较不同窗口长度对预测结果的影响散落在各脚本里会改到怀疑人生。3. 核心模块拆解从原始 CSV 到可训练样本的完整链路3.1 数据预处理缺失值、异常电价、时间索引拿到原始数据后第一件事不是建模而是看时间索引是否连续。电力市场数据偶尔会缺某个小时直接 resample 会产生空值后续训练时 batch 对不齐。项目先按小时重采样再对缺失值做线性插值。这里有个细节电价不能像温度那样随意插值如果连续缺失超过 6 个小时线性插值会磨平价格尖峰导致模型学不到真实的波动形态。项目里对连续缺失超过 6 小时的片段做了整体剔除。电价还有一个特殊之处部分市场允许负电价也就是风电过剩时发电商倒贴钱卖电。负电价是真实信号不能直接当成脏数据丢掉。项目在预处理时用 clip(lower0) 把严重异常的负值清理掉但保留 -20 到 0 之间的合理负电价再经过后续标准化让模型区分正常低价和负电价场景。import pandas as pd import numpy as np def load_and_clean(raw_path: str, output_path: str) - pd.DataFrame: df pd.read_csv(raw_path, parse_dates[time]) df df.set_index(time).sort_index() # 统一到小时粒度避免重复索引和缺失索引 df df.resample(H).mean() # 连续缺失超过6小时的片段直接丢弃 is_missing df[price].isna() miss_group (is_missing ! is_missing.shift()).cumsum() drop_index [] for _, group in df[is_missing].groupby(miss_group): if len(group) 6: drop_index.append(group.index) if drop_index: df df.drop(pd.concat(drop_index).index) # 其余缺失用线性插值补全 df[price] df[price].interpolate(methodlinear, limit_directionboth) # 保留合理范围内的负电价仅过滤极端负值 df[price] df[price].clip(lower-20) df.to_parquet(output_path) return df逻辑说明resample 把原始数据规整到小时粒度mean 聚合可以消除同一小时内有重复记录时的误差。groupby 配合 is_missing 的变化点把缺失值按连续片段分组只对超过阈值的大缺口做删除。interpolate 的 limit_directionboth 表示序列开头和结尾的缺失也用邻近值补全。clip(lower-20) 是一个保守策略略小于市场真实价格下限防止极端异常值影响标准化。如果后续训练损失仍然很大可以把这个阈值继续收紧但不能用 0否则会丢失负电价这个重要市场信号。3.2 特征工程历史特征与价格的特征矩阵原始数据通常只有时间戳、电价、负荷、风电出力和温度几列直接喂给 LSTM 不够。电价预测的特征工程核心是构造时间周期特征和滞回特征。时间周期用 sin/cos 编码而不是直接填 0~23因为小时数值 23 和 0 在欧氏距离上很远但实际是相邻时刻。sin/cos 编码能让模型学到 0 点和 23 点在周期上接近。滞回特征则把过去 24 小时、48 小时、168 小时的电价、负荷拉出来作为额外列。这样的价值在于LSTM 虽然理论上能记住过去 72 小时的信息但工程上显式给出关键滞后量可以显著降低学习难度。另一个常用特征是滚动均值它能平滑短时波动让模型不过度反应前一小时的偶发尖峰。def build_features(df: pd.DataFrame) - pd.DataFrame: df df.copy() hour df.index.hour df[hour_sin] np.sin(2 * np.pi * hour / 24) df[hour_cos] np.cos(2 * np.pi * hour / 24) df[week_sin] np.sin(2 * np.pi * df.index.dayofweek / 7) df[week_cos] np.cos(2 * np.pi * df.index.dayofweek / 7) # 滞后特征 for lag in [24, 48, 168]: df[fprice_lag_{lag}] df[price].shift(lag) df[fload_lag_{lag}] df[load].shift(lag) # 滚动窗口特征 df[price_ma_24] df[price].rolling(24).mean() df[price_std_24] df[price].rolling(24).std() df df.dropna() return df逻辑说明shift 生成滞后特征时前 168 行会因为缺少历史数据变成 NaN直接用 dropna 丢弃这些样本会损失约一周的数据量。从可复现的角度这种方式最简单如果要保样本量可以用全局均值填充首尾 NaN但会稍微改变分布。滚动均值 price_ma_24 和滚动标准差 price_std_24 帮助模型感知当前价格水平是处于高位震荡还是低位稳定。这里有个容易踩坑的点rolling(24) 默认用过去 24 个点如果索引不是严格连续小时窗口内实际包含的条数少于 24计算结果会偏。因此在特征工程之前必须先执行上一步的 resample。3.3 构建序列数据集滑动窗口与训练验证切分模型输入是一个三维张量形状为 (样本数, seq_len, feature_size)。样本数由滑动窗口从连续时间轴上滑出。比如总共有 8760 条小时数据seq_len 取 72能产生的训练样本约 8700 个。这里关键点是切分训练集和验证集必须按时间切不能随机打乱。随机打乱会让验证集里混入未来数据模型等于提前看到了答案验证损失会低估真实误差。import torch from torch.utils.data import TensorDataset, DataLoader def make_dataset(df: pd.DataFrame, feature_cols: list, seq_len: int, pred_len: int): data df[feature_cols].values X, y [], [] for i in range(len(data) - seq_len - pred_len 1): X.append(data[i:i seq_len]) y.append(df[price].values[i seq_len:i seq_len pred_len]) X np.array(X, dtypenp.float32) y np.array(y, dtypenp.float32) # 按时间顺序切分最后20%作为验证集 split int(len(X) * 0.8) X_train, X_val X[:split], X[split:] y_train, y_val y[:split], y[split:] train_ds TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train)) val_ds TensorDataset(torch.from_numpy(X_val), torch.from_numpy(y_val)) train_loader DataLoader(train_ds, batch_size128, shuffleFalse) val_loader DataLoader(val_ds, batch_size128, shuffleFalse) return train_loader, val_loader逻辑说明循环里 i 从 0 滑到 len(data) - seq_len - pred_len确保训练样本的输入窗口和预测窗口不重叠。y 取的是 iseq_len 到 iseq_lenpred_len 之间的 24 个价格点。这就是多步直接预测的训练模式模型一次输出 24 个值。shuffleFalse 在这个场景下很重要。很多跑分类项目的开发者习惯性把 shuffleFalse 忽略但在时序任务里shuffle 会破坏样本之间的时间依赖虽然每个样本内部还是有序的但不同样本之间的顺序被打乱会让验证集的切分失去意义。如果验证集包含训练集时间范围内的数据那验证时模型看到的其实是在自问自答。4. 模型训练与推理LSTM 前向传播和 24 点滚动输出4.1 网络结构定义从隐状态到 24 个电价模型结构不复杂但有几个设计点值得说。LSTM 输入的 feature_size 是特征工程后的列数比如原始列加上滞后列、滚动列、时间编码列一共 12 个。hidden_size 设为 64两层 LSTM 堆叠。最后一层全连接从 hidden_size 直接映射到 pred_len也就是 24。中间加了一层 64 维的隐藏层和 ReLU给模型一点非线性容量。这里没有用 seq2seq 的 Decoder 结构因为实验下来在电价这种波动剧烈但周期明显的任务上一步到位输出 24 个点比逐个自回归更稳。自回归推理时如果前一步预测偏差大误差会滚雪球往往预测出一天都是同一个价位这种翻车现场在多步预测任务里非常常见。import torch.nn as nn class PriceLSTM(nn.Module): def __init__(self, input_size: int, hidden_size: int 64, num_layers: int 2, output_size: int 24): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropout0.2 if num_layers 1 else 0.0 ) self.head nn.Sequential( nn.Linear(hidden_size, 64), nn.ReLU(), nn.Linear(64, output_size) ) def forward(self, x): # x: (batch, seq_len, feature_size) out, _ self.lstm(x) # 取最后一个时间步的隐状态 last_hidden out[:, -1, :] return self.head(last_hidden)逻辑说明batch_firstTrue 让输入张量形状变成 (batch, seq_len, feature_size)更接近直觉也方便直接喂给 DataLoader。out[:, -1, :] 取的是每个样本最后一个时间步的 LSTM 输出这个向量包含了过去 72 小时压缩后的信息。头部全连接把 64 维压到 24 维。参数说明hidden_size 越大模型能记忆的细节越多但在 8000 条样本的量级下超过 128 就会明显过拟合训练损失和验证损失开始脱节。num_layers2 已经能捕捉电价的多尺度波动再加一层收益很小且训练时间翻倍。dropout 只对多层 LSTM 生效单层时 PyTorch 要求 dropout 必须为 0。4.2 训练循环损失、梯度裁剪、学习率训练的目标函数用均方误差 MSE因为电价预测要惩罚大偏差一个尖峰时刻错过会导致全天预测曲线都不像样。优化器选择 Adam学习率 1e-3。为了抑制电价尖峰带来的梯度爆炸训练循环里加了 grad clip这是处理电价这类重尾分布数据的常见做法。import torch.optim as optim def train_one_epoch(model, loader, criterion, optimizer, clip1.0): model.train() total_loss 0 for xb, yb in loader: optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss loss.item() * xb.size(0) return total_loss / len(loader.dataset)逻辑说明每个 batch 前向传播拿到预测曲线和真实 24 点价格计算 MSE反向传播后 clip_grad_norm_ 把梯度的 L2 范数限制在 1.0 以内。如果某一天电价突然出现极端尖峰这个 batch 的梯度可能非常大不裁剪的话参数一步更新幅度过大之后几个 epoch 损失都缓不过来。这里还有个实际细节loss.item() 需要乘上 batch size再除以整个数据集的样本数得到每个 epoch 的平均损失。如果直接 len(loader) 平均会忽略最后一个 batch 样本数量不足的问题。虽然对训练过程影响不大但打印出来的曲线会有点抖动容易误导调参判断。4.3 推理与反标准化把预测结果还原成真实电价模型在标准化后的数据上训练输出也是标准化后的数值。要得到真实电价必须用训练时保存的 scaler 做 inverse_transform。项目在预处理阶段对特征列和价格列分别做了 StandardScaler预测时不能再对全量数据重新 fit否则缩放参数会变化预测结果整体偏移。推理时还需要准备最近 72 小时的真实特征窗口。这里最容易犯的错是直接拿训练集的最后 72 小时来做预测验证时表现不错上线后一执行发现线上数据的时间格式和特征列和训练时不一致结果全是 NaN。实际工程中推理输入来自最近 72 个小时的实时入库数据而不是训练文件。def predict_next_24h(model, recent_df, feature_cols, feat_scaler, price_scaler, seq_len72): model.eval() recent recent_df[feature_cols].values[-seq_len:] recent_scaled feat_scaler.transform(recent) x torch.from_numpy(recent_scaled).float().unsqueeze(0) with torch.no_grad(): pred_scaled model(x).squeeze(0).numpy() pred price_scaler.inverse_transform(pred_scaled.reshape(-1, 1)).flatten() return pred逻辑说明unsqueeze(0) 是为了把二维的 (seq_len, feature_size) 变成 (1, seq_len, feature_size)模拟一个 batch size 为 1 的输入。pred_scaled 是标准化的 24 个值reshape(-1, 1) 是因为 price_scaler 在训练时是按二维列保存的inverse_transform 需要二维输入。参数说明recent_df 必须是按时间升序排列的最新数据取最后 72 行。如果线上数据实时性有延迟比如缺失最近 2 个小时的数据预测曲线会整体后移这个偏差很难从结果中直接看出来。我通常会在推理脚本里加一个时间戳校验确保 recent_df 最后一条记录距离当前时间不超过 1 小时否则拒绝预测并报警。5. 常见问题与排查调电价 LSTM 时最容易踩的 5 个坑5.1 预测结果出现大量负电价现象训练损失正常但预测曲线在凌晨时段经常冲到 -50 甚至更低和真实电价完全对不上。原因特征标准化后价格均值被移到 0 附近模型在输出层没有约束尾部使用 ReLU 或没有激活函数时遇到极端特征组合就可能输出负值另外数据清洗时没有对严重负值做过滤。解决在数据预处理阶段把 price 用向上截断到合理范围比如 clip(lower-20)同时检查是否对价格列做了正确的标准化StandardScaler 不改变数据分布形状只是平移缩放如果训练数据里本身有极端负值模型就会学出负值。必要时在输出层加 Sigmoid 再乘以一个最大电价常量把输出限制在 0 到最大值之间。5.2 验证集上 loss 很低线上预测却完全滞后现象验证集 MAE 只有几块钱但预测未来 24 小时的曲线整体比真实值晚 6 到 8 个小时早高峰错位严重。原因训练集和验证集切分时没有按时间顺序或者特征工程里用了未来数据。最常见的是构建滞后特征时Shift 参数写错方向把未来价格移到了当前特征里模型在验证集上其实是看到了答案。解决检查滞后特征的 shift 值是否为负数标准写法是 df[price].shift(24) 表示取 24 小时前的价格切分数据集时前端特征已经包含了历史数据因此直接按时间顺序取前 80% 做训练、后 20% 做验证即可。我习惯在特征工程之后专门打印一轮数据集的起始时间和终止时间确认验证集全部晚于训练集。5.3 loss 在训练到一半时突然飙升现象前 20 个 epoch loss 稳步下降到第 25 个 epoch 突然变成 NaN 或者翻好几倍而且不是偶发现象。原因电价序列存在极端尖峰一个 batch 里如果恰好出现了全年最大电价计算出的梯度范数非常大单纯靠 Adam 的自适应学习率压不住如果中间层数值过大反向传播经过多层 LSTM 后梯度会指数放大。解决加入梯度裁剪clip 值设置在 1.0 左右如果仍然不稳定降低初始学习率到 3e-4另外确认输入特征中没有原始温度值温度如果直接用摄氏度数值取值范围在 -20 到 35 之间和标准化后的其他特征不在同一量级会干扰梯度。所有连续特征必须经过 StandardScaler 或 MinMaxScaler。5.4 预测曲线整体比真实值平滑太多现象预测曲线能看到早高峰和晚高峰的位置但峰谷幅度明显偏小几乎变成一条波动很小的曲线。原因MSE 损失对大幅值误差惩罚更重模型为了降低总损失倾向于输出接近均值的中等值这是回归任务里常见的回归到均值现象。解决改用 Huber Loss它对大误差的惩罚从二次变成一次减少极端尖峰对模型的主导或者把价格序列做对数变换压缩动态范围后再训练预测结果做 exp 还原。这个项目里换用 Huber Loss 后峰谷恢复明显虽然整体 MAE 差不多但预测曲线的形状更接近真实电价。5.5 预测时出现 NaN全链路排查询不到原因现象训练正常推理阶段第一次调用返回 NaN连续调用稳定复现。原因特征工程时 rolling 窗口生成了 NaN在构建样本时用 dropna 删除但推理用的最近 72 小时数据如果有缺失rolling 生成的值也是 NaN这些 NaN 进入 StandardScaler 后变成 NaN模型输出也跟着变 NaN。解决推理入口处先对 recent_df 做一次空值检查用 ffill bfill 补全缺失值再进入特征构造函数。这类问题最隐蔽因为训练集里没有 NaN 所以不会报错只有推理时才会暴露属于典型的运行时数据质量黑匣子。6. 二次开发把 24 小时电价预测改造成你自己的时间序列服务6.1 换数据源与换目标变量第一步是替换数据和特征列。项目里所有代码都读取 config 里的 feature_cols 列表你只需要把原始 CSV 的列名改成和配置一致。如果想预测的是区域负荷而不是电价把 price 列替换成 load 列Pred_len 维持 24LSTM 结构完全不用改。这类时间序列项目的迁移成本主要不在模型而在数据清洗逻辑的通用性。6.2 包装成定时预测接口常见做法是用 FastAPI 包一个 POST 接口接收最近 72 小时的时序数据返回未来 24 小时预测曲线。模型和 scaler 在启动时加载一次避免每次请求都重新读取权重文件。from fastapi import FastAPI import torch app FastAPI() app.post(/predict) def predict(payload: dict): recent_df pd.DataFrame(payload[recent]) pred predict_next_24h( model, recent_df, feature_cols, feat_scaler, price_scaler ) return {prediction: pred.tolist(), unit: EUR/MWh}这段代码的改造点在于生产环境里每小时跑一次预测服务器需要缓存最近 72 小时的真实数据而不是依赖前端传全量序列。可以在服务内部维护一个历史队列每次入库新数据时更新队列调用预测时直接从队列取最近 72 条。从那以后我每次调试时间序列模型都会强制走一遍时间顺序检查、特征泄漏检查和推理空值检查三条流水线再复杂的模型也不会在原型阶段就翻车。希望这篇笔记能帮你把这个电价预测项目跑起来也让你在改造成其他时序场景时少走一段弯路。本文还有配套的精品资源点击获取