4、时间序列基础:平稳性检验(ADF)、自相关与偏自相关、白噪声检验、时间序列分解
做碳配额价格预测,说白了就是跟时间序列打交道。我刚开始接触碳市场数据时,第一反应就是——这玩意儿跟股票数据长得真像,但细看又完全不是那么回事。碳配额价格受政策影响极大,季节性特征也很明显,所以处理起来需要格外小心。
今天这一章,咱们把时间序列分析最核心的几个基础工具过一遍。这些工具就像工具箱里的螺丝刀和扳手,你不需要每次都全用上,但得知道什么时候该用哪个。
4.1 平稳性检验(ADF检验)
先问个问题:为什么非要研究平稳性?
我举个例子。假设你拿过去三年的碳配额价格数据去训练模型,结果模型学到的全是「价格一直在涨」这个趋势。等模型上线,市场政策一变,价格掉头向下,模型立马崩盘。这就是非平稳数据带来的灾难。
平稳性,说白了就是数据的统计性质不随时间变化。均值恒定、方差恒定、自协方差只跟时间间隔有关,跟具体时间点无关。
ADF检验(Augmented Dickey-Fuller Test)是最常用的平稳性检验方法。它的核心思想很简单:看时间序列是否存在单位根。存在单位根,就是不平稳;不存在,就是平稳。
- 原假设H0:序列存在单位根(不平稳)
- 备择假设H1:序列不存在单位根(平稳)
- p值 < 0.05,拒绝H0,认为序列平稳
- p值 ≥ 0.05,无法拒绝H0,序列不平稳
来看看代码怎么写:
import pandas as pd
import numpy as np
from statsmodels.tsa.stattools import adfuller
import matplotlib.pyplot as plt
# 加载碳配额价格数据(示例)
# 实际项目中我会从交易所API拉取
df = pd.read_csv('carbon_price.csv', parse_dates=['date'], index_col='date')
price_series = df['close_price']
# ADF检验
result = adfuller(price_series, autolag='AIC')
print(f'ADF统计量: {result[0]:.4f}')
print(f'p值: {result[1]:.4f}')
print(f'临界值:')
for key, value in result[4].items():
print(f' {key}: {value:.4f}')
if result[1] < 0.05:
print('✅ 序列平稳,可以继续建模')
else:
print('❌ 序列不平稳,需要差分处理')
我在项目中遇到过一件事。有一次做广东碳市场的价格预测,ADF检验结果显示p值0.03,勉强通过。但我总觉得不对劲,又做了KPSS检验(另一种平稳性检验),结果发现序列其实是趋势平稳。后来我养成了习惯——至少用两种方法交叉验证。
碳配额价格通常是一阶单整的,也就是做一次差分就能变平稳。但要注意,差分会丢失信息,尤其是长期趋势信息。我个人习惯是先做对数变换,再做差分,这样既能稳定方差,又能处理趋势。
4.2 自相关与偏自相关
自相关(ACF)和偏自相关(PACF),这两个概念刚接触时容易搞混。我简单解释一下。
自相关,就是当前时刻的值跟过去某个时刻的值之间的相关性。比如今天的碳价跟昨天的碳价有没有关系?跟三天前的呢?
偏自相关,是在剔除了中间时刻的影响后,两个时刻之间的直接相关性。比如剔除昨天的影响后,今天跟三天前还有没有直接关系?
你想想看,这两个东西有什么用?
嗯,它们最大的用处就是帮我们确定ARIMA模型的阶数。ACF拖尾、PACF截尾,适合AR模型;ACF截尾、PACF拖尾,适合MA模型;两者都拖尾,那就得上ARMA了。
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
# 对差分后的序列画图
diff_series = price_series.diff().dropna()
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))
plot_acf(diff_series, lags=30, ax=ax1)
ax1.set_title('自相关函数 (ACF)')
plot_pacf(diff_series, lags=30, ax=ax2, method='ywm')
ax2.set_title('偏自相关函数 (PACF)')
plt.tight_layout()
plt.show()
ACF和PACF的解读需要结合置信区间(蓝色区域)。超出置信区间的柱子才被认为是显著的。我曾经见过有人把刚好压在边界上的也当作显著,结果模型过拟合得一塌糊涂。
4.3 白噪声检验
白噪声,说白了就是纯随机波动。如果残差是白噪声,说明模型已经把有用信息都提取干净了。如果残差还有自相关结构,说明模型没学好,还有信息没榨出来。
常用的白噪声检验是Ljung-Box检验。原假设是「序列是白噪声」,p值小于0.05就拒绝原假设,说明序列不是白噪声。
from statsmodels.stats.diagnostic import acorr_ljungbox
# 假设我们已经拟合了一个模型,这是残差
residuals = model_fit.resid
# Ljung-Box检验
lb_result = acorr_ljungbox(residuals, lags=[10, 20, 30], return_df=True)
print(lb_result)
# 如果所有p值都大于0.05,说明残差是白噪声
if (lb_result['lb_pvalue'] > 0.05).all():
print('✅ 残差为白噪声,模型拟合良好')
else:
print('❌ 残差存在自相关,需要改进模型')
我记得有一次做欧盟碳配额(EUA)的预测,模型在训练集上表现极好,R²高达0.95。但残差检验一跑,Ljung-Box的p值全是0.00。说白了,模型把噪声也学进去了,泛化能力肯定不行。后来加了正则化,才把问题解决。
我曾经犯过一个低级错误——用训练集做白噪声检验,结果很好,但测试集上一塌糊涂。后来我养成了习惯:白噪声检验一定要在测试集的残差上做,或者至少做滚动验证。
4.4 时间序列分解
时间序列分解,就是把一个序列拆成三部分:趋势、季节性和残差。碳配额价格有明显的季节性特征——履约期前后价格波动剧烈,平时相对平稳。
常用的分解方法有两种:加法模型和乘法模型。加法模型假设各成分相加,乘法模型假设各成分相乘。碳价数据我一般用加法模型,因为波动幅度相对稳定。
from statsmodels.tsa.seasonal import seasonal_decompose
# 加法分解
decomposition = seasonal_decompose(price_series, model='additive', period=365)
# 提取各成分
trend = decomposition.trend
seasonal = decomposition.seasonal
residual = decomposition.resid
# 画图
fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(14, 10))
ax1.plot(price_series, label='原始序列')
ax1.legend()
ax2.plot(trend, label='趋势', color='orange')
ax2.legend()
ax3.plot(seasonal, label='季节性', color='green')
ax3.legend()
ax4.plot(residual, label='残差', color='red')
ax4.legend()
plt.tight_layout()
plt.show()
- 趋势成分:反映长期走势。碳价趋势通常跟宏观经济、政策导向相关
- 季节性成分:反映周期性波动。碳市场有明显的履约周期,每年1-2个高峰
- 残差成分:反映随机波动。如果残差有规律,说明分解不彻底
下面这张图是我自己整理的本章知识体系,方便你快速回顾:
这四个工具,说白了就是一套组合拳。先做平稳性检验,看看数据要不要处理;然后用ACF/PACF确定模型结构;拟合完模型用白噪声检验验证效果;最后用分解理解数据的内在规律。
我个人习惯的顺序是:先分解,再检验平稳性,然后建模,最后用白噪声检验收尾。分解能帮你快速了解数据特征,比如有没有明显的季节性、趋势是涨是跌。有了这些信息,后面的步骤会更有方向感。
碳配额价格数据通常有以下几个特点:
- 存在明显的履约周期(每年11-12月是高峰)
- 政策发布前后会出现突变点
- 交易量低的时候,价格容易出现异常值
处理这些数据时,我建议先用中位数滤波去掉异常值,再做对数差分处理。别问我怎么知道的——都是踩坑踩出来的经验。