简介:基于长短期记忆(LSTM)模型的电价预测源代码,面向电力市场分析、交易决策及时序预测学习场景,适合希望系统掌握LSTM建模流程的学生、研究人员或工程师。压缩包共33个文件,以Python脚本及pyc编译文件为主,辅以XML配置、CSV数据、模型权重与可视化图片,整体约540KB,覆盖数据预处理、模型训练、评估与测试等环节。目前已有203人学习或下载。借助这套代码,可完整走通电价预测流程:从原始数据转换、训练集划分,到LSTM网络设计调参,再到误差评估与结果输出;代码内还保留模型持久化文件,便于直接加载验证。同时项目结构清晰、复用性强,不仅适用于电价序列,也可迁移至股票等其他时序预测场景,是理解循环神经网络实际应用的优质范例。
1. 电价预测没那么玄:LSTM 是怎么把时序问题变成回归问题的
做电价预测,LSTM 几乎是入门必提的模型,但很多人拿到一份基于 LSTM 的电价预测源代码,第一反应是直接跑python train.py,结果不是报错就是预测出一条直线。我一开始也这样,后来发现真正要下功夫的不是模型本身,而是数据怎么喂进去、预测结果怎么从归一化后的数值还原成看得懂的电价。这份源代码覆盖了从数据清洗、滑窗构造、模型训练到评估的完整流程,适合电力交易员、做能源算法的工程师,以及想把 LSTM 用在实际时序预测上的学习者。它能帮你解决“为什么我的预测总是滞后一拍”和“怎么让模型捕捉到早高峰和晚高峰的电价突变”这两类最典型的问题。只要按时间顺序切数据、严格分离训练集和测试集,再把 scaler 存对,跑通一份源码并没有想象中那么难。
2. 先把数据收拾明白:电价序列的清洗、滑窗与归一化处理
2.1 电价数据的特征:缺失值、节假日与突变价格
电价序列和普通股票序列不太一样:一天 24 个小时,但很多市场按 15 分钟出清,所以一天有 96 个交易点。中午光伏大发时价格可能跌到接近零,晚高峰瞬间拉高,遇到极端天气还会有负电价。这些“突变”不是噪声,而是真实的供需信号,清洗时不能一刀切用中位数替换。我见过有人用 3σ 原则直接剔除所有超过阈值的价格点,结果把晚高峰的真实尖峰也删掉了,模型训练出来只会预测“平均电价”,毫无交易参考价值。
源码里对这一段的处理通常包括三步:第一步做缺失值检查,用前后时刻的均值填充,而不是用全天均值;第二步把节假日标记成独立特征,因为节假日的负荷曲线和普通工作日差很多;第三步识别明显的异常尖峰,比如价格超过正常范围 5 倍以上的点,先看它是否对应真实事件,如果不对应再剔除。我一般会在这一步额外输出一张price_with_missing.png来人工确认,而不是直接交给模型。人工确认的意义在于,有些尖峰是真实市场行为,比如极端天气下的限电价格,一旦删除就丢失了模型最需要学习的尾部模式。
下面是一个常见的缺失值填充片段:
import pandas as pd df = pd.read_csv("electricity_price.csv", parse_dates=["time"]) df = df.set_index("time").sort_index() # 电价序列重采样到 15 分钟,保证每个交易点有值 df = df.resample("15min").first() # 缺失值用前后两个点的均值填充,保留趋势 df["price"] = df["price"].interpolate(method="linear", limit_direction="both") # 附加节假日标记:1 表示节假日/周末,0 表示工作日 df["is_holiday"] = (df.index.dayofweek >= 5).astype(int)这里的关键是interpolate(method="linear")在时间轴上做线性插值,比fillna(method='ffill')更适合电价这种随时段变化快的序列,因为ffill会把缺失点直接补成上一个值,丢失了时段变化信息。limit_direction="both"让序列开头和结尾的缺失值也能被填上,否则前几个点会一直是 NaN,后续送入模型时会报错。
is_holiday这个特征在后续 LSTM 输入中会作为一个额外维度,帮模型区分不同日类型的价格曲线。注意这里只用了 weekday 判断周末,实际上很多地区还有法定节假日,比如元旦、春节,这些日子即便不是周末,负荷曲线也完全不同。更严谨的做法是维护一个本地节假日列表,把它也编码成 0/1 特征。源码里如果没有,我会自己加一个calendar模块实现。
2.2 滑窗构造训练样本:用多少步预测多少步
LSTM 的输入是一个三维张量:(样本数, 时间步长, 特征数)。所谓“时间步长”就是滑窗长度。电价预测里有个常见误区:滑窗越长越好。实际上电价受日前市场影响,往往过去 24 到 48 小时的数据足够,太长反而引入不必要的噪声,训练也变慢。我之前试过把滑窗拉到 168 步(一周),结果模型不但没有学到周周期性,反而把上个月某个异常价格事件当成了规律,预测结果一会偏高一会偏低。
我一般用过去 48 个点(也就是 12 小时)预测未来 24 个点(6 小时),这样既能抓住日内早晚高峰的周期性,又不会把一周前的偶然事件当成规律。如果你只需要预测下一个点,horizon=1就够了。源码里通常会提供一个create_sequences函数,核心逻辑如下:
import numpy as np def create_sequences(data, feature_columns, target_column, window_size=48, horizon=24): xs, ys = [], [] for i in range(len(data) - window_size - horizon + 1): x = data.loc[i : i + window_size - 1, feature_columns].values y = data.loc[i + window_size : i + window_size + horizon - 1, target_column].values xs.append(x) ys.append(y) return np.array(xs), np.array(ys)这里的window_size是回看步数,horizon是预测步数。注意y用的是i + window_size到i + window_size + horizon - 1,也就是完全在历史窗口之后的数据,这样才能避免标签泄漏。如果你把y错写成i+1 : i+1+horizon,模型就会同时看到过去和未来,验证集效果会好得离谱,但上线直接崩。
还有一个隐藏细节:样本之间高度重叠。第i个样本和第i+1个样本共享了 47 个时间点,所以训练集和测试集的切分必须在滑窗之前做,否则测试集里会混入训练集后半段的数据。常见做法是先按时间把df切成三块,再分别调用create_sequences,这样每个样本都严格属于某一个时间段。
2.3 归一化与反归一化:LSTM 的激活函数决定了你不能直接输入原始价格
LSTM 内部用的是 tanh 和 sigmoid 激活函数,它们的输出范围是 [-1,1] 和 [0,1]。如果直接把几千块钱一兆瓦时的价格灌进去,梯度很容易爆炸或消失。所以训练前要把所有特征归一化到 [0,1] 或 [-1,1]。源码里一般会提供一个 MinMaxScaler,但有两个细节特别容易踩:第一,fit只能用训练集,不能把整个数据集一起fit,否则信息会从测试集泄漏到训练过程;第二,inverse_transform时要用训练集的 scaler,而不是测试集自带的统计量。
正确的是这样写:
from sklearn.preprocessing import MinMaxScaler # 只对训练集 fit,再分别 transform 训练集和测试集 scaler_price = MinMaxScaler(feature_range=(0, 1)) train_data["price_scaled"] = scaler_price.fit_transform(train_data[["price"]]) test_data["price_scaled"] = scaler_price.transform(test_data[["price"]]) # 训练完成后,把预测值还原成真实价格 pred_price = model.predict(x_test) pred_price = scaler_price.inverse_transform(pred_price.reshape(-1, 1))注意MinMaxScaler要求输入是二维数组,所以单列时要用train_data[["price"]]而不是train_data["price"]。如果inverse_transform时报错形状不匹配,大概率是这里忘了 reshape。reshape(-1, 1)会让数组变成(样本数, 1),这样与 scaler 保存的形状一致。
还有一个容易被忽略的点:滑窗构造样本是在归一化之后进行,还是之前进行?两种做法都有人用。我习惯先归一化再滑窗,因为这样能保证每个特征在同一量纲下计算距离,而且滑窗后的三维数组可以直接进模型,不用再额外处理。但如果你选择了先滑窗再归一化,那么测试集滑窗时,你不能用测试集自己的均值和方差去归一化,而必须沿用训练集的 scaler。源码里如果发现 test 部分单独调用了fit_transform,这就是一个坑,评估结果会偏乐观。
3. 把 LSTM 网络搭起来:从单层到多层的选型与参数设置
3.1 网络结构:为什么单层 LSTM 往往够用,堆叠什么时候有效
在电价预测场景里,我见过太多人一上来就堆三层 LSTM,以为越深越准。实际上电价序列的周期性主要靠时间窗口内的模式识别,单层 LSTM 加上足够的神经元通常就能拟合。堆叠 LSTM 只有在输入特征非常丰富、需要分层提取信息时才有效,比如同时输入历史价格、天气预报、负荷预测和节假日特征。对于只有价格和少量衍生特征的数据,两层以上往往效果提升有限,反而更容易过拟合。
源码里的LSTM模型一般可以这样定义(PyTorch 版本):
import torch import torch.nn as nn class PriceLSTM(nn.Module): def __init__(self, n_features, hidden_size=64, num_layers=1, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=n_features, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0 ) self.regressor = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, 1) # 预测单步或最后一个时刻的价格 ) def forward(self, x): # x shape: (batch, seq_len, n_features) out, _ = self.lstm(x) # 取最后一个时间步的输出 out = out[:, -1, :] return self.regressor(out)这里有几个关键参数:hidden_size是 LSTM 隐藏层神经元数量,它决定了模型记忆容量;num_layers是堆叠层数;dropout只在多层时才生效,PyTorch 的nn.LSTM要求单层时 dropout 必须设为 0 或省略。batch_first=True让输入形状变成(batch, seq_len, features),更符合create_sequences的输出。regressor最后一层输出 1 个值,对应下一个时刻的价格。
hidden_size从 32 到 128 都比较常见。如果hidden_size太小,模型学不到价格突变模式;太大则容易记住训练集噪声,测试集上波动剧烈。我一般先试 64,然后看验证损失曲线再决定要不要加到 128。如果训练损失和验证损失都在高位不降,先别急着加参数,回头检查数据预处理。
3.2 关键参数:hidden_size、num_layers、dropout、学习率如何搭配
参数之间是相互影响的。hidden_size大,模型能记住更多模式,但也需要更大的训练集和更小的学习率。dropout一般设 0.2 到 0.5,但要注意只在训练时生效,评估时要关闭,否则预测结果会随机波动。学习率是另一个容易翻车的点,电价数据往往有尖峰,用固定学习率很容易在损失曲面震荡,我通常搭配一个自适应衰减器来用。
一段典型的训练配置:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = PriceLSTM(n_features=x_train.shape[2], hidden_size=64, num_layers=1, dropout=0.2).to(device) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=5, verbose=True )ReduceLROnPlateau会在验证损失连续 5 个 epoch 不下降时把学习率减半,这比手动调参省心得多。mode="min"表示监控的指标越低越好,对于 MSE 自然用 min。factor=0.5意味着每次衰减到原来的一半,不会一下子跌到零。如果你改用了 MAE 作为监控指标,同样也是 min。
另外,num_layers大于 1 时,dropout参数会传入nn.LSTM;如果num_layers=1,dropout无效,但你在regressor里也可以加一层 dropout。我常常会在全连接那里也加一层nn.Dropout(0.2),让正则化更充分。注意 PyTorch 在模型训练和评估模式下的 dropout 行为不同,调用model.eval()后 dropout 会自动关闭。
3.3 训练循环与早停:怎么判断模型真的在学
训练循环本身不复杂,但有个容易忽略的问题:验证集的损失不是每个 epoch 都单调下降,需要根据“最佳验证损失”保存模型,而不是用最后一个 epoch 的权重。很多源码直接保存最后一个 epoch 的模型,结果验证集上最好的那一步被覆盖了。我习惯这样写训练循环:
def train_model(model, train_loader, val_loader, epochs=50): best_val_loss = float("inf") patience = 0 max_patience = 10 for epoch in range(epochs): model.train() train_losses = [] for xb, yb in train_loader: xb, yb = xb.float().to(device), yb.float().to(device) optimizer.zero_grad() pred = model(xb).squeeze(-1) loss = criterion(pred, yb) loss.backward() optimizer.step() train_losses.append(loss.item()) val_loss = evaluate(model, val_loader) scheduler.step(val_loss) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), "best_model.pt") patience = 0 else: patience += 1 if patience >= max_patience: print(f"Early stop at epoch {epoch}") break model.load_state_dict(torch.load("best_model.pt")) return model这里的evaluate是另一个在torch.no_grad()下计算验证集 MSE 的函数。关键点是:torch.save只保存state_dict,而不是整个模型,这样加载时不必依赖原来的类定义结构。patience是早停耐心值,10 轮内没有提升就停止,可以避免白跑几十个 epoch。由于电价预测训练数据一般不大,epochs=50往往足够,如果数据量大,可以增加到 100。
注意:如果训练损失持续下降、验证损失反而上升,说明过拟合了,应该调大dropout或减小hidden_size。如果两个损失都不降,先检查数据归一化是否正确,再检查学习率是否过大。还有个细节:每次跑之前固定随机种子,包括torch.manual_seed、np.random.seed,以及 DataLoader 的worker_init_fn,这样才能保证调参前后的对比有意义。
4. 避坑与常见问题:跑通源码过程中最容易翻车的五个地方
4.1 问题一:训练损失下降但预测曲线是一条水平线
现象:训练过程中 MSE 一直在降,可视化测试集的预测结果却是一条接近水平的线,完全看不到早晚高峰波动。
原因:多半是滑窗构造样本时出了问题。常见的情况是窗口内目标值取的是窗口最后一个值,而输入特征里也包含了同一个值,模型学会了“直接输出最后一个输入”,而不是学习价格规律。更隐蔽的情况是归一化时把整个数据集一起 fit,测试集信息混进了 scaler,导致模型在验证时“偷看”了未来。
解决:先检查create_sequences里y是否与x的最后一个时间步重叠。如果重叠,把y改为i + window_size之后的值。然后重新检查归一化:只对训练集fit,再用同一个 scaler 去 transform 测试集。修复这两处后,预测曲线通常会恢复波动。我遇到过一位同事的脚本,y写成了data[i+window_size-1],模型直接成了“复制器”,当时也是查了大半天才发现。
4.2 问题二:反归一化后预测值严重偏移,比真实价格整体高或低一截
现象:预测形状正常,但数值整体偏高,比如真实价格在 300 到 500 之间,预测却在 350 到 550。
原因:inverse_transform时用错了 scaler。很多源码在训练脚本里没有保存训练集 scaler,评估时重新创建了一个 MinMaxScaler 对测试集fit_transform,这样会把测试集的 min/max 当作反归一化基准,导致整体偏移。另外,如果预测目标不止一个价格,而是价格加其他特征的不同量纲,也需要分别处理。
解决:在训练完成后把scaler_price保存下来,用pickle或joblib存成文件,评估时加载训练集的那个 scaler。顺便把scaler_feature也保存一份,因为多变量输入时每个特征都有自己的参数。
import joblib joblib.dump(scaler_price, "scaler_price.pkl") joblib.dump(scaler_feature, "scaler_feature.pkl")之后预测时:
scaler_price = joblib.load("scaler_price.pkl") pred_price_real = scaler_price.inverse_transform(pred_scaled.reshape(-1, 1))注意:如果预测的是多步序列,inverse_transform需要按列对应,不要把形状搞错。一个稳健的自检方法是:对训练集预测后反归一化,看它能否大致还原真实价格范围,如果训练集都还原不准,那说明 scaler 用法有问题。
4.3 问题三:滑窗泄漏:不小心把未来数据喂给了模型
现象:验证集效果极好,MAE 逼近 0.5,但部署到线上后预测一塌糊涂。
原因:这是典型的数据泄漏。可能是你在构造滑窗样本时用了全量数据,然后随机train_test_split,导致训练集和测试集有重叠时间段。也可能是你用了次日全天的平均电价作为特征,而这个问题在测试时是无法提前知道的。很多新人在做特征工程时,会顺手把“未来 24 小时的平均温度”也放进去,这在离线验证时看起来完美,上线时根本拿不到。
解决:严格按时间顺序划分训练集、验证集、测试集。比如前 70% 做训练,中间 10% 做验证,最后 20% 做测试。构造滑窗时只允许使用测试集时间段之前的数据,任何来自测试集时间段的统计量都不能参与训练。代码上可以用train_data = df[:cut1],val_data = df[cut1:cut2],test_data = df[cut2:],然后各自单独调用create_sequences。另外,特征列表里凡是涉及“当天后半夜的平均值”这类未来信息,一律删掉。
4.4 问题四:训练集和测试集没有按时间切分,而是随机洗牌
现象:训练损失正常,测试指标也很好,但因为随机洗牌,同一天的数据可能同时出现在训练和测试集里,模型实际上“背诵”了价格曲线。我见过一个模型在测试集上 MAE 只有 1.5,部署后每小时的误差超过 30,差了一个数量级。
原因:使用train_test_split(shuffle=True)或np.random.shuffle处理时间序列,这种操作在非时序任务里没问题,但在电价预测里等于作弊。电量价格是强自相关的,洗牌会让同一周的数据既出现在训练集又出现在测试集,模型看到的是“邻近的复制品”。
解决:使用TimeSeriesSplit或者手动切片。最简单的做法是:
train = df[:int(len(df) * 0.7)] val = df[int(len(df) * 0.7):int(len(df) * 0.85)] test = df[int(len(df) * 0.85):]之后对 train 和 test 分别调用create_sequences,保证测试集的每个样本都在时间上晚于训练集的所有样本。如果数据集跨越多个年份,还要注意季节性分布,最好让训练集覆盖完整的春夏秋冬,测试集再对应下一年的同期,这样评估结果才有可信度。如果只有一年的数据,也可以按“前 10 个月训练、后 2 个月测试”来切分。
4.5 问题五:GPU 显存溢出与 batch_size 的权衡
现象:训练时CUDA out of memory,或者调小 batch_size 之后模型反而不收敛。
原因:LSTM 的隐状态会随序列长度展开,占用大量显存。电价数据动辄百万行,滑窗后样本数巨大,如果 batch_size 设得太大,显存直接爆掉。调小 batch_size 后,梯度噪声变大,训练不稳定,验证损失曲线上蹿下跳。
解决:先固定seq_len=48,减小 batch_size 到 32 或 16,同时把hidden_size从 128 降到 64。如果还溢出,检查是否在forward里保留了所有时间步的输出,对于单步预测,可以直接用out[:, -1, :],不要保留out全序列。此外,可以在每个 epoch 后执行torch.cuda.empty_cache()释放碎片。如果显存实在紧张,改用梯度累积,每 4 个小批次累积一次梯度,等效于 batch_size 翻倍,但显存占用不变。
我个人的习惯是优先用 CPU 跑小数据量测试,确认代码正确后再切到 GPU 调参。因为断电价预测数据的样本量通常几万到几十万,CPU 上跑一个 epoch 可能几分钟,GPU 可能几秒,但如果代码有 bug,GPU 上排错成本更高。
5. 评估与进阶:不只是 MAE,还要看日内峰值预测准不准
5.1 评估指标的选择:MAE、RMSE、MAPE 各自的问题
评估指标要贴合业务。MAE 对每个点的误差一视同仁,适合看整体水平;RMSE 对大幅度误差更敏感,适合衡量风险;MAPE 用百分比表示,在价格接近零的时候会爆炸,所以电价预测里我一般不以 MAPE 为唯一标准。更好的做法是分时段评估,分别计算早高峰(6-10点)、晚高峰(17-21点)和低谷时段的 MAE,因为错峰预测和低谷预测的容错空间完全不同。晚高峰价格波动大,误差 10 块钱可能还可以接受,低谷时段价格本身只有 30,误差 10 块就非常严重。
import numpy as np from sklearn.metrics import mean_absolute_error, mean_squared_error mae = mean_absolute_error(y_true, y_pred) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) # 计算 17-21 点的晚高峰 MAE mask = (hour >= 17) & (hour <= 21) mae_peak = mean_absolute_error(y_true[mask], y_pred[mask])另外要关注的是预测是否滞后。电价序列自相关很强,很多看似优秀的模型其实只是把昨天同时刻的价格复制过来。如果预测曲线的波峰比真实波峰晚了一个采样点,MAE 可能不高,但对交易决策是致命的。我会在误差分析里加上一个“波峰时差”指标,计算预测最高价出现时间与真实最高价出现时间的差。这个指标可以用np.argmax直接比较,差值为 0 才理想。
5.2 进阶技巧:多步预测与多变量输入
如果预测目标不是下一点,而是未来 24 个点,模型结构需要改。常见方法有两种:第一种是递归多步预测,把上一步的预测值作为下一步的输入;第二种是直接多步输出,也就是让最后的全连接层输出 24 个值。递归多步容易累积误差,我一般会先用直接多步,训练时输出 24 个值,损失函数计算每个预测点与真实点之间的误差。
对于多变量输入,除了历史电价,还可以加入天气预报里的温度、湿度、风速,以及前两天同时刻电价和负荷预测,这些特征对尖峰电价有显著提升。如果你用的是 PyTorch,直接多步可以把regressor最后一层改成nn.Linear(hidden_size, horizon),然后 reshape 成(batch, horizon)。评估时再按时间点逐位比较。
从我的经验看,真正让预测水平上一个台阶的往往不是模型复杂度,而是特征工程和评估方式。那份基于 LSTM 的电价预测源代码里提供了一个不错的起点,你下载后可以先把数据格式整理成它期望的样子,跑通一遍基线,再按上面的思路做递进。我后来每次拿到新的时序预测任务,都会强制自己先落一张“特征时间轴”,把每个特征的最早可用时间写在纸上,凡是会用到未来信息的特征一律不建模,从那以后翻车的概率就小了很多。希望帮到你。
本文还有配套的精品资源,点击获取