时间序列的常见建模模式

在上一章中,我们回顾了深度学习(DL)系统中一些主要且常见的构建模块,这些模块特别适用于时间序列。既然我们已经了解了这些模块,现在是进行更实际课程的时候了。让我们看看如何将这些常见模块以不同的方式组合起来,用于时间序列预测的建模,使用的数据集贯穿全书。

本章涵盖以下主要主题:

技术要求

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

你需要为本章运行以下notebooks:

本章的相关代码可以在 https://github.com/PacktPublishing/Modern-Time-Series-Forecasting-with-Python-/tree/main/notebooks/Chapter13 找到。

表格回归

第5章《时间序列预测作为回归》中,我们看到了如何将时间序列问题转化为带有时间嵌入和时间延迟嵌入的标准回归问题。在第6章《时间序列预测的特征工程》中,我们已经为家庭能源消耗数据集创建了必要的特征;在第8章《使用机器学习模型预测时间序列》、第9章《集成与堆叠》和第10章《全局预测模型》中,我们使用了传统的机器学习ML)模型来生成预测。

正如我们使用标准ML模型进行预测一样,我们也可以使用为表格数据构建的深度学习模型,利用我们创建的特征工程数据集。我们已经讨论了数据驱动方法,以及在数据量更大时它们如何表现更好。DL模型进一步推进了这一范式,使我们能够学习高度数据驱动的模型。在这种设置下,与ML模型相比,使用DL模型的一个优势是DL提供的灵活性。在第8章9章10章中,我们只看到了如何使用ML模型创建单步超前预测。我们在第18章中有一个关于多步预测的单独部分,详细介绍了生成多步预测的不同策略,并解决了标准ML模型在多步预测中的一个局限性。但现在,我们只需要理解标准ML模型被设计为只有一个输出,因此生成多步预测并不直接。但对于表格DL模型,我们可以灵活地训练模型预测多个目标,从而轻松生成多步预测。

PyTorch Tabular 是一个开源库(https://github.com/manujosephv/pytorch_tabular),它使得在表格数据领域使用DL模型变得容易,并且它还提供了许多最先进DL模型的现成实现。我们将使用 PyTorch Tabular 基于我们在第6章《时间序列预测的特征工程》中创建的特征工程数据集来生成预测。

PyTorch Tabular 有非常详细的文档和教程,可以在这里开始:https://pytorch-tabular.readthedocs.io/en/latest/。虽然我们不会深入探讨该库的所有细节,但我们将研究如何使用一个极简版本来对当前数据集使用 FTTransformer 模型生成预测。FTTransformer 是处理表格数据的最先进的深度学习模型之一。针对表格数据的深度学习是一种完全不同的模型,我在扩展阅读部分链接了一篇博客文章,作为该研究领域的入门。就我们的目的而言,我们可以将它们视为 scikit-learn 中的任何标准机器学习模型。

笔记本提示:

要跟随完整代码,请使用 01-Tabular_Regression.ipynb 文件夹中的笔记本 01-Tabular_Regression.ipynb 以及 Chapter13 文件夹中的代码和 src 文件夹中的代码。

我们像之前一样开始,加载库和必要的数据集。这里我们唯一额外做的事情是,不采用我们在第2部分时间序列机器学习)中使用的相同块选择,而是通过选择之前一半数量的块来获取更小的数据。

这样做是为了使神经网络NN)的训练更顺畅、更快,并且能够适应 GPU 内存(如果有的话)。我想在这里强调,这纯粹是出于硬件原因,只要我们有足够强大的硬件,就不必为深度学习使用较小的数据集。相反,深度学习喜欢更大的数据集。但因为我们希望将重点放在建模方面,处理更大数据集时的工程约束和技术已超出本书范围。

