全局深度学习预测模型的策略

在过去的几章中,我们一直在建立用于时间序列预测的深度学习。我们从深度学习的基础开始,看到了不同的构建模块,实际使用了一些构建模块来生成样本家庭的预测,最后讨论了注意力和Transformer。现在,让我们稍微改变一下轨迹,看看深度学习的全局模型。在第10章,《全局预测模型》中,我们看到了为什么全局模型有意义,也看到了如何在机器学习背景下使用这些模型。我们在实验中甚至得到了很好的结果。在本章中,我们将看看如何应用类似的概念,但要从深度学习的角度。我们将研究不同的策略,使全局深度学习模型工作得更好。

本章涵盖以下主要主题:

技术要求

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

你需要运行以下笔记本:

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

创建全局深度学习预测模型

在第10章《全局预测模型》中,我们详细讨论了为什么全局模型是有意义的。我们讨论了关于样本量增加、跨学习多任务学习以及随之而来的正则化效应和工程复杂度降低的好处。所有这些对于深度学习模型同样相关。工程复杂度和样本量变得更加重要,因为深度学习模型是数据饥渴的,并且比其他机器学习模型需要更多的工程努力和训练时间。我甚至认为,在深度学习背景下,在大多数需要大规模预测的实际案例中,全局模型是唯一有意义的深度学习范式。

那么,我们为什么花那么多时间研究单个模型呢?嗯,在那个层面上更容易理解概念,而且我们在那个层面上获得的技能和知识很容易迁移到全局建模范式。在第13章时间序列的常见建模模式中,我们看到了如何使用数据加载器从单个时间序列中采样窗口来训练模型。要使模型成为全局模型,我们只需要更改数据加载器,使其不是从单个时间序列采样窗口,而是从多个时间序列采样。采样过程可以看作是一个两步过程(尽管在实践中我们一步完成,但直觉上可以认为是两步)——首先,采样我们需要选取窗口的时间序列,然后从该时间序列中采样窗口。通过这样做,我们训练了一个单一的深度学习模型来共同预测所有时间序列。

为了使我们的生活更容易,在本书中我们将使用 Nixtla 的开源库 PyTorch Forecasting 和 neuralforecast。我们将出于教学目的使用 PyTorch Forecasting,因为它确实提供了更大的灵活性,但 neuralforecast 更新、维护更积极,因此未来的架构会添加在那里。在第16章中,我们将看到如何使用 neuralforecast 进行预测,但现在,我们选择 PyTorch Forecasting 继续前进。

PyTorch Forecasting 旨在使时间序列深度学习预测在研究和实际应用中都变得简单。PyTorch 数据集的工作。这些数据集。PyTorch Forecasting 中的 TimeSeriesDataset 类处理了大量模板代码,用于处理不同的变换、缺失值、填充等。在本章中,当我们研究实现全局深度学习预测模型的不同策略时,我们将使用这个框架。PyTorch Forecasting 中的

笔记本提示

要跟随完整代码,请使用名为 01-Global_Deep_Learning_Models.ipynb 的笔记本,位于 Chapter15 文件夹中。笔记本中有两个变量作为开关—— TRAIN_SUBSAMPLE = True 使笔记本在10个家庭的子集上运行。 train_model = True 使笔记本训练不同的模型(警告:在全数据上训练每个模型需要3小时以上)。 train_model = False 加载训练好的模型权重并进行预测。

数据预处理

我们首先加载必要的库和数据集。我们使用的是在第6章《时间序列预测的特征工程》中创建的预处理和特征工程数据集。数据集中有不同类型的特征,为了使特征分配标准化,我们使用 namedtuplenamedtuple() 是 collections 中的一个工厂方法,允许你创建带有命名字段的 tuple 子类。这些命名字段可以使用点符号访问。我们像这样定义 namedtuple

from collections import namedtuple
FeatureConfig = namedtuple(
    "FeatureConfig",
    [
        "target",
        "index_cols",
        "static_categoricals",
        "static_reals",
        "time_varying_known_categoricals",
        "time_varying_known_reals",
        "time_varying_unknown_reals",
        "group_ids"
    ],
)

让我们也快速明确这些名称的含义:

定义之后,我们可以给每个名称赋予不同的值,如下所示:

feat_config = FeatureConfig(
    target="energy_consumption",
    index_cols=["LCLid", "timestamp"],
    static_categoricals=[
        "LCLid",
        "stdorToU",
        "Acorn",
        "Acorn_grouped",
        "file",
    ],
    static_reals=[],
    time_varying_known_categoricals=[
        "holidays",
        "timestamp_Dayofweek",
    ],
    time_varying_known_reals=["apparentTemperature"],
    time_varying_unknown_reals=["energy_consumption"],
    group_ids=["LCLid"],
)

neuralforecast中设置问题的方式略有不同,但原理是相同的。我们定义的不同类型变量在概念上保持不变,只是用于定义它们的参数名称不同。PyTorch Forecasting需要将目标包含在time_varying_unknown_reals中,但neuralforecast不需要。当我们使用neuralforecast生成预测时,会涵盖所有这些细微差别。

我们可以看到,我们并没有像使用机器学习模型(第10章全局预测模型)那样使用所有特征。这有两个原因:

需要进行一些预处理步骤,以使我们的数据集与PyTorch Forecasting兼容。PyTorch Forecasting需要一个连续的时间索引作为时间的代理。虽然我们有一个timestamp列,但它包含日期时间。所以,我们需要将其转换成一个新列time_idx。完整代码在笔记本中,但代码的实质很简单。我们合并训练和测试DataFrame,并使用基于timestamp列的公式来推导出新的time_idx列。该公式使每个连续的时间戳递增1,并且在traintest之间保持一致。例如,time_idxtrain中最后一个时间步的值为256,而time_idxtest中第一个时间步的值将是257。此外,我们还需要将类别列转换为object数据类型,以便与PyTorch Forecasting的TimeSeriesDataset良好配合。

在我们的实验中,我们选择2天(96个时间步)作为窗口,并预测单步。为了启用早停法,我们还需要一个验证集。早停法是一种正则化方法(防止过拟合的技术,第5章),我们持续监控验证损失,并在验证损失开始增加时停止训练。我们选择训练集的最后一天(48个时间步)作为验证数据,并选择整整一个月作为最终测试数据。但在准备这些DataFrame时,我们需要注意一点:我们选择了两天作为历史长度,为了预测验证集或测试集中的第一个时间步,我们需要连同最后两天的历史数据。因此,我们按照下图所示分割DataFrame(确切代码在笔记本中):

图15.1 – 训练-验证-测试分割

图15.1:训练-验证-测试划分

现在,在对数据使用 TimeSeriesDataset 之前,让我们先了解它的功能以及涉及的不同参数。

理解来自 PyTorch Forecasting 的 TimeSeriesDataset

TimeSeriesDataset 自动化了以下任务及其他更多任务:

以下是 TimeSeriesDataset 的主要参数:

完整文档请参阅 https://pytorch-forecasting.readthedocs.io/en/stable/data.html#time-series-data-set

初始化 TimeSeriesDataset

现在我们已经了解了主要参数,接下来使用我们的数据初始化一个时间序列数据集:

training = TimeSeriesDataSet(
    train_df,
    time_idx="time_idx",
    target=feat_config.target,
    group_ids=feat_config.group_ids,
    max_encoder_length=max_encoder_length,
    max_prediction_length=max_prediction_length,
    time_varying_unknown_reals=[
        "energy_consumption",
    ],
    target_normalizer=GroupNormalizer(
        groups=feat_config.group_ids, transformation=None
    )
)

注意,我们使用了 GroupNormalizer,以便每个家庭使用其自身的均值和标准差进行单独缩放,使用以下众所周知的公式:

$$\frac{x - mean}{standard\ deviation}$$

TimeSeriesDataset 还使得可以使用工厂方法 from_dataset 轻松声明验证和测试数据集。它接收另一个时间序列数据集作为参数,并使用相同的参数、缩放器等,创建新的数据集:

# Defining the validation dataset with the same parameters as training
validation = TimeSeriesDataSet.from_dataset(training, pd.concat([val_history,val_df]).reset_index(drop=True), stop_randomization=True)
# Defining the test dataset with the same parameters as training
test = TimeSeriesDataSet.from_dataset(training, pd.concat([hist_df, test_df]).reset_index(drop=True), stop_randomization=True)

注意,我们将历史数据连接至 val_dftest_df 两者,以确保能够对整个验证和测试期进行预测。

创建数据加载器

剩下的工作就是从 TimeSeriesDataset 创建数据加载器:

train_dataloader = training.to_dataloader(train=True, batch_size=batch_size, num_workers=0)
val_dataloader = validation.to_dataloader(train=False, batch_size=batch_size, num_workers=0)

在继续之前,让我们通过一个例子来巩固对 PyTorch Forecasting 数据加载器的理解。我们刚刚创建的 train 数据加载器已将 DataFrame 拆分为 PyTorch 张量字典。我们选择了 512 作为批量大小,并可以使用以下代码检查数据加载器:

# Testing the dataloader
x, y = next(iter(train_dataloader))
print("\nsizes of x =")
for key, value in x.items():
    print(f"\t{key} = {value.size()}")
print("\nsize of y =")
print(f"\ty = {y[0].size()}")

我们将得到如下输出:

图15.2 – 训练数据加载器一个批次中的张量形状

图15.2: 训练数据加载器一个批次中的张量形状

可以看到,数据加载器和 TimeSeriesDataset 已将 DataFrame 拆分为 PyTorch 张量,并将其打包到字典中,编码器和解码器序列是分开的。我们还可以看到类别特征和连续特征也是分离的。

我们将从该字典中使用的主要 encoder_catencoder_contdecoder_catdecoder_contencoder_catdecoder_cat 键的维度为零,因为我们没有声明任何类别特征。

可视化数据加载器的工作方式

让我们尝试深入一层揭开这里发生的事情,并直观理解 TimeSeriesDataset 的作用:

图15.3 – TimeSeriesDataset – 工作原理示意图

图15.3: TimeSeriesDataset——工作原理示意图

假设我们有一个时间序列,x1x6(这也是 TimeSeriesDataset 术语中的目标以及 time_varying_unknown)。我们有一个随时间变化的实数特征 f1f6,以及一个随时间变化的类别特征 c1c2。除此之外,我们还有一个静态实数特征 r,和一个静态类别特征 s。如果我们设置编码器和解码器长度为 3,我们将得到如图 图15.3 所示构建的张量。注意静态类别和实数特征是如何在所有时间步上重复的。这些不同的张量被构建出来,以便模型编码器可以使用编码器张量进行训练,而解码器张量则用于解码过程。

现在,让我们继续构建我们的第一个全局模型。

构建第一个全局深度学习预测模型

PyTorch Forecasting 在后台使用 PyTorch 和 PyTorch Lightning 来定义和训练深度学习模型。能够与 PyTorch Forecasting 无缝使用的模型本质上是 PyTorch Lightning 模型。然而,推荐的做法是从 PyTorch Forecasting 继承 BaseModel。PyTorch Forecasting 的开发者提供了优秀的文档和教程,帮助新用户按自己的方式使用。这里值得一提的一个教程标题是 如何使用自定义数据并实现自定义模型和指标(链接在 延伸阅读 部分)。

我对教程中的基础模型稍作修改以使其更灵活。实现代码可在 src/dl/ptf_models.py 中找到,名称为 SingleStepRNNModel。该类接受两个参数:

结构非常简单。__init__ 函数将 network_callable 初始化为 network 属性下的 PyTorch 模型。forward 函数将输入送入网络,按 PyTorch Forecasting 期望的格式格式化返回的输出,并返回它。这是一个非常简短的模型,因为繁重的工作由 BaseModel 完成,后者负责损失计算、日志记录、梯度下降等。这样定义模型的好处是,我们现在可以定义标准的 PyTorch 模型,并将其传递给这个模型,使其能与 PyTorch Forecasting 良好配合。

除此之外,我们还定义了一个抽象类 SingleStepRNN,它接受一组参数并初始化由这些参数指定的对应网络。如果参数指定了一个两层 LSTM,那么它将被初始化并保存在 rnn 属性下。它还在 fc 属性下定义了一个全连接层,将 RNN 的输出转换为预测。forward 方法是一个抽象方法,需要在任何继承此类的子类中重写。

定义我们的第一个 RNN 模型

现在我们有了必要的设置,让我们定义第一个继承我们定义的 SingleStepRNN 类的模型:

class SimpleRNNModel(SingleStepRNN):
    def __init__(
        self,
        rnn_type: str,
        input_size: int,
        hidden_size: int,
        num_layers: int,
        bidirectional: bool,
    ):
        super().__init__(rnn_type, input_size, hidden_size, num_layers, bidirectional)
    def forward(self, x: Dict):
        # Using the encoder continuous which has the history window
        x = x["encoder_cont"] # x --> (batch_size, seq_len, input_size)
        # Processing through the RNN
        x, _ = self.rnn(x)  # --> (batch_size, seq_len, hidden_size)
        # Using a FC layer on last hidden state
        x = self.fc(x[:,-1,:])  # --> (batch_size, seq_len, 1)
        return x

这是最直接的实现。我们从字典中获取 encoder_cont,通过 RNN 传递,然后使用 RNN 最后一个隐藏状态上的全连接层生成预测。以 图 15.3 中的例子为例,我们使用 x1x3 作为历史数据,训练模型预测 x4(因为使用了 min_decoder_length=1,解码器和目标中只有一个时间步)。

初始化 RNN 模型

现在,让我们用一些参数初始化模型。我定义了两个参数字典:

现在,我们可以使用 PyTorch Forecasting 模型支持的工厂方法 from_dataset 来初始化它。该工厂方法允许我们传入数据集,并从中推断一些参数,而无需每次都手动填写所有参数:

model = SingleStepRNNModel.from_dataset(
    training,
    network_callable=SimpleRNNModel,
    model_params=model_params,
    **other_params
)

训练 RNN 模型

训练模型与之前章节中的做法类似,因为这是一个 PyTorch Lightning 模型。我们可以按照以下步骤操作:

  1. 初始化带有早停和模型检查点的训练器:
    trainer = pl.Trainer(
        auto_select_gpus=True,
        gpus=-1,
        min_epochs=1,
        max_epochs=20,
        callbacks=[
            pl.callbacks.EarlyStopping(monitor="val_loss", patience=4*3),
            pl.callbacks.ModelCheckpoint(
                monitor="val_loss", save_last=True, mode="min", auto_insert_metric_name=True
            ),
        ],
        val_check_interval=2000,
        log_every_n_steps=2000,
    )
    
  2. 拟合模型:
    trainer.fit(
        model,
        train_dataloaders=train_dataloader,
        val_dataloaders=val_dataloader,
    )
    
  3. 训练后加载最佳模型:
    best_model_path = trainer.checkpoint_callback.best_model_path
    best_model = SingleStepRNNModel.load_from_checkpoint(best_model_path)
    

训练可能需要一些时间。为了节省时间,我预先包含了每个所用模型的训练权重,如果 train_model 标志为 False,则会跳过训练并加载已保存的权重。

使用训练好的模型进行预测

现在,训练完成后,我们可以对 test 数据集进行预测,如下所示:

pred, index = best_model.predict(test, return_index=True, show_progress_bar=True)

我们将预测结果存储在 DataFrame 中,并使用标准指标 MAEMSEmeanMASEForecast Bias 进行评估。让我们看看结果:

图15.4:使用基线全局模型的聚合结果

这不是一个很好的模型,因为我们从第10章全局预测模型中知道,使用LightGBM的基线全局模型如下:

除了Forecast Bias,我们的全局模型远未达到最佳。让我们在接下来的讨论中将全局机器学习模型称为GFMML)并将当前模型称为GFMDL)。现在,让我们开始研究一些使全局模型更好的策略。讨论

使用时变信息

GFM(ML)使用了所有可用特征。因此,显然,该模型比我们目前构建的GFM(DL)能够访问更多的信息。我们刚刚

我们像之前一样初始化训练数据集,但我们在初始化参数中添加了time_varying_known_reals=feat_config.time_varying_known_reals。现在我们已经创建了所有数据集,让我们继续搭建模型。到初始化参数

为了搭建模型,我们需要理解一个概念。我们现在使用目标的历史和时变已知特征。在图15.3中,我们看到了TimeSeriesDataset如何在PyTorch张量中排列不同类型的变量。在上一节中,我们只使用了encoder_cont,因为没有其他变量需要考虑。但现在,我们还有时变变量,这增加了复杂性。如果我们退一步思考,在单步预测场景中,我们可以看到时变变量和目标的历史不能是相同的时间步。

让我们用一个可视化示例来说明:

图15.5 – 使用时变变量进行训练

图15.5: 使用时变变量进行训练

遵循与图15.3中例子相同的精神,但将其简化以适应我们这里的上下文,我们有一个时间序列,x1x4,以及一个时变实变量,f1f4。因此,对于max_encoder_length=3min_decoder_length=1,我们将TimeSeriesDataset制作张量,如图15.5中的步骤1所示。

现在,对于每个时间步,我们有时变变量f和历史x,在encoder_cont中。时变变量f是一个我们也知道未来值的变量,因此对该变量没有因果约束。这意味着为了预测时间步t,我们可以使用ft,因为它是已知的。然而,目标变量的历史则不是。我们不知道未来,因为这正是我们要预测的量。这意味着对x存在因果约束,因此我们不能使用xt来预测时间步t。但目前张量的形成方式,fx在时间步上是对齐的,如果我们将它们传递给模型,我们本质上是在作弊,因为我们会使用xt来预测时间步t。理想情况下,历史x和时变特征f之间应该有一个偏移,使得在时间步t,模型看到xt-1,然后看到ft,最后预测xt

为实现这一点,我们执行以下操作:

  1. 拼接encoder_contdecoder_cont,因为我们需要使用f4来预测时间步t = 4(如图15.5中的步骤2所示)。
  2. 将目标历史x向前移动一个时间步,使得ftxt-1对齐(如图15.5中的步骤3所示)。
  3. 删除第一个时间步,因为我们没有与第一个时间步对应的历史(如图15.5中的步骤4所示)。

这正是我们需要在我们定义的新模型中的forward方法中实现的,同样在DynamicFeatureRNNModel中也是如此:

def forward(self, x: Dict):
    # Step 2 in Figure 15.5
    x_cont = torch.cat([x["encoder_cont"],x["decoder_cont"]], dim=1)
    # Step 3 in Figure 15.5
    x_cont[:,:,-1] = torch.roll(x_cont[:,:,-1], 1, dims=1)
    x = x_cont
    # Step 4 in Figure 15.5
    x = x[:,1:,:] # x -> (batch_size, seq_len, input_size)
    # Processing through the RNN
    x, _ = self.rnn(x)  # --> (batch_size, seq_len, hidden_size)
    # Using a FC layer on last hidden state
    x = self.fc(x[:,-1,:])  # --> (batch_size, seq_len, 1)
    return x

现在,让我们训练这个新模型,看看它的表现如何。具体代码在笔记本中,与之前完全相同:

图15.6 – 使用时变特征的聚合结果

图15.6: 使用时变特征的聚合结果

看起来将温度作为特征确实让模型略有改善,但仍有很长的路要走。别担心;我们还有其他特征可以使用。

使用静态/元信息

有些特征如Acorn分组、是否启用动态定价等,是针对特定家庭的,这将帮助模型学习这些群体特有的模式。很自然,包含这些信息在直觉上是合理的。

然而,正如我们在第10章全局预测模型中所讨论的,分类特征与机器学习模型配合不佳,因为它们不是数值型的。在该章中,我们讨论了几种将分类特征编码为数值表示的方法。我们也可以在深度学习模型中使用这些方法。但有一种处理分类特征的方式是深度学习模型独有的——嵌入向量

独热编码及其为何不理想

将分类特征转换为数值表示的方法之一是独热编码。它将分类特征编码到更高维度,将各个分类值在该空间中放置得等距。它对分类值进行编码所需的维度大小等于分类变量的基数。有关独热编码的更详细讨论,请参阅第10章全局预测模型

对分类特征进行独热编码后得到的表示,我们称之为稀疏表示。如果分类特征的基数(唯一值数量)是C,那么表示该分类特征每个值的行将有C - 1个零。因此,这种表示主要由零组成,故称为稀疏表示。这导致有效编码一个分类特征所需的总体维度等于该向量的基数。因此,对一个有5000个唯一值的分类特征进行独热编码,会立即给问题增加5000个维度。

除此之外,独热编码也完全是无信息的。它将每个分类值彼此等距放置,完全不考虑这些值之间可能的相似性。例如,如果我们编码一周中的天数,独热编码会将每一天放在完全不同的维度中,使它们彼此等距。但如果我们思考一下,周六和周日应该比其他工作日更接近,因为它们是周末,对吗?这种信息是独热编码无法捕获的。

嵌入向量与密集表示

嵌入向量是一种类似的表示,但它力求给出分类特征的密集表示,而非稀疏表示。我们可以通过使用嵌入层来实现这一点。嵌入层可以看作每个分类值与一个数值向量之间的映射,并且这个向量的维度可以远低于分类特征的基数。唯一剩下的问题是:“我们如何知道为每个分类值选择什么向量?””

好消息是我们不需要(知道),因为嵌入层会与网络的其余部分一起训练。因此,在为某个任务训练模型时,模型本身会找出每个分类值的最佳向量表示。这种方法在自然语言处理中非常流行,其中数千个单词被嵌入到只有200或300维的空间中。在PyTorch中,我们可以通过使用nn.Embedding来实现,这是一个模块,是一个简单的查找表,用于存储固定离散值和大小的嵌入。

初始化时需要两个强制参数:

现在,让我们回到全局建模。首先引入静态类别特征。请注意,我们也包含了时变类别特征,因为现在我们知道如何在深度学习模型中处理类别特征。初始化数据集的代码与之前相同,只是在初始化时增加了以下两个参数:

定义具有类别特征的模型

现在我们已经有了数据集,让我们看看如何在新模型StaticDynamicFeatureRNNModel中定义__init__函数。除了调用父模型(设置标准RNN和全连接层)之外,我们还使用输入embedding_sizes设置嵌入层。embedding_sizes是一个元组列表,每个类别特征对应一个元组(基数和嵌入大小):

def __init__(
    self,
    rnn_type: str,
    input_size: int,
    hidden_size: int,
    num_layers: int,
    bidirectional: bool,
    embedding_sizes = []
):
    super().__init__(rnn_type, input_size, hidden_size, num_layers, bidirectional)
    self.embeddings = torch.nn.ModuleList(
        [torch.nn.Embedding(card, size) for card, size in embedding_sizes]
    )

我们使用nn.ModuleList来存储一个nn.Embedding模块列表,每个类别特征对应一个模块。在初始化此模型时,我们需要将embedding_sizes作为输入。每个类别特征所需的嵌入大小在技术上是一个可以调整的超参数。但有一些经验法则可以帮助你入门。这些经验法则背后的思想是:类别特征的基数越大,编码信息所需的嵌入大小就越大。此外,嵌入大小可以远小于类别特征的基数。我们采用的经验法则如下:

$$min\left(50, round\left(\frac{c + 1}{2}\right)\right)$$

因此,我们使用以下代码创建embedding_sizes元组列表:

# Finding the cardinality using the categorical encoders in the dataset
cardinality = [len(training.categorical_encoders[c].classes_) for c in training.categoricals]
# using the cardinality list to create embedding sizes
embedding_sizes = [
    (x, min(50, (x + 1) // 2))
    for x in cardinality
]

现在,将注意力转向forward方法,它将与之前的模型类似,但增加了一个处理类别特征的部分。我们主要使用嵌入层将类别特征转换为嵌入,并将它们与连续特征拼接:

def forward(self, x: Dict):
    # Using the encoder and decoder sequence
    x_cont = torch.cat([x["encoder_cont"],x["decoder_cont"]], dim=1)
    # Roll target by 1
    x_cont[:,:,-1] = torch.roll(x_cont[:,:,-1], 1, dims=1)
    # Combine the encoder and decoder categoricals
    cat = torch.cat([x["encoder_cat"],x["decoder_cat"]], dim=1)
    # if there are categorical features
    if cat.size(-1)>0:
        # concatenating all the embedding vectors
        x_cat = torch.cat([emb(cat[:,:,i]) for i, emb in enumerate(self.embeddings)], dim=-1)
        # concatenating continuous and categorical
        x = torch.cat([x_cont, x_cat], dim=-1)
    else:
        x = x_cont
    # dropping first timestep
    x = x[:,1:,:] # x --> (batch_size, seq_len, input_size)
    # Processing through the RNN
    x, _ = self.rnn(x)  # --> (batch_size, seq_len, hidden_size)
    # Using a FC layer on last hidden state
    x = self.fc(x[:,-1,:])  # --> (batch_size, seq_len, 1)
    return x

现在,让我们训练这个带有静态特征的新模型,看看它的表现:

图15.7 – 使用静态和时变特征的汇总结果

图15.7:使用静态和时变特征的汇总结果

添加静态变量也改进我们的模型。现在,让我们看看另一种策略,它为模型添加了另一个关键信息。

使用时间序列的尺度

我们在GroupNormlizer中使用了TimeSeriesDataset,通过每个家庭自身的均值和标准差来进行缩放。这样做是因为我们希望使目标具有零均值和单位方差,从而模型不会浪费精力去调整参数来捕捉单个家庭消费的尺度。尽管这是一个好的策略,但这里确实存在一些信息损失。可能存在一些模式是特定于消费较高的家庭,而另一些模式是特定于消费较少的家庭。但现在,它们被混在一起,模型试图学习共同的模式。在这种情况下,这些独特的模式对模型来说就像是噪声,因为没有变量来解释它们。

关键在于,我们移除的尺度中包含着信息,重新加入这些信息将是有益的。那么,如何重新加入呢?当然不是通过包含未缩放的目标准则,这会带来我们一开始试图避免的缺点。一种方法是把尺度信息作为静态实值特征添加到模型中。我们一开始缩放每个家庭时需要记录它们的均值和标准差(因为我们需要它们来进行逆变换以恢复原始目标)。我们只需要确保将它们作为静态实值变量包含进来,这样模型在学习时间序列数据集中的模式时就能访问到尺度信息。

PyTorch Forecasting通过TimeSeriesDataset中一个方便的名为add_target_scales的参数简化了这项工作。如果将其设置为True,那么encoder_contdecoder_cont也将包含各个时间序列的均值和标准差。

我们的现有模型无需任何改变;我们只需在初始化时将这一参数添加到TimeSeriesDataset中,然后使用模型进行训练和预测。让我们看看效果如何:

图15.8 – 使用静态、时变和尺度特征的汇总结果

图15.8:使用静态、时变和尺度特征的汇总结果

尺度信息再次改进了模型。接下来,让我们看看本书中将要介绍的最后一个策略。

平衡采样过程

我们已经看到了一些通过添加新类型特征来改进全局深度学习模型的策略。现在,我们来看看在全局建模上下文中相关的另一个方面。在前面的章节中,当我们讨论全局深度学习模型时,我们提到将序列窗口采样输入模型的过程可以看作两个步骤:

  1. 从一组时间序列中采样一个时间序列。
  2. 从该时间序列中采样一个窗口。

让我们用一个类比来使概念更清晰。想象我们有一个大碗,里面装满了N个球。碗中的每个球代表数据集中的一个时间序列(我们数据集中的一个家庭)。现在,每个球i都有Mi张纸条,代表我们可以从中抽取的所有不同样本窗口。

在默认的批量采样中,我们打开所有球,将所有纸条倒入碗中,并丢弃球。现在,闭上眼睛,我们从碗中取出B张纸条并放在一边。这就是我们从数据集中采样的一个批次。我们没有任何信息区分这些纸条,因此抽取任何一张纸条的概率相等,可以表述为:

$$\frac{1}{\sum_{i=0}^{N} M_i}$$

现在,让我们在类比中添加一些数据相关的信息。我们知道有不同类型的时间序列——不同的长度、不同的消费水平等。我们选取一个方面,即序列长度,作为例子(尽管它也适用于其他方面)。因此,如果我们对时间序列的长度进行离散化,我们会得到不同的区间;我们为每个区间分配一种颜色。于是,现在碗中有C种不同颜色的球,纸条也相应地着色。

在我们当前的采样策略中(将所有的纸条(现在有颜色)倒在一起,随机抽取B张纸条),我们最终会在一个批次中复现碗中的概率分布。不难理解,如果碗中较长的时间序列比较短的多,那么抽取的纸条也会有这种偏差。因此,批次也会偏向于长时间序列。这会导致什么结果呢?

在小批量随机梯度下降中(我们在第11章深度学习简介》中看到过),我们每小批量进行一次梯度更新,并使用这个梯度更新模型参数,从而更接近损失函数的最小值。因此,如果小批量偏向于某种特定类型的样本,那么梯度更新也会偏向于对它们更有效的解决方案。这里可以与不平衡学习进行类比。长时间序列和短时间序列可能具有不同的模式,这种采样不平衡会导致模型学习到对长时间序列有效但对短时间序列不太有效的模式。

可视化数据分布

我们计算了每个家庭的长度(LCLid)并将它们分入10个区间——bin_0对应最短区间,bin_9对应最长的区间:

n_bins= 10
# Calculating the length of each LCLid
counts = train_df.groupby("LCLid")['timestamp'].count()
# Binning the counts and renaming
out, bins = pd.cut(counts, bins=n_bins, retbins=True)
out = out.cat.rename_categories({
    c:f"bin_{i}" for i, c in enumerate(out.cat.categories)
})

让我们可视化原始数据中各区间的分布:

图15.9 – 时间序列长度分布

图15.9: 时间序列长度分布

我们可以看到 bin_5bin_6 是最常见的长度,而 bin_0 是最不常见的。现在,让我们从数据加载器中获取前50个批次,并将它们绘制为堆叠柱状图以检查每个批次中的分布:

图15.10 – 批次分布堆叠柱状图

图15.10: 批次分布堆叠柱状图

我们可以看到,你在 图15.9 中看到的相同分布在批次分布中也被复现,其中 bin_5bin_6 领先。bin_0 几乎不出现,而属于 bin_0 的LCLid将不会被很好地学习。

调整采样过程

现在我们该怎么办?让我们稍微深入一下碗里带有纸条的类比。我们随机抽取一个球,发现结果分布与颜色的原始分布相同。因此,为了在批次中获得更平衡的颜色分布,我们需要以不同的概率抽取不同颜色的纸条。换句话说,我们应该从原始分布中代表性低的颜色中抽取更多,而从占主导地位的颜色中抽取更少。

让我们从另一个角度看看我们是从碗中选择纸条的过程。我们知道每个纸条被选中的概率是相等的。因此,从碗中选择纸条的另一种方法是使用均匀随机数生成器。我们从碗中抽出一张纸条,生成一个介于0和1之间的随机数(p),如果随机数小于0.5(p < 0.5),则选择该纸条。因此,选择或拒绝该纸条的可能性相等。我们继续这个过程直到获得 B 个样本。虽然比之前的程序效率稍低,但这个采样过程紧密地近似于原始程序。这里的优势是我们现在有了一个阈值,可以用它来调整采样以满足我们的需求。阈值较低会使纸条更难以被接受,而阈值较高则使其更容易被接受。

现在我们有了一个可以调整采样过程的阈值,我们需要做的就是为每个纸条找到正确的阈值,以便最终批次能够均匀地代表所有颜色。

换句话说,我们需要为每个LCLid找到并分配正确的权重,使得最终批次中所有长度区间分布均匀。

我们该怎么做?有一个非常简单的策略。我们希望长度区间中样本多的权重较低,样本少的权重较高。我们可以通过取每个区间计数的倒数来获得这种权重。如果一个区间有 C 个LCLid,则该区间的权重为 1/C进一步阅读部分提供了一个链接,您可以在其中阅读有关加权随机抽样及其使用的不同算法的更多信息。

TimeSeriesDataset 有一个内部索引,它是一个 DataFrame,包含了它可以从中抽取的所有样本。我们可以利用它来构建权重数组:

# TimeSeriesDataset stores a df as the index over which it samples
df = training.index.copy()
# Adding a bin column to it to represent the bins we have created
df['bins'] = [f"bin_{i}" for i in np.digitize(df["count"].values, bins)]
# Calculate Weights as inverse counts of the bins
weights = 1/df['bins'].value_counts(normalize=True)
# Assigning the weights back to the df so that we have an array of
# weights in the same shape as the index over which we are going to sample
weights = weights.reset_index().rename(columns={"index":"bins", "bins":"weight"})
df = df.merge(weights, on='bins', how='left')
probabilities = df.weight.values

这种方式确保了 probabilities 数组的长度与 TimeSeriesDataset 采样的内部索引长度一致,这是使用该技术时的强制性要求——每个可能的窗口都应该有一个对应的权重与之关联。

现在有了这个权重,我们可以轻松地将其付诸实践。我们可以使用 PyTorch 中的 WeightedRandomSampler,它正是为此目的而创建的:

from torch.utils.data import WeightedRandomSampler
sampler = WeightedRandomSampler(probabilities, len(probabilities))

使用 WeightedRandomSampler 进行数据加载与可视化

现在,我们可以将这个采样器用于从 TimeSeriesDataset 创建的数据加载器中:

train_dataloader = training.to_dataloader(train=True, batch_size=batch_size, num_workers=0, sampler=sampler)

让我们像之前一样可视化前 50 个批次,看看差异:

图15.11 – 加权随机采样下批次分布的堆叠条形图

图15.11:加权随机采样下批次分布的堆叠条形图

现在,我们可以看到每个批次中箱体的分布更加均匀。接下来,我们来看看使用这个新数据加载器训练模型后的结果:

图15.12 – 使用静态、时变和尺度特征以及批次采样器的聚合结果

图15.12:使用静态、时变和尺度特征以及批次采样器的聚合结果

看起来采样器在除 Forecast Bias 之外的所有指标上都显著提升了模型性能。尽管我们尚未取得比 GFM(ML)(其 MAE 为 0.079581)更好的结果,但已经非常接近了。也许通过一些超参数调优、分区或更强的模型,我们可能更接近那个数字,也可能不会。我们使用自定义采样选项来平衡批次中时间序列的长度。我们可以用同样的技术在其他方面进行平衡,例如消费水平、地区或任何其他相关方面。与机器学习一贯的做法一样,我们需要通过实验来确认任何事情,我们所需要做的就是形成关于问题陈述的假设,并设计实验来验证该假设。

至此,我们又结束了一个注重实践(且计算密集)的章节。恭喜你完成了本章;如有尚未理解透彻之处,请随时回顾并参考。

小结

在之前几章建立了深度学习模型的坚实基础后,我们开始探索深度学习模型背景下全局模型的新范式。我们学习了如何使用 PyTorch Forecasting(一个基于深度学习的开源预测库),并利用功能丰富的 TimeSeriesDataset 来开始开发我们自己的模型。

我们从全局上下文中的一个非常简单的 LSTM 开始,了解了如何将时变信息、静态信息以及单个时间序列的尺度添加到特征中,以改进模型。最后,我们探讨了一种用于小批量的交替采样程序,帮助我们在每个批次中呈现更平衡的问题视角。本章绝非旨在全面列举所有改进预测模型的技术,而是旨在培养正确的思维方式,以便你能够处理自己的模型并使其比以前表现更佳。

既然我们已经对深度学习和全局模型有了坚实的基础,接下来将在下一章中探讨近年来为时间序列预测提出的几种专门的深度学习架构。

延伸阅读

你可以查阅以下资料进行延伸阅读: