从零开始的时间序列预测项目

使用 Python 从零开始预测波士顿每月武装抢劫数量,其中 statsmodels 库版本为 0.13.5,博客首发于 MonthlyArmedRobberiesInBoston | Cloudy1225’s Blog

Monthly Armed Robberies in Boston

时间序列预测是一个过程,获得良好预测的唯一方法是实践这个过程。我们将了解如何使用 Python 预测波士顿每月武装抢劫的数量。此项目将提供一个可以用于处理自己时间序列预测问题的框架。完成本项目,可以知道:

  • 如何检查 Python 环境并定义时间序列预测问题。
  • 如何使用时间序列分析工具创建用于评估模型的测试工具、开发基线预测模型以及更好地理解问题。
  • ARIMA 模型的构建、保存、加载与预测。

概述

我们将从头到尾完成一个时间序列预测项目,从下载数据集、定义问题到训练最终模型、做预测。本项目并不详尽,但展示了如何通过系统地处理时间序列预测问题来快速获得不错的结果。项目步骤如下:

  1. 问题描述
  2. 测试工具
  3. 基线模型
  4. 数据分析
  5. ARIMA模型
  6. 模型验证

这将为解决时间序列预测问题提供一个模板,你可以在自己的数据集上使用该模板。

问题描述

我们的问题是预测美国波士顿每月武装抢劫的数量。该数据集提供了 1966 年 1 月至 1975 年 10 月将近10年波士顿每月武装抢劫的数量,总共有 118 个观测值。该数据集由 McCleary 和 Hay 在 1980 年贡献出来。下面是数据集前几行的样本:

MonthRobberies
1966-0141
1966-0239
1966-0350
1966-0440
1966-0543

下载数据集的 CSV 文件,命名为 robberies.csv ,并放于当前工作目录下。

测试工具

我们必须开发一个测试工具来研究数据并评估候选模型。有两个步骤:

  1. 定义验证集(Validation Dataset)
  2. 开发模型评估(Model Evaluation)方法

验证集

数据集不是最新的,这意味着我们无法轻松收集更新的数据来验证模型。因此,我们将假装现在是 1974 年 10 月,并从分析和模型选择中排除最后一年的数据。最后一年的数据将用于验证最终模型。下面的代码将数据集加载为 pandas.Series ,并被分为两部分:一个用于模型开发(dataset.csv),另一个用于验证(validation.csv)。

In:

# 加载原始数据集,并将其分割为训练集与验证集
from pandas import read_csv

series = read_csv('robberies.csv', header=0, index_col=0, parse_dates=True).squeeze('columns')
split_point = len(series) - 12
dataset, validation = series[0:split_point], series[split_point:]
print('Dataset %d, Validation %d' % (len(dataset), len(validation)))
dataset.to_csv('dataset.csv', header=False)
validation.to_csv('validation.csv', header=False)

Out:

Dataset 106, Validation 12

运行该代码将创建两个文件并打印每个文件中的观测值数量:

dataset 106, Validation 12

两个文件的具体内容为:

  • dataset.csv:1966 年 1 月至 1974 年 10 月的观测值(共106个)
  • validation.csv:1974 年 11 月至 1975 年 10 月的观测值(共12个)

验证集只占原始数据集的 10% 。注意,保存的数据集没有标题行,因此在以后处理这些文件时,我们也不需要标题行。

模型评估

模型评估将仅对上一节 dataset.csv 中的数据执行。模型评估涉及两个元素:

  1. 性能度量(Performance Measure)
  2. 测试策略(Test Strategy)
性能度量

数据集中的观测值是抢劫的数量。我们将使用 均方根误差(RMSE)来评估预测性能。RMSE 与原始数据量纲相同,并且它可以放大较大误差。为了使不同方法之间的性能可以直接比较,在计算 RMSE 之前,不应对数据进行任何变换。

可以使用 scikit-learn 库中的函数 mean_squared_error() 来计算 RMSE。这个函数可以计算期望值序列(测试集)和预测值序列之间的均方误差,然后取平方根即可。例如:

from sklearn.metrics import mean_squared_error
from math import sqrt
...
test = ...
predictions = ...
mse = mean_squared_error(test, predictions)
rmse = sqrt(mse)
print('RMSE: %.3f' % rmse)
测试策略

