时间序列预测的特征工程

在上一章中,我们开始将机器学习ML)作为解决时间序列预测问题的工具。我们还讨论了几种技术,例如时间延迟嵌入时间嵌入,它们将时间序列预测问题转化为 ML 范式中的经典回归问题。在本章中,我们将详细研究这些技术,并利用本书始终使用的数据集进行实践。

在本章中,我们将涵盖以下主题:

技术要求

您需要按照本书前言中的说明设置Anaconda环境,以获得包含本书代码所需所有库和数据集的运行环境。任何额外需要的库将在运行笔记本时安装。

在使用本章代码之前,你需要运行以下notebooks:

本章代码可在 https://github.com/PacktPublishing/Modern-Time-Series-Forecasting-with-Python-2E/tree/main/notebooks/Chapter06 找到。

理解特征工程

特征工程,顾名思义,是从数据中设计特征的过程,通常利用领域知识,使学习过程更顺畅、更高效。在典型的机器学习场景中,设计好的特征对于任何机器学习模型获得良好性能至关重要。特征工程是机器学习中非常主观的部分,每个具体问题都有不同的解决路径——针对该问题量身定制。假设你有一个房价数据集,其中包含特征建造年份,它告诉你房屋的建造年份。现在,为了改善信息,我们可以从建造年份特征创建另一个特征房龄。这可能会给模型提供更好的信息,这就是特征工程。

当我们将时间序列问题转化为回归问题时,可以应用一些标准技术。这是过程中的关键步骤,因为机器学习模型对时间的理解程度取决于我们如何设计特征来捕捉时间。我们在第4章《设定稳健基线预测》中介绍的基线方法是专门为时间序列预测场景创建的,因此时间维度已内置于这些模型中。例如,ARIMA 不需要任何特征工程来理解时间,因为时间信息已内置于模型中。然而,标准回归模型没有显式的时间理解能力,因此我们需要创建良好的特征来嵌入问题的时间维度。

在上一章(第5章时间序列预测作为回归)中,我们讨论了在回归框架中编码时间的两种主要方式:时间延迟嵌入时间嵌入。尽管我们在较高层次上涉及了这些概念,但现在是时候深入探讨并观察它们的实际应用了。

笔记本提示

要跟随完整代码,请使用Chapter06文件夹中的01-Feature_Engineering.ipynb笔记本。

我们已经将正在处理的数据集拆分为训练集、验证集和测试集。然而,由于我们正在生成基于先前观测值的特征,操作上,最好将训练集、验证集和测试集合并在一起。稍后会清楚为什么,但现在我们先相信这一点并继续前进。现在,让我们合并这两个数据集:

# Reading the missing value imputed and train test split data
train_df = pd.read_parquet(preprocessed / "selected_blocks_train_missing_imputed.parquet")
val_df = pd.read_parquet(preprocessed / "selected_blocks_val_missing_imputed.parquet")
test_df = pd.read_parquet(preprocessed / "selected_blocks_test_missing_imputed.parquet")
#Adding train, validation and test tags to distinguish them before combining
train_df['type'] = "train"
val_df['type'] = "val"
test_df['type'] = "test"
full_df = pd.concat([train_df, val_df, test_df]).sort_values(["LCLid", "timestamp"])
del train_df, test_df, val_df

现在,我们有了一个full_df,它合并了训练集、验证集和测试集。你们中有些人可能已经在脑海中敲响了警钟:合并训练集和测试集?那数据泄露怎么办?让我们来检查一下。

避免数据泄露

数据泄露发生在模型训练时使用了在预测时不可用的信息。通常,这会导致训练集性能很高,但在未见数据上表现非常差。数据泄露有两种类型:

在处理时间序列预测问题时,我们能犯的最大且最常见的错误就是目标泄露。我们必须仔细思考每个特征,确保不使用预测时不可用的数据。下图将帮助我们记住并内化这个概念:

图6.1 – 可用的和不可用的信息以避免数据泄露

图6.1:可用和不可用的信息以避免数据泄露

为了让这个概念更清晰且与时间序列预测背景更相关,我们来看一个例子。假设我们要预测洗发水的销售额,并使用护发素的销售额作为特征。我们开发了模型,在训练数据上进行了训练,并在验证数据上进行了测试。模型表现很好。但当我们开始预测未来时,问题就出现了:我们也不知道未来护发素的销售额。虽然这个例子很直观,但有时问题并不那么明显。这就是为什么我们在创建特征时需要非常谨慎,并始终以该特征在预测时是否可用的视角来评估特征。

最佳实践

除了仔细思考特征之外,还有多种识别目标泄露的方法:

尽管我们在本书前面生成了预测,但从未明确讨论过预测范围。这是一个重要概念,对我们后续讨论至关重要。让我们花点时间来理解预测范围。

设定预测范围

预测范围是指在任意时间点上我们想要预测的未来时间步数。例如,如果我们想对之前使用的电力消耗数据集预测未来24小时,那么预测范围就是48(因为数据是每半小时记录一次)。在第5章《时间序列预测作为回归》中,我们生成基线时一次性预测了整个测试集。在这种情况下,预测范围等于测试集的长度。

直到现在,我们都不必担心这个问题,因为在经典统计预测方法中,这一决策与建模是解耦的。如果我们训练了一个模型,就可以用它预测任何未来时间点而无需重新训练。但在时间序列预测作为回归中,预测范围受到约束,其根源在于数据泄露。你现在可能对此还不清楚,所以在学习特征工程技术之后我们再来讨论这一点。现在,我们只关注单步预测。就我们使用的数据集而言,这意味着我们将回答这个问题:接下来半小时的能耗是多少?我们将在第4部分《预测的机制》中讨论多步预测和其他预测机制。

现在我们已经设定了一些基本规则,让我们开始研究不同的特征工程技术。要跟随Jupyter notebook进行操作,请进入Chapter06文件夹并使用01-Feature_Engineering.ipynb文件。

时间延迟嵌入

时间延迟嵌入的基本思想是根据最近的观测来嵌入时间。嵌入时间的方法是利用最近的观测。在第5章时间序列预测作为回归》中,我们讨论了将时间序列的先前观测作为滞后图5.6,位于子节时间延迟嵌入下)。

然而,使用这个概念还有更多捕获近期和季节性信息的方法。

让我们来看一下。

滞后或后移

假设我们有一个时间序列,时间步长为YL。考虑我们此刻位于时间T,并且该时间序列的历史长度是L。因此,我们的时间序列中,yT是最新的观测值,然后随着时间回溯,依次是yT-1yT-2等。如第5章时间序列预测作为回归》所述,滞后是指包含时间序列先前观测值的特征,如下图所示:

图6.2 – 滞后特征

图6.2:滞后特征

我们可以通过包含时间步a之前的观测值(yT-a)来创建多个滞后;我们称之为滞后 a。在上图中,我们展示了滞后1滞后2滞后3。然而,我们可以添加任意数量的滞后。现在让我们在代码中学习如何操作:

df["lag_1"]=df["column"].shift(1)

还记得我们将训练集和测试集合并,并请求你暂且相信我吗?现在是时候回报这份信任了。如果我们考虑滞后操作(或任何自回归特征),它依赖于沿时间轴的连续表示。如果我们考虑测试集,对于前几行(或最早的日期),滞后值会缺失,因为它们属于训练集。因此,通过合并两者,我们创建了沿时间轴的连续表示,从而可以利用 pandas 中的标准函数(如 shift)轻松高效地创建这些特征。

就是这么简单,但我们需要对每个 LCLid 分别进行滞后操作。我们在 src.feature_engineering.autoregressive_features 中包含了一个有用的方法 add_lags,它可以快速高效地为每个 LCLid 添加所有需要的滞后值。让我们看看如何使用它。

我们将导入该方法,并使用其几个参数来按我们想要的方式配置滞后操作:

from src.feature_engineering.autoregressive_features import add_lags
# Creating first 5 lags and then same 5 lags but from previous day and previous week to capture seasonality
lags = (
    (np.arange(5) + 1).tolist()
    + (np.arange(5) + 46).tolist()
    + (np.arange(5) + (48 * 7) - 2).tolist()
)
full_df, added_features = add_lags(
    full_df, lags=lags, column="energy_consumption", ts_id="LCLid", use_32_bit=True
)

现在,让我们看看前面代码片段中使用的参数:

该方法返回添加了滞后列的 DataFrame,以及一个包含新添加特征列名的列表。

滚动窗口聚合

通过滞后,我们将当前点连接到过去的单个点,而通过滚动窗口特征,我们将当前点连接到过去一个窗口的聚合统计量。我们不再看之前时间步的观测值,而是看过去三个时间步观测值的平均值。请看下图以更好地理解:

图6.3 – 滚动窗口聚合特征

图6.3:滚动窗口聚合特征

我们可以用不同窗口计算滚动统计量,每个窗口会捕捉历史中的细微差异。在上图中,我们看到了窗口大小为3和窗口大小为4的示例。当我们在时间步T时,窗口大小为3的滚动窗口会将yT – 3yT – 2yT – 1作为过去观测向量。一旦得到这些,我们可以应用任意聚合函数,例如均值、标准差、最小值、最大值等。得到聚合后的标量值,我们就可以将其作为时间步t的一个特征。

我们不包含yT在过去观测向量中,因为这会导致数据泄露。

让我们看看如何用pandas实现这一点:

# We shift by one to make sure there is no data leakage
df["rolling_3_mean"] = df["column"].shift(1).rolling(3).mean()

与滞后特征类似,我们需要对每个LCLid列单独进行此操作。我们在src.feature_engineering.autoregressive_features中包含了一个有用的方法,名为add_rolling_features,它可以快速高效地为每个LCLid添加所有所需的滚动特征。让我们看看如何使用它。

我们将导入该方法,并使用几个参数按我们想要的方式配置滚动操作:

from src.feature_engineering.autoregressive_features import add_rolling_features
full_df, added_features = add_rolling_features(
    full_df,
    rolls=[3, 6, 12, 48],
    column="energy_consumption",
    agg_funcs=["mean", "std"],
    ts_id="LCLid",
    use_32_bit=True,
)

现在,让我们看看前面代码片段中使用的参数:

该方法返回添加了滚动特征的DataFrame,以及一个新特征列名的列表。

季节性滚动窗口聚合

季节性滚动窗口聚合与滚动窗口聚合非常相似,但窗口不是取过去连续n个观测值,而是取季节性窗口,在窗口内每个元素之间跳过恒定数量的时间步。下图将使这一点更清晰:

图6.4 – 季节性滚动窗口聚合

图6.4:季节性滚动窗口聚合

这里的关键参数是季节性周期,通常记为M。这是期望季节性模式重复的时间步数。当我们在时间步T时,一个大小为3的滚动窗口会有yT – 3yT – 2yT – 1作为过去观测向量。但季节性滚动窗口会在窗口内每个元素之间跳过m个时间步。这意味着季节性滚动窗口中的观测值会是yTMyT – 2MyT – 3M。同样,通常一旦我们有了窗口向量,只需应用聚合函数得到一个标量值,并将其作为一个特征。

我们不会将yT作为季节性滚动窗口向量中的一个元素,以避免数据泄露。

这不是一个可以用pandas轻松高效完成的操作。一些巧妙的NumPy索引和Python循环应该可以解决问题。我们将使用来自github.com/jmoralez/window_ops/的一个实现,它利用NumPy和Numba使操作快速高效。

就像我们之前看到的特征一样,我们需要对每个LCLid分别执行此操作。我们在src.feature_engineering.autoregressive_features中提供了一个有用的方法,称为add_seasonal_rolling_features,它可以快速高效地为每个LCLid添加所有所需的季节性滚动特征。让我们看看如何使用它。

我们将导入该方法,并使用其中的一些参数来配置我们想要的季节性滚动操作:

from src.feature_engineering.autoregressive_features import add_seasonal_rolling_features
full_df, added_features = add_seasonal_rolling_features(
    full_df,
    rolls=[3],
    seasonal_periods=[48, 48 * 7],
    column="energy_consumption",
    agg_funcs=["mean", "std"],
    ts_id="LCLid",
    use_32_bit=True,
)

现在,让我们看一下之前代码片段中使用的参数:

与往常一样,该方法返回带有季节性滚动特征的DataFrame以及包含新添加特征列名称的列表。

指数加权移动平均(EWMA)

对于滚动窗口均值操作,我们计算了窗口的平均值,它与 移动平均 同义。EWMA 是移动平均的稍智能版本。移动平均考虑一个滚动窗口,并认为窗口中的每个项目对计算的平均值同等重要,而EWMA尝试对窗口进行加权平均,并且权重以指数速率衰减。有一个参数 $\alpha$,决定了权重衰减的速度。因此,我们可以将所有可用的历史数据视为一个窗口,并让 $\alpha$ 参数决定EWMA中包含多少近期数据。这可以简单递归地写成如下形式:

$$EWMA_T = \alpha \times y_T + (1 - \alpha) \times EWMA_{T-1}$$

这里,我们可以看到 $\alpha$ 的值越大,平均值越偏向近期值(参见 图6.6 以直观了解权重分布)。如果展开递归,每个项的权重计算如下:

$$W_{T-k} = \alpha \times (1 - \alpha)^k$$

