Python-现代时间序列预测第二版-三-

32 阅读1小时+

Python 现代时间序列预测第二版(三)

原文:annas-archive.org/md5/22eab741fce9c15dfad894ecf37bdd51

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:全球预测模型

在前面的章节中,我们已经看到如何使用现代机器学习模型解决时间序列预测问题,本质上是替代了传统的模型,如 ARIMA 或指数平滑。然而,直到现在,我们一直将数据集中的不同时间序列(例如伦敦智能电表数据集中的家庭数据)单独分析,这就像传统模型所做的那样。

然而,现在我们将探讨一种不同的建模范式,其中我们使用单一的机器学习模型一起预测多个时间序列。正如本章所学,这种范式在计算和准确性方面都带来了许多好处。

本章将覆盖以下主要内容:

  • 为什么选择全球预测模型?

  • 创建全球预测模型(GFMs)

  • 改善全球预测模型的策略

  • 可解释性

技术要求

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

在使用本章代码之前,您需要运行以下笔记本:

  • 02-Preprocessing_London_Smart_Meter_Dataset.ipynb(第二章)

  • 01-Setting_up_Experiment_Harness.ipynb(第四章)

  • 来自 Chapter06Chapter07 文件夹:

    • 01-Feature_Engineering.ipynb

    • 02-Dealing_with_Non-Stationarity.ipynb

    • 02a-Dealing_with_Non-Stationarity-Train+Val.ipynb

  • 来自 Chapter08 文件夹:

    • 00-Single_Step_Backtesting_Baselines.ipynb

    • 01-Forecasting_with_ML.ipynb

    • 01a-Forecasting_with_ML_for_Test_Dataset.ipynb

    • 02-Forecasting_with_Target_Transformation.ipynb

    • 02a-Forecasting_with_Target_Transformation(Test).ipynb

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

为什么选择全球预测模型?

我们在第五章中简要讨论了全球模型,时间序列预测作为回归,并提到了相关数据集。我们可以想到很多场景,在这些场景中我们会遇到相关的时间序列。例如,我们可能需要预测零售商所有产品的销售量,城市不同地区出租车服务的请求数量,或者某个特定区域所有家庭的能源消耗(这正是伦敦智能电表数据集的用途)。我们称这些为相关时间序列,因为数据集中的所有不同时间序列可能具有许多共同的因素。例如,零售产品可能会出现的年度季节性现象可能会出现在大部分产品上,或者温度等外部因素对能源消耗的影响可能对大量家庭相似。因此,不管是以何种方式,相关时间序列数据集中的不同时间序列之间共享一些特征。

传统上,我们通常认为每个时间序列是独立的时间序列;换句话说,每个时间序列被假设是由不同的数据生成过程生成的。像 ARIMA 和指数平滑等经典模型是针对每个时间序列进行训练的。然而,我们也可以认为数据集中的所有时间序列是由单一的数据生成过程生成的,随之而来的是一种建模方法,即训练一个单一的模型来预测数据集中的所有时间序列。后者就是我们所称的全球预测模型GFMs)。GFMs是旨在处理多个相关时间序列的模型,允许这些时间序列之间进行共享学习。相比之下,传统方法被称为局部预测模型LFMs)。

尽管我们在第五章中简要讨论了 LFMs 的缺点,时间序列预测作为回归,但我们可以以更具体的方式总结这些缺点,看看为什么 GFMs 有助于克服这些问题。

样本大小

在大多数实际应用中(尤其是在商业预测中),我们需要预测的时间序列并不长。采用完全以数据为驱动的建模方法来处理这样一个较短的时间序列是有问题的。使用少量数据点训练一个高度灵活的模型会导致模型记住训练数据,从而出现过拟合。

传统上,这个问题通过在我们用于预测的模型中加入强先验或归纳偏差来克服。归纳偏差大致指的是一组假设或限制,这些假设或限制被内置到模型中,应该帮助模型预测在训练过程中没有遇到的特征组合。例如,双指数平滑法对季节性和趋势有强烈的假设。该模型不允许从数据中学习任何其他更复杂的模式。因此,使用这些强假设,我们将模型的搜索范围限制在假设空间的一个小部分。虽然在数据较少的情况下这有助于提高准确性,但其反面是这些假设可能限制了模型的准确性。

最近在机器学习领域的进展无疑向我们展示了,使用数据驱动的方法(假设或先验较少)在大规模训练集上能训练出更好的模型。然而,传统的统计学观点告诉我们,数据点的数量需要至少是我们尝试从这些数据点中学习的参数数量的 10 到 100 倍。

因此,如果我们坚持使用 LFMs(局部因果模型),完全数据驱动的方法能被采纳的场景将非常少见。这正是 GFMs 的优势所在。GFM 能够利用数据集中所有时间序列的历史数据来训练模型,并学习一组适用于数据集中所有时间序列的参数。借用在第五章《时间序列预测作为回归》中引入的术语,我们增加了数据集的宽度,而保持长度不变(参见图 5.2)。这种为单一模型提供的大量历史信息让我们可以对时间序列数据集采用完全数据驱动的技术。

跨领域学习

GFMs(广义因果模型)从设计上促进了数据集中不同时间序列之间的跨领域学习。假设我们有一个相对较新的时间序列,且其历史数据不足以有效地训练模型——例如,新推出的零售产品的销售数据或某地区新家庭的电力消费数据。如果我们将这些时间序列单独考虑,可能需要一段时间才能从我们训练的模型中得到合理的预测,但 GFMs 通过跨领域学习使得这个过程变得更加简单。GFMs 具有不同时间序列之间的隐性相似性,它们能够利用在历史数据丰富的类似时间序列中观察到的模式,来为新的时间序列生成预测。

交叉学习的另一种帮助方式是,在估算共同参数(如季节性)时,它起到一种正则化作用。例如,在零售场景中,类似产品所表现出的季节性最好在汇总层面进行估算,因为每个单独的时间序列都可能会有一些噪声,这些噪声可能会影响季节性提取。通过在多个产品之间强制统一季节性,我们实际上是在正则化季节性估算,并且在这个过程中,使季节性估算更加稳健。GFMs 的优点在于,它们采用数据驱动的方法来定义哪些产品的季节性应该一起估算,哪些产品有不同的季节性模式。如果不同产品之间有不同的季节性模式,GFM 可能难以将它们一起建模。然而,当提供足够的区分不同产品的信息时,GFM 也能学会这种差异。

多任务学习

GFMs 可以视为多任务学习范式,其中一个模型被训练用来学习多个任务(因为预测每个时间序列是一个独立的任务)。多任务学习是一个活跃的研究领域,使用多任务模型有许多好处:

  • 当模型从嘈杂的高维数据中学习时,模型区分有用特征和无用特征的难度加大。当我们在多任务框架下训练模型时,模型可以通过观察对其他任务也有用的特征,理解有用特征,从而为模型提供额外的视角来识别有用特征。

  • 有时候,像季节性这样的特征可能很难从特别嘈杂的时间序列中学习。然而,在多任务框架下,模型可以利用数据集中的其他时间序列来学习这些困难的特征。

  • 最后,多任务学习引入了一种正则化方法,它迫使模型找到一个在所有任务上都表现良好的模型,从而减少过拟合的风险。

工程复杂度

对于大规模数据集,LFMs 也带来了工程上的挑战。如果我们需要预测数千个甚至数百万个时间序列,训练和管理这些 LFMs 的生命周期变得越来越困难。在第八章使用机器学习模型预测时间序列中,我们仅对数据集中一部分家庭进行了 LFM 训练。我们花了大约 20 到 30 分钟来训练 150 个家庭的机器学习模型,并且使用的是默认的超参数。在常规的机器学习工作流程中,我们需要训练多个机器学习模型并进行超参数调优,以找到最佳的模型配置。然而,对于数据集中的成千上万的时间序列执行所有这些步骤,变得越来越复杂和耗时。

同样,这就涉及到如何管理这些模型的生命周期。所有这些单独的模型都需要部署到生产环境中,需要监控它们的表现以检查模型和数据的漂移,并且需要在设定的频率下重新训练。随着我们需要预测的时间序列数量越来越多,这变得愈加复杂。

然而,通过转向 GFM 范式,我们大大减少了在模型整个生命周期中训练和管理机器学习模型所需的时间和精力。正如我们将在本章中看到的,在这些 150 个家庭上训练 GFM 的时间只是训练 LFM 所需时间的一小部分。

尽管 GFMs 有许多优点,但它们也并非没有缺点。主要的缺点是我们假设数据集中的所有时间序列都是由单一的数据生成过程DGP)生成的。这可能并不是一个有效的假设,这可能导致 GFM 无法拟合数据集中某些特定类型的时间序列模式,这些模式在数据集中出现得较少。

另一个未解的问题是,GFM 是否适用于处理无关的任务或时间序列。这个问题仍在争论中,但 Montero-Manso 等人证明了使用 GFM 对无关时间序列建模也可以带来收益。Oreshkin 等人从另一个角度提出了相同的发现,他们在 M4 数据集(一个无关的数据集)上训练了一个全局模型,并取得了最先进的表现。他们将这一成果归因于模型的元学习能力。

话虽如此,相关性确实有助于 GFM,因为这样学习任务变得更简单。我们将在本章的后续部分看到这一点的实际应用。

从更大的角度来看,我们从 GFM 范式中获得的好处远大于其缺点。在大多数任务中,GFMs 的表现与局部模型相当,甚至更好。Montero-Manso 等人也从理论上证明了,在最坏的情况下,GFM 学到的函数与局部模型相同。我们将在接下来的部分中清楚地看到这一点。最后,随着你转向 GFM 范式,训练时间和工程复杂性都会大幅降低。

现在我们已经解释了为什么 GFM 是一个值得采用的范式,让我们看看如何训练一个 GFM。

创建 GFMs

训练 GFM 非常简单。在第八章《使用机器学习模型进行时间序列预测》中,我们训练 LFM 时,是在伦敦智能电表数据集中循环处理不同家庭,并为每个家庭训练一个模型。然而,如果我们将所有家庭的数据放入一个单一的数据框(我们的数据集本来就是这样的),并在其上训练一个单一的模型,我们就得到了一个 GFM。需要记住的一点是,确保数据集中的所有时间序列具有相同的频率。换句话说,如果我们在训练这些模型时将日常时间序列与每周时间序列混合,性能下降是显而易见的——尤其是在使用时间变化特征和其他基于时间的信息时。对于纯自回归模型来说,以这种方式混合时间序列问题要小得多。

笔记本提醒:

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

我们在第八章《使用机器学习模型进行时间序列预测》中开发的标准框架足够通用,也适用于 GFM。因此,正如我们在该章节中所做的,我们在01-Global_Forecasting_Models-ML.ipynb笔记本中定义了FeatureConfigMissingValueConfig。我们还稍微调整了 Python 函数,用于训练和评估机器学习模型,使其适用于所有家庭。详细信息和确切的函数可以在该笔记本中找到。

现在,代替循环处理不同的家庭,我们将整个训练数据集输入到get_X_y函数中:

# Define the ModelConfig
from lightgbm import LGBMRegressor
model_config = ModelConfig(
    model=LGBMRegressor(random_state=42),
    name="Global LightGBM Baseline",
    # LGBM is not sensitive to normalized data
    normalize=False,
    # LGBM can handle missing values
    fill_missing=False,
)
# Get train and test data
train_features, train_target, train_original_target = feat_config.get_X_y(
    train_df, categorical=True, exogenous=False
)
test_features, test_target, test_original_target = feat_config.get_X_y(
    test_df, categorical=True, exogenous=False
) 

现在我们已经有了数据,接下来需要训练模型。训练模型也和我们在第八章《使用机器学习模型进行时间序列预测》中看到的一模一样。我们只需选择 LightGBM,这是表现最佳的 LFM 模型,并使用之前定义的函数来训练模型并评估结果:

y_pred, feat_df = train_model(
        model_config,
        _feat_config,
        missing_value_config,
        train_features,
        train_target,
        test_features,
    )
agg_metrics, eval_metrics_df = evaluate_forecast(
    y_pred, test_target, train_target, model_config
) 

现在,在y_pred中,我们将获得所有家庭的预测值,而feat_df将包含特征重要性。agg_metrics将包含所有选定家庭的汇总指标。

让我们来看一下我们的 GFM 模型表现如何:

图 10.1 – 基准 GFM 的汇总指标

图 10.1:基准 GFM 的汇总指标

就指标而言,我们的表现不如最佳的 LFM(第一行)。然而,有一点我们应该注意的是训练模型所花费的时间——大约 30 秒。所有选定家庭的 LFM 训练时间大约需要 30 分钟。这个时间的大幅减少为我们提供了更多的灵活性,可以更快速地迭代不同的特征和技术。

话虽如此,让我们现在看看一些可以提高 GFM 准确性的技术。

改善 GFM 的策略

GFMs 在许多 Kaggle 和其他预测竞赛中被使用。它们已经经过实证检验,尽管很少有研究从理论角度探讨它们为何表现如此出色。Montero-Manso 和 Hyndman(2020)有一篇工作论文,题为《时间序列分组预测的原理与算法:局部性与全局性》,该论文对 GFMs 以及数据科学社区集体开发的许多技术进行了深入的理论和实证研究。在本节中,我们将尝试提出改进 GFMs 的策略,并尽可能地给出理论依据,解释为何这些策略有效。

参考检查:

Montero-Manso 和 Hyndman(2020)的研究论文在参考文献中作为参考文献1被引用。

在论文中,Montero-Manso 和 Hyndman 利用机器学习中关于泛化误差的基本结果进行理论分析,值得花些时间理解这个概念,至少在高层次上是这样。泛化误差,我们知道,是样本外误差与样本内误差之间的差异。Yaser S Abu-Mostafa 有一个免费的在线大规模开放在线课程MOOC)和一本相关的书(两者都可以在进一步阅读部分找到)。这是一个关于机器学习的简短课程,我推荐给任何希望在机器学习领域建立更强理论和概念基础的人。课程和书籍提出的一个重要概念是使用概率理论中的 Hoeffding 不等式来推导学习问题的界限。让我们快速看一下这个结果,以加深理解:

它的概率至少为 1**-.**

E[in]是样本内平均误差,E[out]是预期的样本外误差。N是我们从中学习的数据集的总样本数,H是模型的假设类。它是一个有限的函数集合,可能适配数据。H的大小,记作|H|,表示H的复杂度。虽然这个界限的公式看起来让人害怕,但让我们简化一下它的表达方式,以便发展我们对它的必要理解。

我们希望E[out]尽可能接近E[in],为此,我们需要使平方根中的项尽可能小。平方根下有两个项在我们的控制之中,可以这么说——N和|H|。因此,为了使泛化误差(E[in] - E[out])尽可能小,我们要么需要增加N(拥有更多数据),要么需要减小|H|(采用更简单的模型)。这是一个适用于所有机器学习的结果,但 Montero-Manso 和 Hyndman 在一些假设条件下,将这一结果也适用于时间序列模型。他们使用这一结果为他们工作论文中的论点提供了理论支持。

Montero-Manso 和 Hyndman 将 Hoeffding 不等式应用于 LFM 和 GFM 进行比较。我们可以在这里看到结果(有关完整的数学和统计理解,请参见参考文献中的原始论文):

分别是使用局部方法和全局方法时所有时间序列的平均样本内误差。 分别是局部方法和全局方法下的样本外期望值。H[i] 是第 i 个时间序列的假设类,J 是全局方法的假设类(全局方法只拟合一个函数,因此只有一个假设类)。

其中一个有趣的结果是,LFM 的复杂性项()会随着数据集大小的增长而增加。数据集中时间序列的数量越多,复杂性越高,泛化误差越大;而对于 GFM,复杂性项(log(|J|))保持不变。因此,对于中等大小的数据集,LFM(如指数平滑法)的整体复杂性可能远高于单一的 GFM,无论 GFM 如何复杂。作为一个推论,我们还可以认为,在可用数据集(NK)的情况下,我们可以训练一个具有更高复杂度的模型,而不仅仅是 LFM 的模型。增加模型复杂度有很多方法,我们将在下一节中看到。

现在,让我们回到我们正在训练的 GFM。我们看到,当我们将训练的 GFM 与最好的 LFM(LightGBM)进行比较时,GFM 的性能未达到预期,但它比基准和我们尝试的其他模型更好,因此,我们一开始就知道我们训练的 GFM 还不算差。现在,让我们来看一些提高模型性能的方法。

增加记忆

正如我们在第五章时间序列预测作为回归》中讨论的那样,本书讨论的机器学习模型是有限记忆模型或马尔可夫模型。像指数平滑法这样的模型在预测时会考虑时间序列的整个历史,而我们讨论的任何机器学习模型仅使用有限的记忆来进行预测。在有限记忆模型中,允许模型访问的记忆量被称为记忆大小(M)或自回归阶数(经济计量学中的概念)。

为模型提供更大的记忆量会增加模型的复杂性。因此,提高 GFM 性能的一个方法是增加模型可访问的记忆量。增加记忆量有很多方式。

增加更多的滞后特征

如果你之前接触过 ARIMA 模型,你会知道自回归AR)项通常使用得很少。我们通常看到的 AR 模型滞后项为个位数。虽然没有什么可以阻止我们使用更大的滞后项来运行 ARIMA 模型,但由于我们是在 LFM 范式下运行 ARIMA,模型必须使用有限的数据来学习所有滞后项的参数,因此,在实践中,实践者通常选择较小的滞后项。然而,当我们转向 GFM 时,我们可以承受使用更大的滞后项。Montero-Manso 和 Hyndman 经验性地展示了将更多滞后项添加到 GFM 中的好处。对于高度季节性的时间序列,观察到了一种特殊现象:随着滞后项的增加,准确性提高,但在滞后项等于季节周期时准确性突然饱和并变差。当滞后项超过季节周期时,准确性则有了巨大的提升。这可能是因为季节性引发的过拟合现象。由于季节性,模型容易偏向季节性滞后项,因为它在样本中表现很好,因此最好在季节周期的加号一侧再增加一些滞后项。

添加滚动特征

增加模型记忆的另一种方法是将滚动平均值作为特征。滚动平均值通过描述性统计(如均值或最大值)对来自较大记忆窗口的信息进行编码。这是一种有效的包含记忆的方式,因为我们可以采用非常大的窗口来进行记忆,并将这些信息作为单一特征包含在模型中。

添加 EWMA 特征

指数加权移动平均EWMA)是一种在有限记忆模型中引入无限记忆的方法。EWMA 本质上计算整个历史的平均值,但根据我们设置的加权。因此,通过不同的值,我们可以获得不同种类的记忆,这些记忆又被编码为单一特征。包括不同的 EWMA 特征也在经验上证明是有益的。

我们已经在特征工程中包含了这些类型的特征(第六章时间序列预测的特征工程),它们也是我们训练的基线 GFM 的一部分,因此让我们继续进行下一种提高 GFM 准确性的策略。

使用时间序列元特征

我们在*创建全球预测模型(GFM)*部分中训练的基线 GFM 模型包含滞后特征、滚动特征和 EWMA 特征,但我们并没有提供任何帮助模型区分数据集中不同时间序列的特征。基线 GFM 模型学到的是一个通用的函数,该函数在给定特征的情况下生成预测。对于所有时间序列非常相似的同质数据集,这可能工作得足够好,但对于异质数据集,模型能够区分每个时间序列的信息就变得非常有用。

因此,关于时间序列本身的信息就是我们所说的元特征。在零售环境中,这些元特征可以是产品 ID、产品类别、商店编号等。在我们的数据集中,我们有像stdorToUAcornAcorn_groupedLCLid这样的特征,它们提供了一些关于时间序列本身的信息。将这些元特征包含在 GFM 中将提高模型的性能。

然而,有一个问题——往往这些元特征是类别型的。当特征中的值只能取离散的值时,该特征就是类别型的。例如,Acorn_grouped只能有三个值之一——AffluentComfortableAdversity。大多数机器学习模型处理类别型特征的效果不好。Python 生态系统中最流行的机器学习库 scikit-learn 中的所有模型都完全不支持类别型特征。为了将类别型特征纳入机器学习模型,我们需要将它们编码成数值形式,并且有许多方法可以编码类别列。让我们回顾几种常见的选项。

有序编码和独热编码

编码类别特征的最常见方法是有序编码和独热编码,但它们并不总是最好的选择。让我们快速回顾一下这些技术是什么,以及何时适用它们。

有序编码是其中最简单的一种。我们只需为类别的唯一值分配一个数值代码,然后用数值代码替换类别值。为了编码我们数据集中的Acorn_grouped特征,我们所需要做的就是分配代码,比如为Affluent分配1,为Comfortable分配2,为Adversity分配3,然后将所有类别值替换为我们分配的代码。虽然这非常简单,但这种编码方法会引入我们可能并不打算赋予类别值的含义。当我们分配数值代码时,我们隐含地表示,类别值为2的特征比类别值为1的特征更好。这种编码方式只适用于有序特征(即类别值在意义上有固有排序的特征),并且应该谨慎使用。我们还可以从距离的角度考虑这个问题。当我们进行有序编码时,ComfortableAffluent之间的距离可能比ComfortableAdversity之间的距离更大,这取决于我们如何编码。

一热编码是一种更好的表示没有顺序意义的类别特征的方法。它本质上将类别特征编码到一个更高维度的空间,将类别值在该空间中等距离地分布。编码类别值所需的维度大小等于类别变量的基数。基数是类别特征中唯一值的数量。让我们看看如何在一热编码方案中编码示例数据:

图 10.2 – 类别特征的一热编码

图 10.2:类别特征的一热编码

我们可以看到,结果编码将为类别特征中的每个唯一值设置一列,且该列中的值用 1 表示。例如,第一行是 舒适,因此,除 舒适 列之外的每一列都会是 0,而 舒适 列会是 1。如果我们计算任何两个类别值之间的欧几里得距离,我们可以看到它们是相同的。

然而,这种编码存在三个主要问题,所有这些问题在高基数类别变量中都会变得更加严重:

  • 嵌入本质上是稀疏的,许多机器学习模型(例如基于树的模型和神经网络)在处理稀疏数据时表现不佳(稀疏数据是指数据中大多数值为零)。当基数只有 5 或 10 时,引入的稀疏性可能不是太大问题,但当我们考虑基数为 100 或 500 时,编码变得非常稀疏。

  • 另一个问题是问题维度的爆炸。当我们由于一热编码生成大量新特征而增加问题的总特征数量时,问题变得更难以解决。这可以通过维度灾难来解释。进一步阅读部分有一个链接,提供了更多关于维度灾难的信息。

  • 最后一个问题与实际操作有关。对于一个大型数据集,如果我们对具有数百或数千个唯一值的类别值进行一热编码,生成的数据框将难以使用,因为它将无法适应计算机内存。

还有一种稍微不同的一热编码方法,其中我们会丢弃其中一个维度,这称为虚拟变量编码。这样做的额外好处是使得编码线性独立,这反过来带来一些优势,特别是对于普通线性回归。如果你想了解更多内容,进一步阅读部分有一个链接。

由于我们必须编码的类别列具有较高的基数(至少其中有几个),我们将不会执行这种编码。相反,让我们看看一些可以更好处理高基数类别变量的编码技术。

频率编码

频率编码是一种不增加问题维度的编码方案。它接受一个单一的分类数组,并返回一个单一的数字数组。逻辑非常简单——它用该值在训练数据集中出现的次数来替换分类值。虽然它并不完美,但效果相当好,因为它让模型能够基于类别出现的频率来区分不同的类别。

有一个流行的库,category_encoders,它实现了许多不同的编码方案,采用标准的 scikit-learn 样式估算器,我们在实验中也会使用它。我们在第八章:使用机器学习模型预测时间序列中开发的标准框架,也有一些我们没有使用的功能——encode_categoricalcategorical_encoder

所以,让我们现在使用它们并训练我们的模型:

from category_encoders import CountEncoder
from lightgbm import LGBMRegressor
#Define which columns names are categorical features
cat_encoder = CountEncoder(cols=cat_features)
model_config = ModelConfig(
    model=LGBMRegressor(random_state=42),
    name="Global LightGBM with Meta Features (CountEncoder)",
    # LGBM is not sensitive to normalized data
    normalize=False,
    # LGBM can handle missing values
    fill_missing=False,
    # Turn on categorical encoding
    encode_categorical=True,
    # Pass the categorical encoder to be used
    categorical_encoder=cat_encoder
) 

剩下的过程与我们在*创建全局预测模型(GFM)*部分看到的相同,我们通过编码后的元特征来获取预测结果:

图 10.3 – 使用带元特征的 GFM 的聚合指标(频率编码)

图 10.3:使用带元特征的 GFM 的聚合指标(频率编码)

我们立刻可以看到,虽然误差减少的幅度很小,但还是有减少。我们也可以看到训练时间几乎翻倍了。这可能是因为现在我们除了训练机器学习模型之外,还增加了对分类特征的编码步骤。

频率编码的主要问题是它不适用于数据集中均匀分布的特征。例如,LCLid特征,它是每个家庭的唯一代码,在数据集中是均匀分布的,当我们使用频率编码时,所有的LCLid特征几乎会达到相同的频率,因此机器学习模型几乎会认为它们是相同的。

现在,让我们来看一个稍微不同的方法。

目标均值编码

目标均值编码,在其最基本的形式下,是一个非常简单的概念。它是一种监督式方法,利用训练数据集中的目标来编码分类列。让我们来看一个例子:

图 10.4 – 目标均值编码

图 10.4:目标均值编码

基础的目标均值编码有一些限制。它增加了过拟合训练数据的可能性,因为我们直接使用了均值目标,从而以某种方式将目标信息泄漏到模型中。该方法的另一个问题是,当类别值分布不均时,可能会有一些类别值的样本量非常小,因此均值估计会变得很嘈杂。将这个问题推向极端,我们会遇到测试数据中出现未知的类别值,这在基础版本中也是不支持的。因此,在实践中,这种简单版本几乎不会被使用,而稍微复杂一点的变种广泛应用,并且是编码类别特征的有效策略。

category_encoders中,存在许多这种概念的变种,但这里我们来看看其中两种流行且有效的版本。

在 2001 年,Daniele Micci-Barreca 提出了一种均值编码的变体。如果我们将目标视为一个二进制变量,例如 1 和 0,则均值(即 1 的数量或样本的数量)也可以看作是 1 的概率。基于这个均值的解释,Daniele 提出将先验概率和后验概率融合,作为类别特征的最终编码。

参考检查:

Daniele Micci-Barreca 的研究论文在参考文献中被引用为参考文献2

先验概率定义如下:

这里,n[y]是target = 1 的案例数,而n[TR]是训练数据中样本的数量。

后验概率对于类别i的定义如下:

这里,n[iY]是数据集中category = iY = 1的样本数量,而n[i]是数据集中category = i的样本数量。

现在,类别i的最终编码如下:

这里,是加权因子,它是一个关于n[i]的单调递增函数,且其值被限制在 0 和 1 之间。所以,当样本数量增加时,这个函数会给后验概率更大的权重。

将其调整到回归设置中,概率变为期望值,因此公式变为以下形式:

这里,TR[i]是所有category = 1 的行,而TR[i]中Y的总和。是训练数据集中所有行的Y的总和。与二进制变量类似,我们混合了category = i时的Y的期望值(E[Y|category = i])和Y的期望值(E[Y]),得到最终的类别编码。

我们可以使用许多函数来处理。Daniele 提到了一种非常常见的函数形式(sigmoid):

在这里,n[i]是数据集中样本数量,其中category = ikf是可调的超参数。k决定了我们完全信任估计值的最小样本量的一半。如果k = 1,我们所说的是我们信任来自只有两个样本的类别的后验估计值。f决定了 sigmoid 在两个极值之间的过渡速度。当f趋近于无穷大时,过渡变成了先验概率和后验概率之间的硬阈值。category_encoders中的TargetEncoder实现了这一点!k参数被称为min_samples_leaf,默认值为 1,而f参数被称为smoothing,默认值为 1。让我们看看这种编码在我们的任务中如何工作。在我们正在使用的框架中,使用不同的编码器只需将不同的cat_encoder(已初始化的分类编码器)传递给ModelConfig

from category_encoders import TargetEncoder
cat_encoder = TargetEncoder(cols=cat_features) 

其余代码完全相同。我们可以在相应的笔记本中找到完整的代码。让我们看看新编码的效果如何:

图 10.5 – 使用元特征的 GFM 聚合指标(目标编码)

图 10.5:使用元特征的 GFM 聚合指标(目标编码)

结果并不是很好,对吧?与机器学习模型一样,没有免费午餐定理NFLT)同样适用于分类编码。没有一种编码方案可以始终表现良好。虽然这与主题直接无关,但如果你想了解更多关于 NFLT 的信息,可以前往进一步阅读部分。

对于所有这些有监督的分类编码技术,如目标均值编码,我们必须非常小心,以避免数据泄露。编码器应使用训练数据来拟合,而不是使用验证或测试数据。另一种非常流行的技术是使用交叉验证生成分类编码,并使用样本外编码来完全避免数据泄露或过拟合。

还有许多其他编码方案,例如MEstimateEncoder(它使用加法平滑,如 ),HashingEncoder等等,均在category_encoders中实现。另一种非常有效的编码分类特征的方法是使用深度学习的嵌入。进一步阅读部分提供了一个关于如何进行这种编码的教程链接。

之前,所有这些分类编码是建模之前的一个单独步骤。现在,让我们来看一种将分类特征作为模型训练的原生处理技术。

LightGBM 对分类特征的本地处理

几种机器学习模型的实现可以原生地处理分类特征,特别是梯度提升模型。CatBoost 和 LightGBM 是最流行的 GBM 实现之一,可以直接处理分类特征。CatBoost 有一种独特的方式将分类特征内部转换为数值特征,类似于加法平滑。进一步阅读部分有关于如何进行这种编码的详细信息。category_encoders 已经实现了这种逻辑,称为 CatBoostEncoder,这样我们也可以为任何机器学习模型使用这种编码方式。

虽然 CatBoost 处理了这种内部转换为数值特征的问题,LightGBM 更本地化地处理分类特征。LightGBM 在生长和分割树时将分类特征视为原样。对于具有 k 个唯一值(k 的基数)的分类特征,有 2^k^(-1)-1 种可能的分区。这很快变得难以处理,但是对于回归树,Walter D. Fisher 在 1958 年提出了一种技术,使得找到最优分割的复杂性大大降低。该方法的核心是使用每个分类值的平均目标统计数据进行排序,然后在排序后的分类值中找到最优的分割点。

参考检查:

Fisher 的研究论文被引用在参考文献中,作为第3条参考文献。

LightGBM 的 scikit-learn API 支持这一功能,可以在 fit 过程中传入一个参数 categorical_feature,其中包含分类特征的名称列表。我们可以在我们在 第八章 中定义的 MLModelfit 中使用 fit_kwargs 参数来传递这个参数。让我们看看如何做到这一点:

from lightgbm import LGBMRegressor
model_config = ModelConfig(
    model=LGBMRegressor(random_state=42),
    name="Global LightGBM with Meta Features (NativeLGBM)",
    # LGBM is not sensitive to normalized data
    normalize=False,
    # LGBM can handle missing values
    fill_missing=False,
    # We are using inbuilt categorical feature handling
    encode_categorical=False,
)
# Training the model and passing in fit_kwargs
y_pred, feat_df = train_model(
    model_config,
    _feat_config,
    missing_value_config,
    train_features,
    train_target,
    test_features,
    fit_kwargs=dict(categorical_feature=cat_features),
) 

y_pred 包含了预测结果,我们按照惯例进行评估。让我们也看看结果:

图 10.6 – 使用 GFM 和元特征的聚合指标(原生 LightGBM)

图 10.6: 使用 GFM 和元特征的聚合指标(原生 LightGBM)

我们可以观察到在原生处理分类特征时,MAEmeanMASE 有显著的降低。我们还可以看到总体训练时间的缩短,因为不需要单独的步骤来编码分类特征。经验上,原生处理分类特征大多数情况下效果更好。

现在我们已经对分类特征进行了编码,让我们看看另一种提高准确率的方法。

调整超参数

超参数是控制机器学习模型如何训练的设置,但不是从数据中学习的。相比之下,模型参数是在训练过程中从数据中学习的。例如,在梯度提升决策树GBDT)中,模型参数是每棵树中的决策阈值,从数据中学习得出。超参数,如树的数量学习率树的深度,在训练前设定,并控制模型的结构及其学习方式。虽然参数是基于数据调整的,但超参数必须外部调优。

尽管超参数调优在机器学习中是常见的做法,但由于在 LFM 范式下我们有大量模型,过去我们无法进行调优。现在,我们有一个可以在 30 秒内完成训练的 GFM,超参数调优变得可行。从理论角度来看,我们还看到,GFM 可以承受更大的复杂度,因此能够评估更多的函数,选择最佳的而不会发生过拟合。

数学优化被定义为根据某些标准从一组可用的备选方案中选择最佳元素。在大多数情况下,这涉及从一组备选方案(搜索空间)中找到某个函数(目标函数)的最大值或最小值,并满足一些条件(约束)。搜索空间可以是离散变量、连续变量,或两者的混合,目标函数可以是可微的或不可微的。针对这些变种,已有大量研究。

你可能会想,为什么我们现在要讨论数学优化?超参数调优是一个数学优化问题。这里的目标函数是不可微的,并返回我们优化的度量——例如,平均绝对误差MAE)。

搜索空间包括我们要调优的不同超参数——比如,树的数量或树的深度。它可能是连续变量和离散变量的混合,约束条件是我们对搜索空间施加的任何限制——例如,某个特定的超参数不能为负,或者某些超参数的特定组合不能出现。因此,了解数学优化中使用的术语将有助于我们的讨论。

尽管超参数调优是一个标准的机器学习概念,我们将简要回顾三种主要的技术(除了手动的反复试错法)来进行超参数调优。

网格搜索

网格搜索可以被看作是一种暴力方法,在这种方法中,我们定义一个离散的网格覆盖搜索空间,在网格中的每个点上检查目标函数,并选择网格中最优的点。网格是为我们选择调优的每个超参数定义的一组离散点。一旦网格定义完成,所有网格交点都会被评估,以寻找最优的目标值。如果我们要调优 5 个超参数,并且每个参数的网格有 20 个离散值,那么网格搜索的总试验次数将是 3,200,000 次(20⁵)。这意味着要训练模型 3.2 百万次并对其进行评估。这会成为一个相当大的限制,因为大多数现代机器学习模型有许多超参数。例如,LightGBM 有超过 100 个超参数,其中至少 20 个超参数在调优时具有较大的影响力。因此,使用像网格搜索这样的暴力方法迫使我们将搜索空间限制得很小,以便在合理的时间内完成调优。

对于我们的案例,我们通过将搜索空间限制得非常小,定义了一个只有 27 次试验的小网格。让我们看看我们是如何做到的:

from sklearn.model_selection import ParameterGrid
grid_params = {
    "num_leaves": [16, 31, 63],
    "objective": ["regression", "regression_l1", "huber"],
    "random_state": [42],
    "colsample_bytree": [0.5, 0.8, 1.0],
}
parameter_space = list(ParameterGrid(grid_params)) 

我们只调优三个超参数(num_leavesobjectivecolsample_bytree),每个参数只有三个选项。在这种情况下,执行网格搜索就相当于遍历参数空间,并在每个超参数组合下评估模型:

scores = []
for p in tqdm(parameter_space, desc="Performing Grid Search"):
    _model_config = ModelConfig(
        model=LGBMRegressor(**p, verbose=-1),
        name="Global Meta LightGBM Tuning",
        # LGBM is not sensitive to normalized data
        normalize=False,
        # LGBM can handle missing values
        fill_missing=False,
    )
    y_pred, feat_df = train_model(
        _model_config,
        _feat_config,
        missing_value_config,
        train_features,
        train_target,
        test_features,
        fit_kwargs=dict(categorical_feature=cat_features),
    )
    scores.append(ts_utils.mae(
                test_target['energy_consumption'], y_pred
            )) 

这个过程大约需要 15 分钟完成,并且给我们带来了最好的 MAE 值 0.73454,这相比未调优的 GFM 已经是一个很大的改进。

然而,这使我们想知道是否有一个更好的解决方案,是我们在定义的网格中没有涵盖的。一个选择是扩展网格并再次运行网格搜索。这会指数级增加试验次数,并很快变得不可行。

让我们看看另一种方法,我们可以在相同数量的试验下探索更大的搜索空间。

随机搜索

随机搜索采取了稍微不同的路线。在随机搜索中,我们同样定义搜索空间,但不是离散地定义空间中的具体点,而是定义我们希望探索的范围上的概率分布。这些概率分布可以是均匀分布(表示范围内的每个点出现的概率相同),也可以是高斯分布(在中间有一个熟悉的峰值),或者任何其他特殊的分布,如伽马分布或贝塔分布。只要我们能够从分布中抽样,就可以使用它进行随机搜索。一旦我们定义了搜索空间,就可以从分布中抽取点并评估每个点,找到最佳超参数。

对于网格搜索,试验次数是定义搜索空间的函数,而对于随机搜索,试验次数是用户输入的参数,因此我们可以决定用于超参数调优的时间或计算预算,因此我们也可以在更大的搜索空间中进行搜索。

有了这种新的灵活性,让我们为我们的问题定义一个更大的搜索空间,并使用随机搜索:

import scipy
from sklearn.model_selection import ParameterSampler
random_search_params = {
    # A uniform distribution between 10 and 100, but only integers
    "num_leaves": scipy.stats.randint(10,100),
    # A list of categorical string values
    "objective": ["regression", "regression_l1", "huber"],
    "random_state": [42],
    # List of floating point numbers between 0.3 and 1.0 with a resolution of 0.05
    "colsample_bytree": np.arange(0.3,1.0,0.05),
    # List of floating point numbers between 0 and 10 with a resolution of 0.1
    "lambda_l1":np.arange(0,10,0.1),
    # List of floating point numbers between 0 and 10 with a resolution of 0.1
    "lambda_l2":np.arange(0,10,0.1)
}
# Sampling from the search space number of iterations times
parameter_space = list(ParameterSampler(random_search_params, n_iter=27, random_state=42)) 

这个过程大约运行了 15 分钟,但我们探索了更大的搜索空间。然而,报告的最佳 MAE 值仅为0.73752,低于网格搜索的结果。也许如果我们运行更多次迭代,可能会得到更好的分数,但那只是一个瞎猜。具有讽刺意味的是,这实际上也是随机搜索所做的。它闭上眼睛,随意在飞镖靶上投掷飞镖,希望它能击中靶心。

数学优化中有两个术语,分别是探索(exploration)和利用(exploitation)。探索确保优化算法能够到达搜索空间的不同区域,而利用则确保我们在获得更好结果的区域进行更多的搜索。随机搜索完全是探索性的,它在评估不同的试验时并不关心发生了什么。

让我们看一下最后一种技术,它尝试在探索与利用之间找到平衡。

贝叶斯优化

贝叶斯优化与随机搜索有许多相似之处。两者都将搜索空间定义为概率分布,而且在这两种技术中,用户决定需要评估多少次试验,但它们的关键区别是贝叶斯优化的主要优势。随机搜索是从搜索空间中随机采样,而贝叶斯优化则是智能地进行采样。贝叶斯优化知道它的过去试验以及从这些试验中得到的目标值,这样它就可以调整未来的试验,利用那些曾经得到更好目标值的区域。从高层次来看,它是通过构建目标函数的概率模型并利用它来将试验集中在有希望的区域。算法的细节值得了解,我们在进一步阅读部分提供了一些资源链接,帮助你深入了解。

现在,让我们使用一个流行的库optuna来实现贝叶斯优化,用于我们训练的 GFM 模型的超参数调优。

这个过程非常简单。我们需要定义一个函数,该函数接受一个名为trial的参数。在函数内部,我们从trial对象中采样我们想调优的不同参数,训练模型,评估预测结果,并返回我们希望优化的度量(MAE)。让我们快速实现一下:

def objective(trial):
    params = {
        # Sample an integer between 10 and 100
        "num_leaves": trial.suggest_int("num_leaves", 10, 100),
        # Sample a categorical value from the list provided
        "objective": trial.suggest_categorical(
            "objective", ["regression", "regression_l1", "huber"]
        ),
        "random_state": [42],
        # Sample from a uniform distribution between 0.3 and 1.0
        "colsample_bytree": trial.suggest_uniform("colsample_bytree", 0.3, 1.0),
        # Sample from a uniform distribution between 0 and 10
        "lambda_l1": trial.suggest_uniform("lambda_l1", 0, 10),
        # Sample from a uniform distribution between 0 and 10
        "lambda_l2": trial.suggest_uniform("lambda_l2", 0, 10),
    }
    _model_config = ModelConfig(
        # Use the sampled params to initialize the model
        model=LGBMRegressor(**params, verbose=-1),
        name="Global Meta LightGBM Tuning",
        # LGBM is not sensitive to normalized data
        normalize=False,
        # LGBM can handle missing values
        fill_missing=False,
    )
    y_pred, feat_df = train_model(
        _model_config,
        _feat_config,
        missing_value_config,
        train_features,
        train_target,
        test_features,
        fit_kwargs=dict(categorical_feature=cat_features),
    )
    # Return the MAE metric as the value
    return ts_utils.mae(test_target["energy_consumption"], y_pred) 

一旦定义了目标函数,我们需要初始化一个采样器。optuna 提供了多种采样器,如 GridSamplerRandomSamplerTPESampler。对于所有标准用例,应该使用 TPESamplerGridSampler 进行网格搜索,RandomSampler 进行随机搜索。在定义 树形帕尔岑估计器TPE)采样器时,有两个参数我们需要特别关注:

  • seed:设置随机抽样的种子。这使得该过程具有可重复性。

  • n_startup_trials:这是完全探索性的试验次数。此操作是为了在开始利用之前理解搜索空间。默认值为 10。根据样本空间的大小和计划进行的试验数量,我们可以减少或增加此值。

其余的参数最好保持不变,以适应最常见的使用情况。

现在,我们创建一个研究对象,它负责运行试验并存储所有关于试验的细节:

# Create a study
study = optuna.create_study(direction="minimize", sampler=sampler)
# Start the optimization run
study.optimize(objective, n_trials=27, show_progress_bar=True) 

在这里,我们定义了优化方向,并传入了我们之前初始化的采样器。一旦定义了研究对象,我们需要调用 optimize 方法,并传入我们定义的目标函数、需要运行的试验次数以及一些其他参数。optimize 方法的完整参数列表可以在这里查看—optuna.readthedocs.io/en/stable/reference/generated/optuna.study.Study.html#optuna.study.Study.optimize

这运行时间稍长,可能是因为生成新试验所需的额外计算,但仍然只需要大约 20 分钟来完成 27 次试验。正如预期的那样,这又得到了一个新的超参数组合,其目标值为 0.72838(截至目前为止的最低值)。

为了充分说明三者之间的区别,让我们比较一下三种技术如何分配它们的计算预算:

图 10.7 – 计算工作量的分布(网格搜索 vs 随机搜索 vs 贝叶斯优化)

图 10.7:计算工作量的分布(网格搜索 vs 随机搜索 vs 贝叶斯优化)

我们可以看到,贝叶斯优化在较低值处有一个厚尾,表明它将大部分计算预算用于评估和利用搜索空间中的最优区域。

让我们看看在优化过程进行的过程中,这些不同的技术如何表现。

该笔记本中有对三种技术的更详细比较和评论。

最终结论是,如果我们有无限的计算资源,使用定义良好的精细网格进行网格搜索是最佳选择,但如果我们重视计算效率,我们应该选择贝叶斯优化。

让我们看看新参数的效果如何:

图 10.8 – 使用调整过的 GFM 和元特征的聚合指标

图 10.8:使用元特征调整后的 GFM 的聚合指标

我们在MAEmeanMASE上取得了巨大的改善,主要是因为在超参数调整时我们优化的是 MAE。MAE 和 MSE 的侧重点略有不同,接下来在第四部分预测的机制中我们将更多地讨论这一点。运行时间也有所增加,因为新参数为树构建了比默认参数更多的叶子,模型也比默认参数更复杂。

现在,让我们来看一下另一种提高 GFM 性能的策略。

分区

在我们迄今为止讨论的所有策略中,这个是最不直观的,特别是如果你来自标准的机器学习或统计学背景。通常,我们会期望模型在更多数据的情况下表现更好,但将数据集分区或拆分为多个几乎相等的部分,已经在经验上被证明能提高模型的准确性。虽然这一点已被经验验证,但其背后的原因仍不完全清楚。一个解释是,GFMs 在训练时面对更简单的任务,当它们训练在相似实体的子集上时,因此能学习到特定实体子集的函数。Montero-Manso 和 Hyndman(参考文献1)提出了另一个解释。他们认为,数据分区是增加复杂性的另一种形式,因为我们不再将log(|J|)作为复杂性项,而是

其中,P是分区的数量。按照这个逻辑,LFM 是特例,其中P等于数据集中的时间序列数量。

我们可以用多种方式来划分数据集,每种方式的复杂度不同。

随机分区

最简单的方法是将数据集随机划分为P个相等的分区,并为每个分区训练独立的模型。这个方法忠实地遵循了 Montero-Manso 和 Hyndman 的解释,因为我们是随机划分数据集的,不考虑不同家庭之间的相似性。让我们看看如何操作:

# Define a function which splits a list into n partitions
def partition (list_in, n):
    random.shuffle(list_in)
    return [list_in[i::n] for i in range(n)]
# split the unique LCLids into partitions
partitions = partition(train_df.LCLid.cat.categories.tolist(), 3) 

然后,我们只需遍历这些分区,为每个分区训练独立的模型。具体代码可以在笔记本中找到。让我们看看随机分区效果如何:

图 10.9 – 使用元特征和随机分区调整后的 GFM 的聚合指标

图 10.9:使用元特征和随机分区调整后的 GFM 的聚合指标

即使是随机分区,我们也能看到MAEmeanMASE的下降。运行时间也有所减少,因为每个独立的模型处理的数据较少,因此训练速度更快。

现在,我们来看一下另一种分区方法,同时考虑不同时间序列的相似性。

判断性分区

判断性分割是指我们使用时间序列的某些属性来划分数据集,这种方法被称为判断性分割,因为通常这取决于正在处理模型的人的判断。实现这一目标的方法有很多种。我们可以使用一些元特征,或者使用时间序列的某些特征(例如,量、变异性、间歇性,或它们的组合)来划分数据集。

让我们使用一个元特征,叫做Acorn_grouped,来划分数据集。同样,我们将只遍历Acorn_grouped中的唯一值,并为每个值训练一个模型。我们也不会将Acorn_grouped作为一个特征。具体代码在笔记本中。让我们看看这种分割方法的效果如何:

图 10.10 – 使用调优后的 GFM 与元特征和 Acorn_grouped 分割的汇总指标

图 10.10:使用调优后的 GFM 与元特征和 Acorn_grouped 分割的汇总指标

这种方法比随机分割表现得更好。我们可以假设每个分区(AffluentComfortableAdversity)都有某种相似性,这使得学习变得更加容易,因此我们得到了更好的准确性。

现在,让我们看一下另一种划分数据集的方法,同样是基于相似性。

算法分割

在判断性分割中,我们选择一些元特征或时间序列特征来划分数据集。我们选择少数几个维度来划分数据集,因为我们是在脑海中进行操作的,而我们的思维能力通常无法处理超过两三个维度,但我们可以将这种分割视为一种无监督的聚类方法,这种方法称为算法分割。

聚类时间序列有两种方法:

  • 为每个时间序列提取特征,并使用这些特征形成聚类

  • 使用基于动态时间规整DTW)距离的时间序列聚类技术

tslearn是一个开源 Python 库,已经实现了基于时间序列之间距离的几种时间序列聚类方法。在进一步阅读中有一个链接,提供了有关该库以及如何使用它进行时间序列聚类的更多信息。

在我们的示例中,我们将使用第一种方法,即我们提取一些时间序列特征并用于聚类。从统计和时间文献中,有许多特征可以提取,例如自相关、均值、方差、熵和峰值间距等,这些都可以从时间序列中提取。

我们可以使用另一个开源 Python 库,叫做时间序列特征提取库tsfel),来简化这个过程。

该库有许多类别的特征——统计、时间、和频谱域——我们可以从中选择,剩下的由库来处理。让我们看看如何生成这些特征并创建一个数据框以执行聚类:

import tsfel
cfg = tsfel.get_features_by_domain("statistical")
cfg = {**cfg, **tsfel.get_features_by_domain("temporal")}
uniq_ids = train_df.LCLid.cat.categories
stat_df = []
for id_ in tqdm(uniq_ids, desc="Calculating features for all households"):
    ts = train_df.loc[train_df.LCLid==id_, "energy_consumption"]
    res = tsfel.time_series_features_extractor(cfg, ts, verbose=False)
    res['LCLid'] = id_
    stat_df.append(res)
stat_df = pd.concat(stat_df).set_index("LCLid") 

数据框的样子大概是这样的:

图 10.11 – 从不同时间序列中提取的特征

图 10.11:从不同时间序列中提取的特征

现在我们已经有了一个数据框,每一行代表一个具有不同特征的时间序列,理论上我们可以应用任何聚类方法,如 k-means、k-medoids 或 HDBSCAN 来找到聚类。然而,在高维空间中,许多距离度量(包括欧几里得距离)并不像预期的那样工作。有一篇由 Charu C. Agarwal 等人于 2001 年发布的开创性论文,探讨了这个问题。当我们增加空间的维度时,我们的常识(它概念化了三维空间)就不那么适用了,因此常见的距离度量,如欧几里得距离,在高维度中效果并不好。我们已经链接了一篇总结该论文的博客(在进一步阅读中)和该论文本身(参考文献5),它们使这个概念更加清晰。因此,处理高维聚类的常见方法是先进行降维,然后再使用普通的聚类方法。

主成分分析 (PCA) 是该领域常用的工具,但由于 PCA 在降维时仅捕捉和详细描述线性关系,现在,另一类技术开始变得更受欢迎——流形学习。

t-分布随机邻居嵌入 (t-SNE) 是这一类别中流行的技术,尤其适用于高维可视化。这是一种非常巧妙的技术,我们将点从高维空间投影到低维空间,同时尽可能保持原始空间中的距离分布与低维空间中的距离分布相似。这里有很多内容需要学习,超出了本书的范围。进一步阅读部分中有一些链接可以帮助你入门。

长话短说,我们将使用 t-SNE 将数据集的维度减少,然后使用降维后的数据集进行聚类。如果你真的想对时间序列进行聚类并以其他方式使用这些聚类,我不建议使用 t-SNE,因为它不能保持点之间的距离和点的密度。进一步阅读中的 distil.pub 文章更详细地阐述了这个问题。但在我们的案例中,我们仅将聚类用作训练另一个模型的分组,因此这个近似方法是可以的。让我们看看我们是如何做到的:

from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from src.utils.data_utils import replace_array_in_dataframe
from sklearn.manifold import TSNE #T-Distributed Stochastic Neighbor Embedding
# Standardizing to make distance calculation fair
X_std = replace_array_in_dataframe(stat_df, StandardScaler().fit_transform(stat_df))
#Non-Linear Dimensionality Reduction
tsne = TSNE(n_components=2, perplexity=50, learning_rate="auto", init="pca", random_state=42, metric="cosine", square_distances=True)
X_tsne = tsne.fit_transform(X_std.values)
# Clustering reduced dimensions into 3 clusters
kmeans = KMeans(n_clusters=3, random_state=42).fit(X_tsne)
cluster_df = pd.Series(kmeans.labels_, index=X_std.index) 

由于我们将维度降至二维,因此我们还可以可视化形成的聚类:

图 10.12 – t-SNE 降维后的聚类时间序列

图 10.12:t-SNE 降维后的聚类时间序列

我们已经形成了三个定义明确的聚类,现在我们将使用这些聚类来训练每个聚类的模型。像往常一样,我们对三个聚类进行循环并训练模型。让我们看看我们是如何做的:

图 10.13 – 使用元特征和集群分区调优的 GFM 汇总指标

图 10.13:使用元特征和集群分区调优的 GFM 汇总指标

看起来这是我们所有实验中看到的最佳 MAE,但三种分区技术的 MAE 非常相似。仅凭一个保留集,我们无法判断哪一种优于另一种。为了进一步验证,我们可以使用Chapter08文件夹中的01a-Global_Forecasting_Models-ML-test.ipynb笔记本在测试数据集上运行这些预测。让我们看看测试数据集上的汇总指标:

图 10.14 – 测试数据上的汇总指标

图 10.14:测试数据上的汇总指标

正如预期的那样,集群分区在这种情况下仍然是表现最好的方法。

第八章使用机器学习模型进行时间序列预测中,我们花了 8 分钟 20 秒训练了一个 LFM 来处理数据集中所有家庭的预测。现在,采用 GFM 范式,我们在 57 秒内完成了模型训练(在最坏情况下)。这比训练时间减少了 777%,同时 MAE 也减少了 8.78%。

我们选择使用 LightGBM 进行这些实验。这并不意味着 LightGBM 或其他任何梯度提升模型是 GFMs 的唯一选择,但它们是一个相当不错的默认选择。一个经过精调的梯度提升树模型是一个非常难以超越的基准,但和机器学习中的所有情况一样,我们应该通过明确的实验来检查什么方法效果最好。

尽管没有硬性规定或界限来确定何时 GFM 比 LFM 更合适,但随着数据集中时间序列的数量增加,从准确性和计算角度来看,GFM 变得更为有利。

尽管我们使用 GFM 取得了良好的结果,但通常在这种范式下表现好的复杂模型是黑盒模型。让我们看看一些打开黑盒、理解和解释模型的方式。

可解释性

可解释性可以定义为人类能够理解决策原因的程度。在机器学习和人工智能中,这意味着一个人能够理解一个算法及其预测的“如何”和“为什么”的程度。可解释性有两种看法——透明性和事后解释。

透明性是指模型本身简单,能够通过人类认知来模拟或思考。人类应该能够完全理解模型的输入以及模型如何将这些输入转换为输出的过程。这是一个非常严格的条件,几乎没有任何机器学习或深度学习模型能够满足。

这正是事后解释技术大显身手的地方。有多种技术可以利用模型的输入和输出,帮助理解模型为何做出它的预测。

有许多流行的技术,如排列特征重要性夏普利值LIME。所有这些都是通用的解释技术,可以用于任何机器学习模型,包括我们之前讨论的 GFM。让我们高层次地谈谈其中的一些。

杂质减少的均值:

这是我们从基于树的模型中直接获得的常规“特征重要性”。该技术衡量一个特征在用于决策树节点分裂时,减少杂质(例如分类中的基尼杂质或回归中的方差)多少。杂质减少得越多,特征就越重要。然而,它对连续特征或高基数特征有偏见。该方法快速且在像 scikit-learn 这样的库中易于使用,但如果特征具有不同的尺度或多个类别,它可能会给出误导性的结果。

删除列重要性(Leave One Covariate Out,LOCO):

该方法通过逐个移除特征并重新训练模型来评估特征的重要性。与基线模型的性能下降表明该特征的重要性。它是模型无关的,并捕获特征之间的交互作用,但由于每次移除特征都需要重新训练模型,因此计算开销较大。如果存在共线性特征,模型可能会补偿已删除的特征,从而导致误导性结果。

排列重要性:

排列重要性衡量当一个特征的值被随机打乱,从而破坏它与目标的关系时模型性能的下降。这种技术直观且与模型无关,并且不需要重新训练模型,使其在计算上效率较高。然而,它可能会夸大相关特征的重要性,因为模型可以依赖相关特征来弥补被打乱的特征。

部分依赖图(PDP)和个体条件期望(ICE)图:

PDP(部分依赖图)可视化特征对模型预测的平均影响,展示当特征值变化时目标变量如何变化,而 ICE(个体条件期望)图则展示单个实例的特征效果。这些图有助于理解特征与目标之间的关系,但假设特征之间是独立的,这在存在相关变量时可能导致误导性的解释。

局部可解释模型无关解释(LIME):

LIME 是一种模型无关的技术,它通过使用更简单、可解释的模型(如线性回归)在局部近似复杂模型来解释单个预测。它通过生成数据点的扰动并为这些样本拟合一个局部模型来工作。这种方法直观且广泛适用于结构化和非结构化数据(文本和图像),但定义扰动的正确局部性可能会很具挑战性,尤其是对表格数据而言。

SHapley 加性解释(SHAP)

SHAP 将多种解释方法(包括 Shapley 值和 LIME)统一为一个框架,能够以模型无关的方式归因特征重要性。SHAP 提供了局部和全局解释,并通过快速实现支持树基模型(TreeSHAP)。它结合了 Shapley 值的理论优势和实践中的高效性,尽管对于大规模数据集而言,它仍然可能计算密集。

每种技术都有其优点和折衷,但 SHAP 因其强大的理论基础和有效连接局部与全局解释的能力而脱颖而出。关于此类技术的更广泛介绍,我在进一步阅读中提供了一些链接。由我本人编写的博客系列和 Christopher Molnar 的免费书籍是非常好的资源,能够帮助你更快掌握相关知识(更多关于可解释性的内容见第十七章)。

恭喜你完成了本书的第二部分!这一部分内容相当密集,我们讲解了不少理论和实践课程,希望你现在已经能够熟练运用机器学习进行时间序列预测。

总结

为了很好地总结本书的第二部分,我们详细探讨了 GFMs,了解了它们为何重要以及为何它们在时间序列预测中是一个令人兴奋的新方向。我们看到如何利用机器学习模型来使用 GFM,并回顾了许多技术,这些技术大多在竞赛和行业应用中频繁使用。我们还简要回顾了可解释性技术。现在我们已经完成了机器学习部分的内容,接下来将进入本书的下一章,专注于近年来广为人知的一种机器学习类型——深度学习

参考文献

以下是我们在本章中引用的来源:

  1. Montero-Manso, P., Hyndman, R.J. (2020),预测时间序列群体的原理与算法:局部性与全局性。arXiv:2008.00444[cs.LG]:arxiv.org/abs/2008.00444

  2. Micci-Barreca, D. (2001),分类与预测问题中高基数分类属性的预处理方案SIGKDD Explor. Newsl. 3, 1(2001 年 7 月),27–32:doi.org/10.1145/507533.507538

  3. Fisher, W. D. (1958). 群体最大同质性分组研究美国统计学会期刊,53(284),789–798:doi.org/10.2307/2281952

  4. Fisher, W.D. (1958),分类与预测问题中高基数分类属性的预处理方案SIGKDD Explor. Newsl. 3, 1(2001 年 7 月),27–32。

  5. Aggarwal, C. C., Hinneburg, A., 和 Keim, D. A. (2001). 高维空间中距离度量的惊人行为。在第八届国际数据库理论会议论文集(ICDT ‘01)中,Springer-Verlag, Berlin, Heidelberg, 420-434:dl.acm.org/doi/10.5555/645504.656414.

  6. Oreshkin, B. N., Carpov D., Chapados N., 和 Bengio Y. (2020). N-BEATS: 具有可解释性的时间序列预测的神经基础扩展分析第八届国际学习表示大会,ICLR 2020openreview.net/forum?id=r1ecqn4YwB.

进一步阅读

以下是一些资源,您可以进一步探索以进行详细学习:

加入我们社区的 Discord

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/mts

留下您的评论!

感谢您购买了本书——我们希望您喜欢它!您的反馈对我们非常重要,能够帮助我们不断改进和成长。阅读完本书后,请花点时间留下亚马逊评论;这只需要一分钟,但对像您这样的读者来说意义重大。

扫描二维码或访问链接,领取您选择的免费电子书。

packt.link/NzOWQ

一个带有黑色方块的二维码,描述自动生成

第三部分

时间序列的深度学习

本部分聚焦于深度学习在解决时间序列问题中的应用。它从介绍必要的概念开始,逐步深入到适合处理时间序列数据的不同专门架构。同时,还讨论了深度学习中的全球模型以及一些提高其效果的策略。最后,我们深入探讨了生成概率预测,这是当今预测领域中的重要内容。

本部分包括以下章节:

  • 第十一章深度学习简介

  • 第十二章时间序列深度学习的构建模块

  • 第十三章时间序列的常见建模模式

  • 第十四章时间序列的注意力机制与变换器

  • 第十五章全球深度学习预测模型的策略

  • 第十六章用于预测的专门深度学习架构

  • 第十七章概率预测及更多内容

第十一章:深度学习简介

在上一章中,我们学习了如何使用现代机器学习模型来解决时间序列预测问题。现在,让我们把注意力集中在机器学习的一个子领域——近年来表现出巨大潜力的深度学习上。我们将试图揭开深度学习的神秘面纱,探讨为什么它现在如此流行。我们还将把深度学习分解成主要的组成部分,学习支撑深度学习的“核心力量”——梯度下降。

在本章中,我们将讨论以下主要内容:

  • 什么是深度学习,为什么是现在?

  • 深度学习系统的组成部分

  • 表示学习

  • 线性层和激活函数

  • 梯度下降

技术要求

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

本章相关代码可以在github.com/PacktPublishing/Modern-Time-Series-Forecasting-with-Python-2E/tree/main/notebooks/Chapter11找到。

什么是深度学习,为什么是现在?

第五章时间序列预测作为回归中,我们讨论了机器学习,并借用了 Arthur Samuel 的定义:“机器学习是一个让计算机无需明确编程即可学习的研究领域。”接着我们进一步探讨了如何通过机器学习从数据中学习有用的函数。深度学习是这个研究领域的一个子领域。深度学习的目标同样是从数据中学习有用的函数,但它在实现方法上有一些特定的要求。

在讨论深度学习的特别之处之前,我们先回答另一个问题。为什么我们要把这个机器学习的子领域单独作为一个话题来讨论?答案就在于深度学习方法在众多应用中的不合理有效性。深度学习已经风靡机器学习领域,推翻了各种类型数据(如图像、视频、文本等)上的最先进系统。如果你记得十年前的手机语音识别系统,它们当时更多是用来娱乐的,而不是真的实用。但如今,你可以说嘿,谷歌,播放 Pink Floyd,然后Comfortably Numb会在你的手机或扬声器上播放。多个深度学习系统使这个过程得以流畅实现。手机上的语音助手、自动驾驶汽车、网络搜索、语言翻译——深度学习在我们日常生活中的应用清单不断扩展。

现在你可能在想,这种新技术“深度学习”到底是怎么回事,对吧?其实,深度学习并不是一项新技术。深度学习的起源可以追溯到 20 世纪 40 年代末和 50 年代初。它之所以显得新颖,是因为近年来该领域的流行度急剧上升。

让我们快速了解一下为什么深度学习突然变得如此流行。

为什么是现在?

深度学习在过去二十年里取得了显著进展,主要有两个原因:

  • 计算能力的增加

  • 数据可用性的增加

在接下来的章节中,我们将详细讨论前述的各个要点。

计算能力的增加

早在 1960 年,Frank Rosenblatt 就写了一篇论文(参考文献5)讨论了一个三层神经网络,并表示这项工作在证明神经网络作为模式识别设备的能力方面做出了重要贡献。但在同一篇论文中,他指出,当我们增加连接数时,1960 年代的数字计算机承载的负担过于沉重。然而,在接下来的几十年里,计算机硬件几乎提高了 50,000 倍,这为神经网络和深度学习提供了强大的推动力。然而,仍然不足够,因为神经网络当时仍然不被认为适合大规模应用

这时,一种最初为游戏开发的特定硬件——GPU(图形处理单元)开始发挥作用。虽然尚不完全清楚是谁首先将 GPU 用于深度学习,但 Kyoung-Su Oh 和 Keechul Jung 于 2004 年发表了一篇名为GPU 实现神经网络的论文,这篇论文似乎是首个展示 GPU 在深度学习中能带来显著加速的研究。有关此话题的早期且较为流行的研究论文来自 Rajat Raina、Anand Madhavan 和 Andrew Ng,他们在 2009 年发布了一篇名为使用图形处理器进行大规模深度无监督学习的论文,证明了 GPU 在深度学习中的有效性。

尽管许多由 LeCun、Schmidhuber、Bengio 等人领导的团队一直在尝试使用 GPU,但转折点出现在 Alex Krizhevsky、Ilya Sutskever 和 Geoffrey E. Hinton 使用基于 GPU 的深度学习系统,该系统在一个名为ImageNet 大规模视觉识别挑战 2012的图像识别竞赛中超越了所有其他竞争技术。

GPU 的引入为深度学习的广泛应用提供了急需的推动力,并加速了该领域的进展。

参考检查

论文GPU 实现神经网络使用图形处理器进行大规模深度无监督学习使用深度卷积神经网络进行 ImageNet 分类分别在参考文献部分的123中被引用。

数据可用性的增加

除了计算能力的飞速提升,深度学习的另一个主要推动因素是数据量的急剧增加。随着世界越来越数字化,我们生成的数据量也大幅增加。曾经只有几百或几千行的表格,如今已经膨胀到数百万、数十亿行,而存储成本的不断降低也促进了数据收集的爆炸式增长。

那么,为什么数据量的增加对深度学习有帮助呢?这与深度学习的工作方式有关。深度学习对数据的需求非常大,需要大量的数据来学习出优秀的模型。因此,如果我们不断增加提供给深度学习模型的数据量,模型将能够学习到越来越好的函数。然而,传统机器学习模型并非如此。我们可以通过安德鲁·吴(Andrew Ng)在他著名的机器学习课程——斯坦福大学的Machine Learning by Stanford University(Coursera)中推广的一张图表来加深理解(www.coursera.org/specializations/machine-learning-introduction)(图 11.1)。

图 11.1 – 随着数据量增加,深度学习与传统机器学习的对比

图 11.1:随着数据量增加,深度学习与传统机器学习的对比

在安德鲁·吴推广的图 11.1中,我们可以看到,随着数据量的增加,传统机器学习会达到一个平台期,之后不再有所改善。

通过经验已经证明,深度学习模型的过参数化具有显著的优势。过参数化是指模型中的参数数量超过了用于训练的数据点数量。在经典统计学中,这是一个大忌,因为在这种情况下,模型不可避免地会过拟合。然而,深度学习似乎能够轻松地挑战这一规则。一个过参数化的例子是当前最先进的图像识别系统——NoisyStudent。它拥有 4.8 亿个参数,但是在包含 120 万个数据点的ImageNet上进行训练的。

有人认为,深度学习模型的训练方式(随机梯度下降法,稍后会详细解释)是关键,因为它具有正则化效应。在一篇名为《深度学习的计算极限》的研究论文中,Niel C. Thompson 等人尝试通过一个简单的实验来说明这一点。他们设置了一个包含 1000 个特征的数据集,但只有其中 10 个特征具有信号。然后,他们尝试基于不同的数据集大小,使用该数据集学习四个不同的模型:

  • Oracle 模型:使用精确的 10 个参数,这些参数包含所有信号。

  • 专家模型:使用 10 个显著参数中的 9 个。

  • 灵活模型:使用所有 1000 个参数。

  • 正则化模型:一个使用所有 1,000 个参数的模型,但现在是一个正则化的(lasso)模型。(我们在第八章《使用机器学习模型预测时间序列》中讨论过正则化。)

让我们看看研究论文中的图 11.2

图 11.2 – 该图展示了不同模型在不同数据量下的表现

图 11.2:该图展示了不同模型在不同数据量下的表现

该图的横坐标表示数据点,纵坐标表示性能(-log(均方误差))。不同颜色的线条表示不同类型的模型。Oracle 模型设置了学习的上限,因为它能够访问完美的信息。

专家模型在某一水平上停滞,因为它缺乏信息,并且无法访问十个重要特征中的一个。灵活模型(使用所有 1,000 个特征)需要大量的数据点才能开始识别重要特征,但随着数据量的增加,它仍然趋近于 Oracle 模型的表现。正则化模型(作为深度学习模型的代表)随着我们给模型提供越来越多的数据而不断改进。该模型通过正则化来识别哪些特征与问题相关,并开始以较少的数据点有效利用这些特征,性能随着数据点的增加而持续提升。这再次印证了 Andrew Ng 曾经推广的概念——随着数据量的增加,深度学习开始超越传统机器学习。

除了计算能力和数据可用性之外,许多其他因素也推动了深度学习的成功。Sara Hooker 在她的文章《硬件彩票》(参考文献9)中谈到了一个观点:一个想法之所以能够成功,不一定是因为它优于其他想法,而是因为它适合当时的软件和硬件环境。而一旦一个研究方向赢得了“彩票”,它就会像滚雪球一样发展,因为更多的资金和大型研究机构会支持这一想法,最终它会成为该领域最突出的思想。

我们已经讨论了深度学习一段时间,但仍未真正理解它是什么。现在我们来了解一下。

什么是深度学习?

深度学习没有单一的定义,因为它对不同的人来说意味着略有不同的东西。然而,绝大多数人达成了一个共识:当一个模型能够从原始数据中自动学习特征时,它就被称为深度学习。正如 Yoshua Bengio(图灵奖得主,AI 的教父之一)在他 2021 年发表的论文《无监督学习与迁移学习的深度表示学习》中所解释的:

“深度学习算法试图利用输入分布中未知的结构,以发现良好的表示,通常是在多个层次上,较高层次的学习特征是通过较低层次特征定义的。”

在 2016 年的一次演讲《深度学习与可理解性对比软件工程与验证》中,谷歌研究总监 Peter Norvig 给出了一个类似但更简单的定义:

“一种学习方式,其中你所构建的表示具有多个抽象层次,而不是直接的输入到输出。”

深度学习的另一个关键特性是合成性,很多人都认同这一点。杨·勒昆(Yann LeCun),图灵奖得主,以及 AI 的另一位奠基人,给出了一个略微复杂但更准确的深度学习定义(2020 年 1 月 9 日来自@ylecun的推文):

“深度学习是一种方法论:通过将参数化模块组装成(可能是动态的)图形,并通过基于梯度的方法进行优化来构建模型。”

我们希望在此强调的关键点如下:

  • 组装参数化模块:这指的是深度学习的合成性。正如我们稍后将看到的,深度学习系统由一些具有多个参数(有些没有)的子模块组装而成,形成类似图形的结构。

  • 利用基于梯度的方法进行优化:尽管将基于梯度的学习方法作为深度学习的充分标准并未得到广泛认可,但我们从经验上看到,今天大多数成功的深度学习系统都是通过基于梯度的方法进行训练的。(如果你不了解什么是基于梯度的优化方法,不用担心。我们会在本章中很快涉及这一内容。)

如果你之前读过有关深度学习的内容,可能已经见过神经网络和深度学习一起使用或互换使用。但直到现在我们才讨论神经网络。在此之前,让我们先看一下任何神经网络的基本单元。

感知机——第一个神经网络

我们所说的深度学习和神经网络,很多都深受人类大脑及其内部运作的影响。尽管最近的研究表明人类大脑与人工神经网络之间几乎没有相似之处,但这一理念背后的种子却受到人类生物学的启发。人类想要创造像自己一样的智能生命的愿望早在希腊神话中就有所体现(如加拉提亚与潘多拉)。正因如此,人类多年来一直在研究并从人类解剖学中寻找灵感。大脑作为人体的一个器官,一直是被深入研究的对象,因为它是智慧、创造力及人类一切功能的核心。

尽管我们对于大脑的了解依然有限,但我们确实知道一些基本信息,并利用这些信息来设计人工系统。人脑的基本单元就是我们所称之为神经元,如图所示:

图 11.3 – 生物神经元

图 11.3:生物神经元

你们中的许多人可能已经在生物学或机器学习的相关领域接触过这个概念。但我们还是要再回顾一下。生物神经元有以下几个部分:

  • 树突是神经细胞的分支延伸部分,收集来自周围细胞或其他神经元的输入。

  • 索玛,即细胞体,收集这些输入,结合它们并传递出去。

  • 轴突丘连接细胞体和轴突,并控制神经元的放电。如果信号的强度超过阈值,轴突丘就会通过轴突放电信号。

  • 轴突是连接细胞体和神经末梢的纤维。它的职责是将电信号传递到终端。

  • 突触是神经细胞的终端,并将信号传递给其他神经细胞。

McCulloch 和 Pitts(1943)是第一个设计生物神经元数学模型的人。然而,McCulloch-Pitts 模型有几个局限性:

  • 它只接受二进制变量。

  • 它认为所有输入变量同等重要。

  • 只有一个参数,阈值,这个参数是不可学习的。

1957 年,Frank Rosenblatt 推广了 McCulloch-Pitts 模型,并使其成为一个完整的模型,其参数可以被学习。现代深度学习网络与人脑的相似性到此为止。开始这一研究方向的学习基本单元受人类生物学的启发,同时它也是对人类生物学的一个相当廉价的模仿。

参考检查

Frank Rosenblatt 的原始研究论文《感知机》在参考文献中列为参考文献5

让我们详细了解感知机,因为它是所有神经网络的基本构建块:

图 11.4 – 感知机

图 11.4:感知机

图 11.4所示,感知机有以下组成部分:

  • 输入:这些是传递给感知机的实值输入,就像神经元中的树突一样,收集输入信号。

  • 加权和:每个输入乘以对应的权重并求和。权重决定了每个输入在确定结果时的重要性。

  • 非线性:加权和通过一个非线性函数。对于原始的感知机,它是一个带有阈值激活的阶跃函数。输出将根据加权和和单元的阈值为正或负。现代的感知机和神经网络使用不同种类的激活函数,但我们稍后会看到这些。

我们可以将感知机写成如下数学形式:

图 11.5:感知机,数学视角

图 11.5所示,感知机的输出由输入的加权和定义,并通过一个非线性函数传递。现在,我们也可以通过线性代数来理解这一点。这是一个重要的视角,原因有二:

  • 线性代数的视角将帮助你更快地理解神经网络。

  • 这也使整个过程变得可行,因为矩阵乘法是我们现代计算机和 GPU 擅长的事情。没有线性代数,将这些输入与相应权重相乘将要求我们循环遍历输入,这很快变得不可行。

    线性代数直觉回顾

    让我们回顾一些概念。如果您已经了解向量、向量空间和矩阵乘法,请随时跳过。

    向量和向量空间

    在表面上,向量是一个数字数组。然而,在线性代数中,向量是一个具有大小和方向的实体。让我们举个例子来阐明:

    我们可以看到这是一个数字数组。但是,如果我们在二维坐标空间中绘制这一点,我们会得到一个点。如果我们从原点到这一点画一条线,我们将得到一个具有方向和大小的实体。这就是一个向量。

    二维坐标空间称为向量空间。一个二维向量空间,在非正式情况下,是所有具有两个条目的可能向量。将其扩展到n维,一个n维向量空间是所有具有n个条目的可能向量。

    我想留给您的最终直觉是:向量是 n 维向量空间中的一个点

    矩阵和变换

    再次,在表面上,矩阵是一个看起来像这样的数字的矩形排列:

    矩阵有许多用途,但对我们最相关的直觉是,矩阵指定了它所在的向量空间的线性变换。当我们将一个向量与一个矩阵相乘时,我们实质上是在转换向量,矩阵的值和维度定义了发生的变换类型。根据矩阵的内容,它可以进行旋转、反射、缩放、剪切等操作。

    我们在Chapter11文件夹中包含了一个名为01-Linear_Algebra_Intuition.ipynb的笔记本,其中探讨了矩阵乘法作为一种转换。我们还将这些转换矩阵应用于向量空间,以发展关于矩阵乘法如何旋转和扭曲向量空间的直觉。

    我强烈建议您前往进一步阅读部分,在那里我们提供了一些资源来开始并巩固必要的直觉。

如果我们将输入视为特征空间(具有m维的向量空间)中的向量,则术语不过是输入向量的线性组合。我们可以将方程重写为以下向量形式:

在这里,

偏置也包括在这里作为一个固定值为 1 的虚拟输入,并将添加到向量中。

现在我们已经初步了解了深度学习,让我们回顾一下我们之前讨论的深度学习的一个方面——组合性——并在下一节中对其进行更深入的探讨。

深度学习系统的组成部分

让我们回顾一下 Yann LeCun 对深度学习的定义:

“深度学习是一种方法论:通过将参数化模块组装成(可能是动态的)图,并使用基于梯度的方法优化它,来构建模型。”

这里的核心思想是,深度学习是一个高度模块化的系统。深度学习不仅仅是一个模型,而是一种语言,通过几个具有特定属性的参数化模块来表达任何模型:

  1. 它应该能够通过一系列计算从给定的输入中产生输出。

  2. 如果给定了期望的输出,它应该能够将信息传递给其输入,告诉它们如何改变,以达到期望的输出。例如,如果输出低于预期,模块应该能够告诉其输入在某个方向上进行变化,从而使输出更接近期望的结果。

数学倾向较强的人可能已经弄明白了与第二个导数点的关联。你是对的。为了优化这类系统,我们主要使用基于梯度的优化方法。因此,将这两个属性合并为一个,我们可以说这些参数化模块应该是可微分的函数

让我们借助一张图来进一步辅助讨论。

图 11.6 – 深度学习系统

图 11.6:深度学习系统

图 11.6所示,深度学习可以被看作是一个系统,通过一系列线性和非线性变换从原始输入数据中获取信息,并提供输出。它还可以调整其内部参数,通过学习使输出尽可能接近所期望的输出。为了简化图示,我们选择了一个适用于大多数流行深度学习系统的范式。它的一切从原始输入数据开始。原始输入数据经过N个块的线性和非线性函数进行表示学习。让我们详细探讨这些模块。

表示学习

表示学习,通俗地说,就是学习最佳特征,使得我们可以使问题变得线性可分。线性可分意味着我们可以用一条直线将不同的类别(在分类问题中)分开(图 11.7):

图 11.7 – 使用一个函数将非线性可分的数据转换为线性可分

图 11.7:使用一个函数将非线性可分的数据转换为线性可分,

图 11.6中的表示学习模块可能有多个线性和非线性函数堆叠在一起,整个模块的功能是学习一个函数,,将原始输入转换为使问题线性可分的良好特征。

另一种看待这一问题的方式是通过线性代数的视角。正如我们在本章之前所探讨的,矩阵乘法可以被视为向量的线性变换。如果我们将这种直觉扩展到向量空间上,我们可以看到矩阵乘法在某种程度上会扭曲向量空间。当我们将多个线性和非线性变换叠加时,本质上我们是在扭曲、旋转和压缩输入的向量空间(包含特征),将其映射到另一个空间。当我们要求一个有参数的系统以某种方式扭曲输入空间(如图像的像素),以执行特定任务(例如区分狗与猫的分类),表示学习模块便会学习到正确的变换,从而使任务变得更容易(例如区分猫与狗)。

我制作了一个视频来说明这一点,因为没有什么比一个展示实际情况的视频更能帮助建立直觉的了。我使用了一个非线性可分的数据集,训练了一个神经网络来进行分类,然后将模型如何将输入空间转换为线性可分表示进行了可视化。你可以在这里找到这个视频:www.youtube.com/watch?v=5xYEa9PPDTE

现在,让我们看看表示学习模块。我们可以看到其中有一个线性变换和一个非线性激活。

线性变换

线性变换只是应用于向量空间的变换。当我们在神经网络的上下文中提到线性变换时,实际上指的是仿射变换。

线性变换在应用变换时会固定原点,但仿射变换则不会。旋转、反射、缩放等都是纯粹的线性变换,因为在进行这些操作时原点不会发生变化。但像平移这样的操作,会移动向量空间,它就是一种仿射变换。因此,AX*^T 是线性变换,但AX*^T +b是仿射变换。

所以,线性变换只是矩阵乘法,它将输入的向量空间进行转换,这也是当今任何神经网络或深度学习系统的核心。

如果我们将多个线性变换叠加在一起,会发生什么呢?例如,我们首先将输入X与变换矩阵A相乘,然后将结果与另一个变换矩阵B相乘:

根据结合律(也适用于线性代数),我们可以将这个方程重写为如下形式:

将这一概念推广到多个N个变换矩阵的堆叠,我们可以看到这一切最终会变成单一的线性变换。这种做法似乎违背了堆叠N层的初衷,不是吗?

这就是非线性变得至关重要的地方,我们通过使用非线性函数引入非线性性,这些非线性函数被称为激活函数。

激活函数

激活函数是非线性可微分函数。在生物神经元中,轴突小丘基于输入信号决定是否发出信号。激活函数具有类似的功能,并且对于神经网络建模非线性数据的能力至关重要。换句话说,激活函数是神经网络将输入向量空间(线性不可分)转换为线性可分向量空间的关键,非正式地说。为了扭曲空间,使得线性不可分的点变得线性可分,我们需要进行非线性变换。

我们重复了上一节中的实验,进行神经网络对输入向量空间的训练变换的可视化,但这次没有使用任何非线性变换。生成的视频可以在此观看:www.youtube.com/watch?v=z-nV8oBpH2w。模型学习到的最佳变换仍然不够充分,点仍然是线性不可分的。

理论上,激活函数可以是任何非线性可微分的(严格来说是几乎处处可微分)函数。然而,随着时间的推移,有一些非线性函数被广泛用于作为激活函数。我们来看看其中一些。

Sigmoid

Sigmoid 是最常见的激活函数之一,可能也是最古老的。它也被称为逻辑函数。当我们讨论感知机时,我们提到了一种步进(在文献中也称为 Heaviside)函数作为激活函数。步进函数不是连续函数,因此在任何地方都不可微分。一个非常接近的替代品就是 Sigmoid 函数。

它的定义如下:

其中 g 是 Sigmoid 函数,x 是输入值。

Sigmoid 是一个连续函数,因此在任何地方都是可微分的。其导数也较容易计算。由于这些性质,Sigmoid 在深度学习的早期作为标准激活函数被广泛采用。

让我们看看 Sigmoid 函数长什么样子,以及它如何转换向量空间:

图 11.8 – Sigmoid 激活函数(左),原始和激活后的向量空间(中间和右)

图 11.8:Sigmoid 激活函数(左),原始和激活后的向量空间(中间和右)

sigmoid 函数将输入压缩到 0 和 1 之间,如图 11.8(左)所示。我们可以在向量空间中观察到相同的现象。sigmoid 函数的一个缺点是,梯度在 sigmoid 的平坦部分趋近于零。当神经元接近该区域时,它接收到的梯度变得微不足道,传播的梯度也停止,导致单元停止学习。我们称这种现象为激活的饱和。由于这个原因,现在sigmoid通常不再用于深度学习中,除非在输出层(我们很快会讨论这种用法)。

双曲正切(tanh)

双曲正切是另一种流行的激活函数。它可以很容易地定义如下:

它与 sigmoid 非常相似。实际上,我们可以将tanh表示为 sigmoid 的一个函数。让我们看看这个激活函数的样子:

图 11.9 – TanH 激活函数(左)和原始、激活后的向量空间(中和右)

图 11.9:Tanh 激活函数(左)和原始、激活后的向量空间(中和右)

我们可以看到其形状类似于 sigmoid,但要尖锐一些。关键的不同之处在于,tanh函数输出的值在-1 和 1 之间。而由于其尖锐性,我们还可以看到向量空间被推向了边缘。该函数输出一个对称于原点(0)的值,这有助于网络的优化,因此tanh被优于sigmoid。但由于tanh函数也是一个饱和函数,当梯度非常小,妨碍梯度流动进而影响学习时,这一问题也困扰着tanh激活。

整流线性单元及其变种

随着神经科学对人脑的了解不断深入,研究人员发现大脑中只有 1%到 4%的神经元在任何时候被激活。然而,在使用诸如sigmoidtanh等激活函数时,网络中几乎一半的神经元都会被激活。2010 年,Vinod Nair 和 Geoffrey Hinton 在开创性的论文《Rectified Linear Units Improve Restricted Boltzmann Machines》中提出了整流线性单元ReLU)。从那时起,ReLU 成为深度神经网络中事实上的激活函数。

ReLU

ReLU 的定义如下:

g(x) = max(x, 0)

它只是一个线性函数,但在零点处有一个拐角。大于零的任何值都会保持不变,而所有小于零的值都会被压缩为零。输出的范围从 0 到。让我们来看一下它的可视化效果:

图 11.10 – ReLU 激活函数(左)和原始、激活后的向量空间(中和右)

图 11.10:ReLU 激活函数(左)和原始、激活后的向量空间(中和右)

我们可以看到左下象限的点都被压缩到坐标轴的线上。这种压缩赋予了激活函数非线性。由于激活函数以一种突然变为零而不是像 sigmoid 或 tanh 那样趋近于零的方式变为零,ReLU 是非饱和的。

参考文献检查

提出了 ReLU 的研究论文在参考文献中被引用,参考文献编号为7

使用 ReLU 有一些优点:

  • 激活函数及其梯度的计算成本非常低。

  • 训练收敛速度比使用饱和激活函数的情况要快得多。

  • ReLU 有助于在网络中引入稀疏性(通过将激活设为零,网络中绝大多数神经元可以被关闭),并且类似于生物神经元的工作方式。

但是,ReLU 也不是没有问题的:

  • x < 0 时,梯度变为零。这意味着输出 < 0 的神经元将会有零梯度,因此该单元将不再学习。这些被称为死 ReLU。

  • 另一个缺点是,ReLU 单元的平均输出是正值,当我们堆叠多个层时,这可能导致输出产生正偏差。

让我们来看一些变种,尝试解决我们讨论过的 ReLU 问题。

Leaky ReLU 和参数化 ReLU

Leaky ReLU 是标准 ReLU 的变种,解决了 死 ReLU 问题。它由 Maas 等人于 2013 年提出。Leaky ReLU 可以定义如下:

这里, 是斜率参数(通常设置为非常小的值,例如 0.001),并被视为超参数。这确保了当 x < 0 时梯度不为零,从而避免了 ReLU 的问题。但这里丧失了 ReLU 提供的稀疏性,因为没有零输出来完全关闭神经元。我们来可视化这个激活函数:

图 10.11 – Leaky ReLU 激活函数(左)和原始与激活后的向量空间(中和右)

图 11.11:Leaky ReLU 激活函数(左)和原始与激活后的向量空间(中和右)

2015 年,K. He 等人提出了一种对 Leaky ReLU 的小改进,称为 参数化 ReLU。在参数化 ReLU 中,他们不再将 视为超参数,而是将其视为一个可学习的参数。

参考文献检查

提出了 Leaky ReLU 的研究论文在参考文献中被引用,参考文献编号为8,而参数化 ReLU 在参考文献编号为9中被引用。

还有许多其他激活函数,虽然不太流行,但在 PyTorch 中仍具有足够的使用案例。您可以在这里找到它们的列表:pytorch.org/docs/stable/nn.html#non-linear-activations-weighted-sum-nonlinearity。我们建议您使用 Chapter 11 文件夹中的笔记本 02-Activation_Functions.ipynb 尝试不同的激活函数,看看它们如何改变向量空间。

接下来,我们已经了解了第一块图中的组件,即图 11.6,表示学习。那里的下一个块是线性分类器,它具有线性变换和输出激活。我们已经知道线性变换是什么,但输出激活是什么?

输出激活函数

输出激活函数是强制网络输出具有几个理想属性的函数。

额外阅读

这些函数与最大似然估计MLE)和选择的损失函数有着更深的联系,但我们不会深入探讨,因为这超出了本书的范围。我们在进一步阅读部分中链接到了 Ian Goodfellow、Yoshua Bengio 和 Aaron Courville 的书籍 深度学习。如果你对深度学习有更深入的理解兴趣,我们建议你使用该书。

如果我们希望神经网络在回归的情况下预测一个连续数值,我们只需使用线性激活函数(这就像说没有激活函数)。来自网络的原始输出被视为预测结果并输入到损失函数中。

但在分类的情况下,期望的输出是所有可能类别中的一个类。如果只有两个类别,我们可以使用我们的老朋友 sigmoid 函数,其输出介于 0 和 1 之间。我们还可以使用 tanh,因为其输出将介于 -1 和 1 之间。sigmoid 函数更受青睐,因为它具有直观的概率解释。值越接近一,网络对该预测的信心就越大。

现在,sigmoid 适用于二元分类。那么对于可能类别超过两个的多类分类呢?

Softmax

Softmax 是一个将 K 个实数值向量转换为另一个 K 正实数值向量的函数,其总和为 1。Softmax 的定义如下:

此函数将网络的原始输出转换为类似于 K 类概率的形式。这与 sigmoid 有着紧密的关系——当 K = 2 时,sigmoidsoftmax 的特例。在接下来的图中,让我们看看如何将大小为 3 的随机向量转换为总和为 1 的概率:

图 11.12 – 原始输出与 softmax 输出

图 11.12: 原始输出与 softmax 输出

如果我们仔细观察,我们会发现,除了将真实值转换成类似概率的东西外,它还增加了最大值与其他值之间的相对差距。这种激活是多类分类问题的标准输出激活。

现在,图表中只剩下一个主要组件——损失函数(图 11.6)。

损失函数

第五章中我们提到的损失函数,时间序列预测作为回归问题,同样适用于深度学习。在深度学习中,损失函数也是一种衡量模型预测质量的方式。如果预测偏离目标很远,损失函数值会很高;而当我们越来越接近真实值时,损失函数值会变小。在深度学习范式中,损失函数还有一个额外的要求——它应该是可微的。

经典机器学习中的常见损失函数,如均方误差均绝对误差,在深度学习中同样适用。事实上,在回归任务中,它们是实践者默认选择的损失函数。对于分类任务,我们采用一个来自信息论的概念——交叉熵损失。然而,由于深度学习是一个非常灵活的框架,只要损失函数是可微的,我们就可以使用任何损失函数。已经有很多损失函数被人们尝试并且在许多情况下证明有效。其中很多也已经成为了 PyTorch 的 API 的一部分。你可以在这里找到它们:pytorch.org/docs/stable/nn.html#loss-functions

现在我们已经涵盖了深度学习系统的所有组件,让我们简要看看如何训练整个系统。

前向和后向传播

图 11.6中,我们可以看到两组箭头,一组从输入指向期望的输出,标记为前向计算,另一组则从期望的输出指向输入,标记为后向计算。这两个步骤是深度学习系统学习过程的核心。在前向计算中,通常称为前向传播,我们使用在各层中定义的一系列计算,将输入从网络的起始点传递到输出端。现在我们得到了输出,我们会使用损失函数来评估我们离期望输出有多近或多远。这些信息现在被用于后向计算,通常称为反向传播,以计算相对于所有参数的梯度。

那么,梯度是什么,为什么我们需要它?在高中的数学中,我们可能会遇到梯度或导数,它们也被称为斜率。斜率是指当我们以单位度量改变一个变量时,量的变化速率。导数告诉我们标量函数的局部斜率。导数总是与单一变量相关,而梯度是导数对多变量函数的推广。直观地说,梯度和导数都告诉我们函数的局部斜率。通过损失函数的梯度,我们可以使用数学优化中的一种技术——梯度下降,来优化我们的损失函数。

让我们通过一个例子来看看。

梯度下降

任何机器学习或深度学习模型都可以看作是一个将输入 x 转换为输出 的函数,使用一些参数 。在这里, 可以是我们在整个网络中对输入进行的所有矩阵变换的集合。但为了简化例子,我们假设只有两个参数 ab。如果我们稍微思考一下整个学习过程,就会发现,通过保持输入和预期输出不变,改变损失函数的方式就是通过调整模型的参数。因此,我们可以假设损失函数是通过这些参数来参数化的——在这个例子中就是 ab

笔记本提示

若要跟随完整代码,请使用 Chapter11 文件夹中的名为 03-Gradient_Descent.ipynb 的笔记本,以及 src 文件夹中的代码。

假设损失函数的形式如下:

让我们看看这个函数是什么样子的。我们可以使用三维图来可视化一个有两个参数的函数,如 图 11.13 所示。两个维度将用来表示这两个参数,在那个二维网格的每一点上,我们可以在第三维度中绘制损失值。

这种损失函数的图表也称为损失曲线(在单变量情况下),或损失面(在多变量情况下)。

图 11.13 – 损失面图

图 11.13:损失面图

3D 形状的较亮部分表示损失函数较小,而离开该部分时,损失值增大。

在机器学习中,我们的目标是最小化损失函数,换句话说,就是找到能够使我们的预测输出尽可能接近真实值的参数。这属于数学优化的范畴,而有一种特别的技术非常适合这种方法——梯度下降

梯度下降是一种数学优化算法,通过在最陡下降的方向上迭代地移动来最小化代价函数。在单变量函数中,导数(或斜率)给出我们最陡上升的方向(和大小)。例如,如果我们知道一个函数的斜率为 1,那么我们知道如果向右移动,我们是在沿着斜率向上爬坡,向左移动则是在向下爬坡。同样,在多变量的情况下,函数在任何一点的梯度将给出最陡上升的方向(和大小)。而由于我们关注的是最小化损失函数,因此我们将使用负梯度,它会指引我们向最陡下降的方向移动。

那么,让我们为我们的损失函数定义梯度。我们使用的是高中级别的微积分,但即使你不太擅长,也无需担心:

那么,算法是如何工作的呢?非常简单,具体如下:

  1. 将参数初始化为随机值。

  2. 计算该点的梯度。

  3. 沿着梯度的反方向迈出一步。

  4. 重复步骤 2 和步骤 3,直到收敛或达到最大迭代次数。

还有一个需要澄清的方面:我们在每次迭代中应该迈出多大一步?

理想情况下,梯度的大小告诉你函数在该方向变化的快慢,我们应该直接按照梯度的大小进行步长调整。但梯度有一个特性使得直接这样做并不好。梯度只定义了当前点附近最陡上升的方向和大小,并且对它之外的变化一无所知。因此,我们使用一个超参数,通常称为学习率,来调整我们在每次迭代中所采取的步长。因此,我们不直接采取与梯度相等的步长,而是将步长设置为学习率与梯度的乘积。

从数学上讲,如果 是参数的向量,在每次迭代时,我们使用以下公式更新参数:

这里, 是学习率, 是该点的梯度。

让我们看看一个非常简单的梯度下降实现(有关完整定义和代码,请参阅 GitHub 仓库中的 notebook)。首先,让我们定义一个函数,返回任意点的梯度:

def gradient(a, b):
    return 2*(a-8), 2*(b-2) 

现在我们定义一些初始参数,比如最大迭代次数、学习率,以及ab 的初始值:

# maximum number of iterations that can be done
maximum_iterations = 500
# current iteration
current_iteration = 0
# Learning Rate
learning_rate = 0.01
#Initial value of a, b
current_a_value = 28
current_b_value = 27
Now, all that is left is the actual process of gradient descent:
while current_iteration < maximum_iterations:
    previous_a_value = current_a_value
    previous_b_value = current_b_value
    # Calculating the gradients at current values
    gradient_a, gradient_b = gradient(previous_a_value, previous_b_value)
    # Adjusting the parameters using the gradients
    current_a_value = current_a_value - learning_rate * gradient_a * (previous_a_value)
    current_b_value = current_b_value - learning_rate * gradient_b * (previous_b_value)
    current_iteration = current_iteration + 1 

我们知道这个函数的最小值出现在 a = 8b = 2,因为这会使损失函数为零。而梯度下降法能找到一个非常准确的解——a = 8.000000000000005b = 2.000000002230101。我们还可以将它到达最小值的路径可视化,如图 11.14所示:

图 11.14 – 损失面上的梯度下降优化

图 11.14: 损失面上的梯度下降优化

即使我们将参数初始化远离实际原点,优化算法也会直接路径到达最优点。在每个点上,算法查看点的梯度,朝相反方向移动,最终收敛于最优点。

当梯度下降在学习任务中被采用时,需要注意一些问题。假设我们有一个包含N个样本的数据集。有三种流行的梯度下降变体用于学习,每种都有其优缺点。

批量梯度下降

我们将所有 N个样本通过网络,并计算所有N个实例的损失平均值。现在,我们使用这个损失来计算梯度,在正确方向上迈出一步,然后重复此过程。

优点如下:

  • 优化路径是直接的。

  • 优化路径有保证的收敛性。

缺点如下:

  • 需要对单个步骤评估整个数据集,这在计算上是昂贵的。对于庞大的数据集,每个优化步骤的计算量变得非常高。

  • 每个优化步骤的所需时间较长,因此收敛速度也较慢。

随机梯度下降(SGD)

在随机梯度下降(SGD)中,我们从N个样本中随机抽取一个实例,计算损失和梯度,然后更新参数。

优点如下:

  • 因为我们只使用单个实例来进行优化步骤,每个优化步骤的计算量非常低。

  • 每个优化步骤的所需时间也更短。

  • 随机抽样也作为正则化,有助于避免过拟合。

缺点如下:

  • 梯度估计具有噪声,因为我们仅基于一个实例进行步骤。因此,朝向最优点的路径将是不稳定和嘈杂的。

  • 仅因为每个优化步骤的时间较短,并不意味着收敛速度更快。由于嘈杂的梯度估计,我们可能许多次没有采取正确的步骤。

小批量梯度下降

小批量梯度下降是一种介于批量梯度下降和 SGD 之间的技术。在这种变体中,我们还有另一个质量叫做小批量大小(或简称批量大小),b。在每个优化步骤中,我们从N个样本中随机选择b个实例,并计算所有b个实例的平均损失梯度。当b = N时,我们有批量梯度下降;当b = 1时,我们有随机梯度下降。这是今天训练神经网络最流行的方法。通过改变批量大小,我们可以在这两种变体之间移动,并管理每种选择的优缺点。

没有什么能比得上一个视觉化的操作平台,更能帮助我们直观地理解我们讨论的不同组件的效果。Tensorflow Playground 是一个极好的资源(见 进一步阅读 部分中的链接),它可以帮助你做到这一点。我强烈建议你前往那里,玩一玩这个工具,在浏览器中训练几个神经网络,并实时观察学习过程是如何发生的。

摘要

我们以介绍深度学习开始了本书的新部分。我们从一段历史开始,了解为什么深度学习在今天如此流行,并探讨了它在感知器中的朴素起步。我们理解了深度学习的可组合性,并分析了深度学习的不同组件,如表示学习块、线性层、激活函数等。最后,我们通过观察深度学习系统如何使用梯度下降从数据中学习来总结讨论。基于这些理解,我们现在准备进入下一章,在那里我们将把叙事引向时间序列模型。

参考文献

以下是本章中使用的参考文献列表:

  1. Kyoung-Su Oh 和 Keechul Jung. (2004), 神经网络的 GPU 实现。模式识别,第 37 卷,第 6 期,2004 年: doi.org/10.1016/j.patcog.2004.01.013

  2. Rajat Raina, Anand Madhavan 和 Andrew Y. Ng. (2009), 使用图形处理单元进行大规模深度无监督学习。第 26 届国际机器学习大会(ICML '09)论文集: doi.org/10.1145/1553374.1553486

  3. Alex Krizhevsky, Ilya Sutskever 和 Geoffrey E. Hinton. (2012), 使用深度卷积神经网络进行 ImageNet 分类。Commun. ACM 60, 6 (2017 年 6 月),84–90: doi.org/10.1145/3065386

  4. Neil C. Thompson, Kristjan Greenewald, Keeheon Lee 和 Gabriel F. Manso. (2020). 深度学习的计算极限。arXiv:2007.05558v1 [cs.LG]: arxiv.org/abs/2007.05558v1

  5. Frank Rosenblatt. (1957), 感知器——一个感知与识别的自动机,技术报告 85-460-1,康奈尔航空实验室。

  6. Charu C. Aggarwal, Alexander Hinneburg 和 Daniel A. Keim. (2001). 高维空间中距离度量的惊人表现。第 8 届国际数据库理论会议(ICDT '01)论文集。Springer-Verlag,柏林,海德堡,420–434: dl.acm.org/doi/10.5555/645504.656414

  7. Nair, V. 和 Hinton, G.E. (2010). 修正线性单元改进了限制玻尔兹曼机。ICML: icml.cc/Conferences/2010/papers/432.pdf

  8. Andrew L. Maas, Awni Y. Hannun, 和 Andrew Y. Ng。(2013)。激活函数非线性改善神经网络声学模型。ICML 深度学习音频、语音和语言处理工作坊:ai.stanford.edu/~amaas/papers/relu_hybrid_icml2013_final.pdf

  9. Kaiming He, Xiangyu Zhang, Shaoqing Ren, 和 Jian Sun(2015)。深入研究激活函数:在 ImageNet 分类上超越人类水平表现。2015 IEEE 国际计算机视觉大会(ICCV),1026-1034:ieeexplore.ieee.org/document/7410480

  10. Sara Hooker。(2021)。硬件彩票。Commun. ACM,第 64 卷:doi.org/10.1145/3467017

进一步阅读

如果你想深入了解本章中涉及的某些主题,可以查阅以下资源:

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/mts

第十二章:时间序列的深度学习构建模块

虽然我们在上一章奠定了深度学习的基础,但那是非常通用的。深度学习是一个庞大的领域,应用涉及各个领域,但在本书中,我们将重点讨论其在时间序列预测中的应用。

因此,在本章中,让我们通过查看一些深度学习中常用于时间序列预测的构建模块来强化基础。尽管全球机器学习模型在时间序列问题中表现良好,但一些深度学习方法也显示出了良好的前景。由于它们在建模时的灵活性,它们是你工具箱中的一个良好补充。

在这一章中,我们将涵盖以下内容:

  • 理解编码器-解码器范式

  • 前馈网络

  • 循环神经网络

  • 长短期记忆网络

  • 门控循环单元

  • 卷积网络

技术要求

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

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

理解编码器-解码器范式

第五章时间序列预测作为回归问题中,我们看到机器学习的核心就是学习一个将我们的输入映射到期望输出的函数:

y = h(x)

其中,x 是输入,y 是我们期望的输出。

将其应用于时间序列预测(为了简化起见,使用单变量时间序列预测),我们可以将其重写如下:

y[t] = h(y[t][-1], y[t][-2], …, y[t-N])

在这里,t 是当前时间步,N 是在时间t时可用的历史总量。

深度学习,像任何其他机器学习方法一样,旨在学习一个将历史映射到未来的函数。在第十一章深度学习简介中,我们看到深度学习如何通过表示学习来学习良好的特征,然后使用这些学习到的特征来执行当前任务。通过使用编码器-解码器范式,这一理解可以进一步从时间序列的角度进行细化。

就像研究中的所有内容一样,关于编码器-解码器架构的提出时间和提议者并不完全明确。1997 年,Ramon Neco 和 Mikel Forcada 提出了一个机器翻译架构,其理念与编码器-解码器范式相似。2013 年,Nal Kalchbrenner 和 Phil Blunsom 提出了一个机器翻译的编码器-解码器模型,尽管他们没有使用这个名称。但当 Ilya Sutskever 等人(2014 年)和 Cho 等人(2014 年)分别提出了两种独立的机器翻译新模型时,这一理念才真正兴起。Cho 等人称之为编码器-解码器架构,而 Sutskever 等人称之为 Seq2Seq 架构。它的关键创新是能够以端到端的方式建模可变长度的输入和输出。

参考检查

Ramon Neco 等人、Nal Kalchbrenner 等人、Cho 等人以及 Ilya Sutskever 等人的研究论文分别在参考文献部分被标注为1234

这个想法非常直接,但在我们深入探讨之前,我们需要对潜在空间和特征/输入空间有一个高层次的理解。

特征空间,或 输入空间,是数据所在的向量空间。如果数据有 10 个维度,那么输入空间就是 10 维的向量空间。潜在空间是一个抽象的向量空间,它编码了特征空间的有意义的内部表示。为了理解这一点,我们可以想象人类如何识别老虎。我们不会记住老虎的每一个细节,而是对老虎的外观和显著特征(如条纹)有一个大致的了解。这种压缩的理解帮助我们的大脑更快地处理和识别老虎。

在机器学习领域,像主成分分析PCA)这样的技术会对潜在空间进行类似的转换,保持输入数据的关键特征。通过这个直觉,重新阅读定义可能会让我们对这个概念有更清晰的理解。

现在我们对潜在空间有了一些了解,让我们来看一下编码器-解码器架构的作用。

编码器-解码器架构有两个主要部分——编码器和解码器:

  • 编码器:编码器接收输入向量 x,并将其编码为潜在空间。这个编码后的表示被称为潜在向量 z

  • 解码器:解码器接收潜在向量 z,并将其解码成我们所需的输出形式()。

以下图示展示了编码器-解码器的配置:

图 12.1 – 编码器-解码器架构

图 12.1:编码器-解码器架构

在时间序列预测的背景下,编码器消耗历史数据,并保留解码器生成预测所需的信息。正如我们之前所学,时间序列预测可以表示为如下:

y[t] = h(y[t][-1], y[t][-2], …, y[t-N])

现在,使用编码器-解码器范式,我们可以将其重写如下:

z[t] = h(y[t][-1], y[t][-2], …, y[t-N])

y[t] = g(z[t])

在这里,h 是编码器,g 是解码器。

每个编码器和解码器都可以是适合时间序列预测的特殊架构。让我们来看一下在编码器-解码器范式中常用的几个组件。

前馈网络

前馈网络FFNs)或全连接网络是神经网络可以采用的最基本架构。我们在第十一章《深度学习导论》中讨论了感知器。如果我们将多个感知器(包括线性单元和非线性激活)堆叠起来并创建一个这样的单元网络,我们就得到了我们所说的 FFN。下面的图示将帮助我们理解这一点:

图 12.2 – 前馈网络

图 12.2:前馈网络(FFN)

一个 FFN 接受一个固定大小的输入向量,并通过一系列计算层传递,直到得到所需的输出。该架构称为前馈,因为信息是通过网络向前传递的。这也被称为全连接网络,因为每一层的每个单元都与前一层的每个单元和下一层的每个单元相连接。

第一层称为输入层,其大小等于输入的维度。最后一层称为输出层,其定义根据我们的期望输出。如果我们需要一个输出,就需要一个单元;如果我们需要 10 个输出,就需要 10 个单元。中间的所有层称为隐藏层。有两个超参数定义了网络的结构——隐藏层的数量和每层单元的数量。例如,在图 12.2中,我们有一个具有两个隐藏层且每层有八个单元的网络。

在时间序列预测的背景下,FFN 可以作为编码器和解码器使用。作为编码器,我们可以像在第五章《时间序列预测作为回归》中使用机器学习模型一样使用 FFN。我们嵌入时间并将时间序列问题转化为回归问题,然后输入到 FFN 中。作为解码器,我们在潜在向量(编码器的输出)上使用它来获得输出(这是 FFN 在时间序列预测中最常见的用法)。

额外阅读

本书将始终使用 PyTorch 来处理深度学习。如果你不熟悉 PyTorch,别担心——我会在必要时解释相关概念。为了快速入门,你可以查看第十二章中的01-PyTorch_Basics.ipynb笔记本,在其中我们探索了张量的基本功能,并使用 PyTorch 从头开始训练了一个非常小的神经网络。我还建议你访问本章末尾的进一步阅读部分,在那里你会找到一些学习 PyTorch 的资源。

现在,让我们戴上实践的帽子,看看这些如何实际应用。PyTorch 是一个开源的深度学习框架,主要由Facebook AI ResearchFAIR实验室开发。虽然它是一个可以操作张量(即n维矩阵)并通过 GPU 加速这些操作的库,但这个库的主要用途之一是构建和训练深度学习系统。因此,PyTorch 提供了许多可以直接使用的组件,帮助我们构建深度学习系统。让我们看看如何使用 PyTorch 构建一个 FFN。

笔记本提醒

要跟随完整的代码,请使用Chapter12文件夹中的02-Building_Blocks.ipynb笔记本和src文件夹中的代码。

正如我们在本节中之前学到的,FFN 是一个由线性和非线性单元组成的网络。线性操作包括将输入向量X与权重矩阵W相乘,并加上一个偏置项b。这个操作,即WX + b,被封装在PyTorch库的nn模块中的Linear类中。我们可以通过torch.nn import Linear从库中导入这个类。但通常,我们需要导入整个nn模块,因为我们会使用该模块中的许多组件。对于非线性部分,我们使用ReLU(如第十一章 深度学习简介中介绍),它也是nn模块中的一个类。

在继续之前,让我们创建一个随机游走时间序列,其长度为20

N = 20
df = pd.DataFrame({
    "date": pd.date_range(periods=N, start="2021-04-12", freq="D"),
    "ts": np.random.randn(N)
}) 

我们可以直接在 FFN 中使用这个张量,但通常,我们会使用滑动窗口技术来拆分张量并训练网络。我们这样做有多个原因:

  • 我们可以将其视为一种数据增强技术,与仅使用整个序列一次不同,它可以创建更多的样本。

  • 它通过将计算限制在一个固定的窗口内,帮助我们减少和限制计算。

现在让我们开始:

ts = torch.from_numpy(df.ts.values).float()
window = 15
# Creating windows of 15 over the dataset
ts_dataset = ts.unfold(0, size=window, step=1) 

现在,我们有了一个张量ts_dataset,其大小为6x15(当我们在序列的长度上滑动窗口时,可以创建 6 个样本,每个样本包含 15 个输入特征)。对于标准的 FFN,输入形状指定为批次大小 x 输入特征。所以 6 是我们的批次大小,15 是输入特征的大小。

现在,让我们定义 FFN 中的各层。对于这个练习,我们假设网络结构如下:

图 12.3 – FFN – 矩阵乘法视角

图 12.3:FFN——矩阵乘法视角

输入数据(6x15)将依次通过这些层。在这里,我们可以看到随着数据通过网络时张量维度的变化。每一层线性变换基本上是一个矩阵乘法,它将输入转换成指定维度的输出。在每次线性变换后,我们会堆叠一个非线性激活函数。这些交替的线性和非线性模块赋予神经网络表达能力。线性层是对向量空间的仿射变换(旋转、平移等),而非线性函数则将向量空间“压缩”。它们共同作用,可以将输入空间转化为适合当前任务的形式。现在,让我们看看如何用 PyTorch 编写代码实现这一过程。

我们将使用 PyTorch 中一个非常方便的模块,叫做Sequential,它允许我们将不同的子组件堆叠在一起,并轻松使用它们:

# The FFN we define would have this architecture
# window(windowed input) >> 64 (hidden layer 1) >> 32 (hidden layer 2) >> 32 (hidden layer 2) >> 1 (output)
ffn = nn.Sequential(
    nn.Linear(in_features=window,out_features=64), # (batch-size x window) --> (batch-size x 64)
    nn.ReLU(),
    nn.Linear(in_features=64,out_features=32), # (batch-size x 64) --> (batch-size x 32)
    nn.ReLU(),
    nn.Linear(in_features=32,out_features=32), # (batch-size x 32) --> (batch-size x 32)
    nn.ReLU(),
    nn.Linear(in_features=32,out_features=1), # (batch-size x 32) --> (batch-size x 1)
) 

现在我们已经定义了 FFN,接下来我们来看一下如何使用它:

ffn(ts_dataset)
# or more explicitly
ffn.forward(ts_dataset) 

这将返回一个张量,其形状基于批处理大小 x 输出单元。我们可以有任意数量的输出单元,而不仅仅是一个。因此,在使用编码器时,我们可以为潜在向量设置任意维度。然后,当我们将其用作解码器时,可以让输出单元等于我们预测的时间步数。

预览

我们直到现在还没有看到多步预测,因为它将在第十八章多步预测中更详细地讨论。但现在,只需要理解有些情况下我们需要预测未来多个时间步。经典的统计模型可以直接做到这一点。但对于机器学习和深度学习,我们需要设计能够做到这一点的系统。幸运的是,有几种不同的技术可以实现这一点,这将在本章后面进行介绍。

FFN(前馈神经网络)是为非时间序列数据设计的。我们可以通过将数据嵌入时间序列中,再将其传递给网络来使用 FFN。此外,FFN 的计算成本与我们在嵌入中使用的内存(我们作为特征包含的前几个时间步数)直接成正比。在这种设置下,我们也无法处理变长序列。

现在,让我们来看一下另一种专门为时间序列数据设计的常见架构。

递归神经网络

递归神经网络RNN)是一类专门为处理序列数据而设计的神经网络。它们最早由Rumelhart 等人(1986 年)在他们的开创性工作《通过反向传播误差学习表示》中提出。该工作借鉴了统计学和机器学习中以前工作的思想,如参数共享和递归,从而得到了一种神经网络架构,帮助克服了 FFN 在处理序列数据时的许多缺点。

RNN 架构

参数共享是指在模型的不同部分使用相同的一组参数。除了具有正则化效果(限制模型在多个任务中使用相同的权重,这通过在优化模型时约束搜索空间来正则化模型)外,参数共享使我们能够扩展并将模型应用于不同形式的示例。正因如此,RNN 可以扩展到更长的序列。在 FFN 中,每个时间步(每个特征)都有固定的权重,即使我们要寻找的模式仅偏移一个时间步,网络也可能无法正确捕捉到它。而在启用了参数共享的 RNN 中,模式能够以更好的方式被捕捉到。

在一句话中(它也是一个序列),我们可能希望模型识别出“明天我去银行”和“我明天去银行”是相同的。一个 FFN 做不到这一点,但一个 RNN 可以做到,因为它在所有位置使用相同的参数,并能够识别出模式“我去银行”无论它出现在何处。直观地说,我们可以将 RNN 看作是在每个时间窗口应用相同的 FFN,但通过某种记忆机制增强,以便存储与当前任务相关的信息。

让我们来直观地理解一下 RNN 如何处理输入:

图 12.4 – RNN 如何处理输入序列

图 12.4:RNN 如何处理输入序列

假设我们讨论的是一个包含四个元素的序列,x[1] 到 x[4]。任何 RNN 块(暂时把它当作一个黑盒)都会消耗输入和隐藏状态(记忆),并生成一个输出。一开始没有记忆,因此我们从初始记忆 (H[0]) 开始,这通常是一个全为零的数组。现在,RNN 块接收第一个输入 (x[1]) 和初始隐藏状态 (H[0]),生成输出 (o[1]) 和新的隐藏状态 (H[1])。

为了处理序列中的第二个元素,同一个 RNN 块会接收来自上一个时间步的隐藏状态 (H[1]) 和当前时间步的输入 (x[2]),生成第二个时间步的输出 (o[2]) 和新的隐藏状态 (H[2])。这个过程会持续直到我们处理完序列的所有元素。处理完整个序列后,我们将获得每个时间步的所有输出 (o[1] 到 o[4]) 和最终的隐藏状态 (H[4])。

这些输出和隐藏状态将会编码序列中包含的信息,并可用于进一步处理,例如使用解码器预测下一步。RNN 块也可以作为解码器,接受编码后的表示并生成输出。由于这种灵活性,RNN 块可以根据各种输入和输出组合进行排列,如下所示:

  • 多对一,其中有多个输入和一个输出——例如,单步预测或时间序列分类

  • 多对多,其中我们有多个输入和多个输出——例如,多步预测

现在,让我们看看 RNN 内部发生了什么。

设 RNN 在时间t的输入为x[t],上一时间步的隐状态为H[t][-1]。更新的方程如下:

在这里,UVW是可学习的权重矩阵,b[1]和b[2]是两个可学习的偏置向量。根据它们执行的变换类型,UVW可以很容易地记住为输入到隐层隐层到输出隐层到隐层矩阵。直观地,我们可以将 RNN 执行的操作理解为一种学习和遗忘信息的方式,它根据需要决定保留和遗忘什么信息。tanh激活函数,如我们在第十一章 深度学习导论中所看到的,生成一个介于-1 和 1 之间的值,类似于遗忘和记忆。因此,RNN 将输入转换为一个潜在的维度,使用tanh激活函数决定保留和遗忘当前时间步和之前记忆中的哪些信息,并使用这个新的记忆生成输出。

在标准的反向传播中,我们将梯度从一个单元反向传播到另一个单元。但在递归神经网络(RNN)中,我们有一个特殊情况,必须在单个单元内部进行梯度反向传播,但通过时间或不同的时间步长。为 RNN 开发了一种特殊的反向传播方式,叫做通过时间的反向传播BPTT)。

幸运的是,所有主要的深度学习框架都能够顺利地完成这项工作。有关 BPTT 的更详细理解及其数学基础,请参考进一步阅读部分。

PyTorch 已经将 RNN 作为即用模块提供——你只需导入库中的一个模块并开始使用它。但在这之前,我们需要理解一些其他的概念。

我们将要看的第一个概念是将多个层的 RNN 堆叠在一起的可能性,使得每个时间步的输出成为下一层 RNN 的输入。每一层将具有一个隐状态或记忆。这使得层次化特征学习成为可能,这是当今成功深度学习的基石之一。

另一个概念是双向RNN,由 Schuster 和 Paliwal 在 1997 年提出。双向 RNN 与 RNN 非常相似。在普通的 RNN 中,我们按顺序从开始到结束(前向)处理输入。然而,双向 RNN 使用一组输入到隐层和隐层到隐层的权重从开始到结束处理输入,然后使用另一组权重按反向(从结束到开始)处理输入,并将来自两个方向的隐层状态连接起来。我们在这个连接的隐层状态上应用输出方程。

参考检查

Rumelhart 等人的研究论文和 Schuster 和 Paliwal 的论文分别在 参考文献 部分被引用为 56

PyTorch 中的 RNN

现在,让我们了解一下 PyTorch 中 RNN 的实现。与 线性 模块一样,RNN 模块也可以通过 torch.nn 获得。让我们来看一下初始化时实现所提供的不同参数:

  • input_size:输入中预期的特征数。如果我们只使用时间序列的历史数据,则为 1。但是,当我们同时使用历史数据和其他一些特征时,则为大于 1 的值。

  • hidden_size:隐藏状态的维度。这定义了输入到隐藏层和隐藏层到隐藏层的矩阵大小。

  • num_layers:这是将堆叠在一起的 RNN 数量。默认值是 1

  • nonlinearity:使用的非线性函数。虽然 tanh 是最初提出的非线性函数,但 PyTorch 也允许我们使用 ReLU(relu)。默认值是 tanh

  • bias:该参数决定是否将偏置项添加到我们之前讨论的更新方程中。如果参数为 False,则没有偏置。默认值是 True

  • batch_first:RNN 单元可以使用两种输入数据配置——我们可以将输入设置为(batch sizesequence lengthnumber of features)或(sequence lengthbatch sizenumber of features)。batch_first = True 选择前者作为预期的输入维度。默认值是 False

  • dropout:该参数如果不为零,会在每个 RNN 层的输出上使用 dropout 层,除了最后一层。Dropout 是一种常用的正则化技术,在训练过程中随机忽略选中的神经元(进一步阅读部分包含了提出该技术的论文链接)。dropout 的概率将等于 dropout。默认值是 0

  • bidirectional:该参数启用双向 RNN。如果 True,则使用双向 RNN。默认值是 False

为了继续在本章中使用我们之前生成的相同合成数据,我们将初始化 RNN 模型,如下所示:

rnn = nn.RNN(
    input_size=1,
    hidden_size=32,
    num_layers=1,
    batch_first=True,
    dropout=0,
    bidirectional=False,
) 

现在,让我们看看 RNN 单元预期的输入和输出。

与我们之前看到的线性层不同,RNN 单元接收两个输入——输入序列和隐藏状态向量。输入序列可以是(batch sizesequence lengthnumber of features)或(sequence lengthbatch sizenumber of features),具体取决于我们是否设置了 batch_first=True。隐藏状态是一个张量,大小为(D层数,batch sizehidden size),其中 D = 1 对于 bidirectional=FalseD = 2 对于 bidirectional=True。隐藏状态是一个可选输入,如果未填写,则默认为零张量。

RNN 单元有两个输出:一个输出和一个隐藏状态。输出可以是(batch sizesequence lengthD隐藏层大小)或(sequence lengthbatch sizeD隐藏层大小),具体取决于batch_first。隐藏状态的维度是(D层数,batch sizehidden size)。这里,D = 1 或 2 取决于双向参数。

所以让我们通过 RNN 运行我们的序列,并观察输入和输出(有关更详细的步骤,请参考附带的笔记本):

#input dim: torch.Size([6, 15, 1])
# batch size = 6, sequence length = 15 and number of features = 1, batch_first = True
output, hidden_states = rnn(rnn_input)
# output.shape -> torch.Size([6, 15, 32])
# hidden_states.shape -> torch.Size([1, 6, 32])) 

虽然我们看到 RNN 单元包含输出和隐藏状态,但我们也知道输出只是隐藏状态的仿射变换。因此,为了给用户提供灵活性,PyTorch 只在模块中实现了关于隐藏状态的更新方程。对于某些情况(如多对一场景),我们可能根本不需要每个时间步的输出,如果我们不在每个步骤中执行输出更新,就可以节省计算。因此,PyTorch RNN 的output只是每个时间步的隐藏状态,而hidden_states则是最新的隐藏状态。

我们可以通过检查隐藏状态张量是否等于最后的输出张量来验证这一点:

torch.equal(hidden_states[0], output[:,-1]) # -> True 

为了更清楚地说明这一点,让我们用视觉化的方式来看:

图 12.5 – PyTorch 实现的堆叠 RNN

图 12.5:PyTorch 实现的堆叠 RNN

每个时间步的隐藏状态作为输入传递给后续的 RNN 层,最后一层 RNN 的隐藏状态被收集作为输出。但每一层都有一个隐藏状态(它不会与其他层共享),PyTorch 的 RNN 会收集每一层的最后一个隐藏状态,并将其作为输出返回。

现在,由我们来决定如何使用这些输出。例如,在一步预测中,我们可以使用输出的隐藏状态并在其上堆叠几个线性层,以获取下一个时间步的预测。或者,我们可以使用隐藏状态将记忆传递给另一个 RNN 作为解码器,并生成多个时间步的预测。我们可以使用输出的方式有很多,PyTorch 给了我们这种灵活性。

RNN 在建模序列时虽然非常有效,但有一个大缺点。由于 BPTT,反向传播所需经过的单元数量随着训练使用的序列长度增加而急剧增加。当我们必须在这么长的计算图中进行反向传播时,我们会遇到梯度消失梯度爆炸的问题。这时,梯度在网络中反向传播时,要么缩小为零,要么爆炸成一个非常大的数值。前者使得网络停止学习,而后者则使得学习变得不稳定。

我们可以将发生的事情类比于将一个标量数字反复与自身相乘的过程。如果这个数字小于一,那么每次相乘后,这个数字会变得越来越小,直到几乎为零。如果这个数字大于一,那么它会以指数级别越来越大。这一发现最早由 Hochreiter 在其 1991 年的学位论文中独立提出,之后 Yoshua Bengio 等人在 1993 年和 1994 年分别发表了两篇相关论文。多年来,许多关于该模型和训练过程的改进方案应运而生,以应对这一缺点。如今,传统的 RNN 几乎不再使用,几乎完全被其更新版本所取代。

参考检查

Hochreiter(1991)和 Bengio 等人(1993,1994)的相关文献在参考文献部分被列为789

现在,让我们来看一下对 RNN 架构所做的两项关键改进,这些改进在机器学习社区中表现良好,获得了广泛的关注。

长短期记忆(LSTM)网络

Hochreiter 和 Schmidhuber 在 1997 年提出了对经典 RNN 的修改——LSTM 网络。它旨在解决传统 RNN 中的梯度消失和梯度爆炸问题。LSTM 的设计灵感来自计算机中的逻辑门。它引入了一个新的组件——记忆单元,作为长期记忆,除了经典 RNN 中的隐藏状态记忆外,它还被用于存储信息。在 LSTM 中,多个门负责从这些记忆单元中读取、添加和遗忘信息。这个记忆单元作为一个梯度高速公路,使得信息可以相对不受阻碍地通过网络传递。这正是避免 RNN 中梯度消失的关键创新。

LSTM 架构

假设 LSTM 在时间t的输入是x[t],上一时刻的隐藏状态是H[t][-1]。现在,有三个门处理信息。每个门实际上由两个可学习的权重矩阵组成(一个用于输入,一个用于上一时刻的隐藏状态),以及一个偏置项,它会与输入和隐藏状态相乘/相加,最后通过一个 sigmoid 激活函数。

这些门的输出将是一个介于 0 和 1 之间的实数。让我们详细了解每个门的作用:

  • 输入门:此门的功能是决定从当前输入和前一个隐藏状态中读取多少信息。其更新方程为:

  • 遗忘门:遗忘门决定了从长期记忆中应忘记多少信息。其更新方程为:

  • 输出门:输出门决定了当前单元状态中有多少应当用于生成当前的隐藏状态,隐藏状态即为该单元的输出。其更新方程为:

这里,W[xi]、W[xf]、W[xo]、W[hi]、W[hf] 和 W[ho] 是可学习的权重参数,b[i]、b[f] 和 b[o] 是可学习的偏置参数。

现在,我们可以引入一个新的长期记忆(单元状态),C[t]。之前提到的三个门控机制用于更新和遗忘该记忆。如果前一时刻的单元状态是 C[t-1],那么 LSTM 单元将使用另一个门计算候选单元状态,,这次使用 tanh 激活函数:

这里,W[xc] 和 W[xh] 是可学习的权重参数,b[c] 是可学习的偏置参数。

现在,让我们看一下关键的更新方程式,它用于更新单元的状态或长期记忆:

这里, 是逐元素相乘。我们使用遗忘门来决定从前一时刻传递多少信息,并使用输入门来决定当前候选单元状态中多少将被写入长期记忆。

最后但同样重要的是,我们使用新创建的当前单元状态和输出门来决定通过当前隐藏状态向预测器传递多少信息:

这个过程的可视化表示可以在 图 12.6 中看到。

图 12.6:LSTM 的门控示意图

PyTorch 中的 LSTM

现在,让我们理解一下 PyTorch 中 LSTM 的实现。它与我们之前看到的 RNN 实现非常相似,但有一个关键区别:初始化该类的参数几乎相同。该 API 可以在 pytorch.org/docs/stable/generated/torch.nn.LSTM.html#torch.nn.LSTM 上找到。这里的关键区别在于隐藏状态的使用方式。虽然 RNN 有一个张量作为隐藏状态,但 LSTM 期望的是一个元组,包含两个相同维度的张量:(隐藏状态单元状态)。

LSTM 和 RNN 一样,也有堆叠和双向变体,PyTorch 以相同的方式处理它们。

现在,让我们初始化一些 LSTM 模块,并使用我们一直在使用的合成数据来查看它们的实际效果:

lstm = nn.LSTM(
    input_size=1,
    hidden_size=32,
    num_layers=5,
    batch_first=True,
    dropout=0,
    # bidirectional=True,
)
output, (hidden_states, cell_states) = lstm(rnn_input)
output.shape # -> [6, 15, 32]
hidden_states.shape # -> [5, 6, 32]
cell_states.shape # -> [5, 6, 32] 

现在,让我们来看看对普通 RNN 所做的另一个改进,它解决了梯度消失和梯度爆炸问题。

门控循环单元(GRU)

2014 年,Cho 等人提出了另一种 RNN 变体,它的结构比 LSTM 简单得多,叫做 GRU。其背后的直觉类似于我们使用多个门来调节信息流动,但 GRU 消除了长期记忆部分,仅使用隐藏状态来传播信息。因此,记忆单元不再成为 梯度高速公路,而是隐藏状态本身成为“梯度高速公路”。遵循我们在上一节中使用的相同符号约定,让我们来看一下 GRU 的更新方程。

GRU 结构

虽然 LSTM 中有三个门,但 GRU 中只有两个门:

  • 重置门:该门决定了前一个隐藏状态的多少部分会被作为当前时间步的候选隐藏状态。其方程为:

  • 更新门:更新门决定了前一个隐藏状态的多少部分应被传递下去,以及当前候选隐藏状态的多少部分会被写入隐藏状态。其方程为:

这里的 W[xr]、W[xu]、W[hr] 和 W[hu] 是可学习的权重参数,而 b[r] 和 b[u] 是可学习的偏置参数。

现在,我们可以计算候选隐藏状态(),如下所示:

这里,W[xh] 和 W[hh] 是可学习的权重参数,而 b[h] 是可学习的偏置参数。这里,我们使用重置门来限制从前一个隐藏状态到当前候选隐藏状态的信息流。

最后,当前隐藏状态(即传递给预测器的输出)通过以下方程计算:

我们使用更新门来决定从前一个隐藏状态和当前候选状态中传递给下一个时间步或预测器的比例。

参考检查

LSTM 和 GRU 的研究论文分别在参考文献部分列为1011

该过程的可视化表示可以在图 12.7中找到:

图 12.6 – LSTM 与 GRU 的门控图

图 12.7:GRU 的门控图

PyTorch 中的 GRU

现在,让我们了解 PyTorch 中 GRU 的实现。API、输入和输出与 RNN 相同。可以在这里参考该 API:pytorch.org/docs/stable/generated/torch.nn.GRU.html#torch.nn.GRU。关键的区别在于模块的内部工作原理,其中使用了 GRU 更新方程,而不是标准的 RNN 方程。

现在,让我们初始化一个 GRU 模块,并使用我们一直在使用的合成数据来看它的实际应用:

Gru = nn.GRU(
    input_size=1,
    hidden_size=32,
    num_layers=5,
    batch_first=True,
    dropout=0,
    # bidirectional=True,
)
output, hidden_states = gru(rnn_input)
output.shape # -> [6, 15, 32]
hidden_states.shape # -> [5, 6, 32] 

现在,让我们看看另一种可以用于序列数据的主要组件。

卷积网络

卷积网络,也称为 卷积神经网络 (CNNs),类似于处理网格形式数据的神经网络。这个网格可以是二维(如图像)、一维(如时间序列)、三维(如来自激光雷达传感器的数据)等。尽管本书涉及的是时间序列,通常时间序列预测中使用的是一维卷积,但从二维(如图像)理解卷积会更容易,然后再回到一维网格处理时间序列。

CNN 的基本思想灵感来源于人类视觉的工作原理。1979 年,福岛提出了 Neocognitron(参考文献 12)。这是一种独特的架构,直接受到人类视觉工作原理的启发。但 CNN 如我们今天所知,直到 1989 年才出现,当时 Yann Le Cun 使用反向传播算法学习了这种网络,并通过在手写数字识别中取得最先进的成果(参考文献 13)来证明这一点。2012 年,当 AlexNet(用于图像识别的 CNN 架构)在年度图像识别挑战赛 ImageNet 中获胜时,且与竞争的非深度学习方法相比,差距巨大,CNN 的兴趣和研究达到了巅峰。人们很快意识到,除了图像外,CNN 对于序列数据(如语言和时间序列数据)同样有效。

卷积

CNN 的核心是一个叫做 卷积 的数学运算。卷积操作的数学解释超出了本书的范围,但如果你想了解更多,可以在 进一步阅读 部分找到一些相关链接。为了我们的目的,我们将对卷积操作形成直观的理解。

由于 CNN 在使用图像数据时获得了广泛的关注,我们先从图像领域开始讨论,然后再转向序列领域。

任何图像(为了简化,假设它是灰度图像)可以看作是一个像素值的网格,每个值表示一个点的亮度,1 代表纯白色,0 代表纯黑色。在我们开始讨论卷积之前,先了解什么是 卷积核。目前,我们可以将卷积核看作一个包含某些值的二维矩阵。通常,卷积核的大小小于我们使用的图像的大小。由于卷积核小于图像,因此我们可以将卷积核“放入”图像中。我们从将卷积核对齐到左上角边缘开始。卷积核在当前位置时,图像中有一组值被该卷积核覆盖。我们可以对图像的这一子集和卷积核进行逐元素相乘,然后将所有元素加起来得到一个标量。现在,我们可以通过将卷积核“滑动”到图像的所有位置,重复此过程。例如,下面展示了一个 4x4 的示例输入图像,并演示了如何使用一个 2x2 的卷积核对其进行卷积操作:

图 12.7 – 在 2D 和 1D 输入上进行的卷积操作

图 12.8:在 2D 和 1D 输入上进行的卷积操作

因此,如果我们将 2x2 的卷积核放置在左上角位置,并执行逐元素乘法和求和操作,我们将得到 3x3 输出中的左上角项。如果我们将卷积核向右滑动一个位置,我们将得到输出顶部行中的下一个元素,以此类推。同样,如果我们将卷积核向下滑动一个位置,我们将得到输出中第一列的第二个元素。

虽然这很有趣,但我们想从时间序列的角度理解卷积。为此,让我们将视角转向 1D 卷积——即在 1D 数据(如序列)上执行的卷积操作。在前面的图示中,我们也可以看到一个 1D 卷积的例子,其中我们将 1D 核滑动到序列上,以得到一个 1x3 的输出。

尽管我们已经设置了方便理解和计算的核权重,但在实际应用中,这些权重是通过网络从数据中学习得到的。如果我们将核大小设置为n,并且所有的核权重都设置为 1/n,那么这种卷积会给我们带来什么呢?这是我们在第六章时间序列预测的特征工程中讨论过的内容。是的,它们的结果就是具有n窗口的滚动均值。记住,我们曾经将其作为机器学习模型的特征工程技巧来学习。因此,1D 卷积可以被看作是一个更强大的特征生成器,其中特征是从数据中学习到的。通过在核上使用不同的权重,我们可以提取不同的特征。正是这一点知识,我们在学习时间序列数据的卷积神经网络时需要牢记。

填充、步幅和扩张

现在我们已经理解了卷积操作是什么,我们需要了解更多的术语,如填充步幅扩张

在我们开始讨论这些术语之前,先来看一个给定输入维度(L)、核大小(k)、填充大小(p[l]为左填充,p[r]为右填充)、步幅(s)和扩张(d)时,卷积层输出维度(O)的公式:

这些术语的默认值(填充、步幅和扩张是卷积过程的特例)是 p[r],p[l] = 0,s = 1,d = 1。即使你暂时不理解公式或其中的术语,也不要担心——只要记住这些默认值,当我们理解每个术语时,其他的可以忽略。

图 12.8中,我们看到卷积操作总是会减少输入的大小。因此,在默认情况下,公式变为 O = L – (k - 1)。这是因为我们可以将核放置在序列中的最早位置,即从t = 0 到t = k。然后,通过在序列上进行卷积,我们可以在输出中得到 L – (k - 1) 项。填充是指我们在序列的开始或结束处添加一些值。我们用于填充的值取决于问题。通常,我们选择零作为填充值。因此,填充序列本质上是增加了输入的大小。因此,在前面的公式中,我们可以将 L + p[l] + p[r] 视为填充后序列的有效长度。

接下来的两个术语(步幅和扩张)与卷积层的感受野密切相关。卷积层的感受野是输入空间中影响由卷积层生成的特征的区域。换句话说,它是我们在进行卷积操作时,所使用的输入窗口的大小。对于单个卷积层(使用默认设置),这几乎就是内核的大小。对于多层 CNN,这个计算变得更加复杂,因为它具有层次结构(进一步阅读部分包含了 Arujo 等人提出的一个公式,用于计算 CNN 的感受野)。但通常来说,增加 CNN 的感受野与提高 CNN 的准确度相关。对于计算机视觉,Araujo 等人指出:

“我们观察到分类准确度与感受野大小之间呈对数关系,这表明大感受野对于高层次的识别任务是必要的,但回报递减。”

在时间序列中,这一点很重要,因为如果 CNN 的感受野小于我们想要捕捉的长期依赖性(如季节性),那么网络就无法做到这一点。通过在卷积层上堆叠更多的卷积层来加深 CNN 是增加网络感受野的一种方式。然而,也有几种方法可以增加单个卷积层的感受野。步幅和扩张就是其中的两种方法:

  • 步幅:之前,当我们讨论将内核在序列上滑动时,我们提到我们每次移动内核一个位置。这被称为卷积层的步幅,并且步幅不一定非得是 1。如果我们将步幅设置为 2,那么卷积操作将跳过一个位置,如图 12.9所示。这可以使卷积网络中的每一层查看更大范围的历史,从而增加感受野。

  • 扩张:我们可以通过扩张输入连接来调整基本的卷积层。在标准卷积层中,假设内核大小为 3,我们将内核应用于输入中的三个连续元素,扩张值为 1。如果我们将扩张设置为 2,那么内核将被空间扩张,并且将被应用。它不再应用于三个连续的元素,而是跳过其中的一个元素。图 12.8展示了这个过程。正如我们所看到的,这也可以增加网络的感受野。

这两种技术虽然相似,但有所不同,并且可以互相兼容。下图展示了当我们同时应用步幅和扩张时会发生什么(尽管这种情况不常见):

图 12.8 – 卷积中的步幅和扩张图 12.9: 卷积中的步幅和扩张

现在,如果我们想要使输出维度与输入维度相同怎么办?通过使用一些基本的代数和重新排列前面的公式,我们得到了以下结果:

P[l] + p[r] = d(k-1) + L(s-1) – (s-1)

在时间序列中,我们通常在左侧进行填充,而不是右侧,因为通常存在强自相关性。用零或其他值填充最近的几个条目会使预测函数的学习非常困难,因为最新的隐藏状态直接受到填充值的影响。进一步阅读部分包含了 Kilian Batzner 关于自回归卷积的文章链接。如果你希望真正理解我们在这里讨论的概念,并了解其中的一些限制,这是必读的。进一步阅读部分还包含了一个 GitHub 存储库的链接,其中包含了二维输入卷积动画,这将帮助你直观地理解发生了什么。

在卷积中,有一个常见的术语,尤其是在时间序列中经常听到的——因果卷积。你只需记住因果卷积并不是特殊类型的卷积。只要我们确保在训练时不使用未来的时间步来预测当前的时间步,我们就在执行因果操作。通常通过偏移目标和填充输入来实现这一点。

PyTorch 中的卷积

现在,让我们来了解 CNN 在 PyTorch 中的实现(一维 CNN,通常用于时间序列等序列)。让我们看看在初始化时实现提供的不同参数。我们刚刚讨论了以下术语,所以现在它们应该对你来说很熟悉了:

  • in_channels: 输入中预期的特征数。如果我们仅使用时间序列的历史记录,那么这个值将为 1。但是,当我们同时使用历史记录和其他特征时,这个值将大于 1。对于后续的层,你在前一层中使用的out_channels将成为当前层的in_channels

  • out_channels: 应用于输入的核或过滤器的数量。每个核/过滤器都会产生一个具有自己权重的卷积操作。

  • kernel_size: 这是我们用于卷积的核的大小。

  • stride: 卷积的步幅。默认值为1

  • padding:这是添加到两边的填充。如果我们将值设置为2,那么传递给该层的序列将在左右两边都有填充位置。我们还可以输入validsame。这两者是表示所需填充类型的简便方法。padding='valid'相当于没有填充。padding='same'会对输入进行填充,使得输出形状与输入相同。然而,这种模式不支持除1以外的任何步幅值。默认值为0

  • padding_mode:定义如何用值填充填充位置。最常见和默认的选项是,即所有填充的标记都填充为零。另一个对时间序列相关的有用模式是复制,其行为类似于 pandas 中的前向和后向填充。另两个选项——反射循环——则更为特殊,仅用于特定的用例。默认值为

  • dilation:卷积的膨胀。默认值为1

  • groups:此参数允许你控制输入通道与输出通道的连接方式。groups中指定的数字决定了会形成多少组,以便卷积仅在组内进行,而不会跨组进行。例如,group=2表示一半的输入通道将由一组核进行卷积,而另一半将由另一组核进行卷积。这相当于并行运行两个卷积层。有关此参数的更多信息,请查看文档。再次强调,这适用于一些特殊的用例。默认值为1

  • bias:此参数为卷积添加一个可学习的偏置。默认值为True

让我们对本章早些时候生成的相同合成数据应用 CNN 模型,卷积核大小为 3:

conv = nn.Conv1d(in_channels=1, out_channels=1, kernel_size=k) 

现在,让我们来看一下 CNN 预期的输入和输出。

Conv1d要求输入具有三维—(批次大小,通道数,序列长度)。对于初始输入层,通道数是输入网络的特征数;对于中间层,它是上一层使用的核的数量。Conv1d的输出形式为(批次大小,通道数(输出),序列长度(输出))

那么,让我们通过Conv1d运行我们的序列,并查看输入和输出(有关更详细的步骤,请参阅02-Building_Blocks.ipynb笔记本):

#input dim: torch.Size([6, 1, 15])
# batch size = 6, number of features = 1 and sequence length = 15
output = conv(cnn_input)
# Output should be in_dim - k + 1
assert output.size(-1)==cnn_input.size(-1)-k+1
output.shape #-> torch.Size([6, 1, 13]) 

该笔记本提供了对Conv1d的稍微详细的分析,表格展示了超参数对输出形状的影响,填充方式如何使输入和输出维度相同,以及如何用相等权重的卷积就像一个滚动平均。我强烈建议你查看并尝试不同的选项,以更好地理解该层为你做了什么。

Conv1d中的内置填充源自图像处理,因此填充技术默认是在两侧都添加填充。然而,对于序列数据,最好在左侧使用填充,因此,最好单独处理输入序列的填充方式,而不是使用内置机制。torch.nn.functional提供了一个方便的pad方法,可以用来实现这一点。

其他构建模块也被用于时间序列预测,因为深度神经网络的架构只受创意的限制。但本章的重点是介绍一些在许多不同架构中出现的常见模块。我们故意没有介绍目前最流行的架构之一:变换器(Transformer)。这是因为我们已将另一章(第十四章时间序列中的注意力与变换器)专门用于理解注意力机制,然后再研究变换器。另一个逐渐受到关注的重要模块是图神经网络(GNN),它可以被看作是专门处理基于图形数据的卷积神经网络,而不是网格数据。然而,这些超出了本书的范围,因为它们仍是一个活跃的研究领域。

总结

在上一章介绍了深度学习之后,本章我们更深入地了解了用于时间序列预测的常见架构模块。我们解释了编码器-解码器范式,它是构建深度神经网络用于预测的基本方式。然后,我们学习了前馈神经网络(FFN)、循环神经网络(RNN,包括 LSTM 和 GRU)和卷积神经网络(CNN),并探讨了它们是如何用于处理时间序列的。我们还看到如何通过使用相关的笔记本,在 PyTorch 中使用这些主要模块,并且动手写了一些 PyTorch 代码。

在下一章中,我们将学习一些主要的模式,这些模式可以用来安排这些模块以进行时间序列预测。

参考文献

本章使用了以下参考文献:

  1. Neco, R. P. 和 Forcada, M. L. (1997),使用递归神经网络的异步翻译。神经网络,1997 年,国际会议(第 4 卷,第 2535–2540 页)。IEEE:ieeexplore.ieee.org/document/614693.

  2. Kalchbrenner, N. 和 Blunsom, P. (2013),循环连续翻译模型。EMNLP(第 3 卷,第 39 期,第 413 页):aclanthology.org/D13-1176/.

  3. Kyunghyun Cho, Bart van Merriënboer, Caglar Gulcehre, Dzmitry Bahdanau, Fethi Bougares, Holger Schwenk 和 Yoshua Bengio. (2014),使用 RNN 编码器-解码器进行短语表示学习,用于统计机器翻译。2014 年自然语言处理实证方法会议(EMNLP)论文集,第 1724–1734 页,卡塔尔多哈。计算语言学协会:aclanthology.org/D14-1179/.

  4. Ilya Sutskever, Oriol Vinyals 和 Quoc V. Le. (2014), 基于神经网络的序列到序列学习. 第 27 届国际神经信息处理系统会议论文集 – 第 2 卷: dl.acm.org/doi/10.5555/2969033.2969173.

  5. Rumelhart, D., Hinton, G., 和 Williams, R (1986). 通过反向传播误差学习表示. 自然 323, 533–536: doi.org/10.1038/323533a0.

  6. Schuster, M. 和 Paliwal, K. K. (1997). 双向递归神经网络. IEEE 信号处理学报, 45(11), 2673–2681: doi.org/10.1109/78.650093.

  7. Sepp Hochreiter (1991) 关于动态神经网络的研究. 硕士论文, 慕尼黑工业大学: people.idsia.ch/~juergen/SeppHochreiter1991ThesisAdvisorSchmidhuber.pdf.

  8. Y. Bengio, P. Frasconi 和 P. Simard (1993), 递归网络中学习长期依赖关系的问题. IEEE 国际神经网络会议, 第 3 卷,第 1183-1188 页: 10.1109/ICNN.1993.298725.

  9. Y. Bengio, P. Simard 和 P. Frasconi (1994) 使用梯度下降学习长期依赖关系的困难,发表于 IEEE 神经网络事务,第 5 卷,第 2 期,第 157–166 页,1994 年 3 月: 10.1109/72.279181.

  10. Hochreiter, S. 和 Schmidhuber, J. (1997). 长短期记忆. 神经计算, 9(8), 1735–1780: doi.org/10.1162/neco.1997.9.8.1735.

  11. Cho, K., Merrienboer, B.V., Gülçehre, Ç., Bahdanau, D., Bougares, F., Schwenk, H., 和 Bengio, Y. (2014). 使用 RNN 编码器-解码器学习短语表示用于统计机器翻译. EMNLP: www.aclweb.org/anthology/D14-1179.pdf.

  12. Fukushima, K. Neocognitron:一种自组织神经网络模型,用于位置偏移不影响的模式识别机制. 生物控制论 36, 193–202 (1980): doi.org/10.1007/BF00344251.

  13. Y. Le Cun, B. Boser, J. S. Denker, R. E. Howard, W. Habbard, L. D. Jackel 和 D. Henderson. 1990 年. 使用反向传播网络进行手写数字识别. 神经信息处理系统进展 2. Morgan Kaufmann 出版社,美国旧金山, 396–404: proceedings.neurips.cc/paper/1989/file/53c3bce66e43be4f209556518c2fcb54-Paper.pdf.

进一步阅读

请查看以下资源,以进一步了解本章所涉及的主题:

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者及其他读者讨论:

packt.link/mts

第十三章:时间序列的常见建模模式

在上一章中,我们回顾了几个主要的、适合时间序列的深度学习DL)系统的常见构建模块。现在我们知道这些模块是什么,是时候进行更实用的课程了。让我们看看如何将这些常见模块组合在一起,以不同的方式对本书中一直使用的数据集进行时间序列预测建模。

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

  • 表格回归

  • 单步前向递归神经网络

  • 序列到序列模型

技术要求

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

您需要运行以下笔记本以完成本章内容:

  • Chapter02中的02-Preprocessing_London_Smart_Meter_Dataset.ipynb

  • Chapter04中的01-Setting_up_Experiment_Harness.ipynb

  • Chapter06中的01-Feature_Engineering.ipynb

  • Chapter08中的00-Single_Step_Backtesting_Baselines.ipynb01-Forecasting_with_ML.ipynb02-Forecasting_with_Target_Transformation.ipynb

  • Chapter10中的01-Global_Forecasting_Models-ML.ipynb

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

表格回归

第五章作为回归的时间序列预测中,我们看到如何将时间序列问题转换为一个标准的回归问题,使用时序嵌入和时间延迟嵌入。在第六章时间序列预测的特征工程中,我们已经为我们一直在使用的家庭能源消耗数据集创建了必要的特征,在第八章使用机器学习模型进行时间序列预测第九章集成和堆叠,以及第十章全球预测模型中,我们使用传统的机器学习ML)模型进行预测。

就像我们使用标准的机器学习模型进行预测一样,我们也可以使用为表格数据构建的深度学习模型,使用我们已创建的特征工程数据集。我们已经讨论过数据驱动的方法,以及它们在处理大规模数据时的优势。深度学习模型将这一范式推向了更远的层次,使我们能够学习高度数据驱动的模型。在这种情况下,相比于机器学习模型,使用深度学习模型的一个优点是其灵活性。在 第八章第九章第十章 中,我们仅展示了如何使用机器学习模型进行单步预测。我们在 第十八章 中有一个单独的部分,讨论了多步预测的不同策略,并详细介绍了标准机器学习模型在多步预测中的局限性。但现在,我们要理解的是,标准的机器学习模型设计上只输出一个预测值,因此多步预测并不简单。而使用表格数据深度学习模型,我们可以灵活地训练模型来预测多个目标,从而轻松生成多步预测。

PyTorch Tabular 是一个开源库(github.com/manujosephv/pytorch_tabular),它使得在表格数据领域中使用深度学习模型变得更加容易,并且提供了许多最先进的深度学习模型的现成实现。我们将使用 PyTorch Tabular,通过在 第六章时间序列预测的特征工程 中创建的特征工程数据集来生成预测。

PyTorch Tabular 提供了非常详细的文档和教程,帮助你快速入门:pytorch-tabular.readthedocs.io/en/latest/。虽然我们不会深入探讨这个库的所有细节,但我们会展示如何使用一个简化版的模型,利用 FTTransformer 模型对我们正在处理的数据集进行预测。FTTransformer 是一种用于表格数据的最先进的深度学习(DL)模型。表格数据的深度学习模型是一个与其他类型模型完全不同的领域,我在 进一步阅读 部分链接了一篇博客文章,作为该领域研究的入门。就我们而言,我们可以将这些模型视为任何标准的机器学习(ML)模型,类似于 scikit-learn 中的模型。

笔记本提示:

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

我们的步骤与之前相似,首先加载所需的库和数据集。这里唯一不同的是,我们选择的块数比在 第二部分时间序列的机器学习 中使用的要少,只有一半。

这样做是为了使神经网络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,
) 

我们已经使用了在第十章中训练的未调优的全局预测模型与元数据,作为基准,来粗略检查深度学习模型的表现,如下图所示:

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

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

我们可以看到,FTTransformer 模型与我们在第十章中训练的 LightGBM 模型具有竞争力。也许,在适当的调优和分区下,FTTransformer 模型的表现可以和 LightGBM 模型一样,甚至更好。以与 LightGBM 相同的方式训练一个有竞争力的深度学习模型,在许多方面都是有用的。首先,它提供了灵活性,并训练模型一次性预测多个时间步。其次,这也可以与 LightGBM 模型结合在一起,作为集成模型使用,因为深度学习模型带来了多样性,这可以提升集成模型的表现。

尝试的事项:

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

选择几个家庭进行绘图,看看预测结果与目标值的匹配情况。

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

单步前瞻的循环神经网络

尽管我们稍微绕了一点,检查了如何将深度学习回归模型用于训练我们在第十章中学到的相同全局模型,但现在我们回到专门为时间序列构建的深度学习模型和架构上。和往常一样,我们首先会看简单的一步前瞻和局部模型,然后再转向更复杂的建模范式。事实上,我们还有另一章(第十五章全局深度学习预测模型的策略),专门介绍了训练全局深度学习模型时可以使用的技术。

现在,让我们将注意力重新集中到一步步前瞻的局部模型上。我们看到 RNN(普通 RNN,长短期记忆网络LSTM)和门控循环单元GRU))是我们可以用于诸如时间序列等序列数据的一些模块。现在,让我们看看如何在我们一直使用的数据集上(伦敦智能电表数据集)将它们应用于端到端E2E)模型。

尽管我们将查看一些库(例如 darts),这些库使得训练用于时间序列预测的深度学习模型变得更容易,但在本章中,我们将着重讲解如何从零开始开发这些模型。了解时间序列预测的深度学习模型是如何从基础搭建起来的,将帮助你更好地理解在后续章节中我们将要使用和调整的库所需的概念。

我们将使用 PyTorch,如果你不熟悉,我建议你去第十二章时间序列深度学习的构建模块,以及相关的笔记本做一个快速复习。此外,我们还将使用 PyTorch Lightning,这是另一个建立在 PyTorch 之上的库,可以使使用 PyTorch 训练模型变得更加简单,除此之外还有其他一些优点。

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

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

  • __len__(self):此方法设置数据集中样本的最大数量。

  • __get_item__(self, idx):此方法从数据集中获取第idx个样本。

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

  • Data:该参数可以是 pandas DataFrame 或包含时间序列的 NumPy 数组。这是整个时间序列,包括训练、验证和测试数据,数据划分在类内部进行。

  • window:此参数设置每个样本的长度。

  • horizon:此参数设置我们希望获取的未来时间步数作为目标。

  • n_val:此参数可以是floatint数据类型。如果是int,则表示要保留作为验证数据的时间步数。如果是float,则表示要保留的验证数据占总数据的百分比。

  • n_test:此参数与n_val类似,但用于测试数据。

  • normalize:该参数定义了我们希望如何对数据进行标准化。它有三个选项:none表示不进行标准化,global表示我们计算训练数据的均值和标准差,并用此标准化整个序列,使用的公式如下:

local表示我们使用窗口的均值和标准差来标准化该序列。

  • normalize_params:这个参数接收一个包含均值和标准差的元组。如果提供了这个参数,它可以用于进行全局标准化。这通常用于在验证集和测试集上使用训练集的均值和标准差。

  • mode:这个参数设置我们希望创建的数据集类型。它接受以下三种值之一:trainvaltest

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

图 13.2 – 使用数据集和数据加载器抽样时间序列

图 13.2:使用数据集和数据加载器抽样时间序列

现在我们已经定义了数据集,我们需要另一个 PyTorch 组件,叫做数据加载器(dataloader)。数据加载器使用数据集将样本按批次提取出来。 在 PyTorch Lightning 生态系统中,我们还有一个叫做数据模块(datamodule)的概念,它是生成数据加载器的标准方式。我们需要训练数据加载器、验证数据加载器和测试数据加载器。数据模块为数据管道部分提供了很好的抽象封装。我们在src/dl/dataloaders.py中定义了一个名为TimeSeriesDataModule的数据模块,它接收数据以及批次大小,并准备训练所需的数据集和数据加载器。参数与TimeSeriesDataset完全相同,唯一不同的是增加了batch_size参数。

笔记本提醒:

要跟随完整代码,可以使用Chapter13文件夹中的02-One-Step_RNN.ipynb笔记本以及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 的文档(pytorch-lightning.readthedocs.io/en/latest/starter/introduction.html)提供了很好的资源,帮助我们开始使用并深入了解。此外,在进一步阅读部分,我们还链接了一个视频,帮助从纯 PyTorch 过渡到 PyTorch Lightning。我强烈建议你花一些时间熟悉它。

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

  • training_step:该方法接收批次数据,并使用模型获取输出,计算损失/指标,并返回损失值。

  • validation_steptest_step:这些方法接收批次数据,并使用模型获取输出,计算损失/指标。

  • predict_step:该方法用于定义推理时要执行的步骤。如果在推理过程中需要做一些特别的处理,我们可以定义这个方法。如果没有定义,它将使用test_step作为预测时的步骤。

  • configure_optimizers:该方法定义了使用的优化器,例如AdamRMSProp

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

在我们查看模型是如何定义之前,先来看一下不同的配置(SingleStepRNNConfig)参数:

  • rnn_type:该参数接收三个字符串中的一个作为输入:RNNGRULSTM。它定义了我们将要初始化的模型类型。

  • input_size:该参数定义了 RNN 所期望的特征数量。

  • hidden_sizenum_layersbidirectional:这些参数与我们在第十二章《时间序列深度学习的构建块》中看到的 RNN 单元相同。

  • learning_rate:该参数定义了优化过程中的学习率。

  • optimizer_paramslr_scheduler,和 lr_scheduler_params:这些是可以让我们调整优化过程的参数。现在先不需要担心它们,因为它们都已经被设置为智能的默认值。

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

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 方法,它是模型的核心。我们希望我们的模型能进行一步预测,并且从第十二章中,时间序列深度学习的构建块,我们知道典型的 RNN 输出是什么,以及 PyTorch RNN 如何仅在每个时间步输出隐藏状态。让我们先从视觉上了解我们想要做什么,然后看看如何将其编码实现:

图 13.3 – 单步 RNN

图 13.3:单步 RNN

假设我们使用的是在数据加载器中看到的相同示例——一个包含以下条目的时间序列,x[1],x[2],x[3],……,x[7],并且窗口大小为三。所以,数据加载器给出的一个样本将会包含 x[1],x[2] 和 x[3] 作为输入 (x),并且 x[4] 作为目标。我们可以使用这种方法,将序列通过 RNN 处理,忽略所有输出,只保留最后一个输出,并利用它来预测目标 x[4]。但这不是一种高效利用我们样本的方法,对吧?我们也知道,第一时间步的输出(使用 x[1])应该是 x[2],第二时间步的输出应该是 x[3],依此类推。因此,我们可以将 RNN 设计成一种方式,最大化数据的使用,同时在训练过程中使用这些额外的时间点来给模型提供更好的信号。现在,让我们详细分析 forward 方法。

forward 方法接受一个名为 batch 的单一参数,它是输入和输出的元组。因此,我们将 batch 解包成两个变量 xy,像这样:

x, y = batch 

x 的形状将是 (批量大小,窗口长度,特征数),而 y 的形状将是 (批量大小,目标长度,特征数)

现在我们需要将输入序列 (x) 通过 RNN(RNN、LSTM 或 GRU)处理,像这样:

x, _ = self.rnn(x) 

正如我们在第十二章中看到的,时间序列深度学习的构建块,PyTorch RNN 会处理输入并返回两个输出——每个时间步的隐藏状态和输出(即最后一个时间步的隐藏状态)。在这里,我们需要来自所有时间步的隐藏状态,因此我们将其存储在 x 变量中。x 现在的维度将是 (批量大小,窗口长度,RNN 隐藏层大小)。

我们有了隐藏状态,但要得到输出,我们需要对隐藏状态应用一个全连接层,这个全连接层应该在所有时间步中共享。实现这一点的简单方法是定义一个输入大小等于 RNN 隐藏层大小的全连接层,然后执行以下操作:

x = self.fc(x) 

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

现在,我们已经得到了网络的输出,但我们还需要做一些调整来准备目标。当前,y 只有窗口之外的一个时间步,但如果我们跳过 x 中的第一个时间步并将其与 y 连接,我们就可以得到目标,正如我们在 图 13.3 中所看到的那样:

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

通过使用数组索引,我们选择 x 中除了第一个时间步之外的所有内容,并将其与 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 类中有许多选项,完整的参数列表可以在这里找到:pytorch-lightning.readthedocs.io/en/stable/api/pytorch_lightning.trainer.trainer.Trainer.html#pytorch_lightning.trainer.trainer.Trainer

但在这里,我们只会使用最基本的参数。让我们逐一介绍我们将在这里使用的参数:

  • auto_select_gpusgpus:这两个参数让我们可以选择用于训练的 GPU(如果存在)。如果我们将 auto_select_gpus 设置为 True,并将 gpus 设置为 -1,则 Trainer 类会选择机器中所有的 GPU,如果没有 GPU,它会回退到基于 CPU 的训练。

  • callbacks:PyTorch Lightning 提供了许多在训练过程中可以使用的有用回调,如 EarlyStoppingModelCheckpoint 等。即使我们没有显式设置,大多数有用的回调会自动添加,但 EarlyStopping 是一个需要显式设置的有用回调。EarlyStopping 是一个回调函数,可以在训练过程中监控验证损失或指标,并在验证损失开始变差时停止训练。这是一种正则化形式,帮助我们防止模型在训练数据上过拟合。EarlyStopping 具有以下主要参数(完整的参数列表可以在这里找到:pytorch-lightning.readthedocs.io/en/stable/api/pytorch_lightning.callbacks.EarlyStopping.html):

    • monitor:这个参数接受一个字符串输入,指定我们希望监控的早停指标的确切名称。

    • patience:这个参数指定了在监控的指标没有改善的情况下,回调停止训练的轮次。例如,如果我们将patience设置为10,回调将在监控指标恶化的 10 个轮次后停止训练。关于这些细节,还有更详细的说明,您可以在文档中找到。

    • mode:这是一个字符串输入,接受minmax中的一个。它设置了改善的方向。在min模式下,当监控的量停止下降时,训练会停止;在max模式下,当监控的量停止上升时,训练会停止。

  • min_epochsmax_epochs:这些参数帮助我们设定训练应运行的minmax轮次的限制。如果我们使用了EarlyStoppingmin_epochs决定了无论验证损失/度量如何,都会运行的最小轮次,而max_epochs则设置了最大轮次限制。所以,即使在达到max_epochs时验证损失仍在下降,训练也会停止。

    Glossary:

    这里有一些你应该了解的术语,以便全面理解神经网络训练:

    • Training step:表示对参数的单次梯度更新。在批量随机梯度下降SGD)中,每次批次后的梯度更新被视为一步。

    • Batch:一个 batch 是我们通过模型运行的数据样本数量,并在训练步骤中对这些样本的梯度进行平均更新。

    • Epoch:一个 epoch 指的是模型已经看过数据集中所有样本,或者数据集中的所有批次已经用于梯度更新。

所以,让我们初始化一个简单的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 是一个包含所有测试数据框(用于定义 test_dataloader)项目预测的 NumPy 数组,但记得我们之前对原始时间序列进行了标准化处理。现在,我们需要将这个转换反向处理。我们最初用于标准化的均值和标准差仍然存储在训练数据集中。我们只需要将它们取出并反转之前的转换,如下所示:

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

现在,我们可以对它们进行各种操作,例如与实际数据进行对比、可视化预测结果等等。让我们看看模型的表现如何。为了提供背景信息,我们还包含了第八章中使用的单步机器学习模型,《使用机器学习模型预测时间序列》

图 13.4 – MAC000193 家庭的基础单步前馈 RNN 指标

图 13.4:MAC000193 家庭的基础单步前馈 RNN 指标

看起来 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)模型

我们在第十二章《时间序列深度学习基础》中详细讨论了 Seq2Seq 架构和编码器-解码器范式。为了帮助你回忆,Seq2Seq 模型是一种编码器-解码器模型,其中编码器将序列编码成潜在表示,然后解码器使用该潜在表示执行任务。这种设置本质上更加灵活,因为编码器(负责表示学习)和解码器(使用表示进行预测)是分开的。从时间序列预测的角度来看,这种方法的最大优势之一是取消了单步预测的限制。在这种建模模式中,我们可以将预测扩展到任何我们想要的预测时间范围。

在这一节中,我们将组合几个编码器-解码器模型,并像以前使用单步前馈 RNN 一样测试我们的单步预测。

笔记本提示:

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

我们可以使用上一节中开发的相同机制,如TimeSeriesDataModuleBaseModel类和相应的代码,来实现我们的 Seq2Seq 建模模式。让我们定义一个新的 PyTorch 模型,叫做Seq2SeqModel,继承BaseModel类。同时,我们还可以定义一个新的配置文件,叫做Seq2SeqConfig,用于设置模型的超参数。最终版本的代码可以在src/dl/models.py中找到。

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

RNN 到全连接网络

为了方便起见,我们将编码器限制为 RNN 系列模型——可以是普通的 RNN、LSTM 或 GRU。如同我们在第十二章《时间序列深度学习基础》一书中所看到的,在 PyTorch 中,所有 RNN 系列模型都有两个输出——outputhidden states,我们还看到,output 实际上就是在所有时间步的隐藏状态(在堆叠 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方法中,对于第一种情况,我们可以进行如下操作:

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

在这里,我们只取最新时间步的隐藏状态,并通过unsqueeze操作保持三维结构,以符合目标y的维度。

对于第二种情况,我们可以做如下操作:

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

在这里,我们首先重新调整整个隐藏状态,将其展平,然后将其传递给解码器以获得预测结果。我们使用unsqueeze操作来插入我们刚刚压缩的维度,使得输出和目标y具有相同的维度。

尽管理论上我们可以使用全连接解码器来预测尽可能多的未来步数,但在实际操作中是有限制的。当我们需要预测大量的时间步时,模型必须学习一个如此大的矩阵来生成这些输出,而随着矩阵变大,学习变得更加困难。另一个值得注意的点是,这些预测每一个都是独立发生的,且仅依赖于编码器中潜在表示的信息。例如,预测 5 个时间步后的结果只依赖于编码器中的潜在表示,而与时间步 14的预测无关。让我们看看另一种类型的 Seq2Seq,它使得解码更加灵活,并且能更好地考虑问题的时间性。

RNN 到 RNN

我们可以用另一个 RNN 来作为解码器,而不是使用全连接层作为解码器——所以,RNN 家族中的一个模型负责编码,另一个模型负责解码。初始化解码器的过程与初始化编码器相似。如果我们想使用 LSTM 模型作为解码器,可以按照以下方式进行操作:

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

让我们通过一个可视化表示来加深对这个过程的理解:

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

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

编码器部分保持不变:它接收输入窗口,x[1] 到 x[3],并产生输出,o[1] 到 o[3],以及最后的隐藏状态,h[3]。现在,我们有另一个解码器(来自 RNN 家族的模型),它将 h[3] 作为初始隐藏状态,并使用窗口中的最新输入来生成下一个输出。现在,这个输出被反馈到 RNN 作为输入,我们继续生成下一个输出,这个循环会一直持续,直到我们得到预测所需的时间步数。

你们可能会想知道,为什么在解码时不使用目标窗口(x[4] 到 x[6])。事实上,这是一种有效的训练模型的方法,在文献中称为 教师强制。这种方法与最大似然估计有很强的联系,并且在 Goodfellow 等人的《深度学习》一书中有很好的解释(见《进一步阅读》部分)。因此,代替将模型在前一个时间步的输出作为当前时间步 RNN 的输入,我们将真实观察值作为输入,从而消除了前一个时间步可能引入的错误。

虽然这看起来是最直接的做法,但它也有一些缺点。最主要的缺点是解码器在训练过程中看到的输入类型,可能与实际预测过程中看到的输入类型不同。在预测过程中,我们仍然会将前一步模型的输出作为解码器的输入。这是因为在推理模式下,我们无法访问未来的真实观察值。这在某些情况下可能会引发问题。解决这个问题的一种方法是在训练过程中随机选择模型在前一个时间步的输出和真实观察值之间进行选择(Bengio 等人,2015)。

参考检查:

Bengio 等人提出的教师强制方法在文献 1 中有引用。

现在,让我们看看如何通过一个名为 teacher_forcing_ratio 的参数来编写 forward 方法,这个参数是一个从 0 到 1 的小数,决定教师强制的实施频率。例如,如果 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个时间步中。

现在,我们只需要做一件事——决定是否使用教师强制(teacher forcing),然后继续解码下一个时间步。我们可以通过生成一个介于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),其中包含了模型的所有选项和超参数。让我们来看看配置中不同的参数:

  • encoder_type:一个字符串参数,可以取以下三个值之一:RNNLSTMGRU。它决定了我们需要作为编码器使用的序列模型。

  • decoder_type:一个字符串参数,可以取以下四个值之一:RNNLSTMGRUFC(代表全连接)。它决定了我们需要作为解码器使用的序列模型。

  • encoder_paramsdecoder_params:这些参数接受一个包含键值对的字典作为输入。它们分别是编码器和解码器的超参数。对于 RNN 系列的模型,还有另一个配置类 RNNConfig,它设置了标准的超参数,如 hidden_sizenum_layers。对于 FC 解码器,我们需要提供两个参数:window_size,即输入窗口中包含的时间步数,以及 horizon,即我们希望预测的未来时间步数。

  • decoder_use_all_hidden:我们讨论了两种使用全连接解码器的方法。这个参数是一个标志,用于在两者之间切换。如果设置为True,全连接解码器将扁平化所有时间步的隐藏状态,并将它们用于预测;如果设置为False,它只会使用最后一个隐藏状态。

  • teacher_forcing_ratio:我们之前讨论过教师强制,这个参数决定了训练时教师强制的强度。如果是 0,则没有教师强制;如果是 1,每个时间步都会进行教师强制。

  • optimizer_paramslr_schedulerlr_scheduler_params:这些是让我们调整优化过程的参数。暂时不必担心这些,因为它们都已设置为智能默认值。

现在,使用这个配置和模型,让我们进行几个实验。这些实验与我们在上一节中进行的一组实验完全相同。实验的具体代码可以在附带的笔记本中找到。所以,我们进行了以下实验:

  • LSTM_FC_last_hidden:编码器 = LSTM / 解码器 = 全连接,只使用最后一个隐藏状态

  • LSTM_FC_all_hidden:编码器 = LSTM / 解码器 = 全连接,使用所有隐藏状态

  • LSTM_LSTM:编码器 = LSTM / 解码器 = LSTM

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

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

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

Seq2Seq 模型似乎在指标上表现得更好,而 LSTM_LSTM 模型甚至比随机森林模型更好。

在笔记本中有这些预测的可视化。我建议你查看那些可视化,放大,查看地平线的不同地方,等等。你们中精明的观察者一定已经发现预测中有一些奇怪的地方。为了让这个点更清楚,我们来看看我们生成的预测的放大版本(一天的情况):

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

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

现在你看到什么了?关注时间序列中的峰值。它们是对齐的吗?还是看起来有偏移?你现在看到的现象,是当模型学会模仿上一个时间步(如同朴素预测)而不是学习数据中的真实模式时发生的。我们可能会得到好的指标,并且可能会对预测感到满意,但经过检查后我们会发现,这并不是我们想要的预测。这在单步预测模型中特别明显,因为我们仅仅是在优化预测下一个时间步。因此,模型没有真正的动力去学习长期模式,比如季节性等,最终学到的模型就像朴素预测一样。

训练用来预测更长时间范围的模型能够克服这个问题,因为在这种情形下,模型被迫学习更长期的模式。虽然多步预测是第十八章:多步预测中将详细讨论的话题,我们现在先提前看一点。在笔记本中,我们还使用 Seq2Seq 模型训练了多步预测模型。

我们需要做的唯一改变就是:

  • datamodule和模型中定义的预测范围应该进行调整。

  • 我们评估模型的方式也应该有一些小的改变。

让我们看看如何为多步预测定义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 个时间步。我们可以从多个角度来看待这个问题并衡量相关指标,我们将在第三部分中详细讨论。现在,我们可以选择一种启发式方法,假设我们每天只运行一次这个模型,每次预测包含 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 年 1 月 1 日 00:00:00开始。所以,如果我们选择每 48 个时间步作为一个周期,那么每 48 个时间步间隔进行选择,就像只考虑每天开始时做出的预测。借助numpy提供的一些高级索引,我们很容易做到这一点:

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

我们从索引 0 开始,这是 48 个时间步的第一次预测,然后选择每隔 48 个索引(即时间步),并将数组拉平成一维。我们将得到一个具有所需形状的预测数组,然后按照标准程序进行逆变换和指标计算等操作。

这个笔记本包含了进行以下实验的代码:

  • MultiStep LSTM_FC_last_hidden:编码器 = LSTM / 解码器 = 全连接层,仅使用最后一个隐藏状态

  • MultiStep LSTM_FC_all_hidden:编码器 = LSTM / 解码器 = 全连接层,使用所有隐藏状态

  • MultiStep LSTM_LSTM_teacher_forcing_0.0:编码器 = LSTM / 解码器 = LSTM,不使用教师强制

  • MultiStep LSTM_LSTM_teacher_forcing_0.5:编码器 = LSTM / 解码器 = LSTM,使用随机教师强制(随机地,50%的时间启用教师强制)

  • MultiStep LSTM_LSTM_teacher_forcing_1.0:编码器 = LSTM / 解码器 = LSTM,使用完整的教师强制

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

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

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

尽管我们无法将单步预测准确度与多步预测准确度进行比较,但暂时先不考虑这个问题,将单步预测的指标作为最理想情况。由此可见,我们预测一天(48 个时间步)的模型其实并不是那么差,如果我们将预测结果可视化,也不会出现模仿天真预测的情况,因为现在模型被迫学习长期模型和预测:

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

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

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

尝试的方向:

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

恭喜您又完成了另一个动手实践的实用章节。如果这是您第一次训练神经网络,希望这一课程让您有足够的信心去尝试更多:尝试和实验这些技术是学习的最佳方式。在机器学习中,并没有适用于所有数据集的灵丹妙药,因此我们从业者需要保持开放的选择权,并选择适合我们用例并在其中表现良好的正确算法/模型。在这个数据集中,我们可以看到,对于单步预测,LightGBM 效果非常好。但是 LSTM Seq2Seq 模型的效果几乎一样好。当我们扩展到多步预测的情况时,拥有一个单一模型执行多步预测并具有足够好的性能的优势可能会超过管理多个机器学习模型(关于这一点将在第十八章详细介绍)。我们学习的技术在深度学习领域仍被认为是基础的,在接下来的章节中,我们将深入探讨深度学习的更复杂的方法。

摘要

尽管我们在上一章节学习了深度学习的基本组成部分,但我们在使用 PyTorch 将所有这些内容付诸实践时,将这些组件应用于常见的建模模式。

我们看到了标准的序列模型如 RNN、LSTM 和 GRU 如何用于时间序列预测,然后我们转向了另一种模型范式,称为 Seq2Seq 模型。在这里,我们讨论了如何混合和匹配编码器和解码器以获得我们想要的模型。编码器和解码器可以是任意复杂的。虽然我们看了简单的编码器和解码器,但肯定可以有像卷积块和 LSTM 块的组合一起工作的东西作为编码器。最后但并非最不重要的,我们谈到了教师强制及其如何帮助模型更快地训练和收敛,以及一些性能提升。

在下一章中,我们将探讨过去几年引起广泛关注的一个主题(双关语):注意力和 Transformer。

参考资料

  1. Samy Bengio,Oriol Vinyals,Navdeep Jaitly 和 Noam Shazeer(2015)。用于序列预测的定时采样第 28 届国际神经信息处理系统大会论文集—第 1 卷NIPS’15):proceedings.neurips.cc/paper/2015/file/e995f98d56967d946471af29d7bf99f1-Paper.pdf

进一步阅读

查看以下来源以进一步阅读:

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者一起讨论:

packt.link/mts