uniq_blocks = train_df.file.unique().tolist()
sel_blocks = sorted(uniq_blocks, key=lambda x: int(x.replace("block_","")))[:len(uniq_blocks)//2]
train_df = train_df.loc[train_df.file.isin(sel_blocks)]
test_df = test_df.loc[test_df.file.isin(sel_blocks)]
sel_lclids = train_df.LCLid.unique().tolist()

处理完缺失值后,我们就可以开始使用 PyTorch Tabular 了。我们首先从库中导入必要的类,如下所示:

from pytorch_tabular.config import DataConfig, OptimizerConfig, TrainerConfig
from pytorch_tabular.models import FTTransformerConfig
from pytorch_tabular import TabularModel

PyTorch Tabular 使用一组配置文件来定义运行模型所需的参数,这些配置包括 DataFrame 如何配置、需要应用哪种预处理、需要哪种训练、使用哪个模型、模型的超参数是什么等等。让我们看看如何定义一个极简配置(因为 PyTorch Tabular 尽可能使用智能默认值,以使从业者更容易使用):

data_config = DataConfig(
    target=[target], #target should always be a list
    continuous_cols=[
        "visibility",
        "windBearing",
        …
        "timestamp_Is_month_start",
    ],
    categorical_cols=[
        "holidays",
        …
        "LCLid"
    ],
    normalize_continuous_features=True
)
trainer_config = TrainerConfig(
    auto_lr_find=True, # Runs the LRFinder to automatically derive a learning rate
    batch_size=1024,
    max_epochs=1000,
    auto_select_gpus=True,
    gpus=-1
)
optimizer_config = OptimizerConfig()

我们在 TrainerConfig 中使用了非常高的 max_epochs 参数,因为默认情况下,PyTorch Tabular 采用了一种称为早停的技术,即我们持续跟踪验证集上的性能,并在验证损失开始上升时停止训练。

从 PyTorch Tabular 已实现的模型中选择要使用的模型,只需选择正确的配置即可。每个模型都关联一个定义模型超参数的配置。因此,只需使用该配置,PyTorch Tabular 就能理解用户想要使用的模型。让我们选择 FTTransformerConfig 模型并定义一些超参数:

model_config = FTTransformerConfig(
    task="regression",
    num_attn_blocks=3,
    num_heads=4,
    transformer_head_dim=64,
    attn_dropout=0.2,
    ff_dropout=0.1,
    out_ff_layers="32",
    metrics=["mean_squared_error"]
)

这里唯一的主要且必需的参数是 task,它告诉 PyTorch Tabular 任务是回归还是分类

尽管 PyTorch Tabular 提供了最佳默认值,但我们设置这些参数只是为了加快训练速度并使其适应所使用的 GPU 内存。如果你不是在具有 GPU 的机器上运行笔记本,选择更小更快的模型(例如 CategoryEmbeddingConfig)会更好。

现在,剩下的工作就是将所有这些配置组合到一个名为 TabularModel 的类中,该类是库的核心,与任何 scikit-learn 模型一样,在对象上调用 fit。但是,与 scikit-learn 模型不同,您不需要拆分 xy;我们只需要提供 DataFrame,如下所示:

tabular_model.fit(train=train_df)

训练完成后,你可以通过运行以下代码保存模型:

tabular_model.save_model("notebooks/Chapter13/ft_transformer_global")

如果出于某种原因,你在训练后不得不关闭笔记本实例,你可以随时使用以下代码重新加载模型:

tabular_model = TabularModel.load_from_checkpoint("notebooks/Chapter13/ft_transformer_global")

这样,你就不需要花费大量时间重新训练模型,而是直接用它进行预测。

现在,剩下的就是使用未见数据进行预测并评估性能。以下是具体方法:

forecast_df = tabular_model.predict(test_df)
agg_metrics, eval_metrics_df = evaluate_forecast(
    y_pred=forecast_df[f"{target}_prediction"],
    test_target=forecast_df["energy_consumption"],
    train_target=train_df["energy_consumption"],
    model_name=model_config._model_name,
)

我们使用了在第10章全局预测模型)中训练的未经调优的带元数据全局预测模型作为基线,以便粗略检查深度学习模型的表现,如下面的截图所示:

图13.1 – 基于深度学习的表格回归评估

图13.1:基于深度学习的表格回归评估

我们可以看到FTTransformer模型与我们在第10章中训练的LightGBM模型具有竞争力。也许,经过适当的调优和划分,FTTransformer模型可以做得与LightGBM模型一样好甚至更好。以与LightGBM相同的方式训练有竞争力的深度学习模型在很多方面都有用。首先,它提供了灵活性,并训练模型一次性预测多个时间步。其次,这还可以与LightGBM模型集成相结合,由于深度学习模型带来的多样性,可以使集成性能更好。

可以尝试的事项:

使用PyTorch Tabular的文档,尝试其他模型或更改参数,观察性能如何变化。

选择几个家庭并绘制图形,观察预测与目标值的匹配程度。

现在,让我们看看如何使用循环神经网络RNNs)进行单步预测。

单步前向循环神经网络

尽管我们稍微绕了点路,去了解如何利用DL回归模型来训练我们在第10章《全局预测模型》中学到的相同全局模型,但现在我们回到专门为时间序列构建的DL模型和架构上。一如既往,我们将首先研究简单的单步前向和局部模型,然后再转向更复杂的建模范式。实际上,我们还有另一章(第15章《全局深度学习预测模型的策略》)完全致力于介绍可用于训练全局DL模型的技术。

现在,让我们把注意力转回到单步前向局部模型上。我们看到RNN(vanilla RNN、长短期记忆LSTM)和门控循环单元GRU))是可以用于时间序列等序列的一些组件。现在,让我们看看如何在一个端到端E2E)模型中使用它们,该模型基于我们一直在处理的数据集(伦敦智能电表数据集)。

尽管我们会介绍一些库(例如darts),它们使训练时间序列预测的DL模型变得更加容易,但在本章中,我们将关注如何从头开始开发此类模型。理解时间序列预测的DL模型是如何从零开始构建的,将让你很好地掌握使用和调整后续库所需的概念。

