Python-时间序列预测-全-

Python 时间序列预测(全)

原文:Time Series Forecasting in Python

译者:飞龙

协议:CC BY-NC-SA 4.0

前置材料

前言

在银行工作,我很快意识到时间是一个重要的因素。利率随时间变化,人们的消费随时间变化,资产价格随时间变化。然而,我发现包括我在内的大多数人都不太适应时间序列。所以我决定学习时间序列预测。

结果证明比预期的要难,因为我找到的每个资源都是用 R 语言编写的。我对 Python 很熟悉,Python 无疑是工业界数据科学中最流行的语言。虽然 R 语言限制了你在统计计算方面的能力,但 Python 允许你编写网站、执行机器学习、部署模型、构建服务器等等。因此,我不得不将大量的 R 代码翻译成 Python 来学习时间序列预测。就在那时,我意识到了这个差距,我很幸运地得到了写一本书的机会。

通过这本书,我希望为使用 Python 进行时间序列预测创建一个一站式参考。它涵盖了统计和机器学习模型,还讨论了自动化预测库,因为它们在行业中广泛使用,并且经常作为基线模型。本书非常强调实践和实用方法,包含各种真实场景。在现实生活中,数据是杂乱无章的,有时会缺失,我希望为读者提供一个安全的空间来实验这些困难,从中学习,并将这些技能轻松地应用到他们自己的项目中。

本书专注于时间序列预测。当然,使用时间序列数据,我们还可以进行分类或异常检测,但本书只关注预测,以保持范围的可管理性。

在每一章中,你都会找到可以用来练习和磨练技能的练习。每个练习在 GitHub 上都有完整的解决方案。我强烈建议你花时间完成它们,因为这将帮助你获得重要的实用技能。它们为你提供了一个很好的方式来测试你的知识,看看在特定章节中你需要重新回顾什么,并将建模技术在新的场景中应用。

在阅读章节并完成练习后,你将拥有应对任何预测项目所需的所有工具,并充满信心地取得优异的结果。希望你也会获得超越这本书的好奇心和动力,成为一名时间序列专家。

致谢

首先,我想感谢我的妻子,Lina。感谢你在我不顺利时倾听,感谢你在书的大部分内容上给予反馈,感谢你纠正我的语法错误。你从一开始就支持我,最终使这一切成为可能。

接下来,我想感谢我的编辑,Bobbie Jennings。你让写我的第一本书变得如此简单,这让我想写第二本!你教会了我很多关于写作和考虑受众的知识,而且你并不害怕挑战书中的某些部分,这极大地提高了书的质量。

向所有审稿人——Amaresh Rajasekharan、Ariel Andres、Biswanath Chowdhury、Claudiu Schiller、Dan Sheikh、David Paccoud、David R King、Dinesh Ghanta、Dirk Gomez、Gary Bake、Gustavo Patino、Helder C. R. Oliveira、Howard Bandy、Igor Vieira、Kathrin Björkelund、Lokesh Kumar、Mary Anne Thygesen、Mikael Dautrey、Naftali Cohen、Oliver Korten、Paul Silisteanu、Raymond Cheung、Richard Meinsen、Richard Vaughan、Rohit Goswami、Sadhana Ganapathiraju、Shabie Iqbal、Shankar Swamy、Shreesha Jagadeesh、Simone Sguazza、Sriram Macharla、Thomas Joseph Heiman、Vincent Vandenborne 和 Walter Alexander Mata López——表示感谢。你们所有人都帮助使这本书变得更好。

最后,特别感谢 Brian Sawyer。我想你在我身上看到了一些东西。你给了我这个难以置信的机会来写一本书,并且你一直信任我。对我来说,写一本书是梦想成真,这一切都始于你启动了这个整个过程。我对此非常感激。

关于本书

本书旨在帮助数据科学家掌握时间序列预测,并帮助专业人士从 R 过渡到 Python 进行时间序列分析。它首先定义了时间序列数据,并强调了使用这类数据的独特性(例如,你不能对数据进行洗牌)。然后,它逐步介绍了基线模型的发展,并探讨了何时预测没有意义。

随后的章节深入探讨了预测技术,并逐步增加模型的复杂性,从统计模型到深度学习模型。最后,本书涵盖了自动化预测库,这可以大大加快预测过程。这将让你了解行业正在做什么。

谁应该阅读这本书?

本书适合那些知道如何执行传统回归和分类任务的数据科学家,但当他们遇到时间序列时却感到困惑。如果你到目前为止一直在丢弃日期列,这本书绝对适合你!

本书也适合那些精通 R 并希望过渡到 Python 的专业人士。R 是进行时间序列预测的出色语言,许多方法已在 R 中实现。然而,Python 是数据科学中最流行的语言,并且它具有应用于深度学习模型的优势,这是 R 所不能做到的。

本书如何组织:路线图

本书共有 4 部分和 21 章。

第一部分是时间序列预测的介绍。我们将正式化时间序列数据的概念,开发基线模型,并探讨何时预测不是一个合理的途径:

  • 第一章定义了时间序列数据,并探讨了预测项目的生命周期。

  • 在第二章中,我们将开发基线模型,因为一个模型只能相对于另一个模型进行评估。因此,在转向更复杂的技术之前,首先拥有一个简单的预测模型是很重要的。

  • 在第三章中,我们将研究随机游走模型,这是一个在高级模型中无法合理进行预测的特殊场景,我们必须求助于简单的基线模型。

第二部分专注于使用统计模型进行预测:

  • 在第四章中,我们将开发移动*均模型,MA(q),它是更复杂预测技术的基本构建块之一。

  • 在第五章中,我们将开发自回归模型,AR(p),这是更复杂场景的另一个基础模型。

  • 在第六章中,我们将 AR(p)和 MA(q)模型结合起来形成 ARMA(p,q)模型,并设计一个新的预测程序。

  • 在第七章中,我们将基于前一章的内容,使用 ARIMA(p,d,q)模型来建模非*稳时间序列。

  • 在第八章中,我们将增加另一层复杂性,并使用 SARIMA(p,d,q) (P,D,Q)[m]模型来建模季节性时间序列。

  • 在第九章中,我们将增加最后一层复杂性,达到 SARIMAX 模型,使我们能够使用外部变量来预测我们的数据。

  • 在第十章中,我们将探索向量自回归,VAR(p),模型,它允许我们同时预测多个时间序列。

  • 第十一章以一个综合项目结束第二部分,给我们提供了一个机会来应用自第四章以来所学的知识。

第三部分涵盖了使用深度学习进行预测。当你的数据集变得非常大,具有非线性关系和高维性时,深度学习是进行预测最合适的工具:

  • 第十二章介绍了深度学习以及我们可以构建的模型类型。

  • 第十三章探讨了数据窗口步骤,这对于确保使用深度学习模型进行预测的成功至关重要。

  • 在第十四章中,我们将开发我们的第一个简单的深度学习模型。

  • 在第十五章中,我们将使用 LSTM 架构进行预测。这个架构专门设计用来处理序列数据,就像时间序列一样。

  • 在第十六章中,我们将探索 CNN 架构,它可以通过卷积操作有效地过滤时间序列中的噪声。我们还将 CNN 与 LSTM 架构相结合。

  • 在第十七章中,我们将开发一个自回归深度学习模型,这是一个已被证明能够生成最先进结果的架构,因为模型的输出被反馈作为输入以产生下一个预测。

  • 在第十八章中,我们将通过一个综合项目来结束第三部分。

第四部分探讨了使用自动化预测库,特别是 Prophet,因为它在行业中是最广泛使用的库之一:

  • 第十九章探讨了自动化预测库的生态系统,我们将通过使用 Prophet 的项目来操作。我们还将使用 SARIMAX 模型来比较两种方法的性能。

  • 第二十章是一个综合项目,邀请你使用 Prophet 和 SARIMAX 模型,并查看在那种情况下哪种表现最好。

  • 第二十一章总结了本书,旨在激励你超越自我,探索还可以用时间序列数据做什么。

关于代码

本书包含许多源代码示例,既有编号列表,也有与普通文本并列。在这两种情况下,源代码都使用固定宽度字体(如这样)格式化,以将其与普通文本区分开来。

在许多情况下,原始源代码已被重新格式化;我们添加了换行符并重新调整了缩进,以适应书中的可用页面空间。在某些情况下,即使这样也不够,列表中还包括了行续接标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常已从列表中删除。许多列表旁边都有代码注释,突出显示重要概念。

您可以从本书的在线(liveBook)版本中获取可执行的代码片段,链接为livebook.manning.com/book/time-series-forecasting-in-python-book/。本书的完整源代码可在 GitHub 上找到,链接为github.com/marcopeix/TimeSeriesForecastingInPython。您还可以在那里找到所有练习的解决方案,以及图表的代码。创建可视化有时是一个被忽视的技能,但我相信它非常重要。

所有代码均在 Windows 上使用 Anaconda 中的 Jupyter Notebooks 运行。我使用了 Python 3.7,但任何后续版本都应该可以正常工作。

liveBook 讨论论坛

购买《Python 时间序列预测》包括对 liveBook(Manning 的在线阅读*台)的免费访问。使用 liveBook 的独家讨论功能,您可以在全局或特定章节或段落中添加评论。为自己做笔记、提出和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/time-series-forecasting-in-python-book/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。

Manning 对读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

作者在线

您可以在 Medium 上关注我,了解更多关于数据科学的文章(medium.com/@marcopeixeiro)。我的博客方法与我处理这本书的方法相似:首先是理论,其次是实践项目。您也可以在 LinkedIn 上联系我(www.linkedin.com/in/marco-peixeiro/)。

关于作者

图片

马科·佩西埃罗是加拿大最大银行之一的高级数据科学家。作为一名自学成才的人,他特别清楚一个人需要知道什么才能找到工作并在该行业工作。马科是实践学习方法的大支持者,这也是他在 Medium 博客、他的免费 CodeCamp 数据科学速成课程以及他在 Udemy 的课程中采用的方法。

关于封面插图

《Python 时间序列预测》封面上的图像被标注为“堪察加人”,或“堪察加男子”,取自雅克·格拉塞·德·圣索沃尔的收藏,该收藏于 1797 年出版。每一幅插图都是手工精细绘制和着色的。

在那些日子里,人们通过衣着就能轻易识别出他们的居住地以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面,庆祝计算机行业的创新精神和主动性,这些文化通过像这样的一些收藏品中的图片被重新带回生活。

第一部分:时间不等人

很少有现象不受时间的影响,这本身就已经足以证明理解时间序列的重要性。在这本书的第一部分,我们将定义时间序列,并探讨与之合作的特点。我们还将使用朴素方法开发我们非常第一个预测模型。这些将作为基线模型,我们将在整本书中重复使用这些技术。最后,我们将研究一种预测不可能的情况,以便我们识别并避免陷入这个陷阱。

1 理解时间序列预测

本章涵盖

  • 介绍时间序列

  • 理解时间序列的三个主要组成部分

  • 成功预测项目所需的步骤

  • 预测时间序列与其他回归任务的不同之处

时间序列存在于气象学、金融、计量经济学和市场营销等多个领域。通过记录和分析数据,我们可以研究时间序列,以分析工业流程或跟踪业务指标,例如销售额或参与度。此外,随着大量数据的可用性,数据科学家可以将他们的专业知识应用于时间序列预测的技术。

您可能已经遇到过其他关于时间序列的课程、书籍或文章,它们使用 R 语言实现解决方案,R 语言是一种专门为统计计算而设计的编程语言。许多预测技术都利用了统计模型,正如您将在第三章及以后章节中学习的那样。因此,为了使时间序列分析和预测在 R 语言中使用无缝,已经做了大量工作来开发包。然而,大多数数据科学家都需要精通 Python,因为它是机器学习领域最广泛使用的语言。*年来,社区和大型公司开发了强大的库,利用 Python 执行统计计算和机器学习任务,开发网站等等。虽然 Python 远非一种完美的编程语言,但其多功能性对用户来说是一个强大的优势,因为我们可以在使用同一编程语言的同时开发模型、执行统计测试,并通过 API 提供模型服务或开发 Web 界面。本书将向您展示如何仅使用 Python 实现时间序列预测的统计学习技术和机器学习技术。

本书将完全专注于时间序列预测。您将首先学习如何进行简单的预测,这些预测将作为更复杂模型的基准。然后我们将使用两种统计学习技术,即移动*均模型和自回归模型,来进行预测。这些将作为我们接下来要介绍的更复杂建模技术的基础,这些技术将使我们能够考虑非*稳性、季节性效应和外生变量的影响。之后,我们将从统计学习技术转向深度学习方法,以预测具有高维度的非常大的时间序列,在这种情况下,统计学习通常不如其深度学习对应者表现得好。

目前,本章将探讨时间序列预测的基本概念。我将首先定义时间序列,以便您能够识别它。然后,我们将继续讨论时间序列预测的目的。最后,您将了解为什么预测时间序列与其他回归问题不同,以及为什么这个主题值得拥有自己的书籍。

1.1 介绍时间序列

理解和执行时间序列预测的第一步是学习什么是时间序列。简而言之,时间序列就是一组按时间顺序排列的数据点。此外,数据通常在时间上等间隔分布,这意味着每个数据点之间有相等的间隔。用更简单的说法,数据可以每小时或每分钟记录,也可以每月或每年*均。一些典型的时间序列例子包括特定股票的收盘价、家庭的电力消耗或外面的温度。

时间序列

时间序列是一组按时间顺序排列的数据点。

数据在时间上等间隔分布,这意味着它每小时、每分钟、每月或每季度都被记录。典型的时间序列例子包括股票的收盘价、家庭的电力消耗或外面的温度。

让我们考虑一个代表 1960 年至 1980 年 Johnson & Johnson 股票每股季度收益(美元)的数据集,如图 1.1 所示。我们将在这个书中经常使用这个数据集,因为它具有许多有趣的特性,这将帮助您学习更复杂预测问题的先进技术。

如您所见,图 1.1 清楚地表示了一个时间序列。数据按时间索引,如水*轴上所示。此外,数据在时间上等间隔分布,因为它是在每年每个季度的末尾记录的。我们可以看到数据有趋势,因为随着时间的推移,数值在增加。我们还看到收益在一年中的上下波动,并且这种模式每年都会重复。

图片

图 1.1:1960 年至 1980 年 Johnson & Johnson 的季度收益(美元)显示正向趋势和周期性行为

1.1.1 时间序列的组成部分

通过观察时间序列的三个组成部分:趋势、季节性成分和残差,我们可以进一步理解时间序列。实际上,所有的时间序列都可以分解成这三个元素。

可视化时间序列的组成部分称为分解。分解被定义为一种统计任务,它将时间序列分解为其不同的组成部分。我们可以可视化每个单独的组成部分,这将帮助我们识别数据中的趋势和季节性模式,而这并不总是通过查看数据集就能直接看出的。

让我们更仔细地看看图 1.2 中显示的 Johnson & Johnson 季度每股收益的分解。您可以看到观测数据是如何被分成趋势、季节性和残差的。让我们更详细地研究图表的每一部分。

图片

图 1.2:1960 年至 1980 年 Johnson & Johnson 季度收益分解

首先,顶部的图表,标记为观测,简单地显示了记录的时间序列(图 1.3)。Y轴显示的是强生公司每股季度收益的美元价值,而x轴代表时间。这基本上是图 1.1 的再现,它显示了将图 1.2 中的趋势、季节性和残差图表组合的结果。

图片

图 1.3 关注观测图

然后,我们看到了图 1.4 中的趋势成分。再次提醒,Y轴表示值,而X轴仍然指的是时间。趋势被定义为时间序列中的缓慢变化。我们可以看到,它最初是*的,然后急剧上升,这意味着我们的数据中存在增加的或正的趋势。趋势成分有时被称为水*。我们可以将趋势成分视为试图通过大多数数据点来绘制一条线,以显示时间序列的一般方向。

图片

图 1.4 关注趋势成分。在我们的序列中存在趋势,因为成分不是*的。这表明随着时间的推移,我们的值在增加。

接下来,我们在图 1.5 中看到季节性成分。季节性成分捕捉到季节性变化,这是一个在固定时间段内发生的周期。我们可以看到,在一年或四个季度内,每股收益从低到高,然后在年底再次下降。

图片

图 1.5 关注季节性成分。在这里,我们的时间序列存在周期性波动,这表明收益每年都会上下波动。

注意Y轴显示的是负值。这难道意味着每股收益是负数吗?显然不是,因为我们的数据集严格来说都是正值。因此,我们可以说季节性成分显示了我们是如何偏离趋势的。有时我们会有正值偏离,在观测图中会出现峰值。其他时候,我们会有负值偏离,在观测图中会看到谷值。

最后,图 1.2 中的最后一个图表显示了残差,这是趋势或季节性成分都无法解释的部分。我们可以将残差视为将趋势和季节性图表相加,并将每个时间点的值与观测图进行比较。对于某些点,我们可能会得到与观测图完全相同的值,在这种情况下,残差将为零。在其他情况下,值与观测图中的值不同,因此残差图显示了必须添加到趋势和季节性中才能调整结果并得到与观测图相同的值。残差通常对应于随机误差,也称为白噪声,我们将在第三章中讨论。它们代表我们无法建模或预测的信息,因为它是完全随机的,如图 1.6 所示。

图 1.6

图 1.6 关注残差。残差是无法由趋势和季节性成分解释的部分。

时间序列分解

时间序列分解是一个将时间序列分解为其组成部分的过程:趋势、季节性和残差。

趋势代表时间序列中的缓慢变化。它负责使序列随时间逐渐增加或减少。

季节性成分表示序列中的季节性模式。周期在固定的时间段内反复发生。

残差代表无法由趋势和季节性成分解释的行为。它们对应于随机误差,也称为白噪声。

在进行预测时,我们已能直观地看到每个组成部分是如何影响我们的工作的。如果一个时间序列显示出某种趋势,那么我们预计它将在未来继续。同样,如果我们观察到强烈的季节性影响,这很可能会持续下去,我们的预测必须反映这一点。在本书的后面部分,您将看到如何考虑这些组成部分并将它们包含在您的模型中,以预测更复杂的时间序列。

1.2 时间序列预测的鸟瞰图

预测是使用历史数据和未来可能影响我们预测的知识来预测未来。这个定义充满了承诺,作为数据科学家,我们常常非常渴望开始预测,利用我们的科学知识展示一个几乎完美的预测准确性的惊人模型。然而,在达到预测点之前,还有一些重要的步骤必须完成。

图 1.7 是一个在专业环境中一个完整的预测项目可能看起来像的简化图。请注意,这些步骤并非普遍适用,它们可能或可能不被遵循,这取决于组织及其成熟度。尽管如此,这些步骤对于确保数据团队和业务团队之间良好的协同作用至关重要,从而提供商业价值,避免团队之间的摩擦和挫败感。

图 1.7

图 1.7 预测项目路线图。第一步自然是设定一个目标,以证明预测的必要性。然后你必须确定为了实现这个目标需要预测什么。然后你设定预测的范围。一旦完成,你就可以收集数据并开发一个预测模型。然后模型部署到生产环境中,其性能得到监控,并收集新的数据以重新训练预测模型,确保其仍然相关。

让我们深入一个场景,详细涵盖预测项目路线图中的每个步骤。想象一下,你计划一个月后进行一次为期一周的露营旅行,你想要知道带哪个睡袋,以便你可以在夜间舒适地睡觉。

1.2.1 设定目标

任何项目路线图的第一步是设定目标。在这里,场景中明确指出:你想要知道要携带哪种睡袋以便在夜晚舒适地入睡。如果夜晚会很冷,暖和的睡袋是最好的选择。当然,如果预计夜晚会温暖,那么轻便的睡袋会是更好的选择。

1.2.2 确定需要预测的内容以实现目标

然后你需要确定必须预测哪些内容,以便你决定要携带哪种睡袋。在这种情况下,你需要预测夜晚的温度。为了简化问题,让我们考虑预测最低温度就足以做出决定,并且最低温度出现在夜晚。

1.2.3 设置预测的范围

现在,你可以设置预测的范围。在这种情况下,你的露营之旅将在一个月后开始,并将持续一周。因此,你的预测范围是一周,因为你只对预测露营期间的最小温度感兴趣。

1.2.4 收集数据

现在,你可以开始收集数据了。例如,你可以收集历史每日最低温度数据。你也可以收集可能影响温度的因素的数据,例如湿度和风速。

这时,关于多少数据足够的问题就出现了。理想情况下,你会收集超过一年的数据。这样,你可以确定是否存在年度季节性模式或趋势。在温度的情况下,你当然可以期待一年中存在一些季节性模式,因为不同的季节会带来不同的最低温度。

然而,一年的数据并不是关于多少数据足够的最終答案。它高度依赖于预测的频率。在这种情况下,你将创建每日预测,因此一年的数据应该足够。

如果你想要创建每小时预测,几个月的训练数据就足够了,因为它会包含大量的数据点。如果你正在创建月度或年度预测,你需要一个更长的时间段的历史数据,以便有足够的数据点进行训练。

最后,关于需要多少数据来训练模型并没有明确的答案。确定这一点是构建模型、评估其性能以及测试更多数据是否可以提高模型性能的实验过程的一部分。

1.2.5 开发预测模型

拥有你的历史数据后,你就可以开始开发预测模型了。这个项目路线图的部分是本书的重点。这是你研究数据并确定是否存在趋势或季节性模式的时候。

如果你观察到季节性,那么 SARIMA 模型就相关了,因为这个模型使用季节性效应来产生预测。如果你有风速和湿度的信息,你可以使用 SARIMAX 模型来考虑这些因素,因为你可以向它提供来自外生变量的信息,如风速和湿度。我们将在第八章和第九章中详细探讨这些模型。

如果你成功收集了大量数据,例如过去 20 年的每日最低温度,你可以使用神经网络来利用这大量训练数据。与统计学习方法不同,深度学习往往能产生更好的模型,因为使用了更多数据进行训练。

无论你开发哪种模型,你都会使用部分训练数据作为测试集来评估你的模型性能。测试集将始终是最新的数据点,并且它必须代表预测范围。

在这种情况下,由于你的预测范围是一周,你可以从训练集中移除最后七个数据点,将它们放入测试集中。然后,当每个模型被训练时,你可以产生一周的预测,并将结果与测试集进行比较。可以通过计算误差指标,如均方误差(MSE)来评估模型的表现。这是评估你的预测与真实值之间距离的一种方法。均方误差最低的模型将是表现最好的模型,它将进入下一步。

1.2.6 部署到生产环境

一旦你有了最佳模型,你必须将其部署到生产环境。这意味着你的模型可以接收数据并返回未来 7 天的最低日温度预测。部署模型到生产环境有许多方法,这可能是整本书的主题。你的模型可以作为 API 提供服务,或集成到 Web 应用程序中,或者你可以定义自己的 Excel 函数来运行你的模型。最终,当你可以输入数据并获得预测,而不需要任何手动数据操作时,你的模型就被认为是部署了。在这个阶段,你的模型可以被监控。

1.2.7 监控

由于露营之旅还有 1 个月,你可以看到你的模型表现如何。每天,你可以将你的模型预测与当天记录的实际最低温度进行比较。这让你能够确定模型预测的质量。

你还可以寻找意外事件。例如,热浪可能会出现,降低你模型预测的质量。密切监控你的模型和当前事件,可以帮助你确定意外事件是由于暂时情况引起的,还是将持续 2 个月,在这种情况下,它可能会影响你的露营之旅决策。

1.2.8 收集新数据

通过监控您的模型,您在将模型的预测与当天的观测最低温度进行比较时,必然会收集到新的数据。然后,这些最新、最*的数据可以用于重新训练您的模型。这样,您就有了一组最新的数据,可以用来预测未来 7 天的最低温度。

这个周期将在下一个月内重复,直到你达到露营旅行的日子,如图 1.8 所示。到那时,你将已经做出了许多预测,根据新观察到的数据评估了它们的质量,并在记录新的每日最低温度时重新训练了你的模型。这样,你确保你的模型仍然表现良好,并使用相关数据来预测你的露营旅行温度。

图片

图 1.8 可视化生产循环。一旦模型投入生产,你将进入一个周期,在这个周期中,你监控它,收集新数据,并使用这些数据在再次部署之前调整预测模型。

最后,根据您的模型预测,您可以决定携带哪种睡袋。

1.3 时间序列预测与其他回归任务的不同之处

您可能遇到过回归任务,在这些任务中,您必须根据一组特定的特征预测某些连续的目标。乍一看,时间序列预测似乎是一个典型的回归问题:我们有一些历史数据,我们希望构建一个数学表达式,将未来的值表示为过去值的函数。然而,在时间序列预测和时间独立场景的回归之间有一些关键的区别,在我们查看我们的第一个预测技术之前,这些问题值得解决。

1.3.1 时间序列具有顺序

需要记住的第一个概念是时间序列具有顺序,我们在建模时不能改变这个顺序。在时间序列预测中,我们表达未来的值作为过去值的函数。因此,我们必须保持数据的顺序,以避免违反这种关系。

此外,保持数据顺序是有意义的,因为您的模型只能使用过去直到现在的信息——它将不知道未来将观察到什么。回想一下你的露营旅行。如果你想预测星期二的温度,你不可能使用星期三的信息,因为从模型的角度来看,它是在未来的。你只能使用星期一及以前的数据。这就是为什么在整个建模过程中,数据的顺序必须保持一致。

机器学习中的其他回归任务通常没有顺序。例如,如果你被要求根据广告支出预测收入,那么在何时支出一定金额的广告并不重要。相反,你只是想将广告支出的金额与收入联系起来。实际上,你甚至可能随机打乱数据以使你的模型更稳健。这里的回归任务是简单地推导出一个函数,给定广告支出的金额,返回收入的一个估计值。

另一方面,时序数据是按时间顺序索引的,这种顺序必须保持。否则,你将使用预测时模型所不具备的未来信息来训练模型。这在更正式的术语中被称为前瞻偏差。因此,生成的模型将不可靠,并且在你进行未来预测时很可能会表现不佳。

1.3.2 时序数据有时没有特征

没有使用除了时序本身以外的特征,也可以预测时序。

作为数据科学家,我们习惯于拥有具有许多列的数据集,每一列都代表我们目标变量的潜在预测因子。例如,考虑基于广告支出预测收入的任务,其中收入是目标变量。作为特征,我们可以有在谷歌广告、Facebook 广告和电视广告上的支出金额。使用这三个特征,我们将构建一个回归模型来估计收入。

然而,对于时序数据,通常只给出一个包含时间列和该时间点的值的简单数据集。在没有其他特征的情况下,我们必须学习使用时序的过去值来预测未来值的方法。这就是移动*均模型(第四章)或自回归模型(第五章)发挥作用的时候,因为它们是将未来值表达为过去值的函数的方法。这些模型是更复杂模型的基础,这些模型允许你考虑时序中的季节性模式和趋势。从第六章开始,我们将逐步构建这些基本模型来预测更复杂的时序数据。

1.4 下一步

本书将详细介绍不同的预测技术。我们将从一些非常基础的方法开始,例如移动*均模型和自回归模型,然后我们将逐步考虑更多因素,以便使用 ARIMA、SARIMA 和 SARIMAX 模型来预测具有趋势和季节性模式的时序数据。我们还将处理高维时序数据,这将需要我们使用深度学习技术来处理序列数据。因此,我们将不得不使用 CNN(卷积神经网络)和 LSTM(长短期记忆)来构建神经网络。最后,你将学习如何自动化预测时序的工作。正如之前提到的,本书中的所有实现都将使用 Python 完成。

现在你已经了解了什么是时间序列以及预测将如何不同于你之前可能看到的任何传统回归任务,我们准备继续前进并开始预测。然而,我们第一次尝试预测将专注于作为基线模型的朴素方法。

摘要

  • 时间序列是一组按时间顺序排列的数据点。

  • 时间序列的例子包括股票的收盘价或外界的温度。

  • 时间序列可以被分解为三个组成部分:趋势、季节性成分和残差。

  • 在进行预测时设定一个目标很重要,一旦模型部署后要对其进行监控。这将确保项目的成功和持久性。

  • 在建模时,切勿改变时间序列的顺序。不允许对数据进行洗牌。

2 对未来的简单预测

本章涵盖

  • 定义基线模型

  • 使用*均值设置基线

  • 使用前一个时间窗口的*均值创建基线

  • 使用前一时间步创建基线

  • 实现简单的季节性预测

在第一章中,我们介绍了时间序列是什么,以及预测时间序列与传统的回归任务有何不同。你还学习了构建成功预测项目所需的必要步骤,从定义目标到构建模型,部署它,以及随着新数据的收集而更新它。现在你准备好开始预测时间序列了。

你将首先学习如何进行未来的简单预测,这将成为基线。基线模型是一个简单的解决方案,它使用启发式方法或简单的统计方法来计算预测。开发基线模型并不总是精确的科学。它通常需要通过可视化数据和检测可用于预测的模式来获得一些直觉。在任何建模项目中,拥有一个基线都很重要,因为你可以用它来比较你将来构建的更复杂模型的性能。唯一知道模型是好是坏的方法是将它与基线进行比较。

在本章中,让我们假设我们希望预测强生公司的季度每股收益(EPS)。我们可以查看图 2.1 中的数据集,这与你在第一章中看到的是相同的。具体来说,我们将使用 1960 年到 1979 年底的数据来预测 1980 年四个季度的 EPS。预测期如图 2.1 中的灰色区域所示。

图片

图 2.1 1960 年至 1980 年间强生公司每股收益(美元,USD)。我们将使用 1960 年到 1979 年最后一个季度的数据来构建一个基线模型,该模型将预测 1980 年各季度的每股收益(如图中灰色区域所示)。

你可以在图 2.1 中看到,我们的数据具有趋势,因为它随时间增加。此外,我们有一个季节性模式,因为在一年或四个季度中,我们可以反复观察到峰值和谷值。这意味着我们存在季节性。

回想一下,我们在第一章中分解时间序列时识别了这些组件。组件如图 2.2 所示。我们将在本章的后面部分详细研究这些组件,因为它们将帮助我们获得关于数据行为的直觉,这反过来又帮助我们开发一个好的基线模型。

图片

图 2.2 1960 年至 1980 年强生公司季度收益分解

我们首先将定义什么是基线模型,然后我们将开发四个不同的基线来预测强生公司的季度每股收益。这是我们将最终开始使用 Python 和时间序列预测“动手”的时候。

2.1 定义基准模型

一个基准模型是解决我们问题的简单解决方案。它通常使用启发式方法或简单的统计来生成预测。基准模型是你能想到的最简单解决方案——它不应该需要任何训练,并且实施成本应该非常低。

你能为我们项目想出一个基准吗?

知道我们想要预测强生公司的每股收益(EPS),你能做出最基本、最天真的预测是什么?

在时间序列的背景下,我们可以用来构建基准的一个简单统计量是算术*均值。我们可以简单地计算一定时期内的*均值,并假设未来的值将等于这个*均值。在预测强生公司 EPS 的背景下,这就像说

1960 年至 1979 年间的*均 EPS 为$4.31。因此,我预计 1980 年接下来的四个季度的 EPS 将等于每季度$4.31。

另一个可能的基准是天真地预测最后一个记录的数据点。在我们的背景下,这就像说

如果这个季度的 EPS 是$0.71,那么下一个季度的 EPS 也将是$0.71。

或者,如果我们看到数据中的周期性模式,我们可以简单地重复这个模式到未来。在强生公司的背景下,这就像说

如果 1979 年第一季度的 EPS 是$14.04,那么 1980 年第一季度的 EPS 也将是$14.04。

你可以看到这三个可能的基准依赖于我们数据集中的简单统计、启发式方法和观察到的模式。

基准模型

基准模型是解决你的预测问题的简单解决方案。它依赖于启发式方法或简单的统计,通常是解决方案中最简单的。它不需要模型拟合,并且易于实现。

你可能会想知道那些基准模型是否有效。这些简单的方法能有多好地预测未来?我们可以通过预测 1980 年并测试我们的预测与 1980 年观察到的数据来回答这个问题。这被称为样本外预测,因为我们正在为一个在模型开发时未考虑的时期进行预测。这样我们可以衡量我们模型的性能,并看到当我们预测超出我们拥有的数据时,它们会如何表现,在这种情况下是 1981 年和之后。

在接下来的章节中,你将学习如何开发这里提到的不同基准来预测强生公司的季度 EPS。

2.2 预测历史*均值

如本章开头所述,我们将使用 1960 年至 1980 年 Johnson & Johnson 的季度每股收益(EPS)美元(USD)。我们的目标是使用 1960 年到 1979 年底的数据来预测 1980 年的四个季度。我们将讨论的第一个基线使用的是历史*均值,即过去值的算术*均值。其实现方法是直接的:计算训练集的*均值,它将成为我们对 1980 年四个季度的预测。不过,首先我们需要做一些初步工作,这些工作将用于我们所有的基线实现。

2.2.1 基线实现设置

我们的第一步是加载数据集。为此,我们将使用pandas库,并通过read_csv方法将数据集加载到DataFrame中。您可以在本地机器上下载文件,并将文件的路径传递给read_csv方法,或者简单地输入 CSV 文件在 GitHub 上托管的位置。在这种情况下,我们将使用以下文件:

import pandas as pd

df = pd.read_csv('../data/jj.csv')

注意:本章的完整代码可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH02

DataFramepandas中最常用的数据结构。它是一个二维带标签的数据结构,具有可以存储不同类型数据的列,例如字符串、整数、浮点数或日期。

我们的第二步是将数据分为训练集和测试集。鉴于我们的预测范围是 1 年,我们的训练集将从 1960 年开始,一直持续到 1979 年底。我们将把 1980 年收集的数据保存为测试集。您可以将DataFrame想象成一个带有列名和行索引的表格或电子表格。

在我们的数据集以DataFrame形式存在的情况下,我们可以通过运行以下命令来显示前五条记录

df.head()

这将给出如图 2.3 所示的输出。

图片

图 2.3:Johnson & Johnson 数据集的季度每股收益的前五条记录。注意我们的DataFrame有两个列:日期和数据。它还有从 0 开始的行索引。

图 2.3 将帮助您更好地理解我们的DataFrame所持有的数据类型。我们有一个日期列,它指定了 EPS 计算的每个季度的结束。数据列持有每股收益的美元(USD)值。

我们可以选择显示数据集的最后五条记录,并得到如图 2.4 所示的输出:

df.tail()

图片

图 2.4:数据集的最后五条记录。在这里,我们可以看到 1980 年的四个季度,我们将尝试使用不同的基线模型来预测。我们将比较我们的预测与 1980 年的观测数据,以评估每个基线的性能。

在图 2.4 中,我们看到 1980 年的四个季度,这是我们试图使用基线模型预测的内容。我们将通过将我们的预测与 1980 年四个季度的数据列中的值进行比较来评估基线的性能。我们的预测越接*观察值,性能就越好。

在开发我们的基线模型之前的最后一步是将数据集分为训练集和测试集。如前所述,训练集将包括从 1960 年到 1979 年底的数据,测试集将包括 1980 年的四个季度。训练集将是我们在开发模型时使用的唯一信息。一旦构建了一个模型,我们将预测下一个四个时间步长,这将在我们的测试集中对应于 1980 年的四个季度。这样,我们可以将我们的预测与观察数据进行比较,并评估基线的性能。

为了进行分割,我们将指定我们的训练集将包含df中保存的所有数据,除了最后四个条目。测试集将仅由最后四个条目组成。这就是下一个代码块所做的工作:

train = df[:-4]
test = df[-4:]

2.2.2 实现历史*均值基线

现在我们准备实现我们的基线。我们首先将使用整个训练集的算术*均值。为了计算*均值,我们将使用numpy库,因为它是一个在 Python 中进行科学计算非常快速的包,并且与DataFrames配合得很好:

import numpy as np

historical_mean = np.mean(train['data'])    ❶

print(historical_mean)

❶ 计算训练集中数据列的算术*均值。

在前面的代码块中,我们首先导入numpy库,然后计算整个训练集 EPS 的*均值,并在屏幕上打印出来。这个值是 4.31 美元。这意味着从 1960 年到 1979 年底,强生公司的季度 EPS *均为 4.31 美元。

现在,我们将天真地预测 1980 年每个季度的这个值。为此,我们将简单地创建一个新的列,名为 pred_mean,它包含训练集的历史*均值作为预测值:

test.loc[:, 'pred_mean'] = historical_mean     ❶

❶ 将历史*均值设为预测值。

接下来,我们需要定义并计算一个误差指标,以便评估我们的预测在测试集上的性能。在这种情况下,我们将使用*均绝对百分比误差(MAPE)。它是一种易于解释且与我们的数据规模无关的预测准确度度量。这意味着无论我们处理的是两位数还是六位数,MAPE 都将始终以百分比的形式表示。因此,MAPE 返回预测值与观察值或实际值*均偏差的百分比,无论预测值是高于还是低于观察值。MAPE 在方程 2.1 中定义。

方程 2.1

在公式 2.1 中,A[i] 是时间点 i 的实际值,而 F[i] 是时间点 i 的预测值;n 简单地是预测的数量。在我们的情况下,因为我们正在预测 1980 年的四个季度,n = 4。在求和内部,预测值从实际值中减去,然后除以实际值,这给出了百分比误差。然后我们取百分比误差的绝对值。这个操作对时间点 n 的每个点重复进行,然后将结果相加。最后,我们将总和除以 n,即时间点的数量,这实际上给出了*均绝对百分比误差。

让我们在 Python 中实现这个函数。我们将定义一个 mape 函数,它接受两个向量:y_true 用于测试集中观察到的实际值,y_pred 用于预测值。在这种情况下,因为 numpy 允许我们处理数组,我们不需要循环来求和所有值。我们可以简单地从 y_true 数组中减去 y_pred 数组,然后除以 y_true 来得到百分比误差。然后我们可以取绝对值。之后,我们取结果的*均值,这将负责将向量中的每个值相加并除以预测的数量。最后,我们将结果乘以 100,这样输出就是以百分比而不是小数形式表示:

def mape(y_true, y_pred):
    return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

现在,我们可以计算基线的 MAPE。我们的实际值在 test 数据的 data 列中,所以它将是传递给 mape 函数的第一个参数。我们的预测值在 testpred_mean 列中,所以它将是函数的第二个参数:

mape_hist_mean = mape(test['data'], test['pred_mean'])
print(mape_hist_mean)

运行该函数得到 MAPE 为 70.00%。这意味着我们的基线*均偏离了 1980 年 Johnson & Johnson 观察到的季度 EPS 70%。

让我们可视化我们的预测,以更好地理解我们的 70% MAPE。

列表 2.1 可视化我们的预测

import matplotlib.pyplot as plt

fig, ax = plt.subplots()

ax.plot(train['date'], train['data'], 'g-.', label='Train')
ax.plot(test['date'], test['data'], 'b-', label='Test')
ax.plot(test['date'], test['pred_mean'], 'r--', label='Predicted')
ax.set_xlabel('Date')
ax.set_ylabel('Earnings per share (USD)')
ax.axvspan(80, 83, color='#808080', alpha=0.2)
ax.legend(loc=2)

plt.xticks(np.arange(0, 85, 8), [1960, 1962, 1964, 1966, 1968, 1970, 1972, 1974, 1976, 1978, 1980])

fig.autofmt_xdate()
plt.tight_layout()

在列表 2.1 中,我们使用了 matplotlib 库,这是 Python 中生成可视化的最流行库,用来生成一个显示训练数据、预测范围、测试集的观察值以及 1980 年每个季度的预测的图表。

首先,我们初始化一个 figure 和一个 ax 对象。一个图可以包含多个 ax 对象,这允许我们创建包含两个、三个或更多图表的图。在这种情况下,我们正在创建一个包含单个图表的图,所以我们只需要一个 ax

第二,我们在 ax 对象上绘制我们的数据。我们使用绿色虚线和点线绘制训练数据,并给这条曲线一个“训练”的标签。标签将后来用于生成图表的图例。然后我们绘制测试数据,并使用带有“测试”标签的蓝色连续线。最后,我们使用带有“预测”标签的红色虚线绘制我们的预测。

第三,我们标注了x-轴和y-轴,并绘制一个矩形区域来表示预测范围。由于我们的预测范围是 1980 年的四个季度,该区域应从索引 80 开始,到索引 83 结束,覆盖整个 1980 年。记住,我们通过运行df.tail()获得了 1980 年最后一个季度的索引,这导致了图 2.5。

图片

图 2.5 我们数据集的最后五条记录

我们将这个区域涂成灰色,并使用alpha参数指定不透明度。当alpha为 1 时,形状是完全不透明的;当alpha为 0 时,它是完全透明的。在我们的情况下,我们将使用 20%的不透明度,或 0.2。

然后我们指定x轴上刻度的标签。默认情况下,标签将显示数据集中的每个季度的数据,这将创建一个拥挤的x轴,标签难以阅读。相反,我们将每两年显示一次年份。为此,我们将生成一个指定标签必须出现位置的索引数组。这就是np.arange(0, 81, 8)所做的事情:它生成一个从 0 开始,到 80 结束的数组,因为结束索引(81)不包括在内,步长为 8,因为两年中有 8 个季度。这将有效地生成以下数组:[0,8,16,...72,80]。然后我们指定一个包含每个索引标签的数组,因此它必须从 1960 年开始,以 1980 年结束,就像我们的数据集一样。

最后,我们使用fig.automft_xdate()来自动格式化x轴上的刻度标签。它将略微旋转它们,并确保它们可读。最终的调整是使用plt.tight_layout()来移除图周围的任何多余空白。

最终结果是图 2.6。很明显,这个基线并没有产生准确的预测,因为预测线与测试线相距甚远。现在我们知道,我们的预测*均比 1980 年每个季度的实际 EPS 低 70%。而 1980 年的 EPS 始终高于 10,我们只预测了每个季度的 4.31 美元。

图片

图 2.6 预测历史*均值作为基线。你可以看到预测值与测试集中的实际值相差甚远。这个基线给出了 70%的 MAPE。

尽管如此,我们能从中学习到什么?查看我们的训练集,我们可以看到一个正向趋势,因为 EPS 随时间增加。这一点还得到了我们数据集分解的趋势成分的支持,如图 2.7 所示。

图片

图 2.7 我们时间序列的趋势成分。你可以看到我们的数据中有一个正向趋势,因为它随时间增加。

正如我们所见,不仅存在一个趋势,而且在 1960 年至 1980 年之间,这个趋势并不是恒定的——它正在变得更加陡峭。因此,1960 年观察到的 EPS 可能并不能预测 1980 年的 EPS,因为我们有一个正向趋势,EPS 值随时间增加,并且增长速度越来越快。

你能改进我们的基线吗?

在进入下一节之前,你能想到一种方法来改进我们的基线,同时仍然使用*均值吗?你认为取较短且较*时期(例如 1970 年至 1979 年)的*均值会有帮助吗?

2.3 预测上一年的*均值

从先前的基线中学到的教训是,由于数据集中的正向趋势成分,早期值似乎在长期内并不能预测未来的值。早期值似乎太小,不能代表 1979 年末和 1980 年达到的新 EPS 水*。

如果我们在训练集中使用上一年的*均值来预测下一年,这意味着我们将计算 1979 年的*均每股收益(EPS),并预测 1980 年每个季度的 EPS。随着时间的推移而增加的更*期的值可能更接* 1980 年观察到的值。目前,这仅仅是一个假设,所以让我们实现这个基线并测试其性能。

我们的数据已经分为测试集和训练集(在 2.2.1 节中完成),因此我们可以继续计算训练集中上一年的*均值,这对应于 1979 年的最后四个数据点:

last_year_mean = np.mean(train.data[-4:])     ❶

print(last_year_mean)

❶ 计算训练集最后四个数据点(1979 年四个季度)的*均每股收益。

这给出了*均每股收益为$12.96。因此,我们将预测强生公司 1980 年四个季度的每股收益为$12.96。使用与之前基线相同的程序,我们将创建一个新的 pred_last_yr_mean 列来保存去年的*均值作为我们的预测:

test.loc[:, 'pred__last_yr_mean'] = last_year_mean

然后,使用我们之前定义的mape函数,我们可以评估新基线的性能。记住,第一个参数是观察值,它们存储在测试集中。然后我们传入预测值,它们在 pred_last_yr_mean 列中:

mape_last_year_mean = mape(test['data'], test['pred__last_yr_mean'])
print(mape_last_year_mean)

这给出了 15.60%的 MAPE。我们可以在图 2.8 中可视化我们的预测。

图 2.8 预测训练集中上一年的*均值(1979 年)作为基线模型。你可以看到,与我们在图 2.6 中构建的先前基线相比,预测值更接*测试集的实际值。

你能重新创建图 2.8 吗?

作为练习,尝试重新创建图 2.8,使用 1979 年季度的*均值来可视化预测。代码应该与列表 2.1 相同,只是这次预测值在不同的列中。

这个新的基线与先前的基线相比有明显的改进,尽管其实现同样简单,因为我们已经将 MAPE 从 70%降低到 15.6%。这意味着我们的预测值*均偏离观察值 15.6%。使用上一年的*均值是朝着正确方向迈出的好一步。我们希望将 MAPE 尽可能接* 0%,因为那将意味着我们的预测值更接*我们预测范围内的实际值。

从这个基线我们可以了解到,未来的值很可能依赖于历史中不太久远的数据。这是一个自相关的迹象,我们将在第五章深入探讨这个主题。现在,让我们看看另一种我们可以为这种情况开发的基线。

2.4 使用最后一个已知值进行预测

之前我们使用不同时期的*均值来开发基线模型。到目前为止,最好的基线是我们训练集中最后记录的年份的*均值,因为它产生了最低的 MAPE。我们从那个基线了解到,未来的值依赖于过去的数据,但不是那些很久远的数据。实际上,从 1960 年到 1979 年预测*均 EPS 的预测比预测 1979 年的*均 EPS 要差。

因此,我们可以假设使用训练集的最后一个已知值作为基线模型将给我们带来更好的预测,这将转化为更接* 0%的 MAPE。让我们测试这个假设。

第一步是提取我们训练集的最后一个已知值,这对应于 1979 年最后一个季度的 EPS 记录:

last = train.data.iloc[-1]

print(last)

当我们检索 1979 年最后一个季度的 EPS 记录时,我们得到一个值为$9.99。因此,我们将预测强生公司 1980 年四个季度的 EPS 将为$9.99。

再次,我们将添加一个名为 pred_last 的新列来保存预测。

test.loc[:, 'pred_last'] = last

然后,使用我们之前定义的相同的 MAPE 函数,我们可以评估这个新基线模型的表现。再次,我们将测试集中的实际值和testpred_last列中的预测传递给函数:

mape_last = mape(test['data'], test['pred_last'])

print(mape_last)

这给我们一个 MAPE 为 30.45%。我们可以在图 2.9 中可视化预测结果。

图片

图 2.9 预测训练集最后一个已知值作为基线模型。我们可以看到,这个基线,MAPE 为 30.45%,比我们的第一个基线好,但不如第二个基线表现好。

你能重新创建图 2.9 吗?

尝试自己制作图 2.9!作为数据科学家,我们传达结果的方式对那些不在我们领域工作的人来说是重要的。因此,制作显示我们预测的图表是一项重要的技能。

看起来我们的新假设并没有改进我们构建的最后一个基线,因为我们有一个 MAPE 为 30.45%,而使用 1979 年的*均 EPS 我们实现了 15.60%的 MAPE。因此,这些新的预测比 1980 年的观察值更远。

这可以通过 EPS 显示的周期性行为来解释,它在第一季度和第二季度较高,然后在最后一个季度下降。使用最后一个已知值没有考虑到季节性,因此我们需要使用另一种简单的预测技术来查看我们是否可以产生更好的基线。

2.5 实现简单的季节性预测

我们在本章的前两个基线中考虑了趋势成分,但我们还没有研究数据集中另一个重要的成分,即图 2.10 中显示的季节成分。我们的数据中存在明显的周期性模式,这是我们构建最后一个基线可以使用的信息:朴素季节性预测。

图 2.10 时间序列的季节成分。我们可以看到这里存在周期性波动,这表明存在季节性。

朴素季节性预测将最后一个观察到的周期重复到未来。在我们的例子中,一个完整的周期发生在四个季度中,所以我们将从 1979 年的第一季度取 EPS 并预测 1980 年第一季度的值。然后我们将从 1979 年的第二季度取 EPS 并预测 1980 年第二季度的值。这个过程将重复进行第三和第四季度。

在 Python 中,我们可以通过简单地取训练集的最后四个值来实现这个基线,这对应于 1979 年的四个季度,并将它们分配给 1980 年的相应季度。以下代码将 pred_last_season 列附加到我们的预测中,以使用朴素季节性预测方法:

test.loc[:, 'pred_last_season'] = train['data'][-4:].values    ❶

❶ 我们的预测是训练集的最后四个值,对应于 1979 年的各个季度。

然后我们以与前面章节相同的方式计算 MAPE:

mape_naive_seasonal = mape(test['data'], test['pred_last_season'])

print(mape_naive_seasonal)

这给我们带来了 11.56%的 MAPE,这是本章所有基线中最低的 MAPE。图 2.11 展示了我们的预测与测试集中观察到的数据相比。作为一个练习,我强烈建议你尝试自己重现它。

如您所见,我们的朴素季节性预测在本章构建的所有基线中产生了最低的 MAPE。这意味着季节性对未来值有显著影响,因为将最后一个季节重复到未来会产生相当准确的预测。直观上,这是有道理的,因为我们可以在图 2.11 中清楚地观察到每年重复的周期性模式。在开发这个问题的更复杂预测模型时,我们必须考虑季节性影响。我将在第八章详细解释如何考虑它们。

图 2.11 在测试集上朴素季节性预测的结果。这个预测与测试集中观察到的数据更相似,并且导致了最低的 MAPE。显然,这个数据集的季节性对未来值有影响,预测时必须考虑这一点。

2.6 下一步

在本章中,我们为我们的预测项目开发了四个不同的基线。我们使用了整个训练集的算术*均值、训练集中最后一年份的*均值、训练集的最后一个已知值,以及一个简单的季节性预测。然后,每个基线都使用 MAPE 指标在测试集上进行了评估。图 2.12 总结了本章中我们开发的每个基线的 MAPE。如图所示,使用简单季节性预测的基线具有最低的 MAPE,因此性能最佳。

图片

图 2.12 本章开发的四个基线的 MAPE。MAPE 越低,基线越好;因此,我们将选择简单的季节性基线作为我们的基准,并将其与我们的更复杂模型进行比较。

请记住,基线模型作为比较的基础。我们将通过应用统计学习或深度学习技术来开发更复杂的模型,当我们对测试集进行评估并记录我们的误差指标时,我们可以将它们与基线进行比较。在我们的案例中,我们将比较复杂模型的 MAPE 与我们的简单季节性预测的 MAPE。如果复杂模型的 MAPE 低于 11.56%,那么我们就知道我们有一个性能更好的模型。

在某些特殊情况下,时间序列只能使用简单方法进行预测。这些是过程随机移动且无法使用统计学习方法预测的特殊情况。这意味着我们处于随机游走的状态——我们将在下一章中探讨这一点。

摘要

  • 时间序列预测从作为更复杂模型比较基准的基线模型开始。

  • 基线模型是我们预测问题的简单解决方案,因为它只使用了启发式方法或简单的统计,如*均值。

  • MAPE 代表“*均绝对百分比误差”,它是预测值与实际值偏差的直观度量。

  • 开发基线有许多方法。在本章中,你看到了如何使用*均值、最后一个已知值或最后一个季节。

3 随机游走之旅

本章涵盖

  • 识别随机游走过程

  • 理解自相关函数(ACF)功能

  • 分类差分、*稳性和白噪声

  • 使用自相关图和差分来识别随机游走

  • 预测随机游走

在上一章中,我们比较了不同的简单预测方法,并了解到它们通常作为更复杂模型的基准。然而,在某些情况下,最简单的方法会产生最佳的预测结果。当我们面对随机游走过程时,情况就是这样。

在本章中,你将学习什么是随机游走过程,如何识别它,以及如何使用随机游走模型进行预测。在这个过程中,我们将探讨差分、*稳性和白噪声的概念,这些概念将在我们开发更高级的统计学习模型时在后续章节中再次出现。

对于本章的示例,假设你想购买 Alphabet Inc.(GOOGL)的股票。理想情况下,如果你预计股票的收盘价未来会上涨,那么你就应该买入;否则,你的投资将不会盈利。因此,你决定收集 GOOGL 过去一年的每日收盘价数据,并使用时间序列预测来确定股票的未来收盘价。GOOGL 从 2020 年 4 月 27 日到 2021 年 4 月 27 日的收盘价如图 3.1 所示。在撰写本文时,2021 年 4 月 27 日之后的数据尚未可用。

图片

图 3.1 2020 年 4 月 27 日至 2021 年 4 月 27 日 GOOGL 的每日收盘价

在图 3.1 中,你可以清楚地看到长期趋势,因为从 2020 年 4 月 27 日到 2021 年 4 月 27 日,收盘价一直在上升。然而,趋势中也有突然的变化,有时它会急剧下降,然后又突然上升。

结果表明,GOOGL 的每日收盘价可以使用随机游走模型进行建模。为此,我们首先将确定我们的过程是否是*稳的。如果它是一个非*稳过程,我们必须应用如差分之类的转换,使其变得*稳。然后我们将能够使用自相关函数图来得出结论,即 GOOGL 的每日收盘价可以用随机游走模型*似。差分和自相关图将在本章中介绍。最后,我们将本章以尝试预测 GOOGL 未来收盘价的预测方法结束。

到本章结束时,你将掌握*稳性、差分和自相关性的概念,这些概念将在我们进一步发展预测技能时在后续章节中再次出现。现在,让我们专注于定义随机游走过程。

3.1 随机游走过程

随机游走是一个过程,其中以随机数上升或下降的概率相等。这通常在金融和经济数据中观察到,如 GOOGL 的每日收盘价。随机游走经常暴露出可以观察到正或负趋势的长周期。它们也经常伴随着方向的突然变化。

在随机游走过程中,我们说当前值 y[t] 是前一时间步的值 y[t–1]、常数 C 和随机数 ϵ[t](也称为白噪声)的函数。在这里,ϵ[t] 是标准正态分布的实现,其方差为 1,均值为 0。

因此,我们可以用以下方程式数学地表达一个随机游走,其中 y[t] 是当前时间 t 的值,C 是一个常数,y[t–1] 是前一时间步 t–1 的值,而 ϵ[t] 是一个随机数。

y[t] = C + y[t–1] + ϵ[t]

方程式 3.1

注意,如果常数 C 不为零,我们将其指定为有漂移的随机游走。

3.1.1 模拟随机游走过程

为了帮助您理解随机游走过程,让我们用 Python 模拟一个——这样您可以了解随机游走是如何表现的,我们可以在纯粹的理论场景中研究其属性。然后我们将我们的知识应用到我们的现实生活例子中,我们将模拟和预测 GOOGL 的收盘价。

从方程式 3.1,我们知道随机游走取决于其前一个值 y[t–1]、白噪声 ϵ[t] 和一些常数 C。为了简化我们的模拟,让我们假设常数 C 为 0。这样,我们的模拟随机游走可以表示为

y[t] = y[t–1] + ϵ[t]

方程式 3.2

现在我们必须选择我们模拟序列的第一个值。同样,为了简化,我们将序列初始化为 0。这将 y[0] 的值。

现在,我们可以开始使用方程式 3.2 构建我们的序列。我们将从时间 t = 0 的初始值 0 开始。然后,根据方程式 3.2,时间 t = 1 的值,表示为 y[1],将等于前一个值 y[0] 加上白噪声。

y[0] = 0

y[1] = y[0] + ϵ[1] = 0 + ϵ[1] = ϵ[1]

方程式 3.3

时间 t = 2 的值,表示为 y[2],将等于前一步的值,即 y[1],加上一些白噪声。

y[1] = ϵ[1]

y[2] = y[1] + ϵ[2] = ϵ[1] + ϵ[2]

方程式 3.4

然后时间 t = 3 的值,表示为 y[2],将等于前一步的值,即 y[2],加上一些白噪声。

y[2] = ϵ[1] + ϵ[2]

y[3] = y[2] + ϵ[3] = ϵ[1] + ϵ[2] + ϵ[3]

方程式 3.5

观察方程式 3.5,你应该开始看到一种模式。通过将我们的随机游走过程初始化为 0 并将常数 C 设置为 0,我们确定时间 t 的值仅仅是时间 t = 1 到时间 t 的白噪声之和。因此,我们的模拟随机游走将遵循方程式 3.6,其中 y[t] 是时间 t 的随机游走过程的值,而 ϵt 是时间 t 的一个随机数。

方程式 3-6

方程式 3.6

方程式 3.6 表明,在任意时间点 t,我们模拟的时间序列的值将是随机数序列的累积和。我们可以在图 3.2 中可视化我们的模拟随机游走。

图 3-02

图 3.2 可视化我们模拟随机游走的构建。正如你所见,我们的初始值是 0。然后,由于常数也被设为 0,我们随机游走在任何时间点的值仅仅是随机数的累积和,或者说是白噪声。

我们现在可以使用 Python 来模拟我们的随机过程。为了使这个练习可重复,我们需要设置一个种子,这是一个传递给random.seed方法的整数。这样,无论我们运行代码多少次,都会生成相同的随机数。这确保了你将获得与本章中概述的相同的结果和图表。

注意:在任何时候,你都可以在这里查看本章的源代码:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH03

然后,我们必须决定模拟过程的长度。在这个练习中,我们将生成 1,000 个样本。numpy库允许我们通过使用standard_normal方法从正态分布中生成数字。这确保了数字来自均值为 0 的分布,正如白噪声的定义;我还给它设定了方差为 1(一个正态分布)。然后我们可以将序列的第一个值设为 0。最后,cumsum方法将计算序列中每个时间步长的白噪声的累积和,我们就可以模拟随机游走了:

import numpy as np

np.random.seed(42)                            ❶

steps = np.random.standard_normal(1000)       ❷
steps[0]=0                                    ❸

random_walk = np.cumsum(steps)                ❹

❶ 设置随机种子。这是通过传递一个整数来完成的,在这个例子中是 42。

❷ 从均值为 0 和方差为 1 的正态分布中生成 1,000 个随机数。

❸ 将序列的第一个值初始化为 0。

❹ 计算模拟过程中每个时间步长的误差累积和。

我们可以绘制我们的模拟随机游走并查看其外观。由于我们的 x 轴和 y 轴没有现实生活中的意义,我们将简单地将其标记为“时间步长”和“值”。以下代码块生成了图 3.3:

fig, ax = plt.subplots()

ax.plot(random_walk)
ax.set_xlabel('Timesteps')
ax.set_ylabel('Value')

plt.tight_layout()

你可以在图 3.3 中看到随机游走的定义特征。你会注意到在前 400 个时间步长中有一个正向趋势,随后转为负向趋势,并在最后急剧上升。因此,我们既有突然的变化,也有观察到趋势的长时期。

图 3-03

图 3.3 模拟随机游走。注意我们如何在最初的 400 个时间步长中呈现出一个正向趋势,随后转为负向趋势,并在最后急剧上升。这些都是我们有一个随机游走过程的良好线索。

我们知道这是一个随机游走,因为我们模拟了它。然而,当处理现实生活中的数据时,我们需要找到一种方法来识别我们的时间序列是否是随机游走。让我们看看我们如何实现这一点。

3.2 识别随机游走

要确定我们的时间序列是否可以*似为随机游走,我们首先必须定义随机游走。在时间序列的背景下,随机游走被定义为第一差分是*稳且不相关的序列。

随机游走

随机游走是一个第一差分是*稳且不相关的序列。

这意味着过程完全随机移动。

我刚刚在一句话中介绍了很多新的概念,所以让我们将识别随机游走过程的步骤分解一下。这些步骤在图 3.4 中有所概述。

图片

图 3.4 检验时间序列数据是否可以*似为随机游走的步骤。第一步自然是收集数据。然后我们测试*稳性。如果它不是*稳的,我们应用变换直到达到*稳性。然后我们可以绘制自相关函数(ACF)。如果没有自相关,我们就有了一个随机游走。

在接下来的小节中,我们将详细讨论*稳性和自相关的概念。

3.2.1 *稳性

一个*稳时间序列是指其统计属性随时间不发生变化的时间序列。换句话说,它具有恒定的均值、方差和自相关,并且这些属性与时间无关。

许多预测模型都假设*稳性。移动*均模型(第四章)、自回归模型(第五章)和自回归移动*均模型(第六章)都假设*稳性。只有在我们验证数据确实是*稳的情况下,这些模型才能使用。否则,模型将无效,预测将不可靠。直观上,这是有道理的,因为如果数据是非*稳的,其属性会随时间变化,这意味着我们的模型参数也必须随时间变化。这意味着我们不可能从过去值推导出未来值的函数,因为系数在每一个时间点都会变化,这使得预测不可靠。

我们可以将*稳性视为一个假设,它可以使我们的预测工作更加容易。当然,我们很少会看到原始状态下的*稳时间序列,因为我们通常对具有趋势或季节性周期的预测过程感兴趣。这就是 ARIMA(第七章)和 SARIMA(第八章)模型发挥作用的时候。

*稳性

一个*稳过程是指其统计属性随时间不发生变化的过程。

一个时间序列如果其均值、方差和自相关随时间不发生变化,则被称为*稳的。

目前,由于我们仍处于时间序列预测的早期阶段,我们将专注于*稳时间序列,这意味着我们需要找到将我们的时间序列转换为*稳状态的方法。变换仅仅是数据的数学操作,以稳定其均值和方差,从而使其*稳。

可以应用的最简单的变换是差分。这种变换有助于稳定均值,从而消除或减少趋势和季节性影响。差分涉及计算从一个时间步到另一个时间步的变化序列。为了实现这一点,我们只需从当前时间步的值 y[t] 中减去前一个时间步的值 y[t–1],以获得差分值 y'[t]

y'[t] = y[t]y[t–1]

方程式 3.7

时间序列预测中的变换

变换是对时间序列应用的一种数学运算,以使其*稳。

差分是一种计算从一个时间步到另一个时间步变化的变换。这种变换对于稳定均值是有用的。

对序列应用对数函数可以稳定其方差。

图 3.5 阐述了差分的过程。注意,进行差分会使我们失去一个数据点,因为在初始时间点,我们无法与之前的时间步进行差分,因为 t = –1 不存在。

图 3.5 展示了差分变换的可视化。在这里,应用了一阶差分。注意,在这个变换之后我们失去了一个数据点,因为初始时间点不能与之前的时间点进行差分,因为它们不存在。

时间序列可以进行多次差分。进行一次差分是应用一阶差分。进行第二次差分则是一阶差分。通常不需要进行超过两次的差分来获得一个*稳序列。

虽然差分用于通过时间获得恒定的均值,但我们还必须确保我们有一个恒定的方差,以便我们的过程是*稳的。对数用于帮助稳定方差。

请记住,当我们对一个已经变换的时间序列进行建模时,我们必须对其进行逆变换,以便将模型的结果返回到原始的测量单位。撤销变换的正式术语是逆变换。因此,如果您对数据进行对数变换,请确保将您的预测值提高到 10 的幂,以便将值恢复到其原始幅度。这样,您的预测将在原始上下文中是有意义的。

现在我们知道了需要对时间序列应用哪种类型的变换以使其*稳,我们需要找到一种方法来测试一个序列是否*稳。

3.2.2 *稳性检验

一旦对一个时间序列应用了转换,我们需要测试其*稳性,以确定是否需要应用另一个转换使时间序列*稳,或者是否需要对其进行转换。一个常见的测试是增强迪基-富勒(ADF)测试。

ADF 测试验证以下原假设:时间序列中存在一个单位根。备择假设是不存在单位根,因此时间序列是*稳的。这个测试的结果是 ADF 统计量,它是一个负数。它越负,对原假设的拒绝就越强。在其 Python 实现中,也会返回 p 值。如果其值小于 0.05,我们也可以拒绝原假设,并说序列是*稳的。

增强迪基-富勒(ADF)测试

增强迪基-富勒(ADF)测试通过测试单位根的存在来帮助我们确定时间序列是否*稳。如果存在单位根,则时间序列不是*稳的。

原假设表明存在一个单位根,这意味着我们的时间序列不是*稳的。

让我们考虑一个非常简单的时间序列,其中当前值 y[t] 只取决于其过去值 y[t–1],在系数 α[1]、常数 C 和白噪声 ϵ[t] 的作用下。我们可以写出以下一般表达式:

y[t] = C + α[1]y[t–1] + ϵ[t]

方程式 3.8

在方程式 3.8 中,ϵ[t] 代表一些我们无法预测的错误,C 是一个常数。在这里,α[1] 是时间序列的根。这个时间序列只有在根位于单位圆内时才是*稳的。因此,它的值必须在 –1 和 1 之间。否则,序列是非*稳的。

让我们通过模拟两个不同的序列来验证这一点。一个将是*稳的,另一个将有一个单位根,这意味着它将不会是*稳的。*稳过程遵循方程式 3.9,非*稳过程遵循方程式 3.10。

y[t] = 0.5y[t–1] + ϵ[t]

方程式 3.9

y[t] = y[t–1] + ϵ[t]

方程式 3.10

在方程式 3.9 中,序列的根是 0.5。由于它位于 –1 和 1 之间,这个序列是*稳的。另一方面,在方程式 3.10 中,序列的根是 1,这意味着它是一个单位根。因此,我们预计这个序列是非*稳的。

通过观察图 3.6 中的两个序列,我们可以获得一些关于*稳和非*稳序列随时间演变的直觉。我们可以看到,非*稳过程有长期的正负趋势。然而,*稳过程似乎在长期内没有增加或减少。这种高级定性分析可以帮助我们直观地确定一个序列是否*稳。

图 3.6 展示了在 400 个时间步长内模拟的*稳和非*稳时间序列。你可以看到,*稳序列在长期内不会增加或减少。然而,非*稳过程有长期的正负趋势。

*稳序列随时间保持恒定的属性,意味着均值和方差不是时间的函数,因此让我们绘制每个序列随时间的均值。*稳过程的均值应该随时间保持*坦,而非*稳过程的均值应该变化。

图片

图 3.7 展示了*稳和非*稳过程随时间变化的均值。你可以看到,*稳过程的均值在最初的几个时间步之后变得恒定。另一方面,非*稳过程的均值是时间的明显函数,因为它不断变化。

如图 3.7 所示,你可以看到,在经过最初的几个时间步之后,*稳过程的均值变得恒定。这正是*稳过程的预期行为。均值不随时间变化的事实意味着它独立于时间,正如*稳过程的定义。然而,非*稳过程的均值显然是时间的函数,因为我们看到它在时间上有所下降和再次上升。因此,存在单位根使得序列的均值依赖于时间,所以这个序列不是*稳的。

让我们进一步通过绘制每个序列随时间的方差来证明单位根是非*稳性的一个标志。同样,*稳序列将随时间保持恒定的方差,这意味着它是时间独立的。另一方面,非*稳过程将具有随时间变化的方差。

图片

图 3.8 展示了模拟的*稳和非*稳序列随时间变化的方差。*稳过程的方差不依赖于时间,因为它在最初的几个时间步之后保持恒定。对于非*稳过程,方差随时间变化,这意味着它不是独立的。

在图 3.8 中,我们可以看到,在最初的几个时间步之后,*稳过程的方差随时间保持恒定,这符合方程 3.9。同样,这符合*稳过程的定义,因为方差不依赖于时间。另一方面,具有单位根的过程具有随时间变化的方差,因为它在 400 个时间步长内变化很大。因此,这个序列不是*稳的。

到现在为止,你应该已经确信具有单位根的序列不是*稳序列。在图 3.7 和图 3.8 中,均值和方差都依赖于时间,因为它们的值不断变化。同时,具有 0.5 根的序列在时间上显示出恒定的均值和方差,证明了这一序列确实是*稳的。

所有这些步骤都是为了证明使用增强迪基-富勒 (ADF) 测试的合理性。我们知道 ADF 测试验证了序列中存在单位根。零假设,即存在单位根,意味着序列不是*稳的。如果测试返回的 p 值小于某个显著性水*,通常为 0.05 或 0.01,则我们可以拒绝零假设,这意味着没有单位根,因此序列是*稳的。

一旦我们有一个*稳序列,我们必须确定是否存在自相关。记住,随机游走是一个其第一差分是*稳且不相关的序列。ADF 测试负责*稳性部分,但我们需要使用自相关函数来确定序列是否相关。

3.2.3 自相关函数

一旦一个过程是*稳的,绘制自相关函数 (ACF) 是理解你正在分析的过程类型的好方法。在这种情况下,我们将使用它来确定我们是否在研究随机游走。

我们知道,相关系数衡量的是两个变量之间线性关系的程度。因此,自相关衡量的是时间序列滞后值之间的线性关系。因此,ACF 揭示了任何两个值之间的相关性如何随着滞后的增加而变化。在这里,滞后仅仅是两个值之间分离的时间步数。

自相关函数

自相关函数 (ACF) 衡量的是时间序列滞后值之间的线性关系。

换句话说,它衡量的是时间序列与其自身之间的相关性。

例如,我们可以计算 y[t]y[t–1] 之间的自相关系数。在这种情况下,滞后等于 1,系数将表示为 r[1]。同样,我们可以计算 y[t]y[t–2] 之间的自相关。然后滞后将是 2,系数将表示为 r[2]。当我们绘制 ACF 函数时,系数是因变量,而滞后是自变量。请注意,滞后 0 的自相关系数始终等于 1。这在直观上是有意义的,因为变量在同一时间步长内与其自身的线性关系应该是完美的,因此等于 1。

在存在趋势的情况下,ACF 的图表将显示短滞后时的系数较高,并且随着滞后的增加,它们将线性下降。如果数据是季节性的,ACF 图表也将显示周期性模式。因此,绘制非*稳过程的 ACF 函数不会比通过观察过程随时间的变化提供更多信息。然而,绘制*稳过程的 ACF 可以帮助我们识别随机游走的存在。

3.2.4 综合起来

现在你已经了解了什么是*稳性,如何将时间序列转换为*稳性,可以使用哪些统计测试来评估*稳性,以及如何通过绘制 ACF 函数来帮助你识别随机游走的存在,我们可以将这些概念结合起来,并在 Python 中应用它们。在本节中,我们将使用我们的模拟数据(来自 3.1.1 节)并介绍识别随机游走的必要步骤。

第一步是确定我们的随机游走是否*稳。我们知道,由于我们的序列中有明显的趋势,它不是*稳的。尽管如此,让我们应用 ADF 测试以确保。我们将使用statsmodels库,这是一个 Python 库,实现了许多统计模型和测试。要运行 ADF 测试,我们只需将其传递给我们的模拟数据数组。结果是不同值的列表,但我们主要对前两个值感兴趣:ADF 统计量和 p 值。

from statsmodels.tsa.stattools import adfuller

ADF_result = adfuller(random_walk)           ❶

print(f'ADF Statistic: {ADF_result[0]}')     ❷
print(f'p-value: {ADF_result[1]}')           ❸

❶ 将模拟的随机游走传递给 adfuller 函数。

❷ 查找 ADF 统计量,它是结果列表中的第一个值。

❸ 查找 p 值,它是结果列表中的第二个值。

这将打印出 ADF 统计量为-0.97 和 p 值为 0.77。ADF 统计量不是一个大的负数,并且由于 p 值大于 0.05,我们不能拒绝原假设,即我们的时间序列不是*稳的。我们可以通过绘制 ACF 函数进一步支持我们的结论。

statsmodels库方便地有一个函数可以快速绘制 ACF。同样,我们可以简单地传递它我们的数据数组。我们可以选择指定滞后数,这将确定x轴的范围。在这种情况下,我们将绘制前 20 个滞后,但你可以自由地绘制你想要的任何滞后数。

from statsmodels.graphics.tsaplots import plot_acf

plot_acf(random_walk, lags=20); 

输出结果如图 3.9 所示。

图片

图 3.9 模拟随机游走的 ACF 图。注意自相关系数是如何逐渐减小的。即使在滞后 20 的情况下,值仍然是自相关的,这意味着我们的随机游走目前不是*稳的。

在图 3.9 中,你会注意到随着滞后时间的增加,自相关系数逐渐减小,这是我们的随机游走不是*稳过程的明显指标。请注意,阴影区域代表置信区间。如果一个点在阴影区域内,那么它与 0 没有显著差异。否则,自相关系数是显著的。

由于我们的随机游走不是*稳的,我们需要应用一个转换使其*稳,以便从 ACF 图中检索有用信息。由于我们的序列主要显示趋势的变化而没有季节性模式,我们将应用一阶差分。记住,每次差分我们都会丢失第一个数据点。

为了进行差分,我们将使用numpydiff方法。这将差分给定数据数组。n参数控制数组必须差分多少次。为了应用一阶差分,n参数必须设置为 1:

diff_random_walk = np.diff(random_walk, n=1)

我们可以在图 3.10 中可视化差分的模拟随机游走。

图 3.10

图 3.10 我们差分随机游走的变化。看起来我们已经成功移除了趋势,并且方差是稳定的。

如您在图 3.10 中看到的,我们已经从我们的序列中移除了趋势。此外,方差看起来相当稳定。让我们再次测试*稳性,使用 ADF 测试:

ADF_result = adfuller(diff_random_walk)     ❶

print(f'ADF Statistic: {ADF_result[0]}')
print(f'p-value: {ADF_result[1]}')

❶ 这里我们传递我们的差分随机游走。

这输出了一个 ADF 统计量为-31.79,p 值为 0.。这次 ADF 统计量是一个大的负数,p 值小于 0.05。因此,我们拒绝零假设,我们可以说这个过程没有单位根,因此是*稳的。

我们现在可以绘制我们新**稳的时间序列的 ACF 函数:

plot_acf(diff_random_walk, lags=20);

观察图 3.11,你会注意到在滞后 0 之后没有显著的自相关系数。这意味着*稳过程是完全随机的,因此可以描述为白噪声。每个值只是简单地从上一个值随机跳跃,它们之间没有关系。

图 3.11

图 3.11 我们差分随机游走的 ACF 图。注意在滞后 0 之后没有显著的系数。这是我们在处理随机游走的明确指标。

我们已经证明我们的模拟数据确实是一个随机游走:经过一阶差分后,序列是*稳且不相关的,这符合随机游走的定义。

3.2.5 GOOGL 是随机游走吗?

我们已经应用了必要的步骤来识别模拟数据中的随机游走,因此这是一个测试我们在真实数据集上知识和新技能的好时机。从 2020 年 4 月 27 日到 2021 年 4 月 27 日的 GOOGL 收盘价,来自finance.yahoo.com,让我们确定这个过程是否可以*似为随机游走。

你可以使用pandasread_csv方法将数据加载到DataFrame中:

df = pd.read_csv('data/GOOGL.csv')

希望你的结论是 GOOGL 的收盘价确实是一个随机游走过程。让我们看看我们是如何得出这个结论的。为了可视化的目的,让我们快速绘制我们的数据,这导致了图 3.12:

fig, ax = plt.subplots()

ax.plot(df['Date'], df['Close'])
ax.set_xlabel('Date')
ax.set_ylabel('Closing price (USD)')

plt.xticks(
    [4, 24, 46, 68, 89, 110, 132, 152, 174, 193, 212, 235], 
    ['May', 'June', 'July', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 2021, 'Feb',
➥ 'Mar', 'April']        ❶

fig.autofmt_xdate()
plt.tight_layout()

❶ 仔细标注 x 轴上的刻度。

图 3.12

图 3.12 2020 年 4 月 27 日至 2021 年 4 月 27 日的 GOOGL 收盘价

观察图 3.12,我们可以看到数据中存在趋势,因为收盘价随时间增加;因此,我们没有一个*稳过程。这一点进一步得到了 ADF 测试的支持:

GOOGL_ADF_result = adfuller(df['Close'])

print(f'ADF Statistic: {GOOGL_ADF_result[0]}')
print(f'p-value: {GOOGL_ADF_result[1]}')

这返回了 0.16 的 ADF 统计量和大于 0.05 的 p 值,因此我们知道我们的数据不是*稳的。因此,我们将对数据进行差分,看看这能否使其*稳:

diff_close = np.diff(df['Close'], n=1)

接下来,我们可以对差分数据进行 ADF 测试:

GOOGL_diff_ADF_result = adfuller(diff_close)

print(f'ADF Statistic: {GOOGL_diff_ADF_result[0]}')
print(f'p-value: {GOOGL_diff_ADF_result[1]}')

这给出了-5.3 的 ADF 统计量和小于 0.05 的 p 值,这意味着我们有一个*稳过程。

现在我们可以绘制 ACF 函数,看看是否存在自相关:

plot_acf(diff_close, lags=20);

图 3.13 可能会让你感到困惑,想知道是否存在自相关。我们看不到任何显著的系数,除了滞后 5 和 18。这种情况有时会发生,并且仅是由于偶然。在这种情况下,我们可以安全地假设滞后 5 和 18 的系数并不显著,因为我们没有连续显著的系数。这只是一个偶然现象,差分值与滞后 5 和 18 的值略有相关性。

图 3.13

图 3.13 我们可以看到 ACF 图中没有显著的系数。你可能会注意到,在滞后 5 和 18 时系数是显著的,而其他则不是。这种情况在某些数据中偶然发生,并且可以假设这些点是非显著的,因为我们没有在滞后 0 到 5 或滞后 0 到 18 之间有连续显著的系数。

因此,我们可以得出结论,GOOGL 的收盘价可以通过随机游走过程来*似。取一阶差分使序列*稳,其 ACF 图显示没有自相关,这意味着它是完全随机的。

3.3 预测随机游走

现在我们已经知道了什么是随机游走以及如何识别它,我们可以开始进行预测。这可能会让人感到惊讶,因为我们已经确定随机游走随着时间的推移会采取随机的步骤。

预测随机变化是不可能的,除非我们预测一个随机值,这显然不是理想的情况。在这种情况下,我们只能使用简单的预测方法,或者基线,这些我们在第二章中已经讨论过。由于值是随机变化的,因此无法应用统计学习模型。相反,我们只能合理地预测历史*均值,或者最后一个值。

根据用例的不同,你的预测范围会有所不同。理想情况下,当处理随机游走时,你将只预测下一个时间步。然而,你可能需要预测未来的多个时间步。让我们看看如何应对这些情况。

3.3.1 长期预测

在本节中,我们将对长期视角下的随机游走进行预测。这不是一个理想的情况——随机游走可能会意外地增加或减少,因为过去的观察结果并不能预测未来的变化。在这里,我们将继续使用 3.1.1 节中的模拟随机游走。

为了简化问题,我们将随机游走分配到一个DataFrame中,并将数据集分为训练集和测试集。训练集将包含前 800 个时间步,这对应于模拟数据的 80%。因此,测试集将包含最后 200 个值:

import pandas as pd

df = pd.DataFrame({'value': random_walk})    ❶

train = df[:800]                             ❷
test = df[800:]                              ❸

❶ 将模拟随机游走分配到一个 DataFrame 中。它将包含一个名为 value 的单列。

❷ 数据的前 80%被分配到训练集中。由于我们有 1,000 个时间步,我们模拟数据的 80%对应于索引 800 之前的值。

❸ 将模拟随机游走的最后 20%分配给测试集。

图 3.14 说明了我们的分割。使用训练集,我们现在必须预测测试集中下一个 200 个时间步的值。

图片

图 3.14 展示了我们生成的随机游走的训练/测试分割。前 800 个时间步是训练集的一部分,其余的值是测试集的一部分。我们的目标是预测阴影区域的值。

如前所述,我们只能使用简单的预测方法来处理这种情况,因为我们正在处理随机游走。在这种情况下,我们将使用历史*均值、最后一个已知值和漂移方法。

预测*均值相当直接。我们将简单地计算训练集的*均值,并说下一个 200 个时间步的值将等于这个值。在这里,我们将创建一个新的列 pred_mean,它将保存历史*均值作为预测:

mean = np.mean(train.value)        ❶

test.loc[:, 'pred_mean'] = mean    ❷

test.head()                        ❸

❶ 计算训练集的*均值。

❷ 预测下一个 200 个时间步的历史*均值。

❸ 展示测试数据的头五行。

你将得到一个历史*均值为-3.68。这意味着我们将预测模拟随机游走的下一个 200 个时间步的值将为-3.68。

另一个可能的基线是预测训练集的最后一个已知值。在这里,我们将简单地提取训练集的最后一个值,并将其值作为我们下一个 200 个时间步的预测:

last_value = train.iloc[-1].value        ❶
test.loc[:, 'pred_last'] = last_value    ❷

test.head()

❶ 获取训练集的最后一个值。

❷ 在 pred_last 列中将最后一个值作为下一个 200 个时间步的预测。

此方法产生的预测值恒为-6.81。

最后,我们将应用漂移方法,这是我们尚未讨论的方法。漂移方法是对预测最后一个已知值的修改。在这种情况下,我们允许值随时间增加或减少。未来值将变化的速率等于训练集中观察到的速率。因此,这相当于计算训练集第一行和最后一行之间的斜率,并将这条直线简单地外推到未来。

记住,我们可以通过将 y 轴的变化除以 x 轴的变化来计算直线的斜率。在我们的情况下,y 轴的变化是随机游走的最后一个值 y[f]与其初始值 y[i]之间的差异。然后,x 轴的变化相当于时间步数减 1,如方程 3.11 所示。

方程式 3.11

当我们实现最后一个已知值基线时,我们计算了训练集的最后一个值,并且我们知道我们的模拟随机游走的初始值为 0;因此,我们可以将数字代入方程式 3.11 并计算方程式 3.12 中的漂移。

方程式 3.12

让我们现在在 Python 中实现这个功能。我们将计算 x 轴和 y 轴的变化,并简单地除以它们以获得漂移:

deltaX = 800– 1             ❶
deltaY = last_value– 0      ❷

drift = deltaY / deltaX      ❸

print(drift)

❶ 计算 x 轴的变化,即最后一个索引(799)和第一个索引(0)之间的差异。它等同于时间步数减 1。

❷ 计算模拟随机游走在训练集中的最后和初始值之间的差异。回想一下,训练集的最后一个值在之前实现的基线中的 last_value 变量中。

❸ 根据方程式 3.11 计算漂移。

如预期的那样,这给出了漂移值为 –0.0085,这意味着我们的预测值将随着时间的推移逐渐减少。漂移方法简单地表明,我们的预测值与时间步、漂移值以及随机游走的初始值线性相关,如方程式 3.13 所示。请注意,我们的随机游走从 0 开始,因此我们可以从方程式 3.13 中去掉这一点。

forecast = drift × timestep + y[i]

forecast = drift × timestep

方程式 3.13

由于我们想要预测训练集之后的下一个 200 个时间步,我们首先创建一个数组,包含从 800 开始到 1000 结束,步长为 1 的时间步范围。然后我们只需将每个时间步乘以漂移,以得到我们的预测值。最后,我们将它们分配给 test 的 pred_drift 列:

x_vals = np.arange(800, 1001, 1)         ❶

pred_drift = drift * x_vals              ❷

test.loc[:, 'pred_drift'] = pred_drift   ❸

test.head()

❶ 创建一个包含从 800 开始到 1000 结束,步长为 1 的时间步范围的列表。

❷ 将每个时间步乘以漂移,以得到每个时间步的预测值。

❸ 将我们的预测值分配给 pred_drift 列。

使用这三种方法,我们现在可以可视化我们的预测与测试集的实际值对比情况:

fig, ax = plt.subplots()

ax.plot(train.value, 'b-')                                ❶
ax.plot(test['value'], 'b-')                              ❷
ax.plot(test['pred_mean'], 'r-.', label='Mean')           ❸
ax.plot(test['pred_last'], 'g--', label='Last value')     ❹
ax.plot(test['pred_drift'], 'k:', label='Drift')          ❺

ax.axvspan(800, 1000, color='#808080', alpha=0.2)         ❻
ax.legend(loc=2)                                          ❼

ax.set_xlabel('Timesteps')
ax.set_ylabel('Value')

plt.tight_layout()

❶ 在训练集中绘制值。

❷ 在测试集中绘制观察值。

❸ 从历史*均值绘制预测图。它将是一条红色虚线和点划线。

❹ 从训练集的最后一个值绘制预测图。它将是一条绿色虚线。

❺ 使用漂移方法绘制预测图。它将是一条黑色虚线。

❻ 着色预测范围。

❼ 将图例放置在左上角。

如您在图 3.15 中所见,我们的预测是错误的。它们都无法预测测试集中观察到的突然增加,这是有道理的,因为随机游走的未来变化是完全随机的,因此是不可预测的。

图 3.15 使用*均值、最后已知值和漂移方法预测我们的随机游走。如你所见,所有预测都相当糟糕,并且未能预测测试集中观察到的突然增加。

我们可以通过计算我们预测的均方误差(MSE)来进一步证明这一点。我们无法使用第二章中提到的 MAPE,因为我们的随机游走可以取值为 0——由于这意味着除以 0,这在数学中是不允许的。

因此,我们选择 MSE,因为它可以衡量模型拟合的质量,即使观察到的值为 0。sklearn库中的mean_squared_error函数只需要观察值和预测值。然后它会返回 MSE。

from sklearn.metrics import mean_squared_error

mse_mean = mean_squared_error(test['value'], test['pred_mean'])
mse_last = mean_squared_error(test['value'], test['pred_last'])
mse_drift = mean_squared_error(test['value'], test['pred_drift'])

print(mse_mean, mse_last, mse_drift)

对于历史*均值、最后值和漂移方法,你将分别获得 327、425 和 466 的 MSE。我们可以在图 3.16 中比较这三个基线的 MSE。

图片

图 3.16 我们预测的均方误差(MSE)。显然,随机游走的未来是不可预测的,MSE 超过了 300。

如图 3.16 所示,最佳预测是通过预测历史*均值获得的,但均方误差(MSE)超过了 300。考虑到我们的模拟随机游走值不超过 30,这是一个极高的值。

到现在为止,你应该已经相信在长期预测随机游走是没有意义的。因为未来的值依赖于过去的价值加上一个随机数,在长期预测中,由于在许多时间步长中添加了许多随机数,随机部分被放大。

3.3.2 预测下一个时间步

虽然我们将仍然使用简单的预测方法,但预测随机游走的下一个时间步是我们唯一可以处理的合理情况。具体来说,我们将预测最后一个已知值。然而,我们只会在下一个时间步进行这种预测。这样,我们的预测应该只会因为随机数而偏离,因为随机游走的未来值总是过去的价值加上白噪声。

实现这个方法很简单:我们取我们的初始观察值并用来预测下一个时间步。一旦我们记录了一个新值,它将被用作下一个时间步的预测。然后这个过程会重复到未来。

图 3.17 展示了这个过程。在这里,早上 8:00 观察到的值被用来预测早上 9:00 的值,早上 9:00 实际观察到的值被用来预测早上 10:00 的值,以此类推。

图片

图 3.17 预测随机游走的下一个时间步。在这里,在某个时间点的观察值将被用作下一个时间点的预测。

让我们将这种方法应用于我们的随机游走过程。为了说明这种方法,我们将它应用于整个随机游走。这种朴素预测可能看起来非常令人印象深刻,但实际上我们只是在预测每个时间步的最后已知值。

模拟这个过程的一个好方法是移动我们的数据,而pandas库中的shift方法正好能做我们想要的事情。我们只需传入周期数,在我们的例子中是 1,因为我们正在预测下一个时间步:

df_shift = df.shift(periods=1)    ❶

df_shift.head()

df_shift 现在我们对整个随机游走的预测,并且对应于每个时间步的最后已知值。

你会注意到在第 1 步时,值为 0,这对应于模拟随机游走中第 0 步的观察值。因此,我们实际上是在使用当前观察到的值来预测下一个时间步。绘制我们的预测结果得到图 3.18。

fig, ax = plt.subplots()

ax.plot(df, 'b-', label='actual')
ax.plot(df_shift, 'r-.', label='forecast')

ax.legend(loc=2)

ax.set_xlabel('Timesteps')
ax.set_ylabel('Value')

plt.tight_layout()

图片

图 3.18 对随机游走下一个时间步的朴素预测。这个图给人一种非常好的模型印象,但实际上我们只是在预测前一个时间步观察到的值。

观察图 3.18,你可能会认为我们已经开发了一个惊人的模型,它几乎完美地符合我们的数据。看起来图表中并没有两条分开的线,因为它们几乎完美地重叠,这是完美拟合的标志。现在,我们可以计算均方误差(MSE):

mse_one_step = mean_squared_error(test['value'], df_shift[800:])    ❶

print(mse_one_step)

❶ 在测试集上计算均方误差(MSE)。

这得到一个 0.93 的值,这又可能让我们认为我们有一个非常高效的模型,因为均方误差(MSE)非常接* 0。然而,我们知道我们只是在预测前一个时间步观察到的值。如果我们放大我们的图表,如图 3.19 所示,这一点会更加明显。

图片

图 3.19 对我们随机游走最后 100 个时间步的*距离观察。在这里,我们可以看到我们的预测是如何简单地移动原始时间序列的。

因此,如果必须对随机游走过程进行预测,最好是进行多次短期预测。这样,我们不会让许多随机数随着时间的推移积累,这将在长期内降低我们预测的质量。

由于随机过程会随机地向未来迈出步伐,我们不能使用统计或深度学习技术来拟合这样的过程:从随机性中没有什么可以学习的,它也不能被预测。相反,我们必须依赖朴素预测方法。

3.4 下一步

到目前为止,你已经学习了如何开发基线模型,并且你已经发现,在存在随机游走的情况下,你只能合理地应用基线模型进行预测。你不能在采取未来随机步骤的数据上拟合统计模型或使用深度学习技术。最终,你不能预测随机运动。

你了解到随机游走是一个序列,其中第一差分不是自相关的,并且是一个*稳过程,这意味着它的均值、方差和自相关随时间保持恒定。识别随机游走的步骤在图 3.20 中展示。

图片

图 3.20 识别随机游走的步骤

但如果你的过程是*稳且自相关的,这意味着你在 ACF 图上看到连续的显著系数,会发生什么?目前,图 3.20 只是简单地说明它不是一个随机游走,所以你必须找到另一个模型来*似这个过程并进行预测。在这种情况下,你面临的是一个可以用移动*均(MA)模型、自回归(AR)模型或两者的组合来*似的进程,从而导致自回归移动*均(ARMA)模型。

在下一章中,我们将专注于移动*均模型。你将学习如何识别此类过程以及如何使用移动*均模型进行预测。

3.5 练习

现在是应用本章学到的不同技能的大好时机。以下三个练习将测试你对随机游走和预测随机游走的知识和理解。练习的难度和完成所需的时间依次排列。练习 3.5.1 和 3.5.2 的解决方案在 GitHub 上:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH03

3.5.1 模拟并预测随机游走

模拟一个不同于本章中我们使用过的随机游走。你可以简单地改变种子并得到新的值:

  1. 生成一个包含 500 个时间步长的随机游走。你可以自由选择一个不同于 0 的初始值。同时,确保通过传递不同的整数给np.random.seed()来改变种子。

  2. 绘制你的模拟随机游走图。

  3. 测试数据的*稳性。

  4. 应用一阶差分。

  5. 测试数据的*稳性。

  6. 将你的模拟随机游走分为包含前 400 个时间步长的训练集。剩余的 100 个时间步长将作为测试集。

  7. 应用不同的朴素预测方法并测量 MSE。哪种方法产生的 MSE 最低?

  8. 绘制你的预测图。

  9. 在测试集上预测下一个时间步长并测量 MSE。它是否降低了?

  10. 绘制你的预测图。

3.5.2 预测 GOOGL 的每日收盘价

使用本章中我们使用的 GOOGL 数据集,应用我们讨论的预测技术并测量它们的性能:

  1. 将最后 5 天的数据作为测试集。其余的将作为训练集。

  2. 使用朴素预测方法预测最后 5 天的收盘价并测量均方误差(MSE)。哪种方法最好?

  3. 绘制你的预测图。

  4. 在测试集上预测下一个时间步长并测量 MSE。它是否降低了?

  5. 绘制你的预测图。

3.5.3 预测你选择的股票的每日收盘价

许多股票的历史每日收盘价可以在finance.yahoo.com上免费获得。选择你喜欢的股票代码,并下载其过去 1 年的历史每日收盘价:

  1. 绘制你选择的股票的每日收盘价。

  2. 确定它是否是随机游走。

  3. 如果它不是一个随机游走,请解释原因。

  4. 将最后 5 天的数据保留为测试集。其余的将是训练集。

  5. 使用朴素预测方法预测最后 5 天,并测量均方误差(MSE)。哪种方法最好?

  6. 绘制你的预测。

  7. 在测试集上预测下一个时间步,并测量均方误差(MSE)。它是否减少了?

  8. 绘制你的预测。

摘要

  • 随机游走是一个过程,其中第一差分是*稳的,并且不是自相关的。

  • 我们不能在随机游走中使用统计或深度学习技术,因为它在未来的移动是随机的。因此,我们必须使用朴素预测。

  • *稳时间序列是指其统计属性(均值、方差、自相关)随时间不改变的时间序列。

  • 增量迪基-富勒(ADF)测试通过检验单位根来评估*稳性。

  • ADF 测试的零假设是序列中存在单位根。如果 ADF 统计量是一个大的负值,且 p 值小于 0.05,则拒绝零假设,序列是*稳的。

  • 使用变换来使序列*稳。差分可以稳定趋势和季节性,而对数可以稳定方差。

  • 自相关衡量一个变量与其在先前时间步(滞后)上的相关性。自相关函数(ACF)显示了自相关如何随滞后变化。

  • 理想情况下,我们将对短期或下一个时间步的随机游走进行预测。这样,我们不允许随机数累积,这将在长期内降低我们预测的质量。

第二部分. 使用统计模型进行预测

在本书的这一部分,我们将探讨用于时间序列预测的统计模型。在进行统计建模时,我们需要进行假设检验,仔细研究我们的数据以提取其属性,并找到最适合我们数据的最优模型。

到本部分的结尾,你将拥有一个强大的框架,可以使用统计模型对任何类型的时间序列进行建模。你将开发 MA(q)模型、AR(p)模型、ARMA(p,q)模型、ARIMA(p,d,q)模型用于非*稳时间序列,SARIMA(p,d,q)(P,D,Q)m用于季节性时间序列,以及 SARIMAX 模型以包含外部变量在你的预测中。我们还将涵盖 VAR(p)模型,用于同时预测多个时间序列。我们将以一个综合项目结束本书的这一部分,这样你就可以将所学知识应用于实践。

当然,还有许多其他用于时间序列预测的统计模型。例如,指数*滑基本上是对过去值的加权*均来预测未来值。指数*滑背后的基本思想是,在预测未来时,过去值不如*期值重要,因此它们被分配较小的权重。然后可以将此模型扩展以包括趋势和季节性成分。还有针对不同季节周期的时序建模的统计方法,例如 BATS 和 TBATS 模型。

为了使本节易于管理,我们不会涉及这些模型,但它们在statsmodels库中得到了实现,我们将广泛使用这个库。

4 建立移动*均过程模型

本章涵盖

  • 定义移动*均过程

  • 使用 ACF 识别移动*均过程的阶数

  • 使用移动*均模型预测时间序列

在上一章中,你学习了如何识别和预测随机游走过程。我们将随机游走过程定义为一种序列,其第一差分是*稳的且无自相关。这意味着在滞后 0 之后,其自相关函数(ACF)将不会显示任何显著的系数。然而,一个*稳过程仍然可能表现出自相关。在这种情况下,我们有一个可以*似为移动*均模型(MA(q)),自回归模型(AR(p)),或自回归移动*均模型(ARMA(p,q))的时间序列。在本章中,我们将重点关注使用移动*均模型进行识别和建模。

假设你想预测 XYZ 小部件公司的销售量。通过预测未来销售,公司将能够更好地管理其小部件的生产,避免生产过多或过少。如果生产的小部件不足,公司将无法满足客户的需求,让客户不满意。另一方面,生产过多的小部件将增加库存。小部件可能会过时或失去价值,这将增加企业的负债,最终让股东不满意。

在这个例子中,我们将研究从 2019 年开始的 500 天内小部件的销售情况。随着时间的推移记录的销售量如图 4.1 所示。请注意,销售量以千美元的美元为单位表示。

图片

图 4.1 从 2019 年 1 月 1 日开始,XYZ 小部件公司在 500 天内的销售量。这是虚构数据,但将有助于学习如何识别和建模移动*均过程。

图 4.1 显示了长期趋势,沿途有峰值和谷值。我们可以直观地说,这个时间序列不是一个*稳过程,因为我们可以在时间上观察到趋势。此外,数据中没有任何明显的周期性模式,因此我们现在可以排除任何季节性影响。

为了预测小部件的销售量,我们需要识别潜在的过程。为此,我们将应用我们在第三章中处理随机游走过程时覆盖的相同步骤,如图 4.2 所示再次展示。

图片

图 4.2 识别随机游走的步骤

一旦收集到数据,我们将对其进行*稳性检验。如果它不是*稳的,我们将应用转换使其*稳。然后,一旦序列成为*稳过程,我们将绘制自相关函数(ACF)。在我们的预测小部件销售的例子中,我们的过程将在 ACF 图中显示显著的系数,这意味着它不能被随机游走模型*似。

在本章中,我们将发现 XYZ 小部件公司的产品销量可以*似为移动*均过程,我们将探讨移动*均模型的定义。然后,你将学习如何使用 ACF 图识别移动*均过程的阶数。该过程的阶数决定了模型的参数数量。最后,我们将应用移动*均模型来预测未来 50 天的产品销量。

4.1 定义移动*均过程

移动*均过程,或称为移动*均(MA)模型,表明当前值是当前和过去误差项的线性组合。误差项被假定为相互独立且服从正态分布,就像白噪声一样。

移动*均模型表示为 MA(q),其中 q 是阶数。模型将当前值表示为序列均值 μ、当前误差项 ϵ[t] 和过去误差项 ϵ[tq] 的线性组合。过去误差对当前值的影响程度用系数 θ[q] 来量化。数学上,我们用方程 4.1 表达阶数为 q 的一般移动*均过程。

y[t] = μ + ϵ[t] + θ[1]ϵ[t–1] + θ[2]ϵ[t–2] +⋅⋅⋅+ θ[q]ϵ[tq]

方程 4.1

移动*均过程

在移动*均(MA)过程中,当前值线性依赖于序列的均值、当前误差项和过去误差项。

移动*均模型表示为 MA(q),其中 q 是阶数。MA(q) 模型的一般表达式为

y[t] = μ + ϵ[t] + θ[1]ϵ[t–1] + θ[2]ϵ[t–2] +⋅⋅⋅+ θ[q]ϵ[tq]

移动*均模型的阶数 q 决定了影响当前值的过去误差项的数量。例如,如果它是 1 阶,意味着我们有一个 MA(1) 过程,模型的表达式如方程 4.2 所示。在这里,我们可以看到当前值 y[t] 依赖于均值 μ、当前误差项 ϵ[t] 和前一个时间步的误差项 θ[1]ϵ[t–1]。

y[t] = μ + ϵ[t] + θ[1]ϵ[t–1]

方程 4.2

如果我们有一个二阶移动*均过程,或 MA(2),那么 y[t] 依赖于序列的均值 μ、当前误差项 ϵ[t]、前一个时间步的误差项 θ[1]ϵ[t–1] 和两个时间步之前的误差项 θ[2]ϵ[t–2],结果如方程 4.3 所示。

y[t] = μ + ϵ[t] + θ[1]ϵ[t–1] + θ[2]ϵ[t–2]

方程 4.3

因此,我们可以看到移动*均过程(MA(q))的阶数 q 如何影响模型中必须包含的过去误差项的数量。q 越大,过去误差项对当前值的影响就越大。因此,确定移动*均过程的阶数对于拟合适当的模型非常重要——如果我们有一个二阶移动*均过程,那么将使用二阶移动*均模型进行预测。

4.1.1 识别移动*均过程的阶数

要确定移动*均过程的阶数,我们可以扩展识别随机游走所需的步骤,如图 4.3 所示。

图片

图 4.3 确定移动*均过程阶数步骤

通常,第一步是收集数据。然后我们测试数据的*稳性。如果我们的序列不是*稳的,我们将应用变换,例如差分,直到序列变得*稳。然后我们绘制自相关函数(ACF)并寻找显著的自相关系数。在随机游走的情况下,我们在滞后 0 之后将不会看到显著的系数。另一方面,如果我们看到显著的系数,我们必须检查它们是否在滞后某个时间点 q 之后突然变得不显著。如果是这种情况,那么我们知道我们有一个阶数为 q 的移动*均过程。否则,我们必须遵循不同的步骤来发现时间序列的潜在过程。

让我们使用 XYZ Widget 公司的 widget 销售额数据来实际操作。数据集包含从 2019 年 1 月 1 日开始的前 500 天的销售量数据。我们将遵循图 4.3 中概述的步骤,并确定潜在移动*均过程的阶数。

第一步是收集数据。这一步已经为你完成了,所以这是一个将数据加载到DataFrame中并显示前五行数据的好时机。在任何时候,你都可以参考 GitHub 上本章的源代码:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH04

import pandas as pd

df = pd.read_csv('../data/widget_sales.csv')   ❶
df.head()                                      ❷

❶ 将 CSV 文件读入 DataFrame。

❷ 显示前五行数据。

你会看到销售额的量在 widget_sales 列中。请注意,销售额的量是以千美元为单位的。

我们可以使用matplotlib来绘制我们的数据。我们感兴趣的值在 widget_sales 列中,所以我们将其传递给ax.plot()。然后我们给x轴标记为“时间”和y轴标记为“Widget 销售(k$)”。接下来,我们指定x轴上的刻度标签应显示月份。最后,我们使用plt.tight_layout()倾斜x轴的刻度标签并移除图周围的额外空白。结果是图 4.4。

import matplotlib.pyplot as plt

fig, ax = plt.subplots()

ax.plot(df['widget_sales'])                                       ❶
ax.set_xlabel('Time')                                             ❷
ax.set_ylabel('Widget sales (k$)')                                ❸

plt.xticks(
    [0, 30, 57, 87, 116, 145, 175, 204, 234, 264, 293, 323, 352, 382, 409, 
➥ 439, 468, 498], 
    ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 
➥ 'Nov', 'Dec', '2020', 'Feb', 'Mar', 'Apr', 'May', 'Jun'])      ❹

fig.autofmt_xdate()                                               ❺
plt.tight_layout()                                                ❻

❶ 绘制 widget 销售额的量。

❷ 标记 x 轴。

❸ 标记 y 轴。

❹ 标记 x 轴的刻度。

❺ 倾斜 x 轴刻度标签,以便它们显示得更好。

❻ 移除图周围的额外空白。

图片

图 4.4 从 2019 年 1 月 1 日开始,XYZ Widget 公司 500 天内的 widget 销售额量

下一步是检验*稳性。我们直观地知道这个序列不是*稳的,因为图 4.4 中有一个可观察的趋势。尽管如此,我们仍将使用 ADF 测试来确保。再次,我们将使用statsmodels库中的adfuller函数,并提取 ADF 统计量和 p 值。如果 ADF 统计量是一个大的负数,且 p 值小于 0.05,则我们的序列是*稳的。否则,我们必须应用变换。

from statsmodels.tsa.stattools import adfuller

ADF_result = adfuller(df['widget_sales'])   ❶

print(f'ADF Statistic: {ADF_result[0]}')    ❷
print(f'p-value: {ADF_result[1]}')          ❸

❶ 对存储在widget_sales列中的小部件销售量运行 ADF 测试。

❷ 打印 ADF 统计量。

❸ 打印 p 值。

这导致 ADF 统计量为-1.51,p 值为 0.53。在这里,ADF 统计量不是一个大的负数,p 值大于 0.05。因此,我们的时间序列不是*稳的,我们必须应用变换使其*稳。

为了使我们的序列*稳,我们将尝试通过应用一阶差分来稳定趋势。我们可以通过使用numpy库中的diff方法来实现。记住,此方法接受一个参数n,它指定了差分的阶数。在这种情况下,因为它是一阶差分,所以n将等于 1。

import numpy as np

widget_sales_diff = np.diff(df['widget_sales'], n=1)     ❶

❶ 在我们的数据上应用一阶差分,并将结果存储在widget_sales_diff中。

我们可以可选地绘制差分序列,以查看我们是否已经稳定了趋势。图 4.5 显示了差分序列。我们可以看到,我们成功地移除了序列中的长期趋势成分,因为值在整个样本期间都围绕着 0。

图片

图 4.5 小部件销售量的差分。由于值在整个样本期间都围绕着 0,因此趋势成分已被稳定。

你能重新创建图 4.5 吗?

虽然是可选的,但在应用变换时绘制你的序列是一个好主意。这将帮助你更好地理解在特定变换后序列是否*稳。尝试自己重新创建图 4.5。

现在我们已经对我们的序列应用了变换,我们可以再次使用 ADF 测试来检验*稳性。这次,确保在存储在widget_sales_diff变量中的差分数据上运行测试。

ADF_result = adfuller(widget_sales_diff)    ❶

print(f'ADF Statistic: {ADF_result[0]}')
print(f'p-value: {ADF_result[1]}')

❶ 对差分时间序列运行 ADF 测试。

这给出了 ADF 统计量为-10.6 和 p 值为 7 × 10^(–19)。因此,由于有一个大的负 ADF 统计量和远小于 0.05 的 p 值,我们可以断定我们的序列是*稳的。

我们下一步是绘制自相关函数。statsmodels库方便地包含了plot_acf函数。我们只需传入我们的差分序列,并在lags参数中指定滞后数。记住,滞后数决定了x轴上的值域。

from statsmodels.graphics.tsaplots import plot_acf

plot_acf(widget_sales_diff, lags=30);    ❶

plt.tight_layout()

❶ 绘制差分序列的 ACF。

结果的 ACF 图如图 4.6 所示。您会注意到,直到滞后 2,系数是显著的。然后它们突然变得不显著,因为它们保持在图表的阴影区域内。这意味着我们有一个二阶*稳移动*均过程。我们可以使用二阶移动*均模型,或 MA(2) 模型,来预测我们的*稳时间序列。

图 4.6 差分序列的 ACF 图。注意系数直到滞后 2 都是显著的,然后它们突然落入图表的非显著性区域(阴影区域)。在滞后 20 附*有一些显著的系数,但这很可能是由于偶然,因为它们在滞后 3 到 20 之间以及滞后 20 之后都是不显著的。

您可以看到 ACF 图如何帮助我们确定移动*均过程的阶数。ACF 图将显示直到滞后 q 的显著自相关系数,之后所有系数都将变得不显著。因此,我们可以得出结论,我们有一个阶数为 q 的移动*均过程,或 MA(q) 过程。

4.2 预测移动*均过程

一旦确定了移动*均过程的阶数 q,我们就可以将模型拟合到我们的训练数据上并开始预测。在我们的案例中,我们发现销售量的差分是一个二阶移动*均过程,或 MA(2) 过程。

移动*均模型假设*稳性,这意味着我们的预测必须在*稳时间序列上进行。因此,我们将训练和测试我们的模型在差分后的销售量数据上。我们将尝试两种简单的预测技术并拟合一个二阶移动*均模型。简单的预测将作为基准来评估移动*均模型的表现,我们预计它将优于基准,因为我们之前确定我们的过程是一个二阶移动*均过程。一旦我们获得了对*稳过程的预测,我们就必须对预测进行逆变换,这意味着我们必须撤销差分过程,将预测值恢复到原始尺度。

在这种情况下,我们将 90% 的数据分配给训练集,并保留其余 10% 作为测试集,这意味着我们必须预测未来的 50 个时间步。我们将差分数据分配给一个 DataFrame,然后分割数据。

df_diff = pd.DataFrame({'widget_sales_diff': widget_sales_diff})   ❶

train = df_diff[:int(0.9*len(df_diff))]                            ❷
test = df_diff[int(0.9*len(df_diff)):]                             ❸
print(len(train))
print(len(test))

❶ 将差分数据放入 DataFrame 中。

❷ 前 90% 的数据进入训练集。

❸ 最后 10% 的数据进入测试集进行预测。

我们已打印出训练集和测试集的大小,以提醒您我们在差分时丢失的数据点。原始数据集包含 500 个数据点,而差分序列包含总共 499 个数据点,因为我们进行了一次差分。

现在,我们可以可视化差分和原始序列的预测期。在这里,我们将在同一张图中制作两个子图。结果如图 4.7 所示。

fig, (ax1, ax2) = plt.subplots(nrows=2, ncols=1, sharex=True)    ❶

ax1.plot(df['widget_sales'])
ax1.set_xlabel('Time')
ax1.set_ylabel('Widget sales (k$)')
ax1.axvspan(450, 500, color='#808080', alpha=0.2)

ax2.plot(df_diff['widget_sales_diff'])
ax2.set_xlabel('Time')
ax2.set_ylabel('Widget sales - diff (k$)')
ax2.axvspan(449, 498, color='#808080', alpha=0.2)

plt.xticks(
    [0, 30, 57, 87, 116, 145, 175, 204, 234, 264, 293, 323, 352, 382, 409, 439, 468, 498], 
    ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', '2020', 'Feb', 'Mar', 'Apr', 'May', 'Jun'])

fig.autofmt_xdate()
plt.tight_layout()

❶在同一张图内创建两个子图。

图片

图 4.7 原始序列和差分序列的预测期。请记住,我们的差分序列比原始状态少一个数据点。

对于预测范围,移动*均模型带来一个特殊性。MA(q)模型不允许我们一次性预测 50 步的未来。记住,移动*均模型是线性依赖于过去误差项的,而这些项在数据集中没有观察到——因此必须递归估计。这意味着对于 MA(q)模型,我们只能预测q步的未来。任何超出这个点的预测将没有过去的误差项,模型将只预测*均值。因此,预测超过q步的未来没有增加价值,因为预测将变得*淡,因为只返回*均值,这相当于基线模型。

为了避免简单地预测两个时间步之后的*均值,我们需要开发一个函数,该函数将一次预测两个时间步或更少,直到做出 50 个预测,这样我们就可以将我们的预测与测试集的观察值进行比较。这种方法称为滚动预测。在第一次遍历中,我们将训练前 449 个时间步,并预测时间步 450 和 451。然后,在第二次遍历中,我们将训练前 451 个时间步,并预测时间步 452 和 453。这样重复,直到我们最终预测时间步 498 和 499 的值。

使用 MA(q)模型进行预测

当使用 MA(q)模型时,预测超过q步的未来将简单地返回*均值,因为没有误差项可以估计超过q步。我们可以使用滚动预测来一次预测多达q步,以避免只预测序列的*均值。

我们将比较我们拟合的 MA(2)模型与两个基线:历史*均值和最后一个值。这样,我们可以确保 MA(2)模型将产生比朴素预测更好的预测,这是应该的,因为我们知道*稳过程是一个 MA(2)过程。

注意:在使用 MA(2)模型进行滚动预测时,您不需要预测两个时间步。您可以反复预测一个或两个时间步,以避免只预测*均值。同样,使用 MA(3)模型,您可以进行一个、两个或三个时间步的滚动预测。

为了创建这些预测,我们需要一个函数,该函数将反复拟合模型并在一定时间窗口内生成预测,直到获得整个测试集的预测。此函数在列表 4.1 中显示。

首先,我们从 statsmodels 库中导入 SARIMAX 函数。这个函数将允许我们将 MA(2) 模型拟合到我们的差分序列中。请注意,SARIMAX 是一个复杂的模型,它允许我们在单个模型中考虑季节效应、自回归过程、非*稳时间序列、移动*均过程和外生变量。现在,我们将不考虑所有因素,除了移动*均部分。我们将逐步构建移动*均模型,并在后面的章节中最终达到 SARIMAX 模型:

  • 接下来,我们定义我们的 rolling_forecast 函数。它将接收一个 DataFrame、训练集的长度、预测范围、窗口大小和方法。DataFrame 包含整个时间序列。

  • train_len 参数初始化可以用于拟合模型的观测数据点的数量。随着预测的进行,我们可以更新这个值来模拟观察新值,然后使用它们来做出下一个预测序列。

  • horizon 参数等于测试集的长度,表示必须预测多少个值。

  • window 参数指定了一次预测多少个时间步长。在我们的案例中,因为我们有一个 MA(2) 过程,所以窗口将等于 2。

  • method 参数指定了要使用哪种模型。同一个函数允许我们从朴素方法和 MA(2) 模型生成预测。

注意函数声明中使用了类型提示。这有助于我们避免传递意外类型的参数,这可能会导致我们的函数失败。

然后,每个预测方法都在循环中运行。循环从训练集的末尾开始,一直持续到 total_len(不包括 total_len),步长为 windowtotal_lentrain_lenhorizon 的总和)。这个循环生成一个包含 25 个值的列表,[450,451,452,...,497],但每次通过都会生成两个预测,因此返回一个包含整个测试集的 50 个预测的列表。

列表 4.1 在一个预测范围内进行滚动预测的函数

from statsmodels.tsa.statespace.sarimax import SARIMAX

def rolling_forecast(df: pd.DataFrame, train_len: int, horizon: int, 
➥ window: int, method: str) -> list:                               ❶

    total_len = train_len + horizon

    if method == 'mean':
        pred_mean = []

        for i in range(train_len, total_len, window):
            mean = np.mean(df[:i].values)
            pred_mean.extend(mean for _ in range(window))

        return pred_mean

    elif method == 'last':
        pred_last_value = []

        for i in range(train_len, total_len, window):
            last_value = df[:i].iloc[-1].values[0]
            pred_last_value.extend(last_value for _ in range(window))
        return pred_last_value

    elif method == 'MA':
        pred_MA = []

        for i in range(train_len, total_len, window):
            model = SARIMAX(df[:i], order=(0,0,2))                  ❷
            res = model.fit(disp=False)
            predictions = res.get_prediction(0, i + window - 1)
            oos_pred = predictions.predicted_mean.iloc[-window:]    ❸
            pred_MA.extend(oos_pred)

        return pred_MA

❶ 该函数接收一个包含完整模拟移动*均过程的 DataFrame。我们还将训练集的长度(在本例中为 800)和预测范围(200)传递进去。下一个参数指定了我们希望一次预测多少步(2)。最后,我们指定用于进行预测的方法。

❷ MA(q) 模型是更复杂的 SARIMAX 模型的组成部分。

predicted_mean 方法允许我们检索由 statsmodels 库定义的预测的实际值。

一旦定义了它,我们就可以使用我们的函数并使用三种方法进行预测:历史*均值、最后一个值和拟合的 MA(2) 模型。

首先,我们将创建一个 DataFrame 来保存我们的预测,并将其命名为 pred_df。我们可以复制测试集,包括实际值在 pred_df 中,这样更容易评估我们模型的表现。

然后,我们将指定一些常量。在 Python 中,将常量命名为大写字母是一种好习惯。TRAIN_LEN是训练集的长度,HORIZON是测试集的长度,这里是 50 天,WINDOW可以是 1 或 2,因为我们使用的是 MA(2)模型。在这种情况下,我们将使用 2 的值。

接下来,我们将使用我们的rolling_forecast函数为每种方法生成一系列预测。然后,每个预测列表将存储在pred_df的单独列中。

pred_df = test.copy()

TRAIN_LEN = len(train)
HORIZON = len(test)
WINDOW = 2

pred_mean = rolling_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 'mean')
pred_last_value = rolling_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 
➥ 'last')
pred_MA = rolling_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 'MA')

pred_df['pred_mean'] = pred_mean
pred_df['pred_last_value'] = pred_last_value
pred_df['pred_MA'] = pred_MA

pred_df.head()

现在,我们可以将我们的预测与测试集中的观测值进行可视化。记住,我们仍在处理差分数据集,因此我们的预测也是差分值。

对于这个图,我们将绘制部分训练数据以查看训练集和测试集之间的过渡。我们的观测值将是一条实线,我们将这条曲线标记为“实际”。然后我们将绘制来自历史*均值、最后观测值和 MA(2)模型的预测。它们将分别是一条虚线、一条虚线和虚线,标记为“均值”、“最后”和“MA(2)”。结果如图 4.8 所示。

图片

图 4.8 展示了小部件销售差分量的预测。在专业环境中,报告差分预测是没有意义的。因此,我们将在稍后撤销这种转换。

在图 4.8 中,你会注意到来自历史*均值的预测,以虚线表示,几乎是一条直线。这是预期的;过程是*稳的,因此历史*均值应该随时间保持稳定。

下一步是衡量我们模型的性能。为了做到这一点,我们将计算均方误差(MSE)。在这里,我们将使用来自sklearn包的mean_squared_error函数。我们只需将观测值和预测值传递给该函数即可。

from sklearn.metrics import mean_squared_error

mse_mean = mean_squared_error(pred_df['widget_sales_diff'], 
➥ pred_df['pred_mean'])
mse_last = mean_squared_error(pred_df['widget_sales_diff'], 
➥ pred_df['pred_last_value'])
mse_MA = mean_squared_error(pred_df['widget_sales_diff'], 
➥ pred_df['pred_MA'])

print(mse_mean, mse_last, mse_MA)

这输出了历史*均值方法的均方误差为 2.56,最后值方法的均方误差为 3.25,MA(2)模型的均方误差为 1.95。在这里,我们的 MA(2)模型是表现最好的预测方法,因为它的均方误差是三种方法中最低的。这是预期的,因为我们之前已经识别出小部件销售差分量的二阶移动*均过程,因此与简单的预测方法相比,均方误差更小。我们可以在图 4.9 中可视化所有预测技术的均方误差。

图片

图 4.9 展示了每种预测方法在小部件销售差分量上的均方误差。在这里,MA(2)模型是冠军,因为它的均方误差最低。

现在我们已经有了在*稳序列上的冠军模型,我们需要将我们的预测逆变换,以便将它们恢复到未转换数据集的原始尺度。回想一下,差分是时间t处的值与其前一个值之间的差,如图 4.10 所示。

图片

图 4.10 可视化一阶差分

为了反转我们的第一阶差分,我们需要将初始值y[0]加到第一个差分值y'[1]上。这样,我们就可以在原始尺度上恢复y[1]。这正是方程式 4.4 所展示的:

y[1] = y[0] + y'[1] = y[0] + y[1]– y[0] = y[1]

方程式 4.4

然后,可以使用差分值的累积和来获得y[2],如方程式 4.5 所示。

y[2] = y[0] + y'[1] + y'[2] = y[0] + y[1]– y[0] + y[2]– y[1] = (y[0]– y[0]) + (y[1]– y[1]) + y[2] = y[2]

方程式 4.5

应用一次累积和可以取消一阶差分。在序列经过两次差分以成为*稳的情况下,我们需要重复此过程。

因此,为了获得数据集原始尺度上的预测,我们需要使用测试的第一值作为我们的初始值。然后我们可以执行累积和以获得一系列 50 个原始尺度上的预测。我们将这些预测分配给 pred_widget_sales 列。

df['pred_widget_sales'] = pd.Series()          ❶
df['pred_widget_sales'][450:] = df['widget_sales'].iloc[450] +
➥ pred_df['pred_MA'].cumsum()                 ❷

❶ 初始化一个空列来存储我们的预测。

❷ 将预测值反变换回数据集的原始尺度。

让我们可视化我们的未变换预测与记录的数据。记住,我们现在使用存储在df中的原始数据集。

fig, ax = plt.subplots()

ax.plot(df['widget_sales'], 'b-', label='actual')        ❶
ax.plot(df['pred_widget_sales'], 'k--', label='MA(2)')   ❷

ax.legend(loc=2)

ax.set_xlabel('Time')
ax.set_ylabel('Widget sales (K$)')
ax.axvspan(450, 500, color='#808080', alpha=0.2)
ax.set_xlim(400, 500)

plt.xticks(
    [409, 439, 468, 498], 
    ['Mar', 'Apr', 'May', 'Jun'])

fig.autofmt_xdate()
plt.tight_layout()

❶ 绘制实际值。

❷ 绘制反变换后的预测。

你可以在图 4.11 中看到,我们的预测曲线(用虚线表示)遵循观察值的总体趋势,尽管它没有预测更大的谷值和峰值。

图片

图 4.11 反变换后的 MA(2)预测

最后一步是在原始数据集上报告 MSE。在专业环境中,我们不会报告差分预测,因为从业务角度来看它们没有意义;我们必须报告原始数据尺度上的值和误差。

我们可以使用sklearn中的mean_absolute_error函数来衡量*均绝对误差(MAE)。我们将使用这个指标,因为它易于解释,因为它返回预测值和实际值之间绝对差的*均值,而不是像 MSE 那样的*方差。

from sklearn.metrics import mean_absolute_error

mae_MA_undiff = mean_absolute_error(df['widget_sales'].iloc[450:], 
➥ df['pred_widget_sales'].iloc[450:])

print(mae_MA_undiff)

这会打印出 2.32 的 MAE。因此,我们的预测*均来说,偏离实际值 2,320 美元,要么高于实际值,要么低于实际值。记住,我们的数据单位是千美元,所以我们将 MAE 乘以 1,000 以表示*均绝对差。

4.3 下一步

在本章中,我们介绍了移动*均过程以及如何通过 MA(q)模型对其进行建模,其中q是阶数。你了解到,为了识别移动*均过程,你必须研究它在*稳后的 ACF 图。ACF 图将显示到滞后q的所有显著峰值,其余的将不会与 0 有显著差异。

然而,在研究*稳过程的 ACF 图时,你可能会看到正弦波模式,具有负系数和在大滞后处的显著自相关。现在你可以简单地接受这不是移动*均过程(见图 4.12)。

图片

图 4.12 识别*稳时间序列潜在过程的步骤

当我们在*稳过程的 ACF 图中看到正弦波模式时,这是一个提示表明存在自回归过程,我们必须使用 AR(p)模型来生成预测。就像 MA(q)模型一样,AR(p)模型将需要我们识别其阶数。这次我们将不得不绘制偏自相关函数,看看在哪个滞后系数突然变得不显著。下一章将完全专注于自回归过程,如何识别其阶数以及如何预测这样的过程。

4.4 练习

用这些练习检验一下你对 MA(q)模型的知识和掌握程度。完整的解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH04

4.4.1 模拟 MA(2)过程并进行预测

模拟一个*稳的 MA(2)过程。为此,使用statsmodels库中的ArmaProcess函数模拟以下过程:

y[t] = 0.9θ[t–1] + 0.3θ[t–2]

  1. 对于这个练习,生成 1,000 个样本。

    from statsmodels.tsa.arima_process import ArmaProcess
    import numpy as np
    
    np.random.seed(42)     ❶
    
    ma2 = np.array([1, 0.9, 0.3])
    ar2 = np.array([1, 0, 0])
    
    MA2_process = ArmaProcess(ar2, ma2).generate_sample(nsample=1000)
    

    ❶ 设置随机种子以确保可重复性。如果你想尝试不同的值,可以更改种子。

  2. 绘制你的模拟移动*均图。

  3. 运行 ADF 测试,检查过程是否*稳。

  4. 绘制自相关函数(ACF),看看在滞后 2 之后是否有显著的系数。

  5. 将你的模拟序列分为训练集和测试集。将前 800 个时间步长用于训练集,其余的分配给测试集。

  6. 在测试集上进行预测。使用均值、最后一个值和 MA(2)模型。确保你使用我们定义的recursive_forecast函数反复预测 2 个时间步长。

  7. 绘制你的预测图。

  8. 测量均方误差(MSE),并确定你的冠军模型。

  9. 在条形图中绘制你的 MSE。

4.4.2 模拟 MA(q)过程并进行预测

重新创建之前的练习,但模拟一个你选择的移动*均过程。尝试模拟三阶或四阶移动*均过程。我建议生成 10,000 个样本。特别关注 ACF,看看你的系数在滞后 q 之后是否变得不显著。

摘要

  • 移动*均过程表明,当前值线性依赖于均值、当前误差项和过去的误差项。误差项是正态分布的。

  • 你可以通过研究 ACF 图来识别*稳移动*均过程的阶数 q。系数仅在滞后 q 时显著。

  • 你可以预测未来最多 q 步,因为误差项在数据中未被观察到,必须递归地估计。

  • 预测超过 q 步的未来将简单地返回序列的*均值。为了避免这种情况,你可以应用滚动预测。

  • 如果你将数据应用了转换,你必须撤销这个转换,以便将你的预测回到数据的原始尺度。

  • 移动*均模型假设数据是*稳的。因此,你只能在这个模型上使用*稳数据。

5 建模自回归过程

本章涵盖

  • 展示自回归过程

  • 定义偏自相关函数(PACF)

  • 使用 PACF 图确定自回归过程的阶数

  • 使用自回归模型预测时间序列

在上一章中,我们介绍了移动*均过程,也称为 MA(q),其中q是阶数。您了解到在移动*均过程中,当前值是线性依赖于当前和过去误差项的。因此,如果您预测超过q步,预测将变得*淡,只会返回序列的均值,因为误差项在数据中未观察到,必须递归估计。最后,您看到可以通过研究 ACF 图来确定*稳的 MA(q)过程的阶数;自相关系数将一直显著到滞后q。在自相关系数缓慢衰减或呈现正弦波模式的情况下,您可能处于自回归过程的存在。

在本章中,我们首先定义自回归过程。然后,我们将定义偏自相关函数,并使用它来找到数据集潜在自回归过程的阶数。最后,我们将使用 AR(p)模型进行预测。

5.1 预测零售店*均每周客流量

假设您想要预测零售店*均每周客流量,以便店长能更好地管理员工的工作时间表。如果预计很多人会来商店,应该有更多的员工在场提供帮助。如果预计来店的人较少,经理可以安排较少的员工工作。这样,商店可以优化其工资支出,并确保员工不会被商店的访客压倒或忽视。

对于这个例子,我们有 1,000 个数据点,每个数据点代表从 2000 年开始的零售店*均每周客流量。您可以在图 5.1 中看到数据随时间的变化。

图片

图 5.1 零售店*均每周客流量。数据集包含 1,000 个数据点,从 2000 年的第一周开始。请注意,这是虚构数据。

在图 5.1 中,我们可以看到一条带有沿途峰值和谷值的长期趋势。我们可以直观地说,这个时间序列不是一个*稳过程,因为我们观察到随着时间的推移存在趋势。此外,数据中没有任何明显的周期性模式,因此我们可以暂时排除任何季节性影响。

再次强调,为了预测*均每周客流量,我们需要识别潜在的过程。因此,我们必须应用我们在第四章中介绍的同一步骤。这样,我们可以验证是否存在随机游走或移动*均过程。步骤在图 5.2 中显示。

图片

图 5.2 识别*稳时间序列潜在过程的步骤。到目前为止,我们可以识别出随机游走或移动*均过程。

在这个例子中,数据已经收集完毕,因此我们可以继续测试*稳性。如前所述,时间序列中的趋势存在意味着我们的序列可能不是*稳的,因此我们必须应用转换使其*稳。然后我们将绘制自相关图。随着我们学习本章,您将看到不仅存在自相关,而且自相关图将有一个缓慢衰减的趋势。

这表明存在一个阶数为 p 的自回归过程,也称为 AR(p)。在这种情况下,我们必须绘制 偏自相关函数 (PACF) 来找到阶数 p。就像 MA(q) 过程的 ACF 图上的系数一样,PACF 图上的系数在滞后 p 后会突然变得不显著,从而确定自回归过程的阶数。

再次强调,自回归过程的阶数决定了 AR(p) 模型中必须包含多少参数。然后我们将准备好进行预测。在这个例子中,我们希望预测下周的*均人流量。

5.2 定义自回归过程

自回归过程表明输出变量与其自身的先前值线性相关。换句话说,它是对变量自身的回归。

自回归过程表示为 AR(p) 过程,其中 p 是阶数。在这种情况下,当前值 y[t] 是一个常数 C、当前误差项 ϵ[t](也是白噪声)以及序列的过去值 y[tp] 的线性组合。过去值对当前值影响的大小用 ϕ[p] 表示,它代表 AR(p) 模型的系数。数学上,我们用方程 5.1 表达一个一般的 AR(p) 模型。

y[t] = C + ϕ[1]y[t–1] + ϕ[2]y[t–2] +⋅⋅⋅ ϕ[p]y[tp] + ϵ[t]

方程 5.1

自回归过程

自回归过程是对一个变量对其自身的回归。在时间序列中,这意味着当前值线性依赖于其过去值。

自回归过程表示为 AR(p),其中 p 是阶数。AR(p) 模型的一般表达式是

y[t] = C + ϕ[1]y[t–1] + ϕ[2]y[t–2] +⋅⋅⋅+ ϕ[p]y[tp] + ϵ[t]

与移动*均过程类似,自回归过程的阶数 p 决定了影响当前值的过去值的数量。如果我们有一个一阶自回归过程,也称为 AR(1),那么当前值 y[t] 只依赖于一个常数 C、前一个时间步的值 ϕ[1]y[t–1] 以及一些白噪声 ϵ[t],如方程 5.2 所示。

y[t] = C + ϕ[1]y[t–1] + ϵ[t]

方程 5.2

观察方程式 5.2,你可能会注意到它与我们在第三章中讨论的随机游走过程非常相似。事实上,如果 ϕ[1] = 1,那么方程式 5.2 就变成了

y[t] = C + y[t–1] + ϵ[t]

这就是我们的随机游走模型。因此,我们可以说随机游走是自回归过程的一个特例,其中阶数 p 为 1,且 ϕ[1] 等于 1。注意,如果 C 不等于 0,那么我们有一个带有漂移的随机游走。

在二阶自回归过程或 AR(2)的情况下,当前值 y[t] 线性依赖于常数 C、前一个时间步的值 ϕ[1]y[t–1]、两个时间步之前的值 ϕ[2]y[t–2],以及当前误差项 ϵ[t],如方程式 5.3 所示。

y[t] = C + ϕ[1]y[t–1] + ϕ[2]y[t–2] + ϵ[t]

方程式 5.3

我们可以看到阶数 p 如何影响我们必须包含在模型中的参数数量。与移动*均过程一样,我们必须找到自回归过程的正确阶数,以便构建适当的模型。这意味着如果我们识别出一个 AR(3)过程,我们将使用三阶自回归模型进行预测。

5.3 寻找*稳自回归过程的阶数

就像移动*均过程一样,有一种方法可以确定*稳自回归过程的阶数 p。我们可以扩展识别移动*均阶数所需的步骤,如图 5.3 所示。

图 5.3 识别自回归过程阶数的步骤

自然的第一步是收集数据。在这里,我们将使用本章开头看到的*均每周客流量数据集。我们将使用 pandas 读取数据并将其存储为 DataFrame

注意:您可以随时在 GitHub 上查看本章的源代码:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH05

import pandas as pd

df = pd.read_csv('../data/foot_traffic.csv')    ❶

df.head()                                       ❷

❶ 将 CSV 文件读入 DataFrame。

❷ 显示数据的前五行。

你会看到我们的数据包含一个单独的 foot_traffic 列,其中记录了零售店的*均每周客流量。

如往常一样,我们将绘制数据以查看是否存在任何可观察的模式,例如趋势或季节性。到目前为止,你应该已经熟悉了时间序列的绘制,因此我们不会深入探讨生成图表的代码。结果如图 5.4 所示。

import matplotlib.pyplot as plt

fig, ax = plt.subplots()

ax.plot(df['foot_traffic'])                                      ❶
ax.set_xlabel('Time')                                            ❷
ax.set_ylabel('Average weekly foot traffic')                     ❸

plt.xticks(np.arange(0, 1000, 104), np.arange(2000, 2020, 2))    ❹

fig.autofmt_xdate()                                              ❺
plt.tight_layout()                                               ❻

❶ 绘制零售店的*均每周客流量图。

❷ 标注 x 轴。

❸ 标注 y 轴。

❹ 标注 x 轴的刻度。

❺ 将 x 轴刻度的标签倾斜,以便它们显示得更好。

❻ 移除图周围的多余空白。

观察图 5.4,你会发现没有周期性模式,因此我们可以排除季节性的存在。至于趋势,它在过去几年中有时为正,有时为负,最*的趋势自 2016 年以来一直是正的。

图 5.4 零售店*均每周客流量。数据集包含 1,000 个数据点,始于 2000 年的第一周。

下一步是检查*稳性。如前所述,趋势的存在意味着我们的序列很可能是非*稳的。让我们使用 ADF 测试来验证这一点。再次强调,你应该能够运行这个测试而不需要详细解释代码。

from statsmodels.tsa.stattools import adfuller

ADF_result = adfuller(df['foot_traffic'])     ❶

print(f'ADF Statistic: {ADF_result[0]}')      ❷
print(f'p-value: {ADF_result[1]}')            ❸

❶ 对存储在 foot_traffic 列中的*均每周客流量运行 ADF 测试。

❷ 打印 ADF 统计量。

❸ 打印 p 值。

这将打印出 ADF 统计量为 –1.18 以及 p 值为 0.68。由于 ADF 统计量不是一个大的负数,并且它的 p 值大于 0.05,我们不能拒绝零假设,因此我们的序列是非*稳的。

因此,我们必须应用转换使其*稳。为了消除趋势的影响并稳定序列的均值,我们将使用差分。

import numpy as np
foot_traffic_diff = np.diff(df['foot_traffic'], n=1)     ❶

❶ 对数据进行一阶差分,并将结果存储在 foot_traffic_diff 中。

图 5.5 零售店差分*均每周客流量。注意,趋势效应已被消除,因为序列开始和结束的大致值相同。

可选地,我们可以绘制我们的差分序列 foot_traffic_diff 来查看我们是否成功消除了趋势的影响。差分序列显示在图 5.5 中。我们可以看到我们确实消除了长期趋势,因为序列开始和结束的大致值相同。

你能重新创建图 5.5 吗?

虽然不是必须的,但在应用转换时绘制你的序列是一个好主意。这将帮助你更好地理解在特定转换后序列是否*稳。尝试自己重新创建图 5.5。

对序列应用转换后,我们可以通过在差分序列上运行 ADF 测试来验证序列是否*稳。

ADF_result = adfuller(foot_traffic_diff)      ❶

print(f'ADF Statistic: {ADF_result[0]}')
print(f'p-value: {ADF_result[1]}')

❶ 对差分时间序列运行 ADF 测试。

这将打印出 ADF 统计量为 –5.27 和 p 值为 6.36×10^(–6)。由于 p 值小于 0.05,我们可以拒绝零假设,这意味着我们现在有一个*稳序列。

下一步是绘制 ACF 图,看看是否存在自相关,以及系数在某个滞后后是否突然变得不显著。正如我们在前两个章节中所做的那样,我们将使用 statsmodels 中的 plot_acf 函数。结果显示在图 5.6 中。

from statsmodels.graphics.tsaplots import plot_acf

plot_acf(foot_traffic_diff, lags=20);    ❶

plt.tight_layout()

❶ 绘制差分序列的 ACF 图。

图 5.6 零售店*均每周客流量差异的 ACF 图。注意该图是如何缓慢衰减的。这是我们以前没有观察到的一种行为,它表明了一个自回归过程。

观察图 5.6,你会注意到我们在滞后 0 之外有显著的自相关系数。因此,我们知道我们的过程不是一个随机游走。此外,你会注意到随着滞后的增加,系数呈指数衰减。这意味着不存在滞后使得系数突然变得不显著。这意味着我们没有一个移动*均过程,我们很可能在研究一个自回归过程。

当一个*稳过程的 ACF 图表现出指数衰减的模式时,我们可能有一个自回归过程在起作用,我们必须找到另一种方法来识别 AR(p)过程的阶数p。具体来说,我们必须将注意力转向偏自相关函数(PACF)图。

5.3.1 偏自相关函数 (PACF)

在尝试识别一个*稳自回归过程的阶数时,我们像移动*均过程一样使用了 ACF 图。不幸的是,ACF 图不能给我们提供这些信息,我们必须转向偏自相关函数(PACF)。

记住,自相关衡量的是时间序列滞后值之间的线性关系。因此,自相关函数衡量的是随着滞后的增加,两个值之间的相关性如何变化。

为了理解偏自相关函数,让我们考虑以下场景。假设我们有一个以下 AR(2)过程:

y[t] = 0.33y[t–1] + 0.50y[t–2]

方程 5.4

我们希望测量y[t]y[t–2]之间的关系;换句话说,我们想要测量它们的关联性。这是通过自相关函数(ACF)来完成的。然而,从方程中我们可以看出y[t–1]也对y[t]有影响。更重要的是,它还对y[t–2]的值有影响,因为在 AR(2)过程中,每个值都依赖于前两个值。因此,当我们使用 ACF 测量y[t]y[t–2]之间的自相关时,我们没有考虑到y[t–1]对y[t]y[t–2]都有影响这一事实。这意味着我们并没有测量y[t–2]对y[t]真正影响。为了做到这一点,我们必须消除y[t–1]的影响。因此,我们正在测量y[t]y[t–2]之间的偏自相关。

在更正式的术语中,偏自相关衡量的是在去除中间相关滞后值影响的情况下,时间序列中滞后值之间的相关性。这些被称为混杂变量。偏自相关函数将揭示当滞后增加时,偏自相关如何变化。

偏自相关

部分自相关度衡量的是在移除介于相关滞后值之间的影响后,时间序列中滞后值之间的相关性。我们可以绘制部分自相关函数来确定*稳 AR(p)过程的阶数。在滞后p之后,系数将不显著。

让我们验证绘制 PACF 是否能够揭示方程 5.4 中所示过程的阶数。从方程 5.4 中我们知道我们有一个二阶自回归过程,即 AR(2)。我们将使用statsmodels库中的ArmaProcess函数来模拟它。该函数期望一个包含 MA(q)过程系数的数组和一个包含 AR(p)过程系数的数组。由于我们只对模拟 AR(2)过程感兴趣,我们将 MA(q)过程的系数设为 0。然后,根据statsmodels文档的说明,AR(2)过程的系数必须与我们希望模拟的系数符号相反。因此,数组将包含-0.33 和-0.50。此外,该函数要求我们包括滞后 0 的系数,即乘以y[t]的数字。在这里,这个数字是 1。

一旦定义了系数数组,我们就可以将它们输入到ArmaProcess函数中,并将生成 1,000 个样本。确保将随机种子设置为 42,以便重现这里显示的结果。

from statsmodels.tsa.arima_process import ArmaProcess
import numpy as np

np.random.seed(42)                                                   ❶

ma2 = np.array([1, 0, 0])                                            ❷
ar2 = np.array([1, -0.33, -0.50])                                    ❸

AR2_process = ArmaProcess(ar2, ma2).generate_sample(nsample=1000)    ❹

❶ 将随机种子设置为 42,以便重现这里显示的结果。

❷ 将 MA(q)过程的系数设为 0,因为我们只对模拟 AR(2)过程感兴趣。请注意,滞后 0 的第一个系数是 1,必须按照文档规定提供。

❸ 设置 AR(2)过程的系数。同样,滞后 0 的系数是 1。然后,按照文档规定,将系数写成与方程 5.4 中定义的相反的符号。

❹ 模拟 AR(2)过程并生成 1,000 个样本。

现在我们已经模拟了一个 AR(2)过程,让我们绘制 PACF 图,看看系数是否在滞后 2 后突然变得不显著。如果是这样,我们就知道我们可以使用 PACF 图来确定*稳自回归过程的阶数,就像我们可以使用 ACF 图来确定*稳移动*均过程的阶数一样。

statsmodels库允许我们快速绘制 PACF 图。我们可以使用plot_pacf函数,该函数只需要我们的序列和要在图上显示的滞后数。

from statsmodels.graphics.tsaplots import plot_pacf

plot_pacf(AR2_process, lags=20);    ❶

plt.tight_layout()

❶ 绘制我们模拟的 AR(2)过程的 PACF 图。

结果图如图 5.7 所示,它显示我们有一个阶数为 2 的自回归过程。

图片

图 5.7 模拟 AR(2)过程的 PACF 图。你可以清楚地看到,在滞后 2 之后,部分自相关系数与 0 没有显著差异。因此,我们可以使用 PACF 图来识别*稳 AR(p)模型的阶数。

我们现在知道我们可以使用 PACF 图来识别*稳 AR(p)过程的阶数。PACF 图中的系数将在滞后p之前是显著的。之后,它们应该不会与 0 显著不同。

让我们看看我们是否可以将相同的策略应用到我们的*均每周客流量数据集上。我们使序列*稳,并看到 ACF 图显示了缓慢衰减的趋势。让我们绘制 PACF 图,看看在特定的滞后之后滞后是否变得不显著。

这个过程与我们刚才做的是一样的,但这次我们将绘制存储在foot_traffic_diff中的差分序列的 PACF 图。你可以看到结果图在图 5.8 中。

plot_pacf(foot_traffic_diff, lags=20);    ❶

plt.tight_layout()

❶ 绘制差分序列的 PACF 图。

观察图 5.8,你可以看到在滞后 3 之后没有显著的系数。因此,差分的*均每周客流量是一个 3 阶的自回归过程,也可以表示为 AR(3)。

图 5.8 零售店差分*均每周客流量的 PACF 图。你可以看到在滞后 3 之后系数不再显著。因此,我们可以说我们的*稳过程是一个三阶自回归过程,或者是一个 AR(3)过程。

5.4 预测自回归过程

一旦确定了阶数,我们就可以拟合一个自回归模型来预测我们的时间序列。在这种情况下,该模型也称为 AR(p),其中p仍然是过程的阶数。

我们将使用我们一直在使用的相同数据集来预测下周零售店的*均客流量。为了评估我们的预测,我们将保留最后 52 周的数据作为测试集,其余的将用于训练。这样,我们就可以评估我们的预测在 1 年期间的表现。

df_diff = pd.DataFrame({'foot_traffic_diff': foot_traffic_diff})    ❶

train = df_diff[:-52]                                               ❷
test = df_diff[-52:]                                                ❸

print(len(train))                                                   ❹
print(len(test))                                                    ❺

❶ 从差分客流量数据创建一个 DataFrame。

❷ 训练集是除了最后 52 个数据点之外的所有数据。

❸ 测试集是最后 52 个数据点。

❹ 显示训练集中有多少个数据点。

❺ 显示测试集中有多少个数据点。

你可以看到,我们的训练集包含 947 个数据点,而测试集包含 52 个数据点,正如预期的那样。请注意,两个集合的总和为 999,比我们原始序列少一个数据点。这是正常的,因为我们应用了差分来使序列*稳,而且我们知道差分会从序列中移除第一个数据点。

接下来,我们将可视化我们场景的测试期,包括原始序列和差分序列。该图显示在图 5.9 中。

图 5.9 原始序列和差分序列的预测测试期。记住,我们的差分序列已经失去了第一个数据点。

fig, (ax1, ax2) = plt.subplots(nrows=2, ncols=1, sharex=True, 
➥ figsize=(10, 8))                   ❶

ax1.plot(df['foot_traffic'])
ax1.set_xlabel('Time')
ax1.set_ylabel('Avg. weekly foot traffic')
ax1.axvspan(948, 1000, color='#808080', alpha=0.2)

ax2.plot(df_diff['foot_traffic_diff'])
ax2.set_xlabel('Time')
ax2.set_ylabel('Diff. avg. weekly foot traffic')
ax2.axvspan(947, 999, color='#808080', alpha=0.2)

plt.xticks(np.arange(0, 1000, 104), np.arange(2000, 2020, 2))

fig.autofmt_xdate()
plt.tight_layout()

❶ 使用 figsize 参数指定图的大小。第一个数字是高度,第二个数字是宽度,两者都以英寸为单位。

由于我们的目标是预测下周零售店的*均客流量,我们将对测试集进行滚动预测。记住,我们的数据是在每周期间记录的,所以预测下一个时间步长意味着我们正在预测下周的*均客流量。

我们将使用三种不同的方法进行预测。历史*均方法和最后已知值方法将作为基线,我们将使用 AR(3) 模型,因为我们之前已经确定我们有一个三阶自回归过程。正如我们在上一章所做的那样,我们将使用均方误差 (MSE) 来评估每种预测方法的性能。

此外,我们将重复使用我们在上一章中定义的函数,在测试期间进行递归预测。然而,这次我们必须包括一个使用自回归模型的方法。

我们将再次使用 statsmodels 中的 SARIMAX 函数,因为它包含 AR 模型。如前所述,SARIMAX 是一个复杂的模型,它允许我们在一个单一模型中考虑季节效应、自回归过程、非*稳时间序列、移动*均过程和外生变量。现在,我们将忽略所有因素,除了移动自回归部分。

列表 5.1 用于在预测范围内进行滚动预测的函数

def rolling_forecast(df: pd.DataFrame, train_len: int, horizon: int, 
➥ window: int, method: str) -> list:

    total_len = train_len + horizon
    end_idx = train_len

    if method == 'mean':
        pred_mean = []

        for i in range(train_len, total_len, window):
            mean = np.mean(df[:i].values)
            pred_mean.extend(mean for _ in range(window))

        return pred_mean

    elif method == 'last':
        pred_last_value = []

        for i in range(train_len, total_len, window):
            last_value = df[:i].iloc[-1].values[0]
            pred_last_value.extend(last_value for _ in range(window))

        return pred_last_value

    elif method == 'AR':
        pred_AR = []

        for i in range(train_len, total_len, window):
            model = SARIMAX(df[:i], order=(3,0,0))      ❶
            res = model.fit(disp=False)
            predictions = res.get_prediction(0, i + window - 1)
            oos_pred = predictions.predicted_mean.iloc[-window:]
            pred_AR.extend(oos_pred)

        return pred_AR

❶ 指定 AR(3) 模型的阶数。

一旦我们的函数定义好了,我们就可以用它来根据每种方法生成预测。我们将它们分配给 test 中的各自列。

TRAIN_LEN = len(train)                      ❶
HORIZON = len(test)                         ❷
WINDOW = 1                                  ❸

pred_mean = rolling_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 'mean')
pred_last_value = rolling_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 
➥ 'last')    
pred_AR = rolling_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 'AR')

test['pred_mean'] = pred_mean               ❹
test['pred_last_value'] = pred_last_value   ❹
test['pred_AR'] = pred_AR                   ❹

test.head()

❶ 存储训练集的长度。注意,在 Python 中,常量通常用大写字母表示。

❷ 存储测试集的长度。

❸ 由于我们希望预测下一个时间步长,我们的窗口是 1。

❹ 将预测存储在测试的相应列中。

我们现在可以可视化我们的预测与测试集中的观测值。请注意,我们正在处理差分序列,因此我们的预测也是差分值。结果如图 5.10 所示。

fig, ax = plt.subplots()

ax.plot(df_diff['foot_traffic_diff'])                       ❶
ax.plot(test['foot_traffic_diff'], 'b-', label='actual')    ❷
ax.plot(test['pred_mean'], 'g:', label='mean')              ❸
ax.plot(test['pred_last_value'], 'r-.', label='last')       ❹
ax.plot(test['pred_AR'], 'k--', label='AR(3)')              ❺

ax.legend(loc=2)

ax.set_xlabel('Time')
ax.set_ylabel('Diff. avg. weekly foot traffic')

ax.axvspan(947, 998, color='#808080', alpha=0.2)

ax.set_xlim(920, 999)

plt.xticks([936, 988],[2018, 2019])

fig.autofmt_xdate()
plt.tight_layout()

❶ 绘制训练集的一部分,以便我们可以看到从训练集到测试集的过渡。

❷ 绘制测试集的值。

❸ 绘制历史*均方法预测的结果。

❹ 绘制最后已知值方法的预测结果。

❺ 绘制 AR(3) 模型的预测结果。

图片

图 5.10 零售店*均每周客流量差异的预测

观察图 5.10,你会看到,再次使用历史*均方法产生了一条直线,在图中以虚线表示。至于 AR(3) 模型和最后已知值方法的预测,曲线几乎与测试集混淆,因此我们必须测量 MSE 来评估哪种方法性能最好。同样,我们将使用 sklearn 库中的 mean_squared_error 函数。

ffrom sklearn.metrics import mean_squared_error

mse_mean = mean_squared_error(test['foot_traffic_diff'], test['pred_mean'])
mse_last = mean_squared_error(test['foot_traffic_diff'], 
➥ test['pred_last_value'])
mse_AR = mean_squared_error(test['foot_traffic_diff'], test['pred_AR'])

print(mse_mean, mse_last, mse_AR)

这会输出历史*均方法为 3.11 的均方误差(MSE),最后已知值为 1.45,AR(3)模型为 0.92。由于 AR(3)模型的 MSE 是三者中最低的,我们得出结论,AR(3)模型是预测下周*均客流量表现最好的方法。这是预期的,因为我们已经确定我们的*稳过程是一个三阶自回归过程。使用 AR(3)模型建模产生最佳预测是有意义的。

由于我们的预测是差分值,我们需要进行逆变换,以便将我们的预测值恢复到原始数据尺度;否则,我们的预测在商业环境中将没有意义。为此,我们可以取我们预测值的累积和,并将其加到原始序列训练集的最后一个值上。这个点发生在索引 948 处,因为我们在一个包含 1,000 个点的数据集中预测了最后 52 周。

df['pred_foot_traffic'] = pd.Series()
df['pred_foot_traffic'][948:] = df['foot_traffic'].iloc[948] + 
➥ pred_df['pred_AR'].cumsum()      ❶

❶ 将未微分预测分配给 df 中的 pred_foot_traffic 列。

现在我们可以将我们的未微分预测值与原始序列测试集中的观测值在其原始尺度下进行绘图。

fig, ax = plt.subplots()

ax.plot(df['foot_traffic'])
ax.plot(df['foot_traffic'], 'b-', label='actual')        ❶
ax.plot(df['pred_foot_traffic'], 'k--', label='AR(3)')   ❷

ax.legend(loc=2)

ax.set_xlabel('Time')
ax.set_ylabel('Average weekly foot traffic')

ax.axvspan(948, 1000, color='#808080', alpha=0.2)

ax.set_xlim(920, 1000)
ax.set_ylim(650, 770)

plt.xticks([936, 988],[2018, 2019])

fig.autofmt_xdate()
plt.tight_layout()

❶ 绘制实际值。

❷ 绘制未微分预测。

在图 5.11 中,你可以看到我们的模型(以虚线表示)遵循测试集中观测值的一般趋势。

图片

图 5.11 AR(3)模型的未微分预测

现在,我们可以对原始数据集测量*均绝对误差(MAE),以在商业环境中获得其意义。我们将简单地使用未微分预测来测量 MAE。

from sklearn.metrics import mean_absolute_error

mae_AR_undiff = mean_absolute_error(df['foot_traffic'][948:], 
➥ df['pred_foot_traffic'][948:])

print(mae_AR_undiff)

这会输出*均绝对误差为 3.45。这意味着我们的预测值*均偏离实际值 3.45 人,无论是高于还是低于每周客流量。请注意,我们报告 MAE 是因为它具有简单易懂的商业意义,易于理解和解释。

5.5 下一步

在本章中,我们介绍了自回归过程以及它如何通过 AR(p)模型进行建模,其中p是阶数,它决定了模型中包含多少滞后值。我们还看到了如何绘制 ACF 不能帮助我们确定*稳 AR(p)过程的阶数。相反,我们必须绘制 PACF,其中偏自相关系数将在滞后p时显著。

然而,可能存在一种情况,ACF 和 PACF 都无法提供信息。如果 ACF 和 PACF 图都表现出缓慢衰减或正弦波模式,那会怎样?在这种情况下,无法推断出 MA(q)或 AR(p)过程的阶数。这意味着我们面临的是一个更复杂的过程,它很可能是 AR(p)过程和 MA(q)过程的组合。这被称为自回归移动*均(ARMA)过程,或 ARMA(p,q),它将是下一章的主题。

5.6 练习

通过这些练习测试你对 AR(p)模型的知识和掌握。所有练习的解决方案都可以在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH05

5.6.1 模拟 AR(2)过程并进行预测

模拟一个*稳的 AR(2)过程。使用statsmodels库中的ArmaProcess函数模拟此过程:

y[t] = 0.33y[t–1] + 0.50y[t–2]

  1. 对于这个练习,生成 1,000 个样本。

    from statsmodels.tsa.arima_process import ArmaProcess
    import numpy as np
    
    np.random.seed(42)     ❶
    
    ma2 = np.array([1, 0, 0])
    ar2 = np.array([1, -0.33, -0.50])
    
    AR2_process = ArmaProcess(ar2, ma2).generate_sample(nsample=1000)
    

    ❶ 设置种子以实现可重复性。如果你想尝试不同的值,可以更改种子。

  2. 绘制你的模拟自回归过程图。

  3. 运行 ADF 测试,检查过程是否*稳。如果不*稳,则应用差分。

  4. 绘制自相关函数(ACF)。它是缓慢衰减的吗?

  5. 绘制 PACF。在滞后 2 之后有显著的系数吗?

  6. 将你的模拟序列分为训练集和测试集。取前 800 个时间步长作为训练集,其余的分配给测试集。

  7. 在测试集上进行预测。使用历史均值法、最后已知值法和 AR(2)模型。使用rolling_forecast函数,并使用窗口长度为 2。

  8. 绘制你的预测图。

  9. 测量 MSE,并确定你的冠军模型。

  10. 在条形图中绘制你的 MSE。

5.6.2 模拟 AR(p)过程并进行预测

重新创建之前的练习,但模拟一个你选择的 AR(p)过程。尝试使用三阶或四阶自回归过程。我建议生成 10,000 个样本。

在预测时,尝试调整rolling_forecast函数的window参数的不同值。它如何影响模型的表现?是否存在一个值可以最小化均方误差(MSE)?

摘要

  • 自回归过程表明当前值与其过去值和一个误差项线性相关。

  • 如果一个*稳过程的 ACF 图显示缓慢衰减,那么你很可能有一个自回归过程。

  • 部分自相关衡量的是在移除其他自相关滞后值的影响后,时间序列两个滞后值之间的相关性。

  • 绘制一个*稳自回归过程的 PACF 将显示过程的阶数p。系数将仅在滞后p时显著。

6 模型复杂的时间序列

本章涵盖

  • 检验自回归移动*均模型或 ARMA(p,q)

  • 尝试 ACF 和 PACF 图的限制

  • 使用赤池信息准则 (AIC) 选择最佳模型

  • 使用残差分析分析时间序列模型

  • 建立一个通用的建模程序

  • 使用 ARMA(p,q) 模型进行预测

在第四章中,我们介绍了移动*均过程,表示为 MA(q),其中 q 是阶数。你了解到,在移动*均过程中,当前值与均值、当前误差项和过去误差项线性相关。阶数 q 可以通过自相关图推断,其中自相关系数将在滞后 q 时显著。在自相关图显示缓慢衰减模式或正弦波模式的情况下,可能存在的是自回归过程而不是移动*均过程。

这使我们进入了第五章,其中我们介绍了自回归过程,表示为 AR(p),其中 p 是阶数。在自回归过程中,当前值与其自身的过去值线性相关。换句话说,它是变量对其自身的回归。你看到我们可以通过偏自相关图推断出阶数 p,其中偏自相关系数将在滞后 p 时显著。因此,我们现在可以识别、建模和预测随机游走、纯移动*均过程和纯自回归过程。

下一步是学习如何处理无法从自相关图或偏自相关图推断出顺序的时间序列。这意味着这两个图都表现出缓慢衰减的模式或正弦波模式。在这种情况下,我们面临的是自回归移动*均(ARMA)过程。这表示了我们在前两章中涵盖的自回归和移动*均过程的组合。

在本章中,我们将研究自回归移动*均过程,ARMA(p,q),其中 p 表示自回归部分的阶数,q 表示移动*均部分的阶数。此外,使用 ACF 和 PACF 图来确定 qp 的阶数变得困难,因为两个图都将显示缓慢衰减或正弦波模式。因此,我们将定义一个通用的建模程序,使我们能够对这种复杂的时间序列进行建模。此程序涉及使用 赤池信息准则 (AIC) 进行模型选择,这将确定我们序列中 pq 的最佳组合。然后我们必须通过研究模型的残差的自相关图、Q-Q 图和密度图来评估模型的合理性,以确定它们是否接*白噪声。如果是这样,我们就可以继续使用 ARMA(p,q) 模型预测我们的时间序列。

本章将介绍预测复杂时间序列的基础知识。在这里介绍的所有概念都将在后续章节中重复使用,当我们开始建模非*稳时间序列并引入季节性和外生变量时。

6.1 预测数据中心带宽使用

假设你被分配预测大型数据中心的带宽使用情况的任务。带宽被定义为可以传输数据的最大速率。其基本单位是每秒比特数(bps)。

预测带宽使用情况允许数据中心更好地管理其计算资源。在预期带宽使用较少的情况下,它们可以关闭部分计算资源。这反过来又减少了开支并允许进行维护。另一方面,如果预期带宽使用会增加,它们可以将所需资源分配给满足需求,并确保低延迟,从而保持客户满意。

对于这种情况,有 10,000 个数据点表示从 2019 年 1 月 1 日开始的小时带宽使用情况。在这里,带宽以每秒兆比特数(Mbps)衡量,相当于 10⁶ bps。我们可以在图 6.1 中可视化我们的时间序列。

图片

图 6.1 自 2019 年 1 月 1 日以来的数据中心每小时带宽使用情况。数据集包含 10,000 个数据点。

观察图 6.1,你可以看到随时间变化的长期趋势,这意味着这个序列可能不是*稳的,因此我们需要应用转换。此外,似乎没有周期性行为,因此我们可以排除我们的序列中存在季节性的可能性。

为了预测带宽使用情况,我们需要识别我们序列中的潜在过程。因此,我们将遵循第五章中定义的步骤。这样,我们可以验证我们是否有随机游走、移动*均过程或自回归过程。步骤在图 6.2 中显示。

图片

图 6.2 识别随机游走、移动*均过程和自回归过程的步骤

第一步是收集数据,在这个案例中这项工作已经完成。然后我们必须确定我们的序列是否是*稳的。图中趋势的存在暗示我们的序列可能不是*稳的。尽管如此,我们将应用 ADF 测试来检查*稳性,并根据结果进行相应的转换。

然后我们将绘制 ACF 函数,并发现滞后 0 之后存在显著的自相关系数,这意味着它不是一个随机游走。然而,我们将观察到系数缓慢衰减。它们在一定的滞后后不会突然变得不显著,这意味着它不是一个纯粹移动*均过程。

接下来,我们将转向绘制 PACF 函数。这次我们会注意到一个正弦波模式,这意味着系数在一定的滞后后不会突然变得不显著。这将导致我们得出结论,它也不是一个纯粹的自回归过程。

因此,它必须是一个自回归和移动*均过程的组合,从而形成一个可以用 ARMA(p,q)模型进行建模的自回归移动*均过程,其中p是自回归过程的阶数,q是移动*均过程的阶数。使用 ACF 和 PACF 图分别找到pq是困难的,因此我们将拟合许多具有不同pq值组合的 ARMA(p,q)模型。然后,我们将根据赤池信息准则选择一个模型,并通过分析其残差来评估其可行性。理想情况下,模型的残差将具有类似于白噪声的特征。然后,我们将能够使用此模型进行预测。对于这个例子,我们将预测未来两小时的每小时带宽使用情况。

6.2 检查自回归移动*均过程

自回归移动*均过程是自回归过程和移动*均过程的组合。它表明,当前值线性依赖于其自身的先前值和一个常数,就像在自回归过程中一样,同时也依赖于序列的均值、当前误差项和过去误差项,就像在移动*均过程中一样。

自回归移动*均过程表示为 ARMA(p,q),其中p是自回归部分的阶数,q是移动*均部分的阶数。数学上,ARMA(p,q)过程表示为常数 C、序列y[tp]的过去值、序列的均值µ、过去误差项ϵ[tq]和当前误差项ϵ[t]的线性组合,如方程 6.1 所示。

y[t] = C + ϕ[1]y[t–1] + ϕ[2]y[t–2] +⋅⋅⋅+ ϕ[p]y[t–p] + ϵ[t] + θ[1]ϵ[t–1] + θ[2]ϵ[t–2] +⋅⋅⋅+ θ[q]ϵ[tq]

方程 6.1

自回归移动*均过程

自回归移动*均过程是自回归过程和移动*均过程的组合。

它表示为 ARMA(p,q),其中p是自回归过程的阶数,q是移动*均过程的阶数。ARMA(p,q)模型的一般方程是

y[t] = C + ϕ[1]y[t–1] + ϕ[2]y[t][–2] +⋅⋅⋅+ ϕ[p]y[t–p] + μ + ϵ[t] + θ[1]ϵ[t–1] + θ[2]ϵ[t–2] +⋅⋅⋅+ θ[q]ϵ[tq]

ARMA(0,q)过程等同于 MA(q)过程,因为阶数p = 0 抵消了 AR(p)部分。ARMA(p,0)过程等同于 AR(p)过程,因为阶数q = 0 抵消了 MA(q)部分。

再次强调,阶数p决定了影响当前值的过去值的数量。同样,阶数q决定了影响当前值的过去误差项的数量。换句话说,阶数pq分别决定了自回归和移动*均部分的参数数量。

因此,如果我们有一个 ARMA(1,1)过程,我们就是在结合一个一阶自回归过程,或 AR(1),和一个一阶移动*均过程,或 MA(1)。回想一下,一阶自回归过程是一个常数 C、序列在上一时间步的值ϕ[1]y[t–1]和白色噪声ϵ[t]的线性组合,如方程 6.2 所示。

AR(1) := y[t] = C + ϕ[1]y[t–1] + ϵ[t]

方程 6.2

还要记住,一阶移动*均过程,或 MA(1),是序列均值μ、当前误差项ϵ[t]和上一时间步的误差项θ[1]ϵ[t–1]的线性组合,如方程 6.3 所示。

MA(1) := y[t] = μ + ϵ[t] + θ[1]ϵ[t–1]

方程 6.3

我们可以将 AR(1)和 MA(1)过程结合起来,得到一个 ARMA(1,1)过程,如方程 6.4 所示,它结合了方程 6.2 和 6.3 的效果。

ARMA(1,1) := y[t] = C + ϕ[1]y[t–1] + ϵ[t] + θ[1]ϵ[t–1]

方程 6.4

如果我们有一个 ARMA(2,1)过程,我们就是在结合一个二阶自回归过程和一个一阶移动*均过程。我们知道我们可以将 AR(2)过程表示为方程 6.5,而方程 6.3 中的 MA(1)过程保持不变。

AR(2) := y[t] = C + ϕ[1]y[t–1] + ϕ[2]y[t–2] + ϵ[t]

方程 6.5

因此,一个 ARMA(2,1)过程可以表示为方程 6.5 中定义的 AR(2)过程和方程 6.3 中定义的 MA(1)过程的组合。这如方程 6.6 所示。

ARMA(2, 1) := y[t] = C + ϕ[1]y[t–1] + ϕ[2]y[t–2] + μ + ϵ[t] + θ[1]ϵ[t–1]

方程 6.6

p = 0 的情况下,我们有一个 ARMA(0,q)过程,它等同于第四章中看到的纯 MA(q)过程。同样,如果q = 0,我们有一个 ARMA(p,0)过程,它等同于第五章中看到的纯 AR(p)过程。

我们现在可以看到,阶数p只影响过程的自回归部分,通过确定在方程中包含的过去值的数量。同样,阶数q只影响过程的移动*均部分,通过确定在 ARMA(p,q)方程中包含的过去误差项的数量。当然,pq的阶数越高,包含的项就越多,我们的过程就越复杂。

为了对 ARMA(p,q)过程进行建模和预测,我们需要找到pq的阶数。这样,我们可以使用 ARMA(p,q)模型来拟合可用数据并生成预测。

6.3 识别*稳的 ARMA 过程

现在我们已经定义了自回归移动*均过程,并看到了pq的阶数如何影响模型方程,我们需要确定如何在一个给定的时间序列中识别这样的潜在过程。

我们将扩展第五章中定义的步骤,包括我们有一个 ARMA(p,q)过程的可能性,如图 6.3 所示。

图 6.3 识别随机游走、移动*均过程 MA(q)、自回归过程 AR(p)和自回归移动*均过程 ARMA(p, q)的步骤

在图 6.3 中,你会注意到,如果 ACF 和 PACF 图都没有在显著和非显著系数之间显示清晰的截止点,那么我们就有了一个 ARMA(p,q)过程。为了验证这一点,让我们模拟自己的 ARMA 过程。

我们将模拟一个 ARMA(1,1)过程。这相当于将 MA(1)过程与 AR(1)过程相结合。具体来说,我们将模拟方程 6.7 中定义的 ARMA(1,1)过程。请注意,这里的常数 C 和均值μ都等于 0。0.33 和 0.9 是这个模拟的主观选择。

y[t] = 0.33y[t–1] + 0.9ϵ[t–1] + ϵ[t]

方程 6.7

这个模拟的目标是证明我们无法使用 ACF 图来识别 ARMA(p,q)过程的阶数q,在这个例子中是 1,也无法使用 PACF 图来识别 ARMA(p,q)过程的阶数p,在这个例子中也是 1。

我们将使用statsmodels库中的ArmaProcess函数来模拟我们的 ARMA(1,1)过程。正如前几章所做的那样,我们将定义 AR(1)过程的系数数组,以及 MA(1)过程的系数数组。从方程 6.7 中,我们知道我们的 AR(1)过程将有一个系数为 0.33。然而,请记住,该函数期望具有自回归过程的系数,其符号相反,因为这是在statsmodels库中的实现方式。因此,我们将其输入为-0.33。对于移动*均部分,方程 6.7 指定系数为 0.9。还请记住,在定义你的系数数组时,第一个系数始终等于 1,这是由库指定的,它代表滞后 0 的系数。一旦我们的系数定义完毕,我们将生成 1,000 个数据点。

注意:本章的源代码可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH06

from statsmodels.tsa.arima_process import ArmaProcess
import numpy as np

np.random.seed(42)

ar1 = np.array([1, -0.33])                                        ❶
ma1 = np.array([1, 0.9])                                          ❷

ARMA_1_1 = ArmaProcess(ar1, ma1).generate_sample(nsample=1000)    ❸

❶ 定义 AR(1)部分的系数。记住,根据文档,第一个系数始终为 1。此外,我们必须用方程 6.7 中定义的相反符号来写 AR 部分的系数。

❷ 定义 MA(1)部分的系数。第一个系数为 1,代表滞后 0,这是由文档指定的。

❸ 生成 1,000 个样本。

在我们的模拟数据准备好后,我们可以继续下一步,并验证我们的过程是否是*稳的。我们可以通过运行增强的迪基-富勒(ADF)测试来完成。我们将打印出 ADF 统计量以及 p 值。如果 ADF 统计量是一个大的负数,并且如果我们的 p 值小于 0.05,我们可以拒绝零假设,并得出我们有一个*稳过程的结论。

from statsmodels.tsa.stattools import adfuller

ADF_result = adfuller(ARMA_1_1)      ❶

print(f'ADF Statistic: {ADF_result[0]}')
print(f'p-value: {ADF_result[1]}')

❶ 对模拟的 ARMA(1,1)数据进行 ADF 测试。

这返回一个 ADF 统计量为-6.43 和 p 值为 1.7×10^(–8)。由于我们有一个大的负 ADF 统计量和远小于 0.05 的 p 值,我们可以得出结论,我们的模拟 ARMA(1,1)过程是*稳的。

按照图 6.3 中概述的步骤,我们将绘制自相关函数(ACF)并查看我们是否可以推断出模拟的 ARMA(1,1)过程中移动*均部分的阶数。再次强调,我们将使用statsmodels库中的plot_acf函数来生成图 6.4。

from statsmodels.graphics.tsaplots import plot_acf

plot_acf(ARMA_1_1, lags=20);

plt.tight_layout()

图片

图 6.4:我们模拟的 ARMA(1,1)过程的 ACF 图。注意图中的正弦波模式,这意味着存在一个 AR(p)过程。此外,最后一个显著的系数出现在滞后 2 处,这表明q = 2。然而,我们知道我们模拟了一个 ARMA(1,1)过程,因此q必须等于 1!因此,ACF 图不能用来推断 ARMA(p, q)过程的阶数q

在图 6.4 中,你会在图中注意到一个正弦波模式,这表明存在一个自回归过程。这是预期的,因为我们模拟了一个 ARMA(1,1)过程,并且我们知道存在自回归部分。此外,你还会注意到最后一个显著的系数出现在滞后 2 处。然而,我们知道我们的模拟数据具有 MA(1)过程,因此我们预计只有到滞后 1 处才会有显著的系数。因此,我们可以得出结论,ACF 图并没有揭示关于我们的 ARMA(1,1)过程阶数q的有用信息。

现在,我们可以继续按照图 6.3 中概述的下一步,绘制 PACF 图。在第五章中,你学习了 PACF 可以用来找到*稳 AR(p)过程的阶数。现在,我们将验证我们是否可以找到模拟 ARMA(1,1)过程的阶数p,其中p = 1。我们将使用plot_pacf函数来生成图 6.5。

from statsmodels.graphics.tsaplots import plot_pacf

plot_pacf(ARMA_1_1, lags=20);

plt.tight_layout()

图片

图 6.5:我们模拟的 ARMA(1,1)过程的 PACF 图。再次,我们有一个正弦波模式,显著和非显著系数之间没有明显的截止点。从这个图中,我们不能推断出在我们的模拟 ARMA(1,1)过程中p = 1,这意味着我们不能使用 PACF 图来确定 ARMA(p, q)过程的阶数p

在图 6.5 中,我们可以看到一个清晰的正弦波模式,这意味着我们不能推断出阶数p的值。我们知道我们模拟了一个 ARMA(1,1)过程,但我们不能从图 6.5 中的 PACF 图中确定该值,因为我们有滞后 1 之后的显著系数。因此,PACF 图不能用来找到 ARMA(p, q)过程的阶数p

根据图 6.3,由于 ACF 和 PACF 图中显著和非显著系数之间没有明显的截止点,我们可以得出结论,我们有一个 ARMA(p,q)过程,这确实是情况。

识别*稳的 ARMA(p,q)过程

如果你的过程是*稳的,并且 ACF 和 PACF 图都显示出衰减或正弦波模式,那么它就是一个*稳的 ARMA(p,q)过程。

我们知道,在建模和预测中确定我们过程的顺序是关键,因为顺序将决定我们模型中必须包含多少参数。由于在 ARMA(p,q)过程中 ACF 和 PACF 图没有用,因此我们必须制定一个通用的建模程序,以便我们能够找到适合我们模型的适当(p,q)组合。

6.4 制定一个通用的建模程序

在上一节中,我们介绍了识别*稳 ARMA(p,q)过程的步骤。我们看到了如果 ACF 和 PACF 图都显示出正弦波或衰减模式,我们的时间序列可以通过 ARMA(p,q)过程建模。然而,这两个图在确定pq的顺序时都没有用。在我们的模拟 ARMA(1,1)过程中,我们注意到在滞后 1 之后,两个图中的系数都是显著的。

因此,我们必须制定一个程序,使我们能够找到pq的顺序。这个程序的优势在于它也可以应用于我们的时间序列非*稳且有季节性影响的情况。此外,它也适用于pq等于 0 的情况,这意味着我们可以摆脱 ACF 和 PACF 的绘图,完全依赖于模型选择准则和残差分析。步骤在图 6.6 中显示。

图片

图 6.6 ARMA(p, q)过程的一般建模程序。第一步是收集数据,测试*稳性,并相应地应用转换。然后我们定义pq的可能值列表。然后我们将 ARMA(p, q)的每个组合拟合到我们的数据中,并选择 AIC 最低的模型。然后我们通过查看 Q-Q 图和残差自相关图来进行残差分析。如果它们接*白噪声,则可以使用该模型进行预测。否则,我们必须尝试不同的pq值。

在图 6.6 中,你可以看到这个新的建模程序完全去除了 ACF 和 PACF 的绘图。它允许我们完全基于统计测试和数值标准来选择模型,而不是依赖于 ACF 和 PACF 图的定性分析。

前几个步骤与我们在第五章逐渐建立起来的步骤保持不变,因为我们仍然需要收集数据,测试*稳性,并相应地应用转换。然后我们列出pq的不同可能值——请注意,它们只取正整数。有了可能值的列表,我们可以将每个唯一的 ARMA(p,q)组合拟合到我们的数据中。

一旦完成,我们可以计算赤池信息准则(AIC),这在第 6.4.1 和 6.4.2 节中进行了详细讨论。这量化了每个模型相对于其他模型的质量。然后选择 AIC 最低的模型。

从那里,我们可以分析模型的残差,即模型实际值和预测值之间的差异。理想情况下,残差将看起来像白噪声,这意味着预测值和实际值之间的任何差异都是由于随机性造成的。因此,残差必须是未相关且独立分布的。我们可以通过研究 分位数-分位数图 (Q-Q plot) 和运行 Ljung-Box 测试 来评估这些属性,这些内容将在 6.4.3 节中探讨。如果分析导致我们得出残差完全随机的结论,我们就有一个适合预测的模型。否则,我们必须尝试不同的 pq 值组合,并重新开始这个过程。

随着我们通过新的通用建模过程,将介绍许多新的概念和技术。我们将在未来的章节中详细探讨每一步,并使用我们的模拟 ARMA(1,1) 过程进行操作。然后我们将应用相同的程序来建模带宽使用。

6.4.1 理解 Akaike 信息准则 (AIC)

在介绍图 6.6 中的步骤之前,我们需要确定我们将如何从我们将要拟合的所有模型中选择最佳模型。在这里,我们将使用 Akaike 信息准则 (AIC) 来选择最佳模型。

AIC 估计模型相对于其他模型的质量。由于当模型拟合到数据时,总会丢失一些信息,AIC 量化了模型丢失的相对信息量。丢失的信息越少,AIC 值越低,模型越好。

AIC 是估计参数数量 k 和模型似然函数最大值 的函数,如方程 6.8 所示。

AIC 2k – 2ln()

方程 6.8

Akaike 信息准则 (AIC)

Akaike 信息准则 (AIC) 是衡量模型质量相对于其他模型的指标。它用于模型选择。

AIC 是模型中参数数量 k 和似然函数最大值 的函数:

AIC 2k – 2ln()

AIC 的值越低,模型越好。根据 AIC 进行选择,可以使我们在模型的复杂性和其与数据的拟合优度之间保持*衡。

估计参数数量 k 与 ARMA(p,q) 模型的阶数 (p,q) 直接相关。如果我们拟合一个 ARMA(2,2) 模型,那么我们就有 2 + 2 = 4 个参数需要估计。如果我们拟合一个 ARMA(3,4) 模型,那么我们就有 3 + 4 = 7 个参数需要估计。你可以看到拟合一个更复杂的模型如何惩罚 AIC 分数:随着 (p,q) 阶数的增加,参数数量 k 增加,因此 AIC 也增加。

似然函数衡量模型的拟合优度。它可以被视为分布函数的相反。给定一个具有固定参数的模型,分布函数将衡量观察到数据点的概率。似然函数翻转了这种逻辑。给定一组观察数据,它将估计不同模型参数生成观察数据的可能性有多大。

例如,考虑我们掷一个六面骰子的情况。分布函数告诉我们,我们有 1/6 的概率观察到以下值之一:[1,2,3,4,5,6]。现在让我们翻转这个逻辑来解释似然函数。假设你掷骰子 10 次,得到以下值:[1,5,3,4,6,2,4,3,2,1]。似然函数将确定骰子有六个面的可能性有多大。将这种逻辑应用于 AIC 的上下文中,我们可以将似然函数视为回答“我的观察数据来自 ARMA(1,1)模型的可能性有多大?”的问题。如果可能性非常大,意味着很大,那么 ARMA(1,1)模型与数据拟合得很好。

因此,如果一个模型与数据非常吻合,似然函数的最大值将会很高。由于 AIC 减去似然函数最大值的自然对数,由方程 6.8 中的表示,那么的大值将会降低 AIC。

你可以看到 AIC 如何在欠拟合和过拟合之间保持*衡。记住,AIC 越低,相对于其他模型,模型越好。因此,一个过拟合的模型会有一个非常好的拟合,这意味着很大,AIC 降低。然而,参数的数量k也会很大,这会惩罚 AIC。一个欠拟合的模型会有很少的参数,所以k会很小。然而,由于拟合不好,似然函数的最大值也会很小,这意味着 AIC 再次受到惩罚。因此,AIC 使我们能够在模型参数的数量和训练数据的良好拟合之间找到*衡。

最后,我们必须记住,AIC 仅量化模型相对于其他模型的质量。因此,它是一个相对质量度量。如果我们只将较差的模型拟合到我们的数据上,AIC 将帮助我们确定那一组模型中的最佳模型。

现在,让我们使用 AIC 帮助我们为模拟的 ARMA(1,1)过程选择一个合适的模型。

6.4.2 使用 AIC 选择模型

现在,我们将使用图 6.6 中概述的一般建模步骤,结合我们的模拟 ARMA(1,1)过程进行说明。

在第 6.3 节中,我们进行了*稳性检验,并得出结论,我们的模拟过程已经*稳。因此,我们可以继续定义一个可能的pq值的列表。虽然我们知道这两个阶数来自模拟,但让我们考虑以下步骤作为演示,说明一般建模过程是可行的。

我们将允许pq的值从 0 到 3 变化。请注意,这个范围是任意的,如果您愿意,可以尝试更大的值范围。我们将使用itertools模块中的product函数创建所有可能的(p,q)组合的列表。由于pq有四个可能的值,这将生成一个包含 16 个唯一的(p,q)组合的列表。

from itertools import product

ps = range(0, 4, 1)                  ❶
qs = range(0, 4, 1)                  ❷

order_list = list(product(ps, qs))   ❸

❶ 创建一个从 0(包含)到 4(不包含)开始的可能值列表,步长为 1。

❷ 创建一个从 0(包含)到 4(不包含)开始的可能值列表,步长为 1。

❸ 生成一个包含所有唯一的(p,q)组合的列表。

在创建了可能的值列表之后,我们现在必须将所有唯一的 16 个 ARMA(p,q)模型拟合到我们的模拟数据中。为此,我们将定义一个optimize_ARMA函数,该函数接受数据和唯一的(p,q)组合列表作为输入。在函数内部,我们将初始化一个空列表来存储每个(p,q)组合及其对应的 AIC。然后我们将遍历每个(p,q)组合,并拟合一个 ARMA(p,q)模型到我们的数据。我们将计算 AIC 并存储结果。然后我们将创建一个DataFrame并按 AIC 值升序排序,因为 AIC 越低,模型越好。我们的函数最终将输出排序后的DataFrame,以便我们可以选择合适的模型。optimize_ARMA函数如下所示。

列表 6.1:拟合所有唯一 ARMA(p,q)模型的函数

from typing import Union
from tqdm import tqdm_notebook
from statsmodels.tsa.statespace.sarimax import SARIMAX

def optimize_ARMA(endog: Union[pd.Series, list], order_list: list) -> 
➥ pd.DataFrame:                                           ❶

    results = []                                           ❷

    for order in tqdm_notebook(order_list):                ❸
        try: 
            model = SARIMAX(endog, order=(order[0], 0, order[1]), 
➥ simple_differencing=False).fit(disp=False)              ❹
        except:
            continue

        aic = model.aic                                    ❺
        results.append([order, aic])                       ❻

    result_df = pd.DataFrame(results)                      ❼
    result_df.columns = ['(p,q)', 'AIC']                   ❽

    #Sort in ascending order, lower AIC is better
    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)                  ❾

    return result_df

❶ 函数接受时间序列数据和唯一的(p,q)组合列表作为输入。

❷ 初始化一个空列表来存储顺序(p,q)及其对应的 AIC 作为元组。

❸ 遍历每个唯一的(p,q)组合。使用 tqdm_notebook 将显示进度条。

❹ 使用 SARIMAX 函数拟合 ARMA(p,q)模型。我们指定 simple_differencing=False 以防止差分。回想一下,差分是 y[t]– y[t–1]的结果。我们还指定 disp=False 以避免将收敛消息打印到控制台。

❺ 计算模型的 AIC。

❻ 将(p,q)组合和 AIC 作为元组添加到结果列表中。

❼ 将(p,q)组合和 AIC 存储在 DataFrame 中。

❽ 标记 DataFrame 的列。

❾ 按 AIC 值升序排序 DataFrame。AIC 越低,模型越好。

定义了我们的函数后,现在我们可以使用它来拟合不同的 ARMA(p,q)模型。结果如图 6.7 所示。您会看到 AIC 最低的模型对应于 ARMA(1,1)模型,这正是我们模拟的过程。

result_df = optimize_ARMA(ARMA_1_1, order_list)    ❶
result_df                                          ❷

❶ 在模拟的 ARMA(1,1)数据上拟合不同的 ARMA(p,q)模型。

❷ 显示生成的 DataFrame。

图片

图 6.7:将所有 ARMA(p, q)模型拟合到模拟的 ARMA(1,1)数据的结果DataFrame。我们可以看到 AIC 最低的模型对应于 ARMA(1,1)模型,这意味着我们成功识别了模拟数据的阶数。

如前所述,AIC 是相对质量的度量。在这里,我们可以说 ARMA(1,1) 模型相对于我们拟合到数据中的所有其他模型来说是最好的模型。现在我们需要一个模型质量的绝对度量。这带我们进入了建模过程的下一步,即残差分析。

6.4.3 理解残差分析

到目前为止,我们已经将不同的 ARMA(p,q) 模型拟合到我们的模拟 ARMA(1,1) 过程中。使用 AIC 作为模型选择标准,我们发现 ARMA(1,1) 模型相对于所有其他拟合的模型来说是最好的。现在我们必须通过分析模型的残差来衡量其绝对质量。

这将带我们进入预测前的最后一步,即残差分析和回答图 6.8 中的两个问题:Q-Q 图是否显示一条直线,残差是否不相关?如果这两个问题的答案都是肯定的,那么我们就拥有了一个可以用来进行预测的模型。否则,我们必须尝试不同的 (p,q) 组合并重新开始这个过程。

图 6.8 ARMA(p, q) 过程的一般建模步骤

模型的残差简单地是预测值和实际值之间的差异。考虑我们用方程 6.9 表示的模拟 ARMA(1,1) 过程。

y[t] = 0.33y[t–1] + 0.9ϵ[t–1] + ϵ[t]

方程 6.9

现在假设我们将 ARMA(1,1) 模型拟合到我们的过程中,并且我们完美地估计了模型的系数,使得模型可以表示为方程 6.10。

ŷ[t] = 0.33y[t–1] + 0.9ϵ[t–1]

方程 6.10

残差将是模型输出的值与模拟过程中的观测值之间的差异。换句话说,残差是方程 6.9 和方程 6.10 之间的差异。结果在方程 6.11 中显示。

残差 = 0.33y[t–1] + 0.9ϵ[t–1] + ϵ[t] – (0.33y[t–1] + 0.9)

残差 = ϵ[t]

方程 6.11

如方程 6.11 所示,在理想情况下,模型的残差是白噪声。这表明模型已经捕捉到了所有预测信息,只剩下无法建模的随机波动。因此,为了得出我们有一个适合进行预测的好模型这一结论,残差必须是不相关的,并且具有正态分布。

残差分析有两个方面:定性分析和定量分析。定性分析侧重于研究 Q-Q 图,而定量分析确定我们的残差是否不相关。

定性分析:研究 Q-Q 图

残差分析的第一步是研究 分位数-分位数图 (Q-Q 图)。Q-Q 图是一种图形工具,用于验证我们的假设,即模型的残差是正态分布的。

Q-Q 图是通过在y轴上绘制我们的残差分位数与x轴上理论分布的分位数(在这种情况下是正态分布)相对比来构建的。这产生了一个散点图。我们比较分布与正态分布,因为我们希望残差类似于白噪声,它是正态分布的。

如果两个分布相似,即残差分布接*正态分布,Q-Q 图将显示一条大约位于y = x上的直线。这反过来又意味着我们的模型适合我们的数据。你可以在图 6.9 中看到一个残差正态分布的 Q-Q 图示例。

图 6.9 示例

图 6.9 随机分布残差的 Q-Q 图。在y轴上,我们有来自残差的分位数。在x轴上,我们有来自理论正态分布的分位数。你可以看到一条大约位于y = x上的直线。这是我们的残差非常接*正态分布的指示。

另一方面,如果残差与正态分布不接*的 Q-Q 图将生成一条偏离y = x的曲线。在图 6.10 中,你可以看到粗线不是直的,并且不位于y = x上。如果我们得到这种结果,我们可以得出结论,我们的残差分布不类似于正态分布,这是我们的模型不适合我们的数据的迹象。因此,我们必须尝试不同的pq值范围,拟合模型,选择具有最低 AIC 的模型,并在新的模型上执行残差分析。

Q-Q 图示例

图 6.10 不接*正态分布的残差的 Q-Q 图。你可以清楚地看到粗线是弯曲的,并且不位于y = x上。因此,残差分布与正态分布非常不同。

分位数-分位数图(Q-Q 图)

Q-Q 图是两个分布的分位数相互之间的图。在时间序列预测中,我们在y轴上绘制我们的残差分布,与x轴上的理论正态分布相对比。

这个图形工具使我们能够评估我们模型的拟合优度。如果我们的残差分布类似于正态分布,我们将在y = x上看到一条直线。这意味着我们的模型是一个好的拟合,因为残差类似于白噪声。

另一方面,如果我们的残差分布与正态分布不同,我们将看到一条曲线。然后我们可以得出结论,我们的模型不是一个好的拟合,因为残差分布不接*正态分布,因此残差不类似于白噪声。

你可以看到 Q-Q 图如何帮助我们。我们知道,如果一个模型很好地拟合我们的数据,残差将类似于白噪声,因此将具有相似的性质。这意味着它们应该是正态分布的。因此,如果 Q-Q 图显示一条直线,我们有一个好的模型。否则,我们的模型必须被舍弃,我们必须尝试拟合一个更好的模型。

虽然 Q-Q 图是一种快速评估模型质量的方法,但这种分析仍然是主观的。因此,我们将通过应用 Ljung-Box 检验来进一步支持我们的残差分析,使用定量方法。

定量分析:应用 Ljung-Box 检验

一旦我们分析了 Q-Q 图并确定我们的残差*似正态分布,我们就可以应用 Ljung-Box 检验来证明残差是不相关的。记住,一个好的模型具有类似于白噪声的残差,因此残差应该是正态分布且不相关的。

Ljung-Box 检验是一种统计检验,用于检验一组数据的自相关是否显著不同于 0。在我们的情况下,我们将应用 Ljung-Box 检验来评估模型的残差是否相关。零假设表明数据是独立分布的,这意味着没有自相关。

Ljung-Box 检验

Ljung-Box 检验是一种统计检验,用于确定一组数据的自相关是否显著不同于 0。

在时间序列预测中,我们应用 Ljung-Box 检验于模型的残差,以检验它们是否类似于白噪声。零假设表明数据是独立分布的,这意味着没有自相关。如果 p 值大于 0.05,我们不能拒绝零假设,这意味着残差是独立分布的。因此,没有自相关,残差类似于白噪声,该模型可用于预测。

如果 p 值小于 0.05,我们拒绝零假设,这意味着我们的残差不是独立分布的,并且是相关的。该模型不能用于预测。

测试将返回 Ljung-Box 统计量和 p 值。如果 p 值小于 0.05,我们拒绝零假设,这意味着残差不是独立分布的,这反过来意味着存在自相关。在这种情况下,残差不*似白噪声的性质,该模型必须被舍弃。

如果 p 值大于 0.05,我们不能拒绝零假设,这意味着我们的残差是独立分布的。因此,没有自相关,残差类似于白噪声。这意味着我们可以继续使用我们的模型进行预测。

现在你已经理解了残差分析的概念,让我们将这些技术应用到我们的模拟 ARMA(1,1) 过程中。

6.4.4 执行残差分析

现在,我们将继续对模拟的 ARMA(1,1)过程进行建模。我们已经成功选择了一个具有最低 AIC 的模型,这预期是一个 ARMA(1,1)模型。现在,如图 6.11 所示,我们需要进行残差分析,以评估我们的模型是否很好地拟合了数据。

图 6.11 ARMA(p, q)过程的通用建模过程

我们知道我们的 ARMA(1,1)模型必须是好的,因为我们模拟了一个 ARMA(1,1)过程,但本节将演示我们的建模过程是有效的。在商业环境中,我们不太可能对模拟数据进行建模和预测,因此首先在已知过程中覆盖整个建模过程是很重要的,这样我们才能确信它有效,然后再将其应用于实际数据。

为了进行残差分析,我们需要拟合我们的模型并将残差存储在一个变量中以便于访问。使用statsmodels,我们首先定义一个 ARMA(1,1)模型,然后将其拟合到我们的模拟数据。然后我们可以通过resid属性访问残差。

model = SARIMAX(ARMA_1_1, order=(1,0,1), simple_differencing=False)
model_fit = model.fit(disp=False)
residuals = model_fit.resid          ❶

❶ 存储模型的残差。

下一步是绘制 Q-Q 图,我们将使用statsmodels中的qqplot函数来显示我们的残差与正态分布的对比。该函数只需要数据,它将默认将其分布与正态分布进行比较。我们还需要显示线y = x,以便评估两个分布的相似性。

from statsmodels.graphics.gofplots import qqplot

qqplot(residuals, line='45');      ❶

❶ 绘制残差的 Q-Q 图。指定显示线 y = x。

结果如图 6.12 所示。你会看到一个大约位于y = x上的厚直线。因此,从定性角度来看,模型的残差似乎呈正态分布,就像白噪声一样,这是我们的模型很好地拟合数据的指示。

图 6.12 展示了我们的 ARMA(1,1)残差的 Q-Q 图。你可以看到一个厚直的线躺在y = x上。这意味着我们的残差是正态分布的,就像白噪声一样。

我们将通过使用plot_diagnostics方法来扩展我们的定性分析。这会生成一个包含四个不同图的图形,包括 Q-Q 图。

model_fit.plot_diagnostics(figsize=(10, 8));

结果如图 6.13 所示。你可以看到statsmodels如何使我们能够轻松地进行残差的定性分析。

图 6.13 statsmodels的模型诊断。左上角的图显示了残差,残差直方图在右上角,残差的 Q-Q 图在左下角,右下角显示了残差的 ACF 图。

左上角的图显示了整个数据集的残差。你可以看到没有趋势,并且均值似乎随时间稳定,这表明了*稳性,就像白噪声一样。

右上角的图显示了残差直方图。你可以看到这个图上的正态分布形状,这再次表明残差接*白噪声,因为白噪声也是正态分布的。

在左下角,我们有 Q-Q 图,它与图 6.12 相同,因此得出相同的结论。

最后,右下角的图显示了我们的残差的自相关函数。你可以看到,仅在滞后 0 处有一个显著的峰值,其他地方没有显著的系数。这意味着残差是不相关的,这进一步支持了结论,即它们类似于白噪声,这是我们期望的良好模型所应有的。

残差分析的最后一步是应用 Ljung-Box 测试。这使我们能够定量地评估我们的残差是否确实是不相关的。我们将使用 statsmodels 中的 acorr_ljungbox 函数对残差进行 Ljung-Box 测试。该函数接受残差以及滞后项列表作为输入。这里我们将计算 10 个滞后项的 Ljung-Box 统计量和 p 值。

from statsmodels.stats.diagnostic import acorr_ljungbox

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))    ❶

print(pvalue)                                                       ❷

❶ 在残差上应用 Ljung-Box 测试,在 10 个滞后项上。

❷ 显示每个滞后项的 p 值。

结果的 p 值列表显示,每个值都高于 0.05。因此,在每个滞后项上,不能拒绝零假设,这意味着残差是独立分布且不相关的。

从我们的分析中可以得出结论,残差类似于白噪声。Q-Q 图显示了一条直线,这意味着残差是正态分布的。此外,Ljung-Box 测试显示残差是不相关的,就像白噪声一样。因此,残差是完全随机的,这意味着我们有一个很好地拟合我们数据的模型。

现在让我们将相同的建模过程应用于带宽数据集。

6.5 应用通用建模过程

现在我们有一个通用的建模过程,它允许我们建模和预测一个通用的 ARMA(p,q) 模型,如图 6.14 所示。我们将此过程应用于我们的模拟 ARMA(1,1) 过程,并发现最佳拟合模型是一个 ARMA(1,1) 模型,正如预期的那样。

图 6.14 ARMA(p, q) 过程的通用建模过程

现在我们可以将相同的程序应用于带宽数据集,以获得这种情况的最佳模型。回想一下,我们的目标是预测未来 2 小时的带宽使用情况。

第一步是使用 pandas 收集和加载数据:

import pandas as pd

df = pd.read_csv('data/bandwidth.csv')

然后,我们可以绘制我们的时间序列并寻找趋势或季节性模式。到现在为止,你应该已经熟悉了绘制时间序列。结果如图 6.15 所示。

import matplotlib.pyplot as plt

fig, ax = plt.subplots()

ax.plot(df.hourly_bandwidth)
ax.set_xlabel('Time')
ax.set_ylabel('Hourly bandwith usage (MBps)')

plt.xticks(
    np.arange(0, 10000, 730), 
    ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 
➥ 'Nov', 'Dec', '2020', 'Feb'])

fig.autofmt_xdate()
plt.tight_layout()

图 6.15 自 2019 年 1 月 1 日起数据中心的小时带宽使用情况。数据集包含 10,000 个点。

在图 6.15 中绘制数据后,你可以看到数据中没有周期性模式。然而,你会注意到存在长期趋势,这意味着我们的数据可能不是*稳的。让我们应用 ADF 测试来验证我们的假设。同样,我们将使用statsmodels中的adfuller函数,并打印出 ADF 统计量和 p 值。

from statsmodels.tsa.stattools import adfuller

ADF_result = adfuller(df['hourly_bandwidth'])

print(f'ADF Statistic: {ADF_result[0]}')
print(f'p-value: {ADF_result[1]}')

这打印出一个 ADF 统计量为-0.8 和 p 值为 0.80。因此,我们不能拒绝零假设,这意味着我们的时间序列不是*稳的。

我们必须对我们的数据进行变换,使其*稳。让我们使用numpy进行一阶差分。

import numpy as np

bandwidth_diff = np.diff(df.hourly_bandwidth, n=1)

完成此操作后,我们可以再次应用 ADF 测试,这次是对差分数据进行测试,以检验*稳性。

ADF_result = adfuller(bandwidth_diff)

print(f'ADF Statistic: {ADF_result[0]}')
print(f'p-value: {ADF_result[1]}')

这返回一个 ADF 统计量为-20.69 和 p 值为 0.0。由于 ADF 统计量很大且为负值,且 p 值远小于 0.05,我们可以断定我们的差分序列是*稳的。

现在我们准备开始使用 ARMA(p,q)模型对*稳过程进行建模。我们将把我们的序列分为训练集和测试集。在这里,我们将保留最后 7 天的数据作为测试集。由于我们的预测是针对接下来的 2 小时,因此测试集包含 84 个 2 小时的周期,用于评估我们模型的性能,因为 7 天的每小时数据总计 168 小时。

df_diff = pd.DataFrame({'bandwidth_diff': bandwidth_diff})

train = df_diff[:-168]
test = df_diff[-168:]     ❶

print(len(train))
print(len(test))

❶ 一周有 168 小时,因此我们将最后 168 个数据点分配给测试集。

我们可以打印出训练集和测试集的长度作为合理性检查,果然,测试集有 168 个数据点,训练集有 9,831 个数据点。

现在,让我们可视化我们的训练集和测试集,包括差分序列和原始序列。结果图如图 6.16 所示。

fig, (ax1, ax2) = plt.subplots(nrows=2, ncols=1, sharex=True, figsize=(10, 
➥ 8))

ax1.plot(df.hourly_bandwidth)
ax1.set_xlabel('Time')
ax1.set_ylabel('Hourly bandwidth')
ax1.axvspan(9831, 10000, color='#808080', alpha=0.2)

ax2.plot(df_diff.bandwidth_diff)
ax2.set_xlabel('Time')
ax2.set_ylabel('Hourly bandwidth (diff)')
ax2.axvspan(9830, 9999, color='#808080', alpha=0.2)

plt.xticks(
    np.arange(0, 10000, 730), 
    ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 
➥ 'Nov', 'Dec', '2020', 'Feb'])

fig.autofmt_xdate()
plt.tight_layout()

图片

图 6.16 原始和差分序列的训练集和测试集

准备好训练集后,我们现在可以使用之前定义的optimize_ ARMA函数来拟合不同的 ARMA(p,q)模型。请记住,该函数接受数据和唯一(p,q)组合列表作为输入。在函数内部,我们初始化一个空列表来存储每个(p,q)组合及其对应的 AIC。然后我们遍历每个(p,q)组合,并在我们的数据上拟合一个 ARMA(p,q)模型。我们计算 AIC 并存储结果。然后我们创建一个DataFrame,按 AIC 值升序排序,因为 AIC 越低,模型越好。我们的函数最终输出排序后的DataFrame,以便我们可以选择合适的模型。optimize_ ARMA函数的代码如下所示。

列表 6.2 拟合所有唯一 ARMA(p,q)模型的函数

from typing import Union
from tqdm import tqdm_notebook
from statsmodels.tsa.statespace.sarimax import SARIMAX

def optimize_ARMA(endog: Union[pd.Series, list], order_list: list) -> 
➥ pd.DataFrame:                                                  ❶

    results = []                                                  ❷

    for order in tqdm_notebook(order_list):                       ❸
        try: 
            model = SARIMAX(endog, order=(order[0], 0, order[1]), 
➥ simple_differencing=False).fit(disp=False)                     ❹
        except:
            continue

        aic = model.aic                                           ❺
        results.append([order, aic])                              ❻

    result_df = pd.DataFrame(results)                             ❼
    result_df.columns = ['(p,q)', 'AIC']                          ❽

    #Sort in ascending order, lower AIC is better
    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)                         ❾

    return result_df

❶ 函数接受时间序列数据和唯一(p,q)组合列表作为输入。

❷ 初始化一个空列表来存储顺序(p,q)及其对应的 AIC 作为元组。

❸ 遍历每个唯一的(p,q)组合。使用 tqdm_notebook 将显示进度条。

❹ 使用 SARIMAX 函数拟合 ARMA(p,q)模型。我们指定 simple_differencing=False 以防止差分。我们还指定 disp=False 以避免将收敛消息打印到控制台。

❺ 计算模型的 AIC 值。

❻ 将(p,q)组合和 AIC 作为一个元组添加到结果列表中。

❼ 将(p,q)组合和 AIC 存储在 DataFrame 中。

❽ 标记 DataFrame 的列。

❾ 按 AIC 值升序排序 DataFrame。AIC 值越低,模型越好。

在这里,我们将尝试pq的值从 0 到 3(包括 0 和 3)。这意味着我们将拟合 16 个独特的 ARMA(p,q)模型到我们的训练集,并选择 AIC 最低的那个。你可以随意更改pq的值范围,但请记住,范围越大,拟合的模型越多,计算时间也会更长。另外,你不需要担心过拟合——我们是通过 AIC 来选择模型的,这会防止我们选择一个过拟合的模型。

ps = range(0, 4, 1)                   ❶
qs = range(0, 4, 1)                   ❷

order_list = list(product(ps, qs))    ❸

❶ 阶数 p 可以取值{0,1,2,3}。

❷ 阶数 q 可以取值{0,1,2,3}。

❸ 生成唯一的(p,q)组合。

完成这一步后,我们可以将我们的训练集和唯一的(p,q)组合列表传递给optimize_ARMA函数。

result_df = optimize_ARMA(train['bandwidth_diff'], order_list)
result_df

结果的DataFrame如图 6.17 所示。你会注意到前三个模型的所有 AIC 值都是 27,991,只有细微的差异。因此,我会认为 ARMA(2,2)模型是应该选择的模型。它的 AIC 值非常接* ARMA(3,2)和 ARMA(2,3)模型,而它的复杂度更低,因为它有四个参数需要估计,而不是五个。因此,我们将选择 ARMA(2,2)模型,并继续下一步,即分析模型的残差。

图片

图 6.17 按 AIC 值升序排列的DataFrame,结果是在差分带宽数据集上拟合不同的 ARMA(p, q)模型。注意前三个模型的所有 AIC 值都是 27,991。

为了进行残差分析,我们将对训练集拟合 ARMA(2,2)模型。然后,我们将使用plot_diagnostics方法来研究 Q-Q 图,以及其他伴随的图表。结果如图 6.18 所示。

model = SARIMAX(train['bandwidth_diff'], order=(2,0,2), 
➥ simple_differencing=False)
model_fit = model.fit(disp=False)model_fit = best_model.fit(disp=False)
model_fit.plot_diagnostics(figsize=(10, 8));

图片

图 6.18 statsmodels的模型诊断。左上角的图显示了残差,残差的直方图在右上角,残差的 Q-Q 图在左下角,右下角显示了残差的 ACF 图。

在图 6.18 中,你可以看到左上角的图没有趋势,*均值似乎随时间保持不变,这意味着我们的残差很可能是*稳的。右上角显示了一个密度图,其形状类似于正态分布。左下角的 Q-Q 图显示了一条非常接*y = x的粗直线。最后,右下角的 ACF 图在滞后 0 之后没有自相关。因此,图 6.18 表明我们的残差明显类似于白噪声,因为它们是正态分布且不相关的。

我们的最后一步是对前 10 个滞后项的残差进行 Ljung-Box 测试。如果返回的 p 值超过 0.05,我们不能拒绝零假设,这意味着我们的残差是不相关的且独立分布的,就像白噪声一样。

residuals = model_fit.resid

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))

print(pvalue)

返回的 p 值都超过了 0.05。因此,我们可以得出结论,我们的残差确实是不相关的。我们的 ARMA(2,2)模型已经通过了残差分析的各项检查,我们准备使用这个模型来预测带宽使用。

6.6 预测带宽使用

在上一节中,我们将通用建模过程应用于带宽数据集,并得出结论:ARMA(2,2)模型是我们数据的最佳模型。现在我们将使用 ARMA(2,2)模型来预测未来 7 天内 2 小时的带宽使用情况。

我们将重用我们在第四章和第五章中定义并使用的rolling_forecast函数,如列表 6.3 所示。回想一下,这个函数允许我们一次预测几个时间步长,直到我们对整个预测范围都有预测。这次,当然,我们将对差分数据拟合一个 ARMA(2,2)模型。此外,我们将比较该模型与两个基准的性能:*均值和最后一个已知值。这将确保 ARMA(2,2)模型比简单的预测方法表现更好。

列表 6.3 执行滚动预测的函数

def rolling_forecast(df: pd.DataFrame, train_len: int, horizon: int, 
➥ window: int, method: str) -> list:

    total_len = train_len + horizon
    end_idx = train_len

    if method == 'mean':
        pred_mean = []

        for i in range(train_len, total_len, window):
            mean = np.mean(df[:i].values)
            pred_mean.extend(mean for _ in range(window))

        return pred_mean

    elif method == 'last':
        pred_last_value = []

        for i in range(train_len, total_len, window):
            last_value = df[:i].iloc[-1].values[0]
            pred_last_value.extend(last_value for _ in range(window))
        return pred_last_value

    elif method == 'ARMA':
        pred_ARMA = []

        for i in range(train_len, total_len, window):
            model = SARIMAX(df[:i], order=(2,0,2))      ❶
            res = model.fit(disp=False)
            predictions = res.get_prediction(0, i + window - 1)
            oos_pred = predictions.predicted_mean.iloc[-window:]
            pred_ARMA.extend(oos_pred)

        return pred_ARMA

❶ 指定 ARMA(2,2)模型。

定义了rolling_forecast之后,我们可以用它来评估不同预测方法的性能。我们首先创建一个DataFrame来保存测试集的实际值以及不同方法的预测。然后我们指定训练集和测试集的大小。由于我们有 ARMA(2,2)模型,意味着存在一个 MA(2)成分,我们将一次预测两个步骤。我们知道从第四章中,使用 MA(q)模型预测超过q个步骤的未来将简单地返回*均值,因此预测将保持*坦。因此,我们将通过设置窗口为 2 来避免这种情况。然后我们可以使用*均值方法、最后一个已知值方法和 ARMA(2,2)模型在测试集上进行预测,并将每个预测存储在test的相应列中。

pred_df = test.copy()

TRAIN_LEN = len(train)
HORIZON = len(test)
WINDOW = 2

pred_mean = recursive_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 'mean')
pred_last_value = recursive_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 
➥ 'last')
pred_ARMA = recursive_forecast(df_diff, TRAIN_LEN, HORIZON, WINDOW, 'ARMA')

test.loc[:, 'pred_mean'] = pred_mean
test.loc[:, 'pred_last_value'] = pred_last_value
test.loc[:, 'pred_ARMA'] = pred_ARMA

pred_df.head()

然后,我们可以绘制并可视化每种方法的预测。

fig, ax = plt.subplots()

ax.plot(df_diff['bandwidth_diff'])
ax.plot(test['bandwidth_diff'], 'b-', label='actual')
ax.plot(test['pred_mean'], 'g:', label='mean')
ax.plot(test['pred_last_value'], 'r-.', label='last')
ax.plot(test['pred_ARMA'], 'k--', label='ARMA(2,2)')
ax.legend(loc=2)
ax.set_xlabel('Time')
ax.set_ylabel('Hourly bandwidth (diff)')

ax.axvspan(9830, 9999, color='#808080', alpha=0.2)     ❶

ax.set_xlim(9800, 9999)                                ❷

plt.xticks(
    [9802, 9850, 9898, 9946, 9994],
    ['2020-02-13', '2020-02-15', '2020-02-17', '2020-02-19', '2020-02-21'])

fig.autofmt_xdate()
plt.tight_layout()

❶ 为测试期间分配灰色背景。

❷ 放大测试期间。

结果显示在图 6.19 中。我已经放大了测试期以获得更好的可视化。

图片

图 6.19 使用均值、最后一个已知值和 ARMA(2,2)模型预测的差分小时带宽使用情况。您可以看到 ARMA(2,2)预测和最后一个已知值预测几乎与测试集的实际值相吻合。

在图 6.19 中,您可以看到 ARMA(2,2)预测(以虚线表示)几乎与测试集的实际值相吻合。同样,最后一个已知值方法的预测(以虚线和点划线表示)也是如此。当然,使用均值进行的预测(以点划线表示)在测试期间完全*坦。

现在,我们将测量均方误差(MSE)以评估每个模型的性能。MSE 最低的模型是表现最好的模型。

mse_mean = mean_squared_error(test['bandwidth_diff'], test['pred_mean'])
mse_last = mean_squared_error(test['bandwidth_diff'], 
➥ test['pred_last_value'])
mse_ARMA = mean_squared_error(test['bandwidth_diff'], test['pred_ARMA'])

print(mse_mean, mse_last, mse_ARMA) 

这返回了均值的 MSE 为 6.3,最后一个已知值的 MSE 为 2.2,ARMA(2,2)模型的 MSE 为 1.8。ARMA(2,2)模型优于基准模型,这意味着我们有一个表现良好的模型。

最后一步是将我们预测的变换逆转,以便将其调整到与原始数据相同的尺度。记住,我们通过差分原始数据使其*稳。然后,ARMA(2,2)模型被应用于*稳数据集,并产生了差分的预测。

要逆转差分变换,我们可以应用累积和,就像我们在第四章和第五章中所做的那样。

df['pred_bandwidth'] = pd.Series()
df['pred_bandwidth'][9832:] = df['hourly_bandwidth'].iloc[9832] + 
➥ pred_df['pred_ARMA'].cumsum()

然后,我们可以在数据的原始尺度上绘制预测值。

fig, ax = plt.subplots()

ax.plot(df['hourly_bandwidth'])
ax.plot(df['hourly_bandwidth'], 'b-', label='actual')
ax.plot(df['pred_bandwidth'], 'k--', label='ARMA(2,2)')

ax.legend(loc=2)

ax.set_xlabel('Time')
ax.set_ylabel('Hourly bandwith usage (MBps)')

ax.axvspan(9831, 10000, color='#808080', alpha=0.2)

ax.set_xlim(9800, 9999)

plt.xticks(
    [9802, 9850, 9898, 9946, 9994],
    ['2020-02-13', '2020-02-15', '2020-02-17', '2020-02-19', '2020-02-21'])

fig.autofmt_xdate()
plt.tight_layout()

观察图 6.20 中的结果,您可以看到我们的预测(以虚线表示)紧密跟随测试集的实际值,并且两条线几乎相吻合。

图片

图 6.20 小时带宽使用的未差分预测。注意,代表我们预测的虚线几乎与代表实际值的实线相吻合。这意味着我们的预测非常接*实际值,表明模型表现良好。

我们可以测量未差分的 ARMA(2,2)预测的*均绝对误差(MAE),以了解预测与实际值之间的差距。我们将使用 MAE,因为它易于解释。

mae_ARMA_undiff = mean_absolute_error(df['hourly_bandwidth'][9832:], 
➥ df['pred_bandwidth'][9832:])

print(mae_ARMA_undiff)

这返回了 14 的 MAE,这意味着,*均而言,我们的预测比实际带宽使用量高 14 Mbps 或低 14 Mbps。

6.7 下一步

在本章中,我们介绍了 ARMA(p,q)模型以及它如何有效地将 AR(p)模型与 MA(q)模型结合,以建模和预测更复杂的时间序列。这要求我们定义一个全新的建模程序,该程序不依赖于 ACF 和 PACF 图的定性研究。相反,我们拟合了许多具有不同(p,q)组合的 ARMA(p,q)模型,并选择了具有最低 AIC 的模型。然后我们分析了模型的残差,以确保它们的性质与白噪声相似:正态分布、*稳和无关。这种分析既是定性的,因为我们可以通过研究 Q-Q 图来评估残差是否正态分布,也是定量的,因为我们可以通过应用 Ljung-Box 测试来确定残差是否相关。如果模型的残差具有随机变量的性质,如白噪声,则该模型可用于预测。

到目前为止,我们已经介绍了*稳时间序列的不同模型:主要是 MA(q)模型、AR(p)模型和 ARMA(p,q)模型。每个模型都需要我们在进行预测之前将我们的数据转换为*稳状态。此外,我们还需要在预测上逆转这种转换,以获得数据原始尺度上的预测。

然而,有一种方法可以建模非*稳时间序列,而无需对其进行转换并在预测上逆转转换。具体来说,我们可以使用自回归积分移动*均模型或 ARIMA(p,d,q)来建模积分时间序列。这将是下一章的主题。

6.8 练习

是时候测试你的知识,并使用这些练习应用通用的建模程序了。解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH06

6.8.1 在模拟的 ARMA(1,1)过程中进行预测

  1. 重新使用模拟的 ARMA(1,1)过程,将其分为训练集和测试集。将 80%的数据分配给训练集,剩余的 20%分配给测试集。

  2. 使用rolling_forecast函数,利用 ARMA(1,1)模型、均值方法和最后已知值方法进行预测。

  3. 绘制你的预测图。

  4. 使用均方误差(MSE)评估每种方法的性能。哪种方法表现最好?

6.8.2 模拟 ARMA(2,2)过程并进行预测

模拟一个*稳的 ARMA(2,2)过程。使用statsmodels中的ArmaProcess函数进行模拟:

y[t] = 0.33y[t–1] + 0.50y[t–2] + 0.9ϵ[t–1] + 0.3ϵ[t–2]

  1. 模拟 10,000 个样本。

    from statsmodels.tsa.arima_process import ArmaProcess
    import numpy as np
    
    np.random.seed(42)     ❶
    
    ma2 = np.array([1, 0.9, 0.3])
    ar2 = np.array([1, -0.33, -0.5])
    
    ARMA_2_2 = ArmaProcess(ar2, ma2).generate_sample(nsample=10000)
    

    ❶ 设置种子以实现可重复性。如果你想尝试不同的值,可以更改种子。

  2. 绘制你的模拟过程。

  3. 使用 ADF 测试进行*稳性检验。

  4. 将你的数据分为训练集和测试集。测试集必须包含最后 200 个时间步长。其余的用于训练集。

  5. 定义pq的值范围,并生成所有唯一的(p,q)阶数组合。

  6. 使用optimize_ARMA函数拟合所有唯一的 ARMA(p,q)模型,并选择 AIC 最低的模型。ARMA(2,2)模型是 AIC 最低的吗?

  7. 根据 AIC 选择最佳模型,并将残差存储在一个名为residuals的变量中。

  8. 使用plot_diagnostics方法对残差进行定性分析。Q-Q 图是否显示一条位于y = x上的直线?自相关图是否显示显著的系数?

  9. 通过对前 10 个滞后项应用 Ljung-Box 测试,对残差进行定量分析。所有返回的 p 值是否都高于 0.05?残差是否相关?

  10. 使用rolling_forecast函数使用选定的 ARMA(p,q)模型、均值方法和最后已知值方法进行预测。

  11. 绘制你的预测图。

  12. 使用 MSE 评估每种方法的性能。哪种方法表现最好?

摘要

  • 自回归移动*均模型,表示为 ARMA(p,q),是自回归模型 AR(p)和移动*均模型 MA(q)的组合。

  • ARMA(p,q)过程将在 ACF 和 PACF 图上显示衰减模式或正弦模式。因此,它们不能用来估计pq的阶数。

  • 通用建模过程不依赖于 ACF 和 PACF 图。相反,我们拟合许多 ARMA(p,q)模型,并进行模型选择和残差分析。

  • 模型选择是通过赤池信息准则(AIC)进行的。它量化了模型的信息损失,它与模型中的参数数量及其拟合优度有关。AIC 越低,模型越好。

  • AIC 是质量的相对度量。它返回其他模型中的最佳模型。为了获得绝对的质量度量,我们进行残差分析。

  • 一个好模型的残差必须*似白噪声,这意味着它们必须是不相关的、正态分布的且独立的。

  • Q-Q 图是一种用于比较两个分布的图形工具。我们用它来比较残差分布与理论正态分布。如果图显示一条位于y = x上的直线,那么这两个分布是相似的。否则,这意味着残差不是正态分布的。

  • Ljung-Box 测试使我们能够确定残差是否相关。零假设表明数据是独立分布且不相关的。如果返回的 p 值大于 0.05,我们不能拒绝零假设,这意味着残差是不相关的,就像白噪声一样。

7 预测非*稳时间序列

本章涵盖

  • 检查自回归积分移动*均模型,或 ARIMA(p,d,q)

  • 应用非*稳时间序列的一般建模程序

  • 使用 ARIMA(p,d,q)模型进行预测

在第四章、第五章和第六章中,我们介绍了移动*均模型(MA(q))、自回归模型(AR(p))和自回归移动*均模型(ARMA(p,q))。我们看到了这些模型只能用于*稳时间序列,这要求我们应用变换,主要是差分,并使用 ADF 测试来检验*稳性。在我们所涵盖的例子中,每个模型的预测都返回了差分值,这要求我们逆转这种变换,以便将值恢复到原始数据的尺度。

现在,我们将向 ARMA(p,q)模型添加另一个组件,以便我们可以预测非*稳时间序列。这个组件是积分阶数,用变量d表示。这导致我们得到自回归积分移动*均模型(ARIMA),或 ARIMA(p,d,q)。使用这个模型,我们可以考虑非*稳时间序列,并避免在差分数据上建模的步骤以及需要逆转预测的变换。

在本章中,我们将定义 ARIMA(p,d,q)模型和积分阶数d。然后我们将在我们的通用建模程序中添加一个步骤。图 7.1 显示了在第六章中定义的通用建模程序。我们必须添加一个步骤来确定积分阶数,以便使用 ARIMA(p,d,q)模型。

图片

图 7.1 使用 ARMA(p, q)模型的通用建模程序。在本章中,我们将为此程序添加另一个步骤,以便适应 ARIMA(p,d,q)模型。

然后我们将应用我们修改后的程序来预测一个非*稳时间序列,这意味着该序列具有趋势,或者其方差随时间变化而变化。具体来说,我们将重新研究 1960 年至 1980 年间强生公司每季度的每股收益(EPS)数据集,这是我们首次在第一章和第二章中研究的。该序列显示在图 7.2 中。我们将应用 ARIMA(p,d,q)模型来预测 1 年的季度 EPS。

图片

图 7.2 1960 年至 1980 年强生公司每股收益(EPS)。我们在第一章和第二章中使用了相同的数据集。

7.1 定义自回归积分移动*均模型

自回归积分移动*均过程是自回归过程 AR(p)、积分 I(d)和移动*均过程 MA(q)的组合。

就像 ARMA 过程一样,ARIMA 过程表明当前值依赖于过去值,来自 AR(p)部分,以及过去误差,来自 MA(q)部分。然而,ARIMA 过程使用的是差分序列,表示为 y'[t],而不是原始序列,表示为 y[t]。请注意,y'[t] 可以代表已经差分多次的序列。

因此,ARIMA(p,d,q)过程的数学表达式表明,差分序列 y'[t] 的当前值等于一个常数 C,过去差分序列的值 φ[p]y'[tp],差分序列的均值 µ,过去误差项 θ[q]ϵ[tq],以及当前误差项 ϵ[t] 的总和,如方程 7.1 所示。

y'[t] = C + φ[1]y'[t–1] +⋅⋅⋅ φ[p]y'[tp] + θ[1]ϵ'[t–1] +⋅⋅⋅+ θ[q]ϵ'[tq] + ϵ[t]

方程 7.1

就像在 ARMA 过程中一样,阶数 p 决定了模型中包含多少个滞后值,而阶数 q 决定了模型中包含多少个滞后误差项。然而,在方程 7.1 中,你将注意到没有明确显示阶数 d

在这里,阶数 d 被定义为积分阶数。积分简单来说是差分的逆过程。因此,积分阶数等于一个序列被差分以变得*稳的次数。

如果我们对一个序列进行一次差分后它变得*稳,那么 d = 1。如果一个序列被差分两次以变得*稳,那么 d = 2。

自回归积分移动*均模型

自回归积分移动*均 (ARIMA)过程是 AR(p)和 MA(q)过程的组合,但针对的是差分序列。

它表示为 ARIMA(p,d,q),其中 p 是 AR(p)过程的阶数,d 是积分阶数,q 是 MA(q)过程的阶数。

积分是差分的逆过程,积分阶数 d 等于序列被差分以实现*稳性的次数。

ARIMA(p,d,q)过程的一般方程是

y'[t] = C + φ[1]y'[t][–1] +⋅⋅⋅ φ[p] y'[t–p] + θ[1]ϵ'[t–1] +⋅⋅⋅+ θ[q]ϵ'[tq] + ϵ[t]

注意,y'[t] 代表差分序列,并且可能已经差分了多次。

可以通过应用差分来实现*稳性的时间序列被称为积分序列。在存在非*稳积分时间序列的情况下,我们可以使用 ARIMA(p,d,q)模型进行预测。

因此,简单来说,ARIMA 模型就是一个可以应用于非*稳时间序列的 ARMA 模型。而 ARMA(p,q)模型在拟合 ARMA(p,q)模型之前要求序列必须是*稳的,而 ARIMA(p,d,q)模型则可以用于非*稳序列。我们只需找到积分阶数d,它对应于序列必须差分的最小次数以成为*稳。

因此,在将一般建模过程应用于预测 Johnson & Johnson 的季度每股收益之前,我们必须添加找到积分阶数这一步骤。

7.2 修改一般建模过程以考虑非*稳序列

在第六章中,我们建立了一个一般建模过程,使我们能够对更复杂的时间序列进行建模,这意味着序列既有自回归成分又有移动*均成分。这个过程涉及拟合许多 ARMA(p,q)模型并选择 AIC 最低的那个。然后我们研究模型的残差以验证它们是否类似于白噪声。如果是这样,该模型可以用于预测。我们可以在图 7.3 中可视化当前状态的一般建模过程。

图片

图 7.3 使用 ARMA(p, q)模型的一般建模过程。现在我们必须将其修改为适用于 ARIMA(p,d,q)模型,这样我们就可以处理非*稳时间序列。

一般建模过程的下一次迭代将包括一个确定积分阶数d的步骤。这样,我们可以应用相同的程序,但使用 ARIMA(p,d,q)模型,这将使我们能够预测非*稳时间序列。

从上一节中,我们知道积分阶数d仅仅是序列必须差分的最小次数以成为*稳。因此,如果一个序列在差分一次后变得*稳,那么d = 1。如果它在差分两次后变得*稳,那么d = 2。根据我的经验,时间序列很少需要差分超过两次才能变得*稳。

我们可以添加一个步骤,当对序列应用变换时,我们将d的值设置为序列被差分的次数。然后,我们不再拟合许多 ARMA(p,q)模型,而是拟合许多 ARIMA(p,d,q)模型。其余的过程保持不变,因为我们仍然使用 AIC 来选择最佳模型并研究其残差。该过程如图 7.4 所示。

注意,当d = 0 时,它等同于 ARMA(p,q)模型。这也意味着序列不需要差分就可以成为*稳。还必须指出,ARMA(p,q)模型只能应用于*稳序列,而 ARIMA(p,d,q)模型可以应用于未经差分的序列。

让我们将我们新的一般建模过程应用于预测 Johnson & Johnson 的季度每股收益。

7.3 预测非*稳时间序列

现在我们将应用图 7.4 中显示的通用建模过程来预测强生公司每季度的每股收益(EPS)。我们将使用第一章和第二章中介绍的数据集。我们将预测 1 年的季度 EPS,这意味着我们必须预测四个时间步长到未来,因为一年有四个季度。数据集涵盖了 1960 年至 1980 年之间的时期。

和往常一样,第一步是收集我们的数据。这里数据已经为我们准备好了,所以我们只需加载并显示序列。结果如图 7.5 所示。

图 7.4 使用 ARIMA(p,d,q)模型的通用建模过程。注意增加了一步,我们在这里指定 ARIMA(p,d,q)模型的参数d。在这里,d是序列必须差分的最小次数,以成为*稳的。

注意:在任何时候,都可以自由参考 GitHub 上本章的源代码:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH07

df = pd.read_csv('../data/jj.csv')

fig, ax = plt.subplots()

ax.plot(df.date, df.data)
ax.set_xlabel('Date')
ax.set_ylabel('Earnings per share (USD)')

plt.xticks(np.arange(0, 81, 8), [1960, 1962, 1964, 1966, 1968, 1970, 1972, 
➥ 1974, 1976, 1978, 1980])

fig.autofmt_xdate()
plt.tight_layout()

图 7.5 1960 年至 1980 年间强生公司每季度的每股收益(EPS)

按照我们的程序,我们必须检查数据是否*稳。图 7.5 显示了一个正向趋势,因为每季度的每股收益(EPS)随着时间的推移而增加。尽管如此,我们可以应用增强迪基-富勒(ADF)测试来确定它是否*稳。到现在你应该非常熟悉这些步骤,所以它们将伴随着最少的注释。

ad_fuller_result = adfuller(df['data'])

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

这段代码返回一个 ADF 统计量为 2.74,p 值为 1.0。由于 ADF 统计量不是一个大的负数,且 p 值大于 0.05,我们不能拒绝零假设,这意味着我们的序列不是*稳的。

我们需要确定序列必须差分多少次才能变得*稳。这将然后设置积分阶数d。我们可以应用一阶差分并测试*稳性。

eps_diff = np.diff(df['data'], n=1)       ❶

ad_fuller_result = adfuller(eps_diff)     ❷

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

❶ 应用一阶差分。

❷ 测试*稳性。

这导致 ADF 统计量为-0.41,p 值为 0.9。同样,ADF 统计量不是一个大的负数,且 p 值大于 0.05。因此,我们不能拒绝零假设,我们必须得出结论,在第一次差分后,序列不是*稳的。

让我们再次尝试差分,看看序列是否变得*稳:

eps_diff2 = np.diff(eps_diff, n=1)        ❶

ad_fuller_result = adfuller(eps_diff2)    ❷

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

❶ 对差分序列再次进行差分。

❷ 测试*稳性。

这导致 ADF 统计量为-3.59,p 值为 0.006。现在我们有一个小于 0.05 的 p 值和一个大的负 ADF 统计量,我们可以拒绝零假设,并得出我们的序列是*稳的。需要两次差分才能使我们的数据*稳,这意味着我们的积分阶数为 2,所以d = 2。

在我们继续拟合不同的 ARIMA(p,d,q)模型组合之前,我们必须将我们的数据分为训练集和测试集。我们将保留最后一年数据用于测试。这意味着我们将使用 1960 年至 1979 年的数据来拟合模型,并预测 1980 年的季度 EPS 以评估我们模型的质量与 1980 年观察值相比。在图 7.6 中,测试期是阴影区域。

图片

图 7.6:训练集和测试集。训练期包括 1960 年至 1979 年,而测试集是 1980 年报告的季度 EPS。这个测试集对应于数据集的最后四个数据点。

为了拟合许多 ARIMA(p,d,q)模型,我们将定义optimize_ARIMA函数。它与我们在第六章中定义的optimize_ARMA函数几乎相同,只是这次我们将积分阶数d作为函数的输入。函数的其余部分保持不变,因为我们拟合不同的模型,并按升序 AIC 对它们进行排序,以选择 AIC 最低的模型。optimize_ARIMA函数如下所示。

列表 7.1:拟合所有唯一的 ARIMA(p,d,q)模型的函数

from typing import Union
from tqdm import tqdm_notebook
from statsmodels.tsa.statespace.sarimax import SARIMAX

def optimize_ARIMA(endog: Union[pd.Series, list], order_list: list, d: int) 
➥ -> pd.DataFrame:                                 ❶

    results = []                                    ❷

    for order in tqdm_notebook(order_list):         ❸
        try: 
            model = SARIMAX(endog, order=(order[0], d, order[1]), 
➥ simple_differencing=False).fit(disp=False)       ❹
        except:
            continue

        aic = model.aic                             ❺
        results.append([order, aic])                ❻

    result_df = pd.DataFrame(results)               ❼
    result_df.columns = ['(p,q)', 'AIC']            ❽

    #Sort in ascending order, lower AIC is better
    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)           ❾

    return result_df

❶ 函数接受时间序列数据、唯一(p,q)组合列表和积分阶数 d 作为输入。

❷ 初始化一个空列表以存储每个订单(p,q)及其对应的 AIC 作为元组。

❸ 遍历每个唯一的(p,q)组合。使用 tqdm_notebook 将显示进度条。

❹ 使用 SARIMAX 函数拟合 ARIMA(p,d,q)模型。我们指定 simple_differencing=False 以防止差分。我们还指定 disp=False 以避免将收敛消息打印到控制台。

❺ 计算模型的 AIC。

❻ 将(p,q)组合和 AIC 作为元组追加到结果列表中。

❼ 将(p,q)组合和 AIC 存储在 DataFrame 中。

❽ 标记 DataFrame 的列。

❾ 按 AIC 值升序排序 DataFrame。AIC 越低,模型越好。

在函数就位后,我们可以定义可能的 p 和 q 的值列表。在这种情况下,我们将尝试为两个顺序尝试 0、1、2 和 3 的值,并生成唯一的(p,q)组合列表。

from itertools import product

ps = range(0, 4, 1)                   ❶
qs = range(0, 4, 1)                   ❷
d = 2                                 ❸

order_list = list(product(ps, qs))    ❹

❶ 创建一个可能的 p 值列表,从 0(包含)到 4(不包含),步长为 1。

❷ 创建一个可能的 q 值列表,从 0(包含)到 4(不包含),步长为 1。

❸ 将 d 设置为 2,因为序列需要差分两次才能成为*稳。

❹ 生成一个包含所有唯一(p,q)组合的列表。

注意,我们没有给出参数d的值范围,因为它有一个非常具体的定义:它是序列必须差分多少次才能成为*稳的次数。因此,它必须设置为特定的值,在这种情况下是 2。

此外,d必须保持恒定,以便使用 AIC 比较模型。d的变化将改变用于计算 AIC 值的似然函数,因此使用 AIC 作为比较标准的模型比较将不再有效。

现在我们可以使用训练集运行optimize_ARIMA函数。该函数返回一个DataFrame,其中包含具有最低 AIC 值的模型。

train = df.data[:-4]                               ❶

result_df = optimize_ARIMA(train, order_list, d)   ❷
result_df                                          ❸

❶ 训练集包括所有数据点,除了最后四个。

❷ 运行 optimize_ARIMA 函数以获得具有最低 AIC 值的模型。

❸ 显示结果 DataFrame。

返回的DataFrame显示,pq的值都为 3 时,AIC 值最低。因此,ARIMA(3,2,3)模型似乎是最适合这种情况的。现在让我们通过研究其残差来评估模型的合理性。

为了做到这一点,我们将在训练集上拟合一个 ARIMA(3,2,3)模型,并使用plot_diagnostics方法显示残差的诊断结果。结果如图 7.7 所示。

model = SARIMAX(train, order=(3,2,3), simple_differencing=False)   ❶
model_fit = model.fit(disp=False)

model_fit.plot_diagnostics(figsize=(10,8));                         ❷

❶ 由于该模型具有最低的 AIC 值,因此在训练集上拟合一个 ARIMA(3,2,3)模型。

❷ 显示残差的诊断。

图片

图 7.7 ARIMA(3,2,3)残差的诊断。左下角的 Q-Q 图显示了一条相当直的线,两端有一些偏差。

在图 7.7 中,左上角的图显示了随时间变化的残差。虽然残差中没有趋势,但方差似乎并不恒定,这与白噪声相比存在差异。右上角是残差的分布。我们可以看到它相当接*正态分布。Q-Q 图得出相同的结论,因为它显示了一条相当直的线,这意味着残差的分布接*正态分布。最后,通过观察右下角的自相关图,我们可以看到似乎在滞后 3 处有一个系数是显著的。然而,由于它没有先前的任何显著的自相关系数,我们可以假设这是由于偶然。因此,我们可以说自相关图在滞后 0 之后没有显示显著的系数,就像白噪声一样。

因此,从定性的角度来看,我们的残差似乎接*白噪声,这是一个好兆头,因为它意味着模型的误差是随机的。

最后一步是定量评估残差。因此,我们将应用 Ljung-Box 测试来确定残差是否相关。我们将对前 10 个滞后应用测试,并研究 p 值。如果所有 p 值都大于 0.05,我们不能拒绝零假设,我们将得出结论,残差不相关,就像白噪声一样。

from statsmodels.stats.diagnostic import acorr_ljungbox

residuals = model_fit.resid                                         ❶

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))    ❷

print(pvalue)

❶ 将模型的残差存储在一个变量中。

❷ 对前 10 个滞后应用 Ljung-Box 测试。

在模型残差的前 10 个滞后上运行 Ljung-Box 测试返回一系列 p 值,这些 p 值都大于 0.05。因此,我们无法拒绝零假设,我们得出结论,残差不相关,就像白噪声一样。

我们的 ARIMA(3,2,3)模型已经通过了所有检查,现在可以用于预测。记住,我们的测试集是最后四个数据点,对应于 1980 年报告的四个季度 EPS。作为我们模型的基准,我们将使用简单季节性方法。这意味着我们将使用 1979 年第一季度的 EPS 作为 1980 年第一季度的 EPS 的预测。然后,1979 年第二季度的 EPS 将用于预测 1980 年第二季度的 EPS,依此类推。记住,在建模时需要一个基准或基线模型,以确定我们开发的模型是否优于简单方法。模型的性能必须始终相对于基线模型来评估。

test = df.iloc[-4:]                                      ❶

test['naive_seasonal'] = df['data'].iloc[76:80].values   ❷

❶ 测试集对应于最后四个数据点。

❷ 简单季节性预测是通过选择 1979 年报告的季度 EPS 并使用相同的值作为 1980 年的预测来实现的。

在建立基准后,我们现在可以使用 ARIMA(3,2,3)模型进行预测,并将结果存储在 ARIMA_pred 列中。

ARIMA_pred = model_fit.get_prediction(80, 83).predicted_mean    ❶

test['ARIMA_pred'] = ARIMA_pred                                  ❷

❶ 获取 1980 年的预测值。

❷ 将预测值分配给 ARIMA_pred 列。

让我们可视化我们的预测,看看每种方法的预测值与观察值有多接*。结果图如图 7.8 所示。

图片

图 7.8 展示了 1980 年 Johnson & Johnson 季度 EPS 的预测。我们可以看到,ARIMA(3,2,3)模型(以虚线表示)的预测几乎完美地与 1980 年的观察数据重叠。

在图 7.8 中,我们可以看到简单季节性预测以虚线表示,ARIMA(3,2,3)预测以虚线表示。ARIMA(3,2,3)模型以非常小的误差预测了季度 EPS。

我们可以通过测量*均绝对百分比误差(MAPE)来量化这个误差,并在条形图中显示每种预测方法的指标,如图 7.9 所示。

def mape(y_true, y_pred):                                          ❶
    return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

mape_naive_seasonal = mape(test['data'], test['naive_seasonal'])   ❷
mape_ARIMA = mape(test['data'], test['ARIMA_pred'])                ❸

fig, ax = plt.subplots()

x = ['naive seasonal', 'ARIMA(3,2,3)']
y = [mape_naive_seasonal, mape_ARIMA]

ax.bar(x, y, width=0.4)
ax.set_xlabel('Models')
ax.set_ylabel('MAPE (%)')
ax.set_ylim(0, 15)

for index, value in enumerate(y):
    plt.text(x=index, y=value + 1, s=str(round(value,2)), ha='center')

plt.tight_layout()

❶ 定义一个计算 MAPE 的函数。

❷ 计算简单季节性方法的 MAPE。

❸ 计算 ARIMA(3,2,3)模型的 MAPE。

图片

图 7.9 展示了两种预测方法的 MAPE。你可以看到 ARIMA 模型具有一个误差指标,是基准值的五分之一。

在图 7.9 中,你可以看到简单季节性预测的 MAPE 为 11.56%,而 ARIMA(3,2,3)模型的 MAPE 为 2.19%,大约是基准值的五分之一。这意味着我们的预测值*均偏离实际值 2.19%。ARIMA(3,2,3)模型显然比简单季节性方法更好。

7.4 下一步

在本章中,我们介绍了 ARIMA(p,d,q)模型,它允许我们模拟和预测非*稳时间序列。

积分阶数 d 定义了一个系列必须差分的次数,以使其*稳。该参数然后允许我们在原始系列上拟合模型,并得到相同尺度的预测,与 ARMA(p,q)模型不同,后者要求系列*稳才能应用模型,并要求我们对预测进行反向转换。

要应用 ARIMA(p,d,q)模型,我们在一般建模程序中添加了一个额外的步骤,这仅仅涉及找到积分阶数的值。这对应于一个系列必须差分的最小次数以使其*稳。

现在,我们可以给 ARIMA(p,d,q)模型添加另一个层,使我们能够考虑时间序列的另一个属性:季节性。我们已经足够多地研究了 Johnson & Johnson 数据集,以意识到该系列中存在明显的周期性模式。为了在模型中整合一个系列的季节性,我们必须使用季节性自回归积分移动*均(SARIMA)模型,或 SARIMA(p,d,q)(P,D,Q)[m]。这将是下一章的主题。

7.5 练习

现在是时候将 ARIMA 模型应用于我们之前探索过的数据集了。这个练习的完整解决方案可以在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH07

7.5.1 在第 4、5 和 6 章的数据集上应用 ARIMA(p,d,q)模型

在第 4、5 和 6 章中,介绍了非*稳时间序列,以向您展示如何应用 MA(q)、AR(p) 和 ARMA(p,q)模型。在每个章节中,我们将系列转换为使其*稳,拟合模型,进行预测,并必须在预测上反向转换以将它们恢复到数据的原始尺度。

现在你已经知道了如何处理非*稳时间序列,重新审视每个数据集,并应用 ARIMA(p,d,q)模型。对于每个数据集,执行以下操作:

  1. 应用一般建模程序。

  2. ARIMA(0,1,2)模型适合第四章的数据集吗?

  3. ARIMA(3,1,0)模型适合第五章的数据集吗?

  4. ARIMA(2,1,2)模型适合第六章的数据集吗?

摘要

  • 自回归积分移动*均模型,表示为 ARIMA(p,d,q),是自回归模型 AR(p)、积分阶数 d 和移动*均模型 MA(q)的组合。

  • ARIMA(p,d,q)模型可以应用于非*稳时间序列,并且具有返回与原始系列相同尺度的预测的附加优势。

  • 积分阶数 d 等于一个系列必须差分的最小次数,以使其*稳。

  • ARIMA(p,0,q)模型等同于 ARMA(p,q)模型。

8 考虑季节性

本章涵盖

  • 检查季节性自回归积分移动*均模型,SARIMA(p,d,q)(P,D,Q)[m]

  • 分析时间序列中的季节性模式

  • 使用 SARIMA(p,d,q)(P,D,Q)[m]模型进行预测

在上一章中,我们介绍了自回归积分移动*均模型,ARIMA(p,d,q),它允许我们对非*稳时间序列进行建模。现在,我们将向 ARIMA 模型添加另一层复杂性,以包括时间序列中的季节性模式,从而引出 SARIMA 模型。

季节性自回归积分移动*均(SARIMA)模型,或 SARIMA(p,d,q)(P,D,Q)[m],增加了一组参数,使我们能够在预测时间序列时考虑周期性模式,这并不是 ARIMA(p,d,q)模型总能做到的。

在本章中,我们将检查 SARIMA(p,d,q)(P,D,Q)[m]模型,并将我们的通用建模程序调整以考虑新参数。我们还将确定如何识别时间序列中的季节性模式,并将 SARIMA 模型应用于预测季节性时间序列。具体来说,我们将应用该模型来预测航空公司的月度总旅客数。数据是从 1949 年 1 月到 1960 年 12 月记录的。该系列显示在图 8.1 中。

图 8.1 从 1949 年 1 月到 1960 年 12 月航空公司的月度总旅客数。您会注意到该系列中存在明显的季节性模式,高峰交通出现在每年的中间。

在图 8.1 中,我们可以看到该系列中明显的季节性模式。航空旅客数量在每年的开始和结束时较低,而在六月、七月和八月期间急剧上升。我们的目标是预测一年内每月的航空旅客数量。对于航空公司来说,预测航空旅客数量非常重要,这样他们可以更好地定价机票并安排航班以满足特定月份的需求。

8.1 检查 SARIMA(p,d,q)(P,D,Q)[m]模型

SARIMA(p,d,q)(P,D,Q)[m]模型在上一章的 ARIMA(p,d,q)模型的基础上进行了扩展,增加了季节性参数。您会注意到模型中有四个新的参数:PDQm。前三个参数与 ARIMA(p,d,q)模型中的含义相同,但它们是它们的季节性对应参数。为了理解这些参数的含义以及它们如何影响最终模型,我们必须首先定义m

参数 m 代表频率。在时间序列的上下文中,频率定义为每个周期内的观测次数。周期的长度将取决于数据集。对于每年、每季度、每月或每周记录的数据,周期的长度被认为是 1 年。如果数据是按年记录的,m = 1,因为每年只有一个观测值。如果数据是按季度记录的,m = 4,因为一年中有四个季度,因此每年有四个观测值。当然,如果数据是按月记录的,m = 12。最后,对于每周数据,m = 52。表 8.1 指出了根据数据收集频率选择适当的 m 值。

表 8.1 根据数据选择适当的频率 m

数据收集 频率 m
年度 1
每季度 4
每月 12
周期 52

当数据按每日或亚日收集时,有多种解释频率的方法。例如,每日数据可以具有每周的季节性。在这种情况下,频率是 m = 7,因为在一个完整的 1 周周期中会有七个观测值。它也可能具有年度季节性,这意味着 m = 365。因此,你可以看到每日和亚日数据可以有不同的周期长度,因此有不同的频率 m。表 8.2 提供了根据每日和亚日数据的季节周期选择适当的 m 值。

表 8.2 每日和亚日数据的适当频率 m

数据收集 频率 m
分钟 小时
每日
每小时
每分钟
每秒 60

现在你已经理解了参数 m,那么 PDQ 的含义也就变得直观了。正如之前提到的,它们是来自 ARIMA(p,d,q) 模型的 pdq 参数的季节性对应项。

季节性自回归积分移动*均(SARIMA)模型

SARIMA(季节性自回归积分移动*均)模型向 ARIMA(p,d,q) 模型添加了季节性参数。

它表示为 SARIMA(p,d,q)(P,D,Q)[m],其中 P 是季节性自回归过程(AR(P))的阶数,D 是季节性积分阶数,Q 是季节性移动*均过程(MA(Q))的阶数,而 m 是频率,或每个季节周期内的观测次数。

注意,SARIMA(p,d,q)(0,0,0)[m] 模型等同于 ARIMA(p,d,q) 模型。

让我们考虑一个 m = 12 的例子。如果 P = 2,这意味着我们包括两个过去值,这些值在 m 的倍数滞后中。因此,我们将包括 y[t–12] 和 y[t–24] 的值。

同样,如果 D = 1,这意味着季节差分使序列*稳。在这种情况下,季节差分将表示为方程 8.1。

y'[t] = y[t]y[t–12]

方程 8.1

Q = 2 的情况下,我们将包括过去误差项在m的倍数滞后中。因此,我们将包括误差项ϵ[t–12]和ϵ[t–24]。

让我们使用航空公司的总月度航空乘客数据集来分析这个问题。我们知道这是月度数据,这意味着m = 12。此外,我们可以看到七月和八月通常是每年航空乘客数量最高的月份,如图 8.2 中的圆形标记所示。因此,如果我们预测 1961 年的七月,那么从前几年七月的信息可能会很有用,因为我们可以直观地预期 1961 年七月的航空乘客数量将达到最高点。参数PDQm使我们能够从先前的季节周期中捕捉到这些信息,以帮助我们预测时间序列。

图片

图 8.2 标记了每年的七月。你可以看到七月有最多的航空乘客。因此,下一年七月也看到该年最高的航空乘客数量是有意义的。这种信息被 SARIMA(p,d,q)(P,D,Q)[m]模型中的季节性参数PDQm所捕捉。

现在我们已经研究了 SARIMA 模型,并且你了解了它是如何扩展 ARIMA 模型的,让我们继续识别时间序列中季节性模式的存在。

8.2 识别时间序列中的季节性模式

直观上,我们知道在具有季节性模式的数据上应用 SARIMA 模型是有意义的。因此,确定识别时间序列中季节性的方法非常重要。

通常,绘制时间序列数据就足以观察到周期性模式。例如,查看图 8.3 中的总月度航空乘客,我们很容易识别每年重复的模式,每年六月、七月和八月记录的乘客数量较多,而每年十一月、十二月和一月的乘客数量较少。

图片

图 8.3 强调了月度航空乘客数量中的季节性模式。虚线垂直线将十二个月份的周期分开。我们可以清楚地看到每年中间都有一个峰值,并且每年开始和结束都有非常相似的图案。这种观察通常足以确定数据集是季节性的。

识别时间序列中季节性模式的另一种方法是使用时间序列分解,这是我们首次在第一章中使用的。时间序列分解是一种统计任务,它将时间序列分解为其三个主要组成部分:趋势成分、季节成分和残差。

趋势成分代表时间序列中的长期变化。这个成分负责时间序列随时间的增加或减少。季节成分当然是时间序列中的季节性模式。它代表在固定时间段内发生的重复波动。最后,残差或噪声表示任何无法由趋势或季节成分解释的不规则性。

时间序列分解

时间序列分解是一种统计任务,它将时间序列分解为其三个主要成分:趋势成分、季节成分和残差。

趋势成分代表时间序列中的长期变化。这个成分负责时间序列随时间的增加或减少。季节成分是时间序列中的周期性模式。它代表在固定时间段内发生的重复波动。最后,残差或噪声表示任何无法由趋势或季节成分解释的不规则性。

注意:本章的源代码可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH08

通过时间序列分解,我们可以清楚地识别和可视化时间序列的季节成分。我们可以使用statsmodels库中的STL函数分解空乘数据集,以生成图 8.4。

from statsmodels.tsa.seasonal import STL

decomposition = STL(df['Passengers'], period=12).fit()      ❶

fig, (ax1, ax2, ax3, ax4) = plt.subplots(nrows=4, ncols=1, sharex=True, 
➥ figsize=(10,8))                                          ❷

ax1.plot(decomposition.observed)
ax1.set_ylabel('Observed')

ax2.plot(decomposition.trend)
ax2.set_ylabel('Trend')

ax3.plot(decomposition.seasonal)
ax3.set_ylabel('Seasonal')

ax4.plot(decomposition.resid)
ax4.set_ylabel('Residuals')

plt.xticks(np.arange(0, 145, 12), np.arange(1949, 1962, 1))

fig.autofmt_xdate()
plt.tight_layout()

❶ 使用 STL 函数分解序列。周期等于频率 m。由于我们拥有月度数据,周期为 12。

❷ 在图中绘制每个成分。

图片

图 8.4 空乘数据集的分解。第一幅图显示了观测数据。第二幅图显示了趋势成分,它告诉我们空乘数量随时间增加。第三幅图显示了季节成分,我们可以清楚地看到随时间重复出现的模式。最后,最后一幅图显示了残差,这些是数据中的变化,无法由趋势或季节成分解释。

在图 8.4 中,你可以看到我们时间序列的每个成分。你会注意到趋势、季节和残差成分的图表的y轴与观测数据略有不同。这是因为每个图表显示了归因于该特定成分的变化幅度。这样,趋势、季节和残差成分的总和就等于顶部图表中显示的观测数据。这解释了为什么季节成分有时为负值,有时为正值,因为它在观测数据中创造了峰值和谷值。

在没有季节性模式的时间序列情况下,分解过程将在季节性成分上显示 0 处的*坦水*线。为了证明这一点,我模拟了一个线性时间序列,并使用你刚才看到的方法将其分解为其三个组成部分。结果如图 8.5 所示。

图片

图 8.5 为模拟线性序列的时间序列分解。顶部图显示了观测数据,你会注意到我模拟了一个完美的线性序列。第二幅图显示了趋势成分,它应该与观测数据相同,因为序列随时间线性增加。由于没有季节性模式,季节性成分在 0 处是一条*坦的水*线。在这里,残差也是 0,因为我模拟了一个完美的线性序列。

你可以看到时间序列分解如何帮助我们确定我们的数据是否具有季节性。这是一个图形方法,而不是统计测试,但它足以确定一个序列是否具有季节性,这样我们就可以应用适当的模型进行预测。实际上,没有统计测试可以用来识别时间序列中的季节性。

现在你已经知道如何识别序列中的季节性模式,我们可以继续调整通用建模程序,以包括 SARIMA(p,d,q)(P,D,Q)[m]模型的新参数,并预测每月航空乘客数量。

8.3 预测每月航空乘客数量

在上一章中,我们将我们的通用建模程序调整为考虑 ARIMA 模型中的新参数 d,这使得我们能够预测非*稳时间序列。步骤已在图 8.6 中概述。现在我们必须再次修改它,以考虑 SARIMA 模型的新的参数 PDQm

图片

图 8.6 为 ARIMA 模型的通用建模程序。我们现在需要调整步骤,以考虑 SARIMA 模型的参数 PDQm

收集数据的第一步保持不变。然后我们仍然检查*稳性并应用转换以设置参数 d。然而,我们也可以进行季节性差分,使序列*稳,D 将等于我们应用季节性差分的最小次数。

然后我们为 pqPQ 设置了一系列可能的值,因为 SARIMA 模型还可以包含季节性自回归和季节性移动*均过程的阶数。请注意,这两个新参数的添加将增加我们可以拟合的 SARIMA(p,d,q)(P,D,Q)[m]模型独特组合的数量,因此这一步将需要更长的时间来完成。其余的程序保持不变,因为我们仍然需要选择具有最低 AIC 的模型,并在使用模型进行预测之前进行残差分析。建模过程的结果如图 8.7 所示。

图片

图 8.7 SARIMA 模型的通用建模过程。注意,我们可以将PDQ设置为 0 以获得 ARIMA(p,d,q)模型。

在定义了新的建模过程之后,我们现在可以预测每月航空旅客的总数。对于这个场景,我们希望预测 1 年的月航空旅客数,因此我们将 1960 年的数据作为测试集,如图 8.8 所示。

图片

图 8.8 航空旅客数据集的训练集和测试集划分。阴影区域表示测试期,对应于 1960 年的全年,因为我们的目标是预测一年的月航空旅客数。

基线模型将是简单的季节性预测,我们将使用 ARIMA(p,d,q)和 SARIMA(p,d,q)(P,D,Q)[m]模型来验证添加季节性成分是否会提高预测效果。

8.3.1 使用 ARIMA(p,d,q)模型进行预测

我们将首先使用 ARIMA(p,d,q)模型对数据集进行建模。这样,我们可以比较其性能与 SARIMA(p,d,q)(P,D,Q)[m]模型。

按照我们之前概述的通用建模过程,我们首先将测试*稳性。再次,我们使用 ADF 测试。

ad_fuller_result = adfuller(df['Passengers'])

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

这打印出 ADF 统计量为 0.82 和 p 值为 0.99。因此,我们不能拒绝零假设,并且序列不是*稳的。我们将对序列进行差分并再次测试*稳性。

df_diff = np.diff(df['Passengers'], n=1)     ❶

ad_fuller_result = adfuller(df_diff)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

❶ 一阶差分

这返回了 ADF 统计量为-2.83 和 p 值为 0.054。再次,我们不能拒绝零假设,并且对序列进行一次差分并没有使其*稳。因此,我们将再次差分并测试*稳性。

df_diff2 = np.diff(df_diff, n=1)    ❶

ad_fuller_result = adfuller(df_diff2)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

❶ 序列现在被差分两次

这返回了 ADF 统计量为-16.38 和 p 值为 2.73 × 10^(–29)。现在我们可以拒绝零假设,并且我们的序列被认为是*稳的。由于序列被差分两次以变得*稳,d = 2。

现在我们可以定义参数pq的可能值的范围,并拟合所有唯一的 ARIMA(p,d,q)模型。我们将特别选择从 0 到 12 的范围,以便 ARIMA 模型可以回溯 12 个时间步。由于数据是按月采样的,并且我们知道它是季节性的,我们可以假设给定年份一月份的航空旅客数很可能预示着下一年一月份的航空旅客数。由于这两个点相隔 12 个时间步,我们将允许pq的值从 0 到 12 变化,以在 ARIMA(p,d,q)模型中捕捉这种季节性信息。最后,由于我们正在使用 ARIMA 模型,我们将PDQ设置为 0。注意以下代码中参数s的使用,它与m等价。statsmodels中 SARIMA 的实现简单地使用s而不是m——它们都表示频率。

ps = range(0, 13, 1)                               ❶
qs = range(0, 13, 1)    
Ps = [0]                                           ❷
Qs = [0]    

d = 2                                              ❸
D = 0                                              ❹
s = 12                                             ❺

ARIMA_order_list = list(product(ps, qs, Ps, Qs))   ❻

❶ 允许 p 和 q 从 0 到 12 变化,以捕捉季节性信息。

❷ 将 P 和 Q 设置为 0,因为我们正在使用 ARIMA(p,d,q)模型。

❸ 将参数 d 设置为序列被差分以成为*稳状态的次数。

❹ D 设置为 0,因为我们正在使用 ARIMA(p,d,q)模型。

❺ 参数s等同于 m。它们都表示频率。这就是 SARIMA 模型在 statsmodels 库中的实现方式。

❻ 生成所有可能的(p,d,q)(0,0,0)组合。

你会注意到我们设置了pDQm参数,尽管我们正在使用 ARIMA 模型。这是因为我们将定义一个optimize_SARIMA函数,然后将在下一节中重用它。我们将pDq设置为 0,因为 SARIMA(p,d,q)(0,0,0)[m]模型等同于 ARIMA(p,d,q)模型。

optimize_SARIMA函数基于我们在上一章中定义的optimize_ARIMA函数。这次,我们将整合pq的可能值,以及添加季节积分阶数d和频率m。函数如下所示。

列表 8.1 定义一个函数以选择最佳 SARIMA 模型

from typing import Union
from tqdm import tqdm_notebook
from statsmodels.tsa.statespace.sarimax import SARIMAX

def optimize_SARIMA(endog: Union[pd.Series, list], order_list: list, d: 
➥ int, D: int, s: int) -> pd.DataFrame:      ❶

    results = []

    for order in tqdm_notebook(order_list):   ❷
        try: 
            model = SARIMAX(
                endog, 
                order=(order[0], d, order[1]),
                seasonal_order=(order[2], D, order[3], s),
                simple_differencing=False).fit(disp=False)
        except:
            continue

        aic = model.aic
        results.append([order, aic])
    result_df = pd.DataFrame(results)
    result_df.columns = ['(p,q,P,Q)', 'AIC']

    #Sort in ascending order, lower AIC is better
    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)

    return result_df                          ❸

❶ order_list 参数现在包括 p、q、P 和 Q 阶数。我们还添加了季节差分阶数 D 和频率。记住,SARIMA 模型中的频率 m 在 statsmodels 库的实现中用 s 表示。

❷ 遍历所有唯一的 SARIMA(p,d,q)(P,D,Q)[m]模型,拟合它们并存储 AIC 值。

❸ 返回按最低 AIC 值排序的 DataFrame。

函数准备好后,我们可以使用训练集启动它,以获得具有最低 AIC 值的 ARIMA 模型。尽管我们正在使用optimize_SARIMA函数,但我们仍然拟合 ARIMA 模型,因为我们特别将pDq设置为 0。对于训练集,我们将取所有数据点,但最后十二个,因为它们将被用于测试集。

train = df['Passengers'][:-12]                                       ❶

ARIMA_result_df = optimize_SARIMA(train, ARIMA_order_list, d, D, s)  ❷
ARIMA_result_df                                                      ❸

❶ 训练集包括所有数据点,但最后 12 个,因为最后一年数据用于测试集。

❷ 运行 optimize_SARIMA 函数。

❸ 按 AIC 值递增顺序显示排序后的 DataFrame。

这将返回一个DataFrame,其中具有最低 AIC 值的模型是 SARIMA(11,2,3)(0,0,0)[12]模型,它等同于 ARIMA(11,2,3)模型。正如你所见,允许p的阶数从 0 到 12 变化对模型有益,因为具有最低 AIC 值的模型考虑了序列的过去 11 个值,因为p = 11。我们将看到这是否足以捕捉序列的季节性信息,我们将在下一节比较 ARIMA 模型和 SARIMA 模型的表现。

目前,我们将专注于执行残差分析。我们可以拟合之前获得的 ARIMA(11,2,3)模型并绘制残差诊断图。

ARIMA_model = SARIMAX(train, order=(11,2,3), simple_differencing=False)
ARIMA_model_fit = ARIMA_model.fit(disp=False)

ARIMA_model_fit.plot_diagnostics(figsize=(10,8));

结果如图 8.9 所示。基于定性分析,残差接*白噪声,这意味着误差是随机的。

图片

图 8.9 ARIMA(11,2,3) 模型的残差诊断。在左上角的图中,残差没有趋势,其方差似乎随时间保持相对恒定,这与白噪声的行为相似。右上角的图显示了残差的分布,尽管有异常峰值,但接*正态分布。这一点进一步由左下角的 Q-Q 图证实,该图显示一条相当直的线,位于 y = x 上。最后,右下角的 correlogram 图显示在滞后 0 之后没有显著的自相关系数,这与白噪声完全一样。从这个分析来看,残差类似于白噪声。

下一步是对残差进行 Ljung-Box 测试,以确保它们是独立且不相关的。

from statsmodels.stats.diagnostic import acorr_ljungbox

residuals = ARIMA_model_fit.resid

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))

print(pvalue)

返回的 p 值都大于 0.05,除了前两个值。这意味着根据 Ljung-Box 测试,我们有 5% 的错误概率拒绝零假设,因为我们把显著性边界设定为 0.05。然而,第三个值及以后的值都大于 0.05,所以我们拒绝零假设,得出结论,残差从滞后 3 开始是不相关的。

这是一个有趣的情况,因为残差的可视化分析使我们得出结论,它们类似于白噪声,但 Ljung-Box 测试指出在滞后 1 和 2 处存在一些相关性。这意味着我们的 ARIMA 模型没有捕捉到数据中的所有信息。

在这种情况下,我们将继续使用模型,因为我们知道我们正在使用非季节性模型来模拟季节性数据。因此,Ljung-Box 测试实际上在告诉我们我们的模型并不完美,但这没关系,因为这项练习的一部分就是要比较 ARIMA 和 SARIMA 的性能,并证明在处理季节性数据时 SARIMA 是最佳选择。

如前所述,我们希望预测一年的月度航空乘客量,使用最后 12 个月的数据作为我们的测试集。基线模型是简单的季节性预测,我们只是使用 1959 年每个月的航空乘客数作为 1960 年每个月的预测。

test = df.iloc[-12:]                                             ❶

test['naive_seasonal'] = df['Passengers'].iloc[120:132].values   ❷

❶ 创建测试集。它对应于最后 12 个数据点,即 1960 年的数据。

❷ 简单的季节性预测只是将 1959 年的数据作为 1960 年的预测。

我们可以将我们的 ARIMA(11,2,3) 模型的预测结果添加到 test DataFrame 中。

ARIMA_pred = ARIMA_model_fit.get_prediction(132, 143).predicted_mean    ❶

test['ARIMA_pred'] = ARIMA_pred                                         ❷

❶ 获取 1960 年每个月的预测。

❷ 将预测结果添加到测试集中。

将 ARIMA 模型的预测存储在 test 中后,我们现在将使用 SARIMA 模型,并稍后比较两种模型的性能,以查看 SARIMA 模型在应用于季节性时间序列时是否确实比 ARIMA 模型表现更好。

8.3.2 使用 SARIMA(p,d,q)(P,D,Q)[m] 模型进行预测

在上一节中,我们使用 ARIMA(11,2,3) 模型来预测月度航空乘客数量。现在我们将拟合 SARIMA 模型,看看它是否比 ARIMA 模型表现更好。希望 SARIMA 模型表现更好,因为它可以捕捉季节信息,我们知道我们的数据集表现出明显的季节性,如图 8.10 所示。

图片

图 8.10 从 1949 年 1 月到 1960 年 12 月的航空公司月度总乘客数。你可以看到序列中有一个明显的季节性模式,年中交通流量达到峰值。

按照我们的一般建模过程(图 8.11)的步骤,我们首先检查*稳性并应用所需的转换。

ad_fuller_result = adfuller(df['Passengers'])

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

图片

图 8.11 SARIMA 模型的一般建模过程

对数据集进行的 ADF 测试返回 ADF 统计量为 0.82 和 p 值为 0.99。因此,我们不能拒绝零假设,序列是非*稳的。我们可以应用一阶差分并测试*稳性。

df_diff = np.diff(df['Passengers'], n=1)

ad_fuller_result = adfuller(df_diff)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

这返回 ADF 统计量为 –2.83 和 p 值为 0.054。由于 p 值大于 0.05,我们不能拒绝零假设,序列仍然是非*稳的。因此,让我们应用季节差分并测试*稳性。

df_diff_seasonal_diff = np.diff(df_diff, n=12)     ❶

ad_fuller_result = adfuller(df_diff_seasonal_diff)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

❶ 季节差分。由于我们拥有月度数据,m = 12,因此季节差分是两个相隔 12 个时间步长的值的差。

这返回 ADF 统计量为 –17.63 和 p 值为 3.82 × 10^(–30)。由于 ADF 统计量大且为负,且 p 值小于 0.05,我们可以拒绝零假设,并将转换后的序列视为*稳的。因此,我们进行了一轮差分,意味着 d = 1,以及一轮季节差分,意味着 D = 1。

在完成这一步后,我们现在可以定义 pqPQ 的可能值范围,为每个独特的 SARIMA(p,d,q)(P,D,Q)[m] 模型进行拟合,并选择具有最低 AIC 的模型。

ps = range(0, 4, 1)                                                     ❶
qs = range(0, 4, 1)
Ps = range(0, 4, 1)
Qs = range(0, 4, 1)

SARIMA_order_list = list(product(ps, qs, Ps, Qs))                       ❷

train = df['Passengers'][:-12]                                          ❸

d = 1
D = 1
s = 12

SARIMA_result_df = optimize_SARIMA(train, SARIMA_order_list, d, D, s)   ❹
SARIMA_result_df                                                        ❺

❶ 我们尝试 p、q、P 和 Q 的值分别为 [0,1,2,3]。

❷ 生成唯一组合的阶数。

❸ 训练集包括所有数据,除了最后 12 个数据点,这些数据点用于测试集。

❹ 在训练集上拟合所有 SARIMA 模型。

❺ 显示结果。

一旦函数运行完成,我们发现 SARIMA(2,1,1)(1,1,2)[12] 模型具有最低的 AIC,其值为 892.24。我们可以在训练集上再次拟合此模型以进行残差分析。

我们首先将在图 8.12 中绘制残差的诊断图。

SARIMA_model = SARIMAX(train, order=(2,1,1), seasonal_order=(1,1,2,12), 
➥ simple_differencing=False)
SARIMA_model_fit = SARIMA_model.fit(disp=False)

SARIMA_model_fit.plot_diagnostics(figsize=(10,8));

图片

图 8.12 SARIMA(2,1,1)(1,1,2)[12]模型的残差诊断。左上角的图显示残差没有表现出趋势或方差的变化。右上角的图显示残差的分布非常接*正态分布。这一点进一步由左下角的 Q-Q 图支持,该图显示了一条相当直的线,位于 y = x 上。最后,右下角的 correlogram 在滞后 0 之后没有显示显著的系数。因此,所有这些都指向一个结论,即残差类似于白噪声。

结果显示,我们的残差完全随机,这正是我们在一个好的模型中寻找的。

确定我们是否可以使用此模型进行预测的最终测试是 Ljung-Box 测试。

from statsmodels.stats.diagnostic import acorr_ljungbox

residuals = SARIMA_model_fit.resid

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))

print(pvalue)

返回的 p 值都大于 0.05。因此,我们不拒绝零假设,我们得出结论,残差是独立且不相关的,就像白噪声一样。

我们模型已经通过了残差分析的所有的测试,我们准备用它来进行预测。再次,我们将预测 1960 年的月度航空旅客数量,并将预测值与测试集中的观测值进行比较。

SARIMA_pred = SARIMA_model_fit.get_prediction(132, 143).predicted_mean   ❶

test['SARIMA_pred'] = SARIMA_pred

❶ 预测 1960 年的月度航空旅客数量。

现在我们有了结果,我们可以比较每个模型的性能,并确定我们问题的最佳预测方法。

8.3.3 比较每种预测方法的性能

我们现在可以比较每种预测方法的性能:简单的季节性预测、ARIMA 模型和 SARIMA 模型。我们将使用*均绝对百分比误差(MAPE)来评估每个模型。

我们可以先可视化预测值与测试集中观测值的对比。

fig, ax = plt.subplots()

ax.plot(df['Month'], df['Passengers'])
ax.plot(test['Passengers'], 'b-', label='actual')
ax.plot(test['naive_seasonal'], 'r:', label='naive seasonal')
ax.plot(test['ARIMA_pred'], 'k--', label='ARIMA(11,2,3)')
ax.plot(test['SARIMA_pred'], 'g-.', label='SARIMA(2,1,1)(1,1,2,12)')

ax.set_xlabel('Date')
ax.set_ylabel('Number of air passengers')
ax.axvspan(132, 143, color='#808080', alpha=0.2)

ax.legend(loc=2)

plt.xticks(np.arange(0, 145, 12), np.arange(1949, 1962, 1))
ax.set_xlim(120, 143)      ❶

fig.autofmt_xdate()
plt.tight_layout()

❶ 放大测试集

图 8.13 显示了该图。ARIMA 和 SARIMA 模型的线条几乎与观测数据重合,这意味着预测值非常接*观测数据。

图 8.13 月度航空旅客数量的预测。阴影区域表示测试集。您可以看到,来自 ARIMA 和 SARIMA 模型的曲线几乎掩盖了观测数据,这是良好预测的标志。

我们可以测量每个模型的 MAPE 并显示在条形图中,如图 8.14 所示。

def mape(y_true, y_pred):                                               ❶
    return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

mape_naive_seasonal = mape(test['Passengers'], test['naive_seasonal'])  ❷
mape_ARIMA = mape(test['Passengers'], test['ARIMA_pred'])
mape_SARIMA = mape(test['Passengers'], test['SARIMA_pred'])

fig, ax = plt.subplots()                                                ❸

x = ['naive seasonal', 'ARIMA(11,2,3)', 'SARIMA(2,1,1)(1,1,2,12)']
y = [mape_naive_seasonal, mape_ARIMA, mape_SARIMA]

ax.bar(x, y, width=0.4)
ax.set_xlabel('Models')
ax.set_ylabel('MAPE (%)')
ax.set_ylim(0, 15)

for index, value in enumerate(y):                                       ❹
    plt.text(x=index, y=value + 1, s=str(round(value,2)), ha='center')

plt.tight_layout()

❶ 定义一个计算 MAPE 的函数。

❷ 计算每种预测方法的 MAPE。

❸ 在条形图上绘制 MAPE。

❹ 在条形图中以文本形式显示 MAPE。

图 8.14 所有预测方法的 MAPE。您可以看到,表现最好的模型是 SARIMA 模型,因为它的 MAPE 是所有方法中最低的。

在图 8.14 中,你可以看到我们的基线实现了 9.99% 的 MAPE。ARIMA 模型产生了 3.85% 的 MAPE 的预测,而 SARIMA 模型获得了 2.85% 的 MAPE。MAPE 越接* 0,表明预测越好,因此 SARIMA 模型是这种情况下的最佳性能方法。这是有道理的,因为我们的数据集具有明显的季节性,而 SARIMA 模型正是为了利用时间序列的季节性属性来进行预测。

8.4 下一步

在本章中,我们介绍了 SARIMA(p,d,q)(P,D,Q)[m] 模型,该模型允许我们模拟非*稳季节性时间序列。

参数 PDQm 的添加使我们能够将时间序列的季节性属性包含在模型中,并使用它们来生成预测。在这里,P 是季节性自回归过程的阶数,D 是季节性积分的阶数,Q 是季节性移动*均过程的阶数,而 m 是数据的频率。

我们探讨了如何首先使用时间序列分解来检测季节性模式,并且我们调整了我们的通用建模程序,以测试 PQ 的值。

在第四章到第八章中,我们逐渐构建了一个更通用和复杂的模型,从 MA(q) 和 AR(p) 模型开始,将它们结合成 ARMA(p,q) 模型,这引导我们到 ARIMA(p,d,q) 模型,最终到 SARIMA(p,d,q)(P,D,Q)[m] 模型。这些模型只考虑时间序列本身的值。然而,外部变量也是我们时间序列的预测因素是有道理的。例如,如果我们希望模拟一个国家随时间推移的总支出,查看利率或债务水*可能是预测性的。我们如何在模型中包含这些外部变量?

这引导我们到 SARIMAX 模型。注意 X 的添加,它代表 外生变量。这个模型将结合我们迄今为止所学的一切,并通过添加外部变量的影响来进一步扩展它,以预测我们的目标。这将是下一章的主题。

8.5 练习

利用这个练习,花时间实验 SARIMA 模型。完整的解决方案在 GitHub 上:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH08

8.5.1 在 Johnson & Johnson 数据集上应用 SARIMA(p,d,q)(P,D,Q)[m] 模型

在第七章中,我们将 ARIMA(p,d,q) 模型应用于 Johnson & Johnson 数据集,以预测一年内的季度 EPS。现在使用 SARIMA(p,d,q)(P,D,Q)[m] 模型对同一数据集进行操作,并将其性能与 ARIMA 模型进行比较。

  1. 使用时间序列分解来识别周期性模式的存在。

  2. 使用 optimize_SARIMA 函数并选择具有最低 AIC 的模型。

  3. 执行残差分析。

  4. 预测过去一年的 EPS,并使用 MAPE 来衡量其性能。它更好吗?

摘要

  • 季节性自回归积分滑动*均模型,表示为 SARIMA(p,d,q)(P,D,Q)[m],为 ARIMA(p,d,q)模型添加季节性属性。

  • P是季节性自回归过程的阶数,D是季节性积分的阶数,Q是季节性移动*均过程的阶数,而m是数据的频率。

  • 频率m对应于一个周期内的观测次数。如果数据每月收集一次,则m = 12。如果数据每季度收集一次,则m = 4。

  • 时间序列分解可以用来识别时间序列中的季节性模式。

9 向我们的模型添加外部变量

本章涵盖

  • 检查 SARIMAX 模型

  • 探索外部变量在预测中的应用

  • 使用 SARIMAX 模型进行预测

在第四章到第八章中,我们逐渐构建了一个通用模型,使我们能够考虑时间序列中的更复杂模式。我们的旅程从自回归和移动*均过程开始,然后将其结合成 ARMA 模型。然后我们为模型添加了一层复杂性,以模拟非*稳时间序列,这使我们到达了 ARIMA 模型。最后,在第八章中,我们在 ARIMA 模型的基础上又增加了一层,使我们能够考虑预测中的季节性模式,从而产生了 SARIMA 模型。

到目前为止,我们探索并用于生成预测的每个模型都只考虑了时间序列本身。换句话说,时间序列的过去值被用作未来值的预测因子。然而,外部变量也可能对我们的时间序列产生影响,因此可以作为未来值的良好预测因子。

这将我们引向 SARIMAX 模型。你会注意到增加了 X 项,它表示外生变量。在统计学中,术语“外生”用于描述预测因子或输入变量,而“内生”用于定义目标变量——我们试图预测的内容。使用 SARIMAX 模型,我们现在可以在预测时间序列时考虑外部变量,或外生变量。

作为指导性示例,我们将使用从 1959 年到 2009 年每季度收集的美国宏观经济数据集,来预测实际国内生产总值(GDP),如图 9.1 所示。

图片

图 9.1 1959 年至 2009 年美国实际国内生产总值(GDP)。数据按季度收集,并以千美元为单位表示。注意,多年来呈现明显的上升趋势,没有周期性模式,这表明该系列中不存在季节性。

国内生产总值(GDP)是一个国家内生产的所有最终商品和服务的总市场价值。实际 GDP 是一个经过通货膨胀调整的衡量标准,它消除了通货膨胀对商品市场价值的影响。通货膨胀或通货紧缩可以分别增加或减少商品和服务的货币价值,从而增加或减少 GDP。通过消除通货膨胀的影响,我们可以更好地确定一个经济体是否经历了生产的扩张。

不深入探讨衡量 GDP 的技术细节,我们将 GDP 定义为消费 C、政府支出 G、投资 I 和净出口 NX 的总和,如方程 9.1 所示。

GDP = C + G + I + NX

方程 9.1

方程 9.1 中的每个元素都可能受到某些外部变量的影响。例如,消费可能受到失业率的影响,因为如果就业人数减少,消费很可能会下降。利率也可能产生影响,因为如果它们上升,借款会变得更加困难,从而导致支出减少。我们还可以考虑货币汇率对净出口的影响。一般来说,本币贬值会刺激出口,并使进口变得更加昂贵。因此,我们可以看到许多外生变量可能影响美国的实际 GDP。

在本章中,我们将首先检查 SARIMAX 模型,并探讨在使用它进行预测时需要注意的一个重要注意事项。然后,我们将应用该模型来预测美国的实际 GDP。

9.1 检查 SARIMAX 模型

SARIMAX 模型通过添加外生变量的影响进一步扩展了 SARIMA(p,d,q) (P,D,Q)[m]模型。因此,我们可以简单地用 SARIMA(p,d,q) (P,D,Q)[m]模型来表示当前值y[t],并向其中添加任何数量的外生变量X[t],如方程 9.2 所示。

方程式图片

方程 9.2

SARIMA 模型是一个线性模型,因为它是由时间序列的过去值和误差项的线性组合。在这里,我们添加了另一个不同外生变量的线性组合,因此 SARIMAX 也是一个线性模型。请注意,在 SARIMAX 中,您可以包括分类变量作为外生变量,但请确保您像处理传统回归任务一样对它们进行编码(赋予它们数值或二进制标志)。

自第四章以来,我们一直在使用statsmodels中的SARIMAX函数来实现不同的模型。这是因为 SARIMAX 是用于预测时间序列最通用的函数。现在您已经了解了没有外生变量的 SARIMAX 模型是一个 SARIMA 模型。同样,没有季节性但有外生变量的模型可以表示为 ARIMAX 模型,而没有季节性和外生变量的模型则变为 ARIMA 模型。根据问题,将使用通用 SARIMAX 模型各部分的组合。

SARIMAX 模型

SARIMAX 模型简单地将外生变量的线性组合添加到 SARIMA 模型中。这使得我们能够模拟外部变量对时间序列未来值的影响。

我们可以大致定义 SARIMAX 模型如下:

方程式图片

SARIMAX 模型是预测时间序列最通用的模型。您可以看到,如果没有季节性模式,它就变成了 ARIMAX 模型。如果没有外生变量,它就是一个 SARIMA 模型。如果没有季节性或外生变量,它就变成了 ARIMA 模型。

理论上,这总结了 SARIMAX 模型。第四章到第八章的顺序是故意这样安排的,以便我们逐步开发 SARIMAX 模型,使得添加外生变量易于理解。为了加强您的学习,让我们探索我们数据集的外生变量。

9.1.1 探索美国宏观经济数据集的外生变量

让我们加载美国宏观经济数据集,并探索可用于预测实际 GDP 的不同外生变量。此数据集由 statsmodels 库提供,这意味着您不需要下载和读取外部文件。您可以使用 statsmodelsdatasets 模块加载数据集。

注意:本章的完整源代码可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH09

import statsmodels.api as sm

macro_econ_data = sm.datasets.macrodata.load_pandas().data    ❶
macro_econ_data                                               ❷

❶ 加载美国宏观经济数据集。

❷ 显示 DataFrame。

这显示了包含美国宏观经济数据集的整个 DataFrame。表 9.1 描述了每个变量的含义。我们有一个目标变量,或内生变量,即实际 GDP。然后我们有 11 个外生变量可以用于预测,例如个人和联邦消费支出、利率、通货膨胀率、人口等。

表 9.1 美国宏观经济数据集中所有变量的描述

变量 描述
realgdp 实际国内生产总值(目标变量或内生变量)
realcons 实际个人消费支出
realinv 实际国内私人总投资
realgovt 实际联邦消费支出和投资
realdpi 实际私人可支配收入
cpi 季末消费者价格指数
m1 M1 名义货币存量
tbilrate 月度 3 个月期国库券的季度月*均数
unemp 失业率
pop 季末总人口
infl 通货膨胀率
realint 实际利率

当然,这些变量中的每一个可能或可能不是实际 GDP 的良好预测指标。我们不必执行特征选择,因为线性模型将为在预测目标中不显著的变量分配接* 0 的系数。

为了简单和清晰,我们将在本章中只处理六个变量:我们的目标变量实际 GDP,以及表 9.1 中列出的下一个五个变量(从 realconscpi)作为我们的外生变量。

我们可以通过查看每个变量随时间的变化情况,看看我们是否可以识别出任何独特的模式。结果如图 9.2 所示。

fig, axes = plt.subplots(nrows=3, ncols=2, dpi=300, figsize=(11,6))

for i, ax in enumerate(axes.flatten()[:6]):                 ❶
    data = macro_econ_data[macro_econ_data.columns[i+2]]    ❷

    ax.plot(data, color='black', linewidth=1)
    ax.set_title(macro_econ_data.columns[i+2])              ❸
    ax.xaxis.set_ticks_position('none')
    ax.yaxis.set_ticks_position('none')
    ax.spines['top'].set_alpha(0)
    ax.tick_params(labelsize=6)

plt.setp(axes, xticks=np.arange(0, 208, 8), xticklabels=np.arange(1959, 
➥ 2010, 2))
fig.autofmt_xdate()
plt.tight_layout()

❶ 对六个变量进行迭代。

❷ 跳过年和季度列。这样我们就可以从 realgdp 开始。

❸ 在图表顶部显示变量的名称。

图 9.2 1959 年至 2009 年真实 GDP 和五个外生变量的演变。你会注意到 realgdprealconsrealdpicpi 都有相似的形状,这意味着 realconsrealdpicpi 可能是好的预测因子,尽管图形分析不足以证实这一点。另一方面,realgovt 有峰和谷,这在 realgdp 中没有出现,因此我们可以假设 realgovt 是一个较弱的预测因子。

对于时间序列预测,处理外生变量的方式有两种。首先,我们可以训练多个模型,使用各种外生变量的组合,并查看哪个模型生成的预测最好。或者,我们可以简单地包含所有外生变量,并坚持使用 AIC 进行模型选择,因为我们知道这样可以得到一个拟合良好且不过拟合的模型。

为什么在回归分析中不考虑 p 值?

statsmodels 中的 SARIMAX 实现,附带使用 summary 方法的回归分析。这一点将在本章后面展示。

在那个分析中,我们可以看到 SARIMAX 模型每个预测因子的每个系数的 p 值。通常,p 值被错误地用作特征选择的方法。许多人错误地将 p 值解释为确定预测因子是否与目标相关的手段。

实际上,p 值测试系数是否显著不同于 0。如果 p 值小于 0.05,则我们拒绝零假设,并得出结论,系数与 0 显著不同。它并不决定预测因子是否对预测有用。

因此,你不应该根据 p 值移除预测因子。通过最小化 AIC 选择模型可以处理这一步骤。

要了解更多信息,我建议阅读 Rob Hyndman 的“用于变量选择的统计测试”博客文章:robjhyndman.com/hyndsight/tests2/

9.1.2 使用 SARIMAX 的注意事项

使用 SARIMAX 模型时,有一个重要的注意事项。包含外部变量可能是有益的,因为你可能会找到针对目标变量的强预测因子。然而,在预测多个未来的时间步时,你可能会遇到问题。

回想一下,SARIMAX 模型使用 SARIMA(p,d,q) (P,D,Q)[m] 模型和外生变量的线性组合来预测未来的一个时间步。但如果你希望预测两个时间步的未来呢?虽然使用 SARIMA 模型是可能的,但 SARIMAX 模型要求我们也要预测外生变量。

为了说明这个想法,让我们假设realconsrealgdp的预测因子(这一点将在本章后面得到验证)。假设我们有一个 SARIMAX 模型,其中realcons被用作输入特征来预测realgdp。现在假设我们处于 2009 年底,必须预测 2010 年和 2011 年的实际 GDP。SARIMAX 模型允许我们使用 2009 年的realcons来预测 2010 年的实际 GDP。然而,预测 2011 年的实际 GDP 将需要我们预测 2010 年的realcons,除非我们等待观察 2010 年底的值。

因为realcons变量本身就是一个时间序列,它可以使用 SARIMA 模型的一个版本进行预测。然而,我们知道我们的预测总是与一些误差相关联。因此,为了预测目标变量而必须预测外生变量可能会放大目标预测的误差,这意味着我们的预测随着预测更多时间步长而迅速降低准确性。

避免这种情况的唯一方法是在未来预测一个时间步长,并在预测下一个时间步长的目标变量之前等待观察外生变量。

另一方面,如果你的外生变量易于预测,这意味着它遵循一个已知函数,可以准确预测,那么预测外生变量并使用这些预测来预测目标变量是没有害处的。

最后,没有明确的建议只预测一个时间步长。这取决于具体情况和可用的外生变量。这就是作为数据科学家,你的专业知识以及严格的实验变得重要的地方。如果你确定你的外生变量可以被准确预测,你可以建议预测很多未来的时间步长。否则,你的建议必须是每次只预测一个时间步长,并通过解释随着更多预测的进行,错误会累积,这意味着预测将失去准确性来证明你的决定。

现在我们已经深入探讨了 SARIMAX 模型,让我们将其应用于预测实际 GDP。

9.2 使用 SARIMAX 模型预测实际 GDP

我们现在准备好使用 SARIMAX 模型来预测实际 GDP。在探讨了数据集的外生变量之后,我们将它们纳入我们的预测模型。

在深入探讨之前,我们必须重新介绍一般的建模过程。这个过程没有发生重大变化。唯一的修改是我们现在将拟合一个 SARIMAX 模型。所有其他步骤保持不变,如图 9.3 所示。

图片

图 9.3 SARIMAX 模型的一般建模过程。此过程可以应用于任何问题,因为 SARIMAX 模型是最通用的预测模型,可以容纳我们探索的所有不同时间序列过程和属性。请注意,这里唯一的改变是我们正在适配 SARIMAX 模型,而不是我们在第八章中使用的 SARIMA 模型。其余过程保持不变。

按照图 9.3 中的建模过程,我们首先将使用增强迪基-富勒(ADF)测试检查目标变量的*稳性。

target = macro_econ_data['realgdp']          ❶
exog = macro_econ_data[['realcons', 'realinv', 'realgovt', 'realdpi', 
➥ 'cpi']]                                   ❷

ad_fuller_result = adfuller(target)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

❶ 定义目标变量。在这种情况下,它是实际 GDP。

❷ 定义外生变量。这里我们为了简单起见限制为五个变量。

这将返回一个 ADF 统计量为 1.75 和 p 值为 1.00。由于 ADF 统计量不是一个大的负数,且 p 值大于 0.05,我们不能拒绝零假设,并得出结论,该序列不是*稳的。

因此,我们必须应用一个转换并再次测试序列的*稳性。在这里,我们将对序列进行一次差分:

target_diff = target.diff()     ❶

ad_fuller_result = adfuller(target_diff[1:])

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

❶ 差分序列。

现在返回一个 ADF 统计量为-6.31 和 p 值为 3.32 × 10^(–8)。由于 ADF 统计量是大的负数,且 p 值小于 0.05,我们可以拒绝零假设,并得出结论,该序列现在是*稳的。因此,我们知道d = 1。由于我们不需要对序列进行季节差分使其*稳,D = 0。

我们现在将定义optimize_SARIMAX函数,该函数将适配模型的全部唯一组合,并以 AIC 升序返回一个DataFrame

列表 9.1 适配所有唯一 SARIMAX 模型的函数

from typing import Union
from tqdm import tqdm_notebook
from statsmodels.tsa.statespace.sarimax import SARIMAX

def optimize_SARIMAX(endog: Union[pd.Series, list], exog: Union[pd.Series, 
➥ list], order_list: list, d: int, D: int, s: int) -> pd.DataFrame:

    results = []

    for order in tqdm_notebook(order_list):
        try: 
            model = SARIMAX(
                endog,
                exog,                             ❶
                order=(order[0], d, order[1]),
                seasonal_order=(order[2], D, order[3], s),
                simple_differencing=False).fit(disp=False)
        except:
            continue

        aic = model.aic
        results.append([order, aic])

    result_df = pd.DataFrame(results)
    result_df.columns = ['(p,q,P,Q)', 'AIC']

    #Sort in ascending order, lower AIC is better
    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)

    return result_df

❶ 注意在适配模型时添加外生变量。

接下来,我们将定义可能的值范围pqPQ的阶数。我们将尝试从 0 到 3 的值,但也可以尝试不同的值集。此外,由于数据是按季度收集的,因此m = 4。

p = range(0, 4, 1)
d = 1
q = range(0, 4, 1)
P = range(0, 4, 1)
D = 0
Q = range(0, 4, 1)
s = 4                   ❶

parameters = product(p, q, P, Q)
parameters_list = list(parameters) 

❶ 记住,在 statsmodels 中 SARIMAX 实现的 s 与 m 等价。

为了训练模型,我们将使用目标和外生变量的前 200 个实例。然后我们将运行optimize_SARIMAX函数,并选择具有最低 AIC 的模型。

target_train = target[:200]
exog_train = exog[:200]

result_df = optimize_SARIMAX(target_train, exog_train, parameters_list, d, 
➥ D, s)
result_df

一旦完成,函数将返回结论,SARIMAX(3,1,3)(0,0,0)[4]模型是具有最低 AIC 的模型。请注意,该模型的季节性成分只有 0 阶。这是有道理的,因为如图 9.4 所示,在真实 GDP 的图中没有明显的季节性模式。因此,季节性成分是空的,我们有一个 ARIMAX(3,1,3)模型。

图片

图 9.4 1959 年至 2009 年美国实际国内生产总值(GDP)。数据按季度收集,并以千美元为单位表示。注意,多年来存在明显的上升趋势,没有周期性模式,这表明序列中不存在季节性。

现在,我们可以拟合选定的模型并显示一个汇总表来查看与我们的外生变量相关的系数。结果如图 9.5 所示。

best_model = SARIMAX(target_train, exog_train, order=(3,1,3), 
➥ seasonal_order=(0,0,0,4), simple_differencing=False)
best_model_fit = best_model.fit(disp=False)

print(best_model_fit.summary())     ❶

❶ 显示模型的汇总表。

在图 9.5 中,您会注意到所有外生变量的 p 值都小于 0.05,除了 realdpi,它的 p 值为 0.712。这意味着 realdpi 的系数与 0 没有显著差异。您还会注意到其系数为 0.0091。然而,系数被保留在模型中,因为 p 值并不决定这个预测因子在预测我们的目标时的相关性。

图片

图 9.5 选定模型的汇总表。您可以看到我们的外生变量被分配了系数。您还可以在 P>|z| 列下看到它们的 p 值。

继续进行建模过程,我们现在将研究模型的残差,这些残差在图 9.6 中显示。所有迹象都表明残差是完全随机的,就像白噪声一样。我们的模型通过了视觉检查。

best_model_fit.plot_diagnostics(figsize=(10,8));

图片

图 9.6 选定模型的残差分析。您可以看到残差没有趋势,并且随着时间的推移具有相当恒定的方差,就像白噪声一样。在右上角的图中,残差的分布非常接*正态分布。这进一步得到了左下角 Q-Q 图的支持,该图显示了一条位于 y = x 上的相当直的线。最后,自相关图在滞后 0 之后没有显示显著的系数,就像白噪声一样。因此,从图形分析来看,该模型的残差类似于白噪声。

现在,我们将应用 Ljung-Box 测试以确保残差不相关。因此,我们希望看到大于 0.05 的 p 值,因为 Ljung-Box 测试的零假设是残差是独立且不相关的。

residuals = best_model_fit.resid

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))

print(pvalue)

所有 p 值都大于 0.05。因此,我们不拒绝零假设,并得出结论,残差是独立且不相关的。在通过两个残差检查后,我们的模型可用于预测。

如前所述,使用 SARIMAX 模型的缺点是,合理地预测只有下一个时间步是合理的,以避免预测外生变量,这会导致我们在最终预测中累积预测误差。

相反,为了测试我们的模型,我们将多次预测下一个时间步并*均每次预测的错误。这是通过 rolling_forecast 函数完成的,我们在第 4-6 章中定义并使用了该函数。作为一个基线模型,我们将使用最后已知值方法。

列表 9.2 预测下一个时间步多次的函数

def rolling_forecast(endog: Union[pd.Series, list], exog: 
➥ Union[pd.Series, list], train_len: int, horizon: int, window: int, 
➥ method: str) -> list:

    total_len = train_len + horizon

    if method == 'last':
        pred_last_value = []

        for i in range(train_len, total_len, window):
            last_value = endog[:i].iloc[-1]
            pred_last_value.extend(last_value for _ in range(window))

        return pred_last_value

    elif method == 'SARIMAX':
        pred_SARIMAX = []

        for i in range(train_len, total_len, window):
            model = SARIMAX(endog[:i], exog[:i], order=(3,1,3), 
➥ seasonal_order=(0,0,0,4), simple_differencing=False)
            res = model.fit(disp=False)
            predictions = res.get_prediction(exog=exog)
            oos_pred = predictions.predicted_mean.iloc[-window:]
            pred_SARIMAX.extend(oos_pred)

        return pred_SARIMAX

recursive_forecast 函数允许我们在一定时间内预测下一个时间步。具体来说,我们将使用它来预测从 2008 年开始到 2009 年第三季度的下一个时间步。

target_train = target[:196]       ❶
target_test = target[196:]        ❷

pred_df = pd.DataFrame({'actual': target_test})

TRAIN_LEN = len(target_train)
HORIZON = len(target_test)
WINDOW = 1                        ❸

pred_last_value = recursive_forecast(target, exog, TRAIN_LEN, HORIZON, 
➥ WINDOW, 'last')
pred_SARIMAX = recursive_forecast(target, exog, TRAIN_LEN, HORIZON, WINDOW, 
➥ 'SARIMAX')

pred_df['pred_last_value'] = pred_last_value
pred_df['pred_SARIMAX'] = pred_SARIMAX

pred_df

❶ 我们将模型拟合到 1959 年至 2007 年底的数据。

❷ 测试集包含从 2008 年开始到 2009 年第三季度的值。总共有七个值需要预测。

❸ 这指定了我们只预测下一个时间步长。

预测完成后,我们可以可视化哪个模型具有最低的*均绝对百分比误差(MAPE)。结果如图 9.7 所示。

def mape(y_true, y_pred):
    return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

mape_last = mape(pred_df.actual, pred_df.pred_last_value)
mape_SARIMAX = mape(pred_df.actual, pred_df.pred_SARIMAX)

fig, ax = plt.subplots()

x = ['naive last value', 'SARIMAX']
y = [mape_last, mape_SARIMAX]

ax.bar(x, y, width=0.4)
ax.set_xlabel('Models')
ax.set_ylabel('MAPE (%)')
ax.set_ylim(0, 1)

for index, value in enumerate(y):
    plt.text(x=index, y=value + 0.05, s=str(round(value,2)), ha='center')

plt.tight_layout()

在图 9.7 中,你会发现 SARIMAX 模型仅以 0.04%的优势成为获胜模型。你会欣赏到基线在这里的重要性,因为两种方法都实现了极低的 MAPE,表明 SARIMAX 模型仅略优于简单地预测最后一个值。这就是业务背景发挥作用的地方。在我们的案例中,由于我们正在预测美国的实际 GDP,0.04%的差异代表着数千美元。这种差异可能在这个特定背景下是相关的,这证明了即使 SARIMAX 模型仅略优于基线,使用 SARIMAX 模型也是合理的。

图片

图 9.7 每种方法的预测的*均绝对百分比误差(MAPE)。你可以看到,SARIMAX 模型仅比基线模型略小。这突出了使用基线的重要性,因为 0.70%的 MAPE 已经非常好了,但简单的预测实现了 0.74%的 MAPE,这意味着 SARIMAX 模型仅具有微小的优势。

9.3 下一步

在本章中,我们介绍了 SARIMAX 模型,该模型允许我们在预测目标时间序列时包含外部变量。

添加外生变量有一些注意事项:如果我们需要预测很多时间步长到未来,我们必须也预测外生变量,这可能会放大目标上的预测误差。为了避免这种情况,我们只能预测下一个时间步长。

在考虑预测实际 GDP 的外生变量时,我们也可以假设实际 GDP 可以是其他变量的预测因子。例如,变量cpirealgdp的预测因子,但我们也可能表明realgdp可以预测cpi

在我们希望展示两个随时间变化的变量可以相互影响的情况下,我们必须使用向量自回归(VAR)模型。这个模型允许进行多元时间序列预测,与用于单变量时间序列预测的 SARIMAX 模型不同。在下一章中,我们将详细探讨 VAR 模型,并你会发现它也可以扩展成为VARMA模型和VARMAX模型。

9.4 练习

请花时间通过这个练习测试你的知识。完整的解决方案在 GitHub 上:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH09

9.4.1 在 SARIMAX 模型中使用所有外生变量来预测实际 GDP

在本章中,我们在预测实际 GDP 时限制了外生变量的数量。这次练习是一个机会,使用所有外生变量来拟合 SARIMAX 模型,并验证你是否能实现更好的性能。

  1. 在 SARIMAX 模型中使用所有外生变量。

  2. 进行残差分析。

  3. 对数据集中最后七个时间步长进行预测。

  4. 测量 MAPE。它是否比使用有限数量的外生变量时更好、更差,还是相同?

摘要

  • SARIMAX 模型允许你包含外部变量,也称为外生变量,以预测你的目标。

  • 仅对目标变量应用变换,不对外生变量应用变换。

  • 如果你希望预测未来的多个时间步长,外生变量也必须进行预测。这可能会放大最终预测中的误差。为了避免这种情况,你必须只预测下一个时间步长。

10 多个时间序列的预测

本章涵盖

  • 检查 VAR 模型

  • 探索格兰杰因果检验以验证 VAR 模型的使用

  • 使用 VAR 模型进行多个时间序列的预测

在上一章中,你看到了如何使用 SARIMAX 模型来包括外生变量对时间序列的影响。在 SARIMAX 模型中,关系是单向的:我们假设外生变量只对目标变量有影响。

然而,可能存在两个时间序列之间存在双向关系的情况,这意味着时间序列 t1 是时间序列 t2 的预测因子,同时时间序列 t2 也是时间序列 t1 的预测因子。在这种情况下,拥有一个能够考虑这种双向关系并同时对两个时间序列进行预测的模型将非常有用。

这将我们引向向量自回归(VAR)模型。这个特定的模型允许我们捕捉多个时间序列随时间变化之间的关系。反过来,这使我们能够同时对多个时间序列进行预测,从而执行多元预测。

在本章中,我们将使用与第九章相同的美国宏观经济数据集。这次我们将探索实际可支配收入与实际消费之间的关系,如图 10.1 所示。

图片

图 10.1 1959 年至 2009 年美国实际可支配收入(realdpi)和实际消费(realcons)。数据按季度收集,并以千美元为单位表示。这两个序列在时间上的形状和趋势相似。

实际消费表示人们花费的金额,而实际可支配收入则代表可用于支出的金额。因此,一个更高的可支配收入可能预示着更高的消费。反之亦然,更高的消费意味着有更多的收入可用于支出。这种双向关系可以通过向量自回归(VAR)模型来捕捉。

在本章中,我们将首先详细探讨向量自回归(VAR)模型。然后,我们将介绍格兰杰因果检验,这有助于我们验证两个时间序列相互影响的假设。最后,我们将应用 VAR 模型对实际消费和实际可支配收入进行预测。

10.1 检查 VAR 模型

向量自回归(VAR)模型捕捉了多个时间序列随时间变化之间的关系。在这个模型中,每个时间序列都会对其他时间序列产生影响,这与 SARIMAX 模型不同,在 SARIMAX 模型中,外生变量对目标变量有影响,但反之则不然。回想第九章,我们使用了变量 realconsrealinvrealgovtrealdpicpim1tbilrate 作为 realgdp 的预测因子,但我们没有考虑 realgdp 如何影响这些变量。这就是为什么我们在那种情况下使用了 SARIMAX 模型。

你可能已经注意到了 自回归 的回归,这让我们回到了第五章的 AR(p) 模型。这是一个很好的直觉,因为 VAR 模型可以看作是 AR(p) 模型的一种推广,允许预测多个时间序列。因此,我们也可以将 VAR 模型表示为 VAR(p),其中 p 是阶数,其含义与 AR(p) 模型相同。

回想一下,AR(p) 将时间序列的值表示为常数 C、当前误差项 ϵ[t](它也是白噪声)以及时间序列的过去值 y[tp] 的线性组合。过去值对当前值的影响程度用 ϕ[p] 表示,它代表 AR(p) 模型的系数,如方程式 10.1 所示。

y[t] = C + ϕ[1]y[t–1] + ϕ[2]y[t–2] +⋅⋅⋅+ ϕ[p]y[tp] + ϵ[t]

方程式 10.1

我们可以将方程式 10.1 扩展以允许模拟多个时间序列,其中每个时间序列都会影响其他时间序列。

为了简化,让我们考虑一个由两个时间序列组成的时间序列系统,分别表示为 y[1 ,t] 和 y[2 ,t],并且阶数为 1,这意味着 p = 1。然后,使用矩阵表示法,VAR(1) 模型可以表示为方程式 10.2。

方程式 10-2

方程式 10.2

进行矩阵乘法后,y[1 ,t] 的数学表达式显示在方程式 10.3 中,而 y[2 ,t] 的表达式显示在方程式 10.4 中。

y[1 ,t] = C[1] + ϕ[1,1]y[1,][t–1] + ϕ[1,2]y[2,][t–1] + ϵ[1 ,t]

方程式 10.3

y[2 ,t] = C[2] + ϕ[2,1]y[1,][t–1] + ϕ[2,2]y[2,][t–1] + ϵ[2 ,t]

方程式 10.4

在方程式 10.3 中,你会注意到 y[1 ,t] 的表达式包括了 y[2 ,t] 的过去值。同样,在方程式 10.4 中,y[2 ,t] 的表达式包括了 y[1 ,t] 的过去值。因此,你可以看到 VAR 模型如何捕捉每个序列对其他序列的影响。

我们可以将方程式 10.3 扩展以表达一个考虑 p 个滞后值的通用向量自回归(VAR)模型,从而得到方程式 10.5。请注意,上标不代表指数,而是用于索引。为了简化,我们再次只考虑两个时间序列。

方程式 10-5

方程式 10.5

与 AR(p) 模型一样,VAR(p) 模型要求每个时间序列都是*稳的。

向量自回归模型

向量自回归模型 VAR(p) 模拟两个或更多时间序列之间的关系。在这个模型中,每个时间序列都会影响其他时间序列。这意味着一个时间序列的过去值会影响另一个时间序列,反之亦然。

VAR(p) 模型可以看作是 AR(p) 模型的一种推广,允许模拟多个时间序列。就像在 AR(p) 模型中一样,VAR(p) 模型的阶数 p 决定了多少个滞后值会影响一个时间序列的当前值。然而,在这个模型中,我们还包含了其他时间序列的滞后值。

对于两个时间序列,VAR(p)模型的一般方程是常数向量、两个时间序列的过去值和误差项向量的线性组合:

注意,时间序列必须是*稳的才能应用 VAR 模型。

您已经看到了 VAR(p)模型是如何用数学表达式表示的,每个表达式中都包含了滞后值,如方程 10.3 和 10.4 所示。这应该让您对每个序列如何影响其他序列有一个概念。VAR(p)模型只有在两个序列都对预测对方有用时才是有效的。仅仅观察序列随时间的变化形状是不够支持该假设的。相反,我们必须应用格兰杰因果检验,这是一种统计假设检验,用于确定一个时间序列是否可以预测另一个。只有在这个检验成功之后,我们才能将 VAR 模型应用于预测。这是使用 VAR 模型时建模过程中的一个重要步骤。

10.2 设计 VAR(p)模型的建模过程

VAR(p)模型需要我们对之前使用的建模过程进行略微修改。最显著的修改是增加了格兰杰因果检验,因为 VAR 模型假设两个时间序列的过去值对另一个时间序列具有显著的预测性。

VAR(p)模型的完整建模过程如图 10.2 所示。如您所见,VAR(p)模型的建模过程与我们自 ARMA(p,q)模型引入以来一直使用的建模过程非常相似。

图 10.2 VAR(p)模型的建模过程。它与我们自 ARMA(p, q)模型引入以来一直使用的建模过程非常相似,但这次我们正在拟合不同的 VAR(p)模型,并选择具有最低 AIC 的模型。然后我们运行格兰杰因果检验。如果检验失败,VAR(p)模型无效,我们将不会继续该过程。另一方面,如果检验通过,我们进行残差分析。如果残差类似于白噪声,则 VAR(p)模型可用于预测。

这里的主要区别在于我们只为阶数p列出值,因为我们正在对数据拟合不同的 VAR(p)模型。然后,一旦选择了具有最低 AIC 的模型,我们就进行格兰杰因果检验。这个检验确定一个时间序列的过去值在预测另一个时间序列时是否具有统计显著性。测试这种关系很重要,因为 VAR(p)模型使用一个时间序列的过去值来预测另一个。

如果格兰杰因果检验失败,我们不能说一个时间序列的过去值可以预测另一个时间序列。在这种情况下,VAR(p)模型变得无效,我们必须转而使用 SARIMAX 模型的变体来预测时间序列。另一方面,如果格兰杰因果检验通过,我们可以继续进行残差分析。正如之前所述,如果残差接*白噪声,我们可以使用选定的 VAR(p)模型进行预测。

在我们继续应用这种建模程序之前,花些时间更详细地探讨格兰杰因果检验是值得的。

10.2.1 探索格兰杰因果检验

如前节所示,VAR(p)模型假设每个时间序列都会影响另一个。因此,测试这种关系是否存在非常重要。否则,我们就会假设一个不存在的关系,这会在模型中引入错误,并使我们的预测无效且不可靠。

因此,我们使用格兰杰因果检验。这是一种统计检验,帮助我们确定时间序列y[2,t]的过去值是否可以帮助预测时间序列y[1,t]。如果是这样,那么我们说y[2,t] 格兰杰-引起 y[1,t]。

注意,格兰杰因果检验仅限于预测因果性,因为我们只确定一个时间序列的过去值在预测另一个时间序列时是否具有统计显著性。此外,该测试要求两个时间序列都是*稳的,以便结果有效。另外,格兰杰因果检验只测试单向因果关系;我们必须重复测试以验证y[1,t]也格兰杰-引起y[2,t],以便 VAR 模型有效。否则,我们必须求助于 SARIMAX 模型并分别预测每个时间序列。

该检验的零假设是y[2,t]不格兰杰-引起y[1,t]。同样,我们将使用 p 值和临界值 0.05 来确定是否拒绝零假设。在格兰杰因果检验返回的 p 值小于 0.05 的情况下,我们可以拒绝零假设,并说y[2,t]格兰杰-引起y[1,t]。

你看到,在选择了 VAR(p)模型之后,才进行格兰杰因果检验。这是因为测试需要我们指定要包含在测试中的滞后数,这相当于模型的阶数。例如,如果选定的 VAR(p)模型是 3 阶的,格兰杰因果检验将确定一个时间序列的过去三个值在预测另一个时间序列时是否具有统计显著性。

statsmodels库方便地包含了格兰杰因果检验,我们将在下一节中应用它,当我们预测实际消费和实际可支配收入时。

10.3 预测实际可支配收入和实际消费

在检查了 VAR(p)模型并为其设计了建模程序后,我们现在准备将其应用于预测美国实际可支配收入和实际消费。我们将使用与上一章相同的数据库,其中包含 1959 年至 2009 年的宏观经济数据。

注意:本章的源代码可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH10

macro_econ_data = sm.datasets.macrodata.load_pandas().data
macro_econ_data

现在,我们可以绘制我们感兴趣的两个变量,即实际可支配收入,在数据集中表示为realdpi,和实际消费,表示为realcons。结果如图 10.3 所示。

fig, (ax1, ax2) = plt.subplots(nrows=2, ncols=1, figsize=(10,8))

ax1.plot(macro_econ_data['realdpi'])
ax1.set_xlabel('Date')
ax1.set_ylabel('Real disposable income (k$)')
ax1.set_title('realdpi')
ax1.spines['top'].set_alpha(0)

ax2.plot(macro_econ_data['realcons'])
ax2.set_xlabel('Date')
ax2.set_ylabel('Real consumption (k$)')
ax2.set_title('realcons')
ax2.spines['top'].set_alpha(0)

plt.xticks(np.arange(0, 208, 16), np.arange(1959, 2010, 4))

fig.autofmt_xdate()
plt.tight_layout()

在图 10.3 中,你可以看到两条曲线在时间上具有非常相似的形状,这直观地使它们成为 VAR(p)模型的良好候选者。认为随着可支配收入的增加,消费很可能会增加,就像高消费可能是高可支配收入的一个标志一样,这是合理的。当然,这个假设将在建模过程的后期通过 Granger 因果检验来测试。

我们已经收集了数据,所以现在我们必须确定时间序列是否*稳。在图 10.3 中,它们都表现出随时间推移的正向趋势,这意味着它们是非*稳的。尽管如此,我们将应用增强迪基-富勒(ADF)检验以确保。

ad_fuller_result_1 = adfuller(macro_econ_data['realdpi'])
print('realdpi')                                           ❶
print(f'ADF Statistic: {ad_fuller_result_1[0]}')
print(f'p-value: {ad_fuller_result_1[1]}')

print('\n---------------------\n')

ad_fuller_result_2 = adfuller(macro_econ_data['realcons'])

print('realcons')                                          ❷
print(f'ADF Statistic: {ad_fuller_result_2[0]}')
print(f'p-value: {ad_fuller_result_2[1]}')

❶ 对realdpi进行 ADF 检验

❷ 对realcons进行 ADF 检验。请注意,在它们被用于 VAR(p)模型之前,这两个时间序列都必须是*稳的。

图片

图 10.3 展示了 1959 年至 2009 年美国实际可支配收入和实际消费。数据按季度收集,并以千美元为单位表示。你可以看到,两条曲线在时间上具有相似的形状。

对于这两个变量,ADF 检验输出一个 p 值为 1.0。因此,我们不能拒绝零假设,我们得出结论,正如预期的那样,两个时间序列都不是*稳的。

我们将应用转换使它们*稳。具体来说,我们将对两个序列进行差分,并再次进行*稳性检验。

ad_fuller_result_1 = adfuller(macro_econ_data['realdpi'].diff()[1:])   ❶

print('realdpi')
print(f'ADF Statistic: {ad_fuller_result_1[0]}')
print(f'p-value: {ad_fuller_result_1[1]}')

print('\n---------------------\n')

ad_fuller_result_2 = adfuller(macro_econ_data['realcons'].diff()[1:])  ❷

print('realcons')
print(f'ADF Statistic: {ad_fuller_result_2[0]}')
print(f'p-value: {ad_fuller_result_2[1]}')

❶ 对realdpi进行一阶差分

❷ 对realcons进行一阶差分

realdpi进行的 ADF 检验返回一个 p 值为 1.45 × 10^(–14),而对realcons进行的 ADF 检验返回一个 p 值为 0.0006。在这两种情况下,p 值都小于 0.05。因此,我们拒绝零假设,并得出结论,两个时间序列都是*稳的。如前所述,VAR(p)模型要求时间序列是*稳的。因此,我们可以使用转换后的序列进行建模,并且我们需要对预测进行积分,以将它们恢复到原始尺度。

我们现在处于拟合多个 VAR(p) 模型以选择具有最小赤池信息准则 (AIC) 的模型的步骤。我们将编写一个名为 optimize_VAR 的函数,用于在阶数 p 变化的同时拟合多个 VAR(p) 模型。此函数将返回一个按 AIC 升序排列的有序 DataFrame。此函数如下所示。

列表 10.1 函数用于拟合多个 VAR(p) 模型并选择具有最低 AIC 的模型

from typing import Union
from tqdm import tqdm_notebook
from statsmodels.tsa.statespace.varmax import VARMAX

def optimize_VAR(endog: Union[pd.Series, list]) -> pd.DataFrame:

    results = []

    for i in tqdm_notebook(range(15)):     ❶
        try:
            model = VARMAX(endog, order=(i, 0)).fit(dips=False)
        except:
            continue

        aic = model.aic
        results.append([i, aic])

    result_df = pd.DataFrame(results)
    result_df.columns = ['p', 'AIC']

    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)

    return result_df

❶ 将阶数 p 从 0 变化到 14。

我们现在可以使用此函数来选择使 AIC 最小的阶数 p

首先,我们必须定义训练集和测试集。在这种情况下,我们将使用 80% 的数据进行训练,20% 的数据进行测试。这意味着最后 40 个数据点将用于测试,其余的用于训练。记住,VAR(p) 模型要求两个序列都必须是*稳的。因此,我们将基于差分数据集进行分割,并将差分训练集输入到 optimize_VAR 函数中。

endog = macro_econ_data[['realdpi', 'realcons']]                    ❶

endog_diff = macro_econ_data[['realdpi', 'realcons']].diff()[1:]    ❷

train = endog_diff[:162]                                            ❸
test = endog_diff[162:]                                             ❹

result_df = optimize_VAR(train)                                     ❺
result_df

❶ 仅选择 realdpi 和 realcons,因为它们是此情况下唯一两个感兴趣的变量。

❷ 由于 ADF 测试表明一阶差分使它们*稳,因此对两个序列进行差分。

❸ 前面的 162 个数据点用于训练。这大约是数据集的 80%。

❹ 最后 40 个数据点用于测试集。这大约是数据集的 20%。

❺ 使用存储在 train 中的差分数据运行 optimize_VAR 函数。这是 VAR(p) 模型所必需的。

运行该函数返回一个 DataFrame,我们可以看到 p = 3 具有所有中最小的 AIC 值。因此,所选模型是 VAR(3) 模型,这意味着每个时间序列的过去三个值用于预测其他时间序列。

在建模过程之后,我们现在必须使用格兰杰因果检验。记住,VAR 模型假设 realcons 的过去值对预测 realdpi 有用,而 realdpi 的过去值对预测 realcons 也有用。这种关系必须经过检验。如果格兰杰因果检验返回的 p 值大于 0.05,我们不能拒绝零假设,这意味着变量之间没有格兰杰因果关系,模型无效。另一方面,如果 p 值小于 0.05,我们将能够拒绝零假设,从而验证 VAR(3) 模型,这意味着我们可以继续建模过程。

我们将使用 statsmodels 库中的 grangercausalitytests 函数对两个变量都进行格兰杰因果检验。记住,对于格兰杰因果检验,序列必须是*稳的,这就是为什么它们在传递给函数时进行了差分。此外,我们指定了测试的滞后数,在此情况下为 3,因为模型选择步骤返回 p = 3。

print('realcons Granger-causes realdpi?\n')
print('------------------')
granger_1 = grangercausalitytests(macro_econ_data[['realdpi', 
➥ 'realcons']].diff()[1:], [3])                              ❶

print('\nrealdpi Granger-causes realcons?\n')
print('------------------')
granger_2 = grangercausalitytests(macro_econ_data[['realcons', 
➥ 'realdpi']].diff()[1:], [3])                               ❷

❶ 函数测试第二个变量是否是第一个变量的格兰杰原因。因此,我们测试realcons是否是realdpi的格兰杰原因。然后我们传递一个包含滞后数的列表,在我们的例子中是 3。请注意,这些序列被差分以使它们*稳。

❷ 这里我们测试realdpi是否是realcons的格兰杰原因。

对两个变量进行格兰杰因果检验,两种情况下都返回了小于 0.05 的 p 值。因此,我们可以拒绝零假设,并得出结论,realdpirealcons的格兰杰原因,realcons也是realdpi的格兰杰原因。因此,我们的 VAR(3)模型是有效的。如果其中一个变量不是另一个变量的格兰杰原因,VAR(p)模型就变得无效,不能使用。在这种情况下,我们必须使用 SARIMAX 模型并分别预测每个时间序列。

我们现在可以继续进行残差分析。为此,我们首先在我们的训练集上拟合 VAR(3)模型。

best_model = VARMAX(train, order=(3,0))
best_model_fit = best_model.fit(disp=False)

然后,我们可以使用plot_diagnostics函数来绘制残差的直方图、Q-Q 图和自相关图。然而,在这里我们必须研究两个变量的残差,因为我们正在对realdpirealcons进行建模。

首先让我们关注realdpi的残差。

best_model_fit.plot_diagnostics(figsize=(10,8), variable=0);     ❶

❶ 通过传递变量=0 指定我们想要realdpi残差的图,因为它是被传递给 VAR 模型的第一变量。

图 10.4 的输出显示,残差接*白噪声。

现在我们可以继续分析realcons的残差。

best_model_fit.plot_diagnostics(figsize=(10,8), variable=1);     ❶

❶ 通过传递变量=1 指定我们想要realcons残差的图,因为它是在模型中传递的第二变量。

图 10.4 realdpi的残差分析。标准化残差似乎没有趋势且方差恒定,这与白噪声相符。直方图也紧密地类似于正态分布的形状。这一点进一步得到了 Q-Q 图的支持,该图显示了一条相当直的线,位于y = x上,尽管在两端我们可以看到一些弯曲。最后,自相关图显示除了滞后 5 之外没有显著的系数。然而,这很可能是由于偶然,因为没有先前的显著系数。因此,我们可以得出结论,残差接*白噪声。

图 10.5 的输出显示,realcons的残差非常接*白噪声。

图 10.5 realcons的残差分析。左上角的图显示了随时间变化的残差,你可以看到没有趋势且方差恒定,这与白噪声的行为相符。右上角的分布非常接*正态分布。这一点进一步得到了左下角的 Q-Q 图的支持,该图显示了一条相当直的线,位于y = x上。最后,右下角的自相关图显示在滞后 0 之后没有显著的自相关系数。因此,残差接*白噪声。

一旦完成定性分析,我们就可以继续使用 Ljung-Box 测试进行定量分析。回想一下,Ljung-Box 测试的零假设是残差是独立且不相关的。因此,为了使残差表现得像白噪声,测试必须返回大于 0.05 的 p 值,在这种情况下,我们不拒绝零假设。

测试必须应用于realdpirealcons

realgdp_residuals = best_model_fit.resid['realdpi']

lbvalue, pvalue = acorr_ljungbox(realgdp_residuals, np.arange(1, 11, 1))

print(pvalue)

realdpi的残差进行 Ljung-Box 测试返回的 p 值都大于 0.05。因此,我们不拒绝零假设,这意味着残差是不相关且独立的,就像白噪声一样。

realcons_residuals = best_model_fit.resid['realcons']

lbvalue, pvalue = acorr_ljungbox(realcons_residuals, np.arange(1, 11, 1))

print(pvalue)

接下来,我们将对realcons的残差进行测试。这个测试返回的 p 值都大于 0.05。同样,我们不拒绝零假设,这意味着残差是不相关且独立的,就像白噪声一样。

由于模型通过了残差分析的定性和定量两个方面,我们可以继续使用 VAR(3)模型预测realconsrealdpi。我们将比较 VAR(3)模型与一个简单地预测最后一个观测值的基线。我们将预测四个步骤到未来,这相当于预测一个完整的年份,因为数据是按季度采样的。因此,我们将在整个测试集的长度上执行四个步骤的滚动预测。

为了做到这一点,我们将使用我们在过去几章中多次定义的rolling_forecast函数。这次,我们将对 VAR(3)模型进行一些轻微的修改。它需要输出realdpirealcons的预测结果,因此我们必须返回包含预测的两个列表。下面的列表显示了rolling_forecast函数的代码。

列表 10.2  测试集上的滚动预测函数

def rolling_forecast(df: pd.DataFrame, train_len: int, horizon: int, 
➥ window: int, method: str) -> list:

    total_len = train_len + horizon
    end_idx = train_len

    if method == 'VAR':

        realdpi_pred_VAR = []                                    ❶
        realcons_pred_VAR = []

        for i in range(train_len, total_len, window):
            model = VARMAX(df[:i], order=(3,0))
            res = model.fit(disp=False)
            predictions = res.get_prediction(0, i + window - 1)

            oos_pred_realdpi = predictions.predicted_mean.iloc[-
➥ window:]['realdpi']                                           ❷
            oos_pred_realcons = predictions.predicted_mean.iloc[-
➥ window:]['realcons']                                          ❸

            realdpi_pred_VAR.extend(oos_pred_realdpi)            ❹
            realcons_pred_VAR.extend(oos_pred_realcons)

        return realdpi_pred_VAR, realcons_pred_VAR               ❺

    elif method == 'last':                                       ❻
        realdpi_pred_last = []
        realcons_pred_last = []

        for i in range(train_len, total_len, window):

            realdpi_last = df[:i].iloc[-1]['realdpi']
            realcons_last = df[:i].iloc[-1]['realcons']

            realdpi_pred_last.extend(realdpi_last for _ in range(window))
            realcons_pred_last.extend(realcons_last for _ in range(window))

        return realdpi_pred_last, realcons_pred_last

❶ 初始化两个空列表来保存realdpirealcons的预测结果。

❷ 提取realdpi的预测结果。

❸ 提取realcons的预测结果。

❹ 将新预测结果扩展到每个变量的列表中。

❺ 返回包含realdpirealcons预测的两个列表。

❻ 对于基线,我们也将使用两个列表来保存每个变量的预测结果,并在最后返回它们。

我们现在可以使用这个函数来使用 VAR(3)模型生成realdpirealcons的预测结果。

TRAIN_LEN = len(train)
HORIZON = len(test)
WINDOW = 4                ❶

realdpi_pred_VAR, realcons_pred_VAR = rolling_forecast(endog_diff, 
➥ TRAIN_LEN, HORIZON, WINDOW, 'VAR')

❶ 窗口大小为 4,因为我们想一次预测四个时间步,这相当于 1 年。

回想一下,VAR(3)模型要求序列是*稳的,这意味着我们已经对预测进行了转换。然后我们必须使用累积和来整合它们,以便将它们恢复到数据的原始尺度。

test = endog[163:]

test['realdpi_pred_VAR'] = pd.Series()
test['realdpi_pred_VAR'] = endog.iloc[162]['realdpi'] + 
➥ np.cumsum(realdpi_pred_VAR)                           ❶

test['realcons_pred_VAR'] = pd.Series()
test['realcons_pred_VAR'] = endog.iloc[162]['realcons'] + 
➥ np.cumsum(realcons_pred_VAR)

test                                                     ❷

❶ 使用累积和整合预测结果。

❷ 显示测试 DataFrame。

到目前为止,test包含了测试集的实际值和 VAR(3)模型的预测值。我们现在可以添加基线方法的预测,该方法简单地预测下一个四个时间步长的最后一个已知值。

realdpi_pred_last, realcons_pred_last = rolling_forecast(endog, 
➥ TRAIN_LEN, HORIZON, WINDOW, 'last')           ❶

test['realdpi_pred_last'] = realdpi_pred_last
test['realcons_pred_last'] = realcons_pred_last

test                                             ❷

❶ 使用 rolling_forecast 获取使用最后一个已知值方法得到的基线预测。

❷ 显示测试 DataFrame。

现在test包含了测试集的实际值、VAR(3)模型的预测值和基线方法的预测值。一切都已经为我们设置好,以便可视化预测并使用*均绝对百分比误差(MAPE)评估预测方法。预测结果如图 10.6 所示。

图 10.6 realdpirealcons的预测。你可以看到,VAR(3)模型的预测值(以虚线表示)紧密跟随测试集的实际值。你还会注意到基线方法的点线曲线显示了小的步骤,这在预测四个时间步长内的常数值时是有意义的。

在图 10.6 中,虚线表示 VAR(3)模型的预测值,而点线显示了从最后一个已知值方法的预测。你可以看到这两条线都非常接*测试集的实际值,这使得我们很难从视觉上判断哪种方法更好。

我们现在将计算 MAPE。结果如图 10.7 所示。

def mape(y_true, y_pred):
    return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

mape_realdpi_VAR = mape(test['realdpi'], test['realdpi_pred_VAR'])
mape_realdpi_last = mape(test['realdpi'], test['realdpi_pred_last'])

mape_realcons_VAR = mape(test['realcons'], test['realcons_pred_VAR'])
mape_realcons_last = mape(test['realcons'], test['realcons_pred_last'])

图 10.7 预测realdpirealcons的 MAPE。你可以看到,在realdpi的情况下,VAR(3)模型的表现不如基线,但对于realcons来说,VAR(3)模型的表现优于基线。

在图 10.7 中,你可以看到,在realdpi的情况下,VAR(3)模型的表现不如基线,但对于realcons来说,VAR(3)模型的表现优于基线。这是一个模糊的情况。由于模型在两种情况下都没有优于基线,因此没有明确的结果。

我们可以假设,在realdpi的情况下,尽管格兰杰因果检验通过了,但realcons的预测能力不足以比基线做出更准确的预测。因此,我们应该求助于使用 SARIMAX 模型的变化来预测realdpi。因此,我会得出结论,VAR(3)模型不足以准确预测realdpirealcons。我建议使用两个独立的模型,这些模型可能包括realdpirealcons作为外生变量,同时可能还包括移动*均项。

10.4 下一步

在本章中,我们介绍了 VAR(p)模型,该模型允许我们同时预测多个时间序列。

VAR(p) 模型代表向量自回归,它假设某些时间序列的过去值可以预测其他时间序列的未来值。这种双向关系通过格兰杰因果检验来测试。如果测试失败,即返回的 p 值大于 0.05,则 VAR(p) 模型无效,不能使用。

恭喜你走到这一步——我们已经涵盖了广泛的时间序列预测统计方法!这些统计方法非常适合小数据集和低维数据。然而,当数据集开始变得很大,从 10,000 个数据点或更多,并且具有许多特征时,深度学习可以成为一个获取准确预测和利用所有可用数据的强大工具。

在下一章中,我们将通过一个综合项目来巩固我们对统计方法的知识。然后我们将开始一个新的部分,并在大型数据集上应用深度学习预测模型。

10.5 练习

通过这些练习超越 VAR(p) 模型。完整的解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH10

10.5.1 使用 VARMA 模型预测 realdpi 和 realcons

在本章中,我们使用了 VAR(p) 模型。然而,我们使用了 statsmodels 中的 VARMAX 函数来实现这一点,这意味着我们可以轻松地将 VAR(p) 模型扩展到 VARMA(p,q) 模型。在这个练习中,使用 VARMA(p,q) 模型来预测 realdpirealcons

  1. 使用本章中相同的训练集和测试集。

  2. 生成一个独特的 (p,q) 组合列表。

  3. optimize_VAR 函数重命名为 optimize_VARMA,并修改它以遍历所有独特的 (p,q) 组合。

  4. 选择 AIC 最低的模型,并执行格兰杰因果检验。传入 (p,q) 中的最大阶数。VARMA(p,q) 模型有效吗?

  5. 执行残差分析。

  6. 在测试集上对四步窗口进行预测。使用最后已知值方法作为基线。

  7. 计算*均绝对百分比误差(MAPE)。它是低于还是高于我们的 VAR(3) 模型?

10.5.2 使用 VARMAX 模型预测 realdpi 和 realcons

由于我们使用了 statsmodels 中的 VARMAX 函数,我们知道我们也可以向模型添加外生变量,就像在 SARIMAX 中一样。在这个练习中,使用 VARMAX 模型来预测 realdpirealcons

  1. 使用本章中相同的训练集和测试集。

  2. 生成一个独特的 (p,q) 组合列表。

  3. optimize_VAR 函数重命名为 optimize_VARMAX,并修改它以遍历所有独特的 (p,q) 组合和外生变量。

  4. 选择 AIC 最低的模型,并执行格兰杰因果检验。传入 (p,q) 中的最大阶数。VARMAX(p,q) 模型有效吗?

  5. 执行残差分析。

  6. 在测试集上对单步窗口进行预测。使用最后已知值方法作为基线。

  7. 计算 MAPE。模型的表现是否优于基线?

摘要

  • 向量自回归模型(VAR(p))捕捉了多个序列随时间变化之间的关系。在这个模型中,每个序列都会对其他序列产生影响。

  • 一个 VAR(p)模型只有在每个时间序列格兰杰-引起其他序列时才是有效的。这通过格兰杰因果检验来确定。

  • 格兰杰因果检验的零假设是,一个时间序列不会格兰杰-引起另一个时间序列。如果 p 值小于 0.05,我们拒绝零假设,并得出结论,第一个时间序列格兰杰-引起了另一个时间序列。

11 综合项目:预测澳大利亚抗糖尿病药物处方的数量

本章涵盖了

  • 开发一个预测模型来预测澳大利亚抗糖尿病药物处方的数量

  • 使用 SARIMA 模型应用建模程序

  • 将我们的模型与基线进行比较

  • 确定冠军模型

我们已经涵盖了大量的时间序列预测的统计模型。在第四章和第五章中,你学习了如何建模移动*均过程和自回归过程。然后我们将这些模型结合起来形成 ARMA 模型,并添加一个参数来预测非*稳时间序列,从而得到 ARIMA 模型。然后我们通过 SARIMA 模型添加季节性成分。添加外生变量的影响最终导致了 SARIMAX 模型。最后,我们介绍了使用 VAR 模型进行多元时间序列预测。因此,你现在可以访问许多统计模型,这些模型允许你预测各种时间序列,从简单到更复杂。现在是巩固你的学习并将你的知识应用于一个综合项目的好时机。

本章项目的目标是预测 1991 年至 2008 年澳大利亚抗糖尿病药物处方的数量。在专业环境中,解决这个问题将使我们能够衡量抗糖尿病药物的生产,例如,生产足够的药物以满足需求,同时避免过度生产。我们将使用的数据是由澳大利亚健康保险委员会记录的。我们可以在图 11.1 中可视化时间序列。

图片

图 11.1 1991 年至 2008 年澳大利亚每月抗糖尿病药物处方的数量。

在图 11.1 中,你会看到时间序列中有一个明显的趋势,因为处方的数量随着时间的推移而增加。此外,你还会观察到强烈的季节性,因为每年的开始似乎都是低值,而结束则是高值。到现在为止,你应该直觉地知道哪个模型可能是解决这个问题的最佳选择。

要解决这个问题,请参考以下步骤:

  1. 目标是预测 12 个月的抗糖尿病药物处方。使用数据集的最后 36 个月作为测试集,以便进行滚动预测。

  2. 可视化时间序列。

  3. 使用时间序列分解来提取趋势和季节性成分。

  4. 根据你的探索,确定最合适的模型。

  5. 使用常规步骤对序列进行建模:

    1. 应用转换使其*稳

    2. 设置dD的值。设置m的值。

    3. 找到最优的(p,d,q)(P,D,Q)[m]参数。

    4. 执行残差分析以验证你的模型。

  6. 在测试集上执行 12 个月的滚动预测。

  7. 可视化你的预测。

  8. 将模型的性能与基线进行比较。选择一个合适的基线和误差指标。

  9. 判断模型是否应该被使用。

为了充分利用这个综合项目,强烈建议您根据自己的步骤独立完成它。这将帮助您评估自己在建模过程中的自主性和理解程度。

如果您遇到困难或想要验证您的推理,本章的其余部分将指导您完成这个项目。此外,如果您想直接查看代码,完整的解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH11

祝您在这个项目中好运!

11.1 导入所需的库和加载数据

第一步自然的步骤是导入完成项目所需的所有库。然后我们可以加载数据并将其存储在 DataFrame 中,以便在整个项目中使用。

因此,我们将导入以下库并指定魔法函数 %matplotlib inline 以在笔记本中显示图表:

from sklearn.metrics import mean_squared_error, mean_absolute_error
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from statsmodels.tsa.seasonal import seasonal_decompose, STL
from statsmodels.stats.diagnostic import acorr_ljungbox
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.arima_process import ArmaProcess
from statsmodels.graphics.gofplots import qqplot
from statsmodels.tsa.stattools import adfuller
from tqdm import tqdm_notebook
from itertools import product
from typing import Union

import matplotlib.pyplot as plt
import statsmodels.api as sm
import pandas as pd
import numpy as np

import warnings
warnings.filterwarnings('ignore')

%matplotlib inline

库导入完成后,我们可以读取数据并将其存储在 DataFrame 中。我们还可以显示 DataFrame 的形状以确定数据点的数量。

df = pd.read_csv('data/AusAnti-diabeticDrug.csv')
print(df.shape)                                    ❶

❶ 显示 DataFrame 的形状。第一个值是行数,第二个值是列数。

数据现在可以用于整个项目。

11.2 可视化序列及其组成部分

数据加载完成后,我们现在可以轻松地可视化这些序列。这本质上是在重现图 11.1。

fig, ax = plt.subplots()

ax.plot(df.y)
ax.set_xlabel('Date')
ax.set_ylabel('Number of anti-diabetic drug prescriptions')

plt.xticks(np.arange(6, 203, 12), np.arange(1992, 2009, 1))

fig.autofmt_xdate()
plt.tight_layout()

接下来,我们可以进行分解以可视化时间序列的不同组成部分。记住,时间序列分解使我们能够可视化趋势成分、季节成分和残差。

decomposition = STL(df.y, period=12).fit()     ❶

fig, (ax1, ax2, ax3, ax4) = plt.subplots(nrows=4, ncols=1, sharex=True, 
➥ figsize=(10,8))

ax1.plot(decomposition.observed)
ax1.set_ylabel('Observed')

ax2.plot(decomposition.trend)
ax2.set_ylabel('Trend')

ax3.plot(decomposition.seasonal)
ax3.set_ylabel('Seasonal')

ax4.plot(decomposition.resid)
ax4.set_ylabel('Residuals')

plt.xticks(np.arange(6, 203, 12), np.arange(1992, 2009, 1))

fig.autofmt_xdate()
plt.tight_layout()

❶ 列 y 存放每月抗糖尿病处方的数量。此外,周期设置为 12,因为我们有月度数据。

结果如图 11.2 所示。一切似乎都表明,SARIMA(p,d,q) (P,D,Q)[m] 模型将是预测这个时间序列的最佳解决方案。我们有一个趋势以及明显的季节性。此外,我们没有外生变量可以操作,因此不能应用 SARIMAX 模型。最后,我们只想预测一个目标,这意味着在这个情况下,VAR 模型也不相关。

图 11.2 抗糖尿病药物处方数据集的时间序列分解。第一个图显示了观测数据。第二个图显示了趋势成分,它告诉我们抗糖尿病药物处方的数量随时间增加。第三个图显示了季节成分,我们可以看到随时间重复的模式,表明存在季节性。最后一个图显示了残差,这些残差是趋势和季节成分无法解释的变异。

11.3 建模数据

我们已经决定 SARIMA(p,d,q) (P,D,Q)[m] 模型是最适合建模和预测这个时间序列的。因此,我们将遵循 SARIMAX 模型的通用建模过程,因为 SARIMA 模型是 SARIMAX 模型的特例。建模过程如图 11.3 所示。

图 11.3 SARIMA 模型过程。这个过程是最通用的建模过程,它可以用于 SARIMA、ARIMA 或 ARMA 模型,因为它们只是 SARIMAX 模型的特例。

按照图 11.3 中概述的建模过程,我们首先将使用增强型迪基-富勒(ADF)测试确定序列是否*稳。

ad_fuller_result = adfuller(df.y)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

这返回了一个 p 值为 1.0,意味着我们不能拒绝零假设,我们得出结论,该序列是非*稳的。因此,我们必须应用变换使其*稳。

我们首先对数据进行一阶差分,并再次进行*稳性测试。

y_diff = np.diff(df.y, n=1)

ad_fuller_result = adfuller(y_diff)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

这返回了一个 p 值为 0.12。同样,p 值大于 0.05,意味着序列是非*稳的。由于我们注意到数据中存在强烈的季节性模式,让我们尝试应用季节差分。回想一下,我们有月度数据,这意味着 m = 12。因此,季节差分会减去相隔 12 个时间步长的值。

y_diff_seasonal_diff = np.diff(y_diff, n=12)      ❶

ad_fuller_result = adfuller(y_diff_seasonal_diff)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

❶ 我们有月度数据,所以 n = 12。

返回的 p 值为 0.0。因此,我们可以拒绝零假设,并得出结论,我们的时间序列是*稳的。

由于我们对序列进行了一次差分并取了一个季节差分,d = 1 和 D = 1。另外,由于我们有月度数据,我们知道 m = 12。因此,我们知道我们的最终模型将是一个 SARIMA(p,1,q)(P,1,Q)[12] 模型。

11.3.1 执行模型选择

我们已经确定我们的模型将是一个 SARIMA(p,1,q)(P,1,Q)[12] 模型。现在我们需要找到 pqPQ 的最佳值。这是模型选择步骤,我们选择最小化赤池信息准则(AIC)的参数。

为了做到这一点,我们首先将数据分为训练集和测试集。如章节引言中所述的步骤,测试集将包括最后 36 个月的数据。

train = df.y[:168]
test = df.y[168:]

print(len(test))      ❶

❶ 打印测试集的长度以确保它包含最后 36 个月。

在完成分割后,我们现在可以使用 optimize_SARIMAX 函数找到使 AIC 最小的 pqPQ 的值。请注意,我们可以在这里使用 optimize_SARIMAX,因为 SARIMA 是更通用 SARIMAX 模型的特例。该函数在以下列表中显示。

列表 11.1 寻找使 AIC 最小的 pqPQ 值的函数

from typing import Union
from tqdm import tqdm_notebook
from statsmodels.tsa.statespace.sarimax import SARIMAX

def optimize_SARIMAX(endog: Union[pd.Series, list], exog: Union[pd.Series, 
➥ list], order_list: list, d: int, D: int, s: int) -> pd.DataFrame:

    results = []

    for order in tqdm_notebook(order_list):
        try: 
            model = SARIMAX(
                endog,
                exog,
                order=(order[0], d, order[1]),
                seasonal_order=(order[2], D, order[3], s),
                simple_differencing=False).fit(disp=False)
        except:
            continue

        aic = model.aic
        results.append([order, model.aic])

    result_df = pd.DataFrame(results)
    result_df.columns = ['(p,q,P,Q)', 'AIC']

    #Sort in ascending order, lower AIC is better
    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)

    return result_df

函数定义后,我们现在可以决定尝试pqPQ的值范围。然后我们将生成参数的唯一组合列表。您可以自由地测试与我这里使用的不同范围的值。请注意,范围越大,运行optimize_SARIMAX函数所需的时间就越长。

ps = range(0, 5, 1)
qs = range(0, 5, 1)
Ps = range(0, 5, 1)
Qs = range(0, 5, 1)

order_list = list(product(ps, qs, Ps, Qs))

d = 1
D = 1
s = 12

我们现在可以运行optimize_SARIMAX函数。在这个例子中,测试了 625 个独特的组合,因为我们有 4 个参数的 5 个可能的值。

SARIMA_result_df = optimize_SARIMAX(train, None, order_list, d, D, s)
SARIMA_result_df

一旦函数完成,结果显示,最小 AIC 值是在p = 2,q = 3,P = 1 和Q = 3 时达到的。因此,最优模型是一个 SARIMA(2,1,3)(1,1,3)[12]模型。

11.3.2 进行残差分析

现在我们已经得到了最优模型,我们必须分析其残差以确定该模型是否可以使用。这将取决于残差,它们应该表现为白噪声。如果是这样,该模型可用于预测。

我们可以拟合模型并使用plot_diagnostics方法对其残差进行定性分析。

SARIMA_model = SARIMAX(train, order=(2,1,3), 
➥ seasonal_order=(1,1,3,12), simple_differencing=False)
SARIMA_model_fit = SARIMA_model.fit(disp=False)

SARIMA_model_fit.plot_diagnostics(figsize=(10,8));

结果如图 11.4 所示,我们可以从这种定性分析中得出结论,残差非常接*白噪声。

图片

图 11.4 残差的视觉诊断。在左上角的图中,残差在时间上没有趋势,方差似乎保持恒定。在右上角,残差的分布非常接*正态分布。这一点进一步得到了左下角 Q-Q 图的支撑,该图显示了一条相当直的线,位于y = x上。最后,右下角的自相关图在滞后 0 之后没有显示出显著的系数,就像白噪声一样。

下一步是执行 Ljung-Box 测试,该测试确定残差是否独立且不相关。Ljung-Box 测试的零假设是残差是不相关的,就像白噪声一样。因此,我们希望测试返回的 p 值大于 0.05。在这种情况下,我们不能拒绝零假设,并得出结论,我们的残差是独立的,因此表现为白噪声。

residuals = SARIMA_model_fit.resid

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))

print(pvalue)

在这种情况下,所有 p 值都高于 0.05,因此我们无法拒绝零假设,我们得出结论,残差是独立且不相关的。我们可以得出结论,该模型可用于预测。

11.4 预测和评估模型性能

我们有一个可用于预测的模型,因此我们现在将在 36 个月测试集上进行 12 个月的滚动预测。这样,我们将更好地评估模型的表现,因为在对较少的数据点进行测试时可能会得到偏颇的结果。我们将使用简单的季节性预测作为基线;它将简单地取最后 12 个月的数据,并将它们用作未来 12 个月的预测。

我们首先定义rolling_forecast函数,以 12 个月为一个窗口,在整个测试集上生成预测。该函数如下所示。

列表 11.2 执行滚动预测的函数

def rolling_forecast(df: pd.DataFrame, train_len: int, horizon: int, 
➥ window: int, method: str) -> list:

    total_len = train_len + horizon
    end_idx = train_len

    if method == 'last_season':
        pred_last_season = []

        for i in range(train_len, total_len, window):
            last_season = df['y'][i-window:i].values
            pred_last_season.extend(last_season)

        return pred_last_season

    elif method == 'SARIMA':
        pred_SARIMA = []

        for i in range(train_len, total_len, window):
            model = SARIMAX(df['y'][:i], order=(2,1,3), 
➥ seasonal_order=(1,1,3,12), simple_differencing=False)
            res = model.fit(disp=False)
            predictions = res.get_prediction(0, i + window - 1)
            oos_pred = predictions.predicted_mean.iloc[-window:]
            pred_SARIMA.extend(oos_pred)

        return pred_SARIMA

接下来,我们将创建一个DataFrame来存储预测值以及实际值。这仅仅是测试集的一个副本。

pred_df = df[168:]

现在我们可以定义用于rolling_forecast函数的参数。数据集包含 204 行,测试集包含 36 个数据点,这意味着训练集的长度是 204 - 36 = 168。预测范围是 36,因为我们的测试集包含 36 个月的数据。最后,窗口是 12 个月,因为我们一次预测 12 个月。

设置这些值后,我们可以记录来自基线的预测,基线是一个简单的季节性预测。它只是简单地取最后 12 个月的观测数据,并将它们用作未来 12 个月的预测。

TRAIN_LEN = 168
HORIZON = 36
WINDOW = 12

pred_df['last_season'] = rolling_forecast(df, TRAIN_LEN, HORIZON, WINDOW, 
➥ 'last_season')

接下来,我们将计算 SARIMA 模型的预测。

pred_df['SARIMA'] = rolling_forecast(df, TRAIN_LEN, HORIZON, WINDOW, 
➥ 'SARIMA')

到目前为止,pred_df包含实际值、来自简单季节性方法的预测以及来自 SARIMA 模型的预测。我们可以使用这些数据来可视化我们的预测与实际值。为了清晰起见,我们将x轴限制在测试期间以进行放大。结果图如图 11.5 所示。

fig, ax = plt.subplots()

ax.plot(df.y)
ax.plot(pred_df.y, 'b-', label='actual')
ax.plot(pred_df.last_season, 'r:', label='naive seasonal')
ax.plot(pred_df.SARIMA, 'k--', label='SARIMA')
ax.set_xlabel('Date')
ax.set_ylabel('Number of anti-diabetic drug prescriptions')
ax.axvspan(168, 204, color='#808080', alpha=0.2)

ax.legend(loc=2)

plt.xticks(np.arange(6, 203, 12), np.arange(1992, 2009, 1))
plt.xlim(120, 204)

fig.autofmt_xdate()
plt.tight_layout()

图片

图 11.5 澳大利亚抗糖尿病药物处方的数量预测。基线模型的预测以虚线表示,而 SARIMA 模型的预测以虚线表示。

在图 11.5 中,你可以看到 SARIMA 模型的预测(虚线)比简单的季节性预测(虚线)更接*实际值。因此,我们可以直观地预期 SARIMA 模型的表现优于基线方法。

为了定量评估性能,我们将使用*均绝对百分比误差(MAPE)。MAPE 易于解释,因为它返回一个百分比误差。

def mape(y_true, y_pred):
    return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

mape_naive_seasonal = mape(pred_df.y, pred_df.last_season)
mape_SARIMA = mape(pred_df.y, pred_df.SARIMA)

print(mape_naive_seasonal, mape_SARIMA)

这输出了基线模型的 MAPE 为 12.69%,SARIMA 模型的 MAPE 为 7.90%。我们可以选择性地将每个模型的 MAPE 以条形图的形式绘制出来,以便于可视化,如图 11.6 所示。

fig, ax = plt.subplots()

x = ['naive seasonal', 'SARIMA(2,1,3)(1,1,3,12)']
y = [mape_naive_seasonal, mape_SARIMA]

ax.bar(x, y, width=0.4)
ax.set_xlabel('Models')
ax.set_ylabel('MAPE (%)')
ax.set_ylim(0, 15)

for index, value in enumerate(y):
    plt.text(x=index, y=value + 1, s=str(round(value,2)), ha='center')

plt.tight_layout()

图片

图 11.6 简单季节性预测和 SARIMA 模型的 MAPE。由于 SARIMA 模型的 MAPE 低于基线的 MAPE,我们可以得出结论,应该使用 SARIMA 模型来预测抗糖尿病药物处方的数量。

由于 SARIMA 模型实现了最低的 MAPE,我们可以得出结论,应该使用 SARIMA(2,1,3)(1,1,3)[12]模型来预测澳大利亚每月抗糖尿病药物处方的数量。

下一步

恭喜你完成了这个顶点项目。我希望你能够独立完成它,并且你现在对自己的技能和时间序列预测的统计模型知识感到自信。

当然,熟能生巧,所以我强烈建议你寻找其他时间序列数据集并练习对它们进行建模和预测。这将帮助你建立你的直觉并磨练你的技能。

在下一章中,我们将开始一个新的部分,在那里我们将使用深度学习模型来模拟和预测具有高维度的复杂时间序列。

第三部分:使用深度学习进行大规模预测

统计模型有其局限性,尤其是在数据集很大且具有许多特征和非线性关系时。在这种情况下,深度学习是时间序列预测的完美工具。在本部分书中,我们将处理一个庞大的数据集,并应用不同的深度学习架构,如长短期记忆(LSTM)、卷积神经网络(CNN)和自回归深度神经网络,来预测我们序列的未来。同样,我们将通过一个综合项目来结束本部分,以测试你的技能。

深度学习是机器学习的一个子集,因此可以使用更传统的机器学习算法进行时间序列预测,例如梯度提升树。为了使本节内容合理,我们不会具体介绍这些技术,尽管使用机器学习预测时间序列需要数据窗口,并且我们将多次应用这一概念。

12 介绍时间序列预测的深度学习

本章节涵盖

  • 使用深度学习进行预测

  • 探索不同的深度学习模型类型

  • 准备将深度学习应用于时间序列预测

在上一章中,我们使用统计模型完成了关于时间序列预测的书籍部分。这些模型在您拥有小型数据集(通常少于 10,000 个数据点)时表现尤其出色,当季节周期为月度、季度或年度时也是如此。在您拥有每日季节性或数据集非常大的情况下(超过 10,000 个数据点),这些统计模型变得非常慢,其性能下降。

因此,我们转向深度学习。深度学习是机器学习的一个子集,它专注于在神经网络架构上构建模型。深度学习的优势在于,随着数据的增加,其性能往往更好,这使得它成为预测高维时间序列的绝佳选择。

在本书的这一部分,我们将探讨各种模型架构,以便您拥有一套工具来应对几乎任何时间序列预测问题。请注意,我将假设您对深度学习有一定了解,因此激活函数、损失函数、批次、层和周期等主题应该是已知的。本书的这一部分不会作为深度学习的介绍,而是专注于将深度学习应用于时间序列预测。当然,每种模型架构都将得到详细解释,您将获得关于为什么在特定情况下某种架构可能比另一种架构表现更好的直觉。在这些章节中,我们将使用 TensorFlow,或更具体地说 Keras,来构建不同的深度学习模型。

在本章中,我们确定了使用深度学习的条件,并探讨了可以构建的不同类型的模型,例如单步、多步和多输出模型。我们将以以下章节中应用深度学习模型所需的初始设置来结束本章。最后,我们将探索数据,执行特征工程,并将数据分为训练集、验证集和测试集。

12.1 何时使用深度学习进行时间序列预测

当我们拥有大型复杂数据集时,深度学习表现出色。在这些情况下,深度学习可以利用所有可用数据来推断每个特征与目标之间的关系,通常导致良好的预测。

在时间序列的背景下,当数据点超过 10,000 个时,数据集被认为是大的。当然,这是一个*似值而不是一个硬性限制,所以如果您有 8,000 个数据点,深度学习可能是一个可行的选择。当数据集很大时,任何 SARIMAX 模型的下降都会花费很长时间来拟合,这对模型选择来说并不理想,因为我们通常会在这一步骤中拟合许多模型。

如果你的数据有多个季节周期,则不能使用 SARIMAX 模型。例如,假设你必须预测每小时的温度。可以合理地假设会有日季节性,因为温度在夜间较低,白天较高,但也有年季节性,因为冬季温度较低,夏季较高。在这种情况下,深度学习可以用来利用这两个季节周期的信息进行预测。实际上,在这种情况下拟合 SARIMA 模型通常会导致残差不服从正态分布且仍然相关,这意味着模型根本无法使用。

最终,深度学习被用于统计模型拟合时间过长或它们导致的相关残差不*似为白噪声的情况。这可能是因为模型中无法考虑另一个季节周期,或者简单地因为特征与目标之间存在非线性关系。在这些情况下,深度学习模型可以用来捕捉这种非线性关系,并且它们还有一个额外的优势,那就是训练速度非常快。

12.2 探索不同的深度学习模型类型

我们可以构建三种主要的深度学习模型用于时间序列预测:单步模型、多步模型和多输出模型。

单步模型是三种中最简单的。它的输出是一个值,代表一个变量未来一步的预测。因此,模型简单地返回一个标量,如图 12.1 所示。

图片

图 12.1 单步模型输出一个目标值在未来的一个时间步长。因此,输出是一个标量。

单步模型

单步模型输出一个值,代表下一个时间步长的预测值。输入可以是任何长度,但输出始终是下一个时间步长的单个预测值。

接下来我们可以有一个多步模型,这意味着我们输出一个目标值,但针对未来的多个时间步长。例如,给定每小时的数据,我们可能想要预测接下来的 24 小时。在这种情况下,我们有一个多步模型,因为我们正在预测未来的 24 个时间步长。输出是一个 24 × 1 的矩阵,如图 12.2 所示。

图片

图 12.2 多步模型输出一个变量在多个未来时间步长的预测值。本例预测了 24 个时间步长,结果是一个 24 x 1 的输出矩阵。

多步模型

在一个多步模型中,模型的输出是一个表示未来多个时间步长预测值的序列。例如,如果模型预测接下来的 6 小时、24 小时或 12 个月,它就是一个多步模型。

最后,多输出模型为多个目标生成预测。例如,如果我们预测温度和湿度,我们将使用多输出模型。此模型可以输出所需的时间步长数。在图 12.3 中,一个多输出模型返回了接下来 24 个时间步长的两个特征的预测。在这种情况下,输出是一个 24 x 2 的矩阵。

图片

图 12.3 一个多输出模型为未来一个或多个时间步长内的多个目标进行预测。在此,模型为接下来的 24 个时间步长内的两个目标输出预测。

多输出模型

多输出模型为多个目标生成预测。例如,如果我们预测温度和风速,它就是一个多输出模型。

这些模型可以有不同的架构。例如,卷积神经网络可以用作单步模型、多步模型或多输出模型。在接下来的章节中,我们将实现不同的模型架构,并将它们应用于所有三种模型类型。

这将我们带到这样一个阶段,我们将为接下来五章节中将要实现的不同的深度学习模型进行初始设置。

12.3 准备应用深度学习进行预测

从这里到第十七章,我们将使用 UCI 机器学习仓库中可用的地铁州际交通量数据集。原始数据集记录了 2012 年至 2018 年间明尼苏达州明尼阿波利斯和圣保罗之间 I-94 西行方向的每小时交通流量。为了学习如何应用深度学习进行时间序列预测,该数据集已被缩短并清理,以去除缺失值。虽然清理步骤在本章中未涉及,但您仍然可以参考 GitHub 仓库中本章的预处理代码。我们的主要预测目标是预测每小时交通量。在多输出模型的情况下,我们还将预测每小时温度。在接下来的几个章节的初始设置中,我们将加载数据,执行特征工程,并将其分为训练集、验证集和测试集。

在本书的这一部分,我们将使用 TensorFlow,或者更具体地说,使用 Keras。在撰写本文时,TensorFlow 的最新稳定版本是 2.6.0,这就是我在本章和以下章节中将使用的版本。

注意:本章的完整源代码可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH12

12.3.1 执行数据探索

我们将首先使用 pandas 加载数据。

df = 
➥ pd.read_csv('../data/metro_interstate_traffic_volume_preprocessed.csv')
df.head()

如前所述,此数据集是 UCI 机器学习仓库中原始数据集的简化和清洗版本。在这种情况下,数据集从 2016 年 9 月 29 日下午 5 点开始,到 2018 年 9 月 30 日晚上 11 点结束。使用df.shape,我们可以看到我们总共有六个特征和 17,551 行。

特征包括日期和时间、温度、雨雪量、云量和交通流量。表 12.1 更详细地描述了每一列。

表 12.1 机场间交通流量数据集中的变量

特征 描述
date_time 数据的日期和时间,记录在 CST 时区。格式为 YYYY-MM-DD HH:MM:SS。
temp 每小时记录的*均温度,以开尔文为单位。
rain_1h 每小时发生的雨量,以毫米为单位。
snow_1h 每小时发生的雪量,以毫米为单位。
clouds_all 每小时云量的百分比。
traffic_volume 每小时在 I-94 西行报告的交通流量。

现在,让我们可视化交通流量随时间的变化。由于我们的数据集非常大,有超过 17,000 条记录,我们将只绘制前 400 个数据点,这大约相当于两周的数据。结果如图 12.4 所示。

fig, ax = plt.subplots()

ax.plot(df['traffic_volume'])
ax.set_xlabel('Time')
ax.set_ylabel('Traffic volume')

plt.xticks(np.arange(7, 400, 24), ['Friday', 'Saturday', 'Sunday', 
➥ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 
➥ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 
➥ 'Saturday', 'Sunday'])
plt.xlim(0, 400)

fig.autofmt_xdate()
plt.tight_layout()

在图 12.4 中,您会注意到明显的每日季节性,因为每天的早晚交通量较低。您还会看到周末的交通量较小。至于趋势,两周的数据可能不足以得出合理的结论,但从图中看,流量似乎没有随时间增加或减少。

图片

图 12.4 2016 年 9 月 29 日下午 5 点开始,在明尼苏达州明尼阿波利斯和圣保罗之间 I-94 西行交通流量。您会注意到明显的每日季节性,每天的早晚交通量较低。

我们还可以绘制每小时温度,因为这将是我们多输出模型的目标。在这里,我们预计会看到年度和每日季节性。年度季节性应归因于一年中的季节,而每日季节性将归因于温度在夜间较低,在白天较高的现象。

让我们先可视化整个数据集中的每小时温度,看看我们是否可以识别出任何年度季节性。结果如图 12.5 所示。

fig, ax = plt.subplots()

ax.plot(df['temp'])
ax.set_xlabel('Time')
ax.set_ylabel('Temperature (K)')

plt.xticks([2239, 10999], [2017, 2018])

fig.autofmt_xdate()
plt.tight_layout()

图片

图 12.5 2016 年 9 月 29 日至 2018 年 9 月 30 日的每小时温度(开尔文)。尽管存在噪声,但我们可以看到年度季节性模式。

在图 12.5 中,您会在每小时温度中看到年度季节性模式,因为温度在年初和年末(明尼苏达州的冬季)较低,而在年中(夏季)较高。因此,正如预期的那样,温度具有年度季节性。

现在我们来验证我们是否可以在温度中观察到每日的季节性。结果如图 12.6 所示。

fig, ax = plt.subplots()

ax.plot(df['temp'])
ax.set_xlabel('Time')
ax.set_ylabel('Temperature (K)')

plt.xticks(np.arange(7, 400, 24), ['Friday', 'Saturday', 'Sunday', 
➥ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 
➥ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 
➥ 'Saturday', 'Sunday'])
plt.xlim(0, 400)

fig.autofmt_xdate()
plt.tight_layout()

图片

图 12.6 显示了从 2016 年 9 月 29 日下午 5 点开始的小时温度(开尔文)。尽管有点嘈杂,但我们确实可以看到温度在每天的开始和结束时较低,并在中午达到峰值,这表明存在每日的季节性。

在图 12.6 中,你会注意到温度确实在每天的开始和结束时较低,并在每天的中午达到峰值。这表明存在每日的季节性,正如我们在图 12.4 中观察到的交通量一样。

12.3.2 特征工程和数据拆分

在完成数据探索后,我们将继续进行特征工程和数据拆分。在本节中,我们将研究每个特征,并创建新的特征,这些特征将帮助我们的模型预测交通量和每小时温度。最后,我们将拆分数据,并将每个集合保存为 CSV 文件以供以后使用。

研究数据集特征的一个好方法是使用pandas中的describe方法。此方法返回每个特征的记录数,使我们能够快速识别每个特征的缺失值、*均值、标准差、四分位数以及最大和最小值。

df.describe().transpose()    ❶

❶ 转置方法将每个特征放在它自己的行上。

从输出中,你会注意到rain_1h在整个数据集中大部分为 0,因为它的第三四分位数仍然在 0。由于至少 75%的rain_1h的值是 0,它不太可能是一个强大的交通量预测因子。因此,这个特征将被删除。

观察到snow_1h,你会注意到这个变量在整个数据集中都是 0。这是很容易观察到的,因为它的最小值和最大值都是 0。因此,这不是预测交通量随时间变化的变量。这个特征也将从数据集中删除。

cols_to_drop = ['rain_1h', 'snow_1h']
df = df.drop(cols_to_drop, axis=1)

现在我们遇到了一个有趣的问题,那就是如何将时间编码为可用于我们深度学习模型的可用特征。目前,date_time特征无法被我们的模型使用,因为它是一个datetime字符串。因此,我们将将其转换为数值。

做这件事的一个简单方法是将日期表示为秒数。这是通过使用datetime库中的timestamp方法实现的。

timestamp_s = 
➥ pd.to_datetime(df['date_time']).map(datetime.datetime.timestamp)

不幸的是,我们还没有完成,因为这仅仅是将每个日期表示为秒,如图 12.7 所示。这导致我们失去了时间的周期性,因为秒数只是随时间线性增加。

图片

图 12.7 显示了数据集中每个日期表示的秒数。秒数随时间线性增加,这意味着我们失去了时间的周期性。

因此,我们必须应用一个变换来恢复时间的循环行为。一个简单的方法是应用正弦变换。我们知道正弦函数是循环的,介于-1 和 1 之间。这将帮助我们恢复时间的部分循环属性。

day = 24 * 60 * 60                                              ❶

df['day_sin'] = (np.sin(timestamp_s * (2*np.pi/day))).values    ❷

❶ 时间戳是以秒为单位的,所以在应用正弦变换之前,我们必须计算一天中的秒数。

❷ 应用正弦变换。注意我们在正弦函数中使用弧度。

通过单次正弦变换,我们恢复了一些在转换为秒时丢失的循环属性。然而,此时,中午 12 点等同于凌晨 12 点,下午 5 点等同于凌晨 5 点。这是我们不希望的,因为我们想区分上午和下午。因此,我们将应用余弦变换。我们知道余弦函数与正弦函数不同步。这允许我们区分上午 5 点和下午 5 点,表达一天时间的循环性质。在这个时候,我们可以从DataFrame中删除date_time列。

df['day_cos'] = (np.cos(timestamp_s * (2*np.pi/day))).values    ❶
df = df.drop(['date_time'], axis=1)                             ❷

❶ 将余下的时间戳应用余弦变换。

❷ 删除date_time列。

我们可以通过绘制day_sinday_cos的样本来快速说服自己这些变换是有效的。结果如图 12.8 所示。

df.sample(50).plot.scatter('day_sin','day_cos').set_aspect('equal');

图片

图 12.8 展示了day_sinday_cos编码的样本。我们已经成功地将时间编码为数值,同时保留了每日循环。

在图 12.8 中,你会注意到点形成了一个圆圈,就像一个时钟。因此,我们已经成功地将每个时间戳表达为时钟上的一个点,这意味着我们现在有了保留一天时间循环性质的数值,这可以在我们的深度学习模型中使用。这将会很有用,因为我们观察到温度和交通量的日季节性。

在特征工程完成后,我们现在可以划分我们的数据为训练集、验证集和测试集。训练集是用于拟合模型的样本数据。验证集有点像测试集,模型可以查看它来调整超参数并在模型训练期间提高其性能。测试集完全独立于模型的训练过程,用于对模型性能进行无偏评估。

在这里,我们将使用简单的 70:20:10 分割来划分训练集、验证集和测试集。虽然 10%的数据对于测试集来说似乎是一个小部分,但请记住,我们有超过 17,000 条记录,这意味着我们将评估超过 1,000 个数据点,这已经足够多了。

n = len(df)

# Split 70:20:10 (train:validation:test)
train_df = df[0:int(n*0.7)]               ❶
val_df = df[int(n*0.7):int(n*0.9)]        ❷
test_df = df[int(n*0.9):]                 ❸

❶ 前 70%分配给训练集。

❷ 接下来的 20%分配给验证集。

❸ 剩余的 10%分配给测试集。

在保存数据之前,我们必须将其缩放到所有值都在 0 到 1 之间。这减少了训练深度学习模型所需的时间,并提高了它们的性能。我们将使用MinMaxScalersklearn中缩放我们的数据。

注意,我们将对训练集上的scaler进行拟合,以避免数据泄露。这样,我们正在模拟当我们使用模型时,我们只有训练数据可用,模型不知道任何未来的信息。模型的评估保持无偏。

from sklearn.preprocessing import MinMaxScaler

scaler = MinMaxScaler()
scaler.fit(train_df)       ❶

train_df[train_df.columns] = scaler.transform(train_df[train_df.columns])
val_df[val_df.columns] = scaler.transform(val_df[val_df.columns])
test_df[test_df.columns] = scaler.transform(test_df[test_df.columns])

❶ 仅在训练集上拟合。

值得注意的是,为什么数据是缩放而不是归一化。对于数据科学家来说,缩放和归一化可能是令人困惑的术语,因为它们经常被互换使用。简而言之,缩放数据只会影响其尺度,而不会影响其分布。因此,它只是将值强制到某个范围内。在我们的情况下,我们将值强制在 0 到 1 之间。

数据归一化,另一方面,会影响其分布和其尺度。因此,归一化数据将迫使它具有正态分布或高斯分布。原始范围也会改变,绘制每个值的频率将生成一个经典的钟形曲线。

当我们使用的模型需要数据为正态分布时,归一化数据才有用。例如,线性判别分析(LDA)是从正态分布的假设中推导出来的,因此在使用 LDA 之前最好归一化数据。然而,在深度学习的情况下,没有做出任何假设,因此归一化不是必需的。

最后,我们将每个集合保存为 CSV 文件,以便在后续章节中使用。

train_df.to_csv('../data/train.csv')
val_df.to_csv('../data/val.csv')
test_df.to_csv('../data/test.csv')

12.4 下一步

在本章中,我们探讨了深度学习在预测中的应用,并介绍了三种主要的深度学习模型。然后我们探讨了我们将使用的数据,并进行了特征工程,以便数据准备好在下一章中使用,在下一章中,我们将应用深度学习模型来预测交通量。

在下一章中,我们将首先实现基线模型,这些模型将作为更复杂深度学习架构的基准。我们还将实现线性模型,这是可以构建的最简单模型,然后是至少包含一个隐藏层的深度神经网络。基线、线性模型和深度神经网络将被实现为单步模型、多步模型和多输出模型。你应该对下一章感到兴奋,因为我们将开始使用深度学习进行建模和预测。

12.5 练习

作为练习,我们将为第十二章至第十八章中的深度学习练习准备一些数据。这些数据将用于开发一个深度学习模型,以预测北京奥体中心站的空气质量。

具体来说,对于单变量建模,我们最终将预测二氧化氮(NO2)的浓度。对于多变量问题,我们将预测二氧化氮和温度的浓度。

注意预测空气污染物的浓度是一个重要问题,因为它们可以对人群产生负面影响,例如咳嗽、喘息、炎症和降低肺功能。温度也起着重要作用,因为热空气倾向于上升,产生对流效应,并将污染物从地面移动到更高的海拔。有了准确的模型,我们可以更好地管理空气污染,并更好地向公众提供采取适当预防措施的指导。

原始数据集可在 UCI 机器学习仓库中找到:archive.ics.uci.edu/ml/datasets/Beijing+Multi-Site+Air-Quality+Data。它已经过预处理和清理,以处理缺失数据并使其易于处理(预处理步骤可在 GitHub 上找到)。您将在 GitHub 上的 CSV 文件中找到数据:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH12

本练习的目标是为深度学习准备数据。按照以下步骤操作:

  1. 读取数据。

  2. 绘制目标变量图。

  3. 删除不必要的列。

  4. 确定是否存在每日季节性,并相应地编码时间。

  5. 将数据分为训练集、验证集和测试集。

  6. 使用MinMaxScaler缩放数据。

  7. 将训练集、验证集和测试集保存以供以后使用。

摘要

  • 当使用深度学习进行预测时:

    • 数据集很大(超过 10,000 个数据点)。

    • SARIMAX 模型的下降拟合需要很长时间。

    • 统计模型的残差仍然显示出一些相关性。

    • 季节周期不止一个。

  • 预测模型有三种类型:

    • 单步模型:预测一个变量的一个步骤的未来值。

    • 多步模型:预测一个变量的多个步骤的未来值。

    • 多输出模型:预测一个或多个步骤的未来多个变量。

13 数据窗口化和为深度学习创建基线

本章涵盖

  • 创建数据窗口

  • 实现深度学习的基线模型

在上一章中,我通过介绍深度学习在预测中的理想情况,并概述了三种主要的深度学习模型:单步、多步和多输出,来介绍深度学习用于预测。然后我们进行了数据探索和特征工程,以去除无用的特征并创建有助于我们预测交通量的新特征。完成这个设置后,我们现在准备实现深度学习来预测我们的目标变量,即交通量。

在本章中,我们将构建一个可重用的类,该类将创建数据窗口。这一步可能是本书这一部分最复杂也最有用的主题。应用深度学习进行预测依赖于创建适当的时间窗口并指定输入和标签。一旦完成这些,你就会发现实现不同的模型变得极其简单,这个框架可以用于不同的情况和数据集。

一旦你知道如何创建数据窗口,我们将继续实现基线模型、线性模型和深度神经网络。这将使我们能够衡量这些模型的性能,然后我们可以继续在接下来的章节中转向更复杂的架构。

13.1 创建数据窗口

我们将首先创建DataWindow类,这将使我们能够适当地格式化数据,以便将其提供给我们的深度学习模型。我们还将为此类添加一个绘图方法,这样我们就可以可视化预测值和实际值。

然而,在深入代码和构建DataWindow类之前,理解为什么我们必须为深度学习执行数据窗口化是很重要的。深度学习模型在拟合数据上有一种特别的方式,我们将在下一节中探讨。然后我们将继续前进,并实现DataWindow类。

13.1.1 探索深度学习模型如何进行时间序列预测的训练

在本书的前半部分,我们在训练集上拟合统计模型,如 SARIMAX,并进行预测。实际上,我们是在拟合一组预定义的函数,这些函数具有特定的阶数(pdq)(PDQ)[m],并找出哪个阶数导致了最佳拟合。

对于深度学习模型,我们没有一组函数去尝试。相反,我们让神经网络推导出它自己的函数,这样当它接收输入时,就能生成最佳预测。为了实现这一点,我们执行所谓的数据窗口化。这是一个过程,我们在时间序列上定义一系列数据点,并定义哪些是输入,哪些是标签。这样,深度学习模型就可以在输入上拟合,生成预测,将它们与标签进行比较,并重复这个过程,直到它无法提高其预测的准确性。

让我们通过一个数据窗口的例子来了解一下。我们的数据窗口将使用 24 小时的数据来预测接下来的 24 小时。您可能想知道为什么我们只使用 24 小时的数据来生成预测。毕竟,深度学习是数据密集型的,并且用于大型数据集。关键在于数据窗口。单个窗口有 24 个时间步作为输入来生成 24 个时间步的输出。然而,整个训练集被分割成多个窗口,这意味着我们有许多带有输入和标签的窗口,如图 13.1 所示。

图片

图 13.1 展示了训练集上的数据窗口。输入以方块标记显示,标签以交叉显示。每个数据窗口由 24 个带有方块标记的时间步组成,随后是 24 个带有交叉的标签。

在图 13.1 中,您可以看到我们的训练集交通量的前 400 个时间步。每个数据窗口由 24 个输入时间步和 24 个标签时间步组成(如图 13.2 所示),总长度为 48 个时间步。我们可以使用训练集生成许多数据窗口,因此我们实际上正在利用这些大量的数据。

如您在图 13.2 中看到的,数据窗口的总长度是每个序列长度的总和。在这种情况下,由于我们有 24 个时间步作为输入和 24 个标签,数据窗口的总长度是 48 个时间步。

图片

图 13.2 展示了数据窗口的一个例子。我们的数据窗口有 24 个时间步作为输入和 24 个时间步作为输出。模型将使用 24 小时的输入来生成 24 小时的预测。数据窗口的总长度是输入和标签长度的总和。在这种情况下,我们总共有 48 个时间步。

您可能会认为我们在浪费大量的训练数据,因为如图 13.2 所示,时间步 24 到 47 是标签。这些标签永远不会作为输入使用吗?当然,它们会。我们将在下一节中实现的DataWindow类会生成以t = 0 开始的数据窗口。然后它将创建另一组数据窗口,但这次以t = 1 开始。然后它将以t = 2 开始。这个过程一直持续到训练集中不能有 24 个连续的标签序列,如图 13.3 所示。

图片

图 13.3 展示了由DataWindow类生成的不同数据窗口。您可以看到,通过每次将起点向后移动一个时间步,我们尽可能多地使用训练数据来拟合我们的深度学习模型。

为了提高计算效率,深度学习模型使用批次进行训练。批次仅仅是输入到模型进行训练的数据窗口集合,如图 13.4 所示。

图片

图 13.4 显示了一个批次仅仅是用于训练深度学习模型的数据窗口集合。

图 13.4 展示了 32 个数据窗口大小的批次示例。这意味着 32 个数据窗口被分组在一起用于训练模型。当然,这只是一个批次——DataWindow类会根据给定的训练集尽可能多地生成批次。在我们的例子中,我们有一个包含 12,285 行的训练集。如果每个批次有 32 个数据窗口,那么我们将有 12285/32 = 384 个批次。

一次在所有 384 个批次上训练模型称为一个epoch。一个 epoch 通常不会导致模型准确度提高,因此模型将根据需要训练尽可能多的 epoch,直到无法提高其预测的准确度。

深度学习中数据窗口的最后一个重要概念是洗牌。我在本书的第一章中提到,时间序列数据不能进行洗牌。时间序列数据有顺序,这个顺序必须保持,那么为什么我们在这里要洗牌呢?

在这个上下文中,洗牌发生在批次级别,而不是数据窗口内部——时间序列本身的顺序在每个数据窗口内保持不变。每个数据窗口与其他所有数据窗口独立。因此,在批次中,我们可以洗牌数据窗口,同时仍然保持时间序列的顺序,如图 13.5 所示。洗牌数据不是必需的,但它是推荐的,因为它往往会产生更鲁棒的模型。

图片

图 13.5 展示了批次中数据窗口的洗牌。每个数据窗口与其他所有数据窗口独立,因此在批次内洗牌数据窗口是安全的。注意,时间序列的顺序在每个数据窗口内保持不变。

现在你已经理解了数据窗口的内部工作原理以及它是如何用于训练深度学习模型的,让我们来实现DataWindow类。

13.1.2 实现 DataWindow 类

现在我们已经准备好实现DataWindow类。这个类具有灵活性的优势,意味着你可以在各种场景中使用它来应用深度学习。完整的代码可以在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH13%26CH14

这个类基于输入宽度、标签宽度和位移。输入宽度简单地说就是输入到模型中以进行预测的时间步数。例如,如果我们数据集中的数据是按小时收集的,如果我们向模型提供 24 小时的数据进行预测,那么输入宽度是 24。如果我们只提供 12 小时的数据,那么输入宽度是 12。

标签宽度等同于预测中的时间步数。如果我们只预测一个时间步,标签宽度是 1。如果我们预测一个完整的数据日(按小时数据),标签宽度是 24。

最后,偏移量是输入和预测之间分离的时间步长数。如果我们预测下一个时间步长,偏移量是 1。如果我们预测接下来的 24 小时(使用每小时数据),偏移量是 24。

让我们可视化一些数据窗口,以更好地理解这些参数。图 13.6 显示了一个数据窗口,其中模型根据单个数据点预测下一个数据点。

图 13.6 显示了一个数据窗口,其中模型根据单个数据点预测未来的一个时间步长。输入宽度为 1,因为模型只接受 1 个数据点作为输入。标签宽度也为 1,因为模型只输出 1 个时间步长的预测。由于模型预测下一个时间步长,偏移量也是 1。最后,总窗口大小是偏移量和输入宽度的总和,等于 2。

现在,让我们考虑这种情况,即我们向模型提供 24 小时的数据以预测接下来的 24 小时。这种情况下的数据窗口如图 13.7 所示。

图 13.7 显示了数据窗口,其中模型使用最后 24 小时的数据预测接下来的 24 小时。输入宽度为 24,标签宽度也为 24。由于输入和预测之间有 24 个时间步长,因此偏移量也是 24。这给出了总窗口大小为 48 个时间步长。

现在,你理解了输入宽度、标签宽度和偏移量的概念后,我们可以创建 DataWindow 类并在列表 13.1 中定义其初始化函数。该函数还将接受训练、验证和测试集,因为数据窗口将来自我们的数据集。最后,我们将允许指定目标列。

列表 13.1 定义 DataWindow 的初始化函数

class DataWindow():
    def __init__(self, input_width, label_width, shift, 
                 train_df=train_df, val_df=val_df, test_df=test_df, 
                 label_columns=None):

        self.train_df = train_df
        self.val_df = val_df
        self.test_df = test_df

        self.label_columns = label_columns                              ❶
        if label_columns is not None:
            self.label_columns_indices = {name: i for i, name in 
➥ enumerate(label_columns)}                                            ❷
        self.column_indices = {name: i for i, name in 
➥ enumerate(train_df.columns)}                                         ❸

        self.input_width = input_width
        self.label_width = label_width
        self.shift = shift

        self.total_window_size = input_width + shift

        self.input_slice = slice(0, input_width)                        ❹
        self.input_indices = 
➥ np.arange(self.total_window_size)[self.input_slice]                  ❺

        self.label_start = self.total_window_size - self.label_width    ❻
        self.labels_slice = slice(self.label_start, None)               ❼
        self.label_indices = 
➥ np.arange(self.total_window_size)[self.labels_slice]

❶ 我们希望预测的列名称

❷ 创建一个包含标签列名称和索引的字典。这将用于绘图。

❸ 创建一个包含每列名称和索引的字典。这将用于将特征与目标变量分开。

❹ 切片函数返回一个切片对象,指定如何切片一个序列。在这种情况下,它表示输入切片从 0 开始,到我们达到 input_width 时结束。

❺ 为输入分配索引。这些对于绘图很有用。

❻ 获取标签开始的索引。在这种情况下,它是总窗口大小减去标签宽度。

❼ 对输入应用相同的步骤也应用于标签。

在列表 13.1 中,你可以看到初始化函数基本上分配变量并管理输入和标签的索引。我们的下一步是将窗口在输入和标签之间分割,以便我们的模型可以根据输入进行预测并测量与标签的错误度量。以下 split_to_inputs_labels 函数是在 DataWindow 类中定义的。

def split_to_inputs_labels(self, features):
    inputs = features[:, self.input_slice, :]           ❶
    labels = features[:, self.labels_slice, :]          ❷
    if self.label_columns is not None:                  ❸
        labels = tf.stack(
            [labels[:,:,self.column_indices[name]] for name in 
➥ self.label_columns],
            axis=-1
        )
    inputs.set_shape([None, self.input_width, None])    ❹
    labels.set_shape([None, self.label_width, None])

    return inputs, labels

❶ 使用在 init 中定义的 input_slice 切片窗口以获取输入。

❷ 使用在 init 中定义的 labels_slice 对窗口进行切片以获取标签。

❸ 如果我们有多个目标,我们将堆叠标签。

❹ 形状为[批次,时间,特征]。在此阶段,我们只指定时间维度,允许批次和特征维度稍后定义。

split_to_inputs_labels函数将大数据窗口分为两个窗口:一个用于输入,另一个用于标签,如图 13.8 所示。

图片

图 13.8 split_to_inputs_labels函数简单地将大数据窗口分为两个窗口,其中一个包含输入,另一个包含标签。

接下来,我们将定义一个函数来绘制输入数据、预测值和实际值(列表 13.2)。由于我们将处理许多时间窗口,我们只显示三个时间窗口的图表,但这个参数可以很容易地更改。此外,默认标签将是交通量,但我们可以通过指定任何列来更改它。再次强调,这个函数应该包含在DataWindow类中。

列表 13.2 绘制数据窗口样本的方法

def plot(self, model=None, plot_col='traffic_volume', max_subplots=3):
    inputs, labels = self.sample_batch

    plt.figure(figsize=(12, 8))
    plot_col_index = self.column_indices[plot_col]
    max_n = min(max_subplots, len(inputs))

    for n in range(max_n):
        plt.subplot(3, 1, n+1)
        plt.ylabel(f'{plot_col} [scaled]')
        plt.plot(self.input_indices, inputs[n, :, plot_col_index],
                 label='Inputs', marker='.', zorder=-10)          ❶

        if self.label_columns:
          label_col_index = self.label_columns_indices.get(plot_col, 
➥ None)
        else:
          label_col_index = plot_col_index

        if label_col_index is None:
          continue

        plt.scatter(self.label_indices, labels[n, :, label_col_index],
                    edgecolors='k', marker='s', label='Labels', 
➥ c='green', s=64)                                               ❷
        if model is not None:
          predictions = model(inputs)
          plt.scatter(self.label_indices, predictions[n, :, 
➥ label_col_index],
                      marker='X', edgecolors='k', label='Predictions',
                      c='red', s=64)                              ❸

        if n == 0:
          plt.legend()

    plt.xlabel('Time (h)')

❶ 绘制输入值。它们将以连续的蓝色线条和点表示。

❷ 绘制标签或实际值。它们将以绿色方块的形式出现。

❸ 绘制预测值。它们将以红色十字形出现。

我们几乎完成了DataWindow类的构建。最后一项主要逻辑是将我们的数据集格式化为张量,以便它们可以被喂给我们的深度学习模型。TensorFlow 附带一个非常有用的函数timeseries_dataset_from_array,它根据一个数组创建一个滑动窗口数据集。

def make_dataset(self, data):
    data = np.array(data, dtype=np.float32)
    ds = tf.keras.preprocessing.timeseries_dataset_from_array(
        data=data,                                 ❶
        targets=None,                              ❷
        sequence_length=self.total_window_size,    ❸
        sequence_stride=1,                         ❹
        shuffle=True,                              ❺
        batch_size=32                              ❻
    )

    ds = ds.map(self.split_to_inputs_labels)
    return ds

❶ 输入数据。这对应于我们的训练集、验证集或测试集。

❷ 目标设置为 None,因为它们由 split_to_input_labels 函数处理。

❸ 定义数组的总长度,它等于总窗口长度。

❹ 定义每个序列之间的时间步数。在我们的案例中,我们希望序列是连续的,因此 sequence_stride=1。

❺ 打乱序列。请注意,数据仍然是按时间顺序排列的。我们只是在序列的顺序上进行打乱,这使得模型更加鲁棒。

❻ 定义单个批次中的序列数量。

记住,我们在批次中打乱序列。这意味着在每个序列内部,数据是按时间顺序排列的。然而,在 32 个序列的批次中,我们可以也应该打乱它们,以使我们的模型更加鲁棒,并减少过拟合的风险。

我们将通过定义一些属性来结束DataWindow类的定义,这些属性将应用于训练、验证和测试集上的make_dataset函数。我们还将创建一个样本批次,并将其在类内部缓存,用于绘图目的。

@property
def train(self):
    return self.make_dataset(self.train_df)

@property
def val(self):
    return self.make_dataset(self.val_df)

@property
def test(self):
    return self.make_dataset(self.test_df)

@property
def sample_batch(self):                           ❶
    result = getattr(self, '_sample_batch', None)
    if result is None:
        result = next(iter(self.train))
        self._sample_batch = result
    return result

❶ 获取用于绘图目的的样本批次数据。如果样本批次不存在,我们将检索一个样本批次并将其缓存。

我们现在已经完成了DataWindow类。完整的类,包括所有方法和属性,如列表 13.3 所示。

列表 13.3 完整的DataWindow

class DataWindow():
    def __init__(self, input_width, label_width, shift, 
                 train_df=train_df, val_df=val_df, test_df=test_df, 
                 label_columns=None):

        self.train_df = train_df
        self.val_df = val_df
        self.test_df = test_df

        self.label_columns = label_columns
        if label_columns is not None:
            self.label_columns_indices = {name: i for i, name in 
➥ enumerate(label_columns)}
        self.column_indices = {name: i for i, name in 
➥ enumerate(train_df.columns)}

        self.input_width = input_width
        self.label_width = label_width
        self.shift = shift

        self.total_window_size = input_width + shift

        self.input_slice = slice(0, input_width)
        self.input_indices = 
➥ np.arange(self.total_window_size)[self.input_slice]

        self.label_start = self.total_window_size - self.label_width
        self.labels_slice = slice(self.label_start, None)
        self.label_indices = 
➥ np.arange(self.total_window_size)[self.labels_slice]

    def split_to_inputs_labels(self, features):
        inputs = features[:, self.input_slice, :]
        labels = features[:, self.labels_slice, :]
        if self.label_columns is not None:
            labels = tf.stack(
                [labels[:,:,self.column_indices[name]] for name in 
➥ self.label_columns],
                axis=-1
            )
        inputs.set_shape([None, self.input_width, None])
        labels.set_shape([None, self.label_width, None])

        return inputs, labels

    def plot(self, model=None, plot_col='traffic_volume', max_subplots=3):
        inputs, labels = self.sample_batch
        plt.figure(figsize=(12, 8))
        plot_col_index = self.column_indices[plot_col]
        max_n = min(max_subplots, len(inputs))

        for n in range(max_n):
            plt.subplot(3, 1, n+1)
            plt.ylabel(f'{plot_col} [scaled]')
            plt.plot(self.input_indices, inputs[n, :, plot_col_index],
                     label='Inputs', marker='.', zorder=-10)

            if self.label_columns:
              label_col_index = self.label_columns_indices.get(plot_col, 
➥ None)
            else:
              label_col_index = plot_col_index

            if label_col_index is None:
              continue

            plt.scatter(self.label_indices, labels[n, :, label_col_index],
                        edgecolors='k', marker='s', label='Labels', 
➥ c='green', s=64)
            if model is not None:
              predictions = model(inputs)
              plt.scatter(self.label_indices, predictions[n, :, 
➥ label_col_index],
                          marker='X', edgecolors='k', label='Predictions',
                          c='red', s=64)

            if n == 0:
              plt.legend()

        plt.xlabel('Time (h)')

    def make_dataset(self, data):
        data = np.array(data, dtype=np.float32)
        ds = tf.keras.preprocessing.timeseries_dataset_from_array(
            data=data,
            targets=None,
            sequence_length=self.total_window_size,
            sequence_stride=1,
            shuffle=True,
            batch_size=32
        )

        ds = ds.map(self.split_to_inputs_labels)
        return ds

    @property
    def train(self):
        return self.make_dataset(self.train_df)

    @property
    def val(self):
        return self.make_dataset(self.val_df)
    @property
    def test(self):
        return self.make_dataset(self.test_df)

    @property
    def sample_batch(self):
        result = getattr(self, '_sample_batch', None)
        if result is None:
            result = next(iter(self.train))
            self._sample_batch = result
        return result

目前,DataWindow类可能看起来有点抽象,但我们将很快使用它来应用基线模型。我们将在这本书的深度学习部分的每一章中使用这个类,所以您将逐渐驯服这段代码,并欣赏测试不同深度学习架构的简便性。

13.2 应用基线模型

随着DataWindow类的完成,我们准备好使用它了。我们将应用基线模型作为单步、多步和多输出模型。您将看到,当我们拥有正确的数据窗口时,它们的实现是相似的,并且极其简单。

回想一下,基线被用作基准来评估更复杂的模型。如果一个模型的表现优于另一个模型,那么它就是性能良好的,因此构建基线是建模的重要步骤。

13.2.1 单步基线模型

我们首先将实现一个单步模型作为基线。在单步模型中,输入是一个时间步长,输出是下一个时间步长的预测。

第一步是生成一个数据窗口。由于我们正在定义一个单步模型,输入宽度为 1,标签宽度为 1,偏移量也为 1,因为模型预测下一个时间步长。我们的目标变量是交通量。

single_step_window = DataWindow(input_width=1, label_width=1, shift=1, 
➥ label_columns=['traffic_volume'])

为了绘图目的,我们还将定义一个更宽的窗口,这样我们就可以可视化我们模型的多项预测。否则,我们只能可视化一个输入数据点和一项输出预测,这并不很有趣。

wide_window = DataWindow(input_width=24, label_width=24, shift=1, 
➥ label_columns=['traffic_volume'])

在这种情况下,我们可以做出的最简单的预测是最后一个观察到的值。基本上,预测仅仅是输入数据点。这是通过Baseline类实现的。正如您在下面的列表中可以看到的,Baseline类也可以用于多输出模型。现在,我们将仅关注单步模型。

列表 13.4 返回输入数据作为预测的类

class Baseline(Model):
    def __init__(self, label_index=None):
        super().__init__()
        self.label_index = label_index

    def call(self, inputs):
        if self.label_index is None:                ❶
            return inputs

        elif isinstance(self.label_index, list):    ❷
            tensors = []
            for index in self.label_index:
                result = inputs[:, :, index]
                result = result[:, :, tf.newaxis]
                tensors.append(result)
            return tf.concat(tensors, axis=-1)

        result = inputs[:, :, self.label_index]     ❸
        return result[:,:,tf.newaxis]

❶ 如果没有指定目标,我们将返回所有列。这对于所有列都需要预测的多输出模型很有用。

❷ 如果我们指定了一个目标列表,它将只返回指定的列。同样,这也用于多输出模型。

❸ 返回给定目标变量的输入。

定义了类之后,我们现在可以初始化模型并编译它以生成预测。为此,我们将找到我们的目标列traffic_volume的索引,并将其传递给Baseline。请注意,TensorFlow 要求我们提供一个损失函数和评估指标。在这种情况下,以及在整个深度学习章节中,我们将使用均方误差(MSE)作为损失函数——它惩罚大误差,并且通常会产生拟合良好的模型。对于评估指标,我们将使用*均绝对误差(MAE),因为它易于解释。

column_indices = {name: i for i, name in enumerate(train_df.columns)}    ❶

baseline_last = Baseline(label_index=column_indices['traffic_volume'])   ❷

baseline_last.compile(loss=MeanSquaredError(), 
➥ metrics=[MeanAbsoluteError()])                                        ❸

❶ 生成一个包含训练集中每个列名称和索引的字典。

❷ 在基线类中传递目标列的索引。

❸ 编译模型以生成预测。

现在,我们将评估我们的基线在验证集和测试集上的性能。使用 TensorFlow 构建的模型方便地带有evaluate方法,这允许我们将预测与实际值进行比较,并计算误差指标。

val_performance = {}                                             ❶
performance = {}                                                 ❷

val_performance['Baseline - Last'] = 
➥ baseline_last.evaluate(single_step_window.val)                ❸
performance['Baseline - Last'] = 
➥ baseline_last.evaluate(single_step_window.test, verbose=0)    ❹

❶ 创建一个字典来存储模型在验证集上的 MAE。

❷ 创建一个字典来存储模型在测试集上的 MAE。

❸ 在验证集上存储基线的 MAE。

❹ 在测试集上存储基线的 MAE。

太好了,我们已经成功构建了一个预测最后一个已知值的基线,并对其进行了评估。我们可以使用DataWindow类的plot方法可视化预测结果。请记住使用wide_window来查看不仅仅是两个数据点。

wide_window.plot(baseline_last)

在图 13.9 中,标签是正方形,预测是交叉。每个时间步的交叉只是最后一个已知值,这意味着我们有一个按预期工作的基线。你的图表可能与图 13.9 不同,因为每次初始化数据窗口时缓存的样本批次都会改变。

图 13.9

图 13.9 我们基线单步模型在样本批次中的三个序列上的预测。每个时间步的预测是最后一个已知值,这意味着我们的基线按预期工作。

我们可以选择打印基线在测试集上的 MAE。

print(performance['Baseline - Last'][1])

这返回了一个 MAE 为 0.081。更复杂的模型应该比基线表现更好,从而产生更小的 MAE。

13.2.2 多步基线模型

在上一节中,我们构建了一个单步基线模型,它简单地预测了最后一个已知值。对于多步模型,我们将预测未来超过一个时间步。在这种情况下,我们将根据 24 小时的输入预测接下来 24 小时的数据流量。

再次强调,第一步是生成适当的数据窗口。因为我们希望使用 24 小时的输入预测未来 24 个时间步,所以输入宽度是 24,标签宽度是 24,偏移量也是 24。

multi_window = DataWindow(input_width=24, label_width=24, shift=24, 
➥ label_columns=['traffic_volume'])

在生成数据窗口后,我们现在可以专注于实现基线模型。在这种情况下,有两种合理的基线:

  • 预测接下来 24 个时间步的最后一个已知值。

  • 预测接下来的 24 个时间步的最后一个 24 个时间步。

考虑到这一点,让我们实现第一个基线,我们将简单地重复下一个 24 个时间步内的最后一个已知值。

预测最后一个已知值

为了预测最后一个已知值,我们将定义一个MultiStepLastBaseline类,它简单地接受输入并在 24 个时间步内重复输入序列的最后一个值。这作为模型的预测。

class MultiStepLastBaseline(Model):
    def __init__(self, label_index=None):
        super().__init__()
        self.label_index = label_index

    def call(self, inputs):
        if self.label_index is None:
            return tf.tile(inputs[:, -1:, :], [1, 24, 1])               ❶
        return tf.tile(inputs[:, -1:, self.label_index:], [1, 24, 1])   ❷

❶ 如果未指定目标,则返回接下来 24 个时间步内所有列的最后一个已知值。

❷ 返回目标列接下来 24 个时间步的最后一个已知值。

接下来,我们将初始化类并指定目标列。然后我们将重复上一节中的相同步骤,编译模型并在验证集和测试集上评估它。

ms_baseline_last = 
➥ MultiStepLastBaseline(label_index=column_indices['traffic_volume'])

ms_baseline_last.compile(loss=MeanSquaredError(), 
➥ metrics=[MeanAbsoluteError()])

ms_val_performance = {}
ms_performance = {}
ms_val_performance['Baseline - Last'] = 
➥ ms_baseline_last.evaluate(multi_window.val)
ms_performance['Baseline - Last'] = 
➥ ms_baseline_last.evaluate(multi_window.test, verbose=0)

我们现在可以使用 DataWindowplot 方法可视化预测。结果如图 13.10 所示。

multi_window.plot(ms_baseline_last)

图 13.10 预测下一个 24 个时间步长的最后一个已知值。我们可以看到,预测值(以十字形表示)对应于输入序列的最后一个值,因此我们的基线表现符合预期。

再次,我们可以选择性地打印基线的 MAE。从图 13.10 中,我们可以预期它相当高,因为标签和预测之间存在很大的差异。

print(ms_performance['Baseline - Last'][1])

这给出了 0.347 的 MAE。现在让我们看看是否可以通过简单地重复输入序列来构建一个更好的基线。

重复输入序列

让我们实现第二个基线,用于多步模型,它简单地返回输入序列。这意味着下一个 24 小时的预测将仅仅是最后一个已知的 24 小时数据。这是通过 RepeatBaseline 类实现的。

class RepeatBaseline(Model):
    def __init__(self, label_index=None):
        super().__init__()
        self.label_index = label_index

    def call(self, inputs):
        return inputs[:, :, self.label_index:]    ❶

❶ 返回给定目标列的输入序列。

现在我们可以初始化基线模型并生成预测。请注意,损失函数和评估指标保持不变。

ms_baseline_repeat = 
➥ RepeatBaseline(label_index=column_indices['traffic_volume'])

ms_baseline_repeat.compile(loss=MeanSquaredError(), 
➥ metrics=[MeanAbsoluteError()])

ms_val_performance['Baseline - Repeat'] = 
➥ ms_baseline_repeat.evaluate(multi_window.val)
ms_performance['Baseline - Repeat'] = 
➥ ms_baseline_repeat.evaluate(multi_window.test, verbose=0)

接下来,我们可以可视化预测结果。结果如图 13.11 所示。

图 13.11 将输入序列作为预测。您将看到预测(以十字形表示)与输入序列完全匹配。您还会注意到许多预测与标签重叠,这表明这个基线表现相当好。

这个基线表现良好。这是可以预料的,因为我们已经在上一章中识别出了日季节性。这个基线相当于预测最后一个已知的季节。

再次,我们可以打印测试集上的 MAE 以验证我们确实有一个比简单地预测最后一个已知值更好的基线。

print(ms_performance['Baseline - Repeat'][1])

这给出了 0.341 的 MAE,低于预测最后一个已知值获得的 MAE。因此,我们已经成功构建了一个更好的基线。

13.2.3 多输出基线模型

我们将要介绍的最后一类模型是多输出模型。在这种情况下,我们希望使用单个输入数据点预测下一个时间步长的交通量和温度。本质上,我们是在交通量和温度上应用单步模型,使其成为多输出模型。

再次,我们将从定义数据窗口开始,但在这里我们将定义两个窗口:一个用于训练,另一个用于可视化。由于模型接收一个数据点并输出一个预测,我们希望初始化一个宽的数据窗口来可视化多个时间步长的多个预测。

mo_single_step_window = DataWindow(input_width=1, label_width=1, shift=1, 
➥ label_columns=['temp','traffic_volume'])                               ❶
mo_wide_window = DataWindow(input_width=24, label_width=24, shift=1, 
➥ label_columns=['temp','traffic_volume'])

❶ 注意,我们传递了 temp 和 traffic_volume,因为那是我们的多输出模型的两个目标。

然后,我们将使用为单步模型定义的 Baseline 类。回想一下,这个类可以输出一系列目标中的最后已知值。

列表 13.5 返回输入数据作为预测的类

class Baseline(Model):
    def __init__(self, label_index=None):
        super().__init__()
        self.label_index = label_index

    def call(self, inputs):
        if self.label_index is None:                ❶
            return inputs

        elif isinstance(self.label_index, list):    ❷
            tensors = []
            for index in self.label_index:
                result = inputs[:, :, index]
                result = result[:, :, tf.newaxis]
                tensors.append(result)
            return tf.concat(tensors, axis=-1)
        result = inputs[:, :, self.label_index]     ❸
        return result[:,:,tf.newaxis]

❶ 如果没有指定目标,我们返回所有列。这对于所有列都需要预测的多输出模型很有用。

❷ 如果我们指定了一个目标列表,它将只返回这些指定的列。同样,这用于多输出模型。

❸ 返回给定目标变量的输入。

在多输出模型的情况下,我们必须简单地传递 temp 和 traffic_volume 列的索引,以输出相应变量的最后已知值作为预测。

print(column_indices['traffic_volume'])    ❶
print(column_indices['temp'])              ❷

mo_baseline_last = Baseline(label_index=[0, 2])

❶ 输出 2

❷ 输出 0

使用初始化了我们的两个目标变量的基线,我们现在可以编译模型并评估它。

mo_val_performance = {}
mo_performance = {}

mo_val_performance['Baseline - Last'] = 
➥ mo_baseline_last.evaluate(mo_wide_window.val)
mo_performance['Baseline - Last'] = 
➥ mo_baseline_last.evaluate(mo_wide_window.test, verbose=0)

最后,我们可以将预测值与实际值进行可视化。默认情况下,我们的 plot 方法将在 y-轴上显示交通量,使我们能够快速显示我们的一个目标,如图 13.12 所示。

mo_wide_window.plot(mo_baseline_last)

图片

图 13.12 预测交通量的最后已知值

图 13.12 没有显示任何令人惊讶的内容,因为我们已经在构建单步基线模型时看到了这些结果。多输出模型的特点是,我们还有温度的预测值。当然,我们也可以通过在 plot 方法中指定目标来可视化温度的预测值。结果如图 13.13 所示。

mo_wide_window.plot(model=mo_baseline_last, plot_col='temp')

图片

图 13.13 预测温度的最后已知值。预测值(交叉点)等于前一个数据点,因此我们的基线模型表现如预期。

我们还可以打印基线模型的 MAE。

print(mo_performance['Baseline - Last'])

我们在测试集上获得了一个 MAE 为 0.047。在下一章中,我们将开始构建更复杂的模型,它们的 MAE 应该会更低,因为它们将被训练以拟合数据。

13.3 下一步

在本章中,我们涵盖了创建数据窗口的关键步骤,这将使我们能够快速构建任何类型的模型。然后我们继续为每种类型的模型构建基线模型,这样我们就有基准可以比较,当我们稍后在章节中构建更复杂的模型时。

当然,构建基线模型还不是深度学习应用。在下一章中,我们将实现线性模型和深度神经网络,看看这些模型是否已经比简单的基线模型更有效。

13.4 练习

在上一章中,作为练习,我们为深度学习建模准备了空气污染数据集。现在我们将使用训练集、验证集和测试集来构建基线模型并评估它们。

对于每种类型的模型,遵循概述的步骤。请记住,单步和多步模型的目标是 NO[2]的浓度,而多输出模型的目标是 NO[2]的浓度和温度。完整的解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH13%26CH14

  1. 对于单步模型

    1. 建立一个基线模型,预测最后已知值。

    2. 绘制其图形。

    3. 使用*均绝对误差(MAE)评估其性能,并将其存储在字典中以供比较。

  2. 对于多步模型

    1. 建立一个基线模型,在 24 小时的时间范围内预测最后已知值。

    2. 建立一个基线模型,重复最后 24 小时的数据。

    3. 绘制两个模型的预测结果。

    4. 使用 MAE 评估两个模型,并存储它们的性能。

  3. 对于多输出模型

    1. 建立一个基线模型,预测最后已知值。

    2. 绘制其图形。

    3. 使用 MAE 评估其性能,并将其存储在字典中以供比较。

摘要

  • 数据窗口化在深度学习中至关重要,可以将数据格式化为模型的输入和标签。

  • DataWindow类可以轻松地用于任何情况,并且可以根据您的喜好进行扩展。在您自己的项目中使用它。

  • 深度学习模型需要一个损失函数和评估指标。在我们的案例中,我们选择了均方误差(MSE)作为损失函数,因为它对大误差进行惩罚,并倾向于产生更好的拟合模型。评估指标是*均绝对误差(MAE),因为它易于解释。

14 深度学习的初步尝试

本章涵盖

  • 实现线性模型

  • 实施深度神经网络

在上一章中,我们实现了DataWindow类,它允许我们快速创建用于构建单步模型、多步模型和多输出模型的数据窗口。有了这个关键组件,我们随后开发了将作为我们更复杂模型基准的基线模型,我们将在本章开始构建这些模型。

具体来说,我们将实现线性模型和深度神经网络。线性模型是神经网络的一种特殊情况,其中没有隐藏层。该模型简单地计算每个输入变量的权重,以便输出对目标的预测。相比之下,深度神经网络至少有一个隐藏层,这使得我们可以开始对特征和目标之间的非线性关系进行建模,通常导致更好的预测。

在本章中,我们将继续第十三章开始的工作。我建议你继续使用与上一章相同的笔记本或 Python 脚本进行编码,这样你就可以比较这些线性模型和深度神经网络与第十三章中的基线模型的性能。我们还将继续使用之前相同的同一数据集,我们的目标变量将保持为单步和多步模型中的交通流量。对于多输出模型,我们将保持温度和交通流量作为我们的目标。

14.1 实现线性模型

线性模型是我们可以在深度学习中实现的 simplest 架构。实际上,我们可能会争论这根本不是深度学习,因为模型没有隐藏层。每个输入特征只是被赋予一个权重,然后它们被组合起来输出对目标的预测,就像在传统的线性回归中一样。

让我们以单步模型为例。回想一下,在我们的数据集中有以下特征:温度、云量、交通流量以及day_sinday_cos,它们将一天中的时间编码为数值。线性模型简单地接受所有特征,为每个特征计算一个权重,并将它们相加以输出对下一个时间步的预测。这个过程在图 14.1 中得到了说明。

图 14.1 线性模型作为单步模型的示例。在时间t的每个特征都被分配一个权重(w[1]到w[5])。然后它们被相加以计算下一个时间步交通流量t+1 的输出。这类似于线性回归。

图 14.1 中的模型可以用方程 14.1 表示,其中x[1]是云量,x[2]是温度,x[3]是交通流量,x[4]是day_sinx[5]是day_cos

交通流量[t+1] = w[1]x[1,t] + w[2]x[2,t] + w[3]x[3,t]+ w[4]x[4,t] + w[5]x[5,t]

方程 14.1

我们可以很容易地识别出方程 14.1 是一个简单的多元线性回归。在训练过程中,模型尝试多个值来最小化预测值和下一个时间步交通量实际值之间的均方误差(MSE)。

现在你已经理解了深度学习中线性模型的概念,让我们将其实现为一个单步模型、多步模型和多输出模型。

14.1.1 实现单步线性模型

单步线性模型是实施起来最简单的模型之一,因为它正好如第 14.1 图和第 14.1 方程所描述的那样。我们只需取所有输入,为每个输入分配一个权重,求和,然后生成一个预测。记住,我们正在使用交通量作为目标。

假设你正在使用与上一章相同的笔记本或 Python 脚本,你应该可以访问用于训练的single_step_window和用于绘图的wide_window。还要记住,基线性能存储在val_performanceperformance中。

与基线模型不同,线性模型实际上需要训练。因此,我们将定义一个compile_and_fit函数,该函数配置模型以进行训练,然后根据以下列表将模型拟合到数据上。

注意:你可以查阅 GitHub 上本章的源代码:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH13%26CH14

列表 14.1 配置深度学习模型并在数据上拟合它的函数

def compile_and_fit(model, window, patience=3, max_epochs=50):   ❶
    early_stopping = EarlyStopping(monitor='val_loss',           ❷
                                   patience=patience,            ❸
                                   mode='min')

    model.compile(loss=MeanSquaredError(),                       ❹
                  optimizer=Adam(),
                  metrics=[MeanAbsoluteError()])                 ❺

    history = model.fit(window.train,                            ❻
                       epochs=max_epochs,                        ❼
                       validation_data=window.val,               ❽
                       callbacks=[early_stopping])               ❾

    return history

❶ 该函数接受一个模型和数据窗口类的一个数据窗口。耐心参数表示在验证损失没有改善后模型应该停止训练的 epoch 数;max_epochs 设置训练模型的最大 epoch 数。

❷ 跟踪验证损失以确定是否应该应用早期停止。

❸ 如果连续 3 个 epoch 没有降低验证损失,则发生早期停止,这是由耐心参数设置的。

❹ 使用均方误差(MSE)作为损失函数。

❺ 使用 MAE 作为误差度量。这是我们比较模型性能的方式。MAE 越低,模型越好。

❻ 模型在训练集上拟合。

❼ 模型最多可以训练 50 个 epoch,这是由 max_epochs 参数设置的。

❽ 我们使用验证集来计算验证损失。

❾ early_stopping 作为回调传递。如果在连续 3 个 epoch 后验证损失没有降低,则模型停止训练。这避免了过拟合。

这段代码将在深度学习章节中反复使用,因此理解其工作原理非常重要。compile_and_fit 函数接收一个深度学习模型、来自 DataWindow 类的数据窗口、patience 参数和 max_epochs 参数。patience 参数用于 early_stopping 函数,它允许我们在验证损失没有根据 monitor 参数指定的条件改善时停止模型的训练。这样,我们避免了无用的训练时间并防止过拟合。

然后编译模型。在 Keras 中,这仅仅是配置模型以指定要使用的损失函数、优化器和评估指标。在我们的案例中,我们将使用均方误差(MSE)作为损失函数,因为误差是*方的,这意味着模型对预测值和实际值之间的大差异进行了重罚。我们将使用 Adam 优化器,因为它是一种快速高效的优化器。最后,我们将使用*均绝对误差(MAE)作为评估指标来比较我们模型的性能,因为我们已经在上一章中使用它来评估我们的基线模型,并且它很容易解释。

模型将在训练数据上拟合,最多 50 个周期,这是由 max_epochs 参数设置的。验证在验证集上执行,我们传递 early_stopping 作为回调。这样,如果 Keras 在连续 3 个周期后看到验证损失没有下降,它将应用早停。

compile_and_fit 准备就绪后,我们可以继续构建我们的线性模型。我们将使用 Keras 的 Sequential 模型,因为它允许我们堆叠不同的层。由于我们在这里构建的是线性模型,所以我们只有一个层——一个 Dense 层,这是深度学习中最基本的层。我们将指定单元数为 1,因为模型必须输出仅一个值:下一个时间步的交通量预测。

linear = Sequential([
    Dense(units=1)
])

显然,Keras 使得构建模型变得非常容易。完成这一步后,我们可以使用 compile_and_fit 训练模型,并将性能存储起来以供稍后与基线比较。

history = compile_and_fit(linear, single_step_window)

val_performance['Linear'] = linear.evaluate(single_step_window.val)
performance['Linear'] = linear.evaluate(single_step_window.test, verbose=0)

可选地,我们可以使用 wide_windowplot 方法可视化我们线性模型的预测。结果如图 14.2 所示。

wide_window.plot(linear)

图片

图 14.2 使用线性模型作为单步模型预测交通量的结果。预测值(以交叉表示)相当准确,一些预测值与实际值(以正方形表示)重叠。

我们的模型做出了相当好的预测,因为我们可以在预测值和实际值之间观察到一些重叠。我们将等到本章的末尾来比较我们模型的性能与基线。现在,让我们继续实现多步线性模型和多输出线性模型。

14.1.2 实现多步线性模型

我们的单步线性模型已经构建,现在我们可以将其扩展为多步线性模型。回想一下,在多步情况下,我们希望使用 24 小时的数据窗口来预测接下来的 24 小时的数据。我们的目标仍然是交通量。

这个模型将非常类似于单步线性模型,但这次我们将使用 24 小时的输入并输出 24 小时的预测。多步线性模型如图 14.3 所示。如图所示,模型接受每个特征的 24 小时数据,将它们在一个单独的层中组合,并输出一个包含下一个 24 小时预测的张量。

图 14.3 多步线性模型。我们将取每个特征的 24 小时数据,将它们在一个单独的层中组合,并立即输出预测下一个 24 小时的预测。

实现模型很简单,因为我们的模型只包含一个Dense层。我们可以选择性地将权重初始化为 0,这会使训练过程稍微快一些。然后我们编译并拟合模型,在ms_val_performancems_performance中存储其评估指标。

ms_linear = Sequential([
    Dense(1, kernel_initializer=tf.initializers.zeros)    ❶
])

history = compile_and_fit(ms_linear, multi_window)

ms_val_performance['Linear'] = ms_linear.evaluate(multi_window.val)
ms_performance['Linear'] = ms_linear.evaluate(multi_window.test, verbose=0)

❶ 将权重初始化为 0 会使训练稍微快一些。

我们刚刚构建了一个多步线性模型。你可能会觉得有些失望,因为代码几乎与单步线性模型相同。这是由于我们构建了DataWindow类并正确地窗口化我们的数据。完成这一步后,构建模型变得极其容易。

接下来,我们将实现一个多输出线性模型。

14.1.3 实现多输出线性模型

多输出线性模型将返回交通量和温度的预测。输入是当前时间步,预测是下一个时间步。

模型的架构如图 14.4 所示。在那里,你可以看到我们的多输出线性模型将接受所有在t = 0 的特征,将它们在一个单独的层中组合,并在下一个时间步输出温度和交通量。

图 14.4 多输出线性模型。在这种情况下,模型接受所有特征的当前时间步,并预测下一个时间步的温度和交通量。

到目前为止,我们只预测了交通量,这意味着我们只有一个目标,所以我们使用了Dense(units=1)层。在这种情况下,由于我们必须为两个目标输出预测,我们的层将是Dense(units=2)。像以前一样,我们将训练模型并将性能存储起来,以便稍后与基线和深度神经网络进行比较。

mo_linear = Sequential([
    Dense(units=2)         ❶
])

history = compile_and_fit(mo_linear, mo_single_step_window)

mo_val_performance['Linear'] = 
➥ mo_linear.evaluate(mo_single_step_window.val)
mo_performance['Linear'] = mo_linear.evaluate(mo_single_step_window.test, 
➥ verbose=0)

❶ 我们将输出层的预测目标数量设置为单元数。

再次,你可以看到在 Keras 中构建深度学习模型是多么容易,尤其是在我们有适当的数据窗口作为输入时。

在我们的单步、多步和多输出线性模型完成后,我们现在可以继续实现一个更复杂的架构:深度神经网络。

14.2 实现深度神经网络

在实现了三种类型的线性模型之后,现在是时候转向深度神经网络了。经验表明,在神经网络中添加隐藏层有助于实现更好的结果。此外,我们将引入非线性激活函数来捕捉数据中的非线性关系。

线性模型没有隐藏层;模型有一个输入层和一个输出层。在深度神经网络(DNN)中,我们将在输入层和输出层之间添加更多层,称为隐藏层。这种架构上的差异在图 14.5 中得到了强调。

图片

图 14.5 比较线性模型和深度神经网络。在线性模型中,输入层直接连接到输出层,输出层返回一个预测。因此,只推导出线性关系。深度神经网络包含隐藏层。这些层允许它建模输入和预测之间的非线性关系,通常导致更好的模型。

在网络中添加层的想法是它给模型提供了更多的学习机会,这通常会导致模型在未见过的数据上更好地泛化,从而提高其性能。当然,随着层的增加,模型必然需要更长时间来训练,因此应该学习得更好。

隐藏层中的每个圆圈代表一个神经元,每个神经元都有一个激活函数。神经元的数量等于在 Keras 的Dense层中作为参数传递的units的数量。通常,我们将单元数或神经元数设置为 2 的幂,因为这样更高效——CPU 和 GPU 的计算是在 2 的幂大小的批次中发生的。

在实现深度神经网络之前,我们需要解决隐藏层中每个神经元的激活函数问题。激活函数根据输入定义每个神经元的输出。因此,如果我们希望建模非线性关系,我们需要使用非线性激活函数。

激活函数

激活函数位于神经网络中的每个神经元,并负责从输入数据生成输出。

如果使用线性激活函数,模型将只建模线性关系。因此,为了在数据中建模非线性关系,我们必须使用非线性激活函数。非线性激活函数的例子有 ReLU、softmax 或 tanh。

在我们的例子中,我们将使用修正线性单元(ReLU)激活函数。这个非线性激活函数基本上返回其输入的正部分或 0,如方程 14.2 所定义。

f(x) = x^+ = max (0, x)

方程 14.2

这种激活函数具有许多优点,例如更好的梯度传播、更高效的计算和尺度不变性。由于所有这些原因,它现在是深度学习中应用最广泛的激活函数,并且我们将在有 Dense 层作为隐藏层时使用它。

我们现在准备好在 Keras 中实现深度神经网络。

14.2.1 将深度神经网络实现为单步模型

我们现在回到单步模型,但这次我们将实现一个深度神经网络。DNN 接收当前时间步的特征,以输出下一个时间步的交通量预测。

模型仍然使用 Sequential 模型,我们将按顺序堆叠 Dense 层来构建深度神经网络。在这种情况下,我们将使用两个每个有 64 个神经元的隐藏层。如前所述,我们将指定激活函数为 ReLU。最后一层是输出层,在这种情况下,它只返回一个值,表示交通量的预测。

dense = Sequential([
    Dense(units=64, activation='relu'),    ❶
    Dense(units=64, activation='relu'),
    Dense(units=1)                         ❷
])

❶ 第一个隐藏层有 64 个神经元。指定激活函数为 ReLU。

❷ 输出层只有一个神经元,因为我们只输出一个值。

模型定义完成后,我们现在可以编译它、训练它,并记录其性能,以便与基线模型和线性模型进行比较。

history = compile_and_fit(dense, single_step_window)

val_performance['Dense'] = dense.evaluate(single_step_window.val)
performance['Dense'] = dense.evaluate(single_step_window.test, verbose=0)

当然,我们可以通过 plot 方法查看模型的预测,如图 14.6 所示。我们的深度神经网络似乎正在做出相当准确的预测。

图 14.6

图 14.6 使用深度神经网络作为单步模型预测交通量。这里更多的预测(以交叉表示)与实际值(以正方形表示)重叠,表明模型正在做出非常准确的预测。

让我们比较 DNN 与我们在第十三章中构建的线性模型和基线的 MAE。结果如图 14.7 所示。

mae_val = [v[1] for v in val_performance.values()]
mae_test = [v[1] for v in performance.values()]

x = np.arange(len(performance))

fig, ax = plt.subplots()
ax.bar(x - 0.15, mae_val, width=0.25, color='black', edgecolor='black', 
➥ label='Validation')
ax.bar(x + 0.15, mae_test, width=0.25, color='white', edgecolor='black', 
➥ hatch='/', label='Test')
ax.set_ylabel('Mean absolute error')
ax.set_xlabel('Models')

for index, value in enumerate(mae_val):
    plt.text(x=index - 0.15, y=value+0.0025, s=str(round(value, 3)), 
➥ ha='center')

for index, value in enumerate(mae_test):
    plt.text(x=index + 0.15, y=value+0.0025, s=str(round(value, 3)), 
➥ ha='center')

plt.ylim(0, 0.1)
plt.xticks(ticks=x, labels=performance.keys())
plt.legend(loc='best')
plt.tight_layout()

图 14.7

图 14.7 所有单步模型的*均绝对误差(MAE)。线性模型的表现优于基线模型,后者仅预测最后一个已知值。密集模型的表现优于这两个模型,因为它具有最低的 MAE。

在图 14.7 中,基线的 MAE 最高。随着线性模型和深度神经网络的使用,MAE 逐渐降低。因此,这两个模型都优于基线,深度神经网络的表现最好。

14.2.2 将深度神经网络实现为多步模型

现在让我们将深度神经网络实现为多步模型。在这种情况下,我们希望根据最后 24 小时的记录数据预测接下来的 24 小时的交通量。

我们将再次使用两个隐藏层,每个隐藏层有 64 个神经元,并且我们将使用 ReLU 激活函数。由于我们有 24 小时输入的数据窗口,模型也将输出 24 小时的预测;输出层只有一个神经元,因为我们只预测交通量。

ms_dense = Sequential([
    Dense(64, activation='relu'),
    Dense(64, activation='relu'),
    Dense(1, kernel_initializer=tf.initializers.zeros),
])

然后,我们将编译、训练模型,并保存其性能,以便与线性模型和基线模型进行比较。

history = compile_and_fit(ms_dense, multi_window)

ms_val_performance['Dense'] = ms_dense.evaluate(multi_window.val)
ms_performance['Dense'] = ms_dense.evaluate(multi_window.test, verbose=0)

就像那样,我们已经构建了一个多步深度神经网络模型。让我们看看哪个模型在多步任务中表现最佳。结果如图 14.8 所示。

ms_mae_val = [v[1] for v in ms_val_performance.values()]
ms_mae_test = [v[1] for v in ms_performance.values()]

x = np.arange(len(ms_performance))

fig, ax = plt.subplots()
ax.bar(x - 0.15, ms_mae_val, width=0.25, color='black', edgecolor='black', 
➥ label='Validation')
ax.bar(x + 0.15, ms_mae_test, width=0.25, color='white', edgecolor='black', 
➥ hatch='/', label='Test')
ax.set_ylabel('Mean absolute error')
ax.set_xlabel('Models')

for index, value in enumerate(ms_mae_val):
    plt.text(x=index - 0.15, y=value+0.0025, s=str(round(value, 3)), 
➥ ha='center')

for index, value in enumerate(ms_mae_test):
    plt.text(x=index + 0.15, y=value+0.0025, s=str(round(value, 3)), 
➥ ha='center')

plt.ylim(0, 0.4)
plt.xticks(ticks=x, labels=ms_performance.keys())
plt.legend(loc='best')
plt.tight_layout()

图片

图 14.8 至今为止所有多步模型的 MAE。线性模型的表现优于两个基线模型。密集模型优于所有模型。

在图 14.8 中,你会看到线性模型和深度神经网络都优于我们在第十三章为多步任务构建的两个基线模型。同样,深度神经网络具有最低的 MAE,意味着它是目前表现最好的模型。

14.2.3 将深度神经网络实现为多输出模型

最后,我们将实现一个深度神经网络作为多输出模型。在这种情况下,我们将使用当前时间步的特征来预测下一个时间步的交通量和温度。

对于我们之前实现的 DNNs,我们将使用每个隐藏层 64 个神经元的结构。这次,因为我们预测两个目标,所以我们的输出层有两个神经元或units

mo_dense = Sequential([
    Dense(units=64, activation='relu'),
    Dense(units=64, activation='relu'),
    Dense(units=2)                       ❶
])

❶ 输出层有两个神经元,因为我们正在预测两个目标。

接下来,我们将编译和拟合模型,并存储其性能以供比较。

history = compile_and_fit(mo_dense, mo_single_step_window)

mo_val_performance['Dense'] = mo_dense.evaluate(mo_single_step_window.val)
mo_performance['Dense'] = mo_dense.evaluate(mo_single_step_window.test, 
➥ verbose=0)

让我们看看哪个模型在多输出任务中表现最佳。请注意,报告的 MAE 是针对两个目标*均的。

mo_mae_val = [v[1] for v in mo_val_performance.values()]
mo_mae_test = [v[1] for v in mo_performance.values()]

x = np.arange(len(mo_performance))

fig, ax = plt.subplots()
ax.bar(x - 0.15, mo_mae_val, width=0.25, color='black', edgecolor='black', 
➥ label='Validation')
ax.bar(x + 0.15, mo_mae_test, width=0.25, color='white', edgecolor='black', 
➥ hatch='/', label='Test')
ax.set_ylabel('Mean absolute error')
ax.set_xlabel('Models')

for index, value in enumerate(mo_mae_val):
    plt.text(x=index - 0.15, y=value+0.0025, s=str(round(value, 3)), 
➥ ha='center')

for index, value in enumerate(mo_mae_test):
    plt.text(x=index + 0.15, y=value+0.0025, s=str(round(value, 3)), 
➥ ha='center')

plt.ylim(0, 0.06)
plt.xticks(ticks=x, labels=mo_performance.keys())
plt.legend(loc='best')
plt.tight_layout()

正如你在图 14.9 中可以看到的,我们的模型优于基线模型,其中深度学习模型表现最为出色。

图片

图 14.9 至今为止所有多输出模型的 MAE。同样,基线模型具有最高的 MAE,而深度神经网络实现了最低的错误指标。

14.3 下一步

在本章中,我们实现了线性模型和深度神经网络,以进行单步、多步和多输出预测。在所有情况下,深度神经网络都优于其他模型。这通常是情况,因为 DNNs 可以映射特征和目标之间的非线性关系,这通常会导致更准确的预测。

本章仅对深度学习在时间序列预测中可以实现的表面进行了探讨。在下一章中,我们将探索一个更复杂的架构:长短期记忆(LSTM)。这种架构广泛用于处理数据序列。由于时间序列是时间上等间距的点序列,因此对时间序列预测应用 LSTM 是有意义的。然后我们将测试 LSTM 是否优于 DNN。

14.4 练习

在上一章中,作为练习,你构建了基线模型来预测 NO[2]的浓度和温度。现在你将构建线性模型和深度神经网络。这些练习的完整解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH13%26CH14

  1. 对于单步模型:

    1. 构建一个线性模型。

    2. 绘制其预测结果。

    3. 使用*均绝对误差(MAE)来衡量其性能并存储它。

    4. 构建一个深度神经网络(DNN)。

    5. 绘制其预测结果。

    6. 使用 MAE 来衡量其性能并存储它。

    7. 哪个模型表现最好?

  2. 对于多步模型:

    1. 构建一个线性模型。

    2. 绘制其预测结果。

    3. 使用 MAE(均方误差)来衡量其性能并存储它。

    4. 构建一个深度神经网络(DNN)。

    5. 绘制其预测结果。

    6. 使用 MAE 来衡量其性能并存储它。

    7. 哪个模型表现最好?

  3. 对于多输出模型:

    1. 构建一个线性模型。

    2. 绘制其预测结果。

    3. 使用 MAE 来衡量其性能并存储它。

    4. 构建一个深度神经网络(DNN)。

    5. 绘制其预测结果。

    6. 使用 MAE 来衡量其性能并存储它。

    7. 哪个模型表现最好?

在任何时刻,都可以自由运行自己的深度神经网络实验。添加层,更改神经元数量,并观察这些变化如何影响模型性能。

摘要

  • 线性模型是深度学习中 simplest 的架构。它有一个输入层和一个输出层,没有激活函数。

  • 线性模型只能推导出特征和目标之间的线性关系。

  • 深度神经网络(DNN)有隐藏层,这些层位于输入层和输出层之间。增加更多层通常可以提高模型性能,因为它允许模型有更多时间进行训练和学习数据。

  • 要从数据中建模非线性关系,必须在网络中使用非线性激活函数。非线性激活函数的例子有 ReLU、softmax、tanh、sigmoid 等。

  • 隐藏层中的神经元数量通常是 2 的幂,以提高计算效率。

  • 矩形线性单元(ReLU)是一种流行的非线性激活函数,它不随规模变化,并允许高效地训练模型。

15 使用 LSTM 记住过去

本章涵盖

  • 检查长短期记忆(LSTM)架构

  • 使用 Keras 实现 LSTM

在上一章中,我们构建了我们的第一个深度学习模型,实现了线性和深度神经网络模型。在我们的数据集情况下,我们看到了这两个模型都优于我们在第十三章中构建的基线,其中深度神经网络是单步、多步和多输出任务的最好模型。

现在我们将探索一个更高级的架构,称为长短期记忆(LSTM),它是循环神经网络(RNN)的一个特例。这种神经网络用于处理数据序列,其中顺序很重要。RNN 和 LSTM 的一个常见应用是在自然语言处理中。句子中的单词有顺序,改变这个顺序可以完全改变句子的意思。因此,我们经常在文本分类和文本生成算法中找到这种架构。

另一个数据顺序很重要的情况是时间序列。我们知道时间序列是时间上等间隔的数据序列,它们的顺序不能改变。上午 9 点的观测数据点必须在上午 10 点的数据点之前,在上午 8 点的数据点之后。因此,应用 LSTM 架构进行时间序列预测是有意义的。

在本章中,我们将首先探索循环神经网络的一般架构,然后我们将深入探讨 LSTM 架构,并检查其独特的特性和内部工作原理。然后我们将使用 Keras 实现 LSTM,以产生单步、多步和多输出模型。最后,我们将比较 LSTM 与我们所构建的所有模型的性能,从基线到深度神经网络。

15.1 探索循环神经网络(RNN)

循环神经网络(RNN)是一种特别适合处理数据序列的深度学习架构。它表示一组具有相似架构的网络:长短期记忆(LSTM)和门控循环单元(GRU)是 RNN 的子类型。在本章中,我们将专注于 LSTM 架构。

要理解 RNN 的内部工作原理,我们将从图 15.1 开始,它展示了 RNN 的一个紧凑的示意图。就像在深度神经网络(DNN)中一样,我们有一个输入,表示为x[t],和一个输出,表示为y[t]。在这里,x[t]是序列中的一个元素。当它被输入到 RNN 中时,它计算一个隐藏状态,表示为h[t]。这个隐藏状态充当记忆。它为序列中的每个元素计算,并作为输入反馈到 RNN 中。这样,网络有效地使用为序列的前一个元素计算的历史信息来告知下一个元素序列的输出。

图片

图 15.1 RNN 的紧凑示意图。它计算一个隐藏状态 h[t],这个状态在网络中循环并与其他输入序列结合。这就是 RNN 如何从序列的过去元素中保持信息并使用它们来处理序列的下一个元素。

图 15.2 展示了 RNN 的扩展示意图。你可以看到隐藏状态首先在 t = 0 时计算,然后随着序列的每个元素被处理而更新和传递。这就是 RNN 有效地复制记忆概念并使用过去信息产生新输出的方式。

图片

图 15.2 RNN 的扩展示意图。在这里,你可以看到隐藏状态是如何更新并作为输入传递到序列的下一个元素的。

循环神经网络

循环神经网络(RNN)特别适合处理数据序列。它使用一个反馈到网络中的隐藏状态,这样在处理序列的下一个元素时,它可以利用过去的信息作为输入。这就是它复制记忆概念的方式。

然而,RNN 受到短期记忆的限制,这意味着序列中早期元素的信息将停止对序列进一步的影响。

然而,我们检查的基本 RNN 有一个缺点:由于梯度消失,它们受到短期记忆的限制。梯度是告诉网络如何改变权重的函数。如果梯度的变化很大,权重会以很大的幅度改变。另一方面,如果梯度的变化很小,权重不会显著改变。梯度消失问题指的是当梯度变化变得非常小,有时接* 0 时发生的情况。这反过来意味着网络的权重不会更新,网络停止学习。

在实践中,这意味着 RNN 会忘记序列中远离过去的信息。因此,它受到短期记忆的限制。例如,如果一个 RNN 正在处理 24 小时的每小时数据,第 9、10 和 11 小时的数据点可能仍然会影响第 12 小时的结果,但任何在第 9 小时之前的数据点可能根本不会对网络的训练有任何贡献,因为这些早期数据点的梯度变得非常小。

因此,我们必须找到一种方法来保留网络中过去信息的重要性。这把我们带到了长短期记忆(LSTM)架构,它使用细胞状态作为在内存中长时间保持过去信息的额外方式。

15.2 检查 LSTM 架构

长短期记忆(LSTM)架构通过向 RNN 架构添加细胞状态来避免梯度消失问题,即过去信息不再影响网络的训练。这使得网络能够将过去信息保留在内存中更长时间。

LSTM 架构如图 15.3 所示,你可以看到它比基本的 RNN 架构更复杂。你会注意到细胞状态的添加,表示为C。这个细胞状态使得网络能够将过去的信息在网络中保持更长时间,从而解决了梯度消失问题。请注意,这是 LSTM 架构的独特之处。我们仍然有一个正在处理的序列元素,表示为x[t],并且还计算了一个隐藏状态,表示为h[t]。在这种情况下,细胞状态C[t]和隐藏状态h[t]都被传递到序列的下一个元素,确保过去的信息被用作下一个正在处理的序列元素的输入。

图片

图 15.3 LSTM 神经元的架构。细胞状态表示为C,输入表示为x,隐藏状态表示为h

你还会注意到存在三个门:遗忘门、输入门和输出门。每个门在 LSTM 中都有其特定的功能,所以让我们详细探讨每一个。

长短期记忆

长短期记忆(LSTM)是一种深度学习架构,它是循环神经网络(RNN)的一个子类型。LSTM 通过添加细胞状态来解决短期记忆问题。这使得过去的信息可以在网络中流动更长的时间,这意味着网络仍然携带序列早期值的信息。

LSTM 由三个门组成:

  • 遗忘门决定了哪些过去步骤的信息仍然相关。

  • 输入门决定了哪些当前步骤的信息是相关的。

  • 输出门决定了哪些信息被传递到序列的下一个元素或作为结果传递到输出层。

15.2.1 遗忘门

遗忘门是 LSTM 单元中的第一个门。它的作用是确定从过去值和当前值中哪些信息应该被遗忘或保留在网络中。

图片

图 15.4 LSTM 单元中的遗忘门。序列的当前元素x[t]和过去信息h[t–1]首先被组合。它们被复制,一个被发送到输入门,另一个通过 sigmoid 激活函数。sigmoid 输出一个介于 0 和 1 之间的值,如果输出接* 0,这意味着信息必须被遗忘。如果它接* 1,信息则被保留。然后,输出与过去的细胞状态通过点乘结合,生成一个更新的细胞状态C'[t–1]。

查看图 15.4,我们可以看到不同的输入是如何通过遗忘门的。首先,过去的隐藏状态 h[t–1] 和序列的当前值 x[t] 被输入到遗忘门中。回想一下,过去的隐藏状态携带了来自过去值的信息。然后,h[t–1] 和 x[t] 被组合并复制。一份直接进入输入门,我们将在下一节中研究它。另一份通过一个 Sigmoid 激活函数,这被表示为方程 15.1,并在图 15.5 中展示。

方程 15.1

图 15.5 Sigmoid 函数输出介于 0 和 1 之间的值。在遗忘门的上下文中,如果 Sigmoid 函数的输出接* 0,则输出是即将被遗忘的信息。如果输出接* 1,则是必须保留的信息。

Sigmoid 函数确定要保留或遗忘的信息。然后,这个输出与之前的细胞状态 C[t–1] 通过逐点乘法结合。这导致了一个更新的细胞状态,我们称之为 C'[t–1]。

完成这些后,将两样东西发送到输入门:一个更新的细胞状态,以及过去隐藏状态和当前序列元素的组合的副本。

15.2.2 输入门

信息通过遗忘门后,继续进入输入门。这是网络确定当前序列元素中哪些信息相关的步骤。在这里再次更新细胞状态,得到最终的细胞状态。

图 15.6 LSTM 的输入门。过去的隐藏状态和序列的当前元素再次被复制并通过 Sigmoid 激活函数和双曲正切(tanh)激活函数发送。同样,Sigmoid 确定要保留或丢弃的信息,而 tanh 函数调节网络以保持计算效率。这两个操作的结果通过逐点乘法结合,然后通过逐点加法更新细胞状态,得到最终的细胞状态 C[t]。这个最终的细胞状态随后被发送到输出门。同时,相同的组合 [h[t–1]+ x[t]] 也被发送到输出门。

再次,让我们通过图 15.6 来聚焦于输入门。来自遗忘门的过去隐藏状态和序列当前元素的组合 [h[t–1] + x[t]] 被输入到输入门,并且再次被复制。一份从输入门输出,朝向输出门,我们将在下一节中探讨。另一份通过 Sigmoid 激活函数来确定信息是否将被保留或遗忘。另一份通过双曲正切(tanh)函数,这在图 15.7 中展示。

图 15.7 双曲正切(tanh)函数输出介于 –1 和 1 之间的值。在 LSTM 的上下文中,这作为调节网络的一种方式,确保值不会变得非常大,并确保计算效率。

使用逐点乘法将 sigmoid 和 tanh 函数的输出结合起来,然后将结果与来自遗忘门的更新后的细胞状态 C'[t–1] 使用逐点加法结合起来。这个操作生成了最终的细胞状态 C[t]

因此,我们在输入门中添加序列当前元素的信息到网络的长时记忆中。这个新更新的细胞状态随后被发送到输出门。

15.2.3 输出门

信息现在已经从遗忘门传递到输入门,现在它到达输出门。正是在这个门中,网络内存中包含的过去信息,由细胞状态 C[t] 表示,最终被用来处理序列的当前元素。这也是网络输出结果到输出层或计算新信息以发送到序列下一个元素处理的地方。

图片

图 15.8 LSTM 的输出门。序列的过去隐藏状态和当前元素 [h[t–1] + x[t]] 通过 sigmoid 函数传递,以确定信息是否将被保留或丢弃。然后细胞状态通过 tanh 函数传递,并与 sigmoid 的输出使用逐点乘法结合。这是使用过去信息处理序列当前元素的一步。然后我们输出一个新的隐藏状态 h[t],它被传递到下一个 LSTM 神经元或输出层。细胞状态也被输出。

在图 15.8 中,过去隐藏状态和序列的当前元素通过 sigmoid 函数。同时,细胞状态通过 tanh 函数。然后使用逐点乘法将 tanh 和 sigmoid 函数的结果结合起来,生成一个更新的隐藏状态 h[t]。这是使用表示过去信息的细胞状态 C[t] 来处理序列当前元素信息的一步。

然后将当前的隐藏状态从输出门发送出去。这将被发送到网络的输出层,或者发送到下一个 LSTM 神经元处理序列的下一个元素。对于细胞状态 C[t] 也适用。

总结来说,遗忘门决定了哪些过去信息被保留或丢弃。输入门决定了哪些当前步骤的信息被保留以更新网络的内存或被丢弃。最后,输出门使用存储在网络内存中的过去信息来处理序列的当前元素。

经过检查 LSTM 架构的内部工作原理,我们现在可以将其应用于我们的州际交通数据集。

15.3 实现 LSTM 架构

现在,我们将实现自第十二章以来一直在使用的州际交通数据集的 LSTM 架构。回想一下,我们场景的主要目标是交通量。对于多输出模型,目标是交通量和温度。

我们将实现 LSTM 作为单步模型、多步模型和多输出模型。单步模型将只预测下一个时间步长的交通量,多步模型将预测下一个 24 小时的交通量,多输出模型将预测下一个时间步长的温度和交通量。

确保您的笔记本或 Python 脚本中包含DataWindow类和compile_and_fit函数(来自第十三章和第十四章),因为我们将使用这些代码片段来创建数据窗口并训练 LSTM 模型。

另一个先决条件是读取训练集、验证集和测试集,所以让我们现在就做:

train_df = pd.read_csv('../data/train.csv', index_col=0)
val_df = pd.read_csv('../data/val.csv', index_col=0)
test_df = pd.read_csv('../data/test.csv', index_col=0)

注意:在任何时候,您都可以自由地查阅 GitHub 上本章的源代码:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH15

15.3.1 实现作为单步模型的 LSTM

我们将首先实现 LSTM 架构作为单步模型。在这种情况下,我们将使用 24 小时的数据作为输入来预测下一个时间步长。这样,就有一个时间序列可以被 LSTM 处理,使我们能够利用过去的信息来做出未来的预测。

首先,我们需要创建一个数据窗口来训练模型。这将是一个宽窗口,包含 24 小时的数据作为输入。为了绘图目的,label_width也是 24,这样我们就可以在 24 个时间步长内比较预测值和实际值。请注意,这仍然是一个单步模型,所以 24 小时内模型将一次只预测一个时间步长,就像滚动预测一样。

wide_window = DataWindow(input_width=24, label_width=24, shift=1, 
➥ label_columns=['traffic_volume'])

然后,我们需要在 Keras 中定义我们的 LSTM 模型。我们再次使用Sequential模型,以便我们可以在网络中堆叠不同的层。Keras 方便地提供了LSTM层,它实现了 LSTM。我们将return_sequences设置为True,因为这会指示 Keras 使用序列中的过去信息,即我们之前提到的隐藏状态和细胞状态。最后,我们将定义输出层,它只是一个具有一个单位的Dense层,因为我们只预测交通量。

lstm_model = Sequential([
    LSTM(32, return_sequences=True),     ❶
    Dense(units=1)
])

❶ 将return_sequences设置为True以确保网络正在使用过去的信息。

就这么简单。现在我们可以使用compile_and_fit函数来训练模型,并将其在验证集和测试集上的性能存储起来。

history = compile_and_fit(lstm_model, wide_window)

val_performance = {}
performance = {}

val_performance['LSTM'] = lstm_model.evaluate(wide_window.val)
performance['LSTM'] = lstm_model.evaluate(wide_window.test, verbose=0)

可选地,我们可以使用数据窗口的plot方法可视化模型在三个采样序列上的预测结果。结果如图 15.9 所示。

wide_window.plot(lstm_model)

图片

图 15.9 展示了使用 LSTM 作为单步模型预测交通量。许多预测(以交叉表示)与标签(以正方形表示)重叠,这表明我们有一个性能良好且预测准确的模型。

图 15.9 显示,我们有一个性能良好的模型,能够生成准确的预测。当然,这个可视化只是三个 24 小时序列的样本,所以让我们可视化模型在整个验证集和测试集上的性能,并将其与我们迄今为止构建的先前模型进行比较。

图 15.10 显示了迄今为止构建的所有单步模型的*均绝对误差(MAE)。目前,LSTM 是获胜模型,因为它在验证集和测试集上都具有最低的 MAE。

图 15.10 显示,LSTM 是获胜模型,因为它在验证集和测试集上都具有最低的 MAE,这意味着它生成了所有模型中最准确的预测。

15.3.2 实现一个多步 LSTM 模型

我们将继续实现 LSTM 架构作为多步模型。在这种情况下,我们希望使用 24 小时的输入窗口来预测未来 24 小时的交通量。

首先,我们将定义输入模型的时间窗口。input_widthlabel_width都是 24,因为我们想输入 24 小时的数据,并评估 24 小时的数据预测。这次shift也是 24,指定模型必须一次性输出对未来 24 小时的预测。

multi_window = DataWindow(input_width=24, label_width=24, shift=24, 
➥ label_columns=['traffic_volume'])

接下来,我们将使用 Keras 定义我们的模型。从第十四章,您可能还记得定义多步模型和单步模型的过程是完全相同的。这里也是一样。我们仍然使用Sequential模型,以及LSTM层和一个单元的Dense输出层。

ms_lstm_model = Sequential([
    LSTM(32, return_sequences=True),
    Dense(1, kernel_initializer=tf.initializers.zeros),
])

一旦定义了模型,我们将对其进行训练并存储其评估指标以供比较。到目前为止,您应该已经熟悉了这个工作流程。

history = compile_and_fit(ms_lstm_model, multi_window)

ms_val_performance = {}
ms_performance = {}

ms_val_performance['LSTM'] = ms_lstm_model.evaluate(multi_window.val)
ms_performance['LSTM'] = ms_lstm_model.evaluate(multi_window.test, 
➥ verbose=0)

我们可以使用plot方法可视化模型的预测,如图 15.11 所示。

multi_window.plot(ms_lstm_model)

图 15.11 展示了使用多步 LSTM 模型预测未来 24 小时的交通量。我们可以看到预测和标签之间存在一些差异。当然,这种视觉检查不足以评估模型的性能。

在图 15.11 中,您会看到对顶部序列的预测非常好,因为大多数预测与实际值重叠。然而,在底部两个序列的输出和标签之间存在一些差异。让我们将其 MAE 与其他我们构建的多步模型进行比较。

图 15.12 显示了迄今为止构建的所有多步模型的 MAE。同样,LSTM 是获胜模型,因为它在验证集和测试集上都实现了最低的 MAE。

如您在图 15.12 中可以看到,LSTM 是我们迄今为止最精确的模型,因为它在验证集和测试集上都实现了最低的 MAE。

15.3.3 实现作为多输出模型的 LSTM

最后,我们将实现一个 LSTM 作为多输出模型。同样,我们将使用 24 小时的输入数据,这样网络就可以处理数据点序列并使用过去的信息来生成预测。预测将是下一个时间步的交通量和温度。

在这种情况下,数据窗口由 24 个时间步长的输入和 24 个时间步长的标签组成。shift 为 1,因为我们只想预测下一个时间步。因此,我们的模型将创建滚动预测,一次生成一个时间步的预测,共 24 个时间步。我们将 temp 和 traffic_volume 作为我们的目标列。

mo_wide_window = DataWindow(input_width=24, label_width=24, shift=1, 
➥ label_columns=['temp','traffic_volume'])

下一步是定义我们的 LSTM 模型。就像之前一样,我们将使用 Sequential 模型堆叠一个 LSTM 层和一个具有两个单位的 Dense 输出层,因为我们有两个目标。

mo_lstm_model = Sequential([
    LSTM(32, return_sequences=True),
    Dense(units = 2)   ❶
])

❶ 我们有两个单位,因为我们有两个目标:温度和交通量。

然后,我们将训练模型并存储其性能指标以进行比较。

history = compile_and_fit(mo_lstm_model, mo_wide_window)

mo_val_performance = {}
mo_performance = {}

mo_val_performance['LSTM'] = mo_lstm_model.evaluate(mo_wide_window.val)
mo_performance['LSTM'] = mo_lstm_model.evaluate(mo_wide_window.test, 
➥ verbose=0)

我们现在可以可视化交通量(图 15.13)和温度(图 15.14)的预测。这两个图都显示了大量的预测(以交叉表示)与标签(以正方形表示)重叠,这意味着我们有一个性能良好的模型,能够生成准确的预测。

图片

图 15.13 使用 LSTM 作为多输出模型预测交通量。许多预测(以交叉表示)与标签(以正方形表示)重叠,表明对交通量的预测非常准确。

图片

图 15.14 使用 LSTM 作为多输出模型预测温度。同样,我们看到预测(以交叉表示)和标签(以正方形表示)之间有很多重叠,这表明预测是准确的。

让我们比较我们的 LSTM 模型的性能与其他到目前为止构建的多输出模型。图 15.15 再次显示 LSTM 是获胜模型,因为它在验证集和测试集上实现了最低的 MAE。因此,它为我们两个目标生成了迄今为止最准确的预测。

图片

图 15.15 到目前为止构建的所有多输出模型的*均绝对误差(MAE)。同样,获胜模型是 LSTM,因为它实现了所有模型中最低的 MAE。

15.4 下一步

在本章中,我们研究了长短期记忆(LSTM)架构。你了解到它是一种 RNN 的子类型,并看到了它是如何使用细胞状态来克服基本 RNN 中出现的短期记忆问题的。

我们还研究了 LSTM 的三个门。遗忘门确定哪些过去和现在的信息必须保留,输入门确定序列当前元素的相关信息,输出门使用存储在内存中的信息来生成预测。

我们随后实现了 LSTM 作为单步模型、多步模型和多输出模型。在所有情况下,LSTM 都是获胜模型,因为它实现了迄今为止所有构建的模型中最低的 MAE。

我们将在下一章中探讨的深度学习架构是卷积神经网络(CNN)。你可能在计算机视觉中遇到过 CNN,因为它是一种非常流行的用于分析图片的架构。我们将将其应用于时间序列预测,因为 CNN 比 LSTM 训练更快,对噪声鲁棒,并且是好的特征提取器。

15.5 练习

在上一章中,我们构建了线性模型和深度神经网络来预测空气质量。现在我们将尝试 LSTM 模型,看看是否在性能上有提升。这些练习的解决方案可以在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH15

  1. 对于单步模型:

    1. 构建一个 LSTM 模型。

    2. 绘制其预测。

    3. 使用 MAE 评估它并存储 MAE。

    4. 它是最有效的模型吗?

  2. 对于多步模型:

    1. 构建一个 LSTM 模型。

    2. 绘制其预测。

    3. 使用 MAE 评估它并存储 MAE。

    4. 它是最有效的模型吗?

  3. 对于多输出模型:

    1. 构建一个 LSTM 模型。

    2. 绘制其预测。

    3. 使用 MAE 评估它并存储 MAE。

    4. 它是最有效的模型吗?

在任何时候,尝试以下想法进行实验:

  • 添加更多的LSTM层。

  • 改变LSTM层的单元数量。

  • return_sequences设置为False

  • 在输出Dense层尝试不同的初始化器。

  • 进行尽可能多的实验,并观察它们对误差指标的影响。

摘要

  • 循环神经网络(RNN)是一种特别适合处理时间序列等数据序列的深度学习架构。

  • RNN 使用隐藏状态在内存中存储信息。然而,由于梯度消失问题,这仅是短期记忆。

  • 长短期记忆(LSTM)是一种解决短期记忆问题的 RNN。它使用细胞状态来存储信息更长时间,给网络一个的记忆。

  • LSTM 由三个门组成:

    • 遗忘门决定哪些过去和现在的信息必须被保留。

    • 输入门决定哪些现在的信息必须被保留。

    • 输出门使用存储在内存中的信息来处理序列的当前元素。

16 使用 CNN 过滤时间序列

本章涵盖

  • 检查卷积神经网络(CNN)架构

  • 使用 Keras 实现 CNN

  • 结合 CNN 和 LSTM

在上一章中,我们检查并实现了一个长短期记忆(LSTM)网络,这是一种处理数据序列特别好的循环神经网络(RNN)。它的实现是单步模型、多步模型和多输出模型中表现最好的架构。

现在我们将探索 卷积神经网络 (CNN)。CNN 主要应用于计算机视觉领域,这种架构是许多图像分类和图像分割算法背后的基础。

当然,这种架构也可以用于时间序列分析。结果证明,CNN 具有抗噪声能力,可以通过 卷积 操作有效地过滤时间序列中的噪声。这使得网络能够生成一组稳健的特征,不包含异常值。此外,CNN 通常比 LSTM 训练得更快,因为它们的操作可以并行化。

在本章中,我们将首先探索 CNN 架构,了解网络如何过滤时间序列并创建一组独特的特征。然后我们将使用 Keras 实现一个 CNN 来生成预测。我们还将结合 CNN 架构和 LSTM 架构,看看我们是否可以进一步提高我们深度学习模型的表现。

16.1 检查卷积神经网络(CNN)

卷积神经网络是一种利用卷积操作的深度学习架构。卷积操作允许网络创建一组减少的特征。因此,这是一种正则化网络、防止过拟合和有效过滤输入的方法。当然,为了使这有意义,你必须首先了解卷积操作及其对输入的影响。

从数学的角度来看,卷积是两个函数之间的操作,它生成一个第三函数,该函数表达了其中一个函数的形状如何被另一个函数改变。在 CNN 中,这种操作发生在输入和一个 (也称为 滤波器)之间。核只是一个放置在特征矩阵上的矩阵。在图 16.1 中,核沿着时间轴滑动,计算核与特征之间的点积。这导致特征集的减少,实现了正则化和异常值的过滤。

图片

图 16.1 展示核和特征图。核是应用在特征图上的浅灰色矩阵。每一行对应于数据集的一个特征,而长度是时间轴。

为了更好地理解卷积操作,让我们考虑一个只有一个特征和一个核的简单示例,如图 16.2 所示。为了简化问题,我们只考虑一个特征行。记住,水*轴仍然是时间维度。核是一个较小的向量,用于执行卷积操作。不用担心核和特征向量内部使用的值。它们是任意值。核的值是经过优化的,并且随着网络的训练而改变。

图片

图 16.2 一个特征行和一个核的简单示例。

我们可以在图 16.3 中可视化卷积操作及其结果。起初,核与特征向量的开始对齐,并与与之对齐的特征向量值进行点积。一旦完成,核向右移动一个时间步长——这也可以称为一个时间步长的步长。然后再次在核和特征向量之间进行点积,这次只与核对齐的值。核再次向右移动一个时间步长,这个过程重复进行,直到核到达特征向量的末尾。当核无法再进一步移动,且所有值都与其对齐的特征值对齐时,这种情况发生。

图片

图 16.3 完整的卷积操作。操作从第 1 步中核与特征向量开始对齐开始。第 1 步的计算如中间方程所示,得到输出向量的第一个值。在第 2 步中,核向右移动一个时间步长,再次进行点积,得到输出向量的第二个值。这个过程重复两次,直到核到达特征向量的末尾。

在图 16.3 中,你可以看到使用长度为 6 的特征向量和长度为 3 的核,我们得到一个长度为 4 的输出向量。因此,一般来说,卷积的输出向量长度由方程 16.1 给出。

输出长度 = 输入长度 - 核长度 + 1

方程 16.1

注意,由于核只在一个方向上移动(向右),这是一个1D 卷积。幸运的是,Keras 自带Conv1D层,允许我们轻松地在 Python 中实现它。这主要用于时间序列预测,因为核只能在时间维度上移动。对于图像处理,你经常会看到 2D 或 3D 卷积,但这超出了本书的范围。

卷积层减少了特征集的长度,进行多次卷积将不断减少特征空间。这可能会成为问题,因为它限制了网络中的层数,我们可能在过程中丢失太多信息。防止这种情况的常见技术是填充。填充简单地说就是在特征向量前后添加值,以保持输出长度与输入长度相同。填充值通常是零。你可以在图 16.4 中看到这一点,其中卷积的输出长度与输入相同。

图片

图 16.4 带填充的卷积。在这里,我们用黑色方块将原始输入向量填充为零。因此,卷积的输出长度为 6,与原始特征向量相同。

因此,你可以看到填充是如何保持输出维度恒定的,这使我们能够堆叠更多的卷积层,并允许网络处理更长时间的特征。我们使用零进行填充,因为乘以零会被忽略。因此,通常使用零作为填充值是一个很好的初始选项。

卷积神经网络(CNN)

卷积神经网络(CNN)是一种深度学习架构,它使用卷积操作。这使得网络能够减少特征空间,有效地过滤输入并防止过拟合。

卷积是通过内核进行的,内核在模型拟合期间也会被训练。内核的步长决定了它在卷积的每一步中移动的步数。在时间序列预测中,仅使用 1D 卷积。

为了避免过快地减少特征空间,我们可以使用填充,即在输入向量前后添加零。这保持了输出维度与原始特征向量相同,使我们能够堆叠更多的卷积层,这反过来又允许网络处理更长时间的特征。

现在你已经了解了卷积神经网络(CNN)的内部工作原理,我们可以使用 Keras 来实现它,看看 CNN 能否比我们迄今为止构建的模型产生更准确的预测。

16.2 实现 CNN

与前几章一样,我们将实现 CNN 架构作为单步模型、多步模型和多输出模型。单步模型将仅预测下一个时间步的流量,多步模型将预测未来 24 小时的流量,而多输出模型将预测下一个时间步的温度和流量。

确保你的笔记本或 Python 脚本中有DataWindow类和compile_and_fit函数(来自第十三章到第十五章),因为我们将使用这两段代码来创建数据窗口并训练 CNN 模型。

注意:本章的源代码可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH16

在本章中,我们还将 CNN 架构与 LSTM 架构相结合。看看使用卷积层过滤我们的时间序列,然后用 LSTM 处理过滤后的序列是否会提高我们预测的准确性,这可能会很有趣。因此,我们将实现一个仅 CNN 的模型,以及一个 CNN 与 LSTM 的组合。

当然,另一个先决条件是阅读训练集、验证集和测试集,所以让我们现在就做这件事。

train_df = pd.read_csv('../data/train.csv', index_col=0)
val_df = pd.read_csv('../data/val.csv', index_col=0)
test_df = pd.read_csv('../data/test.csv', index_col=0)

最后,我们在 CNN 实现中将核长度设置为三个时间步。这是一个任意值,你将在本章的练习中有机会尝试各种核长度,并看到它们如何影响模型的表现。然而,你的核长度应该大于 1;否则,你只是在特征空间上乘以一个标量,不会实现任何过滤。

16.2.1 实现单步 CNN 模型

我们将首先实现一个单步 CNN 模型。回想一下,单步模型使用最后一个已知特征来预测下一个时间步的交通量。

然而,在这种情况下,只向 CNN 模型提供一个时间步作为输入是不合理的,因为我们想运行卷积。我们将使用三个输入值来生成对下一个时间步的预测。这样我们就会有一个可以运行卷积操作的数据序列。此外,我们的输入序列长度至少应该等于核的长度,在我们的例子中是 3。回想一下,我们在方程 16.1 中表达了输入长度、核长度和输出长度之间的关系:

输出长度 = 输入长度 - 核长度 + 1

在这个方程中,没有任何长度可以等于 0,因为这意味着没有数据处理或输出。只有当输入长度大于或等于核长度时,长度不能为 0 的条件才成立。因此,我们的输入序列必须至少有三个时间步。

因此,我们可以定义用于训练模型的数据窗口。

KERNEL_WIDTH = 3

conv_window = DataWindow(input_width=KERNEL_WIDTH, label_width=1, shift=1, 
➥ label_columns=['traffic_volume']) 

为了绘图目的,我们希望看到模型在 24 小时内的预测。这样,我们可以逐个时间步评估模型的滚动预测,共 24 个时间步。因此,我们需要定义另一个具有label_width为 24 的数据窗口。shift保持为 1,因为模型只预测下一个时间步。输入长度是通过将方程 16.1 重新排列为方程 16.2 来获得的。

输出长度 = 输入长度 - 核长度 + 1

输入长度 = 输出长度 + 核长度 - 1

方程 16.2

我们现在可以简单地计算生成 24 个时间步序列预测所需的输入长度。在这种情况下,输入长度是 24 + 3 - 1 = 26。这样,我们就可以避免使用填充。稍后,在练习中,你将能够尝试使用填充而不是更长的输入序列来适应输出长度。

我们现在可以定义我们的数据窗口,以便绘制模型的预测。

LABEL_WIDTH = 24
INPUT_WIDTH = LABEL_WIDTH + KERNEL_WIDTH– 1      ❶

wide_conv_window = DataWindow(input_width=INPUT_WIDTH, 
➥ label_width=LABEL_WIDTH, shift=1, label_columns=['traffic_volume'])

❶ 来自方程 16.2

在所有数据窗口准备就绪后,我们可以定义我们的 CNN 模型。同样,我们将使用 Keras 的Sequential模型来堆叠不同的层。然后我们将使用Conv1D层,因为我们正在处理时间序列,核只移动在时间维度上。filters参数等同于Dense层的units参数,它简单地表示卷积层中的神经元数量。我们将kernel_size设置为核的宽度,即 3。我们不需要指定其他维度,因为 Keras 会自动取正确的形状以适应输入。然后我们将 CNN 的输出传递到一个Dense层。这样,模型将在之前由卷积步骤过滤的减少特征集上学习。我们最终将使用只有一个单位的Dense层输出预测,因为我们只预测下一个时间步的交通量。

cnn_model = Sequential([
    Conv1D(filters=32,                    ❶
          kernel_size=(KERNEL_WIDTH,),    ❷
          activation='relu'),
    Dense(units=32, activation='relu'),
    Dense(units=1)
])

❶ 过滤器参数等同于密集层的units参数;它定义了卷积层的神经元数量。

❷ 核宽被指定,但其他维度被省略,因为 Keras 会自动适应输入的形状。

接下来,我们将编译和拟合模型,并将存储其性能指标以供后续比较。

history = compile_and_fit(cnn_model, conv_window)

val_performance = {}
performance = {}

val_performance['CNN'] = cnn_model.evaluate(conv_window.val)
performance['CNN'] = cnn_model.evaluate(conv_window.test, verbose=0)

我们可以使用数据窗口的plot方法可视化预测与标签。结果如图 16.5 所示。

wide_conv_window.plot(cnn_model)

图 16.5 使用 CNN 作为单步模型预测交通量。该模型以三个值作为输入,这就是为什么我们只在第四个时间步看到预测。同样,许多预测(以十字表示)与标签(以正方形表示)重叠,这意味着模型相当准确。

如您在图 16.5 中看到的,许多预测与标签重叠,这意味着我们的预测相当准确。当然,我们必须将这个模型的性能指标与其他模型的性能指标进行比较,以正确评估其性能。

在做那之前,让我们将 CNN 和 LSTM 架构组合成一个单一模型。您在前一章中看到 LSTM 架构产生了迄今为止性能最好的模型。因此,一个合理的假设是在将输入序列馈送到 LSTM 之前过滤它可能会提高性能。

因此,我们将使用两个 LSTM 层来跟随Conv1D层。这是一个任意的选择,所以请确保您稍后进行实验。构建模型很少只有一种好的方法,因此展示可能的方法是很重要的。

cnn_lstm_model = Sequential([
    Conv1D(filters=32,
          kernel_size=(KERNEL_WIDTH,),
          activation='relu'),
    LSTM(32, return_sequences=True),
    LSTM(32, return_sequences=True),
    Dense(1)
]) 

然后,我们将拟合模型并存储其评估指标。

history = compile_and_fit(cnn_lstm_model, conv_window)

val_performance['CNN + LSTM'] = cnn_lstm_model.evaluate(conv_window.val)
performance['CNN + LSTM'] = cnn_lstm_model.evaluate(conv_window.test, 
➥ verbose=0)

在构建并评估了两个模型之后,我们可以查看图 16.6 中我们新构建模型的 MAE。如图所示,CNN 模型并没有比 LSTM 模型表现得更好,而且 CNN 和 LSTM 的组合比单独的 CNN 产生了略高的 MAE。

图 16.6

图 16.6 到目前为止构建的所有单步模型的*均绝对误差(MAE)。你可以看到,CNN 并没有在 LSTM 的性能上有所提升。将 CNN 与 LSTM 结合使用也没有帮助,而且这种组合的性能甚至略低于 CNN。

这些结果可能可以由输入序列的长度来解释。模型只得到了三个值的输入序列,这可能不足以让 CNN 提取对预测有价值的特征。虽然 CNN 比基线模型和线性模型更好,但 LSTM 仍然是目前表现最好的单步模型。

16.2.2 将卷积神经网络(CNN)实现为多步模型

现在,我们将继续进行多步模型的讨论。在这里,我们将使用最后已知的 24 小时来预测接下来的 24 小时的交通量。

再次提醒,卷积操作会减少特征长度,但我们仍然期望模型能够一次性生成 24 个预测。因此,我们将重用方程 16.2,并给模型提供一个长度为 26 的输入序列,以确保我们得到长度为 24 的输出。这当然意味着我们将保持核长度为 3。因此,我们可以为多步模型定义我们的数据窗口。

KERNEL_WIDTH = 3
LABEL_WIDTH = 24
INPUT_WIDTH = LABEL_WIDTH + KERNEL_WIDTH - 1

multi_window = DataWindow(input_width=INPUT_WIDTH, label_width=LABEL_WIDTH, 
➥ shift=24, label_columns=['traffic_volume'])

接下来,我们将定义 CNN 模型。同样,我们将使用Sequential模型,其中我们将堆叠Conv1D层,然后是一个具有 32 个神经元的Dense层,接着是一个只有一个单位的Dense层,因为我们只预测交通量。

ms_cnn_model = Sequential([
    Conv1D(32, activation='relu', kernel_size=(KERNEL_WIDTH)),
    Dense(units=32, activation='relu'),
    Dense(1, kernel_initializer=tf.initializers.zeros),
])

然后,我们可以训练模型并存储其性能指标以供后续比较。

history = compile_and_fit(ms_cnn_model, multi_window)

ms_val_performance = {}
ms_performance = {}

ms_val_performance['CNN'] = ms_cnn_model.evaluate(multi_window.val)
ms_performance['CNN'] = ms_cnn_model.evaluate(multi_window.test, verbose=0)

可选地,我们可以使用multi_window .plot(ms_cnn_model)可视化模型的预测结果。目前,让我们跳过这一步,并将 CNN 架构与 LSTM 架构结合,如之前所述。在这里,我们将简单地用LSTM层替换中间的Dense层。一旦模型定义完成,我们就可以对其进行拟合并存储其性能指标。

ms_cnn_lstm_model = Sequential([
    Conv1D(32, activation='relu', kernel_size=(KERNEL_WIDTH)),
    LSTM(32, return_sequences=True),
    Dense(1, kernel_initializer=tf.initializers.zeros),
])

history = compile_and_fit(ms_cnn_lstm_model, multi_window)
ms_val_performance['CNN + LSTM'] = 
➥ ms_cnn_lstm_model.evaluate(multi_window.val)
ms_performance['CNN + LSTM'] = 
➥ ms_cnn_lstm_model.evaluate(multi_window.test, verbose=0)

在训练并评估了两个新模型之后,我们可以评估它们与迄今为止构建的所有多步模型的性能。如图 16.7 所示,CNN 模型并没有在 LSTM 模型上有所改进。然而,将两个模型结合起来,结果产生了所有多步模型中最低的 MAE,这意味着它产生了最准确的预测。因此,LSTM 模型被取代,我们有一个新的获胜模型。

图 16.7

图 16.7 到目前为止构建的所有多步模型的*均绝对误差(MAE)。由于 CNN 模型的 MAE 更高,因此其性能不如 LSTM 模型。然而,将 CNN 与 LSTM 结合使用,结果产生了所有模型中最低的 MAE。

16.2.3 将卷积神经网络(CNN)实现为多输出模型

最后,我们将实现 CNN 架构作为多输出模型。在这种情况下,我们希望仅预测下一个时间步的温度和交通量。

我们已经看到,长度为 3 的输入序列对于 CNN 模型提取有意义特征是不够的,因此我们将使用与多步模型相同的输入长度。然而,这次我们将在 24 个时间步中逐个时间步预测。

我们将定义我们的数据窗口如下:

KERNEL_WIDTH = 3
LABEL_WIDTH = 24
INPUT_WIDTH = LABEL_WIDTH + KERNEL_WIDTH - 1

wide_mo_conv_window = DataWindow(input_width=INPUT_WIDTH, label_width=24, 
➥ shift=1, label_columns=['temp', 'traffic_volume'])

到现在为止,你应该已经熟悉了使用 Keras 构建模型,因此定义 CNN 架构作为多输出模型应该是直接的。再次强调,我们将使用Sequential模型,其中我们将堆叠一个Conv1D层,然后是一个Dense层,允许网络在一系列过滤特征上学习。输出层将有两个神经元,因为我们正在预测温度和交通量。接下来我们将拟合模型并存储其性能指标。

mo_cnn_model = Sequential([
    Conv1D(filters=32, kernel_size=(KERNEL_WIDTH,), activation='relu'),
    Dense(units=32, activation='relu'),
    Dense(units=2)
])

history = compile_and_fit(mo_cnn_model, wide_mo_conv_window)

mo_val_performance = {}
mo_performance = {}

mo_val_performance['CNN'] = mo_cnn_model.evaluate(wide_mo_conv_window.val)
mo_performance['CNN'] = mo_cnn_model.evaluate(wide_mo_conv_window.test, 
➥ verbose=0)

我们还可以像之前那样将 CNN 架构与 LSTM 架构相结合。我们将简单地用LSTM层替换中间的Dense层,拟合模型并存储其指标。

mo_cnn_lstm_model = Sequential([
    Conv1D(filters=32, kernel_size=(KERNEL_WIDTH,), activation='relu'),
    LSTM(32, return_sequences=True),
    Dense(units=2)
])

history = compile_and_fit(mo_cnn_lstm_model, wide_mo_conv_window)

mo_val_performance['CNN + LSTM'] = 
➥ mo_cnn_model.evaluate(wide_mo_conv_window.val)
mo_performance['CNN + LSTM'] = 
➥ mo_cnn_model.evaluate(wide_mo_conv_window.test, verbose=0)

如往常一样,我们将在图 16.8 中比较新模型与之前的多输出模型的性能。你会注意到,CNN 以及 CNN 和 LSTM 的组合并没有在 LSTM 模型上带来改进。事实上,所有三个模型都实现了相同的*均绝对误差(MAE)。

图片

图 16.8 迄今为止构建的所有多输出模型的*均绝对误差(MAE)。如图所示,CNN 以及 CNN 和 LSTM 的组合并没有在 LSTM 模型上带来改进。

解释这种行为是困难的,因为深度学习模型是黑盒,这意味着它们难以解释。虽然它们可以非常高效,但权衡在于它们的可解释性。存在解释神经网络模型的方法,但它们超出了本书的范围。如果你想了解更多,可以看看 Christof Molnar 的书籍,《可解释机器学习》(Interpretable Machine LearningSecond Edition),christophm.github.io/interpretable-ml-book/

16.3 下一步

在本章中,我们探讨了卷积神经网络(CNN)的架构。我们观察了卷积操作在网络中的应用以及如何使用核函数有效地过滤输入序列。然后我们实现了 CNN 架构,并将其与 LSTM 架构相结合,产生了两个新的单步模型、多步模型和多输出模型。

在单步模型的情况下,使用 CNN 并没有提高结果。事实上,它的表现比单独使用 LSTM 还要差。对于多步模型,我们观察到轻微的性能提升,并获得了最佳表现的多步模型,该模型结合了 CNN 和 LSTM。在多输出模型的情况下,使用 CNN 导致了恒定的性能,因此 CNN、LSTM 以及 CNN 和 LSTM 的组合之间有*局。因此,我们可以看到 CNN 并不一定导致最佳表现模型。在某些情况下它做到了,在另一些情况下没有,在某些情况下没有差异。

当涉及到使用深度学习进行建模时,将 CNN 架构视为你的工具集中的一个工具是很重要的。模型的表现将根据数据集和预测目标的不同而有所不同。关键在于正确地窗口化你的数据,就像DataWindow类所做的那样,并且遵循测试方法,就像我们通过保持训练集、验证集和测试集不变,并使用 MAE 对基线模型进行评估所做的那样。

我们将要特别探索的最后一个深度学习架构是关于多步模型。到目前为止,所有多步模型都是一次性预测未来 24 小时的输出。然而,可以逐步预测未来 24 小时,并将过去的预测反馈到模型中以输出下一个预测。这特别适用于 LSTM 架构,结果是一个自回归 LSTM(ARLSTM)。这将是下一章的主题。

16.4 练习

在上一章的练习中,你构建了 LSTM 模型。现在你将尝试 CNN 和 CNN 与 LSTM 的组合,看看你是否能提高性能。这些练习的解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH16

  1. 对于单步模型:

    1. 构建一个 CNN 模型。设置核宽度为 3。

    2. 绘制其预测图。

    3. 使用*均绝对误差(MAE)评估模型并存储 MAE。

    4. 构建一个 CNN + LSTM 模型。

    5. 绘制其预测图。

    6. 使用 MAE 评估模型并存储 MAE。

    7. 哪个模型表现最好?

  2. 对于多步模型:

    1. 构建一个 CNN 模型。设置核宽度为 3。

    2. 绘制其预测图。

    3. 使用 MAE 评估模型并存储 MAE。

    4. 构建一个 CNN+LSTM 模型。

    5. 绘制其预测图。

    6. 使用 MAE 评估模型并存储 MAE。

    7. 哪个模型表现最好?

  3. 多输出模型:

    1. 构建一个 CNN 模型。设置核宽度为 3。

    2. 绘制其预测图。

    3. 使用 MAE 评估模型并存储 MAE。

    4. 构建一个 CNN + LSTM 模型。

    5. 绘制其预测图。

    6. 使用 MAE 评估模型并存储 MAE。

    7. 哪个模型表现最好?

像往常一样,这是一个实验的机会。你可以探索以下内容:

  • 添加更多层。

  • 改变单元数量。

  • 用填充序列代替增加输入长度。这通过在Conv1D层中使用参数padding="same"来实现。在这种情况下,你的输入序列必须长度为 24。

  • 使用不同的层初始化器。

摘要

  • 卷积神经网络(CNN)是一种利用卷积操作的深度学习架构。

  • 卷积操作是在内核和特征空间之间进行的。它仅仅是内核和特征向量之间的点积。

  • 执行卷积操作会导致输出序列比输入序列短。因此,多次执行卷积可以快速减少输出长度。可以使用填充来防止这种情况。

  • 在时间序列预测中,卷积仅在单维进行:时间维度。

  • CNN 只是你的工具箱中的另一个模型,并不总是性能最好的模型。确保你使用DataWindow正确地窗口化你的数据,并通过保持每套数据恒定、构建基线模型以及使用相同的误差指标评估所有模型来确保你的测试方法有效。

17 使用预测来做出更多预测

本章涵盖

  • 检查自回归 LSTM(ARLSTM)架构

  • 发现 ARLSTM 的局限性

  • 实现 ARLSTM

在上一章中,我们检查并构建了一个卷积神经网络(CNN)。我们甚至将其与 LSTM 架构结合,以测试我们是否能够超越 LSTM 模型。结果混合,因为 CNN 模型作为单步模型表现较差,作为多步模型表现最佳,作为多输出模型表现相等。

现在,我们将完全专注于多步模型,因为它们都一次性输出整个预测序列。我们将修改这种行为,并逐步输出预测序列,使用过去的预测来做出新的预测。这样,模型将创建滚动预测,但使用自己的预测来指导输出。

这种架构通常与 LSTM 一起使用,称为自回归 LSTM(ARLSTM)。在本章中,我们将首先探讨 ARLSTM 模型的一般架构,然后我们将使用 Keras 构建它,以查看我们是否可以构建一个新的顶级多步模型。

17.1 检查 ARLSTM 架构

我们已经构建了许多多步模型,所有这些模型都输出未来 24 小时的交通量预测。每个模型都在一次操作中生成整个预测序列,这意味着我们可以立即从模型中获得 24 个值。

为了说明目的,让我们考虑一个只有 LSTM 层的简单模型。图 17.1 展示了我们迄今为止构建的多步模型的一般架构。每个模型都有输入进来,通过一个层,无论是LSTMDense还是Conv1D,最终产生一个 24 个值的序列。这种架构强制输出 24 个值。

图片

图 17.1 展示了具有 LSTM 层的单次多步模型。我们构建的所有多步模型都具有这种通用架构。LSTM 层可以很容易地被 CNN 层或密集层替换。

但如果我们想要更长的序列或更短的序列呢?如果我们只想预测接下来的 8 小时,或者预测接下来的 48 小时呢?在这种情况下,我们必须重新调整数据窗口并重新训练模型,这可能会是一项相当多的工作。

相反,我们可以选择一个自回归深度学习模型。如图 17.2 所示,每个预测都会被送回模型,允许它生成下一个预测。这个过程会重复进行,直到我们获得所需长度的序列。

图片

图 17.2 自回归 LSTM 模型。此模型在t[24]处返回第一个预测,并将其送回模型以生成t[25]处的预测。这个过程会重复进行,直到获得所需的输出长度。再次强调,这里展示了一个 LSTM 层,但它可以是 CNN 层或密集层。

你可以看到,使用自回归深度学习架构生成任何序列长度是多么容易。这种方法还有一个额外的优势,即允许我们预测不同时间尺度的时序,如小时、天或月,同时避免需要重新训练新模型。这是 Google DeepMind 构建 WaveNet (deepmind.com/blog/article/wavenet-generative-model-raw-audio) 所采用的架构,该模型生成原始音频序列。在时序的背景下,DeepAR (mng.bz/GEoV) 是一种使用自回归循环神经网络实现最先进结果的方法。

然而,自回归深度学习模型存在一个主要的缺点,即误差的累积。我们已经预测了许多时间序列,并且我们知道我们的预测值和实际值之间总是存在一些差异。这种误差随着它被反馈回模型而累积,这意味着后续的预测将比早期的预测有更大的误差。因此,尽管自回归深度学习架构看起来很强大,但它可能不是特定问题的最佳解决方案。因此,使用严格的测试协议非常重要,这正是我们从第十三章开始就一直在开发的。

尽管如此,将这个模型添加到你的时间序列预测方法工具箱中是很好的。在下一节中,我们将编写一个自回归 LSTM 模型来预测接下来的 24 小时。我们将将其性能与之前的多步模型进行比较。

17.2 构建自回归 LSTM 模型

现在,我们已经准备好在 Keras 中编写我们自己的自回归深度学习模型。具体来说,我们将编写一个 ARLSTM 模型,因为我们的实验表明 LSTM 模型在多步模型中实现了最佳性能。因此,我们将尝试通过使其自回归来进一步改进这个模型。

和往常一样,请确保你在笔记本或 Python 脚本中可以访问 DataWindow 类和 compile_and_fit 函数。它们与第十三章中开发的版本相同。

注意:在任何时候,都可以自由查阅 GitHub 上本章的源代码:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH17

第一步是阅读训练集、验证集和测试集。

train_df = pd.read_csv('../data/train.csv', index_col=0)
val_df = pd.read_csv('../data/val.csv', index_col=0)
test_df = pd.read_csv('../data/test.csv', index_col=0)

接下来,我们将定义我们的数据窗口。在这种情况下,我们将重用之前用于 LSTM 模型的数据窗口。输入和标签序列将各有 24 个时间步长。我们将指定一个 shift 为 24,以便模型输出 24 个预测。我们的目标仍然是交通量。

multi_window = DataWindow(input_width=24, label_width=24, shift=24, 
➥ label_columns=['traffic_volume'])

现在我们将用名为 AutoRegressive 的类包装我们的模型,该类继承自 Keras 中的 Model 类。这使得我们可以访问输入和输出。这样,我们将能够指定输出应在每个预测步骤成为输入。

我们将首先在我们的 AutoRegressive 类中定义 __init__ 函数。此函数接受三个参数:

  • self — 指向 AutoRegressive 类的实例。

  • units — 表示层中的神经元数量。

  • out_steps — 表示预测序列的长度。在这种情况下,它是 24。

然后,我们将使用三个不同的 Keras 层:Dense 层、RNN 层和 LSTMCell 层。LSTMCell 层是比 LSTM 层更底层的层。它允许我们访问更细粒度的信息,例如状态和预测,然后我们可以操作这些信息,将输出作为输入反馈到模型中。至于 RNN 层,它用于在输入数据上训练 LSTMCell 层。其输出随后通过 Dense 层生成预测。这是完整的 __init__ 函数:

class AutoRegressive(Model):
    def __init__(self, units, out_steps):                        ❶
        super().__init__()
        self.out_steps = out_steps
        self.units = units
        self.lstm_cell = LSTMCell(units)                         ❷
        self.lstm_rnn = RNN(self.lstm_cell, return_state=True)   ❸
        self.dense = Dense(train_df.shape[1])                    ❹

❶ 层中的神经元数量由 units 定义,预测序列的长度由 out_steps 定义。

❷ LSTMCell 层是一个低级类,它允许我们访问更细粒度的信息,例如状态和输出。

❸ RNN 层包裹了 LSTMCell 层,这使得在数据上训练 LSTM 更加容易。

❹ 预测来自这个 Dense 层。

初始化完成后,下一步是定义一个输出第一个预测的函数。由于这是一个自回归模型,该预测随后被反馈到模型中作为输入以生成下一个预测。因此,我们必须有一种方法在进入自回归循环之前捕获那个非常第一个预测。

因此,我们将定义 warmup 函数,该函数复制单步 LSTM 模型。我们将简单地将输入传递到 lstm_rnn 层,从 Dense 层获取预测,并返回预测和状态。

def warmup(self, inputs):
    x, *state = self.lstm_rnn(inputs)    ❶
    prediction = self.dense(x)           ❷

    return prediction, state

❶ 将输入通过 LSTM 层。输出被发送到 Dense 层。

❷ 从 Dense 层获取预测。

现在我们有了一种捕获第一次预测的方法,我们可以定义 call 函数,该函数将运行一个循环以生成长度为 out_steps 的预测序列。请注意,函数必须命名为 call,因为它是被 Keras 隐式调用的;如果命名不同,将导致错误。

由于我们使用的是低级类 LSTMCell,我们必须手动传入前一个状态。一旦循环完成,我们将我们的预测堆叠起来,并确保它们具有正确的输出形状,使用 transpose 方法。

def call(self, inputs, training=None):
    predictions = []                                      ❶
    prediction, state = self.warmup(inputs)               ❷

    predictions.append(prediction)                        ❸

    for n in range(1, self.out_steps):
        x = prediction                                    ❹
        x, state = self.lstm_cell(x, states=state, training=training)

        prediction = self.dense(x)                        ❺
        predictions.append(prediction)

    predictions = tf.stack(predictions)                   ❻
    predictions = tf.transpose(predictions, [1, 0, 2])    ❼

    return predictions

❶ 初始化一个空列表以收集所有预测。

❷ 首次预测是通过 warmup 函数获得的。

❸ 将第一个预测放入预测列表中。

❹ 预测成为下一个的输入。

❺ 使用前一个预测作为输入生成一个新的预测。

❻ 将所有预测值堆叠起来。到目前为止,我们有一个形状(time, batch, features)。它必须改为(batch, time, features)。

❼ 使用转置来获取所需的形状(batch, time, features)。

完整的类定义如下所示。

列表 17.1 定义一个类来实现 ARLSTM 模型

class AutoRegressive(Model):
    def __init__(self, units, out_steps):
        super().__init__()
        self.out_steps = out_steps
        self.units = units
        self.lstm_cell = LSTMCell(units)
        self.lstm_rnn = RNN(self.lstm_cell, return_state=True)
        self.dense = Dense(train_df.shape[1])

    def warmup(self, inputs):
        x, *state = self.lstm_rnn(inputs)
        prediction = self.dense(x)

        return prediction, state

    def call(self, inputs, training=None):
        predictions = []
        prediction, state = self.warmup(inputs)

        predictions.append(prediction)

        for n in range(1, self.out_steps):
            x = prediction
            x, state = self.lstm_cell(x, states=state, training=training)

            prediction = self.dense(x)
            predictions.append(prediction)

        predictions = tf.stack(predictions)
        predictions = tf.transpose(predictions, [1, 0, 2])

        return predictions

我们现在已经定义了我们的AutoRegressive类,它实现了一个自回归 LSTM 模型。我们可以使用它并在我们的数据上训练一个模型。我们将使用 32 个单元和一个输出序列长度为 24 个时间步长来初始化它,因为多步模型的目的是预测接下来的 24 小时。

AR_LSTM = AutoRegressive(units=32, out_steps=24)

接下来,我们将编译模型,训练它,并存储其性能指标。

history = compile_and_fit(AR_LSTM, multi_window)

ms_val_performance = {}
ms_performance = {}

ms_val_performance['AR - LSTM'] = AR_LSTM.evaluate(multi_window.val)
ms_performance['AR - LSTM'] = AR_LSTM.evaluate(multi_window.test, 
➥ verbose=0)

我们可以通过使用DataWindow类的plot方法来可视化模型的预测值与实际值。

multi_window.plot(AR_LSTM)

在图 17.3 中,许多预测值非常接*实际值,有时甚至与之重叠。这表明我们有一个相当准确的模型。

图 17.3 使用 ARLSTM 模型预测未来 24 小时的交通量。许多预测值(以交叉表示)与实际值(以正方形表示)重叠,这意味着我们有一个相当准确的模型。

这种视觉检查不足以确定我们是否有一个新的顶级模型,因此我们将显示其与所有先前多步模型的 MAE。结果如图 17.4 所示,显示我们的自回归 LSTM 模型在验证集上实现了 0.063 的 MAE,在测试集上实现了 0.049 的 MAE。这个分数比 CNN、CNN + LSTM 模型以及简单的 LSTM 模型都要好。因此,ARLSTM 模型成为了顶级的多步模型。

图 17.4 所有我们的多步模型在验证集和测试集上的 MAE。ARLSTM 模型的 MAE 低于 CNN、CNN + LSTM 模型以及简单的 LSTM 模型。

总是记住,每个模型的性能都取决于所涉及的问题。这里的要点不是 ARLSTM 总是最好的模型,而是它是这种情况下的最佳性能模型。对于另一个问题,你可能会找到另一个冠军模型。如果你从第十三章开始就一直在完成练习,你现在已经看到了这种情况的发生。记住,自第十三章以来我们构建的每个模型都是为了成为你工具箱中的另一个工具,帮助你最大化解决时间序列预测问题的机会。

17.3 下一步

这章相当简短,因为它建立在我们已经覆盖的概念之上,例如 LSTM 架构和数据窗口。

在我们的例子中,自回归 LSTM 模型优于简单的 LSTM 多步模型,并且比 CNN 模型表现更好。再次强调,这并不意味着 ARLSTM 模型总是会优于 CNN 模型或简单的 LSTM 模型。每个问题都是独特的,不同的架构可能对不同的问题产生最佳性能。重要的是,你现在有一系列可以测试和适应每个问题的模型,以找到可能的最佳解决方案。

这几乎完成了本书的深度学习部分。在下一章中,我们将应用我们对时间序列预测深度学习方法的了解,在一个综合项目中。和以前一样,将提供一个问题和数据集,我们必须生成一个预测模型来解决问题。

17.4 练习

从第十三章开始的练习中,我们已经构建了许多模型来预测北京的空气质量,使用了三种类型的模型(单步、多步和多输出)。现在我们将使用一个 ARLSTM 模型构建最后一个多步模型。解决方案可以在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH17

  1. 对于多步模型:

    1. 构建一个 ARLSTM 模型。

    2. 绘制其预测图。

    3. 使用*均绝对误差(MAE)评估模型,并存储 MAE 以进行比较。

    4. ARLSTM 模型是冠军模型吗?

当然,你可以自由地进一步实验。例如,你可以改变单元的数量,看看它如何影响模型的表现。

摘要

  • 深度学习中的自回归架构催生了最先进的模型,如 WaveNet 和 DeepAR。

  • 一个自回归深度学习模型生成一系列预测,但每个预测都会作为输入反馈到模型中。

  • 关于自回归深度学习模型的一个注意事项是,随着序列长度的增加,错误会累积。因此,一个早期的错误预测可能会对晚期的预测产生重大影响。

第十八章:预测家庭的电力消耗

本章涵盖

  • 开发深度学习模型以预测家庭的电力消耗

  • 比较各种多步深度学习模型

  • 评估*均绝对误差并选择冠军模型

恭喜你走到了这一步!在第十二章到第十七章中,我们深入探讨了时间序列预测的深度学习。你了解到,当数据集很大时,统计模型变得低效或无法使用,这通常意味着超过 10,000 个数据点,并且具有许多特征。因此,我们必须转而使用深度学习模型,这些模型可以利用所有可用信息,同时保持计算效率,以产生预测模型。

正如我们在第六章开始使用 ARMA(p,q)模型建模时间序列时必须设计一个新的预测程序一样,使用深度学习技术建模需要我们使用另一种建模程序:使用DataWindow类创建数据窗口。这个类在深度学习建模中起着至关重要的作用,因为它允许我们适当地格式化我们的数据,为我们的模型创建一组输入和标签,如图 18.1 所示。

图片

图 18.1 数据窗口的示例。这个数据窗口有 24 个时间步作为输入和 24 个时间步作为输出。然后模型将使用 24 小时的输入来生成 24 小时的预测。数据窗口的总长度是输入和标签长度的总和。在这种情况下,我们总共有 48 个时间步。

这个数据窗口步骤使我们能够产生各种模型,从简单的线性模型到深度神经网络、长短期记忆(LSTM)网络和卷积神经网络(CNN)。此外,数据窗口可以用于不同的场景,使我们能够创建单步模型,其中我们只预测下一个时间步,多步模型,其中我们预测一系列未来的步骤,以及多输出模型,其中我们预测多个目标变量。

在过去几章中与深度学习合作后,现在是时候将我们的知识应用到综合项目中了。在本章中,我们将通过使用深度学习模型来指导预测项目的步骤。我们首先查看项目并描述我们将使用的数据。然后我们将涵盖数据整理和预处理步骤。尽管这些步骤与时间序列预测没有直接关系,但它们是任何机器学习项目中的关键步骤。然后我们将专注于建模步骤,我们将尝试一系列深度学习模型,以发现最佳表现者。

18.1 理解综合项目

在这个项目中,我们将使用一个追踪家庭电力消耗的数据集。UCI 机器学习仓库公开提供的“个人家庭电力消耗”数据集:archive.ics.uci.edu/ml/datasets/Individual+household+electric+power+consumption

预测电能消耗是一个具有全球应用性的常见任务。在发展中国家,它可以帮助规划电网的建设。在电网已经发达的国家,预测能源消耗确保电网能够高效地为所有家庭提供足够的能源。有了准确的预测模型,能源公司可以更好地规划电网的负载,确保在高峰时段生产足够的能源,或者有足够的能源储备来满足需求。此外,它们可以避免生产过多的电力,如果电力没有被储存,可能会导致电网不*衡,从而面临断电的风险。因此,预测电能消耗是一个重要的日常生活中的问题,它具有深远的影响。

为了开发我们的预测模型,我们将使用之前提到包含法国 Sceaux 市一栋房屋在 2006 年 12 月至 2010 年 11 月之间电力消耗的数据集。数据跨度为 47 个月,并且每分钟记录一次,这意味着我们拥有超过两百万个数据点。

数据集包含总共九列,如表 18.1 所示。主要目标是全局有功功率,因为它代表了电路中实际使用的功率。这是电器使用的部分。另一方面,无功功率在电路的源和负载之间移动,因此它不会产生任何有用的功。

表 18.1 数据集中列的描述

列名 描述
日期 以下格式的日期:dd/mm/yyyy
时间 以下格式的日期:hh:mm:ss
全球有功功率 千瓦(kW)表示的全局有功功率
全球无功功率 千瓦(kW)表示的全局无功功率
电压 伏特(V)表示的电压
全球电流强度 安培(A)表示的电流强度
子计量 1 洗碗机、烤箱和微波炉在厨房消耗的电能,单位为瓦时(Wh)
子计量 2 洗衣机、烘干机、冰箱和照明在洗衣房消耗的电能,单位为瓦时(Wh)
子计量 3 热水器和空调在瓦时(Wh)表示的电能消耗

这个数据集不包含任何天气信息,这可能是能源消耗的强大预测因素。我们可以安全地预期,在炎热的夏日,空调将运行更长时间,从而需要更多的电力。在寒冷的冬日,供暖房屋也需要大量的能源。这些数据在这里不可用,但在专业环境中,我们可以请求这类数据来增强我们的数据集,并可能产生更好的模型。

现在你已经对问题和数据集有了总体了解,让我们定义这个项目的目标和我们将采取的步骤。

18.1.1 本综合项目的目标

这个综合项目的目标是创建一个可以预测未来 24 小时全球活跃功率的模型。如果你有信心,这个目标应该足以让你下载数据集,自己处理,并将你的过程与本章中展示的过程进行比较。

否则,以下是需要执行的步骤:

  1. 数据处理和预处理。这一步是可选的。它并不直接与时间序列预测相关联,但在任何机器学习项目中都是一个重要的步骤。你可以安全地跳过这一步,从步骤 2 开始,使用干净的数据集:

    1. 计算缺失值的数量。

    2. 补充缺失值。

    3. 将每个变量表示为数值(所有数据最初都存储为字符串)。

    4. 将日期和时间列合并为一个DateTime对象。

    5. 确定每分钟采样的数据是否可用于预测。

    6. 按小时重采样数据。

    7. 移除任何不完整的小时。

  2. 特征工程:

    1. 识别任何季节性。

    2. 使用正弦和余弦变换对时间进行编码。

    3. 缩放数据。

  3. 分割数据:

    1. 进行 70:20:10 的分割以创建训练集、验证集和测试集。
  4. 准备深度学习建模:

    1. 实现DataWindow类。

    2. 定义compile_and_fit函数。

    3. 创建一个包含列索引和列名称的字典。

  5. 深度学习模型:

    1. 训练至少一个基线模型。

    2. 训练一个线性模型。

    3. 训练一个深度神经网络。

    4. 训练一个 LSTM。

    5. 训练一个 CNN。

    6. 训练 LSTM 和 CNN 的组合。

    7. 训练一个自回归 LSTM。

    8. 选择表现最好的模型。

你现在拥有了完成这个综合项目所需的所有步骤。我强烈建议你先自己尝试,因为这会揭示你已经掌握的内容和需要复习的内容。在任何时候,你都可以参考以下部分,以详细了解每个步骤。

整个解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH18。请注意,数据文件太大,无法包含在存储库中,因此你需要单独下载数据集。祝你好运!

18.2 数据处理和预处理

数据清洗是将数据转换成易于建模使用的形式的过程。这一步通常涉及探索缺失数据、填充空白值,并确保数据具有正确的类型,这意味着数字是数值而不是字符串。这是一个复杂的步骤,可能是任何机器学习项目中最重要的一个。在预测项目开始时拥有质量较差的数据将保证你会有质量较差的预测。如果你只想专注于时间序列预测,你可以跳过本章的这一部分,但我强烈建议你阅读它,因为它真的会帮助你熟悉数据集。

注意:如果你还没有这样做,你可以从 UC Irvine 机器学习仓库下载“个人家庭电力消耗”数据集:archive.ics.uci.edu/ml/datasets/Individual+household+electric+power+consumption

要执行此数据清洗,你可以从将用于数据操作和可视化的库导入 Python 脚本或 Jupyter Notebook 开始。

import datetime

import numpy as np
import pandas as pd
import tensorflow as tf
import matplotlib.pyplot as plt

import warnings
warnings.filterwarnings('ignore')

每当使用numpy和 TensorFlow 时,我喜欢设置一个随机种子以确保结果可以重现。如果你没有设置种子,你的结果可能会有所不同;如果你设置了一个与我不同的种子,你的结果将与这里显示的不同。

tf.random.set_seed(42)
np.random.seed(42)

下一步是将数据文件读入一个DataFrame。我们正在处理一个原始文本文件,但仍然可以使用pandasread_csv方法。我们只需指定分隔符,在这种情况下是一个分号。

df = pd.read_csv('../data/household_power_consumption.txt', sep=';')   ❶

❶只要我们指定了分隔符,我们就可以使用这种方法来处理.txt 文件。

我们可以选择使用df.head()显示前五行,使用df.tail()显示最后五行。这将显示我们的数据从 2006 年 12 月 16 日下午 5:24 开始,到 2010 年 11 月 26 日下午 9:02 结束,并且数据是每分钟收集的。我们还可以使用df.shape显示我们数据的形状,这会显示我们有 2,075,529 行和 9 列。

18.2.1 处理缺失数据

现在我们来检查缺失值。我们可以通过将isna()方法与sum()方法链接起来来完成这项工作。这会返回我们数据集中每一列的缺失值总和。

df.isna().sum()

从图 18.2 所示的输出中,只有 Sub_metering_3 列有缺失值。实际上,根据数据文档,其大约 1.25%的值是缺失的。

图片

图 18.2 展示了我们数据集中缺失值的总数。您可以看到,只有 Sub_metering_3 列存在缺失值。

处理缺失值有两种可选方案。首先,我们可以简单地删除这一列,因为其他特征没有缺失值。其次,我们可以用某个特定值填充缺失值。这个过程被称为插补

我们首先检查是否存在许多连续的缺失值。如果是这样,最好是删除这一列,因为插补许多连续的值可能会在我们的数据中引入一个不存在的趋势。否则,如果缺失值分散在时间上,填充它们是合理的。以下代码块输出了最长连续缺失值序列的长度:

na_groups = 
➥ df['Sub_metering_3'].notna().cumsum()[df['Sub_metering_3'].isna()]
len_consecutive_na = na_groups.groupby(na_groups).agg(len)

longest_na_gap = len_consecutive_na.max()

这输出了 7,226 分钟的连续缺失数据长度,相当于大约 5 天。在这种情况下,这个差距肯定太大,无法用缺失值填充,所以我们将从数据集中删除这一列。

df = df.drop(['Sub_metering_3'], axis=1)

我们的数据集中不再有任何缺失数据,因此我们可以继续下一步。

18.2.2 数据转换

现在我们来检查数据是否具有正确的类型。我们应该研究数值数据,因为我们的数据集是传感器读数的集合。

我们可以使用df.dtypes输出每一列的类型,这显示每一列都是object类型。在pandas中这意味着我们的数据主要是文本,或者数值和非数值值的混合。

我们可以使用pandas中的to_numeric函数将每一列转换为数值。这是非常重要的,因为我们的模型期望的是数值数据。请注意,我们不会将日期和时间列转换为数值——这些将在后续步骤中处理。

cols_to_convert = df.columns[2:]

df[cols_to_convert] = df[cols_to_convert].apply(pd.to_numeric, 
➥ errors='coerce')

我们可以选择再次使用df.dtypes检查每一列的类型,以确保值已正确转换。这将显示从 Global_active_power 到 Sub_metering_2 的每一列现在都是预期的float64类型。

18.2.3 数据重采样

下一步是检查每分钟采样的数据是否适合建模。有可能每分钟采样的数据太嘈杂,无法构建性能良好的预测模型。

为了检查这一点,我们将简单地绘制我们的目标值,看看它看起来像什么。结果图如图 18.3 所示。

fig, ax = plt.subplots(figsize=(13,6))

ax.plot(df['Global_active_power'])
ax.set_xlabel('Time')
ax.set_xlim(0, 2880)

fig.autofmt_xdate()
plt.tight_layout()

图片

图 18.3 记录的全球活动功率的第一天 24 小时,每分钟采样一次。你可以看到数据相当嘈杂。

图 18.3 显示数据非常嘈杂,每分钟都会出现大的波动或*坦序列。这种模式使用深度学习模型进行预测是困难的,因为它似乎是在随机移动。此外,我们也可以质疑是否需要每分钟预测电力消耗,因为电网的变化不可能在如此短的时间内发生。

因此,我们肯定需要重采样我们的数据。在这种情况下,我们将按小时重采样。这样,我们希望*滑数据并揭示一个可能更容易用机器学习模型预测的模式。

要做到这一点,我们需要一个datetime数据类型。我们可以将日期和时间列合并,创建一个新的列,该列包含与datetime数据类型相同的信息。

df.loc[:,'datetime'] = pd.to_datetime(df.Date.astype(str) + ' ' + 
➥ df.Time.astype(str))        ❶

df = df.drop(['Date', 'Time'], axis=1)

❶ 这个步骤将花费很长时间。如果您的代码看起来像是挂起了,请不要担心。

现在我们可以重采样我们的数据。在这种情况下,我们将对每个变量进行每小时的总和。这样我们就会知道家庭每小时消耗的总电力。

hourly_df = df.resample('H', on='datetime').sum()

记住,我们的数据从 2006 年 12 月 16 日下午 5:24 开始,到 2010 年 11 月 26 日下午 9:02 结束。通过新的重采样,我们现在每小时每列都有一个总和,这意味着我们的数据从 2006 年 12 月 16 日下午 5 点开始,到 2010 年 11 月 26 日下午 9 点结束。然而,数据的第一行和最后一行的总和没有完整的 60 分钟。第一行计算了从下午 5:24 到下午 5:59 的总和,这是 35 分钟。最后一行计算了从晚上 9:00 到晚上 9:02 的总和,只有 2 分钟。因此,我们将删除第一行和最后一行数据,这样我们只处理整个小时的总和。

hourly_df = hourly_df.drop(hourly_df.tail(1).index)
hourly_df = hourly_df.drop(hourly_df.head(1).index)

最后,这个过程已经改变了索引。我个人更喜欢将索引作为整数,将日期作为列,所以我们将简单地重置我们的DataFrame的索引。

hourly_df = hourly_df.reset_index()

我们可以可选地检查我们数据的形状,使用hourly_df.shape,我们会看到我们现在有 34,949 行数据。这比原始的两百万行有大幅减少。尽管如此,这样一个规模的数据集绝对适合深度学习方法。

让我们再次绘制我们的目标,看看重采样我们的数据是否生成了一个可辨别的模式,可以进行预测。这里我们将绘制全球有功功率的前 15 天每小时采样的数据:

fig, ax = plt.subplots(figsize=(13,6))

ax.plot(hourly_df['Global_active_power'])
ax.set_xlabel('Time')
ax.set_xlim(0, 336)

plt.xticks(np.arange(0, 360, 24), ['2006-12-17', '2006-12-18', 
➥ '2006-12-19', '2006-12-20', '2006-12-21', '2006-12-22', '2006-12-23', 
➥ '2006-12-24', '2006-12-25', '2006-12-26', '2006-12-27', '2006-12-28', 
➥ '2006-12-29', '2006-12-30', '2006-12-31'])

fig.autofmt_xdate()
plt.tight_layout()

如您在图 18.4 中看到的,我们现在有一个更*滑的全局有功功率模式。此外,我们可以辨别出日季节性,尽管它不如本书中之前的例子那么明显。

图片

图 18.4 每小时采样的总全球有功功率。我们现在有一个更*滑的模式,带有日季节性。这可以使用深度学习模型进行预测。

数据整理完成后,我们可以将我们的数据集保存为 CSV 文件,这样我们就有一个干净的数据版本。这将是下一节开始的文件。

hourly_df.to_csv('../data/clean_household_power_consumption.csv', 
header=True, index=False)

18.3 特征工程

到目前为止,我们有一个干净的数据集,没有缺失值,并且有一个*滑的模式,这将更容易使用深度学习技术进行预测。无论您是否跟随着上一节,您都可以阅读数据的干净版本,并开始进行特征工程。

hourly_df = pd.read_csv('../data/clean_household_power_consumption.csv')

18.3.1 移除不必要的列

特征工程的第一步是显示每个列的基本统计信息。这特别有助于检测是否有任何变量变化不大。这样的变量应该被移除,因为如果它们在时间上几乎保持不变,它们就不能预测我们的目标。

我们可以使用来自pandasdescribe方法获取每列的描述:

hourly_df.describe().transpose()

如图 18.5 所示,Sub_metering_1 可能不是我们目标的良好预测因子,因为其恒定值无法解释全局活动功率的变化。我们可以安全地移除此列并保留其余部分。

hourly_df = hourly_df.drop(['Sub_metering_1'], axis=1)

图 18.5 数据集中每列的描述。你会注意到 Sub_metering_1 有 75%的时间值为 0。因为这个变量随时间变化不大,所以可以从特征集中移除。

18.3.2 识别季节性周期

由于我们的目标是家庭的全局活动功率,我们可能会有些季节性。我们可以预期在夜间,将使用较少的电力。同样,在工作日人们下班回家时可能会出现消费高峰。因此,假设我们的目标将存在一些季节性是合理的。

我们可以绘制目标数据以查看是否可以直观地检测到周期。

fig, ax = plt.subplots(figsize=(13,6))

ax.plot(hourly_df['Global_active_power'])
ax.set_xlabel('Time')
ax.set_xlim(0, 336)

plt.xticks(np.arange(0, 360, 24), ['2006-12-17', '2006-12-18', 
➥ '2006-12-19', '2006-12-20', '2006-12-21', '2006-12-22', '2006-12-23', 
➥ '2006-12-24', '2006-12-25', '2006-12-26', '2006-12-27', '2006-12-28', 
➥ '2006-12-29', '2006-12-30', '2006-12-31'])

fig.autofmt_xdate()
plt.tight_layout()

在图 18.6 中,你可以看到我们的目标有一些周期性行为,但从图中很难确定季节性周期。虽然我们对每日季节性的假设是有道理的,但我们需要确保它在我们的数据中存在。一种方法是通过傅里叶变换来实现。

图 18.6 前 15 天的总全局活动功率。虽然存在明显的周期性行为,但仅从图中很难确定季节性周期。

不深入细节,傅里叶变换基本上允许我们可视化信号的频率和振幅。因此,我们可以将我们的时间序列视为一个信号,应用傅里叶变换,并找到振幅大的频率。这些频率将决定季节性周期。这种方法的一个巨大优势是它独立于季节性周期。它可以识别年度、周度和日季节性,或我们希望测试的任何特定周期。

注意:有关傅里叶变换的更多信息,我建议阅读 Lakshay Akula 的“使用 Python & SciPy 分析季节性”博客文章,该文章很好地介绍了傅里叶变换以分析季节性:mng.bz/7y2Q

对于我们的情况,让我们测试每周和每日的季节性。

fft = tf.signal.rfft(hourly_df['Global_active_power'])    ❶

f_per_dataset = np.arange(0, len(fft))                    ❷

n_sample_h = len(hourly_df['Global_active_power'])        ❸

hours_per_week = 24 * 7                                   ❹
weeks_per_dataset = n_sample_h / hours_per_week           ❺
f_per_week = f_per_dataset / weeks_per_dataset            ❻

plt.step(f_per_week, np.abs(fft))                         ❼
plt.xscale('log')
plt.xticks([1, 7], ['1/week', '1/day'])                   ❽
plt.xlabel('Frequency')
plt.tight_layout()
plt.show()

❶ 对我们的目标应用傅里叶变换。

❷ 获取傅里叶变换中的频率数。

❸ 查找数据集中的小时数。

❹ 获取一周中的小时数。

❺ 获取数据集中的周数。

❻ 获取数据集中一周的频率。

❼ 绘制频率和振幅。

❽ 标记每周和每日频率。

在图 18.7 中,你可以看到每周和每日频率的振幅。每周频率没有显示出任何明显的峰值,这意味着其振幅非常小。因此,不存在每周的季节性。

图 18.7 展示了我们目标中每周和每日季节性的振幅。你可以看到,每周季节性的振幅接* 0,而每日季节性有一个明显的峰值。因此,我们的目标确实存在日季节性。

然而,从每日频率来看,你会在图中注意到一个明显的峰值。这告诉我们,我们的数据确实存在日季节性。因此,我们将使用正弦和余弦变换来编码时间戳,以表达时间的同时保留其日季节性信息。我们在第十二章准备数据用于深度学习建模时也做了同样的事情。

timestamp_s = 
➥ pd.to_datetime(hourly_df.datetime).map(datetime.datetime.timestamp)

day = 24 * 60 * 60

hourly_df['day_sin'] = (np.sin(timestamp_s * (2*np.pi/day))).values
hourly_df['day_cos'] = (np.cos(timestamp_s * (2*np.pi/day))).values

hourly_df = hourly_df.drop(['datetime'], axis=1)

我们的特征工程已完成,数据已准备好进行缩放并分割为训练集、验证集和测试集。

18.3.3 数据的分割和缩放

最后一步是将数据集分割为训练集、验证集和测试集,并对数据进行缩放。请注意,我们首先分割数据,因此我们将使用仅来自训练集的信息来缩放它,从而避免信息泄露。缩放数据将减少训练时间并提高我们模型的表现。

我们将分别将数据分割为 70%、20%和 10%用于训练集、验证集和测试集。

n = len(hourly_df)

# Split 70:20:10 (train:validation:test)
train_df = hourly_df[0:int(n*0.7)]
val_df = hourly_df[int(n*0.7):int(n*0.9)]
test_df = hourly_df[int(n*0.9):] 

接下来,我们将仅对训练集进行缩放器的拟合,并对每个单独的集合进行缩放。

from sklearn.preprocessing import MinMaxScaler

scaler = MinMaxScaler()
scaler.fit(train_df)

train_df[train_df.columns] = scaler.transform(train_df[train_df.columns])
val_df[val_df.columns] = scaler.transform(val_df[val_df.columns])
test_df[test_df.columns] = scaler.transform(test_df[test_df.columns])

我们现在可以将每个集合保存下来,以便稍后用于建模。

train_df.to_csv('../data/ch18_train.csv', index=False, header=True)
val_df.to_csv('../data/ch18_val.csv', index=False, header=True)
test_df.to_csv('../data/ch18_test.csv', index=False, header=True)

我们现在可以继续进行建模步骤。

18.4 使用深度学习进行建模的准备

在上一节中,我们生成了训练深度学习模型所需的三个数据集。回想一下,这个项目的目标是预测未来 24 小时的全球活跃电力消耗。这意味着我们必须构建一个单变量多步模型,因为我们只预测一个目标 24 个时间步的未来。

我们将构建两个基线,一个线性模型,一个深度神经网络模型,一个长短期记忆(LSTM)模型,一个卷积神经网络(CNN),一个 CNN 和 LSTM 的组合,最后是一个自回归 LSTM。最后,我们将使用*均绝对误差(MAE)来确定哪个模型是最好的。在测试集上实现最低 MAE 的模型将是性能最好的模型。

注意,我们将使用*均绝对误差(MAE)作为评估指标,以及均方误差(MSE)作为损失函数,正如我们从第十三章开始所做的那样。

18.4.1 初始设置

在进行建模之前,我们首先需要导入所需的库,以及定义我们的DataWindow类和一个用于训练模型的函数。

我们将首先导入建模所需的 Python 库。

import numpy as np
import pandas as pd
import tensorflow as tf
import matplotlib.pyplot as plt

from tensorflow.keras import Model, Sequential

from tensorflow.keras.optimizers import Adam
from tensorflow.keras.callbacks import EarlyStopping
from tensorflow.keras.losses import MeanSquaredError
from tensorflow.keras.metrics import MeanAbsoluteError

from tensorflow.keras.layers import Dense, Conv1D, LSTM, Lambda, Reshape, 
➥ RNN, LSTMCell

import warnings
warnings.filterwarnings('ignore')

确保你已经安装了 TensorFlow 2.6,因为这是撰写时的最新版本。你可以使用print(tf.__version__)来检查 TensorFlow 的版本。

可选地,你可以为图表设置参数。在这种情况下,我更喜欢指定大小并移除坐标轴上的网格。

plt.rcParams['figure.figsize'] = (10, 7.5)
plt.rcParams['axes.grid'] = False

然后,你可以设置一个随机种子。这确保了在训练模型时结果的一致性。请记住,深度学习模型的初始化是随机的,因此连续两次训练相同的模型可能会得到略微不同的性能。因此,为了确保可重复性,我们设置了随机种子。

tf.random.set_seed(42)
np.random.seed(42)

接下来,我们需要读取训练集、验证集和测试集,以便它们为建模做好准备。

train_df = pd.read_csv('../data/ch18_train.csv')
val_df = pd.read_csv('../data/ch18_val.csv')
test_df = pd.read_csv('../data/ch18_test.csv')

最后,我们将构建一个字典来存储列名及其对应的索引。这将在构建基线模型和创建数据窗口时非常有用。

column_indices = {name: i for i, name in enumerate(train_df.columns)}

现在,我们将继续定义DataWindow类。

18.4.2 定义 DataWindow 类

DataWindow类允许我们快速创建用于训练深度学习模型的数据窗口。每个数据窗口包含一组输入和一组标签。然后模型被训练以使用输入产生尽可能接*标签的预测。

第十三章的一个整个部分都是专门用于逐步实现DataWindow类,自从那时起我们就一直在使用它,所以我们将直接进入其实现。这里唯一的改变将是当我们将预测与标签可视化时,默认要绘制的列名。

列表 18.1 创建数据窗口的类实现

class DataWindow():
    def __init__(self, input_width, label_width, shift, 
                 train_df=train_df, val_df=val_df, test_df=test_df, 
                 label_columns=None):

        self.train_df = train_df
        self.val_df = val_df
        self.test_df = test_df
        self.label_columns = label_columns
        if label_columns is not None:
            self.label_columns_indices = {name: i for i, name in 
➥ enumerate(label_columns)}
        self.column_indices = {name: i for i, name in 
➥ enumerate(train_df.columns)}

        self.input_width = input_width
        self.label_width = label_width
        self.shift = shift

        self.total_window_size = input_width + shift

        self.input_slice = slice(0, input_width)
        self.input_indices = 
➥ np.arange(self.total_window_size)[self.input_slice]

        self.label_start = self.total_window_size - self.label_width
        self.labels_slice = slice(self.label_start, None)
        self.label_indices = 
➥ np.arange(self.total_window_size)[self.labels_slice]

    def split_to_inputs_labels(self, features):
        inputs = features[:, self.input_slice, :]
        labels = features[:, self.labels_slice, :]
        if self.label_columns is not None:
            labels = tf.stack(
                [labels[:,:,self.column_indices[name]] for name in 
➥ self.label_columns],
                axis=-1
            )
        inputs.set_shape([None, self.input_width, None])
        labels.set_shape([None, self.label_width, None])

        return inputs, labels

    def plot(self, model=None, plot_col='Global_active_power', 
➥ max_subplots=3):                                             ❶
        inputs, labels = self.sample_batch

        plt.figure(figsize=(12, 8))
        plot_col_index = self.column_indices[plot_col]
        max_n = min(max_subplots, len(inputs))

        for n in range(max_n):
            plt.subplot(3, 1, n+1)
            plt.ylabel(f'{plot_col} [scaled]')
            plt.plot(self.input_indices, inputs[n, :, plot_col_index],
                     label='Inputs', marker='.', zorder=-10)

            if self.label_columns:
              label_col_index = self.label_columns_indices.get(plot_col, 
➥ None)
            else:
              label_col_index = plot_col_index
            if label_col_index is None:
              continue

            plt.scatter(self.label_indices, labels[n, :, label_col_index],
                        edgecolors='k', marker='s', label='Labels', 
➥ c='green', s=64)
            if model is not None:
              predictions = model(inputs)
              plt.scatter(self.label_indices, predictions[n, :, 
➥ label_col_index],
                          marker='X', edgecolors='k', label='Predictions',
                          c='red', s=64)

            if n == 0:
              plt.legend()

        plt.xlabel('Time (h)')

    def make_dataset(self, data):
        data = np.array(data, dtype=np.float32)
        ds = tf.keras.preprocessing.timeseries_dataset_from_array(
            data=data,
            targets=None,
            sequence_length=self.total_window_size,
            sequence_stride=1,
            shuffle=True,
            batch_size=32
        )

        ds = ds.map(self.split_to_inputs_labels)
        return ds

    @property
    def train(self):
        return self.make_dataset(self.train_df)

    @property
    def val(self):
        return self.make_dataset(self.val_df)

    @property
    def test(self):
        return self.make_dataset(self.test_df)

    @property
    def sample_batch(self):
        result = getattr(self, '_sample_batch', None)
        if result is None:
            result = next(iter(self.train))
            self._sample_batch = result
        return result

❶ 将我们的目标默认名称设置为全局有功功率。

定义了DataWindow类后,我们只需要一个函数来编译和训练我们将开发的不同的模型。

18.4.3 训练我们模型的实用函数

在启动我们的实验之前,我们的最后一步是构建一个自动化训练过程的函数。这就是我们从第十三章开始使用的compile_and_fit函数。

请记住,这个函数接受一个模型和一个数据窗口。然后它实现了早停机制,意味着如果验证损失在连续三个 epoch 内没有变化,模型将停止训练。这也是我们指定损失函数为均方误差(MSE)和评估指标为*均绝对误差(MAE)的函数。

def compile_and_fit(model, window, patience=3, max_epochs=50):
    early_stopping = EarlyStopping(monitor='val_loss',
                                   patience=patience,
                                   mode='min')

    model.compile(loss=MeanSquaredError(),
                  optimizer=Adam(),
                  metrics=[MeanAbsoluteError()])

    history = model.fit(window.train,
                       epochs=max_epochs,
                       validation_data=window.val,
                       callbacks=[early_stopping])

    return history

到目前为止,我们已经拥有了开始开发预测未来 24 小时全球有功功率的模型所需的一切。

18.5 使用深度学习建模

训练集、验证集和测试集都已准备就绪,以及DataWindow类和训练我们模型的函数。一切准备就绪,我们可以开始构建深度学习模型。

我们首先将实现两个基线模型,然后我们将训练具有递增复杂性的模型:一个线性模型、一个深度神经网络、一个 LSTM、一个 CNN、一个 CNN 和 LSTM 模型,以及一个自回归 LSTM 模型。一旦所有模型都训练完毕,我们将通过比较测试集上的 MAE 来选择最佳模型。MAE 最低的模型将是我们的推荐模型。

18.5.1 基线模型

每个预测项目都必须从一个基线模型开始。基线模型作为我们更复杂模型的基准,因为它们只能与某个基准相比更好。构建基线模型还允许我们评估模型增加的复杂性是否真正产生了显著的收益。可能存在复杂模型的表现并不比基线模型好多少的情况,在这种情况下,实施复杂模型是难以证明其合理性的。在这种情况下,我们将构建两个基线模型:一个重复最后已知值,另一个重复最后 24 小时的数据。

我们首先创建将使用的数据窗口。记住,目标是预测未来 24 小时的全球活跃功率。因此,我们的标签序列长度为 24 个时间步,位移也将是 24 个时间步。我们还将使用 24 个时间步的输入长度。

multi_window = DataWindow(input_width=24, label_width=24, shift=24, 
➥ label_columns=['Global_active_power'])

接下来,我们将实现一个类,该类将重复输入序列的最后已知值作为对未来 24 小时的预测。

class MultiStepLastBaseline(Model):
    def __init__(self, label_index=None):
        super().__init__()
        self.label_index = label_index

    def call(self, inputs):
        if self.label_index is None:
            return tf.tile(inputs[:, -1:, :], [1, 24, 1])
        return tf.tile(inputs[:, -1:, self.label_index:], [1, 24, 1])

现在我们可以使用这个基线生成预测,并将其性能存储在字典中。这个字典将存储每个模型的性能,以便我们可以在最后进行比较。请注意,我们不会在构建模型时显示每个模型的 MAE。我们将在所有模型训练完毕后比较评估指标。

baseline_last = 
➥ MultiStepLastBaseline(label_index=column_indices['Global_active_power'])

baseline_last.compile(loss=MeanSquaredError(), 
➥ metrics=[MeanAbsoluteError()])

val_performance = {}
performance = {}

val_performance['Baseline - Last'] = 
➥ baseline_last.evaluate(multi_window.val)
performance['Baseline - Last'] = baseline_last.evaluate(multi_window.test, 
➥ verbose=0)

我们可以使用DataWindow类的plot方法来可视化预测结果,如图 18.8 所示。图中将显示三个图表,如DataWindow类中指定的。

multi_window.plot(baseline_last)

图片

图 18.8 基线模型的预测,简单地重复最后一个已知的输入值

在图 18.8 中,我们有一个工作基线——预测与最后一个输入值相同的水*线。你可能得到一个略有不同的图表,因为用于创建图表的缓存样本批次可能不同。然而,只要随机种子相同,模型的指标将与这里显示的相同。

接下来,让我们实现一个重复输入序列的基线模型。记住,我们在目标中识别出了日季节性,因此这相当于预测最后已知的季节。

class RepeatBaseline(Model):
    def __init__(self, label_index=None):
        super().__init__()
        self.label_index = label_index

    def call(self, inputs):
        return inputs[:, :, self.label_index:]

一旦定义了基线,我们就可以生成预测并存储基线的性能以进行比较。我们还可以可视化生成的预测,如图 18.9 所示。

baseline_repeat = 
➥ RepeatBaseline(label_index=column_indices['Global_active_power'])

baseline_repeat.compile(loss=MeanSquaredError(), 
➥ metrics=[MeanAbsoluteError()])

val_performance['Baseline - Repeat'] = 
➥ baseline_repeat.evaluate(multi_window.val)
performance['Baseline - Repeat'] = 
➥ baseline_repeat.evaluate(multi_window.test, verbose=0)

图片

图 18.9 作为基线预测的最后季节

在图 18.9 中,你会看到预测值等于输入序列,这是该基线模型预期的行为。在构建模型时,你可以随意打印出每个模型的 MAE。我将在本章末尾以条形图的形式展示它们,以确定哪个模型应该被选择。

在基线模型就绪后,我们可以继续到稍微复杂一点的线性模型。

18.5.2 线性模型

我们可以构建的最简单的模型之一是线性模型。这个模型仅由一个输入层和一个输出层组成。因此,仅计算一系列权重以生成尽可能接*标签的预测。

在这种情况下,我们将构建一个只有一个Dense输出层的模型,该层只有一个神经元,因为我们只预测一个目标。然后我们将训练模型并存储其性能。

label_index = column_indices['Global_active_power']
num_features = train_df.shape[1]

linear = Sequential([
    Dense(1, kernel_initializer=tf.initializers.zeros)
])

history = compile_and_fit(linear, multi_window)

val_performance['Linear'] = linear.evaluate(multi_window.val)
performance['Linear'] = linear.evaluate(multi_window.test, verbose=0)

和往常一样,我们可以使用plot方法可视化预测结果,如图 18.10 所示。

multi_window.plot(linear)

图片

图 18.10 线性模型生成的预测

现在让我们添加隐藏层并实现一个深度神经网络。

18.5.3 深度神经网络

之前的线性模型没有任何隐藏层;它只是一个输入层和一个输出层。现在我们将添加隐藏层,这将帮助我们模拟数据中的非线性关系。

在这里,我们将堆叠两个具有 64 个神经元的Dense层,并使用 ReLU 作为激活函数。然后我们将训练模型并存储其性能以供比较。

dense = Sequential([
    Dense(64, activation='relu'),
    Dense(64, activation='relu'),
    Dense(1, kernel_initializer=tf.initializers.zeros),
])

history = compile_and_fit(dense, multi_window)

val_performance['Dense'] = dense.evaluate(multi_window.val)
performance['Dense'] = dense.evaluate(multi_window.test, verbose=0)

您可以选择使用multi_window.plot(dense)可视化预测结果。

我们接下来要实现的是长短期记忆模型。

18.5.4 长短期记忆(LSTM)模型

长短期记忆(LSTM)模型的主要优势是它能将过去的信息保存在记忆中。这使得它特别适合处理数据序列,如时间序列。它允许我们结合现在和过去的信息来做出预测。

我们将在将输入序列发送到输出层之前,通过一个LSTM层,输出层仍然是一个具有一个神经元的Dense层。然后我们将训练模型并将性能存储在字典中以供比较。

lstm_model = Sequential([
    LSTM(32, return_sequences=True),
    Dense(1, kernel_initializer=tf.initializers.zeros),
])

history = compile_and_fit(lstm_model, multi_window)

val_performance['LSTM'] = lstm_model.evaluate(multi_window.val)
performance['LSTM'] = lstm_model.evaluate(multi_window.test, verbose=0)

我们可以可视化 LSTM 的预测结果——它们在图 18.11 中展示。

multi_window.plot(lstm_model)

图片

图 18.11 LSTM 模型生成的预测

现在让我们实现一个卷积神经网络。

18.5.5 卷积神经网络(CNN)

卷积神经网络(CNN)使用卷积函数来减少特征空间。这有效地过滤了时间序列并执行了特征选择。此外,由于操作是并行化的,CNN 的训练速度比 LSTM 快,而 LSTM 必须一次处理序列中的一个元素。

由于卷积操作会减少特征空间,我们必须提供一个稍微长一点的输入序列,以确保输出序列包含 24 个时间步长。需要多长取决于执行卷积操作的核的长度。在这种情况下,我们将使用一个长度为 3 的核。这是一个任意的选择,所以您可以自由地尝试不同的值,尽管您的结果可能与这里展示的不同。鉴于我们需要 24 个标签,我们可以使用方程式 18.1 来计算输入序列。

输入长度 = 标签长度 + 核长度 - 1

方程式 18.1

这迫使我们为 CNN 模型定义一个特定的数据窗口。请注意,由于我们正在定义一个新的数据窗口,用于绘图的样本批次将与之前使用的不同。

我们现在有了为 CNN 模型定义数据窗口所需的所有必要信息。

KERNEL_WIDTH = 3
LABEL_WIDTH = 24
INPUT_WIDTH = LABEL_WIDTH + KERNEL_WIDTH - 1

cnn_multi_window = DataWindow(input_width=INPUT_WIDTH, 
➥ label_width=LABEL_WIDTH, shift=24, 
➥ label_columns=['Global_active_power'])

接下来,我们将输入通过一个Conv1D层,该层过滤输入序列。然后它被送入一个有 32 个神经元的Dense层进行学习,然后再进入输出层。像往常一样,我们将训练模型并存储其性能以供比较。

cnn_model = Sequential([
    Conv1D(32, activation='relu', kernel_size=(KERNEL_WIDTH)),
    Dense(units=32, activation='relu'),
    Dense(1, kernel_initializer=tf.initializers.zeros),
])

history = compile_and_fit(cnn_model, cnn_multi_window)

val_performance['CNN'] = cnn_model.evaluate(cnn_multi_window.val)
performance['CNN'] = cnn_model.evaluate(cnn_multi_window.test, verbose=0)

我们现在可以可视化预测结果了。

cnn_multi_window.plot(cnn_model)

你会在图 18.12 中注意到,输入序列与我们的先前方法不同,因为使用 CNN 涉及再次对数据进行窗口化以考虑卷积核长度。训练、验证和测试集保持不变,因此仍然可以比较所有模型的性能。

图 18.12 CNN 模型生成的预测

现在让我们将 CNN 模型与 LSTM 模型结合起来。

18.5.6 将 CNN 与 LSTM 结合

我们知道 LSTM 擅长处理数据序列,而 CNN 可以过滤数据序列。因此,测试在将数据序列过滤后再输入到 LSTM 中是否会产生性能更好的模型是有趣的。

我们将输入序列送入一个Conv1D层,但这次使用 LSTM 层进行学习。然后我们将信息发送到输出层。再次,我们将训练模型并存储其性能。

cnn_lstm_model = Sequential([
    Conv1D(32, activation='relu', kernel_size=(KERNEL_WIDTH)),
    LSTM(32, return_sequences=True),
    Dense(1, kernel_initializer=tf.initializers.zeros),
])

history = compile_and_fit(cnn_lstm_model, cnn_multi_window)

val_performance['CNN + LSTM'] = 
➥ cnn_lstm_model.evaluate(cnn_multi_window.val)
performance['CNN + LSTM'] = cnn_lstm_model.evaluate(cnn_multi_window.test, 
➥ verbose=0)

预测结果如图 18.13 所示。

cnn_multi_window.plot(cnn_lstm_model)

图 18.13 CNN 与 LSTM 模型结合的预测

最后,让我们实现一个自回归 LSTM 模型。

18.5.7 自回归 LSTM 模型

我们将实现的最终模型是一个自回归 LSTM(ARLSTM)模型。与一次生成整个输出序列不同,自回归模型将一次生成一个预测,并将该预测作为输入生成下一个预测。这种架构存在于最先进的预测模型中,但有一个缺点。如果模型生成一个非常糟糕的第一个预测,这个错误将会传递到下一个预测中,从而放大错误。尽管如此,测试这个模型以查看它在我们的情况下是否表现良好是值得的。

第一步是定义实现 ARLSTM 模型的类。这与我们在第十七章中使用的类相同。

列表 18.2 实现 ARLSTM 模型的类

class AutoRegressive(Model):
    def __init__(self, units, out_steps):
        super().__init__()
        self.out_steps = out_steps
        self.units = units
        self.lstm_cell = LSTMCell(units)
        self.lstm_rnn = RNN(self.lstm_cell, return_state=True)
        self.dense = Dense(train_df.shape[1])

    def warmup(self, inputs):
        x, *state = self.lstm_rnn(inputs)
        prediction = self.dense(x)

        return prediction, state

    def call(self, inputs, training=None):
        predictions = []
        prediction, state = self.warmup(inputs)

        predictions.append(prediction)

        for n in range(1, self.out_steps):
            x = prediction
            x, state = self.lstm_cell(x, states=state, training=training)

            prediction = self.dense(x)
            predictions.append(prediction)

        predictions = tf.stack(predictions)
        predictions = tf.transpose(predictions, [1, 0, 2])

        return predictions

然后,我们可以使用这个类来初始化我们的模型。我们将在multi_window上训练模型并存储其性能以供比较。

AR_LSTM = AutoRegressive(units=32, out_steps=24)

history = compile_and_fit(AR_LSTM, multi_window)

val_performance['AR - LSTM'] = AR_LSTM.evaluate(multi_window.val)
performance['AR - LSTM'] = AR_LSTM.evaluate(multi_window.test, verbose=0)

然后,我们可以可视化自回归 LSTM 模型的预测,如图 18.14 所示。

multi_window.plot(AR_LSTM)

图 18.14 ARLSTM 模型的预测

现在我们已经构建了各种模型,让我们根据测试集上的 MAE 选择最佳模型。

18.5.8 选择最佳模型

我们为这个项目构建了许多模型,从线性模型到 ARLSTM 模型。现在让我们可视化每个模型的 MAE,以确定冠军。

我们将在验证集和测试集上绘制 MAE 图。结果如图 18.15 所示。

mae_val = [v[1] for v in val_performance.values()]
mae_test = [v[1] for v in performance.values()]

x = np.arange(len(performance))

fig, ax = plt.subplots()
ax.bar(x - 0.15, mae_val, width=0.25, color='black', edgecolor='black', 
➥ label='Validation')
ax.bar(x + 0.15, mae_test, width=0.25, color='white', edgecolor='black', 
➥ hatch='/', label='Test')
ax.set_ylabel('Mean absolute error')
ax.set_xlabel('Models')

for index, value in enumerate(mae_val):
    plt.text(x=index - 0.15, y=value+0.005, s=str(round(value, 3)), 
➥ ha='center')

for index, value in enumerate(mae_test):
    plt.text(x=index + 0.15, y=value+0.0025, s=str(round(value, 3)), 
➥ ha='center')
plt.ylim(0, 0.33)
plt.xticks(ticks=x, labels=performance.keys())
plt.legend(loc='best')
plt.tight_layout()

图片

图 18.15 比较所有测试模型的 MAE。ARLSTM 模型在测试集上实现了最低的 MAE。

图 18.15 显示,所有模型的表现都远远优于基线模型。此外,我们的冠军是 ARLSTM 模型,因为它在测试集上实现了 0.074 的 MAE,这是所有模型中最低的。因此,我们建议使用这个模型来预测未来 24 小时的全球有功功率。

18.6 下一步

恭喜您完成这个毕业设计项目!我希望您能够独立完成它,并且对使用深度学习模型进行时间序列预测的知识感到自信。

我强烈建议您将这个项目变成您自己的。您可以通过预测多个目标将这个项目转变为一个多元预测问题。您还可以更改预测范围。简而言之,进行更改并尝试不同的模型和数据,看看您能独立完成什么。

在下一章中,我们将开始这本书的最后一部分,我们将自动化预测过程。有许多库可以以最少的步骤生成准确的预测,并且它们在工业界中经常被使用,这使得它们成为时间序列预测的必备工具。我们将探讨一个广泛使用的库,称为 Prophet。

第四部分:大规模自动化预测

我们迄今为止都是手动构建我们的模型。这使我们能够对发生的事情进行细粒度控制,但这也可能是一个漫长的过程。因此,是时候探索一些自动时间序列预测的工具了。这些工具在业界广泛使用,因为它们易于使用,并允许快速实验。它们还实现了最先进的模型,使得任何数据科学家都能轻松访问。

在这里,我们将探索自动预测工具的生态系统,并重点关注 Prophet,因为它是最受欢迎的自动预测库之一,并且更多的新库在语法上模仿 Prophet。这意味着如果你知道如何使用 Prophet,那么使用另一个工具也会变得容易。

如前几部分所述,我们将以一个综合项目结束。

19 使用 Prophet 自动化时间序列预测

本章节涵盖了

  • 评估不同的自动化预测库

  • 探索 Prophet 的功能

  • 使用 Prophet 进行预测

在本书的整个过程中,我们构建了涉及许多手动步骤的模型。例如,对于 SARIMAX 模型的偏差,我们必须开发一个函数来根据赤池信息准则(AIC)选择最佳模型,以及一个函数来执行滚动预测。在本书的深度学习部分,我们必须构建一个类来创建数据窗口,以及定义所有深度学习模型,尽管这通过使用 Keras 得到了极大的简化。

虽然手动构建和调整我们的模型可以提供极大的灵活性并完全控制我们的预测技术,但自动化预测过程的大部分工作也是很有用的,这使得预测时间序列变得更加容易,并加速了实验。因此,了解自动化工具非常重要,因为它们是快速获得预测的一种方式,并且它们通常有助于使用最先进模型。

在本章中,我们将首先查看各种自动化时间序列预测过程的库。然后我们将专注于 Prophet 库,这可能是最知名和最广泛使用的预测库。我们将使用实际数据集来探索其功能。最后,我们将通过一个预测项目来结束本章,以便我们可以看到 Prophet 的实际应用。

19.1 自动化预测库概述

数据科学社区和公司已经开发了众多库来自动化预测过程并使其更加容易。以下列出了其中一些最受欢迎的库及其网站:

这绝对不是一份详尽的列表,我希望在它们的使用上保持公正。作为一名数据科学家,你拥有知识和能力来评估在特定情境下某个库是否适合你的需求。

pmdarima 库是 R 中流行的 auto.arima 库的 Python 实现。Pmdarima 实质上是一个包装器,它概括了我们使用过的许多统计模型,例如 ARMA、ARIMA 和 SARIMA 模型。这个库的主要优势是它提供了一个易于使用的界面,可以自动使用我们讨论过的所有工具进行统计模型预测,例如使用增广迪基-富勒(ADF)测试来检验*稳性,以及选择 pqPQ 的阶数以最小化 AIC。它还附带了一些玩具数据集,非常适合初学者在简单的时序数据上测试不同的模型。这个包是由社区构建和维护的,但最重要的是,在撰写本文时,它仍在积极维护中。

Prophet 是 Meta Open Source 的开源包,这意味着它是由 Meta 构建和维护的。这个库专门为大规模商业预测而构建。它起源于 Facebook 内部快速产生准确预测的需求,然后该库被免费提供给公众。Prophet 可以说是行业中最知名的预测库,因为它可以拟合非线性趋势,并结合多个季节性的影响。本章的剩余部分和下一章将完全专注于这个库,我们将在下一节中对其进行更详细的探讨。

NeuralProphet 是基于 Prophet 库构建的,用于自动化使用混合模型进行时间序列预测。这是一个相对较新的项目,在撰写本文时仍处于测试阶段。该库是由来自不同大学和 Facebook 的人合作开发的。这个包引入了经典模型(如 ARIMA)和神经网络相结合,以产生准确的预测。它使用 PyTorch 作为后端,这意味着有经验的用户可以轻松扩展库的功能。最重要的是,它使用与 Prophet 相似的 API,所以一旦你学会了如何使用 Prophet,就可以无缝过渡到使用 NeuralProphet。要了解更多信息,你可以阅读他们的论文,“NeuralProphet: Explainable Forecasting at Scale” (arxiv.org/abs/2111.15397)。它提供了关于 NeuralProphet 内部功能和性能基准的更多细节,同时仍然是一篇易于阅读的文章。

最后,PyTorch Forecasting 促进了最先进的深度学习模型在时间序列预测中的应用。当然,它使用 PyTorch,并提供了一个简单的接口来实现 DeepAR、N-Beats、LSTM 等模型。这个包是由社区构建的,在撰写本文时,它正在积极维护中。

注意:有关 DeepAR 的更多信息,请参阅 David Salinas、Valentin Flunkert、Jan Gasthaus、Tim Januschowski 的文章,“DeepAR:使用自回归循环网络的概率预测”,国际预测杂志 36:3 (2020),mng.bz/z4Kr。有关 N-Beats 的信息,请参阅 Boris N. Oreshkin、Dmitri Carpov、Nicolas Chapados、Yoshua Bengio 的文章,“N-BEATS:用于可解释时间序列预测的神经网络基础扩展分析”,arXiv:1905.10437 (2019),arxiv.org/abs/1905.10437

这为您提供了一个自动预测生态系统的简要概述。请注意,这份列表并不全面,因为还有许多更多用于自动时间序列预测的库。

您不需要学习如何使用我展示的每个库。这旨在概述可用的不同工具。每个时间序列预测问题可能需要不同的工具集,但知道如何使用其中一个库通常会使使用新的库变得更容易。因此,我们将专注于本书剩余部分的 Prophet 库。

正如我提到的,Prophet 是业界知名且广泛使用的库,任何进行时间序列预测的人很可能会遇到 Prophet。在下一节中,我们将更详细地探讨这个包,了解其优势、局限性和功能,然后再用它来进行预测。

19.2 探索 Prophet

Prophet 是由 Meta 创建的开源库,它实现了一个考虑多个季节性周期(如年度、月度、周度和日度)的非线性趋势预测过程。该包可用于与 Python 一起使用。它允许您快速预测,而无需进行大量手动工作。对于像我们这样的高级用户,可以微调模型以确保获得最佳结果。

在底层,Prophet 实现了一个通用加性模型,其中每个时间序列 y(t) 被建模为趋势 g(t)、季节性成分 s(t)、假日效应 h(t) 和误差项 ϵ[t*] 的线性组合,该误差项是正态分布的。数学上,这可以表示为方程式 19.1。

y(t) = g(t) + s(t) + h(t) + ϵ[t]

方程式 19.1

趋势成分模型时间序列的非周期性长期变化。季节性成分模型周期性变化,无论是年度、月度、周度还是日度。假日效应不规则地发生,可能发生在多天。最后,误差项代表任何无法由前三个成分解释的价值变化。

注意,这个模型没有考虑数据的时变性,与 ARIMA(p,d,q)模型不同,在 ARIMA 模型中,未来的值依赖于过去的值。因此,这个过程更接*于将曲线拟合到数据上,而不是寻找潜在的过程。虽然使用这种方法会有一些预测信息的损失,但它具有非常灵活的优点,因为它可以适应多个季节性周期和变化趋势。此外,它对异常值和缺失数据具有鲁棒性,这在商业环境中是一个明显的优势。

包含多个季节性周期是由观察到的现象所激发的,即人类行为产生了多周期季节性时间序列。例如,五天工作周可以产生每周重复一次的模式,而学校假期可以产生每年重复一次的模式。因此,为了考虑多个季节性周期,Prophet 使用傅里叶级数来模拟多个周期性效应。具体来说,季节性成分 s(t) 表示为方程 19.2,其中P是季节性周期的天数长度,N是傅里叶级数中的项数。

方程 19.2

方程 19.2

在方程 19.2 中,如果我们有一个年度季节性,P = 365.25,因为一年中有 365.25 天。对于每周季节性,P = 7。N简单地是我们希望用来估计季节性成分的参数数量。这还有一个额外的优点,即可以根据估计的参数数量N来调整季节性成分的敏感性。我们将在第 19.4 节中探讨这一点,当我们探索 Prophet 的不同功能时。默认情况下,Prophet 使用 10 个项来模拟年度季节性,使用 3 个项来模拟每周季节性。

最后,这个模型允许我们考虑节假日的影响。节假日是不规则事件,可以对时间序列产生明显的影响。例如,美国黑色星期五等事件可以显著增加商店的客流量或电子商务网站的销售额。同样,情人节可能是巧克力和鲜花销售额增加的强烈指标。因此,为了在时间序列中模拟节假日的影响,Prophet 允许我们为特定国家定义一个节假日列表。然后,将这些节假日效应纳入模型中,假设它们都是独立的。如果一个数据点落在节假日日期上,就会计算一个参数K[i]来表示该时间点时间序列的变化。变化越大,节假日的影响就越大。

注意:有关 Prophet 内部工作原理的更多信息,我强烈建议您阅读官方论文,Sean J. Taylor 和 Benjamin Letham,“大规模预测”,PeerJ Preprints 5:e3190v2 (2017),peerj.com/preprints/3190/。它包含对库的更详细解释,包括数学表达式和测试结果,同时保持易于理解。

Prophet 的灵活性使其成为快速准确预测的有吸引力的选择。然而,不应将其视为一刀切解决方案。文档本身指定 Prophet 在具有强烈季节性效应和多个历史季节数据的时间序列上表现最佳。因此,可能存在 Prophet 不是理想选择的情况,但这没关系,因为您工具箱中有各种统计和深度学习模型来生成预测。

让我们现在更深入地了解 Prophet 并探索其功能。

19.3 使用 Prophet 进行基本预测

为了配合我们对 Prophet 功能的探索,我们将使用包含 1981 年至 1990 年间在澳大利亚墨尔本记录的历史每日最低温度的数据集。除了预测天气外,这个数据集还可以帮助我们识别长期气候趋势,并确定每日最低温度是否随着时间的推移而增加。我们的预测范围将是 1 年或 365 天。因此,我们希望构建一个模型来预测下一年每日最低温度。

注意:您随时可以查阅 GitHub 上本章的源代码:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH19

Prophet 的安装与任何其他 Python 包一样简单。然后,您可以使用与使用 pandasnumpy 相同的语法在 Jupyter Notebook 或 Python 脚本中导入它。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from fbprophet import Prophet

关于在 Windows 上安装 Prophet 的注意事项

如果您使用的是 Windows 机器,强烈建议您使用 Anaconda 来执行任何数据科学任务。第一次尝试通过 Anaconda 安装 Prophet 可能会导致错误。这是因为为了使该包在 Windows 上正确运行,必须安装编译器。

如果您使用 Anaconda,您可以在您的 Anaconda 提示符中运行以下命令以成功安装 Prophet:

conda install libpython m2w64-toolchain -c msys2
conda install numpy cython matplotlib scipy pandas -c conda-forge
conda install -c conda-forge pystan
conda install -c conda-forge fbprophet

下一步当然是读取 CSV 文件。

df = pd.read_csv('../data/daily_min_temp.csv') 

我们现在可以绘制我们的时间序列图。

fig, ax = plt.subplots()

ax.plot(df['Temp'])
ax.set_xlabel('Date')
ax.set_ylabel('Minimum temperature (deg C)')

plt.xticks(np.arange(0, 3649, 365), np.arange(1981, 1991, 1))

fig.autofmt_xdate()
plt.tight_layout()

结果如图 19.1 所示。您将看到明显的年度季节性,这是预期的,因为温度通常在夏季较高,在冬季较低。因此,我们有一个相当大的数据集,包含 10 个季节的数据,这对于使用 Prophet 是一个完美的场景,因为当存在强烈的季节性效应和许多历史季节周期时,该库表现最佳。

图片

图 19.1 显示了 1981 年至 1991 年在墨尔本记录的每日最低温度。正如预期的那样,存在年度季节性,因为夏季更热,冬季更冷。

现在,我们可以继续使用 Prophet 进行预测。您将看到如何快速地使用 Prophet 获得准确的预测,而几乎不需要手动操作。

第一步是重命名我们的列。Prophet 预期有一个包含两列的 DataFrame:一列名为 ds 的日期列和一列名为 y 的值列。日期列必须使用 pandas 接受的格式——通常是 YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS。y 列包含要预测的值,这些值必须是数值型,无论是浮点数还是整数。在我们的案例中,数据集只有两列,格式已经正确,所以我们只需要重命名它们。

df.columns = ['ds', 'y']

接下来,我们将数据分为训练集和测试集。我们将保留最后 365 天作为测试集,因为这代表了一整年。然后我们将前 9 年的数据用于训练。

train = df[:-365]
test = df[-365:] 

Prophet 遵循 sklearn API,其中模型通过创建 Prophet 类的实例来初始化,使用 fit 方法训练模型,并使用 predict 方法生成预测。因此,我们将首先通过创建 Prophet 类的实例来初始化一个 Prophet 模型。注意,在本章中,我们将使用 Prophet 的命名约定进行编码。

m = Prophet() 

初始化完成后,我们将在训练集上拟合模型。

m.fit(train);

现在我们有一个模型,只需两行代码就可以生成预测。

下一步是创建一个 DataFrame 来存储 Prophet 的预测结果。我们将使用 make_future_dataframe 方法并指定周期数,即预测范围的天数。在这种情况下,我们想要 365 天的预测,这样它们可以与测试集中观察到的实际值进行比较。

future = m.make_future_dataframe(periods=365)

剩下的工作就是使用 predict 方法生成预测。

forecast = m.predict(future)

花点时间来欣赏这样一个事实:我们只用了四行代码就训练了一个模型并获得了预测。自动化预测库的主要好处之一是我们可以快速实验,并在以后调整模型以适应手头的任务。

然而,我们的工作还没有完成,因为我们希望评估模型并衡量其性能。forecast DataFrame 包含许多列,包含大量信息,如图 19.2 所示。

图片

图 19.2 显示了包含预测不同成分的 forecast DataFrame。注意,如果您将趋势与加性项一起添加,您将得到预测 yhat,它在图中被隐藏,因为 DataFrame 有太多列。还要注意,加性项是每周和每年的总和,这表明我们既有每周季节性也有年度季节性。

我们只对这四个列感兴趣:ds、yhat、yhat_lower 和 yhat_upper。ds 列简单地包含预测的日期戳。yhat 列包含预测的值。你可以看到 Prophet 如何使用 y 表示实际值,使用 yhat 表示预测值作为命名约定。然后,yhat_lower 和 yhat_upper 代表预测 80% 置信区间的下限和上限。这意味着有 80% 的可能性预测值将落在 yhat_lower 和 yhat_upper 之间,其中 yhat 是我们期望获得的价值。

我们现在可以将测试和预测合并在一起,创建一个包含实际值和预测值的单个 DataFrame

test[['yhat', 'yhat_lower', 'yhat_upper']] = forecast[['yhat', 
➥ 'yhat_lower', 'yhat_upper']]

在评估我们的模型之前,让我们先实现一个基线,因为我们的模型只有在与某个基准相比时才能变得更好。在这里,我们将应用上一个季度的简单预测方法,这意味着训练集的最后一年将被重复作为下一年的预测。

test['baseline'] = train['y'][-365:].values

所有的设置都是为了轻松评估我们的模型。我们将使用*均绝对误差(MAE)来评估其易于解释性。请注意,*均绝对百分比误差(MAPE)在这种情况下不适用,因为我们有接* 0 的值,在这种情况下,MAPE 会被放大。

from sklearn.metrics import mean_absolute_error

prophet_mae = mean_absolute_error(test['y'], test['yhat'])
baseline_mae = mean_absolute_error(test['y'], test['baseline'])

这返回了一个基线 MAE 为 2.87,而 Prophet 模型实现的 MAE 为 1.94。因此,我们使用 Prophet 实现了更低的 MAE,这意味着它确实比基线更好。这意味着,*均而言,我们的模型预测的每日最低温度与观察值之间的差异为 1.94 摄氏度,要么高于,要么低于观察值。

我们可以选择绘制预测图,以及 Prophet 的置信区间。结果如图 19.3 所示。

fig, ax = plt.subplots()

ax.plot(train['y'])
ax.plot(test['y'], 'b-', label='Actual')
ax.plot(test['yhat'], color='darkorange', ls='--', lw=3, label='Predictions')
ax.plot(test['baseline'], 'k:', label='Baseline')

ax.set_xlabel('Date')
ax.set_ylabel('Minimum temperature (deg C)')

ax.axvspan(3285, 3649, color='#808080', alpha=0.1)

ax.legend(loc='best')

plt.xticks(
    [3224, 3254, 3285, 3316, 3344, 3375, 3405, 3436, 3466, 3497, 3528, 
➥ 3558, 3589, 3619],
    ['Nov', 'Dec', 'Jan 1990', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 
➥ 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'])
plt.fill_between(x=test.index, y1=test['yhat_lower'], y2=test['yhat_upper'], 
➥ color='lightblue')
plt.xlim(3200, 3649)

fig.autofmt_xdate()
plt.tight_layout()

图片

图 19.3 预测 1990 年的每日最低温度。我们可以看到,Prophet 的预测(以虚线表示)比基线更*滑,这清楚地展示了 Prophet 的曲线拟合特性。

你会看到 Prophet 的预测看起来更像是一个曲线拟合过程,因为其预测(如图 19.3 中的虚线所示)是一条*滑的曲线,似乎过滤了数据中的噪声波动。

使用 Prophet 允许我们用很少的代码行生成准确的预测。然而,我们在 Prophet 的功能方面只是触及了皮毛。这仅仅是使用 Prophet 的基本工作流程。在下一节中,我们将探索更多高级的 Prophet 功能,例如可视化技术和微调过程,以及交叉验证和评估方法。

19.4 探索 Prophet 的高级功能

现在,我们将探索 Prophet 的更高级功能。这些高级功能可以分为三个类别:可视化、性能诊断和超参数调整。我们将使用与上一节相同的 dataset,并且我强烈建议你在之前的 Jupyter Notebook 或 Python 脚本中继续工作。

19.4.1 可视化功能

Prophet 提供了许多方法,使我们能够快速可视化模型预测或其不同成分。

首先,我们可以通过简单地使用plot方法快速生成我们的预测图表。结果如图 19.4 所示。

fig1 = m.plot(forecast)

图 19.4 使用 Prophet 绘制我们的预测。黑色点代表训练数据,而实线代表模型的预测。围绕线的阴影带代表 80%的置信区间。

我们还可以使用plot_components方法显示我们模型中使用的不同成分。

fig2 = m.plot_components(forecast)

最终的图表显示在图 19.5 中。顶部图表显示了趋势成分以及预测期间趋势的不确定性。仔细观察,你会发现趋势随时间变化,共有六个不同的趋势。我们将在稍后更详细地探讨这一点。

图 19.5 显示了我们的模型成分。在这里,我们的模型使用了一个趋势成分和两个不同的季节成分——一个具有每周周期,另一个具有年度周期。

图 19.5 的底部两个图表显示了两个不同的季节成分:一个具有每周周期,另一个具有年度周期。年度季节性是有意义的,因为夏季月份(12 月至 2 月,因为澳大利亚位于南半球)比冬季月份(6 月至 8 月)更热。然而,每周的季节性成分相当奇怪。虽然这可能有助于模型产生更好的预测,但我怀疑没有气象现象可以解释每日最低温度的每周季节性。因此,这个成分可能有助于模型达到更好的拟合和更好的预测,但很难解释其存在。

或者,Prophet 允许我们仅绘制季节成分。具体来说,我们可以使用plot_weekly方法绘制每周的季节性,或者使用plot_yearly方法绘制年度季节性。后者的结果如图 19.6 所示。

from fbprophet.plot import plot_yearly, plot_weekly

fig4 = plot_yearly(m)

图 19.6 展示了我们数据的年度季节成分。这相当于图 19.5 中的第三个图表。

你会认出我们数据的年度季节性成分,因为它与图 19.5 中的第三个图是相同的。然而,这种方法允许我们可视化改变估计季节性成分的项数如何影响我们的模型。回想一下,Prophet 使用傅里叶级数中的 10 个项来估计年度季节性。现在让我们可视化如果使用 20 个项进行估计的季节性成分。

m2 = Prophet(yearly_seasonality=20).fit(train)

fig6 = plot_yearly(m2)

在图 19.7 中,年度季节性成分的波动比图 19.6 中更大,这意味着它更敏感。如果使用过多的项,调整此参数可能导致过度拟合;如果我们减少傅里叶级数中的项数,则可能导致欠拟合。这个参数很少改变,但有趣的是看到 Prophet 附带这种微调功能。

图片

图 19.7 使用 20 个项来估计我们数据中的年度季节性成分。与图 19.6 相比,这种季节性成分的视图更敏感,因为它显示了更多的时间变化。这可能导致过度拟合。

最后,我们在图 19.5 中看到趋势随时间变化,并且我们可以识别出六个独特趋势。Prophet 可以识别这些趋势的变化点。我们可以使用add_changepoints_to_plot方法来可视化它们。

from fbprophet.plot import add_changepoints_to_plot

fig3 = m.plot(forecast)
a = add_changepoints_to_plot(fig3.gca(), m, forecast)

结果如图 19.8 所示。请注意,Prophet 识别出趋势变化的时间点。

图片

图 19.8 展示了模型中的趋势变化点。每个趋势变化点都由一条垂直虚线标识。请注意,有六条垂直虚线,与图 19.5 顶部图中的六个不同的趋势斜率相匹配。

我们已经探讨了 Prophet 最重要的可视化功能,现在让我们继续使用交叉验证来更详细地诊断我们的模型。

19.4.2 交叉验证和性能指标

Prophet 附带一个重要的交叉验证功能,允许我们在数据集的多个时期进行预测,以确保我们有一个稳定的模型。这类似于滚动预测程序。

图片

图 19.9 展示了 Prophet 中的交叉验证过程。整个矩形代表训练集,并确定集合的一个初始子集来拟合模型。在某个截止日期,模型在一定的预测范围内进行预测。在下一步中,更多的数据被添加到训练子集中,模型在另一个时间段内进行预测。然后重复此过程,直到预测范围超过训练集的长度。

记住,对于时间序列,数据的顺序必须保持不变。因此,交叉验证是通过在训练数据的一个子集上训练模型并在一定预测期限上进行预测来进行的。图 19.9 显示了我们是怎样首先在训练集中定义一个子集,并使用它来拟合模型和生成预测。然后我们向初始子集添加更多数据,并预测另一个时间段。这个过程会重复进行,直到整个训练集都被使用。

你会注意到这与滚动预测相似,但这次我们使用这种技术进行交叉验证,以确保我们有一个稳定的模型。一个稳定的模型是指在每个预测期间,评估指标相对恒定,保持预测期限不变。换句话说,无论模型必须从 1 月还是从 7 月开始预测 365 天,我们的模型性能都应该保持恒定。

Prophet 的cross_validation函数需要一个已经拟合到训练数据的 Prophet 模型。然后我们必须在交叉验证过程中指定训练集的初始长度,表示为initial。下一个参数是每个截止日期之间的时间长度,表示为period。最后,我们必须指定预测期限,表示为horizon。这三个参数必须与pandas.Timedelta类的单位兼容(pandas.pydata.org/docs/reference/api/pandas.Timedelta.xhtml)。换句话说,最大的单位是天,最小的单位是纳秒。介于两者之间的任何单位,如小时、分钟、秒或毫秒,都可以正常工作。

默认情况下,Prophet 使用horizon来确定initialperiod的长度。它将initial设置为horizon长度的三倍,将period设置为horizon长度的一半。当然,我们可以调整这种行为以满足我们的需求。

让我们从 730 天的初始训练期开始,这代表了两年的数据。预测的期限将是 365 天,每个截止日期之间相隔 180 天,大约是半年。考虑到我们的训练集大小,我们的交叉验证过程有 13 个步骤。该过程的输出是一个包含日期戳、预测值、上下限、实际值和截止日期的DataFrame,如图 19.10 所示。

from fbprophet.diagnostics import cross_validation

df_cv = cross_validation(m, initial='730 days', period='180 days', 
➥ horizon='365 days')    ❶

df_cv.head()

❶ 初始训练集包含 2 年的数据。每个截止日期之间相隔 180 天,即半年。预测期限是 365 天,即一年。

图片

图 19.10 我们交叉验证DataFrame的前五行。我们可以看到预测值、上下限以及截止日期。

交叉验证完成后,我们可以使用performance_metrics函数评估模型在多个预测周期内的性能。我们传入交叉验证的输出,即df_cv,并设置rolling_window参数。此参数确定我们想要计算误差度量数据的一部分。将其设置为 0 表示为每个预测点计算每个评估指标。将其设置为 1 表示在整个时间跨度上*均评估指标。在这里,让我们将其设置为 0。

from fbprophet.diagnostics import performance_metrics

df_perf = performance_metrics(df_cv, rolling_window=0)

df_perf.head()

此过程的输出如图 19.11 所示。MAPE 不包括在内,因为 Prophet 自动检测到我们有一些接* 0 的值,这使得 MAPE 成为一个不合适的评估指标。

图片 19-11

图 19.11 评估DataFrame的前五行。我们可以看到在不同时间跨度上的不同性能指标,这使我们能够可视化性能如何随时间跨度而变化。

最后,我们可以可视化评估指标随时间跨度的演变。这使我们能够确定误差是否会随着模型预测时间的进一步增加而增加,或者它是否保持相对稳定。再次,我们将使用 MAE,因为这是我们最初评估模型的方式。

from fbprophet.plot import plot_cross_validation_metric

fig7 = plot_cross_validation_metric(df_cv, metric='mae')

结果如图 19.12 所示。理想情况下,我们将看到一条相当*坦的线,如图 19.12 所示,这意味着我们的预测误差不会随着模型预测时间的进一步增加而增加。如果误差增加,我们应该修改预测时间跨度或确保我们能够接受误差的增加。

图片 19-12

图 19.12 预测时间跨度上 MAE 的演变。每个点代表 13 个预测周期中的一个预测期的绝对误差,而实线则是随时间*均这些误差。线相当*坦,这意味着我们有一个稳定的模型,其中误差不会随着预测时间的进一步增加而增加。

现在您已经看到了 Prophet 的交叉验证能力,我们将探讨超参数调整。将这两者结合起来将为我们找到问题的最佳模型提供一种稳健的方法。

19.4.3 超参数调整

我们可以在 Prophet 中将超参数调整和交叉验证结合起来,设计一个健壮的过程,该过程可以自动识别最佳的参数组合以适应我们的数据。

Prophet 附带了许多参数,这些参数可以通过更高级的用户进行微调以产生更好的预测。通常调整四个参数:changepoint_prior_scaleseasonality_prior_scaleholidays_prior_scaleseasonality_mode。技术上可以更改其他参数,但它们通常是前面参数的冗余形式:

  • changepoint_prior_scale—据说changepoint_prior_scale参数是 Prophet 中最有影响力的参数。它决定了趋势的灵活性,特别是趋势变化点处的趋势变化程度。如果参数太小,趋势将欠拟合,数据中观察到的方差将被视为噪声。如果设置得太高,趋势将过度拟合噪声波动。使用范围[0.001, 0.01, 0.1, 0.5]就足以得到一个拟合良好的模型。

  • seasonality_prior_scaleseasonality_prior_scale参数设置季节性的灵活性。大值允许季节性成分拟合较小的波动,而小值将导致更*滑的季节性成分。使用范围[0.01, 0.1, 1.0, 10.0]通常可以找到良好的模型。

  • holidays_prior_scaleholidays_prior_scale参数设置假日效应的灵活性,它的工作方式与seasonality_prior_scale相同。它可以使用相同的范围进行微调,即[0.01, 0.1, 1.0, 10.0]。

  • seasonality_modeseasonality_mode参数可以是additivemultiplicative。默认情况下,它是加性的,但如果您发现季节性波动随时间增大,则可以将其设置为乘法。这可以通过绘制时间序列来观察,但在有疑问的情况下,您可以将它包含在超参数调整过程中。我们当前的历史每日最低温度数据集是一个很好的加性季节性示例,因为年波动随时间不会增加。图 19.13 展示了乘性季节性的一个示例。

图片

图 19.13 示例:乘法季节性。此图取自第十一章的终期项目,其中我们预测了澳大利亚抗糖尿病药物处方的月度数量。我们不仅看到了年度季节性,而且还注意到随着时间的推移,波动幅度越来越大。

让我们将超参数调整和交叉验证结合起来,以找到预测每日最低温度的最佳模型参数。在这个例子中,我们只使用changepoint_prior_scaleseasonality_prior_scale,因为我们没有假日效应,我们的季节性成分是加性的。

我们首先定义每个参数尝试的值范围,并生成一个参数唯一组合列表。然后,对于每个唯一的参数组合,我们将训练一个模型并执行交叉验证。我们将使用 1 个rolling_window来评估模型,以加快过程并在整个预测期间*均评估指标。最后,我们将存储参数组合及其相关的 MAE,以找到最佳参数组合。MAE 最低的组合将被认为是最佳的。我们将使用 MAE,因为我们从项目开始就一直使用它。

from itertools import product

param_grid = {
    'changepoint_prior_scale': [0.001, 0.01, 0.1, 0.5],
    'seasonality_prior_scale': [0.01, 0.1, 1.0, 10.0]
}

all_params = [dict(zip(param_grid.keys(), v)) for v in 
➥ product(*param_grid.values())]                            ❶

maes = []

for params in all_params:                                    ❷
    m = Prophet(**params).fit(train)                         ❸
    df_cv = cross_validation(m, initial='730 days', period='180 days', 
➥ horizon='365 days', parallel='processes')                 ❹
    df_p = performance_metrics(df_cv, rolling_window=1)      ❺
    maes.append(df_p['mae'].values[0])

tuning_results = pd.DataFrame(all_params)                    ❻
tuning_results['mae'] = maes

❶ 创建一个唯一参数组合列表。

❷ 对于每个独特的组合,执行接下来的三个步骤。

❸ 拟合模型。

❹ 执行交叉验证。我们可以通过使用并行化来加速这个过程。

❺ 使用 1 的滚动窗口评估模型。这将在整个预测范围内*均性能。

❻ 将结果组织到 DataFrame 中。

现在可以找到实现最低 MAE 的参数:

best_params = all_params[np.argmin(maes)]

在这种情况下,changepoint_prior_scaleseasonality_prior_scale 都应设置为 0.01。

这标志着我们对 Prophet 高级功能的探索结束。我们主要在发现模式下使用它们,因此让我们通过设计和实施一个使用 Prophet 更高级功能的预测来巩固你所学的知识,例如交叉验证和超参数调整,以自动化预测流程。

19.5 使用 Prophet 实现稳健的预测流程

探索了 Prophet 的高级功能后,我们现在将使用 Prophet 设计一个强大且自动化的预测流程。这个逐步的系统将使我们能够自动找到 Prophet 为特定问题构建的最佳模型。

请记住,找到最佳的 Prophet 模型并不意味着 Prophet 是解决所有问题的最佳解决方案。这个过程只是在使用 Prophet 时确定最佳可能的结果。建议您测试各种模型,无论是深度学习还是统计技术,当然,还包括基线模型,以确保您找到解决预测问题的最佳解决方案。

图 19.14 使用 Prophet 的预测过程。首先,我们将确保数据集具有 Prophet 正确的列名,并且日期以日期戳或时间戳正确表达。然后,我们将超参数调整与交叉验证结合起来,以获得我们模型的最佳参数。最后,我们将使用最佳参数拟合模型,并在测试集上评估它。

图 19.14 展示了使用 Prophet 的预测过程,以确保我们获得最佳的 Prophet 模型。我们首先确保列名和格式正确,以便 Prophet 使用。然后,我们将交叉验证和超参数调整结合起来,以获得最佳参数组合,拟合模型,并在测试集上评估它。这是一个相当直接的过程,这是可以预料的。Prophet 为我们做了很多繁重的工作,使我们能够快速实验并制定出模型。

让我们将这个程序应用到另一个预测项目上。该项目涉及月度数据,Prophet 以特定方式处理这些数据。此外,我们将处理可能受节假日影响的数据,这为我们提供了一个机会,去探索我们尚未探索过的 Prophet 的一个函数。

19.5.1 预测项目:预测 Google 上“巧克力”搜索的热度

对于这个项目,我们将尝试预测谷歌上搜索词“巧克力”的流行度。预测搜索词的流行度可以帮助营销团队更好地优化特定关键词的出价,这当然会影响广告的点击成本,最终影响整个营销活动的投资回报率。它还可以提供对消费者行为的洞察。例如,如果我们知道下个月可能会看到人们搜索巧克力的激增,那么巧克力店提供折扣并确保有足够的供应来满足需求是有意义的。

这个项目的数据直接来自谷歌趋势(trends.google.com/trends/explore?date=all&geo=US&q=chocolate),它显示了从 2004 年到今天美国“巧克力”关键词的月度流行度。请注意,本章是在 2021 年底之前编写的,所以现在访问该链接不会得到完全相同的数据库。我已经在 GitHub 上包含了我使用的 CSV 文件,以确保您可以重现这里展示的工作。

我们将从这个项目开始,读取数据。

df = pd.read_csv('../data/monthly_chocolate_search_usa.csv')

该数据集包含从 2014 年 1 月到 2021 年 12 月的 215 行数据。数据集还包括两列:一列是年份和月份,另一列是“巧克力”搜索的测量流行度。我们可以绘制关键词搜索随时间演化的图表——结果如图 19.15 所示。该图表显示了具有每年重复峰值的高度季节性数据。我们还可以看到明显的趋势,因为随着时间的推移,数据在增加。

fig, ax = plt.subplots()

ax.plot(df['chocolate'])
ax.set_xlabel('Date')
ax.set_ylabel('Proportion of searches using the keyword "chocolate"')

plt.xticks(np.arange(0, 215, 12), np.arange(2004, 2022, 1))

fig.autofmt_xdate()
plt.tight_layout()

图 19.15

图 19.15 展示了从 2004 年 1 月到 2021 年 12 月美国谷歌搜索中“巧克力”关键词的流行度。数值表示相对于搜索词在 2020 年 12 月最流行的时期(值为 100)的比例。因此,特定月份的值为 50 意味着相对于 2020 年 12 月,“巧克力”关键词的搜索频率减半。

这个数据集有两个元素使得它非常适合用 Prophet 进行建模。首先,我们可能存在假日效应。例如,圣诞节在美国是一个假日,而且提供巧克力作为圣诞礼物是很常见的。下一个元素是我们有月度数据。虽然 Prophet 可以用来建模月度数据,但必须进行一些调整以确保我们得到好的结果。Prophet 默认可以处理日和亚日数据,但月度数据需要一些额外的工作。

在 Prophet 的预测流程中,如前图 19.14 所示,我们首先将根据 Prophet 的命名约定重命名我们的列。回想一下,Prophet 期望日期列命名为 ds,而值列必须命名为 y。

df.columns = ['ds', 'y'] 

我们现在可以继续验证日期是否正确格式化。在这种情况下,我们只有年份和月份,这不符合 Prophet 期望的 YYYY-MM-DD 格式。因此,我们将向我们的日期列添加一天。在这种情况下,我们拥有月度数据,这些数据只能在月底获得,所以我们将月底的最后一天添加到日期戳中。

from pandas.tseries.offsets import MonthEnd

df['ds'] = pd.to_datetime(df['ds']) + MonthEnd(1)

在我们深入超参数调整之前,我们首先将数据分为训练集和测试集,这样我们就可以只在训练集上执行超参数调整,避免数据泄露。在这种情况下,我们将保留最后十二个月作为测试集。

train = df[:-12]
test = df[-12:]

现在,我们将进入下一步,我们将结合超参数调整和交叉验证来找到我们模型的最佳参数组合。就像我们之前做的那样,我们将为每个我们希望调整的参数定义一个值范围,并构建一个包含每个唯一值组合的列表。

param_grid = {
    'changepoint_prior_scale': [0.001, 0.01, 0.1, 0.5],
    'seasonality_prior_scale': [0.01, 0.1, 1.0, 10.0]
}

params = [dict(zip(param_grid.keys(), v)) for v in product(*param_grid.values())]

注意:在这里,我们将不会优化holidays_prior_scale以节省时间,但您可以将它作为一个具有以下值范围的可调整参数添加:[0.01, 0.1, 1.0, 10.0]。

接下来,我们将创建一个列表来保存我们将使用的评估指标,以决定最优参数集。我们将使用均方误差(MSE),因为它在拟合过程中惩罚大误差。

mses = []

现在,因为我们正在处理月度数据,我们必须定义我们自己的截止日期。回想一下,截止日期在交叉验证期间定义了训练期和测试期,如图 19.16 所示。因此,当我们处理月度数据时,我们必须定义我们自己的截止日期列表,以指定交叉验证过程中每一步的初始训练期和预测期。这是一个允许我们使用 Prophet 处理月度数据的解决方案。

图 19.16 截止日期在交叉验证期间设置了训练期和预测期之间的边界。通过定义一个截止日期列表,我们可以指定交叉验证过程中每一步的初始训练期和预测期。

在这里,我们将初始训练期设置为数据的前 5 年。因此,我们的第一个截止日期将是 2009-01-31。最后一个截止日期可以设置为训练集的最后一行,我们将每 12 个月分隔一个截止日期,以便我们有一个可以预测整年的模型。

cutoffs = pd.date_range(start='2009-01-31', end='2020-01-31', freq='12M')  ❶

❶ 第一个截止日期是 2009-01-31,为我们提供了交叉验证第一步的 5 年初始训练数据。每个截止日期之间相隔 12 个月,直到训练集的末尾,从而实现了 1 年的预测期。

完成这一步后,我们可以使用交叉验证测试每个参数组合,并将它们的均方误差(MSEs)存储在一个DataFrame中。请注意,我们将使用简单的add_country_holidays方法添加节假日效应,并且我们将指定国家,在这种情况下是美国。

for param in params:
    m = Prophet(**param)
    m.add_country_holidays(country_name='US')     ❶
    m.fit(train)

    df_cv = cross_validation(model=m, horizon='365 days', cutoffs=cutoffs)
    df_p = performance_metrics(df_cv, rolling_window=1)
    mses.append(df_p['mse'].values[0])

tuning_results = pd.DataFrame(params)
tuning_results['mse'] = mses

❶ 添加美国的节假日日期。

超参数调整的完整代码如下所示。

列表 19.1 使用月度数据在 Prophet 中进行超参数调整

param_grid = {
    'changepoint_prior_scale': [0.001, 0.01, 0.1, 0.5],
    'seasonality_prior_scale': [0.01, 0.1, 1.0, 10.0]
}

params = [dict(zip(param_grid.keys(), v)) for v in 
➥ product(*param_grid.values())]

mses = []

cutoffs = pd.date_range(start='2009-01-31', end='2020-01-31', freq='12M')

for param in params:
    m = Prophet(**param)
    m.add_country_holidays(country_name='US')
    m.fit(train)

    df_cv = cross_validation(model=m, horizon='365 days', cutoffs=cutoffs)
    df_p = performance_metrics(df_cv, rolling_window=1)
    mses.append(df_p['mse'].values[0])

tuning_results = pd.DataFrame(params)
tuning_results['mse'] = mses

一旦这个过程完成,我们就可以提取最佳参数组合。

best_params = params[np.argmin(mses)]

结果是changepoint_prior_scale必须设置为 0.01,而seasonality_prior_scale也必须设置为 0.01。

现在我们已经得到了每个参数的最佳值,我们可以在整个训练集上拟合模型,以便稍后在测试集上评估它。

m = Prophet(**best_params)
m.add_country_holidays(country_name='US')
m.fit(train);

下一步是获取与测试集相同时期的模型预测,并将它们与测试集合并,以便更容易评估和绘图。

future = m.make_future_dataframe(periods=12, freq='M')
forecast = m.predict(future)
test[['yhat', 'yhat_lower', 'yhat_upper']] = forecast[['yhat', 
➥ 'yhat_lower', 'yhat_upper']]

在评估我们的模型之前,我们必须有一个基准,因此我们将使用上一个季节作为基线模型。

test['baseline'] = train['y'][-12:].values

我们现在可以评估 Prophet 模型了。我们将使用 MAE 来评估其易于解释性。

prophet_mae = mean_absolute_error(test['y'], test['yhat'])
baseline_mae = mean_absolute_error(test['y'], test['baseline'])

Prophet 实现了 7.42 的 MAE,而我们的基线模型实现了 10.92 的 MAE。由于 Prophet 的 MAE 较低,因此模型优于基线。

我们可以选择绘制预测图,如图 19.17 所示。请注意,此图还显示了 Prophet 模型的置信区间。

fig, ax = plt.subplots()

ax.plot(train['y'])
ax.plot(test['y'], 'b-', label='Actual')
ax.plot(test['baseline'], 'k:', label='Baseline')
ax.plot(test['yhat'], color='darkorange', ls='--', lw=3, label='Predictions')

ax.set_xlabel('Date')
ax.set_ylabel('Proportion of searches using the keyword "chocolate"')

ax.axvspan(204, 215, color='#808080', alpha=0.1)

ax.legend(loc='best')

plt.xticks(np.arange(0, 215, 12), np.arange(2004, 2022, 1))
plt.fill_between(x=test.index, y1=test['yhat_lower'], 
➥ y2=test['yhat_upper'], color='lightblue')          ❶
plt.xlim(180, 215)

fig.autofmt_xdate()
plt.tight_layout()

❶ 绘制 Prophet 模型的 80%置信区间。

在图 19.17 中,很明显,Prophet 的预测(以虚线表示)比基线模型的预测(以虚线表示)更接*实际值。这转化为 Prophet 的 MAE 更低。

图片

图 19.17 预测美国 Google 上“巧克力”搜索的流行度。Prophet 的预测,以虚线表示,比以虚线表示的基线模型更接*实际值。

我们可以通过绘制模型组件,如图 19.18 所示,进一步了解 Prophet 如何模拟我们的数据。

prophet_components_fig = m.plot_components(forecast)

图片

图 19.18 Prophet 模型的组成部分。趋势成分随时间增加,正如预期的那样。我们还可以看到节假日成分,它显示出负面的信号。这很有趣,因为它意味着 Prophet 使用了节假日来确定“巧克力”不是一个流行的搜索词。最后,我们有年度季节性成分,在 1 月份有峰值。

在图 19.18 中,您将看到第一个图中的趋势成分随时间增加,正如我们在首次绘制数据时所指出的。第二个图显示了节假日效应,这很有趣,因为负值中有低谷。这意味着 Prophet 使用了节假日列表来确定“巧克力”搜索可能减少的时间。这与我们最初的直觉相反,当时我们认为节假日可能决定巧克力更受欢迎的时间。最后,第三个图显示了年度季节性,峰值出现在年初和年末,这与圣诞节、新年和情人节相对应。

19.5.2 实验:SARIMA 能否做得更好?

在上一节中,我们使用 Prophet 来预测美国涉及关键词“巧克力”的 Google 搜索的流行度。我们的模型比基线模型表现更好,但看到 SARIMA 模型在这种情况下与 Prophet 相比如何会很有趣。本节是可选的,但这是一个很好的机会,用统计模型回顾我们的建模技能,最终也是一个有趣的实验。

让我们先导入我们需要的库。

from statsmodels.stats.diagnostic import acorr_ljungbox
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.stattools import adfuller
from tqdm import tqdm_notebook
from itertools import product
from typing import Union

接下来,我们将使用增强迪基-富勒(ADF)测试来检查数据是否*稳。

ad_fuller_result = adfuller(df['y'])

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

我们得到了一个 ADF 统计量为-2.03 和一个 p 值为 0.27。由于 p 值大于 0.05,我们未能拒绝零假设,并得出结论,我们的序列不是*稳的。

让我们再次对时间序列进行差分,并测试其*稳性。

y_diff = np.diff(df['y'], n=1)

ad_fuller_result = adfuller(y_diff)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

现在,我们得到了一个 ADF 统计量为-7.03,p 值远小于 0.05,因此我们拒绝零假设,并得出结论,我们的序列现在是*稳的。由于我们只差分了一次,没有进行季节差分,我们设置d = 1 和D = 0。由于我们有月度数据,频率是m = 12。正如你所看到的,有季节数据并不意味着我们必须进行季节差分来使其*稳。

现在我们将使用optimize_SARIMAX函数,如列表 19.2 所示,来找到使赤池信息准则(AIC)最小化的pqPQ的值。请注意,尽管该函数的名称中有 SARIMAX,但我们可以使用它来优化 SARIMAX 模式的任何变体。在这种情况下,我们将通过将外生变量设置为None来优化一个 SARIMA 模型。

列表 19.2 最小化 SARIMAX 模型 AIC 的函数

def optimize_SARIMAX(endog: Union[pd.Series, list], 
➥ exog: Union[pd.Series, list], 
➥ order_list: list, d: int, D: int, s: int) -> pd.DataFrame:

    results = []
    for order in tqdm_notebook(order_list):
        try: 
            model = SARIMAX(
                endog,
                exog,
                order=(order[0], d, order[1]),
                seasonal_order=(order[2], D, order[3], s),
                simple_differencing=False).fit(disp=False)
        except:
            continue

        aic = model.aic
        results.append([order, model.aic])

    result_df = pd.DataFrame(results)
    result_df.columns = ['(p,q,P,Q)', 'AIC']

    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)

    return result_df

为了找到最佳参数,我们首先为每个参数定义一个值范围,并创建一个唯一组合的列表。然后我们可以将此列表传递给optimize_SARIMAX函数。

ps = range(0, 4, 1)
qs = range(0, 4, 1)
Ps = range(0, 4, 1)
Qs = range(0, 4, 1)

order_list = list(product(ps, qs, Ps, Qs))

d = 1
D = 0
s = 12

SARIMA_result_df = optimize_SARIMAX(train['y'], None, order_list, d, D, s)
SARIMA_result_df

结果的DataFrame(如图 19.19 所示)很有趣。最低的 AIC 是 143.51,第二低的 AIC 是 1,127.75。差异非常大,这表明第一组pdPQ值有问题。

图片

图 19.19 按 AIC 升序排列参数(pdPQ)。我们可以看到DataFrame的前两个条目之间存在很大差异。这表明第一组参数有问题,我们应该选择第二组。

因此,我们将使用第二组值,将pqPQ的值设置为 1,得到 SARIMA(1,1,1)(1,0,1)[12]模型。我们可以使用这些值在训练集上拟合模型,并研究其残差,如图 19.20 所示。

SARIMA_model = SARIMAX(train['y'], order=(1,1,1), 
➥ seasonal_order=(1,0,1,12), simple_differencing=False)
SARIMA_model_fit = SARIMA_model.fit(disp=False)

SARIMA_model_fit.plot_diagnostics(figsize=(10,8));

图片

图 19.20 SARIMA(1,1,1)(1,0,1)[12]模型的残差。在左上角,你可以看到残差是随机的,没有趋势。在右上角,分布接*正态分布,但在右侧有一些偏差。这一点在左下角的 Q-Q 图中得到了进一步的支持,我们看到了一条相当直的线,它位于y = x上,但末端有明显的偏离。最后,右下角的 correlogram 显示在滞后 0 之后没有显著系数,就像白噪声一样。

在这一点上,很难确定残差是否足够接*白噪声,因此我们将使用 Ljung-Box 测试来确定残差是否独立且不相关。

residuals = SARIMA_model_fit.resid

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))

返回的 p 值都大于 0.05,除了第一个,它为 0.044。由于其他九个 p 值都大于 0.05,我们可以假设我们可以拒绝零假设,并得出结论,这是我们残差接*白噪声所能达到的极限。

接下来,让我们从测试集的时期生成 SARIMA 模型的预测。

SARIMA_pred = SARIMA_model_fit.get_prediction(204, 215).predicted_mean

test['SARIMA_pred'] = SARIMA_pred

最后,我们将测量 SARIMA 模型的 MAE。记住,我们的 Prophet 模型 MAE 为 7.42,基线模型的 MAE 为 10.92。

SARIMA_mae = mean_absolute_error(test['y'], test['SARIMA_pred'])

在这里,SARIMA 模型实现了 10.09 的 MAE。它比基线模型好,但在这个情况下并不比 Prophet 模型表现更好。

19.6 下一步

在本章中,我们探讨了使用 Prophet 库进行自动时间序列预测的应用。Prophet 使用一个组合趋势成分、季节成分和假日效应的广义加性模型。

这个库的主要优势在于它允许我们快速实验并生成预测。许多函数可用于可视化和理解我们的模型,而且还有更多高级功能,允许我们执行交叉验证和超参数调整。

虽然 Prophet 在业界被广泛使用,但它不能被视为一种万能的解决方案。Prophet 特别适用于具有许多历史季节的强季节性数据。因此,它应该被视为我们预测工具包中的另一个工具,可以与其他统计或深度学习模型一起测试。

在这本书中,我们探讨了时间序列预测的基础,现在你已经看到了一种自动化我们使用统计和深度学习模型所做的大部分手动工作的方法。我强烈建议你浏览 Prophet 的文档以获取更详细的信息,并探索其他自动预测库。现在你已经知道如何使用一个库,转向另一个库是非常容易的。

在下一章中,我们将完成一个最终的综合项目,并预测加拿大牛肉的价格。这是一个很好的机会来应用我们使用 Prophet 开发的预测程序,以及尝试你迄今为止学到的其他模型,以开发最佳解决方案。

19.7 练习

在这里,我们将回顾前几章的问题,但使用 Prophet 进行预测。然后我们可以比较 Prophet 与之前构建的模型的性能。一如既往,解决方案可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH19

19.7.1 预测航空乘客数量

在第八章中,我们使用了一个追踪 1949 年至 1960 年间每月航空乘客数量的数据集。我们开发了一个 SARIMA 模型,实现了 2.85% 的 MAPE。

使用 Prophet 预测数据集的最后 12 个月:

  1. 添加节假日效应是否有意义?

  2. 观察数据,季节性是加性的还是乘性的?

  3. 使用超参数调整和交叉验证来寻找最佳参数。

  4. 使用最佳参数拟合模型,并评估其对过去 12 个月的预测。是否实现了更低的 MAPE?

19.7.2 预测抗糖尿病药物处方的数量

在第十一章中,我们完成了一个综合项目,预测澳大利亚每月抗糖尿病药物处方的月度数量。我们开发了一个 SARIMA 模型,实现了 7.9% 的 MAPE。

使用 Prophet 预测数据集的最后 36 个月:

  1. 添加节假日效应是否有意义?

  2. 观察数据,季节性是加性的还是乘性的?

  3. 使用超参数调整和交叉验证来寻找最佳参数。

  4. 使用最佳参数拟合模型,并评估其对过去 36 个月的预测。是否实现了更低的 MAPE?

Google Trends (trends.google.com/trends/) 是生成时间序列数据集的好地方。这里你可以看到全球范围内在 Google 上流行的搜索。

选择一个关键词和您选择的任何国家,生成一个时间序列数据集。然后使用 Prophet 预测其未来的流行度。这是一个非常开放的项目,没有解决方案。利用这个机会探索 Google Trends 工具,并实验 Prophet 来了解什么有效,什么无效。

摘要

  • 有许多库可以自动化预测过程,例如 pmdarima、Prophet、NeuralProphet 和 PyTorch Forecasting。

  • Prophet 是行业内最广为人知且广泛使用的自动时间序列预测库之一。了解如何使用它对于任何进行时间序列预测的数据科学家来说都很重要。

  • Prophet 使用一个组合趋势成分、季节成分和节假日效应的通用加性模型。

  • Prophet 并不是所有问题的最优解决方案。它在具有多个历史季节训练数据的强季节性数据上表现最佳。因此,它必须被视为预测的几种工具之一。

20 综合项目:预测加拿大牛肉的月*均零售价格

本章涵盖

  • 开发预测模型以预测加拿大牛肉的月*均零售价格

  • 使用 Prophet 的交叉验证功能

  • 开发 SARIMA 模型,并将其性能与 Prophet 进行比较,以确定冠军模型

再次,恭喜你走到这一步!自从本书开始以来,我们已经走了很长的路。我们首先定义了时间序列,并学习了如何使用 SARIMAX 模型这类统计模型来预测它们。然后我们转向大型、高维数据集,并使用深度学习进行时间序列预测。在前一章中,我们介绍了一个用于自动化整个预测过程的流行库:Prophet。我们使用 Prophet 开发了两个预测模型,并看到了如何通过少量手动步骤快速轻松地生成准确的预测。

在这个最后的综合项目中,我们将利用本书中学到的所有知识来预测加拿大牛肉的月*均零售价格。到目前为止,我们拥有一个稳健的方法论和一系列广泛的工具来开发一个性能良好的预测模型。

20.1 理解综合项目

对于这个项目,我们将使用从 1995 年到今天的加拿大食品历史月*均零售价格。请注意,在撰写本文时,2021 年 12 月及以后的数据尚未可用。数据集名为“食品和其他选定产品的月*均零售价格”,可以从加拿大统计局下载:www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1810000201

一篮子商品的价格是一个重要的宏观经济指标。这是消费者价格指数(CPI)的组成部分,用于确定是否存在通货膨胀或通货紧缩时期。这反过来又允许分析师评估经济政策的有效性,当然,它还可以影响政府援助计划,如社会保障。如果预计商品价格将上涨,社会保障的预留金额在技术上应该增加。

原始数据集包含 52 种商品的月*均零售价格,从 1 公斤圆牛肉到一打鸡蛋,60 克除臭剂,以及汽油等。价格以加拿大元为单位,从 1995 年开始到 2021 年 11 月。对于这个项目,我们将专注于预测 1 公斤圆牛肉的价格。

20.1.1 综合项目的目标

这个综合项目的目标是创建一个模型,可以预测未来 36 个月内 1 公斤圆牛肉的月*均零售价格。如果你有信心,可以下载数据集并开发一个预测模型。请随意使用 Prophet。

如果你觉得自己需要更多指导,以下是需要完成的步骤:

  1. 清洗数据,以便您只有关于 1 公斤圆牛排的信息。

  2. 根据 Prophet 的约定重命名列。

  3. 正确格式化日期。日期戳只有年和月,所以必须添加日。回想一下,我们正在处理月*均数,那么添加月初还是月末更有意义呢?

  4. 使用 Prophet 进行超参数调整的交叉验证。

  5. 使用最佳参数拟合 Prophet 模型。

  6. 在测试集上进行预测。

  7. 使用*均绝对误差(MAE)评估您的模型。

  8. 将您的模型与基线进行比较。

还有另一个可选但强烈推荐的步骤:

  1. 开发一个 SARIMA 模型,并将其性能与 Prophet 进行比较。它做得更好吗?

您现在拥有了成功完成此项目所需的所有步骤。我强烈建议您先自己尝试。在任何时候,您都可以参考以下部分以获取详细说明。此外,整个解决方案在 GitHub 上可用:github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH20。祝您好运!

20.2 数据预处理和可视化

我们将首先预处理数据以训练 Prophet 模型。同时,我们将可视化我们的时间序列以推断其一些属性。

首先,我们将导入所需的库。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from fbprophet import Prophet
from fbprophet.plot import plot_cross_validation_metric
from fbprophet.diagnostics import cross_validation, performance_metrics

from sklearn.metrics import mean_absolute_error

from itertools import product

import warnings
warnings.filterwarnings('ignore')

我还喜欢为图形设置一些通用参数。在这里,我们将指定大小并从图中移除网格。

plt.rcParams['figure.figsize'] = (10, 7.5)
plt.rcParams['axes.grid'] = False

接下来,我们将读取数据。您可以从这里下载它(www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1810000201),尽管您很可能会得到一个更新版本的数据库,因为我在写这本书时只有到 2021 年 11 月的数据。如果您想重现这里显示的结果,我建议您使用 GitHub 仓库中该章节的 CSV 文件(github.com/marcopeix/TimeSeriesForecastingInPython/tree/master/CH20)。

df = pd.read_csv('../data/monthly_avg_retail_price_food_canada.csv')

在其原始形式中,该数据库包含从 1995 年 1 月到 2021 年 11 月的 52 种产品的月*均零售价。我们希望特别预测 1 公斤圆牛排的零售价,因此我们可以相应地筛选数据。

df = df[df['Products'] == 'Round steak, 1 kilogram']

下一步是删除不必要的列,只保留包含数据点的月份和年份的 REF_DATE 列,以及包含该月*均零售价的 VALUE 列。

cols_to_drop = ['GEO', 'DGUID', 'Products', 'UOM', 'UOM_ID',
       'SCALAR_FACTOR', 'SCALAR_ID', 'VECTOR', 'COORDINATE', 'STATUS',
       'SYMBOL', 'TERMINATED', 'DECIMALS']

df = df.drop(cols_to_drop, axis=1)

现在我们有一个 2 列和 323 行的数据集。这是可视化我们的时间序列的好时机。结果如图 20.1 所示。

fig, ax = plt.subplots()

ax.plot(df['VALUE'])
ax.set_xlabel('Date')
ax.set_ylabel('Average retail price of 1kg of round steak (CAD')

plt.xticks(np.arange(0, 322, 12), np.arange(1995, 2022, 1))

fig.autofmt_xdate()

图片

图 20.1 展示了从 1995 年 1 月到 2021 年 11 月加拿大 1 公斤圆牛排的月度*均零售价格。数据随时间增加呈现出明显的趋势。然而,这里似乎没有季节性。这可能表明 Prophet 不是解决这个问题的最佳工具。

图 20.1 显示了我们的数据中的明显趋势,但在这个时间序列中没有明显的季节性。因此,Prophet 可能不是解决这类问题的最佳工具。然而,这纯粹是直觉,所以我们将对其进行基准测试,看看我们是否可以成功预测我们的目标。

20.3 使用 Prophet 进行建模

我们已经预处理了数据并对其进行了可视化。下一步是根据 Prophet 的命名约定重命名列。时间列必须命名为 ds,值列必须命名为 y。

df.columns = ['ds', 'y']

接下来,我们必须正确地格式化日期。目前我们的日期戳只有年份和月份,但 Prophet 还期望日期以 YYYY-MM-DD 的格式包含一天。由于我们处理的是月度*均值,我们必须将月份的最后一天添加到日期戳中,因为我们不能在 1 月的最后一天之前报告 1 月的*均零售价格。

from pandas.tseries.offsets import MonthEnd

df['ds'] = pd.to_datetime(df['ds']) + MonthEnd(1)

我们的数据现在格式正确,因此我们将数据集分为训练集和测试集。我们的目标是预测未来的 36 个月,因此我们将最后 36 个数据点分配给测试集。其余的用于训练。

train = df[:-36]
test = df[-36:]

现在我们可以处理超参数调整。我们首先定义一个可能的changepoint_prior_scaleseasonality_prior_scale值的列表。我们不会包括任何假日效应,因为它们可能不会影响商品的价格。然后我们将创建一个包含所有唯一组合的列表。在这里,我们将使用均方误差(MSE)作为选择标准,因为它惩罚大误差,我们希望得到最佳拟合模型。

param_grid = {
    'changepoint_prior_scale': [0.01, 0.1, 1.0],
    'seasonality_prior_scale': [0.1, 1.0, 10.0]
}

params = [dict(zip(param_grid.keys(), v)) for v in 
➥ product(*param_grid.values())]

mses = []

现在我们必须定义一个截止日期列表。回想一下,这是使用 Prophet 处理月度数据的一个解决方案。截止日期指定了初始训练集和交叉验证期间测试期的长度。

在这种情况下,我们将允许使用前 5 年的数据作为初始训练集。然后每个测试周期必须长度为 36 个月,因为这是我们的目标陈述中的范围。因此,我们的截止日期从 2001-01-31 开始,到训练集的结束,即 2018-11-30,每个截止日期之间相隔 36 个月。

cutoffs = pd.date_range(start='2000-01-31', end='2018-11-30', freq='36M')

现在我们可以测试每个参数组合,拟合一个模型,并使用交叉验证来衡量其性能。具有最低均方误差(MSE)的参数组合将被选中,以生成测试集上的预测。

for param in params:
    m = Prophet(**param)
    m.fit(train)

    df_cv = cross_validation(model=m, horizon='365 days', cutoffs=cutoffs)
    df_p = performance_metrics(df_cv, rolling_window=1)
    mses.append(df_p['mse'].values[0])

tuning_results = pd.DataFrame(params)
tuning_results['mse'] = mses

best_params = params[np.argmin(mses)]
print(best_params)

这表明changepoint_prior_scaleseasonality_prior_scale都应该设置为 1.0。因此,我们将使用best_params定义一个 Prophet 模型,并在训练集上拟合它。

m = Prophet(**best_params)
m.fit(train);

接下来,我们将使用make_future_dataframe来定义预测范围。在这种情况下,它是 36 个月。

future = m.make_future_dataframe(periods=36, freq='M')

现在我们可以生成预测。

forecast = m.predict(future)

让我们将它们添加到我们的测试集中,这样更容易评估性能并将预测值与观测值进行对比。

test[['yhat', 'yhat_lower', 'yhat_upper']] = forecast[['yhat', 
➥ 'yhat_lower', 'yhat_upper']]

当然,我们的模型必须与基准进行比较。在这个例子中,我们将简单地使用训练集的最后一个已知值作为对未来 36 个月的预测。我们也可以使用*均值方法,但我会考虑最*几年的*均值,因为数据中存在明显的趋势,这意味着*均值会随时间变化。在这里使用简单的季节性方法是不合适的,因为数据中不存在明显的季节性。

test['Baseline'] = train['y'].iloc[-1]

所有的评估准备工作都已就绪。我们将使用*均绝对误差(MAE)来选择最佳模型。这个指标被选中是因为它易于解释。

baseline_mae = mean_absolute_error(test['y'], test['Baseline'])
prophet_mae = mean_absolute_error(test['y'], test['yhat'])

print(prophet_mae)
print(baseline_mae)

因此,我们使用基线模型获得了 0.681 的*均绝对误差,而 Prophet 达到了 1.163。因此,Prophet 的表现不如基线模型,后者简单地使用最后一个已知值作为预测。

我们可以在图 20.2 中可视化预测结果。

fig, ax = plt.subplots()

ax.plot(train['y'])
ax.plot(test['y'], 'b-', label='Actual')
ax.plot(test['Baseline'], 'k:', label='Baseline')
ax.plot(test['yhat'], color='darkorange', ls='--', lw=3, 
➥ label='Predictions')

ax.set_xlabel('Date')
ax.set_ylabel('Average retail price of 1kg of round steak (CAD')

ax.axvspan(287, 322, color='#808080', alpha=0.1)

ax.legend(loc='best')

plt.xticks(np.arange(0, 322, 12), np.arange(1995, 2022, 1))
plt.fill_between(x=test.index, y1=test['yhat_lower'], 
➥ y2=test['yhat_upper'], color='lightblue')
plt.xlim(250, 322)

fig.autofmt_xdate()
plt.tight_layout()

图片

图 20.2 预测加拿大 1 公斤圆牛肉的月*均零售价格。我们可以看到,Prophet(以虚线表示)往往超过观测值。

我们还可以在图 20.3 中可视化模型的组成部分。

prophet_components_fig = m.plot_components(forecast)

图片

图 20.3 Prophet 模型的组成部分。顶部图显示了趋势成分,有许多转折点,因为我们把changepoint_prior_scale设置得较高,允许趋势更加灵活。底部图显示了年度季节性成分。同样,这个成分可能有助于提高模型的拟合度,但我怀疑没有明显的理由要在 9 月份附*降低商品价格。

图 20.3 显示了 Prophet 模型的组成部分。顶部图显示了趋势成分,具有许多转折点。这是因为我们通过将changepoint_prior_scale设置为 1.0,允许趋势非常灵活,从而在交叉验证期间得到了最佳拟合。

底部图显示了年度季节性成分。这个成分可能有助于 Prophet 实现更好的拟合,但我怀疑没有明显的理由会导致商品价格在 9 月份附*下降。这突出了 Prophet 的曲线拟合过程。它也是一个很好的例子,说明领域知识可能有助于我们更好地微调这个参数。

因此,我们发现了一个 Prophet 不是理想解决方案的情况。实际上,它的表现比我们的简单预测方法还要差。我们知道 Prophet 在强烈季节性数据上表现最佳,但我们直到实际测试之前无法确定。

项目的下一部分是可选的,但我强烈建议您完成它,因为它展示了一个时间序列预测问题的完整解决方案。我们已经测试了 Prophet,但没有获得令人满意的结果,但这并不意味着我们必须放弃。相反,我们必须寻找另一种解决方案并对其进行测试。由于我们没有大量数据集,深度学习不是解决这个问题的一个合适工具。因此,让我们尝试使用 SARIMA 模型。

20.4 可选:开发 SARIMA 模型

在上一节中,我们使用了 Prophet 来预测加拿大 1 公斤圆牛排的月*均零售价格,但 Prophet 的表现不如我们的基线模型。现在我们将开发一个 SARIMA 模型,看看它是否能比我们的基线模型有更好的性能。

第一步是导入所需的库。

from statsmodels.stats.diagnostic import acorr_ljungbox
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.stattools import adfuller
from tqdm import tqdm_notebook
from typing import Union

接下来,我们将测试*稳性。这将确定积分阶数 d 和季节积分阶数 D 的值。回想一下,我们正在使用 ADF 测试来测试*稳性。

ad_fuller_result = adfuller(df['y'])

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

这里我们得到一个 ADF 统计量为 0.31 和一个 p 值为 0.98。由于 p 值大于 0.05,我们得出结论,该序列不是*稳的。这是预期的,因为我们可以在数据中清楚地看到趋势。

我们将序列差分一次,然后再次测试*稳性。

y_diff = np.diff(df['y'], n=1)

ad_fuller_result = adfuller(y_diff)

print(f'ADF Statistic: {ad_fuller_result[0]}')
print(f'p-value: {ad_fuller_result[1]}')

现在我们有一个 ADF 统计量为 –16.78,p 值远小于 0.05。因此,我们得出结论,我们有一个*稳的时间序列。因此,d = 1 和 D = 0。回想一下,SARIMA 还需要设置频率 m。由于我们有月度数据,频率是 m = 12。

接下来,我们将使用列表 20.1 中所示的 optimize_SARIMAX 函数来找到使赤池信息准则(AIC)最小化的参数 (p, q, P, Q)。

列表 20.1 选择使 AIC 最小的参数的函数

def optimize_SARIMAX(endog: Union[pd.Series, list], exog: Union[pd.Series, 
➥ list], order_list: list, d: int, D: int, s: int) -> pd.DataFrame:

    results = []

    for order in tqdm_notebook(order_list):
        try: 
            model = SARIMAX(
                endog,
                exog,
                order=(order[0], d, order[1]),
                seasonal_order=(order[2], D, order[3], s),
                simple_differencing=False).fit(disp=False)
        except:
            continue

        aic = model.aic
        results.append([order, model.aic])

    result_df = pd.DataFrame(results)
    result_df.columns = ['(p,q,P,Q)', 'AIC']

    #Sort in ascending order, lower AIC is better
    result_df = result_df.sort_values(by='AIC', 
➥ ascending=True).reset_index(drop=True)

    return result_df

我们将定义 p, q, P, 和 Q 的可能值范围,生成所有唯一组合的列表,并运行 optimize_SARIMAX 函数。请注意,我们没有外生变量。

ps = range(1, 4, 1)
qs = range(1, 4, 1)
Ps = range(1, 4, 1)
Qs = range(1, 4, 1)

order_list = list(product(ps, qs, Ps, Qs))

d = 1
D = 0
s = 12

SARIMA_result_df = optimize_SARIMAX(train['y'], None, order_list, d, D, s)
SARIMA_result_df

一旦搜索完成,我们会发现 p = 2, q = 3, P = 1, 和 Q = 1 是导致最低 AIC 的组合。现在我们可以使用这个参数组合来拟合模型,并在图 20.4 中研究其残差,结果证明这些残差是完全随机的。

SARIMA_model = SARIMAX(train['y'], order=(2,1,3), 
➥ seasonal_order=(1,0,1,12), simple_differencing=False)
SARIMA_model_fit = SARIMA_model.fit(disp=False)

SARIMA_model_fit.plot_diagnostics(figsize=(10,8));

图片

图 20.4 SARIMA(2,1,3)(1,0,1)[12]模型的残差。左上角的图显示了随时间变化的残差,它们是完全随机的,没有趋势,方差相当恒定,就像白噪声。右上角的图显示了残差的分布,非常接*正态分布。这一点在左下角的 Q-Q 图中得到了进一步的支持。我们看到一条直线位于y = x上,因此我们可以得出结论,残差是正态分布的,就像白噪声。最后,右下角的 correlogram 在滞后 0 之后没有显示显著的系数,这与白噪声的行为相同。我们可以得出结论,残差是完全随机的。

我们可以使用 Ljung-Box 测试进一步支持我们的结论。回想一下,Ljung-Box 测试的零假设是数据是不相关的且独立的。

residuals = SARIMA_model_fit.resid

lbvalue, pvalue = acorr_ljungbox(residuals, np.arange(1, 11, 1))

print(pvalue)

返回的 p 值都大于 0.05,因此我们不能拒绝零假设,反而可以得出结论,残差确实是随机的且相互独立的。因此,我们的 SARIMA 模型可以用于预测。

我们将在测试集的范围内生成预测。

SARIMA_pred = SARIMA_model_fit.get_prediction(287, 322).predicted_mean

test['SARIMA_pred'] = SARIMA_pred

然后,我们将使用 MAE 来评估 SARIMA 模型。

SARIMA_mae = mean_absolute_error(test['y'], test['SARIMA_pred'])

print(SARIMA_mae)

我们得到了一个 MAE 为 0.678,这略好于我们的基线,基线实现了 MAE 为 0.681。我们可以在图 20.5 中可视化 SARIMA 模型的预测。

fig, ax = plt.subplots()

ax.plot(train['y'])
ax.plot(test['y'], 'b-', label='Actual')
ax.plot(test['Baseline'], 'k:', label='Baseline')
ax.plot(test['SARIMA_pred'], 'r-.', label='SARIMA')
ax.plot(test['yhat'], color='darkorange', ls='--', lw=3, label='Prophet')

ax.set_xlabel('Date')
ax.set_ylabel('Average retail price of 1kg of round steak (CAD)')
ax.axvspan(287, 322, color='#808080', alpha=0.1)

ax.legend(loc='best')

plt.xticks(np.arange(0, 322, 12), np.arange(1995, 2022, 1))
plt.fill_between(x=test.index, y1=test['yhat_lower'], 
➥ y2=test['yhat_upper'], color='lightblue')
plt.xlim(250, 322)

fig.autofmt_xdate()
plt.tight_layout()

图片

图 20.5 预测加拿大 1 公斤圆牛肉的月*均零售价格。SARIMA 模型(以虚线和点线表示)实现了最低的 MAE(0.678),但仅略好于我们的基线(0.681),基线以点线表示。

虽然 SARIMA 的表现优于 Prophet,但与基准之间的性能差异是可以忽略不计的。这是一个我们必须问自己是否值得使用更复杂的 SARIMA 模型来获得如此小的差异的情况。我们还可以进一步调查,以确定是否存在可能帮助我们预测目标的外部变量,因为似乎仅使用过去值不足以生成准确的预测。

20.5 下一步

恭喜你完成了这个顶点项目!这很特别,与我们之前看到的不同,因为结果证明我们解决了一个相当复杂的问题,我们无法提出一个非常有效的解决方案。当你解决不同的时间序列预测问题时,这种情况会发生,这就是领域知识、收集更多数据以及使用你的创造力来寻找可能影响你的目标的外部因素发挥作用的地方。

利用这个机会,让这个顶点项目成为你的。我们只研究了一个目标,但可以选择 52 种商品中的另一种。选择另一个目标,看看你是否能生成比基线模型表现更好的预测。你也可以自由地改变预测范围。

如果你想要做得更多,许多政府网站都有开放数据,这使得它们成为时间序列数据集的宝库。以下是纽约市开放数据和加拿大统计局的链接:

探索这些网站,并找到一个你可以用来练习预测技能的时间序列数据集。你可能会遇到一个具有挑战性的问题,迫使你寻找解决方案,最终使你在时间序列预测方面变得更加出色。

21 超越极限

本章涵盖了

  • 巩固你的学习

  • 管理困难的预测问题

  • 探索时间序列预测之外的内容

  • 时间序列数据集的来源

首先,恭喜你完成了这本书的最后一章!到达这里已经是一次相当漫长的旅程,它需要你投入大量的时间、精力和注意力。

你已经获得了许多时间序列预测的技能,但当然还有很多东西要学习。本章的目标是总结你所学的,并概述你可以用时间序列数据实现的其他成就。我还会鼓励你通过列出各种时间序列数据来源来继续练习你的预测技能。

真正的挑战在你面前,当你将你的知识应用于工作或副项目中,那里的解决方案对你来说是未知的。重要的是你要对自己的技能有信心,而这种信心只能来自经验和经常练习。我希望这一章能激励你这样做。

21.1 总结你所学的

我们在时间序列预测中的第一步是定义时间序列为一组按时间顺序排列的数据点。你也很快了解到,数据的顺序必须保持不变,以便我们的预测模型有意义。这意味着在周一测量的数据必须始终在周日之后,在周二之前。因此,在将数据分为训练集和测试集时,不允许对数据进行任何排序。

在第二章中,我们构建了使用非常简单的统计或启发式方法(如历史*均值、最后一个已知值或重复上一个季节)的简单预测方法。这是任何预测项目的关键步骤,因为它为更复杂的模型设定了一个基准,揭示了它们是否真的是性能良好的模型。这些基准还可以质疑一些高级模型的使用,因为正如你在本书中所看到的,有些情况下高级预测模型的表现并不比基线模型好多少。

接下来,在第三章中,我们遇到了随机游走模型,这是一种我们无法应用预测模型的情况。这是因为每个步骤的价值都通过一个随机数变化,没有任何预测技术可以合理地预测一个随机数。在这种情况下,我们只能求助于简单的预测方法。

21.1.1 预测的统计方法

然后,我们在第四章和第五章中深入探讨了移动*均和自回归过程。虽然现实生活中的时间序列很少被纯移动*均(q)或纯自回归(p)模型所*似,但它们是我们后来开发的更复杂模型的基础,例如 ARMA(p,q)模型。所有这些模型之间的联系在于它们假设时间序列是*稳的,这意味着其统计属性,如均值、方差和自相关,不会随时间变化。我们使用了增强迪基-富勒(ADF)测试来检验*稳性。对于这个测试,零假设是序列不是*稳的。因此,如果我们得到一个小于 0.05 的 p 值,我们可以拒绝零假设,并得出我们有一个*稳过程的结论。

尽管我们可以使用自相关函数(ACF)和偏自相关函数(PACF)图来分别找到纯移动*均过程的自相关阶数 q 或纯自回归过程的自回归阶数 p,但在第六章中,ARMA(p,q)过程迫使我们设计了一个通用的建模程序,其中我们选择具有最低赤池信息准则(AIC)的模型。使用这个模型选择标准允许我们选择一个既不太复杂又能很好地拟合数据的模型,从而在过拟合和欠拟合之间取得*衡。

然后,我们研究了模型的残差,即预测值和实际值之间的差异。理想情况下,残差表现为白噪声,这意味着它们是完全随机的且不相关的,这反过来又意味着我们的模型解释了所有非偶然性的方差。我们可以用于残差分析的一个视觉工具是分位数-分位数(Q-Q)图,它比较样本分布与另一个理论分布,在这种情况下,是正态分布。如果它们相同,我们应该看到一个位于 y = x 的直线。我们还使用了 Ljung-Box 测试来确定残差是否独立且不相关。这个测试的零假设是样本是不相关的且独立分布的。因此,如果我们得到一个大于 0.05 的 p 值,我们未能拒绝零假设,并得出残差是随机的结论。这是很重要的,因为这意味着我们的模型已经从数据中捕获了所有信息,只有随机变化未被解释。

从这个通用建模程序中,我们进一步扩展到更复杂的模型,例如第七章中用于非*稳时间序列的 ARIMA(p,d,q)模型。回想一下,我们使用这个模型来预测强生公司(Johnson & Johnson)的每股季度收益。

然后,我们在第八章中转向了 SARIMA(p,d,q) (P,D,Q)[m]来处理时间序列中的季节性。回想一下,季节性是我们看到的数据中的周期性波动。例如,夏天天气更热,冬天更冷,或者白天比晚上有更多的人在路上开车。使用 SARIMA 模型使我们能够准确预测航空公司的月度乘客数量。

然后,我们发现了 SARIMAX(p,d,q)(P,D,Q)[m]模型,它在第九章中添加了外部变量到我们的模型中。使用该模型,我们能够预测美国的实际 GDP。

最后,我们在第十一章中通过向量自回归(VAR(p))总结了统计预测方法,它允许我们一次性预测多个时间序列,但前提是它们之间存在格兰杰因果关系。否则,模型无效。

21.1.2 用于预测的深度学习方法

当数据集变得太大时,用于预测的复杂统计模型会达到极限,通常在约 10,000 个数据点时。在那个点上,统计方法变得非常慢,并且开始失去性能。此外,它们无法对数据中的非线性关系进行建模。

因此,我们将注意力转向了深度学习,它依赖于具有许多特征的大数据集。我们开发了各种深度学习模型来预测明尼苏达州明尼阿波利斯和圣保罗之间 I-94 高速公路的每小时交通流量。我们的数据集有超过 17,000 行数据,六个特征,这使得它成为应用深度学习的一个很好的机会。

我们在第十四章从只有一个输入层和输出层,没有隐藏层的简单线性模型开始。然后我们构建了一个深度神经网络,它添加了隐藏层,可以建模非线性关系。

我们在第十五章中转向了更复杂的架构,即长短期记忆(LSTM)网络。这种架构具有额外的优势,即它能够将过去的信息存储在内存中,以便对未来进行预测。

我们还在第十六章中使用了卷积神经网络(CNN),因为它们通过卷积操作有效地执行特征选择。我们将 CNN 与 LSTM 结合使用,在将其馈送到 LSTM 网络之前过滤我们的时间序列。

在第十七章中,我们向我们的工具集添加了一个最终模型——自回归深度神经网络,它使用自己的预测来做出更多预测。这种架构非常强大,是时间序列预测中一些最先进模型背后的技术,例如 DeepAR。

在整个深度学习部分,由于我们首先进行了数据窗口化,因此模型构建变得非常容易。这一关键步骤涉及以这种方式格式化数据,即我们拥有包含训练示例和测试示例的窗口。这使我们能够快速开发针对各种用例的模型,例如单步预测、多步预测和多变量预测。

21.1.3 自动化预测过程

我们在开发模型的过程中投入了大量的手动工作,并开发了我们自己的函数来自动化这个过程。然而,有许多库可供使用,它们使得时间序列预测变得简单快捷。

重要的是要注意,虽然这些库可以加快预测过程,但它们也增加了一个抽象层次,这会移除我们在开发自己的模型时所拥有的某些灵活性和微调能力。尽管如此,它们是快速原型设计的优秀工具,因为创建模型所需的时间非常短。

其中一个这样的库是 Prophet,它是 Meta 的一个开源项目,可能是行业中最广泛使用的时间序列预测库之一。然而,它并不是万能的解决方案。它在具有许多历史季节性数据的强季节性数据上表现最佳。在这种情况下,它可以快速产生准确的预测。由于它实现了一般加性模型,它可以考虑到多个季节性周期以及假日效应和变化趋势。此外,Prophet 附带了一套可视化预测和你的数据组件的实用工具,并且它包括交叉验证和超参数调整功能,所有这些都在一个库中。

这总结了到目前为止我们所讨论和应用的全部内容。虽然你已经拥有了成功进行时间序列预测所需的所有工具,但你还需要知道如何管理那些预测未来尝试不成功的情况。

21.2 如果预测不成功怎么办?

在这本书中,你学习了如何成功地进行时间序列预测。我们处理了各种各样的情况,从预测每股季度收益到预测加拿大牛排的零售价格。对于每一种情况,我们都设法创建了一个性能良好的预测模型,它优于基线并产生了准确的预测。然而,我们可能会遇到似乎什么方法都不起作用的情况。因此,学习如何管理失败是很重要的。

时间序列预测失败有许多原因。首先,也许你的数据根本不应该被分析为时间序列。例如,你可能被要求预测下一个季度的销售额。虽然你有历史销售数据的访问权限,但销售额可能根本不是时间的函数。相反,销售额可能是广告支出的函数。在这种情况下,我们不应该将这个问题视为时间序列问题,而应该将其视为一个回归问题,使用广告支出作为特征来预测销售额。虽然这个例子很简单,但它展示了如何以不同的方式重新构建问题可能会帮助你找到解决方案。

另一个时间序列预测会失败的情况是当你的数据是随机游走时。回想一下第三章,随机游走是一个时间序列,其中每个步骤有相等的机会随机上升或下降一个随机数。因此,我们实际上是在尝试预测一个随时间随机变化的值。这不是一个合理的事情去做,因为没有任何模型可以预测随机数。在这种情况下,我们必须求助于使用第二章中展示的朴素预测方法。

解决一个困难的预测问题的另一个可能途径是对你的数据进行重采样。例如,假设你正在预测外部的温度。为了收集数据,你将温度计放在外面,并每分钟记录一次温度。我们可以考虑是否每分钟记录温度数据是有意义的。很可能会发现温度每分钟变化不大。如果你有一个非常敏感的温度计,记录 0.1 度或更小的变化,这也可能引入不必要的噪声。在这种情况下,重采样数据是有意义的,这将允许你构建性能良好的预测模型。在这里,你可以将数据重采样,以便每小时有一个温度读数。这样,你可以*滑时间序列,并能够揭示日季节性。或者,你也可以按日重采样数据,揭示年季节性。

因此,你应该探索你时间序列数据的不同重采样可能性。这个想法也可能来自你的目标。在温度预测的例子中,预测下一分钟的温度可能没有意义。没有人可能对此感兴趣。然而,预测下一小时或下一天的温度是有价值的。因此,重采样数据是可行的途径。

最后,如果你的预测努力失败,你可能需要联系某个具有领域知识的人或寻找替代数据。领域知识伴随着经验,而在某个领域有专业知识的人可以更好地指导数据科学家发现新的解决方案。例如,经济学家知道国内生产总值和失业率是相关的,但这个联系可能对数据科学家来说是未知的。因此,领域专家可以帮助数据科学家发现新的关系,并寻找失业数据以预测国内生产总值。

如你所见,管理困难的预测问题有不同方法。在某些情况下,你可能会完全陷入困境,这意味着你正在处理一个非常先进的问题,以前没有人解决过。在这种情况下,拥有一个可以领导研究团队尝试解决问题的学术伙伴可能是最佳选择。

失败总是有价值的,如果预测失败,你不应该感到挫败。事实上,失败的预测可以帮助你成为一个更好的数据科学家,因为你将学会识别哪些问题有很好的解决机会,哪些没有。

21.3 时间序列数据的其他应用

本书完全专注于预测技术,其目标是预测一个连续的数值。然而,我们可以用时间序列数据做更多的事情。我们还可以进行分类。

在时间序列分类中,目标是确定一个时间序列是否来自一个特定的类别。时间序列分类的一个示例应用是分析心电图(ECG)数据,这评估心脏的状况。健康的心脏会产生与有问题的心脏不同的 ECG。由于数据是随时间收集的,这为在现实生活中的情况下应用时间序列分类提供了一个完美的场景。

时间序列分类

时间序列分类是一个任务,其目标是确定一个时间序列是否来自一个特定的类别。

例如,我们可以使用时间序列分类来分析心脏监测数据,并确定它是否来自健康的心脏。

我们还可以使用时间序列数据进行异常检测。异常基本上是一个异常值——一个与其它数据显著不同的数据点。我们可以在数据监控中看到异常检测的应用,这反过来又用于应用维护、入侵检测、信用卡欺诈等。以应用维护为例,想象一个全球电子商务公司正在跟踪页面的访问量。如果页面访问量突然降到零,那么很可能是网站存在问题。异常检测算法会注意到这个事件,并向维护团队发出问题信号。

异常检测

异常检测是一个任务,其目标是识别异常值或异常数据的存在。

例如,我们可以追踪某人的信用卡支出。如果突然出现一个非常大的支出,这可能是一个潜在的异常值,也许这个人成了欺诈的受害者。

异常检测是一个特别有趣的挑战,因为异常值通常很少见,存在生成许多错误正例的风险。它还增加了一层复杂性,因为事件的稀缺意味着我们拥有的训练标签很少。

注意:如果你对这个类型的问题感兴趣,我建议阅读微软和雅虎的两篇论文,其中他们展示了他们如何构建自己的时间序列异常检测框架:Hansheng Ren, Bixiong Xu, Yujing Wang, 等人,“微软的时间序列异常检测服务”,arXiv:1906.03821v1 (2019),arxiv.org/pdf/1906.03821.pdf;以及 Nikolay Laptev, Saeed Amizadeh 和 Ian Flint,“用于自动时间序列异常检测的通用和可扩展框架”,KDD ‘15: 第 21 届 ACM SIGKDD 国际知识发现和数据挖掘会议论文集 (ACM, 2015),mng.bz/pOwE.

当然,我们还可以使用时间序列数据进行许多其他任务,例如聚类、变化点检测、模拟或信号处理。我希望这能鼓励你进一步探索可能性和正在进行的事情。

21.4 继续练习

虽然这本书已经为你提供了许多将知识应用于练习、每章的实际场景和综合项目的机会,但重要的是要继续练习,以真正掌握时间序列预测。你将对自己的技能更有信心,并遇到新的问题,这些问题不可避免地会使你更好地处理时间序列数据。

要做到这一点,你需要访问时间序列数据。以下列表列出了一些可以免费获取此类数据的网站:

  • “数据集”,在[Papers with Code]paperswithcode.com/datasets?mod=time-series.

    一份*一百个时间序列分析数据集的列表(截至写作时)。你可以按任务进行筛选,例如异常检测、预测、分类等。你可能会遇到用于研究论文的数据集,这些数据集用于测试新技术和建立最先进的方法。

  • UCI 机器学习仓库archive.ics.uci.edu/ml/datasets.php.

    这对于许多机器学习实践者来说是一个非常流行的数据来源。点击时间序列数据类型的链接,你会找到 126 个时间序列数据集。你还可以按任务进行筛选,例如分类、回归(预测)和聚类。

  • 纽约市开放数据opendata.cityofnewyork.us/data/.

    该网站收录了来自纽约市的众多数据集。你可以按领域进行筛选,例如教育、环境、健康、交通等。虽然并非所有数据集都是时间序列数据,但你仍然可以找到很多。你也可以检查你所在的城市是否提供公开可访问的数据,并与之合作。

  • 加拿大统计局www150.statcan.gc.ca/n1/en/type/data.

    这是一个加拿大政府机构,提供大量数据的免费访问,包括时间序列数据。您可以按领域筛选,也可以按采样频率(每日、每周、每月等)筛选。搜索您自己政府的网站,看看是否能找到类似资源。

  • Google Trendstrends.google.com/trends/.

    Google Trends 收集来自世界各地的搜索数据。您可以搜索特定的主题,并按国家进行细分。您还可以设置时间序列的长度,这会改变采样频率。例如,您可以下载过去 24 小时的数据,每 8 分钟采样一次。如果您下载过去 5 年的数据,数据则是每周采样一次。

  • Kagglewww.kaggle.com/datasets?tags=13209-Time+Series+Analysis.

    Kaggle 是数据科学家中流行的网站,公司可以在那里举办比赛并奖励表现最优秀的团队。您还可以下载时间序列数据——在撰写本文时,有超过一千个数据集。您还可以找到使用这些数据集的笔记本,以激发您或为您提供起点。但是请注意——任何人都可以在 Kaggle 上发布笔记本,他们的工作流程并不总是正确的。请注意,您需要创建一个免费账户才能在您的本地机器上下载数据集。

您现在拥有各种各样的工具和资源来练习和磨练您的技能。祝您在未来的努力中好运,并希望您阅读这本书的乐趣与我写作时的乐趣一样。

附录。安装说明

安装 Anaconda

本书中的代码是在 Windows 10 计算机上使用 Jupyter Notebooks 和 Anaconda 运行的。我强烈推荐使用 Anaconda,尤其是如果你使用的是 Windows 机器,因为它会自动安装 Python 和本书中我们将使用的许多库,例如 pandasnumpymatplotlibstatsmodels 等。你可以从他们的网站(www.anaconda.com/products/individual)免费安装 Anaconda 的个人版。它附带图形化安装程序,使得安装变得简单。请注意,在撰写本文时,Anaconda 安装的是 Python 3.9。

Python

如果你遵循使用 Anaconda 的建议,你将不需要单独安装 Python。如果你需要单独安装 Python,你可以从官方网站(www.python.org/downloads/)下载。本书中使用的代码是 Python 3.7,但任何后续版本的 Python 都可以工作。

Jupyter Notebooks

本书中的代码是在 Jupyter Notebooks 上运行的。这允许你立即看到代码的输出,并且它是一个学习和探索的绝佳工具。它还允许你编写文本和显示方程式。

假设你已经安装了 Anaconda,Jupyter Notebook 也会安装在你的机器上。在 Windows 上,你可以按 Windows 键并开始键入 Jupyter Notebook。然后你可以启动应用程序,它将打开你的浏览器。它将显示文件夹结构,你可以导航到你想要保存笔记本的位置或你克隆的包含源代码的 GitHub 存储库的位置。

GitHub 存储库

本书的所有源代码均可在 GitHub 上找到:github.com/marcopeix/TimeSeriesForecastingInPython。在存储库的根目录下,有一个数据文件夹,其中包含本书整个过程中使用的所有数据文件。

存储库按章节组织。每个文件夹都包含一个笔记本,该笔记本将运行该特定章节的所有代码并生成相应的图表。你还可以在那里找到练习的答案。如果已经安装了 Git,你可以克隆存储库并在本地机器上访问它:

git clone https://github.com/marcopeix/TimeSeriesForecastingInPython.git

如果没有安装 Git,你可以从 Git 网站(git-scm.com/downloads)下载并安装它。我建议 Windows 用户使用 Git Bash 来运行前面的命令。

安装 Prophet

在本书中,我们使用 Prophet 库,这是一个流行的自动化大多数过程的预测库。Windows 用户可能会在即使使用 Anaconda 的情况下安装库时遇到一些麻烦。

要安装库,你可以在 Anaconda 提示符中运行以下命令:

conda install libpython m2w64-toolchain -c msys2
conda install numpy cython matplotlib scipy pandas -c conda-forge
conda install -c conda-forge pystan
conda install -c conda-forge fbprophet

在 Anaconda 中安装库

如果在使用 Anaconda 的过程中需要安装特定的库,你可以进行 Google 搜索:conda <package name>。第一个搜索结果应该会引导你到 anaconda.org/conda-forge/<package name> 网站,在那里你会看到一系列安装该包的命令。通常,第一个命令就会生效,其格式为 conda install -c conda-forge <package name>

例如,要使用 Anaconda 安装 TensorFlow 2.6,你可以在 Anaconda 提示符中运行 conda install -c conda-forge tensorflow

posted @ 2025-11-18 09:34  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报