☰
深度学习驱动的短期电价预测:特征工程与PyTorch实践
2026/10/14 23:24:34 网站建设 项目流程

简介:面向电力市场研究、能源数据分析人员及深度学习开发者的短期电价预测完整代码包,聚焦电价数据强波动、非线性带来的预测难点,提供基于双向长短时记忆网络与三维时序卷积网络(即CTLSTM)的端到端可复现建模流程。资源共十六个文件,压缩包大小约二点三兆字节,核心为Python源码,包含模型定义、特征工程、数据生成等模块,另有实验数据集、CSV结果与可视化图表,便于快速验证和二次开发;已有一百二十三人学习。方案通过皮尔逊相关系数、互信息、随机森林筛选负荷、湿度及时钟特征,模型在测试集上均方根误差约为十七点一七,较单层长短时记忆模型和卷积神经网络加长短时记忆模型约降低百分之四十二至四十三。读者可获得完整训练评估流程、特征筛选思路和实验对比记录,适合作为电价预测课题研究或电力市场应用开发的参考起点。

1. 短期电价预测不是单纯拟合曲线:为什么深度学习方案值得做

做电力市场的人都知道一个反直觉现象:电价序列和负荷曲线长得非常像,但预测难度完全不在一个量级。负荷有清晰的日内规律,早高峰晚高峰几乎固定在几个时段;电价却会在同样的时段里,因为一次检修、一台机组临停或者一个天气预报的温差修正,直接在半小时内跳变几倍。传统的时间序列方法对这类尖峰和波动束手无策,而深度学习方法最擅长的恰恰是从大量历史样本里把「这个时段、这种天气、这类市场状态」的组合规律学出来。

短期电价预测解决的是「未来 24 小时逐小时电价大概是多少」这个问题,输入是历史负荷、历史电价、天气、日历特征,输出是一组 24 个数值。这项技术直接服务于电力现货市场的报价决策、购电成本估算和风险控制,也常出现在高校课题组、电力交易中心研究项目和个人竞赛作品里。本文从数据处理讲到模型训练,再给出 PyTorch 代码骨架和真实落地中踩过的坑,适合有 Python 基础、想快速跑通一套完整方案的开发者参考。

2. 电价数据与特征工程:预处理方案的细节决定模型上限

2.1 数据集构成与对齐:负荷、电价、天气三类数据的合并方式

短期电价预测的第一步是构建一张规整的时序表。常见的数据来源是某区域电力市场公开的历史运行数据,包含每小时的系统负荷、出清电价,配合气象站记录的温度和湿度。我需要提醒的是:这三类数据的时间戳粒度必须一致,最好统一转到 UTC 或北京时间,别混用。

最省事的做法是把所有序列按小时对齐到一张 Dataframe 上,索引是时间,列是price、load、temperature、weekend等。如果原始数据存在缺失,先看缺失比例——小时粒度数据缺 1~2 个点可以直接用前后均值填充;缺一连串十几个点,就得考虑用同星期同时刻的历史均值补,或者干脆删除这一段。

import pandas as pd price = pd.read_csv('price_history.csv', parse_dates=['time'], index_col='time') load = pd.read_csv('load_history.csv', parse_dates=['time'], index_col='time') weather = pd.read_csv('weather_history.csv', parse_dates=['time'], index_col='time') df = price.join(load).join(weather) df = df.sort_index() df = df[~df.index.duplicated(keep='last')] # 缺失值处理:单个缺失用线性插值,连续缺失用同星期同时刻均值 df = df.interpolate(method='linear', limit=3) for col in df.columns: mask = df[col].isna() if mask.sum() > 0: df.loc[mask, col] = df.groupby([df.index.hour, df.index.weekday])[col].transform('mean').loc[mask]

逻辑说明:join默认按索引做外连接,能保证三个文件按时间戳对齐;duplicated(keep='last')处理的是同一个小时出现两条记录的情况——电力数据采集偶尔会重复上报,保留最后一条是常见惯例。连续缺失超过 3 小时就不插值了,因为线性插值对超过 3 小时的区间会产生明显的平滑误差,这时用同星期同时刻的统计均值替代更符合电价序列的周期性假设。

2.2 特征构建的三个关键维度:滞后项、滚动窗口和时间编码

特征工程直接决定模型预测能力的上限。我的经验是把特征分三组:滞后特征、滚动统计特征、周期编码特征。滞后特征解决「昨天的这个时刻电价是多少」——电价有极强的 24 小时周期性,lag_24通常是最重要的特征。滚动窗口特征捕捉近期趋势,比如过去 24 小时的平均电价、过去 3 小时的负荷变化率。周期编码特征告诉模型「现在是几点、星期几、是不是节假日」。

节假日特征容易被忽略,但它在短期电价预测里非常关键——节假日和普通工作日的负荷曲线完全不同,电价水平也差异很大。我一般会手动维护一个节假日列表,生成is_holiday布尔列。

import numpy as np df['hour_sin'] = np.sin(2 * np.pi * df.index.hour / 24) df['hour_cos'] = np.cos(2 * np.pi * df.index.hour / 24) df['week_sin'] = np.sin(2 * np.pi * df.index.weekday / 7) df['week_cos'] = np.cos(2 * np.pi * df.index.weekday / 7) df['is_weekend'] = (df.index.weekday >= 5).astype(int) df['lag_24'] = df['price'].shift(24) df['lag_168'] = df['price'].shift(168) df['load_lag_24'] = df['load'].shift(24) df['price_ma_24'] = df['price'].rolling(24).mean() df['price_ma_168'] = df['price'].rolling(168).mean() df['load_diff_3h'] = df['load'].diff(3) df['price_change_1h'] = df['price'].diff(1) df['price_trend_24'] = df['price'].iloc[-24:].sum() if len(df) >= 24 else np.nan

逻辑说明:hour_sin/hour_cos是把 0 到 23 的小时编码到单位圆上,避免「23 点和 0 点距离很远」这种错误认知——二者只差 1 小时,在角度编码上就是挨着的。lag_24是前一天同一小时的电价,lag_168是前一周同小时的电价,用于捕捉周周期性。滚动窗口特征有滞后性,用它们做预测时要注意「当前时刻只能用到当前时刻及以前的数据」,所以price_ma_24在预测 t 时刻时用的其实是 t-1 到 t-24 的均值,这在特征命名上容易混淆,我习惯在建模前把滚动列整体shift(1)一下。

2.3 训练集与测试集切分:时序数据按时间切,不能随机打散

时序预测最常见的翻车方式,是像做分类任务一样用train_test_split(random_state=42)随机划分。电价序列有强自相关性,随机打散后训练集和测试集互相「泄漏」,验证指标会好看到不真实,一旦上线立刻现出原形。

正确做法是按时间先后切分:比如 2023 年全年数据作训练,2024 年 1 月作验证,2024 年 2 月作测试。我还要强调一个细节:滑动窗口构造样本时,训练集最后一个样本和验证集第一个样本之间要有间隔,避免窗口重叠带来的信息泄漏。

train_end = '2023-12-31 23:00:00' val_start = '2024-01-01 00:00:00' val_end = '2024-01-31 23:00:00' train_df = df.loc[:train_end].dropna() val_df = df.loc[val_start:val_end].dropna() test_df = df.loc['2024-02-01':].dropna() # 构造时序窗口样本 def make_samples(data, feature_cols, target_col, lookback=168, horizon=24): X, y = [], [] values = data[feature_cols].values targets = data[target_col].values for i in range(lookback, len(data) - horizon + 1): X.append(values[i - lookback:i]) y.append(targets[i:i + horizon]) return np.array(X), np.array(y)

逻辑说明:lookback=168表示用过去一周的每小时数据作为输入,horizon=24表示输出未来 24 小时的逐小时预测。窗口长度为 168 小时是基于电价有一个完整周周期的先验;如果数据量不够大,可以缩短到 120 或 72。构造样本的循环里,targets[i:i+horizon]取的是当前时刻之后 24 个小时的电价,也就是说模型从「过去 168 小时」预测「未来 24 小时」,中间没有重叠,这正是时序预测的标准窗口设计。

归一化要放在切分之后,用训练集的统计量去变换验证集和测试集。原因很简单:测试集代表未来,你在建模时不应该「看过」未来的分布。对电价序列特别要小心的是负电价——很多市场会出现负电价,MinMaxScaler默认范围是 0 到 1,能处理负值,不会报错,但要注意极端尖峰会被压缩到接近 1 的区域,模型学到的分辨力会下降。我通常先对price做分位数截断(比如 1% 到 99% 分位),再把剩余值喂给缩放器,这样能缓解极端尖峰对归一化的干扰。

from sklearn.preprocessing import MinMaxScaler feature_cols = ['load', 'temperature', 'hour_sin', 'hour_cos', 'week_sin', 'week_cos', 'is_weekend', 'lag_24', 'lag_168', 'load_lag_24', 'price_ma_24', 'price_ma_168', 'load_diff_3h', 'price_change_1h'] low, high = train_df['price'].quantile(0.01), train_df['price'].quantile(0.99) train_df['price_clipped'] = train_df['price'].clip(low, high) val_df['price_clipped'] = val_df['price'].clip(low, high) test_df['price_clipped'] = test_df['price'].clip(low, high) scaler = MinMaxScaler() train_scaled = scaler.fit_transform(train_df[feature_cols + ['price_clipped']]) val_scaled = scaler.transform(val_df[feature_cols + ['price_clipped']]) test_scaled = scaler.transform(test_df[feature_cols + ['price_clipped']])

参数说明:quantile(0.01)和quantile(0.99)是截断阈值参数,可以根据数据实际分布调整——如果尖峰特别多,放宽到 0.5% 分位,保留更多尾部信息;如果数据整体平稳,收紧到 5% 分位,让模型专注主流区间。MinMaxScaler的fit只在训练集上执行,transform用同一套统计量处理后续数据集,这是防止数据泄漏的关键。

3. 模型选型与训练设计:为什么 LSTM 仍是短期电价预测的可靠基线

3.1 LSTM/GRU 与 Transformer 的取舍:数据量决定模型复杂度

短期电价预测的模型选择,我的判断标准很简单:先看训练数据量。几万个小时级样本的规模,LSTM 或 GRU 通常比 Transformer 更稳。Transformer 的注意力机制擅长捕捉长距离依赖,但需要大量数据支撑,而且对时序数据的位置编码敏感,调参成本高得多。LSTM 的门控结构天然适合电价这种有强周期性的序列,训练收敛快,超参数不那么敏感。

GRU 是 LSTM 的简化版,参数量更少,在电价预测这类中等规模数据集上通常和 LSTM 表现持平,偶尔更好。我一般先跑 GRU 作为基线,因为训练速度快,方便快速验证特征工程是否有效;基线效果可以接受了,再换更复杂的模型对比。做预测不是参加竞赛,上线稳定比刷几个百分点的 MAPE 重要得多。

3.2 网络结构:输入形状、层数与 dropout 的配置逻辑

确定网络结构之前先明确输入输出形状。我们的样本形状是(样本数, 168, 特征数),即每个样本是 168 个时间步、每步有若干个特征;输出形状是(样本数, 24),即未来 24 小时的预测电价。常见做法是先接一个 GRU/LSTM 层将序列编码为中间向量,再通过全连接层输出 24 个值——这样做比「循环预测下一个小时再把预测值喂回去」更稳定,误差不会累积。

层数方面,两层的循环网络足够捕捉电价序列的层级特征,再多层数收益很小且训练不稳定。hidden_size我常用 64 或 128,这个参数受数据量约束——样本量大可以加宽,量小容易过拟合。dropout在循环层之间设 0.2 到 0.3,防止最后的全连接层把训练集噪音背下来。

3.3 训练配置:损失函数、批次大小、早停与模型保存

训练配置里最关键的是损失函数的选择。电价序列有尖峰特征,MSE 会对这些尖峰赋予极大权重,模型为了压缩少数尖峰的误差,会牺牲大多数普通时段的精度。HuberLoss 在误差小时表现像 MSE,误差大时退化为 MAE,对尖峰天然不敏感,是电价预测里好用的默认选择。如果业务更关注均价误差而不是尖峰,也可以直接用 MAE。

批次大小我用 64,学习率用 Adam 优化器配合 0.001 的初始值。训练过程中需要监控验证集 MAPE,设置早停——连续 10 个 epoch 验证指标不再下降就停止训练,并保存验证指标最好的模型权重。这里需要提醒的是,监控指标和损失函数可以是不同的:损失函数决定梯度走向,监控指标决定你是否该停。

import torch import torch.nn as nn class PricePredictor(nn.Module): def __init__(self, input_size, hidden_size=128, num_layers=2, output_size=24, dropout=0.2): super().__init__() self.gru = nn.GRU(input_size, hidden_size, num_layers, batch_first=True, dropout=dropout) self.fc = nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_size // 2, output_size) ) def forward(self, x): out, _ = self.gru(x) # out: (batch, seq_len, hidden) last = out[:, -1, :] # 取最后一个时间步的隐状态 return self.fc(last) # (batch, output_size)

逻辑说明:这里用 GRU 取序列最后一个时间步的隐状态作为整个历史窗口的压缩表征,然后过一个两层全连接输出 24 个值。输入x的形状在batch_first=True时是(batch, seq_len, input_size),out[:, -1, :]取的是每个样本最后一步的输出。这里有一个可以调整的点:也可以把最后一个隐状态h_n拿出来用,即out改取h_n[-1],两者在单层 GRU 下等价,多层时h_n[-1]取的是最后一层的隐状态,含义更准确。

def train_model(model, train_loader, val_loader, epochs=50, lr=0.001, patience=10): optimizer = torch.optim.Adam(model.parameters(), lr=lr) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) criterion = nn.HuberLoss(delta=1.0) best_val_mape = float('inf') best_state = None wait = 0 for epoch in range(epochs): model.train() train_loss = 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() train_loss += loss.item() * len(x_batch) model.eval() val_preds, val_trues = [], [] with torch.no_grad(): for x_batch, y_batch in val_loader: pred = model(x_batch) val_preds.append(pred.numpy()) val_trues.append(y_batch.numpy()) val_preds = np.concatenate(val_preds) val_trues = np.concatenate(val_trues) val_mape = np.mean(np.abs((val_preds - val_trues) / val_trues)) * 100 scheduler.step(val_mape) if val_mape < best_val_mape: best_val_mape = val_mape best_state = {k: v.clone() for k, v in model.state_dict().items()} wait = 0 else: wait += 1 if wait >= patience: print(f'Early stop at epoch {epoch + 1}') break model.load_state_dict(best_state) return model

逻辑说明:训练循环里值得注意两个配置。clip_grad_norm_(max_norm=1.0)是梯度裁剪,电价样本里如果混入极端值,GRU 的梯度容易爆炸,裁剪到 1.0 能稳定训练;ReduceLROnPlateau在验证指标连续 5 轮不降时把学习率减半,这让模型在接近局部最优时能更精细地调整参数。patience=10控制的早停机制,是为了防止模型在验证集上过拟合后继续训练导致指标反弹。HuberLoss 的delta=1.0是误差阈值参数,小于 1 的误差按平方项计算,大于 1 的误差按线性项计算,这个值可以根据电价归一化后的尺度微调。

预测完成后需要逆归一化,这一步出错的人不少——必须用训练时保存的 scaler 对预测结果做inverse_transform,而不是重新 fit 一个。而且因为训练时对price做了分位截断,逆变换回来的是截断后空间的值,实际使用时可以直接作为预测结果,或者在外面再叠加一个尖峰修正逻辑,这个我在第五部分展开。

4. 完整代码骨架:从数据加载到 24 小时预测的一体化实现

4.1 数据加载与 PyTorch Dataset 封装

把数据处理和模型训练串起来的时候,最容易出问题的是形状对不上。我习惯把数据处理的输出直接对接 PyTorch 的 Dataset 接口,而不是在训练循环里反复做切片。Dataset 负责保存归一化后的特征矩阵和标签矩阵,__getitem__返回一个样本组:输入窗口和对应的 24 小时标签。

from torch.utils.data import Dataset, DataLoader class PriceDataset(Dataset): def __init__(self, X, y): self.X = torch.FloatTensor(X) self.y = torch.FloatTensor(y) def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.y[idx] # 假设 train_scaled 是包含 feature_cols + price_clipped 的二维数组 X_train, y_train = make_samples(train_df, feature_cols, 'price_clipped', lookback=168, horizon=24) X_val, y_val = make_samples(val_df, feature_cols, 'price_clipped', lookback=168, horizon=24) X_test, y_test = make_samples(test_df, feature_cols, 'price_clipped', lookback=168, horizon=24) train_loader = DataLoader(PriceDataset(X_train, y_train), batch_size=64, shuffle=True) val_loader = DataLoader(PriceDataset(X_val, y_val), batch_size=64, shuffle=False) test_loader = DataLoader(PriceDataset(X_test, y_test), batch_size=64, shuffle=False)

逻辑说明:shuffle=True只用于训练集。虽然样本是按时序构造的,但每个样本本身已经是独立的「过去 168 小时 → 未来 24 小时」映射,打乱顺序不影响时序有效性,还能打散同一天相近样本的相似性,帮助模型泛化。验证集和测试集必须shuffle=False,保持时间顺序,方便后续按时间画误差曲线。batch_size=64的选择依据是:太大收敛慢且容易陷入尖锐极小值,太小梯度噪声大,64 在大多数数据集上是折中值。

4.2 预测与逆归一化:把模型的输出还原成真实电价

预测完成后,逆归一化是另一个容易踩坑的地方。由于make_samples生成的是二维特征矩阵的窗口切片,而预测输出y_pred的每行是 24 个时间步,需要按列还原。注意scaler是同时拟合了所有特征列的,不能直接对整个y_pred做inverse_transform——要挑出price_clipped所在的那一列做逆变换。

一个干净的做法是:单独为电价序列保存一个price_scaler,这样逆归一化时逻辑清晰,不会和其他特征的缩放统计量混淆。

price_scaler = MinMaxScaler() price_scaler.fit(train_df[['price_clipped']]) model.eval() test_preds = [] with torch.no_grad(): for x_batch, _ in test_loader: pred = model(x_batch) test_preds.append(pred.numpy()) test_preds = np.concatenate(test_preds) # (N, 24) # 逆归一化:分别处理每一列 def inverse_price(pred_rows): inv = np.zeros_like(pred_rows) for i in range(pred_rows.shape[1]): inv[:, i] = price_scaler.inverse_transform(pred_rows[:, i].reshape(-1, 1)).ravel() return inv test_preds_real = inverse_price(test_preds) test_trues_real = inverse_price(y_test)

代码说明:循环逆归一化的原因在于MinMaxScaler期望输入是二维数组,而预测结果的每一列代表同一个目标变量的不同预测时刻,逐列变换的好处是数值上完全一致,不会因为 reshape 方式不同引入误差。test_preds_real的形状是(N, 24),每一行对应一个历史窗口之后未来 24 小时的预测电价。

4.3 评估指标:MAPE 与 RMSE 的适用范围和失真风险

电价预测的评估指标需要谨慎选择。MAPE 对接近零的电价非常敏感——如果真实电价是 5 元/MWh,预测偏差 2 元就是 40% 的误差,而同样 2 元的偏差在 500 元的电价上只有 0.4%。所以 MAPE 在电价预测里经常被尖峰和低谷扭曲,常用替代方案是sMAPE(对称平均绝对百分比误差)和 RMSE。

我一般同时报三个指标:RMSE 看整体偏差的绝对值水平,MAE 看平均偏差,MAPE 作为参考但要意识到它在低价时段的失真。如果业务上更关心高价时段的预测精度(比如报价决策主要看峰值),那就应该按价格区间分层计算误差,而不是看一个全局 MAPE。

def evaluate_metrics(y_true, y_pred): mae = np.mean(np.abs(y_true - y_pred)) rmse = np.sqrt(np.mean((y_true - y_pred) ** 2)) mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100 return {'MAE': mae, 'RMSE': rmse, 'MAPE(%)': mape} metrics = evaluate_metrics(test_trues_real, test_preds_real) print(metrics)

逻辑说明:打印出的三个指标要综合看。如果 MAPE 很高但 RMSE 不高,说明误差主要出现在低价时段,模型整体趋势是对的;如果 RMSE 高而 MAE 低,说明个别尖峰时刻的预测偏差很大,需要针对尖峰做专项处理,比如在特征里加入机组检修信息或提高对异常时段的采样权重。

5. 避坑与排查:短期电价预测落地时最常见的五个问题

5.1 归一化泄漏:用到全量数据的统计量,验证集指标虚高

现象:训练时验证集 MAPE 在 3% 左右,模型上线后效果大幅缩水,误差几乎翻倍。反复检查特征和模型结构都找不到原因。

原因:数据处理时先对全量数据做了MinMaxScaler.fit(),再切分训练集和测试集。归一化使用的min和max来自未来数据,测试集的归一化结果隐含了「未来分布」信息,属于典型的数据泄漏。这在时序任务里比分类任务更隐蔽,因为没有随机打散的过程,不容易注意到。

解决:严格按第 2.3 节的方式,先切分,再fit(train_df),然后transform验证集和测试集。写完代码后自查一遍:所有fit操作是否只接触过训练集。这个错误我在早期项目里犯过,查了整整两天的特征重要性,最后才发现是归一化顺序的问题,当时整个人都很崩溃。

5.2 预测曲线滞后:输出看着像把输入曲线平移了几个小时

现象:把预测值和真实值画在同一张图上,能看出预测曲线明显「追着」真实曲线跑,总是慢了一拍,提前量的规律性滞后肉眼可见。

原因:这种情况在电价预测里很出名,模型学到了自回归路径——它发现前一小时的电价对下一小时电价预测最有用,于是倾向于把最近的历史值往后推移。特别在电价突变的时刻,模型无法预知突变,只能等突变发生后才在下一个时刻修正,造成系统性滞后。

解决:第一,检查lag_24特征是否被模型过度依赖,可以看特征重要性(如果是线性模型直接用系数,LSTM 可以用 permutation importance);第二,加入更多「前瞻性」特征,比如天气预报温度、预测负荷,帮助模型在突变前做出反应;第三,用直接多步预测而不是递归预测——我们已经用直接多步输出,这会减轻滞后但不会根除。实际上,滞后是电价预测的固有难点,完全消除不现实,目标是把滞后控制在 1 个小时以内,同时确保突变后的第二个小时开始误差快速收敛。

5.3 尖峰电价让 MAPE 彻底失真

现象:模型预测整体走势很好,但某个下午出现了 3 倍于均价的尖峰,模型没跟上,于是当天的 MAPE 冲到 30% 以上,直接拉垮周平均指标。

原因:MAPE 的分母是真实值,真实值越大、百分比误差反而越小,看起来矛盾的原因是尖峰时刻预测偏差的绝对值很大,而分子被分母放大效应抵消得不够。低价时段的误差虽然绝对值小,但在 MAPE 中被分母缩小,贡献反而被夸大了。两个方向的失真叠加,让 MAPE 变得不可信。

解决:改用分时段评估——把一天按峰、平、谷三段划分,分别计算 MAPE;或者用sMAPE替代,它的分母是预测值和真实值的均值之和,能同时削弱两个方向的极端影响。如果业务只看均价,就直接用 MAE。核心原则是:评估指标必须服务于业务关注点,而不是为了报告好看。这个教训来自某项目——当时我们汇报用 MAPE,指标很漂亮,但交易部门反馈「谷段预测根本没法用」,因为低价时段的误差在 MAPE 里被掩盖了。

5.4 多步预测误差累积:递归预测和直接多步的取舍

现象:尝试了「先预测第 1 小时,把预测值作为输入预测第 2 小时」的递归方案,前 6 个小时还行,越往后误差越大,24 小时预测基本不能用。

原因:递归预测把每一步的预测误差喂回输入,误差随时间步长累积,这就是「误差累积」问题。电价序列又特别容易出现一步错、步步错的情况,因为自回归特征在递归中会被连续错误污染。

解决:我常用的方案是直接多步预测——一次性输出 24 个值,就是前面代码里的做法。这个方案避免了误差累积,但代价是模型失去了「看到自己上一步预测结果」的能力,对短期的突变响应略差。另一种折中是「多尺度预测」:先用模型预测 24 小时的电价形状,再用一个轻量模型修正前 6 个小时的细节。如果数据量大,也可以尝试 Transformer 的并行输出结构。在我做过的方案里,直接多步始终是稳定性和简单性的平衡点,推荐作为默认做法。

5.5 逆归一化后结果异常:预测出了一堆常数或负值

现象:训练时 loss 正常下降,验证指标也很低,但把预测结果逆归一化后,得到了一个几乎恒定不变的值或者一堆明显不合理的负电价。

原因:逆归一化时拿错了 scaler,或者对特征矩阵整体做了inverse_transform而没有单独取price列。常见错误是把整个(N, 24)的预测矩阵传给了包含所有特征列的 scaler,导致每一列都用了不同的 min/max 去还原,输出自然支离破碎。负值问题通常是因为MinMaxScaler的min是负的,逆变换后回到了原始空间的负价格——部分市场确实有负电价,但如果负价过多且集中在非低谷时段,就要检查是不是归一化前对负值处理不当。

解决:单独为price_clipped建一个price_scaler,逆变换只经过它;训练和预测用同一个变量,别在 Notebook 里重复执行导致覆盖。负电价处理上,如果现货市场允许负价,保留负值是对的;如果业务上认为负价不合理,可以在预处理时把低于 0 的值截断为 0,再进入归一化。注意这个决策必须放在特征工程阶段,而不是逆归一化后再强行改,否则逆归一化的尺度对不上。

6. 进阶做法与验证方法:把预测结果从实验报告变成可决策依据

预测模型做出来只是第一步,真正能用起来还需要解决两个问题:如何证明模型在长时间跨度上稳定可靠,以及预测结果如何对接实际的交易决策流程。

验证方面,我建议不要只做一次训练/测试切分,而是做滚动回测(rolling backtest):把数据集按周滑动,每周训练一次模型、预测下一周,累计 12 周以上的预测结果,统计误差分布和极端误差出现的频率。这比一次性切分可信得多,因为它暴露了模型在不同季节、不同市场状态下的表现。具体做法是把前面make_samples和训练代码包成一个函数,循环调用,每次更新训练窗口。如果滚动回测中发现某类特定天气条件下误差系统性偏大,再回头看特征工程是否需要额外输入。

预测结果的业务化,要区分两种用途。用于报价策略时,关注的是峰值时段的价格水平,这时要在预测均值之外额外计算预测区间——常见做法是用分位数回归或对多个随机种子的预测结果取分位数,得到 10% 到 90% 的置信带。用于购电成本估算时,关注的是全天的平均电价,这类任务 MAPE 失真问题不严重,对预测精度的容忍度也更高,重点是避免系统性的正偏差或负偏差——我习惯对预测残差做一次均值检验,确认残差均值接近零再投入使用。

最后说一个我在实际项目中形成的习惯:任何模型上线前,我会把预测值和真实值画成连续 7 天的对比图,单独标记出误差最大的 3 个时段,问自己三个问题——这个误差是数据缺失导致的、是极端天气导致的、还是模型结构缺陷?如果答案是极端天气,我会把这类样本从评估集中单独拿出来看,而不是让它污染整体指标。这种排查方法帮我避开过很多看着指标不错、实际没法用的模型。希望这篇文章能帮你把短期电价预测方案稳定地跑起来,并且在一开始就绕开那些让人失眠的坑。

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

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

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

立即咨询