我们将使用PyTorch,如果你不太熟悉,我建议你前往第12章《时间序列深度学习的构建模块》以及相关的笔记本进行快速复习。此外,我们还将使用PyTorch Lightning,这是另一个构建在PyTorch之上的库,旨在简化使用PyTorch训练模型的过程,并带来其他好处。

我们在第5章《将时间序列预测视为回归》中讨论了时间延迟嵌入,其中我们讨论了使用时间窗口将时间序列嵌入到更适合回归的格式。在训练用于时间序列预测的神经网络时,我们同样需要这样的窗口。假设我们针对单个时间序列进行训练。我们可以将这个超长的时间序列直接输入RNN,但这样它只是数据集中的一个样本。而数据集中只有一个样本,几乎不可能训练任何机器学习或深度学习模型。因此,建议从时间序列中采样多个窗口,将时间序列转换为多个数据样本,这个过程与时间延迟嵌入非常相似。这个窗口也设定了深度学习模型的内存。

我们需要做的第一步是创建一个PyTorch数据集,它接收原始时间序列并准备这些样本的窗口。数据集类似于一个数据迭代器,根据提供的索引返回对应的样本。为PyTorch定义自定义数据集很简单,只需定义一个类,它接受一些参数(其中一个是数据),并在类中定义两个必需的方法,如下所示:

我们在src/dl/dataloaders.py中定义了一个名为TimeSeriesDataset的数据集,它接受以下参数:

$$\frac{\text{series} - \text{mean}}{\text{std}}$$

local 表示我们使用窗口均值和标准差对序列进行标准化。

该数据集中的每个样本返回两个张量——窗口(X)和对应的目标(Y)(见图13.2):

图13.2 – 使用数据集和数据加载器对时间序列进行采样

图13.2:使用数据集和数据加载器对时间序列进行采样

现在我们已经定义了数据集,还需要另一个PyTorch工件,称为数据加载器。数据加载器使用数据集将样本挑选到一批样本中,等等。在PyTorch Lightning生态系统中,我们还有另一个概念,称为数据模块,它是生成数据加载器的标准方法。我们需要训练数据加载器、验证数据加载器和测试数据加载器。数据模块提供了一种良好的抽象来封装管道的整个数据部分。我们在 src/dl/dataloaders.py 中定义了一个名为 TimeSeriesDataModule 的数据模块,它接受数据以及批次大小,并准备训练所需的数据集和数据加载器。参数与 TimeSeriesDataset 完全相同,唯一的额外参数是 batch_size

笔记本提示:

要跟随完整代码,请使用 02-One-Step_RNN.ipynb 文件夹中的笔记本 02-One-Step_RNN.ipynb 以及 Chapter13 文件夹中的代码和 src 文件夹中的代码。

我们不会逐一讲解笔记本中的每一步,而只会强调关键点。笔记本中的代码注释充分,我们强烈建议您跟随本书一起学习代码。

我们已经从一个家庭中采样了数据,现在,让我们看看如何定义一个数据模块:

datamodule = TimeSeriesDataModule(data = sample_df[[target]],
        n_val = sample_val_df.shape[0],
        n_test = sample_test_df.shape[0],
        window = 48, # giving enough memory to capture daily seasonality
        horizon = 1, # single step
        normalize = "global", # normalizing the data
        batch_size = 32,
        num_workers = 0)
datamodule.setup()

datamodule.setup()是计算并设置数据加载器的方法。现在,我们可以通过简单的调用datamodule.train_dataloader()来访问训练数据加载器,类似地,分别使用val_dataloadertest_dataloader方法访问验证和测试数据加载器。我们可以按如下方式访问样本:

# Getting a batch from the train_dataloader
for batch in datamodule.train_dataloader():
    x, y = batch
    break
print("Shape of x: ",x.shape) #-> torch.Size([32, 48, 1])
print("Shape of y: ",y.shape) #-> torch.Size([32, 1, 1])

我们可以看到每个样本有两个张量——xy。张量有三个维度,分别对应批量大小序列长度特征

现在数据管道已准备就绪,我们需要构建建模和训练管道。PyTorch Lightning 有标准的定义方法,以便它们可以插入到它们提供的训练引擎中(这让我们的生活轻松很多)。PyTorch Lightning 文档(https://pytorch-lightning.readthedocs.io/en/latest/starter/introduction.html)提供了很好的资源来入门和深入。我们还在进一步阅读部分链接了一个视频,可以使从纯 PyTorch 到 PyTorch Lightning 的过渡变得容易。我强烈建议您花些时间熟悉它。

在 PyTorch 中定义模型时,除了__init__之外,一个名为forward的标准方法是您必须定义的唯一必需方法。这是因为训练循环需要我们自己编写。在01-PyTorch_Basics.ipynb笔记本中,对于第12章——时间序列深度学习构建块,我们看到了如何编写一个 PyTorch 模型以及训练循环来训练一个简单的分类器。但现在我们将训练循环委托给 PyTorch Lightning,我们还需要包含一些额外的方法:

我们在src/dl/models.py中定义了一个BaseModel类,该类实现了所有通用函数,如损失和指标计算以及结果记录,作为实现新模型的框架。使用这个BaseModel类,我们定义了一个SingleStepRNNModel类,它接受标准配置(SingleStepRNNConfig)并初始化一个RNN、LSTM或GRU模型。

在查看模型定义之前,我们先看看不同的配置(SingleStepRNNConfig)参数有哪些:

通过这种设置,定义一个新模型就像这样简单:

rnn_config = SingleStepRNNConfig(
    rnn_type="RNN",
    input_size=1,
    hidden_size=128,
    num_layers=3,
    bidirectional=True,
    learning_rate=1e-3,
    seed=42,
)
model = SingleStepRNNModel(rnn_config)

现在,我们来看一下forward方法,它是模型的核心。我们希望模型进行一步超前预测,从第12章 《时间序列深度学习的构建模块》中,我们知道典型的RNN输出是什么,以及PyTorch的RNN如何仅在每个时间步输出隐藏状态。让我们从视觉效果上看想要做什么,然后看看如何编写代码:

图13.3 – 单步RNN

图13.3:单步RNN

假设我们使用与数据加载器中相同的示例——一个包含以下条目的时间序列:x1x2x3、…、x7,窗口大小为3。因此,数据加载器给出的一个样本将包含x1x2x3作为输入(x),以及x4作为目标。一种使用方法是,将序列传递给RNN,忽略除最后一个之外的所有输出,并用它来预测目标x4。但这样并没有高效利用我们拥有的样本,对吧?我们还知道,第一个时间步(使用x1)的输出应该是x2,第二个时间步的输出应该是x3,依此类推。因此,我们可以这样设计RNN,以最大化数据的使用,并在训练过程中利用这些额外的时间点,为模型提供更好的信号。现在,我们分解一下forward方法。

forward 接收一个名为 batch 的参数,该参数是一个包含输入和输出的元组。因此,我们将 batch 解包为两个变量 xy,如下所示:

x, y = batch

x 的形状为 $\vec{a}$ (batch size, window length, features),而 y 的形状为 $\vec{a}$ (batch size, target length, features)

现在我们需要将输入序列(x)传递给 RNN(RNN、LSTM 或 GRU),如下所示:

x, _ = self.rnn(x)

正如我们在 第12章时间序列深度学习的构建模块 中所看到的,PyTorch RNN 处理输入并返回两个输出——每个时间步的隐藏状态和最终输出(即最后一个时间步的隐藏状态)。这里,我们需要所有时间步的隐藏状态,因此我们将它们捕获在变量 x 中。现在,x 的维度为 (batch size, window length, hidden size of RNN)。

我们已经有了隐藏状态,但要获得输出,我们需要在隐藏状态之上应用一个全连接层,并且这个全连接层应该跨时间步共享。一个简单的方法是定义一个输入大小等于 RNN 隐藏大小的全连接层,然后执行以下操作:

x = self.fc(x)

x 是一个三维张量,当我们在三维张量上使用全连接层时,PyTorch 会自动将该全连接层应用于每个时间步。现在,这个最终输出存储在 x 中,其维度为 (batch size, window length1)

现在,我们已经得到了网络的输出,但我们还需要做一些重排来准备目标。目前,y 仅包含窗口之后的一个时间步,但如果我们跳过 x 中的第一个时间步并将其与 y 拼接,我们将得到目标,如图 图13.3 所示:

y = torch.cat([x[:, 1:, :], y], dim=1)

通过使用数组索引,我们从 x 中选择除第一个时间步之外的所有内容,并在第一维(即 window length)上将其与 y 拼接。

这样,我们就得到了可以返回的 xy 变量,而 BaseModel 类将计算损失并处理其余的训练。关于整个类及其 forward 方法,请参考 src/dl/models.py

让我们通过传递来自数据加载器的批次来测试我们已经初始化的模型:

y_hat, y = model(batch)
print("Shape of y_hat: ",y_hat.shape) #-> ([32, 48, 1])
print("Shape of y: ",y.shape) #-> ([32, 48, 1])

既然模型按预期工作且没有错误,我们开始训练模型。为此,我们可以使用 PyTorch Lightning 中的 TrainerTrainer 类中有许多选项,所有用于调整训练的参数完整列表可在此处找到:https://pytorch-lightning.readthedocs.io/en/stable/api/pytorch_lightning.trainer.trainer.Trainer.html#pytorch_lightning.trainer.trainer.Trainer

但在这里,我们只使用最少的配置。让我们逐一查看我们将在此使用的参数:

那么,让我们初始化一个最基本的 Trainer 类:

trainer = pl.Trainer(
    auto_select_gpus=True,
    gpus=-1,
    min_epochs=5,
    max_epochs=100,
    callbacks=[pl.callbacks.EarlyStopping(monitor="valid_loss", patience=3)],
)

现在,剩下的就是将 modeldatamodule 传递给一个名为 fit 的方法来触发训练:

trainer.fit(model, datamodule)

它将运行一段时间,并根据验证损失开始增加的时间停止训练。模型训练好后,我们仍然可以使用 Trainer 类对新数据进行预测。预测使用我们在 BaseModel 类中定义的 predict_step 方法,该方法又使用我们在 SingleStepRNN 模型中定义的 predict 方法。这是一个非常简单的方法,它调用 forward 方法,获取模型输出,并只从输出中选取最后一个时间步(即我们预测未来的真实输出)。你可以在这里看到示意图:

def predict(self, batch):
        y_hat, _ = self.forward(batch)
        return y_hat[:, -1, :]

那么,让我们看看如何使用 Trainer 类对新数据(准确地说,是新数据加载器)进行预测:

pred = trainer.predict(model, datamodule.test_dataloader())

我们只需要提供训练好的模型和数据加载器(这里,我们使用已经设置并定义好的测试数据加载器)。

现在,输出 pred 是一个张量列表,每个批次对应一个。我们只需将它们连接起来,压缩掉任何多余的维度,从计算图中分离,并转换为 NumPy 数组。具体做法如下:

pred = torch.cat(pred).squeeze().detach().numpy()

现在,pred 是测试 DataFrame 中所有项的预测值的 NumPy 数组(测试 DataFrame 用于定义 test_dataloader),但记住我们对原始时间序列应用了标准化变换。现在,我们需要反向变换。我们用于初始变换的均值和标准差仍存储在训练数据集中。我们只需检索它们并反转之前所做的变换,如下所示:

pred = pred * datamodule.train.std + datamodule.train.mean

现在,我们可以对它们进行各种操作,例如与实际值对比评估、可视化预测等。让我们看看模型的表现如何。为了提供上下文,我们还包含了在第8章使用机器学习模型进行时间序列预测”中完成的单步机器学习模型:

图13.4 – 普通单步RNN在MAC000193住户上的指标

图13.4:普通单步前向RNN在MAC000193家庭上的指标

看起来RNN模型表现相当糟糕。我们再从视觉上看看预测结果:

图13.5 – MAC000193家庭的单步前向RNN预测

图13.5:MAC000193家庭的单步前向RNN预测

我们可以看到模型未能学习到峰值的幅度和模式的细微之处。这或许是因为我们讨论过的RNN问题,因为这里的季节性模式分布在48个时间步上;记住该模式要求RNN具有长期记忆。我们快速将模型换成LSTM和GRU,看看它们的表现如何。唯一需要改动的是rnn_type参数,该参数位于SingleStepRNNConfig中。

笔记本中也包含训练LSTM和GRU的代码。但我们先看看LSTM和GRU的指标:

图13.6 – MAC000193家庭上单步前向LSTM和GRU的指标

图13.6:MAC000193家庭上单步前向LSTM和GRU的指标

现在看起来有竞争力了。LightGBM仍然是最好的模型,但LSTM和GRU模型现在也具有竞争力,而不像普通RNN模型那样完全不行。如果查看预测结果,我们会发现LSTM和GRU模型也更好地捕捉到了模式:

图13.7 – MAC000193家庭的单步前向LSTM和GRU预测

图13.7:MAC000193家庭的单步前向LSTM和GRU预测

可以尝试的事项:

尝试更改模型的参数,看看效果如何。双向LSTM表现如何?增加窗口能否提升性能?

既然我们已经了解了标准RNN如何用于单步超前预测,现在让我们看看另一种比刚才所见更灵活的建模模式。

序列到序列(Seq2Seq)模型

我们在《第12章 时间序列深度学习构件》中详细讨论了Seq2Seq架构和编码器-解码器范式。为了刷新记忆,Seq2Seq模型是一种编码器-解码器模型,编码器将序列编码成潜在表示,然后解码器利用该潜在表示执行当前任务。由于编码器(负责表示学习)和解码器(使用表示进行预测)之间的分离,这种设置本质上更加灵活。从时间序列预测的角度来看,这种方法的最大优势之一是消除了单步超前预测的限制。在这种建模模式中,我们可以将预测扩展到任意所需的预测范围。

在本节中,让我们构建几个编码器-解码器模型,并测试我们的单步超前预测,就像我们之前用单步超前RNN所做的那样。

笔记本提示:

要跟随完整代码,请使用 03-Seq2Seq_RNN.ipynb 文件夹中的笔记本 03-Seq2Seq_RNN.ipynb 以及 Chapter13 文件夹中的代码和 src 文件夹中的代码。

我们可以将上一节中开发的相同机制(例如TimeSeriesDataModuleBaseModel类以及相应代码)用于我们的Seq2Seq建模模式。让我们定义一个新的PyTorch模型,名为Seq2SeqModel,继承BaseModel类。同时,我们还要定义一个名为Seq2SeqConfig的新配置文件,用于设置模型的超参数。两者的最终版本可在src/dl/models.py中找到。

在解释模型和配置中的不同参数之前,让我们先讨论设置此Seq2Seq模型的不同方式。

RNN到全连接网络

为方便起见,我们将编码器限制为RNN家族的一员——可以是普通RNN、LSTM或GRU。现在,我们在《第12章 时间序列深度学习构件》中看到,在PyTorch中,RNN家族的所有模型都有两个输出——outputhidden states,并且我们还看到output实际上就是所有时间步上的所有隐藏状态(堆叠RNN中的最终隐藏状态)。我们得到的隐藏状态包含了堆叠RNN设置中所有层的最新隐藏状态(对于LSTM还有细胞状态)。编码器的初始化方式与上一节中初始化RNN家族模型的方式相同,如下:

self.encoder = nn.LSTM(
                **encoder_params,
                batch_first=True,
            )

forward方法中,我们可以执行以下操作来编码时间序列:

o, h = self.encoder(x)

现在,有几种不同的方式可以解码信息。我们将讨论的第一种方法是使用全连接层。全连接层可以仅使用编码器的最新隐藏状态来预测期望的输出,或者我们可以将所有隐藏状态展平为一个长向量,并用它来预测输出。后者为解码器提供了更多信息,但也可能引入更多噪声。图13.8展示了这两种方式,使用的例子与上一节相同。

图13.8 – RNN作为编码器,全连接层作为解码器

图13.8:RNN作为编码器,全连接层作为解码器

现在看看如何在代码中实现这一点。在第一种情况下,我们只使用编码器的最后一个隐藏状态,解码器将如下所示:

self.decoder = nn.Linear(
                    hidden_size*bi_directional_multiplier, horizon
                )

这里,如果编码器是双向的,bi_directional_multiplier2,否则为 1。这是因为如果编码器是双向的,每个时间步会有两个隐藏状态连接在一起。horizon 是我们想要预测的未来时间步数。

在第二种情况下,我们使用所有时间步的隐藏状态,需要按如下方式构建解码器:

self.decoder = nn.Linear(
                    hidden_size * bi_directional_multiplier * window_size, horizon
                )

这里,输入向量是所有时间步隐藏状态的展平向量,因此输入维度为 hidden_size * window_size

forward 方法中,针对情况1,我们可以执行以下操作:

y_hat = self.decoder(o[:,-1,:]).unsqueeze(-1)

这里,我们只取最后一个时间步的隐藏状态,并通过 unsqueeze 保持三维作为目标 y

对于情况2,我们可以执行以下操作:

y_hat = self.decoder(o.reshape(o.size(0), -1)).unsqueeze(-1)

这里,我们首先重塑整个隐藏状态以展平它,然后通过解码器获得预测。我们使用 unsqueeze 插入我们折叠的维度,以使输出和目标 y 具有相同的维度。

尽管理论上我们可以使用全连接解码器预测任意远的未来,但实际上存在局限性。当需要预测大量步骤时,模型必须学习一个巨大的矩阵来生成这些输出,矩阵越大,学习就越困难。另一点值得注意的是,每个预测都是独立地依赖于潜在表示中编码的信息。例如,预测未来5个时间步仅依赖于编码器的潜在表示,而不依赖于时间步1到4的预测。让我们看看另一种Seq2Seq类型,它使解码更加灵活,并能感知问题的时间特性。

RNN到RNN

我们不用全连接层作为解码器,而是使用另一个RNN进行解码——即一个RNN家族模型负责编码,另一个RNN家族模型负责解码。模型中解码器的初始化也与编码器类似。如果我们希望用LSTM模型作为解码器,可以这样做:

self.decoder = nn.LSTM(
                **decoder_params,
                batch_first=True,
            )

通过可视化表示来理解这一过程:

图13.9 – RNN作为编码器和解码器

图13.9:RNN作为编码器和解码器

编码器部分保持不变:它接收输入窗口 x1x3,生成输出 o1o3,以及最后一个隐藏状态 h3。现在,我们有一个解码器(另一个RNN家族模型),它以 h3 作为初始隐藏状态,并接收窗口中的最新输入来生成下一个输出。然后,这个输出被反馈回RNN作为输入,我们生成下一个输出,如此循环,直到预测出所需的时间步数。

有些人可能会想,为什么不在解码时也使用目标窗口( x4x6)。事实上,这是一种有效的训练方法,在文献中称为 教师强制。这与极大似然估计有紧密联系,并在Goodfellow等人的《深度学习》一书(见扩展阅读部分)中有很好的解释。因此,我们不将前一时刻的模型输出作为当前时刻RNN的输入,而是输入真实观测值,从而消除前一时刻可能累积的误差。

虽然这听起来很直接,但也存在一些缺点。主要问题是解码器在训练时看到的输入可能与实际预测时不同。在预测时,我们仍然会将上一时刻的模型输出馈送到解码器,因为在推理模式下,我们无法获得未来的真实观测值。这在某些情况下会引起问题。缓解这一问题的一种方法是在训练时随机选择上一时刻的模型输出或真实观测值(Bengio等,2015)。

参考文献检查:

Bengio等人提出教师强制的研究论文在参考文献1中被引用。

现在,让我们看看如何针对这两种情况,使用一个从0到1的小数参数teacher_forcing_ratio来编写forward方法。该参数决定教师强制实施的频率。例如,如果teacher_forcing_ratio = 0,则永不实施教师强制;如果teacher_forcing_ratio = 1,则始终实施教师强制。

以下代码块包含解码所需的全部代码,并带有行号,以便我们逐行解释具体操作。

01  y_hat = torch.zeros_like(y, device=y.device)
02  dec_input = x[:, -1:, :]
03  for i in range(y.size(1)):
04      out, h = self.decoder(dec_input, h)
05      out = self.fc(out)
06      y_hat[:, i, :] = out.squeeze(1)
07      #decide if we are going to use teacher forcing or not
08      teacher_force = random.random() < teacher_forcing_ratio
09      if teacher_force:
10          dec_input = y[:, i, :].unsqueeze(1)
11      else:
12          dec_input = out

我们需要做的第一件事是声明一个占位符,用于在解码过程中存储期望的输出。在第1行中,我们通过zeros_like来实现,它生成一个与y维度相同的全零张量。在第2行中,我们将解码器的初始输入设置为输入窗口的最后一个时间步。现在,我们已经准备好开始解码过程。为此,在第3行中,我们启动一个循环,运行y.size(1)次。如果你还记得y的维度,第二个维度是序列长度,因此我们需要运行解码过程那么多次。

第4行中,我们将输入窗口的最后输入和编码器的隐藏状态传递给解码器,它返回当前输出和隐藏状态。我们将当前的隐藏状态捕获到同一个变量中,覆盖旧的隐藏状态。如果你还记得,RNN的输出就是隐藏状态,我们需要将其传入一个全连接层进行预测。因此,在第5行中,我们正是这样做的。在第6行中,我们将全连接层的输出存储到y_hat的第i个时间步中。

现在,我们还有一件事要做——决定是否使用教师强制,并继续解码下一个时间步。我们可以通过生成一个介于01之间的随机数,并检查该数是否小于teacher_forcing_ratio参数来实现。random.random()01之间的均匀分布中采样一个数。如果teacher_forcing_ratio参数是0.5,检查random.random()<teacher_forcing_ratio会自动确保我们只进行50%的教师强制。因此,在第8行中,我们进行这个检查并获得一个布尔输出teacher_force,它告诉我们是否需要在下一个时间步进行教师强制。对于教师强制,我们将y中的当前时间步存储为dec_input第10行)。否则,我们将当前输出存储为dec_input第12行),并且这个dec_input参数将作为下一个时间步RNN的输入。

现在,所有这些(包括全连接解码器和RNN解码器)已经被整合到一个名为Seq2SeqModel的类中,位于src/dl/models.py中,并且还定义了一个配置类(Seq2SeqConfig),它包含了模型的所有选项和超参数。让我们看看配置中的不同参数:

现在,有了这个配置和模型,让我们运行几个实验。这些实验与上一节中运行的实验完全相同。实验的准确代码在附带的笔记本中。因此,我们运行了以下实验:

让我们看看它们在我们一直追踪的指标上的表现如何:

图13.10 – MAC000193家庭的Seq2Seq模型指标

图13.10: MAC000193家庭的Seq2Seq模型指标

Seq2Seq模型在指标上似乎表现更好,并且LSTM_LSTM 模型甚至优于随机森林模型。

在笔记本中有这些预测的可视化图。我强烈建议您查看这些可视化图,放大,观察预测范围的不同位置等。你们中的敏锐观察者一定已经发现了预测中的一些奇怪之处。让我们看看预测的放大版本(一天内),以阐明这一点:

图13.11 – MAC000193家庭的单步前向Seq2Seq预测(1天)

图13.11: MAC000193家庭的单步前向Seq2Seq预测(一天)

你现在看到了什么?关注时间序列中的峰值。它们是对齐的还是有偏移?你现在看到的这种现象是模型学会了模仿上一个时间步(如朴素预测)而不是学习数据中的真实模式。我们会得到良好的指标,并可能对预测感到满意,但调查后发现这并不是我们想要的预测。这在单步前向模型中尤其如此,我们只是优化预测下一个时间步。因此,模型没有真正的动力去学习长期模式,如季节性等,最终学习到的模型类似于朴素预测。

训练用于预测更长时间范围的模型克服了这个问题,因为在这种情况下,模型被迫学习模型中的长期模式。虽然多步预测是一个将在第18章多步预测中详细讨论的主题,但我们现在先稍微预览一下。在笔记本中,我们还使用Seq2Seq模型训练了多步模型。

我们只需要做以下更改:

让我们看看如何为多步预测定义一个 datamodule。我们选择预测完整的一天,即48个时间步。作为输入窗口,我们提供 2 X 48 个时间步:

HORIZON = 48
WINDOW = 48*2
datamodule = TimeSeriesDataModule(data = sample_df[[target]],
        n_val = sample_val_df.shape[0],
        n_test = sample_test_df.shape[0],
        window = WINDOW,
        horizon = HORIZON,
        normalize = "global", # normalizing the data
        batch_size = 32,
        num_workers = 0)

现在我们有了 datamodule,可以像之前一样初始化模型并进行训练。唯一需要改变的是在预测时。

在单步设置中,每个时间步我们预测下一个时间步。但现在,每一步我们预测接下来的48个时间步。有多种方法来看待和衡量指标,我们将在第3部分中详细讨论。现在,让我们选择一个启发式方法,假设我们每天只运行一次该模型,每次预测包含48个时间步。但测试数据加载器仍然每次递增一个时间步——换句话说,测试数据加载器对于每个时间步都给出接下来的48个时间步。因此,执行以下代码,我们将得到一个维度为(时间步时间范围)的预测数组:

pred = trainer.predict(model, datamodule.test_dataloader())
# pred is a list of outputs, one for each batch
pred = torch.cat(pred).squeeze().detach().numpy()

预测从 2014, Jan 1 00:00:00 开始。因此,如果我们每隔48个时间步选择一个时间步,就像只考虑每天开始时做出的预测。利用 numpy 提供的一些花式索引,我们可以轻松做到这一点:

pred = pred[0::48].ravel()

我们从索引0开始,这是第一个48时间步的预测,然后每隔48个索引(即时间步)选取一次,并展平数组。我们将得到一个所需形状的预测数组,然后进行标准的逆变换和指标计算等步骤。

笔记本中包含执行以下实验的代码:

我们来看看这些实验的指标:

图13.12 – MAC000193家庭多步Seq2Seq模型的指标

图13.12:MAC000193家庭多步Seq2Seq模型的指标

尽管我们无法将单步预测精度与多步预测进行比较,但暂时让我们搁置这个顾虑,并使用单步指标作为最佳情况。因此,我们可以看到,我们的模型预测未来一天(48个时间步)并不是一个糟糕的模型,而且如果我们可视化预测,模仿朴素预测的问题也不存在,因为现在模型被迫学习长期模式和预测:

图13.13 – MAC000193家庭的多步超前Seq2Seq预测(1天)

图13.13:MAC000193家庭的多步超前Seq2Seq预测(一天)

我们可以看到,模型试图学习每日模式,因为它被迫预测接下来的48个时间步。通过一些调优和其他训练技巧,我们或许能获得更好的模型。但是,从工程和建模的角度来看,为数据集中的所有LCLid(消费者ID)实例运行单独的模型可能不是最佳选择。我们将在第15章《全局深度学习预测模型的策略》中讨论全局建模的策略。

可以尝试的事项:

你能训练出更好的模型吗?调整超参数,尝试获得更好的性能。使用GRU或将GRU与LSTM结合——可能性是无限的。

恭喜你完成了又一个动手实践的章节。如果这是你第一次训练神经网络,希望这节课能让你有信心尝试更多:尝试和实验这些技术是最好的学习方式。在机器学习中没有万能的解决方案,因此需要从业者保持开放的心态,选择适合我们用例并能良好工作的正确算法/模型。在此数据集中,我们看到对于单步预测,LightGBM表现非常好。但LSTM Seq2Seq模型也几乎同样好。当我们扩展到多步预测场景时,单个模型以足够好的性能进行多步预测的优势可能胜过管理多个机器学习模型(更多内容见第18章)。我们学到的技术在深度学习领域仍被认为是基础的,在接下来的章节中,我们将深入深度学习的海洋,学习更复杂的方法。

小结

尽管我们在上一章学习了深度学习的基本模块,但在使用PyTorch将这些模块应用于常见建模模式时,我们付诸了实践。

我们看到了标准序列模型如RNN、LSTM和GRU如何用于时间序列预测,然后我们转向了另一类模型,称为Seq2Seq模型。这里,我们讨论了如何混合搭配编码器和解码器以获得我们想要的模型。编码器和解码器可以任意复杂。尽管我们看了简单的编码器和解码器,但完全有可能有类似于卷积块和LSTM块组合作为编码器的情况。最后但同样重要的是,我们讨论了教师强制以及它如何帮助模型训练和更快收敛,同时带来一些性能提升。

在下一章中,我们将处理一个在过去几年中吸引了大量关注(双关语)的主题:注意力机制和Transformer。

参考文献

  1. Samy Bengio, Oriol Vinyals, Navdeep Jaitly, and Noam Shazeer (2015). Scheduled Sampling for Sequence Prediction with Recurrent Neural Networks. Proceedings of the 28th International Conference on Neural Information Processing Systems—Volume 1 ( (NIPS’15): https://proceedings.neurips.cc/paper/2015/file/e995f98d56967d946471af29d7bf99f1-Paper.pdf..

延伸阅读

查看以下资料以进一步阅读: