简介:面向电力现货市场短期电价预测场景,该代码包聚焦基于支持向量机(SVM)的次日电价方向变化预测,适合电力市场研究人员、量化分析者及机器学习初学者参考。压缩包共8个文件,核心为3个MATLAB脚本,覆盖自回归模型调优与相关变量增强两个层面;2个mexw64编译函数用于加速预测计算,测试数据(xlsx与mat)及说明文档txt方便直接复现实验,整体仅542KB,轻量且结构清晰。已有532人学习。资源完整复现了在欧洲能源交易所德国和奥地利控制区Phelix指数上的测试流程,200天内预测准确率达76.12%;代码保留了数据读取、特征构造、模型训练与结果评估的完整逻辑,可直接运行或稍作修改用于其他电价数据集。通过该案例可系统掌握SVM参数寻优、核函数对比及外部特征融合的方法,同时也能理解短期电价预测从方案设计到效果验证的关键步骤,为后续研究提供扎实的代码起点。
1. 短期发电市场电价预测,为什么都说先试SVM
刚拿到短期发电市场电价预测这个题目时,直觉是上深度学习。真正跑过一轮才发现:日度样本只有24个点,一年也就八千多个样本,波动还夹着尖峰和负电价,深度学习在这种数据量上很容易过拟合,而且慢。反倒是SVM(支持向量机)配RBF核,在小样本、强非线性的回归任务上,常常先交出一版能看的预测。这个标题里的研究项目,核心就是把SVM当主力模型,对短期发电市场的电价做回归预测。
SVM擅长什么?样本量几千到几万、特征几十维时,它用一个核函数把电价这种非线性关系映射到高维空间,又没有深度模型那么重的调参负担。适合的读者很明确:要搭电力市场电价预测、负荷预测,手头有历史电价序列、数据量还没到海量级别的团队。后面从特征构造、核函数选型、参数调优到避坑,把整套流程说透。
2. 从电价序列到监督学习样本:滞后特征与归一化的第一道坎
SVM不能直接吃一串连续的时间序列,它吃的是特征矩阵X和标签y。所以第一步是把电价历史序列改造成监督学习数据集。这一步做得对不对,直接决定后面SVM跑出来的是可用模型还是玄学。我一般先把序列画出来看周期:发电侧现货电价有明显的24小时日内周期和168小时周周期——早高峰、晚高峰、周末低谷,这是选滞后特征的基本盘。
2.1 滑动窗口怎么开:lag_24、lag_168 这些滞后特征在说什么
import pandas as pd def make_lag_features(df, target='price', lag_hours=(1, 2, 24, 25, 48, 168)): df = df.copy() for h in lag_hours: df[f'lag_{h}h'] = df[target].shift(h) df['hour'] = df.index.hour df['is_weekend'] = (df.index.weekday >= 5).astype(int) return df.dropna() # df 为按时序索引、含 price 列的原始电价表 feature_df = make_lag_features(df) X = feature_df.drop(columns=['price']) y = feature_df['price']逻辑说明:shift(h)是把当前时刻往前推h小时的那个价格挪到当前行。lag_24就是「昨天同一时刻的电价」,lag_168是「上周同一时刻的电价」。保留1、2、25小时这种邻近窗口,是为了让模型感知到电价的短期变化趋势;保留168,是让它感知周周期。SVR不像树模型那样能做自动特征交互,所以这一步宁可显式把周期信息铺开。
参数说明:lag_hours这个元组要根据市场特性调。如果尖峰固定出现在早晚时段,就把对应时段的lag加进来;但也不要贪多,几十个滞后特征塞进去,RBF核的距离计算会被无关维度稀释,训练时间变长、误差反而变差。一般控制在6到12个滞后特征。dropna()会丢掉序列开头的若干行,样本数小于理论值,这是正常的。一年8760个小时点,扣掉开头168个滞后缺失行,训练样本仍然是够的。
2.2 归一化必须落在训练集上:MinMaxScaler 的 fit/transform 分开写
SVR对特征的数值尺度极其敏感。RBF核算的是样本间的欧式距离,电价是百元级、小时数是24、星期几是1到7,不归一化的话,电价这一个维度会完全主导距离计算,核函数等于白选。归一化是硬前提,但标准流程我看过很多人写错:
from sklearn.preprocessing import MinMaxScaler # 先按时间顺序切分,再归一化:scaler 只在训练段上 fit train_len = int(len(X) * 0.8) X_train, X_val = X.iloc[:train_len].copy(), X.iloc[train_len:].copy() y_train, y_val = y.iloc[:train_len].copy(), y.iloc[train_len:].copy() scaler = MinMaxScaler(feature_range=(0, 1)) X_train_scaled = scaler.fit_transform(X_train) X_val_scaled = scaler.transform(X_val)逻辑说明:fit_transform里的fit是只针对训练集计算每个特征的最小值和最大值,transform则是把训练集上算好的缩放参数套到验证集上。验证集、测试集只能被transform,不能被fit。如果先对完整数据集做fit再切分,验证集的价格极值会提前参与训练特征的缩放——这就是最常见的归一化泄漏,离线分数虚高,上了真实数据立刻现原形。这个坑在第5章会再展开。
参数说明:feature_range默认是(0,1)。电价里有负电价时,MinMax会把负值映射到0附近,这是正常的映射结果,不要额外截断。StandardScaler(零均值单位方差)也可以用,但在电价这种含尖峰的长尾数据上,MinMax的边界更直观、更好解释。预测完成后要把数值还原成电价单位,用scaler.inverse_transform处理预测结果。
2.3 时间特征与外部变量:把星期几、预测日类型编码成 SVM 能懂的数
SVR没有时间概念,它不知道3月1日和8月1日有什么区别,也不知道周五和周日之间隔了多久。除了滞后价格,还得喂时间特征。常见做法是:hour直接作为数值特征(电价预测里hour进模型一般就够用,不必非做sin/cos变换);is_weekend用0/1;节假日单独一列is_holiday;供暖季和非供暖季再用一列季节特征。如果手里有公开的负荷或温度数据,加一列同期负荷预测值或温度,对电价预测的提升往往比多加三道滞后特征还明显——价格本质上由供需决定,负荷就是需求侧最直接的代理变量。
我一般会建议团队先把基础特征跑通,再加外部变量。原因很简单:SVR的核函数对每一维特征都做距离计算,加了没用的维度会把噪声也放大进去,效果未必比少加好。外部变量缺失时,用「同一时刻的滞后价格+星期几+小时」已经能解释电价序列的大部分方差。特征造好之后,顺手看一眼每列特征的min/max和缺失率,缺失超过30%的列直接去掉,缺失少的用前向填充,再进归一化。
3. SVR核函数选型与三组必调参数:C、epsilon、gamma怎么试
很多人在SVR上调参像抽签,今天C调成10,明天换成100,看哪个分数高用哪个。实际上SVR的损失函数设计和普通回归完全不同,把「epsilon不敏感带」这件事想明白,参数怎么调就顺理成章了。
3.1 先搞清楚 SVR 在干什么:epsilon 不敏感带与支持向量
SVR回归的目标不是最小化均方误差,而是找到一个函数f(x),让大部分样本的预测误差落在±epsilon的「管道」内。管道内的样本不产生任何损失,只有超出管道的样本才成为支持向量并产生惩罚。这意味着epsilon直接决定了模型对误差的容忍度:epsilon设大了,模型对尖峰、负电价这类离群点视而不见,预测曲线会非常平滑;设小了,模型被噪声带着跑,训练时间暴涨。C是管道外样本的惩罚权重,和分类里的C一个逻辑。
理解了这两条,再看RBF核里的gamma。gamma决定单个样本的影响半径,gamma越大,决策边界越紧贴训练数据,越容易过拟合;落回电价回归,尖峰时段的价格样本不多,却往往决定交易盈亏,gamma太小会把尖峰当噪声抹掉,gamma太大又容易把尖峰附近个别样本单独拟合出来,次日常规时段跟着翻车。SVR的结构不像线性回归那样能把每个特征系数直接读出来,调参时确实有点黑匣子感,所以更依赖一套系统的搜索流程。
3.2 核函数怎么选:RBF 优先,但线性核在电价上常有惊喜
RBF核是SVR的默认选择,它能把特征映射到无穷维,理论上可以拟合任意非线性关系,电价这种强周期性、尖峰多的数据,用RBF不会错。线性核则适合特征和标签关系接近线性的场景——如果只靠滞后特征做电价预测,序列本身的周期性会让线性核也能拿到不差的分数,线性核训练快、外推时不会像高次多项式那样爆发。多项式核在电价预测里我基本不推荐,次数稍微高一点,碰到尖峰样本就会剧烈外溢,预测出离谱的负值。
一个实用的选型策略是先跑线性核做基线,再跑RBF看提升幅度。提升在5%以内,生产环境为了速度和可解释性就留线性核;提升超过10%,用RBF值得付出训练成本。核函数的对比要固定C和epsilon,否则分不清是核的功劳还是参数的功劳。sklearn的SVR里kernel参数直接填'linear'或'rbf',gamma='scale'时RBF会自动按特征数放缩,这个默认值在电价特征规模下是安全的起点。
3.3 网格搜索与 TimeSeriesSplit:别用随机 K 折
from sklearn.svm import SVR from sklearn.model_selection import TimeSeriesSplit, GridSearchCV param_grid = { 'C': [0.1, 1.0, 10.0, 50.0], 'epsilon': [0.01, 0.05, 0.1], 'gamma': ['scale', 0.01, 0.1], } tscv = TimeSeriesSplit(n_splits=5) search = GridSearchCV( SVR(kernel='rbf'), param_grid, cv=tscv, scoring='neg_mean_absolute_error', n_jobs=-1, ) search.fit(X_train_scaled, y_train) print('best params:', search.best_params_) print('best CV MAE:', -search.best_score_)逻辑说明:GridSearchCV会遍历4×3×3共36组参数做5折时序交叉验证;scoring用neg_mean_absolute_error是因为sklearn的 scoring 默认越大越好,MAE取负之后越大相当于MAE越小。TimeSeriesSplit按时序递增切分,前折的数据永远在时间上早于后折,不会出现随机K折把未来样本混进训练集的问题。在电价预测这种时序任务里,随机K折的搜索结果再漂亮都不能用。
参数范围怎么定:C从0.1到50,是因为电价数据尖峰多、噪声大,惩罚太小模型会过于平滑;epsilon从0.01到0.1是看价格尺度——归一化后价格在0到1之间,0.1已经算很宽的管道;gamma用'scale'做基线,再试0.01和0.1两个数量级。更精细的做法是先粗粒度跑一遍,观察最优值落在边界还是中间。落在边界说明范围没给够,继续外扩;落在中间说明附近可以加密网格再搜一轮。注意RBF核的SVR在高C、小epsilon组合下训练很慢,先拿一半数据试跑,确认耗时能接受再全量搜。
4. 不只是MAE:短期电价预测的误差拆解与分时段评估
一个模型交出去之前,只报一个总体MAE,是很不负责任的。电价预测的误差分布极不均匀——把一天24小时的预测误差混在一起算,峰时段的大误差会被谷时段的小误差掩盖。所以评估至少要做三件事:选对指标、分时段记账、和持久性基线对比。
4.1 MAPE 在负电价时会翻车:改用 MAE/RMSE 与 sMAPE
MAPE是很多回归任务的默认指标,但发电侧现货市场现在有负电价——光伏大发、需求疲软的午间,批发价可能跌到负数。MAPE的分母是真实值,真实值为负时MAPE要么为负、要么除以零趋近无穷,完全没法用。sMAPE是常见的替代口径:
import numpy as np def smape(y_true, y_pred): denominator = np.abs(y_true) + np.abs(y_pred) denominator = np.where(denominator == 0, 1e-6, denominator) return np.mean(2 * np.abs(y_true - y_pred) / denominator) * 100逻辑说明:sMAPE的分子是两倍绝对误差,分母是真实值和预测值绝对值之和。真实值为-20、预测值为-15时,误差占比是2×5/(20+15),仍然有界。denominator接近0时的防御处理是必须的,否则个别预测和真实值同时接近0的点会除出无穷大。业务汇报时同时报MAE(单位是元/MWh)和sMAPE(百分比口径),RMSE在需要重点惩罚大误差时再补上。
4.2 分时段误差:峰段永远比谷段难,要分别记账
把预测结果按小时拆开,统计每个时段的误差,比只看一个总量有用得多。下表是我常用的一种分桶方式,量级是相对示意:
| 时段 | 典型价格水平 | 误差相对量级 | 说明 |
|---|---|---|---|
| 夜间低谷(0-6时) | 低 | 较低 | 价格低位、波动小 |
| 早高峰(7-10时) | 高 | 偏高 | 价格爬坡快,滞后特征反应慢 |
| 午间(11-15时) | 中低 | 中等 | 光伏出力影响,天气波动 |
| 晚高峰(17-21时) | 高 | 最高 | 双峰叠加,SVR最难拟合的段 |
| 深夜(22-23时) | 中 | 中等 | 负荷回落,价格下行 |
做法是逐小时计算每天的平均绝对误差,按月份画一张热力图,一眼能看出模型在哪几个钟点系统性跑偏。峰时段误差大未必是模型问题——如果持久性基线在这个时段误差同样大,说明这个时段本身方差就大,模型能做的只是别比基线差太多。反过来,如果谷时段误差反而偏高,多半是特征里有东西没对齐,比如工作日和周末的样本被混在同一个模型里。
4.3 和持久性基线对比:跑不赢「昨天」说明模型白做了
持久性基线(persistence forecast)是电价预测的傻瓜基线:直接拿前一天同一小时的电价作为今天的预测。这个基线在强周期性数据上强得离谱,尤其在平稳天气、无突发机组的市场里。SVM的任何收益都要和它对比着看:
df['persist_pred'] = df['price'].shift(24) # 昨天同一时刻 df['svm_pred'] = svm_predictions # SVR 输出的预测序列 for col in ['svm_pred', 'persist_pred']: mae = np.mean(np.abs(df['price'] - df[col])) print(col, 'MAE:', mae)逻辑说明:shift(24)直接构造持久性基线,它不消耗任何训练成本。SVR模型哪怕只比这个基线低5%的MAE,在业务上都是有意义的,因为电价套利的边际利润率常常只有几个百分点。反过来,如果SVR跑不赢持久性基线,第一反应不是调参,而是回去查特征:是不是lag_24占了绝对主导、模型实际在学「复制昨天」?这和第5章的5.3是同一个病根。
4.4 评估集要跨过完整的市场状态变化
评估集的长度不能拍脑袋定。只看春季一个月的平稳数据,模型表现会很漂亮,但换到夏季高温或冬季寒潮时段,价格结构和波动幅度完全变样,误差会突然拉大。我一般要求评估集覆盖至少一个完整季度,最好跨过一种极端市场状态——比如包含几段价格尖峰和几段负电价时段。如果训练数据里没有这些状态,模型在极端日的表现就是纯外推,这时候要把极端日单独分出来看,而不是混在总体MAE里一带而过。
5. 避坑:数据泄漏、尖峰削平与 SVM 参数玄学的五条记录
以下五条是从特征、交叉验证到参数的真实踩坑记录。调SVM模型翻车,基本都能归到这几类里面。每一条按「现象→原因→解决」写清楚,比背一堆调参口诀有用。
5.1 全量归一化后 CV 分数漂亮,上线第二天翻车:数据泄漏
现象:训练集、验证集、测试集的归一化一起做完之后,交叉验证分数很漂亮,部署后真实预测误差直接放大一倍以上。
原因:全局MinMaxScaler在全部数据上fit,验证集和测试集的价格极值提前参与了训练集特征的缩放。模型训练时的特征分布和真实环境特征分布不一致,离线的低误差是假的。
解决:归一化只在训练段fit,验证段和测试段只transform,按2.2节的写法执行。改完之后再做一次walk-forward回测,确认误差量级和离线评估一致再谈上线。
5.2 TimeSeriesSplit 忘记留 gap,交叉验证分数虚高
现象:已经用TimeSeriesSplit替换随机K折,分数还是偏乐观,尤其当滞后特征里有lag_1、lag_2这种短滞后时间窗时。
原因:前折训练集末尾的样本,它自身的滞后特征可能就指向后折验证集开头的时间点——极端情况下,验证集第一个样本的lag_1就是训练集最后一个样本本身。信息从训练集渗到了验证集,这属于切分引入的泄漏。
解决:TimeSeriesSplit里设置gap参数,让训练集和验证集之间空出至少24个点。例如tscv = TimeSeriesSplit(n_splits=5, gap=24)。gap的取值参考滞后特征的最大跨度,滞后特征里有lag_168时,gap至少给到48或72才有意义。
5.3 预测曲线是昨天的平移:SVR 退化成了 naive 基线
现象:SVR预测的曲线和前一天价格高度重合,误差评估一看,主要误差来自小时级别的相位偏移,整体滞后一个大周期。
原因:lag_24在特征里占绝对主导,SVR发现「复制昨天」能把损失降到很低,于是把其他特征全部当噪声忽略。这是特征工程问题,不是参数问题。
解决:不要用lag_1到lag_24连续一整套滞后特征,保留lag_1、lag_2、lag_24、lag_168这种稀疏组合;把is_weekend、hour这些时间特征保留;有条件就加入外部变量。训练完删掉lag_24再跑一遍,如果误差没有明显变大,说明模型真的学到了周期结构而不是在复制粘贴。
5.4 尖峰永远被削平:epsilon 太大、C 太小
现象:预测曲线整体平滑,早晚高峰被削成圆顶,价格尖峰日的预测值离真实值差一大截,峰时段分桶误差明显高于常规时段。
原因:SVR的epsilon是「不敏感管道」的宽度,管道越宽,尖峰越容易被当作可忽略的噪声。C是管道外样本的惩罚,C小了,模型宁可让尖峰落在管道外也不在乎。
解决:把epsilon从0.1逐步调到0.01或0.005,同时把C从1升到10~50,观察峰时段MAE的变化。注意epsilon下调后训练时间会明显变长,这是正常的计算代价。还有一招:针对尖峰时段(比如早高峰7-10点)单独再训一个小模型,主模型负责常规时段,这个组合在峰谷差异大的市场上效果明显。
5.5 把星期几当成数字 1~7:SVR 的距离假设被坑
现象:周一和周日的预测在边界附近出现奇怪的突变,误差呈周期性抖动,工作日和周末的界限被模糊。
原因:weekday用数字编码后,SVR的核函数按数值距离理解它——1和7之间的距离是6,模型以为「周一」和「周日」是两个极端,实际上它俩是相邻的七天循环。数值编码破坏了循环结构。
解决:把weekday拆成布尔特征,is_weekend一列、is_holiday一列,最多再补is_monday到is_friday中的几列,够用就好。更精细的做法是hour和weekday一起做sin/cos周期编码,但电价预测实践中,稀疏的布尔特征往往更抗噪、更好解释。
6. 让预测真正能上线:walk-forward滚动回测与每日重训
静态的train/val/test切分只能证明「这版参数在历史上不差」,不能证明每天都能正常出数。上线前要做walk-forward滚动回测:从历史某个时点出发,用截至该时点的数据训练,预测未来24小时,然后窗口向前滚动,重复直到覆盖整个测试周期。这个流程比单次切分更接近真实生产环境。
6.1 walk-forward 滚动回测循环怎么写
train_window = 365 * 24 # 用过去一年数据训练 step = 24 # 每次滚动一天 preds, truths = [], [] for end in range(train_window, len(scaled_df), step): train = scaled_df[end - train_window:end] val = scaled_df[end:end + step] X_tr, y_tr = train.drop(columns=['price']), train['price'] X_va, y_va = val.drop(columns=['price']), val['price'] svr = SVR(kernel='rbf', C=10, epsilon=0.05, gamma='scale') svr.fit(X_tr, y_tr) preds.extend(svr.predict(X_va)) truths.extend(y_va.values) print('walk-forward MAE:', np.mean(np.abs(np.array(preds) - np.array(truths))))逻辑说明:这里的scaled_df是已经构造好滞后特征、按时序索引的完整DataFrame,训练前只做当轮归一化、不提前fit全量数据。循环里每次用滚动窗口内的数据重训一次SVR,再预测下一天24小时,窗口向前移动一天。这样得到的误差近似真实部署时的误差。数据跨度大时这一步很慢,可以先每隔7天滚动一次评估误差趋势,确认稳定后再按天滚动。
6.2 每日重训还是增量更新:SVR 没有现成的增量路径
SVR的增量学习不常用,sklearn里的SVR也不支持partial_fit。常见做法是每天凌晨用「最近一年」的数据全量重训一次,训练几分钟、预测几十毫秒,代价完全可接受。数据量超过几十万条时,按天降采样成每小时一个点,或者只保留最近三个月——电价的周期性会随时间漂移,太久以前的数据反而拖后腿。模型更新后把前一天的预测误差记录下来,按周看误差是否随模型版本漂移,比每次都盯着参数调要省心。
6.3 一份能直接照做的验收 checklist
逐项确认:按时序切分训练、验证、测试,归一化只fit训练集;TimeSeriesSplit带gap做参数搜索;离线评估对比持久性基线,并记录优势比例;分时段统计MAE,确认峰时段误差在可接受范围;完整跑一轮walk-forward回测,误差和离线评估量级一致;模型连续运行两周,观察市场进入极端状态时误差是否失控。
有一说一,我最早那版直接把sklearn默认参数的SVR塞进电价数据,预测曲线平滑得像均值回归,尖峰全被抹平,当时还以为是市场数据噪声大。后来把epsilon从默认调小两个数量级、C拉大,又补上星期特征,才算是真正能用的模型。参数这件事多少有点玄学,但每一步踩坑都对应一个明确的特征或切分问题,查清楚比盲目调参有用得多。希望帮到你。
本文还有配套的精品资源,点击获取