去年帮一个课题做短期电价预测,拿到数据第一眼我就觉得这玩意儿跟心电图似的,忽高忽低毫无规律。但真正把序列拆开看,你会发现每天同一时段的电价之间有很强的“记忆”,昨天的低谷和昨天的尖峰,都会以某种形态在今天重现。这正是ARIMA这类时间序列模型发挥价值的地方——它不关心电价为什么涨,只关心涨跌之间的统计规律能不能延续下去。本文围绕基于ARIMA的电价预测展开,重点讲清楚置信区间的含义和Matlab实现方法,适合正在做电价预测课题、想用ARIMA做基准模型、或者刚接触时间序列建模的研究生和从业者参考。
我会把整个建模过程按照实际踩坑顺序展开:先看数据特征,再做平稳化处理,然后是定阶、估计、预测、区间计算,最后是一套可以直接改的Matlab主流程,以及几个让我印象深刻的翻车场景。
1. 电价数据有哪些“脾气”,ARIMA为什么能接住
1.1 电价序列的三个典型特征
第一条特征是非平稳性。电价在一天24小时内会形成明显的“峰—平—谷”结构,早高峰和晚高峰价格高,深夜价格低,周末整体低于工作日。均值在漂移,方差也在变,传统的线性回归拿这种序列没办法,但ARIMA天生带差分环节,可以先把这种结构性变化去掉再建模。
第二条特征是周期性嵌套。电价不仅有24小时的小周期,还有周内的工作日/休息日大周期。夏季和冬季的负荷特性也完全不同。严格的讲,纯ARIMA模型对周期性的处理能力有限,它主要依靠差分和高阶自回归项去“追”这些周期。实际操作时,要么把d加高,要么把数据按小时拆开建模,我后面会详细说。
第三条特征是尖峰毛刺。某个时段突然的负荷激增、极端天气、机组检修,都可能让电价瞬间冲到正常价的数倍。这类点对统计模型非常不友好,它会拉高残差方差,让置信区间整体变宽,同时也容易让模型估计偏掉。如何处理尖峰,是电价预测里必须做的取舍,不是简单删掉就能解决。
1.2 ARIMA模型的定位:不是万能,但够用
很多人在做电价预测时一上来就考虑深度学习、集成学习,仿佛不用神经网络就不算“研究”。但实际上,当你需要一个可解释、可复现、能算置信区间的基准模型时,ARIMA依然是性价比最高的选择。
对比来看:
- 线性回归假设误差独立同分布,电价残差明显自相关,不满足。
- 支持向量回归调参复杂,预测区间需要用分位数回归或bootstrap额外构建。
- 神经网络效果好,但样本量不够时容易过拟合,而且预测区间要费劲地做多次蒙特卡洛。
ARIMA的逻辑是:把“今天的值”理解为“过去若干天的值”加上一组历史扰动加权组合的结果。它结构紧凑,参数数量少,样本量几千条以内完全能跑,而且Matlab的工具箱给得非常完整,建模、估计、预测、区间一条龙。
1.3 在课题中ARIMA通常扮演什么角色
我在实际项目里,ARIMA一般不是最终答案,但它是所有模型的“起跑线”。拿到一段历史电价,先用ARIMA垫底,出一个点预测结果和区间,再对比其他模型的精度和区间覆盖率,就能快速判断更复杂的模型到底值不值得上。
另外一个很重要的用途是给交易策略提供风险边界。电力市场化交易里,单纯知道“明天均价大概是多少”是不够的,更关键的是知道“价格落在什么范围内”。ARIMA给出的置信区间,尽管在很多情况下偏窄,但它至少给出了一个统计意义上的参考边界,配合场景分析可以用得很实在。
2. 数据准备与平稳化:先让序列“老实”下来
2.1 数据粒度选择与缺失值处理
电价预测的第一步是确定粒度。我建议先从小时粒度入手,因为绝大多数市场对外公布的结算电价都是小时粒度,数据获取容易,后续换15分钟粒度只需把周期长度改掉即可。
数据清洗要注意几点:
- 缺失值不要直接删。用前几个同时间点的中位数补齐,或者用线性插值。删掉会造成周周期断点。
- 重复时间戳要合并,保留最后一次有效值。
- 检查单个时段连续多天完全相同的值,这通常意味着数据源有问题,需要回溯。
数据量上,如果做单条序列的宏观建模,至少要有600个以上观测点,也就是25天的数据。如果想按小时拆成24条序列分别建模,建议每条序列至少200个点,也就是至少200天历史。数据太少时,ARIMA的参数估计方差会很大,预测区间看起来很美,实际上毫无意义。
2.2 用ADF检验判断差分次数
ARIMA的“I”部分,也就是差分,是用来把非平稳序列变平稳的。差分的次数d不能靠肉眼拍脑袋,要用单位根检验来定。
Matlab里直接用Econometrics Toolbox的adftest:
[h, pValue] = adftest(prices);h = 1表示拒绝单位根假设,序列已经平稳,d可以取0;h = 0表示存在单位根,需要差分。对原始电价序列而言,大概率h = 0,所以先做一阶差分:
diffPrices = diff(prices); [h1, pValue1] = adftest(diffPrices);如果一阶差分后p值远小于0.05,那d = 1就够了。电价序列一般一阶差分后就能平稳,少数情况需要进行季节性差分,比如24步差分,才能彻底打掉日周期。
注意:adftest默认含常数项,实际做的时候可以对比有趋势项和没有趋势项两种情况。电价序列没有明显的确定性线性趋势,所以默认配置通常够用。
2.3 要不要按小时分拆成24条序列
这是做电价预测最容易纠结的问题。
方案A是把所有小时的数据拼成一条长序列,用ARIMA直接建模。优势是数据量大,能捕捉小时之间的连续性,代码简单;劣势是周期项会让模型阶数变得非常高,常常出现p和q都要到3以上才够,参数多,过拟合风险大。
方案B是把历史数据按小时拆开,例如所有00:00的数据组成一条序列、所有01:00的数据组成另一条,一共24条,各自独立建模预测。优势是避开了日周期问题,每条序列的规律更纯粹;劣势是数据量变成原来的二十四分之一,对数据历史长度要求高,而且忽略了相邻时段之间的关联。
我个人的习惯是:如果历史数据超过一年,优先按小时拆开,分别建模,精度通常更好。如果只有几个月的数据,就用单条序列加高阶差分,不要硬拆。
3. 定阶的逻辑:ACF/PACF肉眼识别加信息准则兜底
3.1 自相关图与偏自相关图的判读
序列平稳之后,就要确定AR项阶数p和MA项阶数q。工具是自相关图(ACF)和偏自相关图(PACF)。
Matlab两个函数很直接:
autocorr(diffPrices, 'NumLags', 48); parcorr(diffPrices, 'NumLags', 48);判读规则很简单,但实际应用时容易被带偏:
- PACF在滞后k阶处突然截尾,而ACF拖尾,对应AR(p),p就是截尾的阶数。
- ACF在滞后k阶处截尾,PACF拖尾,对应MA(q),q就是截尾的阶数。
- 两个都拖尾,说明模型需要AR和MA混合,这时候眼睛就不好使了,需要信息准则兜底。
电价差分序列的ACF和PACF经常出现“双拖尾”,还有一个非常常见的情况:ACF在滞后24阶处又出现明显峰值,这是日周期的残留信号。如果d=1后还有这种情况,你可能需要把d提到24,或者使用SARIMA结构。不过既然标题里强调的是ARIMA,我们优先用信息准则扫参数。
3.2 自动扫参定阶的Matlab脚本
这部分是很多人卡住的地方,我给出一个固定套路:遍历p和q,采用AIC或BIC做比较。AIC更偏重预测精度,BIC更偏重模型简洁,电价预测一般两个都算,优先选两者都认可的候选。
maxP = 4; maxQ = 4; d = 1; AIC = zeros(maxP+1, maxQ+1); BIC = zeros(maxP+1, maxQ+1); for p = 0:maxP for q = 0:maxQ Mdl = arima(p, d, q); try [EstMdl, ~, logL] = estimate(Mdl, prices, 'Display', 'off'); numParams = EstMdl.NumEstimatedParameters; [aic, bic] = aicbic(logL, numParams, length(prices)); AIC(p+1, q+1) = aic; BIC(p+1, q+1) = bic; catch AIC(p+1, q+1) = inf; BIC(p+1, q+1) = inf; end end end [minAIC, idxA] = min(AIC(:)); [minBIC, idxB] = min(BIC(:)); [pA, qA] = ind2sub(size(AIC), idxA); [pB, qB] = ind2sub(size(BIC), idxB);两个注意点:
第一,参数范围不用设太大。电价数据即使有周期,纯ARIMA的p和q通常不会超过5。设到4已经足够,再大会出现数值不稳定,estimate经常报错。
第二,aicbic里传入的logL值必须是极大似然估计得到的对数似然值,estimate的第三个输出就是。NumEstimatedParameters包括常数项和方差,这样比手动数参数个数更准确。
3.3 残差诊断:定阶后不能省的一步
定阶之后必须做残差检验。很多人跑完estimate就直接forecast,后面区间不对才发现问题出在模型本身。
使用infer提取残差:
[residuals] = infer(EstMdl, prices);然后做两件事:画残差的ACF和PACF,跑Ljung-Box白噪声检验:
figure; autocorr(residuals, 'NumLags', 48); parcorr(residuals, 'NumLags', 48); [hLB, pLB] = lbqtest(residuals, 'Lags', [5 10 20 30]);如果残差的ACF里还有显著超出置信带的点,或者Ljung-Box检验返回h = 1,说明残差中还有结构,当前阶数不够,要继续增加p或q。如果残差近似白噪声,说明模型已经把历史信息提取得差不多了,可以进行预测。
这一步就是我说的“返工点”,宁可在定阶时多花几个小时,也不要带着病态模型跑到预测阶段。
4. 置信区间是怎么来的,Matlab里如何拿到它
4.1 预测区间背后的数学逻辑
ARIMA模型经过估计后,会得到一个残差方差σ²,以及一组描述序列动态结构的AR和MA系数。基于这些系数,模型可以把未来时刻的值写成历史扰动项的线性组合。预测第h步的值时,预测误差的来源是:未来尚未发生的随机扰动,以及累计误差。
误差的方差记作预测均方误差,Matlab里叫预测MSE,也就是forecast的第二个输出YMSE。在误差近似正态分布的假设下,95%置信区间的上下界就是:
点预测 ± 1.96 × sqrt(YMSE)置信区间的含义是:如果同样的数据生成机制重复很多次,未来真实值有95%的概率落在这个区间内。注意是“重复数据生成机制”意义上的概率,不是“真实电价一定落在区间里”,这两个说法有本质区别。
4.2 forecast函数的输出与区间计算
Matlab的forecast函数用法非常简单:
numSteps = 24; [forecastY, ymse] = forecast(EstMdl, numSteps, 'Y0', prices);forecastY是接下来24个时段的点预测值,ymse是与预测步长对应的均方误差序列。计算区间:
alpha = 0.05; zScore = norminv(1 - alpha/2); lower = forecastY - zScore * sqrt(ymse); upper = forecastY + zScore * sqrt(ymse);默认情况下forecast的置信水平是95%,如果想算90%或者99%区间,把alpha换掉,重新查正态分位数就行。
有一点必须提醒:forecast返回的ymse是累积误差方差,它会随着预测步数的增加而变化。对平稳的ARMA部分,这个值会逐步收敛到序列的无条件方差,而不是无限膨胀。这一点很多人搞错,以为预测越远区间越宽,其实对平稳序列并不完全是这样。
4.3 为什么区间宽度不会一路膨胀
举个直观例子。对AR(1)模型,如果自回归系数是0.8,残差方差是σ²,那么一步预测误差方差是σ²,两步预测误差方差是σ²乘以(1 + 0.64),三步是σ²乘以(1 + 0.64 + 0.41),逐步增大。但这些项是收敛的,最终极限是σ²除以(1 - 0.64),也就是该序列本身的方差。
也就是说,远期预测区间会稳定在“历史序列一般波动范围”这个水平上,不会无限变大。这个特性在电价预测里非常有用:你无论如何预测30天后的电价,区间宽度都会接近历史全部波动的幅度,这从统计逻辑上是自洽的。当然,如果在模型里加入了差分项,比如d=1,那区间会随步长继续增长,这也是为什么非平稳序列不适合做太长期预测的原因。
5. 一套可以直接改着用的Matlab主流程
5.1 完整流程代码骨架
把前面几个环节拼成一个完整脚本,我通常按下面的结构组织:
%% 参数设置 stepAhead = 24; modelMaxP = 4; modelMaxQ = 4; %% 数据加载与预处理 % prices为列向量,按时间顺序排列 % 缺失值用前向填充或线性插值处理 prices = fillmissing(prices, 'linear'); %% 平稳性检验 [h0, p0] = adftest(prices); d = 0; if h0 == 0 d = 1; end %% 定阶循环 trainLen = length(prices) - stepAhead; trainData = prices(1:trainLen); testData = prices(trainLen+1:end); aicMat = inf(modelMaxP+1, modelMaxQ+1); bicMat = inf(modelMaxP+1, modelMaxQ+1); for p = 0:modelMaxP for q = 0:modelMaxQ Mdl = arima(p, d, q); try [EstMdl, ~, logL] = estimate(Mdl, trainData, 'Display', 'off'); numParams = EstMdl.NumEstimatedParameters; [aic, bic] = aicbic(logL, numParams, length(trainData)); aicMat(p+1, q+1) = aic; bicMat(p+1, q+1) = bic; catch end end end [minAIC, idxA] = min(aicMat(:)); [pOptA, qOptA] = ind2sub(size(aicMat), idxA); pOptA = pOptA - 1; qOptA = qOptA - 1; [minBIC, idxB] = min(bicMat(:)); [pOptB, qOptB] = ind2sub(size(bicMat), idxB); pOptB = pOptB - 1; qOptB = qOptB - 1; % 两个准则不一致时,优先选BIC推荐的更简洁模型 pOpt = pOptB; qOpt = qOptB; %% 最终建模与预测 FinalMdl = arima(pOpt, d, qOpt); FinalEstMdl = estimate(FinalMdl, trainData, 'Display', 'off'); [forecastY, ymse] = forecast(FinalEstMdl, stepAhead, 'Y0', trainData); zScore = norminv(0.975); lowerBound = forecastY - zScore * sqrt(ymse); upperBound = forecastY + zScore * sqrt(ymse);这个骨架可以直接套任何电价数据,只需要替换prices的读入方式。我建议把定阶部分封装成函数,下次换数据时只改一个变量名,能省不少时间。
5.2 画图:区间阴影与真实值对照
预测结果画成图更有说服力。我的标准做法是同时画出历史尾部、点预测、区间阴影、真实值四条信息:
figure; hold on; % 区间阴影 xP = 1:stepAhead; fillX = [xP, fliplr(xP)]; fillY = [upperBound', fliplr(lowerBound')]; fill(fillX, fillY, 'b', 'FaceAlpha', 0.25, 'EdgeColor', 'none'); % 点预测 plot(xP, forecastY, 'r-', 'LineWidth', 1.6); % 真实值 plot(xP, testData, 'k--', 'LineWidth', 1.2); legend('95% 置信区间', 'ARIMA预测', '真实值', 'Location', 'best'); xlabel('预测时段'); ylabel('电价'); grid on; hold off;FaceAlpha设成0.2到0.3之间最合适,太透明看不清,太深又盖住曲线。还有一个小细节:lecture和真实值最好用不同线型,因为实际数据里它们常常非常接近,都用实线会黏在一起。
5.3 几个评估指标:RMSE、MAPE、PICP
只看图不够,还要有量化指标。我做电价预测时必看三个:RMSE看总体误差,MAPE看相对误差,PICP看区间覆盖率。
% RMSE rmse = sqrt(mean((forecastY - testData).^2)); % MAPE mape = mean(abs((forecastY - testData) ./ testData)) * 100; % PICP:区间覆盖率 inside = (testData >= lowerBound) & (testData <= upperBound); picp = sum(inside) / length(testData) * 100;PICP这个指标很多人忽略,但它对置信区间质量是直接检验。如果95%置信区间实际覆盖率只有60%,说明模型对不确定性严重低估,区间“看起来漂亮”但没用。好的模型,PICP应该在95%左右,略低一点可以接受,低太多就要回头查模型假设是否被破坏(比如尖峰值没有处理)。
6. 实测中的翻车现场与应对办法
6.1 遇到尖峰电价时的取舍
做电价预测绕不开尖峰。一次真实的电价尖峰可能就是正常值的3到5倍,不处理它,模型的残差方差会被越抬越高,置信区间范围被迫拉大,而正常时段的预测精度开始下降。
处理尖峰常用三种办法,各有取舍:
| 处理方式 | 优点 | 缺点 |
|---|---|---|
| 直接删除 | 残差方差回归正常 | 信息损失,可能低估真实波动 |
| 缩尾截断 | 保留趋势信号,压制极端值 | 阈值选择主观,需谨慎调整 |
| 做对数变换 | 压缩数量级差异 | 预测值要反变换,区间不再对称 |
我的做法是:先判断尖峰的比例。如果占总样本1%以内,就做缩尾截断,把超过99.5%分位数的值压到分位数处。如果尖峰比例高,超过3%,则做对数变换。对数变换的代价是预测区间在反变换后是偏态的,但偏态在电价场景本身更符合实际——电价的上涨弹性比下跌弹性大得多。
6.2 节假日和突发事件让模型失灵
ARIMA本质上是“历史规律外推”,它假定过去出现的模式在未来会重复。节假日打破了这种假设:某个法定假日的工作/休息模式、负荷曲线、电价形态都可能与平时完全不同。模型没有见过这种形态,预测自然崩盘。
解决策略比较务实:节假日样本从训练集中分离出来单独建模,或者干脆在预测期跳过节假日后接续预测。如果要考虑节假日效应,更合适的是加入外生回归项的ARIMAX,但这已经超出标准ARIMA的范围,我这里不展开。
突发事件更麻烦,比如极端天气导致的全网负荷突增,模型无法提前预测。遇到这种情况,置信区间反而成了最好的预警工具:如果预测区间尾部被真实值“击穿”,说明市场存在模型外信息,此时人工干预比调整参数重要。
6.3 方差不恒定导致区间失真
ARIMA假设残差方差恒定,但电价残差明显存在“高峰时段方差大、低谷时段方差小”的特征。这个假设被破坏后,整体置信区间精度会下降:高峰时段真实值经常越界,低谷时段区间又显得过宽。
想缓解这个问题,有两个方向。方向一是分时段建模,把24小时拆开分别估计,每个时段有自己的残差方差,区间宽度自然不同。方向二是引入GARCH结构,给残差方差建一个动态模型,但Matlab里需要手动组合ARIMA和GARCH,代码复杂度上了一个台阶。
注意:如果只是做课题研究,分时段建模就够了,能做到每个预测时段对应自己方差的区间,已经可以明显提升PICP。
6.4 模型多久重估一次比较合适
ARIMA参数不是一劳永逸的,市场竞争、用电结构变化都可能导致模型系数漂移。我测试过不同重估频率:
- 每天重估:预测最准,但计算开销大,且频繁重估可能让参数没机会收敛。
- 每周重估:精度下降不多,计算量适中,实际项目中我用的最多。
- 每月重估:计算省,但参数更新滞后,碰到季节切换时精度明显掉。
电价的季节性变化意味着固定参数的模型在季节转换期会有一段“错位期”。建议至少每周重估一次,同时每次加入最新一周的观测数据,让模型保持新鲜。
另外一个容易忽略的点:滚动预测时需要重新估计模型,而不是直接在旧模型上继续做forecast。因为ForecastMdl里的参数是用旧数据估计的,新旧序列结构一旦不同,预测偏差就会累积。
最后分享一点我的实际体会
跑完这套流程最大的感受是:ARIMA在电价预测里的价值不完全在点预测精度,而在它给了一个稳定、透明、可解释的不确定性度量。很多复杂模型点预测确实更好,但它们的区间要么需要大量计算才能得到,要么根本没法解释清楚。而对交易决策而言,一个可信的区间比一个漂亮的点更重要。我建议你把这里的代码跑通之后,重点看一下PICP这个数,如果实际覆盖率明显低于置信水平,别急着调模型阶数,先去处理尖峰和节假日这两个变量,它们对区间失真的影响比想象中大得多。