其中 k 是时间步数,是 T 之前的时间步数。如果我们绘制权重,我们可以看到它们呈指数衰减;$\alpha$ 决定了衰减的速度。另一种理解 $\alpha$ 的方式是从 跨度 的角度。跨度是衰减权重趋近于零的周期数(并非严格数学意义,而是直观上)。$\alpha$ 和跨度通过以下方程关联:

$$\alpha = \frac{2}{1 + span}$$

下图将使这一点更加清晰,其中我们绘制了不同 $\alpha$ 值下权重的衰减情况:

图6.5 – 不同 $\alpha$ 值下的指数权重衰减

图6.5:不同 $\alpha$ 值下的指数权重衰减

这里我们可以看到,当到达跨度时,权重变得很小。

直观上,我们可以将EWMA视为时间序列整个历史的平均值,但通过诸如 $\alpha$ 和 跨度 这样的参数,我们可以使历史的不同时期更具平均代表性。如果我们定义一个60周期跨度,那么可以认为过去60个时间段是驱动平均值的主要因素。因此,使用不同跨度或 $\alpha$ 值生成EWMA,可以为我们提供代表不同历史时期的特征。

整个过程如下所示:

图6.6 – EWMA特征

图6.6:EWMA特征

现在,让我们看看如何在pandas中实现这一点:

df["ewma"]=df['column'].shift(1).ewm(alpha=0.5).mean()

与之前讨论的其他特征类似,EWMA 也需要针对每个 LCLid 分别计算。我们在 src.feature_engineering.autoregressive_features 中提供了一个有用的方法 add_ewma,它可以快速高效地为每个 LCLid 添加所有所需的 EWMA 特征。让我们看看如何使用它。

我们将导入该方法,并使用其中的一些参数来按我们想要的方式配置 EWMA:

from src.feature_engineering.autoregressive_features import add_ewma
full_df, added_features = add_ewma(
    full_df,
    spans=[48 * 60, 48 * 7, 48],
    column="energy_consumption",
    ts_id="LCLid",
    use_32_bit=True,
)

现在,让我们看看前面代码片段中使用的参数:

与往常一样,该方法返回包含 EWMA 特征的 DataFrame,以及一个包含新添加特征列名的列表。

这是在 ML 模型中包含时间延迟嵌入的几种标准方法,但您并不局限于这些。与往常一样,特征工程是一个不受规则约束的空间,我们可以尽可能发挥创造力,将领域知识注入模型。除了我们看到的特征之外,我们还可以将滞后差作为自定义滞后引入,从而注入领域知识,等等。在大多数实际案例中,我们最终会在模型中使用不止一种时间延迟嵌入方法。滞后特征在大多数情况下是最基本和最重要的,但我们确实还会使用季节性滞后、滚动特征等来编码更多信息。与 ML 中的一切一样,没有银弹。每个数据集都有其自身的复杂性,这使得特征工程在每个案例中都非常重要且各不相同。

现在,让我们看看可以通过 时间嵌入 添加的另一类特征。

时间嵌入

第5章时间序列预测作为回归中,我们简要讨论了时间嵌入到ML模型可以利用的特征中。如果我们思考一下时间片刻,我们可以看到时间的两个方面在时间序列预测的背景下对我们很重要——时间的流逝时间的周期性

我们可以添加一些特征来帮助我们在ML模型中捕捉这些方面:

让我们逐一查看它们。

日历特征

我们可以提取的第一组特征是基于日历的特征。尽管时间序列的严格定义是按时间顺序取的一系列观测值,但很多时候,我们除了时间序列之外还会拥有这些收集到的观测值的时间戳。我们可以利用这些时间戳来提取日历特征,例如月份、季度、一年中的第几天、小时、分钟等。这些特征捕捉了时间的周期性,有助于ML模型很好地捕捉季节性。只有时间上高于时间序列频率的日历特征才有意义。例如,在周频率的时间序列中,小时特征没有意义,但月份特征和周特征有意义。我们可以利用pandas内置的datetime功能来创建这些特征,并将它们视为模型中的分类特征。

经过时间

这是另一个在ML模型中捕捉时间流逝的特征。该特征随着时间增加而单调递增,让ML模型感受到时间的流逝。创建这个特征有很多方法,但最简单和最高效的方法之一是使用NumPy中日期的整数表示:

df['time_elapsed'] = df['timestamp'].values.astype(np.int64)/(10**9)

我们在src.feature_engineering.temporal_features中包含了一个名为add_temporal_features的有用方法,它可以自动添加所有相关的时间特征。让我们看看如何使用它。

我们将导入该方法,并使用该方法的一些参数来配置和创建时间特征:

full_df, added_features = add_temporal_features(
    full_df,
    field_name="timestamp",
    frequency="30min",
    add_elapsed=True,
    drop=False,
    use_32_bit=True,
)

现在,让我们看看前面代码片段中使用的参数:

就像我们讨论过的先前方法一样,该方法也返回添加了时间特征的新 DataFrame 以及包含新添加特征列名的列表。

傅里叶项

此前,我们提取了一些日历特征,如月份、年份等,并且我们讨论了将它们作为分类变量用于机器学习模型。另一种表示相同信息但采用连续尺度的方法是使用傅里叶项。我们在第3章分析与可视化时间序列数据》中讨论了傅里叶级数。重申一下,傅里叶级数的正弦-余弦形式如下:

$$S_{N(x)} = \frac{a_0}{2} + \sum_{n=1}^{N} \left( a_n \cdot \cos\left(\frac{2\pi \cdot n \cdot x}{P}\right) + b_n \cdot \sin\left(\frac{2\pi \cdot n \cdot x}{P}\right) \right)$$

这里,SN 是信号 SN 项近似。理论上,当 N 趋于无穷时,所得近似等于原始信号。P 是周期的最大长度,anbn 分别是展开式中第 nth 项的余弦和正弦系数,而 a0 是截距。

我们可以创建这些余弦和正弦函数作为特征来表示季节性周期。如果我们对月份进行编码,我们知道它从1到12然后重复。因此,在这种情况下,P 将是12,而 x 将是1,2,…12。因此,对于每个 x,我们可以计算余弦和正弦项,并将它们作为特征添加到机器学习模型中。直观地,我们可以认为模型将根据数据推断系数,从而帮助模型更容易地预测时间序列。

以下图表显示了月份在序数尺度上的表示与傅里叶级数表示之间的差异:

图6.7 – 月份作为序数阶跃函数(上)与傅里叶项(下)

图6.7:月份作为序数阶跃函数(上)与傅里叶项(下)

上图仅显示了一个傅里叶项;我们可以添加多个傅里叶项来帮助捕捉复杂的季节性。

我们不能说季节性的连续表示优于分类表示,因为这取决于你使用的模型类型和数据集。这需要我们通过实验来确定。

为简化添加傅里叶特征的过程,我们在src.feature_engineering.temporal_features中提供了一些易于使用的方法,位于名为bulk_add_fourier_features的文件中,该文件会自动为我们希望的所有日历特征添加傅里叶特征。让我们看看如何使用它。

我们将导入该方法,并使用其几个参数来配置和创建基于傅里叶级数的特征:

full_df, added_features = bulk_add_fourier_features(
    full_df,
    ["timestamp_Month", "timestamp_Hour", "timestamp_Minute"],
    max_values=[12, 24, 60],
    n_fourier_terms=5,
    use_32_bit=True,
)

现在,让我们看看前面代码片段中使用的参数:

就像我们讨论过的方法一样,这也会返回一个新的DataFrame,其中添加了傅里叶特征,以及一个包含新增特征列名的列表。

执行01-Feature_Engineering.ipynb笔记本中的Chapter06后,我们将得到以下特征工程化文件写入磁盘:

在本节中,我们介绍了几种流行且有效的生成时间序列特征的方法。但还有许多其他方法,根据您的问题和领域,其中许多方法都会适用。

附加信息

特征工程的世界非常广阔,有几个开源库可以更轻松地探索这个领域。其中一些包括 https://github.com/Nixtla/tsfeatureshttps://tsfresh.readthedocs.io/en/latest/https://github.com/DynamicsAndNeuralSystems/catch22。Ben D. Fulcher 的一篇名为 Feature-based time-series analysis 的预印本(位于 https://arxiv.org/abs/1709.08055)也对这个领域进行了很好的总结。

一个名为 functime 的新库(https://github.com/functime-org/functime)也提供了快速的特性工程例程,使用 Polars 编写,值得一试。书中讨论的许多特征工程都可以通过 functime 和 Polars 更快地实现。

小结

在上一章简要概述了时间序列预测的机器学习范式之后,本章我们从实践角度出发,看到了如何准备具有所需特征的数据集以开始使用这些模型。我们回顾了几种时间序列特定的特征工程技术,如滞后、滑动窗口和季节性特征。我们在本章学到的所有技术都是工具,可以用来快速迭代实验,找出哪些方法对我们的数据集有效。然而,我们只讨论了影响标准回归方程一侧(y = mX + c)的特征工程。我们预测的目标(y)这一侧同样重要。在下一章,我们将讨论一些概念,如平稳性以及影响目标的一些变换。