时间序列分析实践指南-全-

时间序列分析实践指南(全)

原文:zh.annas-archive.org/md5/b4cad8d79afe247e9b17f26ff108af6b

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

天气、股市和心跳。它们都构成时间序列。如果您对多样化数据和预测未来感兴趣,那么您就对时间序列分析感兴趣。

欢迎阅读实用时间序列分析!如果您拿起了本书,您可能已经注意到时间序列数据随处可见。随着大数据生态系统的扩展,时间序列数据变得日益普遍和重要。无论好坏,传感器和跟踪机制无处不在,因此提供了前所未有的大量高质量时间序列数据。时间序列之所以独具魅力,是因为它们可以解答因果关系、趋势以及未来结果可能性的问题。本书将介绍最常用于时间序列的主要技术,以解决这些问题。

时间序列数据涵盖了广泛的学科和用例。它可以是从客户购买历史到纳米电子系统的电导测量,再到人类语言的数字记录。本书始终讨论的一点是,时间序列分析适用于一组惊人多样的数据。任何具有有序轴的数据都可以用时间序列方法进行分析,即使该有序轴本身并非时间。传统的时间序列数据,如股票数据和天气模式,可以用时间序列方法分析,但异类数据集如葡萄酒的光谱图,其中的“时间”轴实际上是一个频率轴,同样可以用时间序列方法分析。时间序列随处可见。

适合读者

本书的预期读者有两类。第一类也是更大的一类读者是数据科学家,可能很少处理过时间序列数据。这个人可以是行业老手或初级分析师。经验丰富的数据分析师可以快速浏览每章节的介绍性概念区域,但仍然会从本书关于时间序列数据工作的最佳实践以及陷阱的讨论中受益。新手数据分析师可能考虑全书逐章阅读,尽管我尽量使每个主题尽可能自包含。

第二类读者是在组织中负责分析工作的人员,该组织拥有大量内部数据收集。如果您属于这类人群,您仍然需要一些技术背景,但不必在职业生活中编写代码。对于这样的读者,本书有助于指出机构可以利用时间序列分析的机会,即使目前在内部并未实践。本书将指导您使用现有数据资源解决新类型的问题和分析。

预期背景

就编码而言,您应该对 R 和 Python 有一些了解,特别是某些基础包(在 Python 中:NumPy、Pandas 和scikit-learn;在 R 中:data.table)。即使没有所有背景知识,代码示例也应该是可读的,但在这种情况下,您可能需要稍作了解这些包。对于 R 的 data.table 来说,这很可能是最常见的情况,它是一个鲜为人知但性能非常出色的数据框架包,具有出色的时间功能。

在所有情况下,我都提供了相关包的简要概述、一些示例代码以及代码的描述。我还指向读者更全面的最常用包的概述。

关于统计学和机器学习,您应该对以下概念有一些了解:

初步统计

关于方差、相关性和概率分布的思想

机器学习

聚类和决策树

神经网络

它们是什么以及它们是如何训练的

对于这些情况,我在文本中提供了这些概念的简要概述,但对于未接触过的人士,在继续某些章节之前应深入阅读这些内容。对于大多数主题,我提供了推荐的免费在线资源链接,用于简要介绍给定主题或技术的基础知识。

为什么我写这本书

我写这本书有三个原因。

首先,时间序列是数据分析的重要方面,但它并未包含在标准数据科学工具包中。这是不幸的,因为时间序列数据越来越普遍,并且因为它能回答横截面数据无法回答的问题。不了解基本的时间序列分析的分析师未充分利用他们的数据。我希望这本书能填补一个现有且重要的空白。

其次,当我开始写这本书时,我没有找到一个集中讨论现代数据科学视角下时间序列分析最重要方面的中心化概述。传统时间序列分析有许多优秀的资源,尤其是统计时间序列分析的经典教材。还有许多关于传统统计方法以及关于机器学习或神经网络方法应用于时间序列的优秀博客文章。然而,我无法找到一个单一的集中资源来概述所有这些主题并将它们彼此联系起来。这本书的目标是提供这样的资源:一个涵盖时间序列数据和建模完整流程的广泛、现代和实用的时间序列分析概述。再次,我希望这本书能填补一个现有且重要的空白。

第三,时间序列是一个涉及奇怪数据问题的有趣主题。从时间序列的角度来看,与数据泄漏、前瞻性和因果关系相关的问题尤其有趣,许多技术都是针对某种时间轴上有序数据而设计的。广泛调查这些主题并找到一种分类它们的方式是撰写本书的另一个动机。

浏览本书

本书大致组织如下:

历史

第一章 展示了时间序列预测的历史,从古希腊到现代。这使我们的学习能够理解我们在本书中研究的传统而又丰富的学科背景。

关于数据的一切

第 2、3、4 和 5 章解决了获取、清理、模拟和存储时间序列数据相关的问题。这些章节关注的是在实际执行时间序列分析之前需要考虑的一切。这些主题在现有资源中很少被讨论,但在大多数数据流程中非常重要。数据识别和清理代表了大多数时间序列分析师所做工作的一大部分。

模型,模型,模型

第 6、7、8、9 和 10 章涵盖了多种可用于时间序列分析的建模技术。我们首先讨论了两章关于统计方法的内容,涵盖了诸如 ARIMA 和贝叶斯状态空间模型等标准统计模型。然后,我们将更近期开发的方法,如机器学习和神经网络,应用到时间序列数据中,突显了在使用决策树等非本质上时间感知的模型拟合时间序列数据时所面临的数据处理和数据布局方面的挑战。

模型后考虑事项

第十一章和 12 章分别介绍精度指标和性能考虑,为您在进行首次时间序列建模后应考虑的事项提供一些指导。

实际应用案例

第 13、14 和 15 章分别提供了来自医疗保健、金融和政府数据的案例研究。

对最近发生的事情的评论

第十六章和第十七章简要介绍了最近的时间序列发展和未来的预测。第十六章是各种自动化时间序列软件包的概述,一些是开源的,并作为学术努力开发的,一些来自大型科技公司。这些工具正在积极开发,努力通过自动化流程提高时间序列的预测规模。第十七章讨论了时间序列分析未来的一些预测,随着大数据生态系统的增长以及我们对大数据如何帮助时间序列分析的认识的深入,时间序列分析的未来将会如何。

一般来说,我建议在尝试使用代码之前通读一章。每章通常会引入一些新概念,在转到键盘之前给予这些概念一些关注可能会有所帮助。此外,在大多数情况下,执行某些模型的代码相对简单,因此概念理解将是你获得的主要技能,对重要软件包的 API 的了解将是一个次要的好处,如果你注意这些概念,后者将更容易掌握。

此外,这本书的写作方式最好从头到尾阅读(后面的章节会涉及书中早期介绍的概念),但是,我故意尽可能使章节自给自足,以便更有经验的读者可以方便地跳跃阅读。

在线资源

O'Reilly 为这本书托管了一个[GitHub 存储库](https://oreil.ly/time-series-repo)。存储库包括本书中讨论的大部分代码,可以在其中运行数据集,该存储库还提供了数据集。在某些情况下,变量名称、格式等可能与书中产生的不完全相同,但应该能够轻松链接(例如,在某些情况下,由于格式限制,书中的变量名称被缩短)。

如果你喜欢视频演示,我有两个在线教程,涵盖了本书部分内容,并着重于 Python。如果你想用视频演示来补充这本书,请考虑以下资源:

本书中使用的约定

这本书采用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽

用于程序列表,以及段落内引用程序元素,例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应该按原样键入的命令或其他文本。

等宽斜体

显示应由用户提供值或由上下文确定值的文本。

提示

这个元素表示提示或建议。

注意

这个元素表示一般性说明。

警告

这个元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/PracticalTimeSeriesAnalysis/BookRepo下载。

本书旨在帮助您完成工作。一般来说,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。售卖或分发包含 O’Reilly 书籍示例的 CD-ROM 需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到您产品的文档中需要许可。

我们欣赏但不要求归属。归属通常包括标题、作者、出版商和 ISBN。例如:“Practical Time Series Analysis by Aileen Nielsen(O’Reilly)。版权所有 2020 Aileen Nielsen,978-1-492-04165-8。”

如果您认为您使用的代码示例超出了合理使用范围或上述许可,请随时联系我们,邮箱为permissions@oreilly.com

O’Reilly 在线学习

注意

近 40 年来,O’Reilly Media为企业提供技术和商业培训、知识和见解,帮助它们成功。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程,深度学习路径,交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频内容。有关更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书建立了一个网页,列出勘误、示例和任何额外信息。您可以在https://oreil.ly/practical-time-series-analysis访问此页面。

发邮件至bookquestions@oreilly.com对本书进行评论或提出技术问题。

欲了解更多关于我们书籍、课程、会议和新闻的信息,请访问我们的网站http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

感谢本书的两位技术审阅者,Rob Hyndman 教授和 David Stoffer 教授。他们慷慨地花时间审阅本书,并提供了大量有益的反馈和新的想法,使本书比没有他们的参与要好得多。特别感谢 Rob 指出原始草稿中未能突出显示备选方法和许多有趣的时间序列数据来源的遗漏机会。特别感谢 David 对分析过度自动化方法的怀疑,以及在我对某些工具过于乐观时的提醒。感谢你们的所有帮助、知识和深刻的时间序列分析视角。

我感激 O’Reilly 的编辑 Jeff Bleiel,在过去一年中,他对这些页数的审阅和支持非常鼓舞人心、有帮助。我也要感谢我的制作编辑 Katie Tozer,在整理这本书和指导我解决制作中的技术问题的过程中,她非常耐心。感谢 Rachel Monaghan 进行仔细和优秀的拷贝编辑。感谢 Rebecca Demarest,在本书中创建了许多插图,并帮助我整理了许多混乱的图形。还要感谢 Jonathan Hassell,他接手了这个项目,并成功地说服 O’Reilly 出版了它。最后,感谢 Nicole Tache 几年前首次与我联系,与 O’Reilly 合作。感谢 O’Reilly 的每一位同事,感谢你们让我承担这个项目,并在整个过程中给予我的支持。

我感激许多读者在写作过程中提供的反馈或校对帮助,包括 Wenfei Tong、Richard Krajunus、Zach Bogart、Gabe Fernando、Laura Kennedy、Steven Finkelstein、Liana Hitts 和 Jason Greenberg。他们的意见非常有帮助。感谢你们的阅读和反馈。

我要感谢我的母亲(也是我的榜样),Elizabeth,她一生都给予我爱、支持和纪律。我要感谢我的父亲 John 和阿姨 Claire,多年来他们对我的教育给予了无数的爱和支持。最重要的是,我要感谢我的丈夫 Ivan 和我的儿子 Edmund Hillary,在我写作期间对本书的支持和理解,原谅了我在远离家庭时花费的时间。

本书中的任何错误或问题都是我个人的责任。欢迎您通过aileen.a.nielsen@gmail.com提供反馈。

第一章:时间序列:概述和简史

由于通过物联网、医疗数字化和智能城市的兴起而大量产生这类数据,时间序列数据及其分析变得越来越重要。在未来几年,我们可以预期时间序列数据的数量、质量和重要性将迅速增长。

随着持续监控和数据收集变得更加普遍,对具有统计和机器学习技术的时间序列分析的需求将增加。事实上,最有前途的新模型结合了这两种方法。因此,我们将详细讨论每一种方法。我们将研究和使用广泛的时间序列技术,这些技术有助于分析和预测人类行为、科学现象和私营部门的数据,因为所有这些领域都提供丰富的时间序列数据。

让我们从一个定义开始。时间序列分析是从按时间顺序排列的点中提取有意义的总结和统计信息的努力。这样做是为了诊断过去的行为以及预测未来的行为。在本书中,我们将使用各种方法,从一百年前的统计模型到新开发的神经网络架构。

技术的发展从未在真空中或出于纯理论兴趣而发展。时间序列分析的创新源于新的数据收集、记录和可视化方式。接下来我们简要讨论时间序列分析在各种应用中的出现。

多样化应用中的时间序列历史

时间序列分析常常涉及到因果关系的问题:过去是如何影响未来的?有时,这些问题(及其答案)严格地被视为各自学科的一部分,而不是时间序列分析的一般学科的一部分。因此,各种学科为时间序列数据集提供了新的思考方式。

在本节中,我们将调查一些历史上的时间序列数据和分析示例,涉及以下学科:

  • 医学

  • 天气

  • 经济学

  • 天文学

正如我们将看到的,这些学科中的发展速度及每个领域中的贡献与当时可用的时间序列数据的性质密切相关。

医学作为时间序列问题

医学是一个数据驱动的领域,几个世纪以来为人类知识贡献了有趣的时间序列分析。现在,让我们研究医学中几个时间序列数据源的例子,以及它们如何随着时间的推移而出现。

尽管预后在医学实践中是一个重要部分,但医学在思考未来预测的数学方面起步相对缓慢。这有多种原因。统计学和概率思维是近期的现象,这些学科在医学发展的许多世纪里并不可用。此外,大多数医生在没有便捷的专业交流和病人或人群健康的正式记录基础设施的情况下独立实践。因此,即使早期的医生接受了统计思维的训练,他们也可能没有合理的数据来得出结论。

这并不是要批评早期的医生,而是解释为什么一个早期时间序列创新在人口健康领域来自于一位卖帽子的商人而不是一位医生,这其实是合理的思考:在早期世纪,城市帽子销售商可能比医生更擅长记录和发现趋势。

创新者是约翰·格朗特,他是 17 世纪伦敦的一名帽商。格朗特进行了一项对伦敦教区自 1500 年代初以来保存的死亡记录的研究。在此过程中,他创立了人口统计学学科。1662 年,他出版了《自然与政治观察……根据死亡账单制作》(参见图 1-1)。

图 1-1. 约翰·格朗特的寿命表是将时间序列思维应用于医学问题的最早成果之一。来源:维基百科

在这本书中,格朗特首次提出了寿命表,你可能知道它们为精算表。这些表格显示了一个特定年龄的人在下一个生日之前死亡的概率。作为第一个已知制定和出版寿命表的人,格朗特也是第一个记录人类健康统计学的人。他的寿命表看起来类似于表 1-1,取自一些莱斯大学统计学课程笔记

表 1-1. 约翰·格朗特寿命表样本

年龄 该年龄段内死亡比例 开始该年龄段时生存比例
0–6 0.36 1.0
7–16 0.24 0.64
17–26 0.15 0.40
27–36 0.09 0.25

不幸的是,格朗特对人类生存进行数学思考的方式并没有被广泛接受。一个更加互联和数据驱动的世界开始形成——包括国家、认证、专业社团、科学期刊,以及更晚的政府强制健康记录——但医学继续关注生理而非统计学。

这有其可以理解的原因。首先,在少数研究对象中研究解剖学和生理学已经几个世纪为医学带来了主要进展,大多数人(甚至科学家)坚持尽可能长时间使用已知的方法。在生理学如此成功的情况下,没有必要进一步探索其他领域。其次,医师没有足够的报告基础设施来归纳和分享信息,使统计方法优于临床观察。

与统计学和数据分析的其他分支相比,时间序列分析进入主流医学的步伐更为缓慢,可能是因为时间序列分析对记录系统的要求更高。记录必须随时间链接在一起,并最好定期收集。因此,作为流行病学实践的时间序列分析直到最近才逐渐出现,一旦政府和科学基础设施足够,可以确保良好且持久的时间记录。

同样地,使用时间序列分析进行个性化医疗仍然是一个年轻而具有挑战性的领域,因为创建一致的数据集可能会非常困难。即使是基于小型案例研究的研究,也很难维持与个体群体的联系和参与,而且非常困难且昂贵。当这些研究进行了长时间后,它们往往会成为其领域的经典作品,尽管在资金和管理方面存在挑战,但其数据能够解决重要问题。¹

医疗仪器

对于单个患者的时间序列分析比群体级健康研究有着更早且更成功的历史。时间序列分析进入医学领域始于 1901 年发明的第一台实用心电图仪(ECG),通过记录通过心脏的电信号来诊断心脏疾病(见图 1-2)。另一种时间序列仪器脑电图(EEG),1924 年引入医学,非侵入性地测量大脑的电脉冲,为医务人员在医学诊断中应用时间序列分析创造了更多机会(见图 1-3)。

这两种时间序列仪器都是第二次工业革命中重新利用的思想和技术来增强医学的一部分。

图 1-2. 来自 Augustus D. Waller, M.D.于 1877 年的原始论文“A Demonstration on Man of Electromotive Changes Accompanying the Heart’s Beat”中的早期心电图记录。最早期的心电图仪难以制造和使用,因此在几十年后才成为医师实际工具。

图 1-3. 1924 年的第一份人类 EEG 记录。来源:维基百科

ECG 和 EEG 时间序列分类工具仍然是非常实用的研究领域,比如估计突发心脏危机或癫痫的风险。这些测量数据是丰富的数据源,但这些数据的一个“问题”是,它通常只适用于特定疾病的患者。这些机器不会生成能更广泛告知人类健康和行为的长期时间序列,因为它们的测量很少在疾病出现之前或在患者身上应用很长时间。

幸运的是,从数据分析的角度来看,我们正在摆脱 ECG 等医疗时间序列数据占主导地位的时代。随着可穿戴传感器和“智能”电子医疗设备的出现,许多健康的人类自动或几乎不需要人工干预地进行常规测量,导致对患病和健康人群的长期数据持续收集。这与上个世纪几乎只在患病人群中进行、访问权限非常有限的医疗时间序列数据形成鲜明对比。

正如最近的新闻报道所显示的,从巨大的社交媒体公司到金融机构再到零售巨头,各种非传统的参与者正在进入医疗领域。他们很可能都计划利用大数据集来简化医疗保健。医疗领域不仅有新的参与者,还有新的技术。个性化的基因驱动医学意味着时间序列数据正在越来越多地被测量和重视。由于不断增长的现代医疗数据集,无论是医疗保健还是时间序列分析都有望在未来几年内发展,特别是在响应医疗部门利润丰厚的数据集方面。希望时间序列能够使每个人受益。

天气预测

出于显而易见的原因,天气预测长期以来一直是许多人关注的焦点。古希腊哲学家亚里士多德深入研究了天气,他的著作《气象学》探讨了天气的原因和顺序,这些观点一直占主导地位直到文艺复兴时期。那时,科学家们开始借助新发明的仪器,如晴雨表,收集与天气相关的数据,以测量大气状态。他们使用这些仪器每天甚至每小时记录时间序列。这些记录保存在各种地方,包括私人日记和地方城镇的日志。几个世纪以来,这仍然是西方文明跟踪天气的唯一方式。

在 19 世纪 50 年代,罗伯特·菲茨罗伊被任命为英国新政府部门的负责人,记录和发布针对水手的与天气有关的数据。[³] 菲茨罗伊创造了术语天气预报。当时,他因预测质量而受到批评,但现在他被认为在他用来发展这些预报的科学方面领先于他的时代。他建立了在报纸上印刷天气预报的习惯;它们是《伦敦时报》上首次印刷的预报。菲茨罗伊现在被誉为“预报之父”。

在 19 世纪末,即使在许多大气测量技术已经投入使用数百年后,电报也允许从许多不同地点快速编制时间序列中的大气条件。这种实践在 19 世纪 70 年代成为世界许多地区的标准,并导致了基于其他地理位置发生的事情预测本地天气的第一个有意义的数据集的创建。

到了 20 世纪初,通过这些编制的数据集积极追求用计算方法预测天气的想法。早期计算天气的努力需要大量的工作,但效果不佳。虽然物理学家和化学家对相关自然法则有很好的证明,但同时应用所有自然法则的数量太多。由此产生的方程系统如此复杂,以至于第一次有人尝试进行计算时,这是一个显著的科学突破。

几十年的研究随后进行,以简化物理方程的方式增加准确性和计算效率。这些行业内的诀窍甚至传承到当前的天气预测模型中,这些模型运行在已知物理原理和经过验证的启发式方法的混合之上。

如今,许多政府从世界各地数百甚至数千个气象站进行高度精细的气象测量,这些预测建立在具有精确气象站位置和设备信息的数据基础上。这些努力的根源可以追溯到 19 世纪 70 年代的协调数据集,甚至更早的文艺复兴时期的地方天气日记记录实践。

不幸的是,天气预报是越来越多地涉及到时间序列预测领域的科学攻击的一个例子。不仅全球温度的时间序列辩论被政治化,甚至像预测飓风路径这样的更普通的时间序列预测任务也被如此。

预测经济增长

市场中生产和效率的指标长期以来一直提供有趣的时间序列分析数据。最有趣和紧迫的是基于过去预测未来经济状态的问题。这样的预测不仅仅有助于赚钱,还有助于促进繁荣和避免社会灾难。让我们讨论一下经济预测历史上的一些重要发展。

经济预测源于 19 世纪末和 20 世纪初美国和欧洲的周期性银行危机引发的焦虑。那时,企业家和研究人员都从经济可以类比为一个周期性系统的想法中汲取灵感,就像天气被认为会表现出来一样。他们认为,通过正确的测量,可以做出预测并避免崩溃。

早期经济预测的语言甚至反映了天气预报的语言。这种类比并非无意义。在 20 世纪初,经济和天气预测确实相似:两者都相当糟糕。但经济学家的愿景创造了一个至少可以期待进展的环境,因此形成了多种公共和私人机构来跟踪经济数据。早期经济预测努力导致了经济指标的创建以及这些指标的表格化和公开历史记录,这些至今仍在使用。我们甚至会在本书中使用其中一些。

当今,美国和大多数其他国家都有成千上万的政府研究人员和记录员,他们的工作是尽可能准确地记录数据,并使其对公众可用(参见图 1-4)。这一做法被证明对经济增长和避免经济灾难以及痛苦的经济周期有着无价的贡献。此外,企业受益于数据丰富的环境,因为这些公共数据集允许交通运输提供者、制造商、小企业主甚至农民预测未来市场情况。所有这些都源于试图识别被认为是周期性银行失败原因的“商业周期”,这是经济学中时间序列分析的早期形式。

图 1-4. 美国联邦政府资助许多政府机构和相关非营利组织,记录重要统计数据并制定经济指标。来源:美国经济研究局

政府收集的许多经济数据,特别是最具新闻价值的数据,往往是人口整体经济福祉的代理。其中一个例子是申请失业救济的人数。例如,政府对国内生产总值的估计以及在特定年份收到的总税收的报告。

由于对经济预测的渴望,政府已经成为数据的典藏家以及税收的征收者。这些数据的收集使得现代经济学、现代金融业以及数据科学普遍蓬勃发展。由于时间序列分析从经济问题中发展而来,我们现在比过去的任何政府都能够更安全地避免许多银行和金融危机。此外,已经有数百本时间序列教科书以经济学教科书的形式写成,专门用于理解这些金融指标的节奏。

交易市场

让我们回到历史的一面。随着政府在数据收集方面的成功,私人组织开始效仿政府的记录方式。随着时间的推移,商品和股票交易所变得越来越技术化。金融年鉴也变得流行起来。这种现象既因市场参与者变得更加复杂,也因新兴技术使得更大程度的自动化和对价格的新思考方式成为可能。

所有这些详细的记录使得通过数学而不是直觉从市场赚钱的追求成为可能,这种方式完全由统计数据(以及最近的机器学习)驱动。早期的先驱们手工进行这种数学工作,而当前的“量化交易员”则通过非常复杂和专有的时间序列分析方法来完成。

机械交易或通过算法进行时间序列预测的先驱之一是理查德·丹尼斯。丹尼斯是一个自成一家的百万富翁,以教导他们一些关于如何何时交易的选择性规则而闻名,从而将普通人(称为海龟)转变为明星交易员。这些规则是在 20 世纪 70 年代和 80 年代开发的,与 20 世纪 80 年代的“人工智能”思想相呼应,其中启发式规则仍然在如何构建能在现实世界中工作的智能机器的范式中占主导地位。

自那时以来,许多“机械”交易者已经采纳了这些规则,结果在竞争激烈的自动化市场中变得不那么赚钱了。机械交易者的数量和财富继续增长,他们不断寻找下一个最好的东西,因为竞争非常激烈。

天文学

天文学在很大程度上依赖于随时间绘制物体、轨迹和测量数据。因此,天文学家是时间序列的大师,既用于校准仪器,也用于研究感兴趣的对象。举个例子,关于时间序列数据的悠久历史,可以考虑到太阳黑子时间序列早在公元前 800 年就已记录在古代中国,使得太阳黑子数据收集成为有史以来记录最完善的自然现象之一。

过去一个世纪最激动人心的一些天文学发现与时间序列分析有关。发现变星(可用于推断星系距离)和观察超新星等瞬变事件(这增强了我们对宇宙如何随时间变化的理解)都是基于光的波长和强度的时间序列数据的实时监测的结果。时间序列对于我们可以了解和测量宇宙的内容产生了根本性影响。

顺便提一下,这种对天文图像的监测甚至让天文学家们能够在事件发生时捕捉到它们(或者说在我们能够观察到它们时,这可能需要数百万年)。(链接)

在过去几十年中,随着形式上带有时间戳的数据的大量涌现,天文学中的时间序列数据获得了爆炸性的增长,各种新型望远镜收集各种天体数据。一些天文学家甚至称之为时间序列的“数据洪流”。

时间序列分析腾飞

乔治·博克斯是一位开创性的统计学家,他帮助开发了一种流行的时间序列模型,是一位伟大的实用主义者。他曾经说过,“所有模型都是错误的,但有些是有用的。”

Box 提出了这个观点,以回应一种普遍的态度,即适当的时间序列建模是找到最适合数据的模型的问题。正如他解释的那样,任何模型能够描述真实世界的想法是非常不太可能的。Box 在 1978 年做出了这一声明,这在时间序列分析这样一个重要领域的历史上似乎有些晚,但事实上,这一正式学科非常年轻。

例如,使乔治·博克斯闻名的成就之一,即 Box-Jenkins 方法——被认为是时间序列分析的基本贡献——直到 1970 年才首次出现。⁴ 有趣的是,这种方法最初并非出现在学术期刊上,而是在统计学教科书《时间序列分析:预测与控制》(Wiley)中。顺便说一句,这本教材仍然很受欢迎,目前已经是第五版。

最初的 Box-Jenkins 模型是应用于从燃气炉排放的二氧化碳水平数据集。虽然燃气炉本身并不有趣,但用于展示该方法的 300 点数据集确实显得有些过时。当然,20 世纪 70 年代已经有更大的数据集可用,但请记住,那时处理这些数据集是非常困难的。那个时代还没有像 R、Python 甚至 C++这样的便利工具。研究人员有充分的理由专注于小数据集和最小化计算资源的方法。

随着计算机的发展,时间序列分析和预测也在不断进步,更大的数据集和更便捷的编码工具为更多实验和回答更有趣的问题铺平了道路。Rob Hyndman 教授的预测竞赛历史提供了恰当的例子,展示了时间序列预测竞赛如何以与计算机发展并行的速度发展。

教授 Hyndman 认为,“时间序列预测精度的最早非平凡研究”始于 1969 年在诺丁汉大学的博士论文,就在 Box-Jenkins 方法发表的一年之前。那次最初的尝试很快被有组织的时间序列预测竞赛所跟随,最早的竞赛于 20 世纪 70 年代初期举行,涵盖了约 100 个数据集。⁵ 这并不算差,但如果必要的话,完全可以手工完成。

到了 20 世纪 70 年代末,研究人员已经组织了一场拥有约 1,000 个数据集的竞赛,规模显著扩大。顺便说一句,这个时代也标志着第一款商业微处理器的出现、软盘的发展、苹果早期个人电脑和计算机语言 Pascal 的发展。可以肯定这些创新中的一些是有帮助的。到 20 世纪 90 年代末,一次时间序列预测竞赛包括了 3,000 个数据集。尽管这些数据集的收集和整理是相当庞大的,无疑反映了大量工作和创意,但与现在可用的数据量相比显得微不足道。时间序列数据无处不在,很快一切都将成为时间序列。

这种数据集规模和质量的迅速增长源于过去几十年来计算机技术的巨大进步。硬件工程师成功延续了摩尔定律的趋势——预测计算能力的指数增长——在这段时间内。随着硬件变得更小、更强大和更高效,显然更容易大量生产,价格也更加可承受——从带有传感器的微型便携式计算机到支撑现代数据密集型互联网的大型数据中心。最近,可穿戴设备、机器学习技术和 GPU 已经彻底改变了可供研究的数据的数量和质量。⁶

随着计算能力的增强,时间序列无疑会受益匪浅,因为时间序列数据的许多方面在计算上是具有挑战性的。随着计算和数据资源的增加,时间序列分析可以预期将继续快速发展。

统计时间序列分析的起源

统计学是一门非常年轻的科学。统计、数据分析和时间序列的进展始终强烈依赖于数据何时、何地、以何种方式可用及其数量。时间序列分析作为一门学科的出现,不仅与概率论的发展密切相关,同样也与稳定的国家形成有关,这些国家首次实现了记录的真实和有趣目标。我们之前已经在各种学科中覆盖了这一点。现在我们将思考时间序列本身作为一门学科。

时间序列分析作为一门学科的开始之一是将自回归模型应用于实际数据。这直到 20 世纪 20 年代才发生。剑桥大学的实验物理学家转变为统计讲师的乌德尼·尤尔,将自回归模型应用于太阳黑子数据,提供了一种新的思考数据的方式,与旨在拟合振荡频率的方法形成对比。尤尔指出,自回归模型并不假设周期性模型的开始:

当期谱分析应用于尊重任何物理现象的数据时,期望能够引出一个或多个真实的周期性时,通常,我觉得,有一种倾向,从最初的假设开始,即周期性或周期性仅被这种更或少的随机叠加波动(这些波动以任何方式都不会打扰基础周期功能或功能的稳定进程…似乎没有理由假设它是先验上最有可能的假设。

尤尔的思想是他自己的,但很可能某些历史影响促使他注意到传统模型预设了自己的结果。作为一位曾在德国(量子力学理论蓬勃发展的中心)工作过的前实验物理学家,尤尔肯定意识到最近强调了量子力学的概率性质的发展。他也会认识到,将思维狭窄到预设太多的模型中会有多大危险,正如量子力学被发现前的古典物理学家们所做的那样。

随着世界变得更有秩序、更可记录和更可预测,特别是在第二次世界大战后,早期实际时间序列分析中出现了业务部门提出的问题。以业务为导向的时间序列问题在其起源上重要,但不过度理论化。这些问题包括预测需求、估计未来原材料价格和对制造成本进行对冲。在这些工业应用案例中,技术被采用是因为它们有效,被拒绝是因为它们无效。工业工作者有时能够访问比当时学者更大的数据集(现在仍是如此)。这意味着有时候实际上是实用但在理论上尚未深入探索的技术在广泛使用之前进入了使用。

机器学习时间序列分析的起源

早期的时间序列分析中的机器学习可以追溯到几十年前。一篇常被引用的 1969 年的论文,“预测的组合”,分析了将预测组合而非选择“最佳预测”作为提高预测性能的方法。这个想法起初让传统统计学家感到憎恶,但集成方法已经成为许多预测问题中的黄金标准。集成方法拒绝了相对于所有可能模型来说完美甚至显著优越的预测模型的概念。

较近期,时间序列分析和机器学习的实际应用最早出现在 1980 年代,涵盖了各种各样的场景:

  • 计算机安全专家提出异常检测作为识别黑客/入侵的方法。

  • 动态时间规整,作为“测量”时间序列相似性的主要方法之一,因为计算能力终于允许对不同音频记录之间的“距离”进行相对较快的计算而进入使用。

  • 递归神经网络的发明显示其在从损坏数据中提取模式方面的实用性。

时间序列分析和预测还未到达它们的黄金时期,到目前为止,时间序列分析仍然由传统统计方法和更简单的机器学习技术主导,例如树的集成和线性拟合。我们仍在等待一个能够预测未来的重大突破。

更多资源

  • 关于时间序列分析和预测的历史:

    Kenneth F. Wallis,《重新审视弗朗西斯·高尔顿的预测竞赛》,《统计科学》29, no. 3 (2014): 420–24, https://perma.cc/FJ6V-8HUY。

    这是关于在县级集市上预测屠宰牛的重量的一篇非常早期的历史和统计讨论,当时牲畜还活着。

    G. Udny Yule,《关于研究扰动系列中周期性的一种方法,特别参考沃尔弗的太阳黑子数》, 《伦敦皇家学会哲学交易系列 A,数学或物理特征的论文》226 (1927): 267–98, https://perma.cc/D6SL-7UZS。

    Udny Yule 的开创性论文是对实际数据应用自回归移动平均分析的最早尝试,展示了一种消除假定周期性分析的方法。

    J.M. Bates 和 C. W. J. Granger,《预测的组合》,《组织研究季刊》20, No. 4 (1969): 451–68, https://perma.cc/9AEE-QZ2J。

    这篇开创性论文描述了在时间序列预测中使用集成的方法。平均模型比寻找一个完美模型更适合预测的观点对许多传统统计学家来说既新颖又有争议。

    Jan De Gooijer 和 Rob Hyndman,《时间序列预测 25 年》,《国际预测杂志》22 卷,第 3 期(2006 年):443–73,https://perma.cc/84RG-58BU。

    这是对 20 世纪时间序列预测研究的彻底统计摘要。

    Rob Hyndman,《时间序列预测竞赛简史》,Hyndsight 博客,2018 年 4 月 11 日,https://perma.cc/32LJ-RFJW。

    这篇更短、更具体的历史记录提供了过去 50 年中主要时间序列预测竞赛的具体数字、地点和作者。

  • 关于特定领域的时间序列历史和评论:

    NASA,《历经岁月的天气预报》,Nasa.gov,2002 年 2 月 22 日,https://perma.cc/8GK5-JAVT。

    NASA 讲述了天气预报的历史,重点介绍了 20 世纪特定研究挑战和成功之间的关系。

    Richard C. Cornes,《伦敦和巴黎的早期气象数据:扩展北大西洋涛动系列》(https://perma.cc/NJ33-WVXH),博士论文,英国诺里奇东英吉利大学环境科学学院,2010 年 5 月,https://perma.cc/NJ33-WVXH。

    这篇博士论文生动地描述了欧洲两座重要城市的天气信息种类,包括详细列出历史天气的位置和性质的时间序列格式。

    Dan Mayer,《医学与统计学简史》(https://perma.cc/WKU3-9SUX),收录于《基础证据医学》(英国剑桥:剑桥大学出版社,2004 年),https://perma.cc/WKU3-9SUX。

    Mayer 的这一章突出了医学与统计学之间的关系如何极大地依赖于使医疗从业者能够获得数据和统计培训的社会和政治因素。

    Simon Vaughan,《天文学中的随机时间序列》(https://perma.cc/J3VS-6JYB),《英国皇家学会哲学会刊物 A:数学、物理和工程科学》371 卷,第 1984 号(2013 年):1–28,https://perma.cc/J3VS-6JYB。

    Vaughan 总结了时间序列分析在天文学中的多种应用方式,并警告天文学家重新发现时间序列原理或错过与统计学家极具潜力的合作的危险。

¹ 例如,包括英国医生研究和护士健康研究。

² 参见,例如,Darrell Etherington,《亚马逊、摩根大通和伯克希尔哈撒韦将建立自己的医疗保健公司》,TechCrunch,2018 年 1 月 30 日,https://perma.cc/S789-EQGW;Christina Farr,《Facebook 派遣医生秘密任务,请求医院分享患者数据》,CNBC,2018 年 4 月 5 日,https://perma.cc/65GF-M2SJ。

³ 同一位罗伯特·菲茨罗伊曾是 HMS Beagle号的船长,正是在这次环球航行中带领查尔斯·达尔文探索世界。这次航行为达尔文提供了进化论自然选择理论的证据。

⁴ Box-Jenkins 方法已成为选择 ARMA 或 ARIMA 模型最佳参数的经典技术,用于对时间序列进行建模。更多内容请参见第六章。

⁵ 即不同领域的 100 个独立数据集,时间序列的长度各异。

⁶ 鉴于人类随身携带的各种小工具以及在购物、工作电脑门户登录、浏览互联网、检查健康指标、打电话或使用 GPS 导航时产生的时间戳,我们可以安全地说,一个普通美国人一生中可能每年产生数千个时间序列数据点。

第二章:找到和整理时间序列数据。

在本章中,我们讨论了在预处理时间序列数据时可能出现的问题。其中一些问题对经验丰富的数据分析师来说可能很熟悉,但时间戳带来了特定的困难。与任何数据分析任务一样,清理和正确处理数据通常是时间戳流水线中最重要的步骤。花哨的技术不能修复混乱的数据。

大多数数据分析师需要找到、对齐、清理和平滑他们自己的数据,无论是为了学习时间序列分析还是为了在其组织中做有意义的工作。在准备数据时,你需要完成各种任务,从合并不同列到重新采样不规则或缺失的数据,再到将时间序列与不同时间轴对齐。本章将帮助你走向一个有趣且适当准备的时间序列数据集。

我们讨论了以下对于查找和清理时间序列数据有用的技能:

  • 从在线存储库中找到时间序列数据。

  • 发现和准备不原本用于时间序列的数据来源的时间序列数据。

  • 处理时间序列数据时你会遇到的常见难题,特别是由于时间戳而引起的困难。

阅读完本章后,你将掌握识别和准备有趣的时间序列数据源进行下游分析所需的技能。

如何找到时间序列数据。

如果你对如何找到时间序列数据和如何清理它感兴趣,那么在这一章中对你最有帮助的资源取决于这两者哪个是你的主要目标:

  • 找到适合学习或实验目的的合适数据集。

  • 从不明确存储在面向时间的形式中的现有数据中创建一个时间序列数据集。

在第一种情况下,你应该找到已知基准的现有数据集,以便确定你是否正确地进行了分析。这些通常作为竞赛数据集(如 Kaggle)或存储库数据集来找到。在这些情况下,即使已经为你做了一些初步工作,你可能也需要为特定目的清理数据。

在第二种情况下,你应该考虑有效的方法来识别有趣的时间戳数据,将其转换为系列,清理它,并将其与其他时间戳数据对齐,以制作有趣的时间序列数据。我将这些在野数据集称为发现的时间序列(这是我自己的术语,不是技术语言)。

接下来我们讨论准备好的数据集和发现的时间序列。

准备好的数据集。

学习分析或建模技术的最佳方式是在各种数据集上运行它,并看看如何应用它以及它是否帮助你达到具体目标。在这种情况下,准备好的选项是很有帮助的。

尽管时间序列数据随处可见,但并非总是能够在需要时找到想要的数据类型。如果您经常处于这种位置,您可能希望熟悉一些常用的时间序列数据存储库,因此我们将讨论您应该考虑的几个选项。

UCI 机器学习库

UCI 机器学习库(见图 2-1)包含大约 80 个时间序列数据集,从意大利城市的每小时空气质量样本到亚马逊文件访问日志再到糖尿病患者的活动、食物和血糖信息记录。这些都是非常不同类型的数据,浏览文件显示它们反映了跨时间跟踪信息的不同方式,但每个都是时间序列。

图 2-1. UCI 机器学习库包含时间序列数据集的带注释列表。

考虑 UCI 存储库中时间序列部分下列出的第一个数据集,这是关于工作缺勤的数据集(见图 2-2)。

快速查看数据显示,时间列限制为“缺勤月份”、“星期几”和“季节”,没有年份列。存在重复的时间索引,但还有一列指示员工身份,以便我们可以区分这些重复的时间点。最后,还有各种员工属性列。

图 2-2. 工作缺勤数据集是 UCI 机器学习库时间序列数据集列表中的第一个。

这个数据集可能非常具有挑战性,因为您首先需要确定数据是否全来自一年,或者行进展中 1 到 12 个月的循环是否表明年份在变化。您还需要决定是从整体上看问题,从单位时间的缺勤率角度看数据,还是查看数据集中报告的每个 ID 的缺勤情况(见图 2-3)。在第一种情况下,您将拥有一个单一的时间序列,而在后一种情况下,您将拥有多个具有重叠时间戳的时间序列。您如何查看数据将取决于您想要回答的问题。

图 2-3. 澳大利亚手语数据集中一个文件的前几行。正如您所看到的,这是一种宽文件格式。

将缺勤数据集与列表中早期的另一个数据集进行对比,即澳大利亚手语标志数据集,其中包括使用 Nintendo PowerGlove 录制的主题使用澳大利亚手语的录音。数据格式为宽 CSV 文件,每个文件夹表示个体测量,并且文件名指示手语标志。

在这个数据集中,列没有标签,也没有时间戳。尽管如此,这是时间序列数据;时间轴计算时间步长的前进,而不管实际事件发生的时间。注意,对于将符号视为时间序列的目的来说,时间单位并不重要;重要的是事件的顺序,以及您是否可以从数据描述中假设或确认测量是按照规律间隔进行的。

从检查这两个数据集可以看出,您将遇到各种数据处理挑战。我们已经注意到的一些问题有:

  • 不完整的时间戳

  • 时间轴可以是数据中的水平或垂直轴

  • 时间概念的变化

UEA 和 UCR 时间序列分类库

UEA 和 UCR 时间序列分类库是一个新的努力,提供一组常见的时间序列数据,可用于时间序列分类任务的实验和研究。它还展示了非常多样化的数据。我们可以通过查看两个数据集来看到这一点。

其中一个数据集是瑜伽动作分类任务。分类任务涉及区分两位表演者在录制图像时执行瑜伽姿势转换系列的能力。图像被转换为一维系列。数据存储在 CSV 文件中,标签为最左侧列,其余列表示时间步长。时间从左到右穿过列,而不是从上到下穿过行。

展示了每个性别的两个样本时间序列图,见图 2-4。

单变量与多变量时间序列

到目前为止,我们查看的数据集是单变量时间序列;即它们只有一个变量随时间变化。

多变量时间序列是在每个时间戳测量多个变量的序列。它们特别适合分析,因为通常测量的变量彼此相关,并显示彼此之间的时间依赖性。我们稍后会遇到多变量时间序列数据。

图 2-4. 展示了一个男性和一个女性演员重复执行瑜伽动作的情况。我们绘制了每个演员的两个样本时间序列。x 轴上没有显式的时间标签。重要的不是时间单位,而是 x 轴数据点是否均匀分布,正如这里所展示的那样。

也要考虑葡萄酒数据集,其中葡萄酒根据其光谱的形状被分类为不同地区。那么这与时间序列分析有何关联呢?光谱是光波长与强度的图示。在这里,我们看到一个时间序列分类任务,其中根本没有时间的流逝。然而,时间序列分析适用,因为在 x 轴上有一个独特且有意义的排序,其具体含义是沿该轴的距离。时间序列分析通过利用 x 轴的顺序提供的额外信息,无论是时间还是波长或其他东西,与横截面分析有所不同。我们可以在 图 2-5 中看到这样一个“时间”序列的图示。虽然没有时间元素,但我们仍在看一个有序的数据系列,因此时间序列的常规概念同样适用。¹

图 2-5. UCI 葡萄酒数据集中葡萄酒的样本光谱。曲线中的峰值指示具有特别高吸收率的波长区域。波长在 x 轴上均匀分布,而 y 轴表示吸收率,也是线性刻度。我们可以使用时间序列分析来比较诸如上述曲线之类的曲线。

政府时间序列数据集

几十年甚至几个世纪以来,美国政府一直是可靠的时间序列数据提供者。例如,NOAA 国家环境信息中心发布了与全国各气象站有关的各种时间序列数据,涉及温度和降水,甚至可以每 15 分钟精细化。劳工统计局每月发布全国失业率指数。疾病控制与预防中心在流感季节期间每周发布流感病例统计。圣路易斯联邦储备银行提供了一系列非常丰富和有用的经济时间序列数据。

对于初次涉足时间序列分析,我建议您仅对这些现实世界的政府数据集进行探索性分析和可视化。学习这些数据集可能会很困难,因为它们提出了极为复杂的问题。例如,许多经济学家在官方发布前整个职业生涯都在努力预测失业率,但仅有有限的成功。

对于政府面临的重要但难以解决的问题,预测未来不仅在社会上有益,而且在经济上也很有利可图。许多聪明而受过良好训练的人正在解决这些问题,尽管技术水平仍然有些令人失望。解决困难问题是很好的,但不建议在这些问题上学习。

其他有用的来源

虽然我们不能详尽地覆盖所有优秀的时间序列数据源,但还有几个其他的资源库你应该探索:

[Comp CompEngine

这个“自组织时间序列数据数据库”拥有超过 25,000 个时间序列数据库,总计接近 1.4 亿个单独的数据点。这个资源库及其在网页界面上提供的相关软件,重点是促进和推广高度比较时间序列分析(hctsa)。这种分析的目标是生成高级洞见,理解各种时间行为的多种类型,而无需特定学科的数据。

McompM4comp2018 R 包

这些 R 包提供了 1982 年 M 竞赛(1,001 个时间序列)、2000 年 M3 竞赛(3,003 个时间序列)和 2018 年 M4 竞赛(100,000 个时间序列)的竞赛数据。这些时间序列预测竞赛之前在第一章中提到了 Rob Hyndman 教授关于时间序列预测的历史。R 的tscompdata包中还包含了更多的时间序列预测竞赛数据。最后,还可以在 CRAN 的时间序列包资源库列表中找到更多专业的时间序列数据集,详情请见时间序列包的 CRAN 资源库列表中的“时间序列数据”头部。

发现时间序列

在本章的早些时候,我们讨论了“发现时间序列”的概念,即我们从野外数据源中自行整理出来的时间序列数据。更具体地说,这些时间序列是从没有特别为时间序列分析设置的单个数据点中整理出来的,但具有足够的信息来构建时间序列。以从存储公司交易的 SQL 数据库中为特定客户拼接的交易时间序列为例,这就是一个干净的例子。在这种情况下,只要数据库中保存了时间戳或某种时间戳的代理,就可以构建时间序列。² 我们还可以想象从相同的数据中构建的其他时间序列,例如公司每天的总交易量时间序列或每周女性客户的总美元交易量时间序列。我们甚至可以想象生成多变量时间序列数据,例如一个时间序列,分别表示每周所有未满 18 岁客户的总交易量、每周 65 岁以上女性的总支出和公司每周的广告支出。这将在每个时间步骤给我们提供三个指标,一个多变量时间序列。

在结构化数据中找到时间序列数据,即使未明确存储为时间序列,也可以很容易,因为时间戳是无处不在的。以下是一些在数据库中看到时间戳的示例:

事件的时间戳记录

如果您的数据有时间戳,您就有可能构建时间序列。即使您只记录了文件访问时的时间而没有其他信息,您也有了一个时间序列。例如,在这种情况下,您可以模拟时间戳之间的时间差,并将其标记为其后时间戳,使得您的时间序列将由时间轴上的时间和值轴上的时间差组成。您可以进一步进行聚合,将这些时间差作为更大时间段的平均值或总和,或者您可以单独记录它们。

“无时间性”测量,其中另一种测量替代时间

在某些情况下,数据中时间并不显式,但在数据集的基础逻辑中有所体现。例如,当距离由已知的实验参数引起时,您可能会将数据视为“距离与值”的关系。如果您能够将其中一个变量映射到时间上,那么您就拥有了一个时间序列。或者,如果您的一个轴具有已知的距离和排序关系(例如波长),那么您也正在查看时间序列数据,例如前面提到的葡萄酒光谱案例。

物理迹象

许多科学学科记录物理迹象,无论是医学、听力还是天气。这些曾经是通过模拟过程生成的物理迹象,但现在它们以数字格式存储。尽管它们可能存储在不显而易见的格式中,例如图像文件或数据库的单个向量字段中,但这些也是时间序列。

将一组表中的时间序列数据收集进行改装

发现的时间序列的典型例子是从存储在 SQL 数据库中的状态类型和事件类型数据中提取的。这也是最相关的示例,因为仍然有大量数据存储在传统的结构化 SQL 数据库中。

想象一下为一个大型非营利组织工作。您一直在跟踪各种可能适合进行时间序列分析的因素:

  • 电子邮件接收者对电子邮件的反应随时间变化:他们是否打开了邮件?

  • 会员历史记录:会员是否有间断的会员资格期?

  • 交易历史记录:个人何时购买以及我们能预测吗?

您可以使用几种时间序列技术来查看数据:

  • 您可以生成成员对电子邮件响应的二维直方图以了解成员是否因电子邮件而感到疲劳的时间线。(我们将在第三章中说明使用二维直方图来理解时间序列的方法。)

  • 你可以将捐赠预测转化为时间序列预测问题。(我们将在 第 4 章 中讨论经典的统计预测。)

  • 你可以检查在重要情况下成员行为轨迹的典型模式是否存在。例如,是否存在一种典型的事件模式表明一个成员即将离开您的组织(也许连续三封电子邮件被删除)?在时间序列分析中,你可以将这视为基于外部动作检测成员潜在状态的方法。(我们将在 第 7 章 中讨论时间序列分析的状态空间方法。)

正如我们所看到的,在一个简单的 SQL 数据库中有许多时间序列的问题和答案。在许多情况下,组织在设计数据库架构时并未计划进行时间序列分析。在这种情况下,我们需要从不同的表和来源收集和组装时间序列。

一个案例:组装时间序列数据收集

如果你有幸有几个相关的数据源可用,你将需要将它们排列在一起,可能要处理不同的时间戳约定或数据中不同的粒度级别。让我们为我们正在使用的非营利组织示例创建一些数字。假设你有如下所示的数据:表 2-1 到 表 2-3:

表 2-1. 每位成员加入的年份和当前会员状态

成员 ID 加入年份 成员状态
1 2017
2 2018
3 2016 无效

表 2-2. 一周内成员打开的电子邮件数量

成员 ID 打开的邮件数
2 2017-01-08 3
2 2017-01-15 2
1 2017-01-15 1

表 2-3. 成员捐赠给您组织的时间

成员 ID 时间戳 捐赠金额
2 2017-05-22 11:27:49 1,000
2 2017-04-13 09:19:02 350
1 2018-01-01 00:15:45 25

你可能已经用过这种表格形式的数据工作过。有了这样的数据,你可以回答许多问题,比如成员打开的邮件总数与捐赠总数之间的关系。

你还可以回答与时间有关的问题,比如一个成员是在加入后不久捐赠还是很久之后捐赠。然而,如果不将这些数据转换为更适合时间序列的格式,你就无法深入了解可能帮助你预测某人何时可能捐赠的更精细行为(比如根据他们最近是否在打开邮件)。

你需要将这些数据放入一个合理的时间序列分析格式中。你将需要解决一些挑战。

你应该从考虑我们已有数据的时间轴开始。在上述表格中,我们有三个层次的时间分辨率:

  • 一个年度成员状态

  • 一周内打开的电子邮件总数

  • 捐赠的即时时间戳

您还需要检查数据是否意味着您认为它意味着什么。例如,您可能想要确定成员状态是年度状态还是最近的状态。回答这个问题的一个方法是检查是否有任何成员有多个条目:

## python 
>>> YearJoined.groupby('memberId').count().
                   groupby('memberStats').count()

1000

在这里,我们可以看到所有 1,000 名成员只有一个状态,因此他们加入的年份很可能确实是YearJoined,并且伴随一个可能是成员当前状态或加入时的状态。这影响如何使用状态变量,因此,如果您打算进一步分析这些数据,您需要与了解数据管道的人澄清。如果您将成员的当前状态应用于过去数据的分析中,这将是一种预测,因为您会向时间序列模型输入在当时无法知道的内容。这就是为什么在不知道分配时间的状态变量(例如YearJoined)时,您不应该使用它的原因。

查看电子邮件表,week列及其内容都表明数据是一个周时间戳或时间段。这必须是一周的汇总,所以我们应该将这些时间戳视为每周期而不是相隔一周的时间戳。

您应该评估一些重要的特征。例如,您可以从询问时间中如何报告周开始。虽然我们可能没有信息来重新组织表格,但如果与我们的行业相比,一周的划分方式有些奇怪,我们可能也想了解这一点。对于分析人类活动,通常有意义的是查看星期日至星期六或星期一至星期日的日历周,而不是与人类活动周期不太一致的周。因此,例如,不要随意从一月一日开始您的一周。

您还可以问空周是否报告了?也就是说,成员在表中打开 0 封电子邮件的周是否有位置?当我们想要进行面向时间的建模时,这很重要。在这种情况下,我们需要始终在数据中有空周存在,因为 0 周仍然是一个数据点。

## python
>>> emails[emails.EmailsOpened < 1]

Empty DataFrame
Columns: [EmailsOpened, member, week]
Index: []

有两种可能性:要么空值没有报告,要么成员总是至少有一个电子邮件事件。任何使用电子邮件数据的人都知道,让人们打开电子邮件很难,因此成员每周至少打开一封电子邮件的假设是相当不可能的。在这种情况下,我们可以通过查看仅一个用户的历史来解决这个问题。

## python
>>> emails[emails.member == 998]
      EmailsOpened member  week
25464  1           998   2017-12-04
25465  3           998 2017-12-11
25466  3           998 2017-12-18
25467  3           998 2018-01-01
25468  3           998 2018-01-08
25469  2           998 2018-01-15
25470  3           998 2018-01-22
25471  2           998 2018-01-29
25472  3           998 2018-02-05
25473  3           998 2018-02-12
25474  3           998 2018-02-19
25475  2           998 2018-02-26
25476  2           998 2018-03-05

我们可以看到一些周是缺失的。在 2017 年 12 月 18 日后,2017 年 12 月没有任何电子邮件事件。

通过计算该成员的第一个和最后一个事件之间应该有多少周观察,我们可以更数学地检查这一点。首先,我们计算成员任期的长度,以周为单位。

## python
>>> (max(emails[emails.member == 998].week) - 
                     min(emails[emails.member == 998].week)).days/7
25.0

然后我们看看该成员有多少周的数据:

## python
>>> emails[emails.member == 998].shape
(24, 3)

我们这里有 24 行,但应该有 26 行。这显示这个成员的一些周数据是缺失的。顺便说一句,我们也可以通过分组操作同时对所有成员运行此计算,但是仅考虑一个成员更易于理解示例。

现在我们已确认确实有缺失的周,我们将继续填补空白,以便我们现在拥有完整的数据集。我们不能确定识别所有缺失的周,因为有些可能发生在我们记录的日期之前或之后。然而,我们可以做的是填补成员在非空事件发生之前和之后第一个和最后一个时间之间的缺失值。

通过利用 Pandas 的索引功能,填充所有成员的所有缺失周要比编写我们自己的解决方案更容易。我们可以为 Pandas 数据框生成一个 MultiIndex,这将创建周和成员的所有组合——即笛卡尔积:

## python
>>> complete_idx = pd.MultiIndex.from_product((set(emails.week),
                                      set(emails.member)))

我们使用此索引重新索引原始表格,并填充缺失的值——在这种情况下,假设没有记录意味着没有可记录的内容,我们还重置索引以使成员和周信息作为列可用,并将这些列命名为:

## python
>>> all_email = emails.set_index(['week', 'member']).
                             reindex(complete_idx, fill_value = 0).
                             reset_index()
>>> all_email.columns = ['week', 'member', 'EmailsOpened']

让我们再次看看成员 998:

## python
>>> all_email[all_email.member == 998].sort_values('week')
  week     member EmailsOpened
2015-02-09 998    0
2015-02-16 998    0
2015-02-23 998    0
2015-03-02 998    0
2015-03-09 998    0

注意我们一开始就有大量的零。这些可能是成员加入组织之前的时间,因此他们不会出现在电子邮件列表中。在我们想要保留成员真正空闲周的分析中,没有太多种类。如果我们有成员开始接收电子邮件的确切日期,我们将有一个客观的截止日期。由于现状如此,我们将让数据指导我们。对于每个成员,我们通过将电子邮件 DataFrame 按成员分组,并选择最大和最小周值来确定 start_dateend_date 截止日期:

## python
>>> cutoff_dates = emails.groupby('member').week.
                           agg(['min', 'max']).reset_index)
>>> cutoff_dates = cutoff_dates.reset_index()

我们从 DataFrame 中删除那些在时间线上没有贡献意义的行,具体来说是每个成员的第一个非零计数之前的 0 行:

## python
>>> for _, row in cutoff_dates.iterrows(): 
>>>   member     = row['member']
>>>   start_date = row['min']
>>>   end_date   = row['max'] 
>>>   all_email.drop(
              all_email[all_email.member == member]
             [all_email.week < start_date].index, inplace=True) 
>>>   all_email.drop(all_email[all_email.member == member]
             [all_email.week > end_date].index, inplace=True)

< 还是 <= ?

我们使用 < 和 > 操作符,不包括等号,因为 start_dateend_date 包含了有意义的数据点,并且因为我们正在删除数据,而不是保留数据,按照我们的代码编写。在这种情况下,我们希望在分析中包括这些周,因为它们是第一个和最后一个有意义的数据点。

你最好与你的数据工程师和数据库管理员一起工作,说服他们以时间感知的方式存储数据,特别是关于如何创建时间戳及其含义。你能在上游解决的问题越多,下游数据管道的工作就越少。

现在我们已经清理了电子邮件数据,我们可以考虑新的问题。例如,如果我们想考虑成员电子邮件行为与捐赠的关系,我们可以做一些事情:

  • DonationAmount聚合到每周,以便时间段可比。然后可以合理地询问捐赠是否与成员对电子邮件的响应有某种关联。

  • 将上周的EmailsOpened作为给定周的DonationAmount的预测因子。请注意,我们必须使用上周的数据,因为EmailsOpened是一周的汇总统计数据。如果我们想要预测星期三的捐款,并且我们的EmailsOpened总结了从周一到周日的电子邮件开启行为,那么使用同一周的信息可能会告诉我们成员在我们能知道之后的行为(例如,他们是否在捐款后的星期五打开了电子邮件)。

构建一个基础时间序列。

考虑如何将电子邮件和捐款数据相互关联。我们可以将捐款数据降采样,使其变成一个每周的时间序列,可与电子邮件数据进行比较。作为一个组织,我们对每周总金额感兴趣,因此我们通过求和将时间戳聚合为每周周期。一周内捐款超过一个的情况不太可能发生,因此每周捐款金额将反映大多数捐赠者的个人捐款金额。

## python
>>> donations.timestamp = pd.to_datetime(donations.timestamp)
>>> donations.set_index('timestamp', inplace = True)
>>> agg_don = donations.groupby('member').apply(
              lambda df: df.amount.resample("W-MON").sum().dropna())

在这段代码中,我们首先将字符串字符转换为适当的时间戳数据类,以便从 Pandas 的内置日期相关索引中获益。我们将时间戳设为索引,这是对数据帧进行重新采样所必需的。最后,对于从子集到每个会员得到的数据帧,我们按周分组并汇总捐款,删除没有捐款的周,然后将它们收集在一起。

请注意,我们进行了带锚定周的重新采样,以便与我们电子邮件表中已有的同一周日期匹配。还要注意,从人类角度来看,将周锚定在“星期一”是有意义的。

现在我们有了捐款信息和电子邮件信息以相同频率采样的数据,我们可以将它们连接起来。只要我们已经将周的数据锚定到同一周的某一天,Pandas 使这变得很简单。我们可以迭代每个会员,并合并数据帧:

## python
>>> for member, member_email in all_email.groupby('member'):
>>>     member_donations = agg_donations[agg_donations.member 
                                         == member]

>>> 	 member_donations.set_index('timestamp', inplace = True) 
>>> 	 member_email.set_index    ('week', inplace = True) 

>>> 	 member_email = all_email[all_email.member == member]
>>> 	 member_email.sort_values('week').set_index('week') 

>>> 	 df = pd.merge(member_email, member_donations, how = 'left', 
                 			  left_index = True, 
                 			  right_index = True)
>>> 	 df.fillna(0) 

>>> 	 df['member'] = df.member_x 
>>> 	 merged_df = merged_df.append(df.reset_index()
                   			 [['member', 'week', 'emailsOpened', 'amount']])

现在我们有了按会员排列的电子邮件和捐款数据。对于每个会员,我们只包括有意义的周,而不是会员期前后的周。

我们可能会将电子邮件行为视为与捐赠行为相关的“状态”变量,但我们可能希望从上周保留状态,以避免预先看到。例如,假设我们正在构建一个模型,该模型使用电子邮件行为来预测会员的下次捐赠。在这种情况下,我们可能会考虑观察一周内电子邮件打开的模式作为潜在的指标。我们需要将给定周的捐赠与上周的电子邮件行为对齐。我们可以轻松地对我们的数据进行处理,使其按周对齐,然后根据适当的周数进行移动。例如,如果我们想将捐赠向前推移一周,我们可以使用 shift 运算符轻松实现,尽管我们需要确保对每个会员都这样做:

## python
>>> df = merged_df[merged_df.member == 998]
>>> df['target'] = df.amount.shift(1)
>>> df = df.fillna(0)
>>> df

最好将这个目标存储在一个新列中,而不是覆盖旧列,特别是如果你没有将捐赠金额的时间戳也同时前移。我们使用 Pandas 内置的 shift 功能,将捐赠金额向未来推移了一周。你也可以用负数向过去推移。通常情况下,你会有更多的预测因子而不是目标变量,因此将目标变量向后移动是合理的。我们可以在这里看到代码的结果:

amount 	 emailsOpened   member   	week         target
 0       	1            998 		2017-12-04     	0
 0       	3            998 		2017-12-11     	0
 0       	3            998 		2017-12-18     	0
 0       	0            998 		2017-12-25     	0
 0       	3            998 		2018-01-01     	0
50       	3            998 		2018-01-08     	0
 0       	2            998 		2018-01-15    	50

现在我们已经填补了缺失的行,为会员 998 创建了所需的 26 行。我们的数据现在更加清洁和完整。

总结一下,这些是我们用来重构数据的特定于时间序列的技术:

  1. 重新校准我们数据的分辨率以适应我们的问题。通常数据提供的时间信息比我们实际需要的更加具体。

  2. 避免使用数据来生成时间戳,从而避免预先看到数据的可用性。

  3. 记录所有相关时间段,即使“什么也没发生”。零计数与其他计数同样具有信息量。

  4. 通过不使用数据来生成我们还不应该知道的信息的时间戳,避免预先看到

到目前为止,我们通过使捐赠和电子邮件时间序列在相同时间点和相同频率采样来创建了原始发现时间序列。然而,在进行分析之前,我们没有彻底清理这些数据或者完全探索它们。这是我们将在第三章中进行的工作。

时间戳问题

时间戳对于时间序列分析非常有帮助。通过时间戳,我们可以推断出许多有趣的特征,比如时间的具体时段或一周中的某一天。这些特征对于理解数据尤为重要,尤其是涉及人类行为的数据。然而,时间戳也有其难点。在这里,我们讨论了时间戳数据的一些困难。

哪个时间戳?

当看到时间戳时,你应该首先问的问题是:这个时间戳是由什么过程生成的,是怎样生成的,以及何时生成的。通常发生的事件并不一定与记录事件的时间一致。例如,研究人员可能会在笔记本上写下一些内容,然后稍后将其转录到用作日志的 CSV 文件中。时间戳是否表示他们写下内容的时间,还是将其转录到 CSV 文件的时间?或者移动应用程序用户可能会在他们的手机离线时执行操作,以便数据稍后通过一些时间戳的组合上传到您的服务器。这些时间戳可以反映行为发生的时间,应用程序记录行为的时间,元数据上传到服务器的时间,或者从服务器下载数据返回到应用程序时的时间(或数据管道中的任何其他事件)。

一开始,时间戳可能会提供清晰度,但如果缺乏适当的文档,这种清晰度很快就会消失。当您查看新的时间戳时,您的第一步应始终是尽可能好地了解事件时间的来源。

一个具体的例子将说明这些困难。假设您正在查看从减肥移动应用程序中获取的数据,并看到餐饮日记,其中包含如表 2-4 所示的条目。

Table 2-4. 减肥应用程序的样本餐饮日记

时间 摄入
Mon, April 7, 11:14:32 煎饼
Mon, April 7, 11:14:32 三明治
Mon, April 7, 11:14:32 pizza

可能是这位用户同时吃了煎饼、三明治和披萨,但更有可能的情况是什么?用户是否指定了这个时间,还是它是自动创建的?界面是否提供了用户可以调整或选择忽略的自动时间?对这些问题的一些答案可以更好地解释相同时间戳背后的原因,而不是一个试图减肥的用户同时吃了煎饼、披萨和三明治作为开胃菜。

即使用户确实在 11:14 时吃了所有这些食物,但世界上哪里是 11:14 呢?这是用户的本地时间还是全球时钟?即使在用户在一顿饭中吃了所有这些食物的情况下(尽管可能性很小),仅凭这些行就不能很好地了解到这顿饭的时间方面。我们不知道这是早餐、午餐、晚餐还是小吃。要向用户提供有趣的信息,我们需要能够具体讨论一天中的当地小时,而没有时区信息我们无法做到这一点。

解决这些问题的最佳方法是查看所有收集和存储数据的代码,或者与编写该代码的人交谈。在研究系统上所有可用的人类和技术数据规范后,你还应该亲自尝试整个系统,以确保数据的行为与你被告知的一致。你对数据管道了解得越深入,你就越不太可能因为时间戳实际上不是你认为的那样而问错问题。

你需全面了解数据,对于上游管道的工作人员来说,他们并不知道你为分析准备了什么。尽可能地亲自评估时间戳生成的方式。因此,如果你正在分析来自移动应用管道的数据,请下载该应用,在各种情况下触发事件,并查看你自己的数据是什么样子。在与管理数据管道的人交谈后,你可能会对你的行为如何记录感到惊讶。追踪多个时钟和不同情况很困难,因此大多数数据集会简化时间的真实情况。你需要确切了解它们的处理方式。

猜测时间戳以理解数据含义

如果你处理遗留数据管道或未记录的数据,你可能无法探索工作中的管道或与维护它的人交谈的选项。你需要进行一些实证调查,以了解是否能推断出时间戳的含义:

  • 如同前面的示例中所做的那样阅读数据,你可以对时间戳含义产生初步的假设。在前述情况下,查看多个用户的数据,看看是否存在相同模式(具有相同时间戳和不太可能的单一餐内容的多行),或者这是否是个异常情况。

  • 使用聚合级别分析,你可以测试关于时间戳含义或可能含义的假设。对于前面的数据,存在一些未解的问题:

    • 时间戳是本地时间还是世界协调时间(UTC)?

    • 时间是否反映了用户的操作或某种外部约束,比如连接性?

本地时间还是世界协调时间?

大多数时间戳都以世界协调时间(UTC)或单一时区存储,这取决于服务器的位置,而与用户的位置无关。按照本地时间存储数据是相当不寻常的。但我们应该考虑这两种可能性,因为“野外”中都有这两种情况。

我们假设,如果时间是本地时间戳(每个用户的本地时间),我们应该在数据中看到反映白天和黑夜行为的日常趋势。更具体地说,我们应该期待在夜间,即使在大多数文化中人们不会在深夜吃饭的时段,也应该看到餐次数显著减少。对于我们移动应用程序的示例,如果我们创建一天内每小时餐次计数的直方图,应该会发现几个小时内记录的餐次较少。

如果我们在显示的小时内没有看到以日为导向的模式,我们可以得出结论,数据很可能是由某个全球时钟标记的,并且用户群体必须分布在许多不同的时区。在这种情况下,推断个别用户的当地时间将非常具有挑战性(假设没有时区信息)。我们可能会考虑每个用户的个别探索,看看是否可以编写启发式代码来近似标记用户的时区,但这样的工作既计算密集又不总是准确。

即使你无法准确确定用户的时区,拥有全局时间戳仍然很有用。首先,你可以确定你的应用服务器在何时何地最频繁记录饭菜时间。你还可以计算用户记录的每餐之间的时间差异,因为时间戳是绝对时间,所以不需要担心用户是否更改了时区。除了侦探工作之外,这也是一种有趣的特征生成方式:

## python
>>> df['dt'] = df.time - df.time.shift(-1)

dt列将是你可以在分析中使用的特征。使用这种时间差异也可以让你有机会估计每个用户的时区。你可以查看用户通常具有长dt的时间,这可能指向该用户的夜间发生时间。从那里开始,你可以开始确定每个人的“夜间”时间,而不必进行峰对峰分析。

用户行为还是网络行为?

返回到我们短数据样本提出的另一个问题,我们问的是我们的用户是否吃了奇怪的饭菜,或者我们的时间戳是否与上传活动相关。

用来确定用户时区的相同分析方法也适用于确定时间戳是否是用户或网络行为的功能。一旦你有了dt列(如先前计算的),你可以寻找 0 值的聚类,并且可以定性地确定它们是单个行为事件还是单个网络事件。你还可以查看dt是否在不同的日期间隔内表现出周期性。如果它们是用户行为的功能,它们更可能是周期性的,而不是网络连接或其他软件相关行为的功能。

总结一下,以下是一些你可能能够用现有数据集解决的问题,即使几乎没有关于时间戳如何生成的信息:

  • 使用每个用户的时间戳差异来了解餐食间隔或数据条目间隔(根据你的工作假设,时间指示用户行为或网络行为)。

  • 描述聚合用户行为,以确定在 24 小时周期内你的服务器最可能活跃的时间。

什么是有意义的时间尺度?

您应该基于关于您正在研究的行为的领域知识以及您能确定的与数据收集方式相关的细节,对您接收到的时间戳的时间分辨率持保留态度。

例如,想象一下您正在查看日销售数据,但您知道在许多情况下,经理们会等到周末报告数据,估计粗略的日常数字而不是每天记录它们。由于回忆问题和内在认知偏差,测量误差可能会相当大。您可以考虑将销售数据的分辨率从日常更改为每周,以减少或平均这种系统误差。否则,您应该构建一个模型,考虑不同工作日之间可能存在的偏误。例如,可能经理在星期五报告数据时系统性地高估他们的星期一业绩。

心理时间贴现

时间贴现是一种称为心理距离的现象的表现,它描述了我们在进行距离较远的估算或评估时更加乐观(也更不现实)的倾向。时间贴现预测,与最近记忆中记录的数据相比,从较远过去报告的数据将系统性地存在偏差。这与更普遍的遗忘问题不同,并暗示了非随机误差。每当您查看手动输入但未与记录事件同时发生的人工生成数据时,请记住这一点。

另一种情况涉及对系统的物理知识。例如,一个人的血糖水平变化速度存在一定的限制,因此,如果您在几秒钟内查看一系列血糖测量值,您可能应该对它们进行平均处理,而不是将它们视为不同的数据点。任何医生都会告诉您,如果您在几秒钟内查看了许多测量值,那么您正在研究设备的误差而不是血糖变化的速率。

人类知道时间在流逝

每当您在测量人类时,请记住人们对时间的流逝有多种反应方式。例如,最近的研究显示,调整一个人视野中的时钟速度会影响该人血糖水平变化的速度。

清洗您的数据

在本节中,我们将解决时间序列数据集中的以下常见问题:

  • 缺失数据

  • 更改时间序列的频率(即上采样和下采样)

  • 平滑数据

  • 处理数据的季节性

  • 防止无意识的超前看法

处理缺失数据

缺失数据非常普遍。例如,在医疗保健领域,医疗时间序列中的缺失数据可能有多种原因:

  • 患者未遵守所需的测量。

  • 患者的健康统计数据很好,因此不需要进行特定的测量。

  • 患者被遗忘或未经适当治疗。

  • 医疗设备发生了随机技术故障。

  • 发生了数据输入错误。

一般化地说,与横截面数据分析相比,时间序列分析中缺失数据更为常见,因为纵向采样的负担特别重:不完整的时间序列非常普遍,因此已经开发出方法来处理记录中的空缺。

处理时间序列缺失数据最常见的方法有:

插补

当我们根据对整个数据集的观察填补缺失数据时。

插值

当我们使用相邻数据点来估计缺失值时。插值也可以是一种插补形式。

删除受影响的时间段

当我们选择根本不使用具有缺失数据的时间段时。

我们将讨论插补和插值,并很快展示这些方法的机制。我们关注保留数据,而像删除具有缺失数据的时间段这样的方法会导致模型的数据减少。是否保留数据或丢弃问题时间段将取决于您的用例以及考虑到模型数据需求是否能够牺牲这些时间段。

准备数据集以测试缺失数据插补方法

我们将使用自 1948 年以来由美国政府发布的月度失业数据(可免费下载)。然后我们将从这些基础数据生成两个数据集:一个是真正随机缺失数据的情况,另一个是时间序列历史上最高失业率的月份。这将为我们提供两个测试案例,以查看在随机和系统性缺失数据存在的情况下,插补的行为如何。

提示

我们将转向 R 来进行下一个示例。在整本书中,我们将自由切换使用 R 和 Python。我假设您在使用数据框架和 R 与 Python 中的矩阵方面具有一定的背景知识。

## R
> require(zoo)        ## zoo provides time series functionality 
> require(data.table) ## data.table is a high performance data frame

> unemp <- fread("UNRATE.csv")
> unemp[, DATE := as.Date(DATE)]
> setkey(unemp, DATE)

> ## generate a data set where data is randomly missing
> rand.unemp.idx <- sample(1:nrow(unemp), .1*nrow(unemp))
> rand.unemp     <- unemp[-rand.unemp.idx]

> ## generate a data set where data is more likely 
> ## to be missing when unemployment is high
> high.unemp.idx <- which(unemp$UNRATE > 8)
> num.to.select  <- .2 * length(high.unemp.idx)
> high.unemp.idx <- sample(high.unemp.idx,)
> bias.unemp     <- unemp[-high.unemp.idx]

因为我们从数据表中删除了行来创建一个带有缺失数据的数据集,所以我们需要读取缺失的日期和 NA 值,对于这一点,data.table包的rolling join功能非常有用。

## R
> all.dates <- seq(from = unemp$DATE[1], to = tail(unemp$DATE, 1), 
                                                  by = "months")
> rand.unemp = rand.unemp[J(all.dates), roll=0]
> bias.unemp = bias.unemp[J(all.dates), roll=0]
> rand.unemp[, rpt := is.na(UNRATE)]
## here we label the missing data for easy plotting

使用滚动连接,我们生成数据集开始和结束日期之间应该可用的所有日期序列。这为我们提供了数据集中要填充为NA的行。

现在我们有了带有缺失值的数据集,我们将看一下填充这些缺失值的几种具体方法:

  • 前向填充

  • 移动平均

  • 插值

我们将比较这些方法在随机缺失和系统缺失数据集中的性能。由于我们从完整数据集生成了这些数据集,因此实际上可以确定它们的表现而不是推测。当然,在现实世界中,我们永远不会有缺失数据来检查我们的数据插补。

前向填充

填补缺失值的最简单方法之一是向前传递缺失点前的最后已知值,这种方法被称为前向填充。不需要数学或复杂的逻辑。只需考虑随着可用数据的时间推移经验,您可以看到在时间的缺失点,您唯一可以确信的是已记录的数据。在这种情况下,使用最近的已知测量是有意义的。

可以使用zoo包中的na.locf轻松实现前向填充:

## R
> rand.unemp[, impute.ff := na.locf(UNRATE, na.rm = FALSE)]
> bias.unemp[, impute.ff := na.locf(UNRATE, na.rm = FALSE)]
> 
> ## to plot a sample graph showing the flat portions
> unemp[350:400, plot (DATE, UNRATE,    
                          col = 1, lwd = 2, type = 'b')]
> rand.unemp[350:400, lines(DATE, impute.ff, 
                          col = 2, lwd = 2, lty = 2)]
> rand.unemp[350:400][rpt == TRUE, points(DATE, impute.ff, 
                          col = 2, pch = 6, cex = 2)]

这将导致一个看起来很自然的图形,除非您看到重复的数值以解决缺失数据,如在图 2-6 中所示。正如您在图中所注意到的,前向填充的数值通常不会偏离真实数值太远。

图 2-6. 原始时间序列用实线绘制,以及用虚线绘制的具有随机缺失点的前向填充值的时间序列。前向填充的值标记有向下指向的三角形。

我们还可以通过将系列的值相互绘制来比较这些系列的值。也就是说,对于每个时间步,我们将真实已知值与具有插值值的同一时间的值进行绘制。大多数值应完全匹配,因为大多数数据是存在的。我们在图 2-7 中看到这一点体现在 1:1 线上。我们还看到一些点散布在该线外,但它们似乎并没有系统偏离。

图 2-7. 绘制真实失业率与前向填充系列的图形。这张图表明,前向填充并没有系统地扭曲数据。

向后填充

正如您可以将过去的值带入以填补缺失数据一样,您也可以选择向后传播值。然而,这是一种预测,所以只有在您不打算从数据中预测未来,并且从领域知识来看,向后填充数据比向前填充更合理时,才应该这样做。

在某些情况下,前向填充是完成缺失数据的最佳方法,即使“更高级的方法”也是可能的。例如,在医疗环境中,缺失值通常表示医务人员认为不必重新测量值,可能是因为预期患者的测量值是正常的。在许多医疗情况下,这意味着我们可以应用前向填充到缺失值,使用最后已知的值,因为这是激励医务人员不重新测量的假设。

向前填充有许多优点:它计算上不那么要求,可以轻松应用于实时流数据,并且在插补方面表现出色。我们很快会看到一个例子。

移动平均

我们还可以使用滚动均值或中位数来填充数据。被称为移动平均,它类似于向前填充,因为您使用过去的值来“预测”缺失的未来值(插补可以是一种预测形式)。然而,使用移动平均时,您将使用多个最近过去时间的输入。

在许多情况下,移动平均数据插补比向前填充更适合任务。例如,如果数据嘈杂,并且你有理由怀疑任何个别数据点相对于整体平均值的价值,你应该使用移动平均而不是向前填充。向前填充可能会包括比你感兴趣的“真实”度量更多的随机噪声,而平均化可以去除部分噪声。

为了防止前瞻,仅使用发生在缺失数据点之前的数据。因此,您的实现可能会像这样:

## R
> ## rolling mean without a lookahead
> rand.unemp[, impute.rm.nolookahead := rollapply(c(NA, NA, UNRATE), 3,
>              function(x) {
>                          if (!is.na(x[3])) x[3] else mean(x, na.rm = TRUE)
>                          })]         
> bias.unemp[, impute.rm.nolookahead := rollapply(c(NA, NA, UNRATE), 3,
>              function(x) {
>                          if (!is.na(x[3])) x[3] else mean(x, na.rm = TRUE)
>                          })]        

我们将缺失数据的值设置为其之前值的平均值(因为我们以最终值为索引,并使用此值来确定其是否缺失以及如何替换它)。

提示

移动平均不一定是算术平均。例如,指数加权移动平均会更加重视近期数据而不是过去的数据。另外,几何平均在展现强序列相关性的时间序列和值随时间复合的情况下会有帮助。

在用移动平均插补缺失数据时,考虑您是否需要仅使用前瞻数据来了解移动平均值的值,或者是否愿意构建前瞻。如果您不关心前瞻,您的最佳估计将包括缺失数据之前和之后的点,因为这将最大化输入到您估计中的信息。在这种情况下,您可以实现一个滚动窗口,如使用zoo包的rollapply()功能所示:

## R
> ## rolling mean with a lookahead
> rand.unemp[, complete.rm := rollapply(c(NA, UNRATE, NA), 3,
>             function(x) {
>                         if (!is.na(x[2])) 
>                             x[2]                               
>                         else 
>                             mean(x, na.rm = TRUE)
>                         })]         

在可视化和记录应用程序中使用过去和未来信息对于数据预处理很有用,但正如前面提到的,如果您正在准备将数据馈送到预测模型中,则不合适。

前瞻移动平均和使用未来和过去数据计算的移动平均的结果显示在图 2-8 中。

图 2-8. 虚线显示了没有前瞻的移动平均插补,而虚线则显示了具有前瞻的移动平均插补。同样,方形显示了非前瞻插补点,而倒三角形显示了具有前瞻的移动平均。

滚动平均数据插补可以减少数据集的方差。这是在计算准确性、R²统计或其他误差指标时需要记住的事情。你的计算可能会高估模型的性能,这是构建时间序列模型时经常遇到的问题。

使用数据集的平均值来填补缺失数据

在横断面的情况下,常见的方法是通过在变量缺失的地方填入平均值或中位数来填补缺失数据。虽然这可以用于时间序列数据,但大多数情况下并不合适。知道数据集的平均值涉及看向未来……这就是预先看!

插值

插值是一种根据我们希望整体数据行为的几何约束来确定缺失数据点值的方法。例如,线性插值将缺失数据限制为与已知相邻点一致的线性拟合。

线性插值特别有用和有趣,因为它允许你利用你对系统随时间行为的了解。例如,如果你知道系统以线性方式行为,你可以将这种知识应用进去,这样只有线性趋势会用于填补缺失数据。在贝叶斯术语中,它允许你向你的插补注入先验

与移动平均类似,插值可以这样做,以便同时查看过去和未来的数据,或者只查看一个方向。通常的注意事项是:如果你接受这样做会导致预先看的影响,并且你确定这对你的任务不是问题,那么允许你的插值只有未来数据访问。

在这里,我们使用过去和未来的数据点进行插值(参见图 2-9):

## R
> ## linear interpolation
> rand.unemp[, impute.li := na.approx(UNRATE)]
> bias.unemp[, impute.li := na.approx(UNRATE)]
> 
> ## polynomial interpolation
> rand.unemp[, impute.sp := na.spline(UNRATE)]
> bias.unemp[, impute.sp := na.spline(UNRATE)]
> 
> use.idx = 90:120
> unemp[use.idx, plot(DATE, UNRATE, col = 1, type = 'b')]
> rand.unemp[use.idx, lines(DATE, impute.li, col = 2, lwd = 2, lty = 2)]
> rand.unemp[use.idx, lines(DATE, impute.sp, col = 3, lwd = 2, lty = 3)]

图 2-9. 虚线显示线性插值,而点线显示样条插值。

有许多情况适合使用线性(或样条)插值。考虑平均每周温度,其中根据一年中的时间,有升高或降低温度的已知趋势。或者考虑增长中的企业的年销售数据。如果趋势是每年业务量线性增长,基于该趋势填补缺失数据是合理的数据插补。换句话说,我们可以使用线性插值,这将考虑到趋势,而不是移动平均,它将不会。如果存在增值趋势,则移动平均将系统地低估缺失值。

也有很多情况不适合线性(或样条)插值。例如,如果您在天气数据集中缺少降水数据,您不应该在线性插值已知天数之间外推;我们都知道,降水并不是这样工作的。同样,如果我们看的是某人每天的睡眠小时数,但是缺少了几天的数据,我们不应该在线性外推睡眠小时数之间。例如,已知的端点之一可能包括熬夜学习后的 30 分钟小睡。这不太可能估算缺失的数据。

总体比较

现在我们已经进行了几种不同类型的插补,我们可以查看结果,比较这些不同的数据插补在这个数据集上的表现如何。

我们生成了两个具有缺失数据的数据集,一个是随机缺失数据,另一个是缺失了不利数据点(高失业率)。当我们比较我们采用的方法以确定哪种方法产生了最佳结果时,我们可以看到均方误差可能会有很大的百分比差异:

## R
> sort(rand.unemp[ , lapply(.SD, function(x) mean((x - unemp$UNRATE)²,
>												  na.rm = TRUE)),
>          .SDcols = c("impute.ff", "impute.rm.lookahead", 
>		 			                "impute.rm.nolookahead", "impute.li", 
>                      "impute.sp")])
impute.li   impute.rm.lookahead   impute.sp   impute.ff   impute.rm.nolookahead
0.0017      0.0019       		  0.0021      0.0056      0.0080

> sort(bias.unemp[ , lapply(.SD, function(x) mean((x - unemp$UNRATE)²,
> 												  na.rm = TRUE)),
>          .SDcols = c("impute.ff", "impute.rm.lookahead", 
>					                 "impute.rm.nolookahead", "impute.li", 
>                      "impute.sp")])
impute.sp    impute.li   impute.rm.lookahead  impute.rm.nolookahead   impute.ff
0.0012       0.0013      0.0017               0.0030                  0.0052

请记住,前述的许多方法都包含了前瞻性。唯一不包含前瞻性的方法是前向填充和没有前瞻性的移动平均(也有带有前瞻性的移动平均)。因此,不足为奇的是在错误方面存在一系列差异,并且没有前瞻性的方法表现不如其他方法。

最终备注

在这里,我们涵盖了时间序列应用中最简单和最常用的缺失数据插补方法。数据插补仍然是数据科学研究的一个重要领域。你做出的决策越重要,就越需要仔细考虑数据缺失的潜在原因及其修正可能带来的潜在影响。以下是你应该记住的一些谨慎提示:

  • 无法证明数据真的是随机缺失,而且在大多数真实世界的情况下,缺失并不真的是随机的,这也不足为奇。

  • 有时测量数据缺失的概率可以通过您已经测量的变量来解释,但有时则不然。具有许多特征的广泛数据集是调查缺失数据模式可能解释的最佳方式,但这并不是时间序列分析的常规方法。

  • 当您需要了解将缺失数据插入值引入的不确定性时,您应该运行各种场景,并尽可能与参与数据收集过程的人交流。

  • 您应该考虑如何处理缺失数据以适应后续数据使用。您必须小心防止前瞻性,或者决定前瞻性将如何严重影响您后续工作的有效性。

上采样和下采样

常常来自不同来源的相关时间序列数据将不具有相同的采样频率。这是你可能希望改变数据采样频率的众多原因之一。当然,你不能改变信息实际被测量的速率,但你可以改变数据收集中时间戳的频率。这被称为上采样下采样,分别用于增加或减少时间戳的频率。

我们在“从一组表中重新调整时间序列数据收集”中对时间数据进行了降采样。在这里,我们更加通用地讨论了降采样和上采样的如何和为什么。

注意

降采样是将数据子集化,使时间戳的频率低于原始时间序列。上采样则是将数据表示为如果它被更频繁地收集的情况。

降采样

任何时候,当你降低数据的频率,你就在进行降采样。这通常发生在以下情况下。

数据的原始分辨率不合理

原始数据粒度不合理的原因很多。例如,你可能测量得太频繁了。假设你有一个数据集,其中某人每秒测量一次室外空气温度。常识告诉我们,这种测量过于频繁,相对于额外的数据存储和处理负担,可能提供的新信息非常有限。事实上,测量误差可能与秒与秒之间的空气温度变化一样大。因此,你可能不希望存储这种过多且无信息量的数据。在这种情况下——也就是对定期采样的数据来说——降采样就像是选择每 n 个元素一样简单。

关注季节周期的特定部分

在时间序列中不必担心季节数据,你可以选择创建一个只关注一个季节的子系列。例如,我们可以应用降采样来创建一个子系列,就像在这种情况下,我们从原始的月度时间序列中生成了一组一月份的测量值。在这个过程中,我们将数据降低到了年度频率。

## R
> unemp[seq.int(from = 1, to = nrow(unemp), by = 12)]
DATE         UNRATE
1948-01-01    3.4
1949-01-01    4.3
1950-01-01    6.5
1951-01-01    3.7
1952-01-01    3.2
1953-01-01    2.9
1954-01-01    4.9
1955-01-01    4.9
1956-01-01    4.0
1957-01-01    4.2

与低频率数据进行匹配

你可能希望降低数据的频率,以便与其他低频数据匹配。在这种情况下,你可能希望对数据进行聚合或降采样,而不仅仅是丢弃数据点。这可以是简单的平均值或总和,也可以是更复杂的加权平均,后续值具有更高的权重。我们之前在捐赠数据中看到,将一周内的所有捐款总额相加的想法可能更加有趣。

相比之下,对于我们的经济数据,最有可能感兴趣的是年均值。我们使用均值而不是滚动均值,因为我们想要总结一年的情况,而不是获取该年的最新值,强调的是最近性。我们通过将日期格式化为字符串,将其年份作为例子来分组,展示如何可以创造性地利用类似 SQL 的操作进行时间序列功能:

## R
> unemp[, mean(UNRATE), by = format(DATE, "%Y")]
format   V1
1948    3.75
1949    6.05
1950    5.21
1951    3.28
1952    3.03
1953    2.93
1954    5.59
1955    4.37
1956    4.13
1957    4.30

上采样

上采样并不简单等同于下采样的逆过程。下采样在现实世界中是有意义的;决定更少频繁地进行测量是简单的。相比之下,上采样可能就像试图白嫖一样——也就是说,不进行实时测量却希望从不频繁的测量中获取高分辨率数据。引用流行的R时间序列包XTS的作者的话:

不可能将低周期性的系列转换为高周期性的系列 - 例如,从每周到每日或每日到 5 分钟柱,因为那需要魔法。

然而,有合理的理由希望以比其默认频率更高的频率标记数据。在这样做时,你需要记住数据的限制。请记住,你只是增加了更多的时间标签,而不是增加了更多信息。

让我们讨论一些上采样有意义的情况。

不规则时间序列

上采样的一个非常常见的原因是你有一个不规则采样的时间序列,你希望将其转换为定期的时间序列。这是一种上采样,因为你将所有数据转换为一个频率,这个频率可能高于数据之间滞后所指示的频率。如果出于这个原因进行上采样,你已经知道如何通过滚动连接来实现,就像我们在填补缺失的经济数据时所做的那样,通过 R:

## R
> all.dates <- seq(from = unemp$DATE[1], to = tail(unemp$DATE, 1),
>                  by = "months")
> rand.unemp = rand.unemp[J(all.dates), roll=0]

不同频率下的输入采样

有时,你仅仅需要上采样低频信息,以便将其与高频信息一起在需要输入对齐和同时采样的模型中传递。你必须对前瞻性保持警惕,但如果我们假设已知状态在出现新的已知状态之前是真实的,我们可以安全地上采样并传递我们的数据。例如,假设我们知道(相对地)大多数新的工作岗位都是在每月的第一天开始的。我们可能会决定使用给定月份的失业率来代表整个月份(不考虑它是前瞻性,因为我们假设失业率在整个月份内保持稳定)。

## R
> daily.unemployment = unemp[J(all.dates), roll = 31]
> daily.unemployment
   DATE      UNRATE
1948-01-01    3.4
1948-01-02    3.4
1948-01-03    3.4
1948-01-04    3.4
1948-01-05    3.4

时间序列动态的知识

如果你对变量通常的时间行为有基础知识,你也可能能够将上采样问题视为缺失数据问题。在这种情况下,我们已经讨论过的所有技术仍然适用。插值是产生新数据点的最有可能的方法,但你需要确保你的系统动态能够证明你的插值决策是合理的。

正如前面讨论的,即使在最干净的数据集中,上采样和下采样也会经常发生,因为你几乎总是希望比较不同时间尺度的变量。还应该指出,Pandas 具有特别方便的上采样和下采样功能,使用resample方法。

平滑数据

数据平滑可以出于各种原因进行,尤其是在进行数据可视化之前,常常会对真实世界的时间序列数据进行平滑,以便讲述数据背后的可理解故事。在本节中,我们进一步讨论为什么进行平滑以及最常见的时间序列平滑技术:指数平滑。

平滑的目的

尽管异常值检测是一个独立的主题,如果你有理由认为数据应该平滑,你可以通过移动平均值来消除测量的突变,测量误差或两者都有的情况。即使这些突变是准确的,它们可能也不反映底层过程,并且可能更多地是仪器问题的问题;这就是为什么平滑数据是相当常见的。

平滑数据与填充缺失数据密切相关,因此这里也涉及到一些技术。例如,你可以通过应用滚动均值来平滑数据,无论是否有前瞻,因为这只是计算其平滑值时所用窗口的点相对位置的问题。

当你在平滑数据时,你需要考虑一些问题:

  • 为什么要平滑?平滑可以达到多种目的:

    数据准备

    你的原始数据是否不适用?例如,你可能知道非常高的值不太可能或不符合物理规律,但你需要一种有原则的方法来处理它们。平滑是最直接的解决方案。

    特征生成

    从数据中取样,无论是关于一个人、图像或其他任何内容的许多特征,然后用几个度量标准对其进行总结。通过这种方式,一个更全面的样本被折叠成几个维度或减少到几个特征。特征生成对机器学习尤为重要。

    预测

    对某些类型的过程来说,最简单的预测形式是均值回归,你可以通过从平滑特征进行预测来获得这种形式。

    可视化

    你想要在看似杂乱的散点图中增加一些信号吗?如果是这样,你这样做的意图是什么?

  • 平滑或不平滑将如何影响你的结果?

    • 你的模型是否假设数据是嘈杂和不相关的,从而使你的平滑可能会影响这种假设?

    • 在实时生产模型中是否需要平滑?如果是的话,您需要选择一种不使用前瞻的平滑方法。

    • 您是否有一个原则性的平滑方法,或者您将只进行超参数网格搜索?如果是后者,您将如何确保使用时间感知的交叉验证形式,以确保未来的数据不会向过去泄漏?

指数平滑

平滑时,通常不会将所有时间点等同看待。特别是,您可能希望将最近的数据视为信息更多的数据,这种情况下指数平滑是一个不错的选择。与我们之前讨论过的移动平均相比——在那种情况下,每个数据缺失的点可以被补充为其周围点的平均值——指数平滑更加关注时间性,将更近期的点权重更高,而不太近期的点权重则指数级减少(因此得名)。

指数平滑的工作原理如下。对于给定的时间段 t,通过计算来找到系列的平滑值:

时间 t 的平滑值 = S t = d × ; S t-1 + ( 1 d ) × x t

考虑这如何随时间传播。时间 (t – 1) 的平滑值本身就是同样东西的产物:

S t-1 = d × S t-2 + ( 1 d ) × x t-1

因此,我们可以看到在时间 t 的平滑值有一个更复杂的表达式:

d × ( d × S t2 + ( 1 d ) × x t-1 ) + ( 1 - d ) × x t

数学倾向的读者会注意到我们有以下形式的系列:

d 3 × x t-3 + d 2 × x t-2 + d × x t-1

实际上,正是因为这种形式,指数移动平均相当容易处理。更多详细信息可以在在线和教科书中广泛找到;参见我的最喜爱的摘要在“更多资源”中。

我将在 Python 中进行平滑演示,因为 Pandas 包含多种平滑选项。平滑选项在 R 中也广泛可用,包括基础 R,以及许多时间序列包中。

虽然我们一直在看美国失业率数据,我们将转向另一个常用的数据集:航空公司乘客数据集(追溯到 Box 和 Jenkins 的著名时间序列书籍并广泛可用)。原始数据集记录了按月分解的数千名航空公司乘客:

## python
>>> air
       Date  Passengers    
0    1949-01       112  
1    1949-02       118  
2    1949-03       132  
3    1949-04       129 
4    1949-05       121 
5    1949-06       135  
6    1949-07       148  
7    1949-08       148  
8    1949-09       136  
9    1949-10       119  
10   1949-11       104 

我们可以使用各种衰减因子以及应用 Pandas 的 ewma() 函数轻松地平滑乘客的值,如下所示:

## python
>>> air['Smooth.5'] = pd.ewma(air, alpha = .5).Passengers
>>> air['Smooth.9'] = pd.ewma(air, alpha = .9).Passengers

正如我们所见,alpha 参数的水平,也称为平滑系数,影响值更新到其当前值与保留现有平均值信息的程度。alpha 值越高,值就越快地更新到其当前价格。Pandas 接受多个参数,所有这些参数都插入相同的方程,但它们提供了多种方式来思考如何指定指数移动平均数。³

## python
>>> air
       Date  Passengers    Smooth.5  Smooth.9
0    1949-01       112  112.000000  112.000000
1    1949-02       118  116.000000  117.454545
2    1949-03       132  125.142857  130.558559
3    1949-04       129  127.200000  129.155716
4    1949-05       121  124.000000  121.815498
5    1949-06       135  129.587302  133.681562
6    1949-07       148  138.866142  146.568157
7    1949-08       148  143.450980  147.856816
8    1949-09       136  139.718200  137.185682
9    1949-10       119  129.348974  120.818568
10   1949-11       104  116.668295  105.681857

然而,简单的指数平滑在具有长期趋势的数据(用于预测)中表现不佳。霍尔特方法和霍尔特-温特斯平滑是两种应用于具有趋势或趋势和季节性数据的指数平滑方法。

还有许多广泛使用的平滑技术。例如,卡尔曼滤波器通过将时间序列过程建模为已知动态和测量误差的组合来平滑数据。LOESS(局部估计散点平滑)是一种非参数方法,用于局部平滑数据。这些方法及其他方法逐渐提供了更复杂的理解平滑的方式,但计算成本也随之增加。需要注意的是,卡尔曼滤波和 LOESS 都包含了早期和晚期的数据,因此如果使用这些方法,要注意信息向时间向后的泄露,以及它们通常不适合用于准备用于预测应用的数据。

平滑是一种常用的预测形式,当你测试更复杂的方法是否实际上产生成功的预测时,可以使用平滑后的时间序列(无前瞻)作为一种简单的空模型。

季节性数据

数据中的季节性是任何一种频率稳定的重复行为。它可以在同一时间发生多种不同的频率。例如,人类行为倾向于具有每日季节性(每天固定时间吃午餐),每周季节性(星期一类似于其他星期一)和每年季节性(元旦交通量较低)。物理系统也展示了季节性,比如地球绕太阳公转的周期。

辨别和处理季节性是建模过程的一部分。另一方面,它也是一种数据清理的形式,例如经济上重要的美国就业报告。事实上,许多政府统计数据,特别是经济数据,在发布时都会进行非季节性调整。

要了解季节性数据平滑能做什么,我们回到经典的航空公司乘客计数数据集。通过绘图可以快速显示这是高度季节性的数据,但前提是你制作正确的图表。

注意使用 R 的默认图(使用点; 参见图 2-10)与添加参数来指示你想要一条线的区别(参见图 2-11)。

图 2-10. 从散点图中明显可见数据均值和方差的增加,但我们看不到明显的季节性趋势。

图 2-11. 线图清楚地显示了季节性。

如果您仅查看默认的 R 图表,可能会忽略数据的季节性特征。希望这种情况不会持续太久,毫无疑问,您还会做其他探索数据的工作,也许是使用自相关图(在第三章中讨论)或其他诊断工具。

人类是习惯的动物。

人类行为数据几乎总是具有某种形式的季节性,即使有几个周期(每小时的模式,每周的模式,夏冬季的模式等)。

散点图确实比线图更清楚地显示了一些信息。我们的数据的方差正在增加,均值也在增加,当我们看到数据点以锥形向外散布时,这一点尤为明显,倾向于向上。这些数据显然具有增长趋势,因此我们可能会对其进行对数转换或差分处理,具体取决于我们模型的需求。这些数据也显然具有增加方差的趋势。在建模章节中,我们将更详细地讨论特定于模型的季节性数据转换,所以这里不再详细说明。

除了线图中关于季节性的证据外,我们还获得了有用的信息。我们了解到季节性的种类。也就是说,我们看到数据不仅仅是季节性的,而且是以乘法方式的季节性。随着总体值的增加,季节波动也增加(可以将这视为从峰值到低谷的大小变化)。

我们可以像这样轻松地将数据分解为其季节性、趋势和剩余部分,只需一行 R 代码即可显示。

## R
> plot(stl(AirPassengers, "periodic"))

根据原始数据,得出的图表看起来非常合理(参见图 2-12)。我们可以想象将季节性、趋势和剩余数据重新组合以获得原始序列。我们还可以看到,这种特定的分解没有考虑到这一系列显示的是乘法季节性而不是加法季节性,因为残差在时间序列的开始和结束时最大。看起来,这种分解将平均季节变异性作为季节性分量的变异性。

图 2-12. 将原始时间序列分解为季节性组成部分、趋势和残差。请注意每个图表的 y 轴,它们非常不同。请注意,这是每个图表右侧灰色条的原因。这些灰色条的绝对大小相同(以 y 轴单位表示),因此它们的相对不同显示是对不同组成部分不同 y 轴比例的视觉提醒。

要初步了解其工作原理,我们可以查看官方的 R 文档:

季节性分量是通过 LOESS 平滑季节子序列(所有一月值的系列...)找到的。如果s.window = “periodic”,平滑实际上被取均值替代。季节性值被移除,余下的被平滑以找到趋势。总体水平从季节分量中移除并加到趋势分量中。这个过程迭代几次。余数分量是季节加趋势拟合的残差。

早先引入的 LOESS 方法是一种计算量大的平滑数据点的方法,它涉及使用移动窗口根据其邻近点估计每个点的平滑值(我希望你的前瞻警报正在响起!)。

时区

时区本质上是乏味、痛苦且难以正确处理,即使花费大量精力也是如此。这就是为什么你绝不应该使用自己的解决方案。时区自诞生以来就非常复杂,随着个人计算机的出现变得更加复杂。其中有许多原因:

  • 时区受政治和社会决策的影响。

  • 没有标准的方式在不同语言之间或通过 HTTP 协议传输时区信息。

  • 没有单一的协议来命名时区或确定夏令时偏移的开始和结束日期。

  • 因夏令时的存在,某些时区一年中会出现两次特定的时间!

大多数语言依赖于底层操作系统获取时区信息。不幸的是,Python 内置的自动时间检索函数datetime.datetime.now()不返回带时区信息的时间戳。部分原因在于设计上的考虑。标准库中的一些决定包括禁止在datetime模块中包含时区信息(因为这些信息经常更改),允许datetime对象既带有时区信息又不带有时区信息。然而,将带有时区和不带有时区的对象进行比较将引发TypeError

一些博主声称,多数库都假定tzinfo==None。虽然这一说法难以证实,但与大多数经验一致。人们也报告难以 pickle 带有时区标记的对象,因此如果你打算使用 pickling,这也是一个早期需要检查的事项。⁴

让我们看看在 Python 中使用时区的方法。你可能会使用的主要库包括datetimepytzdateutil。此外,Pandas 提供了基于后两个库的便捷时区相关功能。

接下来我们将介绍最重要的时区功能。

首先注意,当你从datetime模块检索“现在”时,它并不包含时区信息,尽管它会给出适合你所在时区的正确时间。例如,注意now()utcnow()的响应差异:

## python
>>> datetime.datetime.utcnow()
datetime.datetime(2018, 5, 31, 14, 49, 43, 187680)

>>> datetime.datetime.now()
datetime.datetime(2018, 5, 31, 10, 49, 59, 984947)
>>> # as we can see, my computer does not return UTC 
>>> # even though there is no time zone attached

>>> datetime.datetime.now(datetime.timezone.utc)
datetime.datetime(2018, 5, 31, 14, 51, 35, 601355, 
                  tzinfo=datetime.timezone.utc)

注意,如果我们确实传入一个时区,我们将得到正确的信息,但这不是默认行为。在 Python 中处理时区时,我们创建一个时区对象,例如美国太平洋时间的western时区:

## python
>>> western = pytz.timezone('US/Pacific')
>>> western.zone
'US/Pacific'

然后我们可以使用这些对象来localize一个时区,如下所示:

## python
>>> ## the API supports two ways of building a time zone–aware time, 
>>> ## either via 'localize' or to convert a time zone from one locale 
>>> ## to another
>>> # here we localize
>>> loc_dt = western.localize(datetime.datetime(2018, 5, 15, 12, 34, 0))
datetime.datetime(2018, 5, 15, 12, 34, 
            tzinfo=<DstTzInfo 'US/Pacific' PDT-1 day, 17:00:00 DST>)

请注意,然而,直接将时区传递给datetime构造函数通常不会产生我们期望的结果:

## python
>>> london_tz = pytz.timezone('Europe/London')
>>> london_dt = loc_dt.astimezone(london_tz)

>>> london_dt
datetime.datetime(2018, 5, 15, 20, 34, 
               tzinfo=<DstTzInfo 'Europe/London' BST+1:00:00 DST>)

>>> f = '%Y-%m-%d %H:%M:%S %Z%z'
>>> datetime.datetime(2018, 5, 12, 12, 15, 0, 
                       tzinfo = london_tz).strftime(f)
'2018-05-12 12:15:00 LMT-0001'

>>> ## as highlighted in the pytz documentation using the tzinfo of
>>> ## the datetime.datetime initializer does not always lead to the 
>>> ## desired outcome, such as with the London example

>>> ## according to the pytz documentation, this method does lead to 
>>> ## the desired results in time zones without daylight savings

这一点非常重要,比如在计算时间差时。以下三个示例中的第一个是要注意的地方:

## python
>>> # generally you want to store data in UTC and convert only when 
>>> # generating human-readable output
>>> # you can also do date arithmetic with time zones
>>> event1 = datetime.datetime(2018, 5, 12, 12, 15, 0, 
                               tzinfo = london_tz)
>>> event2 = datetime.datetime(2018, 5, 13, 9, 15, 0, 
                               tzinfo = western)
>>> event2 - event1
>>> ## this will yield the wrong time delta because the time zones 
>>> ## haven't been labelled properly

>>> event1 = london_tz.localize(
                 datetime.datetime(2018, 5, 12, 12, 15, 0))
>>> event2 = western.localize(
                 datetime.datetime(2018, 5, 13, 9, 15, 0))
>>> event2 - event1

>>> event1 = london_tz.localize(
               (datetime.datetime(2018, 5, 12, 12, 15, 0))).
                  astimezone(datetime.timezone.utc)
>>> event2 = western.localize(
               datetime.datetime(2018, 5, 13, 9, 15, 0)).
                  astimezone(datetime.timezone.utc)
>>> event2 - event1

pytz提供了常见时区和按国家划分的时区列表,这两者都可以作为方便的参考:

## python
## have a look at pytz.common_timezones
>>> pytz.common_timezones
(long output...)

## or country specific
>>> pytz.country_timezones('RU')
['Europe/Kaliningrad', 'Europe/Moscow', 'Europe/Simferopol', 
'Europe/Volgograd', 'Europe/Kirov', 'Europe/Astrakhan', 
'Europe/Saratov', 'Europe/Ulyanovsk', 'Europe/Samara', 
'Asia/Yekaterinburg', 'Asia/Omsk', 'Asia/Novosibirsk', 
'Asia/Barnaul', 'Asia/Tomsk', 'Asia/Novokuznetsk', 
'Asia/Krasnoyarsk', 'Asia/Irkutsk', 'Asia/Chita', 
'Asia/Yakutsk', 'Asia/Khandyga', 'Asia/Vladivostok', 
'Asia/Ust-Nera', 'Asia/Magadan', 'Asia/Sakhalin', 
'Asia/Srednekolymsk', 'Asia/Kamchatka', 'Asia/Anadyr']
>>>
>>> pytz.country_timezones('fr')
>>> ['Europe/Paris']

特别棘手的问题是夏令时问题。某些人类可读的时间存在两次(在秋季落后),而其他时间根本不存在(在春季提前跳过):

## python
>>> ## time zones
>>> ambig_time = western.localize(
                    datetime.datetime(2002, 10, 27, 1, 30, 00)).
                       astimezone(datetime.timezone.utc)
>>> ambig_time_earlier = ambig_time - datetime.timedelta(hours=1)
>>> ambig_time_later = ambig_time + datetime.timedelta(hours=1)
>>> ambig_time_earlier.astimezone(western)
>>> ambig_time.astimezone(western)
>>> ambig_time_later.astimezone(western)

>>> #results in this output
datetime.datetime(2002, 10, 27, 1, 30, 
            tzinfo=<DstTzInfo 'US/Pacific' PDT-1 day, 17:00:00 DST>)
datetime.datetime(2002, 10, 27, 1, 30, 
            tzinfo=<DstTzInfo 'US/Pacific' PST-1 day, 16:00:00 STD>)
datetime.datetime(2002, 10, 27, 2, 30, 
            tzinfo=<DstTzInfo 'US/Pacific' PST-1 day, 16:00:00 STD>)
>>> # notice that the last two timestamps are identical, no good!

>>> ## in this case you need to use is_dst to indicate whether daylight 
>>> ## savings is in effect
>>> ambig_time = western.localize(
        datetime.datetime(2002, 10, 27, 1, 30, 00), is_dst = True).
          astimezone(datetime.timezone.utc)
>>> ambig_time_earlier = ambig_time - datetime.timedelta(hours=1)
>>> ambig_time_later = ambig_time + datetime.timedelta(hours=1)
>>> ambig_time_earlier.astimezone(western)
>>> ambig_time.astimezone(western)
>>> ambig_time_later.astimezone(western)

datetime.datetime(2002, 10, 27, 0, 30, 
           tzinfo=<DstTzInfo 'US/Pacific' PDT-1 day, 17:00:00 DST>)
datetime.datetime(2002, 10, 27, 1, 30, 
           tzinfo=<DstTzInfo 'US/Pacific' PDT-1 day, 17:00:00 DST>)
datetime.datetime(2002, 10, 27, 1, 30, 
           tzinfo=<DstTzInfo 'US/Pacific' PST-1 day, 16:00:00 STD>)
## notice that now we don't have the same time happening twice. 
## it may appear that way until you check the offset from UTC

对于你的工作来说,时区问题可能并不重要,因此这些知识的实用性取决于你数据的性质。当然,在某些情况下,如果出错可能会有灾难性后果(比如,在时区变更期间飞行的商业航班生成天气预报,突然发现它们的位置发生了 drastical 改变)。

防止前瞻性

前瞻性在建模流水线中特别容易引入,特别是在使用 R 和 Python 提供的向量化功能数据操作界面时。很容易将变量向错误的方向移动、移动得比预期的多或少,或者以其他方式发现自己得到的数据并非完全“诚实”,因为在你的系统中,你可能在计划拥有数据之前就有了数据。

不幸的是,对于前瞻性,并没有一个明确的统计诊断—毕竟,时间序列分析的整个尝试就是对未知数进行建模。除非系统在某种程度上是确定性的,具有已知的动力学规律,否则很难区分非常好的模型和具有前瞻性的模型—也就是说,直到你将一个模型投入生产,并意识到你计划拥有它的数据时,你是否丢失了数据,或者简单地说,在生产中得到的结果并不反映你在训练过程中看到的情况。

防止这种尴尬的最佳方法是保持持续警惕。每当你进行数据的时间转移、平滑处理、填补数据或者上采样时,都要问自己在某个特定时间点是否能知道一些信息。记住,这不仅仅包括日历时间,还包括真实的时间滞后,以反映某件事情发生后到你的组织获得相关数据之间的延迟时间。例如,如果你的组织仅每周从 Twitter 上获取情感分析数据,你需要在训练和验证数据的分割中包括这种每周周期性。同样,如果你只能每月重新训练你的模型一次,你需要确定随着时间推移哪种模型适用于哪些数据。例如,你不能仅仅为七月份训练一个模型,然后将其应用于七月份进行测试,因为在实际情况下,如果训练需要很长时间,你将来不及准备好那个模型。

这里还有一些其他的想法,可以作为一个通用的检查清单。在规划构建模型时和事后审计过程时,都要记住这些想法:

  • 如果你在平滑数据或填充缺失数据,要仔细考虑是否可能通过引入一个前瞻来影响你的结果。不仅要考虑这个问题,还要像之前进行实验一样,看看填补和平滑的效果如何。它们是否看起来是向前看的?如果是,你能否证明使用它们是合理的?(可能不是。)

  • 用一个非常小的数据集(只有一个data.table中的几行或者任何数据格式中的几行时间步长)构建你的整个过程。然后,在每个步骤中进行随机抽查,看看是否意外地将任何信息在时间上移动到不适当的位置。

  • 对于每一种数据,找出相对于其自身时间戳的滞后时间。例如,如果时间戳是数据“发生”的时间,而不是上传到服务器的时间,你需要知道这一点。数据帧的不同列可能具有不同的滞后时间。为了解决这个问题,你可以根据数据帧自定义滞后时间,或者(更好且更现实的方法)选择最大的滞后时间并应用于所有数据。虽然你不希望过度悲观地影响你的模型,但这是一个很好的起点,之后你可以逐步放松这些过于约束的规则,小心地进行一项一项的调整!

  • 使用时间感知的错误(滚动)测试或交叉验证。这将在第十一章中讨论,但要记住,对于时间序列数据,随机化训练与测试数据集是行不通的。你不希望未来的信息泄漏到过去的模型中。

  • 有意引入先行查看,并查看您的模型行为。尝试不同程度的先行查看,这样您就有了模型准确性如何变化的概念。如果您有一些先行查看的准确性概念,您就知道没有未来不公平知识的真实模型的上限将会如何。请记住,许多时间序列问题非常困难,因此一个具有先行查看的模型可能看起来很棒,直到您意识到您正在处理的是高噪声/低信号数据集。

  • 缓慢添加功能,特别是您可能正在处理的功能,以便您可以查找跳跃。先行查看的一个标志是当某个特定功能表现出乎意料的好,并且没有很好的解释。在您的解释列表的顶部,应该始终是“先行查看”。

注意

处理和清理与时间相关的数据可能是一个繁琐而细致的过程。

在数据清理和处理中引入先行查看是非常危险的!只有当它们是有意的时候,才应该使用先行查看,而这很少是适当的。

更多资源

  • 关于缺失数据:

    Steffen Moritz 等,《比较 R 中单变量时间序列插补的不同方法》,未发表的研究论文,2015 年 10 月 13 日,链接https://perma.cc/M4LJ-2DFB。

    这份详尽的 2015 年总结概述了在单变量时间序列数据情况下输入时间序列数据的可用方法。单变量时间序列数据是一个特殊的挑战,因为许多先进的缺失数据插补方法依赖于观察协变量之间的分布,而在单变量时间序列情况下这种选择是不可用的。本文总结了各种 R 包的可用性和性能,以及这些方法在各种数据集上的实证结果。

    James Honaker 和 Gary King,《关于时间序列横截面数据中缺失值的处理方法》,《美国政治科学杂志》54 卷,2 期(2010 年):561-81,链接https://perma.cc/8ZLG-SMSX。

    本文探讨了在宽面板协变量时间序列中缺失数据的最佳实践。

    Léo Belzile,《不规则时间序列和缺失值笔记》,未标明日期,链接https://perma.cc/8LHP-92FP。

    作者提供了处理不规则数据作为缺失数据问题的示例,以及一些常用 R 包的概述。

  • 关于时区:

    Tom Scott,《时区和时间问题》,Computerphile 视频,2013 年 12 月 30 日,链接https://oreil.ly/iKHkp。

    这段时长 10 分钟、观看量超过 150 万的 YouTube 视频详细描述了处理时区的危险和挑战,特别是在 Web 应用程序的背景下。

    Wikipedia,《时区》,链接https://perma.cc/J6PB-232C。

    这是一个简要而迷人的历史,揭示了在上个世纪之前时间的记录方式以及从铁路开始的技术进步如何导致不同地点的人们需要协调他们的时间。还有一些有趣的时区地图。

    Declan Butler,“GPS 故障威胁数千台科学仪器”,《自然》杂志,2019 年 4 月 3 日,https://perma.cc/RPT6-AQBC.

    本文与时区无直接关系,而是涉及时间戳问题的更普遍问题。它描述了一个最近的问题,即美国全球定位系统中的一个错误可能导致时间戳数据问题,因为它传输了一个从 1980 年 1 月 6 日开始计数的二进制 10 位“周数”。该系统总共只能涵盖 1024 周(2 的 10 次方)。这个计数在 2019 年 4 月第二次达到。未设计以应对此限制的设备将会重置为零,并错误地为科学和工业数据标记时间戳。本文描述了一些关于时间限制与科学设备不足以预见这一问题的困难。

  • 关于平滑和季节性:

    Rob J. Hyndman 和 George Athanasopoulos,“指数平滑”, 收录于《预测:原理与实践》,第 2 版(墨尔本:OTexts,2018),https://perma.cc/UX4K-2V5N.

    Hyndman 和 Athanasopoulos 的介绍性学术教材中的这一章节,涵盖了时间序列数据的指数平滑方法,包括指数平滑的分类以及扩展到预测应用的方法。

    David Owen,“启动指数移动平均的正确方式”, Forward Motion 博客,2017 年 1 月 31 日,https://perma.cc/ZPJ4-DJJK.

    正如前面的一个侧记所强调的,指数移动平均的概念简单易懂,计算起来也很容易,但是如何“启动”计算则稍显复杂。我们希望确保我们的移动平均能够根据记录信息的时间长短来适应新信息的更新。如果我们在考虑这一点时启动移动平均,即使是一个新的移动平均也会像具有无限回溯的平均值一样行事,从而在应对新信息时不公平地进行折价。有关更多详细信息和计算解决方案,请参阅本博客文章。

    Avner Abrami,Aleksandr Arovkin 和 Younghun Kim,“使用指数平滑单元的时间序列”, 未发表研究论文,最后修订于 2017 年 9 月 29 日,https://perma.cc/2JRX-K2JZ.

    这是一篇非常易于理解的时间序列研究论文,详细阐述了简单指数平滑的概念,以开发“指数平滑单元”。

  • 关于函数数据分析:

    简-王,J.-M. Chiou,和 H.-G. Müller,《功能数据分析综述》,《统计学及其应用年度评论》,2015 年,https://perma.cc/3DNT-J9EZ。

    本统计文章提供了一种易于理解的数学概述,介绍了重要的功能数据分析技术。文章中还展示了一些有用的可视化技术。

    Shahid Ullah 和 Caroline F. Finch,《功能数据分析应用:系统综述》,《BMC 医学研究方法学》,第 13 卷,第 43 期(2013 年),https://perma.cc/VGK5-ZEUX。

    本评述采用跨学科的方法,调查了最近发表的使用功能数据分析进行的分析。作者认为功能数据分析技术具有比目前认知更广泛的适用性,并提出生物和健康科学可以通过更多地使用这些技术来查看医学时间序列数据的情况。

¹ 要了解更多关于这种数据分析的信息,还可以参考

² SQL 数据库是使用基于表的数据存储机制的传统数据库。例如,如果您想在 SQL 数据库中存储客户交易记录,您可能会有一个包含客户信息(包括唯一标识符)的表,以及另一个包含交易记录的表,每个交易记录都包含其中一个唯一的客户标识符。

³ 您可以根据自己的喜好指定阿尔法平滑因子、半衰期、跨度或质心。详情请参阅文档

⁴ Pickling 是将 Python 对象以字节格式存储的过程。这通过流行的内置 pickle 模块完成。大多数情况下,泡菜工作得很好,但有时与时间相关的对象可能难以泡制。

第三章:时间序列的探索性数据分析

我们将把我们对时间序列探索性数据分析的讨论分解为两个不同的部分。首先,我们讨论如何在时间序列上应用常用的数据方法。特别是,我们讨论如何将直方图、绘图和分组操作应用于时间序列数据。

其次,我们强调用于时间序列分析的基本时间方法——即专门针对时间序列数据开发的方法,只在具有时间关系的数据点之间的上下文中才有意义(而不是横截面的)。

熟悉的方法

我们首先考虑如何将常用的数据探索技术应用于时间序列数据集。这个过程与您在非时间序列数据上执行的过程相同。

您需要了解可用的列、它们的值范围以及哪些逻辑单位的测量效果最佳。您需要解答与任何新数据集相关的探索性问题,比如:

  • 列之间是否存在强相关性?

  • 您感兴趣的变量的整体平均值是多少?它的方差是多少?

要回答这些问题,您可以使用熟悉的技术,如绘图、汇总统计、应用直方图和使用有针对性的散点图。您还需要明确回答与时间相关的问题,比如:

  • 您看到的值的范围是多少,它们是否按时间段或其他逻辑单位变化?

  • 数据看起来是否一致且均匀测量,或者是否暗示随时间的变化或行为变化?

要解决这些问题,您需要之前提到的方法——直方图、散点图和汇总统计——以考虑时间轴。为了实现这种行为,我们将时间纳入到我们的统计中,作为图表中的轴或作为直方图或散点图中的分组操作中的一组。

在本节的其余部分,我们将通过几种不同类型的时间序列数据的探索性数据分析示例来探讨如何直接使用传统的非时间序列方法或带有时间特定修改的方法。为了演示这些探索方法,我们将使用 R 语言中提供的欧洲股票市场数据,这是一个时间序列数据集。

绘图

让我们探索 R 提供的典型时间序列,EuStockMarkets 数据集。为了对其有所了解,我们可以查看数据集的开头:

## R
> head(EuStockMarkets)
        DAX   SMI    CAC    FTSE
[1,] 1628.75 1678.1 1772.8 2443.6
[2,] 1613.63 1688.5 1750.5 2460.2
[3,] 1606.51 1678.6 1718.0 2448.2
[4,] 1621.04 1684.1 1708.1 2470.4
[5,] 1618.16 1686.6 1723.1 2484.7
[6,] 1610.61 1671.6 1714.3 2466.8

这是 R 语言中的内置数据集,包含了 1991 年至 1998 年期间四个主要欧洲股票指数的每日收盘价格。数据集仅包括工作日。

这个数据集比我们迄今为止看过的更好准备,因为它已经很好地格式化并对我们进行了取样。我们不需要担心缺失值、时区或不正确的测量,因此我们可以直接进行探索性数据分析。

我们查看这些数据的第一步将与非基于时间的时间序列非常相似,尽管我们在时间数据中有一个比在横截面数据中更简单的选项。我们可以单独绘制每个值,这样的绘图是有意义的。与横截面数据相对比,那里任何个体特征与数据集中的索引的绘图将只显示数据输入或从 SQL 服务器返回的顺序(换句话说,是任意顺序),但没有显示底层情况。然而,在时间序列的情况下,绘图非常具有信息性,就像您在图 3-1 中看到的那样。

## R
> plot(EuStockMarkets)

图 3-1. 时间序列数据的简单绘图。

注意,图像会通过简单的plot()命令自动分割成不同的时间序列。事实上,正如这里展示的,我们正在使用一个 R 中的mts对象(如果只有一个时间序列,我们将使用一个ts对象):

## R
> class(EuStockMarkets)
[1] "mts"    "ts"     "matrix"

许多流行的包大量使用ts对象和其衍生类。这些对象配备了适当绘图函数的自动调用,正如我们在前面的例子中看到的,一个简单的plot()调用创建了一个标记齐全的多面板图。ts对象还有一些便利函数:

  • frequency用于查找数据的年度频率:

    ## R
    > frequency(EuStockMarkets)
    [1] 260
    
  • startend用于找出系列中表示的第一个和最后一个时间:

    ## R
    > start(EuStockMarkets)
    [1] 1991  130
    > end(EuStockMarkets)
    1] 1998  169
    
    
  • window用于获取数据的时间段:

    ## R
    > window(EuStockMarkets, start = 1997, end = 1998)
    Time Series:
    Start = c(1997, 1)
    End = c(1998, 1)
    Frequency = 260
    	        DAX SMI CAC   FTSE
    1997.000 2844.09 3869.8 2289.6 4092.5
    1997.004 2844.09 3869.8 2289.6 4092.5
    1997.008 2844.09 3869.8 2303.8 4092.5
    ...
    1997.988 4162.92 6115.1 2894.5 5168.3
    1997.992 4055.35 5989.9 2822.9 5020.2
    1997.996 4125.54 6049.3 2869.7 5018.2
    1998.000 4132.79 6044.7 2858.1 5049.8
    
    

ts类有其优缺点。如前所述,ts及其衍生类在许多时间序列包中被广泛使用。此外,自动设置绘图参数可能会有所帮助。然而,索引有时可能会比较棘手,并且使用window访问数据的子段随着时间推移可能会感到不便。在本书中,您将看到多种包含和访问时间序列数据的方法,您可以根据自己的使用情况选择最方便的方式。

直方图

让我们继续比较时间序列和非时间序列数据的探索性数据分析。例如,我们想要对数据进行直方图分析,就像大多数探索性数据分析一样。我们通过对差分数据进行直方图分析,引入了一个新的问题,因为我们想要使用我们的时间轴(参见图 3-2):

## R
> hist(     EuStockMarkets[, "SMI"], 30)
> hist(diff(EuStockMarkets[, "SMI"], 30))

图 3-2. 未转换数据的直方图(顶部)极其宽阔,不显示正态分布。考虑到底层数据存在趋势,这是可以预料的。我们对数据进行差分以消除趋势,这将使数据转变为更接近正态分布的形式(底部)。

在时间序列的背景下,数据差的hist()通常比未转换数据的hist()更有趣。毕竟,在时间序列的背景下,通常(特别是在金融领域)最有趣的是一个值如何从一次测量到下一次测量的变化,而不是这个值的实际测量。这在绘图中尤为明显,因为对具有趋势的数据进行直方图统计不会产生非常信息丰富的可视化效果。

注意我们从差分系列的直方图中获得的新信息。虽然前一节中股票的原始图表描绘了股票不断上涨的非常乐观的经济图景,但这个直方图向我们展示了跟随股票的日常体验。差分的直方图告诉我们,时间序列的值随时间既上升(正差分值),又下降(负差分值),而且大致相同。股票指数并没有随时间完全同样的上升和下降,因为我们知道股票指数确实有一个增长趋势。然而,从这个直方图中我们可以看到,稍微偏向正差分而不是负差分正是导致这种趋势的原因。

这是为什么我们在抽样、总结和问数据问题时需要关注时间尺度的一个很好的例子。无论是表现看起来很好(长期图)还是一般般(差分图的直方图),都取决于我们的时间尺度:我们关心日常吗,还是我们有更长期的视角?如果我们在一个以年度盈利为目标的机构工作,我们需要考虑每次向上司报告倾向于负值的“差分”的短期经历。然而,如果我们是一个大型机构投资者——也许是大学或医院——我们可能负担得起采取长期视角,预期上升。后一种情况也有其挑战:我们如何花足够长的时间来最大化利润,但不至于失去机会?这些问题使得金融行业与时间序列研究和预测息息相关。

散点图

使用散点图的传统方法对时间序列数据与其他类型的数据同样有用。我们可以使用散点图来确定两只股票在特定时间点的联系以及它们的价格变动如何随时间相关。

在这个例子中,我们绘制了两种情况(见图 3-3):

  • 两只不同股票随时间变化的值

  • 这两只股票每日变化的值随时间变化(通过 R 的diff()函数进行差分)

## R
> plot(     EuStockMarkets[, "SMI"],       EuStockMarkets[, "DAX"])
> plot(`diff`(EuStockMarkets[, "SMI"]), `diff`(EuStockMarkets[, "DAX"]))

图 3-3. 两个股票指数的简单散点图显示了强烈的相关性。然而,我们有理由对这些图表持怀疑态度。

正如我们已经看到的,实际值比相邻时间点之间的差异信息更少,因此我们在第二个散点图中绘制了这些差异。这些看起来像是非常强的相关性,但实际关系并不像它们看起来那么强。(要深入了解,请跳到 “伪相关性” 一节。)

图 3-3 中的明显相关性很有趣,但即使它们是真实的相关性(但有理由怀疑它们是否是),这些也不是我们作为股票交易员可以货币化的相关性。当我们知道一只股票是涨还是跌时,与之相关的股票也已经涨或跌,因为我们是在相同时间点的值之间进行相关性分析。我们需要做的是找出一只股票之前的变化是否能预测另一只股票之后的变化。为此,在查看散点图之前,我们将一个股票的差异向后移动 1 步。仔细阅读以下代码;请注意,我们仍在进行差异化处理,但现在我们还在一个差分时间序列上应用了滞后(参见 图 3-4):

## R
> plot(`lag`(diff(EuStockMarkets[, "SMI"]), 1), 
           diff(EuStockMarkets[, "DAX"]))

提示

这些代码示例中的代码行易于阅读,因为每个部分都是对齐的。在编写长行的难以阅读的代码时,特别是在像 R 或 Python 这样的函数式编程语言中,这可能是诱人的,但尽量避免!

图 3-4。在引入时间滞后后,股票之间的相关性消失了,这表明 SMI 似乎不能预测 DAX。

这个结果告诉我们一些重要的事情:

  • 对于时间序列数据,虽然我们可能会使用与非时间序列数据相同的探索技术,但盲目应用是行不通的。我们需要考虑如何利用相同的技术,但应用在重塑后的数据上。

  • 往往是不同时间点数据之间的关系或随时间变化的变化最能说明你的数据行为方式。

  • 图 3-4 显示了为什么成为股票交易员可能会很困难。如果你是一名被动投资者并且耐心等待,你可能会从长期上升的趋势中受益。然而,如果你试图预测未来,你会发现这并不容易!

警告

R 的 lag() 函数可能不会按照你预期的时间方向移动数据。 lag() 函数是向前的时间移动。记住这一点很重要,因为在特定用例中,你不会希望将数据移动到错误的时间方向,不同的用例可能需要前向和后向的时间移动。

时间序列特定的探索方法

分析时间序列数据的几种方法侧重于同一系列不同时间点的值之间的关系,如果你之前没有处理过时间序列数据,你可能以前没见过这些。在本章的其余部分,我们将介绍一些用于分类时间序列的概念和相关技术。

我们将探讨的概念包括:

稳定性

时间序列平稳的含义及其统计检验

自相关

说一个时间序列与自身相关意味着什么,以及这样的相关性对时间序列底层动态的指示

虚假相关性

一个相关性是虚假的意味着什么,以及你应该预期何时会遇到虚假相关性

我们将学习应用的方法包括:

  • 滚动和扩展窗口函数

  • 自相关函数

    • 自相关函数

    • 偏自相关函数

我们将按照从稳定性到自相关再到虚假相关性的顺序涵盖这些概念及其相应的方法。在我们深入具体内容之前,让我们讨论一下这种特定顺序背后的逻辑。

你可能会首先询问关于时间序列的一个问题,即它是否表现出反映一个“稳定”的系统还是一个不断变化的系统。稳定性的水平,或平稳性,是评估重要的,因为我们需要知道系统的长期过去行为如何反映其长期未来行为。一旦我们评估了时间序列的“稳定性”(这里并非以技术意义使用此词),我们试图确定是否存在该序列的内部动态(例如季节性变化)。也就是说,我们在寻找自相关,回答如何预测未来数据的基本问题,无论是远距离还是近距离的过去数据。最后,当我们认为已经找到了系统内部的某些行为动态时,我们需要确保我们不是基于根本不暗示我们希望发现的因果关系的动态来识别关系;因此,我们必须寻找虚假相关性

理解稳定性

许多传统的统计时间序列模型依赖于时间序列的平稳性。一般来说,一个平稳的时间序列是指其统计性质随时间变化相对稳定,特别是在均值和方差方面。这看起来相对直观。

尽管如此,稳定性可能是一个棘手的概念,特别是在应用于实时序列数据时。这既太直觉了,也太容易让你依赖自己的直觉。在讨论稳定性的一般测试和如何应用这一概念的实际细节之前,我们将直观地和带有一定正式定义地讨论这个概念。

直觉

静止时间序列是指时间序列测量反映系统处于稳定状态。有时很难确定这究竟意味着什么,可以更容易地排除是静止的东西,而不是说某些东西静止的。一个明显的非静止数据的例子是我们在第二章中检查的航空乘客数据集,如图 3-5 中所示(提醒一下,在 R 中可以作为AirPassengers使用,并且广泛可供下载)。

有几个特征显示这个过程并不是静止的。首先,均值随时间增长而不是保持稳定。其次,每年的高峰和低谷之间的距离增大,因此过程的方差随时间增加。第三,该过程显示出强烈的季节性行为,与静止相反。

图 3-5. 航空乘客数据集是一个明显的非静止时间序列的典型示例。随着时间的推移,数据的均值和方差都在变化。我们还看到了季节性的证据,这本质上反映了一个非静止的过程。

静止性定义和增广迪基-富勒检验

一个静止过程的简单定义如下:如果对于所有可能的滞后ky[t]y[t+1],...,y[t+k]的分布不依赖于t,则过程是静止的。

针对静止性的统计检验通常归结为一个问题:过程的特征方程是否存在单位根——即 1 是否是过程特征方程的解。[¹] 如果存在单位根,则线性时间序列是非静止的,尽管缺乏单位根并不证明静止性。解决静止性作为一个一般性问题仍然棘手,确定一个过程是否具有单位根仍然是当前的研究领域。

尽管如此,从随机游走的例子中可以得出单位根的简单直觉:

y t = ϕ × y t-1 + e t

在这个过程中,给定时间点上的时间序列值是其前一个时间点值和一些随机误差的函数。如果ϕ等于 1,该序列具有单位根,将“逃跑”,并且不会是静止的。有趣的是,序列不是静止的并不意味着它必须具有趋势。随机游走是一个非静止时间序列的良好示例,没有潜在趋势。[²]

用于确定过程是否平稳的检验被称为假设检验。增广迪基-富勒(ADF)检验是评估时间序列平稳性问题最常用的指标。该检验提出一个空假设,即时间序列中存在单位根。根据检验结果,可以在指定的显著性水平上拒绝该空假设,这意味着在给定显著性水平下可以拒绝单位根的存在。

请注意,平稳性检验侧重于系列的均值是否变化。方差通过变换而非正式检验来处理。因此,系列是否平稳的测试实际上是对系列是否集成的测试。阶数为d的集成系列是一种必须差分d次才能变得平稳的系列。

迪基-富勒检验的框架如下:

Δ y t = y t y t-1 = ( ϕ 1 ) × y t-1 + ϵ t

然后,测试ϕ = 1 是否一个简单的t检验,检验滞后y[t–1]上的参数是否等于 0。ADF 检验的不同之处在于考虑更多的滞后,使得底层模型考虑更高阶的动态效应,可以写成一系列差分滞后:

Y t ϕ 1 × y t-1 ϕ 2 × y t-2 . . . = ϵ t

这需要更多的代数来写成一系列差分滞后,并且用于测试空假设的期望分布与原始迪基-富勒检验有所不同。ADF 检验是时间序列文献中最广泛展示的平稳性检验。

不幸的是,由于多种原因,这些检验远非你解决平稳性问题的灵丹妙药:

  • 这些检验在区分接近单位根和单位根方面的能力较低。

  • 样本量较小时,单位根的假阳性相当普遍。

  • 大多数检验不会针对所有可能导致非平稳时间序列的问题进行测试。例如,有些测试专门测试系列的均值或方差(但不是两者都)。其他测试则更广泛地检查整体分布。在使用时,理解所应用测试的限制并确保这些限制与您对数据的信念一致至关重要。

设定替代空假设:KPSS 检验

虽然 ADF 检验假设存在单位根的空假设,但 Kwiatkowski-Phillips-Schmidt-Shin(KPSS)检验假设一个平稳过程的空假设。与 ADF 不同,KPSS 不在基础 R 中可用,但仍然广泛实施。关于这些检验的用途和正确使用方式存在一些细微差别;这些内容超出本文范围,但在网络上广泛讨论

在实践中

在实践中,平稳性对多种原因至关重要。首先,大量模型假设一个平稳过程,如具有已知强度和统计模型的传统模型。我们将在第六章中涵盖这些模型类。

更广义的一点是,非平稳时间序列模型在时间序列的度量指标变化时精度也会变化。也就是说,如果你正在寻找一个模型来帮助你估计一个具有非平稳均值和方差的时间序列的均值,那么你的模型中的偏差和误差将随时间变化,此时你的模型的价值变得值得怀疑。

通常情况下,一个时间序列可以通过几个简单的变换使其足够平稳。对数变换和平方根变换是两种流行的选择,特别是在时间上变化方差的情况下。同样地,通过差分来去除趋势是最常见的做法。有时候一个序列必须进行多次差分。然而,如果你发现自己进行了过多的差分(超过两三次),那么用差分来解决你的平稳性问题是不太可能的。

对数还是平方根?

虽然平方根通常比对数计算复杂性低,但你应该探索两种选择。考虑你的数据范围以及你希望如何压缩大值,而不是过早优化(即过度悲观化)你的代码和分析。

平稳性不是预测模型所做的唯一假设。另一个常见但不同的假设是输入变量或预测变量的正态分布。在这种情况下,可能需要其他变换。一个常见的变换是 Box Cox 变换,在 R 的forecast包和 Python 的scipy.stats中实现。该变换使非正态分布的数据(偏斜数据)更接近正态分布。然而,仅仅因为你可以对数据进行变换并不意味着你应该这样做。在转换之前,请仔细考虑原始数据集中数据点之间距离的含义,并确保无论任务如何,变换都能保留最重要的信息。

应用窗口函数

让我们回顾一下最重要的时间序列探索图表,这些图表可能在大多数初始时间序列分析中使用。

滚动窗口

一个特定于时间序列的常见函数是窗口函数,它是任何一种在聚合数据时压缩数据(正如我们在上一章中看到的下采样的情况)或平滑数据(也在第二章中讨论过)的函数。除了已经讨论过的应用外,平滑数据和窗口聚合数据可用于信息丰富的探索性可视化。

我们可以使用基于 R 的filter()函数计算移动平均线和其他涉及一系列点的线性函数的计算,如下所示:

## R
> ## calculate a rolling average using the base R
> ## filter function
> x  <- rnorm(n = 100, mean = 0, sd = 10) + 1:100
> mn <- function(n) rep(1/n, n)

> plot(x, type = 'l',               lwd = 1)
> lines(filter(x, mn( 5)), col = 2, lwd = 3, lty = 2)
> lines(filter(x, mn(50)), col = 3, lwd = 3, lty = 3)

此代码生成了图表图 3-6。

图 3-6. 通过滚动均值平滑生成的两条探索曲线。我们可以使用这些曲线来寻找特别嘈杂数据中的趋势,或者决定哪些线性行为偏离是有趣的研究对象,而哪些可能只是噪音。

如果我们在寻找不是窗口中所有点的线性组合的函数,则无法使用filter(),因为它依赖于数据的线性变换。但是,我们可以使用zoozoo包中的rollapply()函数非常方便(见图 3-7):

## R
> ## you can also do more 'custom' functionality
> require(zoo)

> f1 <- rollapply(zoo(x), 20, function(w) min(w), 
>                align = "left",  partial = TRUE)
> f2 <- rollapply(zoo(x), 20, function(w) min(w), 
>                align = "right", partial = TRUE)

> plot (x,           lwd = 1,         type = 'l')
> lines(f1, col = 2, lwd = 3, lty = 2)
> lines(f2, col = 3, lwd = 3, lty = 3)

图 3-7. 滚动窗口最小值,左对齐(长划线)或右对齐(短划线)。左对齐可以看到未来的事件,而右对齐只能看到过去的事件。这一点很重要,避免了预先看看。然而,有时左对齐可以用来提出探索性问题,比如“如果我事先知道这个,那么它会有用吗?”有时即使预先看也不具信息性,这意味着特定变量不具信息性。当了解一个度量的未来对于帮助无济于事时,在时间序列的任何时间中,这个度量可能都不是有用的度量。
警告

zoo函数中使用zoo对象。如果直接将数值向量传递给rollapply(),则align参数将不会生效。您可以通过删除前面代码中对xzoo()包装来确认这一点。您会发现这两条曲线是相同的,实际上这是一个悄无声息的失败,在时间序列分析中尤为危险,因为它可能引入意外的预看。这正是为什么您需要经常进行理性检查的例子,即使在探索性数据分析中也是如此。不幸的是,悄无声息的失败在许多流行的 R 包和其他脚本语言中并不罕见,因此保持警惕!

也有可能“自己动手”实现这种功能,这样做可以限制依赖。在这种情况下,我强烈建议从现有广泛使用的包(例如zoo)的源代码开始。因为即使对于单变量时间序列,也有许多意外情况需要考虑,比如如何处理NA以及如何处理系列的开始和结束,这些地方点数可能少于窗口指定的大小。

扩展窗口

扩展窗口在时间序列分析中使用较少,与滚动窗口相比,因其适用范围更为有限。扩展窗口仅在你估计一个稳定过程而非随时间演变或振荡显著的总结统计量时才有意义。扩展窗口从给定的最小尺寸开始,但随着时间序列的进行,窗口会逐渐扩展,包括直到特定时间的每个数据点,而不是保持一个有限且恒定的大小。

扩展窗口在随时间推移中提供了对测试统计量更大的确定性,使您能够从深入到特定时间序列中受益。但是,它仅在您假设您的基础系统是静止的情况下才有效。它可以帮助您保持“在线”摘要统计,就像在收集更多信息时实时估计它们一样。

如果您查看基础 R,您会意识到许多函数已存在作为扩展窗口的实现,例如cummaxcummin。您还可以轻松重新使用cumsum来创建累积均值。在下图中,我们展示了扩展窗口max和扩展窗口mean(参见图 3-8):

## R
> # expanding windows
> plot(x, type = 'l', lwd = 1)
> lines(cummax(x),             col = 2, lwd = 3, lty = 2) # max
> lines(cumsum(x)/1:length(x), col = 3, lwd = 3, lty = 3) # mean

图 3-8. 扩展窗口“max”(长划线)和扩展窗口“mean”(短划线)。使用扩展窗口意味着最大值始终反映到那时的全局最大值,使其成为单调函数。由于扩展窗口的长“记忆”,扩展窗口均值比滚动均值低(参见图 3-7),因为扩展均值中的基础趋势不那么显著。这是好是坏还是中立,取决于我们的假设和对基础系统的知识。

如果您需要带有滚动窗口的自定义函数,您可以使用rollapply(),就像我们为滚动窗口所做的那样。在这种情况下,您需要指定一系列窗口大小而不是单个标量。运行以下代码将生成与图 3-8 相同的图,但这次是使用rollapply()而不是内置 R 函数创建的:

## R
> plot(x, type = 'l', lwd = 1)
> lines(rollapply(zoo(x), seq_along(x), function(w) max(w), 
>                         partial = TRUE, align = "right"), 
>          col = 2, lwd = 3, lty = 2)
> lines(rollapply(zoo(x), seq_along(x), function(w) mean(w), 
>                         partial = TRUE, align = "right"), 
>          col = 3, lwd = 3, lty = 3)

自定义滚动函数

我们只关心是否有可能应用自定义滚动函数。在实践中,这是您在分析已知具有已知基本行为规律或必要用于正确分析的有用启发式的时间序列领域时可能看到的内容。

例如,我们可能正在查看包含特定特征的窗口,这在领域知识的基础上具有信息意义。我们可能希望知道我们处于单调(比如,血糖上升)还是上下波动的情况,这表明是仪器噪声而不是趋势。我们可以为这种情况编写自定义函数,并使用移动或扩展窗口应用它。

理解和识别自相关

在其最基本的层面上,时间序列的自相关是指时间序列中的一个值在某一时间点可能与另一个时间点的值相关。请注意,“自相关”在此处是非正式使用,用于描述一个概念而非技术性描述。

举个自相关的例子,如果您拿年度时间序列的每日温度数据来说,您可能会发现,将每年的 5 月 15 日与每年的 8 月 15 日进行比较,会得到一些相关性,例如较热的 5 月 15 日倾向于与较热的 8 月 15 日相关联(或者倾向于与较冷的 8 月 15 日相关联)。这可能让您觉得已经学到了关于温度系统的潜在有趣事实,表明存在一定的长期可预测性。另一方面,您可能会发现相关性接近于零,这种情况下,您也会发现一些有趣的事实,即仅仅知道 5 月 15 日的温度并不能为您提供关于 8 月 15 日温度范围的任何信息。这就是自相关在轶事中的要点。

从这个简单的例子出发,我们将扩展到自相关,它通过不锚定到特定时间点来推广自相关。特别是,自相关提出了更一般的问题,即在特定时间序列中,任意两个点之间是否存在固定距离的相关性。接下来我们将更详细地讨论这一点,以及对偏自相关的最终阐述。

自相关函数

我们从Wikipedia对自相关的优秀定义开始:

自相关,也称为串行相关,是信号与延迟副本之间的相关性,其延迟作为延迟函数。非正式地说,它是观察之间的相似性,作为它们之间时间滞后的函数。

让我们用更简单的英语来解释一下。自相关告诉您不同时间点的数据点如何作为它们时间差的函数而线性相关。

自相关函数(ACF)可以通过绘图直观理解。我们可以在 R 中轻松绘制它(参见图 3-9):

## R
> x <- 1:100
> y <- sin(x × pi /3)
> plot(y, type = "b")
> acf(y)

图 3-9. 正弦函数及其自相关函数的绘图。

从 ACF 中,我们可以看到,时间滞后为 0 的点之间的相关性为 1(对于每个时间序列都是如此),而滞后 1 的点之间的相关性为 0.5。滞后 2 的点之间的相关性为–0.5,依此类推。

计算 ACF 非常简单。我们可以使用data.tableshift()函数自己做到这一点:

## R
> cor(y, shift(y, 1), use = "pairwise.complete.obs")
[1] 0.5000015
> cor(y, shift(y, 2), use = "pairwise.complete.obs")
[1] -0.5003747

我们的计算与图 3-9 中的图形结果大致匹配。虽然可以使用自定义代码直接计算 ACF,但通常最好使用预先定义的版本,例如 R 的acf函数。这样做有几个优点:

  • 自动绘图与有用的标签

  • 通常但并非总是合理的最大滞后数,以及可以覆盖此最大值的选项

  • 处理多变量时间序列的一个优雅方法

就数学角度而言,关于 ACF 有几个重要事实:

  • 周期函数的 ACF 具有与原始过程相同的周期性。 您可以在前述正弦示例图中看到这一点。

  • 周期函数的总和的自相关是每个函数单独自相关的总和。 您可以使用一些简单的代码轻松地制定一个示例。

  • 所有时间序列在滞后 0 处的自相关系数为 1。

  • 白噪声样本的自相关在除了 0 之外的所有滞后处大约为 0。

  • ACF 关于负滞后和正滞后对称,因此只需明确考虑正滞后。您可以尝试绘制手动计算的 ACF 来证明这一点。

  • 确定显著非零 ACF 估计的统计规则由“临界区域”给出,其边界为+/–1.96 × sqrt(n)。 此规则依赖于足够大的样本量和过程的有限方差。

偏自相关函数

偏自相关函数(PACF)比 ACF 更难理解。给定滞后的时间序列的偏自相关是在两个时间点之间的所有信息下,时间序列与其自身在该滞后处的偏相关。

在这里点头表示认同听起来很合理。但是,准确来说,什么是在两个时间点之间考虑信息?这意味着您需要计算多个条件相关并从总相关中减去这些。计算 PACF 并非易事,并且有多种估计方法。我们这里不讨论这些,但您可以在相关的 R 和 Python 文档中找到讨论。

PACF 在图形上比概念上更容易理解。 它在图表中的实用性也比讨论中更为明显(见图 3-10):

## R
> y <- sin(x × pi /3)
> plot(y[1:30], type = "b")
> pacf(y)

图 3-10。 季节性无噪声过程的绘图和 PACF。

对于正弦级数的情况,PACF 与 ACF 形成鲜明对比。 PACF 显示哪些数据点是信息丰富的,哪些是较短时间周期的谐波。

对于季节性且无噪声的过程,如正弦函数,以周期 T 为周期,将在 T、2T、3T 等处看到相同的 ACF 值,直到无限大。 ACF 未能消除这些冗余相关性。 另一方面,PACF 显示了哪些相关性是特定滞后的“真实”信息相关性,而不是冗余性。 这对于知道我们何时收集了足够的信息以获得足够长的窗口在数据的适当时间尺度上是非常宝贵的。

PACF 的临界区域与 ACF 相同。 临界区域在± 1 . 96 n处有界。 任何计算的 PACF 值落在临界区域内的滞后实际上为零。

到目前为止,我们只看了完全无噪声的单频过程示例。现在我们看一个稍微复杂的例子。我们将考虑无噪声、低噪声和高噪声条件下两个正弦曲线的求和。

首先,让我们查看无噪声的情况下各自的图表(见图 3-11):

## R
> y1 <- sin(x × pi /3)
> plot(y1, type = "b")
> acf (y1)
> pacf(y1)

> y2 <- sin(x × pi /10)
> plot(y2, type = "b")
> acf (y2)
> pacf(y2)

提示

静止数据的自相关函数(ACF)应快速下降至零。对于非静止数据,滞后 1 的值为正且较大。

图 3-11. 两个正弦函数、它们的自相关函数(ACF)和偏自相关函数(PACF)的图表。

我们通过求和来结合这两个系列,并为求和系列创建相同的图表(见图 3-12):

## R
> y <- y1 + y2
> plot(y, type = "b")
> acf (y)
> pacf(y)

正如我们所见,我们的 ACF 图与前述特性一致;两个周期系列的求和的 ACF 是各自 ACF 的和。您可以通过注意到 ACF 的正→负→正→负部分来清楚地看到这一点,这些部分与更慢振荡的 ACF 相关。在这些波浪中,您可以看到更高频 ACF 的快速波动。

图 3-12. 两个正弦系列的求和的图表、自相关函数(ACF)和偏自相关函数(PACF)。

PACF 不是各个分量的 PACF 函数的简单求和。一旦计算出来,PACF 就足够简单理解,但生成或预测起来并不容易。该 PACF 表明,与原始系列中的任一点相比,分离由某个滞后分隔的点的相关性,考虑它们之间点的值时,在求和系列中更具信息性。这与系列的两个不同周期有关,这导致给定点不太由相邻点的值确定,因为现在两个周期的振荡在不同频率下继续。

让我们看看相同情况,但带有更多噪音(见图 3-13):

## R
> noise1 <- rnorm(100, sd = 0.05)
> noise2 <- rnorm(100, sd = 0.05)

> y1 <- y1 + noise1
> y2 <- y2 + noise2
> y  <- y1 + y2

> plot(y1, type = 'b')
> acf (y1)
> pacf(y1)

> plot(y2, type = 'b')
> acf (y2)
> pacf(y2)

> plot(y, type = 'b')
> acf (y)
> pacf(y)

图 3-13. 两个带有噪声的正弦过程及其求和的图表、自相关函数(ACF)和偏自相关函数(PACF)。

最后,我们给时间序列添加更多噪声,使得初始数据本身甚至不看起来特别像正弦波。 (我们省略了代码示例,因为它与前一个示例相同,只是rnormsd参数更大。)我们可以看到,这增加了进一步的解释困难,特别是对 PACF。图 3-14 中的图与之前的图唯一的区别是噪声变量的sd值更大。

图 3-14. 两个正弦过程非常嘈杂的求和的图表、自相关函数(ACF)和偏自相关函数(PACF)。

让我们以一个真实数据集的 ACF 和 PACF 来总结。在下面,我们检查AirPassengers数据。根据我们到目前为止看到的内容,思考一下为什么 ACF 有这么多“关键”值(答案:它有趋势),以及为什么 PACF 对于一个大滞后有关键值(答案:年度季节循环,即使数据中有趋势也是可以识别的)。

图示

图 3-16. 对 AirPassengers 数据的自相关函数(ACF)和偏自相关函数(PACF)绘图。这里的滞后值不是单位数字,因为滞后以年的分数形式表示。这是因为 AirPassengers 数据集采用了一个 ts 对象的形式,该对象具有内置频率用于绘图(及其他目的)。

伪相关性

那些刚接触时间序列分析的人通常会从标准的探索性数据实践开始,例如将两个变量绘图并计算它们的相关性。新的分析师在数据探索过程的早期会非常兴奋,当他们注意到一个看似非常强的相关性和有益的关系时。他们会继续寻找其他令人惊讶的高相关性;多么令人惊叹的系统!他们会希望自己早些在职业生涯中开始处理时间序列数据。他们会对自己想:“我只是赢了。”

然后分析师将会稍微阅读一下时间序列分析(最佳情况),或者将他们的发现展示给其他人,并意识到并不是所有事情都说得通(并非最佳情况)。一个怀疑论者会指出,相关性太高了。似乎任何两个值都有关联。当分析师使用其他变量集重新运行分析时,会出现更多问题,并发现它们也有令人惊讶的高相关性。在某个时候,会清楚地意识到不可能有这么多真正的高相关性。

这一轨迹与计量经济学早期历史非常相似。19 世纪,当经济学家首次开始思考商业周期的概念时,其中一些人寻找了周期的外部驱动因素,比如太阳黑子(一个 11 年周期)或各种气象周期(比如假设的 4 年降水周期)。他们总是得到非常积极且强相关的结果,即使他们没有因果假设来解释这些结果。

许多经济学家和统计学家对此持怀疑态度,这是正当的。乌德尼·尤尔(Udny Yule)通过一篇题为“为什么有时我们会得到无意义的相关性?”的论文正式调查了这个问题,从而开创了一个研究领域,并继续为学术界带来麻烦和乐趣。虚假相关性仍然是一个需要警惕的重要问题,在诉讼情境中尤为热议,其中一方断言存在关联,而另一方试图驳斥。类似地,一种质疑气候变化数据的尝试依赖于一个论点,即增加的碳排放和全球变暖之间的相关性是由两个数据集的趋势造成的虚假相关性(我认为这一论点并不令人信服)。

经济学家经过时间的学习已经了解到,具有基础趋势的数据可能会产生虚假相关性。以下是一个简单的思考方式:趋势时间序列中包含的信息比静止时间序列中的信息更多,因此数据点之间有更多的机会同时变动。

除了趋势之外,时间序列的一些其他常见特征也可能引入虚假相关性:

  • 季节性——例如,考虑热狗消费与溺水死亡之间的虚假相关性(夏季)。

  • 数据由于时间的制度变化而产生的水平或斜率变化(产生类似哑铃形的分布,其中高相关性毫无意义)。

  • 累积求和数量(这是某些行业用来使模型或相关性看起来比实际更好的技巧)。

有一个众所周知的博客(现在也有一本书),充满了关于虚假相关性的精彩例子,我在图 3-17 中分享了其中一个。每当您试图认为您发现了一个特别强的关系时,请务必检查数据是否存在明显的问题原因,比如趋势。

图 3-17。一些虚假相关性看起来出奇地令人信服。此图摘自Tyler Vigen 的网站,展示了虚假相关性。

一些有用的可视化

图表对于时间序列的彻底探索性分析至关重要。您肯定希望通过时间轴来可视化数据,最好以一种能够回答数据集中您关心的一般问题的方式,比如特定变量的行为或数据点的整体时间分布。

本章早些时候,我们探讨了一些对任何数据分析师都很熟悉的绘图技术,例如根据时间绘制值的图表或随时间散点绘制不同列的值的图表。在探索性数据分析的最后一节中,我们讨论了几种特别有助于提供关于时间序列行为的新见解的可视化方法。

我们将看一些复杂程度不同的可视化:

  • 一维可视化,以理解我们在第二章中组装的时间序列的整体时间分布

  • 一个二维直方图,用于理解在许多并行测量(例如测量许多年或同一现象的许多时间序列)的情况下,值随时间的典型轨迹。

  • 三维可视化中,时间可以占据多达两个维度或一个维度,但仍然可以隐含地存在

一维可视化

在许多单位测量的情况下(许多用户、成员等),我们并行考虑多个时间序列。在视觉上堆叠这些序列可能会很有趣,强调个体分析单位及其各自的时间框架。我们忽略测量的值,而是将数据存在的时间范围视为感兴趣的信息。时间跨度本身成为分析单位。在这里,我们使用了 R 语言的timevis包,但还有许多其他选择。我们查看了我们在第二章中准备的donations数据的一个小子集(参见图 3-18):

## R
> require(timevis)
> donations <- fread("donations.csv")
> d         <- donations[, .(min(timestamp), max(timestamp)), user]
> names(d)  <- c("content", "start", "end")
> d         <- d[start != end]
> timevis(d[sample(1:nrow(d), 20)])

图 3-18. 一个随机样本的甘特图可以提供一些关于用户/捐赠者“活跃”时间段分布的想法。

图 3-18 中的图表帮助我们看到,全球会员群体可能有“繁忙”的时期。我们还从中获得了一些关于会员组织中个体捐赠时间跨度分布的感觉。

甘特图已经被使用了一个多世纪,最常见的是用于项目管理任务。它们在许多不同的行业中独立产生,一旦看到它们,这个概念就显而易见。尽管起源于项目管理,甘特图在时间序列分析中也很有用,其中有许多独立的参与者,而不是单一的过程被测量。图 3-18 中的图形迅速回答了我关于整个用户群体在捐赠历史上的重叠程度的问题,这是我在仅仅阅读表格数据时发现很难理解的分布。

二维可视化

现在我们将使用AirPassengers数据来查看季节性和趋势,但我们不应将时间视为线性。特别是,时间可以在多个轴上发生。当然,有从一天到另一天和一年到另一年的时间轴,但我们也可以考虑将时间布置在一天中的小时轴或一周中的日期轴上等等。通过这种方式,我们可以更容易地思考季节性,例如某些行为发生在一天的特定时间或一年中的特定月份。我们特别关注如何以季节方式理解我们的数据,而不仅仅是根据线性、时间顺序的可视化。

我们从AirPassengersts对象中提取数据,并将其放入适当的矩阵形式中:

## R
> t(matrix(AirPassengers, nrow = 12, ncol = 12))
     [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12]
[1,]   112 118  132 129 121  135 148 148 136   119 104 118
[2,]   115 126  141 135 125  149 170 170 158   133 114 140
[3,]   145 150  178 163 172  178 199 199 184   162 146 166
[4,]   171 180  193 181 183  218 230 242 209   191 172 194
[5,]   196 196  236 235 229  243 264 272 237   211 180 201
[6,]   204 188  235 227 234  264 302 293 259   229 203 229
[7,]   242 233  267 269 270  315 364 347 312   274 237 278
[8,]   284 277  317 313 318  374 413 405 355   306 271 306
[9,]   315 301  356 348 355  422 465 467 404   347 305 336
[10,]  340 318  362 348 363  435 491 505 404   359 310 337
[11,]  360 342  406 396 420  472 548 559 463   407 362 405
[12,]  417 391  419 461 472  535 622 606 508   461 390 432

注意,我们必须对数据进行转置,以使其与ts对象呈现的方式一致。

列优先与行优先

R 默认是column major,这与 Python 的NumPy(row major)以及大多数 SQL 数据库不同。了解给定语言中默认和可用的行为是有益的,这不仅仅是为了展示目的,还要考虑如何有效地管理和访问内存。

我们在反映一年中月份进展的一组轴上绘制每年的曲线(见图 3-19):

## R
> colors <- c("green",  "red",         "pink",   "blue",
>               "yellow","lightsalmon", "black",  "gray",
>               "cyan",  "lightblue",   "maroon", "purple")
> matplot(matrix(AirPassengers, nrow = 12, ncol = 12), 
>           type = 'l', col = colors,  lty = 1, lwd = 2.5, 
>           xaxt = "n", ylab = "Passenger Count")
> legend("topleft", legend = 1949:1960, lty = 1, lwd = 2.5, 
>          col = colors)
> axis(1, at = 1:12, labels = c("Jan", "Feb", "Mar", "Apr", 
>                               "May", "Jun", "Jul", "Aug", 
>                               "Sep", "Oct", "Nov", "Dec"))

图 3-19. 按年份和月份计算的每月计数。⁴

使用forecast包可以更轻松地生成相同的图表(见图 3-20):

## R
> require(forecast)
> seasonplot(AirPassengers)

x 轴是每年的月份。每年,航空公司乘客数量在七月或八月(第 7 和 8 月)达到峰值。大多数年份,三月(第 3 月)也有局部高峰。因此,该图可以显示更多关于季节性行为的细节。

图 3-20. 使用 seasonplot()函数更轻松地生成类似的季节性图表。

不同年份的曲线很少交叉。增长非常强劲,几乎不可能在同一月份不同年份有相同数量的乘客。有一些例外情况,但在高峰月份并不多见。仅凭这些观察,我们就可以为航空公司提供建议,帮助其做出关于如何规划增长的决策。

更少标准但同样有用的按年份排列的每月曲线的备选图(见图 3-21):

## R
> months <- c("Jan", "Feb", "Mar", "Apr", "May", "Jun", 
>            "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")

> matplot(t(matrix(AirPassengers, nrow = 12, ncol = 12)), 
>             type = 'l', col = colors, lty = 1, lwd = 2.5)
> legend("left", legend = months,  
>                         col = colors, lty = 1, lwd = 2.5)

图 3-21. 按年份计算的每月曲线的时间序列。⁵

多年来,增长趋势正在加速,即增长率本身在增加。此外,七月和八月两个月增长速度比其他月份快。使用forecast包提供的简易可视化函数,我们可以获得类似的可视化和洞察(见图 3-22):

## R
> monthplot(AirPassengers)

我们可以从这些图表中得出两个一般性观察:

  • 时间序列有多个有用的时间轴可以绘制。我们使用了一年中每个月份(一月到十二月)的轴和数据集年份(第一年至最后/第十二年)的轴。

  • 我们可以从堆叠时间序列数据的可视化中获取大量有用的信息和预测细节。

图 3-22. 通过使用 monthplot()函数,我们可以看到每月性能随着年份的变化如何变化。

接下来我们考虑一个适当的二维直方图。在时间序列的背景下,我们可以将二维直方图看作一个轴表示时间(或时间的代理)和另一个轴表示兴趣单位。我们刚刚做的“堆叠”图已经在成为二维直方图的路上,但需要一些改变:

  • 我们需要在时间轴和乘客数量上对数据进行分箱。

  • 我们需要更多数据。在堆叠曲线相互交错之前,二维直方图没有意义;它们不能单独被正确地看到。否则,二维直方图无法传达任何额外的信息。

我们在这个小数据集上生成了二维直方图作为示例,然后转向更有意义的示例。我们从头开始构建我们自己的二维直方图函数,如下所示:

## R
> hist2d <- function(data, nbins.y, xlabels) {
>   ## we make ybins evenly spaced to include 
>   ## minimum and maximum points
>   ymin = min(data)
>   ymax = max(data) × 1.0001 
>   ## the lazy way out to avoid worrying about inclusion/exclusion

>   ybins = seq(from = ymin, to = ymax, length.out = nbins.y + 1 )

>   ## make a zero matrix of the appropriate size
>   hist.matrix = matrix(0, nrow = nbins.y, ncol = ncol(data))

>   ## data comes in matrix form where each row 
>   ## represents one data point
>   for (i in 1:nrow(data)) {
>       ts = findInterval(data[i, ], ybins)
>       for (j in 1:ncol(data)) {
>         hist.matrix[ts[j], j] = hist.matrix[ts[j], j] + 1
>       }
>   }              
>   hist.matrix
> }

我们制作了一个带有热力图着色的直方图,如下所示:

## R
> h = hist2d(t(matrix(AirPassengers, nrow = 12, ncol = 12)), 5, months)
> image(1:ncol(h), 1:nrow(h), t(h), col = heat.colors(5),
>      axes = FALSE, xlab = "Time", ylab = "Passenger Count")

然而,得到的图像(图 3-23)并不是很令人满意。

图 3-23. 我们自制的 AirPassengers 数据的二维直方图热力图。

这个图表是无用的,因为数据不足。我们只有 12 条曲线,并将它们分成 5 个桶。然而,更重要的问题是我们没有稳态数据。直方图的使用假设数据集是稳态的。在这种情况下,存在趋势,因此尽管我们想看到季节性,但趋势必然会妨碍。

现在我们来看一个样本数更多且没有趋势污染的数据集。这是来自 UCR 时间序列分类存档 的 FiftyWords 数据集的子集。该数据集包括 50 个不同单词的表示,每个时间序列的长度相同。用于绘制 图 3-24 的数据子集是我在有关时间序列分类的一般教程中使用的数据集的一个片段。你可以 下载该子集,但是对于这个练习的目的,你可以使用任何一个:

## R
> require(data.table)

> words <- fread(url.str)
> w1    <- words[V1 == 1]

> h = hist2d(w1, 25, 1:ncol(w1))

> colors <- gray.colors(20, start = 1, end = .5)
> par(mfrow = c(1, 2))
> image(1:ncol(h), 1:nrow(h), t(h), 
>      col = colors, axes = FALSE, xlab = "Time",  ylab = "Projection Value")
> image(1:ncol(h), 1:nrow(h), t(`log`(h)), 
>       col = colors, axes = FALSE, xlab = "Time", ylab = "Projection Value")

图 3-24. 单词音频度量的二维直方图。左图使用线性计数尺度,而右图使用对数计数尺度。

Figure 3-24 的右图看起来更好,因为计数根据对数变换进行了着色,而不是直接计数。

这是与将时间序列取对数以减少方差和减少离群值的距离的相同想法的应用。使用对数变换通过不浪费相对稀疏的高计数值的大部分范围来改进我们的可视化。

我们自制的选项不会像许多预制选项那样视觉上吸引人,所以我们应该看看它们有何不同。为了利用这些预制解决方案,我们需要重新调整数据,因为这些选项期望将 x-y 值对转换为二维直方图。与我们的自制解决方案不同,用于二维直方图的预制选项并非专门为时间序列数据设计。尽管如此,它们仍提供了出色的可视化解决方案(见图 Figure 3-25):

## R
> w1 <- words[V1 == 1]

> ## melt the data to the pairs of paired-coordinates 
> ## expected by most 2d histogram implementations
> names(w1) <- c("type", 1:270)
> w1        <- melt(w1, id.vars = "type")

> w1        <- w1[, -1]
> names(w1) <- c("Time point", "Value")

> plot(hexbin(w1))

图 3-25. 同一数据的替代二维直方图可视化。

3D 可视化

3D 可视化并不随 base R 附带,但有许多可用的包。这里我展示了一些用 plotly 制作的快速图,我选择它是因为它生成的图可以在 RStudio 中轻松旋转并导出到 Web 界面。而且,下载和安装 plotly 通常很简单,这在所有可视化包中并非如此。

让我们考虑AirPassengers数据。我们将其绘制在三维空间中,使用两个维度表示时间(月份和年份),一个维度表示数据值:

## R
> require(plotly)
> require(data.table)

> months = 1:12
> ap = data.table(matrix(AirPassengers, nrow = 12, ncol = 12))
> names(ap) = as.character(1949:1960)
> ap[, month := months]
> ap = melt(ap, id.vars = 'month')
> names(ap) = c("month", "year", "count")

> p <- plot_ly(ap, x = ~month, y = ~year, z = ~count, 
>              color = ~as.factor(month)) %>%
>   add_markers() %>%
>   layout(scene = list(xaxis = list(title = 'Month'),
>                       yaxis = list(title = 'Year'),
>                       zaxis = list(title = 'PassengerCount')))

这种三维可视化帮助我们感受数据的整体形状。我们之前见过很多类似的内容,但扩展到三维散点图明显比二维直方图更好,也许是因为数据的稀缺性(参见图 3-26 和 3-27)。

图 3-26. AirPassenger 数据的三维散点图。这个视角突出了季节性。⁶

图 3-27. 同一数据的另一视角更清晰地说明了从一年到下一年的增长趋势。我强烈建议在您自己的计算机上运行这段代码,您可以自行旋转它。⁷

我们并不一定需要在时间上耗费两个轴。相反,我们可能会用两个轴表示位置,一个轴表示时间。我们可以如下方式可视化二维随机漫步,稍微修改了 plotly 的演示代码(见图 3-28 和 3-29):

## R
> file.location <- 'https://raw.githubusercontent.com/plotly/datasets/master/\
 _3d-line-plot.csv'
> data <- read.csv(file.location)
> p <- plot_ly(data, x = ~x1, y = ~y1, z = ~z1, 
>                 type = 'scatter3d', mode = 'lines',
>                 line = list(color = '#1f77b4', width = 1)) 

图的交互性质是关键。不同的视角可能会误导或启示我们,这些效果在我们能够旋转数据之前是无法预知的。

图 3-28. 关于二维随机漫步随时间变化的一个视角。

图 3-29. 同一个随机漫步的这一视角更加具有启发性。再次鼓励您自己尝试这段代码!

一个很好的练习是生成两维中嘈杂的季节性运动数据,并以我们在这里可视化随机漫步数据的方式进行可视化。在您看到的图中,与随机漫步数据相比应该有显著的差异。像 plotly 这样的包可以帮助您快速实验,并提供全面的视觉反馈。

更多资源:

¹ 如果这对你没有印象,别担心。如果你希望深入了解,可以在“更多资源”中阅读更多内容。

² 由随机漫步生成的给定样本时间序列过程可能看起来有趋势,这在分析股票价格时间序列时特别引起了许多争论。

³ 使用 R 的cor()函数计算的样本相关性与使用 R 的acf()函数计算的样本自相关性可能不完全匹配,因为它们使用不同的除数。欲了解更多信息,请参阅StackExchange

⁴ 访问GitHub 仓库查看原始图表,或自行绘制以获取更详细的查看。

⁵ 访问GitHub 仓库查看原始图表,或自行绘制以获取更详细的查看。

⁶ 访问GitHub 仓库查看原始图表,或自行绘制以获取更详细的查看。

⁷ 访问GitHub 仓库查看原始图表,或自行绘制以获取更详细的查看。

第四章:模拟时间序列数据

到目前为止,我们已经讨论了在哪里找到时间序列数据以及如何处理它。现在我们将看看如何通过模拟创建时间序列数据。

我们的讨论分为三部分。首先,我们将时间序列数据的模拟与其他类型的数据模拟进行比较,注意在考虑时间流逝时会出现哪些新的特别关注的领域。其次,我们看一些基于代码的模拟。第三,我们讨论时间序列模拟中的一些一般趋势。

本章的大部分内容将集中在生成各种类型时间序列数据的具体代码示例上。我们将逐个讨论以下示例:

  • 我们模拟非营利组织成员多年来的电子邮件打开和捐赠行为。这与我们在"从表格集合中重塑时间序列数据收集"章节中检验的数据相关。

  • 我们在一天之内模拟了 1,000 辆出租车的事件,这些出租车有不同的班次开始时间和与一天中小时相关的乘客接载频率。

  • 我们使用相关物理定律,模拟给定温度和尺寸的磁性固体的逐步状态演变。

这三个代码示例对应于三类时间序列模拟:

启发式模拟

我们决定世界应该如何运作,确保它合理,然后一条规则一条规则地编写代码。

离散事件模拟

我们在我们的宇宙中构建具有特定规则的个体行为者,然后运行这些行为者,看看宇宙随时间的演变。

基于物理的模拟

我们应用物理定律来观察系统如何随时间演变。

模拟时间序列可以是一个有价值的分析练习,我们将在后续章节中演示,因为它涉及到具体的模型。

模拟时间序列的特别之处在于什么?

模拟数据是数据科学中很少被教授的一个领域,但对于时间序列数据却是一项特别有用的技能。这是因为时间数据的一个缺点是:同一时间序列中的两个数据点是不能完全比较的,因为它们发生在不同的时间。如果我们想考虑在特定时间发生了什么,我们就进入了模拟的世界。

模拟可以简单也可以复杂。在简单的一面,你会在任何关于时间序列的统计学教科书中遇到合成数据,比如随机漫步形式。这些通常通过随机过程的累积总和(如 R 的rnorm)或周期函数(如正弦曲线)生成。在更复杂的一面,许多科学家和工程师把他们的职业建立在模拟时间序列上。时间序列模拟仍然是许多领域的一个活跃研究领域,也是一个计算上要求高的领域,包括:

  • 气象学

  • 金融学

  • 流行病学

  • 量子化学

  • 等离子体物理学

在某些情况下,行为的基本规则是被充分理解的,但由于方程的复杂性(气象学、量子化学、等离子物理学),仍然很难考虑到所有可能发生的事情。在其他情况下,不可能知道所有的预测变量,专家甚至不确定由于系统的随机非线性特性是否可以做出完美的预测(金融、流行病学)。

模拟与预测

模拟和预测是类似的练习。在这两种情况下,您必须形成关于基础系统动态和参数的假设,然后从这些假设中推断出数据点。

然而,在学习和开发模拟而不是预测时,需要记住重要的差异:

  • 将定性观察整合到模拟中可能比整合到预测中更容易。

  • 模拟是在规模上运行的,这样您可以看到许多替代场景(成千上万甚至更多),而预测应该更加仔细地制作。

  • 模拟比预测风险较低;没有生命和资源在危险线上,因此您在最初的模拟轮次中可以更有创意和探索性。当然,最终您希望能够证明您构建模拟的方式,就像您必须证明您的预测一样。

代码中的模拟

接下来,我们将看一看编写时间序列模拟的三个例子。在阅读这些例子时,请考虑可以模拟的广泛数据范围,以生成“时间序列”,以及时间元素可以非常具体和人为驱动,比如捐赠的星期几和一天中的时间,但也可以非常不具体,基本上是未标记的,比如物理模拟的“第 n 步”。

在本节中,我们将讨论三个模拟的例子:

  • 模拟合成数据集以测试我们关于组织成员之间接受组织电子邮件和愿意捐赠之间相关行为的假设。这是最自助的例子,因为我们硬编码关系并生成包括for循环在内的表格数据。

  • 模拟合成数据集以探索出租车队的聚合行为,包括轮班时间和依赖时间的乘客频率。在这个数据集中,我们利用 Python 的面向对象属性以及生成器,在我们想要启动系统并观察其行为时非常有帮助。

  • 模拟磁性材料逐渐定向其个体磁元素的物理过程,这些元素最初处于无序状态,但最终会融合成一个有序的系统。在这个例子中,我们看到物理法则如何驱动时间序列模拟,并将自然的时间尺度插入到一个过程中。

自己动手完成工作

当您编写模拟程序时,需要牢记适用于系统的逻辑规则。在这里,我们通过示例详细说明了程序员通过确保数据合理性来完成大部分工作(例如,不指定按照不合逻辑顺序发生的事件)。

我们首先定义会员的成员资格宇宙,即我们有多少会员以及每个会员何时加入组织。我们还将每个会员与会员状态配对:

## python
>>> ## membership status
>>> years        = ['2014', '2015', '2016', '2017', '2018']
>>> memberStatus = ['bronze', 'silver', 'gold', 'inactive']

>>> memberYears = np.random.choice(years, 1000, 
>>>               p = [0.1, 0.1, 0.15, 0.30, 0.35])
>>> memberStats = np.random.choice(memberStatus, 1000, 
>>>               p = [0.5, 0.3, 0.1, 0.1])

>>> yearJoined = pd.DataFrame({'yearJoined': memberYears,
>>>                          'memberStats': memberStats})

请注意,这些行代码中已经内置了许多规则/假设来模拟。我们设定了会员加入年份的具体概率。我们还使会员的状态完全独立于他们加入的年份。在现实世界中,我们可能已经能够做得比这更好,因为这两个变量应该有一定的关联,特别是如果我们想要激励人们继续保持会员身份。

我们制作了一张表格,表明会员每周何时打开邮件。在这种情况下,我们定义了我们组织的行为:我们每周发送三封邮件。我们还定义了会员在邮件方面的不同行为模式:

  • 从不打开邮件

  • 邮件开启率/参与度保持不变

  • 参与度的增加或减少

我们可以想象根据老兵的轶事观察或者我们对影响数据的不可观察过程的新颖假设,使这一过程变得更加复杂和微妙:

## python
>>> NUM_EMAILS_SENT_WEEKLY = 3

>>> ## we define several functions for different patterns 
>>> def never_opens(period_rng):
>>>   return []

>>> def constant_open_rate(period_rng):
>>>   n, p = NUM_EMAILS_SENT_WEEKLY, np.random.uniform(0, 1)
>>>   num_opened = np.random.binomial(n, p, len(period_rng))
>>>   return num_opened

>>> def increasing_open_rate(period_rng):
>>>   return open_rate_with_factor_change(period_rng, 
>>>                                        np.random.uniform(1.01, 
>>>                                                          1.30))

>>> def decreasing_open_rate(period_rng):
>>>   return open_rate_with_factor_change(period_rng, 
>>>                                        np.random.uniform(0.5,  
>>>                                                          0.99))

>>> def open_rate_with_factor_change(period_rng, fac):
>>>     if len(period_rng) < 1 :
>>>         return [] 
>>>     times = np.random.randint(0, len(period_rng), 
>>>                                int(0.1 * len(period_rng)))    
>>>     num_opened = np.zeros(len(period_rng))
>>>     for prd in range(0, len(period_rng), 2):  
>>>         try:
>>>             n, p = NUM_EMAILS_SENT_WEEKLY, np.random.uniform(0, 
>>>                                                              1)
>>>             num_opened[prd:(prd + 2)] = np.random.binomial(n, p, 
>>>                                                            2)
>>>             p = max(min(1, p * fac), 0)
>>>         except:
>>>             num_opened[prd] = np.random.binomial(n, p, 1)
>>>     for t in range(len(times)):
>>>         num_opened[times[t]] = 0    
>>>     return num_opened

我们已经定义了模拟四种不同行为的函数:

从不打开我们发送给他们的邮件的会员

(never_opens())

每周打开邮件数量大致相同的会员

(constant_open_rate())

每周打开邮件数量逐渐减少的会员

(decreasing_open_rate())

每周打开邮件数量逐渐增加的会员

(increasing_open_rate())

我们确保那些随时间增加或减少参与度的人通过函数open_rate_with_factor_change()以及函数increasing_open_rate()decreasing_open_rate()进行了相同的模拟。

我们还需要设计一个系统来模拟捐赠行为。我们不希望过于天真,否则我们的模拟将无法给我们带来关于我们应该期待什么的见解。也就是说,我们希望在模型中加入我们对会员行为的当前假设,然后测试基于这些假设的模拟是否与我们在真实数据中看到的相符。在这里,我们使捐赠行为与会员打开的邮件数量 loosely 但不是确定性地相关:

## python
>>> ## donation behavior
>>> def produce_donations(period_rng, member_behavior, num_emails, 
>>>                       use_id, member_join_year):
>>>     donation_amounts = np.array([0, 25, 50, 75, 100, 250, 500, 
>>>                                  1000, 1500, 2000])
>>>     member_has = np.random.choice(donation_amounts)    
>>>     email_fraction = num_emails  / 
>>>                        (NUM_EMAILS_SENT_WEEKLY * len(period_rng))  
>>>     member_gives = member_has * email_fraction
>>>     member_gives_idx = np.where(member_gives 
>>>                                  >= donation_amounts)[0][-1]
>>>     member_gives_idx = max(min(member_gives_idx, 
>>>                                len(donation_amounts) - 2), 
>>>                            1)
>>>     num_times_gave = np.random.poisson(2) * 
>>>                        (2018 - member_join_year)
>>>     times = np.random.randint(0, len(period_rng), num_times_gave)
>>>     dons = pd.DataFrame({'member'   : [], 
>>>                          'amount'   : [],  
>>>                          'timestamp': []})

>>>     for n in range(num_times_gave):    
>>>         donation = donation_amounts[member_gives_idx 
>>>                      + np.random.binomial(1, .3)]
>>>         ts = str(period_rng[times[n]].start_time 
>>>                   + random_weekly_time_delta())
>>>         dons = dons.append(pd.DataFrame(
>>>                   {'member'   : [use_id],
>>>                    'amount'   : [donation],
>>>                    'timestamp': [ts]}))
>>>     
>>>     if dons.shape[0] > 0:
>>>         dons = dons[dons.amount != 0]
>>>         ## we don't report zero donation events as this would not
>>>         ## be recorded in a real world database 
>>>                                    
>>>     return dons

我们在这里采取了一些步骤,以确保代码产生逼真的行为:

  • 我们使总体捐款数量依赖于某人成为会员的时间长短。

  • 我们生成每位会员的财富状态,建立了一个关于行为的假设,即捐款金额与一个人用于捐款的稳定金额相关。

因为我们的会员行为与特定时间戳相关联,所以我们必须选择每个会员何时进行捐赠以及在那一周的哪个时间进行捐赠。我们编写了一个实用函数来在一周内选择随机时间:

## python
>>> def random_weekly_time_delta():
>>>     days_of_week = [d for d in range(7)]
>>>     hours_of_day = [h for h in range(11, 23)]
>>>     minute_of_hour = [m for m in range(60)]
>>>     second_of_minute = [s for s in range(60)]
>>>     return pd.Timedelta(str(np.random.choice(days_of_week))   
>>>                             + " days" ) +
>>>          pd.Timedelta(str(np.random.choice(hours_of_day))     
>>>                             + " hours" )  +
>>>          pd.Timedelta(str(np.random.choice(minute_of_hour))   
>>>                             + " minutes") +
>>>          pd.Timedelta(str(np.random.choice(second_of_minute)) 
>>>                             + " seconds")

您可能已经注意到,我们只从时间戳的小时范围中提取了 11 到 23 点之间的小时(hours_of_day = [h for h in range(11, 23)])。我们假设的是一个非常有限时区范围内的人们甚至只有一个单一时区的宇宙,因为我们不允许超出给定范围的小时。在这里,我们正在构建更多关于用户行为的基础模型。

因此,我们预期会看到我们的用户表现出统一的行为,就好像他们都在一个或几个相邻的时区内,而我们进一步推测合理的捐赠行为是人们从早上稍晚到晚上捐款,但不包括过夜和早上醒来时第一件事。

最后,我们将所有开发的组件放在一起,以一种方式模拟一定数量的会员和相关事件,确保事件仅在会员加入后发生,并且会员的电子邮件事件与其捐赠事件有一定关系(但关系不是不切实际小的):

## python
>>> behaviors        = [never_opens, 
>>>                    constant_open_rate,
>>>                    increasing_open_rate, 
>>>                    decreasing_open_rate]
>>> member_behaviors = np.random.choice(behaviors, 1000, 
>>>                                    [0.2, 0.5, 0.1, 0.2])

>>> rng = pd.period_range('2015-02-14', '2018-06-01', freq = 'W')
>>> emails = pd.DataFrame({'member'      : [], 
>>>                        'week'        : [], 
>>>                        'emailsOpened': []})
>>> donations = pd.DataFrame({'member'   : [], 
>>>                           'amount'   : [], 
>>>                           'timestamp': []})

>>> for idx in range(yearJoined.shape[0]):
>>>     ## randomly generate the date when a member would have joined
>>>     join_date = pd.Timestamp(yearJoined.iloc[idx].yearJoined) + 
>>>                   pd.Timedelta(str(np.random.randint(0, 365)) + 
>>>                                   ' days')
>>>     join_date = min(join_date, pd.Timestamp('2018-06-01'))
>>>  
>>>     ## member should not have action timestamps before joining
>>>     member_rng = rng[rng > join_date]    
>>>   
>>>     if len(member_rng) < 1:
>>>         continue
>>> 
>>>     info = member_behaviorsidx
>>>     if len(info) == len(member_rng):
>>>         emails = emails.append(pd.DataFrame(
>>>            {'member': [idx] * len(info), 
>>>             'week': [str(r.start_time) for r in member_rng], 
>>>             'emailsOpened': info}))
>>>         donations = donations.append(
>>>            produce_donations(member_rng, member_behaviors[idx], 
>>>                                 sum(info), idx, join_date.year))

然后,我们查看捐款的时间行为,以了解我们可能如何尝试进行进一步分析或预测。我们绘制了数据集每个月收到的捐款总额的总和(见图 4-1):

## python
>>> df.set_index(pd.to_datetime(df.timestamp), inplace = True)
>>> df.sort_index(inplace = True)
>>> df.groupby(pd.Grouper(freq='M')).amount.sum().plot()

图 4-1. 数据集每个月收到的捐款总额。

从 2015 年到 2018 年,捐款数和已打开的电子邮件数量看起来随着时间的推移有所增加。这并不令人惊讶,因为成员数量随时间增加,如成员的累积总和和加入年份所示。事实上,我们模型的一个内置假设是,一旦成员加入,我们就可以无限期地保留其成员资格。我们没有设定终止成员资格的任何规定,除非允许成员打开越来越少的电子邮件。即使在这种情况下,我们也留下了继续捐赠的可能性。我们在图 4-1 中看到了这种无限期持续会员资格(及相关的捐赠行为)的假设。我们可能应该回过头来改进我们的代码,因为无限期的会员资格和捐赠并不是一个现实的情景。

这不是一个经典的时间序列模拟,因此可能更像是生成表格数据的练习。但我们确实必须意识到时间序列:

  • 我们不得不做出关于我们的用户处于多少时间序列中的决策。

  • 我们必须对我们将随时间建模的趋势类型做出决策:

    • 在电子邮件的情况下,我们决定有三种趋势:稳定、增长和减少的电子邮件开启率。

    • 在捐款的情况下,我们使捐款成为一个稳定的行为模式,与成员在其生命周期内打开的电子邮件数量有关。这包括一个前瞻,但由于我们正在生成数据,这是一种决定成员在组织中整体亲和力的方式,这将导致更多的电子邮件打开,也会增加捐款的频率。

  • 我们必须小心确保在成员加入组织之前没有打开电子邮件或进行捐款。

  • 我们必须确保我们的数据不会进入未来,以使其对数据的消费者更具现实意义。请注意,对于模拟来说,如果我们的数据进入未来是可以的。

但它并不完美。这里提出的代码笨拙不堪,并且不能产生一个真实的宇宙。更重要的是,由于只有程序员检查了逻辑,他们可能会错过一些边界情况,导致事件按照不合逻辑的顺序发生。在运行模拟之前,建立外部度量和有效性标准是防止此类错误的一种保护措施之一。

我们需要一种能够强制实现逻辑和一致的软件宇宙。我们将在下一节中看一看 Python 生成器作为更好的选择。

构建一个自运行的模拟宇宙

有时候你有一个特定的系统,你想为该系统设置规则并观察其运行。也许你想设想一下独立成员访问你的应用程序将使用的宇宙,或者你想试图验证一个基于假设的外部行为的内部决策理论。在这些情况下,你希望看到个体代理如何随时间推移贡献到你的聚合度量中。由于生成器的可用性,Python 在这项工作中尤为合适。当你开始构建软件而不仅仅停留在分析时,即使你更喜欢 R,使用 Python 也是有道理的。

生成器允许我们创建一系列独立(或依赖的!)行为者,并让它们运转起来,观察它们的行为,而无需太多样板代码来跟踪一切。

在接下来的代码示例中,我们探索一个出租车模拟。¹ 我们想象一下,一群打算在不同时间开始工作的出租车会以何种方式进行集体行动。为了做到这一点,我们希望创建许多个体出租车,将它们释放在一个虚拟城市中,并让它们汇报它们的活动。

这样的模拟可能异常复杂。为了演示目的,我们接受将构建一个比我们想象中更简单的世界(“所有模型都是错误的…”)。我们首先尝试理解 Python 生成器是什么。

让我们首先考虑一种我编写的用于获取出租车识别号的方法:

## python
>>> import numpy as np

>>> def taxi_id_number(num_taxis):
>>>    arr = np.arange(num_taxis)
>>>    np.random.shuffle(arr)
>>>    for i in range(num_taxis):
>>>        yield arr[i]

对于那些不熟悉生成器的人,这里是前面的代码示例:

## python
>>> ids = taxi_id_number(10)
>>> print(next(ids))
>>> print(next(ids))
>>> print(next(ids))

可能会打印出以下内容:

7
2
5

这将迭代直到发出 10 个数字,此时它将退出生成器中保存的for循环,并发出StopIteration异常。

taxi_id_number()生成一次性对象,它们之间都是相互独立的,并且保持自己的状态。这是一个生成器函数。你可以把生成器想象成维护自己的一小组状态变量的小对象,当你希望许多对象并行运行时特别有用,每个对象都关注自己的变量。

在这个简单的出租车模拟案例中,我们将出租车分成不同的班次,并且使用生成器来指示班次。我们通过在特定时间开始班次设置不同的概率,白天中段调度更多的出租车,而不是在晚上或过夜的班次:

## python
>>> def shift_info():
>>>    start_times_and_freqs = [(0, 8), (8, 30), (16, 15)]
>>>    indices               = np.arange(len(start_times_and_freqs))
>>>    while True:
>>>        idx   = np.random.choice(indices, p = [0.25, 0.5, 0.25])
>>>        start = start_times_and_freqs[idx]
>>>        yield (start[0], start[0] + 7.5, start[1])

注意start_times_and_freqs。这是我们第一段代码,将有助于将其变成时间序列模拟。我们指出一天中的不同时间段有不同的出租车分配概率。此外,一天中的不同时间有不同的平均出租车次数。

现在我们创建一个更复杂的生成器,它将使用前面的生成器来建立单个出租车的参数,并创建单个出租车的时间线:

## python
>>> def taxi_process(taxi_id_generator, shift_info_generator):
>>>    taxi_id = next(taxi_id_generator)
>>>    shift_start, shift_end, shift_mean_trips = 
>>>                                    next(shift_info_generator)
>>>    actual_trips = round(np.random.normal(loc   = shift_mean_trips, 
>>>                                          scale = 2))
>>>    average_trip_time = 6.5 / shift_mean_trips * 60 
>>>    # convert mean trip time to minutes
>>>    between_events_time = 1.0 / (shift_mean_trips - 1) * 60
>>>    # this is an efficient city where cabs are seldom unused
>>>    time = shift_start
>>>    yield TimePoint(taxi_id, 'start shift', time)    
>>>    deltaT = np.random.poisson(between_events_time) / 60
>>>    time += deltaT
>>>    for i in range(actual_trips):
>>>        yield TimePoint(taxi_id, 'pick up    ', time)
>>>        deltaT = np.random.poisson(average_trip_time) / 60
>>>        time += deltaT
>>>        yield TimePoint(taxi_id, 'drop off   ', time)
>>>        deltaT = np.random.poisson(between_events_time) / 60
>>>        time += deltaT        
>>>    deltaT = np.random.poisson(between_events_time) / 60
>>>    time += deltaT        
>>>    yield TimePoint(taxi_id, 'end shift  ', time)

在这里,出租车访问生成器来确定其 ID 编号、班次开始时间以及其开始时间的平均出租车次数。从那里开始,它将在自己的时间轴上运行一定数量的行程,并将这些行程传递给调用此生成器上的next()的客户端。实际上,这个生成器为单个出租车产生了时间序列点。

出租车生成器产生了TimePoint,其定义如下:

## python
>>> from dataclasses import dataclass

>>> @dataclass
>>> class TimePoint:
>>>    taxi_id:    int
>>>    name: str
>>>    time: float

>>>    def __lt__(self, other):
>>>        return self.time < other.time

我们使用相对较新的dataclass装饰器简化代码(这需要 Python 3.7)。我建议所有使用 Python 的数据科学家熟悉这个新的对数据友好的 Python 添加。

Python 的 Dunder 方法

Python 的dunder方法,名称以双下划线开始和结束,是每个类的内置方法集。Dunder 方法在使用给定对象时会自动调用。这些方法有预定义的实现,可以在你自己定义类时覆盖。你可能希望这样做的原因有很多,例如在前面的代码中,我们希望仅基于它们的时间而不是基于它们的taxi_idname属性来比较TimePoint

Dunder 源于“double under”的缩写。

除了自动生成的TimePoint初始化器外,我们只需要其他两个 dunder 方法,__lt__(用于比较TimePoint)和__str__(用于打印TimePoint,这里没有显示)。我们需要比较,因为我们将把所有生成的TimePoint放入一个数据结构中,这个数据结构将按照它们的优先级来保持它们的顺序:一个优先队列是一个抽象数据类型,可以按任何顺序插入对象,但将按其优先级发射对象。

抽象数据类型

抽象数据类型是一种由其行为定义的计算模型,其行为由一组可能的操作和输入数据的枚举集合以及对于某些数据集的操作结果组成。

一个广为人知的抽象数据类型是先进先出(FIFO)数据类型。这要求对象从数据结构中被发射出来的顺序与它们被送入数据结构的顺序相同。程序员如何选择实现这一点是实现的问题,而不是定义的问题。

我们有一个模拟类来运行这些出租车生成器并保持它们的组装。这不仅仅是一个dataclass,因为它在初始化器中有相当多的功能,来将输入整理成一个合理的信息和处理数组。请注意,唯一面向公众的功能是run()函数:

## python
>>> import queue

>>> class Simulator:
>>>    def __init__(self, num_taxis):
>>>        self._time_points = queue.PriorityQueue()
>>>        taxi_id_generator = taxi_id_number(num_taxis)
>>>        shift_info_generator = shift_info()
>>>        self._taxis = [taxi_process(taxi_id_generator, 
>>>                                    shift_info_generator) for 
>>>                                             i in range(num_taxis)]        
>>>        self._prepare_run()        

>>>    def _prepare_run(self):
>>>        for t in self._taxis:
>>>            while True:
>>>                try:
>>>                    e = next(t)
>>>                    self._time_points.put(e)
>>>                except:
>>>                    break        

>>>    def run(self):
>>>        sim_time = 0
>>>        while sim_time < 24:
>>>            if self._time_points.empty():
>>>                break
>>>            p = self._time_points.get()
>>>            sim_time = p.time
>>>            print(p)

首先,我们创建所需数量的出租车生成器来表示正确数量的出租车。然后我们遍历每一个这些出租车,当它还有TimePoint时,将所有这些TimePoint推入优先队列。对象的优先级由我们对TimePoint类的__lt__实现决定,我们在其中比较开始时间。因此,当TimePoint被推入优先队列时,它们将准备好按时间顺序被发射出去。

我们运行模拟:

## python
>>> sim = Simulator(1000)
>>> sim.run()

下面是输出的样子(你的输出将会不同,因为我们还没有设置种子——每次运行代码时都会与上次迭代不同):

id: 0539 name: drop off    time: 23:58
id: 0318 name: pick up     time: 23:58
id: 0759 name: end shift   time: 23:58
id: 0977 name: pick up     time: 23:58
id: 0693 name: end shift   time: 23:59
id: 0085 name: end shift   time: 23:59
id: 0351 name: end shift   time: 23:59
id: 0036 name: end shift   time: 23:59
id: 0314 name: drop off    time: 23:59

在生成随机数时设置种子

当您编写生成随机数的代码时,您可能希望确保它是可重现的(例如,如果您想为通常是随机的代码设置单元测试,或者如果您试图调试并希望缩小变异源以便更容易调试)。为了确保随机数以相同的非随机顺序输出,您设置了一个种子。这是一个常见操作,因此有关如何在任何计算机语言中设置种子的指南。

我们已经将显示简单性舍入到最接近的分钟,尽管我们有更精细的数据可用。我们使用什么时间分辨率将取决于我们的目的:

  • 如果我们想为我们城市的人们制作一个教育展示,展示出出租车队对交通的影响,我们可以显示每小时的汇总。

  • 如果我们是一个出租车应用程序,并且需要了解服务器的负载情况,我们可能希望查看逐分钟甚至更高分辨率数据,以考虑我们的基础设施设计和容量。

我们决定按照“发生”的方式报告出租车的“时间点”。也就是说,我们报告出租车乘车的开始(“接人”),而不是结束时间,尽管我们很容易可以压缩这一信息。这是使时间序列更加真实的一种方式,因为在实时流中,您很可能会以这种方式记录事件。

请注意,与前一种情况类似,我们的时间序列模拟尚未生成时间序列。我们已经生成了一个日志,并可以通过多种方式使其成为时间序列,然而:

  • 在运行模拟的同时,将结果输出到 CSV 文件或时间序列数据库。

  • 运行某种在线模型,连接到我们的模拟以了解如何开发实时流数据处理管道。

  • 将输出保存到文件或数据库中,然后进行更多的后处理,以便以一种方便的形式(但可能与前瞻性风险相关)打包数据,例如将给定乘车的起始和结束时间配对,以研究出租车乘车时间在不同时间段的行为。

除了能够测试关于出租车系统动态的假设之外,模拟此数据还有几个优点。以下是一些情况,合成时间序列数据可能会有用:

  • 相对于已知的模拟底层动态,测试各种预测模型的优点。

  • 在等待真实数据时,为基于合成数据的数据构建管道。

作为时间序列分析员,您能够利用生成器和面向对象编程将大有裨益。此示例仅提供了一个如何利用此类知识简化生活并提高代码质量的例子。

对于广泛的模拟,请考虑基于代理的建模

我们在这里编码的解决方案还算可以,但需要大量样板来确保逻辑条件得到尊重。如果基于离散行动者的离散事件模拟能成为模拟时间序列数据的有用来源,则应考虑采用面向模拟的模块。SimPy模块是一个有帮助的选择,具有易于访问的 API,并具有相当多的灵活性,可以执行我们在本节中处理的各种模拟任务。

物理模拟

在另一种模拟场景中,您可能完全掌握了定义系统的物理定律。然而,这不一定是物理本身,它也可以适用于许多其他领域:

  • 金融量化研究人员通常会假设市场的“物理”规则。经济学家也是如此,尽管时间尺度不同。

  • 心理学家提出了关于人类决策方式的“心理物理学”规则。这些规则可以用来生成关于期望人类对各种选项反应的“物理”规则。

  • 生物学家研究系统如何在时间上响应各种刺激的规则。

一个了解简单物理系统某些规则的案例是模拟磁铁行为。这是我们要处理的情况,通过一个常见的统计力学模型称为伊辛模型²。我们将看看如何模拟其随时间的行为。我们将初始化磁性材料,使其各个磁性组件指向随机方向。然后,我们将观察这个系统如何在已知物理法则和几行代码的作用下演变成所有磁性组件指向相同方向的有序状态。

接下来我们讨论如何通过马尔可夫链蒙特卡洛(MCMC)方法实现这样的模拟,讨论该方法在一般情况下以及应用于这个特定系统时的工作原理。

在物理学中,MCMC 模拟可以用于了解个别分子中的量子跃迁如何影响该系统的聚集集合测量结果随时间的变化。在这种情况下,我们需要应用一些特定的规则:

  1. 在马尔可夫过程中,未来状态的转移概率仅依赖于当前状态(而不是过去信息)。

  2. 我们将施加一个物理特定的条件,要求能量的玻尔兹曼分布;即,T ij / T ji = e -b(E j -E i ) 。对于大多数人来说,这只是一个实现细节,非物理学家无需担心。

我们按以下方式实施 MCMC 模拟:

  1. 随机选择每个个体晶格点的起始状态。

  2. 对于每个时间步长,选择一个个体晶格点并翻转其方向。

  3. 根据您正在使用的物理定律计算此翻转导致的能量变化。在这种情况下,这意味着:

    • 如果能量变化为负,你正在过渡到一个更低能量状态,这将始终被偏爱,因此您会保持这种转换并继续到下一个时间步骤。

    • 如果能量变化不为负,你以 e (-energychange) 的接受概率接受它。这与规则 2 一致。

持续执行步骤 2 和 3,直到收敛,以确定您正在进行的任何综合测量的最可能状态。

让我们来看一下伊辛模型的具体细节。想象我们有一个由物体网格组成的二维材料,每个物体都有一个可以指向上或向下的微小磁铁。我们在时间零时随机放置这些微小磁铁,然后记录系统从随机状态到低温有序状态的演变。³

首先,我们配置我们的系统如下:

## python
>>> ### CONFIGURATION
>>> ## physical layout
>>> N           = 5 # width of lattice
>>> M           = 5 # height of lattice
>>> ## temperature settings
>>> temperature = 0.5
>>> BETA        = 1 / temperature

然后我们有一些实用方法,比如随机初始化我们的起始块:

>>> def initRandState(N, M):
>>>     block = np.random.choice([-1, 1], size = (N, M))
>>>     return block

我们还计算了相对于其邻居的中心状态对齐给定能量:

## python
>>> def costForCenterState(state, i, j, n, m):
>>>     centerS = state[i, j]
>>>     neighbors = [((i + 1) % n, j), ((i - 1) % n, j),
>>>                  (i, (j + 1) % m), (i, (j - 1) % m)]
>>>     ## notice the % n because we impose periodic boundary cond
>>>     ## ignore this if it doesn't make sense - it's merely a 
>>>     ## physical constraint on the system saying 2D system is like
>>>     ## the surface of a donut
>>>     interactionE = [state[x, y] * centerS for (x, y) in neighbors]
>>>     return np.sum(interactionE)

我们想要确定给定状态下整个块的磁化:

## python
>>> def magnetizationForState(state):
>>>    return np.sum(state)

这里是我们介绍了前面讨论过的 MCMC 步骤:

## python
>>> def mcmcAdjust(state):
>>>     n = state.shape[0]
>>>     m = state.shape[1]
>>>     x, y = np.random.randint(0, n), np.random.randint(0, m)
>>>     centerS = state[x, y]
>>>     cost = costForCenterState(state, x, y, n, m)
>>>     if cost < 0:
>>>         centerS *= -1
>>>     elif np.random.random() < np.exp(-cost * BETA):
>>>         centerS *= -1
>>>     state[x, y] = centerS
>>>     return state

现在我们实际运行一次模拟,我们需要一些记录以及对 MCMC 调整的重复调用:

## python
>>> def runState(state, n_steps, snapsteps = None):
>>>     if snapsteps is None:
>>>         snapsteps = np.linspace(0, n_steps, num = round(n_steps / (M * N * 100)),
>>>         						dtype = np.int32)
>>>     saved_states = []
>>>     sp = 0
>>>     magnet_hist = []
>>>     for i in range(n_steps):
>>>         state = mcmcAdjust(state)
>>>         magnet_hist.append(magnetizationForState(state))
>>>         if sp < len(snapsteps) and i == snapsteps[sp]:
>>>             saved_states.append(np.copy(state))
>>>             sp += 1
>>>     return state, saved_states, magnet_hist

然后我们运行模拟:

## python
>>> ### RUN A SIMULATION
>>> init_state = initRandState(N, M)
>>> print(init_state)
>>> final_state = runState(np.copy(init_state), 1000)

通过观察初始状态和最终状态,我们可以从这个模拟中得到一些见解(见 图 4-2)。

图 4-2. 一个 5 × 5 模拟铁磁材料的初始状态,每个状态随机选择为向上或向下旋转,概率相等。

在 图 4-2 中,我们研究了一个随机生成的初始状态。虽然您可能期望看到这两个状态更加混合,但请记住从概率上讲,得到完美的棋盘效应并不那么可能。尝试多次生成初始状态,您会发现这种看似“随机”或“50/50”的棋盘状态并不常见。然而,请注意,我们的大约一半站点起始于每种状态。还要意识到,您在初始状态中发现的任何模式很可能是您的大脑遵循的非常人类的倾向,即在那里没有任何模式的地方也看到模式。

我们将初始状态传递到 runState() 函数中,允许 1,000 个时间步骤后检查结果,并观察 图 4-3 中的结果。

这是在第 1,000 步拍摄的状态快照。此时至少有两个有趣的观察点。首先,与第 1,000 步相比,主导状态已经反转。其次,主导状态在数字上并不比第 1,000 步的另一主导状态更主导。这表明,即使在可能偏向于其它情况的情况下,温度也可能继续翻转站点。为了更好地理解这些动态,我们应该考虑绘制整体的聚合测量,比如磁化,或制作影片,在那里我们可以以时间序列格式查看我们的二维数据。

图 4-3. 在我们模拟中运行的最终低温状态,看起来像是在 1,000 个时间步骤时。

我们通过许多先前模拟的独立运行来获得随时间变化的磁化,如 图 4-4 所示:

## python
>>> we collect each time series as a separate element in results list
>>> results = []
>>> for i in range(100):
>>>     init_state = initRandState(N, M)
>>>     final_state, states, magnet_hist = runState(init_state, 1000)
>>>     results.append(magnet_hist)
>>> 
>>> ## we plot each curve with some transparency so we can see
>>> ## curves that overlap one another
>>> for mh in results:
>>>     plt.plot(mh,'r', alpha=0.2)

磁化曲线只是我们可以想象系统随时间演变的一个例子。我们还可以考虑记录 2D 时间序列,作为每个时间点总体状态的快照。或者可能有其他有趣的聚合变量在每一步测量,比如布局熵或总能量的测量。磁化或熵等量是相关的量,因为它们是每个晶格点状态几何布局的函数,但每个量略有不同的度量。

Figure 4-4. 对系统进入低温时可能出现磁化状态的 100 次独立模拟,即使每个原始晶格点是随机初始化的。

我们可以像讨论出租车数据那样使用这些数据,即使底层系统完全不同。例如,我们可以:

  • 利用模拟数据作为设置管道的动力。

  • 在我们费心清理真实世界数据进行建模之前,用这些合成数据测试机器学习方法,看看它们是否有助于物理数据。

  • 观看重要指标的电影般的图像,以开发关于系统更好的物理直觉。

关于模拟的最后说明

我们已经看过许多非常不同的例子,模拟测量描述随时间变化的行为。我们看过有关消费者行为(NGO 成员资格和捐赠)、城市基础设施(出租车接载模式)以及物理定律(随机磁性材料逐渐有序化)的模拟数据。这些例子应该使你感到足够自信,可以开始阅读模拟数据的代码示例,并且可以思考你自己的工作如何从模拟中受益。

过去,你可能在不知道如何测试数据假设或替代可能性的情况下做出关于数据的假设。模拟为你提供了这样的机会,这意味着你关于数据的讨论可以扩展到包括假设的例子和来自模拟的定量指标。这将在时间序列领域以及数据科学的其他分支中为你的讨论奠定基础,并开辟新的可能性。

统计模拟

统计模拟是模拟时间序列数据的最传统途径。当我们了解随机系统的基本动态并希望估计几个未知参数或查看不同假设对参数估计过程的影响时,它们尤其有用(我们将在本书后面看到一个例子)。即使对于物理系统,有时统计模拟效果也更好。

当我们需要确定我们对模拟精度的不确定性的定量度量时,时间序列数据的统计模拟也是非常有价值的。在传统的统计模拟中,例如 ARIMA 模型(将在第六章中讨论),误差的公式是被充分验证的,这意味着为了理解一个假设的基础统计模型的系统,您不需要运行许多模拟来对误差和方差进行数值断言。

深度学习模拟

深度学习模拟时间序列是一个新兴但有前途的领域。深度学习的优势在于即使在没有完全理解动态的情况下,也能捕捉时间序列数据中非常复杂的非线性动态。然而,这也是一个缺点,因为从业者在理解系统动态方面没有原则性的基础。

深度学习模拟也在隐私成为问题时提供了希望。例如,深度学习已被用于基于实际时间序列数据生成合成的异质时间序列数据,但不会泄露私人信息。如果可以确实地生成这样的数据集而不会泄露隐私信息,这将是非常宝贵的,因为研究人员可以访问大量(否则昂贵且侵犯隐私的)医疗数据。

更多资源

Cristóbal Esteban, Stephanie L. Hyland 和 Gunnar Rätsch, “用递归条件生成对抗网络生成实值(医学)时间序列,” 未发表手稿, 最后修订于 2017 年 12 月 4 日, https://perma.cc/Q69W-L44Z.

作者展示了如何利用生成对抗网络来生成看起来逼真的异质医疗时间序列数据。这是深度学习模拟可用于创建伦理、法律和(希望是)保护隐私的医疗数据集的一个示例,以便在医疗保健背景下为机器学习和深度学习提供更广泛的有用数据访问。

Gordon Reikard 和 W. Erick Rogers, “预测海洋波浪:将基于物理模型与统计模型进行比较,” 海岸工程 58 (2011): 409–16, https://perma.cc/89DJ-ZENZ.

本文提供了一个关于用物理或统计方法建模系统的两种截然不同方式的易于理解和实用的比较。研究人员得出结论,对于他们解决的特定问题,预测者感兴趣的时间尺度应该决定应用哪种范式。虽然本文是关于预测的,但模拟与之密切相关,并且相同的见解同样适用。

Wolfgang Härdle, Joel Horowitz 和 Jens-Peter Kreiss, “时间序列的自举方法,” 国际统计评论 / International Statistical Review 71, no. 2 (2003): 435–59, https://perma.cc/6CQA-EG2E.

一篇经典的 2005 年综述文章,讨论了统计模拟时间序列数据在处理时间依赖性方面的困难。作者在一本高度技术性的统计期刊中解释了,为什么时间序列数据的自举方法落后于其他类型数据的方法,并且在写作时可用的有前景的方法是什么。由于技术发展并未有太大改变,这篇文章是一篇有用的、尽管具有挑战性的阅读材料。

¹ 本例受到 Luciano Ramalho 的书籍《流畅的 Python》(O'Reilly 2015)的启发。我强烈推荐阅读该书的完整仿真章节,以提高你的 Python 编程技能,并看到基于代理的仿真的更多精彩机会。

² Ising 模型是一个众所周知且广泛教授的经典统计力学模型,用于研究磁铁。如果你有兴趣了解更多,你可以在编程和物理上下文中找到许多此模型的代码示例和进一步讨论。

³ Ising 模型更常用于理解铁磁体的平衡态,而不是考虑铁磁体如何进入平衡态的时间方面。然而,我们将随时间的演变视为时间序列。

第五章:存储时间数据

时间序列数据的价值通常体现在回顾性而非实时流式场景中。因此,对于大多数时间序列分析来说,存储时间序列数据是必要的。

一个良好的存储解决方案应该能够在不需要大量计算资源投入的情况下,实现数据的轻松访问和可靠性。在本章中,我们将讨论设计时间序列数据存储时应考虑的数据集的各个方面。我们还将讨论 SQL 数据库、NoSQL 数据库以及各种平面文件格式的优势。

设计通用的时间序列存储解决方案是一项挑战,因为有许多不同种类的时间序列数据,每种数据都具有不同的存储、读写和分析模式。一些数据将被反复存储和检查,而其他数据只在短时间内有用,之后可以完全删除。

这里有几个时间序列存储的使用案例,它们具有不同的读、写和查询模式:

  1. 您正在收集生产系统的性能指标。您需要将这些性能指标存储多年,但数据变老越久,它的详细程度就越低。因此,您需要一种存储形式,能够随着信息老化自动进行降采样和剪辑。

  2. 您可以访问远程开源时间序列数据存储库,但需要在您的计算机上保留本地副本以减少网络流量。远程存储库将每个时间序列存储在一个可通过 Web 服务器下载的文件夹中,但您希望将所有这些文件合并到一个单一的数据库中,以简化操作。数据应该是不可变的,并且能够无限期地存储,因为它旨在是远程存储库的可靠副本。

  3. 您通过在不同时间尺度上整合各种数据源,并进行不同的预处理和格式化,创建了自己的时间序列数据。数据的收集和处理是费力且耗时的。您希望将数据以最终形式存储,而不是反复运行预处理步骤,但您也希望保留原始数据,以备稍后探索预处理替代方案。随着您开发新的机器学习模型并随时间添加新的、更新的原始数据,您预计会经常回顾处理和原始数据。即使您为了分析而对数据进行降采样或剪辑,您也永远不会在存储中降低数据质量。

这些使用案例在其对系统的主要需求方面相当多样化:

性能随大小变化的重要性

在第一个用例中,我们将寻找一个能够集成自动化脚本以删除旧数据的解决方案。我们不会担心系统如何为大数据集进行扩展,因为我们计划保持数据集较小。相比之下,在第二和第三个用例中,我们预期会有稳定的大型数据集(用例 2)或大型且增长中的数据集(用例 3)。

随机访问数据点与顺序访问数据点的重要性

在用例 2 中,我们预计所有数据都将以相等的方式访问,因为这些时间序列数据在插入时都是相同的“年龄”,并且都引用了有趣的数据集。相比之下,在用例 1 和 3 中(尤其是在用例 1 中,根据前述描述),我们预计最近的数据会更频繁地被访问。

自动化脚本的重要性

用例 1 看起来可能会自动化,而用例 2 不需要自动化(因为数据是不可变的)。用例 3 表明几乎没有自动化,但需要获取和处理所有数据部分,而不仅仅是最近的数据。在用例 1 中,我们希望找到一个能够与脚本或存储过程集成的存储解决方案,而在用例 3 中,我们希望找到一个允许轻松定制数据处理的解决方案。

仅仅三个例子就已经很好地展示了一种通用时间序列解决方案需要满足的众多用例。

在实际用例中,您将能够定制您的存储解决方案,而不必担心找不到适合所有用例的工具。话虽如此,您始终会在类似的可用技术范围内进行选择,这些技术往往归结为以下几种:

  • SQL 数据库

  • NoSQL 数据库

  • 平面文件格式

在本章中,我们涵盖了所有三种选项,并讨论了每种选项的优缺点。当然,具体情况取决于具体用例,但本章将为您提供在寻找适合您用例的时间序列存储选项时所需的基础。

我们首先讨论在选择存储解决方案时应该问什么问题。然后我们看一下 SQL 与 NoSQL 的大辩论,并检查一些最流行的时间序列存储解决方案。最后,我们考虑设置政策来让旧的时间序列数据过期并被删除。

定义需求

当您考虑存储时间序列数据时,您需要问自己一些问题:

  • 你将存储多少时间序列数据?这些数据会以多快的速度增长? 你需要选择一个适合你预期数据增长速度的存储解决方案。从事时间序列工作的数据库管理员通常会对时间序列数据集的增长速度感到惊讶,尤其是那些从事事务性数据集工作的管理员。

  • 你的测量是否趋向于无尽的更新通道(例如,持续不断的网页流量更新),还是独立的事件(例如,过去 10 年每个主要美国假期的每小时空中交通时间序列)? 如果你的数据像一个无尽的通道,你大多数情况下会查看最近的数据。另一方面,如果你的数据是一个由独立时间序列分割成单独事件的集合,那么时间上更远的事件可能仍然相当有趣。在后一种情况下,随机访问是更可能的模式。

  • 你的数据是定期还是不定期分布的? 如果你的数据是定期分布的,你可以提前更准确地计算你预期收集的数据量以及数据的输入频率。如果你的数据是不定期分布的,你必须准备一个不太可预测的数据访问方式,能有效地处理静止期和写入活动期。

  • 你是否将持续收集数据,还是你的项目有一个明确定义的结束? 如果你的数据收集有一个明确定义的结束,这将更容易知道你需要适应多大的数据集。然而,许多组织发现一旦开始收集某种类型的时间序列,就不想停止!

  • 你将如何处理你的时间序列?你需要实时可视化吗?为神经网络预处理数据以进行数千次迭代?高度可用于大型移动用户群体的分片数据? 你的主要用例将表明你更可能需要顺序访问还是随机访问你的数据,以及延迟在你选择存储格式时应扮演的重要角色。

  • 你将如何剔除或降低数据采样率?你将如何防止无限增长?一个时间序列中个别数据点的生命周期应该是什么样子? 不可能永久存储所有事件。最好是系统地和提前做出关于数据删除策略的决定,而不是临时决定。你越是能在前期做出承诺,你在存储格式选择上就能做出更好的选择。关于这点,我们将在下一节详细讨论。

这些问题的答案将表明你是否应该存储原始数据还是处理后的数据,数据是否应该根据时间或其他轴来定位在内存中,以及是否需要以便于读写的形式存储你的数据。用例会有所不同,所以你应该在每一个新数据集上做一次全新的盘点。

提示

分片 数据是一个大数据系统中的一部分,但分散到更小、更可管理的块中,通常分布在网络上的多个服务器上。

实时数据与存储数据

在考虑数据的存储选项时,理解数据的生命周期至关重要。对于数据的实际使用案例能越实际,你需要保存的数据就越少,你需要担心找到最佳存储系统的时间也就越少,因为你不会迅速积累到无法处理的数据量。通常,组织会过度记录感兴趣的事件,并害怕丢失它们的数据存储,但是将更多数据存储在无法处理的形式中远不如在有意义的时间尺度上存储聚合数据有用。

对于短暂的数据,比如仅用于确保一切正常的性能数据,可能永远不需要将数据存储在收集的形式中,至少不需要很长时间。这对于事件驱动数据尤为重要,其中没有单个事件重要,而是感兴趣的是聚合统计数据。

假设你运行一个 Web 服务器,记录并向你报告每个移动设备加载特定网页所需的时间。结果是不规则的时间序列,可能看起来像表 5-1 所示。

表 5-1. Web 服务器时间序列

时间戳 加载页面时间
2018 年 4 月 5 日晚上 10:22:24 23 秒
2018 年 4 月 5 日晚上 10:22:28 15 秒
2018 年 4 月 5 日晚上 10:22:41 14 秒
2018 年 4 月 5 日晚上 10:23:02 11 秒

由于多种原因,你可能对页面加载时间的任何单独测量都不感兴趣。你希望聚合数据(例如,每分钟平均加载时间),即使聚合统计数据也只有短暂的兴趣。假设你在那台服务器上通宵值班。你希望确保在你负责期间性能良好。你可以简化为你值班 12 小时的数据点,这将包含你可能需要的大部分信息,如 Table 5-2 所示。

表 5-2. 值班时段简化数据点

时间段 最流行的访问小时 加载次数 平均加载时间 最大加载时间
2018 年 4 月 5 日晚上 8 点至次日早上 8 点 23 时 3,470 21 秒 45 秒

在这种情况下,你不应计划无限期地存储单个事件。相反,你应该建立一个存储解决方案,仅提供临时存储的个体事件,直到数据进入其最终形式。通过防止数据暴增开始之前的操作,你将为自己和同事节省很多麻烦。与无人感兴趣的 3,470 个单独事件相比,你将拥有易于访问和紧凑的感兴趣数据。在可能的情况下,你应该通过聚合和去重简化数据存储。

接下来,我们考虑几种减少数据量而不丢失信息的机会。

缓慢变化的变量

如果你正在存储一个状态变量,请考虑只记录数值发生变化的数据点。例如,如果你每隔五分钟记录一次温度,你的温度曲线可能看起来像一个阶梯函数,特别是如果你只关心最接近度数的值。在这种情况下,存储重复数值是不必要的,通过不这样做你可以节省存储空间。

嘈杂的高频数据

如果你的数据很嘈杂,有理由不太关心任何特定的数据点。考虑在记录数据点之前对它们进行聚合,因为高噪声水平使得任何单个测量值的价值降低。当然,这将是相当特定于领域,并且你需要确保下游用户仍然能够评估其目的中测量数据中的噪声。

过时数据

数据越老,你的组织使用它的可能性就越小,除非以非常一般的方式。每当你开始记录一个新的时间序列数据集时,你应该预先考虑时间序列数据何时可能变得不相关:

  • 是否有自然的过期日期?

  • 如果不能,你能查看一下你的分析部门过去的研究,看看它们到底可以追溯到多远吗?你的数据集中最老的数据上次真正被 Git 仓库中的任何脚本访问是什么时候?

如果你能自动化地删除数据,而不会妨碍数据分析工作,你将通过减少可扩展性的重要性或者减少庞大数据集上慢查询的负担,来改善你的数据存储选项。

到目前为止,我们已经讨论了时间序列存储的一般使用情况范围。我们还回顾了一组关于如何生成和分析时间序列数据集的查询,以便这些查询可以指导我们选择存储格式。我们现在回顾两种常见的时间序列存储选项:数据库和文件。

数据库解决方案

对于几乎所有的数据分析师或数据工程师来说,数据库是如何存储数据的直观而熟悉的解决方案。与关系型数据一样,数据库通常是处理时间序列数据的良好选择。特别是当你需要一个开箱即用的解决方案时,其中具有经典数据库特性之一:

  • 可扩展到多个服务器的存储系统

  • 低延迟读/写系统

  • 已经可以计算常用指标的函数(例如在分组查询中计算平均值,其中分组可以应用于时间指标)

  • 可用于调整系统性能和分析瓶颈的故障排除和监控工具

这些是选择数据库而不是文件系统的充分理由,其中之一,特别是在处理新数据集时,你应该始终考虑数据库解决方案。数据库,特别是 NoSQL 数据库,可以帮助你保持灵活性。此外,与单独处理文件相比,数据库将使您的项目更快地启动,因为您将需要的大部分样板代码已经就位。即使最终决定采用文件存储解决方案(大多数人不会这样做),首先使用数据库可以帮助您确定在新的数据处理成熟时如何组织自己的文件结构。

在本节的其余部分,我们将介绍 SQL 和 NoSQL 数据库在时间序列中的各自优势,然后讨论目前流行的时间序列应用数据库选项。

好消息是,时间序列图表似乎是目前数据库领域中增长最快的类别,因此您可以期待在未来看到更多甚至更好的时间序列数据库解决方案选择。

SQL 与 NoSQL 的比较

SQL 与 NoSQL 在时间序列数据库社区中的辩论与更广泛的领域一样活跃。许多专家数据库管理员坚持认为 SQL 是唯一的选择,并且没有任何形式的数据不能通过一组良好的关系表格进行很好地描述。尽管如此,在实践中,当组织试图扩展 SQL 解决方案以适应大量时间序列数据时,通常会出现性能下降的情况,因此始终值得考虑 NoSQL 解决方案,特别是如果您正在寻找一个开放的解决方案,可以扩展以适应时间序列数据收集开始时看不到有限时间范围的情况。

尽管 SQL 和 NoSQL 解决方案都可以适用于时间序列数据,但我们首先通过探索时间序列数据与 SQL 数据库开发的数据类型之间的不同,来激励我们讨论将数据库逻辑应用于时间序列数据的困难。

最初启发 SQL 数据库的数据特征

通过回顾 SQL 解决方案的历史,我们可以最好地理解 SQL 式思维与时间序列数据之间的不匹配。SQL 解决方案基于事务数据,这是完全描述离散事件所需的任何数据。事务由反映许多主键的属性组成,例如产品、参与者、时间和交易的价值。请注意,时间可以作为一个主键存在,但只能作为众多主键之一,而不是作为信息的主要轴。

事务数据有两个重要特征,与时间序列需求大不相同:

  • 现有数据点经常会更新。

  • 数据访问有些随机,因为不需要底层排序。

时间序列数据的特征

时间序列数据详细记录了某物的整个历史,而事务记录仅告诉我们最终状态。因此,时间序列数据通常不需要更新,这意味着随机访问的写操作不是首要考虑的。

这意味着在设计数据库用于时间序列时,性能目标对几十年来 SQL 数据库设计的关键性并不重要。事实上,考虑到在设计时间序列数据库时的目标,我们有着非常不同的优先级,因为我们将如何使用时间序列数据。我们时间序列数据使用案例的主要特征包括:

  • 写操作优先于读操作。

  • 数据的写入、读取和更新并非随机顺序,而是按照时间顺序相关的顺序进行。

  • 并发读取远比事务数据更可能发生。

  • 除了时间本身,几乎没有其他主键。

  • 大量删除比单个数据点删除更常见。

这些特性支持使用 NoSQL 数据库,因为许多通用应用的 NoSQL 数据库提供了时间序列数据库所需的大部分功能,尤其是对写操作的强调优于读操作。从概念上讲,NoSQL 数据库与时间序列数据非常匹配,因为它们本身就反映了时间序列数据收集的各个方面,例如并非所有数据点都会收集所有字段。NoSQL 的灵活模式与时间序列数据天然契合。当前推动 NoSQL 数据库流行的很大一部分数据就是时间序列数据。

因此,开箱即用的 NoSQL 数据库在写操作方面往往比 SQL 数据库表现更好。图 5-1 展示了用于时间序列数据点插入(写操作)的常用 SQL 数据库与常用 NoSQL 数据库的性能比较。

图 5-1. 为了时间序列使用而设计的数据库的一个共同特征是,数据插入速率与数据库大小成正比。与传统的 SQL 存储解决方案相比,这是一个显著的优势。

如何在 SQL 和 NoSQL 之间做出选择

或许会让人觉得我在推动你使用 NoSQL,但 SQL 数据库也有许多良好的使用场景。在考虑自己的数据时,请记住这一适用于数据存储的原则,无论是在 SQL 数据库、NoSQL 数据库还是纯文本文件中:那些通常在同一时间被请求的数据应该存储在相同的位置。这是无论你的使用场景如何都是最重要的因素。

许多会议演示和博客文章把 NoSQL 解决方案宣扬为精确迎合高写入低更新情景的解决方案,其中数据不会随机排序。尽管如此,SQL 数据库仍然是一种可行且经常使用的时间序列存储选项。特别是,在一些对传统 SQL 数据库及其内存结构的重点进行架构变更后,这些挑战可以得到应对,同时保留 SQL 的一些优势。例如,如何简单且看似显而易见地构建 SQL 表的内存表示以考虑时间,可以显著提升性能。

最终,NoSQL 和 SQL 之间的区别高度依赖于实现,并不像它们被宣传的那样系统化或重要。让你的数据决定你选择这些技术中的某一个具体实现。当你考虑你的时间序列数据的属性和访问使用模式时,你可以记住一些一般的限制:

SQL 在时间序列中的优点

  • 如果你的时间序列存储在 SQL 数据库中,你可以轻松地将其与存储在该数据库中的相关横截面数据关联起来。

  • 分层时间序列数据与关系表是自然匹配的。适当的 SQL 模式集将帮助你组织相关的时间序列,并清晰地划分层次结构,而这些在 NoSQL 解决方案中可能会比较零散地分布。

  • 如果你正在创建基于交易数据的时间序列,在这种情况下最好将数据存储在 SQL 数据库中,这样可以轻松进行验证、交叉引用等操作。

NoSQL 在时间序列中的优点

  • 写入速度快。

  • 如果你对未来数据不了解,设计一个智能和健壮的模式将是很困难的,它们是不错的选择。

  • 对于不熟悉的用户来说,这些数据库通常是开箱即用的更高性能解决方案,因为你不太可能设计一个笨拙的模式或者被锁定在一个平庸的模式设计中。

流行的时间序列数据库和文件解决方案

现在我们将讨论一些流行的时间序列数据的数据库解决方案。这些给你一种可用性的感觉,除了传统的 SQL 解决方案之外。请注意,这里讨论的技术占据了一个拥挤和碎片化的技术景观。今年常用的技术明年可能不那么流行。因此,这个讨论不应被视为具体的技术推荐,而更像是一组样本,帮助说明市场的当前状态。

时间序列专用数据库及相关监控工具

首先,我们讨论专门用于存储和监控时间序列数据的工具。特别是,我们看一下时间序列数据库(InfluxDB)和另一款性能监控工具,它也可以作为时间序列存储解决方案(Prometheus)。每个工具的优势必然反映了其独特的重点和使用模式。

InfluxDB

InfluxDB 是一个专门针对时间序列的数据库,根据其在 GitHub 项目网页上的描述

InfluxDB 是一个开源时间序列数据库,用于记录度量、事件并进行分析。

在 InfluxDB 中,数据按时间序列组织。InfluxDB 中的数据点包括:

  • 一个时间戳

  • 指示测量内容的标签

  • 一个或多个键/值字段(例如 temperature=25.3

  • 包含元数据标签的键/值对

作为一个时序感知的数据库,InfluxDB 会自动为任何未带时间戳的数据点添加时间戳。此外,InfluxDB 使用类似于 SQL 的查询语言,例如:

SELECT * FROM access_counts WHERE value > 10000

InfluxDB 的其他优势包括:

  • 数据保留选项,允许您轻松自动指定和删除陈旧数据

  • 高数据摄取速度和激进的数据压缩

  • 允许为个别时间序列添加标签,以便快速索引与特定条件匹配的时间序列

  • 成为成熟的TICK 堆栈的一部分,这是一个用于捕获、存储、监控和显示时间序列数据的平台

还有许多其他的时间序列专用数据库;目前 InfluxDB 是最流行的,因此您最有可能遇到它。它提供的选项是时间序列数据存储中常见的期望属性。

作为一个数据库,InfluxDB 是一个推送式系统,这意味着在使用它时,您将数据推送到数据库进行摄取。这与我们将讨论的下一个选项 Prometheus 不同。

鉴于这些规格,像 InfluxDB 这样的解决方案从一开始就以时序感知的方式提供所有一般功能。这意味着您可以利用现有的 SQL 技能,同时还能从捕获但控制时序数据增长的需求中获益。最后,在捕获时序数据时,您还可以获得所需的快速写入。

Prometheus

Prometheus 描述自己 为一个“监控系统和时间序列数据库”,通过 HTTP 工作。这个描述表明它的一般重点:首先是监控,其次是存储。Prometheus 的一个巨大优势在于它是一个拉取式系统,这意味着用于拉取数据以创建时间序列的逻辑以及拉取的频率可以轻松调整和检查。

Prometheus 在紧急情况下是一个很好的资源,因为它是原子性和自我依赖的。然而,由于其拉取式架构,不能保证完全实时或准确。虽然它应该是快速和粗糙性能监控的首选技术,但不适用于数据必须是 100%准确的应用程序。

Prometheus 使用称为 PromQL 的功能表达语言进行查询:

access_counts > 10000

Prometheus 还通过 PromQL 提供了一个 API,用于许多常见的时间序列任务,甚至包括如何做出预测(predict_linear())和计算时间序列的每单位时间增长率(rate())等复杂功能。通过简单的接口也提供了时间段内的聚合。相比于 InfluxDB,Prometheus 更强调监控和分析,因此自动化数据整理功能较少。

Prometheus 是一个有用的时间序列存储解决方案,特别适用于实时流应用和数据可用性至关重要的场景。由于其自定义脚本语言和较少类似数据库的架构与 API,它具有较陡的学习曲线,但仍被许多开发人员广泛使用和喜爱。

通用 NoSQL 数据库

虽然时间序列特定的数据库提供了许多优势,但您也可以考虑更通用的 NoSQL 数据库的情况。这些数据库基于文档结构而不是结构,并且通常不具备专门用于时间序列的显式功能。

尽管 NoSQL 数据库的灵活模式对于时间序列数据仍然很有用,特别是对于数据集的生命周期中数据收集的节奏和输入通道数量可能会发生变化的新项目。例如,时间序列可能最初只包含一个数据通道,但逐渐增加到包含更多种类的数据,所有数据都有时间戳。后来,可能会决定一些输入通道并不特别有用,它们可能会被停用。

在这种情况下,将数据存储在 SQL 表中将会因几个原因而困难,并导致大量 NaN,即数据不可用时的标记。相比之下,NoSQL 数据库在数据不可用时会简单地保留这些通道的缺失,而不是在矩形数据存储中标记大量 NaN。

一种受欢迎且性能出色的 NoSQL 时间序列数据库是 MongoDB。Mongo 特别关注其作为时间序列数据库的价值,并积极推动开发适合物联网的架构和指导。它提供了高级聚合特性,可用于按时间和与时间相关的分组的聚合操作,并提供了许多自动化流程来将时间分割为人类相关的标记,例如星期几或每月的日期:

  • $dayOfWeek

  • $dayOfMonth

  • $hour

此外,Mongo 专注于展示如何处理时间序列的广泛文档工作。¹ 一套准备好的文档和机构专注于时间序列数据明确意味着用户可以期待这个数据库会继续开发更多面向时间的功能。

然而,比所有这些功能更有用的是 Mongo 在随时间演变的架构上的灵活性。如果你在处理不断变化的数据收集实践的快速演变的时间序列数据集上工作,这种架构的灵活性将为你节省大量麻烦。

例如,想象一下一家医疗创业公司可能发生的时间序列数据收集实践:

  1. 你的创业公司推出了一款血压应用,并只收集两个指标,收缩压和舒张压,鼓励用户每天多次测量…

  2. 但是你的用户希望得到增强的生活方式建议,并且他们乐意为你提供更多的数据。用户提供从他们的生日到每月体重记录再到每小时步数和卡路里计数等一切。显然,这些不同类型的数据以截然不同的节奏进行收集…

  3. 但后来你意识到一些数据并不是很有用,所以你停止了收集它…

  4. 但是即使你不再使用,你的用户也会想念你的数据显示,所以你重新开始收集最受欢迎的数据…

  5. 然后,你最大市场之一的政府通过了关于健康数据存储的法律,你需要清除那些数据或加密它,因此你需要一个新的加密字段…

  6. 并且变化还在继续。

当你需要像这样的应用程序中描述的查询和架构灵活性时,你可以选择一个提供了合理平衡的通用 NoSQL 数据库,既具有时间特定性又具有更一般灵活性。

与时序特定数据库相比,通用 NoSQL 数据库的另一个优势是你可以更轻松地将非时序数据集成到同一数据库中,以便跨数据集引用相关数据。有时候,通用 NoSQL 数据库恰好是性能考量和类似 SQL 功能的完美结合,而无需为时序功能优化 SQL 数据库架构的聪明办法。

在本节中,我们已经研究了 NoSQL 数据库解决方案²,并调查了一些当前流行的选项。虽然我们可以预期主导市场的特定技术随着时间的推移而演变,但这些技术操作的一般原则和它们提供的优势将保持不变。作为一个快速回顾,我们在不同类型的数据库中发现的一些优势包括:

  • 高读取或写入容量(或两者兼有)

  • 灵活的数据结构

  • 推送或拉取数据摄取

  • 自动化数据修剪流程

时序数据库专用于时序特定任务,可以提供最多的开箱即用自动化功能,但其模式灵活性较低,整合相关的横截面数据的机会也较少,相比之下,通用的 NoSQL 数据库更具有这些特点。

一般而言,数据库比平面文件存储格式提供更大的灵活性,但这种灵活性意味着数据库不如简单的平面文件存储格式流畅,并且在 I/O 性能上也较差,接下来我们将讨论这一点。

文件解决方案

总而言之,数据库是一种集成了脚本和数据存储功能的软件。它本质上是一个包装在特殊软件中的平面文件,负责使该文件尽可能安全且易于使用。

有时候,摆脱这个外部层并全面承担数据存储的责任是有意义的。虽然这在商业应用中并不常见,但在科学研究中经常这样做,在极少数的工业应用(如高频交易)中,速度至关重要。在这种情况下,分析师将设计一个更复杂的数据管道,涉及分配存储空间、打开文件、读取文件、关闭文件、保护文件等,而不仅仅是编写一些数据库查询。

如果符合以下任何条件,平面文件解决方案是一个不错的选择:

  • 你的数据格式已经成熟,因此可以承诺在相当长的时间内使用特定规范。

  • 你的数据处理受 I/O 限制,因此在加速开发时间上投入是有意义的。

  • 你不需要随机访问,而可以顺序读取数据。

在本节中,我们简要概述了一些常见的平面文件解决方案。你还应该记住,你始终可以创建自己的文件存储格式,尽管这相当复杂,并且通常只有在你使用高性能语言(如 C++或 Java)时才值得这样做。

如果你的系统已经成熟且对性能要求敏感到足以支持平面文件系统,那么即使可能需要将数据迁出数据库,实施平面文件系统也有几个优点。这些优点包括:

  • 平面文件格式与系统无关。如果需要共享数据,只需以已知的可共享格式提供文件即可。无需请求合作者远程访问你的数据库或设置他们自己的镜像数据库。

  • 平面文件格式的 I/O 开销必然比数据库少,因为这只涉及对平面文件的简单读取操作,而不是对数据库的检索和读取操作。

  • 平面文件格式编码了数据应该读取的顺序,而并非所有数据库都会这样做。这强制执行了数据的串行读取,这在某些情况下是可取的,比如用于深度学习训练体制。

  • 与数据库相比,您的数据将占用比较小的内存空间,因为您可以最大程度地利用压缩机会。相关地,您可以调整数据压缩程度,以明确平衡在应用程序中最小化数据存储占用和 I/O 时间的愿望。更多的压缩将意味着更小的数据占用,但更长的 I/O 等待时间。

NumPy

如果您的数据纯粹是数字,一种广泛使用的选择是 Python NumPy 数组。NumPy 数组可以轻松保存为各种格式,并且有许多基准出版物比较它们的相对性能。例如,array_storage_benchmark GitHub 仓库 专为测试各种 NumPy 文件格式的效率和速度而设计。

NumPy 数组的缺点是它们只有一个数据类型,这意味着您不能自动存储异构时间序列数据,而必须考虑是否可以将单一数据类型用于原始或处理后的数据(尽管有办法绕过此限制)。另一个缺点是,NumPy 数组不自然地为行或列添加标签,因此没有直接的方法为数组的每一行添加时间戳,例如。

使用 NumPy 数组的优点是可以使用多种选项保存,包括占用空间更小并且比数据库解决方案具有更快 I/O 的压缩二进制格式。从分析和存储的角度来看,它也是一种开箱即用的性能良好的数据结构。

Pandas

如果您希望轻松标记数据或轻松存储异构时间序列数据(或两者兼有),请考虑不那么流畅但更灵活的 Pandas 数据框架。当您有一个时间序列,其中包含许多不同类型的数据(例如事件计数(整数)、状态测量(浮点数)和标签(字符串或独热编码))时,Pandas 数据框架特别有用。在这种情况下,您可能希望坚持使用 Pandas 数据框架(还要记住,“pandas”这个名字实际上来自于“面板数据”的省略,因此对许多用例来说,这是一种自然的格式)。

Pandas 数据框架被广泛使用,有多个在线资源可用于比较用于存储此类数据的各种格式

标准 R 等效物

存储 R 对象的本地格式是 .Rds.Rdata 对象。这些都是二进制文件格式,因此它们在压缩和 I/O 方面必然比基于文本的格式更有效率,从这个角度来看,它们类似于 Python 中的 Pandas 数据框架。相关地,feather 格式 可用于在 R 和 Python 中以语言无关的文件格式保存数据框架。对于 R 用户来说,本地二进制格式(当然)是性能最佳的。表格 5-3 比较了文件格式选项。

表格 5-3. 文件格式选项的大小和性能比较

格式名称 相对大小 相对加载时间
.RDS 1 倍 1 倍
feather 2 倍 1 倍
csv 3 倍 20 倍

正如我们所见,本地格式明显是减少存储空间和 I/O 的优胜者,而那些使用文本文件格式而不是二进制文件的人在存储和 I/O 减速方面会付出沉重代价。

Xarray

当你的时间序列数据扩展到许多维度时,可能是时候考虑更工业化的数据解决方案了,即Xarray,这有多个理由是有用的:

  • 命名维度

  • 像 NumPy 这样的向量化数学操作

  • 类似 Pandas 的分组操作

  • 允许根据时间范围进行索引的类似数据库的功能

  • 多种文件存储选项

Xarray 是一种支持许多时间序列特定操作的数据结构,例如在时间上索引和重采样、插值数据以及访问日期时间的各个组成部分。Xarray 作为一个高性能科学计算工具构建,对于时间序列分析应用来说被严重低估和轻视。

Xarray 是 Python 中的一种数据结构,它提供了多种存储选项。它实现了 pickle 和另一种称为netCDF的二进制文件格式,这是一种跨平台和多语言支持的通用科学数据格式。如果你希望在 Python 中提升你的时间序列应用能力,Xarray 是一个不错的起点。

正如您所见,有许多选项用于时间序列数据的平面文件存储,一些选项带有相关功能(如 Xarray),一些选项则具有非常简化的纯数字格式(如 NumPy)。当将数据集从面向数据库的流水线迁移到面向文件的流水线时,将会有一些与简化数据和重写脚本以将逻辑从数据库移出并放入显式 ETL(提取-转换-加载)脚本相关的成长阵痛。在性能至关重要的情况下,将数据移到文件中很可能是减少延迟的最重要一步。常见的希望采取此方法的情况包括:

  • 对延迟敏感的预测,比如用于用户界面的软件

  • 高 I/O 密集型重复数据访问情况,例如训练深度学习模型

另一方面,对于许多应用程序而言,数据库的便利性、可扩展性和灵活性远远超过了较高的延迟。你的最佳存储方案将在很大程度上取决于你存储的数据的性质以及你想要做什么。

更多资源

  • 关于时间序列数据库技术:

    Jason Moiron,《关于时间序列数据库的思考》,jmoiron 博客,2015 年 6 月 30 日,https://perma.cc/8GDC-6CTX。

    这篇 2015 年的经典博文提供了一个早期时间段与时间序列数据库以及记录一切的崇拜之间的一瞥。这篇关于存储时间序列数据库选项和典型用例的高层次概述对于数据库管理和工程初学者非常有益。

    Preetam Jinka, “时间序列数据库列表,” Misframe 博客,2016 年 4 月 9 日,https://perma.cc/9SCQ-9G57.

    这份时间序列数据库的长列表经常更新,展示目前市场上的数据库。每个数据库条目都有重点介绍,说明特定数据库与竞争对手和前辈的关系。

    Peter Zaitsev, “Percona 博客调查:您使用哪种数据库引擎存储时间序列数据?” Percona 博客,2017 年 2 月 10 日,https://perma.cc/5PXF-BF7L.

    这项 2017 年的数据库工程师调查显示,关系数据库(SQL 数据库)继续作为一个群体占据主导地位,35% 的受访者表示他们使用这些数据库来存储时间序列数据。ElasticSearch、InfluxDB、MongoDB 和 Prometheus 也是受欢迎的选择。

    Rachel Stephens, “时间序列数据库市场现状,” RedMonk,2018 年 4 月 3 日,https://perma.cc/WLA7-ABRU.

    一位技术分析师最近的数据驱动撰写描述了通过对 GitHub 和 Stack Overflow 活动的实证调查,时间序列数据库存储的最流行解决方案。报告还显示由于时间序列数据的广泛用例以及数据库分段化的趋势,时间序列存储领域存在较高的分散度。

    Prometheus.io, “Prometheus 文档:与其他备选方案的比较,” 无日期,https://perma.cc/M83E-NBHQ.

    这份极其详细和全面的列表将 Prometheus 与其他流行的时间序列存储解决方案进行比较。您可以将此模型用作快速参考,了解与 Prometheus 替代方案相关的主导数据结构和存储结构。这是一个了解当前提供的选择以及各种为时序数据设计的数据库之间权衡的好地方。

  • 关于调整通用数据库技术:

    Gregory Trubetskoy, “高效在 PostgreSQL 中存储时间序列,” Notes to Self 博客,2015 年 9 月 23 日,https://perma.cc/QP2D-YBTS.

    这篇较老但仍然相关的博文解释了如何以性能为重点在 Postgres 中存储时间序列。Trubetskoy 解释了“朴素”方法(数值列和时间戳列)的困难,并给出了基于 Postgres 数组的实用建议。

    Josiah Carlson, “使用 Redis 作为时间序列数据库,” InfoQ,2016 年 1 月 2 日,https://perma.cc/RDZ2-YM22.

    本文提供了有关如何使用 Redis 作为时间序列数据库的详细建议和示例。虽然 Redis 自创建以来一直被用于时间序列数据,但作者指出了几个需要注意的地方,以及可以在许多时间序列使用案例场景中应用的优势数据结构。这篇文章不仅对于学习如何使用 Redis 处理时间序列数据很有帮助,而且作为如何将更一般的工具重新用于时间序列特定用途的示例也非常有用。

    迈克·弗里德曼,“时间序列数据:为什么(以及如何)使用关系数据库而不是 NoSQL”,Timescale 博客,2017 年 4 月 20 日,https://perma.cc/A6CU-6XTZ。

    TimescaleDB 的创始人在这篇博客文章中描述了他的团队如何构建一个具有特定于时间数据的内存布局修改的时间序列数据库作为关系数据库。TimescaleDB 认为传统 SQL 数据库在处理时间序列数据时的主要问题是不可扩展性,导致数据在内存中进出进行时间相关查询时性能缓慢。TimescaleDB 建议布置内存和内存映射以反映数据的时间性质,并减少将不同数据交换到内存中和从内存中交换出去的次数。

    希尔马尔·布赫塔,“将多个表与有效时间范围合并为单个维度”,Oraylis 博客,2014 年 11 月 17 日,https://perma.cc/B8CT-BCEK。

    虽然标题不太吸引人,但对于我们这些不是 SQL 数据库专家的人来说,这篇博客文章非常有用。它展示了一种处理标记为仅在特定时间范围内有效的数据及如何跨多个表进行联合的良好方式。这是一个令人惊讶地常见且难以解决的问题。所涉及的示例包括几个人力资源图表,包括雇员的雇佣有效日期、给定部门的有效日期、特定办公室位置的有效日期以及公司汽车的有效日期。一个非常可能的任务是确定某一天雇员的状态—该人是否受雇,并且在哪个部门、在哪个位置,并且是否有公司汽车?这听起来很直观,但要回答这个问题所需的 SQL 查询却很困难,在这篇文章中有详细的覆盖。

¹ 例如,参见“MongoDB 中的时间序列数据模式设计”“时间序列数据与 MongoDB:第一部分 – 简介”,以及“时间序列数据与 MongoDB:第二部分 – 模式设计最佳实践”。相反的观点请参考“如何在 MongoDB 中存储时间序列数据,以及为什么这是个坏主意”

² SQL 数据库针对时间序列数据的优化超出了本书的范围,并且往往非常具体于使用情况。本章末尾的一些资源解答了这个问题。

第六章:时间序列统计模型

在本章中,我们研究了一些时间序列的线性统计模型。这些模型与线性回归有关,但考虑到同一时间序列中数据点之间的相关性,与应用于横断面数据的标准方法形成对比,在后者中假设样本中的每个数据点独立于其他数据点。

我们将讨论的具体模型包括:

  • 自回归(AR)模型,移动平均(MA)模型和自回归积分移动平均(ARIMA)模型

  • 向量自回归(VAR)

  • 层次模型。

这些模型传统上是时间序列预测的主力军,并继续在从学术研究到工业建模的广泛情境中应用。

为什么不使用线性回归?

作为数据分析师,您可能已经对线性回归非常熟悉。如果不熟悉,可以定义如下:线性回归假设您拥有独立同分布(iid)的数据。正如我们在前几章节中详细讨论的那样,这在时间序列数据中并非如此。在时间序列数据中,接近时间的点往往彼此强相关。事实上,如果没有时间相关性,时间序列数据几乎无法用于传统的时间序列任务,如预测未来或理解时间动态。

有时,时间序列教程和教科书会给人一种错误的印象,即线性回归对于时间序列没有用处。他们让学生认为简单的线性回归根本行不通。幸运的是,这完全不是事实。普通最小二乘线性回归可以应用于时间序列数据,只要以下条件成立:

关于时间序列行为的假设

  • 时间序列对其预测变量具有线性响应。

  • 没有输入变量在时间上是恒定的或与另一个输入变量完全相关的。这简单地扩展了传统线性回归对独立变量的要求,以考虑数据的时间维度。

关于误差的假设

  • 对于每个时间点,考虑到所有时间段(向前和向后)的所有解释变量,误差的期望值为 0。

  • 在任意给定时间点上的误差与过去或未来的输入是不相关的。因此,误差的自相关函数图表不会显示任何模式。

  • 误差的方差与时间无关。

如果这些假设成立,那么普通最小二乘回归是系数的无偏估计,即使对于时间序列数据也是如此。¹ 在这种情况下,估计量的样本方差具有与标准线性回归相同的数学形式。因此,如果你的数据符合刚刚列出的假设,你可以应用线性回归,这无疑会帮助你提供时间序列行为的清晰和简单的直觉。

刚刚描述的数据要求类似于应用于横截面数据的标准线性回归的要求。我们增加的是对数据集时间特性的强调。

警告

不要强行使用线性回归。当你的数据不满足所需的假设时,应用线性回归可能会产生一些后果:

  • 你的系数将不会最小化模型的误差。

  • 由于依赖未满足的假设,你用于确定系数是否为非零的p值将是错误的。这意味着你对系数显著性的评估可能是错误的。

当合适时,线性回归可以提供简单和透明性,但错误的模型肯定不是透明的!

可以质疑时间序列分析师是否过于严格地应用标准线性回归所需的假设,以至于不能使用线性回归技术。现实世界的分析师偶尔在模型假设上取得一些自由是有益的,只要了解这样做的潜在风险。

遵守模型假设的重要性在很大程度上取决于领域。有时候,模型会在明知其基本假设未被满足的情况下应用,因为相对于回报来说,后果并不严重。例如,在高频交易中,尽管没有人相信数据严格遵循所有标准假设,线性模型因为多种原因而非常受欢迎。²

什么是无偏估计量?

如果一个估计值既不是过高也不是过低,那么它使用的是一个无偏估计量。这通常是一件好事,尽管你应该注意偏差-方差权衡,这是对统计和机器学习问题的描述,在这些问题中,参数估计偏差较小的模型往往会有更高的参数估计方差。参数估计的方差反映了估计在不同数据样本中的变化程度。

如果你发现自己的情况适合使用线性回归进行预测任务,考虑利用tslm(),这是forecast中设计的提供时间序列数据的简易线性回归方法的函数。

为时间序列开发的统计方法

我们考虑专门为时间序列数据开发的统计方法。我们首先研究为单变量时间序列数据开发的方法,从自回归模型的非常简单的情况开始,即一个模型,它表示时间序列的未来值是其过去值的函数。然后,我们逐步深入复杂的模型,最后讨论多元时间序列的向量自回归以及一些额外的专门的时间序列方法,例如 GARCH 模型和层次建模。

自回归模型

自回归(AR)模型依赖于过去预测未来的直觉,因此假设一个时间序列过程,在这个过程中,时间t的值是该系列在早期时间点的值的函数。

我们对这个模型的讨论将更为详细,以便让您了解统计学家如何考虑这些模型及其特性。因此,我们从一个相当详细的理论概述开始。如果您对如何推导时间序列的统计模型特性不感兴趣,可以略读这部分内容。

使用代数来理解 AR 过程的约束条件。

自回归看起来就像许多人在尝试拟合时间序列时会使用的第一种方法,特别是如果除了时间序列本身外没有其他信息的话。它确实如其名称所示:对过去值进行回归以预测未来值。

最简单的 AR 模型,即 AR(1)模型,描述如下:

y t = b 0 + b 1 × y t-1 + e t

在时间t的系列值是一个常数b[0]、其前一个时间步的值乘以另一个常数 b 1 × y t-1 和一个也随时间变化的误差项e[t]的函数。假定这个误差项具有恒定的方差和均值为 0。我们将仅回顾前一个时间点的自回归项称为 AR(1)模型,因为它包括一个一期滞后的查看。

顺便说一句,AR(1)模型与仅有一个解释变量的简单线性回归模型具有相同的形式。也就是说,它映射为:

Y = b 0 + b 1 × x + e

如果我们知道b[0]和b[1]的值,我们可以计算y[t]的期望值和方差,给定y[t–1]。参见 Equation 6-1.³

方程式 6-1. E ( y t | y t-1 ) = b 0 + b 1 × y t-1 + e t

V a r ( y t | y t-1 ) = V a r ( e t ) = V a r ( e )

此表示法的推广允许 AR 过程的当前值依赖于最近的 p 个值,从而产生 AR(p) 过程。

现在我们转向更传统的符号表示,使用 ϕ 表示自回归系数:

y t = ϕ 0 + ϕ 1 × y t-1 + ϕ 2 × y t-2 + . . . + ϕ p × y t-p + e t

如 第三章 中讨论的,稳定性是时间序列分析中的关键概念,因为许多时间序列模型,包括 AR 模型,都需要它。

我们可以从稳定性的定义中确定 AR 模型保持稳定的条件。我们继续关注最简单的 AR 模型,即 AR(1) 在 方程 6-2 中。

方程 6-2. y t = ϕ 0 + ϕ 1 × y t-1 + e t

我们假设过程是稳定的,然后“向后”推导看看这对系数意味着什么。首先,根据稳定性的假设,我们知道过程的期望值在所有时间点上必须相同。我们可以按照 AR(1) 过程的方程重新表述 y[t]

E ( y t ) = μ = E ( y t-1 )

根据定义,e[t] 的期望值为 0。此外,phises 是常数,因此它们的期望值就是它们的恒定值。方程 6-2 在左侧简化为:

E ( y t ) = E ( ϕ 0 + ϕ 1 × y t-1 + e t )

E ( y t ) = μ

以及右侧为:

ϕ 0 + ϕ 1 × μ + 0

这简化为:

μ = ϕ 0 + ϕ 1 × μ

这反过来意味着 (方程 6-3)。

方程 6-3. μ = ϕ 0 1-ϕ 1

因此,我们找到了过程的均值与基础 AR(1) 系数之间的关系。

我们可以采取类似的步骤来查看恒定方差和协方差如何对 ϕ 系数施加条件。我们首先用 ϕ[0] 的值代替,我们可以从 方程 6-3 推导出 方程 6-4。

方程 6-4. ϕ 0 = μ × ( 1 ϕ 1 )

到 方程 6-2:

y t = ϕ 0 + ϕ 1 × y t-1 + e t

y t = ( μ μ × ϕ 1 ) + ϕ 1 × y t-1 + e t

y t μ = ϕ 1 ( y t-1 μ ) + e t

如果你检查方程 6-4,你会发现左右两边的表达式非常相似,即 y t μy t-1 μ 。考虑到这个时间序列是平稳的,我们知道时间 t 的数学应与时间 t–1 的数学相同。我们将方程 6-4 重新写成一个时间步骤较早的方程 6-5。

方程式 6-5. y t-1 μ = ϕ 1 ( y t-2 μ ) + e t-1

我们可以将其代入方程 6-4 如下:

y t μ = ϕ 1 ( ϕ 1 ( y t-2 μ ) + e t-1 ) + e t

我们为了清晰起见重新排列方程 6-6。

方程 6-6. y t μ = e t + ϕ 1 ( e t-1 + ϕ 1 ( y t-2 μ ) )

你应该注意到在方程 6-6 中还可以进行另一次替换,使用我们早期使用的同样递归替换,但这次不是在 y[t–1] 上工作,而是在 y[t–2] 上工作。如果你进行这种替换,模式就变得清晰了。

y t - μ = e t + ϕ 1 ( e t-1 + ϕ 1 ( e t-2 + ϕ 1 ( y t-3 - μ ) ) )

= e t + ϕ × e t-1 + ϕ 2 × e t-2 + ϕ 3 × e t-3 + (expressions still to be substituted)

因此,我们可以更一般地得出结论,y t μ = i=1 ϕ 1 i × e t-i

用简单的英语来说,y[t] 减去过程均值是误差项的线性函数。

结果可以用来计算给定E [ ( y t - μ ) × e t+1 ] = 0的期望值,假设在不同t值上e t的值是独立的。由此我们可以得出结论,y[t–1]和e[t]的协方差为 0,这正如预期的那样。我们可以类似地应用逻辑来计算y[t]的方差通过平方这个等式:

y t - μ = ϕ 1 ( y t-1 - μ ) + e t

v a r ( y t ) = ϕ 1 2 v a r ( y t-1 ) + v a r ( e t )

因为由于平稳性方程两边的方差数量必须相等,所以(var(y[t]) = var(y[t] – 1),这意味着:

v a r ( y t ) = var(e t ) 1ϕ 1 2

鉴于方差根据定义必须大于或等于 0,我们可以看到,ϕ 1 2必须小于 1,以确保在前述方程的右侧有正值。这意味着对于平稳过程,我们必须有 –1 < ϕ[1] < 1。这是弱平稳的必要和充分条件。

我们研究了 AR(1)过程,因为它是最简单的自回归过程。在实践中,您将一直拟合更复杂的模型。可以推导出任意阶 AR(p)过程的类似平稳条件,并且有许多书籍在其中进行了演示。如果您有兴趣更详细地了解这一点,请查看本章末尾列出的资源。从这次讨论中最重要的收获是,通过一些代数和统计学,时间序列是非常易于理解的,并且平稳性不仅仅是绘制模型的问题,而是可以根据任何给定统计模型的具体情况进行计算的数学概念。

提示

分布是描述由过程生成特定值的所有可能值的概率的统计函数。虽然您可能尚未正式遇到此术语,但无疑已经遇到了此概念。例如,考虑钟形曲线,它是一个概率分布,指出大多数测量值将接近并均匀分布在平均值的两侧。在统计学中通常称为正态高斯分布

选择 AR(p)模型的参数

要评估 AR 模型对数据的适用性,请从绘制过程及其偏自相关函数(PACF)开始。AR 过程的 PACF 应在 AR(p)过程的阶数p之外截尾为零,从而在数据中以实证方式给出 AR 过程的阶数的明确和视觉指示。

另一方面,AR 过程不会有信息性的自相关函数(ACF),尽管它将具有 ACF 的特征形状:随着时间偏移增加呈指数衰减。

让我们通过一些实际数据来看看这个。我们使用了发布在UCI 机器学习库中的一些需求预测数据。

首先,我们按时间顺序绘制数据(图 6-1)。由于我们将其建模为 AR 过程,我们查看偏自相关函数以设置过程的阶数阈值(图 6-2)。

图 6-1. 每日银行订单数(2)。

图 6-2. 未转换订单时间序列的偏自相关图,如图 6-1 所示。

我们可以看到 PACF 的值在滞后 3 处穿过了 5%的显著性阈值。这与 R 语言stats包中的ar()函数的结果一致。如果没有指定自回归模型的阶数,ar()函数会自动选择其阶数:

## R
> fit <- ar(demand[["Banking orders (2)"]], method = "mle")
> fit

Call:
ar(x = demand[["Banking orders (2)"]], method = "mle")
Coefficients:
      1            2            3 
 -0.1360        -0.2014      -0.3175  

如果我们查看ar()函数的文档,我们可以看到所选的阶数是根据赤池信息准则(AIC)确定的(我们保持了默认参数未改变)。这是有帮助的,因为它显示我们通过检查 PACF 所做的视觉选择与通过最小化信息准则进行的选择是一致的。这两种选择模型阶数的方式是不同的,但在这种情况下它们是一致的。

注意,ar()函数还为我们提供了模型的系数。然而,我们可能希望限制这些系数。例如,观察 PACF,我们可能会想知道是否真的要包括滞后-1 项的系数,或者是否应该将该项的系数指定为强制的 0,因为其 PACF 值远低于显著性阈值。在这种情况下,我们还可以使用stats包中的arima()函数。

在这里,我们展示了如何调用函数来拟合 AR(3)模型,通过将阶数参数设置为c(3, 0, 0),其中 3 指的是 AR 分量的阶数(在后续示例中,我们将指定本章后几页中涵盖的差分和移动平均参数的其他分量):

## R
> est <- arima(x = demand[["Banking orders (2)"]], 
>             order = c(3, 0, 0))
> est

Call:
arima(x = demand[["Banking orders (2)"]], order = c(3, 0, 0))

Coefficients:
         ar1 ar2      ar3 intercept
     -0.1358  -0.2013 -0.3176  79075.350
s.e.   0.1299  0.1289 0.1296   2981.125

sigma² estimated as 1.414e+09:  log likelihood = -717.42, 
                                 aic = 1444.83

要在我们的模型中注入先验知识或观点,我们可以将一个系数约束为 0。例如,如果我们想要在我们的模型中将滞后-1 项的系数约束为 0,我们可以使用以下调用:

## R
> est.1 <- arima(x = demand[["Banking orders (2)"]], 
>                order = c(3, 0, 0), 
>                fixed = c(0, NA, NA, NA))
> est.1

Call:
arima(x = demand[["Banking orders (2)"]], 
       order = c(3, 0, 0), 
       fixed = c(0, NA, NA, NA))

Coefficients:
     ar1   ar2       ar3     intercept
       0  -0.1831  -0.3031    79190.705
s.e.   0  0.1289    0.1298     3345.253

sigma² estimated as 1.44e+09:  log likelihood = -717.96, 
                                aic = 1443.91

将传递给arima函数的固定参数向量中的一个值设置为 0 而不是NA,将约束该值保持为 0:

## R
> fixed <- c(0, NA, NA, NA)

我们现在检查我们模型在训练数据上的表现,以评估我们模型对这个数据集的拟合程度。我们可以通过两种方式来做这件事。首先,我们绘制残差的自相关函数(即误差),以查看是否存在模型未覆盖的自相关模式。

由于arima()函数的输出,绘制残差非常简单(见图 6-3):

## R
> acf(est.1$residuals)

图 6-3. 我们拟合的 AR(3)模型的残差的 ACF,强制滞后 - 1 参数为 0。

ACF 的值都未超过显著性阈值。当然,我们不应盲目依赖显著性阈值来评估或拒绝显著性,但这一观察结果是我们已认为合理的模型中的一个有用数据点。

我们在这里的残差(即误差项)中没有看到自相关的模式。如果我们看到这样的模式,我们可能会希望回到原始模型,并考虑包括额外的项以增加复杂性,以解释残差的显著自相关。

另一个常见的测试是Ljung-Box 测试,这是对时间序列随机性的整体测试。更正式地提出了以下零假设和备择假设:

  • H0: 数据不表现出串行相关。

  • H1: 数据表现出串行相关。

这个测试通常适用于 AR(更广义地说,是 ARIMA)模型,更具体地说是适用于模型拟合的残差而不是模型本身:

## R
> Box.test(est.1$residuals, lag = 10, type = "Ljung", fitdf = 3)

Box-Ljung test

data:  est.1$residuals
X-squared = 9.3261, df = 7, p-value = 0.2301

我们对我们的est.1模型应用 Ljung-Box 测试来评估其拟合优度。我们无法拒绝数据不表现出串行相关的零假设。这证实了我们刚刚通过绘制残差的 ACF 发现的结果。

使用 AR(p)过程进行预测

在接下来的章节中,我们将说明如何使用 AR 过程进行预测。我们首先探讨了一步预测的情况,然后讨论了多步预测与进一步情况的区别。好消息是从编码的角度来看,并没有太大的差异,尽管后者的基础数学更为复杂。

向前预测一步

首先考虑我们想要用已知(或估计)的 AR 模型预测一步的情况。在这种情况下,我们实际上拥有所有所需的信息。

我们继续使用需求数据的模型,将滞后 - 1 系数约束为 0(之前作为est.1拟合)。

我们使用forecast包中的fitted()函数绘制预测图。这里是完整的代码;这样做非常简单:

## R
> require(forecast)
> plot(demand[["Banking orders (2)"]], type = 'l')
> lines(fitted(est.1), col = 3, lwd = 2) ## use the forecast package

这导致了图 6-4 所示的绘图结果。

图 6-4. 这里我们看到实线表示的原始时间序列和虚线表示的拟合时间序列。

AR(p)模型是移动窗口函数。

我们除了使用forecast包的拟合功能来从我们的 AR(3)模型生成预测之外,还有一种选择:我们可以更明确地手工编写一个预测,通过使用zoo包的rollapply()函数来实现,这是我们之前讨论过的。该函数可以计算窗口函数,也可以计算 AR 过程。为此,我们将从ar()拟合中获取系数,并将这些权重应用于表示不同滞后值的输入向量,以在每个点上生成预测。我留给你作为一个练习。

现在让我们考虑预测的质量。如果我们计算预测值与实际值之间的相关性,我们得到 0.29。在某些情境下这并不差,但请记住,有时候对数据进行差分会消除看似强关系,取而代之的是一种基本上是随机的关系。

如果我们在拟合数据时数据确实不是真正稳定的,那么未识别的趋势就会伪装成良好的模型性能,而实际上它是我们在建模之前应该解决的数据特征。

我们可以对系列数据和预测值同时进行差分,以查看模型是否能够良好地预测一个时间段到下一个时间段的变化。即使在差分之后,我们的预测和数据显示出相似的模式,这表明我们的模型是有效的(参见图 6-5)。

图 6-5。差分系列和差分预测之间存在强相关性,表明模型确实识别出了一种潜在的关系。

我们还可以通过绘制差分系列并观察其相关性来测试我们是否在同一时间预测相同的移动。此图表显示了一些相关性,我们可以通过计算相关值来确认。即使用于预测从一个时间步到下一个时间步的变化,该模型也能够正常工作。

回顾预测与实际值的原始图表,我们可以看到预测与数据之间的主要区别在于预测的变化幅度比数据小。它可能能够正确预测未来的方向,但无法准确预测不同时间段之间的变化幅度。这本身并不是问题,而是反映了预测是预测分布的均值,因此必然比采样数据具有更低的变异性。

统计模型的这一属性有时会在快速可视化的便利性中被忽视,这些可视化往往倾向于表明未来会比实际情况更加稳定。在呈现可视化数据时,请务必提醒观众正在绘制的内容。在这种情况下,我们的预测表明未来会比实际情况平稳得多。

预测未来多个时间步

到目前为止,我们已经完成了单步预测。但是,我们可能希望进一步预测未来更长的时间。让我们想象一下,我们想要生成一个两步预测,而不是一个步预测。我们首先会生成一个步预测,然后使用这个预测值来确定我们需要预测的 y t 的值,以便预测 y t+1

请注意,在我们当前的模型中,从一步预测转变为两步预测实际上不需要这些复杂的操作,因为在预测 y[t] 时并没有使用 y[t – 1] 的值。我们知道所有需要知道的内容来进行两步预测,并且不需要估计。事实上,我们应该得到与一步预测相同的一系列值——不会有新的错误或变异源。

然而,如果我们希望预测更远的时间,我们需要生成预测的未来值作为预测的输入。让我们预测 y[t+3]。这将涉及一个模型,其系数依赖于 y[t+1] 和 y[t]。因此,我们需要预测这两个值——y[t + 1] 和 y[t]——然后依次使用这些估计值来预测 y[t+3]。与之前一样,我们可以使用 forecast 包中的 fitted() 函数来实现这一点——在编码上并不比单步预测更难。正如之前提到的,这也可以通过 rollapply() 方法来完成,但这样做需要更多的工作并且更容易出错。

现在我们使用 fitted() 函数,增加了一个额外的参数 h 作为预测的时间跨度。提醒一下,我们的对象 est.1 表示一个 AR(3) 过程,其滞后 -1 (时间减一) 的系数被限制为 0:

## R
> fitted(est.1, h = 3)

我们可以利用预测多个时间步骤的便利性,为不同的时间跨度生成多步预测。在下一个例子中,我们可以看到从同一基础模型生成的预测值随着前瞻时间增加而增加的方差。(注意,在显示中,四舍五入和逗号分隔修改了原始输出,以便更清楚地看到随着前瞻时间增加时估计方差的变化。)

正如你在图 6-6 中所见,随着预测时间的增加,预测的方差减少。这突显了模型的一个重要限制,即随着时间推移,实际数据的影响越来越小,因为输入数据的系数仅考虑有限的前几个时间点(在此模型中,仅回溯到滞后 - 3;即时间 - 3)。可以这样说,随着时间的推移,预测越来越接近无条件预测,即不受数据条件的影响。未来的预测接近于系列的均值,因此误差项和预测值的方差收缩为 0:

## R
> var(fitted(est.1, h = 3), na.rm = TRUE)
[1] 174,870,141
> var(fitted(est.1, h = 5), na.rm = TRUE)
[1] 32,323,722
> var(fitted(est.1, h = 10), na.rm = TRUE)
[1] 1,013,396
> var(fitted(est.1, h = 20), na.rm = TRUE)
[1] 1,176
> var(fitted(est.1, h = 30), na.rm = TRUE)
[1] 3.5

对于未来足够遥远的预测将仅仅预测过程的均值,这是有道理的。在未来某个时间点,我们当前的数据不会给我们提供与未来相关的具体合理信息,因此我们的预测逐渐回归到已知过程的基本特性,比如其均值。

图 6-6. 对未来的预测绘图。随着预测时间的延长,y 轴的值范围越来越窄,模型越来越接近提供过程均值的常数预测。预测的视角从上到下分别是 3、10 和 30 个时间步。

从中要记住的重要一点是,AR(以及 MA、ARMA 和 ARIMA,稍后将讨论)模型最适合进行短期预测。这些模型对于较长时间段的预测失去了预测能力。

对于剩余的模型,我们将进行类似的处理,尽管总体上的细节较少。关于时间序列分析的所有标准教科书都有详细讨论。

移动平均模型

移动平均(MA)模型依赖于一个过程的图像,在这个过程中,每个时间点的值是最近过去值的“误差”项的函数,每个项相互独立。我们将在同样的步骤中回顾这个模型,就像我们研究 AR 模型一样。

AR MA 等效性

在许多情况下,一个 MA 过程可以被表达为一个无限阶的 AR 过程。同样地,在许多情况下,一个 AR 过程可以被表达为一个无限阶的 MA 过程。要了解更多,请查看MA 过程的可逆性Wold 表示定理,以及一般的MA/AR 对偶性过程。这涉及到的数学远远超出了本书的范围!

该模型

移动平均模型可以类似于自回归模型来表达,只是线性方程中包含的项是现在和过去的误差项,而不是过程本身的现在和过去的值。因此,一个阶数为 q 的 MA 模型表示为:

y t = μ + e t + θ 1 × e t-1 + θ 2 × e t-2 . . . + θ q × e t-q

警告

不要将 MA 模型与移动平均混淆。它们并不相同。一旦您知道如何拟合移动平均过程,甚至可以将 MA 模型的拟合与底层时间序列的移动平均进行比较。我留这个作为一个练习给你。

经济学家将这些误差项称为系统的“冲击”,而具有电气工程背景的人则可能将其视为一系列脉冲和模型本身作为有限脉冲响应滤波器,这意味着任何特定脉冲的影响仅保留有限时间。措辞并不重要,但许多独立事件在不同的过去时间影响当前过程值,每个事件都作出个别贡献,这是主要思想。

MA 模型根据定义是弱平稳的,无需对其参数施加任何约束。这是因为 MA 过程的均值和方差都是有限的,并且随时间不变,因为误差项被假定为均值为 0 的独立同分布。我们可以看到这样的表达式:

E ( y t = μ + e t + θ 1 × e t-1 + θ 2 × e t-2 . . . + θ q × e t-q )

= E ( μ ) + θ 1 × 0 + θ 2 × 0 + . . . = μ

用于计算过程方差的事实是 e[t] 项是独立同分布的,并且还有一个一般的统计性质,即两个随机变量的和的方差等于它们各自方差的总和加上两倍的协方差。对于独立同分布变量,协方差为 0。这导致了以下表达式:

V a r ( y t ) = ( 1 + θ 1 2 + θ 2 2 + . . . . + θ q 2 ) × σ e 2

因此,无论参数值如何,MA 过程的均值和方差都是随时间恒定的。

为 MA(q) 过程选择参数

我们对同样的数据拟合了一个 MA 模型,可以使用 ACF 来确定 MA 过程的阶数(见 Figure 6-7)。在继续阅读之前,请考虑一下 MA 过程的工作原理,并看看是否能理解为什么我们使用 ACF 而不是 PACF 来确定过程的阶数:

## R
> acf(demand[["Banking orders (2)"]])

图 6-7. 我们使用需求时间序列的 ACF 来确定 MA 模型的阶数。

ACF 和 PACF 模式与 MA 和 AR 过程不同。

不同于自回归过程,其自相关函数缓慢衰减,MA 过程的定义确保了对于大于 q 的任何值,自相关函数具有尖锐的截断。这是因为自回归过程依赖于先前的项,并且它们通过先前的冲击对系统进行了融入,而 MA 模型则通过它们的值直接融入冲击,从而有一种机制可以阻止冲击传播无限期。

我们可以看到在滞后 3 和 9 处有显著的值,因此我们拟合了包含这些滞后的 MA 模型。我们需要注意,我们不要错误地将模型中的错系数约束为 0,我们可以通过打印显示来确认:

## R
> ma.est = arima(x = demand[["Banking orders (2)"]], 
                 order = c(0, 0, 9),
                 fixed = c(0, 0, NA, rep(0, 5), NA, NA))
> ma.est
 Call:
 arima(x = demand[["Banking orders (2)"]], order = c(0, 0, 9), 
       fixed = c(0, 0, NA, rep(0, 5), NA, NA))

 Coefficients:
       ma1  ma2      ma3  ma4  ma5  ma6  ma7  ma8      ma9  intercept
         0    0  -0.4725    0    0    0    0    0  -0.0120   79689.81
 s.e.    0    0   0.1459    0    0    0    0    0   0.1444    2674.60

 sigma² estimated as 1.4e+09:  log likelihood = -717.31,  
                                aic = 1442.61

我们还应该检查我们的拟合,就像我们对 AR 模型所做的那样,通过绘制模型残差的 ACF,并作为模型性能的第二个单独测试,运行 Ljung-Box 测试以检查残差的整体随机性。请注意,Box.test()的输入要求我们指定自由度的数量,即可以估计的模型参数数量,而不是被约束为特定值的数量。在这种情况下,自由参数是截距以及 MA3 和 MA9 项:

## R
> Box.test(ma.est$residuals, lag = 10, type = "Ljung", fitdf = 3)

Box-Ljung test

data:  ma.est$residuals
X-squared = 7.6516, df = 7, p-value = 0.3643

我们不能拒绝无残差点之间的时间相关性的原假设。同样,残差的 ACF 图表明没有时间相关性(这留作读者的练习)。

预测一个 MA(q)过程

我们可以再次使用forecast包的fitted()方法生成预测,这是用于 AR 过程的技术:

## R
> fitted(ma.est, h=1)

Time Series:
Start = 1
End = 60
Frequency = 1
[1]   90116.64  80626.91 74090.45   38321.61 74734.77 101153.20  65930.90
[8]  106351.80 104138.05 86938.99  102868.16 80502.02  81466.01  77619.15
[15] 100984.93  81463.10 61622.54   79660.81 88563.91  65370.99 104679.89
[22]  48047.39  73070.29 115034.16  80034.03 70052.29  70728.85  90437.86
[29]  80684.44  91533.59 101668.18  42273.27 93055.40  68187.65  75863.50
[36]  40195.15  82368.91  90605.60  69924.83 54032.55  90866.20  85839.41
[43]  64932.70  43030.64  85575.32  76561.14 82047.95  95683.35  66553.13
[50]  89532.20  85102.64  80937.97  93926.74 47468.84  75223.67 100887.60
[57]  92059.32  84459.85  67112.16  80917.23

MA 模型表现出强烈的均值回归,因此预测迅速收敛到过程的均值。这是合理的,因为该过程被认为是白噪声的函数。

如果您预测超出模型通过其顺序建立的范围,则预测将根据过程的定义必然是过程的均值。考虑一个 MA(1)模型:

y t = μ + θ 1 × e t-1 + e t

要预测未来一个时间步长,我们对y[t+1]的估计是 μ + θ 1 × y t + e t 。如果我们想预测未来两个时间步长,我们的估计是:

E ( y t+2 = μ + e t+2 + θ 1 × ; e t+1 ) = μ + 0 + θ 1 × 0 = μ

对于 MA(1)过程,我们无法在一步之后提供有根据的预测,对于一般的 MA(q)过程,我们不能在比过程发射的均值更有根据地提供超过q步的预测。通过“有根据的”预测,我指的是我们最近的测量对预测的影响。

传统符号为负

请注意,MA 模型通常不像这里描述的那样编写。传统上,θ系数前的符号是负的。这是由于推导的原因,而将 MA 模型视为 AR 模型并对其参数施加约束的一种方式。这种公式,通过大量的代数运算,导致θ的系数为负。

通过我们刚刚拟合的 MA(9)模型生成预测,我们现在寻求未来 10 个时间步长的预测:

## R
> fitted(ma.est, h=10)

Time Series:
Start = 1
End = 60
Frequency = 1
[1]       NA NA       NA NA NA       NA NA NA
[9]       NA NA 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81
[17] 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81
[25] 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81
[33] 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81
[41] 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81
[49] 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81 79689.81
[57] 79689.81 79689.81 79689.81 79689.81

当我们试图预测未来的 10 个时间步长时,我们预测每个时间步长的均值。即使没有花哨的统计模型,我们也可以做到这一点!

常识很重要。如果你在不理解其工作原理的情况下应用模型,可能会出现一些令人尴尬的事情,比如在你浪费了先前的时间和计算资源后,每天都向老板发送完全相同的预测。

自回归积分移动平均模型

现在我们已经分别检验了 AR 和 MA 模型,我们转向自回归积分移动平均(ARIMA)模型,该模型结合了这两者,认识到同一时间序列可能具有同时存在的 AR 和 MA 模型动态。这本身将导致一个 ARMA 模型,但我们扩展到 ARIMA 模型,它考虑了差分,一种去除趋势并使时间序列平稳的方法。

ARIMA 模型继续在性能上保持接近最先进的状态,特别是在数据集较小的情况下,更复杂的机器学习或深度学习模型表现不佳的情况下。然而,即使是 ARIMA 模型,尽管相对简单,也存在过拟合的危险。

模型

如果你一直在认真关注,那么此时你可能会摸不着头脑,因为我刚刚将同样的数据拟合到了 AR 和 MA 过程中,却没有加以评论。这是你在时间序列分析教材中有时会遇到的令人恼火的习惯。一些作者会承认这种数据的懒惰,而其他人则会漠然地忽视它。我们并没有深入调查我们之前的模型是否特别合适,但从我们使用的拟合过程中,似乎清楚地表明可以用 AR 或 MA 模型描述数据是有辩护理由的。这引发了一个问题:将这两种行为结合到同一个模型中是否有帮助?

表格 6-1 可以是检查时间序列过程的一个方便方法,以确定 AR、MA 或 ARMA 描述的最佳模型。

表格 6-1. 确定最佳描述我们时间序列的模型

绘图类型 AR(p) MA(q) ARMA
ACF 行为 缓慢下降 滞后 = q后急剧下降 无急剧截断
PACF 行为 滞后 = p 后急剧下降 缓慢下降 无急剧截断

这将我们引向自回归移动平均(ARMA)模型,该模型适用于当 AR 和 MA 项单独描述的情况不足以充分描述经验动态时。这是一个常见情况,当 AR 和 MA 的诊断统计(PACF 和 ACF)指向非零值时,表明某个阶数的 AR 或 MA 项。这些可以结合到 ARMA 模型中。

沃尔德定理

沃尔德定理告诉我们,每个协方差平稳时间序列可以被写成两个时间序列的和,一个确定性的和一个随机的。根据这个定理,我们也可以说,一个平稳过程可以被 ARMA 模型合理地近似,尽管找到适当的模型当然可能相当困难。

在这里,我们通过对 MA 过程系数应用负号来切换到更传统的统计符号表示方式:

y t = ϕ 0 + ( ϕ i × ; r t-i ) + e t ( θ i × e t-i )

ARMA 过程的平稳性取决于其 AR 组分的平稳性,并由相同的特征方程控制,该特征方程决定了 AR 模型是否平稳。

从 ARMA 模型到 ARIMA 模型的转变非常简单。ARIMA 模型与 ARMA 模型的区别在于 ARIMA 模型包括“整合”术语,这指的是模拟时间序列必须进行多少次差分以产生平稳性。

ARIMA 模型在实践中被广泛应用,特别是在学术研究和预测问题中,远远超过了 AR、MA 和 ARMA 模型。快速的 Google 学者搜索显示 ARIMA 被应用于各种预测问题,包括:

  • 前往台湾的入境航空旅客

  • 土耳其能源需求按燃料类型

  • 印度批发蔬菜市场的日销售量

  • 美国西部的急诊室需求

重要的是,差分的次数不应过大。一般来说,ARIMA(p, d, q)模型的每个参数值都应尽可能保持较小,以避免不必要的复杂性和对样本数据的过度拟合。作为一个并非普遍适用的经验法则,你应对d超过 2 和pq超过 5 或左右持怀疑态度。此外,你应该预计pq项将主导,并且另一个将相对较小。这些都是从分析师那里收集到的实践者笔记,并非硬性数学真理。

选择参数

ARIMA 模型在参数(p, d, q)方面进行规定。我们根据手头的数据选择合适的p, d,q值。

下面是维基百科描述 ARIMA 模型的一些知名示例:

  • ARIMA(0, 0, 0)是一个白噪声模型。

  • ARIMA(0, 1, 0)是一个随机游走,带非零常数的 ARIMA(0, 1, 0)是带漂移的随机游走。

  • ARIMA(0, 1, 1)是指数平滑模型,ARIMA(0, 2, 2)与 Holt 线性方法相同,将指数平滑扩展到带趋势的数据,以便用于具有潜在趋势的数据预测。

我们根据领域知识的结合、各种拟合评估指标(如 AIC)以及对给定基础过程的 PACF 和 ACF 应如何出现的一般知识(如表 6-1 中描述的)来选择我们模型的顺序。接下来,我们将展示如何使用基于 PACF 和 ACF 的手动迭代过程以及基于forecast包的auto.arima()函数的自动参数选择工具来拟合 ARIMA 模型。

手动拟合模型

有关选择 ARIMA 模型参数的启发法则,其中“简洁性至上”是首要考虑的。一个流行且长期存在的方法是 Box-Jenkins 方法,这是一个迭代的多步骤过程:

  1. 利用你的数据、可视化和基础知识来选择适合你数据的模型类别。

  2. 根据你的训练数据估计参数。

  3. 根据训练数据评估你的模型性能,并调整模型参数以解决性能诊断中发现的弱点。

让我们通过一个拟合数据的例子来逐步进行。首先,我们需要一些数据。在这种情况下,为了透明和知道正确答案,我们从 ARMA 过程生成我们的数据:

## R
> require(forecast)
> set.seed(1017)
> ## order of arima model hidden on purpose
> y = arima.sim(n = 1000, list(ar = ###, ma = ###))

暂时不要关注创建的模型顺序;让我们把这当作一个谜题来解决。首先,我们应该绘制时间序列,正如我们总是做的,看它是否呈现稳态(图 6-8)。然后,我们检查y的 ACF 和 PACF(图 6-9),并与表 6-1 进行比较。

图 6-8. 我们时间序列的绘图。

图 6-9. 我们时间序列的 ACF 和 PACF。

我们可以看到,无论是 ACF 还是 PACF 都没有明显的截尾,这表明(见表 6-1),这是一个 ARMA 过程。我们首先拟合一个相对简单的 ARIMA(1, 0, 1)模型,因为我们看不到差分的必要性,也没有证据(图 6-10)。

## R
> ar1.ma1.model = Arima(y, order = c(1, 0, 1))
> par(mfrow = c(2,1))
> acf(ar1.ma1.model$residuals)
> pacf(ar1.ma1.model$residuals)

图 6-10. ARIMA(1, 0, 1)模型残差的 ACF 和 PACF。

图 6-10 中的残差显示出特别大的 PACF 值,表明我们还没有完全描述自回归行为。因此,我们通过添加更高阶的 AR 分量来扩展模型,在下面的代码中测试 ARIMA(2, 0, 1)模型,并绘制此更复杂模型残差的 ACF 和 PACF(图 6-11):

## R
> ar2.ma1.model = Arima(y, order = c(2, 0, 1))
> plot(y, type = 'l')
> lines(ar2.ma1.model$fitted, col = 2)
> plot(y, ar2.ma1.model$fitted)
> par(mfrow = c(2,1))
> acf(ar2.ma1.model$residuals)
> pacf(ar2.ma1.model$residuals)

图 6-11. ARIMA(2, 0, 1)模型残差的 ACF 和 PACF。

图 6-11 中的残差再也不显示 ACF 或 PACF 的大值了。考虑到我们对一个简约模型的渴望以及过度拟合 ARIMA 模型的危险,一个明智的分析师可能会停在这里,因为在残差中没有进一步需要通过自回归、移动平均或差分组件进行拟合的行为。留给读者作为练习来考虑拟合更复杂的模型。尽管这里没有展示图形,我尝试用以下代码拟合更复杂的模型。我看到它们并没有显著改进模型与数据的拟合,也没有比前一个模型进一步降低 ACF 或 PACF 值。你可以自行验证:

## R
> ar2.ma2.model = Arima(y, order = c(2, 0, 2))
> plot(y, type = 'l')
> lines(ar2.ma2.model$fitted, col = 2)
> plot(y, ar2.ma2.model$fitted)
> par(mfrow = c(2,1))
> acf(ar2.ma2.model$residuals)
> pacf(ar2.ma2.model$residuals)
> 
> ar2.d1.ma2.model = Arima(y, order = c(2, 1, 2))
> plot(y, type = 'l')
> lines(ar2.d1.ma2.model$fitted, col = 2)
> plot(y, ar2.d1.ma2.model$fitted)
> par(mfrow = c(2,1))
> acf(ar2.d1.ma2.model$residuals)
> pacf(ar2.d1.ma2.model$residuals)

一个快速比较模型的方法在这里展示,我看看拟合模型的预测与实际值的相关性有多好:

## R
> cor(y, ar1.ma1.model$fitted)
[1] 0.3018926
> cor(y, ar2.ma1.model$fitted)
[1] 0.4683598
> cor(y, ar2.ma2.model$fitted)
[1] 0.4684905
> cor(y, ar2.d1.ma2.model$fitted)
[1] 0.4688166

从 ARIMA(1, 0, 1)模型转换到 ARIMA(2, 0, 1)模型(前两个模型)后,我们看到了显著的改善,相关性从 0.3 增加到了 0.47。另一方面,随着我们增加复杂性,相关性并没有显著提高。这进一步支持了我们早前得出的结论,即 ARIMA(2, 0, 1)模型似乎很好地描述了模型行为,并且没有必要添加更多的 AR 或 MA 分量来改善拟合。

顺便说一句,我们可以通过比较原始拟合系数(此处显示,之前被掩盖)与拟合系数来看一下拟合效果:

## R
## original coefficients
> y = arima.sim(n = 1000, list(ar = c(0.8, -0.4), ma = c(-0.7)))
> ar2.ma1.model$coef
         ar1          ar2          ma1    intercept 
 0.785028320 -0.462287054 -0.612708282 -0.005227573 

拟合系数与用于模拟数据的实际系数之间存在良好的匹配。

手动拟合 ARIMA 模型不仅仅是我们在这里展示的那样简单。几十年来,从业者们已经发展出了一些好的经验法则,用于识别问题,例如当某种类型的项过多时,当模型被过度差分时,当残差模式指向特定问题时等等。一个很好的起步资源是宾夕法尼亚州立大学一位教授编写的在线指南,可在此处获取。

有一些对手动拟合 ARIMA 过程的合理批评。手动拟合可能是一个定义不太清晰的过程,对分析师的判断力施加了很大压力,并且可能耗时且最终结果依赖路径。几十年来,这是一个表现良好的解决方案,用于生成实际世界中使用的预测,但并非完美。

使用自动模型拟合

如今,在某些情况下,我们可以放弃手动迭代拟合过程,转而采用自动模型选择。我们的模型选择可以根据各种信息损失准则进行驱动,例如我们通过forecast包中的auto.arima()函数简要讨论的 AIC:

## R
> est = auto.arima(demand[["Banking orders (2)"]], 
            stepwise = FALSE, ## this goes slower 
                              ## but gets us a more complete search
            max.p = 3, max.q = 9)
>  est
Series: demand[["Banking orders (2)"]] 
ARIMA(0,0,3) with non-zero mean 

Coefficients:
          ma1      ma2      ma3       mean
      -0.0645  -0.1144  -0.4796  79914.783
s.e.   0.1327   0.1150   0.1915   1897.407

sigma² estimated as 1.467e+09:  log likelihood=-716.71
AIC=1443.42   AICc=1444.53   BIC=1453.89

在这种情况下,我们通过比较我们早期探索中的一行配置输入的先验知识,来放置了一个一行描述。换句话说,我们指定了 AR 和 MA 过程的最大阶数,我们准备接受的,实际上,模型选择选择了一个比我们指定的更加简洁的模型,包括没有 AR 项。尽管如此,这个模型拟合效果很好,除非我们有充分的理由这样做,否则我们不应该过多地对数据进行窥探。请注意,根据 AIC 标准,我们在前一节手动选择的 MA 模型略优于这个模型,但当我们查看图表时,这种差异似乎不具有意义。

留给读者的练习是将模型绘制为预测的模型,以及检查这个自动选择的模型的残差,并确认使用的模型似乎没有留下需要通过增加更多项来解决的残差行为。代码与我们在之前的 AR 和 MA 部分使用的代码没有区别。对于制作预测的情况也是如此,因此在此处省略了,因为我们选择的 ARIMA 模型与之前讨论和用于预测的 MA 模型并没有太大不同。

我们还可以快速研究当我们手动拟合模型时,auto.arima()在我们在上一节中生成的模型上的表现如何:

## R
> auto.model = auto.arima(y)
> auto.model
Series: y 
ARIMA(2,0,1) with zero mean 

Coefficients:
         ar1      ar2      ma1
      0.7847  -0.4622  -0.6123
s.e.  0.0487   0.0285   0.0522

sigma² estimated as 1.019:  log likelihood=-1427.21
AIC=2862.41   AICc=2862.45   BIC=2882.04

我们甚至没有使用auto.arima()的可选参数建议模型搜索从哪里开始,但它却收敛到了与我们相同的解决方案。因此,正如我们在这里所看到的,在某些情况下,我们会发现不同方法得出相同的解决方案。我们通过查看更简单模型的残差的 ACF 和 PACF 来驱动我们的分析,以构建更复杂的模型,而auto.arima()主要通过网格搜索来最小化 AIC 来驱动。当然,鉴于我们是从 ARIMA 过程生成原始数据,这代表了一个比许多现实数据更简单的情况。在后一种情况下,并不总是我们的手动拟合和自动模型选择得出相同的结论。

如果您将auto.arima()或类似的自动模型选择工具作为分析的重要组成部分,重要的是要阅读文档,尝试合成数据,并阅读其他分析师对此函数的使用经验。已知存在一些情况,该函数的表现不如预期简单,并且已知有一些解决方法。总体而言,这是一个很好的解决方案,但并不完美。⁴ 另外,关于auto.arima()的工作方式的良好描述,请参阅该主题的在线教科书章节,该章节由该函数的作者罗布·亨德曼教授撰写。

我们演示了两种不同的参数估计方式:要么遵循 Box-Jenkins 方法来拟合模型,要么使用forecast包中的自动拟合。实际上,从业者对此有很强烈的意见,有些人激烈主张只支持手动迭代,而另一些人则坚决支持自动选择工具。这仍然是该领域的一个持续争论。从长远来看,随着大数据在时间序列分析中占据越来越多的地位,自动探索和模型拟合可能会主导大数据集时间序列分析。

向量自回归

在现实世界中,我们经常很幸运地有几个平行的时间序列,这些序列可能相互关联。我们已经探讨了如何清理和对齐这些数据,现在我们可以学习如何最大化利用它们。我们可以通过将 AR(p)模型推广到多变量的情况来做到这一点。这种模型的美妙之处在于它考虑了变量相互影响并相互影响——也就是说,并不存在一个特权的y,而其他所有变量都被指定为x。相反,拟合是对所有变量对称的。请注意,如果系列不是平稳的,可以像以前的其他模型一样应用差分。

外生和内生变量

在统计学中,一旦我们采用了变量相互影响的模型,我们称这些变量为内生变量,意味着它们的值由模型内部的内容解释。相反,外生变量是那些在模型内部无法解释的变量——也就是说,它们的值不能通过假设来解释,因此我们接受它们的值,不质疑它们产生的动态过程。

由于每个时间序列都被认为预测其他时间序列以及自身,我们将为每个变量拥有一个方程。假设我们有三个时间序列:我们将这些时间序列在时间t的值表示为y[1, t]、y[2, t]和y[3, t]。然后,我们可以将二阶(考虑两个时间滞后)向量自回归(VAR)方程写为:

y 1,t = ϕ 01 + ϕ 11,1 × y 1,t-1 + ϕ 12,1 × y 2,t-1 + ϕ 13,1 × y 3,t-1 + ϕ 11,2 × y 1,t-2 + ϕ 12,2 × y 2,t-2 + ϕ 13,2 × y 3,t-2

y 2,t = ϕ 02 + ϕ 21,1 × y 1,t-1 + ϕ 22,1 × y 2,t-1 + ϕ 23,1 × y 3,t-1 + ϕ 21,2 × y 1,t-2 + ϕ 22,2 × y 2,t-2 + ϕ 23,2 × y 3,t-2

y 1,t = ϕ 03 + ϕ 31,1 × y 1,t-1 + ϕ 32,1 × y 2,t-1 + ϕ 33,1 × y 3,t-1 + ϕ 31,2 × y 1,t-2 + ϕ 32,2 × y 2,t-2 + ϕ 33,2 × y 3,t-2

矩阵乘法

如果你熟悉线性代数,你会注意到,在矩阵表示法中,表达前面三个方程中显示的关系要简单得多。特别是,你可以以非常类似的方式编写 VAR。在矩阵形式中,这三个方程可以表示为:

y = ϕ 0 + ϕ 1 × y t-1 + ϕ 2 × y t-2

其中yϕ[0]是 3 × 1 矩阵,而其他ϕ矩阵是 3 × 3 矩阵。

即使是简单的情况,你也可以看到模型中的参数数量增长得非常快。例如,如果我们有p个滞后和N个变量,我们可以看到每个变量的预测方程为1 + p × N个值。由于我们有N个值要预测,这转化为N + p × N 2总变量,这意味着变量数量与研究的时间序列数量成O ( N 2 )比例增长。因此,我们不应仅仅因为有数据而随意引入时间序列,而应该将此方法保留给我们真正预期有关系的情况。

VAR 模型最常用于计量经济学。它们有时会受到批评,因为除了所有值互相影响的假设外,它们没有任何结构。正是因为这个原因,模型的拟合优度可能很难评估。然而,VAR 模型仍然很有用——例如,用于测试一个变量是否导致另一个变量。它们在需要预测多个变量并且分析师没有领域知识来确定任何特定关系的情况下,有时也会很有帮助。它们有时还可以帮助确定预测值的方差有多少归因于其基础“原因”。

这里有一个快速演示。让我们看看基础的 UCI 需求信息,并考虑使用第二列来预测银行订单 (2),而不仅仅是它自己的数据(注意,由于对称处理变量的方式,我们也将预测该列)。让我们考虑使用来自交通控制部门的订单。听起来这应该是完全不同的,因此它可能提供相对于财政部门自身过去订单的相当独立的信息源。我们还可以想象,每列提供了有关经济状况以及未来需求增减的基础信息。

要确定要使用的参数,我们使用 vars 包,该包带有 VARselect() 方法:

## R
> VARselect(demand[, 11:12, with = FALSE], lag.max=4,
            +           type="const")
$selection
AIC(n)  HQ(n)  SC(n) FPE(n) 
   3      3      1      3 

$criteria
             1            2            3            4
AIC(n) 3.975854e+01 3.967373e+01 3.957496e+01 3.968281e+01
HQ(n)  3.984267e+01 3.981395e+01 3.977126e+01 3.993521e+01
SC(n)  3.997554e+01 4.003540e+01 4.008130e+01 4.033382e+01
FPE(n) 1.849280e+17 1.700189e+17 1.542863e+17 1.723729e+17

我们可以看到该函数提供了各种信息准则供选择。还请注意,我们指出我们希望拟合一个 "const" 项来适应非零均值。我们还可以选择拟合漂移项,或者二者都不拟合,但是 "const" 选项似乎最适合我们的数据。在这里,我们将从查看三个滞后开始,看看效果如何:

## R
> est.var <- VAR(demand[, 11:12, with = FALSE], p=3, type="const")
> est.var

> par(mfrow = c(2, 1))
> plot(demand$`Banking orders (2)`, type = "l")
> lines(fitted(est.var)[, 1], col = 2)
> plot(demand$`Banking orders (3)`, 
>      type = "l")
> lines(fitted(est.var)[, 2], col = 2)

> par(mfrow = c(2, 1))
> acf(demand$`Banking orders (2)` - fitted(est.var)[, 1])
> acf(demand$`Banking orders (3)` - 
>     fitted(est.var)[, 2])

此代码生成了图表显示在 6-12 和 6-13 中所示的图形。

图 6-12. 在顶部,我们看到了银行订单 (2) 的实际值(实线)与预测值(虚线),在底部,我们看到了银行订单 (3) 的同样情况。有趣的是,顶部的图表更像是一个典型的预测,其中预测相对于实际数据的变化有些“缓慢”,而在底部的图表中,我们看到预测实际上提前预测了变化。这表明银行订单 (2) “领先于” 银行订单 (3),这意味着银行订单 (2) 在预测交通控制器订单方面是有帮助的,但可能不是反过来,或者至少不是在这种程度上。

图 6-13. 我们绘制了每个时间序列残差的自相关函数。请注意,对于两个序列,误差在滞后 3 处存在一些边缘显著的自相关,可能在模型中没有完全解释。

ACF 并未如我们所希望地清楚地支持残差中的无自相关性,因此在这种情况下,我们还可以通过vars包的serial.test()方法应用Portmanteau 测试来检验串行相关性。这个测试类似于我们在单变量情况下看到的串行相关性测试:

## R
> serial.test(est.var, lags.pt = 8, type="PT.asymptotic")

Portmanteau Test (asymptotic)

data:  Residuals of VAR object est.var
Chi-squared = 20.463, df = 20, p-value = 0.4293

由于p值如此之高,我们无法拒绝不存在残差中串行相关的零假设。这为我们提供了进一步的证据,证明该模型做得相当不错。

鉴于对于我们检验的单变量模型,我们逐步分析了各种模型,一直到 ARMA 和 ARIMA 版本,您可能会想知道是否存在 VARIMA 模型。确实存在,但由于 VAR 表现相对较好且已相当复杂,因此并不经常使用。在工业和学术用例中,您会发现使用 VAR 远远超过 VARIMA 的情况。

另一个相关的模型类是 CVAR 模型,即共整合向量自回归模型。这指的是个别时间序列不是平稳的,但时间序列的线性组合在不经过差分的情况下是平稳的。

统计模型的变体

存在许多其他类型的针对时间序列数据开发的统计模型。其中一些扩展了 ARIMA 模型,而其他一些则对时间动态的基本假设与 ARIMA 模型不同。在本节中,我们简要讨论一些最常用和知名的统计时间序列模型。

季节性 ARIMA

季节性 ARIMA(SARIMA)模型假设存在乘法季节性。因此,SARIMA 模型可以表示为 ARIMA(p, d, q)×(P, D, Qm。该模型假设季节性行为本身可以被视为 ARIMA 过程,m指定每个季节周期中的时间步数。在这个因子中重要的是,该模型认识到时间相邻点可以互相影响,无论是在同一个季节内还是在不同季节内,但通过通常的时间接近方法。

确定 SARIMA 甚至比确定 ARIMA 模型更加棘手,因为您需要处理季节效应。幸运的是,在forecasts包中的auto.arima()可以像处理标准 ARIMA 估计任务一样处理这一点。正如早些时候讨论的那样,除非您有强有力的知识表明需要覆盖自动方法确定的选择模型,否则有很多理由选择自动参数选择。

ARCH、GARCH 及其众多类似模型

ARCH 代表“条件异方差自回归”,这种模型几乎专门用于金融行业。它经常出现在时间序列课程中,因此在这里值得一提。这类模型基于这样一个观察结果:股票价格的方差并非恒定,实际上方差本身在较早的方差条件下似乎是自回归的(例如,股票交易所的高波动日出现成簇)。在这些模型中,被建模的是过程的方差,而不是过程本身。

分层时间序列模型

在现实世界中,分层时间序列非常常见,尽管它们并未以此形式呈现。你可以轻松地构想出它们可能出现的情况:

  • 公司产品的总月需求金额,然后可以按 SKU 编号进行细分。

  • 全体选民的每周政治民意调查数据,然后根据不同的人口统计学特征(重叠或非重叠),比如女性与男性,或者西班牙裔与非裔美国人。

  • 每天进入欧盟的游客总数与特定成员国每天进入的游客数量的总计

处理分层时间序列的一个方便的方法是通过 R 的hts包。这个包既可以用于可视化分层时间序列数据,也可以用于生成预测。

可以使用一些历史上已经在这个包中可用的不同方法生成预测:

  • 生成最低级的预测(最个性化的),然后将其汇总以生成更高级别的预测。

  • 生成最高级别的预测,然后基于聚合每个组成部分的历史比例生成低级别的预测。这种方法在进行低级别预测时往往不太准确,尽管可以通过多种技术来预测聚合比例本身随时间的变化。

  • 可以尝试通过选择“中间”方法来获取每种方法的最佳效果,其中会生成中层预测(假设你有多层级)。然后在广义上向上下传播,以进行其他预测。

最终,hts包的黄金标准在于,可以为所有层次的分层生成独立的预测。然后hts结合这些预测,以保证方法的一致性,如Hyndman et al.中所述。

我们在本章讨论的许多统计模型反过来又会应用于决定预测哪个层次的时间序列的问题。分层分析部分往往是围绕这些基础模型的一个包装器。

时间序列统计方法的优缺点

当您在考虑是否将此处描述的某个统计模型应用于时间序列问题时,从优缺点的库存开始可能是个不错的方法。这些优缺点在本节中详细列出。

优点

  • 这些模型简单透明,因此可以清晰地理解其参数。

  • 由于定义这些模型的简单数学表达式,可以以严格的统计方式推导出它们的有趣属性。

  • 您可以将这些模型应用于相对较小的数据集,仍然可以获得良好的结果。

  • 这些简单模型及其相关修改表现非常出色,甚至与非常复杂的机器学习模型相比也不遑多让。因此,您可以在不过度拟合的情况下获得良好的性能。

  • 发展模型的订单和估计其参数的自动化方法非常成熟,因此可以轻松生成这些预测。

缺点

  • 由于这些模型相当简单,因此在处理大数据集时并不总是能提升性能。如果您正在处理极大数据集,则可能更适合使用机器学习和神经网络方法的复杂模型。

  • 这些统计模型侧重于分布的均值点估计,而不是分布本身。的确,您可以推导出样本方差等作为预测不确定性的某种替代,但您的基本模型只提供了有限的表达方式来相对于您在选择模型时所做的所有选择来表达不确定性。

  • 根据定义,这些模型并非用于处理非线性动态,并且在描述非线性关系占主导地位的数据时表现不佳。

更多资源

  • 经典文本:

    罗伯特·J·亨德曼和乔治·阿瑟纳索普洛斯,《预测:原理与实践》第二版(墨尔本:OTexts,2018),https://perma.cc/9JNK-K6US。

    这本实用且极易接近的教科书免费介绍了在 R 中预处理时间序列数据的所有基础知识以及利用该数据进行预测的方法。重点是让读者迅速掌握并熟练使用时间序列预测的实用方法。

    蔡瑞,《金融时间序列分析》(霍博肯,新泽西州:约翰·威利·父子,2001)。

    这本经典教材介绍了各种时间序列模型,包括对开发 AR、MA 和 ARIMA 模型的非常详尽和易于理解的章节,应用于历史股票价格。还包括大量的 R 实例。就可访问性而言,这是一本中等难度的书籍,假设读者具有某些统计学和其他数学知识,但对于已掌握高中微积分和入门统计学课程的任何人来说都是相当易读的。

    罗伯特·H·舒姆韦,《时间序列分析及其应用》(纽约,纽约州:斯普林格国际,2017)。

    这是另一本经典教科书,内容稍微更为理论化且不易理解。最好先看看 Tsay 的书,但这本书包括了更多关于统计时间序列模型数学过程的讨论。它还包括了从科学和经济数据源中得出的更广泛的数据分析。这本书比其他列出的教科书更具挑战性,但这是因为它内容密集且信息丰富,并不是因为它需要更高的数学能力水平(实际上并不需要,尽管如果读得太快可能会有这种感觉)。

  • 启发性指导:

    Robert Nau,“识别 ARIMA 模型的规则总结,” 来自杜克大学 Fuqua 商学院的课程笔记,https://perma.cc/37BY-9RAZ。

    这些笔记详细指导您如何选择 ARIMA 模型的三个参数。当您阅读本摘要时,您会立即注意到对简约模型的强调。

    美国国家标准与技术研究院(NIST),“Box-Jenkins 模型,” 收录于 NIST/SEMATECH 统计方法 e-Handbook(华盛顿特区:NIST,美国商务部,2003 年),https://perma.cc/3XSC-Y7AG。

    这部分在线 NIST 手册提供了实施 Box-Jenkins 方法的具体步骤,这是一种常用的 ARIMA 参数选择方法学。这也是 NIST 为时间序列统计分析提供的资源的一个很好的例子,作为更大型精心编纂的最佳实践手册的一部分。

    Rob J. Hyndman,“ARIMAX 模型的混淆问题,” Hyndsight 博客,2010 年 10 月 4 日,https://perma.cc/4W44-RQZB。

    这篇简洁的博客文章来自著名预测专家 Rob Hyndman,描述了将协变量纳入 ARIMA 模型的方法,这是处理多变量时间序列的 VAR 替代方法。

    Richard Hardy,“ARIMA 模型的交叉验证和正则化问题,” Cross Validated, StackExchange 上的问题,2015 年 5 月 13 日,https://perma.cc/G8NQ-RCCU。

    在自回归项较高或在 VAR 的情况下输入较多的情况下,正则化是有意义的,在许多行业中,这会带来显著的性能改进。这篇关于 Cross Validated 问答网站的帖子提供了一些初步讨论以及与计算实现和相关学术研究的链接。

¹ 请注意,即使在移除某些条件的情况下,普通最小二乘法仍然是无偏的。例如,当误差相关且异方差时,普通最小二乘法仍然可以提供系数的无偏估计,尽管存在效率问题。有关效率的更多信息,请参阅 Wikipedia

² 在这种情况下,有一些缓解因素可以证明使用标准线性回归的合理性。首先,有人认为在足够短的时间尺度上,金融市场的波动彼此独立(iid)。其次,由于线性回归计算效率高,即使在假设不准确的情况下,快速模型在追求速度的行业中仍然是一个好模型。第三,使用这些模型的企业能够赚钱,因此他们一定是做对了什么。

³ 只有随后引用的方程式才编号。

⁴ 对于一些示例讨论,请参阅诸如 Stack OverflowRob Hyndman’s blog

第七章:时间序列的状态空间模型

状态空间模型与我们在前一章中研究的统计模型类似,但更具有“现实世界”的动机。它们解决了在真实世界工程问题中出现的问题,例如在进行估计时如何考虑测量误差以及如何将先验知识或信念注入估计中。

状态空间模型假设一个世界,其中真实状态不能直接测量,而只能从可测量的内容推断。状态空间模型还依赖于指定系统的动态,例如世界真实状态如何随时间演变,既受内部动态的影响,也受到施加在系统上的外部力量的影响。

尽管在数学背景下你可能之前没见过状态空间模型,但你可能在日常生活中使用过它们。例如,想象一下你看到一名司机在交通中迂回。你试图确定司机要去哪里以及如何最好地保护自己。如果司机可能醉酒,你会考虑报警,而如果司机因一时分心而不会重复的原因,你可能会顾及自己的事务。在接下来的几秒或几分钟内,你将更新你对该司机的状态空间模型,然后决定该做什么。

使用状态空间模型的一个经典例子是发射到太空的火箭。我们知道牛顿定律,因此我们可以编写系统动态的规则以及随时间应该看起来如何运动。我们还知道我们的 GPS 或传感器或者我们用于跟踪位置的任何东西都会有一些测量误差,我们可以量化并试图考虑到我们计算不确定性的这种影响。最后,我们知道我们无法解释作用在特定火箭上的世界上所有力量,因为系统中存在许多未知因素,所以我们希望这个过程对其他未知噪声源具有鲁棒性,也许是太阳风或地球风或两者兼而有之。事实证明,在过去 50 年的统计和工程进步中,这些方法已被证明非常有用。

两种不同的历史趋势导致了状态空间模型的发展以及对它们所处理问题种类的兴趣。首先,在 20 世纪中期左右,我们进入了一个机械自动化的时代。天空中有火箭和航天器,潜艇的导航系统以及所有种类的其他自动发明,这些发明需要估计无法测量的系统状态。当研究人员考虑如何估计系统状态时,他们开始开发状态空间方法,最重要的是区分测量误差和系统中其他类型的不确定性。这导致了状态空间方法的首次应用。

在此期间,记录技术和相关计算也在发展。这导致了时间序列的数据集变得更大,包括更长或更详细的时间序列数据集。随着更多时间序列数据的可用性,可以与状态空间建模的新思维一起为其开发更多数据密集型方法。

在本章中,我们将学习这些常用的状态空间方法:

  • 应用于线性高斯模型的卡尔曼滤波器

  • 隐马尔可夫模型

  • 贝叶斯结构时间序列

在每种情况下,使用这些模型是相当简便和良好实施的。对于每个模型,我们将对数学提出一些直觉,并讨论适合使用该方法的数据类型。最后,我们将看到每种方法的代码示例。

在每种情况下,我们将区分我们观察到的内容和产生我们观察结果的状态。在基于观察结果估计基础状态时,我们可以将我们的工作划分为不同的阶段或类别:

滤波

利用时间 t 处的测量更新我们对时间 t 处状态的估计

预测

利用时间 t - 1 处的测量生成时间 t 处预期状态的预测(同时允许我们推断时间 t 处的预期测量)

平滑

利用包括 t 时间点之前和之后的时间范围内的测量来估计时间 t 处的真实状态

这些操作的机制通常相似,但区别很重要。滤波是一种决定如何将最新信息与过去信息进行权衡,以更新我们对状态估计的方法。预测是在没有未来信息的情况下预测未来状态。平滑是在制定给定时间点状态的最佳估计时利用未来和过去信息的方法。

状态空间模型:优缺点

状态空间模型可用于确定性和随机应用,并且可以应用于连续采样数据和离散采样数据。¹

这一点单独就能让你对它们的效用和极大的灵活性有所了解。状态空间模型的灵活性是驱动这一类模型优缺点的根源。

状态空间模型有许多优势。状态空间模型允许对时间序列中通常最有趣的内容进行建模:产生被分析的嘈杂数据的动态过程和状态,而不仅仅是嘈杂数据本身。通过状态空间模型,我们在建模过程中注入了一个解释生成过程的模型,以解释首次产生过程的动因。这对于我们已经对系统如何运作有强有力理论或可靠知识的情况非常有用,而我们希望我们的模型帮助我们探索我们已经熟悉的一般动态的更多细节。

状态空间模型允许随时间改变系数和参数,这意味着它允许随时间改变的行为。在使用状态空间模型时,我们没有对数据施加平稳性条件。这与我们在第六章中检验的模型有很大不同,那里假设了一个稳定的过程,并且只用一个系列的系数来建模,而不是时间变化的系数。

尽管如此,状态空间模型也有一些缺点,有时状态空间模型的优点也是它的弱点:

  • 由于状态空间模型如此灵活,需要设置许多参数,并且状态空间模型可以采取许多形式。这意味着特定状态空间模型的性质通常未被充分研究。当你制定一个适合你时间序列数据的状态空间模型时,你很少会在统计教科书或学术研究论文中找到其他人已经研究过该模型。这使得你在理解模型性能或可能出现错误的地方时,往往处于不太确定的领域。

  • 状态空间模型在计算上可能非常耗费资源,因为涉及到许多参数。此外,某些类型的状态空间模型的参数数量非常庞大,这可能使你容易过拟合,特别是如果数据量不足的话。

卡尔曼滤波器

卡尔曼滤波器是一种成熟并广泛应用的方法,用于从时间序列中获得新信息,并与先前已知的信息以智能方式结合,以估计潜在状态。卡尔曼滤波器最初的应用之一是在阿波罗 11 号任务中,当 NASA 工程师意识到机载计算元件无法支持其他更耗费内存的位置估计技术时,选择了该滤波器。正如您将在本节中看到的那样,卡尔曼滤波器的优点在于它相对容易计算,并且不需要存储过去的数据来进行现在或未来的预测。

概述

对于初学者来说,卡尔曼滤波器的数学可能有些令人望而却步,不是因为它特别困难,而是因为需要跟踪相当数量的量,并且这是一个迭代的、有些循环的过程,涉及许多相关的量。因此,我们在这里不会推导卡尔曼滤波器的方程式,而是通过对这些方程式的高级概述来了解它们的工作原理。²

我们从线性高斯模型开始,假设我们的状态和观察具有以下动态:

x[t] = F × x[t–1] + B × u[t] + w[t]

y[t] = A × x[t] + v[t]

也就是说,时间t的状态是前一个时间步骤的状态(F × x[t–1])、外部力项(B × u[t])和随机项(w[t])的函数。同样,时间t的测量是状态在时间t和随机误差项、测量误差的函数。

让我们想象x[t]是宇宙飞船的实际位置,而y[t]是我们用传感器测量的位置。v[t]是我们传感器设备(或多个设备的范围)的测量误差。卡尔曼滤波器适用的基本方程是这个方程,它展示了如何在时间t给出新信息后更新我们的估计:

x^[t] = K[t] × y[t] + (1 – K[t]) × x^[t*–1]

我们在这里看到的是一个滤波步骤,即如何使用时间t的测量来更新我们在时间t的状态估计的决策。请记住,我们假设的情况是我们只能观察y[t]并推断状态,但不能确切知道状态。从上面我们看到,数量K[t]在我们的估计中建立了旧信息(x^[t–1])和新信息(y[t])之间的平衡。

要深入了解更详细的机制,我们需要定义一些术语。我们使用P[t]来表示我们状态协方差的估计(这可以是标量或矩阵,取决于状态是否单变量或多变量,后者更常见)。P^–[t]是在考虑时间t的测量之前的时间t的估计。

我们还用R来表示测量误差方差,即v[t]的方差,这可以是一个标量或协方差矩阵,具体取决于测量的维度。R通常对于一个系统是很好定义的,因为它描述了特定传感器或测量设备的已知物理特性。对于w[t]的适当值Q在建模过程中较少定义并且需要调整。

然后我们从一个过程开始,该过程使我们在时间 0 处已知或估计了xP的值。然后,在时间 0 后,我们遵循预测和更新阶段的迭代过程,预测阶段首先进行,然后是更新/过滤阶段,如此循环。

预测:

x^ ^– [t] = F × x^[t–1] + B × u[t]

P^–[t] = F × P[t–1] × F^T + Q

滤波:

x^[t] = x^ ^– [t] + K[t] × (y[t] – A × x</mi></mover></math>-[t])

P[t] = (I – K[t] × A) × P^-[t]

其中K[t],即 Kalman 增益,为:

K[t] = P ^– [t] × A^T × (A × P^-[t] × A^T + R) ^(– 1)

您将看到许多这种递归过程的可视化。有些将其分解为多个步骤,或许多达四到五步,但最简单的思考方式是:在没有y[t](预测值)的测量的情况下,预测时间t的值,然后是在时间t,在已知测量y[t]后(过滤)所采取的步骤。

要开始,您需要以下值:

  • RQ的估计值——分别是测量误差(易知)和状态随机性(通常估计)的协方差矩阵

  • 估计或已知的状态值在时间 0,x^[0](基于y[0]估计)

  • 在时间t计划应用的力的预先知识以及这些如何影响状态——即矩阵B和值u[t]

  • 知识系统动态,决定从一个时间步骤到另一个时间步骤的状态转移,即F

  • 理解测量如何依赖于状态的知识,即A

有许多方法可以推导 Kalman 滤波器方程,包括从期望值的概率视角、最小二乘最小化问题,或作为最大似然估计问题。Kalman 滤波器的推导广泛可得,有兴趣的读者可以通过几次互联网搜索来深入了解这个主题。

Kalman 滤波器的代码

我们考虑一个经典的用例:试图跟踪一个受牛顿力学影响、带有误差传感器的物体。我们根据牛顿运动定律生成一个时间序列,即物体的位置是其速度和加速度的函数。尽管底层运动是连续的,但我们想象进行离散测量。我们首先确定一系列加速度,然后假设位置和速度都从 0 开始。虽然这不符合物理实际情况,我们假设每个时间步长开始时有即时的加速度变化,并且稳定的加速度值:

## R
## rocket will take 100 time steps
ts.length <- 100

## the acceleration will drive the motion
a <- rep(0.5, ts.length)

## position and velocity start at 0
x  <- rep(0, ts.length)
v  <- rep(0, ts.length)
for (ts in 2:ts.length) {
  x[ts] <- v[ts - 1] * 2 + x[ts - 1] + 1/2 * a[ts-1] ^ 2 
  x[ts] <- x[ts] + rnorm(1, sd = 20) ## stochastic component
  v[ts] <- v[ts - 1] + 2 * a[ts-1]
}

如果您不了解或不记得牛顿运动定律,您可能希望熟悉一下,尽管在当前目的(计算x[ts]v[ts])上,您也可以直接接受这些。

一个快速的绘图练习向我们展示了我们通过结构化加速度所创建的运动(见图 7-1):

## R
par(mfrow = c(3, 1))
plot(x,            main = "Position",     type = 'l')
plot(v,            main = "Velocity",     type = 'l')
plot(acceleration, main = "Acceleration", type = 'l')

我们假设这些变量将代表状态的完整描述,但我们唯一可用的数据是物体的位置,并且此数据仅来自于一个嘈杂的传感器。这个传感器在下面的代码中表示为 x,我们绘制了测量值如何与实际位置相关联在 Figure 7-2 中:

## R
z <-  x + rnorm(ts.length, sd = 300)
plot (x, ylim = range(c(x, z)))
lines(z)

在 Figure 7-2 中,我们看到一个恒定加速度(底部图)驱动线性增加的速度(中部图),从而在位移(顶部图)中产生一个抛物线形状。如果这些机械关系不熟悉,您可以接受它们或查阅物理入门教科书中关于基本力学的快速回顾。

图 7-1. 我们火箭的位置、速度和加速度。

图 7-2. 真实位置(点)与我们嘈杂的测量(线)的对比。请注意,位置 x 由于我们将噪声放入状态转移方程而不反映出完美的抛物线。

现在我们应用卡尔曼滤波器。首先,我们编写一个通用函数,反映了我们在本节早些时候讨论和推导的内容:

## R
kalman.motion <- function(z, Q, R, A, H) {
  dimState = dim(Q)[1]

  xhatminus <- array(rep(0, ts.length * dimState), 
                    c(ts.length, dimState)) 
  xhat      <- array(rep(0, ts.length * dimState), 
                    c(ts.length, dimState)) 

  Pminus <- array(rep(0, ts.length * dimState * dimState), 
                 c(ts.length, dimState, dimState))
  P      <- array(rep(0, ts.length * dimState * dimState), 
                 c(ts.length, dimState, dimState))  

  K <- array(rep(0, ts.length * dimState), 
            c(ts.length, dimState)) # Kalman gain

  # intial guesses = starting at 0 for all metrics
  xhat[1, ] <- rep(0, dimState)
  P[1, , ]  <- diag(dimState)

  for (k in 2:ts.length) {
    # time update
    xhatminus[k, ] <- A %*% matrix(xhat[k-1, ])
    Pminus[k, , ] <- A %*% P[k-1, , ] %*% t(A) + Q

    K[k, ] <- Pminus[k, , ] %*% H %*% 
                            solve( t(H) %*% Pminus[k, , ] %*% H + R )
    xhat[k, ] <- xhatminus[k, ] + K[k, ] %*% 
                            (z[k]- t(H) %*% xhatminus[k, ])
    P[k, , ] <- (diag(dimState)-K[k,] %*% t(H)) %*% Pminus[k, , ]
  }    

  ## we return both the forecast and the smoothed value
  return(list(xhat = xhat, xhatminus = xhatminus))
}

我们应用这个函数,只测量火箭的位置(不包括加速度或速度):

## R
## noise parameters
R <- 10² ## measurement variance - this value should be set
          ## according to known physical limits of measuring tool 
          ## we set it consistent with the noise we added to x 
          ## to produce x in the data generation above
Q <- 10   ## process variance - usually regarded as hyperparameter
          ## to be tuned to maximize performance

## dynamical parameters
A <- matrix(1) ## x_t = A * x_t-1 (how prior x affects later x) 
H <- matrix(1) ## y_t = H * x_t   (translating state to measurement)

## run the data through the Kalman filtering method
xhat <- kalman.motion(z, diag(1) * Q, R, A, H)[[1]]

我们在 Figure 7-3 中绘制了真实位置、测量位置和估计位置。

图 7-3. 许多相关数量:测量位置、实际/真实位置、位置的滤波估计(即,在时间 t 时合并时间 t 的测量的位置的最佳估计)、位置的预测(即,在时间 t 时仅合并已知系统动态加上时间 t - 1 之前的测量,而不包括时间 t)。

卡尔曼滤波器消除了大部分测量误差中的噪声。它的效果取决于我们对 R 的值,即测量噪声参数,它反映了滤波器在最近值相对较早值之间应如何权衡。正如我们所见,该滤波器对数据的预测效果令人满意。特别是,注意到预测数据和实际数据之间没有滞后,这表明该方法不仅仅是基于上一个值预测当前值。

在这里,我们已经通过一个卡尔曼滤波器的简单例子进行了探讨。卡尔曼滤波器因其在各种应用中特别是在系统内部动态非常了解的情况下非常有用而广泛研究。这使得它成为像简单的火箭例子这样的情况下的理想工具,我们理解了驱动系统的动态。

在这个简单的例子中,请注意卡尔曼滤波器的全部威力和实用性尚未完全体现。当我们有多种测量方式时特别有用,比如同时使用多个设备测量不同量或同时测量相同物体。此外,如果您的领域很有前景,卡尔曼滤波器还有许多值得研究的扩展。

正如我们所示,卡尔曼滤波器的一个重要优点是它是递归的。这意味着在每个过程迭代中不必查看所有数据点。相反,在每个时间步,所有先前时间步骤的信息已经以最佳方式整合在少数估计参数中,即最近的状态和协方差估计。这种方法的美妙之处在于我们可以智能地更新,仅使用这些“摘要统计量”,并且我们已经知道如何相对于最新数据进行智能加权。这使得卡尔曼滤波器在计算时间或资源有限的实际应用中非常有用。在许多情况下,这也与真实系统的动态相一致,即过程相对马尔可夫(除了即时前状态外无记忆),并且是基于仅能以一定误差测量的基础状态的函数。

关于卡尔曼滤波器,还有许多有用的扩展我们这里没有讨论。卡尔曼滤波器的一个主要用途是将其适应于平滑,这意味着使用时间t之前和之后的数据来最佳估计时间t处的真实状态。数学和代码与已经呈现的内容类似。同样类似的是扩展卡尔曼滤波器(EKF),它将卡尔曼滤波器适应于具有非线性动态的数据。这也相对简单地实现,并且在各种 R 和 Python 软件包中广泛可用。

卡尔曼滤波器在时间序列长度上是O(T),但相对于状态维度dO(d²)。因此,在不必要过度指定状态时,采用更简化的规范就足够了。然而,正是这种对时间序列长度的线性特性使得卡尔曼滤波器在实际生产场景中普遍使用,并比为时间序列状态空间建模开发的其他滤波器更受欢迎。

隐马尔可夫模型

隐马尔可夫模型(Hidden Markov Models,HMMs)是一种特别有用和有趣的时间序列建模方法,因为它是时间序列分析中无监督学习的一个罕见实例,意味着没有标记的正确答案可以用来训练。HMM 受到与本章早期实验卡尔曼滤波器时类似的直觉驱使,即我们能够观察到的变量可能不是系统中最具描述性的变量。就像卡尔曼滤波器应用于线性高斯模型时一样,我们假设该过程具有状态,并且我们的观察结果提供关于此状态的信息。同样,我们需要对状态变量如何影响我们能观察到的内容有一些看法。在 HMM 的情况下,我们假设该过程是一个非线性过程,其特征是在离散状态之间跳跃。

模型工作原理

HMM 假设存在一些不直接可观察的状态。该系统是马尔可夫过程,这意味着仅通过系统的当前状态可以完全计算未来事件的概率。也就是说,知道系统的当前状态及其先前状态并不比仅仅知道系统的当前状态更有用。

马尔可夫过程通常用矩阵来描述。例如,假设有一个在 A 状态和 B 状态之间波动的系统。在任一状态下,该系统统计上更有可能保持在相同状态,而不是在任何不同的时间步骤翻转到另一状态。一个这样的系统可以用以下矩阵描述:

A B
A 0.7 0.3
B 0.2 0.8

假设我们的系统处于 A 状态,即(1, 0)。在这种情况下,系统保持在 A 状态的概率为 0.7,而系统翻转的概率为 0.3。我们不需要知道系统在其最近时刻之前处于什么状态。这就是马尔可夫过程的含义。

隐马尔可夫模型表示的是同类系统,不同的是我们不能直接从观察中推断出系统的状态。相反,我们的观察提供了关于系统状态的线索(参见图 7-4)。

图 7-4. HMM 的过程。在特定时间点系统的实际状态由 x(t)表示,而在特定时间点可观察的数据由 y(t)表示。仅 x(t)与思考 y(t)相关。换句话说,如果我们知道 x(t),则 x(t − 1)不提供预测 y(t)的任何额外信息。同样,仅 x(t)与预测 x(t + 1)有关,而知道 x(t – 1)则不提供任何额外信息。这是系统的马尔可夫特性。

请注意,对于实际应用,状态通常会产生互相重叠的输出,因此不清楚哪个状态产生了输出的情况并不罕见。例如,我们将会将 HMM 应用于类似于图 7-5 的数据。这些数据是从四个状态模拟得到的,但是仅通过简单的视觉检查时间序列,并不明显有多少个状态,它们的划分在哪里,或者系统何时从一个状态转换到另一个状态。

图片

图 7-5. 这个时间序列是用四个状态模拟的,但从视觉上观察并不清楚是否有四个状态,也不清楚一个状态何时结束以及另一个状态何时开始。

HMM 的一些现实应用示例包括:

我们如何拟合模型

我们假设存在一个无法直接测量的状态,并且在许多可以应用这种技术的数据集中,没有办法得到一个可以证明正确的答案。那么这种算法如何在不事先了解任何信息的情况下识别隐藏状态?答案是:迭代。没有一个万能方法能够推导出解释观察到的最可能的隐藏状态序列,但是一旦我们完全指定了系统,就可以逐步逼近一个估计。

在一个 HMM 中,我们假设系统完全由以下信息描述:

  • x(t) 转移到 x(t) + 1 的转移概率。这等效于指定一个类似刚才提到的矩阵:在状态 A 和 B 之间转换。矩阵的大小取决于假设状态的数量。

  • 发射概率,即给定 x(t) 后观察 y(t) 的概率。

  • 系统的初始状态。

更具体地,以下是用于描述和拟合 HMM 过程所需的变量列表:

  • Q = q[1], q[2], …q[N] 系统的不同状态

  • A = a[i,j] = a[1,1], a[1,2], ...a[N,N] 转移概率矩阵,指示在任何给定时间步骤中从状态 i 转移到状态 j 的转移概率

  • O = o[1], o[2], …o[T] 从这个过程中按顺序抽样得到的一系列观测值,即观测序列的时间序列

  • b[i(ot)] 表示发射概率,即在状态为 q[i] 时观察到给定观察值 o[t] 的概率

  • p = p[1], p[2], …p[N],初始概率分布,即系统以状态 q[1], q[2], …q[N] 开始的概率

然而,对于实际数据,一般都不知道这些变量中的任何一个。已知的只是实际可观测序列 y[1], y[2], …y[t]

Baum Welch 算法

在估计隐马尔可夫模型的参数时,我们使用Baum Welch 算法。这指导我们在复杂任务中估计所有参数的值,就像前面章节详细描述的那样。我们的任务是多方面的。我们正在寻求:

  • 确定每个可能隐藏状态的不同发射概率,并确定从每个可能隐藏状态到每个其他可能隐藏状态的转移概率。我们使用 Baum-Welch 算法。

  • 根据观测历史的完整信息确定每个时间步的最可能的隐藏状态。我们使用维特比算法(稍后描述)。

这是两个相关的任务,每个任务都很困难且计算量大。更重要的是,它们是相互依赖的。在两个相互关联的任务——参数估计和似然最大化——的情况下,我们可以使用期望最大化算法在这两个步骤之间迭代,直到找到一个可接受的解决方案。

要应用 Baum Welch 算法,第一步是指定似然函数,即给定假设参数的情况下观察到给定序列的概率。在我们的情况下,假设的参数将是假设状态每个数学参数。

例如,如果我们假设状态产生具有不同均值和标准差的高斯输出,并且这些值取决于状态,并假设一个双状态模型,我们可能描述模型为μ[1], σ[1], μ[2], 和 σ[2],其中μ[u=i]表示第i个状态的均值,σ[i]表示第i个状态的标准差。这些可以描述发射概率,我们统称为θ。我们还可以将状态序列假设为x[1], x[2], …x[t](我将其简记为X[t]),尽管我们无法观察到,但暂且假定我们能。

然后,似然函数将描述观察到的序列的可能性,给定发射概率的参数(即给定特定状态时观察到的给定观测的概率)和隐藏状态序列作为所有可能的X[t]的积分。p(y[1], y[2], …y[t] |μ[1], σ[1], μ[2], σ[2], …μ[N], σ[Nt]) = p(y[1], y[2], …y[t] |μ[1], σ[1], μ[2], σ[2], …μ[N], σ[N])。

然而,由于复杂性随时间步数呈指数增长,意味着详尽的网格搜索并不现实。因此,我们通过以下方式简化任务,应用 EM 算法:

  1. 随机初始化发射概率变量。

  2. 计算每个可能X[t]的概率,假设了发射概率值。

  3. 使用这些X[t]的值生成发射概率变量的更好估计。

  4. 重复步骤 2 和 3 直到收敛。

这意味着,更不正式地说,如果随机提出两个分布,我们将查看每个时间步长,并确定每个时间步长中特定状态被占据的概率(例如,在时间步长t时,状态 A 的概率是多少?状态 B 呢?)一旦我们为每个时间步长分配了假设状态,我们将使用这些标签重新估计发射概率(聚焦于状态的更好均值和标准差)。然后,我们会再次重复这个过程,使用新更新的发射概率变量来改进我们对X[t]轨迹的估计。

关于 EM 算法的一个重要点要记住的是,不能保证找到全局最优的参数集。因此,值得运行多次适应以查看多次初始化时的全局共识。还重要的是要记住,适应会需要一个烧入期,其适当长度将取决于您的数据和模型的细节。

Viterbi 算法

一旦通过 Baum Welch 算法估计出 HMM 过程的参数,下一个感兴趣的问题是,在给定可观察值时间序列的情况下,最可能的潜在状态序列是什么。

与 Baum Welch 算法不同,Viterbi 算法保证为您提供您所询问问题的最佳解决方案。这是因为它是一种动态规划算法,旨在通过保存路径的部分解决方案全面而有效地探索可能的拟合范围,因此随着路径长度的增加,无需重新计算所有路径的所有路径长度(参见图 7-6)。

图 7-6. Viterbi 算法搜索所有可能解释给定观察时间序列的路径,其中路径表示每个时间步长占据的状态。

在代码中拟合 HMM

虽然 HMM 拟合过程非常复杂,但它在 R 语言的多个软件包中实现。在这里,我们将使用depmixS4软件包。首先,我们需要制定一个适当的时间序列。我们可以通过以下代码来做到这一点:

## R
## notice in this case we have chosen to set a seed
## if you set the same seed, our numbers should match
set.seed(123) 

## set parameters for the distribution of each of the four
## market states we want to represent
bull_mu    <-  0.1
bull_sd    <-  0.1

neutral_mu <-  0.02
neutral_sd <-  0.08

bear_mu    <- -0.03
bear_sd    <-  0.2

panic_mu   <- -0.1
panic_sd   <-  0.3

## collect these parameters in vectors for easy indexing
mus <- c(bull_mu, neutral_mu, bear_mu, panic_mu)
sds <- c(bull_sd, neutral_sd, bear_sd, panic_sd)

## set some constants describing the time series we will generate
NUM.PERIODS     <- 10
SMALLEST.PERIOD <- 20
LONGEST.PERIOD  <- 40

## stochastically determine a series of day counts, with
## each day count indicating one 'run' or one state of the market
days <- sample(SMALLEST.PERIOD:LONGEST.PERIOD, NUM.PERIODS, 
               replace = TRUE)

## for each number of days in the vector of days
## we generate a time series for that run of days in a particular
## state of the market and add this to our overall time series
returns  <- numeric()
true.mean <- numeric()
for (d in days) {
  idx = sample(1:4, 1, prob = c(0.2, 0.6, 0.18, 0.02))
  returns <- c(returns, rnorm(d, mean = mus[idx], sd = sds[idx]))
  true.mean <- c(true.mean, rep(mus[idx], d))
}

在上述代码中,我们使用了一个受股市启发的例子,其中包含牛市、熊市、中性和恐慌模式。选择状态持续的随机天数,以及描述每个状态的发射概率分布的变量(即,_mu 和 _sd 变量,它们指示给定特定状态时我们期望看到的测量值类型)。

通过查看在样本中有多少天对应于每个true.mean,我们可以了解生成的时间序列的外观和每个状态的频率:

## R
> table(true.mean)
true.mean
-0.03  0.02   0.1 
  155   103    58 

灾难!虽然我们打算在模拟系列中包含四个状态,但只包含了三个。这可能是因为第四个状态被包含的概率非常低(0.02)。我们看到,最不可能的状态甚至没有被选入系列。对于给定的时间序列,我们并不能总是知道并非所有可能的状态实际上都包含在时间序列中,这也说明了为什么拟合隐马尔可夫模型困难,并且对算法有些不公平。尽管如此,我们将继续进行分析,指定四个组,看看会得到什么结果。³

我们仍需要拟合一个隐马尔可夫模型(HMM)。所得的 HMM 将为我们提供每个状态的后验概率时间序列,数量与我们指定的状态数量相符。与 EM 算法的早期描述一致,只需指定潜在状态的数量。其余将通过逐步迭代的前后过程逐渐确定。

通常情况下,通过正确的软件包,分析中的难点实际上在需要编写的代码量方面非常简单。在这种情况下,我们使用 R 中的depmixS4⁴包。该模型分为两步进行。首先,使用depmix()函数进行规定,指示预期分布、状态数量和用于拟合的输入数据。然后通过fit函数拟合模型,该函数以模型规范作为其输入。最后,为了生成状态标签的后验分布,我们使用posterior()函数。此时模型本身已经被拟合,因此这是一个将数据标记化的独立任务,现在可以估计状态分布和转移概率参数:

## R
require(depmixS4)
hmm.model  <- depmix(returns ~ 1, family = gaussian(), 
                    nstates = 4, data=data.frame(returns=returns))
model.fit  <- fit(hmm.model)
post_probs <- posterior(model.fit)

在这里,我们生成一个hmm.model,指定可观察向量为returns。我们还指定状态数量为4,并通过family参数的规定说明发射概率遵循高斯分布。我们通过fit()函数拟合模型,然后使用posterior()函数计算后验概率。后验概率告诉我们在给定时间内,对于我们通过拟合过程确定的模型参数,某个状态的概率。

现在我们可以按如下方式可视化状态及其测量值(参见图 7-7):

## R
plot(returns, type = 'l', lwd = 3, col = 1, 
     yaxt = "n", xaxt = "n", xlab = "", ylab = "",
     ylim = c(-0.6, 0.6))

lapply(0:(length(returns) - 1, function (i) {
  ## add a background rectangle of the appropriate color
  ## to indicate the state during a given time step
  rect(i,-0.6,(i + 1),0.6, 
       col = rgb(0.0,0.0,0.0,alpha=(0.2 * post_probs$state[i + 1])),
       border = NA)
}

图 7-7. 在此图中,背景表示不同的状态,实线黑色线表示实际值。垂直白线显示的物体实际上是非常窄的切片,代表被估计处于四个状态中最罕见的状态的时间。

当我们查看模型通过其属性确定的假设分布参数时,我们可以获取有关我们原始数据生成参数的信息。

bull_mu    <-  0.1
bull_sd    <-  0.1

neutral_mu <-  0.02
neutral_sd <-  0.08

bear_mu    <- -0.03
bear_sd    <-  0.2

panic_mu   <- -0.1
panic_sd   <-  0.3

如果我们要匹配(记住恐慌状态没有出现在数据中),它会看起来像,实际上在数据中存在的状态组大致相关:

> attr(model.fit, "response")
[[1]]
[[1]][[1]] *<- coincidentally has a mean near the panic regime
              but that regime did not actually produce any data
              in the sample that was fit. instead the algorithm
              allotted this fourth state to more negative values*
Model of type gaussian (identity), formula: returns ~ 1
Coefficients: 
(Intercept) 
-0.09190191 
sd  0.03165587 

[[2]]
[[2]][[1]] *<- could be consistent with the bear market regime*
Model of type gaussian (identity), formula: returns ~ 1
Coefficients: 
(Intercept) 
-0.05140387 
sd  0.2002024 

[[3]]
[[3]][[1]]  *<- could be consistent with the bull market regime*
Model of type gaussian (identity), formula: returns ~ 1
Coefficients: 
(Intercept) 
  0.0853683 
sd  0.07115133 

[[4]]
[[4]][[1]] *<- could be somewhat consistent with the neutral market regime*
Model of type gaussian (identity), formula: returns ~ 1
Coefficients: 
  (Intercept) 
-0.0006163519 
sd  0.0496334 

如果我们的拟合结果与我们的基线隐藏状态不太吻合,可能的原因之一是我们没有使用适当的转移矩阵,而模型用了一个适合拟合的转移矩阵。我们状态之间的转换不是真正的马尔可夫过程,这可能会影响拟合效果。此外,我们拟合的是一个相对较短的时间序列,状态之间的转换较少,而 HMM 在更长的时间序列上表现更好,有更多的机会观察/推断状态转换。当你在尝试这种技术并准备将其拟合到真实数据时,我建议你想出更加现实的合成数据来测试提议的 HMM。请记住,在大多数真实数据情况下,你是在假设一个不可观测的状态,因此在更受控制的环境(使用合成数据)中思考模型性能的限制,然后再为更有野心的项目做准备。

HMM 非常适合分析许多种类的数据。HMM 已被用来模拟金融市场是处于增长还是衰退阶段,确定细胞内蛋白质折叠的阶段,以及描述人体运动(在深度学习出现之前)。这些模型继续是有用的,更多用于理解系统动态而非预测。此外,HMM 提供的不仅仅是一个点估计或预测。最后,我们可以将先验知识或信念注入到我们的模型中,例如指定用于拟合我们的 HMM 的状态数量。通过这种方式,我们既可以利用统计方法的好处,又可以对系统的先验知识进行参数化。

隐马尔可夫模型(HMM)的数学和计算非常有趣且易于理解。通过研究 HMM 拟合数据的最常见方法,你可以学习许多易于理解的编程技术和数值优化算法。你还将学习到动态规划技术,这对数据科学家或软件工程师非常有帮助。

与卡尔曼滤波器一样,HMM 可用于各种任务。事实上,由于离散状态的增加复杂性,与 HMM 系统相关的推断问题的种类更加复杂。当使用 HMM 时,你可能会面临一些推断任务,其中包括:

  • 确定产生一系列观察结果的状态的最可能描述。这涉及估计这些状态的发射概率以及描述一个状态可能导致另一个状态的传输矩阵。我们做到了这一点,尽管我们没有明确看待过转移概率。

  • 确定在给定一系列观察和状态描述及其发射和转移概率的情况下,最可能的状态序列。我们在前一任务中也这样做过。这有时被称为“最可能的解释”,通常使用维特比算法计算。

  • 过滤和平滑。在这种情况下,过滤将对应于估计最近观测的最新时间步的隐藏状态。平滑将对应于确定在特定时间步给定之前、期间和之后的观察时,隐藏状态的最可能分布。

贝叶斯结构时间序列

贝叶斯结构时间序列(BSTS)与我们先前通过卡尔曼滤波处理的线性高斯模型相关。主要区别在于,贝叶斯结构时间序列提供了一种使用预先存在的组件构建更复杂模型的方式,可以反映关于系统已知事实或有趣假设的已知事实。然后我们设计结构,使用鲁棒拟合技术来估计我们数据模型的参数,并查看该模型是否能很好地描述和预测系统的行为。

与我们在卡尔曼滤波讨论中涵盖的线性高斯模型相比,BSTS 模型的数学和计算任务较为复杂。因此,我们将坚持概述一般情况,然后应用代码。

拟合 BSTS 模型有四个步骤,按以下顺序进行:

  1. 定义了一个结构模型,包括先验的规定。

  2. 应用卡尔曼滤波器来更新基于观测数据的状态估计。

  3. 用于在结构模型内执行变量选择的尖峰与板条方法⁵。

  4. 贝叶斯模型平均应用于合并结果以生成预测。

在下一个示例中,我们将仅关注步骤 1 和 2,其中我们通过预先存在的模块化组件定义一个灵活的模型,然后使用这个模型通过贝叶斯方法来拟合我们的数据,随着时间的推移更新参数估计。

bsts 的代码

在这里,我们使用了来自谷歌的流行和强大的 BSTS 软件包 bsts,以及来自OpenEI.org的开放数据集。

我们首先绘制数据以了解我们试图建模的内容(图 7-8):

## R
elec = fread("electric.csv")

require(bsts)
n = colnames(elec)[9]
par(mfrow = c(2, 1))
plot(elec[[n]][1:4000])
plot(elec[[n]]1:96) 
## as discussed earlier in the book
## appropriate temporal scale is key to understanding ts data

![

图 7-8. 我们在上图中拟合的完整系列(两千个连续的小时测量),以及下图中更短、更易理解的数据子集。在查看日常模式时,图表更有意义。

通过查看我们的数据,我们能够了解如何建模。我们可以看到肯定存在每日模式,甚至可能存在一周中每日的模式。这些反映了我们将在模型中描述的季节性行为。此外,考虑到我们在图 7-8 上部面板中绘制整体数据时看到的非平稳行为,我们还希望允许数据中的趋势。

## R
ss <- AddLocalLinearTrend(list(), elec[[n]])
ss <- AddSeasonal(ss, elec[[n]], nseasons = 24, season.duration = 1 )
ss <- AddSeasonal(ss, elec[[n]], nseasons = 7,  season.duration = 24)

此模型中的局部线性趋势假设数据中的趋势的均值和斜率都遵循随机游走。⁶

模型的季节性组件有两个参数,一个表示不同季节的数量,另一个表示季节的持续时间。在第一个季节性组件中,我们添加一个反映每天循环的组件,每小时一个季节,每个季节持续一小时。在第二个季节性组件中,我们添加一个反映每周循环的组件,每天一个季节,每个季节持续 24 小时。

虽然您可能会想知道我们是否确实从星期一的凌晨 12:01 开始(或者我们希望如何定义一周),但保持一致性比确切的一天季节性标签是否与星期一完全对应更重要。在这里看到的循环模式中,似乎任何将数据分割为 24 小时的方式都可能适用于季节性分析。

代码中最耗费计算资源的部分如下所示。bsts 包的优势在于我们能够运行多个马尔可夫链蒙特卡洛(MCMC)后验计算:

## R
model1 <- bsts(elec[[n]],
               state.specification = ss,
               niter = 100)
plot(model1, xlim = c(1800, 1900)

我们还可以检查季节性组件。例如,我们可以像这样检查一周中每天的季节性组件(参见图 7-9 和图 7-10):

## R
plot(model1, "seasonal", nseasons = 7, season.duration = 24)

图片

图 7-9。显示一周中各天的季节性,显示不同天的差异。还显示了每天的参数分布在时间上保持稳定。

图片

图 7-10。数据趋势以及每日和每周季节性组件的贡献分布。如果将这三个组件相加,就可以得到预测值。

一周中每天的季节性组件显示出很高的稳定性,而在图 7-10 中间绘图中显示的每天小时季节性则倾向于显示随时间变化的趋势,可能与白昼时间的变化相关。在图 7-10 中,我们还可以看到局部线性趋势的参数拟合,显示出整体电力需求的下降趋势。

最后,我们预测,包括未来预测的完整后验分布图(见图 7-11)。请注意,我们在此建模过程结束前具有灵活性,可以指定我们希望向前预测多少个时间段。请记住,这是小时数据,因此向前预测 24 个时间段可能看起来很有野心,但只相当于一天。我们还指出,我们希望看到预测前的 72 个时间段以供参考:

pred <- predict(model1, horizon = 24, quantiles = c(0.05, 0.95))
plot(pred, plot.original = 72)

图 7-11。我们的数据最后 72 小时与未来 24 小时的预测,以及预测的 5%和 95%分位数边界。请注意,随着我们预测时间越来越远,预测的分布也变得更加散布。

bsts 软件包和贝叶斯结构时间序列建模中,我们甚至还未利用的可选功能有相当多。

  • 我们没有指定非标准先验。

  • 我们没有使用尖峰和平板法来选择回归变量。

  • 我们没有使用贝叶斯模型平均。

这些都可以通过 bsts 软件包完成,并且您可以在文档中找到详细指导。

在我们的示例中,我们只是简单地介绍了 BSTS 的一些应用。以下是 BSTS 的其他几个重要选项:

  • BSTS 允许您在建模过程中注入任何类型的先验知识。在讨论卡尔曼滤波器时,我们介绍的标准线性高斯模型只是注入了一种相对基础的先验知识的一种方式,而 BSTS 提供了许多选项(例如,非对称先验)。

  • BSTS 模型允许您进行变量选择。

  • BSTS 模型可以通过贝叶斯模型平均进行组合,这有助于在首次选择模型时考虑与之相关的不确定性。

虽然我们在当前的建模案例中没有使用这些选项,但在使用 bsts 软件包时,它们并不难集成,并且您可以在网上找到许多示例。

更多资源

  • 关于卡尔曼滤波器和线性高斯状态空间模型:

    Greg Welch 和 Gary Bishop,《“卡尔曼滤波器介绍”》,北卡罗来纳大学教堂山分校,1995 年技术报告,https://perma.cc/ZCU8-MXEF。

    这篇关于卡尔曼滤波器的介绍性概述提供了滤波器的推导和矩阵形式化。该介绍还讨论了扩展卡尔曼滤波器,在实际场景中更为常见,可用于非线性过程或非线性测量误差。

    R.E. 卡尔曼,《“线性滤波和预测问题的一种新方法”》,《ASME—基础工程杂志》82,D 系列(1960 年):35–45,https://perma.cc/GNC4-YLEC。

    这篇 1960 年的研究文章是卡尔曼滤波器的最初介绍。如果具备统计学和微积分的基础,其数学内容是相当容易理解的,读起来很有趣,可以了解卡尔曼滤波器的原始动机及其创造者的智力背景。

    R. Labbe,“Python 中的卡尔曼和贝叶斯滤波器”,GitHub 仓库,https://perma.cc/CMU5-Y94A。

    这个庞大的 GitHub 仓库包含数十个使用卡尔曼滤波器及相关“过滤”技术的例子。该仓库像教科书一样,提供了详细的案例、相关 PDF 书籍以及带解答的练习题。

    Marie Auger-Méthé等,“状态空间模型的隐秘小秘密:即使是简单的线性高斯模型也可能存在估计问题”科学报告 6 卷,编号 26677(2016 年),https://perma.cc/9D8V-Z7KJ。

    这篇文章突出了一个案例,即使是简单的线性高斯模型(比如我们在卡尔曼滤波器讨论中使用的模型),在面对测量误差远大于时间序列数值的情况下,也很容易发生极端的错误规定化。作者从生态学家的角度探讨了这个问题,但这个一般性问题在各种数据驱动学科中依然具有普遍的重要性,并且提供了与强调该方法的众多优点相比的平衡视角。

  • 关于隐马尔可夫模型:

    Andrew Moore,“隐马尔可夫模型”,卡内基梅隆大学计算机科学学院讲义,https://perma.cc/K3HP-28T8。

    这些全面的讲义概述了 HMM,包括估计算法的示例和机器人学中 HMM 在实际应用中的使用情况。

    Dan Klein,“人工智能:隐马尔可夫模型”,加州大学伯克利分校讲义,https://perma.cc/V7U4-WPUA。

    这是另一组易于查阅的参考笔记。其中列举了 HMM 在语音数字化和发展 AI 玩策略游戏中的实用示例。

    user34790,“前向-后向和维特比算法之间的区别是什么?” 在交叉验证 StackExchange 上发布的问题,2012 年 7 月 6 日,https://perma.cc/QNZ5-U3CN。

    这篇 StackExchange 帖子提供了一个有趣的讨论和大纲,涉及针对特定 HMM 用例部署的多种估计算法。即使你对相关建模算法的细节不感兴趣,这篇帖子也能帮助你理解 HMM 如何用于理解时间序列数据的方式。

  • 关于贝叶斯结构时间序列:

    Mark Steel,“贝叶斯时间序列分析”,收录于宏观计量经济学与时间序列分析,编辑 Steven N. Durlauf 和 Lawrence E. Blume(英国巴辛斯托克:Palgrave Macmillan,2010 年),35–45 页,https://perma.cc/578D-XCVH。

    这篇简短的阅读提供了关于贝叶斯时间序列分析中不同技术的全面概述,以及对每种方法优劣的简明评论。

    Steven Scott 和 Hal Varian,《用贝叶斯结构时间序列预测现在》,未发表论文,2013 年 6 月 28 日,https://perma.cc/4EJX-6WGA。

    这篇基于经济时间序列的 Google 论文展示了将时间序列预测问题应用于关于特定时间的数据的示例,这些数据随不同滞后期变得可用。特别地,作者使用当前的 Google 搜索来预测失业率,而后者只定期发布,而 Google 搜索计数则是持续可用的。这是通常所说的“现在预测”的一个例子,以指示实际上正在对当前情况进行的预测,因为有一个报告滞后。该论文使用贝叶斯结构时间序列和集成技术的组合。

    Jennifer Hoeting 等人,《贝叶斯模型平均:教程》,统计科学 14 卷 4 期(1999 年):382–401,https://perma.cc/BRP8-Y33X。

    本文全面介绍了贝叶斯模型平均如何通过几种不同方法工作。正如本文所述,贝叶斯模型平均的目的是为了在模型选择过程中考虑由于不确定性而引起的不确定性。通过实例讲解,作者提供了更好估计预测中不确定性的方法。与此相关的,还有一个简要概述BMA,这是用于贝叶斯模型平均的 R 包。

¹ 在本书中,我们分析的是离散采样的数据,在现实世界的应用中最为常见。

² 我还建议阅读关于卡尔曼滤波器的许多替代简单解释,可以从Mathematics StackExchange开始。

³ 注意到模拟数据的另一个问题是,我们没有建立转移概率矩阵来控制隐藏状态从一个状态到另一个状态的流动。实质上,我们内置了这样一种假设:一个状态在很多天内保持不变的可能性更大,然后以相等的概率跳转到任何其他状态。为了保持代码的简洁,我们省略了转移矩阵的正式规定和使用。

⁴ 此包的名称反映了 HMM 的另一个名称,依赖混合模型

⁵ 要了解更多关于尖峰与平板方法的信息,请从Wikipedia开始。数学相当复杂,所以我们不会在这里进一步讨论它。尖峰与平板方法在有许多输入并需要变量选择来简化模型的情况下最为有用。

⁶ 更多信息请参阅文档

第八章:为时间序列生成和选择特征

在前两章中,我们探讨了依赖于使用时间序列中所有数据点来拟合模型的时间序列分析方法。然而,在为下一章讨论机器学习应用于时间序列分析做准备时,在本章中我们将学习时间序列的特征生成和选择。如果您对特征生成的概念不熟悉,那么很快就会了解了。这是一个直观的过程,使数据分析能够发挥创造性。

特征生成是将时间序列数据的最重要特征量化为几个数字值和分类标签的过程。通过一组特征来压缩原始时间序列数据,以描述该时间序列(我们稍后会通过一个快速示例来解释)。例如,一个非常简单的特征生成可以用平均值和时间序列中的时间步数来描述每个时间序列。这是一种在不逐步处理所有原始数据的情况下描述时间序列的方法。

特征生成的目的是尽可能多地压缩关于完整时间序列的信息到几个度量值中,或者用这些度量值来识别时间序列的最重要信息并丢弃其余部分。这对于机器学习方法非常重要,大多数机器学习方法是在非时间数据上开发的,但可以有效地应用于时间序列问题,前提是我们能将时间序列消化成适当格式的输入。在本章中,我们将特别关注允许我们自动生成常用时间序列特征的软件包,因此我们无需重新发明或手工编码它们。

一旦我们生成了一些有用的特征,我们必须确保它们确实有用。虽然手工制作过多无用的特征的可能性不大,但当您使用自动生成大量时间序列特征的代码时,就会遇到这个问题。因此,一旦生成了特征,我们必须检查它们,看看哪些可以在后续分析中丢弃。

传统的机器学习模型最初并未考虑时间序列,因此它们并不自动适用于时间序列分析应用。然而,使这些模型能够处理时间数据的一种方法是特征生成。例如,通过不是用一系列详细描述过程逐步输出的数字来描述单变量时间序列,而是用一组特征来描述它,我们可以访问为横截面数据设计的方法。

在本章中,我们将首先通过一个非常简单的时间序列特征生成示例来工作。然后,我们将审查时间序列的特征生成包,无论是在 R 还是 Python 中。最后,我们将通过一个自动特征生成和特征选择示例来工作。阅读完本章后,您将掌握为下游机器学习应用程序预处理时间序列数据集所需的所有技能,在第九章中介绍。

介绍性示例

想象一下过去一周的早晨、中午和晚上的温度如表 8-1 所示。

表 8-1. 过去一周的温度

时间 温度(°F)
星期一早晨 35
星期一中午 52
星期一晚上 15
星期二早晨 37
星期二中午 52
星期二晚上 15
星期三早晨 37
星期三中午 54
星期三晚上 16
星期四早晨 39
星期四中午 51
星期四晚上 12
星期五早晨 41
星期五中午 55
星期五晚上 20
星期六早晨 43
星期六中午 58
星期六晚上 22
星期日早晨 46
星期日中午 61
星期日晚上 35

您可以绘制这些数据,您会看到周期性(每日循环)的元素,以及总体温度上升的趋势。但我们不能将绘图图像存储在数据库中,大多数接受图像作为输入的方法都是数据密集型的,并试图将图像剥离为摘要度量。因此,我们应该自己做摘要度量。与其将表 8-1 中的 21 个数字描述为时间序列,我们可以用几个词和数字来描述这个系列:

  • 日常/周期性

  • 递增趋势;通过计算斜率可以使这一趋势更加量化

  • 早晨、中午和晚上的均值

通过这样做,我们可以用 2 到 5 个数字来总结这个 21 点时间序列——这是一种在不丢失太多细节的情况下进行的数据压缩。这是特征生成的一个简单案例。然后,特征选择将涉及剔除任何不能充分描述以证明其包含必要性的特征。决定包含什么将取决于我们对特征的下游使用。

计算特征时的一般注意事项

就分析的任何方面而言,在计算时间序列数据集的时间序列特征时,您将希望思考您的分析是否合理,以及您投入生成特征的努力是否更可能因特征过多而导致过拟合,而不是产生有意义的见解。

最佳方法是在进行时间序列探索和清洗时开发一组潜在有用的特征。当您可视化数据并考虑什么区分了同一数据集中的不同时间序列或同一时间序列中的不同时间段时,您会得出关于哪种测量方法对于标记或预测时间序列有用的想法。您还可以从您对系统的任何背景知识或甚至您想通过后续分析测试的工作假设中获得有用的帮助。

接下来,我们将讨论在生成时间序列特征时需要牢记的几个不同考虑因素。

时间序列的本质

当您决定生成时间序列特征时,需要牢记您在数据探索和清洗过程中确定的时间序列的基本属性。

平稳性

平稳性是一个考虑因素。许多时间序列特征假设平稳性,并且在底层数据不平稳或至少不遍历时是无用的。例如,只有在时间序列是平稳的情况下,使用时间序列的平均值作为特征才是实际的,以便平均值的概念有意义。在我们有一个非平稳时间序列的情况下,这个值作为平均值测量的意义不大,因为这种情况下的值更多或少是一个偶然事件,是太多交织在一起的过程(如趋势或季节循环)的结果。

平稳时间序列与遍历时间序列

遍历 时间序列是一个在每个(相当大的)子样本都平等代表序列的序列。这是一个比平稳性更弱的标签,需要这些子样本具有相等的均值和方差。遍历性要求时间中的每个切片都“相等”,包含有关时间序列信息,但其统计测量(均值和方差)不一定相等。您可以在StackExchange找到有关此的有益讨论。

时间序列的长度

特征生成的另一个考虑因素是时间序列的长度。对于平稳时间序列而言,某些特征可能是合理的,但随着时间序列长度的增加,这些特征可能变得不稳定,例如时间序列的最小值和最大值。对于相同的基础过程,较长的时间序列可能会测量比由同一过程产生的较短时间序列更极端的最大和最小值,仅仅因为有更多的数据收集机会。

领域知识

领域知识应该是时间序列特征生成的关键,如果您有幸有一些见解的话。如何应用领域知识生成特定的时间序列特征的一些示例将在本章后面提供,但现在我们将专注于更一般的观点。

例如,如果你正在处理物理时间序列,你应该量化在你研究的系统时间尺度上有意义的特征,同时确保你选择的特征不会过度受传感器误差的特征影响而非底层系统的特征影响。

举个例子,假设你正在处理来自特定金融市场的数据。为了确保金融稳定,该市场规定了一天内的最大价格变动。如果价格变动过大,市场将关闭。在这种情况下,你可以考虑生成一个特征,指示某一天的最高价格。

外部考虑因素

你的计算资源及相关存储资源的范围同样重要。同样重要的是你生成特征的动机。你是生成用于存储以便能够丢弃大量原始数据的特征吗?还是仅仅计算单个分析的特征并计划仅保留原始数据?

你的时间序列特征生成的目的可能会影响你决定计算多少特征以及是否应考虑特别计算密集的特征。这也可能取决于你正在分析的数据集的整体大小。对于小数据集,所有这些决策都将风险较低,但对于极大的时间序列数据集,你可能会冒着开始一项特征生成任务但未完成的风险,浪费计算能量和编码。

在考虑了所有这些因素之后,尝试将特征列表组合在一起,并在一个小数据集上运行它们,以了解它们的运行速度。如果小数据集运行速度太慢,你应该在继续分析之前大幅减少你的时间序列。同样地,你可能需要在数据子集上探索计算负担特征的实用性,然后再使用完整数据集进行分析。

一个灵感来源目录,以寻找特征的地方

时间序列特征生成仅受你的数据、想象力、编码技能和领域知识的限制。只要你能想出一个合理一般且定义明确的方法来量化时间序列的行为,你就可以生成一个特征。一些简单且经常使用的时间序列特征等同于你在其他应用中使用过的同样的汇总统计函数,例如:

  • 均值和方差

  • 最大值和最小值

  • 最后值与第一个值之间的差异

你还将直观地识别其他更具有挑战性的特征,虽然它们在计算上更具有挑战性,但通常是有用的。一些例子包括:

  • 局部极大值和极小值的数量

  • 时间序列的平滑性

  • 时间序列的周期性和自相关

在这种情况下,您需要对实现定义进行一些定义,因为有不同的方法来识别这些常用特征。保持自己的特征生成代码库可用会很有帮助,但您可能还希望查看时间序列数据特征生成库,特别是在您对更具计算要求的特征感兴趣时。在这种情况下,您应该寻找一个优秀的实现,以确保代码既可靠又高效。

现在我们将转向使用时间序列特征生成库,特别关注通过自动特征生成可以获益的广泛特征范围。

开源时间序列特征生成库

曾有多次尝试自动化生成时间序列特征,因为它们往往在各个领域中具有趣味性、描述性,甚至预测性。

tsfresh Python 模块

Python 中自动特征生成的一个特别引人注目的例子是 tsfresh 模块,它实现了大量通用特征。通过考虑一些可用的一般特征类别,我们可以了解到 实现的特征 的广度。这些包括:

描述性统计

这些驱动了我们在 第六章 中学习的传统统计时间序列方法,包括:

  • 增广的迪基-富勒检验值

  • AR(k) 系数

  • 滞后为 k 的自相关

物理启发的非线性和复杂性指标

该类别包括:

  • 函数c3()是计算 L 2 ( X 2 ) × L ( X ) × X(其中 L 是滞后算子)期望值的代理。这被提议作为时间序列中 非线性 的一种度量。

  • 函数cid_ce()计算从 0 到 n – 2 × 滞后的和的平方根,即 (x[i] – x[i+1])²。这被提议作为时间序列 复杂性 的一种度量。

  • 函数friedrich_coefficients()返回拟合模型描述复杂非线性运动的系数。

历史压缩计数

该类别包括诸如:

  • 出现多次的时间序列值之和

  • 最长连续子序列的长度,该子序列高于或低于均值

  • 时间序列中最小值或最大值的最早出现

类似于tsfresh的模块可以帮助您节省时间并选择有效的特征选择实现。它还可以教育您描述数据的方式,这可能与您自己的研究不同但相关。使用模块还有许多其他好处,特别是当您将分析与开源、经过充分验证的工具结合使用时。

  • 在计算标准特征时,没有必要重新发明轮子。通过使用共享库,您可以确保其他用户进行了一些准确性检查,而不是怀疑自己的代码并需要验证它。

  • 此类库提供了计算特征的框架,而不仅仅是特征列表。例如,tsfresh具有特征计算器类,您可以使用它来为自己的目的扩展此库,同时享受系统化框架的好处。

  • 此库设计用于与特征的下游消费者连接,最重要的是与sklearn连接,以便您的特征可以轻松传递给机器学习模型。

tsfresh库在技术上具有特别的风格,其中许多特征源于对科学实验数据分析思想的理解。

Cesium 时间序列分析平台

一个更易接近但同样广泛的生成特征目录是Cesium库中实现的列表。当前列表在其文档中可用,并且接下来我们选择一些有趣的特征进行讨论和检查。一般类别在源代码中分解,但我们在此进一步细分:

  • 描述数据值的整体分布的特征,而不考虑其时间关系。此类别可以包括多种特征,尽管它们都是时间无关的:

    • 数据直方图中有多少个局部峰值?

    • 数据点中有多少百分比落在接近数据中位数的固定值窗口内?

  • 描述数据时间分布的特征:

    • 以时间测量之间的分布为其自身分布,并计算类似于刚刚描述的统计量的特征,现在是在时间差异的分布上而不是数据值上

    • 计算下一个观测值在n个时间步骤内发生的概率的特征,考虑到观察到的分布

  • 描述时间序列行为周期性度量的特征。通常,这些特征与Lomb-Scargle 周期图相关联。

刚刚描述的特征可以计算为整个时间序列或作为滚动或扩展窗口函数。考虑到我们在早期章节中学到的关于编写滚动和扩展窗口函数的机制,我们当然可以自己实现这些特征,并且我们有能力理解文档和这个库所做的事情的含义。在这种情况下,我们将应用滚动窗口函数来总结数据,而不是清理数据。时间序列分析中的相同技术在许多不同但同样有用的情况下使用。

cesium库提供了除特征生成外的补充功能。例如,它包括一个基于 Web 的 GUI 来执行特征生成,并且还与sklearn集成。

如果您在自己的数据上尝试这些库,您会注意到时间序列生成非常耗时。因此,您应该仔细考虑您需要为数据生成多少特征,以及在何时自动生成特征比仔细开发自己的特征更有意义。

这些库生成的许多特征在计算上是繁重的,并且——考虑到特征列表的广泛程度——通常不会解决您尝试回答的问题的关键点。通过一些领域知识,您甚至可能认识到某种特征是无关的、噪音的或不具有预测性的。不要无谓地计算这些无用的特征。这样做会减慢整个分析过程,而不会增加清晰度。自动特征生成库是有用的,但应该谨慎使用,而不是肆意使用。

R 的tsfeatures

tsfeatures,由 Rob Hyndman 等人开发,是一个方便的 R 包,用于生成各种常用和有用的时间序列特征。文档包括特征清单,其中包括以下有用的函数:

  • acf_features()pacf_features(),它们分别计算与系列行为中自相关的重要性相关的一些相关值。对于acf_features()函数,文档描述了以下返回值:“一个包含 6 个值的向量:原始系列、一阶差分系列和两阶差分系列的第一个自相关系数和前 10 个自相关系数平方和。对于季节性数据,还返回第一个季节性滞后的自相关系数。”

  • lumpiness()stability(),这些是瓦片窗口驱动的函数,以及 max_level_shift()max_var_shift(),这些是滚动窗口驱动的函数。在每种情况下,都会对时间序列的重叠(滚动)或非重叠(瓦片)窗口上测量的值应用差异和多样性测量统计量。

  • unitroot_kpss()unitroot_pp()

tsfeatures 包有用地整合并包括了来自多个学术项目的时间序列特征研究,以及其他持续改进的努力,目的是创建在各种领域中有用的时间序列特征。这些包括:

  • compengine() 计算了由 comp-engine.org 项目开发的相同时间序列特征,这些特征在许多领域的时间序列数据中被发现是有帮助的。¹

  • 一些特征借鉴自 hctsa ,该包旨在在 Matlab 中运行高度比较的时间序列分析。其中一些特征包括:autocorr_features()firstmin_ac()pred_features()trev_num()。你还可以从文档中找到其他特征。

tsfeatures 文档 还包括每个生成特征函数的用途和输出的有用示例,以及广泛的参考文献,链接到与该包中提供的时间序列特征集相关的统计学和机器学习工作。

特定领域特征示例

另一个灵感来源可以来自为各种时间序列数据开发的特定领域特征。通常这些特征已经发展了几十年,要么来自经验性能够工作但不被很好理解的启发式方法,要么来自对系统底层机制如何工作的科学知识。

接下来我们将回顾两类时间序列数据(金融和医疗)的一些特定领域特征。

技术股市指标

技术股市指标可能是用于特定领域时间序列应用的最广泛记录和正式化的指标集。经济学家在过去一个世纪研究时间序列数据时,有一套他们常用的功能列表,用于量化金融市场的时间序列并进行预测。即使你对金融市场没有兴趣,这个列表也能启发人们看到,特定领域的特征列表也可以非常广泛、描述性和创造性。

为了让你了解这些指标有多复杂且高度特定于金融市场,这里包含了一个非详尽列表。考虑到其复杂性,人们花费整个职业生涯试图理解这些“信号”如何预测金融市场的涨跌。

相对强弱指数(RSI)

这个测量值等于 100 - 100 /(1 + RS),其中RS是“上升”期间(价格上涨)平均增益与“下降”期间(价格下跌)平均损失的比率,这些上升和下降期间的回顾期是一个输入参数,因此可以对不同的回顾期获得不同的 RSI 值。交易员已经制定了关于 RSI 值截断的经验法则,指示资产相对真实价值是被低估还是被高估。RSI 被称为“动量指标”,因为它依赖于资产运动的测量。

移动平均收敛/发散(MACD)

这个指标本身由三个时间序列组成:

  • MACD 时间序列是资产短期指数移动平均值(“快速”)与资产长期指数移动平均值(“慢速”)之间的差异时间序列。

  • “平均”时间序列是 MACD 时间序列的指数移动平均值。

  • “分歧”时间序列是 MACD 时间序列与“平均”时间序列之间的差异。这通常是用于制定财务预测的值。其他输入系列(MACD 和平均值)通常仅准备用于创建“分歧”系列。

Chaikin 货币流(CMF)

这个指标衡量支出趋势的方向。要计算它:

  • 计算资金流乘数:((Close - Low) - (High - Close))/(High - Low*)。

  • 计算资金流量,即当天的交易量乘以资金流乘数。

  • 对特定天数的资金流量进行汇总,并将其除以相同天数的成交量。该指标是一个“振荡器”,范围在-1 到 1 之间。它指示“购买压力”或“销售压力”,衡量市场的方向。

正如您从这篇对金融时间序列中可以构建的许多技术特性的小样本中看到的那样,有许多方法可以描述时间序列。金融市场是一个特别丰富且广泛研究的特征生成领域。

医疗保健时间序列

医疗保健是另一个具有特定领域含义甚至名称的时间序列特征的领域。正如我们在第一章中讨论的那样,健康数据提供了广泛的时间序列数据。其中一个例子是心电图数据(见图 8-2)。读取心电图既是科学也是艺术,各种特征由医生手动识别并用于读取时间序列。如果您将要选择心电图数据的机器学习研究特征,您肯定会首先研究这些特征,并与知识渊博的医生交谈,以了解这些特征的目的及其指示的内容。

图 8-2。医疗专业人士用于读取心电图时间序列数据的时间序列特征示例。

类似地,如果您正在分析高分辨率血糖时间序列数据,了解影响每日数据的模式类型以及医疗专业人员如何理解和标记此数据也很有帮助(见图 8-3)。

图 8-2 和 8-3 中展示的两个时间序列提供了我们有兴趣找到局部最大值或它们之间距离的良好示例。因此,我们可以很容易地设想从tsfreshcesium库中提取出与医疗时间序列相关的特定特征,这些特征与领域特定知识相关。

图 8-3. 在一天的血糖时间序列中,我们可以看到大多数医疗专业人士会在典型的一天中识别四个特征,其中一个特征与食物无关,而是被称为“黎明现象”。这是我们希望在一个或多个特征中识别的内容之一。

一旦生成了特征,如何选择这些特征

假设您已自动生成了许多特征来描述您的大型时间序列数据集。在首次通过数据时,您可能无法查看所有建议的特征,因此将自动特征生成与自动特征选择相结合可以帮助。一个有用的特征选择算法是 FRESH 算法,该算法在之前描述的tsfresh包中实现。FRESH 代表基于可扩展假设检验的特征提取。

FRESH 算法受到可用时间序列数据数量不断增加的推动,这些数据通常以分布方式存储,从而便于计算的并行化。该算法通过计算每个输入特征相对于目标变量的p值来评估每个输入特征的显著性。一旦计算完成,通过 Benjamini-Yekutieli 程序评估每个特征的p值,该程序根据有关可接受误差率等的输入参数确定保留哪些特征。Benjamini-Yekutieli 程序是在 FRESH 算法初始步骤中用于产生p值的假设检验期间限制发现的假阳性数量的方法。

要独自实施这些步骤将是一项相当艰巨的任务,但我们可以通过几行代码来完成,通过tsfresh。在这里,我们遵循模块文档中插图中使用的代码。首先,我们下载与机器人执行失败相关的时间序列数据:

## python
>> from tsfresh.examples.robot_execution_failures import 
                          download_robot_execution_failures, 
                          load_robot_execution_failures
>> download_robot_execution_failures()
>> timeseries, y = load_robot_execution_failures()

然后我们提取特征,无需指定它们,因为该软件包会自动计算所有特征。从这个意义上说,它与本章中给出的建议相抵触,因为它极度包容,不考虑计算资源的问题。在这个测试数据集中,数据点不多,但您可能不希望在未经缩小的情况下盲目地将其部署到您的数据集中:

## python
>> from tsfresh import extract_features
>> extracted_features = extract_features(timeseries, 
                                         column_id   = "id", 
                                         column_sort = "time")

虽然tsfresh提供了一种指定要计算哪些特征的方法,但在这个例子中,我们选择包括所有特征。您还可以手动设置那些需要考虑参数的特征的参数,而不是使用默认值。所有这些都在文档中有详细描述和插图。

如果您执行了完整的提取,就像我们使用tsfresh提供的示例数据一样,您会看到计算了大量特征:

## python
>> extracted_features.columns
Index(['F_x__abs_energy', 'F_x__absolute_sum_of_changes',
       'F_x__agg_autocorrelation__f_agg_"mean"',
       'F_x__agg_autocorrelation__f_agg_"median"',
       'F_x__agg_autocorrelation__f_agg_"var"',
       'F_x__agg_linear_trend__f_agg_"max"__chunk_len_10__attr_
                                                        "intercept"',
       'F_x__agg_linear_trend__f_agg_"max"__chunk_len_10__attr_
                                                        "rvalue"',
       'F_x__agg_linear_trend__f_agg_"max"__chunk_len_10__attr_
                                                        "slope"',
       'F_x__agg_linear_trend__f_agg_"max"__chunk_len_10__attr_
                                                        "stderr"',
       'F_x__agg_linear_trend__f_agg_"max"__chunk_len_50__attr_
                                                        "intercept"',
       ...
       'T_z__time_reversal_asymmetry_statistic__lag_1',
       'T_z__time_reversal_asymmetry_statistic__lag_2',
       'T_z__time_reversal_asymmetry_statistic__lag_3',
       'T_z__value_count__value_-inf', 'T_z__value_count__value_0',
       'T_z__value_count__value_1', 'T_z__value_count__value_inf',
       'T_z__value_count__value_nan', 'T_z__variance',
       'T_z__variance_larger_than_standard_deviation'],
      dtype='object', name='variable', length=4764)

有 4,764 列。这比我们手工计算的特征数要多得多,但在实际数据集上运行却非常耗时。在决定如何和何时部署这样一组超大规模的特征时,请务必实际考虑您的计算能力以及仔细审查结果的能力。请记住,对于时间序列数据,异常值可能对后续分析产生特别重大且不利的影响。您将希望确保您选择的特征对异常值具有一定的抗性。

虽然 FRESH 算法有助于解释特征之间的依赖关系,但要理解它却有些困难。我们还可以使用更传统和透明的特征选择技术——递归特征消除(RFE)。我们可以使用 RFE 来补充我们对 FRESH 算法的使用,并增强我们对 FRESH 算法选择和未选择的特征之间差异程度的理解。

注意

RFE 描述了一种逐步减少特征的特征选择方法,通过逐步从更加包容的模型中排除特征,直到选择过程开始时设定的最小特征数目为止。

这种技术被称为后向选择,因为您从最包容的模型开始,然后“向后”移动到更简单的模型。相比之下,前向选择是逐步添加特征,直到达到指定特征的最大数目或其他停止标准为止。

我们可以使用 RFE 进行特征选择,同时也可以作为评估特征重要性的一种方式。为了运行一个实验,我们从由 FRESH 算法保留的特征列表中随机选择 10 个特征,并从 FRESH 算法拒绝的特征列表中随机选择 10 个特征:

## R
>> x_idx = random.sample(range(len(features_filtered.columns)), 10)
>> selX = features_filtered.iloc[:, x_idx].values
>> unselected_features = list(set(extracted_features.columns)
                        .difference(set(features_filtered.columns)))
>> unselected_features = random.sample(unselected_features, 10)
>> unsel_x_idx = [idx for (idx, val) in enumerate(
           extracted_features.columns) if val in unselected_features]
>> unselX = extracted_features.iloc[:, unsel_x_idx].values
>> mixed_X = np.hstack([selX, unselX])

有了这组 20 个特征,我们可以进行 RFE,以了解这些特征对数据集和我们在 RFE 中使用的模型的重要性排名:

>> svc = SVC(kernel="linear", C=1)
>> rfe = RFE(estimator=svc, n_features_to_select=1, step=1)
>> rfe.fit(mixed_X, y)
>> rfe.ranking_
array([ 9, 12,  8,  1,  2,  3,  6,  4, 10, 11, 
       16,  5, 15, 14,  7, 13, 17, 18, 19, 20])

在这里,我们可以看到我们输入 RFE 算法的 20 个特征的相对排名。我们希望前 10 个特征(这些特征是由 FRESH 算法选定的)的排名高于未被 FRESH 算法选定的特征。这在很大程度上但不完全是这样的。例如,我们可以看到在数组的后半部分,表示未选定特征的排名中,实际上有第 5 和第 7 重要的特征,占据了所有 20 个特征中的前半部分。然而,我们不应期望完美匹配,结果在很大程度上是一致的。

我们可以使用 RFE 对选定的特征进行进一步修剪。如果我们尝试微调 FRESH 算法的输入参数或者首次输入到 FRESH 算法的特征数目,我们还可以将其用作健全性检查。

请注意,FRESH 算法本身基本上是无参数的,因此我们输入的特征的数量和质量是影响其输出的最佳方式。我们为 FRESH 算法设置的另一个参数是 fdr_level,即我们在生成特征后预期的无关特征的百分比。该参数默认为 0.05,但在生成大量特征而不考虑其是否适合您感兴趣的领域时,您可能决定将此值设置得更高,以增强特征过滤的选择性。

总结思考

在本章中,我们讨论了特征选择的动机,以及特征生成如何工作的简单示例,即使是一个短时间序列也可以将其转换为几乎与原始数据同样信息量的更紧凑的数字集。我们还看了两个用于在时间序列数据上实现自动特征生成和选择的 Python 模块的示例,这些模块可以方便地生成数千个时间序列的特征。因为通过这种方式生成的特征中有许多可能不特别有用,所以我们还看了选择最有用的特征的方法,以便将其传递到我们的分析管道的后续阶段,从而使特征生成不会生成嘈杂或无信息的特征。

特征生成对许多目的都很有用:

  • 生成关于时间序列的下游数据,以一种适合于机器学习算法使用的格式,这些算法大多设计为接受每个数据点的特征集,而不是时间序列。

  • 以一种方式总结时间序列数据,将时间观察压缩为几个数字和定性指标的简写。这不仅对分析有用,而且在我们不需要保留完整时间序列的情况下,还可以将时间序列数据存储为更简洁和可读的格式。

  • 提供一组通用的度量来描述和识别在许多不同条件下可能已经被测量的数据的相似性。通过更广泛地总结我们的数据,我们可以使本来看起来不容易比较的数据变得可比较。

在第九章中,我们将使用特征生成来准备数据输入,用于多种依赖于时间序列特征而不是原始时间序列数据的机器学习算法,用于分类和预测目的。

更多资源

¹ 对于有兴趣深入了解细节的读者,推荐查阅时间序列特征集合 Catch22,这些特征已被证明在各种时间序列数据集中非常有用。将特征集从超过 4,000 个减少到仅 22 个,将计算时间减少了 1,000 倍,分类任务的准确度仅降低了 7%。研究人员使用的特征选择流程也非常值得学习,确保了一组相对独立但仍然准确的特征从其起始集中提取出来。

² 对于金融市场时间序列机器学习中可用的广泛特征目录,请参阅Kaggle 博文。不幸的是,看起来那些代码并没有取得很大的成功,但它是基于其领域相关性对潜在有用特征进行详尽准备的一个很好的例子。

第九章:时间序列的机器学习

在本章中,我们将看几个例子,将机器学习方法应用于时间序列分析。这是时间序列分析的一个相对年轻的领域,但已显示出潜力。我们将研究的机器学习方法并不是原本为时间序列特定数据开发的——与我们在过去两章中研究的统计模型不同——但它们已被证明对其非常有用。

这一转向机器学习是从本书早期章节的预测工作中转变而来的。到目前为止,我们一直专注于时间序列预测的统计模型。在开发这些模型时,我们制定了关于时间序列动态和描述其行为中噪声和不确定性的统计的基本理论。然后,我们利用假设的过程动态进行预测,还估计了关于预测的不确定度。对于这些方法,模型识别和参数估计都要求我们认真思考如何最好地描述我们数据的动态。

现在我们转向那些不假设底层过程或关于底层过程的任何规则的方法。我们的注意力转向识别描述过程行为的模式,这些模式对于预测感兴趣的结果(如时间序列的适当分类标签)具有相关性。我们还将考虑无监督学习,以时间序列聚类的形式。

我们涵盖了基于树的方法进行预测和分类,以及聚类作为分类的一种形式。在基于树的方法中,形成时间序列的特征是使用方法的必要步骤,因为树不是一个“时间感知”的方法,不像 ARIMA 模型。

在聚类和基于距离的分类情况下,我们将看到我们可以选择使用特征或使用原始时间序列作为输入的选项。要将时间序列本身用作输入,我们研究了一种称为动态时间规整的距离度量标准,它可以直接应用于时间序列,保留了数据中完整的时间顺序信息,而不是将其折叠成一组必然有限的特征。

时间序列分类

在本节中,我们通过一个例子,演示如何将原始脑电图(EEG)时间序列数据转换为特征,然后可以将这些特征用于机器学习算法。之后,我们使用决策树方法对从 EEG 时间序列中提取的特征进行分类。

选择和生成特征

在上一章中,我们对时间序列特征生成的目的进行了一般讨论。我们还通过tsfresh对时间序列数据集生成特征进行了简要示例。现在我们将使用另一个讨论过的时间序列特征包cesium来生成特征。

cesium包的一个非常方便的属性之一是它提供了各种有用的时间序列数据集,包括最初来源于2001 年研究论文的 EEG 数据集。在这篇论文中,您可以阅读更多有关数据准备的详细信息。对于我们的目的,知道 EEG 时间序列数据集中的五个类别均代表从以下连续时间 EEG 样本中剪切出的等长片段即可。

  • 健康人群闭眼和睁眼时的 EEG 记录(两个独立类别)

  • 癫痫患者在发作期间无发作时的脑部 EEG 记录,来自两个非癫痫相关区域(两个独立类别)

  • 在癫痫发作期间进行的颅内 EEG 记录(一个类别)。

我们通过cesium提供的便捷函数下载了该数据集:

## python
>>> from cesium import datasets
>>> eeg = datasets.fetch_andrzejak()

首先查看我们正在分析的数据的几个示例可能是有帮助的,以了解我们希望如何对这些时间序列进行分类:

## python
>>> plt.subplot(3, 1, 1)
>>> plt.plot(eeg["measurements"][0])
>>> plt.legend(eeg['classes'][0])
>>> plt.subplot(3, 1, 2)
>>> plt.plot(eeg["measurements"][300])
>>> plt.legend(eeg['classes'][300])
>>> plt.subplot(3, 1, 3)
>>> plt.plot(eeg["measurements"][450])
>>> plt.legend(eeg['classes'][450])

这些图表显示了 EEG 测量类别之间的一些差异(见图 9-1)。EEG 图表明显不同并不奇怪:它们正在测量不同大脑部分在健康受试者和癫痫患者中不同活动期间的活动。

图 9-1. 我们绘制了来自 EEG 数据集的三个随机选择样本。这些是独立的样本,而不是来自同一大脑不同部分的同时测量。每个是在不同时间对不同患者进行的独立时间序列测量。

这些可视化为特征生成提供了指导。例如,Z 类和 G 类似乎比 S 类具有更少的偏斜数据。此外,每个类别的值范围差异相当大,通过检查 y 轴可以看出。这表明幅度特征可能是有用的。此外,不仅仅是总振幅,而是点的整体分布在三个类别中似乎具有特征性差异。我们将在分析中使用这些特征以及其他几个,并且接下来我们展示生成这些特征的代码。

在这里,我们使用cesium生成特征:

## python
>>> from cesium import featurize.featurize_time_series as ft
>>> features_to_use = ["amplitude",
>>>                    "percent_beyond_1_std",
>>>                    "percent_close_to_median",
>>>                    "skew",
>>>                    "max_slope"]
>>> fset_cesium = ft(times           = eeg["times"],
>>>                  values          = eeg["measurements"],
>>>                  errors          = None,
>>>                  features_to_use = features_to_use,
>>>                  scheduler       = None)

这产生了我们的特征,如从 Jupyter 笔记本中截图的图 9-2 所示。

图 9-2. 我们在数据集中生成的前几个样本的特征数值。

请注意,这些值中许多没有经过标准化,所以如果我们使用假定已标准化输入的技术,这将是我们需要牢记的事情。

我们还应确认我们理解我们的特征指示的内容,并且我们的理解是否与cesium计算的内容相符。作为错误检查和常识确认的例证,我们可以验证一个时间序列样本的percent_beyond_1_std

## python
>>> np.std(eeg_small["measurements"][0])
40.411
>>> np.mean(eeg_small["measurements"][0])
-4.132
>>> sample_ts = eeg_small["measurements"][0]
>>> sz = len(sample_ts)
>>> ll = -4.13 - 40.4
>>> ul = -4.13 + 40.4
>>> quals = [i for i in range(sz) if sample_ts[i] < ll or 
                                    sample_ts[i] > ul  ]
>>> len(quals)/len(ser)
0.327 ## this checks out with feature generated in Figure 9-2

特征应该是遍历的

在选择为时间序列生成特征时,请确保选择遍历性特征,意味着随着来自同一过程的更多数据的收集,测量的值将收敛到稳定值。不满足这一条件的例子是随机漫步,对于随机漫步来说,过程的平均值测量是无意义的,也不是遍历性的。随机漫步的平均值不会收敛到一个特定值。

在我们绘制的 EEG 数据中,从任何给定时间序列的不同子样本显然是可比较的,并且序列本身是弱稳态的,因此我们生成的特征是有意义的。

你应该能够验证你正在使用的任何特征。这只是一个负责任分析的简单问题。你不应该向你的算法提供你不能理解、解释和验证的信息。

警告

不要过度使用特征生成库。编写自己的代码生成特征并不难。如果你在一个领域工作,在这个领域中特定组合的特征经常生成,你应该编写自己的代码,即使最初使用了一个包。

你可以通过各种方法优化你的代码,而普通探索性包的作者无法做到这一点。例如,如果你有几个特征依赖于时间序列的平均值计算,你可以创建代码只计算一次平均值,而不是分别计算每个特征一次。

决策树方法

基于树的方法反映了人类做决策的方式:一步一步地,以非常非线性的方式。它们反映了我们做复杂决策的方式:一步一步地思考一个变量如何影响我们的决策,然后是另一个变量,非常类似于流程图。

我假设你已经使用过决策树或可以快速直觉地理解决策树是什么。如果你需要更多支持,请暂停在这里并查看一些背景阅读

图 9-3 展示了一个简单的决策树示例,用于估计某人的体重。

图 9-3。在这个简单的回归树中,一系列逻辑分支用于得出人体预测体重。这是一个粗糙的模型,但它说明了即使是简单的树模型也可以应用于回归问题的非线性和多变方法。

在分析时间序列数据时,有大量人类行为表现得像一个决策树。例如,一名自主的股票市场交易员可能会使用技术指标,但他们可能会以串行的层次化方式使用它们,就像一棵树一样 —— 首先询问,例如,根据一个技术指标趋势动量的方向是什么,然后询问随时间变化波动率的演变情况,这第二个问题的答案与第一个问题以非线性的树状方式相互作用。很可能他们的大脑中有类似决策树结构,用来预测市场的走向。

同样,当医疗专业人员阅读脑电图(EEG)或心电图(ECG)时,通常会先寻找一个特征的存在,然后再考虑其他特征,依次通过一系列因素进行工作。如果一个特征存在而另一个不存在,将会导致与相反情况下不同的诊断,从而对患者预后产生不同的预测。

我们将使用从脑电图数据生成的特征作为输入,应用于两种不同的决策树方法,随机森林和梯度增强树,每种方法都可以用于分类任务。我们的任务将仅基于我们从原始数据生成的特征对讨论过的脑电图数据进行分类。

随机森林

随机森林是一种模型,其中我们不是使用单一的决策树,而是使用许多决策树。我们的分类或回归结果是这些树输出的平均值。随机森林寻求“众人的智慧”,其中众人由许多简单模型组成,每个模型本身可能并不特别好,但是所有这些模型在一起通常能够胜过一个经过精细调整的单一决策树。

早在 1969 年的研究论文《预测的组合》中,两位备受尊敬的统计学家 J.M.贝茨和 C.W.J.格兰杰提出了将一组模型组合以产生预测的想法,而不仅仅是努力寻找单一的“最佳”模型。该论文表明,组合两个单独的航空公司乘客数据预测可以导致具有较低均方误差的模型,这是一个令人惊讶且当时并不直观的结果。年轻一代分析师,他们通常是通过机器学习而不是统计学进入数据分析领域,对这样的想法感到直观而不是令人不安,而随机森林已经成为各种预测问题的得力工具。

随机森林是根据指定的树木数量和这些树木的最大允许深度来构建的。然后,对于每棵树,使用数据和其特征的随机样本来训练该树。这些树通常被参数化为相当简单,以避免过拟合,并且模型可以对许多一般模型求平均,其中没有一个特别好,但所有这些模型都足够一般化以避免数据中的“陷阱”。

正如前面提到的,我们将计算出的每个时间序列样本的特征输入模型作为我们的训练输出。理论上,我们可以考虑如何输入原始的时间序列数据,而不是计算出的特征,但是这种方法存在一些问题:

  • 处理不等长时间序列会很复杂。

  • 这么多输入(等于或接近时间步长的数量)将导致计算成本高昂的模型和训练。

  • 假设没有特别重要的时间步长(因为任何给定时间步长都会与一个特征相关联),从树的角度来看,训练时会有很多噪音和非常少的信号。

因此,随机森林不适合处理原始形式的时间序列数据,但在其被压缩为摘要特征后,它们可以是有用的。以下是一些具体原因:

  • 从效率/计算资源的角度来看,我们可以将极长的时间序列压缩成少数特征,并找到具有合理准确度的模型,这是非常棒的。

  • 随机森林减少了过拟合的风险是有帮助的。正如我们之前讨论过的,过拟合对于时间序列分析是一个问题,因为过拟合和前瞻之间存在不利的协同效应。有意愿采用愚蠢/简单的方法可以部分解决这些问题。

  • 对于我们没有工作模型或关于过程基础机制的假设的时间序列数据,随机森林可能特别合适。

一般规则是,分析师在时间序列分类案例中更容易成功地部署随机森林,而不是时间序列预测案例。相比之下,我们讨论的下一个方法,梯度提升树,在这两个任务中都取得了很大成功。

梯度提升树

提升 是另一种构建预测器集合的方法。提升按顺序创建模型,后续模型应纠正先前模型的错误,并且后续模型应更重视先前模型的数据拟合不良。

梯度提升树已成为首选的提升方法论,尤其在时间序列中表现卓越,这一点在最近几年的一些数据科学竞赛中得到了证明。

XGBoost 的工作方式是顺序构建树,每棵树都试图预测之前树组合的残差。例如,XGBoost 构建的第一棵树将尝试直接匹配数据(一个类别或数值)。第二棵树将尝试预测真实值减去预测值。第三棵树将尝试预测真实值减去第一棵树预测值再减去第二棵树对第一棵树残差的预测值。

然而,XGBoost 并不是无限地构建模型,试图无限制地最小化预测的残差的预测的残差。XGBoost 算法最小化的损失函数还包括一个惩罚项,用于限制将要生成的树的数量。也可以直接限制生成的树的数量。

在过去的几年中,许多人报告使用 XGBoost 比起传统的时间序列机器学习方法(例如在 Kaggle 竞赛或工业机器学习会议中)取得了更大的成功。

Tip

Bagging(或更正式地称为 bootstrap aggregating)是一种训练模型的技术,其中为集成中的每个不同模型随机生成训练集。随机森林通常在模型训练中使用 bagging 方法。

Boosting,正如已经注意到的,是一种训练模型的技术,其中一个集成由顺序训练的模型组成,每个模型专注于纠正其前任所犯的错误。Boosting 是梯度提升树模型训练的核心所在。

Code example

对于随机森林和 XGBoost,编写一个机器学习模型可能比理解该模型的工作原理更容易。在这个例子中,我们将训练一个随机森林和一个梯度提升树模型来基于我们生成的特征对我们的 EEG 数据进行分类。

我们使用 sklearn 将我们的数据分成训练和测试数据集:

## python
>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(
     		fset_cesium.values, eeg["classes"], random_state=21)

我们首先使用随机森林分类器。在这里,我们可以看到如何轻松创建一个用于分类我们的 EEG 数据的模型:

## python
>>> from sklearn.ensemble import RandomForestClassifier
>>> rf_clf = RandomForestClassifier(n_estimators = 10, 
>>>                                 max_depth    =  3,
>>>                                 random_state = 21)
>>> rf_clf.fit(X_train, y_train)

我们随后可以通过对 Classifier 对象的方法调用来确定我们数据的外样准确性:

## python
>>> rf_clf.score(X_test, y_test)
0.616

在仅仅几行代码中,我们有一个模型,它比我作为一个没有医学教育的人类分类器做得更好。还要记住,由于特征选择,这个模型只看到摘要统计数据,而不是整个 EEG。

XGBoost 分类器的代码同样简单而简洁:

## python
>>> import xgboost as xgb
>>> xgb_clf = xgb.XGBClassifier(n_estimators    = 10, 
>>>                              max_depth    =  3,
>>>                              random_state = 21)
>>> xgb_clf.fit(X_train, y_train)
>>> xgb_clf.score(X_test, y_test)
0.648

我们可以看到 XGBoost 分类器模型比随机森林模型稍微好一些。它还训练速度稍快,正如我们可以通过这个快速实验计算每个模型的训练时间所看到的:

## python
>>> start = time.time()
>>> xgb_clf.fit(X_train, y_train)
>>> end = time.time()
>>> end - start
0.0189

## Random Forest
>>> start = time.time()
>>> rf_clf.fit(X_train, y_train)
>>> end = time.time()
>>> end - start
0.027

这种执行速度显著提高,随机森林比XGBoost花费的时间多 50%。虽然这不是一个明确的测试,但它确实指出了XGBoost的优势,特别是在处理大数据集时。您需要确保这种优势在使用更多示例和更多特征的更大数据集时扩展。

我们可以公平地问,是否有关于我们特定的超参数集合,使XGBoost比随机森林更有优势的内容。例如,如果我们通过设置较低的深度使用更简单的树,或者如果我们允许模型中存在更少的决策树总数,这些可能性都很容易测试,再次显示出XGBoost倾向于保持其优势。

例如,如果我们允许集成中有相同数量的决策树,但通过减少树的深度降低了复杂性,我们会发现梯度提升模型的准确性高于随机森林模型:

## python
>>> ## Test the same number of trees (10) but with less complexity
>>> ## (max_depth = 2)
>>> 
>>> ## XGBoost
>>> xgb_clf = xgb.XGBClassifier(n_estimators = 10, 
                             max_depth    =  2,
                             random_state = 21)
>>> xgb_clf.fit(X_train, y_train)
>>> xgb_clf.score(X_test, y_test)
0.616

>>> ## Random Forest
>>> rf_clf = RandomForestClassifier(n_estimators = 10,
                                max_depth    =  2,
                                random_state = 21)
>>> rf_clf.fit(X_train, y_train)
>>> rf_clf.score(X_test, y_test)
0.544

即使我们进一步减少树的复杂度,这也是真实的:

>>> ## Test the same number of trees (10) but with less complexity
>>> ## (max_depth = 1)

>>> ## XGBoost
>>> xgb_clf = xgb.XGBClassifier(n_estimators = 10,
                                max_depth    =  1,
                                random_state = 21)
>>> xgb_clf.fit(X_train, y_train)
>>> xgb_clf.score(X_test, y_test)
0.632

>>> ## Random Forest
>>> rf_clf = RandomForestClassifier(n_estimators = 10,
                                max_depth    =  1,
                                random_state = 21)
>>> rf_clf.fit(X_train, y_train)
>>> rf_clf.score(X_test, y_test)
0.376

解释梯度提升树优于随机森林的性能和潜在优势的几个可能原因。一个重要的考虑因素是,我们不能确定我们为分类选择的所有特征是否特别有用。这突显了梯度提升(梯度提升树)可能优于装袋(随机森林)的一个例子。提升会更有可能忽略无用的特征,因为它总是利用完整的特征集并优先选择相关的特征,而某些由装袋产生的树则被迫使用意义较小的特征。

这也表明了在与我们在上一章中讨论过的超级特征生成库配对时,提升的有用性。如果您采取生成数百个时间序列特征的方法——远远超出您能合理检查的数量——提升可能会避免真正灾难性的结果。

提示

梯度提升树对于大数据集特别有用,包括大型时间序列数据集。您选择的实现可能会稍微影响您的准确性和训练速度。除了XGBoost,您还应考虑LightGBMCatBoost。据报道,后两个软件包的性能有时比XGBoost快得多,尽管有时在样本外测试准确性略有下降。

分类与回归

在前面的例子中,我们考虑了用于时间序列分类的随机森林和梯度提升树方法。这些方法也可以用于时间序列预测。

许多统计学家认为,在预测领域,机器学习并没有比传统的时间序列统计分析更成功,或者说并没有更成功。然而,在过去几年中,梯度提升树用于预测已经起飞,并且在给定足够大的数据集时,通常在预测竞赛和工业应用中表现优异。然而,在这种情况下,需要花费大量时间来调整模型的参数以及准备时间序列特征。

梯度提升树模型的一个优势在于它们在筛选掉无关或嘈杂特征并专注于最重要特征方面接近“自动驾驶”。然而,仅凭这种倾向还不足以使模型达到最先进的性能。即使对于看似自动的方法,如梯度提升树,输出的好坏也取决于输入的质量。提高模型性能的最重要方式仍然是提供高质量和经过充分测试的输入特征。

有多种方法可以改进当前的模型。我们可以通过使用XGBoost的选项来生成特征重要性指标来学习。这可以帮助我们识别有用特征和无用特征的特性,然后我们可以通过添加类似于已判断为有用的特征的新特征来扩展数据集。我们还可以进行超参数网格搜索来调整模型参数化。最后,我们可以查看误标记数据的原始时间序列,看看是否有误标记数据的特性未被当前特征集所表示。我们可以考虑添加能更好地描述误标记数据的特征,进一步增强我们的输入。

聚类

聚类的一般理念是,彼此相似的数据点在分析目的上构成有意义的群组。这一理念对于时间序列数据和其他类型的数据同样适用。

正如我们之前讨论的那样,我假设您对非时间序列上下文中的相关机器学习概念有一定的了解。如果您对聚类技术不熟悉,我建议您在继续本节之前进行一些简短的背景阅读

时间序列的聚类可以用于分类和预测两种情况。在分类的情况下,我们可以使用聚类算法在训练阶段识别出所需的聚类数量。然后,我们可以使用这些聚类来建立时间序列的类型,并识别新样本是否属于特定群组。

在预测的情况下,应用可以是纯粹的聚类,也可以受到聚类的启发,以使用相关的距离度量(关于距离度量的更多内容即将详述)。有几种选项可以根据聚类和相关技术在未来的时间H内生成预测。请记住,在这种情况下,我们将不会完全观察到时间序列,而只能看到其前N步,我们希望预测其在时间步N + h的值。在这种情况下有几种选项。

一种选择是使用类成员资格来生成基于该类典型行为的预测。为此,首先确定时间序列样本根据其前N个时间步属于哪个聚类,然后根据聚类成员资格推断未来行为的可能性。具体来说,查看这个聚类中时间序列值在时间步N和时间步N + h之间如何变化。请注意,您需要基于其前N步执行所有时间序列的原始聚类,而不是基于时间序列的所有部分,以避免预先看到。

另一种选择是基于样本时间序列在样本空间中最近邻(或最近邻)的行为预测未来行为。在这种情况下,根据前N个时间步的指标,找到时间序列样本的最近邻(们),其完整轨迹已知。然后对这些最近邻的N + h行为取平均,这就是当前样本的预测。

在分类和预测的情况下,最重要的考虑因素是如何评估时间序列之间的相似性。可以使用各种距离度量进行聚类,并且已经有大量研究致力于思考如何在高维问题中测量距离。例如,两个求职者之间的“距离”是什么?两个血样本之间的“距离”是什么?这些挑战在横截面数据中已经存在,并且在时间序列数据中仍然存在。

在将聚类技术应用于时间序列数据时,我们有两类距离度量选项:

基于特征的距离

为时间序列生成特征,并将其视为计算数据的坐标。这并不能完全解决选择距离度量的问题,但它将问题缩小到与任何横截面数据集提出的距离度量问题相同的范围内。

基于原始时间序列数据的距离

找到一种方法来确定不同时间序列的“接近程度”,最好能够处理不同的时间尺度、不同的测量次数以及其他可能存在的时间序列样本之间的差异。

我们将把这两种距离度量应用于一个时间序列数据集,其中每个样本代表从 2D 图像到 1D 时间序列的手写单词的投影。

从数据生成特征

我们已经讨论了生成和选择特征的方法。在这里,我们考虑如何根据它们特征的相似性来评估时间序列数据集之间的距离。

在理想情况下,我们可能已经通过使用树来评估特征的重要性来删除不重要或无趣的时间序列。我们不希望将这些特征包含在距离计算中,因为它们可能会错误地指示两个时间序列之间的不同,而实际上它们只是不与我们分类任务中的类别或预测任务中的结果相关。

就像我们对 EEG 数据集所做的那样,我们从查看一些类别示例开始我们的分析,并注意随时间变化及时间序列结构中的明显差异。

我们的数据是 UEA 和 UCR 时间序列分类存储库提供的FiftyWords数据集的子集。这个数据集是根据一篇 2003 年的论文中的方法进行释放的,该论文研究了在历史文档中聚类手写单词。在那篇论文中,作者开发了“单词轮廓”作为将手写单词的二维图像映射到一维曲线的一种方法,该曲线由相同数量的测量组成,无论单词长度如何。存储库数据集与论文中的数据集不完全相同,但原理相同。原始论文的目的是开发一种方法,通过单一标签标记文档中所有相似或相同的单词,以便人类可以返回并数字化地标记这些单词(这是一个现在可能会直接由神经网络完成的壮举,借助 20 年的技术进步来帮助完成任务)。

在这个例子中,我们看到了投影文件中的样本投影,其中“投影”指的是它们将图像从二维空间转换为一维空间,后者由于顺序的重要性,适合进行时间序列分析。请注意,“时间”轴实际上不是时间,而是书面文字从左到右的进展。尽管如此,概念是相同的——有序且等间距的数据——因此为了简单起见,在分析中我将使用时间时间上的这两个词,尽管这并非严格正确。对于我们的用例,这没有区别。

在图 9-4 中,我们看到了几个不同单词的示例。¹

当我们检查这些数据时,特别是考虑到绘图中对人眼明显的模式,就像对 EEG 一样,我们可以制定一些特征作为我们分析的起点——例如,峰值的高度和位置以及它们上升的锐度和峰顶的形状。

我描述的许多特征开始听起来更像是图像识别特征,而不像时间序列,这对于特征生成是一个有用的视角。毕竟,视觉数据通常是我们可以轻松处理并且更直观的数据。在考虑特征时,将其视为图像可能会有所帮助。这个视角也说明了为什么生成特征可能会令人惊讶地困难。在某些情况下,通过检查时间序列可能很明显如何区分两个类别,但我们可能会发现编写代码并不那么明显。或者我们可能会发现,我们可以编写代码,但这非常费力。

图 9-4. 三个不同单词(12、23 和 9)的投影轮廓彼此非常不同。我们已经看到一些可以区分这些单词的特征:最大峰值或第二大峰值的时间位置(x 轴),有多少局部峰值,值的总范围以及曲线的平均凸度。

在过去的十年中,深度学习已经成为图像分类的最强表现者,有了足够的数据,我们可以在这些图像的基础上训练深度学习分类器(更多内容请参见第十章)。目前,对于我们来说,想出解决编程困难的方法将是有帮助的。例如,生成定位每个峰值的特征将会很困难,因为找峰值在程序上要求很高,而且有点艺术性。

我们还可以使用 1D 直方图,无论是所有类别示例的还是单个示例的。这可能建议出一些计算上较少要求的方法,用于识别峰值或找到其他代理值,这些代理值将映射到我们在时间序列中看到的整体形状。在这里,我们绘制了以前绘制过的相同的单个类成员,现在伴随它们的 1D 直方图(参见图 9-5):

## python
>>> plt.subplot(3, 2, 1)
>>> plt.plot(words.iloc[1, 1:-1])
>>> plt.title("Word = " + str(words.word[1]), fontweight = 'bold')
>>> plt.subplot(3, 2, 2)
>>> plt.hist(words.iloc[1, 1:-1], 10)
>>> plt.subplot(3, 2, 3)
>>> plt.plot(words.iloc[3, 1:-1])
>>> plt.title("Word = " + str(words.word[3]), fontweight = 'bold')
>>> plt.subplot(3, 2, 4)
>>> plt.hist(words.iloc[3, 1:-1], 10)
>>> plt.subplot(3, 2, 5)
>>> plt.plot(words.iloc[5, 1:-1])
>>> plt.title("Word = " + str(words.word[11]), fontweight = 'bold')
>>> plt.subplot(3, 2, 6)
>>> plt.hist(words.iloc[5, 1:-1], 10)

图 9-5. 衡量类别的另一种方法是,头脑风暴有用的特征。特别是,单个类别示例的直方图表明直方图的属性,如局部峰值的数量,偏斜度和峰度可能会有所帮助,并且可能是一些显而易见的时间序列曲线属性的良好代理,这些属性对人眼来说很明显,但用代码识别起来并不容易。

我们还希望确保我们正在查看的示例与这些单词的其他示例不是离群值。因此,我们构造了两个单词的 2D 直方图,以了解个体变异(参见图 9-6):

## python
>>> x = np.array([])
>>> y = np.array([])
>>> 
>>> w = 12
>>> selected_words = words[words.word == w]
>>> selected_words.shape
>>> 
>>> for idx, row in selected_words.iterrows():
>>>     y = np.hstack([y, row[1:271]])
>>>     x = np.hstack([x, np.array(range(270))])
>>>     
>>> fig, ax = plt.subplots()

图 9-6. 单词 = 12 的 1D 单词投影的 2D 直方图。y 轴是给定时间步长的值,x 轴代表每个时间序列样本/单词投影的 270 个时间步长。

图 9-6 展示了数据集中所有 word = 12 成员的 2D 直方图。虽然图 9-5 中的单个曲线表明我们应该专注于找到似乎主导时间序列的两个大峰值,但在这里我们看到,这一类大多数成员共同点可能是这些峰值之间的平坦区间,根据该区域内点的强度,这个区间似乎从大约时间步骤 120 到 200。

我们还可以使用这个 2D 直方图来建立这一类的最大峰值的截止点,这一点似乎在时间步骤 50 和 150 之间的位置变化。我们甚至可能希望编写一个像“在点 50 到 150 之间是否达到最大值”这样具体的功能。

我们出于同样的原因绘制了另一个 2D 直方图,这次选择 word 类别 23,该类别在我们在图 9-5 中绘制的例子中有许多小颠簸,这是一个难以量化的特征(参见图 9-7)。

图 9-7. word = 23 的 1D 单词投影的 2D 直方图。y 轴表示给定时间步骤的值,x 轴代表每个时间序列样本/单词投影的 270 个时间步骤。

看到 word 类别 23 在图 9-7 中特别“扩展”的直方图并不奇怪,因为即使我们在图 9-5 中绘制的例子中显示了许多特征,如果这些特征在样本之间没有完全匹配,我们也会预期在 2D 直方图中看到许多扩展。然而,我们还看到这一类的最大点值在这里的时间步骤范围内没有重叠,与 word 类别 12 相比。对于这一类,最大值出现在 150 之后,这是合理的,因为我们在 word 类别 23 示例中看到的两个最大峰值正好在这个范围内。2D 直方图倾向于证实,较早的峰值不如较晚的峰值高,表明有其他方法来量化这个时间序列的形状,以区别于其他类别。

这些 2D 直方图有助于让我们了解单个类别内特征的变异性,这样我们在考虑如何形成我们的特征时,就不会过分依赖一个单一的类别示例。

在这种情况下,我们选择生成一组从词投影形状衍生出的特征,以及从词投影直方图形状衍生出的另一组特征(将一维摘要投射到不同的一维摘要,再从中生成特征)。这是对我们在二维直方图中看到的大“模糊区域”的响应,表明存在高峰,但它们的位置并不特别稳定。使用直方图生成每个词投影的第二个特征形状可能比词投影本身更可靠和特征化。直方图表征系列中出现的值的类型,而不表征它们在系列中的位置,而对于我们来说,重要的是考虑到投影中的高峰并没有特别稳定的时间位置。

首先,我们为每个时间序列生成 270 个时间步长的特征。在这种情况下,我们缩短了用于生成代码可读性特征的函数名称:

from cesium import featurize.featurize_time as ft
## python
>>> word_vals      = words.iloc[:, 1:271]
>>> times          = []
>>> word_values    = []
>>> for idx, row in word_vals.iterrows():
>>>     word_values.append(row.values)
>>>     times.append(np.array([i for i in range(row.values.shape[0])]))
>>> 
>>> features_to_use = ['amplitude',
>>>                    'percent_beyond_1_std',
>>>                    'percent_close_to_median']
>>> featurized_words = ft(times           = times,
>>>                       values          = word_values,
>>>                       errors          = None,
>>>                       features_to_use = features_to_use,
>>>                       scheduler       = None)

接下来,我们生成直方图,并将其用作另一个要生成特征的时间序列:²

## python
>>> ## create some features derived from histogram
>>> times = []
>>> hist_values = []
>>> for idx, row in words_features.iterrows():
>>>     hist_values.append(np.histogram(row.values, 
>>>                                     bins=10, 
>>>                                     range=(-2.5, 5.0))[0] + .0001) 
>>>                                    ## 0s cause downstream problems
>>>     times.append(np.array([i for i in range(9)]))
>>> 
>>> features_to_use = ["amplitude",
>>>                    "percent_close_to_median",
>>>                    "skew"
>>>                   ]
>>> 
>>> featurized_hists = ft(times           = times,
>>>                       values          = hist_values,
>>>                       errors          = None,
>>>                       features_to_use = features_to_use,
>>>                       scheduler       = None)

我们通过传递给np.histogram()的参数,确保所有的直方图使用相同数量和值范围的箱子作为基础。这样做确保所有的直方图是直接可比较的,具有相同范围的箱值,这将在这些直方图通过时间序列特征生成时成为“时间”轴。如果我们不强制保持这种一致性,生成的特征可能不一定有意义,无法比较一个直方图与另一个。

最后,我们结合这两个特征来源:

## python
>>> features = pd.concat([featurized_words.reset_index(drop=True), 
>>>                       featurized_hists], 
>>>                       axis=1)

时间感知距离度量

运行聚类分析时,我们必须选择一个距离度量。对于时间序列特征,正如我们刚才所做的那样,我们可以将各种标准距离度量应用于它们,就像在横截面数据的标准聚类分析中所做的那样。如果您对在这些情况下选择距离度量的过程不熟悉,我建议您先做一些背景阅读

在本节中,我们将重点讨论通过定义它们之间的距离度量来测量时间序列之间相似性的问题。其中一个最著名的度量指标例子是动态时间规整(DTW)。DTW 适用于聚类时间序列,其最显著的特征是其整体形状,正如我们的词投影数据所示。

这项技术的名字源于其方法论,依赖于时间上的“扭曲”,以使时间序列沿其时间轴对齐,从而比较它们的形状。用图片来传达动态时间扭曲的概念远比用文字更有价值,所以请看一下图 9-8。时间(x 轴)被扭曲——即根据需要被扩展或收缩——以便在两条描绘的曲线(即两个时间序列)之间找到最佳的点对齐,以比较它们的形状。

图 9-8. 动态时间扭曲的工作原理。一个时间序列上的每个点都映射到另一个时间序列上的一个点,但并不要求必须有一对一的点映射。这有一些影响:(1) 时间序列不需要具有相同的长度或相同的时间尺度。重要的是形状。(2) 在拟合过程中,时间并不总是沿着相同的速度前进,并且可能不同时间序列的进展速度也不相同。通过时间的进展,我指的是沿着 x 轴方向的曲线。来源:维基百科

注意,在这种算法的标准形式中,与另一条曲线相比的一个曲线上的实际时间值与另一个曲线上的时间轴上的时间值并不相关。我们可以比较一个以纳秒为单位测量的时间序列与另一个以千年为单位测量的时间序列(虽然这可能不是一个明智的练习)。算法的目的类似于比较该算法的视觉“形状”,而不是考虑时间流逝的多少。实际上,“时间”在这里只是指一个有序的均匀间隔的点集,而不是时间本身的概念。

DTW 的规则如下:

  • 每一个时间序列中的点都必须至少与另一个时间序列中的一个点匹配。

  • 每个时间序列的第一个和最后一个索引必须与另一个时间序列中相应的索引匹配。

  • 点的映射必须使时间向前移动,而不是向后。通过匹配一个时间序列中的一个点与另一个时间序列中的一个已经在时间轴上过去的点,不能回到过去。然而,时间不必始终向前移动。例如,原始系列中的两个连续时间步骤可以通过在拟合期间被压缩到 x 轴上的相同位置来被扭曲,就像在图 9-8 中在上曲线/实线的第一个“弯曲”处所示。

有许多方法可以调整时间对齐以遵循这些规则,但选择的匹配是使曲线之间距离最小化的匹配。这个距离,或成本函数,通常被测量为匹配点之间的绝对差的和,其中绝对差是指点之间的值的差异。

现在我们对 DTW 的直观工作方式有了一定的了解,我们可以看看代码:

## python
>>> def distDTW(ts1, ts2):
>>>     ## this is setup
>>>     DTW={}
>>>     for i in range(len(ts1)):
>>>         DTW[(i, -1)] = np.inf
>>>     for i in range(len(ts2)):
>>>         DTW[(-1, i)] = np.inf
>>>     DTW[(-1, -1)] = 0
>>> 
>>>     ## this is where we actually calculate the optimum
>>>     ## one step at at time
>>>     for i in range(len(ts1)):
>>>         for j in range(len(ts2)):
>>>             dist = (ts1[i] - ts2[j])**2
>>>             DTW[(i, j)] = dist + min(DTW[(i-1, j)],
>>>                                      DTW[(i, j-1)], 
>>>                                      DTW[(i-1, j-1)])
>>>             ## this is an example of dynamic programming
>>> 
>>>     ## once we have found the complete path, we return 
>>>     ## the associated distance
>>>     return sqrt(DTW[len(ts1)-1, len(ts2)-1])

如评论所指出的,解决这个问题的方法是动态规划的一个例子,DTW 距离是一个经典的动态规划问题。我们可以从每个时间序列的开头逐步迈出一步,知道我们可以一步一步地建立解决方案,并参考我们先前的知识来做出后续的决策。

有许多不同的 DTW 实现,有各种各样的想法来使寻找最优解或接近最优解的过程更加高效。如果您正在处理更大的数据集,应该特别注意这些。

还有其他衡量时间序列之间距离的方法。以下是几种:

弗雷歇距离

这是两条曲线在时间扭曲遍历期间的最大距离,始终寻求最小化两条曲线之间的距离的一个示例。这种距离度量通常通过一个狗和它的主人伴随着两条曲线之间的系带进行解释。它们需要分别从开始到结束遍历每条曲线,它们可以以不同的速度前进,并且可以在曲线上改变速度,只要它们始终朝着同一个方向移动。弗雷歇距离是它们完成任务所需的系带的最短长度,遵循最佳轨迹(假设它们能找到!)。

皮尔逊相关系数

两个时间序列之间的相关性可以作为衡量它们之间距离的一种方式。与其他距离度量不同,通过最大化相关性指标来最小化时间序列之间的距离。相关性相对容易计算。然而,这种方法要求时间序列具有相同数量的数据点,或者将一个时间序列进行降采样以匹配另一个时间序列的较少数据点。计算相关性的时间复杂度为O(n),这使得它在计算资源方面特别高效。

最长公共子序列

这种距离测量适用于表示分类或整数值序列的时间序列。在这种情况下,为了考虑两个时间序列的相似性,我们可以确定最长公共子序列的长度,即连续值完全相同的最长长度,尽管它们在时间序列中的确切位置不必匹配。与 DTW 类似,这意味着我们更关心找到一个共同性的形状,而不是共同形状出现的时间。还要注意,像 DTW 一样,但不像皮尔逊相关性,这不要求时间序列具有相同的长度。一个相关的测量是编辑距离,通过该距离我们找到我们需要对一个时间序列进行的更改数量,使其与另一个时间序列完全相同,并使用此值定义距离度量。

距离与相似性

有关测量时间序列之间距离的文献也使用相似性一词来描述这些度量。在大多数情况下,你可以将这些术语互换使用,即用来确定哪些时间序列更像或更不像彼此的一种方式。尽管如此,有些度量将是适当的距离,例如 Fréchet 距离,它可以用适当的单位(例如“英尺”或“kg/美元”或时间序列正在测量的任何度量单位)进行计算。其他度量则是无单位的,例如相关性。

有时,一点创意可以为寻找简单但恰当的解决方案提供很大帮助,因此,考虑清楚你的需求并尽可能具体地定义它们总是个好主意。考虑一个Stack Overflow 帖子,寻找一种距离度量用于特定应用,即将时间序列分类以匹配之前聚类分析中的三个质心之一。这三个类别分别是:

  • 一条平直的线。

  • 时间序列开始处有峰值,其他时间为平直线。

  • 时间序列结束处有峰值,其他时间为平直线。用户发现包括欧几里得距离和 DTW 在内的几种标准距离度量方法未能达到预期效果。在这种情况下,DTW 过于宽松,认为任何具有峰值的时间序列与开始处和结束处均有峰值的时间序列距离相等(因此,尽管计算复杂,DTW 并非万能药!)。

在这种情况下,一位聪明的评论者建议了一种转换,使距离度量工作得更好,即比较累积总和的时间序列而不是原始时间序列。在这种转换之后,无论是欧几里得距离还是 DTW 距离都能正确排序,使得时间序列在开始处有峰值的最短距离与该类别原型的距离最小,而不是与开始和结束处均有峰值的原型距离相等。这应该让我们想起我们之前学习过的分析,即通过转换时间序列可以使 ARIMA 模型适用,即使原始数据不满足必要条件。

不幸的是,选择距离度量的“自动驾驶”功能并不存在。你需要凭借自己的判断力来找到以下平衡:

  • 最小化计算资源的使用。

  • 选择强调时间序列特征与你最终目标最相关的度量。

  • 确保你的距离度量反映了你正在配对的分析方法的假设和优缺点。例如,k-均值聚类不使用成对距离,而是最小化方差,因此只有类似于欧几里得距离的技术才有意义。

聚类代码

现在我们已经讨论了如何为聚类分析生成特征,以及如何直接在时间序列之间测量距离作为聚类的距离度量,我们将使用我们选择的特征和我们的配对 DTW 距离矩阵执行聚类,以比较结果。

规范化特征的层次聚类

我们为我们的单词作为时间序列计算了特征,分别为原始记录的时间序列和时间序列的直方图。这些特征可能在完全不同的尺度上出现,因此如果我们想对它们应用单一的距离度量,我们会像特征聚类的标准操作程序一样对它们进行标准化:

## python
>>> from sklearn import preprocessing
>>> feature_values = preprocessing.scale(features.values)

我们选择了一种层次聚类算法,并对 50 个聚类进行了拟合,因为我们试图将这些聚类与我们数据集中的 50 个单词匹配:

## python
>>> from sklearn.cluster import AgglomerativeClustering
>>> feature_clustering = AgglomerativeClustering(n_clusters = 50, 
>>>                                              linkage    = 'ward')
>>> feature_clustering.fit(feature_values)
>>> words['feature_labels'] = feature_clustering.fit_predict(p)

然后我们想看看这些聚类(其标签与原始单词标签无关)是否显示出与单词标签有用的对应关系:

## python
>>> from sklearn.metrics.cluster import homogeneity_score
>>> homogeneity_score(words.word, words.feature_labels)
0.508

我们很幸运地在处理标记数据,否则我们可能会根据我们形成的聚类得出错误的结论。在这种情况下,少于一半的聚类与单个词密切相关。如果我们回过头考虑如何改进这个结果,我们有几个选择:

  • 我们只使用了六个特征。这并不是很多特征,所以我们可以添加更多。

  • 我们可以寻找相对不相关的特征,这一点我们在这里没有做。

  • 我们仍然缺少显然有用的特征。从数据的视觉探索中我们注意到一些特征,但没有包括在内,比如显著峰值的数量和位置。我们可能需要重新设计这个分析,以包括该信息或一些更好的代理。

  • 我们应该探索使用其他距离度量,也许是那些更加强调某些特征而不是其他特征的度量,优先考虑人眼认为有用的特征。

使用 DTW 距离矩阵的层次聚类

我们已经完成了基于时间序列聚类的直接聚类的困难部分,通过计算通过 DTW 的配对距离矩阵。这是计算上非常耗费资源的,这就是为什么我们小心保存结果以便在需要时重新审视分析的原因:

## python
>>> p = pairwise_distances(X, metric = distDTW)
>>> ## this takes some time to calculate so worth saving for reuse
>>> with open("pairwise_word_distances.npy", "wb") as f:
    np.save(f, p)

现在我们已经有了它们,我们可以使用层次聚类算法:

## python
>>> from sklearn.cluster import AgglomerativeClustering
>>> dtw_clustering = AgglomerativeClustering(linkage   = 'average',
>>>                                         n_clusters = 50, 
>>>                                         affinity   = 'precomputed') 
>>> words['dtw_labels'] = dtw_clustering.fit_predict(p)

最后,与以往一样,我们比较拟合的聚类与已知标签之间的对应关系:

## python
>>> from sklearn.metrics.cluster import homogeneity_score, 
>>>                                     completeness_score
>>> homogeneity_score(words.word,  words.dtw_labels)
0.828
>>> completeness_score(words.word, words.dtw_labels)
0.923

我们看到基于 DTW 的这种聚类比我们基于特征的聚类要好得多。然而,如果你在自己的计算机上运行 DTW 距离计算代码——特别是如果它是一台标准笔记本电脑——你将看到 DTW 需要比我们选择的特征计算更长的时间。我们很可能可以改进我们基于特征的聚类,而现在计算出的 DTW 距离聚类则没有明显的改进途径。我们改进这一点的替代方案可能是:

  • 包括特征以及 DTW 距离。从编码的角度来看,这很棘手,从概念的角度来看,也很难决定如何将特征与 DTW 距离结合起来。

  • 尝试其他距离度量。正如前面讨论的那样,适当的距离度量将取决于您的数据、您的目标和您的下游分析。我们需要更明确地定义我们对这个词分析的目标,并几何地思考一下 DTW 是否真的是我们想要实现的最佳度量。

更多资源

  • 关于时间序列距离和相似度测量:

    Meinard Müller,“动态时间规整”, 收录于《信息检索》中,用于音乐和动作(柏林:斯普林格,2007 年),69–84,https://perma.cc/R24Q-UR84。

    Müller 书中的这一章节提供了动态时间规整的广泛概述,包括讨论为减少计算 DTW 的计算复杂度所做的常见近似。

    Stéphane Pelletier,“计算两个多边形曲线之间的弗雷歇距离”,(讲座笔记,计算几何,麦吉尔大学,2002 年),https://perma.cc/5QER-Z89V。

    麦吉尔大学的这套讲座笔记提供了弗雷歇距离是什么以及如何计算的直观视觉和算法解释。

    Pjotr Roelofsen,“时间序列聚类”, 硕士论文,商业分析,阿姆斯特丹自由大学,2018 年,https://perma.cc/K8HJ-7FFE。

    这篇关于时间序列聚类的硕士论文,从详尽且有用的讨论开始,介绍了计算时间序列之间距离的主流技术,包括距离计算的计算复杂度信息,以及帮助建立直觉的有用示例。

    Joan Serrà 和 Josep Ll. Arcos,“时间序列分类相似度测量的经验评估”, 《基于知识的系统》67 卷(2014 年):305–14,https://perma.cc/G2J4-TNMX。

    本文对使用七种不同的时间序列相似度测量方法构建分类模型的外样本测试精度进行了经验分析:欧氏距离、傅里叶系数、AR 模型、DTW、编辑距离、时间扭曲编辑距离和最小跳跃成本不相似度。作者在 UCR 时间序列库的 45 个公开数据集上测试了这些测量方法。

  • 关于时间序列的机器学习:

    Keogh Eamonn,“时间序列数据挖掘简介”, 幻灯片教程,无日期,https://perma.cc/ZM9L-NW7J。

    这组幻灯片概述了预处理时间序列数据以进行机器学习,测量时间序列之间的距离,并识别可用于分析和比较的“主题”。

    Spyros Makridakis, Evangelos Spiliotis, 和 Vassilios Assimakopoulos, “M4 竞赛:结果、发现、结论与未来方向,” 国际预测学杂志 34 卷, 第 4 期 (2018): 802–8, https://perma.cc/42HZ-YVUU.

    本文总结了 2018 年 M4 竞赛的结果,该竞赛比较了多种时间序列预测技术,包括许多集成技术,应用于随机选择的 10 万个时间序列数据,包括以不同频率收集的数据(年度、每小时等)。在竞赛结果的概述中,作者指出少数“混合”方法,在统计学上严重依赖但也包含一些机器学习组件,夺得了竞赛的头两名。这些结果表明了理解和应用统计学和机器学习方法在预测中的重要性。

¹ 注意,关于每个“单词”实际内容的信息是不可用的,并且当原始数据集编制时并不特别重要。其想法是认识到同一标签的所有单词都是相同的,以减少标记文档所需的人力工作。

² 就像单词预测本身一样,其中 x 轴实际上不是时间,而是另一个有序、均匀间隔的轴,可以看作是时间,直方图也是如此。在我们的分析中,可以将它们的 x 轴视为时间,以生成特征等目的。

第十章:时间序列的深度学习

时间序列的深度学习是一个相对新的尝试,但是它是一个有希望的尝试。由于深度学习是一种高度灵活的技术,它对时间序列分析具有优势。最有希望的是,它提供了模拟高度复杂和非线性时间行为的可能性,而无需猜测功能形式——这可能对非统计预测技术是一个改变游戏规则的因素。

如果你对深度学习不熟悉,这里有一个段落摘要(稍后我们会详细讨论)。深度学习描述了机器学习的一个分支,其中构建了一个“图”,将输入节点连接到复杂的节点和边的结构中。通过边从一个节点传递到另一个节点时,值会乘以该边的权重,然后通常通过某种非线性激活函数传递。正是这种非线性激活函数使得深度学习如此有趣:它使我们能够拟合高度复杂、非线性的数据,这是之前没有成功做到的。

深度学习主要在过去 10 年内发展起来,随着商用硬件的改进和海量数据的提供,使得这种重型模型拟合成为可能。深度学习模型可以拥有数百万个参数,因此理解它们的一种方式是想象出你能想到的任何图,其中包括各种矩阵乘法和非线性变换,然后想象释放一个智能优化器,逐步调整这个大模型的权重,以便逐渐提供越来越好的输出。这就是深度学习的核心。

深度学习在预测方面尚未像在图像处理和自然语言处理等其他领域那样取得惊人的结果。然而,有充分的理由乐观地认为,深度学习最终将改进预测的技术,同时减少传统预测模型中常见的脆弱和高度统一的假设与技术要求。

当使用深度学习模型时,许多预处理数据以适应模型假设的头痛问题都不复存在:

  • 没有稳态的要求。

  • 无需开发选择参数的艺术和技能,例如评估季节性和季节性 ARIMA 模型的顺序。

  • 无需对系统的底层动态做假设,这对状态空间建模非常有帮助。

在第九章讨论了这些优势后,这些优势应该听起来很熟悉,即将机器学习应用于时间序列。深度学习因多种原因而更加灵活:

  • 许多机器学习算法在需要训练算法的维度和输入数据类型方面往往比较脆弱。相比之下,深度学习在模型和输入数据的性质上具有高度的灵活性。

  • 异构数据对于许多常见的机器学习技术来说是具有挑战性的,而对于深度学习模型来说却很常见。

  • 机器学习模型很少被开发用于时间序列问题,而深度学习则提供了灵活性,可以开发特定于时间数据的架构。

然而,深度学习并非万能药。尽管对于应用于时间序列的深度学习没有平稳性的要求,但实际上,除非标准架构被修改以适应趋势,深度学习在拟合带有趋势的数据方面表现不佳。因此,我们仍然需要预处理我们的数据或者我们的技术。

此外,深度学习最适合于不同通道中数值输入,所有数值都缩放到-1 到 1 之间的相似值。这意味着即使在理论上并不需要,你仍需要预处理你的数据。而且,你需要以避免前瞻的方式进行预处理,这不是整个深度学习社区花费大量时间完善的事项。

最后,针对面向时间的神经网络(其中最大的类别是循环神经网络或 RNN)的深度学习优化技术和建模并没有像图像处理(其中最大的类别是卷积神经网络或 CNN)那样发展得好。这意味着你在选择和训练架构方面会比非时间任务获得更少的最佳实践和经验法则指导。

除了将深度学习应用于时间序列的这些困难外,你会发现这样做的回报是一把双刃剑。首先,深度学习在时间序列的性能并不总是优于传统方法用于时间序列预测和分类。确实,预测是一个从深度学习中可以得到改进的领域,但至今这些改进还没有实质性的体现。

尽管如此,有理由期待将深度学习添加到你的时间序列分析工具包中会带来即时和长期的好处。首先,大型科技公司已经开始推出专门针对时间序列的深度学习服务,使用他们在内部开发的定制架构,通常考虑了行业特定的建模任务。你可以使用这些服务或将它们与你自己的分析结合,以获得良好的性能。

其次,你可能有一个数据集,在时间序列的深度学习中表现得非常出色。总的来说,你的信噪比越强,你的表现就会越好。我曾经有过不止一位新手程序员告诉我,他们用一些简单的深度学习取得了惊人的成功。

例如,一所大学发现,一个简单的 LSTM(稍后详细介绍)在预测哪些学生可能很快失败或辍学方面,与超负荷的指导顾问的表现一样好,以便学校可以联系这些学生并提供更多资源。虽然最好的结果是拥有更多的指导顾问,但令人欣慰的是,简单的 LSTM 应用于学生成绩和出勤记录的异质时间序列数据,可以通过标记他们进行联系和增强支持,来帮助改变脆弱的学生。未来,我相信我们可以期待更多这样的创新和支持性使用深度学习的时间序列。

警告

记住,每个模型都有假设。机器学习模型,包括神经网络,在架构和训练方法上都不可避免地有内建的假设。即使是大多数神经网络在输入缩放到[–1, 1]时表现最佳,这也暗示着模型中存在强烈的假设,即使这些假设尚未被充分确认。

甚至可能是神经网络预测尚未达到最佳性能,更好地理解神经网络预测的理论基础和要求将导致性能提升。

如果您是深度学习的新手,本章节不会为您提供开始所需的所有概念和编程工具。然而,它将为您提供一个出发点,从这里,有许多好的教程、书籍,甚至在线课程可以帮助您深入学习。关于深度学习的好消息是,您不需要涉及数学就可以对其工作原理有一个大致了解,并知道如何编程。此外,各种级别的 API 都可用。这意味着初学者可以使用相当高级的 API 来尝试一些入门技术,而即使是专家也会使用这些高级 API 来节省时间。后来,当需要某些特定的架构创新时,随着您的理解加深,您可以使用更低级别的 API,在这些 API 中,更多的决策和具体规定由您自行决定。

本章将简要回顾激发和支持深度学习作为数学和计算机科学追求的概念,并提供您可以用来将深度学习模型应用于数据的代码的具体示例。

深度学习概念

深度学习在许多领域有其根源。生物学启发了人们,计算机科学家和量化分析师在思考是否建立智能机器的方法是模仿大脑,其神经元网络根据特定触发器发射。数学上的启发来自于万能逼近定理在各种激活函数上的证明,其中许多证明起源于 1980 年代末和 1990 年代初。最后,计算能力和可用性的增长,加上机器学习领域的蓬勃发展,显示出只要有足够的数据和参数,就可以建模和预测复杂系统。深度学习通过创建由数百万参数描述的网络,这些网络在大数据集上训练,并具有理论基础表明神经网络应能以高精度表示任意的非线性函数,进一步发展了这些想法。图 10-1 展示了一个简单的神经网络,即多层感知机(或全连接网络)。

在 图 10-1 中,我们可以看到多通道输入是如何以维度 d 的向量形式提供给模型的。节点表示输入值,边表示乘数。进入节点的所有边表示先前值乘以其所经过的边的值。单个节点中来自所有输入的这些值被求和,通常通过非线性激活函数传递,从而创建非线性。

我们可以看到输入由三个通道组成,即长度为 3 的向量。有四个隐藏单元。我们将每个输入与其分配给的四个隐藏单元的不同权重相乘,这意味着我们需要 3 × 4 = 12 个权重来完整描述问题。此外,由于我们随后将对这些不同乘法的结果求和,矩阵乘法不仅仅类似于我们所做的,而是确切地是我们所做的。

图 10-1. 一个简单的前馈网络。

如果我们想要详细描述我们正在做的步骤,可以大致如下:

  1. 输入向量 X 共有三个元素。第一层的权重由 W[1] 表示,是一个 4 × 3 的矩阵,我们通过 W[1] × X[1] 计算隐藏层的值。这会得到一个 4 × 1 的矩阵,但实际上这并不是隐藏层的输出:

    W[1] × X[1]

  2. 我们需要应用一个非线性函数,可以使用各种“激活函数”,如双曲正切(tanh)或 sigmoid 函数(σ)。通常我们还会在激活函数内部应用偏置 B1,这样隐藏层的输出实际上是:

    H = a(W[1] × X[1] + B[1])

  3. 在 图 10-1 中描述的神经网络中,我们有两个输出要预测。因此,我们需要将四维隐藏状态输出转换为两个输出。传统上,最后一层不包括非线性激活函数,除非我们考虑在分类问题中应用 softmax。假设我们只是尝试预测两个数字,而不是两个概率或类别,因此我们简单地应用一个“密集层”将隐藏层的四个输出组合成最终的输出。这个密集层将四个输入组合成两个输出,因此我们需要一个 2 × 4 的矩阵 W[2]:

    Y = W[2] × H

希望这些内容能让您对这些模型的工作方式有所了解。总体思想是具有大量参数和非线性的机会。

选择正确数量和形式的参数、正确的训练超参数以及一个相对可访问的问题,这些都是一种艺术形式。魔法在于学习如何从一开始就以聪明的方式初始化这些参数,并确保模型朝着正确的方向朝着一个相对良好的解决方案前进。这是一类非凸模型,意图并非在于找到全局最优解。相反,思路是,只要找到聪明的方法来正则化模型,你就会找到一个“足够好”的局部最优解来满足你的需求。

编程神经网络

原则上理解神经网络如何工作可能比理解应用于此问题的相关编程框架要容易得多。然而,正如我们将在这里讨论的那样,这些框架通常具有几个共同的广泛主题。

数据、符号、操作、层和图

深度学习框架通常侧重于某种图形的概念及其构建。其核心思想是任何架构都可以描述为其各个组件及其相互关系。此外,将变量与实际值分离的概念也非常重要。因此,您可能会有一个符号 A 和一个符号 B,然后第三个符号 C,它是将 A 和 B 进行矩阵乘法得到的结果:

# pseudo code
symbol A;
symbol B;
symbol C = matmul(A, B);

由于符号与数据之间的关系,这种区分对于每个框架都至关重要。符号用于学习更一般的关系;数据可能存在噪声。即使我们可能有数百万甚至数十亿个值要为 A 提供输入,并且每个值都与相应的 B 和 C 成对出现,但只有一个符号 A。

当我们退一步思考我们如何处理数据时,这些就是操作。我们可以将符号相加或相乘,我们可以将这些视为操作。我们还可以执行单变量操作,例如改变符号的形状(也许将符号 A 从 2 × 4 矩阵转换为 8 × 1 矩阵),或通过激活函数传递值,例如计算 tanh(A)。

更进一步地,我们可以将层视为我们与常见架构相关联的传统处理单元,例如全连接层,在前一节中我们探讨过。考虑到激活函数和偏置以及核心矩阵操作,我们可以说:

层 L = tanh(A × B + 偏置)

在许多框架中,这一层可能表达为一个或两个层,而不是几个操作,这取决于是否有足够流行的操作组合来保证其作为层单元的独立指定。

最后,我们可以将多个层与彼此关联,一个传递到下一个:

层 L1 = tanh(A × B + 偏置 1)

层 L2 = tanh(L1 × D + 偏置 2)

所有这些符号、操作和层的整合形成一个图。图不一定是完全连接的——也就是说,并不一定所有的符号都彼此依赖。图的作用在于准确地整理哪些符号依赖于哪些其他符号以及依赖关系如何。这是至关重要的,因为当我们计算梯度以进行梯度下降时,我们可以在每次迭代中微调权重,以获得更好的解决方案。好处是,大多数现代深度学习包已经为我们处理了所有这些。我们不需要指定什么依赖于什么以及随着每个添加层如何改变梯度——这一切都已经内置。

更重要的是,正如我之前提到的,我们可以使用高级 API,这样我们就不需要像我在前面的示例中那样详细说明矩阵乘法。相反,如果我们想使用全连接层,这相当于矩阵乘法,然后是矩阵加法,再然后是某种元素级的激活函数,我们不需要编写所有的数学公式。例如,在mxnet中,我们可以通过以下简单的代码实现这一点:

## python
>>> import mxnet as mx
>>> fc1 = mx.gluon.nn.Dense(120, activation='relu')

这将给我们一个完全连接的层,将输入转换为 120 的输出维度。此外,这一行代表了我刚才提到的所有内容:矩阵乘法,接着是矩阵加法,再接着是逐元素的激活函数。

您可以在文档中验证这一点,或者在您知道权重时尝试样本输出和输入。令人印象深刻的是 API 不需要您做太多的事情。您不需要指定输入形状(虽然一旦构建了图形,这必须是一个常量,以便可以推断出来)。您不需要指定数据类型——这将默认为非常明智和最常用的float32(对于深度学习来说,float64 是过度的)。如果您的数据恰好以非 1D 形状每个示例传入,该层还将自动“展平”您的数据,以便以适当的形状输入到完全连接/密集层中。这对于初学者很有帮助,但是一旦您发展出一定的能力水平,重温文档以理解在一个简单的深度学习模型中有多少决策和细节被自动处理是很有益的。

这个简单的代码,当然,即使是用于短期深度学习任务,也不需要你所需要的一切。你需要描述你的输入、目标以及如何测量损失。你还需要将你的层置于模型中。我们在下面的代码中实现了这一点:

## python
>>> ## create a net rather than a freestanding layer
>>> from mx.gluon import nn
>>> net = nn.Sequential()
>>> net.add(nn.Dense(120, activation='relu'),
>>>         nn.Dense(1))
>>> net.initialize(init=init.Xavier())
>>> 
>>> ## define the loss we want
>>> L2Loss = gluon.loss.L2Loss()
>>> 
>>> trainer = gluon.Train(net.collect_params(), 'sgd', 
>>>                                  {'learning_rate': 0.01})

最后,假设我们已经按需设置好我们的数据,我们可以通过一个训练周期(即,所有数据的一次遍历)来运行如下:

## python
>>> for data,target in train_data:
>>>     ## calculate the gradient
>>>     with autograd.record():
>>>           out = net(data)
>>>           loss = L2Loss(output, data)
>>>     loss.backward()
>>>     ## apply the gradient to update the parameters
>>>     trainer.step(batch_size)

在大多数软件包中,如同在mxnet中一样,保存模型及其相关参数以供后续生产使用或额外训练也是很容易的:

## python
>>> net.save_parameters('model.params')

在接下来的例子中,我们将使用mxnetModule API,以便展示创建图形和训练模型的另一种方式。

作为深度学习实践者,你最终会希望熟悉所有主要的软件包及其各种 API(通常软件包至少具有一个非常高级别的 API 和一个低级别的 API),这样你就能轻松阅读示例代码。掌握所有主要的深度学习模块知识对于跟上最新的行业实践和学术研究是必要的,因为这些信息最容易通过开源代码学习。

构建训练流水线

在本节中,我们将模拟相同的数据集:多年来多个地点的每小时电使用测量。我们将预处理这些数据,以便查看每小时电使用量的变化,这比预测总体值更加困难,因为我们正在观察时间序列中最不可预测的部分。

检查我们的数据集

我们使用一个小时电测量的开放数据仓库,在演示一个新的神经网络架构的代码库中提供(我们稍后在本文中会讨论)¹。为了了解这些数据的样子,我们在 R 中读取并快速绘制一些图:

## R
> elec = fread("electricity.txt")
> elec
       V1  V2  V3  V4  V5   V6 V7   V8  V9 V10 V11 V12 V13 V14 V15  V16 V17 V18
    1: 14  69 234 415 215 1056 29  840 226 265 179 148 112 171 229 1001  49 162
    2: 18  92 312 556 292 1363 29 1102 271 340 235 192 143 213 301 1223  64 216
    3: 21  96 312 560 272 1240 29 1025 270 300 221 171 132 185 261 1172  61 197
    4: 20  92 312 443 213  845 24  833 179 211 170 149 116 151 209  813  40 173
    5: 22  91 312 346 190  647 16  733 186 179 142 170  99 136 148  688  29 144

从快速检查中,我们可以看出我们有多少行和列的数据,以及没有时间戳。我们已经独立地被告知这些时间戳是每小时的,但我们不知道这些测量究竟是在什么时候进行的:

## R
> ncol(elec)
[1] 321
> nrow(elec)
[1] 26304

我们还可以绘制一些数据的随机样本,以了解其外观(见图 10-2)。由于这是小时数据,我们知道如果绘制 24 个数据点,我们将得到一整天的数据:

## R
> elec[125:148, plot(V4,    type = 'l', col = 1, ylim = c(0, 1000))]
> elec[125:148, lines(V14,  type = 'l', col = 2)]
> elec[125:148, lines(V114, type = 'l', col = 3)]

我们还制作了一周的图表(见图 10-3):

## R
> elec[1:168, plot(V4,    type = 'l', col = 1, ylim = c(0, 1000))]
> elec[1:168, lines(V14,  type = 'l', col = 2)]
> elec[1:168, lines(V114, type = 'l', col = 3)]

请记住,我们不知道任何特定索引的本地时间是什么,尽管我们知道它们之间的关系。然而,与我们在第二章中的讨论一致,根据电力使用的模式,我们可能可以猜测哪些时间段代表标准人类日程的一部分。我们可能还可以识别周末。我们在这里没有这样做,但包含关于一天的时间和一周的时间模型可能是一个更深入分析和建模这一数据集的好主意。

图 10-2. 从数据集中抽取的三个不同位置的 24 小时样本,数据集中共有 321 个位置。虽然我们不知道这些索引对应的本地小时是哪些,但我们看到了一个连贯的日常模式。

图 10-3. 同一三个位置的数据的完整七天周期样本,与日常图中显示的相同。通过这种更广泛的数据查看,我们确认了我们对日常模式的感觉,表明每天有一个大峰值以及一些较小的特征在行为上似乎是一致的。

尽管我们可以预测数据的绝对值作为我们的预测技术,但这在学术论文和博客文章中已经完成。相反,我们将预测数据的差异。预测时间序列的差异而不是总值往往更具挑战性,因为数据更加嘈杂,我们可以在我们之前做过的类似图中看到这一点(见图 10-4):²

## R
> elec.diff[1:168, plot( V4, type = 'l', col = 1, ylim = c(-350, 350))]
> elec.diff[1:168, lines(V14, type = 'l', col = 2)]
> elec.diff[1:168, lines(V114, type = 'l', col = 3)]

图 10-4. 同一三个站点的电力时间序列的一个星期样本,表示电力使用的逐小时变化。尽管这个序列仍然表现出模式,就像原始序列一样,但序列的不可预测组成部分变得更加明显,因为它们在差异系列的值中所占比例比在原始序列中所占比例要大。

如果我们要运行传统的统计模型,状态空间模型,甚至是机器学习模型,我们需要在此时进行大量分析,以查看数据集中不同用电站点之间的相关性。我们需要看看数据中是否随时间漂移,并评估其平稳性。

您也应该为深度学习做这些事情,这样您可以评估数据集的适当模型,并为您认为您的模型可以执行多好建立期望。然而,深度学习的美妙之处在于,即使我们的数据有些凌乱,甚至没有通过任何特定的统计测试来评估我们数据的质量,我们也可以继续前进。在生产环境中,我们将花更多时间进行数据探索,但出于本章的目的,我们将继续进行建模选项。

训练管道的步骤

通常情况下,当我们使用神经网络建模时,我们的脚本总是会有一些共同的步骤。这些脚本通常比我们拟合统计或传统机器学习模型时更难撰写,因为我们的数据集往往更大。此外,我们以批次方式拟合这些深层模型,因此我们使用迭代器而不是整个数据集的数组。

数据管道将包括以下步骤:

  • 通过导入默认训练值的预设参数列表,使我们的代码易于配置,这特别方便,因为否则有太多的值需要设置。

  • 将数据加载到内存中并进行预处理。

  • 将数据塑造成适当预期的格式。

  • 构建适合您使用的深度学习模块的迭代器。

  • 构建一个图表,使用这些迭代器来了解期望的数据形状;这包括构建整个模型。

  • 设置训练参数,如优化器、学习率以及训练的时期数。

  • 建立一些记录系统,用于记录您的权重和每个时期的结果。

使我们的代码易于配置。

以下代码显示了我们如何完成这些任务。我们从标准导入列表开始:

## python
>>> from math import floor
>>> 
>>> ## for archiving
>>> import os
>>> import argparse
>>> 
>>> ## deep learning module
>>> import mxnet as mx
>>> 
>>> ## data processing
>>> import numpy as np
>>> import pandas as pd
>>> 
>>> ## custom reporting
>>> import perf

然后,我们使用一些硬编码变量和可调参数。在一定程度上,这些是基于经验和您在训练时的优先考虑因素。不要指望这些变量现在能够很有意义,因为其中许多参数适用于我们稍后在本章讨论的神经网络组件。主要的事情是注意参数的可调性:

## python
>>> ## some hyperparameters we won't tune via command line inputs
>>> DATA_SEGMENTS    = { 'tr': 0.6, 'va': 0.2, 'tst': 0.2}
>>> THRESHOLD_EPOCHS = 5
>>> COR_THRESHOLD    =  0.0005
>>>     
>>> ## set up parser
>>> parser = argparse.ArgumentParser()
>>> 
>>> ## DATA SHAPING
>>> parser.add_argument('--win',       type=int,   default=24*7)
>>> parser.add_argument('--h',         type=int,   default=3)
>>> 
>>> ## MODEL SPECIFICATIONS
>>> parser.add_argument('--model',     type=str,   default='rnn_model')
>>> ## cnn components
>>> parser.add_argument('--sz-filt',   type=str,   default=8)
>>> parser.add_argument('--n-filt',    type=int,   default=10)
>>> ## rnn components
>>> parser.add_argument('--rnn-units', type=int,   default=10)
>>> 
>>> ## TRAINING DETAILS
>>> parser.add_argument('--batch-n',   type=int,   default=1024)
>>> parser.add_argument('--lr',        type=float, default=0.0001)
>>> parser.add_argument('--drop',      type=float, default=0.2)
>>> parser.add_argument('--n-epochs',  type=int,   default=30)
>>> 
>>> ## ARCHIVE WORK
>>> parser.add_argument('--data-dir',  type=str,   default='../data')
>>> parser.add_argument('--save-dir',  type=str,   default=None)

具有许多可调参数至关重要,因为训练深度学习模型始终涉及超参数搜索,以改进从基线到模型的效果。通常调整的超参数会影响训练的各个方面,从数据准备(查看多久以前的时间)到模型规范(建立多复杂和什么样的模型)以及训练(训练多长时间,使用多大的学习率)。

在前述代码中有不同类别的参数。首先,数据形状化涉及我们如何处理我们的原始输入,本例中是一个 CSV 文件,其中包含 321 个站点的并行差分时间序列电力使用情况。为了塑造我们的数据,我们需要两个参数。window变量是我们允许我们的模型在试图进行预测时向后查看的时间范围。horizon变量是我们希望向前预测的时间跨度。请注意,这些并不是特定的单位,比如“5 分钟”,而是时间步长,与我们在早期章节的做法一致。像其他统计和机器学习模型一样,神经网络关心我们的计算表示,并不在意数据是 5 分钟还是 5 个纪元。

倒数第二部分,培训细节,通常是超参数优化中最重要的部分,也是最常调整的部分。在开始时,调整学习率并确保你没有选择一个远远偏离的数值是至关重要的。一个好的经验法则是从 0.001 开始,然后按数量级上下调整。拥有恰当的学习率并不是那么重要,但拥有正确的数量级却是很重要的。

模型规范允许我们指定各种模型(如 RNN 与 CNN 之间的比较)以及关于这些模型的架构细节。一般来说,我们将希望调整超参数。

对于当前的示例,我们在命令行中使用以下超参数提供给我们的脚本:

--drop=0.2 --win=96 --batch-n=128 --lr=0.001 --n-epochs=25 
--data-dir=/data/elec --save-dir=/archive/results 
--model=model_will_vary

准备我们的输入数据

一旦我们有了可配置的参数,就有一种方法可以向我们的脚本提供信息,例如文件的位置、我们想要预测的提前量以及我们想要在给定时间段内包含的时间回溯量。即使在初步读取和正确形状化数据的阶段,这些可配置的参数也非常重要。我们还需要安排基础设施来处理数据,因为神经网络是通过各种随机梯度下降的变体进行训练的,这意味着训练是逐批次进行的,一个周期意味着所有数据都已用于训练(尽管不是同时)。

接下来我们讨论通过迭代器为训练提供数据的高级过程以及塑造输入到迭代器的数据的细节。

形状化输入数据

在前面的部分中,我们看到了如何从 NumPy 数组中形成迭代器,并且我们认为这些 NumPy 数组是已经准备好的。现在我们将讨论数据的形状,首先是概念上,然后是通过代码示例。我们将讨论两种数据格式,NC 和 NTC。

我们从一个与编码无关的实际示例开始讨论不同的输入数据格式。让我们想象我们有多变量时间序列数据,具有列 A、B 和 C。

时间 A B C
t – 3 0 –1 –2
t – 2 3 –2 –3
t – 1 4 –2 –4
t 8 –3 –9

我们想要建立一个模型,预测一步,我们想要使用前两个时间点的数据来预测。我们希望使用 A、B 和 C 的数据来预测 A、B 和 C。我们将称我们的输入为 X,输出为 Y。

我们试图预测时间t的 Y。在时间t,这些是 Y 的实际值 = [A, B, C]:

t 8 –3 –9

我们指定,我们将为所有变量提供前两个时间点以进行预测。这总计如下:

A, t – 1 A, t – 2 B, t – 1 B, t – 2 C, t – 1 C, t – 2
4 3 –2 –2 –4 –3

同样地,为了预测时间t – 1 时的 Y,我们有以下数据作为我们的目标:

t – 1 4 –2 –4

并且我们期望能够使用以下值进行预测:

A, t – 2 A, t – 3 B, t – 2 B, t – 3 C, t – 2 C, t – 3
3 0 –2 –1 –3 –2

如果我们想要将这两个数据点的输入存储在一个数据格式中,它看起来会像这样:

时间 A, 时间 – 1 A, 时间 – 2 B, 时间 – 1 B, 时间 – 2 C, 时间 – 1 C, 时间 – 2
t – 1 3 0 –2 –1 –3 –2
t 4 3 –2 –2 –4 –3

这被称为 NC 数据格式,其中N表示个体样本,C表示通道,这是描述多变量信息的另一种方式。我们将使用这种数据格式来训练全连接神经网络,并且它是我们将讨论的方法中的第一选项,该方法将输入数据以 CSV 格式接收并转换为正确形状和维度的 NumPy 数组。

另一方面,我们可以以不同的方式塑造数据,以创建一个特定的时间轴。这通常通过将数据放置在 NTC 格式中来完成,该格式指定了样本数量 × 时间 × 通道数。在这种情况下,样本是原始数据的每一行,即我们想要进行预测的每个时间切片(并且我们有可用数据来这样做)。时间维度是我们将查看多远以前的时间,例如在此示例中为两个时间步长(并在我们本章的示例脚本中通过--win指定)。

在 NTC 格式中,我们之前格式化的输入数据看起来会像这样,以预测t – 1 的水平:

时间 A B C
t – 1 0, 3 –1, –2 –2, –3

或者,如果我们想要紧凑地表示我们先前生成的两个样本的输入数据,我们可以这样做:

时间 A B C
t – 1 0, 3 –1, –2 –2, –3
t 3, 4 –2, –2 –3, –4

这可以与我们为 Y 生成的标签连接在一起:

时间 A B C
t – 1 4 –2 –4
t 8 –3 –9

这两种表示法都没有比另一种更准确,但 NTC 表示法的一个便利之处在于时间具有显式的时间轴含义。

我们形成两种输入形状的原因是一些模型更喜欢一种格式,而另一些模型喜欢另一种。我们将使用 NTC 格式将输入提供给卷积和循环神经网络,这些我们将在本章后面讨论。

构建迭代器

广义上讲,为训练程序提供数据,我们需要提供迭代器。迭代器不仅适用于深度学习或 Python,而是反映了对象沿着某种集合的通用概念,跟踪其位置并指示何时完成整个集合的遍历。在训练数据来自 NumPy 数组的情况下,形成迭代器非常简单。如果 XY 是 NumPy 数组,我们可以看到形成迭代器非常简单:

## python
>>> ################################
>>> ## DATA PREPARATION ##
>>> ################################
>>> 
>>> def prepare_iters(data_dir, win, h, model, batch_n):
>>>     X, Y = prepared_data(data_dir, win, h, model)
>>> 
>>>     n_tr = int(Y.shape[0] * DATA_SEGMENTS['tr'])
>>>     n_va = int(Y.shape[0] * DATA_SEGMENTS['va'])
>>> 
>>>     X_tr, X_valid, X_test = X[            : n_tr], 
>>>                             X[n_tr        : n_tr + n_va], 
>>>                             X[n_tr + n_va : ]
>>>     Y_tr, Y_valid, Y_test = Y[            : n_tr], 
>>>                             Y[n_tr        : n_tr + n_va], 
>>>                             Y[n_tr + n_va : ]
>>>     
>>>     iter_tr = mx.io.NDArrayIter(data       = X_tr,
>>>                                 label      = Y_tr,
>>>                                 batch_size = batch_n)
>>>     iter_val = mx.io.NDArrayIter(data       = X_valid,
>>>                                  label      = Y_valid,
>>>                                  batch_size = batch_n)
>>>     iter_test = mx.io.NDArrayIter(data       = X_test,
>>>                                   label      = Y_test,
>>>                                   batch_size = batch_n)
>>> 
>>>     return (iter_tr, iter_val, iter_test)

在这里,我们有一种准备数据集迭代器的方法,这些迭代器包装了由名为 prepared_data() 的方法接收的 numpy 数组(稍后详细说明)。一旦数组可用,它们将被分解为训练、验证和测试数据源,其中训练数据是最早的,验证数据用作调整超参数的一种方式,并具有外样本反馈,测试数据则保留到最后进行真正的测试。³

请注意,迭代器的初始化程序接受输入(data)、目标值(label)和 batch_size 参数,该参数反映了每次迭代中将使用多少个示例来计算梯度并更新模型权重。

在代码中形成数据的形状

现在我们知道要创建的两种数据形状,我们可以查看形成它的代码:

## python
>>> def prepared_data(data_dir, win, h, model_name):
>>>     df = pd.read_csv(os.path.join(data_dir, 'electricity.diff.txt'), 
>>>                      sep=',', header=0)
>>>     x  = df.as_matrix()
>>>     ## normalize data. notice this creates a lookahead since
>>>     ## we normalize based on values measured across the data set
>>>     ## so in a less basic pipeline we would compute these as
>>>     ## rolling statistics to avoid the lookahead problem
>>>     x = (x - np.mean(x, axis = 0)) / (np.std(x, axis = 0)) 
>>> 
>>>     if model_name == 'fc_model': ## NC data format
>>>         ## provide first and second step lookbacks in one flat input
>>>         X = np.hstack([x[1:-1], x[:-h]])
>>>         Y = x[h:]
>>>         return (X, Y)
>>>     else:                        ## TNC data format
>>>         # preallocate X and Y
>>>         # X shape = num examples * time win * num channels (NTC)
>>>         X = np.zeros((x.shape[0] - win - h, win, x.shape[1]))
>>>         Y = np.zeros((x.shape[0] - win - h, x.shape[1]))
>>>         
>>>         for i in range(win, x.shape[0] - h):
>>>             ## the target/label value is h steps ahead
>>>             Y[i-win] = x[i + h - 1     , :] 
>>>             ## the input data are the previous win steps
>>>             X[i-win] = x[(i - win) : i , :] 
>>>  
>>>         return (X, Y)

从文本文件中读取数据后,我们对每一列进行标准化。请注意,每一列都是单独标准化的,而不是整个数据集统一标准化。这是因为即使在我们简短的数据探索中,我们也看到不同的电力站具有非常不同的值(请参见图 10-2 和 10-4 中的图表):

## python
>>> x = (x - np.mean(x, axis = 0)) / (np.std(x, axis = 0)) 

NC 数据格式

生成 NC 数据格式非常简单:

## python 
>>> if model_name == 'fc_model': ## NC data format
>>>     ## provide first and second step lookbacks in one flat input
>>>     X = np.hstack([x[1:-h], x[0:-(h+1)]])
>>>     Y = x[(h+1):]

生成代表时间 t – hX 输入,以便在 t 时进行预测时,我们取 x 并移除最后 h 行(因为该输入数据需要比我们拥有的最新数据稍后的标签值)。然后,我们沿时间轴将这些数据向后移动,以产生进一步滞后的值,并且我们需要确保代表不同滞后的 NumPy 数组具有相同的形状,以便它们可以堆叠在一起。这就是导致前述公式的原因。值得在自己的计算机上通过这个过程并证明其有效性。你还可以考虑如何将表达式推广为任意长的回顾。

通过设置 Pdb 断点来检查我们的工作,并验证 XY 中的值是否与它们在 x 中的预期对应:

## python
(Pdb) X[0, 1:10] == x[1, 1:10]
array([ True,  True,  True,  True,  True,  True,  True,  True,  True])
(Pdb) X[0, 322:331] == x[0, 1:10]
array([ True,  True,  True,  True,  True,  True,  True,  True,  True])
(Pdb) Y[0, 1:10] == x[4, 1:10]
array([ True,  True,  True,  True,  True,  True,  True,  True,  True])

X中前半部分的列代表我们用于预测的最后时间点,而预测的标签/目标则比这个时间点晚三个步骤。这就是为什么X[0, 1:10]应该匹配x[1, 1:10],而Y[0, 1:10]应该匹配x[4, 1:10],因为它应该是向前三个时间步(我们的输入集将视野设定为3)。

可能会令人困惑的是,时间和样本(数据点索引)经常具有相同的标签,但它们是不同的概念。有我们预测的时间提前,有我们拍摄输入快照的时间,以便进行预测,还有我们查看以收集数据以进行预测的时间。这些值必然是相互关联的,但将这些概念分开是个好主意。

NTC 数据格式

生成 NTC 格式也不算太难:

## python
>>> # preallocate X and Y
>>> # X shape = num examples * time win * num channels (NTC)
>>> X = np.zeros((x.shape[0] - win - h, win, x.shape[1]))
>>> Y = np.zeros((x.shape[0] - win - h, x.shape[1]))
>>>         
>>> for i in range(win, x.shape[0] - h):
>>>      ## the target/label value is h steps ahead
>>>      Y[i-win] = x[i + h - 1     , :]
>>>      ## the input data are the previous win steps
>>>      X[i-win] = x[(i - win) : i , :]

对于任何给定的示例(即N维度,即第一维度),我们获取了输入数据的最后win行,跨所有列。这就是我们创建三维数据的方式。第一维度实际上是数据点索引,而在该数据点中提供的值总计为 2D 数据,即时间 × 通道(在这里是电力站)。

与之前一样,我们设置了Pdb断点来测试这段代码。同时注意,我们确认了我们对代码测试的理解。通常情况下,测试数据格式的代码比实际代码更具启发性,因为我们使用具体数字进行抽查测试:

## python
(Pdb) Y[0, 1:10] == x[98, 1:10]
array([ True,  True,  True,  True,  True,  True,  True,  True,  True])
(Pdb) X.shape
(26204, 96, 321)
(Pdb) X[0, :, 1] == x[0:96, 1]
array([ True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True,  True,  True,  True,
        True,  True,  True,  True,  True,  True])

我们看到我们准备在XY中的第一个数据点(即第一行)对应于行 0 到 96(因为我们将可配置的窗口回溯设置为 96 个时间步长),而向前预测 3 个时间步对应于第 98 行(因为x结束于 95;请记住,对切片的索引排除切片中的最后一个数字,因此x表示从 0 到 95(包括 0 到 96)的所有行)。

数据处理代码容易出错,混乱且速度慢。然而,您会发现,每次编写和处理它时,它都会变得更加清晰。尽管如此,彻底测试您的数据处理代码并将其安全地保存在某处,以避免每次需要整理数据时都需要处理示例问题,这是明智的做法。此外,将此代码保存在版本控制系统中,并有一些方式跟踪使用哪个版本的代码来训练特定模型,也是明智的做法。

设置训练参数并建立记录系统

我们将在接下来的章节讨论各种模型的细节,因此暂时跳过与图构建相关的代码部分,直接进入训练和记录保留的逻辑。

这就是我们在接下来要重点讨论的简单示例中如何实现训练的方法:

## python
>>> def train(symbol, iter_train, valid_iter, iter_test,
>>>           data_names, label_names,
>>>           save_dir):
>>>     ## save training information/results 
>>>     if not os.path.exists(args.save_dir):
>>>         os.makedirs(args.save_dir)
>>>     printFile = open(os.path.join(args.save_dir, 'log.txt'), 'w')
>>>     def print_to_file(msg):
>>>         print(msg)
>>>         print(msg, file = printFile, flush = True)
>>>     ## archiving results header
>>>     print_to_file('Epoch     Training Cor     Validation Cor')
>>> 
>>>     ## storing prior epoch's values to set an improvement threshold
>>>     ## terminates early if progress slow
>>>     buf     = RingBuffer(THRESHOLD_EPOCHS)
>>>     old_val = None
>>> 
>>>     ## mxnet boilerplate
>>>     ## defaults to 1 gpu, of which index is 0
>>>     devs = [mx.gpu(0)]
>>>     module = mx.mod.Module(symbol,
>>>                            data_names=data_names,
>>>                            label_names=label_names,
>>>                            context=devs)
>>>     module.bind(data_shapes=iter_train.provide_data,
>>>                 label_shapes=iter_train.provide_label)
>>>     module.init_params(mx.initializer.Uniform(0.1))
>>>     module.init_optimizer(optimizer='adam',
>>>                           optimizer_params={'learning_rate': 
>>>                                             args.lr})
>>> 
>>>     ## training
>>>     for epoch in range( args.n_epochs):
>>>         iter_train.reset()
>>>         iter_val.reset()
>>>         for batch in iter_train:
>>>             # compute predictions
>>>             module.forward(batch, is_train=True) 
>>>             # compute gradients
>>>             module.backward() 
>>>             # update parameters 
>>>             module.update()                      
>>> 
>>>         ## training results
>>>         train_pred  = module.predict(iter_train).asnumpy()
>>>         train_label = iter_train.label[0][1].asnumpy()
>>>         train_perf  = perf.write_eval(train_pred, train_label,
>>>                                       save_dir, 'train', epoch)
>>> 
>>>         ## validation results
>>>         val_pred  = module.predict(iter_val).asnumpy()
>>>         val_label = iter_val.label[0][1].asnumpy()
>>>         val_perf = perf.write_eval(val_pred, val_label,
>>>                                    save_dir, 'valid', epoch)
>>> 
>>>         print_to_file('%d         %f       %f ' % 
>>>                      (epoch, train_perf['COR'], val_perf['COR']))
>>>         
>>>         # if we don't yet have measures of improvement, skip
>>>         if epoch > 0:        
>>>             buf.append(val_perf['COR'] - old_val) 
>>>          # if we do have measures of improvement, check them
>>>         if epoch > 2:                                
>>>             vals = buf.get()
>>>             vals = [v for v in vals if v != 0]
>>>             if sum([v < COR_THRESHOLD for v in vals]) == len(vals):
>>>                 print_to_file('EARLY EXIT')
>>>                 break
>>>         old_val = val_perf['COR']
>>>                 
>>>     ## testing
>>>     test_pred  = module.predict(iter_test).asnumpy()
>>>     test_label = iter_test.label[0][1].asnumpy()
>>>     test_perf = perf.write_eval(test_pred, test_label, 
>>>                                 save_dir, 'tst', epoch)
>>>     print_to_file('TESTING PERFORMANCE')
>>>     print_to_file(test_perf)

上述代码完成了多样的任务,都是例行公事。首先,我们设置了值来跟踪验证准确性分数历史,以确保训练看到改进。如果模型训练速度不够快,我们不希望继续旋转我们的 GPU,浪费时间和电力。

MXNet 的样板使用Module API(而不是我们在本章前面看到的Gluon API):

## python
>>>     ## mxnet boilerplate
>>>     ## defaults to 1 gpu of which index is 0
>>>     devs = [mx.gpu(0)]
>>>     module = mx.mod.Module(symbol,
>>>                            data_names=data_names,
>>>                            label_names=label_names,
>>>                            context=devs)
>>>     module.bind(data_shapes=iter_train.provide_data,
>>>                 label_shapes=iter_train.provide_label)
>>>     module.init_params(mx.initializer.Uniform(0.1))
>>>     module.init_optimizer(optimizer='adam',
>>>                           optimizer_params={'learning_rate': 
>>>                                              args.lr})

这四行代码实现了以下功能:

  1. 将神经网络的原始组件设置为计算图。

  2. 设置数据形状,以便网络知道要期望什么并进行优化。

  3. 初始化图中所有权重为随机值(这是一门艺术,不仅仅是从无限可能性中随机选择的一组数字)。

  4. 初始化优化器,可以有各种风味,我们根据输入参数显式设置初始学习率。

接下来,我们使用我们的训练数据迭代器逐步递增我们在训练过程中的数据:

## python
>>> for epoch in range( args.n_epochs):
>>>    iter_train.reset()
>>>    iter_val.reset()
>>>    for batch in iter_train:
>>>        module.forward(batch, is_train=True) # compute predictions
>>>        module.backward()                    # compute gradients
>>>        module.update()                      # update parameters

然后,我们测量训练集和验证集的预测结果(与之前相同的外部for循环):

## python
>>> ## training results
>>> train_pred  = module.predict(iter_train).asnumpy()
>>> train_label = iter_train.label[0][1].asnumpy()
>>> train_perf  = evaluate_and_write(train_pred, train_label,  
>>>                                  save_dir, 'train', epoch)
>>> 
>>> ## validation results
>>> val_pred  = module.predict(iter_val).asnumpy()
>>> val_label = iter_val.label[0][1].asnumpy()
>>> val_perf  = evaluate_and_write(val_pred, val_label,  
>>>                                save_dir, 'valid', epoch)

循环结束时有一些早停逻辑:

## python
>>> if epoch > 0:
>>>     buf.append(val_perf['COR'] - old_val)
>>> if epoch > 2:
>>>     vals = buf.get()
>>>     vals = [v for v in vals if v != 0]
>>>     if sum([v < COR_THRESHOLD for v in vals]) == len(vals):
>>>         print_to_file('EARLY EXIT')
>>>         break
>>> old_val = val_perf['COR']

这个笨重的代码做一些记录和简单逻辑,记录预测值与实际值之间每个连续相关值。如果从时代到时代的相关性没有充分改善足够的时代(或者甚至变得更糟),训练将停止。

评估指标

我们的函数evaluate_and_write,同时记录每个时代的相关性和目标值与估计值的原始值。我们在所有训练结束时对测试也是如此:

## python
>>> def evaluate_and_write(pred, label, save_dir, mode, epoch):
>>>     if not os.path.exists(save_dir):
>>>         os.makedirs(save_dir)
>>>         
>>>     pred_df  = pd.DataFrame(pred)
>>>     label_df = pd.DataFrame(label)
>>>     pred_df.to_csv( os.path.join(save_dir, '%s_pred%d.csv'  
>>>                                  % (mode, epoch)))
>>>     label_df.to_csv(os.path.join(save_dir, '%s_label%d.csv' 
>>>                                  % (mode, epoch)))
>>> 
>>>     return { 'COR': COR(label,pred) }

这反过来又利用了我们定义的相关函数,如下所示:

## python
>>> def COR(label, pred):
>>>     label_demeaned = label - label.mean(0)
>>>     label_sumsquares = np.sum(np.square(label_demeaned), 0)
>>> 
>>>     pred_demeaned = pred - pred.mean(0)
>>>     pred_sumsquares = np.sum(np.square(pred_demeaned), 0)
>>> 
>>>     cor_coef =  np.diagonal(np.dot(label_demeaned.T, pred_demeaned)) /
>>>                 np.sqrt(label_sumsquares * pred_sumsquares)                   
>>> 
>>>     return np.nanmean(cor_coef)

在这个数据集中偶尔会出现零方差的情况,这可能会在一列中创建一个NAN,所以我们选择使用np.nanmean()而不是np.mean()

注意,我们在这里不包括的一个基本功能是保存模型权重,通过训练过程进行检查点。如果我们正在为生产进行训练并且需要能够重新加载模型并部署它,我们将使用Module.save_checkpoint(保存权重)和Module.load(从此加载模型回到内存,从此你可以继续训练或将模型部署到生产中)。有很多东西可以学习,开始一个合适的深度学习管道,但在这里我们保持基本。

汇总

我们在我们的__main__范围内把我们的管道组件放在一起:

## python
>>> if __name__ == '__main__':
>>>     # parse command line args
>>>     args = parser.parse_args()
>>> 
>>>     # create data iterators
>>>     iter_train, iter_val, iter_test = prepare_iters(
>>>         args.data_dir, args.win, args.h,
>>>         args.model, args.batch_n)    
>>> 
>>>     ## prepare symbols
>>>     input_feature_shape = iter_train.provide_data[0][1]    
>>>     
>>>     X = mx.sym.Variable(iter_train.provide_data[0].name )
>>>     Y = mx.sym.Variable(iter_train.provide_label[0].name)
>>>     
>>>     # set up model
>>>     model_dict = {
>>>         'fc_model'            : fc_model,
>>>         'rnn_model'           : rnn_model,
>>>         'cnn_model'           : cnn_model,
>>>         'simple_lstnet_model' : simple_lstnet_model
>>>         }
>>>     model = model_dict[args.model]
>>>     
>>>     symbol, data_names, label_names = model(iter_train,
>>>                                            input_feature_shape, 
>>>                                            X, Y,
>>>                                            args.win, args.sz_filt,
>>>                                            args.n_filt, args.drop)
>>> 
>>>     ## train 
>>>     train(symbol, iter_train, iter_val, iter_test,
>>>           data_names, label_names, args.save_dir)

在这里,我们利用我们刚刚安排好的基础设施。首先,我们解析命令行参数。然后,我们创建可配置输入的迭代器,包括我们的预测周期、回溯窗口、批处理大小以及我们想要构建的模型的名称。我们创建 MXNet 符号,并记录输入形状,这些都传递给我们创建模型的过程中。最后,我们将关于模型的信息、以及我们的迭代器和保存目录传递给训练函数,这部分很有趣:训练模型并输出其性能指标。

因此,在这种情况下,我们看到一个最小但完全功能的训练流水线,包括数据摄取和重塑、模型构建、模型训练以及重要数值记录用于模型评估。

顺便说一下,我们的print_to_file()方法只是print()的一个便利包装器:

## python
def print_to_file(msg):
     print(msg, file = printFile, flush = True)esfa
print_to_file(args)

当你训练模型时,你会想要创建一个记录。可能你最终选择的模型权重并不是你完成训练时的权重,而是之前的某个时间点。记录训练进展将帮助你调整与模型结构和训练相关的超参数,从参数数量的选择(以避免欠拟合或过拟合)到训练时的学习率(以避免在训练过程中进行过大或过小的调整)。

现在我们有了一个完整的最小可行流水线,只是缺少我们的模型。现在我们将看一些可以应用于时间序列数据的基本模型,并训练每个模型以查看其相对性能。

前馈网络

这本书在时间序列分析的背景下呈现前馈网络相当不寻常。大多数现代时间序列分析问题使用递归网络结构或者较少见的卷积网络结构。然而,我们从前馈神经网络开始,因为它是最简单和最具历史意义的网络结构。有几个原因使得它是一个好的起点:

  • 前馈网络(Feed forward networks)具有高度可并行化的特点,这意味着它们的性能相当出色。如果你能找到一个合适的前馈模型,你可以非常快速地计算它。

  • 前馈网络是检验您的序列是否真的具有复杂时间轴动态的良好测试。并非所有时间序列都真的是时间序列,即早期值与后续值具有特定关系的意义。将前馈神经网络作为一个基线进行拟合可能是一个好方法,与更简单的线性模型分开。

  • 前馈网络组件通常集成到更大更复杂的时间序列深度学习架构中。因此,即使它们在您的工作中不会形成整个模型,您也需要了解它们的工作原理。

简单示例

前馈神经网络是最简单的神经网络类型,因此我们首先将前馈架构应用于一个时间序列示例。标准前馈神经网络的结构中并没有暗示时间关系,但算法可能仍然能够学习过去输入如何预测未来输入。我们在 图 10-1 中看到了一个前馈神经网络的示例。前馈神经网络是一系列全连接层,即每一层的输入连接到图中的每个节点。

我们从这个简单的示例开始,它使用了我们之前看到的第一种数据格式化方式。也就是说,我们的输入 X 简单地是 N × C 的二维数据(即样本 × 通道),其中时间成分已被展平成通道。这对应于以下数据格式化方式:

A, t – 2 A, t – 3 B, t – 2 B, t – 3 C, t – 2 C, t – 3
3 0 –2 –1 –3 –2

作为提醒,在代表数据这一分支的代码中:

## python
>>> if model_name == 'fc_model':
>>>    ## provide first and second step lookbacks in one flat input
>>>    X = np.hstack([x[1:-1], x[:-2]])
>>>    Y = x[2:]
>>>    return (X, Y)

我们随后使用 MXNet 的 Module API 构建一个全连接模型:

## python
>>> def fc_model(iter_train, window, filter_size, num_filter, dropout):
>>>     X = mx.sym.Variable(iter_train.provide_data[0].name)
>>>     Y = mx.sym.Variable(iter_train.provide_label[0].name)
>>> 
>>>     output = mx.sym.FullyConnected(data=X, num_hidden = 20)
>>>     output = mx.sym.Activation(output, act_type = 'relu')
>>>     output = mx.sym.FullyConnected(data=output, num_hidden = 10)
>>>     output = mx.sym.Activation(output, act_type = 'relu')
>>>     output = mx.sym.FullyConnected(data = output, num_hidden = 321)
>>> 
>>>     loss_grad = mx.sym.LinearRegressionOutput(data  = output, 
>>>                                               label = Y)
>>>     return loss_grad, [v.name for v in iter_train.provide_data], 
>>>                       [v.name for v in iter_train.provide_label]    

在这里,我们构建了一个三层全连接网络。第一层有 20 个隐藏单元,第二层有 10 个隐藏单元。在前两层之后,有一个“激活”层。这是使模型非线性的关键所在,如果没有这一层,模型将简单地成为一系列矩阵乘法,最终变成一个线性模型。全连接层有效地是:

Y=XWT+b

W 是一组权重,对应于矩阵乘法的修正维度,其输出结果是一个维度等于隐藏单元数的向量。然后加上一个偏置项 bWb 中的权重都是可训练的。然而,训练一个这样的权重集,甚至一系列这样的权重集,将会非常无聊——它将是一个迂回的线性回归方式。然而,是在这组矩阵运算后应用的激活函数,如 tanh 和 ReLU,造成了兴趣,这些函数在图 10-5 和 10-6 中有所展示。

图 10-5. tanh 函数在小值处表现出非线性行为,随后在更高的值处变为函数常数,其导数为零。

图 10-6. ReLU 函数易于计算,同时引入了非线性。

在前述代码中,您会注意到我们将 ReLU 函数作为激活函数。在模型构建和超参数调整的早期阶段,测试各种激活函数(ReLU、tanh、sigmoid)通常是有意义的。

现在让我们使用这个模型进行训练,并看看它的表现:

Epoch     Training Cor     Validation Cor
0          0.578666       0.557897 
1          0.628315       0.604025 
2          0.645306       0.620324 
3          0.654522       0.629658 
4          0.663114       0.636299 
5          0.670672       0.640713 
6          0.677172       0.644602 
7          0.682624       0.648478 
8          0.688570       0.653288 
9          0.694247       0.657925 
10         0.699431       0.663191 
11         0.703147       0.666099 
12         0.706557       0.668977 
13         0.708794       0.670228 
14         0.711115       0.672429 
15         0.712701       0.673287 
16         0.714385       0.674821 
17         0.715376       0.674976 
18         0.716477       0.675744 
19         0.717273       0.676195 
20         0.717911       0.676139 
21         0.718690       0.676634 
22         0.719405       0.677273 
23         0.719947       0.677286 
24         0.720647       0.677451 

 TESTING PERFORMANCE
{'COR': 0.66301745}

这是一个良好的表现吗?在一个孤立的情况下很难说。当你进入深度学习模型时,只有当它能超越我们在前几章中查看过的显著简化模型时,才应该将其投入生产。深度学习模型预测所需的时间较长,通常具有更高的开销,因此只有当它能证明额外成本是合理的时候才应该使用。因此,当你用深度学习工具解决时间序列问题时,通常希望有先前拟合的模型作为一个客观的标准来超越。

无论如何,我们看到即使一个没有任何时间意识的模型也能训练并产生与测量值合理相关的预测。

使用注意力机制使前馈网络更具时间感知性

尽管前馈网络在时间序列问题上并未取得重大进展,但仍在进行研究,以设想可能增强其在序列数据上性能的架构变体。其中一种想法是注意力,它描述了添加到神经网络中的机制,允许它学习集中精力处理序列的哪一部分以及传入序列的哪一部分可能与期望的输出相关。

注意力的概念是,神经网络架构应该提供一种机制,让模型学会在什么时候学习哪些信息是重要的。这通过注意力权重来实现,每个时间步都要调整这些权重,使得模型学会如何结合不同时间步的信息。这些注意力权重乘以模型的输出或隐藏状态,从而将隐藏状态转换为上下文向量,因为隐藏状态现在更好地融入和关联时间序列中的所有信息,希望包括时间模式。

注意力首先在循环神经网络中开始使用(本章后面将更详细介绍),但其使用和实现实际上更容易理解,在一篇研究论文中提出了一种应用于前馈架构的变体。该论文提出了一种方法,可以将前馈神经网络应用于序列数据,使得网络对序列的每个步骤的反应可以用作该网络最终输出的输入。这种方法还允许变长序列,就像真实世界问题中常见的变长时间序列一样。

图 10-7 展示了一个示例架构,说明了前馈神经网络如何用于处理序列数据的任务。

图 10-7. “前馈注意力机制。”

在每个时间步骤,前馈网络逐个应用于输入,为每个时间步骤生成一个“隐藏状态” h[1]...h[T]。这有效地创建了一系列隐藏状态。第二个神经网络专门用于学习 a ( h t ) ,显示在角落里,这就是注意机制。注意机制允许网络学习如何加权每个输入,其中每个输入表示来自不同时间点的状态。这使得机制能够确定在最终输入的总和中对哪些时间步骤进行更重或更轻的加权。

然后,在最终处理之前,不同时间的隐藏状态与它们的注意系数结合起来,以生成所寻找的目标/标签。设计这个网络的研究人员发现,在通常被认为需要递归神经网络来满足记住先前输入并将其与后来的输入结合起来的要求的各种任务上,它表现得相当不错。

这是一个很好的例子,说明在深度学习模型中没有简单的架构,因为甚至基本模型也有很多适应复杂时间序列问题的可能性。

正如注意到的那样,前向神经网络并不是处理时间序列问题的领先网络。然而,它们是建立相对简单模型性能的一个有用起点。有趣的是,如果我们不包括激活函数,我们可以使用这些来使用 MXNet 框架编码 AR 和 VAR 模型。这在某些情况下可能非常有用。更重要的是,有全连接模型的架构变体可以对某些时间序列数据集非常精确。

CNNs

如果您已经接触过深度学习,您很可能对卷积神经网络(CNNs)非常熟悉。过去几年,计算机实现了极其复杂和复杂的卷积架构,以至于产生了令人瞠目结舌的、创纪录的、人类化的功能。尽管如此,卷积的概念相当直观,并且早在深度学习之前就已经存在。卷积长期以来在更透明的人类驱动的图像处理和图像识别研究中被广泛使用,从简单的高斯模糊开始。如果您不熟悉图像处理内核的概念,也不知道如何编写一个,Stack Overflow 提供了一个很好的解释,说明了如何使用高级 API 或通过 NumPy 手动实现。

卷积意味着将一个核(一个矩阵)应用到一个较大的矩阵上,通过在较大矩阵上滑动核来形成一个新的矩阵。新矩阵的每个元素是核和较大矩阵的一个子部分的逐元素乘法的总和。这个核被重复应用,因此不同的特征可以出现。如何在多层上运作的示意图显示在图 10-8 中。

图 10-8. 一个卷积网络。许多指定核大小的二维窗口滑过原始图像,通过应用于图像的可训练权重产生许多特征映射。通常,这些特征映射会被汇聚和用激活函数进行后处理。这个过程在几层中重复进行,将许多特征折叠到一个较小的值范围内,最终导致例如分类评分。

由于多种原因,传统卷积不太适合时间序列。卷积的一个主要特点是所有空间被等同对待。这对图像来说是合理的,但对于时间序列来说却不是,因为某些时间点比其他时间点更接近。卷积网络通常被设计为尺度不变,以便在图像中识别马的时候,无论它占据图像的较大部分还是较小部分都能够被识别出来。然而,在时间序列中,我们可能希望保留尺度和缩放特征。例如,年度季节性振荡不应以与日常振荡相同的方式或相同的特征选择器进行解释,尽管在某些情况下这可能有所帮助。

正是卷积的这种双刃剑特性——它们的优势从时间序列的角度来看也是它们的弱点——这导致它们通常被用作时间序列分析网络的组成部分,而不是整个网络的主体。此外,这也导致它们更多地被研究用于分类而不是预测,尽管这两种用途在该领域中都有发现。

卷积网络在时间序列应用中的一些用途包括:

  • 建立一个互联网用户浏览历史的“指纹”,有助于检测异常浏览活动

  • 从心电图数据中识别异常心跳模式

  • 基于大城市多个位置的过去记录生成交通预测

卷积本身并不适用于单变量时间序列,因此并不是所有情况下都十分有趣。多通道时间序列可能更有趣,因为我们可以开发一个二维(甚至三维)图像,其中时间仅是一个轴。

还有其他值得一提的架构创新,我们将在下一节讨论两个时间序列的卷积示例。

一个简单的卷积模型

我们可以将卷积模型拟合到我们的数据中,而不是完全连接的模型,通过替换不同的模型。在这种情况下(以及其余的示例中),我们采用 NTC 格式的数据,作为提醒,它看起来像这样:

时间 A B C
t – 1 0, 3 –1, –2 –2, –3
t 3, 4 –2, –2 –3, –4

这是 N × T × C。然而,卷积层期望的数据是 batch_size, channel, height × width

## python
>>> def cnn_model(iter_train, input_feature_shape, X, Y,
>>>               win, sz_filt, n_filter, drop):
>>>     conv_input = mx.sym.reshape(data=X, shape=(0, 1, win, -1)) 
>>>     ## Convolution expects 4d input (N x channel x height x width)
>>>     ## in our case channel = 1 (similar to a black and white image
>>>     ## height = time and width = channels slash electric locations
>>>     
>>>     cnn_output = mx.sym.Convolution(data=conv_input,
>>>                                     kernel=(sz_filt,
>>>                                             input_feature_shape[2]),
>>>                                     num_filter=n_filter)
>>>     cnn_output = mx.sym.Activation(data=cnn_output, act_type='relu')
>>>     cnn_output = mx.sym.reshape(mx.sym.transpose(data=cnn_output,
>>>                                                  axes=(0, 2, 1, 3)),
>>>                                 shape=(0, 0, 0)) 
>>>     cnn_output = mx.sym.Dropout(cnn_output, p=drop)
>>>         
>>>     output = mx.sym.FullyConnected(data=cnn_output,
>>>                                    num_hidden=input_feature_shape[2])
>>>     loss_grad = mx.sym.LinearRegressionOutput(data=output, label=Y)
>>>     return (loss_grad,
>>>             [v.name for v in iter_train.provide_data],
>>>             [v.name for v in iter_train.provide_label])   

再次注意,这并不包括任何明确的时间意识。不同的是,现在时间沿着单一轴排列,因此具有某种顺序。

这种时间意识能提高性能吗?可能并不是:

0          0.330701       0.292515 
1          0.389125       0.349906 
2          0.443271       0.388266 
3          0.491140       0.442201 
4          0.478684       0.410715 
5          0.612608       0.564204 
6          0.581578       0.543928 
7          0.633367       0.596467 
8          0.662014       0.586691 
9          0.699139       0.600454 
10         0.692562       0.623640 
11         0.717497       0.650300 
12         0.710350       0.644042 
13         0.715771       0.651708 
14         0.717952       0.651409 
15         0.712251       0.655117 
16         0.708909       0.645550 
17         0.696493       0.650402 
18         0.695321       0.634691 
19         0.672669       0.620604 
20         0.662301       0.597580 
21         0.680593       0.631812 
22         0.670143       0.623459 
23         0.684297       0.633189 
24         0.660073       0.604098 

 TESTING PERFORMANCE
{'COR': 0.5561901}

确定为何一个模型没有超越另一个模型可能是困难的。事实上,甚至模型的创建者对模型为何如此有效可能也会错误。这里几乎没有分析证明,考虑到数据集的轮廓有多重要,这也可能导致混淆。CNN 没有表现更好是否反映了大部分重要信息在最近的时间点上?还是反映了参数数量的不同?或者可能反映了未能选择好的超参数。在实际应用中,重要的是要理解性能是否合理,考虑到模型的结构、数据的结构以及可用的总参数数量。

我们还可以在代码中看到我们早停逻辑的一个缺陷。看起来它过于宽松。在这种情况下,我重新审视了这个问题,并注意到相关性的变化可能如下所示,经过一系列的 epochs:

[-0.023024142, 0.03423196, -0.008353353, 0.009730637, -0.029091835]

这意味着相关性的变化可能非常糟糕,甚至负面——只要偶尔有改善,即使是微小的改善,也会如此。这种宽松度证明是一个糟糕的决定,因此最好回溯到我们的流程管道,并设置更严格的早停条件。这是你在拟合深度学习模型时会遇到的一种让步和取舍。在建立管道时进行多次试运行有助于了解哪些参数适用于你的数据集和感兴趣的模型,要牢记,这些参数在不同的数据集中会有很大的差异。

替代卷积模型

尽管简单的卷积模型没有进行任何时间感知性的修改,但我们刚刚看到它表现出乎意料地好。现在我们讨论研究和工业中使用卷积架构解决时间序列问题的两种方法。

为什么这么吸引人呢?有几个原因。首先,卷积架构是经过验证的方法,实践者都熟知其中的最佳实践。这使得卷积模型很有吸引力,因为它们是已知的量。此外,卷积模型的参数很少,因为相同的滤波器一遍又一遍地重复使用,意味着没有太多需要训练的权重。最后,卷积模型的大部分计算可以并行进行,这意味着它们在推理过程中非常快速。

因果卷积

因果卷积最好通过图像来理解,因为这可以直观地表达卷积如何被修改以产生因果性和时间感。图 10-9 展示了一个扩张因果卷积的示例。因果性部分指的是只有早于当前时间的点可以输入到任何给定的卷积滤波器中。这就是为什么图像不对称的原因:早期的点流入到稍后时间使用的卷积中,但反之则不成立。

扩张部分指的是在卷积滤波器的排列中跳过点,使得任何给定点仅进入每个层次中的一个卷积滤波器。这促进了模型的稀疏性,并减少了冗余或重叠的卷积,使模型可以在保持总体计算合理的情况下更深入地观察过去的时间。

图 10-9. 此图描述了WaveNet架构的一个重要组成部分。在这里,我们看到卷积神经网络如何针对时间序列进行了架构修改。

这个扩张因果卷积的示例引入了时间因果性的概念,通过允许仅来自之前时间点的数据。也就是说,在这个图像中的卷积并非机会均等;它们只允许数据从过去流向未来,而不是反过来。原始输入中的每个数据点确实对最终的点产生影响。请注意这里的扩张是什么意思,即卷积的“深度”层之间跳过越来越多的数据点。在这里设置的扩张方式是,原始输入中的每个数据点都包含在最终的输入中,但只出现一次。这并不是扩张的必需条件,只是在这种情况下使用的方式。扩张也可以用于跳过时间点。

虽然因果卷积听起来复杂和理论化,但实际上执行起来非常简单。只需在矩阵的左侧添加填充——即添加早期时间步骤的零占位符,这样它们就不会对值产生贡献——然后设置填充为“valid”,这样卷积将只针对矩阵的实际边界运行,而不包括虚构的空单元。鉴于卷积的工作方式是元素逐元素乘积的总和,将零添加到图 10-9 的左侧意味着我们可以运行标准卷积,而额外的零单元格不会改变最终结果。

因果卷积在实际应用中取得了巨大成功,尤其是作为谷歌文本转语音和语音识别技术模型的一部分。

将时间序列转换为图片

卷积模型在图像分析上表现非常好,因此在试图使它们与时间序列相关时的一个好主意是找到一种将时间序列转换为图片的方法。有许多方法可以做到这一点,其中一个方法很有趣,因为它可以将单变量时间序列甚至转换为图片:构建循环图(参见图 10-10)。

图 10-10. 从左到右展示了四种类型时间序列的美观而深刻的可视化:(1)白噪声,(2)具有两个频率的谐波/季节性系列,(3)带有趋势的混沌数据,以及(4)自回归过程。来源:维基百科,由诺伯特·马尔万(Norbert Marwan)于 2006 年提供。

循环图是一种在相位状态空间中描述时间序列何时大致重访先前时间点相同相位和状态的方法。这是通过二进制循环函数定义的。如果f(i) - f(j)足够小,则循环被定义为R(i, j) = 1;否则为 0。这导致了一个二进制的黑白图像,就像我们在图 10-10 中看到的那些。请注意,ij是时间值,时间轴没有被限制或以任何方式限制。

尽管编写自己的循环图相对容易,但循环图功能也包含在诸如pyts之类的软件包中,并且绘图的源代码易于查找和理解。

我们可以想象,通过将多变量时间序列的不同变量视为图像的不同“通道”,将这个想法推广到单变量时间序列之外。这只是深度学习架构和技术非常灵活和开放变化的一个例子。

RNNs

递归神经网络(RNNs)是一类广泛的网络,在输入随时间变化的同时应用相同的参数。这听起来很像我们之前介绍的前馈神经网络,然而 RNNs 构成学术界和工业界在基于序列的任务、语言、预测和时间序列分类中最成功的模型,全面或部分如此。RNN 和前馈网络之间的重要区别是:

  • RNN 逐个时间步地看到时间步骤。

  • RNN 保留了从一个时间步到另一个时间步的状态,正是这种状态以及其静态参数决定了它对每个步骤的每个新信息的响应更新。

  • 因此,RNN 具有帮助它“更新”其状态(包括隐藏状态)的参数,从一个时间步到另一个时间步。

在介绍递归神经网络时,通常会通过“展开”范式来呈现,因为递归架构是基于单元的。这描述了相同参数一次又一次地使用,以便即使是非常长的时间序列,参数数量也相对较少(见图 10-11)。

图 10-11. 当递归神经网络架构应用于数据时,如何每个时间步展开一次。

然而,理解 RNN 如何工作的最简单方法可能只是看一个例子。我特别喜欢的 RNN 单元是门控循环单元(GRU)。有时,看到数学形式的表达式可能比看到代码更具威胁性,因此在此我提供了一个在 Python 中使用 NumPy 实现 GRU 的示例。如你所见,这里使用了两个激活函数:sigmoid 和 tanh。除此之外,我们所做的就是进行矩阵乘法和加法,以及逐元素矩阵乘法(Hadamard 乘积)。

## python
>>> ## this code is designed to work with weights exported
>>> ## from TensorFlow's 
>>> ## https://www.tensorflow.org/api_docs/python/tf/contrib/cudnn_rnn/CudnnGRU
>>> ## but can easily be repurposed to accommodate other weights
>>> def calc_gru(X, weights, num_inputs, num_features): 
>>>     Us = weights[:(3*num_features*num_inputs)] 
>>>     Us = np.reshape(Us, [3, num_features, num_inputs]) 
>>> 
>>>     Ws = weights[(3*num_features*num_inputs):(3*num_features*num_features +
        3*num_features*num_inputs)] 
>>>     Ws = np.reshape(Ws, [3, num_features, num_features]) 
>>> 
>>>     Bs = weights[(-6 * num_features) :] 
>>>     Bs = np.reshape(Bs, [6, num_features]) 
>>>     s = np.zeros([129, num_features]) 
>>>     h = np.zeros([129, num_features]) 
>>>      
>>>     for t in range(X.shape[0]):        
>>>         z = sigmoid(np.matmul(Us[0, :, :], X[t, :]) + 
>>>   np.matmul(Ws[0, :, :], s[t, :]) + Bs[0, :] + Bs[3, :]) 
>>>         r = sigmoid(np.matmul(Us[1, :, :], X[t, :]) + 
>>>   np.matmul(Ws[1, :, :], s[t, :]) + Bs[1, :] + Bs[4, :]) 
>>>         h[t+1, :] = np.tanh(np.matmul(Us[2, :, :], X[t, :]) + 
>>>   Bs[2, :] + 
>>>   r*(np.matmul(Ws[2, :, :], s[t, :]) + Bs[5, :])) 
>>>         s[t+1, :] = (1 - z)*h[t + 1, :] + z*s[t, :] 
>>>                          
>>>     return h, s

GRU 目前是最广泛使用的 RNN 单元之一。它是长短期记忆(LSTM)单元的简化版本,其操作方式类似。GRU 和 LSTM 之间的区别如下:

  • GRU 有两个“门”,而 LSTM 有三个。这些门用于确定允许多少新信息进入,保留多少旧信息等。因为 LSTM 有更多的门,所以它有更多的参数。

  • LSTM 往往具有更好的性能,但 GRU 训练速度更快(由于参数数量)。然而,有出版的结果显示,GRU 优于 LSTM。GRU 尤其可能在非语言任务中优于 LSTM。

正如你所看到的,区别更多地取决于适合你的培训资源的复杂程度以及你试图理解和预测的内容。

熟悉 GRU 和 LSTM 的矩阵实现非常重要,这样你就能了解它们的工作原理。一旦你了解了这一点,当它们在特定数据集上无法训练时,你也可以发展出一些直觉,知道为什么会这样。这里显示的格式可能难以识别动态特性的某些内容。

请注意,GRU 和 LSTM 都有助于解决当使用 RNN 时首次遇到的问题,即梯度爆炸和消失。由于同一参数的递归应用,经常出现梯度迅速降至零(无帮助)或无穷大(同样无帮助)的情况,这意味着反向传播难以进行,甚至不可能进行,因为递归网络已展开。这个问题通过 GRU 和 LSTM 得到了解决,因为它们倾向于保持单元中的输入和输出在可控的值范围内。这既是由于它们使用的激活函数的形式,也是由于更新门可以学习如何传递或不传递信息,从而使合理的梯度值比在没有门控概念的香草 RNN 单元中更有可能。

尽管 GRU 和 LSTM 都相对容易自行实现,正如刚刚展示的那样,在现实中你不会希望这样做。最重要的原因是,许多矩阵乘法操作可以被融合。用于处理这些操作并利用硬件的最有效和可访问的实现是 NVIDIA 的 cuDNN,它融合了 GRU 和 LSTM 单元所需的矩阵乘法操作。使用 cuDNN 接口而不是另一种实现显著提高了速度,甚至被一些 Kaggle 比赛的获胜者引用为赢与输的区别,因为它在加速训练过程中非常有帮助。所有主要的深度学习框架都提供对此实现的访问,尽管在某些情况下(例如 TensorFlow 的tf.contrib.cudnn_rnn),你需要使用特别指定的接口。在其他情况下,如 MXNet,你会默认使用 cuDNN,只要不对自定义展开的单元做任何花哨的处理。

继续我们的电力示例

我们也可以使用 RNN 来处理电力预测示例。同样地,我们从 TNC 数据格式作为输入开始。这是 RNN 的预期格式,因此我们甚至不需要对其进行更改:

## python
>>> def rnn_model(iter_train, window, filter_size, num_filter, 
>>>               dropout):
>>>     input_feature_shape = iter_train.provide_data[0][1]    
>>>     X = mx.sym.Variable(iter_train.provide_data[0].name)
>>>     Y = mx.sym.Variable(iter_train.provide_label[0].name)
>>> 
>>>     rnn_cells = mx.rnn.SequentialRNNCell()
>>>     rnn_cells.add(mx.rnn.GRUCell(num_hidden=args.rnn_units))
>>>     rnn_cells.add(mx.rnn.DropoutCell(dropout))
>>>     outputs, _ = rnn_cells.unroll(length=window, inputs=X, 
>>>                                   merge_outputs=False)
>>> 
>>>     output = mx.sym.FullyConnected(data=outputs[-1], 
>>>                                    num_hidden = 
                                       input_feature_shape[2])
>>>     loss_grad = mx.sym.LinearRegressionOutput(data  = output, 
>>>                                               label = Y)
>>> 
>>>     return loss_grad, [v.name for v in iter_train.provide_data], 
>>>                       [v.name for v in iter_train.provide_label]

鉴于设计用于处理时间数据,这个模型的性能相当令人失望:

Epoch     Training Cor     Validation Cor
0          0.072042       0.069731 
1          0.182215       0.172532 
2          0.297282       0.286091 
3          0.371913       0.362091 
4          0.409293       0.400009 
5          0.433166       0.422921 
6          0.449039       0.438942 
7          0.453482       0.443348 
8          0.451456       0.444014 
9          0.454096       0.448437 
10         0.457957       0.452124 
11         0.457557       0.452186 
12         0.463094       0.455822 
13         0.469880       0.461116 
14         0.474144       0.464173 
15         0.474631       0.464381 
16         0.475872       0.466868 
17         0.476915       0.468521 
18         0.484525       0.477189 
19         0.487937       0.483717 
20         0.487227       0.485799 
21         0.479950       0.478439 
22         0.460862       0.455787 
23         0.430904       0.427170 
24         0.385353       0.387026 

 TESTING PERFORMANCE
{'COR': 0.36212805}

研究性能为何不佳需要更多调查。我们是否没有向 RNN 提供足够的参数?增加一些常用的相关模型架构,比如注意力,是否有意义?(这同样适用于 RNN 和前馈网络,正如之前讨论的那样。)

该模型在训练过程中比前馈或卷积模型早达到了其性能峰值。这是描述数据集的参数不足,或者 RNN 专门为这些数据设计,或者其他原因。你需要进行额外的实验和调整,以了解原因。

自编码器的创新

偶尔你可能会遇到一个数据集,一个非常简单的模型已经为你希望实现的任务做了惊人的工作。⁴ 但有时候打破常规也可能会带来丰硕的成果。例如,早期发现,简单的 RNN 创新在类似序列建模中也能显著提高性能。虽然这个模型最初是为语言学习和机器翻译开发的,但在更多数值任务,如预测电力负荷预测或股票价格方面,它通常也被证明非常成功。被称为自编码器,或者seq2seq模型,这是一个非常常用的模型,你应该将其作为时间序列深度学习工具包的核心部分(见 Figure 10-12)。我们将在本书的几个示例章节中部署它,分析真实世界的时间序列数据。

自编码器是两个递归层,但不是传统意义上的每个层依次处理每个输入。相反,第一层运行完成后。它的隐藏状态然后传递到第二层,第二层将这个隐藏状态和自己的输出作为新的输入传递到下一个步骤。这个模型特别是以机器翻译的概念来构建的,其中在每个时间步输出预测并不重要,因为在不同的语言中,单词和概念的排序可能截然不同,但说的是同样的事情。这个想法是一旦第一层完全处理了时间序列,它的隐藏状态就能具有某种总结功能。然后这个摘要将被注入到新模型中,新模型将通过将这个摘要与自己的每个时间步的输出结合起来逐渐展开到新的语言中,以便它知道它说了什么。

如前所述,尽管这个模型起源于自然语言处理,但它也对传统的时间序列任务非常有用,比如预测单变量或多变量时间序列。例如,在一个 Kaggle 竞赛中,预测维基百科文章中的网络流量,第一名获得者在尝试了许多超参数和架构组件后,最终选择了自编码器模型。看来,获胜者的优势可能主要在于智能训练和超参数搜索,以及对有用特征生成和选择的深入研究。

图 10-12. 自编码器,通常称为 seq2seq 模型,在语言处理和建模中非常流行,但在时间序列分析中也显示出显著的成功。

组合架构

在时间序列预测的成功工业和竞赛应用中,很多时候会使用一些新颖的架构,无论是在应用传统 LSTM 单元上的新颖方法,还是结合不同的组件。卡内基梅隆大学研究人员提出的 2018 年神经网络架构就是一个例子。研究人员试图利用卷积和递归架构的优势,同时还添加了其他创新。

他们开发了一个“跳跃递归”层,使得递归模型可以调整以关注数据集中存在的周期性(如年度、每周、每日,具体取决于数据集的性质),这种周期性本身可以作为超参数进行探索。

他们认识到许多时间序列的趋势不适合非线性深度学习模型建模。这意味着仅仅使用深度学习模型无法展示某些时间序列数据集在时间上显著的规模变化。研究人员通过使用传统的线性模型进行了调整,即我们在第六章中讨论的自回归模型。

最终的模型,修改后的 LSTNet,是来自 AR 模型和一个使用传统递归层和平行跳跃递归层构建的模型输出之和。输入到每个递归层的是卷积层的输出,这些卷积层沿着时间和通道轴进行卷积(参见图 10-13)。

研究人员发现,在他们尝试的四个数据集中,有三个数据集的表现优于涵盖广泛主题领域的最新发表结果。仅在外汇汇率这一领域失败,这是一个以高噪声信号比和高效市场著称的金融领域,在市场变得高效并且投资者试图寻找优势时,任何信号都很快消失。

图 10-13. 在修改后的 LSTNet 架构中,我们可以看到图像底部存在一个自回归组件,与神经网络架构并行。神经网络架构将卷积元素和递归元素按顺序放置在同一个输入上,一个接一个地操作。

下一个详细阐述的灵感来自研究人员的论文,我们将使用从 MXNet 包示例目录中修改的代码⁵,由示例的作者 Oliver Pringle 在一篇博客文章中进行了详细描述。

正如前文所述,我们应用基于对 MXNet 存储库的修改的代码,通过删除季节性/跳跃连接,并仅使用一个卷积滤波器大小来简化它。我们应用一个卷积层,就像我们在cnn_model示例中做的那样:

## python
>>> ## must be 4d or 5d to use padding functionality
>>> conv_input = mx.sym.reshape(data=X, shape=(0, 1, win, -1)) 
>>> 
>>> ## convolutional element
>>> ## we add padding at the end of the time win
>>> cnn_output = mx.sym.pad(data=conv_input,
>>>                         mode="constant",
>>>                         constant_value=0,
>>>                         pad_width=(0, 0,
>>>                                    0, 0,
>>>                                    0, sz_filt - 1, 
>>>                                    0, 0))
>>> cnn_output = mx.sym.Convolution(data=cnn_output,
>>>                                 kernel=(sz_filt,
>>>                                         input_feature_shape[2]),
>>>                                 num_filter=n_filter)
>>> cnn_output = mx.sym.Activation(data=cnn_output, act_type='relu')
>>> cnn_output = mx.sym.reshape(mx.sym.transpose(data=cnn_output,
>>>                                              axes=(0, 2, 1, 3)),
>>>                             shape=(0, 0, 0))
>>> cnn_output = mx.sym.Dropout(cnn_output, p=drop)

然后,我们将 RNN 应用于卷积组件而不是原始输入,如下所示:

## python
>>> ## recurrent element
>>> stacked_rnn_cells = mx.rnn.SequentialRNNCell()
>>> stacked_rnn_cells.add(mx.rnn.GRUCell(num_hidden=args.rnn_units))
>>> outputs, _ = stacked_rnn_cells.unroll(length=win,
>>>                                       inputs=cnn_output,
>>>                                       merge_outputs=False)
>>> 
>>> rnn_output    = outputs[-1] 
>>> n_outputs     = input_feature_shape[2]
>>> cnn_rnn_model = mx.sym.FullyConnected(data=rnn_output,
>>>                                       num_hidden=n_outputs)

最后,与 CNN/RNN 结合并行,我们训练了一个 AR 模型,每个电站位置一个 AR 模型(因此有 321 个不同的 AR 模型,每个对应一个列/变量/电站),如下所示。这使用了每个站点的每个时间点,模型逐个站点指定:

## python
>>> ## ar element
>>> ar_outputs = []
>>> for i in list(range(input_feature_shape[2])):
>>>     ar_series = mx.sym.slice_axis(data=X,
>>>                                   axis=2,
>>>                                   begin=i,
>>>                                   end=i+1)
>>>     fc_ar = mx.sym.FullyConnected(data=ar_series, num_hidden=1)
>>>     ar_outputs.append(fc_ar)
>>> ar_model = mx.sym.concat(*ar_outputs, dim=1)

完整代码如下:

## python
>>> def simple_lstnet_model(iter_train,  input_feature_shape, X, Y,
>>>                         win, sz_filt, n_filter, drop):
>>>     ## must be 4d or 5d to use padding functionality
>>>     conv_input = mx.sym.reshape(data=X, shape=(0, 1, win, -1)) 
>>> 
>>>     ## convolutional element
>>>     ## we add padding at the end of the time win
>>>     cnn_output = mx.sym.pad(data=conv_input,
>>>                             mode="constant",
>>>                             constant_value=0,
>>>                             pad_width=(0, 0,
>>>                                        0, 0,
>>>                                        0, sz_filt - 1, 
>>>                                        0, 0))
>>>     cnn_output = mx.sym.Convolution(data = cnn_output,
>>>                                     kernel = (sz_filt,
>>>                                             input_feature_shape[2]),
>>>                                     num_filter = n_filter)
>>>     cnn_output = mx.sym.Activation(data     = cnn_output, 
>>>                                    act_type = 'relu')
>>>     cnn_output = mx.sym.reshape(mx.sym.transpose(data = cnn_output,
>>>                                                  axes = (0, 2, 1, 3)),
>>>                                 shape=(0, 0, 0))
>>>     cnn_output = mx.sym.Dropout(cnn_output, p = drop)
>>> 
>>>     ## recurrent element
>>>     stacked_rnn_cells = mx.rnn.SequentialRNNCell()
>>>     stacked_rnn_cells.add(mx.rnn.GRUCell(num_hidden = args.rnn_units))
>>>     outputs, _ = stacked_rnn_cells.unroll(length = win,
>>>                                           inputs = cnn_output,
>>>                                           merge_outputs = False)
>>>     rnn_output    = outputs[-1] 
>>>     n_outputs     = input_feature_shape[2]
>>>     cnn_rnn_model = mx.sym.FullyConnected(data=rnn_output,
>>>                                           num_hidden=n_outputs)
>>>     ## ar element
>>>     ar_outputs = []
>>>     for i in list(range(input_feature_shape[2])):
>>>         ar_series = mx.sym.slice_axis(data=X,
>>>                                       axis=2,
>>>                                       begin=i,
>>>                                       end=i+1)
>>>         fc_ar = mx.sym.FullyConnected(data       = ar_series, 
                                          num_hidden = 1)
>>>         ar_outputs.append(fc_ar) 
>>>     ar_model = mx.sym.concat(*ar_outputs, dim=1)
>>> 
>>>     output = cnn_rnn_model + ar_model
>>>     loss_grad = mx.sym.LinearRegressionOutput(data=output, label=Y)
>>>     return (loss_grad,
>>>             [v.name for v in iter_train.provide_data],
>>>             [v.name for v in iter_train.provide_label])    

注意,这个模型的性能明显比其他任何模型都要好:

Epoch     Training Cor     Validation Cor
0          0.256770       0.234937 
1          0.434099       0.407904 
2          0.533922       0.506611 
3          0.591801       0.564167 
4          0.630204       0.602560 
5          0.657628       0.629978 
6          0.678421       0.650730 
7          0.694862       0.667147 
8          0.708346       0.680659 
9          0.719600       0.691968 
10         0.729215       0.701734 
11         0.737400       0.709933 
12         0.744532       0.717168 
13         0.750767       0.723566 
14         0.756166       0.729052 
15         0.760954       0.733959 
16         0.765159       0.738307 
17         0.768900       0.742223 
18         0.772208       0.745687 
19         0.775171       0.748792 
20         0.777806       0.751554 
21         0.780167       0.754034 
22         0.782299       0.756265 
23         0.784197       0.758194 
24         0.785910       0.760000 

 TESTING PERFORMANCE
{'COR': 0.7622162}

这个模型显然比其他模型做得更好,所以这个架构在使用卷积图像作为循环层训练的序列数据上有一些特别和有用的东西。传统的 AR 模型作为一个统计工具也增加了相当多的功能。⁶ 这个模型远远是最好的,这是我们进行少量数据探索和培训的一个很好的教训。值得尝试各种模型,你甚至不需要花费大量时间来训练所有的模型以发现一个明确的领导者。

总结

我们在这一章节训练中一个有趣的方面是,一些看似概念上更复杂的模型表现不如简单的前馈网络。然而,这并不一定能解决对于我们数据集来说哪些模型更好或更差的问题,原因有很多:

  • 我们没有检查每个模型使用的参数数量。不同类型的模型可能会因参数数量不同而表现出截然不同的性能。我们可以玩转模型复杂性,例如卷积/递归层数或过滤器/隐藏单元的数量。

  • 我们没有调整超参数。有时候,获取正确的超参数可以极大地提升模型的性能。

  • 我们没有对数据进行足够的探索,以便有一个预先的想法,即在时间和不同列/电站之间的相关性中,我们期望哪个模型表现更好或更差。

更多资源

  • 历史文献:

    Sepp Hochreiter 和 Jürgen Schmidhuber,《长短期记忆》,《神经计算》9,第 8 卷(1997 年):1735–80,https://perma.cc/AHR3-FU5H。

    这部 1997 年的开创性作品介绍了长短期记忆单元(LSTM),并提出了几个至今用于研究神经网络在序列分析上性能的实验基准。

    Peter G. Zhang, Eddy Patuwo, 和 Michael Hu, “用人工神经网络进行预测: 现状综述,” International Journal of Forecasting 14, no. 1 (1998): 35–62,https://perma.cc/Z32G-4ZQ3。

    本文概述了 1998 年时间序列和深度学习的现状。

  • 对于 RNNs:

    Aurélien Geron, “使用 RNNs 和 CNNs 处理序列”,在Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow,第二版(Sebastopol: O’Reilly Media, Inc., 2019)。

    Aurélien Geron 在这本流行书中提供了大量关于如何将深度学习应用于时间序列数据的示例。如果你对这些工具很熟悉,这个 Jupyter 笔记本 展示了许多不同种类的模型在序列数据上的应用实例,包括一些带解决方案的练习。

    Valentin Flunkert, David Salinas 和 Jan Gasthaus, “DeepAR: 自回归递归网络的概率预测,” 2017,https://perma.cc/MT7N-A2L6。

    本篇开创性的论文展示了亚马逊开发的适应其零售数据时间序列的模型,该数据出现在多种规模和趋势中。工作的一个特别创新之处在于能够进行概率预测,而不是通常由深度学习分析产生的点估计结果。

    Lingxue Zhu 和 Nikolay Laptev, “Uber 时间序列的深度和自信预测,” 论文发表于 2017 年 IEEE 国际数据挖掘工作坊(ICDMW),新奥尔良,路易斯安那州,https://perma.cc/PV8R-PHV4。

    本文展示了另一个关于典型 RNN 进行概率和统计启发修改的例子。在这种情况下,Uber 提出了一种新颖的贝叶斯深度模型,提供了点估计和不确定性估计,可以在生产环境中实现相对快速的性能。

    Zhengping Che 等人,“多变量时间序列中的递归神经网络和缺失值,” Scientific Reports 8, no. 6085 (2018),https://perma.cc/4YM4-SFNX。

    本文展示了一项关于医疗时间序列的最新工作,展示了使用 GRU 结合新颖架构处理缺失数据并将缺失信息转化为信息属性的方法。作者展示了能够击败目前所有用于预测患者健康和住院统计的临床指标的神经网络。这是一个关于如何通过对简单且广泛使用的 RNN 结构(GRU)进行直观易懂的修改来取得突破性成果的绝佳例子。

  • 对于 CNNs:

    Aäron van den Oord 和 Sander Dieleman, “WaveNet: 一种原始音频生成模型 DeepMind 博客,” 2016 年 9 月 8 日,https://perma.cc/G37Y-WFCM。

    这篇博客提供了一篇极为详尽且易于理解的描述,介绍了一种突破性的 CNN 架构,该架构用于增强文本到语音和语音到文本技术,在多种语言和不同发言者中得到了应用。新架构显著提升了性能,并随后应用于其他与序列相关的 AI 任务,特别是时间序列预测。

  • 在深度学习的应用:

    Vera Rimmer 等人,《通过深度学习实现自动网站指纹识别》,2018 年 NDSS 会议论文,圣迭戈,CA,https://perma.cc/YR2G-UJUW。

    本文阐述了一种利用深度学习揭示用户互联网浏览内容隐私信息的方法,即通过网站指纹识别。特别是作者强调了各种神经网络架构可以用来制定网站指纹攻击,以突破用户隐私保护。

    CPMP,《Kaggle 网页流量预测竞赛第二名解决方案》,Kaggle 博客,2017 年,https://perma.cc/UUR4-VNEU。

    这篇博客在比赛结束前写成,描述了第二名胜者在设计混合机器学习/深度学习解决方案时的思考。在相关博客文章中还有一些回顾性评论。这是一个现代化包和相关编程风格的典范。第一名解决方案的 GitHub 存储库也可以在此处找到,还有一个关于其基于神经网络的架构的讨论。

¹ 文件下载来自 GitHub 的数据提供者。

² 我们没有包含差分代码,因为这在第十一章已经覆盖,并且只涉及几行代码。

³ 正如我们在本书中之前讨论的,金标准是在多个时间段内训练和推进模型,但我们在这个代码库中避免了这种复杂性。在生产代码库中,您会希望进行滚动验证和测试,以优化模型并更好地了解实际性能;这种技术引入了测试数据和训练数据之间的额外滞后,并且意味着用于评估模型的测试数据最终只反映了整个历史的一个时间段。

⁴ 有传言称谷歌翻译曾由一个相当普通的七层 LSTM 模型驱动。然而,他们庞大的数据集无疑有所帮助,还有巧妙和谨慎的训练。并非所有普通模型都一样!

⁵ 代码也可以在 Oliver Pringle 的个人 GitHub 仓库找到。

⁶ 如果你不相信我,可以尝试在没有增强现实(AR)组件的情况下进行训练——修改代码移除这个组件并不困难。

第十一章:测量误差

在之前的章节中,我们使用了各种措施来比较模型或评估模型执行任务的效果。在本章中,我们将重点介绍评估预测准确性的最佳实践,特别关注时间序列数据的相关问题。

对于那些刚接触时间序列预测的人来说,最重要的是要理解通常不建议使用标准交叉验证。你不能通过随机选择数据的随机样本来选择训练、验证和测试数据集,因为这种方式对时间不敏感。

但事情比这更复杂。你需要考虑不同数据样本在时间上的关系,即使它们看起来是独立的。例如,假设你正在处理时间序列分类任务,因此你有许多样本的单独时间序列,每个都是自己的数据点。在这种情况下,可能会诱人地认为你可以随机选择每个训练、验证和测试的时间序列,但如果你这样做就错了。这种方法的问题在于它不会反映你将如何使用你的模型,即你的模型将在较早的数据上进行训练,并在较晚的数据上进行测试。

你不希望未来信息泄漏到你的模型中,因为这不符合真实世界建模情况。这意味着你在测试中测量的预测误差会比在生产中低,因为在测试中你会通过交叉验证模型,获得了未来信息(即,反馈告诉你应该使用哪个模型)。

下面是一个具体的场景,展示了这种情况可能发生的方式。想象一下,你正在为美国西部主要城市的空气质量监测器进行训练。在你的训练集中,你包括了旧金山、盐湖城、丹佛和圣地亚哥在 2017 年和 2018 年的所有数据。在你的测试集中,你包括了同一日期范围内的拉斯维加斯、洛杉矶、奥克兰和凤凰城的数据。你发现你的空气质量模型在拉斯维加斯和洛杉矶表现特别好,而且整体上在 2018 年也表现非常好。太棒了!

然后你试图在来自早几十年数据的模型训练过程中复制你的模型训练过程,并发现它在其他训练/测试运行中的测试效果不如训练效果好。然后你记得 2018 年南加州的创纪录森林大火,并意识到它们已经“内建”到原始的测试/训练运行中,因为你的训练集给了你一个窥视未来的窗口。这是避免标准交叉验证的一个重要原因。

有时,未来信息向后传播到模型选择中并不是一个问题。例如,如果您仅希望通过测试来了解时间序列的动态,您并不是真正寻求预测,而更多地是测试给定模型对数据的最佳适应性。在这种情况下,包括未来数据有助于理解动态,尽管您要警惕过度拟合。因此,甚至在这种情况下,保持一个有效的测试集——需要防止未来信息向后泄漏——仍然会引起关于时间序列和交叉验证的担忧。

完成了这一般性评论后,我们转向一个具体的例子,讲述了将数据分配到训练、验证和测试模型的具体机制。然后我们更加普遍地讨论了如何确定一个预测何时足够好,或者尽可能地好。我们还研究了如何估计我们的预测的不确定性,特别是在使用那些输出中不直接产生不确定性或误差测量的技术时。最后,我们列出了一些容易忽视但在构建时间序列模型或准备投入生产时可能会有帮助的注意事项。这可能有助于您避免一些尴尬!

基础知识:如何测试预测

生成预测最重要的元素是确保您仅使用足够提前获取的数据来构建它。因此,您不仅需要考虑事件发生的时间,还需要考虑数据何时对您可用。¹

尽管这似乎足够简单,但请记住,常见的预处理,如指数平滑,可能会无意中导致从训练期到测试期的信息泄漏。您可以通过首先将线性回归拟合到自回归时间序列,然后再将其拟合到指数平滑的自回归时间序列来自行测试这一点。您会发现,随着时间序列的平滑程度越来越高,以及平滑半衰期越来越长,您的预测会变得“更好”。这是因为实际上,随着您的价值越来越多地由过去值的指数平均组成,您需要做的预测就越来越少。这是一个相当危险的展望,它如此隐匿,以至于它仍然出现在学术论文中!

由于存在这些危险以及其他难以发现的方式将未来信息输入到过去或反之,任何模型的黄金标准应该是使用向前滚动的训练、验证和测试期进行反向测试。

在回测中,模型是针对一组日期或日期范围开发的,但然后在历史数据上进行广泛测试,最好能够代表可能条件和变化的全范围。同样重要的是,实践者需要有为特定模型进行回测的原则性理由,并且应尽量避免尝试过多的模型。正如大多数数据分析师所知,您测试的模型越多,您就越可能对数据过拟合——即选择了一个过于关注当前数据集细节的模型,而不是以稳健方式进行泛化。不幸的是,对于时间序列实践者来说,这是一个棘手的平衡行为,可能会在将模型投入生产时导致令人尴尬的结果。

具体来说,我们如何实现某种形式的回测?我们以保持类似交叉验证的结构进行,尽管是时间感知的。通常的范例,假设您有代表时间按字母顺序进行推移的数据,如下所示:

使用[A]进行训练 使用[B]进行测试
使用[A B]进行训练 使用[C]进行测试
使用[A B C]进行训练 使用[D]进行测试
使用[A B C D]进行训练 使用[E]进行测试
使用[A B C D E]进行训练 使用[F]进行测试

图 11-1 说明了这种测试结构。

图 11-1。评估时间序列模型性能的黄金标准,即向前滚动您的训练、验证和测试窗口。

也可以移动训练数据窗口而不是扩展它。在这种情况下,您的训练可能如下所示:

使用[A B]进行训练 使用[C]进行测试
使用[B C]进行训练 使用[D]进行测试
使用[C D]进行训练 使用[E]进行测试
使用[D E]进行训练 使用[F]进行测试

您选择的方法部分取决于您认为系列行为是否随时间变化。如果您认为会变化,最好使用移动窗口,以便所有测试期间都使用训练到最相关数据的模型进行测试。另一个考虑因素可能是,您想避免过拟合,使用扩展窗口将比使用固定长度窗口更好地约束您的模型。

因为这种滚动拆分是常见的训练需求,R 和 Python 都提供了生成它的简单方法:

  • 在 Python 中,生成数据拆分的一种简单方法是使用sklearn.model_selection.TimeSeriesSplit

  • 在 R 中,forecast包中的tsCV将使用这种回测模式向前推进模型,并报告错误。

在 R 和 Python 中还有其他包可以执行相同的操作。如果您对如何为特定项目实施此模型测试有具体想法,您还可以编写自己的函数来拆分数据。您可能想跳过某些时间段,因为它们表现出异常的动态,或者您可能希望更加权重某些时间段的性能。

例如,假设您处理的是金融数据。根据您的目标,排除 2008 年金融危机等特殊时期的数据可能是值得的。或者如果您处理零售数据,即使在预测低销量季节的准确性上牺牲了一些,也可能希望在圣诞购物季节最重视模型性能的权重。

回测模型的具体考虑因素

在构建您的反向测试时,请考虑您正在训练的模型的动态特性,特别是与使用特定时间范围数据训练模型有关的含义。

对于传统的统计模型(如 ARIMA),在选择模型参数时,所有数据点都是平等考虑的,因此如果您认为模型参数应随时间变化,则更多数据可能会使模型不太准确。对于机器学习模型也是如此,所有训练数据都平等地被考虑进去。²

另一方面,分批随机方法可能会导致权重和估计随时间演变。因此,如果您按时间顺序训练数据,使用典型的随机梯度下降方法训练的神经网络模型将在某种程度上考虑数据的时间性质。权重的最新梯度调整将反映最新的数据。在大多数情况下,时间序列神经网络模型是按照时间顺序训练的,因为这往往比按随机顺序训练的模型产生更好的结果。

警告

不要在您的数据中留下空洞。时间序列数据的难度和美丽之处在于数据点是自相关的。因此,我们不能随机选择时间序列中的点进行验证或测试,因为这将破坏数据的某些自相关性。结果,我们的模型对数据的自回归组成的识别将受到损害,这是最不希望的结果。

状态空间模型也提供了适应时间变化的机会,因为模式会适应。这有利于使用更长的训练窗口,因为长时间窗口不会阻止后验估计随时间演变。

随时间快照您的模型

因为您将通过在过去数据上拟合后将其向前推移来测试您的模型,所以您需要一种简单的方法来保存带有时间戳的模型,以便您知道可以适当使用该模型的最早时间点。这将帮助您避免在其自身训练数据上无意中测试模型。它还将为您提供在测试数据上应用来自不同时间段的几种不同模型的机会。这可以成为一种看出模型最近训练与其在测试数据上的准确性是否重要的方法,这最终可以帮助您选择时间序列模型在生产代码中需要多频繁地重新拟合。

何时您的预测足够好?

当你的预测足够好时,这取决于你的整体目标,你可以“逃脱”的最低质量以及你需要做的事情,以及你的数据的限制和性质。如果你的数据本身具有非常高的噪声与信号比,你应该对你的模型的预期有所限制。

记住,时间序列模型并不完美。但你应该希望做得像或稍微比替代方法好,例如解决有关气候变化的微分方程组,向一个消息灵通的股票交易员询问意见,或者咨询显示如何分类脑电图迹象的医学教科书。在评估性能时,请记住预测的已知领域专家限制,这些措施表明目前在许多预测问题中性能的上限。

有时候你会知道你的模型还不够好,你可以做得更好。以下是一些你可以采取的措施来识别这些机会:

绘制你的模型在测试数据集上的输出

模型产生的分布应该与你试图预测的值的分布相匹配,假设没有预期的制度变化或潜在趋势。例如,如果你试图预测股票价格,并且你知道股票价格上下波动的频率大致相等,如果模型总是预测价格上涨,那么你的模型就是不充分的。有时分布显然会有所偏差,而在其他时候,你可以应用一个检验统计量来比较你模型的输出与实际目标的关系。

绘制你的模型残差随时间的图像

如果残差随时间不均匀,你的模型就是不充分的。残差的时间行为可以指导你需要描述模型中的额外参数以描述时间行为。

对你的模型进行简单的时间感知空模型测试

一个常见的空模型是每个时间t的预测应该是时间t - 1 的值。如果你的模型无法击败这样一个简单的模型,那就没有任何理由支持它。如果一个简单的通用天真模型能够击败你制作的模型,那么这是你的模型、损失函数或数据预处理的基本问题,而不是超参数网格搜索的问题。或者,这可能表明数据相对于信号具有大量噪声,这也表明你的模型对其预期用途是无用的。³

研究你的模型如何处理异常值

在许多行业中,异常值简单地被称为:异常。这些事件很可能是无法预测的,⁴ 这意味着你的模型最好忽略这些异常值而不是适应它们。事实上,你的模型预测异常值很可能是过拟合或选择了不合适的损失函数的迹象。这取决于你选择的模型和所使用的损失函数,但对于大多数应用程序,你希望模型的预测不要像数据集中的极端值那样极端。当然,在异常事件的成本高和主要预测任务是在可能的情况下警告异常事件时,这条建议并不适用。

进行时间敏感性分析

在相关的时间序列中产生相关结果的行为在你的模型中是否有类似的特性?通过你对系统基础动态的理解,确保这一点是正确的,并且你的模型能够以相同的方式识别和处理类似的时间模式。例如,如果一个时间序列显示上升趋势,每天漂移 3 单位,而另一个时间序列显示上升趋势,每天漂移 2.9 单位,你可能希望确保最终为这些序列制作的预测是相似的。同时,你还需要确保与输入数据相比,预测的排名是合理的(更大的漂移应导致更大的预测值)。如果情况不是这样,你的模型可能存在过拟合的风险。

这不是测试时间序列模型的众多方法的完整列表,但可以作为在特定领域获得经验的起点。

使用模拟来估计模型中的不确定性

传统统计时间序列分析的一个优点是,这类分析具有关于估计不确定性的明确定义的分析公式。然而,即使在这种情况下,特别是在非统计方法的情况下,通过计算方法了解与预测模型相关的不确定性也是有帮助的。一个非常直观和易于接近的方法是简单的模拟。

假设我们已经对我们认为是 AR(1) 过程进行了分析。作为提醒,AR(1) 过程可以表达为:

y t = ϕ × y t-1 + e t

在模型拟合之后,我们想研究我们的ϕ系数估计有多变。在这种情况下,研究的一种方法是运行多个蒙特卡罗模拟。只要我们记得在第六章中学到的 AR 过程的内容,这可以在 R 中轻松完成:

## R
> require(forecast)
> 
> phi         <- 0.7
> time_steps  <- 24
> N           <- 1000
> sigma_error <- 1
> 
> sd_series   <- sigma_error² / (1 - phi²) 
> starts      <- rnorm(N, sd = sqrt(sd_series))
> estimates   <- numeric(N)
> res         <- numeric(time_steps)
> 
> for (i in 1:N) {
>   errs = rnorm(time_steps, sd = sigma_error)
>   res[1]  <- starts[i] + errs[1]
>   
>   for (t in 2:time_steps) {
>     res[t] <- phi * tail(res, 1) + errs[t]
>   }
>   estimates <- c(estimates, arima(res, c(1, 0, 0))$coef[1])
> }
> 
> hist(estimates, 
>      main = "Estimated Phi for AR(1) when ts is AR(1)", 
>      breaks = 50)  

这导致了图中显示的 Figure 11-2 对于估计的ϕ的直方图。

图 11-2. ϕ 的估计分布。

我们还可以通过应用于estimatessummary()函数,获得估计和分位数的范围感。

## R
> summary(estimates1)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
-0.3436  0.4909  0.6224  0.5919  0.7204  0.9331 

我们也可以使用自举法来提出更复杂的问题。假设我们想知道与地面真实情况相比,简化我们模型可能会付出何种数值成本。想象一下,我们研究的过程是一个 AR(2),即使我们将其诊断为 AR(1)过程。为了了解这如何影响我们的估计,我们可以修改前面的 R 代码如下:

## R
> ## now let's assume we have a true AR(2) process
> ## because this is more complicated, we switch over to arima.sim
> phi_1 <- 0.7
> phi_2 <- -0.2
> 
> estimates <- numeric(N)
> for (i in 1:N) {
>   res <- arima.sim(list(order = c(2,0,0), 
>                        ar = c(phi_1, phi_2)), 
>                   n = time_steps)
>   estimates[i] <- arima(res, c(1, 0, 0))$coef[1]
> }
> 
> hist(estimates, 
>      main = "Estimated Phi for AR(1) when ts is AR(2)", 
>      breaks = 50)

我们在图 11-3 中看到结果分布。正如我们所见,与正确指定的模型的分布相比,对于这个错误指定的模型来说,分布不如此平滑和明确定义,这一点是明显的。

图 11-3. 估计的滞后 1 系数分布,适用于一个真实描述为 AR(2)过程的过程拟合的 AR(1)模型。

你可能会觉得这些分布并没有太大差异,你是对的。我们通过汇总统计数据来确认这一点:⁵

## R
> summary(estimates)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
-0.5252  0.4766  0.6143  0.5846  0.7215  0.9468 

我们可以看到,在模型错误指定时,估计范围更广,而第一阶项的估计稍逊于模型正确指定时的情况,但偏差并不太大。

这可以解决低估模型阶数将如何影响我们对ϕ估计的担忧。我们可以运行多种模拟场景来解决潜在问题,并了解在某些想象的情况下可能的误估范围。

预测多步骤

尽管在大多数过去的章节中我们讨论的是一步预测,但你可能也想预测多个时间步长。这种情况发生的原因之一是,你拥有的时间序列数据的时间分辨率比你想预测的时间序列值更高。例如,你可能有每日股票价格数据,但你希望预测月度股票价格,以便为退休储蓄执行长期策略。或者,你可能有每分钟进行一次的大脑电活动读数,但你希望能预测至少五分钟前的癫痫发作,以尽可能提前警告用户/患者。在这种情况下,你有多种选择生成多步预测。

直接适应感兴趣的地平线。

这就像将你的y(目标)值设置为反映感兴趣的预测视角一样简单。因此,如果你的数据由分钟指标组成,但你希望对你的预测进行五分钟的前瞻性分析,你将在时间t截止,并将其训练为使用时间t + 5 生成的标签。然后,你会将这些数据拟合到你试图预测的数据上,无论是通过简单线性回归、机器学习模型甚至是深度学习网络。这实际上可以作为:

m o d e l ( X ) = Y

您可以选择Y具有您想要的任何时间范围。因此,每个都可能是一个合法的场景,具体取决于您的前瞻性兴趣(无论是 10 步还是此处所示的 3 步):

  • m o d e l 1 ( X t ) is fit to Y t+10

  • m o d e l 2 ( X t ) is fit to Y t+3

递归方法处理遥远时间视角

使用递归方法适应各种视角,您可以构建一个模型,但准备将其输出反馈作为输入以预测更遥远的视角。这个想法应该很熟悉,因为我们展示了如何使用这种策略利用 ARIMA 建模进行多步预测。假设我们开发了一个模型,以一步为目标,通过训练 m o d e l ( X t ) = Y t+1 。如果我们希望适应三步的视角,我们将执行以下操作:

  • m o d e l ( X t ) e s t i m a t e Y t+1

  • m o d e l ( X t with estimate of Y t+1 ) estimate of Y t+2

  • m o d e l ( X t with estimate of Y t+1 and estimate of Y t+2 ) estimate of Y t+3

我们对Y[t+3]的估计误差预期必然大于我们对Y[t+1]的估计误差。大多少?这可能很复杂。获取感觉的一个好选择是运行仿真,正如本章早些时候讨论的。

应用于时间序列的多任务学习

多任务学习是深度学习中的一个通用概念,可以应用于时间序列分析的特定含义。更一般地说,多任务学习描述了一个模型可以同时服务多个目的或通过尝试预测几个不同但相关目标来学习泛化的想法。一些人认为这是一种正则化的手段,通过教导相关任务来鼓励模型更加通用。

在时间序列上下文中,您可以通过在预测环境中为不同的时间视角设置目标来应用多任务学习。在这种情况下,适配您的模型可能看起来像这样:

  • m o d e l ( X t ) = ( Y t+1 , Y t+10 , Y t+100 )

  • m o d e l ( X t ) = ( Y t+1 , Y t+2 , Y t+3 )

在训练这样一个模型时,您还可以考虑如何查看损失函数:您是否希望平等加权所有预测,还是您希望优先考虑某些预测视角而不是其他预测视角?

如果您寻求进行非常遥远的预测,您可以使用多任务视角通过包括短期视角来教导您的模型,这些短期视角可能指向远期预测的显著特征,但在远期预测的低信噪比数据中直接找出却很困难。多任务建模的另一个场景可能是在未来的几个时间窗口中适配,所有这些时间窗口处于同一季节,但可能是不同的时间点(例如,几年内的春季或几周内的星期一)。这将是同时适应季节性和趋势的一种方式。

模型验证的一些小问题

在考虑是否已经适当测试您的模型与预期生产实现相比时,需要考虑的最重要问题如下:

前瞻性

本书始终强调前瞻性,因为难以避免,而当模型投入生产时,这可能会带来灾难性和尴尬的后果。尽可能地避免使模型在生产中突然停止验证是很重要的。这是在您的系统中没有意识到前瞻性的标志。不要让这种情况发生在您身上!

结构性变化

识别结构性变化在某种程度上是判断问题、主题问题和数据质量问题的综合体。时间序列底层的动态可能随时间变化,它们可以变化到足以使一个适合于时间序列一部分的模型不再适用于另一部分。这是探索性分析重要的原因之一——确保在结构性变化时不要训练相同的模型,这既不合理也不明智。

更多资源

Christoph Bergmeir、Rob J. Hyndman 和 Bonsoo Koo,《关于用于评估自回归时间序列预测的交叉验证有效性的说明》("A Note on the Validity of Cross-Validation for Evaluating Autoregressive Time Series Prediction"),计算统计与数据分析 120 (2018): 70–83,https://perma.cc/YS3J-6DMD。

本文的作者(包括值得尊敬且极富产量的 Rob Hyndman)讨论了即使对于时间序列分析,交叉验证也会产生良好结果的情况。这篇文章尤其有帮助,因为它提供了关于为什么交叉验证会产生误差以及在标准交叉验证分析中可以使用哪些模型而不是反向拟合的见解。

Angelo Canty,《时间序列的自举法》("tsboot: Bootstrapping of Time Series"),无日期,https://perma.cc/MQ77-U5HL。

这段描述位于boot包中的tsboot()函数,作为教育性阅读内容,因为它实现了多种被广泛接受的区块抽样作为一种自举法的变体。探索该函数中提供的选项是熟悉各种自举时间序列的好方法,而对该函数的熟悉则能够使您能够方便、可靠地估计测试统计量,并在数据集较小的情况下评估估计的不确定性。

Hans R. Kunsch,《一般平稳观察的半自助法与自助法》("The Jackknife and the Bootstrap for General Stationary Observations"),统计年鉴 17, no. 3 (1989): 1217–41,https://perma.cc/5B2T-XPBC。

这篇经典且广为引用的文章展示了一种统计方法,将 Jackknife(较少使用)和自助法方法扩展到时间序列数据,通过消除这些方法的一个假设,即数据点是独立分布的。这篇文章介绍了通过单位块而不是单个点的思考和抽样来处理时间序列。

Christian Kleiber, “(经济)时间序列的结构变化,” 复杂性与协同作用,斯特凡·穆勒等编。(瑞士,查姆: 斯普林格国际,2018),https://perma.cc/7U8N-T4RC.

本章总结了多种经典方法,用于识别时间序列结构性变化,并从商业和经济时间序列的角度进行了阐述,尽管这些教训更广泛地适用。它特别有用,因为它提供了可以用来应用这些广泛接受的结构变化识别方法的 R 包列表,并提供了有用的可视化,展示了在分析相同时间序列时,方法的行为有所不同。

Robert Stambaugh, “预测回归,” 《金融经济学杂志》 54 (1999): 375–421, https://perma.cc/YD7U-RXBM.

这篇广为引用的文章演示了如何对回归系数进行贝叶斯后验估计,以及在对金融时间序列模型的基础动态假设不同的情况下,有限样本量估计的数量可能会有显著差异。这是一个很好的例子,尽管领域特定,但它展示了起始假设对估计不确定性的影响有多大。

¹ 顺便说一下,对于某些时间段有或没有可用数据的建模,可以展示及时数据交付的重要性,这可以激励你的数据工程师和经理优先考虑特定的数据输入,如果你能证明这将对你的模型准确性和/或组织的底线有所影响。

² 请注意,您可以为您的损失函数编写自定义加权函数,以便更加重视最近的数据,但这需要以上平均水平的编程技能和关于数值优化的知识。

³ 一个简单的空模型可能出奇地难以超越。

⁴ 在某些情况下,可能没有任何东西可以为未来决策学习。

⁵ 如果误差对具体系数影响较大,那么误差拟合可能会成为问题。在这里,我们的模型误配影响不是太大,但在其他情况下,可能会是灾难性的。

第十二章:适合时间序列模型的性能考虑

在机器学习和统计分析的文献中,过于关注模型的准确性。虽然在评估模型时通常准确性应该是主要关注点,但有时在面对大数据集或广泛部署的模型以服务大量客户应用程序时,计算性能的考虑也非常重要。

时间序列数据集变得如此庞大,以至于分析根本无法完成,或者因为对可用计算资源的需求太大而无法进行适当的分析。在这种情况下,许多组织会如下处理他们的选择:

  • 增加计算资源(在经济和环境上既昂贵又常常是浪费的)。

  • 把项目做糟(不够的超参数调整,数据不足等)。

  • 不要做这个项目。¹

当您刚开始使用新数据集或新分析技术时,这些选项都不令人满意。不知道您的失败是由于数据质量差,问题过于复杂,还是资源不足,可能会令人沮丧。希望我们能找到一些解决方案,以扩展您在非常严苛的分析或非常大的数据集情况下的选择。

本章旨在指导您考虑如何减少训练或推断使用特定模型所需的计算资源。在很大程度上,这些问题是特定于给定数据集、您可用的资源以及您的准确性和速度目标的。您将在本章中详细讨论的关注点中看到这种现实,但希望它们部分地涵盖您遇到的问题,并能为进一步的头脑风暴提供灵感。这些是在您完成首轮分析和建模后才需要考虑的事项,但在将某事投入生产或将小型研究项目扩展为更大项目时,您应经常重新审视这些问题。

使用为更通用用例构建的工具

时间序列数据的一个挑战是,大多数工具,特别是用于机器学习的工具,都是为更通用的用例而构建的,大多数说明性示例展示了横截面数据的用法。这些机器学习方法因此无法像处理时间序列数据那样高效。你的具体问题的解决方案会有所不同,但一般的思路是相同的。在本节中,我将讨论常见问题和潜在解决方案。

为横截面数据构建的模型不会在样本之间“共享”数据

在许多情况下,当您将时间序列数据的离散样本输入算法时,最常见的是在机器学习模型中,您会注意到您在样本之间输入的大部分数据存在重叠。例如,假设您拥有以下月销售数据:

月份 销售的小部件数量
2014 年 1 月 11,221
2014 年 2 月 9,880
2014 年 3 月 14,423
2014 年 4 月 16,720
2014 年 5 月 17,347
2014 年 6 月 22,020
2014 年 7 月 21,340
2014 年 8 月 25,973
2014 年 9 月 11,210
2014 年 10 月 11,583
2014 年 11 月 12,014
2014 年 12 月 11,400
2015 年 1 月 11,539
2015 年 2 月 10,240

您正在通过将每个“形状”映射到最近的邻居曲线来进行预测。您从这些数据准备了许多形状。这里列出了其中一小部分数据点,因为您可能希望使用六个月的曲线作为感兴趣的“形状”(请注意,我们不会对数据进行任何预处理,例如归一化或创建额外的感兴趣特征,如移动平均或平滑曲线):²

11221, 9880, 14423, 16720, 17347, 22020
9880, 14423, 16720, 17347, 22020, 21340
14423, 16720, 17347, 22020, 21340, 25973

有趣的是,我们通过这些输入的准备工作,只是使我们的数据集扩大了六倍,而没有包含任何额外的信息。从性能的角度来看,这是一场灾难,但通常是各种机器学习模块的输入必需品。

如果您遇到这个问题,您应该考虑几个解决方案。

不要使用重叠的数据

考虑仅生成“数据点”,以便每个单独的月份只出现在一个曲线中。如果这样做,前面的数据可能看起来像以下表格:

11221, 9880, 14423, 16720, 17347, 22020
21340, 25973, 11210, 11583, 12014, 11400

请注意,这将特别简单,因为它只是一个简单的数组重塑,而不是数据的自定义重复。

采用类似生成器的范式来迭代数据集

使用类似生成器的范式来迭代数据集,根据需要从相同的数据结构重新采样,特别容易在 Python 中编码,但也可以在 R 和其他语言中完成。如果我们想象原始数据存储在一个 1D NumPy 数组中,这可能看起来像以下代码(请注意,这需要与接受生成器的机器学习数据结构或算法配对使用):

## python
>>> def array_to_ts(arr):
>>>   idx = 0
>>>   while idx + 6 <= arr.shape[0]:
>>>      yield arr[idx:(idx+6)]

注意,设计不会不必要地扩大数据集的数据建模代码,从培训和生产的角度来看都是可取的。在培训中,这将允许你在内存中拟合更多的训练示例,在生产中,你可以使用更少的训练资源进行多次预测(或分类)在重叠数据上。如果你为同一用例频繁进行预测,你可能会处理重叠数据,因此这个问题及其解决方案将非常相关。

不预先计算的模型会在测量数据和进行预测之间造成不必要的延迟

通常情况下,机器学习模型不会准备或考虑提前计算部分结果,而不是等到所有数据都准备好。然而对于时间序列来说,这是一个非常常见的场景。

如果你正在为医学预测、车辆位置估计或股票价格预测等对时间敏感的应用程序提供模型服务,你可能会发现在所有数据可用后才计算预测的延迟时间过长。在这种情况下,你应该考虑你选择的模型是否实际上可以部分提前计算。以下是一些可能性的示例:

  • 如果你正在使用一个递归神经网络,在超过 100 个不同时间步的多个信道中传递信息,你可以预先计算/展开神经网络的前 99 个时间步。然后,当最后一个数据点最终到来时,你只需进行一次最终的矩阵乘法(和其他激活函数计算),而不是 100 次。理论上,这可以使你的响应时间加快 100 倍。

  • 如果你正在使用 AR(5) 模型,你可以预先计算除了构成模型的和的最近项之外的所有项。作为提醒,AR(5) 过程看起来像下面的方程。如果你要输出预测,这意味着你已经知道 y[t – 4]、y[t–3]、y[t–2] 和 y[t–1] 的值,这意味着你可以在知道 y[t] 之前提前准备好除了 p h i 0 Ã y t 外的一切:

    y t+1 = p h i 4 × y t-4 + p h i 3 × y t-3 + p h i 2 × y t-2 + p h i 1 × y t-1 + p h i 0 × y t

  • 如果你正在使用聚类模型来通过总结时间序列的特征(均值、标准差、最大值、最小值等)来找到最近的邻居,你可以用一个数据点较少的时间序列计算这些特征,并用该时间序列运行你的模型来识别几个最近的邻居。然后,当最终值到来时更新这些特征,并重新运行在第一轮分析中找到的最近邻的分析。这实际上会在总体上需要更多的计算资源,但会导致在采取最终测量和提供预测之间的延迟时间减少。

在许多情况下,您的模型可能并不像网络延迟或其他因素那样缓慢,因此预计算只有在反馈时间极为重要并且您确信模型计算实质上在应用程序接收所有所需信息并输出有用预测之间的时间中有重大贡献时才是一种值得的技术。

数据存储格式:优缺点

在训练和生产化时间序列模型的性能瓶颈中,一个被忽视的领域是数据存储的方法。一些常见的错误包括:

  • 即使时间序列是通过遍历列来形成的,也要将数据存储在基于行的数据格式中。这将导致时间相邻的点在内存中不是相邻的。

  • 存储原始数据并从这些数据运行分析。对于给定的模型,预处理和降采样的数据优先。

接下来我们讨论这些数据存储因素,以尽可能保持模型训练和推断的速度。

将数据存储为二进制格式

很容易将数据存储在逗号分隔的文本文件中,比如 CSV 文件。这通常是数据的提供方式,所以惯性驱使我们选择这种方式。这些文件格式也是人类可读的,这使得可以轻松地检查文件中的数据与流水线输出的一致性。最后,这种数据通常易于在不同平台之间传输。³

但是,对于您的计算机来说并不容易读取文本文件。如果您处理的数据集非常大,以至于在训练过程中无法将所有数据放入内存中,您将需要处理与您选择的文件格式相关的 I/O 和相关处理。通过将数据存储为二进制格式,您可以通过多种方式大大减少与 I/O 相关的减速:

  • 因为数据以二进制格式存储,您的数据处理包已经“理解”它。无需读取 CSV 并将其转换为数据框架。当您输入数据时,您将已经拥有一个数据框架。

  • 因为数据以二进制格式存储,它可以比 CSV 或其他基于文本的文件压缩得更多。这意味着 I/O 本身将更短,因为要读取一个文件所需的物理内存较少,以便重新创建其内容。

二进制存储格式在 R 和 Python 中都很容易访问。在 R 中,使用save()load()来处理data.table。在 Python 中,使用 pickling,并注意 Pandas(pd.DataFrame.load()pd.DataFrame.save())和 NumPy(np.load()np.save())都包含了围绕 pickling 的包装器,您可以用来处理它们特定的对象。

以允许您“滑动”处理数据的方式预处理数据

此建议与“为横截面数据构建的模型不会在样本间‘共享’数据”相关。在这种情况下,您还应考虑如何预处理数据,并确保您的做法与使用移动窗口在数据上生成多个测试样本的方式一致。

作为示例,考虑归一化或移动平均作为预处理步骤。如果您计划针对每个时间窗口执行这些操作,则可能会提高模型的准确性(尽管根据我的经验,这种增益通常不大)。但是,存在几个缺点:

  • 您需要更多计算资源来多次在重叠数据上计算这些预处理特征,最终得到非常相似的数字。

  • 您需要多次存储具有稍有不同预处理的重叠数据。

  • 您无法充分利用滑动窗口在数据上的滑动优势。

修改您的分析以适应性能考虑

我们许多人都有使用特定分析工具集的习惯,以及关于如何拟合模型的软件套件和经验法则。我们也倾向于在一次评估准确性需求后就不再重新评估它们,尽管我们确定了各种可能的模型性能的计算成本。

时间序列数据通常用于快速预测,特别容易需要能够快速拟合和生产化的模型。模型需要快速拟合,以便可以用新数据进行更新,同时需要快速执行,以便模型预测的消费者有尽可能多的时间来采取行动。因此,您有时可能希望修改您的期望值及相关分析,以实现更快速和更简化计算的过程用于分析和预测。

使用所有您的数据并非总是更好

思考如何简化分析的一个重要因素是要理解时间序列中并非所有数据都同等重要。更遥远的数据较不重要。在“异常”时期的数据对建立普通时期模型较不重要。

有多种方式可以考虑减少用于训练模型的数据量。虽然这本书前面已经讨论了许多这些选项,但仍然有必要进行复习,特别是关于性能方面:

降采样

通常情况下,您可以使用较少频率的数据来覆盖相同的回溯窗口进行预测。这是通过乘法因子缩小数据的一种方法。请注意,根据您使用的分析技术,您还有更多创造性的选项,例如根据数据远近以不同速率进行降采样。

仅训练最近的数据

尽管机器学习喜欢数据,但在许多时间序列模型中,仅专注于最近数据而不是对所有数据进行训练,统计或甚至深度学习技术实际上会更好。这将帮助您通过剔除对模型而言信息量较小的数据来减少输入数据。

缩小用于预测的回顾窗口

在许多时间序列模型中,随着你向过去看得越久,模型的性能可能会继续提高,即使只有稍微。你应该对实际需要的准确性与性能做出一些决策。也许每个样本加载到内存中的数据量远远超出了实现可接受性能所需的数据量。

复杂的模型并不总是表现得更好

在选择分析模型时尝试最新和最好的可能会很有趣和有趣。然而,真正的问题是,是否更复杂的模型需要额外的计算资源来“支付”。

近年来机器学习中几乎所有的计算进步都来自于对问题投入更多计算资源。对于像图像识别这样确实存在正确答案且需要 100%准确性的问题,这当然是合理的选择。

另一方面,对于像时间序列预测这样的问题,可能存在物理或数学限制,决定选择更复杂的模型不应该只是自动升级而没有成本效益分析。考虑准确性增益是否足以证明模型可能在计算预测时产生的额外延迟,或者所需的额外训练时间,或者将启动的额外计算资源。也许一个资源消耗较少但准确性稍差的方法比一个几乎与简单版本无异的花哨模型更好。

如果你是数据分析师,这种复杂性/准确性和延迟时间/计算资源之间的权衡是你应该分析的内容,把它看作另一个需要调整的超参数。你的工作是指出这些权衡,而不是假设数据工程师会处理这些问题。数据管道中上下游的人员不能替代你在模型选择上的判断,因此在权衡利弊时你必须考虑到数据科学的工程方面。

简要提及备选高性能工具

如果你已经全面探索了前述选项,你还可以考虑改变你的基础代码库,更具体地说是摆脱像 Python 和 R 这样的较慢脚本语言。有几种方法可以实现这一点:

  • 全面采用 C++ 和 Java。即使你以前没有接触过这些语言,仅仅学习基础知识有时也足以加速管道中的慢部分,将不可能的任务转变为可以管理的任务。特别是 C++ 在可用性和适用于数据处理的标准库方面已经有了巨大的进展。STL 和 C++ 17 语法现在提供了许多与 Python 在各种数据结构上操作相当的选项。即使你多年前讨厌 C++ 和 Java,也应该重新审视它们。⁴

  • 在 Python 中,你可以使用几个不同的模块,其中可以编写 Python 代码并编译为 C 或 C++ 代码,加快执行时间。这对于具有许多for循环的非常重复的代码特别有用,在 Python 中这些循环很慢,在 C 或 C++ 中可以更加高效,而无需进行巧妙的设计 —— 只需在更快的语言中实现相同的代码即可解决问题。NumbaCython 都是可以帮助你通过这种方式加速 Python 代码的可访问的 Python 模块。

  • 同样,在 R 中,你可以使用 Rcpp 来实现类似的功能。

更多资源

  • 在模型表现相等的情况下:

    Anthony Bagnall 等人,“时间序列分类大比拼: 最近提出的算法的实验评估,” 数据挖掘与知识发现 31, no. 3 (2017): 606–60, https://perma.cc/T76B-M635。

    本文进行了大量的实验来评估现代时间序列分类方法的性能,在广泛的公开数据集上比较它们的表现。数据集的计算复杂性最终变化比尝试的方法实际表现更加显著。正如作者所强调的那样,确定在给定数据集上哪种方法最有效,不尝试所有方法仍然是一门艺术和研究领域。从计算资源的角度来看,这里需要学习的教训是计算复杂性应在方法决策中起重要作用。除非你对复杂且资源密集型算法有非常具有说服力的用例,否则选择一些更简单的东西。

  • 在构建简单模型方面:

    Yoon Kim 和 Alexander M. Rush,“序列级知识蒸馏,” 收录于《2016 年经验方法在自然语言处理会议论文集》, 编辑 Jian Su, Kevin Duh, 和 Xavier Carreras (Austin, TX: Association for Computational Linguistics, 2016), 1317–27, https://perma.cc/V4U6-EJNU。

    本文将“蒸馏”的一般概念应用于序列学习,适用于机器翻译任务。蒸馏的概念是一个广泛有用的概念。其核心思想是首先设计并训练一个复杂模型,然后在复杂模型的输出上训练一个简化模型。通过利用复杂模型的输出而不是数据本身,可以减少噪音并简化学习问题,使得简化模型能够通过去除噪音来学习大致相同的关系。虽然这种技术不会减少训练时间,但应该能够产生一个在生产中执行速度更快、资源需求更少的模型。

¹ 是的,在现实世界中这种情况经常发生。

² 我们在早期章节讨论的机器学习和深度学习模型案例中,从一个大的时间序列中提取了许多时间序列样本。

³ 尽管存在与不同平台和设备相关的 Unicode 问题,因此仅仅因为使用了基于文本的文件格式,并不代表你可以轻松解决问题。

⁴ 学习曲线陡峭,但一旦你掌握了基本的编译基础设施,这对你的组织将是一个巨大的优势。

第十三章:医疗应用

在本章中,我们将在医疗背景下进行时间序列分析,涉及两个案例研究:流感预测和即时预测以及血糖预测。 >这两者都是常见健康问题的重要医疗应用。此外,在这两种情况下,这些都不是解决的问题,而是学术界和医疗行业正在研究的课题。

预测流感

在给定地理区域内从一周到另一周预测流感率是一个长期而持续的问题。传染病专家和全球安全专业人员一致认为,传染病对人类福祉构成重大风险。对于流感来说尤为如此,它在全球范围内侵袭弱势群体,每年造成数百人死亡,大多数是婴幼儿和老年人。从健康和国家安全的角度来看,开发流感在给定季节中的准确模型至关重要。流感预测模型不仅有助于具体预测病毒,还帮助研究人员探索传染病地理传播的一般理论。

一个大都市地区流感的案例研究

我们将研究 2004 年至 2013 年法国各行政区域每周流感报告的数据集。我们将预测巴黎大都会地区Île de France 的流感率。可以从Kaggle¹下载数据,也可以在本书的代码库中找到。

数据探索和一些清理工作

我们首先要熟悉原始数据,首先以表格形式进行检查:

## R
> flu = fread("train.csv")
> flu[, flu.rate := as.numeric(TauxGrippe)] 
> head(flu)
     Id   week region_code     region_name TauxGrippe flu.rate
1: 3235 201352          42          ALSACE          7        7
2: 3236 201352          72       AQUITAINE          0        0
3: 3237 201352          83        AUVERGNE         88       88
4: 3238 201352          25 BASSE-NORMANDIE         15       15
5: 3239 201352          26       BOURGOGNE          0        0
6: 3240 201352          53        BRETAGNE         67       67

我们还进行一些基本的质量检查,比如在我们感兴趣的变量中查找NA。我们可能不知道这些NA值来自哪里,但我们需要对其进行处理:

## R
> nrow(flu[is.na(flu.rate)]) / nrow(flu)
[1] 0.01393243
> unique(flu[is.na(flu.rate)]$region_name)
 [1] "HAUTE-NORMANDIE"      "NORD-PAS-DE-CALAIS"   "PICARDIE"            
 [4] "LIMOUSIN"             "FRANCHE-COMTE"        "CENTRE"              
 [7] "AUVERGNE"             "BASSE-NORMANDIE"      "BOURGOGNE"           
[10] "CHAMPAGNE-ARDENNE"    "LANGUEDOC-ROUSSILLON" "PAYS-DE-LA-LOIRE"    
[13] "CORSE"     

整体NA数据点的比率并不是很高。此外,我们感兴趣的Île-de-France 地区不在NA值列表中。

我们进行一些数据清理工作,将时间戳列的周和年份部分分开(目前是字符格式,而不是数值或时间戳格式):

## R
> flu[, year := as.numeric(substr(week, 1, 4))]
> flu[, wk   := as.numeric(substr(week, 5, 6))] 
> ## style note it's not great we have 2 week columns

我们添加了一个Date类列,以便在时间绘图中有更好的绘图轴,而不是将数据视为非时间戳形式:

## R
> flu[, date:= as.Date(paste0(as.character(flu$week), "1"), "%Y%U%u")]

这行代码稍微有些复杂。为了将月份-周数组合转换为日期,我们添加了一个表示天的组件。这就是paste0()的目的,它将每个日期标记为周的第一天,将一个"1"粘贴到已经指定了年份和周数的字符串中(一年 52 周——稍后详述)。² 注意格式字符串中的%U%u:这些与根据年份周数和星期几标记时间有关,这是一种稍显不同的时间戳格式。³

然后我们将数据子集化以涉及具体巴黎的数据,并按日期排序:⁴

## R
## let's focus on Paris
> paris.flu = flu[region_name == "ILE-DE-FRANCE"]
> paris.flu = paris.flu[order(date, decreasing = FALSE)]

> paris.flu[, .(week, date, flu.rate)]
       week       date flu.rate
  1: 200401 2004-01-05       66
  2: 200402 2004-01-12       74
  3: 200403 2004-01-19       88
  4: 200404 2004-01-26       26
  5: 200405 2004-02-02       17
 ---                           
518: 201350 2013-12-16       13
519: 201351 2013-12-23       49
520: 201352 2013-12-30       24
521: 200953       <NA>      145
522: 200453       <NA>       56

如果你注意到,行数可能会让你感到惊讶。如果一年有 52 周,我们有 10 年的数据,为什么我们有 522 行?我们本来预期是 52 周 × 10 年 = 520 行。类似地,为什么有两个NA日期?如果您返回原始数据,您将看到解释。似乎 2004 年和 2009 年都有第 53 周。每隔几年,一年有 53 周而不是 52 周——这不是错误,而是格里高利历系统的一部分。

然后我们检查数据是否覆盖了一个完整且定期采样的日期范围,首先确保每年具有相同数量的数据点:⁵

## R
> paris.flu[, .N, year]
    year  N
 1: 2004 53
 2: 2005 52
...
 9: 2012 52
10: 2013 52

> paris.flu[, .N, wk]
    wk  N
 1:  1 10
 2:  2 10
 3:  3 10
...
51: 51 10
52: 52 10
53: 53  2
    wk  N

我们可以看到数据符合预期;即每年(除了刚刚讨论过的两年)有 52 周,并且每个年度周标签有 10 个数据点,除了第 53 周。

现在我们已经考虑了数据的时间戳,我们要检查时间序列的实际值(到目前为止我们只考虑了时间索引)。有趋势吗?季节性?让我们找出来(见图 13-1):

## R
> paris.flu[, plot(date, flu.rate, 
>                  type = "l", xlab = "Date",
>                  ylab = "Flu rate")]

从简单的折线图可以明显看出有明显的季节性(这是您可能在您自己的社区中经历过的)。这个图表表明有强烈的季节性成分,但并不表明除了季节性外有时间漂移。

季节性行为使得第 53 周变得复杂。如果我们想要拟合一个季节性模型,我们需要根据年的周来定义季节性,而不能有可变的季节性大小(这是季节与周期不同的地方,如第三章所讨论的)。虽然我们可以想象一些创造性的解决方案来解决第 53 周的问题,但我们将采取简单的方式来抑制这些数据:

## R
> paris.flu <- paris.flu[week != 53]

图 13-1. 通过绘制流感率的时间序列,我们可以看出巴黎的流感率的季节性。

是否删除数据点对于数据集和我们提出的问题是否是一个重要问题。我把它留给读者来探索在保留第 53 周数据的同时拟合数据的其他可能性。有多种选择可以做到这一点。其中一种是通过对两周取平均数将第 53 周数据合并到第 52 周数据中。另一种方法是使用一个可以考虑周期行为而无需锁定于每年完全相同长度周期的模型。第三个选项是,机器学习模型也许可以通过对数据进行创造性标记以指示季节性作为输入特征来适应这种情况。

拟合季节性 ARIMA 模型

首先考虑对数据拟合季节性 ARIMA 模型,因为存在强烈的季节性。在这种情况下,数据的周期性为 52,因为数据是每周抽样的。我们希望选择一个相当简洁的模型——即没有太多参数的模型——因为我们的时间序列有 520 个数据点,并不是特别长。

这个时间序列是我们如果过于依赖自动化可能会出错的一个很好的例子。例如,我们可能首先考虑是否对数据进行差分,因此我们可能会考虑流感率的自相关图以及流感率差分时间序列的自相关图,分别显示在图 13-2 中:

## R
> acf(paris.flu$flu.rate,         )
> acf(diff(paris.flu$flu.rate, 52))

图 13-2. 巴黎流感率及其差分流感率的自相关函数图。我们只考虑了有限范围的滞后值。

如果我们粗心大意,可能会因为一次差分而自我祝贺,认为解决了这个时间序列的平稳性问题。但这完全没有任何意义。这是周数据,我们观察到了强烈的季节性。为什么我们在自相关图中看不到它呢?我们使用了acf()函数的默认参数,这在滞后空间中不能足够远地展示季节效应,我们从第 52 个滞后开始(一年)。让我们用一个充分的窗口重新运行acf()(参见图 13-3):

## R
> acf(paris.flu$flu.rate,           lag.max = 104)
> acf(diff(paris.flu$flu.rate, 52), lag.max = 104)

图 13-3. 巴黎流感率及其差分流感率的自相关函数图。现在我们考虑了更广泛范围的滞后值。

这给了我们一个更真实的系列自相关图。正如我们所看到的,各种滞后值都有显著的自相关,这是有道理的(至少根据我在四季气候中的经验)。流感率与其测量时间附近的周有很强的相关性。

流感率也会有很强的相关性,与时间间隔约为 52 或 104 的时间段具有季节性相关性。但流感率还与中间值的时间段(例如半年,即 26 周)具有相当强的关系,因为这些滞后也涉及到季节性差异和可预测的天气变化。例如,我们知道半年时间内,流感值可能会发生相当大的变化。如果之前很高,现在应该很低,反之亦然,这也是由于季节性。所有这些都在图 13-3 的上图中显示。

然后,我们检查差分后的系列,如图 13-3 中的下图所示。现在我们看到大量时间序列的自相关已经减少。然而,仍然存在相当多的自相关,不仅在 52 或 104 周(一年或两年)处,而且在中间值处也有。

虽然我们可能会试图继续差分,但我们需要记住,真实世界的数据永远不会完美地适合一个 SARIMA 模型。相反,我们寻求模拟数据的最合理方式。我们可以考虑再次进行季节性差分或采用不同的策略并在线性时间上进行差分。我们在这里绘制了每一种可能性(参见图 13-4):

## R
> plot(diff(diff(paris.flu$flu.rate, 52), 52))
> plot(diff(diff(paris.flu$flu.rate, 52), 1))

图 13-4. 我们选择的两个版本进行序列差分以了解数据的季节行为。

虽然两种结果都不理想,但后者——季节性差分的标准第一次差分更为令人满意。

选择拟合或参数选择是一种判断调用,正如应用测试一样。在这里,我们选择给季节性明显的权重,但也不要过于复杂化模型或使其不透明。因此,在 SARIMA (p, d, q) (P, D, Q)模型中,我们将使用 d = 1 和 D = 1 进行拟合。然后我们选择我们的 AR 和 MA 参数作为标准的 ARIMA 参数,pq。我们通过标准的可视化方法进行这些选择,使用以下代码(参见图 13-5):

## R
> par(mfrow = c(2, 1))
> acf (diff(diff(paris.flu$flu.rate, 52), 1), lag.max = 104)
> pacf(diff(diff(paris.flu$flu.rate, 52), 1), lag.max = 104)

图 13-5. 我们选择的差分序列的偏自相关函数图。

我们有限的数据集并且在选择更简单的模型方面犯了错误。PACF 模型表明 AR(2)模型可能是合适的,因此我们将我们的数据以 SARIMA (2, 1, 0), (0, 1, 0)的形式简洁地建模。

我们对于了解这个模型如何在连续拟合新数据时运行感兴趣,这种方式是大多数为真实世界系统构建的模型所采用的。也就是说,如果我们从几年前开始对流感进行建模,每周只使用到那个时间点为止的数据,我们的模型将如何运行?我们通过按照以下方式推进模型的拟合和评估来回答这个问题:

## R
> ## arima fit
> ## let's estimate 2 weeks ahead
> first.fit.size <- 104
> h              <- 2
> n              <- nrow(paris.flu) - h - first.fit.size
> 
> ## get standard dimensions for fits we'll produce
> ## and related info, such as coefs
> first.fit <- arima(paris.flu$flu.rate[1:first.fit.size], order = c(2, 1, 0),
>                    seasonal = list(order = c(0,1,0), period = 52))
> first.order <- arimaorder(first.fit)
> 
> ## pre-allocate space to store our predictions and coefficients
> fit.preds <- array(0, dim = c(n, h))
> fit.coefs <- array(0, dim = c(n, length(first.fit$coef)))
> 
> ## after initial fit, we roll fit forward
> ## one week at a time, each time refitting the model
> ## and saving both the new coefs and the new forecast
> ## caution! this loop takes a while to run
> for (i in (first.fit.size + 1):(nrow(paris.flu) - h)) {
>   ## predict for an increasingly large window
>   data.to.fit = paris.flu[1:i]
>   fit = arima(data.to.fit$flu.rate, order = first.order[1:3],
>               seasonal = first.order[4:6])
>   fit.preds[i - first.fit.size, ] <- forecast(fit, h = 2)$mean
>   fit.coefs[i - first.fit.size, ] <- fit$coef
> }

然后我们绘制这些滚动结果(参见图 13-6):

## R
> ylim <- range(paris.flu$flu.rate[300:400],
>                  fit.preds[, h][(300-h):(400-h)])
> par(mfrow = c(1, 1))
> plot(paris.flu$date[300:400], paris.flu$flu.rate[300:400],
>      ylim = ylim, cex = 0.8,
> main = "Actual and predicted flu with SARIMA (2, 1, 0), (0, 1, 0)",
> xlab = "Date", ylab = "Flu rate")
> lines(paris.flu$date[300:400], fit.preds[, h][(300-h):(400-h)],
>       col = 2, type = "l", 
>       lty = 2, lwd = 2)

该图展示了一个有用的预测,但也突显了该模型的一些局限性。这个模型不够现实,有时会预测出负的流感率,这表明 ARIMA 模型本身没有固有的东西来强制施加诸如流感率必须为非负的约束条件。在拟合模型之前,我们必须通过数据转换来实施这些物理约束。

此外,该模型对异常点的敏感度似乎超出我们的预期。2013 年初就是一个典型例子,模型多次严重高估了流感率。当涉及到分配关键的抗疾病资源时,这不是一个可接受的模型。

最后,该模型在峰值处产生的极端值比任何一年内测量到的值都要高。这可能导致资源分配过多,超过实际需要,这不是一个好结果,特别是当资源高度受限时。这是一个关于模型的担忧,既根植于现实世界的资源限制,也根植于纯粹的数据分析。

图 13-6. 流感率(点)与我们的 SARIMA 预测(虚线)配对。这个简单模型的预测可以帮助公共卫生规划。

现在我们已经考虑了基本 ARIMA 模型对这个问题的拟合情况,我们将看看其他建模可能性。

替代 ARIMA 模型:外生谐波回归器取代季节性

考虑到前一节讨论的 SARIMA 模型的表现,我们可以进行一些修改。在这里,我们考虑两种修改,每种修改都是独立的,可以分别应用。⁶

首先,我们希望在我们的模型中建立约束条件,以防止预测负值。一种方法是对数据进行对数转换,这样我们预测的是时间序列值的对数,而不是值本身。然后,当我们想要看到“真实”的系列,表示实际测量到的数字时,我们将使用指数变换来撤消对数变换,并得到它们的实际单位预测值。

其次,我们希望找到一种更透明的方法来处理数据的季节性。虽然我们将我们的季节性 ARIMA 模型描述为简单的,但实际上,处理 52 周季节性重复的模型并不简单。季节性 ARIMA 模型在较短季节周期上表现更好,在较长季节周期(52 周是一个长季节周期)上表现不佳。

在这里我们将使用动态谐波回归。在这种方法中,我们找到描述数据周期性的傅立叶级数,然后将该级数用作与 ARIMA 项一起拟合的外生回归器。⁷ 由于我们可以在时间上向前外推傅立叶级数(由于其纯粹周期性的性质),在生成预测时,我们还可以预先计算未来预期的值。

该模型的优势在于,模型的自由度可以用来解释基础行为,而不是将大量解释力量用于季节行为。

动态谐波回归有一些缺点。首先,我们假设行为非常规律,并在完全相同的间隔内重复。其次,我们假设季节性行为不会改变;也就是说,季节性行为的周期和振幅不会改变。这些限制与 SARIMA 模型类似,尽管 SARIMA 模型在振幅如何影响数据随时间变化方面更具灵活性。

在这里,我们展示了如何执行类似于之前使用的 R 代码的动态谐波回归:

## R
> ## preallocate vectors to hold coefs and fits
> fit.preds       <- array(0, dim = c(n, h))
> fit.coefs       <- array(0, dim = c(n, 100))
> 
> ## exogenous regressors
> ## that is components of Fourier series fit to data
> flu.ts          <-  ts(log(paris.flu$flu.rate + 1) + 0.0001, 
>                        frequency = 52) 
> ## add small offsets because small/0 vals
> ## cause numerical problems in fitting
> exog.regressors <- fourier(flu.ts, K = 2)
> exog.colnames   <- colnames(exog.regressors)
> 
> ## fit model anew each week with
> ## expanding window of training data
> for (i in (first.fit.size + 1):(nrow(paris.flu) - h)) {
>   data.to.fit       <- ts(flu.ts[1:i], frequency = 52)
>   exogs.for.fit     <- exog.regressors[1:i,]
>   exogs.for.predict <- exog.regressors[(i + 1):(i + h),]
>   
>   fit <- auto.arima(data.to.fit, 
>                     xreg = exogs.for.fit,
>                     seasonal = FALSE)
>   
>   fit.preds[i - first.fit.size, ] <- forecast(fit, h = h, 
>                                xreg = exogs.for.predict)$mean
>   fit.coefs[i - first.fit.size, 1:length(fit$coef)] = fit$coef
> }

在这里,我们对上一节中的代码进行了一些调整。首先,我们使用了一个 ts 对象。⁸ 使用 ts 对象时,我们在创建 ts 对象时明确指定了时间序列的季节性(52 周)。

此时我们也对数据进行对数变换,以确保对我们最终感兴趣的值,即流感率,进行正面预测:

## R
> flu.ts = ts(log(paris.flu$flu.rate + 1) + 0.0001, ## add epsilon
>                       frequency = 52) 

我们还添加了一个小的数值偏移(+ 0.0001),因为数值拟合不太适合严格的零值或者非常小的值。我们的两个调整之一已经通过这行代码实现了(即对数变换以强制非负值的物理条件)。

接下来我们生成外生谐波回归器(即傅里叶逼近),以替代 SARIMA 中的季节性参数。我们通过 forecast 包的 fourier() 函数来实现这一点:

## R
> exog.regressors <- fourier(flu.ts, K = 2)
> exog.colnames   <- colnames(exog.regressors)

首先,我们生成了伴随的谐波序列,覆盖整个时间序列,然后根据需要将其子集化,以适应后续的循环滚动拟合。

超参数 K 表示我们将包括多少个单独的正弦/余弦对来拟合我们的模型,其中每个对代表一个新的用于拟合正弦/余弦的频率。一般来说,K 对于更长的季节周期长度会更大,对于较短的季节周期长度会更小。在更详细的示例中,我们可以考虑如何使用信息准则来调整 K,但在本例中我们使用 K = 2 作为一个合理的模型。

最后,我们所需做的就是生成新的拟合,考虑到我们刚刚拟合的外生 Fourier 组件。我们进行拟合如下,其中 xreg 参数将拟合的 Fourier 级数作为附加的回归器,这些回归器与标准的 ARIMA 参数一起拟合:

## R
> fit <- auto.arima(data.to.fit, 
>                   xreg     = exogs.for.fit,
>                   seasonal = FALSE)

我们将 seasonal 参数设置为 FALSE,以确保我们不会在这种情况下具有多余的季节性参数。

当我们生成预测时,我们还需要包括回归器,这意味着我们需要指示预测目标时回归器的内容:

## R
> fit.preds[i - first.fit.size, ] <- forecast(fit, h = 2h, 
>                                xreg = exogs.for.predict)$mean

我们绘制该模型的性能,如下所示(参见图 13-7):

## R
> ylim = range(paris.flu$flu.rate)
> plot(paris.flu$date[300:400], paris.flu$flu.rate[300:400],
>      ylim = ylim, cex = 0.8, 
> main = "Actual and predicted flu with ARIMA + 
> harmonic regressors",
> xlab = "Date", ylab = "Flu rate")
> lines(paris.flu$date[300:400], exp(fit.preds[, h][(300-h):(400-h)]),
>       col = 2, type = 'l', 
> lty = 2, lwd = 2)

正面的一点是,我们预测的系列不再出现负值。然而,模型性能中有许多令人失望的方面。最明显的是,许多预测相差甚远。峰值的幅度完全错误,并且出现时间也错误。

图 13-7. 显示实际流感率(点)与我们的 ARIMA + 动态谐波回归模型预测(虚线)的图表。

一个解释问题的原因是,规律性的季节性并不是流感的季节性的一个好描述。根据疾病控制和预防中心(CDC)的说法,在美国,流感可能在每年冬季的 12 月至 3 月间出现高峰。我们可以在测试数据中看到这一点。考虑下面的代码和图 13-8 中的图,这些识别了数据测试范围内的峰值:

## R
> plot(test$flu.rate)
> which(test$flu.rate > 400)
Error in which(res$flu.rate > 400) : object 'res' not found
> which(test$flu.rate > 400)
[1]   1  59 108 109 110 111 112 113
> abline(v = 59)
> which.max(test$flu.rate)
[1] 109
> abline(v = 109)

图 13-8. 仅显示流感测试值及其明显峰值位置的图。

峰值出现在索引 59 和 109 处,相距 50 周(而不是 52 周)。此外,出现在索引 59 处的峰值至少比上一个峰值晚了 59 周,而且可能更久,因为我们的图中没有完整展示索引=0 周围的峰值情况。

在一个案例中,样本峰值间距超过 59 周,在另一个案例中为 50 周,我们可以看到年度间存在相当大的变异性。我们的动态谐波回归没有考虑到这一点,它比 SARIMA 模型具有更严格的季节性模型,因为后者的季节性行为会随着时间变化。假设与数据之间的这种不匹配可能在很大程度上解释了这个模型的性能不佳,使用一个不好的模型实际上引起了我们对数据一个重要特征的注意,这是我们之前没有注意到的。

尽管性能不佳,这个替代模型在几个方面已经证明是有用的。它向我们展示了将系统的物理限制纳入数据预处理的价值,例如通过对数转换。它还向我们展示了尝试在每个时间点选择带有模型背后理论的几类模型的价值。我们选择了这个模型来简化我们的季节性,但我们发现,很可能,无论是 SARIMA 还是动态谐波回归季节性模型都不是这个系统的很好选择。

流感预测的技术水平是什么?

我们刚刚探讨的模型在进行巴黎大都会区流感率的短期预测或现场预测方面相对简单但相当有效。这些模型是开始了解数据集并认识到在嘈杂但复杂的系统中什么东西可能具有预测能力的良好起点,例如在特定地理区域的流感中。

将我们简单的方法与目前政府机构部署的一些尖端方法或最近学者们发表的讨论当前技术水平的方法进行比较是很有趣的。接下来,我将简要概述当前流感预测的工作情况以及研究人员近年来发展的试图改进传统模型的新方法。

流感预测研究

疾病控制与预防中心(CDC)积极鼓励研究人员从事流感预测研究,甚至赞助一个R 包来使其数据更容易获取。五年多来,CDC 还主办了流感预测竞赛,尽管直到 2017-2018 流感季节才将流感预测正式纳入其官方通讯和公告中。来自卡内基梅隆大学的 Delphi 研究小组迄今赢得了大多数竞赛,并使用他们描述的三种不同方法预测流感:

  • 一种经验贝叶斯方法,将过去的流感季节数据应用到一系列操作中,形成当前季节数据的先验,基于过去流感季节时间序列的总体“形状”。

  • 一个众包平台,任何人都可以提交对流感率的预测。

  • 一种现在预测方法,利用传感器融合,聚合来自多个来源的数据,例如维基百科访问计数和相关 Twitter 查询,以生成地理位置化的流感预测。

这是仅仅一个学术研究团队中使用的多种方法集合!如果我们进一步观察学术界,我们会看到更多的多样性:

  • 使用深度卷积网络(CNNs)对 Instagram 图片进行分类,并将这些 CNN 的输出与来自 Twitter 的文本相关特征一起作为各种机器学习模型(包括XGBoost树)的输入。一篇名为paper的论文因专注于小型语言社区(芬兰语使用者)而具有优势,这使得可以在一定程度上区域特定地使用主流社交媒体平台。

  • 识别大型社交媒体群集中可靠用户。一篇名为paper的论文专注于通过找到在社交媒体平台上位置最佳且最值得信赖的用户来改进流感预测。

  • 访问电子健康记录以获取更完整和补充的数据源,除了公开可用数据。一篇名为paper的论文表明,通过将电子健康记录整合到预测输入流中,可以在多个时间尺度上极大提高预测准确性。不幸的是,这很难安排,并且表明准确的流感预测能力将落入富有的数据持有者手中,而不是最有创造力的研究人员手中(尽管有时二者可能是同一个人)。

从解决这一问题的多样化方法可以看出,有许多途径可以进行流感预测,目前没有一个方法是绝对胜出的。尽管一些更好的策略已经被应用于政府使用和公共信息,但这仍然是一个积极的研究和发展领域。

我们在这里的讨论只是冰山一角。决定流感季节走向涉及大量的生物学、社会学、医学和经济政策,还有各种不同的模型,这些模型不仅仅是面向时间序列分析,还包括其他流感行为方面的导向。时间序列为这个非常复杂的主题提供了一个丰富的视角。

预测血糖水平

在健康应用的时间序列数据的机器学习研究的另一个活跃领域是预测个体患者的血糖水平。糖尿病患者自己经常进行这种预测,特别是如果他们有一定程度的疾病需要在餐时注射胰岛素的话。在这种情况下,糖尿病患者需要估计他们即将食用的食物将如何影响他们的血糖,并相应地调整剂量。

同样地,糖尿病患者必须根据时间安排饮食和药物,以优化血糖水平,最好保持在特定范围内,既不过高也不过低。除了需要考虑诸如进食和运动等影响血糖的活动外,糖尿病患者还需考虑特定的一天中时间的影响。例如,黎明现象是所有人都会出现的血糖上升,但对糖尿病患者可能成为问题。另一方面,对于 1 型糖尿病患者来说,在睡眠时间内的血糖过低可能是生命威胁性事件,由于未能准确预测而导致。

在这里,我们查看一个小型数据集:一个个体的自我报告连续血糖监测(CGM)数据,分布在几个不连续的时间段内。这些数据是在互联网上自行发布的,并进行了修改以保护患者的隐私。

获取糖尿病数据集还有其他选择。除了大型医疗机构和一些拥有大量连续血糖监测数据的初创公司外,随着个体越来越多地通过 DIY 方法管理糖尿病,还有许多自行发布的数据集可供使用,例如Night Scout 项目。此外,还有几个糖尿病CGM 数据集可供研究目的使用。

在本节中,我们将探索真实数据集的混乱,并尝试对该数据进行预测。

数据清理和探索

数据存储在本书的几个文件中,可以在这本书的GitHub 存储库中找到。我们首先加载这些文件并将它们合并为一个data.table

## R
> files <- list.files(full.names = TRUE)
> files <- grep("entries", files, value = TRUE)
> dt    <- data.table()
> for (f in files) {
>   dt <- rbindlist(list(dt, fread(f)))
> }
> 
> ## remove the na columns
> dt <- dt[!is.na(sgv)]

有日期信息的字符串可用,但我们没有适当的时间戳类列,因此我们使用现有的信息制作一个,包括时区和日期字符串:

## R
> dt[, timestamp := as.POSIXct(date)] 
> ## this works for me because my computer is on EST time
> ## but that might not be true for you
> 
> ## proper way
> dt[, timestamp := force_tz(as.POSIXct(date), "EST")]
> 
> ## order chronologically
> dt = dt[order(timestamp, decreasing = FALSE)]

然后我们检查数据的后处理:

## R
> head(dt[, .(date, sgv)])
                  date sgv
1: 2015-02-18 06:30:09 162
2: 2015-02-18 06:30:09 162
3: 2015-02-18 06:30:09 162
4: 2015-02-18 06:30:09 162
5: 2015-02-18 06:35:09 154
6: 2015-02-18 06:35:09 154

存在许多重复的数据条目,因此我们需要出于两个原因进行清理:

  • 事先没有理由认为某些数据点应该比其他数据点更优越或更高权重,但是重复会产生这样的效果。

  • 如果我们要基于时间序列窗口生成特征,如果存在重复的时间点,这些特征将是没有意义的。

我们首先看看是否可以通过删除所有完全重复的行来解决这个问题:

## R
> dt <- dt[!duplicated(dt)]

然而,我们不应假设这解决了问题,所以我们检查是否存在相同时间戳的非相同数据点:

## R
> nrow(dt)
[1] 24861
> length(unique(dt$timestamp))
[1] 23273
> ## we still have duplicated data as far as timestamps
> ## we will delete them

由于我们仍然有重复的时间点,我们进行一些数据审查以查看其含义。我们确定具有最多重复行的时间戳,并检查这些行:

## R
> ## we can identify one example using
> ## dt[, .N, timestamp][order(N)]
> ## then look at most repeated data
> dt[date == "2015-03-10 06:27:19"]
device                date                   dateString sgv direction type
1: dexcom 2015-03-10 06:27:19 Tue Mar 10 06:27:18 EDT 2015  66      Flat  sgv
2: dexcom 2015-03-10 06:27:19 Tue Mar 10 06:27:18 EDT 2015  70      Flat  sgv
3: dexcom 2015-03-10 06:27:19 Tue Mar 10 06:27:18 EDT 2015  66      Flat  sgv
4: dexcom 2015-03-10 06:27:19 Tue Mar 10 06:27:18 EDT 2015  70      Flat  sgv
5: dexcom 2015-03-10 06:27:19 Tue Mar 10 06:27:18 EDT 2015  66      Flat  sgv
6: dexcom 2015-03-10 06:27:19 Tue Mar 10 06:27:18 EDT 2015  70      Flat  sgv
7: dexcom 2015-03-10 06:27:19 Tue Mar 10 06:27:18 EDT 2015  66      Flat  sgv
8: dexcom 2015-03-10 06:27:19 Tue Mar 10 06:27:18 EDT 2015  70      Flat  sgv
## more inspection suggests this is not too important

从这些数值来看,我们可以看到报告的血糖值是不同的,但它们并不是完全不同¹⁰,所以我们会抑制重复的时间戳,即使它们并非完全相同¹¹。

## R
> dt <- unique(dt, by=c("timestamp"))

现在我们有了正确的时间戳和每个时间戳的单一值,我们可以绘制我们的数据并了解其范围和行为(参见 图 13-9)。

图 13-9. 这个天真的时间序列图显示了可用数据的全时域和值范围。不幸的是,数据在时间上分布很广且不连贯,这个图并不能给我们一个关于系统行为的良好理解。

我们放大到我们在时间序列早期看到的时期,即 2015 年 3 月(参见 图 13-10):

## R
> ## let's look at some shorter time windows in the data
> start.date <- as.Date("2015-01-01")
> stop.date  <- as.Date("2015-04-01")
> dt[between(timestamp, start.date, stop.date), 
>              plot(timestamp, sgv, cex = .5)]

图 13-10. 关注时间序列的特定段落更有帮助,但对于我们理解任何时间序列动态来说仍然太过压缩。我们应该在时间轴上进一步放大。

即使在仅仅显示三月日期的图中,我们仍然无法理解时间序列的行为。是否有季节性?漂移?日常模式?我们不知道,因此我们绘制了一个更短的时间窗口(参见 图 13-11):

## R
> ## also look at a single day to see what that can look like
> ## if we had more time we should probably do 2D histograms of days
> ## and see how that works
> par(mfrow = c(2, 1))
> start.date = as.Date("2015-07-04")
> stop.date  = as.Date("2015-07-05")
> dt[between(timestamp, start.date, stop.date), 
>               plot(timestamp, sgv, cex = .5)]
> 
> start.date = as.Date("2015-07-05")
> stop.date  = as.Date("2015-07-06")
> dt[between(timestamp, start.date, stop.date), 
>                plot(timestamp, sgv, cex = .5)]
> ## if we had more days we could probably do some "day shapes"
> ## analysis, but here we don't seem to have enough data

图 13-11. 展示了七月份两天的数据。我们可以看到某种日常模式正在形成。最后,我们在一个时间尺度上观察数据,这样人眼可以理解正在发生的事情。

通过更多的探索性绘图(留给读者作为练习),您应该对数据的规模、质量动态以及描述其行为的重要性有直观的理解。您应该对此数据集应用的一些进一步的探索技术包括:

  • 日长度的二维直方图,用于查找一天内的模式,以及相关的聚类练习。是否存在不同类型的日子?

  • 根据一天中的小时或一年中的季节进行分组统计,以寻找时间上的系统差异。

  • 平滑数据以寻找长期趋势,特别是比较一天之间的关系,而不是一天内的关系。开发超长期血糖预测将是有价值的,这只能通过超越更明显的一天内模式来实现。

生成特征

有了一些背景知识,以及我们对数据集的简要探索的观察,我们可以为我们的数据生成有助于预测血糖的特征。

我们从时间本身的特征化开始。例如,我们注意到一天中有一定的结构,这表明一天中的时间应该与预测相关。同样,如果你绘制不同月份的血糖时间序列数据,一些月份显示的方差比其他月份大。¹² 我们在这里生成了一些与时间相关的特征(见图 13-12):

## R
> ## we should consider putting in a feature for month of the year.
> ## perhaps there are seasonal effects
> dt[, month := strftime(timestamp, "%m")]
> 
> dt[, local.hour := as.numeric(strftime(timestamp, "%H"))]
> ## we notice some hours of the day are greatly overrepresented
> ## this person is not consistently wearing the device or perhaps
> ## the device has some time of day functionality or recordkeeping
> ## issues. since it is a weird peak at midnight, this seems to 
> ## suggest the device rather than a user (who puts device both 
> ## on and off at midnight?)
> 
> hist(dt$local.hour)

我们还考虑时间序列值的特质,而不仅仅是数据收集的时间戳。例如,我们可以看到平均血糖浓度随一天中的时间变化(见图 13-13):

## R
> ## blood glucose values tend to depend on the time of day
> plot(dt[, mean(sgv), local.hour][order(local.hour)], type = 'l', 
>        ylab = "Mean blood glucose per local hour", 
>        xlab = "Local hour")

图 13-12。从本地小时的直方图中我们可以看出,CGM 收集和报告数据的时间并不是随机的。午夜附近的高度集中还表明了报告或设备功能上的不规则性,这很可能不是由用户行为引起的(用户不太可能在那个时候操纵他们的设备)。

图 13-13。平均血糖浓度在一天中显著变化。

血糖值的数据中还有其他有趣的数据。数据集中名为direction的列从 CGM 设备中提取信息,应用制造商的专有软件,并为数据提供方向趋势标签。我们可以使用这一点而不是计算自己的趋势统计,因此我们试图稍微理解一下,例如询问不同小时是否有不同的趋势。

我们定义一个函数,它将为给定向量提供第 n 个最流行的标签,并且我们首先使用它来找到每个本地小时的最流行标签:

## R
> nth.pos = function(x, pos) {
>   names(sort(-table(x)))[pos] 
>   ## this code snippet thanks to r user group
> }
> dt[, nth.pos(direction, 1), local.hour][order(local.hour)]
    local.hour   V1
 1:          0 Flat
 2:          1 Flat
 3:          2 Flat
 ...
21:         20 Flat
22:         21 Flat
23:         22 Flat
24:         23 Flat
    local.hour   V1

全天最流行的方向标签是“flat”,这让人感到放心,因为如果趋势仅仅可以通过一天中的时间强烈预测,系统的动态将是可疑的。

然而,一天中每小时第二常见的方向标签确实随时间变化,我们可以在这里看到:

## R
> dt[, nth.pos(direction, 2), local.hour][order(local.hour)]
    local.hour            V1
 1:          0   FortyFiveUp
 2:          1   FortyFiveUp
 3:          2   FortyFiveUp
 4:          3 FortyFiveDown
 5:          4 FortyFiveDown
 6:          5   FortyFiveUp
 7:          6 FortyFiveDown
 8:          7 FortyFiveDown
 9:          8 FortyFiveDown
10:          9   FortyFiveUp
11:         10   FortyFiveUp
12:         11 FortyFiveDown
13:         12 FortyFiveDown
14:         13 FortyFiveDown
15:         14 FortyFiveDown
16:         15 FortyFiveDown
17:         16   FortyFiveUp
18:         17   FortyFiveUp
19:         18 FortyFiveDown
20:         19   FortyFiveUp
21:         20   FortyFiveUp
22:         21   FortyFiveUp
23:         22   FortyFiveUp
24:         23 FortyFiveDown
    local.hour            V1

有趣的是,我们可以将这些标签与我们在前一节中包含的一天内数据图进行交叉引用。结果是否匹配?(读者留作练习)

接下来我们解决一个可能具有预测性的数字标签,即最近时间窗口内血糖测量的方差。我们计算一个短期回溯窗口的标准差。

为此,我们必须考虑数据的非连续性。我们不想计算时间表中不相邻但在data.table中仅相邻的数据点的标准差。我们将计算目标窗口起始点和结束点之间的时间差,以确保它们是正确的时间尺度,但我们需要小心处理。以下是我们可能会出错的一个例子:

## R
> ## FIRST don't do this the wrong way
> ## Note: beware calculating time differences naively!
> as.numeric(dt$timestamp[10] - dt$timestamp[1])
[1] 40
> as.numeric(dt$timestamp[1000] - dt$timestamp[1])
[1] 11.69274
> dt$timestamp[1000] - dt$timestamp[1]
Time difference of 11.69274 days

时间差可能以不同的单位返回,这会使前面代码的结果变得毫无意义。计算时间差的正确方法,以及我们如何使用这个时间差来确定给定行的标准差计算的有效性,如下所示:

## R
> dt[, delta.t := as.numeric(difftime(timestamp, shift(timestamp, 6), 
>                                    units = 'mins'))]
> dt[, valid.sd := !is.na(delta.t) & delta.t < 31]
> dt[, .N, valid.sd]
valid.sd     N
1:    FALSE  1838
2:     TRUE 21435

一旦我们标记了适合进行标准差计算的行,我们就开始进行计算。我们在所有行上进行计算,并计划用列平均值覆盖无效值,作为简单的缺失值插补方法:

## R
> dt[, sd.window := 0]
> for (i in 7:nrow(dt)) {
>   dt[i, ]$sd.window = sd(dt[(i-6):i]$sgv)
> }
> ## we will impute the missing data for the non-valid sd cases
> ## by filling in the population mean  (LOOKAHEAD alert, but we're aware)
> imputed.val = mean(dt[valid.sd == TRUE]$sd.window)
> dt[valid.sd == FALSE, sd.window := imputed.val]

接下来,我们设置了一个列来确定我们应该为我们的预测目标选择的真实值。我们选择预测血糖值提前 30 分钟,这足以在预测到血糖危险高或低时提醒糖尿病患者。我们将目标预测值放入名为target的列中。我们还创建了另一个名为pred.valid的列,该列指示在我们希望进行预测之前的数据点是否足够完整(即,在前 30 分钟以 5 分钟间隔定期采样):

## R
> ## now we also need to fill in our y value
> ## the actual target
> ## this too will require that we check for validity as when 
> ## computing sd due to nontemporally continuous data sitting 
> ## in same data.table. let's try to predict 30 minutes ahead 
> ## (shorter forecasts are easier)
> 
> ## we shift by 6 because we are sampling every 5 minutes
> dt[, pred.delta.t := as.numeric(difftime(shift(timestamp, 6, 
>                                                type = "lead"), 
>                                          timestamp, 
>                                          units = 'mins'))]
> dt[, pred.valid := !is.na(pred.delta.t) & pred.delta.t < 31]
> 
> dt[, target := 0]
> for (i in 1:nrow(dt)) {
>   dt[i, ]$target = dt[i + 6]$sgv
> }

我们抽查我们的工作,看看它是否产生了合理的结果:

## R
> ## now we should spot check our work
> i = 300
> dt[i + (-12:10), .(timestamp, sgv, target, pred.valid)]
              timestamp sgv target pred.valid
 1: 2015-02-19 16:15:05 146    158       TRUE
 2: 2015-02-19 16:20:05 150    158       TRUE
 3: 2015-02-19 16:25:05 154    151      FALSE
 4: 2015-02-19 16:30:05 157    146      FALSE
 5: 2015-02-19 16:35:05 160    144      FALSE
 6: 2015-02-19 16:40:05 161    143      FALSE
 7: 2015-02-19 16:45:05 158    144      FALSE
 8: 2015-02-19 16:50:05 158    145      FALSE
 9: 2015-02-19 17:00:05 151    149       TRUE
10: 2015-02-19 17:05:05 146    153       TRUE
11: 2015-02-19 17:10:05 144    154       TRUE
12: 2015-02-19 17:15:05 143    155       TRUE
13: 2015-02-19 17:20:05 144    157       TRUE
14: 2015-02-19 17:25:05 145    158       TRUE
15: 2015-02-19 17:30:05 149    159       TRUE
16: 2015-02-19 17:35:05 153    161       TRUE
17: 2015-02-19 17:40:05 154    164       TRUE
18: 2015-02-19 17:45:05 155    166       TRUE
19: 2015-02-19 17:50:05 157    168       TRUE
20: 2015-02-19 17:55:05 158    170       TRUE
21: 2015-02-19 18:00:04 159    172       TRUE
22: 2015-02-19 18:05:04 161    153      FALSE
23: 2015-02-19 18:10:04 164    149      FALSE
              timestamp sgv target pred.valid

仔细看看这个输出。其中的某些内容可能会让你质疑我们在评估“有效性”计算最近窗口的血糖数据标准差时是否太苛刻。作为一个独立的练习,看看你能否发现这些点,并思考如何重新设计pred.valid标签,使其更正确地包含。

现在我们有了大量的特征和一个用于训练模型的目标值,但我们还没有生成所有的特征。我们应该简化我们已经生成的一些时间特征,以减少模型的复杂性。例如,而不是将本地小时作为输入的 23 个二进制值(每天的每小时减去一个),我们应该减少“小时”类别的数量。我们可以这样做:

## R
> ## Let's divide the day into quarters rather than 
> ## into 24-hour segments. We do these based on a notion 
> ## of a 'typical' day 
> dt[, day.q.1 := between(local.hour,  5, 10.99)]
> dt[, day.q.2 := between(local.hour, 11, 16.99)]
> dt[, day.q.3 := between(local.hour, 17, 22.99)]
> dt[, day.q.4 := !day.q.1 & !day.q.2 & !day.q.3] 

我们还将月份数据简化为一组更简单的类别:

## R
> ## let's have a "winter/not winter" label rather than 
> ## a month label. this decision is partly based on the 
> ## temporal spread of our data
> dt[, is.winter := as.numeric(month) < 4]

最后,要使用direction列,我们需要对该值进行独热编码,就像我们对不同时间的处理方式一样。我们还清理了一些不一致标记的特征("NOT COMPUTABLE""NOT_COMPUTABLE"):

## R
> ## we also need to one-hot encode a direction feature
> ## and clean that data somewhat
> dt[direction == "NOT COMPUTABLE", direction := "NOT_COMPUTABLE"]
> dir.names = character()
> for (nm in unique(dt$direction)) {
>   new.col = paste0("dir_", nm)
>   dir.names = c(dir.names, new.col)
>   dt[, eval(parse(text = paste0(new.col, " := 
 (direction == '", nm, "')")))]
> }

现在我们已经对相关特征进行了独热编码,并简化了其他特征,所以我们准备将这些特征引导到模型中。

拟合模型

最后,我们开始进行时间序列分析中有趣的部分:做出预测。

模型建模需要花费多少时间?

正如您在本章的两个真实模型中看到的那样,真实世界的数据是混乱的,每次清理都必须以领域知识和常识为基础进行。没有通用模板。然而,您应该始终小心谨慎,不要急于适应模型。我们只想在确信我们没有投入垃圾数据时才适应模型!

我们的首要任务是创建训练和测试数据集:¹³

## R
> ## we need to set up training and testing data
> ## for testing, we don't want all the testing to come 
> ## at end of test period since we hypothesized some of behavior
> ##  could be seasonal let's make the testing data
> ##  the end data of both "seasons"
> winter.data      <- dt[is.winter == TRUE]
> train.row.cutoff <- round(nrow(winter.data) * .9)
> train.winter     <- winter.data[1:train.row.cutoff]
> test.winter      <- winter.data[(train.row.cutoff + 1): nrow(winter.data)]
> 
> spring.data      <- dt[is.winter == FALSE]
> train.row.cutoff <- round(nrow(spring.data) * .9)
> train.spring     <- spring.data[1:train.row.cutoff]
> test.spring      <- spring.data[(train.row.cutoff + 1): nrow(spring.data)]
> 
> train.data <- rbindlist(list(train.winter, train.spring))
> test.data  <- rbindlist(list(test.winter,  test.spring))
> 
> ## now include only the columns we should be using
> ## categorical values: valid.sd, day.q.1, day.q.2, day.q.3, is.winter 
> ## plus all the 'dir_' colnames
> col.names <- c(dir.names, "sgv", "sd.window", "valid.sd", 
>               "day.q.1", "day.q.2", "day.q.3", "is.winter")
> 
> train.X <- train.data[, col.names, with = FALSE]
> train.Y <- train.data$target
> 
> test.X <- test.data[, col.names, with = FALSE]
> test.Y <- test.data$target

与许多前沿工作一致,我们选择XGBoost梯度提升树作为我们的模型。在最近的出版物中,这些模型在某些用例中达到或超过了血糖预测的最新标准:¹⁴

## R
> model <- xgboost(data = as.matrix(train.X), label = train.Y, 
>                     max.depth = 2, 
>                     eta       = 1, 
>                     nthread   = 2, 
>                     nrounds   = 250, 
>                     objective = "reg:linear")
> y.pred <- predict(model, as.matrix(test.X))

然后我们可以检查这次预测的结果(见图 13-14)。我们还看了我们对特定一天的预测(见图 13-15):

## R
> ## now let's look at a specific day
> test.data[date < as.Date("2015-03-17")]
> par(mfrow = c(1, 1))
> i <- 1
> j <- 102
> ylim <- range(test.Y[i:j], y.pred[i:j])
> plot(test.data$timestamp[i:j], test.Y[i:j], ylim = ylim)
> points(test.data$timestamp[i:j], y.pred[i:j], cex = .5, col = 2)

图 13-14. 在这么大的尺度上,我们的预测看起来不错,但是从这么远的距离看很难判断。这不是使用预测的人会体验到的方式。

图 13-15. 逐日检查可以更好地了解我们预测算法的工作效果。

预测看起来合理,但这个图表表明底层数据可能存在问题。周日到周一之间午夜时的这个点看起来异常低,特别是其邻近点并不低。更可能是设备故障,而不是某人经历了如此剧烈但短暂的低血糖。我们可能需要考虑更多数据清理,或者也许是添加额外标签来表明这些特定点看起来可疑。¹⁵ 我们的模型预测这些可能无效的低血糖数据点,可能是过拟合的迹象。我们希望我们的模型预测实际的血糖值,而不是设备故障。

此时,我们应该考虑我们算法的目标。如果我们仅想展示我们能在半小时内做出合理的血糖预测——甚至没有知道某人正在吃什么或者何时锻炼的好处——我们已经可以说“任务完成”。这已经相当了不起——令人印象深刻的是,我们可以在没有相关输入(如饮食和运动信息)的情况下做出合理的预测。

然而,我们主要的目标是预测会提醒人们危险的预测——即他们的血糖将过低或过高的时候。让我们专注于这些数据点,同时考虑我们的预测与测量值的比较。

重要的是,我们将高血糖和低血糖值分开绘制。如果将它们绘制在一起,我们将看到一个看似高相关性的人工哑铃形状分布的高低点(见图 13-16):

## R
> par(mfrow = c(2, 1))
> high.idx = which(test.Y > 300)
> plot(test.Y[high.idx], y.pred[high.idx], xlim = c(200, 400), 
>                                          ylim = c(200, 400))
> cor(test.Y[high.idx], y.pred[high.idx])
[1] 0.3304997
> 
> low.idx = which((test.Y < 60 & test.Y > 10))
> plot(test.Y[low.idx], y.pred[low.idx], xlim = c(0, 200), 
>                                        ylim = c(0, 200))
> cor(test.Y[low.idx], y.pred[low.idx])
[1] 0.08747175

这些图表更加严格地评估了我们模型的表现。特别令人关注的是,该模型在低血糖水平下表现不佳。这可能是因为危险低血糖发生率较低,所以模型没有太多机会进行训练。我们应考虑数据增强或重新采样,以找到使这种现象对我们的模型更加显著的方法。我们还可以修改我们的损失函数,更加重视我们数据中已有的示例。

图 13-16。绘制预测值与实际值在极端端点处的对比图。在顶部,我们绘制高测量血糖值相互之间的值,底部是低测量血糖值。

在这里,我们看到了一个案例,初始模型的预测看起来令人满意,但可能并没有实现模型最基本的目的。在这种情况下,我们可以预测一般的血糖趋势,但我们应该更多地预测高低血糖,特别是关注血糖严重低下的情况。

在这种情况下,就像流感预测一样,我们看到了常识对建模时间序列的重要性。我们不能盲目地清理数据或准备特征,而不考虑它们在上下文中的含义。同样,我们对时间序列领域的了解越多,比如理解时间对人类血糖的重要性,我们就越能够清理数据、准备特征,并检查我们的预测在重要的成功或失败案例中的表现。

更多资源

Vasileios Lampos 等人,“利用搜索查询日志进行流感样疾病率的现场预测进展,” 科学报告 5 卷,第 12760 号(2015 年),https://perma.cc/NQ6B-RUXF。

这篇 2015 年《自然通讯》论文非常易懂,既是流感现场预测的良好介绍,也是一篇历史性的文章,展示了大数据和实时社交媒体以及互联网数据输入如何革新了现场预测和预报。作者们运用传统的线性模型和创新的非线性模型来比较不同的方法,并展示了在季节性传染病率这样复杂系统中非线性模型的实用性。

大卫·法罗,“模拟流感的过去、现在和未来,” 卡内基梅隆大学计算生物学系博士论文,2016 年,https://perma.cc/96CZ-5SX2。

这篇论文详细阐述了“传感器融合”的理论和实践,用于整合各种地理和时间粒度的社交媒体来源,以制定 Delphi 小组的主要流感预测之一。它提供了从病毒学到人口动态再到数据科学实验的深入预测流感的概述。

Rob J. Hyndman,《动态回归》,讲义,无日期,https://perma.cc/5TPY-PYZS。

这些讲义提供了如何使用动态回归来补充传统统计预测模型的实际示例,当 SARIMA 不适合时,可以使用一系列替代模型来处理季节性,通常是因为周期性太复杂或周期相对于可用数据或计算资源来说太长。

¹ 虽然这不是公共数据集,但可以通过注册参加竞赛来访问数据。

² 选择一周的第几天(选项为 1 到 7)作为起始日取决于计算流感率的具体日期,但从提供的数据中我们无法知道这一点。出于简便起见,我们选择第一天。在这种情况下选择最后一天也同样合理,只要我们在分析中保持一致即可。

³ 一年中的周数和星期几的适当格式化字符串可能取决于您的操作系统。我的解决方案在 macOS 上有效,而在 Linux 上我使用了稍微不同的格式。

⁴ 如果我们在处理大数据集时,应将此作为第一步,以避免计算负担较重的操作,例如将字符串转换为Date对象,对于无关数据而言。

⁵ 这并非确保数据定期采样和完整性的唯一必要检查。我将其余部分留给你。

⁶ 通常情况下,你应该单独考虑它们,但这里它们被合并以简洁表达。

⁷ 傅立叶级数是将函数描述为一系列正弦和余弦函数的实践。我们之前曾提及过傅立叶级数。如果您对傅立叶级数不熟悉,请考虑花几分钟了解其背景。在 R 和 Python 包中广泛提供了将傅立叶级数“拟合”到任何时间序列的方法。Ritchie Vink 有一个我非常喜欢的简短教程

⁸ 这些在第三章中简要讨论过。

⁹ 要测试这个解释是否适合当前情况,您可以使用模拟生成一些合成数据,以测试使用此模型的替代数据集时,流感数据似乎表明的周期性行为是否会导致外生谐波回归模型失败。

¹⁰ 在美国的测量单位系统中,血糖仪的接受误差通常约为 15,而这些数字使用了该系统。

¹¹ 除了抑制之外,还有其他选项。你可以自己探索这些选项。

¹² 所有这些观察结果都应以高度怀疑的态度看待。这个数据集只包含来自单个用户在部分一年内的数据,并且数据并不连续。这意味着即使我们有诱惑去推测,我们也不能就季节性得出结论。尽管如此,为了适应我们拥有的有限数据,我们将考虑这些时间组件。

¹³ 在实践中,对于较大的数据集,你还需要设置一个验证测试集,这将是你的训练数据的一个特殊子集,类似于测试数据但不会污染你的实际测试数据。正如测试数据通常应该是最近的数据(以防止未来信息向后泄漏,例如前瞻性),在这种情况下,验证数据集应该在训练期末出现。考虑自行设置这一点,以探索模型的超参数。

¹⁴ 这里我们不调整超参数(这需要一个验证数据集),但一旦你确定了一般的建模流程,这是你应该在 XGBoost 中做的事情。

¹⁵ 一个想法是以时间序列感知的方式应用异常值检测,以注意到这些点与该时间窗口的一般趋势不符。在某些情况下,我们还可以使用基础物理学、化学和生物学来说明某些下降是不可能的。

第十四章:金融应用

金融市场是所有时间序列数据的鼻祖。如果您在高科技交易所购买专有交易数据,您可以接收到大量数据,可能需要数天才能处理,即使使用高性能计算和尴尬并行处理也是如此。

高频交易者是金融界最新和最臭名昭著的成员之一,他们根据微秒级别的时间序列分析得出的信息和见解进行交易。另一方面,传统的交易公司——查看长期时间序列,例如几小时、几天甚至几个月——继续在市场上获得成功,表明金融数据的时间序列分析可以通过多种成功的方式和时间尺度进行,从毫秒到月份的数量级不等。

注意

尴尬并行 描述了数据处理任务,其中处理一个数据段的结果与另一个数据段的值无关。在这种情况下,将数据分析任务转换为并行运行而不是顺序运行,以利用多核或多机计算选项是非常容易的。

例如,考虑计算给定股票的每日分钟级回报的日均值的任务。每天可以单独并行处理。相比之下,计算每日波动率的指数加权移动平均不是尴尬并行的,因为特定日的值取决于之前几天的值。有时候并不尴尬并行的任务仍然可以部分并行执行,但这取决于具体情况。

在这里,我们将通过一个经典的时间序列分析示例来进行乐趣和盈利:预测标准普尔 500 指数明天的股票回报。

获取和探索金融数据

如果您有特定产品或时间分辨率需要,获取金融数据可能非常困难。在这种情况下,通常需要购买数据。但是,历史股票价格可以从多种服务中广泛获取,包括:

我们将分析限制在从 Yahoo 获取的免费提供的每日股票价格数据上。我们下载了 1990 年至 2019 年的标准普尔 500 指数数据。在以下代码中,我们看到下载数据集中的可用列,并绘制每日收盘价来开始探索我们的数据(见图 14-1):

## python
>>> df = pd.read_csv("sp500.csv")
>>> df.head()
>>> df.tail()
>>> df.index = df.Date
>>> df.Close.plot()

我们可以看到,在覆盖 CSV 文件日期周期的开始和结束时,值明显不同。当我们查看收盘价格的完整时间序列时(见 图 14-2),这种价值变化比查看数据框中的样本时更为明显。

查看 图 14-2 可以发现,该时间序列不是平稳的。我们还可以看到可能存在不同的“制度”。由于图中清晰可见的原因,金融分析师热衷于开发识别股票价格制度转变的模型。即使我们可能没有确切的定义,不同的制度似乎存在于这些转变之间[²]。

图 14-1. CSV 文件起始和结束时的原始数据。注意,从 1990 年到 2019 年,值有明显变化——对熟悉美国金融市场历史的人来说并不奇怪。

图 14-2. 标准普尔 500 指数的日收盘价格不是平稳的时间序列。

变化点和不同制度的潜力表明,将数据集分成不同的子数据集进行单独建模可能是个好主意。然而,我们希望保持所有数据在一起,因为几十年来的日数据并不产生很多数据点;我们需要尽可能保留所有数据。我们考虑,即使我们只对未来一天的预测感兴趣,我们是否能够证明保持所有这些数据是合理的。

我们可以考虑是否归一化数据可以使不同时间段的数据可比。让我们看看在时间序列内三个不同十年的一周内缩放后的收盘价格(见 图 14-3):

## python
>>> ## pick three weeks (Mon - Fri) from different years
>>> ## scale closing prices for each day by the week’s mean closing price
>>> ## 1990
>>> vals = df['1990-05-07':'1990-05-11'].Close.values
>>> mean_val = np.mean(vals)
>>> plt.plot([1, 2, 3, 4, 5], vals/mean_val)
>>> plt.xticks([1, 2, 3, 4, 5],
>>>			 labels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'])
>>>
>>> ## 2000
>>> vals = df['2000-05-08':'2000-05-12'].Close.values
>>> mean_val = np.mean(vals)
>>> plt.plot([1, 2, 3, 4, 5], vals/mean_val)
>>> plt.xticks([1, 2, 3, 4, 5],
>>>			 labels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'])
>>>
>>> ## 2018
>>> vals = df['2018-05-07':'2018-05-11'].Close.values
>>> mean_val = np.mean(vals)
>>> plt.plot([1, 2, 3, 4, 5], vals/mean_val)
>>> plt.xticks([1, 2, 3, 4, 5],
>>>			 labels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'])

我们绘制了三个不同十年内三周的收盘价,每个周的均值如前述代码所示。在一周内,每天之间的相对百分比变化似乎在几个十年间都大致相同。

这些图表显示了很大的希望。虽然收盘价格的均值和方差随时间变化很大,但图表表明一旦我们将数据归一化到给定十年的均值,随时间的行为趋势相似。

鉴于此,我们接下来考虑是否可以找到一种方法使整个时间段内的所有数据足够相似,以便用模型进行有意义的训练。我们想知道是否有一种方法可以转换数据,在金融上有意义的同时使整个时间段的数据可比。

图 14-3. 1990 年、2000 年和 2018 年 5 月的一周均值缩放后的收盘价格。

我们计算每个交易日的日收益率,即从每个交易日开始到结束的价格变动(见 图 14-4):

## python
>>> df['Return'] = df.Close - df.Open
>>> df.Return.plot()

正如我们在图 14-4 中看到的,单独这还不足以使数据可比较。我们还必须找到一种方法,在没有前瞻性的情况下对数据进行归一化,以便我们用于模型的输入和输出值在感兴趣的时间段内更加均匀。我们将在下一节中看到如何做到这一点。

图 14-4。每日收益显示随时间几乎为零的均值,但每日收益的方差在不同时间段明显变化。正是这种行为启发了 GARCH 等模型的发展,这在第六章中简要讨论过。

深度学习的财务数据预处理

我们的数据预处理将分三个步骤完成:

  1. 我们将从原始输入中形成新的经济意义上的感兴趣的数量。

  2. 我们将计算感兴趣的数量的指数加权移动平均和方差,以便我们可以在没有前瞻性的情况下对其进行缩放。

  3. 我们将把我们的结果打包成适合我们将用于拟合数据的递归深度学习模型的格式。

金融时间序列是其自身的学科

金融时间序列是一个整体学科,数千名学者在勤奋地努力理解金融市场的运作,无论是出于利润还是智能监管。已经开发了许多统计模型来处理我们在这里涉及的金融数据的一些棘手方面,例如 GARCH 模型。如果您希望将统计学和机器学习应用于金融时间序列建模,您应该研究量化金融的历史以及常用模型的主要类别。

添加感兴趣的数量到我们的原始数值

我们已经在前一节中计算了每日收益。我们可以从原始输入中形成的另一个感兴趣的数量是每日波动率,即在交易日内记录的最高和最低价格之间的差异。给定原始数据,这可以很容易地计算出来(见图 14-6):

## python
>>> df['DailyVolatility'] = df.High - df.Low
>>> df.DailyVolatility.plot()

就像每日收益价格是非平稳时间序列一样,每日波动率时间序列也是如此。这进一步确认了我们需要找到一种适当的方法来进行适当的缩放。我们还希望这样做而不引入前瞻性。

图 14-6。每日波动率是一个正值时间序列(根据定义),在标准普尔 500 指数时间序列的不同点显示出显著不同的方差。

在没有前瞻性的情况下扩展感兴趣的数量

我们将预测未来一天的每日收益。一些可能有帮助的感兴趣的数量包括:

  • 先前的每日收益

  • 先前的每日波动率

  • 先前的每日成交量

我们将通过减去指数加权移动平均值然后除以指数加权标准差来缩放这些量中的每一个。我们早期的每周数据探索显示,通过适当的预处理,我们感兴趣的各种量可以变成平稳的时间序列。

首先,我们计算数据帧中每列的指数加权移动平均,并绘制每日波动率的指数加权移动平均值(参见图 14-8)。与图 14-7 中的图相比,这个图更加平滑,因为有了平均化效果。请注意,这里有一个参数,你应该将其有效地考虑为模型的一部分,作为超参数,尽管它用于数据预处理步骤中:指数平滑的半衰期。你的模型行为肯定会依赖于这个参数:

## python
>>> ewdf = df.ewm(halflife = 10).mean()
>>> ewdf.DailyVolatility.plot()

图 14-7. 每日波动率的指数加权移动平均图比原始值的图更加平滑,但仍显示出非平稳的时间序列。

我们可以使用这个值,以及在此处计算的指数加权移动方差,按一种方式缩放感兴趣的值,以便产生随时间更一致行为的系列(参见图 14-8):

## python
>>> ## compute exponentially weighted moving variance 
>>> vewdf = df.ewm(halflife = 10).var()
>>>
>>> ## scale by demeaning and normalizing
>>> scaled = df.DailyVolatility - ewdf.DailyVolatility
>>> scaled = scaled / vewdf.DailyVolatility**0.5
>>> scaled.plot()

图 14-8. 使用指数加权平均和方差转换数据会得到一个时间序列更加平稳的结果,从 1990 年到 2019 年整个时间段内的值可比性很高。

我们将感兴趣的三个原始输入转换为如下的缩放版本:

## python
>>> df['ScaledVolatility'] = ((df.DailyVolatility - 
>>>                            ewdf.DailyVolatility) 
>>>                            / vewdf.DailyVolatility**0.5 )
>>> df['ScaledReturn']     = ((df.Return - ewdf.Return) 
>>>                            / vewdf.Return**0.5 )
>>> df['ScaledVolume']     = ((df.Volume - ewdf.Volume)
>>>                           / vewdf.Volume**0.5 )

最后,我们丢弃由指数平滑产生的NA结果:³

## python
>>> df = df.dropna()

为神经网络格式化我们的数据

目前我们的数据存储在一个 Pandas 数据帧中,我们计划的输入与许多我们不打算使用的原始输入一起存储。另外,对于神经网络,我们将把我们的数据格式化为 TNC 格式,你可能还记得,这代表时间 × 样本数 × 通道数。因此,即使在我们已经讨论过的重新缩放工作之后,我们仍需要进行一些预处理。

首先,我们将数据分为训练和测试组件:⁴

## python
>>> ## break our data into training and testing components
>>> train_df = df[:7000]
>>> test_df = df[7000:]
>>>
>>> ## build our pipeline variables off training data
>>> ## taking only values of interest from larger data frames
>>> horizon = 10
>>> X = train_df[:(7000 - horizon)][["ScaledVolatility", "ScaledReturn",
                                              "ScaledVolume"]].values
>>> Y = train_df[horizon:]["ScaledReturn"].values

注意,在我们这里设置Y的基础上,存在一些问题。在继续阅读之前,请花一分钟思考一下这个问题。

这个设置的问题在于Y值是缩放后的回报,而不仅仅是回报。这对训练更好,因为值落在适当的范围内,但这也意味着我们预测的Y并不是我们感兴趣的实际回报,而是由移动平均调整过的回报。我们在预测时是在预测我们的回报与指数加权移动平均的差异。

从本质上讲,这并不是错的,但这意味着我们让任务变得比真正的预测任务更容易。我们应该意识到这一点,因此当我们的训练看起来比我们模型实际表现更好时,我们知道这部分原因是因为在训练中,我们关注一个混合任务,而最终要通过这个模型赚钱只取决于真正的预测。

我们专注于训练数据,现在需要将X放入递归神经网络架构所期望的 TNC 格式中。我们通过一系列 NumPy 操作来实现这一点。

最初,X是二维的,因为它来自 Pandas 数据框架。我们想添加第三个维度,即轴 1(因此将第二个维度推到轴 2,其中轴从 0 开始编号):

## python
>>> X = np.expand_dims(X, axis = 1) 

我们的时间轴已经是轴 0,因为数据框架已按时间排序。最后一个轴,现在是轴 2,已经是“通道轴”,因为我们的每个输入占据该维度的一列。

我们将尝试一个模型,它将看到 10 个时间步长——即向后查看 10 天的数据。因此,我们需要截断轴 0 以长度为 10。我们每 10 行沿轴 0 进行切片,并重新组合产生的子矩阵列表,使得样本数量(即生成列表长度)成为第二轴的维度:⁵

## python
>>> X = np.split(X, X.shape[0]/10, axis = 0)
>>> X = np.concatenate(X, axis = 1)
>>> X.shape
(10, 699, 3)

根据 TNC 格式,我们有长度为 10 的时间序列,具有三个并行输入。这些中有 699 个示例。批量大小将决定一个 epoch 由多少批组成,其中一个 epoch 是对我们数据的一个循环。

鉴于我们似乎只有很少的示例,我们没有太多数据可以训练。我们是如何从 30 年的数据变得没有多少数据的呢?答案是,目前每个数据点仅包含在一个样本时间序列中。但是,每个数据点可以在 10 个不同的时间序列中,每个时间序列占据不同的位置。

这可能不是显而易见的,因此让我们看一个简单的例子。假设我们有以下时间序列:

1, 3, 5, 11, 3, 2, 22, 11, 5, 7, 9

我们希望用这个时间序列训练一个神经网络,这次假设时间窗口长度为 3。如果使用我们刚刚执行的数据准备,我们的时间序列示例将是:

  • 1, 3, 5

  • 11, 3, 2

  • 22, 11, 5

  • 7, 9, _

然而,并没有理由特别偏向我们的数据的起始点,好像必须设定每个样本时间序列的开始和结束。这些窗口是任意的。一些同样有效的时间序列,从整体中以窗口切出来,包括:

  • 3, 5, 11

  • 2, 22, 11

  • 5, 7, 9

因此,如果我们需要更多数据,最好生成时间序列样本,因为我们在整个数据集上滑动窗口,这将产生比我们通过将数据切成不重叠时间序列样本所做的更多的个体时间序列样本。在准备自己的数据集时请记住这一点。下面,您将看到我们使用这种滑动窗口方法预处理我们的数据。

建立和训练一个递归神经网络

如本章开头提到的,金融时间序列模型和理解通常是非常困难的。尽管金融行业继续是西方经济的支柱,专家们一致认为预测是非常困难的。因此,我们寻找一种适合于具有潜在非线性动态的复杂系统的技术,即深度学习神经网络。然而,由于我们缺乏数据,我们选择了一个简单的递归神经网络(LSTM)架构和训练方案,具体由以下参数描述:

## python
>>> ## architecture parameters
>>> NUM_HIDDEN = 4
>>> NUM_LAYERS = 2
>>>
>>> ## data formatting parameters
>>> BATCH_SIZE  = 64
>>> WINDOW_SIZE = 20
>>>
>>> ## training parameters
>>> LEARNING_RATE = 1e-2
>>> EPOCHS        = 30

与第 10 章相比,我们使用的是 TensorFlow 软件包而不是 MXNet,这样你就可以看到另一个广泛使用的深度学习框架示例。在 TensorFlow 中,我们为网络中使用的所有量定义变量,即使是具有代表输入的变化值。对于输入,我们使用 placeholders,这是一种让图形知道期望形状的方法:

## python
>>> Xinp = tf.placeholder(dtype = tf.float32, 
>>>                           shape = [WINDOW_SIZE, None, 3])
>>> Yinp = tf.placeholder(dtype = tf.float32, shape = [None])

然后我们构建我们的网络并实现损失计算和优化步骤:

## python
>>> with tf.variable_scope("scope1", reuse=tf.AUTO_REUSE):
>>>     cells = [tf.nn.rnn_cell.LSTMCell(num_units=NUM_HIDDEN) 
>>>                                for n in range(NUM_LAYERS)]
>>>     stacked_rnn_cell = tf.nn.rnn_cell.MultiRNNCell(cells)
>>>     rnn_output, states = tf.nn.dynamic_rnn(stacked_rnn_cell, 
>>>                                            Xinp, 
>>>                                            dtype=tf.float32) 
>>>     W = tf.get_variable("W_fc", [NUM_HIDDEN, 1], 
>>>                         initializer = 
>>>                         tf.random_uniform_initializer(-.2, .2))
>>> 
>>>     ## notice we have no bias because we expect average zero return
>>>     output = tf.squeeze(tf.matmul(rnn_output[-1, :, :], W))
>>> 
>>>     loss = tf.nn.l2_loss(output - Yinp)
>>>     opt = tf.train.GradientDescentOptimizer(LEARNING_RATE)
>>>     train_step = opt.minimize(loss)

由于我们早前讨论过的原因,我们有一种相当复杂的数据输入方式,即每个数据点应该位于多个时间序列中,这取决于我们使用的偏移量。在这里,我们处理了与第 10 章中详细讨论的相同的数据格式化问题:

## python
>>> ## for each epoch
>>> y_hat_dict = {}
>>> Y_dict = {}
>>> 
>>> in_sample_Y_dict = {}
>>> in_sample_y_hat_dict = {}
>>> 
>>> for ep in range(EPOCHS):
>>>     epoch_training_loss = 0.0
>>>     for i in range(WINDOW_SIZE):
>>>         X = train_df[:(7000 - WINDOW_SIZE)][["ScaledVolatility", 
>>>                                              "ScaledReturn", 
>>>                                              "ScaledVolume"]].values
>>>         Y = train_df[WINDOW_SIZE:]["ScaledReturn"].values
>>> 
>>>         ## make it divisible by window size
>>>         num_to_unpack = math.floor(X.shape[0] / WINDOW_SIZE)
>>>         start_idx = X.shape[0] - num_to_unpack * WINDOW_SIZE
>>>         X = X[start_idx:] 
>>>         Y = Y[start_idx:]  
>>>         
>>>         X = X[i:-(WINDOW_SIZE-i)]
>>>         Y = Y[i:-(WINDOW_SIZE-i)]                                
>>>         
>>>         X = np.expand_dims(X, axis = 1)
>>>         X = np.split(X, X.shape[0]/WINDOW_SIZE, axis = 0)
>>>         X = np.concatenate(X, axis = 1)
>>>         Y = Y[::WINDOW_SIZE]
>>>         ## TRAINING
>>>         ## now batch it and run a sess
>>>         for j in range(math.ceil(Y.shape[0] / BATCH_SIZE)):
>>>             ll = BATCH_SIZE * j
>>>             ul = BATCH_SIZE * (j + 1)
>>>             
>>>             if ul > X.shape[1]:
>>>                 ul = X.shape[1] - 1
>>>                 ll = X.shape[1]- BATCH_SIZE
>>>             
>>>             training_loss, _, y_hat = sess.run([loss, train_step, 
>>>                                        output],
>>>                                        feed_dict = {
>>>                                            Xinp: X[:, ll:ul, :], 
>>>                                             Yinp: Y[ll:ul]
>>>                                        })
>>>             epoch_training_loss += training_loss          
>>>             
>>>             in_sample_Y_dict[ep]     = Y[ll:ul] 
>>>             ## notice this will only net us the last part of 
>>>             ## data trained on
>>>             in_sample_y_hat_dict[ep] = y_hat
>>>             
>>>         ## TESTING
>>>         X = test_df[:(test_df.shape[0] - WINDOW_SIZE)]
>>>                          [["ScaledVolatility", "ScaledReturn", 
>>>                            "ScaledVolume"]].values
>>>         Y = test_df[WINDOW_SIZE:]["ScaledReturn"].values
>>>         num_to_unpack = math.floor(X.shape[0] / WINDOW_SIZE)
>>>         start_idx = X.shape[0] - num_to_unpack * WINDOW_SIZE
>>>         ## better to throw away beginning than end of training 
>>>         ## period when must delete
>>>         X = X[start_idx:] 
>>>         Y = Y[start_idx:]                              
>>>         
>>>         X = np.expand_dims(X, axis = 1)
>>>         X = np.split(X, X.shape[0]/WINDOW_SIZE, axis = 0)
>>>         X = np.concatenate(X, axis = 1)
>>>         Y = Y[::WINDOW_SIZE]
>>>         testing_loss, y_hat = sess.run([loss, output],
>>>                                  feed_dict = { Xinp: X, Yinp: Y })
>>>         ## nb this is not great. we should really have a validation 
>>>         ## loss apart from testing
>>>         
>>>     print("Epoch: %d Training loss: %0.2f   
>>>            Testing loss %0.2f:" %
>>>            (ep, epoch_training_loss, testing_loss))
>>>     Y_dict[ep] = Y
>>>     y_hat_dict[ep] = y_hat

这里我们看到我们的训练和测试指标:

Epoch: 0   Training loss: 2670.27   Testing loss 526.937:
Epoch: 1   Training loss: 2669.72   Testing loss 526.908:
Epoch: 2   Training loss: 2669.53   Testing loss 526.889:
Epoch: 3   Training loss: 2669.42   Testing loss 526.874:
Epoch: 4   Training loss: 2669.34   Testing loss 526.862:
Epoch: 5   Training loss: 2669.27   Testing loss 526.853:
Epoch: 6   Training loss: 2669.21   Testing loss 526.845:
Epoch: 7   Training loss: 2669.15   Testing loss 526.839:
Epoch: 8   Training loss: 2669.09   Testing loss 526.834:
Epoch: 9   Training loss: 2669.03   Testing loss 526.829:
Epoch: 10   Training loss: 2668.97   Testing loss 526.824:
Epoch: 11   Training loss: 2668.92   Testing loss 526.819:
Epoch: 12   Training loss: 2668.86   Testing loss 526.814:
Epoch: 13   Training loss: 2668.80   Testing loss 526.808:
Epoch: 14   Training loss: 2668.73   Testing loss 526.802:
Epoch: 15   Training loss: 2668.66   Testing loss 526.797:
Epoch: 16   Training loss: 2668.58   Testing loss 526.792:
Epoch: 17   Training loss: 2668.49   Testing loss 526.788:
Epoch: 18   Training loss: 2668.39   Testing loss 526.786:
Epoch: 19   Training loss: 2668.28   Testing loss 526.784:
Epoch: 20   Training loss: 2668.17   Testing loss 526.783:
Epoch: 21   Training loss: 2668.04   Testing loss 526.781:
Epoch: 22   Training loss: 2667.91   Testing loss 526.778:
Epoch: 23   Training loss: 2667.77   Testing loss 526.773:
Epoch: 24   Training loss: 2667.62   Testing loss 526.768:
Epoch: 25   Training loss: 2667.47   Testing loss 526.762:
Epoch: 26   Training loss: 2667.31   Testing loss 526.755:
Epoch: 27   Training loss: 2667.15   Testing loss 526.748:
Epoch: 28   Training loss: 2666.98   Testing loss 526.741:
Epoch: 29   Training loss: 2666.80   Testing loss 526.734:

我们选择的误差度量标准并不能告诉我们整体数据与结果的匹配情况,所以绘图是有帮助的。我们绘制了在样本外表现(重要)和样本内表现(不那么重要),如 图 14-9 所示:

## python
>>> plt.plot(test_y_dict[MAX_EPOCH])
>>> plt.plot(test_y_hat_dict[MAX_EPOCH], 'r--')
>>> plt.show()

图 14-9. 测试期间某一子段的实际返回值(实线)与神经网络预测(虚线)的绘图。预测的尺度与实际数据非常不同,这使得评估模型变得困难。

我们可以看到,我们对收益的预测值通常与实际收益不一致。接下来我们检查皮尔逊相关性:

## python
>>> pearsonr(test_y_dict[MAX_EPOCH], test_y_hat_dict[MAX_EPOCH])
(0.03595786881773419, 0.20105107068949668)

如果你之前没有处理过金融时间序列,这些数字可能看起来令人沮丧。在这个行业中,尽管有图表和 p 值,我们的模型可能仍然有用。在金融领域,一个正相关的结果是令人兴奋的,而且可以逐步改进。实际上,许多研究项目在起步时并不能达到这样的“高”相关性。

我们可以通过将预测收益扩大一个数量级并再次绘图,来更好地了解预测是否至少在同一个方向上变化 (图 14-10):

## python
>>> plt.plot(test_y_dict[MAX_EPOCH][:100])
>>> plt.plot(test_y_hat_dict[MAX_EPOCH][:100] * 50, 'r--')
>>> plt.show()

图 14-10. 更好地理解模型预测(虚线)与实际数据(实线)的比较。然而,由于相关性很低,我们更可能认为看到了某种模式,而实际上并非如此。因此,对于嘈杂的金融数据,定量指标比视觉评估更有用。

如果您已经阅读过有关使用深度学习处理金融时间序列的博客,您很可能会发现这里的表现令人失望。例如,您可能已经看到某人将简单的多层 LSTM 应用于某些日常股票数据,并产生几乎与实际股市数据相同的预测,即使是在样本外。这些结果看起来不错,但实际上并不令人印象深刻的原因有两个重要的原因:

  • 对代码进行预处理,使用像sklearn.preprocessing.MinMaxScaler这样的开箱即用的缩放解决方案来调整代码规模。这并不理想,因为它包含了使用所有时间段的值来缩放数据的前瞻性。

  • 预测价格而不是收益。这是一个更容易的任务——首先,对于第 T + 1 天的价格的出色预测是第 T 天的价格。因此,很容易构建一个似乎能够合理预测价格并生成令人印象深刻图表的模型。不幸的是,这种模型不能用来赚钱。

我们尝试了一个更真实的行业示例,这意味着挑战更大,而结果图形不会那么令人满意。

毫无疑问,我们尚未对模型的性能进行全面分析。这样做将为我们提供洞察,以便建立下一个模型,了解我们可能忽视的问题,并确定深度学习模型在相对于线性模型的额外复杂性是否合理。从这里出发,有许多改进模型性能的途径。

有许多方法可以改进此模型,您应该将其视为此代码的扩展:

  • 通过基于这些输入生成附加特征来从原始数据中添加更多输入。我们没有使用所有原始输入列,还有其他重新表达这些数量的方法可能会有用。您可以考虑分类变量,例如“当天高点或低点是否与开盘或收盘一致?”(这个问题中包含了几个二元条件)。

  • 集成其他股票的并行时间序列。这将增加进一步的信息和数据用于训练。

  • 使用几个不同的时间尺度的数据。一篇广泛引用的论文讨论了一个名为ClockworkRNN的架构。

  • 通过采用现有时间序列示例并添加抖动来增强您的数据。这将有助于解决这组数据提供的数据不足的问题。

  • 如果您已经扩展了输入数量或数据量,请允许您的网络架构增长。更复杂的架构并不总是提高性能的方法,但如果您发现网络性能达到了极限,则可能是适当的选择。

  • 尝试按时间顺序训练数据,而不是我们每个 epoch 循环多次数据的方法。有时这可能非常有帮助(但这取决于数据集)。鉴于我们看到时间序列行为随时间变化,可能更好地以最后数据来结束训练,以便权重反映出行为的变化。

  • 考虑不同的损失函数。这里我们使用了 L2 范数,它倾向于比小差异更严重地惩罚大差异。然而,鉴于领域的不同,我们可能希望以不同的方式评估成功。也许我们只想正确预测每日回报的符号,而不太关心其大小。在这种情况下,我们可以考虑将目标设定为分类变量:正、负、零。对于分类数据,通常我们希望使用交叉熵损失度量。然而,由于这并不是纯粹的分类数据,而是排名的(即零更接近于负而不是正更接近于负),我们可能希望使用自定义损失函数来反映这一点。

  • 考虑构建一组简单的神经网络集成,而不是单个的。保持每个单独的网络较小。集成对于低信噪比数据特别有用,例如金融数据。

  • 确定为什么预测的规模与实际值的规模如此不同。考虑首先评估我们使用的损失函数是否存在问题,考虑到每日收益强烈倾向于零值。

正如你所看到的,有很多方法可以改进网络的性能或调整其功能。如何做取决于数据集。始终使用网络性能的可视化、领域知识和明确定义的目标(在这种情况下可能是“赚钱”)来推动你精细调整模型的方式是非常有帮助的。否则,你可能会在大量的选择中迷失方向。

更多资源

朱曼娜·戈斯恩和约书亚·本吉奥,《股票选择的多任务学习》,剑桥:麻省理工学院出版社,1996 年,https://perma.cc/GR7A-5PQ5。

这篇 1997 年的论文提供了将神经网络应用于金融市场问题的一个非常早期的例子。在这种情况下,作者使用了现在被认为是相当简单的网络和最少的数据,但他们仍然发现他们的网络能够盈利地选择股票。有趣的是,这也是多任务学习的早期例子之一。

劳伦斯·竹内和李宇莹,《应用深度学习增强股票动量交易策略》,2013 年,https://perma.cc/GJZ5-4V6Z。

在这篇论文中,作者通过“动量训练”的视角解释了他们的神经网络,这是在机器学习之前量化预测金融市场的传统方式。该论文对于其讨论如何做出训练决策和评估模型性能是很有趣的。

“有人在交易中使用深度学习赚钱吗?” Quora, https://perma.cc/Z8C9-V8FX.

在这个问题和答案中,我们看到了关于深度学习在金融应用中成功程度的各种观点。正如一些答案所描述的那样,任何盈利的知识产权可能会受到严格的保密协议和利润激励的保护,在这个行业中很难评估目前的最先进性能。答案还指出了一系列潜在的金融应用——预测回报只是众多问题中的一个。

¹ 顺便提一句,这在 R 和 Python 社区中产生了相当多的无效代码。

² 注意,标准普尔 500 指数也很棘手,因为它是许多不同股票的组合作为输入,其权重定期调整,并且完全专有的因子也用于划分股票的加权平均值。因此,强大的领域知识和对公司采取不同行动如何影响其股票价格及其标准普尔 500 权重的理解,也是更好地理解我们在此看到的长期行为的重要因素。

³ 我们可以选择不舍弃它们,而是将指数平滑列的值设置为当时已知的唯一值。无论哪种情况,这都不重要。

⁴ 我们通常应该有一个单独的验证集,以避免信息从测试集向后泄漏,但我试图保持代码简单。

⁵ 对于 R 用户而言:请记住 Python 是从 0 开始计数的,因此第二轴是轴 1,而不是轴 2。

第十五章:政府时间序列

时间序列分析对于政府应用非常重要且相关,原因有很多。首先,无论大小,政府都是全球一些最重要时间序列数据的保管者,包括美国的就业报告、海洋温度数据(即全球变暖数据)和本地犯罪统计数据。其次,按定义,政府提供我们所有人依赖的一些最基本服务,因此,如果他们不希望在这些服务上大幅超支或者人手不足,他们就需要相当熟练地预测需求。因此,时间序列的所有方面对于政府目的都是相关的:存储、清理、探索和预测。

正如我在第二章中提到的,“找到”的时间序列数据在所有政府数据中所占比例非常高,只需进行一些重组即可看到。通常情况下,大多数政府数据集都是持续进行数据收集的结果,而不是时间的单一切片。然而,由于多种原因,政府数据集可能令人望而却步:

  • 由于组织约束或随着时间变化的政治力量而不一致的记录保持

  • 不透明或令人困惑的数据实践

  • 大型数据集,信息含量相对较低

尽管如此,审视政府数据集仍然可能非常有趣,无论是出于知识兴趣还是出于许多实际目的。在本章中,我们探讨了一个政府数据集,其中包括从 2010 年至今所有在纽约市投诉的情况,该数据集是通过拨打 311 市政热线获取的。由于数据集持续更新,书中看到的数据可能与您下载时看到的数据不同;在我准备本章时,您将比我拥有更多信息。尽管如此,结果应该是相当相似的。在本章中,我们将讨论以下几个主题:

  • 政府数据的有趣来源,包括我们将要分析的那个

  • 处理极大的纯文本数据文件

  • 在不将所有数据保留在内存中的情况下,对大数据集进行在线/滚动统计分析以及其他分析选项

获取政府数据

在“找到数据”类别中的政府数据集在数据一致性的角度来看可能是一场噩梦。这些数据集虽然具有时间戳,但通常是为了开放数据倡议而发布,而不是为了特定的时间序列目的。通常几乎没有关于数据时间戳惯例或其他记录惯例的信息可用。确认底层记录实践是否一致可能会很困难。¹

尽管如此,如果您冒险或渴望成为首批发现与政府活动相关的人类行为中有趣时间特征的人,您会发现自己生活在开放政府和开放数据的时代。近年来,许多各级政府已经更加努力地使其时间序列数据对公众透明化。以下是一些您可以获取包含时间序列组件的开放政府数据的例子:

  • 英国国家医疗服务的月度医院数据。该数据集令人惊讶地具有时间序列意识:其中包括名为“MAR 时间序列”的选项卡,并描述了记录约定及其随时间演变的方式。

  • 牙买加的开放数据门户还包括对时间序列数据的认识和认可,例如其时间戳的2014 年 Chikungunya 病例数据集以及相关数据报告,其中包括动画(即时间序列可视化)和流行曲线。

  • 新加坡的开放数据门户展示了大量数据集,并通过其主页上的两个时间序列图表来宣传某些数据的时间序列特性,如图 15-1 所示。

图 15-1. 新加坡开放数据网站主页上四幅图表中的两幅是用来展示有关该国重要信息的时间序列可视化。

您可能会注意到,所有我的例子都来自讲英语的地区,但当然他们并不垄断政府开放数据运动。例如,巴黎市塞尔维亚国家,以及African Development Bank Group都在运行开放数据网站。²

在本章的例子中,我们从纽约市开放数据门户中获取信息,选择这里因为纽约市是一个大而有趣的地方,也是我的家。在下一节中,我们将深入研究他们的 311 热线数据集。

探索大时间序列数据

当数据量足够大时,您将无法将其全部装入内存。在达到此限制之前,数据需要多大取决于您使用的硬件。³ 您最终需要理解如何逐个可管理的块迭代您的数据。对于那些熟悉深度学习的人来说,特别是涉及图像处理的人,您可能已经这样做过。在深度学习框架中,通常有 Python 迭代器,可以通过指定目录遍历数据集,每个目录中有许多文件。⁴

当我下载了 311 数据集时,它以 CSV 格式超过了 3 GB。 我的计算机无法打开它,所以我的第一个想法是使用标准的 Unix 操作系统选项,比如 head

不幸的是,打印出来的内容已经太大,无法在 Unix 命令行界面上管理,至少对于不熟悉 Unix 工具的人来说是这样:

## linux os command line
$ head 311.csv

Unique Key,Created Date,Closed Date,Agency,Agency Name,Complaint Type,Descripto
27863591,04/17/2014 12:00:00 AM,04/28/2014 12:00:00 AM,DOHMH,Department of Heal
27863592,04/17/2014 12:00:00 AM,04/22/2014 12:00:00 AM,DOHMH,Department of Heal
27863595,04/17/2014 10:23:00 AM,0417/2014 12:00:00 PM,DSNY,Queens East 12,Derel
27863602,04/17/2014 05:01:00 PM,04/17/2014 05:01:00 PM,DSNY,BCC - Queens East,D
27863603,04/17/2014 12:00:00 AM,04/23/2014 12:00:00 AM,HPD,Department of Housin
27863604,04/17/2014 12:00:00 AM,04/22/2014 12:00:00 AM,HPD,Department of Housin
27863605,04/17/2014 12:00:00 AM,04/21/2014 12:00:00 AM,HPD,Department of Housin

尽管内容很难处理,但这个视图足以显示出有几个时间戳以及其他有趣且有序的信息,比如地理坐标。 显然数据非常广泛,因此我们需要能够操纵此信息以获取我们想要的列。 ⁵

即使您是 Linux 的新手,也可以轻松了解一些简单的命令行工具,这些工具可以提供有用的信息。 我们可以获取 CSV 文件的行数,以便了解我们正在查看的规模,即我们有多少数据点。 这是一个一行命令:

## linux os command line
$ wc -l 311.csv
19811967 311.csv

我们可以看到自 2010 年以来,纽约市已经接到了大约 2000 万起 311 投诉。 这超过了每位居民的两次投诉。

有了这些知识,我们使用 R 的 data.table,知道它的 fread() 函数可以部分读取文件(在阅读有关 nrowsskip 参数的文档时可以看到),并且 data.table 在处理大数据集时非常高效。 我们可以使用这个来获取初始信息,如下面的代码所示:

## R
> df = fread("311.csv", skip = 0, nrows = 10)
> colnames(df)
 [1] "Unique Key"                     "Created Date"                  
 [3] "Closed Date"                    "Agency"                        
 [5] "Agency Name"                    "Complaint Type"                
 [7] "Descriptor"                     "Location Type"                 
 [9] "Incident Zip"                   "Incident Address"              
[11] "Street Name"                    "Cross Street 1"                
[13] "Cross Street 2"                 "Intersection Street 1"         
[15] "Intersection Street 2"          "Address Type"                  
[17] "City"                           "Landmark"                      
[19] "Facility Type"                  "Status"                        
[21] "Due Date"                       "Resolution Description"        
[23] "Resolution Action Updated Date" "Community Board"               
[25] "BBL"                            "Borough"                       
[27] "X Coordinate (State Plane)"     "Y Coordinate (State Plane)"    
[29] "Open Data Channel Type"         "Park Facility Name"            
[31] "Park Borough"                   "Vehicle Type"                  
[33] "Taxi Company Borough"           "Taxi Pick Up Location"         
[35] "Bridge Highway Name"            "Bridge Highway Direction"      
[37] "Road Ramp"                      "Bridge Highway Segment"        
[39] "Latitude"                       "Longitude"                     
[41] "Location"   

仅仅从阅读前 10 行,我们已经可以看到列名。 对于所有我列出的 NoSQL 处理时间序列数据的优点来说,在大数据集中,从一开始就知道列名可能是一件好事。 当然,对于 NoSQL 数据,有解决方法,但大多数都需要用户付出一些努力,而不是自动发生。

几列表明了有用的信息:

"Created Date"                  
"Closed Date"                              
"Due Date"                           
"Resolution Action Updated Date"             

这些在转换之前可能是字符类型,但一旦我们转换为 POSIXct 类型,我们就可以看到这些日期之间的时间跨度是什么样的:

## R
> df$CreatedDate = df[, CreatedDate := as.POSIXct(CreatedDate, 
>                                   format = "%m/%d/%Y %I:%M:%S %p")

在格式化字符串中,我们需要使用 %I 表示小时,因为它只以 01-12 的格式表示,以及 %p,因为时间戳包含 AM/PM 的指示。

为了了解这些日期如何分布,特别是在投诉创建与关闭之间的时间,让我们加载更多行并检查所谓的投诉生命周期的分布(即创建与关闭之间的时间跨度):

## R
> summary(as.numeric(df$ClosedDate - df$CreatedDate, 
>            units = "days"))
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's 
-75.958   1.000   4.631  13.128  12.994 469.737     113 

正如我们所看到的,这是一个广泛分布的情况。 一些投诉的闭环时间长达一年,令人惊讶的不仅是这些投诉的闭环时间长,有些投诉的闭环时间甚至是负数,甚至是极端负数。 如果负数时间约为 -365 天(一年),我们可能会想象这是数据输入问题,但对于 -75 天这样的数字,这种情况似乎不太可能。 这是我们需要研究的问题。

我们可以通过取创建日期的范围来发现另一个问题:

## R
> range(df$CreatedDate)
[1] "2014-03-13 12:56:24 EDT" "2019-02-06 23:35:00 EST"

鉴于这个 CSV 文件的大小以及它应该是持续更新的事实,令人惊讶的是前几行不是从标记了最早数据集的 2010 年开始的日期。我们本来期望 CSV 文件是持续追加的。更令人惊讶的是,2019 年的日期出现在前几行,并且 2014 年和 2019 年的日期都在同一前 10000 行中。这表明我们无法轻易确定文件中数据的日期顺序。我们可以通过执行行索引与日期的线图来可视化从一行到下一行的日期分布,就像我们在图 15-2 中所做的那样。

图 15-2

图 15-2。虽然代码的前 10000 行中的大部分日期似乎都在 2014 年,但它们也会向前跳到 2019 年——而且经常如此!

无法避免行动问题。如果我们想了解行为如何随时间变化,我们将不得不面对无序的数据。但我们有几个选择。

在迭代过程中进行上采样和聚合数据

一种选择是在迭代过程中对数据进行上采样,以构建具有聚合统计信息的压缩时间序列。我们可以从分析的开始选择分辨率和聚合计数,然后在迭代过程中计算这些内容。然后,我们可以在分析结束时对结果进行排序。这看起来就像是拥有从 2010 年到现在的所有日期的字典/列表,然后为每一行添加到适当的日期。这种方法将产生一个具有相对较少条目的列表/字典,然后我们可以基于日期在结束时对其进行排序。

这样做的好处是相对容易编码,并且是将数据清理、探索和分析结合到一个探索性步骤中的方法。缺点是详细数据仍然会在未排序的文件混乱中丢失,因此如果有特定的时间段感兴趣,我们将不得不搜索整个未排序的文件以找到所有相关条目。

由于我们已经在第二章中做过上采样的示例,我把这部分留给读者作为练习。

对数据进行排序

另一种选择是对数据进行排序。考虑到文件的大尺寸和相对无序的日期,这是一项艰巨的任务,从我们观察到的情况来看。即使对于 2014 年的数据,日期似乎也不是按顺序排列的。因此,我们没有任何迹象表明我们可以信任数据的任何切片,因此我们应该将其视为随机顺序的数据堆叠。

对整个文件进行排序将消耗极大的内存,但有两个理由使得这样做是值得的。一个是我们只需要排序一次,然后可以保存结果,以供我们想要进行的任何后续分析。第二个是我们可以保留完整的详细级别,这样如果我们在分析中发现感兴趣的特定时间段,我们可以检查数据的所有细节,以了解正在发生的情况。

具体来说,思考我们如何完成这个任务,我们有几个选择:

  • Linux 有一个命令行工具用于排序。

  • 大多数数据库都可以对数据进行排序,所以我们可以将数据转移到数据库中,并让数据库来处理它。⁷

  • 我们可以想出自己的排序算法并实施它。我们需要制定一些不消耗大量内存的东西。很可能我们的努力几乎无法与预打包排序选项中可用的功能匹配。

我们选择使用 Linux 命令行工具。虽然可能需要一些时间来做到这一点,但我们将开发一个新的生活技能,并获得一个对于这个大文件而言实现良好且正确的排序。

我们首先创建一个小的测试文件以便使用:

## linux command line
$ head -n 1000 311.csv | tail -n 999  >  test.csv

注意,这涉及到head(即打印开头)和tail(即打印结尾)命令。head 将包括文件的第一行,对于这个文件来说,它提供了列名。如果我们将其包含并与数值一起排序,它将不会保留列名作为文件顶部行,因此我们在排序之前将其删除。

如果您使用基于 Linux 的操作系统,那么可以如下应用sort命令:

## linux command line
$ sort --field-separator=',' --key=2,3 test.csv > testsorted.csv

在这种情况下,我们确定字段分隔符,然后指示我们想要按第二和第三列排序,即按创建日期和关闭日期排序(这些信息我们只能从先前检查的文件中得知)。我们将其输出到一个新文件中,因为将其输出到标准输出中并不会有太大帮助。

现在我们可以在 R 中检查排序后的文件,但不幸的是我们会发现这也不会返回一个排序后的文件。回到我们在 CSV 文件上运行head命令的几页前,你就会明白为什么了。我们按日期列进行排序(这将被处理为字符串,而不是日期感知方式)。然而,当前日期的格式是从月份开始,所以我们最终会得到一个按月份而不是按整体时间排序的日期列,这是当我们审查由前面命令生成的“排序后”CSV 时所见到的:

  1: 02/02/2019 10:00:27 AM 02/06/2019 07:44:37 AM
  2: 03/27/2014 07:38:15 AM 04/01/2014 12:00:00 AM
  3: 03/27/2014 11:07:31 AM 03/28/2014 01:09:00 PM
  4: 03/28/2014 06:35:13 AM 03/28/2014 10:47:00 PM
  5: 03/28/2014 08:31:38 AM 03/28/2014 10:37:00 AM
 ---                                              
995: 07/03/2017 12:40:04 PM 07/03/2017 03:40:02 PM
996: 07/04/2017 01:30:35 AM 07/04/2017 02:50:22 AM
997: 09/03/2014 03:32:57 PM 09/04/2014 04:30:30 PM
998: 09/05/2014 11:17:53 AM 09/08/2014 03:37:25 PM
999: 11/06/2018 07:15:28 PM 11/06/2018 08:17:52 PM

正如我们所见,排序作为字符串排序而不是日期排序是有道理的。事实上,这指出了使用正确的 ISO 日期格式的优点之一,即当作为字符串进行排序时,它仍将正确排序,与之前的格式不同。这是“现有”时间序列数据常见问题的一个示例:可用的时间戳格式可能不利于时间序列分析。

我们重新访问纽约市开放数据界面,看看是否有解决此格式问题的方法(见图 15-3)。

图 15-3. 纽约市开放数据门户提供了通过网页界面进行排序的功能,这似乎适用于这个大数据集的任何列。考虑到排序所需的计算能力,这是一个令人印象深刻的免费资源。

对数据的网页表格视图的一瞥似乎与我们的 CSV 文件一致,即数据似乎没有按时间顺序排序。然而,我们看到有排序选项,因此我们将其应用于数据集,确实在存储大数据集时会有一个可以理解的等待时间后更新数据。这将是一个很好的解决方案,但不幸的是,下载得到的 CSV 文件仍然是无序的。

此时我们可以探索其他几种解决方案。我们可以查看使用开放数据的 API 而不是网页界面是否提供了更易处理的日期或确保排序的方法。我们可以使用更多的 Linux 命令行工具,比如awk,来提取时间戳的不同部分到不同的列或者合并到一个带有 ISO 格式的重新排列列中。

相反,我们采用了简化的方法,看看我们现有的工具是否能够处理这个 CSV,如果我们只读取特定的列。我对这个数据集感兴趣的第一个问题是创建 311 投诉与关闭该投诉之间的滞后时间可能随时间变化的情况。在这种情况下,我假设我只需要两列:CreatedDateClosedDate。我将看看是否可以只读取这两列,这在数量和字符计数方面都是所有列的一个小部分(因为某些列非常长),在我的轻便笔记本电脑上是可能的。(我还可以探索修复问题的懒惰方式,即进行硬件升级,无论是暂时的还是永久的。)

现在我们能够读取数据的所有行,并且我们随后的分析将基于整个数据集而不仅仅是前 1000 行:

## R
> ## read in only the columns of interest
> df = fread("311.tsv", select = c("Created Date", "Closed Date"))
> 
> ## use data.table's recommended 'set' paradigm to set col names
> setnames(df, gsub(" ", "", colnames(df)))
> 
> ## eliminate rows with a blank date field
> df = df[nchar(CreatedDate) > 1 & nchar(ClosedDate) > 1]
> 
> ## convert string date columns to POSIXct
> fmt.str = "%m/%d/%Y %I:%M:%S %p"
> df[, CreatedDate := as.POSIXct(CreatedDate, format = fmt.str)]
> df[, ClosedDate  := as.POSIXct(ClosedDate,  format = fmt.str)]
> 
> ## order in chronological order of CreatedDate
> setorder(df, CreatedDate)
> 
> ## calculate the number of days between creating and closing
> ## a 311 complaint
> df[, LagTime := as.numeric(difftime(ClosedDate, CreatedDate, 
>                            units = "days"))]

这是在 2015 年生产的一款轻便笔记本电脑上完成的,因此您可能也可以用于工作或家庭使用的设备进行操作。对于“大数据”新手来说,1900 万行数据并不算什么大不了的事情,但实际情况常常如此。我们实际上只需要数据的一小部分来回答相关的时间序列问题。

当我们查看LagTime列时,我们注意到一些令人惊讶的错误数字——数以万计的天数,甚至是负数。我们排除了这些数字,并在一定程度上根据数据随机样本的分布加以限制:

## R
> summary(df$LagTime)
Min.  1st Qu.   Median     Mean  3rd Qu.     Max.     NA's 
-42943.4      0.1      2.0      2.0      7.9 368961.1   609835 

> nrow(df[LagTime < 0]) / nrow(df)
[1] 0.01362189

> nrow(df[LagTime > 1000]) / nrow(df)
[1] 0.0009169934

> df = df[LagTime < 1000]
> df = df[LagTime > 0]

> df.new = df[seq(1, nrow(df), 2), ]
> write.csv(df.new[order(ClosedDate)], "abridged.df.csv")

我们丢弃负滞后时间的数据,因为我们缺乏相关文档或领域知识来了解这些数据的含义。我们也会拒绝那些我们认为有不切实际或者极端不适的数值,例如关闭 311 投诉所需的滞后时间超过 1000 天的数据。⁸

警告

在丢弃数据时请谨慎。在这个练习中,由于处理 311 投诉所需的滞后时间过长,导致我们丢弃了约 1.3%的数据,这些投诉可能因为太长而无法解释,或者因为是负数,暗示着数据录入错误或者其他我们单靠数据无法解决的问题。

鉴于我们的问题与数据整体分布有关,这些少数数据点不太可能影响我们关于分布问题的分析。然而,在实际应用中,您需要调查这些数据点及其可能对分析任务的后续影响。这并非时间序列特定的建议,而只是一般实践的问题。

现在我们能够同时将所有感兴趣的数据保留在内存中,我们可以全面地对序列提出问题。然而,我们感兴趣的问题是滞后时间的分布是否随时间变化而改变。我们可以通过时间序列的滑动或滚动窗口来实现这一点,但这是计算密集型的;当我们在数据上滑动窗口时,我们需要重复执行许多相关的计算。同时,我们也希望探索一种可以应用于实时数据流的方法,因为我们可以想象将这个项目持续到当前数据的情况。因此最好不要无限期地存储这个多 GB 的数据文件。

时间序列数据的在线统计分析

我们将使用一个名为P-square 算法的在线分位数估算工具,稍作修改以使其具有时间感知性。原始算法假定存在一个稳定的分布用于推断分位数,但我们希望考虑分布随时间变化的情况。与指数加权移动平均类似,我们通过减少早期观察的权重来引入时间感知性,并且每次有新的测量值时都以相同的方式引入一个因子来缩小以前测量的权重(参见图 15-4)。

图 15-4. 我们使用 P-square 算法进行在线分位数估计的计算结构。我们维护一系列标记,指示我们认为分位数在哪里以及所有小于或等于每个分位数的数据点的累积计数。

该算法需要一些记录,使得在更面向对象的编程语言中更容易实现,因此我们将从 R 切换到 Python。但是,请注意,我们将使用 R 中预处理的数据,因为data.table包在处理大数据时比 Python 中的工具具有更好的性能。

我们实现的 P-square 算法版本形成值的直方图。因此,当我们创建一个PQuantile对象时,为我们的直方图计数、箱位和观测的累计总和分配了预设数量的箱:

## python
>>> ## imports and a utilit lambda
>>> import bisect
>>> import math
>>> 
>>> sign = lambda x: (1, -1)[x < 0]
>>> 
>>> ## beginning of class definition
>>> class PQuantile:
>>>     def __init__(self, b, discount_factor):
>>>         ## initialization
>>>         self.num_obs = 0 ## self-explanatory
>>>         ## counts per quantile 
>>>         self.n = [i for i in range(self.b+1)] 
>>>         self.q = [] ## the estimated quantile values
>>> 
>>>         ## b is the number of quantiles, 
>>>         ## including the 0th and 100th
>>>         ## (min and max values) quantile
>>>         self.b = b
>>>         ## the discount factor sets how we adjust prior counts
>>>         ## when new data is available
>>>         self.discount_factor = discount_factor

有两个可配置参数:用于估计的均匀分布的分位数的数量和旧观测的折现因子。

其他类成员包括观测数量的累计总和(这将根据可配置的时间折现因子进行时间折现),估计的分位数以及小于或等于给定分位数值的观测计数的累计总和。

只有一个公共函数,其作用是接受下一个观测值。当有新的观测到来时,结果取决于序列的早期阶段。对于前self.b个值,接受这些输入并且必然构成分位数的估计。self.q被排序,使其值反映分位数。

例如,假设您输入了b = 5,用于期望的分位数值,然后输入了序列2, 8, 1, 4, 3。在这个序列结束时,self.q将等于[1, 2, 3, 4, 8]self.n,小于或等于每个分位数的值的计数,将等于[1, 2, 3, 4, 5],这是在__init__中初始化的值:

## python
>>>     def next_obs(self, x):        
>>>         if self.num_obs < (self.b + 1):
>>>             self.q.append(x)
>>>             self.q.sort()
>>>             self.num_obs = self.num_obs + 1
>>>         else:
>>>             self.next_obs2(x)
>>>             self.next_obs = self.next_obs2

一旦您拥有超过self.b个值,事情就变得有趣起来。在这一点上,代码开始决定如何组合值以估计分位数,而无需保留所有数据点以进行重复分析。在这种情况下,P-square 算法通过我们称之为self.next_obs2来执行此操作:

## python
>>>      def next_obs2(self, x):
>>>         ## discounting the number of observations
>>>         if self.num_obs > self.b * 10:
>>>             corrected_obs = max(self.discount_factor * self.num_obs,  
>>>                                self.b)
>>>             self.num_obs = corrected_obs + 1
>>>             self.n = [math.ceil(nn * self.discount_factor)
>>>                                           for nn in self.n]
>>>         
>>>             for i in range(len(self.n) - 1):
>>>                 if self.n[i + 1] - self.n[i] == 0:
>>>                     self.n[i+1] = self.n[i + 1] + 1
>>>                 elif self.n[i + 1] < self.n[1]:
>>>                     ## in practice this doesn't seem to happen
>>>                     self.n[i + 1] = self.n[i] - self.n[1 + 1] + 1
>>>         else:
>>>             self.num_obs = self.num_obs + 1            
>>> 
>>>         k = bisect.bisect_left(self.q, x)
>>>         if k is 0:
>>>             self.q[0] = x
>>>         elif k is  self.b+1 :
>>>             self.q[-1] = x
>>>             k = self.b          
>>>         if k is not 0:
>>>             k = k - 1
>>> 
>>>         self.n[(k+1):(self.b+1)] = [self.n[i] + 1
>>>                                    for i in range((k+1), 
>>>                                                  (self.b+1))]                
>>>         for i in range(1, self.b):
>>>             np = (i)*(self.num_obs - 1 )/(self.b) 
>>>             d = np - self.n[i]
>>>             if (d >= 1 and (self.n[i+1] - self.n[i]) > 1):
>>>                 self._update_val(i, d)
>>>             elif (d <= -1 and (self.n[i-1] - self.n[i]) < -1):
>>>                 self._update_val(i, d)  

理想情况下,第i个分位数值应均匀分布,以便恰好有i/b × 总观测值小于它。如果不是这种情况,标记将向左或向右移动一个位置,并且其相关的分位数值将使用从直方图的局部抛物线形状假设推导出的公式进行修改。该公式根据局部抛物线形状的假设确定是否需要调整特定分位数值和计数的d变量的大小。

如果值确实需要调整,那么另一个决定就是确定抛物线还是线性调整是否合适。这在以下代码中实现。更多细节请参见原始论文中的推导。这篇论文很棒,因为它使用的数学技术易于理解,同时还清楚地说明了如何实施该方法以及如何测试您的实施方法:

## python
>>>     ## overall update
>>>     ## as you can see both self.q and self.n are updated
>>>     ## as a quantile position is shifted
>>>     def _update_val(self, i, d):
>>>         d = sign(d)
>>>         qp = self._adjust_parabolic(i, d)
>>>         if self.q[i] < qp < self.q[i+1]:
>>>             self.q[i] = qp 
>>>         else:
>>>             self.q[i] = self._adjust_linear(i, d)
>>>         self.n[i] = self.n[i] + d
>>> 
>>>     ## this is the primary update method
>>>     def _adjust_parabolic(self, i, d):
>>>         new_val = self.q[i]
>>>         m1 =  d/(self.n[i+1] - self.n[i-1])
>>>         s1 = (self.n[i] - self.n[i-1] + d) *
>>>                  (self.q[i+1] - self.q[i]) /
>>>                  (self.n[i+1] - self.n[i])
>>>         s2 = (self.n[i+1] - self.n[i] - d) *
>>> 
>>>     ## this is the backup linear adjustment when parabolic
>>>     ## conditions are not met
>>>     def _adjust_linear(self, i, d):
>>>         new_val = self.q[i]
>>>         new_val = new_val + d * (self.q[i + d] - self.q[i]) /
>>>                                   (self.n[i+d] - self.n[i])
>>>         return new_val

对于这种方法的简易性的视角,所有的班级代码都在这里列出:

## python
>>> class PQuantile:
>>>     ## INITIALIZATION
>>>     def __init__(self, b, discount_factor):
>>>         self.num_obs = 0
>>>         self.b = b
>>>         self.discount_factor = discount_factor
>>>         self.n = [i for i in range(self.b+1)]        
>>>         self.q = []
>>> 
>>>     ## DATA INTAKE
>>>     def next_obs(self, x):        
>>>         if self.num_obs < (self.b + 1):
>>>             self.q.append(x)
>>>             self.q.sort()
>>>             self.num_obs = self.num_obs + 1
>>>         else:
>>>             self.next_obs2(x)
>>>             self.next_obs = self.next_obs2
>>>             
>>>     def next_obs2(self, x):
>>>         ## discounting the number of observations
>>>         if self.num_obs > self.b * 10:
>>>             corrected_obs = max(self.discount_factor 
>>>                                       * self.num_obs,  
>>>                                self.b)
>>>             self.num_obs = corrected_obs + 1
>>>             self.n = [math.ceil(nn * self.discount_factor) 
>>>                                           for nn in self.n]
>>>         
>>>             for i in range(len(self.n) - 1):
>>>                 if self.n[i + 1] - self.n[i] == 0:
>>>                     self.n[i+1] = self.n[i + 1] + 1
>>>                 elif self.n[i + 1] < self.n[1]:
>>>                     ## in practice this doesn't seem to happen
>>>                     self.n[i + 1] = self.n[i] - self.n[1 + 1] + 1
>>>         else:
>>>             self.num_obs = self.num_obs + 1            
>>> 
>>>         k = bisect.bisect_left(self.q, x)
>>>         if k is 0:
>>>             self.q[0] = x
>>>         elif k is  self.b+1 :
>>>             self.q[-1] = x
>>>             k = self.b          
>>>         if k is not 0:
>>>             k = k - 1
>>> 
>>>         self.n[(k+1):(self.b+1)] = [self.n[i] + 1 
>>>                                    for i in range((k+1), 
>>>                                                   (self.b+1))]                
>>>         for i in range(1, self.b):
>>>             np = (i)*(self.num_obs - 1 )/(self.b) 
>>>             d = np - self.n[i]
>>>             if (d >= 1 and (self.n[i+1] - self.n[i]) > 1):
>>>                 self._update_val(i, d)
>>>             elif (d <= -1 and (self.n[i-1] - self.n[i]) < -1):
>>>                 self._update_val(i, d)    
>>> 
>>>     ## HISTOGRAM ADJUSTMENTS
>>>     def _update_val(self, i, d):
>>>         d = sign(d)
>>>         qp = self._adjust_parabolic(i, d)
>>>         if self.q[i] < qp < self.q[i+1]:
>>>             self.q[i] = qp 
>>>         else:
>>>             self.q[i] = self._adjust_linear(i, d)
>>>         self.n[i] = self.n[i] + d
>>> 
>>>     def _adjust_parabolic(self, i, d):
>>>         new_val = self.q[i]
>>>         m1 =  d/(self.n[i+1] - self.n[i-1])
>>>         s1 = (self.n[i] - self.n[i-1] + d) * 
>>>                  (self.q[i+1] - self.q[i]) /
>>>                  (self.n[i+1] - self.n[i])
>>>         s2 = (self.n[i+1] - self.n[i] - d) * 
>>>                  (self.q[i] - self.q[i-1]) /
>>>                  (self.n[i] - self.n[i-1])
>>>         new_val = new_val + m1 * (s1 + s2)
>>>         return new_val
>>>             
>>>     def _adjust_linear(self, i, d):
>>>         new_val = self.q[i]
>>>         new_val = new_val + d * (self.q[i + d] - self.q[i]) /
>>>                                   (self.n[i+d] - self.n[i])
>>>         return new_val

现在我们有了这种时间导向的方法,我们应该确信它在玩具示例中运行得相当好。我们首先尝试从一个分布中抽样数据点,然后突然切换到另一个分布。在每种情况下,我们都在采样第 40 百分位数,尽管基于配置的 10 个直方图点,我们维护的直方图显示了 0、10、20…90、100 百分位数。这很有帮助,因为这意味着我们可以对变化的分布有相当详细的描述。对于这个玩具示例,我们只关注第 40 百分位数(qt.q[4]),这导致了图 15-5 中的绘图:

## python
>>> qt = PQuantile(10, `0.3`)               
>>> qt_ests = []
>>> 
>>> for _ in range(100):
>>>    b.next_obs(uniform())
>>>    if len(b.q) > 10:
>>>        qt_ests.append(qt.q[4])
>>> for _ in range(100):
>>>    b.next_obs(uniform(low = 0.9))
>>>    qt_ests.append(qt.q[4])
>>> 
>>> plt.plot(qt_ests)

图 15-5. 当我们大幅度折扣旧测量数据(乘以较小的折扣因子)时,我们更快地看到底层分布已经发生变化。

相反,当我们对较大折扣因子的情况下较少折扣旧测量时,我们看到对于变化的分位数估计较慢(见图 15-6):

## python
>>> qt = PQuantile(10, `0.8`)               
>>> qt_ests = []
>>> 
>>> for _ in range(100):
>>>    b.next_obs(uniform())
>>>    if len(b.q) > 10:
>>>        qt_ests.append(qt.q[4])
>>> for _ in range(100):
>>>    b.next_obs(uniform(low = 0.9))
>>>    qt_ests.append(qt.q[4])
>>> 
>>> plt.plot(qt_ests)

图 15-6. 当我们对较老的测量数据的折扣较少(乘以较大的折扣因子)时,我们的分位数估计较慢地认识到底层分布已经发生变化。

现在我们将这种滚动分位数应用于我们数据的一个子集(参见图 15-7)。我们并不对整个数据集进行操作,不是因为计算上的挑战,而是因为将所有记录的分位数绘制到我的日常笔记本电脑上过于繁重!

## python
>>> import numpy
>>> nrows = 1000000
>>> qt_est1 = np.zeros(nrows)
>>> qt_est2 = np.zeros(nrows)
>>> qt_est3 = np.zeros(nrows)
>>> qt_est4 = np.zeros(nrows)
>>> qt_est5 = np.zeros(nrows)
>>> qt_est6 = np.zeros(nrows)
>>> qt_est7 = np.zeros(nrows)
>>> qt_est8 = np.zeros(nrows)
>>> qt_est9 = np.zeros(nrows)
>>> for idx, val in enumerate(df.LagTime[:nrows]):
>>>     qt.next_obs(val)
>>>     if len(qt.q) > 10:
>>>         qt_est1[idx] = qt.q[1]
>>>         qt_est2[idx] = qt.q[2]
>>>         qt_est3[idx] = qt.q[3]
>>>         qt_est4[idx] = qt.q[4]
>>>         qt_est5[idx] = qt.q[5]
>>>         qt_est6[idx] = qt.q[6]
>>>         qt_est7[idx] = qt.q[7]
>>>         qt_est8[idx] = qt.q[8]
>>>         qt_est9[idx] = qt.q[9]
>>> 
>>> plot(qt_est9, color = 'red')
>>> plt.plot(qt_est7, color = 'pink')
>>> plt.plot(qt_est5, color = 'blue')
>>> plt.plot(qt_est3, color = 'gray')
>>> plt.plot(qt_est2, color = 'orange'

图 15-7. 数据集中前 100,000 行按关闭日期排序时,时间内的第 90、70、50、30 和 20 百分位数值。由于按关闭日期排序,可能会发现许多迅速解决的 311 投诉集中在前期,这解释了数据集前部分中更小的分位数估计。⁹

图 15-7 显示了按关闭日期排序时数据集前 100,000 行的时间内第 90、70、50、30 和 20 百分位数值。由于按关闭日期排序,可能会发现许多迅速解决的 311 投诉集中在前期,这解释了数据集前部分中更小的分位数估计。⁹

看起来分布有变化吗?从视觉上看,似乎有几个原因。其中一个是左截尾,稍后会简要描述,这反映了我们如何对数据进行排序和选择。我们按ClosedData列排序数据,并结合这个数据集似乎没有无限的回溯期(也就是说,假设在某个日期之前提交的 311 投诉并未进入这个系统),这使得我们在开始日期看到的滞后时间似乎较短。换句话说,这种看似随时间变化的现象只是我们不完整的数据(以及不完整的底层数据集)的产物,再加上我们选择排序的结果。

另一方面,我们可以看到仍然有特征表明随时间分布发生变化。分位数曲线估计中出现高峰和低谷,我们甚至可以考虑我们的曲线中是否可能存在周期性行为,因为由于外生组织因素的原因,分位数值可能在可预测的时间上升和下降(也许是为了在月底之前结案投诉的推动,或者某些资金周期增加了特定时间可用于结案投诉的工人数量)。

鉴于我们现在有了初步结果,最好的选择是确定一些重要日期(即我们在哪些日期看到尖峰或周期性行为?),并尝试将它们与我们可以确认的工作节奏相关的任何制度事实进行交叉参考。我们还应该运行模拟,评估我们认为左截尾如何在不同情景下影响早期分位数估计。通过这种方式,我们可以更好地理解系统未知方面的定性和定量内容,这些信息对于最终确定解决时间分布是否随时间演变以及如何演变非常有帮助。

比方说,如果我们采取了这些步骤——接下来呢?我们需要寻找一种方法来比较分布的相似性或差异性,只使用分布的分位数而不是所有样本点。我们可以通过运行模拟/引导整个过程来做到这一点。这将带来一个答案,我们可以通过编码我们的模拟来完全阐明和控制模型所依赖的假设。事实上,许多进行这种比较的统计方法也专注于引导法。

余下的问题

我们的可视化表明了新的查询需求。其中一个与周期性或季节性行为的可能性相关。所有估计的分位数都似乎有周期性的起伏。我们可以考虑进一步调查这一点,并有几种方法可以做到这一点:

  • 我们可以尝试将谐波(正弦和余弦)拟合到这些分位数曲线上,看看是否会出现共同的周期性。

  • 我们可以将分位数本身建模为 ARIMA 或 SARIMA 过程,并寻找季节性的证据。这还需要进行初步步骤,如探索我们将作为时间序列建模的曲线的 ACF 和 PACF。

  • 我们可以向运行 311 服务的机构索取更多信息,看看他们是否认识到任何由其组织结构和运营程序引起的周期性行为。

除了周期性行为之外,在索引约为 70,000 位置附近,我们还可以看到估计分位数值的跳跃。考虑到所有分位数都跳跃了,似乎不太可能仅由单个或少数异常值引起。我们可以通过几种方式进行调查:

  • 回到这段时间内的原始数据,看看有哪些特征可能可以提供解释。是否有 311 投诉激增?或者是某种类型的投诉激增,这类投诉通常需要更长时间来解决?

  • 或者,我们可以重新审视原始数据,以确定分位数跳跃的大致日期,并与本地新闻进行交叉参考,最好是在某人的帮助下指引我们正确的方向。来自机构的某人的帮助,或者对城市政府了解深刻的人的帮助,可能是最有价值的。然而,也有可能,这个日期可能对应于纽约市的一个重大事件,例如 2012 年的超级飓风桑迪,这可以解释这个跳跃。

进一步的改进

我们还可以使这个算法更加时间感知。我们对 P-square 算法的修改排除了先前的观察,但它假定所有观察值是均匀分布的。这体现在下一个观察值的输入中没有时间戳,并且始终应用相同的折扣因子。我们可以通过使用时间变化到旧信息相对于新信息的折扣来设计一个更灵活的算法,使折扣取决于自上次更新以来经过的时间变化。这对我们的 311 数据集也会更加精确。这留给读者作为一个练习,但只涉及更改几行代码。提示:折扣因子应该成为时间的函数。

我们还可以探索其他通过时间估计分位数的方法,无论是在线还是窗口测量。由于在线数据的重要性越来越大,特别是对于在线大数据,关于这个主题有大量新兴研究。统计学和机器学习方法在过去几年已经处理了这个问题,并且有许多适合实际数据科学家的可接近的学术论文。

更多资源

Ted Dunning 和 Otmar Ertal,“使用 t-Digest 计算极其精确的分位数,” 研究论文,2019,https://perma.cc/Z2A6-H76H。

f-digest 算法用于极高效且灵活地计算在线时间序列数据的分位数,正迅速成为处理非平稳分布情况下的在线时间序列分位数估计的领先技术。该方法在多种语言中都有实现,包括 Python 和高性能的 C++以及 Go 变体。该方法特别有用,因为无需预先决定感兴趣的分位数,而是将整个分布建模为一组聚类,从中可以推断出任何你想要的分位数。

Dana Draghicescu, Serge Guillas, 和 Wei Biao Wu, “非平稳时间序列的分位曲线估计与可视化,” 计算与图形统计杂志 18 卷, 1 期 (2009): 1–20, https://perma.cc/Z7T5-PSCB.

本文展示了几种非参数方法来建模时间序列分位数估计中的非平稳分布。它之所以有用,是因为涵盖了真实世界数据、模拟数据和非标准分布(如非高斯分布)。此外,该文章提供了样例代码(对于统计学术期刊文章来说是不寻常的),尽管可能需要付费才能获取。

András A. Benczúr, Levente Kocsis, 和 Róbert Pálovics, “大数据流中的在线机器学习,” 研究论文,2018 年,https://perma.cc/9TTY-VQL3.

该参考资料讨论了与时间序列相关的各种常见机器学习任务的技术方法。特别有趣的是关于多种机器学习任务的在线数据处理的讨论,以及关于在线任务并行化的技术提示。

Sanjay Dasgupta, “在线和流式聚类算法,” 计算机科学与工程,加利福尼亚大学圣迭戈分校,2008 年春季,https://perma.cc/V3XL-GPK2.

虽然不专门针对时间序列数据,这些讲义总结了关于在线数据的无监督聚类的一般概述。这些讲义足以帮助您开始构建时间序列特定应用的潜在解决方案。

Ruofeng Wen 等, “多时域分位数递归预测器,” 研究论文,2017 年 11 月,https://perma.cc/22AE-N7F3.

亚马逊的这篇研究论文提供了一个例子,展示了如何有效地使用数据中的分位数信息来训练递归神经网络,以便进行时间序列预测。研究人员展示了如何有效地训练神经网络,使其能够生成概率评估而非点估计。本文很好地阐明了分位数信息的另一个潜在用例,这在时间序列分析中是一个未充分利用的资源。

¹ 请注意,像美国的就业报告等备受追捧的时间序列数据非常重视时间序列,经过精心清洗和格式化。然而,这类数据已经被充分利用,不太可能为即将成为研究人员或企业家的人提供新的时间序列应用。

² 当然,您的访问权限可能取决于您的语言能力(或者一位乐意帮助的同事的语言能力)。

³ 如果您的组织总是通过扩展解决 RAM 不足的问题,那么您的做法是错误的。

⁴ 作为灵感,阅读TensorFlow 关于数据集和相关类的文档

⁵ 请注意,精通 Unix 系统的人可以轻松使用awk在命令行或简单的 shell 脚本中有效地操作 CSV。通常,这些工具在处理大数据时非常高效且实现良好,与不幸的是,许多常用的 R 和 Python 数据分析工具相比。如果您在使用您喜爱的数据处理工具时遇到问题,学习一些 Unix 命令行工具来补充可能是个好主意,尤其是在处理大数据的情况下。

⁶ 请注意,Python 的 Pandas 提供了类似的功能

⁷ 我们必须选择一个能提供这种功能的数据库——并非所有数据库都能提供,尤其是不是所有时间序列数据库都能假定数据按时间顺序进入数据库。

⁸ 虽然 1,000 天可能看起来很长,但事实上,我个人已经等了几年,提交了多次 311 投诉,但仍未解决。树死后,我一直在等纽约市在我家门前替换树木——每次我打电话给 311,他们都告诉我他们正在处理。

⁹ 第 20 百分位数非常小,在这里呈现的其他分位数尺度上几乎看不见。它看起来像其他分布的基础上的一条实线,但如果切换到对数尺度或单独绘制它,您就能更清楚地看到它。

第十六章:时间序列包

在过去几年里,大型科技公司发布了许多关于如何处理他们收集的大量时间序列数据的包和论文,这些数据来自拥有庞大客户群、先进业务分析和众多预测与数据处理需求的数字化组织。在本章中,我们将讨论与这些不断扩展的时间序列数据集相关的主要研究和开发领域,具体包括:大规模预测和异常检测。

大规模预测

对许多大型科技公司来说,处理时间序列是一个日益重要的问题,并且是自然而然地在他们的组织内部产生的问题。随着时间的推移,其中一些公司通过开发智能化、专门针对“大规模预测”的自动化时间序列包来响应这一问题,因为在各种领域需要大量的预测。以下是谷歌两位开发公司自动化预测包的数据科学家描述他们的产品动机的情况,在2017 年的一篇博客文章中有所强调:

随着谷歌在其成立的第一个十年中迅速成长,对时间序列预测的需求也在迅速增长。各种业务和工程需求导致了多种预测方法的出现,大多依赖于直接的分析支持。 这些方法的数量和多样性,以及在某些情况下它们的不一致性,迫使我们尝试统一、自动化和扩展预测方法,并通过可以可靠部署的工具来分发结果。也就是说,我们试图开发出能够在谷歌内部实现准确大规模时间序列预测的方法和工具。

面对如此多的相关数据和如此多的预测任务,引入足够多的分析员来生成每一个组织感兴趣的预测,既昂贵又具有组织挑战性。因此,这些方案都转向了“足够好”的理念;也就是说,一个相对不错的预测远胜过在等待完美、由具有领域知识的时间序列专家精心制作的预测时一无所获。接下来我们详细讨论两个自动化预测框架,来自 Google 和 Facebook。

谷歌的工业内部预测

Google 已经发布了一些信息关于其内部自动化预测工具,这是由该公司搜索基础设施部门的几位数据科学家领导的努力所推动。任务是编写一个统一的方法,在整个组织中进行自动化预测。由于任务是自动化的,这意味着结果必须是可靠的——不能偏离轨道太远,并且必须伴随有关预测不确定性的一些估计。此外,由于团队寻求的是一个广泛适用的解决方案,该方法论必须解决与时间序列数据集相关的人类常见问题,例如季节性、缺失数据、假期以及随时间演变的行为。

Google 在此方案中推出了三个与前几章讨论内容相关的有趣步骤:

  1. 自动化和广泛的数据清洗和平滑处理

  2. 时间聚合和数据的地理/概念解聚

  3. 合并预测与基于模拟的不确定性估计生成

我们将依次讨论每个步骤。

自动化和广泛的数据清洗和平滑处理

在早前提到的非官方 Google 数据科学博客文章中,该内部项目的两位领导指出数据清洗和平滑处理了许多问题:

不完美数据的影响

  • 缺失数据

  • 异常值检测

  • 水平变化(例如由于产品推出或行为突然但永久改变)

  • 数据转换

不完美数据是生活中的一部分。由于技术故障可能会导致缺失数据。异常值可能具有类似的原因或者是“真实值”,但如果它们不太可能重复出现,则不值得包含在预测中。水平变化(制度变化)可能由于多种原因而发生,包括基础行为发生了 drast ic 改变(不断发展的世界),正在测量的事物发生了 drast ic 改变(不断发展的产品),或者正在记录的事物发生了 drast ic 改变(不断发展的日志记录)。最后,数据可以以远离许多时间序列模型所假设的正态分布或平稳性的分布形式呈现。 Google 方法依次处理每个数据不完美之处,使用自动化方法来检测和“修正”这些问题。

与日历相关的效应

  • 年度季节性

  • 周期性季节性(一周中的某一天的影响)

  • 假期

对于谷歌这样一个全球运营和用户遍布的组织来说,处理与日历相关的效应特别棘手。全球不同地区的年季节性会有很大不同,尤其是在具有自己气候模式的对半球和具有不同基础日历的文化中。正如博客文章指出的那样,有时同一个假期可能在同一个(格里高利)年历中发生多次。同样,某些人群所操作的“季节”在格里高利年历中可能会在年内发生变化,这通常是因为伊斯兰历与格里高利历有不同的周期性。

时间聚合和地理/概念分解

谷歌团队发现,对于他们大部分感兴趣的预测,周数据效果很好,因此在上一步清洗数据后,他们将其聚合成周增量以便进行预测。在这个意义上,他们执行了时间聚合。

然而,团队也发现将数据进行分解很有帮助,有时是按地理区域,有时是按类别(如设备类型),有时是按多种因素的组合(如地理区域按设备类型)。团队发现,在这种情况下,为分解的子系列做预测,然后将这些预测调和以生成全局预测,如果子系列和全局系列都感兴趣的话,这种方法更有效。我们在第六章讨论了适合大规模和高度并行处理的层次时间序列拟合的各种方法,而这种方法反映了从较低级别预测开始并向上传播的方法论。

结合预测和基于模拟的不确定性估计

谷歌采用集成方法,结合多种不同的预测模型结果来生成最终预测。这对多个原因都很有用。谷歌认为这种方法生成了一种智囊团式的预测,汲取了许多表现良好且有充分理由的预测模型的好处(如指数平滑、ARIMA 等)。此外,预测集成生成了预测分布,为确定其中是否有不同于“群体”的预测提供了依据。最后,集成提供了量化预测不确定性的方法,谷歌也通过时间中的错误前向传播模拟来做到这一点。

最终,正如数据科学团队承认的那样,谷歌的方法受益于大规模和高度并行的适用于公司可用的巨大计算资源的方法。它还从建立并行任务(如多次模拟错误在时间中传播)和自动化过程中受益,其中有分析师的输入空间但没有必要。通过这种方式,可以大规模生成合理的预测,并适用于各种数据集。

虽然你的工作可能没有 Google 所需的高自动化水平或同样的优势,但即使作为独立的数据科学家,你仍然可以从他们的模型中吸收许多想法并将其整合到你的工作流程中。这些想法包括:

  • 为清理数据构建框架或“管道”,作为您希望在对每个时间序列数据集建模之前准备的基线“足够好”的版本。

  • 建立尊重的预测模型集合作为您的“首选”工具包。

Facebook 的开源 Prophet 包

Facebook 在大约与 Google 发布其内部包信息同时,开源了其自动化时间序列预测包 Prophet。在其自己的博客文章中,Facebook 强调了 Google 方法中强调的一些相同问题,特别是:

  • "人类尺度"的季节性和不规则的假期

  • 水平变化

  • 缺失数据和异常值

此外,Facebook 包还带来了诸如:

  • 能够处理各种粒度水平的数据集,例如分钟级或小时级数据,以及每日数据

  • 表示非线性增长趋势的趋势,例如达到饱和点

Facebook 指出,其包的结果往往与分析师的结果一样出色。与 Google 一样,Facebook 在开发其时间序列拟合管道时发现了许多高度并行的任务实例。Facebook 表示,Prophet 已经发展成为一款可靠的预测工具,其预测不仅在内部使用,还用于外部产品。此外,Facebook 还表示,它已经开发了一种“分析师参与”的工作模式,使得自动化过程可以在必要时进行监督和修正,最终导致可以根据任务所需的资源水平协助或替代人类分析的产品。

Facebook 的方法与 Google 的方法大不相同;它包括三个简单的组成部分:

  • 年度和周度季节效应

  • 自定义假期列表

  • 分段线性或逻辑趋势曲线

这些组件用于形成加法回归模型。这是一个非参数回归模型,意味着不对底层回归函数的形式做出任何假设,也不强加线性性。该模型比线性回归更灵活和可解释,但存在更高的方差(可以考虑机器学习和统计学中通常知道的偏差-方差权衡)和更多的过拟合问题。这种模型对 Facebook 寻求的一般任务是有意义的,即必须以自动化方式对复杂的非线性行为进行建模,避免不必要的税收方法。

Prophet 具有许多优点,包括:

  • 简单的 API

  • 开源且正在积极开发

  • 在 Python 和 R 中拥有完整且相同的 API,有助于多语言数据科学团队

先知易于使用。我们在此节中以一个代码片段示例结束,该代码片段取自快速入门指南,以便您可以看到用于自动化预测所需的最小代码。我们将先知与pageviews一起使用,后者是一个能轻松检索与维基百科页面浏览量相关的时间序列数据的 R 包。首先,我们下载一些时间序列数据:

## R
> library(pageviews)
> df_wiki = article_pageviews(project = "en.wikipedia", 
>                             article = "Facebook", 
>                             start = as.Date('2015-11-01'), 
>                             end = as.Date("2018-11-02"), 
>                             user_type = c("user"), 
>                             platform = c("mobile-web"))
> colnames(df_wiki)
[1] "project"   "language"  "article"   "access"    "agent"     "granularity"
[7] "date"      "views"   

现在我们已经有了一些以每日时间分辨率和几年时间为单位的数据,我们可以通过几个简单的步骤(参见图 16-1)使用先知来预测这些数据:

## R
> ## we subset the data down to what we need
> ## and give it the expected column names
> df = df_wiki[, c("date", "views")]
> colnames(df) = c("ds", "y")

> ## we also log-transform the data because the data
> ## has such extreme changes in values that large
> ## values dwarf normal inter-day variation
> df$y = log(df$y)

> ## we make a 'future' data frame which
> ## includes the future dates we would like to predict
> ## we'll predict 365 days beyond our data
> m = prophet(df)
> future <- make_future_dataframe(m, periods = 365)
> tail(future)
             ds
1458 2019-10-28
1459 2019-10-29
1460 2019-10-30
1461 2019-10-31
1462 2019-11-01
1463 2019-11-02

> ## we generate the forecast for the dates of interest
> forecast <- predict(m, future)
> tail(forecast[c('ds', 'yhat', 'yhat_lower', 'yhat_upper')])
             ds     yhat yhat_lower yhat_upper
1458 2019-10-28 9.119005   8.318483   9.959014
1459 2019-10-29 9.090555   8.283542   9.982579
1460 2019-10-30 9.064916   8.251723   9.908362
1461 2019-10-31 9.066713   8.254401   9.923814
1462 2019-11-01 9.015019   8.166530   9.883218
1463 2019-11-02 9.008619   8.195123   9.862962
> ## now we have predictions for our value of interest

> ## finally we plot to see how the package did qualitatively
> plot(df$ds, df$y, col = 1, type = 'l', xlim = range(forecast$ds),
>      main = "Actual and predicted Wikipedia pageviews of 'Facebook'")
> points(forecast$ds, forecast$yhat, type = 'l', col = 2)

图 16-1. 维基百科页面计数数据的绘图(细实线)及先知模型对该数据的预测(粗虚线)。

先知还提供绘制构成预测的组件(趋势和季节性)的选项(请参见图 16-2):

## R
> prophet_plot_components(m, forecast)

警告

限制先知的使用到每日数据。根据其自身描述,先知包是为每日数据开发并且在此类数据上表现最佳的。不幸的是,这种狭窄的专业化意味着对于不同时间尺度的数据,同样的技术可能会非常不可靠。因此,当您的数据不是每日数据时,在使用该包及其相关技术时应谨慎对待。

图 16-2. 预测分解为趋势、每周和每年组件。预测由这些组件的总和形成。请注意,不同的组件形成方式不同。趋势数据具有基本线性形状,而年度数据由于其基础傅里叶级数拟合而呈曲线形状(更多信息请参阅早期提到的 Facebook 博客文章)。

随着时间的推移,越来越多的自动化时间序列开源软件包和黑盒产品开始出现。这些可以是对于新手进行预测并寻求合理预测的一个很好的入门点。然而,在不久的将来,这些软件包可能不会为每个时间序列和每个组织提供最佳的预测结果。当您能将领域知识和相关组织约束纳入您的时间序列模型中时,您将得到更好的结果,目前这仍然是人类分析师的任务,直到更普遍的预测软件包出现。

异常检测

在时间序列中,异常检测是另一个技术公司正在做出重大努力并与开源社区分享的领域。在时间序列中,异常检测因几个原因而重要:

  • 如果我们拟合的模型对此类异常值不够稳健,移除异常值是有帮助的。

  • 如果我们想要构建一个专门用于预测此类异常事件程度的预测模型,那么识别异常值是很有帮助的,前提是我们知道它们将会发生。

接下来我们将讨论 Twitter 在其开源异常检测工作中采取的方法。

Twitter 的开源异常检测包

四年前,Twitter 开源了一个异常检测包,AnomalyDetection,¹ 这个包仍然很有用且表现良好。该包实现了季节性混合 ESD(极端学生化离群值检测),比一般的 ESD 模型更复杂,用于识别离群值。一般化 ESD 测试本身基于另一个统计测试,Grubbs 测试,它定义了一个用于测试数据集中是否存在单个离群值的统计量。一般化 ESD 将这个测试重复应用,首先对最极端的离群值,然后对逐渐较小的离群值,同时调整测试的临界值,以考虑多个顺序测试。季节性混合 ESD 基于一般化 ESD 来考虑行为的季节性,通过时间序列分解。

我们可以看到这个包在以下 R 代码中的简单使用。首先,我们加载 Twitter 包提供的一些样本数据:

## R
> library(AnomalyDetection)
> data(raw_data)
> head(raw_data)
            timestamp   count
1 1980-09-25 14:01:00 182.478
2 1980-09-25 14:02:00 176.231
3 1980-09-25 14:03:00 183.917
4 1980-09-25 14:04:00 177.798
5 1980-09-25 14:05:00 165.469
6 1980-09-25 14:06:00 181.878

然后,我们使用 Twitter 的自动化异常检测函数来处理两组参数:

  1. 我们在正或负方向的大部分异常中寻找。

  2. 我们只在正范围内寻找少量异常。

这些用例在这里展示:

## R
> ## detect a high percentage of anomalies in either direction
> general_anoms = AnomalyDetectionTs(raw_data, max_anoms=0.05, 
>                          direction='both')
> 
> ## detect a lower percentage of anomalies only in pos direction
> high_anoms = AnomalyDetectionTs(raw_data, max_anoms=0.01, 
>                          direction='pos')

我们分别在这两种情况下绘制结果在图 16-3 中。

图 16-3. 从 Twitter 的 AnomalyDetectionTs()函数返回的异常,一组使用非常包容性设置(顶部)和使用更有限设置(底部)。

我们看到许多异常报告集中在时间序列的某一部分,因此我们也将图表裁剪到这部分时间序列,以更好地理解发生了什么(图 16-4)。

图 16-4. 同一天内发生的异常,现在关注的是这些异常发生的聚类。

在仔细观察后,我们可以更好地理解为什么这些点是异常。它们偏离了日常模式,这是存在的。至于为什么我们可能只想寻找正偏差,想象一下,我们正在为一个高流量、数据密集型网站构建基础设施。虽然向下/负异常可能是感兴趣的,但对我们来说决定性的业务异常将是我们的基础设施无法处理高流量机会的时刻。我们最感兴趣的是识别出现峰值的时刻,由于多种原因:

  • 如果这些数字是假的,我们希望将它们清除,以便了解真实使用的真正上限。异常高的数字会促使我们购买我们不需要的计算资源,这与异常低的数字不符。使用异常检测可以帮助我们在预处理步骤中清理数据。

  • 如果计算设备便宜,我们宁愿购买更多设备来适应这些异常。如果我们能标记异常,那就是生成标记数据以尝试预测这些异常的第一步。然而,按定义异常很难预测,因此您大部分时间不应对这些努力抱有很高期望!

许多参数可以与 Twitter 的自动异常检测一起使用,这是一个在探索新数据集时非常有用的工具,既用于数据清理又用于建模数据。²

其他时间序列包

在本章中,我们主要关注了由一些最大的科技公司开发的广泛使用的包,这些公司与它们核心业务运营的大数据集和相关预测相结合。然而,这些公司远非时间序列包的主要或最高级提供者。有一个专门为以下内容提供的大型时间序列包生态系统:

  • 时间序列存储和基础设施

  • 时间序列数据集

  • 断点检测

  • 预测

  • 频域分析³

  • 非线性时间序列

  • 自动化时间序列预测

这并非详尽无遗的列表。一切都有包,字面上是数十甚至数百个时间序列包。最广泛的开源包列表由罗布·亨德曼教授在 R 官方 CRAN 存储库网页上维护。值得查看此列表,既可以找到适合特定项目分析需求的具体包,也可以更广泛地了解社区中正在积极部署的时间序列分析方法。就像本书一样,该页面提供了有关时间序列数据相关任务的概述和界定。在 Python 中,没有类似于这种广泛和统一的时间序列模块列表,但数据科学家马克斯·克里斯特编制了一个非常有帮助的列表

更多资源

StatsNewbie123, “是否可以自动化时间序列预测?”,Cross Validated 上的帖子,2019 年 12 月 6 日,https://perma.cc/E3C4-RL4L。

最近的 StackExchange 帖子询问是否可能为任何时间序列自动化时间序列预测。有两个非常有用和详细的回复,概述了自动化包的概况以及讨论了这项任务中的所有挑战。

CausalImpact,Google 的因果推断开源包,https://perma.cc/Y72Z-2SFD。

这个开源的谷歌包建立在另一个谷歌发布的bsts,即贝叶斯结构时间序列包之上,我们在第七章中使用过。CausalImpact 包使用bsts来拟合模型,并在拟合后构建反事实控制示例,评估时间序列数据中的因果关系和效果大小。该包的 GitHub 仓库包含相关研究论文的链接,以及该包创建者之一的有用视频概述。还值得查看相关研究论文

Murray Stokely, Farzan Rohani, 和 Eric Tassone,《R 中的大规模并行统计预测计算》,JSM Proceedings,Physical and Engineering Sciences 部分(Alexandria, VA: American Statistical Association,2011),https://perma.cc/25D2-RVVA。

本文详细解释了如何为谷歌内部时间序列预测编写 R 包,以高度并行和可扩展的方式进行时间序列。这不仅仅是关于预测细节的好读物,还有助于更好地理解如何为大型组织构建时间序列数据管道,处理大量和不同类型的时间序列数据。

Danilo Poccia,《Amazon Forecast: 时间序列预测变得简单》,AWS News Blog,2018 年 11 月 28 日,https://perma.cc/Y2PE-EUDV。

我们没有涵盖的最新自动化时间序列模型之一是亚马逊的新预测服务,Amazon Forecast。它不是开源的,但有许多令人期待的评价。它提供了一种使用亚马逊在零售方面开发的模型帮助公司进行业务预测的方式。虽然这是一个付费服务,但您可以尝试免费层,它提供了相当慷慨的选项。该服务旨在强调准确性和易用性,对于那些寻找高量预测情况下“足够好”模型的组织来说是一个不错的选择。亚马逊的包使用了深度学习和传统统计模型的混合方法,类似于第十章中简单的 LSTNET 模型将深度学习模型与自回归组件结合的方式。了解亚马逊的标志性神经网络架构用于预测,DeepAR,也是值得一读的,链接在这里DeepAR

¹ 在项目的GitHub 代码库Twitter 的博客上进一步阅读。

² 值得注意的是,Twitter 在发布 AnomalyDetection 包的同时,也发布了一个水平变化检测包 BreakoutDetection。在 BreakoutDetection 的情况下,该包用于识别时间序列中发生水平转变的位置。这个包同样易于访问和使用,尽管它没有像 AnomalyDetection 包那样受到广泛关注,也没有像它那样脱颖而出。还有许多其他经过广泛测试和部署的替代断点检测包。

³ 频域分析在本书中仅简单提及,但仍然是时间序列分析中的重要领域,在物理学和气候学等学科中被广泛使用。

第十七章:关于预测的预测

有许多关于预测未来绝望无助的好引言,但我还是忍不住想用一些关于未来即将到来的思考来结束本书。

预测作为服务

由于时间序列预测的专业从业者比数据科学的其他领域少,因此有一种驱动力去开发时间序列分析和预测作为服务,可以轻松打包并以高效的方式推广。例如,正如在第十六章中所述,亚马逊最近推出了一个时间序列预测服务,并且不止这一家公司这么做。该公司的模型似乎有意地设计得很通用,并将预测框架化为数据管道中的一个步骤(见图 17-1)。

这些作为服务的预测建模努力旨在创建一个足够好的通用模型,可以适应各种领域,避免进行极不准确的预测。它们大多描述其模型为深度学习和传统统计模型的混合使用。然而,由于该服务最终是一个黑盒子,难以理解可能导致预测错误的原因,甚至很难事后调查如何改进预测。这意味着预测的质量水平相当高,但可能也存在性能上限。

这项服务对于需要大量预测但没有足够人手逐个生成的公司非常有价值。然而,对于拥有大量历史数据的公司来说,如果能发现更一般性的启发法则和他们数据的“定律”,熟悉领域的分析师很可能能够胜过这些算法。

图 17-1

图 17-1. 一个时间序列预测作为服务的示例管道。类似的服务由多家小型初创公司和科技巨头提供,尤其是亚马逊,其推出了一套产品,专门针对自动和大规模时间序列数据预测,通过深度学习和统计模型驱动。

注意,预测作为服务领域销售的产品中,有相当一部分与预测的良好可视化以及管道工具有关,可以轻松修订预测、更改预测频率等。即使您的组织最终将开展自己的预测分析,了解正在兴起的行业标准也可能会有所帮助。

深度学习增强了概率可能性

在过去的几年里,许多最大的科技公司已经公开了一些关于它们如何为其最重要的服务进行预测的信息。在这种情况下,我们不是在谈论需要对影响公司业务的大量指标进行多个并行预测的需求,而是核心关注点。在这些情况下,预测质量至关重要,公司通常表明它们正在使用带有概率组成部分的深度学习技术。

例如,Uber 已经发布博文关于预测乘车需求,亚马逊则开发了一个备受推崇的自回归递归神经网络,灵感来自于统计思想,用于预测产品需求。研究人员越能整合统计方法论,比如注入领域知识的先验和不确定性量化,寻找深度学习模型的理由就越少,因为深度学习模型可以同时提供统计学和深度学习的优势。

然而,制作合理可解释的深度学习模型——以便我们知道预测可能有多么“错误”或极端——仍然是一项困难的任务,因此传统的统计模型可能不会被抛弃,它们具有更深的理论理解和机械清晰度。对于那些健康和安全可能受到威胁的关键预测,人们可能会继续依赖几十年来有效的方法,直到能够为机器学习预测开发更透明和可检验的方法为止。

机器学习的重要性日益增加,而不是统计学

从经验上看,在建模数据和生成预测方面,使用适当的统计学方法似乎越来越少。不要绝望:统计学领域仍在蓬勃发展,并回答与统计学相关的有趣问题。然而,尤其是对于仅需达到足够水平的低风险预测来说,机器学习技术和以结果为导向的统计方法,而不是花哨的理论和闭式解或收敛证明,正在实际部署和现实用例中占据主导地位。

从从业者的角度来看,这是件好事。如果你很久以前就愉快地把问题集抛在脑后,不想再去证明什么,你就不必担心证明和类似的事情会再次出现。另一方面,随着这些技术渗透到生活中越来越多的基本方面,这是一个令人担忧的趋势。我并不介意访问使用机器学习来猜测我未来购买行为的零售商网站。但我希望知道,模拟我的健康结果或我的孩子学术进展的时间序列预测更为全面,并且在统计上得到验证,因为一个有偏的模型在这些核心领域可能会真正伤害到某人。

目前,用于工业目的的时间序列思维领袖正在从事低风险领域的工作。对于预测来自广告活动或社交媒体产品推出的收入等问题,预测是否经过完全验证并不重要。随着更多与人类生活关怀和喂养相关的基本方面进入建模领域,让我们希望统计学在高风险预测中发挥更加基础性的作用。

统计和机器学习方法的组合日益增多

有多种迹象表明,应该将机器学习和统计方法¹结合起来,而不仅仅是简单地寻找用于预测的“最佳”方法。这是对合成方法在预测中日益被接受和使用的延伸,这一现象在本书中我们已经讨论过。

一个具有许多真实数据集的非常健壮测试的例子是最近的M4 竞赛,这是一个测量100,00 个时间序列数据集预测准确性的时间序列竞赛,如第二章和第九章简要提到。这次比赛的获胜作品结合了统计模型和神经网络的元素。同样,亚军则结合了机器学习和统计学,这种情况下使用了一个统计模型的集合,然后使用梯度增强树(XGBoost)来选择集合中每个模型的相对权重。在这个例子中,我们看到了机器学习和统计方法可以结合的两种独特方式:一种是作为替代模型组合在一起(就像获胜作品的情况),另一种是一种方法确定另一方法的元参数设定(就像亚军的情况)。随后,这次竞赛结果的一份全面且高度可访问的总结随后发表在国际预测杂志上。

随着这些组合方法获得认可,我们可能会看到研究在确定哪些问题最适合结合统计和机器学习模型方面的发展,以及关于调整这些模型性能和选择架构的最佳实践的出现。我们预计会看到与其他复杂架构(例如神经网络)类似的精炼,随着时间的推移出现已知的设计范式,具有已知的优势、劣势和训练技术。

更多面向日常生活的预测

越来越多面向消费者的公司,如移动健康和健康应用程序,已经推出被要求推出个性化预测。随着人们越来越意识到他们的应用程序存储了多少关于他们和他人的数据,他们希望通过获取针对诸如健康和健身目标等指标的定制预测来利用这些数据。同样,人们经常寻找关于从未来房地产价值(难以预测)到候鸟物种可能到达的日期的预测。

越来越多的产品明确受到需求驱动,要进行预测,无论是在奥秘主题上还是个体指标上。这意味着更多的预测管道将被整合到以前不太可能的地方,例如面向休闲读者而非行业专家的移动应用程序和网站。这种情况变得越来越普遍,时间序列术语很可能成为日常语言的一部分。希望人们也能获得足够的时间序列教育,以理解预测的限制和假设,这样他们就不会过度依赖这些产品。

¹ 特别感谢技术审阅者罗布·亨德曼(Rob Hyndman),他建议了这个话题(以及书中许多其他有用的建议)。

posted @ 2025-11-23 09:26  绝不原创的飞龙  阅读(31)  评论(0)    收藏  举报