候选模型将使用前移验证(walk-forward validation)进行评估。这是因为问题的定义使得我们需要一个类型为滚动预测的模型,需要根据所有之前可用数据来进行下一步预测。本次前移验证细节如下:

  1. 前 50% 的数据将被保留来训练模型;
  2. 剩下的 50% 将用于迭代地测试模型;
  3. 对于测试集上的每一次迭代:
    1. 训练一个模型;
    2. 进行一步预测,并将预测值保存以供后续评估;
    3. 测试集的实际观测值将添加到训练集中,以供下一次迭代使用。
  4. 评估每次迭代的预测值,即计算 RMSE 分数。

鉴于数据量较小,我们将在每次预测之前根据所有可用数据重新训练模型。可以使用 NumPy 和 Python 来编写测试工具的代码,首先,直接将数据集拆分为训练集和测试集。注意应始终将加载的数据转换为 float32 类型,以防一些是字符串或整数类型。

# 数据准备
X = series.values
X = X.astype('float32')
train_size = int(len(X) * 0.50)
train, test = X[0:train_size], X[train_size:]

接下来,我们可以迭代测试数据集中的时间步。训练集存储在 Python 的 list 中,因为我们需要在每次迭代时很容易地添加一个新的观测值,而使用 NumPy 数组进行连接感觉有点过了。习惯上,模型做出的预测称为 yhat,因为结果或观测值被称为 y,而 yhat(即 y ^ \hat{y} y^)是预测 y 变量的数学符号。每次迭代,预测值和观测值都会打印,以便在模型出现问题时进行合理性检验(sanity check)。

# walk-forward validation
history = [x for x in train]
predictions = list()
for i in range(len(test)):
    # 预测值
    yhat = ...
    predictions.append(yhat)
    # 观测值
    obs = test[i]
    history.append(obs)
    print('>Predicted=%.3f, Expected=%.3f' % (yhat, obs))

基线模型

陷入数据分析和建模泥潭之前的第一步是建立性能基线(baseline)。这既为测试工具评估模型提供一个模板,又提供了一个可以比较所有更复杂预测模型的性能度量。时间序列预测的基线预测称为 naive forecast 或 persistence。它其实就是令预测值等于前一时间步的观测值。我们可以将其直接插入上一节中定义的测试工具中。完整代码如下:

In:

# 评估 persistence model
from pandas import read_csv
from sklearn.metrics import mean_squared_error
from math import sqrt

# 加载数据
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
# 准备数据
X = series.values
X = X.astype('float32')
train_size = int(len(X) * 0.50)
train, test = X[0:train_size], X[train_size:]
# walk-forward validation
history = [x for x in train]
predictions = list()
for i in range(len(test)):
    # 预测值
    yhat = history[-1]  # 前一步值作为下一步的预测值
    predictions.append(yhat)
    # 实际观测值
    obs = test[i]
    history.append(obs)
    print('>Predicted=%.3f, Expected=%.3f' % (yhat, obs))
# 计算性能
rmse = sqrt(mean_squared_error(test, predictions))
print('RMSE: %.3f' % rmse)

Out:

>Predicted=98.000, Expected=125.000
>Predicted=125.000, Expected=155.000
...
>Predicted=355.000, Expected=460.000
>Predicted=460.000, Expected=364.000
>Predicted=364.000, Expected=487.000
RMSE: 51.844

运行测试工具会打印测试集每次迭代的预测值和实际观测值。该例子以打印模型的 RMSE 结束。在本例中,可以看到基线模型的 RMSE 达到51.844。这意味着平均而言,对于每次预测,模型错估了大约 51 次抢劫。

...
>Predicted=241.000, Expected=287.000
>Predicted=287.000, Expected=355.000
>Predicted=355.000, Expected=460.000
>Predicted=460.000, Expected=364.000
>Predicted=364.000, Expected=487.000
RMSE: 51.844

现在我们有了一个基准预测方法与基准性能,可以深入挖掘数据了。

数据分析

我们可以使用概括性统计量(summary statistics)和数据图来快速了解有关预测问题结构的更多信息。本节中,我们将从四个角度观察数据:

  1. 概括性统计量(Summary Statistics)
  2. 折线图(Line Plot)
  3. 密度图(Density Plot)
  4. 箱线图(Box and Whisker Plot)

概括性统计量

在文本编辑器中打开 robberies.csv 文件并查看数据,快速扫一下有没有明显的缺失值。这样便可能在将 NaN? 强制转换为浮点数之前发现它。概括性统计量提供了数据集的总体视图,可以我们帮助快速了解正在使用的数据。下面的代码计算并打印时间序列的 summary statistics:

In:

# summary statistics of time series
from pandas import read_csv
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
print(series.describe())

Out:

count    106.000000
mean     173.103774
std      112.231133
min       29.000000
25%       74.750000
50%      144.500000
75%      271.750000
max      487.000000
Name: 1, dtype: float64

运行上面代码将打印几个概括性统计量,我们可以从中观察到:

  • 观测值的数量(count)符合我们的预期,说明我们成功分割了数据;
  • 平均值(mean)约为 173,我们可以将其视为此时间序列的取值水平;
  • 标准差(std,与平均值的偏差)相对较大,约为 112;
  • min、25%、50%、75%、max 以及 std 确实表明数据差异较大。

如果数据取值的较大差异是由随机波动(如非系统性波动)引起的,那么高准确预测将变得困难。

折线图

时间序列的折线图可以提供对问题的大量见解。下面的代码创建并显示了数据集的折线图:

In:

# 时间序列的折线图
from pandas import read_csv
from matplotlib import pyplot
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
series.plot()
pyplot.show()

Out:

折线图
运行代码并观察折线图,注意时间序列中任何明显的时间结构。我们可以从折线图中观察到:

  1. 随着时间的推移,抢劫数量呈上升趋势;
  2. 似乎没有任何明显的离群点(outliers);
  3. 每年有较大的上下波动;
  4. 后几年的波动似乎大于前几年的波动
  5. 上升趋势与波动的明显变化意味着数据集几乎可以肯定是非平稳的(non-stationary)。

这些简单的观察结果表明,对趋势进行建模并将其从时间序列中移除可能会很有帮助。或者,我们可以用差分使序列平稳以进行建模。如果以后几年的波动有增长趋势,我们甚至可能需要两个层次的差分。

密度图

观察观测值的密度图可以进一步了解数据的结构。下面的代码创建了没有任何时间结构的观测值的直方图和密度图(没有任何时间结构指我们忽略时间序列数据的先后关系,仅看其取值):

In:

# 时间序列数据的密度图
from pandas import read_csv
from matplotlib import pyplot
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
pyplot.figure(1)
pyplot.subplot(211)
series.hist()
pyplot.subplot(212)
series.plot(kind='kde')
pyplot.show()

Out:

密度图
运行代码并观察数据图,我们可以观察到:

  • 不是高斯分布
  • 分布是左移的,可能是指数或双高斯分布

这说明在建模之前探索数据的一些幂变换可能会有不错的效果。

箱线图

我们可以按年份对每月数据进行分组,并了解每年观测值的分布以及其可能如何变化。我们确实希望看到一些趋势(均值或中位数的增大),但看看分布的其余部分可能如何变化也很有趣。下面的代码按年份对观测值进行分组,并为每年的观测值创建一个箱线图。1974 年只包含 10 个月,与其他年份的 12 个月的观测值相比可能没有用。因此,仅绘制了 1966 年至 1973 年之间的数据。

In:

# 时间序列的箱线图
from pandas import read_csv
from pandas import DataFrame
from pandas import Grouper
from matplotlib import pyplot
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
groups = series['1966':'1973'].groupby(Grouper(freq='A'))
years = DataFrame()
for name, group in groups:
    years[name.year] = group.values
years.boxplot()
pyplot.show()

Out:

箱线图
运行程序将创建8个箱线图,依次代表每年的数据,我们可以观察到:

  • 每年的中位数(绿色线)展现的趋势可能不是线性的
  • 中间 50% 的数据(蓝色框)有所不同,随时间推移不一致
  • 前2年与剩下几年的数据集差别很大

这表明,逐年波动可能不是系统性的,也难以建模,同时也暗示了如果确实完全不同,那么从建模中裁剪前两年的数据可能会有一些作用。这种年度数据视图是一个有趣的途径,可以通过查看每年的概括性统计量及其变化来进一步研究。接下来,我们开始建立时间序列预测模型。

ARIMA 模型

在本节中,我们将开发 ARIMA(Autoregressive Integrated Moving Average)模型来解决问题。主要有四步:

  1. 开发手动配置的ARIMA模型;
  2. 使用网格搜索来优化 ARIMA 模型;
  3. 分析预测残差来评估模型中的任何偏差;
  4. 使用幂变换探索对模型的改进。

Manually Configured ARIMA

非季节性 ARIMA(p, d, q) 需要三个参数,传统上是手动配置的。对时间序列数据的分析需要假设其是平稳的。但该时间序列几乎可以肯定是非平稳的。我们可以先通过对序列进行差分使其平稳,然后使用统计检验来确认结果确实是平稳的来达到假设条件。下面的代码创建该时间系列的平稳版本并将其保存到文件 stationary.csv

In:

# 时间序列平稳性的统计检验
from pandas import read_csv
from pandas import Series
from statsmodels.tsa.stattools import adfuller

# 创建差分时间序列
def difference(dataset):
    diff = list()
    for i in range(1, len(dataset)):
        value = dataset[i] - dataset[i-1]
        diff.append(value)
    return Series(diff)

series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
X = series.values
# 差分数据
stationary = difference(X)
stationary.index = series.index[1:]
# 检验是否平稳
result = adfuller(stationary)
print('ADF Statistic: %f' % result[0])
print('p-value: %f' % result[1])
print('Critical Values:')
for key, value in result[4].items():
    print('\t%s: %.3f' % (key, value))
# 保存
stationary.to_csv('stationary.csv', header=False)

Out:

ADF Statistic: -3.980946
p-value: 0.001514
Critical Values:
	1%: -3.503
	5%: -2.893
	10%: -2.584

运行代码输出 ADF检验 的结果—— “1-lag” 差分后的时间序列是否平稳。可以发现检验统计量的值 -3.980946 小于 -3.503,这表明我们可以在显著性水平为 0.01 下拒绝原假设(序列是非平稳的,序列有单位根),即 “1-lag” 差分后的时间序列是平稳的,没有时间相关的结构。

以上可知,ARIMA模型中的参数 d 应至少为 1。下一步是分别为自回归AR和移动平均MA选择参数。我们可以通过观察自相关函数ACF和偏自相关函数PACF的图来启发性地选择。下面的代码为时间序列创建 ACF 和 PACF 图。

In:

# 时间序列的 ACF & PCAF 图
from pandas import read_csv
from statsmodels.graphics.tsaplots import plot_acf
from statsmodels.graphics.tsaplots import plot_pacf
from matplotlib import pyplot
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
pyplot.figure()
pyplot.subplot(211)
plot_acf(series, lags=50, ax=pyplot.gca())
pyplot.subplot(212)
plot_pacf(series, lags=50, ax=pyplot.gca(), method='ywm')
pyplot.tight_layout()  # 调整子图间距,防止标题重叠
pyplot.show()

Out:

ACF&PACF

运行代码并查看绘图,深入了解如何为 ARIMA 模型设置 p 和 q 参数。图中可以看出:

  • ACF 表现出10-11个月的显著滞后。
  • PACF 显示出可能2个月的显著滞后。
  • ACF 和 PACF 在同一点都出现了下降,这可能表明 AR 和 MA 的混合。

对于 p 和 q 来说,11 和 2 应该是不错的初始值。

上面的分析表明,ARIMA(11, 1, 2) 可能是一个很好的起点。但实验表明,ARIMA 的这种配置不会收敛并导致底层库出错,类似的大 AR 值也是如此。一些实验表明,当同时定义了非零 AR 和 MA 阶,该模型似乎并不稳定。该模型可以简化为 ARIMA(0, 1, 2)。下面的示例展示了此 ARIMA 模型在测试工具上的性能。

In:

# 评估手动配置的 ARIMA 模型
from pandas import read_csv
from sklearn.metrics import mean_squared_error
from statsmodels.tsa.arima.model import ARIMA
from math import sqrt
from pandas import Series

# 加载数据
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
# 准备数据
X = series.values
X = X.astype('float32')
train_size = int(len(X) * 0.50)
train, test = X[0:train_size], X[train_size:]
# walk-forward validation
history = [x for x in train]
predictions = list()
for i in range(len(test)):
    # 预测值
    model = ARIMA(history, order=(0,1,2))
    model_fit = model.fit()
    yhat = model_fit.forecast()[0]
    predictions.append(yhat)
    # 实际观测值
    obs = test[i]
    history.append(obs)
    print('>Predicted=%.3f, Expected=%.3f' % (yhat, obs))
# 计算性能
rmse = sqrt(mean_squared_error(test, predictions))
print('RMSE: %.3f' % rmse)

Out:

>Predicted=99.923, Expected=125.000
>Predicted=116.442, Expected=155.000
...
>Predicted=329.367, Expected=460.000
>Predicted=409.007, Expected=364.000
>Predicted=335.283, Expected=487.000
RMSE: 51.115

运行代码,RMSE 为 51.115,低于上面的基线模型。这是一个良好的开端,但我们可以通过配置更好的 ARIMA 模型来改进结果。

网格搜索 ARIMA 超参数

许多 ARIMA 配置在此数据集上不稳定,但可能还有其他超参数可以改进模型性能。在本节中,我们将搜索 p、d、q 的值以查找不会导致错误的组合,并找到导致最佳性能的组合。我们将使用网格搜索来探索整数值子集中的所有组合。具体来说,我们将搜索以下参数的所有组合:

  • p: 0 to 12
  • d: 0 to 3
  • q: 0 to 12

共需运行 13 × 4 × 13 = 676 13 \times 4 \times 13 = 676 13×4×13=676 次。下面列出了测试工具的网格搜索版本的完整工作代码:

In:

# 时间序列 ARMIA 参数的网格搜索
import warnings
from pandas import read_csv
from sklearn.metrics import mean_squared_error
from statsmodels.tsa.arima.model import ARIMA
from math import sqrt

# 根据给定的order(p,d,q)来评估ARIMA模型,返回 RMSE
def evaluate_arima_model(X, arima_order):
    # 准备训练集
    X = X.astype('float32')
    train_size = int(len(X) * 0.50)
    train, test = X[0:train_size], X[train_size:]
    history = [x for x in train]
    # 进行预测
    predictions = list()
    for t in range(len(test)):
        model = ARIMA(history, order=(arima_order))
        model_fit = model.fit()
        yhat = model_fit.forecast()[0]
        predictions.append(yhat)
        history.append(test[t])
    # 计算 RMSE
    rmse = sqrt(mean_squared_error(test, predictions))
    return rmse

# 评估 ARIMA 模型的 (p,d,q) 组合
def evaluate_models(dataset, p_values, d_values, q_values):
    dataset = dataset.astype('float32')
    best_score, best_cfg = float('inf'), None
    for p in p_values:
        for d in d_values:
            for q in q_values:
                order = (p, d, q)
                try:
                    rmse = evaluate_arima_model(dataset, order)
                    if rmse < best_score:
                        best_score, best_cfg = rmse, order
                    print('ARIMA%s RMSE=%.3f' % (order, rmse))
                except:
                    continue
    print('Best ARIMA%s RMSE=%.3f' % (best_cfg, best_score))

# 加载数据集
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
# 评估参数
p_values = range(0, 13)
d_values = range(0, 4)
q_values = range(0, 13)
warnings.filterwarnings('ignore')
evaluate_models(series.values, p_values, d_values, q_values)

Out:

ARIMA(0, 0, 0) RMSE=154.962
ARIMA(0, 0, 1) RMSE=99.354
ARIMA(0, 0, 2) RMSE=92.071
ARIMA(0, 0, 3) RMSE=72.271
ARIMA(0, 0, 4) RMSE=72.136
ARIMA(0, 1, 0) RMSE=51.844
ARIMA(0, 1, 1) RMSE=50.717
ARIMA(0, 1, 2) RMSE=51.115
ARIMA(0, 1, 3) RMSE=52.058
...

运行该程序将遍历所有组合,并报告收敛组合的结果,而不会出错。结果表明,发现的最佳配置为ARIMA(5, 3, 8)。

查看残差

模型的最终检查是查看残余预测误差。理想情况下,残差的分布应该是均值为零的高斯分布。我们可以通过绘制残差的直方图和密度图来检查这一点。下面的示例计算测试集预测的残差并绘制密度图。

In:

# 绘制 ARIMA 模型的残差密度图
import warnings
warnings.filterwarnings('ignore')
from pandas import read_csv
from pandas import DataFrame
from statsmodels.tsa.arima.model import ARIMA
from matplotlib import pyplot

# 加载数据
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
# 准备数据
X = series.values
X = X.astype('float32')
train_size = int(len(X) * 0.50)
train, test = X[0:train_size], X[train_size:]
# walk-forward validation
history = [x for x in train]
predictions = list()
for i in range(len(test)):
    # 预测
    model = ARIMA(history, order=(5,3,8))
    model_fit = model.fit()
    yhat = model_fit.forecast()[0]
    predictions.append(yhat)
    # 观测值
    obs = test[i]
    history.append(obs)
# errors
residuals = [test[i] - predictions[i] for i in range(len(test))]
residuals = DataFrame(residuals)
pyplot.figure()
pyplot.subplot(211)
residuals.hist(ax = pyplot.gca())
pyplot.subplot(212)
residuals.plot(kind='kde', ax=pyplot.gca())
pyplot.show()

Out:

密度图
运行代码将绘制两张图,它们表现为具有较长右尾的高斯分布。这可能是预测有偏差的迹象,在这种情况下,在建模之前对原始数据进行基于幂的转换可能是有用的。

我们还可以检查残差的时间序列看看有没有任何类型的自相关。如果有,则表明模型有更多机会对数据中的时间结构进行建模。下面的代码重新计算残差并创建 ACF 和 PACF 图以检查是否有任何显著的自相关:

In:

# 预测残差的 ACF 和 PACF 图
import warnings
warnings.filterwarnings('ignore')
from pandas import read_csv
from pandas import DataFrame
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.graphics.tsaplots import plot_acf
from statsmodels.graphics.tsaplots import plot_pacf
from matplotlib import pyplot

# 加载数据
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
# 准备数据
X = series.values
X = X.astype('float32')
train_size = int(len(X) * 0.50)
train, test = X[0:train_size], X[train_size:]
# walk-forward validation
history = [x for x in train]
predictions = list()
for i in range(len(test)):
    # 预测
    model = ARIMA(history, order=(5,3,8))
    model_fit = model.fit()
    yhat = model_fit.forecast()[0]
    predictions.append(yhat)
    # 观测值
    obs = test[i]
    history.append(obs)
# errors
residuals = [test[i] - predictions[i] for i in range(len(test))]
residuals = DataFrame(residuals)
pyplot.figure()
pyplot.subplot(211)
plot_acf(residuals, lags=25, ax=pyplot.gca())
pyplot.subplot(212)
plot_pacf(residuals, lags=25, ax=pyplot.gca())
pyplot.tight_layout()
pyplot.show()

Out:

ACF&PACF
结果表明,残差序列几乎没有自相关。

Box-Cox 变换

Box-Cox 变换是一种能够评估一套幂变换的方法,包括但不限于对数、平方根和倒数变换。下面的代码进行数据的对数转换,并生成一些绘图来查看对时间序列的影响。

In:

# 绘制 Box-Cox 变换后的数据集
from pandas import read_csv
from scipy.stats import boxcox
from matplotlib import pyplot
from statsmodels.graphics.gofplots import qqplot

series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
X = series.values
transformed, lam = boxcox(X)
print('Lambda: %f' % lam)
pyplot.figure(1)
# 折线图
pyplot.subplot(311)
pyplot.plot(transformed)
# 直方图
pyplot.subplot(312)
pyplot.hist(transformed)
# Q-Q 图
pyplot.subplot(313)
qqplot(transformed, line='r', ax=pyplot.gca())
pyplot.tight_layout()
pyplot.show()

Out:

Lambda: 0.260060

请添加图片描述
运行程序将创建三张图:转换后的时间序列的折线图、显示转换值分布的直方图,以及显示值分布与理想高斯分布相比情况的 Q-Q 图。从图中可以观察到:

  • 较大的波动已从时间系列的折现图中移除。
  • 直方图显示出更平坦、更均匀的值分布。
  • Q-Q 图是合理的,但仍然不是高斯分布的完美拟合。

毫无疑问,Box-Cox 变换对时间序列做了一些很有用的事情。在继续使用转换后的数据测试 ARIMA 模型之前,我们必须有一种逆转换的方法,以便将使用在转换后的数据上训练的模型所做的预测转换回原始尺度。程序中使用的 boxcox() 函数通过优化代价函数来寻找理想的 λ \lambda λ 值。
Box-Cox 变换公式如下:
y = { log ⁡ ( x ) , λ = 0 x λ − 1 λ , λ ≠ 0 y = \begin{cases} \log(x) & ,\lambda=0 \\ \frac{x^{\lambda}-1}{\lambda} & ,\lambda \neq 0 \end{cases} y={log(x)λxλ1,λ=0,λ=0

故逆变换公式:
x = { e y , λ = 0 e log ⁡ ( λ y + 1 ) λ , λ ≠ 0 x = \begin{cases} e^{y} & ,\lambda=0 \\ e^{\frac{\log{(\lambda y + 1)}}{\lambda}} & ,\lambda \neq 0 \end{cases} x={eyeλlog(λy+1),λ=0,λ=0

逆 Box-Cox 变换函数的Python实现如下:

# 逆 Box-Cox 变换
from math import log
from math import exp
def boxcox_inverse(value, lam):
    if lam == 0:
        return exp(value)
    return exp(log(lam*value + 1) / lam)

我们将使用 Box-Cox 变换后的数据重新评估 ARIMA(5, 3, 8)。这包括在拟合 ARIMA 模型之前首先转换先前历史数据,然后在存储预测值之前逆转换以供以后与预期值进行比较。boxcox() 函数可能会失败:在实践中,它似乎是由返回的 λ \lambda λ 值小于 -5 发出的信号。按照惯例, λ \lambda λ 值的计算值介于 -5 和 5 之间。
首先会检查 λ \lambda λ 是否小于 -5,如果小于 -5,则假定 λ \lambda λ 值为 1,并使用原始历史记录来拟合模型,因为 λ \lambda λ 值 1 相当于无变换。下面列出了完整的代码:

In:

# 使用 Box-Cox 转换后的时间序列评估 ARIMA 模型
# 预测残差的 ACF 和 PACF 图
import warnings
warnings.filterwarnings('ignore')
from pandas import read_csv
from sklearn.metrics import mean_squared_error
from statsmodels.tsa.arima.model import ARIMA
from math import sqrt
from math import log
from math import exp
from scipy.stats import boxcox

# 逆 Box-Cox 变换
def boxcox_inverse(value, lam):
    if lam == 0:
        return exp(value)
    return exp(log(lam*value + 1) / lam)

# 加载数据
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
# 准备数据
X = series.values
X = X.astype('float32')
train_size = int(len(X) * 0.50)
train, test = X[0:train_size], X[train_size:]
# walk-forward validation
history = [x for x in train]
predictions = list()
for i in range(len(test)):
    # Box-Cox 变换
    transformed, lam = boxcox(history)
    if lam < -5:
        transformed, lam = history, 1
    # 预测
    model = ARIMA(transformed, order=(5, 3, 8))
    model_fit = model.fit()
    yhat = model_fit.forecast()[0]
    # 逆转换预测值
    yhat = boxcox_inverse(yhat, lam)
    predictions.append(yhat)
    # 观测值
    obs = test[i]
    history.append(obs)
    print('>Predicted=%.3f, Expected=%.3f' % (yhat, obs))

# 计算 RMSE
rmse = sqrt(mean_squared_error(test, predictions))
print('RMSE: %.3f' % rmse)

Out:

>Predicted=92.319, Expected=125.000
>Predicted=126.906, Expected=155.000
...
>Predicted=312.469, Expected=460.000
>Predicted=419.800, Expected=364.000
>Predicted=372.240, Expected=487.000
RMSE: 54.285

然而尴尬的是,最终 RMSE 为 54.285,并没有变换之前的好。我们还是选择未变换数据 ARIMA(5, 3, 8) 作为最终模型。

模型验证

在开发和选择最终模型后,在开发模型并选择最终模型后,必须对其进行验证和最终确定。验证是一个可选部分,但它提供了最后的检查,以确保我们没有欺骗自己。本节有以下几步:

  • 最终确定模型: 训练并保存最终模型
  • 进行预测: 加载最终模型进行预测
  • 评估模型: 加载并评估最终模型

保存模型

最终确定模型(Finalize Model)涉及在完整数据集上拟合 ARIMA 模型,在本例中,在完整数据集的转换版本上拟合。拟合后,可以将模型保存到文件中供以后使用。由于也对数据进行了 Box-Cox 变换,因此我们需要知道所选的 lambda,以便模型所做的任何预测都可以转换回原始的未变换比例。下面的示例在 Box-Cox 变换后数据集上拟合 ARIMA(0,1,2) 模型,并将整个拟合对象和 lambda 值保存到文件中。

In:

from pandas import read_csv
from statsmodels.tsa.arima.model import ARIMA

# 加载数据
series = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
# 准备数据
X = series.values
X = X.astype('float32')
# 拟合模型
model = ARIMA(X, order=(5, 3, 8))
model_fit = model.fit()
# 保存模型
model_fit.save('model.pkl')

运行程序会创建一个本地文件:

  • model.pky: 这是调用 ARIMA.fit() 返回的 ARIMAResult 对象。这包括拟合模型时返回的系数和所有其他内部数据。

我们真正需要的是模型中的 AR 和 MA 系数、差分的 d 参数、滞后观测值和模型残差。

进行预测

一般是加载模型并进行单步预测,这相对简单,包括恢复保存的模型并调用 forecast() 函数。下面的代码将加载模型,对下一个时间步进行预测,然后打印预测值。

In:

# 加载模型,进行预测
from statsmodels.tsa.arima.model import ARIMAResults

model_fit = ARIMAResults.load('model.pkl')
yhat = model_fit.forecast()[0]
print('Predicted: %.3f' % yhat)

Out:

Predicted: 486.600

运行程序,预测值为 486.600,如果我们查看 validation.csv 内容,可以看到下一个时间段的第一行上的值为 452,模型不是很准确。

验证模型

在“测试工具”节中,我们将原始数据集的最后 12 个月保存在一个单独的文件中,以验证最终模型。我们现在可以加载这个 validation.csv 文件,并使用它来看看我们的模型在未知数据上的表现情况,.我们可以通过两种方式实现:

  • 加载模型并使用它来预测未来 12 个月。但预测前一两个月后,表现将会下降。
    -加载模型并以滚动预测(rolling-forecast)方式使用它,每过一个时间步长便更新模型。这是首选方法,实践中便如此使用此模型,它可以实现最佳性能。

与前面节中的模型评估一样,我们将以滚动预测的方式进行预测,将新的观测值作为历史记录来更新模型。

In:

# 在验证集上评估最终模型
from pandas import read_csv
from matplotlib import pyplot
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.arima.model import ARIMAResults
from sklearn.metrics import mean_squared_error
from math import sqrt

# 加载并准备数据
dataset = read_csv('dataset.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
X = dataset.values.astype('float32')
history = [x for x in X]
validation = read_csv('validation.csv', header=None, index_col=0, parse_dates=True).squeeze('columns')
y = validation.values.astype('float32')
# 加载模型
model_fit = ARIMAResults.load('model.pkl')
# 进行第一次预测
predictions = list()
yhat = model_fit.forecast()[0]
predictions.append(yhat)
history.append(y[0])
print('>Predicted=%.3f, Expected=%.3f' % (yhat, y[0]))
# 滚动预测
for i in range(1, len(y)):
    # 预测值
    model = ARIMA(history, order=(5, 3, 8))
    model_fit = model.fit()
    yhat = model_fit.forecast()[0]
    predictions.append(yhat)
    # 观测值
    obs = y[i]
    history.append(obs)
    print('>Predicted=%.3f, Expected=%.3f' % (yhat, obs))

# 计算 RMSE
rmse = sqrt(mean_squared_error(y, predictions))
print('RMSE: %.3f' % rmse)
pyplot.plot(y)
pyplot.plot(predictions, color='red')
pyplot.show()

Out:

>Predicted=486.600, Expected=452.000
>Predicted=457.122, Expected=391.000
>Predicted=414.261, Expected=500.000
>Predicted=465.520, Expected=451.000
>Predicted=487.513, Expected=375.000
>Predicted=428.620, Expected=372.000
>Predicted=364.956, Expected=302.000
>Predicted=355.342, Expected=316.000
>Predicted=355.303, Expected=398.000
>Predicted=320.682, Expected=394.000
>Predicted=431.841, Expected=431.000
>Predicted=450.471, Expected=431.000
RMSE: 59.221

请添加图片描述
运行程序将打印验证集中每个时间步的预测值和实际值。最终验证集的 RMSE 约为 59。
我们还画了预测值与实际值相比的预测图。预测的结果具有“惯性”,可见尽管这个时间序列具有明显的趋势,但想要准确预测仍然相当困难。

扩展

项目并不详尽,可以做更多的事情来改进结果,比如:

  • 统计显著性检验:使用统计检验来检查不同模型之间的结果差异在统计意义上是否显著,t检验将是一个很好的起点;
  • 使用数据变换进行网格搜索:使用 Box-Cox 变换在 ARIMA 超参数中重复网格搜索,看看是否可以实现一组不同的、更好的参数;
  • 检查残差:使用 Box-Cox 变换研究最终模型上的预测残差,以查看是否存在可以解决的偏差和自相关;
  • 简化模型保存:仅存储所需的系数,而不是整个 ARIMAResults 对象;
  • 手动处理趋势:使用线性或非线性模型直接对趋势进行建模,并将其从序列中显式地移除。如果趋势是非线性的,并且可以比线性情况更好地建模,则这可能会带来更好的性能;
  • 置信区间:显示验证集上预测的置信区间;
  • 数据选择:考虑在没有前两年数据的情况下对问题进行建模,看看这是否对预测能力有影响。

总结

我们可以学习到:

  • 如何使用性能度量和评估方法开发测试工具,以及如何快速开发基线预测模型;
  • 如何通过分析时间序列得到启发来对预测问题进行建模;
  • 如何开发、保存并加载 ARIMA 模型,以对新数据进行预测。

参考

Introduction — statsmodels

Monthly Armed Robberies in Boston | Kaggle

Time Series Forecast Case Study with Python: Monthly Armed Robberies in Boston (machinelearningmastery.com)

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Cloudy_Nebula

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值