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

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

人类一直在追寻预测未来的能力。从最早的文明开始,人们就尝试预测未来。萨满、神谕者和预言家们使用从占星术、手相学到数字学等各种方法,来满足人类对未来的探索需求。在过去的一个世纪里,随着信息技术的发展,预测未来的重担落到了数据分析师和数据科学家的肩上。那么,我们如何预测未来呢?不再是通过查看手上的线条或星星的位置,而是通过分析过去生成的数据。我们不再依赖预言,而是依靠预测。

时间,作为我们世界中的第四维,使得所有在世界上生成的数据都成为时间序列数据。所有在现实世界中生成的数据都与时间相关。无论时间因素是否与问题相关,是另外一个问题。然而,为了更加具体和直观,我们可以在许多行业中找到时间序列预测的应用场景,如零售、能源、医疗和金融。我们可能想知道某个特定产品将要运送到某个特定商店的数量,或者我们可能想知道需要生产多少电力才能满足需求。

在本书中,通过使用真实世界的数据集,你将学习如何使用pandasplotly处理和可视化时间序列数据,如何使用darts生成基准预测,如何使用机器学习和深度学习进行预测,使用流行的 Python 库,如scikit-learnPyTorch。本书最后几章介绍了一些很少触及的方面,如多步预测、预测指标和时间序列的交叉验证。

本书将帮助你掌握并应用现代机器学习和深度学习的概念,构建可以扩展到数百万个时间序列的真实世界时间序列预测系统。

本书适用对象

本书非常适合数据科学家、数据分析师、机器学习工程师和 Python 开发者,他们希望构建行业级的时间序列模型。由于本书从基础讲解大多数概念,掌握基本的 Python 技能即可。如果你有机器学习或预测方面的先验知识,将有助于加快学习进度。对于经验丰富的机器学习和预测领域从业者,本书在高级技术和最新时间序列预测研究前沿方面也有很多值得学习的内容。

本书内容概述

第一部分——熟悉时间序列

第一章时间序列简介,完全是为你介绍时间序列的世界。我们给出时间序列的定义,并讨论它与数据生成过程DGP)的关系。我们还将讨论预测的局限性,以及我们无法预测的内容,然后通过阐述一些术语,为你理解本书的其余部分做准备。

第二章获取与处理时间序列数据,讲解了如何处理时间序列数据。你将了解不同形式的时间序列数据如何在表格中表示。你将学习pandas中的各种日期时间相关功能,并学习如何使用适合时间序列的技术填补缺失数据。最后,通过一个真实世界的数据集,你将跟随一步步学习如何使用pandas处理时间序列数据。

第三章分析与可视化时间序列数据,进一步加深你对时间序列的理解,通过学习如何可视化和分析时间序列。你将了解时间序列数据中常用的不同可视化方法,接着通过分解时间序列成其组成部分,进一步深入理解。最后,你还将学习识别和处理时间序列数据中的异常值的方法。

第四章设定强基线预测,直接进入时间序列预测的主题,我们使用经济计量学中经过验证的方法,如ARIMA指数平滑法,来生成强基线。这些高效的预测方法将提供强有力的基线,帮助我们超越这些经典技术,学习现代技术,如机器学习。你还将了解另一个关键主题——使用谱熵变异系数等技术评估预测能力。

第二部分——时间序列的机器学习

第五章将时间序列预测视为回归问题,开启了我们使用机器学习进行预测的旅程。简短的机器学习介绍为接下来的章节奠定了基础。你还将从概念上理解如何将时间序列问题转化为回归问题,以便我们能应用机器学习来解决它。最后,我们会暗示全球预测模型的可能性。

第六章时间序列预测中的特征工程,转向一个更具实践性的课程。通过使用一个真实世界的数据集,你将学习不同的特征工程技术,如滞后特征滚动特征傅里叶项,这些技术帮助我们将时间序列问题表述为回归问题。

第七章时间序列预测的目标变换,继续探索不同的目标变换技术,以适应时间序列中的非平稳性。你将学习如增广的 Dickey-Fuller 检验Mann-Kendall 检验等技术,以识别和处理非平稳性。

第八章使用机器学习模型进行时间序列预测,从上一章结束的地方继续,开始在我们一直使用的数据集上训练机器学习模型。利用书中提供的标准代码框架,你将训练如线性回归随机森林梯度提升决策树等模型。

第九章集成与堆叠,回顾并探索如何利用多个预测结果,并将其结合起来产生更好的预测。你将探索一些流行的技术,如最优拟合、不同版本的爬山算法模拟退火堆叠,将我们生成的不同预测结合起来,从而得到更优的结果。

第十章全球预测模型,结束了你在机器学习预测中启蒙的旅程,进入了一个令人兴奋的新范式——全球预测模型。在这一章中,你将学习如何使用全球预测模型和行业验证的技术来提高其性能,最终让你能够为成千上万的时间序列开发可扩展且高效的机器学习预测系统。

第三部分——时间序列的深度学习

第十一章深度学习导论,我们切换话题,开始讲解一种特定类型的机器学习——深度学习。在本章中,我们通过探讨表示学习线性变换激活函数梯度下降等不同主题,为深度学习奠定基础。

第十二章时间序列深度学习的构建模块,继续深入深度学习,并使其具体化为时间序列相关内容。考虑到深度学习系统的组合性,你将学习如何利用不同的构建模块来构建深度学习架构。本章首先建立了编码器-解码器架构,然后讲解了不同的模块,如前馈网络循环神经网络卷积神经网络

第十三章时间序列常见建模模式,通过展示一些具体且常见的模式,增强了上一章中你所见的编码器-解码器架构,帮助你将构建模块组合在一起生成预测。这是一个实践性较强的章节,你将使用基于深度学习的表格回归和不同的序列到序列模型来生成预测。

第十四章用于时间序列的注意力机制与 Transformer,讨论了利用注意力机制来改进深度学习模型这一当代话题。本章首先介绍了一个通用的注意力模型,在此模型下你将学习不同类型的注意力机制,如scaled dot productadditive。你还将调整前一章中的序列到序列模型,加入注意力机制,并训练这些模型以生成预测。随后,本章讲解了transformer模型,这是一种完全依赖于注意力机制的深度学习架构,然后你将使用该模型生成预测。

第十五章全球深度学习预测模型的策略,探讨了基于深度学习的预测的另一个重要方面。尽管本书早些时候讨论了全球预测模型,但在深度学习模型中的实现有所不同。在本章中,你将学习如何实现全球深度学习模型,并掌握使这些模型更优秀的技术。你还将看到它们在实践部分的应用,我们将使用一直在操作的真实世界数据集来生成预测。

第十六章用于预测的专业化深度学习架构,通过介绍几种流行的、专门用于时间序列预测的深度学习架构,结束了你对基于深度学习的时间序列预测的学习旅程。利用你在前几章中学到的概念和构建模块,本章将带你走向研究的前沿,展示一些时间序列预测中的领先前沿模型,如N-BEATSN-HiTSInformerAutoformerTemporal Fusion Transformer。除了理解它们,你还将学习如何使用这些模型通过真实世界的数据集生成预测。

第十七章概率预测与更多,带你进入概率预测的领域,介绍了如一致性预测、蒙特卡洛丢弃、分位函数和概率密度函数等技术,并使你能够使用流行的开源框架来实践这些技术。本章还讲解了时间序列预测中的少有人涉足的领域,如间歇性预测、可解释性、冷启动预测和层次预测,并以高层次的方式为你深入这些主题提供了一个起点。

第四部分——预测的机制

第十八章多步预测,讨论了一个很少被提及但极为相关的主题——多步预测。你将学习如何为多个时间步预测未来的不同策略,如递归直接DirRecRecJointRectify。本书还讨论了每种策略的优缺点,并帮助你为自己的问题选择合适的策略。

第十九章评估预测误差——预测度量概述,涉及另一个很少被讨论但充满争议的话题,各方观点不一。你将学习不同衡量预测准确性的方法,并通过实验揭示不同度量指标的优势和劣势。本章最后提出一些指导方针,帮助你选择适合解决问题的正确度量标准。

第二十章评估预测——验证策略,通过讨论可以用于时间序列的不同验证策略来结束预测的评估和本书。你将学习不同的验证策略,如留出法、交叉验证及其变体。本章还涉及在设计全局设置的验证策略时需要考虑的方面。在本章的结尾,你将看到选择验证策略的几条指导方针以及回答类似“我们可以在时间序列中使用交叉验证吗?”的问题。

要从这本书中获得最大收益

这本书必须与 GitHub 上的笔记本和相关代码库捆绑使用,当两者结合使用时效果最佳。你可以通过这本书进行三个层次的学习。第一个层次仅通过书中的文本就能进行,它将带你通过理论,建立直觉,并通过基本代码快速实现某些内容。为了巩固学习,我们建议你迈出下一步,使用提供的笔记本并与其进行实验。在大多数笔记本中,我们展示了如何以特定方式完成某事。但是有许多可调节的杠杆,比如超参数,你可以调整并运行,以了解输出随这些变化而变化的方式。这种学习层次将使你理解代码,并对书中学到的概念有更深入的理解。

最后,我们将一些代码抽象到存储库的 src 文件夹中,这些代码在笔记本中使用。你的最后一个学习层次是仔细阅读和理解这些代码,这样你就会知道它是如何在幕后工作的。大多数代码都有很好的注释,因此你更容易理解你在笔记本中使用的函数和类的内部工作原理。这将使你的学习提升到一个水平,你可以自信地将这些技术应用到像老板一样的其他用例中。

你应该具备基本的 Python 编程知识,因为我们在实际操作部分使用的所有代码都是 Python 编写的。对 Python 中的主要库(如pandasscikit-learn)有所了解并不是必须的(因为本书会覆盖一些基础知识),但会帮助你更快地完成本书。对PyTorch的了解也不是必需的,但会加速你的学习。本书的任何软件需求都不应该成为障碍,因为在当今的互联网时代,唯一挡在你和知识世界之间的就是你最喜欢的搜索引擎的搜索框。

设置环境

为本书设置一个环境(最好是单独的环境)是强烈推荐的。我们建议的两种主要创建环境的方法是:Anaconda/Mamba 或 Python 虚拟环境。

使用 Anaconda/Miniconda/Mamba

设置环境的最简单方法是使用Anaconda,它是一个用于科学计算的 Python 发行版。如果你不想安装 Anaconda 附带的预装包,你也可以使用Miniconda,它是一个最小化的 Conda 安装器。你还可以使用Mamba,它是conda包管理器在 C++ 中的重新实现。Mambaconda更快,并且可以直接替代conda。推荐使用 Mamba,因为它可以大大减少在 Anaconda 中遇到解析依赖…屏幕卡住的几率。如果你使用的是 Anaconda 版本 23.10 或更高版本,那么你不必过于担心 Mamba,因为高效的包解析器已经默认包含在 Anaconda 中。

  1. 安装 Anaconda/Miniconda/Mamba/MicroMamba:Anaconda 可以从www.anaconda.com/products/distribution下载安装。根据你的操作系统,选择相应的文件并按照说明进行安装。你也可以从这里安装 Miniconda:docs.anaconda.com/miniconda/。你可以从这里安装 Mamba 和 MicroMamba:mamba.readthedocs.io/en/latest/。如果你使用的是 Mamba,下面的所有指令中,请将“conda”替换为“mamba”。

  2. 打开 conda 提示符:打开 Anaconda Prompt(或在 Linux 或 macOS 上的终端),请按以下步骤操作:

    • Windows:打开 Anaconda Prompt(开始 | Anaconda Prompt

    • macOS:打开 Launchpad,然后打开终端。输入 conda activate

    • Linux:打开终端。输入 conda activate

  3. 创建新环境:使用以下命令创建你选择的新环境。例如,要创建一个名为modern_ts_2E的环境,并使用 Python 3.10(建议使用 3.10 或更高版本),使用以下命令:

    conda create -n modern_ts_2E python=3.10 
    
  4. 激活环境:使用以下命令激活环境:

    conda activate modern_ts_2E 
    
  5. 从官方网站安装PyTorch:最好从官方网站安装PyTorch。请访问pytorch.org/get-started/locally/,并根据你的系统选择合适的选项。如果你想使用 Mamba 进行安装,可以将conda替换为mamba

  6. 导航到下载的代码:使用操作系统特定的命令导航到你下载代码的文件夹。例如,在 Windows 中,使用cd

  7. 安装所需的库:使用提供的anaconda_env.yml文件安装所有必需的库。使用以下命令:

    conda env update --file anaconda_env.yml 
    

这将会在环境中安装所有必需的库。可能需要一些时间。

  1. 检查安装情况:我们可以通过在下载的代码文件夹中执行一个脚本python test_installation.py来检查所有必需的库是否正确安装。如果 GPU 没有显示出来,请在环境中重新安装 PyTorch。

  2. 激活环境并运行笔记本:每次想要运行笔记本时,首先使用conda activate modern_ts_2E命令激活环境,然后根据你的喜好使用 Jupyter Notebook(jupyter notebook)或 Jupyter Lab(jupyter lab)。

使用 Python 虚拟环境和 pip

如果你更倾向于使用原生 Python 进行环境管理,我们还提供了一个备用的requirements.txt,可以帮助你完成这项工作:

  1. 安装 Python:你可以从www.python.org/downloads/下载 Python。推荐使用 3.10 或更高版本

  2. 创建虚拟环境:使用以下命令创建名为modern_ts_2E的虚拟环境:

    python -m venv modern_ts_2E 
    
  3. 激活环境:使用以下命令激活环境:

    • Windows: modern_ts_2E\Scripts\activate

    • macOS/Linux: source modern_ts_2E/bin/activate

  4. 安装 PyTorch:最好从官方网站安装PyTorch。请访问pytorch.org/get-started/locally/,并根据你的系统选择合适的选项。

  5. 导航到下载的代码:使用操作系统特定的命令导航到你下载代码的文件夹。例如,在 Windows 中,使用cd

  6. 安装所需的库:使用提供的requirements.txt文件安装所有必需的库。使用以下命令:

    pip install -r requirements.txt 
    

这将会在环境中安装所有必需的库。可能需要一些时间。

  1. 检查安装情况:我们可以通过在下载的代码文件夹中执行一个脚本来检查所有必需的库是否正确安装。

    python test_installation.py 
    
  2. 激活环境并运行笔记本:每次你想运行笔记本时,首先使用命令 modern_ts_2E\Scripts\activate(Windows)或 source modern_ts_2E/bin/activate(macOS/Linux)激活环境,然后根据你的偏好使用 Jupyter Notebook (jupyter notebook) 或 Jupyter Lab (jupyter lab)。

当环境创建时出现错误,该怎么办?

考虑到全球计算机的多样性以及不断变化的库依赖性,书中提供的环境设置(截至 2024 年 9 月已测试并能正常工作)很可能无法经受住时间的考验。在这种情况下,我们建议你打开 requirements.txt 文件,查看我们安装了哪些库,并尝试通过降级或升级版本来解决问题。

另一种解决方法是在使用某个笔记本时,只安装所需的包。通常,冲突发生在我们尝试在同一环境中安装不同的库时。例如,一个库需要 PyTorch < 1.0.0,而另一个库需要 PyTorch > 1.0.0。在这种情况下,将这两个库分到两个环境中,或者找到一个与其他库兼容的版本更为明智。

在这种情况下,你最好的朋友是谷歌搜索和随后的 GitHub 评论筛选,那些评论中提到相同的问题。作为最后的手段,你还可以在书籍的代码库中提一个问题,我们会尽力帮助你。

下载数据

你将在全书中使用一个数据集。本书使用的是来自 Kaggle 的 London Smart Meters 数据集。许多早期章节中的笔记本是后续章节的一些依赖项。因此,如果你想按顺序跳过某些章节运行笔记本,我们提供了一个 data.zip 文件,里面包含了所有所需的数据集,以去除这种依赖。

要进行设置,请按照以下步骤操作:

  1. 从 AWS 下载数据:packt-modern-time-series-py.s3.eu-west-1.amazonaws.com/data.zip

  2. 解压内容。

  3. data 文件夹复制到你从 GitHub 拉取的 Modern-Time-Series-Forecasting-with-Python-2E 文件夹中。

就是这样!你现在可以开始运行代码了。

如果你使用的是本书的数字版本,我们建议你亲自输入代码或从本书的 GitHub 仓库获取代码。这样可以帮助你避免因复制粘贴代码而可能出现的错误。

随书提供的代码并不是一个库,而是帮助你开始实验的指南。你从书籍和代码中能学到的内容,与你对代码的实验以及是否敢于走出舒适区的程度成正比。所以,尽管开始实验吧,将书中学到的技能付诸实践。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Modern-Time-Series-Forecasting-with-Python-2E。如果代码有更新,它将在 GitHub 仓库中更新。

我们还提供了其他的代码包,来自我们丰富的书籍和视频目录,您可以在github.com/PacktPublishing/查看。

下载彩色图像

我们还提供了一个包含本书中截图和图表的彩色图像 PDF 文件,您可以在此下载:packt.link/gbp/9781835883181

使用的约定

本书中使用了若干文本约定。

代码文本中的内容:表示文本中的代码、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入以及 Twitter 用户名。以下是一个示例:“statsmodels.tsa.seasonal中有一个名为seasonal_decompose的函数。”

一段代码的格式如下:

#Does not support missing values, so using imputed ts instead
res = seasonal_decompose(ts, period=7*48, model="additive", extrapolate_trend="freq") 

任何命令行输入或输出格式如下:

conda env create -f anaconda_env.yml 

粗体:表示新术语、重要单词或屏幕上显示的单词。例如,菜单或对话框中的单词会显示为粗体。例如:“但是,如果你查看时间间隔列,它会特别突出。”

重要提示 以这种形式出现。

小贴士 以这种形式出现。

与我们联系

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何部分有疑问,请通过电子邮件联系我们:customercare@packtpub.com,并在邮件主题中注明书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感谢您向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上发现我们的作品以任何形式的非法复制,感谢您提供该内容的具体位置或网站名称。请通过copyright@packt.com与我们联系,并提供链接。

如果您有兴趣成为作者:如果您在某个领域具有专业知识,并且有兴趣编写或参与编写一本书,请访问authors.packtpub.com

留下评论!

感谢您购买这本由 Packt Publishing 出版的书籍——我们希望您喜欢它!您的反馈对我们来说非常宝贵,帮助我们不断改进和成长。阅读完本书后,请抽空留下一个Amazon 评论;这只需一分钟,但对像您这样的读者而言,能带来巨大的不同。

扫描下面的二维码,获取您选择的免费电子书。

![A qr code with black squares Description automatically generated]

packt.link/NzOWQ

下载本书的免费 PDF 副本

感谢购买本书!

你喜欢随时随地阅读,但又无法随身携带纸质书籍吗?

你的电子书购买与所选设备不兼容吗?

别担心,现在每本 Packt 书籍,你都可以免费获得该书的无 DRM PDF 版本。

随时随地在任何设备上阅读。在你最喜欢的技术书籍中搜索、复制并将代码直接粘贴到你的应用程序中。

优惠不仅仅于此,你还可以独家获取折扣、新闻通讯,并且每天在邮箱中收到丰富的免费内容。

按照这些简单步骤获取福利:

  1. 扫描二维码或访问下面的链接:

packt.link/free-ebook/9781835883181

  1. 提交你的购买凭证。

  2. 就是这样!我们会直接将免费的 PDF 和其他福利发送到你的邮箱。

第一部分

熟悉时间序列

我们通过理解什么是时间序列、如何处理和操作时间序列数据,以及如何分析和可视化时间序列数据,开始接触时间序列预测。本部分还涵盖了经典的时间序列预测方法,如 ARIMA,用以作为强有力的基准。

本部分包括以下章节:

  • 第一章介绍时间序列

  • 第二章获取和处理时间序列数据

  • 第三章分析和可视化时间序列数据

  • 第四章设定强有力的基准预测

第一章:引入时间序列

欢迎来到《使用 Python 进行现代时间序列预测》!本书面向那些希望通过学习来自机器学习(ML)领域的新技术提升时间序列分析技能的数据科学家或机器学习ML)工程师。时间序列分析在常规的机器学习书籍、课程等中通常被忽视。它们通常从分类问题开始,稍微涉及回归,然后就继续进行。但时间序列分析在商业中却是非常有价值且无处不在的。我们从三维的视角看待世界,时间是一个我们很少考虑的隐藏维度,但它无所不在。只要时间是我们生活的世界中的四个维度之一,时间序列数据也是无处不在的。

分析时间序列数据能为企业带来大量价值。时间序列分析并不新鲜——它自 1920 年代以来就已存在。但在当前的数据时代,企业收集的时间序列数据正以分钟为单位不断增长和扩展。结合数据量的爆炸式增长以及机器学习(ML)的复兴,时间序列分析的领域也发生了显著变化。本书尝试带领你超越经典的统计方法,如自回归积分滑动平均ARIMA),并向你介绍来自机器学习领域的最新时间序列分析技术。

我们将从一些基本概念开始,并快速提升到更复杂的话题。在本章中,我们将涵盖以下主要内容:

  • 什么是时间序列?

  • 数据生成过程(DGP)

  • 我们能预测什么?

  • 预测术语和符号

技术要求

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

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

什么是时间序列?

简单来说,时间序列是按时间顺序收集的一组观察数据。重点是“时间”这个词。如果我们在不同的时间点上进行相同的观察,我们就能得到一个时间序列。例如,如果你每月记录自己拥有的巧克力棒数目,你最终就会得到一个关于巧克力消费的时间序列。假设你在每个月的开始时记录自己的体重,你就得到另一个体重的时间序列。这两个时间序列之间是否存在关系?很可能是有的。但到本书的最后,我们将能够科学地分析这一点。

其他一些时间序列的例子包括你关注的某只股票的每周收盘价、你所在城市的每日降水量或降雪量,以及你智能手表记录的每小时脉搏率。

时间序列的类型

根据时间间隔,时间序列数据可以分为两种类型,如下所示:

  • 规则时间序列:这是最常见的时间序列类型,其中观察数据是在规则的时间间隔内获取的,例如每小时或每月。例如,如果我们拿城市的温度作为时间序列进行分析,那么我们会得到按规则间隔(无论选择何种频率)的时间序列数据。

  • 非规则时间序列:有些时间序列中的观察数据不是在规则时间间隔内获取的。例如,假设我们有一系列病人的实验室检测数据。在这种时间序列中,只有当病人去诊所并进行实验室检测时,我们才能获得观察数据,而这种情况并不是在规则的时间间隔内发生的。

本书仅关注规则时间序列,即时间间隔均匀的时间序列。非规则时间序列稍微复杂一些,需要专门的技术来处理。有关这一主题的几篇综述文章是了解非规则时间序列的好方法,你可以在本章的进一步阅读部分找到它们。

时间序列分析的主要应用领域

时间序列分析的应用领域大致分为三大类,概述如下:

  • 时间序列预测:给定过去的时间序列数据,预测未来的值——例如,使用过去五年的温度数据来预测明天的温度。这个用例是最流行和最重要的之一,因为任何需要做的规划都需要对未来有一定的预测。例如,规划下个月生产多少巧克力就需要预测预期的需求。

  • 时间序列分类:有时,我们不仅需要预测时间序列的未来值,还可能希望基于过去的值预测某个动作。例如,给定脑电图EEG;跟踪大脑电活动)或心电图EKG;跟踪心脏电活动)的历史测量数据,我们需要预测脑电图或心电图的结果是否正常。

  • 异常值检测:在某些情况下,我们仅仅想检测某些异常事件,或者是否出现了不正常的情况。在这种情况下,我们通常使用分类或预测方法,但我们也可以进行异常值检测。例如,你身上的可穿戴设备记录了加速度计的读数,并且可以通过异常值检测来识别跌倒或意外事故。

  • 解释与因果关系:你可以使用时间序列分析来理解时间序列的“是什么”和“为什么”,基于过去的值理解几个相关时间序列之间的关系,或基于时间序列数据推导因果推断。例如,我们有一个品牌的市场份额时间序列和另一个广告支出时间序列。通过使用解释与因果关系技术,我们可以开始理解广告投入如何影响市场份额,并可能采取适当的行动。

本书的重点主要是时间序列预测,但你所学到的技术也将帮助你处理时间序列分类问题,只需对方法做最小的修改。解释部分也有涉及,尽管只是简要介绍,而因果关系则是本书没有涉及的领域,因为它需要一种完全不同的方法。

现在我们对时间序列的概况有了一个大致的了解,让我们构建一个关于时间序列数据生成方式的思维模型。

数据生成过程(DGP)

我们已经看到,时间序列数据是沿时间维度顺序收集的观察值。任何时间序列反过来都是由某种机制生成的。例如,来自制造厂的每日巧克力出货量的时间序列数据受很多因素的影响,比如一年中的时间(例如假期季节)、可可的供应量、工厂机器的正常运转时间等。在统计学中,生成时间序列的这个基础过程被称为DGP。时间序列数据是由随机过程和确定性过程生成的。确定性过程涉及随着时间变化而以可预测的方式演变的量。例如,元素的放射性衰变,其中剩余量按照精确的数学公式减少,导致时间上量的稳定减少。但大多数有趣的时间序列(从预测的角度来看)是由随机过程生成的。随机过程是一种描述事物如何随着时间变化的方式,虽然这种变化是随机的,但具有某些模式和概率的可预测性,就像天气每天的变化,涉及一些模式和概率。因此,让我们更深入地讨论由随机过程生成的时间序列。

如果我们对现实有完整且完美的知识,那么我们所需要做的就是将这个 DGP 用数学形式表达出来,你就能得到最准确的预测。但可惜的是,没有人拥有现实的完整和完美的知识。所以,我们要做的就是尽可能在数学上逼近 DGP,这样我们对 DGP 的模仿就能为我们提供最佳的预测(或我们想从分析中得到的任何其他输出)。这种模仿被称为模型,它提供了 DGP 的有用近似。

但我们必须记住,模型并不是数据生成过程(DGP),而是对现实中某些关键方面的表征。例如,考虑班加罗尔的鸟瞰图和班加罗尔的地图,如下所示:

图 1.1 – 班加罗尔的鸟瞰图(左)和班加罗尔的地图(右)

图 1.1:班加罗尔的鸟瞰图(左)和班加罗尔的地图(右)

班加罗尔的地图无疑是有用的——我们可以用它从 A 点到 B 点。但班加罗尔的地图并不等同于班加罗尔的照片。它没有展示繁华的夜生活或令人无法忍受的交通。地图只是一个模型,代表了一个地点的一些有用特征,比如道路和地点。以下图示可能有助于我们内化这一概念并记住它:

图 1.2 – 数据生成过程(DGP)、模型和时间序列

图 1.2:数据生成过程(DGP)、模型和时间序列

自然地,下一个问题是:我们有用的模型吗? 每个模型都有其局限性和挑战。正如我们所看到的,班加罗尔的地图并不能完美地代表班加罗尔。但如果我们的目的是在班加罗尔导航,那么地图就是一个非常有用的模型。如果我们想了解文化呢?地图并不能给你这种感觉。那么,曾经有用的同一个模型,在新的语境下就完全无用。

不同的情况和目标需要不同类型的模型。例如,预测的最佳模型可能与做因果推断的最佳模型不同。

我们可以使用数据生成过程(DGP)的概念,生成多个具有不同复杂度的合成时间序列。

生成合成时间序列

合成时间序列或人工时间序列是理解时间序列空间、实验不同技术,甚至测试新模型或建模方法的绝佳工具。这些时间序列设计为可预测的,尽管有些具有挑战性。让我们来看几个实际例子,通过一组基本构建块来生成几个时间序列。你可以发挥创意,任意组合这些组件,甚至将它们相加,生成复杂度任意的时间序列。

白噪声和红噪声

一种生成时间序列的随机过程的极端情况是白噪声过程。它具有一系列随机数,均值为零,方差为常数。这也是时间序列中最常见的噪声假设之一。

让我们看看如何生成这样的时间序列并将其绘制出来:

# Generate the time axis with sequential numbers upto 200
time = np.arange(200)
# Sample 200 hundred random values
values = np.random.randn(200)*100
plot_time_series(time, values, "White Noise") 

以下是输出结果:

图 1.3 – 白噪声过程

图 1.3:白噪声过程

红噪声,另一方面,均值为零,方差为常数,但在时间上具有序列相关性。这种序列相关性或红色性由相关系数r参数化,如下所示:

其中,w是来自白噪声分布的随机样本。

让我们看看如何生成这个,如下所示:

# Setting the correlation coefficient
r = 0.4
# Generate the time axis
time = np.arange(200)
# Generate white noise
white_noise = np.random.randn(200)*100
# Create Red Noise by introducing correlation between subsequent values in the white noise
values = np.zeros(200)
for i, v in enumerate(white_noise):
    if i==0:
        values[i] = v
    else:
        values[i] = r*values[i-1]+ np.sqrt((1-np.power(r,2))) *v
plot_time_series(time, values, "Red Noise Process") 

这是输出结果:

图 1.4 – 红噪声过程

图 1.4:红噪声过程

周期性或季节性信号

在时间序列中,最常见的信号之一是季节性或周期性信号。因此,你可以通过几种方式将季节性引入到生成的序列中。

让我们借助一个非常有用的库来生成剩余的时间序列——TimeSynth。欲了解更多信息,请参考github.com/TimeSynth/TimeSynth

这是一个生成时间序列的有用库。它包含各种 DGP(数据生成过程),你可以自由组合,创建一个真实的合成时间序列。

笔记本警告

有关具体的代码和使用方法,请参考相关的 Jupyter 笔记本。

让我们看看如何使用正弦函数来创造周期性。在TimeSynth库中,有一个有用的函数叫做generate_timeseries,它帮助我们组合信号并生成时间序列。请看以下代码片段:

#Sinusoidal Signal with Amplitude=1.5 & Frequency=0.25
signal_1 =ts.signals.Sinusoidal(amplitude=1.5, frequency=0.25)
#Sinusoidal Signal with Amplitude=1 & Frequency=0\. 5
signal_2 = ts.signals.Sinusoidal(amplitude=1, frequency=0.5)
#Generating the time series
samples_1, regular_time_samples, signals_1, errors_1 = generate_timeseries(signal=signal_1)
samples_2, regular_time_samples, signals_2, errors_2 = generate_timeseries(signal=signal_2)
plot_time_series(regular_time_samples,
                 [samples_1, samples_2],
                 "Sinusoidal Waves",
                 legends=["Amplitude = 1.5 | Frequency = 0.25", "Amplitude = 1 | Frequency = 0.5"]) 

这是输出结果:

图 1.5 – 正弦波

图 1.5:正弦波

注意,这两个正弦波在频率(时间序列穿过零点的速度)和振幅(时间序列偏离零点的距离)上是不同的。

Sinusoidal class:
# PseudoPeriodic signal with Amplitude=1 & Frequency=0.25
signal = ts.signals.PseudoPeriodic(amplitude=1, frequency=0.25)
#Generating Timeseries
samples, regular_time_samples, signals, errors = generate_timeseries(signal=signal)
plot_time_series(regular_time_samples,
                 samples,
                 "Pseudo Periodic") 

这是输出结果:

图 1.6 – 伪周期信号

图 1.6:伪周期信号

自回归信号

另一个在现实世界中非常常见的信号是自回归(AR)信号。我们将在第四章《设置强有力的基线预测》中详细讲解这一点,但目前为止,AR 信号指的是当前时间步的时间序列值依赖于前一时间步的时间序列值。这种序列相关性是 AR 信号的一个关键特性,它通过几个参数来进行参数化,具体如下:

  • 序列相关性的顺序——换句话说,信号依赖于多少个前时间步

  • 用来组合前几个时间步的系数

让我们看看如何生成 AR 信号,并查看它的样子,如下所示:

# We have re-implemented the class in src because of a bug in TimeSynth
from src.synthetic_ts.autoregressive import AutoRegressive
# Autoregressive signal with parameters 1.5 and -0.75
# y(t) = 1.5*y(t-1) - 0.75*y(t-2)
signal= AutoRegressive(ar_param=[1.5, -0.75])
#Generate Timeseries
samples, regular_time_samples, signals, errors = generate_timeseries(signal=signal)
plot_time_series(regular_time_samples,
                 samples,
                 "Auto Regressive") 

这是输出结果:

图 1.7 – AR 信号

图 1.7:AR 信号

混合与匹配

还有许多其他组件可以用来创建你的 DGP,从而生成时间序列,但让我们快速看一下如何结合已经看到的组件来生成一个真实的时间序列。

让我们使用一个带有白噪声的伪周期信号,并将其与 AR 信号结合,如下所示:

#Generating Pseudo Periodic Signal
pseudo_samples, regular_time_samples, _, _ = generate_timeseries(signal=ts.signals.PseudoPeriodic(amplitude=1, frequency=0.25), noise=ts.noise.GaussianNoise(std=0.3))
# Generating an Autoregressive Signal
ar_samples, regular_time_samples, _, _ = generate_timeseries(signal= AutoRegressive(ar_param=[1.5, -0.75]))
# Combining the two signals using a mathematical equation
ts = pseudo_samples*2+ar_samples
plot_time_series(regular_time_samples,
                 ts,
                 "Pseudo Periodic with AutoRegression and White Noise") 

这是输出结果:

图 1.8 – 带有 AR 和白噪声的伪周期信号

图 1.8:带有 AR 和白噪声的伪周期信号

平稳和非平稳时间序列

在时间序列中,平稳性非常重要,是许多建模方法的关键假设。具有讽刺意味的是,许多(如果不是大多数的话)实际的时间序列是非平稳的。所以,让我们从一个外行的角度来理解什么是平稳时间序列。

有多种方式来理解平稳性,但最清晰、最直观的方式之一是考虑时间序列的概率分布或数据分布。当一个时间序列的概率分布在每个时间点保持不变时,我们称该时间序列为平稳的。换句话说,如果你选择不同的时间窗口,这些窗口中的数据分布应该是相同的。

标准的高斯分布由两个参数定义——均值和方差。因此,平稳性假设可能被打破的方式有两种,如下所述:

  • 均值随时间的变化

  • 方差随时间的变化

让我们详细看看这些假设,并更好地理解它们。

均值随时间的变化

这是非平稳时间序列最常见的表现方式。如果时间序列有上升/下降趋势,那么两个时间窗口间的均值就不相同。

另一种非平稳性的表现形式是季节性。例如,我们正在查看过去五年每月的平均温度时间序列。根据我们的经验,我们知道温度在夏季达到峰值,在冬季下降。因此,当我们取冬季的平均温度和夏季的平均温度时,它们会是不同的。

让我们生成一个带有趋势和季节性的时间序列,看看它是如何表现出来的:

# Sinusoidal Signal with Amplitude=1 & Frequency=0.25
signal=ts.signals.Sinusoidal(amplitude=1, frequency=0.25)
# White Noise with standard deviation = 0.3
noise=ts.noise.GaussianNoise(std=0.3)
# Generate the time series
sinusoidal_samples, regular_time_samples, _, _ = generate_timeseries(signal=signal, noise=noise)
# Regular_time_samples is a linear increasing time axis and can be used as a trend
trend = regular_time_samples*0.4
# Combining the signal and trend
ts = sinusoidal_samples+trend
plot_time_series(regular_time_samples,
                 ts,
                 "Sinusoidal with Trend and White Noise") 

这是输出结果:

图 1.9 – 带趋势和白噪声的正弦信号

图 1.9:带趋势和白噪声的正弦信号

如果你检查图 1.9中的时间序列,你会看到明显的趋势和季节性,这两者共同导致数据分布的均值在不同时间窗口间剧烈变化。

方差随时间的变化

非平稳性还可以表现为时间序列方差的波动。如果时间序列从低方差开始,随着时间的推移,方差逐渐增大,我们就得到了一个非平稳时间序列。在统计学中,这种现象有一个可怕的名字——异方差性。航空乘客数据集(时间序列中的“鸢尾花数据集”——最受欢迎、最常用但没什么实际用处)就是一个典型的异方差时间序列示例。让我们来看一下图:

图 1.10:航空乘客数据集—异方差时间序列示例

在图中,你可以看到随着时间的推移,季节性高峰越来越宽广,这是一个经典的信号,表明该时间序列是异方差的。但并非所有异方差的时间序列都容易被发现。我们有统计测试来检查每种平稳性情况,这些内容将在第七章中讨论,时间序列预测的目标变换

本书只是试图让你了解平稳和非平稳时间序列。在这部分的讨论中有大量统计理论和深度内容,我们为了集中精力讨论时间序列的实际应用而略去了这些内容。

在掌握了 DGP 的思维模型之后,我们已经处在了思考另一个重要问题的正确位置:我们能预测什么?

我们能预测什么?

在我们继续之前,还有一个关于时间序列预测的方面需要理解——时间序列的可预测性。在预测时间序列时,最基本的假设是未来依赖于过去。但是并非所有时间序列都是同样可预测的。

让我们看几个例子,尝试按可预测性从易到难进行排名,如下所示:

  • 下周一的高潮潮汐

  • 下周日的彩票号码

  • 下周五特斯拉的股价

直观上,排名这些时间序列的难易程度是非常容易的。下周一的高潮潮汐是最容易预测的,因为它非常可预测,下周五特斯拉的股价会比较难预测,但并非不可能,而彩票号码则非常难预测,因为它们几乎是随机的。

然而,对于那些认为通过书中介绍的先进技术可以预测股价并发财的人来说,这(很可能)不会发生。虽然这个话题值得深入讨论,但我们可以用一小段话总结出关键点。

股票价格不是过去值的函数,而是对未来值的预期,这违反了我们在预测时的第一个假设。如果这还不够糟糕,金融股票价格通常具有非常低的信噪比。过程中最后的障碍是有效市场假说EMH)。这个看似无害的假设宣称,股票价格的所有已知信息都已经反映在股票价格中。这个假说的含义是,如果你能准确预测,其他人也会做得到,因此,股票的市场价格已经反映了这种预测所带来的价格变化。

M6 竞赛选择直接应对这个问题,评估有效市场假说(EMH)是否成立,通过进行一年的预测和投资策略竞赛。虽然结果并非定论,但显示出大多数参与者符合有效市场假说(EMH),除了少数顶尖团队。即使在这些顶尖团队中,研究发现预测准确性与股票选择之间没有显著的相关性,也就是说,团队并未选择那些能够预测得更好的股票(完整报告链接可在进一步阅读部分找到)。

回到我们讨论的主题——可预测性——以下三个主要因素构成了这一心理模型:

  • 理解数据生成过程(DGP):你对数据生成过程(DGP)理解得越透彻,时间序列的可预测性就越高。

  • 数据量:你拥有的数据越多,预测的准确性就越高。

  • 足够重复的模式:任何数学模型要有效工作,时间序列中应该有足够重复的模式。模式越可重复,你的可预测性就越高。

即使你已经有了一个关于如何思考可预测性的心理模型,我们将在第三章《分析与可视化时间序列数据》中,探讨更具体的评估时间序列可预测性的方法,但关键的结论是,并非所有时间序列都是同样可预测的。

为了能够完全跟上接下来的章节讨论,我们需要建立一个标准符号系统,并学习时间序列分析中特有的术语。

预测术语

有一些术语将帮助你理解本书以及其他关于时间序列的文献。以下是这些术语的详细描述:

  • 预测

预测是利用已知的时间序列过去的数值和/或其他相关变量来预测未来的时间序列值。这与机器学习中的预测非常相似,我们使用一个模型来预测未见过的数据。

  • 多元预测

多元时间序列由多个时间序列变量组成,这些变量不仅依赖于其过去的数值,还与其他变量存在一定的依赖关系。例如,一个国家的宏观经济指标集合,如国内生产总值GDP)和通货膨胀,可以视为一个多元时间序列。多元预测的目标是提出一个模型,捕捉不同变量之间的相互关系,以及它们与过去的关系,并预测所有时间序列在未来的表现。

  • 解释性预测

除了时间序列的过去数值外,我们可能会使用一些其他信息来预测时间序列的未来数值。例如,在预测零售店销售额时,关于促销活动(历史和未来的)的信息通常是有帮助的。这种利用除自身历史之外的信息进行预测的方法称为解释性预测。

  • 回测

从训练数据中设置一个验证集来评估模型是机器学习领域常见的做法。回测是验证的时间序列等价物,通过使用历史数据来评估训练好的模型。我们将在后面介绍用于时间序列数据的验证和交叉验证的不同方法。

  • 样本内和样本外

再次与机器学习进行类比,样本内指的是训练数据,样本外指的是未见或测试数据。当你听到样本内指标时,这指的是在训练数据上计算的指标,而样本外指标则是在测试数据上计算的指标。

  • 外生和内生变量

外生变量是平行时间序列变量,不直接用于输出建模,但用于帮助我们建模感兴趣的时间序列。通常,外生变量不受系统中其他变量的影响。内生变量是受系统中其他变量影响的变量。一个纯粹的内生变量是完全依赖于系统中其他变量的变量。稍微放宽严格的假设,我们可以将目标变量视为内生变量,将模型中包括的解释性回归变量视为外生变量。

  • 预测组合

时间序列世界中的预测组合类似于机器学习世界中的集成。预测组合是通过使用一个函数(学习或基于启发式的)来结合多个预测的过程,例如三个预测模型的简单平均值。

还有许多特定于时间序列的术语,其中一些我们将在整本书中逐渐介绍。但这些术语应该是一个良好的起点,让你对这个领域有基本的了解。

总结

在本章中,我们首次接触了时间序列,讨论了不同类型的时间序列,看了一个 DGP 如何生成时间序列,并了解了我们如何思考一个重要问题:我们能有多好地预测一个时间序列?我们还快速回顾了理解本书其余部分所需的术语。在下一章中,我们将开始动手学习如何获取和处理时间序列数据。如果你还没有设置好环境,请休息一下,花点时间来做这件事。

进一步阅读

  • 关于从不规则采样时间序列中学习的原则、模型和方法的调查:从离散化到注意力和不变性 作者:S.N. Shukla 和 B.M. Marlin(2020):arxiv.org/abs/2012.00168

  • 从不规则采样时间序列中学习:缺失数据视角 作者:S.C. Li 和 B.M. Marlin(2020),ICML:arxiv.org/abs/2008.07599

  • M6 预测竞赛:弥合预测和投资决策之间的差距 作者:Spyros Makridakis 等(2023):arxiv.org/abs/2310.13357

加入我们在 Discord 上的社区

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

packt.link/mts

第二章:获取和处理时间序列数据

在前一章中,我们学习了什么是时间序列,并建立了一些标准符号和术语。现在,让我们从理论转向实践。在本章中,我们将动手处理数据。虽然我们说时间序列数据无处不在,但我们还没有开始处理一些时间序列数据集。我们将开始处理本书中将要使用的数据集,正确地处理它,并学习一些处理缺失值的技术。

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

  • 理解时间序列数据集

  • pandas 日期时间操作,索引和切片——刷新一下

  • 处理缺失数据

  • 映射附加信息

  • 将文件保存到磁盘和加载文件

  • 处理较长时间的缺失数据

技术要求

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

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

处理时间序列数据就像处理其他表格数据集一样,只是更加注重时间维度。与任何表格数据集一样,pandas同样适合处理时间序列数据。

让我们从头开始处理一个数据集。本书将始终使用伦敦智能电表数据集。如果您尚未在环境设置中下载数据,请转到前言并执行此操作。

理解时间序列数据集

这是您遇到任何新数据集的关键第一步,甚至在进行探索性数据分析EDA)之前,我们将在第三章 分析和可视化时间序列数据中进行讨论。了解数据的来源,背后的数据生成过程以及源域对于对数据集有一个良好的理解是至关重要的。

伦敦数据存储库,一个免费开放的数据共享门户,由 Jean-Michel D 收集并丰富了此数据集,并上传到 Kaggle。

数据集包含 5,567 个伦敦家庭的能耗读数,这些家庭参与了英国电力网络主导的低碳伦敦项目,时间跨度从 2011 年 11 月到 2014 年 2 月。读数间隔为半小时。数据集的一部分还包括一些有关家庭的元数据。让我们看看数据集的元数据包含哪些信息:

  • CACI UK 将英国人口划分为不同的群体,称为 Acorn。对于数据中的每个家庭,我们都有对应的 Acorn 分类。Acorn 类别(奢华生活方式、城市精英、学生生活等)被归类为上级类别(富裕成就者、上升中的繁荣、经济紧张等)。完整的 Acorn 类别列表可以在表 2.1中找到。详细列出每个类别的完整文档可以在 acorn.caci.co.uk/downloads/Acorn-User-guide.pdf 中查看。

  • 数据集包含两组客户——一组在 2013 年使用动态时间段电价dToU),另一组则采用固定费率电价。dToU 的电价会提前一天通过智能电表的 IHD 或短信告知。

  • Jean-Michel D 还为数据集增加了天气和英国法定假期的数据。

下表显示了 Acorn 分类:

Acorn 分组 Acorn 分类
富裕成就者 A-奢华生活方式
B-高管财富
C-成熟财富
上升中的繁荣 D 城精英
E-职业攀升者
舒适社区 F-乡村社区
G-成功的郊区
H-稳定的社区
I-舒适老年人
J-起步阶段
经济紧张 K-学生生活
L-普通收入
M-奋斗中的家庭
N-贫困的退休人员
城市困境 O-Young 艰难困苦
P-挣扎中的地产
Q-困境中的人群

表 2.1:Acorn 分类

Kaggle 数据集也对时间序列数据进行了每日预处理,并将所有单独的文件合并在一起。在这里,我们将忽略这些文件,从原始文件开始,这些文件可以在 hhblock_dataset 文件夹中找到。学习处理原始文件是从事实际行业数据集工作的重要部分。

准备数据模型

一旦我们理解了数据的来源,就可以查看它,理解不同文件中所包含的信息,并构建一个思维模型来关联不同的文件。你可以称之为传统方式,但 Microsoft Excel 是一个非常适合获取这种第一层次理解的工具。如果文件太大以至于无法在 Excel 中打开,我们也可以在 Python 中读取它,将一部分数据保存到 Excel 文件中,然后打开它。然而,请记住,Excel 有时会乱改数据格式,尤其是日期格式,所以我们需要小心不要保存文件并写回 Excel 所做的格式更改。如果你对 Excel 有抵触,可以使用 Python 完成,尽管会需要更多的键盘操作。这个练习的目的是查看不同的数据文件包含了什么,探索不同文件之间的关系等。

我们可以通过绘制一个数据模型,使其更正式和明确,类似于以下图示:

图 2.1 – 伦敦智能电表数据集的数据模型

图 2.1:伦敦智能电表数据集的数据模型

这个数据模型主要是帮助我们理解数据,而不是任何数据工程的目的。因此,它只包含最基本的信息,如左侧的关键列和右侧的示例数据。我们还有箭头连接不同的文件,并使用键来关联这些文件。

让我们看一下几个关键列名及其含义:

  • LCLid:家庭的唯一消费者 ID

  • stdorTou:家庭是否使用 dToU 或标准收费

  • Acorn:ACORN 类

  • Acorn_grouped:ACORN 组

  • file:块编号

每个 LCLid 都有一个唯一的时间序列与之关联。该时间序列文件采用一种稍微复杂的格式——每天,文件的列中会有 48 个观测值,以半小时为频率。

笔记本提示

为了跟随完整的代码,请使用 Chapter01 文件夹中的 01-Pandas_Refresher_&_Missing_Values_Treatment.ipynb 笔记本。

在开始处理数据集之前,有几个概念需要我们明确。其中一个在 pandas DataFrame 中至关重要的概念是 pandas 日期时间属性和索引。让我们快速回顾几个对我们有用的 pandas 概念。

如果你熟悉 pandas 中的日期时间操作,可以跳到下一节。

pandas 日期时间操作、索引和切片——回顾

我们不使用稍微复杂的数据集,而是选择一个简单且格式良好的股票交易价格数据集,来自 UCI 机器学习库,来查看 pandas 的功能:

# Skipping first row cause it doesn't have any data
df = pd.read_excel("https://archive.ics.uci.edu/ml/machine-learning-databases/00247/data_akbilgic.xlsx", skiprows=1) 

我们读取的 DataFrame 如下所示:

图 2.2 – 包含股票交易价格的 DataFrame

图 2.2:包含股票交易价格的 DataFrame

现在我们已经读取了 DataFrame,让我们开始处理它。

将日期列转换为 pd.Timestamp/DatetimeIndex

首先,我们必须将日期列(该列可能并不会被 pandas 自动解析为日期)转换为 pandas 的日期时间格式。为此,pandas 提供了一个非常方便的函数叫做 pd.to_datetime。它会自动推断日期时间格式,并将输入转换为 pd.Timestamp(如果输入是一个 string)或 DatetimeIndex(如果输入是一个字符串的 list)。因此,如果我们传入单一的日期字符串,pd.to_datetime 会将其转换为 pd.Timestamp,而如果我们传入一组日期,它会将其转换为 DatetimeIndex。我们还可以使用一个方便的函数,strftime,它可以将日期格式化为我们指定的格式。它使用 strftime 的约定来指定数据的格式。例如,%d 代表零填充的日期,%B 代表月份的全名,%Y 代表四位数字的年份。strftime 约定的完整列表可以在 strftime.org/ 查找:

>>> pd.to_datetime("13-4-1987").strftime("%d, %B %Y")
'13, April 1987' 

现在,让我们来看一个自动解析失败的案例。日期是 1987 年 1 月 4 日。让我们看看将该字符串传递给函数时会发生什么:

>>> pd.to_datetime("4-1-1987").strftime("%d, %B %Y")
'01, April 1987' 

嗯,那并不是预期的结果,对吧?不过仔细想想,任何人都可能犯这个错误,因为我们没有告诉计算机是月在前还是日,在这种情况下 pandas 假定月份在前。让我们来纠正一下:

>>> pd.to_datetime("4-1-1987", dayfirst=True).strftime("%d, %B %Y")
'04, January 1987' 

自动日期解析失败的另一个情况是当日期字符串采用非标准形式时。在这种情况下,我们可以提供一个strftime格式化字符串,帮助 pandas 正确解析日期:

>>> pd.to_datetime("4|1|1987", format="%d|%m|%Y").strftime("%d, %B %Y")
'04, January 1987' 

strftime 格式化规范的完整列表可以在strftime.org/找到。

实践者提示

由于数据格式的多样性,pandas 可能会错误地推断时间。在读取文件时,pandas 会尝试自动解析日期并创建错误。有许多方法可以控制这种行为:我们可以使用 parse_dates 标志来关闭日期解析,使用 date_parser 参数传入自定义日期解析器,以及使用 year_firstday_first 来轻松表示两种常见的日期格式。从 2.0 版本开始,pandas 支持 date_format,可以用来传入日期的确切格式,作为一个 Python 字典,列名作为键。

在所有这些选项中,如果使用 pandas >=2.0,我更倾向于使用 date_format。我们可以保持 parse_dates=True,然后传入确切的日期格式,使用 strftime 格式化规范。这确保了日期按照我们希望的方式被解析。

如果使用的是 pandas <2.0,那么我更倾向于在 pd.read_csvpd.read_excel 中都保持 parse_dates=False,以确保 pandas 不会自动解析数据。之后,你可以使用 format 参数转换日期,该参数允许你显式设置列的日期格式,采用 strftime 格式化规范。pd.to_datetime 中还有两个参数可以减少推断日期时的错误——yearfirstdayfirst。如果没有提供显式的日期格式,至少要提供其中一个。

现在,让我们把我们股票价格数据集中的日期列转换为日期时间格式:

df['date'] = pd.to_datetime(df['date'], yearfirst=True) 

现在,'date' 列的 dtype 应该是 datetime64[ns]<M8[ns],这两者都是 pandas/NumPy 本地的日期时间格式。但为什么我们需要这么做呢?

这是因为它解锁了大量额外的功能。传统的 min()max() 函数开始生效,因为 pandas 知道这是一个日期时间列:

>>> df.date.min(),df.date.max()
(Timestamp('2009-01-05 00:00:00'), Timestamp('2011-02-22 00:00:00')) 

让我们看一下日期时间格式带来的一些酷炫特性。

使用 .dt 访问器和日期时间属性

由于列现在是日期格式,所有包含在日期中的语义信息都可以通过 pandas datetime 属性使用。我们可以使用 .dt 访问器访问许多日期时间属性,例如 monthday_of_weekday_of_year 等等:

>>> print(f"""
     Date: {df.date.iloc[0]}
     Day of year: {df.date.dt.day_of_year.iloc[0]}
     Day of week: {df.date.dt.dayofweek.iloc[0]}
     Month: {df.date.dt.month.iloc[0]}
     Month Name: {df.date.dt.month_name().iloc[0]}
     Quarter: {df.date.dt.quarter.iloc[0]}
     Year: {df.date.dt.year.iloc[0]}
     ISO Week: {df.date.dt.isocalendar().week.iloc[0]}
     """)
Date: 2009-01-05 00:00:00
Day of year: 5
Day of week: 0
Month: 1
Month Name: January
Quarter: 1
Year: 2009
ISO Week: 2 

从 pandas 1.1.0 开始,week_of_year 已被弃用,因为它在年末/年初产生不一致的结果。取而代之的是采用了 ISO 日历标准(该标准广泛应用于政府和企业中),我们可以访问 ISO 日历以获取 ISO 周。

索引和切片

当我们将日期列设为 DataFrame 的索引时,真正有趣的部分开始了。通过这样做,你可以在 datetime 轴上使用 pandas 支持的所有花式切片操作。我们来看看其中的几个操作:

# Setting the index as the datetime column
df.set_index("date", inplace=True)
# Select all data after 2010-01-04(inclusive)
df["2010-01-04":]
# Select all data between 2010-01-04 and 2010-02-06(exclusive)
df["2010-01-04": "2010-02-06"]
# Select data 2010 and before
df[: "2010"]
# Select data between 2010-01 and 2010-06(both including)
df["2010-01": "2010-06"] 

除了语义信息和智能索引及切片功能外,pandas 还提供了创建和操作日期序列的工具。

创建日期序列和管理日期偏移

如果你熟悉 Python 中的 range 和 NumPy 中的 np.arange,那么你就知道它们帮助我们通过提供起始点和结束点来创建 整数/浮动 序列。pandas 也有类似的功能来处理日期时间——pd.date_range。该函数接受起始日期和结束日期,以及频率(日、月等),并在这两个日期之间创建日期序列。我们来看看几种创建日期序列的方式:

# Specifying start and end dates with frequency
pd.date_range(start="2018-01-20", end="2018-01-23", freq="D").astype(str).tolist()
# Output: ['2018-01-20', '2018-01-21', '2018-01-22', '2018-01-23']
# Specifying start and number of periods to generate in the given frequency
pd.date_range(start="2018-01-20", periods=4, freq="D").astype(str).tolist()
# Output: ['2018-01-20', '2018-01-21', '2018-01-22', '2018-01-23']
# Generating a date sequence with every 2 days
pd.date_range(start="2018-01-20", periods=4, freq="2D").astype(str).tolist()
# Output: ['2018-01-20', '2018-01-22', '2018-01-24', '2018-01-26']
# Generating a date sequence every month. By default it starts with Month end
pd.date_range(start="2018-01-20", periods=4, freq="M").astype(str).tolist()
# Output: ['2018-01-31', '2018-02-28', '2018-03-31', '2018-04-30']
# Generating a date sequence every month, but month start
pd.date_range(start="2018-01-20", periods=4, freq="MS").astype(str).tolist()
# Output: ['2018-02-01', '2018-03-01', '2018-04-01', '2018-05-01'] 

我们还可以使用 pd.TimeDelta 来给日期加减天数、月份和其他值:

# Add four days to the date range
(pd.date_range(start="2018-01-20", end="2018-01-23", freq="D") + pd.Timedelta(4, unit="D")).astype(str).tolist()
# Output: ['2018-01-24', '2018-01-25', '2018-01-26', '2018-01-27']
# Add four weeks to the date range
(pd.date_range(start="2018-01-20", end="2018-01-23", freq="D") + pd.Timedelta(4, unit="W")).astype(str).tolist()
# Output: ['2018-02-17', '2018-02-18', '2018-02-19', '2018-02-20'] 

pandas 中有很多这样的别名,包括 WW-MONMS 等。完整列表可以在 pandas.pydata.org/docs/user_guide/timeseries.html#timeseries-offset-aliases 查找到。

在本节中,我们了解了在 datetime 索引上可以执行的一些有用特性和操作,并且知道如何操作包含 datetime 列的 DataFrame。现在,让我们回顾一下几种可以处理缺失数据的技术。

处理缺失数据

在处理大规模数据集时,难免会遇到缺失数据。如果它不是时间序列的一部分,可能是你收集和映射的附加信息的一部分。在我们仓促地用均值填充或删除这些行之前,先考虑一下几个方面:

  • 首先需要考虑的是,我们担心的缺失数据到底是缺失还是其他原因。为此,我们需要考虑数据生成过程DGP)(生成时间序列的过程)。举个例子,假设我们在处理某个本地超市的销售数据。你已经得到了过去 2 年的销售点POS)交易数据,并正在将数据处理成时间序列。在分析数据时,你发现有一些产品几天内没有交易记录。现在,你需要考虑的是,这些缺失数据是真的缺失,还是缺失本身就能提供一些信息。如果某个产品一天没有交易记录,它在处理时会被视为缺失数据,尽管实际上它并不缺失。实际上,这意味着那天没有销售,因此你应该用零来填补这些缺失数据。

  • 现在,如果你发现每个星期天数据都缺失——也就是说,缺失有规律可循,怎么办?这就变得有些棘手,因为如何填补这些空缺取决于你打算使用的模型。如果你用零填补这些空缺,那么一个基于近期数据预测未来的模型可能会受到影响,尤其是在周一的预测中。然而,如果你告诉模型前一天是星期天,那么模型仍然能够学会区分这些情况。

  • 最后,如果你看到某个热销产品的销售量为零,而它通常总是有销售,这该怎么办?这可能是因为某些原因,如 POS 机故障、数据录入错误或缺货等情况。可以使用一些技术来填补这类缺失值。

让我们看看澳大利亚堪培拉 ACT 政府发布的一个空气质量数据集(根据 CC by Attribution 4.0 国际许可证发布,链接:www.data.act.gov.au/Environment/Air-Quality-Monitoring-Data/94a5-zqnn),并看看我们如何使用 pandas 填充这些缺失值(本章稍后将介绍更多复杂的技术)。

实践者提示

使用read_csv等方法读取数据时,pandas 提供了几种便捷的方式来处理缺失值。默认情况下,pandas 会将#N/Anull等值视为NaN。我们可以使用na_valueskeep_default_na参数来控制允许的NaN值列表。

我们选择了Monash地区和PM2.5的读数,并人为地引入了一些缺失值,如下图所示:

图 2.3 – 空气质量数据集中的缺失值

图 2.3:空气质量数据集中的缺失值

现在,让我们来看一些可以用来填充缺失值的简单技术:

  • 最后观察值前向填充或前向填充:这个插补技术使用最后一个观察值填充所有缺失值,直到遇到下一个观察值。这也被称为前向填充。我们可以这样操作:

    df['pm2_5_1_hr'].ffill() 
    
  • 下一个观察值后向填充或后向填充:这个插补技术使用下一个观察值,回溯并用该值填充所有缺失值。这也被称为后向填充。让我们来看看如何在 pandas 中实现这一点:

    df['pm2_5_1_hr'].bfill() 
    
  • 均值填充:这个插补技术也相当简单。我们计算整个序列的均值,并在遇到缺失值时用均值填充:

    df['pm2_5_1_hr'].fillna(df['pm2_5_1_hr'].mean()) 
    

让我们绘制使用这三种技术得到的插补线:

图 2.4 – 使用前向填充、后向填充和均值填充插补缺失值

图 2.4: 使用前向填充、后向填充和均值填充插补缺失值

另一类插补技术涉及插值:

  • 线性插值:线性插值就像是在两个观察点之间画一条直线,并填充缺失值使其位于这条直线上。我们可以这样操作:

    df['pm2_5_1_hr'].interpolate(method="linear") 
    
  • 最近邻插值:这直观上就像是前向填充和后向填充的结合。对于每个缺失值,找到最接近的观察值并用它来填充缺失值:

    df['pm2_5_1_hr'].interpolate(method="nearest") 
    

让我们绘制这两条插值线:

图 2.5 – 使用线性插值和最近邻插值插补缺失值

图 2.5: 使用线性插值和最近邻插值插补缺失值

还有一些非线性插值技术:

  • 样条插值、多项式插值和其他插值方法:除了线性插值外,pandas 还支持非线性插值技术,这些技术会在后台调用 SciPy 函数。样条插值和多项式插值相似,它们会为数据拟合一个给定阶数的样条/多项式,并用它来填补缺失值。在使用 splinepolynomial 作为 interpolate 方法时,我们应该始终提供 order 参数。阶数越高,拟合观察点的函数就越灵活。让我们来看一下如何使用样条插值和多项式插值:

    df['pm2_5_1_hr'].interpolate(method="spline", order=2)
    df['pm2_5_1_hr'].interpolate(method="polynomial", order=5) 
    

让我们绘制这两种非线性插值技术:

图 2.6 – 使用样条插值和多项式插值插补缺失值

图 2.6: 使用样条插值和多项式插值插补缺失值

有关interpolate支持的所有插值技术,请访问pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.interpolate.htmldocs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interp1d.html#scipy.interpolate.interp1d

现在我们已经更熟悉 pandas 如何处理 datetime 类型的数据,接下来让我们回到数据集,并将数据转换成更易于管理的形式。

笔记本提示

若要查看完整的预处理代码,请使用Chapter02文件夹中的02-Preprocessing_London_Smart_Meter_Dataset.ipynb笔记本。

将半小时区块数据(hhblock)转换为时间序列数据

在开始处理之前,让我们了解一下时间序列数据集中可能会出现的几类信息:

  • 时间序列标识符:这些是特定时间序列的标识符。它可以是一个名称、一个 ID,或任何其他唯一的特征——例如,我们正在处理的零售销售数据集中的 SKU 名称、消费者 ID,或者能源数据集中的消费者 ID,都是时间序列标识符。

  • 元数据或静态特征:这些信息不随时间变化。例如,我们数据集中的 ACORN 家庭分类就是静态特征。

  • 时间变化特征:这些信息会随时间变化——例如,天气信息。对于每一个时间点,我们都有不同的天气值,而不像 Acorn 分类那样固定。

接下来,让我们讨论一下数据集的格式化。

紧凑格式、扩展格式和宽格式数据

有很多方式可以格式化时间序列数据集,特别是像我们现在处理的这种包含多个相关时间序列的数据集。一种标准的方式是宽格式数据。这里日期列相当于索引,每个时间序列占据一个不同的列。如果有一百万个时间序列,就会有一百万零一个列(因此称为“宽格式”)。除了标准的宽格式数据,我们还可以考虑两种非标准的时间序列数据格式。尽管这些格式没有统一的命名,我们将在本书中将其称为紧凑格式扩展格式。扩展格式在一些文献中也被称为长格式

紧凑格式数据是指在 pandas DataFrame 中,每个时间序列只占据一行——也就是说,时间维度作为一个数组被管理在 DataFrame 的一行中。时间序列标识符和元数据占据列并包含标量值,接着是时间序列的值;其他随时间变化的特征占据列并包含数组。还会额外添加两列来推断时间——start_datetimefrequency。如果我们知道时间序列的开始日期和频率,就可以轻松构造时间并从 DataFrame 中恢复时间序列。这仅适用于定期采样的时间序列。其优点是 DataFrame 占用的内存更少,处理起来也更加容易和快速:

图 2.7 – 紧凑格式数据

图 2.7:紧凑格式数据

扩展格式是指时间序列沿着 DataFrame 的行进行展开。如果时间序列中有 n 步,它将在 DataFrame 中占据 n 行。时间序列标识符和元数据会沿着所有行重复。随时间变化的特征也会沿着行展开。此外,代替开始日期和频率,我们将使用时间戳作为一列:

图 2.8 – 扩展格式数据

图 2.8:扩展格式数据

如果紧凑格式中有时间序列标识符作为键,那么时间序列标识符和日期时间列将被组合并成为键。

宽格式数据在传统时间序列文献中更为常见。它可以视为一种遗留格式,在许多方面都有局限性。你还记得我们之前看到的股票数据(图 2.2)吗?我们将日期作为索引或列之一,将不同的时间序列作为 DataFrame 的不同列。随着时间序列数量的增加,它们变得越来越宽,因此得名。这种数据格式无法包含任何关于时间序列的元数据。例如,在我们的数据中,我们有关于某个家庭是否使用标准定价或动态定价的信息。在宽格式中我们无法包含这种元数据。从操作角度来看,宽格式也不太适合关系型数据库,因为每当我们获取新的时间序列时,我们就必须不断向表中添加列。在本书中,我们将不会使用这种格式。

强制时间序列的定期间隔

你应该首先检查和纠正的事情之一是你所拥有的定期采样时间序列数据是否具有相等的时间间隔。实际上,即使是定期采样的时间序列,也可能由于数据收集错误或其他数据采集方式的特殊性,出现缺失的样本。因此,在处理数据时,我们会确保强制执行时间序列中的定期间隔。

最佳实践

在处理包含多个时间序列的数据集时,最好检查所有时间序列的结束日期。如果它们不一致,我们可以将它们对齐到数据集中所有时间序列的最新日期。

在我们的智能电表数据集中,一些 LCLid 列比其他列结束得早。也许是家庭选择退出该计划,或者他们搬走了并且房子空置了,原因可以有很多。但我们需要处理这一点,同时保持规则的时间间隔。

我们将在下一节学习如何将数据集转换为时间序列格式。此过程的代码可以在 02-Preprocessing_London_Smart_Meter_Dataset.ipynb 笔记本中找到。

将伦敦智能电表数据集转换为时间序列格式

对于每个你遇到的数据集,转换成紧凑形式或扩展形式的步骤会有所不同。这取决于原始数据的结构。这里,我们将展示如何转换伦敦智能电表数据集,以便将这些经验转移到其他数据集。

在开始处理数据为紧凑或扩展形式之前,我们需要完成两个步骤:

  1. 找到全局结束日期:我们必须找到所有区块文件中的最大日期,以便知道时间序列的全局结束日期。

  2. 基本预处理:如果你记得 hhblock_dataset 的结构,你会记得每一行都有一个日期,而列中有半小时的区块。我们需要将其重塑为长格式,其中每行有一个日期和一个半小时的区块。这样处理更方便。

现在,让我们定义分别转换数据为紧凑形式和扩展形式的函数,并将这些函数 apply 到每个 LCLid 列。我们将为每个 LCLid 分别执行此操作,因为每个 LCLid 的开始日期不同。

扩展形式

将数据转换为扩展形式的函数执行以下操作:

  1. 查找开始日期。

  2. 使用开始日期和全局结束日期创建标准的 DataFrame。

  3. LCLid 的 DataFrame 进行左连接与标准 DataFrame,缺失的数据填充为 np.nan

  4. 返回合并后的 DataFrame。

一旦我们拥有所有的 LCLid DataFrame,我们需要执行几个额外的步骤来完成扩展形式的处理:

  1. 将所有 DataFrame 合并为一个单一的 DataFrame。

  2. 创建一个名为 offset 的列,它是半小时区块的数值表示;例如,hh_33

  3. 通过为日期加上 30 分钟的偏移来创建时间戳,并删除不必要的列。

对于一个区块,这种表示占用大约 47 MB 的内存。

紧凑形式

将数据转换为紧凑形式的函数执行以下操作:

  1. 查找开始日期和时间序列标识符。

  2. 使用开始日期和全局结束日期创建标准的 DataFrame。

  3. LCLid 的 DataFrame 进行左连接与标准 DataFrame,缺失的数据填充为 np.nan

  4. 按日期对值进行排序。

  5. 返回时间序列数组,以及时间序列标识符、开始日期和时间序列的长度。

一旦我们为每个 LCLid 获取了这些信息,我们可以将它们编译成一个 DataFrame,并将频率设置为 30 分钟。

对于一个区块,这种表示形式仅占用约 0.002 MB 的内存。

我们将使用紧凑形式,因为它更易于处理,且占用资源较少。

映射附加信息

从我们之前准备的数据模型中,我们知道有三个关键文件需要映射:家庭信息天气银行假期

informations_households.csv 文件包含关于家庭的元数据。这里有一些与时间无关的静态特征。为此,我们只需基于 LCLid(时间序列标识符)将 informations_households.csv 与紧凑形式进行左连接。

最佳实践

在进行 pandas 的 merge 时,一个最常见且意外的结果是操作前后行数不相同(即使你是进行左连接)。这通常发生在合并的键存在重复值时。作为最佳实践,可以在 pandas 合并时使用 validate 参数,它接受 one_to_onemany_to_one 等输入,这样可以在合并时进行检查,如果不符合假设,将抛出错误。更多信息,请访问 pandas.pydata.org/docs/reference/api/pandas.merge.html

银行假期和天气是时变特征,应当根据情况进行处理。需要牢记的最重要的一点是,在映射这些信息时,它们应该与我们已经存储的时间序列数组完美对齐。

uk_bank_holidays.csv 是一个包含假期日期和假期类型的文件。假期信息在这里非常重要,因为在假期期间,家庭成员通常待在家里,一起度过时间、看电视等,这会影响能源消耗模式。按照以下步骤处理该文件:

  1. 将日期列转换为 datetime 格式,并将其设置为 DataFrame 的索引。

  2. 使用我们之前看到的resample函数,我们必须确保索引每 30 分钟重采样一次,这是时间序列的频率。

  3. 对假期数据进行前向填充,并将其余的 NaN 值填充为 NO_HOLIDAY

现在,我们已经将假期文件转换成了一个 DataFrame,且每行代表一个 30 分钟的时间间隔。每行中有一列指定该天是否为假期。

weather_hourly_darksky.csv是一个每日频率的文件,我们需要将其下采样至 30 分钟频率,因为我们需要映射到的数据是半小时频率的。如果不进行此操作,天气数据将只映射到按小时计算的时间戳,导致半小时的时间戳没有数据。

我们处理该文件的步骤也类似于处理假期的方式:

  1. 将日期列转换为 datetime 格式,并将其设置为 DataFrame 的索引。

  2. 使用resample函数时,我们必须确保每 30 分钟对索引进行重采样,这也是时间序列的频率。

  3. 使用前向填充方法填充由于重采样而产生的缺失值。

现在你已经确保了时间序列和随时间变化的特征之间的对齐,你可以遍历每个时间序列,并提取天气和公共假日数组,然后将其存储在 DataFrame 的相应行中。

保存和加载文件到磁盘。

完全合并的 DataFrame 在紧凑形式下仅占~10 MB。然而,保存此文件需要一点工程工作。如果我们尝试以 CSV 格式保存文件,由于在 pandas 列中存储数组的方式(因为数据以紧凑形式存储),这将无法成功。我们可以将其保存为pickleparquet格式,或者任何二进制文件格式。根据我们机器上可用的内存大小,这种方式是可行的。虽然完全合并的 DataFrame 只有~10 MB,但以pickle格式保存时,文件大小会暴增至~15 GB。

我们可以做的是将其作为文本文件保存,同时进行一些调整,以适应列名、列类型和其他读取文件所需的元数据。最终保存的文件大小仍然是~15 GB,但由于这是 I/O 操作,我们并不将所有数据保存在内存中。我们将这种格式称为时间序列(.ts)格式。保存紧凑形式到.ts格式、读取.ts格式并将紧凑形式转换为展开形式的功能可在本书的 GitHub 仓库中的src/data_utils.py文件找到。

如果你不需要将整个 DataFrame 存储在一个文件中,可以将其拆分成多个块,并以二进制格式单独保存,如parquet格式。对于我们的数据集,我们可以选择这个方法,将整个 DataFrame 拆分成多个块并保存为parquet文件。这是最适合我们的方案,原因有以下几点:

  • 它利用了该格式自带的压缩特性。

  • 它按块读取整个数据,以便快速迭代和实验。

  • 数据类型在读写操作之间得以保留,从而减少了歧义。

对于非常大的数据集,我们可以使用一些 pandas 的替代库,这样可以更容易处理内存溢出的数据集。Polars 是一个非常棒的库,支持懒加载且运行非常快。对于真正庞大的数据集,使用带有分布式集群的 PySpark 可能是合适的选择。

现在我们已经处理完数据集并将其存储到磁盘上,让我们将其重新读取到内存中,并探讨一些处理缺失数据的技术。

处理长时间缺失数据

我们之前看到了一些处理缺失数据的技巧——前向填充、后向填充、插值等。如果只有一个或两个数据点缺失,这些技巧通常有效。但是,如果缺失的是一大段数据,那么这些简单的技巧就显得不足了。

笔记本提示

若要跟随缺失数据填充的完整代码,请使用Chapter02文件夹中的03-Handling_Missing_Data_(Long_Gaps).ipynb笔记本。

让我们从内存中读取0–7 parquet区块:

block_df = pd.read_parquet("data/london_smart_meters/preprocessed/london_smart_meters_merged_block_0-7.parquet") 

我们保存的数据是紧凑形式的。我们需要将其转换为扩展形式,因为在这种形式下处理时间序列数据更为方便。由于我们只需要时间序列的一个子集(为了更快的演示),我们将从这七个区块中提取一个区块。为了将紧凑形式转换为扩展形式,我们可以使用src/utils/data_utils.py中一个名为compact_to_expanded的有用函数:

#Converting to expanded form
exp_block_df = compact_to_expanded(block_df[block_df.file=="block_7"], timeseries_col = 'energy_consumption',
static_cols = ["frequency", "series_length", "stdorToU", "Acorn", "Acorn_grouped", "file"],
time_varying_cols = ['holidays', 'visibility', 'windBearing', 'temperature', 'dewPoint',
       'pressure', 'apparentTemperature', 'windSpeed', 'precipType', 'icon',
       'humidity', 'summary'],
ts_identifier = "LCLid") 

可视化一组相关时间序列中缺失数据的最佳方法之一是使用一个非常有用的包,叫做missingno

# Pivot the data to set the index as the datetime and the different time series along the columns
plot_df = pd.pivot_table(exp_block_df, index="timestamp", columns="LCLid", values="energy_consumption")
# Generate Plot. Since we have a datetime index, we can mention the frequency to decide what do we want on the X axis
msno.matrix(plot_df, freq="M") 

上述代码会产生以下输出:

图 2.9 – 第 7 区块中缺失数据的可视化

图 2.9:第 7 区块中缺失数据的可视化

仅在相关的时间序列中尝试missingno可视化,并且这些时间序列数量应少于 25 个。如果你有一个包含成千上万条时间序列的数据集(例如我们的完整数据集),应用这种可视化会导致我们得到一个无法辨认的图形,并使计算机卡住。

这张可视化图表一眼就能告诉我们很多信息。Y轴显示的是我们所绘制可视化图表的日期,而X轴则包含了列,这里是不同的家庭。我们知道,并非所有时间序列都完全对齐——也就是说,并非所有的时间序列都在相同的时间开始或结束。我们可以看到,在许多时间序列的开头都有明显的白色空隙,这表明这些消费者的数据收集开始得比其他消费者晚。我们还可以看到有一些时间序列比其他序列提前结束,这意味着这些消费者要么停止了消费,要么测量阶段停止了。许多时间序列中还有一些较小的白色线条,它们代表着真实的缺失值。我们还可以注意到右侧有一个迷你图,它是每一行缺失列数量的紧凑表示。如果没有缺失值(即所有时间序列都有数据),那么迷你图会显示在最右侧。如果缺失值很多,迷你图的线条会出现在最左侧。

仅仅因为存在缺失值,我们并不会立即填充/估算这些数据,因为是否填充缺失数据的决策会在后续流程中做出。对于某些模型,我们不需要进行填充,而对于其他模型则需要。填充缺失数据有多种方法,选择哪种方法是我们在此之前无法做出的决定。

那么现在,让我们选择一个LCLid,深入分析。我们已经知道在2012-09-302012-10-31之间存在一些缺失值。让我们可视化这一时期的数据:

# Taking a single time series from the block
ts_df = exp_block_df[exp_block_df.LCLid=="MAC000193"].set_index("timestamp")
msno.matrix(ts_df["2012-09-30": "2012-10-31"], freq="D") 

上面的代码将产生如下输出:

图 2.10 – 2012-09-30 到 2012-10-31 之间 MAC000193 缺失数据的可视化

图 2.10:2012-09-30 到 2012-10-31 之间 MAC000193 缺失数据的可视化

在这里,我们可以看到缺失数据出现在2012-10-182012-10-19之间。通常情况下,我们会继续填充这一时期的缺失数据,但由于我们以学术的视角来看待这个问题,我们将采取略微不同的路径。

让我们引入一个人工缺失数据部分,看看我们将要使用的不同技术如何填充缺失数据,并计算一个指标来评估我们与真实时间序列的接近程度(我们将使用一个叫做平均绝对误差MAE)的指标来做比较,它不过是所有时间点的绝对误差的平均值。只需要明白它是一个越小越好的指标,我们将在书中的后续章节详细讨论它。):

# The dates between which we are nulling out the time series
window = slice("2012-10-07", "2012-10-08")
# Creating a new column and artificially creating missing values
ts_df['energy_consumption_missing'] = ts_df.energy_consumption
ts_df.loc[window, "energy_consumption_missing"] = np.nan 

现在,让我们在时间序列中绘制缺失区域:

图 2.11 – 2012-10-05 到 2012-10-10 之间 MAC000193 的能耗

图 2.11:2012-10-05 到 2012-10-10 之间 MAC000193 的能耗

我们缺少了整整两天的能耗数据,这意味着有 96 个缺失的数据点(每半小时一个数据点)。如果我们使用之前看到的一些方法,例如插值法,我们会发现结果大多是直线,因为这些方法不够复杂,无法捕捉长期的模式。

有一些技术可以用来填补如此大的数据缺口。我们现在将介绍这些方法。

使用前一天数据进行填补

由于这是一个半小时一次的能耗时间序列,可以合理推测,每天可能会有重复的模式。上午 9:00 到 10:00 之间的能耗可能较高,因为每个人都在准备去办公室,而白天大部分时间住宅可能是空的,能耗较低。

因此,填补缺失数据最简单的方法就是使用前一天的能耗数据,这样 2012-10-18 10:00 A.M. 的能耗可以用 2012-10-17 10:00 A.M. 的能耗来填补:

#Shifting 48 steps to get previous day
ts_df["prev_day"] = ts_df['energy_consumption'].shift(48)
#Using the shifted column to fill missing
ts_df['prev_day_imputed'] =  ts_df['energy_consumption_missing']
ts_df.loc[null_mask,"prev_day_imputed"] = ts_df.loc[null_mask,"prev_day"]
mae = mean_absolute_error(ts_df.loc[window, "prev_day_imputed"], ts_df.loc[window, "energy_consumption"]) 

让我们看看填补后的效果如何:

图 2.12 – 使用前一天数据进行填补

图 2.12:使用前一天数据进行填补

尽管这看起来更好,但这种方法也很脆弱。当我们复制前一天的数据时,我们也假设任何变化或异常行为都会被复制。我们已经可以看到前一天和后一天的模式并不相同。

每小时平均曲线

更好的方法是从数据中计算出每小时曲线——每个小时的平均消耗——并用这个平均值来填补缺失数据:

#Create a column with the Hour from timestamp
ts_df["hour"] = ts_df.index.hour
#Calculate hourly average consumption
hourly_profile = ts_df.groupby(['hour'])['energy_consumption'].mean().reset_index()
hourly_profile.rename(columns={"energy_consumption": "hourly_profile"}, inplace=True)
#Saving the index because it gets lost in merge
idx = ts_df.index
#Merge the hourly profile dataframe to ts dataframe
ts_df = ts_df.merge(hourly_profile, on=['hour'], how='left', validate="many_to_one")
ts_df.index = idx
#Using the hourly profile to fill missing
ts_df['hourly_profile_imputed'] = ts_df['energy_consumption_missing']
ts_df.loc[null_mask,"hourly_profile_imputed"] = ts_df.loc[null_mask,"hourly_profile"]
mae = mean_absolute_error(ts_df.loc[window, "hourly_profile_imputed"], ts_df.loc[window, "energy_consumption"]) 

让我们看看这是否更好:

图 2.13 – 使用每小时曲线进行填补

图 2.13:使用每小时曲线进行填补

这样我们得到了一个更加通用的曲线,没有我们在单独日期中看到的尖峰波动。每小时的起伏也已经按预期被捕捉到了。MAE(平均绝对误差)也比之前低。

每个工作日的每小时平均值

我们可以通过引入每个工作日的特定曲线进一步完善这个规则。可以合理推测,工作日的使用模式与周末并不相同。因此,我们可以分别计算每个工作日的每小时平均能耗,这样我们就可以为星期一、星期二等每一天得到一条独立的曲线:

#Create a column with the weekday from timestamp
ts_df["weekday"] = ts_df.index.weekday
#Calculate weekday-hourly average consumption
day_hourly_profile = ts_df.groupby(['weekday','hour'])['energy_consumption'].mean().reset_index()
day_hourly_profile.rename(columns={"energy_consumption": "day_hourly_profile"}, inplace=True)
#Saving the index because it gets lost in merge
idx = ts_df.index
#Merge the day-hourly profile dataframe to ts dataframe
ts_df = ts_df.merge(day_hourly_profile, on=['weekday', 'hour'], how='left', validate="many_to_one")
ts_df.index = idx
#Using the day-hourly profile to fill missing
ts_df['day_hourly_profile_imputed'] = ts_df['energy_consumption_missing']
ts_df.loc[null_mask,"day_hourly_profile_imputed"] = ts_df.loc[null_mask,"day_hourly_profile"]
mae = mean_absolute_error(ts_df.loc[window, "day_hourly_profile_imputed"], ts_df.loc[window, "energy_consumption"]) 

让我们看看这看起来如何:

图 2.14 – 填补每个工作日的每小时平均值 图 2.14:填补每个工作日的每小时平均值

这看起来和之前的结果非常相似,但这是因为我们正在填补的这一天是工作日,而工作日的曲线非常相似。MAE 也比单一日期的曲线低。周末的曲线稍有不同,您可以在相关的 Jupyter 笔记本中看到。

季节性插值

尽管计算季节性配置文件并用它进行填充效果很好,但在某些情况下,特别是当时间序列中存在趋势时,这种简单的技术就不够用了。简单的季节性配置文件根本没有捕捉到趋势,完全忽略了它。在这种情况下,我们可以采取以下措施:

  1. 计算季节性配置文件,类似于我们之前计算平均值的方式。

  2. 减去季节性配置文件并应用我们之前看到的任何插值技术。

  3. 将季节性配置文件返回到插值后的序列中。

这个过程已经在本书的 GitHub 仓库中的 src/imputation/interpolation.py 文件中实现。我们可以按以下方式使用它:

from src.imputation.interpolation import SeasonalInterpolation
# Seasonal interpolation using 48*7 as the seasonal period.
recovered_matrix_seas_interp_weekday_half_hour = SeasonalInterpolation(seasonal_period=48*7,decomposition_strategy="additive", interpolation_strategy="spline", interpolation_args={"order":3}, min_value=0).fit_transform(ts_df.energy_consumption_missing.values.reshape(-1,1))
ts_df['seas_interp_weekday_half_hour_imputed'] = recovered_matrix_seas_interp_weekday_half_hour 

这里的关键参数是 seasonal_period,它告诉算法寻找每 seasonal_period 个数据点重复的模式。如果我们指定 seasonal_period=48,它会寻找每 48 个数据点重复的模式。在我们的例子中,这是每天之后的模式(因为一天有 48 个半小时的时间步)。除了这个参数,我们还需要指定需要执行哪种插值方法。

附加信息

在内部,我们使用了一种叫做季节性分解的方法(statsmodels.tsa.seasonal.seasonal_decompose),它将在第三章《分析和可视化时间序列数据》中讲解,用来分离季节性成分。

在这里,我们使用 48(半小时)和 48*7(工作日到半小时)进行了季节性插值,并绘制了得到的填充结果:

图 2.15 – 使用季节性插值进行填充

图 2.15:使用季节性插值进行填充

在这里,我们可以看到两者都捕捉到了季节性模式,但每个工作日的半小时配置文件更好地捕捉了第一天的峰值,因此它们的 MAE 较低。在每小时平均值方面没有改进,主要是因为时间序列中没有强烈的上升或下降模式。

至此,本章内容已经结束。我们现在正式进入了时间序列数据的清洗、处理和操控的细节。恭喜你完成本章的学习!

总结

在这一章中,在简要回顾 pandas DataFrame,特别是 datetime 操作和处理缺失数据的简单技巧之后,我们学习了存储和处理时间序列数据的两种形式——紧凑型和展开型。通过这些知识,我们将原始数据集处理并构建了一个管道,将其转换为紧凑型。如果你已经运行了附带的笔记本,你应该已经将预处理后的数据集保存到磁盘。我们还深入探讨了处理长时间缺失数据的几种技术。

现在我们已经得到了处理后的数据集,在下一章中,我们将学习如何可视化和分析时间序列数据集。

加入我们的 Discord 社区

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

packt.link/mts

第三章:分析和可视化时间序列数据

在上一章中,我们学习了如何获取时间序列数据集,以及如何使用 pandas 操作时间序列数据、处理缺失值等。现在我们已经获得了处理过的时间序列数据,是时候理解数据集了,数据科学家将此过程称为探索性数据分析EDA)。这是一个数据科学家通过查看聚合统计信息、特征分布、可视化等,分析数据以发现其中的模式并在建模中加以利用的过程。在本章中,我们将探索几种分析时间序列数据集的方法,介绍一些专门为时间序列设计的技术,并回顾一些时间序列数据的可视化技术。

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

  • 时间序列的组成部分

  • 可视化时间序列数据

  • 分解时间序列

  • 检测和处理异常值

技术要求

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

你需要运行Chapter02文件夹中的02-Preprocessing_London_Smart_Meter_Dataset.ipynb笔记本。

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

时间序列的组成部分

在我们开始分析和可视化时间序列之前,我们需要了解时间序列的结构。任何时间序列都可以包含以下部分中的一些或全部内容:

  • 趋势

  • 季节性

  • 周期性

  • 不规则性

这些成分可以以不同的方式混合在一起,但两种最常见的假设方式是加法模型Y = 趋势 + 季节性 + 周期性 + 不规则)和乘法模型Y = 趋势 * 季节性 * 周期性 * 不规则),其中Y表示时间序列。

趋势成分

趋势是时间序列平均值的长期变化。它是时间序列沿特定方向平稳而稳定的运动。当时间序列向上移动时,我们称之为上升或增长趋势,而当它向下移动时,我们称之为下降或减少趋势。在撰写本文时,如果我们回顾特斯拉过去几年的收入,如下图所示,我们可以看到它在过去几年里一直在持续增长:

图 3.1 – 特斯拉的收入(以百万美元计)

图 3.1:特斯拉的收入(以百万美元计)

观察前面的图表,我们可以说特斯拉的收入呈现增长趋势。趋势不需要是线性的,它也可以是非线性的。

季节性成分

当时间序列表现出规律的、重复的上下波动时,我们称之为季节性。例如,零售销售通常在节假日期间激增,尤其是在西方国家的圣诞节期间。类似地,热带地区的电力消费在夏季高峰,而寒冷地区则在冬季达到高峰。在这些例子中,你可以看到每年重复出现的特定上下波动模式。另一个例子是太阳黑子,如下图所示:

图 3.2 – 1749 到 2017 年的太阳黑子数量

图 3.2:1749 到 2017 年的太阳黑子数量

如你所见,太阳黑子每 11 年达到一次高峰。

周期性成分

周期性成分常常与季节性混淆,但由于一个微妙的差异,它们是不同的。像季节性一样,周期性成分也表现出围绕趋势线的相似上下波动模式,但这个周期的时间并不是固定的,并且会有一定的变化。例如,经济衰退通常会在一个 10 年的周期内发生。然而,这种现象并非如时钟般准确;有时它可能在 10 年内发生一次,也可能更多或更少。

不规则成分

这个成分是从时间序列中去除趋势、季节性和周期性后的残余部分。传统上,这个成分被认为是不可预测的,也被称为残差误差项,或噪声项。在常见的基于经典统计的模型中,任何“模型”的目标是捕捉其他所有成分,直到只剩下无法捕捉的非规律成分。

在现代机器学习中,我们并不认为这个成分完全不可预测。我们尝试通过使用外生变量来捕捉这个成分的一部分。例如,零售销售的非规律成分可能是由他们进行的不同促销活动来解释的。当我们有了这些附加信息后,这个“不可预测”的成分开始变得可以预测。但是,无论你向模型中添加多少额外的变量,总会有一些成分,它是无法简化的误差,依然被遗留下来。这部分时间序列是无法通过任何模型解释的,无论模型有多强大,或者你向模型中添加多少额外的信息。

现在我们知道了时间序列的不同成分,让我们看看如何可视化它们。

可视化时间序列数据

第二章获取和处理时间序列数据中,我们学习了如何准备数据模型,作为分析新数据集的第一步。如果准备数据模型像是接近你喜欢的人并进行首次接触,那么 EDA 就像是与这个人约会。此时,你拥有了数据集,正在试图了解它们,试图弄清楚它们的运作方式,喜欢和不喜欢什么,等等。

EDA(探索性数据分析)通常采用可视化技术来发现模式、识别异常、形成并验证假设等。花些时间理解你的数据集,在你试图从模型中榨取每一分性能时会帮助你很多。你可能会明白需要创建什么样的特征、应该应用哪种建模技术等。

本章将介绍几种适用于时间序列数据集的可视化技术。

笔记本提醒

若要跟随完整的时间序列可视化代码,请使用 Chapter03 文件夹中的 01-Visualizing_Time_Series.ipynb 笔记本。

线图

这是最基本和常见的可视化方法,用于理解时间序列。我们只需将时间绘制在 x 轴上,将时间序列值绘制在 y 轴上。让我们看看如果我们绘制数据集中某个家庭的数据会是什么样子:

图 3.3 – 家庭 MAC000193 的线图

图 3.3:家庭 MAC000193 的线图

当你有一个变化较大的长期时间序列时,就像我们所拥有的,线图可能会显得有些混乱。获得时间序列的宏观趋势和变化的一种选择是绘制平滑版本的时间序列。让我们看看时间序列的滚动月平均值是什么样子:

图 3.4 – 家庭 MAC000193 的滚动月均能耗

图 3.4:家庭 MAC000193 的滚动月均能耗

现在我们可以更清楚地看到宏观模式。季节性很明显——该序列在冬季达到峰值,夏季则处于最低谷。如果你仔细思考,这也很有道理。我们在谈论的是伦敦,冬季因为低温和随之而来的取暖系统使用,能耗会更高。

例如,对于热带地区的家庭,模式可能会反转,能耗的峰值出现在夏季,当空调开始使用时。

线图的另一个用途是同时可视化两个或多个时间序列,并调查它们之间的相关性。在我们的例子中,试着将温度与能耗一起绘制,看看我们关于温度影响能耗的假设是否成立:

图 3.5 – 温度和能耗(底部的放大图)

图 3.5:温度和能耗(底部的放大图)

在这里,我们可以看到能耗和温度之间在年度分辨率上的明显负相关。冬季在宏观上表现为更高的能耗。我们还可以看到与温度松散相关的日常模式,但这也许是因为其他因素,例如人们下班后回家等。

还有一些其他的可视化方法,更适合突出时间序列中的季节性。让我们来看看。

季节性图

季节性图与折线图非常相似,但关键的区别在于,x轴表示“季节”,y轴表示时间序列值,且不同的季节性周期使用不同的颜色或线型表示。例如,按月分辨率展示的年度季节性可以用月份表示在x轴上,并且用不同的颜色表示不同的年份。

让我们看看我们家庭的情况。这里,我们绘制了多年来的平均月度能源消耗:

图 3.6 – 按月分辨率绘制的季节性图

图 3.6:按月分辨率绘制的季节性图

我们可以立即看到这种可视化的吸引力,因为它让我们能够轻松地看到季节性模式。我们可以看到,夏季的消费量下降,并且我们还可以看到这种情况在多个年份中始终如一。在我们拥有数据的这两年中,我们可以看到,2013 年 10 月的行为与 2012 年略有不同。也许还有其他因素可以帮助我们解释这一差异——比如温度呢?

我们也可以将季节性图与其他感兴趣的变量一起绘制,比如温度:

图 3.7 – 按月分辨率绘制的季节性图(能源消耗与温度对比)

图 3.7:按月分辨率绘制的季节性图(能源消耗与温度对比)

注意到 10 月了吗?2013 年 10 月,气温持续较高长达一个月,因此能源消费模式与去年略有不同。

我们还可以在其他分辨率下绘制这些图形,比如按小时划分的季节性。我们所需要做的就是计算每个小时和每个月天数的平均消费量,并将它们绘制在x轴上显示小时数,使用不同的颜色表示每月的不同天数(图 3.8(上图))。但是,当季节性周期过多时,图形会变得杂乱无章。季节性图的一个替代方法是季节性箱型图。

季节性箱型图

我们可以将不同的季节性周期表示为箱型图(图 3.8(下图)),而不是用不同的颜色或线型绘制不同的季节性周期。这样可以立即清除图中的杂乱信息。通过这种表示方法,你还可以理解季节性周期之间的变动性:

图 3.8 – 按小时分辨率绘制的季节性图(上图)和季节性箱型图(下图)

图 3.8:按小时分辨率绘制的季节性图(上图)和季节性箱型图(下图)

在这里,我们可以看到这个分辨率下的季节性图像过于杂乱,难以分辨模式和跨季节周期的变化。然而,季节性箱型图提供了更多的信息。箱型中的水平线表示中位数,盒子表示四分位数范围IQR),而标记的点是异常值。通过观察中位数,我们可以看到消费的高峰发生在上午 9 点之后。但从上午 9 点开始,变化性也变得更大。如果你为每周绘制单独的箱型图,比如说,你会发现周日的模式稍有不同(更多可视化内容请参考关联的笔记本)。

然而,还有一种可视化方法可以让你从两个维度检查这些模式。

日历热图

与其为每周的每一天绘制单独的箱型图或线图,不如将这些信息浓缩成一张图。这时,日历热图就派上用场了。热图可视化使用颜色渐变表示数据值,不同的颜色表示不同的强度或频率。日历热图使用矩形块中的着色单元格来表示信息。在矩形的两侧,我们可以看到两种不同粒度的时间单位,如月份和年份。在每个交点处,单元格的颜色根据该交点处时间序列的值进行着色。

让我们看看不同工作日的每小时平均能源消耗在日历热图中的表现(请参见颜色图像文件:packt.link/gbp/9781835883181):

图 3.9 – 能源消耗的日历热图

图 3.9:能源消耗的日历热图

从右侧的颜色比例尺中,我们知道较浅的颜色表示较高的值。我们可以看到从周一到周六的高峰是相似的——也就是早上和晚上各一次。然而,周日的模式略有不同,全天的消费较高。

到目前为止,我们已经回顾了许多能够突显季节性的可视化。现在,让我们看看用于检查自相关的可视化方法。

自相关图

如果相关性表示两个变量之间线性关系的强度和方向,那么自相关则是时间序列中连续周期值之间的相关性。大多数时间序列对前一个周期的值有较强的依赖关系,而这也是我们将要看到的许多预测模型中的一个关键组成部分。

类似于 ARIMA(我们将在第四章设定强基准预测中简要介绍)的方法是基于自相关的。因此,直观地展示并理解对前一时间步的依赖强度总是有帮助的。

这就是自相关图派上用场的地方。在这些图中,我们将不同的滞后(t-1t-2t-3等)放在* x 轴上,将t与不同滞后的相关性放在y轴上。除了自相关,我们还可以查看部分自相关,它与自相关非常相似,但有一个关键的不同点:部分自相关在展示相关性之前,会去除任何可能存在的间接相关性。让我们通过一个例子来理解这一点。如果t是当前时间步长,我们假设t-1t有很强的相关性。那么根据这个逻辑,t-2会与t-1高度相关,由于这种相关性,tt-2之间的自相关性会很高。然而,部分自相关纠正了这一点,并提取了这种相关性,它可以纯粹归因于t-2t*。

我们需要记住的一点是,自相关和部分自相关分析在时间序列平稳时效果最佳(我们将在第六章《时间序列预测的特征工程》中详细讨论平稳性)。

最佳实践

有很多方法可以使一个序列变得平稳,但一个快速且简便的方法是使用季节性分解,并直接选择残差。它应该没有趋势和季节性,这些是时间序列中非平稳性的主要驱动因素。但正如我们在本书后面将看到的,这并不是将序列真正意义上使其平稳的万无一失的方法。

现在,让我们看看这些图表在我们家庭数据集中的表现(在使其平稳后):

图 3.10 – 自相关和部分自相关图

图 3.10:自相关和部分自相关图

在这里,我们可以看到第一个滞后(t-1)具有最大的影响,而且在部分自相关图中,它的影响迅速降到接近零。这意味着一天的能源消耗与前一天的能源消耗高度相关。

如果你之前见过这样的图表,你可能会看到图表上有一个包络线,显示置信区间,作为选择显著自相关的指南。虽然这是一个很好的经验法则,但这里没有包含,因为我不希望你将其作为规则。置信区间的相关性取决于一些假设(如正态性等),这些假设并不总是成立,尤其是在实际使用场景中。

通过这些,我们已经看到了时间序列的不同组成部分,并学习了如何可视化其中的一些。现在,让我们看看如何将一个时间序列分解为它的组成部分。

分解时间序列

季节性分解是将时间序列分解为其组成部分的过程——通常是趋势、季节性和残差。分解时间序列的一般方法如下:

  1. 去趋势化:在这里,我们估算趋势成分(即时间序列中的平滑变化)并将其从时间序列中移除,从而得到去趋势化的时间序列

  2. 去季节化:在这里,我们从去趋势化的时间序列中估算季节性成分。去除季节性成分后,剩下的是残差。

让我们详细讨论一下它们。

去趋势化

去趋势化可以通过几种不同的方式进行。两种常用的方法是使用移动平均局部估计散点图平滑LOESS回归

移动平均

估算趋势的最简单方法之一是使用沿时间序列的移动平均。它可以看作是一个在时间序列上滑动的窗口,每一步记录窗口中所有值的平均值。这个移动平均是一个平滑的时间序列,有助于我们估算时间序列中的缓慢变化,即趋势。缺点是这种技术相当嘈杂。即使使用这种技术平滑时间序列,提取的趋势仍然不会是平滑的,它会带有噪声。理想情况下,噪声应当存在于残差中,而不是趋势中(参见图 3.13中的趋势线)。

LOESS

LOESS算法,也称为局部加权多项式回归,是由比尔·克利夫兰(Bill Cleveland)在 70 年代至 90 年代间开发的。它是一种非参数方法,用于将平滑曲线拟合到噪声信号上。我们使用一个在时间序列中移动的序列变量作为自变量,将时间序列信号作为因变量。

对于每个序列变量中的值,算法使用最接近的点的一部分,并仅使用这些点进行加权回归,以估算平滑的趋势。这些加权回归中的权重是与当前点最接近的点。距离当前点越远,权重越小,最接近的点给予最高的权重。这为我们提供了一个非常有效的工具,用于建模时间序列中的平滑变化(趋势)(参见图 3.14中的趋势线)。

去季节化

季节性成分也可以通过几种不同的方式进行估算。最常见的两种方法是使用周期调整后的平均值或傅里叶级数。

周期调整后的平均值

这是一种相当简单的技术,我们通过取所有周期中各期的平均值来计算预期周期中的季节性指数。为了更清楚地说明这一点,我们来看一个月度时间序列,假设我们预期该时间序列具有年季节性。那么,起伏模式将在 12 个月内完成一个完整周期,或者季节性周期为 12。换句话说,时间序列中的每 12 个数据点具有相似的季节性成分。因此,我们取所有 1 月数据的平均值作为 1 月的周期调整平均值。同样,我们计算所有 12 个月的周期平均值。在整个过程结束时,我们得到 12 个周期平均值,还可以计算一个平均周期平均值。现在,我们可以通过从每个周期平均值中减去所有周期平均值的平均值(加法型)或将每个周期平均值除以所有周期平均值的平均值(乘法型)来将这些周期平均值转化为一个指数。

傅里叶级数

在 18 世纪末,数学家和物理学家约瑟夫·傅里叶在研究热流时,意识到了一个深刻的事实——任何周期性函数都可以分解为一系列简单的正弦波和余弦波。让我们稍微停下来思考一下。任何周期性函数,无论它的形状如何、是否有曲线,或者它如何围绕轴线剧烈振荡,都可以分解为一系列正弦波和余弦波。

附加信息

对于数学较为敏感的人来说,原始理论提出将任何周期性函数分解为指数函数的积分。利用欧拉公式,,我们可以将其视为正弦波和余弦波的总和。进一步阅读部分包含了一些资源,如果你想更深入地探讨并了解相关概念,比如傅里叶变换。

正是这种特性,我们用它来从时间序列中提取季节性,因为季节性是一个周期性函数,而任何周期性函数都可以通过正弦波和余弦波的组合来近似。傅里叶级数的正弦-余弦形式如下:

这里,S[N] 是信号 SN 项近似值。从理论上讲,当 N 趋近于无穷大时,所得近似值等于原始信号。P 是周期的最大长度。

我们可以使用这个傅里叶级数,或者从傅里叶级数中提取几个项,来建模我们的季节性。在我们的应用中,P 是我们试图建模的周期的最大长度。例如,对于月度数据的年度季节性,周期的最大长度 (P) 为 12。x 是一个从 1P 的序数变量。在这个例子中,x 将是 1, 2, 3, … 12。现在,利用这些项,剩下的就是找到 a[n] 和 b[n],我们可以通过对信号进行回归来完成这个过程。

我们已经看到,通过适当组合傅里叶项,我们可以复制任何信号。但问题是,我们是否应该这么做?我们希望从数据中学到的是一个通用的季节性特征,这个特征在未见过的数据上也能表现良好。因此,我们使用 N 作为超参数,从数据中提取我们所需的复杂信号。

现在是复习三角函数的好时机,回想一下正弦波和余弦波的形态。第一个傅里叶项 (n=1) 就是你熟悉的正弦波和余弦波,它们在最大周期长度 (P) 内完成一个完整的周期。随着 n 的增加,我们得到的正弦波和余弦波将在最大周期长度 (P) 内完成多个周期。下图中可以看到这一点:

图 3.11 – 余弦傅里叶项 (n=1, 2, 3)

图 3.11:余弦傅里叶项 (n = 1, 2, 3)

正弦波和余弦波是互为补充的,如下图所示:

图 3.12 – 正弦和余弦傅里叶项 (n=1)

图 3.12:正弦和余弦傅里叶项 (n = 1)

现在,让我们看看如何在实践中使用它。

实现方法

笔记本提示

要跟随完整的时间序列分解代码,可以使用 Chapter03 文件夹中的 02-Decomposing_Time_Series.ipynb 笔记本。

这里我们将介绍四种实现方法,接下来的小节中将详细讲解。

来自 statsmodel 的 seasonal_decompose

statsmodels.tsa.seasonal 有一个名为 seasonal_decompose 的函数。这个实现使用移动平均来处理趋势成分,使用周期调整的平均值来处理季节性成分。它支持加法模式和乘法模式的分解。然而,它不能处理缺失值。让我们来看看如何使用它:

#Does not support missing values, so using imputed ts instead
res = seasonal_decompose(ts, period=7*48, model="additive", extrapolate_trend="freq") 

一些需要记住的关键参数如下:

  • period 是你预期模式重复的季节周期。

  • model 需要一个 additivemultiplicative 的参数来确定分解类型。

  • filt 接受一个数组,作为移动平均(卷积,具体来说)的权重。它也可以用来定义计算移动平均所需的窗口。我们可以增加窗口大小,以在一定程度上平滑趋势成分。

  • extrapolate_trend 是一个参数,我们可以用它将趋势成分扩展到两侧,以避免在应用移动平均滤波器时生成的缺失值。

  • two_sided 是一个参数,允许我们定义如何计算移动平均。如果设置为 True(默认值),则移动平均是基于过去和未来的值计算的,因为移动平均的窗口是居中的。如果设置为 False,则只使用过去的值来计算移动平均。

让我们看看我们如何能够分解数据集中一个时间序列。我们使用 period=7*48 来捕捉工作日的小时数据轮廓,并使用 filt=np.repeat(1/(30*48), 30*48) 对 30 天内的数据进行均匀权重的移动平均:

图 3.13 – 使用 statsmodels 进行季节性分解

图 3.13:使用 statsmodels 进行季节性分解

我们看不到季节性模式,因为它在整个图表的尺度下太小了。相关的笔记本有放大的图表,帮助你理解季节性模式。即使使用较大的平滑窗口(例如 20 天),趋势仍然有一些噪音。我们可能通过增加窗口来进一步减少这些噪音,但现在有一个更好的选择,我们马上就会看到。

使用 LOESS 进行季节性和趋势分解(STL)

正如我们之前看到的,LOESS 更适合用于趋势估计。STL 是一种使用 LOESS 进行趋势估计,使用周期平均值进行季节性分解的实现。尽管 statsmodels 已经有一个实现,我们重新实现了它,以获得更好的性能和灵活性。

该实现可以在本书的 GitHub 仓库中的 src.decomposition.seasonal.py 找到。它期望输入一个带有日期时间索引的 pandas DataFrame 或系列。让我们看看如何使用这个:

stl = STL(seasonality_period=7*48, model = "additive")
res_new = stl.fit(ts_df.energy_consumption) 

这里的关键参数如下:

  • seasonality_period 是你期望模式重复的季节性周期。

  • modeladditivemultiplicative 作为参数来确定分解类型。

  • lo_frac 是用于拟合 LOESS 回归的样本数据的比例。

  • lo_delta 是在其中使用线性插值而非加权回归的距离比例。使用非零的 lo_delta 会显著减少计算时间。

让我们看看这个分解的效果。这里,我们使用了 seasonality_period=7*48 来捕捉工作日小时模式:

图 3.14 – STL 分解

图 3.14:STL 分解

让我们也看看只针对一个月的分解,看看提取的季节性模式更清晰:

图 3.15 – STL 分解(放大查看一个月)

图 3.15:STL 分解(放大查看一个月)

现在趋势已经足够平滑,季节性也已被捕捉到。这里,我们可以清楚地看到每小时的峰谷变化,并且周末的峰值较高。然而,由于我们依赖于周期平均值来推导季节性,它也会受到异常值的很大影响。时间序列中的一些极高或极低的值会扭曲从周期平均值推导出的季节性模式。这种技术的另一个缺点是,当数据分辨率与预期的季节性周期之间的差异较大时,提取的季节性的“好坏”会受到影响。例如,在日数据或子日数据上提取年度季节性时,这将使提取的季节性非常嘈杂。如果你期望的季节性周期少于两个周期,这种技术也不适用——例如,如果我们想提取年度季节性,但我们只有不到 2 年的数据。

傅里叶分解

我们可以在src.decomposition.seasonal.py中找到使用傅里叶项分解时间序列的 Python 实现。它使用 LOESS 进行趋势检测,并使用傅里叶项提取季节性。我们可以通过两种方式使用它。首先,我们可以指定seasonality_periodpandas的日期时间属性(例如hour和 week_of_day):

stl = FourierDecomposition(seasonality_period="hour", model = "additive", n_fourier_terms=5)
res_new = stl.fit(pd.Series(ts.squeeze(), index=ts_df.index)) 

或者,我们可以创建一个与时间序列长度相同的自定义季节性数组,该数组具有季节性的序数表示。如果是日数据的年度季节性,则该数组的最小值为1,最大值为365,因为它每天增加 1:

#Making a custom seasonality term
ts_df["dayofweek"] = ts_df.index.dayofweek
ts_df["hour"] = ts_df.index.hour
#Creating a sorted unique combination df
map_df = ts_df[["dayofweek","hour"]].drop_duplicates().sort_values(["dayofweek", "hour"])
# Assigning an ordinal variable to capture the order
map_df["map"] = np.arange(1, len(map_df)+1)
# mapping the ordinal mapping back to the original df and getting the seasonality array
seasonality = ts_df.merge(map_df, on=["dayofweek","hour"], how='left', validate="many_to_one")['map']
stl = FourierDecomposition(model = "additive", n_fourier_terms=50)
res_new = stl.fit(pd.Series(ts, index=ts_df.index), seasonality=seasonality) 

参与此过程的关键参数如下:

  • seasonality_period 是从日期时间索引中提取的季节性。可以使用pandas的日期时间属性,如week_of_daymonth,来指定最显著的季节性。如果设置为None,则在调用fit时需要提供季节性数组。

  • model 接受additivemultiplicative作为参数来确定分解类型。

  • n_fourier_terms 确定用于提取季节性的傅里叶项的数量。增加该参数时,从数据中提取的季节性会变得更加复杂。

  • lo_frac 是用于拟合 LOESS 回归的数据比例。

  • lo_delta 是我们使用线性插值而不是加权回归的距离比例。使用非零的lo_delta显著减少计算时间。

让我们看看使用FourierDecomposition进行分解的放大图:

图 3.16 – 使用傅里叶项的分解(放大显示一个月的情况)

图 3.16:使用傅里叶项的分解(放大显示一个月的情况)

趋势将与 STL 方法相同,因为我们这里也使用了 LOESS。季节性轮廓可能略有不同,并且对异常值具有鲁棒性,因为我们使用傅里叶项对信号进行正则化回归。另一个优势是,我们已经解耦了数据分辨率和预期季节性。现在,在子日数据上提取年度季节性不再像使用周期平均值时那样具有挑战性。

到目前为止,我们只看到了每个序列提取一个季节性的技术;通常,我们提取的是主要的季节性。那么,当我们有多个季节性模式时,应该怎么办呢?

使用 LOESS 进行多季节性分解(MSTL)

高频数据(如日数据或小时数据)的时间序列往往会表现出多种季节性模式。例如,可能会有小时季节性模式、周季节性模式和年季节性模式。但如果我们只提取主要模式,而将其余的部分留给残差,我们就没有充分做到分解。Kasun Bandara 等人提出了一种扩展的 STL 分解方法,用于多重季节性,称为 MSTL,并且在 R 生态系统中有相应的实现。Python 中有一个非常相似的实现,可以在 src.decomposition.seasonal.py 中找到。除了 MSTL,实施中还使用傅里叶项提取多重季节性。

参考检查

Kasun Bandara 等人的研究论文在 参考文献 部分被引用为参考文献 1

让我们来看一个如何使用它的例子:

stl = MultiSeasonalDecomposition(seasonal_model="fourier",seasonality_periods=["day_of_year", "day_of_week", "hour"], model = "additive", n_fourier_terms=10)
res_new = stl.fit(pd.Series(ts, index=ts_df.index)) 

这里的关键参数如下:

  • seasonality_periods 是预期季节性的列表。对于 stl,它是季节周期的列表,而对于 FourierDecomposition,它是表示 pandas 日期时间属性的字符串列表。

  • seasonality_model 采用 fourieraverages 作为参数来确定季节性分解类型。

  • model 采用 additivemultiplicative 作为参数来确定分解类型。

  • n_fourier_terms 决定了提取季节性时使用的傅里叶项数。随着该参数的增大,从数据中提取的季节性会变得更加复杂。

  • lo_frac 是用于拟合 LOESS 回归的数据比例。

  • lo_delta 是我们使用线性插值而不是加权回归的区间距离。使用非零的 lo_delta 可以显著减少计算时间。

让我们看看使用傅里叶分解时,分解结果是什么样的:

图 3.17 – 使用傅里叶项进行的多重季节性分解

图 3.17:使用傅里叶项进行的多重季节性分解

在这里,我们可以看到 day_of_week(星期几)季节性已经被提取出来。为了查看 day_of_weekhour(小时)季节性成分,我们需要稍微放大一下:

图 3.18 – 使用傅里叶项进行的多重季节性分解(放大显示一个月的数据)

图 3.18:使用傅里叶项进行的多重季节性分解(放大显示一个月的数据)

在这里,我们可以观察到 hour(小时)季节性已经被很好地提取,并且它还隔离了 day_of_week(星期几)季节性成分,该成分在周末达到峰值。day_of_week 季节性成分的 离散步骤 特性是由于数据的频率是半小时一次,对于 48 个数据点,day_of_week 会是相同的。

我们已经在下表中总结了我们所涵盖的四种技术:

实现 趋势 季节性 支持多重季节性? 支持缺失值?
季节性分解 移动平均 周期调整平均值
STL LOESS 周期调整平均值
傅里叶分解 LOESS 傅里叶项
多重季节性分解 LOESS 周期调整平均值 / 傅里叶项

表 3.1:不同的季节性分解技术

MSTL 也已在 statsmodels 中实现,并且随附的笔记本中有相应的代码。statsmodels 与本书中代码实现的关键区别在于,本书中的实现还提供了使用基于傅里叶级数分解的选项。

现在,让我们理解并分析一个时间序列数据集。

检测和处理异常值

异常值,顾名思义,是指那些与其他观测值距离异常的观测值。如果我们将数据生成过程DGP)视为生成时间序列的随机过程,那么异常值就是那些从 DGP 生成的概率最小的点。这可能有许多原因,包括测量设备故障、数据录入错误和黑天鹅事件等等。能够检测到这样的异常值并处理它们,可能有助于你的预测模型更好地理解数据。

异常值/异常检测本身就是时间序列中的一个专业领域,但在本书中,我们将限制在一些简单的异常值识别和处理技术上。原因是我们的主要目的是清理数据,以便预测模型能够更好地执行,而不是检测异常值。如果你想了解更多关于异常检测的内容,可以前往进一步阅读部分,找到一些入门资源。

现在,让我们看一下几种识别异常值的技术。

笔记本提醒

要跟随完整的异常值检测代码,请使用Chapter03文件夹中的03-Outlier_Detection.ipynb笔记本。

标准差

这是一个经验法则,几乎每个有过一段时间数据处理经验的人都听说过——如果是时间序列的均值,是标准差,那么任何超出的值都可以视为异常值。其背后的理论深深植根于统计学。如果我们假设时间序列的值遵循正态分布(这是一种具有非常理想性质的对称分布),通过概率论,我们可以推导出正态分布下 68%的区域位于均值的一个标准差范围内,约 95%的区域位于两个标准差范围内,约 99%的区域位于三个标准差范围内。因此,当我们将界限设为三个标准差(使用经验法则)时,我们的意思是,如果任何观测值属于概率分布的概率低于 1%,那么它们就是异常值。

稍微转向更实际的问题,三倍标准差的截断值并非不可改变。我们需要尝试不同的倍数值,并通过主观评估所得到的结果来确定合适的倍数。倍数越高,异常值越少。

对于高度季节性的数据,直接将规则应用于原始时间序列是行不通的。在这种情况下,我们必须使用之前讨论的任一方法来去季节化数据,然后再应用异常值检测于残差。如果不这样做,可能会错误地将季节性峰值标记为异常值,这正是我们不希望发生的情况。

这里的另一个关键假设是正态分布。然而,实际上,我们遇到的许多时间序列数据可能并不是正态分布的,因此此规则的理论保证会迅速失效。

IQR

另一种非常相似的技术是使用 IQR(四分位距)代替标准差来定义超出范围的观测值,从而标记为异常值。分位数将所有数据按顺序排列,然后将其分成相等的部分,使每部分包含相同数量的项目。四分位数也做同样的事,但特定地将数据分成四等份。IQR 是第三四分位数(即第 75 百分位或 0.75 分位数)与第一四分位数(即第 25 百分位或 0.25 分位数)之间的差值。上下界限定义如下:

  • 上界 = Q3 + n x IQR

  • 下界 = Q1 - n x IQR

这里,IQR = Q3-Q2n是 IQR 的倍数,用于确定可接受区域的宽度。

对于那些异常值出现频繁且波动剧烈的数据集,这种方法比标准差稍微更稳健。这是因为标准差和均值会受到数据集中单个点的强烈影响。如果 是之前方法中的经验法则,那么这里则是 IQR 的 1.5 倍。这也与相同的正态分布假设相关,IQR 的 1.5 倍大约等于 ~ (精确来说是 )。在应用规则之前去季节化的数据问题同样适用,这对我们接下来会看到的所有技术都适用。

隔离森林

Isolation Forest 是一种基于决策树的无监督异常检测算法。典型的异常检测算法会建模正常点,并将不符合正常的点视为异常值。然而,Isolation Forest 采用了不同的方式,直接建模异常值。它通过随机划分特征空间来创建决策树的森林。该技术假设异常值位于外部边缘,且更容易进入树的叶节点。因此,你可以在短枝上找到异常值,而正常点则由于靠得更近需要更长的枝条。任何点的“异常评分”由达到该点之前需要遍历的树的深度来决定。scikit-learn 提供了该算法的实现,位于 sklearn.ensemble.IsolationForest。除了决策树的标准参数外,关键参数是污染度。默认设置为auto,但可以设置为00.5之间的任何值。该参数指定你预计数据集中多少百分比的数据是异常的。

但是我们必须记住的一点是,IsolationForest 完全不考虑时间因素,只会突出显示异常值

极端学生化偏差(ESD)和季节性 ESD(S-ESD)

这种基于统计的技术比基础的 技术更复杂,但仍然使用相同的正态性假设。它基于另一种统计测试,叫做 Grubbs 检验,用于在正态分布的数据集中找到单个异常值。ESD 通过逐步识别和移除异常值来迭代使用 Grubbs 检验。它还根据剩余点的数量调整临界值。要更详细地了解该测试,请参见进一步阅读部分,在那里我们提供了一些关于 ESD 和 S-ESD 的资源。2017 年,Twitter Research 的 Hochenbaum 等人提出使用去季节化的广义 ESD 作为时间序列异常值检测的方法。

我们已经根据自己的使用案例调整了该算法的现有实现,并且它可以在本书的 GitHub 仓库中找到。虽然其他所有方法都需要用户通过调整几个参数来确定异常值的适当级别,但 S-ESD 只需输入预期异常值的上限,然后独立地识别异常值。例如,我们将上限设置为 800,算法在我们使用的数据中识别出了大约 400 个异常值。

参考检查:

Hochenbaum 等人的研究论文在参考文献部分作为参考文献2被引用。

让我们看看我们回顾的所有技术是如何检测异常值的:

图 3.19 – 使用不同技术检测到的异常值

图 3.19:使用不同技术检测到的异常值

现在我们已经学会了如何检测异常值,让我们来谈谈如何处理这些异常值并清理数据集。

处理异常值

我们必须回答的第一个问题是,是否应该修正我们识别出的异常值。自动识别异常值的统计检验应该通过另一个层级的人工验证。如果我们盲目地“处理”异常值,可能会剪切掉一个有价值的模式,而这个模式可能有助于我们预测时间序列。如果你只预测少量时间序列,那么查看异常值并通过分析异常值的原因来将其与现实相结合仍然是有意义的。

但当你拥有数千个时间序列时,人工无法检查所有的异常值,因此我们将不得不求助于自动化技术。一个常见做法是将异常值替换为启发式方法,如最大值、最小值和第 75 百分位数。更好的方法是将异常值视为缺失数据,并使用我们之前讨论的任何技术来填补这些异常值。

我们必须记住的一点是,异常值修正不是预测中的必要步骤,特别是在使用现代方法,如机器学习或深度学习时。我们是否进行异常值修正是需要实验并加以验证的。

做得好!这一章内容较为繁忙,涉及了许多概念和代码,所以祝贺你完成了这一章。你可以根据需要回头复习一些主题。

总结

在本章中,我们学习了时间序列的关键组成部分,并熟悉了趋势和季节性等术语。我们还复习了几种时间序列特有的可视化技术,这些技术将在探索性数据分析(EDA)过程中派上用场。然后,我们学习了可以将时间序列分解为其组成部分的技术,并看到了检测数据中异常值的技术。最后,我们学习了如何处理已识别的异常值。现在,你已经准备好开始预测时间序列了,这将在下一章开始。

参考文献

以下是本章的参考资料:

  1. Kasun Bandara、Rob J Hyndman 和 Christoph Bergmeir。(2021)。MSTL:一种用于具有多重季节性模式时间序列的季节趋势分解算法。arXiv:2107.13462 [stat.AP]。arxiv.org/abs/2107.13462

  2. Hochenbaum, J., Vallis, O., & Kejariwal, A.(2017)。通过统计学习实现云中的自动异常检测。ArXiv,abs/1704.07706。arxiv.org/abs/1704.07706

进一步阅读

要了解更多关于本章涉及的主题,请查看以下资源:

加入我们在 Discord 上的社区

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

packt.link/mts

留下评论!

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

扫描二维码或访问链接以获得您选择的免费电子书。

packt.link/NzOWQ

自动生成的带有黑色方块的二维码图片描述

第四章:设置强基线预测

在上一章中,我们介绍了一些可以用来理解时间序列数据、进行探索性数据分析EDA)等技术。但现在,让我们进入重点——时间序列预测。理解数据集、观察模式、季节性等,目的是为了让预测这个序列的工作变得更容易。像任何机器学习任务一样,我们在进一步操作之前需要首先建立一个基线

基线是一个简单的模型,它能提供合理的结果,而且不需要花费大量时间来得出这些结果。许多人认为基线是由常识推导出来的,例如平均值或某些经验法则。但作为最佳实践,基线可以是我们想要的任何复杂度,只要它能够快速且容易地实现。我们想要取得的任何进一步进展,都会基于这个基线的性能。

本章中,我们将介绍一些可以作为基线使用的经典技术,并且这些基线非常强大。有人可能认为我们将在本章讨论的预测技术不应作为基线,但我们将它们保留在这里,因为这些技术经受住了时间的考验——这有充分的理由。它们也非常成熟,并且可以轻松应用,得益于实现这些技术的开源库。在许多类型的问题或数据集中,可能很难超越我们将在本章讨论的基线技术,而在这些情况下,依赖这些基线技术并不羞耻。

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

  • 设置测试框架

  • 生成强基线预测

  • 评估时间序列的可预测性

技术要求

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

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

  • Chapter02中的02-Preprocessing_London_Smart_Meter_Dataset.ipynb预处理笔记本

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

设置测试框架

在开始进行预测和设置基准之前,我们需要设置一个测试工具。在软件测试中,测试工具是一个由代码和输入组成的集合,旨在在不同情况下测试程序。在机器学习中,测试工具是一组代码和数据,用于评估算法。设置测试工具非常重要,这样我们就可以以标准且快捷的方式评估未来的所有算法。

我们首先需要的是留出(测试)验证数据集。

创建留出(测试)和验证数据集

作为机器学习中的标准做法,我们将数据集分为两个部分,命名为验证数据测试数据,并且完全不用于训练模型。验证数据用于建模过程,用来评估模型的质量。为了选择不同的模型类、调优超参数、执行特征选择等,我们需要一个数据集。测试数据则是你所选择模型的最终测试,它告诉你模型在未见过的数据上表现如何。如果验证数据像期中考试,那么测试数据就像期末考试。

在常规的回归或分类中,我们通常会随机抽取一些记录并将其保留下来。但在处理时间序列时,我们需要尊重数据集的时间特性。因此,一个最佳实践是将数据集的最新部分作为测试数据。另一个经验法则是将验证数据集和测试数据集设置为相同大小,以便我们基于验证数据所做的关键建模决策尽可能接近测试数据。我们在第二章《获取和处理时间序列数据》中介绍的数据集——伦敦智能能源数据集,包含了 2011 年 11 月到 2014 年 2 月期间伦敦家庭的能源消耗读数。因此,我们将把 2014 年 1 月作为验证数据,2014 年 2 月作为测试数据。

让我们打开01-Setting_up_Experiment_Harness.ipynb文件(位于Chapter04文件夹中)并运行它。在笔记本中,我们必须在填补缺失值之前和之后,使用SeasonalInterpolation进行训练-测试拆分,并相应地保存它们。运行完成后,你将在预处理文件夹中创建以下文件,并将 2014 年的数据单独保存:

  • selected_blocks_train.parquet

  • selected_blocks_val.parquet

  • selected_blocks_test.parquet

  • selected_blocks_train_missing_imputed.parquet

  • selected_blocks_val_missing_imputed.parquet

  • selected_blocks_test_missing_imputed.parquet

现在我们有了一个固定的数据集,可以用来公平地评估多个算法,我们需要一种方法来评估不同的预测结果。

选择评估指标

在机器学习中,我们有一些可以用来衡量连续输出的指标,主要是平均绝对误差均方误差。但在时间序列预测领域,有大量的指标,而且没有真正达成共识来决定应该使用哪些指标。造成这种众多指标的原因之一是没有一个指标可以衡量预测的所有特性。因此,我们专门为这个话题编写了整整一章(第十九章评估预测误差——预测指标调查)。现在,我们只是回顾几个我们将用于衡量预测的指标。我们将按字面意思进行考虑:

  • 平均绝对误差 (MAE):MAE 是一个非常简单的指标。它是预测值在时间步* t f[t])与观察值在时间ty*[t])之间无符号(忽略符号)的误差的平均值。公式如下:

这里,N是时间序列的数量,L是时间序列的长度(在本例中为测试期的长度),fy分别是预测值和观察值。

  • 均方误差 (MSE):MSE 是预测值(f[t])和观察值(y[t])之间的平方误差的平均值:

  • 平均绝对缩放误差 (MASE):MASE 比 MSE 和 MAE 稍微复杂一些,但它能提供一个稍微更好的度量,克服前两者依赖规模的性质。如果我们有多个时间序列,其平均值不同,MAE 和 MSE 会对高值时间序列显示较高的误差,而低值时间序列则相对较低。MASE 通过基于朴素预测方法(这是最基础的一种预测方法,我们将在本章稍后进行回顾)中的样本内 MAE 来缩放误差,从而克服了这一点。直观地说,MASE 给出了我们的预测比朴素预测更好的程度:

  • 预测偏差 (FB):这是一个与我们看到的其他指标略有不同的度量。虽然其他指标有助于评估预测的正确性,不考虑误差的方向,预测偏差让我们了解模型的整体偏差。预测偏差有助于我们理解预测是否持续存在过度预测或不足预测的情况。

我们通过预测值总和与观察值总和之间的差异来计算预测偏差,并表示为所有实际值总和的百分比:

现在,我们的测试工具已经准备好了。我们也知道如何评估和比较在单一固定的保留数据集上,通过不同模型生成的预测结果,并使用一组预定的指标进行比较。现在,到了开始进行预测的时候了。

生成强基准预测

时间序列预测自 1920 年代初期以来就已存在,并且多年来,许多杰出的人物提出了不同的模型,既有统计模型,也有启发式模型。我将它们统称为经典统计模型计量经济学模型,尽管它们并不完全是统计/计量经济学模型。

在这一部分,我们将回顾一些模型,这些模型在我们尝试现代预测技术时可以作为强有力的基准。作为练习,我们将使用一个出色的开源时间序列预测库——NIXTLA(github.com/Nixtla)。02-Baseline_Forecasts_using_NIXTLA.ipynb 笔记本包含了本部分的代码,方便你跟随学习。

在我们开始了解预测技术之前,让我们快速了解如何使用 NIXTLA 库来生成预测。我们将从数据集中选择一个消费者,并在验证数据集上逐一尝试所有的基准技术。

我们需要做的第一件事是使用每个客户的唯一 ID(LCLid 列,来自扩展数据)选择我们想要的消费者,并将时间戳设置为 DataFrame 的索引:

ts_train = train_df.loc[train_df.LCLid=="MAC000193",['LCLid',"timestamp","energy_consumption"]]
ts_val = val_df.loc[val_df.LCLid=="MAC000193", ['LCLid',"timestamp","energy_consumption"]]
ts_test = test_df.loc[test_df.LCLid=="MAC000193", ['LCLid',"timestamp","energy_consumption"]] 

NIXTLA 具有灵活性,可以直接与 pandas 或 Polars DataFrame 一起使用。默认情况下,NIXTLA 查找三个列:

  • id_col:默认情况下,它期望一个名为 unique_id 的列。这个列唯一标识时间序列。如果你只有一个时间序列,请添加一个具有相同唯一标识符的虚拟列。

  • time_col:默认情况下,它期望一个名为 ds 的列。这是你的时间戳列。

  • target_col:默认情况下,它期望一个名为 y 的列。这个列是你希望 NIXTLA 进行预测的目标。

这非常方便,因为无需进一步操作即可从数据到建模。NIXTLA 遵循 scikit-learn 风格,使用 .fit().predict(),并且还采用了 .forecast() 方法,这是一个内存高效的方法,不会存储部分模型输出,而 scikit-learn 接口会存储拟合的模型:

sf = StatsForecast(
    models=[model],
    freq=freq,
    n_jobs=-1,
    fallback_model=Naive()
)
sf.fit(df = _ts_train,          id_col = 'LCLid',
       time_col = 'timestamp',
       target_col = 'energy_consumption',
)
baseline_test_pred_df = sf.predict(len(ts_test) ) 

NIXTLA 还提供了 .forecast() 方法,这是一个内存高效的方法,不会存储部分模型输出,而 scikit-learn 接口会存储拟合的模型:

# Efficiently fit and predict without storing memory
y_pred = sf.forecast(
    h=len(ts_test),
    df=ts_train,
    id_col = 'LCLid',    time_col = 'timestamp',    target_col = 'energy_consumption',
) 

当我们调用 .predict .forecast 时,我们必须告诉模型需要预测多久以后的数据。这被称为预测的时间范围(horizon)。在我们的案例中,我们需要预测测试期,我们可以通过获取 ts_test 数组的长度轻松实现这一点。

我们还可以使用 NIXTLA 的类轻松计算之前讨论的测试指标。为了更大的灵活性,我们可以循环遍历一个指标列表,为每个预测获取多个测量值:

# Calculate metrics
metrics = [mase, mae, mse, rmse, smape, forecast_bias]
for metric in metrics:
    metric_name = metric.__name__
    if metric_name == 'mase':
        evaluation[metric_name] = 	
metric(results[target_col].values,            results[model_name].values,
ts_train[target_col].values, seasonality=48)
    else:
        evaluation[metric_name] =
metric(results[target_col].values,
results[model_name].values) 

注意,对于 MASE,训练集也包括在内。

为了方便实验,我们将所有这些内容封装到笔记本中的一个便捷函数evaluate_performance中。该函数返回预测结果和计算的指标,格式为 DataFrame。

现在,让我们开始看看一些非常简单的预测方法。

简单预测

简单预测是最简单的预测方法。它的预测结果就是时间序列中最后一个/最新的观测值。如果时间序列中最新的观测值是 10,那么未来所有时间点的预测值都是 10。我们可以使用 NIXTLA 中的Naive类来实现这一点:

from statsforecast.models import Naive
models = Naive() 

一旦我们初始化了模型,就可以调用笔记本中的evaluate_performance函数,运行并记录预测结果和指标。

让我们可视化一下我们刚刚生成的预测结果:

图 4.1:简单预测

在这里,我们可以看到预测是一条直线,完全忽略了序列中的任何模式。这是迄今为止最简单的预测方法,因此它被称为简单预测。现在,让我们看另一种简单的方法。

移动平均预测

虽然简单预测记住了最新的过去,但它也记住了每个时间步的噪音。移动平均预测是另一种简单的方法,它试图克服简单预测方法的纯粹记忆化。它不是采用最新的观测值,而是采用最近n步的均值作为预测结果。移动平均并不是 NIXTLA 中现有的模型之一,但我们在本书的 GitHub 仓库中的Chapter04文件夹实现了一个与 NIXTLA 兼容的模型:

from src.forecasting.baselines import NaiveMovingAverage
#Taking a moving average over 48 timesteps, i.e, one day
naive_model = NaiveMovingAverage(window=48) 

让我们看看我们生成的预测结果:

图 4.2 – 移动平均预测

图 4.2:移动平均预测

这个预测几乎是一条直线。现在,让我们看另一种简单的方法,但它同时考虑了季节性因素。

季节性简单预测

季节性简单预测是在简单的简单预测方法基础上的一种变体。在简单预测方法中,我们采用了最后一个观测值(Y[t-1]),而在季节性简单预测中,我们采用了 Y[t-k]的观测值。所以,我们对于每个预测回顾了k步。这使得算法能够模拟上一季的季节性周期。例如,如果我们设置k=48*7,我们将能够模拟最新的季节性周周期。

该方法已在 NIXTLA 中实现,我们可以像这样使用它:

from statsforecast.models import SeasonalNaive
seasonal_naive = SeasonalNaive(season_length=48*7) 

让我们看看这个预测结果是什么样子的:

图 4.3:季节性简单预测

在这里,我们可以看到预测正在尝试模拟季节性模式。然而,由于它盲目地遵循了上一个季节性周期,它并不非常准确。

现在我们已经了解了几种简单方法,让我们看看一些统计模型。

指数平滑

指数平滑是生成预测最流行的方法之一。自 20 世纪 50 年代末以来,它一直存在,并证明了其可靠性,经受住了时间的考验。ETS 有几种不同的变体——单一指数平滑双重指数平滑霍尔特-温特季节性平滑等等。但它们都有一个关键的思想,这些思想以不同的方式被使用。在朴素方法中,我们只是使用了最新的观察数据,这就像是说只有最新的历史数据点才重要,之前的数据点不重要。另一方面,移动平均法认为最后 n 个观察值同样重要,并取它们的平均值。

ETS 结合了这两种思路,并表示所有历史数据都很重要,但最近的历史数据更为重要。因此,预测是通过加权平均值生成的,其中权重随着我们向历史的深入而指数递减:

在这里,是平滑参数,决定了权重衰减的速度,y[t]是时间步长t时的实际值,f[t]是时间步长t时的预测值。

简单指数平滑SES)是在历史数据上直接应用此平滑程序。这更适用于没有趋势或季节性的时间序列,且预测将是平坦的。预测是使用以下公式生成的:

双指数平滑DES)将平滑思想扩展到趋势建模。它有两个平滑方程——一个用于水平,另一个用于趋势。一旦你得到了水平和趋势的估计值,就可以将它们结合起来。这个预测不一定是平坦的,因为估计的趋势被用来将其外推到未来。预测是根据以下公式生成的:

首先,我们使用水平方程结合可用的观察数据来估计水平(l[t])。然后,我们使用趋势方程来估计趋势。最后,为了得到预测,我们将l[t]和b[t]结合在一起,使用预测方程

研究人员发现,经验证据表明,这种常数外推方法可能会导致长期预测中的过度预测。这是因为,在现实世界中,时间序列数据不会永远以恒定的速度增长。受到这一点的启发,已引入了一种附加方法,通过一个系数来减缓趋势,从而在时没有衰减,它与 DES 完全相同。

三重指数平滑Holt-WintersHW)通过包括另一个平滑项来建模季节性,进一步发展了这一方法。该方法有三个平滑参数( ),并使用季节性周期(m)作为输入参数。你还可以选择加法季节性或乘法季节性。加法模型的预测方程如下:

这些公式的使用与双指数平滑类似。不同的是,我们不仅估计水平和趋势,还会分别估计水平、趋势和季节性。

ETS 方法家族不仅限于我们刚刚讨论的三种方法。思考这些不同模型的一种方式是通过这些模型的趋势和季节性组件来进行分析。趋势可以是无趋势、加法趋势或加法衰减趋势。季节性可以是无季节性、加法季节性或乘法季节性。这些参数的每种组合在该家族中都是一种不同的技术,如下表所示:

趋势组件 季节性组件
N(无)
N(无) 简单指数平滑
A(加法) 双指数平滑
Ad(加法衰减) 衰减双指数平滑

表 4.1:指数平滑家族

NIXTLA 拥有完整的 ETS 方法家族。

让我们看看如何在 NIXTLA 中初始化 ETS 模型:

from statsforecast.models import (SimpleExponentialSmoothing, Holt, HoltWinters, AutoETS)
exp_smooth = HoltWinters(error_type = 'A', season_length = 48)] 

在这里,error_type = 'A' 表示加法误差。用户可以选择加法误差或乘法误差,后者可以通过 error_type = 'M' 来调用。NIXTLA 模型提供了使用 AutoETS() 的选项。该模型会自动选择最合适的指数平滑模型:简单指数平滑、双指数平滑(Holt 方法)或三重指数平滑(Holt-Winters 方法)。它还会为每个单独的时间序列选择最佳的参数和误差类型。有关如何使用 AutoETS() 的示例,请参考 GitHub 笔记本。

让我们来看一下使用 ETS 进行预测的结果,在图 4.4中:

图 4.4:指数平滑预测

该预测已捕捉到季节性,但未能捕捉到峰值。但我们已经可以看到 MAE 的改善。

现在,让我们来看看目前最受欢迎的预测方法之一。

自回归积分滑动平均(ARIMA)

ARIMA 模型是另一类方法,像 ETS 一样,它经受住了时间的考验,是最受欢迎的经典预测方法之一。ETS 方法家族以趋势和季节性为基础建模,而 ARIMA 则依赖于 自相关y[t] 与 y[t][-1]、y[t][-2] 等的相关性)。

家族中最简单的模型是ARp)模型,它使用线性回归p个过去的时间步长,或者换句话说,就是p个滞后期。数学上可以写作:

其中,c是截距,且 是时间步长 t 时的噪声或误差。

家族中的下一个模型是MAq)模型,在这种模型中,我们使用过去的* q *个误差(假设为纯白噪声)而不是过去观察到的值来进行预测:

这里, 是白噪声,c是截距。

这种模型通常不会单独使用,而是与ARp)模型结合使用,因此接下来我们要讨论的就是ARMApq)模型。ARMA(自回归滑动平均)模型定义为 y[t] = ARp)+ MAq)。

在所有的 ARIMA 模型中,有一个基本假设——时间序列是平稳的(我们在第一章《时间序列简介》中讨论过平稳性,并将在第六章《时间序列预测特征工程》中进一步阐述)。有许多方法可以使序列变得平稳,而对连续值进行差分就是其中一种技术。这被称为差分。有时,我们只需要进行一次差分,而其他时候,则必须进行多次差分,直到时间序列变为平稳。我们执行差分操作的次数被称为差分阶数。ARIMA 中的 I,作为谜题的最后一部分,代表集成。它定义了在时间序列变为平稳之前需要做的差分阶数,记作d

所以,完整的ARIMApdq)模型表示我们进行d阶差分后,考虑自回归方式的最后p项,再包括最后q个滑动平均项来做出预测。

我们迄今讨论的 ARIMA 模型仅适用于非季节性时间序列。然而,使用我们讨论过的相同概念,只不过应用于季节性周期,我们就得到了季节性 ARIMApdq会略微调整,以便它们适应季节周期 m。为了与普通的pdq区分开来,我们将季节性的值称为PDQ。例如,如果p表示取最后p个滞后期,P则表示取最后P个季节性滞后期。如果p[1]是y[t][-1],那么P[1]将是y[t][-m]。类似地,D表示季节性差分的阶数。

选择合适的 pdq 以及 PDQ 值并不是很直观,我们需要借助统计测试来确定它们。然而,当你需要预测多个时间序列时,这种方法就显得不太实际了。一种自动化的方式是通过不同参数的迭代来找到最佳的 pdq,以及 PDQ 值,这种方法被称为 AutoARIMA。在 Python 中,NIXTLA 实现了这个方法,即 AutoARIMA()。NIXTLA 还提供了普通的 ARIMA 实现,虽然它速度更快,但需要手动输入 pdq 值。

实际考虑事项

尽管 ARIMA 和 AutoARIMA 在许多情况下可以提供表现良好的模型,但当你面对较长的季节性周期和长时间序列时,它们的计算速度可能会非常慢。在我们的案例中,历史数据接近 27K 个观测值,ARIMA 变得非常缓慢,并且占用大量内存。即使是对数据进行子集化处理,单次 AutoARIMA 拟合也需要大约 60 分钟。放弃季节性参数可以显著降低运行时间,但对于像能源消耗这样的季节性时间序列来说,这样做是没有意义的。AutoARIMA 包括了许多此类拟合过程来识别最佳参数,因此对于长时间序列数据集,它变得不切实际。Python 生态系统中的几乎所有实现都存在这个缺陷。NIXTLA 声称拥有比原始 R 方法更快、更准确的 AutoARIMA 版本。

让我们看看如何使用 NIXTLA 应用 ARIMAAutoARIMA

from statsforecast.models import (ARIMA, AutoARIMA)
#ARIMA model by specifying parameters
arima_model = ARIMA(order = (2,1,1), seasonal_order = (1,1,1), season_length = 48)
#AutoARIMA model by specifying max limits for parameters and letting the algorithm find the best ones
auto_arima_model = AutoARIMA( max_p = 2, max_d=1, max_q = 2, max_P=2, max_D = 1, max_Q = 2, stepwise = True, season_length=48) 

要查看 AutoARIMA 所有参数的完整列表,请访问 NIXTLA 文档 nixtlaverse.nixtla.io/statsforecast/docs/models/autoarima.html

让我们看看我们实验的家庭在 ETS 和 ARIMA 预测下的结果:

图 4.5:ETS 和 ARIMA 预测

使用 NIXTLA,ETS 和 ARIMA 都很好地捕捉了季节性和峰值。由此产生的 MAE 分数也非常相似,分别为 0.191 和 0.203。现在,让我们看一下另一种方法——Theta 预测。

Theta 预测

Theta 预测 是 2002 年 M3 预测竞赛中表现最好的提交方法。该方法依赖于一个参数,,根据所选值的不同,它会放大或平滑时间序列的局部曲率。使用 ,我们可以平滑或放大原始时间序列。这些平滑后的线条被称为 Theta 线。V. Assimakopoulos 和 K. Nikolopoulos 提出了这种方法,作为一种分解方法来进行预测。尽管理论上可以使用任意数量的 Theta 线,最初提出的方法使用了两条 Theta 线,,并取这两条 Theta 线的预测平均值作为最终预测值。

M 竞赛是由领先的预测研究人员 Spyros Makridakis 组织的预测竞赛。它们通常会策划一个时间序列数据集,设定用来评估预测结果的指标,并向全球的研究人员开放,目的是获得最好的预测结果。这些竞赛被认为是世界上最大和最受欢迎的时间序列预测竞赛。截至本文写作时,已经完成了六次此类竞赛。要了解最新竞赛的更多信息,请访问这个网站:mofc.unic.ac.cy/the-m6-competition/

2002 年,Rob Hyndman 等人简化了 Theta 方法,并展示了我们可以使用带有漂移项的 ETS 来得到与原始 Theta 方法等效的结果,这也是今天大多数 Theta 方法实现所采用的方式。Theta 预测的主要步骤(在 NIXTLA 中实现)如下:

  1. 去季节化:对时间序列应用经典的乘法分解,以去除季节性成分(如果存在)。这将分析集中在潜在的趋势和周期性成分上。去季节化通过statsmodels.tsa.seasonal.seasonal_decompose来完成。此步骤创建了一个新的去季节化时间序列。

  2. Theta 系数应用:使用系数 将去季节化的时间序列分解为两条“Theta”线。这些系数修改时间序列的第二差分,以减弱 () 或强调 () 局部波动。

  3. Theta 线的外推:将每条 Theta 线视为一个独立的序列,并将其预测到未来。这是通过对 Theta 线应用线性回归(其中 )得到一条直线,对 Theta 线应用简单指数平滑(其中 )完成的。

  4. 重组:将两个 Theta 线的预测结果结合起来。原始方法对这两条线采用相等的权重,有效地整合了长期趋势和短期波动。

  5. 重新季节化:如果数据在开始时已经去季节化,则进行重新季节化。

NIXTLA 有许多不同的 Theta 方法变体。有关 NIXTLA 实现的详细信息,请访问:nixtlaverse.nixtla.io/statsforecast/docs/models/autotheta.html

让我们看看如何在实际中使用它:

theta_model = Theta(season_length =48, decomposition_type = 'additive' ) 

这里的关键参数如下:season_lengthdecomposition_type。这些参数用于初步季节分解。如果留空,系统会自动测试季节性并使用乘法分解自动去除季节性。若我们知道这些参数的值,建议使用我们的领域知识进行设置。分解类型可以是乘法(默认)或加法。

让我们使用 Theta 预测法可视化我们刚刚生成的预测:

图 4.6:Theta 预测

参考检查

V. Assimakopoulos 和 K. Nikolopoulos 提出 Theta 方法的研究论文在参考文献部分被引用为参考文献1,而 Rob Hyndman 的后续简化方法则被引用为参考文献2

季节性模式已被捕捉,但未能准确达到峰值。让我们看看另一种非常强大的方法——TBATS。

TBATS

有时,时间序列有多个季节性模式或非整数季节周期,通常称为复杂季节性。例如,一个小时预测可能具有每日季节性(与一天中的时间有关)、每周季节性(与一周中的某天有关)以及每年季节性(与年份中的某一天有关)。此外,大多数时间序列模型是为较小的整数季节周期设计的,如每月(12)或每季度(4)数据,但年度季节性可能会带来问题,因为一年有 364.25 天。TBATS 旨在应对这些为许多预测模型带来问题的挑战。然而,任何自动化方法都有可能出现预测不准确的情况。

TBATS代表:

  • Trigonometric 季节性

  • Box-Cox 变换

  • ARMA 误差

  • Trend

  • Seasonal components

该模型首次由 Rob J. Hyndman、Alysha M. De Livera 和 Ralph D. Snyder 在 2011 年提出。还有另一种 TBATS 的变体,称为 BATS,去除了三角季节性成分。TBATS 属于状态空间模型家族。在状态空间预测模型中,观测到的时间序列被假定为底层状态变量与一个将状态变量与观测数据联系起来的测量方程的组合。状态变量捕捉了数据中的底层模式、趋势和关系。

BATS 有参数 ,表示 Box-Cox 参数、衰减参数、ARMA 参数(pq)和季节周期(m[1],m[2],…,m[t])。由于其灵活性,BATS 模型可以被视为一个模型家族,涵盖了我们之前看到的许多其他模型。例如:

  • BATS(1, 1, 0, 0, m[1]) = Holt-Winters 加性季节性

  • BATS(1, 1, 0, 0, m[2]) = Holt-Winters 加性双重季节性

BATS 具有多季节性的灵活性;然而,它仅限于基于整数的季节周期,并且在有多个季节性时,可能会有大量状态,导致模型复杂度增加。这正是 TBATS 要解决的问题。

参考,TBATS 参数空间为:

TBATS 的主要优点如下:

  • 适用于单一、复杂和非整数季节性(三角季节性)

  • 处理真实世界时间序列中常见的非线性模式(Box-Cox 变换)

  • 处理残差中的自相关(自回归移动平均误差)

为了更好地理解 TBATS 的内部工作原理,让我们分解每个步骤。

使用 TBATS 时,操作执行的顺序(与缩写中的顺序不同)是:

  1. Box-Cox 变换

  2. 指数平滑趋势

  3. 使用傅里叶级数进行季节性分解(三角季节性)

  4. 自回归移动平均 (ARMA)

  5. 通过基于似然的方法进行参数估计

Box-Cox 变换

Box-Cox 是幂变换家族中的一种变换。

在时间序列中,使数据平稳是预测前的重要步骤(如第一章中讨论)。平稳性确保我们的数据不会随时间发生统计变化,从而更准确地类似于概率分布。可以应用几种可能的变换。关于各种目标变换的更多细节,包括 Box-Cox,可在第七章中找到。

作为预览,这是 Box-Cox 变换的一个输出示例。经过变换后,我们的数据更接近正态分布。Box-Cox 变换只能用于正数数据,但在实践中,这通常是可以的。

图 4.7 显示了时间序列在 Box-Cox 变换前后的示例。

图 4.7:Box-Cox 变换

指数平滑趋势

使用 局部估计散点图平滑 (LOESS),从时间序列中提取平滑趋势:

LOESS 通过对数据点应用局部加权的低阶多项式回归,在数据点之间创建一条平滑的流线。这种技术在捕捉局部趋势变化方面非常有效,不假设数据的全局形式,这使得它对于具有变化趋势或季节性变化的数据特别有用。这就是我们在第三章中用来分解时间序列趋势的 LOESS。

使用傅里叶级数进行季节性分解(三角季节性)

然后使用傅里叶项(在第三章中讨论)对剩余的残差进行建模,以分解季节性成分。

使用傅里叶方法建模季节性的主要优点是能够建模多个季节性,以及非整数季节性,例如使用日数据的年季节性,因为一年有 364.25 天。大多数其他分解方法无法处理非整数周期,只能将其四舍五入为 365,这可能无法识别真实的季节性。以下是使用傅里叶方法分解的时间序列示例。此示例中的观察时间序列是按小时数据。因此,我们的季节性周期为:

每日 = 24

每周 = 24 *** 7 = 168

在这里,你可以清楚地看到定义的季节性模式、趋势和剩余残差。图 4.8展示了趋势和季节性的分解,之后残差通过 ARMA 过程进行建模。

图 4.8:分解后的时间序列

ARMA

ARMA 前面已经讨论过,作为 ARIMA 家族的一个子集:

TBATS 中的 ARMA 模型用于建模剩余的残差,以捕捉滞后变量的自相关性。自回归AR)组件捕捉观察值与若干滞后观察值之间的相关性。这涉及到序列的动量或延续性。移动平均MA)组件将误差项建模为先前时间段的误差的线性组合,捕捉 AR 部分无法单独解释的信息。

参数优化

为了选择最优的参数空间,TBATS 会拟合多个模型并自动选择最佳参数。TBATS 内部拟合的一些模型包括:

  • 有与无 Box-Cox 变换

  • 有趋势与无趋势

  • 有趋势与无趋势衰减

  • 季节性与非季节性模型

  • ARMA(pq)参数

最终模型是通过选择使赤池信息准则AIC)最小化的参数组合来确定的,AutoARIMA 用于确定 ARMA 参数。

与所有预测方法一样,不同的模型有其优点和权衡。虽然 TBATS 在许多其他模型的不足之处提供了一些改进,但其权衡是需要构建多个模型,这会导致更长的计算时间。如果你需要对多个时间序列进行建模,这可能会成为一个问题。此外,TBATS 不允许包含外生变量。

实践者注意

TBATS 无法处理外生回归,因为它与 ETS 模型相关,正如 Hyndman 本人所说,他建议不太可能包括协变量(Hyndman, 2014; 参考文献 7)。如果需要使用外部回归变量,应使用其他方法,如 ARIMAX 或 SARIMAX。如果时间序列具有复杂的季节性,可以将傅里叶特征作为协变量添加到 ARIMAX 或 SARIMAX 模型中,以帮助捕捉季节性模式。

这在NIXLA中实现,我们可以使用此处展示的实现方法:

TBATS_model = TBATS(seasonal_periods  = 48, use_trend=True, use_damped_trend=True) 

在 NIXTLA 中,你还可以使用 AutoTBATS 让系统优化如何处理各种参数。

让我们看看 TBATS 预测的效果:

图 4.9:TBATS 预测

再次,季节性模式已经被复制,并且捕捉到了大部分预测中的峰值。现在让我们看看另一种非常适合高度季节性时间序列的方法(即使它像我们这样有多重季节性)。

多重季节性-趋势分解使用 LOESS(MSTL)

记得我们在第三章做的时间序列分解吗?如果我们能使用相同的技术来进行预测呢?这正是 MSTL 的作用。让我们再次看看时间序列的组成部分:

  • 趋势

  • 周期性

  • 季节性

  • 不规则

趋势和周期成分可以使用 LOESS 回归提取。如果我们在趋势值上拟合一个简单模型,我们可以用它进行未来的外推。而季节性成分则可以轻松外推,因为它应该是一个重复的模式。将这些结合起来,我们得到了一个表现相当不错的预测模型。

NIXTLA 中的 MSTL 方法应用 LOESS 技术将时间序列分解成其各种季节性成分。在此分解之后,它使用专门的非季节性模型来预测趋势,并使用季节性朴素模型来预测每个季节性成分。这种方法允许对具有复杂季节性模式的时间序列进行详细分析和预测:

MSTL_model = MSTL(season_length  = 48) 

让我们看看 MSTL 预测的效果:

图 4.10:MSTL 预测

让我们也来看一下我们为每个预测选择的不同指标在家庭实验中的表现(来自笔记本):

A screenshot of a computer  Description automatically generated

图 4.11:所有基线算法的汇总

在我们尝试的所有基线算法中,AutoETS 在 MAE 和 MSE 上表现最好。ARIMA 是第二好的模型,随后是 TBATS。然而,如果你看时间消耗这一列,TBATS 脱颖而出,仅需 7.4 秒,而 ARIMA 则需要 19 秒。由于它们的表现相似,我们将选择 TBATS 而非 ARIMA 作为基线,并将 AutoETS 作为我们的基线,运行它们在我们选择的所有 399 个家庭(包括验证集和测试集)上(此代码可在02-Baseline_Forecasts_using_NIXTLA.ipynb笔记本中找到)。

评估基线预测

由于我们已经有了从 ETS 和 TBATS 生成的基线预测,我们也应该评估这些预测。以下是这两种方法对所有选定家庭的汇总指标:

A screenshot of a graph

图 4.12:所有选定家庭(包括验证集和测试集)的汇总指标

看起来 AutoETS 在所有三个指标上表现都要好得多。我们也在家庭层面计算了这些指标。让我们来看看这些指标在所有选定家庭的验证数据集中的分布:

图 4.13:验证数据集中的 MASE 分布和基准预测的预测偏差

ETS 的 MASE 直方图似乎比 TBATS 的分布要窄。ETS 的中位数 MASE 也低于 TBATS。我们在预测偏差上也看到了类似的模式,ETS 的预测偏差集中在零附近,且分布更小。

回到第一章介绍时间序列,我们看到了为什么并非每个时间序列都是同样可预测的,并且探讨了三个有助于思考这个问题的因素——理解数据生成过程DGP)、数据量以及模式的充分重复。在大多数情况下,前两者相对容易评估,但第三个因素需要一些分析。尽管基准方法的表现能给我们一些关于时间序列可预测性的想法,但它们仍然是依赖于模型的。因此,与其衡量时间序列的可预测性,不如衡量所选模型能够如何逼近时间序列。这时,依赖于时间序列统计特性的几种更基本的技术就显得尤为重要。

评估时间序列的可预测性

尽管有许多统计量可以用来评估时间序列的可预测性,但我们将只看一些更易于理解且在处理大规模时间序列数据集时更为实用的统计量。相关的笔记本(02-Forecastability.ipynb)包含了跟随的代码。

变异系数

变异系数CoV)基于这样一个事实:你在时间序列中发现的变异性越大,预测它就越困难。那么,我们如何衡量一个随机变量的变异性呢?标准差

在许多实际的时间序列中,我们看到的变异性是依赖于时间序列的规模的。假设有两个零售产品,ABA的月均销售量是 15,而B是 50。如果我们看一些这样的实际例子,我们会发现,如果AB的标准差相同,那么具有较高均值的BA更容易预测。为了适应这种现象,并确保我们将数据集中的所有时间序列带到一个共同的尺度上,我们可以使用 CoV:

这里,是标准差,是时间序列的均值,n

CoV 是数据点围绕均值的相对离散度,它比单纯看标准差要好得多。

CoV 值越大,时间序列的可预测性越差。虽然没有硬性标准,但 0.49 被认为是一个经验值,用于将相对容易预测的时间序列与那些难以预测的区分开来。根据数据集的整体难度,我们可以调整这个临界值。我发现有用的一种方法是绘制数据集中 CoV 值的直方图,并根据此推导出切分点。

尽管 CoV 在行业中广泛使用,但它存在一些关键问题:

  • 它没有考虑季节性。正弦或余弦波会比水平线有更高的 CoV,但我们知道两者同样是可预测的。

  • 它没有考虑趋势。线性趋势会使得一系列数据具有更高的变异系数(CoV),但我们知道它同样是可预测的,就像一条水平线。

  • 它没有处理时间序列中的负值。如果存在负值,会使均值减小,从而使 CoV 膨胀。

为了克服这些缺点,我们提出了另一种衍生的度量方式。

残差变异性

残差变异性RV)的思想是尽量测量与我们试图通过 CoV 捕捉的相同类型的变异性,但没有其缺点。我曾在思考如何避免使用 CoV 时遇到的问题,通常是季节性问题,并尝试将 CoV 应用于季节性分解后的残差。那时我意识到,残差会有一些负值,且 CoV 表现不好。斯特凡·德·科克(Stefan de Kok),一位需求预测和概率预测领域的思想领袖,建议使用原始实际值的均值,这种方法有效。

计算 RV 时,必须执行以下步骤:

  1. 执行季节性分解。

  2. 计算残差或不规则成分的标准差。

  3. 将标准差除以原始观测值的均值(分解前)。

从数学角度来看,可以表示为:

其中,是分解后残差的标准差,是原始观测值的均值。

这里的关键假设是季节性和趋势是可以预测的成分。因此,我们对时间序列可预测性的评估应该只看残差的变异性。然而,我们不能直接对残差使用 CoV,因为残差可能有正负值,因此残差的均值失去了序列水平的解释,并趋向于零。当残差趋向零时,由于均值为分母,CoV 度量会趋于无穷大。因此,我们使用原始序列的均值作为缩放因子。

让我们看看如何为数据集中的所有时间序列计算 RV(它们是紧凑形式的):

block_df["rv"] = block_df.progress_apply(lambda x: calc_norm_sd(x['residuals'],x['energy_consumption']), axis=1) 

在本节中,我们研究了基于时间序列标准差的两种度量方法。现在,让我们来看一下如何评估时间序列的可预测性。

基于熵的度量

是科学中一个普遍使用的术语。我们看到它出现在物理学、量子力学、社会科学和信息理论中。在所有这些领域,它都用来表示系统中混乱或不可预测性的度量。我们现在最感兴趣的熵是来自信息理论的熵。信息理论涉及数字信息的量化、存储和传播。

克劳德·E·香农在他的开创性论文《通信的数学理论》中提出了通信的定性和定量模型,作为一种统计过程。尽管该论文介绍了许多思想,但对我们来说,相关的概念有信息熵和比特的概念——比特是信息的基本度量单位。

参考文献检查

克劳德·E·香农的《通信的数学理论》参考文献部分被引用为参考文献3

这个理论本身涵盖的内容相当多,但为了总结关键信息,可以参考以下简短的词汇表:

  • 信息不过是一串符号,这些符号可以通过一个叫做通道的媒介从接收者传送到发送者。例如,当我们给某人发短信时,符号序列就是我们使用的语言中的字母/单词;而通道就是电子媒介。

  • 可以被认为是给定某些符号分布的情况下,一串符号中不确定性惊讶的量。

  • 如前所述,比特是信息的单位,是一个二进制数字。它可以是 0 或 1。

现在,如果我们要传输一个比特的信息,它将把接收者的不确定性减少二倍。为了更好地理解这一点,我们可以考虑一次掷硬币的情境。我们把硬币扔到空中,在它旋转的过程中,我们无法知道它是正面还是反面。但我们知道它最终会是这两者之一。当硬币落地并最终静止时,我们发现它是正面。我们可以用一个比特的信息来表示硬币是正面还是反面(0 表示正面,1 表示反面)。因此,当硬币落下时传递给我们的信息,将可能的结果从两个减少到一个(正面)。这种信息传递只需要一个比特。

在信息理论中,离散随机变量的熵是该变量可能结果中固有的信息惊讶不确定性的平均水平。用更专业的术语来说,它是最佳编码方案所需的比特数,表示随机变量中信息的期望值。

相关阅读

如果你想直观理解熵、交叉熵、Kullback-Leibler 散度等内容,可以前往进一步阅读部分。那里有一些博客链接(其中一个是我自己的博客),我们尝试阐明这些度量背后的直觉。

熵的正式定义如下:

在这里,X是一个离散随机变量,可能的结果是x[1]、x[2]、……、x[n]。每个结果都有一个发生的概率,分别用P(x[1])、P(x[2])、……、P(x[n])表示。

为了建立一些直觉,我们可以认为,概率分布越分散,分布中的混乱就越多,从而熵也越大。让我们通过一些代码来快速验证这一点:

# Creating an array with a well balanced probability distribution
flat = np.array([0.1,0.2, 0.3,0.2, 0.2])
# Calculating Entropy
print((-np.log2(flat)* flat).sum()) 
>> 2.2464393446710154 
# Creating an array with a peak in probability
sharp = np.array([0.1,0.6, 0.1,0.1, 0.1])
# Calculating Entropy
print((-np.log2(sharp)* sharp).sum()) 
>> 1.7709505944546688 

在这里,我们可以看到,质量分布较为分散的概率分布具有更高的熵。

在时间序列的背景下,n是时间序列观察值的总数,P(x[i])是时间序列字母表中每个符号的概率。一个尖锐的分布意味着时间序列的值集中在一个小范围内,因此应该更容易预测。另一方面,广泛或平坦的分布意味着时间序列的值可以在更广泛的范围内均等出现,因此更难预测。

如果我们有两个时间序列,一个包含抛硬币的结果,另一个包含掷骰子的结果,掷骰子的结果会有一个介于 1 到 6 之间的输出,而硬币的结果则只能是 0 或 1。硬币投掷的时间序列熵较低,比掷骰子的时间序列更容易预测。

然而,由于时间序列通常是连续的,而熵要求离散的随机变量,我们可以采用一些策略将连续时间序列转换为离散时间序列。许多策略,如量化或分箱,可以应用,从而引入各种复杂性度量。让我们回顾一种既有用又实际的度量。

谱熵

为了计算时间序列的熵,我们需要将时间序列离散化。一种方法是使用快速傅里叶变换FFT)和功率谱密度PSD)。这种连续时间序列的离散化被用于计算谱熵。

我们之前在本章中学习了傅里叶变换,并用它来生成基准预测。但通过使用 FFT,我们还可以估计一个称为功率谱密度的量。这个问题的答案是,信号在某个特定频率下有多少成分? 从时间序列中估计功率谱密度有许多方法,但其中最简单的方法之一是使用Welch 方法,这是一种基于离散傅里叶变换的非参数方法。这个方法也可以通过scipy中的periodogram(x)函数方便地实现。

返回的PSD长度等于估计的频率数,但这些是密度而非定义良好的概率。因此,我们需要将PSD归一化到 0 和 1 之间:

这里,F是返回的功率谱密度中所包含的频率数。

现在我们已经得到了概率值,可以将其代入熵公式,从而得到谱熵:

当我们介绍基于熵的度量时,我们看到一个分布的概率质量越分散,熵值越高。在这种情况下,谱密度分布的频率越多,谱熵就越高。因此,较高的谱熵意味着时间序列更复杂,因此更难预测。

由于 FFT 假设序列是平稳的,建议在使用谱熵作为度量前先将时间序列转化为平稳序列。我们甚至可以将此度量应用于去趋势和去季节化的时间序列,这时我们可以称之为残差谱熵。本书的 GitHub 仓库中包含了src.forecastability.entropy.spectral_entropy下的谱熵实现。该实现还包含一个参数transform_stationary,如果设置为True,则在应用谱熵前会对序列进行去趋势处理。让我们来看一下如何为我们的数据集计算谱熵:

from src.forecastability.entropy import spectral_entropy
block_df["spectral_entropy"] = block_df.energy_consumption.progress_apply(lambda x: spectral_entropy(x, transform_stationary=True))
block_df["residual_spectral_entropy"] = block_df.residuals.progress_apply(spectral_entropy) 

还有其他基于熵的度量方法,比如近似熵和样本熵,但我们在本书中不会涉及它们。它们的计算复杂度较高,而且通常不适用于包含少于 200 个值的时间序列。如果你对这些度量方法感兴趣,可以查看进一步阅读部分。

另一种稍有不同的度量是 Kaboudan 度量。

Kaboudan 度量

在 1999 年,Kaboudan 定义了一种时间序列可预测性的度量,称之为-度量。其背后的理念非常简单。如果我们对时间序列进行块随机排列,本质上是破坏了时间序列中的信息。块随机排列是将时间序列分割成多个块,然后打乱这些块的顺序。因此,如果我们计算基于时间序列训练的预测的平方误差和SSE),并将其与基于打乱后时间序列训练的预测的 SSE 进行对比,我们可以推测时间序列的可预测性。计算公式如下:

这里,SSE[Y]是从原始时间序列生成的预测的 SSE,而SSE[S]是从块随机排列后的时间序列生成的预测的 SSE。

如果时间序列包含某些可预测的信号,SSE[Y]会低于SSE[S],而会接近 1。这是因为某些信息或模式因块洗牌而被破坏。另一方面,如果一个序列只是白噪声(按定义是不可预测的),那么SSE[Y]和SSE[S]之间几乎没有差异,会接近零。

2002 年,段某在他的论文中研究了这个度量并提出了一些修改建议。他所发现的一个问题,特别是在长期时间序列中, 是值集中在接近 1 的狭窄区间内,并且他对公式进行了轻微修改。我们称之为修改后的 Kaboudan 度量。下方的度量也被限制为零。有时,度量值可能会低于零,因为SSE[S]低于SSE[Y],这意味着该序列是不可预测的,并且由于纯粹的偶然,块洗牌使得 SSE 值较低:

参考检查

提出的 Kaboudan 度量的研究论文在参考文献部分被引用为参考文献4。段某建议的后续修改被引用为参考文献5

这个修改版以及原版都已经在本书的 GitHub 仓库中实现。

对于生成预测的预测模型没有限制,这使得它具有更多的灵活性。理想情况下,我们可以选择一种经典的统计方法,这种方法足够快速,能够应用于整个数据集。但这也使得 Kaboudan 度量依赖于模型,模型的局限性也在度量中体现。该度量衡量了一个序列预测的难度以及模型预测该序列的难度。

同样,这两个度量已经在本书的 GitHub 仓库中实现。让我们看看如何使用它们:

from src.forecastability.kaboudan import kaboudan_metric, modified_kaboudan_metric
model = Theta(theta=3, seasonality_period=48*7, season_mode=SeasonalityMode.ADDITIVE)
block_df["kaboudan_metric"] = [kaboudan_metric(r[0], model=model, block_size=5, backtesting_start=0.5, n_folds=1) for r in tqdm(zip(*block_df[["energy_consumption"]].to_dict("list").values()), total=len(block_df))]
block_df["modified_kaboudan_metric"] = [modified_kaboudan_metric(r[0], model=model, block_size=5, backtesting_start=0.5, n_folds=1) for r in tqdm(zip(*block_df[["energy_consumption"]].to_dict("list").values()), total=len(block_df))] 

虽然我们可以使用更多度量来实现这个目的,但我们刚刚回顾的用于评估可预测性的度量涵盖了很多流行的应用场景,并且足以用来衡量任何时间序列数据集在预测难度方面的情况。我们可以使用这些度量将一个时间序列与另一个时间序列进行比较,或者将一个数据集中的一组相关时间序列与另一个数据集进行对比,用于基准测试。

进一步阅读

如果你想深入分析这些指标的行为,了解它们之间的相似性,以及它们在衡量可预测性方面的有效性,请前往03-Forecastability.ipynb笔记本的最后部分。我们计算了这些指标之间的排名相关性,以理解它们的相似度。我们还可以找到与最佳表现基准方法计算指标之间的排名相关性,以了解这些指标在估计时间序列的可预测性方面的效果。我强烈建议你玩转这个笔记本,理解不同指标之间的差异。挑选一些时间序列,检查不同指标如何给出略有不同的解释。

恭喜你生成了基准预测——这是我们通过本书生成的第一组预测!你可以随意进入笔记本,调整方法的参数,看看预测结果如何变化。这将帮助你培养对基准方法的直觉。如果你有兴趣了解如何改进这些基准方法,可以查看进一步阅读部分,我们提供了关于 F. Petropoulos 和 E. Spiliotis 的论文数据的智慧:如何最大化单变量时间序列预测的链接。

总结

至此,我们已经完成了第一部分了解时间序列。我们从仅仅理解时间序列是什么,到生成具有竞争力的基准预测,走过了很长的路。在此过程中,我们学会了如何处理缺失值和异常值,如何使用 pandas 操作时间序列数据。我们把这些技能应用到了一个关于能源消费的实际数据集上。我们还探讨了如何可视化和分解时间序列。在这一章中,我们设置了一个测试框架,学习了如何使用 NIXTLA 库生成基准预测,并介绍了几种可用于理解时间序列可预测性的指标。

对于你们中的一些人来说,这可能是一个复习,希望这一章能为你提供一些细节和实际考虑的价值。对于其余的读者,我们希望你们已经打下了坚实的基础,准备在本书的下一部分开始涉足现代机器学习技术。

在下一章中,我们将讨论机器学习的基础,并深入探讨时间序列预测。

参考文献

本章提供了以下参考文献:

  1. Assimakopoulos, Vassilis 和 Nikolopoulos, K. (2000). The theta model: A decomposition approach to forecasting. 国际预测学杂志。16. 521-530. www.researchgate.net/publication/223049702_The_theta_model_A_decomposition_approach_to_forecasting

  2. Rob J. Hyndman, Baki Billah. (2003). 揭示 Theta 方法的真相. 国际预测学期刊. 19. 287-290. robjhyndman.com/papers/Theta.pdf.

  3. Shannon, C.E. (1948), 通信的数学理论. 贝尔系统技术杂志, 27: 379-423. people.math.harvard.edu/~ctm/home/text/others/shannon/entropy/entropy.pdf.

  4. Kaboudan, M. (1999). 利用遗传编程应用于股票收益的时间序列可预测性度量. 预测学杂志, 18, 345-357: www.aiecon.org/conference/efmaci2004/pdf/GP_Basics_paper.pdf.

  5. Duan, M. (2002). 时间序列可预测性: citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.68.1898&rep=rep1&type=pdf.

  6. De Livera, A. M., & Hyndman, R. J. (2009). 使用指数平滑法预测具有复杂季节模式的时间序列(经济计量学与商业统计学工作论文系列 15/09)

  7. Hyndman, Rob. “Rob J Hyndman - 带有回归项的 TBATS 模型.” Rob J Hyndman, 2014 年 10 月 6 日, robjhyndman.com/hyndsight/tbats-with-regressors

进一步阅读

要了解更多本章所涉及的主题,请查看以下资源:

加入我们的 Discord 社区

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

packt.link/mts

第二部分

时间序列的机器学习

在这一部分,我们将探讨如何应用现代机器学习技术进行时间序列预测。本部分还涵盖了强大的预测组合方法以及令人兴奋的全球模型新范式。通过本部分的学习,你将能够使用现代机器学习技术为时间序列预测建立模型流水线。

本部分包括以下章节:

  • 第五章将时间序列预测视为回归问题

  • 第六章时间序列预测的特征工程

  • 第七章时间序列预测的目标转换

  • 第八章使用机器学习模型进行时间序列预测

  • 第九章集成与堆叠

  • 第十章全球预测模型

第五章:将时间序列预测作为回归问题

在本书的前一部分中,我们对时间序列有了基本的理解,并装备了分析和可视化时间序列的工具和技术,甚至生成了我们第一个基线预测。到目前为止,我们主要介绍了经典和统计技术。现在,让我们深入了解现代机器学习,并学习如何利用这个相对较新的领域来进行时间序列预测。机器学习是近年来迅速发展的一个领域,能够利用这些新技术进行时间序列预测,将在今天的世界中成为一项无价的技能。

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

  • 了解机器学习的基础

  • 将时间序列预测作为回归问题

  • 局部模型与全局模型

了解机器学习的基础

我们希望使用机器学习进行时间序列预测。但在开始之前,让我们花一些时间来了解什么是机器学习,并建立一个框架来展示它的功能(如果你已经对机器学习非常熟悉,可以跳到下一节“将时间序列预测作为回归问题”,或者继续跟我们一起复习这些概念)。1959 年,Arthur Samuel 将机器学习定义为“一种使计算机能够在没有明确编程的情况下学习的研究领域。”传统上,编程是一种我们知道一套规则/逻辑来执行某个动作,并且在给定的数据上执行该动作以获得我们想要的输出的范式。而机器学习则颠覆了这一点。

在机器学习中,我们从数据和输出开始,要求计算机告诉我们通过哪些规则可以从数据中获得期望的输出:

图 5.1 – 传统编程与机器学习

图 5.1:传统编程与机器学习

机器学习中有许多种问题设置,如监督式学习、无监督式学习、自监督学习等,但我们将专注于监督式学习,这是最常见的,也是本书内容最适用的。监督式学习指的是我们在程序、数据和输出的范式转换示例中已经提到的内容。我们使用一个包含输入和预期输出的配对样本的数据集,并要求模型学习它们之间的关系。

让我们从一个小的讨论开始,逐步构建整个示意图,它包含了监督式机器学习问题的主要关键组件:

图 5.2 – 监督式机器学习示意图,部分 1 – 理想函数

图 5.2:监督式机器学习示意图,部分 1—理想函数

正如我们已经讨论过的,我们希望机器学习从数据中学习并得出一套规则/逻辑。在数学中,与逻辑/规则最接近的类比是函数,它接受一个输入(这里是数据)并提供一个输出。从数学上看,可以写作如下:

y = g(X)

其中,X 是特征集合,g理想目标函数(在图 5.2中用1表示),它将 X 输入(在示意图中用2表示)映射到目标(理想)输出,y(在示意图中用3表示)。理想目标函数在很大程度上是一个未知函数,类似于我们在第一章《引入时间序列》中看到的数据生成过程DGP),它不在我们的控制之下。

图 5.3 – 监督式机器学习示意图,第二部分 – 学到的近似值

图 5.3:监督式机器学习示意图,第二部分—学到的近似值

但我们希望计算机能够学习这个理想目标函数。这个理想目标函数的近似值用另一个函数 h 表示(在示意图中用4表示),它接受相同的特征集 X,并输出预测的目标,(在示意图中用5表示)。h 函数的参数(或模型参数):

图 5.4 – 监督式机器学习示意图,第三部分 – 将所有内容整合

图 5.4:监督式机器学习示意图,第三部分—将所有内容整合

现在,我们如何找到这个近似 h 函数及其参数,?通过示例数据集(在示意图中用6表示)。监督式机器学习问题的前提是我们能够收集一组包含特征 X 和相应目标 y 的示例,这些目标在文献中也称为标签。计算机就是从这组示例(数据集)中学习近似函数 h 以及最优模型参数,。在之前的示意图中,唯一真正未知的实体是理想目标函数 g。因此,我们可以使用训练数据集 D 来为数据集中的每个样本预测目标。我们已经知道所有示例的理想目标。我们需要一种方法来比较理想目标和预测目标,这就是损失函数(在示意图中用7表示)发挥作用的地方。损失函数告诉我们,使用近似函数 h 时,我们离真实结果有多远。

尽管 h 可以是任何函数,但它通常是从一个著名函数类别 H 中选择的。H 是一个有限的函数集,可以拟合数据。这个函数类别就是我们口语中所说的模型。例如,h 可以从所有线性函数或所有基于树的函数中选择,等等。从 H 中选择一个 h 是通过超参数(由模型设计者指定)和模型参数(从数据中学习)来组合完成的。

现在,剩下的就是运行不同的函数,找到最好的近似函数 h,它给我们带来最低的损失。这是一个优化过程,我们称之为训练

让我们还看看一些关键概念,这些概念将在接下来的讨论中非常重要。

有监督的机器学习任务

机器学习可以用来解决各种任务,如回归分类推荐。但由于分类和回归是最常见的两类问题,我们将花一点时间回顾它们是什么。

分类任务和回归任务的区别非常简单。在机器学习框架图(图 5.2)中,我们讨论了 y,即目标。这个目标可以是一个实数值,或者是一个项的类别。例如,我们可能在预测下周的股价,或者我们只预测股价是上涨还是下跌。在第一种情况下,我们是在预测一个实数值,这叫做回归。在另一种情况下,我们预测的是两个类别中的一个(上涨下跌),这叫做分类

过拟合和欠拟合

机器学习系统面临的最大挑战是,我们训练出的模型必须在新的、未见过的数据集上表现良好。一个机器学习模型在这方面的能力被称为泛化能力。机器学习中的训练过程类似于数学优化,但有一个微妙的区别。数学优化的目标是到达提供的数据集中的全局最大值。而在机器学习中,目标是通过使用训练误差作为代理,达到较低的测试误差。一个机器学习模型在训练误差和测试误差上的表现与过拟合和欠拟合的概念密切相关。让我们通过一个例子来理解这些术语。

机器学习模型的学习过程与人类学习有很多相似之处。假设三名学生,ABC,正在为考试做准备。A 是个懒汉,前一天晚上去夜店了。B 决定死记硬背,把教科书从头到尾都背一遍。C 上课时认真听讲,理解了考试的内容。

正如预期的那样,A 没有通过考试,C 得了最高分,B 还行。

A因为没有学到足够的内容而挂科。这在机器学习模型中也会发生,当它们没有学到足够的模式时,这被称为欠拟合。其特点是训练误差和测试误差都很高。

B的成绩没有预期的高;毕竟,他们确实把整篇文章逐字逐句地记住了。但考试中的许多问题并不是直接来自教科书,B没有能够正确回答这些问题。换句话说,考试中的问题是新的和未见过的。而且,由于B记住了所有内容,但没有努力理解基础概念,B没有能够将所学知识推广到新的问题上。在机器学习中,这种情况被称为过拟合。其特点通常是训练误差和测试误差之间的差距很大。通常我们会看到非常低的训练误差和很高的测试误差。

第三个学生,C,学得正确并理解了基础概念,因此能够推广新的和未见过的问题。这也是机器学习模型的理想状态。这种状态的特点是合理较低的测试误差,以及训练误差和测试误差之间的小差距。

我们刚刚看到了机器学习中的两个最大挑战。现在,让我们也来看一些可以用来应对这些挑战的方法。

模型的容量与欠拟合或过拟合之间有着密切的关系。一个模型的容量是指它足够灵活,能够拟合各种不同函数的能力。容量较低的模型可能很难拟合训练数据,导致欠拟合。容量较高的模型可能通过过度记忆训练数据而发生过拟合。为了更好地理解这个容量的概念,我们来看一个例子。当我们从线性回归转向多项式回归时,我们正在增加模型的容量。我们不仅仅拟合直线,而是让模型也能拟合曲线。

当机器学习模型的容量与当前学习问题相匹配时,通常表现良好。

图 5.5 – 欠拟合与过拟合图 5.5:欠拟合与过拟合

图 5.5 显示了一个非常常见的例子,用于说明过拟合和欠拟合。我们通过已知函数创建一些随机点,并尝试使用这些数据样本进行学习。我们可以看到,作为最简单模型之一的线性回归,通过在这些点之间画一条直线,未能充分拟合数据。多项式回归是线性回归,但加入了一些更高阶的特征。现在,你可以将从线性回归到多项式回归(增加高阶)的转变,视为增加模型的容量。因此,当我们使用 4 次时,可以看到所学函数很好地拟合了数据,并与我们的理想函数匹配。但是,如果我们继续增加模型的容量,达到 degree = 15 时,我们会看到所学的函数仍然通过训练样本,但它已经学到了一个完全不同的函数,导致了对训练数据的过拟合。找到能够学习出具有良好泛化能力的函数的最佳容量,是机器学习中的核心挑战之一。

容量是模型的一个方面,另一个方面是正则化。即使在相同容量下,模型也可以从所有函数的假设空间中选择多个函数。通过正则化,我们试图在假设空间中对某些函数给予偏好,而非其他函数。

尽管所有这些函数都是有效的,可以选择的函数,我们会通过某种方式推动优化过程,使其最终趋向我们偏好的某种函数。尽管正则化是一个广泛的术语,用于指代我们在学习过程中施加的任何约束,以减少所学函数的复杂性,但更常见的是将其以权重衰减的形式使用。我们以线性回归为例,线性回归是通过学习与每个特征相关联的权重,将一条直线拟合到输入特征上。

线性回归模型可以用数学公式表示如下:

在这里,N 是特征的数量,c 是截距,x[i] 是第 i 个特征,w[i] 是与第 i 个特征相关的权重。我们通过将这个问题视为优化问题,最小化 y(真实输出)之间的误差,从而估计出正确的权重 (L)。

现在,通过正则化,我们向 L 添加了一个额外的项,强制权重变得更小。通常,这是通过使用 L1L2 正则化器来完成的。L1 正则化器是将权重的平方和添加到 L 上:

其中, 是正则化系数,决定了我们对权重的惩罚强度。L2 正则化器是将权重的平方和加到 L 上:

在这两种情况下,我们都在强制要求更小的权重优于更大的权重,因为这样可以避免函数过度依赖于机器学习模型中任何一个特征。正则化是一个独立的话题;如果你想了解更多,请前往进一步阅读部分查看一些关于正则化的资源。

另一个有效减少过拟合的方法是简单地使用更多的数据来训练模型。通过使用更大的数据集,模型过拟合的可能性会减少,因为大数据集能够捕获更多的多样性。

现在,我们如何调整参数,以在欠拟合和过拟合之间取得平衡呢?让我们在下一节中详细探讨。

超参数和验证集

几乎所有的机器学习模型都有一些超参数。超参数是模型的参数,它们不是从数据中学习到的,而是在训练开始之前就已经设置好的。例如,正则化的权重就是一个超参数。大多数超参数要么帮助我们控制模型的容量,要么对模型应用正则化。通过控制容量、正则化或两者,我们可以找到在欠拟合和过拟合模型之间的边界,得到一个恰到好处的模型。

但是,由于这些超参数必须在算法外部设置,我们如何估计最佳的超参数呢?虽然它不是核心的学习过程的一部分,但我们也可以从数据中学习超参数。不过,如果我们仅仅使用训练数据来学习超参数,那么它会选择最大的可能模型容量,这会导致过拟合。这就是我们需要验证集的原因,验证集是训练过程中无法访问的部分数据。借用之前的类比,验证集就像学生参加的模拟考试,用来检查他们是否已经学得足够好。但当数据集较小(不是成千上万的样本)时,单一验证集上的表现并不能保证公平的评估。在这种情况下,我们依赖于交叉验证。常见的做法是对原始数据集的不同子集重复进行训练和评估程序。常见的一种方法叫做k 折交叉验证,它将原始数据集分成k个相等的、互不重叠且随机的子集,每个子集在训练其他子集后都会被评估。如果你想了解更多关于交叉验证的技术,我们在进一步阅读部分提供了相关链接。稍后在本书中,我们也会从时间序列的角度讲解这个话题,它与标准的交叉验证方式略有不同。

建议阅读

尽管本书已经触及了机器学习的皮毛,但还有很多内容,若要更好地理解本书的其余部分,建议更深入地学习机器学习。我们建议从斯坦福大学的机器学习课程(Andrew Ng)开始—www.coursera.org/learn/machine-learning。如果你时间紧迫,谷歌的机器学习速成课程也是一个不错的起点—developers.google.com/machine-learning/crash-course/ml-intro

近些年,机器学习取得了很大进展,伴随着这些进展,能够从数据中学习复杂模式的强大模型也随之出现。当我们将这些模型与经典的时间序列预测模型进行比较时,我们可以看到这些新型模型有着巨大的潜力。但机器学习与时间序列预测之间仍然存在一些根本的差异。在下一节中,我们将了解如何克服这些差异,并使用机器学习进行时间序列预测。

将时间序列预测视为回归

正如我们在第一章介绍时间序列》中所看到的,时间序列是按时间顺序采集的一组观察值。通常,时间序列预测是关于尝试预测这些观察值在未来将会是什么样。给定一段任意长度的历史观察序列,我们可以预测未来某个时间点的值。

我们看到回归,或者说机器学习用于预测连续变量,是在一组示例数据集上进行的,每个示例由输入特征和目标组成。我们可以看出,回归任务是基于一组输入来预测单一输出,这与预测任务本质上是不同的,预测任务是基于一组历史值来预测未来值。这种时间序列与机器学习回归模型之间的根本不兼容性,就是我们不能直接使用回归来进行时间序列预测的原因。

此外,时间序列预测从定义上来说是一个外推问题,而回归大多数情况下是一个插值问题。外推通常比插值更难通过数据驱动方法来解决。回归问题中的另一个关键假设是训练所使用的样本是独立同分布iid)的。但时间序列破坏了这个假设,因为时间序列中的后续观察值显示出显著的依赖性。

然而,为了利用机器学习的各种技术,我们需要将时间序列预测转化为回归问题。幸运的是,有方法可以将时间序列转换为回归,并通过引入一些特征来为机器学习模型添加记忆,从而克服 IID 假设。让我们看看如何做到这一点。

时间延迟嵌入

我们在第四章《设定强基准预测》中讨论了 ARIMA 模型,并看到了它是一个自回归模型。我们可以使用相同的概念,将一个时间序列问题转换为回归问题。让我们通过以下图示来明确这一概念:

图 5.6 – 使用滑动窗口将时间序列转换为回归

图 5.6:使用滑动窗口将时间序列转换为回归

假设我们有一个时间序列,包含L个时间步长,就像图示中所示。我们有T作为最新的观测值,T - 1T - 2,依此类推,随着时间倒退,一直到T - L。在理想的世界中,每个观测值在进行预测时应该以所有先前的观测值为条件。但这是不切实际的,因为L可以非常长。我们通常会限制预测函数,只使用序列中最新的M个观测值,其中M < L。这些被称为有限记忆模型,或马尔科夫模型,而M被称为自回归的阶数、记忆大小或感受野。

因此,在时间延迟嵌入中,我们假设一个任意长度的窗口M < L,并通过将窗口在时间序列的长度上滑动,提取固定长度的子序列。

在图示中,我们采用了一个记忆大小为3的滑动窗口。所以,首先提取的子序列(如果从最新时间点开始,按时间倒序提取)是T – 3T – 2T - 1。而T是紧接在该子序列之后的观测值。这将成为数据集中的第一个例子(图示中表格的第1行)。

现在,我们将窗口向左滑动一个时间步长(即倒退到过去),并提取新的子序列,T – 4T – 3T - 2。对应的目标将变为T - 1。我们在回到时间序列的开始时重复这一过程,在每一步滑动窗口时,我们都会向数据集中添加一个新的例子。

最终,我们得到了一个对齐的数据集,特征的固定向量大小(即等于窗口大小)和一个单一目标,这就是典型的机器学习数据集的样子。

现在我们有一个包含三个特征的表格,我们也给这三个特征赋予了语义意义。如果我们查看图示中表格的最右列,我们可以看到该列中的时间步长总是比目标落后一个时间步长。我们称之为滞后 1。从右数第二列总是比目标滞后两个时间步长,我们称之为滞后 2。一般化地说,特征中观测值比目标滞后n个时间步长时,我们称之为滞后 n

通过时间延迟嵌入将时间序列转换为回归模型,能够以一种标准回归框架能够利用的方式,编码时间序列的自回归结构。我们还可以考虑另一种使用回归进行时间序列预测的方法,那就是对时间进行回归。

时间嵌入

如果我们依赖自回归模型中的先前观察值,那么我们在时间嵌入模型中则依赖于时间的概念。核心思想是,我们忽略时间序列的自回归特性,并假设时间序列中的任何值仅仅依赖于时间。我们从与时间序列相关的时间戳中提取能够捕捉时间、时间的流逝、时间的周期性等特征,然后使用这些特征通过回归模型来预测目标值。实现这一点的方法有很多,从简单地对齐一个单调且均匀递增的数值列来捕捉时间的流逝,到使用复杂的傅里叶项来捕捉时间中的周期性成分。我们将在第六章时间序列预测的特征工程中详细讨论这些技术。

在我们结束本章之前,让我们讨论一个在时间序列预测领域逐渐受到关注的关键概念。本书的大部分内容都采纳了这种新的预测范式。

全球预测模型——范式转变

传统上,每个时间序列都是孤立地处理的。正因为如此,传统的预测方法总是仅仅基于单一时间序列的历史来拟合预测函数。但近年来,由于在当今以数字为主的世界中,收集数据变得更加容易,许多公司开始收集来自相似来源或相关时间序列的大量数据。

例如,零售商如沃尔玛会收集跨千家商店的数百万种产品的销售数据。像 Uber 和 Lyft 这样的公司会收集城市中所有区域的乘车需求。在能源领域,能源消费数据会跨所有消费者进行收集。所有这些时间序列数据集都有共同的行为,因此被称为相关时间序列

我们可以认为,所有相关时间序列中的时间序列都来自不同的 DGP(数据生成过程),因此可以将它们分别建模。我们称这些为局部预测模型。该方法的另一种替代方式是假设所有时间序列都来自同一个 DGP。我们不为每个时间序列单独拟合预测函数,而是为所有相关时间序列拟合一个单一的预测函数。这种方法在文献中被称为全局跨学习。大多数现代深度学习模型以及机器学习方法都采用了全局模型的范式。我们将在接下来的章节中详细看到这些内容。

参考检查

全球一词由David Salinas 等人DeepAR论文(参考文献1)中提出,跨学习则由Slawek Smyl(参考文献2)提出。

我们之前看到,拥有更多数据将减少过拟合的可能性,因此可以降低泛化误差(训练误差与测试误差之间的差异)。这是局部方法的一个缺点。传统上,时间序列数据通常不长,且在很多情况下,收集更多数据既困难又耗时。在小数据上拟合机器学习模型(具有强大的表达能力)容易导致过拟合。这也是为什么传统上用于预测此类时间序列的时间序列模型会强制施加强先验的原因。但这些限制传统时间序列模型拟合的强先验,也可能导致一种形式的欠拟合,限制了准确性。

强大且具有表达能力的数据驱动模型,如机器学习模型,需要大量数据来生成能够对新数据进行泛化的模型。时间序列本质上是与时间相关的,有时收集更多数据意味着需要等待数月甚至数年,这是不理想的。因此,如果我们无法增加时间序列数据集的长度,我们可以增加时间序列数据集的宽度。如果我们将多个时间序列加入数据集,我们就增加了数据集的宽度,从而增加了模型训练时可用的数据量。

图 5.7 展示了通过视觉化增加时间序列数据集宽度的概念:

图 5.7 – 时间序列数据集的长度和宽度图 5.7:时间序列数据集的长度和宽度

这对机器学习模型有利,因为在拟合预测函数时,机器学习模型具有更高的灵活性,且能够利用更多数据,从而能够学习出比传统时间序列模型更复杂的预测函数,而传统时间序列模型通常是通过与相关时间序列共享的方式进行的,完全基于数据驱动。

局部方法的另一个缺点是可扩展性问题。以我们之前提到的沃尔玛为例,需要预测数百万个时间序列,并且无法对所有这些模型进行人工监督。如果从工程角度考虑,在生产系统中训练和维护数百万个模型对任何工程师来说都是一场噩梦。但在全球方法下,我们只需为所有这些时间序列训练一个模型,这大大减少了我们需要维护的模型数量,同时还能够生成所有所需的预测。

这种新的预测范式已经获得了广泛关注,并且在多个时间序列竞赛中持续证明能够改进局部方法,尤其是在相关时间序列数据集上。在 Kaggle 竞赛中,例如Rossman 商店销售(2015 年)、维基百科网页流量时间序列预测(2017 年)、Favorita 公司杂货销售预测(2018 年)以及M5 竞赛(2020 年),获胜的参赛作品都是全球模型——无论是机器学习、深度学习还是二者的结合。Intermarché预测竞赛(2021 年)的获胜作品也是全球模型。有关这些竞赛的链接可以在进一步阅读部分找到。

尽管我们有许多经验性发现表明,全球模型在相关时间序列预测中优于局部模型,但全球模型仍然是一个相对较新的研究领域。Montero-Manson 和 Hyndman (2020) 展示了一些非常有趣的结果,并表明任何局部方法都可以通过具备必要复杂度的全球模型进行逼近,他们提出的最有趣的发现是,即使面对不相关的时间序列,全球模型也能表现得更好。我们将在第十章中进一步讨论全球模型及其策略,即全球预测模型

参考检查

Montero-Manson 和 Hyndman (2020) 的研究论文在参考文献中被引用,参考文献编号为3

总结

我们已经开始了超越基准预测方法的探索,并初步涉足机器学习领域。在简要回顾机器学习的基础知识后,我们了解了诸如过拟合、欠拟合、正则化等关键概念,之后我们看到如何将时间序列预测问题转化为机器学习中的回归问题。我们还对不同的嵌入方法,如时间延迟嵌入和时间嵌入,进行了概念性的理解,这些方法可以用于将时间序列问题转化为回归问题。最后,我们还了解了一种新的时间序列预测范式——全球模型,并在概念层面上将其与局部模型进行了对比。在接下来的几章中,我们将开始实践这些概念,学习特征工程技术和全球模型的策略。

参考文献

以下是我们在本章中使用的参考文献:

  1. David Salinas, Valentin Flunkert, Jan Gasthaus, Tim Januschowski (2020). DeepAR: 基于自回归递归网络的概率预测. 国际预测学杂志. 36-3. 1181–1191: doi.org/10.1016/j.ijforecast.2019.07.001

  2. Slawek Smyl (2020). 指数平滑与递归神经网络的混合方法用于时间序列预测. 国际预测学杂志. 36-1: 75–85 doi.org/10.1016/j.ijforecast.2019.03.017

  3. Pablo Montero-Manso, Rob J Hyndman (2020), Principles and Algorithms for Forecasting Groups of Time Series: Locality and Globality. arXiv:2008.00444[cs.LG]: arxiv.org/abs/2008.00444

进一步阅读

你可以查看以下资源以便进一步阅读:

加入我们的 Discord 社区

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

packt.link/mts

第六章:时间序列预测中的特征工程

在上一章中,我们开始将机器学习ML)作为解决时间序列预测问题的工具进行探讨。我们还讨论了一些技术,如时间延迟嵌入时间嵌入,它们将时间序列预测问题视为从 ML 范式出发的经典回归问题。在本章中,我们将详细介绍这些技术,并通过实践操作,使用我们在本书中一直使用的数据集进行讲解。

在本章中,我们将讨论以下主题:

  • 理解特征工程

  • 避免数据泄露

  • 设置预测视野

  • 时间延迟嵌入

  • 时间嵌入

技术要求

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

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

  • 02-Preprocessing_London_Smart_Meter_Dataset.ipynb 来自第二章

  • 01-Setting_up_Experiment_Harness.ipynb 来自第四章

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

理解特征工程

特征工程顾名思义,是从数据中提取特征的过程,主要通过领域知识来使学习过程更加顺畅高效。在典型的机器学习设置中,工程化良好的特征对于获得优秀的模型性能至关重要。特征工程是机器学习中一个高度主观的部分,每个具体问题都有不同的解决路径——这一路径是为该问题量身定制的。假设你有一个房价数据集,并且有一个特征,建造年份,它告诉你房屋的建造年份。那么,为了优化这些信息,我们可以根据建造年份特征创建另一个特征,房屋年龄。这可能会为模型提供更好的信息,这就是特征工程的应用。

当我们将时间序列问题转化为回归问题时,有一些标准的技术可以应用。这是过程中的关键步骤,因为机器学习模型如何理解时间,取决于我们如何设计特征来捕捉时间。我们在第四章《设定强基准预测》中讨论的基线方法是针对时间序列预测的特定用例设计的,因此问题的时间维度已经内建于这些模型中。例如,ARIMA 模型不需要任何特征工程来理解时间,因为它已经内建于模型中。然而,标准回归模型并没有明确的时间理解,因此我们需要创建良好的特征来嵌入问题的时间维度。

在上一章(第五章,《将时间序列预测作为回归问题》)中,我们讨论了在回归框架中编码时间的两种主要方法:时间延迟嵌入时间嵌入。虽然我们在高层次上触及了这些概念,但现在是时候深入探讨并看到它们的实际应用了。

笔记本提醒

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

我们已经将正在使用的数据集拆分为训练集、验证集和测试集。然而,由于我们正在生成基于先前观察的特征,从操作角度来说,当我们将训练集、验证集和测试集结合时会更好。稍后会更清楚为什么,但现在,让我们先相信这一点,继续前进。现在,让我们将这两个数据集合并:

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

现在,我们有了一个full_df,它结合了训练、验证和测试数据集。你们中的一些人可能已经在脑海中敲响了警钟,合并训练集和测试集会带来什么问题呢?那就是数据泄露。让我们来检查一下。

避免数据泄露

数据泄露发生在模型训练时包含了在预测时无法获得的信息。通常,这会导致训练集中的性能很高,但在未见过的数据中表现非常差。数据泄露有两种类型:

  • 目标泄露是指关于目标(我们试图预测的内容)的信息泄露到模型中的某些特征中,导致模型过度依赖这些特征,最终导致泛化能力差。这包括以任何方式使用目标的特征。

  • 训练-测试污染是指训练集和测试集之间存在一些信息泄露。这可能是由于数据处理不当或拆分数据时的疏忽造成的。但也可能以更微妙的方式发生,例如在拆分训练集和测试集之前对数据集进行缩放。

当我们处理时间序列预测问题时,最大的且最常见的错误是目标泄漏。我们必须对每个特征进行深思熟虑,确保我们不会使用那些在预测时无法获得的数据。以下的图示可以帮助我们记住并内化这个概念:

图 6.1 – 可用与不可用信息,以避免数据泄漏

图 6.1:可用信息与不可用信息,以避免数据泄漏

为了使这个概念在时间序列预测的背景下更加清晰和相关,我们来看一个例子。假设我们正在预测洗发水的销量,而我们使用护发素的销量作为一个特征。我们开发了模型,在训练数据上进行了训练,并在验证数据上进行了测试。模型表现得非常好。然而,当我们开始预测未来时,就会看到一个问题。我们也不知道护发素的未来销量是多少。虽然这个例子比较简单,但有时这种问题并不那么显而易见。这就是为什么我们在创建特征时需要格外小心,并且始终从这个特征在预测时是否可用?的角度来评估特征。

最佳实践

除了对特征进行深思熟虑外,还有很多方法可以识别目标泄漏:

  • 如果你构建的模型好得让人难以置信,那么你很可能存在泄漏问题

  • 如果任何单一特征在模型的特征重要性中占比过大,那么这个特征可能存在泄漏问题

  • 仔细检查与目标高度相关的特征

尽管我们在本书前面已经生成了预测,但我们从未明确讨论过预测视野。这是一个重要的概念,对于我们接下来的讨论至关重要。让我们花点时间来理解预测视野。

设置预测视野

预测视野是指我们在任何时间点希望预测的未来时间步数。例如,如果我们想要预测在过去的电力消耗数据集上接下来的 24 小时的情况,那么预测视野就是 48(因为数据是按半小时记录的)。在第五章时间序列预测作为回归问题中,我们生成了基准模型,我们一次性预测了所有的测试数据。在这种情况下,预测视野等于测试数据的长度。

在此之前我们从未需要担心这一点,因为在经典的统计预测方法中,这个决策与建模是分开的。如果我们训练了一个模型,就可以用它来预测任何未来的点,而无需重新训练。但是在时间序列回归预测中,我们对预测范围有所限制,这与数据泄漏有关。现在这可能对你来说还不清楚,因此我们将在学习特征工程技术后再回顾这一点。目前,我们只关注单步预测。在我们正在使用的数据集上下文中,这意味着我们将回答的问题是:下一小时的能耗是多少? 我们将在第四部分预测的机制 中讨论多步预测和预测的其他机制。

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

时间延迟嵌入

时间延迟嵌入的基本思想是通过最近的观察值将时间嵌入其中。在第五章时间序列回归预测中,我们讨论了将时间序列的前几次观察作为滞后项第 5.6 图,位于时间延迟嵌入小节下)。

然而,使用这个概念还有一些其他方法可以捕捉近期和季节性的信息。

  • 滞后项

  • 滚动窗口聚合

  • 季节性滚动窗口聚合

  • 指数加权移动平均

让我们来看看。

滞后项或回溯

假设我们有一个时间序列,时间步为 Y[L]。假设当前时间为 T,并且我们有一个历史长度为 L 的时间序列。所以我们的时间序列中,y[T] 是最新的观察值,接着是 y[T-1]、y[T-2] 等等,随着时间的推移向后。正如第五章时间序列回归预测中所解释的,滞后项是包含时间序列中先前观察值的特征,如下图所示:

图 6.2 – 滞后特征

图 6.2:滞后特征

我们可以通过包括比当前时间点的时间步(y[T-a])来创建多个滞后项;我们将其称为滞后 a。在前面的图示中,我们展示了滞后 1滞后 2滞后 3。然而,我们可以根据需要添加任意数量的滞后项。现在让我们来学习如何在代码中实现这一点:

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

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

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

我们将导入该方法,并使用其中的一些参数来配置我们希望的滞后操作:

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

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

  • lags:该参数接收一个整数列表,表示我们需要创建为特征的所有滞后。

  • column:要进行滞后操作的列名。在我们的例子中,这是 energy_consumption

  • ts_id:包含时间序列唯一 ID 的列名。如果为 None,则假定数据框仅包含单一的时间序列。在我们的例子中,LCLid 就是该列的名称。

  • use_32_bit:该参数在功能上没有任何作用,但通过牺牲浮动点数的精度,使得数据框在内存中的大小变得更小。

该方法返回添加滞后后的数据框(DataFrame),以及包含新添加特征的列名的列表。

滚动窗口聚合

使用滞后时,我们将当前点与过去的单个点连接,而使用滚动窗口特征时,我们将当前点与过去窗口的汇总统计量连接。我们不是查看前几个时间步的观察值,而是查看过去三个时间步的观察值的平均值。请查看下面的图表,以更好地理解这一点:

图 6.3 – 滚动窗口聚合特征

图 6.3:滚动窗口聚合特征

我们可以使用不同的窗口来计算滚动统计量,每个窗口将捕捉历史的略有不同的方面。在前面的图表中,我们可以看到一个窗口为三和一个窗口为四的示例。当我们处于时间步* T 时,窗口大小为三的滚动窗口将具有 y[T] [– 3],y[T] [– 2],y*[T] [– 1] 作为过去观察值的向量。一旦我们拥有这些数据,就可以应用任何聚合函数,如均值、标准差、最小值、最大值等。通过聚合函数得到标量值后,我们可以将其作为时间步 t 的特征。

我们不包括 y[T]在过去观测值的向量中,因为那会导致数据泄漏。

让我们看看如何通过 pandas 来实现这一操作:

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

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

我们将导入这个方法,并使用它的一些参数来按照我们希望的方式配置滚动操作:

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

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

  • rolls: 该参数接受一个整数列表,表示我们需要计算聚合统计量的所有窗口。

  • column: 要进行滞后操作的列名。在我们的案例中,这列是energy_consumption

  • agg_funcs: 这是一个聚合函数列表,用于对rolls中声明的每个窗口进行处理。允许的聚合函数包括{mean, std, max, min}

  • n_shift: 这是在进行滚动操作之前需要移动的时间步数。此参数可以避免数据泄漏。虽然我们在这里移动了一个时间步,但也有需要移动多个时间步的情况。通常这用于多步预测,我们将在第四部分预测的机制中讨论。

  • ts_id: 包含时间序列唯一 ID 的列名。如果为None,则假定数据框架只有一个时间序列。在我们的案例中,LCLid就是该列的名称。

  • use_32_bit: 该参数在功能上没有任何作用,但能使数据框架在内存中占用更小的空间,牺牲浮动点数的精度。

该方法返回添加了滚动特征的数据框架,并且返回一个包含新添加特征列名的列表。

季节性滚动窗口聚合

季节性滚动窗口聚合与滚动窗口聚合非常相似,但它们不同的是,季节性窗口不会采用过去的n个连续观测值,而是采用一个季节性窗口,在窗口中的每个项之间跳过一个恒定数量的时间步。下面的图表将使这一点更加清晰:

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

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

这里的关键参数是季节性周期,通常称为 M。这是我们预计季节性模式会重复的时间步数。在时间步 T 时,大小为三的滚动窗口将包含 y[T] [– 3]、y[T] [– 2]、y[T] [– 1],作为过去观察值的向量。但是,季节性滚动窗口将在窗口中的每个元素之间跳过 m 个时间步数。这意味着季节性滚动窗口中的观察值将是 y[T] [–] [M]、y[T] [– 2][M]、y[T] [– 3][M]。此外,像往常一样,一旦我们获得窗口向量,我们只需应用聚合函数以获得标量值,并将其作为特征包含在内。

我们 不包括 y[T] 作为季节性滚动窗口向量中的一个元素,以避免数据泄漏。

这是一个你不能使用 pandas 轻松高效完成的操作。需要一些高级的 NumPy 索引和 Python 循环来实现这个功能。我们将使用来自 github.com/jmoralez/window_ops/ 的实现,它利用 NumPy 和 Numba 来使操作更快速高效。

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

我们将导入该方法并使用该方法的几个参数来配置我们想要的季节性滚动操作:

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

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

  • seasonal_periods:这是一个季节性周期的列表,应在季节性滚动窗口中使用。在多季节性的情况下,我们可以包含所有季节性滚动特征。

  • rolls:该参数接受一个整数列表,表示我们需要计算聚合统计量的所有窗口。

  • column:要滞后的列名。在我们的例子中,这是 energy_consumption

  • agg_funcs:这是我们希望对在 rolls 中声明的每个窗口进行的聚合操作的列表。允许的聚合函数为 {mean, std, max, min}

  • n_shift:这是在进行滚动操作之前需要移动的季节性时间步数。该参数可以防止数据泄漏。

  • ts_id:包含时间序列唯一 ID 的列名。如果为 None,则假设 DataFrame 仅包含单个时间序列。在我们的例子中,LCLid 就是该列名。

  • Use_32_bit:该参数在功能上没有任何作用,但通过牺牲浮动点数的精度,使 DataFrame 在内存中变得更小。

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

指数加权移动平均(EWMA)

在滚动窗口均值操作中,我们计算了窗口的平均值,这与移动平均是同义的。EWMA 是移动平均的稍微聪明一些的“亲戚”。移动平均考虑了一个滚动窗口,并且对窗口中的每个项在计算的平均值中赋予相同的权重,而 EWMA 则尝试对窗口进行加权平均,并且权重按指数速率衰减。有一个参数,,决定了权重衰减的速度。因为这个原因,我们可以将所有可用的历史数据视为一个窗口,让参数决定 EWMA 中包括多少近期的数据。这个过程可以简单地递归表示如下:

在这里,我们可以看到,的值越大,平均值越偏向于最近的值(查看图 6.6以获得关于权重如何分布的直观印象)。如果我们展开递归,每一项的权重将会是:

其中,k是相对于T的时间步长。如果我们绘制权重,就能看到它们呈指数衰减;决定了衰减的速度。另一种理解的方式是从跨度的角度来看。跨度是指衰减后的权重接近零的周期数(这不是严格的数学意义,而是直观的理解)。和跨度通过以下方程相关:

这一点将在下图中更清晰地展示,我们绘制了不同值下权重衰减的情况

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

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

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

直观地,我们可以将 EWMA 视为整个时间序列历史的平均值,但通过跨度等参数,我们可以使不同时期的历史对平均值的影响更加显著。如果我们定义了一个 60 期的跨度,我们可以认为最后的 60 个时间期主要驱动了平均值。因此,通过使用不同跨度或值的 EWMA,我们可以得到具有代表性的特征,捕捉不同历史时期的特征。

整个过程在下图中进行了展示:

图 6.6 – EWMA 特征

图 6.6:EWMA 特征

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

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

和我们之前讨论的其他特性一样,EWMA 也需要对每个LCLid单独进行处理。我们在src.feature_engineering.autoregressive_features中包含了一个有用的方法,叫做add_ewma,它可以快速高效地为每个LCLid添加你所需要的所有 EWMA 特征。让我们看看如何使用它。

我们将导入这个方法,并使用该方法的几个参数来配置我们想要的 EWMA:

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

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

  • alphas:这是我们需要计算 EWMA 特征的所有的列表。

  • spans:作为替代,我们可以用它列出我们需要计算 EWMA 特征的所有跨度。如果使用此特性,alphas将被忽略。

  • column:需要进行滞后处理的列名。在我们的案例中,这一列是energy_consumption

  • n_shift:这是我们在进行滚动操作之前需要移动的季节性时间步数。这个参数可以避免数据泄漏。

  • ts_id:包含时间序列唯一 ID 的列名。如果为None,则假设数据框仅包含单一时间序列。在我们的案例中,LCLid是该列的名称。

  • use_32_bit:这个参数在功能上没有任何作用,但可以使数据框在内存中占用更少的空间,代价是牺牲浮点数的精度。

一如既往,该方法返回包含 EWMA 特征的数据框,并返回一个包含新添加特征列名的列表。

这些是将时间延迟嵌入到机器学习模型中的几种标准方式,但你并不局限于这些。像往常一样,特征工程是一个没有固定规则的领域,我们可以尽情发挥创意,并将领域知识注入到模型中。除了我们已经看到的特征,我们还可以包括滞后差异作为自定义滞后特征来注入领域知识,等等。在大多数实际情况下,我们最终会使用不止一种方式将时间延迟嵌入到模型中。滞后特征在大多数情况下是最基本和最重要的,但我们确实会通过季节性滞后、滚动特征等编码更多信息。和机器学习中的一切一样,没有万能的解决方案。每个数据集都有其独特性,这使得特征工程对于每种情况都非常重要且不同。

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

时间嵌入

第五章时间序列预测作为回归问题中,我们简要讨论了时间嵌入的过程,即我们尝试将时间嵌入到机器学习模型可以利用的特征中。如果我们稍微思考一下时间,我们会发现,在时间序列预测的背景下,时间有两个方面对我们来说至关重要——时间的流逝时间的周期性

有一些特征可以帮助我们在机器学习模型中捕捉这些方面:

  • 日历特征

  • 时间流逝

  • 傅里叶项

让我们逐一看一下它们。

日历特征

我们可以提取的第一类特征是基于日历的特征。尽管时间序列的严格定义是按时间顺序获取的一组观测值,但我们通常会在这些观测值的时间戳旁边收集时间序列。我们可以利用这些时间戳并提取日历特征,例如月份、季度、年中的第几天、小时、分钟等。这些特征捕捉了时间的周期性,帮助机器学习模型有效地捕捉季节性。只有比时间序列频率更高的日历特征才有意义。例如,在一个每周频率的时间序列中,小时特征是没有意义的,但月份和周数特征则有意义。我们可以利用 pandas 中的内置日期时间功能来创建这些特征,并在模型中将它们视为分类特征。

时间流逝

这是另一个在机器学习模型中捕捉时间流逝的特征。随着时间的推移,该特征单调增加,给机器学习模型提供时间流逝的感知。创建这个特征有很多方法,但最简单且高效的方法之一是使用 NumPy 中日期的整数表示:

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

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

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

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

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

  • field_name:这是包含应当用于创建特征的日期时间的列名。

  • frequency:我们应当提供时间序列的频率作为输入,以便方法自动提取相关特征。这些是标准的 pandas 频率字符串。

  • add_elapsed:此标志用于开启或关闭时间流逝特征的创建。

  • use_32_bit:该参数在功能上没有任何作用,但使得 DataFrame 在内存中占用更小的空间,牺牲了浮点数的精度。

就像我们讨论的前几种方法一样,这个方法也会返回一个新的 DataFrame,添加了时间特征,并返回一个包含新增特征列名的列表。

傅里叶项

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

在这里,S[N]是信号SN项近似。理论上,当N为无限时,得到的近似值等于原始信号。P是周期的最大长度,a[n]和b[n]分别是余弦项和正弦项的系数,a[0]是截距。

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

下图展示了按序数尺度表示的月份与作为傅里叶级数表示之间的差异:

图 6.7 – 作为序数步进函数的月份(上图)与傅里叶项(下图)

图 6.7:作为序数步进函数的月份(上图)与傅里叶项(下图)

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

我们不能简单地说季节性的连续表示优于类别表示,因为这取决于你使用的模型类型和数据集。这是我们需要通过经验来发现的。

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

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

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

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

  • columns_to_encode:这是我们需要使用傅里叶项进行编码的日历特征列表。

  • max_values:这是一个季节性循环的最大值列表,对于日历特征,按columns_to_encode中给出的顺序排列。例如,对于month要作为列进行编码时,我们给出12作为对应的max_value。如果没有给出,max_value将会被推断。只有在你的数据至少包含一个完整的季节性循环时,才建议使用此方法。

  • n_fourier_terms:这是要添加的傅里叶项数量。这与前面提到的傅里叶级数公式中的n是同义词。

  • use_32_bit:这个参数在功能上没有任何作用,但它会使 DataFrame 在内存中变得更小,从而牺牲浮点数的精度。

就像我们之前讨论过的方法一样,它也会返回一个新的 DataFrame,添加了傅里叶特征,同时返回一个包含新增特征列名的列表。

在执行Chapter06中的01-Feature_Engineering.ipynb笔记本后,我们将生成以下特征工程文件并保存到磁盘:

  • selected_blocks_train_missing_imputed_feature_engg.parquet

  • selected_blocks_val_missing_imputed_feature_engg.parquet

  • selected_blocks_test_missing_imputed_feature_engg.parquet

在本节中,我们探讨了一些流行且有效的时间序列特征生成方法。但实际上还有很多其他方法,具体选择哪些方法取决于你的问题和领域,很多方法都将适用。

附加信息

特征工程的领域非常广泛,已有一些开源库使得探索这一领域变得更加容易。其中一些库包括github.com/Nixtla/tsfeaturestsfresh.readthedocs.io/en/latest/以及github.com/DynamicsAndNeuralSystems/catch22。Ben D. Fulcher 撰写的预印本论文《基于特征的时间序列分析》(arxiv.org/abs/1709.08055)也提供了对这一领域的良好总结。

一个名为 functime 的新库(github.com/functime-org/functime)也提供了快速的特征工程例程,它是用 Polars 编写的,值得一试。书中讨论的许多特征工程方法,通过使用 functime 和 Polars 可以使处理速度更快。

总结

在上一章简要概述了时间序列预测的机器学习范式后,本章我们从实践角度深入探讨,了解如何准备带有所需特征的数据集,以开始使用这些模型。我们回顾了几种时间序列特定的特征工程技术,如滞后、滚动窗口和季节性特征。本章中学习的所有技术都是我们可以快速迭代实验的工具,帮助我们找到最适合我们数据集的方案。然而,我们仅讨论了特征工程,它只影响标准回归方程的一部分(y = mX + c)。另一个部分,即我们预测的目标(y),同样重要。下一章我们将探讨一些概念,比如平稳性以及一些影响目标的转换。

加入我们在 Discord 上的社区

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

packt.link/mts

第七章:时间序列预测的目标转换

在上一章中,我们探讨了如何通过利用特征工程技术进行时间嵌入和时间延迟嵌入。但那只是回归方程的一方面——特征。通常,我们会发现方程的另一侧——目标,并没有按照我们预期的方式表现出来。换句话说,目标并没有具备一些理想的特性,这些特性能够让预测变得更加容易。这个领域的主要罪魁祸首之一是平稳性——或者更具体地说,缺乏平稳性。这会给我们在开发机器学习ML)/统计模型时所做的假设带来问题。在本章中,我们将讨论一些处理目标相关问题的技术。

本章将覆盖以下主题:

  • 处理时间序列中的非平稳性

  • 检测和修正单位根

  • 检测和修正趋势

  • 检测和修正季节性

  • 检测和修正异方差性

  • AutoML 方法用于目标转换

技术要求

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

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

  • Chapter02文件夹中的02-Preprocessing_London_Smart_Meter_Dataset.ipynb

  • Chapter04文件夹中的01-Setting_up_Experiment_Harness.ipynb

  • Chapter06文件夹中的01-Feature_Engineering.ipynb

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

检测时间序列中的非平稳性

平稳性是大多数计量经济学模型中普遍的假设,是一个严谨且数学化的概念。但在不涉及太多数学的情况下,我们可以直观地理解平稳性为时间序列所采样的分布的统计特性随着时间保持不变的状态。这在时间序列回归中也很相关,因为我们在跨时间估计一个单一的预测函数。如果时间序列的行为随时间发生变化,那么我们估计的单一函数可能并不总是适用。例如,如果我们把附近公园每日的游客数量看作一个时间序列,我们知道这些模式在疫情前后会有很大不同。在机器学习领域,这种现象被称为概念漂移

直观上,我们可以理解,平稳的序列比非平稳的序列更容易预测。但这里有个关键点:在现实世界中,几乎所有的时间序列都不满足平稳假设——更具体地说,严格平稳假设。严格平稳是指所有统计性质,如均值、方差、偏度等,都不随时间变化。很多时候,这一严格平稳假设被放宽,采用弱平稳,即只要求时间序列的均值和方差不随时间变化。

我们可以问自己四个主要问题来检查我们的时间序列是否平稳:

  • 均值是否随时间变化?换句话说,时间序列中是否有趋势?

  • 方差是否随时间变化?换句话说,时间序列是否异方差?

  • 时间序列的均值是否表现出周期性的变化?换句话说,时间序列中是否存在季节性?

  • 时间序列是否具有单位根?

其中前三个问题可以通过简单的视觉检查来确定。单位根较难理解,我们将稍后深入探讨单位根。现在,让我们看几个时间序列,检查我们是否能通过视觉检查来判断它们是否平稳(你可以记录你的答案):

图 7.1 – 测试你对平稳性的理解

图 7.1:测试你对平稳性的理解

现在,检查你的回答,看看你猜对了多少个。如果你答对了六个中的至少四个,你对平稳性的直觉理解就非常好:

  • 时间序列 1平稳的,因为它是一个白噪声过程,按照定义,它的均值为零,方差恒定。它通过了我们对前三个问题的检查。

  • 时间序列 2非平稳的,因为它有一个明显的下降趋势。这意味着序列在开始时的均值与结束时不同。因此,它没有通过我们的第一个检查问题。

  • 时间序列 3乍一看可能是平稳的,因为它基本上围绕 0 波动,但随着时间的推移,波动幅度逐渐增大。这意味着它的方差在增大——换句话说,它是异方差的。因此,尽管它回答了我们的第一个问题,但未能通过第二个检查,即方差是否恒定。所以,它是非平稳的。

  • 现在,我们要讨论的难题是——时间序列 4。乍一看,我们可能认为它是平稳的,因为尽管它一开始有趋势,但它也发生了反转,使得均值几乎保持不变。而且,方差的变化也不那么明显。但这实际上是一个含有单位根的时间序列(我们将在稍后的单位根部分详细讨论),通常,含单位根的时间序列很难通过视觉检查来判断。

  • 时间序列 5 回答了前两个问题——常数均值和常数方差——但它有一个非常明显的季节性模式,因此是非平稳的

  • 时间序列 6 是另一个白噪声过程,仅用于迷惑你。这也是平稳的

当我们拥有成百上千,甚至是数百万个时间序列时,我们在实际操作中无法通过视觉检查来判断它们是否平稳。因此,现在让我们来看看使用统计测试检测这些关键特性的一些方法,以及如何尝试修正它们。

虽然我们讨论的是修正或使时间序列平稳,但在机器学习范式下,并不总是必须这样做,因为一些问题可以通过使用合适的特征在模型中得到处理。是否将一个序列转换为平稳序列是一个我们需要在实验技术后做出的决定。因为正如你将看到的,虽然将序列平稳化有其优点,但使用这些技术也有其缺点,我们将在详细讨论每种转换时看到这些问题。

笔记本提醒

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

检测和修正单位根

让我们先谈谈单位根,因为这是最常见的平稳性测试。时间序列分析起源于计量经济学和统计学,而单位根是直接源于这些领域的一个概念。

单位根

单位根相当复杂,但为了培养一些直觉,我们可以简化来看。让我们考虑一个一阶自回归模型(AR(1) 模型):

,其中 是白噪声, 是 AR 系数。

如果我们考虑方程中不同的 值,我们可以提出三种情境(图 7.2):

  1. :当 大于 1 时,时间序列中的每个后续值都会乘以大于 1 的数字,这意味着它将具有强烈且迅速增加/减少的趋势,因此是非平稳的。

  2. :当 小于 1 时,时间序列中的每个后续值都会乘以小于 1 的数字,这意味着从长远来看,序列的均值趋向于零,并围绕零振荡。因此,它是平稳的。

  3. :当 等于 1 时,情况变得更加复杂。当 AR(1) 模型时,这被称为具有单位根,方程变为 。这在计量经济学中被称为随机漫步,是金融和经济领域中一种非常流行的时间序列类型。从数学上讲,我们可以证明这样的序列将具有常数均值,但方差却是非恒定的:

图 7.2 – 具有不同参数的自回归时间序列

图 7.2:具有不同 参数的自回归时间序列。顶部:情境 1,phi <1;中间:情境 2,phi = 1;底部:情境 3,phi>1

虽然我们讨论了AR(1)过程中的单位根问题,但我们可以将相同的直觉扩展到多个滞后项或AR(p)模型。在那里,计算和检验单位根更加复杂,但仍然是可能的。

既然我们已经知道什么是单位根,如何通过统计方法进行检验呢?这时 Dickey-Fuller 检验就派上用场了。

扩展的 Dickey-Fuller(ADF)检验

这个检验中的零假设是AR(1)模型中的 等于 1,从而使序列非平稳。备择假设是AR(1)模型中的 小于 1。ADF 检验将 Dickey-Fuller 检验扩展到AR(p)模型,因为大多数时间序列不仅仅由一个滞后项定义。这个检验是检查单位根的标准且最流行的统计方法。该检验的核心是对时间序列的滞后进行回归,并计算残差方差的统计量。

让我们看看如何在 Python 中使用statsmodels来实现这一点:

from statsmodels.tsa.stattools import adfuller
result = adfuller(y) 

adfullerresult是一个元组,包含了检验统计量p 值和不同置信度下的临界值。在这里,我们最关注的是 p 值,这是一个简单实用的方法来检查零假设是否被拒绝。如果p < 0.05,则有 95%的概率表明序列没有单位根;从单位根的角度来看,序列是平稳的。

为了让这个过程更加简便,我们在src.transforms.stationary_utils中包含了一个名为check_unit_root的方法,它会为你进行推断(通过将返回的概率与置信度进行比较并拒绝或接受零假设),并返回一个包含布尔属性stationarynamedtuple,以及来自statsmodels的完整结果在results中:

from src.transforms.stationary_utils import check_unit_root
# We pass the time series along with the confidence with which we need the results
check_unit_root(y, confidence=0.05) 

现在我们已经学会了如何检查一个序列是否有单位根,那么如何使其平稳呢?我们来看看几个可以帮助我们实现这一目标的变换方法。

差分变换

差分变换是使时间序列平稳,或至少消除单位根的非常流行的变换方法。这个概念很简单:我们将时间序列从观察领域转化为观察变化领域。差分变换通过将后续观察值相互减去来实现:

z[t] = y[t] – y[t][-1]

差分有助于稳定时间序列的均值,并因此减少或消除趋势和季节性。让我们看看差分是如何使序列变得平稳的。

让我们考虑问题中的时间序列 ,其中 是系数, 是白噪声。从这个方程中,我们可以看到时间 t 是方程的一部分,这使得 y[t] 成为一个具有趋势的时间序列。因此,差分后的时间序列 z 将如下所示:

我们需要在这个新方程中寻找的是没有提到 t。这意味着,创建趋势的 t 依赖性已经被去除,现在时间序列在任何时间点都有恒定的均值和方差。

差分并不能去除所有种类的非平稳性,但适用于大多数时间序列。然而,这种方法也有一些缺点。其中一个缺点是我们在建模过程中会丢失时间序列的尺度。很多时候,时间序列的尺度包含一些对预测有用的信息。例如,在供应链中,高销售量的 SKU 表现出不同于低销售量 SKU 的模式,而当我们进行差分时,这种区分信息会丢失。

另一个缺点更多是从操作角度来看。当我们使用差分进行预测时,我们还需要在从模型获取差分后的输出后对其进行逆变换。这是我们必须管理的一个额外复杂性。解决方法之一是将最新的观察值保存在内存中,并不断将差分添加到其中以逆变换。另一种方法是为每个需要逆变换的 t 保留 y[t][-1],并将差分不断添加到 y[t][-1] 中。

我们已经使用 datetime 索引作为关键字来对齐并获取src.transforms.target_transformations.py中* y *[t][-1]的观察值,该文件位于本书的 GitHub 仓库中。让我们看看如何使用它:

from src.transforms.target_transformations import AdditiveDifferencingTransformer
diff_transformer = AdditiveDifferencingTransformer()
# [1:] because differencing reduces the length of the time series by one
y_diff = diff_transformer.fit_transform(y, freq="1D")[1:] 

y_diff将包含变换后的序列。要返回原始时间序列,我们可以调用inverse_transform并使用diff_transformer。关联的笔记本有示例和图表,展示差分如何改变时间序列。

在这里,我们将差分看作是从时间序列中减去后续值的过程。但我们也可以使用其他运算符进行差分,例如除法(y[t]/ y[t][-1]),这在src.transforms.target_transformations.py文件中实现为MultiplicativeDifferencingTransformer。我们还可以尝试这些变换,以检查它们是否对你的数据集最有效。

尽管差分解决了大多数平稳性问题,但并不能保证解决所有类型的趋势(非线性或分段趋势)、季节性等问题。有时候,我们可能不想对序列进行差分,但仍然需要处理趋势和季节性。那么,让我们看看如何检测并去除时间序列中的趋势。

检测并纠正趋势

第五章,《作为回归的时间序列预测》中,我们讨论了预测是一个困难的问题,因为它本质上是一个外推问题。趋势是导致预测成为外推问题的主要因素之一。如果我们有一个上升趋势的时间序列,任何试图预测它的模型都需要在训练过程中所看到的值范围之外进行外推。ARIMA 通过自回归来处理这个问题,而指数平滑通过显式建模趋势来处理它。但标准回归可能并不天然适合外推。然而,通过合适的特征,如滞后,它也可以开始进行外推。

但是,如果我们能自信地估计并提取时间序列中的趋势,我们可以通过去趋势化时间序列来简化回归问题。

但在继续之前,值得了解两种主要的趋势类型。

确定性和随机性趋势

让我们再次使用之前看到的简单AR(1)模型来培养对这个问题的直觉。之前我们看到在AR(1)模型中有时,时间序列中会出现趋势。但我们也可以通过将时间作为有序变量包含在定义时间序列的方程中来考虑趋势时间序列。例如,让我们考虑两个时间序列:

时间序列 1

时间序列 2

这些可以通过以下图表看到:

图 7.3 – 时间序列 1(随机趋势)和时间序列 2(确定性趋势)

图 7.3:顶部:随机趋势;底部:确定性趋势

我们之前看到过这两个方程;时间序列 1AR(1)模型,而时间序列 2是我们选择用来说明差分的时间序列方程。我们已经知道,对于时间序列 1时间序列 2都有趋势。但这两种趋势之间有差异。

时间序列 2中,趋势是恒定的,可以完美地建模。在这种情况下,简单的线性拟合就能完美地解释趋势。但在时间序列 1中,趋势并不是可以通过简单的线性拟合来解释的。它本质上依赖于时间序列的前一个值,因此是随机的。因此,时间序列 2具有确定性趋势,而时间序列 1具有随机性趋势。

我们可以使用本章中早些时候看到的相同 ADF 检验来检查时间序列是否具有确定性或随机性趋势。在不深入讨论统计检验的数学原理的情况下,我们知道它通过将AR(p)模型拟合到时间序列上来检验单位根。我们可以通过statsmodels实现中的regression参数指定该检验的几种变体。该参数接受以下值:

  • c:这意味着我们在AR(p)模型中包括了常数截距项。实际上,这意味着即使序列不以零为中心,我们也会认为时间序列是平稳的。这是statsmodels中的默认设置。

  • n:这意味着我们在AR(p)模型中甚至不包括常数截距项。

  • ct:如果我们提供此选项,则AR(p)模型将包含常数截距项和线性、确定性趋势分量。这意味着即使时间序列中存在确定性趋势,它也会被忽略,并且该序列将被测试为平稳序列。

  • ctt:这是当我们包括常数截距项,即线性和二次趋势时。

因此,如果我们使用regression="c"运行 ADF 检验,它将显示为非平稳序列。而如果我们使用regression="ct"运行 ADF 检验,它将显示为平稳序列。这意味着,当我们从时间序列中移除确定性趋势后,它变得平稳。这项测试可以帮助我们判断时间序列中观察到的趋势是确定性趋势还是随机趋势。在进一步阅读部分,我们提供了一个由Fabian Kostadinov撰写的博客链接,他通过对一些时间序列进行实验,清楚地说明了不同 ADF 检验变种的区别。

我们已经在src.transforms.stationary_utils中实现了这个测试,命名为check_deterministic_trend,它会为您进行推断,并返回一个包含布尔属性deterministic_trendnamedtuple。如果您想进一步调查,namedtuple还包含我们在adf_resadf_ct_res中进行的两个adfuller检验的原始结果。让我们看看如何使用这个测试:

check_deterministic_trend(y, confidence=0.05) 

这将告诉我们趋势是平稳的还是确定性的。接下来,让我们看看几种方法,如何识别并对时间序列中的趋势(无论是否为确定性趋势)进行统计检验。

Kendall 的 Tau

Kendall 的 Tau 是相关性的一种衡量方式,但它是在数据的等级上进行的。Kendall 的 Tau 是一个非参数检验,因此不对数据做任何假设。相关系数 Tau 的值介于-1 和 1 之间,其中 0 表示无关系,1 或-1 表示完全关系。我们不会深入探讨 Kendall 的 Tau 是如何计算的,也不会讨论其显著性检验,因为这超出了本书的范围。进一步阅读部分包含了一个很好的链接来解释这一点。

在本节中,我们将看到如何使用 Kendall 的 Tau 来衡量我们时间序列中的趋势。如前所述,Kendall 的 Tau 计算两个变量之间的等级相关性。如果我们选择其中一个变量作为时间序列,并将另一个变量设置为时间的序数表示,则得到的 Kendall 的 Tau 将表示时间序列中的趋势。另一个好处是,Kendall 的 Tau 值越高,我们预计趋势越强。

scipy 提供了一个 Kendall’s Tau 的实现,我们可以如下使用:

import scipy.stats as stats
tau, p_value = stats.kendalltau(y, np.arange(len(y))) 

我们可以将返回的 p 值与所需的置信度(通常为 0.05)进行比较,并得出结论:如果 p_value < confidence,我们可以认为趋势在统计上是显著的。tau 的符号告诉我们这是一个上升趋势还是下降趋势。

我们在 src.transforms.stationary_utils 中实现了 Kendall’s Tau,命名为 check_trend,它会帮助你检查是否存在趋势。我们只需要提供以下参数:

  • y:需要检查的时间序列

  • confidence:对结果 p 值进行检查的置信度水平

还有一些参数,但它们是用于 Mann-KendallM-K)检验的,接下来会做详细解释。

让我们看看如何使用这个检验:

check_trend(y, confidence=0.05) 

此方法还检查已识别的趋势是确定性还是随机性,并计算趋势的方向。结果会作为 namedtuple 返回,包含以下参数:

  • trend:这是一个布尔标志,表示是否存在趋势。

  • direction:这将是 increasingdecreasing

  • slope:这是估算趋势线的斜率。对于 Kendall’s Tau,它将是 Tau 值。

  • p:这是统计检验的 p 值。

  • deterministic:这是一个布尔标志,表示确定性趋势。

现在,让我们来看看 Mann-Kendall 检验。

Mann-Kendall 检验(M-K 检验)

Mann-Kendall 检验用于检查是否存在单调上升或下降的趋势。由于 M-K 检验是非参数检验,就像 Kendall’s Tau 一样,不需要假设数据的正态性或线性。检验通过分析时间序列中连续点之间的符号来进行。检验的核心思想是,在存在趋势的情况下,如果将符号值求和,它会不断增加或减少。

尽管是非参数检验,但原始检验中有几个假设:

  • 时间序列中没有自相关

  • 时间序列中没有季节性

多年来,已经对原始检验进行了许多修改,以解决这些问题,很多这种修改,包括原始检验,已在 github.com/mmhs013/pyMannKendall 上实现。它们也作为 pymannkendallpypi 上提供。

预白化 是一种常用的技术,用于去除时间序列中的自相关。简而言之,基本思路如下:

  1. 使用 AR(1) 模型识别

M. Bayazit 和 B. Önöz(2007)建议,如果样本量大于 50 且趋势足够强(slope>0.01),在进行 M-K 检验时不需要使用预白化。对于季节性数据,pymannkendall 也实现了 M-K 检验的季节性变体。

参考检查

M. Bayazit 和 B. Önöz 的研究论文在 参考文献 部分被引用,引用号为 1

我们之前讨论过的相同方法check_trend,也实现了 M-K 检验,可以通过设置mann_kendall=True来启用。然而,我们需要记住的一点是,M-K 检验比 Kendall’s Tau 要慢得多,特别是对于长时间序列。M-K 检验还有一些特定的参数:

  • seasonal_period:默认值为None。但如果存在季节性,我们可以在此提供seasonal_period,并且会检索到 M-K 检验的季节性变体。

  • prewhiten:这是一个布尔标志,用于在应用 M-K 检验之前对时间序列进行预白化处理。默认值为None。在这种情况下,我们会根据之前讨论的条件(N>50)来决定是否进行预白化处理。如果我们在此明确传递TrueFalse,将会尊重此设置。

让我们看看如何使用这个检验:

check_trend(y, confidence=0.05, mann_kendall=True) 

结果将作为一个namedtuple返回,包含以下参数:

  • trend:这是一个布尔标志,表示是否存在趋势。

  • direction:这将是increasing(增加)或decreasing(减少)。

  • slope:这是估算趋势线的斜率。对于 M-K 检验,它将是使用 Theil-Sen 估算器估算的斜率。

  • p:这是统计检验的 p 值。

  • deterministic:这是一个布尔标志,表示确定性趋势。

让我们来看一个示例,在其中我们对一个时间序列应用了这两个检验(完整代码请参见02-Dealing_with_Non-Stationarity.ipynb):

# y_unit_root is the a synthetic unit root timeseries
y_unit_root.plot()
plt.show() 

图 7.4:M-K 检验

kendall_tau_res = check_trend(y_unit_root, confidence=0.05)
mann_kendall_res = check_trend(y_unit_root, confidence=0.05, mann_kendall=True)
print(f"Kendalls Tau: Trend: {kendall_tau_res.trend} | Direction: {kendall_tau_res.direction} | Deterministic: {kendall_tau_res.deterministic}")
print(f"Mann-Kendalls: Trend: {mann_kendall_res.trend} | Direction: {mann_kendall_res.direction} | Deterministic: {mann_kendall_res.deterministic}")
## Output 
>> Kendalls Tau: Trend: True | Direction: decreasing | Deterministic: False
>> Mann-Kendalls: Trend: True | Direction: decreasing | Deterministic: False 

如果你能生成一些时间序列,甚至选择一些你遇到的时间序列,使用这些函数来看它是如何工作的,以及结果如何帮助你,那将对你有很大帮助。相关的笔记本中有一些示例可以帮助你入门。你可以观察不同类型趋势下方向和斜率的不同。

现在我们知道如何检测趋势,让我们来看一下去趋势化。

去趋势转换

如果趋势是确定性的,去除趋势将为建模过程增添一些价值。在第三章分析与可视化时间序列数据中,我们讨论了去趋势化,因为它是我们进行分解时的一个重要部分。但像移动平均或 LOESS 回归这类技术有一个缺点——它们无法进行外推。然而,如果我们考虑的是确定性的线性(甚至是多项式)趋势,那么可以通过线性回归轻松估算。这里的一个额外优点是,识别出的趋势可以轻松进行外推。

这个过程很简单:我们将时间序列对时间的序数表示进行回归,并提取参数。一旦我们获得这些参数,就可以利用日期将趋势外推到未来的任何一点。Python 中的核心逻辑如下:

# y is the time series we are detrending
x = np.arange(len(y))
# degree is the degree of trend we are estimating. Linear, or polynomial
# Fitting a regression on y using a linearly increasing x
linear_params = np.polyfit(x=x, y=y, deg=degree)
# Extract trend using fitted parameters
trend = get_trend(y)
# Now this extracted trend can be removed from y
detrended = y - trend 

我们已经将这个去趋势器作为一个变换器实现,并且放在src.transforms.target_transformations.py中,名为DetrendingTransformer。你可以在 GitHub 仓库中查看我们是如何实现的。现在,让我们看看如何使用它:

from src.transforms.target_transformations import DetrendingTransformer
detrending_transformer = DetrendingTransformer(degree=1)
y_detrended = detrending_transformer.fit_transform(y, freq="1D") 

y_detrended将包含去趋势后的时间序列。要恢复原始时间序列,我们可以使用detrending_transformer调用inverse_transform。相关的笔记本中有示例和图表,展示去趋势如何改变时间序列。

最佳实践

我们必须小心趋势假设,特别是当我们进行长期预测时。即使是线性趋势假设,也可能导致不现实的预测,因为现实世界中的趋势不会永远以相同的方式延续。通常建议通过某种因子来减弱趋势,,以便在趋势外推时保持保守。这种减弱可以像一样简单。

使时间序列非平稳的另一个关键因素是季节性。让我们来看一下如何识别季节性并将其去除。

检测和修正季节性

绝大多数现实世界的时间序列具有季节性,比如零售销售、能源消耗等。通常,季节性的存在与否是领域知识的一部分。但是,当我们处理时间序列数据集时,领域知识会有所稀释。大多数时间序列可能表现出季节性,但这并不意味着数据集中的每个时间序列都有季节性。例如,在一个零售数据集中,可能有季节性商品,也可能有非季节性商品。因此,当处理时间序列数据集时,能够判断某个特定时间序列是否具有季节性是有价值的。

检测季节性

除了肉眼观察,还有两种常见的检测季节性的方法:自相关和快速傅里叶变换。两者都能自动识别季节性周期。为了讨论的方便,我们将介绍自相关方法,并探讨如何使用该方法来确定季节性。

自相关,如第三章《时间序列数据的分析与可视化》中所解释的,是时间序列与其滞后值之间的相关性。通常,我们期望在立即的滞后期(lag 1lag 2等)中相关性较高,随着时间推移,相关性逐渐减弱。但对于具有季节性的时间序列,我们也会看到在季节性周期中出现相关性峰值。

让我们通过一个例子来理解这一点。考虑一个合成时间序列,它只是白噪声与一个具有 25 周期季节性信号的正弦波组合(与我们之前在图 7.1中看到的季节性时间序列相同):

#WhiteNoise + Seasonal
y_random = pd.Series(np.random.randn(length), index=index)
t = np.arange(len(y_random))
y_seasonal = (y_random+1.9*np.cos((2*np.pi*t)/(length/4))) 

如果我们绘制这个时间序列的自相关函数ACF),它将如下所示(计算和绘制此图的代码可以在02-Dealing_with_Non-Stationarity.ipynb笔记本中找到):

图 7.4 – 具有 25 周期季节性信号的合成时间序列自相关图

图 7.5:具有 25 周期季节性信号的合成时间序列自相关图

我们可以看到,除了前几个滞后期外,自相关在接近季节周期时增加,并在季节性达到峰值时达到最高。我们可以利用自相关函数(ACF)这一特性来检测季节性。darts 是一个时间序列预测库,它实现了这种检测季节性的技术。但由于它是为 darts 的时间序列数据结构设计的,因此我们已将相同的逻辑改编为适用于常规的 pandas 序列,代码位于 src.transforms.stationary_utils.py,命名为 check_seasonality。该实现可以执行两种季节性检查。它可以接受一个 seasonality_period 作为输入,并验证数据中是否存在与该 seasonality_period 对应的季节性。如果我们没有提前提供 seasonality_period,它将返回一个统计学上显著的最短 seasonality_period

这个过程从高层次上来说,执行以下操作:

  1. 它计算 ACF。

  2. 它会在 ACF 中找到所有的相对最大值。相对最大值是指函数从增加变为减少的转折点。

  3. 它检查提供的 seasonal_period 是否为相对最大值。如果不是,我们就得出结论,表示该 seasonal_period 没有相关的季节性。

  4. 现在,我们假设 ACF 服从正态分布,并计算指定置信度下的上限。上限由以下公式给出:

其中 r[h] 是滞后 h 时的估计自相关,SE 是标准误差,而 是基于所需置信度的正态分布分位数,。SE 使用 Bartlett 公式近似(有关数学原理,请参考 进一步阅读 部分)。

  1. 每一个 seasonality_period 的候选值都会与这个上限进行比较,超过该上限的值被认为是统计上显著的。

除了时间序列本身,这个函数只有三个参数:

  • max_lag:指定应该包含在 ACF 中的最大滞后期,并在随后的季节性搜索中使用。这个值应该至少比预期的季节性周期多一个。

  • seasonal_period:这是我们从领域知识中给出的季节性周期的直觉,函数会验证这一假设。

  • confidence:这是标准的统计置信度水平,默认值为 0.05

让我们看看如何在我们之前在图 7.4中看到的相同数据上使用这个函数(季节周期为 25)。这将返回一个包含seasonal(表示季节性的布尔标志)和seasonal_periods(具有显著季节性的季节周期)的namedtuple

# Running the function without specifying seasonal period to identify the seasonality
seasonality_res = check_seasonality(y_seasonal, max_lag=60, confidence=0.05)
print(f"Seasonality identified for: {seasonality_res.seasonal_periods}")
## Output 
>> Seasonality identified for: 25 
This function can also be used to verify if your assumption about the seasonality is right.
# Running the function specifying seasonal period to verify
seasonality_res = check_seasonality(y_seasonal, max_lag=30, seasonal_period=25, confidence=0.05)print(f"Seasonality Test for 25th lag: {seasonality_res.seasonal}")
## Output 
>> Seasonality Test for 25th lag: True 

既然我们已经知道如何识别和检测季节性,接下来我们来谈谈去季节化。

去季节化转换

第三章时间序列数据的分析与可视化中,我们回顾了季节性分解的技术。我们可以在这里使用相同的技术,但只需稍作调整。之前,我们并不关心将季节性投射到未来。但在进行预测时,去季节化处理是至关重要的,必须能够将季节性投射到未来。幸运的是,季节性周期的前向投射是非常简单的。这是因为我们正在处理一个固定的季节性配置,它将在季节性周期中不断重复。例如,如果我们为一年的 12 个月份(按月频率的数据)识别了季节性配置,那么为这 12 个月提取的季节性将会在每 12 个月的周期中重复出现。

使用这一特性,我们在src.transforms.target_transformations.py中实现了一个转换器,命名为DeseasonalizingTransformer。我们需要注意一些参数和属性:

  • seasonality_extraction:此转换器支持两种提取季节性的方法——"period_averages",即通过季节性平均来估计季节性配置,以及"fourier_terms",即通过对傅里叶项进行回归来提取季节性。

  • seasonality_period:根据我们用于提取季节性的方法,这个参数可以是整数或字符串。如果是"period_averages",此参数表示季节性周期重复的周期数。如果是"fourier_terms",则表示从日期时间索引中提取的季节性。可以使用pandas datetime的属性,如week_of_daymonth等来指定最显著的季节性。类似于我们之前看到的FourierDecomposition,我们也可以省略此参数,并在fit/transform方法中提供自定义季节性。

  • n_fourier_terms:此参数指定在回归中要包含的傅里叶项的数量。增加此参数会使拟合的季节性变得更加复杂。

  • 该实现中没有去趋势处理,因为我们之前已经看到过DetrendingTransformer。该实现假设在使用fit函数之前,任何趋势都已经被去除。

让我们来看一下如何使用它:

from src.transforms.target_transformations import DeseasonalizingTransformer
deseasonalizing_transformer = DeseasonalizingTransformer(seasonality_extraction="period_averages",seasonal_period=25)
y_deseasonalized = deseasonalizing_transformer.fit_transform(y, freq="1D") 

y_deseasonalized将包含去季节化的时间序列。为了恢复到原始时间序列,我们可以使用inverse_transform函数。通常,这可以在做出预测后用于将季节性加回。

最佳实践

季节性建模可以单独进行,如这里所讨论的,或者使用我们在本章前面讨论的季节性特征。尽管最终的评估需要通过实证方法找到哪个方法更有效,但我们可以遵循一些经验法则/指导方针来决定优先级。

当我们有足够的数据时,让模型将季节性作为主要预测问题的一部分来学习似乎效果更好。但在数据不丰富的情况下,单独提取季节性然后再输入到机器学习模型中也能取得良好的效果。

当数据集具有不同的季节性(即不同时间序列有不同的季节性周期)时,应该根据情况进行处理。可以分别去季节化每个时间序列,或将全局机器学习模型拆分为多个本地模型,每个本地模型都有其独特的季节性模式。

我们之前讨论的最后一个方面是异方差性。让我们也快速看一下这个内容。

检测并修正异方差性

尽管这个名字听起来有些吓人,异方差性其实是一个足够简单的概念。它源自古希腊,hetero 意为 不同skedasis 意为 离散。正如它的名字所示,当一个变量的变异性在另一个变量中不同时,我们就定义为异方差性。在时间序列的背景下,当时间序列的变异性或离散度随着时间变化时,我们就说时间序列是异方差的。例如,让我们想象一个家庭在若干年的支出情况。在这些年里,这个家庭经历了从贫困到中产阶级,再到上层中产阶级的变化。当家庭贫困时,支出较少,仅限于必需品,因此支出的变异性也较小。但当他们接近上层中产阶级时,家庭有能力购买奢侈品,这在时间序列中造成了波动,从而导致了更高的变异性。如果我们回顾图 7.1,可以看到异方差时间序列的表现。

但是,除了视觉检查外,如果我们能够进行自动化的统计测试来验证异方差性,那就更好了。

检测异方差性

检测异方差性有很多方法,但我们将使用其中最流行的技术之一,即 1980 年由 Halbert White 提出的White 检验。White 检验通过辅助回归任务来检查方差是否恒定。我们先使用一些协变量进行初步回归,并计算该回归的残差。然后,我们用这些残差作为目标,协变量、协变量的平方以及协变量的交叉乘积作为特征,再进行一次回归。最终的统计量是通过这个辅助回归的R²值来估计的。要了解更详细的测试过程,请参考进一步阅读部分;对于严格的数学过程,相关研究论文已在参考文献部分中引用。

参考检查

要了解 White 检验的严格数学过程,请查看参考文献部分中标注为2的研究论文。

在时间序列的背景下,我们通过使用一个确定性趋势模型来调整这种公式。初步回归是通过将时间作为序数变量进行的,残差用于进行 White 检验。White 检验在statsmodelshet_white中有实现,我们将使用它来执行这个检验。het_white检验返回两个统计量和 p 值——拉格朗日乘数(Lagrangian Multiplier)和 F 统计量。拉格朗日乘数检验残差的方差与回归模型中的自变量之间是否存在关系。F 统计量比较原始模型与允许误差方差变化的模型的拟合优度。任何一个检验的 p 值小于置信度都表示存在异方差性。为了更保守起见,我们也可以使用两个检验,只有当两个 p 值都小于置信度时,才标记为异方差性。

我们已经将这一切封装到src.transforms.stationary_utils中的一个有用函数check_heteroscedasticity里,该函数只有一个附加参数——confidence。让我们来看一下该方法的核心实现(Python 代码):

import statsmodels.api as sm
# Fitting a linear trend regression
x = np.arange(len(y))
x = sm.add_constant(x)
model = sm.OLS(y,x)
results = model.fit()
# Using the het_white test on residuals
lm_stat, lm_p_value, f_stat, f_p_value = het_white(results.resid, x)
# Checking if both p values are less than confidence
if lm_p_value<confidence and f_p_value < confidence:
        hetero = True
    else:
        hetero = False 

现在,让我们看看如何使用这个函数:

from src.transforms.stationary_utils import check_heteroscedastisticity
check_heteroscedastisticity(y, confidence=0.05) 

它返回一个namedtuple,包含以下参数:

  • Heteroscedastic: 一个布尔值标志,指示是否存在异方差性

  • lm_statistic: 拉格朗日乘数LM)统计量

  • lm_p_value: 与 LM 统计量相关的 p 值

最佳实践

我们正在进行的异方差性检验仅考虑回归中的趋势,因此在季节性存在的情况下,可能效果不佳。建议在应用该函数之前先去季节性化数据。

检测异方差性是较为简单的部分。有一些变换方法试图去除异方差性,但每种方法都有其优缺点。我们来看看几种这样的变换。

对数变换

如其名称所示,对数变换是指对时间序列应用对数变换。对数变换有两个主要特性——方差稳定性和减少偏态——从而使数据分布更接近正态分布。在这两点中,我们更关注第一个特性,因为它有助于对抗异方差性。

对数变换通常被认为能减少数据的方差,从而消除数据中的异方差性。直观地看,我们可以将对数变换视为将直方图右侧的极端值拉回,同时拉伸直方图左侧的极低值。

但是已经证明,对数变换并不总是能稳定方差。除此之外,对数变换在机器学习中还带来了另一个挑战。现在,损失的优化发生在对数尺度上。由于对数变换在值范围的较低端压缩得比高端更多,因此学到的模型可能对较低范围的错误不如对较高范围的错误敏感。另一个主要缺点是,对数变换只能应用于严格正的数据。如果你的数据中有零或负值,你将需要通过加上某个常数 M 来偏移整个分布,然后应用变换。这也会在数据中引入一些扰动,可能会产生不利影响。

关键是我们在应用对数变换时需要小心。我们在 src.transforms.target_transformations.py 中实现了一个名为 LogTransformer 的变换器,它只有一个参数 add_one,该参数在变换之前加一,在逆变换后减一。Python 中的关键逻辑就像在变换中应用 np.log1pnp.log 函数,分别用 np.expm1np.exp 进行逆变换:

# Transform
np.log1p(y) if self.add_one else np.log(y)
# Inverse Transform
np.expm1(y) if self.add_one else np.exp(y)y_log = log_transformer.fit_transform(y) 

我们所做的就是将它封装成一个漂亮且易于使用的变换器。让我们看看如何使用它:

from src.transforms.target_transformations import LogTransformer
log_transformer = LogTransformer(add_one=True)
y_log = log_transformer.fit_transform(y) 

y_log 是对数变换后的时间序列。我们可以调用 inverse_transform 来恢复原始时间序列。

Box-Cox 变换

尽管对数变换有效且常见,但它是非常 的。但对数变换并不是我们能使用的唯一单调变换。还有许多其他的变换,如 y², 等等,它们统称为幂变换家族。这个家族中非常著名且广泛使用的一类变换就是 Box-Cox 变换:

和,

参考检查

Box 和 Cox 的原始研究论文在 参考文献 部分被引用,作为参考文献 3

直观地,我们可以看到 Box-Cox 变换是一个广义的对数变换。对数变换只是 Box-Cox 变换的特例(当 时)。在不同的 值下,它近似其他变换,如当 时为 y²,当 时为 ,当 时为 ,依此类推。当 时,没有重大变换。

我们之前提到的对数变换的许多缺点在这里也适用,但这些效果的程度有所不同,我们有一个参数,,可以帮助我们决定这些效果的合适程度。像对数变换一样,Box-Cox 变换也仅适用于严格正值的数据。同样,必须对数据分布加上一个常数来进行偏移。该参数的另一面是,它增加了一个需要调节的超参数。

有一些自动化方法可以找到任何数据分布的最佳。其中之一是通过最小化数据分布的对数似然,假设数据服从正态分布。因此,基本上,我们将做的事情是找到最佳的,使数据分布尽可能接近正态分布。这种优化已经在流行的实现中实现,例如scipy中的scipy.special模块中的boxcox函数。

另一种找到最佳的方法是使用 Guerrero 方法,这种方法通常适用于时间序列。在此方法中,我们不是试图将数据分布调整为正态分布,而是尝试最小化时间序列中不同子序列之间的变异性,这些子序列在时间序列中是同质的。子序列的定义有些主观,但通常我们可以安全地认为子序列是季节性长度。因此,我们将要做的是最小化时间序列在不同季节性周期之间的变异性。

参考文献检查

提出 Guerrero 方法的研究论文已在参考文献部分的参考文献4中引用。

这两种优化方法的工作方式有显著差异,我们在使用它们时需要小心。如果我们的主要关注点是去除时间序列的异方差行为,Guerrero 方法是我们可以使用的方法。

我们在src.transforms.target_transformations.py中提供了一个名为BoxCoxTransformer的转换器。我们需要注意一些参数和属性:

  • box_cox_lambda:这是 Box-Cox 变换中使用的参数。如果设置为None,实现将自动找到最佳的

  • optimization:可以是guerrero(默认设置)或loglikelihood。这决定了参数的估计方式。

  • seasonal_period:这是使用 Guerrero 方法寻找最佳参数的输入。严格来说,这是子序列的长度,通常取为季节性周期。

  • bounds:这是另一个参数,用于通过 Guerrero 方法控制优化。这是一个包含下界和上界的元组,用于搜索最佳的参数。

  • add_one:这是一个标志,在应用对数变换之前将一加到序列中,以避免对数为零。

Transformer 中实现的核心逻辑如下:

## Fit Process
# Add one if needed
y = self._add_one(y)
# Find optimum box cox lamda if optimization is Guerrero
self.boxcox_lambda = self._optimize_lambda(y)
## Transform Process
boxcox(y.values, lmbda=self.boxcox_lambda)
## Inverse Transform
self._subtract_one(inv_boxcox(y.values, self.boxcox_lambda)) 

现在,让我们看看如何使用它:

from src.transforms.target_transformations import BoxCoxTransformer
boxcox_transformer = BoxCoxTransformer()
y_boxcox = boxcox _transformer.fit_transform(y) 

y_boxcox 将包含经过 Box-Cox 变换的时间序列。若要恢复到原始时间序列,可以使用 inverse_transform 函数。

Box-Cox 变换和对数变换都可以用于修正异方差性。但是,如前所述,对数变换是一种强烈的变换,而 Box-Cox 变换则为我们提供了另一种手段,允许我们调整和优化变换,以适应我们的数据。我们可以将 Box-Cox 看作是一种灵活的对数变换,可以根据需要调整,以实现适合我们数据的正确变换。请查看笔记本,在其中你可以查看并尝试这些不同的变换,感受它们对数据的影响。

当我们面对大规模的预测问题时,我们将需要分析成百上千甚至数百万个时间序列,才能进行预测。在这种情况下,AutoML 方法显得尤为重要,才能保持实用性。

AutoML 方法用于目标变换

到目前为止,我们已经讨论了许多使序列 稳定的方法(这里我们使用稳定的非数学意义),例如去趋势、去季节性、差分和单调变换。我们还查看了统计测试,以检查时间序列中是否存在趋势、季节性等。因此,下一步自然是将这些方法整合起来,以自动化的方式执行这些变换,并在可能的情况下选择合适的默认值。这正是我们所做的,并在 src.transforms.target_transformations 中实现了一个 AutoStationaryTransformer

以下流程图以自动化方式解释了这一逻辑:

图 7.5 – AutoStationaryTransformer 的流程图

图 7.6:AutoStationaryTransformer 的流程图

我们在这个实现中排除了差分,原因有两个:

  • 在预测的背景下,差分带来了相当大的技术债务。如果进行差分,你本质上会使得进行多步预测变得更加困难。虽然可以做到,但它更加困难且灵活性较差。

  • 差分可以看作是我们所做的事情的另一种方式。因为差分去除了线性趋势,而季节性差分也去除了季节性。因此,对于自回归时间序列来说,差分可以做很多事情,值得作为独立的变换来使用。

现在,让我们看看可以使用哪些参数来调整 AutoStationaryTransformer

  • confidence:这是统计测试的置信水平,默认值为 0.05

  • seasonal_period:这是季节性周期重复的周期数。如果设置为 None,则 seasonal_period 将从数据中推断出来,默认值为 None

  • seasonality_max_lags: 仅在未提供seasonality_period时使用。这设置了我们搜索季节性的最大滞后。默认为None

  • trend_check_params: 这些是用于趋势统计检验的参数。check_trend默认为{"mann_kendall": False}

  • detrender_params: 这些是传递给DetrendingTransformer的参数。默认为{"degree":1}

  • deseasonalizer_params: 传递给DeseasonalizingTransformer的参数。seasonality_extraction被固定为period_averages

  • box_cox_params: 这些是传递给BoxCoxTransformer的参数。它们默认为{"optimization": "guerrero"}

让我们将这个AutoStationaryTransformer应用于一个合成时间序列,看看它的效果如何(完整代码在相关笔记本中):

from src.transforms.target_transformations import AutoStationaryTransformer
auto_stationary = AutoStationaryTransformer(seasonal_period=25)
y_stat = auto_stationary.fit_transform(y_final) 

图 7.7:AutoStationaryTransformer—之前和之后

我们可以看到AutoStationaryTransformer已经对时间序列进行了去季节化和去趋势化处理。在这个特定的例子中,AutoStationaryTransformer应用了去趋势化、去季节化和 Box-Cox 转换。

现在,让我们将这种自动转换应用于我们一直在处理的数据集:

train_df = pd.read_parquet(preprocessed/"selected_blocks_train_missing_imputed_feature_engg.parquet")
transformer_pipelines = {}
for _id in tqdm(train_df["LCLid"].unique()):
    #Initialize the AutoStationaryTransformer with a seasonality period of 48*7
    auto_stationary = AutoStationaryTransformer(seasonal_period=48*7)
    #Creating the timeseries with datetime index
    y = train_df.loc[train_df["LCLid"]==_id, ["energy_consumption","timestamp"]].set_index("timestamp")
    #Fitting and transforming the train
    y_stat = auto_stationary.fit_transform(y, freq="30min")
    # Setting the transformed series back to the dataframe
    train_df.loc[train_df["LCLid"]==_id, "energy_consumption"] = y_stat.values
    #Saving the pipeline
    transformer_pipelines[_id] = auto_stationary 

执行此操作的代码分为两个笔记本,分别为02-Dealing_with_Non-Stationarity.ipynb02a-Dealing_with_Non-Stationarity-Train+Val.ipynb,位于Chapter06文件夹中。前者对训练数据进行自动稳态转换,而后者对训练和验证数据合并进行转换。这是为了模拟我们如何对验证数据进行预测(仅使用训练数据进行训练),以及对测试数据进行预测(在训练中使用训练和验证数据)。

这个过程稍微耗时。我建议您运行笔记本,吃午餐或小吃,然后回来。一旦完成,02-Dealing_with_Non-Stationarity.ipynb笔记本将保存一些文件:

  • selected_blocks_train_auto_stat_target.parquet: 一个 DataFrame,其索引为LCLidtimestamp,而转换后的目标

  • auto_transformer_pipelines_train.pkl: 一个 Python 字典,包含每个LCLidAutoStationaryTransformer,以便我们将来可以反转转换

02a-Dealing_with_Non-Stationarity-Train+Val.ipynb笔记本还保存了用于训练和验证数据集的相应文件。

我们正在处理的数据集几乎没有趋势,并且在整个过程中非常稳定。这些转换的影响将在具有强趋势和异方差性的时间序列中更为明显。

最佳实践

这种在建模之前进行的显式去趋势和去季节性化也可以视为一种增强方法。这应该被视为另一种将所有这些因素一起建模的替代方法。在某些情况下,让模型通过数据驱动的方式从头到尾学习,可能比通过显式去趋势和去季节性化注入这些强烈的归纳偏见效果更好,反之亦然。交叉验证的测试得分应该始终拥有最终的发言权。

恭喜你顺利完成了这一章,里面充满了新概念、一些统计学内容和数学内容。从应用机器学习模型于时间序列的角度来看,本章的概念将对提升你的模型水平非常有帮助。

小结

在上一章深入实践后,我们停留在那儿,继续回顾了像平稳性这样的概念,以及如何处理这些非平稳的时间序列。我们学习了可以显式处理非平稳时间序列的技术,例如差分、去趋势、去季节性等。为了将这些内容结合起来,我们看到了自动转换目标的方式,学习了如何使用提供的实现,并将其应用于我们的数据集。现在我们掌握了将时间序列有效转换为机器学习数据集所需的技能,在下一章中,我们将开始使用我们创建的特征对数据集应用一些机器学习模型。

参考文献

以下是本章的参考文献:

  1. Bayazit, M. 和 Önöz, B. (2007),在趋势分析中是否需要预处理?,《水文学科学期刊》,52:4,611–624。 doi.org/10.1623/hysj.52.4.611.

  2. White, H. (1980),异方差一致的协方差矩阵估计量及异方差性的直接检验。《计量经济学》 第 48 卷,第 4 期(1980 年 5 月),817–838 页(22 页)。 doi.org/10.2307/1912934.

  3. Box, G. E. P. 和 Cox, D. R. (1964),变换分析。《皇家统计学会学报》B 系列,26,211–252。 www.ime.usp.br/~abe/lista/pdfQWaCMboK68.pdf.

  4. Guerrero, Victor M. (1993), 通过幂变换支持的时间序列分析。《预测学期刊》,第 12 卷,第 1 期,37–48。 onlinelibrary.wiley.com/doi/10.1002/for.3980120104.

进一步阅读

要了解本章所涉及的更多主题,请参考以下资源:

加入我们的 Discord 社区

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

packt.link/mts

第八章:使用机器学习模型进行时间序列预测

在上一章中,我们开始将机器学习作为解决时间序列预测问题的工具。我们讨论了几种技术,如时间延迟嵌入和时间嵌入,这两者将时间序列预测问题作为机器学习范式中的经典回归问题。在本章中,我们将详细探讨这些技术,并通过本书中一直使用的伦敦智能电表数据集,实践这些技术。

本章将涵盖以下主题:

  • 使用机器学习模型进行训练和预测

  • 生成单步预测基准

  • 标准化代码用于训练和评估机器学习模型

  • 为多个家庭进行训练和预测

技术要求

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

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

  • 02-Preprocessing_London_Smart_Meter_Dataset.ipynb位于Chapter02

  • 01-Setting_up_Experiment_Harness.ipynb位于Chapter04

  • 01-Feature_Engineering.ipynb位于Chapter06

  • 02-Dealing_with_Non-Stationarity.ipynb位于Chapter07

  • 02a-Dealing_with_Non-Stationarity-Train+Val.ipynb位于Chapter07

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

使用机器学习模型进行训练和预测

第五章时间序列预测作为回归中,我们讨论了监督式机器学习的示意图(图 5.2)。在示意图中,我们提到监督学习问题的目的是找到一个函数,,其中 是预测值,X 是作为输入的特征集, 是模型参数,h 是理想函数的近似。在本节中,我们将更详细地讨论 h,并看看如何使用不同的机器学习模型来估计它。

h 是任何近似理想函数的函数,但可以视为一个函数族中所有可能函数的元素。更正式地,我们可以这样说:

这里,H 是我们也称之为模型的一个函数族。例如,线性回归是一种模型或函数族。对于每个系数值,线性回归模型都会给出一个不同的函数,H 就是线性回归模型可以生成的所有可能函数的集合。

有许多种函数或模型可供选择。为了更全面地理解这一领域,我们需要参考其他机器学习资源。进一步阅读部分包含了一些可能帮助您开始学习的资源。至于本书的范围,我们将其狭义地定义为应用机器学习模型进行预测,而不是机器学习的全部内容。尽管我们可以使用任何回归模型,但我们将仅回顾几个流行且有用的时间序列预测模型,并观察它们的实际应用。我们鼓励您自己探索其他算法,熟悉它们。然而,在查看不同模型之前,我们需要再次生成几个基准。

生成单步预测基准

我们在第四章设置强基准预测中回顾并生成了几个基准模型。但有一个小问题——预测范围。在第六章时间序列预测的特征工程中,我们讨论了机器学习模型一次只能预测一个目标,而且我们坚持使用单步预测。我们之前生成的基准并非单步预测,而是多步预测。生成单步预测的基准算法,如 ARIMA 或 ETS,需要我们根据历史数据进行拟合,预测一步,然后再次拟合,增加一天数据。以这种迭代方式对测试或验证期进行预测需要我们迭代约 1,440 次(每天 48 个数据点,30 天),并且需要对我们选择的数据集中的所有家庭(在我们的例子中是 150 个)进行此操作。这将需要相当长的时间来计算。

我们选择了简单方法和季节性简单方法(第四章设置强基准预测),这两种方法可以作为原生的 pandas 方法实现,用作生成单步预测的基准方法。

简单预测在单步预测中表现异常好,可以被认为是一个强有力的基准。在Chapter08文件夹中,有一个名为00-Single_Step_Backtesting_Baselines.ipynb的笔记本,它生成这些基准并将其保存到磁盘。让我们现在运行这个笔记本。该笔记本会为验证集和测试集生成基准,并将预测、指标和聚合指标保存到磁盘。测试期的聚合指标如下:

图 8.1 – 单步基准的聚合指标

图 8.1:单步基准的聚合指标

为了简化这些模型的训练和评估,我们在整个过程中使用了统一的结构。让我们快速回顾一下这个结构,以便您能够更好地跟随笔记本的内容。

标准化代码以训练和评估机器学习模型

训练机器学习模型时有两个主要因素 —— 数据模型 本身。因此,为了标准化流程,我们定义了三个配置类(FeatureConfigMissingValueConfigModelConfig),以及一个封装类(MLForecast),用于 scikit-learn 风格的估算器(.fit - .predict),以使过程更加顺畅。让我们逐一了解它们。

笔记本提示:

要跟随代码进行操作,请使用 Chapter08 文件夹中的 01-Forecasting_with_ML.ipynb 笔记本和 src 文件夹中的代码。

FeatureConfig

FeatureConfig 是一个 Python dataclass,定义了一些在处理数据时必需的关键属性和函数。例如,连续型、分类型和布尔型列需要分别进行预处理,才能输入机器学习模型。让我们看看 FeatureConfig 包含了什么:

  • date:一个必填列,设置 DataFrame 中 date 列的名称。

  • target:一个必填列,设置 DataFrame 中 target 列的名称。

  • original_target:如果 target 包含已转换的目标(如对数转换、差分等),则 original_target 指定没有经过转换的目标列的名称。这对于计算依赖于训练历史的指标,如 MASE,非常重要。如果未提供此参数,则默认认为 targetoriginal_target 是相同的。

  • continuous_features:一个连续特征列表。

  • categorical_features:一个分类特征列表。

  • boolean_features:一个布尔特征列表。布尔特征是分类特征,但只有两个独特值。

  • index_cols:在预处理过程中设置为 DataFrame 索引的列列表。通常,我们会将日期时间以及在某些情况下时间序列的唯一 ID 作为索引。

  • exogenous_features:一个外生特征列表。DataFrame 中的特征可能来自特征工程过程,如滞后或滚动特征,也可能来自外部数据源,例如我们数据集中的温度数据。这是一个可选字段,允许我们将外生特征与其余特征区分开来。此列表中的项应是 continuous_featurescategorical_featuresboolean_features 的子集。

除了对输入进行一些验证外,类中还有一个名为 get_X_y 的有用方法,包含以下参数:

  • df:一个包含所有必要列的 DataFrame,包括目标列(如果可用)。

  • categorical:一个布尔标志,用于指示是否包含分类特征。

  • exogenous:一个布尔标志,用于指示是否包含外生特征。

该函数返回一个元组 (features, target, original_target)

我们只需像初始化任何其他类一样,初始化该类,并将特征名称分配给类的参数。包含所有特征的完整代码可在随附的笔记本中找到。

在设置FeatureConfig数据类后,我们可以将任何定义了特征的 DataFrame 传递给get_X_y函数,获取特征、目标和原始目标:

train_features, train_target, train_original_target = feat_config.get_X_y(
    sample_train_df, categorical=False, exogenous=False
) 

如你所见,我们在这里没有使用类别特征或外生特征,因为我想专注于核心算法,并展示它们如何成为我们之前看到的其他经典时间序列模型的直接替代。我们将在第十五章全球深度学习预测模型策略中讨论如何处理类别特征。

MissingValueConfig

另一个关键设置是如何处理缺失值。我们在第三章分析与可视化时间序列数据中看到了一些填充时间序列上下文中的缺失值的方法,并且我们已经填充了缺失值并准备好了数据集。但是,在将时间序列转换为回归问题所需的特征工程中,还会产生一些缺失值。例如,在创建滞后特征时,数据集中的最早日期将没有足够的数据来创建滞后,因此会留空。

最佳实践:

尽管用零或均值填充是大多数数据科学家社区的默认方法或常用方法,但我们应该始终尽力尽可能智能地填充缺失值。在滞后特征方面,填充零可能会扭曲特征。与其填充零,不如使用向后填充(使用列中的最早值向后填充),这可能会更合适。

一些机器学习模型自然处理空值或NaN特征,而对于其他机器学习模型,我们需要在训练之前处理这些缺失值。如果我们能定义一个config,为一些预期会有NaN信息的列设置如何填充这些缺失值,那将非常有帮助。MissingValueConfig是一个 Python dataclass,正是用来做这件事的。让我们看看它包含了什么:

  • bfill_columns:需要使用向后填充策略来填充缺失值的列名列表。

  • ffill_columns:需要使用向前填充策略来填充缺失值的列名列表。如果某列名同时出现在bfill_columnsffill_columns中,则该列首先使用向后填充,剩余的缺失值则使用向前填充策略。

  • zero_fill_columns:需要用零填充的列名列表。

填充缺失值的顺序是bfill_columns,然后是ffill_columns,最后是zero_fill_columns。作为默认策略,数据类使用列均值填充缺失值,因此即使你没有为某列定义任何策略,缺失值也会使用列均值填充。有一个叫做impute_missing_values的方法,它接受 DataFrame 并根据指定的策略填充空单元格。

ModelConfig

ModelConfig是一个 Python 的dataclass,它包含了关于建模过程的一些细节,比如是否对数据进行标准化、是否填充缺失值等等。让我们详细看一下它包含的内容:

  • model: 这是一个必需的参数,可以是任何 scikit-learn 风格的估算器。

  • name: 模型的字符串名称或标识符。如果未使用该参数,它将恢复为作为model传递的类名称。

  • normalize: 一个布尔标志,用于设置是否对输入应用StandardScaler

  • fill_missing: 一个布尔标志,用于设置是否在训练之前填充空值。一些模型能够自然处理NaN,而其他模型则不能。

  • encode_categorical: 一个布尔标志,用于设置是否在拟合过程中将类别列进行编码。如果为False,则期望单独进行类别编码,并将其作为连续特征的一部分包含。

  • categorical_encoder: 如果encode_categoricalTrue,则categorical_encoder是我们可以使用的 scikit-learn 风格的编码器。

让我们看看如何定义ModelConfig数据类:

model_config = ModelConfig(
    model=LinearRegression(),
    name="Linear Regression",
    normalize=True,
    fill_missing=True,
) 

它只有一个方法,clone,该方法会将估算器和配置克隆到一个新的实例中。

MLForecast

最后但同样重要的是,我们有一个围绕 scikit-learn 风格模型的包装类。它使用我们之前讨论的不同配置来封装训练和预测函数。让我们看看初始化模型时有哪些可用的参数:

  • model_config: 我们在ModelConfig部分讨论过的ModelConfig类的实例。

  • feature_config: 我们之前讨论过的FeatureConfig类的实例。

  • missing_config: 我们之前讨论过的MissingValueConfig类的实例。

  • target_transformer: 来自src.transforms的目标转换器实例。它应支持fittransforminverse_transform。它还应返回带有日期时间索引的pd.Series,以确保无错误运行。如果我们单独执行了目标转换,那么在预测时也会使用它来执行inverse_transform

MLForecast有一些功能,可以帮助我们管理模型生命周期,一旦初始化。让我们看一下。

拟合函数

fit函数的目的与 scikit-learn 的fit函数相似,但它额外处理了标准化、类别编码和目标转换,使用了三个配置中的信息。该函数的参数如下:

  • X: 这是一个包含要在模型中使用的特征(作为列)的 pandas DataFrame。

  • y: 这是目标,可以是 pandas DataFrame、pandas Series 或 numpy 数组。

  • is_transformed: 这是一个布尔参数,告诉我们目标是否已经被转换。如果为True,即使我们已经用target_transformer初始化了对象,fit方法也不会转换目标。

  • fit_kwargs:这是一个 Python 字典,包含需要传递给估算器fit函数的关键字参数。

predict 函数

predict函数处理推断。它是对 scikit-learn 估算器的predict函数的封装,但与fit一样,它还做了一些其他事情,比如标准化、分类编码和反转目标转换。这个函数只有一个参数:

  • X:一个包含特征的 pandas DataFrame,作为模型的列。DataFrame 的索引将传递到预测中。

feature_importance 函数

feature_importance函数从模型中提取特征重要性(如果有的话)。对于线性模型,它提取系数;而对于基于树的模型,它提取内建的特征重要性并返回一个排序后的 DataFrame。

用于评估模型的辅助函数

虽然我们之前看到的其他函数处理了核心训练和预测,我们还希望评估模型、绘制结果等等。我们在笔记本或代码库中也定义了这些函数。下面的函数是用来在笔记本中评估模型的:

def evaluate_model(
    model_config,
    feature_config,
    missing_config,
    train_features,
    train_target,
    test_features,
    test_target,
):
    ml_model = MLForecast(
        model_config=model_config,
        feature_config=feat_config,
        missing_config=missing_value_config,
    )
    ml_model.fit(train_features, train_target)
    y_pred = ml_model.predict(test_features)
    feat_df = ml_model.feature_importance()
    metrics = calculate_metrics(test_target, y_pred, model_config.name, train_target)
    return y_pred, metrics, feat_df 

这为我们提供了评估所有不同模型的标准方法,并且能够在大规模上自动化这个过程。我们还有一个用于计算指标的函数calculate_metrics,它定义在src/forecasting/ml_forecasting.py中。

本书中提供的标准实现并非一刀切的方法,而是最适合本书的流程和数据集的方法。请不要将其视为一个强健的库,而是一个很好的起点和指南,帮助你开发自己的代码。

现在我们有了基准线和应用不同模型的标准方法,让我们回到讨论不同模型的内容。接下来的讨论中,我们暂时不考虑时间因素,因为我们已将时间序列预测问题转化为回归问题,并将时间作为问题的特征(滞后和滚动特征)进行处理。

线性回归

线性回归是一类函数,具有以下形式:

在这里,k是模型中的特征数量,且是模型的参数。每个特征都有一个,还有一个,我们称之为截距,它是从数据中估算出来的。实质上,输出是特征向量X[i]的线性组合。顾名思义,这是一个线性函数。

模型参数可以通过数据 D(X[i,] y[i])来估算,使用优化方法和损失函数,最常用的估算方法是普通最小二乘法OLS)。在这里,我们找到模型参数,它最小化残差平方和(均方误差MSE)):

这里的损失函数非常直观。我们本质上是在最小化训练样本与我们预测点之间的距离。平方项作为一种技术,能够避免正负误差相互抵消。除了损失函数的直观性之外,另一个广泛选择它的原因是最小二乘法有解析解,因此我们不需要使用像梯度下降这样计算密集型的优化技术。

线性回归深深植根于统计学,在正确的假设下,它可以成为一个强大的工具。线性回归通常与五个假设相关,如下所示:

  • 自变量和因变量之间的关系是线性的。

  • 误差服从正态分布。

  • 误差的方差在所有自变量的值范围内是恒定的。

  • 误差中没有自相关性。

  • 自变量之间几乎没有相关性(多重共线性)。

但是,除非你担心使用线性回归来得出预测区间(预测结果可能出现在某个区间内的概率),否则我们可以在一定程度上忽略除第一个假设外的所有假设。

线性假设(第一个假设)很重要,因为如果变量之间不是线性相关的,会导致欠拟合,从而表现不佳。我们可以通过将输入投影到更高维空间中,在一定程度上解决这个问题。从理论上讲,我们可以将一个非线性问题投影到更高维空间,在那里问题变成线性。例如,考虑一个非线性函数,。如果我们在的输入空间中运行线性回归,我们知道得到的模型将会严重欠拟合。但如果我们通过使用多项式变换将输入空间从投影到,那么y的函数将成为完美的线性拟合。

多重共线性假设(最后一个假设)与线性函数的拟合部分相关,因为当我们有高度相关的自变量时,估计的系数会变得非常不稳定且难以解释。拟合函数仍然能够很好地工作,但由于存在多重共线性,即使是输入中的微小变化也会导致系数的幅度和符号发生变化。如果你正在使用纯线性回归,检查多重共线性是一个最佳实践。这通常是时间序列中的一个问题,因为我们提取的特征,如滞后和滚动特征,可能会相互关联。因此,在使用和解释时间序列数据上的线性回归时,我们需要格外小心。

现在,让我们看看如何使用线性回归并评估来自验证数据集的一个样本家庭的拟合效果:

from sklearn.linear_model import LinearRegression
model_config = ModelConfig(
    model=LinearRegression(),
    name="Linear Regression",
    # LinearRegression is sensitive to normalized data
    normalize=True,
    # LinearRegression cannot handle missing values
    fill_missing=True,
)
y_pred, metrics, feat_df = evaluate_model(
    model_config,
    feat_config,
    missing_value_config,
    train_features,
    train_target,
    test_features,
    test_target,
) 

单步预测看起来不错,且已经优于天真预测(MAE = 0.173):

图 8.2 – 线性回归预测

图 8.2:线性回归预测

模型的系数,(可以通过已训练的 scikit-learn 模型的coef_属性访问),展示了每个特征对输出的影响程度。因此,提取并绘制这些系数为我们提供了对模型的第一层可视化。让我们来看看模型的系数:

图 8.3 – 线性回归的特征重要性(前 15 名)

图 8.3:线性回归的特征重要性(前 15 名)

如果我们查看特征重要性图表中的Y轴,可以看到它是以十亿为单位的,因为某些特征的系数以十亿的数量级存在。我们还可以看到这些特征是基于傅里叶级数的特征,并且彼此相关。尽管我们有很多系数在十亿级别,但我们可以发现它们分布在零的两侧,因此它们在函数中实际上会相互抵消。这就是我们之前讨论的多重共线性问题。我们可以通过去除多重共线特征并进行某种特征选择(前向选择或后向消除)来使线性模型变得更好。

但是,既然如此,不如我们来看看可以对线性模型进行的一些修改,这些修改能使其在面对多重共线性和特征选择时更为稳健。

正则化线性回归

我们在第五章《时间序列预测与回归》中简要讨论了正则化,并提到正则化在一般意义上是我们在学习过程中对学习函数复杂度进行约束的任何手段。线性模型变得更加复杂的一种方式是系数的幅度很大。例如,在线性拟合中,我们的系数为 200 亿。这个特征的任何微小变化都会导致预测结果出现巨大波动。直观地看,如果我们有一个较大的系数,函数就会变得更加灵活和复杂。我们可以通过应用正则化(如权重衰减)来解决这个问题。权重衰减是指在损失函数中加入一个惩罚项,用于惩罚系数的幅度。损失函数,即残差平方和,现在变为如下形式:

这里,W 是权重衰减, 是正则化的强度。

W 通常是权重矩阵的范数。在线性代数中,矩阵的范数是衡量其元素大小的一种方式。矩阵有许多种范数,但用于正则化的两种最常见的范数是 L1 范数和 L2 范数。当我们使用 L1 范数来正则化线性回归时,我们称之为 lasso 回归,而当我们使用 L2 范数时,我们称之为 ridge 回归。当我们应用权重衰减正则化时,我们迫使系数变小,这意味着它也充当了内部特征选择的作用,因为那些没有提供太多价值的特征会得到非常小或零的系数(取决于正则化类型),这意味着它们在最终的函数中贡献甚微甚至没有贡献。

L1 范数定义为矩阵各元素绝对值的和。对于权重衰减正则化,L1 范数可以表示为如下形式:

L2 范数定义为矩阵各个值的平方和。对于权重衰减正则化,L2 范数可以表示为如下形式:

通过将这个项添加到线性回归的损失函数中,我们迫使系数变小,因为优化器在减少 RSS 的同时,还会激励它减少 W

我们思考正则化的另一个方式是从线性代数和几何学的角度来看。

接下来的部分讨论正则化的几何直觉。虽然这将使你对正则化的理解更加深刻,但它并不是理解本书其余部分的必备条件。所以,如果你时间紧迫或者以后有时间再回来复习,你可以跳过接下来的部分,只阅读 关键点 提示。

正则化——几何视角

如果我们从稍微不同的角度来看 L1 和 L2 范数,我们会发现它们是距离的度量。

B表示线性回归中所有系数的向量,。向量是一个数字数组,但从几何学的角度来看,它也是从原点到n维坐标空间中某一点的箭头。现在,L2 范数仅仅是该向量 B 所定义的空间中从原点到该点的欧几里得距离。L1 范数是从原点到该点的曼哈顿距离或出租车距离,该点也由向量 B 定义。让我们通过图示来看看这一点:

图 8.4 – 欧几里得距离与曼哈顿距离

图 8.4:欧几里得距离与曼哈顿距离

欧几里得距离是从原点到该点的直线距离。但如果我们只能平行于两个坐标轴移动,那么我们首先需要沿一个坐标轴走距离 ,然后再沿另一个坐标轴走距离 。这就是曼哈顿距离。

假设我们在一个城市(例如曼哈顿),那里建筑物排布成方形街区,直路相交成直角,我们想从 A 点到 B 点。欧几里得距离是从 A 点到 B 点的直接距离,在现实中,只有在我们能在建筑物顶上进行跑酷的情况下才能实现。而曼哈顿距离则是出租车沿着直角道路从 A 点到 B 点的实际行驶距离。

为了进一步发展对 L1 和 L2 范数的几何直觉,我们进行一个思想实验。如果我们在二维空间中移动点 ,同时保持欧几里得距离或 L2 范数不变,我们将得到一个以原点为中心的圆。在三维空间中,这将变成一个球体,在 n 维空间中则是一个超球体。如果我们保持 L1 范数不变而追踪相同的路径,我们将得到一个以原点为中心的菱形。在三维空间中,这将变成一个立方体,在 n 维空间中是一个超立方体。

现在,当我们优化权重时,除了减少损失函数的主要目标外,我们还鼓励系数保持在一个定义的距离(范数)内,远离原点。从几何角度来看,这意味着我们要求优化找到一个向量 ,该向量在最小化损失函数的同时,也保持在由范数定义的几何形状(圆形或正方形)内。我们可以在以下图表中看到这一点:

图 8.5 – 使用 L1 范数(套索回归)与 L2 范数(岭回归)进行正则化

图 8.5:使用 L1 范数(套索回归)与 L2 范数(岭回归)进行正则化

图中的同心圆是损失函数的等高线,最内圈为最低点。随着我们向外移动,损失增加。因此,正则化回归不会选择 ,而是选择一个与范数几何形状相交的

这种几何解释也使得理解岭回归和套索回归之间的另一个关键区别变得更容易。由于 L1 范数的作用,套索回归会产生一个稀疏解。之前,我们提到过权重衰减正则化会进行隐式特征选择。但根据你是应用 L1 还是 L2 范数,隐式特征选择的方式是不同的。

关键点:

对于 L2 范数,较不相关特征的系数会被推近零,但不会完全为零。该特征仍然会在最终的函数中发挥作用,但其影响会微乎其微。另一方面,L1 范数则将这些特征的系数完全推向零,从而得到一个稀疏解。因此,L1 正则化促进稀疏性和特征选择,而 L2 正则化通过将系数缩小到接近零来减少模型复杂度,但不一定会完全消除任何系数。

使用正则化的几何解释可以更好地理解这一点。在优化中,通常在极值点或角落处找到有趣的点。圆形没有角落,因此创建了 L2 范数;极小值可以位于圆的任何边缘。但对于菱形,我们有四个角,极小值会出现在这些角落。因此,使用 L2 范数时,解可以接近零,但不一定是零。然而,使用 L1 范数时,解会出现在角落处,在那里系数可以被压缩到绝对零。

现在,让我们看看如何使用岭回归并评估我们验证数据集中一个家庭的拟合情况:

from sklearn.linear_model import RidgeCV
model_config = ModelConfig(
    model=RidgeCV(),
    name="Ridge Regression",
    # RidgeCV is sensitive to normalized data
    normalize=True,
    # RidgeCV does not handle missing values
    fill_missing=True
)
y_pred, metrics, feat_df = evaluate_model(
    model_config,
    feat_config,
    missing_value_config,
    train_features,
    train_target,
    test_features,
    test_target,
) 

让我们来看看RidgeCV的单步预测。它看起来与线性回归非常相似。即使是 MAE,对于这个家庭来说也完全相同:

图 8.6 – 岭回归预测

图 8.6:岭回归预测

但用 L2 正则化模型来看系数是很有意思的。让我们来看看模型的系数:

图 8.7 – 岭回归特征重要性(前 15 名)

图 8.7:岭回归特征重要性(前 15 名)

现在,Y轴看起来合理且较小。多重共线性特征的系数已经缩小到一个更合理的水平。像滞后特征这样的特征,理应具有较大影响力,已经占据了前几位。正如你可能记得的,在线性回归中(图 8.3),这些特征被傅里叶特征的巨大系数所压制。我们这里只绘制了前 15 个特征,但如果查看完整的列表,你会看到很多特征的系数接近零。

现在,让我们尝试对这个家庭样本进行套索回归:

from sklearn.linear_model import LassoCV
model_config = ModelConfig(
    model=LassoCV(),
    name="Lasso Regression",
    # LassoCV is sensitive to normalized data
    normalize=True,
    # LassoCV does not handle missing values
    fill_missing=True
)
y_pred, metrics, feat_df = evaluate_model(
    model_config,
    feat_config,
    missing_value_config,
    train_features,
    train_target,
    test_features,
    test_target,
) 

让我们来看看LassoCV的单步预测。像岭回归一样,与线性回归几乎没有视觉差异:

图 8.8 – 套索回归预测

图 8.8:套索回归预测

让我们来看看模型的系数:

图 8.9 – 套索回归特征重要性(前 15 名)

图 8.9:套索回归特征重要性(前 15 名)

系数与岭回归非常相似,但如果查看完整的系数列表(在笔记本中),你会看到很多特征的系数为零。

即使在 MAE、MSE 等指标相同的情况下,岭回归或套索回归仍然比线性回归更受偏好,因为正则化回归带来了额外的稳定性和鲁棒性,尤其是在预测中,多重共线性几乎总是存在。但我们需要记住,所有的线性回归模型仍然只是捕捉线性关系。如果数据集具有非线性关系,那么线性回归的拟合效果不会太好,有时甚至会非常糟糕。

现在,让我们换个方向,看看另一类模型——决策树

决策树

决策树是另一类函数,其表达能力远超过线性函数。决策树将特征空间划分为不同的子空间,并对每个子空间拟合一个非常简单的模型(例如平均值)。让我们通过一个例子来理解这种划分是如何工作的。我们考虑一个回归问题,目标是预测Y,并且只有一个特征X,如下图所示:

图 8.10 – 由决策树划分的特征空间

图 8.10:由决策树划分的特征空间

我们可以立即看到,拟合一个线性函数会导致欠拟合。但决策树的做法是将特征空间(在这里就是X)划分为不同的区域,其中目标Y是相似的,然后对每个区域拟合一个简单的函数,如平均值(因为这是一个回归问题)。在这种情况下,决策树将特征空间划分为 A、B 和 C 三个区域。现在,对于任何落入区域 A 的X,预测函数将返回区域 A 中所有点的平均值。

这些划分是通过使用数据创建决策树来形成的。直观地说,决策树通过创建一组 if-else 条件来划分特征空间,并尝试找到最佳的划分方式,以最大化目标变量在每个划分中的同质性。理解决策树作用的一种有帮助的方法是将数据点想象成珠子,沿着树流动,根据其特征选择路径,最终停留在一个最终位置。在我们讨论如何从数据中创建决策树之前,让我们先看看它的组成部分,并理解相关的术语:

图 8.11 – 决策树的结构

图 8.11:决策树的结构

决策树中有两种类型的节点——决策节点叶节点。决策节点是我们之前提到的 if-else 语句。这个节点将有一个基于数据点流向左支路或右支路的条件。位于最顶部的决策节点有一个特殊的名称——根节点。最后,根据条件划分数据点并将其导向右支路或左支路的过程被称为划分。叶节点是没有其他分支的节点。它们是“珠子流经树”的类比中的最终停靠点。这些就是我们在本节中讨论的划分。

正式地,我们可以定义由具有 M 个划分的决策树生成的函数 P[1]、P[2]、…、P[M],如下所示:

在这里,x 是输入,c[m] 是该区域的常数响应,P[m],而 I 是一个函数,当 时为 1;否则,它为 0。

对于回归树,我们通常采用平方损失作为损失函数。在这种情况下,c[m] 通常设置为所有 y 的平均值,其中对应的 x 落入 P[m] 划分中。

现在我们知道了决策树是如何工作的,剩下的就是理解如何决定在什么特征上进行划分,以及如何划分特征。

多年来,已经提出了许多算法来根据数据创建决策树,比如 ID3、C4.5、CART 等。在这些算法中,使用 分类与回归树CART)是最流行的方法之一,并且它也支持回归。因此,本书中我们将坚持使用 CART。分类树用于目标变量为类别型(例如,预测类标签)时。回归树用于目标变量为连续型(例如,预测数值)时。

全局最优的二分划分集合,能够最小化平方和,通常是无法处理的。因此,我们采用贪心算法来创建决策树。贪心优化是一种启发式方法,它通过逐步构建解决方案,每一步选择局部最优解。因此,我们不会全局寻找最佳的特征划分,而是通过逐个节点创建决策树,在每个阶段选择最优的特征划分。对于回归树,我们选择一个划分特征 f 和划分点 s,使得它创建两个划分 P[1] 和 P[2],其最小化如下:

在这里,c[1] 和 c[2] 是所有 y 的平均值,其中对应的 x 落在 P[1] 和 P[2] 之间。

因此,通过使用这个标准,我们可以不断地将区域进一步划分。每划分一次,我们就增加一次树的深度。在某个时刻,我们会开始过拟合数据集。但是如果我们没有做足够的划分,可能也会导致欠拟合数据。一个策略是当我们达到预定的深度时停止进一步的划分。在 DecisionTreeRegressor 的 scikit-learn 实现中,这对应于 max_depth 参数。这个超参数需要通过验证数据集进行估算。还有其他策略可以停止划分,比如设置划分所需的最小样本数(min_samples_split),或者进行划分所需的最小成本下降(min_impurity_decrease)。有关 DecisionTreeRegressor 中参数的完整列表,请参阅文档 scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeRegressor.html

现在,让我们看看如何使用决策树并在验证数据集中的一个家庭样本上评估拟合情况:

from sklearn.tree import DecisionTreeRegressor
model_config = ModelConfig(
    model=DecisionTreeRegressor(max_depth=4, random_state=42),
    name="Decision Tree",
    # Decision Tree is not affected by normalization
    normalize=False,
    # Decision Tree in scikit-learn does not handle missing values
    fill_missing=True,
)
y_pred, metrics, feat_df = evaluate_model(
    model_config,
    feat_config,
    missing_value_config,
    train_features,
    train_target,
    test_features,
    test_target,
) 

让我们来看一下 DecisionTreeRegressor 的单步预测。它的表现不如我们迄今为止运行的线性回归或正则化线性回归模型:

图 8.12 – 决策树预测

图 8.12:决策树预测

对于线性模型,一些系数帮助我们理解每个特征在预测函数中的重要性。而在决策树中,我们没有系数,但特征的重要性依然是通过损失函数的平均下降进行估算的,这一变化归因于树构建过程中的每个特征。在 scikit-learn 模型中,我们可以通过训练模型的 feature_importance_ 属性来访问这个特征重要性。让我们来看看这个特征重要性:

图 8.13 – 决策树的特征重要性(前 15)

图 8.13:决策树的特征重要性(前 15)

最佳实践:

尽管默认的特征重要性是快速且简便的方式来检查不同特征的使用情况,但在将其用于其他目的(如特征选择或业务决策)之前,应谨慎处理。这种评估特征重要性的方法会给一些连续特征和高基数类别特征误导性地赋予过高的值。建议使用置换重要性(sklearn.inspection.permutation_importance)来进行更好的特征重要性评估,这是一种简单但更好的评估方法。进一步阅读部分包含了一些关于模型可解释性的资源,这些资源可以作为理解模型影响因素的良好起点。

在这里,我们可以看到重要的特征,如滞后特征和季节性滚动特征,排在了最上面。

我们在第五章时间序列预测作为回归中讨论了过拟合和欠拟合。这些也在机器学习术语中被称为高偏差(欠拟合)和高方差(过拟合)(进一步阅读部分包含了链接,如果你想了解更多关于偏差、方差及它们之间的权衡问题,可以参考这些资源)。

决策树是一种非常容易发生过拟合或高方差的算法,因为与线性函数不同,决策树在具有足够表现力的情况下,能够通过划分特征空间来记住训练数据集的特征。另一个关键缺点是决策树无法外推。假设我们有一个特征f,它线性地增加我们的目标变量y。我们拥有的训练数据中,f[max]是f的最大值,y[max]是y的最大值。由于决策树划分了特征空间,并为每个区域分配了一个常数值,即使我们提供了f > f[max],它依然会仅仅给出一个预测值为的结果。

现在,让我们来看一个使用决策树模型,但在集成方法中使用的模型,并且不容易发生过拟合。

随机森林

随机森林是一种集成学习方法,在训练过程中构建多个决策树,并将它们的结果结合在一起,以提高准确性和鲁棒性。它在分类和回归任务中都表现出色,通过袋装法和特征随机性减少了过拟合,并提高了预测性能。

集成学习是一种使用多个模型或专家,并通过某种方式将它们结合来解决手头问题的过程。它借用了集体智慧的方法,这一方法认为一群人的决策通常比其中任何一个人做出的决策更为准确。在机器学习中,这些单独的模型被称为基本学习器。单一模型可能因为过拟合数据集而表现不佳,但当我们将多个这样的模型结合起来时,它们可以形成一个强大的学习器。

袋装法(Bagging)是一种集成学习方法,在这种方法中,我们使用自助采样(从总体中反复有放回地抽取样本)来绘制数据集的不同子集,在每个子集上训练弱学习器,然后通过平均或投票的方式将它们结合在一起(分别用于回归和分类)。袋装法最适合于高方差、低偏差的弱学习器,而决策树就是一个非常适合与袋装法配合使用的成功候选者。从理论上讲,袋装法保持弱学习器的偏差水平不变,但减少了方差,从而提高了模型的表现。但如果弱学习器之间存在相关性,袋装法的效果将会受到限制。

2001 年,Leo Brieman 提出了随机森林,它在标准的袋装法基础上作出了重要修改,通过构建大量去相关的决策树来改进。为了确保在自助采样数据集上生成的所有树不相互关联,他提出略微修改树的构建过程。

参考文献检查:

随机森林的原始研究论文在 参考文献 部分被引用为参考文献 1

在随机森林算法中,我们决定构建多少棵树。我们称之为 M 棵树。现在,对于每棵树,重复以下步骤:

  1. 从训练数据集中抽取一个自助采样样本。

  2. 从所有特征中随机选择 f 个特征。

  3. 仅使用 f 个特征选择最佳分裂,并将节点分裂成两个子节点。

  4. 重复 步骤 2步骤 3,直到达到任何定义的停止标准。

这组 M 棵树就是随机森林。与常规树的关键区别在于每次分裂时特征的随机抽样,这增加了随机性并减少了不同树输出之间的相关性。在预测时,我们使用每棵 M 树来获得一个预测结果。对于回归问题,我们取它们的平均值,而对于分类问题,我们取多数投票。我们从随机森林中学习到的回归预测函数如下:

这里,Tt 是随机森林中 t^(th) 棵树的输出。

我们需要控制决策树复杂度的所有超参数在这里同样适用(来自 scikit-learn 的 RandomForestRegressor)。除了这些之外,我们还有两个其他重要的参数——集成中要构建的树的数量(n_estimators)和每次分裂时随机选择的特征数量(max_features)。

现在,让我们看看如何使用随机森林并在验证数据集中的一个样本家庭上评估拟合效果:

from sklearn.ensemble import RandomForestRegressor
model_config = ModelConfig(
    model=RandomForestRegressor(random_state=42, max_depth=4),
    name="Random Forest",
    # RandomForest is not affected by normalization
    normalize=False,
    # RandomForest in scikit-learn does not handle missing values
    fill_missing=True,
)
y_pred, metrics, feat_df = evaluate_model(
    model_config,
    feat_config,
    missing_value_config,
    train_features,
    train_target,
    test_features,
    test_target,
) 

让我们看看 RandomForestRegressor 的这一步预测。它比决策树更好,但不如线性模型。然而,我们应该记住,我们还没有调优模型,可能通过设置正确的超参数能够获得更好的结果。

现在,让我们看看使用随机森林生成的预测:

图 8.14 – 随机森林预测

图 8.14:随机森林预测

就像决策树中的特征重要性一样,随机森林也有一个非常类似的机制来估计特征重要性。由于我们在随机森林中有很多棵树,我们会累积所有树中的分裂标准的减少,并得出一个单一的特征重要性。我们可以通过使用训练模型的 feature_importance_ 属性来访问这一点。让我们来看一下特征重要性:

图 8.15 – 决策树的特征重要性(前 15 名)

图 8.15:决策树的特征重要性(前 15 名)

在这里,我们可以看到特征重要性与决策树非常相似。关于这种特征重要性的警告同样适用于这里。这只是一种粗略的方式来看模型在内部使用了哪些特征。

通常,随机森林在许多数据集上表现良好,几乎不需要调整,因此随机森林在机器学习中非常流行。由于随机森林不容易过拟合,这也增加了它的吸引力。但由于随机森林使用决策树作为弱学习器,决策树无法外推的问题也会传递给随机森林。

scikit-learn中实现的随机森林在树的数量和数据量较大时可能会变得较慢。XGBoost库中的XGBRFRegressor提供了一个随机森林的替代实现,特别是在较大的数据集上,由于XGBoost优化的算法和并行化能力,它可能会更快。此外,XGBRFRegressor使用的超参数与scikit-learn的随机森林类似,使得在调整模型时切换实现方式相对简单。在大多数情况下,这是一个直接替代,几乎能得到相同的结果。细微的差异来源于一些小的实现细节。我们在笔记本中也使用了这个变体。由于显而易见的运行时考虑,未来我们更倾向于使用这种变体。它还原生支持缺失值处理,避免了额外的预处理步骤。有关实现的更多细节以及如何使用它,可以参见xgboost.readthedocs.io/en/latest/tutorials/rf.html

现在,让我们看一下一种最后的函数族,它是最强大的学习方法之一,并且在各种数据集上得到了极好的验证——梯度提升。

梯度提升决策树

提升法,像袋装法一样,是另一种集成方法,使用一些弱学习器来生成强大的模型组合。袋装法和提升法的关键区别在于弱学习器的组合方式。与袋装法在自助采样数据集上并行构建不同的模型不同,提升法按顺序使用弱学习器,每次弱学习器都会应用于反复修改过的数据版本。

为了理解加法函数的公式,让我们考虑这个函数:

我们可以将这个函数拆分成f1= 25,f2= x²,f3= cos(x),并将F(x)重新写成如下形式:

F(x) = f1 + f2 + f3

这是我们在提升法中学习的加法集成函数。尽管从理论上讲,我们可以使用任何弱学习器,但决策树是最常见的选择。因此,我们使用决策树来探讨梯度提升是如何工作的。

早些时候,当我们讨论决策树时,我们看到一个具有M个分区的决策树,P[1],P[2],…,P[M],其形式如下:

在这里,x 是输入,C[m] 是该区域的常数响应,P[m],I 是一个函数,如果 ,则为 1;否则为 0。一个增强决策树模型是这些树的总和:

由于为所有树在集成中找到最佳分区 P 和常数值 c 是一个非常困难的优化问题,我们通常采用一种次优的阶段性解决方案,在构建集成时逐步优化每一步。在梯度提升中,我们使用损失函数的梯度来指导优化过程,因此得名“梯度提升”。

让我们在训练中使用的损失函数为 。由于我们观察的是一种逐步加法的功能形式,我们可以将 替换为 ,其中 是前 k-1 棵树的预测总和,Tk 是第 k 阶段树的预测。我们来看一下用于训练数据 D(有 N 个样本)的梯度提升学习过程:

  1. 通过最小化损失函数来初始化模型,使用常数值:

  • b[0] 是在第 0 次迭代中最小化损失函数的模型预测。在这个迭代中,我们还没有任何弱学习者,这个优化与任何特征无关。

  • 对于平方误差损失,这相当于所有训练样本的平均值,而对于绝对误差损失,它是中位数。

  1. 现在我们有了初步解,可以开始树构建过程。对于 k=1 到 M,我们必须执行以下操作:

    1. 对所有训练样本计算

      • r[k] 是损失函数关于 F(x) 相对于上一轮迭代的导数。它也叫做伪残差。

      • 对于平方误差损失,这只是残差()。

    2. 建立一个常规回归树,针对 r[k] 值,使用 M[k] 个分区或叶节点,P[mk]。

    3. 计算

      • 是当前阶段叶节点或分区值的缩放因子。

      • 是当前阶段决策树学习到的函数。

    4. 更新

      • 是收缩参数或学习率。

这种“增强”前一个弱模型误差的过程赋予了算法它的名字——梯度提升,其中梯度指的是前一个弱模型的残差。

提升(Boosting)通常是一种高方差算法。这意味着过拟合训练数据集的风险相当高,因此需要采取足够的措施来确保不会发生过拟合。在梯度提升树中,已经实现了多种正则化和容量约束方法。与往常一样,决策树为了减少容量以适应数据的所有关键参数在这里依然有效,因为弱学习器是决策树。除此之外,还有两个其他的关键参数——树的数量,M(在 scikit-learn 中为 n_estimators),以及学习率,(在 scikit-learn 中为 learning_rate)。

当我们在加法形式中应用学习率时,本质上是在缩小每个弱学习器的影响力,从而减少任何一个弱学习器对整体函数的影响。这最初被称为收缩(shrinkage),但现在,在所有流行的梯度提升树实现中,它被称为学习率。树的数量和学习率是高度互相依赖的。对于相同的问题,如果我们减少学习率,就需要更多的树。经验表明,较低的学习率可以改善泛化误差。因此,一种非常有效且便捷的方法是将学习率设置为非常低的值(<0.1),将树的数量设置为非常高的值(>5,000),并通过早停法训练梯度提升树。早停法是指在训练模型时,我们使用验证数据集来监控模型的外部样本表现。当外部样本误差不再减少时,我们停止向集成中添加更多的树。

许多实现采用的另一个关键技术是子采样(subsampling)。子采样可以在行和列上进行。行子采样类似于自助采样(bootstrapping),即每个候选模型在数据集的子样本上训练。列子采样类似于随机森林中的随机特征选择。两种技术都为集成引入了正则化效果,有助于减少泛化误差。像 XGBoostLightGBM 等一些梯度提升树的实现直接在目标函数中实现了 L1 和 L2 正则化。

有许多回归梯度提升树的实现。以下是一些流行的实现:

  • scikit-learn 中的 GradientBoostingRegressorHistGradientBoostingRegressor

  • 由 T Chen 开发的 XGBoost

  • 来自 Microsoft 的 LightGBM

  • 来自 Yandex 的 CatBoost

这些实现中的每一种都在标准的梯度提升算法上做出了从细微到非常根本的改动。我们在进一步阅读部分包含了一些资源,您可以通过这些资源了解这些差异,并熟悉它们支持的不同参数。

在本次练习中,我们将使用微软研究院的 LightGBM,因为它是最快且表现最佳的实现之一。LightGBM 和 CatBoost 还原生支持类别特征并处理缺失值。

参考检查:

XGBoost、LightGBM 和 CatBoost 的原始研究论文在参考文献部分分别被引用为234

现在,让我们看看如何使用 LightGBM 并在验证数据集中的一个家庭样本上评估拟合效果:

from lightgbm import LGBMRegressor
model_config = ModelConfig(
    model=LGBMRegressor(random_state=42),
    name="LightGBM",
    # LightGBM is not affected by normalization
    normalize=False,
    # LightGBM handles missing values
    fill_missing=False,
)
y_pred, metrics, feat_df = evaluate_model(
    model_config,
    feat_config,
    missing_value_config,
    train_features,
    train_target,
    test_features,
    test_target,
) 

让我们看看从 LGBMRegressor 获得的单步预测。它已经明显优于我们迄今为止尝试的所有其他模型:

图 8.16 – LightGBM 预测

图 8.16:LightGBM 预测

与决策树中的特征重要性类似,梯度提升法(Gradient Boosting)也有一个非常相似的机制来估计特征重要性。集成模型的特征重要性由所有树中每个特征的划分标准减少量的平均值给出。这可以通过训练模型的feature_importance_属性在 scikit-learn API 中访问。让我们来看一下特征重要性:

图 8.17 – LightGBM 特征重要性(前 15 名)

图 8.17:LightGBM 特征重要性(前 15 名)

有多种方式可以从模型中获取特征重要性,每种实现的计算方式略有不同。这些方式是由参数控制的。最常见的提取方式(使用 LightGBM 术语)是 splitgain。如果我们选择 split,特征重要性就是特征用于划分树中节点的次数。另一方面,gain 是划分标准的总减少量,可以归因于任何特征。图 8.17 显示了 split,这是 LightGBM 中的默认值。我们可以看到,特征重要性的顺序与决策树或随机森林非常相似,几乎相同的特征占据了前三名。

梯度提升决策树GBDTs)通常在表格数据和时间序列上表现非常好,回归任务也不例外。这个非常强大的模型通常是近年来几乎所有 Kaggle 时间序列预测竞赛获胜作品的一部分。虽然它是最好的机器学习模型家族之一,但它仍然有一些缺点:

  • GBDT 是高方差算法,因此容易过拟合。这就是为什么在大多数成功实现的 GBDT 中以不同的方式应用了各种正则化技术。

  • GBDT 通常需要更长的训练时间(尽管许多现代实现已经加速了这一过程),并且不像随机森林那样容易进行并行化。在随机森林中,我们可以并行训练所有的树,因为它们彼此独立。但在 GBDT 中,算法的顺序性限制了并行化。所有成功的实现都有一些巧妙的方式来在创建决策树时启用并行化。LightGBM 具有多种并行化策略,例如特征并行、数据并行和投票并行。有关这些策略的详细信息可以在lightgbm.readthedocs.io/en/latest/Features.html#optimization-in-distributed-learning找到,值得理解。该库的文档还包含一个有用的指南,帮助选择这些并行化策略之间的优先级,表格中有详细说明:

表 8.1 – LightGBM 中的并行化策略

图 8.18:LightGBM 中的并行化策略

  • 外推是 GBDT 的一个问题,就像所有基于树的模型一样。GBDT 在外推上有一些非常微弱的潜力,但并没有解决这个问题的方法。因此,如果你的时间序列具有一些强趋势,基于树的方法很可能无法捕捉到这个趋势。解决方法是使用去趋势的数据来训练模型,或者转向另一种模型类别。一种简单的去趋势方法是使用 AutoStationaryTransformer,我们在第六章《时间序列预测的特征工程》中讨论过。

总结一下,让我们看看这些机器学习模型所采用的度量标准和运行时间。如果你在本章中运行了笔记本,那么你会在其中找到以下总结表格:

图 8.18 – 示例家庭的度量标准和运行时间总结

图 8.18:示例家庭的度量标准和运行时间总结

一开始,我们就能看到所有尝试过的机器学习模型在所有度量标准上都优于基线,除了预测偏差。三种线性回归模型在 MAE、MASE 和 MSE 上表现相当,且正则化模型的运行时间略有增加。决策树的表现不尽如人意,但这通常是可以预期的。决策树需要稍微调整一下,以减少过拟合。随机森林(包括 scikit-learn 和XGBoost 实现)提高了决策树的表现,这是我们所期望的。这里需要注意的一个关键点是,XGBoost 实现的随机森林比 scikit-learn 实现的快了将近六倍。最后,LightGBM 在所有度量标准上表现最佳,且运行时间更短。

现在,这只是所有选定家庭中的一个。为了查看这些模型的表现,我们需要在所有选定的家庭上进行评估。

为多个家庭进行训练和预测

我们选择了一些模型(LassoCVXGBRFRegressorLGBMRegressor),这些模型在指标和运行时间方面表现更好,来处理我们验证数据集中的所有家庭。这个过程非常简单:循环遍历所有独特的组合,内部循环遍历不同的模型进行运行,然后进行训练、预测和评估。代码可以在Chapter08中的01-Forecasting_with_ML.ipynb笔记本里找到,位于对所有消费者进行机器学习预测标题下。你可以运行代码然后休息一下,因为这个过程大约需要不到一小时。笔记本还会计算指标,并包含一个总结表格,当你回来时它已经准备好。

现在我们来看一下总结:

图 8.19 – 验证数据集上所有家庭的聚合指标

图 8.19:验证数据集上所有家庭的聚合指标

在这里,我们可以看到,即使在聚合级别,我们使用的不同模型也按预期表现。笔记本还将验证集的预测结果保存到磁盘。

笔记本提醒:

我们还需要运行另一个名为01a-Forecasting_with_ML_for_Test_Dataset.ipynb的笔记本,位于Chapter08中。这个笔记本遵循相同的流程,生成预测,并计算测试数据集上的指标。

测试数据集的聚合指标如下(来自笔记本):

图 8.20 – 测试数据集上所有家庭的聚合指标

图 8.20:测试数据集上所有家庭的聚合指标

第六章时间序列预测的特征工程中,我们在所有家庭上使用了AutoStationaryTransformer(而不是转换器模型,我们将在第十四章中学习)并保存了转化后的数据集。

使用 AutoStationaryTransformer

这个过程与我们在本章早些时候做的非常相似,唯一的区别是有一些小的变化。我们读取了转化后的目标数据,并将其与常规数据集连接,原始目标命名为energy_consumption,而转化后的目标命名为energy_consumption_auto_stat

#Reading the missing value imputed and train test split data
train_df = pd.read_parquet(preprocessed/"block_0-7_train_missing_imputed_feature_engg.parquet")
auto_stat_target = pd.read_parquet(preprocessed/"block_0-7_train_auto_stat_target.parquet")
transformer_pipelines = joblib.load(preprocessed/"auto_transformer_pipelines_train.pkl")
#Reading in validation as test
test_df = pd.read_parquet(preprocessed/"block_0-7_val_missing_imputed_feature_engg.parquet")
# Joining the transformed target
train_df = train_df.set_index(['LCLid','timestamp']).join(auto_stat_target).reset_index() 

在定义FeatureConfig时,我们使用energy_consumption_auto_stat作为targetenergy_consumption作为original_target

笔记本提醒:

02-Forecasting_with_ML_and_Target_Transformation.ipynb02a-Forecasting_with_ML_and_Target_Transformation_for_Test_Dataset.ipynb笔记本使用这些转化后的目标生成验证和测试数据集的预测。

让我们看看这些笔记本在转化数据上生成的总结指标:

图 8.21 – 验证数据集中所有家庭的目标转化后的汇总指标

图 8.21:验证数据集中所有家庭的目标转化后的汇总指标

目标转化后的模型表现不如原始模型。这可能是因为数据集没有强烈的趋势。

恭喜你顺利完成了这一章,它包含了大量的理论和实践内容。我们希望这能增强你对机器学习的理解,并提升你将这些现代技术应用于时间序列数据的能力。

总结

这一章非常实用,具有操作性,我们开发了一些标准代码来训练和评估多个机器学习模型。接着,我们回顾了几个关键的机器学习模型,如岭回归、套索回归、决策树、随机森林和梯度提升树,并探讨了它们背后的工作原理。为了巩固和强化所学知识,我们将所学的机器学习模型应用于伦敦智能电表数据集,并观察它们的表现。本章为接下来的章节做好了准备,届时我们将利用标准化代码和这些模型深入探索使用机器学习进行预测。

在下一章,我们将开始将不同的预测结果合并为一个单一的预测,并探讨组合优化和堆叠等概念,以实现最先进的结果。

参考文献

本章提供了以下参考文献:

  1. Breiman, L. 随机森林,机器学习 45,5–32(2001):doi.org/10.1023/A:1010933404324

  2. Chen, Tianqi 和 Guestrin, Carlos. (2016). XGBoost: A Scalable Tree Boosting System. 2016 年 ACM SIGKDD 国际知识发现与数据挖掘会议论文集 (KDD ‘16)。计算机协会,纽约,NY,美国,785–794: doi.org/10.1145/2939672.2939785

  3. Ke, Guolin 等(2017),LightGBM: 高效的梯度提升决策树。神经信息处理系统进展,3149-3157 页:dl.acm.org/doi/pdf/10.5555/3294996.3295074

  4. Prokhorenkova, Liudmila 等(2018),CatBoost: 带有类别特征的无偏增强。2018 年神经信息处理系统国际会议论文集(NIPS’18):dl.acm.org/doi/abs/10.5555/3327757.3327770

进一步阅读

若想深入了解本章所涉及的主题,请查看以下资源:

加入我们社区的 Discord

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

packt.link/mts

第九章:集成和堆叠

在上一章中,我们查看了几种机器学习算法,并使用它们对伦敦智能电表数据集进行了预测。现在,我们对数据集中的所有家庭生成了多个预测,如何通过选择或结合这些不同的预测来得出一个单一的预测呢?最终,我们只能拥有一个预测,用于规划你正在预测的任务。这就是我们在本章中要做的——我们将学习如何利用组合和数学优化来得出一个单一的预测。

本章将涵盖以下主题:

  • 结合预测的策略

  • 堆叠或混合

技术要求

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

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

  • 02-处理伦敦智能电表数据集.ipynbChapter02

  • 01-设置实验框架.ipynbChapter04

  • 02-使用 darts 的基准预测.ipynbChapter04

  • 01-特征工程.ipynbChapter06

  • 02-处理非平稳性.ipynbChapter07

  • 02a-处理非平稳性-训练+验证.ipynbChapter07

  • 00-单步回测基准.ipynbChapter08

  • 01-使用机器学习进行预测.ipynbChapter08

  • 01a-使用机器学习进行测试数据集预测.ipynbChapter08

  • 02-使用目标转换进行预测.ipynbChapter08

  • 02a-使用目标转换进行预测(测试).ipynbChapter08

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

结合预测

我们已经使用许多技术生成了预测——有些是单变量的,有些是机器学习的,等等。但最终,我们需要一个单一的预测,这意味着选择一个预测或结合多种预测。最简单的选择是选择在验证数据集中表现最好的算法,在我们的案例中是 LightGBM。我们可以将这种选择看作是另一个函数,它接受我们生成的预测作为输入并将它们合并成一个最终的预测。从数学角度来看,可以表示如下:

Y = F(Y[1], Y[2], …, Y[N])

在这里,F 是一个结合 N 个预测的函数。我们可以使用 F 函数来选择验证数据集中表现最好的模型。然而,这个函数可以非常复杂,选择一个合适的 F 函数,同时平衡偏差和方差是必须的。

笔记本提示:

要跟随代码进行操作,请在Chapter09文件夹中使用01-Forecast_Combinations.ipynb笔记本。

我们将从加载所有预测(包括验证和测试预测)以及到目前为止生成的所有预测对应的指标开始,并将它们合并到pred_val_dfpred_test_df中。接下来,我们必须使用pd.pivot重塑 DataFrame,以便获得我们想要的形状。到目前为止,我们一直在跟踪多个指标。但为了实现这个目标,我们需要选择一个指标。在这个练习中,我们选择 MAE 作为指标。验证指标可以合并并重塑成metrics_combined_df

图 9.1 – 重塑后的预测 DataFrame

图 9.1:重塑后的预测 DataFrame

现在,让我们看看结合预测的几种不同策略。

最佳拟合

这种选择最佳预测的策略迄今为止是最流行的,其方法非常简单:根据验证指标为每个时间序列选择最佳预测。这种策略已被许多自动化预测软件工具所采用,称其为“最佳拟合”预测。该算法非常简单:

  1. 使用验证数据集找到每个时间序列的最佳预测。

  2. 对于每个时间序列,选择相同模型的测试数据集中的预测。

我们可以轻松做到这一点:

# Finding the lowest metric for each LCLid
best_alg = metrics_combined_df.idxmin(axis=1)
#Initialize two columns in the dataframe
pred_wide_test["best_fit"] = np.nan
pred_wide_test["best_fit_alg"] = ""
#For each LCL id
for lcl_id in tqdm(pred_wide_test.index.get_level_values(0).unique()):
    # pick the best algorithm
    alg = best_alg[lcl_id]
    # and store the forecast in the best_fit column
    pred_wide_test.loc[lcl_id, "best_fit"] = pred_wide_test.loc[lcl_id, alg].values
    # also store which model was chosen for traceability
    pred_wide_test.loc[lcl_id, "best_fit_alg"] = alg 

这将创建一个名为best_fit的新列,其中包含根据我们讨论的策略选择的预测。现在,我们可以评估这个新的预测,并获得测试数据集的指标。下表显示了最佳单一模型(LightGBM)和新策略—best_fit

图 9.2 – 最佳拟合策略的汇总指标

图 9.2:最佳拟合策略的汇总指标

在这里,我们可以看到最佳拟合策略整体表现优于单一的最佳模型。然而,这种策略的一个缺点是它的基本假设——在验证期表现最佳的模型也将在测试期表现最佳。它没有考虑其他预测模型等的对冲策略。考虑到时间序列的动态特性,这并不总是最佳策略。这种方法的另一个缺点是最终预测的不稳定性。

当我们在实际环境中使用这种规则时,每周重新训练并重新运行最佳拟合时,任何时间序列的预测可能会在不同的预测模型之间来回跳动,产生截然不同的预测结果。因此,最终的预测表现出很大的周间不稳定性,这会妨碍我们使用这些预测进行的后续操作。我们可以考虑一些没有这种不稳定性的其他技术。

集中趋势度量

另一种显著的策略是使用均值或中位数来合并预测。这是一个函数,F,它不依赖于验证指标。这既是这种方法的吸引力,也是一种困扰。由于我们根本没有使用验证指标,所以不可能过度拟合验证数据。但另一方面,由于没有任何验证指标的信息,我们可能会包括一些非常差的模型,这会拉低整体预测效果。然而,经验表明,这种简单的平均或中位数合并方法已被证明是一种非常强大的预测组合方法,且很难被超越。让我们看看如何实现这一方法:

# ensemble_forecasts is a list of column names(forecast) we want to combine
pred_wide_test["average_ensemble"] = pred_wide_test[ensemble_forecasts].mean(axis=1)
pred_wide_test["median_ensemble"] = pred_wide_test[ensemble_forecasts].median(axis=1) 

上述代码将创建两个新列,分别称为average_ensemblemedian_ensemble,用于存储合并后的预测。现在,我们可以评估这个新的预测,并获取测试数据集的指标。下表显示了最佳单个模型(LightGBM)和新的策略:

图 9.3 – 均值和中位数策略的汇总指标

图 9.3:均值和中位数策略的汇总指标

在这里,我们可以看到,无论是均值策略还是中位数策略,都没有比最好的单个模型整体表现得更好。这可能是因为我们包含了像 Theta 和 FFT 这样的模型,它们的表现远不如其他机器学习方法。但由于我们没有使用验证数据集中的任何信息,所以我们并不知道这一点。我们可以做一个例外,假设我们使用验证指标来选择哪些模型包含在平均值或中位数中。但我们必须小心,因为现在我们越来越接近于假设在验证期有效的模型也会在测试期有效。

这里有几种手动技术可以使用,例如修剪(丢弃表现最差的模型)和筛选(只选择表现最好的几个模型)。虽然这些方法有效,但有些主观性,尤其是在我们需要从成百上千个模型中选择时,它们变得难以使用。

如果我们考虑这个问题,本质上是一个组合优化问题,我们需要选择最佳的模型组合来优化我们的指标。如果我们考虑通过取平均值来合并不同的预测,从数学角度看,可以表示为:

在这里,L 是我们尝试最小化的损失或指标。在我们的例子中,我们选择的是 MAE。 是每个基础预测的二进制权重。最后,N个基础预测集合,Y 是时间序列的实际观测值。

但与纯粹的优化不同,纯粹优化中没有偏差和方差的概念,我们需要一个能够泛化的最优解。因此,选择训练数据中的全局最小值并不可取,因为那样可能会进一步过拟合训练数据集,增加最终模型的方差。在这种最小化中,我们通常使用样本外预测,在这种情况下可以是验证期间的预测。

最直接的解决方案是找到* w *,使其在验证数据上最小化此函数。但这种方法有两个问题:

  • 随着基准预测数量N的增加,可能的候选(基准预测的不同组合)呈指数增长。这很快就变得计算上难以处理。

  • 在验证期间选择全局最小值可能不是最佳策略,因为可能会导致验证期间的过拟合。

现在,让我们来看看一些基于启发式的解决方案来解决这个组合优化问题。

启发式问题解决是一种策略,利用经验法则或捷径快速找到解决方案,即使这些解决方案可能不是最优的。当精确解法在计算上昂贵或耗时时,启发式方法可以非常有用。然而,在某些情况下,它们可能导致次优甚至错误的解决方案。

启发式方法通常与其他问题解决方法(如元启发式方法)结合使用,以提高搜索过程的效率和效果。元启发式方法是高层次的、与问题无关的策略,用于解决优化问题。它们提供了一个框架,用于开发启发式算法,能够高效地探索复杂的搜索空间并找到近似最优解。与传统优化方法不同,元启发式方法通常从自然现象或生物过程中汲取灵感。

元启发式方法的常见例子包括遗传算法(灵感来自自然选择)、模拟退火(灵感来自冶金学)、粒子群优化(灵感来自鸟群聚集)和蚁群优化(灵感来自蚂蚁觅食)。这些方法采用概率或随机方法来平衡探索与开发,使其能够避免陷入局部最优解,发现潜在的更好解决方案。

简单的爬山法

在讨论决策树以及梯度提升树时,我们简要介绍了贪婪算法。贪婪优化是一种启发式方法,通过逐步构建解决方案,在每一步选择一个局部最优解。在这两种机器学习模型中,我们采用了贪婪的、逐步的方式来解决计算上不可行的优化问题。为了选择最佳子集,给我们提供最佳的预测组合,我们可以采用一种简单的贪婪算法,称为爬坡算法。如果我们将目标函数的曲面看作一座山,为了找到最大值,我们需要爬上这座山。顾名思义,爬坡算法逐步上升,在每一步中,它选择最优路径,从而增加目标函数的值。下面的示意图可以帮助更清晰地理解。

图 9.4:一维目标的爬坡算法示意图

我们可以从 图 9.4 中看到,目标函数(我们需要优化的函数)有多个峰值(山峰),而在爬坡算法中,我们“爬”上山,逐步到达峰顶。我们还需要记住,根据我们开始爬坡的位置不同,可能会达到目标中的不同点。在 图 9.4 中,如果我们从 A 点开始爬坡,我们到达局部最优解,并错过全局最优解。现在,让我们看看该算法是如何以更严格的方式运作的。

在这里,C 是一组候选解(基础预测),O 是我们希望最小化的目标。简单爬坡算法如下:

  1. 初始化起始解,C[best],作为在 O 中给出最小值的候选解,即 O[best],并从 C 中移除 C[best]。

  2. C 的长度大于 0 时,执行以下操作:

    1. 通过将 C[best] 中的基础预测与 C 中的每个元素进行平均,评估 C 中的所有成员,并选择最佳成员 (C[stage best]),将其添加到 C[best] 中,以最小化目标函数 O(即 O[stage best])。

    2. 如果 O[stage best] > O[best],则执行以下操作:

      1. C[best] = C[best] U C[stage best]。

      2. O[best] = O[stage best]。

      3. C 中移除 C[stage best]。

    3. 否则,退出。

在运行结束时,我们得到 C[best],这是通过贪婪优化得到的最佳预测组合。我们在 src.forecasting.ensembling.py 中实现了这个功能,位于 greedy_optimization 函数下。该函数的参数如下:

  • objective:这是一个可调用函数,接受一个字符串列表作为候选解,并返回一个 float 类型的目标值。

  • candidates:这是一个候选解列表,将被包括在优化中。

  • verbose:一个标志,指定是否打印进度。

该函数返回一个元组,其中包含作为字符串列表的最佳解和通过优化得到的最佳评分。

让我们看看如何在我们的示例中使用这个算法:

  1. 导入所有必需的库/函数:

    # Used to partially construct a function call
    from functools import partial
    # calculate_performance is a custom method we defined to calculate the MAE provided a list of candidates and prediction dataframe
    from src.forecasting.ensembling import calculate_performance, greedy_optimization 
    
  2. 定义目标函数并运行贪心优化:

    # We partially construct the function call by passing the necessary parameters
    objective = partial(
        calculate_performance, pred_wide=pred_wide_val, target="energy_consumption"
    )
    # ensemble forecasts is the list of candidates
    solution, best_score = greedy_optimization(objective, ensemble_forecasts) 
    
  3. 一旦我们得到了最佳解,我们可以在测试数据框中创建组合预测:

    pred_wide_test["greedy_ensemble"] = pred_wide_test[solution].mean(axis=1) 
    

一旦我们运行此代码,我们将在预测数据框(DataFrame)中得到名称为greedy_ensemble的组合预测。最优解中的候选模型包括 LightGBM、Lasso 回归和 LightGBM_auto_stat。接下来,让我们评估结果并查看汇总的度量指标:

图 9.4 – 基于简单爬山的集成方法汇总指标

图 9.5:基于简单爬山的集成方法汇总指标

如我们所见,简单的爬山算法的表现优于我们迄今为止看到的任何单一模型或集成技术。在这种情况下,贪心算法似乎运作良好。现在,让我们了解爬山算法的几个局限性,如下所示:

  • 运行时考虑:由于简单的爬山算法需要在每一步评估所有候选者,这可能会导致运行时瓶颈。如果候选者数量很大,这种方法可能会花费更多时间才能完成。

  • 短视性:爬山优化是短视的。在优化过程中,它每一步总是选择最佳的选项。有时,通过在某一步选择一个稍差的解决方案,我们可能会得到一个更好的整体解决方案。

  • 只前进:爬山算法是一个只前进的算法。一旦一个候选者被纳入解决方案,我们就不能回头将其移除。

贪心算法并不总能为我们找到最优解,尤其是在需要组合多个模型时。因此,让我们来看看一种小的变种——爬山算法,它试图克服贪心算法的一些局限性。

随机爬山

简单的爬山算法和随机爬山算法的关键区别在于候选者的评估。在简单的爬山中,我们会评估所有可能的选项并从中挑选最佳的一个。然而,在随机爬山中,我们会随机挑选一个候选者,如果它比当前解更好,就将其添加到解决方案中。换句话说,在爬山算法中,我们总是逐步向上移动,但在随机爬山中,我们会神奇地瞬移到目标函数的不同点,检查自己是否比之前更高。这种加入随机性的做法有助于优化算法避免陷入局部最大值/最小值,但也引入了相当大的不确定性,可能无法达到任何最优解。接下来,我们来看看这个算法。

在这里,C 是候选集(基础预测),O 是我们希望最小化的目标,N 是我们希望运行优化的最大迭代次数。随机爬山算法如下:

  1. 初始化起始解,C[best],作为候选者。可以通过随机挑选一个候选者或选择表现最好的模型来完成。

  2. C[best] 的目标函数值 O 设置为 O[best],并从 C 中移除 C[best]。

  3. N 次迭代重复以下步骤:

    1. C 中随机抽取一个样本,将其加入 C[best],并存储为 C[stage]。

    2. 在目标函数 O 上评估 C[stage],并将其存储为 O[stage]。

    3. 如果 O[stage] > O[best],则执行以下操作:

      1. C[best] = C[best] U C[stage]

      2. O[best] = O[stage]。

      3. C 中移除 C[best]。

在运行结束时,我们得到的是 C[best],它是通过随机爬山算法获得的最佳预测组合。我们已经在 src.forecasting.ensembling.py 中的 stochastic_hillclimbing 函数下实现了这一方法。该函数的参数如下:

  • objective:这是一个可调用的函数,它接收一个包含候选字符串的列表并返回一个 float 类型的目标值。

  • candidates:这是一个候选列表,将被包含在优化过程中。

  • n_iterations:执行爬山算法的迭代次数。如果未给定该值,则使用启发式方法(候选数量的两倍)来设置该值。

  • init:决定用于初始解的策略,可以是 randombest

  • verbose:一个标志,用来指定是否打印进度。

  • random_state:一个种子,用于获得可重复的结果。

该函数返回一个元组,包含作为字符串列表的最佳解和通过优化获得的最佳得分。

这可以与 greedy_optimization 以非常相似的方式使用。我们这里只展示不同的部分,完整代码可在笔记本中查看:

from src.forecasting.ensembling import stochastic_hillclimbing
# ensemble forecasts is the list of candidates
solution, best_score = stochastic_hillclimbing(
    objective, ensemble_forecasts, n_iterations=10, init="best", random_state=9
) 

一旦我们运行这段代码,我们将在预测 DataFrame 中得到一个名为 stochastic_hillclimb__ensemble 的组合预测。作为最佳解的一部分的候选模型包括 LightGBM、Lasso 回归 _auto_stat、LightGBM_auto_stat 和 Lasso 回归。现在,让我们评估结果并查看聚合指标:

图 9.5 – 基于随机爬山的集成的聚合指标

图 9.6:基于随机爬山的集成的聚合指标

随机爬山算法的效果不比贪婪算法更好,但却优于均值、媒体和最佳拟合集成。我们之前讨论了简单爬山法的三个缺点——运行时考虑、短视性和仅向前搜索。随机爬山解决了运行时考虑的问题,因为我们并没有评估所有的组合并选择最佳,而是通过随机评估组合并一旦找到一个表现更好的解就将其加入集成。它在一定程度上解决了短视性问题,因为算法中的随机性可能会导致每个阶段选择一个次优解,但它仍然只选择比当前解更好的解。

现在,让我们来看一下另一个改进版的爬山算法,它也解决了这个问题。

模拟退火

模拟退火是对爬山算法的一种改进,灵感来源于一种物理现象——退火固体。退火是将固体加热到预定温度(通常高于其再结晶温度,但低于其熔点),保持一段时间,然后冷却(可以慢慢冷却,也可以通过在水中淬火来快速冷却)。

这样做是为了确保原子达到新的全局最小能量状态,这会在某些金属中引入期望的性质,比如铁。

1952 年,Metropolis 提出了模拟退火作为一种优化技术。退火类比也适用于优化背景。当我们说加热系统时,实际上是指我们鼓励随机扰动。因此,当我们以高温开始优化时,算法会探索空间并得出问题的初始结构。随着温度的降低,结构会被细化,从而得到最终解决方案。这种技术有助于避免陷入任何局部最优解。局部最优解是目标函数表面上的极值,它比附近的其他值更好,但可能不是最优解。进一步阅读部分包含了简洁解释局部最优解和全局最优解的资源。

现在,让我们来看看这个算法。

这里,C是候选集(基本预测),O是我们要最小化的目标,N是我们希望运行优化的最大迭代次数,T[max]是最大温度,是温度衰减。模拟退火算法如下:

  1. 初始化一个起始解,C[best],作为候选解。这可以通过随机选择一个候选解或选择表现最好的模型来完成。

  2. C[best]设置目标函数的值,O,作为O[best],并从C中移除C[best]。

  3. 将当前温度设置为t = T[max]。

  4. N次迭代重复执行此操作:

    1. C中随机抽取一个样本,添加到C[best],并将其存储为C[stage]。

    2. 在目标函数上评估C[stage],O,并将其存储为O[stage]。

    3. 如果 O[stage] > O[best],则执行以下操作:

      1. C[best].= C[best] U C[stage]

      2. O[best] = O[stage]

      3. C中移除C[best]。

    4. 否则,执行以下操作:

      1. 计算接受概率,

      2. 从 0 到 1 之间随机抽取一个样本,记为 p。

      3. 如果 p < s,则执行以下操作:

      4. C[best] = C[best] U C[stage]

      5. O[best] = O[stage]。

      6. C中移除C[best]。

    5. t = t - (对于线性衰减)和 t = t/(对于几何衰减)。

    6. C为空时退出。

在运行结束时,我们得到了 C[best],这是通过模拟退火获得的最佳预测组合。我们在 src.forecasting.ensembling.py 文件中的 simulated_annealing 函数下提供了该实现。将温度设置为合适的值对于算法的良好运行至关重要,而且通常是最难设置的超参数。更直观地,我们可以将温度视为开始时接受较差解的概率。在实现中,我们还使得可以输入接受较差解的起始和结束概率。

1989 年,D.S. Johnson 等人提出了一种从给定的概率范围估算温度范围的过程。该过程已经在 initialize_temperature_range 中实现。

总结一下,算法从随机解开始,并评估每个解的好坏。然后,它不断尝试新解,有时接受较差的解以避免陷入局部最优解,但随着时间的推移,它接受较差解的可能性会降低,因为它在“冷却”(就像金属冷却并硬化一样)。它重复这一过程,直到选项用尽或温度变得太低,最终保留找到的最佳解。

参考检查:

D.S. Johnson 的研究论文,标题为 模拟退火优化:实验评估第一部分,图划分,在 参考文献 部分被引用为参考文献 1

simulated_annealing 函数的参数如下:

  • objective:这是一个可调用的函数,接受一个字符串列表作为候选项,并返回一个 float 类型的目标值。

  • candidates:这是一个候选列表,用于包含在优化中。

  • n_iterations:模拟退火运行的迭代次数。这个参数是必需的。

  • p_range:起始和结束概率的元组。这是在模拟退火中接受较差解的概率。温度范围(t_range)将在优化过程中从 p_range 推断得出。

  • t_range:如果我们想直接设置温度范围为一个元组(起始,结束),可以使用这个参数。如果设置了该值,p_range 会被忽略。

  • init:这个参数决定了用于初始解的策略。可以是 randombest

  • temperature_decay:指定温度衰减的方式。可以是 lineargeometric

  • verbose:一个标志,指定是否打印进度。

  • random_state:用于获取可重复结果的种子。

该函数返回一个元组,包含作为字符串列表的最佳解决方案和通过优化获得的最佳得分。

这可以像其他组合预测的方式一样使用。我们将在这里展示不同之处。完整代码可在笔记本中查看:

from src.forecasting.ensembling import simulated_annealing
# ensemble forecasts is the list of candidates
solution, best_score = simulated_annealing(
    objective,
    ensemble_forecasts,
    p_range=(0.5, 0.0001),
    n_iterations=50,
    init="best",
    temperature_decay="geometric",
    random_state=42,
) 

一旦我们运行这段代码,我们将在预测 DataFrame 中得到一个名为simulated_annealing_ensemble的组合预测。作为最优解的一部分的候选模型包括 LightGBM、Lasso 回归 _auto_stat、LightGBM_auto_stat 和 XGB 随机森林。让我们评估一下结果并查看汇总指标:

图 9.6 – 基于模拟退火的集成的汇总指标

图 9.7:基于模拟退火的集成的汇总指标

模拟退火似乎比随机爬山表现得更好。我们之前讨论过简单爬山算法的三个缺点——运行时考虑、目光短浅以及仅向前搜索。模拟退火解决了运行时问题,因为我们不是评估所有组合并选择最优的,而是随机评估组合,并在发现更好的解时立即将其添加到集成中。它也解决了目光短浅的问题,因为通过使用温度,我们在优化的初期也会接受略差的解。然而,它仍然是一个仅向前搜索的过程。

到目前为止,我们已经看过组合优化问题,因为我们说过。但如果我们可以放宽这个约束,使得 (实数),那么组合优化问题可以放宽为一个一般的数学优化问题。让我们看看如何做到这一点。

最优加权集成

之前,我们将我们试图解决的优化问题定义为如下:

这里,L是我们试图最小化的损失或指标。在我们的例子中,我们选择了 MAE。 N 个基本预测集合,而 Y 是时间序列的真实观测值。我们不再定义 ,而是让 成为每个基本预测的连续权重。通过这个新放宽的约束,组合变成了不同基本预测之间的加权平均。现在,我们正在看不同预测的软混合,而不是基于硬选择的组合优化(这也是我们一直使用的方法)。

这是一个可以使用 scipy 中现成算法解决的优化问题。让我们看看如何使用 scipy.optimize 来解决这个问题。

首先,我们需要定义一个损失函数,该函数接受一组作为列表的权重,并返回我们需要优化的指标:

def loss_function(weights):
        # Calculating the weighted average
        fc = np.sum(pred_wide[candidates].values * np.array(weights), axis=1)
        # Using any metric function to calculate the metric
        return metric_fn(pred_wide[target].values, fc) 

现在,我们所需要做的就是用必要的参数调用scipy.optimize。让我们学习如何做这件事:

from scipy import optimize
opt_weights = optimize.minimize(
        loss_function,
        # set x0 as initial values, which is a uniform distribution over all the candidates
        x0=[1 / len(candidates)] * len(candidates),
        # Set the constraint so that the weights sum to one
        constraints=({"type": "eq", "fun": lambda w: 1 - sum(w)}),
        # Choose the optimization technique. Should be gradient-free and bounded.
        method="SLSQP",
        # Set the lower and upper bound as a tuple for each element in the candidate list.
        # We set the maximum values between 1 and 0
        bounds=[(0.0, 1.0)] * len(candidates),
        # Set the tolerance for termination
        options={"ftol": 1e-10},
    )["x"] 

优化通常很快,我们会得到作为浮动点数的权重列表。我们将其包装在src.forecasting.ensembling.py中的一个名为find_optimal_combination的函数中。该函数的参数如下:

  • candidates:这是待纳入优化的候选项列表。它们的返回顺序与返回的权重顺序相同。

  • pred_wide:这是我们需要学习权重的预测数据框。

  • target:这是目标列的名称。

  • metric_fn:这是任何具有metric(actuals, pred)签名的可调用对象。

该函数返回最优权重,作为一个浮动点数列表。让我们看看通过验证预测学习到的最优权重是什么:

图 9.7 – 通过优化学习得到的最优权重

图 9.8:通过优化学习得到的最优权重

在这里,我们可以看到优化自动学会忽略FFTThetaXGB Random ForestXGB Random Forest_auto_stat,因为它们对集成模型贡献不大。它还学会了为每个预测分配一些非零的权重。这些权重已经与我们之前讨论的技术选择相似。现在,我们可以使用这些权重来计算加权平均值,并将其称为optimal_combination_ensemble

聚合结果应该如下所示:

图 9.8 – 基于最优组合的集成的聚合指标

图 9.9:基于最优组合的集成的聚合指标

在这里,我们可以看到,这种软性混合预测的表现远远好于所有基于硬性选择的集成方法,在所有三个指标上都有明显的提升。

在我们讨论的所有技术中,我们使用的是 MAE 作为目标函数。但我们也可以使用任何指标、指标组合,甚至是带正则化的指标作为目标函数。当我们讨论随机森林时,我们提到去相关树对提高性能至关重要。一个非常相似的原则也适用于选择集成方法。拥有去相关的基础预测为集成模型增值。因此,我们可以使用任何多样性度量来对我们的指标进行正则化。例如,我们可以使用相关性作为度量,并创建一个正则化指标用于这些技术。Chapter09文件夹中的01-Forecast_Combinations.ipynb笔记本包含一个附加部分,展示了如何做到这一点。

我们一开始讨论的是如何通过数学公式来组合预测:

Y = F(Y[1], Y[2], …, Y[N])

在这里,F是将N个预测值组合起来的函数。

我们在寻找将该函数作为优化问题的解决方案时,使用了均值或中位数等方法来组合这些指标。但我们也看到了另一种从数据中学习这个函数F的方式,不是吗?让我们看看怎么做。

堆叠与混合

本章一开始我们讨论了机器学习算法,这些算法从一组输入和输出中学习一个函数。在使用这些机器学习算法的过程中,我们学习了预测时间序列的函数,现将其称为基预测。

为什么不使用相同的机器学习范式来学习我们也想学习的这个新函数 F 呢?

这正是堆叠中所做的(通常称为堆叠泛化),我们在一些基学习器的预测结果上训练另一个学习算法来结合这些预测。这种二级模型通常被称为堆叠模型元模型。通常,这种元模型的表现与基学习器相当,甚至更好。这与混合(blending)非常相似,唯一的区别在于我们分割数据的方式。

尽管这一思想最早由沃尔珀特(Wolpert)于 1992 年提出,但莱奥·布雷曼(Leo Breiman)在 1996 年的论文《堆叠回归》(Stacked Regressions)中正式化了这一概念,成为如今的应用方式。并且在 2007 年,M. J. Van der Laan 等人建立了这一技术的理论基础,并提供了证明,表明这种元模型的表现至少与基学习器一样好,甚至更好。

参考检查:

莱奥·布雷曼(1996 年)和马尔科·J·范德兰(2007 年)等人的研究论文在参考文献部分被标记为23

这是在机器学习竞赛中非常流行的一种技术,比如 Kaggle,被认为是机器学习从业者之间的一种秘密技巧。我们还讨论了其他一些技术,比如袋装法(bagging)和提升法(boosting),它们通过将基学习器组合成更复杂的模型来改进效果。但这些技术要求基学习器是一个弱学习器。而堆叠(stacking)则不同,因为堆叠尝试将一组多样化学习器进行组合。

堆叠的直觉是,不同的模型或函数族学习输出函数的方式略有不同,捕捉了问题的不同特征。例如,一个模型可能很好地捕捉了季节性,而另一个模型则可能更好地捕捉了与外生变量的某种交互。堆叠模型将能够将这些基模型结合成一个模型,其中一个模型关注季节性,另一个模型关注交互。这是通过让元模型学习基模型的预测结果来实现的。但为了防止数据泄漏并避免过拟合,元模型应在样本外的预测数据上进行训练。如今有两种小变体的技术——堆叠和混合。

堆叠是指元模型在整个训练数据集上进行训练,但使用的是样本外预测。堆叠过程包括以下步骤:

  1. 将训练数据集分割成k部分。

  2. k-1部分上迭代训练基本模型,在k^(th)部分上进行预测,并保存预测结果。完成此步骤后,我们有了来自所有基本模型的训练数据集的样本外预测。

  3. 在这些预测上训练一个元模型。

混合与此类似,但在生成样本外预测的方式上略有不同。混合涉及以下步骤:

  1. 将训练数据集分为两部分——训练和保留。

  2. 在训练数据集上训练基本模型并在保留数据集上进行预测。

  3. 在具有基本模型预测结果的验证数据集上训练元模型。

直觉上,我们可以看到堆叠可以工作得更好,因为它使用一个更大的数据集(通常是所有训练数据)作为样本外预测,所以元模型可能更加泛化。但是有一个警告:我们假设整个训练数据是独立同分布iid)。这通常是一个很难在时间序列中满足的假设,因为数据生成过程可以随时改变(逐渐或急剧)。如果我们知道数据分布随时间发生了显著变化,那么混合保留期(通常是数据集的最近部分)更好,因为元模型只学习最新的数据,从而尊重数据分布的时间变化。

我们可以包含作为基本模型的模型数量没有限制,但通常会达到一个平台,额外的模型不会对堆叠集成产生太大的影响。我们还可以添加多个堆叠级别。例如,假设有四个基本学习器:B[1,] B[2,] B[3] 和 B[4]。我们还训练了两个元模型 M[1] 和 M[2],在基本模型上。现在,我们可以在 M[1] 和 M[2] 的输出上训练第二级元模型 M,并将其用作最终预测。我们可以使用pystacknet Python 库(github.com/h2oai/pystacknet),这是一个名为stacknet的旧库的 Python 实现,以便轻松创建多级(或单级)堆叠集成的过程。

另一个要牢记的关键点是我们通常用作元模型的模型类型。假设大部分学习已经由基本模型完成,这些基本模型是用于预测的多维数据的模式。因此,元模型通常是简单模型,例如线性回归、决策树,甚至比基本模型低得多的随机森林。另一种思考这个问题的方式是从偏差和方差的角度来看。堆叠可能会过拟合训练或留出集,并通过包含具有更大灵活性或表达能力的模型族,我们正在促使这种过拟合发生。进一步阅读部分包含了一些链接,从通用机器学习的角度解释了不同的堆叠技术。

现在,让我们快速看看如何在我们的数据集中使用这个:

from sklearn.linear_model import LinearRegression
stacking_model = LinearRegression()
# ensemble_forecasts is the list of candidates
stacking_model.fit(
    pred_wide_val[ensemble_forecasts], pred_wide_val["energy_consumption"]
)
pred_wide_test["linear_reg_blending"] = stacking_model.predict(
    pred_wide_test[ensemble_forecasts]
) 

这将为线性回归保存混合预测为linear_reg_blending。我们可以使用相同的代码,但交换模型以尝试其他模型。

最佳实践:

当存在许多基本模型并且我们想要进行隐式基本模型选择时,我们可以选择其中一个正则化线性模型,例如岭回归或套索回归。在他最初的论文中,Breiman 提出了堆叠回归,建议使用具有正系数且没有截距的线性回归作为元模型。他认为这样可以理论上保证堆叠模型至少与任何最佳个体模型一样好。但在实践中,我们可以在实验中放宽这些假设。没有截距的非负回归与我们之前讨论过的最佳加权集成非常接近。最后,如果我们正在评估多个堆叠模型以选择哪个效果好,我们应该要么使用单独的验证数据集(而不是训练-验证-测试分割,我们可以使用训练-验证-验证元-测试分割),要么使用交叉验证估计。如果我们只是选择在测试数据集上表现最好的堆叠模型,那么我们就是在测试数据集上过拟合了。

现在,让我们看看混合模型在我们的测试数据上的表现:

图 9.9 – 混合模型的聚合指标

图 9.10:混合模型的聚合指标

在这里,我们可以看到简单的线性回归学习了一个比我们任何平均集成方法都要好得多的元模型。而 Huber 回归(这是一种直接优化 MAE 的方法)在 MAE 基准测试上表现得更好。然而,请记住这并非普遍适用,必须针对遇到的每个问题进行评估。选择要优化的指标和要用于组合的模型会产生很大的差异。通常,简单的平均集成是组合模型的一个非常可观的基准。

Huber 回归是线性回归的另一种版本(如岭回归和套索回归),其损失函数是平方损失(用于常规线性回归)和绝对损失(用于 L1 方法)的组合。对于小残差,它表现得像平方损失,而对于大残差,它表现得像绝对损失。这使得它对异常值不太敏感。Scikit-Learn 提供了 HuberRegressor (scikit-learn.org/stable/modules/generated/sklearn.linear_model.HuberRegressor.html),用于实现这一方法。

附加阅读:

还有其他更具创新性的方式来结合基础预测。这是一个活跃的研究领域。进一步阅读部分包含了两个非常相似的想法的链接。基于特征的预测模型平均法FFORMA)从时间序列中提取一组统计特征,并用它来训练一个机器学习模型,预测基础预测应如何加权结合。另一种技术(用于快速且可扩展的时间序列超参数调整的自监督学习),来自 Facebook(Meta)研究,训练一个分类器,预测给定一组从时间序列中提取的统计特征时,哪个基础学习器表现最好。

摘要

延续上一章中的实用课程,我们又完成了一个动手实践的课程。在本章中,我们从上一章的不同机器学习模型中生成了预测结果。我们学会了如何将这些不同的预测结果结合成一个比任何单一模型表现都更好的预测。接着,我们探索了组合优化和堆叠/混合等概念,以实现最先进的结果。

在下一章中,我们将开始讨论全球预测模型,并探索策略、特征工程等,以便实现这种建模。

参考文献

本章提供了以下参考文献:

  1. David S. Johnson,Cecilia R. Aragon,Lyle A. McGeoch,和 Catherine Schevon(1989),模拟退火优化:实验评估;第一部分,图形划分。运筹学,1989 年,第 37 卷,第 6 期,865-892:dx.doi.org/10.1287/opre.37.6.865

  2. L. Breiman(1996),堆叠回归。机器学习 24,49-64:doi.org/10.1007/BF00117832

  3. Mark J. van der Laan;Eric C. Polley;和 Alan E. Hubbard(2007),超级学习者。加利福尼亚大学伯克利分校生物统计学系工作论文系列。工作论文 222:biostats.bepress.com/ucbbiostat/paper222

进一步阅读

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

加入我们的 Discord 社区

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

packt.link/mts

第十章:全球预测模型

在前面的章节中,我们已经看到如何使用现代机器学习模型解决时间序列预测问题,本质上是替代了传统的模型,如 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 的基数)的分类特征,有 2k(-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

第十四章:时间序列中的注意力与变换器

在上一章中,我们卷起袖子,实施了一些用于时间序列预测的深度学习DL)系统。我们使用了在第十二章中讨论的常见构建块,深度学习时间序列的构建块,将它们组合成一个编码器-解码器架构,并训练它们产生我们期望的预测。

现在,让我们谈谈深度学习(DL)中的另一个关键概念,它在过去几年迅速席卷了这个领域——注意力。注意力有着悠久的历史,并最终成为了 DL 工具箱中最受欢迎的工具之一。本章将带领你从理论的角度理解注意力和变换器模型,并通过实际示例巩固这一理解。

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

  • 什么是注意力?

  • 广义注意力模型

  • 使用序列到序列模型和注意力进行预测

  • 变换器——注意力就是你所需要的一切

  • 使用变换器进行预测

技术要求

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

本章你需要运行以下笔记本:

  • 02-Preprocessing_London_Smart_Meter_Dataset.ipynbChapter02

  • 01-Setting_up_Experiment_Harness.ipynbChapter04

  • 01-Feature_Engineering.ipynbChapter06

  • 02-One-Step_RNN.ipynb03-Seq2Seq_RNN.ipynbChapter13 中(用于基准测试)

  • 00-Single_Step_Backtesting_Baselines.ipynb01-Forecasting_with_ML.ipynbChapter08

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

什么是注意力?

注意力的概念灵感来自于人类的认知功能。我们眼中的视神经、鼻中的嗅神经以及耳中的听神经,时刻向大脑传送大量的感官信息。这些信息量过于庞大,显然超过了大脑的处理能力。但我们的大脑已经发展出一种机制,帮助我们只对那些重要的刺激物注意——比如一个不属于的声音或气味。经过多年的进化,我们的大脑已经被训练去挑出异常的声音或气味,因为这对于我们在野外生存至关重要,那时捕食者自由漫游。在认知科学中,注意力被定义为一种认知过程,允许个体选择性地专注于特定信息,同时忽略其他无关的刺激。

除了这种本能的注意力外,我们还可以通过所谓的集中注意力来控制我们的注意力。你现在正在做的就是通过选择忽视所有其他的刺激,专注于本书的内容。当你阅读时,手机响了,屏幕亮了,尽管书本仍然摆在你面前,你的大脑决定将注意力集中到手机屏幕上。这种人类认知功能的特性正是深度学习中注意力机制的灵感来源。赋予学习机器这种注意力的能力,已经带来了今天 AI 各个领域的巨大突破。

这个理念最早应用于深度学习中的 Seq2Seq 模型,我们在第十三章《时间序列的常见建模模式》中学习了这一点。在那一章中,我们看到了编码器和解码器之间的“握手”是如何进行的。对于递归神经网络RNN)系列模型,我们使用序列末尾的编码器隐藏状态作为解码器的初始隐藏状态。我们把这种“握手”称为上下文。这里的假设是,解码任务所需的所有信息都被编码在上下文中,并且这是按时间步进行的。因此,对于较长的上下文窗口,第一时间步的信息必须通过多次写入和重写才能在最后一个时间步使用。这就形成了一种信息瓶颈(图 14.1),模型可能会在这个有限的上下文中难以保持重要信息。先前的隐藏状态中可能包含对解码任务有用的信息。2015 年,Bahdanau 等人(参考文献1)提出了深度学习领域中第一个已知的注意力模型。他们提出为每个对应于输入序列的隐藏状态学习注意力权重,,并在解码时将这些权重合并成一个单一的上下文向量。

在每个解码步骤中,这些注意力权重会根据解码过程中隐藏状态与输入序列中所有隐藏状态之间的相似度重新计算(图 14.2):

图 14.1 – 传统模型(上)与注意力模型(下)在 Seq2Seq 模型中的对比

图 14.1:传统模型(上)与注意力模型(下)在 Seq2Seq 模型中的对比

为了更清楚地说明这一机制,我们采用一种正式的描述方式。设为编码过程中生成的隐藏状态,为解码过程中生成的隐藏状态。上下文向量将是c[j]:

图 14.2 – 使用注意力机制解码

图 14.2:使用注意力机制进行解码

现在,我们有了来自编码阶段的隐藏状态(H),我们需要一种方法在每个解码步骤中使用这些信息。关键在于,在每个解码步骤中,来自不同隐藏状态的信息可能是相关的。这正是注意力权重的作用。因此,在解码步骤 j 中,我们使用 s[j][-1] 并计算注意力权重(我们很快会详细看一下如何学习注意力权重),a[i],j,使用 s[j][-1] 与 H 中每个隐藏状态的相似度。现在,我们计算上下文向量,它以正确的方式结合 H 中的信息:

我们可以使用这个上下文向量的两种主要方式,我们将在本章稍后详细讨论。这打破了传统 Seq2Seq 模型中的信息瓶颈,使得模型可以访问更大的信息池,并决定在每个解码步骤中哪些信息是相关的。

现在,让我们来看一下这些注意力权重,,是如何计算的。

通用的注意力模型

多年来,研究人员提出了不同的计算注意力权重和在深度学习模型中使用注意力的方法。Sneha Chaudhari 等人(参考文献8)发布了一篇关于注意力模型的调查论文,提出了一种通用的注意力模型,旨在将所有变体融合到一个框架中。让我们围绕这个通用框架组织我们的讨论。

我们可以将注意力模型看作是使用一组查询 q 来为一组键 K 学习一个注意力分布()。在我们上一节讨论的示例中,查询将是 S[j][-1]——解码过程中最后一个时间步的隐藏状态——而键将是 H——使用输入序列生成的所有隐藏状态。在某些情况下,生成的注意力分布被应用到另一个称为值 V 的输入集合。在许多情况下,KV 是相同的,但为了保持框架的通用形式,我们将它们分开考虑。使用这个术语,我们可以将注意力模型定义为 qKV 的一个函数:

在这里,a 是一个对齐函数,它计算查询(q)和键(k[i])之间的相似性或相似度的概念,而 v[i] 是与索引 对应的值。在我们在前面一节中讨论的示例中,这个对齐函数计算的是编码器隐藏状态与解码器隐藏状态的相关性,p 是一个分布函数,它将此分数转换为总和为 1 的注意力权重。

参考文献检查:

Sneha Choudhari 等人的研究论文在参考文献部分被引用为参考文献8

现在我们已经有了通用的注意力模型,让我们看看如何在 PyTorch 中实现它。完整实现可以在src/dl/attention.py中的Attention类中找到,但我们在这里将重点介绍其中的关键部分。

初始化该模块之前,我们所需的唯一信息是查询和键(编码器和解码器)的隐藏维度。因此,类定义和__init__函数如下所示:

class Attention(nn.Module, metaclass=ABCMeta):
    def __init__(self, encoder_dim: int, decoder_dim: int):
        super().__init__()
        self.encoder_dim = encoder_dim
        self.decoder_dim = decoder_dim 

现在,我们需要定义一个forward函数,该函数接受两个输入:

  • query:大小为(batch sizedecoder dimension)的查询向量,我们将用它来计算注意力权重,并用这些权重来组合键值。这是A(qKV)中的q

  • key:大小为(batch sizesequence lengthencoder dimension)的键向量,这是我们将计算注意力权重的隐状态序列。这是A(qKV)中的K

我们假设键和值是相同的,因为在大多数情况下,它们确实是。所以,根据通用的注意力模型,我们知道我们需要执行几个步骤:

  1. 计算一个对齐分数—a(k[i],q)—对于每个查询和键的组合。

  2. 通过应用一个函数将分数转换为权重—p(a(k[i],q))。

  3. 使用学习到的权重来组合数值—

那么,让我们看看在forward方法中的这些步骤:

def forward(
        self,
        query: torch.Tensor,  # [batch_size, decoder_dim]
        values: torch.Tensor,  # [batch_size, seq_length, encoder_dim]
    ):
        scores = self._get_scores(query, values)  # [batch_size, seq_length]
        weights = torch.nn.functional.softmax(scores, dim=-1)
        return (values*weights.unsqueeze(-1)).sum(dim=1)  # [batch_size, encoder_dim] 

forward方法中的三行代码对应我们之前讨论的三个步骤。第一步是计算分数,这是关键步骤,促成了许多不同类型的注意力机制,因此我们将其抽象为一个_get_scores方法,任何继承Attention类的类都必须实现此方法。在第二行,我们使用了softmax函数将分数转换为权重,最后一行则是对权重和数值进行逐元素相乘(*),并沿序列长度求和,得到加权后的值。

现在,让我们将注意力转向对齐函数。

对齐函数

随着时间的推移,已经出现了许多不同版本的对齐函数。让我们回顾一下今天常用的几种。

点积

这可能是所有对齐函数中最简单的一种。Luong 等人于 2015 年提出了这种形式的注意力机制。从线性代数中我们知道,两个向量的点积可以告诉我们一个向量在另一个向量方向上的投影量。它衡量了两个向量之间的相似性,这种相似性同时考虑了向量的大小和它们在向量空间中的夹角。因此,当我们计算查询和键向量的点积时,我们得到了一种它们之间相似性的度量。需要注意的一点是,查询和键的隐藏维度应该相同,才能应用点积注意力。正式地,相似度函数可以定义如下:

我们需要为每个键中的元素K[i]计算这个分数,K中的每个元素可以通过使用一个巧妙的矩阵乘法技巧,一次性计算出所有键的分数,而无需遍历K中的每个元素。让我们看看如何为点积注意力定义_get_scores函数。

从上一节我们知道,查询和值(在我们的情况下与键相同)分别是(batch sizedecoder dimension)和(batch sizesequence lengthencoder dimension)维度,在_get_scores函数中它们将分别称为qv。在这个特殊情况下,解码器维度和编码器维度是相同的,因此分数可以通过如下方式计算:

scores = (q @ v.transpose(1,2)) 

这里,@torch.matmul的简写,用于进行矩阵乘法。整个实现的名称为DotProductAttention,可以在src/dl/attention.py中找到。

缩放点积注意力

在 2017 年,Vaswani 等人提出了这种注意力机制,在开创性的论文《Attention Is All You Need》中进行了介绍。我们将在本章稍后深入探讨这篇论文,但现在,让我们先理解他们对点积注意力提出的一个关键修改。这个修改是出于这样一个考虑:当输入非常大时,我们用来将分数转换为权重的softmax 函数可能会有非常小的梯度,这使得高效学习变得困难。

这是因为softmax函数不是尺度不变的。softmax函数中的指数函数是导致这种行为的原因。因此,当我们扩大输入的尺度时,最大的输入会更加主导输出,从而限制了网络中的梯度流动。如果我们假设qv是具有零均值和方差为 1 的d[k]维向量,那么它们的点积将具有零均值和d[k]的方差。因此,如果我们通过来缩放点积的输出,那么我们就将点积的方差重新调整为 1。因此,通过控制softmax函数输入的尺度,我们能够在网络中维持健康的梯度流动。进一步阅读部分包含了一篇博客文章链接,详细讲解了这一内容。因此,缩放点积对齐函数可以定义如下:

因此,我们需要对PyTorch实现做的唯一更改就是再增加一行:

scores = scores/math.sqrt(encoder_dim) 

这已经在src/dl/attention.py中的DotProductAttention参数中实现。如果在初始化类时传递scaled=True,它将执行缩放点积注意力。我们需要记住,与点积注意力类似,缩放变种也要求查询和数值具有相同的维度。

通用注意力

2015 年,Luong 等人(参考文献2)通过在计算中引入可学习的W矩阵,提出了点积注意力的轻微变种。他们称之为通用注意力。我们可以将其视为一种注意力机制,它允许查询在计算相似度分数时,先通过W矩阵投影到一个与值/键相同维度的学习平面上,再使用点积来计算相似度分数。对齐函数可以写成如下:

相应的PyTorch实现可以在src/dl/attention.py中找到,命名为GeneralAttention。计算注意力分数的关键代码如下:

scores = (q @ self.W) @ v.transpose(1,2) 

这里,self.W是一个大小为(编码器隐藏维度 x 解码器隐藏维度)的张量。通用注意力可以用于查询和键/值维度不同的情况。

加性/拼接注意力

2015 年,Bahdanau 等人提出了加性注意力,这是首次将注意力引入深度学习系统的尝试之一。与使用定义好的相似度函数(如点积)不同,Bahdanau 等人提出相似度函数可以通过学习来获得,使得网络在判断什么是相似时更加灵活。他们建议我们可以将查询和键拼接成一个张量,并使用可学习矩阵W来计算注意力分数。这个对齐函数可以写成如下:

在这里,v[t]、W[q] 和 W[k] 是可学习的矩阵。如果查询和键具有不同的隐藏维度,我们可以使用 W[q] 和 W[k] 将它们投影到同一个维度,然后对它们进行相似度计算。如果查询和键具有相同的隐藏维度,这也等同于 Luong 等人使用的注意力变体,他们称之为 concat 注意力,表示如下:

通过简单的线性代数可以看出,这两者是相同的,并且为了工程简便性,进一步阅读 部分有一个指向 Stack Overflow 解答的链接,解释了两者的等价性。

我们在 src/dl/attention.py 中的 ConcatAttentionAdditiveAttention 下包含了这两种实现。

对于 AdditiveAttention,计算得分的关键行如下:

q = q.repeat(1, v.size(1), 1)  # [batch_size, seq_length, decoder_dim]
scores = self.W_q(q) + self.W_v(v)  # [batch_size, seq_length, decoder_dim]
torch.tanh(scores) @ self.v  # [batch_size, seq_length] 

第一行将查询向量重复到序列长度。这只是一个线性代数技巧,用于一次性计算所有编码器隐藏状态的得分,而不是通过它们进行循环。第 2 行 使用 self.W_qself.W_v 将查询和数值投影到相同的维度,第 3 行 应用 tanh 激活函数并与 self.v 进行矩阵乘法以产生最终得分。self.W_qself.W_vself.v 是可学习的矩阵,定义如下:

self.W_q = torch.nn.Linear(self.decoder_dim, self.decoder_dim)
self.W_v = torch.nn.Linear(self.encoder_dim, self.decoder_dim)
self.v = torch.nn.Parameter(torch.FloatTensor(self.decoder_dim) 

ConcatAttention 中唯一的不同之处是,我们没有两个独立的权重——self.W_qself.W_v——而是只有一个权重——self.W,定义如下:

self.W = torch.nn.Linear(self.decoder_dim + self.encoder_dim, self.decoder_dim) 

并且我们不再添加投影(第 2 行),而是使用以下一行:

scores = self.W(
            torch.cat([q, v], dim=-1)
        )  # [batch_size, seq_length, decoder_dim] 

因此,我们可以认为 AdditiveAttentionConcatAttention 执行相同的操作,但 AdditiveAttention 被调整以处理不同的编码器和解码器维度。

参考检查:

Luong 等人、Badahnau 等人和 Vaswani 等人的研究论文在 参考文献 部分分别作为参考文献 215 被引用。

现在我们已经了解了一些流行的对齐函数,让我们将注意力转向注意力模型的分布函数。

分布函数

分布函数的主要目标是将对齐函数中学习到的得分转换为一组加起来为 1 的权重。softmax 函数是最常用的分布函数,它将得分转换为一组加起来为 1 的权重。这也让我们能够将学习到的权重解释为概率——即相应元素是最相关的概率。

虽然softmax是最常用的选择,但它并非没有缺点。softmax的权重通常是密集的。这意味着概率质量(某些权重)会分配给我们计算注意力时序列中的每个元素。这些权重可能很低,但仍然不为零。有时我们希望在分布函数中引入稀疏性。也许我们希望确保不为某些不太可能的选项分配任何权重。也许我们希望使注意力机制更加可解释。

还有其他分布函数,如sparsemax(Martins 等人 2016 年,参考文献3)和entmax(Peters 等人 2019 年,参考文献4),它们能够将概率质量分配给一些相关元素,并将其余元素的概率设置为零。当我们知道输出仅依赖于编码器中的某些时间步时,我们可以使用这样的分布函数将这些知识编码到模型中。像Sparsemax这样的空间激活函数具有可解释性的优势,因为它们提供了一个更清晰的区分,表明哪些元素是重要的(非零概率),哪些是无关的(零概率)。

参考检查:

Martins 等人和 Peters 等人的研究论文分别在参考文献部分被引用为参考文献34

现在我们已经了解了一些注意力机制,是时候将它们付诸实践了。

使用序列到序列模型和注意力机制进行预测

让我们从第十三章时间序列的常见建模模式中接着讲,那里我们使用 Seq2Seq 模型预测了一个示例家庭的情况(如果你还没有阅读前一章,我强烈建议你现在阅读)并修改了Seq2SeqModel类以包括注意力机制。

笔记本提醒:

若要跟进完整的代码,请使用Chapter14文件夹中的01-Seq2Seq_RNN_with_Attention.ipynb笔记本以及src文件夹中的代码。

我们仍然会继承在src/dl/models.py中定义的BaseModel类,并且整体结构将与Seq2SeqModel类非常相似。关键的区别在于,在我们的新模型中,使用注意力机制时,我们不接受全连接层作为解码器。并不是因为它不可行,而是出于实现的便利性和简洁性。事实上,实现一个带有全连接解码器的 Seq2Seq 模型是你可以自己做的事情,这样可以真正内化这个概念。

类似于Seq2SeqConfig类,我们定义了一个非常相似的Seq2SeqwAttnConfig类,它具有完全相同的一组参数,但增加了一些额外的验证检查。一个验证检查是禁止使用全连接的解码器。另一个验证检查是确保解码器输入的大小允许使用注意力机制。我们稍后会详细看到这些要求。

除了 Seq2SeqwAttnConfig,我们还定义了一个 Seq2SeqwAttnModel 类来启用支持注意力的解码。这里唯一的额外参数是 attention_type,这是一个字符串类型的参数,可以取以下值:

  • dot:点积注意力

  • scaled dot:缩放点积注意力

  • general:通用注意力

  • additive:加法注意力

  • concat:拼接注意力

整个代码可以在 src/dl/models.py 中找到。我们将在书中详细讲解 forward 函数,因为这是唯一一个有关键区别的地方。类的其余部分涉及根据输入参数等定义正确的注意力模型。

编码器部分与我们在上一章看到的 SeqSeqModel 完全相同。唯一的区别是在解码部分,我们将使用注意力。

现在,让我们讨论一下我们将如何在解码中使用注意力输出。

如我之前提到的,在解码时使用注意力有两种思路。我们使用的注意力术语来看,让我们看看它们之间的区别。

Luong 等人使用解码器在步骤 j 的隐藏状态 s[j],计算它与所有编码器隐藏状态 H 之间的相似度,从而计算上下文向量 c[j]。然后,将该上下文向量 c[j] 与解码器隐藏状态 s[j] 拼接在一起,这个组合后的张量被用作输入到生成输出的线性层。

Bahdanau 等人以另一种方式使用注意力。他们使用前一时间步解码器的隐藏状态 s[j-1],并计算它与所有编码器隐藏状态 H 之间的相似度,从而计算上下文向量 c[j]。然后,这个上下文向量 c[j] 会与解码步骤 j 的输入 x[j] 拼接在一起。正是这个拼接后的输入被用在使用 RNN 的解码步骤中。

我们可以在 图 14.3 中直观地看到它们之间的区别。进一步阅读 部分还提供了关于注意力的另一精彩动画——Attn: Illustrated Attention。这也能帮助你更好地理解机制:

图 14.3 – 基于注意力的解码:Bahdanau 与 Luong

图 14.3:基于注意力的解码:Bahdanau 与 Luong

在我们的实现中,我们选择了 Bahdanau 解码方式,在这种方式下,我们将拼接的上下文向量和输入作为解码的输入。因此,解码器必须满足一个条件:解码器的 input_size 参数应当等于编码器的 input_size 参数与编码器的 hidden_size 参数之和。这个验证被内建在 Seq2SeqwAttnConfig 中。

以下代码块包含了所有必要的注意力解码代码,并且带有行号,这样我们可以逐行解释我们在做什么:

01        y_hat = torch.zeros_like(y, device=y.device)
02        dec_input = x[:, -1:, :]
03        for i in range(y.size(1)):
04            top_h = self._get_top_layer_hidden_state(h)
05            context = self.attention(
06                top_h.unsqueeze(1), o
07            )
08            dec_input = torch.cat((dec_input, context.unsqueeze(1)), dim=-1)
09            out, h = self.decoder(dec_input, h)
10            out = self.fc(out)
11            y_hat[:, i, :] = out.squeeze(1)
12            teacher_force = random.random() < self.hparams.teacher_forcing_ratio
13            if teacher_force:
14                dec_input = y[:, i, :].unsqueeze(1)
15            else:
16                dec_input = out 

第 1 行第 2 行Seq2SeqModel类中的设置相同,我们在其中设置变量来存储预测,并提取传递给解码器的起始输入,第 3 行开始逐步进行解码循环。

现在,在每一步中,我们需要使用前一时间步的隐藏状态来计算上下文向量。如果你记得 RNN 的输出形状(第十二章构建时间序列深度学习的基础),我们知道它是(层数批量大小隐藏大小)。但我们需要的查询隐藏状态的维度是(批量大小隐藏大小)。Luong 等人建议使用堆叠 RNN 模型顶部层的隐藏状态作为查询,这正是我们在这里所做的:

hidden_state[-1, :, :] 

如果 RNN 是双向的,我们需要稍微调整检索过程,因为现在,张量的最后两行将是来自最后一层的输出(一前一后)。有很多方式可以将它们合并为一个单一张量——我们可以将它们连接起来,或者对它们求和,甚至可以通过线性层将它们混合。这里,我们只是将它们连接起来:

torch.cat((hidden_state[-1, :, :], hidden_state[-2, :, :]), dim=-1) 

现在我们有了隐藏状态,我们将其作为查询传入注意力层(第 5 行)。在第 8 行,我们将上下文与输入连接起来。第 9第 16 行以类似于Seq2SeqModel的方式完成剩余的解码。

该笔记本训练了一个多步骤的 Seq2Seq 模型(最佳表现的变体使用教师强制)以及本章中介绍的所有不同类型的注意力机制,使用我们在上一章中开发的相同设置。结果总结如下表所示:

图 14.4 – 带注意力机制的 Seq2Seq 模型汇总表

图 14.4:带注意力机制的 Seq2Seq 模型汇总表

我们可以看到,通过引入注意力机制,MAEMSEMASE都有了显著的改善,在所有注意力变体中,简单的点积注意力表现最好,其次是加性注意力。此时,可能有些人心中会有一个问题——为什么缩放点积没有比点积注意力表现得更好? 缩放应该能使点积效果更好,不是吗?

这里有一个教训(适用于所有机器学习ML))。无论某种技术在理论上有多好,你总能找到一些例子证明它表现更差。在这里,我们只看到了一个家庭,并不奇怪我们看到缩放点积注意力没有比普通点积注意力更好。但如果我们在大规模评估中发现这是跨多个数据集的模式,那么就值得关注了。

所以,我们已经看到,注意力机制确实使得模型变得更好。大量的研究都在探讨如何利用不同形式的注意力来增强神经网络NN)模型的性能。大部分这类研究都集中在自然语言处理NLP)领域,特别是在语言翻译和语言建模方面。不久后,研究人员偶然发现了一个惊人的结果,这一发现极大地改变了深度学习(DL)发展的轨迹——Transformer 模型。

Transformer——注意力就是你所需要的一切

虽然引入注意力机制对 RNN 和 Seq2Seq 模型来说是一针强心剂,但它们仍然存在一个问题。RNN 是递归的,这意味着它们需要按顺序处理句子中的每个单词。

对于流行的 Seq2Seq 模型应用,如语言翻译,这意味着处理长序列的单词变得非常耗时。简而言之,很难将它们扩展到大规模的数据语料库。在 2017 年,Vaswani 等人(参考文献5)发表了一篇具有里程碑意义的论文,题为Attention Is All You Need。正如论文标题所暗示的,他们探讨了一种使用注意力(缩放点积注意力)的架构,并完全抛弃了递归网络。令全球 NLP 研究人员惊讶的是,这些被称为 Transformer 的模型在语言翻译任务中超过了当时最先进的 Seq2Seq 模型。

这激发了围绕这一新型模型类别的研究热潮,没过多久,在 2018 年,Google 的 Devlin 等人(参考文献6)开发了一种双向 Transformer,并训练了现在著名的语言模型BERT(即Bidirectional Encoder Representations from Transformers),并在多个任务中突破了许多最新的技术成果。这被认为是 Transformer 作为模型类别真正登上舞台的时刻。快进到 2022 年——Transformer 模型已经无处不在。它们几乎被应用于 NLP 中的所有任务,并且在许多其他基于序列的任务中也有所应用,比如时间序列预测和强化学习RL)。它们还成功应用于计算机视觉CV)任务中。

对原始 Transformer 模型进行了许多修改和适应,使其更适合时间序列预测。但让我们先讨论 Vaswani 等人 2017 年提出的原始 Transformer 架构。

注意力就是你所需要的一切

Vaswani 等人提出的模型(以下简称“原始 Transformer”)也是一个编码器-解码器模型,但编码器和解码器都是非递归的。它们完全由注意力机制和前馈网络组成。由于 Transformer 模型最初是为文本序列开发的,我们就用相同的例子来理解,然后再适应到时间序列的上下文。

为了将整个模型组合起来,需要理解模型中的几个关键组件。我们逐一来看。

自注意力

我们之前在本章中看到了缩放点积注意力是如何工作的(在对齐函数部分),但在那里,我们计算的是编码器和解码器隐藏状态之间的注意力。当我们有一个输入序列并计算该输入序列本身之间的注意力分数时,这就是自注意力。直观地说,我们可以将这个操作视为增强上下文信息,并使下游组件能够利用这些增强的信息进行进一步处理。

我们之前看过PyTorch中编码器-解码器注意力的实现,但那个实现更偏向于逐步解码 RNN。通过标准矩阵乘法一次性计算每个查询-键对的注意力分数是非常简单且对计算效率至关重要的事情。

笔记本提示:

要跟随完整的代码,请使用位于Chapter14文件夹中的笔记本02-Self-Attention_and_Multi-Headed_Attention.ipynb

在自然语言处理中,将每个单词表示为称为嵌入的可学习向量是标准做法。这是因为文本或字符串在数学模型中没有位置。为了我们的示例,假设我们为每个单词使用大小为 512 的嵌入向量,并假设注意机制具有 64 的内部维度。让我们通过一个包含 10 个单词的句子来阐明注意机制。

在嵌入后,句子将成为一个维度为(10, 512)的张量。我们需要三个可学习的权重矩阵W[q]、W[k]和W[v]来将输入嵌入投影到注意力维度(64)。参见图 14.5

图 14.5 – 自注意力层:输入句子和可学习权重

图 14.5:自注意力层:输入句子和可学习权重

第一步操作将句子张量投影到查询、键和值,其维度等于(序列长度注意力维度)。这是通过使用句子张量和可学习矩阵之间的矩阵乘法来实现的。参见图 14.6

图 14.6 – 自注意力层:查询、键和值投影

图 14.6:自注意力层:查询、键和值投影

现在我们有了查询、键和值,我们可以使用查询与键的转置之间的矩阵乘法来计算每个查询-键对的注意力权重。矩阵乘法实际上就是每个查询与每个值的点积,给出了一个大小为(序列长度序列长度)的方阵。参见图 14.7

图 14.7

图 14.7:自注意力层:查询和键之间的注意力分数

将注意力分数转换为注意力权重只是简单地进行缩放并应用softmax函数,正如我们在缩放点积注意力部分讨论过的那样。

现在我们已经得到了注意力权重,可以利用它们来结合值。通过元素级的乘法然后在权重上求和,可以通过另一种矩阵乘法高效完成。见图 14.8

图 14.8 – 自注意力层:使用学习到的注意力权重结合 V

图 14.8:自注意力层:使用学习到的注意力权重结合 V

现在,我们已经看到注意力如何应用于所有查询-键对的整体矩阵运算,而不是以顺序方式对每个查询进行相同的操作。但Attention Is All You Need提出了一个更好的方法。

多头注意力

由于 Transformers 旨在摒弃整个递归架构,它们需要增强注意力机制,因为那是模型的主力。因此,论文的作者提出了多个注意力头共同作用于不同子空间。我们知道,注意力帮助模型专注于众多元素中的少数几个。多头注意力MHA)做了同样的事情,但它关注的是不同的方面或不同的元素集,从而增加了模型的容量。如果我们想用人类思维来做个类比,我们在做决策前会考虑情况的多个方面。

比如,如果我们决定走出家门,我们会关注天气,关注时间,以确保无论我们想完成什么,都是可能的,我们会关注和你约好见面的朋友过去的守时情况,然后根据这些去决定何时离开家。你可以把这些看作是注意力的每一个头。因此,MHA 使得 Transformers 能够同时关注多个方面。

通常情况下,如果有八个头,我们会认为我们必须做上节中看到的计算八次。但幸运的是,事实并非如此。通过使用相同类型的矩阵乘法,但现在使用更大的矩阵,有巧妙的方法完成这个 MHA。让我们来看一下是如何做到的。

我们将继续使用相同的例子,看看在我们有八个注意力头的情况下会发生什么。有一个条件需要满足,以便高效计算 MHA——注意力维度应该能够被我们使用的头数整除。

最初的步骤完全相同。我们将输入句子的张量传入,并将其投影到查询、键和值。现在,我们通过进行一些基本的张量重排,将查询、键和值分割成每个头的独立查询、键和值子空间。见图 14.9

图 14.9 – 多头注意力:将 Q、K 和 V 重塑为每个头的子空间

图 14.9:多头注意力:将 Q、K 和 V 重塑为每个头的子空间

现在,我们对每个头计算注意力得分并将其与值结合,以获取每个头的注意力输出。请参见图 14.10

图 14.10:多头注意力:计算注意力权重并结合值

我们得到了每个头的注意力输出,保存在attn_output变量中。现在,我们只需要重塑数组,将所有注意力头的输出堆叠在一个维度上。请参见图 14.11

图 14.11 – 多头注意力:重塑并堆叠所有注意力头输出

图 14.11:多头注意力:重塑并堆叠所有注意力头输出

通过这种方式,我们可以快速高效地执行多头注意力(MHA)。现在,让我们来看一下另一项关键创新,它使得 Transformers 能够工作。

位置信息编码

Transformer 成功地避免了递归,突破了顺序操作的性能瓶颈。这也意味着 Transformer 模型对序列的顺序不敏感。从数学角度来看,如果 RNNs 考虑将序列视为一个序列,Transformers 则将其视为一组值。对于 Transformer 来说,每个位置彼此独立,因此我们期望从处理序列的模型中获得的一个关键特征是缺失的。原始作者确实提出了一种方法,确保我们不会丢失这些信息——位置信息编码

在后续几年的研究中,出现了许多位置信息编码的变种,但最常见的仍然是原始 Transformer 中使用的变种。

Vaswani 等人提出的解决方案是,在处理输入标记通过自注意力层之前,向每个输入标记添加一个特殊的向量,该向量通过正弦和余弦函数对位置进行数学编码。如果输入X是一个n个标记的d[model]维嵌入,位置信息编码P是一个相同大小的矩阵(n x d[model])。矩阵中pos^(行)和 2i^(列)或(2i + 1)^(列)的元素定义如下:

尽管这看起来有点复杂且违背直觉,但让我们分解一下,便于更好地理解。

从 20,000 英尺的高度来看,我们知道这些位置信息编码捕捉了位置信息,并将其添加到输入嵌入中。但为什么我们要将它们添加到输入嵌入中呢?让我们来澄清一下。假设嵌入维度只有 2(这是为了便于可视化和更好地理解概念),并且我们有一个单词,A,用这个标记表示。为了方便实验,假设在我们的序列中多次重复相同的单词,A。如果我们将位置信息编码添加到它上面会发生什么呢?

我们知道正弦或余弦函数的值在 0 和 1 之间变化。因此,我们添加到单词嵌入中的每个编码只是扰动了单词嵌入在单位圆内的位置。随着pos的增加,我们可以看到位置编码的单词嵌入在原始嵌入周围描绘一个单位圆(见图 14.12):

图 14.12 – 位置编码:直观理解

图 14.12:位置编码:直观理解

图 14.12中,我们假设了一个单词A(由交叉标记表示)的随机嵌入,并且假设A处于不同的位置,添加了位置嵌入。这些位置编码向量由星号标记表示,并在旁边用数字标注了对应的位置。我们可以看到,每个位置是原始向量的一个稍微扰动的点,并且这种扰动是以顺时针方向周期性进行的。我们可以看到位置 0 位于最上方,接下来是 1、2、3,依此类推,按顺时针方向排列。通过这种表示,模型能够识别单词在不同位置的含义,并且仍然保持语义空间中的整体位置。

现在我们知道为什么要将位置编码添加到输入嵌入中,并且了解了它为何有效,让我们深入了解细节,看看正弦和余弦函数中的各个项是如何计算的。pos表示标记在序列中的位置。如果序列的最大长度是 128,pos的值从 0 到 127 变化。i表示嵌入维度中的位置,由于公式的定义方式,对于每个i值,我们有两个值——一个正弦和一个余弦。因此,i将是维度数量的一半,d[model],并且从 0 到d[model]/2 变化。

有了这些信息,我们知道正弦和余弦函数内部的项在我们接近嵌入维度的末端时趋向于 0。它还从 0 开始随着序列维度的推进而增加。对于嵌入维度中每一对(2i 和 2i+1)的位置,我们都有一个互补的正弦和余弦波,如图 14.13所示:

图 14.13 – 位置编码:正弦和余弦项

图 14.13:位置编码:正弦和余弦项

我们可以看到,嵌入维度4041是具有相同频率的正弦和余弦波,而嵌入维度4042是正弦波,频率略有增加。通过使用频率不同的正弦和余弦波组合,位置编码可以将丰富的位置信息编码为一个向量。如果我们绘制整个位置编码向量的热图(参考颜色图像文件:packt.link/gbp/9781835883181),我们可以看到值的变化及其如何编码位置信息:

图 14.14 – 位置编码:整个向量的热图

图 14.14:位置编码:整个向量的热图

另一个有趣的观察是,随着我们在嵌入维度中前进,位置编码会迅速收敛到 0/1,因为正弦或余弦函数中的项(弧度角度)会由于分母过大而迅速变为零。放大的图表清晰地显示了颜色差异。

现在,让我们来看一下 Transformer 模型中的最后一个组件。

位置-wise 前馈层

我们已经在第十二章《时间序列深度学习的构建块》中讨论过前馈网络。这里唯一需要注意的是,位置-wise 前馈层是指我们在每个位置上独立地应用相同的前馈层。如果我们有 12 个位置(或单词),那么我们将有一个前馈网络来处理每个位置。

Vaswani 等人将其定义为一个两层的前馈网络,其中转换操作被定义为将输入维度扩展到四倍的输入维度,应用 ReLU 激活函数后,再将其转换回原输入维度。具体操作可以写成如下数学公式:

FFN(x) = max(0, W[1]x + b[1]) W[2] + b[2]

这里,W[1]是一个维度为(输入大小4输入大小)的矩阵,W[2]是一个维度为(4输入大小,输入大小)的矩阵,b[1]和b[2]是相应的偏置,max(0, x)是标准的 ReLU 操作符。

有一些研究尝试将 ReLU 替换为其他激活函数,特别是门控线性单元GLUs),这在实验中显示出了潜力。来自谷歌的 Noam Shazeer 在此方面有一篇论文,如果你想了解更多关于这些新激活函数的信息,我建议查阅他在进一步阅读部分的论文。

现在我们已经了解了 Transformer 模型的所有必要组件,接下来看看它们是如何组合在一起的。

编码器

原始的 Transformer 模型是一个编码器-解码器模型。模型包含 N 个编码器块,每个编码器块内含有一个 MHA 层,并且在其间有一个带残差连接的位置-wise 前馈层(图 14.15):

图 14.15 – Transformer 模型,来自 Vaswani 等人《Attention is All You Need》

图 14.15:Vaswani 等人《Attention Is All You Need》中的 Transformer 模型

现在,让我们关注一下图 14.15的左侧部分,即编码器。编码器接收输入嵌入,并将位置编码向量加到输入中作为输入。进入 MHA 的三叉箭头表示查询(query)、键(key)和值(value)分割。MHA 的输出进入一个名为Add and Norm的块。让我们快速了解一下这个操作。

这里有两个关键操作——残差连接层归一化

残差连接

残差连接(或跳跃连接)是一系列引入深度学习的技术,旨在使深度网络的学习变得更加容易。该技术的主要优势在于它改善了网络中的梯度流动,从而促进了网络各部分的学习。它们在网络中引入了一个通过的记忆通道。我们已经看到一个实例,跳跃连接(尽管不是显而易见的)解决了梯度流动问题——长短时记忆网络LSTM)。LSTM 中的细胞状态作为这个通道,让梯度能够顺利通过网络,避免了梯度消失问题。

但如今,当我们提到残差连接时,我们通常想到的是 ResNets,它通过一种卷积神经网络CNN)架构,在深度学习历史上掀起了波澜,赢得了多个重要的图像分类挑战赛,包括 2015 年的 ImageNet 竞赛。他们引入了残差连接,以训练比当时流行的架构更深的网络。这个概念看似简单,我们来直观地理解它:

图 14.16 – 残差网络

图 14.16:残差网络

假设我们有一个包含两层函数的深度学习模型,M[1]和M[2]。在常规的神经网络中,输入 x 会通过这两层,从而得到输出 y。这两个单独的函数可以看作一个将 x 转换为 y 的单一函数:y = F(x)。

在残差网络中,我们将这种范式改变为每个独立的函数(或层)仅学习输入与期望输出之间的差异。这就是残差连接名称的由来。因此,如果 h[1] 是期望输出,x 是输入,那么 M1 = h[1] - x。重写这一公式,我们得到 h[1] = M1 + x。这就是最常用的残差连接。

残差连接的诸多好处之一是它改善了梯度流动,此外它还使损失面更加平滑(Li 等,2018 年,参考文献 7),更适合基于梯度的优化。关于残差网络的更多细节和直觉,我建议你查看 Further reading 部分中的博客链接。

所以,Transformer 中的 Add and Norm 块中的 Add 实际上是残差连接。

层归一化

深度神经网络DNNs)中的归一化一直是一个活跃的研究领域。在众多好处中,归一化能够加速训练、提高学习速率,甚至起到一定的正则化作用。批归一化是最常见的归一化技术,通常应用于计算机视觉(CV)中,它通过在当前批次中减去输入均值并除以标准差,使得输入数据的均值接近零,方差接近单位。

但在自然语言处理(NLP)中,研究人员更倾向于使用层归一化,其中归一化发生在每个特征上。可以在 图 14.17 中看到两者的区别:

图 14.17 – 批量归一化与层归一化

图 14.17:批量归一化与层归一化

层归一化的偏好是通过经验得出的,但已经有研究探讨了这种偏好的原因。与计算机视觉(CV)数据相比,自然语言处理(NLP)数据通常具有更高的方差,而这种方差会导致批量归一化出现一些问题。另一方面,层归一化对此免疫,因为它不依赖于批量级别的方差。

无论如何,Vaswani 等人决定在他们的加法与归一化(Add and Norm)块中使用层归一化。

现在,我们知道加法与归一化块实际上就是一个残差连接,之后通过层归一化。因此,我们可以看到,位置编码的输入首先在多头自注意力(MHA)层中使用,MHA 的输出再次与位置编码的输入相加,并通过层归一化。接下来,这个输出通过位置-wise 前馈网络和另一个加法与归一化层,这就形成了编码器的一个块。一个重要的点是,编码器中所有元素的架构设计使得每个位置的输入维度在整个过程中得以保持。换句话说,如果嵌入向量的维度为 100,那么编码器的输出也将具有 100 的维度。这是一种便捷的方式,使得能够使用残差连接并尽可能堆叠多个层。现在,有多个这样的编码器块堆叠在一起,形成 Transformer 的编码器。

解码器

解码器块与编码器块非常相似,但有一个关键的区别。解码器块不仅包含单一的自注意力层,还包括一个自注意力层,该层作用于解码器输入,并且还有一个编码器-解码器注意力层。编码器-解码器注意力层在每个阶段从解码器获取查询(query),并从上层编码器块获取键(key)和值(value)。

解码器块中应用的自注意力有一些特别之处。让我们来看看到底是什么。

掩蔽自注意力

我们谈到了 Transformer 如何并行处理序列并且在计算上具有高效性。但解码的范式提出了另一个挑战。假设我们有一个输入序列,X = {x[1], x[2], …, x[n]},任务是预测下一个步骤。所以,在解码器中,如果我们给定序列X,由于并行处理架构,每个序列都会通过自注意力一次性处理。而且我们知道自注意力与序列顺序无关。如果不加限制,模型将通过使用未来时间步的信息来预测当前时间步。这就是掩蔽注意力变得重要的地方。

我们在 自注意力 部分中已经看过如何计算一个方阵(如果查询和键有相同的长度)的注意力权重,正是使用这些权重我们将信息从值向量中进行组合。这种自注意力没有时间性概念,所有的令牌都会关注所有其他令牌,而不管它们的位置。

让我们看看 图 14.18 来巩固我们的理解:

图 14.18 – 掩码自注意力

图 14.18:掩码自注意力

我们有序列,X = {x[1], x[2], …, x[5]},我们仍然尝试预测一步之遥。所以,解码器的期望输出是 。当我们使用自注意力时,学习到的注意力权重将是一个 5 X 5 的方阵。但是如果我们看方阵的上三角部分(图 14.18 中阴影部分),这些令牌组合会违反时间序列的独立性。

我们可以通过简单地添加一个预生成的掩码来解决这个问题,掩码中所有白色单元格为零,所有阴影单元格为 -inf,然后将其添加到生成的注意力能量中(即应用 softmax 之前的阶段)。这样可以确保阴影区域的注意力权重为零,从而确保在计算值向量的加权和时不使用未来的信息。

现在,为了总结所有内容,解码器的输出会传递给一个标准的任务特定头部,以生成我们期望的输出。

我们在讨论 Transformer 时是在 NLP 的背景下进行的,但将其适配到时间序列数据上是一个非常小的飞跃。

时间序列中的 Transformers

时间序列与 NLP 有很多相似之处,因为两者都涉及到序列中的信息,而且在这两种情况下,元素的顺序都很重要。在时间序列中,元素通常是按时间排序的数据点,而在 NLP 中,元素是构成句子或文档的令牌(如单词或字符)。这一点可以通过这样一个现象得到进一步验证:大多数流行的 NLP 技术很快就被适配到时间序列的上下文中,Transformers 也不例外。

我们不再查看每个位置的标记,而是每个位置都有实数。而且,我们不再谈论输入嵌入,而是可以谈论输入特征。每个时间步的特征向量可以视为 NLP 中嵌入向量的等效物。并且,我们对因果解码有严格要求,而在 NLP 中,因果解码通常是一个可选步骤(这实际上取决于任务)。因此,将 Transformer 适应时间序列是微不足道的,尽管实际上存在许多挑战,因为在时间序列中,我们通常遇到比 NLP 中更长的序列,这会带来问题,因为自注意力的复杂度随着输入序列长度的增加呈二次方增长。已经有许多替代性的自注意力提案使得在长序列中使用自注意力成为可能,我们将在第十六章《用于预测的专门化深度学习架构》中介绍其中的一些。

现在,让我们尝试将我们学到的关于 Transformer 的知识付诸实践。

使用 Transformer 进行预测

为了保持一致性,我们将使用之前用 RNN 和带注意力的 RNN 进行预测的相同家庭示例。

笔记本提醒

要跟随完整代码,请使用 Chapter14 文件夹中的 03-Transformers.ipynb 笔记本,并使用 src 文件夹中的代码。

虽然我们学习了 vanilla Transformer 作为一个具有编码器-解码器架构的模型,但它最初是为语言翻译任务设计的。在语言翻译中,源序列和目标序列是完全不同的,因此编码器-解码器架构显得有意义。但很快,研究人员发现,仅使用 Transformer 的解码器部分也能取得良好的效果。文献中称之为解码器仅 Transformer。这个命名有点令人困惑,因为如果你思考一下,解码器和编码器有两个不同之处——掩码自注意力和编码器-解码器注意力。那么,在解码器仅 Transformer 中,我们如何弥补编码器-解码器注意力呢?简短的回答是我们不需要。解码器仅 Transformer 的架构更像是编码器块,但我们称其为解码器仅 Transformer,因为我们使用掩码自注意力来确保模型遵守序列的时间顺序。

我们还将实现一个解码器仅 Transformer。我们需要做的第一件事是定义一个配置类 TransformerConfig,并包含以下参数:

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

  • d_model:此参数定义了 Transformer 的隐藏维度,或所有注意力计算和后续操作发生的维度。

  • n_heads:此参数定义了 MHA 机制中有多少个头。

  • n_layers:此参数定义了我们要堆叠在一起的编码器块数量。

  • ff_multiplier:此参数定义了位置前馈层内扩展的尺度。

  • activation:此参数允许我们定义在位置前馈层中使用的激活函数,可以是relugelu

  • multi_step_horizon:此参数让我们定义预测未来多少个时间步。

  • dropout:此参数允许我们定义在 Transformer 模型中应用的 dropout 正则化的大小。

  • learning_rate:定义优化过程的学习率。

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

现在,我们将继承我们在src/dl/models.py中定义的BaseModel类,并定义一个TransformerModel类。

我们需要实现的第一个方法是_build_network。整个模型可以在src/dl/models.py中找到,但我们也将在这里介绍一些重要的部分。

我们需要定义的第一个模块是一个线性投影层,它接受input_size参数并将其投影到d_model

self.input_projection = nn.Linear(
            self.hparams.input_size, self.hparams.d_model, bias=False
        ) 

这是我们为使 Transformer 适应时间序列预测范式所添加的额外步骤。在传统的 Transformer 中,这一步并不需要,因为每个词都由一个通常维度为 200 或 500 的嵌入向量表示。但是在进行时间序列预测时,我们可能需要仅使用一个特征(即历史数据)进行预测,这大大限制了我们为模型提供能力的方式,因为没有投影层时,d_model只能等于input_size。因此,我们引入了一个线性投影层,解耦了可用特征的数量和d_model

现在,我们需要有一个模块来添加位置编码。我们已将之前看到的代码打包成一个PyTorch模块,并将其添加到src/dl/models.py中。我们只需使用该模块并定义我们的位置信息操作符,如下所示:

self.pos_encoder = PositionalEncoding(self.hparams.d_model) 

我们之前说过,我们将使用仅解码器的方法来构建模型,为此,我们使用了TransformerEncoderLayerTransformerEncoder模块,这些模块在PyTorch中已定义。只需要记住,当使用这些层时,我们将使用掩蔽自注意力,这使得它成为一个仅解码器的 Transformer。代码如下:

self.encoder_layer = nn.TransformerEncoderLayer(
            d_model=self.hparams.d_model,
            nhead=self.hparams.n_heads,
            dropout=self.hparams.dropout,
            dim_feedforward=self.hparams.d_model * self.hparams.ff_multiplier,
            activation=self.hparams.activation,
            batch_first=True,
        )
        self.transformer_encoder = nn.TransformerEncoder(
            self.encoder_layer, num_layers=self.hparams.n_layers
        ) 

我们需要定义的最后一个模块是一个线性层,它将 Transformer 的输出转换为我们预测的时间步数:

self.decoder = nn.Sequential(nn.Linear(self.hparams.d_model, 100),
            nn.ReLU(),
            nn.Linear(100, self.hparams.multi_step_horizon)
        ) 

这就是模型定义的全部内容。接下来,让我们在forward方法中定义前向传播。

第一步是生成我们需要应用掩蔽自注意力的掩码:

mask = self._generate_square_subsequent_mask(x.shape[1]).to(x.device) 

我们定义了掩码,使其与输入序列的长度相同。_generate_square_subsequent_mask是我们定义的方法,用于生成掩码。假设序列长度为 5,我们可以查看准备掩码的两个步骤:

mask = (torch.triu(torch.ones(5, 5)) == 1).transpose(0, 1) 

torch.ones(sz,sz)会创建一个全是 1 的方阵,而torch.triu(torch.ones(sz,sz))会生成一个上三角矩阵(包括对角线),其余部分填充为 0。通过使用带有一个条件的等式运算符并进行转置,我们可以得到一个掩码,该掩码在所有下三角区域(包括对角线)中为True,其他地方为False。前面语句的输出将是这样的:

tensor([[ True, False, False, False, False],
        [ True,  True, False, False, False],
        [ True,  True,  True, False, False],
        [ True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True]]) 

我们可以看到这个矩阵在所有需要掩蔽注意力的位置上是False。现在,我们只需要将所有True实例填充为0,将所有False实例填充为-inf

mask = (
                mask.float()
                .masked_fill(mask == 0, float("-inf"))
                .masked_fill(mask == 1, float(0.0))
            ) 

这两行代码被封装到_generate_square_subsequent_mask方法中,我们可以在训练模型时使用它。

现在我们已经为掩蔽自注意力创建了掩码,接下来我们开始处理输入x

# Projecting input dimension to d_model
x_ = self.input_projection(x)
# Adding positional encoding
x_ = self.pos_encoder(x_)
# Encoding the input
x_ = self.transformer_encoder(x_, mask)
# Decoding the input
y_hat = self.decoder(x_) 

在这四行代码中,我们将输入投影到d_model维度,添加位置编码,通过 Transformer 模型处理,最后使用线性层将输出转换为我们想要的预测结果。

现在我们有了y_hat,它是模型的预测结果。现在我们需要思考的是如何训练这个输出,使其成为期望的输出。

我们知道 Transformer 模型一次性处理所有的 tokens,如果序列中有N个元素,那么也会有N个预测值(每个预测值对应下一个时间步)。如果每个预测值对应接下来的 H 个时间步,那么y_hat的形状将是(B, N, H),其中B是批量大小。我们可以通过几种方式使用这个输出与目标进行比较。最简单且最朴素的方法是直接使用最后一个位置的预测(它将有H个时间步)并将其与y(它也有H个时间步)进行比较。

但这并不是利用我们所有信息的最有效方式,对吧?我们丢弃了N-1个预测值,并且没有给模型提供关于这些N-1个预测的任何信号。因此,在训练时,使用这些N-1个预测是有意义的,这样模型在学习时可以得到更加丰富的反馈信号。

我们可以通过使用原始输入序列x,但是将其偏移一个位置来实现。当H=1时,我们可以将其视为一个简单的任务,其中每个位置的预测值与下一个位置(即前进一步)的目标进行比较。我们可以通过将x[:,1:,:](输入序列偏移 1)与y(原始目标)连接,并将其视为目标来轻松完成。但当H > 1 时,这变得稍微复杂,但我们仍然可以通过使用PyTorch中的一个有用函数unfold来做到这一点:

y = torch.cat([x[:, 1:, :], y], dim=1).squeeze(-1).unfold(1, y.size(1), 1) 

我们首先将输入序列(偏移一个位置)与y连接起来,然后使用unfold创建大小 = H的滑动窗口。这样我们就得到了一个形状相同的目标(BNH)。

但是在推理过程中(当我们使用训练好的模型进行预测时),我们不需要所有其他位置的输出,因此我们会将它们丢弃,如下所示:

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

我们定义的BaseModel类还允许我们通过使用predict方法来定义一个稍有不同的预测步骤。你可以再次查看src/dl/models.py中的完整模型,以巩固你的理解。

现在我们已经定义了模型,可以使用我们一直在使用的相同框架来训练TransformerModel。完整的代码可以在笔记本中找到,但我们将只查看一个总结表格,展示结果:

图 14.19 – Transformer 模型在 MAC000193 家庭上的度量

图 14.19:Transformer 模型在 MAC000193 家庭上的度量

我们可以看到模型的表现不如其 RNN 同行。造成这种情况的原因可能有很多,但最可能的原因是 Transformers 非常依赖数据。Transformers 的归纳偏差要少得多,因此只有在有大量数据可供学习时才能发挥其优势。当仅对一个家庭进行预测时,我们的模型可以访问的数据非常有限,可能效果不好。到目前为止,这对于我们看到的所有深度学习模型都在一定程度上是成立的。在第十章全球预测模型中,我们讨论了如何训练一个可以同时处理多个家庭的模型,但那个讨论仅限于经典的机器学习模型。深度学习同样完全能够应对全球预测模型,这正是我们在下一章——第十五章全球深度学习预测模型的策略中要讨论的内容。

现在,恭喜你完成了又一章充满概念和信息的章节。注意力机制这一席卷领域的概念,现在应该比开始时更清晰了。我建议你再花点时间重新阅读这一章,通读进一步阅读部分,如果有不清楚的地方,可以做一些自己的研究,因为未来的章节假设你理解这一内容。

总结

在过去的几章中,我们快速穿越了深度学习的世界。我们从深度学习的基本前提开始,了解了它是什么,为什么它变得如此流行。接着,我们看到了时间序列预测中常用的一些基本构件,并亲自实践了如何使用 PyTorch 将所学知识付诸实践。虽然我们讨论了 RNN、LSTM、GRU 等,但我们有意将注意力机制和 Transformers 留给了独立的章节。

本章开始时,我们学习了广义注意力模型,帮助你将所有不同的注意力方案框架化,然后详细讨论了几种常见的注意力方案,如缩放点积、加性和一般性注意力。在将注意力机制融入我们在 第十二章时间序列深度学习构建模块 中使用的 Seq2Seq 模型后,我们开始研究 Transformer。

我们从自然语言处理的角度审视了原始 Transformer 模型中所有的构建模块和架构决策,并在理解了架构之后,将其适配到时间序列设置中。

最后,我们通过训练一个 Transformer 模型来对一个样本家庭进行预测,从而为本章画上了圆满的句号。现在,通过完成这一章,我们已经掌握了所有基本的要素,可以真正开始使用深度学习进行时间序列预测。

在下一章中,我们将提升我们一直在做的工作,并转向全球预测模型范式。

参考文献

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

  1. Dzmitry Bahdanau, KyungHyun Cho, 和 Yoshua Bengio (2015). 通过联合学习对齐与翻译的神经机器翻译。收录于 第三届国际学习表征会议arxiv.org/pdf/1409.0473.pdf

  2. Thang Luong, Hieu Pham, 和 Christopher D. Manning (2015). 基于注意力的神经机器翻译的有效方法。收录于 2015 年自然语言处理经验方法会议aclanthology.org/D15-1166/

  3. André F. T. Martins, Ramón Fernandez Astudillo (2016). 从 Softmax 到 Sparsemax:一种稀疏的注意力模型及多标签分类。收录于 第 33 届国际机器学习会议论文集proceedings.mlr.press/v48/martins16.html

  4. Ben Peters, Vlad Niculae, André F. T. Martins (2019). 稀疏序列到序列模型。收录于 第 57 届计算语言学协会年会论文集aclanthology.org/P19-1146/

  5. Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Lukasz Kaiser, 和 Illia Polosukhin (2017). 注意力即你所需的一切。收录于 神经信息处理系统进展papers.nips.cc/paper/2017/hash/3f5ee243547dee91fbd053c1c4a845aa-Abstract.html

  6. Jacob Devlin, Ming-Wei Chang, Kenton Lee, 和 Kristina Toutanova(2019)。BERT:深度双向 Transformer 的预训练用于语言理解。发表于 2019 年北美计算语言学协会年会论文集:人类语言技术,第 1 卷(长篇和短篇论文)aclanthology.org/N19-1423/

  7. Hao Li, Zheng Xu, Gavin Taylor, Christoph Studer, 和 Tom Goldstein(2018)。可视化神经网络的损失景观。发表于 神经信息处理系统进展proceedings.neurips.cc/paper/2018/file/a41b3bb3e6b050b6c9067c67f663b915-Paper.pdf

  8. Sneha Chaudhari, Varun Mithal, Gungor Polatkan 和 Rohan Ramanath(2021)。注意力模型的细致调查ACM 智能系统技术期刊,12 卷,第 5 期,第 53 号文章(2021 年 10 月)doi.org/10.1145/3465055

进一步阅读

以下是一些进一步阅读的资源:

加入我们的 Discord 社区

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

packt.link/mts

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

在过去的几章中,我们一直在建立时间序列预测的深度学习模型。我们从深度学习的基础开始,了解了不同的构建模块,并实际使用了其中一些构建模块来对一个样本家庭进行预测,最后讨论了注意力和 Transformer。现在,让我们稍微改变方向,看看全球深度学习模型。在第十章“全球预测模型”中,我们看到了为什么全球模型是有意义的,还看到了如何在机器学习背景下使用这些模型。我们在实验中甚至得到了良好的结果。在本章中,我们将探讨如何从深度学习的角度应用类似的概念。我们将探讨可以使全球深度学习模型更好运作的不同策略。

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

  • 创建全球深度学习预测模型

  • 使用时变信息

  • 使用静态/元信息

  • 使用时间序列的规模

  • 平衡采样过程

技术要求

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

你将需要运行这些笔记本:

  • 02-Preprocessing_London_Smart_Meter_Dataset.ipynbChapter02

  • 01-Setting_up_Experiment_Harness.ipynbChapter04

  • 01-Feature_Engineering.ipynbChapter06

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

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

第十章“全球预测模型”中,我们详细讨论了为什么全球模型是有意义的。我们详细讨论了增加样本大小跨学习多任务学习以及与之相关的正则化效应,以及减少的工程复杂性的好处。所有这些对深度学习模型同样适用。工程复杂性和样本大小变得更为重要,因为深度学习模型对数据需求量大,并且比其他机器学习模型需要更多的工程工作和训练时间。在深度学习背景下,我认为在大多数需要大规模预测的实际情况中,全球模型是唯一有意义的深度学习范式。

那么,为什么我们要花这么多时间研究单一模型呢?其实,从这个层面上理解概念更容易,而且我们在这个层面获得的技能和知识非常容易转移到全球建模的范式中。在第十三章时间序列的常见建模模式中,我们看到如何使用数据加载器从单一时间序列中抽样窗口来训练模型。为了使模型成为一个全球模型,我们需要做的就是改变数据加载器,使其不再从单一时间序列中抽样窗口,而是从多个时间序列中抽样。这个抽样过程可以看作是一个两步走的过程(尽管在实践中我们是一气呵成的,但从直观上讲它是两个步骤)—首先,从需要选择窗口的时间序列中抽样,然后,从该时间序列中抽样窗口。通过这样做,我们正在训练一个单一的深度学习模型来一起预测所有时间序列。

为了让我们的生活更轻松,本文将使用开源库 PyTorch Forecasting 和 Nixtla 的 neuralforecast。我们将出于教学目的使用 PyTorch Forecasting,因为它提供了更多的灵活性,但 neuralforecast 更为现代且在积极维护,因此更近期的架构将添加到该库中。在第十六章中,我们将看到如何使用 neuralforecast 进行预测,但现在让我们选择 PyTorch Forecasting 继续前进。

PyTorch Forecasting 旨在使深度学习时间序列预测对于研究和实际应用都变得更加简便。PyTorch Forecasting 还实现了一些最先进的预测架构,我们将在第十六章中回顾这些架构,标题为专用深度学习架构用于预测。但现在,让我们使用 PyTorch Forecasting 的高级 API。这样可以大大减少我们在准备 PyTorch 数据集时的工作量。PyTorch Forecasting 中的 TimeSeriesDataset 类处理了许多样板代码,涉及不同的转换、缺失值、填充等问题。在本章中,我们将使用这个框架来探讨实现全球深度学习预测模型的不同策略。

笔记本提示

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

数据预处理

我们从加载必要的库和数据集开始。我们使用的是在第六章中创建的经过预处理和特征工程处理的数据集,时间序列预测的特征工程。数据集中有不同种类的特征,为了使我们的特征分配标准化,我们使用namedtuplenamedtuple()collections中的一个工厂方法,允许你创建带有命名字段的tuple子类。这些命名字段可以通过点表示法进行访问。我们这样定义namedtuple

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

让我们快速了解一下这些名称的含义:

  • target:我们尝试预测的目标的列名。

  • index_cols:我们需要将这些列设置为索引,以便快速访问数据。

  • static_categoricals:这些列是分类性质的,并且不会随着时间变化。它们是每个时间序列特有的。例如,我们数据集中的Acorn 组static_categorical,因为它是分类性质的,并且是一个与家庭相关的值。

  • static_reals:这些列是数值型的,并且随着时间的推移不会变化。它们是每个时间序列特有的。例如,我们数据集中的平均能耗是数值型的,且只适用于单个家庭。

  • time_varying_known_categoricals:这些列是分类性质的,并且随着时间变化,并且我们知道未来的值。它们可以视为随着时间不断变化的量。一个典型的例子是节假日,它是分类的,且随时间变化,我们知道未来的节假日。

  • time_varying_known_reals:这些列是数值型的,会随着时间变化,并且我们知道未来的值。一个典型的例子是温度,它是数值型的,随着时间变化,并且我们知道未来的值(前提是我们获取天气数据的来源也提供了未来天气的预报数据)。

  • time_varying_unknown_reals:这些列是数值型的,会随着时间变化,并且我们不知道未来的值。我们尝试预测的目标就是一个很好的例子。

  • group_ids:这些列唯一标识数据框中的每个时间序列。

一旦定义好,我们可以为这些名称分配不同的值,如下所示:

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

neuralforecast中,问题设置的方式略有不同,但原理是相同的。我们定义的不同类型的变量在概念上保持一致,只是我们用来定义它们的参数名称不同。PyTorch Forecasting 需要将目标包含在time_varying_unknown_reals中,而neuralforecast则不需要。这些细微的差异将在我们使用neuralforecast生成预测时进行详细说明。

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

  • 由于我们使用的是顺序深度学习模型,因此我们尝试通过滚动特征等捕捉的许多信息,模型已经能够自动获取。

  • 与强大的梯度提升决策树模型不同,深度学习模型对噪声的鲁棒性较差。因此,无关特征会使模型表现变差。

为了使我们的数据集与 PyTorch Forecasting 兼容,有几个预处理步骤是必需的。PyTorch Forecasting 需要一个连续的时间索引作为时间的代理。虽然我们有一个 timestamp 列,但它包含的是日期时间。因此,我们需要将其转换为一个新的列 time_idx。完整的代码可以在笔记本中找到,但代码的核心思想很简单。我们将训练集和测试集的 DataFrame 合并,并使用 timestamp 列中的公式推导出新的 time_idx 列。这个公式是这样的:每个连续的时间戳递增 1,并且在 traintest 之间保持一致。例如,train 中最后一个时间步的 time_idx256,而 test 中第一个时间步的 time_idx 将是 257。此外,我们还需要将类别列转换为 object 数据类型,以便与 PyTorch Forecasting 中的 TimeSeriesDataset 良好配合。

对于我们的实验,我们选择了 2 天(96 个时间步)作为窗口,并预测一个时间步的未来。为了启用提前停止,我们还需要一个验证集。提前停止是一种正则化方法(防止过拟合的技术,第五章),它通过监控验证损失,当验证损失开始上升时停止训练。我们选择了训练的最后一天(48 个时间步)作为验证数据,并选择了 1 个月作为最终测试数据。但在准备这些 DataFrame 时,我们需要注意一些问题:我们选择了两天作为历史数据,而为了预测验证集或测试集中的第一个时间步,我们需要将过去两天的历史数据一同考虑进去。因此,我们按照下图所示的方式划分 DataFrame(具体代码在笔记本中):

图 15.1 – 训练-验证-测试集划分

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

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

理解 PyTorch Forecasting 中的 TimeSeriesDataset

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

  • 对数值特征进行缩放并编码类别特征:

    • 对数值特征进行缩放,使其具有相同的均值和方差,有助于基于梯度下降的优化方法更快且更好地收敛。

    • 类别特征需要编码为数字,以便我们能够在深度学习模型中正确处理它们。

  • 归一化目标变量:

    • 在全局模型上下文中,目标变量对于不同的时间序列可能具有不同的尺度。例如,某个家庭通常有较高的能源消耗,而其他一些家庭可能是空置的,几乎没有能源消耗。将目标变量缩放到一个单一的尺度有助于深度学习模型专注于学习模式,而不是捕捉尺度上的方差。
  • 高效地将 DataFrame 转换为 PyTorch 张量字典:

    • 数据集还会接受关于不同列的信息,并将 DataFrame 转换为 PyTorch 张量的字典,分别处理静态信息和随时间变化的信息。

这些是TimeSeriesDataset的主要参数:

  • data:这是包含所有数据的 pandas DataFrame,每一行通过time_idxgroup_ids进行唯一标识。

  • time_idx:这指的是我们之前创建的连续时间索引的列名。

  • targetgroup_idsstatic_categoricalsstatic_realstime_varying_known_categoricalstime_varying_known_realstime_varying_unknown_categoricalstime_varying_unknown_reals:我们已经在数据预处理部分讨论过所有这些参数,它们的含义相同。

  • max_encoder_length:设置给定编码器的最大窗口长度。

  • min_decoder_length:设置解码上下文中给定的最小窗口长度。

  • target_normalizer:这是一个 Transformer,用于对目标进行标准化。PyTorch Forecasting 内置了几种标准化器——TorchNormalizerGroupNormalizerEncoderNormalizerTorchNormalizer对整个目标进行标准化和鲁棒性缩放,而GroupNormalizer则对每个组分别进行相同的处理(组是由group_ids定义的)。EncoderNormalizer在运行时根据每个窗口中的值进行标准化。

  • categorical_encoders:该参数接受一个字典,字典中的值是 scikit-learn 的 Transformer,作为类别编码器。默认情况下,类别编码类似于LabelEncoder,它将每个独特的类别值替换为一个数字,并为未知值和NaN值添加额外的类别。

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

初始化 TimeSeriesDataset

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

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

请注意,我们使用了GroupNormalizer,使得每个家庭根据其自身的均值和标准差分别进行缩放,使用的是以下著名的公式:

TimeSeriesDataset还使得声明验证和测试数据集变得更加容易,通过工厂方法from_dataset。它接受另一个时间序列数据集作为参数,并使用相同的参数、标准化器等,创建新的数据集:

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

请注意,我们将历史数据连接到val_dftest_df中,以确保可以在整个验证和测试期间进行预测。

创建数据加载器

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

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

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

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

我们将得到如下的输出:

图 15.2 – 批量数据加载器中的张量形状

图 15.2:批量数据加载器中张量的形状

我们可以看到,数据加载器和TimeSeriesDataset已经将数据框拆分为 PyTorch 张量,并将它们打包进一个字典中,编码器和解码器序列被分开。我们还可以看到,类别特征和连续特征也被分开了。

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

可视化数据加载器的工作原理

让我们尝试更深入地剖析这里发生了什么,并通过视觉化的方式理解TimeSeriesDataset所做的事情:

图 15.3 – TimeSeriesDataset – 它是如何工作的示意图

图 15.3:TimeSeriesDataset——它是如何工作的示意图

假设我们有一个时间序列,x[1] 到 x[6](这将是目标以及TimeSeriesDataset术语中的time_varying_unknown)。我们有一个时间变化的实数,f[1] 到 f[6],和一个时间变化的类别,c[1] 到 c[2]。除此之外,我们还拥有一个静态实数,r,和一个静态类别,s。如果我们将编码器和解码器的长度设置为3,那么我们将得到如图 15.3所示的张量。请注意,静态类别和实数在所有时间步长上都被重复。这些不同的张量构造是为了让模型的编码器能够使用编码器张量进行训练,而解码器张量则在解码过程中使用。

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

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

PyTorch Forecasting 使用 PyTorch 和 PyTorch Lightning 在后台定义和训练深度学习模型。可以与 PyTorch Forecasting 无缝配合使用的模型本质上是 PyTorch Lightning 模型。然而,推荐的做法是从 PyTorch Forecasting 继承 BaseModel。PyTorch Forecasting 的开发者提供了出色的文档和教程,帮助新用户按照自己的需求使用它。这里值得一提的一个教程名为 如何使用自定义数据并实现自定义模型和指标(链接在 进一步阅读 部分)。

我对教程中的基础模型做了一些修改,使其更加灵活。实现代码可以在 src/dl/ptf_models.py 文件中找到,名为 SingleStepRNNModel。该类接收两个参数:

  • network_callable:这是一个可调用对象,当初始化时,它将成为一个 PyTorch 模型(继承自 nn.Module)。

  • model_params:这是一个字典,包含初始化 network_callable 所需的所有参数。

结构相当简单。__init__ 函数将 network_callable 初始化为一个 PyTorch 模型,并将其存储在 network 属性下。forward 函数将输入传递给网络,格式化返回的输出,使其符合 PyTorch Forecasting 的要求,并返回结果。这个模型非常简短,因为大部分工作都由 BaseModel 完成,它负责处理损失计算、日志记录、梯度下降等任务。我们通过这种方式定义模型的好处是,现在我们可以定义标准的 PyTorch 模型,并将其传递给这个模型,使其能够与 PyTorch Forecasting 配合得很好。

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

定义我们的第一个 RNN 模型

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

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

这是最直接的实现方式。我们从字典中取出 encoder_cont,并将其传递给 RNN,然后在 RNN 的最后一个隐藏状态上使用全连接层来生成预测。如果我们以 图 15.3 中的示例为例,我们使用 x[1] 到 x[3] 作为历史数据,并训练模型预测 x[4](因为我们使用了 min_decoder_length=1,所以解码器和目标中只有一个时间步)。

初始化 RNN 模型

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

  • model_params:这包含了初始化 SingleStepRNN 模型所需的所有参数。

  • other_params:这些是我们传递给SingleStepRNNModel的所有参数,如learning_rateloss等。

现在,我们可以使用 PyTorch Forecasting 模型支持的工厂方法from_dataset进行初始化。这个工厂方法允许我们传入数据集,并从数据集中推断一些参数,而不需要每次都填入所有内容:

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

训练 RNN 模型

训练模型就像我们在前几章中所做的那样,因为这是一个 PyTorch Lightning 模型。我们可以按照以下步骤进行:

  1. 使用早期停止和模型检查点初始化训练器:

    trainer = pl.Trainer(
        auto_select_gpus=True,
        gpus=-1,
        min_epochs=1,
        max_epochs=20,
        callbacks=[
            pl.callbacks.EarlyStopping(monitor="val_loss", patience=4*3),
            pl.callbacks.ModelCheckpoint(
                monitor="val_loss", save_last=True, mode="min", auto_insert_metric_name=True
            ),
        ],
        val_check_interval=2000,
        log_every_n_steps=2000,
    ) 
    
  2. 拟合模型:

    trainer.fit(
        model,
        train_dataloaders=train_dataloader,
        val_dataloaders=val_dataloader,
    ) 
    
  3. 训练完成后加载最佳模型:

    best_model_path = trainer.checkpoint_callback.best_model_path
    best_model = SingleStepRNNModel.load_from_checkpoint(best_model_path) 
    

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

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

现在,训练完成后,我们可以在test数据集上进行预测,方法如下:

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

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

A close up of numbers  Description automatically generated

图 15.4:使用基线全局模型汇总结果

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

  • MAE = 0.079581

  • MSE = 0.027326

  • meanMASE = 1.013393

  • 预测偏差 = 28.718087

除了预测偏差,我们的全局模型与最佳模型相差甚远。我们将全局机器学习模型称为GFM(ML),将当前模型称为GFM(DL),并在接下来的讨论中使用这两个术语。现在,让我们开始探索一些策略,改善全局模型。

使用时变信息

GFM(ML)使用了所有可用的特征。因此,显然,该模型比我们目前构建的 GFM(DL)访问了更多的信息。我们刚刚构建的 GFM(DL)只使用了历史数据,其他的都没有。让我们通过加入时变信息来改变这一点。这次我们只使用时变的真实特征,因为处理类别特征是我希望留到下一节讨论的话题。

我们以与之前相同的方式初始化训练数据集,但在初始化参数中加入了time_varying_known_reals=feat_config.time_varying_known_reals。现在我们已经创建了所有数据集,让我们继续设置模型。

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

让我们使用一个视觉示例来阐明:

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

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

延续图 15.3示例的精神,但将其简化以适应我们的上下文,我们有一个时间序列,x[1] 到 x[4],以及一个时变的真实变量,f[1] 到 f[4]。所以,对于max_encoder_length=3min_decoder_length=1,我们会让TimeSeriesDataset生成张量,如图 15.5中的步骤 1所示。

现在,对于每个时间步,我们有时变变量f和历史数据xencoder_cont中。时变变量f是我们也知道未来值的变量,因此对该变量没有因果约束。这意味着对于预测时间步t,我们可以使用f[t],因为它是已知的。然而,目标变量的历史数据则不是。我们无法知道未来的值,因为它正是我们想要预测的量。这意味着x上有因果约束,因此我们不能使用x[t]来预测时间步t。但是目前张量的形成方式是,fx在时间步上是对齐的,如果我们将它们输入到模型中,就相当于作弊,因为我们会使用x[t]来预测时间步t。理想情况下,历史数据x和时变特征f之间应该有一个偏移量,这样在时间步t时,模型看到的是x[t][-1],然后看到f[t],然后预测x[t]。

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

  1. encoder_contdecoder_cont连接起来,因为我们需要使用f[4]来预测时间步t = 4(图 15.5中的步骤 2)。

  2. 将目标历史数据x向前移动一个时间步,使得f[t]和x[t][-1]对齐(图 15.5中的步骤 3)。

  3. 去掉第一个时间步,因为我们没有与第一个时间步相关的历史数据(在图 15.5中的步骤 4)。

这正是我们在新模型DynamicFeatureRNNModelforward方法中需要实现的内容:

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

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

图 15.6 – 使用时变特征汇总结果

图 15.6:使用时变特征汇总结果

看起来温度作为特征确实使模型稍微改善了一些,但还有很长的路要走。别担心,我们还有其他特征可以使用。

使用静态/元信息

有些特征,如橡子组、是否启用动态定价等,特定于某个家庭,这将帮助模型学习特定于这些组的模式。自然地,包含这些信息是有直觉意义的。

然而,正如我们在第十章全球预测模型》中讨论的,分类特征与机器学习模型的配合不太好,因为它们不是数值型的。在那一章中,我们讨论了几种将分类特征编码为数值表示的方法。这些方法同样适用于深度学习模型。但有一种处理分类特征的方法是深度学习模型特有的——嵌入向量

独热编码及其为何不理想

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

在对分类特征进行独热编码后,我们得到的表示被称为稀疏表示。如果分类特征的基数(唯一值的数量)是C,那么每一行代表分类特征的一个值时,将有C - 1 个零。因此,该表示大部分是零,因此称为稀疏表示。这导致有效编码一个分类特征所需的总体维度等于向量的基数。因此,对一个拥有 5,000 个唯一值的分类特征进行独热编码会立刻给你要解决的问题添加 5,000 个维度。

除此之外,独热编码也是完全没有信息的。它将每个分类值放置在相等的距离之内,而没有考虑这些值之间可能的相似性。例如,如果我们要对一周的每一天进行编码,独热编码会将每一天放在一个完全不同的维度中,使它们彼此之间距离相等。但如果我们仔细想想,周六和周日应该比其他工作日更接近,因为它们是周末,对吧?这种信息在独热编码中并没有被捕捉到。

嵌入向量和密集表示

嵌入向量是一种类似的表示方式,但它不是稀疏表示,而是努力为我们提供类别特征的密集表示。我们可以通过使用嵌入层来实现这一点。嵌入层可以被视为每个类别值与一个数值向量之间的映射,而这个向量的维度可以远小于类别特征的基数。唯一剩下的问题是“我们怎么知道为每个类别值选择哪个向量?

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

初始化时有两个必需的参数:

  • num_embeddings:这是嵌入字典的大小。换句话说,这是类别特征的基数。

  • embedding_dim:这是每个嵌入向量的大小。

现在,让我们回到全局建模。首先介绍静态类别特征。请注意,我们还包括了时间变化的类别特征,因为现在我们已经知道如何在深度学习模型中处理类别特征。初始化数据集的代码是一样的,只是添加了以下两个参数:

  • static_categoricals=feat_config.static_categoricals

  • time_varying_known_categoricals=feat_config.time_varying_known_categoricals

定义带有类别特征的模型

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

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

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

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

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

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

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

现在,让我们用静态特征来训练这个新模型,并看看它的表现如何:

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

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

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

使用时间序列的规模

我们在TimeSeriesDataset中使用了GroupNormlizer来对每个家庭进行缩放,使用它们各自的均值和标准差。这样做是因为我们希望使目标具有零均值和单位方差,以便模型不必浪费精力调整其参数来捕捉单个家庭消费的规模。虽然这是一种很好的策略,但我们确实在这里丢失了一些信息。可能有一些模式是特定于消费较大家庭的,而另外一些模式则是特定于消费较少的家庭的。但现在,这些模式被混在一起,模型试图学习共同的模式。在这种情况下,这些独特的模式对模型来说就像噪音一样,因为没有变量来解释它们。

关键是我们移除的尺度中包含了信息,将这些信息加回来将会是有益的。那么,我们该如何加回来呢?绝对不是通过包括未缩放的目标,这样会带回我们一开始想要避免的缺点。实现这一点的一种方式是将尺度信息作为静态真实特征添加到模型中。当我们最初进行缩放时,我们会记录每个家庭的均值和标准差(因为我们需要它们进行反向变换,并恢复原始目标)。我们需要做的就是确保将它们作为静态真实变量包含在内,这样模型在学习时间序列数据集中的模式时就能访问到尺度信息。

PyTorch Forecasting 通过在TimeSeriesDataset中提供一个方便的参数add_target_scales,使这变得更简单。如果将其设置为True,那么encoder_contdecoder_cont也将包含各个时间序列的均值和标准差。

我们现有的模型没有变化;我们只需要在初始化时将这个参数添加到TimeSeriesDataset中,然后使用模型进行训练和预测。让我们看看它是如何为我们工作的:

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

图 15.8:使用静态、时间变化和尺度特征的聚合结果

尺度信息再次改善了模型。有了这些,我们来看看本书最后将讨论的一种策略。

平衡采样过程

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

  1. 从一组时间序列中采样一个时间序列。

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

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

在我们默认使用的批量采样中,我们打开所有的球,将所有纸片倒入碗中,然后丢弃这些球。现在,闭上眼睛,我们从碗中随机挑选B张纸片,将它们放到一边。这就是我们从数据集中采样的一个批次。我们没有任何信息来区分纸片之间的差异,所以抽到任何纸片的概率是相等的,这可以表示为:

现在,让我们在数据类比中加入一些内容。我们知道,时间序列有不同的种类——不同的长度、不同的消费水平等等。我们选择其中一个方面,即序列的长度,作为我们的例子(尽管它同样适用于其他方面)。所以,如果我们将时间序列的长度离散化,我们就会得到不同的区间;我们为每个区间分配一个颜色。现在,我们有C种不同颜色的球在碗里,纸片也会按相应的颜色来标记。

在我们当前的采样策略中(我们将所有纸片都倒入碗中,并随机抽取B张纸片),我们最终会在一个批次中复制碗的概率分布。可以理解的是,如果碗中包含更多的长时间序列而不是短时间序列,那么我们抽到的纸片也会偏向于这一点。因此,批次也会偏向长时间序列。那会发生什么呢?

在小批量随机梯度下降(我们在第十一章,《深度学习导论》中看到过)中,我们在每个小批次后进行一次梯度更新,并使用该梯度来更新模型参数,从而使得模型更接近损失函数的最小值。因此,如果一个小批次偏向某一类型的样本,那么梯度更新将会偏向一个对这些样本效果更好的解。这和不平衡学习有着很好的类比。较长的时间序列和较短的时间序列可能有不同的模式,而这种采样不平衡导致模型学到的模式可能对长时间序列效果较好,而对短时间序列的效果不太理想。

可视化数据分布

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

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

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

图 15.9 – 时间序列长度分布

图 15.9:时间序列长度分布

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

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

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

我们可以看到,在批次分布中,和图 15.9中看到的相同的分布也得到了复现,bin_5bin_6占据了领先位置。bin_0几乎没有出现,而在bin_0中的 LCLid 将不会被学得很好。

调整采样过程

那么接下来我们该怎么办?让我们暂时进入一个装有纸条的碗的类比。我们在随机挑选一个球,结果发现分布和原始的颜色分布完全一致。因此,为了让批次中的颜色分布更加平衡,我们需要按不同的概率从不同颜色的纸条中抽取。换句话说,我们应该从原始分布中低频的颜色上抽取更多,而从占主导地位的颜色中抽取更少。

让我们从另一个角度来看选择碗中筹码的过程。我们知道选择碗中每个筹码的概率是相等的。所以,另一种选择筹码的方法是使用均匀随机数生成器。我们从碗中抽取一个筹码,生成一个介于 0 和 1 之间的随机数(p),如果随机数小于 0.5(p < 0.5),则选择该筹码。所以,我们选择或拒绝筹码的概率是相等的。我们继续进行,直到得到B个样本。虽然这个过程比前一个过程稍微低效一些,但它与原始过程非常接近。这里的优势在于,我们现在有了一个阈值,可以调整我们的采样以适应需求。较低的阈值使得在该采样过程中更难接受筹码,而较高的阈值则使其更容易被接受。

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

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

我们该如何做呢?有一个非常简单的策略。我们希望对于样本多的长度区间,权重较低;对于样本少的长度区间,权重较高。我们可以通过取每个区间样本数量的倒数来得到这种权重。如果一个区间中有C个 LCLid,则该区间的权重可以是 1/C进一步阅读部分有一个链接,您可以通过它了解更多关于加权随机采样和为此目的使用的不同算法。

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

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

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

现在我们有了这个权重,有一个简单的方法可以将其付诸实践。我们可以使用 PyTorch 中的WeightedRandomSampler,它是专门为此目的创建的:

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

使用并可视化带有WeightedRandomSampler的 dataloader

现在,我们可以在我们从TimeSeriesDataset创建的 dataloader 中使用这个采样器:

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

让我们像之前一样可视化前 50 个批次,看看有什么不同:

图 15.11 – 带有加权随机采样的批次分布堆叠柱状图

图 15.11:带有加权随机采样的批次分布堆叠柱状图

现在,我们可以看到每个批次中更均匀的区间分布。让我们也看看使用这个新 dataloader 训练模型后的结果:

图 15.12 – 使用静态、时间变化和规模特征以及批量采样器聚合结果

图 15.12:使用静态、时间变化和规模特征以及批量采样器聚合结果

看起来,采样器在所有指标上对模型的改进都很大,除了预测偏差之外。虽然我们没有比 GFM(ML)(它的 MAE 为 0.079581)取得更好的结果,但我们已经足够接近了。也许通过一些超参数调整、数据划分或更强的模型,我们能够更接近那个数值,或者可能不能。我们使用了自定义采样选项,使得每批数据中的时间序列长度保持平衡。我们可以使用相同的技术在其他方面进行平衡,比如消费水平、地区,或任何其他相关的方面。像机器学习中的所有事情一样,我们需要通过实验来确定一切,而我们需要做的就是根据问题陈述形成假设,并构建实验来验证这个假设。

至此,我们已完成又一章以实践为主(且计算量大)的内容。恭喜你顺利完成本章;如果有任何概念尚未完全理解,随时可以回去查阅。

小结

在过去几章中,我们已经打下了深度学习模型的坚实基础,现在开始探讨在深度学习模型背景下的全球模型新范式。我们学习了如何使用 PyTorch Forecasting,这是一个用于深度学习预测的开源库,并利用功能丰富的TimeSeriesDataset开始开发自己的模型。

我们从一个非常简单的 LSTM 开始,在全球背景下看到了如何将时间变化信息、静态信息和单个时间序列的规模添加到特征中,以改善模型性能。最后,我们讨论了交替采样程序,它帮助我们在每个批次中提供问题的更平衡视图。本章绝不是列出所有使预测模型更好的技术的详尽清单。相反,本章旨在培养一种正确的思维方式,这是在自己模型上工作并使其比之前更好地运行所必需的。

现在,我们已经建立了深度学习和全球模型的坚实基础,是时候在下一章中探索一些多年来为时间序列预测提出的专用深度学习架构了。

进一步阅读

你可以查阅以下资料进行进一步阅读:

加入我们在 Discord 上的社区

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

packt.link/mts

第十六章:专用深度学习架构用于预测

我们在深度学习DL)世界中的旅程即将结束。在上一章中,我们介绍了全球预测范式,并看到如何使一个简单的模型,如递归神经网络RNN),表现接近全球机器学习模型设定的高基准。在本章中,我们将回顾一些专门为时间序列预测设计的流行深度学习架构。借助这些更复杂的模型架构,我们将更好地应对现实世界中需要比普通 RNN 和 LSTM 更强大模型的问题。

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

  • 对专用架构的需求

  • NeuralForecast 简介

  • 可解释的时间序列预测的神经基础扩展分析

  • 带外生变量的可解释的时间序列预测的神经基础扩展分析

  • 用于时间序列预测的神经层次插值

  • Autoformer

  • LTSF-Linear 家族

  • 补丁时间序列预测

  • iTransformer

  • 时序融合变换器

  • TSMixer

  • 时间序列密集编码器

技术要求

你需要按照前言中的说明设置 Anaconda 环境,以获取包含本书代码所需的所有包和数据集的工作环境。

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

你需要运行以下笔记本文件来进行本章学习:

  • 02-Preprocessing_London_Smart_Meter_Dataset.ipynbChapter02

  • 01-Setting_up_Experiment_Harness.ipynbChapter04

  • 01-Feature_Engineering.ipynbChapter06

对专用架构的需求

偏倚归纳(或学习偏倚)指的是学习算法在将其在训练数据上学到的函数推广到未见数据时所做的一系列假设。偏倚归纳本身并不是一件坏事,它不同于学习理论中的“偏倚”和“方差”中的“偏倚”。我们通过模型架构或特征工程来使用和设计偏倚归纳。例如,卷积神经网络CNN)在图像上表现优于标准的前馈神经网络FFN)在纯像素输入上的表现,因为 CNN 具有 FFN 所没有的局部性和空间偏倚。虽然 FFN 理论上是一个通用逼近器,但我们可以利用 CNN 所具有的偏倚归纳来学习更好的模型。

深度学习被认为是一种完全数据驱动的方法,特征工程和最终任务都是端到端学习的,从而避免了模型设计者在特征设计时所植入的归纳偏差。但这种看法并不完全正确。过去通过特征引入的归纳偏差,如今通过架构设计的方式融入其中。每种深度学习架构都有自己的归纳偏差,这就是为什么某些类型的模型在某些类型的数据上表现更好。例如,卷积神经网络(CNN)在图像上表现良好,但在序列数据上的表现则不如图像,因为 CNN 所带来的空间归纳偏差和平移等价性对图像最为有效。

在理想的世界里,我们会有无限的好数据,并且能够学习完全数据驱动的网络,没有强烈的归纳偏差。但遗憾的是,在现实世界中,我们永远不会有足够的数据来学习如此复杂的函数。这就是设计正确类型的归纳偏差成败的关键。我们曾经在序列建模中大量依赖 RNN,它们内置了强烈的自回归归纳偏差。但后来,Transformer 的出现改变了这一局面,尽管它对序列的归纳偏差较弱,但在大数据的支持下,它们能够更好地学习序列的函数。因此,如何决定将多强的归纳偏差融入模型,是设计深度学习架构时的重要问题。

多年来,许多深度学习架构专门用于时间序列预测,每种架构都有其独特的归纳偏差。我们无法一一回顾所有这些模型,但我们会介绍一些对该领域产生深远影响的主要模型。我们还将探讨如何使用一些开源库在我们的数据上训练这些模型。

我们将专注于能够处理全局建模范式的模型,无论是直接还是间接。这是因为在大规模预测时,为每个时间序列训练独立模型是不现实的。

我们将介绍几种用于时间序列预测的流行架构。影响模型选择的一个主要因素是是否有支持这些模型的稳定开源框架。这里列出的并不是完整的架构清单,因为有许多架构我们没有涵盖。我会在进一步阅读部分尝试分享一些链接,帮助你开始探索之旅。

在我们深入本章的核心内容之前,让我们先了解一下我们将要使用的库。

NeuralForecast 简介

NeuralForecast 是 NIXTLA 团队的又一款库。你可能还记得在第四章设置强基准预测中,我们使用了statsforecast来处理经典的时间序列模型,如 ARIMA、ETS 等。他们有一整套用于时间序列预测的开源库(如mlforecast用于基于机器学习的预测,hierarchicalforecast用于层级数据的预测整合,utilsforecast包含一些预测工具,datasetsforecast提供一些现成的数据集,以及TimeGPT,他们的时间序列基础模型)。

由于我们已经学习了如何使用statsforecast,因此将其扩展到neuralforecast将变得非常容易,因为这两个库在 API、结构和工作方式上非常相似。neuralforecast提供了经典和前沿的深度学习模型,并且具有易于使用的 API,非常适合本章的实际应用。

NeuralForecast 的结构旨在提供一个直观且灵活的 API,能够与现代数据科学工作流无缝集成。该包包括多个著名模型的实现,每个模型都针对时间序列预测的不同方面。

常见的参数和配置

类似于statsforecastneuralforecast也期望输入数据具有特定的格式:

  • ds:此列应包含时间索引。它可以是一个日期时间列,也可以是表示时间的整数列。

  • y:此列应包含我们要预测的时间序列。

  • unique_id:此列使我们能够通过选择的唯一 ID 来区分不同的时间序列。它可以是我们数据中的家庭 ID,或者是我们指定的任何其他唯一标识符。

neuralforecast包中的大多数模型共享一组控制各种方面的通用参数,例如:

  • stat_exog_list:这是一个列出静态连续列的列表。

  • hist_exog_list:这是一个列出历史可用的外生特征的列表。

  • futr_exog_list:这是一个列出未来可用的外生特征的列表。

  • learning_rate:这是决定模型学习速度的参数。较高的学习率可能更快收敛,但可能会超过最优权重,而较低的学习率则能保证更稳定的收敛,但速度较慢。

  • batch_size:此参数影响每次训练步骤中输入模型的数据量,进而影响内存使用和训练动态。

  • max_steps:此参数定义了最大训练轮数——即整个数据集通过神经网络正向和反向传递的次数。它是最大值,因为我们还可以添加早停机制,在这种情况下,轮数可能会少于这个值。

  • loss:这是用于衡量预测值与实际值之间差异的指标,指导优化过程。有关包含的损失函数的列表,请参阅 NIXTLA 文档:nixtlaverse.nixtla.io/neuralforecast/losses.pytorch.html

  • scaler_type:这是一个字符串,表示使用的时间归一化类型。时间归一化会在每个批次的窗口级别对每个实例分别进行缩放。常见的类型包括['minmax,' 'robust,' 'standard']。此参数仅适用于基于窗口的模型,如 NBEATS 和 TimesNet,而不适用于递归模型如 RNN。如果您想查看所有归一化器,请查阅 NIXTLA 时间归一化器:nixtlaverse.nixtla.io/neuralforecast/common.scalers.html

  • early_stop_patience_steps:如果定义了该参数,它设置了在验证得分没有改进的情况下,我们将在多少步后停止训练。

  • random_seed:此参数定义了随机种子,它对于结果的可复现性至关重要。

实践者小贴士

参数stat_exog_listhist_exog_listfutr_exog_list仅对支持这些参数的模型可用。在使用的模型文档中检查,查看该模型是否支持这些参数。有些模型支持所有三个,有些模型只支持futr_exog_list,等等。有关支持的模型和可用项的完整列表,请参阅:nixtlaverse.nixtla.io/neuralforecast/docs/capabilities/overview.html

如果某些特征只有历史数据,那么它们应该放在hist_exog_list中。如果某些特征既有历史数据又有未来数据,它们应同时放在hist_exog_listfutr_exog_list中。

除了这些常见的模型参数外,neuralforecast还具有一个核心类NeuralForecast,它协调训练过程(就像我们在statsforecast中使用StatsForecast一样)。与statsforecast类似,这里定义了我们需要预测的模型列表等参数。让我们也来看一下这个类中的一些参数:

  • models:该参数定义了我们需要拟合或预测的模型列表。

  • freq:此参数设置我们想要预测的时间序列的频率。它可以是一个字符串(一个有效的 pandas 或 polars 偏移别名)或一个整数。此参数用于生成用于预测的未来数据框,并应根据您所拥有的数据进行定义。

如果ds是一个日期时间列,那么freq可以是一个字符串,表示重复的频率(例如‘D’代表天,‘H’代表小时等),或者是一个整数,表示默认单位(通常为天)的倍数,用于确定每个日期之间的间隔。如果ds是数值列,那么freq也应是一个数值列,表示值之间的固定数值增量。

  • local_scaler_type:这是另一种缩放时间序列的方式。在基于 Windows 的模型中,scaler_type 在每个窗口中缩放时间序列,而此方法在预处理步骤中缩放每个时间序列。对于每个 unique_id,此步骤会单独缩放时间序列并存储缩放器,以便在预测时可以应用逆变换。

使用 neuralforecast 的典型工作流程如下所示:

from neuralforecast import NeuralForecast
from neuralforecast.models import LSTM
horizon = 12
models = [LSTM(h=horizon,
               max_steps=500,
               scaler_type='standard',
               encoder_hidden_size=64,
               decoder_hidden_size=64,),
          ]
nf = NeuralForecast(models=models, freq='M')
nf.fit(df=Y_df)
Y_hat_df = nf.predict() 

“自动”模型

neuralforecast 包的一个突出特点是包含了“自动”模型。这些模型自动化了超参数调优和模型选择的过程,简化了用户的工作流程。通过利用自动化机器学习AutoML)技术,这些模型能够根据数据集自适应其架构和设置,大大减少了模型配置中的手动工作量。它们定义了智能的默认范围,因此即使你没有声明要调优的范围,它们也会采用默认范围并进行调优。更多信息请见:nixtlaverse.nixtla.io/neuralforecast/models.html

外部特征

NeuralForecast 还可以轻松地将外部变量纳入预测过程中(具体取决于模型的能力)。外部特征是指能够影响目标变量的外部因素,对于提高预测准确性至关重要,尤其是当这些外部因素对结果产生显著影响时。neuralforecast 包中的许多模型可以整合这些特征,通过考虑时间序列数据中可能不存在的附加信息来优化预测。

例如,将假期效应、天气状况或经济指标作为外部变量纳入,可以提供纯历史数据无法提供的重要洞察。这个特性在像 NBEATSx、NHITS 和 TSMixerx 这样的模型中尤为有用,这些模型能够建模历史和未来外部输入之间的复杂交互。通过有效处理外部特征,NeuralForecast 提高了模型在现实场景中预测准确性的能力,因为外部因素在这些场景中起着关键作用。要查看哪些模型可以处理外部信息,请参考网站上的文档:nixtlaverse.nixtla.io/neuralforecast/docs/capabilities/overview.html

现在,让我们不再拖延,开始介绍列表中的第一个模型。

解释性时间序列预测的神经基础扩展分析(N-BEATS)

第一个使用深度学习(DL)一些组件的模型(我们不能称其为 DL,因为它本质上是 DL 与经典统计学的混合)并在该领域引起轰动的是在 2018 年赢得 M4 竞赛(单变量)的一个模型。这是由 Slawek Smyl(当时在 Uber)设计的模型,它是指数平滑和 RNN 的“怪物”式混合,名为ES-RNN进一步阅读部分提供了使用 GPU 加速的该模型更新版的链接)。这促使 Makridakis 等人提出“混合方法和方法组合是未来的方向”这一观点。N-BEATS模型的创造者希望通过设计一个纯 DL 架构来挑战这一结论,用于时间序列预测。当他们创建出一个在 M4 竞赛中击败所有其他方法的模型时(尽管他们没有及时发布以参与竞赛),他们成功地实现了这一目标。这是一个非常独特的架构,深受信号处理的启发。让我们更深入地了解并理解这一架构。

参考检查

Makridakis 等人的研究论文以及 Slawek Smyl 的博客文章在参考文献部分分别被引用为12

在继续解释之前,我们需要先建立一些背景和术语。它们所解决的核心问题是单变量预测,这意味着它类似于经典方法,如指数平滑和 ARIMA,因为它只使用时间序列的历史数据来生成预测。模型中没有包含其他协变量的机制。模型展示一个历史窗口,并要求预测接下来的几个时间步。历史窗口被称为回溯期,而未来的时间步则是预测期

N-BEATS 的架构

N-BEATS 架构在几个方面不同于当时的现有架构:

  • 与常见的编码器-解码器(或序列到序列)形式不同,N-BEATS 将问题表述为多元回归问题。

  • 当时大多数其他架构相对较浅(大约 5 层 LSTM)。然而,N-BEATS 使用残差原则堆叠了许多基础块(我们稍后会解释这一点),而且论文表明我们可以堆叠多达 150 层,仍然能够实现高效学习。

  • 该模型让我们将其扩展为可人类解释的输出,仍然是以一种有原则的方式进行的。

让我们来看看架构,并深入分析:

图 16.1 – N-BEATS 架构

图 16.1:N-BEATS 架构

我们可以看到三个矩形块的列,每一个都是另一个的爆炸视图。从最左侧开始(那是最细节的视图),然后逐步向上构建,直到完整的架构。在最顶部,有一个代表性的时间序列,它包含一个回溯窗口和一个预测期。

N-BEATS 中的基本学习单元是。每个块,l,接受一个输入,(x[l]),其大小为回看期,并生成两个输出:一个预测,(),和一个反向预测,()。反向预测是块对回看期的最佳预测。它在经典意义上与拟合值同义;它们告诉我们堆栈如何使用它所学到的函数预测回看窗口。块的输入首先通过堆叠的四个标准完全连接层进行处理(包含偏置项和非线性激活),将输入转换为隐藏表示,h[l]。现在,这个隐藏表示通过两个独立的线性层(没有偏置或非线性激活)转换为论文中称之为扩展系数的内容,分别是反向预测和预测的系数,

块的最后部分将这些扩展系数映射到输出,使用一组基础层()。稍后我们会详细讲解基础层,但现在,先理解它们将扩展系数转换为所需的输出()。

堆栈

现在,让我们向上一层抽象,转到图 16.1的中间列。它展示了不同块在一个堆栈s)中的排列方式。堆栈中的所有块共享相同类型的基础层,因此它们被归为一个堆栈。如我们之前所见,每个块有两个输出,。这些块按残差方式排列,每个块一步步处理和清洗时间序列。块的输入,l,是。在每个步骤中,块生成的反向预测会从输入中减去,然后再传递到下一层。堆栈中所有块的预测输出会被加起来形成堆栈预测

堆栈中最后一个块的残差反向预测是堆栈残差x^s)。

整体架构

接下来,我们可以转到图 16.1的最右侧列,该列展示了架构的顶层视图。我们看到每个堆栈有两个输出——一个是堆栈预测(ys),另一个是堆栈残差(*x*s)。可以有N个堆栈组成 N-BEATS 模型。每个堆栈是串联在一起的,因此对于任何堆栈(s),前一个堆栈的堆栈残差(x(s-1))是输入,堆栈会生成两个输出:堆栈预测(*y*s)和堆栈残差(x^s)。最后,N-BEATS 预测,,是所有堆栈预测的加和:

现在我们已经理解了模型在做什么,我们需要回到一个之前留到后面讲的点——基函数

免责声明

这里的解释主要是为了帮助理解,因此我们可能略过了一些数学概念。如果想深入理解这一主题,建议参考涉及该主题的数学书籍或文章。例如,《函数作为向量空间》在进一步阅读部分和函数空间cns.gatech.edu/~predrag/courses/PHYS-6124-12/StGoChap2.pdf)。

基函数与可解释性

要理解什么是基函数,我们需要了解线性代数中的一个概念。我们在第十一章《深度学习导论》中讨论过向量空间,并给出了向量和向量空间的几何解释。我们谈到了向量是* n 维向量空间中的一个点。我们曾讨论过常规的欧几里得空间(R*^n),它用于表示物理空间。欧几里得空间是通过一个原点和一个正交归一基来定义的。正交归一基是单位向量(大小=1),且它们彼此正交(直观理解为 90 度)。因此,一个向量,,可以写成!,其中是正交归一基。你可能还记得这部分内容是高中学过的。

现在,有一个数学分支将函数视为向量空间中的一个点(此时我们称其为泛函空间)。这一观点源自于这样一个事实:向量空间需要满足的所有数学条件(如加法性、结合性等)在我们考虑函数而非点时依然有效。为了更好地理解这一点,让我们考虑一个函数,f(x) = 2x + 4x²。我们可以将这个函数视为在具有基函数xx²的函数空间中的一个向量。现在,系数 2 和 4 可以变化,给我们不同的函数;它们可以是从-到+的任何实数。所有能够以xx²为基的函数的集合就是泛函空间,泛函空间中的每个函数都可以表示为基函数的线性组合。我们可以选择任意函数作为基函数,这给我们提供了极大的灵活性。从机器学习的角度来看,在这个泛函空间中寻找最佳函数,实际上意味着我们在限制函数的搜索范围,使其具备基函数所定义的某些性质。

回到 N-BEATS,我们讨论了扩展系数,,这些系数通过一组基础层()映射到输出。一个基础层也可以被看作是一个基础函数,因为我们知道一个层仅仅是一个函数,它将输入映射到输出。因此,通过学习扩展系数,我们实际上是在寻找一个最佳的函数,这个函数能够表示输出,但受到我们选择的基础函数的限制。

N-BEATS 有两种操作模式:通用模式和可解释模式。N-BEATS 论文表明,在这两种模式下,N-BEATS 都能够在 M4 比赛中超越最优秀的模型。通用模式是指我们没有任何基础函数来限制函数搜索。我们也可以将此看作是将基础函数设置为恒等函数。因此,在这种模式下,我们让模型通过线性投影的方式,完全学习函数。这种模式缺乏人类可解释性,因为我们无法了解不同的函数是如何学习的,也无法理解每一层的意义。

但是,如果我们有固定的基础函数来限制函数空间,我们就可以引入更多的可解释性。例如,如果我们有一个基础函数来限制输出表示堆栈中所有模块的趋势,那么我们可以说该堆栈的预测输出代表趋势成分。同样,如果我们有另一个基础函数来限制输出表示堆栈中所有模块的季节性,那么我们可以说该堆栈的预测输出代表季节性。

这正是论文中提出的内容。他们定义了特定的基础函数来捕捉趋势和季节性,包含这些模块可以通过给出分解来使最终的预测更具可解释性。趋势基础函数是一个低阶多项式,p。因此,只要 p 较低,比如 1、2 或 3,就会迫使预测输出模仿趋势成分。对于季节性基础函数,作者选择了傅里叶基础(类似于我们在第六章时间序列预测的特征工程中看到的)。这迫使预测输出成为这些正弦基础函数的组合,模仿季节性。换句话说,模型学习将这些正弦波与不同的系数组合,以尽可能好地重构季节性模式。

为了更深入理解这些基础函数及其结构,我在进一步阅读部分链接了一篇Kaggle 笔记本,其中提供了关于趋势和季节性基础函数的清晰解释。相关笔记本还包含一个额外的部分,展示了季节性基础函数的前几个可视化图像。结合原始论文,这些附加阅读材料将有助于加深你的理解。

N-BEATS 并非专为全球模型设计,但在全球环境中表现良好。M4 竞赛是一个包含无关时间序列的集合,N-BEATS 模型的训练方式使得模型能够接触到所有这些序列,并学习一种共同的函数来预测数据集中的每个时间序列。这种方法,再加上使用不同回溯窗口的多个 N-BEATS 模型的集成,构成了 M4 竞赛的成功公式。

参考检查

Boris Oreshkin 等人的研究论文(N-BEATS)在参考文献部分被标注为3

使用 N-BEATS 进行预测

N-BEATS 以及我们将在本章中探讨的许多其他专用架构,都已在 NIXTLA 的 NeuralForecast 包中实现。首先,我们来看一下该实现的初始化参数。

NeuralForecast 中的NBEATS类有很多参数,以下是最重要的一些:

  • stack_types:这定义了我们在 N-BEATS 模型中需要的堆叠数量。它应该是一个包含字符串的列表(generictrendseasonality),表示堆叠的数量和类型。示例包括["trend", "seasonality"]["trend", "seasonality", "generic"]["generic", "generic", "generic"]。不过,如果整个网络是通用的,我们也可以只使用一个通用堆叠,并添加更多的块。

  • n_blocks:这是一个整数列表,表示我们已定义的每个堆叠中块的数量。如果我们已将stack_types定义为["trend", "seasonality"],并希望每个堆叠有三个块,那么我们可以将n_blocks设置为[3,3]

  • input_size:这是一个整数,表示要测试的自回归单位(滞后)。

  • shared_weights:这是一个布尔值列表,表示生成扩展系数的权重是否与堆叠中的其他块共享。建议在可解释的堆叠中共享权重,而在身份堆叠中则不共享。

还有一些其他参数,但这些参数不如上述重要。参数及其描述的完整列表可以在nixtlaverse.nixtla.io/neuralforecast/models.nbeats.html找到。

由于该模型的优势在于预测较长时间的跨度,我们只需设置预测时长参数h = 48,即可进行一次性的 48 步预测。

笔记本提示

用于训练 N-BEATS 的完整代码可以在Chapter16文件夹中的01-NBEATS_NeuralForecast.ipynb笔记本中找到。

解释 N-BEATS 预测结果

N-BEATS,如果我们在可解释模型中运行它,也通过将预测分解为趋势和季节性,提供了更多的可解释性。要获得可解释的输出,我们可以调用decompose函数。我们必须确保在初始参数中包括趋势和季节性组件的堆叠类型:stack_types = ['trend','seasonality']

model_interpretable = model_untuned.models[0]
dataset, *_ = TimeSeriesDataset.from_df(df = training_df, id_col='LCLid',time_col='timestamp',target_col='energy_consumption')
y_hat = model_interpretable.decompose(dataset=dataset) 

这将返回一个array,从中可以访问趋势和季节性,比如y_hat =[0,1]。趋势或季节性的顺序取决于你在stack_types中如何包含它,但默认顺序是['seasonality','trend'],意味着季节性是y_hat =[0,1],趋势是y_hat =[0,1]

让我们看看其中一个家庭预测是如何分解的:

图 16.2 – N-BEATS 的分解预测(可解释)

图 16.2:N-BEATS 的分解预测(可解释)

尽管 N-BEATS 取得了巨大成功,但它仍然是一个单变量模型。它不能接受任何外部信息,除了它的历史数据。这在 M4 竞赛中是可以的,因为所有相关的时间序列都是单变量的。然而,许多实际世界中的时间序列问题会带有额外的解释变量(或外生变量)。让我们看看对 N-BEATS 做的一个小改动,如何使其能够处理外生变量。

带外生变量的可解释时间序列预测的神经基础扩展分析(N-BEATSx)

Olivares 等人提出了 N-BEATS 模型的扩展,通过使其与外生变量兼容。整体结构与 N-BEATS(图 16.1)相同(包含块、堆叠和残差连接),因此我们将仅关注N-BEATSx模型提出的关键差异和新增内容。

参考检查

Olivares 等人的研究论文(N-BEATSx)在参考文献部分被引用为4

处理外生变量

在 N-BEATS 中,一个块的输入是回溯窗口,yb。但在这里,块的输入是回溯窗口,*y*b,以及外生变量数组,x。这些外生变量可以分为两种类型:时间变化型和静态型。静态变量使用静态特征编码器进行编码。这个编码器实际上是一个单层全连接网络(FC),将静态信息编码为用户指定的维度。现在,编码后的静态信息、时间变化的外生变量和回溯窗口被连接在一起,形成一个块的输入,从而使得块l的隐藏状态表示,h[l],不再是像 N-BEATS 中的FC(yb),而是*FC*([*y*b;x]),其中[;]表示连接。这样,外生信息作为输入的一部分,与残差在每一步都被连接到每个块中。

外生块

此外,论文还提出了一种新型的模块——外生模块。外生模块接收串联的回顾窗口和外生变量(与任何其他模块相同)作为输入,并生成反向预测和预测:

这里,N[x]表示外生特征的数量。

在这里,我们可以看到外生预测是外生变量的线性组合,并且这个线性组合的权重由扩展系数学习。该论文将此配置称为可解释的外生模块,因为通过使用扩展权重,我们可以定义每个外生变量的重要性,甚至找出由特定外生变量引起的预测的确切部分。

N-BEATSx 还具有外生模块的通用版本(不可解释)。在此模块中,外生变量通过一个学习上下文向量C[l]的编码器传递,并且使用以下公式生成预测:

他们提出了两种编码器:时间卷积网络(TCN)和WaveNet(类似于 TCN 的网络,但通过扩张来扩展感受野)。进一步阅读部分包含了更多关于 WaveNet 的资源,这是一种起源于声音领域的架构。

N-BEATSx 也在 NIXTLA 的neuralforecast中实现,然而在撰写本文时,它尚不能处理分类数据。因此,我们需要将分类特征编码为数值表示(就像我们在第十章 全球预测模型中所做的)后再使用neuralforecast

研究论文还表明,N-BEATSx 在电力价格预测方面明显优于 N-BEATS、ES-RNN 和其他基准模型。

在延续 N-BEATS 的基础上,我们现在将讨论另一种修改架构的方法,使其适用于长期预测。

神经层次插值用于时间序列预测(N-HiTS)

尽管深度学习在处理时间序列预测方面取得了相当多的成果,但对长期预测的关注仍然很少。尽管最近取得了进展,但由于两个原因,长期预测仍然是一个挑战:

  • 真正捕捉变化所需的表达能力

  • 计算复杂度

基于注意力的方法(Transformers)和类似 N-BEATS 的方法在内存和与预测时间范围相关的计算成本上呈二次扩展。

作者声称,与现有基于 Transformer 的架构相比,N-HiTS 大大降低了长期预测的计算成本,同时在大量多变量预测数据集上显示出 25%的准确率改进。

参考检查

Challu 等人在N-HiTS上的研究论文被引用为5,详见参考文献部分。

N-HiTS 的架构

N-HiTS 可以被视为对 N-BEATS 的修改,因为它们两者在架构上有很大一部分相同。 图 16.1 展示了 N-BEATS 的架构,对于 N-HiTS 仍然有效。N-HiTS 也具有以残差方式排列的块堆叠;它们的不同之处仅在于所使用的块的种类。例如,N-HiTS 没有为可解释块提供规定。所有 N-HiTS 中的块都是通用的。虽然 N-BEATS 试图将信号分解为不同的模式(趋势、季节性等),但 N-HiTS 试图将信号分解为多个频率并分别进行预测。

为了实现这一点,提出了一些关键的改进:

  • 多速率数据采样

  • 分层插值

  • 将输入采样率与各个块之间的输出插值尺度同步

多速率数据采样

N-HiTS 在完全连接块之前加入了子采样层,以便每个块的输入分辨率不同。这类似于用不同分辨率平滑信号,以便每个块查看发生在不同分辨率的模式,例如,如果一个块每天查看输入,另一个块每周查看输出等。这样,当用不同块查看不同分辨率时,模型将能够预测在这些分辨率下发生的模式。这显著减少了内存占用和所需的计算,因为我们不是查看所有H步的回顾窗口,而是查看较小的系列(例如 H/2、H/4 等)。

N-HiTS 使用了一个最大池化或平均池化层,内核大小为k[l],在回顾窗口上进行池化操作。池化操作类似于卷积操作,但使用的函数是非可学习的。在第十二章时间序列深度学习的基本构建块中,我们学习了卷积、内核、步长等。而卷积使用从数据中学习的权重进行训练,池化操作使用非可学习和固定的函数来聚合内核接收场中的数据。这些函数的常见示例包括最大值、平均值、求和等。N-HiTS 在不同块中使用MaxPool1dAvgPool1d(以PyTorch术语表示),每个池化操作的步长也等于内核,导致非重叠窗口上进行聚合操作。为了刷新我们的记忆,让我们看看kernel=2stride=2的最大池化是什么样子:

图 16.3 - 一维最大池化 - 内核=2,步长=2

图 16.3:一维最大池化 - 内核 = 2,步长 = 2

因此,较大的内核大小会倾向于削减输入中更多的高频(或小时间尺度)成分。这样,块被迫专注于更大尺度的模式。该论文将此称为多速率信号采样

分层插值

在标准的多步预测设置中,模型必须预测H个时间步长。随着H的增大,计算要求增加,并导致模型所需的表现力爆炸性增长。

训练一个具有如此大表现力的模型而不发生过拟合本身就是一个挑战。为了应对这些问题,N-HiTS 提出了一种名为时间插值的技术((不是在两个已知时间点之间的简单插值,而是与架构特定的插值方法)`)。

汇聚的输入(我们在前一节中看到的)与常规机制一起进入区块,生成扩展系数,并最终转换为预测输出。但是在这里,N-HiTS 将扩展系数的维度设置为r[l] X H,而不是H,其中r[l]是表现力比率。这个参数本质上减少了预测输出的维度,从而控制了我们在前一段中讨论的问题。为了恢复原始采样率并预测预测范围内的所有H点,我们可以使用插值函数。插值函数有很多选择——线性插值、最近邻插值、三次插值等。所有这些选项都可以通过PyTorch中的interpolate函数轻松实现。

同步输入采样和输出插值

除了通过池化和输出插值提出输入采样外,N-HiTS 还提出了以特定方式将它们排列在不同区块中的方法。作者认为,层次化插值只有在表现力比率以与多速率采样同步的方式分布在区块之间时,才能正确进行。离输入较近的区块应该具有较小的表现力比率r[l],以及较大的卷积核大小k[l]。这意味着离输入较近的区块将生成更高分辨率的模式(因为进行更为积极的插值),同时被迫查看大幅度下采样的输入信号。论文提出了随着从初始区块到最后一个区块的移动,表现力比率呈指数增长,以处理广泛的频率带。官方的 N-HiTS 实现使用以下公式来设置表现力比率和池化卷积核:

pooling_sizes = np.exp2(
    np.round(np.linspace(0.49, np.log2(prediction_length / 2), n_stacks))
)
pooling_sizes = [int(x) for x in pooling_sizes[::-1]]
downsample_frequencies = [
    min(prediction_length, int(np.power(x, 1.5))) for x in pooling_sizes
] 

我们还可以提供显式的pooling_sizesdownsampling_fequencies来反映已知的时间序列周期(例如每周季节性、每月季节性等)。N-BEATS 的核心原理(一个区块从信号中去除其捕捉的影响并传递给下一个区块)在这里也得到了应用,因此在每一层中,区块捕捉到的模式或频率会在传递给下一个区块之前从输入信号中去除。最终,最终的预测是所有这些单独区块预测的总和。

使用 N-HiTS 进行预测

N-HiTS 已在 NIXTLA 预测中实现。我们可以使用与 N-BEATS 相同的框架,并扩展其以便在我们的数据上训练 N-HiTS。更棒的是,该实现支持外生变量,就像 N-BEATSx 处理外生变量一样(尽管没有外生模块)。首先,让我们看看实现的初始化参数。

neuralforecast 中的 NHITS 类具有以下参数:

  • n_blocks:这是一个整数列表,表示每个堆叠中使用的块的数量。例如,[1,1,1] 表示将有三个堆叠,每个堆叠中有一个块。

  • n_pool_kernel_size:这是一个整数列表,用于定义每个堆叠的池化大小(k[l])。这是一个可选参数,如果提供,我们可以更好地控制不同堆叠中池化的方式。使用从高到低的排序可以提高结果。

  • pooling_mode:定义要使用的池化类型。它应该是 'MaxPool1d''AvgPool1d'

  • n_freq_downsample:这是一个整数列表,用于定义每个堆叠的表现力比率(r[l])。这是一个可选参数,如果提供,我们可以更好地控制不同堆叠中插值的方式。

笔记本提示

训练 N-HiTS 的完整代码可以在 Chapter16 文件夹中的 02-NHiTS_NeuralForecast.ipynb 笔记本中找到。

现在,让我们把注意力转向 Transformer 模型的一些修改,使其更适合时间序列预测。

Autoformer

最近,Transformer 模型在捕捉长期模式方面表现出比标准 RNN 更强的性能。其主要原因之一是自注意力机制,Transformer 正是依靠这一机制来减少相关序列信息在被用于预测之前必须保存的长度。换句话说,在 RNN 中,如果第 12 步之前的时间步包含重要信息,那么这些信息必须通过 12 次更新存储在 RNN 中,才能用于预测。但是,在 Transformer 中,由于结构中没有递归,模型可以自由地在滞后 12 步和当前步骤之间创建一个快捷通道。

但同样的自注意力机制也是我们无法将原生 Transformer 扩展到长序列的原因。在上一节中,我们讨论了长期预测面临的挑战,主要有两个原因:捕捉变化所需的表现力和计算复杂度。自注意力的二次计算复杂度就是第二个原因。

研究界已经意识到这个挑战,并投入了大量努力,通过许多技术,如下采样、低秩近似、稀疏注意力等,来设计高效的变换器。有关这些技术的详细介绍,请参阅进一步阅读部分中的高效变换器:调查链接。

Autoformer是另一种为长期预测设计的模型。Autoformer 发明了一种新的注意力机制,并将其与时间序列分解的各个方面结合。让我们来看一下是什么使得 Autoformer 如此特别。

Autoformer 模型的架构

Autoformer 模型是变换器的改进版。以下是其主要贡献:

  • 一致性输入表示:一种系统化的方法,将序列的历史信息与其他信息结合,有助于捕获长期信号,如周、月、节假日等。

  • 生成式解码器:用于在一次前向传播中生成长期预测,而不是通过动态递归生成。

  • 自相关机制:一种替代标准点积注意力的方法,考虑的是子序列相似性,而非点对点相似性。

  • 分解架构:一种专门设计的架构,在建模时间序列时将季节性、趋势和残差分离。

参考检查

吴等人在 Autoformer 上的研究论文在参考文献部分被引用为9

一致性输入表示

RNN 通过其递归结构捕获时间序列模式,因此只需要序列;它们不需要时间戳信息来提取模式。然而,变换器中的自注意力是通过点对点操作来完成的,这些操作在一组内执行(顺序在一组内不重要)。通常,我们会包含位置编码来捕获序列的顺序。我们可以不使用位置编码,而是使用更丰富的信息,如层次化时间戳信息(例如周、月、年等)。这正是作者通过一致性输入表示提出的。

一致性输入表示使用三种类型的嵌入来捕获时间序列的历史、时间序列中的值序列和全局时间戳信息。时间序列中的值序列通过d_model维度的标准位置嵌入来捕获。

一致性输入表示使用一个一维卷积层,kernel=3stride=1,将历史数据(它是标量或一维的)投影到d_model维度的嵌入中。这被称为值嵌入

全球时间戳信息通过一个可学习的嵌入(d_model维度,有限词汇表)嵌入机制来实现,这个机制与将类别变量嵌入到固定大小的向量中的方法相同(第十五章全球深度学习预测模型策略)。这被称为时间嵌入

现在我们有三个相同维度的嵌入,d_model,我们需要做的就是将它们加在一起,得到统一输入表示。

生成式解码器

推理 Transformer 模型的标准方式是一次解码一个标记。这个自回归过程非常耗时,并且每一步都会重复很多计算。为了解决这个问题,Autoformer 模型采用了更具生成性的方式,在一次前向传播中生成整个预测区间。

在自然语言处理(NLP)中,使用特殊标记(START)来开始动态解码过程是一种流行的技术。Autoformer 模型没有选择为此目的使用特殊标记,而是从输入序列中选择一个样本,例如输出窗口之前的一个较早的切片。例如,假设输入窗口是t[1]到t[w],我们将从输入中采样一个长度为C的序列,t[w-c]到t[w],并将该序列作为解码器的起始序列。为了使模型在一次前向传播中预测整个预测区间,我们可以扩展解码器输入张量,使其长度为C + H,其中H是预测区间的长度。初始的C个标记由输入的样本序列填充,剩余部分填充为零——即,。这只是目标。尽管的预测区间填充了零,但这仅仅是目标。其他信息,例如全球时间戳,包含在中。还采用了充分的注意力矩阵遮蔽,使得每个位置不会关注未来的位置,从而保持预测的自回归特性。

现在,让我们来看一下时间序列分解架构。

分解架构

我们在第三章时间序列数据的分析与可视化中曾看到过分解的思想,甚至在本章(N-BEATS)中也有所提及。Autoformer 成功地将 Transformer 架构改造成了一种深度分解架构:

图 16.5 – Autoformer 架构

图 16.4:Autoformer 架构

首先理解整体架构,然后再深入了解细节会更容易。在图 16.4中,有标记为自相关系列分解的框。现在,只需知道自相关是一种注意力机制,而系列分解是一个将信号分解为趋势-周期性和季节性分量的特定模块。

编码器

通过前面章节讨论的抽象级别,让我们理解编码器中发生了什么:

  1. 时间序列的均匀表示x[en]是编码器的输入。输入通过一个自相关块(用于自注意力),其输出是x[ac]。

  2. 均匀表示x[en]作为残差连接加回x[ac],x[ac] = x[ac] + x[en]。

  3. 现在,x[ac]通过一个系列分解块,该块将信号分解为趋势-周期性成分(x[T])和季节性成分x[seas]。

  4. 我们丢弃x[T]并将x[seas]传递给前馈网络,该网络输出x[FF]。

  5. x[seas]再次作为残差连接加回x[FF],x[seas] = x[FF] + x[seas]。

  6. 最后,这个x[seas]通过另一个系列分解层,该层再次将信号分解为趋势!和季节性成分!

  7. 我们丢弃!,并将!作为一个编码器块的最终输出传递。

  8. 可能会有N个编码器块堆叠在一起,每个编码器块都将前一个编码器的输出作为输入。

现在,让我们将注意力转向解码器块。

解码器

Autoformer 模型通过包含来自输入序列的采样窗口,使用类似 START 标记的机制。但 Autoformer 不仅仅取序列,而是对其进行了一些特殊处理。Autoformer 将其学习能力的重点放在了季节性上。变换器的输出也是季节性。因此,Autoformer 不包含完整的输入序列窗口,而是将信号分解,并仅在 START 标记中包含季节性成分。让我们一步步地来看这个过程:

  1. 如果输入(上下文窗口)是x,我们通过系列分解块将其分解为!和!

  2. 现在,我们从的末尾采样C个时间步,并添加H个零,其中H是预测视野,构造x[ds]。

  3. 这个x[ds]接着用于创建一个均匀表示x[dec]。

  4. 与此同时,我们从的末尾采样C个时间步,并附加H个时间步与序列的均值(mean(x)),其中H是预测视野,构造x[dt]。

然后,这个x[dec]作为输入用于解码器。这就是解码器中发生的事情:

  1. 输入x[dec]首先通过一个自相关(用于自注意力)块,其输出是x[dac]。

  2. 均匀表示x[dec]作为残差连接加回x[dac],x[dac] = x[dac] + x[dec]。

  3. 现在,x[dac]通过一个系列分解块,该块将信号分解为趋势-周期性成分(x[dT][1])和季节性成分x[dseas]。

  4. 在解码器中,我们不会丢弃趋势组件;相反,我们会将其保存。这是因为我们将会将所有包含趋势的趋势组件(x[dt])加在一起,形成整体的趋势部分(T)。

  5. 序列分解块的季节性输出(x[dseas]),以及来自编码器的输出(),然后传入另一个自相关块,在这里计算解码器序列和编码器序列之间的交叉注意力。让这个块的输出是x[cross]。

  6. 现在,x[dseas]作为残差连接被加回到x[cross]上,x[cross] = x[cross] + x[dseas]。

  7. x[cross]再次通过序列分解块,该块将x[cross]分解为两个组件—x[dT2]和x[dseas2]。

  8. x[dseas]随后通过前馈网络转换为x[dff],并通过残差连接将x[dseas]加到其中,x[dff] = x[dff] + x[dseas]。

  9. 最后,x[dff]通过另一个序列分解块,该块将其分解为两个组件—x[dT3]和x[dseas3]。x[dseas3]是解码器的最终输出,捕捉季节性。

  10. 另一个输出是残差趋势,,它是通过解码器的序列分解块提取的所有趋势组件的总和的投影。投影层是一个Conv1d层,它将提取的趋势投射到所需的输出维度:

  11. M个这样的解码器层被依次堆叠,每个层将其输出作为下一个层的输入。

  12. 每个解码器层的残差趋势,,会加到趋势初始化部分x[dt]上,以建模整体的趋势组件(T)。

  13. 最终解码器层的x[dseas3]被认为是整体的季节性组件,并通过一个线性层投射到所需的输出维度(S)。

  14. 最终,预测或预测结果 X[out] = T + S

整个架构巧妙地设计,以便将时间序列中相对稳定且易于预测的部分(趋势-周期性)移除,从而能够很好地建模难以捕捉的季节性。

现在,序列分解块是如何分解序列的呢?这个机制你可能已经熟悉了:AvgPool1d配合一定的填充,这样它可以保持与输入相同的大小。这就像是在指定的核宽度上做一个移动平均。

我们在整个解释过程中都在讨论自相关块。现在,让我们来理解一下自相关块的巧妙之处。

自相关机制

Autoformer 使用自相关机制替代标准的缩放点积注意力机制。该机制基于周期性发现子序列的相似性,并利用这种相似性聚合相似的子序列。这个巧妙的机制通过将标准缩放点积注意力的逐点操作扩展到子序列级操作,从而打破了信息瓶颈。整体机制的初始部分类似于标准的注意力过程,其中我们使用权重矩阵将查询、键和值投影到相同的维度。关键区别在于注意力权重的计算方式以及它们如何被用来计算值。这个机制通过使用两个显著的子机制来实现这一点:发现基于周期的依赖关系和时间延迟聚合。

基于周期的依赖关系

Autoformer 使用自相关作为相似性度量。正如我们所知,自相关表示给定时间序列 X[t] 与其滞后序列之间的相似性。例如, 是时间序列 X[t] 与 之间的自相关。Autoformer 将这个自相关视为特定滞后的未归一化置信度。因此,从所有 中,我们选择 k 个最可能的滞后并使用 softmax 将这些未归一化的置信度转换为概率。我们使用这些概率作为权重来聚合相关子序列(我们将在下一节讨论这一点)。

自相关计算并不是最高效的操作,Autoformer 提出了一个替代方案,使得计算更快。根据 Wiener–Khinchin 定理(该内容在随机过程中涉及,超出了本书的范围,但对于感兴趣的读者,我在进一步阅读部分提供了链接),自相关也可以通过 快速傅里叶变换 (FFT) 进行计算。过程如下所示:

这里, 表示 FFT, 表示共轭操作(复数的共轭是具有相同实部和虚部符号相反的数值,大小相等。有关这一部分的数学推导超出了本书的范围)。

这可以很容易地用 PyTorch 编写如下:

# calculating the FFT of Query and Key
q_fft = torch.fft.rfft(queries.permute(0, 2, 3, 1).contiguous(), dim=-1)
k_fft = torch.fft.rfft(keys.permute(0, 2, 3, 1).contiguous(), dim=-1)
# Multiplying the FFT of Query with Conjugate FFT of Key
res = q_fft * torch.conj(k_fft) 

现在, 处于频谱域。为了将其带回实数域,我们需要做一个逆 FFT:

这里, 表示逆 FFT。在 PyTorch 中,我们可以轻松实现:

corr = torch.fft.irfft(res, dim=-1) 

当查询和键相同,计算的是自注意力;当它们不同,计算的是交叉注意力。

现在,我们所需要做的就是从 corr 中取出前 k 个值,并用它们来聚合子序列。

时间延迟聚合

我们通过 FFT 和逆 FFT 确定了自相关的主要滞后。例如,我们使用的数据集(伦敦智能电表数据集)具有半小时的频率,并且具有强烈的日常和每周季节性。因此,自相关识别可能已将 48 和 48*7 选为最重要的两个滞后。在标准的注意力机制中,我们使用计算出的概率作为权重来聚合值。Autoformer 也做了类似的事情,但它不是将权重应用于单个点,而是将它们应用于子序列。

Autoformer 通过将时间序列按滞后进行平移来实现此功能,,然后使用滞后权重对其进行聚合:

这里,softmax 归一化后的 top-k 自相关概率。

在我们的示例中,我们可以将其理解为将序列平移 48 个时间步长,以使前一天的时间步长与当前日对齐,然后使用 48 个滞后的权重进行缩放。然后,我们可以转到 487 的滞后,将前一周的时间步长与当前周对齐,再使用 487 滞后的权重进行缩放。因此,最终我们将获得一个加权的季节性模式混合,这些模式可以在日常和每周中观察到。由于这些权重是由模型学习的,我们可以假设不同的模块学习专注于不同的季节性,因此整体上,模块学习了时间序列中的总体模式。

使用 Autoformer 进行预测

Autoformer 实现在 NIXTLA 预测中。我们可以使用之前用于 NBEATS 的相同框架,并扩展它以在我们的数据上训练 Autoformer。首先,让我们看看实现中的初始化参数。

我们必须记住,Autoformer 模型不支持外生变量。它官方支持的唯一附加信息是全局时间戳信息,如周、月等,以及假期信息。我们可以从技术上将其扩展到任何类别特征(静态或动态),但目前不支持任何实值信息。

让我们看看实现中的初始化参数。

Autoformer 类具有以下主要参数:

  • distil:这是一个布尔标志,用于开启或关闭注意力蒸馏。

  • encoder_layers:这是一个整数,表示编码器层的数量。

  • decoder_layers:这是一个整数,表示解码器层的数量。

  • n_head:这是一个整数,表示注意力头的数量。

  • conv_hidden_size:这是一个整数参数,指定卷积编码器的通道数,可以类似于控制卷积层中内核或滤波器的数量。通道的数量有效地决定了应用于输入数据的不同滤波器的数量,每个滤波器捕捉不同的特征。

  • activation:这是一个字符串值,可以是 relugelu 中的一个。它是用于编码器和解码器层的激活函数。

  • factor:这是一个整数值,帮助我们控制在我们讨论的自相关机制中将要选择的 top-k 值。top_k = int(self.factor * math.log(length)) 是使用的准确公式,但我们可以将 k 看作一个因子来控制前 K 的选择。

  • dropout:这是一个介于 0 和 1 之间的浮动值,决定网络中 dropout 的强度。

笔记本警告

训练 Autoformer 模型的完整代码可以在 Chapter16 文件夹中的 03-Autoformer_NeuralForecast.ipynb 笔记本中找到。

让我们换个话题,来看一下提出的用于挑战 Transformer 的一类简单线性模型,用于长期时间序列预测LTSF)。

LTSF-线性系列模型

关于 Transformer 是否适合用于预测问题,许多论文讨论了 Transformer 论文普遍没有使用强基线来展示其优越性、顺序不敏感的注意力机制可能不是处理强序列时间序列的最佳方法等等。对于长期时间序列预测的批评更为明显,因为它更多依赖于提取强趋势和季节性。2023 年,曾爱玲等人决定对 Transformer 模型进行测试,并通过使用 5 个多元数据集进行广泛研究,将五种 Transformer 模型(FEDFormer、Autoformer、Informer、Pyraformer 和 LogTrans)与他们提出的一组简单线性模型进行对比。令人惊讶的是,他们提出的简单线性模型轻松超过了所有 Transformer 模型。

参考检查

曾爱玲等人的研究论文和不同的 Transformer 模型,FEDFormer、Autoformer、Informer、Pyraformer 和 LogTrans,分别在 参考文献 部分被引用为 1416981517

在 LTSF 模型系列中,作者提出了三种模型:

  1. 线性

  2. D-线性

  3. N-线性

这些模型非常简单,以至于它们能超越 Transformer 模型简直有些让人羞愧。但一旦你更了解它们,你可能会欣赏到这些模型内建的简单但有效的归纳偏置。让我们一一来看。

线性

正如名字所示,这是一个简单的线性模型。它取上下文窗口并应用一个线性层来预测预测视野。它还将不同的时间序列视为独立通道,并对每个通道应用不同的线性层。在 PyTorch 中,我们只需要为每个通道拥有一个 nn.Linear 层:

# Declare nn.Linear for each channel
layers = nn.ModuleList([nn.Linear(context_window, forecast_horizon) for _ in range(n_timeseries)])
## Forward Method ##
# Now use these layers once you get the input (Batch, Context Length, Channel)
forecast = layers[i for i in range(n_timeseries)] 

这个令人尴尬的简单模型能够超越一些 Transformer 模型,如 Informer、LogTrans 等等。

D-线性

D-Linear 将简单的线性模型与一个分解先验结合起来。我们在第三章中已经看到如何将时间序列分解为趋势、季节性和残差。D-Linear 正是这样做的,并使用移动平均(窗口或核大小是一个超参数)将输入时间序列x分解为趋势t(移动平均)和剩余部分r(季节性+残差)。接下来,它对tr分别应用独立的线性层,最后将它们加在一起得到最终的预测。让我们来看一下简化的PyTorch实现:

# Declare nn.Linear for each channel, trend and seasonality separately
trend_layers = nn.ModuleList([nn.Linear(context_window, forecast_horizon) for _ in range(n_timeseries)])
seasonality_layers = nn.ModuleList([nn.Linear(context_window, forecast_horizon) for _ in range(n_timeseries)])
## Forward Method ##
# Now use these layers once you get the input (Batch, Context Length, Channel)
# series_decomp is a function extracting trend using moving aveages
trend, seasonality = series_decomp(input)
trend_forecast = trend_layers[i for i in range(n_timeseries)]
seasonality_forecast = seasonality_layers[i for i in range(n_timeseries)]
forecast = [trend_forecast[i] + seasonality_forecast[i] for i in range(n_timeseries)] 

模型中的分解先验使其比简单的线性模型表现得更好,并且它在研究中几乎所有数据集上都超过了所有 Transformer 模型。

N-Linear

作者还提出了另一种模型,向线性模型添加了另一个非常简单的修改。这个修改是用来处理时间序列数据中固有的分布性变化。在 N-Linear 中,我们只需提取输入上下文中的最后一个值,并将其从整个序列中减去(进行一种规范化处理),然后使用线性层进行预测。现在,一旦线性层的输出可用,我们再将之前减去的最后一个值加回去。在 PyTorch 中,一个简单的实现可能是这样的:

# Declare nn.Linear for each channel
layers = nn.ModuleList([nn.Linear(context_window, forecast_horizon) for _ in range(n_timeseries)])
## Forward Method ##
# Extract the last value once you get the input (Batch, Context Length, Channel)
# Get the last value of time series
last_value = sample_data[:,-1:,:]
# Normalize the time series
norm_ts = sample_data - last_value
# Use the linear layers
output = layers[i for i in range(n_timeseries)]
# Add back the last value
forecast = [o + last_value[:,:,i] for i, o in enumerate(output)] 

与研究中的其他 Transformer 模型相比,N-Linear 模型表现得也相当优秀。在研究中大多数数据集里,N-Linear 或 D-Linear 都成为了表现最好的模型,这一点非常值得注意。

本文揭示了我们在使用 Transformer 模型进行时间序列预测时存在的一些重大缺陷,特别是在多变量时间序列问题上。Transformer 的典型输入形式是(Batch x Time steps x Embedding)。预测多变量时间序列的最常见方法是将所有时间序列或其他特征作为 embedding 传入每个时间步。这会导致看似不相关的值被嵌入到一个单一的 token 中,并在注意力机制中混合在一起(而注意力机制本身并不强制按顺序排列)。这就导致了“混乱”的表示,进而使得 Transformer 模型可能在从数据中提取真实模式时遇到困难。

本文产生了如此大的影响,以至于许多新模型,包括 PatchTST 和 iTransformer(我们将在本章后面看到这些模型),都将这些模型作为基准,并且表现优于它们。这强调了需要保留强大而简单的方法作为可靠的基准,以避免被任何算法的“炫酷性”所误导。

现在让我们看看如何使用这些简单的线性模型并获得良好的长期预测。

使用 LTSF-Linear 家族进行预测

NLinear 和 DLinear 在 NIXTLA 预测中实现,采用了我们在前述模型中看到的相同框架。

让我们看看实现中的初始化参数。

DLinear 类与许多其他模型具有类似的参数。以下是一些主要参数:

  • moving_avg_window:这是一个整数值,表示用于趋势季节性分解的窗口大小。此值应该是一个奇数。

  • exclude_insample_y:这是一个布尔值,用于跳过自回归特征。

NLinear 类没有额外的参数,因为它只是一个输入窗口到输出窗口的映射。

笔记本提醒

训练 D-Linear 模型的完整代码可以在04-DLinear_NeuralForecast.ipynb笔记本中找到,N-Linear 模型的代码则位于05-NLinear_NeuralForecast.ipynb笔记本中的Chapter16文件夹里。

从业者提示

这个辩论尚无定论,因为 Transformer 模型在不断修改以适应时间序列预测。可能总有某些数据集,在这些数据集上使用基于 Transformer 的模型能够比其他类型的模型获得更好的性能。作为从业者,我们应该能够怀疑一切,并尝试不同类别的模型,看看哪种模型适合我们的应用场景。毕竟,我们关心的只是我们正在预测的数据集。

现在,让我们看看如何根据 LTSF-Linear 论文的见解修改 Transformer 来用于时间序列,并展示它如何优于我们刚才看到的简单线性模型。

Patch 时间序列 Transformer(PatchTST)

2021 年,Alexey Dosovitskiy 等人提出了 Vision Transformer,该架构将 Transformer 架构广泛应用于自然语言处理领域,并取得了巨大成功,随后也被引入到计算机视觉中。尽管不是第一个引入 patch 技术的模型,但他们在视觉任务中的应用方式非常成功。该设计将图像分成多个 patch,并依次将每个 patch 输入到 Transformer 中。

参考检查

Alexey Dosovitskiy 等人关于 Vision Transformers 的研究论文和 Yuqi Nie 等人关于 PatchTST 的研究论文分别在参考文献部分中标记为1213

快进到 2023 年,我们看到同样的 patch 设计被应用于时间序列预测。Yuqi Nie 等人提出了Patch 时间序列 TransformerPatchTST),通过为时间序列采用 patch 设计。它们的动机是更复杂的 Transformer 设计(如 Autoformer 和 Informer)在时间序列预测中的效果并不明显。

2023 年,Zheng 等人通过将多个 Transformer 模型与一个简单的线性模型进行比较,展示了该线性模型在常见基准测试中优于大多数 Transformer 模型。论文的一个关键洞察是,时间序列点对点地应用到 Transformer 架构无法捕捉时间序列数据中的局部信息和强排序性。因此,作者提出了一种更简单的替代方案,它比线性模型表现更好,并解决了在不增加内存和计算需求的情况下将长时间窗口引入 Transformer 的问题。

PatchTST 模型的架构

PatchTST 模型是对 Transformers 的修改。以下是其主要贡献:

  • 分片:一种有条理的方法,用于将序列的历史信息与其他信息结合,这有助于捕捉长期信号,如周、月、节假日等。

  • 通道独立性:一种将多变量时间序列作为独立时间序列进行处理的概念方式。虽然我不认为这是一项重大贡献,但这确实是我们需要注意的地方。

让我们更详细地看看这些参数。

分片

我们在本章早些时候看到了一些针对时间序列预测的 Transformer 改进方法。它们都集中在使注意力机制适应时间序列预测和更长的上下文窗口。但它们都以逐点的方式使用注意力。让我们通过一个图示来澄清这一点,并引入分片的概念。

图 16.5:输入 Transformer 的分片与非分片时间序列

图 16.5中,我们以一个有 8 个时间步长的时间序列为例。在左侧,我们可以看到我们讨论过的所有其他 Transformer 架构如何处理时间序列。它们使用一些机制,如 AutoFormer 中的统一表示,将时间序列的每个点转换为 k 维嵌入,然后将其逐点输入到 Transformer 架构中。每个点的注意力机制是通过查看上下文窗口中所有其他点来计算的。

PatchTST 论文声称,这种点对点的注意力机制并不能有效捕捉时间序列的局部性,提出将时间序列转换为片段,并将这些片段馈送到 Transformer 中。分片不过是将时间序列变成更短的时间序列,这一过程与我们之前在书中看到的滑动窗口操作非常相似(或者几乎相同)。主要的不同之处在于,这种分片是在我们已经从更大的时间序列中采样一个窗口后进行的。

分片通常由几个参数定义:

  • 片段长度(P)是每个子时间序列或片段的长度。

  • 步幅(S)是两个连续片段之间不重叠区域的长度。更直观地说,这就是每次分片迭代时我们移动的时间步数。这与卷积中的步幅具有完全相同的意义。

固定这两个参数后,一个长度为L的时间序列将会产生 个片段。在这里,我们还将最后一个值的重复数字填充到原始序列的末尾,以确保每个片段的大小相同。

图 16.5中,我们可以看到我们已经说明了一个时间序列补丁处理的过程,长度为!,包含!,和!。使用刚才看到的公式,我们可以计算出!。我们还可以看到,最后一个值 8 已经在末尾重复,作为填充,使得最后一个补丁的长度也为 4。

现在,考虑到每个补丁可以看作一种嵌入,并被传入并由 Transformer 架构处理。通过这种输入补丁,对于给定的上下文!,Transformer 的输入令牌数量大约可以减少到!。这意味着计算复杂度和内存使用也减少了约!。这使得模型在相同的硬件限制下能够处理更长的上下文窗口,从而可能提高模型的预测性能。

现在,让我们来看一下通道独立性。

通道独立性

多变量时间序列可以看作是一个多通道信号。Transformer 输入可以是单个通道或多个通道。大多数基于 Transformer 的多变量预测模型采用的是将通道混合在一起并处理的方法。换句话说,输入令牌从所有时间序列中获取信息,并将其投影到共享的嵌入空间,混合信息。而其他更简单的方法则分别处理每个通道,PatchTST 的作者将这种独立性引入到了 Transformer 中。

实际上,这非常简单。让我们通过一个例子来理解。考虑一个数据集,其中有!个时间序列,形成一个多变量时间序列。因此,PatchTST 的输入将是!,其中!是批量大小,!是上下文窗口的长度。补丁处理后,它变成了!,其中!是补丁的数量,!是补丁的长度。现在,为了以通道独立的方式处理这个多变量信号,我们只需要将张量重塑,使得每个 M 个时间序列变成批次中的一个样本,即!,其中!

虽然这种独立性为模型带来了一些理想的特性,但这也意味着不同时间序列之间的任何交互都被忽略,因为它们被视为完全独立。模型仍然是在全局模型范式下训练的,并将从跨领域学习中受益,但不同时间序列之间的任何显式交互(如两个时间序列一起变化)并未被捕捉。

除了这些主要组件外,架构与标准 Transformer 架构非常相似。现在,让我们来看看如何使用 PatchTST 进行实际的预测。

使用 PatchTST 进行预测

PatchTST 在 NIXTLA 预测中得到了实现。与之前使用的框架相同,这里也可以使用相同的框架来实现 PatchTST。

让我们来看一下实现的初始化参数。

PatchTST 类具有以下主要参数:

  • encoder_layers:这是一个整数,表示编码器层的数量。

  • hidden_size:该参数设置嵌入和编码器的大小,直接影响模型的能力及其从数据中捕获信息的能力。这是编码器和解码器层使用的激活函数。

  • patch_lenstride:这些参数定义了输入序列如何被划分成补丁,进而影响模型如何感知时间依赖性。patch_len 控制每个段的长度,而 stride 则影响这些段之间的重叠部分。

  • stride:该参数设置嵌入和编码器的大小,直接影响模型的能力及其从数据中捕获信息的能力。这是编码器和解码器层使用的激活函数。

正则化参数:

  • dropout:这是一个介于 0 和 1 之间的浮动值,用于确定网络中的丢弃强度。

  • fc_dropout:这是一个浮动值,表示线性层的丢弃率。

  • head_dropout:这是一个浮动值,表示扁平化层的丢弃率。

  • attn_dropout:这是一个浮动值,表示注意力层的丢弃率。

    笔记本提示

    训练 PatchTST 模型的完整代码可以在 Chapter16 文件夹中的 06-PatchTST_NeuralForecast.ipynb 笔记本中找到。

现在,让我们看一个基于 Transformer 的模型,它从 PatchTST 中获得创新,并反过来加以改进,取得了良好的效果,超越了 LTSF-Linear 模型。

iTransformer

我们已经详细讨论了 Transformer 架构在处理多变量时间序列时的不足之处,即无法高效捕获局部性,基于顺序无关的注意力机制使得跨时间步的信息混乱等问题。在 2024 年,Yong Liu 等人对这个问题持有稍微不同的看法,并用他们自己的话说,“一种极端的补丁处理方式。”

iTransformer 的架构

他们提出,问题不在于 Transformer 架构在时间序列预测中无效,而是其使用不当。作者建议,我们可以翻转输入到 Transformer 架构,使得注意力机制不再跨时间步应用,而是跨不同的变量或时间序列的不同特征进行应用。图 16.6 清楚地展示了这种差异。

图 16.6:Transformers 与 iTransformers 的差异

在原始 Transformer 中,我们使用输入为(批次 x 时间步 x 嵌入特征)),注意力机制在时间步之间应用,最终,按位置的前馈网络将不同的特征混合成一个变元混合表示。但当你将输入反转为(批次 x 嵌入特征) x 时间步)时,注意力机制会在变量之间进行计算,按位置的前馈网络会混合时间并保持变元在变元未混合表示中的独立性。

这种“反转”带来了一些额外的好处。由于注意力机制不再跨时间进行计算,我们可以在计算和内存约束较小的情况下包含非常大的上下文窗口(记住,计算和内存复杂度来自注意力机制的 O(N²))。事实上,论文建议将整个时间序列历史作为上下文窗口。另一方面,我们需要注意包含在模型中的特征数或并发时间序列的数量。

一个典型的 Transformer 架构包含以下主要组件:

  • 注意力机制

  • 前馈网络

  • 层归一化

在倒转版本中,我们已经看到,注意力机制是跨变元应用的,前馈网络FFN)学习了用于最终预测的回溯窗口的可泛化表示。层归一化在倒转版本中也表现得很好。在标准 Transformer 中,层归一化通常用于归一化每个时间步的多变量表示。但在倒转版本中,我们对每个变元在时间维度上分别进行归一化。这类似于我们在 N-Linear 模型中做的归一化,并且已被证明对非平稳时间序列问题有效。

参考检查

Yong Liu 等人关于 iTransformer 的研究论文在参考文献部分被引用为18

使用 iTransformer 进行预测

iTransformer 在 NIXTLA 预测中实现。可以使用与之前相同的框架,也可以在这里使用 iTransformer。

iTransformer类有以下主要参数:

  • n_series:这是一个整数,表示时间序列的数量。

  • e_layers:这是一个整数,表示编码器层的数量。

  • d_layers:这是一个整数,表示解码器层的数量。

  • d_ff:这是一个整数,表示在编码器和解码器层中使用的 1 维卷积层的核数。

笔记本提醒

训练 iTransformer 模型的完整代码可以在Chapter16文件夹中的07-iTransformer_NeuralForecast.ipynb笔记本中找到。

现在,让我们来看一下另一种非常成功的架构,它设计得很好,能够在全局上下文中利用各种信息。

时间融合 Transformer(TFT)

TFT 是一种从全局建模角度精心设计的模型,以最有效地利用各种静态和动态变量信息为特点。TFT 在所有设计决策中都注重解释性。其结果是一个高性能、可解释和全局的深度学习模型。

参考检查

Lim 等人关于 TFT 的研究论文在参考文献部分被引用为10

乍一看,模型架构看起来复杂且令人望而生畏。但一旦你剥开这层外皮,它其实相当简单和巧妙。我们将一级一级地深入模型,以帮助您更好地理解。在这个过程中,会有很多黑箱我会请您默认接受,但不用担心——我们会逐一打开它们,深入探讨。

TFT 的架构

在我们开始之前,让我们先建立一些符号和设置。我们有一个包含I个唯一时间序列的数据集,每个实体i都有一些静态变量(s[i])。所有实体的所有静态变量的集合可以用S表示。我们还有长度为k的上下文窗口。除此之外,我们还有时间变化的变量,这些变量有一个区别——对于某些变量,我们没有未来数据(未知),而对于其他变量,我们知道未来(已知)。让我们用输入的上下文窗口x[t-k]…x[t]来表示所有时间变化信息(上下文窗口、已知和未知的时间变化变量)。未来的已知时间变化变量用表示,其中是预测的时间跨度。有了这些符号,我们准备好来看第一层抽象化了:

图 16.6 – TFT – 高层概述

图 16.7:TFT—高层概述

这里有很多内容需要分析。让我们从静态变量S开始。首先,静态变量通过变量选择网络VSN)传递。VSN 对实例级特征进行选择,并对输入进行一些非线性处理。处理后的输入被馈送到一组静态协变量编码器SEs)。SE 块被设计为以一种原则性的方式整合静态元数据。

如果你按照图 16.6中 SE 模块的箭头,你会看到静态协变量在架构中三个(四个不同输出)不同的地方被使用。我们将在讨论这些模块时看到它们是如何在这些地方使用的。但这些不同的地方可能在关注静态信息的不同方面。为了让模型具有这种灵活性,处理过并经过变量选择的输出被输入到四个不同的门控残差网络GRNs)中,这四个 GRN 会分别生成四个输出——c[s]、c[e]、c[c]和c[h]。我们稍后会解释什么是 GRN,但现在只需要理解它是一个可以进行非线性处理的模块,并带有残差连接,这使得它在需要时可以跳过非线性处理。

过去的输入,x[t-k]…x[t],以及未来已知的输入,,也会通过各自的 VSN,并且这些处理后的输出会被输入到局部增强LE)的 Seq2Seq 层。我们可以把 LE 看作是一种将局部上下文和时间顺序编码到每个时间步的嵌入中的方式。这类似于普通 Transformer 中的位置编码。我们还可以看到在Conv1d层中也有类似的尝试,这些层用于在 Autoformer 模型中对历史进行编码。我们稍后会探讨 LE 内部的工作原理,但现在只需要理解它捕捉了基于其他观测变量和静态信息的局部上下文。我们将这个模块的输出称为局部编码上下文向量)。

术语、符号和主要模块的分组与原始论文中不同。我对这些进行了修改,以使它们更易于理解。

现在,这些 LE 上下文向量被输入到时间融合解码器TFD)中。TFD 以 Transformer 模型中类似的方式应用了多头自注意力的微小变体,并生成解码表示)。最后,这个解码表示通过门控线性单元GLU)和一个Add & Norm模块,后者将 LE 上下文向量作为残差连接添加进去。

GLU 是一个帮助模型决定需要允许多少信息流过的单元。我们可以将其视为一个学习的信息节流装置,它在自然语言处理NLP)架构中广泛应用。公式非常简单:

这里,WV是可学习的权重矩阵,bc是可学习的偏置,是激活函数,是哈达玛积运算符(元素逐个相乘)。

Add & Norm模块与普通 Transformer 中的相同;我们在第十四章时间序列的注意力和 Transformer中讨论过这一点。

现在,为了完美收尾,我们有一个Dense层(带偏置的线性层),它将Add & Norm模块的输出投影到所需的输出维度。

现在是我们在抽象层次上进一步下沉的时候了。

局部增强 Seq2Seq 层

让我们剥开洋葱,看看 LE Seq2Seq 层内部发生了什么。让我们从一张图开始:

图 16.7 – TFT – LE Seq2Seq 层

图 16.8:TFT—LE Seq2Seq 层

LE 使用 Seq2Seq 架构来捕获局部上下文。处理过程从过去的输入开始。LSTM 编码器接收这些过去的输入,x[t-k]…x[t]。c[h] c[c]来自静态协变量编码器,作为 LSTM 的初始隐藏状态。编码器逐步处理每个时间步,产生每个时间步的隐藏状态,H[t-k]…H[t]。最后的隐藏状态(上下文向量)被传递给 LSTM 解码器,解码器处理已知的未来输入,,并在每个未来时间步生成隐藏状态,。最后,所有这些隐藏状态都通过一个GLU + AddNorm模块,带有来自 LSTM 处理前的残差连接。输出是 LE 上下文向量()。

现在,让我们看看下一个模块:TFD。

时间融合解码器

让我们从另一张图开始讨论:

图 16.8 – 时间融合变换器 – 时间融合解码器

图 16.9:时间融合变换器—时间融合解码器

来自过去输入和已知未来输入的 LE 上下文向量被连接成一个单一的 LE 上下文向量。现在,这可以被视为在 Transformer 范式中的位置编码标记。TFD 首先做的是用来自静态协变量编码器创建的静态信息c[e]来丰富这些编码。它与嵌入一起连接。一个逐位置的 GRN 用于丰富嵌入。这些增强的嵌入现在作为掩码可解释多头注意力模块的查询、键和值。

论文提出,掩码可解释多头注意力模块学习了跨时间步的长期依赖关系。局部依赖关系已经通过 LE Seq2Seq 层在嵌入中捕获,但逐点的长期依赖关系则通过掩码可解释多头注意力捕获。该模块还增强了架构的可解释性。生成的注意力权重为我们提供了一些关于过程涉及的主要时间步的信息。然而,从可解释性的角度来看,多头注意力有一个缺点。

在传统的多头注意力中,我们为值使用了独立的投影权重,这意味着每个头的值是不同的,因此注意力权重并不容易解释。

TFT 通过使用一个共享权重矩阵将值投影到注意力维度上,克服了这一限制。即使有共享的值投影权重,由于每个查询和键的投影权重不同,每个头部仍然可以学习不同的时间模式。除了这一点,TFT 还使用了掩蔽机制,确保在操作中不会使用来自未来的信息。我们在第十四章时间序列的注意力和变换器中讨论了这种因果掩蔽。通过这两项修改,TFT 将这一层命名为掩蔽可解释的多头注意力

到这里,我们可以打开我们所使用的最后一个也是最精细的抽象层级了。

门控残差网络

我们已经讨论 GRN 有一段时间了;到目前为止,我们只是从表面上看待它们。现在让我们来理解一下 GRN 内部发生了什么——这是 TFT 中最基础的构建块之一。

让我们来看一个 GRN 的示意图,以更好地理解它:

图 16.9 – TFT – GRN(左)和 VSN(右)

图 16.10:TFT—GRN(左)和 VSN(右)

GRN 接收两个输入:主输入a和外部上下文c。上下文c是一个可选输入,如果不存在,则视为零。首先,两个输入ac通过单独的密集层和随后的激活函数进行变换——指数线性单元ELU)(pytorch.org/docs/stable/generated/torch.nn.ELU.html)。

现在,变换后的ac输入被加在一起,然后通过另一个Dense层再次进行变换。最后,这通过一个带有来自原始a的残差连接的GLU+加法与归一化层进行处理。这个结构结合了足够的非线性,能够学习输入之间的复杂交互,但同时通过残差连接让模型能够忽略这些非线性。因此,这样的模块可以让模型根据数据规模调整所需的计算量。

变量选择网络

TFT 的最后一个构建块是 VSN。VSN 使得 TFT 能够进行实例级的变量选择。大多数真实世界的时间序列数据集包含许多预测能力较弱的变量,因此能够自动选择那些具有预测能力的变量,将有助于模型挑选出相关的模式。图 16.9(右)展示了这个 VSN。

这些附加变量可以是分类的也可以是连续的。TFT 使用实体嵌入将分类特征转换为我们所需的数值向量维度(d[model])。我们在第十五章《全球深度学习预测模型策略》中讨论过这个问题。连续特征则被线性转换(独立地)为相同维度的向量,d[model]。这为我们提供了变换后的输入,,其中m是特征数量,t是时间步长。我们可以将所有这些嵌入(扁平化)拼接在一起,得到的扁平化表示可以表示为!

现在,这些嵌入被处理的方式有两个平行流:一个用于对嵌入进行非线性处理,另一个用于特征选择。每个嵌入通过独立的 GRN 进行处理(但所有时间步共享),以得到非线性处理后的嵌入,。在另一个流中,VSN 处理扁平化表示,,以及可选的上下文信息c,并通过带有 softmax 激活函数的 GRN 进行处理。这为我们提供了一个权重向量v[t],它的长度为m。现在,v[t]被用于对所有非线性处理后的特征嵌入进行加权求和,,该加权和计算如下:

使用 TFT 进行预测

TFT在 NIXTLA 预报中得到了实现。我们可以使用与 NBEATS 相同的框架,并将其扩展以在我们的数据上训练TFT。此外,NIXTLA 支持外生变量,就像 N-BEATSx 处理外生变量一样。首先,让我们看一下实现的初始化参数。

NIXTLA 中的TFT类有以下主要参数:

  • hidden_size:这是一个整数,表示模型中的隐藏维度。在这个维度中,所有的 GRN、VSN、LSTM 隐藏层、self-attention 隐藏层等都会进行计算。可以说,这是模型中最重要的超参数。

  • n_head:这是一个整数,表示注意力头的数量。

  • dropout:这是一个介于 0 和 1 之间的浮动值,决定变量选择网络中的 dropout 强度。

  • attn_dropout:这是一个介于 0 和 1 之间的浮动值,决定解码器注意力层中 dropout 的强度。

笔记本提醒

完整的 TFT 训练代码可以在Chapter16文件夹中的08-TFT_NeuralForecast.ipynb笔记本中找到。

解释 TFT

TFT 从一个稍微不同的角度来探讨可解释性,与 N-BEATS 不同。N-BEATS 为我们提供了解构输出以实现可解释性,而 TFT 则让我们看清模型是如何解释其使用的变量的。由于 VSN,我们可以轻松访问特征权重。类似于树模型中的特征重要性,TFT 也能提供类似的评分。由于自注意力层,注意力权重也可以被解释,帮助我们理解哪些时间步骤在注意力机制中占据较大权重。

PyTorch Forecasting 通过执行几个步骤使这一切成为可能。首先,我们通过 predict 函数中的 mode="raw" 获取原始预测。然后,我们将这些原始预测用于 interpret_output 函数。interpret_output 函数中有一个名为 reduction 的参数,用于决定如何聚合不同实例的权重。我们知道 TFT 在 VSN 中进行实例级的特征选择,且注意力机制也是按实例进行的。'mean' 是查看全局可解释性的一个不错选择:

raw_predictions, x = best_model.predict(val_dataloader, mode="raw", return_x=True)
interpretation = best_model.interpret_output(raw_predictions, reduction="sum") 

这个 interpretation 变量是一个字典,包含模型中不同方面的权重,例如 attentionstatic_variablesencoder_variablesdecoder_variables。PyTorch Forecasting 还为我们提供了一个简单的方式来可视化这些重要性:

best_model.plot_interpretation(interpretation) 

这会生成四个图表:

图 16.10 – 解释 TFT

图 16.11:解释 TFT

我们还可以查看每个实例,并为每个预测绘制类似的可视化图表。我们只需要使用 reduction="none",然后自行绘制即可。附带的笔记本探讨了如何实现这一点及更多内容。

现在,让我们换个角度,看看一些模型,这些模型证明了简单的 MLP 同样能够匹敌甚至超越基于 Transformer 的模型。

TSMixer

当基于 Transformer 的模型如火如荼地推进时,一条平行的研究轨迹开始使用多层感知机MLPs)代替 Transformer 作为关键学习单元。这一趋势始于 2021 年,当时 MLP-Mixer 展示了仅使用 MLP 就能在视觉问题上达到最先进的性能,取代了卷积神经网络。于是,类似的 MLP 混合架构开始在各个领域出现。2023 年,Google 的 Si-An Chen 等人将 MLP 混合引入了时间序列预测。

参考检查

Si-An 等人关于 TSMixer 的研究论文在参考文献部分被引用为19

TSMixer 模型的架构

TSMixer 确实从 Transformer 模型中汲取了灵感,但它试图通过 MLP 模拟类似的过程。让我们通过图 16.10来理解其相似性与差异。

图 16.12:Transformer 与 TSMixer 对比

如果你观察 Transformer 块,我们可以看到有一个多头注意力机制,它会跨时间步长进行观察,并利用注意力机制“混合”它们。然后,这些输出会传递给位置逐步前馈网络,它们将不同的特征混合在一起。从这些灵感中,TSMixer 也在混合器块中包含了一个时间混合组件和一个特征混合组件。时间投影层将混合器块的输出投影到输出空间。

让我们逐层解释。图 16.11展示了整个架构。

图 16.13:TSMixer 架构

输入是一个多变量时间序列,它被送入 N 个混合器层,逐个处理,最终混合器层的输出被送入时间投影层,时间投影层将学到的表示转换为实际的预测。尽管图表和论文中提到了“特征”,但它们并不是我们在本书中讨论的特征。这里的“特征”指的是多变量设置中的其他时间序列。

现在,让我们深入研究混合器层,看看时间混合和特征混合是如何在一个块内工作的。

混合器层

图 16.14:TSMixer—混合器块

输入的形式为(批量大小 x 特征 x 时间步长),首先通过时间混合块。输入首先被转置为(批量大小 x 时间步长 x 特征)的形式,这样,时间混合 MLP 中的权重就能混合时间步长。现在,这个“时间混合”的输出被传递到特征混合 MLP,后者使用其权重来混合不同的特征,得到最终的学习表示。批量归一化层和残差连接被加入其中,以使模型更加健壮,并学习更深层次和更平滑的连接。

给定输入矩阵 ,时间混合可以用数学公式表示为:

特征混合实际上是一个两层的 MLP,其中一层将数据投影到隐藏维度H[inner],下一层从H[inner]投影到输出维度H。如果没有明确指定,这将默认使用原始特征数量(或时间序列数量)C

因此,整个混合器层可以表示为:

现在,这个输出被传递到时间投影层以得到预测。

时间投影层

图 16.15:TSMixer—时间投影层

时间投影层只是一个应用于时间域的全连接层。这与我们之前看到的简单线性模型相同,在这个模型中,我们将全连接层应用于输入上下文以得到预测。TSMixer 并不是将该层应用于输入,而是将其应用于来自混合器层的“混合”输出。

前一层的输出形式为(批量大小 x 时间步长 x 特征)。它被转置为(批量大小 x 特征 x 时间步长),然后通过一个全连接层,该层将输入投影到(批量大小 x 特征 x 预测视野),从而得到最终的预测结果。

给定作为第k个 Mixer Layer 的输出和预测视野,T

那么,如何加入额外的特征呢?许多时间序列问题有静态特征和动态(面向未来的)特征,这为问题增加了相当多的信息。到目前为止,我们讨论的架构并未让你包含这些信息。为此,作者提出了一个小的调整来包括这些额外信息,即 TSMixerx。

TSMixerx—带辅助信息的 TSMixer

按照之前的符号约定,考虑我们有输入的时间序列(或时间序列集合),。现在,我们将有一些历史特征,,一些面向未来的特征,,以及一些静态特征,。为了有效地包含所有这些额外信息,作者定义了另一个学习单元,称为条件特征混合层(Conditional Feature Mixing layer),并以一种能够同化所有信息的方式使用它。

条件特征混合CFM)层几乎与特征混合层相同,不同之处在于增加了一个处理静态信息和特征的额外层。静态信息首先被重复跨越时间步长,并通过线性层投影到输出维度。然后,它与输入特征连接,连接后的输入再被“混合”在一起,并投影到输出维度。

数学上,它可以表示为:

其中,表示连接操作,而Expand表示将静态信息重复到所有时间步长中。

现在,让我们看看 CFM 层如何在整体 TSMixerx 架构中使用。

图 16.16:TSMixerx—使用外生变量的架构

首先,我们有X,它们有L个时间步长,即上下文窗口的长度。因此,我们将它们连接,并使用一个简单的时间投影层将组合张量投影到,其中T是预测视野的长度,这也是面向未来特征Z的长度。接着,我们使用 CFM 层将其与静态信息结合,并将它们投影到一个隐藏维度,H。形式上,这一步表示为:

现在,我们希望有条件地混合面向未来的特征,Z,以及静态信息,S。因此,我们使用 CFM 层来实现这一点,并将这些组合信息投影到一个隐藏维度,H

在这一点上,我们有!,它们都在!维度下。因此,我们使用另一个 CFM 层进一步混合这些特征,并根据!进行条件处理。这给了我们第一个混合特征的潜在表示,!

现在,这个潜在表示通过!后续的 CFM(类似于常规的 TSMixer 架构),其中!是总的 Mixer 层数,最终得到!,即最终的潜在表示。共有!层,因为第一个 Mixer 层已经定义并且在输入维度上与其他层不同。

现在,我们可以使用一个简单的线性层将这个输出投影到所需的输出维度。如果它是单一时间序列的点预测,我们可以将其投影到!。如果我们预测的是 M 个时间序列,那么我们可以将其投影到!

使用 TSMixer 和 TSMixerx 进行预测

TSMixer 在 NIXTLA 预测中实现,使用的是我们在之前模型中看到的相同框架。

让我们看一下实现的初始化参数。

TSMixer类具有以下主要参数:

  • n_series:这是一个整数值,表示时间序列的数量。

  • n_block:这是一个整数值,表示模型中使用的混合层数量。

  • ff_dim:这是一个整数值,表示第二个前馈层中要使用的单元数量。

  • revin:这是一个布尔值,如果为 True,则使用可逆实例归一化来处理输入和输出(ICLR 2022 论文:openreview.net/forum?id=cGDAkQo1C0p)。

类似于 NBEATX,TSMixerx类可以处理外生信息。要使用外生信息进行预测,您需要将适当的信息添加到下面的参数中:

  • futr_exog_list:这是一个未来外生列的列表。

  • hist_exog_list:这是一个历史外生列的列表。

  • stag_exog_list:这是一个外生列的列表。

笔记本提醒

用于训练 TSMixer 模型的完整代码可以在Chapter16文件夹中的09-TSMixer_NeuralForecast.ipynb笔记本中找到。

现在让我们看一下另一种基于 MLP 的架构,它已经证明比我们之前看到的 PatchTST 和线性系列模型表现得更好。

时间序列稠密编码器(TiDE)

我们在本章早些时候看到,线性模型家族的表现超越了许多 Transformer 模型。2023 年,Google 的 Das 等人提出了一种将这一思想扩展到非线性的模型。他们认为,当未来与过去之间存在内在的非线性依赖关系时,线性模型会失效。而协变量的加入加剧了这个问题。

参考检查

Das 等人关于 TiDE 的研究论文在参考文献部分被引用为20

因此,他们提出了一种简单高效的基于多层感知机MLP)的架构,用于长期时间序列预测。该模型本质上通过密集的 MLP 编码时间序列的过去及其协变量,然后将这个潜在表示解码为一个预测。该模型假设通道独立性(类似于 PatchTST),并将多元问题中不同相关的时间序列视为单独的时间序列。

TiDE 模型的架构

该架构有两个主要组件——编码器和解码器。但在整个架构中,他们称之为残差块的一个学习组件被重复使用。让我们先看看残差块。

残差块

残差块是一个带有 ReLU 和后续线性投影的 MLP,允许残差连接。图 16.14显示了一个残差块。

图 16.17:TiDE:残差块

我们通过设置隐藏维度和输出维度来定义该层。第一层 Dense 将输入转换为隐藏维度,然后对输出应用 ReLU 非线性。然后,该输出被线性投影到输出维度,并在其上堆叠一个丢弃层。残差连接随后通过将输入投影到输出维度并使用另一个线性投影来添加到输出中。最后,输出通过层归一化进行处理。

为该块的输入,h为隐藏维度,o为输出维度。那么,残差块可以表示为:

让我们建立一些符号,以帮助我们理解接下来的解释。数据集中有N个时间序列,L是回溯窗口的长度,H是预测的长度。因此,第 i^(th)个时间序列的回溯可以表示为,而它的预测是。在时间的 r 维动态协变量表示为。第 i^(th)个时间序列的静态特征是

现在,让我们看一下图 16.15中的更大架构。

图 16.18:TiDE:整体架构

编码器

编码器的任务是将回溯窗口及其相应的协变量映射到一个稠密的潜在表示。第一步是对动态协变量进行线性投影,映射到,其中,称为时间宽度,远小于r。我们使用残差块来完成这个投影。

从程序的角度来看(其中B是批量大小),如果动态协变量的输入维度是,我们将其投影到

这样做是为了当我们将时间序列及其协变量展平后输入编码器时,结果张量的维度不会爆炸。这就引出了下一步,即张量的展平和拼接操作。展平和拼接操作类似于这样:

  1. 回溯窗口:

  2. 动态协变量:

  3. 静态信息:

  4. 拼接表示:

现在,这个拼接后的表示会通过一组n[e]个残差块进行编码,转换成稠密的潜在表示。

从程序的角度来看,维度从转换到,其中H是潜在表示的隐藏层大小。

现在我们已经得到了潜在表示,让我们来看一下如何从中解码预测结果。

解码器

和编码器一样,解码器也有两个独立的步骤。在第一步中,我们使用一组n[d]个残差块将潜在表示解码成维度为的解码向量,其中解码器输出维度。这个解码向量被重新形状为维的向量。

现在,我们使用时间解码器将这个解码向量转换成预测。时间解码器仅是一个残差块,它接受拼接后的解码向量和编码后的未来外生向量。作者认为,这个残差连接允许某些未来的协变量在预测中产生更强的影响。例如,如果在零售预测问题中,未来的协变量之一是节假日,那么你希望这个变量对预测有很强的影响。

这个残差连接帮助模型在需要时启用“高速公路”功能。

最后,我们添加一个全局残差连接,将回溯窗口线性映射到预测结果,经过线性映射到正确的维度。这确保了我们在本章前面看到的线性模型变成了 TiDE 模型的一个子类。

使用 TiDE 进行预测

TiDE 在 NIXTLA 预测中实现,采用了我们在之前的模型中看到的相同框架。

让我们来看一下实现的初始化参数。

TIDE 类有以下主要参数:

  • decoder_output_dim:一个整数,控制解码器输出中的单元数量 。它直接影响解码序列的维度,并且可能会影响模型重建目标序列的能力。

  • temporal_decoder_dim:一个整数,定义了时间解码器的输出大小。尽管我们讨论过时间解码器的输出是最终预测结果,NeuralfForecast 通过网络输出到期望输出维度的统一映射。因此,temporal_decoder_dim 表示倒数第二层的维度,最终将被转换为最终输出。该维度的大小决定了你允许传递到最终预测层的信息量。

  • num_encoder_layers:堆叠在一起的编码器层的数量。

  • num_decoder_layers:堆叠在一起的解码器层的数量

  • temporal_width:一个整数,影响较低时间投影维度 ,影响外生数据如何被投影和处理。它在模型如何整合和学习外生信息方面起着重要作用。

  • layernorm:该布尔标志决定是否应用层归一化。层归一化可以稳定并加速训练,可能会提高性能,特别是在更深的网络中。

此外,TIDE 可以处理外生信息,这些信息可以包含在以下参数中:

  • futr_exog_list:接受一个未来外生列的列表

  • hist_exog_list:接受一个历史外生列的列表

  • stag_exog_list:这是一个外生列的列表

    笔记本提醒

    用于训练 TIDE 模型的完整代码可以在 Chapter16 文件夹中的 10-TIDE_NeuralForecast.ipynb 笔记本中找到。

我们已经介绍了几种常见的时间序列预测专用架构,但这绝不是完整的列表。外面有很多模型架构和技术。我在进一步阅读部分列出了一些,供你参考。

恭喜你成功完成了本书中可能最具挑战性和最密集的章节之一。给自己拍拍背,坐下来放松一下吧。

总结

我们关于时间序列的深度学习之旅终于迎来了结尾,我们回顾了一些时间序列预测的专门架构。我们理解了为何在时间序列和预测中采用专门架构是合理的,并且进一步了解了如何使不同的模型,如 N-BEATSN-BEATSxN-HiTSAutoformerTFTPatchTSTTiDETSMixer 等工作。除了介绍这些架构背后的理论,我们还探讨了如何使用 neuralforecast(由 NIXTLA 开发)在实际数据集上应用这些模型。我们无法预知哪个模型在我们的数据集上效果最好,但作为从业者,我们需要培养出能够指导实验的直觉。了解这些模型的工作原理对于培养这种直觉至关重要,也将帮助我们更高效地进行实验。

本书的这一部分到此结束。到目前为止,你应该已经更熟悉如何使用深度学习来解决时间序列预测问题。

在下一章中,我们将讨论另一个在预测中非常重要的主题——概率预测。

参考文献

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

  1. Spyros Makridakis, Evangelos Spiliotis, 和 Vassilios Assimakopoulos. (2020). M4 竞赛:100,000 个时间序列和 61 种预测方法. 《国际预测期刊》,第 36 卷,第 1 期。页码 54–74。doi.org/10.1016/j.ijforecast.2019.04.014

  2. Slawek Smyl. (2018). M4 预测竞赛:引入一种新的混合 ES-RNN 模型www.uber.com/blog/m4-forecasting-competition/

  3. Boris N. Oreshkin, Dmitri Carpov, Nicolas Chapados, 和 Yoshua Bengio. (2020). N-BEATS: 可解释时间序列预测的神经基础扩展分析. 第 8 届国际学习表示大会(ICLR)。openreview.net/forum?id=r1ecqn4YwB

  4. Kin G. Olivares, Cristian Challu, Grzegorz Marcjasz, R. Weron, 和 A. Dubrawski. (2022). 带外生变量的神经基础扩展分析:使用 NBEATSx 预测电力价格. 《国际预测期刊》,2022 年。www.sciencedirect.com/science/article/pii/S0169207022000413

  5. Cristian Challu, Kin G. Olivares, Boris N. Oreshkin, Federico Garza, Max Mergenthaler-Canseco, 和 Artur Dubrawski. (2022). N-HiTS: 用于时间序列预测的神经层次插值. arXiv 预印本 Arxiv: Arxiv-2201.12886。arxiv.org/abs/2201.12886

  6. Vaswani, Ashish, Shazeer, Noam, Parmar, Niki, Uszkoreit, Jakob, Jones, Llion, Gomez, Aidan N, Kaiser, Lukasz, 和 Polosukhin, Illia. (2017). 注意力即你所需要的。神经信息处理系统进展. papers.nips.cc/paper/2017/hash/3f5ee243547dee91fbd053c1c4a845aa-Abstract.html.

  7. Haoyi Zhou, Shanghang Zhang, Jieqi Peng, Shuai Zhang, Jianxin Li, Hui Xiong, 和 Wancai Zhang. (2021). Informer: 超越高效的 Transformer 用于长序列时间序列预测。第三十五届{AAAI}人工智能会议,{AAAI} 2021. ojs.aaai.org/index.php/AAAI/article/view/17325.

  8. Haixu Wu, Jiehui Xu, Jianmin Wang, 和 Mingsheng Long. (2021). Autoformer: 带有自动相关性的分解 Transformer 用于长期序列预测。神经信息处理系统进展 34: 2021 年神经信息处理系统年会,NeurIPS 2021, 2021 年 12 月 6 日至 14 日. proceedings.neurips.cc/paper/2021/hash/bcc0d400288793e8bdcd7c19a8ac0c2b-Abstract.html.

  9. Bryan Lim, Sercan Ö. Arik, Nicolas Loeff, 和 Tomas Pfister. (2019). 时间融合 Transformer 用于可解释的多视角时间序列预测。国际预测期刊,第 37 卷,第 4 期,2021 年,第 1,748–1,764 页. www.sciencedirect.com/science/article/pii/S0169207021000637.

  10. Alexey Dosovitskiy, Lucas Beyer, Alexander Kolesnikov, Dirk Weissenborn, Xiaohua Zhai, Thomas Unterthiner, Mostafa Dehghani, Matthias Minderer, Georg Heigold, Sylvain Gelly, Jakob Uszkoreit, 和 Neil Houlsby. (2021). 一张图片值 16x16 个词:用于大规模图像识别的 Transformer。第九届国际学习表征大会,ICLR 2021. openreview.net/forum?id=YicbFdNTTy.

  11. Yuqi Nie, Nam H. Nguyen, 和 Phanwadee Sinthong 和 J. Kalagnanam. (2022). 一条时间序列值 64 个词:使用 Transformer 的长期预测。第十届国际学习表征大会,ICLR 2022. openreview.net/forum?id=Jbdc0vTOcol.

  12. Ailing Zeng 和 Mu-Hwa Chen, L. Zhang, 和 Qiang Xu. (2023). Transformer 是否对时间序列预测有效? AAAI 人工智能会议. ojs.aaai.org/index.php/AAAI/article/view/26317.

  13. Liu, Shizhan 和 Yu, Hang 和 Liao, Cong 和 Li, Jianguo 和 Lin, Weiyao 和 Liu, Alex X 和 Dustdar, Schahram. (2022). Pyraformer: 低复杂度的金字塔注意力机制用于长时间序列建模与预测. 国际学习表征会议 (ICLR)。openreview.net/pdf?id=0EXmFzUn5I

  14. Zhou, Tian 和 Ma, Ziqing 和 Wen, Qingsong 和 Wang, Xue 和 Sun, Liang 和 Jin, Rong. (2022). {FEDformer}: 增强频率的分解变换器用于长期时间序列预测。第 39 届国际机器学习会议 (ICML 2022)。proceedings.mlr.press/v162/zhou22g.html

  15. Shiyang Li, Xiaoyong Jin, Yao Xuan, Xiyou Zhou, Wenhu Chen, Yu-Xiang Wang 和 Xifeng Yan. (2019). 增强局部性并打破变换器在时间序列预测中的记忆瓶颈. 神经信息处理系统进展(Advances in Neural Information Processing Systems)。proceedings.neurips.cc/paper_files/paper/2019/file/6775a0635c302542da2c32aa19d86be0-Paper.pdf

  16. Yong Liu, Tengge Hu, Haoran Zhang, Haixu Wu, Shiyu Wang, Lintao Ma 和 Mingsheng Long. (2024). iTransformer:倒转变换器在时间序列预测中的有效性。第 12 届国际学习表征会议 (ICLR 2024)。openreview.net/forum?id=JePfAI8fah

  17. Si-An Chen, Chun-Liang Li, Sercan O Arik, Nathanael Christian Yoder 和 Tomas Pfister. (2023). TSMixer: 一种全 MLP 架构的时间序列预测模型。机器学习研究期刊(Transactions on Machine Learning Research)。openreview.net/forum?id=wbpxTuXgm0

  18. Abhimanyu Das, Weihao Kong, Andrew Leach, Shaan Mathur, Rajat Sen 和 Rose Yu. (2023). 使用 TiDE 进行长期预测:时间序列稠密编码器. 机器学习研究期刊(Transactions on Machine Learning Research)。openreview.net/forum?id=pCbC3aQB5W

进一步阅读

你可以查阅以下资源进行进一步阅读:

加入我们社区的 Discord

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

packt.link/mts

第十七章:概率预测及更多

在整本书中,我们学习了生成预测的不同技术,包括一些经典方法,使用机器学习以及一些深度学习架构。但我们一直在关注一种典型的预测问题——为连续时间序列生成点预测,并且没有层级关系且历史数据足够丰富。我们之所以这样做,是因为这是你会遇到的最常见的问题。但在本章中,我们将花一些时间讨论几个利基话题,尽管这些话题的受关注程度较低,但同样重要。

在本章中,我们将重点讨论以下主题:

  • 概率预测

    • 概率密度函数

    • 分位数函数

    • 蒙特卡洛 Dropout

    • 一致性预测

  • 间歇性/稀疏时间序列预测

  • 可解释性

  • 冷启动预测

    • 预训练模型,如 TimeGPT

    • 基于相似度的预测

  • 层级预测

概率预测

到目前为止,我们一直在讨论将预测作为一个单一的数字。我们一直在将我们的深度学习模型投射到一个单一维度,或者训练我们的机器学习模型输出一个单一的数字。随后,我们使用如均方误差之类的损失函数来训练模型。这种范式就是我们所说的点预测。但是我们没有考虑到一个重要方面。我们正在使用历史数据来训练模型,以做出最佳预测。但是模型对于其预测有多确定呢?那些熟悉机器学习和分类问题的人会意识到,对于分类问题,除了得到样本属于哪个类别的预测,我们还会得到模型的不确定性。可是我们的预测是回归问题,我们并没有免费的不确定性。

那么,为什么在预测中量化不确定性是重要的呢?任何预测都是为了某个目的创建的,即某个下游任务,需要使用预测信息。换句话说,我们生成的预测是为了做出某些决策。而在做决策时,我们通常希望能够获得尽可能多的信息。

让我们看一个例子,来真正强调这一点。你已经记录了过去 5 年每月的食品消费量,并使用书中的技术,创建了一个超精准的预测和一个应用程序,告诉你每个月需要购买多少食品。你打开应用程序,它告诉你这个月需要购买 2 个面包。你前往超市,拿了两个面包,然后回家。结果在月底前一周,面包就用完了,剩下的时间你饿着肚子。就在饿得快不行的时候,你开始质疑自己的决定和预测。你分析了数据,发现自己哪里出错了,意识到每个月的面包消费量变化很大。有些月份你吃了 4 个面包,而有些月份则只有 1 个。所以,很有可能这个预测会导致你某些月份没有面包,而其他月份则面包过剩。

然后,你阅读了这一章,并将你的预测转化为概率预测,现在它告诉你,50%的情况下你下个月的面包消费量是 2 个。可是,现在应用程序中有一个附加功能,它询问你是更愿意挨饿,还是希望每天结束时有剩余的面包。所以,依据你对挨饿或节省金钱的接受程度,你做出了选择。假设你不想挨饿,但如果面包偶尔缺货,比如 10%的情况,你也能接受。一旦你将这个偏好输入应用程序,它就会修正预测,并告诉你,你应该买 3 个面包,这样你就再也不会挨饿了(当然,也因为你更聪明了,从超市里买了其他东西)。

能够利用预测中的不确定性,并根据我们对风险的接受度进行修正,是概率预测的主要实用功能之一。它还帮助我们的预测变得更加透明,并使用户更加信任。

现在,让我们快速看一下使用学习模型进行预测问题时可能出现的不确定性类型。

预测不确定性类型

我们在第五章中看到,监督式机器学习无非就是学习一个函数,,其中h以及是我们学习的模型,而它们的输入数据则是这些内容。所以,如果我们考虑不确定性的来源,它可以来自这两个组成部分中的任意一个。

我们所学习的模型,h,是使用数据集X进行近似的,这个数据集可能并没有完全覆盖所有情况,因此可能会从中引入一些不确定性。我们称之为认识性不确定性。在机器学习的背景下,当模型没有接触到足够的数据、模型本身不足以学习问题的复杂性,或者它所训练的数据不能代表所有可能的情境时,就会出现认识性不确定性。这也被称为系统性或可减少不确定性,因为这是总预测不确定性中可以通过更好的模型、更好的数据等方式主动减少的部分;换句话说,就是通过获得更多关于系统的知识来减少它。让我们通过几个例子来更清楚地理解这一概念:

  • 如果一个天气预测模型来自某个特定区域的数据较少(可能是因为传感器故障),那么对该区域的了解将会减少,从而增加不确定性。

  • 如果将线性模型应用于非线性问题,那么由于更简单的模型对系统了解较少,我们就会引入一些不确定性。

  • 如果一个经济预测模型没有使用一些关键的影响因素进行训练,例如经济政策的变化、气候变化对决策的影响等等,这也会产生一些不确定性。

这种不确定性的一大优点是,我们可以通过收集更好的数据、训练更好的模型等方式主动减少它。

现在在总预测不确定性中还有另一种不确定性——Aleatoric 不确定性。这指的是数据中固有的随机性,是无法通过解释来消除的。这也被称为统计性或不可约不确定性。尽管我们的宇宙对我们来说似乎是决定论的,但在表面之下始终存在一层普遍的不确定性。

例如,天体的运动可以准确计算(感谢广义相对论和爱因斯坦),但仍然有可能随机的小行星撞击任何天体并改变计算出的轨迹。这种不可约且无法避免的不确定性被称为 Aleatoric 不确定性。让我们来看几个例子:

  • 无论天气预测的测量和模型多么精确,仍然存在可变性。天气中有一种固有的随机性,我们可能永远无法完全解释清楚。

  • 一个运动员的表现,无论他们训练得多么努力并遵循规则,仍然不是完全决定性的。很多因素,如天气、健康以及比赛期间或之前的其他随机事件,都可能影响运动员的表现。

现在我们已经理解了不同类型的不确定性,以及为什么我们需要不确定性量化,让我们看看在预测的背景下它意味着什么。

什么是概率预测和预测区间?

概率预测是指预测不仅给出单点预测结果,还能够捕捉预测的不确定性。概率预测是一种通过提供一系列可能值及其相关概率或置信水平来预测未来事件或结果的方法。这种方法捕捉了预测过程中的不确定性。

在计量经济学和经典时间序列领域,预测区间已经内嵌在公式中。这些方法的统计基础和强假设确保了模型输出能够以概率的方式进行解释(只要满足这些模型规定的假设)。但在现代机器学习/深度学习领域,概率预测不是事后考虑的。多种因素的组合,如较少的严格假设以及我们训练模型的方式,导致了这种局面。

我们可以通过不同的方法将概率维度加入到预测中,本章将讨论其中的一些方法。但在此之前,让我们首先理解概率预测的一种最有用的表现形式——预测区间

预测区间是一个范围,未来的观测值预计会以特定概率落在该范围内。例如,如果我们有一个 95%的预测区间为 [5,8],我们可以说,95%的情况下,实际值会落在 5 到 8 之间。我们以一个正态分布为例,假设其均值为 ,方差为 ,这是我们在时间步长 t 时的预测(我们将在接下来的内容中讨论其中一种方法,可以给我们这样的结果)。因此,时间 t 时的预测区间,假设显著性水平(即预测值可能落在区间外的概率)为 ,可以表示为:

其中 z 是正态分布的 z 分数。

预测区间 (PI) 与 置信区间 (CI)

最令人困惑的主题之一是预测区间和置信区间。让我们在这里解开它们的迷雾。它们都是量化不确定性的方法,但在预测的背景下,它们服务于不同的目的,并且解释方式不同。置信区间提供一个范围,但针对样本数据的总体参数(如均值),而预测区间则专注于为未来的观测值提供一个范围。两者的一个关键区别是,置信区间通常比预测区间更窄,因为预测区间还考虑了新点的不确定性。另一方面,置信区间只考虑模型参数的不确定性。因此,当我们需要给出未来某个观测值可能落入的范围(比如下个月的销售额)时,我们使用预测区间。当我们需要为一个估计参数(比如年均需求量)提供一个范围时,我们使用置信区间。

在详细讨论之前,我们需要澄清一些术语和概念。

置信水平、误差率和分位数

在处理预测区间时,了解置信水平、误差率和分位数之间的关系至关重要。这些概念有助于确定未来观测值在一定概率范围内的预期区间。

误差率 () 是预测区间不包含未来观测值的允许概率。它通常以百分比或 0 到 1 之间的小数表示。如果我们说 ,这意味着未来的观测值有 10%的机会不在预测区间内。

置信水平 () 是误差率的补数,它是预测区间包含未来观测值的概率。。如果我们说误差率是 10%,那么置信水平将是 90%。

分位数 是将数据分成具有相等概率的区间的点。简单来说,分位数表示一个值,低于该值的数据占某个百分比。例如,第 5 百分位数或 0.05 分位数标志着 5%的数据位于该值以下。因此,当我们没有基于分布假设的解析方法来获得预测区间时,也可以使用分位数来定义预测区间。

图 17.1中,我们展示了标准正态分布的预测区间。

图 17.1:标准正态分布的预测区间

错误率、置信水平和分位数之间有着密切的关系。错误率和置信水平是彼此的直接补充,可以互换使用,以定义我们希望预测区间具有的置信度或我们可以接受的预测区间的误差率。另一种看法是通过曲线下的面积。在图 17.1中,绿色阴影区域的面积表示置信水平,而红色区域的面积表示误差率。

对于标准正态分布,我们可以直接通过使用解析公式获得预测区间:

其中!是分布的均值,!是分布的标准差,!是对应所需置信水平的标准正态分布临界值,!。取!是因为我们允许误差率在两侧分布(图 17.1中的曲线两侧的红色阴影区域)。

现在让我们看看错误率和置信水平是如何与分位数相联系的,因为如果我们不知道分布是什么(并且我们不想假设任何分布),我们就无法通过解析公式获得预测区间。在这种情况下,我们可以使用分位数来获得相同的结果。就像我们使用解析公式时一样,错误率!应该在两侧均匀分配。因此,预测区间将是:

其中!t^(th)分位数。所以,从分位数的定义,我们知道!将有! %的数据低于它,!将有! %的数据高于它,从而使区间外的面积为!

利用这一关系,我们可以从错误率转化为分位数,或者从置信水平转化为分位数。如果错误率是!,我们已经看到对应的分位数表示预测区间。让我们再看一个简单的公式,将置信水平(以百分比表示)转化为分位数:

在 Python 代码中,这仅仅是:

level = 95  # Confidence levels
qs = [50 - level / 2, 50 + level / 2] # Quantiles 

现在,让我们来看一下如何衡量预测区间的优良性。

衡量预测区间的优良性

我们知道什么是概率预测,以及什么是预测区间。但在我们查看生成预测区间的技术之前,我们需要一种方法来衡量这种区间的优良性。标准指标如平均绝对误差或均方误差不再适用,因为它们是点预测衡量指标。

我们从预测区间中想要得到什么?如果我们有一个 90%置信度的预测区间,我们希望数据点至少有 90%的时间落在该区间内。这可以通过设置非常宽的预测区间来轻松获得,但那样预测区间就变得没有用。因此,我们希望预测区间尽可能窄,同时仍能满足 90%的置信度要求。为了衡量这两个不同的方面,我们可以使用两个指标——覆盖率预测区间的平均长度

覆盖率是指真实值落入预测区间的比例。从数学上讲,如果我们用表示每个观察值i的预测区间,用表示真实值,则覆盖率可以定义为:

其中,是一个指示函数,如果内部条件为真,则值为 1,否则为 0,N是观察值的总数。接近所需置信水平(例如,95%的预测区间)的覆盖率指标表明模型的不确定性估计得到了很好的校准。

预测区间的平均长度是通过对所有观察值的预测区间长度取平均值来计算的。使用与上面相同的符号,它可以用数学方式写成:

这个指标有助于理解区间的覆盖率与其精度之间的权衡。我们还将看一下这两个指标的 Python 函数:

覆盖率

import numpy as np
def coverage(y_true, lower_bounds, upper_bounds):
    """
    Calculate the coverage of prediction intervals.

    Parameters:
    y_true (array-like): True values.
    lower_bounds (array-like): Lower bounds of prediction intervals.
    upper_bounds (array-like): Upper bounds of prediction intervals.

    Returns:
    float: Coverage metric.
    """
    y_true = np.array(y_true)
    lower_bounds = np.array(lower_bounds)
    upper_bounds = np.array(upper_bounds)
    # Check if true values fall within the prediction intervals
    coverage = np.mean((y_true >= lower_bounds) & (y_true <= upper_bounds))
    return coverage 

平均长度

def average_length(lower_bounds, upper_bounds):
    """
    Calculate the average length of prediction intervals.

    Parameters:
    lower_bounds (array-like): Lower bounds of prediction intervals.
    upper_bounds (array-like): Upper bounds of prediction intervals.

    Returns:
    float: Average length of prediction intervals.
    """
    lower_bounds = np.array(lower_bounds)
    upper_bounds = np.array(upper_bounds)

    # Calculate the length of each prediction interval
    lengths = upper_bounds - lower_bounds
    # Calculate the average length
    average_length = np.mean(lengths)
    return average_length 

这两个 Python 函数可以在src/utils/ts_utils.py中找到,我们将在本章中使用它们来衡量生成的预测区间的质量。

现在,让我们看一下可以用来获得概率预测的不同技术,以及如何实际使用它们。

概率密度函数(PDF)

这是概率预测中最常用的技术之一,尤其是在深度学习领域,因为它的实现简单。在时间t的预测可以看作是概率分布的实现。与其估计,我们可以估计。如果我们假设是一个参数化的分布之一,,其参数为,那么我们可以直接估计参数,而不是估计

例如,如果我们假设预测来自正态分布,则我们可以建模为

所以,代替让我们的模型输出!,我们可以让它输出!和!。通过!和!,我们可以轻松计算给定!的预测区间:

其中,Z是来自标准正态分布的临界值,对应于所需的置信水平。对于 90%的置信水平,!。够简单吧?等等,别急!

现在我们正在对一个分布的参数进行建模,那么我们如何训练模型呢?我们仍然有实际的点预测作为目标。在正态分布的情况下,目标仍然是实际的!,而不是均值和标准差。我们通过使用像对数似然度这样的损失函数,而不是像平方误差这样的损失函数来解决这个问题。

例如,假设我们有一组i.i.d观测值(在我们这个例子中是目标值),!。通过假定分布的预测参数(!),我们将能够计算出每个目标值的概率,!i.i.d假设意味着每个样本相互独立。高中数学告诉我们,当两个独立事件发生时,我们可以通过将两个独立的概率相乘来计算它们的联合概率。使用相同的逻辑,我们可以通过将所有独立概率相乘来计算所有n i.i.d观测值的联合概率或似然度(即所有这些事件发生的概率)。

最大化似然度有助于模型学习每个样本的正确参数,使得在假定分布下的概率最大化。我们可以直观地这样理解:对于一个假定的分布,比如正态分布,最大化似然度可以确保目标值位于由预测参数定义的分布中心。

然而,这个操作并不是数值稳定的。由于概率是!,将它们相乘会使结果逐渐变小,并且很快可能导致数值下溢问题。因此,我们使用对数似然度,它只是对似然度进行对数变换。我们这样做是因为:

  • 作为严格单调变换,优化一个函数等同于优化该函数的对数变换。因此,优化似然度和对数似然度是一样的。

  • 对数变换将乘法转换为加法,这是一种更加数值稳定的操作。

这种方法的主要缺点是它依赖于参数化的概率分布,其对数似然计算是可行的。因此,我们被迫对输出做出假设,并预先选择一个可能适合的分布。

这是一把双刃剑。一方面,我们可以将一些领域知识注入到问题中,并且对模型训练进行正则化,但另一方面,如果我们不清楚选择正态分布是否是正确的选择,就可能导致模型过于受限。

许多流行的分布,如正态分布、泊松分布、负二项分布、指数分布、对数正态分布、Tweedie 分布等,都可以用来通过这种技术生成概率预测。

现在,我们已经具备了训练和学习模型的所有组件,并能够使其预测完整的概率分布,而不仅仅是点预测。既然理论已经讲解完毕,让我们换个角度,看看如何使用这项技术。

使用 PDF 进行预测——机器学习模型

我们已经在第二部分《时间序列的机器学习》中看到如何使用标准的机器学习模型进行预测。但那时我们讨论的都是点预测。那么,能否通过 PDF 方法轻松地将所有内容转换为概率预测呢?从理论上讲,是的。但实际上,事情并没有那么简单。像sci-kit learnxgboostlightgbm等流行的机器学习模型实现采用的是点预测范式。作为这些开源库的用户,我们很难轻易地调整和重写代码,以使其优化对数似然作为回归损失。但不要担心,这并不是不可能的。NGBoost是流行的梯度提升模型,如xgboostlightgbm的远亲,它的实现方式是预测 PDF,而不是点预测。

还有其他技术,如分位数预测(Quantile Forecast)或保形预测(Conformal Prediction),这些方法在我们书中讨论的机器学习模型中具有更广泛的应用性(并且是推荐的),尤其是在最终目标是获取预测区间时。为了完整性考虑,书中提到的 NGBoost 适用于需要输出完整概率分布的情况。

我们不会在这里深入探讨 NGBoost 是什么以及它与常规梯度提升模型的区别,但只需知道,它是一种预测概率分布而不是点预测的模型。

参考检查

由 Duan 等人提出的研究论文,介绍了NGBoost,在参考文献中以参考文献1列出。

进一步的 阅读提供了关于 NGBoost 的博客链接,里面更深入地讨论了该模型的细节。

笔记本提醒

要跟着完整的代码走,请使用Chapter17文件夹中的01-NGBoost_prediction_intervals.ipynb笔记本。

让我们使用来自 M4 竞赛的 8 条时间序列样本(该竞赛有 100,000 条时间序列,参考文献5)来进行概率预测。数据可以轻松在线获得,下载脚本也包含在笔记本中。我们使用这个比我们之前使用的数据集更简单的时间序列,因为我想避免因外生变量等问题而使叙述变得复杂。以下是 8 条抽样时间序列的最后 100 个时间步的图示。

图 17.2:来自 M4 竞赛的 8 条抽样时间序列的最后 100 个时间步。测试期用虚线紫色线表示。

让我们使用 mlforecast 快速创建一些特征,将问题转换为回归问题。在第六章中,我们有一个额外的笔记本,展示了如何使用mlforecast作为特征工程的替代方法,这些特征工程也包括在书的代码库中。详细的代码可以参考完整的笔记本,但现在假设我们有一个名为data的数据框,里面包含了运行机器学习模型所需的所有特征。我们已经将其拆分为trainval,然后进一步拆分为X_trainy_trainX_valy_val。接下来,让我们看看如何训练一个模型,假设输出服从正态分布。

from ngboost import NGBRegressor
from ngboost.distns import Normal
# Training the model
ngb = NGBRegressor(Dist=Normal).fit(X_train, Y_train) 

NGBoost 没有很多需要调整的参数,因此不像其他梯度提升决策树GBDT)那样灵活。而且它的速度也不如其他 GBDT 模型。这个模型只有在需要概率输出的特殊应用场景下才会使用。

以下是 NGBoost 的一些参数:

  • Dist:这是输出的假定分布形式。该包目前支持NormalLogNormalExponential,这些都可以从ngboost.distns导入。

  • Score:这是任何有效的评分函数,用来将预测的分布与实际观测值进行比较。我们之前讨论的对数似然评分在包中称为LogScore,并且是默认值。所有评分函数都可以从ngboost.scores导入。

  • n_estimators:这是在提升树中使用的估计器数量。

  • learning_rate:这是用于结合提升树的学习率。

  • mini_batch_frac:每次迭代时子采样的行数百分比。默认值为1.0

现在我们已经训练好了 NGBoost 模型,接下来让我们看看如何使用它来生成预测值和预测区间。

为了获得点预测,语法与 scikit-learn API 完全相同。

y_pred = ngb.predict(X_val) 

这仅仅是一个包装方法,用来计算假定分布的定位参数。例如,对于正态分布,预测分布的均值即为点预测值。

现在,为了获得底层的概率预测以及随后的预测区间,我们需要使用不同的方法:

y_pred_dists = ngb.pred_dist(X_val) 

y_pred_dists中的每个点都是一个完整的分布。如果我们只想预测前五个点,可以这样做:

y_pred_dists[0:5].params 

现在,为了获取预测区间,我们可以使用y_pred_dist并调用一个方法,给出我们期望的置信度水平。这反过来又调用scipy分布(如scipy.stats.norm),其具有一个interval方法,可以在给定置信度水平的情况下获取区间。

y_pred_lower, y_pred_upper = y_pred_dists.dist.interval(0.95) 

现在,我们围绕y_pred有一个足够宽的窗口,能够包围每个数据点的预期不确定性——即预测区间。让我们看看预测结果和度量指标。

图 17.3:NGBoost 的预测区间

以下是我们为这八个时间序列计算的度量指标(平均绝对误差、覆盖率和平均长度):

图 17.4:NGBoost 的度量指标

尽管对于某些时间序列,区间看起来很好,但一些其他序列(如时间序列 H103)的预测区间似乎过于狭窄,这在低覆盖率中也很明显。

使用 PDF 进行预测——深度学习模型

与机器学习模型不同,转换我们在书中学到的所有深度学习模型为它们的 PDF 版本非常简单。记得我们开始实际应用前的讨论吗?我们需要做的主要改动就是这些:

  • 与预测单一的点预测(单个数字)不同,我们预测的是概率分布的参数(一个或多个数字)。

  • 与其使用像均方误差这样的点损失,不如使用像对数似然这样的概率评分函数。

在深度学习范式下,这些改动非常简单,不是吗?在我们书中学到的几乎所有深度学习模型中,最后都有一个线性投影,将输出投影到所需的维度上。从概念上讲,通过改变线性投影,让输出变为多个数字(假设分布的参数)是足够简单的。同样,改变损失函数也很简单。值得注意的是,DeepAR(参考文献3)是一个使用这种技术进行概率预测的知名深度学习模型。

笔记本提醒

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

让我们看看如何在neuralforecast(我们在第十六章中使用的库)中实现这个功能。在这里的示例中,我们将使用一个简单的模型,如 LSTM,但我们可以对任何模型执行相同操作,因为我们所做的只是将损失函数切换为DistributionLoss

对于这个例子,我们将使用 M4 竞赛数据集,该数据集可以自由获取(下载数据集的代码已包含在笔记本中)。

从数据已经按照neuralforecast期望的格式在Y_train_dfY_test_df中整理好的地方开始。我们需要做的第一件事是导入必要的类。

from neuralforecast import NeuralForecast
from neuralforecast.models import LSTM
from neuralforecast.losses.pytorch import DistributionLoss 

本书中唯一我们之前没有涉及的类是DistributionLoss。这是一个封装了torch.distribution类并实现我们之前讨论的负对数似然损失的类。在写本书时,DistributionLoss类支持以下这些底层分布:

  • Poisson

  • Normal

  • StudentT

  • NegativeBinomial

  • Tweedie

  • Bernoulli(时间分类器)

  • ISQF(增量样条分位数函数)

在这些不同的分布之间的选择完全取决于模型构建者,并且是模型中的关键假设。如果我们建模的输出预期符合正态分布,那么我们可以选择Normal。以下是DistributionLoss的主要参数:

  • distribution:这是一个字符串,用于标识我们假设的分布类型。它可以是我们之前看到的任何一个分布。

  • level:这是一个浮动值列表,定义我们希望建模的不同置信水平。例如,如果我们想建模 80%和 90%的置信度,我们应该给出值为[80, 90]

  • quantiles:这是定义水平的另一种方式。你可以不使用 95%的置信度,而是使用分位数 → [0.1, 0.9]

    实践者提示

    做出分布假设需要对领域和数据进行深入研究。但如果你对这些分布不完全熟悉,NormalStudentT是一个很好的起点,因为许多数据类似于正态分布。但在你将Normal分布到处使用之前,应该在问题领域中进行一些文献研究,并根据实际情况选择合适的分布。例如,间歇性需求或稀疏需求,在零售业中非常常见,使用Poisson分布建模会更好。如果预测的是计数数据(正整数),Negative Binomial是一个不错的选择。

现在让我们设置一个预测期限、需要的水平以及一些 LSTM 的超参数(我们选择了一些简单且较小的超参数来加速训练。在实际问题中,建议进行超参数搜索,以找到最佳参数)。

horizon = 48
levels = [80, 90]
lstm_config = dict(input_size=3*horizon, encoder_hidden_size=8, decoder_hidden_size=8) 

现在我们需要定义将要使用的模型和NeuralForecast类。我们定义两个模型——一个使用Normal,另一个使用StudentT

models = [
    LSTM(
        h=horizon,
        loss=DistributionLoss(distribution="StudentT", level=levels),
        alias="LSTM_StudentT",
        **lstm_config
    ),
    LSTM(
        h=horizon,
        loss=DistributionLoss(distribution="Normal", level=levels),
        alias="LSTM_Normal",
        **lstm_config
    ),
]
# Setting freq=1 because the ds column is not date, but instead a sequentially increasing number
nf = NeuralForecast(models=models, freq=1) 

注意,语法与点预测完全相同,唯一不同的是我们选择的分布损失。现在剩下的就是训练模型了。

nf.fit(df=Y_train_df) 

一旦模型训练完成,我们可以使用预测方法进行预测。该输出将在我们定义的别名下提供点预测,并提供我们定义的所有置信水平的高低区间。

Y_hat_df = nf.predict() 

现在我们已经得到了概率预测,让我们来看一下它们,并计算相关指标。

图 17.5:使用 LSTM 和 StudentT 分布作为输出的预测带置信区间

图 17.6:LSTM 与正态分布和 StudentT 分布输出的度量标准

如果我们将覆盖率与 NGBoost 的覆盖率进行比较,我们可以看到深度学习方法提高了覆盖率,但在大多数情况下间隔也比必要的更宽(如更大的平均宽度所示)。

这种方法的最大缺点是我们将输出限制在参数化分布之一。在许多实际情况下,数据可能不符合任何参数化分布。

现在,让我们看一个不需要假设任何参数分布的方法,但仍然可以得到预测区间。

分位数函数

如果我们的唯一目的是获得预测区间,我们也可以使用分位数来做同样的事情。让我们从稍微不同的角度看 PDF 方法。在 PDF 方法中,我们在每个时间步有一个完整的概率分布作为输出,并使用该分布来获得分位数,这些分位数就是预测区间。尽管对于大多数参数分布,有获取分位数的解析公式,但我们也可以通过数值方法找到分位数。我们只需绘制足够数量的 N 个样本,然后计算绘制样本的分位数。关键在于,即使我们有完整的概率分布,对于预测区间,我们所需的只是分位数。而给定 N 个样本计算分位数并不依赖于分布的种类。

那么,如果我们可以训练我们的模型直接预测指定的分位数,而不假设潜在的概率分布会怎样?这正是我们用分位数函数所做的。

在讨论分位数函数之前,让我们花一分钟了解累积分布函数CDF)。再次强调,这是高中的概率知识。简单来说,CDF 返回某个随机变量X小于或等于某个值x的概率:

这里F是 CDF。该函数接受一个输入x,并返回一个介于 0 和 1 之间的值。我们称这个值为

分位数函数是 CDF 的反函数。该函数告诉你使返回特定值x的值。

这个函数,,就是分位数函数

从实施的角度来看,对于能够进行多输出预测的模型(如深度学习模型),我们可以使用一个模型,并通过更改输出层来预测我们想要的所有分位数。对于仅限于单输出的模型(如机器学习模型),我们可以为每个分位数学习单独的分位数模型。

现在,就像之前一样,我们不能使用均方误差这样的点损失。我们通过使用对数似然函数在 PDF 中克服了这个问题。在这里,我们可以使用分位损失或针球损失。

参考检查

提出分位损失和回归的论文已在 参考文献 中第 4 条进行了引用。

分位损失可以定义如下:

其中 是时刻 t 的目标值, 是分位预测值,而 q 是我们预测的分位数。公式看起来很复杂,但请耐心等一下,其实很简单。让我们尝试理解一下损失的直觉。我们知道中位数是 0.5 分位数,这是一个集中趋势的度量。但是,如果我们想让预测值接近第 75 百分位数或 0.75 分位数,我们就必须促使模型做出高估,对吗?如果我们想让模型高估,我们需要在模型低估时给予更高的惩罚。反之,如果我们想预测 0.25 分位数,我们需要低估。分位损失正是做到了这一点:

  • 对于 (低估),损失为

  • 对于 (高估),损失为

这种不对称性来源于 q 或 1 - q。另一个项只是实际值和预测值之间的差异。

让我们通过一个例子来理解这一点。假设我们有真实值 ,并且我们想要估计 0.75 分位数()。

  • 案例 1:高估。由于 ,我们的分位损失将是:

  • 案例 2:低估。由于 ,我们的分位损失将是:

笔记本提醒

若要跟随完整代码,请使用 Chapter17 文件夹中的名为 03-Understanding_Quantile_Loss.ipynb 的笔记本。

因此,通过调整 q 的值,我们可以使损失变得更具不对称性,且倾向于任一方向。下面的 图 17.7 显示了 q = 0.5 和 q = 0.75 的损失曲线,并标出了这些示例预测。

图 17.7:q=0.5 和 q=0.75 的分位损失曲线

我们可以看到,q = 0.5 的分位损失是对称的,因为它代表了中位数,而 q = 0.75 的损失曲线是非对称的,对低估的惩罚远高于高估。

尽管公式具有分支结构,但在实现代码时可以轻松避免使用最大操作。Python 中的分位损失代码如下:

def quantile_loss(q, y, y_hat_q):
    """ Calculate the quantile loss for a given quantile.
    Args:
    q (float): The quantile to be evaluated, e.g., 0.5 for median.
    y (float): The target value.
    y_hat_q (float): The quantile forecast.
    """
    error = y - y_hat_q
    return np.maximum(q * error, (q - 1) * error) 

现在,让我们进入实际操作,学习如何在本书中涵盖的不同技术(包括机器学习和深度学习)中使用分位损失。

使用分位损失进行预测(机器学习)

常规机器学习模型通常只能建模一个输出。因此,我们需要为每个我们感兴趣的分位数训练不同的模型,使用分位数损失。所以,如果我们想预测一个问题的 0.5、0.05 和 0.95 分位数,我们必须训练三个独立的模型,每个分位数一个模型。

笔记本提示

要跟随完整代码,请使用 Chapter17 文件夹中的 04-LightGBM_Prediction_Interval_Quantile_Loss.ipynb 笔记本。

让我们看看如何实现这一点。就像在 PDF 部分中一样,我们使用 mlforecast 快速生成一个合成问题并创建一些特征。有关详细代码,请参考完整的笔记本,但现在假设我们有一个数据框 data,其中包含运行机器学习模型所需的所有特征。我们将其分为 trainval,然后分别划分为 X_trainy_trainX_valy_val

第一步是导入 LGBMRegressor 并设置我们希望训练的某些参数和分位数。

params = {
    'objective': 'quantile',
    'metric': 'quantile',
    'max_depth': 4,
    'num_leaves': 15,
    'learning_rate': 0.1,
    'n_estimators': 100,
    'boosting_type': 'gbdt'
}
# converting levels to quantiles
# For 90% Confidence - 0.05 for lower, 0.5 for median, and 0.95 for upper
quantiles = [0.5] + sum([level_to_quantiles(l) for l in levels], []) 

这里需要注意的关键参数是 objective 设置为 quantile,并且 metric 也设置为 quantile。其余的 LightGBM 参数可以根据每个用例进行调优。现在,让我们训练所有的分位数模型。

# Training a model each for the quantiles
quantile_models = {}
for q in quantiles:
    model = LGBMRegressor(alpha=q, **params)
    model = model.fit(X_train, Y_train)
    quantile_models[q] = model 

现在模型已经训练完成,我们可以从分位数模型中获取点预测和预测区间。

# Point Forecast using the 0.5 quantile model
y_pred = quantile_models[0.5].predict(X_val)
# Prediction Intervals using the 0.1 and 0.9 quantile models
y_pred_lower = quantile_models[0.1].predict(X_val)
y_pred_upper = quantile_models[0.9].predict(X_val) 

现在,让我们看看预测和度量指标的结果。

图 17.8:使用 Quantile 回归和 LightGBM 进行带预测区间的预测

图 17.9:使用 LightGBM 的分位数回归的度量指标

这样做的缺点是我们为每个分位数训练一个模型。这会很快变得难以管理。与训练一个模型不同,训练三个模型会导致总训练时间增加。另一个问题是,由于三个模型的训练方式不同,它们可能具有不同的属性,它们解决问题的方式也可能有很大不同。由于这种不一致性,预测区间也可能存在一些问题。我们可以清楚地看到这一点,在许多时间序列中的图 17.7,它们似乎与中位预测断开连接。这是深度学习领域没有的问题。

使用分位数损失进行预测(深度学习)

在深度学习模型中,我们使用一个共同的学习结构,并对不同的分位数在共享投影上使用不同的线性投影。这确保了所有分位数的基础表示和学习是相同的,从而可以产生更一致的分位数预测。因此,对于我们在本书中学到的所有深度学习模型,我们可以通过做两件事将它们转换为分位数预测模型:

  1. 我们不是预测单一数字的点预测,而是预测概率分布的参数(一个或多个数字)。

  2. 与其使用像均方误差这样的点损失,不如使用像对数似然这样的概率评分函数。

就像我们在 PDF 部分做的那样,我们所需要做的只是将neuralforecast中的损失函数切换掉。

笔记本警告

要跟随完整代码,请使用Chapter17文件夹中的05-NeuralForecast_prediction_intervals_Quantile_Loss.ipynb笔记本。

让我们看看如何在neuralforecast中实现这一点(我们在第十六章中使用的库)。就像之前一样,我们将使用一个简单的模型,如 LSTM 和 M4 竞赛数据集,但我们可以对任何模型或任何数据集执行相同的操作,因为我们所做的只是将损失函数切换为MQLoss(多分位数损失)。

让我们从数据已经按照neuralforecast所需的格式(即Y_train_dfY_test_df)开始。首先,我们需要导入必要的类。

from neuralforecast import NeuralForecast
from neuralforecast.models import LSTM
from neuralforecast.losses.pytorch import MQLoss 

我们之前没有查看过的唯一类是MQLoss。这个类计算我们刚刚讨论的多分位数的分位数损失(这通常是你训练模型的方式)。这些是MQLoss的主要参数:

  • level:这是一个浮动数值列表,定义了我们感兴趣的不同置信度水平。例如,如果我们想建模 80%和 90%的置信度,我们应该给出[80,90]

  • quantiles:这是一种定义水平的替代方法。你可以将其定义为分位数,而不是 95%的置信度 → [0.1, 0.9]

现在,让我们设置一个预测范围,确定所需的水平,并为 LSTM 设置一些超参数。

horizon = 48
levels = [80, 90]
lstm_config = dict(input_size=3*horizon) 

现在,我们需要定义我们将要使用的模型和NeuralForecast类。让我们只定义一个模型。

models = [LSTM(h=horizon, loss=MQLoss(level=levels), **lstm_config)]
# Setting freq=1 because the ds column is not date, but instead a sequentially increasing number
nf = NeuralForecast(models=models, freq=1) 

请注意,语法与点预测完全相同,唯一不同的是我们选择了多分位数损失。现在,剩下的就是训练模型了。

nf.fit(df=Y_train_df) 

一旦模型训练完成,我们就可以使用predict方法进行预测。这个输出将包含我们定义的别名下的点预测,以及我们所定义的所有水平的高低区间。

Y_hat_df = nf.predict() 

现在,我们有了一种不对输出分布做假设的预测区间方法,这在现实世界中非常有价值,特别是在我们不确定基础输出分布时。让我们看看生成的概率预测及其指标。

图 17.10:使用分位数回归(深度学习)进行预测并生成预测区间

图 17.11:分位数回归(深度学习)的指标

我们可以看到,预测区间彼此之间非常一致,与中位预测结果没有脱节,像 LightGBM 中的不同模型那样断开。这是因为对于所有分位数,都在进行相同的学习,只是最终的投影头不同。总体而言,覆盖率也更好。

还有一种获取预测区间的方法,它非常简单,但由于 PyTorch 的使用方式,实际上并不容易实现。这就是我们接下来要看到的内容。

蒙特卡洛丢弃

丢弃是深度学习中一种非常流行的正则化层。简单来说,丢弃正则化是指在训练过程中随机使网络的部分权重为零(在推理时,丢弃会被关闭)。直观地看,这迫使模型不依赖少数几个权重,而是将权重的相关性分布在整个网络中。从另一个角度看,我们正在应用一种类似于岭回归(Ridge regularization)的正则化方法,确保没有任何权重单独过高,以至于会剧烈地影响输出。

从技术上讲,除了将部分权重设为零,我们还通过按保留(未置零)节点/权重的比例进行归一化,来消除每一层的偏差。现在让我们对这一层进行公式化。如果丢弃的概率是 ,并应用于一个中间激活值 h,那么丢弃后的激活值 将是:

为什么在应用丢弃时,我们需要缩放/归一化输出?直观的答案是为了确保在训练期间(丢弃启用时)和推理期间(丢弃关闭时),输出的尺度一致。更长的答案如下。

假设在没有丢弃(dropout)的情况下,一个节点的输出是 h。现在,使用丢弃时,节点的输出有 的概率变为 0,且有 的概率保持为 h。因此,该节点的期望值为:。这意味着输出的平均值会被 缩减,这在训练和推理过程中是不希望发生的,因为它会改变数值的尺度。因此,解决方案是将被丢弃保留的节点的输出按 进行缩放。

现在,我们知道了什么是丢弃。但请记住,我们仅在训练期间使用丢弃作为正则化手段。在 2015 年,Yarin Gal 等人提出了经典的丢弃法也可以作为 贝叶斯高斯过程近似。这是一堆我们之前没接触过的术语。让我们简要地绕个路,从高层次上理解这些术语,我会在 进一步阅读 中提供更多链接。

参考检查

Yarin Gal 等人的关于蒙特卡洛丢弃的论文在 参考文献 中以参考文献 2 的形式被引用。

贝叶斯推断是一种统计方法,当更多证据或信息变得可用时,它会更新假设的概率。它基于贝叶斯定理,该定理在数学上表达了先验概率、似然性和后验概率之间的关系。形式上,贝叶斯定理表示为:

其中,是给定数据D的假设后验概率似然性(给定假设观察到数据D的概率),是观察数据之前假设先验概率,而是边际似然性或证据(在所有可能的假设下观察到数据的总概率)。

虽然它有一些特定的术语,但这个方法非常直观,并提供了一种在面对证据或数据时更新我们先前信念的结构化方式。我们从一个先验分布开始,表示我们对参数的初始信念。当我们观察到数据D时,我们更新我们的信念,得到后验分布。这个后验分布结合了先验信息和观察到的数据的似然性,提供了一个关于参数的新、更新的信念。对于感兴趣的人,进一步阅读提供了更详细的解释。它还包含了Seeing Theory中的一页,帮助你以直观的方式可视化这些内容。

现在,让我们继续讲解高斯过程GP)。正如我们在第五章中看到的,监督学习是学习一个函数,其中是我们感兴趣的预测量,h是我们学习的函数,X是输入数据,而表示模型参数。因此,高斯过程将这个函数假设为一个概率分布,并使用贝叶斯推断,通过我们可用于训练的数据来更新该函数的后验。这是一种与数据学习截然不同的方式,并且本质上是概率性的。

还有一个术语,就是近似。在许多情况下,贝叶斯模型中的复杂后验分布使其无法直接计算。因此,我们有一种称为变分推断的技术,其中我们使用一个已知的参数分布族q,并找到最接近真实后验的成员。详细讨论变分推断和高斯过程(GP)超出了本书的范围,但我在进一步阅读中为有兴趣的人提供了一些链接。

回到 dropout,Yarin Gal 等人展示了在每个权重层之前定义带有 dropout 层的神经网络,实际上是 GP 的贝叶斯近似。因此,如果带有 dropout 的模型是 GP,并且 GP 是一个函数的后验分布,那么这应该给我们一个概率输出,对吧?但是在这里,我们没有像正态分布这样的明确定义的参数化概率分布,可以让我们分析地计算分布的属性(例如均值、标准差或分位数)。那我们该怎么做呢?

记得我们在量化函数章节开始时讨论过的内容:如果我们从一个分布中抽取N个样本,并且N足够大,我们可以近似地估计该分布的属性?我们把这个过程叫做蒙特卡洛采样蒙特卡洛采样是一种计算技术,用于通过从分布中生成许多随机样本来估计分布的统计属性。将这一理念应用到启用了 dropout 的神经网络中,我们可以通过蒙特卡洛采样评估函数后验概率分布的属性,这意味着我们需要在推理时保持 dropout 开启,并通过执行N次前向传播从后验中进行采样。

理论上的依据使我们可以将 dropout 应用于任何神经网络,通过简单的操作获得不确定性估计。难道这份简洁性不美吗?

所以,所有这些归结为以下简单步骤来获取预测区间:

  1. 选择任意一种深度学习架构。

  2. 在每个重要操作之前插入 Dropout 层,并将其设置为一个值 ,其中

  3. 在训练完模型并启用 dropout 后,执行N次前向传播。

  4. 使用N个样本,估计中位数(用于点预测),以及对应于定义的置信水平的分位数,以获得预测区间。

这听起来足够简单,但因为一个原因,它其实很复杂。PyTorchTensorflow的设计方式是 dropout 在推理阶段会被关闭。在PyTorch中,我们可以通过model.train()model.eval()来分别指示模型处于训练阶段或推理阶段。而大多数流行的实现(例如PyTorch Lightning)在后台会在预测之前执行model.eval()步骤。因此,当使用像neuralforecast这样的库(它在后台使用PyTorch Lightning)时,在预测时启用 dropout 并不容易。

在我们学习如何为 neuralforecast 模型实现 MC Dropout 之前,让我们稍作绕行,学习如何在 neuralforecast 中定义自定义模型。这在你需要根据自己的使用案例调整任何模型时非常有用。我们之所以在这里做,是有两个原因:

  1. 我想向你展示如何在 neuralforecast 中定义一个新模型。

  2. 我希望模型能很好地适配 MC Dropout 技术,也就是说,模型需要在每一层/权重之前都有 dropout 操作。

在 neuralforecast 中创建自定义模型

现在是时候开始一些有趣的事情了,定义一个自定义的PyTorch模型,使其能够与neuralforecast一起使用。为了简化起见,我们将使用一个基于我们在第十六章中学到的D-Linear的小改进模型。除了线性趋势和季节性之外,我们还添加了一个非线性趋势的组件。让我们也给它取个奇怪的名字——D-NonLinear。模型架构大致如下:

图 17.12:D-NonLinear 模型架构

现在,让我们理解如何编写一个与neuralforecast兼容的模型。所有neuralforecast中的模型都继承自三种类之一——BaseWindowsBaseRecurrentBaseMultivariate。文档清楚地解释了BaseWindows的目的,这正是我们在此用例中所需的。我们需要在训练时从时间序列中采样窗口。

还需要记住的一点是,neuralforecast在后台使用PyTorch Lightning进行训练。这个链接提供了有关如何为neuralforecast定义新模型的更多详细信息:nixtlaverse.nixtla.io/neuralforecast/docs/tutorials/adding_models.html

如果你不熟悉面向对象编程OOP)和继承,那么你可能会很难理解我们在这里做的事情。继承允许子类继承父类中定义的所有属性和方法。这使得开发人员可以在基类中定义通用功能,然后继承该类以获得所有功能,再在此基础上添加任何你想要添加的特定功能。强烈建议你理解继承,不仅仅是为了这个例子,更是为了成为一个更好的开发者。网上有成百上千的教程,我这里链接一个:ioflood.com/blog/python-inheritance

模型的完整代码可以在src/dl/nf_models.py中找到,但我们将在这里查看模型定义的关键部分。

我们从定义__init__函数开始(这里只包含相关部分;完整的类定义请参见 Python 文件)。

class DNonLinear(BaseWindows):
    def __init__(
        self,
        # Inherited hyperparameters with no defaults
        h,
        input_size,
        # Model specific hyperparameters
        # Window over which the moving average operates for trend extraction
        moving_avg_window=3,
        dropout=0.1,
        # Inhereted hyperparameters with defaults
        ...
        **trainer_kwargs,
    ):
        super(DropoutDNonLinear, self).__init__(
            h=h,
            ...
            **trainer_kwargs,
        )
        # Model specific hyperparameters
        self.moving_avg_window = moving_avg_window
        self.dropout = dropout
        # Model initialization to follow 

我们现在已经定义了__init__函数的一部分。接下来,让我们初始化在方法的其余部分中需要的不同层。我们有一个系列分解层,它使用移动平均将输入分割为趋势和季节性分量,还有一个线性趋势预测器和季节性预测器,负责将线性趋势和季节性分量投影到未来,最后是一个非线性预测器,它接受原始输入并将其投影到未来。

 # Defining a decomposition Layer
        self.decomp = SeriesDecomp(self.moving_avg_window)
        # Defining a non-linear trend predictor with dropout
        self.non_linear_block = nn.Sequential(
            nn.Dropout(self.dropout),
            nn.Linear(self.input_size, 100),
            nn.ReLU(),
            nn.Dropout(self.dropout),
            nn.Linear(100, 100),
            nn.ReLU(),
            nn.Dropout(self.dropout),
            nn.Linear(100, self.h),
        )
        # Defining a linear trend predictor with dropout
        self.linear_trend = nn.Sequential(
            nn.Dropout(self.dropout),
            nn.Linear(self.input_size, self.h),
        )
        # Defining a seasonality predictor with dropout
        self.seasonality = nn.Sequential(
            nn.Dropout(self.dropout),
            nn.Linear(self.input_size, self.h),
        ) 

现在,让我们定义 forward 方法。forward 方法应该只有一个参数,它是一个包含不同输入的字典:

  • insample_y:我们需要预测的目标时间序列的上下文窗口

  • futr_exog:未来的外生变量

  • hist_exog:上下文窗口的外生变量

  • stat_exog:静态变量

对于这个用例,我们只需要 insample_y,因为我们的模型不使用其他信息。所以,这是 forward 方法的实现:

 def forward(self, windows_batch):
        # Parse windows_batch
        insample_y = windows_batch[
            "insample_y"
        ].clone()  # --> (batch_size, input_size)
        seasonal_init, trend_init = self.decomp(
            insample_y
        )  # --> (batch_size, input_size)
        # Non-linear block
        non_linear_part = self.non_linear_block(
            insample_y
        )  # --> (batch_size, horizon)
        # Linear trend block
        trend_part = self.linear_trend(trend_init)  # --> (batch_size, horizon)
        # Seasonality block
        seasonal_part = self.seasonality(
            seasonal_init
        )  # --> (batch_size, horizon)
        # Combine the components
        forecast = (
            trend_part + seasonal_part + non_linear_part
        )  # --> (batch_size, horizon)
        # Map the forecast to the domain of the target
        forecast = self.loss.domain_map(forecast)
        return forecast 

代码非常直观。我们唯一需要确保与 neuralforecast 模型对齐的地方是,从输入字典中提取我们需要的数据,并在最后调用 self.loss.domain_map,这样它就会根据损失函数映射到正确的输出大小。现在,这个模型将像 neuralforecast 库中的任何其他模型一样运行。

现在,让我们回到 MC Dropout 及其实现。

使用 MC Dropout 进行预测(neuralforecast)

我们之前提到,像 neuralforecastPyTorch Lightning 这样的框架中实现 MC Dropout 并不容易,但仅仅因为某件事情不容易并不应该阻止我们去做。我们只需要确保在预测过程中启用 dropout,并进行多次采样。如果你正在编写自己的 PyTorch 训练代码,那么只需确保在预测前不调用 model.eval()。但最佳做法是将 dropout 层置于训练模式,而不是整个模型。可能会有像批量归一化这样的层,它们在推理时的行为也不同,可能会受到影响。让我们看看一个便捷的方法,将所有 dropout 层都设置为训练模式。

def enable_dropout(model):
    """Function to enable the dropout layers during test-time"""
    for m in model.modules():
        if m.__class__.__name__.startswith("Dropout"):
            m.train() 

对于 neuralforecast,我们已经准备了一份配方,可以使用 MC Dropout 处理他们的任何模型(前提是模型有足够的 dropout)。现在,我们将使用我们刚定义的自定义 DNonLinear 模型。请注意,定义中的每个组件都以 dropout 层开始,这样我们就可以毫无顾虑地应用 MC Dropout。

笔记本警告

要跟随完整的代码,请使用 Chapter17 文件夹中的名为 06-Prediction_Intervals_MCDropout.ipynb 的笔记本。

如果你记得的话,我们在 第十三章 使用了 PyTorch Lightning,并解释了它基本上是标准的 PyTorch 代码,但以指定的形式组织——training_stepvalidation_steppredict_stepconfigure_optimizers 等。如果需要复习,请回顾 第十三章 和本章中的 进一步阅读 部分,了解更多关于如何从 PyTorch 迁移到 PyTorch Lightning 的信息。由于 neuralforecast 已经在后台使用了 PyTorch Lightning,我们继承的 BaseWindows 本身就是一个 PyTorch Lightning 模型。这个信息非常重要,因为我们实际上需要修改 predict_step 方法来实现我们的 MC Dropout。

使用我们之前用来继承BaseWindows的相同方法,我们可以继承我们早前定义的DNonLinear类,并做一些修改,使其成为一个 MC Dropout 模型。为此,我们只需要重新定义predict_step方法。predict_stepPyTorch Lightning每次需要获取批次预测时调用的方法。所以,我们不直接使用现有的预测,而是需要保持 dropout 启用,从N次前向传递中获取N个样本,计算预测区间和中位数(点预测),并返回它。

class MCDropoutDNonLinear(DNonLinear):
    def predict_step(self, batch, batch_idx):
        enable_dropout(self)
        pred_samples = []
        # num_samples and levels will be saved to the model in MCNeuralForecast predict method
        for i in range(self.num_samples):
            y_hat = super().predict_step(batch, batch_idx)
            pred_samples.append(y_hat)
        # Stack the samples
        pred_samples = torch.stack(pred_samples, dim=0)
        # Calculate the median and the quantiles
        y_hat = [pred_samples.quantile(0.5, dim=0)]
        if self.levels is not None:
            for l in self.levels:
                lo, hi = level_to_quantiles(l)
                y_hat_lo = pred_samples.quantile(lo, dim=0)
                y_hat_hi = pred_samples.quantile(hi, dim=0)
                y_hat.extend([y_hat_lo, y_hat_hi])
        # Stack the results
        y_hat = torch.stack(y_hat, dim=-1)
        return y_hat 

我们完成了吗?还没有。只剩下一个小事情需要做。在第十六章中,我们使用了一个名为NeuralForecast的类来进行neuralforecast模型的所有拟合和预测。这个类就像一个包装类,在调用底层模型之前,负责以正确的方式准备输入和输出。这个类必须知道我们已经修改了predict_step,因此我们需要在这里做一个小改动。这个解决方案更多的是一个“黑客”方法,而不是一种有原则的编辑方式,但如果能达到目的,黑客方法也不失为一个好方法。我已经对实现进行了探索,以找出修改NeuralForecast类以支持我们的 MCDropout 推断的最佳方法。没有简短的解释方法,但请理解,我错误地使用了neuralforecast根据不同损失灵活生成点预测和预测区间的方式。所以,下面是重新定义的NeuralForecast类,其中predict方法做了一个黑客修改。

class MCNeuralForecast(NeuralForecast):
    def __init__(self, num_samples, levels=None, **kwargs):
        super().__init__(**kwargs)
        self.num_samples = num_samples
        self.levels = levels
    def predict(
        self,
        df=None,
        static_df=None,
        futr_df=None,
        sort_df=True,
        verbose=False,
        engine=None,
        **data_kwargs,
    ):
        # Adding model columns to loss output names
        # Necessary hack to get the quantiles and format it correctly
        for model in self.models:
            model.loss.output_names = ["-median"]
            for l in list(self.levels):
                model.loss.output_names.append(f"-lo-{l}")
                model.loss.output_names.append(f"-hi-{l}")
            # Setting the number of samples and levels in the model
            model.num_samples = self.num_samples
            model.levels = self.levels
        return super().predict(
            df, static_df, futr_df, sort_df, verbose, engine, **data_kwargs
        ) 

就这样。我们成功地“黑进”了这个库,让它按我们的意愿工作。除了这篇文章是一个 MC Dropout 教程外,它还是一个关于如何“黑进”一个库让它做你想让它做的事的教程。需要注意的是,这并不会让你成为一个“黑客”,所以在更新你的 LinkedIn 头衔之前请先停手。

现在,开始训练模型。这与在neuralforecast中训练其他模型基本相同,只是你需要使用我们定义的新MCNeuralForecast类,而不是NeuralForecast类。

horizon = len(Y_test_df.ds.unique()) # 48
levels = [80, 90]
model = MCDropoutDNonLinear (
    h= horizon,
    input_size=WINDOW,
    moving_avg_window=horizon*3,
    dropout=0.1,
    max_steps=500,
    early_stop_patience_steps=5,
)
mcnf = MCNeuralForecast(models=[model], freq=1, num_samples=100, levels=levels)
mcnf.fit(Y_train_df, val_size= horizon, verbose=True) 

一旦训练完成,我们可以像这样生成预测:

Y_hat_df = mcnf.predict()
Y_hat_df = Y_hat_df.reset_index() 

输出与neuralforecast的其他模型完全一样,预测区间的格式为<ModelName>-lo-<level><ModelName>-hi-<level>。点预测可以在<ModelName>-median下找到。在这种情况下,<ModelName>MCDropoutDNonLinear

让我们看看预测结果和指标的图表。

图 17.13:使用 MC Dropout 的预测和预测区间

图 17.14:MC Dropout 的指标

我们的预测模型在数据上的表现还算不错;虽然不能说是出类拔萃,但也算得上中规中矩。如果我们做一个消融研究,可能会发现我们添加的非线性组件根本没有任何作用。但只要我们在过程中感到愉快,并且从中学到了东西,我就很满足了。现在,看看预测区间吧。它们并不平滑,观察时会有相当多的“噪声”,对吧?这是因为该方法本身存在随机性,也可能是因为学习不足。当我们使用 MC Dropout 时,我们实际上是依赖于N个子模型或子网络,并基于这N个预测计算分位数。也许其中有些子网络并没有很好地学习,而这些输出可能会扭曲分位数,从而影响预测区间。

MC Dropout 方法有许多批评声音。贝叶斯社区中的许多人不认为 MC Dropout 是贝叶斯方法,认为其提出的变分逼近是如此差劲的近似,以至于我们不能将其度量的东西称为贝叶斯不确定性。Loic Le Folgoc 等人有一篇未发表的 Arxiv 论文,名为“MC Dropout 是贝叶斯方法吗?”(参考文献6),声称 MC Dropout 并非贝叶斯方法。但这并不改变 MC Dropout 作为一种量化不确定性的廉价方法的事实。然而,在医学研究等领域,不确定性量化至关重要的情况下,我们可能希望采用更加原理化的方法。

我们还可以注意到,在所有时间序列中,覆盖率表现得相当糟糕。这同样是在不同研究中展现出来的。在 2023 年,Nicolas Dewolf 等人发表了一项研究,比较了回归问题中不同的不确定性量化方法(参考文献7)。他们发现,MC Dropout 在覆盖率和平均长度方面表现最差,进一步验证了 MC Dropout 是对不确定性的非常粗糙的近似。

现在,让我们看另一种有理论保证完美覆盖的概率预测技术,近年来已经成为炙手可热的趋势。

适应性预测

如果我告诉你有一种技术能够生成预测区间,并且从统计学上保证完美覆盖,能够适用于任何模型,而且无需我们对输出分布做任何假设呢?适应性预测就是这样的一种方法。适应性预测是一种帮助机器学习模型做出可靠预测的技术,通过估算模型的不确定性来实现。适应性预测为任何机器学习模型提供了稳健、统计上有效的不确定性量化方法,确保在关键应用中做出可靠且可信的预测。

尽管早在 2005 年,Vladmir Vovk 就提出了这一方法参考文献8),但它在过去几年受到了更多关注。让我们首先理解一下使用分类示例的符合预测的基本原理,然后看看如何将其应用于回归和时间序列示例。

分类的符合预测

让我们从一个已训练的模型开始,,它输出K个输出类别的估计概率(softmax得分)()。这个模型是什么并不重要;它可以是机器学习模型、深度学习模型,甚至是基于规则的模型。我们有训练数据,,和测试数据,。现在,我们需要一些额外的数据(除了训练和测试数据)叫做校准数据。那么,我们从这些数据中想要什么呢?通过使用,我们想要创建一个可能的标签预测集合,,确保测试数据点属于该集合的概率几乎正好是用户定义的误差率,(在整个讨论中,我们将讨论误差率。10%的误差率意味着 90%的置信水平)。这正是符合预测所保证的内容。它被称为边际覆盖保证,可以更正式地写为:

自然,你可能会在心中产生这个问题。这个 是什么?这个术语表示覆盖保证是从一个有限大小为n的样本中得出的。由于n在分母中,我们知道当n增大时,这个术语会变得越来越小。将其扩展到极限,我们知道如果 ,这个术语将为零,覆盖度将正好是

我们知道我们想要什么,但我们该如何实现呢?符合预测的核心思想非常简单,可以分为四个步骤:

  1. 使用训练好的模型识别不确定性的启发式概念。在我们的分类示例中,这可以是 softmax 得分。

  2. 定义一个得分函数,,也称为非一致性得分。这个得分可以是输入预测值,,和实际值,,并给出一个表示它们之间不一致的得分。得分越高,不一致性越大。在分类示例中,这可以是像 这样简单的东西。用简单的英语来说,这意味着计算正确类别的 softmax 得分,并做出

  3. 计算作为校准得分的分位数。我们使用校准数据和得分函数来计算校准得分,并计算该数据上的分位数。的分位数计算仍然来源于有限样本修正。当趋近于无穷大时,该项趋近于零。

  4. 使用这个分位数为新的示例形成预测集:。这意味着从输出集中选择所有得分(根据得分函数)大于阈值的项,

这个简单的技术将为我们提供预测集,保证满足边际覆盖要求,无论使用什么模型或数据分布如何。让我们看看使用Python代码时,这有多简单,并假设我们讨论的模型是一个scikit-learn分类器。

我们有一个训练好的模型,model,校准数据,X_calib,以及测试数据,X_test。完整的代码和一些可视化请参阅笔记本。

笔记本提醒

要跟随完整的代码,使用位于Chapter17文件夹中的名为07-Understanding_Conformal_Prediction.ipynb的笔记本。

# 1: Get conformal scores
n = calib_y.shape[0]
cal_smx = model.predict_proba(calib_x) # shape (n, n_classes)
# scores from the softmax score for the correct class
cal_scores = 1 - cal_smx[np.arange(n), calib_y] # shape (n,)
# 2: Get adjusted quantile
alpha = 0.1  # Confidence level (1 - alpha)
q_level = np.ceil((n + 1) * (1 - alpha)) / n
qhat = np.quantile(cal_scores, q_level, method='higher')
# 3: Form prediction sets
val_smx = model.predict_proba(test_x)
prediction_sets = val_smx >= (1 - qhat) 

现在,让我们思考预测集,。我们一直将其定义为在分类场景下具有离散类别的集合值。这个集合的大小会根据初始启发式不确定性估计的置信度而增大或减小。

回归的符合性预测

让我们将预测集的概念扩展到回归问题。在回归中,输出空间是连续的,而不是离散的,我们的目标是构建连续的预测集,通常是中的一个连续区间。这个想法是保持相同的覆盖原则:预测区间应该以较高的概率包含真实值。因此,现在我们之前看到的预测集,,也是回归背景下的预测区间。但是随着预测集解释的变化,我们也需要更改得分函数,用于计算不一致性得分。一个常用的得分函数是与条件均值的距离,(参考10)。当我们有一个训练好的模型,,我们可以将模型的输出视为条件均值,这将使得每个点的绝对残差值。

注意,这个得分满足条件。偏差越大,“启发式”不确定性度量越大。其余的过程几乎保持不变——计算分位数,,并形成预测区间,

让我们检查一下 Python 代码如何变化(完整代码见笔记本)。

# 1: Get conformal scores
calib_preds = model.predict(calib_x)
cal_scores = np.abs(calib_y - calib_preds)
# 2: Get adjusted quantile
qhat = … # Exactly the same as classification
# 3: Form prediction intervals
test_preds = model.predict(test_x)
lower_bounds = test_preds - qhat
upper_bounds = test_preds + qhat 

就这么简单。我们可以检查覆盖情况,看到它将大于 90%,这是我们用 定义的误差率。

实践者注意事项

在许多应用场景中,我们将为多个实体或我们关心的群体训练一个单一模型。例如,对于我们在第十章中讨论的全球预测模型,我们使用一个回归模型来处理多个时间序列。在这种情况下,我们也可以对每个时间序列或时间序列组分别进行共形预测,以更好地适应该子集中的误差。这将允许在群体/时间序列层面提供覆盖保证。

但你可能注意到了一些问题。在这种方法中,我们始终使用相同宽度的区间。但我们期望当模型更有信心时,区间应该更紧,而当模型不确定时,区间应该更宽(从现在起我们称之为自适应预测区间)。让我们看看另一种具有此特性的技术。

共形化分位回归

我们了解了分位回归作为概率预测方法(或一般回归的一种形式)。分位回归之所以强大,是因为它不要求我们对潜在的输出分布做任何先验假设。但它并没有享受共形预测所提供的覆盖保证。在 2019 年,Yaniv Roano 等人(参考文献 11)将两者的优点结合到了共形化分位回归CQR)中。他们提出了一种方法,可以将分位预测进行共形化,使其具备共形预测所保证的覆盖保证。

在这种情况下,所使用的模型有一个限制。它应该是一个能够输出分位预测的模型。并且,结合我们之前的讨论,我们知道如果误差率为 ,那么我们需要的预测区间的分位数是

所以,分位模型预测 。根据定义,如果 是真实分位数的准确估计,单独使用分位回归将具有完美的覆盖率。但模型拟合可能并不完美,这将导致低于标准的覆盖率。我们将使用共形预测来根据校准数据修正分位数,从而实现共形预测所承诺的完美覆盖。

Yaniv Roano 等人提出使用一种新的非共形得分函数来处理分位回归。

让我们稍作停顿,使用图 17.15中的示意图来探索得分函数。

图 17.15:用于共形化分位回归的得分函数示意图

最大操作符中有两个项。如果真实值,,位于两个分位数之间,,那么两个项的值都为负数,并表示到最近预测区间的距离(参见图 17.15中的 B 点和 C 点)。

现在,让我们来看一下 A 点(3),它位于较高的分位数之上。分位数为[1, 2]。最大操作符中的两个项将是:

这使得得分函数为 3,因为最大操作符的作用。现在,我们看到了 D 点(1.6),它位于较低的分位数之下(分位数为[3.5, 2])。这使得得分函数为 max{0.4, -1.9},结果为 0.4。因此,最大操作符确保得分为正,如果它位于分位数之外。

因此,我们得到的得分函数会将正值分配给实际值落在区间外的点,将负值分配给落在区间内的点。而对于那些落在区间外的点,得分函数的构造方式将选择较差的误差。这满足了我们对得分函数的要求。较大的得分表示较大的不确定性,并且它还编码了一种不确定性的启发式概念。

现在我们已经有了得分,接下来的步骤几乎是相同的:

  1. 计算作为标定得分的分位数。

  2. 使用这个分位数为新示例形成预测集:,即我们通过扩大现有的分位数,并获得覆盖保证。

分位回归是获得自适应预测区间的更好方法之一。现在,让我们来看一下这种技术。

符合化不确定性估计

如果我们深入思考一下符合性分位回归(来自上一节),我们可以意识到,基础的分位回归所做的事情是捕捉我们预测中每个点的不确定性估计。然后,我们对这些估计进行符合化处理,以获得更好的覆盖。如果我们能够捕捉到这个不确定性估计,我们就有希望将其符合化,从而获得更好的自适应预测区间

假设我们有一个训练好的模型,,以及一个不确定性标量,,当不确定性较高时,该标量值较大,反之亦然。我们可以将我们的非符合性得分定义为:

这个得分的自然解释是,我们正在将一个修正因子乘到标准的上。一旦我们有了这个新得分,剩下的过程与之前完全相同——从得分中取,并形成预测区间,表示为

那么,有哪些方法可以捕捉这种不确定性呢?(请注意,这种不确定性度量应该在数据点层面捕捉它。)

  • 假设一个概率分布,并对其参数进行建模(概率密度函数)。以高斯分布为例,我们可以通过标准差来估算不确定性,,我们认为它是

  • 使用 MC Dropout 技术生成样本,并计算来自样本的的标准差:

  • 在主模型预测的基础上,训练另一个模型来预测残差,并设置

  • 使用模型集成生成每个数据点的多个预测,并对每个数据点的不同预测取标准差:

虽然上面的列表并不详尽,但它表明我们可以将符合预测应用于几乎所有的不确定性估计(包括我们在本章中已经看到的那些)。这使得符合预测范式成为一个非常灵活的工具包,可以为各种问题提供覆盖保证。即便具有这种灵活性,仍然存在一些情况会影响框架所承诺的覆盖保证。在我们讨论时间序列的背景下,理解这一点非常重要。

符合预测中的可交换性与时间序列预测

可交换性是符合预测中的一个基本假设。可交换性意味着数据点是同质分布的,并且当数据点的顺序发生变化时,它们的联合概率分布不变。这个概念确保了过去的数据点能够可靠地预测未来的数据点。

假设有一家巧克力工厂生产重量一致的巧克力。如果生产过程高度可控,且使用相同的条件和原料,那么巧克力的重量是可交换的,因为生产顺序不会影响其重量。你可以抽取 100 颗巧克力,测量它们的重量,并根据与预测重量的偏差计算不合规分数。利用这些分数,你可以为未来的巧克力制定预测区间。由于巧克力是可交换的,样本分布代表了未来的分布,从而使预测区间可靠。

然而,如果生产过程随着时间的推移发生变化——由于机器磨损或不同的原料批次——那么权重就不再是可交换的。生产顺序会影响权重,使得样本分布无法代表未来的权重,从而导致预测区间不可靠。

在时间序列数据中,观测值通常依赖于之前的观测值,这违反了可交换性假设。例如,在销售预测中,今天的销售可能会影响明天的销售,这可能是由于趋势因素,或者一年前的销售数据可能会影响明天的销售,这是由于季节性效应。这种依赖性意味着过去数据的分布并不能准确代表未来数据的分布。

那么,这对我们意味着什么?最明显的答案是我们的覆盖率保证将会受到影响。但我们还能将这些技术应用于时间序列数据吗?当然可以。从经验上看,社区已经发现这个框架也适用于时间序列数据,但会有一些覆盖率保证的损失。在大多数实际应用中,使用常规的符合性预测方法来处理时间序列数据不会有问题。2023 年,Barber 等人(参考文献12)研究了这个问题,并为不可交换数据(如时间序列)推导了理论覆盖率保证。他们定义了覆盖率差距,作为预期覆盖率()与实际覆盖率之间的差异,并推导了这个差距的上界,以显示得分的可交换性假设被违反的程度。对于这个界限,他们考虑了我们原始模型的校准数据得分!与一个替代模型的得分,后者是在同一数据上训练的,但将训练数据中的一个随机选择的数据点与测试数据点交换后得到的!

该界限被证明与成正比,这是这两个分数之间的分布距离。在我们使用的大多数算法中,交换一个数据点可能不会显著改变模型,因此我们仍然可以在时间序列数据上使用符合性预测,并且对覆盖率的损失最小。

但另一方面,如果我们希望预测区间更准确,或者我们使用的模型特别容易受到数据点交换的影响,那么我们需要一些技术来克服由于分布变化所造成的性能下降。目前有很多方法可以解决这个问题,并且这是撰写本书时一个活跃的研究领域。这里有两种非常简单的方法值得提及。

加权符合性预测

假设我们在时间序列中有缓慢变化的数据分布!,并且我们使用一个校准集!,该集取自最后的k时间步。我们感兴趣的是预测测试集!,其中H是预测的时间范围。

因此,合理的推测是, 中的最近时间步将最接近我们在测试时间段中观察到的值分布。那么,如果我们给校准数据中的不一致性分数分配权重,使得最近的时间步获得更高的权重,并计算加权分位数而不是常规分位数呢?显然,这是一个非常好的想法,并且有坚实的理论支持。

使用最近性来加权校准数据只是我们利用权重解决分布偏移问题的方式之一。更一般地说,任何权重调度, 都可以在这里使用。也许对于一个强季节性的时间序列,使用季节性周期来定义权重是有意义的,或者可能有其他已知的标准,使得不同的校准数据实例与未来预测的相关性高低不同。

在我们深入了解这一点的实际机制之前,我们需要理解什么是加权分位数。如果你已经熟悉这个概念,可以直接跳过。如果你需要一些直观的理解,我强烈建议你查看章节文件夹中名为08-Quantiles_and_Weighted_Quantiles.ipynb的笔记本。

现在,让我们回到我们的加权一致性预测方法。

正如我们之前讨论的,对于任何归一化的权重调度,,以及校准分数,s[i],加权分位数可以正式定义为:

其中 inf 是下确界, 是一个指示函数,当条件为真时值为 1,否则为 0。在这种情况下,下确界 是满足不等式成立的最小 q 值。这只是定义我们在前述笔记本中看到的加权分位数的一种更严谨的方式。其余过程与之前完全相同。

实际上,我们可以通过几种不同的方式将其应用于时间序列问题。例如:

  1. 我们可以考虑一个长度为 K 的滑动窗口,并且有一个长度为 K 的固定权重向量。在这个方案下,我们将对时间序列中的每个点应用权重,直到最近的 K 个点,并为该点计算预测区间。这些权重可以是相等的权重,甚至是衰减的权重,捕捉时间元素。

  2. 当我们一起建模多个时间序列时,我们可以确保权重反映了另一个时间序列与我们为其生成区间的时间序列之间的接近程度,以及时间上下文。

关键是,我们在确定权重时可以尽可能地发挥创造力。基本原则是,权重应当反映校准数据点与正在生成预测区间的数据点之间的差异。记住,我们之前提到过上限,。我们选择的权重将抵消这个项。当我们对“距离”我们关注的数据点较远的数据点赋予较小的权重时,会降低覆盖差距的上限,从而使其更加紧密。

现在,让我们了解另一种非常简单的修改方式,用于应对分布漂移。

自适应保形推断(ACI)

在 2021 年,Gibbs 等人(参考文献13)提出了另一种处理分布漂移(特别是在时间序列中的分布漂移)的方法,适用于在线环境。时间序列数据通常是一个数据点一个数据点地到达,这种处理分布漂移的方法依赖于在线特性,提出基于持续流入的数据不断调整预测区间,从而使预测区间适应变化的分布。这个方法被称为自适应保形推断ACI),可以与任何预测算法结合使用,在非平稳条件下提供稳健的预测集。

在传统的保形预测中,我们有一个评分函数,,和一个分位数函数,,它们给我们提供预测区间,。请注意,基础的不确定性模型,经过保形处理后为,可以是任何估计不确定性的方法,例如分位回归、PDF、MC Dropouts 等等。当数据是可交换时,我们在校准数据上计算的会在未来的测试数据点上保持有效。但当分布发生漂移时,这个也会逐渐变得不再相关。为了解决这个问题,作者建议定期重新估计这些函数,以与最新的数据观测对齐。具体来说,在每个时间点t,基于最新数据拟合一个新的评分函数,,以及一个新的分位数函数,(.)。

为此,他们将 误覆盖率,定义为真实标签 落在预测区间 外的概率,其中概率是通过校准数据和测试数据点计算的。我们希望 误覆盖率,等于 (期望误差率)。但由于数据分布正在变化, 不太可能在时间上保持不变,并且它可能不等于目标水平 。作者假设,对于每个时间点 ,可能存在一个最佳覆盖水平 ,使得误覆盖率 约等于

为了估计这个 ,作者提出了一个简单的在线更新方程。该更新考虑了前一个观察值的经验误覆盖率,然后增加或减少我们对 的估计。具体而言,如果我们设置 ,则可以定义误差为:

现在,我们可以递归地定义更新步骤为:

在这里, 作为历史误覆盖率的估计值, 是步长(一个超参数;稍后会详细讨论)。因此,当 (预测在区间内)时, 将为正值,从而使得更新后的 高于 。这反过来会使预测区间变窄(根据我们定义的 )。按照相同的逻辑,当 (预测在区间外)时,预测区间会变宽。

一个自然的替代更新方法,稍微更多地考虑历史信息,是使用过去时间步的加权平均:

其中 是一个递增的权重序列,满足 。与仅仅查看最后一个时间步来估计误覆盖不同,这种更新方法会查看最近的历史。这使得它在理论上稍微更加稳健。论文报告称,两种策略之间没有显著差异。他们用来决定权重的策略之一是:

他们报告说,从简单更新和加权更新中获得的预测区间轨迹几乎相同,但加权更新的轨迹显得更加平滑,且在 中具有更少的局部变化。

现在,让我们也花点时间了解步长参数的效果,。直觉上与深度学习模型中的学习率非常相似。决定我们更新的幅度。值越大,更新速度越快,反之亦然。该论文还向我们解释了,分布偏移越大,的值就越大。对于所有的实验,他们使用了,并且他们证明这个值使得轨迹相对平滑,同时又足够大以允许适应分布偏移。我们可以把这看作是一个控制“适应”强度并让我们在非自适应间隔和强自适应间隔之间移动的参数。

现在,让我们看看如何将这些应用到实践中。

使用符合预测进行预测

我们没有找到所有我们想展示的技术的现成实现,特别是具有以下特性的实现:

  • 模型层与符合预测层的完全分离(成为模型无关的符合预测的最令人兴奋的特性之一)

  • neuralforecast预测的即插即用兼容性

  • 时间序列焦点

  • 教学便利

因此,我们已经包含了一个文件(src/conformal/conformal_predictions.py),其中包含与neuralforecast预测兼容且具有统一 API 的必要实现。它也足够简单易懂。我们将详细讨论代码的主要部分,但要看到所有内容如何组合在一起,你应该直接查看该文件。

我们讨论的所有方法,如回归的符合预测、符合化分位数回归和符合化不确定性估计,都已在同一 API 中编码出来。让我们看看最基本的回归符合预测以了解 API。它可以在文件中的ConformalPrediction类中找到。其他技术继承了这个类并进行了轻微调整。所有这些类的编码方式都是以相同的方式接受来自neuralforecast(或statsforecast)的预测数据框架,并使用相同的命名约定来进行符合化预测。理论上,任何可以转换为预期格式的预测都可以与这些类一起使用。格式中预期的列包括:

  • ds:此列应包含日期或时间的数值等效项。

  • y:对于训练和校准数据集,此列是必需的,它代表该时间序列的实际值。

  • unique_id:此列是不同时间序列的唯一标识符。

除了这些列外,我们还将有一个(或多个)相应命名的预测列。

在我们开始生成保形预测之前,还需要一些数据和预测。我们使用的是本章中已经使用的数据(M4),并创建了一个额外的拆分(校准)。利用新的训练数据,我们已经使用level = 90生成了这三个预测:

  1. LSTM 点预测(LSTM

  2. 带分位数回归的 LSTM(LSTM_QR

  3. 带 PDF(正态分布)的 LSTM(LSTM_PDF

笔记本提醒

若要跟随完整的代码,请使用Chapter17文件夹中的笔记本09-Conformal_Techniques.ipynb

笔记本包含了完整的代码,但我们可以从数据已经被分割为Y_train_dfY_calib_dfY_test_df,并且预测已生成并存储在字典prediction_dict中这一点开始。让我们先查看准备好的数据框的前五行,看看我们正在使用的数据是什么样的。

图 17.16:我们正在使用的 Y_calib_df 的前五行。这是我们编写的保形预测类所期望的格式。

现在,让我们开始实际操作,创建预测区间。

回归的保形预测

ConformalPrediction类提供了一种基于选定模型的校准数据集预测,计算预测区间的结构化方式。它包括以下输入参数:

  • model(str):包含你想进行保形化的预测的列名。这是一个必填参数。

  • level(float):预测区间的置信度水平,以百分比表示(例如,95 表示 95%的置信区间)。该值必须在 1 和 100 之间。这是必填参数。

  • alias(str,选填):一个可选的字符串,用于为模型提供别名。当使用多个模型或版本时,如果想要给输出命名为与模型不同的名称时,这非常有用。如果未提供别名,则使用model作为默认值。

使用该类的主要功能包括:

  • fit(Y_calib_df):该方法协调整个校准过程。它首先使用calculate_scores计算校准分数,然后使用get_quantile为每个unique_id确定分位数。结果的分位数(q_hat)作为类的属性存储,供后续预测区间使用。

  • predict(Y_test_df):该方法将预测区间应用于测试数据。它使用calc_prediction_interval方法来计算区间,然后将它们作为新列添加到 DataFrame 中。这些新列的格式为:f"{self.alias or self.model}-{self._mthd}-lo-{self.level}"。例如,使用 LSTM 的保形预测的高区间会在 DataFrame 中显示为LSTM-CP-hi-90

这些类是外部 API。内部有一些方法实际定义了如何进行操作。让我们在常规保形预测的上下文中看看主要方法。

calculate_scores方法如下所示,在这里我们只是使用校准数据集计算绝对残差作为得分:

def calculate_scores(self, Y_calib_df):
    Y_calib_df = Y_calib_df.copy()
    Y_calib_df["calib_scores"] = np.abs(Y_calib_df["y"] - Y_calib_df[self.model])
    return Y_calib_df 

get_quantile方法使用定义的为每个unique_id计算分位数:

def get_quantile(self, Y_calib_df):
    def get_qhat(Y_calib_df):
        n_cal = len(Y_calib_df)
        q_level = np.ceil((n_cal + 1) * (1 - self.alpha)) / n_cal
        return np.quantile(
                Y_calib_df["calib_scores"].values, q_level, method="higher"
            )
    return Y_calib_df.groupby(
"unique_id").apply(get_qhat).to_dict() 

calc_prediction_interval方法使用计算出的q_hat和平均预测值生成预测区间。对于常规的符合预测,它的流程如下:

def calc_prediction_interval(self, Y_test_df, q_hat):
    return (
            Y_test_df[self.model] - Y_test_df["unique_id"].map(q_hat),
            Y_test_df[self.model] + Y_test_df["unique_id"].map(q_hat),
        ) 

现在,让我们用它来进行预测。

from src.conformal.conformal_predictions import ConformalPrediction
Y_calib_df, Y_test_df = prediction_dict['LSTM']
# Y_calib_df & Y_test_df have forecasts in column named "LSTM"
cp = ConformalPrediction(model="LSTM", level=level)
# Calibrating the model
cp.fit(Y_calib_df=Y_calib_df)
# Generating Prediction intervals
Y_test_df_cp = cp.predict(Y_test_df=Y_test_df) 

生成的带有预测区间的数据框(图 17.15)将有两列——LSTM-CP-lo-90LSTM-CP-hi-90,分别对应下限和上限预测区间。CP是我们为符合预测类分配的方法标签。

我们可以通过以下方式检查任何对象的方法名称:

>> cp.method_name
'Vanilla Conformal Prediction (CP) 

让我们看看预测数据框的样子(CP 是我们为符合预测类分配的方法标签。我们可以通过执行cp.method_name来检查任何对象的方法名称):

图 17.17:带有预测区间的生成数据框

现在,我们还需要计算区间的覆盖率和平均长度,以评估这些预测区间的表现。我们使用之前使用过的相同方法来实现这一点。与其讨论每种方法的性能,不如把讨论留到最后,先看看如何创建预测区间。

所以,让我们继续下一种技术。

符合分位数回归

应用这种符合预测方法的第一个条件是,必须已经从基础的分位数回归中得到一组预测区间。因此,我们使用了这里训练的LSTM_QR模型。

传统的符合预测和 CQR 之间的主要区别在于得分和预测区间的计算方式。因此,我们可以继承ConformalPrediction并重新定义这两种方法。

让我们看一下calculate_scores方法。

def calculate_scores(self, Y_calib_df):
    Y_calib_df = Y_calib_df.copy()
    lower_bounds = Y_calib_df[self.lower_quantile_model]
    upper_bounds = Y_calib_df[self.upper_quantile_model]
    Y_calib_df["calib_scores"] = np.maximum(
            lower_bounds - Y_calib_df["y"], Y_calib_df["y"] - upper_bounds
        )
    return Y_calib_df 

我们刚刚实现了之前看到的公式。self.lower_quantile_modelself.upper_quantile_model是从 CQR 生成的已存在区间的列名。

现在,我们还需要定义calc_prediction_interval方法。

def calc_prediction_interval(self, Y_test_df, q_hat):
    return (
            Y_test_df[self.lower_quantile_model] - Y_test_df["unique_id"].map(q_hat),
            Y_test_df[self.upper_quantile_model] + Y_test_df["unique_id"].map(q_hat),
        ) 

q_hat是一个字典,包含为每个unique_id计算的分位数。因此,我们要做的就是拿到 CQR 生成的现有预测区间,并通过映射输入数据框中的unique_id来调整它。

现在,让我们用这个进行预测。API 与之前完全相同。

from src.conformal.conformal_predictions import ConformalizedQuantileRegression
Y_calib_df, Y_test_df = prediction_dict['LSTM_QR']
# Forecast in column "LSTM_QR"
cp = ConformalizedQuantileRegression(model="LSTM_QR", level=level)
cp.fit(Y_calib_df=Y_calib_df)
Y_test_df_cqr = cp.predict(Y_test_df=Y_test_df) 

现在,让我们看看我们讨论过的第三种技术。

符合化不确定性估计

如果你记得我们之前的讨论,使用这种技术需要对不确定性的估计,并且该估计可以进一步符合化。这就是为什么我们选择了之前使用的其他技术之一,PDF。但我们也可以轻松地使用 MC Dropout 来实现这一点。我们需要的只是标准差或类似的东西,以捕捉每个数据点的不确定性。

我们正在使用我们之前生成的LSTM_PDF预测模型来完成这个任务。虽然模型预测的是正态分布的均值和标准差,但它在内部用于生成预测区间。因此,我们之前定义的 PDF 模型的输出将是预测区间,但我们需要的是标准差。别担心,我们知道预测区间是使用正态分布生成的。因此,从预测区间中重新计算标准差并不难。

使用基本的数学,我们可以推导出:

Z的获取非常简单。我们可以使用scipy.stats.norm来实现。下面是一个方法,可以从预测区间中获得标准差(请记住,这仅适用于使用正态分布创建的 PDF)。

from scipy.stats import norm
def calculate_standard_deviation(upper_bound, point_prediction, confidence_level):
    # Calculate the Z-value from the confidence level
    z_value = norm.ppf((1 + confidence_level) / 2)
    # Calculate the standard deviation
    sigma = (upper_bound - point_prediction) / z_value

    return sigma
def reverse_engineer_sd(X, model_tag, level):
    X["std"] = calculate_standard_deviation(
        X[f"{model_tag}-hi-{level}"], X[model_tag], level / 100
    )
    return X 

现在,我们将其添加到Y_calib_dfY_test_df中。

Y_calib_df = reverse_engineer_sd(Y_calib_df, "LSTM_Normal", level)
Y_test_df = reverse_engineer_sd(Y_test_df, "LSTM_Normal", level) 

现在,让我们看看如何定义这个类。这里我们需要一些之前不需要的额外信息——不确定性估计的列名。因此,我们定义我们的新类(仍然继承ConformalPrediction)如下:

class ConformalizedUncertaintyEstimates(ConformalPrediction):
    def __init__(
        self,
        model: str,
        uncertainty_model: str,
        level: Optional[float] = None,
        alias: str = None,
    ):
        super().__init__(model, level, alias)
        self.method = "Conformalized Uncertainty Intervals"
        self._mthd = "CUE"
        self.uncertainty_model = uncertainty_model 

我们定义了一个额外的参数uncertainty_model,并将其他参数传递给父类。

现在,很简单。我们需要定义分数是如何计算的:

def calculate_scores(self, Y_calib_df):
    Y_calib_df = Y_calib_df.copy()
        uncertainty = Y_calib_df[self.uncertainty_model]
    Y_calib_df["calib_scores"] = (
            np.abs(Y_calib_df["y"] - Y_calib_df[self.model]) / uncertainty
        )
    return Y_calib_df 

以及calc_prediction_interval方法:

def calc_prediction_interval(self, Y_test_df, q_hat:
    uncertainty = Y_test_df[self.uncertainty_model]
    return (
            Y_test_df[self.model] - uncertainty * Y_test_df["unique_id"].map(q_hat),
            Y_test_df[self.model] + uncertainty * Y_test_df["unique_id"].map(q_hat),
        ) 

就是这样。现在,我们有了一个新的类,用于符合化不确定性估计。让我们用它来获取我们正在使用的数据集的预测。

from src.conformal.conformal_predictions import ConformalizedUncertaintyEstimates
# We have saved uncertainty estimates in "std"
cp = ConformalizedUncertaintyEstimates(model="LSTM_Normal", uncertainty_model="std", level=level)
cp.fit(Y_calib_df=Y_calib_df)
Y_test_df_pdf = cp.predict(Y_test_df=Y_test_df) 

现在,让我们看看我们之前看到的两种技术,它们更适合处理时间序列问题,尤其是当存在分布漂移时。

加权符合预测

我们之前看到,加权符合预测只是将正确类型的权重应用于校准数据,使得与测试点相似的点比不相似的点获得更多的权重。关键区别仅在于分位数的计算方式。

这意味着我们可以使用任何符合预测技术,但不是计算简单的分位数,而是需要计算加权分位数。所以,从实现的角度来看,我们可以将这个类视为对我们定义的其他技术的封装类,并将它们转换为加权符合预测。

虽然加权符合预测可以通过多种方式实现,采用不同种类的权重(跨时间、跨unique_id等等),但我们将实现一种基于简单回溯窗口的加权符合预测。我们选择最后的K时间步,并在这K个分数上使用给定的权重计算加权分位数。这些权重可以是对所有K步的简单均匀权重,或者是逐渐衰减的权重,给最新的分数赋予最高的权重。它们甚至可以具有完全自定义的权重。

那么,我们可以按如下方式定义类的__init__方法:

class WeightedConformalPredictor:
    def __init__(
        self,
        conformal_predictor: ConformalPrediction,
        K: int,
        weight_strategy: str,
        custom_weights: list = None,
        decay_factor: float = 0.5,
    ):
    … 

在这里,K是窗口,conformal_predictor是我们应该使用的底层一致性预测类(它应该是我们定义的三个类之一)。我们可以将权重策略定义为uniform(均匀)、decay(衰减)或custom(自定义)权重,分别对应均匀权重、衰减权重或自定义权重。decay_factor决定衰减权重策略的衰减速度,而custom_weights让你精确指定这些K时间步上的权重。

虽然我们在这里不会查看完整的代码,但我们会浏览一些常见部分,以便你理解发生了什么。但我确实强烈建议你花时间消化文件中的代码。

首先,我们有我们的fit方法。在此方法中,我们仅使用基础一致性预测器的得分计算,并存储校准数据框。

def fit(self, Y_calib_df):
    self.calib_df = self.conformal_predictor.calculate_scores(
            Y_calib_df.sort_values(["unique_id", "ds"])
        ) 

现在,让我们看一下predict方法的主要部分。

def predict(self, Y_test_df):
    # Groupby unique_id
    …   
    # Calculate quantiles for each unique_id
    self.q_hat = {}
    for unique_id, group in grouped_calib:
        # Take the last K timesteps
        group = group.iloc[-self.K :]
        scores = group["calib_scores"].values
        # Calculate weights based on the last K timesteps
        total_timesteps = len(scores)
        weights = self._calculate_weight(total_timesteps)
        normalized_weights = weights / weights.sum()
        # Calculate quantile for the current unique_id
        quantile = self.get_weighted_quantile(
                scores, normalized_weights, self.conformal_predictor.alpha
            )
        self.q_hat[unique_id] = quantile
    # Calculate prediction intervals using the underlying conformal predictor's method
    lo, hi = self.conformal_predictor.get_prediction_interval_names()
    Y_test_df[lo], Y_test_df[hi] = (
 self.conformal_predictor.calc_prediction_interval(Y_test_df, self.q_hat)
        )
    return Y_test_df 

现在,让我们拿一个我们之前看到的方法,并在其上应用加权一致性预测器包装器。对于我们的示例,让我们选择简单的ConformalPrediction。让我们看看如何使用这个类:

from src.conformal.conformal_predictions import WeightedConformalPredictor
Y_calib_df, Y_test_df = prediction_dict['LSTM']
# Defining an underlying conformal predictor
cp = ConformalPrediction(model="LSTM", level=level)
# using the defined conformal predictor in weighted version
weighted_cp = WeightedConformalPredictor(
    conformal_predictor=cp,
    K=50,
    weight_strategy="uniform",
)
weighted_cp.fit(Y_calib_df=Y_calib_df)
Y_test_df_wcp = weighted_cp.predict(Y_test_df=Y_test_df) 

这将创建带有标签CP_Wtd的预测区间。我们可以通过执行weighted_cp.method_name随时检查标签。

现在,这个实现有一个小小的缺陷。尽管我们在得分中考虑了时间顺序,但我们仍然有一个固定的校准集。因此,直到我们“重新拟合”或使用最新数据点进行校准之前,我们仍将使用相同的校准数据集。所以,如果你仔细想想,这理想情况下应该以在线方式应用,每次我们预测新的时间步时,前一个时间步(带有实际值)应该添加到校准数据中。我们还提供了一个替代实现,能够以在线方式执行此操作。我们不会详细讨论实现细节,因为核心逻辑是相同的,但 API 是不同的,这使得更新校准数据成为可能。完整的实现可以在文件中的OnlineWeightedConformalPredictor类中找到。

让我们看看它如何使用。首先,我们定义设置,初始化类并进行校准拟合。

from src.conformal.conformal_predictions import OnlineWeightedConformalPredictor
cp = ConformalPrediction(model="LSTM", level=level)
online_weighted_cp = OnlineWeightedConformalPredictor(
    conformal_predictor=cp,
    K=50,
    weight_strategy="uniform",
)
online_weighted_cp.fit(Y_calib_df=Y_calib_df)
joblib.dump(online_weighted_cp, "path/to/saved/file.pkl") 

现在,在推断过程中,我们可以为每个时间步做类似这样的事情:

# Loading the saved model
online_weighted_cp = joblib.load("path/to/saved/file.pkl")
# current timestep data = current
# past timestep actuals = last_timestep_actuals
prediction = online_weighted_cp.predict_one(current_test)
# updating the calibration data using the last timestep actuals
online_weighted_cp.update(last_timestep_actuals) 

对于我们的特殊情况,在测试数据上进行评估并且已知实际值时,还有另一个方法可以为数据执行类似的在线预测:offline_predict

Y_test_df_wcpo = online_weighted_cp.offline_predict(Y_test_df=Y_test_df) 

现在,让我们看最后一个方法。

自适应一致性推断

最后,我们来看一下自适应符合性推断。这也可以作为其他符合性预测方法的封装器来实现,因为该技术涉及更新,以确保在分布发生变化时仍然维持覆盖率。由于该技术的性质,我们只能以在线方式应用它,即在每个时间步根据可用数据更新 alpha。因此,它将拥有与我们之前看到的OnlineWeightedConformalPredictor相同的 API。

完整的类可以在src/conformal/conformal_predictions.py中找到,但在这里,我们将先看一些主要部分,以便你理解。让我们先看看__init__函数:

class OnlineAdaptiveConformalInference:
    def __init__(
        self,
        conformal_predictor: ConformalPrediction,
        gamma: float = 0.005,
        update_method: str = "simple",
        momentum_bw: float = 0.95,
        per_unique_id: bool = True,
    ):
        … 

WeightedConformalPredictor类似,我们传入一个基础的符合性预测器(conformal_predictor)。此外,我们还有gamma,即步长(),update_method,它可以是simple(只使用最后一个时间步进行更新)或momentum(使用误差轨迹的滚动平均)。最后,我们还可以定义momentum_bw,它是用于计算过去误差加权平均的动量反向权重。较高的动量(例如0.95)使得轨迹更平滑,表现为过去的错误覆盖率衰减得较慢。若选择另一极端值(0.05),加权平均将更加敏感,接近于“简单”方法。最后,我们还有一个参数,用于决定是单独计算每个unique_id的误差,还是将所有误差汇总在一起。

和往常一样,我们有一个fit方法,它使用一个标定数据集来计算分数,并将其保留以备后续使用。我们也可以将其视为在线实现中的一个预热阶段。更新将使用标定数据作为初始历史记录开始。

def fit(self, Y_calib_df):
    """
    Fit the conformal predictor model with calibration data.
    """
    self.calib_df = self.conformal_predictor.calculate_scores(Y_calib_df)
    self.scores_by_id = (
        self.calib_df.groupby("unique_id")["calib_scores"].apply(list).to_dict()
    )
    # Some more code to initialize necessary data structures     
    …
    return self 

现在,让我们看看predict_one方法,它用于预测一个时间步。

def predict_one(self, current_test):
    unique_ids = current_test["unique_id"].unique()
    predictions = []
    for unique_id in unique_ids:
        group_scores = self.scores_by_id.get(unique_id, [])
        if group_scores:
            # Determine the appropriate alpha to use
            alpha = (
                self.alphat[unique_id]
                if self.per_unique_id
                else self.alphat_global
            )
            # Calculate quantile for the current unique_id
            self.q_hat = {
                unique_id: np.quantile(group_scores, 1 - alpha, method="higher")
            }
            # Calculating prediction intervals using conformal_predictor
            …
            current_test[lo] = lower.values
            current_test[hi] = upper.values
            # Storing most recent prediction
            …
        # Collecting and returning concatenated predictions
        … 

代码已经有详细注释,你应该能够理解它。对于每个unique_id,我们获取分数,获取相应的 ,计算分位数,利用这些信息通过基础的符合性预测器计算预测区间,并将预测结果存储起来以供后续使用。

现在,我们来看一下update方法,我们可以在时间步的实际值可用时使用它。

def update(self, new_data):
    new_scores = self.conformal_predictor.calculate_scores(new_data)
    for unique_id, score in zip(
        new_scores["unique_id"], new_scores["calib_scores"]
    ):
        # Updating score trajectory with new score
    …
        # Retrieve stored predictions and calculate adapt_err
        if self.per_unique_id:
            lower, upper = self.predictions[unique_id]
            actual_y = new_data.loc[new_data["unique_id"] == unique_id, "y"].values[0]
            adapt_err = int(actual_y < lower or actual_y > upper)
            # Update alpha updates the alpha using simple or momentum method
            self.update_alpha(unique_id, adapt_err)
        else:
            # Do the same update at a global error-pooled way 

让我们看看它是如何使用的。首先,我们定义设置,初始化类,并进行标定。

from src.conformal.conformal_predictions import OnlineAdaptiveConformalInference
cp = ConformalPrediction(model="LSTM", level=level)
aci_cp = OnlineAdaptiveConformalInference(
    conformal_predictor=cp,
    gamma=0.005,
    update_method="simple",
)
aci_cp.fit(Y_calib_df=Y_calib_df)
joblib.dump(aci_cp, "path/to/saved/file.pkl") 

现在,在推理过程中,我们可以对每个时间步执行如下操作:

# Loading the saved model
aci_cp = joblib.load("path/to/saved/file.pkl")
# current timestep data = current
# past timestep actuals = last_timestep_actuals
prediction = aci_cp.predict_one(current_test)
# updating the calibration data using the last timestep actuals
aci_cp.update(last_timestep_actuals) 

对于我们在测试数据上评估且已知真实值的特殊情况,还有一个方法可以执行类似的在线预测:offline_predict

Y_test_df_aci = aci_cp.offline_predict(Y_test_df=Y_test_df) 

现在我们已经看到了所有的技术实现,接下来让我们看看它们的表现如何。

评估结果

如果您一直在关注这些笔记本,您会知道我们一直在计算每种技术的覆盖率和平均长度。简而言之,覆盖率衡量实际值落在我们预测的区间内的百分比,而平均长度则衡量我们预测的区间宽度的平均值。对于level=90,我们期望覆盖率约为 90%或 0.9,且平均长度尽可能小,而不会影响覆盖率。

让我们看一下以下图表,总结了覆盖率和平均长度:

图 17.18:所有保形技术的覆盖率(针对每个unique_id),根据它们与 0.9 的接近程度着色(我们设置了level=90

图 17.19:所有保形技术的平均长度(针对每个unique_id),根据其大小着色

这里是一个快速回顾图例,以帮助理解不同的列,这些列只是根据应用的不同,结合了这些标签:

  • LSTM:我们训练的基础模型,用于获取点预测

  • LSTM_QR:我们训练的基础分位回归模型

  • LSTM_Normal:我们基于正态分布假设训练的基础 PDF 模型

  • CP:常规保形预测

  • CQR:保形化分位回归

  • CUE:保形化不确定性估计

  • CP_Wtd:对保形预测的加权修正

  • CP_Wtd_O:对保形预测的在线加权修正

  • ACI:自适应保形推断

一开始,我们可以看到修正了分布偏移的技术表现最佳。两张表格的右侧有更多的“绿色”(接近 0.9 的覆盖率和较低的平均长度)。记住,我们从常规的保形预测开始,然后对其进行了分布偏移修正。我们可以注意到,对于大多数unique_id,常规保形预测level = 90时的区间比必要的宽。覆盖率大于 0.9,在大多数情况下是 1.0。但加权修正CP_Wtd)和自适应保形推断ACI)使预测区间变得更窄,并且更接近我们期望的水平。这两者之间没有明显的优胜者,需根据您的数据集进行评估。

这也结束了我们关于概率预测的讨论。我们希望通过这部分内容,您能够自信地进入这一较少被关注的领域,为您所预测的对象提供更多价值。

现在,让我们简要地看一下时间序列预测中一些很少被关注,但在许多领域都非常相关的小众话题。

时间序列预测中较少走的路

本节将以罗伯特·弗罗斯特的《未选择的路》为灵感,探讨一些不太为人知但具有深远影响的时间序列预测技术。正如弗罗斯特选择了一条不常走的路,我们也深入探讨那些虽然不主流,但在各个领域中提供独特见解和潜在突破的小众方法。

间歇性或稀疏需求预测

间歇性时间序列预测处理的是具有间歇性、不规则事件的数据,这类数据常见于零售行业,其中产品可能会有不规律的销售。它对库存管理至关重要,可以避免缺货或过度库存,尤其是对于慢速移动的商品。传统方法在处理这些模式时存在困难,因为大多数这类商品的需求预期接近零,但需要专业技术来提高预测的准确性,使得这些技术在零售预测中变得不可或缺。

在这里,我们将快速列举一些为间歇性预测设计的替代算法,并指出它们的实现位置。

  • Croston 及其变种:1972 年开发的 Croston 模型通过分别估计两个组成部分来预测间歇性需求:需求率(即销售发生时)和销售间隔时间,忽略零销售的时期。这些估计值被结合起来,预测未来的需求,因此对于销售不频繁的行业非常有用。然而,该模型没有考虑外部因素或需求模式的变化,这可能会影响其在更复杂情况中的准确性。尽管这是 1972 年开发的方法,但它以多种形式延续至今,并且经过许多修改和扩展。在Nixtlastatsforecast中,我们可以找到CrostonClassicCrostonOptimizedCrostonSBATSB这四种模型,可以像我们在第四章:设定强基线预测中看到的其他模型一样使用。

  • ADIDA聚合-拆解间歇性需求方法ADIDA)结合了简单指数平滑SES)和时间聚合来预测间歇性需求。该方法首先将需求数据聚合成不重叠的时间段,每个时间段的长度等于平均需求间隔MI)。然后,将 SES 应用于这些聚合值以生成预测,接着将预测拆解回原始的时间尺度,从而为每个时间段提供需求预测。此模型在statsforecast中以ADIDA的形式提供。

  • IMAPA间歇性多重聚合预测算法IMAPA)通过在规律的时间间隔内聚合数据,并应用优化的简单指数平滑SES)来预测未来值。IMAPA 高效、对缺失数据具有鲁棒性,并且在各种间歇性时间序列中有效,因此它是一种实际且易于实施的选择。此模型在statsforecast中以IMAPA的形式提供。

可解释性

我们在第十章全局预测模型》中为你推荐了一些机器学习模型的解释性技术。尽管其中一些方法,如 SHAP 和 LIME,仍可应用于深度学习模型,但它们都没有考虑时间维度,因为这些技术最初是为更通用的任务(如分类和回归)而开发的。尽管如此,深度学习模型和时间序列模型的解释性工作已经有所进展。在这里,我列出了一些专门处理时间维度的前沿论文:

虽然这不是一个详尽无遗的清单,但这些是我们认为重要且有前景的一些工作。这个领域正在积极研究,随着时间的推移,会有新的技术出现。

冷启动预测

冷启动预测解决了缺乏历史数据的产品需求预测难题,这是零售、制造业和消费品等行业的常见问题。它通常出现在新产品发布、品牌接入或扩展到新地区时。传统的统计预测模型,如 ARIMA 或指数平滑方法,无法解决这个问题,因为它们需要大量的历史数据。

但并非所有希望都破灭。我们确实有一些方法来处理这种情况:

  • 手动替代映射:如果我们知道一个新产品正在替代另一个产品,我们可以进行手动对齐,将旧产品的历史数据转移到新产品上,并使用常规技术进行预测。

  • 全球机器学习模型:我们在第十章中看到过如何使用全球模型对多个时间序列进行建模。那时,我们使用了一些构造特征,如滞后、滚动聚合等。但如果我们在没有依赖历史的特征的情况下训练这样的模型,并且使用一些描述产品特征的特征(这些特征可以帮助我们与其他更长时间的时间序列进行交叉学习),我们可以利用交叉学习从类似产品中学习预测。但这种方法最适用于替代其他产品的新发布产品。

  • 发布档案模型:如果新产品是全新的,我们可以使用与全球机器学习模型非常相似的设置,但稍作调整。我们可以将时间序列转换为不依赖日期/时间的方式,并将每个时间序列视为从发布日期开始。例如,每个产品发布后的第一个时间步可以是 1,接下来的是 2,依此类推。一旦我们以这种方式转换所有时间序列,我们就得到一个数据集,考虑了新产品发布及其逐步增长的过程。这可以用来训练考虑发布初期增长的模型。

  • 基础时间序列模型:时间序列的基础模型利用大规模预训练模型捕捉不同时间序列任务中的通用模式。这些模型在冷启动预测等应用中非常有效,因为在这种情况下几乎没有历史数据可用。通过使用基础模型,实践者可以将预训练的知识应用于新场景,提高在零售、医疗和金融等行业中的预测任务准确性。基础模型具有灵活性,可以进行微调或零样本应用,因此在处理复杂、稀疏或间歇性数据时尤为有用。许多时间序列的基础模型已可供使用——其中一些是商业产品,另一些是开源的。以下是编写本书时关于基础模型的一个不错的综述:时间序列分析的基础模型:教程与综述:arxiv.org/abs/2403.14735。此类模型的性能最好也只是一般,但在没有任何数据的情况下(冷启动),它仍然是一个值得尝试的不错选择。一些流行的实际应用方法包括:

层级预测

层级预测处理的是可以分解为嵌套层级的时间序列,例如产品类别或地理区域。这些结构要求预测保持一致性,即较低层级的预测应该加起来与较高层级一致,反映出聚合效果。组合不同层级(例如产品类型和地理位置)的时间序列增加了复杂性。目标是生成一致且准确的预测,符合数据的自然聚合,这使得层级预测在处理多个维度的大规模时间序列数据的企业中至关重要。

有一些技术用于分解、聚合或调和所有预测,使它们以逻辑的方式加总起来。Rob Hyndman 的《时间序列预测圣经》第10 章(《预测:原理与实践》)详细讨论了这一点,是加速掌握这一主题的非常好资源(特别提醒:内容偏数学)。你可以在这里找到该章节:otexts.com/fpp2/hierarchical.html。对于更实用的做法,你可以查看 Nixtla 的 hierarchicalforecast 库,地址为:nixtlaverse.nixtla.io/hierarchicalforecast/index.html

至此,本书最长的章节之一终于结束了。恭喜你成功阅读并消化了所有信息。欢迎随时使用笔记本,并尝试不同的代码选项等,以更好地理解发生了什么。

摘要

在本章中,我们探讨了生成概率预测的不同技术,如概率密度函数、分位数函数、蒙特卡罗 Dropout 和一致性预测。我们深入研究了每一种技术,并学习了如何将其应用于实际数据。在一致性预测这一活跃研究领域中,我们学习了不同的方法来使预测区间符合不同的基本机制,比如一致性分位回归、一致性不确定性估计等。最后,我们看到了一些调整,使一致性预测在时间序列问题中表现得更好。

总结一下,我们还讨论了一些不太常见的主题,如间歇性需求预测、可解释性、冷启动预测和层次预测。

在本书的下一部分,我们将探讨一些预测的机制,如多步预测、交叉验证和评估。

参考文献

以下是本章的参考文献:

  1. Tony Duan, A. Avati, D. Ding, S. Basu, A. Ng, 和 Alejandro Schuler. (2019). NGBoost: 用于概率预测的自然梯度提升。国际机器学习大会。proceedings.mlr.press/v119/duan20a/duan20a.pdf

  2. Y. Gal 和 Zoubin Ghahramani. (2015). Dropout 作为贝叶斯近似:在深度学习中表示模型不确定性。国际机器学习大会。proceedings.mlr.press/v48/gal16.html

  3. Valentin Flunkert, David Salinas, 和 Jan Gasthaus. (2017). DeepAR: 使用自回归递归网络进行概率预测。国际预测期刊。www.sciencedirect.com/science/article/pii/S0169207019301888

  4. Koenker, Roger. (2005). 分位数回归. 剑桥大学出版社. 第 146-147 页. ISBN 978-0-521-60827-5. www.econ.uiuc.edu/~roger/research/rq/QRJEP.pdf.

  5. Spyros Makridakis, Evangelos Spiliotis, Vassilios Assimakopoulos. (2020). M4 竞赛:100,000 个时间序列和 61 种预测方法. 国际预测学杂志. www.sciencedirect.com/science/article/pii/S0169207019301128.

  6. Loic Le Folgoc 和 Vasileios Baltatzis 和 Sujal Desai 和 Anand Devaraj 和 Sam Ellis 和 Octavio E. Martinez Manzanera 和 Arjun Nair 和 Huaqi Qiu 和 Julia Schnabel 和 Ben Glocker. (2021). MC Dropout 是贝叶斯的吗?. arXiv 预印本 arXiv: Arxiv-2110.04286. arxiv.org/abs/2110.04286.

  7. Nicolas Dewolf 和 Bernard De Baets 和 Willem Waegeman. (2023). 回归问题的有效预测区间. 人工智能评论. doi.org/10.1007/s10462-022-10178-5.

  8. V. Vovk, A. Gammerman, 和 G. Shafer. (2005). 随机世界中的算法学习. Springer. link.springer.com/book/10.1007/b106715.

  9. Anastasios N. Angelopoulos 和 Stephen Bates. (2021). 关于符合预测与无分布不确定性量化的温和介绍. arXiv 预印本 arXiv: Arxiv-2107.07511. arxiv.org/abs/2107.07511.

  10. Harris Papadopoulos, Kostas Proedrou, Volodya Vovk, 和 Alex Gammerman. (2002). 回归的归纳置信度机器. 机器学习: ECML 2002. ECML 2002. 计算机科学讲义系列(), 第 2430 卷. Springer, Berlin, Heidelberg. doi.org/10.1007/3-540-36755-1_29.

  11. Romano, Yaniv 和 Patterson, Evan 和 Candes, Emmanuel. (2019). 标准化分位数回归. 神经信息处理系统进展. proceedings.neurips.cc/paper_files/paper/2019/file/5103c3584b063c431bd1268e9b5e76fb-Paper.pdf.

  12. R. Barber, E. Candès, Aaditya Ramdas, 和 R. Tibshirani. (2022). 超越可交换性的符合预测. 统计年鉴. projecteuclid.org/journals/annals-of-statistics/volume-51/issue-2/Conformal-prediction-beyond-exchangeability/10.1214/23-AOS2276.full.

  13. Gibbs, Isaac 和 Candes, Emmanuel. (2021). 分布变化下的自适应符合推断. 神经信息处理系统进展. proceedings.neurips.cc/paper_files/paper/2021/file/0d441de75945e5acbc865406fc9a2559-Paper.pdf.

进一步阅读

要了解更多本章涉及的主题,请查看以下资源。

加入我们的 Discord 社区

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

packt.link/mts

留下您的评论!

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

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

packt.link/NzOWQ

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

第四部分

预测学原理

在最后一部分,我们介绍了一些对于构建行业级预测系统至关重要的概念。我们讨论了很少有人提及的概念,如多步预测,并深入探讨了如何评估预测的细节。

本部分包含以下章节:

  • 第十八章多步预测

  • 第十九章评估预测误差—预测指标调查

  • 第二十章评估预测—验证策略

第十八章:多步预测

在前面的部分中,我们介绍了一些预测的基础知识和时间序列预测的不同建模技术。然而,一个完整的预测系统不仅仅是模型。时间序列预测的某些机制往往会带来很大的差异。这些内容不能被称为基础,因为它们需要对预测范式有更为精细的理解,这也是我们没有在一开始就介绍它们的原因。

既然你已经进行了一些预测模型的训练,并且熟悉了时间序列,现在是时候在我们的方法上更精细一些了。本书中我们进行的大多数预测练习都集中在预测下一个时间步。在本章中,我们将探讨生成多步预测的策略——换句话说,就是如何预测接下来的H个时间步。在大多数实际的预测应用中,我们需要预测多个时间步的未来,而能够处理这种情况是一个必备的技能。

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

  • 为什么选择多步预测?

  • 标准符号

  • 递归策略

  • 直接策略

  • 联合策略

  • 混合策略

  • 如何选择多步预测策略

为什么选择多步预测?

多步预测任务包括预测时间序列的下一个H个时间步,即y[t][+1],…,y[t][+H],其中y[1],…,y[t]为已知的时间序列,且H > 1。大多数实际的时间序列预测应用都要求进行多步预测,无论是家庭的能源消耗还是产品的销售。这是因为预测的目的从来不是为了知道未来会发生什么,而是为了利用我们获得的可见性来采取行动。

为了有效地采取任何行动,我们希望提前了解预测结果。例如,我们在本书中使用的数据集是关于家庭能源消耗的,每半小时记录一次。如果能源供应商希望计划其能源生产以满足客户需求,那么预测下一个半小时的需求根本没有帮助。同样,如果我们考虑零售场景,假设我们想预测某个产品的销售,我们会希望提前预测几天,以便能够及时采购所需商品,运送到商店等。

尽管多步预测是一个更为普遍的应用场景,但它并没有受到应有的关注。原因之一是经典统计模型或计量经济学模型的存在,如ARIMA指数平滑方法,这些方法将多步策略作为模型的一部分;因此,这些模型可以轻松地生成多个时间步的预测(尽管正如我们将在本章中看到的,它们依赖于一种特定的多步策略来生成预测)。由于这些模型曾是最流行的模型,实践者无需担心多步预测策略。然而,机器学习ML)和深度学习DL)方法在时间序列预测中的出现,再次推动了对多步预测策略的更深入研究。

多步预测较低流行度的另一个原因是它比单步预测要难。这是因为我们向未来推演的步数越多,预测中的不确定性就越大,原因在于不同步之间复杂的交互。根据我们选择的策略,我们将不得不管理对先前预测的依赖、错误的传播和放大等问题。

有许多策略可以用来生成多步预测,以下图表清晰地总结了这些策略:

图 17.1 – 多步预测策略

图 18.1:多步预测策略

图 18.1中的每个节点都是一个策略,具有共同元素的不同策略通过图中的边连接在一起。在本章的其余部分,我们将介绍这些节点(策略),并详细解释它们。

标准符号

让我们建立一些基本符号,帮助我们理解这些策略。我们有一个时间序列,Y[T],共有T个时间步,y[1],…,y[T]。Y[T]表示同一个序列,但结束于时间步t。我们还考虑一个函数,W,它从时间序列中生成大小为k > 0 的窗口。

该函数是我们为本书中所见不同模型准备输入的代理。所以,如果我们看到W(Y[t]),这意味着该函数将从以时间步t结束的Y[T]中提取一个窗口。我们还将H视为预测范围,其中H > 1。我们还将使用;作为运算符,表示连接。

现在,让我们看一下不同的策略(参考1是一篇关于不同策略的优秀综述文章)。关于各自优缺点以及在何种场景下使用它们的讨论将在后续章节中总结。

递归策略

递归策略是生成多步预测的最古老、最直观、最流行的技术。要理解一种策略,我们需要理解两个主要的领域:

  • 训练方案:模型的训练是如何进行的?

  • 预测方案:训练好的模型如何用于生成预测?

让我们借助一个图表来理解递归策略:

图 17.2 – 递归策略的多步预测

图 18.2:多步预测的递归策略

让我们详细讨论这些方案。

训练方案

递归策略涉及训练一个单一模型执行一步预测。我们可以在图 18.2中看到,我们使用窗口函数W(Y[t]),从Y[t]中绘制一个窗口,并训练模型预测Y[t][+1]。

在训练过程中,使用损失函数(衡量模型输出与实际值之间的差异,Y[t][+1])来优化模型的参数。

预测方案

我们已经训练了一个模型来进行一步预测。现在,我们以递归方式使用这个模型生成H时间步的预测。对于第一步,我们使用W(Y[t]),使用训练数据中最新的时间戳生成窗口,并预测一步,。现在,这个生成的预测被添加到历史记录中,并从这个历史记录中绘制一个新窗口,。这个窗口被输入到同一个一步预测模型中,并生成下一个时间步的预测,。这个过程重复进行,直到我们得到所有H时间步的预测。

这是经受时间考验的经典模型(如ARIMA指数平滑)在生成多步预测时内部使用的策略。在机器学习的背景下,这意味着我们将训练一个模型来预测一步,然后进行递归操作,其中我们预测一步,使用新的预测重新计算所有特征,如滞后、滚动窗口等,并预测下一步。该方法的伪代码将是:

# Function to create features (e.g., lags, rolling windows, external features like holidays or item category)
def create_features(df, **kwargs):
    ## Feature Pipeline goes here ##
    # Return features DataFrame
    return features
# Function to train the model
def train_model(train_df, **kwargs):
    # Create features from the training data
    features = create_features(train_df, **kwargs)

    ## Training code goes here ##

    # Return the trained model
    return model
def recursive_forecast(model, train_df, forecast_steps, **kwargs):
    """
    Perform recursive forecasting using the trained one-step model.
    - model: trained one-step-ahead model
    - train_df: DataFrame with time series data
    - forecast_steps: number of steps ahead to forecast
    - kwargs: other parameters necessary like lag size, rolling size etc.
    """  
    forecasts = []
    for step in range(forecast_steps):
        input_features = create_features(train_df, **kwargs)
        ## Replace with actual model.predict() code ##
        next_forecast = model.predict(input_features)
        forecasts.append(next_forecast)
        train_df = train_df.append({'target': next_forecast, "other_features": other_features}, ignore_index=True)

    return forecasts 

在深度学习模型的背景下,我们可以将其视为将预测添加到上下文窗口,并使用训练好的模型生成下一步。该方法的伪代码将是:

def recursive_dl_forecast(dl_model, train_df, forecast_steps, **kwargs):
    """
    - dl_model: trained DL model (e.g., LSTM, Transformer)
    - train_df: DataFrame with time series data (context window)
    - forecast_steps: number of steps ahead to forecast
    - kwargs: other parameters like window size, etc.
    """
    forecasts = []
    # Extract initial context window from the end of the training data
    context_window = train_df['target'].values[-kwargs['window_size']:]
    for step in range(forecast_steps):
        ## Replace with actual dl_model.predict() code ##
        next_forecast = dl_model.predict(context_window)
        forecasts.append(next_forecast)
        # Update the context window by removing the oldest value and adding the new forecast
        context_window = np.append(context_window[1:], next_forecast)

    return forecasts 

请注意,这个伪代码不是可直接运行的代码,而更像是一个您可以根据自己情况调整的框架。现在,让我们看另一种多步预测策略。

直接策略

直接策略,也称为独立策略,是一种在预测中使用机器学习的流行策略。这涉及独立地预测每个时间段。让我们先看一个图表:

图 17.3 – 多步预测的直接策略

图 18.3:多步预测的直接策略

接下来,让我们详细讨论这些方案。

训练方案

在直接策略(图 18.3)下,我们训练H个不同的模型,这些模型接受相同的窗口函数,但被训练以预测预测时间范围内的不同时间步。因此,我们为每个时间步学习一组独立的参数,使得所有模型的组合从窗口W(Y[t])到预测时间范围H之间学习到一个直接且独立的映射。

随着基于机器学习的时间序列预测的流行,这种策略逐渐获得了关注。从机器学习的角度来看,我们可以通过两种方式来实际实现它:

  • 平移目标:每个时间步的模型通过将目标平移与训练预测的时间步数一致来训练。

  • 消除特征:每个时间步的模型通过只使用允许的特征来训练,符合规则。例如,当预测H = 2 时,我们不能使用滞后 1(因为要预测H = 2 时,我们无法获得H = 1 的实际值)。

上述两种方法在我们仅使用滞后作为特征时非常有效。例如,为了消除特征,我们可以简单地删除不合适的滞后并训练模型。但在使用滚动特征和其他更复杂特征的情况下,简单的删除方法并不适用,因为滞后 1 已经用于计算滚动特征,这会导致数据泄露。在这种情况下,我们可以创建一个动态函数来计算这些特征,传入一个参数来指定我们为其创建特征的时间范围。我们在第六章《时间序列预测的特征工程》中使用的所有辅助方法(add_rolling_featuresadd_seasonal_rolling_featuresadd_ewma)都有一个名为n_shift的参数,用于处理这种情况。如果我们为H = 2 训练模型,我们需要传入n_shift=2,然后该方法会处理剩余部分。在训练模型时,我们使用这个动态方法分别为每个时间范围重新计算这些特征。

预测方案

预测方案也相当直接。我们有为每个时间步训练的H个模型,并使用W(Y[t])来独立预测每个模型。

对于机器学习模型,这要求我们为每个时间步训练独立的模型,但scikit-learn中的MultiOutputRegressor使这一过程更加可管理。让我们看一些伪代码:

# Function to create shifted targets for direct strategy
def create_shifted_targets(df, horizon, **kwargs):
    ## Add one step ahead, 2 step ahead etc targets to the feature dataframe ##
    return dataframe, target_cols
def train_direct_ml_model(train_df, horizon, **kwargs):
    # Create shifted target columns for the horizon
    train_df, target_cols = create_shifted_targets(train_df, horizon, **kwargs)
    # Prepare features (X) and shifted targets (y) for training
    X = train_df.loc[:, [c for c in train_df.columns if c not in target_cols]]
    y = train_df.loc[:, target_cols]
    # Initialize a base model (e.g., Linear Regression) and MultiOutputRegressor
    base_model = LinearRegression()  # Example: can use any other model
    multioutput_model = MultiOutputRegressor(base_model)
    # Train the MultiOutputRegressor on the features and shifted targets
    multioutput_model.fit(X, y)
    return multioutput_model
def direct_ml_forecast(multioutput_model, test_df, horizon, **kwargs):
    # Adjust based on how test_df is structured
    X_test = test_df.loc[:, features]
    # (array with H steps)
    forecasts = multioutput_model.predict(X_test)
    return forecasts 

现在,是时候看看另一种策略了。

联合策略

前两种策略考虑模型只有单一输出。这是大多数机器学习模型的情况;我们将模型设计为在接收一组输入后预测一个单一的标量值:多输入单输出MISO)。但也有一些模型,如深度学习(DL)模型,可以配置为提供多个输出。因此,联合策略,也称为多输入多输出MIMO),旨在学习一个单一模型,输出整个预测时间范围:

图 17.4 – 多步预测的联合策略

图 18.4:多步预测的联合策略

让我们看看这些方案是如何工作的。

训练方案

联合策略涉及训练一个单一的多输出模型,一次性预测所有时间步的结果。如图 18.4所示,我们使用窗口函数,W(Y[t]),从Y[t]中提取一个窗口,并训练模型预测y[t][+1],…,y[t][+][H]。在训练过程中,使用一个损失函数,衡量模型所有输出与实际值(y[t][+1],…,y[t][+][H])之间的差异,以优化模型的参数。

预测方案

预测方案也非常简单。我们有一个经过训练的模型,可以预测整个时间范围的所有时间步,并且我们使用W(Y[t])一次性预测所有时间步。

该策略通常用于深度学习模型中,我们配置最后一层以输出H个标量,而不是 1。

我们已经在书中的多个地方看到过这种策略的应用:

  • 表格回归(第十三章时间序列的常见建模模式)范式可以轻松扩展为输出整个预测范围。

  • 我们已经看到过使用这种策略进行多步预测的Sequence-to-Sequence模型,具有全连接解码器(第十三章时间序列的常见建模模式)。

  • 第十四章时间序列的注意力与变换器中,我们使用该策略通过变换器进行预测。

  • 第十六章用于预测的专用深度学习架构中,我们看到像N-BEATSN-HiTSTemporal Fusion Transformer这样的模型,它们使用该策略生成多步预测。

混合策略

我们已经讨论的三种策略是多步预测的三种基本策略,每种策略都有其优缺点。多年来,研究人员尝试将它们结合成混合策略,旨在捕捉每种策略的优点。我们将在这里讨论一些混合策略。这不是一个全面的列表,因为不存在这样的列表。任何有足够创造力的人都可以提出替代策略,但我们将只介绍一些受预测社区关注和深入研究的策略。

DirRec 策略

正如名字所示,DirRec策略是直接递归策略的结合,用于多步预测。直接方法的一个缺点是它独立地预测每个时间步,因此在预测远期时会失去一些上下文。为了解决这个问题,我们通过使用n-步预测模型生成的预测作为n+1步预测模型的特征,将直接方法和递归方法结合起来。

让我们看一下以下图示,并加深对这个概念的理解:

图 17.5 – DirRec 多步预测策略

图 18.5:DirRec 策略用于多步预测

现在,让我们看看这些机制如何在 DirRec 策略中运作。

训练机制

与直接策略类似,DirRec 策略(图 18.5)也有H个模型来进行H步预测,但有所不同。我们从使用W(Y[t])开始,并训练一个模型来预测一步之后的结果。在递归策略中,我们用这个预测的时间步长在同一个模型中预测下一个时间步。而在 DirRec 中,我们为H = 2 训练一个独立的模型,使用在H = 1 时生成的预测结果。为了在时间步h < H进行泛化,除了W(Y[t])外,我们还包括了在时间步 1 到h之间不同模型生成的所有预测结果。

预测机制

预测机制就像训练机制一样,但不是训练模型,而是使用H训练好的模型递归地生成预测。

让我们通过一些高级伪代码来巩固我们的理解:

def train_dirrec_models(train_data, horizon, **kwargs):
    models = []  # To store the trained models for each timestep
    # Train the first model to predict the first step ahead (t+1)
    model_t1 = train_model(train_data)  # Train model for t+1
    models.append(model_t1)
    for step in range(2, horizon + 1):
        previous_forecasts = []
        for prev_model in models:
            # Recursive prediction
            previous_forecasts.append(prev_model.predict(train_data))
        # Use the forecasts as features for the next model
        augmented_train_data = add_forecasts_as_features(train_data, previous_forecasts)
        # Train the next model (e.g., for t+2, t+3, ...)
        model = train_model(augmented_train_data)
        models.append(model)
    return models
def dirrec_forecast(models, input_data, horizon, **kwargs):
    forecasts = []  
    # Generate the first forecast (t+1)
    forecast_t1 = models[0].predict(input_data)
    forecasts.append(forecast_t1)
    # Generate subsequent forecasts recursively
    for step in range(1, horizon):
        augmented_input_data = add_forecasts_as_features(input_data, forecasts)
        next_forecast = models[step].predict(augmented_input_data)
        forecasts.append(next_forecast)
    return forecasts 

现在,让我们学习另一种创新的多步预测方法。

迭代块状直接策略

迭代块状直接IBD)策略也被称为迭代多 SVR 策略,以致敬提出此策略的研究论文(参考文献 2)。直接策略需要训练H个不同的模型,这使得它在长时间跨度的预测中难以扩展。

IBD 策略尝试通过使用块状迭代的预测方式来解决这一短板:

图 17.6 – 用于多步预测的 IBD 策略

图 18.6:IBD 策略用于多步预测

让我们了解这个策略的训练和预测机制。

训练机制

在 IBD 策略中,我们将预测跨度H分成R个长度为L的块,使得H = L x R。我们不再训练H个直接模型,而是训练L个直接模型。

预测机制

在进行预测时(图 18.6),我们使用L个训练好的模型来生成H中前L个时间步(T + 1 到T + L)的预测,使用窗口W(Y[T])。我们将这个L步的预测结果表示为Y[T][+][L]。然后,我们将Y[T][+][L]和Y[T]一起,用于窗口函数生成一个新的窗口W(Y[T];Y[T][+][L])。这个新窗口用于生成接下来的L个时间步(T + LT + 2L)的预测。这个过程会重复多次,直到完成整个预测跨度。

让我们来看一下这个过程的一些高级伪代码:

def train_ibd_models(train_data, horizon, block_size, **kwargs):
    # Calculate the number of models (L)
    n_models = horizon // block_size
    models = []
    # Train a model for each block
    for n in range(n_models):
        block_model = train_direct_model(train_data, n)
        models.append(block_model)
    return models
def ibd_forecast(models, input_data, horizon, block_size, **kwargs):
    forecasts = []
    window = input_data  # Initial window from the time series data
    num_blocks = horizon // block_size
    # Generate forecasts block by block
    for _ in range(num_blocks):
        # Predict the next block of size L using direct models
        block_forecast = []
        for model in models:
            block_forecast.append(model.predict(window))
        # Append the block forecast to the overall forecast
        forecasts.extend(block_forecast)
        # Update the window by including the new block of predictions
        window = update_window(window, block_forecast)
    return forecasts 

现在,让我们转向另一种创造性地混合不同策略的方法。

修正策略

修正策略是我们可以结合直接和递归策略的另一种方式。它通过形成一个两阶段的训练和推断方法,在两者之间找到一个平衡点。我们可以将其视为一种模型堆叠方法(第九章集成与堆叠),但它是应用于不同的多步预测策略。在第一阶段,我们训练一个一步预测模型,并使用该模型生成递归预测。

然后,在第二阶段,我们使用原始窗口和特征以及递归预测来训练针对预测区间的直接模型。

图 17.7 – 修正策略用于多步预测

图 18.7:多步预测的修正策略

让我们详细了解一下这个策略是如何运作的。

训练机制

训练分为两个步骤。递归策略应用于预测区间,并生成所有H时间步长的预测。我们称之为。接着,我们使用原始历史数据Y[t]和递归预测!作为输入,训练针对每个预测区间的直接模型。

预测机制

预测机制与训练过程类似,首先生成递归预测,然后将递归预测与原始历史数据一起用于生成最终预测。

我们来看一下这个策略的高级伪代码:

# Stage 1: Train recursive models
recursive_model, recursive_forecasts = train_one_step_ahead_model(train_data, horizon=horizon)
# Stage 2: Train direct models
direct_models = train_direct_models(train_data, recursive_forecasts, horizon=horizon)
def rectify_forecast(recursive_model, direct_models, input_data, horizon, **kwargs):
    # Generate recursive forecasts using the recursive model
    recursive_forecasts = generate_recursive_forecasts(recursive_model, input_data, horizon)
    # Generate final direct forecasts using original data and recursive forecasts
    direct_forecasts = generate_direct_forecasts(direct_models, input_data, recursive_forecasts, horizon)
    return direct_forecasts
forecast = rectify_forecast(recursive_model, direct_models, train_data, horizon) 

现在,让我们进入我们将要讨论的最后一种策略。

RecJoint

名副其实,RecJoint是递归策略和联合策略的结合,但它适用于多输出模型。它通过利用递归预测的同时,考虑预测区间内多个时间步之间的依赖关系,从而平衡两者的优点。

图 17.8 – RecJoint 策略用于多步预测

图 18.8:RecJoint 策略用于多步预测

以下部分详细介绍了该策略的工作原理。

训练机制

RecJoint 策略中的训练机制(图 18.8)与递归策略非常相似,都是训练一个模型,并通过递归使用t + 1 时刻的预测作为输入来训练t + 2 时刻的预测,依此类推。但递归策略只在下一个时间步上训练模型,而 RecJoint 则生成整个预测区间的预测,并在训练过程中共同优化整个区间的预测。这迫使模型查看接下来的H个时间步,并共同优化整个预测区间,而不是短视地只关注一步之遥的目标。我们在使用 RNN 编码器和解码器训练 Seq2Seq 模型时看到了这个策略的应用(第十三章时间序列的常见建模模式)。

预测机制

RecJoint 的预测机制与递归策略完全相同。

现在我们已经了解了几种策略,接下来让我们讨论它们的优点和缺点。

如何选择多步预测策略

让我们在表格中总结一下我们已经学习的所有不同策略:

图 17.9-多步预测策略-摘要

图 18.9:多步预测策略-摘要

在这里,以下内容适用:

  • S.O:单输出

  • M.O:多输出

  • T[SO]和I[SO]:单输出模型的训练和推理时间

  • T[mO]和I[mO]:多输出模型的训练和推理时间(实际上,T[mO]大多大于T[SO],因为多输出模型通常是 DL 模型,其训练时间高于标准 ML 模型)

  • H:地平线

  • L = H/R,其中R是 IBD 策略中的块数

  • 是一些正实数

该表格帮助我们从多个角度理解和决定哪种策略更好:

  • 工程复杂性递归联合RecJoint << IBD << 直接DirRec << 校正

  • 训练时间递归 << 联合(通常T[mO] > T[SO]) << RecJoint << IBD << 直接DirRec << 校正

  • 推理时间联合 << 直接递归DirRecIBDRecJoint << 校正

它还帮助我们决定我们可以为每种策略使用哪种模型。例如,联合策略只能使用支持多输出的模型,如 DL 模型。然而,我们还没有讨论这些策略如何影响准确性。

尽管在机器学习中,最终的结论取决于实证证据,但我们可以分析不同的方法以提供一些指导方针。Taieb 等人从理论上和使用模拟数据分析了这些多步预测策略的偏差和方差。

通过这种分析,以及多年来的其他实证发现,我们对这些策略的优势和劣势有了一定的了解,并且从这些发现中得出了一些指导方针。

参考检查

Taieb 等人的研究论文被引用在参考文献3中。

Taieb 等人指出递归策略的几个缺点,与基于误差分析的偏差和方差成分对比,与直接策略形成对比。他们通过实证研究进一步证实了这些观察结果。

阐明性能差异的关键点如下:

  • 对于递归策略,步骤h = 1 中的误差的偏差和方差成分会影响步骤h = 2。由于这种现象,递归模型的错误会随着预测地平线的进一步移动而累积。但对于直接策略,这种依赖关系不明显,因此不会像递归策略中看到的那样受到相同的恶化。这也在实证研究中看到,递归策略非常不稳定,方差最高,随着地平线的进一步移动而显著增加。

  • 对于直接策略,h = 1 步骤中的误差的偏差和方差成分不会影响 h = 2。这是因为每个预测期 h 都是孤立预测的。这种方法的一个缺点是,它可能会在不同预测期之间生成完全不相关的预测,导致不现实的预测。直接策略无法捕捉预测期之间可能存在的复杂依赖关系。例如,在具有非线性趋势的时间序列上使用直接策略可能会导致曲线断裂,因为预测期内每个时间步的独立性。

  • 实际上,在大多数情况下,直接策略产生的一致性预测效果较好。

  • 当预测模型产生的预测具有较大变动时,递归策略的偏差也会被放大。已知复杂模型具有低偏差,但有较大的变动,这些较大的变动似乎会放大递归策略模型的偏差。

  • 当我们有非常大的数据集时,直接策略的偏差项会变为零,而递归策略的偏差仍然不为零。实验进一步证明了这一点——对于长时间序列,直接策略几乎总是优于递归策略。从学习理论的角度来看,直接策略使用数据学习 H 个函数,而递归策略只学习一个函数。因此,在相同数据量的情况下,学习 H 个真实函数比学习一个函数更为困难。这在数据量较少的情况下尤为突出。

  • 尽管递归策略在理论和实证上似乎不如直接策略,但它并非没有一些优势:

    • 对于高度非线性和噪声较大的时间序列,学习所有预测期的直接函数可能会很困难。在这种情况下,递归策略可能表现得更好。

    • 如果基础的数据生成过程DGP)非常平滑且容易逼近,递归策略可能表现得更好。

    • 当时间序列较短时,递归策略可能表现得更好。

  • 我们提到过直接策略可能会生成预测期之间不相关的预测,但正是联合策略解决了这一问题。联合策略可以被看作是直接策略的扩展,但它并不是拥有 H 个不同的模型,而是一个模型产生 H 个输出。我们从给定数据中学习一个函数,而不是 H 个函数。因此,联合策略不会像直接策略那样在短时间序列中表现出同样的弱点。

  • 联合策略(和 RecJoint)的一个弱点是对于非常短的预测期(如 H = 2, H = 3 等)的高偏差。我们学习了一个模型,该模型使用标准损失函数(如均方误差)在整个 H 时间步内进行优化。但这些误差的尺度是不同的。随着时间延续,可能发生的误差会比短期的误差大,这隐式地使得模型更加重视较长的预测期;因此,模型学习到的函数倾向于使较长的预测期更加准确。

  • 联合策略和 RecJoint 策略从方差的角度来看是可比的。然而,联合策略可以给我们带来更低的偏差,因为 RecJoint 策略学习的是递归函数,可能没有足够的灵活性来捕捉模式。联合策略充分利用预测模型的全部能力来直接预测未来的时间段。

混合策略,如 DirRec、IBD 等,试图平衡基础策略(如直接预测、递归预测和联合预测)的优缺点。通过这些优缺点,我们可以创建一个有根据的实验框架,以便为当前问题找出最佳策略。

总结

在这一章中,我们探讨了一个与实际应用密切相关但很少被讨论和研究的预测问题。我们了解了为何需要多步预测,并随后回顾了几种可以使用的流行策略。我们探讨了如直接预测、递归预测和联合预测等流行且基础的策略,接着又分析了几种混合策略,如 DirRec、rectify 等。最后,我们讨论了这些策略的优缺点,并提出了选择适合问题的策略的一些指南。

在下一章中,我们将探讨预测的另一个重要方面——评估。

参考文献

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

  1. Taieb, S.B., Bontempi, G., Atiya, A.F., and Sorjamaa, A. (2012). 基于 NN5 预测竞赛的多步时间序列预测策略综述与比较。专家系统应用,39,7067–7083: arxiv.org/pdf/1108.3259.pdf

  2. Li Zhang, Wei-Da Zhou, Pei-Chann Chang, Ji-Wen Yang, 和 Fan-Zhang Li. (2013). 基于多个支持向量回归模型的迭代时间序列预测。神经计算,2013 年第 99 卷: www.sciencedirect.com/science/article/pii/S0925231212005863

  3. Taieb, S.B. and Atiya, A.F. (2016). 多步时间序列预测的偏差与方差分析。IEEE 神经网络与学习系统学报,2016 年 1 月,第 27 卷,第 1 期,62–76 页: ieeexplore.ieee.org/document/7064712

加入我们在 Discord 上的社区

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

packt.link/mts

第十九章:评估预测误差——预测度量的调查

我们在上一章开始探讨预测的细微差别,学习了如何生成多步预测。虽然那涵盖了一个方面,但预测的另一个方面同样重要且令人困惑——如何评估预测

在现实世界中,我们生成预测是为了让一些下游过程能够更好地规划并采取相关行动。例如,一家自行车租赁公司运营经理应决定第二天下午 4 点在地铁站应该提供多少辆自行车。然而,他可能并不只是盲目使用这些预测,而是希望了解哪些预测他应该相信,哪些预测不应该相信。这只能通过衡量预测的准确性来实现。

我们在整本书中已经使用了一些度量,现在是时候深入了解这些度量,了解何时使用它们,何时不使用它们。我们还将通过实验阐明这些度量的一些方面。

本章将涵盖以下几个主要内容:

  • 预测误差度量的分类

  • 研究误差度量

  • 误差度量的实验研究

  • 选择度量的指南

技术要求

你需要按照本书前言中的说明,设置 Anaconda 环境,以便获得包含本书所需的所有软件包和数据集的工作环境。

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

对于本章,你需要运行本书 GitHub 仓库中 Chapters02Chapter04 文件夹里的笔记本。

预测误差度量的分类

测量是通向控制和最终改进的第一步。

– H. James Harrington

传统上,在回归问题中,我们有非常少的通用损失函数,例如均方误差或平均绝对误差,但当你进入时间序列预测的领域时,你会遇到各种各样的不同度量。

由于本书的重点是点预测(而非概率预测),我们将专注于回顾点预测度量。

有一些关键因素区分了时间序列预测中的度量:

  • 时间相关性:我们所做的预测的时间维度是预测范式中的一个重要方面。像预测偏差和跟踪信号这样的度量会考虑到这一方面。

  • 聚合指标:在大多数商业用例中,我们通常不是预测单一的时间序列,而是一组时间序列,这些序列可能相关也可能无关。在这种情况下,查看单个时间序列的指标变得不可行。因此,应该有能够捕捉这些时间序列组合特征的指标。

  • 过度预测或低估预测:时间序列预测中的另一个关键概念是过度预测和低估预测。在传统的回归问题中,我们不太关心预测结果是否大于或小于预期,但在预测范式中,我们必须小心那些总是过度或低估预测的结构性偏差。当这些偏差与时间序列的时间维度相结合时,会积累错误,导致下游规划出现问题。

上述因素以及其他一些因素导致了预测指标数量的爆炸性增长。在 Hewamalage 等人(参考文献1)最近的一篇调查论文中,涵盖的指标数量达到了38。让我们尝试将这些指标统一到某个结构下。图 19.1展示了预测误差度量的分类法:

图 18.1 – 预测误差度量的分类法

图 19.1:预测误差度量的分类法

我们可以语义上将不同的预测指标分为两类——内在外在内在指标仅使用生成的预测和对应的实际值来度量预测的准确性。顾名思义,这是一种非常向内看的指标。而外在指标则除了使用生成的预测和实际数据外,还使用外部参考或基准来衡量预测质量。

在我们开始讨论指标之前,先建立一些符号来帮助我们理解。y[t] 和 分别表示时间 t 的实际观测值和预测值。预测的时间范围用 H 表示。在拥有时间序列数据集的情况下,我们假设有 M 个时间序列,通过 m 索引,最后, 表示时间步 t 的误差。现在,让我们从内在指标开始。

内在指标

内在指标用于评估没有任何外部信息的预测。这些是模型开发、超参数调优等的理想选择。这并不意味着我们不能使用这种类型的指标向非技术人员报告性能,但它必须通过其他基准来进行限定,以展示我们做得如何。

有四种主要的基础误差——绝对误差、平方误差、百分比误差和对称误差——它们以不同的方式在各种指标中被聚合或总结。因此,这些基础误差的任何属性也适用于聚合的误差,因此我们先来看看这些基础误差。

绝对误差

误差,e[t],可以是正数或负数,这取决于是否 ,但是当我们计算并在时间范围内累加这个误差时,正负误差可能会相互抵消,从而描绘出一个过于乐观的画面。因此,我们在 e[t] 上加一个函数,以确保误差不会相互抵消。

绝对函数是以下这些函数之一:绝对误差 (AE) = |e[t]|。绝对误差是尺度依赖性误差。这意味着误差的大小取决于时间序列的尺度。例如,如果你有一个 AE 值为 10,它本身并不意味着什么,直到你把它放到上下文中来看。对于一个值在 500 到 1,000 之间的时间序列,AE 值为 10 可能是一个非常好的数字,但如果时间序列的值在 50 到 70 之间,那么它就是差的。

当我们查看单个时间序列时,尺度依赖性并不是一个致命的问题,但当我们聚合或比较多个时间序列时,尺度依赖性误差会倾向于大规模时间序列,扭曲指标。这里需要注意的有趣一点是,这不一定是坏事。有时候,时间序列中的尺度是有意义的,从业务角度看,更关注大规模时间序列而非小规模时间序列是合理的。例如,在零售场景中,人们更愿意准确预测热销产品的销售,而不是低销量产品。在这些情况下,使用尺度依赖性误差自然会偏向于热销产品。

你可以通过自己进行实验来验证这一点。生成一个随机时间序列,A。然后,同样地,生成这个时间序列的随机预测,F。现在,我们将预测值 F 和时间序列 A 乘以 100,得到两个新的时间序列及其预测值,分别为 A[scaled] 和 F[scaled]。如果我们计算这两组时间序列和预测的预测指标,依赖尺度的指标会给出非常不同的值,而不依赖尺度的指标则会给出相同的值。

许多指标都是基于这种误差的:

  • 平均绝对误差 (MAE):

    • 中位绝对误差MdAE = median(|e[t]|)

    • 几何平均绝对误差

  • 加权平均绝对误差:这是一种更为深奥的方法,允许你在时间范围内对特定的时间步加大权重:

在这里,w[t] 是特定时间步的权重。这可以用来为特殊的日子(如周末或促销日)分配更多的权重。

  • 标准化偏差 (ND):这是一个严格用于计算时间序列数据集上聚合指标的指标。它也是行业中用于衡量不同时间序列聚合性能的流行指标之一。这个指标不是无量纲的,会倾向于较大规模的时间序列。这个指标与另一个指标——加权平均百分比误差 (WAPE) 有着密切的联系。我们将在后续章节讨论这些联系。

为了计算 ND,我们只需将所有时间序列和时间范围内的绝对误差相加,然后通过实际观测值进行缩放:

平方误差

平方是另一种将误差变为正数的函数,从而防止误差相互抵消:

有许多基于此误差的指标:

  • 均方误差

    • 均方根误差 (RMSE):

    • 根中位数平方误差

    • 几何均方根误差

  • 标准化均方根误差 (NRMSE):这个指标与 ND 在本质上非常相似。唯一的区别在于,我们取的是分子中平方误差的平方根,而不是绝对误差:

百分比误差

虽然绝对误差和平方误差是与尺度相关的,但百分比误差是无量纲的误差度量。在百分比误差中,我们使用实际时间序列的观测值来对误差进行缩放:。一些使用百分比误差的指标如下:

  • 均值绝对百分比误差 (MAPE):

  • 中位数绝对百分比误差

  • WAPE:WAPE 是一个承认尺度依赖性的指标,并显式地根据时间步长的尺度加权误差。如果我们希望对时间范围内的高值给予更多关注,可以对这些时间步进行更高的加权。我们不是简单地取平均值,而是使用加权平均来计算绝对百分比误差。权重可以是任何值,但通常情况下,它会选择为观测值的数量。而在这种特殊情况下,数学运算(在某些假设下)会变成一个简单的公式,这个公式与 ND 类似。不同之处在于,ND 是一个在多个时间序列上聚合的指标,而 WAPE 则是一个在时间步长上加权的指标:

对称误差

百分比误差存在一些问题——它是不对称的(我们稍后会在本章中详细讨论),而且当实际观测值为零时,它会崩溃(因为会发生除零错误)。对称误差被提出作为一种替代方法,以避免这种不对称性,但事实证明,对称误差本身也是不对称的——稍后我们会进一步讨论这一点,但现在,让我们先了解什么是对称误差:

在这个基础误差下,只有两种指标是常用的:

  • 对称平均绝对百分比误差 (sMAPE):

  • 对称中位绝对百分比误差

其他内在指标

还有一些其他指标是内在的,但不符合其他指标的标准。最为显著的是三种衡量预测过度或不足预测的指标:

  • 累积预测误差 (CFE):CFE 只是所有误差的总和,包括误差的符号。在这里,我们希望正负误差相互抵消,以便我们理解在给定的预测区间内,预测是持续过度预测还是不足预测。接近零的 CFE 表示预测模型既没有过度预测,也没有不足预测:

  • 预测偏差 (FB):虽然 CFE 衡量的是过度和不足预测的程度,但它仍然依赖于规模。当我们想要跨时间序列进行比较或直观理解过度或不足预测的程度时,可以通过实际观测值来缩放 CFE。这就是所谓的预测偏差:

  • 跟踪信号 (TS):跟踪信号是另一个用于衡量预测中过度和不足预测的指标。虽然 CFE 和预测偏差更多地用于离线处理,但跟踪信号则适用于在线环境,在这种环境下,我们会按周期性时间间隔(如每小时或每周)跟踪过度和不足预测。它帮助我们检测预测模型中的结构性偏差。通常,跟踪信号会与一个阈值一起使用,当超过或低于该阈值时,会触发警告。虽然经验法则是使用 ,但完全由你决定为你的问题选择合适的阈值。值 3.75 源自正态分布的性质,对应于 99% 的置信区间,这意味着该值足够灵敏,当存在结构性偏差时会触发警报,同时避免了假阳性。

但归根结底,这个值应该只是一个起点,用于启动回溯测试,以找出在你的数据中引发正确警报的阈值:

这里,w 是计算 TS 的过去窗口。

现在,让我们将注意力转向一些外部指标。

外部指标

外部度量通过将预测结果与实际结果进行比较,并且与一些外部参考或基准进行对比来评估预测质量。这可以是一个基准模型、行业标准或竞争对手的预测。这些度量更适合向非技术人员报告模型的表现,因为他们可以立即了解模型的效果。如果你有一个现有的预测,并试图改进它,将该预测作为参考会立即让你的度量变得可解释。

外部度量下有两大类度量——相对误差和缩放误差。

相对误差

内在度量的一个问题是,除非存在基准得分,否则它们的意义不大。例如,如果我们听到 MAPE 为 5%,它的意义不大,因为我们不知道该时间序列的可预测性。也许 5% 是一个较差的误差率。相对误差通过在计算中加入基准预测来解决这个问题,这样我们衡量的预测误差就可以与基准进行比较,从而展示预测的相对增益。因此,除了我们已经建立的符号外,我们还需要添加一些内容。

为基准的预测, 为基准误差。我们可以通过两种方式将基准纳入度量中:

  • 使用来自基准预测的误差来缩放预测的误差

  • 使用来自基准预测的预测度量来缩放我们正在衡量的预测的度量

让我们来看一些相对误差:

  • 平均相对绝对误差MRAE):

  • 中位数相对绝对误差

  • 几何平均相对绝对误差

  • 相对平均绝对误差RelMAE):

,其中 是基准预测的 MAE。

  • 相对均方根误差RelRMSE):

,其中 是基准预测的 RMSE。

  • 平均相对绝对误差:Davydenko 和 Fildes(参考文献 2)提出了另一个度量指标,专门用于计算时间序列的汇总得分。他们认为,使用个别时间序列的相对绝对误差(RelMAE)的几何平均值比算术平均值更好,因此他们定义了平均相对绝对误差如下:

缩放误差

Hyndman 和 Koehler 在 2006 年引入了缩放误差的概念。这是相对误差的替代方法,旨在克服选择基准预测时的一些缺点和主观性。缩放误差使用基准方法的样本内 MAE(如天真预测)来缩放预测误差。设整个训练历史为 T 时间步,由 i 索引。

因此,缩放误差定义如下:

有几个度量采用了这一原则:

  • 均值绝对标准化误差MASE):

  • 均方根标准化误差RMSSE):为平方误差开发了一种类似的标准化误差,并在 2020 年的 M5 预测竞赛中使用:

其他外部度量

还有一些不属于我们所做的误差分类的外部度量。一个这样的误差度量如下。

百分比改进PB)是一种基于计数的方法,可以应用于单个时间序列以及时间序列数据集。其思路是使用基准方法,并统计给定方法优于基准的次数,结果以百分比的形式报告。正式地,我们可以使用 MAE 作为参考误差来定义它,公式如下:

这里,是一个指示函数,如果条件为真,则返回 1,否则返回 0。

我们在前面的章节中看到了很多度量,现在是时候进一步理解它们的工作原理以及它们适用的场景了。

调查误差度量

仅仅知道不同的度量是不够的;我们还需要理解它们是如何工作的,适用于什么场景,哪些不适用。我们可以从基本误差开始,逐步了解,因为理解绝对误差平方误差百分比误差对称误差等基本误差的性质,也有助于我们理解其他度量,因为大多数其他度量都是这些基础误差的派生,或者是通过聚合它们或使用相对基准来计算的。

让我们通过一些实验来进行调查,并通过结果来理解它们。

笔记本警告:

用于自行运行这些实验的笔记本是01-Loss_Curves_and_Symmetry.ipynb,位于Chapter19文件夹中。

损失曲线和互补性

所有这些基础误差都依赖于两个因素——预测值和实际观测值。我们可以通过固定一个并改变另一个,在对称的潜在误差范围内检查这些度量的行为。预期是度量在两侧的表现应该相同,因为在任何一侧偏离实际观测值都应该在无偏度量中受到同样的惩罚。我们还可以交换预测值和实际观测值;这同样不应影响度量。

在笔记本中,我们进行了这些实验——损失曲线和互补对。

绝对误差

当我们绘制这些绝对误差时,得到的图形是图 19.2

A graph of error and error  Description automatically generated with medium confidence

图 19.2:绝对误差的损失曲线和互补对

第一张图将带符号的误差与绝对误差进行了绘制,第二张图则将绝对误差与所有实际值和预测值的组合绘制出来,这些组合加起来为 10。两张图显然是对称的,这意味着偏离实际值的误差在两侧受到相同的惩罚,如果我们交换实际观察值和预测值,指标保持不变。

平方误差

现在,让我们来看一下平方误差:

图 18.3 – 平方误差的损失曲线和互补对

图 19.3:平方误差的损失曲线和互补对

这些图表看起来也有对称性,因此平方误差也不存在不对称的误差分布问题——但我们在这里注意到一件事。平方误差随着误差增大而呈指数增长。这揭示了平方误差的一个特性——它对离群值赋予了过高的权重。如果有一些时间步长的预测非常差,而其他所有时间点的预测都非常好,平方误差就会膨胀这些离群误差的影响。

百分比误差

现在,让我们来看一下百分比误差:

图 18.4 – 百分比误差的损失曲线和互补对

图 19.4:百分比误差的损失曲线和互补对

我们的对称性就这样消失了。当你从实际值向两侧偏移时,百分比误差是对称的(主要是因为我们保持实际值不变),但互补对给我们讲述了完全不同的故事。当实际值是 1 而预测值是 9 时,百分比误差为 8,但当我们交换它们时,百分比误差降至 1。这样的不对称性可能导致指标偏向低估预测。在图 19.4的第二张图的右半部分,都是低估预测的情况,我们可以看到,与左半部分相比,那里的误差非常小。

我们将在另一个实验中详细查看低估和高估预测。

对称误差

现在,让我们继续,看看我们遇到的最后一种误差——对称误差:

图 18.5 – 对称误差的损失曲线和互补对

图 19.5:对称误差的损失曲线和互补对

提出对称误差主要是因为我们在百分比误差中看到的不对称性。使用百分比误差的 MAPE 是最流行的指标之一,而 sMAPE 的提出就是为了直接挑战并取代 MAPE。正如它所声称的那样,它确实解决了百分比误差中存在的不对称性。然而,它也引入了自身的不对称性。在第一张图中,我们可以看到,对于特定的实际值,如果预测值在两侧移动,受到的惩罚是不同的,因此,实际上,这个指标偏向高估预测(这与百分比误差偏向低估预测正好相反)。

外部误差

完成所有内在度量之后,我们也可以看看外在度量。对于外在度量,绘制损失曲线并检查对称性并不容易。我们不再仅有两个变量,而是有三个——实际观测值、预测值和参考预测值。度量的值可以随着这些变量中的任何一个变化。我们可以使用等高线图来展示这一点,如图 19.6所示:

图 18.6 – 损失表面的等高线图 – 相对绝对误差和绝对缩放误差

图 19.6:损失表面的等高线图—相对绝对误差和绝对缩放误差

等高线图使我们能够在 2D 图中绘制三维数据。两个维度(误差和参考预测)位于X轴和Y轴上。第三个维度(相对绝对误差和绝对缩放误差值)则通过颜色表示(参考颜色图像文件:packt.link/gbp/9781835883181),等高线将同色区域分开。误差围绕误差(水平)轴对称。这意味着,如果我们保持参考预测不变并改变误差,两个度量在误差两侧变化的幅度是一样的。这并不令人惊讶,因为这两个误差都源于绝对误差,而我们知道绝对误差是对称的。

有趣的观察是对参考预测的依赖性。我们可以看到,对于相同的误差,相对绝对误差在不同的参考预测下具有不同的值,但缩放误差则没有这个问题。这是因为它不直接依赖于参考预测,而是使用了天真的预测的 MAE(平均绝对误差)。这个值对于一个时间序列是固定的,消除了选择参考预测的任务。因此,缩放误差对于绝对误差具有良好的对称性,并且对参考预测的依赖性很小或是固定的。

偏向于过度预测或不足预测

我们在查看的一些指标中看到了偏向过度预测或不足预测的迹象。事实上,看起来流行的指标 MAPE 倾向于不足预测。为了最终验证这一点,我们可以用合成生成的时间序列进行另一个实验;在这个实验中,我们包含了更多的指标,这样我们就能知道哪些指标是安全的,哪些需要仔细查看。

笔记本警告:

你可以在Chapter19文件夹中的02-Over_and_Under_Forecasting.ipynb笔记本上运行这些实验。

实验简单且详细,步骤如下:

  1. 我们从一个均匀分布中随机抽取一个长度为 100 的整数计数时间序列,范围在25之间:

    np.random.randint(2,5,n) 
    
  2. 我们使用相同的过程生成预测值,该值也是从25之间的均匀分布中抽取的:

    np.random.randint(2,5,n) 
    
  3. 现在,我们生成两个额外的预测,一个是从04的均匀分布,另一个是从37的均匀分布。前者主要低估预测,后者则是过度预测:

    np.random.randint(0,4,n)# Underforecast
    np.random.randint(3,7,n) # Overforecast 
    
  4. 我们使用所有三种预测计算我们要调查的所有度量。

  5. 我们重复实验 10,000 次,以平衡随机抽样的影响。

实验完成后,我们可以绘制不同度量的箱型图,展示每个度量在这三种预测中,经过 10,000 次实验运行后的分布情况。让我们看看图 19.7中的箱型图:

图 18.7 – 过度预测与低估预测实验

图 19.7:过度预测与低估预测实验

让我们首先讨论一下我们期望从这个实验中得到的结果。过度预测(绿色)和低估预测(红色)的误差将大于基线(蓝色)。过度预测和低估预测的误差应该是相似的。

总结一下我们的主要发现:

  • MAPE 明显偏向低估预测,其 MAPE 值低于过度预测的 MAPE 值。

  • WAPE,尽管基于百分比误差,但通过明确的加权方式克服了这一问题。这可能抵消了百分比误差所带来的偏差。

  • sMAPE 在尝试修正 MAPE 时,反而在相反方向上表现得更差。sMAPE 强烈偏向于过度预测。

  • 基于绝对误差和平方误差的度量(如 MAE 和 RMSE)不会偏向过度预测或低估预测。

  • MASE 和 RMSSE(都使用了尺度误差的版本)也表现良好。

  • MRAE,尽管在参考预测方面存在一些不对称性,但从过度预测和低估预测的角度来看,结果是无偏的。

  • 基于绝对误差和平方误差的相对度量(RelMAE 和 RelRMSE)也没有对过度预测或低估预测产生偏向。

  • 平均绝对百分比误差的相对度量 RelMAPE,继承了 MAPE 对低估预测的偏向。

我们已经调查了不同错误度量的几个特性,并理解了其中一些度量的基本特性。为了进一步理解并帮助我们选择适合我们问题的度量,让我们做一个使用本书中一直使用的伦敦智能电表数据集的实验。

错误度量的实验研究

正如我们之前讨论的那样,过去几年里,许多人提出了许多不同的预测度量。尽管这些度量有许多不同的公式,但它们所衡量的内容可能是相似的。因此,如果我们在建模时要选择一个主要和次要度量,我们应当选择一些多样的度量,涵盖预测的不同方面。

通过这个实验,我们将尝试找出这些指标之间的相似性。我们将使用本书中一直使用的伦敦智能电表数据集的一个子集,并为每个家庭生成一些预测。我选择使用 darts 库进行这个练习,因为我想进行多步预测。我使用了五种不同的预测方法——季节性朴素法、指数平滑法、Theta、FFT 和 LightGBM(本地)——并生成了预测。除此之外,我还对所有这些预测计算了以下指标:MAPE、WAPE、sMAPE、MAE、MdAE、MSE、RMSE、MRAE、MASE、RMSSE、RelMAE、RelRMSE、RelMAPE、CFE、预测偏差和 PB(MAE)。除了这些,我们还计算了几个汇总指标:meanMASE、meanRMSSE、meanWAPE、meanMRAE、AvgRelRMSE、ND 和 NRMSE。

使用 Spearman 等级相关性

实验的基础是,如果不同的指标度量相同的潜在因子,那么它们也会在不同的家庭中对预测进行相似的排名。例如,如果我们说 MAE 和 MASE 测量的是预测的一个潜在属性,那么这两个指标会对不同家庭给出相似的排名。在汇总层面上,有五个不同的模型和汇总指标,它们度量相同的潜在因子,也应该以相似的方式对它们进行排名。

让我们首先看看汇总指标。我们使用每个指标在汇总层面上对不同的预测方法进行了排名,然后计算了排名的 Pearson 相关性。这给我们提供了预测方法和指标之间的 Spearman 等级相关性。相关性矩阵的热图(请参见彩色图片文件:packt.link/gbp/9781835883181)在图 19.8中:

图 18.8 – 预测方法与汇总指标之间的 Spearman 等级相关性

图 19.8:预测方法与汇总指标之间的 Spearman 等级相关性

主要的观察结论如下:

  • 我们可以看到,meanMASEmeanWAPEND(都基于绝对误差)高度相关,表明它们可能在测量预测的相似潜在因子。

  • 另一个高度相关的指标对是meanRMSSENRMSE,它们都基于平方误差。

  • meanMASEmeanRMSSE 之间有弱相关性,可能是因为它们都使用了缩放误差。

  • meanMRAE预测偏差 似乎高度相关,尽管没有强有力的依据表明它们有共同的行为。这些相关性可能是偶然的,需要在更多数据集上进一步验证。

  • meanMRAEAvgRelRMSE 似乎测量的是与其他指标和彼此非常不同的潜在因子。

同样地,我们计算了所有家庭之间预测方法与度量标准的斯皮尔曼秩相关系数(图 19.9)。这使我们能够在项目级别进行与之前相同的比较:

图 18.9 – 预测方法与项目级度量标准之间的斯皮尔曼秩相关系数

图 19.9:预测方法与项目级度量标准之间的斯皮尔曼秩相关系数

主要观察结果如下:

  • 我们可以看到,有五个高度相关的度量标准簇(五个较深的绿色框)。

  • 第一组是 MASERMSSE,它们高度相关。这可能是因为这两个度量标准都采用了比例误差公式。

  • WAPEMAPEsMAPE 是第二组。坦白说,这有点让人困惑,因为我本以为 MAPEsMAPE 的相关性较低。它们确实从过度预测和欠预测的角度表现得相反。也许我们用来检查这个相关性的所有预测都没有出现过度或欠预测,因此相似性通过共享的百分比误差基础显现出来。这需要进一步调查。

  • MAEMdAEMSERMSE 形成了第三组高度相似的度量标准。MAEMdAE 都是绝对误差度量,而 MSERMSE 都是平方误差度量。它们之间的相似性可能是因为预测中没有异常误差。这两种基本误差的唯一区别在于,平方误差对异常误差赋予了更大的权重。

  • 下一个类似度量标准的组合是各种相对度量——MRAERelMAERelRMSERelMAPEPB(MAE)——但该组之间的相关性不如其他组强。相关性较低的度量标准对是 MRAERelRMSE 以及 RelMAPERelRMSE

  • 最后一组与其他任何度量标准的相关性较低,但彼此之间的相关性较高的是 预测偏差CFE。这两个度量标准都是基于无符号误差计算的,衡量的是过度预测或欠预测的程度。

  • 如果我们查看组间相似性,唯一突出的是比例误差组与绝对误差和平方误差组之间的相似性。

在聚合度量上的斯皮尔曼秩相关性是使用单一数据集计算的,需要谨慎对待。项目级别的相关性具有更大的意义,因为它是基于多个家庭的数据进行计算的,但其中仍有一些内容值得进一步调查。我建议你在其他数据集上重复这个实验,检查是否看到相同的模式再将其作为规则采用。

现在我们已经探讨了不同的度量标准,是时候总结一下,并给出一些选择度量标准的指南了。

选择度量标准的指南

在本章中,我们逐渐认识到,选择一个预测指标并普遍适用是困难的。每个指标都有其优缺点,选择指标时意识到这些优缺点才是唯一理性的做法。

让我们总结一下,并记录在本章中通过不同实验得出的一些要点:

  • 绝对误差和平方误差都是对称损失,且在低估或高估的角度上是无偏的。

  • 平方误差确实有放大离群误差的倾向,因为其中包含平方项。因此,如果我们使用基于平方误差的指标,我们将比小误差更大幅度地惩罚高误差。

  • RMSE 通常优于 MSE,因为 RMSE 与原始输入在同一尺度上,因此它的解释性更强。

  • 百分比误差和对称误差并非在完整意义上是对称的,分别偏向低估和高估。MAPE,作为一种非常流行的指标,存在这一缺点。例如,如果我们在进行需求预测,优化 MAPE 会导致你选择一个保守的预测,从而低估预测值。这将导致库存短缺和缺货情况。尽管存在各种缺点,sMAPE 目前已不再受到实践者的青睐。

  • 相对度量是一个很好的替代百分比误差基础的指标,因为它们也本质上是可解释的,但相对度量依赖于基准方法的质量。如果基准方法表现不佳,相对度量将倾向于减弱模型的误差影响。另一方面,如果基准预测接近近乎零误差的 oracle 预测,相对度量将夸大模型的误差。因此,在选择基准预测时必须小心,这是一个额外的考虑因素。

  • 尽管几何平均数相较于算术平均数有一些优点(例如对离群值的抵抗力和在数据变化剧烈时的更好近似),它也并非没有自身的问题。基于几何平均数的度量意味着,即使单一系列(在聚合时间序列时)或单一时间步(在聚合时间步时)表现得非常好,由于乘法效应,整体误差将大幅下降。

  • PB,尽管是一个直观的指标,但有一个缺点。我们仅仅是在计算我们表现更好的实例次数。然而,它并没有评估我们做得好或不好的程度。无论我们的误差比参考误差低 50% 还是 1%,对 PB 得分的影响是一样的。

Hewamalage 等人(参考文献1)提出了一张非常详细的流程图,帮助决策,但这也更像是一个关于什么不应该使用的指南。选择单一的指标是一个非常有争议的任务。关于这一点有很多相互矛盾的观点,我仅仅是其中的一个声音。以下是我提出的几条建议,帮助你选择合适的预测指标:

  • 避免使用MAPE。无论在哪种情况下,总有更好的指标来衡量你想要的结果。至少,单一时间序列数据集要坚持使用WAPE

  • 对于单一时间序列数据集,最佳的选择指标是MAERMSE(取决于你是否希望更多地惩罚较大的误差)。

  • 对于多个时间序列数据集,使用NDNRMSSE(取决于你是否希望更多地惩罚较大的误差)。作为第二选择,meanMASEmeanRMSSE也可以使用。

  • 如果时间序列中发生了较大变化(在我们测量的时间范围内,时间序列水平发生了巨大变化),可以使用PBMRAE等指标。

  • 无论你选择哪种指标,始终确保使用预测偏差CFE或跟踪信号来关注结构性过度或不足预测的问题。

  • 如果你要预测的时间序列是间歇性的(即,具有很多值为零的时间步),使用RMSE并避免使用MAEMAE倾向于偏好生成全零的预测结果。避免使用基于百分比误差的所有指标,因为间歇性揭示了这些指标的另一个缺点——当实际观测值为零时,它们是未定义的(进一步阅读部分有一个链接,探讨了间歇性序列的其他指标)。

恭喜你完成了这一章,里面有许多新的术语和指标。希望你已经获得了必要的直觉,能够智能地选择下一个预测任务中应该专注的指标!

总结

在本章中,我们探讨了一个人口密集且争议颇多的预测指标领域。我们从预测度量的基本分类法开始,帮助你对这一领域的所有指标进行分类和整理。

然后,我们通过一些实验,了解了这些指标的不同特性,逐渐接近对这些指标测量内容的更好理解;通过查看合成时间序列实验,我们了解到MAPEsMAPE分别偏向于低估和高估预测。

我们还分析了这些指标在真实数据上的排名相关性,看看不同指标之间的相似性,最后通过列出一些指南来帮助你为你的问题选择一个合适的预测指标。

在下一章(我们的最后一章),我们将讨论时间序列的交叉验证策略。

参考文献

以下是我们在本章中使用的参考文献:

  1. Hewamalage, Hansika; Ackermann, Klaus; 和 Bergmeir, Christoph. (2022). 数据科学家的预测评估:常见陷阱与最佳实践。arXiv 预印本 arXiv: Arxiv-2203.10716:arxiv.org/abs/2203.10716v2

  2. Davydenko, Andrey 和 Fildes, Robert. (2013). 衡量预测准确性:对 SKU 级需求预测的判断性调整案例研究。发表于 国际预测学期刊。第 29 卷,第 3 期,2013 年,页码 510-522:doi.org/10.1016/j.ijforecast.2012.09.002

  3. Hyndman, Rob J. 和 Koehler, Anne B. (2006). 重新审视预测准确性度量。发表于 国际预测学期刊,第 22 卷,第 4 期,2006 年,页码 679-688:robjhyndman.com/publications/another-look-at-measures-of-forecast-accuracy/

进一步阅读

加入我们的 Discord 社区

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

packt.link/mts

留下评论!

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

扫描二维码或访问链接以获得您选择的免费电子书。

packt.link/NzOWQ

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

第二十章:评估预测—验证策略

在过去几章中,我们一直在关注一些相关但很少讨论的时间序列预测方面。虽然我们在前一章中学习了不同的预测指标,但现在我们要进入拼图的最后一块——验证策略。这是评估预测的另一个重要部分。

在本章中,我们尝试回答问题:如何选择验证策略来从时间序列预测的角度评估模型? 我们将查看不同的策略及其优缺点,以便在本章结束时,您能够做出明智的决策,为您的时间序列问题设置验证策略。

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

  • 模型验证

  • 留出法策略

  • 交叉验证策略

  • 选择验证策略

  • 多时间序列数据集的验证策略

技术要求

您需要通过按照本书前言中的说明设置 Anaconda 环境,才能获得一个具备本书中所需所有软件包和数据集的工作环境。

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

模型验证

第十九章评估预测误差预测指标概述》中,我们学习了不同的预测指标,这些指标可以用来衡量预测的质量。其主要用途之一是衡量我们的预测在测试数据(新的、未见过的数据)上的表现,但这通常是在我们训练模型、调整模型、反复修改直到满意后进行的。那么我们如何知道一个正在训练或调整的模型是否足够好呢?

模型验证是使用数据评估训练模型的一种过程,用来评估模型的好坏。我们使用在第十九章评估预测误差预测指标概述》中学到的指标来计算预测的准确度。但有一个问题我们还没有解答:我们应该使用数据的哪一部分来进行评估?在标准的机器学习设置中(分类或回归),我们随机抽取一部分训练数据作为验证数据,所有的建模决策都是基于这些数据做出的。行业中的最佳实践是使用交叉验证。交叉验证是一种重采样程序,我们在多个迭代中从训练数据集中抽取不同部分的数据进行训练和测试。除了重复评估,交叉验证还能够最有效地利用数据。

然而,在时间序列预测领域,并不存在这种最佳实践的共识。这主要是由于时间序列的时间性以及我们可以采取的多种方式的差异。不同的时间序列可能有不同的历史长度,我们可能选择不同的建模方式,或者可能有不同的预测时段,等等。由于数据中的时间依赖性,标准的独立同分布(i.i.d.)假设不再成立;因此,像交叉验证这样的技术就会面临其自身的复杂性。当数据集被随机选择时,验证集和训练集可能不独立,这将导致对误差的估计过于乐观并且具有误导性。

验证的主要范式有两种:

  • 样本内验证:顾名思义,模型是在与训练数据相同或相同数据子集上进行评估的。

  • 样本外验证:在这一范式下,我们用来评估模型的数据与用于训练模型的数据没有交集。

样本内验证帮助你了解模型在已知数据上的拟合效果。这在统计学时代非常流行,当时的模型设计非常精细,主要用于推理而非预测。在这种情况下,样本内误差显示了指定模型如何拟合数据,以及我们从该模型中推导出的推论的有效性。但在预测范式中,像大多数机器学习一样,样本内误差并不是衡量模型好坏的正确标准。复杂的模型可以轻松地拟合训练数据,记住数据,却在新数据和未见过的数据上表现不佳。因此,样本外验证几乎是当今预测模型评估中唯一使用的验证方式。由于本书专注于预测任务,我们将仅使用样本外评估。

如前所述,为预测问题选择验证策略并不像标准机器学习那样简单。这里有两种主要的思想流派:

  • 基于保留的策略,尊重问题的时间完整性

  • 基于交叉验证的策略,在这种策略中,验证划分没有或者只有非常宽松的时间顺序

让我们讨论每个类别中的主要策略。我们必须记住的是,本书中讨论的所有验证策略并非详尽无遗,它们只是一些流行的策略。在接下来的解释中,验证期的长度是L[v],训练期的长度是L[t]。

现在,让我们看看第一个思想流派。

保留策略

保留策略有三个方面,它们可以组合搭配,创造出许多不同的策略变体。例如,我们可以有一个固定划分的采样策略,一个滚动窗口的训练数据,以及每次迭代时重新校准模型。三个方面如下:

  • 采样策略:采样策略是指我们如何从训练数据中抽取验证集。

  • 窗口策略:窗口策略决定了我们如何从训练数据中抽取训练集的窗口。

  • 校准策略:校准策略决定了是否需要重新校准模型。

也就是说,为时间序列问题设计一个留出验证策略需要在这三个方面做出决策。

采样策略是从训练数据中选取一个或多个原点的方法。这些原点是确定验证集起点和训练集终点的时间点。验证集的确切长度由参数L[v]决定,这是为验证选择的时间范围。训练集的长度则取决于窗口策略。

窗口策略

我们可以通过两种方式来设置训练集窗口——扩展窗口和滚动窗口。图 20.1展示了两种设置之间的区别:

图 19.1 – 扩展(左)与滚动(右)策略

图 20.1:扩展(左)与滚动(右)窗口策略

在扩展窗口策略下,训练集会随着原点向前推进而扩展。换句话说,在扩展窗口策略下,我们选择所有在原点之前可用的数据作为训练集。每当原点向前推进时,训练长度就会有效增加。

在滚动窗口策略中,我们保持训练集的长度不变(L[t])。因此,当我们将原点向前推进三个时间步时,训练集会从时间序列的开始处删除三个时间步的数据。

尽管扩展窗口和滚动窗口的概念可能会让你联想到我们在特征工程或深度学习模型中使用的窗口,但这两者并不相同。本章中讨论的窗口是我们为训练模型所选择的训练数据窗口。例如,机器学习模型的特征可能仅延伸到前 5 天,我们可以使用过去 5 年的数据来划分训练集。

这两种窗口策略各有优缺点。让我们通过几个关键点来总结它们:

  • 扩展窗口适用于短时间序列,其中扩展窗口可以为模型提供更多的数据。

  • 滚动窗口会从训练集中移除最旧的数据。如果时间序列是非平稳的,并且行为会随着时间的推移发生变化,那么使用滚动窗口有助于保持模型的最新状态。

  • 当我们在交叉验证等重复评估中使用扩展窗口策略时,训练中使用的时间序列长度的增加可能会引入一些偏差,尤其是对历史较长的窗口有偏好。滚动窗口策略通过保持时间序列的相同长度来解决这种偏差。

现在,让我们来看看验证策略的另一个方面。

校准策略

校准策略仅在我们使用不同原点进行多次评估的情况下有效。我们可以用两种方式进行不同原点的评估——重新校准每个原点,或者更新每个原点(Tashman 术语,参考1)。

重新校准策略下,模型会在每个原点使用新的训练拆分进行重新训练。这个重新训练的模型将用于评估验证拆分。而对于更新策略,我们不会重新训练模型,而是使用已训练的模型来评估新的验证拆分。

让我们总结几个选择策略时需要考虑的关键点:

  • 黄金标准是重新校准每个新的原点,但很多时候这可能不可行。在计量经济学/经典统计模型中,标准是重新校准每个原点。这是可行的,因为这些模型相对计算负担较小,而当时的数据集也较小。因此,可以在非常短的时间内重新拟合模型。如今,数据集的规模和模型的规模都大幅增长。每次移动原点时重新训练深度学习模型可能没有那么容易。

因此,如果你使用的是现代的、复杂的模型,并且训练时间较长,那么更新策略可能会更好。

  • 对于运行速度较快的经典模型,我们可以探索重新校准策略。然而,如果你要预测的时间序列变化如此之快,行为频繁变化,那么重新校准策略可能更合适。

现在,让我们进入验证策略的第三部分。

采样策略

在保留策略中,我们会在时间序列中采样一个点(原点),最好选择接近时间序列末尾的位置,使得原点之后的时间序列部分比原点之前的部分短。从这个原点开始,我们可以使用扩展窗口滚动窗口策略来生成训练和验证拆分。模型在训练拆分上进行训练,并在保留的验证拆分上进行测试。这个策略被简单地称为保留策略。校准策略固定为重新校准,因为我们只对模型进行一次测试和评估。

简单的保留策略有一个缺点——我们在保留数据上计算的预测指标可能不足够稳健,因为它依赖于单一的评估范式。我们依赖于数据的单一拆分来计算模型的预测性能。对于非平稳时间序列,这可能成为一个问题,因为我们可能选择了一个只捕捉到我们所选拆分特性的模型。

我们可以通过多次重复 Holdout 评估来克服这个问题。我们可以根据业务领域知识定制不同的起始点,例如考虑季节性或其他因素,或者我们可以随机抽样起始点。如果我们重复这个过程n次,就会有n个验证拆分,它们可能会重叠,也可能不会。来自这些重复试验的性能指标可以使用均值、最大值和最小值等函数进行聚合。这就是重复 HoldoutRep-Holdout)策略。

关于实现的说明:

简单的 Holdout 策略非常容易实现,因为我们决定验证拆分的大小,并将时间序列的末尾部分保留作为验证集。Rep-Holdout策略涉及随机抽样多个窗口或使用预定义的窗口作为验证拆分。我们可以使用 scikit-learn 中的PredefinedSplit类来实现这一点。

图 20.2展示了使用扩展窗口方法的两种 Holdout 策略:

图 19.2 – Holdout 策略 (a) 和 Rep-Holdout 策略 (b)

图 20.2:Holdout 策略 (a) 和 Rep-Holdout 策略 (b)

Rep-Holdout 策略有一些变种。基础的Rep-Holdout策略评估多个验证数据集,通常是手工构建的,且验证数据集可能会重叠。一种变种策略则要求多个验证拆分之间不应有重叠,这是一个更受欢迎的选择。我们称之为重复 Holdout(无重叠)(Rep-Holdout-O)。它继承了交叉验证家族的一些特性,并试图更系统地利用更多数据。图 20.3 (a)展示了这一策略:

图 19.3 – Rep-Holdout 策略的变种

图 20.3:Rep-Holdout 策略的变种

Rep-Holdout-O策略在使用TimeSeriesSplit类对单一时间序列数据集进行操作时非常容易实现。

笔记本提醒:

相关的笔记本,展示了如何实现不同的验证策略,可以在Chapter20文件夹下找到,文件名为01-Validation_Strategies.ipynb

sklearn.model_selection中的TimeSeriesSplit类实现了 Rep-Holdout 验证策略,甚至支持扩展或滚动窗口变种。主要参数是n_splits,它决定了你希望从数据中拆分多少个部分,验证拆分的大小则会根据以下公式自动决定:

在默认配置下,这实现了一个扩展窗口的 Rep-Holdout-O 策略。但是有一个max_train_size参数。如果我们设置这个参数,那么它将以滚动窗口的方式使用max_train_size大小的窗口。

另一种 Rep-Holdout 策略的变体引入了长度为L[g]的间隔,将训练集和验证集之间进行分隔。这是为了增加训练集和验证集之间的独立性,从而通过该过程获得更好的误差估计。我们称这种策略为带间隔的重复保留法(Rep-Holdout-O(G))。该策略在图 20.3 (b)中进行了展示。

我们也可以使用TimeSeriesSplit类来实现这一点。我们需要做的只是使用一个叫做gap的参数。默认情况下,gap 设置为 0。但是如果我们更改为非零值,它将在训练的结束和验证的开始之间插入相应的时间步长间隔。

在继续讲解下一组策略之前,让我们总结并讨论一下关于保留法策略的一些关键点:

  • 保留法策略尊重问题的时间完整性,并且长期以来一直是评估预测模型的首选方法。然而,它们在有效利用可用数据方面存在不足。对于短时间序列,保留法或 Rep-Holdout 可能没有足够的训练数据来训练模型。

  • 简单的保留法依赖于单次评估,且误差估计不够稳健。即便在平稳序列中,这种方法也无法保证获得准确的误差估计。而在非平稳时间序列中,例如季节性时间序列,这个问题更加严重。但 Rep-Holdout 及其变体能够解决这个问题。

现在,我们来看看另一种主要的思路。

交叉验证策略

交叉验证是评估标准回归和分类方法时最重要的工具之一。其原因有两个:

  • 简单的保留法方法并未利用所有可用数据,在数据稀缺的情况下,交叉验证能够更好地利用现有的数据。

  • 从理论上讲,我们观察到的时间序列是一个随机过程的一个实现,因此从数据中获得的误差度量也是一个随机变量。因此,采样多个误差估计值来了解该随机变量的分布是非常必要的。从直观上讲,我们可以将其理解为“缺乏可靠性”,即单一数据切片获得的误差度量的不确定性。

在标准机器学习中最常用的策略叫做k 折交叉验证。在这种策略下,我们随机打乱并将训练数据分成k个相等的部分。现在,整个模型训练和误差计算的过程会重复k次,每次我们选择一个k子集作为测试集,且仅使用一次。当我们使用某个子集作为测试数据时,我们会将其他所有子集作为训练数据。获得k个不同的误差估计值后,我们会使用平均等函数对其进行聚合。这个均值通常比单一误差度量更稳健。

然而,有一个假设对有效性过程至关重要:独立同分布(i.i.d.)样本。这个假设在时间序列问题中是无效的,因为根据定义,时间序列中不同的样本通过自相关性相互依赖。

有人认为,当我们使用时间延迟嵌入将时间序列转换为回归问题时,可以开始在时间序列问题上使用 k-fold 交叉验证。尽管在理论上存在明显的问题,Bergmeir 等人(参考文献 2)表明,从经验上看,k-fold 交叉验证并不是一个坏的选择,但需要注意的是,时间序列必须是平稳的。我们将在下一节中详细讨论这个问题,在那里我们将讨论这些策略的优缺点。

然而,针对 k-折策略,特别是针对顺序数据,已经有了一些修改。

Snijders 等人(参考文献 4)提出了一种修改方案,我们称之为 阻塞交叉验证 (Bl-CV) 策略。它与标准的 k-fold 策略类似,但我们在将数据集划分为 k 个长度为 L[v] 的子集之前不会随机打乱数据集。因此,这种划分策略会导致 k 个连续的观测块。然后,像标准的 k-fold 策略一样,我们对这些 k 个块进行训练和测试,并将多个评估的误差度量进行聚合,从而部分地满足问题的时间完整性要求。

换句话说,时间完整性在每个块内部得到保持,但块与块之间则不然。图 20.4 (a) 显示了这一策略:

图 19.4 – Bl-CV 策略

图 20.4:Bl-CV 策略

要实现 Bl-CV 策略,我们可以使用 scikit-learn 中相同的 Kfold 类。如前所述,scikit-learn 中交叉验证类的主要参数是 n_splits。在这里,n_splits 还定义了它选择的相等大小的折叠数。另一个参数是 shuffle,默认设置为 True。如果我们确保数据按照时间排序,然后使用 shuffle=FalseKfold 类,它将模仿 Bl-CV 策略。相关的笔记本中展示了这种用法。我强烈建议你查看笔记本,深入理解它是如何实现的。

在上一节中,我们讨论了在训练集和验证集之间引入间隙,以增加它们之间的独立性。Bl-CV 的另一个变体是使用这些间隙的版本。我们称之为 带间隙的阻塞交叉验证 (Bl-CV(G))。我们可以在 图 20.4 (b) 中看到这种方法的实际应用。

不幸的是,scikit-learn 中的 Kfold 实现不支持这种变体,但扩展 Kfold 实现以包含间隙是很简单的。相关的笔记本中有这种实现。它有一个额外的参数 gap,让我们可以设置训练集和验证集之间的间隙。

我们已经看到许多不同的验证策略;现在让我们尝试列出一些要点,帮助你决定适合你问题的正确策略。

选择验证策略

选择合适的验证策略是机器学习工作流程中最重要但常被忽视的任务之一。一个好的验证设置将在建模过程的各个步骤中发挥重要作用,比如特征工程、特征选择、模型选择和超参数调优。虽然在设置验证策略时没有硬性规则,但我们可以遵循一些指导原则。其中一些来自经验(包括我自己的和他人的),而另一些则来自已发布的实证和理论研究论文:

  • 设计的一个指导原则是,我们尽量让验证策略尽可能模拟模型的实际使用。例如,如果模型将用于预测接下来的 24 个时间步长,我们将验证集的长度设置为 24 个时间步长。当然,这并不是那么简单,因为设计验证策略时还需要考虑其他实际约束条件,如足够的数据、时间和计算资源。

  • 尊重时间序列问题时间顺序的 Rep-Holdout 策略是首选,尤其是在有足够数据的情况下。

  • 对于纯自回归形式的平稳时间序列,可以使用常规的Kfold,并且 Bergmeir 等人(参考文献 2)通过实证研究表明,它们的表现优于保持策略。但是,Bl-CV 在交叉验证策略中是一个更好的选择。Cerqueira 等人(参考文献 3)在他们的平稳时间序列实证研究中证实了这一发现。

  • 如果时间序列是非平稳的,Cerqueira 等人通过实证研究表明,保持策略(特别是 Rep-Holdout 策略)是最优的选择。

  • 如果时间序列较短,在将时间序列平稳化后使用 Bl-CV 是自回归模型(如时间延迟嵌入)的一种好策略。然而,对于那些使用历史记忆来预测的模型,如指数平滑或深度学习模型(如 RNN),交叉验证策略可能并不安全。

  • 如果我们除了自回归部分还具有外生变量,那么使用交叉验证策略可能并不安全。最好坚持基于保持的策略。

  • 对于强季节性的时间序列,使用模拟预测视野的验证期是有益的。例如,如果我们预测的是十月、十一月和十二月,那么检查去年十月、十一月和十二月的模型表现会很有帮助。

到目前为止,我们一直在讨论单一时间序列的验证策略。但在全球模型的背景下,我们已经到了需要考虑这些情况的验证策略的阶段。

针对具有多个时间序列的数据集的验证策略

直到现在为止,我们所见过的所有策略对于具有多个时间序列的数据集都是完全有效的,例如我们在本书中使用的伦敦智能电表数据集。我们在上一节讨论的见解也是有效的。由于我们讨论的 scikit-learn 类适用于单一时间序列,因此这些策略的实现可能稍显复杂。这些实现假设我们只有一个单独的时间序列,并且按时间顺序排序。如果有多个时间序列,划分将会杂乱无章。

对于具有多个时间序列的数据集,我们可以采用几种选择:

  • 我们可以遍历不同的时间序列,使用我们讨论过的方法进行训练-验证划分,然后将结果集跨所有时间序列连接起来。但这样做并不高效。

  • 我们可以编写一些代码并设计验证策略,以使用日期时间或时间索引(例如我们在第十五章中看到的 PyTorch 预测中的时间索引,“全球深度学习预测模型的策略”)。我在本章的进一步阅读部分提供了一个来自Konrad Banachewicz的精彩笔记本链接,他使用了一个自定义的GroupSplit类,将时间索引用作分组。这是对具有多个时间序列的数据集使用 Rep-Holdout 策略的一种方式。

对于具有多个时间序列的数据集,我们需要记住几个要点:

  • 不要为不同的时间序列使用不同的时间窗口。因为不同的时间窗口会产生不同的误差,这会扭曲我们跟踪的总体误差度量。

  • 如果不同的时间序列具有不同的长度,请对所有序列的验证期长度进行对齐。训练长度可以不同,但验证窗口应该相同,以便每个时间序列对总误差度量的贡献相等。

  • 很容易被复杂的验证方案所吸引,但始终要记住选择特定技术所带来的技术债务。

到这里,我们已经结束了一个简短但重要的章节。

总结

我们已经结束了在时间序列预测领域的探索。在过去的几个章节中,我们讨论了预测的一些机制,比如如何进行多步预测以及如何评估预测。当前章节讨论的主题是评估预测和预测模型的不同验证策略。

我们首先通过阐明模型验证为何是一个重要任务来开始。接着,我们探讨了几种不同的验证策略,例如留出法,并讨论了时间序列中交叉验证的争议性使用。我们花了一些时间总结并提出了几个选择验证策略时的指导方针。最后,我们讨论了这些验证策略如何适用于包含多个时间序列的数据集,并谈到了如何将其应用到这类场景中。

至此,我们来到了本书的结尾。恭喜你顺利读完,希望你从本书中获得了足够的技能,以应对下一个出现的时间序列问题。我强烈建议你开始将本书中学到的技能付诸实践,因为正如理查德·费曼所说的那样,“你不知道任何东西,直到你付诸实践。”

参考文献

以下是本章引用的参考文献:

  1. Tashman, Len. (2000). 样本外预测精度测试:分析与评述. 《国际预测学杂志》16 卷, 437–450,10.1016/S0169-2070(00)00065-0:www.researchgate.net/publication/223319987_Out-of-sample_tests_of_forecasting_accuracy_An_analysis_and_review

  2. Bergmeir, Christoph 和 Benítez, José M. (2012). 关于时间序列预测评估中交叉验证的使用. 载于《信息科学》期刊,191 卷,2012 年,第 192–213 页:www.sciencedirect.com/science/article/abs/pii/S0020025511006773

  3. Cerqueira, V., Torgo, L., 和 Mozetič, I. (2020). 评估时间序列预测模型:关于性能估计方法的实证研究. 机器学习 109, 1997–2028 (2020): doi.org/10.1007/s10994-020-05910-7

  4. Snijders, T.A.B. (1988). 关于时间序列预测评估中的交叉验证. 收录于:Dijkstra, T.K. (编辑) 关于模型不确定性及其统计学意义. 经济学与数学系统讲义,卷 307。Springer 出版社,柏林,海德堡。doi.org/10.1007/978-3-642-61564-1_4

深入阅读

加入我们的社区,参加 Discord 讨论

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

packt.link/mts

留下评论!

感谢你购买 Packt 出版的这本书——希望你喜欢它!你的反馈对我们至关重要,帮助我们改进和成长。请花几分钟时间在 Amazon 评价上留下你的评论;这只需要一分钟,但对像你这样的读者来说却意义重大。

扫描下方的二维码,免费领取你选择的电子书。

A qr code with black squares Description automatically generated

packt.link/NzOWQ

packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助你规划个人发展并推动职业生涯。欲了解更多信息,请访问我们的网站。

为什么要订阅?

  • 通过来自超过 4,000 位行业专家的实用电子书和视频,减少学习时间,增加编程时间

  • 使用为你特别设计的技能计划提升你的学习效果

  • 每月免费获取一本电子书或视频

  • 完全可搜索,方便快速访问重要信息

  • 复制、粘贴、打印和书签内容

www.packt.com,你还可以阅读免费的技术文章,注册各种免费的电子邮件通讯,获取 Packt 书籍和电子书的独家折扣和优惠。

你可能喜欢的其他书籍

如果你喜欢这本书,你可能会对 Packt 出版的以下其他书籍感兴趣:

Pandas Cookbook

威廉·艾德、马修·哈里森

ISBN:9781836205876

  • pandas 类型系统以及如何最好地导航它

  • 导入/导出 DataFrame 到/从常见的数据格式

  • 通过数十个实践问题进行 pandas 数据探索

  • 分组、聚合、转换、重塑和过滤数据

  • 通过 pandas 类 SQL 操作合并来自不同来源的数据

  • 在高级分析中利用强大的 pandas 时间序列功能

  • 扩展 pandas 操作,充分利用你的系统性能

  • pandas 可以与之协同工作并补充的大型生态系统

Mastering PyTorch

阿希什·兰贾·贾

ISBN:9781801074308

  • 使用 PyTorch 实现文本、视觉和音乐生成模型

  • 在 PyTorch 中构建深度 Q 网络(DQN)模型

  • 在移动设备(安卓和 iOS)上部署 PyTorch 模型

  • 使用 fastai 在 PyTorch 中熟练进行快速原型设计

  • 使用 AutoML 有效进行神经网络架构搜索

  • 使用 Captum 轻松解释机器学习模型

  • 设计 ResNet、LSTM 和图神经网络(GNN)

  • 使用 Hugging Face 创建语言和视觉转换器模型

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并立即申请。我们已经与成千上万的开发者和技术专业人士合作,帮助他们将自己的见解与全球技术社区分享。你可以进行一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

posted @ 2025-07-16 12:31  绝不原创的飞龙  阅读(226)  评论(0)    收藏  举报