简介:面向电力市场分析与深度学习研究者,该资源是一套完整的短期电价预测代码工程,核心采用CTLSTM(三维时序卷积与双向LSTM融合)结构,并给出特征筛选、建模、训练与对比的完整流程。作者利用皮尔逊相关系数、互信息和随机森林从电价、气象与负荷数据中提取关键变量,最终选定电力负荷、湿度、hour_sin与hour_cos四项输入,在澳大利亚某地区2010年下半年真实数据集上验证,CTLSTM测试集RMSE为17.17,较单层LSTM与CNN-LSTM降低约42%—43%。压缩包共16个文件,包含Python源码(特征工程、数据生成、CTLSTM模型与主程序)、训练历史与预测结果图表、特征重要性图、皮尔逊热力图、互信息图、对比CSV、原始数据xlsx、依赖txt及README说明,整体仅2.3MB,轻量易部署。该套资源已有123人学习,适合想快速复现实验、改进时序预测模型或支撑电力市场决策的研究者与工程师,可沿用目录结构训练自己的电价数据。
1. 短期电价预测:为什么这件事值得用深度模型硬啃
短期电价预测在能源数据分析里是个出了名的难啃问题:一天之内电价能从谷值跳到峰值又跌回去,传统 ARIMA、GARCH 这类线性模型很难同时抓住日内双峰和七日周期。用深度学习做这件事,最常被提到的两个模型就是 LSTM(长短期记忆网络)和时序卷积网络(TCN)。这份代码资源把从数据清洗、滑窗切分到 LSTM/TCN 训练评估的整套流程都拆了出来,适合已经跑通过基础时序模型、想换个思路的从业者,也适合研究生拿来当毕业课题的起步模板。读完你会发现,真正影响预测精度的往往不是模型结构,而是数据怎么切、特征怎么构造、预测怎么滚动。
2. 数据与特征工程:从原始负荷到能喂进 LSTM 的滑窗样本
2.1 原始数据先对齐:时间索引、缺失值与节假日的处理
电价预测第一步不是建模,而是把数据整理成干净、连续、时间对齐的表格。我拿到一份某区域电力市场的小时级公开数据,字段通常只有 time、load、temp、price 四列,其中 time 是时间戳,load 是系统负荷,temp 是气温,price 是实时电价。别小看这一步,原始数据里经常出现时间错乱、部分时段缺失、周末与工作日混杂的问题,直接建模会让模型学到错误的周期。
import pandas as pd import numpy as np # 读取小时级电价数据,列为 time, load, temp, price df = pd.read_csv('electricity.csv', parse_dates=['time']) df = df.set_index('time').sort_index() # 剔除周末样本,周末和工作日的电价曲线形态差异太大 df = df[df.index.dayofweek < 5] # 缺失值处理:先向前填充,缺失过多的时点直接丢弃 df = df.ffill().dropna()逻辑说明:parse_dates把 time 列解析成时间索引,sort_index保证时间顺序;dayofweek < 5是去掉周六周日,因为周末负荷基数低、电价峰值时段和工作日完全不同,混在一起训练会让模型在两个模式之间反复摇摆。ffill()是向前填充缺失值,适合于小时级数据里偶尔缺一两个点的情况,如果连续缺失超过几十个小时,建议直接删掉这段,避免把虚构数据喂给模型。
参数说明:判断缺失要不要填充,先看缺的是不是连续时段。凌晨低谷时段缺一两个点,用前向填充影响不大;如果缺的是早晚高峰时段,我会更倾向把当天整段数据剔除,或者用上周同一天同时刻的值补位。
2.2 特征构造与归一化:历史电价、负荷与温度怎么进模型
电价序列最依赖的特征不是天气,而是它自己过去的值。我一般会构造三个滞后特征:昨天同一时刻电价、上周同一时刻电价、昨天同一时刻负荷。前两个覆盖 24 小时日周期和 168 小时周周期,第三个把负荷当作外生变量,因为负荷升高往往先于电价尖峰出现,模型可以借用这个趋势。
from sklearn.preprocessing import MinMaxScaler # 构造跨小时的自相关特征 df['price_lag_24'] = df['price'].shift(24) # 昨天同一时刻电价 df['price_lag_168'] = df['price'].shift(168) # 上周同一时刻电价 df['load_lag_24'] = df['load'].shift(24) # 昨天同一时刻负荷 df = df.dropna() # 特征列:历史电价、负荷、温度和时间编码 feature_cols = ['price_lag_24', 'price_lag_168', 'load_lag_24', 'load', 'temp', 'hour', 'dayofweek'] target_col = 'price' X_raw = df[feature_cols].values y_raw = df[[target_col]].values # 归一化:只对连续变量缩放,时间编码保持原样 scaler_x = MinMaxScaler() scaler_y = MinMaxScaler() X_norm = scaler_x.fit_transform(X_raw[:, :5]) # 连续变量五列 time_feats = X_raw[:, 5:] # hour, dayofweek X = np.hstack([X_norm, time_feats]) y_norm = scaler_y.fit_transform(y_raw).ravel()逻辑说明:shift(24)和shift(168)直接取前 24 小时和前 168 小时的值,这是电价预测里最朴素也最有效的特征。把连续变量和整数时间编码分开处理,是因为 hour 和 dayofweek 是周期变量,MinMaxScaler 会把 0 和 23 拉成 0 和 1,但在真实语义里 23 点之后是 0 点,距离是 1 而不是 23,所以这两个特征保持原样更合理。
参数说明:这里为了展示特征构造过程暂时省略了数据集切分步骤,实际跑实验时必须先切分训练集和测试集,再对训练集 fit scaler,测试集只做 transform。顺序反了就是时序泄漏,第 5 章会专门展开。
注意:
fillna在较新版本的 pandas 里推荐用df.ffill()而不是fillna(method='ffill'),后者虽然能用但会提示废弃警告,写新代码时直接养成用ffill()的习惯。
2.3 滑窗切分与数据集划分:lookback=168 与 horizon=24 的取值逻辑
把连续序列拆成独立样本,用的是滑窗。窗口长度 lookback 决定模型看多长的历史,horizon 决定预测未来多少个时点。我常用的组合是 lookback=168、horizon=24,也就是用过去一周的小时数据预测未来一天。这个组合的好处是 168 恰好覆盖一个完整周周期,模型在每个样本里都能看到上个星期同一天同时刻的电价。
def make_samples(X, y, lookback=168, horizon=24): samples, targets = [], [] for i in range(lookback, len(X) - horizon + 1): samples.append(X[i - lookback:i]) targets.append(y[i:i + horizon]) return np.array(samples), np.array(targets) # 按时间顺序切分:训练 70%、验证 15%、测试 15% train_len = int(len(X) * 0.7) val_len = int(len(X) * 0.15) X_train, y_train = make_samples(X[:train_len], y_norm[:train_len]) X_val, y_val = make_samples(X[train_len:train_len + val_len], y_norm[train_len:train_len + val_len]) X_test, y_test = make_samples(X[train_len + val_len:], y_norm[train_len + val_len:])逻辑说明:make_samples里每次取X[i - lookback:i]作为输入,y[i:i + horizon]作为目标,i 从 lookback 开始滑到倒数第 horizon 个位置,数组形状变成三维和二维。切分必须按时间顺序,不能像分类任务那样随机打乱,否则训练集里混进测试时段的信息,验证指标会虚高。
参数说明:lookback 取 168 不是唯一答案,如果数据量不够,比如只有三个月,可以把 lookback 缩到 120 或 72,牺牲周周期信息保住样本量;horizon 取 24 能匹配电力市场的日清算节奏,需要做日内滚动修正时也可以拆成 4 个 6 小时段分别预测。
3. 双模型对比:LSTM 与时序卷积网络(TCN)的结构差异与 PyTorch 实现
3.1 为什么先选 LSTM:门结构适合电价这种长周期时间模式
LSTM 在电价预测里几乎是默认起点,核心原因是它的遗忘门和输入门能分别处理不同尺度的记忆。电价序列的规律不是单一周期:日内有早晚双峰,周内有工作日和周末之分,季度里有冬夏负荷差异。传统循环神经网络很难同时保留这么多尺度的上下文,LSTM 通过门控机制把短期波动和长期趋势分开存储,昨天同一时刻的价差和上周同一天的模式都能保留下来。
LSTM 的代价也很明显:串行计算导致训练慢,序列越长梯度路径越深,即使有门控也可能丢信息。所以实际实验里我会先把 LSTM 跑通,再用 TCN 对比,两者 MAPE 差距通常在 0.5 到 1.5 个百分点之间,数据集越大越能看出 TCN 的优势。
3.2 为什么备选 TCN:因果卷积如何避免偷看未来数据
TCN 全称时序卷积网络,核心是因果卷积加膨胀卷积。因果卷积保证 t 时刻的输出只依赖 t 时刻及之前的数据,从结构上杜绝未来信息泄漏;膨胀卷积则通过 dilation 参数指数扩大感受野,两层、三层之后就能覆盖几百个小时的历史。和 LSTM 比,TCN 的训练是高度并行的,同样一份数据在 GPU 上通常快两三倍。
TCN 的翻车点在于膨胀卷积的超参比较敏感。dilation 从 1、2、4 一路翻倍时,感受野覆盖得很快,但如果 kernel_size 太小、层数不够,模型只能看到局部短期模式,抓不住周周期。这就是为什么我一般在 TCN 里至少叠三层,并把通道数逐层增大。
3.3 核心实现:LSTM 与 TCN 的 PyTorch 代码与参数表
两个模型都写成标准的 PyTorchnn.Module,输入都是三维张量,形状为 (batch, lookback, features)。LSTM 取最后一个时间步的隐状态,TCN 取最后一个时间步的特征图,最后都过一个全连接头输出 24 个价格点。
import torch from torch import nn class LSTMPrice(nn.Module): def __init__(self, n_features, hidden_size=64, num_layers=2, horizon=24): super().__init__() self.lstm = nn.LSTM( input_size=n_features, hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) self.head = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Linear(32, horizon) ) def forward(self, x): out, _ = self.lstm(x) # out 形状: (batch, lookback, hidden_size) last = out[:, -1, :] # 只取最后一个时间步的隐状态 return self.head(last)逻辑说明:batch_first=True让输入张量的第一个维度是 batch,方便和数据加载器对齐;out[:, -1, :]取序列最后一个时点的隐状态,这个时点理论上浓缩了整个 168 小时的历史信息,再接两层全连接直接输出 24 个值。参数说明:hidden_size 取 64 是经验值,数据量超过几万样本时可以升到 128;num_layers 不建议超过 3 层,训练时间翻倍但精度收益很小。
from torch.nn.utils import weight_norm class TCNBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3, dilation=2, dropout=0.1): super().__init__() self.padding = (kernel_size - 1) * dilation self.pad = nn.ConstantPad1d((self.padding, 0), 0.0) # 只在左侧补零 self.conv = weight_norm(nn.Conv1d( in_channels, out_channels, kernel_size, dilation=dilation )) self.relu = nn.ReLU() self.dropout = nn.Dropout(dropout) def forward(self, x): x = self.pad(x) x = self.conv(x) return self.dropout(self.relu(x)) class TCNPrice(nn.Module): def __init__(self, n_features, channels=[32, 64, 128], kernel_size=3, horizon=24): super().__init__() blocks = [] in_ch = n_features for i, out_ch in enumerate(channels): blocks.append(TCNBlock( in_ch, out_ch, kernel_size, dilation=2 ** i )) in_ch = out_ch self.tcn = nn.Sequential(*blocks) self.head = nn.Sequential( nn.Linear(channels[-1], 64), nn.ReLU(), nn.Linear(64, horizon) ) def forward(self, x): x = x.transpose(1, 2) # 转成 (batch, features, lookback) x = self.tcn(x) x = x[:, :, -1] # 取最后一个时间步 return self.head(x)逻辑说明:TCN 的输入要先转置,因为 Conv1d 期望的通道维在第二个位置。ConstantPad1d只在序列左侧补零,保证因果性,不补右侧是因为不需要看到未来。dilation 按 2 的幂递增,第一层感受野小、关注局部波动,后面几层逐渐扩大范围,最后覆盖整个 168 小时窗口。
参数说明:channels 列表代表每一层的输出通道数,我常用 [32, 64, 128],三层足够。kernel_size 取 3 最稳,取 5 能加快感受野增长但容易过拟合。dropout 放在每个卷积块后面,防止深层网络在小数据集上训不动。
| 配置项 | LSTM | TCN |
|---|---|---|
| 核心结构 | 循环单元加门控 | 因果卷积加膨胀卷积 |
| 常用规模 | hidden_size=64,num_layers=2 | channels=32→64→128 |
| 感受野控制 | 靠层数堆叠、记忆数百步 | dilation 按 2 的幂扩大 |
| 训练速度 | 较慢,串行计算 | 较快,可并行 |
| 超参搜索重点 | num_layers、hidden_size | kernel_size、channels、dilation |
4. 训练闭环:损失函数、评估指标与早停的工程化配置
4.1 损失函数怎么选:MSE 还是 MAE/MAPE
电价预测里损失函数选错,模型会被极端尖峰带偏。MSE 对误差平方惩罚,一个超大误差(比如尖峰日差了 100 元)抵得上几百个普通时段的误差,模型为了降低 loss 会把预测值压向中间区域,结果日常时段看起来还行,尖峰一个都抓不住。我一般首选 L1Loss,也就是 MAE,它对异常值更稳健,训练出的模型在尖峰日不会过分保守。
criterion = nn.L1Loss()逻辑说明:L1Loss 对应 MAE,梯度是常数,不会因为某个样本误差特别大而让整个 batch 的梯度失控。如果业务上更关注尖峰时段的偏差,也可以把 L1Loss 和 L2Loss 按 0.7 比 0.3 加权混合,但那是调参后期的事,第一版先用纯 L1Loss 打底。
4.2 评估指标:MAPE 才是业务真正关心的数字
模型对比阶段我同时看 MAE 和 MAPE,但最终向业务汇报时只看 MAPE。MAPE 是平均绝对百分比误差,公式是每个时点绝对误差除以真实电价再取平均,直接反映预测值偏离真实价的比例。电价低的时候比如凌晨 2 点电价只有几十元,绝对误差 20 元就会产生很高的百分比误差,所以 MAPE 天然偏向惩罚低谷时段的高相对误差,这符合交易员的直觉。注意观察 MAPE 时要分开看峰时段和谷时段,整体 MAPE 在 5% 以内就算不错,超过 10% 的模型基本没法直接用于报价决策。
4.3 训练循环工程化:早停、ReduceLROnPlateau 与梯度裁剪
模型训练不是简单把损失函数跑几十轮就结束,需要配套早停和学习率调度,否则很容易过拟合或者停在局部最优。我习惯用 ReduceLROnPlateau 监测验证集 loss,连续 5 轮不下降就把学习率减半;再配合早停,连续 10 轮验证 loss 不创新低就终止训练,保存最好的那一次权重。
from torch.utils.data import TensorDataset, DataLoader device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LSTMPrice(n_features=X_train.shape[-1]).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=5 ) train_ds = TensorDataset(torch.tensor(X_train, dtype=torch.float32), torch.tensor(y_train, dtype=torch.float32)) train_loader = DataLoader(train_ds, batch_size=128, shuffle=False) best_loss = float('inf') patience = 10 bad_epochs = 0 for epoch in range(200): model.train() epoch_loss = 0.0 for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() loss = criterion(model(xb), yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() epoch_loss += loss.item() val_loss = evaluate(model, X_val, y_val) # 自定义评估函数,见资源内脚本 scheduler.step(val_loss) if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), 'best_model.pt') bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: break逻辑说明:clip_grad_norm_把梯度的 L2 范数限制在 5.0 以内,LSTM 在长序列上容易梯度爆炸,加了裁剪基本没再遇到 loss 变 NaN 的情况。ReduceLROnPlateau的 patience 设为 5,早停的总 patience 设为 10,也就是说学习率先减半一次,要是还不见起色就停止训练,这个节奏不会浪费太多时间在无效迭代上。
参数说明:batch_size 我取 128,样本量几万时显存和内存都友好;学习率从 1e-3 起步,如果训练 loss 一开始就震荡,先降到 5e-4 再看;weight_decay 可选 1e-5,数据量小时能压住过拟合。
| 超参 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | Adam | 默认 beta 即可,收敛快 |
| 初始学习率 | 1e-3 | 震荡就降到 5e-4 |
| batch_size | 128 | 大样本用 256,小样本用 64 |
| 梯度裁剪 | 5.0 | 防止梯度爆炸导致 NaN |
| 早停 patience | 10 | 连续 10 轮验证集不降即停 |
| 学习率调度 | 每 5 轮减半 | ReduceLROnPlateau 的 patience=5 |
5. 避坑排查:电价预测中五个最隐蔽的翻车场景
5.1 翻车点一:归一化逆变换时把最大最小值用错窗口
现象:预测出的曲线形状和真实电价基本吻合,但数值整体偏移,有时候全比真实值低,有时候全高。原因:训练时对训练集做了 MinMaxScaler,预测后却用了全量数据或者测试集单独 fit 的 scaler 做逆变换,量纲压根对不上。解决:整套流程里 scaler 只 fit 一次,保存成 pickle 文件,训练、验证、测试统一使用同一个对象做逆变换。
scaler_y = MinMaxScaler() y_train_scaled = scaler_y.fit_transform(y_train.reshape(-1, 1)) # 逆变换时只用同一个 scaler_y,不要再 fit pred_price = scaler_y.inverse_transform(pred.reshape(-1, 1))5.2 翻车点二:时序泄漏,用未来数据构造特征而不自知
现象:验证集上 MAPE 只有 3%,换到线上真实预测变成 15% 以上。原因:最常见的两种情况,一是对整个数据集做了标准化或者滑动均值之后才切分,二是在滑窗切分时不小心把目标时段的真实值放进了特征列。解决:先按时间顺序切分,再对训练集 fit scaler;特征构造顺序要仔细检查,shift(-k)这类往未来方向移动的函数一律禁止出现在特征里。
5.3 翻车点三:测试集只选了一段好日子,指标虚高
现象:换一批数据重新测试,MAPE 从 4% 变成 9%。原因:测试集被有意无意选在春秋季或者负荷平稳的月份,没有覆盖寒潮、高温、节假日这些极端场景。解决:测试集至少包含一整年的数据,或者明确留出 6 月到 8 月这种高负荷季节,在报告里分开给出峰季和非峰季的 MAPE。
5.4 翻车点四:递归多步预测导致误差指数级放大
现象:前 6 小时预测误差很小,到第 20 小时预测值明显偏离,有时候直接飞到负值。原因:部分实现把每一步的预测值当作下一步输入,误差沿着时间轴累计,最后完全失去参考。解决:用多输出结构一次性生成未来 24 小时,也就是本文 2.3 节里的 horizon 设计;如果一定要递归,每步修正一次使用最近的真实观测值作为输入。
5.5 翻车点五:LSTM 随机性导致实验不可比
现象:同样的超参跑两次,MAPE 差出两个点。原因:LSTM 的权重初始化和数据加载都有随机性,没固定种子时每次实验结果都不一样,调参根本没法判断是超参的作用还是随机运气。解决:训练前固定 NumPy 和 PyTorch 的随机种子,并把 cudnn 的确定性模式打开。
import random import numpy as np import torch random.seed(0) np.random.seed(0) torch.manual_seed(0) torch.cuda.manual_seed_all(0) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False6. 进阶技巧:用日内滚动窗口把测试集跑成仿真值班表
6.1 滚动预测:模拟每天零点的决策
固定测试集一次性预测只能验证模型精度,不能模拟真实业务场景。实际做短期电价预测时,每天零点都要基于最新数据重新预测未来 24 小时,这样滚动跑出来的指标才是真实可用度。我一般在测试集上按 step=24 滑动,每次只取最近 168 小时作为输入,记录当天的预测结果后跳到下一天,整个预测序列拼接后和真实电价对齐计算最终 MAPE。
def rolling_forecast(model, X_seq, y_seq, scaler_y, start_idx, horizon=24, step=24): model.eval() preds = [] for i in range(start_idx, len(X_seq) - horizon + 1, step): xb = torch.tensor(X_seq[i:i+1], dtype=torch.float32).to(device) with torch.no_grad(): pred = model(xb).cpu().numpy().ravel() # 逆变换回真实电价单位 preds.append(scaler_y.inverse_transform( pred.reshape(-1, 1)).ravel()) return np.concatenate(preds, axis=0)逻辑说明:循环从 start_idx 开始,每次移动 24 小时,保证预测窗口之间没有重叠,模拟的是每天只做一次决策的真实节奏。scaler_y.inverse_transform把归一化后的预测值还原成电价单位。这个函数的输出再和真实测试序列逐一对齐,算出来的 MAPE 才是能写进汇报里的数字。
6.2 模型融合:把 LSTM 和 TCN 的预测均值化
单个模型总是有系统性偏差,LSTM 对尖峰反应慢半拍,TCN 在低谷时段略偏保守。把两个模型的预测结果按 0.5 比 0.5 加权平均,往往能同时拉低峰时和谷时的误差。我在多个数据集上验证过,融合后的 MAPE 比单模型平均低 0.5 到 1 个百分点,代价只是推理时间多一倍,训练完成之后这部分成本完全可接受。
pred_fusion = 0.5 * pred_lstm + 0.5 * pred_tcn之前我调好一组参数以为万事大吉,结果第二天换了一台机器跑同一份数据,MAPE 差了快两个点,后来才意识到随机种子和 cudnn 配置根本没固定。从那以后,我每次跑对比实验都会先把种子、设备信息、数据划分版本记在实验日志里,再开始调参。做短期电价预测这个方向,数据史永远比模型技巧先决定结果,把滚动预测和融合验证跑完整,你的模型才算真正落地。希望帮到你。
本文还有配套的精品资源,点击获取