当一个时间序列的均值有规律的、周期性的变化时,我们就说这个时间序列表现出季节性。季节性的变化通常是遵循时间的——以一天、一周或一年为单位重复。
这里介绍两种季节性特征。一种适用于观测较少的季节,例如在一周中每日观测的序列;另一种是傅里叶特征,适用于观测较多的季节,例如在一年中每日观测的序列。
类似于用移动平均图来发现时间序列的趋势,我们可以使用季节图来发现时间序列的季节模式。
季节性图将时间序列按固定周期分割成了片段,这些片段对应于一个相同的周期,这个周期就是我们想要观察的**“季节”**。下图为维基百科关于三角学文章的日浏览量的季节图,周期为一周。
季节指标是二元特征,表示时间序列水平的季节差异,将季节性时期视为分类特征并进行独热编码,就可以得到季节性指标。通过对一周中的天数进行独热编码,我们得到每周的季节性指标,即六个新的“虚拟特征”。(如果去掉其中一个指标,线性回归的效果会更好,这里剔除了星期一):
Date | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday |
---|---|---|---|---|---|---|
2016-01-04 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
2016-01-05 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
2016-01-06 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 |
2016-01-07 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 |
2016-01-08 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 |
2016-01-09 | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 |
2016-01-10 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 |
2016-01-11 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
… | … | … | … | … | … | … |
在训练集数据中加入季节性指标有助于模型区分季节期间的均值:
傅里叶特征更适用于长季节的观测,它不会用独热编码的方式为每个日期创建一个特征,而是试图用几个特征来捕捉季节曲线的整体形状。
从图中我们可以发现三角学序列在不同频率上的重复:一年中三次长时间的上下运动,一年 52 次短时间的每周运动,也许还有其他频率。现在我们试图用傅里叶特征来捕捉一个季节内的这些频率,采用三角函数正弦和余弦的曲线。
傅里叶特征是一对正弦和余弦曲线,从最长的季节开始,每个潜在频率对应一对,模拟年度季节性的傅里叶对将具有的频率:每年一次、每年两次、每年三次,等等。
如果我们将一组这样的正弦/余弦曲线添加到我们的训练数据中,线性回归算法将计算出适合目标序列中季节成分的权重。下图中给出了如何使用四个傅里叶对来模拟维基三角学序列中的年度季节性。
我们发现只需要 8 个特征(4 个正弦/余弦对)就可以很好地估计年度季节性,与此相比,季节性指标方法需要数百个特征(一年中的每一天特征)。通过傅里叶特征我们只模拟了季节性的“主效应”,通常需要在训练数据中添加更少的特征,这意味着减少了计算时间和降低了过拟合的风险。
应该在特征集中包含多少傅里叶对?通常我们用周期图来回答这个问题,周期图给出了时间序列中频率的强度,具体来说,下图中 y 轴上的值为 (a ** 2 + b ** 2) / 2
,其中 a
和 b
是该频率下的正弦和余弦的系数。
可以看出,从左到右,周期图在 Quarterly 之后下降,一年四次。这就是为什么我们选择了四个傅里叶对来模拟每年的季节,这里忽略了“每周”的频率,因为它更适合用独热编码的季节指标来建模。
尽管 statsmodel
库已经对傅里叶特征的计算进行了完美的封装,这里还是给出了逐步的计算过程,以便帮助我们加深对细节的了解,这里给出了如何从时间序列的索引中推到出一组傅里叶特征。
import numpy as np
import pandas as pd
def fourier_features(index, freq, order):
time = np.arange(len(index), dtype=np.float32)
k = 2 * np.pi * (1 / freq) * time
features = {}
for i in range(1, order+1):
features.update({
f"sin_{freq}_{i}": np.sin(i * k),
f"cos_{freq}_{i}": np.cos(i * k),
})
return pd.DataFrame(features, index=index)
# 利用 4 个傅里叶特征对计算一年中每日观察的时间序列的季节性
# fourier_features(y, freq=365.25, order=4)
这里定义了两个函数:seasonal_plot
和 plot_periodogram
。
from pathlib import Path
from warnings import simplefilter
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
from sklearn.linear_model import LinearRegression
from statsmodels.tsa.deterministic import CalendarFourier, DeterministicProcess
simplefilter("ignore")
# Configuration
simplefilter("ignore")
sns.set(style="whitegrid")
plt.rc("figure", autolayout=True, figsize=(11, 5))
plt.rc(
"axes",
labelweight="bold",
labelsize="large",
titleweight="bold",
titlesize=14,
titlepad=10,
)
plot_params = dict(
color="0.75",
style=".-",
markeredgecolor="0.25",
markerfacecolor="0.25",
legend=False,
)
%config InlineBackend.figure_format = "retina"
def seasonal_plot(X, y, period, freq, ax=None):
if ax is None:
_, ax = plt.subplots()
palette = sns.color_palette("husl", n_colors=X[period].nunique(),)
ax = sns.lineplot(
x=freq,
y=y,
hue=period,
data=X,
ci=False,
ax=ax,
palette=palette,
legend=False,
)
ax.set_title(f"Seasonal Plot ({period}/{freq})")
for line, name in zip(ax.lines, X[period].unique()):
y_ = line.get_ydata()[-1]
ax.annotate(
name,
xy=(1, y_),
xytext = (6, 6),
color=line.get_color(),
xycoords=ax.get_yaxis_transform(),
textcoords="offset points",
size=14,
va="center",
)
return ax
def plot_periodogram(ts, detrend='linear', ax=None):
from scipy.signal import periodogram
fs = pd.Timedelta('365D') / pd.Timedelta('1D')
freqencis, spectrum = periodogram(
ts,
fs=fs,
detrend=detrend,
window='boxcar',
scaling='spectrum',
)
if ax is None:
_, ax = plt.subplots()
ax.step(freqencis, spectrum, color='purple')
ax.set_xscale('log')
ax.set_xticks([1, 2, 4, 6, 12, 26, 52, 104])
ax.set_xticklabels(
[
'Annual (1)',
'Semiannual (2)',
'Quarterly (4)',
'Bimonthly (6)',
'Monthly (12)',
'Biweekly (26)',
'Weekly (52)',
'Semiweekly (104)',
],
rotation=30
)
ax.ticklabel_format(axis='y', style='sci', scilimits=(0, 0))
ax.set_ylabel('Variance')
ax.set_title('Periodogram')
return ax
data_dir = Path('data/ts-course-data')
tunnel = pd.read_csv(data_dir / 'tunnel.csv', parse_dates=['Day'])
tunnel = tunnel.set_index('Day').to_period('D')
X = tunnel.copy()
# days within a week
X['day'] = X.index.dayofweek # the x-axis (freq)
X['week'] = X.index.week # the seasonal period (period)
# days within a year
X['dayofyear'] = X.index.dayofyear
X['year'] = X.index.year
fig, (ax0, ax1) = plt.subplots(2, 1, figsize=(11, 6))
seasonal_plot(X, y='NumVehicles', period='week', freq='day', ax=ax0)
seasonal_plot(X, y='NumVehicles', period='year', freq='dayofyear', ax=ax1)
# 看一下周期图
plot_periodogram(tunnel.NumVehicles)
从周期图可以发现:周季较强,年季较弱。周季的建模我们采用季节指标,年季的建模采用傅里叶特征,周期图在双月 Bimonthly(6) 和 Monthly(12) 之间下降,所以我们使用 10 个傅里叶对。季节性特征的创建使用 DeterministicProcess
。
from statsmodels.tsa.deterministic import CalendarFourier, DeterministicProcess
fourier = CalendarFourier(freq='A', order=10) # 10 sin/cos pairs for 'A'nnual seasonality
dp = DeterministicProcess(
index=tunnel.index,
constant=True, # dummy feature for bias (y-intercept)
order=1, # trend (order 1 means linear)
seasonal=True, # weekly seasonality (indicators)
additional_terms=[fourier], # annual seasonality (fourier)
drop=True, # drop terms to avoid collinearity
)
X = dp.in_sample()
X.head()
const | trend | s(2,7) | s(3,7) | s(4,7) | s(5,7) | s(6,7) | s(7,7) | sin(1,freq=A-DEC) | cos(1,freq=A-DEC) | sin(2,freq=A-DEC) | cos(2,freq=A-DEC) | sin(3,freq=A-DEC) | cos(3,freq=A-DEC) | sin(4,freq=A-DEC) | cos(4,freq=A-DEC) | sin(5,freq=A-DEC) | cos(5,freq=A-DEC) | sin(6,freq=A-DEC) | cos(6,freq=A-DEC) | sin(7,freq=A-DEC) | cos(7,freq=A-DEC) | sin(8,freq=A-DEC) | cos(8,freq=A-DEC) | sin(9,freq=A-DEC) | cos(9,freq=A-DEC) | sin(10,freq=A-DEC) | cos(10,freq=A-DEC) | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Day | ||||||||||||||||||||||||||||
2003-11-01 | 1.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | -0.867456 | 0.497513 | -0.863142 | -0.504961 | 0.008607 | -0.999963 | 0.871706 | -0.490029 | 0.858764 | 0.512371 | -0.017213 | 0.999852 | -0.875892 | 0.482508 | -0.854322 | -0.519744 | 0.025818 | -0.999667 | 0.880012 | -0.474951 |
2003-11-02 | 1.0 | 2.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | -0.858764 | 0.512371 | -0.880012 | -0.474951 | -0.043022 | -0.999074 | 0.835925 | -0.548843 | 0.899631 | 0.436651 | 0.085965 | 0.996298 | -0.811539 | 0.584298 | -0.917584 | -0.397543 | -0.128748 | -0.991677 | 0.785650 | -0.618671 |
2003-11-03 | 1.0 | 3.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | -0.849817 | 0.527078 | -0.895839 | -0.444378 | -0.094537 | -0.995521 | 0.796183 | -0.605056 | 0.933837 | 0.357698 | 0.188227 | 0.982126 | -0.735417 | 0.677615 | -0.963471 | -0.267814 | -0.280231 | -0.959933 | 0.668064 | -0.744104 |
2003-11-04 | 1.0 | 4.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | -0.840618 | 0.541628 | -0.910605 | -0.413279 | -0.145799 | -0.989314 | 0.752667 | -0.658402 | 0.961130 | 0.276097 | 0.288482 | 0.957485 | -0.648630 | 0.761104 | -0.991114 | -0.133015 | -0.425000 | -0.905193 | 0.530730 | -0.847541 |
2003-11-05 | 1.0 | 5.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | -0.831171 | 0.556017 | -0.924291 | -0.381689 | -0.196673 | -0.980469 | 0.705584 | -0.708627 | 0.981306 | 0.192452 | 0.385663 | 0.922640 | -0.552435 | 0.833556 | -0.999991 | 0.004304 | -0.559589 | -0.828770 | 0.377708 | -0.925925 |
# 进行 90 天的预测
y = tunnel['NumVehicles']
model = LinearRegression(fit_intercept=False)
_ = model.fit(X, y)
y_pred = pd.Series(model.predict(X), index=y.index)
X_fore = dp.out_of_sample(steps=90)
y_fore = pd.Series(model.predict(X_fore), index=X_fore.index)
ax = y.plot(color='0.25', style='.', title='Tunnel Traffic - Seasonal Forecast')
ax = y_pred.plot(ax=ax, label='Seasonal')
ax = y_fore.plot(ax=ax, label='Seasonal Forecast', color='C3')
_ = ax.legend()