机器学习算法交易教程第二版-全-

机器学习算法交易教程第二版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

如果你正在阅读这篇文章,你可能已经意识到机器学习ML已经成为许多行业的战略能力,包括投资行业。与 ML 的兴起密切相关的数字数据的爆炸对投资产生了特别强大的影响,而投资已经有了使用复杂模型处理信息的悠久历史。这些趋势正在促成量化投资的新方法,并增加了将数据科学应用于自主和算法交易策略的需求。

跨资产类别的交易范围非常广泛,因为它涵盖了从股票和政府债券到商品和房地产的范围。这意味着一个非常广泛的新型替代数据源可能与过去大多数分析工作的核心市场和基本数据相关。

你可能也已经了解到,成功应用 ML 或数据科学需要个人或团队层面的统计知识、计算技能和领域专业知识的整合。换句话说,关键是提出正确的问题,识别和理解可能提供答案的数据,使用广泛的工具来获取结果,并以导致正确决策的方式解释它们。

因此,本书提供了关于将 ML 应用于投资和交易领域的综合视角。在本序言中,我们概述了你应该期望什么,我们如何组织内容以便实现我们的目标,以及你需要什么来实现目标并在过程中享受乐趣。

预期

本书旨在为您提供战略视角、概念理解和实用工具,以在将 ML 应用于交易和投资过程中增加价值。为此,我们将 ML 视为过程的关键要素,而不是一个独立的练习。最重要的是,我们介绍了一个端到端 ML 交易(ML4T)工作流程,我们将其应用于许多具有相关数据和代码示例的用例。

ML4T 的工作流程始于生成想法和获取数据,然后继续提取特征,调整 ML 模型,并设计能够根据模型预测信号行动的交易策略。它还包括使用回测引擎在历史数据上模拟策略,并评估其性能。

首先,本书演示了如何从各种数据源中提取信号,并使用广泛的监督、无监督和强化学习算法为不同资产类别设计交易策略。此外,它提供了相关的数学和统计背景,以便调整算法和解释结果。最后,它包括金融背景,使您能够处理市场和基本数据,提取有信息量的特征,并管理交易策略的性能。

本书强调,投资者可以从第三方数据中获得至少与其他行业同等的价值。因此,它不仅涵盖了如何处理市场和基本数据,还涵盖了如何获取、评估、处理和建模替代数据源,如非结构化文本和图像数据。

毫不奇怪,这本书不提供投资建议或现成的交易算法。相反,它意在传达机器学习在交易领域面临许多额外挑战,从信号内容较低到时间序列较短,这些往往使得获得稳健结果更加困难。事实上,我们包含了一些例子,这些例子并没有取得很好的结果,以避免夸大机器学习的好处或低估获取好主意、获得正确数据、构建巧妙特征和设计有效策略(可能具有吸引力回报)所需的努力。

相反,您应该将本书视为一个利用关键机器学习算法指导交易策略的指南。为此,我们提出了一个框架,指导您完成以下 ML4T 过程

  1. 为任何投资目标获取、评估和组合数据

  2. 设计和调整从数据中提取预测信号的 ML 模型

  3. 基于结果开发和评估交易策略

阅读本书后,您将能够开始设计和评估自己基于机器学习的策略,并可能考虑参加比赛或连接到在线经纪人的 API 并开始在真实世界中交易。

第二版的新内容

本次更新的重点在于端到端的 ML4T 工作流程,反映在一个新的章节上,即战略回测(第八章ML4T 工作流程 - 从模型到策略回测),一个描述了超过 100 种不同的 alpha 因子的新附录,以及许多新的实际应用。我们还重写了大部分现有内容,以提高清晰度和可读性。

应用现在使用了更广泛的数据源,包括日常美国股票价格之外的国际股票和 ETF,以及分钟级频率的股票数据,以展示日内交易策略。此外,现在还有更广泛的替代数据源涵盖范围,包括用于情感分析和回报预测的 SEC 文件,以及用于分类土地用途的卫星图像。

此外,本书复制了几篇最近发表的学术论文中的应用。第十八章用于金融时间序列和卫星图像的卷积神经网络,演示了如何将卷积神经网络应用于转换为图像格式的时间序列,用于回报预测。第二十章用于条件风险因素和资产定价的自编码器,展示了如何使用自编码器提取以股票特征为条件的风险因素,用于资产定价。第二十一章用于合成时间序列数据的生成对抗网络,研究了如何使用生成对抗网络创建合成训练数据。

所有应用现在都使用最新可用的软件版本(写作时),如 pandas 1.0 和 TensorFlow 2.2。还有一个定制版本的 Zipline,可以在设计交易策略时轻松包含机器学习模型的预测。

谁应该阅读这本书

如果你是分析师、数据科学家或机器学习工程师,对金融市场有一定的了解并对交易策略感兴趣,那么你应该会觉得这本书很有启发。如果你是一名投资专业人士,希望利用机器学习做出更好的决策,你也会发现其中的价值。

如果你的背景是软件和机器学习,你可能可以只浏览或跳过一些本领域的介绍性材料。同样,如果你的专业是投资,你可能已经熟悉我们为不同背景的人提供的一些或全部金融背景。

本书假设你想继续学习这个非常动态的领域。为此,它包括了许多章末的学术参考文献,以及伴随 GitHub 仓库中每章的 README 文件中链接的其他资源。

你应该能够熟练使用 Python 3 和科学计算库,如 NumPy、pandas 或 SciPy,并期待在学习过程中掌握更多其他技能。一些机器学习和 scikit-learn 的经验会有所帮助,但我们会简要介绍基本的工作流程,并引用各种资源来填补空白或深入了解。同样,基本的金融和投资知识会让一些术语更容易理解。

本书涵盖了什么

本书全面介绍了机器学习如何为交易策略的设计和执行增加价值。它分为四个部分,涵盖了数据获取和策略开发过程的不同方面,以及解决各种机器学习挑战的不同解决方案。

第一部分 - 数据、Alpha 因子和投资组合

第一部分涵盖了在利用机器学习的交易策略中相关的基本方面。它专注于驱动本书讨论的 ML 算法和策略的数据,概述了如何构建捕捉数据信号内容的特征,并解释了如何优化和评估投资组合的绩效。

第一章交易的机器学习 - 从构想到执行,总结了 ML 如何以及为什么对于交易变得重要,描述了投资过程,并概述了 ML 如何增加价值。

第二章市场和基本数据 - 来源和技术,涵盖了如何获取和处理市场数据,包括交易所提供的 tick 数据和报告的财务数据。它还演示了许多开源数据提供商的访问,这些提供商在本书中将是我们依赖的。

第三章金融替代数据 - 类别和用例,解释了评估爆炸式增长的来源和提供商的类别和标准。它还演示了如何通过网页抓取创建替代数据集,例如,收集用于与自然语言处理NLP)和情感分析一起使用的收益电话转录,在本书的第二部分中我们将介绍。

第四章金融特征工程 - 如何研究阿尔法因子,介绍了创建和评估捕捉预测信号的数据转换过程,并展示了如何衡量因子绩效。它还总结了对旨在解释被认为是高效的金融市场中阿尔法的风险因素研究的见解。此外,它演示了如何使用 Python 库离线工程阿尔法因子,并介绍了ZiplineAlphalens库来回测因子并评估其预测能力。

第五章投资组合优化与绩效评估,介绍了如何管理、优化和评估由策略执行产生的投资组合。它提供了风险度量标准,并展示了如何使用 Zipline 和pyfolio库应用它们。它还介绍了从投资组合风险角度优化策略的方法

第二部分 - 用于交易的 ML - 基础知识

第二部分阐明了在端到端工作流程背景下,基本的监督和无监督学习算法如何指导交易策略。

第六章机器学习过程,通过概述如何系统地制定、训练、调整和评估 ML 模型的预测性能,为舞台设置了背景。它还解决了特定领域的问题,例如使用与金融时间序列交叉验证以在备选 ML 模型中进行选择。

第七章线性模型 - 从风险因子到收益预测,展示了如何使用线性和 logistic 回归进行推断和预测,以及如何使用正则化来管理过拟合的风险。它演示了如何预测美国股票的收益或其未来走势的方向,并使用 Alphalens 评估这些预测的信号内容。

第八章ML4T 工作流程 - 从模型到策略回测,将迄今为止单独讨论过的 ML4T 工作流程的各个组成部分集成在一起。它从端到端的视角展示了设计、模拟和评估由 ML 算法驱动的交易策略的过程。为此,它演示了如何使用 Python 库 backtrader 和 Zipline 在历史市场环境中进行 回测 ML 驱动的策略

第九章用于波动率预测和统计套利的时间序列模型,涵盖了单变量和多变量时间序列诊断和模型,包括向量自回归模型以及用于波动率预测的 ARCH/GARCH 模型。它还介绍了协整,并演示了如何使用它进行 使用各种交易所交易基金 (ETFs) 的配对交易策略

第十章贝叶斯 ML - 动态夏普比率和配对交易,介绍了概率模型以及如何使用 马尔可夫链蒙特卡洛 (MCMC) 采样和变分贝叶斯进行近似推断。它还说明了如何使用 PyMC3 进行概率编程,以更深入地了解 参数和模型不确定性,例如在评估 投资组合绩效 时。

第十一章随机森林 - 用于日本股票的多头-空头策略,展示了如何构建、训练和调整非线性基于树的模型进行洞察和预测。它介绍了基于树的集成,并展示了随机森林如何使用自助聚合来克服决策树的一些弱点。然后,我们继续开发和回测 用于日本股票的多头-空头策略

第十二章提升您的交易策略,介绍了梯度提升,并演示了如何使用库 XGBoost、LightBGM 和 CatBoost 进行高性能训练和预测。它回顾了如何调整众多超参数并解释模型使用 SHapley Additive exPlanation (SHAP) 值之前,基于 LightGBM 收益预测构建和评估交易美国股票的策略。

第十三章基于数据驱动的风险因子和无监督学习的资产配置,展示了如何使用降维和聚类进行算法交易。它使用主成分和独立成分分析提取数据驱动的风险因子并生成 特征组合。它介绍了几种聚类技术,并演示了使用分层聚类进行 资产配置

第三部分 - 自然语言处理

第三部分专注于文本数据,并介绍了从这一关键替代数据源提取高质量信号的最新无监督学习技术。

第十四章用于交易的文本数据 - 情绪分析,演示了如何将文本数据转换为数字格式,并将第二部分的分类算法应用于大型数据集的情绪分析。

第十五章主题建模 - 摘要财经新闻,使用无监督学习来提取总结大量文档的主题,并提供更有效地探索文本数据或将主题用作分类模型特征的方法。它演示了如何将这种技术应用于第三章中获取的收益电话转录和提交给美国证券交易委员会SEC)的年度报告。

第十六章用于收益电话和 SEC 提交文件的词嵌入,使用神经网络学习最先进的语言特征,以词向量的形式捕捉语义上下文,比传统文本特征更好地表示,并且代表从文本数据中提取交易信号的一个非常有前途的途径。

第四部分 - 深度学习和强化学习

第四部分介绍了深度学习和强化学习。

第十七章交易的深度学习,介绍了 TensorFlow 2 和 PyTorch,这两个最流行的深度学习框架,我们将在第四部分中使用。它提供了训练和调整的技巧,包括正则化。它还构建并评估了一种美国股票的交易策略

第十八章用于金融时间序列和卫星图像的卷积神经网络,涵盖了非常适用于大规模非结构化数据分类任务的卷积神经网络CNNs)。我们将介绍成功的架构设计,训练一个 CNN 模型来处理卫星数据(例如,预测经济活动),并使用迁移学习加速训练。我们还将复制一个最近的想法,将金融时间序列转换为二维图像格式,以利用 CNNs 的内置假设。

第十九章用于多变量时间序列和情感分析的循环神经网络,展示了循环神经网络RNNs)在序列到序列建模中的用处,包括用于预测的单变量和多变量时间序列。它演示了如何使用第十六章介绍的词嵌入来捕捉长期内的非线性模式,以根据 SEC 提交的文件中表达的情绪来预测回报

第二十章用于条件风险因素和资产定价的自编码器,涵盖了用于高维数据的非线性压缩的自编码器。它实现了一篇最近的论文,该论文使用深度自编码器从数据中学习风险因素回报和因子加载,同时将后者条件化为资产特征。我们将创建一个包含元数据的大型美国股票数据集,并生成预测信号。

第二十一章用于合成时间序列数据的生成对抗网络,介绍了深度学习中最激动人心的进展之一。生成对抗网络GANs)能够学习生成目标数据类型的合成副本,例如名人的图像。除了图像外,GANs 还被应用于时间序列数据。本章复制了一种生成合成股价数据的新方法,这些数据可用于训练 ML 模型或回测策略,并评估其质量。

第二十二章深度强化学习 - 构建交易代理,介绍了强化学习RL)如何允许设计和训练代理程序,这些程序会随着时间的推移而学会如何在环境变化中优化决策。您将看到如何创建自定义交易环境,并使用 OpenAI Gym 构建一个根据市场信号做出响应的代理。

第二十三章总结与下一步,总结了所学到的教训,并概述了您可以采取的几个步骤,继续学习并构建自己的交易策略。

附录Alpha 因子库,列出了近 200 个流行的金融特征,解释了它们的基本原理,并展示了如何计算它们。它还评估并比较了它们在预测每日股票收益方面的表现。

获取本书最大的收益

除了前一节总结的内容外,本书的实践性质还包括托管在 GitHub 上的超过 160 个 Jupyter 笔记本,演示了如何在广泛的数据源上将 ML 用于实践交易。本节描述了如何使用 GitHub 存储库,获取许多示例中使用的数据,并设置运行代码的环境。

GitHub 存储库

本书围绕将 ML 算法应用于交易展开。实践方面在 Jupyter 笔记本中进行,这些笔记本托管在 GitHub 上,详细说明了许多概念和模型。虽然各章节旨在是独立的,但代码示例和结果通常需要太多空间才能完整地包含在其中。因此,在阅读每章时,查看包含重要额外内容的笔记本非常重要,即使您不打算自己运行代码。

存储库组织良好,每个章节都有自己的目录,其中包含相关的笔记本和一个包含必要的单独说明的 README 文件,以及与该章节内容相关的参考和资源。在必要时,会在每章节中标识出相关的笔记本。存储库还包含有关如何安装必需库和获取数据的说明。

您可以在以下地址找到代码文件:github.com/PacktPublishing/Machine-Learning-for-Algorithmic-Trading-Second-Edition

数据来源

我们将使用来自市场、基本和替代来源的免费可用历史数据。第二章第三章 讨论了这些数据源的特点和访问方式,并介绍了本书中将使用的一些主要提供者。刚才描述的配套 GitHub 仓库包含了获取或创建本书中将使用的一些数据集的说明,其中包括一些较小的数据集。

我们将要使用的一些样本数据源包括但不限于:

  • 纳斯达克 ITCH 挂单数据

  • 电子数据获取、分析和检索 (EDGAR) 美国证监会文件

  • 来自 Seeking Alpha 的收益电话转录

  • Quandl 每日价格和其他超过 3,000 家美国公司的数据点

  • 来自 Stooq 的国际股票数据,并使用 yfinance 库

  • 来自美国联邦储备系统的各种宏观基本和基准数据

  • 大型 Yelp 商家评论和 Twitter 数据集

  • EUROSAT 卫星图像数据

一些数据很大(几个吉字节),例如纳斯达克和美国证监会文件。笔记本会在这种情况下指出。

请查看 GitHub 仓库根目录中的数据目录以获取说明。

Anaconda 和 Docker 镜像

本书要求使用 Python 3.7 或更高版本,并使用 Anaconda 发行版。本书使用各种 conda 环境来覆盖四个部分,涵盖了广泛的库,同时限制了依赖关系和冲突。

GitHub 仓库中的安装目录包含了详细的说明。您可以使用提供的 Docker 镜像创建具有所需环境的容器,也可以使用 .yml 文件在本地创建。

下载示例代码文件

您可以从您在www.packtpub.com上的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support,注册后文件将直接发送至您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择SUPPORT选项卡。

  3. 点击代码下载和勘误

  4. 搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用您首选的最新版本的压缩工具解压缩或提取文件夹:

  • Windows 上的 WinRAR 或 7-Zip

  • Mac 上的 Zipeg、iZip 或 UnRarX

  • Linux 上的 7-Zip 或 PeaZip

本书的代码捆绑包也托管在 GitHub 上,网址为github.com/PacktPublishing/Machine-Learning-for-Algorithmic-Trading-Second-Edition。我们的丰富书籍和视频目录中还有其他代码捆绑包,可以在github.com/PacktPublishing/上找到。欢迎查看!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此处下载:static.packt-cdn.com/downloads/9781839217715_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。例如,“compute_factors() 方法创建一个 MeanReversion 因子实例,并创建长、短和排名管道列。”

代码块设置如下:

from pykalman import KalmanFilter
kf = KalmanFilter(transition_matrices = [1],
                  observation_matrices = [1],
                  initial_state_mean = 0,
                  initial_state_covariance = 1,
                  observation_covariance=1,
                  transition_covariance=.01) 

粗体:表示一个新术语,一个重要单词,或者您在屏幕上看到的单词,例如菜单或对话框中的单词,也会出现在文本中。例如,“Python Algorithmic Trading Library (PyAlgoTrade) 专注于回测,并支持模拟交易和实盘交易。”

信息提示以这种方式出现。

第一章:从构想到执行的交易机器学习

算法交易依赖于执行算法的计算机程序,以自动化交易策略的一部分或全部元素。算法是设计用于实现目标的一系列步骤或规则。它们可以采用许多形式,并促进了整个投资过程的优化,从构想生成到资产配置、交易执行和风险管理。

机器学习ML)涉及从数据中学习规则或模式的算法,以实现最小化预测误差等目标。本书中的示例将说明 ML 算法如何从数据中提取信息,以支持或自动执行关键的投资活动。这些活动包括观察市场和分析数据,形成对未来的期望并决定是否放置买入或卖出订单,以及管理由此产生的投资组合,以产生相对于风险的吸引人的回报。

最终,主动投资管理的目标是产生 alpha,即超过用于评估的基准的投资组合回报。主动管理的基本法则假定产生 alpha 的关键是准确的回报预测与对这些预测采取行动的能力(Grinold 1989; Grinold and Kahn 2000)。

该法则将信息比率IR)定义为表示主动管理价值的比率,即投资组合与基准之间的回报差异与这些回报的波动性的比率。它进一步将 IR 近似为以下乘积:

  • 信息系数IC),它以与结果的等级相关性来衡量预测的质量

  • 一种策略的广度表示为对这些预测的独立投注数量的平方根

金融市场上的高级投资者竞争意味着,要产生 alpha 并进行精确预测,需要优越的信息,无论是通过获取更好的数据、优越的处理能力,还是两者兼而有之。

这就是 ML 的作用所在:用于交易的机器学习ML4T)应用通常旨在更有效地利用迅速多样化的数据范围,以产生更好且更具操作性的预测,从而提高投资决策和结果的质量。

历史上,算法交易曾被更狭义地定义为将交易执行自动化以最小化卖方提供的成本。本书采用了更全面的视角,因为算法的使用以及特别是 ML 的使用已经影响到更广泛的活动范围,从生成想法和从数据中提取信号到资产配置、头寸规模和测试和评估策略。

本章探讨了引领机器学习成为投资行业竞争优势来源的行业趋势。我们还将研究机器学习在投资过程中的定位,以实现算法交易策略。具体而言,我们将涵盖以下主题:

  • 促使机器学习在投资行业崛起的关键趋势

  • 利用机器学习设计和执行交易策略

  • 交易中机器学习的流行用例

您可以在 GitHub 存储库的本章 README 文件中找到其他资源和参考资料的链接(github.com/PacktPublishing/Machine-Learning-for-Algorithmic-Trading-Second-Edition)。

机器学习在投资行业的崛起

在过去几十年里,投资行业发生了巨大变革,并在竞争加剧、技术进步和具有挑战性的经济环境中继续发展。本节回顾了塑造整体投资环境和算法交易及机器学习使用的关键趋势。

推动算法交易和机器学习走向目前突出地位的趋势包括:

  • 市场微观结构的变化,例如电子交易的普及和跨资产类别和地理区域的市场整合

  • 风险因子暴露为框架制定的投资策略的发展,而不是资产类别

  • 计算能力数据生成与管理以及统计方法革命,包括深度学习的突破

  • 算法交易的先驱相对于人类、自主投资者的超额表现

此外,2001 年和 2008 年的金融危机影响了投资者对分散化和风险管理的态度。其中一个结果是被动投资工具的兴起,即交易所交易基金(ETF)形式的低成本被动投资工具

在 2008 年危机引发的低收益和低波动性背景下,领先央行大规模购买资产,注重成本的投资者将超过 3.5 万亿美元从主动管理的共同基金转移到被动管理的 ETF。

竞争压力还体现在对冲基金费用的降低,这些费用从传统的年管理费 2%和利润提取 20%降至 2017 年分别为平均 1.48%和 17.4%。

从电子交易到高频交易

自 20 世纪 60 年代网络开始将价格路由到计算机终端以来,电子交易在能力、交易量、资产类别覆盖范围和地理范围方面取得了巨大进步。股票市场在全球范围内一直处于这一趋势的前沿。有关金融市场相关变化的全面报道,请参阅 Harris(2003 年)和 Strumeyer(2017 年);在下一章中,我们将回顾如何处理市场和基本数据时再次讨论这个话题。

1997 年,SEC 发布的订单处理规则通过电子通讯网络ECNs)向交易所引入了竞争。 ECN 是自动化的替代交易系统ATS),可以按指定价格匹配买卖订单,主要用于股票和货币交易,并注册为经纪人。它允许不同地理位置的重要经纪公司和个人交易者直接进行交易,无需中介,无论是在交易所内还是在交易所外的交易时间。

暗池是另一种私人 ATS,允许机构投资者交易大额订单,而不公开其信息,与交易所在 ECN 竞争之前管理其订单簿的方式相反。暗池不公布交易前的买入和卖出报价,交易价格仅在执行后一段时间后才变为公开。自 2000 年代中期以来,它们已大幅增长,占美国股票交易的 40%,原因是对大额订单不利价格波动和高频交易者操纵订单的担忧。它们通常设在大型银行内,并受 SEC 监管。

随着电子交易的兴起,成本有效执行的算法迅速发展,并且从卖方向买方和跨资产类别迅速传播。自 2000 年左右,自动交易作为一种旨在实现成本有效执行的卖方工具出现,将订单分解为较小的、序列化的块,以限制其市场影响。这些工具传播到买方,并且通过考虑交易成本和流动性、以及短期价格和交易量预测等因素而变得越来越复杂。

直接市场访问DMA)通过允许交易者使用交易所成员经纪人的基础设施和市场参与者身份直接向交易所发送订单,从而使交易者对执行拥有更大的控制权。赞助访问通过经纪人移除了交易前的风险控制,并形成了高频交易HFT)的基础。

HFT 是指金融工具中以极低的延迟在微秒范围内执行的自动化交易,并且参与者持有非常短的持仓时间。其目标是检测和利用市场微观结构的效率低下,即交易场所的制度基础设施。

在过去的 10 年里,HFT 已大幅增长,并估计在美国股市交易量中占约 55%,在欧洲股市交易量中占约 40%。HFT 在期货市场也已大幅增长,占外汇期货交易量的大约 80%,以及利率期货和国债 10 年期期货交易量的三分之二(Miller 2016)。

HFT(高频交易)策略旨在利用被动或主动策略每次交易赚取小额利润。被动策略包括套利交易,以从不同交易场所交易的同一资产或其衍生品的极小价格差异中获利。主动策略包括订单预测或动量点火。订单预测,也称为流动性检测,涉及提交小型探索性订单的算法,以检测大型机构投资者的隐藏流动性,并在大订单之前进行交易,从随后的价格变动中获利。动量点火意味着一个算法执行并取消一系列订单,以欺骗其他 HFT 算法更积极地买入(或卖出)并从结果的价格变化中获利。

监管机构对某些激进的 HFT 策略与市场脆弱性和波动性增加之间的潜在联系表示关注,例如 2010 年 5 月的闪电崩盘、2014 年 10 月的国库市场波动以及 2015 年 8 月 24 日道琼斯工业平均指数暴跌超过 1000 点。与此同时,由于 HFT 的存在,市场流动性随着交易量的增加而增加,这降低了整体交易成本。

交易量减少、波动性降低以及技术成本和获取数据和交易场所的访问成本上升的组合导致了财务压力。根据估计,2017 年美国股票的 HFT 总收入首次跌破了 2008 年以来的 10 亿美元,从 2009 年的 79 亿美元下降。这一趋势导致了行业整合,例如,由最大的上市专营交易公司 Virtu Financial 进行的各种收购,以及共享基础设施投资,例如芝加哥到东京之间的新 Go West 超低延迟路线。与此同时,像 Alpha Trading Labs 这样的初创公司正在通过为分享利润的算法众包使 HFT 交易基础设施和数据变得可用,以使 HFT 民主化。

因子投资和智能贝塔基金

资产提供的回报是与投资相关的不确定性或风险的函数。例如,股票投资意味着承担公司的业务风险,而债券投资则意味着违约风险。在特定风险特征预测回报的程度上,识别和预测这些风险因素的行为成为设计投资策略时的主要关注点。它产生了有价值的交易信号,并且是实现优秀主动管理结果的关键。随着时间的推移,行业对风险因素的理解已经发生了非常大的变化,并且已经影响了 ML 用于交易的方式。第四章金融特征工程 - 如何研究 Alpha 因素,以及第五章投资组合优化和绩效评估,将更深入地探讨这里概念的实际应用;详见 Ang(2014)进行全面的覆盖。

现代投资组合理论MPT)引入了对给定资产的特异性和系统性风险来源的区分。特异性风险可以通过分散化来消除,但系统性风险则不能。20 世纪 60 年代初,资本资产定价模型CAPM)确定了推动所有资产回报的单一因素:市场投资组合超过国库券的回报。市场投资组合由所有可交易证券组成,按其市值加权。资产对市场的系统性暴露由贝塔来衡量,即资产回报与市场投资组合回报之间的相关性。

资产风险不仅仅取决于单个资产,而是取决于它与其他资产和整个市场的相对运动,这是一个重大的概念性突破。换句话说,资产根据它们暴露于所有资产共同面临的基础普遍风险,而不是由于它们特定的、特殊的特征,来获得风险溢价

随后,学术研究和行业经验提出了许多关于 CAPM 预测的关键问题,即资产的风险溢价仅取决于其暴露于由资产贝塔测量的单一因素。相反,已经发现了许多额外的风险因素。因素是一种可量化的信号、属性或任何变量,它在历史上与未来的股票回报相关,并且预计在未来仍然相关。

这些风险因素被标记为异常因素,因为它们与有效市场假说EMH)相矛盾。EMH 认为市场均衡总是根据 CAPM 定价证券,因此其他因素不应具有预测能力(Malkiel 2003)。因素背后的经济理论可以是理性的,即因素风险溢价补偿了在不景气时期的低回报,也可以是行为的,即代理人未能套利掉多余的回报。

知名的异常包括价值、规模和动量效应,它们有助于预测回报,同时控制 CAPM 市场因素。规模效应依赖于小型公司系统性地胜过大型公司(班兹,1981 年;Reinganum,1981 年)。价值效应(Basu 等,1981 年)表明具有低估价指标的公司胜过具有相反特征的同行。它表明,具有低价格倍数(例如市盈率或市净率)的公司比它们更昂贵的同行表现更好(正如价值投资的发明者本杰明·格雷厄姆和大卫·多德所建议,并由沃伦·巴菲特所推广)。

动量效应是在 1980 年代晚期被发现的,其中包括 Clifford Asness 等人,AQR 的创始合伙人,它表明,具有良好动量的股票,即近 6-12 个月回报表现良好的股票,未来的回报比市场风险相似的动量较差的股票高。研究人员还发现,价值和动量因素解释了美国以外股票的回报,以及其他资产类别,如债券、货币和大宗商品,以及其他风险因素(Jegadeesh 和 Titman,1993 年;Asness,Moskowitz 和 Pedersen,2013 年)。

在固定收益领域,价值策略被称为顺势买入收益率曲线,是一种期限溢价形式。在大宗商品领域,它被称为卷动回报,如果期货曲线呈上升趋势,则为正回报,否则为负回报。在外汇市场,价值策略被称为持有

还存在非流动性溢价。更不流动的证券以低价格交易,并且相对于更流动的同行,具有高平均超额回报。具有较高违约风险的债券的平均回报往往更高,反映了信用风险溢价。由于投资者愿意为避免回报暴跌而支付高波动性保险,因此在期权市场上出售波动性保护的卖方往往获得高回报。

多因素模型比市场组合更广泛、更多样地定义了风险。1976 年,史蒂芬·罗斯提出了套利定价理论,它主张投资者会因为无法分散的多个系统性风险而获得补偿(Roll 和 Ross,1984 年)。最重要的三个宏观因素是增长、通胀和波动性,除此之外还有生产力、人口统计和政治风险。1993 年,尤金·法玛和肯尼斯·弗伦奇将股票风险因素的规模和价值与市场因素结合成一个单一的三因子模型,更好地解释了横截面股票回报。他们后来添加了一个同时解释两个资产类别回报的模型,该模型还包括债券风险因素(法玛和弗伦奇,1993 年;2015 年)。

风险因素尤其吸引人的一点是它们的低或负相关性。例如,价值和动量风险因素呈负相关,降低了风险,并使风险调整后的回报超过了风险因素所暗示的收益。此外,利用杠杆和多空策略,因子策略可以组合成市场中性方法。在暴露于正面风险的证券中开设多头仓位,并在暴露于负面风险的证券中减仓或建立空头仓位的组合,使得动态风险溢价得以收集。

因此,解释超出 CAPM 的回报的因素被纳入了倾向于支持一个或多个因素的投资风格中,资产开始向基于因子的投资组合转移。2008 年金融危机突显了当投资者不关注基础因子风险时,资产类别标签可能极具误导性并造成虚假的分散化感,因为资产类别同时暴跌。

在过去几十年中,量化因子投资已经从基于两种或三种风格的简单方法演变为多因子智能或异类基准产品。智能基金在 2017 年突破了 1 万亿美元的资产管理规模,证明了这种混合投资策略的普及程度,该策略将主动和被动管理相结合。智能基金采取被动策略,但根据一个或多个因素进行修改,例如选择更便宜的股票或根据股息支付进行筛选,以产生更好的回报。这种增长与对传统主动管理者收取高额费用的批评以及对其业绩加强的审查趋势相一致。

在投资行业中,发现并成功预测风险因素的持续影响未来资产回报的过程,无论是单独还是与其他风险因素结合,都是机器学习激增的关键驱动因素,并将贯穿本书的主题。

算法先驱胜过人类

领先引入算法交易的公司的资产管理规模AUM)的业绩记录和增长,在激起投资者兴趣以及后续行业努力复制其成功方面发挥了关键作用。系统性基金与高频交易不同之处在于,交易可能持有时间更长,同时寻求利用套利机会,而不是仅仅追求速度优势。

大多或完全依赖算法决策的系统性策略最著名地由数学家詹姆斯·西蒙斯引入,他于 1982 年创立了文艺复兴技术,并将其打造成首屈一指的量化公司。其神秘的 Medallion 基金,对外关闭,自 1982 年以来年化回报率估计为 35%。

D. E. Shaw、Citadel 和 Two Sigma,这三家最著名的量化对冲基金,使用基于算法的系统化策略,在 2017 年首次成为总收益前 20 名的表现最好的基金之一,扣除费用,并自创立以来。

D. E. Shaw 公司成立于 1988 年,2019 年资产管理规模达到 500 亿美元,排名第三。由 Kenneth Griffin 于 1990 年创立的 Citadel 公司管理着 320 亿美元,排名第五。Two Sigma 公司仅在 2001 年由 D. E. Shaw 的前员工 John Overdeck 和 David Siegel 创立,从 2011 年的 80 亿美元资产管理规模增长到 2019 年的 600 亿美元。Bridgewater公司由雷·达里奥于 1975 年创立,2019 年资产管理规模超过 1600 亿美元,并且由于其 Pure Alpha 基金而继续领先,该基金还融合了系统化策略。

同样地,在《机构投资者》2018 年对冲基金 100 强榜单上,前四家最大的公司和前六家公司中的五家主要或完全依赖计算机和交易算法来做投资决策,并且它们在一个具有挑战性的环境中不断增加其资产。一些量化型公司上升了排名,并在某些情况下以两位数的百分比增加了其资产。排名第二的Applied Quantitative ResearchAQR)公司在 2017 年将其对冲基金资产增加了 48%,2018 年增加了 29%,达到了近 900 亿美元。

由机器学习驱动的基金吸引了 1 万亿美元的资产管理规模。

计算能力、数据可用性和统计方法的三次革命使得系统化、数据驱动策略的采用不仅更加引人注目和具有成本效益,而且是竞争优势的关键来源。

因此,算法方法不仅在率先采用这些策略的对冲基金行业中找到了更广泛的应用,而且还在更广泛的资产管理公司以及 ETF 等被动管理车辆中找到了应用。特别是,利用机器学习和算法自动化的预测分析在投资过程的各个环节中扮演着越来越重要的角色,包括从构思和研究到战略制定和组合构建、交易执行和风险管理等各个资产类别。

行业规模的估计各不相同,因为没有对定量或算法基金的客观定义。许多传统的对冲基金甚至包括共同基金和 ETF 都在引入基于计算机的策略或将其融入到人加机器的环境中的自主性方法中。

根据经济学人杂志,2016 年,系统性基金成为了美国股市机构交易的最大推动力量(忽略高频交易,高频交易主要充当中间商)。到 2019 年,它们占据了机构交易量的 35%以上,而 2010 年仅为 18%;仅有 10%的交易仍然是由传统的股票基金所致。以罗素 3000 指数衡量,美国股票的价值约为 31 万亿美元。三种由计算机管理的基金—指数基金、交易所交易基金(ETFs)和量化基金—约占了 35%,而传统对冲基金和其他互惠基金的人类管理者仅占 24%。

市场研究公司 Preqin 估计,几乎有 1500 家对冲基金的大部分交易都是依靠计算机模型的帮助。量化对冲基金现在负责投资者进行的所有美国股票交易的 27%,而 2013 年仅为 14%。但是很多使用数据科学家—或称之为量化交易员—他们反过来使用机器来建立大型统计模型。

然而,近年来,基金已经转向真正的机器学习,人工智能系统能够以速度分析大量数据,并通过这些分析改善自身。最近的例子包括 Rebellion Research、Sentient 和 Aidyia,它们依靠进化算法和深度学习来设计完全自动的人工智能AI)驱动的投资平台。

从核心对冲基金行业,算法策略的采用已经扩展到了互惠基金甚至被动管理的交易所交易基金,以智能贝塔基金的形式,以及自主基金的量化方法的形式。

量化基金的出现

主动投资管理已经演变出了两种不同的方法:系统性或量化)和自主投资。系统性方法依赖算法来识别跨许多证券的投资机会的可重复和数据驱动方法。相比之下,自主方法涉及对少量证券基本面的深入分析。随着基本面经理采取更多的数据科学驱动方法,这两种方法变得越来越相似。

即使基本面交易员现在也装备了定量技术,根据巴克莱的数据,占据了 55 亿美元的系统性资产。与特定公司无关,量化基金根据跨越广泛证券范围的模式和动态进行交易。根据巴克莱 2018 年编制的数据,这样的量化交易员占据了总对冲基金资产的约 17%

Point72,资产规模达到 140 亿美元,已经将大约一半的投资组合经理转向了人加机器的方法。Point72 还投资数千万美元到一个分析大量替代数据并将结果传递给交易员的团队中。

战略能力投资

三个趋势推动了数据在算法交易策略中的使用,并可能进一步将投资行业从裁量性转向定量风格:

  • 数字数据的可用性呈指数级增长

  • 计算能力和数据存储容量的成本降低导致了它们的增加

  • 用于分析复杂数据集的统计方法的进展

对相关能力——技术、数据以及最重要的是熟练人员——的投资不断增加,突显了利用机器学习进行算法交易对竞争优势的重要性,尤其是考虑到自 2008 年金融危机以来,被动指数投资工具(如 ETF)的流行度不断上升。

摩根士丹利指出,仅有 23%的量化客户表示他们不考虑使用或尚未使用机器学习,而 2016 年这一比例为 44%。古根海姆合伙公司在加利福尼亚州的劳伦斯伯克利国家实验室为 100 万美元建立了一个所谓的超级计算机集群,帮助为古根海姆的量化投资基金进行数据处理。电脑的电费每年还要花费 100 万美元。

AQR是一家依靠学术研究来识别并系统交易长期以来证明能够击败整体市场的因素的量化投资集团。该公司曾经回避了像文艺复兴技术或 DE Shaw 等量化同行纯计算机驱动的策略。然而,最近,AQR 已经开始使用机器学习在市场上寻找有利可图的模式,以分析新的数据集,例如油井和油船投射的卫星图片。

领先的公司黑石集团,管理着超过 5 万亿美元的资产,也通过大量投资于 SAE,一家在金融危机期间收购的系统性交易公司,来打败裁量性基金经理,押注算法。富兰克林·坦普尔顿公司以未披露的金额收购了 Random Forest Capital,一家以债务为重点、以数据为导向的投资公司,希望其技术能支持更广泛的资产管理。

机器学习和替代数据

信息优势和发现新的不相关信号的能力长期以来一直是对冲基金追求阿尔法的目标。历史上,这包括诸如对购物者的专有调查,或者对选举或公投前的选民的调查。

偶尔,利用公司内部人员、医生和专家网络来扩展对行业趋势或公司的了解,可能会越过法律界限:自 2010 年以来,一系列针对交易员、投资组合经理和分析师使用内部信息的起诉已经动摇了该行业。

相比之下,利用机器学习开发常规和替代数据源的信息优势不取决于专家和行业网络或者对公司管理层的接触,而是取决于收集大量非常多样的数据源并实时分析这些数据的能力。

传统数据包括经济统计数据、交易数据或公司报告。替代数据范围更广,包括卫星图像、信用卡销售、情感分析、移动地理位置数据和网站抓取等来源,以及将业务日常生成的数据转化为有价值情报。从原则上讲,它包括任何包含(潜在的)交易信号的数据来源

例如,来自保险公司关于新汽车保险政策销售的数据不仅捕捉到了新车销售的数量,还可以分解成品牌或地理区域。许多供应商从网站中抓取有价值的数据,范围从应用程序下载和用户评论到航空公司和酒店预订。社交媒体网站也可以被爬取以获取消费者观点和趋势的提示。

通常,这些数据集很大,需要使用可扩展的数据解决方案进行存储、访问和分析,例如 Hadoop 和 Spark。据德意志银行称,全球有超过 10 万亿个网页的 10 亿个网站,共有 500 艾字节(或 5000 亿吉字节)的数据。每年有超过 1 亿个网站被添加到互联网上。

在公司的业绩公布之前,可以通过其网站上职位招聘数量的下降、员工在招聘网站 Glassdoor 上对其首席执行官的内部评价或其网站上衣物平均价格的下降获得对公司前景的实时见解。这些信息可以与汽车停车场的卫星图像和移动电话的地理位置数据相结合,后者可以指示有多少人正在访问商店。另一方面,战略动向可以从特定功能区域或特定地理位置的职位发布量的增加中获得。

最有价值的信息来源之一是直接揭示消费者支出的数据,其中信用卡信息是主要来源。这些数据只提供了销售趋势的部分视图,但与其他数据结合使用时可以提供重要的见解。例如,Point72 曾经一度每天分析 8000 万笔信用卡交易。我们将在第三章金融替代数据-分类和用例中详细探讨各种数据来源、它们的用例以及如何评估它们。

投资集团在过去两年中将他们在替代数据和数据科学家上的支出翻了一番,因为资产管理行业试图振兴其日渐衰落的命运。在 2018 年 12 月,alternativedata.org(由 Yipit 提供支持)列出了 375 家替代数据提供商。

2017 年,资产管理公司总共花费了 3.73 亿美元用于数据集和雇佣新员工来解析这些数据,比 2016 年增长了 60%,今年可能会总共花费 6.16 亿美元,根据 alternativedata.org 投资者的调查。该网站预测,到 2020 年,总体支出将上升到 10 亿美元以上。一些估计甚至更高:咨询公司 Optimus 估计,投资者每年在替代数据上的支出约为 50 亿美元,并预计未来几年该行业将以每年 30% 的速度增长。

随着对宝贵数据源的竞争加剧,数据源合同中的排他性安排成为一个关键特征,以保持信息优势。与此同时,隐私问题日益严重,监管机构已经开始关注当前基本没有受到监管的数据提供商行业。

众包交易算法

近年来,一些算法交易公司开始提供投资平台,该平台提供数据访问和编程环境,以众包方式获取风险因素,这些因素成为投资策略或整个交易算法的一部分。主要例子包括 WorldQuant、Quantopian,以及最近的 Alpha Trading Labs(于 2018 年推出)。

WorldQuant 是 Millennium Management(资产管理规模:410 亿美元)于 2007 年拆分出来的子公司,为该公司管理大约 50 亿美元的资金。在其 alpha 工厂中,它雇佣了数百名科学家和更多的兼职工人,这个工厂将投资过程组织为一个定量流水线。该工厂声称已经生产了 400 万个成功测试的 alpha 因子,用于更复杂的交易策略,并且目标是 1 亿个。每个 alpha 因子都是一个算法,旨在预测未来的资产价格变化。然后其他团队将 alpha 因子组合成策略,策略组合成投资组合,在投资组合之间分配资金,并管理风险,同时避免相互损害的策略。请参见附录Alpha 因子库,了解 WorldQuant 使用的几十个定量因子的示例。

设计和执行基于机器学习的策略

在本书中,我们演示了机器学习如何融入设计执行评估交易策略的整个过程。为此,我们将假设基于机器学习的策略是由包含目标领域和策略的预测信号的数据源驱动的,这些信号经过适当的预处理和特征工程后,允许机器学习模型预测资产回报或其他策略输入。模型预测又会转化为基于人工裁量或自动规则的买入或卖出订单,这些规则又可以由另一个机器学习算法通过端到端的方法进行手动编码或学习。

图 1.1 描绘了这个工作流程的关键步骤,这也塑造了本书的组织结构:

图 1.1

图 1.1:ML4T 工作流程

第一部分介绍了适用于不同策略和 ML 用例的重要技能和技术。这些包括以下内容:

  • 如何获取和管理重要数据源

  • 如何设计具有信息量的特征或 alpha 因子以提取信号内容

  • 如何管理投资组合并跟踪策略绩效

此外,在第二部分的第八章ML4T 工作流程 - 从模型到策略回测中,涵盖了策略回测。在转向相关 ML 用例之前,我们将简要概述这些领域,这些用例构成本书第 2、3 和 4 部分的大部分内容。

数据的采集和管理

在数据可用性方面的体积,种类和速度的戏剧性演变是对 ML 应用于交易的重要补充,反过来又推动了行业对获取新数据源的支出。然而,数据供应的不断增加需要谨慎选择和管理,以发现潜在价值,包括以下步骤:

  1. 识别和评估不会迅速衰减的市场,基本和替代数据源中包含的 alpha 信号。

  2. 部署或访问基于云的可扩展数据基础设施和分析工具,如 Hadoop 或 Spark,以便快速,灵活地访问数据。

  3. 通过在特定时间点上将数据进行调整,精心管理和筛选数据以避免前瞻偏差。这意味着数据应该只反映在给定时间可用和已知的信息。在扭曲的历史数据上训练的机器学习算法几乎肯定会在实时交易中失败。

我们将在第二章市场和基本数据 - 来源和技术,以及第三章金融替代数据 - 类别和用例中详细介绍这些方面。

从 alpha 因子研究到投资组合管理

Alpha 因子旨在从数据中提取信号,以预测给定投资范围在交易期内的回报。典型因子在评估给定时间点时对每个资产都采用单个值,但可能组合一个或多个输入变量或时间段。如果您已经熟悉 ML 工作流程(参见第六章机器学习过程),您可以将 alpha 因子视为专为特定策略设计的领域特定特征。使用 alpha 因子涉及研究阶段和执行阶段,如图 1.2所述:

图 1.2:alpha 因子研究过程

研究阶段

研究阶段包括设计和评估 alpha 因子。预测因子捕获了数据源和重要策略输入(如资产回报)之间系统关系的某些方面。优化预测能力需要通过有效的数据转换进行创造性的特征工程。

由于数据挖掘导致的虚假发现是一个需要仔细管理的重要风险。降低这种风险的一种方式是通过遵循几十年的学术研究指导进行搜索,该研究产生了几个诺贝尔奖。许多投资者仍然更喜欢与金融市场和投资者行为理论相一致的因素。阐述这些理论超出了本书的范围,但参考资料突显了深入探讨这一重要框架的途径。

验证 alpha 因子的信号内容需要在代表性环境中得到其预测能力的健壮估计。有许多可能破坏可靠估计的方法论和实际陷阱。除了数据挖掘和未对多重检验偏差进行校正之外,这些陷阱还包括使用受到存活或前瞻性偏差污染的数据,以及不反映现实主、息、税PIT)信息。第四章金融特征工程 - 如何研究 Alpha 因子,讨论了如何成功地管理这个过程。

执行阶段

执行阶段,alpha 因子发出信号,导致买入或卖出订单。由此产生的投资组合持有,反过来又具有特定的风险配置,相互交互并对整体投资组合风险做出贡献。投资组合管理涉及优化仓位大小,以实现与投资目标一致的投资组合收益和风险的平衡。

第五章投资组合优化和绩效评估,介绍了适用于交易策略工作流程的这个阶段的关键技术和工具,从投资组合优化到绩效评估。

策略回测

将投资理念纳入实际算法策略中意味着需要科学方法来处理重大风险。这种方法包括通过广泛的经验性测试来拒绝基于其在备选样本市场情景中的表现而接受该理念。测试可能涉及使用模拟数据来捕捉可能发生但未反映在历史数据中的情景。

要为候选策略获得无偏的性能估计,我们需要一个回测引擎,以真实的方式模拟其执行。除了数据引入的潜在偏差或统计使用上的缺陷之外,回测引擎还需要准确地表现出交易信号评估、订单下达和执行方面的实际情况,符合市场条件。

第八章ML4T 工作流程 - 从模型到策略回测,展示了如何使用 backtrader 和 Zipline,并应对多种方法上的挑战,并完成了端到端 ML4T 工作流程的介绍。

用于交易的机器学习 - 策略和用例

实际上,我们将 ML 应用于特定策略的交易,以达到特定的业务目标。在本节中,我们简要描述了交易策略的演变和多样化,并概述了 ML 应用的实际例子,重点介绍了它们与本书内容的关系。

算法策略的演变

定量策略已经在三个阶段中发展并变得更加复杂:

  1. 在 20 世纪 80 年代和 90 年代,信号通常来源于学术研究,并使用从市场和基本数据派生的单一或极少量的输入。今天最大的定量对冲基金之一 AQR 于 1998 年成立,以大规模实施此类策略。这些信号现在主要是商品化的,并作为 ETF 提供,例如基本的均值回归策略。

  2. 在 2000 年代,基于因子的投资在 Eugene Fama 和 Kenneth French 等人的开创性工作基础上迅速蔓延。基金使用算法识别暴露于价值或动量等风险因素的资产,寻求套利机会。在金融危机早期的赎回触发了 2007 年 8 月的量化地震,这一地震经由基于因子的基金行业传播开来。这些策略现在也作为仅多头的智能 beta 基金提供,根据给定的风险因素倾斜投资组合。

  3. 第三个时代是通过投资于ML 能力和替代数据来生成可重复交易策略的盈利信号。因子衰减是一个主要挑战:新的异常收益从发现到发布后已经降低了四分之一,由于竞争和拥挤,发布后降低了超过 50%。

今天,当交易者使用算法执行规则时,他们追求一系列不同的目标:

  • 旨在实现有利定价的交易执行算法

  • 旨在从小的价格波动中获利的短期交易,例如由于套利而引起的

  • 旨在预测其他市场参与者行为的行为策略

  • 基于绝对价格和相对价格和回报预测的交易策略

交易执行程序旨在限制交易的市场影响,范围从简单的切片交易以匹配按时间加权或按成交量加权的平均定价。简单的算法利用历史模式,而更复杂的版本则考虑交易成本、执行差距或预测的价格波动。

高频交易基金最突出地依赖非常短的持有期,以从买卖价差或统计套利的微小价格波动中获益。行为算法通常在流动性较低的环境中运行,并旨在预测具有显著价格影响的较大玩家的动作,例如依据生成对其他市场参与者策略洞察的嗅探算法。

在本书中,我们将重点放在根据对不同时间范围内相对价格变化的预期进行交易的策略上,这些策略不受延迟优势的影响,因为它们被广泛使用且非常适合 ML 的应用。

用于交易的 ML 用例

ML 能够从各种市场、基本和替代数据中提取可交易的信号,因此适用于针对一系列资产类别和投资期限的策略。然而,更普遍地说,它是一个灵活的工具,用于支持或自动化具有可量化目标和数字数据相关的决策。因此,它可以应用于交易过程的几个步骤。在不同类别中有许多用例,包括:

  • 数据挖掘以识别模式、提取特征和生成洞察

  • 监督学习生成风险因素或 alpha 并创建交易思路

  • 将个别信号聚合成策略

  • 根据算法学习的风险配置资产

  • 策略的测试和评估,包括使用合成数据

  • 使用强化学习进行交互式自动化策略的优化

我们简要介绍了其中一些应用,并指出我们将在后面的章节中演示它们的用途。

数据挖掘以进行特征提取和洞察

大规模、复杂数据集的成本效益评估需要大规模检测信号。本书中有几个例子:

  • 信息论有助于估计候选特征的信号内容,因此有助于从 ML 模型中提取最有价值的输入。在第四章金融特征工程 - 如何研究 Alpha 因子中,我们使用互信息来比较个别特征对监督学习算法预测资产收益的潜在价值。De Prado(2018)的第十八章估计了价格序列的信息内容,作为在不同交易策略之间做出决策的基础。

  • 无监督学习提供了一系列方法来识别数据中的结构,以获得洞察或帮助解决下游任务。我们提供了几个例子:

    • 第十三章基于数据驱动的风险因素和无监督学习的资产配置中,我们介绍了聚类和降维以从高维数据集生成特征。

    • 第十五章主题建模 - 总结金融新闻中,我们应用贝叶斯概率模型对金融文本数据进行总结。

    • 第二十章自动编码器用于条件风险因素和资产定价中,我们使用深度学习提取根据资产特征条件化的非线性风险因素,并根据 Kelly 等人(2020)预测股票收益。

  • 模型透明度强调了获得对个别变量的预测能力的模型特定方法,并引入了一种新颖的博弈论方法,称为 SHapley 加法解释SHAP)。我们将其应用于具有大量输入变量的梯度提升机,见 第十二章提升您的交易策略,以及 附录Alpha 因子库

用于 alpha 因子创建的监督学习

将 ML 应用于交易的最熟悉理由之一是获得关于资产基本面、价格变动或市场状况的预测。一种策略可以利用多个建立在彼此之上的 ML 算法:

  • 下游模型可以通过整合关于个别资产前景、资本市场预期和证券之间的相关性的预测来在投资组合水平上生成信号。

  • 或者,ML 预测可以像之前概述的量化基本方法那样通知自由交易

ML 预测也可以针对特定的风险因素,例如价值或波动性,或者实施技术方法,例如趋势跟随或均值回归:

  • 第三章金融的另类数据 - 分类和用例 中,我们说明了如何处理基本数据,以创建 ML 驱动的估值模型的输入。

  • 第十四章用于交易的文本数据 - 情感分析第十五章主题建模 - 总结财经新闻,以及 第十六章用于盈利电话和 SEC 报告的词嵌入 中,我们使用了商业评论的另类数据,这些数据可以作为估值练习的输入,用于预测公司的收入。

  • 第九章用于波动率预测和统计套利的时间序列模型 中,我们演示了如何预测宏观变量作为市场预期的输入,以及如何预测诸如波动率之类的风险因素。

  • 第十九章多元时间序列和情感分析的 RNN 中,我们介绍了能够在非线性时间序列数据上取得优越性能的循环神经网络。

资产配置

ML 已经被用于基于决策树模型的资产配置,这些模型计算出一种分层形式的风险均衡。因此,风险特征受到资产价格模式的驱动,而不是资产类别,并且具有优越的风险-回报特性。

第五章投资组合优化和绩效评估,以及 第十三章无监督学习中的数据驱动风险因素和资产配置 中,我们说明了层次聚类如何提取数据驱动的风险类别,这些类别比传统的资产类别定义更好地反映了相关性模式(参见 De Prado 2018 中的 第十六章)。

测试交易理念

回测是选择成功的算法交易策略的关键步骤。使用合成数据进行交叉验证是一种关键的 ML 技术,当与适当的方法结合使用以校正多重测试时,可以生成可靠的样本外结果。财务数据的时间序列性质要求修改标准方法以避免前瞻偏差,否则会污染用于训练、验证和测试的数据。此外,历史数据的有限可用性催生了使用合成数据的替代方法。

我们将展示使用市场、基本和替代数据源测试 ML 模型的各种方法,以获得可靠的样本外错误估计。

第二十一章用于合成时间序列数据的生成对抗网络中,我们介绍了生成对抗网络GANs),它们能够生成高质量的合成数据。

强化学习

交易发生在竞争激烈的互动市场中。强化学习旨在训练代理学习基于奖励的策略函数;它通常被认为是金融 ML 中最有前景的领域之一。例如,参见 Hendricks 和 Wilcox(2014)以及 Nevmyvaka、Feng 和 Kearns(2006)的交易执行应用。

第二十二章深度强化学习 - 构建交易代理中,我们介绍了关键的强化学习算法,如 Q-learning,以演示使用 OpenAI 的 Gym 环境训练交易强化学习算法。

总结

在本章中,我们回顾了算法交易策略的关键行业趋势、替代数据的出现以及利用 ML 利用这些新信息优势的使用。此外,我们介绍了 ML4T 工作流程的关键要素,并概述了 ML 在不同策略背景下用于交易的重要用例。

在接下来的两章中,我们将更深入地研究推动任何算法交易策略的油料——市场、基本和替代数据来源——使用 ML。

第二章:市场和基本数据-来源和技术

数据一直是交易的重要驱动因素,交易员长期以来一直努力获得优势,以获取优越信息的访问权。这些努力至少可以追溯到有关罗斯柴尔德家族通过鸽子携带跨越海峡的关于滑铁卢战役英国胜利的消息事先从债券购买中受益丰厚的传闻。

如今,对更快数据访问的投资呈现为连接芝加哥商品交易所(CME)与东京的领先高频交易HFT)公司的 Go West 财团。CME 与纽约的BATSBetter Alternative Trading System)交易所之间的往返延迟已降至接近理论极限的八毫秒,因为交易者竞争利用套利机会。与此同时,监管机构和交易所已开始引入减速装置,以减缓交易速度,限制对信息不均匀访问的不利影响。

传统上,投资者主要依赖于公开市场和基本数据。通过专有调查等方式创建或获取私有数据集的努力有限。传统策略侧重于股票基本面,并在报告的财务数据上构建财务模型,可能结合行业或宏观数据来预测每股收益和股价。或者,他们利用技术分析从市场数据中提取信号,使用从价格和成交量信息计算出的指标。

机器学习ML)算法承诺比人定义的规则和启发式方法更有效地利用市场和基本数据,特别是当结合替代数据时,这是下一章的主题。我们将阐明如何应用从线性模型到递归神经网络RNNs)的 ML 算法到市场和基本数据,并生成可交易的信号。

本章介绍了市场和基本数据来源,并解释了它们反映的环境。交易环境的细节不仅对市场数据的正确解释至关重要,还对您的策略的设计和执行以及实施逼真的回测模拟至关重要。

我们还说明了如何使用 Python 访问和处理来自各种来源的交易和财务报表数据。

特别是,本章将涵盖以下主题:

  • 市场数据如何反映交易环境的结构

  • 在分钟频率下处理交易和报价数据

  • 从 tick 数据中重建订单簿使用纳斯达克 ITCH

  • 使用各种类型的条形图总结 tick 数据

  • 处理使用可扩展商业报告语言XBRL)编码的电子申报

  • 解析和组合市场和基本数据以创建市盈率P/E)序列

  • 如何使用 Python 访问各种市场和基本数据源

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和额外资源的链接。笔记本包括图像的彩色版本。

市场数据反映了其环境

市场数据是交易者直接或通过中介在众多市场之一下订单的产品,以及订单是如何处理以及价格是如何通过匹配需求和供给来确定的。因此,数据反映了交易场所的制度环境,包括管理订单、交易执行和价格形成的规则和法规。详细全球概览请参阅 Harris (2003),美国市场的详细信息请参阅 Jones (2018)。

算法交易者使用算法,包括机器学习,来分析买卖订单流动以及由此产生的交易量和价格统计数据,以提取捕捉洞察力的交易信号,例如需求供给动态或特定市场参与者的行为。

在我们开始使用由纳斯达克这样的环境创建的实际刻度数据之前,我们将首先回顾在回测期间影响交易策略模拟的制度特征。

市场微观结构 – 细枝末节

市场微观结构研究了制度环境如何影响交易过程,以及形成价格发现、买卖报价和报价、日内交易行为以及交易成本等结果(Madhavan 2000; 2002)。它是金融研究中增长最快的领域之一,受到算法交易和电子交易的快速发展推动。

如今,对冲基金赞助内部分析师跟踪快速发展的、复杂的细节,并确保以最佳市场价格执行,并设计利用市场摩擦的策略。在我们深入研究交易生成的数据之前,我们将简要概述这些关键概念。参考文献中包含了许多详细介绍这一主题的来源。

如何交易 – 不同类型的订单

交易者可以下不同类型的买入或卖出订单。有些订单保证立即执行,而其他订单可能说明价格阈值或触发执行的其他条件。订单通常在同一交易日有效,除非另有说明。

市价订单旨在在到达交易场所时立即执行订单,以当时的价格执行。相比之下,限价订单仅在市场价格高于卖出限价订单的限价或低于买入限价订单的限价时执行。止损订单则仅在市场价格升至指定价格以上时对买入止损订单生效,或降至指定价格以下时对卖出订单生效。买入止损订单可用于限制空头交易的损失。止损订单也可能有限制条件。

订单可以附加许多其他条件。例如,全部或无订单防止部分执行;只有指定数量的股票可用时才会填充,并且可以有效期一天或更长时间。它们需要特殊处理,并且对市场参与者不可见。全部成交或取消订单也防止部分执行,但如果立即未执行则取消。立即成交或取消订单立即购买或出售可用数量的股票,并取消剩余部分。不指定时间的订单允许经纪人决定执行的时间和价格。最后,市场在 开盘/收盘 订单在市场开盘或收盘时执行或接近执行。部分执行是允许的。

何处交易 - 从交易所到深池

证券在高度组织化和受监管的交易所交易,或者在场外交易OTC)市场以不同程度的形式交易。交易所是买家和卖家竞争最低的要价和最高的出价的中央市场。交易所监管通常会实施上市和报告要求,以创建透明度并吸引更多的交易者和流动性。场外交易市场,例如最佳市场(OTCQX)或风险市场(OTCQB),通常具有较低的监管壁垒。因此,它们适用于更广泛范围的证券,包括债券或美国存托凭证ADRs;例如,在外国交易所上市的股票,例如雀巢公司)。

交易所可能依靠双边交易或根据特定规则匹配所有买入和卖出订单的集中式订单驱动系统。许多交易所使用提供流动性的中介,这些中介包括充当自己代理的交易商和代表他人交易的经纪人。价格形成可能通过拍卖进行,例如在纽约证券交易所NYSE)中,最高的出价和最低的要价被匹配,或者通过从卖方手中购买并向买方出售的交易商进行。

从前,公司要么在纽约证券交易所(NYSE)注册并进行交易,要么在场外交易市场如纳斯达克进行交易。在纽约证券交易所,一位专家负责中介特定证券的交易。专家通过经纪人接收买入和卖出订单,并在一个中央订单簿中跟踪限价订单。限价订单根据价格和时间优先执行。买入市价订单被路由到专家以与限价订单簿中最低的要价成交(卖出市价订单被路由到专家以与最高的出价成交),在平局的情况下优先考虑较早的限价订单。专家可以访问中央订单簿中的所有订单,从而公布最佳买价、卖价,并根据整体买卖失衡情况设置市场价格。

在纳斯达克,多个市场创造者促成了股票交易。每个经销商都向中央行情系统提供他们的最佳买入和卖出价格,并准备按指定价格以及指定数量进行交易。交易员通过他们的经纪人将订单路由到最佳报价的市场创造者处。订单的竞争很可能使执行价格公平。市场创造者确保了一个公平和有序的市场,提供了流动性,并像专家一样传播价格,但只能访问路由到他们的订单,而不是整个市场的供需。这种碎片化可能会导致难以确定公平价值市场价格的困难。

如今,交易已经分散;在美国,不再只有两个主要的交易场所,而是有十三个以上的展示交易场所,包括交易所和(未受监管的)另类交易系统ATSs),如电子通讯网络ECNs)。每个场所以不同的延迟报告交易到综合带,但规则不同,有多种不同的定价和排队模型。

下表列出了一些较大的全球交易所及截至 03/2018 12 个月的各类资产交易量,包括衍生品。通常,少数金融工具占据了大部分交易:

交易所 股票
市值(亿美元) 上市公司数量
纽约证券交易所 23,138,626
纳斯达克 - 美国 10,375,718
日本交易所集团公司 6,287,739
上海证券交易所 5,022,691
欧洲交易所 4,649,073
香港交易及结算所 4,443,082
伦敦证券交易所集团 3,986,413
深圳证券交易所 3,547,312
德国交易所 2,339,092
印度孟买证券交易所有限公司 2,298,179
印度国家证券交易所有限公司 2,273,286
BATS 全球市场 - 美国
芝加哥期权交易所
国际证券交易所

前面提到的 ATS 包括数十个允许交易者匿名执行的暗池。据估计,2017 年,暗池交易占所有美国股票交易的 40%,而 2010 年估计为 16%。暗池出现在 20 世纪 80 年代,当时 SEC 允许经纪人匹配大宗股票的买方和卖方。高频电子交易的兴起和 2007 年 SEC 颁布的旨在通过透明度刺激竞争并削减交易成本的规则,作为全国市场体系监管Reg NMS)的一部分,推动了暗池的增长,因为交易者旨在避免大宗交易的可见性(Mamudi 2017)。Reg NMS 还建立了国家最佳买卖价NBBO)的要求,要求经纪人将订单路由到提供最佳价格的交易场所。

一些 ATS 被称为暗池,因为它们不广播预先交易的数据,包括传统交易所需要做的买卖订单的存在、价格和数量。然而,暗池在交易发生后向金融业监管局FINRA)报告有关交易的信息。因此,暗池在交易执行之后才开始对价格发现过程产生影响,但在第一章中列出的各种高频交易策略提供了保护。

在下一节中,我们将看到市场数据如何捕捉交易活动并反映美国市场的机构基础设施。

处理高频数据

两类市场数据涵盖了在美国交易所上市的数千家公司,这些公司在 Reg NMS 下交易:整合信息源将来自每个交易场所的交易和报价数据合并在一起,而每个独立的交易所则提供了专有产品,其中包含了该特定场所的额外活动信息。

在本节中,我们首先会展示纳斯达克提供的专有订单流数据,该数据代表了订单、交易和随之发生的价格的实际流动,按 tick 逐步进行。然后,我们将演示如何将这个连续的、不定时间到达的数据流规范化为固定时间间隔的条形图。最后,我们将介绍 AlgoSeek 的股票分钟条数据,其中包含了整合的交易和报价信息。在每种情况下,我们都将说明如何使用 Python 处理这些数据,以便您可以为您的交易策略利用这些来源。

如何处理纳斯达克订单簿数据

市场数据的主要来源是订单簿,它在整个交易日实时更新以反映所有交易活动。交易所通常以实时服务的形式提供这些数据,收费;然而,它们可能免费提供一些历史数据。

在美国,股票市场提供了三个层次的报价,即一级、二级和三级,这些报价提供了越来越细化的信息和功能:

  • 一级(L1):实时的买卖价信息,可从众多在线来源获取。

  • 二级(L2):提供特定市场做市商的买入和卖出价格信息,以及最近交易的规模和时间,以更好地了解给定股票的流动性。

  • 三级(L3):增加了输入或更改报价、执行订单和确认交易的能力,仅提供给市场做市商和交易所成员公司。访问三级报价允许注册经纪人满足最佳执行要求。

交易活动反映在由市场参与者发送的众多 订单消息 中。这些消息通常符合电子金融信息交换 (FIX) 通信协议,用于证券交易和市场数据的实时交换,或者符合本地交易所协议。

用 FIX 协议进行交易通信

就像 SWIFT 是后台(例如在交易结算中)消息传递的消息协议一样,FIX 协议是交易执行前和期间交易所、银行、经纪商、清算公司和其他市场参与者之间的 事实上的消息标准,富达投资和所罗门兄弟于 1992 年引入了 FIX,以便促进经纪商和机构客户之间的电子通信,而之前,他们是通过电话交换信息的。

它在全球股票市场中变得流行,然后扩展到外汇、固定收益和衍生品市场,进一步进入后期交易以支持一键处理。交易所提供 FIX 消息的访问作为实时数据源,由算法交易员进行解析,以跟踪市场活动,并且,例如,识别市场参与者的足迹并预测他们的下一步行动。

消息序列允许 重建订单簿。跨多个交易所的交易规模产生了大量(~10 TB)难以处理的非结构化数据,因此可能成为竞争优势的来源。

FIX 协议,目前版本为 5.0,是一个具有庞大关联行业专业人士社区的免费开放标准。它是自描述的,类似于较新的 XML,FIX 会话由底层的 传输控制协议 (TCP) 层支持。社区不断添加新功能。

该协议支持管道分隔的键值对,以及基于标签的 FIXML 语法。一个请求服务器登录的示例消息如下所示:

8=FIX.5.0|9=127|35=A|59=theBroker.123456|56=CSERVER|34=1|32=20180117- 08:03:04|57=TRADE|50=any_string|98=2|108=34|141=Y|553=12345|554=passw0rd!|10=131| 

Python 中有一些开源的 FIX 实现,可用于构建和解析 FIX 消息。服务提供商 Interactive Brokers 提供了一种基于 FIX 的 计算机对计算机接口 (CTCI) 用于自动化交易(参见 GitHub 仓库本章的资源部分)。

纳斯达克 TotalView-ITCH 数据源

虽然 FIX 占据主导市场份额,但交易所也提供原生协议。纳斯达克提供了一种名为 TotalView-ITCH 的直接数据反馈协议,允许订阅者从下单到执行或取消跟踪股票工具的个别订单。

此数据流的历史记录允许重建特定证券的订单簿,该订单簿跟踪活跃限价订单。订单簿通过列出每个价格点出价或要约的股票数量来揭示一天中的市场深度。它还可能确定特定买入和卖出订单的市场参与者,除非它们是匿名下单的。市场深度是流动性和大额市场订单的潜在价格影响的关键指标。

除了匹配市价和限价订单外,纳斯达克还进行拍卖或交叉交易,在市场开盘和收盘时执行大量交易。随着被动投资继续增长,交叉交易变得越来越重要,交易者寻找执行更大的股票交易的机会。TotalView 还传播纳斯达克开盘和收盘交叉以及纳斯达克 IPO/停牌交叉的净订单不平衡指示器NOII)。

如何解析二进制订单消息

ITCH v5.0 规范声明了与系统事件、股票特性、限价订单的下单和修改以及交易执行相关的 20 多种消息类型。它还包含了开盘和收盘交叉前的净订单不平衡信息。

纳斯达克提供了数个月的每日二进制文件样本。本章的 GitHub 存储库包含一个笔记本,parse_itch_order_flow_messages.ipynb,演示了如何下载和解析 ITCH 消息的样本文件。然后笔记本 rebuild_nasdaq_order_book.ipynb 继续重建给定股票的已执行交易和订单簿。

下表显示了 2019 年 10 月 30 日样本文件中最常见的消息类型的频率:

消息类型 订单簿影响 消息数量
A 新的无属性限价订单 127,214,649
D 订单取消 123,296,742
U 订单取消并替换 25,513,651
E 全部或部分执行;原始订单可能有多条消息 7,316,703
X 部分取消后修改 3,568,735
F 添加属性订单 1,423,908
P 交易消息(非交叉) 1,525,363
C 在与初始显示价格不同的价格全部或部分执行 129,729
Q 交叉交易消息 17,775

对于每条消息,规范详细说明了组件及其相应的长度和数据类型:

名称 偏移量 长度 注释
消息类型 0 1 S 系统事件消息。
股票定位 1 2 整数 始终为 0。
跟踪号 3 2 整数 纳斯达克内部跟踪号。
时间戳 5 6 整数 从午夜开始的纳秒数。
订单参考号 11 8 整数 在接收时分配给新订单的唯一参考号。
买/卖指示符 19 1 Alpha 正在添加的订单类型:B = 买入订单,S = 卖出订单。
股票数量 20 4 整数 与要添加到订单簿中的订单相关联的股票总数。
股票 24 8 Alpha 股票代码,右侧用空格填充。
价格 32 4 价格 (4) 新订单的显示价格。请参阅规范中的数据类型以获取字段处理注意事项。
归因 36 4 Alpha 与输入订单相关联的纳斯达克市场参与者标识符。

Python 提供了 struct 模块,使用格式字符串解析二进制数据,该格式字符串通过指示 byte 字符串的各个组件的长度和类型来标识消息元素,如规范中所述。

让我们走过解析交易信息和重建订单簿所需的关键步骤:

  1. ITCH 解析器依赖于文件message_types.xlsx中提供的消息规范(有关详细信息,请参阅笔记本parse_itch_order_flow_messages.ipynb)。它根据formats字典组装格式字符串:

    formats = {
        ('integer', 2): 'H',  # int of length 2 => format string 'H'
        ('integer', 4): 'I',
        ('integer', 6): '6s', # int of length 6 => parse as string, 
          convert later
        ('integer', 8): 'Q',
        ('alpha', 1)  : 's',
        ('alpha', 2)  : '2s',
        ('alpha', 4)  : '4s',
        ('alpha', 8)  : '8s',
        ('price_4', 4): 'I',
        ('price_8', 8): 'Q',
    } 
    
  2. 解析器将消息规范转换为格式字符串和命名元组,其中包含了消息内容:

    # Get ITCH specs and create formatting (type, length) tuples
    specs = pd.read_csv('message_types.csv')
    specs['formats'] = specs[['value', 'length']].apply(tuple, 
                               axis=1).map(formats)
    # Formatting for alpha fields
    alpha_fields = specs[specs.value == 'alpha'].set_index('name')
    alpha_msgs = alpha_fields.groupby('message_type')
    alpha_formats = {k: v.to_dict() for k, v in alpha_msgs.formats}
    alpha_length = {k: v.add(5).to_dict() for k, v in alpha_msgs.length}
    # Generate message classes as named tuples and format strings
    message_fields, fstring = {}, {}
    for t, message in specs.groupby('message_type'):
        message_fields[t] = namedtuple(typename=t,
                                      field_names=message.name.tolist())
        fstring[t] = '>' + ''.join(message.formats.tolist()) 
    
  3. alpha 类型的字段需要后处理,如format_alpha函数中所定义:

    def format_alpha(mtype, data):
        """Process byte strings of type alpha"""
        for col in alpha_formats.get(mtype).keys():
            if mtype != 'R' and col == 'stock':
                data = data.drop(col, axis=1)
                continue
            data.loc[:, col] = (data.loc[:, col]
                                .str.decode("utf-8")
                                .str.strip())
            if encoding.get(col):
                data.loc[:, col] = data.loc[:, col].map(encoding.get(col))
        return data 
    

单日的二进制文件包含超过 3 亿条消息,总计超过 9 GB。该脚本将解析结果迭代附加到一个以 HDF5 格式存储的文件中,以避免内存限制。(有关 HDF5 格式的更多信息,请参阅本章后面的使用 pandas 进行高效数据存储部分。)

以下(简化的)代码处理二进制文件并生成按消息类型存储的解析订单:

with (data_path / file_name).open('rb') as data:
    while True:
        message_size = int.from_bytes(data.read(2), byteorder='big', 
                       signed=False)
        message_type = data.read(1).decode('ascii')
        message_type_counter.update([message_type])
        record = data.read(message_size - 1)
        message = message_fields[message_type]._make(
            unpack(fstring[message_type], record))
        messages[message_type].append(message)

        # deal with system events like market open/close
        if message_type == 'S':
            timestamp = int.from_bytes(message.timestamp, 
                                       byteorder='big')
            if message.event_code.decode('ascii') == 'C': # close
                store_messages(messages)
                break 

总结所有 8,500 只股票的交易活动

如预期的那样,这一天交易的 8,500 多种证券中只有少数占据了大部分交易:

with pd.HDFStore(itch_store) as store:
    stocks = store['R'].loc[:, ['stock_locate', 'stock']]
    trades = (store['P'].append(
            store['Q'].rename(columns={'cross_price': 'price'}),
            sort=False).merge(stocks))
trades['value'] = trades.shares.mul(trades.price)
trades['value_share'] = trades.value.div(trades.value.sum())
trade_summary = (trades.groupby('stock').value_share
                 .sum().sort_values(ascending=False))
trade_summary.iloc[:50].plot.bar(figsize=(14, 6),
                                 color='darkblue',
                                 title='Share of Traded Value')
f = lambda y, _: '{:.0%}'.format(y)
plt.gca().yaxis.set_major_formatter(FuncFormatter(f)) 

图 2.1 显示了生成的图表:

图 2.1:50 只最活跃证券的交易价值份额

如何重建所有交易和订单簿

解析后的消息允许我们重建给定日期的订单流。'R'消息类型包含了给定日期内所有交易的清单,包括首次公开发行IPO)和交易限制的信息。

在一天中,新订单被添加,而执行和取消的订单则从订单簿中移除。对于引用前日期放置的订单的消息的正确核算将需要跟踪多天的订单簿。

get_messages()函数说明了如何收集影响交易的单只股票的订单(有关每条消息的详细信息,请参阅 ITCH 规范)。代码略有简化;有关更多详细信息,请参阅笔记本rebuild_nasdaq_order_book.ipynb

def get_messages(date, stock=stock):
    """Collect trading messages for given stock"""
    with pd.HDFStore(itch_store) as store:
        stock_locate = store.select('R', where='stock = 
                                     stock').stock_locate.iloc[0]
        target = 'stock_locate = stock_locate'
        data = {}
        # relevant message types
        messages = ['A', 'F', 'E', 'C', 'X', 'D', 'U', 'P', 'Q']
        for m in messages:
            data[m] = store.select(m,  
              where=target).drop('stock_locate', axis=1).assign(type=m)
    order_cols = ['order_reference_number', 'buy_sell_indicator', 
                  'shares', 'price']
    orders = pd.concat([data['A'], data['F']], sort=False,  
                        ignore_index=True).loc[:, order_cols]
    for m in messages[2: -3]:
        data[m] = data[m].merge(orders, how='left')
    data['U'] = data['U'].merge(orders, how='left',
                                right_on='order_reference_number',
                                left_on='original_order_reference_number',
                                suffixes=['', '_replaced'])
    data['Q'].rename(columns={'cross_price': 'price'}, inplace=True)
    data['X']['shares'] = data['X']['cancelled_shares']
    data['X'] = data['X'].dropna(subset=['price'])
    data = pd.concat([data[m] for m in messages], ignore_index=True, 
                      sort=False) 

重建成功交易 - 即已执行而非取消的订单 - 与与交易相关的消息类型CEPQ相对简单。

def get_trades(m):
    """Combine C, E, P and Q messages into trading records"""
    trade_dict = {'executed_shares': 'shares', 'execution_price': 'price'}
    cols = ['timestamp', 'executed_shares']
    trades = pd.concat([m.loc[m.type == 'E',
                              cols + ['price']].rename(columns=trade_dict),
                        m.loc[m.type == 'C',
                              cols + ['execution_price']]
                        .rename(columns=trade_dict),
                        m.loc[m.type == 'P', ['timestamp', 'price',
                                              'shares']],
                        m.loc[m.type == 'Q',
                              ['timestamp', 'price', 'shares']]
                        .assign(cross=1), ],
                       sort=False).dropna(subset=['price']).fillna(0)
    return trades.set_index('timestamp').sort_index().astype(int) 

订单簿跟踪限价订单,并且买单和卖单的各种价格水平构成了订单簿的深度。重建给定深度级别的订单簿需要以下步骤:

add_orders()函数按升序累积卖单,按降序累积买单,直到达到所需的深度级别的给定时间戳:

def add_orders(orders, buysell, nlevels):
    new_order = []
    items = sorted(orders.copy().items())
    if buysell == 1:
        items = reversed(items)  
    for i, (p, s) in enumerate(items, 1):
        new_order.append((p, s))
        if i == nlevels:
            break
    return orders, new_order 

我们遍历所有 ITCH 消息,并根据规范处理订单及其替换所需的订单:

for message in messages.itertuples():
    i = message[0]
    if np.isnan(message.buy_sell_indicator):
        continue
    message_counter.update(message.type)
    buysell = message.buy_sell_indicator
    price, shares = None, None
    if message.type in ['A', 'F', 'U']:
        price, shares = int(message.price), int(message.shares)
        current_orders[buysell].update({price: shares})
        current_orders[buysell], new_order = 
          add_orders(current_orders[buysell], buysell, nlevels)
        order_book[buysell][message.timestamp] = new_order
    if message.type in ['E', 'C', 'X', 'D', 'U']:
        if message.type == 'U':
            if not np.isnan(message.shares_replaced):
                price = int(message.price_replaced)
                shares = -int(message.shares_replaced)
        else:
            if not np.isnan(message.price):
                price = int(message.price)
                shares = -int(message.shares)
        if price is not None:
            current_orders[buysell].update({price: shares})
            if current_orders[buysell][price] <= 0:
                current_orders[buysell].pop(price)
            current_orders[buysell], new_order = 
              add_orders(current_orders[buysell], buysell, nlevels)
            order_book[buysell][message.timestamp] = new_order 

图 2.2突显了任意给定时间点的流动性深度,使用不同的强度来可视化不同价格水平上的订单数量。左侧面板显示了限价订单价格的分布是如何向更高价格的买单倾斜的。

右侧面板绘制了整个交易日限价订单和价格的演变情况:深色线跟踪了市场小时内执行交易的价格,而红色和蓝色点表示每分钟基础上的单个限价订单(有关详细信息,请参阅笔记本):

图 2.2:根据订单簿的 AAPL 市场流动性

从刻度到条形图 - 如何规范市场数据

交易数据按纳秒索引,以不规则间隔到达,且非常嘈杂。例如,买卖盘弹跳会导致价格在买入和卖出价格之间震荡,当交易启动在买入和卖出市价订单之间交替时。为了改善噪声-信号比和价格序列的统计特性,我们需要通过聚合交易活动对刻度数据进行重新采样和规范化。

我们通常收集聚合周期内的开盘价(第一)、最高价、最低价和收盘价(最后)以及成交量(联合简称为OHLCV),以及与数据相关的成交量加权平均价格VWAP)和时间戳。

有关附加详细信息,请参阅 GitHub 上本章节文件夹中的normalize_tick_data.ipynb笔记本。

原材料 - 刻度条

以下代码生成了 AAPL 的原始刻度价格和成交量数据的图表:

stock, date = 'AAPL', '20191030'
title = '{} | {}'.format(stock, pd.to_datetime(date).date()
with pd.HDFStore(itch_store) as store:
    sys_events = store['S'].set_index('event_code') # system events
    sys_events.timestamp = sys_events.timestamp.add(pd.to_datetime(date)).dt.time
    market_open = sys_events.loc['Q', 'timestamp'] 
    market_close = sys_events.loc['M', 'timestamp']
with pd.HDFStore(stock_store) as store:
    trades = store['{}/trades'.format(stock)].reset_index()
trades = trades[trades.cross == 0] # excluding data from open/close crossings
trades.price = trades.price.mul(1e-4) # format price
trades = trades[trades.cross == 0]    # exclude crossing trades
trades = trades.between_time(market_open, market_close) # market hours only
tick_bars = trades.set_index('timestamp')
tick_bars.index = tick_bars.index.time
tick_bars.price.plot(figsize=(10, 5), title=title), lw=1) 

图 2.3显示了生成的图:

图 2.3:刻度条

刻度回报远非正态分布,这表明scipy.stats.normaltest的 p 值很低:

from scipy.stats import normaltest
normaltest(tick_bars.price.pct_change().dropna())
NormaltestResult(statistic=62408.76562431228, pvalue=0.0) 

常规去噪 - 时间条

时间条涉及按周期进行交易聚合。以下代码获取时间条的数据:

def get_bar_stats(agg_trades):
    vwap = agg_trades.apply(lambda x: np.average(x.price, 
           weights=x.shares)).to_frame('vwap')
    ohlc = agg_trades.price.ohlc()
    vol = agg_trades.shares.sum().to_frame('vol')
    txn = agg_trades.shares.size().to_frame('txn')
    return pd.concat([ohlc, vwap, vol, txn], axis=1)
resampled = trades.groupby(pd.Grouper(freq='1Min'))
time_bars = get_bar_stats(resampled) 

我们可以将结果显示为价格-成交量图表:

def price_volume(df, price='vwap', vol='vol', suptitle=title, fname=None):
    fig, axes = plt.subplots(nrows=2, sharex=True, figsize=(15, 8))
    axes[0].plot(df.index, df[price])
    axes[1].bar(df.index, df[vol], width=1 / (len(df.index)), 
                color='r')
    xfmt = mpl.dates.DateFormatter('%H:%M')
    axes[1].xaxis.set_major_locator(mpl.dates.HourLocator(interval=3))
    axes[1].xaxis.set_major_formatter(xfmt)
    axes[1].get_xaxis().set_tick_params(which='major', pad=25)
    axes[0].set_title('Price', fontsize=14)
    axes[1].set_title('Volume', fontsize=14)
    fig.autofmt_xdate()
    fig.suptitle(suptitle)
    fig.tight_layout()
    plt.subplots_adjust(top=0.9)
price_volume(time_bars) 

上述代码产生了图 2.4

图 2.4:时间柱

或者,我们可以使用 Bokeh 绘图库将数据表示为蜡烛图:

resampled = trades.groupby(pd.Grouper(freq='5Min')) # 5 Min bars for better print
df = get_bar_stats(resampled)
increase = df.close > df.open
decrease = df.open > df.close
w = 2.5 * 60 * 1000 # 2.5 min in ms
WIDGETS = "pan, wheel_zoom, box_zoom, reset, save"
p = figure(x_axis_type='datetime', tools=WIDGETS, plot_width=1500, 
          title = "AAPL Candlestick")
p.xaxis.major_label_orientation = pi/4
p.grid.grid_line_alpha=0.4
p.segment(df.index, df.high, df.index, df.low, color="black")
p.vbar(df.index[increase], w, df.open[increase], df.close[increase], 
       fill_color="#D5E1DD", line_color="black")
p.vbar(df.index[decrease], w, df.open[decrease], df.close[decrease], 
       fill_color="#F2583E", line_color="black")
show(p) 

这会产生图 2.5中的图表:

图 2.5:Bokeh 蜡烛图

考虑订单碎片化 – 交易量柱

时间柱平滑了原始 tick 数据中包含的一些噪音,但可能未能解决订单碎片化的问题。执行重点的算法交易可能会在给定期间内匹配成交量加权平均价格 (VWAP)。这将将单个订单拆分为多个交易,并根据历史模式下订单。时间柱将以不同的方式处理相同的订单,即使市场中没有新的信息到来。

交易量柱提供了一种通过按交易量聚合交易数据的替代方法。我们可以按如下方式完成:

min_per_trading_day = 60 * 7.5
trades_per_min = trades.shares.sum() / min_per_trading_day
trades['cumul_vol'] = trades.shares.cumsum()
df = trades.reset_index()
by_vol = (df.groupby(df.cumul_vol.
                     div(trades_per_min)
                     .round().astype(int)))
vol_bars = pd.concat([by_vol.timestamp.last().to_frame('timestamp'),
                      get_bar_stats(by_vol)], axis=1)
price_volume(vol_bars.set_index('timestamp')) 

对于上述代码,我们得到了图 2.6的图表:

图 2.6:交易量柱

考虑价格变化 – 美元柱

当资产价格发生显着变化或股票分拆后,给定数量的股票价值会发生变化。交易量柱无法正确反映这一点,并且可能会妨碍对反映此类变化的不同期间的交易行为进行比较。在这些情况下,应调整交易量柱方法以利用股票和价格的乘积产生美元柱。

下面的代码显示了美元柱的计算:

value_per_min = trades.shares.mul(trades.price).sum()/(60*7.5) # min per trading day
trades['cumul_val'] = trades.shares.mul(trades.price).cumsum()
df = trades.reset_index()
by_value = df.groupby(df.cumul_val.div(value_per_min).round().astype(int))
dollar_bars = pd.concat([by_value.timestamp.last().to_frame('timestamp'), get_bar_stats(by_value)], axis=1)
price_volume(dollar_bars.set_index('timestamp'), 
             suptitle=f'Dollar Bars | {stock} | {pd.to_datetime(date).date()}') 

由于价格在整个交易日都相当稳定,该图与交易量柱非常相似:

图 2.7:美元柱

AlgoSeek 分钟柱 – 股票报价和交易数据

AlgoSeek 提供了以前仅供机构投资者使用的历史日内交易数据。AlgoSeek 股票柱以用户友好的格式提供了非常详细的日内报价和交易数据,旨在使设计和回测日内 ML 驱动策略变得容易。正如我们将看到的,该数据不仅包括 OHLCV 信息,还包括盘口报价价差和上下价格变动的次数等其他信息。

AlgoSeek 提供了 2013-2017 年纳斯达克 100 只股票分钟柱数据样本,以供演示,并将提供此数据的子集给本书读者。

在本节中,我们将介绍可用的交易和报价信息,并展示如何处理原始数据。在后面的章节中,我们将演示如何使用这些数据进行 ML 驱动的日内策略。

从综合信息流到分钟柱

AlgoSeek 分钟柱基于由 证券信息处理器 (SIP) 提供的数据,该处理器管理本节开头提到的综合信息流。您可以在 www.algoseek.com/samples/ 上查看文档。

SIP(股票信息处理器)汇总了来自每个交易所的最佳买价和卖价报价,以及相应的交易和价格。根据法律规定,交易所在将报价和交易发送到直接数据源之前,不得将其发送到 SIP。鉴于美国股票交易的碎片化特性,综合数据源提供了市场当前状态的便捷快照。

更重要的是,SIP 作为监管机构根据 Reg NMS 确定全国最佳买卖报价NBBO)的基准。OHLC K 线报价价格基于 NBBO,每个买入或卖出报价价格都指的是 NBBO 价格。

每个交易所都会发布其最优报价和该价格可获得的股票数量。当已发布的报价改善 NBBO 时,NBBO 就会变化。买入/卖出报价会持续存在,直到由于交易、价格改进或取消最新的买入或卖出而发生变化。虽然历史 OHLC(开盘、最高、最低、收盘)K 线通常基于该时间段内的交易,但 NBBO 买卖报价可能会从前一个时间段一直延续到发生新的 NBBO 事件。

AlgoSeek K 线覆盖了整个交易日,从第一个交易所开盘到最后一个交易所收盘。在正常交易时间之外的 K 线通常表现出有限的活动。交易时间(东部时间)如下:

  • 盘前市场:大约从 04:00:00(根据交易所而异)到 09:29:59

  • 市场交易时间:09:30:00 到 16:00:00

  • 延长交易时间:16:00:01 到 20:00:00

报价和交易数据字段

分钟级 K 线数据包含多达 54 个字段。对于 K 线的开盘价最高价最低价收盘价元素,有八个字段,即:

  • K 线的时间戳及相应的交易

  • 主要买卖报价和相关交易的价格和数量

还有 14 个带有成交量信息的数据点:

  • 股票数量及相应的交易

  • 在买入价以下、买入价和中间价之间、中间价、中间价和卖出价之间以及在卖出价以上的交易量,以及交叉交易量

  • 与前一个价格运动方向不同的价格上涨或下跌的交易量,以及价格未发生变化时的交易量

AlgoSeek 数据还包含报告给 FINRA 并由经纪商、暗池或场外交易所内部处理的股票数量。这些交易代表着隐藏或直到事后才可公开获取的成交量。

最后,数据包括成交量加权平均价格VWAP)和该时间段的最小和最大买卖价差。

如何处理 AlgoSeek 的日内数据

在本节中,我们将处理 AlgoSeek 的示例数据。GitHub 上的 data 目录包含了如何从 AlgoSeek 下载数据的说明。

分钟级数据有四个版本:带有和不带有报价信息,以及带有或不带有 FINRA 报告的成交量。每天有一个压缩文件夹,其中包含一个 CSV 文件。

下面的代码示例将交易分钟级数据提取到每日的 .parquet 文件中:

directories = [Path(d) for d in ['1min_trades']]
target = directory / 'parquet'
for zipped_file in directory.glob('*/**/*.zip'):
    fname = zipped_file.stem
    print('\t', fname)
    zf = ZipFile(zipped_file)
    files = zf.namelist()
    data = (pd.concat([pd.read_csv(zf.open(f),
                                   parse_dates=[['Date',
                                                 'TimeBarStart']])
                       for f in files],
                      ignore_index=True)
            .rename(columns=lambda x: x.lower())
            .rename(columns={'date_timebarstart': 'date_time'})
            .set_index(['ticker', 'date_time']))
    data.to_parquet(target / (fname + '.parquet')) 

我们可以将 parquet 文件合并为单个 HDF5 存储,产生 5380 万条记录,占用 3.2 GB 的内存,涵盖了 5 年和 100 支股票:

path = Path('1min_trades/parquet')
df = pd.concat([pd.read_parquet(f) for f in path.glob('*.parquet')]).dropna(how='all', axis=1)
df.columns = ['open', 'high', 'low', 'close', 'trades', 'volume', 'vwap']
df.to_hdf('data.h5', '1min_trades')
print(df.info(null_counts=True))
MultiIndex: 53864194 entries, (AAL, 2014-12-22 07:05:00) to (YHOO, 2017-06-16 19:59:00)
Data columns (total 7 columns):
open      53864194 non-null float64
high      53864194 non-null float64
Low       53864194 non-null float64
close     53864194 non-null float64
trades    53864194 non-null int64
volume    53864194 non-null int64
vwap      53852029 non-null float64 

我们可以使用 plotly 快速创建一个交互式蜡烛图,以查看 AAPL 数据的一天在浏览器中的显示:

idx = pd.IndexSlice
with pd.HDFStore('data.h5') as store:
    print(store.info())
    df = (store['1min_trades']
          .loc[idx['AAPL', '2017-12-29'], :]
          .reset_index())
fig = go.Figure(data=go.Ohlc(x=df.date_time,
                             open=df.open,
                             high=df.high,
                             low=df.low,
                             close=df.close)) 

图 2.8 显示了生成的静态图像:

图 2.8:Plotly 蜡烛图

AlgoSeek 还提供调整因子,以纠正股票拆分、股息和其他公司行动的定价和成交量。

API 访问市场数据

有几种选项可供您使用 Python 通过 API 访问市场数据。我们首先介绍了内置于 pandas 库和yfinance工具中的一些来源,该工具便于从 Yahoo! Finance 下载每日市场数据和最近的基本数据。

然后,我们将简要介绍交易平台 Quantopian、数据提供者 Quandl 以及本书后续将使用的 Zipline 回测库,以及列出访问各种类型市场数据的几个其他选项。GitHub 上的 data_providers 目录包含了几个笔记本,说明了这些选项的使用情况。

使用 pandas 远程数据访问

pandas 库通过相关的 pandas-datareader 库实现通过 read_html 函数访问网站上显示的数据以及访问各种数据提供者的 API 端点。

读取 HTML 表格

下载一个或多个 HTML 表格的内容(例如来自 Wikipedia 的标准普尔 500 指数成分)的过程如下:

sp_url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies'
sp = pd.read_html(sp_url, header=0)[0] # returns a list for each table
sp.info()
RangeIndex: 505 entries, 0 to 504
Data columns (total 9 columns):
Symbol                    505 non-null object
Security                  505 non-null object
SEC filings                505 non-null object
GICS Sector               505 non-null object
GICS Sub Industry         505 non-null object
Headquarters Location     505 non-null object
Date first added           408 non-null object
CIK                       505 non-null int64
Founded                   234 non-null object 

用于市场数据的 pandas-datareader

pandas 用于直接访问数据提供者的 API,但此功能已移至 pandas-datareader 库(请参阅 README 获取文档链接)。

API 的稳定性因提供者政策而异,且不断变化。请参阅文档以获取最新信息。截至 2019 年 12 月,版本 0.8.1,以下来源可用:

Source 范围 评论
Tiingo 提供股票、共同基金和 ETF 的历史收盘价。 免费注册获取 API 密钥。免费账户只能访问 500 个符号。
Investor Exchange (IEX) 如果在 IEX 上交易,则提供历史股价。 需要从 IEX Cloud 控制台获取 API 密钥。
Alpha Vantage 每日、每周和每月频率的历史股票数据,20+ 年,以及过去 3-5 天的分钟级数据。它还具有外汇和行业绩效数据。
Quandl 免费数据来源如它们网站上所列。
Fama/French 风险因子投资组合回报。 用于 第七章线性模型 - 从风险因素到回报预测
TSP 基金数据 共同基金价格。
纳斯达克 交易标的的最新元数据。
Stooq 指数数据 由于许可问题,某些股票指数在其他地方不可用。
MOEX 莫斯科交易所历史数据。

数据的访问和检索遵循所有来源的类似 API,如雅虎财经所示:

import pandas_datareader.data as web
from datetime import datetime
start = '2014'              # accepts strings
end = datetime(2017, 5, 24) # or datetime objects
yahoo= web.DataReader('FB', 'yahoo', start=start, end=end)
yahoo.info()
DatetimeIndex: 856 entries, 2014-01-02 to 2017-05-25
Data columns (total 6 columns):
High         856 non-null float64
Low          856 non-null float64
Open         856 non-null float64
Close        856 non-null float64
Volume       856 non-null int64
Adj Close    856 non-null float64
dtypes: float64(5), int64(1) 

yfinance – 从雅虎财经获取数据

yfinance 旨在提供从雅虎财经下载历史市场数据的可靠快速方法。该库最初命名为 fix-yahoo-finance。使用该库非常简单;笔记本 yfinance_demo 展示了该库的功能。

如何下载每日和分钟价格

Ticker 对象允许从雅虎网站抓取的各种数据点的下载:

import yfinance as yf
symbol = 'MSFT'
ticker = yf.Ticker(symbol) 

.history 方法获取各种期间的历史价格,从一天到可用的最大值,并以不同频率,而分钟内仅适用于最近几天。要下载调整后的 OHLCV 数据以及公司行动,请使用:

data = ticker.history(period='5d',
                      interval='1m',
                      actions=True,
                      auto_adjust=True)
data.info()
DatetimeIndex: 1747 entries, 2019-11-22 09:30:00-05:00 to 2019-11-29 13:00:00-05:00
Data columns (total 7 columns):
Open            1747 non-null float64
High            1747 non-null float64
Low             1747 non-null float64
Close           1747 non-null float64
Volume          1747 non-null int64
Dividends       1747 non-null int64
Stock Splits    1747 non-null int64 

该笔记本还说明了如何访问季度和年度财务报表、可持续性评分、分析师建议和即将公布的收益日期。

如何下载期权链和价格

yfinance 还提供了访问期权到期日期和价格以及其他各种合约信息的功能。使用前面示例中的 ticker 实例,我们可以使用以下方式获取到期日期:

ticker.options
('2019-12-05',  '2019-12-12',  '2019-12-19',..) 

对于任何这些日期,我们可以访问期权链,并查看各种看涨/看跌合约的详情,如下所示:

options = ticker.option_chain('2019-12-05')
options.calls.info()
Data columns (total 14 columns):
contractSymbol       35 non-null object
lastTradeDate        35 non-null datetime64[ns]
strike               35 non-null float64
lastPrice            35 non-null float64
bid                  35 non-null float64
ask                  35 non-null float64
change               35 non-null float64
percentChange        35 non-null float64
volume               34 non-null float64
openInterest         35 non-null int64
impliedVolatility    35 non-null float64
inTheMoney           35 non-null bool
contractSize         35 non-null object
currency             35 non-null object 

该库还允许使用代理服务器以防止速率限制,并便于批量下载多个股票。笔记本演示了这些功能的使用。

Quantopian

Quantopian 是一家提供研究平台以集体创建交易算法的投资公司。注册是免费的,会员可以使用各种数据源研究交易想法。它还提供了一个环境,可以使用历史数据对算法进行回测,以及使用实时数据对其进行样本外的前向测试。它为表现优秀的算法授予投资分配,其作者有权获得 10%(写作时)的利润份额。

Quantopian 研究平台由用于 alpha 因子研究和绩效分析的 Jupyter Notebook 环境组成。还有一个用于编写算法策略和使用从 2002 年以来带有分钟频率的历史数据回测结果的 交互式开发环境 (IDE)。

用户还可以使用实时数据模拟算法,这被称为模拟交易。Quantopian 提供各种市场数据集,包括美国股票和期货价格和交易量数据,以及美国股票公司基本面数据,还整合了众多备选数据集。

我们将在第四章金融特征工程——如何研究 Alpha 因子中更详细地探讨 Quantopian 平台,并在整本书中依赖其功能,所以请随时开设一个账户。(有关更多详细信息,请参阅 GitHub 存储库。)

Zipline

Zipline 是算法交易库,为 Quantopian 回测和实时交易平台提供支持。它也可以离线使用,用于开发策略,使用有限数量的免费数据包进行测试,然后将结果传输到在线 Quantopian 平台进行模拟和实时交易。

Zipline 需要一个定制环境——查看笔记本zipline_data_demo.ipynb开头的说明。以下代码说明了 Zipline 如何允许我们访问一系列公司的每日股票数据。您可以在 Jupyter Notebook 中使用同名的魔术函数运行 Zipline 脚本。

首先,您需要使用所需的安全符号初始化上下文。我们还将使用一个计数器变量。然后,Zipline 调用handle_data,我们使用data.history()方法回溯一个时间段,并将上一天的数据附加到一个.csv文件中:

%load_ext zipline
%%zipline --start 2010-1-1 --end 2018-1-1 --data-frequency daily
from zipline.api import order_target, record, symbol
def initialize(context):
    context.i = 0
    context.assets = [symbol('FB'), symbol('GOOG'), symbol('AMZN')]

def handle_data(context, data):
    df = data.history(context.assets, fields=['price', 'volume'], 
                      bar_count=1, frequency="1d")
    df = df.to_frame().reset_index()

    if context.i == 0:
        df.columns = ['date', 'asset', 'price', 'volume']
        df.to_csv('stock_data.csv', index=False)
    else:
        df.to_csv('stock_data.csv', index=False, mode='a', header=None)
    context.i += 1
df = pd.read_csv('stock_data.csv')
df.date = pd.to_datetime(df.date)
df.set_index('date').groupby('asset').price.plot(lw=2, legend=True, 
       figsize=(14, 6)); 

我们得到了前述代码的以下图:

图 2.9:Zipline 数据访问

我们将在接下来的章节中更详细地探讨 Zipline 的功能,特别是在线 Quantopian 平台。

Quandl

Quandl 提供广泛的数据源,包括免费数据和订阅数据,使用 Python API。注册并获取免费 API 密钥,可每天进行 50 多次调用。Quandl 数据涵盖除股票以外的多种资产类别,包括外汇、固定收益、指数、期货和期权以及大宗商品。

API 使用简单,文档完善,灵活,除了单个系列下载外,还有许多其他方法,例如,包括批量下载或元数据搜索。

下面的调用从 1986 年开始获取美国能源部的报价的油价:

import quandl
oil = quandl.get('EIA/PET_RWTC_D').squeeze()
oil.plot(lw=2, title='WTI Crude Oil Price') 

我们从前面的代码中得到了这个图:

图 2.10:Quandl 油价示例

其他市场数据提供商

各种提供商提供各种资产类别的市场数据。相关类别的示例包括:

  • 交易所越来越多地从越来越广泛的数据服务中获得收入,通常使用订阅方式。

  • 彭博社和汤姆森路透一直是领先的数据聚合商,在 285 亿美元的金融数据市场中占据了超过 55%的份额。较小的竞争对手,如 FactSet,正在增长或出现,如 money.net,Quandl,Trading Economics 和 Barchart。

  • 专业数据提供商比比皆是。一个例子是 LOBSTER,它实时聚合纳斯达克订单簿数据。

  • 免费数据提供商包括 Alpha Vantage,该公司提供实时股票、外汇和加密货币市场数据的 Python API,以及技术指标。

  • 提供数据访问的众包投资公司包括 Quantopian 以及于 2018 年 3 月推出的 Alpha Trading Labs,后者提供 HFT 基础设施和数据。

如何处理基础数据

基础数据涉及确定证券价值的经济驱动因素。数据的性质取决于资产类别:

  • 对于股票和公司信用,它包括公司财务状况,以及行业和整体经济数据。

  • 对于政府债券,它包括国际宏观数据和外汇。

  • 对于大宗商品,它包括特定资产的供需决定因素,例如作物的天气数据。

我们将专注于美国的股票基础数据,因为数据更易于获取。全球有超过 13,000 家公开公司,每年生成超过 200 万页的年度报告和超过 30,000 小时的盈利电话。在算法交易中,基础数据和从这些数据中衍生的特征可以直接用于产生交易信号,例如,作为价值指标,并且是预测模型的基本输入,包括 ML 模型。

财务报表数据

美国证券交易委员会(SEC)要求美国发行人,即上市公司和证券,包括共同基金,在提交三份季度财务报表(Form 10-Q)和一份年度报告(Form 10-K)之外,还需遵守各种其他监管文件要求。

自 1990 年代初以来,SEC 通过其Electronic Data Gathering, Analysis, and RetrievalEDGAR)系统提供了这些文件。它们构成了对股票和其他证券进行基本分析的主要数据来源,例如企业信用,其价值取决于发行人的业务前景和财务状况。

自动化处理 - XBRL

自 SEC 推出XBRL以来,对监管文件的自动化分析变得更加容易。这是一种免费的、开放的、全球标准的业务报告的电子表示和交换。XBRL 基于 XML;它依赖于定义报告元素含义的分类法,并映射到在报告的电子版本中突出显示相应信息的标签。其中一种分类法代表了美国的通用会计准则GAAP)。

SEC 于 2005 年推出了自愿提交 XBRL 文件,以应对会计丑闻,随后于 2009 年要求所有提交者采用这种格式,并继续将强制性覆盖范围扩展到其他监管申报。SEC 维护了一个网站,列出了塑造不同申报内容的当前术语表,并可用于提取特定项目。

以下数据集以扁平化数据格式提供从提交给委员会的 EX-101 附件中提取的信息,以帮助用户消费数据进行分析。数据反映了从 XBRL 标记的财务报表中选择的信息。目前包括来自季度和年度财务报表的数字数据,以及某些附加字段,例如,标准工业分类 (SIC)。

有几种途径可以跟踪和访问报告给美国证监会(SEC)的基本数据:

  • 作为 EDGAR 公开传播服务 (PDS)的一部分,可按费用获取接受申报的电子数据流。

  • SEC 每 10 分钟更新 RSS 订阅源,其中列出了结构化披露提交。

  • 有用于通过 FTP 检索所有申报的公共索引文件,以进行自动处理。

  • 财务报表(及注释)数据集包含了所有财务报表和附注的解析 XBRL 数据。

SEC 还发布包含通过 SEC.gov 对 EDGAR 申报的互联网搜索流量的日志文件,尽管有 6 个月的延迟。

构建基本数据时间序列

财务报表和注释数据集中的数据范围包括从主要财务报表(资产负债表、损益表、现金流量表、权益变动表和综合收益表)和这些报表的附注中提取的数字数据。可用数据早在 2009 年就已经存在。

提取财务报表和注释数据集

以下代码下载并提取给定季度范围内财务报表和注释 (FSN) 数据集中包含的所有历史申报(有关更多详细信息,请参阅edgar_xbrl.ipynb):

SEC_URL = 'https://www.sec.gov/files/dera/data/financial-statement-and-notes-data-sets/'
first_year, this_year, this_quarter = 2014, 2018, 3
past_years = range(2014, this_year)
filing_periods = [(y, q) for y in past_years for q in range(1, 5)]
filing_periods.extend([(this_year, q) for q in range(1, this_quarter + 
                                                    1)])
for i, (yr, qtr) in enumerate(filing_periods, 1):
    filing = f'{yr}q{qtr}_notes.zip'
    path = data_path / f'{yr}_{qtr}' / 'source'
    response = requests.get(SEC_URL + filing).content
    with ZipFile(BytesIO(response)) as zip_file:
        for file in zip_file.namelist():
            local_file = path / file
            with local_file.open('wb') as output:
                for line in zip_file.open(file).readlines():
                    output.write(line) 

数据相当庞大,为了比原始文本文件允许的更快访问,最好将文本文件转换为二进制、Parquet 列格式(请参考本章后面关于使用 pandas 进行高效数据存储的部分,以了解与 pandas DataFrames 兼容的各种数据存储选项的性能比较):

for f in data_path.glob('**/*.tsv'):
    file_name = f.stem  + '.parquet'
    path = Path(f.parents[1]) / 'parquet'
    df = pd.read_csv(f, sep='\t', encoding='latin1', low_memory=False)
    df.to_parquet(path / file_name) 

对于每个季度,FSN 数据被组织成八个文件集,其中包含有关提交、数字、术语标签、展示等的信息。每个数据集由行和字段组成,并以制表符分隔的文本文件形式提供:

文件 数据集 描述
SUB 提交 标识每个 XBRL 提交的公司、表单、日期等
TAG 标签 定义和解释每个术语标签
DIM 维度 为数字和纯文本数据添加细节
NUM 数值 每个文件中不同数据点的一行
TXT 纯文本 包含所有非数值 XBRL 字段
REN 渲染 在 SEC 网站上渲染的信息
PRE 展示 主要报表中标签和数字展示的详细信息
CAL 计算 展示标签之间的算术关系

检索所有季度苹果申报文件

提交的数据集包含检索申报所需的唯一标识符:中心索引码CIK)和访问编号adsh)。以下显示了关于苹果 2018Q1 10-Q 申报的一些信息:

apple = sub[sub.name == 'APPLE INC'].T.dropna().squeeze()
key_cols = ['name', 'adsh', 'cik', 'name', 'sic', 'countryba',  
            'stprba', 'cityba', 'zipba', 'bas1', 'form', 'period', 
            'fy', 'fp', 'filed']
apple.loc[key_cols]
name                    APPLE INC
adsh                    0000320193-18-000070
cik                     320193
name                    APPLE INC
sic                     3571
countryba               US
stprba                  CA
cityba                  CUPERTINO
zipba                   95014
bas1                    ONE APPLE PARK WAY
form                    10-Q
period                  20180331
fy                      2018
fp                      Q2
filed                   20180502 

使用 CIK,我们可以识别出苹果可用的所有历史季度申报,并将此信息结合起来获取 26 份 10-Q 表单和 9 份年度 10-K 表单:

aapl_subs = pd.DataFrame()
for sub in data_path.glob('**/sub.parquet'):
    sub = pd.read_parquet(sub)
    aapl_sub = sub[(sub.cik.astype(int) == apple.cik) & 
                   (sub.form.isin(['10-Q', '10-K']))]
    aapl_subs = pd.concat([aapl_subs, aapl_sub])
aapl_subs.form.value_counts()
10-Q    15
10-K     4 

有了每个文件的存取编号,现在我们可以依赖于分类法,从NUMTXT文件中选择适当的 XBRL 标签(列在TAG文件中),以获取感兴趣的数值或文本/脚注数据点。

首先,让我们提取所有可用的 19 份苹果申报中的数值数据:

aapl_nums = pd.DataFrame()
for num in data_path.glob('**/num.parquet'):
    num = pd.read_parquet(num).drop('dimh', axis=1)
    aapl_num = num[num.adsh.isin(aapl_subs.adsh)]
    aapl_nums = pd.concat([aapl_nums, aapl_num])
aapl_nums.ddate = pd.to_datetime(aapl_nums.ddate, format='%Y%m%d')
aapl_nums.shape
(28281, 16) 

构建一个价格/收益时间序列

总共,9 年的申报历史为我们提供了超过 28,000 个数值。我们可以选择一个有用的字段,比如每股收益EPS),然后将其与市场数据相结合,计算流行的市盈率P/E)估值比。

但是我们需要注意,苹果于 2014 年 6 月 4 日将其股票进行了 7:1 的拆分,并调整分拆前的每股收益值,以使其与价格数据相比可比,这在其 调整后 形式中考虑到了这些变化。以下代码块向您展示了如何调整收益数据:

field = 'EarningsPerShareDiluted'
stock_split = 7
split_date = pd.to_datetime('20140604')
# Filter by tag; keep only values measuring 1 quarter
eps = aapl_nums[(aapl_nums.tag == 'EarningsPerShareDiluted')
                & (aapl_nums.qtrs == 1)].drop('tag', axis=1)
# Keep only most recent data point from each filing
eps = eps.groupby('adsh').apply(lambda x: x.nlargest(n=1, columns=['ddate']))
# Adjust earnings prior to stock split downward
eps.loc[eps.ddate < split_date,'value'] = eps.loc[eps.ddate < 
        split_date, 'value'].div(7)
eps = eps[['ddate', 'value']].set_index('ddate').squeeze()
# create trailing 12-months eps from quarterly data
eps = eps.rolling(4, min_periods=4).sum().dropna() 

我们可以使用 Quandl 获取自 2009 年以来的苹果股票价格数据:

import pandas_datareader.data as web
symbol = 'AAPL.US'
aapl_stock = web.DataReader(symbol, 'quandl', start=eps.index.min())
aapl_stock = aapl_stock.resample('D').last() # ensure dates align with 
                                               eps data 

现在我们有了数据,可以计算整个时期的滚动 12 个月 P/E 比率:

pe = aapl_stock.AdjClose.to_frame('price').join(eps.to_frame('eps'))
pe = pe.fillna(method='ffill').dropna()
pe['P/E Ratio'] = pe.price.div(pe.eps)
axes = pe.plot(subplots=True, figsize=(16,8), legend=False, lw=2); 

我们从前面的代码中得到以下图表:

图 2.11:来自 EDGAR 申报的滚动市盈率

其他基本数据来源

还有许多其他的基本数据来源。许多可以使用之前介绍的pandas_datareader模块访问。还可以直接从某些组织获取额外的数据,例如 IMF、世界银行或世界各地的主要国家统计机构(参见 GitHub 上的参考资料)。

pandas-datareader – 宏观和行业数据

pandas-datareader 库根据在前一节关于市场数据介绍的约定提供访问。它涵盖了众多全球基本宏观和行业数据源的 API,包括以下内容:

  • Kenneth French 的数据库:捕获关键风险因素的投资组合的市场数据,如规模、价值和动量因素,按行业细分(参见 第四章金融特征工程 - 如何研究阿尔法因子

  • 圣路易斯联邦储备(FRED):美国经济和金融市场的联邦储备数据

  • 世界银行:关于长期、低频经济和社会发展以及人口统计的全球数据库

  • OECD:类似于世界银行数据,针对经济合作与发展组织成员国

  • Enigma:各种数据集,包括替代来源

  • 欧洲统计局:欧盟经济、社会和人口数据重点关注

使用 pandas 进行高效数据存储

在本书中,我们将使用许多不同的数据集,并且值得比较主要格式的效率和性能。特别是,我们将比较以下内容:

  • CSV:逗号分隔的标准平面文本文件格式。

  • HDF5:分层数据格式,最初由国家超级计算应用中心开发。对于数字数据,它是一种快速且可扩展的存储格式,可通过 PyTables 库在 pandas 中使用。

  • Parquet:Apache Hadoop 生态系统的一部分,二进制、列存储格式,提供高效的数据压缩和编码,由 Cloudera 和 Twitter 开发。它可通过由 pandas 原始作者 Wes McKinney 领导的 pyarrow 库提供,后者是 pandas 的原始作者。

storage_benchmark.ipynb 笔记本使用可以配置为包含数字或文本数据,也可以两者兼有的测试 DataFrame 来比较前述库的性能。对于 HDF5 库,我们测试了固定格式和表格式。表格格式允许查询并且可以附加。

以下图表说明了在 100,000 行数据中读取和写入性能,数据包括 1,000 列随机浮点数和 1,000 列随机 10 个字符的字符串,或仅包含 2,000 列浮点数(采用对数刻度):

图 2.12:存储基准测试

左侧面板显示,对于纯数字数据,HDF5 格式远远表现最佳,而表格格式在 1.6 GB 的情况下也与 CSV 具有最小的内存占用。fixed 格式使用的空间是它的两倍,而 parquet 格式使用了 2 GB。

对于数字和文本数据的混合,Parquet 是读写操作的最佳选择。HDF5 在 读取 方面相对于 CSV 有优势,但在 写入 方面较慢,因为它将文本数据进行了 pickle 化。

该笔记本说明了如何使用 %%timeit 单元格魔术配置、测试和收集计时,并同时演示了使用这些存储格式所需的相关 pandas 命令的用法。

概要

本章介绍了组成大多数交易策略骨干的市场和基础数据源。您了解到了访问这些数据的各种方式以及如何预处理原始信息,以便您可以开始使用我们即将介绍的 ML 技术提取交易信号。

在下一章, 在继续设计和评估交易策略以及使用机器学习模型之前,我们需要介绍近年来出现的替代数据集,这些数据集对算法交易中机器学习的流行起到了重要推动作用。

第三章:金融领域的替代数据 - 类别和用例

前一章涵盖了与市场和基本数据的工作,这些一直是交易策略的传统驱动因素。在本章中,我们将快进到最近出现的更多种类的更多样化的数据源作为离散和算法策略的燃料。它们的异质性和新颖性激发了替代数据的标签,并创造了一个迅速增长的提供者和服务行业。

这一趋势背后有一个熟悉的故事:在互联网和移动网络的爆炸性增长推动下,数字数据在新数据源的处理、存储和分析技术的进步中呈指数级增长。数字数据的可用性和管理能力的指数增长反过来推动了机器学习(ML)的显著性能改进,推动了包括投资行业在内的各行业的创新。

数据革命的规模是非凡的:仅过去两年就见证了世界上现有所有数据的 90%的创建,到 2020 年,全球 77 亿人口中的每个人预计每天每秒会产生 1.7MB 的新信息。另一方面,2012 年时,只有 0.5%的数据被分析和使用,而到 2020 年,有 33%的数据被认为具有价值。数据可用性与使用之间的差距可能会迅速缩小,因为全球对分析的投资预计将在 2020 年之前超过 2100 亿美元,而价值创造潜力则是数倍于此。

本章解释了个人、业务流程和传感器如何生成替代数据。它还提供了一个框架来导航和评估用于投资目的的不断增长的替代数据供应。它展示了从获取到预处理和存储的工作流程,使用 Python 处理通过网络爬虫获得的数据,为机器学习的应用做好准备。它最后提供了一些来源、提供者和应用的示例。

本章将涵盖以下主题:

  • 替代数据革命释放了哪些新的信息来源

  • 个人、业务流程和传感器如何生成替代数据

  • 评估用于算法交易的不断增长的替代数据供应

  • 在 Python 中使用替代数据,例如通过网络爬虫进行爬取

  • 替代数据的重要类别和提供者

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和额外资源的链接。笔记本包括图像的彩色版本。

替代数据革命

数字化、网络化和存储成本的暴跌带来的数据洪流已经导致了可用于预测分析的信息性质发生了深刻的定性变化,通常由五个“V”来概括:

  • 容量:作为在线和离线活动、交易、记录和其他来源的副产品,产生、收集和存储的数据量呈数量级增长。随着分析和存储能力的增长,数据量持续增长。

  • 速度:数据生成、传输和处理以便以接近实时或实时的速度可用。

  • 多样性:数据以不再限于结构化、表格形式的格式组织,如 CSV 文件或关系数据库表。而是,新的来源产生了半结构化格式,如 JSON 或 HTML,以及非结构化内容,包括原始文本、“图片”?和音频或视频数据,为将数据适应机器学习算法增添了新的挑战。

  • 真实性:数据来源和格式的多样性使得验证数据信息内容的可靠性变得更加困难。

  • 价值:确定新数据集的价值可能比以往更加耗时、耗资源,也更加不确定。

对于算法交易,如果新数据来源提供了无法从传统来源获取的信息或提供了更早的访问机会,则这些新数据来源将提供信息优势。顺应全球趋势,投资行业正迅速从市场和基本数据扩展到替代来源,通过信息优势实现阿尔法收益。数据、技术能力和相关人才的年度支出预计将从目前的 30 亿美元以每年 12.8%的速度增长至 2020 年。

如今,投资者可以实时获取宏观或特定公司的数据,而这些数据在历史上只能以更低的频率获得。新数据来源的用例包括以下内容:

  • 在线价格数据可用于衡量一组代表性商品和服务的通货膨胀。

  • 店铺访问或购买数量允许对公司或行业特定销售或经济活动进行实时估计。

  • 卫星图像可以在其他地方获得这些信息之前,揭示农业产量,或者矿山或油井上的活动。

随着大数据集的标准化和采用的推进,传统数据中所包含的信息可能会失去大部分预测价值。

此外,处理和整合多样化数据集并应用机器学习的能力可实现复杂的见解。过去,量化方法依赖于使用历史数据对公司进行排名的简单启发式方法,例如市净率,而机器学习算法会综合新指标并学习和调整这些规则,同时考虑到不断发展的市场数据。这些见解创造了捕捉价值、动能、质量和情绪等经典投资主题的新机会:

  • 动能:机器学习可以识别资产暴露于市场价格波动、行业情绪或经济因素。

  • 价值:算法可以分析大量的经济和行业特定的结构化和非结构化数据,超越财务报表,来预测公司的内在价值。

  • 质量:综合数据的复杂分析可以评估客户或员工的评论、电子商务或应用流量,以识别市场份额或其他潜在收益质量驱动因素的增长。

  • 情感:对新闻和社交媒体内容的实时处理和解释使 ML 算法能够快速检测到新兴情感,并将信息从各种来源综合成更为连贯的整体图景。

然而,在实践中,包含有价值信号的数据通常并不是自由可用的,并且通常是为了除交易目的之外的其他目的而生成的。因此,需要对替代数据集进行彻底评估、昂贵的获取、谨慎的管理和复杂的分析,以提取可交易信号。

替代数据来源

替代数据集由许多来源生成,但可以在高层次上分类为主要由以下方面产生:

  • 在社交媒体上发布、评价产品或使用搜索引擎的个人

  • 记录商业交易(特别是信用卡支付)或作为中介捕获供应链活动的企业

  • 传感器,除其他功能外,通过卫星图像或安全摄像头捕获经济活动,或通过诸如手机基站等移动模式

替代数据的性质在不断迅速演变,因为新的数据来源变得可用,并且以前被标记为“替代”的来源成为主流的一部分。例如,波罗的海干散货指数BDI)汇集了数百家航运公司的数据,以近似干散货船的供需,并且现在可在彭博终端上获得。

替代数据包括原始数据以及已经聚合或以某种形式加工以增加价值的数据。例如,一些提供商旨在提取可交易信号,如情感评分。我们将在第四章金融特征工程-如何研究阿尔法因子中讨论各种类型的提供商。

替代数据源在决定其价值或信号内容的关键方面存在差异。在查看本节的主要来源后,我们将在下一节中讨论这些方面。

个人

个人通过在线活动自动生成电子数据,以及通过线下活动,后者通常以电子方式捕获,并经常与在线身份相关联。个人生成的数据通常以文本、图像或视频格式呈现,通过多个平台传播,包括:

  • 社交媒体帖子,例如在 Twitter、Facebook 或 LinkedIn 等通用站点上的意见或反应,或在 Glassdoor 或 Yelp 等商业评论站点上的评论

  • 反映对产品的兴趣或感知的电子商务活动,如亚马逊或 Wayfair 等网站上的活动

  • 使用 Google 或 Bing 等平台的搜索引擎活动

  • 移动应用程序的使用情况、下载量和评论

  • 个人数据,如消息流量

社交媒体情感分析变得非常流行,因为它可应用于个别股票、行业篮子或市场指数。最常见的来源是 Twitter,其次是各种新闻供应商和博客网站。供应竞争激烈,价格较低,因为它通常是通过日益成熟的网络抓取获得的。可靠的社交媒体数据集通常包括博客、推文或视频,因为消费者最近才大规模采用这些工具,因此通常历史不到 5 年。相比之下,搜索历史可追溯到 2004 年。

业务流程

企业和公共实体产生并收集许多有价值的替代数据源。业务流程产生的数据通常比个人生成的数据更具结构性。作为活动的领先指标,它对于以往频率低得多的活动非常有效。

由业务流程产生的数据包括:

  • 可能可从处理器和金融机构购买的支付卡交易数据

  • 普通数字化活动或记录保留产生的公司废弃数据,如银行记录、收银员扫描数据或供应链订单

  • 贸易流量和市场微观结构数据(如 L2 和 L3 订单簿数据,由第二章“市场和基本数据 - 来源和技术”中的纳斯达克 ITCH tick 数据示例说明)

  • 由信用评级机构或金融机构监控的公司付款,以评估流动性和信用状况

信用卡交易和公司的废弃数据,如销售数据,是最可靠和具有预测性的数据集之一。信用卡数据可追溯到约 10 年的历史,而且在不同的滞后期几乎接近实时,而公司收入则是以 2.5 周的滞后期季度报告。公司废弃数据的时间跨度和报告滞后期因来源而异。市场微观结构数据有超过 15 年的历史,而销售端流量数据通常只有不到 5 年的一致历史。

传感器

嵌入在各种设备中的网络传感器是增长最快的数据来源之一,这是由智能手机的普及和卫星技术成本的降低推动的。

这一类替代数据通常非常无结构,通常比个人或业务流程产生的数据量大得多,并且具有更加严峻的处理挑战。该类别中的主要替代数据源包括:

  • 卫星成像用于监测经济活动,如建筑、航运或商品供应

  • 地理定位数据用于跟踪零售店的交通情况,例如利用志愿者智能手机数据,或者在船只或卡车上的运输路线上。

  • 摄像机位于感兴趣的位置

  • 天气和污染传感器

物联网IoT)将通过将网络微处理器嵌入个人和商业电子设备(例如家用电器、公共空间和工业生产过程)进一步加速这类替代数据的大规模收集。

基于传感器的替代数据通常具有 3 至 4 年的历史,其中包括卫星图像、移动应用程序使用情况或手机位置跟踪。

卫星

发射地球观测卫星所需的资源和时间表已经大幅下降;现在,将一个小卫星作为次要有效载荷送入低地球轨道的成本已经降至约 10 万美元左右,而不再需要数千万美元和数年的准备时间。因此,公司可以利用整个卫星舰队实现更高频率的覆盖(目前约为每日一次)特定位置。

用例包括监测可通过空中覆盖范围捕捉的经济活动,例如农业和矿产生产和装运,或者商业或住宅建筑物或船只的建造;工业事故,例如火灾;或感兴趣地点的车辆和人流量。相关的传感器数据由用于农业的无人机提供,利用红外光监测作物。

在卫星图像数据可靠用于机器学习模型之前,通常需要解决几个挑战。除了进行大量的预处理外,还需要考虑诸如云覆盖和节假日季节效应等天气条件。卫星可能还只提供特定位置的不规则覆盖,这可能会影响预测信号的质量。

地理定位数据

地理定位数据是传感器生成的另一种快速增长的替代数据类别。熟悉的来源是智能手机,个人可以通过应用程序自愿分享其地理位置,或者通过 GPS、CDMA 或 Wi-Fi 等无线信号测量感兴趣地点周围的人流量,例如商店、餐馆或活动场所。

此外,越来越多的机场、购物中心和零售店安装了跟踪顾客数量和动向的传感器。虽然最初部署这些传感器的动机通常是为了衡量营销活动的影响,但产生的数据也可用于估算人流量或销售额。用于捕捉地理位置数据的传感器包括 3D 立体视频和热成像,这降低了隐私顾虑,但对移动物体效果很好。还有附在天花板上的传感器,以及压力敏感的垫子。一些供应商结合使用多种传感器,包括视觉、音频和手机定位,以全面记录购物者的旅程,其中不仅包括访问次数和持续时间,还延伸到转化和重复访问的测量。

评估替代数据的标准

替代数据的最终目标是在寻找产生 alpha(即正的、不相关的投资回报)的交易信号方面提供信息优势。在实践中,从替代数据集中提取的信号可以单独使用,也可以作为量化策略的一部分与其他信号结合使用。如果基于单一数据集的策略生成的夏普比率足够高,独立使用是可行的,但在实践中很少见。(有关信号测量和评估的详细信息,请参阅第四章金融特征工程 - 如何研究 Alpha 因子。)

量化公司正在构建可能在个体上是弱信号但组合起来可以产生有吸引力回报的 alpha 因子库。正如第一章用于交易的机器学习 - 从构想到执行中所强调的,投资因子应该基于基本的经济原理;否则,它们更有可能是对历史数据的过度拟合的结果,而不是在新数据上持久存在并产生 alpha。

由于竞争而导致的信号衰减是一个严重问题,在替代数据生态系统不断发展的情况下,许多数据集不太可能保留有意义的夏普比率信号。延长替代数据集信号内容半衰期的有效策略包括独家协议,或者专注于提高数据处理难度以增加进入壁垒的数据集。

可以根据替代数据集的信号内容质量、数据的定性特征以及各种技术方面来评估替代数据集。

信号内容的质量

可以针对目标资产类别、投资风格、与传统风险溢价的关系以及最重要的是其 alpha 内容来评估信号内容。

资产类别

大多数替代数据集包含与股票和商品直接相关的信息。2006 年 Zillow 成功地开创了价格估算之后,针对房地产投资的有趣数据集也在不断增加。

企业信用的替代数据正在增长,因为正在开发监控企业支付的替代来源,包括针对较小企业的数据。围绕固定收益和利率预测的数据是一个较新的现象,但随着更多产品销售和价格信息的大规模获取,它继续增加。

投资风格

大多数数据集都专注于特定行业和股票,因此自然吸引长短股权投资者。随着替代数据收集的规模和范围不断扩大,替代数据可能也会变得与宏观主题的投资者相关,例如消费信贷,新兴市场活动和商品趋势。

一些代表更广泛经济活动或消费者情绪的替代数据集可用作传统市场风险度量的代理。相比之下,捕捉新闻的信号可能更相关于使用量化策略的高频交易员,在短期内进行交易。

风险溢价

一些替代数据集,如信用卡支付或社交媒体情绪,已被证明产生的信号与传统风险溢价在股票市场上的相关性很低(低于 5%),如价值,动量和波动性质量。因此,将从这些替代数据派生的信号与基于传统风险因子的算法交易策略相结合,可以成为构建更多样化风险溢价组合的重要组成部分。

Alpha 内容和质量

正义投资替代数据集的投资所需的信号强度自然取决于其成本,而替代数据的价格差异很大。社会情绪得分的数据可以用几千美元或更少的价格获得,而全面及时的信用卡支付数据集的成本可能每年达数百万美元。

我们将详细探讨如何使用历史数据评估由替代数据驱动的交易策略,即所谓的回测,以估算数据集中包含的 alpha 数量。在个别情况下,数据集可能包含足够的 alpha 信号以单独驱动策略,但更典型的是结合使用各种替代和其他数据源。在这些情况下,数据集允许提取产生小正夏普比率的弱信号,这些信号单独不会获得资本配置,但与类似其他信号集成后可以提供组合级策略。然而,这并不是保证,因为也有许多替代数据集不包含任何 alpha 内容。

除了评估数据集的 alpha 内容之外,评估信号的增量程度或正交程度也很重要,即数据集的唯一性或已被其他数据捕捉的程度——在后一种情况下,比较这种类型信号的成本也很重要。

最后,评估依赖给定策略的潜在容量是至关重要的,即可以分配的资金数量,而不会破坏其成功。这是因为容量限制会使数据成本更难以收回。

数据质量

数据集的质量是另一个重要的标准,因为它影响了分析和货币化所需的工作量,以及它包含的预测信号的可靠性。质量方面包括数据频率和其可用历史的长度,其包含信息的可靠性或准确性,其与当前或潜在未来法规的符合程度,以及其使用的独占性。

法律和声誉风险

使用替代数据集可能会带来法律或声誉风险,尤其是当它们包含以下内容时:

  • 重大非公开信息 (MNPI),因为它涉及内幕交易规定的侵犯

  • 个人可识别信息 (PII),主要是因为欧盟已经颁布了通用数据保护条例 (GDPR)

因此,法律和合规要求需要进行彻底审查。当数据提供者也是积极根据数据集交易的市场参与者时,可能会存在利益冲突。

独占性

替代数据集包含充分预测信号以推动策略的可能性,其与其可用性和处理的便捷性成反比,对于一个有意义的时间段内具有较高的夏普比率。换句话说,数据越独占性越强,越难处理,具有 alpha 内容的数据集驱动策略的机会就越好,而不会遭受快速信号衰减的影响。

提供标准财务比率的公开基本数据包含较少的 alpha,并且对于独立策略而言不具吸引力,但它可能有助于多样化风险因素组合。大型复杂数据集需要更长时间才能被市场吸收,并且新数据集会经常出现。因此,评估其他投资者对数据集的熟悉程度以及数据提供者是否是此类信息的最佳来源至关重要。

独占性或是早期采用新数据集的其他好处可能在企业开始销售其为其他目的生成的耗尽数据时出现。这是因为可能有可能影响数据的收集或筛选方式,或者协商条件以限制竞争对手的访问,至少在一定时间内。

时间范围

为了测试数据集在不同场景中的预测能力,更广泛的历史记录是非常可取的。可用性在几个月到几十年之间变化很大,并且对基于数据构建和测试的交易策略的范围有重要影响。在介绍主要数据来源时,我们提到了不同数据集的时间范围。

频率

数据的频率决定了新信息何时可用,以及在给定时期内预测信号的差异化程度。它还影响投资策略的时间范围,范围从日内到日常、每周,甚至更低的频率。

可靠性

当然,数据准确反映其意图的程度,以及这可以被验证的程度,是一个重要问题,应该通过彻底的审计来验证。这适用于原始数据和处理后的数据,其中用于提取或聚合信息的方法需要进行分析,考虑到提议收购的成本效益比。

技术方面

技术方面涉及延迟或报告延迟,以及数据的可用格式。

延迟

数据提供商通常会分批提供资源,由于数据的收集、后续处理和传输,以及监管或法律约束,可能会导致延迟。

格式

数据以广泛的格式提供,具体取决于来源。处理过的数据将以用户友好的格式提供,并通过强大的 API 轻松集成到现有系统或查询中。在另一端是庞大的数据源,例如视频、音频或图像数据,或专有格式,这需要更多的技能来准备分析,但也为潜在竞争对手提供更高的进入障碍。

另类数据市场

2018 年,投资行业在数据服务上的支出估计达到了 20-30 亿美元,预计这一数字将以两位数的年增长率增长,与其他行业保持一致。这些支出包括另类数据的获取、相关技术的投资以及合格人才的招聘。

安永(Ernst & Young)的一项调查显示,2017 年另类数据的采用率显著增加;例如,43%的基金正在使用抓取的网络数据,而近 30%的基金正在尝试卫星数据(见图 3.1)。根据迄今为止的经验,基金经理认为抓取的网络数据和信用卡数据最具洞察力,而与之相反的是地理位置和卫星数据,约 25%的人认为信息较少:

图 3.1:另类数据的有用性和使用情况(来源:安永,2017 年)

鉴于这个新行业的快速增长,另类数据提供商市场相当分散。摩根大通列出了 500 多家专业数据公司,而AlternativeData.org列出了 300 多家。提供商扮演多种角色,包括顾问、聚合器和技术解决方案的中间商;卖方支持以各种格式提供数据,从原始数据到半加工数据或从一个或多个来源提取的某种形式的信号。

我们将重点介绍主要类别的规模,并概述几个突出的例子,以说明它们的多样性。

数据提供商和用例

AlternativeData.org(由提供商 YipitData 支持)列出了几个类别,可以作为各种数据提供商领域活动的粗略代理。社交情绪分析是迄今为止最大的类别,而卫星和地理位置数据近年来增长迅速:

产品类别 # 提供商
社交情绪 48
卫星 26
地理位置 22
网页数据和流量 22
基础设施和接口 20
顾问 18
信用卡和借记卡使用 14
数据经纪人 10
公共数据 10
应用使用 7
电子邮件和消费者收据 6
卖方 6
天气 4
其他 87

以下简要示例旨在说明服务提供商和潜在用例的广泛范围。

社交情绪数据

社交情绪分析与 Twitter 数据密切相关。 Gnip 是一个早期的社交媒体聚合器,通过 API 从众多网站提供数据,并于 2014 年以 1.34 亿美元被 Twitter 收购。搜索引擎是另一个来源,在研究人员在《自然》杂志上发表,称基于 Google Trends 等诸如债务等术语的投资策略可用于较长时期的盈利交易策略时变得突出(Preis、Moat 和 Stanley 2013 年)。

Dataminr

Dataminr 成立于 2009 年,根据与 Twitter 的独家协议提供社交情绪和新闻分析。该公司是较大的另类提供商之一,于 2018 年 6 月通过 Fidelity 领投筹集了额外的 3.91 亿美元,估值达到 16 亿美元,使总融资额达到 5.69 亿美元。它强调利用机器学习从社交媒体源提取的实时信号,并为广泛的客户提供服务,包括不仅限于买方和卖方投资公司,还包括新闻机构和公共部门。

StockTwits

StockTwits 是一个社交网络和微博平台,数十万投资专业人士通过 StockTwits 分享信息和交易想法。这些被大量金融网站和社交媒体平台的用户所观看。这些数据可以被利用,因为它可能反映投资者情绪,或者本身推动交易,从而影响价格。Nasseri、Tucker 和 de Cesare(2015)基于选定特征构建了交易策略。

RavenPack

RavenPack 分析大量多样化的非结构化文本数据,生成结构化指标,包括情绪分数,旨在提供与投资者相关的信息。底层数据源涵盖高级新闻、监管信息、新闻稿和超过 19,000 个网络出版物。J.P. 摩根基于情绪分数测试了基于主权债券和股票的多头多空策略,并取得了积极的结果,与传统风险溢价相关性低(Kolanovic 和 Krishnamachari,2017)。

卫星数据

成立于 2010 年的 RS Metrics,通过卫星、无人机和飞机三角测量地理空间数据,重点关注金属和大宗商品、房地产和工业应用。该公司基于自己的高分辨率卫星提供信号、预测分析、警报和最终用户应用。应用案例包括估算特定连锁店或商业地产的零售流量,以及某些常见金属的生产和储存,或相关生产地点的就业情况。

地理位置数据

成立于 2015 年的 Advan 为对冲基金客户提供从手机流量数据中派生的信号,目标是在美国和欧盟的各个部门中覆盖 1,600 个股票。该公司使用应用程序收集数据,在用户明确同意的情况下在智能手机上安装地理位置代码,并通过几个渠道(如 Wi-Fi、蓝牙和蜂窝信号)跟踪位置以增强准确性。使用案例包括估算物理店面的客流量,进而可以作为预测交易公司的总收入的模型的输入。

电子收据数据

Eagle Alpha 提供包括大量在线交易数据在内的服务,使用电子收据,涵盖超过 5,000 家零售商,包括按 SKU 分类的 53 个产品组的交易数据。根据 J.P. 摩根的分析,涵盖了 2013-16 年的时间序列数据,覆盖了整个样本期间活跃的一组不变用户。数据集包含每个周期的总体支出、订单数和独立买家数(Kolanovic 和 Krishnamachari,2017)。

处理替代数据

我们将说明使用网络抓取获取替代数据,首先针对 OpenTable 餐厅数据,然后转向 Seeking Alpha 托管的盈利电话会议记录。

抓取 OpenTable 数据

另类数据的典型来源是评论网站,如 Glassdoor 或 Yelp,这些网站利用员工评论或客户评价传达内部见解。显然,用户贡献的内容并不能反映一个代表性观点,而是受到严重的选择偏见的影响。例如,我们将在 第十四章 用于交易的文本数据 - 情感分析 中查看 Yelp 评论,并发现比预期更多的五星级评价,包括非常积极和非常消极的评价。尽管如此,这些数据可以为旨在预测企业前景或市场价值相对于竞争对手或随时间获取交易信号的 ML 模型提供宝贵的输入。

需要从 HTML 源代码中提取数据,除非存在任何法律障碍。为了说明 Python 提供的网页抓取工具,我们将从 OpenTable 检索有关餐厅预订的信息。此类数据可以用于预测地理位置、房地产价格或餐厅连锁店营收的经济活动。

使用 Requests 和 BeautifulSoup 解析 HTML 数据

在本节中,我们将请求并解析 HTML 源代码。我们将使用 Requests 库发出 超文本传输协议 (HTTP) 请求并检索 HTML 源代码。然后,我们将依赖于 Beautiful Soup 库,它使解析 HTML 标记代码并提取我们感兴趣的文本内容变得很容易。

但是,我们会遇到一个常见的障碍:网站可能仅在初始页面加载后使用 JavaScript 从服务器请求某些信息。因此,直接的 HTTP 请求将不会成功。为了绕过这种类型的保护,我们将使用一个无界面浏览器,它将像浏览器一样检索网站内容:

from bs4 import BeautifulSoup
import requests
# set and request url; extract source code
url = https://www.opentable.com/new-york-restaurant-listings
html = requests.get(url)
html.text[:500]
' <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"/><meta http-equiv="X-UA-Compatible" content="IE=9; IE=8; IE=7; IE=EDGE"/> <title>Restaurant Reservation Availability</title> <meta name="robots" content="noindex" > </meta> <link rel="shortcut icon" href="//components.otstatic.com/components/favicon/1.0.4/favicon/favicon.ico" type="image/x-icon"/><link rel="icon" href="//components.otstatic.com/components/favicon/1.0.4/favicon/favicon-16.png" sizes="16x16"/><link rel=' 

现在,我们可以使用 Beautiful Soup 解析 HTML 内容,然后查找我们通过检查源代码获得的与餐厅名称相关的所有 span 标签,rest-row-name-text(请参阅 GitHub 存储库,获取与检查网站源代码相关的链接指示):

# parse raw html => soup object
soup = BeautifulSoup(html.text, 'html.parser')
# for each span tag, print out text => restaurant name
for entry in soup.find_all(name='span', attrs={'class':'rest-row-name-text'}):
    print(entry.text)
Wade Coves
Alley
Dolorem Maggio
Islands
... 

一旦您确定了感兴趣的页面元素,Beautiful Soup 就会轻松获取其中包含的文本。例如,如果您想获取每家餐厅的价格类别,您可以使用:

# get the number of dollars signs for each restaurant
for entry in soup.find_all('div', {'class':'rest-row-pricing'}):
    price = entry.find('i').text 

但是,当您尝试获取预订数量时,您只会收到一个空列表,因为该站点在初始加载完成后使用 JavaScript 代码请求此信息:

soup.find_all('div', {'class':'booking'})
[] 

这正是我们之前提到的挑战 - 不是将所有内容作为静态页面发送到浏览器中,以便轻松解析,而是 JavaScript 动态加载关键片段。为了获取此内容,我们需要执行 JavaScript,就像浏览器一样 - 这就是 Selenium 的作用。

介绍 Selenium - 使用浏览器自动化

我们将使用浏览器自动化工具 Selenium 操作一个无头 Firefox 浏览器,该浏览器将为我们解析 HTML 内容。

以下代码打开 Firefox 浏览器:

from selenium import webdriver
# create a driver called Firefox
driver = webdriver.Firefox() 

让我们关闭浏览器:

# close it
driver.close() 

现在,我们使用 Selenium 和 Firefox 检索 HTML 源代码,包括动态加载的部分。为此,我们向驱动程序提供 URL,然后使用其 page_source 属性获取完整页面内容,如浏览器中显示的那样。

从这里开始,我们可以借助 Beautiful Soup 解析 HTML,如下所示:

import time, re
# visit the opentable listing page
driver = webdriver.Firefox()
driver.get(url)
time.sleep(1) # wait 1 second
# retrieve the html source
html = driver.page_source
html = BeautifulSoup(html, "lxml")
for booking in html.find_all('div', {'class': 'booking'}):
    match = re.search(r'\d+', booking.text)
    if match:
        print(match.group()) 

构建一个餐厅预订和评级的数据集

现在,您只需要从网站中组合所有有趣的元素,以创建一个特征,您可以在模型中使用该特征来预测地理区域的经济活动,或特定社区的人流量。

使用 Selenium,您可以跟踪到下一页的链接,并快速构建纽约市超过 10,000 家餐厅的数据集,然后您可以定期更新它以跟踪时间序列。

首先,我们设置一个函数,用于解析我们打算爬行的页面内容,使用熟悉的 Beautiful Soup 解析语法:

def parse_html(html):
    data, item = pd.DataFrame(), {}
    soup = BeautifulSoup(html, 'lxml')
    for i, resto in enumerate(soup.find_all('div',
                                           class_='rest-row-info')):
        item['name'] = resto.find('span',
                                 class_='rest-row-name-text').text
        booking = resto.find('div', class_='booking')
        item['bookings'] = re.search('\d+', booking.text).group() \
            if booking else 'NA'
        rating = resto.find('div', class_='star-rating-score')
        item['rating'] = float(rating['aria-label'].split()[0]) \
            if rating else 'NA'
        reviews = resto.find('span', class_='underline-hover')
        item['reviews'] = int(re.search('\d+', reviews.text).group()) \
            if reviews else 'NA'
        item['price'] = int(resto.find('div', class_='rest-row-pricing')
                            .find('i').text.count('$'))
        cuisine_class = 'rest-row-meta--cuisine rest-row-meta-text sfx1388addContent'
        item['cuisine'] = resto.find('span', class_=cuisine_class).text
        location_class = 'rest-row-meta--location rest-row-meta-text sfx1388addContent'
        item['location'] = resto.find('span', class_=location_class).text
        data[i] = pd.Series(item)
    return data.T 

然后,我们启动一个无头浏览器,它会继续为我们点击“下一页”按钮,并捕获每个页面上显示的结果:

restaurants = pd.DataFrame()
driver = webdriver.Firefox()
url = https://www.opentable.com/new-york-restaurant-listings
driver.get(url)
while True:
    sleep(1)
    new_data = parse_html(driver.page_source)
    if new_data.empty:
        break
    restaurants = pd.concat([restaurants, new_data], ignore_index=True)
    print(len(restaurants))
    driver.find_element_by_link_text('Next').click()
driver.close() 

2020 年初的一次样本运行提供了关于 10,000 家餐厅的位置、菜系和价格分类信息。此外,在周一约有 1,750 家餐厅提供当天预订服务,并且大约有 3,500 家餐厅的评分和评论信息。

图 3.2 显示了一个简要摘要:左侧面板显示了前 10 个拥有最多餐厅的地点按价格分类的分布情况。中央面板表明,平均而言,价格较高的餐厅评分更高,右侧面板突出显示评分较高的餐厅接受的预订更多。随时间跟踪这些信息可能是有信息价值的,例如,关于消费者情绪、地点偏好或特定餐厅连锁店的情况:

图 3.2:OpenTable 数据摘要

网站不断变化,因此此代码可能在某个时候停止工作。为了更新我们的机器人,我们需要识别网站导航的变化,如新的类或 ID 名称,并相应地更正解析器。

使用 Scrapy 和 Splash 进一步实现自动化

Scrapy 是一个强大的库,用于构建跟踪链接、检索内容并以结构化方式存储解析结果的机器人。结合 Splash 无头浏览器,它还可以解释 JavaScript,并成为 Selenium 的高效替代品。

您可以在 01_opentable 目录中使用 scrapy crawl opentable 命令运行蜘蛛,结果将记录在 spider.log 中:

from opentable.items import OpentableItem
from scrapy import Spider
from scrapy_splash import SplashRequest
class OpenTableSpider(Spider):
    name = 'opentable'
    start_urls = ['https://www.opentable.com/new-york-restaurant-
                   listings']
    def start_requests(self):
        for url in self.start_urls:
            yield SplashRequest(url=url,
                                callback=self.parse,
                                endpoint='render.html',
                                args={'wait': 1},
                                )
    def parse(self, response):
        item = OpentableItem()
        for resto in response.css('div.rest-row-info'):
            item['name'] = resto.css('span.rest-row-name-
                                      text::text').extract()
            item['bookings'] = 
                  resto.css('div.booking::text').re(r'\d+')
            item['rating'] = resto.css('div.all-
                  stars::attr(style)').re_first('\d+')
            item['reviews'] = resto.css('span.star-rating-text--review-
                                         text::text').re_first(r'\d+')
            item['price'] = len(resto.css('div.rest-row-pricing > 
                                i::text').re('\$'))
            item['cuisine'] = resto.css('span.rest-row-meta—
                                         cuisine::text').extract()
            item['location'] = resto.css('span.rest-row-meta—
                               location::text').extract()
            yield item 

有许多方法可以从这些数据中提取信息,超出了个别餐厅或连锁店的评论和预订之外。

我们可以进一步收集和地理编码餐厅的地址,例如,将餐厅的实际位置与其他感兴趣的地区(如热门零售点或社区)联系起来,以获得关于特定经济活动方面的见解。如前所述,这些数据与其他信息结合使用将是最有价值的。

爬取和解析盈利电话会议记录

文本数据是一个重要的替代数据来源。文本信息的一个例子是盈利电话会议的记录,执行人员在这些记录中不仅呈现最新的财务结果,还回答金融分析师的问题。投资者利用这些记录来评估情绪变化、特定主题的强调或沟通风格的变化。

我们将说明从流行的交易网站www.seekingalpha.com爬取和解析盈利电话会议记录的过程。与 OpenTable 示例类似,我们将使用 Selenium 访问 HTML 代码,并使用 Beautiful Soup 解析内容。为此,我们首先实例化一个适用于 Firefox 浏览器的 Selenium webdriver实例:

from urllib.parse import urljoin
from bs4 import BeautifulSoup
from furl import furl
from selenium import webdriver
transcript_path = Path('transcripts')
SA_URL = 'https://seekingalpha.com/'
TRANSCRIPT = re.compile('Earnings Call Transcript')
next_page = True
page = 1
driver = webdriver.Firefox() 

然后,我们对记录页面进行迭代,根据我们从检查网站获得的导航逻辑创建 URL。只要我们找到与其他记录相关的超链接,我们就访问 webdriver 的page_source属性,并调用parse_html函数来提取内容:

while next_page:
    url = f'{SA_URL}/earnings/earnings-call-transcripts/{page}'
    driver.get(urljoin(SA_URL, url))
    response = driver.page_source
    page += 1
    soup = BeautifulSoup(response, 'lxml')
    links = soup.find_all(name='a', string=TRANSCRIPT)
    if len(links) == 0:
        next_page = False
    else:
        for link in links:
            transcript_url = link.attrs.get('href')
            article_url = furl(urljoin(SA_URL, 
                           transcript_url)).add({'part': 'single'})
            driver.get(article_url.url)
            html = driver.page_source
            meta, participants, content = parse_html(html)
            meta['link'] = link
driver.close() 

要从非结构化的记录中收集结构化数据,我们除了使用 Beautiful Soup 外,还可以使用正则表达式。

它们使我们能够收集有关盈利电话会议公司和时间的详细信息,还能了解到谁在场,并将陈述归因于分析师和公司代表:

def parse_html(html):
    date_pattern = re.compile(r'(\d{2})-(\d{2})-(\d{2})')
    quarter_pattern = re.compile(r'(\bQ\d\b)')
    soup = BeautifulSoup(html, 'lxml')
    meta, participants, content = {}, [], []
    h1 = soup.find('h1', itemprop='headline').text
    meta['company'] = h1[:h1.find('(')].strip()
    meta['symbol'] = h1[h1.find('(') + 1:h1.find(')')]
    title = soup.find('div', class_='title').text
    match = date_pattern.search(title)
    if match:
        m, d, y = match.groups()
        meta['month'] = int(m)
        meta['day'] = int(d)
        meta['year'] = int(y)
    match = quarter_pattern.search(title)
    if match:
        meta['quarter'] = match.group(0)
    qa = 0
    speaker_types = ['Executives', 'Analysts']
    for header in [p.parent for p in soup.find_all('strong')]:
        text = header.text.strip()
        if text.lower().startswith('copyright'):
            continue
        elif text.lower().startswith('question-and'):
            qa = 1
            continue
        elif any([type in text for type in speaker_types]):
            for participant in header.find_next_siblings('p'):
                if participant.find('strong'):
                    break
                else:
                    participants.append([text, participant.text])
        else:
            p = []
            for participant in header.find_next_siblings('p'):
                if participant.find('strong'):
                    break
                else:
                    p.append(participant.text)
            content.append([header.text, qa, '\n'.join(p)])
    return meta, participants, content 

我们将结果存储在几个.csv文件中,以便在我们使用 ML 处理第 14-16 章的自然语言时轻松访问:

def store_result(meta, participants, content):
    path = transcript_path / 'parsed' / meta['symbol']
    pd.DataFrame(content, columns=['speaker', 'q&a', 
              'content']).to_csv(path / 'content.csv', index=False)
    pd.DataFrame(participants, columns=['type', 'name']).to_csv(path / 
                 'participants.csv', index=False)
    pd.Series(meta).to_csv(path / 'earnings.csv') 

有关如何开发网络爬虫应用程序的详细信息和进一步资源的参考,请参阅 GitHub 存储库中的README

概要

在本章中,我们介绍了作为大数据革命结果而提供的新型替代数据来源,包括个人、业务流程和传感器,如卫星或 GPS 定位设备。我们提出了一个框架来从投资角度评估替代数据集,并列出了主要类别和提供者,帮助您浏览这个提供用于使用 ML 的算法交易策略的关键输入的庞大而迅速扩展的领域。

我们还探索了强大的 Python 工具,您可以使用这些工具以规模收集您自己的数据集。我们这样做是为了让您有可能通过网络爬虫作为算法交易员获得您自己的私人信息优势。

我们现在将在接下来的章节中进行 Alpha 因子的设计和评估,这些因子产生交易信号,并查看如何在组合环境中将它们结合起来。

第四章:金融特征工程-如何研究阿尔法因子

算法交易策略由指示何时买入或卖出资产以相对于基准(如指数)生成优越收益的信号驱动。资产回报中未被此基准暴露解释的部分称为阿尔法,因此旨在产生这种不相关回报的信号也称为阿尔法因子

如果您已经熟悉 ML,您可能知道特征工程是成功预测的关键要素。在交易中也不例外。然而,投资领域特别丰富,有几十年的关于市场如何运作以及哪些特征可能比其他特征更好地解释或预测价格波动的研究。本章提供了一个概述作为您自己寻找阿尔法因子的起点。

本章还介绍了促进计算和测试阿尔法因子的关键工具。我们将重点介绍 NumPy、pandas 和 TA-Lib 库如何促进数据操作,并展示流行的平滑技术,如小波和卡尔曼滤波器,这有助于降低数据中的噪音。

我们还将预览如何使用交易模拟器 Zipline 评估(传统)阿尔法因子的预测性能。我们将讨论关键的阿尔法因子指标,如信息系数和因子换手率。随后将深入介绍使用机器学习进行回测交易策略的第六章机器学习过程,其中涵盖了我们将在本书中用于评估交易策略的 ML4T 工作流程。

特别是,本章将讨论以下主题:

  • 存在哪些类别的因子,为什么它们起作用以及如何衡量它们

  • 使用 NumPy、pandas 和 TA-Lib 创建阿尔法因子

  • 如何使用小波和卡尔曼滤波器去噪声数据

  • 使用 Zipline 离线和在 Quantopian 上测试单个和多个阿尔法因子

  • 如何使用 Alphalens 评估预测性能和换手率,包括信息系数IC)等指标

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和附加资源链接。笔记本包括图片的彩色版本。附录,阿尔法因子库,包含有关金融特征工程的其他信息,包括 100 多个示例,您可以借此为自己的策略提供帮助。

实践中的阿尔法因子-从数据到信号

阿尔法因子是旨在预测资产价格波动的原始数据转换。它们旨在捕捉驱动资产回报的风险。因子可能结合一个或多个输入,但每次策略评估因子以获得信号时都会为每个资产输出单个值。交易决策可能依赖于跨资产的相对因子值或单个资产的模式。

Alpha 因子的设计、评估和组合是算法交易策略工作流程研究阶段的关键步骤,如图 4.1所示:

图 4.1:Alpha 因子研究和执行工作流程

本章重点讨论研究阶段;下一章将涵盖执行阶段。本书的其余部分将重点介绍如何利用机器学习从数据中学习新因子,并有效地汇总来自多个 Alpha 因子的信号。

Alpha 因子是市场、基本和替代数据的转换,其中包含预测信号。一些因子描述了基本的、全球经济范围内的变量,如增长、通胀、波动率、生产率和人口风险。其他因子代表了投资风格,如价值或成长,以及可以交易的动量投资,因此市场定价了这些因子。还有一些因子解释了基于金融市场的经济学或制度设置,或投资者行为的价格变动,包括这种行为的已知偏见。

因子背后的经济理论可以是理性的,这样因子在长期内的回报可以弥补其在不景气时期的低回报。它也可以是行为的,因子风险溢价是由于可能偏向的、或不完全理性的代理行为而产生的,这些行为并不能被套利掉。

不断寻找和发现新的因子,可能更好地捕捉已知的或反映新的回报驱动因素。Research Affiliates 的联合创始人杰森·许(Jason Hsu)发现,截至 2015 年,已经有大约 250 种因子在权威期刊中发表了实证证据。他估计,每年这个数字可能增加 40 个因子。

为了避免错误的发现并确保因子提供一致的结果,它应该基于各种已建立的因子类别,如动量、价值、波动率或质量及其基本原理,具有有意义的经济直觉,我们将在下一节中概述。这使得因子更有可能反映市场愿意补偿的风险。

Alpha 因子是通过对原始市场、基本或替代数据进行简单算术转换而产生的,例如随时间变化的变量的绝对或相对变化,数据系列之间的比率,或在时间窗口上的聚合,如简单或指数移动平均值。它们还包括从价格和成交量模式的技术分析中产生的指标,例如需求与供应之间的相对强度指数,以及从证券基本面分析中熟悉的众多指标。Kakushadze(2016)列出了 101 种 Alpha 因子的公式,其中 80%在撰写本文时被 WorldQuant 对冲基金用于生产。

历史上,交易策略应用简单的排名启发式、价值阈值或分位数截断到投资领域中的一个或多个 alpha 因素。例如,华伦·巴菲特喜爱的书籍之一,格雷厄姆和多德(1934 年)的证券分析中普及的价值投资方法,依赖于诸如账面市值比这样的指标。

预测超过市场回报的 alpha 因素的现代研究由尤金·法玛(2013 年诺贝尔经济学奖获得者)和肯尼斯·弗伦奇领导,后者提供了关于规模和价值因素(1993 年)的证据。这项工作导致了三因子和五因子模型,我们将在第七章线性模型 - 从风险因素到收益预测中使用作者在他们的网站上提供的因子收益的每日数据进行讨论。关于现代因子投资的卓越、更近期的概述由安德鲁·安格(2014 年)撰写,他在 BlackRock 领导这一学科,该公司管理着接近 7 万亿美元。

正如我们将在本书中看到的,ML 在从更多样化和更大规模的输入数据中直接提取信号方面已被证明相当有效,而不使用规定的公式。然而,正如我们将看到的,alpha 因素仍然是一个 ML 模型的有用输入,该模型以比手动设置规则更优的方式结合它们的信息内容。

因此,算法交易策略今天利用大量信号,其中许多信号在个体上可能很弱,但当与 ML 算法通过模型驱动或传统因素组合时,可以产生可靠的预测。

建立在数十年的因子研究基础上

在理想化的世界中,风险因素应该彼此独立,产生正的风险溢价,并形成一个完整的集合,涵盖资产在给定类别中的所有风险维度,并解释系统性风险。实际上,这些要求仅在近似情况下成立,并且不同因素之间存在重要的相关性。例如,动量在较小的公司中通常更强(侯,薛和张,2015 年)。我们将展示如何使用无监督学习派生合成的、数据驱动的风险因素—特别是主成分和独立成分分析—在第十三章数据驱动风险因素和无监督学习的资产配置中。

在本节中,我们将回顾在金融研究和交易应用中突出的一些关键因素类别,解释它们的经济原理,并提供通常用于捕捉这些收益驱动因素的指标。

在接下来的部分中,我们将演示如何使用 NumPy 和 pandas 实现其中一些因素,使用 TA-Lib 库进行技术分析,并演示如何使用 Zipline 回测库评估因素。我们还将重点介绍一些内置于 Zipline 中的因素,在 Quantopian 平台上可用。

动量和情绪 - 趋势是你的朋友

动量投资是最为成熟的因子策略之一,自从 Jegadeesh 和 Titman(1993)在美国股票市场提供定量证据以来得到了支持。它遵循这句格言:趋势是你的朋友或让你的赢家持续奔跑。动量因子旨在做多表现良好的资产,同时做空一段时间内表现不佳的资产。最近,AQR 2000 亿美元对冲基金的创始人 Clifford Asness 展示了跨越八个不同资产类别和市场的动量效应证据(Asness、Moskowitz 和 Pedersen,2013)。

使用这一因子的策略的前提是资产价格存在趋势,表现为正的串行相关性。这种价格动量违背了有效市场假说,该假说认为过去的价格回报单独不能预测未来的表现。尽管存在相反的理论论点,但价格动量策略已经在各种资产类别中产生了正的回报,并且是许多交易策略的重要组成部分。

图 4.2中的图表显示了基于其暴露于各种阿尔法因子的投资组合的历史表现(使用来自 Fama-French 网站的数据)。赢家减输家WML)因子代表了包含美国股票的投资组合在前 2-12 个月的收益中排名前三个和最后三个十分位数的差异:

图片

图 4.2:各种风险因子的回报

动量因子在 2008 年危机之前明显优于其他显著的风险因子。其他因素包括高减低HML)价值因子,强劲减弱劲RMW)盈利能力因子和保守减激进CMA)投资因子。股票溢价是市场回报(例如,标准普尔 500 指数)与无风险利率之间的差异。

为什么动量和情绪可能驱动超额回报?

动量效应的原因指向投资者行为、持续的供需失衡、风险资产与经济之间的正反馈循环或市场微观结构。

行为学理论反映了对市场新闻的低反应(Hong、Lim 和 Stein,2000)和过度反应(Barberis、Shleifer 和 Vishny,1998)的偏见,因为投资者以不同的速度处理新信息。在对新闻的初始低反应之后,投资者经常推断过去的行为并创造价格动量。科技股在 90 年代晚期市场泡沫期间的上涨就是一个极端的例子。恐惧和贪婪心理也促使投资者增加对获胜资产的暴露,并继续出售亏损资产(Jegadeesh 和 Titman,2011)。

动量也可以有基本驱动因素,比如风险资产和经济之间的正反馈循环。经济增长推动了股票,而由此产生的财富效应通过更高的支出再次反馈到经济中,进而促进了增长。价格和经济之间的正反馈往往会使股票和信贷的动量延续到比债券、FOEX 和大宗商品更长的时间范围,而在这些市场中,负反馈会引发逆转,需要更短的投资周期。动量的另一个原因可能是由于市场摩擦导致的持续的供需失衡。一个例子是商品生产在适应需求变化方面的延迟。石油生产可能滞后于经济蓬勃发展带来的更高需求多年,而持续的供应短缺可能会触发和支持价格上涨的动量(Novy-Marx,2015)。

在较短的,日内时间范围内,市场微观结构效应也可以产生价格动量,因为投资者实施模拟其偏好的策略。例如,根据交易智慧在资产表现不佳时割损和在表现良好时保留利润的原则,投资者使用交易策略,如止损、常比例组合保险CPPI)、动态对冲和基于期权的策略,如保护性买入期权。这些策略会产生动量,因为它们意味着在资产表现不佳时就提前承诺卖出,在表现良好时就提前承诺买入。

类似地,风险平价策略(见下一章)倾向于购买通常表现良好的低波动资产,并出售通常表现不佳的高波动资产(请参阅本章后面的波动性和规模异常部分)。使用这些策略自动平衡投资组合会加强价格动量。

如何衡量动量和情绪

动量因子通常是通过识别趋势和模式来从价格时间序列中得出的。它们可以基于绝对收益或相对收益构建,通过比较资产横截面或分析资产的时间序列,在或跨传统资产类别和不同时间范围内进行。

下表列出了一些流行的说明性指标(请参阅附录以获取公式):

因素 描述
相对强度指数(RSI) RSI 比较股票之间最近价格变化的幅度,以识别超买或超卖的股票。高 RSI(通常在 70 以上)表示超买,低 RSI(通常在 30 以下)表示超卖。它首先计算过去一定数量(通常为 14)的交易日内上涨价格的平均变化!和下跌价格!,然后分别计算!
价格动量 该因素计算了给定数量的先前交易日的总回报。在学术文献中,通常使用过去 12 个月,除了最近一个月,因为经常观察到的短期逆转效应。然而,也广泛使用较短的时期。
过去 12 个月价格动量的调整 该指标通过将过去 12 个月的总回报除以这些回报的标准偏差来归一化。
价格加速度 价格加速度使用线性回归在较长和较短的时间段内(例如,1 年和 3 个月的交易日)计算价格趋势的梯度(根据波动性调整),并比较斜率变化作为价格加速度的度量。
百分比偏离 52 周高点 该因素使用最近价格与过去 52 周最高价格之间的百分比差异。

其他情绪指标包括以下指标;诸如分析师预测之类的输入可以从 Quandl 或 Bloomberg 等数据提供商获取:

因素 描述
盈利预测计数 该指标按照一致性预测的数量对股票进行排名,作为分析师覆盖和信息不确定性的代理。更高的值更可取。
推荐 N 个月的变化 该因素按照前N个月的一致性推荐变化对股票进行排名,改进是可取的(无论是从强烈卖出到卖出,还是从买入到强烈买入等等)。
股份在过去 12 个月的变化 该因素衡量了公司分割调整后的股票数量在过去 12 个月的变化,其中负变化意味着股票回购,并且是可取的,因为它表明管理层认为股票相对于其内在价值和未来价值而言是便宜的。
目标价格的 6 个月变化 该指标跟踪分析师目标价格的 6 个月变化。更高的正变化自然更可取。
净盈利修订 该因素表示盈利预测的上调和下调修订之间的差异,作为总修订数量的百分比。
短期利益占流通股的百分比 这个指标是当前被卖空的流通股的百分比,即投资者借来卖出的股票,并在以后的某一天回购它,同时猜测其价格将下跌。因此,高水平的卖空利益表明负面情绪,并且预计将来会信号较差的表现。

还有许多数据提供商致力于提供从社交媒体(例如 Twitter)构建的情绪指标。我们将在本书的第三部分中使用自然语言处理创建我们自己的情绪指标。

价值因素 - 寻找基本便宜货物

相对于其基本价值的低价格股票往往会提供超过市值加权基准的回报。价值因子反映了这种相关性,并旨在发送对相对便宜的被低估资产的买入信号以及对被高估资产的卖出信号。因此,任何价值策略的核心都是估算资产公平或基本价值的模型。公平价值可以被定义为绝对价格水平,与其他资产的价差,或者资产应该交易的范围。

相对价值策略

价值策略依赖价格回归到资产公平价值的均值。它们假设价格只是暂时偏离公平价值,这是由于行为效应,如过度反应或群体行为,或者流动性效应,例如临时市场影响或长期供需摩擦所致。价值因子通常表现出与动量因子相反的特性,因为它们依赖于均值回归。对于股票来说,价值股的相反是增长股,其高估值是由于增长预期所致。

价值因子可以启用广泛的系统化策略,包括基本和市场估值以及跨资产相对价值。它们经常被集体标记为统计套利StatArb)策略,并作为市场中性的多空组合进行实施,没有暴露于其他传统或替代风险因子。

基本价值策略

基本价值策略从经济和基本指标中推导出公平资产价值,这些指标依赖于目标资产类别。在固定收益、货币和商品中,指标包括资本账户余额的水平和变化、经济活动、通货膨胀或资金流动。对于股票和公司信用,价值因子可以追溯到格雷厄姆和多德之前提到的证券分析。股权价值方法将股票价格与基本指标进行比较,如账面价值、销售额、利润或各种现金流指标。

市场价值策略

市场价值策略使用统计或机器学习模型来识别由于流动性提供效率低下而导致的定价错误。统计套利和指数套利是突出的例子,它们捕捉了短期时间段内临时市场影响的回归。(我们将在第九章用于波动率预测和统计套利的时间序列模型中介绍配对交易)。在较长的时间范围内,市场价值交易还利用了股票和商品的季节性效应。

跨资产相对价值策略

跨资产相对价值策略关注跨资产类别的错定价。例如,可转债套利涉及在债券可以转换为股票的情况下进行交易,以及单个公司的基础股票之间的相对价值。相对价值策略还包括信用和股票波动率之间的交易,利用信用信号交易股票或商品和相关股票之间的交易。

为什么价值因素有助于预测回报?

存在价值效应的合理和行为解释都有,定义为价值股票组合相对于成长股票组合的超额回报,前者市值较低,后者市值相对于基本面较高。我们将从众多研究中引用一些著名的例子(例如,看看法马和弗伦奇,1998 年,以及阿斯尼斯,莫斯科维茨和佩德森,2013 年)。

理性有效市场观点中,价值溢价是对更高的实际或感知风险的补偿。研究人员提出了证据表明,价值公司比更精简、更灵活的成长公司更难适应不利的经济环境,或者价值股风险与高财务杠杆和更不确定的未来收益有关。价值和小盘股组合也被证明对宏观冲击更敏感,而成长和大盘股组合则较少(拉克尼绍克,施莱费尔和维希尼,1994 年)。

行为学角度来看,价值溢价可以通过损失规避和心理会计偏差来解释。由于之前的收益提供了保护垫,投资者可能对具有强劲近期表现的资产的损失不那么担心。这种损失规避偏差导致投资者认为股票比以前更不具有风险,并以较低的速度贴现其未来现金流。相反,近期表现不佳可能会导致投资者提高资产的贴现率。

这些不同的回报期望可以产生价值溢价:相对于基本面的高价倍数的成长股票过去表现良好,但由于投资者对较低风险的偏见感知,他们将需要更低的平均回报率,而对于价值股票则相反。

如何捕捉价值效应

许多估值代理是从基本数据计算出来的。这些因素可以组合为机器学习估值模型的输入,以预测资产价格。以下示例适用于股票,我们将在接下来的章节中看到一些这些因素是如何使用的:

因素 描述
现金流量收益率 该比率将每股经营现金流量除以股价。较高的比率意味着股东获得更好的现金回报(如果使用股息或股票回购或将利润有利地再投资于业务中)。
自由现金流收益率 此比率将每股自由现金流除以股价,自由现金流反映了经过必要支出和投资后可用于分配的现金金额。较高且增长的自由现金流收益率通常被视为超额表现的信号。
投资资本现金流回报率(CFROIC) CFROIC 衡量了公司的现金流盈利能力。它将经营现金流除以投入资本,定义为总债务加上净资产。更高的回报意味着公司在给定的投入资本量下拥有更多现金,为股东创造更多价值。
现金流与总资产比 此比率将经营现金流除以总资产,表示公司相对于其资产能产生多少现金,较高的比率是更好的,就像 CFROIC 一样。
企业自由现金流与企业价值比 此比率衡量了公司相对于其企业价值(即股权和债务的综合价值)所产生的自由现金流,债务和股权价值可从资产负债表中获取,但市场价值通常提供了更准确的图景,假设相应资产是活跃交易的。
EBITDA 与企业价值比 此比率衡量了公司的利息、税收、折旧和摊销前收入(EBITDA),这是相对于其企业价值的现金流的替代指标。
收益率 此比率将过去 12 个月的收益总和除以最后的市场(收盘)价格。
前瞻收益率 这个比率不使用历史收益,而是将股票分析师预测的下一个 12 个月的平均收益除以最后的价格。
PEG 比率 市盈率增长比(PEG)比率将一家公司的市盈率(P/E)比率除以一个给定时期的盈利增长率。该比率通过公司的盈利增长调整支付的每一美元的价格(由 P/E 比率衡量)。
前 1 年相对于行业的 P/E 相对于相应行业 P/E 的 P/E 比率预测。它旨在通过考虑估值中的行业差异来减轻通用 P/E 比率的行业偏见。
销售收益率 此比率衡量了股票相对于其产生收入能力的估值。其他条件相等的情况下,具有较高历史销售价格比的股票预计将表现优异。
前瞻销售收益率 前瞻销售收益率使用分析师的销售预测,结合成一个(加权)平均值。
账面价值收益率 此比率将历史账面价值除以股价。
股息收益率 当前年化股息除以最后的收盘价格。贴现现金流估值假设公司的市场价值等于其未来现金流的现值。

第二章市场和基本数据-来源和技术,讨论了您如何从公司文件中获取用于计算这些指标的基本数据。

波动性和规模异常

规模效应是较旧的风险因素之一,与市值较低的股票的超额表现有关(参见本节开头的图 4.2)。最近,已经显示低波动性因子可以捕捉到波动性、β值或特异风险低于平均水平的股票的超额回报。市值较大的股票 tend to 有较低的波动性,因此传统的规模因子经常与最近的波动性因子结合使用。

低波动性异常是与金融基本原理相抵触的实证难题。资本资产定价模型CAPM)和其他资产定价模型断言,较高的风险应该获得较高的回报(我们将在下一章中详细讨论),但在许多市场和较长时期内,情况恰恰相反,较低风险的资产表现优于其较高风险的同行。

图 4.3 绘制了 1990-2019 年标普 500 收益率的滚动平均值与 VIX 指数的关系,VIX 指数衡量标普 100 名义期权的隐含波动率。它说明了股票收益率和这个波动率衡量指标如何以负相关的方式随着时间变化。除了这种总体效应之外,还有证据表明,对 VIX 变化更敏感的股票表现更差(Ang 等人,2006 年):

图 4.3:VIX 与标普 500 之间的相关性

为什么波动性和规模能预测回报?

低波动性异常与有效市场假设和 CAPM 假设相矛盾。已提出了几种行为解释来解释其存在。

彩票效应建立在实证证据之上,即个人承担类似于彩票的赌注,预期损失很小但潜在赢利很大,即使这种大赢利可能性相当低。如果投资者认为低价、波动性大的股票的风险收益概况类似于一张彩票,那么它可能是一个有吸引力的赌注。因此,由于其偏好的原因,投资者可能会为高波动性股票付出过高的价格,并为低波动性股票付出过低的价格。

代表性偏见表明,投资者将少数广为人知的高波动性股票的成功推广到所有高波动性股票,而忽视了这些股票的投机性质。

投资者可能也会对自己预测未来的能力过于自信,对于波动较大、结果更不确定的股票,他们的意见分歧更大。由于通过看多——即持有资产——来表达积极观点比通过看空来表达消极观点更容易,乐观主义者可能会超过悲观主义者,并继续推高波动性股票的价格,导致回报降低。

此外,投资者在牛市和危机期间的行为不同。在牛市中,贝塔的离散度要低得多,因此低波动性股票的表现不会很差,如果有的话,在危机期间,投资者会寻找或保留低波动性股票,贝塔离散度会增加。因此,长期来看,波动性较低的资产和投资组合表现更好。

如何衡量波动性和规模

用于识别低波动性股票的指标涵盖了广泛的范围,从实现波动率(标准偏差)到预测(隐含)波动率和相关性。一些将低波动性的运作定义为低贝塔。有关波动性异常的证据在不同的指标下似乎都很坚实(Ang,2014)。

量化投资的质量因素

质量因素旨在捕捉公司获得的超额回报,这些公司非常盈利、运营高效、安全、稳定且治理良好——简而言之,质量高。市场似乎也奖励相对盈利的确定性,并惩罚盈利波动性高的股票。

对高质量企业进行组合倾向在依赖基本分析的股票挑选者中长期被提倡,但在量化投资中却是一个相对较新的现象。主要挑战是如何使用定量指标一致和客观地定义质量因素,考虑到质量的主观性。

基于独立质量因素的策略往往呈逆周期性,因为投资者为了最小化下行风险支付溢价并推高估值。因此,质量因素经常与其他风险因素结合在多因子策略中,最常见的是与价值因素结合以制定合理价位的质量策略。

长短期质量因素往往具有负的市场贝塔,因为它们看多质量高、波动性低的股票,并做空波动性更高、质量更低的股票。因此,质量因素通常与低波动性和动量因素呈正相关,与价值和广泛市场敞口呈负相关。

为什么质量很重要

质量因素可能暗示超额收益,因为优秀的基本面,如持续的盈利能力、现金流量稳定增长、谨慎的杠杆化、对资本市场融资需求较低,或者低金融风险支撑了对股票的需求,从而长期支持这些公司的股价。从公司财务的角度来看,质量好的公司通常会谨慎管理资本,降低过度杠杆化或过度资本化的风险。

行为解释指出,投资者对于质量的信息反应不足,类似于动量理论,投资者追逐赢家并卖出失败者。

质量溢价的另一个论点是一个类似于成长股的赶羊效应。基金经理可能会发现更容易去购买一个基本面强劲的公司,即使它变得昂贵,而不是一个更加波动(风险)的价值股。

如何衡量资产质量

质量因素依赖于从资产负债表和利润表计算的指标,这些指标反映在高利润或现金流量利润率、运营效率、财务实力和竞争力方面的盈利能力,因为它意味着能够长期维持盈利地位的能力。

因此,质量已经使用毛利率(最近已添加到法玛-法国因子模型中;见第七章线性模型-从风险因子到收益预测)、投入资本回报率、收益波动率低、或者各种盈利能力、收益质量和杠杆指标的组合进行衡量,以下表格列出了一些选项。

收益管理主要通过操纵应计项目进行。因此,应计项目的规模通常被用作收益质量的代理:相对于资产的较高的总应计项目使得低收益质量更有可能。然而,这并不明确,因为应计项目既可以反映出收益操纵,也可以反映出未来业务增长的会计估计:

因素 描述
资产周转率 这个因素衡量公司如何高效地利用其需要资本的资产产生收入,并通过销售额除以总资产来计算。较高的周转率更好。
资产周转率 12 个月变动 这个因素衡量了管理团队在过去一年内利用资产产生收入的效率变化。通常预期效率改善水平最高的股票会表现出色。
流动比率 流动比率是一种衡量公司偿付短期债务能力的流动性指标。它将公司的流动资产与流动负债进行比较,从质量的角度来看,较高的流动比率更好。
利息覆盖率 这个因素衡量公司支付债务利息的容易程度。它是通过将公司的利息前税收盈利(EBIT)除以其利息支出来计算的。较高的比率是可取的。
杠杆 具有比股本更多债务的公司被认为是高度杠杆的。债务股本比通常与前景呈反比关系,较低的杠杆比较好。
股息支付比率 将利润支付给股东的股息所占比例。股息支付比率较高的股票排名较高。
股东权益回报率(ROE) ROE 被计算为净收入与股东权益的比率。具有较高历史股东权益回报率的股票排名较高。

配备了与不同程度的异常收益相关的阿尔法因子的高级分类,我们现在将开始从市场、基本和替代数据中开发我们自己的金融特征。

工程化预测回报的阿尔法因子

基于对关键因子类别、其基本原理和流行度量的概念理解,一个关键任务是识别可能更好地捕捉到前述回报驱动因素所体现的风险的新因子,或者找到新因子。在任一情况下,将重要的是将创新因子的表现与已知因子的表现进行比较,以识别增量信号增益。

促进数据转化为因子的关键工具包括用于数值计算的 Python 库 NumPy 和 pandas,以及用于技术分析的专用库 TA-Lib 的 Python 包装器。另一种选择是 Zura Kakushadze 在 2016 年论文《101 Formulaic Alphas》中开发的表达式阿尔法,并由 alphatools 库实现。此外,Quantopian 平台提供了大量内置因子,以加速研究过程。

要将一个或多个因子应用于投资范围,我们可以使用 Zipline 回测库(其中还包括一些内置因子),并使用 Alphalens 库评估它们的性能,使用下一节讨论的指标。

如何使用 pandas 和 NumPy 工程化因子

NumPy 和 pandas 是定制因子计算的关键工具。本节演示了如何使用它们快速计算产生各种阿尔法因子的转换。如果您对这些库不熟悉,特别是我们将在本书中始终使用的 pandas,请参阅 GitHub 存储库中本章的README,其中包含指向文档和教程的链接。

alpha_factors_in_practice目录中的笔记本feature_engineering.ipynb包含了创建各种因子的示例。该笔记本使用了根目录 GitHub 存储库中data文件夹中create_data.ipynb笔记本生成的数据,该数据以 HDF5 格式存储以加快访问速度。有关 pandas DataFrames 的 parquet、HDF5 和 CSV 存储格式的比较,请参阅 GitHub 存储库中第二章目录中的笔记本storage_benchmarks.ipynb

NumPy 科学计算库是由 Travis Oliphant 于 2005 年创建的,通过整合自上世纪 90 年代中期以来开发的较旧的 Numeric 和 Numarray 库而形成。它采用高性能的n维数组数据结构ndarray,其功能与 MATLAB 相当。

pandas 库于 2008 年出现,当时 Wes McKinney 在 AQR Capital Management 工作。它提供了 DataFrame 数据结构,该结构基于 NumPy 的ndarray,但允许更友好的数据操作和基于标签的索引。它包括丰富的计算工具,特别适用于金融数据,包括具有自动日期对齐的丰富时间序列操作,我们将在这里探讨。

以下各节演示了将原始股票价格数据转换为选定因子的一些步骤。有关我们在此处省略以节省空间的其他详细信息和可视化,请参见笔记本feature_engineering.ipynb。有关如何使用 pandas 和 NumPy 的文档和教程的链接,请参见 GitHub 上本章的README中列出的资源。

加载、切片和重塑数据

在加载 Quandl Wiki 美国股票价格数据之后,我们通过将pd.IndexSlice应用于包含时间戳和股票代码信息的pd.MultiIndex来选择 2000-18 年的时间切片。然后我们使用.stack()方法选择并取消旋转调整后的收盘价列,以将 DataFrame 转换为宽格式,其中股票代码在列中,时间戳在行中:

idx = pd.IndexSlice
with pd.HDFStore('../../data/assets.h5') as store:
    prices = (store['quandl/wiki/prices']
              .loc[idx['2000':'2018', :], 'adj_close']
              .unstack('ticker'))
prices.info()
DatetimeIndex: 4706 entries, 2000-01-03 to 2018-03-27
Columns: 3199 entries, A to ZUMZ 

重采样 - 从日频到月频

为了减少训练时间并尝试更长期限的策略,我们使用可用的调整后收盘价将业务日数据转换为月末频率:

monthly_prices = prices.resample('M').last() 

如何计算多个历史期间的收益

为了捕捉时间序列动态如动量模式,我们使用pct_change(n_periods)方法计算历史多期收益,其中n_periods表示滞后期数。然后我们使用.stack()将宽格式结果转换回长格式,使用.pipe().clip()方法应用于结果 DataFrame,并在[1%, 99%]水平上进行调整收益;也就是说,我们在这些百分位数上限制异常值。

最后,我们使用几何平均值对收益进行标准化。在使用.swaplevel()更改MultiIndex级别顺序后,我们获得了六个不同期间的复合月收益,范围从 1 到 12 个月:

outlier_cutoff = 0.01
data = pd.DataFrame()
lags = [1, 2, 3, 6, 9, 12]
for lag in lags:
    data[f'return_{lag}m'] = (monthly_prices
                           .pct_change(lag)
                           .stack()
                           .pipe(lambda x: 
                                 x.clip(lower=x.quantile(outlier_cutoff),
                                        upper=x.quantile(1-outlier_cutoff)))
                           .add(1)
                           .pow(1/lag)
                           .sub(1)
                           )
data = data.swaplevel().dropna()
data.info()
MultiIndex: 521806 entries, (A, 2001-01-31 00:00:00) to (ZUMZ, 2018-03-
                             31 00:00:00)
Data columns (total 6 columns):
return_1m 521806 non-null float64
return_2m 521806 non-null float64
return_3m 521806 non-null float64
return_6m 521806 non-null float64
return_9m 521806 non-null float64
return_12m 521806 non-null float6 

我们可以使用这些结果来计算基于长期收益与最近一个月收益之间差异的动量因子,以及 3 个月和 12 个月收益之间的差异,如下所示:

for lag in [2,3,6,9,12]:
    data[f'momentum_{lag}'] = data[f'return_{lag}m'].sub(data.return_1m)
data[f'momentum_3_12'] = data[f'return_12m'].sub(data.return_3m) 

使用滞后收益和不同持有期

为了将滞后值用作与当前观察值相关的输入变量或特征,我们使用.shift()方法将历史收益移至当前期间:

for t in range(1, 7):
    data[f'return_1m_t-{t}'] = data.groupby(level='ticker').return_1m.shift(t) 

类似地,为了计算不同持有期的回报,我们使用之前计算的标准化周期回报,并将它们向后移以与当前的金融特征对齐:

for t in [1,2,3,6,12]:
    data[f'target_{t}m'] = (data.groupby(level='ticker')
                            [f'return_{t}m'].shift(-t)) 

该笔记本还演示了如何计算不同回报系列的各种描述统计量,并使用 seaborn 库可视化它们的相关性。

计算因子贝塔

我们将在第七章中介绍 Fama-French 数据,使用线性回归来估计资产对常见风险因子的暴露。五个 Fama-French 因子,即市场风险、规模、价值、经营盈利能力和投资,在经验上被证明可以解释资产回报。它们通常用于评估投资组合对风险和回报的著名驱动因素的暴露,然后将未解释的部分归因于管理者的特殊技能。因此,在旨在预测未来回报的模型中,将过去的因子暴露作为金融特征是很自然的。

我们可以使用 pandas-datareader 访问历史因子回报,并使用 pyfinance 库中的 PandasRollingOLS 滚动线性回归功能来估计历史暴露,具体如下:

factors = ['Mkt-RF', 'SMB', 'HML', 'RMW', 'CMA']
factor_data = web.DataReader('F-F_Research_Data_5_Factors_2x3', 
              'famafrench', start='2000')[0].drop('RF', axis=1)
factor_data.index = factor_data.index.to_timestamp()
factor_data = factor_data.resample('M').last().div(100)
factor_data.index.name = 'date'
factor_data = factor_data.join(data['return_1m']).sort_index()
T = 24
betas = (factor_data
         .groupby(level='ticker', group_keys=False)
         .apply(lambda x: PandasRollingOLS(window=min(T, x.shape[0]-1), y=x.return_1m, x=x.drop('return_1m', axis=1)).beta)) 

如前所述,我们将更详细地探讨 Fama-French 因子模型和线性回归,在第七章中,更多地从风险因子到回报预测的线性模型。查看笔记本 feature_engineering.ipynb 以获取其他示例,包括滞后和前瞻回报的计算。

如何添加动量因子

我们可以使用 1 个月和 3 个月的结果来计算简单的动量因子。下面的代码示例展示了如何计算长期回报与最近一个月的回报之间的差异,以及 3 个月与 12 个月回报之间的差异:

for lag in [2,3,6,9,12]:
    data[f'momentum_{lag}'] = data[f'return_{lag}m'].sub(data.return_1m)
data[f'momentum_3_12'] = data[f'return_12m'].sub(data.return_3m) 

添加时间指标以捕捉季节效应

基本因子还包括季节性异常,如一月效应,已经观察到这个月份的股票回报率较高,可能是出于税收原因。这和其他季节性效应可以通过代表特定时间段的指示变量来建模,例如年份和/或月份。这些可以按以下方式生成:

dates = data.index.get_level_values('date')
data['year'] = dates.year
data['month'] = dates.month 

如何创建滞后的回报特征

如果你想使用滞后的回报,也就是来自以前期间的回报作为输入变量或特征来训练一个学习回报模式以预测未来回报的模型,你可以使用 .shift() 方法将历史回报移动到当前期间。下面的例子将过去 1 到 6 个月的回报移动到相应的滞后,使它们与当前月份的观察相关联:

for t in range(1, 7):
    data[f'return_1m_t-{t}'] = data.groupby(level='ticker').return_1m.shift(t) 

如何创建前瞻回报

同样地,你可以使用 .shift() 来为当前期间创建前瞻回报,也就是将会发生在未来的回报,只需使用负周期(假设你的数据按升序排序):

for t in [1,2,3,6,12]:
    data[f'target_{t}m'] = (data.groupby(level='ticker')
                            [f'return_{t}m'].shift(-t)) 

我们将在第六章机器学习过程开始训练 ML 模型时使用前向回报。

如何使用 TA-Lib 创建技术阿尔法因子

TA-Lib 是一个使用 C++编写的开源库,并带有 Python 接口,被广泛用于交易软件开发。它包含 200 多个流行技术分析指标的标准化实现;也就是说,这些指标只使用市场数据,即价格和成交量信息。

TA-Lib 与 pandas 和 NumPy 兼容,其使用非常简单。以下示例演示了如何计算两个流行指标。

布林带简单移动平均线(SMA)围绕着两个标准差上下的带组成。当价格在两条带之外的上下方时,这种带可以用于可视化潜在的超买/超卖条件,分别是。发明者约翰·波林格实际上推荐了一套包含 22 条规则的交易系统,用于生成交易信号。

我们可以计算布林带,并且,为了比较,在本章节中先前描述的相对强度指数如下所示。

我们加载单只股票的调整收盘价—在本例中为 AAPL:

with pd.HDFStore(DATA_STORE) as store:
    data = (store['quandl/wiki/prices']
            .loc[idx['2007':'2010', 'AAPL'],
                 ['adj_open', 'adj_high', 'adj_low', 'adj_close', 
                  'adj_volume']]
            .unstack('ticker')
            .swaplevel(axis=1)
            .loc[:, 'AAPL']
            .rename(columns=lambda x: x.replace('adj_', ''))) 

然后,我们通过相关的 TA-Lib 函数将一维pd.Series传递:

from talib import RSI, BBANDS
up, mid, low = BBANDS(data.close, timeperiod=21, nbdevup=2, nbdevdn=2, 
                      matype=0)
rsi = RSI(adj_close, timeperiod=14) 

然后,我们在 DataFrame 中收集结果,并使用 AAPL 股票价格和 RSI 与 30/70 线绘制布林带,这表明有长期/短期机会:

data = pd.DataFrame({'AAPL': data.close, 'BB Up': up, 'BB Mid': mid, 
                     'BB down': low, 'RSI': rsi})
fig, axes= plt.subplots(nrows=2, figsize=(15, 8))
data.drop('RSI', axis=1).plot(ax=axes[0], lw=1, title='Bollinger Bands')
data['RSI'].plot(ax=axes[1], lw=1, title='Relative Strength Index')
axes[1].axhline(70, lw=1, ls='--', c='k')
axes[1].axhline(30, lw=1, ls='--', c='k') 

结果显示在图 4.4中,相当混合—在早期危机后复苏期间,两个指标都表明超买条件,当价格继续上涨时:

图 4.4:布林带和相对强度指数

使用卡尔曼滤波器对阿尔法因子进行去噪

数据中的噪声概念与信号处理领域相关,旨在从发送的信号中检索正确信息,例如,通过电磁波形式通过空气发送。随着波浪穿过空间,环境干扰可以以噪声的形式添加到原始纯净信号中,这使得在接收后需要分离这两个信号。

卡尔曼滤波器于 1960 年引入,并已成为许多需要处理嘈杂数据的应用程序非常流行,因为它允许更准确地估计底层信号。

此技术广泛用于跟踪计算机视觉中的对象,支持飞机和航天器的定位和导航,并基于嘈杂的传感器数据控制机器人运动,除了其在时间序列分析中的用途。

噪声在数据科学、金融和其他领域中也类似使用,这意味着原始数据包含有用信息,例如,以交易信号的形式,需要从不相关的、无关的信息中提取和分离出来。显然,我们不知道真实信号的事实有时会使这种分离变得相当具有挑战性。

我们将首先回顾卡尔曼滤波器的工作原理以及它做出的假设,然后我们将演示如何使用pykalman库将其应用于金融数据。

卡尔曼滤波器是如何工作的?

卡尔曼滤波器是一种动态线性模型,用于适应顺序数据,如时间序列,随着新信息的到来而适应。与使用固定大小窗口(如移动平均值)或给定一组权重(如指数移动平均值)不同,它根据概率模型将新数据合并到其对时间序列当前值的估计中。

更具体地说,卡尔曼滤波器是一种关于一系列观测z[1],z[2],…,z[T]和相应隐藏状态x[1],x[2],…,x[T]的概率模型(使用我们将在此处演示的pykalman库使用的符号)。这可以通过以下图表示:

图 4.5:卡尔曼滤波器作为图形模型

图 4.5:卡尔曼滤波器作为图形模型

从技术上讲,卡尔曼滤波器采用贝叶斯方法,随着时间的推移传播状态变量x的后验分布,给定它们的测量z(有关贝叶斯推断的更多详细信息,请参见第十章贝叶斯 ML - 动态夏普比率和配对交易)。我们还可以将其视为一种无监督算法,用于跟踪连续状态空间中的单个对象,在这里,我们将对象视为,例如,安全性的价值或回报,或一个 alpha 因子(有关第十三章使用无监督学习的数据驱动风险因子和资产配置)。

为了从可能实时可用的一系列观测中恢复隐藏状态,该算法在两个步骤之间迭代:

  1. 预测步骤:估计过程的当前状态。

  2. 测量步骤:使用嘈杂的观测来通过以更加确定的估计更高的权重平均两个步骤的信息来更新其估计。

该算法背后的基本思想是:对动态系统和相应测量历史做出一些假设将使我们能够估计系统的状态,以一种最大化以前测量概率的方式。

实现其恢复隐藏状态的目标,卡尔曼滤波器做出以下假设:

  • 我们正在建模的系统以线性方式行为。

  • 隐藏状态过程是一个马尔可夫链,因此当前隐藏状态x[t]仅取决于最近的先前隐藏状态x[t-1]。

  • 测量受高斯、不相关噪声的影响,具有恒定的协方差。

结果上,卡尔曼滤波器类似于隐藏的马尔可夫模型,只是潜变量的状态空间是连续的,并且隐藏和观测变量都具有正态分布,表示为 具有均值 和标准差

在数学术语中,该模型的关键组成部分(以及在 pykalman 实现中对应的参数)是:

  • 初始隐藏状态具有正态分布: 具有 initial_state_meaninitial_state_covariance

  • 隐藏状态 x[t+1] 是 x[t] 的仿射变换,其中 transition_matrix Atransition_offset b,并且添加了带有 transition_covariance Q 的高斯噪声:

  • 观察 z[t] 是隐藏状态 x[t] 的仿射变换,其中 observation_matrix Cobservation_offset d,并且添加了带有 observation_covariance R 的高斯噪声:

卡尔曼滤波器的优点之一是,它能够灵活适应具有不断变化分布特征的非平稳数据(有关平稳性的更多细节,请参见第九章用于波动率预测和统计套利的时间序列模型)。

主要缺点是线性假设和高斯噪声的假设,而金融数据通常违反这些假设。为了解决这些缺点,卡尔曼滤波器已经扩展到具有非线性动态的系统,形式为扩展卡尔曼滤波器和无迹卡尔曼滤波器。粒子滤波器是一种使用基于采样的蒙特卡洛方法来估计非正态分布的替代方法。

如何使用 pykalman 应用卡尔曼滤波器

卡尔曼滤波器特别适用于随时间变化的数据值或模型参数的滚动估计。这是因为它根据新观察在每个时间步骤调整其估计,并且倾向于更加重视最近的观察。

除了传统的移动平均外,卡尔曼滤波器不要求我们指定用于估计的窗口长度。相反,我们从潜在状态的均值和协方差的估计开始,并让卡尔曼滤波器根据周期性观察来纠正我们的估计。本节的代码示例在笔记本 kalman_filter_and_wavelets.ipynb 中。

以下代码示例显示了如何将卡尔曼滤波器应用于平滑 2008-09 年期间的标准普尔 500 股价系列:

with pd.HDFStore(DATA_STORE) as store:
    sp500 = store['sp500/stooq'].loc['2008': '2009', 'close'] 

我们使用单位协方差矩阵和零均值初始化 KalmanFilter(有关如何处理选择适当初始值的挑战的建议,请参阅 pykalman 文档):

from pykalman import KalmanFilter
kf = KalmanFilter(transition_matrices = [1],
                  observation_matrices = [1],
                  initial_state_mean = 0,
                  initial_state_covariance = 1,
                  observation_covariance=1,
                  transition_covariance=.01) 

然后,我们运行 filter 方法来触发前向算法,该算法迭代地估计隐藏状态,即时间序列的均值:

state_means, _ = kf.filter(sp500) 

最后,我们添加移动平均以进行比较并绘制结果:

sp500_smoothed = sp500.to_frame('close')
sp500_smoothed['Kalman Filter'] = state_means
for months in [1, 2, 3]:
    sp500_smoothed[f'MA ({months}m)'] = (sp500.rolling(window=months * 21)
                                         .mean())
ax = sp500_smoothed.plot(title='Kalman Filter vs Moving Average',
                         figsize=(14, 6), lw=1, rot=0) 

图 4.6中的结果图表显示,卡尔曼滤波器的表现与 1 个月移动平均值相似,但对时间序列行为的变化更敏感:

图 4.6:卡尔曼滤波器与移动平均值

如何使用小波预处理嘈杂的信号

小波与傅立叶分析相关联,后者将不同频率的正弦和余弦波组合起来以逼近嘈杂的信号。虽然傅立叶分析特别适用于将信号从时间域转换为频率域,但小波可用于过滤可能出现在不同尺度上的特定模式,这反过来可能对应于一定的频率范围。

小波是将离散或连续时间信号分解为不同尺度分量的函数或类似波形。小波变换则利用小波作为缩放和平移的有限长度波形的扩展和平移副本来表示函数。对于具有不连续性和尖峰的函数以及近似非周期性或非平稳信号,该变换优于傅立叶变换。

要去噪信号,可以使用小波收缩和阈值方法。首先,选择特定的小波模式来分解数据集。小波变换产生与数据集中的细节相对应的系数。

阈值法的理念很简单,就是省略所有低于特定截止值的系数,假设它们代表不必要表示真实信号的细节。然后,这些剩余系数被用于反向小波变换以重构(去噪)数据集。

现在我们将使用 pywavelets 库将小波应用于嘈杂的股票数据。以下代码示例说明了如何使用 Daubechies 6 小波和不同阈值值进行前向和反向小波变换去噪标普 500 指数收益率。

首先,我们为 2008-09 期间生成每日标普 500 指数收益率:

signal = (pd.read_hdf(DATA_STORE, 'sp500/stooq')
          .loc['2008': '2009']
          .close.pct_change()
          .dropna()) 

然后,我们从众多内置小波函数中选择了一个 Daubechies 小波:

import pywt
pywt.families(short=False)
['Haar', 'Daubechies',  'Symlets',  'Coiflets',  'Biorthogonal',  'Reverse biorthogonal',  'Discrete Meyer (FIR Approximation)',  'Gaussian',  'Mexican hat wavelet',  'Morlet wavelet',  'Complex Gaussian wavelets',   'Shannon wavelets',  'Frequency B-Spline wavelets',  'Complex Morlet wavelets'] 

Daubechies 6 小波由缩放函数 和小波函数 定义(有关详细信息,请参阅 PyWavelet 文档和附带的笔记本kalman_filter_and_wavelets.ipynb中的所有内置小波函数的图表):

图 4.7:Daubechies 小波

给定小波函数,我们首先使用.wavedec函数分解返回信号,该函数产生小波变换的系数。接下来,我们过滤掉所有高于给定阈值的系数,然后使用仅这些系数的逆变换.waverec来重构信号:

wavelet = "db6"
for i, scale in enumerate([.1, .5]):

    coefficients = pywt.wavedec(signal, wavelet, mode='per')
    coefficients[1:] = [pywt.threshold(i, value=scale*signal.max(), mode='soft') for i in coefficients[1:]]
    reconstructed_signal = pywt.waverec(coefficients, wavelet, mode='per')
    signal.plot(color="b", alpha=0.5, label='original signal', lw=2, 
                 title=f'Threshold Scale: {scale:.1f}', ax=axes[i])
    pd.Series(reconstructed_signal, index=signal.index).plot(c='k', label='DWT smoothing}', linewidth=1, ax=axes[i]) 

笔记本展示了如何使用不同的阈值应用这种去噪技术,所得到的图表,在图 4.8中清楚地显示出更高的阈值值产生了显著更平滑的系列:

图 4.8:不同阈值的小波去噪

从信号到交易 - Zipline 用于回测

开源库 Zipline 是一个事件驱动的回测系统。它生成市场事件来模拟算法交易策略的反应,并跟踪其表现。一个特别重要的特性是,它为算法提供了避免前瞻性偏差的历史时点数据。

该库由众包的量化投资基金 Quantopian 推广,Quantopian 在生产中使用它来促进算法开发和实时交易。

在本节中,我们将简要演示其基本功能。第八章ML4T 工作流程 - 从模型到策略回测,包含了更详细的介绍,以准备我们进行更复杂的用例。

如何回测单因子策略

您可以与数据包一起离线使用 Zipline 进行 alpha 因子的研究和评估。在 Quantopian 平台上使用时,您将获得更广泛的基本和替代数据。我们还将在本章中演示 Quantopian 研究环境,并在下一章中演示回测 IDE。本节的代码位于本章的 GitHub 存储库文件夹的01_factor_research_evaluation子目录中,其中包括安装说明和适用于 Zipline 依赖项的环境。

安装请参阅本章的 GitHub 上的README中的说明。安装后,在执行第一个算法之前,您需要摄取一个数据包,默认情况下包含 Quandl 社区维护的 3000 家美国上市公司的股价、股息和拆股数据。

运行以下代码需要 Quandl API 密钥,该代码将数据存储在您的home文件夹下的~/.zipline/data/<bundle>目录中:

$ QUANDL_API_KEY=<yourkey> zipline ingest [-b <bundle>] 

市场数据的单个 alpha 因子

我们首先将在离线环境中说明 Zipline alpha 因子研究工作流程。特别是,我们将开发和测试一个简单的均值回归因子,该因子衡量了最近表现与历史平均值的偏离程度。

短期反转是一种常见策略,利用了股票价格很可能在不到 1 分钟到 1 个月的时间内恢复到滚动平均值的弱预测模式。详情请参阅笔记本single_factor_zipline.ipynb

为此,该因子计算了最后一个月回报相对于过去一年内滚动月回报的z值。此时,我们不会下任何订单,只是为了说明CustomFactor的实现并在模拟过程中记录结果。

Zipline 包含许多内置因子,用于许多常见操作(有关详细信息,请参阅 GitHub 上链接的 Quantopian 文档)。虽然这通常很方便且足够,但在其他情况下,我们希望以不同方式转换我们的可用数据。为此,Zipline 提供了 CustomFactor 类,它为我们指定了一系列计算提供了很大的灵活性。它使用 NumPy 提供的各种功能来为证券的横截面和自定义回顾期提供各种计算。

为此,在进行一些基本设置之后,MeanReversionCustomFactor 为子类,并定义了一个 compute() 方法。它创建了默认输入的月度回报率,覆盖了默认的一年窗口,以便 monthly_return 变量在给定日期的 Quandl 数据集中每个证券都有 252 行和一列。

compute_factors() 方法创建了一个 MeanReversion 因子实例,并创建了长、短和排名管道列。前两者包含布尔值,可用于下达订单,后者反映了用于评估整体因子性能的整体排名。此外,它使用内置的 AverageDollarVolume 因子将计算限制在更流动的股票上:

from zipline.api import attach_pipeline, pipeline_output, record
from zipline.pipeline import Pipeline, CustomFactor
from zipline.pipeline.factors import Returns, AverageDollarVolume
from zipline import run_algorithm
MONTH, YEAR = 21, 252
N_LONGS = N_SHORTS = 25
VOL_SCREEN = 1000
class MeanReversion(CustomFactor):
    """Compute ratio of latest monthly return to 12m average,
       normalized by std dev of monthly returns"""
    inputs = [Returns(window_length=MONTH)]
    window_length = YEAR
    def compute(self, today, assets, out, monthly_returns):
        df = pd.DataFrame(monthly_returns)
        out[:] = df.iloc[-1].sub(df.mean()).div(df.std())
def compute_factors():
    """Create factor pipeline incl. mean reversion,
        filtered by 30d Dollar Volume; capture factor ranks"""
    mean_reversion = MeanReversion()
    dollar_volume = AverageDollarVolume(window_length=30)
    return Pipeline(columns={'longs'  : mean_reversion.bottom(N_LONGS),
                             'shorts' : mean_reversion.top(N_SHORTS),
                             'ranking': 
                          mean_reversion.rank(ascending=False)},
                          screen=dollar_volume.top(VOL_SCREEN)) 

结果将允许我们进行长期和短期订单的下达。在下一章中,我们将学习如何通过选择再平衡周期和在新信号到达时调整投资组合持仓来构建投资组合。

initialize() 方法注册了 compute_factors() 管道,而 before_trading_start() 方法确保管道每天运行一次。record() 函数将管道的排名列以及当前资产价格添加到由 run_algorithm() 函数返回的性能 DataFrame 中:

def initialize(context):
    """Setup: register pipeline, schedule rebalancing,
        and set trading params"""
    attach_pipeline(compute_factors(), 'factor_pipeline')
def before_trading_start(context, data):
    """Run factor pipeline"""
    context.factor_data = pipeline_output('factor_pipeline')
    record(factor_data=context.factor_data.ranking)
    assets = context.factor_data.index
    record(prices=data.current(assets, 'price')) 

最后,在 UTC 术语中定义 startend 时间戳对象,设置资本基础,并执行带有对关键执行方法的引用的 run_algorithm()。性能 DataFrame 包含嵌套数据,例如,价格列由每个单元格的 pd.Series 组成。因此,当以 pickle 格式存储时,后续数据访问更容易:

start, end = pd.Timestamp('2015-01-01', tz='UTC'), pd.Timestamp('2018-
             01-01', tz='UTC')
capital_base = 1e7
performance = run_algorithm(start=start,
                            end=end,
                            initialize=initialize,
                            before_trading_start=before_trading_start,
                            capital_base=capital_base)
performance.to_pickle('single_factor.pickle') 

我们将在下一节中使用存储在 performance DataFrame 中的因子和定价数据来评估不同持有期的因子表现,但首先,我们将看一下如何通过组合 Quantopian 平台上多样数据源的几个 alpha 因子来创建更复杂的信号。

内置 Quantopian 因子

附带的笔记本 factor_library_quantopian.ipynb 包含了许多示例因子,这些因子要么由 Quantopian 平台提供,要么通过从 Jupyter Notebook 使用研究 API 访问的数据源计算而来。

有一些内置因子可以与量化 Python 库(特别是 NumPy 和 pandas)结合使用,从广泛的相关数据源(如美国股票价格、Morningstar 基本面和投资者情绪)中派生出更复杂的因子。

例如,市销率可作为 Morningstar 基本面数据集的一部分获得。它可以作为将在我们介绍 Zipline 库时进一步描述的管道的一部分使用。

结合来自多种数据源的因子

Quantopian 研究环境专为快速测试预测性α因子而设计。该过程非常类似,因为它建立在 Zipline 之上,但提供了更丰富的数据源访问。下面的代码示例说明了如何计算α因子,不仅来自市场数据,如以前所做的那样,还来自基本数据和替代数据。有关详细信息,请参阅笔记本multiple_factors_quantopian_research.ipynb

Quantopian 免费提供了数百个 Morningstar 基本面变量,并将 Stocktwits 信号作为替代数据源的示例。还有一些自定义的宇宙定义,比如QTradableStocksUS,它应用了几个过滤器来限制回测宇宙,使其仅包括在现实市场条件下可能可交易的股票:

from quantopian.research import run_pipeline
from quantopian.pipeline import Pipeline
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.data.morningstar import income_statement, 
     operation_ratios, balance_sheet
from quantopian.pipeline.data.psychsignal import stocktwits
from quantopian.pipeline.factors import CustomFactor, 
     SimpleMovingAverage, Returns
from quantopian.pipeline.filters import QTradableStocksUS 

我们将使用自定义的AggregateFundamentals类来使用最后报告的基本数据点。这旨在解决基本面数据每季度报告一次的事实,而 Quantopian 目前没有提供一种轻松的方法来聚合历史数据,比如说,在滚动基础上获取过去四个季度的总和:

class AggregateFundamentals(CustomFactor):
    def compute(self, today, assets, out, inputs):
        out[:] = inputs[0] 

我们将再次使用前面代码中的自定义MeanReversion因子。我们还将使用rank()方法的mask参数来计算给定宇宙定义的几个其他因子的排名:

def compute_factors():
    universe = QTradableStocksUS()
    profitability = (AggregateFundamentals(inputs=
                     [income_statement.gross_profit],
                                           window_length=YEAR) /
                     balance_sheet.total_assets.latest).rank(mask=universe)
    roic = operation_ratios.roic.latest.rank(mask=universe)
    ebitda_yield = (AggregateFundamentals(inputs=
                             [income_statement.ebitda],
                                          window_length=YEAR) /
                    USEquityPricing.close.latest).rank(mask=universe)
    mean_reversion = MeanReversion().rank(mask=universe)
    price_momentum = Returns(window_length=QTR).rank(mask=universe)
    sentiment = SimpleMovingAverage(inputs=[stocktwits.bull_minus_bear],
                                    window_length=5).rank(mask=universe)
    factor = profitability + roic + ebitda_yield + mean_reversion + 
             price_momentum + sentiment
    return Pipeline(
            columns={'Profitability'      : profitability,
                     'ROIC'               : roic,
                     'EBITDA Yield'       : ebitda_yield,
                     "Mean Reversion (1M)": mean_reversion,
                     'Sentiment'          : sentiment,
                     "Price Momentum (3M)": price_momentum,
                     'Alpha Factor'       : factor}) 

此算法简单地将每个资产的六个个别因子的排名平均起来,以结合它们的信息。这是一种相当简单的方法,它并未考虑到每个因子在预测未来收益时可能提供的相对重要性和增量信息。下一章的机器学习算法将使我们能够使用相同的回测框架来做到这一点。

执行还依赖于run_algorithm(),但 Quantopian 平台上的returnDataFrame 仅包含由Pipeline创建的因子值。这很方便,因为这种数据格式可以用作 Alphalens 的输入,该库用于评估α因子的预测性能。

使用 TA-Lib 与 Zipline

TA-Lib 库包含众多技术因素。Python 实现可供本地使用,例如,与 Zipline 和 Alphalens 一起使用,它还可在 Quantopian 平台上使用。该笔记本还说明了使用 TA-Lib 可用的几个技术指标。

使用 Alphalens 分离信号与噪声

Quantopian 已经开源了用于预测性股票因子的性能分析的 Python Alphalens 库。它与 Zipline 回测库以及投资组合性能和风险分析库 pyfolio 很好地集成在一起,我们将在下一章中探讨。

Alphalens 便于分析 alpha 因子的预测能力,涉及:

  • 信号与随后收益的相关性

  • 基于(子集的)信号的等权或因子加权组合的盈利能力

  • 因子的周转率,以指示潜在的交易成本

  • 特定事件期间的因子表现

  • 前面的按行业细分

分析可以使用纸质文档或个别计算和绘图进行。纸质文档在在线存储库中有图示,以节省一些空间。

创建前向收益和因子分位数

要使用 Alphalens,我们需要提供两个输入:

  • 资产组合的信号,例如MeanReversion因子的排名返回的那些信号

  • 我们投资于某一资产的给定持有期将获得的前向收益

有关详细信息,请参阅笔记本 06_performance_eval_alphalens.ipynb

我们将从 single_factor.pickle 文件中恢复价格如下(并对factor_data 以同样方式进行处理;参见笔记本):

performance = pd.read_pickle('single_factor.pickle')
prices = pd.concat([df.to_frame(d) for d, df in performance.prices.items()],axis=1).T
prices.columns = [re.findall(r"\[(.+)\]", str(col))[0] for col in 
                  prices.columns]
prices.index = prices.index.normalize()
prices.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 755 entries, 2015-01-02 to 2017-12-29
Columns: 1661 entries, A to ZTS
dtypes: float64(1661) 

我们可以使用 get_clean_factor_and_forward_returns 实用函数,从 Zipline 输出中生成所需格式的 Alphalens 输入数据,即先前描述的因子信号和前向收益。该函数返回给定持有期的信号五分位和前向收益:

HOLDING_PERIODS = (5, 10, 21, 42)
QUANTILES = 5
alphalens_data = get_clean_factor_and_forward_returns(factor=factor_data,
                                     prices=prices,
                                     periods=HOLDING_PERIODS,
                                     quantiles=QUANTILES)
Dropped 14.5% entries from factor data: 14.5% in forward returns computation and 0.0% in binning phase (set max_loss=0 to see potentially suppressed Exceptions). max_loss is 35.0%, not exceeded: OK! 

alphalens_data DataFrame 包含给定日期投资于指定资产的收益和所示持有期的因子值,即该资产的 MeanReversion 在该日期的排名和相应的分位数值:

date asset 5D 10D 21D 42D factor factor_quantile
1/2/2015 A -1.87% -1.11% -4.61% 5.28% 2618 4
AAL -0.06% -8.03% -9.63% -10.39% 1088 2
AAP -1.32% 0.23% -1.63% -2.39% 791 1
AAPL -2.82% -0.07% 8.51% 18.07% 2917 5
ABBV -1.88% -0.20% -7.88% -8.24% 2952 5

前向收益和信号分位数是评估信号预测能力的基础。通常,因子应该为不同的分位数提供明显不同的回报,例如因子值底部五分位的负回报和顶部分位数的正回报。

因子分位数的预测性能

作为第一步,我们想要可视化因子分位数的平均期回报。我们可以使用性能模块中的内置函数 mean_return_by_quantile 和绘图模块中的 plot_quantile_returns_bar

from alphalens.performance import mean_return_by_quantile
from alphalens.plotting import plot_quantile_returns_bar
mean_return_by_q, std_err = mean_return_by_quantile(alphalens_data)
plot_quantile_returns_bar(mean_return_by_q); 

结果是一个条形图,根据因子信号的五分位将四个不同持有期的前向收益的平均值进行了拆分。

正如您在 图 4.9 中所见,除了最长持有期外,底部五分位的收益远远低于顶部五分位:

图 4.9:因子分位数的平均期回报

10D持有期在整个交易期间对第一和第四四分位提供了略微更好的结果。

我们还希望看到由每个信号五分位驱动的投资随时间的表现。为此,我们计算了5D持有期的日收益,而不是平均收益。Alphalens 调整了期间收益,以解决日信号和较长持有期之间的不匹配(有关详细信息,请参阅 Alphalens 文档):

from alphalens.plotting import plot_cumulative_returns_by_quantile
mean_return_by_q_daily, std_err =
     mean_return_by_quantile(alphalens_data, by_date=True)
plot_cumulative_returns_by_quantile(mean_return_by_q_daily['5D'], 
     period='5D'); 

图 4.10中得到的线形图显示,在这 3 年的大部分时间里,前两个五分位明显优于后两个五分位。然而,正如前一个图所示,第四个五分位的信号由于其在 2017 年的相对绩效而产生了略微更好的绩效:

图 4.10:5 天持有期按分位累积收益

对于交易策略有用的因子表现出前述模式,其中累积收益沿着明显不同的路径发展,因为这允许具有较低资本要求和相应较低整体市场风险敞口的多头策略。

但是,我们还需要考虑期间收益的分散,而不仅仅是平均值。为此,我们可以依赖内置的plot_quantile_returns_violin

from alphalens.plotting import plot_quantile_returns_violin
plot_quantile_returns_violin(mean_return_by_q_daily); 

这个分布图,显示在图 4.11中,突出了日收益范围相当广泛。尽管有不同的均值,但分布的分离非常有限,以至于在任何给定的一天,不同五分位之间的绩效差异可能相当有限:

图 4.11:按因子五分位分布的期间收益

虽然我们专注于评估单个 alpha 因子,但在我们在下一章节中处理正确的回测时,我们通过忽略与交易执行相关的实际问题来简化事务。其中一些问题包括:

  • 交易的交易成本

  • 滑点,或者是决策价和交易执行价之间的差异,例如由于市场影响

信息系数

本书的大部分内容都是关于使用 ML 模型设计 alpha 因子。ML 是关于优化某些预测目标,而在本节中,我们将介绍用于衡量 alpha 因子性能的关键指标。我们将定义alpha超过基准的平均收益

这导致了信息比率IR),它通过将 alpha 除以跟踪风险来衡量单位风险的平均超额收益。当基准是无风险利率时,IR 对应于著名的夏普比率,我们将强调在典型情况下,即收益不符合正态分布时出现的关键统计测量问题。我们还将解释主动管理的基本法则,该法则将 IR 分解为预测技能的组合以及策略有效利用这些预测技能的能力。

Alpha 因子的目标是准确预测未来收益的方向。因此,一个自然的绩效度量是 alpha 因子预测与目标资产未来收益之间的相关性。

最好使用非参数的 Spearman 秩相关系数,它衡量了两个变量之间的关系如何能够使用单调函数描述,而不是 Pearson 相关系数,后者衡量线性关系的强度。

我们可以使用 Alphalens 获取信息系数IC),它依赖于底层的scipy.stats.spearmanr(请参阅存储库以了解如何直接使用scipy获取p-values 的示例)。factor_information_coefficient函数计算周期相关性,plot_ic_ts创建带有 1 个月移动平均线的时间序列图:

from alphalens.performance import factor_information_coefficient
from alphalens.plotting import plot_ic_ts
ic = factor_information_coefficient(alphalens_data)
plot_ic_ts(ic[['5D']]) 

图 4.12中的时间序列图显示了具有显著正移动平均 IC 的延长时期。如果有足够的机会应用这种预测技能,IC 为 0.05 甚至 0.1 可以实现显著的超额收益,正如主动管理的基本法则所说明的那样:

图 4.12:5 天视野的 IC 移动平均值

年均 IC 的图表突出显示了该因子的历史表现不均匀:

ic = factor_information_coefficient(alphalens_data)
ic_by_year = ic.resample('A').mean()
ic_by_year.index = ic_by_year.index.year
ic_by_year.plot.bar(figsize=(14, 6)) 

这产生了图表图 4.13

图 4.13:按年份划分的 IC

如本例中所示,信息系数低于 0.05,但显著,并且相对于基准可以产生正的残差收益,我们将在下一节中看到。命令create_summary_tear_sheet(alphalens_data)创建 IC 摘要统计。

风险调整 IC 的结果是通过将 IC 均值除以 IC 的标准差来得到的,还要经受双边t检验,零假设为IC = 0,使用scipy.stats.ttest_1samp

5 天 10 天 21 天 42 天
IC 均值 0.021 0.025 0.015 0.001
IC 标准差 0.144 0.13 0.12 0.12
风险调整 IC 0.145 0.191 0.127 0.01
t 值(IC) 3.861 5.107 3.396 0.266
p 值(IC) 0 0 0.001 0.79
IC 偏度 0.384 0.251 0.115 0.134
IC 峰度 0.019 -0.584 -0.353 -0.494

因子周转率

因子周转率衡量了与特定分位数关联的资产频繁变动的频率,即调整投资组合以适应信号序列所需的交易次数。更具体地说,它衡量了当前处于因子分位数中的资产份额,而上一期不在该分位数中的资产份额。以下表格由此命令生成:

create_turnover_tear_sheet(alphalens_data) 

加入基于五分位数的投资组合的资产份额相当高,这表明交易成本对于收获预测性能的好处构成了一项挑战:

平均周转率 5D 10D 21D 42D
四分位数 1 0.587 0.826 0.828 0.41
四分位数 2 0.737 0.801 0.81 0.644
四分位数 3 0.764 0.803 0.808 0.679
四分位数 4 0.737 0.803 0.808 0.641
四分位数 5 0.565 0.802 0.809 0.393

因子周转的另一个视角是由于因子的资产排名在不同持有期间的相关性,也是撕裂表的一部分:

5D 10D 21D 42D
平均因子等级自相关性 0.713 0.454 -0.011 -0.016

通常,更多的稳定性更可取,以保持交易成本可控。

Alpha 因子资源

研究过程需要根据其信号的预测能力设计和选择 Alpha 因子。算法交易策略通常会基于多个为每个资产发送信号的 Alpha 因子构建。这些因子可以使用 ML 模型聚合,以优化各种信号如何转化为关于个别仓位的时间和大小决策,正如我们将在后续章节中看到的那样。

替代算法交易库

用于算法交易和数据收集的其他开源 Python 库包括以下内容(请参阅 GitHub 链接):

  • QuantConnect是 Quantopian 的竞争对手。

  • WorldQuant提供在线竞赛,并招募社区贡献者参与众包式对冲基金。

  • Alpha Trading Labs提供了一个与 Quantopian 类似的高频率测试基础设施,并采用了相似的商业模式。

  • Python 算法交易库PyAlgoTrade)专注于回测,并支持模拟交易和实时交易。它允许您使用历史数据评估交易策略的想法,并旨在以最小的努力实现这一目标。

  • pybacktest是一个矢量化的回测框架,使用 pandas,并旨在紧凑、简单且快速。(该项目目前暂停。)

  • ultrafinance是一个较老的项目,结合了实时财务数据收集和交易策略的分析和回测。

  • 用 Python 进行交易提供了课程和一套用于量化交易的函数和类。

  • 互动经纪商提供了一个用于在其平台上进行实时交易的 Python API。

摘要

在本章中,我们介绍了一系列α因子,这些因子已被专业投资者用于设计和评估策略数十年。我们阐述了它们的工作原理,并说明了一些被认为推动其绩效的经济机制。我们这样做是因为对因子如何产生超额收益有扎实的理解有助于创新新的因子。

我们还介绍了几种工具,您可以使用这些工具从各种数据源生成自己的因子,并演示了卡尔曼滤波器和小波如何使我们能够平滑嘈杂的数据,希望能够获得更清晰的信号。

最后,我们简要介绍了用于事件驱动交易算法的 Zipline 库,无论是离线还是在线于 Quantopian 平台上。您了解了如何实现一个简单的均值回归因子,以及如何简单地结合多个因子来驱动基本策略。我们还看了 Alphalens 库,该库允许评估信号的预测性能和交易换手率。

投资组合构建过程反过来则从更广泛的角度出发,旨在从风险和回报的角度对头寸进行最优定量。在下一章节中,投资组合优化与策略评估,我们将探讨在投资组合过程中平衡风险和回报的各种策略。我们还将更详细地研究在有限历史数据集上回测交易策略的挑战,以及如何解决这些挑战。

第五章:组合优化和绩效评估

阿尔法因子产生信号,算法策略将其转化为交易,进而产生多头和空头仓位。由此产生的投资组合的回报和风险决定了策略的成功。

在市场条件下测试策略之前,我们需要模拟算法将进行的交易并验证其绩效。策略评估包括针对历史数据进行反向测试以优化策略的参数,并进行向前测试以验证样本内绩效与新的样本外数据。目标是避免从针对特定过去情况量身定制策略中发现虚假结果。

在组合环境中,正资产回报可以抵消负价格波动。一个资产的正价格变动更有可能抵消另一个资产的损失,这两个仓位之间的相关性越低。基于仓位协方差对组合风险的影响,哈里·马科维茨于 1952 年开发了现代投资组合管理背后的理论,该理论基于多样化。结果是均值-方差优化,它为给定的资产集合选择权重以最小化风险,风险用预期收益的标准差来衡量。

资本资产定价模型(CAPM)引入了一个风险溢价,该溢价以超过无风险投资的预期收益来衡量,作为持有资产的平衡报酬。这种奖励是对持有资产暴露于一个系统性风险因素——市场的补偿,与特定资产特有的非系统性风险相对,因此无法通过分散化消除。

风险管理随着额外的风险因素和更精细的暴露选择的出现而变得更加复杂。凯利准则是动态组合优化的一种流行方法,即选择一系列随时间变化的仓位;它最初被爱德华·索普在 1968 年从赌博中著名地改编到股票市场中。

因此,有几种优化投资组合的方法,包括将机器学习(ML)应用于学习资产之间的层次关系,并将它们的持仓视为相互补充或替代品,以满足组合风险概况。

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

  • 如何衡量组合风险和回报

  • 使用均值-方差优化和替代方法管理组合权重

  • 在组合环境中使用机器学习来优化资产配置

  • 使用 Zipline 模拟交易并基于阿尔法因子创建投资组合

  • 如何使用 pyfolio 评估组合绩效

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和其他资源的链接。笔记本包括图像的彩色版本。

如何衡量组合绩效

为了评估和比较不同的策略或改进现有策略,我们需要反映它们相对于我们目标的表现的指标。在投资和交易中,最常见的目标是投资组合的回报和风险。

通常,这些指标与代表替代投资机会的基准进行比较,例如作为美国股票的摘要的投资宇宙的标准普尔 500 或固定收益资产的无风险利率。

有几种指标可用于评估这些目标。在本节中,我们将回顾比较投资组合结果最常见的度量标准。当我们研究不同的方法来优化投资组合绩效,使用 Zipline 模拟策略与市场的交互,并在后续章节中使用 pyfolio 库计算相关绩效指标时,这些措施将会很有用。

我们将使用一些简单的符号:设 R 为单期简单投资组合回报的时间序列,R=(r[1], ..., r[T]),从日期 1 到 T,并且 R^f =(r^f[1], ..., r^f[T]) 为相匹配的无风险利率的时间序列,因此 R[e]=R-R[f] =(r[1]-r^f[1],..., r[T]-r^f[T]) 是超额回报。

用一个数字捕捉风险-回报权衡关系

回报和风险目标暗示着一种权衡关系:在某些情况下,承担更多风险可能会带来更高的回报,但也意味着更大的下行风险。为了比较不同策略如何权衡这一权衡关系,非常流行的是计算单位风险回报率的比率。我们将依次讨论夏普比率和信息比率。

夏普比率

夏普比率SR)前瞻性地比较了投资组合的预期超额回报与该超额回报的波动性,由其标准偏差测量。它衡量了作为单位风险承担的平均超额回报作为补偿:

预期回报和波动性不可观察,但可以根据历史数据如下估算:

除非无风险利率波动较大(如新兴市场),超额回报和原始回报的标准偏差将相似。

对于独立同分布IID)的回报,用于统计显著性检验的 SR 估计量的分布,根据大样本统计理论,可以从中心极限定理CLT)的应用得出,这意味着像 这样的 IID 随机变量的和收敛于正态分布。

当您需要比较不同频率的夏普比率时,例如月度和年度数据,您可以将更高频率的夏普比率乘以包含在更低频率中的对应时期的平方根。要将月度夏普比率转换为年度夏普比率,请乘以,要将日度转换为月度,请乘以

然而,金融回报往往违反独立同分布假设。安德鲁·罗已经推导出了对分布和时间聚合进行必要调整的公式,以适应平稳但自相关的回报。这很重要,因为投资策略的时间序列特性(例如,均值回归,动量和其他形式的序列相关性)可能对夏普比率估计本身产生非平凡的影响,特别是当从高频数据年化夏普比率时(Lo,2002)。

信息比率

信息比率IR)类似于夏普比率,但使用的是基准而不是无风险利率。基准通常被选为代表可用投资领域的投资组合,如标普 500 指数用于大型美国股票投资组合。

因此,信息比率(IR)衡量的是投资组合的超额回报,也称为阿尔法,相对于跟踪误差,即投资组合回报与基准回报之间的偏差,即:

IR 还被用来解释超额回报如何取决于管理者的技能和她的策略的性质,我们将在下文中看到。

主动管理的基本定律

“分散化是对无知的防护。如果你知道你在做什么,那就没多大意义。”

– 沃伦·巴菲特

一个好奇的事实是,文艺复兴技术RenTec),由吉姆·西蒙斯创立的表现最佳的量化基金,在第一章交易的机器学习-从构想到执行中提到过,尽管采取了极其不同的方法,却产生了与沃伦·巴菲特相似的回报。沃伦·巴菲特的投资公司伯克希尔·哈撒韦持有大约 100-150 只股票,持有时间相当长,而 RenTec 可能每天执行 10 万笔交易。我们如何比较这些不同的策略?

高 IR 反映了相对于承担的额外风险而言,基准的吸引人的超额表现。主动管理的基本定律解释了如何实现这样的结果:它将 IR 近似为信息系数IC)和策略的广度的乘积。

正如前一章所讨论的,IC 衡量了回报预测之间的等级相关性,例如由阿尔法因子暗示的预期回报与实际未来回报之间的相关性。因此,它是管理者的预测技能的一种度量。策略的广度由投资者在给定时间段内进行的独立下注数(即交易数)来衡量,从而代表了应用预测技能的能力。

基本法律规定,IR,也称为评估风险(特雷纳和布莱克),是两个值的乘积。换句话说,它总结了频繁(高广度)和出色表现(高 IC)的重要性:

这个框架已经扩展到包括转移系数TC)来反映投资组合约束作为一个额外因素(例如,对做空的限制),这可能会限制信息比率在否则可达到的水平以下的因素。 TC 代表了管理者将见解转化为投资组合投注的效率:如果没有约束,TC 将简单地等于一;但是如果管理者尽管预测表明他们应该这样做而不做空股票,TC 将小于一,并减少 IC(Clarke 等人,2002)。

基本法律之所以重要,是因为它突出了超额表现的关键驱动因素:准确预测和能够进行独立预测并在这些预测上采取行动都很重要。

在实践中,拥有广泛投资决策的管理人员可以通过信息系数在 0.05 至 0.15 之间实现显著的风险调整超额回报,如下模拟所示:

图 5.1:不同广度和信息系数值的信息比率

在实践中,鉴于预测之间的横截面和时间序列相关性,估计策略的广度是困难的。您应该将基本法律及其扩展视为一个有用的分析框架,用于思考如何改善您的风险调整后的投资组合绩效。接下来我们将看一些实践中做到这一点的技术。

如何管理投资组合风险和回报

投资组合管理旨在挑选和规模化金融工具中的头寸,以实现与基准相关的期望风险回报权衡。作为投资组合经理,在每个时期,您选择优化多样化的头寸,以降低风险并实现目标回报。在不同时期,这些头寸可能需要重新平衡,以考虑价格波动导致的权重变化,以实现或维持目标风险配置。

现代投资组合管理的演变

多样化使我们能够通过利用不完美的相关性,使一个资产的收益弥补另一个资产的损失,以降低给定预期回报的风险。哈里·马科维茨于 1952 年发明了现代投资组合理论MPT),并提供了通过选择适当的投资组合权重来优化多样化的数学工具。

马科维茨展示了投资组合风险,以投资组合收益的标准偏差衡量,如何取决于所有资产收益之间的协方差以及它们的相对权重。这种关系意味着存在一组投资组合的有效前沿,其最大化了在给定最大化投资组合风险水平的情况下的投资组合回报。

然而,均值-方差前沿对于其计算所需的输入的估计非常敏感,这些输入包括预期回报、波动率和相关性。在实践中,将这些输入约束在一定范围内以减少抽样误差的均值-方差组合投资组合表现更好。这些受限制的特殊情况包括等权重、最小方差和风险平价投资组合。

资本资产定价模型CAPM)是建立在 MPT 风险-回报关系基础上的资产估值模型。它引入了一个概念,即投资者在市场均衡状态下持有风险资产时可以期望的风险溢价;该溢价补偿了货币时间价值和无法通过分散化消除的整体市场风险暴露(与特定资产的特殊风险相对)。

不可分散风险的经济基础包括,例如,影响所有股票回报或债券违约的业务风险的宏观驱动因素。因此,资产的预期回报E[r[i]]是无风险利率r[f]和与资产暴露于市场组合预期超额回报r[m]的风险溢价的乘积的总和:

理论上,市场组合包含所有可投资资产,并且在均衡状态下,将由所有理性投资者持有。在实践中,一个广义的价值加权指数近似于市场,例如,对于美国股票投资来说是标准普尔 500 指数(S&P 500)。

测量了资产i对市场组合超额回报的暴露。如果 CAPM 有效,则截距部分应为零。实际上,CAPM 的假设通常未得到满足,而α捕捉了通过暴露于广泛市场而未解释的回报。

正如前一章所讨论的,随着时间的推移,研究揭示了非传统风险溢价,例如动量或股票价值效应,这些溢价解释了一些原始α。经济原理,如投资者对新信息的欠反应或过度反应的行为偏差,为暴露于这些替代风险因素的风险溢价提供了合理解释。

这些因素演变成了旨在捕捉这些替代贝塔的投资风格,这些替代贝塔可以通过专门的指数基金进行交易。同样,风险管理现在旨在控制除市场组合以外的多种风险来源的暴露。

在分离这些替代风险溢价的贡献后,真正的α仅限于特定资产回报和管理者调整风险暴露的能力。

有效市场假说EMH)在过去几十年中得到了完善,以纠正 CAPM 的许多原始缺陷,包括信息不完全以及与交易、融资和代理相关的成本。许多行为偏见具有相同的效果,一些摩擦被建模为行为偏见。

过去几十年来,现代投资组合理论和实践发生了重大变化。我们将介绍几种方法:

  • 均值-方差优化及其缺点

  • 最小风险和 1/n 分配等替代方案

  • 风险平价方法

  • 风险因子方法

均值-方差优化

现代投资组合理论解决了为最小化给定预期收益的波动性或为给定波动性水平最大化收益的最优投资组合权重。关键的先决条件输入是预期资产收益率、标准偏差和协方差矩阵。

它的工作原理

多样化的作用是因为投资组合收益的方差取决于资产的协方差。通过包含相关性不完全的资产,可以将其降低到资产方差的加权平均值以下。

特别是,给定投资组合权重向量 和协方差矩阵 ,投资组合方差 定义为:

马科维茨表明,最大化预期投资组合收益的问题,在目标风险约束下具有等效的最小化投资组合风险的对偶表示,主要是在目标预期收益水平下,。因此,优化问题变为:

在 Python 中找到有效边界

我们可以使用 scipy.optimize.minimize 和资产收益率、标准偏差以及协方差矩阵的历史估计来计算有效边界。 SciPy 的 minimize 函数实现了一系列标量函数的受约束和无约束优化算法,这些函数从一个或多个输入变量中输出一个单一数字(详见 SciPy 文档以获取更多详情)。代码可以在本章的存储库的 strategy_evaluation 子文件夹中找到,并实现以下步骤序列:

首先,模拟使用狄利克雷分布生成随机权重,并使用历史回报数据计算每个样本投资组合的均值、标准偏差和 SR:

def simulate_portfolios(mean_ret, cov, rf_rate=rf_rate, short=True):
    alpha = np.full(shape=n_assets, fill_value=.05)
    weights = dirichlet(alpha=alpha, size=NUM_PF)
    if short:
        weights *= choice([-1, 1], size=weights.shape)
    returns = weights @ mean_ret.values + 1
    returns = returns ** periods_per_year - 1
    std = (weights @ monthly_returns.T).std(1)
    std *= np.sqrt(periods_per_year)
    sharpe = (returns - rf_rate) / std
    return pd.DataFrame({'Annualized Standard Deviation': std,
                         'Annualized Returns': returns,
                         'Sharpe Ratio': sharpe}), weights 

接下来,我们设置二次优化问题,以解决给定收益的最小标准偏差或最大 SR。为此,我们定义了测量关键绩效指标的函数:

def portfolio_std(wt, rt=None, cov=None):
    """Annualized PF standard deviation"""
    return np.sqrt(wt @ cov @ wt * periods_per_year)
def portfolio_returns(wt, rt=None, cov=None):
    """Annualized PF returns"""
    return (wt @ rt + 1) ** periods_per_year - 1
def portfolio_performance(wt, rt, cov):
    """Annualized PF returns & standard deviation"""
    r = portfolio_returns(wt, rt=rt)
    sd = portfolio_std(wt, cov=cov)
    return r, sd 

接下来,我们定义一个目标函数,表示 scipy 的 minimize 函数要优化的负 SR,考虑到权重受到的约束为 [0, 1],并且绝对值总和为一:

def neg_sharpe_ratio(weights, mean_ret, cov):
    r, sd = portfolio_performance(weights, mean_ret, cov)
    return -(r - rf_rate) / sd
weight_constraint = {'type': 'eq',
                     'fun': lambda x: np.sum(np.abs(x)) - 1}
def max_sharpe_ratio(mean_ret, cov, short=False):
    return minimize(fun=neg_sharpe_ratio,
                    x0=x0,
                    args=(mean_ret, cov),
                    method='SLSQP',
                    bounds=((-1 if short else 0, 1),) * n_assets,
                    constraints=weight_constraint,
                    options={'tol':1e-10, 'maxiter':1e4}) 

然后,我们通过迭代一系列目标回报并解决相应的最小方差组合来计算有效前沿。为此,我们使用对投资组合风险和回报的约束作为权重的函数来制定优化问题,如下所示:

def min_vol_target(mean_ret, cov, target, short=False):
    def ret_(wt):
        return portfolio_returns(wt, mean_ret)
    constraints = [{'type': 'eq', 'fun': lambda x: ret_(x) - target},
                     weight_constraint]
    bounds = ((-1 if short else 0, 1),) * n_assets
    return minimize(portfolio_std, x0=x0, args=(mean_ret, cov),
                    method='SLSQP', bounds=bounds,
                    constraints=constraints,
                    options={'tol': 1e-10, 'maxiter': 1e4}) 

解决方案需要迭代在可接受值范围内的范围,以确定最佳的风险-回报组合:

def efficient_frontier(mean_ret, cov, ret_range):
    return [min_vol_target(mean_ret, cov, ret) for ret in ret_range] 

模拟产生了一部分可行的投资组合,有效前沿确定了样本内可实现的最佳回报-风险组合,给定历史数据。

图 5.2显示了结果,包括最小方差组合、最大化 SR 的组合,以及几个由替代优化策略产生的组合,我们将在接下来的章节中讨论:

图 5.2:有效前沿和不同的优化组合

投资组合优化可以在交易策略的每个评估步骤中运行,以优化仓位。

挑战和缺点

前面的均值方差边界估计说明了样本内,也就是向后看优化。在实践中,投资组合优化需要前瞻性的输入和输出。然而,预期收益的准确估计是非常困难的。最好将其视为众多改进的起点和基准。

协方差矩阵的估计可能会更可靠一些,这导致了几种替代方法的出现。然而,具有相关资产的协方差矩阵会带来计算上的挑战,因为优化问题需要矩阵求逆。高条件数会导致数值不稳定性,从而产生马尔可夫茨诅咒:需要更多分散(通过相关的投资机会)时,算法产生的权重就越不可靠。

许多投资者更喜欢使用具有较少繁琐输入要求的投资组合优化技术。我们现在将介绍几种旨在解决这些缺点的替代方法,包括一种基于机器学习的较新方法。

均值方差优化的替代方案

对均值方差优化问题的准确输入的挑战已经导致了几种实用的替代方案的采用,这些方案限制了均值、方差或两者,或者省略了更具挑战性的回报估计,例如风险平价方法,我们将在本节后面讨论。

1/N 投资组合

简单的投资组合提供了用于衡量生成过拟合风险的复杂模型的增值的有用基准。最简单的策略——等权重投资组合——已被证明是最佳表现之一。

闻名的 de Miguel、Garlappi 和 Uppal(2009)比较了由各种均值-方差优化器产生的投资组合在样本外的表现,包括健壮的贝叶斯估计器、投资组合约束和投资组合的最优组合,与简单的 1/N 规则。他们发现,1/N 投资组合在各种数据集上产生了比替代方案更高的夏普比率,这可以解释为估计误差的高成本通常超过了样本外复杂优化的好处。

更具体地说,他们发现,对于具有 25 个资产的投资组合,样本为基础的均值-方差策略及其延伸优于 1/N 基准所需的估计窗口约为 3,000 个月,而对于具有 50 个资产的投资组合约为 6,000 个月。

在前一节的图 5.2中还包括了 1/N 投资组合。

最小方差投资组合

另一种选择是全局最小方差GMV)投资组合,它优先考虑风险的最小化。它在图 5.2中显示,并可以通过使用均值-方差框架,如下所示,通过最小化投资组合标准偏差来计算:

def min_vol(mean_ret, cov, short=False):
    return minimize(fun=portfolio_std,
                    x0=x0,
                    args=(mean_ret, cov),
                    method='SLSQP',
                    bounds=bounds = ((-1 if short else 0, 1),) * 
                          n_assets,
                          constraints=weight_constraint,
                          options={'tol': 1e-10, 'maxiter': 1e4}) 

相应的最小波动率投资组合位于有效前沿上,正如之前在图 5.2中所示。

全球投资组合优化 – Black-Litterman 方法

Black 和 Litterman(1992)的全球投资组合优化方法将经济模型与统计学习相结合。它很受欢迎,因为它能够在许多情况下生成合理的预期收益估计。

该技术假定市场是一个均值-方差投资组合,正如 CAPM 平衡模型所暗示的那样。它建立在这样一个事实上,即观察到的市场资本化可以被视为市场对每个证券分配的最佳权重。市场权重反映了市场价格,而市场价格反过来又体现了市场对未来收益的预期。

该方法因此可以从市场足够接近 CAPM 定义的均衡的假设中逆向推导出不可观察的未来预期收益。投资者可以使用缩小估计器将这些估计值调整到自己的信念。该模型可以被解释为投资组合优化的贝叶斯方法。我们将在第十章贝叶斯 ML – 动态夏普比率和配对交易策略中介绍贝叶斯方法。

如何确定你的赌注大小 – 凯利准则

凯利准则在赌博界有着悠久的历史,因为它提供了在具有不同(但有利)赔率的(无限)一系列赌注中如何押注以最大化期末财富的指导。它是由 John Kelly 在 1956 年的论文信息速率的新解释中发表的,他是 Claude Shannon 在贝尔实验室的同事。他对在新的问答节目“$64,000 问题”上对候选人的投注感兴趣,其中一位西海岸的观众利用三小时的延迟获取内幕信息来获取胜利者。

凯利将与香农的信息论联系起来,以解决在赔率有利但不确定性仍然存在时,长期资本增长的最佳赌注。他的准则最大化对每个游戏成功概率的对数财富,并包括隐含的破产保护,因为log(0)为负无穷,因此凯利赌徒自然会避免失去一切。

投注的最佳大小

凯利从分析具有二元赢输结果的游戏开始。关键变量包括:

  • b:定义了每次赌注赢得的金额的赔率。赔率= 5/1 意味着如果赌注获胜,则获得 5 美元,加上 1 美元的资本返还。

  • p:定义有利结果可能性的概率。

  • f:要下注的当前资本份额。

  • V:由于赌注而产生的资本价值。

凯利准则旨在最大化无限重复赌注的价值增长率G

WL分别为赢得和输掉的次数时,则:

我们可以通过最大化G关于f的变化来最大化增长率G,使用 SymPy 进行说明,如下所示(您可以在kelly_rule笔记本中找到此内容):

from sympy import symbols, solve, log, diff
share, odds, probability = symbols('share odds probability')
Value = probability * log(1 + odds * share) + (1 - probability) * log(1 
        - share)
solve(diff(Value, share), share)
[(odds*probability + probability - 1)/odds] 

我们得出了要下注的资本的最佳份额:

最佳投资 - 单资产

在金融市场背景下,结果和替代方案都更加复杂,但凯利准则的逻辑仍然适用。它由 Ed Thorp 流行起来,他首先将其成功地应用于赌博(在书籍Beat the Dealer中描述),后来成立了成功的对冲基金 Princeton/Newport Partners。

对于连续的结果,资本的增长率由对可能的不同回报的概率分布的积分来定义,可以通过数值优化来优化:

我们可以使用scipy.optimize模块解决此表达式以得出最优的f**。quad函数使用 FORTRAN 的 QUADPACK 库计算在两个值ab*之间的定积分值(因此得名)。它返回积分值和误差估计:

def norm_integral(f, m, st):
    val, er = quad(lambda s: np.log(1+f*s)*norm.pdf(s, m, st), m-3*st, 
                   m+3*st)
    return -val
def norm_dev_integral(f, m, st):
    val, er = quad(lambda s: (s/(1+f*s))*norm.pdf(s, m, st), m-3*st, 
                   m+3*st)
    return val
m = .058
s = .216
# Option 1: minimize the expectation integral
sol = minimize_scalar(norm_integral, args=(
                m, s), bounds=[0., 2.], method='bounded')
print('Optimal Kelly fraction: {:.4f}'.format(sol.x))
Optimal Kelly fraction: 1.1974 

最佳投资 - 多个资产

我们将使用一个包含各种权益的示例。E. Chan(2008)说明了如何得出凯利准则的多资产应用,结果等同于(可能是杠杆的)从均值-方差优化中得出的最大夏普比率投资组合。

计算涉及精度矩阵(协方差矩阵的逆)与回报矩阵的点积:

mean_returns = monthly_returns.mean()
cov_matrix = monthly_returns.cov()
precision_matrix = pd.DataFrame(inv(cov_matrix), index=stocks, columns=stocks)
kelly_wt = precision_matrix.dot(mean_returns).values 

凯利投资组合也显示在之前的有效边界图中(标准化以使绝对权重之和等于一)。许多投资者更喜欢减少凯利权重以降低该策略的波动性,而半凯利则变得特别受欢迎。

风险平价

过去 15 年的两次全球股票市场危机、一直呈上升趋势的收益率曲线以及利率的普遍下降,使得风险平价看起来成为一种特别引人注目的选择。许多机构将战略性配置给了风险平价以进一步实现投资组合的多样化。

风险平价的简单实现根据它们的方差的倒数分配资产,忽略了相关性,尤其是回报预测:

var = monthly_returns.var()
risk_parity_weights = var / var.sum() 

风险平价投资组合也显示在本节开头的有效边界图中。

风险因子投资

估算输入的另一种替代框架是向下工作到驱动资产风险和回报的基础决定因素或因子。如果我们了解这些因素如何影响回报,我们又了解这些因素,我们将能够构建更健壮的投资组合。

因子投资的概念超越了资产类别标签。它着眼于我们在上一章关于 alpha 因子中讨论过的基础因子风险,以最大程度地实现分散化的利益。因子投资不是通过诸如对冲基金或私募股权之类的标签来区分投资工具,而是旨在根据暴露于基本风险因子的差异来识别不同的风险-回报配置(Ang 2014)。

对于均值-方差投资的天真方法将(人为的)分组视为不同的资产类别并纳入均值-方差优化器中。因子投资认识到这些分组与传统资产类别共享许多相同的因子风险。分散化的好处可能被夸大,正如投资者在 2008 年危机期间发现的那样,当风险资产类别之间的相关性增加时,由于暴露于相同的基础因子风险,相关性增加。

第七章线性模型-从风险因素到收益预测中,我们将展示如何测量投资组合对各种风险因素的暴露,以便您可以调整头寸以调整您的因子敞口,或者相应地进行对冲。

分层风险平价

均值-方差优化对预期收益的估计和这些收益的协方差非常敏感。当收益高度相关时,协方差矩阵的求逆也变得更具挑战性和不太准确,这在实践中经常是情况。结果被称为马科维茨诅咒:当分散化更为重要因为投资相关时,传统的投资组合优化器可能会产生不稳定的解决方案。分散化的好处可能会被错误的估计抵消。正如讨论的那样,甚至是天真的、等权重的投资组合都可以在样本外击败均值-方差和基于风险的优化。

更健壮的方法已经合并了额外的约束 (Clarke et al., 2002) 或贝叶斯先验 (Black and Litterman, 1992),或者使用收缩估计量使精度矩阵更加数值稳定 (Ledoit and Wolf, 2003),这在 scikit-learn 中可用 (scikit-learn.org/stable/modules/generated/sklearn.covariance.LedoitWolf.html)。

分层风险平价 (HRP) 相比之下,利用无监督机器学习来实现出色的样本外投资组合分配。 最近在投资组合优化方面的创新利用图论和层次聚类来构建投资组合分为三个步骤 (Lopez de Prado, 2015):

  1. 定义一个距离度量,使相关资产彼此接近,并应用单链接聚类来识别层次关系。

  2. 利用层次相关结构对协方差矩阵进行准对角化。

  3. 应用自顶向下的逆方差加权使用递归二分搜索将集群资产视为投资组合构建中的补充而不是替代品,并减少自由度数量。

构建分层聚类投资组合 (HCP) 的一种相关方法由 Raffinot (2016) 提出。 从概念上讲,诸如金融市场之类的复杂系统倾向于具有结构,并且通常以分层方式组织,而层次结构中元素之间的相互作用塑造了系统的动态。 相关矩阵也缺乏层次结构的概念,这使得权重可以自由变化并且可能以意想不到的方式变化。

JP Morgan (2012) 对各种股票投资组合进行了 HRP 和 HCP 的测试。 其中,HRP 尤其产生了与天真分散、最大分散投资组合或 GMV 投资组合相比相等或更优的风险调整后回报和夏普比率。

我们将在第十三章使用无监督学习的数据驱动风险因素和资产配置中介绍 Python 实现。

使用 Zipline 进行交易和管理投资组合

在上一章中,我们介绍了 Zipline 来模拟对一系列股票的市场、基本和替代数据的 alpha 因子的计算。 在本节中,我们将开始根据 alpha 因子发出的信号行动起来。 我们将通过提交买入和卖出订单来做到这一点,以便我们可以建立多头和空头头寸或者重新平衡投资组合,以将我们的持仓调整到最新的交易信号。

我们将推迟优化投资组合权重至本章后面,并且,目前,只是为每个持仓分配相同价值的仓位。 正如前一章中所述,对包括 ML 模型的策略进行测试和评估的深入介绍将在第六章机器学习流程中进行。

安排信号生成和交易执行

我们将使用前一章开发的自定义MeanReversion因子(请参见01_backtest_with_trades.ipynb中的实现)。

compute_factors()方法创建的Pipeline返回一个包含 50 个多头和空头的列的表格。它根据其上个月回报率与年均值的最大负偏差和最大正偏差选择股票,标准差归一化:

def compute_factors():
    """Create factor pipeline incl. mean reversion,
        filtered by 30d Dollar Volume; capture factor ranks"""
    mean_reversion = MeanReversion()
    dollar_volume = AverageDollarVolume(window_length=30)
    return Pipeline(columns={'longs'  : mean_reversion.bottom(N_LONGS),
                             'shorts' : mean_reversion.top(N_SHORTS),
                             'ranking': mean_reversion.rank(ascending=False)},
                    screen=dollar_volume.top(VOL_SCREEN)) 

它还将宇宙限制为过去 30 个交易日交易量最高的 1,000 只股票。before_trading_start()确保每日执行Pipeline和记录结果,包括当前价格:

def before_trading_start(context, data):
    """Run factor pipeline"""
    context.factor_data = pipeline_output('factor_pipeline')
    record(factor_data=context.factor_data.ranking)
    assets = context.factor_data.index
    record(prices=data.current(assets, 'price')) 

新的rebalance()方法向由Pipeline标记为多头和空头头寸的资产提交交易订单,权重相等并且为正负值。它还剥离任何当前持仓,这些持仓不再包含在因子信号中:

def exec_trades(data, assets, target_percent):
    """Place orders for assets using target portfolio percentage"""
    for asset in assets:
        if data.can_trade(asset) and not get_open_orders(asset):
            order_target_percent(asset, target_percent)
def rebalance(context, data):
    """Compute long, short and obsolete holdings; place trade orders"""
    factor_data = context.factor_data
    assets = factor_data.index
    longs = assets[factor_data.longs]
    shorts = assets[factor_data.shorts]
    divest = context.portfolio.positions.keys() - longs.union(shorts)
    exec_trades(data, assets=divest, target_percent=0)
    exec_trades(data, assets=longs, target_percent=1 / N_LONGS if N_LONGS 
                else 0)
    exec_trades(data, assets=shorts, target_percent=-1 / N_SHORTS if N_SHORTS 
                else 0) 

rebalance()方法根据schedule_function()工具在周初开始,紧接着market_open运行,正如内置的US_EQUITIES日历所规定的那样(有关规则的详细信息,请参阅 Zipline 文档)。

您还可以指定交易佣金,既以相对比例,又以最小金额。还有一种定义滑点的选项,即交易决策与执行之间价格不利变化的成本:

def initialize(context):
    """Setup: register pipeline, schedule rebalancing,
        and set trading params"""
    attach_pipeline(compute_factors(), 'factor_pipeline')
    schedule_function(rebalance,
                      date_rules.week_start(),
                      time_rules.market_open(),
                      calendar=calendars.US_EQUITIES)
    set_commission(us_equities=commission.PerShare(cost=0.00075, 
                                                   min_trade_cost=.01))
    set_slippage(us_equities=slippage.VolumeShareSlippage(volume_limit=0.0025, price_impact=0.01)) 

调用run_algorithm()函数后,算法继续执行,并返回我们在前一章中看到的相同的回测性能DataFrame

实施均值方差投资组合优化

我们在上一节中演示了如何使用scipy.optimize找到有效前沿。在本节中,我们将利用 PyPortfolioOpt 库,该库提供投资组合优化(使用 SciPy 作为内部支持),包括有效前沿技术和更近期的收缩方法,用于正则化协方差矩阵(请参见第七章线性模型 - 从风险因素到收益预测,关于线性回归的收缩)。代码示例位于02_backtest_with_pf_optimization.ipynb中。

我们将使用从MeanReversion因子排名派生的 50 个多头和空头头寸相同的设置。rebalance()函数接收建议的多头和空头头寸,并将每个子集传递给新的optimize_weights()函数以获得asset: target_percent对的字典:

def rebalance(context, data):
    """Compute long, short and obsolete holdings; place orders"""
    factor_data = context.factor_data
    assets = factor_data.index
    longs = assets[factor_data.longs]
    shorts = assets[factor_data.shorts]
    divest = context.portfolio.positions.keys() - longs.union(shorts)
    exec_trades(data, positions={asset: 0 for asset in divest})
    # get price history
    prices = data.history(assets, fields='price',
                          bar_count=252+1, # 1 yr of returns 
                          frequency='1d')
    if len(longs) > 0:
        long_weights = optimize_weights(prices.loc[:, longs])
        exec_trades(data, positions=long_weights)
    if len(shorts) > 0:
        short_weights = optimize_weights(prices.loc[:, shorts], short=True)
        exec_trades(data, positions=short_weights) 

optimize_weights()函数使用PyPortfolioOpt提供的EfficientFrontier对象,找到基于最近一年的收益和协方差矩阵的最大夏普比率的权重,库也计算这两个值:

def optimize_weights(prices, short=False):
    returns = expected_returns.mean_historical_return(prices=prices, 
                                                      frequency=252)
    cov = risk_models.sample_cov(prices=prices, frequency=252)
    # get weights that maximize the Sharpe ratio
    ef = EfficientFrontier(expected_returns=returns, 
                           cov_matrix=cov, 
                           weight_bounds=(0, 1), 
                           gamma=0)

    weights = ef.max_sharpe()
    if short:
        return {asset: -weight for asset, weight in ef.clean_weights().items()}
    else:
        return ef.clean_weights() 

它返回归一化的权重,总和为 1,对于空头头寸设置为负值。

图 5.3 表明,对于这个特定的策略和时间范围,均值方差优化的投资组合表现明显更好:

图 5.3: 均值方差 vs 等权重投资组合表现

PyPortfolioOpt 还可以找到最小波动率的投资组合。更普遍地说,这个例子说明了如何使用前一节介绍的方法,或者您选择的任何其他方法,添加逻辑来调整投资组合权重。

我们现在将转向投资组合回报和风险的常见衡量标准,以及如何使用 pyfolio 库来计算它们。

使用 pyfolio 测量回测性能

Pyfolio 便利地分析了投资组合性能,在和样本外都使用了丰富的指标和可视化。它生成了覆盖回报、仓位和交易分析的泪水表,以及在市场压力期间使用几种内置场景进行事件风险分析。它还包括贝叶斯样本外性能分析。

Pyfolio 依赖于投资组合回报和仓位数据,还可以考虑交易活动的交易成本和滑点损失。它使用 empyrical 库,该库也可以独立用于计算性能指标。

创建回报和基准输入

该库是 Quantopian 生态系统的一部分,并与 Zipline 和 Alphalens 兼容。我们首先将演示如何从 Alphalens 生成所需的输入,然后展示如何从 Zipline 回测性能的 DataFrame 中提取它们。本节的代码示例在笔记本 03_pyfolio_demo.ipynb 中。

从 Alphalens 获取 pyfolio 输入

Pyfolio 还直接集成了 Alphalens,并允许使用 create_pyfolio_input 创建 pyfolio 输入数据:

from alphalens.performance import create_pyfolio_input
qmin, qmax = factor_data.factor_quantile.min(), 
             factor_data.factor_quantile.max()
input_data = create_pyfolio_input(alphalens_data,
                                  period='1D',
                                  capital=100000,
                                  long_short=False,
                                  equal_weight=False,
                                  quantiles=[1, 5],
                                  benchmark_period='1D')
returns, positions, benchmark = input_data 

有两个选项可以指定如何生成投资组合权重:

  • long_short: 如果为 False,权重将对应于因子值除以它们的绝对值,以便负因子值生成空头头寸。如果为 True,则因子值首先被减去,以便多头和空头头寸相互抵消,投资组合是市场中性的。

  • equal_weight: 如果True并且 long_shortTrue,资产将分成两个等大小的组,顶部/底部一半组成多头/空头头寸。

如果factor_data 包括每个资产的部门信息,还可以为组创建多头-空头投资组合,例如。

从 Zipline 回测获取 pyfolio 输入

还可以使用 extract_rets_pos_txn_from_zipline 将 Zipline 回测的结果转换为所需的 pyfolio 输入:

returns, positions, transactions = 
         extract_rets_pos_txn_from_zipline(backtest) 

步行前测试 - 样本外回报

测试交易策略涉及回测和前瞻性测试。前者涉及历史数据,并且通常指的是用于微调 alpha 因子参数的样本期间。前瞻性测试在新市场数据上模拟策略,以验证其在样本外的表现,并且不会过于贴近特定的历史情况。

Pyfolio 允许指定一个样本外期间来模拟前瞻性测试。测试策略以获得统计上可靠的结果时,有许多方面需要考虑。我们将在第八章 ML4T 工作流程 – 从模型到策略回测 中更详细地讨论这一点。

函数 plot_rolling_returns 显示样本内和样本外累积回报与用户定义的基准(我们使用标准普尔 500)的对比。Pyfolio 将累积回报计算为每个回报加 1 后的简单回报的乘积:

from pyfolio.plotting import plot_rolling_returns
plot_rolling_returns(returns=returns,
                     factor_returns=benchmark_rets,
                     live_start_date='2016-01-01',
                     cone_std=(1.0, 1.5, 2.0)) 

图 5.4 中的图表包括一个锥体,显示扩展的置信区间,以指示在随机行走假设下,样本外回报何时似乎不太可能。在这里,我们的玩具策略在模拟的 2016 年样本外期间表现不佳,相对于标准普尔 500 指数:

图 5.4:Pyfolio 累积表现图

摘要性能统计

Pyfolio 提供了几个分析函数和图表。perf_stats 摘要显示了年度和累积回报、波动率、偏度和峰度以及 SR。

以下附加指标(也可以单独计算)最为重要:

  • 最大回撤:相对于前一峰值的最大百分比损失

  • 卡尔玛比率:年度组合回报与最大回撤的相对值

  • 欧米伽比率:对于回报目标的基于概率加权的增益与损失比率,默认为零

  • 索蒂诺比率:相对于下行标准差的超额回报

  • 尾部比率:右尾部的大小(增益,第 95 百分位的绝对值)相对于左尾部的大小(损失,第 5 百分位的绝对值)

  • 每日风险价值(VaR):对应于每日均值下两个标准偏差的损失

  • 阿尔法:未被基准回报解释的投资组合回报

  • 贝塔:与基准的暴露

函数 plot_perf_stats 通过自助法估计参数变异性,并将结果显示为箱线图:

图 5.5:Pyfolio 表现统计图

函数 show_perf_stats 计算整个期间以及样本内和样本外期间的多个指标:

from pyfolio.timeseries import show_perf_stats
show_perf_stats(returns=returns, 
                factor_returns=benchmark_rets, 
                positions=positions, 
                transactions=transactions, 
                live_start_date=oos_date) 

对于从 MeanReversion 因子派生的模拟多空投资组合,我们得到以下表现统计:

指标 全部 样本内 样本外
年度回报 2.80% 2.10% 4.70%
累积回报 11.60% 6.60% 4.70%
年度波动率 8.50% 8.80% 7.60%
夏普比率 0.37 0.29 0.64
卡尔马比率 0.21 0.16 0.57
稳定性 0.26 0.01 0.67
最大回撤 -13.10% -13.10% -8.30%
欧米伽比率 1.07 1.06 1.11
索提诺比率 0.54 0.42 0.96
偏度 0.33 0.35 0.25
峰度 7.2 8.04 2
尾部比率 1.04 1.06 1.01
日风险价值 -1.10% -1.10% -0.90%
总杠杆 0.69 0.68 0.72
日换手率 8.10% 8.00% 8.40%
Alpha 0 -0.01 0.03
Beta 0.25 0.27 0.17

请参阅附录以获取有关投资组合风险和回报度量的计算和解释详细信息。

跌幅期和因素暴露

函数plot_drawdown_periods(returns)绘制投资组合的主要跌幅期,并且还有几个绘图函数显示滚动的 SR 和滚动因子暴露于市场 beta 或 Fama-French 尺寸、增长和动量因子:

fig, ax = plt.subplots(nrows=2, ncols=2, figsize=(16, 10))
axes = ax.flatten()
plot_drawdown_periods(returns=returns, ax=axes[0])
plot_rolling_beta(returns=returns, factor_returns=benchmark_rets, 
                  ax=axes[1])
plot_drawdown_underwater(returns=returns, ax=axes[2])
plot_rolling_sharpe(returns=returns) 

图 5.6 中的图表突出显示了各种撕裂表中包含的可视化子集,说明了 pyfolio 如何让我们深入了解绩效特征并让我们接触到风险和回报的基本驱动因素:

图 5.6:随时间变化的各种 pyfolio 绘图

建模事件风险

Pyfolio 还包括各种事件的时间表,您可以使用它来比较投资组合在此期间与基准的表现。 Pyfolio 默认使用标准普尔 500 指数,但您也可以提供您选择的基准回报。以下示例将绩效与 2015 年秋季的标准普尔 500 指数相比,该指数跌至英国脱欧后的情况:

interesting_times = extract_interesting_date_ranges(returns=returns)
interesting_times['Fall2015'].to_frame('pf') \
 .join(benchmark_rets) \
 .add(1).cumprod().sub(1) \
 .plot(lw=2, figsize=(14, 6), title='Post-Brexit Turmoil') 

图 5.7显示了结果图:

图 5.7:Pyfolio 事件风险分析

摘要

在本章中,我们涵盖了投资组合管理的重要主题,这涉及将投资头寸组合起来以管理风险和回报的权衡目标。我们介绍了 pyfolio 来计算和可视化关键的风险和回报指标,并比较各种算法的性能。

我们看到准确的预测对于优化投资组合权重和最大化多样化效益至关重要。我们还探讨了机器学习如何通过从资产收益协方差矩阵中学习分层关系,促进更有效的投资组合构建。

现在我们将转向本书的第二部分,重点介绍机器学习模型的使用。这些模型将通过更有效地利用更多样化的信息来产生更准确的预测。他们这样做是为了捕捉比迄今为止最突出的简单的 alpha 因子更复杂的模式。

我们将首先通过交叉验证来训练、测试和调整用于回归和分类的线性模型,以实现强健的样本外表现。我们还将把这些模型嵌入到定义和回测算法交易策略的框架中,这是我们在前两章中介绍过的。

第六章:机器学习过程

本章开始了本书的第二部分,我们将演示如何使用各种监督和无监督的机器学习模型进行交易。在展示相关 Python 库的各种应用之前,我们将解释每个模型的假设和用例。在第 2-4 部分我们将涵盖的模型类别包括:

  • 用于截面、时间序列和面板数据回归和分类的线性模型

  • 广义加性模型,包括非线性基于树的模型,如决策树

  • 集成模型,包括随机森林和梯度提升机

  • 用于降维和聚类的无监督线性和非线性方法

  • 神经网络模型,包括循环和卷积架构

  • 强化学习模型

我们将这些模型应用于本书第一部分介绍的市场、基本和替代数据源。我们将通过演示如何将这些模型嵌入到将模型信号转换为交易的交易策略中,如何优化投资组合以及如何评估策略绩效来扩展到目前为止所涵盖的材料。

这些模型及其应用中有许多共同点。本章涵盖了这些共同点,以便我们可以专注于以下章节中的特定模型用法。它们包括从数据中学习功能关系的总体目标,通过优化目标或损失函数。它们还包括测量模型性能的相关方法。

我们将区分无监督学习和监督学习,并概述算法交易的用例。我们将对比监督回归和分类问题,并使用监督学习进行输入和输出数据之间关系的统计推断,以及用于未来输出预测的使用。

我们还将说明预测误差是由于模型的偏差或方差,或者是由于数据中高噪声信号比引起的。最重要的是,我们将提出诊断错误来源(如过度拟合)并改善模型性能的方法。

在本章中,我们将涵盖以下与在实践中应用 ML 工作流相关的主题:

  • 从数据中学习的监督和无监督学习的工作原理

  • 训练和评估用于回归和分类任务的监督学习模型

  • 偏差-方差权衡如何影响预测性能

  • 如何诊断和解决由于过度拟合而导致的预测误差

  • 使用交叉验证来优化超参数,重点关注时间序列数据

  • 在测试样本外时,为什么金融数据需要额外注意

如果您已经对机器学习非常熟悉,可以跳过并直接开始学习如何使用机器学习模型为算法交易策略生成和组合阿尔法因子。本章的 GitHub 存储库中包含代码示例和额外资源的目录。

机器学习是如何从数据中进行的

许多机器学习的定义都围绕着对数据中有意义的模式的自动检测。两个显著的例子包括:

  • 人工智能先驱阿瑟·塞缪尔森在 1959 年将机器学习定义为计算机科学的一个子领域,使计算机能够在没有明确编程的情况下学习。

  • 汤姆·米切尔,这个领域的现任领导者之一,更加具体地确定了一个明确定义的学习问题,1998 年:一台计算机程序通过与任务和性能度量相关的经验来学习,以确定任务的性能是否随着经验的积累而提高(Mitchell 1997)。

经验以训练数据的形式呈现给算法。与以往构建解决问题的机器的尝试的主要区别在于,算法用于做出决策的规则是从数据中学习的,而不是像上世纪 80 年代突出的专家系统那样由人类编程的。

推荐的涵盖各种算法和通用应用的教科书包括 James 等人(2013),Hastie、Tibshirani 和 Friedman(2009),Bishop(2006)和 Mitchell(1997)。

挑战——将算法与任务匹配

自动学习的关键挑战是识别训练数据中的模式,这些模式在将模型的学习推广到新数据时具有意义。模型能够识别的潜在模式的数量很大,而训练数据只构成了算法在未来执行任务时可能遇到的更大现象集合的样本。

无限数量的函数可能会从给定输入生成观察到的输出,这使得搜索真实函数的过程成为不可能,除非限制符合条件的候选集。算法能够学习的模式类型受其包含可能表示的函数的假设空间大小以及样本数据提供的信息量的限制。

假设空间的大小在各种算法之间变化很大,我们将在接下来的章节中看到。一方面,这种限制使得成功搜索成为可能,另一方面,它意味着一种归纳偏差,可能导致算法从训练样本泛化到新数据时性能不佳。

因此,关键挑战在于如何选择一个具有足够大的假设空间的模型,以包含对学习问题的解决方案,同时又足够小,以确保给定训练数据的可靠学习和概括性。有了更多信息的数据,具有更大假设空间的模型成功的机会更大。

无免费午餐定理表明没有通用的学习算法。相反,学习者的假设空间必须根据任务领域的先验知识进行定制,以便搜索出能够成功概括的有意义模式(Gómez and Rojas 2015)。

我们将在本章中密切关注模型对特定任务的数据关系所作的假设,并强调将这些假设与从数据探索中获得的经验证据相匹配的重要性。

机器学习任务有几个类别,其目的、可用信息以及因此学习过程本身有所不同。主要类别包括监督、无监督和强化学习,接下来我们将审查它们的关键区别。

监督学习——通过示例教学

监督学习是最常用的 ML 类型。我们将在本书的大部分章节中致力于这一类别的应用。术语监督意味着存在一个结果变量来引导学习过程——也就是说,它教会了算法对手头任务的正确解决方案。监督学习旨在从反映这种关系的个体样本中捕获功能输入-输出关系,并通过对新数据做出有效的陈述来应用其学习。

根据领域的不同,输出变量也可以互换地称为标签、目标或结果,以及内生或左侧变量。我们将使用y[i]表示结果观测值i=1,...,N,或者y表示(列)向量的结果。一些任务具有多个结果,并称为多标签问题

监督学习问题的输入数据也称为特征,以及外生或右侧变量。我们使用x[i]表示观测值i=1,...,N的特征向量,或者在矩阵表示中表示为X,其中每列包含一个特征,每行包含一个观测值。

监督学习问题的解决方案是一个函数 ,表示模型从样本中学到的输入-输出关系,并逼近真实关系,表示为 。这个函数可以潜在地用于推断变量之间的统计关联或甚至因果关系,超出样本范围,或者可以用于预测新输入数据的输出。

从数据中学习输入-输出关系以便对新输入进行准确预测的任务面临重要的权衡。更复杂的模型具有更多的可移动部分,能够表示更微妙的关系。然而,它们也更有可能学习到特定于训练样本的随机噪声,而不是代表一般模式的系统信号。当这种情况发生时,我们称模型对训练数据过度拟合。此外,复杂模型可能也更难以检查,这使得理解学到的关系的性质或特定预测的驱动因素变得更加困难。

另一方面,过于简单的模型将错过复杂的信号并提供有偏见的结果。这种权衡在监督学习中被称为偏差-方差权衡,但从概念上讲,这也适用于其他形式的机器学习,即太简单或太复杂的模型可能在训练数据之外表现不佳。

无监督学习-发现有用的模式

当解决无监督学习问题时,我们只观察特征,并没有对结果进行测量。 无监督算法的目标不是预测未来结果或推断变量之间的关系,而是旨在识别输入中的结构,从而允许对数据中包含的信息进行新的表示。

经常,成功的衡量标准是结果对解决其他问题的贡献。这包括识别观察之间的共同点或群集,或者转换特征以获得捕获相关信息的压缩摘要。

关键挑战在于无监督算法必须在没有结果信息提供的情况下完成任务。因此,我们通常无法像在监督情况下那样将结果与基本事实进行评估,其质量可能视人而异。但是,有时,我们可以评估其对下游任务的贡献,例如降维使得更好的预测成为可能。

有许多方法,从成熟的聚类算法到前沿的深度学习模型,以及几个与我们目的相关的使用案例。

使用案例-从风险管理到文本处理

在后续章节中,我们将涵盖许多无监督学习的交易使用案例:

  • 将具有相似风险和回报特征的证券分组(请参阅第十三章使用无监督学习的数据驱动风险因子和资产配置中的分层风险均等化

  • 使用主成分分析第十三章使用无监督学习的数据驱动风险因子和资产配置)或自编码器第十九章用于多变量时间序列和情感分析的 RNN)找出驱动大量证券表现的少量风险因子。

  • 识别文档集合(例如,收入电话会议记录)中包含的最重要方面的潜在主题(第十四章用于交易的文本数据 - 情感分析

在高层次上,这些应用依赖于识别聚类的方法和降低数据维度的方法。

聚类算法 - 寻找相似观察

聚类算法应用相似性概念来识别包含可比较信息的观察或数据属性。它们通过将大量数据点分配给较少数量的聚类来总结数据集。它们这样做是为了使聚类成员彼此之间的关系比与其他聚类成员的关系更密切。

聚类算法在假设关于各种分组是如何生成以及是什么使它们相似方面有所不同。因此,它们倾向于产生不同类型的聚类,因此应根据数据的特性选择。一些著名的例子包括:

  • K 均值聚类:数据点属于等大小的k个椭圆形簇之一。

  • 高斯混合模型:数据点由各种多元正态分布之一生成。

  • 基于密度的聚类:聚类可以是任意形状的,并且仅由附近的最小数量的数据点的存在定义。

  • 分层聚类:数据点属于由逐渐合并较小簇形成的各种超集。

降维 - 压缩信息

降维生成包含源数据中最重要信息的新数据。这些算法不是将数据分组到保留原始数据的簇中,而是以使用更少特征来表示原始信息的目标来转换数据。

算法在数据转换方式及因此产生的压缩数据集的性质上有所不同,如下列表所示:

  • 主成分分析(PCA):找到线性转换,捕获现有数据集中大部分方差

  • 流形学习:识别出产生数据的低维表示的非线性变换

  • 自动编码器:使用神经网络对数据进行非线性压缩,最小化信息损失。

在以下几章中,我们将更深入地研究这些无监督学习模型,包括对自然语言处理NLP)的重要应用,例如主题建模和 Word2vec 特征提取。

强化学习 - 通过试错学习

强化学习RL)是 ML 的第三种类型。它以代理为中心,代理需要在每个时间步选择一个动作,这是基于环境提供的信息。代理可以是自动驾驶汽车、玩棋盘游戏或视频游戏的程序,或者在某个安全市场上运行的交易策略。您可以在Sutton 和 Barto(2018)中找到一篇优秀的介绍。

代理的目标是选择随着时间推移产生最高回报的行动,这是基于一组描述环境当前状态的观察而进行的。它既是动态的又是交互式的:积极和消极奖励的流动影响着算法的学习,而现在采取的行动可能会影响环境和未来的奖励。

代理需要从一开始就采取行动,并以“在线”方式学习,随着时间的推移,一次一个样本。学习过程采用试错方法。这是因为代理需要在利用过去产生某种奖励的行动和探索可能增加未来奖励的新行动之间进行权衡。RL 算法使用动态系统理论以及具有不完整信息的马尔可夫决策过程的最优控制来优化代理的学习。

RL 与监督学习不同,监督学习的训练数据为算法提供了上下文和正确决策。它专门针对交互式设置,其中结果只在一段时间后变得可用,并且学习必须随着代理获取新经验而持续进行。

人工智能AI)中一些最显著的进展涉及强化学习(RL),它使用深度学习来逼近动作、环境和未来奖励之间的功能关系。它与无监督学习不同,因为尽管有延迟,但会提供对动作的反馈。

RL 特别适用于算法交易,因为在不确定的、动态的环境中,追求回报的代理模型与与金融市场互动的投资者或交易策略有很多共同之处。我们将在第二十一章中介绍 RL 方法来构建算法交易策略,生成对抗网络用于合成时间序列数据

机器学习工作流程

为了最大程度地提高成功的机会并节约资源,为算法交易策略开发 ML 解决方案需要系统的方法。使过程透明且可复制非常重要,以促进协作、维护和后续的改进。

以下图表概述了从问题定义到部署预测解决方案的关键步骤:

图 6.1:机器学习工作流程的关键步骤

整个过程都是迭代的,并且各个阶段所需的工作量会根据项目而变化。然而,通常情况下,这个过程应该包括以下步骤:

  1. 确定问题,确定目标指标,并定义成功。

  2. 数据源,清理和验证数据。

  3. 了解你的数据并生成信息性特征。

  4. 为你的数据选择一个或多个适合的机器学习算法。

  5. 训练,测试和调整你的模型。

  6. 使用你的模型来解决原始问题。

我们将在以下部分逐步完成这些步骤,使用一个简单的示例来说明一些关键点。

基本步骤 - 最近邻算法

本书 GitHub 仓库中本章文件夹中的 machine_learning_workflow.ipynb 笔记本包含了几个示例,用于演示使用房价数据集的机器学习工作流程。

我们将使用相当直观的 最近邻 (KNN) 算法,它允许我们解决回归和分类问题。在其默认的 scikit-learn 实现中,它识别出最近的 k 个数据点(基于欧氏距离)来进行预测。在分类或回归情况下,它分别预测邻居中的最频繁类别或平均结果。

这一章的 GitHub 上的 README 链接到了额外的资源;查看 Bhatia 和 Vandana (2010) 进行简要调查。

框架问题 - 从目标到度量

任何机器学习项目的起点是它最终旨在解决的用例。有时,这个目标将是统计推断,以识别变量之间的关联或甚至因果关系。然而,最常见的情况是,目标是预测结果以产生交易信号。

推断和预测任务都依赖于度量指标来评估模型实现其目标的程度。由于它们在实践中的显著性,我们将重点关注常见的目标函数以及用于预测模型的相应误差度量。

我们通过输出的性质来区分预测任务:连续输出变量构成了一个 回归 问题,一个分类变量意味着 分类,而有序分类变量的特殊情况代表了一个 排名 问题。

通常可以以不同的方式对给定的问题进行框架化。手头的任务可能是如何有效地结合几个 alpha 因子。你可以将这个任务框架化为一个回归问题,旨在预测回报,一个二元分类问题,旨在预测未来价格走势的方向,或一个多类问题,旨在将股票分配到各种表现类别,比如回报五分位数。

在下一节中,我们将介绍这些目标,并看看如何测量和解释相关的错误指标。

预测与推断

监督学习算法产生的功能关系可用于推断,即了解结果生成的方式。或者,您可以用它来预测未知输入的输出。

对于算法交易,我们可以使用推断来估计资产回报与风险因子的统计关联。这意味着,例如,评估此观察是否可能由噪声引起,而不是风险因子的实际影响。反过来,预测可以用来预测风险因子,这可以帮助预测资产回报和价格,并转化为交易信号。

统计推断是关于从样本数据中得出关于潜在概率分布或总体参数的结论。可能的结论包括关于个体变量分布特征的假设检验,或者关于变量之间的数值关系的存在或强度的假设检验。它们还包括指标的点估计或区间估计。

推断取决于关于生成数据的过程的假设。我们将回顾这些假设以及在线性模型中用于推断的工具,在那里它们已经得到了很好的确认。更复杂的模型对输入和输出之间的结构关系做出更少的假设。相反,它们以较少的限制处理函数逼近任务,同时将数据生成过程视为黑匣子。

这些模型,包括决策树、集成模型和神经网络,因其在预测任务上的表现通常优于其他模型而备受青睐。然而,我们将看到,近年来已经有大量努力增加复杂模型的透明度。例如,随机森林最近获得了一个用于统计推断的框架(Wager 和 Athey 2019)。

因果推断 - 相关性不意味着因果关系

因果推断旨在识别输入值导致特定输出的关系 - 例如,一定的宏观变量组合导致给定资产价格以某种方式变动,同时假设所有其他变量保持不变。

关于两个或更多变量之间的关系的统计推断会产生相关性的度量。只有在满足其他几个条件时,相关性才能被解释为因果关系,例如当排除了替代解释或逆向因果关系时。

满足这些条件需要一个实验设置,在这个设置中,所有感兴趣的变量可以完全控制,以隔离因果关系。或者,准实验设置以随机方式将观察单位暴露于输入变化中,以排除其他可观察或不可观察的特征对环境变化观察效果的影响。

这些条件很少被满足,因此推断性结论需要谨慎处理。对于依赖于特征和输出之间的统计关联的预测模型的性能也是如此,这种关联可能会随着不是模型一部分的其他因素的变化而变化。

KNN 模型的非参数性质不利于推断,因此我们将推迟工作流程中的此步骤,直到我们在第七章线性模型 - 从风险因素到回报预测中遇到线性模型。

回归 - 流行的损失函数和误差度量

回归问题旨在预测连续变量。均方根误差RMSE)是最受欢迎的损失函数和误差度量,其中一个原因是它是可微的。损失是对称的,但更大的错误在计算中占更多的权重。使用平方根的优点是我们可以用目标变量的单位来衡量误差。

误差的对数的均方根RMSLE)适用于目标受指数增长的情况。其不对称的惩罚使得负误差比正误差的权重小。您也可以在训练模型之前对目标进行对数转换,然后使用 RMSE,就像我们将在本节后面的示例中所做的那样。

绝对误差的平均值MAE)和绝对误差的中位数MedAE)是对称的,但不会给较大的错误更多的权重。MedAE 对异常值具有鲁棒性。

解释方差得分计算模型解释的目标方差比例,介于 0 和 1 之间。R2 得分也称为决定系数,如果残差的均值为 0,则产生相同的结果,否则可能会有所不同。特别是在对样本外数据进行计算时(或者对没有截距的线性回归进行计算时),它可能为负。

下表定义了用于计算的公式以及可以从度量模块导入的相应的 scikit-learn 函数。scoring参数与自动化的训练-测试函数(如cross_val_scoreGridSearchCV)结合使用,我们稍后将在本节中介绍,并在附带的笔记本中进行说明:

名称 公式 scikit-learn 函数 评分参数
均方误差 mean_squared_error neg_mean_squared_error
平均平方对数误差 mean_squared_log_error neg_mean_squared_log_error
平均绝对误差 mean_absolute_error neg_mean_absolute_error
中位绝对误差 median_absolute_error neg_median_absolute_error
解释方差 explained_variance_score explained_variance
R²得分 r2_score r2

图 6.2 展示了我们将在笔记本中计算的房价回归的各种错误度量:

图 6.2:样本内回归误差

sklearn 函数还支持多标签评估——即,将多个结果值分配给单个观测值;有关更多详细信息,请参阅 GitHub 上引用的文档。

分类 – 理解混淆矩阵的含义

分类问题具有分类结果变量。大多数预测器将输出一个分数,以指示观察是否属于某一类别。在第二步中,这些分数然后被转换为实际预测,使用一个阈值值。

在二元情况下,具有正类标签和负类标签,得分通常在零和一之间变化,或者相应地进行归一化。一旦将得分转换为对其中一类的预测,就会产生四种结果,因为两个类中的每一个都可以被正确或错误地预测。如果区分几种潜在的错误,那么在两个以上的类别中可能会有更多的情况。

所有错误度量都是从 2×2 混淆矩阵的四个字段的预测分解中计算的,该矩阵将实际类别和预测类别相关联。

下表中列出的度量,如准确性,评估了给定阈值下的模型:

图 6.3:混淆矩阵和相关的错误度量

分类器通常不会输出校准的概率。相反,用于区分正负情况的阈值本身是一个决策变量,应该进行优化,考虑到正确和错误预测的成本和效益。

一切相等的情况下,较低的阈值往往意味着更多的正预测,可能会导致假阳性率上升,而对于较高的阈值,相反的可能是真的。

接收器操作特性曲线下的面积

接收器操作特性 (ROC) 曲线允许我们根据它们的性能可视化、比较和选择分类器。它计算了使用所有预测分数作为阈值产生类预测的真正阳性率 (TPR) 和假阳性率 (FPR) 的对。它在一个边长为单位的正方形内可视化这些对。

随机预测(根据类别不平衡加权),平均而言,产生相等的 TPR 和 FPR,这些都出现在对角线上,这成为基准案例。由于性能不佳的分类器会从重新标记预测中受益,因此该基准也成为最小值。

曲线下面积(AUC)被定义为 ROC 图下的面积,其值在 0.5 和最大值 1 之间变化。它是分类器分数能够根据其类别成员资格对数据点进行排名的摘要度量。更具体地说,分类器的 AUC 具有重要的统计属性,表示分类器将随机选择的正实例排在随机选择的负实例之上的概率,这相当于 Wilcoxon 排名检验(Fawcett 2006)。此外,AUC 具有不对类别不平衡敏感的好处。

精确率-召回率曲线 - 放大一个类

当对其中一个类的预测特别感兴趣时,精确率和召回率曲线可视化了这些误差指标在不同阈值下的权衡。这两个指标评估了特定类别的预测质量。以下列表显示了它们如何应用于正类别:

  • 召回率(Recall)衡量分类器预测为正例的实际正类成员所占比例,针对给定的阈值。它源自信息检索,并且衡量了搜索算法成功识别的相关文档的比例。

  • 精确率(Precision),相反地,衡量了正确的正预测所占的比例。

召回率通常随着较低的阈值而增加,但精确率可能会降低。精确率-召回率曲线可视化了可达到的组合,并允许根据错过大量相关情况或产生质量较低的预测的成本和收益来优化阈值。

F1 分数是给定阈值下精确率和召回率的调和平均数,并且可以用于数值优化阈值,同时考虑到这两个指标应该承担的相对权重。

图 6.4 展示了 ROC 曲线及相应的 AUC,以及精确率-召回率曲线和 F1 分数,使用精确率和召回率的相等权重,得出了 0.37 的最佳阈值。该图表摘自附带的笔记本,您可以在其中找到针对二值化房价操作的 KNN 分类器的代码:

图 6.4:接收器操作特征(ROC)、精确率-召回率曲线和 F1 分数图

收集和准备数据

我们已经在第二章市场和基础数据 - 来源和技术第三章金融替代数据 - 类别和用例中讨论了如何获取市场、基础和替代数据的重要方面。我们将继续使用这些来源的各种示例,以展示各种模型的应用。

除了市场和基本数据外,我们还将获取和转换文本数据,当我们探索自然语言处理时,获取图像数据并进行图像处理和识别时。除了获取、清洗和验证数据外,我们还可能需要分配标签,如新闻文章的情感或时间戳,以使其与通常以时间序列格式可用的交易数据对齐。

将其存储在能够快速探索和迭代的格式中也很重要。我们推荐 HDF 和 parquet 格式(参见第二章市场和基本数据-来源和技术)。对于不适合内存且需要在多台机器上进行分布式处理的数据,Apache Spark 通常是交互式分析和机器学习的最佳解决方案。

探索、提取和工程化特征

理解单个变量的分布以及结果和特征之间的关系是选择适当算法的基础。这通常从诸如散点图的可视化开始,如附带的笔记本中所示并在图 6.5中显示:

图 6.5:结果和特征的成对散点图

它还包括从线性指标如相关性到非线性统计量如我们在第四章中介绍的信息系数时遇到的 Spearman 等级相关系数的数值评估。还有信息论量度,如互信息,我们将在下一小节中进行说明。

系统性的探索性分析也是成功预测模型中往往最重要的一个组成部分的基础:特征工程,它提取了数据中包含的信息,但这些信息在原始形式下不一定对算法可见。特征工程受益于领域专业知识、统计学和信息论的应用,以及创造力。

它依赖于智能数据转换,可以有效地揭示输入和输出数据之间的系统关系。有许多选择,包括异常值检测和处理、功能转换以及多个变量的组合,包括无监督学习。我们将在整个过程中举例说明,但会强调这个 ML 工作流程的核心方面最好通过经验来学习。Kaggle 是一个与社区分享经验的其他数据科学家的好地方。

使用信息论评估特征

特征和结果之间的互信息MI)是两个变量之间相互依赖的一种度量。它将相关性的概念扩展到非线性关系。更具体地说,它量化了通过另一个随机变量获得的有关某一随机变量的信息。

互信息(MI)的概念与随机变量的熵的基本概念密切相关。熵量化了随机变量中包含的信息量。形式上,两个随机变量 XY 的互信息——I(X, Y)——定义如下:

sklearn 函数实现了feature_selection.mutual_info_regression,它计算所有特征和连续结果之间的互信息,以选择最有可能包含预测信息的特征。还有一个分类版本(有关更多详情,请参阅 sklearn 文档)。mutual_information.ipynb笔记本包含了我们在第四章金融特征工程—如何研究 Alpha 因子中创建的财务数据的应用。

选择一个 ML 算法

本书的其余部分将介绍几个模型家族,从对输入和输出变量之间的功能关系的本质作出相当强的假设的线性模型,到几乎没有假设的深度神经网络。正如在介绍部分中所提到的,更少的假设将需要更多关于关系的重要信息,以便学习过程能够成功进行。

在介绍这些模型时,我们将概述关键假设及其如何进行检验。

设计和调整模型

ML 过程包括诊断和管理模型复杂性的步骤,基于对模型的泛化误差的估计。ML 过程的一个重要目标是使用统计上可靠且高效的程序获取对此错误的无偏估计。管理模型设计和调整过程的关键是理解偏差-方差权衡与欠拟合和过拟合之间的关系。

偏差-方差权衡

ML 模型的预测误差可以分解为可减少和不可减少的部分。不可减少部分是由于数据中的随机变动(噪声)引起的,例如,由于缺乏相关变量、自然变动或测量误差。广义泛化误差的可减少部分又可分解为由偏差方差引起的误差。

这两者都是由于真实功能关系与机器学习算法所做假设之间的差异而产生的,详细列在以下清单中:

  • 偏差导致的误差:假设过于简单,无法捕捉真实功能关系的复杂性。因此,每当模型试图学习真实功能时,它都会出现系统性错误,平均而言,预测也将同样存在偏差。这也被称为欠拟合

  • 方差导致的错误:该算法在真实关系方面过于复杂。它不是捕捉真实关系,而是对数据过拟合,并从噪声中提取模式。因此,它从每个样本中学习到不同的功能关系,并且样本外的预测会变化很大。

欠拟合与过拟合 – 一个可视化例子

图 6.6 通过逐渐复杂的多项式逼近 正弦 函数来说明过拟合。更具体地说,我们抽取一个带有一些噪声的随机样本(n = 30)来学习不同复杂度的多项式(请参见笔记本中的代码 bias_variance.ipynb)。该模型预测新的数据点,我们捕获这些预测的均方误差。

图 6.6 的左侧面板显示了一次多项式;一条直线明显地欠拟合了真实函数。然而,估计线不会在从真实函数绘制的一个样本到下一个样本之间有明显差异。

中间面板显示了一个 5 次多项式在大约从 的区间上合理地逼近真实关系。另一方面,15 次多项式几乎完美地拟合了小样本,但提供了真实关系的不良估计:它对样本数据点的随机变化进行了过拟合,学到的函数将随样本的函数强烈变化:

图 6.6:多项式过拟合的可视化示例

如何处理偏差-方差折衷

为了进一步说明过拟合与欠拟合的影响,我们将尝试学习带有一定噪声的 正弦 函数的第九次泰勒级数逼近。图 6.7 显示了对真实函数的 100 个随机样本进行了欠拟合、过拟合和提供大约正确的灵活性的多项式的样本内外误差和样本外预测,分别为 1、15 和 9 次多项式。

左侧面板显示了从预测值中减去真实函数值产生的误差的分布。拟合度低但方差小的一次拟合多项式与拟合度高但方差极高的 15 次过拟合多项式的误差相比。欠拟合多项式产生一条直线,其内部拟合较差,样本外明显偏离目标。过拟合模型在样本内显示出最佳拟合,误差最小,但代价是样本外的大方差。与真实模型功能形式相匹配的适当模型在样本外数据上平均表现最佳。

图 6.7 的右侧面板显示实际预测而不是错误,以可视化实践中的不同拟合类型:

图 6.7:不同次数多项式的误差和样本外预测

学习曲线

学习曲线绘制了用于学习函数关系的数据集大小演变对训练和测试错误的影响。它有助于诊断给定模型的偏差-方差权衡,并回答增加样本量是否可能提高预测性能的问题。具有高偏差的模型将在样本内和样本外都具有高但相似的训练误差。过拟合模型将具有非常低的训练但较高的测试误差。

图 6.8显示了过拟合模型的样本外误差随着样本大小增加而下降,表明它可能受益于额外的数据或限制模型复杂性的工具,例如正则化。正则化向模型的复杂性添加了数据驱动的约束;我们将在第七章线性模型 - 从风险因素到回报预测中介绍这个技术。

相反,拟合不足的模型需要更多特征或需要增加其容量以捕获真实关系:

图 6.8:学习曲线和偏差-方差权衡

如何使用交叉验证选择模型

通常有几个适用于您用例的候选模型,选择其中一个的任务称为模型选择问题。目标是识别在给定新数据时产生最低预测误差的模型。

一个好的选择需要对这种泛化误差进行无偏估计,而这又需要在不参与模型训练的数据上对模型进行测试。否则,模型将已经能够窥视“解决方案”,并提前了解有关预测任务的信息,这将夸大其性能。

为了避免这种情况,我们仅使用部分可用数据来训练模型,并将另一部分数据保留以验证其性能。模型在新数据上的预测误差的估计结果只有在绝对没有关于验证集的信息泄漏到训练集时才会无偏,如图 6.9所示:

图 6.9:训练集和测试集

交叉验证CV)是一种常用的模型选择策略。CV 背后的主要思想是将数据分割一次或多次。这样做是为了每次分割都被用作一次验证集,而剩余部分被用作训练集:一部分数据(训练样本)用于训练算法,剩余部分(验证样本)用于估计算法的预测性能。然后,CV 选择具有最小估计误差或风险的算法。

可以使用几种方法来拆分可用数据。它们在使用用于训练的数据量、误差估计的方差、计算强度以及在拆分数据时是否考虑数据的结构性方面有所不同,例如维持类标签之间的比率。

虽然数据拆分启发式方法非常通用,但 CV 的一个关键假设是数据是独立同分布的IID)。在接下来的部分和本书中的整个过程中,我们将强调时间序列数据需要不同的方法,因为它通常不符合这一假设。此外,我们需要确保拆分尊重时间顺序,以避免前瞻性偏差。我们将通过将一些信息从我们旨在预测的未来纳入历史训练集中来实现这一点。

模型选择通常涉及超参数调整,这可能导致许多 CV 迭代。表现最佳模型的结果验证分数将受到多重检验偏差的影响,它反映了 CV 过程中固有的抽样噪声。因此,它不再是泛化误差的良好估计。为了得到无偏的误差率估计,我们必须从新的数据集中估计分数。

因此,我们使用了数据的三分拆分,如 图 6.10 所示:其中一部分用于交叉验证,并被重复拆分为训练集和验证集。其余部分被保留为保留集,仅在交叉验证完成后使用一次,以生成一个无偏的测试误差估计。

我们将在下一章开始构建 ML 模型时说明这种方法:

图 6.10:训练、验证和保留测试集

如何在 Python 中实现交叉验证

我们将说明将数据拆分为训练集和测试集的各种选项。我们将通过展示如何将具有 10 个观测值的模拟数据集的索引分配给训练集和测试集(有关详细信息,请参见 cross_validation.py),如下代码所示:

data = list(range(1, 11))
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 

Scikit-learn 的 CV 功能,我们将在本节中演示,可以从 sklearn.model_selection 导入。

对于将数据拆分为训练集和测试集的单次拆分,请使用 train_test_split,其中 shuffle 参数默认确保观察结果的随机选择。您可以通过设置 random_state 来对随机数生成器进行种子化以确保可复制性。还有一个 stratify 参数,它确保对于分类问题,训练集和测试集将包含大约相同比例的每个类别。结果如下所示:

train_test_split(data, train_size=.8)
[[8, 7, 4, 10, 1, 3, 5, 2], [6, 9]] 

在这种情况下,我们使用除行号69之外的所有数据来训练模型,这些行号将用于生成预测并根据已知标签测量错误。该方法对于快速评估很有用,但对分割敏感,并且性能度量估计的标准误差会更高。

KFold 迭代器

KFold迭代器生成多个不相交的分割,并将这些分割中的每一个一次分配给验证集,如下所示的代码所示:

kf = KFold(n_splits=5)
for train, validate in kf.split(data):
    print(train, validate)
[2 3 4 5 6 7 8 9] [0 1]
[0 1 4 5 6 7 8 9] [2 3]
[0 1 2 3 6 7 8 9] [4 5]
[0 1 2 3 4 5 8 9] [6 7]
[0 1 2 3 4 5 6 7] [8 9] 

除了分割的数量外,大多数 CV 对象都接受一个shuffle参数,以确保随机化。为了使结果可重现,请按以下方式设置random_state

kf = KFold(n_splits=5, shuffle=True, random_state=42)
for train, validate in kf.split(data):
    print(train, validate)
[0 2 3 4 5 6 7 9] [1 8]
[1 2 3 4 6 7 8 9] [0 5]
[0 1 3 4 5 6 8 9] [2 7]
[0 1 2 3 5 6 7 8] [4 9]
[0 1 2 4 5 7 8 9] [3 6] 

留一出交叉验证

原始 CV 实现使用一种留一法,将每个观测值一次用作验证集,如下所示的代码所示:

loo = LeaveOneOut()
for train, validate in loo.split(data):
    print(train, validate)
[1 2 3 4 5 6 7 8 9] [0]
[0 2 3 4 5 6 7 8 9] [1]
...
[0 1 2 3 4 5 6 7 9] [8]
[0 1 2 3 4 5 6 7 8] [9] 

这最大化了训练的模型数量,增加了计算成本。虽然验证集不重叠,但是训练集的重叠被最大化,推动模型和其预测误差的相关性增加。因此,具有更多折叠的模型的预测误差的方差更高。

留 P 个出交叉验证

与留一法 CV 类似的版本是留 P 个出 CV,它生成p数据行的所有可能组合,如下所示的代码所示:

lpo = LeavePOut(p=2)
for train, validate in lpo.split(data):
    print(train, validate)
[2 3 4 5 6 7 8 9] [0 1]
[1 3 4 5 6 7 8 9] [0 2]
...
[0 1 2 3 4 5 6 8] [7 9]
[0 1 2 3 4 5 6 7] [8 9] 

ShuffleSplit

ShuffleSplit类创建具有潜在重叠验证集的独立分割,如下所示的代码所示:

ss = ShuffleSplit(n_splits=3, test_size=2, random_state=42)
for train, validate in ss.split(data):
    print(train, validate)
[4 9 1 6 7 3 0 5] [2 8]
[1 2 9 8 0 6 7 4] [3 5]
[8 4 5 1 0 6 9 7] [2 3] 

跨金融领域的交叉验证挑战

到目前为止讨论的交叉验证方法的一个关键假设是训练样本的 IID 分布。

对于金融数据,情况通常并非如此。相反,由于串行相关性和时间变化的标准差,金融数据既不是独立分布的,也不是相同分布的,这也称为异方差性(有关更多详细信息,请参见第七章线性模型 - 从风险因素到回报预测第九章用于波动率预测和统计套利的时间序列模型)。sklearn.model_selection模块中的TimeSeriesSplit旨在处理时间序列数据的线性顺序。

使用 scikit-learn 进行时间序列交叉验证

数据的时间序列性意味着交叉验证产生了一种情况,即未来的数据将用于预测过去的数据。这充其量是不现实的,最糟糕的情况是数据偷窥,因为未来的数据反映了过去的事件。

为了解决时间依赖性,TimeSeriesSplit对象实现了一种前向测试,其中扩展训练集,后续训练集是过去训练集的超集,如下所示的代码所示:

tscv = TimeSeriesSplit(n_splits=5)
for train, validate in tscv.split(data):
    print(train, validate)
[0 1 2 3 4] [5]
[0 1 2 3 4 5] [6]
[0 1 2 3 4 5 6] [7]
[0 1 2 3 4 5 6 7] [8]
[0 1 2 3 4 5 6 7 8] [9] 

您可以使用max_train_size参数来实现滚动交叉验证,其中训练集的大小随时间保持恒定,类似于 Zipline 测试交易算法。Scikit-learn 可以使用子类化设计自定义交叉验证方法,我们将在以下章节中实现这些方法。

净化、禁运和组合 CV

对于金融数据,标签通常是从重叠的数据点派生的,因为收益是从多个时期的价格计算出来的。在交易策略的背景下,模型的预测结果,可能意味着对某种资产采取头寸,只有在稍后评估此决策时才能知道,例如,当头寸被平仓时。

风险包括信息从测试到训练集的泄漏,这很可能会人为地增加性能。我们需要通过确保所有数据都是时间点数据来解决这一风险,即在用作模型输入时真正可用且已知。例如,财务披露可能涉及某个时间段,但仅在稍后才可用。如果我们过早包含这些信息,我们的模型可能在事后表现得比在实际情况下好得多。

Marcos Lopez de Prado,这个领域的领先实践者和学者之一,在他的著作《金融机器学习进展(2018)》中提出了几种方法来解决这些挑战。调整交叉验证以适应金融数据和交易背景的技术包括:

  • 净化:消除训练数据点,其中评估发生在验证集中的时间点数据预测之后,以避免前瞻性偏差。

  • 禁运:进一步消除跟随测试期的训练样本。

  • 组合交叉验证:滚动交叉验证严重限制了可以测试的历史路径。相反,给定T个观测值,为N<T个组计算所有可能的训练/测试拆分,每个组都保持其顺序,并净化和禁运可能重叠的组。然后,在所有N-k组的组合上训练模型,同时在其余k组上测试模型。结果是可能的历史路径数量要大得多。

Prado 的金融机器学习进展中包含了实施这些方法的示例代码;该代码也可通过新的 Python 库 timeseriescv 获得。

使用 scikit-learn 和 Yellowbrick 进行参数调整

模型选择通常涉及重复交叉验证不同算法(如线性回归和随机森林)或不同配置的模型的样本外表现。不同的配置可能涉及超参数的更改或不同变量的包含或排除。

Yellowbrick 库扩展了 scikit-learn API 以生成诊断可视化工具,以便促进模型选择过程。这些工具可用于调查特征之间的关系,分析分类或回归错误,监视聚类算法性能,检查文本数据的特征,并帮助模型选择。我们将展示提供有价值信息的验证和学习曲线,在参数调整阶段使用 - 有关实施细节,请参阅machine_learning_workflow.ipynb笔记本。

验证曲线 - 绘制超参数的影响

验证曲线(见图 6.11中的左侧面板)可视化单个超参数对模型交叉验证性能的影响。这有助于确定模型是否对给定数据集欠拟合或过拟合。

在我们的KNeighborsRegressor示例中,只有一个超参数,即邻居的数量k。请注意,随着邻居数量的减少,模型的复杂度增加,因为模型现在可以为特征空间中更多不同的区域进行预测。

我们可以看到,在k大于 20 时,模型出现欠拟合现象。随着我们减少邻居数量并使我们的模型更复杂,验证误差下降。对于小于 20 的值,随着训练和验证误差的分歧和平均样本外表现的迅速恶化,模型开始过拟合:

图 6.11:验证和学习曲线

学习曲线 - 诊断偏差-方差权衡

学习曲线(见我们房价回归示例中图 6.11的右侧面板)有助于确定模型的交叉验证性能是否会受益于额外数据,并且预测误差更多是由偏差还是方差驱动。

如果训练和交叉验证分数收敛,那么增加数据很可能不会改善性能。在这一点上,重要的是评估模型性能是否符合人类基准的预期。如果不是这样,则应修改模型的超参数设置以更好地捕捉特征和结果之间的关系,或选择具有更高复杂度以捕捉复杂性的不同算法。

此外,由阴影置信区间显示的训练和测试误差的变化提供了有关预测误差的偏差和方差来源的线索。交叉验证误差的变异是方差的证据,而训练集的变异性则暗示了偏差,具体取决于训练误差的大小。

在我们的示例中,交叉验证性能持续下降,但增量改进已经减少,误差已经趋于稳定,因此增加训练集的好处不大。另一方面,与训练误差相比,数据显示了相当大的变化。

使用 GridSearchCV 和 pipeline 进行参数调整

由于超参数调整是机器学习工作流程的关键组成部分,因此有工具可以自动化此过程。Scikit-learn 库包括一个GridSearchCV接口,它并行地交叉验证所有参数组合,捕获结果,并在完整数据集上自动使用在交叉验证中表现最佳的参数设置训练模型。

在实践中,训练和验证集通常需要一些处理才能进行交叉验证。Scikit-learn 提供了Pipeline,可以在使用GridSearchCV时自动执行任何特征处理步骤。

您可以查看包含的machine_learning_workflow.ipynb笔记本中的实现示例,以了解这些工具的实际操作。

总结

在本章中,我们介绍了从数据中学习的挑战,并将监督学习、无监督学习和强化学习模型作为我们将在本书中研究的主要学习形式,以构建算法交易策略。我们讨论了监督学习算法需要对它们试图学习的功能关系进行假设的必要性。它们这样做是为了限制搜索空间,同时产生可能导致过度泛化误差的归纳偏差。

我们介绍了机器学习工作流程的关键方面,介绍了回归和分类模型的最常见错误度量标准,解释了偏差-方差权衡,并说明了使用交叉验证管理模型选择过程的各种工具。

在下一章中,我们将深入研究用于回归和分类的线性模型,以开发我们的第一个使用机器学习的算法交易策略。

第七章:线性模型 - 从风险因素到回报预测

线性模型家族代表了最有用的假设类之一。许多在算法交易中广泛应用的学习算法依赖于线性预测器,因为它们可以被有效地训练,相对而言对嘈杂的金融数据具有较强的鲁棒性,并且与金融理论有着紧密的联系。线性预测器也直观易懂,容易解释,并且通常能很好地拟合数据,或者至少提供一个良好的基准。

线性回归已经被人们所知超过 200 年,自从 Legendre 和 Gauss 将其应用于天文学并开始分析其统计属性以来。自那时起,许多扩展都已适应了线性回归模型和基线普通最小二乘法OLS)方法来学习其参数:

  • 广义线性模型GLM)通过允许响应变量表示除正态分布以外的误差分布来扩展应用范围。 GLM 包括针对分类问题中出现的分类响应变量的 probit 或 logistic 模型。

  • 更多稳健的估计方法使得在数据违反基线假设的情况下进行统计推断成为可能,例如,随时间或观察之间的相关性。这在包含对同一单位进行重复观察的面板数据中经常发生,例如,一组资产的历史回报。

  • 收缩方法旨在改善线性模型的预测性能。它们使用复杂性惩罚来偏置模型学习的系数,目的是减少模型的方差并提高样本外的预测性能。

在实践中,线性模型被应用于回归和分类问题,其目标是推断和预测。学术界和工业界的研究人员已经开发了许多利用线性回归的资产定价模型。应用包括确定驱动资产回报的重要因素,以实现更好的风险和绩效管理,以及在各种时间范围内预测回报。另一方面,分类问题包括方向性价格预测。

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

  • 线性回归是如何工作的以及它做出了什么样的假设

  • 训练和诊断线性回归模型

  • 使用线性回归预测股票回报

  • 使用正则化来提高预测性能

  • 逻辑回归的工作原理

  • 将回归问题转化为分类问题

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和额外资源的链接。笔记本包括彩色版本的图像。

从推断到预测

正如其名称所示,线性回归模型假设输出是输入的线性组合的结果。该模型还假设存在随机误差,使得每个观测值都可以偏离预期的线性关系。导致模型不能完美地以确定的方式描述输入和输出之间关系的原因包括,例如,缺少变量、测量或数据收集问题。

如果我们想要基于从样本估计的回归参数对人口中的真实(但未观察到的)线性关系进行统计结论,我们需要增加关于这些误差的统计性质的假设。基线回归模型做出了一个强大的假设,即误差的分布在观察中是相同的。它还假设错误相互独立——换句话说,知道一个错误不会帮助预测下一个错误。独立同分布(IID)误差的假设意味着它们的协方差矩阵是身份矩阵乘以代表误差方差的常数。

这些假设保证了 OLS 方法提供的估计不仅是无偏的,而且是有效的,这意味着 OLS 估计在所有线性学习算法中获得最低的抽样误差。然而,这些假设在实践中很少得到满足。

在金融领域,我们经常遇到具有给定横截面上重复观测的面板数据。尝试估计资产组合对一组风险因素随时间的系统性暴露通常会在时间轴上显示相关性,在横截面维度上也会如此。因此,出现了一些替代学习算法,假设误差协方差矩阵比身份矩阵的倍数更复杂。

另一方面,学习线性模型的有偏参数的方法可能会产生方差较低的估计值,从而提高其预测性能。收缩方法通过应用正则化来降低模型的复杂性,这会向线性目标函数添加惩罚项。

这个惩罚项与系数的绝对大小正相关,因此相对于基线情况,它们会被收缩。较大的系数意味着更复杂的模型,它对输入变化的反应更强烈。当正确校准时,惩罚可以限制模型系数的增长,使其不超过从偏差-方差角度看到的最优值。

首先,我们将介绍交叉和面板数据的线性模型的基线技术,以及在关键假设被违反时产生准确估计的重要增强方法。然后,我们将通过估计在算法交易策略开发中普遍存在的因子模型来说明这些方法。最后,我们将转向收缩方法如何应用正则化,并演示如何使用它们来预测资产回报并生成交易信号。

基线模型 - 多重线性回归

我们将从模型的规范和目标函数开始,以及我们可以用来学习其参数的方法,以及允许推断和诊断这些假设的统计假设。然后,我们将介绍扩展内容,用于适应违反这些假设的情况。用于额外背景的有用参考资料包括Wooldridge20022008)。

如何制定模型

多重回归模型定义了一个连续结果变量和p个输入变量之间的线性功能关系,这些输入变量可以是任何类型,但可能需要预处理。相比之下,多元回归是指多个输出变量对多个输入变量的回归。

在总体中,线性回归模型对于输出y的单个实例,输入向量,和误差项具有以下形式:

系数的解释很简单:系数的值是变量x[i]对输出的部分平均效应,保持所有其他变量恒定。

我们还可以以矩阵形式更紧凑地写出模型。在这种情况下,y是一个由N个输出观测值组成的向量,X是设计矩阵,有N行观测值和p个变量的列,另外还有一列用于截距,是包含P = p+1 个系数的向量:

该模型在其p +1 个参数中是线性的,但如果我们选择或相应地转换变量,例如,通过包含多项式基函数扩展或对数项,它可以表示非线性关系。您还可以使用带有虚拟编码的分类变量,并通过创建形式为x[i]x[j]的新输入来包含变量之间的交互作用。

从统计学角度完成模型的制定,以便我们可以测试关于其参数的假设,我们需要对误差项进行具体的假设。在介绍了学习参数的最重要方法后,我们将这样做。

如何训练模型

有几种方法可以从数据中学习模型参数:普通最小二乘法OLS),最大似然估计MLE),和随机梯度下降SGD)。我们将依次介绍每种方法。

普通最小二乘法 - 如何将超平面拟合到数据

最小二乘法是学习最佳逼近输出与输入数据之间关系的超平面的原始方法。顾名思义,它采用最佳逼近以最小化模型表示的输出值与超平面之间的距离的平方和。

对于给定数据点,模型预测与实际结果之间的差异是残差(而真实模型与人口真实输出之间的偏差被称为误差)。因此,形式上,最小二乘估计方法选择系数向量以最小化残差平方和RSS):

因此,最小二乘系数 计算如下:

最小化 RSS 的最优参数向量结果是将上述表达式对 的导数设置为零。假设 X 具有满列秩,这要求输入变量不是线性相关的,因此可逆,我们得到一个唯一解,如下所示:

yX 的均值为零时,可以通过减去它们的各自均值来实现, 表示输入与输出的协方差之比 和输出方差

还有一种几何解释:最小化 RSS 的系数确保残差向量 垂直于由 XP 列张成的子空间,而估计值 是该子空间的正交投影。

最大似然估计

MLE 是用于估计统计模型参数的重要一般方法。它依赖于似然函数,该函数计算了在给定输入数据的情况下观察到输出样本的可能性,作为模型参数的函数。似然性不同于概率,因为它没有归一化为 0 到 1 的范围。

通过假设错误项的分布,如标准正态分布,我们可以为多元线性回归示例设置似然函数:

这使我们能够计算出在给定输入向量 x[i] 和参数 , 时观察到给定输出 y[i] 的条件概率:

假设输出值在给定输入的条件下是条件独立的,样本的似然性与各个输出数据点的条件概率的乘积成比例。由于处理总和比处理乘积更容易,我们对其应用对数以获得对数似然函数

MLE 的目标是选择最大化观察输出样本的概率的模型参数,假定输入是给定的。因此,MLE 参数估计的结果来自于最大化对数似然函数:

由于假设误差正态分布,最大化对数似然函数产生与最小二乘相同的参数解。这是因为唯一依赖于参数的表达式是指数中的平方残差。

对于其他分布假设和模型,MLE 将产生不同的结果,就像我们将在最后一节关于二元分类中看到的那样,其中结果遵循伯努利分布。此外,MLE 是一种更一般的估计方法,因为在许多情况下,最小二乘法是不适用的,正如我们稍后将看到的逻辑回归一样。

梯度下降

梯度下降是一种通用的优化算法,将找到平滑函数的驻点。如果目标函数是凸函数,则解将是全局最优解。梯度下降的变体广泛用于训练复杂的神经网络,还用于计算 MLE 问题的解决方案。

该算法使用目标函数的梯度。梯度包含了关于参数的偏导数。这些导数表示了在对应参数的方向上进行微小(无穷小)步骤时,目标的变化量。事实证明,函数值的最大变化来自于沿着梯度方向的步骤。

图 7.1描绘了对于单变量x和凸函数f(x)的过程,其中我们正在寻找最小值,x[0]。当函数具有负斜率时,梯度下降增加x[0]的目标值,否则减少值:

图 7.1:梯度下降

当我们最小化描述例如预测误差成本的函数时,算法使用训练数据计算当前参数值的梯度。然后,它根据其相应梯度分量的负值,按比例修改每个参数。结果,目标函数将取得较低的值,并将参数移动到解决方案附近。优化停止时,梯度变得很小,参数值变化很少。

这些步骤的大小由学习率确定,学习率是一个关键参数,可能需要调整。许多实现包括此学习率随迭代次数逐渐减小的选项。根据数据的大小,算法可能会多次迭代整个数据集。每个这样的迭代被称为一个epoch。你可以调整的其他超参数包括迭代的数量和用于停止进一步迭代的容差。

随机梯度下降(Stochastic gradient descent)随机选择一个数据点,并为该数据点计算梯度,而不是对较大样本进行平均以实现加速。还有一些批处理版本,每一步使用一定数量的数据点。

高斯-马尔可夫定理

要评估模型的统计特性并进行推断,我们需要对残差做出假设,这些残差代表了模型无法正确拟合或“解释”的输入数据的部分。

高斯-马尔可夫定理GMT)定义了 OLS 产生模型参数 无偏估计所需的假设,以及这些估计在横截面数据的所有线性模型中具有最低标准误差。

基线多元回归模型对以下 GMT 做出如下假设(Wooldridge 2008):

  • 在总体中,线性成立,因此 ,其中 未知但恒定, 是随机误差。

  • 输入变量 的数据是来自总体的随机样本。

  • 无完全共线性—输入变量之间不存在精确的线性关系。

  • 误差 在给定任何输入时的条件均值为零:

  • 同方差性(Homoskedasticity)—误差项 在给定输入的情况下具有恒定方差:

第四个假设暗示不存在与任何输入变量相关的缺失变量。

在前四个假设(GMT 1-4)下,OLS 方法提供无偏估计。包括一个无关变量不会使截距和斜率估计出现偏差,但省略一个相关变量将导致参数估计偏差。

在 GMT 1-4 下,OLS 也是一致的:随着样本大小的增加,估计值收敛于真实值,标准误差变得任意。反之亦然:如果误差的条件期望不为零,因为模型遗漏了一个相关变量或函数形式错误(例如,遗漏了二次项或对数项),那么所有参数估计都是有偏的。如果误差与任何输入变量相关,则 OLS 也不一致,增加更多数据也不会消除偏差。

如果我们加上第五个假设,那么 OLS 也会产生最佳线性无偏估计BLUE)。最佳意味着估计在所有线性估计器中具有最低的标准误差。因此,如果五个假设成立且目标是统计推断,则 OLS 估计是正确的选择。然而,如果目标是预测,那么我们将看到其他估计器存在,它们在许多情况下通过牺牲一些偏差以获得更低的方差来实现更优的预测性能。

现在我们已经介绍了基本的 OLS 假设,我们可以来看看小样本和大样本中的推断。

如何进行统计推断

在线性回归背景下的推断旨在从样本数据中推断出人口中的真实关系。这包括测试关于总体关系或特定系数值的假设,以及置信区间的估计。

统计推断的关键要素是具有已知分布的检验统计量,通常是从感兴趣的数量(如回归系数)计算得出的。我们可以针对该统计量制定一个关于零假设,并计算在假设正确的情况下,在样本中观察到该统计量的实际值的概率。这个概率通常被称为p 值:如果它降低到显著性阈值以下(通常为 5%),那么我们会拒绝假设,因为它使我们在样本中观察到的检验统计量的值非常不太可能。另一方面,p 值反映了我们在拒绝实际上是正确假设时可能出错的概率。

除了五个 GMT 假设之外,经典线性模型还假设正态性——即人口误差服从正态分布,并且与输入变量独立。这个强假设意味着在给定输入变量的情况下,输出变量服从正态分布。它允许推导出系数的精确分布,进而意味着在小样本中进行精确假设检验所需的测试统计量的精确分布。在实践中,这个假设通常是不成立的——例如,资产回报并不服从正态分布。

然而,幸运的是,即使在正态性不成立时,正态性下使用的检验统计量也近似有效。更具体地说,测试统计量的以下分布特征在 GMT 假设 1-5 下大致成立,在正态性成立时完全成立:

  • 参数估计值遵循多元正态分布:

  • 在 GMT 1-5 下,参数估计值是无偏的,并且我们可以使用 得到 的无偏估计,即常数误差方差。

  • 关于个别系数的假设检验的t 统计量 ,并且遵循具有 N-p-1 自由度的t分布,其中 的对角线的第 j 个元素。

  • t分布收敛于正态分布。由于正态分布的 97.5 分位数约为 1.96,因此95%置信区间的一个有用的经验法则是,其公式为,其中se表示标准误差。包含零的区间意味着我们无法拒绝真参数为零的零假设,因此对模型无关。

  • F 统计量允许对多个参数的限制进行测试,包括整个回归是否显著。它衡量了由额外变量导致的 RSS 的变化(减少)。

  • 最后,拉格朗日乘数LM)检验是测试多重约束的 F 检验的替代方法。

如何诊断和解决问题

诊断验证模型假设,并帮助我们在解释结果和进行统计推断时防止错误结论。它们包括拟合优度指标和有关误差项假设的各种测试,包括残差与正态分布的拟合程度

此外,诊断评估残差方差是否确实恒定或是否表现出异方差性(稍后在本节中介绍)。它们还测试错误是否条件不相关或是否表现出串行相关性,即,如果知道一个错误有助于预测连续的错误。

除了进行以下诊断测试之外,您还应该始终直观地检查残差。这有助于检测它们是否反映了系统模式,而不是表明模型缺少一个或多个驱动结果的因素的随机噪声。

拟合优度

拟合优度指标评估模型解释结果变异的能力。它们有助于评估模型规范的质量,例如在选择不同的模型设计时。

拟合优度指标在衡量拟合程度时存在差异。在这里,我们将重点关注样本内指标;当我们关注预测模型时,我们将使用样本外测试和交叉验证。

杰出的拟合优度指标包括(调整后的)R²,应最大化,并且基于最小二乘估计:

  • R²衡量模型解释结果数据变异的份额,并计算为,其中 TSS 是结果与其均值之间的平方偏差的和。它还对应于实际结果值与模型估计值之间的平方相关系数。隐含的目标是最大化 R²。但是,随着添加更多变量,它永远不会减少。因此,R²的一个缺点是它鼓励过度拟合。

  • 调整后的 R²对 R²进行惩罚以添加更多变量;每个额外变量都需要显著降低 RSS 才能产生更好的拟合优度。

或者,要最小化的是赤池信息准则AIC)和贝叶斯信息准则BIC),它们基于最大似然估计:

  • ,其中是最大化似然函数的值,k是参数的数量。

  • ,其中 N 是样本量。

这两种度量都对复杂性进行了惩罚。BIC 施加了更高的惩罚,因此相对于 AIC 可能欠拟合,反之亦然。

在概念上,AIC 旨在找到最佳描述未知数据生成过程的模型,而 BIC 试图在候选模型集合中找到最佳模型。在实践中,当目标是样本内拟合时,可以同时使用这两个标准来指导模型选择;否则,交叉验证和基于泛化误差估计的选择更可取。

异方差性

GMT 假设 5 要求残差协方差采用形状,即对角矩阵,其条目等于误差项的恒定方差。异方差性发生在残差方差不恒定,而是在观察之间不同的情况下。如果残差方差与输入变量呈正相关,即当误差较大时,与其均值相距较远的输入值,那么 OLS 标准误差估计将过低;因此,t 统计量将被膨胀,导致在实际上不存在关系的情况下发现了假阳性关系。

诊断从对残差的可视检查开始。在(假定为随机的)残差中出现系统性模式表明对错误是同方差的零假设进行统计检验,而不同的备择假设。这些检验包括 Breusch-Pagan 和 White 检验。

有几种方法可以校正 OLS 估计的异方差性:

  • 稳健标准误差(有时称为White 标准误差)在计算误差方差时考虑了异方差性,使用所谓的夹心估计量

  • 聚类标准误差假定数据中存在不同的组,这些组是同方差的,但是组间的误差方差不同。这些组可以是不同的资产类别或来自不同行业的股票。

有几种替代 OLS 的方法,它们使用不同的假设来估计误差协方差矩阵,当时。以下是statsmodels中可用的选项:

  • 加权最小二乘法(WLS):用于异方差误差,其中协方差矩阵仅具有对角项,与 OLS 相同,但现在允许这些项变化。

  • 可行广义最小二乘法(GLSAR):适用于自相关误差遵循自回归 AR(p) 过程(见 第九章波动率预测和统计套利的时间序列模型)的情况。

  • 广义最小二乘(GLS):适用于任意协方差矩阵结构;在异方差性或序列相关存在时产生高效且无偏的估计值。

序列相关

序列相关意味着线性回归产生的连续残差是相关的,这违反了第四个 GMT 假设。正序列相关意味着标准误差被低估,并且 t 统计量将被夸大,如果忽略这一点,可能导致错误发现。但是,在计算标准误差时有纠正序列相关的程序。

杜宾-沃森统计量用于诊断序列相关性。它检验普通最小二乘法残差不具有自相关性的假设,假设它们遵循自回归过程(我们将在下一章中探讨)。检验统计量的范围从 0 到 4;接近 2 的值表示非自相关性,较低的值暗示着正自相关,而较高的值则表示负自相关。确切的阈值取决于参数和观测值的数量,并且需要在表中查找。

多重共线性

多重共线性是指两个或更多个自变量高度相关时发生的情况。这带来了几个挑战:

  • 很难确定哪些因素影响因变量。

  • 单个 p 值可能会误导——即使变量实际上很重要,p 值也可能很高。

  • 回归系数的置信区间将会过宽,甚至可能包含零。这使得确定一个独立变量对结果的影响变得复杂。

没有正式的或基于理论的解决方案可以纠正多重共线性。相反,请尝试删除一个或多个相关的输入变量,或增加样本量。

在实践中如何运行线性回归

附带的笔记本linear_regression_intro.ipynb演示了简单线性回归和多元线性回归,后者同时使用 OLS 和梯度下降。对于多元回归,我们生成两个范围从-50 到+50 的随机输入变量x[1]和x[2],以及一个作为输入的线性组合加上随机高斯噪声的结果变量,以满足正态性假设 GMT 6:

使用 statsmodels 进行 OLS

我们使用statsmodels来估计一个准确反映数据生成过程的多元回归模型,如下所示:

import statsmodels.api as sm
X_ols = sm.add_constant(X)
model = sm.OLS(y, X_ols).fit()
model.summary() 

这产生了以下普通最小二乘回归结果摘要:

图 7.2:OLS 回归结果摘要

摘要的上半部分显示了数据集的特征——即估计方法和观测值和参数的数量,并指出标准误差估计不考虑异方差性。中间面板显示了系数值,这些值与人工数据生成过程密切相关。我们可以确认,在摘要结果中间显示的估计值可以使用先前导出的 OLS 公式获得:

beta = np.linalg.inv(X_ols.T.dot(X_ols)).dot(X_ols.T.dot(y))
pd.Series(beta, index=X_ols.columns)
const   53.29
X_1      0.99
X_2      2.96 

以下代码可视化了模型对随机生成数据点的拟合情况:

three_dee = plt.figure(figsize=(15, 5)).gca(projection='3d')
three_dee.scatter(data.X_1, data.X_2, data.Y, c='g')
data['y-hat'] = model.predict()
to_plot = data.set_index(['X_1', 'X_2']).unstack().loc[:, 'y-hat']
three_dee.plot_surface(X_1, X_2, to_plot.values, color='black', alpha=0.2, linewidth=1, antialiased=True)
for _, row in data.iterrows():
    plt.plot((row.X_1, row.X_1), (row.X_2, row.X_2), (row.Y, row['y-hat']),              'k-');
three_dee.set_xlabel('$X_1$');three_dee.set_ylabel('$X_2$');three_dee.set_zlabel('$Y, \hat{Y}$') 

图 7.3显示了结果超平面和原始数据点:

图 7.3:回归超平面

面板的右上部分显示了我们刚刚讨论过的拟合优度度量,以及 F 检验,它拒绝了所有系数为零且无关的假设。类似地,t 统计量表明截距和两个斜率系数显然是高度显著的。

摘要的底部包含了残差诊断。左侧面板显示了偏度和峰度,用于检验正态性假设。Omnibus 和 Jarque–Bera 测试都未能拒绝残差服从正态分布的原假设。Durbin–Watson 统计量测试残差的序列相关性,并且具有接近 2 的值,考虑到两个参数和 625 个观测值,未能拒绝有关此主题的前一节中概述的无序列相关假设。

最后,条件数提供了关于多重共线性的证据:它是包含输入数据的设计矩阵的最大特征值和最小特征值的平方根之比。30 以上的值表明回归可能存在显著的多重共线性。

statsmodels包含了与笔记本链接的额外诊断测试。

使用 sklearn 的随机梯度下降

sklearn 库在其linear_models模块中包括一个SGDRegressor模型。为了使用该方法学习相同模型的参数,我们需要对数据进行标准化,因为梯度对尺度很敏感。

我们使用StandardScaler()来实现这个目的:它在拟合步骤期间计算每个输入变量的平均值和标准差,然后在转换步骤期间减去平均值并除以标准差,我们可以方便地在单个fit_transform()命令中进行:

scaler = StandardScaler()
X_ = scaler.fit_transform(X) 

然后,我们使用默认值实例化SGDRegressor,除了设置一个random_state以便复制:

sgd = SGDRegressor(loss='squared_loss', 
                   fit_intercept=True,
                   shuffle=True, # shuffle data for better estimates
                   random_state=42,
                   learning_rate='invscaling', # reduce rate over time
                   eta0=0.01, # parameters for learning rate path
                   power_t=0.25) 

现在,我们可以拟合sgd模型,为 OLS 模型和sgd模型创建样本内预测,并计算每个模型的均方根误差:

sgd.fit(X=X_, y=y)
resids = pd.DataFrame({'sgd': y - sgd.predict(X_),
                      'ols': y - model.predict(sm.add_constant(X))})
resids.pow(2).sum().div(len(y)).pow(.5)
ols   48.22
sgd   48.22 

如预期的那样,两个模型产生相同的结果。我们现在将承担一个更有雄心的项目,使用线性回归来估计多因素资产定价模型。

如何构建线性因子模型

算法交易策略使用因素模型来量化资产回报与风险来源之间的关系,这些风险是这些回报的主要驱动因素。每个因素风险都带有溢价,而总资产回报预计将对应于这些风险溢价的加权平均。

因素模型在组合管理流程的各个方面都有几个实际应用,从构建和资产选择到风险管理和绩效评估。随着共同的风险因素现在可交易,因素模型的重要性不断增长:

  • 通过少数因素总结许多资产的回报,可减少在优化组合时估计协方差矩阵所需的数据量。

  • 对于资产或投资组合对这些因素的暴露的估计允许管理产生的风险,例如当风险因素本身可交易或可被替代时采取合适的对冲措施。

  • 因素模型还允许评估新α因素的增量信号内容。

  • 因素模型还有助于评估管理人员相对于基准的表现是否确实是由于熟练的资产选择和市场择时,还是表现可以解释为对已知回报驱动因素的组合倾向。今天,这些驱动因素可以通过低成本、被动管理基金来复制,而不需要支付主动管理费用。

以下示例适用于股票,但对于所有资产类别都已识别出风险因素(Ang 2014)。

从 CAPM 到法玛-法国因素模型

风险因素自资本资产定价模型CAPM)解释了所有 N 资产的预期回报 ,即它们各自对单一因素的暴露 与总体市场超过无风险利率的预期超额回报 ,成为量化模型的关键因素。CAPM 模型采取以下线性形式:

这与经典的基本面分析不同,如道德和格雷厄姆,其中回报取决于公司特征。理论基础是,总体上,投资者不能通过分散化消除所谓的系统风险。因此,在均衡状态下,他们要求持有资产的补偿与其系统风险相称。该模型暗示,鉴于市场高效,价格立即反映所有公开信息,不应该有优越的风险调整回报。换句话说, 的价值应为零。

模型的实证检验使用线性回归,一直持续失败,例如通过识别异常,即不依赖于整体市场暴露的优越风险调整回报,例如更小型公司的较高回报(Goyal 2012)。

这些失败引发了关于是市场有效还是联合假设的单一因素方面出了问题的热烈辩论。结果表明,这两个前提都可能是错误的:

  • 约瑟夫·斯蒂格利茨部分因表明市场通常不是完全有效的而获得了 2001 年诺贝尔经济学奖:如果市场是有效的,那么收集数据就没有价值,因为这些信息已经反映在价格中。然而,如果没有动力来收集信息,很难看到它如何已经反映在价格中。

  • 另一方面,对 CAPM 的理论和实证改进表明,额外因素有助于解释先前提到的一些异常情况,这导致了各种多因素模型。

斯蒂芬·罗斯于 1976 年提出了套利定价理论APT)作为另一种选择,它允许多个风险因素,同时避开市场效率。与 CAPM 相反,它假设由于错定价而获得优越回报的机会可能存在,但会很快被套利掉。该理论不指定因素,但研究表明最重要的因素可能是通胀和工业生产的变化,以及风险溢价或利率期限结构的变化。

肯尼斯·弗伦奇和尤金·法玛(2013 年诺贝尔奖得主)确定了依赖于公司特征并且今天被广泛使用的额外风险因素。 1993 年,法玛-法国三因子模型将相对规模和公司价值添加到了单一 CAPM 风险来源中。2015 年,五因子模型进一步扩展了集合,包括公司盈利能力和投资水平,这些在介入的几年中被证明是显著的。此外,许多因子模型包括价格动量因子。

法玛-法国风险因素是根据反映特定风险因素的度量标准计算出高值或低值多元化投资组合的回报差异。通过根据这些度量标准对股票进行排序,然后做多某个百分位以上的股票,同时做空某个百分位以下的股票来获得这些回报。与风险因素相关的度量标准定义如下:

  • 规模市值ME

  • 价值股本账面价值BE)除以 ME

  • 经营盈利能力(OP):收入减去销售成本/资产

  • 投资:投资/资产

还有无监督学习技术用于基于数据发现风险因素,该技术使用因素和主成分分析。我们将在第十三章《基于数据的风险因素和无监督学习的资产配置》中探讨这一点。

获取风险因素

法玛和法伦奇通过他们的网站提供了更新的风险因素和研究投资组合数据,您可以使用 pandas_datareader 库来获取数据。对于这个应用程序,请参考 fama_macbeth.ipynb 笔记本以获取以下代码示例和额外细节。

具体来说,我们将使用法玛-法伦奇因子中的五个因子,这些因子是根据股票排序而得出的,首先分为三个大小组,然后分为两个,每个剩余的三个特定公司因子。因此,这些因子涉及三组以大小和账面市值排序的加权组合,大小和经营盈利能力排序,以及大小和投资排序。计算的风险因素值作为投资组合PF)的平均回报在下表中概述:

概念 标签 名称 风险因素计算
规模 SMB 小减大 九个小股票 PF 减去九个大股票 PF。
价值 HML 高减低 两个价值 PF 减去两个成长(具有低 BE/ME 价值)PF。
盈利能力 RMW 强大减弱小 两个强大的 OP PF 减去两个弱小的 OP PF。
投资 CMA 保守减激进 两个保守投资组合减去两个激进投资组合。
市场 Rm-Rf 市场的超额回报 所有在美国主要交易所上市并上市的公司的加权回报减去一个月期的国债利率。

我们将使用 2010 年至 2017 年的时期获得的每月频率的回报,如下所示:

import pandas_datareader.data as web
ff_factor = 'F-F_Research_Data_5_Factors_2x3'
ff_factor_data = web.DataReader(ff_factor, 'famafrench', start='2010', 
                               end='2017-12')[0]
ff_factor_data.info()
PeriodIndex: 96 entries, 2010-01 to 2017-12
Freq: M
Data columns (total 6 columns):
Mkt-RF 96 non-null float64
SMB    96 non-null float64
HML    96 non-null float64
RMW    96 non-null float64
CMA    96 non-null float64
RF     96 non-null float64 

法玛和法伦奇还提供了许多投资组合,我们可以用来说明因子暴露度的估计,以及市场上某个特定时期可用的风险溢价的价值。我们将使用每月频率的 17 个行业投资组合的面板。我们会从回报中减去无风险利率,因为因子模型使用超额回报:

ff_portfolio = '17_Industry_Portfolios'
ff_portfolio_data = web.DataReader(ff_portfolio, 'famafrench', start='2010', 
                                  end='2017-12')[0]
ff_portfolio_data = ff_portfolio_data.sub(ff_factor_data.RF, axis=0)
ff_factor_data = ff_factor_data.drop('RF', axis=1)
ff_portfolio_data.info()
PeriodIndex: 96 entries, 2010-01 to 2017-12
Freq: M
Data columns (total 17 columns):
Food     96 non-null float64
Mines    96 non-null float64
Oil      96 non-null float64
...
Rtail    96 non-null float64
Finan    96 non-null float64
Other    96 non-null float64 

现在,我们将基于这些面板数据构建一个线性因子模型,使用一种方法来解决一些基本线性回归假设的失败。

法玛-麦克贝斯回归

鉴于风险因素和投资组合回报的数据,估计投资组合对这些回报的暴露度是有用的,以了解它们对投资组合回报的推动程度。了解市场为暴露于给定因素的回报支付的溢价也很有趣,即,承担这种风险有多值得。风险溢价然后允许估计任何投资组合的回报,只要我们知道或可以假设其因子暴露。

更正式地说,我们将有 i=1, ..., N 个资产或投资组合在 t=1, ..., T 个时期的回报,每个资产的超额时期回报将被标记。目标是测试 j=1, ..., M 个因素是否解释了超额回报以及与每个因素相关的风险溢价。在我们的情况下,我们有 N=17 个投资组合和 M=5 个因素,每个因素有 96 个时期的数据。

对于给定期间的许多股票估计因子模型。在这种交叉断面回归中可能会出现推断问题,因为古典线性回归的基本假设可能不成立。可能的违规行为包括测量误差,由于异方差性和序列相关性导致的残差协变,以及多重共线性(法马和麦克贝斯,1973 年)。

为了解决残差相关性引起的推断问题,法马和麦克贝斯提出了一种跨时间回归的两阶段方法,该方法针对回报因子进行了交叉断面回归。两阶段法马-麦克贝斯回归旨在估计市场对特定风险因素暴露的奖励溢价。这两个阶段包括:

  • 第一阶段:N时间序列回归,每个资产或组合一个,将其超额回报回归到因子上以估计因子加载。以矩阵形式,对于每个资产:

  • 第二阶段:T交叉断面回归,每个时间段一个,以估计风险溢价。以矩阵形式,我们获得了每个时期的风险溢价向量:

现在,我们可以计算因子风险溢价的时间平均值,并获得一个 t 统计量来评估它们的个别显着性,假设风险溢价估计在时间上是独立的:

如果我们有一个非常大而具代表性的交易风险因素的数据样本,我们可以使用样本均值作为风险溢价的估计。但是,我们通常没有足够长的历史数据,并且样本均值周围的误差边际可能非常大。法马-麦克贝斯方法利用因子与其他资产的协方差来确定因子溢价。

资产回报的第二阶矩比第一阶矩更容易估计,获取更精细的数据显着提高了估计的准确性,而这并不适用于均值估计。

我们可以实施第一阶段以获得 17 个因子加载估计,如下所示:

betas = []
for industry in ff_portfolio_data:
    step1 = OLS(endog=ff_portfolio_data.loc[ff_factor_data.index, industry],
                exog=add_constant(ff_factor_data)).fit()
    betas.append(step1.params.drop('const'))
betas = pd.DataFrame(betas,
                     columns=ff_factor_data.columns,
                     index=ff_portfolio_data.columns)
betas.info()
Index: 17 entries, Food  to Other
Data columns (total 5 columns):
Mkt-RF    17 non-null float64
SMB       17 non-null float64
HML       17 non-null float64
RMW       17 non-null float64
CMA       17 non-null float64 

对于第二阶段,我们对组合的交叉断面期间回报进行 96 次回归,以对因子加载进行回归:

lambdas = []
for period in ff_portfolio_data.index:
    step2 = OLS(endog=ff_portfolio_data.loc[period, betas.index],
                exog=betas).fit()
    lambdas.append(step2.params)
lambdas = pd.DataFrame(lambdas,
                       index=ff_portfolio_data.index,
                       columns=betas.columns.tolist())
lambdas.info()
PeriodIndex: 96 entries, 2010-01 to 2017-12
Freq: M
Data columns (total 5 columns):
Mkt-RF    96 non-null float64
SMB       96 non-null float64
HML       96 non-null float64
RMW       96 non-null float64
CMA       96 non-null float64 

最后,我们计算 96 个期间的平均值,以获得我们的因子风险溢价估计:

lambdas.mean()
Mkt-RF    1.243632
SMB      -0.004863
HML      -0.688167
RMW      -0.237317
CMA      -0.318075
RF       -0.013280 

linearmodels 库通过实现两阶段法马-麦克贝斯程序来扩展statsmodels,其中包含了面板数据的各种模型:

model = LinearFactorModel(portfolios=ff_portfolio_data, 
                          factors=ff_factor_data)
res = model.fit() 

这为我们提供了相同的结果:

图 7.4:线性因子模型估计摘要

随附的笔记本演示了在估计更大范围的个别股票的风险溢价时,通过使用行业虚拟变量来使用分类变量的方法。

使用收缩进行正则化的线性回归

使用最小二乘法训练线性回归模型将在满足高斯-马尔科夫假设时产生最佳的线性和无偏的系数估计。即使 OLS 关于误差协方差矩阵的假设被违反,类似 GLS 的变体也能表现出色。然而,有些估计器会产生偏差系数以减小方差并在整体上实现更低的泛化误差(Hastie,Tibshirani 和 Friedman 2009)。

当线性回归模型包含许多相关变量时,它们的系数将被较差地确定。这是因为大正系数对 RSS 的影响可以被相关变量上的同样大的负系数抵消。因此,由于系数的这种摆动空间使得模型更有可能对样本过拟合,导致了因高方差而产生预测误差的风险增加。

如何对冲过拟合

控制过拟合的一种流行技术是正则化,它涉及向误差函数添加惩罚项,以阻止系数达到较大的值。换句话说,对系数的大小施加约束可以缓解系数对样本外预测可能产生的负面影响。由于过拟合是一个普遍存在的问题,因此我们将遇到所有模型的正则化方法。

在本节中,我们将介绍缩小方法,以解决迄今为止讨论的线性模型的两个改进动机:

  • 预测准确度:最小二乘估计的低偏差但高方差表明,通过缩小或将某些系数设为零,可以减小泛化误差,从而以稍高的偏差换取模型方差的减小。

  • 解释:大量的预测变量可能会复杂化结果的解释或传达整体结果的信息。牺牲一些细节以限制模型到具有最强效应的更小的参数子集可能更可取。

缩小模型通过对系数施加惩罚来限制回归系数的大小。它们通过向目标函数添加一个项 来实现这一目标。这一项意味着缩小模型的系数将最小化 RSS,加上一个与系数的(绝对)大小正相关的惩罚。

添加的惩罚因此将线性回归系数转换为受约束的最小化问题的解,一般情况下,采用以下的拉格朗日形式:

正则化参数 确定惩罚效应的大小,即正则化的强度。一旦 是正的,系数将与无约束的最小二乘参数不同,这意味着偏估计。您应该通过交叉验证自适应地选择超参数 来最小化预期预测误差的估计。我们将在下一节中说明如何做到这一点。

收缩模型通过如何计算惩罚来区分,即* S *的函数形式。最常见的版本是岭回归,它使用平方系数的总和,以及套索模型,它将惩罚基于系数的绝对值的总和。

弹性网络回归在这里没有明确涵盖,它使用两者的组合。Scikit-learn 包含一个实现,其工作方式与我们将在这里演示的示例非常相似。

岭回归的工作原理

岭回归通过将惩罚添加到等于系数平方和的目标函数来收缩回归系数,这反过来对应于系数向量的 L2 范数(Hoerl 和 Kennard 1970):

因此,岭系数被定义为:

截距 已从惩罚中排除,以使该过程与所选的输出变量的原点无关—否则,向所有输出值添加常数将改变所有斜率参数,而不是平行移位。

标准化输入很重要,通过从每个输入中减去相应的平均值并将结果除以输入的标准差来实现。这是因为岭回归解对输入的尺度敏感。对于岭估计器,也有一个类似 OLS 案例的封闭解决方案:

在求逆之前,解决方案在 X^TX 之前添加了缩放的单位矩阵 ,这确保了即使 X^TX 不具有完全秩,问题也是非奇异的。这是最初引入此估计器时的动机之一。

岭惩罚导致所有参数的按比例缩小。对于正交输入的情况,岭估计仅是最小二乘估计的缩放版本,即:

利用输入矩阵 X奇异值分解 (SVD),我们可以深入了解收缩如何影响输入,在更常见的情况下,即它们不是正交的情况下。中心矩阵的奇异值分解代表了矩阵的主成分(见第十三章使用无监督学习的数据驱动风险因素和资产配置),以方差递减的顺序捕获数据列空间中的不相关方向。

岭回归相对于输入变量与展示最大方差的数据方向的对齐来缩小系数。更具体地说,它最大程度地缩小了那些表示与捕捉较少方差的主成分对齐的输入的系数。因此,岭回归中隐含的假设是,在数据中变化最大的方向在预测输出时将是最有影响力或最可靠的。

套索回归的工作原理

套索回归(Hastie, Tibshirani, 和 Wainwright 2015),在信号处理中被称为基 pursuit,也通过对残差平方和添加惩罚来缩小系数,但套索惩罚的效果略有不同。套索惩罚是系数向量的绝对值之和,对应于其 L1 范数。因此,套索估计由以下公式定义:

与岭回归类似,输入需要标准化。套索惩罚使解决方案非线性化,并且没有像岭回归中那样的系数闭式表达式。相反,套索解决方案是一个二次规划问题,有有效的算法可以计算系数的整个路径,这导致了与岭回归相同计算成本的不同值的!

随着正则化的增加,套索惩罚逐渐将一些系数减小到零的效果。因此,套索可以用于连续选择特征子集。

现在让我们继续并将各种线性回归模型实际应用并生成预测性股票交易信号。

如何使用线性回归预测回报

在本节中,我们将使用带有和不带收缩的线性回归来预测回报并生成交易信号。

首先,我们需要创建模型的输入和输出。为此,我们将按照我们在第四章金融特征工程 - 如何研究阿尔法因子中讨论的方向创建特征,以及不同时间范围的前瞻性回报,我们将用作模型的结果。

然后,我们将应用上一节讨论的线性回归模型来说明它们与statsmodels和 sklearn 的使用,并评估它们的预测性能。在下一章中,我们将使用结果来开发一个交易策略,并演示由机器学习模型驱动的策略回测的端到端过程。

准备模型特征和前瞻性回报

为了为我们的预测模型准备数据,我们需要:

  • 选择一组股票和一个时间范围

  • 构建和转换我们将用作特征的阿尔法因子

  • 计算我们的目标是预测的前瞻性回报

  • 并(可能)清理我们的数据

笔记本 preparing_the_model_data.ipynb 包含了本节的代码示例。

创建投资范围

我们将使用来自 Quandl Wiki US Stock Prices 数据集的每日股票数据,时间为 2013 年至 2017 年。请参阅此书 GitHub 存储库的根文件夹中的data目录中的说明,了解如何获取数据。

我们首先加载每日(调整后的)开盘价、最高价、最低价、收盘价和成交量OHLCV)价格和元数据,其中包括部门信息。使用您最初保存 Quandl Wiki 数据的DATA_STORE路径:

START = '2013-01-01'
END = '2017-12-31'
idx = pd.IndexSlice # to select from pd.MultiIndex
DATA_STORE = '../data/assets.h5'
with pd.HDFStore(DATA_STORE) as store:
    prices = (store['quandl/wiki/prices']
              .loc[idx[START:END, :],
                   ['adj_open', 'adj_close', 'adj_low', 
                    'adj_high', 'adj_volume']]
              .rename(columns=lambda x: x.replace('adj_', ''))
              .swaplevel()
              .sort_index())
    stocks = (store['us_equities/stocks']
              .loc[:, ['marketcap', 'ipoyear', 'sector']]) 

我们删除没有至少 2 年数据的股票:

MONTH = 21
YEAR = 12 * MONTH
min_obs = 2 * YEAR
nobs = prices.groupby(level='ticker').size()
keep = nobs[nobs > min_obs].index
prices = prices.loc[idx[keep, :], :] 

接下来,我们清理部门名称,并确保我们仅使用具有价格和部门信息的权益:

stocks = stocks[~stocks.index.duplicated() & stocks.sector.notnull()]
# clean up sector names
stocks.sector = stocks.sector.str.lower().str.replace(' ', '_')
stocks.index.name = 'ticker'
shared = (prices.index.get_level_values('ticker').unique()
          .intersection(stocks.index))
stocks = stocks.loc[shared, :]
prices = prices.loc[idx[shared, :], :] 

现在,我们还剩下 2,265 个具有至少 2 年每日价格数据的股票。首先,是prices DataFrame:

prices.info(null_counts=True)
MultiIndex: 2748774 entries, (A, 2013-01-02) to (ZUMZ, 2017-12-29)
Data columns (total 5 columns):
open      2748774 non-null float64
close     2748774 non-null float64
low       2748774 non-null float64
high      2748774 non-null float64
volume    2748774 non-null float64
memory usage: 115.5+ MB 

接下来是stocks DataFrame:

stocks.info()
Index: 2224 entries, A to ZUMZ
Data columns (total 3 columns):
marketcap    2222 non-null float64
ipoyear      962 non-null float64
sector       2224 non-null object
memory usage: 69.5+ KB 

我们将使用 21 天滚动平均的(调整后的)美元交易量来选择我们模型中最流动的股票。限制股票数量还有减少训练和回测时间的好处;排除低美元交易量股票也可以减少价格数据的噪音。

计算要求我们将每日收盘价与相应的成交量相乘,然后对每个股票使用.groupby()应用滚动平均,如下所示:

prices['dollar_vol'] = prices.loc[:, 'close'].mul(prices.loc[:, 'volume'], axis=0)
prices['dollar_vol'] = (prices
                        .groupby('ticker',
                                 group_keys=False,
                                 as_index=False)
                        .dollar_vol
                        .rolling(window=21)
                        .mean()
                        .reset_index(level=0, drop=True)) 

然后,我们使用此值对每个日期的股票进行排名,以便我们可以选择,例如,给定日期的前 100 个最活跃的股票:

prices['dollar_vol_rank'] = (prices
                             .groupby('date')
                             .dollar_vol
                             .rank(ascending=False)) 

使用 TA-Lib 选择和计算阿尔法因子

我们将使用 TA-Lib 创建一些动量和波动性因子,如第四章金融特征工程-如何研究阿尔法因子中所述。

首先,我们添加相对强度指数RSI),如下所示:

prices['rsi'] = prices.groupby(level='ticker').close.apply(RSI) 

一个快速的评估显示,对于最活跃的 100 只股票,5 天期的平均和中位数正向回报确实随着 RSI 值的减少而减少,分组反映了通常的 30/70 买入/卖出阈值:

(prices[prices.dollar_vol_rank<100]
 .groupby('rsi_signal')['target_5d'].describe()) 
rsi_signal count Mean std min 25% 50% 75% max
(0, 30] 4,154 0.12% 1.01% -5.45% -0.34% 0.11% 0.62% 4.61%
(30, 70] 107,329 0.05% 0.76% -16.48% -0.30% 0.06% 0.42% 7.57%
(70, 100] 10,598 0.00% 0.63% -8.79% -0.28% 0.01% 0.31% 5.86%

然后,我们计算布林带。TA-Lib 的BBANDS函数返回三个值,因此我们设置一个函数,返回一个用于groupby()apply()DataFrame中的上下轨带:

def compute_bb(close):
    high, mid, low = BBANDS(close)
    return pd.DataFrame({'bb_high': high, 'bb_low': low}, index=close.index)
prices = (prices.join(prices
                      .groupby(level='ticker')
                      .close
                      .apply(compute_bb))) 

我们取股票价格与上下布林带之间的百分比差异,并取对数来压缩分布。目标是反映当前值与最近波动趋势的相对值:

prices['bb_high'] = prices.bb_high.sub(prices.close).div(prices.bb_high).apply(np.log1p)
prices['bb_low'] = prices.close.sub(prices.bb_low).div(prices.close).apply(np.log1p) 

接下来,我们计算平均真实范围ATR),它需要三个输入,即最高、最低和收盘价格。我们标准化结果,以使指标在股票之间更具可比性:

def compute_atr(stock_data):
    df = ATR(stock_data.high, stock_data.low, 
             stock_data.close, timeperiod=14)
    return df.sub(df.mean()).div(df.std())
prices['atr'] = (prices.groupby('ticker', group_keys=False)
                 .apply(compute_atr)) 

最后,我们生成移动平均收敛/发散MACD)指标,反映了较短和较长期指数移动平均之间的差异:

def compute_macd close:
   macd = MACD(close)[0]
    return (macd - np.mean(macd))/np.std(macd)
prices['macd'] = (prices
                  .groupby('ticker', group_keys=False)
                  .close
                  .apply(lambda x: MACD(x)[0])) 

添加滞后回报

为了捕捉各种历史滞后的价格趋势,我们计算相应的回报并将结果转换为日几何平均值。我们将使用 1 天的滞后;1 周和 1 周;以及 1、2 和 3 个月。我们还将通过在 0.01 和 99.99 分位数处剪切值来修剪回报的 winsorize 值:

q = 0.0001
lags = [1, 5, 10, 21, 42, 63]
for lag in lags:
    prices[f'return_{lag}d'] = (prices.groupby(level='ticker').close
                                .pct_change(lag)
                                .pipe(lambda x: x.clip(lower=x.quantile(q),
                                                       upper=x.quantile(1 - q)
                                                       ))
                                .add(1)
                                .pow(1 / lag)
                                .sub(1)
                                ) 

然后,我们将每日、(双周)和每月的回报向后移动,以便将其用作当前观察的特征。换句话说,除了这些时期的最新回报外,我们还使用前五个结果。例如,我们将前 5 周的周回报向后移动,以使其与当前观察对齐,并可用于预测当前的前瞻回报:

for t in [1, 2, 3, 4, 5]:
    for lag in [1, 5, 10, 21]:
        prices[f'return_{lag}d_lag{t}'] = (prices.groupby(level='ticker')
                                           [f'return_{lag}d'].shift(t * lag)) 

生成目标前瞻回报

我们将测试各种前瞻期的预测。目标是确定产生最佳预测准确度的持有期,衡量标准是信息系数IC)。

更具体地说,我们将时间段 t 的回报向前移动 t 天,以便将其用作前瞻回报。例如,我们将 t[0] 的 5 天回报向后移动 5 天,这样该值就成为 t[0] 的模型目标。我们可以生成每日、(双周)和每月的前瞻回报如下:

for t in [1, 5, 10, 21]:
    prices[f'target_{t}d'] = prices.groupby(level='ticker')[f'return_{t}d'].shift(-t) 

类别变量的虚拟编码

我们需要将任何分类变量转换为数值格式,以便线性回归可以处理它。为此,我们将使用一种虚拟编码,为每个类别级别创建单独的列,并在原始分类列中标记该级别的存在,输入为 1,否则为 0。pandas 函数 get_dummies() 自动化了虚拟编码。它检测并正确转换类型为对象的列,如此处所示。如果您需要对包含整数的列进行虚拟变量处理,例如,您可以使用关键字 columns 进行识别:

df = pd.DataFrame({'categories': ['A','B', 'C']})
  categories
0          A
1          B
2          C
pd.get_dummies(df)
   categories_A  categories_B  categories_C
0             1             0             0
1             0             1             0
2             0             0             1 

当将所有类别转换为虚拟变量并估计带有截距的模型(通常会这样做)时,您无意中创建了多重共线性:矩阵现在包含冗余信息,不再具有完整秩,而是变得奇异。

通过移除其中一列新的指示器列,可以简单地避免这个问题。缺失类别级别的系数现在将被截距捕获(即使每个剩余类别虚拟变量都为 0 时,截距始终为 1)。

使用 drop_first 关键字来正确调整虚拟变量:

pd.get_dummies(df, drop_first=True)
   categories_B  categories_C
0             0             0
1             1             0
2             0             1 

为了捕捉季节效应和不断变化的市场情况,我们为年份和月份创建时间指示变量:

prices['year'] = prices.index.get_level_values('date').year
prices['month'] = prices.index.get_level_values('date').month 

然后,我们将我们的价格数据与部门信息结合,并为时间和部门类别创建虚拟变量:

prices = prices.join(stocks[['sector']])
prices = pd.get_dummies(prices,
                        columns=['year', 'month', 'sector'],
                        prefix=['year', 'month', ''],
                        prefix_sep=['_', '_', ''],
                        drop_first=True) 

我们得到了大约 50 个特征,现在我们可以将其与前一节讨论的各种回归模型一起使用。

使用 statsmodels 进行线性最小二乘回归

在本节中,我们将演示如何使用 statsmodels 运行股票回报数据的统计推断并解释结果。笔记本 04_statistical_inference_of_stock_returns_with_statsmodels.ipynb 包含此部分的代码示例。

选择相关的股票池

基于我们的美元交易量的排名滚动平均值,我们选择样本中任何给定交易日的前 100 只股票:

data = data[data.dollar_vol_rank<100] 

然后,我们创建我们的结果变量和特征,如下所示:

y = data.filter(like='target')
X = data.drop(y.columns, axis=1) 

估计普通最小二乘回归

我们可以使用 statsmodels 估计一个普通最小二乘回归模型,如前所示。我们选择一个前向回报,例如,一个 5 天的持有期,并相应地拟合模型:

target = 'target_5d'
model = OLS(endog=y[target], exog=add_constant(X))
trained_model = model.fit()
trained_model.summary() 

诊断统计

您可以在笔记本中查看完整的摘要输出。我们将在这里省略它以节省一些空间,鉴于特征数量很大,并且只显示诊断统计:

=====================================================================
Omnibus:               33104.830   Durbin-Watson:               0.436
Prob(Omnibus):             0.000   Jarque-Bera (JB):      1211101.670
Skew:                     -0.780   Prob(JB):                     0.00
Kurtosis:                 19.205   Cond. No.                     79.8
===================================================================== 

诊断统计显示 Jarque-Bera 统计量的 p 值较低,这表明残差不服从正态分布:它们呈负偏斜和高峰度。图 7.5 的左侧面板绘制了残差分布与正态分布的对比,并突出显示了这个缺点。实际上,这意味着模型产生的大误差比“正常”更多:

图 7.5:残差分布和自相关图

此外,杜宾-沃森统计量为 0.43,因此我们可以在 5%的水平下舒适地拒绝“无自相关”的零假设。因此,残差可能呈正相关。前一图的右侧面板绘制了前 10 个滞后的自相关系数,指出了直到滞后 4 的显著正相关性。这个结果是由于我们结果的重叠造成的:我们为每一天预测 5 天的回报,因此连续天的结果包含四个相同的回报。

如果我们的目标是了解哪些因素与前向回报显著相关,我们将需要使用稳健标准误差重新运行回归(statsmodels.fit() 方法中的一个参数)或者完全使用不同的方法,例如允许更复杂误差协方差的面板模型。

使用 scikit-learn 进行线性回归

由于 sklearn 面向预测,我们将基于交叉验证评估线性回归模型的预测性能。您可以在笔记本 05_predicting_stock_returns_with_linear_regression.ipynb 中找到此部分的代码示例。

选择特征和目标

我们将为我们的实验选择股票池,就像我们在 OLS 情况下那样,将股票代码限制在任何给定日期按美元价值交易量排名前 100 的股票中。样本仍然包含 2013 年至 2017 年的 5 年数据。

交叉验证模型

我们的数据包含大量的时间序列,每个安全性一个。如第六章机器学习过程中所讨论的那样,像时间序列这样的顺序数据需要进行谨慎的交叉验证,以便我们不会不经意地引入前瞻性偏见或泄漏。

我们可以使用我们在第六章机器学习过程中介绍的 MultipleTimeSeriesCV 类来实现这一点。我们用所需的训练和测试期长度,我们想要运行的测试期数量,以及我们预测视野中的期数来初始化它。split() 方法返回一个生成器,产生训练和测试索引对,然后我们可以用它来选择结果和特征。成对的数量取决于参数 n_splits

测试期不重叠,位于数据可用期间的末尾。使用完测试期后,它将成为向前滚动的训练数据的一部分,并保持大小不变。

我们将使用 63 个交易日或 3 个月的时间来训练模型,然后预测接下来 10 天的 1 日回报。因此,在 2015 年开始,我们可以使用大约 75 个 10 天的分割期在 3 年期间进行测试。我们将首先定义基本参数和数据结构,如下所示:

train_period_length = 63
test_period_length = 10
n_splits = int(3 * YEAR/test_period_length)
lookahead =1 
cv = MultipleTimeSeriesCV(n_splits=n_splits,
                          test_period_length=test_period_length,
                          lookahead=lookahead,
                          train_period_length=train_period_length) 

交叉验证循环遍历由 TimeSeriesCV 提供的训练和测试索引,选择特征和结果,训练模型,并预测测试特征的回报。我们还捕获实际值和预测值之间的均方根误差和 Spearman 秩相关性:

target = f'target_{lookahead}d'
lr_predictions, lr_scores = [], []
lr = LinearRegression()
for i, (train_idx, test_idx) in enumerate(cv.split(X), 1):
    X_train, y_train, = X.iloc[train_idx], y[target].iloc[train_idx]
    X_test, y_test = X.iloc[test_idx], y[target].iloc[test_idx]
    lr.fit(X=X_train, y=y_train)
    y_pred = lr.predict(X_test)
    preds_by_day = (y_test.to_frame('actuals').assign(predicted=y_pred)
                    .groupby(level='date'))
    ic = preds_by_day.apply(lambda x: spearmanr(x.predicted,
                                                x.actuals)[0] * 100)
    rmese = preds_by_day.apply(lambda x: np.sqrt(
                               mean_squared_error(x.predicted, x.actuals)))
    scores = pd.concat([ic.to_frame('ic'), rmse.to_frame('rmse')], axis=1)

    lr_scores.append(scores)
    lr_predictions.append(preds) 

交叉验证过程需要 2 秒。我们将在下一节中评估结果。

评估结果 - 信息系数和 RMSE

我们已经捕获了我们宇宙中每天测试预测的 3 年数据。为了评估模型的预测性能,我们可以计算每个交易日的信息系数,以及通过汇总所有预测来计算整个期间的信息系数。

图 7.6 的左侧面板(请查看笔记本中的代码)显示了为每一天计算的秩相关系数的分布,并显示它们的平均值和中位数,分别接近 1.95 和 2.56。

图的右侧面板显示了在所有测试期间内预测和实际 1 日回报的散点图。seaborn jointplot 估计了一个鲁棒回归,对离群值赋予较低的权重,并显示了一个小的正相关关系。在整个 3 年的测试期间,实际和预测回报的秩相关性是正的,但较低,为 0.017,并且在统计上显著:

图 7.6:线性回归的每日和汇总 IC

此外,我们可以跟踪预测在 IC 方面的每日表现。图 7.7显示了每日信息系数和 RMSE 的 21 天滚动平均值,以及它们在验证期间的平均值。这个视角突显了整个时期的小正 IC 隐藏了从-10 到+10 的大幅度变化:

图 7.7:线性回归模型每日 IC 和 RMSE 的 21 天滚动平均值

使用 scikit-learn 进行岭回归

现在我们将转向正则化岭模型,我们将使用它来评估参数约束是否改善了线性回归的预测性能。使用岭模型使我们能够选择确定模型目标函数中惩罚项权重的超参数,正如前面的收缩方法:线性回归的正则化部分所讨论的那样。

使用交叉验证调整正则化参数

对于岭回归,我们需要使用关键字alpha调整正则化参数,该参数对应于我们先前使用的。我们将尝试从 10^(-4)到 10⁴的 18 个值,其中较大的值意味着更强的正则化:

ridge_alphas = np.logspace(-4, 4, 9)
ridge_alphas = sorted(list(ridge_alphas) + list(ridge_alphas * 5)) 

我们将应用与线性回归案例相同的交叉验证参数,训练 3 个月以预测 10 天的每日收益。

岭惩罚的尺度敏感性要求我们使用StandardScaler对输入进行标准化。请注意,我们始终从训练集中学习平均值和标准差,使用.fit_transform()方法,然后将这些学到的参数应用于测试集,使用.transform()方法。为了自动化预处理,我们创建了一个Pipeline,如下面的代码示例所示。我们还收集了岭系数。否则,交叉验证类似于线性回归过程:

for alpha in ridge_alphas:
    model = Ridge(alpha=alpha,
                  fit_intercept=False,
                  random_state=42)
    pipe = Pipeline([
        ('scaler', StandardScaler()),
        ('model', model)])
    for i, (train_idx, test_idx) in enumerate(cv.split(X), 1):
        X_train, y_train = X.iloc[train_idx], y[target].iloc[train_idx]
        X_test, y_test = X.iloc[test_idx], y[target].iloc[test_idx]
        pipe.fit(X=X_train, y=y_train)
        y_pred = pipe.predict(X_test)
        preds = y_test.to_frame('actuals').assign(predicted=y_pred)
        preds_by_day = preds.groupby(level='date')
        scores = pd.concat([preds_by_day.apply(lambda x: 
                                               spearmanr(x.predicted, 
                                                   x.actuals)[0] * 100)
                            .to_frame('ic'),
                            preds_by_day.apply(lambda x: np.sqrt(
                                                    mean_squared_error(
                                                    y_pred=x.predicted, 
                                                    y_true=x.actuals)))
                            .to_frame('rmse')], axis=1)
        ridge_scores.append(scores.assign(alpha=alpha))
        ridge_predictions.append(preds.assign(alpha=alpha))
        coeffs.append(pipe.named_steps['model'].coef_) 

交叉验证结果和岭系数路径

现在我们可以绘制每个超参数值的 IC,以可视化随着正则化的增加而如何演变。结果显示,我们得到了的最高平均和中位数 IC 值

对于这些正则化水平,图 7.8的右侧面板显示,与(几乎)无约束模型相比,系数略有收缩,其中包括

图 7.8:岭回归交叉验证结果

图的左侧面板显示,对于最优正则化值,预测准确性在平均和中位数 IC 值方面仅略有增加。

前 10 个系数

系数的标准化使我们能够通过比较它们的绝对值大小来得出关于它们相对重要性的结论。图 7.9显示了使用进行正则化的 10 个最相关系数,这些系数是在所有训练模型上进行了平均:

图 7.9:每日 IC 分布和最重要的系数

对于这个简单模型和样本期间,滞后的月度收益和各种部门指标发挥了最重要的作用。

使用 sklearn 进行 Lasso 回归

Lasso 实现看起来与我们刚刚运行的岭模型非常相似。主要区别在于,Lasso 需要使用迭代坐标下降来找到解决方案,而岭回归可以依赖封闭形式的解决方案。这可能导致较长的训练时间。

交叉验证 Lasso 模型

交叉验证代码仅在Pipeline设置方面有所不同。Lasso对象允许您设置容忍度和用于确定是否已收敛或应该中止的最大迭代次数。您还可以依赖warm_start,以便下一次训练从最后的最佳系数值开始。请参考 sklearn 文档和笔记本以获取更多详细信息。

我们将在范围为 10^(-10)到 10^(-3)的八个alpha值中使用:

lasso_alphas = np.logspace(-10, -3, 8)
for alpha in lasso_alphas:
    model = Lasso(alpha=alpha,
                  fit_intercept=False,
                  random_state=42,
                  tol=1e-4,
                  max_iter=1000,
                  warm_start=True,
                  selection='random')
    pipe = Pipeline([
        ('scaler', StandardScaler()),
        ('model', model)]) 

评估结果 - IC 和 Lasso 路径

与之前一样,我们可以绘制交叉验证期间所有测试集的平均信息系数。我们再次可以看到,正则化改善了无约束模型的 IC,在的水平上提供了最佳的样本外结果。

最佳正则化值与岭回归不同,因为惩罚由相对较小的系数值的绝对值之和组成。我们还可以在图 7.10中看到,对于这个正则化水平,系数已经被类似地缩小,如岭回归案例中所示:

图 7.10:Lasso 交叉验证结果

在这种情况下,Lasso 回归的平均和中位数 IC 系数略高,并且性能最佳的模型平均使用了不同的系数集:

图 7.11:Lasso 每日 IC 分布和前 10 个系数

比较预测信号的质量

总的来说,岭回归和 Lasso 回归通常会产生类似的结果。岭回归通常计算速度更快,但 Lasso 回归也通过逐渐将系数减少到零来提供连续的特征子集选择,从而消除了特征。

在这种特定情况下,Lasso 回归产生了最佳的平均和中位数 IC 值,如图 7.12所示:

图 7.12:三个模型的日均和中位 IC

此外,我们可以使用 Alphalens 计算各种度量和反映模型预测信号质量的可视化效果,如第四章中介绍的金融特征工程 - 如何研究 Alpha 因子。笔记本06_evaluating_signals_using_alphalens.ipynb包含了将模型预测与价格信息结合以生成 Alphalens 所需的 alpha 因子输入的代码示例。

下表显示了按照模型预测的不同五分位数投资组合的 alpha 和 beta 值。在这个简单的例子中,性能差异非常小:

指标 Alpha Beta
模型 1D 5D 10D
线性回归 0.03 0.02 0.007
岭回归 0.029 0.022 0.012
Lasso 回归 0.03 0.021 0.009

线性分类

到目前为止讨论的线性回归模型假设了一个定量的响应变量。在这一部分中,我们将重点介绍对定性输出变量进行建模的方法,进行推断和预测,这个过程被称为分类,在实践中比回归更频繁地发生。

预测数据点的定性响应称为对该观察进行分类,因为它涉及将观察分配给类别或类别。在实践中,分类方法通常为定性变量的每个类别预测概率,然后使用此概率来确定适当的分类。

我们可以通过忽略输出变量取离散值的事实来解决这个分类问题,然后应用线性回归模型尝试使用多个输入变量预测分类输出。然而,很容易构造出这种方法表现非常差的示例。此外,当我们知道时,模型产生大于 1 或小于 0 的值并不直观。

有许多不同的分类技术或分类器可用于预测定性响应。在这一部分中,我们将介绍广泛使用的逻辑回归,它与线性回归密切相关。在接下来关于广义可加模型的章节中,我们将介绍更复杂的方法,包括决策树和随机森林,以及梯度提升机和神经网络。

逻辑回归模型

逻辑回归模型的提出源于希望在 x 中线性的函数给定情况下建模输出类别的概率,就像线性回归模型一样,同时确保它们总和为 1 并保持在[0, 1]之间,这是我们从概率中期望的。

在这一部分,我们将介绍逻辑回归模型的目标和功能形式,并描述训练方法。然后我们将用statsmodels演示如何使用逻辑回归进行宏观数据的统计推断,以及如何使用 sklearn 实现的正则化逻辑回归来预测价格变动。

目标函数

为了说明目标函数,我们将使用输出变量 y,如果股票回报在给定时间范围 d 内为正,则取值为 1,否则为 0:

我们可以轻松地将 y 扩展到三个类别,其中 0 和 2 反映了超出某个阈值的负面和正面价格变动,否则为 1。

与直接建模输出变量 y 不同,逻辑回归模型输出 y 属于哪个类别的概率,给定了一个 alpha 因子或特征 x[t] 的向量。换句话说,逻辑回归模型建模了股价上涨的概率,这取决于模型中包含的变量的值:

逻辑函数

为了防止模型产生超出 [0, 1] 区间的值,我们必须使用一个只在整个 x 的定义域上给出 0 和 1 之间输出的函数来对 p(x) 建模。逻辑函数满足这一要求,总是产生 S 形曲线,因此,无论 x 的值如何,我们都会得到一个在概率术语中有意义的预测:

在这里,向量 x 包括一个截距为 的第一个分量。我们可以转换这个表达式以分离类似线性回归的部分,得到:

p(x)/[1−p(x)] 称为赔率,这是一种表达概率的替代方式,可能在赌博中很常见。它可以取任何在 0 和 之间的赔率值,低值也意味着低概率,高值意味着高概率。

逻辑函数也称为对数几率(因为它是赔率的对数)。因此,逻辑回归表示在 x 中是线性的 logit,看起来很像前面的线性回归。

最大似然估计

必须使用可用的训练数据估计系数向量 。虽然我们可以使用(非线性)最小二乘法拟合 logistic 回归模型,但更一般的最大似然方法更受青睐,因为它具有更好的统计特性。正如我们刚刚讨论的,使用最大似然拟合 logistic 回归模型的基本直觉是寻求估计 ,使得预测概率 尽可能与实际结果相符。换句话说,我们试图找到 ,使得这些估计在股价上涨的所有情况下产生接近 1 的数字,并在其他情况下产生接近 0 的数字。更正式地说,我们正在寻求最大化似然函数:

用总和比乘积更容易处理,因此让我们在两边取对数,得到对数似然函数和 logistic 回归系数的相应定义:

为了最大化这个方程,我们将的导数设为零。这产生了p+1 个所谓的得分方程,这些方程对参数非线性,并且可以使用迭代数值方法求解。

如何使用 statsmodels 进行推断

我们将演示如何根据包含从 1959 年到 2009 年的季度美国宏观数据的简单内置数据集使用statsmodels进行逻辑回归(有关详情,请参见笔记本logistic_regression_macro_data)。

变量及其转换列在以下表中:

变量 描述 转换
realgdp 国内生产总值真实值 年增长率
realcons 真实个人消费支出 年增长率
realinv 真实私人国内生产总投资 年增长率
realgovt 联邦政府实际支出和总投资 年增长率
realdpi 真实私人可支配收入 年增长率
m1 M1 名义货币存量 年增长率
tbilrate 月度国库券利率 水平
unemp 季调失业率(%) 水平
infl 通货膨胀率 水平
realint 实际利率 水平

为了获得二进制目标变量,我们计算季度实际 GDP 年增长率的 20 季度滚动平均值。然后,如果当前增长超过移动平均值,则分配 1,否则为 0。最后,我们将指示变量移位以将下一个季度的结果与当前季度对齐。

我们使用一个截距,并将季度值转换为虚拟变量,并训练逻辑回归模型,如下所示:

import statsmodels.api as sm
data = pd.get_dummies(data.drop(drop_cols, axis=1), columns=['quarter'], drop_first=True).dropna()
model = sm.Logit(data.target, sm.add_constant(data.drop('target', axis=1)))
result = model.fit()
result.summary() 

我们的模型生成了以下摘要,显示了 198 个观测值和 13 个变量,包括一个截距:

图 7.13:Logit 回归结果

摘要表明,该模型是使用最大似然法进行训练的,并提供了对数似然函数的最大化值为-67.9。

-136.42 的 LL-Null 值是仅包括截距时最大化对数似然函数的结果。它构成伪 R²统计量对数似然比LLR)测试的依据。

伪 R²统计量是最小二乘法下可用的熟悉 R²的替代品。它基于零模型m[0]和完全模型m[1]的最大化对数似然函数的比率计算,如下所示:

值从 0(当模型不改善似然函数时)到 1(当模型完全适配并且对数似然在 0 处最大化时)变化。因此,较高的值表示拟合度较好。

LLR 测试通常比较更受限制的模型,并计算如下:

零假设是受限模型表现更好,但低的 p 值表明我们可以拒绝这个假设,而更倾向于完整模型而不是空模型。这类似于线性回归的 F 检验(当我们使用 MLE 估计模型时,我们也可以使用 LLR 检验)。

z 统计量在线性回归输出中扮演与 t 统计量相同的角色,并且与系数估计和其标准误的比率一样计算。p 值还指示了在假设零假设 下观察测试统计量的概率,即总体系数为零。我们可以拒绝这个假设对于 interceptrealconsrealinvrealgovtrealdpiunemp

使用逻辑回归预测价格变动

套索 L1 惩罚和岭 L2 惩罚都可以与逻辑回归一起使用。它们具有我们刚刚讨论的相同的收缩效应,套索可以再次用于任何线性回归模型的变量选择。

就像线性回归一样,对输入变量进行标准化非常重要,因为正则化模型对尺度敏感。正则化超参数也需要使用交叉验证进行调优,就像线性回归的情况一样。

如何将回归问题转化为分类问题

我们将继续使用价格预测示例,但现在我们将使结果变量二元化,以便每当 1 天回报为正时取值为 1,否则取值为 0(请参阅笔记本 predicting_price_movements_with_logistic_regression.ipynb 中给出的代码示例):

target = 'target_1d'
y['label'] = (y[target] > 0).astype(int) 

结果略显不平衡,正面移动比负面移动多:

y.label.value_counts()
1    56443
0    53220 

有了这个新的分类结果变量,我们现在可以使用默认的 L2 正则化来训练逻辑回归。

对逻辑回归超参数进行交叉验证

对于逻辑回归,正则化的制定与线性回归相反:较高的值 意味着较少的正则化,反之亦然。

我们将使用我们的自定义 TimeSeriesCV 对 11 个正则化超参数选项进行交叉验证,如下所示:

n_splits = 4*252
cv = TimeSeriesCV(n_splits=n_splits,
                  test_period_length=1,
                  train_period_length=252)
Cs = np.logspace(-5, 5, 11) 

现在,train-test 循环采用 sklearn 的 LogisticRegression 并计算 roc_auc_score(详情请参阅笔记本):

for C in Cs:
    model = LogisticRegression(C=C, fit_intercept=True)
    pipe = Pipeline([
        ('scaler', StandardScaler()),
        ('model', model)])
    for i, (train_idx, test_idx) in enumerate(cv.split(X), 1):
        X_train, y_train, = X.iloc[train_idx], y.label.iloc[train_idx]
        pipe.fit(X=X_train, y=y_train)
        X_test, y_test = X.iloc[test_idx], y.label.iloc[test_idx]
        y_score = pipe.predict_proba(X_test)[:, 1]
        auc = roc_auc_score(y_score=y_score, y_true=y_test) 

另外,我们还可以基于预测概率和实际回报来计算信息系数(IC):

 actuals = y[target].iloc[test_idx]
        ic, pval = spearmanr(y_score, actuals) 

使用 AUC 和 IC 评估结果

我们可以再次绘制一系列超参数值的 AUC 结果。在 图 7.14 中,左侧面板显示了 C=0.1 时最佳的中位数 AUC 结果,而最佳均值 AUC 对应于 C=10^(-3)。右侧面板显示了具有 C=10⁴ 的模型的信息系数分布。这也突显了与先前显示的回归模型相比,我们获得的中位数和均值略高的值:

图 7.14:逻辑回归

在下一章中,我们将利用这些基本模型产生的预测来为交易策略生成信号,并演示如何对其性能进行回测。

摘要

在本章中,我们介绍了我们的第一个机器学习模型,使用线性模型作为回归和分类的重要基线案例。我们探讨了两种任务的目标函数的构造,学习了各种训练方法,并学会了如何对模型进行推断和预测。

我们将这些新的机器学习技术应用于估计线性因子模型,这些模型对于管理风险、评估新的α因子以及评估绩效都非常有用。我们还应用了线性回归和分类来完成第一个预测任务,即绝对和方向性地预测股票回报。

在下一章中,我们将总结迄今为止所学内容,并以机器学习用于交易的工作流程形式呈现出来。这个过程从收集和准备关于特定投资领域的数据以及计算有用特征开始,继续设计和评估机器学习模型以从这些特征中提取可操作的信号,最终以模拟执行和评估策略告终,将这些信号转化为优化组合。

第八章:ML4T 工作流程 - 从模型到策略回测

现在,是时候整合我们迄今为止分开讨论的机器学习交易(ML4T)工作流的各种构建模块了。本章的目标是呈现设计、模拟和评估由 ML 算法驱动的交易策略过程的端到端视角。为此,我们将更详细地演示如何使用 Python 库 backtrader 和 Zipline 在历史市场环境中回测 ML 驱动的策略。

ML4T 工作流的终极目标是从历史数据中收集证据。这有助于我们决定是否在实时市场部署候选策略并将财务资源置于风险之中。此过程建立在您在前几章中培养的技能基础上,因为它依赖于您的能力来:

  • 使用各种数据源来工程化信息丰富的因子

  • 设计生成预测信号以指导您的交易策略的 ML 模型

  • 从风险收益角度优化结果组合

实现你的策略的逼真模拟还需要忠实地表现出安全市场的运作方式以及交易是如何执行的。因此,在设计回测或评估回测引擎是否包含准确的性能度量所需特性时,交易所的制度细节,例如可用的订单类型以及价格确定方式也很重要。

具体来说,通过完成本章后,您将能够:

  • 规划和实施端到端策略回测

  • 在实施回测时了解并避免关键陷阱

  • 讨论矢量化与事件驱动回测引擎的优缺点

  • 识别和评估事件驱动回测器的关键组成部分

  • 使用分钟和每日频率的数据源以及单独训练的 ML 模型或作为回测的一部分执行 ML4T 工作流程

  • 使用 Zipline 和 backtrader

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和其他资源链接。笔记本包括图像的彩色版本。

如何回测 ML 驱动的策略

简而言之,图 8.1 中所示的 ML4T 工作流程是关于回测利用机器学习生成交易信号的交易策略,选择和确定头寸,或者优化交易执行。它涉及以下步骤,并考虑特定的投资领域和时间跨度:

  1. 获取和准备市场、基本和替代数据

  2. 工程预测性的 alpha 因子和特征

  3. 设计、调整和评估生成交易信号的 ML 模型

  4. 根据这些信号做出交易决策,例如通过应用规则

  5. 在组合背景下确定个别头寸的大小

  6. 模拟使用历史市场数据触发的交易结果

  7. 评估结果持有的表现将会如何

图 8.1:ML4T 工作流

当我们在第六章机器学习流程中讨论了机器学习过程时,我们强调了模型的学习应该对新应用具有很好的泛化性。换句话说,基于给定数据集训练的 ML 模型的预测在提供新输入数据时应该表现同样良好。同样,策略的(相对)回测性能应该表明未来市场的表现

在我们看如何运行历史模拟的回测引擎之前,我们需要审查几个方法上的挑战。未能适当解决这些挑战将使结果不可靠,并导致关于策略实时实施的错误决定。

回测陷阱及其避免方法

回测根据历史数据模拟算法策略,目的是产生适用于新市场条件的性能结果。除了在不断变化的市场背景下预测的一般不确定性之外,在样本内性能与将来保持的模式之间存在的几个实施方面可能会偏置结果,并增加将样本内性能误认为将适用于样本外的模式的风险。

这些方面在我们的控制范围内,并包括数据的选择和准备、对交易环境的不切实际假设,以及统计测试的错误应用和解释。错误回测发现的风险随着计算能力的增加、数据集的增大以及更复杂的算法而增加,这些算法有助于在噪声样本中误识别明显信号。

在本节中,我们将概述最严重和最常见的方法错误。请参考关于多重测试的文献以获取进一步的细节,特别是马科斯·洛佩斯·德·普拉多(Marcos Lopez de Prado)在2018 年金融机器学习进展中收集的一系列文章。我们还将介绍被调整的夏普比率SR),该比率说明了如何在使用相同一组财务数据进行分析时调整由重复试验产生的指标。

获取正确的数据

削弱回测有效性的数据问题包括前瞻性偏差生存偏差异常值控制以及样本期的选择。我们将依次解决这些问题。

前瞻性偏差 – 仅使用点时间数据

算法策略的核心是根据数据触发行动的交易规则。当我们在未知或不可用的历史信息之前开发或评估交易规则时,前瞻性偏差就会出现。由此产生的绩效度量将是误导性的,并且不代表未来,因为实时策略执行期间的数据可用性不同。

这种偏差的常见原因是未能考虑在其初始发布后对报告的财务数据进行的更正或重新说明。股票分割或反向分割也可能产生前瞻性偏差。例如,在计算收益率时,每股收益EPS)数据通常以季度为基础报告,而市场价格则以更高的频率提供。因此,调整后的 EPS 和价格数据需要同步,考虑到可用数据实际上是何时发布给市场参与者的。

解决方案涉及对进入回测的所有数据的时间戳进行仔细验证。我们需要确保结论仅基于时点数据,不会意外地包含未来的信息。高质量的数据提供商确保满足这些标准。当时点数据不可用时,我们需要对报告滞后进行(保守的)假设。

幸存者偏差 - 跟踪您的历史投资组合

当回测数据仅包含当前活动的证券,而省略随时间消失的资产时,幸存者偏差就会出现,原因可能是破产、退市或收购等。不再属于投资范围的证券通常表现不佳,而未能包括这些情况会使回测结果呈正偏。

解决方案自然是验证数据集是否包含随时间变化的所有证券,而不仅仅是在运行测试时仍然可用的证券。在某种程度上,这是确保数据真正时点的另一种方式。

异常值控制 - 不要排除现实中的极端情况

数据准备通常包括对异常值的处理,例如 winsorizing 或剪切极端值。挑战在于确定那些确实不代表分析期间的异常值,而不是任何那时市场环境的一个组成部分的极端值。许多市场模型假设在观察到更频繁的极端值时,数据呈正态分布,正如 fat-tailed 分布所建议的那样。

解决方案涉及对极端值进行仔细分析,以及根据这种现实调整策略参数。

样本期间 - 尝试代表相关的未来场景

如果样本数据不反映当前(和可能的未来)环境,则回测将不会产生推广到未来的代表性结果。糟糕选择的样本数据可能缺乏相关的市场制度方面,例如波动性或成交量方面,未能包含足够的数据点,或包含太多或太少的极端历史事件。

解决方案涉及使用包含重要市场现象的样本期间或生成反映相关市场特征的合成数据。

确保模拟正确

实施历史模拟时涉及的实际问题包括:

  • 未按市场价值核算以准确反映市场价格和考虑回撤

  • 关于交易的可用性、成本或市场影响的不切实际假设

  • 错误的信号和交易执行时间

让我们看看如何识别和解决每一个问题。

按市场价值核算的绩效 - 随时间跟踪风险

策略需要始终满足投资目标和约束条件。如果它在回测过程中表现良好,但随着时间推移导致不可接受的损失或波动,这显然是行不通的。投资组合经理需要定期追踪和报告其头寸的价值,称为按市场价值核算,并可能是实时的。

解决方案包括随时间绘制绩效图或计算(滚动)风险指标,例如价值-at-风险VaR)或 Sortino 比率。

交易成本 - 假设一个真实的交易环境

市场不允许在任何时候或以目标价格执行所有交易。假设实际上可能无法进行或以不利条件发生的交易的回测将产生有偏见的结果。

实际缺点包括假设可能没有对手方的卖空交易策略,或者低估了交易的市场影响(滑点)对大量交易或涉及较少流动性资产的影响,或者由于经纪费而产生的成本。

解决方案包括将限制在流动性范围内和/或对交易和滑点成本进行真实参数假设。这也可以防止不稳定因子信号产生的误导性结论,这些信号迅速衰减并产生高投资组合换手率。

决策的时间 - 正确排序信号和交易

类似于前瞻性偏差,模拟可能对何时接收和交易信号做出不切实际的假设。例如,当交易仅在下一个开市时可用时,信号可能是从收盘价格计算的,价格可能相差很大。当我们使用收盘价格评估绩效时,回测结果将不代表现实未来结果。

解决方案涉及精心组织信号到达、交易执行和绩效评估的顺序。

正确地获取统计数据

在回测有效性方面最突出的挑战,包括已发布的结果,是发现由于多次测试而导致的虚假模式。基于对同一数据的不同候选者的测试选择策略将使选择有所偏见。这是因为积极的结果更可能是由于绩效度量本身的随机性造成的。换句话说,该策略过度拟合了测试样本,产生了欺骗性的积极结果,不太可能推广到在实际交易中遇到的未来数据。

因此,只有报告试验次数才能提供有关选择偏见风险的评估,这在实际或学术研究中很少见,这引发了对许多已发表声明有效性的怀疑。

此外,回测过拟合的风险不仅来自运行大量测试,而且还影响基于先前的工作与否的知识设计的策略。由于风险包括他人在相同数据上运行的回测的知识,因此回测过拟合在实践中很难避免。

提出的解决方案包括优先考虑可以用投资或经济理论来证明的测试,而不是任意的数据挖掘努力。这也意味着在各种背景和情景下进行测试,包括可能在合成数据上进行测试。

最小回测长度和通缩的 SR

马科斯·洛佩兹·德·普拉多(www.quantresearch.info/)在回测风险以及如何检测或避免回测风险方面发表了大量文章。这包括回测过拟合的在线模拟器(datagrid.lbl.gov/backtest/贝利等人,2015 年)。

另一个结果包括投资者应要求的回测期的最小长度的估计,以避免选择一种在给定数量的样本试验中达到一定 SR 但预期的样本外 SR 为零的策略。结果表明,例如,2 年的每日回测数据不支持对超过七种策略的结论。 5 年的数据将这个数字扩展到 45 种策略变体。有关实施细节,请参见贝利,博尔温和普拉多(2016 年)

贝利和普拉多(2014 年) 还推导出了一个通缩的 SR 来计算 SR 在控制多重测试、非正常收益和较短样本长度的通胀效应时是否具有统计学显著性的概率。(有关deflated_sharpe_ratio.py的 Python 实现以及相关公式推导的参考,请参见multiple_testing子目录。)

回测的最佳停止

除了将回测限制在可以在理论上证明的策略上,而不是仅仅进行数据挖掘练习之外,一个重要问题是何时停止运行额外的测试。

回答这个问题的一种方法是依赖于最优停止理论中的秘书问题的解决方案。该问题假设我们根据面试结果选择申请人,并需要决定是否进行额外的面试或选择最近的候选人。在这种情况下,最优规则是始终拒绝前n/e个候选人,然后选择超过所有先前选项的第一个候选人。使用这个规则会导致选择最佳候选人的概率为 1/e,而不管候选人池的大小n如何。

将此规则直接翻译成回测背景产生以下建议:测试合理策略的 1/e(大约 37%)的随机样本,并记录它们的表现。然后,继续测试,直到有一个策略的表现优于之前测试的策略。这个规则适用于测试几种替代方案,目标是尽快选择近乎最佳的策略,同时尽量减少出现假阳性的风险。有关更多信息,请参阅 GitHub 上列出的资源。

回测引擎的工作原理

简而言之,回测引擎会迭代历史价格(和其他数据),将当前值传递给您的算法,接收订单,并跟踪生成的头寸及其价值。

在实践中,对于创建一个在本章开头所示 ML4T 工作流的真实而健壮的模拟,存在许多要求。矢量化和事件驱动方法的区别说明了如何忠实地再现实际交易环境会增加显著的复杂性。

矢量化与事件驱动回测

矢量化回测是评估策略的最基本方式。它简单地将表示目标头寸大小的信号矢量与投资周期的收益矢量相乘,以计算期间的性能。

让我们使用前一章中使用岭回归创建的每日收益预测来说明矢量化方法。使用几个简单的技术因素,我们预测了下一个交易日的 100 支最近美元交易量最高的股票的收益(有关详细信息,请参见第七章,“线性模型 - 从风险因素到收益预测”)。

我们将把预测转换成一个非常简单的策略的信号:在任何给定的交易日,我们将对最高的 10 个正预测进行多头操作,并对最低的 10 个负预测进行空头操作。如果正预测或负预测较少,我们将持有较少的多头或空头头寸。笔记本vectorized_backtest包含以下代码示例,脚本data.py创建存储在backtest.h5中的输入数据。

首先,我们加载我们策略的数据,以及标普 500 价格(我们将其转换为每日收益)以基准来评估其表现。

sp500 = web.DataReader('SP500', 'fred', '2014', '2018').pct_change()
data = pd.read_hdf('00_data/backtest.h5', 'data')
data.info()
MultiIndex: 187758 entries, ('AAL', Timestamp('2014-12-09 00:00:00')) to ('ZTS', Timestamp('2017-11-30 00:00:00'))
Data columns (total 6 columns):
 #   Column     Non-Null Count   Dtype  
---  ------     --------------   -----  
 0   predicted  74044 non-null   float64
 1   open       187758 non-null  float64
 2   high       187758 non-null  float64
 3   low        187758 non-null  float64
 4   close      187758 non-null  float64
 5   volume     187758 non-null  float64 

数据结合了 2014-17 年期间 253 支不同股票的每日收益预测和 OHLCV 市场数据,每天有 100 支股票。现在,我们可以计算每日的前瞻性收益,并将这些收益和预测转换为宽格式,每一列一个股票:

daily_returns = data.open.unstack('ticker').sort_index().pct_change()
fwd_returns = daily_returns.shift(-1)
predictions = data.predicted.unstack('ticker') 

下一步是选择正预测和负预测,按降序和升序排序,并使用整数掩码创建多头和空头信号,该掩码在每一侧的前 10 个位置上标识出正预测和负预测,并将不在前 10 个位置上的预测标识为 1,零:

long_signals = (predictions.where(predictions>0).rank(axis=1, ascending=False) > 10).astype(int)
short_signals = (predictions.where(predictions<0).rank(axis=1) > 10).astype(int) 

我们随后可以将二进制数据框与前向收益相乘(对于做空,使用它们的负倒数),以获取每个头寸的每日表现,假设投资规模相同。这些回报的每日平均值对应于等权重的多头和空头投资组合的表现,总和反映了市场中性多空策略的整体回报:

long_returns = long_signals.mul(fwd_returns).mean(axis=1)
short_returns = short_signals.mul(-fwd_returns).mean(axis=1)
strategy = long_returns.add(short_returns).to_frame('strategy') 

当我们比较结果时,如图 8.2所示,我们的策略在前两年表现良好,与标普 500 指数相比—也就是说,在基准追赶上来并且我们的策略在 2017 年表现不佳之前。

与标普 500 相比,策略回报也不太波动,标准差为 0.002,而标普 500 的标准差为 0.008;相关性低且为负值-0.093:

图 8.2:向量化回测结果

虽然这种方法允许快速进行草稿评估,但它错过了强大、逼真和用户友好的回测引擎的重要特性;例如:

  • 我们需要手动对齐预测和回报的时间戳(使用 pandas 的内置功能),并且没有任何防止意外前瞻偏差的保护措施。

  • 也没有明确的头寸规模和交易过程的表示,该表示考虑了成本和其他市场现实,或者跟踪头寸及其绩效的会计系统。

  • 此外,除了我们事后计算的内容外,也没有任何绩效测量,而且像止损这样的风险管理规则很难模拟。

这就是事件驱动型回测的用武之地。事件驱动型回测引擎明确模拟了交易环境的时间维度,并对模拟施加了更多结构。这包括使用历史日历定义何时可以进行交易以及何时可以获得报价。时间戳的强制执行也有助于避免前瞻性偏差和先前部分提到的其他实施错误(但不能保证)。

一般来说,事件驱动型系统旨在更紧密地捕捉策略遇到的行动和约束,并且理想情况下可以轻松转化为提交实际订单的实时交易引擎。

关键的实施方面

对于逼真模拟的要求可能通过支持以端到端方式完成过程的单一平台,或者通过多个工具实现,每个工具专注于不同的方面而满足。

例如,您可以使用通用的 ML 库如 scikit-learn 或者本书中将遇到的其他库来处理生成信号的 ML 模型的设计和测试,并将模型输出馈送到单独的回测引擎中。或者,您可以在 Quantopian 或 QuantConnect 等单一平台上端到端运行整个 ML4T 工作流。

以下部分重点介绍需要解决以实施此过程的关键项目和实施细节。

数据摄入 - 格式、频率和时机

过程的第一步涉及数据来源。传统上,算法交易策略侧重于市场数据,即我们在 第二章市场和基本数据 - 来源和技术中讨论的 OHLCV 价格和成交量数据。今天,数据源更加多样化,并引发了关于支持多少种不同的存储格式和数据类型以及是否使用专有或自定义格式,还是依赖于第三方或开源格式的问题。

另一个方面是可以使用的数据源的频率以及是否可以合并不同频率的源。按计算复杂性和内存和存储需求的增加顺序,常见选项包括每日、分钟和刻度频率。中间频率也是可能的。尽管量化基金投资者正在崛起,正如在 第一章从想法到执行的交易机器学习中讨论的那样,算法策略在较高频率下往往表现更好。但无论如何,机构投资者肯定会要求刻度频率。

最后,数据摄入还应考虑时点约束,以避免前瞻性偏差,正如前一节所述。使用交易日历有助于限制数据在合法日期和时间内;需要在摄入之前进行调整,以反映像股票拆分和股息或在特定时间影响价格的重述等公司行为。

因子工程 - 内置因子与库

为了便于为 ML 模型工程化 Alpha 因子,许多回测引擎包括适用于许多标准转换的计算工具,例如移动平均线和各种技术指标。内置因子工程的一个关键优势是将回测流水线轻松转换为一个应用相同计算于输入数据的实时交易引擎。

第四章金融特征工程 - 如何研究 Alpha 因子中介绍的数字 Python 库(pandas、NumPy、TA-Lib)是预先计算因子的替代方案。当目标是在各种回测中重用因子以分摊计算成本时,这可能是有效的。

ML 模型、预测和信号

正如前面提到的,第六章机器学习流程中讨论的 ML 工作流可以嵌入到一个端到端平台中,该平台将模型设计和评估部分整合到回测过程中。虽然方便,但这也很昂贵,因为当目标可能是调整交易规则时,模型训练变成了回测的一部分。

类似于因子工程,您可以解耦这些方面,并使用专门用于此目的的通用库设计、训练和评估 ML 模型,并将相关预测提供为回测器的输入。我们在本书中大多采用这种方法,因为它使表达更简洁,减少了重复性。

交易规则和执行

一个真实的策略模拟需要忠实地表现交易环境。这包括访问相关交易所,可以使用第二章市场和基本数据 - 来源和技术中讨论的各种订单类型,以及考虑交易成本。成本包括经纪人佣金、买卖价差和滑点,使我们得到目标执行价格与最终获得价格之间的差异。还重要的是确保交易执行具有反映流动性和运营时间的延迟。

绩效评估

最后,回测平台需要方便的绩效评估。它可以提供从交易记录中派生的标准指标,或者提供可以与诸如pyfolio之类的库一起使用的指标输出,这是适用于此目的的库。

在接下来的两节中,我们将探讨两个最流行的回测库,即 backtrader 和 Zipline。

backtrader – 一个灵活的用于本地回测的工具

backtrader 是一个流行、灵活和用户友好的 Python 库,用于本地回测,拥有出色的文档,自 2015 年由 Daniel Rodriguez 开发。除了拥有庞大且活跃的个人交易者社区外,还有几家银行和交易公司使用 backtrader 来原型化和测试新策略,然后再将其移植到生产就绪平台,例如 Java。您还可以选择使用 backtrader 与多家经纪商进行实时交易(请参阅 backtrader 文档和第二十三章结论与下一步)。

我们将首先总结 backtrader 的关键概念,以澄清此平台上回测工作流程的大局,并展示其用于由 ML 预测驱动的策略的使用。

backtrader 的 Cerebro 架构的关键概念

backtrader 的Cerebro(西班牙语意为“大脑”)架构将回测工作流程的关键组件表示为(可扩展的)Python 对象。这些对象相互作用以促进处理输入数据和因子的计算,制定和执行策略,接收和执行订单,以及跟踪和测量绩效。一个 Cerebro 实例组织了从收集输入、逐根执行回测到提供结果的整个过程。

该库使用一些约定来简化这些交互,允许您省略一些细节并简化回测设置。如果您计划使用 backtrader 来开发自己的策略,我强烈建议浏览文档以深入了解。

图 8.3 概述了 Cerebro 架构中的关键元素,以下各小节总结了它们的最重要功能:

图 8.3:backtrader Cerebro 架构

数据 feed、线和指标

数据 feed 是策略的原材料,包含有关个别证券的信息,例如每个观察时间戳的 OHLCV 市场数据,但您可以自定义可用字段。backtrader 可以从各种来源摄取数据,包括 CSV 文件和 pandas DataFrames,以及来自 Yahoo Finance 等在线来源的数据。您还可以使用扩展连接到在线交易平台(如 Interactive Brokers)以摄取实时数据并执行交易。与 DataFrame 对象的兼容性意味着您可以从 pandas 可访问的地方加载数据,从数据库到 HDF5 文件都可以(参见 如何在实践中使用 backtrader 部分中的演示;还请参阅 pandas 文档中的 I/O 部分)。

加载完成后,我们将数据 feed 添加到一个 Cerebro 实例中,然后按接收顺序使其对一个或多个策略可用。您的策略交易逻辑可以通过名称(例如股票代码)或序列号访问每个数据 feed,并检索数据 feed 的当前值和过去值的任何字段。每个字段称为线

backtrader 自带超过 130 种常见的技术指标,允许您从线或其他指标为每个数据 feed 计算新值以驱动您的策略。您还可以使用标准的 Python 操作来派生新值。使用方法非常简单,并且在文档中有很好的解释。

从数据和信号到交易 - 策略

策略对象包含您的交易逻辑,根据 Cerebro 实例在每个 bar 的回测执行期间呈现的数据 feed 信息来下单。您可以通过配置策略来接受您在将策略实例添加到 Cerebro 时定义的任意参数来轻松测试变化。

对于回测的每个 bar,Cerebro 实例都会调用您的策略实例的 .prenext().next() 方法之一。.prenext() 的作用是处理尚未完整为所有 feed 提供数据的 bar,例如,在有足够的周期计算内置移动平均线之前,或者如果存在缺失数据。默认情况下不执行任何操作,但如果您的主策略设计为处理缺失值,可以添加您选择的交易逻辑或调用 next()(请参阅 如何在实践中使用 backtrader 部分)。

您也可以使用 backtrader 而无需定义明确的策略,而是使用简化的信号接口。尽管策略 API 给予您更多控制和灵活性,但请参阅 backtrader 文档以了解如何使用信号 API 的详细信息。

策略输出订单:让我们看看 backtrader 如何处理这些订单。

佣金而不是佣金方案

一旦您的策略在每个柱上评估了当前和过去的数据点,它需要决定下订单。backtrader 让你创建几种标准的订单类型,Cerebro 将其传递给经纪人实例执行,并在每个柱上提供结果的通知。

您可以使用策略方法buy()sell()来下市价、平仓和限价订单,以及止损和止损限价订单。执行如下:

  • 市价订单:在下一个开盘柱填充

  • 平仓订单:在下一根收盘柱填充

  • 限价订单:仅在满足价格阈值(例如,仅在某个有效期内的某个价格购买)时执行

  • 止损订单:如果价格达到给定阈值,则变为市价订单

  • 止损限价单:一旦触发止损,就变成限价单

在实践中,止损订单与限价订单不同,因为在价格触发之前市场看不到它们。backtrader 还提供了目标订单,根据当前仓位计算所需大小,以实现某种投资组合分配,例如股票数量、仓位价值或组合价值的百分比。此外,还有框架订单,它们为长订单组合了一个买入和两个限价卖出订单,在买入执行时激活。如果其中一个卖出订单成交或取消,另一个卖出订单也会取消。

经纪人负责订单执行,跟踪投资组合、现金价值和通知,并实施佣金和滑点等交易成本。如果现金不足,经纪人可能会拒绝交易;确保买卖的顺序以确保流动性可能很重要。backtrader 还有一个cheat_on_open功能,允许向后看到下一个柱,以避免由于下一个柱的不利价格变动而拒绝交易。当然,这个功能会使你的结果产生偏差。

除了像固定或百分比的绝对交易价值的佣金方案之外,您还可以像后面演示的那样实现自己的逻辑,按股票的固定费用。

让一切都发生 - 大脑

大脑控制系统根据它们的时间戳表示的柱同步数据源,并根据事件的基础运行交易逻辑和经纪人操作。backtrader 不对频率或交易日历施加任何限制,并且可以并行使用多个时间框架。

如果它可以预加载源数据,它还可以为指标的计算进行矢量化。有几个选项可用于从内存的角度优化操作(请参阅 Cerebro 文档以获取详细信息)。

如何在实践中使用 backtrader

我们将使用来自第七章线性模型 - 从风险因子到收益预测中岭回归的每日收益预测来演示 backtrader,就像我们在本章前面的向量化回测中所做的那样。我们将创建 Cerebro 实例,加载数据,制定和添加策略,运行回测,并审查结果。

笔记本backtesting_with_backtrader包含以下代码示例和一些额外的细节。

如何加载价格和其他数据

我们需要确保我们具有我们想要买入或卖出股票的所有日期的价格信息,而不仅仅是预测日的价格信息。要从 pandas DataFrame 加载数据,我们子类化 backtrader 的PandasData类以定义我们将提供的字段:

class SignalData(PandasData):
    """
    Define pandas DataFrame structure
    """
    cols = OHLCV + ['predicted']
    # create lines
    lines = tuple(cols)
    # define parameters
    params = {c: -1 for c in cols}
    params.update({'datetime': None})
    params = tuple(params.items()) 

然后,我们实例化一个Cerebro类,并使用SignalData类为我们从 HDF5 加载的数据集中的每个股票添加一个数据源:

cerebro = bt.Cerebro()  # create a "Cerebro" instance
idx = pd.IndexSlice
data = pd.read_hdf('00_data/backtest.h5', 'data').sort_index()
tickers = data.index.get_level_values(0).unique()
for ticker in tickers:
    df = data.loc[idx[ticker, :], :].droplevel('ticker', axis=0)
    df.index.name = 'datetime'
    bt_data = SignalData(dataname=df)
    cerebro.adddata(bt_data, name=ticker) 

现在,我们准备好定义我们的策略了。

如何制定交易逻辑

我们的MLStrategy子类化了 backtrader 的Strategy类,并定义了可用于修改其行为的参数。我们还创建了一个日志文件来记录交易:

class MLStrategy(bt.Strategy):
    params = (('n_positions', 10),
              ('min_positions', 5),
              ('verbose', False),
              ('log_file', 'backtest.csv'))
    def log(self, txt, dt=None):
        """ Logger for the strategy"""
        dt = dt or self.datas[0].datetime.datetime(0)
        with Path(self.p.log_file).open('a') as f:
            log_writer = csv.writer(f)
            log_writer.writerow([dt.isoformat()] + txt.split(',')) 

策略的核心位于.next()方法中。我们对最高正/最低负预测的n_position只进行多头/空头操作,只要至少有min_positions个仓位。我们始终卖出任何不在新多头和空头列表中的现有仓位,并使用order_target_percent在新目标中建立等权重仓位(为节省一些空间而省略了日志记录):

 def prenext(self):
        self.next()
    def next(self):
        today = self.datas[0].datetime.date()
        positions = [d._name for d, pos in self.getpositions().items() if pos]
        up, down = {}, {}
        missing = not_missing = 0
        for data in self.datas:
            if data.datetime.date() == today:
                if data.predicted[0] > 0:
                    up[data._name] = data.predicted[0]
                elif data.predicted[0] < 0:
                    down[data._name] = data.predicted[0]
        # sort dictionaries ascending/descending by value
        # returns list of tuples
        shorts = sorted(down, key=down.get)[:self.p.n_positions]
        longs = sorted(up, key=up.get, reverse=True)[:self.p.n_positions]
        n_shorts, n_longs = len(shorts), len(longs)
        # only take positions if at least min_n longs and shorts
        if n_shorts < self.p.min_positions or n_longs < self.p.min_positions:
            longs, shorts = [], []
        for ticker in positions:
            if ticker not in longs + shorts:
                self.order_target_percent(data=ticker, target=0)
         short_target = -1 / max(self.p.n_positions, n_short)
        long_target = 1 / max(self.p.top_positions, n_longs)
        for ticker in shorts:
            self.order_target_percent(data=ticker, target=short_target)
        for ticker in longs:
            self.order_target_percent(data=ticker, target=long_target) 

现在,我们需要配置我们的Cerebro实例并添加我们的Strategy

如何配置 Cerebro 实例

我们使用一个自定义的佣金方案,假设我们每买入或卖出一股就支付固定金额的$0.02:

class FixedCommisionScheme(bt.CommInfoBase):
    """
    Simple fixed commission scheme for demo
    """
    params = (
        ('commission', .02),
        ('stocklike', True),
        ('commtype', bt.CommInfoBase.COMM_FIXED),
    )
    def _getcommission(self, size, price, pseudoexec):
        return abs(size) * self.p.commission 

然后,我们定义我们的起始现金金额并相应地配置经纪人:

cash = 10000
cerebro.broker.setcash(cash)
comminfo = FixedCommisionScheme()
cerebro.broker.addcommissioninfo(comminfo) 

现在,唯一缺少的是将MLStrategy添加到我们的Cerebro实例中,提供所需仓位数和最小多头/空头仓位数的参数。我们还将添加一个 pyfolio 分析器,以便查看我们在第五章组合优化和绩效评估中呈现的绩效图表:

cerebro.addanalyzer(bt.analyzers.PyFolio, _name='pyfolio')
cerebro.addstrategy(MLStrategy, n_positions=10, min_positions=5, 
                    verbose=True, log_file='bt_log.csv')
results = cerebro.run()
ending_value = cerebro.broker.getvalue()
f'Final Portfolio Value: {ending_value:,.2f}'
Final Portfolio Value: 10,502.32 

回测使用了 869 个交易日,并花费大约 45 秒时间运行。以下图表显示了累积收益和组合价值的变化,以及多头和空头仓位的每日价值。

绩效看起来与前面的向量化测试有些相似,在前半段相对于标准普尔 500 指数的表现良好,在后半段表现不佳。

backtesting_with_backtrader笔记本包含完整的 pyfolio 结果:

图 8.4:backtrader 结果

backtrader 摘要和下一步

backtrader 是一个非常简单灵活且性能优异的本地回测引擎。由于与 pandas 兼容性,您可以从广泛的来源加载您所需频率的任何数据集。Strategy允许您定义任意的交易逻辑;您只需确保按需访问不同的数据源。它还与 pyfolio 集成良好,以进行快速而全面的绩效评估。

在演示中,我们将我们的交易逻辑应用于预训练模型的预测结果。我们也可以在回测过程中训练模型,因为我们可以访问当前柱之前的数据。然而,通常情况下,将模型训练与策略选择解耦并避免重复模型训练更加高效。

backtrader 之所以受欢迎的一个原因是可以将其用于与所选经纪人的实时交易。社区非常活跃,并且用于连接经纪人或其他数据源(包括加密货币)的代码可以在网上轻松找到。

Zipline – Quantopian 的可扩展回测

回测引擎 Zipline 支持 Quantopian 的在线研究、回测和实时(模拟)交易平台。作为一家对冲基金,Quantopian 的目标是确定符合其风险管理标准的稳健算法。为此,他们使用竞赛选择最佳策略,并分配资本与赢家分享利润。

Quantopian 于 2012 年首次发布了 Zipline,版本为 0.5,最新版本为 1.3,日期为 2018 年 7 月。Zipline 与其姊妹库 Alphalens、pyfolio 和 empyrical 配合良好,我们在第四章金融特征工程 – 如何研究 Alpha 因子第五章组合优化与绩效评估中介绍了这些库,与 NumPy、pandas 和数值库集成良好,但不一定始终支持最新版本。

Zipline 的设计是为了在数千个证券的规模下运作,并且每个证券可以关联大量的指标。它在回测过程中比 backtrader 更加结构化,以确保数据质量,例如消除前视偏差,并在执行回测时优化计算效率。我们将在图 8.5中展示的主要概念和架构元素之前,介绍 Zipline 的使用方法,以便在所选数据上回测基于 ML 的模型。

用于稳健模拟的日历和 Pipeline

有助于实现可扩展性和可靠性目标的关键特性包括数据捆绑包,它存储带有即时调整的 OHLCV 市场数据,用于拆分和股息,反映全球交易所的营业时间的交易日历,以及强大的 Pipeline API(见下图)。我们将在接下来的章节中讨论它们的使用方法,以补充我们在早期章节中对 Zipline 的简要介绍:

图 8.5:Zipline 架构

捆绑包 - 即时数据与即时调整

主要数据存储是一个捆绑包,以压缩的、列式的 bcolz 格式存储在磁盘上,以便高效检索,并与存储在 SQLite 数据库中的元数据相结合。捆绑包设计为仅包含 OHLCV 数据,并且限制为每日和每分钟的频率。一个很棒的特性是捆绑包存储了分割和分红信息,而 Zipline 根据你选择的回测时间段计算即时调整

Zipline 依赖于 TradingCalendar 库(也由 Quantopian 维护)来获取世界各地交易所的运营细节,如时区、开盘和收盘时间或假期。数据来源具有域(目前为国家),需要符合指定的交易所日历。Quantopian 正在积极开发对国际证券的支持,这些功能可能会不断演变。

安装完成后,命令 zipline ingest -b bundle 可让你立即安装 Quandl Wiki 数据集(每日频率)。结果存储在.zipline 目录中,默认位于你的主目录中。此外,你还可以设计自己的捆绑包,我们会看到。

除了捆绑包外,你还可以将 OHCLV 数据作为 pandas DataFrame 或 Panel 提供给算法。(Panel 最近已弃用,但 Zipline 落后几个 pandas 版本。)然而,捆绑包更加方便和高效。

捆绑包的一个缺点是它们不允许你存储除价格和成交量信息之外的数据。然而,有两种替代方法可以实现这一点:fetch_csv() 函数从 URL 下载 DataFrame,并设计用于其他 Quandl 数据来源,例如基本面数据。Zipline 合理地期望数据与你提供的 OHCLV 数据引用相同的证券,并相应地对齐柱。通过使用 pandas 加载本地 CSV 或 HDF5 很容易修补该库,GitHub 仓库提供了一些如何执行此操作的指导。

此外,DataFrameLoaderBlazeLoader 允许你向管道提供额外的属性(请参阅本章后面的 DataFrameLoader 演示)。BlazeLoader 可以与多种来源进行接口,包括数据库。然而,由于 Pipeline API 仅限于每日数据,fetch_csv() 将是关键,以在后续章节中添加分钟频率的功能。

算法 API - 按计划进行回测

TradingAlgorithm 类实现了 Zipline 算法 API,并在与给定交易日历对齐的 BarData 上运行。在初始设置之后,回测将在指定的期间运行,并在特定事件发生时执行其交易逻辑。这些事件由每日或每分钟的交易频率驱动,但你也可以安排任意函数来评估信号、下订单和重新平衡你的投资组合,或者记录有关进行中模拟的信息。

您可以通过命令行,在 Jupyter Notebook 中,或者使用底层 TradingAlgorithm 类的 run_algorithm() 方法执行算法。该算法需要一个 initialize() 方法,在模拟开始时调用一次。它通过上下文字典保持状态,并通过包含即时当前和历史数据的数据变量接收可操作信息。

您可以向上下文字典添加属性,该字典对所有其他 TradingAlgorithm 方法可用,或者注册执行更复杂数据处理的管道,例如计算 alpha 因子并相应地筛选证券。

算法执行通过 Zipline 自动安排的可选方法或用户定义的间隔发生。每天市场开盘前调用 before_trading_start() 方法,并主要用于识别算法可能在当天交易的一组证券。 handle_data() 方法以给定的交易频率调用,例如,每分钟。

完成后,如果有任何交易,算法将返回包含投资组合绩效指标的 DataFrame,以及用户定义的指标。正如在第五章, 投资组合优化和绩效评估中所演示的,输出与 pyfolio 兼容,因此您可以快速创建绩效表。

已知问题

目前,Zipline 要求 Treasury 曲线和标普 500 指数收益率用于基准测试 (github.com/quantopian/zipline/issues/2480)。后者依赖于 IEX API,现在需要注册才能获取密钥。很容易修补 Zipline 以规避此问题,并从联邦储备局下载数据,例如。GitHub 存储库描述了如何操作。另外,您可以将 zipline/resources/market_data/SPY_benchmark.csv 中提供的 SPY 收益移动到您的 .zipline 文件夹中,该文件夹通常位于您的主目录中,除非您更改了其位置。

实时交易 (github.com/zipline-live/zipline) 您自己的系统仅适用于 Interactive Brokers,并且并不受 Quantopian 完全支持。

使用分钟数据摄取您自己的 bundle

我们将使用 AlgoSeek 提供的 NASDAQ100 2013-17 样本,该样本在第二章, 市场和基本数据 - 来源和技术 中介绍,来演示如何编写自己的自定义 bundle。有四个步骤:

  1. 将您的 OHCLV 数据分成一个文件 per ticker,并存储元数据以及拆分和股息调整。

  2. 编写一个脚本将结果传递给一个 ingest() 函数,该函数依次负责将 bundle 写入 bcolz 和 SQLite 格式。

  3. 在位于您主目录的 .zipline 目录中的 extension.py 脚本中注册 bundle,并为数据源创建符号链接。

  4. 对于 AlgoSeek 数据,我们还提供了自定义 TradingCalendar,因为它包括了纽约证券交易所市场小时之外的交易活动。

目录custom_bundles包含了本节的代码示例。

准备数据以进行捆绑

第二章,市场和基本数据 - 来源和技术中,我们解析了包含 AlgoSeek 纳斯达克 100 OHLCV 数据的每日文件,以获取每个股票的时间序列。我们将使用这个结果,因为 Zipline 也单独存储每个证券。

另外,我们使用 pandas DataReader 的get_nasdaq_symbols()函数获取股票的元数据。最后,由于 Quandl Wiki 数据涵盖了相关时期的纳斯达克 100 个股票代码,我们从该包的 SQLite 数据库中提取拆分和股利调整。

结果是一个包含约 135 个股票价格和交易量数据以及相应的元数据和调整数据的 HDF5 存储。脚本algoseek_preprocessing.py说明了这个过程。

撰写您的自定义捆绑ingest函数

Zipline 文档概述了ingest()函数的必需参数,该函数启动 I/O 过程,但没有提供太多实际细节。脚本algoseek_1min_trades.py展示了如何使这部分适用于分钟数据。

有一个load_equities()函数提供元数据,一个ticker_generator()函数将符号提供给data_generator(),后者依次加载和格式化每个符号的市场数据,并且一个algoseek_to_bundle()函数,它集成了所有这些部分并返回所需的ingest()函数。

时区对齐很重要,因为 Zipline 将所有数据系列转换为 UTC;我们为 OHCLV 数据添加了 US/Eastern 时区信息并将其转换为 UTC。为了方便执行,我们在.zipline目录中的custom_data文件夹中为该脚本和algoseek.h5数据创建了符号链接,我们将在下一步中将其添加到PATH中,以便 Zipline 可以找到这些信息。

注册您的捆绑

在我们可以运行zipline ingest -b algoseek之前,我们需要注册我们的自定义捆绑,以便 Zipline 知道我们在说什么。为此,我们将在.zipline文件中的extension.py脚本中添加以下行,您可能首先需要创建它,以及一些输入和设置(请参阅extension.py示例)。

注册本身相当简单,但强调了一些重要细节。首先,Zipline 需要能够导入algoseek_to_bundle()函数,因此其位置需要在搜索路径上,例如,通过使用sys.path.append()。其次,我们引用了一个自定义日历,我们将在下一步中创建和注册。第三,我们需要通知 Zipline 我们的交易日比默认的 NYSE 交易日的 6 小时 30 分钟要长,以避免错位:

register('algoseek',
        algoseek_to_bundle(),
        calendar_name='AlgoSeek',
        minutes_per_day=960
        ) 

创建并注册自定义 TradingCalendar

正如之前提到的,Quantopian 还提供了一个 TradingCalendar 库来支持全球交易。该包包含大量示例,并且相当容易从这些示例中派生出一个。基于 NYSE 日历,我们只需要重写开盘/收盘时间并更改名称:

class AlgoSeekCalendar(XNYSExchangeCalendar):
    """
    A calendar for trading assets before and after market hours
    Open Time: 4AM, US/Eastern
    Close Time: 19:59PM, US/Eastern
    """
    @property
    def name(self):
        return "AlgoSeek"
    @property
    def open_time(self):
        return time(4, 0)
    @property
    def close_time(self):
        return time(19, 59) 

我们将定义放入 extension.py 中,并添加以下注册:

register_calendar(
        'AlgoSeek',
        AlgoSeekCalendar()) 

现在,我们可以参考此交易日历以确保回测包含非市场小时的活动。

Pipeline API – 对 ML 信号进行回测

Pipeline API 促进了从历史数据中为一组证券定义和计算 Alpha 因子。Pipeline 显著提高了效率,因为它优化了整个回测期间的计算,而不是分别处理每个事件。换句话说,它仍然遵循事件驱动的架构,但在可能的情况下对因子的计算进行了矢量化。

Pipeline 使用 factors、filters 和 classifiers 类来定义为一组证券生成表中列的计算,该表具有一组证券的点值历史数据。Factors 接受一个或多个历史条数据输入数组,并为每个证券产生一个或多个输出。有许多内置因子,您还可以设计自己的 CustomFactor 计算。

以下图表显示了如何使用 DataFrameLoader 加载数据,使用 Pipeline API 计算预测的 MLSignal,以及各种预定活动如何与通过 run_algorithm() 函数执行的整体交易算法集成。我们将在本节中详细介绍细节和相应的代码:

图 8.6:使用 Zipline 的 Pipeline API 进行 ML 信号回测

您需要使用 initialize() 方法注册您的 Pipeline,并在每个时间步或自定义时间表上执行它。Zipline 提供了许多内置的计算,例如移动平均线或布林带,可以用于快速计算标准因子,但它也允许创建自定义因子,我们将在接下来进行说明。

最重要的是,Pipeline API 使 Alpha 因子研究模块化,因为它将 Alpha 因子的计算与算法的其余部分(包括交易订单的放置和执行以及投资组合持仓、价值等的簿记)分开。

现在我们将说明如何将 lasso 模型的每日回报预测与我们的投资组合中的价格数据一起加载到 Pipeline 中,并使用 CustomFactor 来选择前 10 个和后 10 个预测作为做多和做空头寸。笔记本 backtesting_with_zipline 包含以下代码示例。

我们的目标是将每日收益预测与我们的 Quandl 捆绑的 OHCLV 数据结合起来,然后在预测收益最高的 10 支股票上做多,而在预测收益最低的股票上做空,每边至少需要五只股票,类似于上面的 backtrader 示例。

为我们的 Pipeline 启用 DataFrameLoader

首先,我们加载我们对 2015-17 期间的预测,并使用 bundle.asset_finder.lookup_symbols() 方法提取此期间我们宇宙中 ~250 支股票的 Zipline ID,如下代码所示:

def load_predictions(bundle):
    predictions = pd.read_hdf('../00_data/backtest.h5', 'data')[['predicted']].dropna()
    tickers = predictions.index.get_level_values(0).unique().tolist()
    assets = bundle.asset_finder.lookup_symbols(tickers, as_of_date=None)
    predicted_sids = pd.Int64Index([asset.sid for asset in assets])
    ticker_map = dict(zip(tickers, predicted_sids))
    return (predictions
            .unstack('ticker')
            .rename(columns=ticker_map)
            .predicted
            .tz_localize('UTC')), assets
bundle_data = bundles.load('quandl')
predictions, assets = load_predictions(bundle_data) 

为了使预测结果可供 Pipeline API 使用,我们需要定义一个具有适当数据类型的 Column,用于具有适当 domainDataSet,如下所示:

class SignalData(DataSet):
    predictions = Column(dtype=float)
    domain = US_EQUITIES 

虽然捆绑的 OHLCV 数据可以依赖于内置的 USEquityPricingLoader,但我们需要定义自己的 DataFrameLoader,如下所示:

signal_loader = {SignalData.predictions:
                     DataFrameLoader(SignalData.predictions, predictions)} 

实际上,我们需要稍微修改 Zipline 库的源代码,以避免假设我们只加载价格数据。为此,我们向 run_algorithm 方法添加一个 custom_loader 参数,并确保在 pipeline 需要 SignalData 的 Column 实例时使用此加载器。

创建具有自定义 ML 因子的 pipeline

我们的 pipeline 将有两个布尔列,用于识别我们想交易的资产作为多头和空头头寸。为了达到这个目标,我们首先定义一个名为 MLSignalCustomFactor,它只接收当前的收益预测。动机是允许我们使用一些方便的 Factor 方法来对证券进行排名和筛选:

class MLSignal(CustomFactor):
    """Converting signals to Factor
        so we can rank and filter in Pipeline"""
    inputs = [SignalData.predictions]
    window_length = 1
    def compute(self, today, assets, out, preds):
        out[:] = preds 

现在,我们可以通过实例化 CustomFactor 来设置我们的实际 pipeline,它除了提供的默认参数外不需要任何参数。我们将其 top()bottom() 方法与过滤器结合起来选择最高的正预测和最低的负预测:

def compute_signals():
    signals = MLSignal()
    return Pipeline(columns={
        'longs' : signals.top(N_LONGS, mask=signals > 0),
        'shorts': signals.bottom(N_SHORTS, mask=signals < 0)},
            screen=StaticAssets(assets)) 

下一步是通过定义一些上下文变量、设置交易成本参数、执行时间表再平衡和日志记录,并附加我们的 pipeline 来初始化我们的算法:

def initialize(context):
    """
    Called once at the start of the algorithm.
    """
    context.n_longs = N_LONGS
    context.n_shorts = N_SHORTS
    context.min_positions = MIN_POSITIONS
    context.universe = assets
    set_slippage(slippage.FixedSlippage(spread=0.00))
    set_commission(commission.PerShare(cost=0, min_trade_cost=0))
    schedule_function(rebalance,
                      date_rules.every_day(),
                      time_rules.market_open(hours=1, minutes=30))
    schedule_function(record_vars,
                      date_rules.every_day(),
                      time_rules.market_close())
    pipeline = compute_signals()
    attach_pipeline(pipeline, 'signals') 

每天在市场开盘前,我们都会运行我们的 Pipeline 来获取最新的预测:

def before_trading_start(context, data):
    """
    Called every day before market open.
    """
    output = pipeline_output('signals')
    context.trades = (output['longs'].astype(int)
                      .append(output['shorts'].astype(int).mul(-1))
                      .reset_index()
                      .drop_duplicates()
                      .set_index('index')
                      .squeeze()) 

市场开盘后,我们为我们的多头和空头目标下订单,并关闭所有其他头寸:

def rebalance(context, data):
    """
    Execute orders according to schedule_function() date & time rules.
    """
    trades = defaultdict(list)
    for stock, trade in context.trades.items():
        if not trade:
            order_target(stock, 0)
        else:
            trades[trade].append(stock)
    context.longs, context.shorts = len(trades[1]), len(trades[-1])
    if context.longs > context.min_positions and context.shorts > context.min_positions:
        for stock in trades[-1]:
            order_target_percent(stock, -1 / context.shorts)
        for stock in trades[1]:
            order_target_percent(stock, 1 / context.longs) 

现在,我们已经准备好执行我们的回测并将结果传递给 pyfolio:

results = run_algorithm(start=start_date,
                       end=end_date,
                       initialize=initialize,
                       before_trading_start=before_trading_start,
                       capital_base=1e6,
                       data_frequency='daily',
                       bundle='quandl',
                       custom_loader=signal_loader) # need to modify zipline
returns, positions, transactions = pf.utils.extract_rets_pos_txn_from_zipline(results) 

图 8.7 显示了策略累积收益(左侧面板)和滚动夏普比率的图表,这与之前的 backtrader 示例可比。

回测只需大约一半的时间:

图 8.7:Zipline 回测结果

笔记本 backtesting_with_zipline 包含了完整的 pyfolio tearsheet,包括额外的指标和图表。

如何在回测期间训练模型

我们还可以将模型训练集成到我们的回测中。您可以在 ml4t_with_zipline 笔记本中找到我们 ML4T 工作流程的以下端到端示例的代码:

图 8.8:带有模型训练的 Zipline 回测流程图

目标是大致复制我们之前使用的岭回归每日收益预测,并在第七章线性模型 - 从风险因子到收益预测中生成的。 但是,我们将使用一些额外的管道因子来说明它们的用法。 主要的新元素是一个CustomFactor,它接收特征并将它们作为输入用于训练模型并生成预测。

准备特征 - 如何定义管道因子

要创建一个管道因子,我们需要一个或多个输入变量,一个指示每个输入和证券的最新数据点数量的window_length,以及我们想要执行的计算。

我们使用线性回归估计的线性价格趋势(见第七章线性模型 - 从风险因子到收益预测)的工作方式如下:我们使用最新的 252 个收盘价格来计算线性时间趋势的回归系数:

class Trendline(CustomFactor):
    # linear 12-month price trend regression
    inputs = [USEquityPricing.close]
    window_length = 252
    def compute(self, today, assets, out, close):
        X = np.arange(self.window_length).reshape(-1, 1).astype(float)
        X -= X.mean()
        Y = close - np.nanmean(close, axis=0)
        out[:] = (X.T @ Y / np.var(X)) / self.window_length 

我们将使用 10 个自定义和内置因子作为我们模型的特征,以捕获动量和波动性等风险因子(有关详情,请参见笔记本 ml4t_with_zipline)。 接下来,我们将设计一个CustomFactor来训练我们的模型。

如何设计自定义 ML 因子

我们的CustomFactor,称为ML,将具有StandardScaler和随机梯度下降的岭回归SGD)实现作为实例属性,并且我们将每周训练 3 天:

class LinearModel(CustomFactor):
    """Obtain model predictions"""
    train_on_weekday = [0, 2, 4]
    def __init__(self, *args, **kwargs):
        super().__init__(self, *args, **kwargs)
        self._scaler = StandardScaler()
        self._model = SGDRegressor(penalty='L2')
        self._trained = False 

compute方法生成预测(解决潜在的缺失值),但首先检查是否应该训练模型:

 def _maybe_train_model(self, today, returns, inputs):
        if (today.weekday() in self.train_on_weekday) or not self._trained:
            self._train_model(today, returns, inputs)
    def compute(self, today, assets, out, returns, *inputs):
        self._maybe_train_model(today, returns, inputs)
        # Predict most recent feature values
        X = np.dstack(inputs)[-1]
        missing = np.any(np.isnan(X), axis=1)
        X[missing, :] = 0
        X = self._scaler.transform(X)
        preds = self._model.predict(X)
        out[:] = np.where(missing, np.nan, preds) 

_train_model方法是这个谜题的核心。 它移动回报并将结果的向前回报与因子特征对齐,同时在过程中删除缺失值。 它将剩余的数据点进行缩放,并训练线性SGDRegressor

 def _train_model(self, today, returns, inputs):
        scaler = self._scaler
        model = self._model
        shift_by = N_FORWARD_DAYS + 1
        outcome = returns[shift_by:].flatten()
        features = np.dstack(inputs)[:-shift_by]
        n_days, n_stocks, n_features = features.shape
        features = features.reshape(-1, n_features)
        features = features[~np.isnan(outcome)]
        outcome = outcome[~np.isnan(outcome)]
        outcome = outcome[np.all(~np.isnan(features), axis=1)]
        features = features[np.all(~np.isnan(features), axis=1)]
        features = scaler.fit_transform(features)
        model.fit(X=features, y=outcome)
        self._trained = True 

make_ml_pipeline()函数预处理并将结果、特征和模型部分合并为一个具有预测列的管道:

def make_ml_pipeline(universe, window_length=21, n_forward_days=5):
    pipeline_columns = OrderedDict()
    # ensure that returns is the first input
    pipeline_columns['Returns'] = Returns(inputs=[USEquityPricing.open],
                                          mask=universe,
                                          window_length=n_forward_days + 1)
    # convert factors to ranks; append to pipeline
    pipeline_columns.update({k: v.rank(mask=universe)
                             for k, v in features.items()})
    # Create ML pipeline factor.
    # window_length = length of the training period
    pipeline_columns['predictions'] = LinearModel(
        inputs=pipeline_columns.values(),
        window_length=window_length + n_forward_days,
        mask=universe)
    return Pipeline(screen=universe, columns=pipeline_columns) 

在回测期间跟踪模型性能

我们使用before_trading_start()函数获取新的预测,该函数在市场开盘前每天早晨运行:

def before_trading_start(context, data):
    output = pipeline_output('ml_model')
    context.predicted_returns = output['predictions']
    context.predicted_returns.index.rename(['date', 'equity'], inplace=True)
    evaluate_predictions(output, context) 

evaluate_predictions确切地做到了这一点:它跟踪我们模型过去的预测,并在相关的时间范围内实现收益时对其进行评估(在我们的示例中,是第二天):

def evaluate_predictions(output, context):
    # Look at past predictions to evaluate model performance out-of-sample
    # A day has passed, shift days and drop old ones
    context.past_predictions = {
        k - 1: v for k, v in context.past_predictions.items() if k > 0}
    if 0 in context.past_predictions:
        # Use today's forward returns to evaluate predictions
        returns, predictions = (output['Returns'].dropna()
                                .align(context.past_predictions[0].dropna(),
                                       join='inner'))
        if len(returns) > 0 and len(predictions) > 0:
            context.ic = spearmanr(returns, predictions)[0]
            context.rmse = np.sqrt(
                mean_squared_error(returns, predictions))
            context.mae = mean_absolute_error(returns, predictions)
            long_rets = returns[predictions > 0].mean()
            short_rets = returns[predictions < 0].mean()
            context.returns_spread_bps = (
                long_rets - short_rets) * 10000
    # Store current predictions
    context.past_predictions[N_FORWARD_DAYS] = context.predicted_returns 

我们还每天记录评估结果,以便在回测后进行审查:

图 8.9:模型外样本性能

以下图表总结了回测性能,以累积收益和滚动 SR 为指标。 与之前的示例相比,结果有所改善(由于不同的特征集),但自 2016 年中期以来,模型仍然表现不佳:

图 8.10:使用模型训练的 Zipline 回测性能

请查看笔记本,了解我们如何定义宇宙、运行回测、以及使用 pyfolio 重新平衡和分析结果的附加细节。

而不是如何使用

笔记本 ml4t_quantopian 包含了一个示例,展示了如何在 Quantopian 研究环境中使用简单的 ML 模型进行回测策略。在 Quantopian 云中使用 Zipline 的关键好处是可以访问许多额外的数据集,包括基本数据和替代数据。请查看笔记本,了解我们可以在这种情况下导出的各种因素的更多详细信息。

总结

在本章中,我们更加深入地了解了回测的工作原理,存在哪些挑战,以及如何管理它们。我们演示了如何使用两个流行的回测库,backtrader 和 Zipline。

然而,最重要的是,我们走过了设计和测试 ML 模型的端到端流程,向你展示了如何实现根据模型预测提供的信号执行交易逻辑,并且看到了如何进行和评估回测。现在,我们已经准备好继续探索比我们最初开始的线性回归更广泛和更复杂的 ML 模型阵列。

下一章将介绍如何将时间维度纳入我们的模型中。

第九章:用于波动率预测和统计套利的时间序列模型

第七章线性模型 - 从风险因素到资产收益预测中,我们介绍了用于推断和预测的线性模型,从静态模型开始,考虑具有即时影响的横截面输入的同时关系。我们介绍了普通最小二乘法OLS)学习算法,并且发现它对于正确指定的模型产生无偏系数,且残差与输入变量不相关。假设残差具有恒定方差,可以保证 OLS 在无偏估计器中产生最小的均方预测误差。

我们还遇到了既有横截面又有时间序列维度的面板数据,当我们学习如何使用 Fama-Macbeth 回归来估计随时间和跨资产的风险因素的价值时。然而,随着时间的推移,收益之间的关系通常相当低,因此这个过程可能会大部分忽略时间维度。

此外,我们介绍了正则化的岭回归和套索回归模型,它们产生了偏差系数估计,但可以减少均方预测误差。这些预测模型采用了更加动态的视角,将历史收益与其他输入结合起来,以预测未来的回报。

在本章中,我们将建立动态线性模型,以明确表示时间,并包括在特定间隔或滞后观察到的变量。时间序列数据的一个关键特征是它们的顺序:与横截面数据中的个体观测随机样本不同,我们的数据是一个无法重复的随机过程的单个实现。

我们的目标是识别时间序列中的系统模式,以帮助我们预测时间序列在未来的行为。更具体地说,我们将专注于从输出的历史序列中提取信号的模型,并且可以选择其他同时或滞后的输入变量来预测输出的未来值。例如,我们可以尝试使用过去的回报,结合基准或宏观经济变量的历史回报,来预测股票的未来回报。在转向第四部分之前,我们将专注于线性时间序列模型,然后转向非线性模型,如循环或卷积神经网络。

时间序列模型在交易中具有内在的时间维度,因此非常受欢迎。主要应用包括资产收益和波动率的预测,以及资产价格序列的共同波动的识别。随着越来越多连接设备收集具有潜在信号内容的定期测量,时间序列数据可能会变得更加普遍。

我们首先介绍可以用来诊断时间序列特征和提取捕捉潜在模式的特征的工具。然后,我们将介绍如何诊断和实现时间序列平稳性。接下来,我们将介绍单变量和多变量时间序列模型,并将其应用于预测宏观数据和波动性模式。最后,我们将介绍协整概念以及如何将其应用于开发配对交易策略。

具体来说,我们将涵盖以下主题:

  • 如何使用时间序列分析来准备和指导建模过程

  • 估计和诊断单变量自回归和移动平均模型

  • 构建自回归条件异方差ARCH)模型以预测波动性

  • 如何构建多元向量自回归模型

  • 使用协整来开发配对交易策略

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和其他资源链接。这些笔记本包含了图片的彩色版本。关于本章主题的投资角度的全面介绍,请参阅 Tsay(2005 年)和 Fabozzi、Focardi 和 Kolm(2010 年)。

诊断和特征提取工具

时间序列是一系列以离散间隔分隔的值,通常是均匀间隔的(除了缺失值)。时间序列通常被建模为一个随机过程,由一组随机变量,,组成,每个时间点有一个变量,。单变量时间序列由每个时间点上的单个值y组成,而多变量时间序列由可以用向量表示的多个观察值组成。

两个不同时间点t[i]、t[j]之间的周期数,,称为滞后,每个时间序列有T-1 个不同的滞后。正如交叉模型中不同变量在给定时间点的关系对于横截面模型至关重要一样,分隔给定滞后的数据点之间的关系对于分析和利用时间序列中的模式至关重要。

对于横截面模型,我们用标签yx来区分输入和输出变量,或目标和预测变量。在时间序列的情况下,结果y的一些或所有滞后值扮演横截面情况下输入或x值的角色。

如果一个时间序列是独立同分布IID)的随机变量序列,且具有有限均值和方差,则称其为白噪声。特别地,如果这个序列的随机变量服从均值为零、方差为常数的正态分布,则称其为高斯白噪声

如果时间序列可以写成过去扰动的加权和,,也称为创新,并且在此假定为代表白噪声的扰动序列的平均值,那么时间序列是线性的:

时间序列分析的一个关键目标是了解由系数驱动的动态行为,。时间序列分析提供了针对这种类型数据量身定制的方法,目的是提取有用的模式,进而帮助我们建立预测模型。

我们将介绍实现这一目的的最重要工具,包括分解为关键的系统元素、自相关分析以及滚动窗口统计,如移动平均值。

对于本章中的大多数示例,我们将使用联邦储备提供的数据,您可以使用我们在 第二章市场和基本数据-来源和技术 中介绍的 pandas-datareader 访问。本节的代码示例在笔记本 tsa_and_stationarity 中可用。

如何分解时间序列模式

时间序列数据通常包含多种模式,可以将其分解为几个组件。特别是,时间序列通常将趋势、季节性和周期性等系统性组件与非系统性噪声结合在一起。这些组件可以建模为线性组合(例如,当波动不依赖于系列水平时)或非线性乘法形式。

根据模型假设,它们也可以自动拆分。Statsmodels 包括一种简单的方法,使用移动平均值将时间序列拆分为单独的趋势、季节性和残差组件。我们可以将其应用于包含强趋势组件和季节性组件的月度工业制造数据,如下所示:

import statsmodels.tsa.api as tsa
industrial_production = web.DataReader('IPGMFN', 'fred', '1988', '2017-12').squeeze()
components = tsa.seasonal_decompose(industrial_production, model='additive')
ts = (industrial_production.to_frame('Original')
      .assign(Trend=components.trend)
      .assign(Seasonality=components.seasonal)
      .assign(Residual=components.resid))
ts.plot(subplots=True, figsize=(14, 8)); 

图 9.1 显示了显示加法组件的结果图表。残差组件将成为后续建模工作的重点,假设趋势和季节性组件更具确定性并且更易于简单外推:

图 9.1:时间序列分解为趋势、季节性和残差

还有更复杂的基于模型的方法,例如 Hyndman 和 Athanasopoulos(2018)中的 第六章机器学习过程

滚动窗口统计和移动平均线

鉴于时间序列数据的顺序排列,自然而然地可以计算给定长度期间的熟悉描述性统计。目标是检测系列是否稳定或随时间变化,并获得捕获系统方面的平滑表示,同时过滤噪声。

滚动窗口统计信息服务于此过程:它们生成一个新的时间序列,其中每个数据点代表原始数据的一定期间内的摘要统计量。移动平均是最熟悉的例子。原始数据点可以以相等的权重进入计算,也可以,例如,强调更近期的数据点。指数移动平均递归地计算权重,这些权重对于更早的数据点衰减。新数据点通常是所有先前数据点的摘要,但也可以从周围的窗口计算得出。

Pandas 库包括滚动或扩展窗口,并允许使用各种权重分布。在第二步中,您可以对每个窗口捕获的数据集应用计算。这些计算包括用于单个系列的内置函数,如均值或总和,以及用于多个系列的相关性或协方差,以及用户定义的函数。

我们在第四章金融特征工程 - 如何研究阿尔法因子第七章线性模型 - 从风险因素到收益预测中使用了这个功能来设计特征。例如,下一节中的移动平均和指数平滑示例也将应用这些工具。

早期的预测模型包括带有指数权重的移动平均模型,称为指数平滑模型。我们将再次遇到移动平均作为线性时间序列的关键构建模块。依赖指数平滑方法的预测使用过去观察值的加权平均值,其中权重随着观察值变老而指数衰减。因此,较近期的观察值会获得较高的相关权重。这些方法在时间序列没有非常复杂或突然的模式时很受欢迎。

如何测量自相关

自相关(也称为串行相关)将相关性的概念应用到时间序列的背景下:就像相关系数衡量两个变量之间线性关系的强度一样,自相关系数,衡量了相隔给定滞后时间k的时间序列值之间的线性关系的程度:

因此,我们可以计算时间序列的T-1 个滞后期中的每个自相关系数。自相关函数ACF)将相关系数作为滞后期的函数计算。

超过 1 个滞后(即,两个观察值相隔超过一个时间步长)的自相关反映了这些观察值之间的直接相关性以及介于其间数据点的间接影响。偏自相关消除了这种影响,仅测量给定滞后距离T处数据点之间的线性依赖关系。消除意味着使用线性回归的残差,其中结果x[t]和滞后值x[t][-1]、x[t][-2],…,x[T][-1]作为特征(也称为AR(T-1)模型,我们将在下一节关于单变量时间序列模型中讨论此模型)。偏自相关函数PACF)提供了一旦较短滞后的相关性效应被消除后所得到的所有相关性,如前述所述。

还有一些算法根据样本自相关来估计偏自相关,这些算法基于 PACF 和 ACF 之间的精确理论关系。

自相关图简单地是顺序滞后k=0,1,...,n的 ACF 或 PACF 的图。它允许我们一眼看出不同滞后的相关结构(有关示例,请参见图 9.3)。自相关图的主要用途是在去除确定性趋势或季节性后检测任何自相关性。ACF 和 PACF 都是线性时间序列模型设计的关键诊断工具,我们将在下一节关于时间序列变换的示例中回顾 ACF 和 PACF 图。

如何诊断和实现平稳性

平稳时间序列的统计特性,如均值、方差或自相关,与周期无关—即,它们随时间不变。因此,平稳性意味着时间序列没有趋势或季节效应。此外,它要求在不同滚动窗口计算的描述性统计量,如均值或标准差,是恒定的或随时间变化不显著。平稳时间序列会回归到其均值,并且偏差具有恒定的振幅,而短期波动在统计意义上始终是相同的。

更正式地说,严格平稳性要求时间序列观测的任何子集的联合分布在所有时刻上都与时间无关。因此,除了均值和方差外,高阶矩如偏度和峰度也需要保持恒定,而不受不同观测值之间滞后的影响。在大多数应用中,例如本章中大多数可用于建模资产收益率的时间序列模型中,我们将平稳性限制在一阶和二阶矩上,使得时间序列具有恒定的均值、方差和自相关性。然而,在构建建模波动率时,我们放弃了这一假设,并明确假设方差会以可预测的方式随时间变化。

请注意,我们特别允许输出值在不同滞后期之间存在依赖关系,就像我们希望线性回归的输入数据与结果相关一样。平稳性意味着这些关系是稳定的。平稳性是经典统计模型的关键假设。下面的两个小节介绍了可以帮助使时间序列平稳的转换,以及如何处理由单位根引起的随机趋势的特殊情况。

转换时间序列以实现平稳性

为了满足许多时间序列模型的平稳性假设,我们需要对原始序列进行转换,通常需要几个步骤。常见的转换包括(自然)对数,将指数增长模式转换为线性趋势并稳定方差。通货紧缩意味着将一个时间序列除以另一个导致趋势行为的序列,例如,将一个名义序列除以一个价格指数以将其转换为实际度量。

如果一个序列是趋势稳定的,那么它将恢复到一个稳定的长期线性趋势。通常可以通过使用线性回归拟合趋势线并使用残差来使其平稳。这意味着在线性回归模型中包括时间指数作为独立变量,可能结合对数化或通货紧缩。

在许多情况下,去趋势化不足以使序列平稳。相反,我们需要将原始数据转换为一系列周期间和/或季节间差异。换句话说,我们使用相邻数据点或季节滞后的值相减的结果。请注意,当这种差分应用于对数变换序列时,结果代表的是金融背景下的瞬时增长率或收益率。

如果单变量序列经过d次差分后变得平稳,就称为d阶整合,或者简称为一阶整合如果d=1。这种行为是由单位根引起的,接下来我们将解释。

处理而不是如何处理

单位根对确定使时间序列平稳的转换方法构成特殊问题。在讨论诊断测试和解决方案之前,我们将首先解释单位根的概念。

关于单位根和随机游走

时间序列经常被建模为以下自回归形式的随机过程,以使当前值成为过去值的加权和,再加上一个随机扰动:

在下一节中,我们将更详细地探讨这些模型作为 ARIMA 模型中 AR 的构建块的单变量时间序列模型。这样的过程具有以下形式的特征方程:

如果这个多项式的(最多)p个根之一等于 1,则过程被称为具有单位根。它将是非平稳的,但不一定会有趋势。如果特征方程的剩余根的绝对值小于 1,则该过程的一阶差分将是平稳的,并且过程是一阶整合的或 I(1)。如果额外的根的绝对值大于 1,则积分阶数更高,并且需要额外的差分。

在实践中,利率或资产价格的时间序列通常不是平稳的,因为没有价格水平可供系列回归到。非平稳序列的最突出例子是随机漫步。给定具有初始价格 p[0](例如,股票的 IPO 价格)和白噪声扰动 的价格时间序列 p[t],随机漫步满足以下自回归关系:

重复替换表明当前值 p[t] 是所有先前扰动或创新,,以及初始价格 p[0] 的总和。如果方程包括一个常数项,那么随机漫步被认为有漂移

随机漫步因此是以下形式的自回归随机过程

它的特征方程为 ,具有单位根,既是非平稳的,也是一阶整合的。一方面,鉴于 的 IID 特性,时间序列的方差等于 ,这是不是二阶平稳的,这意味着原则上,该序列可以随时间取任何值。另一方面,进行第一阶差分,得到 ,留下了 平稳的序列 ,鉴于对 的统计假设。

具有单位根的非平稳序列的定义特征是长期记忆:由于当前值是过去扰动的总和,大的创新比均值回归、平稳序列持续时间更长。

如何诊断单位根

统计单位根检验是一种确定是否需要(额外)差分的常见方法。这些是用于确定是否需要差分的平稳性的统计假设检验。

增广的迪基-富勒检验ADF 检验)评估一个时间序列样本是否具有单位根的零假设,对立假设是平稳性。它将差分时间序列回归到时间趋势、第一滞后和所有滞后差分上,并从滞后时间序列值的系数值计算出一个检验统计量。statsmodels使其易于实现(参见笔记本tsa_and_stationarity)。

形式上,对于一个时间序列的 ADF 检验,,进行线性回归,其中 是一个常数, 是时间趋势上的系数,p 指的是模型中使用的滞后数:

约束 意味着一个随机游走,而仅有 意味着一个带漂移的随机游走。滞后阶数通常使用赤池信息准则AIC)和贝叶斯信息准则BIC)信息准则来决定,这些准则在 第七章线性模型 - 从风险因素到回报预测 中介绍。

ADF 检验统计量使用样本系数 ,在单位根非平稳的零假设下,该系数为零,在其他情况下为负。其意图在于证明,对于一个积分序列,滞后序列值不应提供有用信息来预测超过滞后差异的一阶差异。

如何去除单位根并处理得到的序列

除了使用相邻数据点之间的差异来消除恒定变化模式外,我们还可以应用季节性差分来消除季节性变化模式。这涉及到在代表季节模式长度的滞后距离处取值的差异。对于月度数据,这通常涉及到滞后 12,而对于季度数据,它涉及到滞后 4,以消除季节性和线性趋势。

确定正确的转换,特别是适当的差分数量和滞后数,并不总是一目了然的。一些启发式方法已经被提出,可以总结如下:

  • 滞后一期自相关接近零或为负,或者自相关普遍较小且无规律:无需进行更高阶的差分处理

  • 正自相关延伸至 10+ 滞后:该序列可能需要更高阶的差分

  • 滞后一期自相关 < -0.5:该序列可能存在过度差分

  • 轻微的过度或不足的差分可以通过 AR 或 MA 项进行校正(请参阅下一节关于单变量时间序列模型)

一些作者建议使用分数差分作为使积分序列变得平稳的更灵活的方法,并且可能能够保留比简单或季节性差分在离散间隔更多的信息或信号。例如,参见 Marcos Lopez de Prado(2018)的 第五章投资组合优化和绩效评估

实践中的时间序列变换

图 9.2 中的图表显示了纳斯达克股票指数和工业生产的时间序列,经过对数变换和随后的应用第一次和季节性差分(在滞后 12 处),分别展示了它们的原始形式以及变换后的版本,跨越 2017 年的 30 年时间。

图表还显示了 ADF p 值,这使我们能够在两种情况下拒绝单位根非平稳性的假设:

图 9.2:时间序列转换和单位根检验结果

我们可以进一步分析使用 Q-Q 图和基于 ACF 和 PACF 的自相关图的转换系列的相关时间序列特征的特征。

对于图 9.3中的 NASDAQ 图表,我们可以看到虽然没有趋势,但方差不是恒定的,而是在 1980 年代末、2001 年和 2008 年的市场动荡期间显示出集中的尖峰。Q-Q 图突出了分布的厚尾,极端值比正态分布所暗示的更频繁。

ACF 和 PACF 显示类似的模式,几个滞后的自相关显得显著:

图 9.3:转换后的 NASDAQ 综合指数的描述统计

对于工业制造生产的月度时间序列,我们可以看到在 2008 年危机后有一个大的负异常值,以及 Q-Q 图中相应的偏度(见图 9.4)。自相关远高于 NASDAQ 收益,并且平稳下降。PACF 在滞后 1 和 13 处显示出明显的正自相关模式,并在滞后 3 和 4 处显示出显著的负系数:

图 9.4:转换后的工业生产数据的描述统计

单变量时间序列模型

多元线性回归模型将感兴趣的变量表示为输入的线性组合,加上一个随机扰动。相比之下,单变量时间序列模型将时间序列的当前值与系列滞后值的线性组合、当前噪声以及可能的过去噪声项相关联。

尽管指数平滑模型基于对数据中趋势和季节性的描述,但ARIMA 模型旨在描述数据中的自相关性。ARIMA(p, d, q) 模型需要平稳性,并利用两个构建块:

  • 自回归AR)项包含时间序列的p滞后值

  • 包含q滞后扰动的移动平均MA)项

I 代表 integrated,因为模型可以通过对系列进行d次差分来考虑单位根非平稳性。自回归一词强调了 ARIMA 模型意味着时间序列对其自身值的回归。

我们将介绍 ARIMA 的构建模块,AR 和 MA 模型,并解释如何将它们组合成可以像 ARIMA 模型那样考虑序列积分,或者包含外生变量像AR(I)MAX模型那样的自回归移动平均(ARMA)模型。此外,我们将说明如何包含季节性 AR 和 MA 项以扩展工具箱,使其还包括SARMAX模型。

如何构建自回归模型

p阶 AR 模型旨在捕捉不同滞后时期时间序列值之间的线性相关性,可以写成如下形式:

这与对y[t]的滞后值进行多元线性回归非常相似。该模型具有以下特征方程:

解这个关于xp次多项式的倒数是特征根,如果所有根的绝对值都小于 1,则 AR(p)过程是稳定的,否则不稳定。对于稳定的序列,多步预测将收敛于序列的均值。

我们可以使用熟悉的最小二乘法估计模型参数,使用p+1,...,T的观测数据确保每个滞后项和结果都有数据。

如何确定滞后数量

在实践中,挑战在于确定滞后项的适当阶p。我们在如何测量自相关性部分讨论过的串行相关性时间序列分析工具在做出这一决定方面起着关键作用。

更具体地说,对自相关图的目视检查通常提供了有用的线索:

  • ACF估计不同滞后时期观测值之间的自相关性,其结果既来自直接线性相关性,也来自间接线性相关性。因此,如果p阶 AR 模型是正确的模型,自相关函数将显示出直至滞后k的显著串行相关性,并且由于线性关系间接效应所造成的惯性,将延伸到随后的滞后直至最终随着效应的减弱而消失。

  • PACF只测量了给定滞后间隔的观测值之间的直接线性关系,因此不会反映超过k的滞后的相关性。

如何诊断模型拟合度

如果模型正确捕捉了滞后间的线性依赖关系,则残差应类似于白噪声,而自相关函数应突出显示出显著的自相关系数的缺失。

除了残差图外,Ljung-Box Q 统计量还允许我们测试残差序列是否符合白噪声的假设。零假设是所有m个序列相关系数都为零,而备择假设是某些系数不为零。检验统计量是根据不同滞后k的样本自相关系数计算的,并且遵循X²分布:

正如我们将看到的,statsmodels 提供了关于不同滞后的系数显著性的信息,不显著的系数应该被移除。如果 Q 统计拒绝无自相关的零假设,你应该考虑额外的 AR 项。

如何构建移动平均模型

MA(q) 模型使用 q 个过去扰动,而不是时间序列的滞后值,作为回归模型的一部分,如下所示:

由于我们没有观察到白噪声扰动值,,MA(q) 不像我们迄今为止看到的那样是一个回归模型。MA(q) 模型不是使用最小二乘法估计的,而是使用最大似然估计MLE),或者在系列开始时初始化或估计扰动,然后递归和迭代地计算剩余值。

MA(q) 模型得名于将 y[t] 的每个值表示为过去 q 个创新的加权移动平均值。换句话说,当前的估计值表示相对于模型过去错误的修正。MA(q) 模型中使用移动平均值的方式与指数平滑或季节时间序列成分的估计不同,因为 MA(q) 模型旨在预测未来值,而不是去噪声或估计过去值的趋势周期。

MA(q) 过程总是平稳的,因为它们是白噪声变量的加权和,这些变量本身就是平稳的。

如何确定滞后数

由 MA(q) 过程生成的时间序列由前 q 个模型预测的残差驱动。因此,MA(q) 过程的 ACF 将显示出直到滞后 q 的值的显著系数,然后急剧下降,因为这是模型假定系列值被生成的方式。

注意这与我们刚刚描述的 AR 情况不同,那里 PACF 会显示类似的模式。

AR 和 MA 模型之间的关系

无论何时,都可以使用重复替换的方法将 AR(p) 模型表示为一个 过程,例如“如何处理由单位根引起的随机趋势”部分中的随机游走示例。

当 MA(q) 过程的系数满足一定的大小约束时,它也变得可逆,并且可以表示为一个 过程(详见 Tsay,2005 年)。

如何构建 ARIMA 模型及其扩展

自回归积分滑动平均—ARIMA(p, d, q)—模型结合了 AR(p) 和 MA(q) 过程,以利用这些基本模型的互补性并简化模型开发。它们使用更紧凑的形式并减少参数数量,进而减少过度拟合的风险。

这些模型还通过使用时间序列值的d^(th)差分来消除单位根的非平稳性。一个 ARIMA(p, 1, q)模型相当于使用序列的第一阶差分的 ARMA(p, q)模型。用y'表示非季节性差分d次后的原始序列,ARIMA(p, d, q)模型简单地表示为:

ARIMA 模型也使用 MLE 进行估计。根据实现方式,更高阶的模型可能一般包含较低阶的模型。

例如,截至版本 0.11,statsmodels 包括所有低阶pq项,并且不允许删除低于最高值的滞后的系数。在这种情况下,更高阶的模型将始终拟合得更好。使用太多项不要使模型过度拟合数据。在撰写时,最新版本为 0.11,增加了一个带有更灵活配置选项的实验性新 ARIMA 模型。

如何对差分序列建模

使用数据设计单变量时间序列模型时也有一些指导方针:

  • 没有差分的模型假设原始序列是平稳的,包括均值回归。它通常包括一个常数项,以允许非零均值。

  • 一个一阶差分的模型假设原始序列具有恒定的趋势,因此应包括一个常数项。

  • 一个两阶差分的模型假设原始序列具有时间变化的趋势,并且不应包括一个常数。

如何确定 AR 和 MA 项的数量

由于 AR(p)和 MA(q)项相互作用,ACF 和 PACF 提供的信息不再可靠,只能用作起点。

传统上,AIC 和 BIC 信息准则被用于在选择模型设计时依赖样本内拟合。或者,我们可以依赖样本外测试来交叉验证多个参数选择。

在考虑 AR 和 MA 模型时,以下摘要提供了如何选择模型阶数的一些指导:

  • PACF 截断的滞后阶数是表示 AR 项的数量。如果差分序列的 PACF 显示出明显的截断和/或滞后 1 的自相关为正,则添加一个或多个 AR 项。

  • ACF 截断的滞后阶数是表示 MA 项的数量。如果差分序列的 ACF 显示出明显的截断和/或滞后 1 的自相关为负,则考虑将 MA 项添加到模型中。

  • AR 和 MA 项可能会抵消彼此的影响,因此,如果您的模型同时包含两者,请始终尝试将 AR 和 MA 项的数量减少 1,以避免过度拟合,特别是如果更复杂的模型需要超过 10 次迭代才能收敛。

  • 如果 AR 系数之和接近于 1,并且建议模型的 AR 部分存在单位根,则消除一个 AR 项并再次进行差分。

  • 如果 MA 系数总和接近于一,并且暗示模型的 MA 部分存在单位根,则删除一个 MA 项并将差分阶数减少一个。

  • 不稳定的长期预测表明模型的 AR 或 MA 部分可能存在单位根。

添加特征 – ARMAX

具有外生输入的自回归滑动平均模型(ARMAX)模型在 ARMA 时间序列模型的右侧添加输入变量或协变量(假设系列是平稳的,所以我们可以跳过差分):

这类似于线性回归模型,但解释起来相当困难。这是因为y[t]的影响并不是x[t]增加一个单位所产生的影响,如线性回归中那样。相反,方程右侧的y[t]的滞后值的存在意味着只有在给定响应变量的滞后值的情况下,系数才能被解释,这几乎是不直观的。

添加季节差分 – SARIMAX

对于具有季节效应的时间序列,我们可以包括捕捉季节性周期性的 AR 和 MA 项。例如,当使用月度数据且季节效应长度为 1 年时,季节性 AR 和 MA 项将反映这个特定的滞后长度。

然后 ARIMAX(p, d, q) 模型变成了 SARIMAX(p, d, q) × (P, D, Q) 模型,这个写法略显复杂,但 statsmodels 文档(GitHub 上的链接)详细提供了这些信息。

我们现在将使用宏观数据构建一个季节性 ARMA 模型,以说明其实现过程。

如何预测宏观基本面

我们将为 1988-2017 年期间的工业生产时间序列构建一个月度数据的 SARIMAX 模型。如在分析工具的第一节中所示,数据已经进行了对数变换,并且我们使用了季节性(滞后 12 个月)差分。我们对一系列普通和常规的 AR 和 MA 参数进行估计,使用了 10 年的训练数据的滚动窗口,并评估了 1 步预测的均方根误差RMSE),如下所示的简化代码(详细信息请参见笔记本arima_models):

for p1 in range(4):                # AR order
    for q1 in range(4):            # MA order
        for p2 in range(3):        # seasonal AR order
            for q2 in range(3):    # seasonal MA order
                y_pred = []
                for i, T in enumerate(range(train_size, len(data))):
                    train_set = data.iloc[T - train_size:T]
                    model = tsa.SARIMAX(endog=train_set, # model specification
                                        order=(p1, 0, q1),
                                        seasonal_order=(p2, 0, q2, 12)).fit()
                    preds.iloc[i, 1] = model.forecast(steps=1)[0]
                mse = mean_squared_error(preds.y_true, preds.y_pred)
                results[(p1, q1, p2, q2)] = [np.sqrt(mse),
                    preds.y_true.sub(preds.y_pred).std(),
                    np.mean(aic)] 

我们还收集了 AIC 和 BIC 准则,显示出 0.94 的非常高的秩相关系数,其中 BIC 更倾向于比 AIC 稍少参数的模型。根据 RMSE 最佳的五个模型是:

 RMSE         AIC         BIC
p1 q1 p2 q2                                  
2  3  1  0   0.009323 -772.247023 -752.734581
3  2  1  0   0.009467 -768.844028 -749.331586
2  2  1  0   0.009540 -770.904835 -754.179884
   3  0  0   0.009773 -760.248885 -743.523935
   2  0  0   0.009986 -758.775827 -744.838368 

我们重新估计 SARIMA(2, 0 ,3) × (1, 0, 0) 模型,如下所示:

best_model = tsa.SARIMAX(endog=industrial_production_log_diff, order=(2, 0, 3),
                         seasonal_order=(1, 0, 0, 12)).fit()
print(best_model.summary()) 

我们得到以下总结:

图 9.5:SARMAX 模型结果

系数是显著的,并且 Q 统计量拒绝了进一步自相关的假设。相关图表同样表明我们已成功消除了序列的自相关:

图 9.6:SARIMAX 模型诊断

如何使用时间序列模型预测波动率

在金融领域中,单变量时间序列模型的一个特别重要的应用是对波动性进行预测。这是因为波动性通常随时间而变化,并且会出现波动性集聚的情况。方差的变化给使用经典的假设平稳的 ARIMA 模型进行时间序列预测带来了挑战。为了解决这一挑战,我们现在将对波动性进行建模,以便我们可以预测方差的变化。

异方差性 是指变量方差的变化的技术术语。ARCH 模型将误差项的方差表示为前期误差的函数。更具体地说,它假设误差方差遵循一个 AR(p) 模型。

广义自回归条件异方差 (GARCH) 模型扩展了 ARCH 的范围,以允许 ARMA 模型。时间序列预测通常将 ARIMA 模型用于期望均值,并将 ARCH/GARCH 模型用于时间序列的预期方差。2003 年诺贝尔经济学奖授予了罗伯特·恩格尔和克莱夫·格兰杰,因为他们开发了这一类模型。前者还在纽约大学斯特恩商学院经营着波动率实验室(vlab.stern.nyu.edu),该实验室有许多关于我们将讨论的模型的在线示例和工具。

ARCH 模型

ARCH(p) 模型简单地是应用于时间序列模型残差方差的 AR(p) 模型,这使得在时期 t 的方差条件于滞后观测的方差。

更具体地说,误差项,,是原始时间序列上的线性模型(如 ARIMA)的残差,被分为一个时间相关的标准差,,和一个扰动,z[t],如下所示:

ARCH(p) 模型可以使用 OLS 进行估计。恩格尔提出了一种方法来使用拉格朗日乘数检验来确定适当的 ARCH 阶数,该检验对应于线性回归中所有系数为零的假设的 F 检验(见 第七章线性模型 - 从风险因素到收益预测)。

ARCH 模型的一个关键优点是,它产生具有正超额峰度的波动性估计 —— 即,相对于正态分布的 fat tails —— 这与有关收益的实证观察相一致。缺点包括假设正面和负面波动性冲击具有相同的效应,而资产价格往往会有不同的反应。它也不能解释波动性的变化,并且可能会过度预测波动性,因为它们对收益序列的大规模、孤立的冲击反应迟缓。

对于一个正确规范的 ARCH 模型,标准化残差(除以该时期标准差的模型估计值)应该类似于白噪声,并且可以进行 Ljung-Box Q 检验。

推广 ARCH —— GARCH 模型

ARCH 模型相对简单,但通常需要许多参数来捕捉资产回报序列的波动率模式。GARCH 模型适用于对数回报序列,r[t],具有扰动项,如图所示:,如果:

GARCH(p, q) 模型假设误差项方差的 ARMA(p, q) 模型,如图所示:

与 ARCH 模型类似,GARCH(1,1) 过程的尾部分布比正态分布更重。该模型遇到与 ARCH 模型相同的弱点。例如,它对正面和负面冲击的反应相同。

要为 ARCH 和 GARCH 模型配置滞后阶数,请使用训练好的时间序列的平均预测的平方残差。残差被零中心化,以便它们的平方也是方差。然后,检查平方残差的 ACF 和 PACF 图,以识别时间序列方差的自相关模式。

如何构建一个预测波动率的模型

资产回报序列的波动率模型开发包括四个步骤:

  1. 基于 ACF 和 PACF 显示的序列依赖性,为金融时间序列构建 ARMA 时间序列模型

  2. 再次依赖于序列的 ACF 和 PACF 来测试 ARCH/GARCH 效应的模型残差

  3. 如果序列相关效应显著,则指定波动率模型,并联合估计平均值和波动率方程

  4. 仔细检查拟合的模型,并在必要时进行调整

当将波动率预测应用于回报序列时,序列依赖性可能有限,因此可以使用恒定均值而不是 ARMA 模型。

arch 库(参见 GitHub 上的文档链接)提供了几种选项来估计波动率预测模型。您可以将预期平均值建模为恒定值,如 如何构建自回归模型 部分中讨论的 AR(p) 模型,或者作为更近期的异质自回归过程 (HAR),它使用每日(1 天)、每周(5 天)和每月(22 天)滞后来捕捉短期、中期和长期投资者的交易频率。

平均模型可以与几种条件异方差模型一起定义和估计,除了 ARCH 和 GARCH 之外,还包括允许在正面和负面回报之间存在非对称效应的指数 GARCH (EGARCH) 模型,以及补充 HAR 平均模型的异质 ARCH (HARCH) 模型。

我们将使用 2000 年至 2020 年的每日纳斯达克回报来演示 GARCH 模型的使用方法(详见笔记本 arch_garch_models):

nasdaq = web.DataReader('NASDAQCOM', 'fred', '2000', '2020').squeeze()
nasdaq_returns = np.log(nasdaq).diff().dropna().mul(100) # rescale to facilitate optimization 

重新缩放后的每日收益序列仅表现出有限的自相关性,但是相对于平均值的平方偏差确实具有显著的记忆,这反映在缓慢衰减的 ACF 和 PACF 中,对于前两个而言高,在第六个滞后之后才被截断:

plot_correlogram(nasdaq_returns.sub(nasdaq_returns.mean()).pow(2), lags=120,                  title='NASDAQ Daily Volatility') 

函数plot_correlogram生成如下输出:

图 9.7:每日纳斯达克综合波动性

因此,我们可以估计一个 GARCH 模型来捕捉过去波动性的线性关系。我们将使用滚动 10 年窗口来估计一个 GARCH(p, q)模型,其中pq的范围为 1-4,以生成一步外样本预测。

然后,我们比较预测波动性的 RMSE 与实际收益偏离其均值的平方差,以确定最具预测性的模型。我们使用修剪数据来限制极端收益值的影响,这些值在波动率的正偏态中反映得非常高:

trainsize = 10 * 252  # 10 years
data = nasdaq_returns.clip(lower=nasdaq_returns.quantile(.05),
                           upper=nasdaq_returns.quantile(.95))
T = len(nasdaq_returns)
results = {}
for p in range(1, 5):
    for q in range(1, 5):
        print(f'{p} | {q}')
        result = []
        for s, t in enumerate(range(trainsize, T-1)):
            train_set = data.iloc[s: t]
            test_set = data.iloc[t+1]  # 1-step ahead forecast
            model = arch_model(y=train_set, p=p, q=q).fit(disp='off')
            forecast = model.forecast(horizon=1)
            mu = forecast.mean.iloc[-1, 0]
            var = forecast.variance.iloc[-1, 0]
            result.append([(test_set-mu)**2, var])
        df = pd.DataFrame(result, columns=['y_true', 'y_pred'])
        results[(p, q)] = np.sqrt(mean_squared_error(df.y_true, df.y_pred)) 

GARCH(2, 2)模型实现了最低的 RMSE(与 GARCH(4, 2)相同的值,但参数更少),所以我们继续估计这个模型以检查摘要:

am = ConstantMean(nasdaq_returns.clip(lower=nasdaq_returns.quantile(.05),
                                      upper=nasdaq_returns.quantile(.95)))
am.volatility = GARCH(2, 0, 2)
am.distribution = Normal()
best_model = am.fit(update_freq=5)
print(best_model.summary()) 

输出显示了最大化的对数似然以及 AIC 和 BIC 标准,这些标准通常在选择基于样本内性能的模型时被最小化(见第七章线性模型 - 从风险因素到收益预测)。它还显示了均值模型的结果,这种情况下,只是一个常数估计,以及常数 omega 的 GARCH 参数,AR 参数,,和 MA 参数,,所有这些都具有统计学显著性:

图 9.8:GARCH 模型结果

现在让我们探索多时间序列模型和协整概念,这将使一种新的交易策略成为可能。

多元时间序列模型

多元时间序列模型旨在同时捕获多个时间序列的动态,并利用这些序列之间的依赖关系进行更可靠的预测。对这一主题最全面的介绍是 Lütkepohl(2005)。

方程组

单变量时间序列模型,就像我们刚刚讨论的 ARMA 方法一样,仅限于目标变量及其滞后值或滞后扰动以及外生序列之间的统计关系,在 ARMAX 的情况下。相比之下,多元时间序列模型还允许其他时间序列的滞后值影响目标。这种影响适用于所有系列,导致复杂的交互作用,如下图所示:

图 9.9:单变量和多变量时间序列模型中的交互作用

除了可能更好的预测之外,多元时间序列还用于获得对交叉系列依赖性的见解。例如,在经济学中,多元时间序列用于理解一个变量的政策变化,例如利率,如何在不同的视角影响其他变量。

多变量模型产生的冲击响应函数达到了这一目的,并允许我们模拟一个变量如何对其他变量的突然变化做出反应。格兰杰因果性概念分析了一个变量是否对另一个变量的预测有用(以最小二乘意义上)。此外,多元时间序列模型允许对预测误差方差进行分解,以分析其他系列如何做出贡献。

向量自回归(VAR)模型

我们将看到向量自回归 VAR(p) 模型如何通过创建一个包含所有k系列p滞后值的k方程组来扩展 AR(p) 模型。在最简单的情况下,k=2 的 VAR(1) 模型采用以下形式:

这个模型可以用矩阵形式更简洁地表达:

对输出滞后值的系数提供有关系列本身动态的信息,而交叉变量的系数则提供一些关于系列之间交互作用的见解。此符号扩展到k时间序列和p阶,如下所示:

VAR(p)模型还需要平稳性,以便从单变量时间序列建模的初步步骤延伸。首先,探索系列并确定必要的转换。然后,应用增广迪基-福勒检验来验证每个系列是否满足平稳性标准,否则应用进一步的转换。它可以根据初始信息进行 OLS 估计,也可以根据 MLE 进行估计,这是对正态分布误差而言的等价物,但对其他情况不是。

如果一些或全部的k系列是单位根非平稳的,它们可能是协整的(见下一节)。将单位根概念扩展到多个时间序列意味着两个或更多系列的线性组合是平稳的,因此是均值回归的。

VAR 模型无法处理这种情况,需要进行差分处理;而是使用向量误差修正模型VECM,Johansen 和 Juselius 1990)。我们将进一步探讨协整性,因为如果存在并被认为是持续存在的,它可以用于配对交易策略。

滞后阶数的确定也从每个系列的 ACF 和 PACF 中获得线索,但受到相同滞后阶数适用于所有系列的限制。在模型估计之后,残差诊断还要求结果类似于白噪声,并且模型选择可以使用样本内信息准则,或者如果目标是使用模型进行预测,则可以使用样本外预测性能来交叉验证备选模型设计

如在单变量情况中提到的那样,对原始时间序列的预测要求我们在训练模型之前撤销应用于使序列稳定的转换。

使用 VAR 模型进行宏观预测

我们将扩展使用单一时间序列的示例,其中包括工业生产的月度数据以及消费者情绪的月度时间序列,这两者都由美联储的数据服务提供。我们将使用熟悉的 pandas-datareader 库从 1970 年到 2017 年检索数据:

df = web.DataReader(['UMCSENT', 'IPGMFN'],
                     'fred', '1970', '2017-12').dropna()
df.columns = ['sentiment', 'ip'] 

对工业生产系列进行对数变换,并使用 12 个滞后期进行季节性差分,得到稳定的结果:

df_transformed = pd.DataFrame({'ip': np.log(df.ip).diff(12),
                              'sentiment': df.sentiment.diff(12)}).dropna()
test_unit_root(df_transformed) # see notebook for details and additional plots
          p-value
ip          0.0003
sentiment   0.0000 

这给我们留下了以下系列:

图 9.10:转换后的时间序列:工业生产和消费者情绪

为了限制输出大小,我们将仅使用 statsmodels 的VARMAX实现(允许使用可选的外生变量)以及使用前 480 个观测来估计一个带有常数趋势的 VAR(1)模型:

model = VARMAX(df_transformed.loc[:'2017'], order=(1,1),
               trend='c').fit(maxiter=1000) 

这产生了以下摘要:

图 9.11:VAR(1)模型结果

输出包含了两个时间序列方程的系数,如前述 VAR(1)示例所述。statsmodels 提供了诊断图来检查残差是否符合白噪声假设。在这个简单的例子中,这并不完全成立,因为方差似乎不是恒定的(左上角),而且量化图显示了分布的差异,即尾部较大(左下角):

图 9.12:statsmodels VAR 模型诊断图

您可以按以下方式生成样本外预测:

preds = model.predict(start=480, end=len(df_transformed)-1) 

下面的实际值和预测值的可视化展示了预测滞后于实际值,并且不能很好地捕捉非线性、样本外模式:

图 9.13:VAR 模型预测与实际值

协整 - 具有共享趋势的时间序列

我们在前一节关于多变量时间序列模型中简要提到了协整。现在让我们更详细地解释这个概念以及如何诊断其存在,然后再利用它进行统计套利交易策略。

我们已经看到一个时间序列可以具有创建随机趋势的单位根,并使时间序列高度持久。当我们在线性回归模型中将这种整合的时间序列作为特征使用时,而不是作为差分形式使用,它与结果的关系通常会显得统计上显著,尽管实际上并不是。这种现象称为伪回归(详见 Wooldridge,2008 年的第十八章用于金融时间序列和卫星图像的 CNNs)。因此,建议的解决方案是在将它们用于模型之前对时间序列进行差分,使它们变得平稳。

然而,在结果和一个或多个输入变量之间存在协整关系时,有一个例外。要理解协整的概念,让我们首先记住回归模型的残差是输入和输出系列的线性组合。

通常,对一个或多个这样的系列的整合时间序列的回归的残差产生的是非平稳的残差,这些残差也是整合的,因此表现出类似于随机游走的行为。然而,对于一些时间序列,情况并非如此:回归产生的系数会形成时间序列的线性组合,这种线性组合是平稳的,尽管各个系列不是。这种时间序列是协整的

一个非技术示例是一个醉酒的人在随机行走,他的狗(被拴在绳索上)陪伴着他。两个轨迹都是非平稳的,但协整,因为狗偶尔会回到它的主人身边。在交易的背景下,套利约束意味着现货和期货价格之间存在协整关系。

换句话说,两个或多个协整系列的线性组合有一个稳定的均值,这个线性组合会回归到这个稳定的均值。当各个系列是高阶整合的时候,这也适用,线性组合会减少整体的整合次数。

协整与相关性不同:两个系列可以高度相关,但不一定是协整的。例如,如果两个增长系列是彼此的常数倍数,它们的相关性将很高,但任何线性组合也会增长而不是回归到一个稳定的均值。

协整非常有用:如果两个或更多资产价格系列趋向于回归到一个共同的均值,我们可以利用与趋势偏离,因为它们应该暗示着未来价格的相反方向的移动。协整背后的数学更加复杂,因此我们将只关注实践方面;有关深入处理,请参阅 Lütkepohl(2005)。

在本节中,我们将讨论如何识别具有这种长期平稳关系的配对,估计任何失衡纠正的预期时间,以及如何利用这些工具来实施和回测长短对交易策略。

有两种测试协整的方法:

  • 恩格尔-格兰杰两步法

  • 约翰逊测试

在展示它们如何帮助识别趋向于恢复到共同趋势的共整合证券之前,我们将依次讨论每一个。

恩格尔-格兰杰两步法

恩格尔-格兰杰方法用于确定两个系列之间的共整合关系。它涉及到以下两个方面:

  1. 对另一个系列进行回归以估计长期稳定关系

  2. 将 ADF 单位根测试应用于回归残差

零假设是残差具有单位根并且是集成的;如果我们可以拒绝它,那么我们假设残差是稳定的,因此系列是共整合的(Engle and Granger 1987)。

这种方法的一个关键好处是回归系数表示的是使组合稳定的乘数,即,均值回归。不幸的是,测试结果将取决于我们考虑哪个变量是独立的,因此我们尝试两种方式,然后选择具有更负的测试统计量且具有较低 p 值的关系。

这项测试的另一个缺点是,它仅限于成对关系。更复杂的约翰逊程序可以识别多达十几个时间序列之间的显著共整合。

约翰逊似然比检验

相比之下,约翰逊程序测试了上一节中讨论的共整合对 VAR 模型施加的限制。更具体地说,在从通用 VAR(p)模型的两边减去目标向量之后,我们得到了误差修正模型ECM)的表述:

结果修改后的 VAR(p)方程只有一个向量项在水平(y[t][-1])中,并且没有使用运算符作为差异。共整合的性质取决于系数矩阵的等级(约翰逊 1991)。

尽管这个方程在结构上与 ADF 测试设置相似,但现在有了多个系列的潜在公共趋势组合。为了确定共整合关系的数量,约翰逊测试连续测试增加的等级,从 0 开始(无共整合)。我们将在下一节探讨应用于两个系列的情况。

冈萨洛和李(1998)讨论了由于错误指定的模型动态和其他实施方面的挑战,包括如何结合我们将在下一节中依赖的两种测试程序的实际统计套利策略。

具有共整合关系的统计套利

统计套利指的是利用某些统计模型或方法来利用资产的相对定价错误,同时保持一定程度的市场中性的策略。

对冲交易 是一个概念上简单直接的策略,至少从 20 世纪八十年代中期起就被算法交易者采用(Gatev、Goetzmann 和 Rouwenhorst 2006)。其目标是找到历史上价格走势相似的两个资产,跟踪价差(即它们价格之间的差异),并一旦价差扩大,买入价格低于共同趋势的失败者并做空价格高于共同趋势的赢家。如果这种关系持续存在,做多和/或做空腿将随着价格趋同而获利,并且位置将被关闭。

该方法通过从多个证券形成篮子,并使一个资产对抗另一个篮子而扩展到多变量环境。

在实践中,该策略需要两个步骤:

  1. 形成阶段:确定具有长期均值回归关系的证券。理想情况下,价差应具有较高的方差,以允许频繁的盈利交易,并可靠地回归到共同趋势。

  2. 交易阶段:当价格变动导致价差分歧和趋同时,触发进出交易规则。

在这一领域越来越活跃的研究中,过去几年中出现了几种形成和交易阶段的方法,跨越多个资产类别。下一小节概述了主要区别,然后我们将深入介绍一个示例应用。

如何选择和交易共动资产对

最近对对冲交易策略的全面调查(Krauss 2017)确定了四种不同的方法,以及一些更近期的方法,包括基于 ML 的预测:

  • 距离法:最古老且研究最多的方法使用诸如相关性之类的距离度量标识候选对,并使用诸如布林带之类的非参数阈值触发进出交易。其计算简单性允许进行大规模应用,自从 Gatev 等人(2006)以来,在不同市场和资产类别中已经表现出盈利性质,并持续了相当长的时间。然而,最近的表现已经有所衰退。

  • 协整法:如前所述,该方法依赖于两个或更多变量之间的长期关系的计量模型,并允许进行统计测试,承诺比简单距离度量更可靠。该类别中的示例使用 Engle-Granger 和 Johansen 程序来识别证券对和篮子,以及旨在捕获概念的更简单的启发式方法(Vidyamurthy 2004)。交易规则通常类似于距离度量使用的简单阈值。

  • 时间序列法:着眼于交易阶段,该类别中的策略旨在将价差建模为均值回归的随机过程,并相应地优化进出规则(Elliott、Hoek 和 Malcolm 2005)。它假定有希望的配对已经被确定。

  • 随机控制方法:类似于时间序列方法,其目标是使用随机控制理论优化交易规则,以找到值函数和策略函数来得到最优组合(Liu 和 Timmermann,2013)。我们将在第二十一章用于合成时间序列数据的生成对抗网络中讨论这种类型的方法。

  • 其他方法:除了基于无监督学习(如主成分分析,参见第十三章使用无监督学习进行数据驱动的风险因子和资产配置)和统计模型(如 copulas,Patton 2012)的配对识别外,近年来机器学习也变得流行,用于基于相对价格或收益预测识别配对(Huck,2019)。我们将在接下来的章节中介绍几种可用于此目的的 ML 算法,并且说明相应的多变量配对交易策略

这些不同方法的摘要只是略显一斑地展示了配对交易策略设计所带来的灵活性。除了有关配对选择和交易规则逻辑的高层次问题之外,还有许多参数需要我们定义以供实施。这些参数包括以下内容:

  • 用于筛选潜在配对或篮子的投资范围

  • 形成期的长度

  • 用于选择可交易候选者的关系强度

  • 与公共均值的偏离程度和收敛程度来触发进入或退出交易或根据价差波动调整现有仓位

实践中的配对交易

距离方法使用(标准化的)资产价格或其收益的相关性来识别配对,简单且计算成本远远低于协整检验。笔记本cointegration_test对具有 4 年每日数据的约 150 只股票样本进行了说明:计算与 ETF 收益的相关性大约需要 30ms,而进行一系列协整检验(使用 statsmodels)则需要 18 秒 - 慢了 600 倍。

速度优势尤为宝贵。这是因为潜在配对的数量是要考虑到每一方的候选人数的乘积,因此评估 100 只股票和 100 只 ETF 的组合需要比较 10,000 个测试(我们稍后将讨论多重测试偏差的挑战)。

另一方面,距离度量不一定选择最有利可图的配对:相关性在完美共同运动时最大化,这反过来消除了实际的交易机会。经验证实证研究表明,协整配对的价差波动性几乎是距离配对的两倍(Huck 和 Afawubo,2015)。

为了平衡计算成本与生成对质量之间的权衡,Krauss(2017)根据他的文献综述建议采用一种结合了两种方法的程序:

  1. 选择传播稳定且漂移小的对,以减少候选项的数量

  2. 对剩余的传播方差最高的对进行协整检验

此过程旨在选择具有较低偏离风险的协整对,同时确保更具波动性的传播,进而产生更多的利润机会。

大量的测试引入了数据窥探偏差,如第六章机器学习过程中所讨论的:多重检验可能会增加错误地拒绝无协整假设的假阳性数量。虽然统计显著性对于盈利交易可能不是必需的(Chan 2008),但商品对研究(Cummins and Bucca 2012)表明,控制家族内误差率以提高检验的功效,根据 Romano and Wolf(2010)的说法,可以带来更好的性能。

在接下来的小节中,我们将更详细地研究各种资产价格共同变动程度的预测能力对协整检验结果的影响。

示例代码使用了 172 只股票和 138 只 ETFs,在 2010 年至 2019 年间由 Stooq 提供的每日数据,这些股票和 ETFs 在纽约证券交易所和纳斯达克交易。

这些证券代表了其所属类别在样本期内的最大平均美元成交量;高度相关且平稳的资产已被移除。有关如何获取数据的说明,请参见 GitHub 仓库的data文件夹中的笔记本create_datasets,以及有关代码和额外预处理和探索细节的笔记本cointegration_tests

基于距离的启发式方法来寻找协整对

compute_pair_metrics() 计算了超过 23,000 对股票和交易所交易基金ETFs)在 2010-14 年和 2015-19 年的以下距离度量:

  • 传播的漂移,定义为传播的时间趋势对传播的线性回归

  • 传播的波动性

  • 标准化价格系列之间和它们的回报之间的相关性

低漂移和波动,以及高相关性,是协整的简单代理。

为了评估这些启发式规则的预测能力,我们还使用 statsmodels 对前述对运行恩格尔-格兰杰和约翰逊协整检验。这发生在compute_pair_metrics()的后半部分的循环中。

我们首先估计我们需要为约翰逊检验指定的滞后数的最佳数量。对于两个测试,我们假设协整系列(传播)可能有一个不同于零的截距,但没有趋势:

def compute_pair_metrics(security, candidates):
    security = security.div(security.iloc[0])
    ticker = security.name
    candidates = candidates.div(candidates.iloc[0])
    # compute heuristics
    spreads = candidates.sub(security, axis=0)
    n, m = spreads.shape
    X = np.ones(shape=(n, 2))
    X[:, 1] = np.arange(1, n + 1)
    drift = ((np.linalg.inv(X.T @ X) @ X.T @ spreads).iloc[1]
             .to_frame('drift'))
    vol = spreads.std().to_frame('vol')
    corr_ret = (candidates.pct_change()
                .corrwith(security.pct_change())
                .to_frame('corr_ret'))
    corr = candidates.corrwith(security).to_frame('corr')
    metrics = drift.join(vol).join(corr).join(corr_ret).assign(n=n)
    tests = []
    # compute cointegration tests
    for candidate, prices in candidates.items():
        df = pd.DataFrame({'s1': security, 's2': prices})
        var = VAR(df)
        lags = var.select_order() # select VAR order
        k_ar_diff = lags.selected_orders['aic']
        # Johansen Test with constant Term and estd. lag order
        cj0 = coint_johansen(df, det_order=0, k_ar_diff=k_ar_diff)
        # Engle-Granger Tests
        t1, p1 = coint(security, prices, trend='c')[:2]
        t2, p2 = coint(prices, security, trend='c')[:2]
        tests.append([ticker, candidate, t1, p1, t2, p2, 
                      k_ar_diff, *cj0.lr1])

    return metrics.join(tests) 

为了检验协整检验的显著性,我们将约翰逊迹统计量对于秩 0 和 1 的临界值进行比较,并得到恩格尔-格兰杰 p 值。

我们遵循上一节末尾提到的 Gonzalo 和 Lee (1998) 的建议,即同时应用两个测试,并接受它们达成一致意见的对。作者建议在存在分歧时进行额外的尽职调查,但我们将跳过这一步:

spreads['trace_sig'] = ((spreads.trace0 > trace0_cv) &
                        (spreads.trace1 > trace1_cv)).astype(int)
spreads['eg_sig'] = (spreads.p < .05).astype(int) 

对于两个样本期间的超过 46,000 对,约翰逊测试将 3.2% 的关系视为显著,而恩格尔-格兰杰测试将 6.5% 视为显著。它们对 366 对(0.79%)达成一致意见。

启发式方法能多好地预测显著的协整?

当我们比较那些根据两个测试协整的系列和其余系列的启发式方法的分布时,波动性和漂移确实较低(绝对值)。图 9.14 显示这两个相关性指标的情况不太明确:

图 9.14:启发式方法的分布,按照两个协整测试的显著性来分解

为了评估启发式方法的预测准确性,我们首先运行一个 logistic 回归模型,使用这些特征来预测显著的协整。它达到了曲线下面积 (AUC) 交叉验证分数为 0.815;排除相关性指标后,它仍然得分 0.804。决策树在 AUC=0.821 时表现稍好,无论是否包含相关性特征。

由于强烈的类别不平衡,存在大量的假阳性:正确识别 366 个协整对的 80% 意味着有超过 16,500 个假阳性,但也消除了几乎 30,000 个候选对。有关更多详细信息,请参阅笔记本 cointegration_tests

关键要点 是距离启发式方法可以帮助更高效地筛选大范围的内容,但这样做的代价是可能会错过一些共整对,并且仍然需要大量的测试。

准备策略回测

在本节中,我们将基于股票和 ETF 的样本以及 2017-2019 年期间实施基于协整的统计套利策略。为了简化演示,某些方面进行了简化。有关代码示例和额外细节,请参阅笔记本 statistical_arbitrage_with_cointegrated_pairs

我们首先生成并存储所有候选对的协整测试及其产生的交易信号,然后,鉴于该过程的计算强度,我们对基于这些信号的策略进行回测。

预计算协整测试

首先,我们在一个两年的回溯期内对每一对可能的 23,000 个对进行季度协整测试,然后,我们选择那些约翰逊(Johansen)和恩格尔-格兰杰(Engle-Granger)测试均同意进行交易的对。我们应该在回溯期内排除静止的资产,但我们排除了整个期间都是静止的资产,所以我们跳过了这一步以简化流程。

此过程遵循先前概述的步骤;请参阅笔记本以获取详细信息。

图 9.15显示了选定用于交易的两个不同配对的原始股票和 ETF 系列;请注意样本期间共同趋势的明显存在:

图 9.15:样本期间两个选定配对的价格系列

获取入场和出场交易

现在,我们可以根据滚动对冲比率计算每个候选配对的价差。我们还计算布林带,因为我们将考虑价差大于其移动平均值两个滚动标准偏差的移动,作为长和短的入场信号,并将移动平均值的交叉点反向作为退出信号。

使用卡尔曼滤波器平滑价格

为此,我们首先应用滚动卡尔曼滤波器KF)来消除一些噪音,正如在第四章金融特征工程 - 如何研究 Alpha 因子中所示:

def KFSmoother(prices):
    """Estimate rolling mean"""

    kf = KalmanFilter(transition_matrices=np.eye(1),
                      observation_matrices=np.eye(1),
                      initial_state_mean=0,
                      initial_state_covariance=1,
                      observation_covariance=1,
                      transition_covariance=.05)
    state_means, _ = kf.filter(prices.values)
    return pd.Series(state_means.flatten(),
                     index=prices.index) 

使用卡尔曼滤波器计算滚动对冲比率

为了获得动态对冲比率,我们使用 KF 进行滚动线性回归,如下所示:

def KFHedgeRatio(x, y):
    """Estimate Hedge Ratio"""
    delta = 1e-3
    trans_cov = delta / (1 - delta) * np.eye(2)
    obs_mat = np.expand_dims(np.vstack([[x], [np.ones(len(x))]]).T, axis=1)
    kf = KalmanFilter(n_dim_obs=1, n_dim_state=2,
                      initial_state_mean=[0, 0],
                      initial_state_covariance=np.ones((2, 2)),
                      transition_matrices=np.eye(2),
                      observation_matrices=obs_mat,
                      observation_covariance=2,
                      transition_covariance=trans_cov)
    state_means, _ = kf.filter(y.values)
    return -state_means 

估计均值回归的半衰期

如果我们将价差视为一个连续时间的均值回归随机过程,我们可以将其建模为奥恩斯坦-乌伦贝克过程。这种视角的好处在于,我们得到了一个均值回归半衰期的公式,作为偏离后价差再次收敛所需时间的近似值(有关详细信息,请参阅 Chan 2013 年的第二章市场和基本数据 - 来源和技术):

def estimate_half_life(spread):
    X = spread.shift().iloc[1:].to_frame().assign(const=1)
    y = spread.diff().iloc[1:]
    beta = (np.linalg.inv(X.T@X)@X.T@y).iloc[0]
    halflife = int(round(-np.log(2) / beta, 0))
    return max(halflife, 1) 

计算价差和布林带

以下函数组织了前述计算,并将价差表达为 z 分数,该分数捕捉了相对于滚动标准偏差的移动平均值的偏差,窗口大小等于两个半衰期:

def get_spread(candidates, prices):
    pairs, half_lives = [], []
    periods = pd.DatetimeIndex(sorted(candidates.test_end.unique()))
    start = time()
    for p, test_end in enumerate(periods, 1):
        start_iteration = time()
        period_candidates = candidates.loc[candidates.test_end == test_end, 
                                          ['y', 'x']]
        trading_start = test_end + pd.DateOffset(days=1)
        t = trading_start - pd.DateOffset(years=2)
        T = trading_start + pd.DateOffset(months=6) - pd.DateOffset(days=1)
        max_window = len(prices.loc[t: test_end].index)
        print(test_end.date(), len(period_candidates))
        for i, (y, x) in enumerate(zip(period_candidates.y, 
                                       period_candidates.x), 1):
            pair = prices.loc[t: T, [y, x]]
            pair['hedge_ratio'] = KFHedgeRatio(
                y=KFSmoother(prices.loc[t: T, y]),
                x=KFSmoother(prices.loc[t: T, x]))[:, 0]
            pair['spread'] = pair[y].add(pair[x].mul(pair.hedge_ratio))
            half_life = estimate_half_life(pair.spread.loc[t: test_end])
            spread = pair.spread.rolling(window=min(2 * half_life, 
                                                    max_window))
            pair['z_score'] = pair.spread.sub(spread.mean()).div(spread.
std())
            pairs.append(pair.loc[trading_start: T].assign(s1=y, s2=x, period=p, pair=i).drop([x, y], axis=1))
            half_lives.append([test_end, y, x, half_life])
    return pairs, half_lives 

获取长和短头寸的入场和出场日期

最后,我们使用一组 z 分数来推导交易信号:

  1. 如果 z 分数低于(高于)两个,我们进入长(空)头寸,这意味着价差已经移动了两个滚动标准偏差低于(高于)移动平均值

  2. 当价差再次穿过移动平均线时,我们退出交易

我们每季度制定一组规则,用于通过协整测试的一组配对,这些测试是在先前的回溯期间进行的,但允许在随后的 3 个月内退出配对。

我们再次简化了这个过程,通过删除在这个 6 个月期间没有收盘的配对。或者,我们可以通过我们在策略中包含的止损风险管理来处理这个问题(请参阅关于回测的下一节):

def get_trades(data):
    pair_trades = []
    for i, ((period, s1, s2), pair) in enumerate(
             data.groupby(['period', 's1', 's2']), 1):
        if i % 100 == 0:
            print(i)
        first3m = pair.first('3M').index
        last3m = pair.last('3M').index
        entry = pair.z_score.abs() > 2
        entry = ((entry.shift() != entry)
                 .mul(np.sign(pair.z_score))
                 .fillna(0)
                 .astype(int)
                 .sub(2))
        exit = (np.sign(pair.z_score.shift().fillna(method='bfill'))
                != np.sign(pair.z_score)).astype(int) - 1
        trades = (entry[entry != -2].append(exit[exit == 0])
                  .to_frame('side')
                  .sort_values(['date', 'side'])
                  .squeeze())
        trades.loc[trades < 0] += 2
        trades = trades[trades.abs().shift() != trades.abs()]
        window = trades.loc[first3m.min():first3m.max()]
        extra = trades.loc[last3m.min():last3m.max()]
        n = len(trades)
        if window.iloc[0] == 0:
            if n > 1:
                print('shift')
                window = window.iloc[1:]
        if window.iloc[-1] != 0:
            extra_exits = extra[extra == 0].head(1)
            if extra_exits.empty:
                continue
            else:
                window = window.append(extra_exits)
        trades = (pair[['s1', 's2', 'hedge_ratio', 'period', 'pair']]
                  .join(window. to_frame('side'), how='right'))
        trades.loc[trades.side == 0, 'hedge_ratio'] = np.nan
        trades.hedge_ratio = trades.hedge_ratio.ffill()
        pair_trades.append(trades)
    return pair_trades 

使用 backtrader 进行策略回测

现在,我们准备在我们的回测平台上制定我们的策略,执行它,并评估结果。为此,除了跟踪我们的配对以外,我们还需要跟踪单独的投资组合头寸,并监视活跃和非活跃配对的价差,以应用我们的交易规则。

使用自定义 DataClass 跟踪配对

为了考虑活跃的配对,我们定义了一个dataclass(在 Python 3.7 中引入 - 详见 Python 文档以获取详情)。这个数据结构,称为Pair,允许我们存储配对组件、它们的股数和对冲比率,并计算当前价差和收益等内容。请参见以下代码中的简化版本:

@dataclass
class Pair:
    period: int
    s1: str
    s2: str
    size1: float
    size2: float
    long: bool
    hr: float
    p1: float
    p2: float
    entry_date: date = None
    exit_date: date = None
    entry_spread: float = np.nan
    exit_spread: float = np.nan
    def compute_spread(self, p1, p2):
        return p1 * self.size1 + p2 * self.size2
    def compute_spread_return(self, p1, p2):
        current_spread = self.compute_spread(p1, p2)
        delta = self.entry_spread - current_spread
        return (delta / (np.sign(self.entry_spread) *
                         self.entry_spread)) 

运行和评估策略

关键的实施方面包括:

  • 每天退出已触发退出规则或超过给定负回报的配对

  • 对触发入场信号的价差的新多头和空头仓位的开仓

  • 此外,我们调整头寸以考虑不同数量的配对

策略本身的代码在这里展示太占用空间;详见笔记本pairs_trading_backtest获取详情。

图 9.16显示,至少在 2017-2019 年期间,这个简化的策略有其时机(请注意,我们利用了一些前瞻性偏见并忽略了交易成本)。

在这些宽松的假设下,它在期间的开始和结束时表现不及标普 500 指数,并且在其他时候大致处于相同水平(左侧面板)。它产生 0.08 的 alpha 和-0.14 的负 beta(右侧面板),平均夏普比率为 0.75,Sortino 比率为 1.05(中央面板):

图 9.16:策略绩效指标

尽管我们应该对这些绩效指标持谨慎态度,但这个策略展示了一种基于协整的统计套利的解剖学,以配对交易的形式呈现。让我们看看您可以采取哪些步骤来在此框架上进行改进以获得更好的表现。

扩展 - 如何做得更好

协整是一个非常有用的概念,可以识别出倾向于同步运动的股票配对或组合。与协整的统计复杂性相比,我们使用的是非常简单和静态的交易规则;季度基础上的计算也扭曲了策略,因为长期和短期持有的模式显示(请参见笔记本)。

要取得成功,你至少需要筛选更大的资产组合,并优化其中的几个参数,包括交易规则。此外,风险管理应考虑到当某些资产经常出现在同一交易对的同一侧时产生的集中仓位。

你也可以操作篮子而不是单个配对;然而,为了解决不断增长的候选数量,你可能需要限制篮子的组成。

正如在配对交易 - 具有协整的统计套利部分中所述,存在着旨在预测价格走势的替代方案。在接下来的章节中,我们将探讨各种机器学习模型,这些模型旨在预测给定投资范围和时间跨度的价格走势的绝对大小或方向。将这些预测用作多头和空头进入信号是对我们在本节中学习的配对交易框架的自然扩展或替代方法。

总结

在本章中,我们探讨了用于单个系列的单变量线性时间序列模型,以及用于多个交互系列的多变量模型。我们遇到了用于预测宏观基本面的应用程序,用于风险管理中广泛使用的预测资产或组合波动性的模型,以及捕捉多个宏观系列动态的多元 VAR 模型。我们还研究了协整概念,这是支撑流行的配对交易策略的基础。

类似于第七章线性模型 - 从风险因素到收益预测,我们看到线性模型施加了很多结构,也就是说,它们做出了强烈的假设,可能需要进行转换和广泛的测试来验证这些假设是否成立。如果确实如此,模型的训练和解释就会很直接,而且这些模型提供了一个很好的基准,更复杂的模型可能能够改进。在接下来的两章中,我们将看到两个示例,即随机森林和梯度提升模型,并且我们将在第四部分中遇到更多示例,该部分是关于深度学习的。

第十章:贝叶斯 ML – 动态夏普比率和对冲交易

在本章中,我们将介绍贝叶斯方法在机器学习ML)中的应用以及它们在开发和评估交易策略时对不确定性的不同视角所带来的价值。

贝叶斯统计学允许我们量化对未来事件的不确定性,并以原则性的方式在新信息到达时改进我们的估计。这种动态方法在金融市场的演变性质下适应良好。当相关数据较少且我们需要系统地整合先前知识或假设时,它特别有用。

我们将看到,贝叶斯方法对机器学习的应用允许更丰富地了解统计指标、参数估计和预测周围的不确定性。这些应用范围从更精细的风险管理到动态更新预测模型,其中包括市场环境的变化。资产配置的黑-利特曼方法(参见第五章组合优化和绩效评估)可以被解释为贝叶斯模型。它计算资产的预期回报作为市场均衡和投资者观点的加权平均值,每个资产的波动性,跨资产相关性和对每个预测的信心。

更具体地说,在本章中,我们将涵盖:

  • 贝叶斯统计学如何应用于 ML

  • 使用 PyMC3 进行概率编程

  • 使用 PyMC3 定义和训练 ML 模型

  • 如何运行最先进的抽样方法进行近似推理

  • 贝叶斯 ML 应用于计算动态夏普比率、动态对冲交易对冲比率和估计随机波动性

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和附加资源的链接。笔记本包括图像的彩色版本。

贝叶斯机器学习的工作原理

经典统计学被认为遵循频率主义方法,因为它将概率解释为长期观察大量试验后事件发生的相对频率。在概率的背景下,事件是一个或多个试验的基本结果的组合,例如掷两个骰子的六个等可能结果中的任何一个,或者在某一天资产价格下跌了 10%或更多。

相比之下,贝叶斯统计学将概率视为事件发生的信心或信念的度量。因此,贝叶斯观点为主观看法和意见差留下了更多的空间,而不是频率主义解释。这种差异在那些不经常发生以至于无法得出长期频率客观度量的事件中最为显著。

换句话说,频率统计假设数据是从总体中随机抽样的,并旨在识别生成数据的固定参数。贝叶斯统计则将数据视为给定,并认为参数是随数据可推断的随机变量的分布。因此,频率主义方法至少需要与要估计的参数数量相同数量的数据点。另一方面,贝叶斯方法适用于较小的数据集,并且非常适合从一次一个样本进行在线学习。

贝叶斯观点对于许多在某些重要方面罕见或独特的现实事件非常有用。例如,下次选举的结果或者市场是否会在 3 个月内崩溃的问题。在每种情况下,都有相关的历史数据以及随着事件逼近而展开的独特环境。

我们首先会介绍贝叶斯定理,该定理通过将先验假设与新的经验证据相结合来阐明通过更新信念的概念,并将结果参数估计与其频率论对应物进行比较。然后我们将展示两种贝叶斯统计推断方法,即共轭先验和近似推断,这些方法揭示了潜在参数(即未观察到的参数)的后验分布,如期望值:

  • 共轭先验 通过提供闭合形式的解决方案来促进更新过程,从而使我们能够精确计算解决方案。然而,这种精确的分析方法并不总是可用。

  • 近似推断 模拟由假设和数据结合而成的分布,并使用该分布的样本来计算统计洞见。

如何从经验证据中更新假设

"当事实改变时,我改变我的想法。您怎么做,先生?"

–约翰·梅纳德·凯恩斯

250 多年前,托马斯·贝叶斯牧师提出的定理使用基本的概率理论来规定概率或信念应该如何随着相关新信息的到来而改变。前面的凯恩斯引言捕捉了那种精神。它依赖于条件和总概率以及链式法则;有关介绍和更多信息,请参见 Bishop(2006)和 Gelman 等人(2013)。

概率信念涉及单个参数或参数向量(也称为假设)。每个参数可以是离散的或连续的。可以是一维统计量,例如(离散的)分类变量的众数或(连续的)均值,或者是更高维的值集,例如协方差矩阵或深度神经网络的权重。

与频率统计学的关键区别在于,贝叶斯假设是以概率分布而不是参数值表示的。因此,频率派推断侧重于点估计,而贝叶斯推断产生概率分布。

贝叶斯定理通过计算从以下输入计算后验概率分布来更新对感兴趣参数的信念,如图 10.1 所示:

  • 先验分布表示我们考虑每个可能假设的可能性有多大。

  • 似然函数输出在给定参数 值时观察到数据集的概率,即针对特定假设。

  • 证据衡量了给定所有可能假设的观察数据的可能性。因此,对于所有参数值,它是相同的,并用于规范化分子。

图 10.1:证据如何更新先验到后验概率分布

后验是先验和似然的乘积,除以证据。因此,它反映了假设的概率分布,通过考虑先前假设和数据更新。从不同角度看,后验概率来自应用链式规则,这反过来将数据和参数的联合分布分解为因子。

对于更高维度、连续变量,该公式变得更加复杂,涉及(多个)积分。此外,另一种表述使用赔率将后验赔率表达为先验赔率乘以似然比(参见 Gelman 等人 2013 年)。

精确推断 - 最大后验估计

将贝叶斯定理应用于精确计算后验概率的实际应用非常有限。这是因为计算分母中的证据项相当具有挑战性。证据反映了观察数据在所有可能参数值上的概率。它也被称为边际似然,因为它需要通过添加或对其分布进行积分来“边缘化”参数的分布。这通常仅在具有少量假设值的少量离散参数的简单情况下才可能。

最大后验概率(MAP)估计利用了证据是一个将后验缩放到满足概率分布要求的常数因子的事实。由于证据不依赖于 ,后验分布与似然和先验的乘积成正比。因此,MAP 估计选择使后验最大化的值,给定观察到的数据和先验信念,即后验的模式。

MAP 方法与定义概率分布的参数的最大似然估计(MLE)相对比。MLE 选择最大化观察训练数据的似然函数的参数值!

通过定义可以看出,MAP 与 MLE 的不同之处在于包括先验分布。换句话说,除非先验是一个常数,否则 MAP 估计将与其 MLE 对应物不同:

MLE 解决方案往往反映了频率学观念,即概率估计应反映观察到的比率。另一方面,先验对 MAP 估计的影响通常对应于将反映先验假设的数据添加到 MLE 中。例如,一个关于硬币偏向的强先验可以通过添加倾斜的试验数据来纳入 MLE 环境中。

先验分布是贝叶斯模型的关键组成部分。我们现在将介绍一些便利的选择,以促进分析推断。

如何选择先验

先验应该反映对参数分布的了解,因为它影响着 MAP 估计。如果先验不确定,我们需要做出选择,通常从几个合理的选项中选择。一般来说,证明先验并通过测试替代是否导致相同结论是一种良好的做法。

有几种类型的先验:

  • 客观先验将最大化数据对后验的影响。如果参数分布未知,我们可以选择一个无信息的先验,如参数值的相关范围上的均匀分布,也称为平坦先验

  • 相比之下,主观先验旨在将模型外的信息纳入估计中。在 Black-Litterman 环境中,投资者对资产未来收益的信念将是主观先验的一个例子。

  • 一个经验先验结合了贝叶斯和频率主义方法,并利用历史数据消除了主观性,例如,通过估计各种时刻来拟合标准分布。使用一些历史上的日收益率平均值而不是对未来收益的信念,就是一个简单经验先验的例子。

在 ML 模型的上下文中,先验可以被视为正则化器,因为它限制了后验可以假设的值。例如,具有零先验概率的参数不是后验分布的一部分。通常,更多的好数据允许得出更强的结论,并减少先验的影响。

如何保持推断简单 - 共轭先验

当得到的后验与先验相同类或家族的分布相同时,先验分布与似然函数共轭。例如,当先验和似然都是正态分布时,后验也是正态分布的。

先验和似然的共轭意味着后验的封闭形式解,有利于更新过程,并避免使用数值方法来近似后验。此外,得到的后验可以用作下一次更新步骤的先验。

让我们用股票价格变动的二元分类例子来说明这个过程。

资产价格变动的动态概率估计

当数据由具有某一成功概率的二元伯努利随机变量组成时,重复试验中的成功次数遵循二项分布。共轭先验是支持区间[0,1]上的贝塔分布,并具有两个形状参数,以对成功概率上的任意先验分布进行建模。因此,后验分布也是一个贝塔分布,我们可以通过直接更新参数来得到。

我们将收集不同大小的二值化的每日标普 500 指数收益率样本,其中正面结果是价格上涨。从一个不具信息的先验开始,在区间[0,1]中为每个可能的成功概率分配相等的概率,我们计算不同证据样本的后验概率。

下面的代码示例显示,更新仅涉及将观察到的成功和失败的数量添加到先验分布的参数中以获得后验:

n_days = [0, 1, 3, 5, 10, 25, 50, 100, 500]
outcomes = sp500_binary.sample(n_days[-1])
p = np.linspace(0, 1, 100)
# uniform (uninformative) prior
a = b = 1
for i, days in enumerate(n_days):
    up = outcomes.iloc[:days].sum()
    down = days - up
    update = stats.beta.pdf(p, a + up , b + down) 

结果后验分布已绘制在下图中。它们说明了从将所有成功概率视为等可能的均匀先验到越来越尖峰分布的演变。

经过 500 个样本后,从 2010 年到 2017 年,正向移动的概率集中在实际概率的 54.7%附近。它还显示了 MLE 和 MAP 估计之间的小差异,后者倾向于被稍微拉向均匀先验的期望值:

图 10.2:经过最多 500 次更新后的标普 500 指数第二天上涨概率的后验分布

在实践中,使用共轭先验的情况受到低维情况的限制。此外,简化的 MAP 方法避免了计算证据项,但有一个关键缺点,即使在可用时也是如此:它不返回分布,因此我们无法得出不确定性的度量或将其用作先验。因此,我们需要使用数值方法和随机模拟进行近似推断,而不是精确推断,接下来我们将介绍。

确定性和随机近似推断

对于大多数具有实际相关性的模型,将无法解析地推导出精确的后验分布并计算潜在参数的期望值。模型可能具有太多参数,或者后验分布可能对于解析解而言过于复杂:

  • 对于连续变量,积分可能没有封闭形式的解,而空间的维数和被积函数的复杂性可能会阻止数值积分。

  • 对于离散变量,边缘化涉及对所有可能的隐藏变量配置求和,尽管这在原则上总是可能的,但实践中我们经常发现可能存在指数多个隐藏状态,使得这种计算代价过高。

尽管对于一些应用来说,未观察参数的后验分布可能很重要,但通常主要是需要评估期望,例如进行预测。在这种情况下,我们可以依赖于近似推断,其中包括随机和确定性方法:

  • 基于马尔可夫链蒙特卡罗(MCMC)抽样的随机技术已经在许多领域推广了贝叶斯方法的使用。它们通常具有收敛到精确结果的属性。在实践中,抽样方法可能计算成本高,通常仅限于小规模问题。

  • 称为变分推断变分贝叶斯确定性方法基于对后验分布的解析近似,并且可以很好地扩展到大型应用程序。它们进行简化假设,例如,后验分解为特定方式或具有特定参数形式,例如高斯分布。因此,它们不生成精确结果,并且可以用作抽样方法的补充。

我们将在接下来的两个部分概述这两种方法。

马尔可夫链蒙特卡罗抽样

抽样是指从给定分布p(x)中绘制样本X=(x[1], …, x[n])。假设样本是独立的,大数定律确保对于增加的样本数量,样本中给定实例x[i]的比例(对于离散情况)对应于其概率p(x=x[i])。在连续情况下,类似的推理适用于样本空间的给定区域。因此,对样本的平均值可以用作分布参数的期望值的无偏估计量。

实际挑战之一是确保独立抽样,因为分布是未知的。依赖样本可能仍然是无偏的,但往往会增加估计的方差,因此需要更多的样本来获得与独立样本相同的精确估计。

从多元分布中抽样在状态数量随维度数呈指数增长时计算成本很高。许多算法简化了这个过程;我们将在这里介绍几种基于 MCMC 的流行变体。

马尔可夫链是描述一组由转移概率连接的状态上的随机游走的动态随机模型。马尔可夫属性规定了过程没有记忆,并且下一步仅取决于当前状态。换句话说,这取决于当前、过去和未来是否独立,也就是说,过去状态的信息不会帮助我们预测未来超出我们从现在所知道的内容。

蒙特卡罗方法依赖于重复随机抽样来近似可能是确定性的但不允许精确解析解的结果。它在曼哈顿计划期间开发,用于估算原子级别的能量,并获得了其持久的代码名称以确保保密性。

许多算法将蒙特卡罗方法应用于马尔可夫链,并通常按以下方式进行:

  1. 从当前位置开始。

  2. 从提议分布中绘制一个新位置。

  3. 根据数据和先验分布评估新位置的概率。

    1. 如果足够可能,移动到新位置。

    2. 否则,保持在当前位置。

  4. 步骤 1重复。

  5. 经过一定数量的迭代后,返回所有被接受的位置。

MCMC 方法旨在识别和探索后验中集中显著概率密度的有趣区域。当它连续地移动到后验的附近高概率状态时,被称为无记忆过程的过程收敛。一个关键挑战是平衡对样本空间随机探索的需求与降低接受率的风险。

过程的初始步骤可能更多地反映了起始位置而不是后验,并且通常被丢弃作为burn-in 样本。 MCMC 的一个关键属性是在一定(但未知)数量的迭代之后,该过程应“忘记”其初始位置。

剩下的样本被称为该过程的。假设收敛,则样本的相对频率近似后验,并且可以根据大数定律计算期望值。

如前所述,估计的精度取决于由随机游走收集的样本的串行相关性,其中每个样本设计上仅依赖于前一个状态。更高的相关性限制了对后验的有效探索,并且需要进行诊断测试。

设计这样一个马尔可夫链的一般技术包括 Gibbs 抽样、Metropolis-Hastings 算法和更近期的哈密顿 MCMC 方法,后者通常表现更好。

Gibbs 抽样

Gibbs 抽样将多变量抽样简化为一系列一维绘制。从某个起始点开始,它在抽样第n个变量时迭代地保持n-1 个变量不变。它包含了这个样本并重复它。

该算法非常简单易实现,但产生高度相关的样本,减慢了收敛速度。顺序性质也阻止了并行化。详见 Casella 和 George (1992) 中对其的详细描述和解释。

Metropolis-Hastings 取样

Metropolis-Hastings 算法根据当前状态随机提出新的位置。它这样做是为了有效地探索样本空间并减少相对于 Gibbs 采样的样本相关性。为了确保它从后验中取样,它使用先验和似然的乘积来评估提议,这与后验成比例。它接受的概率取决于结果相对于当前样本的相应值。

提议评估方法的一个关键优点是,它使用与后验的精确评估不同的比例评估。但是,它可能需要很长时间才能收敛。这是因为与后验无关的随机移动可能会降低接受率,从而使大量步骤只产生少量(可能相关的)样本。接受率可以通过减小提议分布的方差来调节,但由此产生的较小步骤意味着较少的探索。详见 Chib 和 Greenberg (1995) 中对该算法的详细介绍。

汉密尔顿蒙特卡洛 – 进入 NUTS

汉密尔顿蒙特卡洛 (HMC) 是一种混合方法,利用了似然梯度的一阶导数信息。借助此信息,它提出了新的状态以进行探索,并克服了一些 MCMC 的挑战。此外,它结合了动量以有效地在后验中“跳跃”。因此,它比简单的随机行走 Metropolis 或 Gibbs 采样更快地收敛到高维目标分布。详见 Betancourt (2018) 中全面的概念介绍。

无 U-Turn 采样器 (NUTS, Hoffman 和 Gelman 2011) 是一种自调整的 HMC 扩展,它在选择提议之前自适应地调节后验周围的移动的大小和数量。它在高维和复杂的后验分布上表现良好,并允许拟合许多复杂模型而无需关于拟合算法本身的专业知识。正如我们将在下一节中看到的,它是 PyMC3 中的默认采样器。

变分推断和自动微分

变分推断 (VI) 是一种通过优化来近似概率密度的机器学习方法。在贝叶斯背景下,它近似后验分布,如下所示:

  1. 选择一个参数化的概率分布族

  2. 找到该族中距离目标最近的成员,其度量为 Kullback-Leibler 散度

与 MCMC 相比,变分贝叶斯倾向于更快地收敛并且对大数据具有更好的可扩展性。虽然 MCMC 通过来自链条的样本逼近后验概率,这些样本最终将收敛到目标任意接近的地方,但变分算法通过优化的结果来逼近后验概率,不能保证与目标重合。

变分推断更适用于大型数据集,例如,数亿个文本文档,因此我们可以快速探索许多模型。相反,当时间和计算资源的约束较少时,MCMC 将在较小的数据集上提供更精确的结果。例如,如果您花了 20 年时间收集了一个小但昂贵的数据集,并且确信您的模型是适当的,并且您需要精确的推断,则 MCMC 将是一个不错的选择。有关更详细的比较,请参见 Salimans、Kingma 和 Welling(2015)。

变分推断的缺点是需要模型特定的导出和定制优化例程的实现,这减慢了广泛采用的速度。

最近的自动微分变分推断ADVI)算法自动化了这个过程,使得用户只需指定模型,表达为一个程序,ADVI 会自动生成相应的变分算法(详见 GitHub 上的参考资料以了解实现细节)。

我们将看到 PyMC3 支持各种变分推断技术,包括 ADVI。

使用 PyMC3 的概率编程

概率编程提供了一种描述和拟合概率分布的语言,以便我们可以设计、编码并自动估计和评估复杂的模型。它旨在抽象出一些计算和分析复杂性,以便我们可以专注于贝叶斯推理和推断的概念上更直观、更简单的方面。

自 Uber 开源了 Pyro(基于 PyTorch)后,该领域变得非常动态。最近,Google 为 TensorFlow 添加了一个概率模块

因此,贝叶斯方法在机器学习中的实际相关性和使用可能会增加,以生成对不确定性有洞察力的见解,特别是对于需要透明而不是黑匣子模型的用例。

在本节中,我们将介绍流行的PyMC3库,该库使用 Python 为 ML 模型实现了高级 MCMC 抽样和变分推断。与 Stan(以蒙特卡罗方法发明者 Stanislaw Ulam 命名,由哥伦比亚大学的 Andrew Gelman 自 2012 年以来开发)一起,PyMC3 是最受欢迎的概率编程语言。

使用 Theano 的贝叶斯机器学习

PyMC3 于 2017 年 1 月发布,以将 Hamiltonian MC 方法添加到 PyMC2(2012 年发布)中使用的 Metropolis-Hastings 采样器中。PyMC3 使用 Theano 作为其计算后端,用于动态 C 编译和自动微分。Theano 是由 Yoshua Bengio 的蒙特利尔学习算法研究所MILA)开发的一个以矩阵为重点且支持 GPU 的优化库,这启发了 TensorFlow。由于更新的深度学习库的成功,MILA 最近停止进一步开发 Theano(详见第十六章收益电话和 SEC 文件的单词嵌入)。

PyMC4 于 2019 年 12 月发布 alpha 版,使用 TensorFlow 代替 Theano,并旨在限制对 API 的影响(请参阅 GitHub 上的存储库链接)。

PyMC3 工作流程 - 预测衰退

PyMC3 旨在实现直观、易读且功能强大的语法,反映了统计学家描述模型的方式。建模过程通常遵循以下三个步骤:

  1. 通过定义编码概率模型:

    1. 量化潜变量的知识和不确定性的先验分布

    2. 将条件参数化为观察数据的似然函数

  2. 使用前一节描述的选项之一分析后验:

    1. 使用 MAP 推断获取一个点估计

    2. 使用 MCMC 方法从后验中采样

    3. 使用变分贝叶斯近似后验

  3. 使用各种诊断工具检查您的模型

  4. 生成预测

生成的模型可用于推理,以获得有关参数值的详细见解,以及对新数据点的预测结果。

我们将使用简单的 logistic 回归来说明这个工作流程,以模拟衰退的预测(请参阅笔记本pymc3_workflow)。随后,我们将使用 PyMC3 来计算和比较贝叶斯夏普比率,估计动态配对交易比率,并实现贝叶斯线性时间序列模型。

数据 - 领先的衰退指标

我们将使用一个小型和简单的数据集,以便我们可以专注于工作流程。我们将使用美联储经济数据FRED)服务(详见第二章市场和基本数据 - 来源和技术)下载由美国经济研究局NBER)定义的美国衰退日期。我们还将获取四个通常用于预测衰退发生的变量(Kelley 2019),这些变量可以通过 FRED 获得,即:

  • 长期国债收益率曲线的传播,定义为 10 年期和 3 个月期国债收益率之间的差异

  • 密歇根大学的消费者情绪指数

  • 全国金融状况指数NFCI

  • NFCI 非金融杠杆子指数

衰退日期以季度为基础确定;我们将重新采样所有系列的频率为每月频率,以从 1982 年至 2019 年获得大约 457 个观测值。如果一个季度被标记为衰退,我们将考虑该季度中的所有月份都是衰退的。

我们将构建一个意图回答问题的模型:未来 x 个月美国经济是否会陷入衰退? 换句话说,我们不只关注预测衰退的第一个月;这限制了 48 个衰退月的不平衡性。

为此,我们需要选择一个先导时间;已经进行了大量研究,以找到各种领先指标的适当时间范围:收益率曲线倾向于在衰退之前提前至多 24 个月发送信号;NFCI 指标倾向于具有较短的先导时间(参见 Kelley, 2019)。

以下表格在很大程度上证实了这种经验:它显示了二元衰退变量与四个领先指标之间的互信息(参见第六章机器学习流程)在 1-24 个月的时间段内的情况:

图

图 10.3:衰退与领先指标之间的互信息,时间段为 1-24 个月

为了在 NFCI 指标的较短时间范围和收益率曲线之间取得平衡,我们将选择 12 个月作为我们的预测时间范围。 以下图表显示了每个指标的分布,按衰退状态划分:

图

图 10.4:按衰退状态划分的领先指标分布

这表明,经济衰退往往与国债收益率曲线的长期差价负相关,也被称为倒挂收益率曲线,当短期利率上升到长期利率之上时。 NFCI 指标的表现符合我们的预期;情绪指标似乎具有最弱的关联性。

模型定义 - 贝叶斯逻辑回归

第六章机器学习流程中讨论的那样,逻辑回归估计了一组特征与二元结果之间的线性关系,通过一个 Sigmoid 函数进行中介,以确保模型产生概率。 频率主义方法导致了参数的点估计,这些参数测量了每个特征对数据点属于正类的概率的影响,置信区间基于参数分布的假设。

相比之下,贝叶斯逻辑回归估计参数本身的后验分布。 后验允许更健壮地估计每个参数的所谓贝叶斯可信区间,其好处在于更透明地了解模型的不确定性。

概率程序由观察和未观察的随机变量RVs)组成。 正如之前讨论的,我们通过似然分布定义观察到的 RVs,通过先验分布定义未观察到的 RVs。 PyMC3 包含许多概率分布,用于此目的。

PyMC3 库使得执行逻辑回归的近似贝叶斯推断非常简单。 逻辑回归模型基于左侧图中所述的 k 个特征,来建模经济在 i 个月后是否会陷入衰退的概率:

图 10.5:贝叶斯逻辑回归

我们将使用上下文管理器with来定义一个我们稍后可以引用的manual_logistic_model作为概率模型:

  1. 未观测参数的 RVs,用于拦截和两个特征的被表达使用不含信息的先验,这些假设正态分布的均值为 0,标准差为 100。

  2. 似然性根据逻辑回归的规范将参数与数据结合起来。

  3. 结果被建模为具有由似然给定的成功概率的 Bernoulli RV:

    with pm.Model() as manual_logistic_model:
        # coefficients as rvs with uninformative priors
        intercept = pm.Normal('intercept', 0, sd=100)
        beta_1 = pm.Normal('beta_1', 0, sd=100)
        beta_2 = pm.Normal('beta_2', 0, sd=100)
        # Likelihood transforms rvs into probabilities p(y=1)
        # according to logistic regression model.
        likelihood = pm.invlogit(intercept +
                                 beta_1 * data.yield_curve +
                                 beta_2 * data.leverage)
        # Outcome as Bernoulli rv with success probability
        # given by sigmoid function conditioned on actual data
        pm.Bernoulli(name='logit',
                     p=likelihood,
                     observed=data.recession) 
    

模型可视化和板符号

命令pm.model_to_graphviz(manual_logistic_model)生成右侧图 10.5中显示的板符号。 它显示了未观察到的参数作为浅色椭圆,观察到的元素作为深色椭圆。 矩形表示在模型定义中包含的数据暗示的观察到的模型元素的重复次数。

广义线性模型模块

PyMC3 包括许多常见的模型,以便我们可以限制自定义应用程序的手动规范。

以下代码将相同的逻辑回归定义为广义线性模型 (GLM)家族的成员。它使用受统计语言 R 启发的公式格式,并由 patsy 库移植到 Python 中:

with pm.Model() as logistic_model:
    pm.glm.GLM.from_formula(recession ~ yield_curve + leverage,
                            data,
                            family=pm.glm.families.Binomial()) 

精确 MAP 推断

我们使用刚刚定义的模型的.find_MAP()方法获得三个参数的点 MAP 估计。 如预期的那样,较低的展开值增加了衰退的概率,较高的杠杆(但程度较小)也是如此:

with logistic_model:
    map_estimate = pm.find_MAP()
print_map(map_estimate)
Intercept     -4.892884
yield_curve   -3.032943
leverage       1.534055 

PyMC3 使用拟牛顿Broyden-Fletcher-Goldfarb-Shanno (BFGS)算法解决找到具有最高密度的后验点的优化问题,但提供了 SciPy 库提供的几种替代方案。

MAP 点估计与相应的statsmodels系数相同(请参阅笔记本pymc3_workflow)。

近似推断 – MCMC

如果我们只对模型参数的点估计感兴趣,那么对于这个简单模型来说,MAP 估计就足够了。 更复杂的自定义概率模型需要采样技术以获得参数的后验概率。

我们将使用所有变量的模型来说明 MCMC 推断:

formula = 'recession ~ yield_curve + leverage + financial_conditions + sentiment'
with pm.Model() as logistic_model:
    pm.glm.GLM.from_formula(formula=formula,
                            data=data,
                            family=pm.glm.families.Binomial())
# note that pymc3 uses y for the outcome
logistic_model.basic_RVs
[Intercept, yield_curve, leverage, financial_conditions, sentiment, y] 

请注意,测量在非常不同尺度上的变量可能会减慢采样过程。 因此,我们首先应用由 scikit-learn 提供的scale()函数来标准化所有特征。

一旦我们用新的公式定义了我们的模型,我们就可以执行推断以近似后验分布。 MCMC 采样算法可通过pm.sample()函数获得。

默认情况下,PyMC3 自动选择最有效的采样器并初始化采样过程以实现高效收敛。对于连续模型,PyMC3 选择在前一节中讨论的 NUTS 采样器。它还通过 ADVI 运行变分推断以找到采样器的良好起始参数。其中一种替代方法是使用 MAP 估计。

要了解收敛的情况,我们首先在调整采样器 1,000 次迭代后只绘制 100 个样本。这些将被丢弃。可以使用cores参数(除了使用 GPU 时)将采样过程并行化为多个链:

with logistic_model:
    trace = pm.sample(draws=100,
                      tune=1000,
                      init='adapt_diag',
                      chains=4,
                      cores=4,
                      random_seed=42) 

结果的trace包含每个 RV 的采样值。我们可以使用plot_traces()函数检查链的后验分布。

plot_traces(trace, burnin=0) 

图 10.6显示了前两个特征和截距的样本分布及其随时间的值(请参阅笔记本以获取完整输出)。此时,采样过程尚未收敛,因为对于每个特征,四条轨迹产生了完全不同的结果;左侧五个面板中垂直显示的数字是由四条轨迹生成的分布的模式的平均值:

图 10.6:100 个样本后的轨迹

我们可以通过提供先前运行的迹作为输入来继续采样。额外 20,000 个样本后,我们观察到一个非常不同的图像,如下图所示。这显示了采样过程现在更接近收敛。还要注意,初始系数点估计值与当前值相对较接近:

图 10.7:额外 50,000 个样本后的轨迹

我们可以计算可信区间,即贝叶斯区间估计的对应物,作为迹的百分位数。结果边界反映了我们对于给定概率阈值的参数值范围的置信度,而不是参数在大量试验中在此范围内的次数。图 10.8显示了变量的收益曲线和杠杆的可信区间,以系数值的指数函数(参见第七章线性模型 - 从风险因素到收益预测)来表示。

请参阅笔记本pymc3_workflow的实现:

图 10.8:收益曲线和杠杆的可信区间

近似推断 - 变分贝叶斯

变分推断的接口与 MCMC 实现非常相似。我们只是使用fit()而不是sample()函数,可以选择包括一个早期停止的CheckParametersConvergence回调,如果分布拟合过程收敛到给定的容差:

with logistic_model:
    callback = CheckParametersConvergence(diff='absolute')
    approx = pm.fit(n=100000,
                    callbacks=[callback]) 

我们可以从近似分布中抽取样本以获得一个迹对象,就像我们之前为 MCMC 采样器所做的那样:

trace_advi = approx.sample(10000) 

检查跟踪摘要显示结果稍微不太准确。

模型诊断

贝叶斯模型诊断包括验证采样过程是否收敛并且是否一直从后验的高概率区域进行采样,以及确认模型是否很好地表示了数据。

收敛性

我们可以通过随时间变化的样本和它们的分布来可视化结果的质量。下图显示了初始 100 个样本和额外的 200,000 个样本后的后验分布,说明了收敛意味着多个链识别相同的分布:

图 10.9:400 个样本后和超过 200,000 个样本后的跟踪

PyMC3 为采样器生成各种摘要统计信息。这些信息可以作为统计模块中的单独函数提供,也可以通过将追踪传递给函数pm.summary()来获取。

下表包含(分别计算的)statsmodels 逻辑斯蒂回归系数作为第一列,以显示在这种简单情况下,两个模型略有一致,因为样本均值与系数不匹配。这很可能是由于准分离的高程度造成的:收益率曲线的高可预测性允许对 17%的数据点进行完美预测,这反过来导致逻辑回归的最大似然估计定义不清晰(有关更多信息,请参见笔记本中的 statsmodels 输出)。

参数 statsmodels PyMC3
系数 均值 标准差
截距 -5.22 -5.47
收益率曲线 -3.30 -3.47
杠杆 1.98 2.08
金融条件 -0.65 -0.70
情绪 -0.33 -0.34

剩余列包含最高后验密度(HPD)估计的最小宽度可信区间,这是置信区间的贝叶斯版本,此处计算为 95%的水平。n_eff统计量总结了从绘制的有效(未被拒绝)样本数量。

R-hat,也被称为Gelman-Rubin 统计量,通过比较链间方差和链内方差来检查收敛性。如果采样器收敛了,这些方差应该相同,即链应该看起来相似。因此,该统计量应接近 1。

对于具有许多变量的高维模型,检查大量的跟踪变得很麻烦。在使用 NUTS 时,能量图帮助我们评估收敛问题。它总结了随机过程探索后验的效率。图表显示了能量和能量转换矩阵,它们应该匹配良好,就像下图右侧面板中显示的例子一样:

图 10.10: 森林和能量图

后验预测检查

后验预测检查 (PPCs) 对于检查模型与数据拟合程度非常有用。它们通过使用从后验抽取的参数从模型生成数据来实现这一点。我们使用函数 pm.sample_ppc 来实现此目的,并为每个观测值获取 n 个样本(GLM 模块自动将结果命名为 'y'):

ppc = pm.sample_ppc(trace_NUTS, samples=500, model=logistic_model)
ppc['y'].shape
(500, 445) 

我们可以使用接收器操作特征曲线下面积(AUC,参见第六章机器学习过程)得分来评估样本内适配度,例如,比较不同的模型:

roc_auc_score(y_score=np.mean(ppc['y'], axis=0),
              y_true=data.income)
0.9483627204030226 

结果相当高,几乎为 0.95。

如何生成预测

预测使用 Theano 的 共享变量 来在运行后验预测检查之前用测试数据替换训练数据。为了可视化和简化表述,我们使用收益曲线变量作为唯一的预测变量,并忽略我们数据的时间序列特性。

相反,我们使用 scikit-learn 的基本 train_test_split() 函数创建训练集和测试集,通过结果进行分层,以保持类别不平衡:

X = data[['yield_curve']]
labels = X.columns
y = data.recession
X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=y) 

然后我们为该训练集创建一个共享变量,然后在下一步中用测试集替换它。请注意,我们需要使用 NumPy 数组并提供列标签的列表:

X_shared = theano.shared(X_train.values)
with pm.Model() as logistic_model_pred:
    pm.glm.GLM(x=X_shared, labels=labels,
               y=y_train, family=pm.glm.families.Binomial()) 

然后我们像之前一样运行采样器:

with logistic_model_pred:
    pred_trace = pm.sample(draws=10000,
                           tune=1000,
                           chains=2,
                           cores=2,
                           init='adapt_diag') 

现在,我们将测试数据替换为共享变量上的训练数据,并将 pm.sample_ppc 函数应用于生成的 trace

X_shared.set_value(X_test)
ppc = pm.sample_ppc(pred_trace,
                    model=logistic_model_pred,
                    samples=100)
y_score = np.mean(ppc['y'], axis=0)
roc_auc_score(y_score=np.mean(ppc['y'], axis=0),
              y_true=y_test)
0.8386 

这个简单模型的 AUC 分数是 0.86。显然,如果训练集已经包含了来自附近月份的衰退示例,那么对于另一个月份预测相同的衰退要容易得多。请记住,我们仅用于演示目的使用此模型。

图 10.11 绘制了从 100 个 Monte Carlo 链中抽样的预测及其周围的不确定性,以及实际的二元结果和对应于模型预测的 logistic 曲线:

图 10.11: 单变量模型预测

摘要和主要观点

我们建立了一个简单的 logistic 回归模型,用于预测美国经济在 12 个月内陷入衰退的概率,使用了四个领先指标。对于这个简单模型,我们可以得到精确的 MAP 估计系数值,然后使用这些系数值对模型进行参数化和预测。

但是,更复杂的自定义概率模型将不允许此捷径,而且 MAP 估计也不会生成有关点估计之外的后验分布的洞见。因此,我们演示了如何使用 PyMC3 进行近似推理。结果说明了我们如何了解每个模型参数的后验分布,但也显示出即使对于一个小模型,与 statsmodels MLE 估计相比,计算成本也会显著增加。尽管如此,对于复杂的概率模型,基于采样的解决方案是了解数据的唯一途径。

我们现在将说明如何将贝叶斯分析应用于一些与交易相关的用例。

交易的贝叶斯机器学习

既然我们熟悉了 ML 和用 PyMC3 进行概率编程的贝叶斯方法,让我们探讨一下几个相关的交易应用,即:

  • 将夏普比率建模为更具洞察力的性能比较的概率模型

  • 使用贝叶斯线性回归计算配对交易对冲比率

  • 从贝叶斯角度分析线性时间序列模型

托马斯·韦基,PyMC3 的主要作者之一,也是 Quantopian 的数据科学主管,已经创建了几个示例,以下各节将继续并在其基础上建立。PyMC3 文档有许多额外的教程(请查看 GitHub 获取链接)。

用于性能比较的贝叶斯夏普比率

在本节中,我们将说明:

  • 如何使用 PyMC3 定义夏普比率SR)作为概率模型

  • 如何比较不同回报序列的后验分布

为两个序列进行贝叶斯估计提供了非常丰富的见解,因为它提供了效应大小,组 SR 均值及其差异的可信值的完整分布,以及标准偏差及其差异的完整分布。Python 实现是由 Thomas Wiecki 完成的,并受到了 R 包 BEST(Meredith 和 Kruschke,2018)的启发。

使用贝叶斯 SR 的相关用例包括分析替代策略之间的差异,或者策略的样本内回报与样本外回报之间的差异(详见笔记本bayesian_sharpe_ratio)。贝叶斯 SR 也是 pyfolio 的贝叶斯信息单页的一部分。

定义一个自定义的概率模型

要将 SR 建模为概率模型,我们需要关于回报分布及其控制此分布的参数的先验。相对于低自由度DF)的正态分布,学生 t 分布具有较厚的尾部,是捕获回报此方面的合理选择。

因此,我们需要对此分布的三个参数进行建模,即回报的均值和标准偏差,以及 DF。我们将假设对于均值和标准偏差,分别采用正态和均匀分布,对于 DF 采用指数分布,期望值足够低以确保尾部较厚。

这些概率输入是基于这些回报的,而年化 SR 来自标准计算,忽略无风险利率(使用每日回报)。我们将提供 2010-2018 年的 AMZN 股票回报作为输入(有关数据准备的更多信息,请参阅笔记本):

mean_prior = data.stock.mean()
std_prior = data.stock.std()
std_low = std_prior / 1000
std_high = std_prior * 1000
with pm.Model() as sharpe_model:
    mean = pm.Normal('mean', mu=mean_prior, sd=std_prior)
    std = pm.Uniform('std', lower=std_low, upper=std_high)
    nu = pm.Exponential('nu_minus_two', 1 / 29, testval=4) + 2
    returns = pm.StudentT('returns', nu=nu, mu=mean, sd=std,
observed=data.stock)
    sharpe = returns.distribution.mean / returns.distribution.variance ** 
.5 * np.sqrt(252)
    pm.Deterministic('sharpe', sharpe) 

板符号,我们在关于 PyMC3 工作流程的上一节中引入的,可视化了三个参数及其关系,以及我们在以下图中提供的回报和观察次数:

图 10.12:贝叶斯 SR 在板符号中

然后我们运行我们在前一节介绍的 MCMC 抽样过程(请参阅笔记本bayesian_sharpe_ratio以了解随后的实现细节)。经过约 25000 次对四个链的抽样后,我们获得了模型参数的后验分布,结果显示在以下图中:

plot_posterior(data=trace); 

图 10.13:模型参数的后验分布

现在我们知道如何评估单个资产或投资组合的 SR,让我们看看如何使用贝叶斯 SR 来比较两个不同回报序列的性能。

比较两个回报序列的性能

为了比较两个回报序列的性能,我们将分别对每个组的 SR 进行建模,并将效应大小计算为波动率调整后回报之间的差异。以下图表显示的相应概率模型自然更大,因为它包括两个 SR 以及它们的差异:

图 10.14:在板符号中两个贝叶斯 SR 之间的差异

一旦我们定义了模型,我们就通过 MCMC 抽样过程运行它以获得其参数的后验分布。我们使用 2010-2018 年的 2,037 个每日回报来比较 AMZN 股票与同一时期的标普 500 指数回报。我们可以使用我们任何策略回测的回报,而不是 AMZN 的回报。

可视化轨迹揭示了对每个指标分布的精细性能洞察,如图 10.15中的各种图表所示:

图 10.15:两个贝叶斯 SR 之间的后验分布

最重要的度量标准是底部面板中两个 SR 之间的差异。给定完整的后验分布,可以直观地可视化或计算从 SR 角度来看一个回报序列优越的概率。

用于配对交易的贝叶斯滚动回归

在前一章中,我们介绍了配对交易作为一种流行的交易策略,它依赖于两个或更多资产的协整性。 鉴于这样的资产,我们需要估计对冲比率,以决定多头和空头位置的相对大小。 一种基本方法使用线性回归。 你可以在笔记本rolling_regression中找到此部分的代码,该笔记本遵循了托马斯·威克(Thomas Wiecki)的滚动回归示例(请参阅 GitHub 上 PyMC3 教程的链接)。

配对交易候选资产的一个流行示例是 ETF GLD,它反映了金价和像 GFI 这样的金矿股票。 我们使用 yfinance 获取 2004 年至 2020 年期间的收盘价数据。 图 10.16的左侧面板显示了历史价格序列,而右侧面板显示了历史价格的散点图,其中色调表示时间维度,以突出显示相关性的演变方式。 注意我们应该使用收益率,就像我们在第九章波动率预测和统计套利的时间序列模型中所做的那样,来计算对冲比率; 然而,使用价格序列创建更引人注目的可视化效果。 建模过程本身保持不变:

图 10.16:两对交易候选资产的价格序列和随时间的相关性

我们想说明滚动贝叶斯线性回归如何随时间跟踪两个资产价格之间关系的变化。 主要思想是通过允许回归系数的变化将时间维度纳入线性回归中。 具体来说,我们将假设截距和斜率随时间呈随机游走:

我们使用 PyMC3 内置的pm.GaussianRandomWalk过程指定model_randomwalk。 它要求我们为截距 alpha 和斜率 beta 都定义标准差:

model_randomwalk = pm.Model()
with model_randomwalk:
    sigma_alpha = pm.Exponential('sigma_alpha', 50.)
    alpha = pm.GaussianRandomWalk('alpha', 
                                  sd=sigma_alpha,
                                  shape=len(prices))
    sigma_beta = pm.Exponential('sigma_beta', 50.)
    beta = pm.GaussianRandomWalk('beta', 
                                 sd=sigma_beta,
                                 shape=len(prices)) 

鉴于概率模型的规范,我们现在将定义回归并将其连接到输入数据:

with model_randomwalk:
    # Define regression
    regression = alpha + beta * prices_normed.GLD
    # Assume prices are normally distributed
    # Get mean from regression.
    sd = pm.HalfNormal('sd', sd=.1)
    likelihood = pm.Normal('y', 
                           mu=regression, 
                           sd=sd, 
                           observed=prices_normed.GFI) 

现在,我们可以运行 MCMC 采样器以生成模型参数的后验分布:

with model_randomwalk:
    trace_rw = pm.sample(tune=2000, 
                         cores=4, 
                         draws=200, 
                         nuts_kwargs=dict(target_accept=.9)) 

图 10.17描述了截距和斜率系数随时间的变化,强调了演变的相关性:

图 10.17:截距和斜率系数随时间的变化

使用动态回归系数,我们现在可以通过这种贝叶斯方法来可视化滚动回归建议的对冲比率随时间的变化,该方法将系数建模为随机游走。

下图将价格序列和回归线结合在一起,其中色调再次表示时间线(在笔记本中查看以获得彩色输出):

图 10.18:滚动回归线和价格序列

对于我们的最后一个示例,我们将实现一个贝叶斯随机波动模型。

随机波动模型

正如前一章所讨论的,资产价格具有时变波动性。在某些时期,收益非常不稳定,而在其他时期则非常稳定。我们在 第九章波动性预测和统计套利的时间序列模型 中从经典线性回归的角度探讨了 ARCH/GARCH 模型。

贝叶斯随机波动性模型通过潜在波动性变量捕捉这种波动现象,该变量被建模为随机过程。使用这种模型介绍了无 U 转向采样器 (Hoffman, et al. 2011),笔记本 stochastic_volatility 使用了 S&P 500 每日数据来说明这种用例。图 10.19 显示了此期间的几个波动性集群:

图 10.19:每日 S&P 500 对数收益率

概率模型规定对数收益率遵循 t 分布,该分布具有厚尾,如资产收益通常观察到的情况。 t 分布受参数 ν 控制,表示自由度。它也被称为正态参数,因为 t 分布在 ν 增加时逼近正态分布。假定此参数具有参数 的指数分布。

此外,假定对数收益率的平均值为零,而标准差遵循具有指数分布的标准差随机游走:

我们将此模型实现为 PyMC3,以模仿其概率规范,使用对数收益率匹配模型:

prices = pd.read_hdf('../data/assets.h5', key='sp500/prices').loc['2000':,
                                                                  'Close']
log_returns = np.log(prices).diff().dropna()
with pm.Model() as model:
    step_size = pm.Exponential('sigma', 50.)
    s = GaussianRandomWalk('s', sd=step_size, 
                           shape=len(log_returns))
    nu = pm.Exponential('nu', .1)
    r = pm.StudentT('r', nu=nu, 
                    lam=pm.math.exp(-2*s), 
                    observed=log_returns) 

接下来,在经过 2,000 个样本的燃烧阶段后,我们绘制了 5,000 个 NUTS 样本,使用比默认值 0.8 更高的接受率,根据 PyMC3 文档建议,对于有问题的后验分布(请参阅 GitHub 上的适当链接):

with model:
    trace = pm.sample(tune=2000, 
                      draws=5000,
                      nuts_kwargs=dict(target_accept=.9))
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [nu, s, sigma]
Sampling 4 chains, 0 divergences: 100%|██████████| 28000/28000 [27:46<00:00, 16.80draws/s]
The estimated number of effective samples is smaller than 200 for some parameters. 

在四个链总共 28,000 个样本之后,下图中的迹线图证实了采样过程已经收敛:

图 10.20:随机波动率模型的迹线图

当我们将样本绘制到 图 10.21 中的 S&P 500 收益率上时,我们可以看到这个简单的随机波动性模型相当好地跟踪了波动性集群:

图 10.21:模型

记住这代表了样本内拟合。作为下一步,你应该尝试评估预测准确性。我们在前面滚动线性回归的子章节中介绍了如何进行预测,并在前几章中使用了时间序列交叉验证,这为您提供了完成此目的所需的所有工具!

概要

在本章中,我们探讨了贝叶斯方法与机器学习的结合。我们看到它们有几个优点,包括能够编码先验知识或意见,更深入地了解模型估计和预测周围的不确定性,并适用于在线学习,其中每个训练样本逐渐影响模型的预测。

我们学习了如何使用 PyMC3 应用贝叶斯工作流程,从模型规范到估计、诊断和预测,并探讨了几个相关的应用场景。我们将在第十四章中遇到更多的贝叶斯模型,用于交易的文本数据 - 情感分析,在那里我们将讨论自然语言处理和主题建模,以及在第二十章中,用于条件风险因素和资产定价的自动编码器,在那里我们将介绍变分自动编码器。

下一章介绍了非线性的基于树的模型,即决策树,并展示了如何将多个模型组合成一组树的集合,以创建一个随机森林。

第十一章:随机森林 - 用于日本股票的长短策略

在本章中,我们将学习如何使用两种新的机器学习模型进行交易:决策树随机森林。我们将看到决策树如何从数据中学习规则,这些规则编码了输入和输出变量之间的非线性关系。我们将说明如何训练决策树,并使用它进行回归和分类问题的预测,可视化并解释模型学到的规则,并调整模型的超参数以优化偏差-方差权衡,并防止过拟合。

决策树不仅是重要的独立模型,而且经常被用作其他模型的组成部分。在本章的第二部分中,我们将介绍集成模型,这些模型将多个单独模型组合起来,产生一个具有更低预测误差方差的单一聚合预测。

我们将阐述自助聚合,通常称为bagging,作为几种方法之一,用于随机化个体模型的构建,并减少由集合成员的预测误差造成的相关性。我们将说明如何通过 bagging 有效地减少方差,并学习如何配置、训练和调整随机森林。我们将看到随机森林作为一个(可能较大的)决策树集合,可以显著减少预测误差,但可能会牺牲一些解释性。

然后,我们将继续构建一个使用随机森林为过去 3 年大型日本股票生成盈利信号的长短交易策略。我们将获取并准备股价数据,调整随机森林模型的超参数,并根据模型的信号进行交易规则的回测。由此产生的长短策略使用机器学习而不是我们在第九章中看到的共整关系,以识别和交易在给定投资期限内价格可能朝相反方向移动的证券篮子。

简而言之,阅读完本章后,你将能够:

  • 使用决策树进行回归和分类

  • 从决策树中获得见解,并可视化从数据中学到的规则

  • 理解为什么集成模型往往能够提供优越的结果

  • 使用自助聚合来解决决策树过拟合的挑战

  • 训练、调整和解释随机森林

  • 使用随机森林设计和评估盈利交易策略

你可以在 GitHub 仓库的相应目录中找到本章的代码示例和其他资源的链接。笔记本包括图像的彩色版本。

决策树 - 从数据中学习规则

决策树是一种机器学习算法,它根据从数据中学习到的决策规则来预测目标变量的值。该算法可以通过改变管理树如何学习规则的目标函数应用于回归和分类问题。

我们将讨论决策树如何使用规则进行预测,如何训练它们来预测(连续的)收益以及(分类的)价格走势方向,以及如何有效地解释、可视化和调整它们。有关更多详细信息和背景信息,请参阅 Rokach 和 Maimon(2008)以及 Hastie、Tibshirani 和 Friedman(2009)。

树是如何学习和应用决策规则的

我们在第七章线性模型 - 从风险因素到收益预测第九章时间序列模型用于波动率预测和统计套利中学习的线性模型通过学习一组参数来预测输出,可能在逻辑回归的情况下通过 S 形链接函数进行转换。

决策树采用不同的方法:它们学习并依次应用一组规则,将数据点分成子集,然后为每个子集做出一个预测。这些预测基于应用给定规则序列所导致的训练样本子集的结果值。分类树预测从相对类频率或最多类的值直接估计的概率,而回归树计算可用数据点的结果值均值的预测。

每一条规则都依赖于一个特定的特征,并使用一个阈值将样本分成两组,其中值要么低于要么高于该特征的阈值。二叉树自然地表示了模型的逻辑:根是所有样本的起点,节点代表决策规则的应用,数据沿着边移动,当它被分成更小的子集时,直到到达叶节点,模型进行预测。

对于线性模型,参数值允许解释输入变量对输出和模型预测的影响。相反,对于决策树,从根到叶的各种可能路径确定了特征及其值如何导致模型做出具体决策。因此,决策树能够捕捉线性模型无法“开箱即用”捕捉的特征之间的相互依赖

以下图表突出显示了模型如何学习一条规则。在训练过程中,算法扫描特征,并且对于每个特征,它寻求找到一个分割数据以最小化由预测造成的损失的切分点。它使用将结果来自于分割的子集,按每个子集中的样本数量加权:

图 11.1:决策树如何从数据中学习规则

在训练期间构建整个树,学习算法重复此过程,即将特征空间,即p个输入变量X[1]、X[2]、...、X[p]的可能值集合,划分为互斥且集体穷尽的区域,每个区域由一个叶节点表示。不幸的是,由于特征空间的可能组合和阈值序列的爆炸性数量,算法将无法评估特征空间的每种可能分区。基于树的学习采用了一种自顶向下贪婪的方法,称为递归二元分割,以克服这种计算限制。

此过程是递归的,因为它使用来自先前分割的数据子集。它是自顶向下的,因为它从树的根节点开始,所有观察仍然属于单个区域,然后通过向预测器空间添加一个以上的分割来连续创建树的两个新分支。它是贪婪的,因为算法根据对目标函数的直接影响选择最佳规则,而不是向前看并评估数步之后的损失。我们将在更具体的回归和分类树的上下文中返回分割逻辑,因为这代表了它们之间的主要差异。

训练样本数量随着递归分割向树中添加新节点而不断减少。如果规则将样本均匀分割,导致树完美平衡,每个节点都有相同数量的子节点,那么在第n级就会有 2^n 个节点,每个节点包含总观测数的相应部分。实际上,这是不太可能的,因此沿某些分支的样本数量可能会迅速减少,并且树倾向于沿不同路径生长到不同的深度。

递归分割将继续,直到每个叶节点仅包含单个样本,并且训练误差已经降低到零。我们将介绍几种方法来限制分割并防止决策树产生极端过拟合的自然倾向。

为了对新的观测值进行预测,模型使用在训练期间推断出的规则来决定数据点应分配到哪个叶节点,然后使用特征空间相应区域中训练观测的平均值(用于回归)或模式(用于分类)。特征空间中给定区域(即给定叶节点)中训练样本数量较少,会降低预测的置信度,并可能反映出过拟合。

实践中的决策树

在本节中,我们将说明如何使用基于树的模型来获得洞察并进行预测。为了演示回归树,我们预测收益,对于分类案例,我们回到了正向和负向资产价格变动的示例。本节的代码示例位于笔记本decision_trees中,除非另有说明。

数据 - 月度股票收益和特征

我们将选择涵盖 2006-2017 年期间的 Quandl 美国股票数据集的子集,并按照我们第一个特征工程示例中的过程进行操作,第四章金融特征工程 - 如何研究阿尔法因子。我们将计算月度收益和基于它们的 5 年移动平均值的 500 种最常交易的股票的 25 个(希望是)预测性特征,产生 56,756 个观察值。这些特征包括:

  • 过去 1、3、6 和 12 个月的历史收益

  • 动量指标将最近 1 个或 3 个月的收益与较长时间跨度的收益相关联。

  • 设计用于捕捉波动性的技术指标,如(归一化的)平均真实范围(NATR 和 ATR)和像相对强弱指数RSI)这样的动量指标。

  • 根据滚动 OLS 回归的五个 Fama-French 因子的因子加载

  • 年份和月份以及部门的分类变量

图 11.2显示了这些特征与我们用于回归的月度收益之间的互信息(左侧面板),以及它们的二值化分类对应物,代表了相同期间的正向或负向价格变动。它显示,从单变量的角度来看,无论是对于这些特征的哪一种结果,都存在着信号内容的显著差异。

更多细节可以在这一章的 GitHub 存储库中的data_prep笔记本中找到。本章的决策树模型不具备处理缺失或分类变量的能力,因此我们将放弃前者并对分类部门变量应用虚拟编码(参见第四章金融特征工程 - 如何研究阿尔法因子第六章机器学习过程):

图 11.2:特征与收益或价格变动方向的互信息

使用时间序列数据构建回归树

回归树根据分配给给定节点的训练样本的平均结果值进行预测,并且通常依靠平均平方误差在递归二进制分割过程中选择最佳规则。

给定一个训练集,算法在p个预测变量 X[1]、X[2]、...、X[p] 和n个可能的切分点 s[1]、s[2]、...、s[n] 上进行迭代,以找到最佳组合。最佳规则将特征空间分割成两个区域,{X|X[i] < s[j]} 和 {X|X[i] > s[j]},其中 X[i] 特征的值要么在 s[j] 阈值以下,要么在 s[j] 阈值以上,以便基于训练子集的预测最大程度地减少相对于当前节点的平方残差。

让我们从一个简化的示例开始,以便进行可视化,并演示如何使用时间序列数据与决策树。我们将只使用 2 个月的滞后回报来预测以下月份,与前一章中的 AR(2) 模型类似:

使用 scikit-learn,配置和训练回归树非常简单:

from sklearn.tree import DecisionTreeRegressor
# configure regression tree
regression_tree = DecisionTreeRegressor(criterion='mse',      
                                        max_depth=6,         
                                        min_samples_leaf=50)
# Create training data
y = data.target
X = data.drop(target, axis=1)
X2 = X.loc[:, ['t-1', 't-2']]
# fit model
regression_tree.fit(X=X2, y=y)
# fit OLS model
ols_model = sm.OLS(endog=y, exog=sm.add_constant(X2)).fit() 

OLS 摘要和决策树前两个层级的可视化展示了模型之间的显著差异(见 图 11.3)。OLS 模型提供了三个参数,分别为截距和两个特征,符合该模型对函数的线性假设。

相反,回归树图表显示了前两个层级的每个节点使用的特征和阈值来拆分数据(请注意特征可以重复使用),以及均方误差(MSE)、样本数量和基于这些训练样本的预测值的当前值。此外,请注意,与线性回归的 66 微秒相比,训练决策树需要 58 毫秒。虽然两种模型在只有两个特征时运行速度很快,但差异是 1,000 倍:

图 11.3:OLS 结果和回归树

树状图还突出显示了节点间样本分布的不均匀性,因为在第一个分割之后,样本数量在 545 到 55,000 之间变化。

为了进一步说明输入变量和输出之间的不同假设关系的功能形式,我们可以将当前回报预测可视化为特征空间的函数,即基于滞后回报值的值范围的函数。下图显示了线性回归(左侧面板)和回归树的当前月回报与一段时间前回报之间的关系:

图 11.4:线性回归和回归树的决策表面

左侧的线性回归模型结果强调了滞后和当前回报之间关系的线性性,而右侧的回归树图表则说明了特征空间的递归分区中编码的非线性关系。

构建分类树

分类树的工作方式与回归版本相同,只是结果的分类性质需要不同的方法来进行预测和衡量损失。虽然回归树使用相关训练样本的平均结果来预测分配给叶节点的观测值的响应,但是分类树使用模式,即相关区域内训练样本中最常见的类别。分类树还可以基于相对类频率生成概率预测。

如何优化节点纯度

在构建分类树时,我们也使用递归二元拆分,但是我们不是使用减少均方误差来评估决策规则的质量,而是可以使用分类错误率,它简单地是给定(叶)节点中不属于最常见类别的训练样本的比例。

然而,更倾向于使用基尼不纯度交叉熵等替代度量方法,因为它们对节点纯度的敏感性比分类错误率更高,正如您在图 11.5中所见。节点纯度指的是节点中单个类别占优势的程度。一个只包含属于单个类别结果的样本的节点是纯净的,并且意味着在特征空间的这个特定区域的成功分类。

看看如何计算具有K类别 0,1,...,K-1(在二进制情况下为K=2)的分类结果的这些度量值。对于给定的节点m,让p[mk]为来自k^(th)类的样本比例:

下图显示了当类别比例均匀时(在二进制情况下为 0.5),基尼不纯度和交叉熵度量在[0,1]区间内达到最大值。当类别比例接近零或一时,这些度量值会下降,并且由于拆分而导致的子节点趋向纯净。与此同时,它们对节点不纯度的惩罚比分类错误率更高:

图 11.5:分类损失函数

请注意,与基尼测度相比,交叉熵的计算时间几乎要长 20 倍(详见笔记本中的详情)。

如何训练分类树

现在我们将使用 80%的样本进行训练,预测剩余的 20%来训练、可视化和评估一个最多进行五次连续拆分的分类树。为了简化说明,我们将采用一种捷径并使用内置的train_test_split,它不会防止前瞻偏差,就像我们在第六章——机器学习过程中介绍的自定义MultipleTimeSeriesCV迭代器一样,稍后我们将在本章中使用。

树的配置意味着最多有 2⁵=32 个叶节点,平衡情况下平均会包含超过 1,400 个训练样本。看一下下面的代码:

# randomize train-test split
X_train, X_test, y_train, y_test = train_test_split(X, y_binary, test_size=0.2, random_state=42)
# configure & train tree learner
clf = DecisionTreeClassifier(criterion='gini',
                            max_depth=5,
                            random_state=42)
clf.fit(X=X_train, y=y_train)
# Output:
DecisionTreeClassifier(class_weight=None, criterion='gini', max_depth=5,
            max_features=None, max_leaf_nodes=None,
            min_impurity_decrease=0.0, min_impurity_split=None,
            min_samples_leaf=1, min_samples_split=2,
            min_weight_fraction_leaf=0.0, presort=False, random_state=42,
            splitter='best') 

训练模型后的输出显示了所有DecisionTreeClassifier的参数。我们将在超参数调整部分详细讨论这些。

可视化决策树

您可以使用 Graphviz 库(请参阅 GitHub 安装说明)来可视化树,因为 scikit-learn 可以输出使用该库使用的 DOT 语言描述的树的描述。您可以配置输出以包括特征和类标签,并限制级别的数量以使图表可读,如下所示:

dot_data = export_graphviz(classifier,
                           out_file=None, # save to file and convert to png
                           feature_names=X.columns,
                           class_names=['Down', 'Up'],
                           max_depth=3,
                           filled=True,
                           rounded=True,
                           special_characters=True)
graphviz.Source(dot_data) 

下图显示了模型如何使用不同的特征,并指示了连续和分类(虚拟)变量的分裂规则。在每个节点的标签值下,图表显示了来自每个类的样本数量,并在类标签下显示了最常见的类(在样本期间上涨的月份更多):

图 11.6:分类树的可视化

评估决策树预测

为了评估我们第一个分类树的预测准确度,我们将使用测试集生成预测的类概率,如下所示:

# only keep probabilities for pos. class
y_score = classifier.predict_proba(X=X_test)[:, 1] 

.predict_proba()方法为每个类别生成一个概率。在二元类别中,这些概率是互补的并总和为 1,因此我们只需要正类的值。为了评估泛化误差,我们将使用基于接收器操作特征的曲线下面积,我们在第六章机器学习过程中介绍过。结果表明,相对于随机预测的基准值 0.5,有了显著的改进(但请记住,这里的交叉验证方法不考虑数据的时间序列性):

roc_auc_score(y_score=y_score, y_true=y_test)
0.6341 

过拟合和正则化

决策树在数据集相对于样本数量具有大量特征时很容易过拟合。正如前几章讨论的那样,过拟合会增加预测误差,因为模型不仅学习了训练数据中包含的信号,还学习了噪音。

有多种方法可以解决过拟合的风险,包括:

  • 降维(请参阅第十三章使用无监督学习的数据驱动风险因子和资产配置)通过用更少、更具信息性和更少噪声的特征表示现有特征来改善特征与样本的比率。

  • 集成模型,例如随机森林,结合了多个树,同时随机化树的构建,我们将在本章的第二部分中看到。

决策树提供了几个正则化超参数来限制树的增长和相关复杂度。虽然每次分裂都会增加节点数,但也会减少每个节点可用于支持预测的样本数量。对于每个额外的层级,需要两倍数量的样本才能使新节点以相同的样本密度填充。

树修剪是减少树复杂性的另一种工具。它通过消除添加了很少价值但增加了模型方差的节点或整个树的部分来实现。例如,成本复杂度修剪从一个大树开始,并通过将节点替换为叶子来递归地减小其大小,本质上是反向运行树构建。各种步骤产生一系列树,然后可以使用交叉验证来选择理想的大小。

如何规范化决策树

以下表格列出了 scikit-learn 决策树实现中用于此目的的关键参数。在介绍了最重要的参数后,我们将说明如何使用交叉验证来优化超参数设置,以便在偏差-方差权衡和降低预测误差方面:

参数 描述 默认 选项
max_depth 最大级别数:分割节点直到达到max_depth。所有叶子都是纯的,或者包含的样本少于min_samples_split None int
max_features 考虑用于分割的特征数量。 None None:所有特征 int:# 特征float:分数autosqrt:sqrt(n_featureslog2:log2(n_features
max_leaf_nodes 分割节点直到创建这么多叶子。 None None:无限 int
min_impurity_decrease 如果不纯度减少至少这个值,则分割节点。 0 float
min_samples_leaf 只有在左右分支的每个中至少有min_samples_leaf训练样本时,才会考虑分割。 1 int;float(作为百分比N
min_samples_split 分割内部节点所需的最小样本数。 2 intfloat(百分之N
min_weight_fraction_leaf 在叶子节点上需要的所有样本权重的最小加权分数。除非在拟合方法中提供了sample_weight,否则样本具有相同的权重。 0

max_depth 参数对连续分割的次数施加了硬限制,并代表了限制树生长的最直接方式。

min_samples_splitmin_samples_leaf 参数是另一种基于数据的方法来限制树的生长。与对连续分割的次数施加硬限制不同,这些参数控制进一步分割数据所需的最小样本数。后者保证了每个叶子的一定样本数量,而前者在分割导致非常不均匀的分布时可能会创建非常小的叶子。较小的参数值有助于过拟合,而较高的数字可能会阻止树学习数据中的信号。默认值通常相当低,您应该使用交叉验证来探索一系列潜在值。您还可以使用浮点数来表示百分比,而不是绝对数字。

scikit-learn 文档中包含有关如何在不同用例中使用各种参数的其他详细信息;有关更多信息,请参阅 GitHub 上链接的资源。

决策树修剪

递归二元分裂很可能会在训练集上产生良好的预测结果,但往往会导致数据过度拟合,产生较差的泛化性能。这是因为它导致了过于复杂的树,这在大量叶节点或特征空间的划分中反映出来。较少的分裂和叶节点意味着总体较小的树,并且通常会导致更好的预测性能,以及可解释性。

限制叶节点数量的一种方法是除非它们产生目标度量的显着改善,否则避免进一步分裂。然而,这种策略的缺点是,有时候,导致小幅改善的分裂在样本组成不断变化时会使后续更有价值的分裂变得更加困难。

相比之下,树修剪首先通过生长一个非常大的树,然后移除或修剪节点,以将大树减小为一个较少复杂且过度拟合的子树。成本复杂度修剪通过对向树模型添加叶节点增加惩罚并引入正则化参数来生成一系列子树,类似于套索和岭线性回归模型,调节惩罚的影响。应用于大树,增加的惩罚将自动产生一系列子树。可以使用正则化参数的交叉验证来识别最佳的修剪子树。

这种方法是在 scikit-learn 版本 0.22 中引入的;有关各种方法的工作原理和性能,请参见 Esposito 等人(1997)的调查。

超参数调整

决策树提供了一系列超参数来控制和调整训练结果。交叉验证是获得对泛化误差的无偏估计的最重要工具,反过来又允许在各种配置选项之间做出明智选择。scikit-learn 提供了几个工具来简化交叉验证大量参数设置的过程,即我们将在下一节中介绍的GridSearchCV便利类。学习曲线还允许进行诊断,评估收集额外数据以减少泛化误差的潜在好处。

使用自定义度量标准的 GridsearchCV

第六章机器学习流程中所强调的,scikit-learn 提供了一种定义多个超参数值范围的方法。它自动化了交叉验证各种参数值组合的过程,以确定最佳配置。让我们逐步了解自动调整模型的过程。

第一步是实例化一个模型对象,并定义一个字典,其中关键词命名超参数,值列出要测试的参数设置:

reg_tree = DecisionTreeRegressor(random_state=42)
param_grid = {'max_depth': [2, 3, 4, 5, 6, 7, 8, 10, 12, 15],
              'min_samples_leaf': [5, 25, 50, 100],
              'max_features': ['sqrt', 'auto']} 

然后,实例化GridSearchCV对象,提供估算器对象和参数网格,以及评分方法和交叉验证选择,传递给初始化方法。

我们将自定义的MultipleTimeSeriesSplit类设置为对模型进行 60 个月或 5 年的数据训练,并使用随后的 6 个月验证性能,重复此过程 10 次以覆盖 5 年的样本外期间:

cv = MultipleTimeSeriesCV(n_splits=10,
                          train_period_length=60,
                          test_period_length=6,
                          lookahead=1) 

我们使用roc_auc指标对分类器进行评分,并使用 scikit-learn 的make_scorer函数为回归模型定义自定义信息系数(IC)指标:

def rank_correl(y, y_pred):
    return spearmanr(y, y_pred)[0]
ic = make_scorer(rank_correl) 

我们可以使用n_jobs参数并通过设置refit=True自动获取使用最佳超参数的训练模型。

所有设置都已就绪后,我们可以像任何其他模型一样拟合GridSearchCV

gridsearch_reg = GridSearchCV(estimator=reg_tree,
                          param_grid=param_grid,
                          scoring=ic,
                          n_jobs=-1,
                          cv=cv,  # custom MultipleTimeSeriesSplit
                          refit=True,
                          return_train_score=True)
gridsearch_reg.fit(X=X, y=y) 

训练过程为我们的GridSearchCV对象生成了一些新的属性,最重要的是有关最佳设置和最佳交叉验证分数的信息(现在使用适当的设置,避免了前瞻偏差)。

以下表格分别列出了最佳回归模型和分类模型的参数和分数。具有更浅的树和更加正则化的叶节点,回归树的 IC 为 0.083,而分类器的 AUC 得分为 0.525:

参数 回归 分类
max_depth 6 12
max_features sqrt sqrt
min_samples_leaf 50 5
分数 0.0829 0.5250

自动化非常方便,但我们也希望检查性能如何随不同参数值的变化而变化。完成此过程后,GridSearchCV对象提供了详细的交叉验证结果,以便我们可以获得更多见解。

如何检查树结构

该笔记本还说明了如何手动运行交叉验证以获取自定义树属性,例如与某些超参数设置相关联的总节点数或叶节点数。以下函数访问内部的.tree_ 属性以检索有关总节点计数以及其中多少个节点是叶节点的信息:

def get_leaves_count(tree):
    t = tree.tree_
    n = t.node_count
    leaves = len([i for i in range(t.node_count) if t.children_left[i]== -1])
    return leaves 

我们可以将此信息与训练和测试分数结合起来,以获取有关模型在整个交叉验证过程中行为的详细知识,如下所示:

train_scores, val_scores, leaves = {}, {}, {}
for max_depth in range(1, 26):
    print(max_depth, end=' ', flush=True)
    clf = DecisionTreeClassifier(criterion='gini', 
                                 max_depth=max_depth,
                                 min_samples_leaf=10,
                                 max_features='auto',
                                 random_state=42)
    train_scores[max_depth], val_scores[max_depth] = [], [] 
    leaves[max_depth] = []
    for train_idx, test_idx in cv.split(X):
        X_train, = X.iloc[train_idx], 
        y_train  = y_binary.iloc[train_ idx]
        X_test, y_test = X.iloc[test_idx], y_binary.iloc[test_idx]
        clf.fit(X=X_train, y=y_train)
        train_pred = clf.predict_proba(X=X_train)[:, 1]
        train_score = roc_auc_score(y_score=train_pred, y_true=y_train)
        train_scores[max_depth].append(train_score)
        test_pred = clf.predict_proba(X=X_test)[:, 1]
        val_score = roc_auc_score(y_score=test_pred, y_true=y_test)
        val_scores[max_depth].append(val_score)    
        leaves[max_depth].append(get_leaves_count(clf)) 

以下图表显示了叶节点数随树深度增加而增加的情况。由于每个交叉验证折叠的样本大小为 60 个月,每个样本约包含 500 个数据点,因此将min_samples_leaf限制为 10 个样本时,叶节点数限制在约 3,000 个:

图 11.7:分类树的可视化

比较回归和分类的性能

为了更仔细地观察模型的性能,我们将展示各种深度的交叉验证性能,同时保持产生最佳网格搜索结果的其他参数设置。图 11.8显示了训练和验证分数,并突出了更深的树的过拟合程度。这是因为训练分数稳步增加,而验证性能保持不变或下降。

请注意,对于分类树,网格搜索建议使用 12 个级别以获得最佳预测准确性。然而,图表显示较简单的树(具有三个或七个级别)的 AUC 得分相似。我们更倾向于一个更浅的树,它承诺具有可比较的泛化性能,同时减少了过拟合的风险:

图 11.8:两个模型的训练和验证分数

使用学习曲线诊断训练集规模

学习曲线是一种有用的工具,显示了随着训练样本数量增加,验证和训练分数如何演变。

学习曲线的目的是找出模型在训练过程中是否以及在多大程度上会受益于使用更多数据。它还有助于诊断模型的泛化误差更可能是由偏差还是方差驱动。

如果训练分数符合预期的性能,并且随着训练样本数量的增加,验证分数表现出显著改善,那么训练更长的回溯期或获取更多数据可能会增加价值。另一方面,如果尽管训练集大小增加,但验证分数和训练分数都收敛到类似较差的值,则错误更可能是由于偏差,并且额外的训练数据不太可能有所帮助。

以下图片显示了最佳回归和分类模型的学习曲线:

图 11.9:每个模型最佳版本的学习曲线

特别是对于回归模型,随着训练集规模的扩大,验证性能有所提高。这表明延长训练周期可能会产生更好的结果。你可以自己试一试,看看是否奏效!

从特征重要性中获得洞察力

决策树不仅可以可视化以检查给定特征的决策路径,还可以总结每个特征对模型学习以拟合训练数据的规则的贡献。

特征重要性捕捉到每个特征产生的分裂如何帮助优化模型用于评估分裂质量的度量,我们的情况下是基尼不纯度。特征的重要性是计算为这个度量的(标准化的)总减少,并考虑到受分裂影响的样本数量。因此,在树的较早节点使用的特征,其中节点往往包含更多的样本,通常被认为是更重要的。

图 11.10 显示了每个模型前 15 个特征的特征重要性的图表。注意特征的顺序如何与本节开头给出的互信息分数的单变量评估不同。显然,决策树捕获时间段与其他特征之间的相互依赖关系的能力可能改变每个特征的价值:

图 11.10:最佳回归和分类模型的特征重要性

决策树的优势和劣势

回归树和分类树在预测方面与我们在前几章中探讨过的线性模型有着非常不同的方法。如何决定哪种模型更适合当前的问题?考虑以下内容:

  • 如果结果与特征之间的关系大致是线性的(或可以相应地转换),那么线性回归可能会胜过更复杂的方法,例如不利用这种线性结构的决策树。

  • 如果关系呈现高度非线性和更复杂,决策树可能会胜过传统模型。请记住,关系的复杂性需要是系统的或“真实的”,而不是由噪声驱动,这会导致更复杂的模型过拟合。

决策树具有几个优点

  • 它们相当容易理解和解释,尤其是因为它们可以很容易地可视化,因此更容易让非技术人员理解。决策树也被称为白盒模型,因为它们在如何得出预测方面具有高度透明性。黑盒模型,例如集成和神经网络,可能会提供更好的预测精度,但是决策逻辑往往更难理解和解释。

  • 决策树比那些对数据做出更强假设或对异常值更敏感且需要数据标准化的模型需要更少的数据准备(例如正则化回归)。

  • 一些决策树实现可以处理分类输入,不需要创建虚拟变量(提高内存效率),并且可以处理缺失值,正如我们将在 第十二章提升您的交易策略 中看到的,但这不适用于 scikit-learn。

  • 预测速度很快,因为它与叶节点的数量呈对数关系(除非树变得极不平衡)。

  • 可以使用统计测试验证模型并考虑其可靠性(有关更多细节,请参阅参考文献)。

决策树还具有几个关键劣势

  • 决策树内置了对训练集的过度拟合的倾向,并产生了高的泛化误差。解决这一弱点的关键步骤是修剪和正则化,使用限制树生长的早停准则,如本节所述。

  • 决策树还对不平衡类权重敏感,可能会产生偏向某一类的树。一种选择是对少数类进行过采样或对更频繁出现的类进行欠采样。通常更好的选择是使用类权重并直接调整目标函数。

  • 决策树的高方差与它们密切适应训练集的能力有关。因此,数据中的细微变化可能会导致树结构和模型预测的广泛波动。一个关键的预防机制是使用一组具有低偏差且产生不相关预测误差的随机决策树的集成。

  • 决策树学习的贪婪方法优化了减少当前节点预测误差的局部标准,并不保证全局最优结果。同样,由随机树组成的集成有助于缓解这个问题。

我们现在将转向缓解决策树使用时固有的过拟合风险的集成方法。

随机森林——使树更可靠

决策树不仅因其透明度和解释性而有用。它们也是更强大的集成模型的基本构建模块,这些模型组合了许多个体树,同时随机变化其设计,以解决我们刚刚讨论的过拟合问题。

为什么集成模型表现更好

集成学习涉及将几个机器学习模型组合成一个新的模型,旨在比任何单个模型做出更好的预测。更具体地说,集成整合了几个基本估计器的预测,这些估计器使用一个或多个学习算法进行训练,以减少它们自己产生的泛化误差。

为了使集成学习达到这个目标,个体模型必须是

  • 准确: 胜过一个简单的基线(例如样本平均值或类比例)

  • 独立: 预测是通过不同的方式产生的,以产生不同的误差

集成方法是最成功的机器学习算法之一,特别适用于标准的数值数据。大型集成在机器学习竞赛中非常成功,可能由许多不同的个体模型组成,这些模型已经手工组合或使用另一个机器学习算法组合起来。

将不同模型的预测组合起来存在几个缺点。这些包括降低了解释性以及训练、预测和模型维护的复杂性和成本。因此,在实践中(不考虑竞赛),从大规模集成中获得的准确度微小增益可能不值得额外的成本。

根据它们如何优化组成模型然后将结果集成为单个集成预测,通常可以区分两组集成方法:

  • 平均方法独立地训练多个基本估计器,然后对它们的预测进行平均。如果基本模型没有偏差,并且产生不高度相关的不同预测误差,那么合并的预测可能具有较低的方差,更可靠。这类似于从具有不相关回报的资产构建组合以减少波动性而不牺牲回报。

  • 提升方法相反,按顺序训练基本估计器,其特定目标是减少组合估计器的偏差。其动机是将几个弱模型组合成一个强大的集成。

在本章的剩余部分我们将专注于自动平均方法,而在第十二章《提升你的交易策略》中关注提升方法。

自助聚合

我们发现决策树很可能由于高方差而做出糟糕的预测,这意味着树结构对可用训练样本非常敏感。我们还看到,方差较低的模型,如线性回归,产生类似的估计值,尽管训练样本不同,只要给定特征数足够多。

对于给定一组独立观察值,每个观察值的方差为 ,样本均值的标准误差由 给出。换句话说,对更大的观察集进行平均会减小方差。因此,减少模型方差和泛化误差的一种自然方法是从总体中收集许多训练集,在每个数据集上训练不同的模型,然后对结果预测进行平均。

在实践中,我们通常没有许多不同的训练集。这就是装袋法的用武之地,即自助聚合。装袋法是一种通用方法,用于降低机器学习模型的方差,当应用于决策树时特别有用且受欢迎。

我们将首先解释这种技术如何缓解过拟合,然后展示如何将其应用于决策树。

如何通过装袋法降低模型的方差

装袋指的是自助采样的聚合,这些自助采样是带替换的随机采样。这样的随机样本与原始数据集具有相同数量的观察值,但由于替换可能包含重复观察值。

装袋法提高了预测准确性,但降低了模型的可解释性,因为不再能够可视化树以了解每个特征的重要性。作为一个集成算法,装袋方法在这些自助采样样本上训练给定数量的基本估计器,然后将它们的预测聚合成最终的集成预测。

装袋法通过以下方式降低基本估计器的方差,从而降低它们的泛化误差:

  1. 随机化每棵树的生长方式

  2. 对它们的预测进行平均

这通常是一种简单的方法来改进给定模型,而无需更改基础算法。这种技术对于具有低偏差和高方差的复杂模型特别有效,例如深层次的决策树,因为其目标是限制过拟合。相比之下,提升方法在弱模型(例如浅决策树)上效果最佳。

有几种 bagging 方法的不同之处在于它们应用于训练集的随机抽样过程:

  • 粘贴从训练数据中无替换地随机抽取样本,而 bagging 则使用替换抽样。

  • 随机子空间随机从特征(即列)中抽取样本,不进行替换。

  • 随机补丁通过随机抽样观察值和特征来训练基本估算器。

Bagged 决策树

要将 bagging 应用于决策树,我们通过反复抽样替换来创建从训练数据中生成 bootstrap 样本。然后,我们在每个样本上训练一个决策树,并通过对不同树的预测进行平均来创建一个集成预测。您可以在笔记本bagged_decision_trees中找到此示例的代码,除非另有说明。

Bagged 决策树通常生长较大,即它们具有许多层和叶子节点,并且不进行修剪,以使每棵树的偏差低而方差高。然后,对其预测进行平均的效果旨在减少其方差。通过构建在 bootstrap 样本上训练的数百甚至数千棵树的集成,已经证明了 bagging 能够显著提高预测性能。

要说明 bagging 对回归树方差的影响,我们可以使用 scikit-learn 提供的BaggingRegressor元估计器。它基于指定抽样策略的参数来训练用户定义的基础估算器。

  • max_samplesmax_features分别控制从行和列中绘制的子集的大小。

  • bootstrapbootstrap_features确定是否使用或不使用替换来绘制这些样本。

以下示例使用指数函数生成单个DecisionTreeRegressor和由 10 棵树组成的BaggingRegressor集成的训练样本,每棵树都生长了 10 层深度。这两个模型都在随机样本上进行训练,并为添加了噪声的实际函数预测结果。

由于我们知道真实函数,因此我们可以将均方误差分解为偏差、方差和噪声,并根据以下分解比较这些组件的相对大小:

我们将分别从训练集和测试集中随机抽取 100 组样本,每组样本包括 250 个训练观察和 500 个测试观察,以训练每个模型并收集预测结果:

noise = .5  # noise relative to std(y)
noise = y.std() * noise
X_test = choice(x, size=test_size, replace=False)
max_depth = 10
n_estimators=10
tree = DecisionTreeRegressor(max_depth=max_depth)
bagged_tree = BaggingRegressor(base_estimator=tree, n_estimators=n_estimators)
learners = {'Decision Tree': tree, 'Bagging Regressor': bagged_tree}
predictions = {k: pd.DataFrame() for k, v in learners.items()}
for i in range(reps):
    X_train = choice(x, train_size)
    y_train = f(X_train) + normal(scale=noise, size=train_size)
    for label, learner in learners.items():
        learner.fit(X=X_train.reshape(-1, 1), y=y_train)
        preds = pd.DataFrame({i: learner.predict(X_test.reshape(-1, 1))},
                              index=X_test)
        predictions[label] = pd.concat([predictions[label], preds], axis=1) 

对于每个模型,在图 11.11中显示的绘图如下:

  • 平均预测值和平均值周围两个标准偏差的带状区间(上部面板)。

  • 根据真实函数的值(底部面板),进行偏差-方差-噪声分解。

我们发现,个体决策树预测的方差(左侧)几乎是基于自助样本的小集成的两倍高:

图 11.11:个体和袋装决策树的偏差-方差分解

请参阅笔记本 bagged_decision_trees 以获取实现细节。

如何构建随机森林

随机森林算法基于装袋引入的随机化来进一步减少方差并提高预测性能。

除了在自助训练数据上训练每个集合成员外,随机森林还会随机从模型中使用的特征中进行抽样(不重复)。根据实现方式,随机样本可以为每棵树或每次分裂进行抽取。因此,算法在学习新规则时面临不同选择,无论是在树的级别还是在每次分裂时。

特征的样本大小在回归和分类树之间有所不同:

  • 对于分类,样本大小通常是特征数量的平方根。

  • 对于回归,特征数量可以是从三分之一到所有特征,并且应基于交叉验证进行选择。

以下图表说明了随机森林如何随机化个体树的训练,然后将它们的预测汇总成集成预测:

图 11.12:随机森林如何生长个体树

除了训练观察结果外,随机化特征的目标是进一步使个体树的预测误差失相关。所有特征并非同等重要,一小部分高度相关的特征将在树构建过程中更频繁且更早地被选择,使得整个集成中的决策树更加相似。然而,个体树的泛化误差之间的相关性越小,整体方差就会减小得越多。

如何训练和调整随机森林

关键的配置参数包括介绍的各个决策树的超参数,在如何调整超参数部分。以下表格列出了两个RandomForest类的其他选项:

关键字 默认值 描述
bootstrap TRUE 训练期间启用自助采样
n_estimators 10 森林中的树的数量
oob_score FALSE 使用袋外样本来估计未见数据上的 R2

bootstrap参数激活了刚才描述的装袋算法。装袋又可以启用计算袋外得分(oob_score),该分数估计了未包括在用于训练给定树的自助样本中的样本的泛化准确性(请参阅袋外测试部分)。

参数n_estimators定义了作为森林一部分生长的树的数量。更大的森林表现更好,但也需要更长的时间来构建。重要的是随着基学习者数量的增加来监控交叉验证误差。目标是确定在训练额外的树的成本上升超过减少验证误差的好处的时候,或者后者开始再次增加的时候。

参数max_features控制在学习新决策规则并分割节点时可用的随机选择特征子集的大小。较低的值会降低树之间的相关性,从而减少集成的方差,但也可能增加偏差。正如本节开头所指出的,良好的起始值是回归问题的训练特征数量以及分类问题的这个数字的平方根,但会取决于特征之间的关系,并且应该使用交叉验证进行优化。

随机森林被设计为包含深度完全生长的树,可以使用max_depth=Nonemin_samples_split=2来创建。然而,这些值不一定是最优的,特别是对于具有许多样本和因此可能非常深的树的高维数据,可以变得非常计算和内存密集。

由 scikit-learn 提供的RandomForest类支持并行训练和预测,通过将n_jobs参数设置为要在不同核心上运行的k个作业数。值-1使用所有可用的核心。进程间通信的开销可能会限制速度提升呈线性增长,因此k个作业可能需要超过单个作业的 1/k时间。尽管如此,对于数据大、分裂评估变得昂贵的大型森林或深层个体树,速度提升通常是相当显著的。

始终应该使用交叉验证来识别最佳的参数配置。以下步骤说明了这个过程。本示例的代码在笔记本random_forest_tuning中。

我们将使用GridSearchCV来识别一组最佳参数,用于分类树的集成:

rf_clf = RandomForestClassifier(n_estimators=100,
                                criterion='gini',
                                max_depth=None,
                                min_samples_split=2,
                                min_samples_leaf=1,
                                min_weight_fraction_leaf=0.0,
                                max_features='auto',
                                max_leaf_nodes=None,
                                min_impurity_decrease=0.0,
                                min_impurity_split=None,
                                bootstrap=True, oob_score=False,
                                n_jobs=-1, random_state=42) 

我们使用与以前决策树示例中相同的 10 折自定义交叉验证,并使用关键配置设置的值填充参数网格:

cv = MultipleTimeSeriesCV(n_splits=10, train_period_length=60,
                          test_period_length=6, lookahead=1)
clf = RandomForestClassifier(random_state=42, n_jobs=-1)
param_grid = {'n_estimators': [50, 100, 250],
              'max_depth': [5, 15, None],
              'min_samples_leaf': [5, 25, 100]} 

使用上述内容配置GridSearchCV

gridsearch_clf = GridSearchCV(estimator=clf,
                              param_grid=param_grid,
                              scoring='roc_auc',
                              n_jobs=-1,
                              cv=cv,
                              refit=True,
                              return_train_score=True,
                              verbose=1) 

我们像以前一样运行网格搜索,并找到最佳的回归和分类模型的以下结果。与分类器相比,随机森林回归模型对较浅的树表现更好,但在其他方面使用相同的设置:

Parameter Regression Classification
max_depth 5 15
min_samples_leaf 5 5
n_estimators 100 100
Score 0.0435 0.5205

然而,这两个模型都不及它们各自的单独决策树模型表现好,这凸显了在数据噪声较大且过拟合风险较高时,更复杂的模型并不一定优于更简单的方法。

随机森林的特征重要性

随机森林集成可能包含数百棵单独的树,但仍然可以从袋装模型中获得特征重要性的总体摘要指标。

对于给定的特征,重要性评分是由于对该特征进行拆分而导致的目标函数值的总减少量,并且在所有树上进行了平均。由于目标函数考虑了拆分影响的特征数量,因此在树的顶部附近使用的特征将获得较高的分数,因为更小数量的可用节点中包含了更多的观测数据。通过对以随机方式生长的许多树进行平均,特征重要性估计失去了一些方差,并变得更加准确。

分数以回归树的均方误差和分类树的基尼不纯度或熵来衡量。 scikit-learn 进一步规范化特征重要性,使其总和为 1。因此,计算得到的特征重要性也很受欢迎,作为特征选择的替代方法,与我们在第六章“机器学习流程”中看到的互信息度量相比(参见 sklearn.feature_selection 模块中的 SelectFromModel)。

图 11.13显示了两个模型的前 15 个特征的值。回归模型更依赖于时间段,而性能更好的决策树:

图 11.13:随机森林特征重要性

袋外测试

随机森林提供了内置交叉验证的好处,因为个别树是在训练数据的自助版本上训练的。因此,每棵树平均只使用了可用观测数据的三分之二。为了理解原因,考虑到自助样本的大小与原始样本相同,每个观测数据被抽取的概率是相同的,即 1/n。因此,根本不进入自助样本的概率是(1-1/n)n,它迅速收敛到 1/e,或者大约三分之一。

这剩余的三分之一未包含在训练集中的观测数据用于生成一个称为袋外 (OOB) 观测数据的装袋树,并可以作为验证集。正如交叉验证一样,我们为每棵树构建的不包含此观测数据的装袋样本预测响应,然后对每个 OOB 样本的单个集成预测进行平均(如果回归是目标,则为预测响应的平均值,如果分类是目标,则为多数投票或预测概率)。这些预测产生了对泛化误差的无偏估计,方便在训练期间计算。

由此产生的 OOB 误差是对该观测的泛化误差的有效估计。这是因为预测是使用在缺乏该观测时学到的决策规则产生的。一旦随机森林足够大,OOB 误差会接近于留一交叉验证误差。对于大型数据集,OOB 方法估计测试错误非常高效,而交叉验证可能计算成本很高。

然而,与交叉验证相同的警告适用:您需要注意避免“超前展望偏差”,如果 OOB 观测可以无序选择,则会发生这种偏差。在实践中,这使得在时间序列数据中使用 OOB 测试非常困难,因为验证集需要根据数据的顺序性选择。

随机森林的优缺点

袋装集成模型既有优点又有缺点。

随机森林的优点包括:

  • 根据用例,随机森林可以与最佳监督学习算法表现相当。

  • 随机森林提供可靠的特征重要性估计。

  • 它们提供了测试错误的有效估计,而不会产生与交叉验证相关的重复模型训练成本。

另一方面,随机森林的缺点包括:

  • 集成模型本质上比单个决策树不太可解释。

  • 训练大量深树可能会产生高计算成本(但可以并行化)并使用大量内存。

  • 预测速度较慢,这可能对需要低延迟的应用程序产生挑战。

现在让我们看看如何使用随机森林进行交易策略。

日本股票的多空信号

第九章用于波动率预测和统计套利的时间序列模型中,我们使用协整检验来识别具有长期均衡关系的股票对,其价格呈现共同趋势,并回归到该趋势。

在本章中,我们将利用机器学习模型的预测结果来识别可能上涨或下跌的资产,以便我们可以相应地进行市场中性的做多和做空操作。这种方法类似于我们最初的交易策略,该策略在第七章线性模型 - 从风险因素到收益预测,以及第八章ML4T 工作流程 - 从模型到策略回测中使用了线性回归。

我们将使用 LightGBM 软件包而不是 scikit-learn 随机森林实现,后者主要设计用于梯度增强。 LightGBM 的几个优点之一是其能够有效地将分类变量编码为数值特征,而不是使用独热编码(Fisher 1958)。我们将在下一章中提供更详细的介绍,但是代码示例应该很容易理解,因为逻辑与 scikit-learn 版本相似。

数据 - 日本股票

我们将设计一个关于日本股票宇宙的策略,使用由 Stooq 提供的数据,这是一家波兰数据提供商,目前提供了各种资产类别、市场和频率的有趣数据集,我们在 第九章用于波动率预测和统计套利的时间序列模型 中也依赖了这些数据。

尽管关于数据的来源和质量几乎没有透明度,但它目前的免费使用具有强大的优势。换句话说,我们可以使用每日、每小时和每 5 分钟的股票、债券、商品和外汇数据进行实验,但应该对结果持谨慎态度。

本书 GitHub 存储库的 data 目录中的 create_datasets 笔记本包含了下载数据并将其存储为 HDF5 格式的说明。对于本例子,我们使用了 2010 年至 2019 年间约 3000 支日本股票的价格数据。最后的两年将作为样本外测试期,而之前的几年将作为我们的模型选择的交叉验证样本。

请参阅笔记本 japanese_equity_features 中的代码示例。我们移除了连续五个以上缺失值的股票代码,并仅保留了 1000 支交易量最高的股票。

特征 - 滞后回报和技术指标

我们会保持相对简单,将历史回报与 1、5、10、21 和 63 个交易日的几个技术指标结合起来,这些指标由 TA-Lib 提供(见第四章金融特征工程 - 如何研究阿尔法因子)。

具体来说,我们为每支股票计算:

  • 百分比价格振荡器PPO):是移动平均线收敛/发散MACD)指标的归一化版本,用于衡量 14 天和 26 天指数移动平均线之间的差异,以捕捉不同资产间的动量差异。

  • 归一化平均真实波幅NATR):以一种可比较各资产的方式来衡量价格波动。

  • 相对强度指数RSI):另一个流行的动量指标(详见第四章金融特征工程 - 如何研究阿尔法因子)。

  • 布林带:移动平均线与移动标准差的比率,用于识别均值回归的机会。

我们还将包括标记以表示年、月和星期,以及根据每个交易日的六个间隔的最新回报对股票进行排名,排名从 1 到 20。

结果 - 不同视角的前瞻回报

为了测试随机森林在给定这些特征情况下的预测能力,我们生成了相同间隔的前瞻回报,最多到 21 个交易日(1 个月)。

历史和前瞻回报所暗示的滞后和领先导致了一些数据的丢失,该丢失随着投资视角的增加而增加。我们最终得到了 941 支股票的 18 个特征和 4 个结果的 230 万个观察值。

基于 LightGBM 的 ML4T 工作流程

现在我们将选择一个能产生可交易信号的随机森林模型。有几项研究已经成功地做到了,例如 Krauss、Do 和 Huck(2017)以及 Rasekhschaffe 和 Jones(2019)等,以及其中引用的资源。

我们将使用微软开源的快速且内存高效的 LightGBM 实现,它是最受欢迎的梯度提升方法,也是下一章节的主题,我们将更详细地了解各种 LightGBM 特性。

我们将从讨论关键实验设计决策开始,然后构建和评估一个预测模型,其信号将驱动我们将在最后一步设计和评估的交易策略。除非另有说明,请参考笔记本 random_forest_return_signals 中的代码示例。

从宇宙选择到超参数调整

要开发使用机器学习模型的交易策略,我们需要就模型的范围和设计做出几项决策,包括:

  • 回溯期:用于训练的历史交易日数

  • 前瞻期:预测收益的未来天数

  • 测试期间:使用相同模型进行连续预测的天数

  • 超参数:要评估的参数和配置

  • 集成:是否依赖于单一模型或多个模型的组合

为了评估感兴趣的选项,我们还需要选择交叉验证的宇宙时间段,以及一个样本外测试期和宇宙。更具体地说,我们在我们的日本股票样本的子集上交叉验证了截至 2017 年的几个选项。

一旦我们确定了一个模型,我们将定义交易规则,并在完整宇宙上过去两年的样本外使用我们模型的信号回测策略,以验证其性能。

对于时间序列交叉验证,我们将依赖于我们在第七章《线性模型 - 从风险因素到收益预测》中开发的 MultipleTimeSeriesCV,以对训练和测试期的长度进行参数化,同时避免前瞻性偏差。这个自定义的 CV 类允许我们:

  • 对每个 ticker 进行连续包含 train_length 天的样本训练模型。

  • 在后续包含 test_length 天和 lookahead 天数的期间内验证其性能,除了训练期外,以避免数据泄漏。

  • 在每次将训练和验证期向前滚动 test_length 天时,重复 n_splits 次。

在本节中,我们将进行模型选择步骤,并在接下来的一节中进行策略回测。

对股票进行抽样以加快交叉验证的速度

训练随机森林所需的时间比线性回归要长得多,这取决于配置,树的数量和深度是主要驱动因素。

为了使我们的实验易于管理,我们将选择 2010-17 年期间交易量最大的 250 支股票,以评估不同结果和模型配置的性能,如下所示:

DATA_DIR = Path('..', 'data')
prices = (pd.read_hdf(DATA_DIR / 'assets.h5', 'stooq/jp/tse/stocks/prices')
          .loc[idx[:, '2010': '2017'], :])
dollar_vol = prices.close.mul(prices.volume)
dollar_vol_rank = dollar_vol.groupby(level='date').rank(ascending=False)
universe = dollar_vol_rank.groupby(level='symbol').mean().nsmallest(250).index 

定义回顾、展望和向前滚动期间

运行我们的策略需要以滚动方式训练模型,使用一定数量的交易日(回顾期)从我们的宇宙中学习模型参数,并预测一定数量的未来日子的结果。在我们的示例中,我们将考虑在每次迭代期间进行 63、126、252、756 和 1,260 个交易日的训练,并向前滚动和预测 5、21 或 63 天。

我们将把参数配对成一个列表,以便轻松迭代和可选的采样和/或洗牌,如下所示:

train_lengths = [1260, 756, 252, 126, 63]
test_lengths = [5, 21, 63]
test_params = list(product(train_lengths, test_lengths))
n = len(test_params)
test_param_sample = np.random.choice(list(range(n)), 
                                     size=int(n), 
                                     replace=False)
test_params = [test_params[i] for i in test_param_sample] 

使用 LightGBM 进行超参数调整

LightGBM 模型接受大量参数,如文档详细说明(参见lightgbm.readthedocs.io/和下一章)。对于我们的目的,我们只需要通过定义boosting_type启用随机森林算法,将bagging_freq设置为正数,并将objective设置为regression

base_params = dict(boosting_type='rf',
                   objective='regression',
                   bagging_freq=1) 

接下来,我们选择最有可能影响预测准确性的超参数,即:

  • 为模型生长的树的数量(num_boost_round

  • 用于装袋的行(bagging_fraction)和列(feature_fraction)的份额

  • 在叶子中需要的最小样本数(min_data_in_leaf)以控制过拟合

LightGBM 的另一个好处是,我们可以评估训练模型的子集(或在一定数量的评估之后继续训练),这使我们能够在单个训练会话中测试多个num_iteration值。

或者,您可以启用early_stopping在验证集的损失指标不再改善时中断训练。但是,由于模型使用了在实际情况下不可用的结果信息,交叉验证性能估计将被偏向上。

我们将使用以下值来控制装袋方法和树生长的超参数:

bagging_fraction_opts = [.5, .75, .95]
feature_fraction_opts = [.75, .95]
min_data_in_leaf_opts = [250, 500, 1000]
cv_params = list(product(bagging_fraction_opts,
                         feature_fraction_opts,
                         min_data_in_leaf_opts))
n_cv_params = len(cv_params) 

在各种视野上交叉验证信号

要评估给定一组超参数的模型,我们将使用回顾、展望和向前滚动期间生成预测。

首先,我们将识别分类变量,因为 LightGBM 不需要独热编码;而是根据结果对类别进行排序,这样对回归树的效果更好,根据费舍尔(1958 年)的说法。我们将创建变量来识别不同的时期:

categoricals = ['year', 'weekday', 'month']
for feature in categoricals:
    data[feature] = pd.factorize(data[feature], sort=True)[0] 

为此,我们将创建二进制的 LightGBM 数据集,并使用给定的train_lengthtest_length配置MultipleTimeSeriesCV,这确定了我们 2 年验证期间的拆分数量:

for train_length, test_length in test_params:
    n_splits = int(2 * YEAR / test_length)
    cv = MultipleTimeSeriesCV(n_splits=n_splits,
                              test_period_length=test_length,
                              lookahead=lookahead,
                              train_period_length=train_length)
    label = label_dict[lookahead]
    outcome_data = data.loc[:, features + [label]].dropna()
    lgb_data = lgb.Dataset(data=outcome_data.drop(label, axis=1),
                           label=outcome_data[label],
                           categorical_feature=categoricals,
                           free_raw_data=False) 

接下来,我们采取以下步骤:

  1. 为这次迭代选择超参数。

  2. 将我们刚刚创建的二进制 LightGM 数据集切分为训练集和测试集。

  3. 训练模型。

  4. 为一系列num_iteration设置生成验证集的预测:

    for p, (bagging_fraction, feature_fraction, min_data_in_leaf) \
            in enumerate(cv_params_):
        params = base_params.copy()
        params.update(dict(bagging_fraction=bagging_fraction,
                           feature_fraction=feature_fraction,
                           min_data_in_leaf=min_data_in_leaf))
        start = time()
        cv_preds, nrounds = [], []
        for i, (train_idx, test_idx) in \
                enumerate(cv.split(X=outcome_data)):
            lgb_train = lgb_data.subset(train_idx.tolist()).construct()
            lgb_test = lgb_data.subset(test_idx.tolist()).construct()
            model = lgb.train(params=params,
                              train_set=lgb_train,
                              num_boost_round=num_boost_round,
                              verbose_eval=False)
            test_set = outcome_data.iloc[test_idx, :]
            X_test = test_set.loc[:, model.feature_name()]
            y_test = test_set.loc[:, label]
            y_pred = {str(n): model.predict(X_test, num_iteration=n)
                      for n in num_iterations}
            cv_preds.append(y_test.to_frame('y_test')
                            .assign(**y_pred).assign(i=i))
            nrounds.append(model.best_iteration) 
    
  5. 为了评估验证性能,我们计算了完整预测集的 IC,以及每日的 IC,对一系列迭代次数进行了评估:

    df = [by_day.apply(lambda x: spearmanr(x.y_test,
                                           x[str(n)])[0]).to_frame(n)
          for n in num_iterations]
    ic_by_day = pd.concat(df, axis=1)
    daily_ic.append(ic_by_day.assign(bagging_fraction=bagging_fraction,
                                     feature_fraction=feature_fraction,
                                     min_data_in_leaf=min_data_in_leaf))
    cv_ic = [spearmanr(cv_preds.y_test, cv_preds[str(n)])[0]
             for n in num_iterations]
    ic.append([bagging_fraction, feature_fraction,
               min_data_in_leaf, lookahead] + cv_ic) 
    

现在,我们需要评估预测的信号内容以选择我们的交易策略的模型。

分析交叉验证性能

首先,我们将查看各种训练和测试窗口以及各种超参数设置在所有超参数设置中的 IC 分布。然后,我们将更详细地研究超参数设置对模型预测准确性的影响。

不同回溯、滚动向前和展望期的 IC

下图说明了每日平均 IC 的分布和分位数,针对四个预测时段和五个训练窗口,以及最佳的 21 天测试窗口。不幸的是,它并没有给出关于较短或较长窗口哪个效果更好的确切见解,而是说明了由于我们测试的模型配置范围以及由此产生的结果缺乏一致性而导致的数据噪声程度:

图 11.14:各种模型配置的每日平均信息系数的分布

使用 OLS 回归分析随机森林配置参数

为了更详细地了解我们实验的参数如何影响结果,我们可以对这些参数在每日平均 IC 上进行 OLS 回归分析。图 11.15显示了 1 天和 5 天展望期的系数和置信区间。

所有变量都经过 one-hot 编码,并且可以相对于每个变量的最小类别进行解释,该最小类别由常数捕获。结果在不同时段间有所不同;对于 1 天的预测,最长的训练期效果最好,但对于 5 天的预测效果最差,没有明显的模式。较长的训练似乎在一定程度上改善了 1 天模型,但对于 5 天模型来说这一点不太明确。唯一比较一致的结果似乎表明较低的装袋分数和较高的最小样本设置:

图 11.15:各种随机森林配置参数的 OLS 系数和置信区间

集成预测 - 使用 Alphalens 进行信号分析

最终,我们关心模型预测关于我们的投资范围和持有期的信号内容。为此,我们将使用 Alphalens 评估等权重组合投资于预测收益不同分位数产生的收益差异。

第四章金融特征工程 - 如何研究 Alpha 因子中讨论的那样,Alphalens 计算并可视化了各种摘要统计量,总结了 Alpha 因子的预测性能。笔记本alphalens_signals_quality说明了如何使用实用函数get_clean_factor_and_forward_returns将模型预测与价格数据以适当的格式结合起来。

为了解决 CV 预测中固有噪声的一些问题,我们根据其平均每日 IC 选择了前三个 1 天模型,并平均了它们的结果。

当我们将结果信号提供给 Alphalens 时,我们发现了以下结果,持续 1 天:

  • 年化 alpha 为 0.081,beta 为 0.083

  • 前五分位数收益之间的平均期间差距为 5.16 基点

以下图像可视化了按因子五分位数的平均期间收益和与每个分位数中的股票相关的累积每日正向收益:

图 11.16:Alphalens 因子信号评估

上述图像显示,1 天预测似乎包含了基于前五分位数的收益差异的有用交易信号。我们现在将继续开发并回测一种策略,该策略使用了在验证期间产生了这些结果的前十个 1 天前瞻模型的预测。

策略 - 使用 Zipline 进行回测

要设计并使用 Zipline 回测交易策略,我们需要为测试期间的我们的股票池生成预测,摄入日本股票数据并将信号加载到 Zipline 中,设置一个 pipeline,并定义重新平衡规则以相应地触发交易。

将日本股票纳入 Zipline

我们遵循第八章ML4T 工作流程 - 从模型到策略回测中描述的流程,将我们的 Stooq 股票 OHLCV 数据转换为 Zipline bundle。目录custom_bundle包含了创建资产 ID 和元数据的预处理模块,定义了一个执行繁重任务的摄入函数,并使用扩展注册了 bundle。

该文件夹包含一个带有额外说明的 README

运行样本内外策略的回测

笔记本 random_forest_return_signals 显示了如何选择产生最佳验证 IC 性能的超参数,并相应地生成预测。

我们将使用我们的 1 天模型预测,并应用一些简单的逻辑:对于具有最高正预期收益和最低负预期收益的 25 个资产,我们将建立多头和空头头寸。只要每边至少有 15 个候选资产,我们每天都会交易,并清除所有不在当前最佳预测中的头寸。

这次,我们还将包括每股 $0.05 的小额交易佣金,但不会使用滑点,因为我们正在以相对较小的资本基础交易日本最流动的股票。

结果 - 使用 pyfolio 进行评估

图 11.17中的左侧面板显示了该策略相对于日经 225 指数的样本内(2016-17 年)和样本外(2018-19 年)表现,而日经 225 指数在整个时期基本持平。

该策略在样本内年化基础上获得了 10.4%的收益率,在样本外年化基础上获得了 5.5%的收益率。

右侧面板显示了 3 个月滚动夏普比率,在样本内达到 0.96,在样本外达到 0.61:

图 11.17:Pyfolio 策略评估

总体表现统计数据突显了交易成本(每股 0.05 美分)后的 36.6%累计收益率,意味着样本外 Alpha 为 0.06,Beta 为 0.08(相对于日经 225 指数)。样本内最大回撤为 11.0%,样本外最大回撤为 8.7%。

全部 样本内 样本外
# 月份 48 25 23
年化收益率 8.00% 10.40% 5.50%
累积收益率 36.60% 22.80% 11.20%
年化波动率 10.20% 10.90% 9.60%
夏普比率 0.8 0.96 0.61
Calmar 比率 0.72 0.94 0.63
稳定性 0.82 0.82 0.64
最大回撤 -11.00% -11.00% -8.70%
Sortino 比率 1.26 1.53 0.95
日度风险价值 -1.30% -1.30% -1.20%
Alpha 0.08 0.11 0.06
Beta 0.06 0.04 0.08

pyfolio tearsheets 包含许多有关暴露、风险配置和其他方面的额外细节。

摘要

在本章中,我们了解了一种能够捕捉非线性关系的新型模型,与我们迄今为止探索的经典线性模型形成对比。我们看到了决策树如何学习规则来将特征空间划分为产生预测的区域,从而将输入数据分段成特定区域。

决策树非常有用,因为它们提供了关于特征和目标变量之间关系的独特见解,我们看到了如何可视化树结构中编码的决策规则序列。

不幸的是,决策树容易过拟合。我们了解到集成模型和自举聚合方法成功地克服了决策树的一些缺点,并将它们作为更强大的复合模型的组成部分变得有用。

在下一章中,我们将探讨另一个集成模型,即提升(boosting),它已被认为是最重要的机器学习算法之一。

第十二章:提升您的交易策略

在上一章中,我们看到随机森林通过将许多树组合成集成来改进决策树的预测。降低个别树高方差的关键在于使用装袋,简称自助聚合,它在生长个别树的过程中引入了随机性。更具体地说,装袋从数据中进行替换抽样,使每棵树都在一个不同但大小相等的随机子集上训练,一些观测值重复出现。此外,随机森林随机选择一些特征的子集,使每棵树的训练集的行和列都是原始数据的随机版本。然后,集成通过对各个树的输出进行平均来生成预测。

个别随机森林树通常生长较深,以确保低偏差,同时依靠随机化训练过程产生不同的、不相关的预测误差,当聚合时,这些误差的方差较低于个别树的预测。换句话说,随机化训练旨在去相关(考虑多样化)各个树的误差。它这样做是为了使整体对过拟合的影响较小,方差较低,从而更好地推广到新数据。

这一章探讨了提升(boosting),这是一种替代决策树的集成算法,通常能够产生更好的结果。其关键区别在于,提升根据模型到目前为止累积的错误修改每个新树的训练数据。与独立训练许多树的随机森林不同,提升使用数据的重新加权版本进行顺序处理。最先进的提升实现还采用了随机森林的随机化策略。

在过去的三十年中,提升已经成为最成功的机器学习ML)算法之一,主导着许多结构化、表格数据的 ML 竞赛(与高维图像或具有更复杂输入输出关系的语音数据相反,在这些领域深度学习表现出色)。我们将展示提升的工作原理,介绍几种高性能实现,并将提升应用于高频数据并对日内交易策略进行回测。

更具体地说,阅读本章后,您将能够:

  • 理解提升与装袋的区别,以及梯度提升如何从自适应提升演变而来。

  • 使用 scikit-learn 设计和调整自适应提升和梯度提升模型。

  • 使用最先进的实现 XGBoost、LightGBM 和 CatBoost 在大型数据集上构建、调整和评估梯度提升模型。

  • 解释并从梯度提升模型中获得见解。

  • 使用高频数据进行提升,设计日内策略。

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和其他资源链接。笔记本包括图像的彩色版本。

入门–自适应增强

像装袋一样,增强是一种集成学习算法,它将基学习器(通常是决策树)组合成一个集成。增强最初是为分类问题开发的,但也可用于回归,并且被称为过去 20 年中引入的最有效的学习思想之一(Hastie,Tibshirani 和 Friedman 2009)。与装袋一样,它是一种通用方法或元方法,可应用于许多统计学习方法。

增强的动机是找到一种方法,将许多弱模型(即它们仅比随机猜测略好一点)的输出合并成高度准确增强联合预测(Schapire 和 Freund 2012)。

一般来说,增强学习得出一个类似于线性回归的加法假设H[M]。然而,求和的每个m= 1,..., M元素都是一个称为h[t]的弱基学习器,它本身需要训练。以下公式总结了这种方法:

正如前一章所讨论的,装袋在不同的数据随机样本上训练基学习器。相比之下,增强通过在数据上顺序训练基学习器,该数据反复修改以反映累积学习。其目标是确保下一个基学习器弥补当前集成的缺陷。我们将在本章中看到,增强算法在定义缺陷方面存在差异。集成使用弱模型的预测的加权平均值进行预测。

第一个具有数学证明的增强算法,可以增强弱学习者的性能,是由罗伯特·沙皮尔和约阿夫·弗洛伊德于 1990 年左右开发的。1997 年,一种解决分类问题的实用解决方案以自适应增强AdaBoost)算法的形式出现,该算法在 2003 年获得了哥德尔奖(Freund 和 Schapire 1997)。大约另外 5 年后,当 Leo Breiman(发明随机森林的人)将这种方法与梯度下降联系起来,并且 Jerome Friedman 于 1999 年提出梯度增强时,该算法被扩展到任意目标函数(Friedman 2001)。

近年来出现了许多优化的实现,例如 XGBoost、LightGBM 和 CatBoost,我们稍后将在本章中介绍,这些实现已经确立了梯度增强作为结构化数据的首选解决方案。在接下来的章节中,我们将简要介绍 AdaBoost,然后重点介绍梯度增强模型,以及我们刚刚提到的这个非常强大和灵活的算法的三种最新实现。

AdaBoost 算法

当 AdaBoost 在 1990 年代出现时,它是第一个集成算法,通过迭代适应累积学习进展,当拟合额外的集成成员时。特别地,AdaBoost 改变了训练数据上的权重,以反映当前集成在训练集上的累积误差,然后拟合一个新的弱学习器。AdaBoost 当时是最准确的分类算法,利奥·布雷曼在 1996 年 NIPS 会议上称其为世界上最好的现成分类器(Hastie、Tibshirani 和 Friedman 2009)。

在随后的几十年里,该算法对机器学习产生了巨大影响,因为它提供了理论性能保证。这些保证仅需要足够的数据和一个可靠地预测略优于随机猜测的弱学习器。由于这种分阶段学习的自适应方法,开发准确的 ML 模型不再需要在整个特征空间上准确地表现。相反,模型的设计可以专注于找到仅在一小部分特征上优于硬币翻转的弱学习器。

与 bagging 相反,bagging 构建了非常大的树的集成以减小偏差,AdaBoost 则以浅树为弱学习器,通常使用树桩(即由单一分裂形成的树)产生更高的准确性。该算法从均匀加权的训练集开始,然后逐步改变样本分布。在每次迭代后,AdaBoost 增加被错误分类的观察值的权重,并减少正确预测样本的权重,以便随后的弱学习器更多地关注特别困难的情况。一旦训练完成,新的决策树将根据其减少训练误差的贡献加入到集成中。

基于预测离散类别 y N 个训练观测结果,基础学习器的集成算法 AdaBoost 可以总结如下:

  1. 对于观察值 i=1, ..., N,初始化样本权重 w[i]=1/N

  2. 对于每个基础分类器 h[m],m=1, ..., M,执行以下操作:

    1. w[i] 加权拟合 hm 到训练数据。

    2. 计算基础学习器在训练集上的加权错误率

    3. 根据其错误率计算基础学习器的集成权重 ,如下公式所示:

    4. 根据 更新误分类样本的权重。

  3. 当集成成员的加权和为正时,预测为正类,否则为负类,如下公式所示:

AdaBoost 有许多实际的优势,包括易于实现和快速计算,并且可以与任何用于识别弱学习器的方法结合使用。除了集成大小之外,没有需要调整的超参数。AdaBoost 也适用于识别异常值,因为接收最高权重的样本是那些始终被错误分类和固有模糊的样本,这也是异常值的典型特征。

还有缺点:AdaBoost 在给定数据集上的性能取决于弱学习器充分捕获特征与结果之间关系的能力。正如理论所述,当数据不足或者集成成员的复杂度与数据的复杂度不匹配时,Boosting 效果不佳。它也容易受到数据中的噪声影响。

详细介绍和审查了提升算法,请参阅 Schapire 和 Freund (2012)。

使用 AdaBoost 预测月度价格走势

作为其集成模块的一部分,scikit-learn 提供了一个支持两个或更多类别的AdaBoostClassifier实现。本节的代码示例在笔记本boosting_baseline中,该笔记本将各种算法的性能与始终预测最频繁类别的虚拟分类器进行比较。

我们需要首先将base_estimator定义为所有集成成员的模板,然后配置集成本身。我们将使用默认的DecisionTreeClassifiermax_depth=1 — 即,具有单一拆分的桩。可选的包括符合 scikit-learn 接口的任何其他模型,从线性或逻辑回归到神经网络(请参阅文档)。然而,在实践中,决策树是最常见的。

base_estimator的复杂度是一个关键的调节参数,因为它取决于数据的性质。正如前一章所示,对于max_depth的更改应与适当的正则化约束相结合,使用例如对min_samples_split的调整,如下面的代码所示:

base_estimator = DecisionTreeClassifier(criterion='gini', 
                                        splitter='best',
                                        max_depth=1, 
                                        min_samples_split=2, 
                                        min_samples_leaf=20, 
                                        min_weight_fraction_leaf=0.0,
                                        max_features=None, 
                                        random_state=None, 
                                        max_leaf_nodes=None, 
                                        min_impurity_decrease=0.0, 
                                        min_impurity_split=None) 

在第二步中,我们将设计集成。n_estimators参数控制弱学习器的数量,而learning_rate确定每个弱学习器的贡献,如下面的代码所示。默认情况下,弱学习器是决策树桩:

ada_clf = AdaBoostClassifier(base_estimator=base_estimator,
                            n_estimators=100,
                            learning_rate=1.0,
                            algorithm='SAMME.R',
                            random_state=42) 

负责良好结果的主要调节参数是n_estimatorsbase_estimator的复杂度。这是因为树的深度控制了特征之间的相互作用程度。

我们将使用自定义的OneStepTimeSeriesSplit来交叉验证 AdaBoost 集成,这是MultipleTimeSeriesCV的简化版本(请参见第六章 机器学习过程)。它实现了一个 12 折滚动时间序列拆分,以预测样本中最后 12 个月的 1 个月,使用所有可用的先前数据进行训练,如下面的代码所示:

cv = OneStepTimeSeriesSplit(n_splits=12, test_period_length=1, shuffle=True)
def run_cv(clf, X=X_dummies, y=y, metrics=metrics, cv=cv, fit_params=None):
    return cross_validate(estimator=clf,
                          X=X,
                          y=y,
                          scoring=list(metrics.keys()),
                          cv=cv,
                          return_train_score=True,
                          n_jobs=-1,                 # use all cores
                          verbose=1,
                          fit_params=fit_params) 

验证结果显示加权准确率为 0.5068,AUC 分数为 0.5348,精度和召回率分别为 0.547 和 0.576,相应地,F1 分数为 0.467。这略低于采用默认设置的随机森林,在验证 AUC 为 0.5358 时,图 12.1以箱形图显示了 12 个训练和测试折的各种指标的分布(注意,随机森林完全适应于训练集):

图 12.1:AdaBoost 交叉验证性能

有关交叉验证和处理结果的代码的详细信息,请参阅附带的笔记本。

梯度提升 - 大多数任务的集成

AdaBoost 也可以解释为一种逐步向前的方法,用于最小化二元结果的指数损失函数,y ,在每次迭代,m,中识别一个新的基学习器,h[m],具有相应的权重,,并将其添加到集成中,如下面的公式所示:

将 AdaBoost 解释为最小化特定损失函数,即指数损失的梯度下降算法,是在其原始发布几年后才发现的。

梯度提升利用这一见解,将提升方法应用于更广泛范围的损失函数。该方法使得可以设计机器学习算法来解决任何回归、分类或排名问题,只要能够使用可微分的损失函数并且具有梯度。不同任务的常见示例损失函数包括:

  • 回归(Regression):均方和绝对损失

  • 分类(Classification):交叉熵

  • 学习排名(Learning to rank):Lambda 排名损失

我们在第六章 机器学习过程中讨论了回归和分类损失函数;学习排名超出了本书的范围,但可以参考中本(2011 年)进行介绍和陈等人(2009 年)了解排名损失的详细信息。

将此通用方法定制为许多特定预测任务的灵活性对于提升其受欢迎程度至关重要。梯度提升也不局限于弱学习器,并且通常使用数层深度的决策树获得最佳性能。

结果梯度提升机器GBMs)算法背后的主要思想是训练基本学习者学习集成当前损失函数的负梯度。因此,集成的每个添加直接有助于减少整体训练误差,考虑到先前集成成员的错误。由于每个新成员代表数据的新函数,因此也可以说梯度提升是以加法方式优化数据的函数 h[m]。

简而言之,该算法连续拟合弱学习者 h[m],例如决策树,到当前集成评估的损失函数的负梯度,如下公式所示:

换句话说,在给定迭代次数 m 的情况下,该算法计算每个观察值的当前损失的梯度,然后将回归树拟合到这些伪残差上。在第二步中,它确定每个叶节点的最佳预测,以最小化由于将此新学习者添加到集成中而产生的增量损失。

这与独立决策树和随机森林不同,独立决策树和随机森林的预测取决于分配给终端节点的训练样本的结果,即回归的平均值或二元分类的正类别频率。对损失函数梯度的关注还意味着梯度提升使用回归树来学习回归和分类规则,因为梯度始终是连续函数。

最终的集成模型根据个体决策树预测的加权和进行预测,每个个体决策树都已经训练以最小化集成损失,考虑到给定一组特征值的先前预测,如下图所示:

图 12.2:梯度提升算法

梯度提升树已经在许多分类、回归和排名基准上产生了最先进的性能。它们可能是最受欢迎的集成学习算法,作为多种 ML 竞赛中的独立预测器,以及现实世界生产管道中的独立预测器,例如,用于预测在线广告的点击率。

梯度提升成功的基础在于其以增量方式学习复杂的功能关系。然而,该算法的灵活性需要通过调整 超参数 来谨慎管理过拟合风险,这些超参数限制了模型学习训练数据中的噪声而不是信号的倾向。

我们将介绍控制梯度提升树模型复杂性的关键机制,然后使用 sklearn 实现说明模型调整。

如何训练和调整 GBM 模型

尽管集合增长显著,模型复杂性也增加,但提升通常表现出令人瞩目的抗过拟合性。非常低且不增加的验证错误通常与增强对预测的信心相关联:随着提升继续增加集合,以改善最具挑战性情况的预测为目标,它调整决策边界以最大化数据点的距离或间隔。

然而,过拟合确实会发生,梯度提升性能的两个关键驱动因素是集合大小和其组成决策树的复杂性。

控制决策树复杂性的目的是避免学习高度具体的规则,这些规则通常意味着叶节点中的样本数量很少。我们在上一章中介绍了用于限制决策树过拟合训练数据能力的最有效约束。它们包括最小阈值:

  • 分裂节点或接受其作为终端节点所需的样本数量。

  • 节点质量的改善,通常由分类的纯度或熵,或回归的均方误差衡量,以进一步增长树。

除了直接控制集合大小外,还有各种正则化技术,例如我们在第七章中遇到的缩减,用于岭回归模型和套索线性回归模型的上下文。此外,用于随机森林上下文中的随机化技术也经常应用于梯度提升机。

集合大小和早停

每个提升迭代旨在减少训练损失,增加了对大型集合过拟合的风险。交叉验证是寻找最优集合大小以最小化泛化误差的最佳方法。

由于需要在训练之前指定集合大小,因此监视验证集上的性能并在给定迭代次数时,当验证错误不再减少时中止训练过程是很有用的。这种技术称为早停,经常用于需要大量迭代且容易过拟合的模型,包括深度神经网络。

请记住,在使用相同验证集进行大量试验时,使用早停也会导致过拟合,但只会针对特定的验证集而不是训练集。在开发交易策略时最好避免运行大量实验,因为误发现的风险显著增加。无论如何,保留一个留存测试集以获得对泛化错误的无偏估计是最好的。

缩减和学习率

收缩技术对模型的复杂性增加施加惩罚,将收缩应用到模型的损失函数中。对于提升集成,收缩可以通过缩小每个新集成成员的贡献的因子在 0 和 1 之间进行。这个因子称为提升集成的学习速率。降低学习速率增加收缩,因为它降低了每个新决策树对集成的贡献。

学习速率与集成大小产生相反的影响,学习速率降低时集成大小趋于增加。已发现较低的学习速率结合较大的集成可以减少测试误差,特别是对于回归和概率估计。大量迭代在计算上更昂贵,但是只要个别树保持浅层,快速的、最新的实现通常是可行的。

根据实现的不同,您还可以使用自适应学习率,它会根据迭代次数调整,通常降低后期添加的树的影响。我们将在本章后面看到一些示例。

子采样和随机梯度提升

如前一章节详细讨论的那样,自举平均(Bagging)改善了否则嘈杂分类器的性能。

随机梯度提升在每次迭代中对训练数据进行无替换采样以生成下一棵树(而 Bagging 使用替换采样)。优点是由于较小的样本和通常更好的准确性,计算工作量较小,但是子采样应与收缩结合使用。

你可以看到,超参数的数量不断增加,这导致潜在组合的数量增加。因此,在基于有限的训练数据进行大量试验并从中选择最佳模型时,假阳性的风险增加。最佳方法是按顺序进行,并逐个选择参数值,或者使用低基数子集的组合。

如何使用 sklearn 进行梯度提升

sklearn 的集成模块包含了梯度提升树的实现,用于回归和分类,二元和多类别都有。下面的GradientBoostingClassifier初始化代码说明了关键的调整参数。笔记本sklearn_gbm_tuning包含了本节的代码示例。最近(版本 0.21),scikit-learn 引入了一个更快的、但仍然是实验性的HistGradientBoostingClassifier,灵感来自以下章节中的实现。

可用的损失函数包括导致 AdaBoost 算法的指数损失和对应于概率输出的逻辑回归的偏差。friedman_mse节点质量度量是均方误差的一种变体,其中包含一个改进分数(请参阅 GitHub 上链接的 scikit-learn 文档),如下所示的代码所示:

# deviance = logistic reg; exponential: AdaBoost
gb_clf = GradientBoostingClassifier(loss='deviance',                
# shrinks the contribution of each tree
                                   learning_rate=0.1,              
# number of boosting stages
                                   n_estimators=100,               
# fraction of samples used t fit base learners
                                   subsample=1.0,                  
# measures the quality of a split
                                   criterion='friedman_mse',       
                                   min_samples_split=2,            
                                   min_samples_leaf=1, 
# min. fraction of sum of weights
                                   min_weight_fraction_leaf=0.0,   
# opt value depends on interaction
                                   max_depth=3,                    
                                   min_impurity_decrease=0.0, 
                                   min_impurity_split=None, 
                                   max_features=None, 
                                   max_leaf_nodes=None, 
                                   warm_start=False, 
                                   presort='auto',
                                   validation_fraction=0.1, 
                                   tol=0.0001) 

类似于AdaBoostClassifier,这个模型无法处理缺失值。我们将再次使用 12 折交叉验证来获取对滚动 1 个月持有期方向性回报进行分类的错误,如下所示的代码:

gb_cv_result = run_cv(gb_clf, y=y_clean, X=X_dummies_clean)
gb_result = stack_results(gb_cv_result) 

我们解析并绘制结果,发现与AdaBoostClassifier和随机森林相比略有改善,使用默认参数值,测试 AUC 提高到 0.537。图 12.3显示了我们正在跟踪的各种损失指标的箱线图:

图 12.3:scikit-learn 梯度提升分类器的交叉验证性能

如何使用 GridSearchCV 调整参数

model_selection模块中的GridSearchCV类便于对我们想要测试的所有超参数值的组合进行系统评估。在下面的代码中,我们将为七个调整参数说明这个功能,一旦定义,就会导致总共个不同的模型配置:

cv = OneStepTimeSeriesSplit(n_splits=12)
param_grid = dict(
        n_estimators=[100, 300],
        learning_rate=[.01, .1, .2],
        max_depth=list(range(3, 13, 3)),
        subsample=[.8, 1],
        min_samples_split=[10, 50],
        min_impurity_decrease=[0, .01],
        max_features=['sqrt', .8, 1]) 

.fit()方法使用自定义的OneStepTimeSeriesSplitroc_auc分数执行 12 折交叉验证。Sklearn 让我们使用joblib pickle 实现持久化结果,就像对任何其他模型一样,如下所示的代码:

gs = GridSearchCV(gb_clf,
                  param_grid,
                  cv=cv,
                  scoring='roc_auc',
                  verbose=3,
                  n_jobs=-1,
                  return_train_score=True)
gs.fit(X=X, y=y)
# persist result using joblib for more efficient storage of large numpy arrays
joblib.dump(gs, 'gbm_gridsearch.joblib') 

GridSearchCV对象在完成后具有几个附加属性,我们可以在加载拾取的结果后访问它们。我们可以使用它们来了解哪种超参数组合表现最佳及其平均交叉验证 AUC 分数,这导致与默认值相比略有改善。如下代码所示:

pd.Series(gridsearch_result.best_params_)
learning_rate              0.01
max_depth                  9.00
max_features               1.00
min_impurity_decrease      0.01
min_samples_split         50.00
n_estimators             300.00
subsample                  1.00
gridsearch_result.best_score_
0.5569 

参数对测试分数的影响

GridSearchCV结果存储了平均交叉验证分数,以便我们可以分析不同超参数设置如何影响结果。

右侧面板中的六个 seaborn swarm 图展示了所有超参数值的 AUC 测试分数分布。在这种情况下,最高的 AUC 测试分数需要低learning_rate和大的max_features值。一些参数设置,比如低learning_rate,会产生一系列取决于其他参数的互补设置的结果:

图 12.4:scikit-learn 梯度提升模型的超参数影响

我们现在将探讨超参数设置如何共同影响交叉验证性能。为了深入了解参数设置如何相互作用,我们可以训练一个DecisionTreeRegressor,以平均 CV AUC 作为结果,以及参数设置,以一位热编码或虚拟格式编码(详情请参见笔记本)。树结构突出显示,使用所有特征(max_features=1)、低learning_ratemax_depth大于三导致了最佳结果,如下图所示:

图 12.5:梯度提升模型超参数设置对测试性能的影响

图 12.4左侧面板的条形图显示了超参数设置对产生不同结果的影响,通过它们对已经达到最大深度的决策树的特征重要性来衡量。自然地,出现在树顶部附近的特征也累积了最高的重要性分数。

如何在留存集上进行测试

最后,我们想要评估我们从GridSearchCV练习中排除的留存集上最佳模型的性能。它包含样本期的最后 7 个月(截至 2018 年 2 月;详情请参阅笔记本)。

我们根据留存期的第一个月的 AUC 得分(为 0.5381)获得了一般化性能估计,使用以下代码示例:

idx = pd.IndexSlice
auc = {}
for i, test_date in enumerate(test_dates):
    test_data = test_feature_data.loc[idx[:, test_date], :]
    preds = best_model.predict(test_data)
    auc[i] = roc_auc_score(y_true=test_target.loc[test_data.index], y_score=preds)
auc = pd.Series(auc) 

sklearn 梯度提升实现的缺点是有限的训练速度,这使得快速尝试不同的超参数设置变得困难。在下一节中,我们将看到,在过去几年中出现了几个优化实现,这些实现显着减少了训练大规模模型所需的时间,并且极大地扩展了这种高效算法的应用范围。

使用 XGBoost、LightGBM 和 CatBoost

在过去几年中,出现了几个新的梯度提升实现,利用了各种创新加速训练,提高资源效率,并允许算法扩展到非常大的数据集。新实现及其来源如下:

  • XGBoost:由 T. Chen 在他的博士期间于 2014 年启动(T. Chen 和 Guestrin 2016)

  • LightGBM:由微软于 2017 年 1 月发布(Ke 等人 2017)

  • CatBoost:由 Yandex 于 2017 年 4 月发布(Prokhorenkova 等人 2019)

这些创新解决了训练梯度提升模型的特定挑战(请参阅本章的 GitHub 上的README文件,以获取文档链接)。XGBoost 实现是第一个获得流行的新实现:在 Kaggle 于 2015 年发布的 29 个获奖解决方案中,有 17 个解决方案使用了 XGBoost。其中有 8 个仅依赖于 XGBoost,而其他解决方案将 XGBoost 与神经网络结合使用。

我们将首先介绍随时间发展并最终趋同的关键创新(以便大多数功能对于所有实现都是可用的),然后说明它们的实现。

算法创新如何提升性能

随机森林可以通过在独立的自助样本上生长个体树来并行训练。相反,梯度提升的顺序方法会减慢训练速度,从而使得需要调整的大量超参数的实验变得复杂,这些超参数需要适应任务和数据集的特性。

要向集成中添加一棵树,该算法最小化与损失函数的负梯度相关的预测误差,类似于传统的梯度下降优化器。因此,训练期间的计算成本与评估每个特征的潜在分割点的时间成正比

二阶损失函数近似

最重要的算法创新通过使用依赖于二阶导数的近似来降低评估损失函数的成本,类似于牛顿法来寻找稳定点。因此,评分潜在分割变得更快。

如前所述,梯度提升集成H[M]是逐步训练的,以最小化预测误差和正则化惩罚的总和。将步骤m后的集成对结果y[i]的预测表示为,作为可微的凸损失函数,衡量结果与预测之间的差异,作为随着集成H[M]的复杂性增加而增加的惩罚。增量假设h[m]旨在最小化以下目标L

正则化惩罚有助于通过偏爱使用简单但具有预测性的回归树的模型来避免过拟合。例如,在 XGBoost 的情况下,回归树h的惩罚取决于每棵树的叶子数T、每个终端节点的回归树分数w以及超参数。这在下面的公式中总结如下:

因此,在每一步中,该算法贪婪地添加最能改善正则化目标的假设h[m]。基于泰勒展开的损失函数的二阶近似加速了目标的评估,如下面的公式所总结的那样:

在这里,g[i]是在给定特征值的情况下添加新学习器之前的损失函数的一阶梯度,h[i]是相应的二阶梯度(或 Hessian)值,如下面的公式所示:

XGBoost 算法是第一个利用损失函数的这种近似来计算给定树结构的最优叶子分数和损失函数对应值的开源算法。得分由终端节点中样本的梯度和 Hessian 总和的比率组成。它使用此值对信息增益进行评分,该信息增益是结果章节中看到的节点不纯度度量的一个类似版本,但适用于任意损失函数。有关详细推导,请参见 Chen 和 Guestrin(2016)。

简化的分割查找算法

sklearn 的原始梯度提升实现找到枚举连续特征的所有选项的最佳分裂。这个精确贪婪算法由于每个特征可能的分裂选项数量可能非常大,计算上是非常耗费资源的。当数据不适合内存或在多台机器上的分布式设置中进行训练时,这种方法面临额外的挑战。

一个近似分裂查找算法通过将特征值分配给用户确定的一组箱中的特征值来减少分裂点的数量,这也可以在训练期间极大地减少内存需求。这是因为每个箱只需要存储一个值。XGBoost 引入了一个分位数草图算法,将加权训练样本分成百分位数箱,以实现均匀分布。XGBoost 还引入了处理稀疏数据的能力,原因是缺失值、频繁的零梯度统计和独热编码,并且可以学习给定分裂的最佳默认方向。因此,该算法只需要评估非缺失值。

相反,LightGBM 使用基于梯度的单边采样GOSS)来排除具有小梯度的大部分样本,并仅使用其余部分来估计信息增益并相应地选择分裂值。具有较大梯度的样本需要更多的训练,并且往往对信息增益贡献更多。

LightGBM 还使用独占特征捆绑来组合彼此互斥的特征,即它们很少同时取非零值,以减少特征数量。因此,LightGBM 是发布时最快的实现,并且通常仍然表现最佳。

深度优先与叶子节点优先增长

LightGBM 与 XGBoost 和 CatBoost 的不同之处在于它如何确定要分裂哪些节点的优先级。LightGBM 决定按叶子节点进行分裂,即,它分裂最大化信息增益的叶子节点,即使这会导致树不平衡。相反,XGBoost 和 CatBoost 按深度扩展所有节点,并首先在给定深度级别上分裂所有节点,然后再添加更多级别。这两种方法以不同的顺序扩展节点,并且除了完全树外,将产生不同的结果。以下图示了这两种方法:

图 12.6:深度优先 vs 叶子节点优先增长

LightGBM 的叶子节点优先分裂倾向于增加模型的复杂性,并可能加快收敛速度,但也增加了过拟合的风险。一个深度为n级别的树有最多 2^n 个终端节点,而具有 2^n 个叶子节点的叶子优先树可能有更多级别,并且在某些叶子中包含相应地更少的样本。因此,调整 LightGBM 的num_leaves设置需要额外的小心,该库同时允许我们控制max_depth以避免不必要的节点不平衡。LightGBM 的更高版本也提供了深度优先树增长。

基于 GPU 的训练

所有新的实现都支持在一个或多个 GPU 上进行训练和预测,以实现显著的加速。它们与当前支持 CUDA 的 GPU 兼容。安装要求因版本而异,而且正在迅速发展。XGBoost 和 CatBoost 实现适用于几个当前版本,但是 LightGBM 可能需要本地编译(请参阅 GitHub 获取文档链接)。

加速取决于库和数据类型,范围从低位、个位数的倍数到数十倍因子。只需更改任务参数即可激活 GPU,并且不需要进行其他超参数修改。

DART – 增加回归树的辍学

Rashmi 和 Gilad-Bachrach(2015)提出了一个新模型,用于训练梯度提升树以解决他们称之为过度专业化的问题:在后续迭代中添加的树往往只影响少数实例的预测,同时对其余实例的贡献较小。然而,该模型的样本外表现可能会受到影响,并且可能会对少数树的贡献过度敏感。

新算法采用了辍学,在学习更准确的深度神经网络时已被成功使用,其中在训练期间会静音一部分神经连接。因此,更高层次的节点无法依赖于少数连接传递预测所需的信息。这种方法对于许多任务的深度神经网络的成功做出了重要贡献,还与其他学习技术(如逻辑回归)一起使用。

DART,或者称为增加回归树的辍学,在树的层面上操作,而不是在单个特征上进行操作。其目标是使使用 DART 生成的整体树对最终预测贡献更加均匀。在某些情况下,这已被证明对排名、回归和分类任务产生更准确的预测。该方法首次在 LightGBM 中实现,并且也适用于 XGBoost。

对分类特征的处理

CatBoost 和 LightGBM 实现可直接处理分类变量,无需进行虚拟编码。

CatBoost 实现(因其对分类特征的处理而命名)包括几种处理此类特征的选项,除了自动独热编码外。它将单个特征的类别或几个特征的组合分配给数值。换句话说,CatBoost 可以从现有特征的组合创建新的分类特征。与单个特征或特征组合的类别级别相关的数值取决于它们与结果值的关系。在分类情况下,这与在样本上基于先验和平滑因子计算的观察到正类的概率相关。有关更详细的数值示例,请参阅 CatBoost 文档。

LightGBM 实现将分类特征的级别分组,以最大化组内相对于结果值的同质性(或最小化方差)。XGBoost 实现不直接处理分类特征,需要独热(或虚拟)编码。

附加功能和优化

XGBoost 在几个方面优化计算以实现多线程。最重要的是,它将数据保留在压缩的列块中,其中每列按相应特征值排序。它在训练之前计算此输入数据布局一次,并在整个过程中重复使用以分摊前期成本。因此,对列上的分割统计的搜索变成了可以并行进行的分位数的线性扫描,并支持列子抽样

随后发布的 LightGBM 和 CatBoost 库基于这些创新,而 LightGBM 通过优化线程和减少内存使用量进一步加速了训练。由于它们的开源性质,库往往随着时间的推移而趋于融合。

XGBoost 还支持单调性约束。这些约束确保给定特征的值在其整个范围内与结果呈正相关或负相关。它们有助于将关于模型的外部假设纳入其中,这些假设已知为真。

带增强的多空交易策略

在本节中,我们将设计、实现和评估一个由梯度提升模型产生的每日收益预测驱动的美国股票交易策略。我们将使用 Quandl Wiki 数据来设计一些简单的特征(详见笔记本preparing_the_model_data),在使用 2015/16 作为验证期间选择模型,并在 2017 年进行样本外测试。

与之前的示例一样,我们将提供一个框架并构建一个具体的示例,您可以根据自己的实验进行调整。您可以变化的方面有很多,从资产类别和投资范围到更精细的方面,如特征、持有期或交易规则。例如,查看附录中的 Alpha 因子库以获取更多的附加功能。

我们将保持交易策略简单,只使用单个 ML 信号;实际应用可能会使用来自不同来源的多个信号,例如在不同数据集上训练的互补 ML 模型,或者具有不同前瞻或回溯期的模型。它还将使用复杂的风险管理,从简单的止损到价值风险分析。

使用 LightGBM 和 CatBoost 生成信号

XGBoost、LightGBM 和 CatBoost 提供了多种语言的接口,包括 Python,并且具有与其他 scikit-learn 功能兼容的 scikit-learn 接口,如GridSearchCV,以及用于训练和预测梯度提升模型的自己的方法。我们在本章的前两节中使用的笔记本boosting_baseline.ipynb说明了每个库的 scikit-learn 接口。该笔记本比较了各种库的预测性能和运行时间。它通过使用我们在第四章金融特征工程-如何研究 Alpha 因子中创建的特征,来训练提升模型以预测 2001-2018 年间的美国股票月回报。

下图左侧显示了使用所有实现的默认设置预测 1 个月股票价格波动的准确性,以 12 倍交叉验证产生的平均 AUC 为指标:

图 12.7:各种梯度提升模型的预测性能和运行时间

预测性能的范围从 0.525 到 0.541 不等。这看起来可能是一个小范围,但随机基准 AUC 为 0.5,最差的模型将基准提高了 5 个百分点,而最佳模型则提高了 8 个百分点,这相当于相对增长了 60 个百分点。使用 GPU 的 CatBoost 和使用整数编码的分类变量的 LightGBM 表现最佳,突显了将分类变量转换为数值变量的之前概述的好处。

实验的运行时间变化显著大于预测性能。在此数据集上,LightGBM 比 XGBoost 或 CatBoost(使用 GPU)快 10 倍,而预测性能非常相似。由于这种巨大的速度优势,并且因为 GPU 并不是每个人都可以使用的,我们将专注于 LightGBM,但也会说明如何使用 CatBoost;XGBoost 与两者非常相似。

使用 LightGBM 和 CatBoost 模型需要:

  1. 创建特定于库的二进制数据格式

  2. 配置和调整各种超参数

  3. 评估结果

我们将在接下来的章节中描述这些步骤。笔记本trading_signals_with_lightgbm_and_catboost包含了本小节的代码示例,除非另有说明。

从 Python 到 C++——创建二进制数据格式

LightGBM 和 CatBoost 都是用 C++ 编写的,并在预先计算特征统计信息之前将 Python 对象(如 pandas DataFrame)转换为二进制数据格式,以加速搜索分割点,如前一节所述。结果可以持久化以加速后续训练的启动。

我们将在前一节提到的数据集子集上进行交叉验证,直到 2016 年底,以验证多种模型配置的效果,包括不同的回溯和前瞻窗口,以及不同的向前期和超参数。我们的模型选择方法将类似于我们在上一章中使用的方法,并使用在第七章介绍的自定义 MultipleTimeSeriesCV

我们选择训练和验证集,识别标签和特征,并对值从零开始的分类变量进行整数编码,这是 LightGBM 预期的(只要类别代码的值小于 2³² 即可,但可以避免警告):

data = (pd.read_hdf('data.h5', 'model_data')
            .sort_index()
            .loc[idx[:, :'2016'], :])
labels = sorted(data.filter(like='fwd').columns)
features = data.columns.difference(labels).tolist()
categoricals = ['year', 'weekday', 'month']
for feature in categoricals:
    data[feature] = pd.factorize(data[feature], sort=True)[0] 

笔记本示例遍历许多配置,可选择使用随机样本来加快使用多样化子集进行模型选择的速度。目标是在不尝试每种可能的组合的情况下识别最具影响力的参数。

为此,我们创建二进制 Dataset 对象。对于 LightGBM,这看起来如下所示:

import lightgbm as lgb
outcome_data = data.loc[:, features + [label]].dropna()
lgb_data = lgb.Dataset(data=outcome_data.drop(label, axis=1),
                           label=outcome_data[label],
                           categorical_feature=categoricals,
                           free_raw_data=False) 

CatBoost 数据结构称为 Pool,工作原理类似:

cat_cols_idx = [outcome_data.columns.get_loc(c) for c in categoricals]
catboost_data = Pool(label=outcome_data[label],
                    data=outcome_data.drop(label, axis=1),
                    cat_features=cat_cols_idx) 

对于这两个库,我们根据结果信息确定要转换为数值变量的分类变量。CatBoost 实现需要使用索引而不是标签来识别特征列。

我们可以简单地使用 MultipleTimeSeriesCV 提供的训练和验证集索引来切片二进制数据集,如下所示,在交叉验证期间进行,将两个示例合并为一个片段:

for i, (train_idx, test_idx) in enumerate(cv.split(X=outcome_data)):
   lgb_train = lgb_data.subset(train_idx.tolist()).construct()
   train_set = catboost_data.slice(train_idx.tolist()) 

如何调整超参数

LightGBM 和 CatBoost 实现带有许多允许精细控制的超参数。每个库都有参数设置来:

  • 指定任务目标和学习算法

  • 设计基础学习者

  • 应用各种正则化技术

  • 在训练期间处理提前停止

  • 启用 GPU 或 CPU 并行化

每个库的文档详细介绍了各种参数。由于它们实现了相同算法的变体,参数可能指的是相同的概念,但跨库具有不同的名称。GitHub 仓库列出了澄清 XGBoost 和 LightGBM 参数具有相似效果的资源。

目标和损失函数

这些库支持几种提升算法,包括树和线性基础学习者的梯度提升,以及 LightGBM 和 XGBoost 的 DART。LightGBM 还支持我们之前描述的 GOSS 算法,以及随机森林。

梯度提升的吸引力在于对任意可微损失函数的有效支持,每个库都提供了各种选项用于回归、分类和排名任务。除了选择的损失函数外,还可以使用其他评估指标来监控训练和交叉验证期间的性能。

学习参数

梯度提升模型通常使用决策树来捕捉特征交互,并且个体树的大小是最重要的调整参数。XGBoost 和 CatBoost 将max_depth默认设置为 6。相反,LightGBM 使用默认的num_leaves值为 31,这对应于平衡树的五个级别,但不对级别数量施加任何限制。为了避免过拟合,num_leaves应该小于 2^(max_depth)。例如,对于表现良好的max_depth值为 7,您应该将num_leaves设置为 70-80,而不是 2⁷=128,或者直接约束max_depth

树的数量或提升迭代次数定义了整体集合的大小。所有库都支持early_stopping来在给定的迭代次数内一旦损失函数不再注册进一步改进就中止训练。因此,通常最有效的方法是设置大量迭代并根据验证集上的预测性能停止训练。但是,请注意,由于暗示的前瞻偏差,验证误差会被偏高。

这些库还允许使用自定义损失指标来跟踪训练和验证性能并执行early_stopping。笔记本演示了如何为 LightGBM 和 CatBoost 编写信息系数IC)。但是,为了避免偏差,我们不会依赖early_stopping进行实验。

正则化

所有的库都实现了对基础学习器的正则化策略,例如对样本数量的最小值或对拆分和叶节点所需的最小信息增益的限制。

它们还支持在整体集成层面上使用收缩来进行正则化,这通过限制新树的贡献来实现学习率。也可以通过回调函数实现自适应学习率,随着训练的进行降低学习率,例如在神经网络的背景下已成功使用。此外,梯度提升损失函数可以使用 L1 或 L2 正则化进行约束,类似于岭回归和套索回归模型,例如,通过增加添加更多树的惩罚来约束梯度提升损失函数。

这些库还允许使用装袋或列抽样来随机化树的生长,用于随机森林,以及去相关化预测错误以减少总体方差。对于近似拆分查找,特征量化添加了更大的箱作为另一个选项,以防止过拟合。

随机网格搜索

为了探索超参数空间,我们指定了我们想要测试的关键参数的值的组合。sklearn 库支持RandomizedSearchCV来交叉验证从指定分布中随机抽样的一部分参数组合。我们将实现一个自定义版本,允许我们监控性能,以便一旦满意结果就可以中止搜索过程,而不是事先指定一组迭代次数。

为此,我们为每个库的相关超参数指定了选项,使用 itertools 库提供的笛卡尔积生成器生成所有组合,并对结果进行了洗牌。

就 LightGBM 而言,我们关注学习率、树的最大大小、训练期间特征空间的随机化以及需要拆分的数据点的最小数量。这导致以下代码,其中我们随机选择了一半的配置:

learning_rate_ops = [.01, .1, .3]
max_depths = [2, 3, 5, 7]
num_leaves_opts = [2 ** i for i in max_depths]
feature_fraction_opts = [.3, .6, .95]
min_data_in_leaf_opts = [250, 500, 1000]
cv_params = list(product(learning_rate_ops,
                         num_leaves_opts,
                         feature_fraction_opts,
                         min_data_in_leaf_opts))
n_params = len(cv_params)
# randomly sample 50%
cvp = np.random.choice(list(range(n_params)),
                           size=int(n_params / 2), 
                           replace=False)
cv_params_ = [cv_params[i] for i in cvp] 

现在,我们基本上已经准备就绪:在每次迭代期间,我们根据lookaheadtrain_period_lengthtest_period_length参数创建一个MultipleTimeSeriesCV实例,并相应地在一个 2 年的时间段内交叉验证所选的超参数。

请注意,我们生成了一系列的合奏大小的验证预测,以便我们可以推断出最佳迭代次数:

num_iterations = [10, 25, 50, 75] + list(range(100, 501, 50))
num_boost_round = num_iterations[-1]
for lookahead, train_length, test_length in test_params:
   n_splits = int(2 * YEAR / test_length)
   cv = MultipleTimeSeriesCV(n_splits=n_splits,
                             lookahead=lookahead,
                             test_period_length=test_length,
                             train_period_length=train_length)
   for p, param_vals in enumerate(cv_params_):
       for i, (train_idx, test_idx) in enumerate(cv.split(X=outcome_data)):
           lgb_train = lgb_data.subset(train_idx.tolist()).construct()
           model = lgb.train(params=params,
                             train_set=lgb_train,
                             num_boost_round=num_boost_round,
                             verbose_eval=False)
           test_set = outcome_data.iloc[test_idx, :]
           X_test = test_set.loc[:, model.feature_name()]
           y_test = test_set.loc[:, label]
           y_pred = {str(n): model.predict(X_test, num_iteration=n) for n in num_iterations} 

请查看笔记本trading_signals_with_lightgbm_and_catboost以获取更多细节,包括如何记录结果、计算和捕获我们需要评估结果的各种指标,接下来我们将转向这一点。

如何评估结果

现在,交叉验证了大量配置,我们需要评估预测性能,以确定为我们未来的交易策略生成最可靠和最有利可图的信号的模型。笔记本evaluate_trading_signals包含了本节的代码示例。

我们生成了更多的 LightGBM 模型,因为它的运行速度比 CatBoost 快一个数量级,因此将相应地展示一些评估策略。

交叉验证结果 – LightGBM 对比 CatBoost

首先,我们比较了两个库生成的模型在所有配置方面的预测性能,包括它们的验证 IC,既跨整个验证期间又在日预测上平均。

下图显示,LightGBM 的表现(略微)优于 CatBoost,特别是对于更长的预测期。这并不是完全公平的比较,因为我们对 LightGBM 运行了更多的配置,这也不出所料地显示了更广泛的结果分散:

图 12.8:LightGBM 和 CatBoost 模型在三个预测期内的总体和日 IC

无论如何,我们将专注于 LightGBM 的结果;请查看笔记本trading_signals_with_lightgbm_and_catboostevaluate_trading_signals以获取有关 CatBoost 的更多详细信息或运行您自己的实验。

鉴于模型结果之间的显著分散,让我们更仔细地研究表现最佳的参数设置。

最佳表现参数设置

表现最佳的 LightGBM 模型使用以下参数进行三个不同的预测时间范围(详情请参阅笔记本):

Lookahead Learning Rate # Leaves Feature Fraction Min. Data in Leaf Daily Average Overall
IC # Rounds IC # Rounds
1 0.3 4 95% 1,000 1.70 75
1 0.3 4 95% 250 1.34 250
1 0.3 4 95% 1,000 1.70 75
5 0.1 8 95% 1,000 3.95 300
5 0.3 4 95% 1,000 3.43 150
5 0.3 4 95% 1,000 3.43 150
21 0.1 8 60% 500 5.84 25
21 0.1 32 60% 250 5.89 50
21 0.1 4 60% 250 7.33 75

请注意,较浅的树在三个预测时间范围内产生了最佳的整体 IC。长达 4.5 年的较长训练也产生了更好的结果。

超参数影响 - 线性回归

接下来,我们想了解是否存在系统性的、统计上的超参数与每日预测结果之间的关系。为此,我们将使用各种 LightGBM 超参数设置作为虚拟变量,并将每日验证 IC 作为结果进行线性回归。

图 12.9中的图表显示了 1 天和 21 天预测时间范围的系数估计及其置信区间。对于较短的时间范围,更长的回溯期、更高的学习率和更深的树(更多叶节点)会产生积极影响。对于较长的时间范围,情况稍微不太清晰:较短的树效果更好,但回溯期不显著。更高的特征采样率也有所帮助。在这两种情况下,更大的集成效果更好。请注意,这些结果仅适用于此特定示例。

图 12.9:不同预测时间范围的系数估计及其置信区间

使用 IC 而不是信息系数

我们对前五个模型进行平均,并提供相应的价格给 Alphalens,以便计算在不同持有期内投资于每日因子五分位数的等权重投资组合上获得的平均周期回报:

指标 持有期
1D 5D
平均周期间差异(基点) 12.1654
Ann. alpha 0.1759
beta 0.0891

我们发现顶部和底部五分位数之间有 12 个基点的差距,这意味着年化 alpha 为 0.176,而 beta 低至 0.089(见图 12.10):

图 12.10:因子分位数的平均和累积回报

以下图表显示了在最佳表现模型的 2 年验证期内,1 天和 21 天预测的季度滚动 IC:

图 12.11:1 天和 21 天回报预测的滚动 IC

短期和长期模型的平均 IC 分别为 2.35 和 8.52,在样本中大多数天数保持正值。

我们现在将看看如何在选择模型、生成预测、定义交易策略和评估其性能之前,获得有关模型工作方式的额外见解。

在黑匣子内——解释 GBM 结果

了解为什么模型会预测特定结果对于多种原因非常重要,包括信任、可操作性、问责制和调试。当目标是更多地了解研究对象的基本驱动因素时,模型揭示的特征与结果之间的非线性关系以及特征之间的相互作用也具有价值。

获取树集成方法(如梯度提升或随机森林模型)预测见解的一种常见方法是将特征重要性值归因于每个输入变量。这些特征重要性值可以针对单个预测或全局计算整个数据集(即所有样本),以获得模型如何进行预测的更高层次的视角。

本节的代码示例位于笔记本model_interpretation中。

特征重要性

有三种主要方法来计算全局特征重要性值:

  • 增益:这是一种经典方法,由 Leo Breiman 于 1984 年引入,它使用给定特征所有拆分贡献的损失或不纯度的总减少。动机在很大程度上是启发式的,但这是一种常用的特征选择方法。

  • 分割计数:这是一种替代方法,根据所选特征的选择基于产生的信息增益来计算特征用于做出分割决策的频率。

  • 排列:这种方法随机排列测试集中的特征值,并测量模型误差的变化程度,假设一个重要特征应该会导致预测误差大幅增加。不同的排列选择会导致此基本方法的替代实现。

计算单个预测的个性化特征重要性值,计算特征对单个预测的相关性较少见。这是因为可用的模型不可知解释方法比树特定方法慢得多。

所有梯度提升实现在训练后都会提供特征重要性得分作为模型属性。LightGBM 库提供了两个版本,如下列表所示:

  • 增益:特征对减少损失的贡献

  • split:该特征被使用的次数

这些值可通过训练模型的 .feature_importance() 方法和相应的 importance_type 参数获得。对于表现最佳的 LightGBM 模型,20 个最重要特征的结果如 图 12.12 所示:

图 12.12:LightGBM 特征重要性

时间周期指标占主导地位,其次是最新的回报、标准化 ATR、部门虚拟变量和动量指标(有关实施细节,请参见笔记本)。

部分依赖性图

除了总结单个特征对模型预测的贡献之外,部分依赖性图还可视化目标变量与一组特征之间的关系。梯度提升树的非线性性质导致这种关系取决于所有其他特征的值。因此,我们将对这些特征进行边际化。通过这样做,我们可以将部分依赖性解释为预期的目标响应。

我们只能为单个特征或特征对可视化部分依赖性。后者会产生等高线图,显示出不同预测概率的特征值组合如何产生不同的组合,如下面的代码所示:

fig, axes = plot_partial_dependence(estimator=best_model,
                                    X=X,
                                    features=['return_12m', 'return_6m', 
                                              'CMA', ('return_12m',
                                                      'return_6m')],
                                    percentiles=(0.01, 0.99),
                                    n_jobs=-1,
                                    n_cols=2,
                                    grid_resolution=250) 

经过一些额外的格式化(请参见配套笔记本),我们得到了如 图 12.13 所示的结果:

图 12.13:scikit-learn GradientBoostingClassifier 的部分依赖性图

右下图显示了在消除[1%,99%]分位数的异常值后,对于滞后的 12 个月和 6 个月回报值范围内的下个月正回报概率的依赖性。 month_9 变量是一个虚拟变量,因此图形类似于阶梯函数。我们还可以按照以下代码将依赖性可视化为 3D:

targets = ['return_12m', 'return_6m']
pdp, axes = partial_dependence(estimator=gb_clf,
                               features=targets,
                               X=X_,
                               grid_resolution=100)
XX, YY = np.meshgrid(axes[0], axes[1])
Z = pdp[0].reshape(list(map(np.size, axes))).T
fig = plt.figure(figsize=(14, 8))
ax = Axes3D(fig)
surf = ax.plot_surface(XX, YY, Z,
                       rstride=1,
                       cstride=1,
                       cmap=plt.cm.BuPu,
                       edgecolor='k')
ax.set_xlabel(' '.join(targets[0].split('_')).capitalize())
ax.set_ylabel(' '.join(targets[1].split('_')).capitalize())
ax.set_zlabel('Partial Dependence')
ax.view_init(elev=22, azim=30) 

这产生了关于滞后 6 个月和 12 个月回报的部分依赖性的 1 个月回报方向的以下 3D 图:

图 12.14:部分依赖性的 3D 图

SHapley Additive exPlanations

在 2017 年 NIPS 会议上,华盛顿大学的 Scott Lundberg 和 Su-In Lee 提出了一种解释树集成模型输出中单个特征贡献的新方法,称为SHapley Additive exPlanations,或SHAP值。

这种新算法与观察到的树集成的特征归因方法不一致,如我们之前所看到的那样,即,增加模型中特征对输出的影响的变化可能降低该特征的重要性值(有关详细说明,请参见 GitHub 上的参考资料)。

SHAP 值统一了协作博弈理论和局部解释的思想,并根据期望表明在理论上是最优的、一致的和局部准确的。最重要的是,Lundberg 和 Lee 开发了一种算法,成功地将这些与模型无关的、可加性的特征归因方法的复杂性从 O(TLDM) 降低到 O(TLD²),其中 TM 分别是树和特征的数量,DL 是树中的最大深度和叶子数。这一重要的创新使得可以在几秒钟内解释以前难以处理的具有数千棵树和特征的模型的预测。一个开源实现在 2017 年末可用,并兼容 XGBoost、LightGBM、CatBoost 和 sklearn 树模型。

夏普利值起源于博弈论,作为一种为合作博弈中的每个玩家分配价值的技术,反映了他们对团队成功的贡献。SHAP 值是对博弈论概念在基于树的模型中的一种改编,并计算每个特征和每个样本的 SHAP 值。它们衡量了一个特征对给定观察的模型输出的贡献。因此,SHAP 值提供了不同的见解,说明了特征的影响如何随着样本的变化而变化,这在这些非线性模型中的交互效应的作用中至关重要。

如何按特征总结 SHAP 值

要对多个样本的特征重要性进行高层次概述,有两种绘制 SHAP 值的方法:一种是对所有样本进行简单平均,类似于之前计算的全局特征重要性度量(如 图 12.15 左侧面板所示),或者绘制散点图以显示每个特征对每个样本的影响(如图的右侧面板所示)。使用兼容库中的训练模型和匹配输入数据,它们非常容易产生,如下面的代码所示:

# load JS visualization code to notebook
shap.initjs()
# explain the model's predictions using SHAP values
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
shap.summary_plot(shap_values, X_test, show=False) 

散点图根据其在所有样本中的总 SHAP 值对特征进行排序,然后显示每个特征对模型输出的影响,由 SHAP 值来衡量,作为特征值的函数,其颜色表示特征值,红色表示相对于特征范围的高值,蓝色表示低值:

图 12.15:SHAP 概要图

与传统特征重要性相比,图 12.12 显示的有一些有趣的差异;即 MACD 指标更为重要,以及相对收益指标。

如何使用力量图来解释一个预测

以下图像中的力量图显示了各种特征及其值对模型输出的 累积影响,在本例中为 0.6,比基础值 0.13(提供的数据集的平均模型输出)要高得多。突出显示为红色的特征并向右箭头指向的特征增加了输出。月份为十月是最重要的特征,并将输出从 0.338 增加到 0.537,而年份为 2017 则降低了输出。

因此,我们可以得到模型如何得出特定预测的详细分解,如下图所示:

图像

图 12.16:SHAP 力量图

我们还可以同时为多个数据点或预测计算 多个数据点的力量图,并使用 聚类可视化 来洞察数据集中某些影响模式的普遍程度。下图显示了前 1,000 个观察结果的力量图,旋转了 90 度,水平堆叠,并根据给定观察结果中不同特征对结果的影响排序。

实现使用数据点的特征 SHAP 值的分层凝聚聚类来识别这些模式,并显示结果以进行探索性分析(请参见笔记本),如下图所示的代码:

shap.force_plot(explainer.expected_value, shap_values[:1000,:],
                X_test.iloc[:1000]) 

这将产生以下输出:

图像

图 12.17:SHAP 聚类力量图

如何分析特征交互

最后,SHAP 值使我们能够通过将这些交互作用与主要效应分开来获得有关不同特征之间交互作用效应的额外见解。shap.dependence_plot 可以定义如下:

shap.dependence_plot(ind='r01',
                     shap_values=shap_values,
                     features=X,
                     interaction_index='r05',
                     title='Interaction between 1- and 5-Day Returns') 

它显示了对 1 个月回报率的不同值(x 轴)如何影响结果(y 轴上的 SHAP 值),并根据 3 个月回报率进行区分(请参见以下图):

图像

图 12.18:SHAP 交互作用图

SHAP 值在每个单独预测的级别提供细粒度的特征归因,并通过(交互式)可视化实现对复杂模型的更丰富的检查。本节前面显示的 SHAP 摘要点图(图 12.15)比全局特征重要性条形图提供了更多差异化的见解。单个聚类预测的力量图允许更详细的分析,而 SHAP 依赖图捕获交互作用效应,因此提供比部分依赖图更准确和详细的结果。

与任何当前特征重要性度量一样,SHAP 值的局限性涉及高度相关的变量的影响归因,因为它们的相似影响可以以任意方式分解。

基于提升集成的策略回溯测试

在本节中,我们将使用 Zipline 评估一个长短策略的表现,该策略根据每日收益预测信号输入 25 个多头和 25 个空头头寸。为此,我们将选择表现最佳的模型,生成预测,并设计根据这些预测行事的交易规则。

根据我们对交叉验证结果的评估,我们将选择一个或多个模型来为新的样本外期间生成信号。在本例中,我们将结合对最佳 10 个 LightGBM 模型的预测,以减少基于 Alphalens 计算的其稳定均值分位数传播的 1 天预测周期的方差。

我们只需获取表现最佳模型的参数设置,然后相应地进行训练。笔记本making_out_of_sample_predictions包含必要的代码。模型训练使用表现最佳模型的超参数设置和测试期数据,但在其他方面非常紧密地遵循了交叉验证时使用的逻辑,因此我们将在此省略细节。

在笔记本backtesting_with_zipline中,我们已经组合了验证和测试期间前 10 个模型的预测,如下所示:

def load_predictions(bundle):
    predictions = (pd.read_hdf('predictions.h5', 'train/01')
                   .append(pd.read_hdf('predictions.h5', 'test/01')
                   .drop('y_test', axis=1)))
    predictions = (predictions.loc[~predictions.index.duplicated()]
                   .iloc[:, :10]
                   .mean(1)
                   .sort_index()
                   .dropna()
                  .to_frame('prediction')) 

我们将使用我们在《第八章》《ML4T 工作流程-从模型到策略回测》中引入的自定义 ML 因子,将预测导入并在管道中进行可访问。

我们将从验证期的开始到测试期的结束执行Pipeline图 12.19显示(不足为奇)样本内表现稳健,年回报率为 27.3%,而样本外为 8.0%。图片的右侧面板显示了与标普 500 相对的累计回报率:

指标 全部 样本内 样本外
年回报率 20.60% 27.30% 8.00%
累计回报率 75.00% 62.20% 7.90%
年波动率 19.40% 21.40% 14.40%
夏普比率 1.06 1.24 0.61
最大回撤 -17.60% -17.60% -9.80%
Sortino 比率 1.69 2.01 0.87
偏度 0.86 0.95 -0.16
峰度 8.61 7.94 3.07
每日风险价值 -2.40% -2.60% -1.80%
日换手率 115.10% 108.60% 127.30%
Alpha 0.18 0.25 0.05
Beta 0.24 0.24 0.22

夏普比率分别为样本内为 1.24,样本外为 0.61;右侧面板显示了季度滚动数值。样本内的 Alpha 为 0.25,样本外为 0.05,对应的 Beta 值分别为 0.24 和 0.22。最严重的回撤导致 2015 年下半年损失了 17.59%:

图 12.19:策略表现—累计回报率和滚动夏普比率

多头交易略微比空头交易更有利润,空头交易平均损失:

摘要统计 所有交易 空头交易 多头交易
总往返数 22,352 11,631 10,721
盈利百分比 50.0% 48.0% 51.0%
获胜往返 11,131 5,616 5,515
输掉的往返 11,023 5,935 5,088
即使往返 198 80 118

学到的教训和下一步计划

总的来说,我们可以看到,尽管只使用高度流动的市场数据,梯度提升模型仍然能够提供比随机猜测显着更好的预测。显然,利润远非可以保证,尤其是因为我们对交易成本做出了非常慷慨的假设(注意高周转率)。

然而,有几种方法可以改进这个基本框架,即通过从更一般和战略性的参数变化到更具体和战术性的方面,例如:

  1. 尝试不同的投资范围(例如,更少的流动股票或其他资产)。

  2. 在添加互补数据源方面要有创意。

  3. 设计更复杂的特征工程。

  4. 使用更长或更短的持有和回望期等不同实验设置。

  5. 提出更有趣的交易规则,并使用多个而不是一个单一的 ML 信号。

希望这些建议能激发您在我们提出的模板上建立并提出有效的 ML 驱动交易策略!

用于日内策略的提升

我们在第一章从想法到执行的交易机器学习中介绍了高频交易HFT)作为加速算法策略采用的关键趋势。没有一个客观的定义能够准确定义 HFT 所涵盖的活动的特性,包括持有期、订单类型(例如,被动与主动),以及策略(动量或回归、方向性或提供流动性等)。然而,大多数更技术性的 HFT 处理似乎都同意,驱动 HFT 活动的数据往往是最精细的可用数据。通常,这将是直接来自交易所的微观结构数据,例如我们在第二章市场和基本数据-来源和技术中介绍的 NASDAQ ITCH 数据,以演示它如何详细描述每笔下单、每笔成交和每笔取消,从而允许至少对于股票而言重建完整的限价订单簿,除了某些隐藏订单。

将 ML 应用于 HFT 包括优化交易执行,无论是在官方交易所还是在黑池中。ML 还可以用于生成交易信号,正如我们将在本节中展示的那样;另请参见 Kearns 和 Nevmyvaka(2013)以获取有关 ML 如何在 HFT 环境中增加价值的其他详细信息和示例。

本节使用来自证券信息处理器生产的一致性数据源的AlgoSeek 纳斯达克 100 数据集。该数据包括最佳买卖盘报价和分钟级别的交易价格信息。还包含一些有关价格动态的特征,例如买卖价的交易数量,或者在价格级别上下正负价格波动之后的交易数量(有关更多背景信息以及在 GitHub 存储库中的数据目录中的下载和预处理说明,请参阅第二章市场和基本数据-来源和技术)。

我们将首先描述如何为此数据集设计特征,然后训练一个梯度提升模型来预测下一分钟的成交量加权平均价格,然后评估生成的交易信号的质量。

针对高频数据的工程特征

AlgoSeek 慷慨地为本书提供了一份数据集,其中包含了 2013-2017 年间任意给定日子、以分钟为频率的 100 只股票的 50 多个变量。数据还涵盖了盘前和盘后交易,但我们将此示例限制在正式交易时间内,即上午 9:30 到下午 4:00 的 390 分钟,以限制数据规模,并避免处理不规则交易活动期间的问题。请参阅笔记本intraday_features,其中包含本节中的代码示例。

我们将选择 12 个变量,其中包含超过 5100 万次观察结果作为创建 ML 模型特征的原材料。这将旨在预测 1 分钟后的成交量加权平均价格:

MultiIndex: 51242505 entries, ('AAL', Timestamp('2014-12-22 09:30:00')) to ('YHOO', Timestamp('2017-06-16 16:00:00'))
Data columns (total 12 columns):
 #   Column  Non-Null Count     Dtype  
---  ------  --------------     -----  
 0   first   51242500 non-null  float64
 1   high    51242500 non-null  float64
 2   low     51242500 non-null  float64
 3   last    51242500 non-null  float64
 4   price   49242369 non-null  float64
 5   volume  51242505 non-null  int64  
 6   up      51242505 non-null  int64  
 7   down    51242505 non-null  int64  
 8   rup     51242505 non-null  int64  
 9   rdown   51242505 non-null  int64  
 10  atask   51242505 non-null  int64  
 11  atbid   51242505 non-null  int64  
dtypes: float64(5), int64(7)
memory usage: 6.1+ GB 

由于数据的内存占用量较大,我们只创建了 20 个简单的特征,即:

  • 过去 10 分钟的滞后收益。

  • 在一根 K 线中,有上涨和下跌的交易数量,除以总交易数量。

  • 在一根 K 线中,交易价格相同(重复)的交易数量,除以总交易数量,其间有上涨或下跌。

  • 在一根 K 线中,以卖出价和买入价进行交易的股票数量之差,除以该 K 线的总成交量。

  • 包括力量平衡、商品通道指数和随机相对强度指数等多个技术指标(有关详情,请参阅附录Alpha 因子库)。

我们会确保移动数据以避免前瞻偏差,正如示范的货币流指数的计算所示,该指数使用了 TA-Lib 实现:

data['MFI'] = (by_ticker
               .apply(lambda x: talib.MFI(x.high,
                                          x.low,
                                          x['last'],
                                          x.volume,
                                          timeperiod=14)
                      .shift())) 

下图展示了对各个特征的单独预测内容进行独立评估,评估使用它们与 1 分钟后收益的等级相关性。它显示最近的滞后收益可能是最具信息量的变量:

图 12.20:高频特征的信息系数

我们现在可以开始使用这些特征训练梯度提升模型。

使用 LightGBM 的分钟频率信号

为了为我们的 HFT 策略生成预测信号,我们将训练一个 LightGBM 提升模型来预测 1 分钟前向回报。模型在训练期间接收 12 个月的分钟数据,并为随后的 21 个交易日生成样本外预测。我们将重复这个过程 24 次训练-测试拆分,以涵盖我们 5 年样本的最后 2 年。

训练过程与前述的 LightGBM 示例密切相关;有关实现细节,请参阅笔记本 intraday_model

一个关键区别是将自定义 MultipleTimeSeriesCV 调整到分钟频率;我们将引用 MultiIndexdate_time 级别(有关实现,请参阅笔记本)。我们根据每个股票和每天的 390 个观测值计算训练和测试期间的长度如下:

DAY = 390   # minutes; 6.5 hrs (9:30 - 15:59)
MONTH = 21  # trading days
n_splits = 24
cv = MultipleTimeSeriesCV(n_splits=n_splits,
                          lookahead=1,
                          test_period_length=MONTH * DAY,
                          train_period_length=12 * MONTH * DAY,
                          date_idx='date_time') 

大数据规模显著推高了训练时间,所以我们使用默认设置,但将每个集成的树数量设置为 250. 我们使用以下 ic_lgbm() 自定义指标定义跟踪测试集上的 IC,我们将其传递给模型的 .train() 方法。

自定义指标接收模型预测和二元训练数据集,我们可以使用它来计算任何感兴趣的指标;注意我们将 is_higher_better 设置为 True,因为模型默认通过最小化损失函数来进行优化(有关更多信息,请参阅 LightGBM 文档):

def ic_lgbm(preds, train_data):
    """Custom IC eval metric for lightgbm"""
    is_higher_better = True
    return 'ic', spearmanr(preds, train_data.get_label())[0], is_higher_better
model = lgb.train(params=params,
                  train_set=lgb_train,
                  valid_sets=[lgb_train, lgb_test],
                  feval=ic_lgbm,
                  num_boost_round=num_boost_round,
                  early_stopping_rounds=50,
                  verbose_eval=50) 

在 250 次迭代中,大多数折叠的验证 IC 仍在改善,因此我们的结果并不理想,但是这种方式的训练已经花费了数小时。现在让我们来看一下我们模型生成的信号的预测内容。

评估交易信号的质量

现在,我们想知道模型的样本外预测有多准确,以及它们是否可以成为盈利交易策略的基础。

首先,我们计算 IC,既对所有预测,也在每日基础上,如下所示:

ic = spearmanr(cv_preds.y_test, cv_preds.y_pred)[0]
by_day = cv_preds.groupby(cv_preds.index.get_level_values('date_time').date)
ic_by_day = by_day.apply(lambda x: spearmanr(x.y_test, x.y_pred)[0])
daily_ic_mean = ic_by_day.mean()
daily_ic_median = ic_by_day.median() 

对于连续 2 年的样本外测试,我们得到了一个统计上显著的正值为 1.90. 每日的均值 IC 为 1.98,中位数 IC 等于 1.91。

这些结果清楚地表明,预测包含了我们可以用于交易策略的短期价格运动方向和大小的有意义信息。

接下来,我们计算每个预测的十分位的平均和累积前向回报:

dates = cv_preds.index.get_level_values('date_time').date
cv_preds['decile'] = (cv_preds.groupby(dates, group_keys=False)
min_ret_by_decile = cv_preds.groupby(['date_time', 'decile']).y_test.mean()
                      .apply(lambda x: pd.qcut(x.y_pred, q=10))))
cumulative_ret_by_decile = (min_ret_by_decile
                            .unstack('decile')
                            .add(1)
                            .cumprod()
                            .sub(1)) 

图 12.21 展示了结果。左侧面板显示了每个十分位的平均 1 分钟回报,并显示每分钟 0.5 个基点的平均差异。右侧面板显示了等权重组合投资于每个十分位的累积回报,表明在交易成本之前,一个多空策略似乎是有吸引力的:

图 12.21:每个十分位的平均 1 分钟回报和累积回报

使用分钟级数据进行回测非常耗时,因此我们省略了这一步;但是,可以随意尝试使用 Zipline 或 backtrader 在更现实的交易成本假设下评估这个策略,或者使用适当的风险控制。

总结

在本章中,我们探讨了梯度提升算法,它用于以顺序方式构建集成模型,逐步添加浅层决策树来改善已做出的预测,这些决策树仅使用极少量的特征。我们看到了梯度提升树可以非常灵活地应用于广泛的损失函数,以及提供了许多机会来调整模型以适应给定数据集和学习任务。

最近的实现大大地促进了梯度提升的使用。他们通过加速训练过程并提供更一致和详细的洞察力,以了解特征的重要性和个别预测的驱动因素。

最后,我们开发了一个简单的交易策略,由一组梯度提升模型驱动,至少在交易成本显著之前是有盈利的。我们还看到了如何使用梯度提升处理高频数据。

在下一章中,我们将转向贝叶斯方法来进行机器学习。

第十三章:使用无监督学习的数据驱动风险因子和资产配置。

第六章机器学习过程,介绍了无监督学习通过发现数据结构而增加价值,而不需要结果变量来指导搜索过程。这与前几章的监督学习形成了对比:无监督学习不是预测未来结果,而是旨在学习数据的信息表示,帮助探索新数据、发现有用的见解或更有效地解决其他任务。

降维和聚类是无监督学习的主要任务:

  • 降维将现有特征转换为新的、较小的集合,同时尽量减小信息损失。算法在如何衡量信息损失、是否应用线性或非线性转换以及对新特征集施加哪些约束方面存在差异。

  • 聚类算法识别并对相似的观察结果或特征进行分组,而不是识别新特征。算法在如何定义观察结果的相似性以及对结果组的假设方面存在差异。

当一个数据集不包含结果时,这些无监督算法非常有用。例如,我们可能希望从大量财务报告或新闻文章中提取可交易的信息。在第十四章用于交易的文本数据 - 情感分析中,我们将使用主题建模来发现隐藏的主题,以更有效地探索和总结内容,并且识别有助于我们提取信号的有意义的关系。

当我们希望独立于结果地提取信息时,这些算法也非常有用。例如,与使用第三方行业分类不同,聚类允许我们根据资产的属性(例如在一定时间范围内的回报、风险因子的暴露或类似的基本面)识别出对我们有用的合成分组。在本章中,我们将学习如何使用聚类来通过识别资产回报之间的分层关系来管理投资组合风险。

更具体地说,在阅读本章后,您将了解:

  • 如何通过主成分分析(PCA)和独立成分分析(ICA)进行线性降维。

  • 使用 PCA 从资产回报中识别基于数据的风险因子和特征组合。

  • 使用流形学习有效地可视化非线性、高维数据。

  • 使用 T-SNE 和 UMAP 来探索高维图像数据。

  • k-means、层次和基于密度的聚类算法的工作原理。

  • 使用凝聚式聚类构建具有分层风险平衡的强大投资组合。

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和附加资源链接。笔记本包括图像的彩色版本。

降维

用线性代数的术语来说,数据集的特征创建了一个向量空间,其维度对应于线性独立行或列的数量,取两者中较大的一个。 当两列完全相关时,它们是线性相关的,以至于一个可以使用加法和乘法的线性运算从另一个计算出来。

换句话说,它们是代表相同方向而不是不同方向的平行向量,在数据中只构成一个维度。 同样,如果一个变量是几个其他变量的线性组合,那么它是由这些列创建的向量空间的一个元素,并且不会添加自己的新维度。

数据集的维数数量很重要,因为每个新维度都可能添加有关结果的信号。 但是,还存在一个被称为维数灾难的负面效应:随着独立特征数量的增加,而观察数量保持不变,数据点之间的平均距离也增加,并且特征空间的密度呈指数级下降,这对机器学习ML)有重大影响。 当观察值之间距离更远时,即彼此不同,预测变得更加困难。 替代数据源,如文本和图像,通常具有很高的维度,但它们通常影响依赖大量特征的模型。 下一节将解决由此产生的挑战。

降维旨在通过使用更少的特征更有效地表示数据。 为此,算法将数据投影到低维空间,同时丢弃任何不具信息量的变化,或者通过识别数据所在位置附近的低维子空间或流形。

流形是一个在局部类似于欧几里得空间的空间。 一维流形包括线或圆,但不包括数字八的可视表示,因为没有交叉点。

流形假设认为高维数据通常驻留在较低维空间中,如果识别出,就可以在此子空间中忠实地表示数据。 有关背景信息和测试此假设的算法描述,请参阅 Fefferman,Mitter 和 Narayanan(2016)。

因此,降维通过找到一个不同的、更小的变量集合来捕捉原始特征中最重要的内容,以最小化信息损失。 压缩有助于对抗维数灾难,节省内存,并允许可视化原本非常难以探索的高维数据的显着方面。

降维算法的不同之处在于它们对新变量施加的约束以及它们如何最小化信息损失(参见 Burges 2010 提供的出色概述):

  • 像 PCA 和 ICA 这样的线性算法将新变量限制为原始特征的线性组合;例如,低维空间中的超平面。而 PCA 要求新特征无相关性,ICA 进一步强调统计独立性,意味着没有线性和非线性关系。

  • 非线性算法不受超平面限制,可以捕捉数据中更复杂的结构。然而,鉴于无限的选项,算法仍然需要做出假设才能得出解决方案。本节稍后,我们将解释t-分布随机邻域嵌入t-SNE)和均匀流形近似和投影UMAP)如何用于可视化更高维度的数据。图 13.1说明了流形学习如何在三维特征空间中识别二维子空间。(笔记本manifold_learning说明了使用其他算法,包括局部线性嵌入。)

    图 13.1:非线性降维

维度诅咒

数据集维度的增加意味着在相应的欧几里得空间中,代表每个观察值的特征向量中有更多的条目。

我们使用欧几里得距离(也称为 L²范数)在向量空间中测量距离,我们将其应用于线性回归系数向量以训练正则化的岭回归。

两个具有笛卡尔坐标p = (p[1], p[2], ..., p[n])和q = (q[1], q[2], ..., q[n])的n维向量之间的欧几里得距离是使用毕达哥拉斯开发的熟悉公式计算的:

因此,每个新维度都会向总和中添加非负项,使得距离随着不同向量的维数增加而增加。换句话说,随着特征数量对于给定观察数的增长,特征空间变得越来越稀疏,即变得更少或更空。另一方面,较低的数据密度需要更多的观察来保持数据点之间的平均距离不变。

图 13.2说明了随着维度数量增加,为保持观察之间的平均距离所需的数据点数量呈指数增长。在线上均匀分布的 10 个点对应于二维中的 10²个点和三维中的 10³个点,以保持密度不变。

图 13.2:为保持平均距离恒定所需的特征数量随维度数量的指数增长

本节的 GitHub 存储库文件夹中的笔记本the_curse_of_dimensionality模拟了随着维度数量增长,数据点之间的平均距离和最小距离如何增加(见图 13.3)。

图 13.3:单位超立方体中 1,000 个数据点的平均距离

模拟随机从不相关均匀或相关正态分布中的[0, 1]范围内抽取高达 2,500 个特征。对于正态分布,数据点之间的平均距离增加到超过单位特征范围的 11 倍,对于(极端)不相关均匀分布,增加到超过 20 倍。

观察之间的距离增加时,监督式机器学习变得更加困难,因为对新样本的预测不太可能基于从类似训练特征中学习。简而言之,随着特征数量的增加,可能的唯一行数呈指数增长,使得有效地对空间进行抽样变得更加困难。同样,通过对实际关系做出较少假设的灵活算法学习的函数的复杂度随维度数量的增加呈指数增长。

灵活的算法包括我们在第十一章看到的基于树的模型,随机森林-一种日本股票的多空策略,以及第十二章提升您的交易策略。它们还包括本书后面将介绍的深度神经网络,从第十六章开始,用于盈利电话和 SEC 提交的词嵌入。随着更多维度增加了过拟合的机会,这些算法的方差增加,导致泛化性能不佳。

降维利用了实践中特征通常相关或变化很小的事实。如果是这样,它可以在不损失信号太多的情况下压缩数据,并补充使用正则化来管理由方差和模型复杂性导致的预测误差。

随后我们将探讨的关键问题是:找到数据的低维表示的最佳方法是什么?

线性降维

线性降维算法计算线性组合,转换旋转重新缩放原始特征,以捕获数据中的显着变化,同时受制于对新特征特性的约束。

PCA 由 Karl Pearson 于 1901 年发明,它找到反映数据中最大方差方向的新特征,同时彼此不相关。相比之下,ICA 起源于 20 世纪 80 年代的信号处理,其目标是在施加较强的统计独立性约束的同时分离不同的信号。

本节介绍了这两种算法,然后说明了如何将 PCA 应用于资产回报,以从数据中学习风险因素,并构建所谓的特征组合以进行系统交易策略。

主成分分析

PCA 找到现有特征的线性组合,并使用这些主成分来表示原始数据。组件的数量是一个超参数,它决定了目标维度,并且最多可以等于行数或列数中较小的那个。

PCA 的目标是捕获数据中大部分的方差,以便容易地恢复原始特征,并确保每个组件都添加信息。它通过将原始数据投影到主成分空间来降低维度。

PCA 算法通过识别一系列组件来工作,每个组件都与考虑了先前计算的组件捕捉的方差后数据中的最大方差的方向对齐。顺序优化确保新组件与现有组件不相关,并为向量空间生成正交基。

这个新基是原始基的旋转,使得新轴指向逐渐减小的方差的方向。由每个主成分解释的原始数据方差量的下降反映了原始特征之间相关性的程度。换句话说,捕获例如原始变异的 95%的组件的份额提供了有关原始数据中的线性独立信息的见解。

在二维中可视化 PCA

图 13.4说明了用于二维随机数据集的 PCA 的几个方面(参考笔记本 pca_key_ideas):

  • 左侧面板显示了第一和第二主成分如何与最大方差的方向对齐并且正交。

  • 中央面板显示了第一主成分如何将重构误差最小化,其衡量方式为数据点与新轴之间的距离之和。

  • 右侧面板说明了有监督 OLS(参考第七章线性模型 - 从风险因素到回报预测),它通过从单个特征x[1]计算的线来近似结果(x[2])。垂直线突出显示 OLS 最小化沿结果轴的距离,而 PCA 最小化与超平面正交的距离。

图 13.4:来自各个角度的二维 PCA

PCA 的关键假设

PCA 做出了几个重要的假设,需要记住。其中包括:

  • 高方差意味着高信噪比。

  • 数据经过标准化处理,使得各个特征的方差可比较。

  • 线性变换捕捉了数据的相关方面。

  • 第一和第二阶的统计量之外的高阶统计量并不重要,这意味着数据具有正态分布。

对第一和第二时刻的强调与标准的风险/收益度量一致,但正态性假设可能与市场数据的特征相冲突。市场数据经常表现出与正态分布不同的偏斜或峰度(厚尾),PCA 将不考虑这些特征。

PCA 算法的工作原理

该算法找到向量来创建目标维度的超平面,以最小化重构误差,重构误差以数据点到平面的平方距离之和来衡量。如前所述,这个目标对应于找到一系列向量,这些向量与最大保留方差的方向相一致,给定其他分量,同时确保所有主成分互相正交。

在实践中,该算法通过计算协方差矩阵的特征向量或使用 奇异值分解SVD)来解决问题。

我们使用一个随机生成的具有 100 个数据点的三维椭圆来说明计算,如 图 13.5 的左面板所示,包括由前两个主成分定义的二维超平面。(参见笔记本 the_math_behind_pca,下面三个部分的代码示例。)

图 13.5: 从 3D 到 2D 的维度缩减的可视化表示

基于协方差矩阵的 PCA

我们首先使用方阵协方差矩阵计算主成分,其中特征 x[i]、x[j] 的成对样本协方差作为第 i 行和第 j 列的条目:

对于 n 维度的方阵 M,我们将特征向量 和特征值 [i],i=1, ..., n 定义如下:

因此,我们可以使用特征向量和特征值来表示矩阵 M,其中 W 是一个包含特征向量作为列向量的矩阵,L 是一个包含特征值 [i] 作为对角线条目(其他情况下为 0)的矩阵。我们将 特征值分解 定义为:

使用 NumPy,我们实现如下,其中 pandas DataFrame 数据包含椭圆的 100 个数据点:

# compute covariance matrix: 
cov = np.cov(data.T) # expects variables in rows by default
cov.shape
(3, 3) 

接下来,我们计算协方差矩阵的特征向量和特征值。特征向量包含主成分(符号是任意的):

eigen_values, eigen_vectors = eig(cov)
eigen_vectors
array([[ 0.71409739, -0.66929454, -0.20520656],
       [-0.70000234, -0.68597301, -0.1985894 ],
       [ 0.00785136, -0.28545725,  0.95835928]]) 

我们可以将结果与从 sklearn 获得的结果进行比较,并发现它们在绝对意义上匹配:

pca = PCA()
pca.fit(data)
C = pca.components_.T # columns = principal components
C
array([[ 0.71409739,  0.66929454,  0.20520656],
       [-0.70000234,  0.68597301,  0.1985894 ],
       [ 0.00785136,  0.28545725, -0.95835928]])
np.allclose(np.abs(C), np.abs(eigen_vectors))
True 

我们也可以 验证特征值分解,从包含特征值的对角矩阵 L 开始:

# eigenvalue matrix
ev = np.zeros((3, 3))
np.fill_diagonal(ev, eigen_values)
ev # diagonal matrix
array([[1.92923132, 0\.        , 0\.        ],
       [0\.        , 0.55811089, 0\.        ],
       [0\.        , 0\.        , 0.00581353]]) 

我们发现结果确实成立:

decomposition = eigen_vectors.dot(ev).dot(inv(eigen_vectors))
np.allclose(cov, decomposition) 

使用奇异值分解的 PCA

接下来,我们将查看使用 SVD 进行的备用计算。当观测数量大于特征数量时(这是典型情况),此算法较慢,但在一些特征高度相关的情况下(这通常是使用 PCA 的原因)产生更好的数值稳定性

SVD 将我们刚刚应用于方阵和对称协方差矩阵的特征分解推广到更一般的m x n矩形矩阵情况。它的形式如下图中心所示。的对角线值是奇异值,V的转置包含作为列向量的主成分。

图 13.6:SVD 分解

在这种情况下,我们需要确保我们的数据以零均值为中心(之前的协方差计算已经处理了这个):

n_features = data.shape[1]
data_ = data - data.mean(axis=0) 

使用居中的数据,我们计算 SVD:

U, s, Vt = svd(data_)
U.shape, s.shape, Vt.shape
((100, 100), (3,), (3, 3)) 

我们可以将仅包含奇异值的向量s转换为一个n x m矩阵,并展示分解的工作原理:

S = np.zeros_like(data_)
S[:n_features, :n_features] = np.diag(s)
S.shape
(100, 3) 

我们发现分解确实复制了标准化数据:

np.allclose(data_, U.dot(S).dot(Vt))
True 

最后,我们确认V的转置的列包含主成分:

np.allclose(np.abs(C), np.abs(Vt.T)) 

在下一节中,我们将演示 sklearn 如何实现 PCA。

使用 sklearn 进行 PCA

sklearn.decomposition.PCA实现遵循基于fit()transform()方法的标准 API,分别计算所需数量的主成分并将数据投影到组件空间。方便的fit_transform()方法在一个步骤中完成此操作。

PCA 提供了三种不同的算法,可以使用svd_solver参数指定:

  • full使用由 scipy 提供的 LAPACK 求解器计算精确的 SVD。

  • arpack运行适合计算不到完整数量组件的截断版本。

  • randomized使用基于抽样的算法,当数据集具有超过 500 个观测值和特征,并且目标是计算少于 80%的组件时,它更有效率。

  • auto也随机化到最有效的地方;否则,它使用完整的 SVD。

请在 GitHub 上查看算法实现细节的参考资料。

PCA 对象的其他关键配置参数是:

  • n_components:通过传递None(默认值)来计算所有主成分,或将数量限制为int。对于svd_solver=full,还有两个额外选项:[0, 1]区间内的float计算保留数据方差相应份额所需的组件数量,选项mle使用最大似然估计维度数量。

  • whiten:如果为True,则将组件向量标准化为单位方差,在某些情况下,这可能对预测模型有用(默认值为False)。

要计算三维椭圆的前两个主成分并将数据投影到新空间中,请使用 fit_transform()

pca2 = PCA(n_components=2)
projected_data  = pca2.fit_transform(data)
projected_data.shape
(100, 2) 

前两个成分的解释方差非常接近 100%:

pca2.explained_variance_ratio_
array([0.77381099, 0.22385721]) 

图 13.5 显示了数据投影到新的二维空间中。

独立成分分析

ICA 是另一个线性算法,它确定一个新的基来表示原始数据,但追求的目标与 PCA 不同。有关详细介绍,请参阅 Hyvärinen 和 Oja(2000)。

ICA 出现在信号处理中,它旨在解决的问题被称为盲源分离。通常将其描述为鸡尾酒会问题,其中给定数量的客人同时发言,以至于单个麦克风记录重叠信号。ICA 假设存在与说话者数量相同的不同麦克风,每个麦克风放置在不同的位置,以便它们记录不同的信号混合。然后,ICA 旨在从这些不同的记录中恢复单个信号。

换句话说,有 n 个原始信号和一个未知的方阵混合矩阵 A,产生一个 nm 观测值集合,使得

目标是找到矩阵 W = A^(-1),解开混合信号以恢复源。

唯一确定矩阵 W 的能力取决于数据的非高斯分布。否则,由于多变量正态分布在旋转下的对称性,W 可以任意旋转。此外,ICA 假设混合信号是其组成部分的和,因此无法识别高斯分量,因为它们的总和也是正态分布的。

ICA 假设

ICA 做出了以下关键假设:

  • 信号的源是统计独立的

  • 线性变换足以捕获相关信息

  • 独立成分不具有正态分布

  • 混合矩阵 A 是可以求逆的。

ICA 还要求数据被居中和白化,即彼此不相关且具有单位方差。使用前述概述的 PCA 对数据进行预处理可以实现所需的转换。

ICA 算法

FastICA 是 sklearn 中使用的一种固定点算法,它使用高阶统计量来恢复独立源。特别是,它将每个组件的距离最大化到正态分布,作为独立性的代理。

一种称为 InfoMax 的替代算法将组件之间的互信息最小化,作为统计独立性的度量。

用 sklearn 进行 ICA

sklearn 中的 ICA 实现使用与 PCA 相同的接口,因此几乎没有额外添加。请注意,没有解释方差的度量,因为 ICA 不会连续计算组件。相反,每个组件旨在捕获数据的独立方面。

流形学习 – 非线性降维

线性降维将原始数据投影到一个与数据中信息方向对齐的较低维度超平面上。专注于线性变换简化了计算,并回应了常见的金融度量,例如 PCA 旨在捕获最大方差。

然而,线性方法自然会忽略数据中非线性关系反映的信号。这样的关系在包含图像或文本数据的替代数据集中非常重要。在探索性分析期间检测到这种关系可以提供有关数据潜在信号内容的重要线索。

相比之下,流形假设强调高维数据通常位于或接近嵌入在高维空间中的较低维度非线性流形上。在本章开头显示的二维瑞士卷(图 13.1)阐明了这样的拓扑结构。流形学旨在找到固有维度的流形,然后在该子空间中表示数据。一个简化的例子使用道路作为三维空间中的一维流形,并使用房屋编号作为局部坐标来识别数据点。

几种技术可以近似一个较低维度的流形。其中一个例子是局部线性嵌入LLE),由劳伦斯·索尔和萨姆·罗维斯(2000 年)发明,并用于“展开”在图 13.1中显示的瑞士卷(查看manifold_learning_lle笔记本中的示例)。

对于每个数据点,LLE 识别给定数量的最近邻居,并计算代表每个点的线性组合的权重。它通过在较低维度流形上的全局内部坐标上线性投影每个邻域来找到一个较低维度的嵌入,并可以被看作是一系列 PCA 应用。

可视化要求降维至少三个维度,可能低于固有维度,并提出了忠实地表示局部和全局结构的挑战。这个挑战与维度诅咒有关;也就是说,虽然球体的体积随着维度数量的增加呈指数级增长,但用于表示高维数据的低维空间要有限得多。例如,在 12 个维度中,可能有 13 个等距点;然而,在二维空间中,只能有 3 个形成边长相等的三角形。因此,在较低维度准确反映一个点到其高维邻居的距离会有可能扭曲所有其他点之间的关系。结果就是拥挤问题:为了保持全局距离,局部点可能需要被放置得太接近。

接下来的两个部分涵盖了使我们在处理复杂数据集的可视化中取得进展的技术。我们将使用 Fashion MNIST 数据集,这是一个更复杂的选择,用于计算机视觉的经典手写数字 MNIST 基准数据。它包含 60,000 个训练图像和 10,000 个测试图像,分为 10 个类别(在笔记本 manifold_learning_intro 中查看样本图像)。该数据的流形学习算法的目标是检测类别是否位于不同的流形上,以促进它们的识别和区分。

t-分布随机近邻嵌入

t-SNE 是由 Laurens van der Maaten 和 Geoff Hinton 于 2008 年开发的获奖算法,用于检测高维数据中的模式。它采用概率、非线性的方法来定位数据在几个不同但相关的低维流形上。该算法强调将相似的点放在低维中放在一起,而不是像 PCA 这样的算法那样保持在高维中相距较远的点之间的距离最小化。

该算法通过 将高维距离转换为(条件)概率 来进行,其中高概率意味着低距离,并反映了基于相似性对两个点进行采样的可能性。首先,在每个点上定位一个正态分布,并计算点和每个邻居的密度,其中 perplexity 参数控制有效邻居的数量。在第二步中,它将点排列在低维中,并使用类似计算的低维概率来匹配高维分布。它通过 Kullback-Leibler 散度来衡量分布之间的差异,这会对低维中的相似点放置高惩罚。

低维概率使用一个自由度为 1 的学生 t 分布,因为它有更胖的尾部,减少了放置更远的高维点的惩罚,以管理拥挤问题。

图 13.7 的上半部分显示了 t-SNE 如何区分 FashionMNIST 图像类别。更高的困惑度值增加了用于计算局部结构的邻居数,并逐渐强调全局关系。 (参考存储库以获取此图的高分辨率彩色版本。)

图 13.7:Fashion MNIST 图像数据的 t-SNE 和 UMAP 可视化,针对不同的超参数

t-SNE 是目前高维数据可视化的最新技术。其缺点包括计算复杂度随着点数 n 呈二次增长,因为它评估所有成对距离,但随后基于树的实现将成本降低到 n log n

不幸的是,t-SNE 不利于将新数据点投影到低维空间。压缩的输出对于基于距离或密度的聚类算法不是非常有用,因为 t-SNE 对待小距离和大距离的方式不同。

统一流形近似和投影

UMAP 是一种用于可视化和通用降维的较新算法。它假设数据在局部连接流形上均匀分布,并寻找最接近的低维等价物,使用模糊拓扑。它使用一个 neighbors 参数,其影响结果与前面一节中的 perplexity 类似。

它比 t-SNE 更快,因此更适用于大型数据集,并且有时比 t-SNE 更好地保留全局结构。它还可以使用不同的距离函数,包括用于测量单词计数向量之间距离的余弦相似度。

上图说明了 UMAP 确实将不同的聚类进一步分开,而 t-SNE 则提供了更精细的局部结构洞察。

笔记本还包含交互式 Plotly 可视化,用于探索每个算法的标签,并确定哪些对象彼此靠近。

用于交易的 PCA

PCA 在算法交易中有几个方面的用处,包括:

  • 将 PCA 应用于资产收益以数据驱动地推导风险因素

  • 基于资产收益相关系数矩阵的主成分构建不相关投资组合

我们将在本节中说明这两个应用。

数据驱动的风险因素

第七章中,线性模型 - 从风险因素到收益预测,我们探讨了量化金融中用于捕捉收益主要驱动因素的风险因素模型。这些模型根据资产暴露于系统性风险因素的程度以及与这些因素相关的回报来解释资产收益的差异。特别是,我们探讨了法玛-法 rench 方法,该方法根据关于平均收益的经验行为的先验知识指定因子,将这些因子视为可观察因子,然后使用线性回归估计风险模型系数。

另一种方法将风险因素视为潜在变量,并使用因子分析技术如 PCA 同时从数据中学习因子并估计它们如何影响收益。在本节中,我们将演示这种方法如何以纯粹的统计或数据驱动方式推导因子,并具有不需要事先了解资产收益行为的优点(详见笔记本 pca_and_risk_factor_models 了解更多详情)。

准备数据 - 美国前 350 家股票

我们将使用 Quandl 股票价格数据,并选择市值最大的 500 支股票的每日调整收盘价和 2010 年至 2018 年期间的数据。然后,我们将计算每日收益如下:

idx = pd.IndexSlice
with pd.HDFStore('../../data/assets.h5') as store:
    stocks = store['us_equities/stocks'].marketcap.nlargest(500)
    returns = (store['quandl/wiki/prices']
               .loc[idx['2010': '2018', stocks.index], 'adj_close']
               .unstack('ticker')
               .pct_change()) 

我们获得了 351 只股票和超过 2000 个交易日的回报:

returns.info()
DatetimeIndex: 2072 entries, 2010-01-04 to 2018-03-27
Columns: 351 entries, A to ZTS 

PCA 对异常值敏感,因此我们分别在 2.5%和 97.5%的分位数上修剪数据:

PCA 不允许缺失数据,因此我们将删除任何在至少 95%的时间段内没有数据的股票。然后,在第二步中,我们将删除在剩余股票中至少 95%的交易日没有观察到的日子:

returns = returns.dropna(thresh=int(returns.shape[0] * .95), axis=1)
returns = returns.dropna(thresh=int(returns.shape[1] * .95)) 

我们留下了 315 个股票回报系列,覆盖了一个类似的时期:

returns.info()
DatetimeIndex: 2071 entries, 2010-01-05 to 2018-03-27
Columns: 315 entries, A to LYB 

我们使用给定交易日的平均回报来填补任何剩余的缺失值:

daily_avg = returns.mean(1)
returns = returns.apply(lambda x: x.fillna(daily_avg)) 

运行 PCA 以确定主要的回报驱动因素

现在我们已经准备好使用默认参数将主成分模型拟合到资产收益上,使用全 SVD 算法来计算所有组件:

pca = PCA(n_components='mle')
pca.fit(returns) 

我们发现最重要的因素解释了大约 55%的日回报变动。主导因素通常被解释为“市场”,而其余因素可以根据更密切的检查结果(请参阅下一个示例)被解释为行业或风格因素,与我们在第五章投资组合优化和绩效评估第七章线性模型—从风险因子到回报预测中的讨论一致。

图 13.8右侧的图显示了累积解释方差,并指出大约有 10 个因子解释了这个股票横截面收益的 60%。

图 13.8:基于 PCA 的风险因子解释回报方差(累积)

笔记本包含了对更广泛的股票横截面和更长的 2000-2018 年时间段的模拟。结果发现,平均而言,前三个组件解释了 500 只随机选择的股票的 40%、10%和 5%,如图 13.9所示:

图 13.9:前 10 个主要组件的解释方差—100 次试验

累积图显示了一种典型的“肘部”模式,可以帮助确定一个合适的目标维度,即超过该维度的组件所增加的价值较少。

我们可以选择前两个主成分来验证它们确实是不相关的:

risk_factors = pd.DataFrame(pca.transform(returns)[:, :2], 
                            columns=['Principal Component 1', 
                                     'Principal Component 2'], 
                            index=returns.index)
(risk_factors['Principal Component 1']
.corr(risk_factors['Principal Component 2']))
7.773256996252084e-15 

此外,我们可以绘制时间序列以突出每个因子捕捉不同波动性模式的情况,如下图所示:

图 13.10:第一个和第二个主成分捕获的回报波动模式

风险因子模型将采用主成分的子集作为特征来预测未来的回报,类似于我们在第七章线性模型—从风险因子到回报预测中的方法。

特征组合

PCA 的另一个应用涉及标准化回报的协方差矩阵。相关矩阵的主成分按降序捕捉大部分资产之间的协变化,并且彼此不相关。此外,我们可以将标准化主成分用作投资组合权重。你可以在笔记本 pca_and_eigen_portfolios 中找到此部分的代码示例。

让我们使用 2010-2018 年间有数据的 30 家最大的股票来简化阐述:

idx = pd.IndexSlice
with pd.HDFStore('../../data/assets.h5') as store:
    stocks = store['us_equities/stocks'].marketcap.nlargest(30)
    returns = (store['quandl/wiki/prices']
               .loc[idx['2010': '2018', stocks.index], 'adj_close']
               .unstack('ticker')
               .pct_change()) 

我们再次对回报进行截尾并进行标准化处理:

normed_returns = scale(returns
                       .clip(lower=returns.quantile(q=.025), 
                             upper=returns.quantile(q=.975), 
                             axis=1)
                       .apply(lambda x: x.sub(x.mean()).div(x.std()))) 

在像上一个示例中一样剔除资产和交易日后,我们剩下了 23 个资产和超过 2000 个交易日。我们计算回报协方差并估计所有主成分,发现前两个分别解释了 55.9% 和 15.5% 的协变化:

cov = returns.cov()
pca = PCA()
pca.fit(cov)
pd.Series(pca.explained_variance_ratio_).head()
0	55.91%
1	15.52%
2	5.36%
3	4.85%
4	3.32% 

接下来,我们选择并标准化四个最大的成分,使它们总和为 1,然后我们可以将它们用作投资组合的权重,以便与由所有股票组成的等权投资组合进行比较:

top4 = pd.DataFrame(pca.components_[:4], columns=cov.columns)
eigen_portfolios = top4.div(top4.sum(1), axis=0)
eigen_portfolios.index = [f'Portfolio {i}' for i in range(1, 5)] 

权重显示出明显的强调,如 图 13.11 所示。例如,投资组合 3 对样本中的两个支付处理器 Mastercard 和 Visa 有较大的权重,而投资组合 2 对技术公司有更多的暴露:

图 13.11: 主成分投资组合权重

当比较样本期内每个投资组合的表现与由我们的小样本组成的“市场”时,我们发现投资组合 1 的表现非常相似,而其他投资组合捕捉到不同的回报模式(见 图 13.12)。

图 13.12: 累积主成分投资组合回报

聚类

聚类和降维都对数据进行总结。正如我们刚刚讨论的,降维通过使用新的、更少的特征来表示数据,从而压缩数据,以捕捉最相关的信息。相比之下,聚类算法将现有观察结果分配给由相似数据点组成的子组。

聚类可以通过学习连续变量得到的类别视角更好地理解数据。它还允许您根据学习到的标准自动对新对象进行分类。相关应用的示例包括层次分类、医学诊断和客户分割。或者,可以使用聚类来表示群体作为原型,例如使用聚类的中点作为学习群体的最佳代表。一个示例应用是图像压缩。

聚类算法在识别分组的策略方面存在差异:

  • 组合 算法选择不同观察结果的最一致的分组。

  • 概率 建模估计最可能生成聚类的分布。

  • 层次聚类 找到一系列嵌套的聚类,优化任何给定阶段的一致性。

算法还通过何为需要匹配数据特征、领域和应用目标的有用对象的概念而有所不同。 分组类型包括:

  • 明确分离的各种形状的组

  • 原型或基于中心的紧凑聚类

  • 任意形状的基于密度的聚类

  • 连通性或基于图的聚类

聚类算法的其他重要方面包括:

  • 需要独占式聚类成员资格

  • 进行硬的,即二进制的,或软的,概率的分配

  • 是完整的,并将所有数据点分配到聚类中

以下各节介绍了关键算法,包括k-means层次基于密度的聚类,以及高斯混合模型GMMs)。 笔记本 clustering_algos 比较了这些算法在不同的标记数据集上的性能,以突出它们的优缺点。 它使用互信息(参见第六章机器学习过程)来衡量聚类分配与标签的一致性。

k-means 聚类

k-means 是最知名的聚类算法,最早由 1957 年贝尔实验室的 Stuart Lloyd 提出。 它找到 k 个质心,并将每个数据点分配到恰好一个聚类中,目标是最小化簇内方差(称为 惯性)。 它通常使用欧几里得距离,但也可以使用其他度量标准。 k-means 假设聚类是球形且大小相等,并忽略特征之间的协方差。

将观察分配到聚类

该问题在计算上是困难的(NP-hard),因为有 k^N 种方法将 N 个观测分成 k 个聚类。 标准的迭代算法对于给定的 k 提供了局部最优解,并按照以下步骤进行:

  1. 随机定义 k 个聚类中心并将点分配给最近的质心

  2. 重复:

    1. 对于每个聚类,将特征的平均值计算为质心

    2. 将每个观察分配给最近的质心

  3. 收敛:分配(或簇内变异)不发生变化

笔记本 kmeans_implementation 展示了如何使用 Python 编写该算法的代码。 它可视化了算法的迭代优化,并演示了结果质心如何将特征空间划分为称为 Voronoi 的区域,这些区域勾勒出了簇。 对于给定的初始化,结果是最优的,但是不同的起始位置将产生不同的结果。 因此,我们从不同的初始值计算多个聚类,并选择最小化簇内方差的解决方案。

k-means 需要连续或独热编码的分类变量。 距离度量通常对规模敏感,因此需要对特征进行标准化以确保它们具有相同的权重。

k-means 的优点包括其广泛的适用性,快速收敛,对大数据的线性可伸缩性以及生成大小均匀的聚类。缺点包括需要调整超参数k,不能保证找到全局最优解,限制性假设聚类为球形,特征不相关。它还对离群值敏感。

评估聚类质量

聚类质量度量有助于从多个聚类结果中选择。笔记本kmeans_evaluation说明了以下选项。

k-means 目标函数建议我们比较惯性或聚类内方差的演变。最初,额外的质心会急剧降低惯性,因为新的聚类改善了整体拟合。一旦找到适当数量的聚类(假设存在),新的质心减少了聚类内方差,因为它们倾向于分割自然的分组。

因此,当 k-means 找到数据的良好聚类表示时,惯性往往会呈现类似于 PCA 的解释方差比的拐点形状(查看笔记本以获取实现细节)。

轮廓系数提供了聚类质量的更详细的图景。它回答了一个问题:最近聚类中的点相对于分配的聚类中的点有多远?为此,它比较了平均类内距离a与最近聚类的平均距离b,并计算了以下分数s

分数可以在-1 和 1 之间变化,但在实践中不太可能出现负值,因为它们意味着大多数点被分配到错误的聚类中。轮廓分数的一个有用的可视化将每个数据点的值与全局平均值进行比较,因为它突显了每个聚类相对于全局配置的一致性。经验法则是要避免平均分数低于所有样本的平均值的聚类。

图 13.13显示了三个和四个聚类的轮廓图节选,前者突出了通过对全局轮廓分数的不足贡献来强调聚类 1 的拟合不佳,而所有四个聚类都具有一些值,这些值显示出高于平均分数的分数。

图 13.13:三个和四个聚类的轮廓图

总之,鉴于通常是无监督的性质,有必要改变聚类算法的超参数并评估不同的结果。还重要的是校准特征的比例,特别是当一些特征应该被赋予更高的权重并因此以较大的比例进行测量时。最后,为了验证结果的稳健性,使用数据子集来确定是否会一致出现特定的聚类模式。

分层聚类

分层聚类避免了需要指定目标聚类数的需要,因为它假设数据可以被逐步合并为越来越不相似的聚类。它不追求全局目标,而是逐步决定如何产生一系列从单个聚类到由个别数据点组成的聚类的嵌套聚类。

不同的策略和不相似度度量标准

有两种分层聚类方法:

  1. 聚合聚类 自下而上进行,基于相似性顺序合并剩余的两个组。

  2. 分裂聚类 自顶向下工作,顺序地分裂剩余的聚类,以产生最不同的子组。

两个组都产生N-1 个层次聚类,并有助于在最佳将数据分区为同质组的级别上选择聚类。我们将重点放在更常见的聚合聚类方法上。

聚合聚类算法不从个别数据点出发,而是计算一个包含所有相互距离的相似度矩阵。然后,它进行N-1 步,直到没有更多的不同聚类,并且每次都更新相似度矩阵以替换被新聚类合并的元素,使矩阵逐渐缩小。

虽然分层聚类没有像 k-means 那样的超参数,但是聚类之间(而不是个别数据点之间)的不相似度度量对聚类结果有重要影响。选项有以下不同:

  • 单连接法:两个聚类的最近邻之间的距离

  • 完全连接法:各自聚类成员之间的最大距离

  • 瓦德法:最小化簇内方差

  • 组平均法:使用聚类中点作为参考距离

可视化 - 树状图

分层聚类提供了关于观察值之间相似程度的见解,因为它继续合并数据。从一次合并到下一次合并的相似度度量的显着变化表明在此点之前存在自然的聚类。

树状图 将连续的合并可视化为一棵二叉树,将个别数据点显示为叶子,并将最终合并显示为树的根。它还显示了相似度如何从底部向顶部单调递减。因此,通过切割树状图来选择聚类是很自然的。有关实现详细信息,请参阅笔记本hierarchical_clustering

图 13.14 展示了经典的鸢尾花数据集的树状图,其中有四类和三个特征,使用了前面部分介绍的四种不同的距离度量标准。它评估了分层聚类的拟合程度,使用了科菲尼系数,该系数比较了点之间的成对距离和聚类相似度指标,该指标显示了成对合并发生的聚类相似度度量。系数为 1 意味着更接近的点总是较早合并。

图 13.14:不同不相似度度量的树状图和共辐相关性

不同的链接方法产生不同的树状图“外观”,因此我们无法使用此可视化来跨方法比较结果。此外,最小化簇内方差的 Ward 方法可能不能正确反映方差从一个级别到下一个级别的变化。相反,树状图可以反映不同级别的总簇内方差,这可能会产生误导。与整体目标一致的替代质量度量更为适当,例如共辐相关性或与总体目标一致的度量标准,如惯性

分层聚类的优势包括:

  • 该算法不需要特定数量的聚类,而是通过直观的可视化提供了关于潜在聚类的见解。

  • 它生成一系列可用作分类法的聚类层次结构。

  • 它可以与 k 均值结合以减少聚合过程开始时的项目数量。

另一方面,它的缺点包括:

  • 由于大量相似性矩阵更新而产生的计算和内存成本高昂。

  • 所有合并都是最终的,因此它无法达到全局最优。

  • 维度诅咒导致对嘈杂的高维数据困难重重。

基于密度的聚类

基于密度的聚类算法根据与其他聚类成员的接近程度分配聚类成员资格。它们追求识别任意形状和大小的密集区域的目标。它们不需要指定一定数量的聚类,而是依赖于定义邻域大小和密度阈值的参数。

我们将概述两种流行的算法:DBSCAN 及其较新的分层精化。请参考笔记本density_based_clustering中的相关代码示例以及本章的 GitHub 上的README链接,以了解 Jonathan Larking 使用 DBSCAN 进行配对交易策略的 Quantopian 示例。

DBSCAN

基于密度的带噪声空间聚类DBSCAN)于 1996 年开发,并因其在理论和实践中所受到的关注,在 2014 年的 KDD 会议上获得了 KDD 时代测试奖。

它旨在识别核心和非核心样本,其中前者扩展了一个聚类,而后者是聚类的一部分,但没有足够的附近邻居来进一步扩展聚类。其他样本是异常值,并且不分配给任何聚类。

它使用参数eps来表示邻域的半径和min_samples来表示需要的核心样本数量。它是确定性的和独占的,并且在具有不同密度和高维数据的情况下存在困难。调整参数以满足必要密度可能是具有挑战性的,特别是因为密度通常不是恒定的。

分层 DBSCAN

层次 DBSCANHDBSCAN)是更近期的发展,它假设聚类是潜在密度不同的岛屿,以克服刚提到的 DBSCAN 的挑战。它还旨在识别核心和非核心样本。它使用参数min_cluster_sizemin_samples选择邻域并扩展聚类。该算法迭代多个eps值并选择最稳定的聚类。除了识别密度不同的聚类外,它还提供了有关数据密度和层次结构的洞察。

图 13.15显示了 DBSCAN 和 HDBSCAN 分别如何能够识别形状与 k 均值发现的聚类显著不同的簇。聚类算法的选择取决于数据的结构;请参考本节早期提到的配对交易策略以获得一个实际示例。

图 13.15:比较 DBSCAN 和 HDBSCAN 聚类算法

高斯混合模型

GMM 是假设数据由各种多元正态分布的混合生成的生成模型。该算法旨在估计这些分布的均值和协方差矩阵。

GMM 泛化了 k 均值算法:它在特征之间添加了协方差,使得聚类可以是椭球而不是球体,而聚类的中心点由每个分布的均值表示。GMM 算法执行软分配,因为每个点都有成为任何聚类成员的概率。

笔记本gaussian_mixture_models演示了实现并可视化结果聚类的过程。当 k 均值假设球形聚类过于约束时,你可能更喜欢 GMM 而不是其他聚类算法;鉴于其更大的灵活性,GMM 通常需要更少的聚类来产生良好的拟合效果。当你需要一个生成模型时,GMM 算法也更可取;因为 GMM 估计生成样本的概率分布,所以基于结果生成新样本很容易。

用于最优组合的层次聚类

第五章组合优化和绩效评估中,我们讨论了几种旨在选择给定资产集的组合权重以优化所得组合的风险和回报特性的方法。这些方法包括马科维茨的现代投资组合理论的均值-方差优化、凯利准则和风险平价。在本节中,我们介绍了更近期的创新(Prado 2016)——层次风险平价HRP),它利用层次聚类根据子组的风险特征来分配资产仓位。

我们将首先介绍 HRP 的工作原理,然后使用我们在上一章中开发的梯度提升模型,通过长仓策略比较其性能。

层次风险平价的工作原理

层次风险平价的关键思想包括以下几点:

  • 使用协方差矩阵的层次聚类将具有相似相关结构的资产分组在一起

  • 通过仅在构建投资组合时将相似资产视为替代品来减少自由度

有关实施详细信息,请参阅子文件夹 hierarchical_risk_parity 中的笔记本和 Python 文件。

第一步是计算一个距离矩阵,代表相关资产的接近度并满足距离度量的要求。得到的矩阵成为 SciPy 层次聚类函数的输入,该函数使用先前在本章讨论过的几种可用方法之一计算连续的群集。

def get_distance_matrix(corr):
    """Compute distance matrix from correlation;
        0 <= d[i,j] <= 1"""
    return np.sqrt((1 - corr) / 2)
distance_matrix = get_distance_matrix(corr)
linkage_matrix = linkage(squareform(distance_matrix), 'single') 

linkage_matrix 可用作 sns.clustermap 函数的输入,以可视化结果的层次聚类。由 seaborn 显示的树状图显示了个别资产和资产集合如何根据它们的相对距离合并(参见图 13.16 的左面板)。

clustergrid = sns.clustermap(distance_matrix,
                             method='single',
                             row_linkage=linkage_matrix,
                             col_linkage=linkage_matrix,
                             cmap=cmap, center=0)
sorted_idx = clustergrid.dendrogram_row.reordered_ind
sorted_tickers = corr.index[sorted_idx].tolist() 

与原始相关矩阵的 seaborn.heatmap 相比,排序数据(右面板)中现在具有显着更多的结构,与中央面板中显示的原始相关矩阵相比。

图 13.16:原始和聚类相关矩阵

使用根据聚类算法诱导的层次结构排序的标记,HRP 现在开始计算一个自上而下的逆方差分配,根据树下进一步子集的方差连续调整权重。

def get_inverse_var_pf(cov):
    """Compute the inverse-variance portfolio"""
    ivp = 1 / np.diag(cov)
    return ivp / ivp.sum()
def get_cluster_var(cov, cluster_items):
    """Compute variance per cluster"""
    cov_ = cov.loc[cluster_items, cluster_items]  # matrix slice
    w_ = get_inverse_var_pf(cov_)
    return (w_ @ cov_ @ w_).item() 

为此,该算法使用二分搜索将群集的方差分配给其元素,这些元素基于它们的相对风险性。

def get_hrp_allocation(cov, tickers):
    """Compute top-down HRP weights"""
    weights = pd.Series(1, index=tickers)
    clusters = [tickers]  # initialize one cluster with all assets
    while len(clusters) > 0:
        # run bisectional search:
        clusters = [c[start:stop] for c in clusters
                    for start, stop in ((0, int(len(c) / 2)),
                                        (int(len(c) / 2), len(c)))
                    if len(c) > 1]
        for i in range(0, len(clusters), 2):  # parse in pairs
            cluster0 = clusters[i]
            cluster1 = clusters[i + 1]
            cluster0_var = get_cluster_var(cov, cluster0)
            cluster1_var = get_cluster_var(cov, cluster1)
            weight_scaler = 1 - cluster0_var / (cluster0_var + cluster1_var)
            weights[cluster0] *= weight_scaler
            weights[cluster1] *= 1 - weight_scaler
    return weights 

结果组合配置产生的权重总和为 1,并反映在相关矩阵中存在的结构(详见笔记本)。

使用 ML 交易策略回测 HRP

现在我们知道 HRP 的工作原理,我们想测试它在实践中的表现如何与一些替代方案相比,即简单的等权重组合和均值-方差优化组合。您可以在笔记本 pf_optimization_with_hrp_zipline_benchmark 中找到此部分的代码示例以及其他详细信息和分析。

为此,我们将建立在上一章开发的梯度提升模型的基础上。我们将对 2015-2017 年的策略进行回测,使用最流动的 1000 只美国股票的宇宙。该策略依赖于模型预测,以买入次日预测收益最高的 25 只股票。我们每天重新平衡我们的持仓,以使我们的目标位置的权重与 HRP 建议的值匹配。

合并梯度提升模型的预测

我们首先对 2015-16 交叉验证期间表现最佳的 10 个模型的预测进行平均,如下面的代码摘录所示:

def load_predictions(bundle):
    path = Path('../../12_gradient_boosting_machines/data')
    predictions = (pd.read_hdf(path / 'predictions.h5', 'lgb/train/01')
                   .append(pd.read_hdf(path / 'predictions.h5', 'lgb/test/01').drop('y_test', axis=1)))
    predictions = (predictions.loc[~predictions.index.duplicated()]
                   .iloc[:, :10]
                   .mean(1)
                   .sort_index()
                   .dropna()
                  .to_frame('prediction')) 

我们每天都会获得模型预测并选择前 25 个股票代码。如果至少有 20 个股票有正面预测,我们会进入多头仓位并关闭所有其他持仓:

def before_trading_start(context, data):
    """
    Called every day before market open.
    """
    output = pipeline_output('signals')['longs'].astype(int)
    context.longs = output[output!=0].index
    if len(context.longs) < MIN_POSITIONS:
        context.divest = set(context.portfolio.positions.keys())
    else:
        context.divest = context.portfolio.positions.keys() - context.longs 

使用 PyPortfolioOpt 计算 HRP 权重

我们在第五章《投资组合优化与绩效评估》中使用的 PyPortfolioOpt 来计算均值-方差优化权重,也实现了 HRP。我们将在每天早上进行的定期再平衡的一部分中运行它。它需要目标资产的历史回报,并返回一个我们用于下订单的股票-权重对的字典:

def rebalance_hierarchical_risk_parity(context, data):
    """Execute orders according to schedule_function()"""
    for symbol, open_orders in get_open_orders().items():
        for open_order in open_orders:
            cancel_order(open_order)
    for asset in context.divest:
        order_target(asset, target=0)

    if len(context.longs) > context.min_positions:
        returns = (data.history(context.longs, fields='price',
                          bar_count=252+1, # for 1 year of returns 
                          frequency='1d')
                   .pct_change()
                   .dropna(how='all'))
        hrp_weights = HRPOpt(returns=returns).hrp_portfolio()
        for asset, target in hrp_weights.items():
            order_target_percent(asset=asset, target=target) 

Markowitz 再平衡遵循类似的过程,如第五章《投资组合优化与绩效评估》中所述,并包含在笔记本中。

与 pyfolio 的性能比较

以下图表显示了等权重EW)、HRP均值-方差MV)优化投资组合的样本内和样本外(相对于 ML 模型选择过程)的累积收益。

图片

图 13.17:不同投资组合的累积收益

累积收益分别为 MV 为 207.3%,EW 为 133%,HRP 为 75.1%。夏普比率分别为 1.16、1.01 和 0.83。Alpha 收益分别为 MV 为 0.28,EW 为 0.16,HRP 为 0.16,对应的贝塔值分别为 1.77、1.87 和 1.67。

因此,在这种特定情境下,常受批评的MV 方法效果最好,而HRP则排在最后。然而,请注意结果对交易股票数量、时间周期和其他因素非常敏感。

试着自己尝试一下,了解在最适合您的情况下哪种技术表现最好!

总结

在本章中,我们探讨了无监督学习方法,这些方法允许我们从数据中提取有价值的信号,而无需依赖标签提供的结果信息的帮助。

我们学习了如何使用线性降维方法如 PCA 和 ICA 来从数据中提取无关或独立的组件,这些组件可以作为风险因子或投资组合权重。我们还涵盖了先进的非线性流形学习技术,这些技术可以生成复杂、替代数据集的最新可视化效果。在章节的第二部分,我们涵盖了几种根据不同假设产生数据驱动的分组的聚类方法。这些分组可以用来构建将风险平价原则应用于已经按层次聚类的资产的投资组合,例如。

在接下来的三章中,我们将学习关于一种重要的替代数据来源的各种机器学习技术,即自然语言处理文本文档。

第十四章:用于交易的文本数据——情感分析

这是专门从文本数据中提取用于算法交易策略的信号的三章之一,使用自然语言处理NLP)和机器学习ML)。

文本数据在内容上非常丰富,但结构非常不规则,因此需要更多的预处理以使 ML 算法能够提取相关信息。一个关键挑战是将文本转换为数字格式而不丢失其含义。我们将介绍几种能够捕捉语言细微差别的技术,以便它们可以用作 ML 算法的输入。

在本章中,我们将介绍基本的特征提取技术,重点放在个别语义单元上,即单词或称为令牌的短组合。我们将展示如何将文档表示为令牌计数的向量,方法是创建一个文档-术语矩阵,然后继续将其用作新闻分类情感分析的输入。我们还将介绍朴素贝叶斯算法,该算法在这方面很受欢迎。

在接下来的两章中,我们将基于这些技术,并使用主题建模和词向量嵌入等 ML 算法来捕获更广泛上下文中包含的信息。

特别是在本章中,我们将涵盖以下内容:

  • 基本的 NLP 工作流程是什么样的

  • 如何使用 spaCy 和 TextBlob 构建多语言特征提取管道

  • 执行 NLP 任务,例如词性POS)标记或命名实体识别

  • 使用文档-术语矩阵将令牌转换为数字

  • 使用朴素贝叶斯模型对文本进行分类

  • 如何执行情感分析

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和其他资源链接。笔记本包括图像的彩色版本。

使用文本数据的 ML——从语言到特征

鉴于人类使用自然语言进行沟通和存储的信息量之大,文本数据可能非常有价值。与金融投资相关的各种数据源涵盖了从正式文件(如公司声明,合同和专利)到新闻,观点,分析师研究或评论,再到各种类型的社交媒体帖子或消息。

在网上有大量丰富多样的文本数据样本可供探索 NLP 算法的使用,其中许多列在这一章的 GitHub 的README文件中的资源中。有关全面介绍,请参见 Jurafsky 和 Martin(2008 年)。

为了实现文本数据的潜在价值,我们将介绍专门的 NLP 技术和最有效的 Python 库,概述特定于处理语言数据的关键挑战,介绍 NLP 工作流程的关键要素,并突出与算法交易相关的 NLP 应用。

处理文本数据的关键挑战

将非结构化文本转换为机器可读格式需要仔细的预处理,以保留数据的宝贵语义方面。人类如何理解语言的内容尚不完全清楚,改进机器理解语言的能力仍然是一个非常活跃的研究领域。

自然语言处理(NLP)特别具有挑战性,因为有效利用文本数据进行机器学习需要理解语言的内在工作原理以及它所指的世界的知识。 主要挑战包括以下内容:

  • 由于多义性而产生的歧义,即一个词或短语根据上下文具有不同的含义(“Local High School Dropouts Cut in Half”)

  • 在社交媒体上尤其是非标准和不断发展的语言使用

  • 使用诸如“throw in the towel”这样的习语

  • 像“Where is A Bug's Life playing?”这样的棘手的实体名称

  • 对世界的了解:“Mary and Sue are sisters”与“Mary and Sue are mothers”

自然语言处理工作流程

从文本数据中使用机器学习进行算法交易的一个关键目标是从文档中提取信号。 文档是来自相关文本数据源的单个样本,例如公司报告、标题、新闻文章或推文。 语料库,反过来,是文档的集合。

图 14.1 概述了将文档转换为可以用于训练具有可操作预测能力的监督机器学习算法的数据集的关键步骤

图 14.1:自然语言处理工作流程

基本技术 提取文本特征作为被称为标记的孤立语义单元,并使用规则和字典对它们进行语言和语义信息的标注。 词袋模型使用令牌频率将文档建模为令牌向量,这导致经常用于文本分类、检索或摘要的文档-术语矩阵。

高级方法 依赖于机器学习来改进基本特征,例如令牌,并生成更丰富的文档模型。 这些包括反映跨文档使用令牌的联合的主题模型和旨在捕获令牌使用上下文的单词向量模型。

在下一节中,我们将详细审查工作流程每一步的关键决策以及相关的权衡,并在示例中使用 spaCy 库说明它们的实现。 以下表格总结了自然语言处理管道的关键任务:

特征 描述
分词 将文本分割成单词、标点符号等。
词性标注 为令牌分配词类型,如动词或名词。
依存句法分析 标记句法令牌依赖关系,如主语<=>宾语。
词干提取和词形还原 分配单词的基本形式:“was” => “be”,“rats” => “rat”。
句子边界检测 找到并分割单个句子。
命名实体识别 标记“真实世界”对象,如人物、公司或地点。
相似性 评估单词、文本跨度和文档的相似性。

解析和标记文本数据 - 选择词汇表

标记是给定文档中字符序列的实例,并被认为是一个语义单位。词汇是被认为对进一步处理相关的语料库中包含的标记的集合;不在词汇中的标记将被忽略。

当然,目标是提取最能准确反映文档含义的标记。在这一步的关键折衷是选择更大的词汇量,以更好地反映文本来源,代价是增加更多的特征和更高的模型复杂性(在第十三章使用无监督学习评估数据驱动的风险因素和资产配置中讨论为维度诅咒)。

在这方面的基本选择涉及标点和大写的处理,拼写校正的使用,以及是否排除非常频繁的所谓“停用词”(如“and”或“the”)作为无意义的噪音。

另外,我们需要决定是否将由n个单独的标记组成的n-gram作为语义单位(一个单独的标记也称为unigram)包含在内。一个二元组(或bigram)的例子是“纽约”,而“纽约市”是一个三元组(或trigram)。这个决定可以依赖于词典或者对个体和联合使用的相对频率进行比较。与 unigrams 相比,标记的唯一组合更多,因此添加n-grams 将增加特征数量,并且除非按频率进行过滤,否则会添加噪音。

语言学标注 - 标记之间的关系

语言学标注包括将句法和语法规则应用于识别句子边界,尽管标点符号模糊不清,以及词标注和依赖解析中的一个标记的角色和关系。它还允许识别词根的常见形式以进行词干提取和词形还原,以将相关单词分组在一起。

以下是与标注相关的一些关键概念:

  • 词干提取使用简单的规则从标记中删除常见的结尾,比如slyinged,并将其减少到其词干或根形式。

  • 词形还原使用更复杂的规则来推导单词的规范根(lemma)。它可以检测到不规则的常见根,比如“better”和“best”,并更有效地压缩词汇,但比词干提取慢。这两种方法都是以语义细微差别为代价来简化词汇。

  • POS标注有助于根据它们的功能消除标记的歧义(例如,当动词和名词具有相同的形式时),这会增加词汇量,但可能捕捉到有意义的区别。

  • 依赖解析识别标记之间的分层关系,通常用于翻译。对于需要更高级语言理解的交互应用程序,比如聊天机器人,这一点至关重要。

语义注释 - 从实体到知识图

命名实体识别NER)旨在识别表示感兴趣对象的标记,如人物、国家或公司。它可以进一步发展成捕捉这些实体之间语义和层次关系的知识图。这对于那些旨在预测新闻事件对情绪影响的应用至关重要。

标签化 - 为预测建模分配结果

许多自然语言处理应用程序通过从文本中提取的有意义信息来学习预测结果。监督学习需要标签来教会算法真实的输入输出关系。在文本数据中,建立这种关系可能具有挑战性,并且可能需要显式的数据建模和收集。

示例包括如何量化文本文档(例如电子邮件、转录的采访或推文)中隐含的情感,与新领域相关的,或者应该分配特定结果的研究文档或新闻报告的哪些方面。

应用

使用文本数据进行交易的机器学习依赖于提取有意义的信息以形成有助于预测未来价格走势的特征。应用范围从利用新闻的短期市场影响到对资产估值驱动因素的长期基本分析。例如:

  • 评估产品评论情感以评估公司的竞争地位或行业趋势

  • 检测信贷合同中的异常以预测违约的概率或影响

  • 预测新闻影响的方向、幅度和受影响的实体

例如,摩根大通公司基于 25 万份分析师报告开发了一个预测模型,该模型的表现优于多个基准指数,并且相对于从共识 EPS 和推荐变化中形成的情绪因素产生了不相关的信号。

从文本到标记 - 自然语言处理流程

在本节中,我们将演示如何使用开源 Python 库 spaCy 构建一个 NLP 流程。textacy 库基于 spaCy 构建,并提供了易于访问的 spaCy 属性和额外功能。

请参阅笔记本nlp_pipeline_with_spaCy以获取以下代码示例、安装说明和更多详细信息。

使用 spaCy 和 textacy 的自然语言处理流程

spaCy 是一个广泛使用的 Python 库,具有多语言快速文本处理的综合功能集。使用标记化和注释引擎需要安装语言模型。本章中我们将使用的功能仅需要小型模型;较大的模型还包括我们将在第十六章中介绍的词向量。

安装并链接库后,我们可以实例化一个 spaCy 语言模型,然后将其应用于文档。结果是一个Doc对象,它对文本进行标记化和处理,根据默认的可配置流水线组件进行处理,这些组件通常包括标记器、解析器和命名实体识别器:

nlp = spacy.load('en')
nlp.pipe_names
['tagger', 'parser', 'ner'] 

让我们用一个简单的句子来说明流水线:

sample_text = 'Apple is looking at buying U.K. startup for $1 billion'
doc = nlp(sample_text) 

解析、标记和注释一个句子

解析后的文档内容是可迭代的,每个元素都有由处理流程生成的许多属性。下一个示例演示了如何访问以下属性:

  • .text: 原始词文本

  • .lemma_: 词的词根

  • .pos_: 基本词性标记

  • .tag_: 详细的词性标记

  • .dep_: 标记词间的句法关系或依赖性

  • .shape_: 词的形状,以大写、标点和数字为准

  • .is alpha: 检查标记是否为字母数字

  • .is stop: 检查标记是否在给定语言的常用词列表中

我们迭代处理每个标记,并将其属性分配给一个pd.DataFrame

pd.DataFrame([[t.text, t.lemma_, t.pos_, t.tag_, t.dep_, t.shape_, 
               t.is_alpha, t.is_stop]
              for t in doc],
             columns=['text', 'lemma', 'pos', 'tag', 'dep', 'shape', 
                      'is_alpha', is_stop']) 

这产生了以下结果:

text lemma pos tag dep shape is_alpha is_stop
Apple apple PROPN NNP nsubj Xxxxx TRUE FALSE
is be VERB VBZ aux xx TRUE TRUE
looking look VERB VBG ROOT xxxx TRUE FALSE
at at ADP IN prep xx TRUE TRUE
buying buy VERB VBG pcomp xxxx TRUE FALSE
U.K. u.k. PROPN NNP compound X.X. FALSE FALSE
startup startup NOUN NN dobj xxxx TRUE FALSE
for for ADP IN prep xxx TRUE TRUE
$ | $ SYM $ | quantmod | $ FALSE FALSE
1 1 NUM CD compound d FALSE FALSE
billion billion NUM CD pobj xxxx TRUE FALSE

我们可以使用以下方法在浏览器或笔记本中可视化句法依赖:

displacy.render(doc, style='dep', options=options, jupyter=True) 

上述代码使我们能够获得如下的依赖树:

图 14.2:spaCy 依赖树

我们可以使用spacy.explain()获取属性含义的额外见解,例如:

spacy.explain("VBZ") 
verb, 3rd person singular present 

批处理文档

现在我们将读取一个更大的数据集,包含 2,225 篇 BBC 新闻文章(详见 GitHub 获取数据源细节),这些文章分属五个类别,并存储在单独的文本文件中。我们执行以下操作:

  1. 调用pathlib模块的Path对象的.glob()方法。

  2. 迭代处理结果列表中的路径。

  3. 读取新闻文章中除了第一行标题之外的所有行。

  4. 将清理后的结果附加到列表中:

    files = Path('..', 'data', 'bbc').glob('**/*.txt')
    bbc_articles = []
    for i, file in enumerate(sorted(list(files))):
        with file.open(encoding='latin1') as f:
            lines = f.readlines()
            body = ' '.join([l.strip() for l in lines[1:]]).strip()
            bbc_articles.append(body)
    len(bbc_articles)
    2225 
    

句子边界检测

调用 NLP 对象对文章的第一句进行句子检测:

doc = nlp(bbc_articles[0])
type(doc)
spacy.tokens.doc.Doc 

spaCy 根据句法分析树计算句子边界,因此标点符号和大写字母起着重要但不决定性的作用。因此,边界将与从句边界重合,即使是标点不良的文本也是如此。

我们可以使用 .sents 属性访问解析后的句子:

sentences = [s for s in doc.sents]
sentences[:3]
[Quarterly profits at US media giant TimeWarner jumped 76% to $1.13bn (£600m) for the three months to December, from $639m year-earlier.  ,
 The firm, which is now one of the biggest investors in Google, benefited from sales of high-speed internet connections and higher advert sales.,
 TimeWarner said fourth quarter sales rose 2% to $11.1bn from $10.9bn.] 

命名实体识别

spaCy 通过 .ent_type_ 属性启用了命名实体识别:

for t in sentences[0]:
    if t.ent_type_:
        print('{} | {} | {}'.format(t.text, t.ent_type_, spacy.explain(t.ent_type_)))
Quarterly | DATE | Absolute or relative dates or periods
US | GPE | Countries, cities, states
TimeWarner | ORG | Companies, agencies, institutions, etc. 

Textacy 让访问第一篇文章中出现的命名实体变得很容易:

entities = [e.text for e in entities(doc)]
pd.Series(entities).value_counts().head()
TimeWarner        7
AOL               5
fourth quarter    3
year-earlier      2
one               2 

N-grams

N-grams 结合了 n 个连续的标记。这对于词袋模型可能是有用的,因为根据文本上下文,将(例如)"数据科学家" 视为单个标记可能比将 "数据" 和 "科学家" 两个不同的标记更有意义。

Textacy 使查看至少出现 min_freq 次的给定长度 nngrams 变得很容易:

pd.Series([n.text for n in ngrams(doc, n=2, min_freq=2)]).value_counts()
fourth quarter     3
quarter profits    2
Time Warner        2
company said       2
AOL Europe         2 

spaCy 的流式 API

要通过处理管道传递更多的文档,我们可以使用 spaCy 的流式 API 如下所示:

iter_texts = (bbc_articles[i] for i in range(len(bbc_articles)))
for i, doc in enumerate(nlp.pipe(iter_texts, batch_size=50, n_threads=8)):
    assert doc.is_parsed 

多语言自然语言处理

spaCy 包含了针对英语、德语、西班牙语、葡萄牙语、法语、意大利语和荷兰语的经过训练的语言模型,以及一个用于命名实体识别的多语言模型。由于 API 不变,跨语言使用很简单。

我们将使用 TED 演讲字幕的平行语料库来说明西班牙语言模型(请参阅数据来源参考的 GitHub 存储库)。为此,我们实例化两个语言模型:

model = {}
for language in ['en', 'es']:
    model[language] = spacy.load(language) 

我们在每个模型中读取相应的小文本样本:

text = {}
path = Path('../data/TED')
for language in ['en', 'es']:
    file_name = path /  'TED2013_sample.{}'.format(language)
    text[language] = file_name.read_text() 

句子边界检测使用相同的逻辑,但找到了不同的分解:

parsed, sentences = {}, {}
for language in ['en', 'es']:
    parsed[language] = modellanguage
    sentences[language] = list(parsed[language].sents)
    print('Sentences:', language, len(sentences[language]))
Sentences: en 22
Sentences: es 22 

词性标注也是以相同的方式工作:

pos = {}
for language in ['en', 'es']:
    pos[language] = pd.DataFrame([[t.text, t.pos_, spacy.explain(t.pos_)] 
                                  for t in sentences[language][0]],
                                 columns=['Token', 'POS Tag', 'Meaning'])
pd.concat([pos['en'], pos['es']], axis=1).head() 

这产生了以下表格:

Token POS 标记 意思 Token POS 标记 意思
There ADV 副词 Existe VERB 动词
s VERB 动词 una DET 限定词
a DET 限定词 estrecha ADJ 形容词
tight ADJ 形容词 y CONJ 连词
and CCONJ 并列连词 sorprendente ADJ 形容词

下一节将说明如何使用解析和注释的标记构建可以用于文本分类的文档-术语矩阵。

使用 TextBlob 的自然语言处理

TextBlob 是一个提供简单 API 用于常见自然语言处理任务的 Python 库,它构建在 自然语言工具包 (NLTK) 和 Pattern 网络挖掘库的基础上。TextBlob 提供了词性标注、名词短语提取、情感分析、分类和翻译等功能。

要说明 TextBlob 的用法,我们采样了一篇标题为 "Robinson ready for difficult task" 的 BBC Sport 文章。与 spaCy 和其他库类似,第一步是通过由 TextBlob 对象表示的管道将文档传递,以分配所需任务的注释(请参阅笔记本 nlp_with_textblob):

from textblob import TextBlob
article = docs.sample(1).squeeze()
parsed_body = TextBlob(article.body) 

词干提取

要执行词干提取,我们从 NTLK 库实例化 SnowballStemmer,对每个标记调用其 .stem() 方法,并显示因此而被修改的标记:

from nltk.stem.snowball import SnowballStemmer
stemmer = SnowballStemmer('english')
[(word, stemmer.stem(word)) for i, word in enumerate(parsed_body.words) 
 if word.lower() != stemmer.stem(parsed_body.words[i])]
('Manchester', 'manchest'),
 ('United', 'unit'),
 ('reduced', 'reduc'),
 ('points', 'point'),
 ('scrappy', 'scrappi') 

情感极性和主观性

TextBlob 提供使用 Pattern 库提供的字典为解析的文档提供极性和主观度估计。这些字典将产品评论中频繁出现的形容词与情感极性分数进行词典映射,分数范围从 -1 到 +1(负面 ↔ 正面),以及类似的主观度分数(客观 ↔ 主观)。

.sentiment 属性为每个分数提供相关标记的平均值,而 .sentiment_assessments 属性则列出了每个标记的基础值(请参阅笔记本):

parsed_body.sentiment
Sentiment(polarity=0.088031914893617, subjectivity=0.46456433637284694) 

计算标记 - 文档-词汇矩阵

在本节中,我们首先介绍词袋模型如何将文本数据转换为数值向量空间表示。目标是通过它们在该空间中的距离来近似文档的相似性。然后,我们继续说明如何使用 sklearn 库创建文档-词汇矩阵。

词袋模型

词袋模型基于文档包含的术语或标记的频率来表示文档。每个文档变成一个向量,其中每个标记在词汇表中对应一个条目,反映了该标记对文档的相关性。

创建文档-词汇矩阵

给定词汇表,文档-词汇矩阵很容易计算。然而,它也是一种粗糙的简化,因为它抽象了单词顺序和语法关系。尽管如此,它通常能够快速在文本分类中取得良好的结果,因此为非常有用的起点。

图 14.3 的左侧面板说明了这种文档模型如何将文本数据转换为一个矩阵,其中每行对应一个文档,每列对应词汇表中的一个标记。由此产生的矩阵通常是非常高维和稀疏的,即它包含许多零条目,因为大多数文档只包含整体词汇的一小部分。

![

图 14.3:文档-词汇矩阵和余弦相似性

有几种方法可以对一个标记的向量条目进行加权,以捕捉其与文档的相关性。我们将演示如何使用 sklearn 使用二进制标志表示存在或不存在、计数以及考虑到语料库中所有文档中术语频率差异的加权计数。

测量文档的相似性

将文档表示为词向量将给每个文档分配一个位置,该位置位于由词汇表创建的向量空间中。解释该空间中的向量条目为笛卡尔坐标,我们可以使用两个向量之间的角度来测量它们的相似性,因为指向相同方向的向量包含具有相同频率权重的相同术语。

上述图的右侧面板在二维中简化地说明了一个由向量 d[1] 表示的文档与由向量 q 表示的查询向量(可以是一组搜索词或另一个文档)之间距离的计算。

余弦相似度 等于两个向量之间角度的余弦值。它将角度大小转换为范围为 [0, 1] 的数字,因为所有向量条目都是非负的标记权重。值为 1 意味着两个文档在其标记权重方面完全相同,而值为 0 意味着两个文档仅包含不同的标记。

如图所示,角度的余弦等于向量的点积,即它们坐标的和积除以它们各自向量的欧几里德范数的乘积。

使用 scikit-learn 创建文档 - 词项矩阵

scikit-learn 预处理模块提供了两个工具来创建文档 - 词项矩阵。CountVectorizer 使用二进制或绝对计数来测量每个文档 d 和标记 t词频 (TF) tf(d, t)。

TfidfVectorizer 相比之下,通过逆文档频率IDF)加权(绝对)词频。因此,在更多文档中出现的词语将比在给定文档中具有相同频率但在所有文档中频率较低的标记接收到较低的权重。更具体地说,使用默认设置,文档 - 词项矩阵的 tf-idf(d, t) 条目被计算为 tf-idf(d, t) = tf(d, t) x idf(t) ,其中:

其中 n[d] 是文档数,df(d, t) 是词项 t 的文档频率。每个文档的结果 TF-IDF 向量都针对它们的绝对或平方总数进行了归一化(有关详细信息,请参阅 sklearn 文档)。TF-IDF 度量最初用于信息检索以对搜索引擎结果进行排名,并随后被证明对于文本分类和聚类非常有用。

这两个工具使用相同的接口,并在向量化文本之前对文档列表进行分词和进一步可选的预处理,通过生成标记计数来填充文档 - 词项矩阵。

影响词汇表大小的关键参数包括以下内容:

  • stop_words: 使用内置或用户提供的(频繁)词语列表来排除词语

  • ngram_range: 包括 n-gram,在由元组定义的 n 范围内的 n

  • lowercase: 相应地转换字符(默认值为 True

  • min_df/max_df: 忽略出现在较少/较多(int)的文档中的词语,或者出现在较小/较大比例的文档中(如果是 float [0.0,1.0])

  • max_features: 限制词汇表中的标记数量

  • binary: 将非零计数设置为 1(True

查看笔记本 document_term_matrix 以获取以下代码示例和更多详细信息。我们再次使用 2,225 篇 BBC 新闻文章作为示例。

使用 CountVectorizer

笔记本中包含一个交互式可视化,探索 min_dfmax_df 设置对词汇量大小的影响。我们将文章读入 DataFrame,设置 CountVectorizer 以生成二进制标志并使用所有词项,并调用其 .fit_transform() 方法以生成文档-词项矩阵:

binary_vectorizer = CountVectorizer(max_df=1.0,
                                    min_df=1,
                                    binary=True)
binary_dtm = binary_vectorizer.fit_transform(docs.body)
<2225x29275 sparse matrix of type '<class 'numpy.int64'>'
   with 445870 stored elements in Compressed Sparse Row format> 

输出是一个以行格式存储的 scipy.sparse 矩阵,有效地存储了 2,225(文档)行和 29,275(词项)列中的 445,870 个非零条目中的一小部分(<0.7%)。

可视化词汇分布

图 14.4中的可视化显示,要求词项出现在至少 1% 且少于 50% 的文档中,将词汇限制在近 30000 个词项中的约 10% 左右。

这样留下了每个文档略多于 100 个唯一词项的模式,如下图左侧面板所示。右侧面板显示了剩余词项的文档频率直方图:

图 14.4:唯一词项和每个文档的词项数量的分布

查找最相似的文档

CountVectorizer 的结果使我们能够使用由 scipy.spatial.distance 模块提供的 pairwise 距离的 pdist() 函数找到最相似的文档。它返回一个压缩的距离矩阵,其条目对应于正方形矩阵的上三角形。我们使用 np.triu_indices() 将最小距离的索引转换为相应于最接近的词项向量的行和列索引:

m = binary_dtm.todense()        # pdist does not accept sparse format
pairwise_distances = pdist(m, metric='cosine')
closest = np.argmin(pairwise_distances)  # index that minimizes distance
rows, cols = np.triu_indices(n_docs)      # get row-col indices
rows[closest], cols[closest]
(6, 245) 

文章 6 和 245 在余弦相似性上最接近,因为它们共享 303 个词汇中的 38 个词项(见笔记本)。以下表格总结了这两篇文章,并展示了基于词数的相似度测量对于识别更深层语义相似性的有限能力:

文章 6 文章 245
主题 商业 商业
标题 美国就业增长仍然缓慢 Ebbers 对 WorldCom 的欺诈案件有所了解
正文 美国在一月份创造的工作岗位少于预期,但求职者的减少使失业率降至三年来的最低水平。根据劳工部的数据,美国企业在一月份仅增加了 146,000 个工作岗位。 前世界通信公司总裁 Bernie Ebbers 在该公司的 110 亿美元金融欺诈案中直接参与其中,他最亲密的同事在美国法院告诉说。在 Ebbers 先生的刑事审判中作证,前财务主管 Scott Sullivan 暗示了他的同事在该公司的会计丑闻中的牵连。

CountVectorizerTfidfVectorizer 都可以与 spaCy 一起使用,例如,执行词形还原并在词项化过程中排除某些字符:

nlp = spacy.load('en')
def tokenizer(doc):
    return [w.lemma_ for w in nlp(doc) 
                if not w.is_punct | w.is_space]
vectorizer = CountVectorizer(tokenizer=tokenizer, binary=True)
doc_term_matrix = vectorizer.fit_transform(docs.body) 

参见笔记本以获取更多细节和更多示例。

TfidfTransformer 和 TfidfVectorizer

TfidfTransformer 从文档-词项矩阵中计算 TF-IDF 权重,类似于 CountVectorizer 生成的矩阵。

TfidfVectorizer 在一个步骤中执行这两个计算。它添加了一些参数到 CountVectorizer API,用于控制平滑行为。

对于一个小的文本样本,TFIDF 计算如下:

sample_docs = ['call you tomorrow',
               'Call me a taxi',
               'please call me... PLEASE!'] 

我们像以前一样计算术语频率:

vectorizer = CountVectorizer()
tf_dtm = vectorizer.fit_transform(sample_docs).todense()
tokens = vectorizer.get_feature_names()
term_frequency = pd.DataFrame(data=tf_dtm,
                             columns=tokens)
  call  me  please  taxi  tomorrow  you
0     1   0       0     0         1    1
1     1   1       0     1         0    0
2     1   1       2     0         0    0 

文档频率是包含该标记的文档数:

vectorizer = CountVectorizer(binary=True)
df_dtm = vectorizer.fit_transform(sample_docs).todense().sum(axis=0)
document_frequency = pd.DataFrame(data=df_dtm,
                                  columns=tokens)
   call  me  please  taxi  tomorrow  you
0     3   2       1     1         1    1 

TF-IDF 权重是这些值的比率:

tfidf = pd.DataFrame(data=tf_dtm/df_dtm, columns=tokens)
   call   me  please  taxi  tomorrow  you
0  0.33 0.00    0.00  0.00      1.00 1.00
1  0.33 0.50    0.00  1.00      0.00 0.00
2  0.33 0.50    2.00  0.00      0.00 0.00 

平滑的效果

为了避免零除法,TfidfVectorizer 使用平滑处理文档和术语频率:

  • smooth_idf: 将文档频率加一,就像额外的文档包含词汇表中的每个标记一样,以防止零除法

  • sublinear_tf: 应用亚线性 tf 缩放,即用 1 + log(tf) 替换 tf

与规范化权重相结合,结果略有不同:

vect = TfidfVectorizer(smooth_idf=True,
                      norm='l2',  # squared weights sum to 1 by document
                      sublinear_tf=False,  # if True, use 1+log(tf)
                      binary=False)
pd.DataFrame(vect.fit_transform(sample_docs).todense(),
            columns=vect.get_feature_names())
   call   me  please  taxi  tomorrow  you
0  0.39 0.00    0.00  0.00      0.65 0.65
1  0.43 0.55    0.00  0.72      0.00 0.00
2  0.27 0.34    0.90  0.00      0.00 0.00 

使用 TfidfVectorizer 摘要新闻文章

由于它们能够分配有意义的标记权重,TF-IDF 向量也用于总结文本数据。例如,Reddit 的 autotldr 函数基于类似的算法。请参阅笔记本以查看使用 BBC 文章的示例。

关键教训而不是已学到的教训

处理自然语言以在 ML 模型中使用的技术和选项数量庞大,这对应于这个高度非结构化数据源的复杂性质。构建良好的语言特征既具有挑战性又具有回报,可以说是揭示文本数据中隐藏的语义价值的最重要步骤。

在实践中,经验有助于选择消除噪声而不是信号的变换,但很可能仍然需要交叉验证和比较不同预处理选择组合的性能。

交易的自然语言处理

一旦文本数据使用前面讨论的自然语言处理技术转换为数值特征,文本分类就像任何其他分类任务一样。

在本节中,我们将这些预处理技术应用于新闻文章、产品评论和 Twitter 数据,并教授各种分类器以预测离散的新闻类别、评论分数和情感极性。

首先,我们将介绍朴素贝叶斯模型,这是一种与词袋模型产生的文本特征很好配合的概率分类算法。

本节的代码示例位于笔记本 news_text_classification 中。

朴素贝叶斯分类器

朴素贝叶斯算法在文本分类中非常流行,因为它的低计算成本和内存需求有助于在非常大的高维数据集上进行训练。其预测性能可以与更复杂的模型竞争,提供一个良好的基线,并以成功检测垃圾邮件而闻名。

该模型依赖于贝叶斯定理和各种特征相互独立的假设。换句话说,对于给定的结果,知道一个特征的值(例如,在文档中存在一个标记)不会提供有关另一个特征值的任何信息。

贝叶斯定理复习

贝叶斯定理表达了一个事件(例如,电子邮件是垃圾邮件而不是良性“ham”)在另一个事件(例如,电子邮件包含某些词)给定的条件概率如下:

实际上,电子邮件实际上是垃圾邮件的后验概率,而它包含某些词的事实取决于三个因素的相互作用:

  • 电子邮件实际上是垃圾邮件的先验概率

  • 在垃圾邮件中遇到这些词的似然

  • 证据,即在电子邮件中看到这些词的概率

要计算后验,我们可以忽略证据,因为对所有结果(垃圾邮件与 ham)来说它是相同的,而无条件先验可能很容易计算。

但是,这种可能性给合理大小的词汇表和实际邮件语料库带来了不可逾越的挑战。原因在于在不同文档中联合出现或未出现的单词的组合爆炸,这阻止了计算概率表和为可能性赋值所需的评估。

条件独立假设

使模型既易于处理又赢得“朴素”名称的关键假设是特征在给定结果的条件下是独立的。为了说明,让我们对包含三个词“发送资金现在”的电子邮件进行分类,这样贝叶斯定理就变成了以下形式:

形式上,假设这三个单词在条件上是独立的意味着观察到“发送”不受其他术语的影响,前提是邮件是垃圾邮件,即,P(send | money, now, spam) = P(send | spam)。因此,我们可以简化似然函数:

使用“朴素”的条件独立假设,分子中的每个术语都很容易从训练数据的相对频率中计算出来。当需要比较而不是校准后验概率时,分母在类别之间是恒定的,可以忽略。随着因素数量,即特征数量的增加,先验概率变得不太相关。

总之,朴素贝叶斯模型的优点在于训练和预测速度快,因为参数的数量与特征的数量成比例,并且它们的估计具有封闭形式的解决方案(基于训练数据频率),而不是昂贵的迭代优化。它也是直观的并且有些可解释的,不需要超参数调整,并且在存在足够信号的情况下相对不太依赖于不相关的特征。

但是,当独立性假设不成立,并且文本分类依赖于特征的组合或特征相关时,模型的性能将较差。

对新闻文章进行分类

我们从用于新闻文章分类的朴素贝叶斯模型的示例开始,使用之前阅读的 BBC 文章,以获得包含来自五个类别的 2,225 篇文章的DataFrame

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2225 entries, 0 to 2224
Data columns (total 3 columns):
topic      2225 non-null object
heading    2225 non-null object
body       2225 non-null object 

为了训练和评估多项式朴素贝叶斯分类器,我们将数据分成默认的 75:25 训练测试集比例,确保测试集类别与训练集类别紧密匹配:

y = pd.factorize(docs.topic)[0] # create integer class values
X = docs.body
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=1,
                                                    stratify=y) 

我们继续从训练集中学习词汇,并使用默认设置的CountVectorizer转换两个数据集,以获得近 26,000 个特征:

vectorizer = CountVectorizer()
X_train_dtm = vectorizer.fit_transform(X_train)
X_test_dtm = vectorizer.transform(X_test)
X_train_dtm.shape, X_test_dtm.shape
((1668, 25919), (557, 25919)) 

训练和预测遵循标准的 sklearn fit/predict 接口:

nb = MultinomialNB()
nb.fit(X_train_dtm, y_train)
y_pred_class = nb.predict(X_test_dtm) 

我们使用accuracy评估多类预测,以找到默认分类器几乎达到了 98%的准确率:

accuracy_score(y_test, y_pred_class)
0.97666068222621 

用 Twitter 和 Yelp 数据进行情感分析

情感分析是 NLP 和 ML 在交易中最流行的用途之一,因为对资产或其他价格驱动因素的积极或消极看法可能会影响收益。

通常,情感分析的建模方法依赖于词典(如 TextBlob 库)或针对特定领域结果进行训练的模型。后者通常更可取,因为它允许更有针对性的标记,例如,通过将文本特征与后续价格变化而不是间接情感分数相关联。

我们将使用具有二进制极性标签的 Twitter 数据集和具有五点结果量表的大型 Yelp 商业评论数据集来说明情感分析的 ML。

用 Twitter 数据进行二进制情感分类

我们使用一个包含来自 2009 年的 1.6 百万训练推文和 350 个测试推文的数据集,该数据集具有算法分配的二进制积极和消极情感分数,这些分数分布相对均匀(有关更详细的数据探索,请参阅笔记本)。

多项式朴素贝叶斯

我们创建一个具有 934 个标记的文档-术语矩阵如下:

vectorizer = CountVectorizer(min_df=.001, max_df=.8, stop_words='english')
train_dtm = vectorizer.fit_transform(train.text)
<1566668x934 sparse matrix of type '<class 'numpy.int64'>'
    with 6332930 stored elements in Compressed Sparse Row format> 

然后,我们像以前一样训练MultinomialNB分类器并预测测试集:

nb = MultinomialNB()
nb.fit(train_dtm, train.polarity)
predicted_polarity = nb.predict(test_dtm) 

结果的准确率超过了 77.5%:

accuracy_score(test.polarity, predicted_polarity)
0.7768361581920904 

与 TextBlob 情感分数的比较

我们还为推文获取 TextBlob 情感分数,并注意(参见图 14.5中的左面板),积极的测试推文收到了显着较高的情感估计。然后,我们使用MultinomialNB模型的.predict_proba()方法计算预测概率,并使用我们在第六章“机器学习过程”中介绍的相应曲线下面积AUC来比较两个模型(参见图 14.5中的右面板)。

图 14.5:定制与通用情感分数的准确性

在这种情况下,定制的朴素贝叶斯模型优于 TextBlob,测试 AUC 为 0.848,而 TextBlob 为 0.825。

Yelp 商业评论的多类情感分析

最后,我们将情感分析应用于规模大得多的 Yelp 企业评论数据集,其中有五个结果类别(有关代码和其他详细信息,请参见笔记本 sentiment_analysis_yelp)。

数据包含了有关企业、用户、评论以及 Yelp 提供的其他方面的信息,以鼓励数据科学创新。

我们将使用在 2010-2018 年期间产生的约六百万条评论(详见笔记本)。以下图表显示了每年的评论数量和平均星级,以及所有评论中星级的分布。

图 14.6:Yelp 评论的基本探索性分析

我们将在截至 2017 年的数据的 10%样本上训练各种模型,并使用 2018 年的评论作为测试集。除了评论文本生成的文本特征外,我们还将使用有关给定用户的评论提交的其他信息。

结合文本和数值特征

数据集包含各种数值特征(有关实现细节,请参见笔记本)。

向量化器产生 scipy.sparse 矩阵。要将向量化的文本数据与其他特征结合起来,我们首先需要将其转换为稀疏矩阵;许多 sklearn 对象和其他库(如 LightGBM)可以处理这些非常节省内存的数据结构。将稀疏矩阵转换为稠密的 NumPy 数组会有内存溢出的风险。

大多数变量都是分类变量,因此由于我们有一个相当大的数据集来容纳特征的增加,我们使用一位有效编码。

我们将编码的数值特征转换并与文档-词矩阵相结合:

train_numeric = sparse.csr_matrix(train_dummies.astype(np.uint))
train_dtm_numeric = sparse.hstack((train_dtm, train_numeric)) 

基准准确率

使用最频繁的星级数(=5)来预测测试集,准确率接近 52%:

test['predicted'] = train.stars.mode().iloc[0]
accuracy_score(test.stars, test.predicted)
0.5196950594793454 

多项式朴素贝叶斯模型

接下来,我们使用由 CountVectorizer 生成的文档-词矩阵来训练一个朴素贝叶斯分类器,其采用默认设置。

nb = MultinomialNB()
nb.fit(train_dtm,train.stars)
predicted_stars = nb.predict(test_dtm) 

预测在测试集上的准确率达到了 64.7%,比基准提高了 24.4%:

accuracy_score(test.stars, predicted_stars)
0.6465164206691094 

使用文本和其他特征的组合进行训练将测试准确率提高到 0.671。

逻辑回归

第七章线性模型-从风险因素到回报预测中,我们介绍了二元逻辑回归。 sklearn 还实现了一个多类别模型,具有多项式和一对所有训练选项,后者训练一个针对每个类别的二元模型,同时将所有其他类别视为负类。多项式选项比一对所有实现要快得多且更准确。

我们评估正则化参数 C 的一系列值,以确定表现最佳的模型,使用 lbfgs 求解器如下(有关详细信息,请参见 sklearn 文档):

def evaluate_model(model, X_train, X_test, name, store=False):
    start = time()
    model.fit(X_train, train.stars)
    runtime[name] = time() – start
    predictions[name] = model.predict(X_test)
    accuracy[result] = accuracy_score(test.stars, predictions[result])
    if store:
        joblib.dump(model, f'results/{result}.joblib')
Cs = np.logspace(-5, 5, 11)
for C in Cs:
    model = LogisticRegression(C=C, multi_class='multinomial', solver='lbfgs')
    evaluate_model(model, train_dtm, test_dtm, result, store=True) 

图 14.7 显示了验证结果的图表。

使用 LightGBM 的多类别梯度提升

为了进行比较,我们还训练了一个具有默认设置和multiclass目标的 LightGBM 梯度提升树集成:

param = {'objective':'multiclass', 'num_class': 5}
booster = lgb.train(params=param, 
                    train_set=lgb_train, 
                    num_boost_round=500, 
                    early_stopping_rounds=20,
                    valid_sets=[lgb_train, lgb_test]) 

预测性能

图 14.7显示了每个模型对于组合数据的准确性。右侧面板绘制了逻辑回归模型在两个数据集和不同正则化水平下的验证性能。

多项式逻辑回归的测试准确性略高于 74%。朴素贝叶斯的表现明显较差。默认的 LightGBM 设置并未提高线性模型的准确性为 0.736. 但是,我们可以调整梯度提升模型的超参数,并且很可能会看到性能提升,使其至少与逻辑回归持平。无论哪种方式,该结果都提醒我们不要低估简单的、正则化的模型,因为它们不仅可能会产生良好的结果,而且可能会迅速做到这一点。

图 14.7:组合数据的测试性能(所有模型,左)以及逻辑回归的不同正则化下的性能

摘要

在本章中,我们探讨了许多处理非结构化数据的技术和选项,目的是提取用于机器学习模型的语义上有意义的数值特征。

我们涵盖了基本的分词和注释流水线,并使用 spaCy 和 TextBlob 说明了它在多种语言中的实现。我们建立在这些结果上,构建了一个基于词袋模型的文档模型,将文档表示为数值向量。我们学习了如何优化预处理流水线,然后使用向量化的文本数据进行分类和情感分析。

我们还有两章关于替代文本数据。在下一章中,我们将学习如何使用无监督学习总结文本以识别潜在主题。然后,在第十六章中,收益电话和 SEC 备案的词嵌入,我们将学习如何将单词表示为反映单词用法上下文的向量,这是一种非常成功的技术,为各种分类任务提供了更丰富的文本特征。

第十五章:主题建模 – 总结财经新闻

在上一章中,我们使用了词袋BOW)模型将非结构化文本数据转换为数字格式。该模型抽象了词序,并将文档表示为词向量,其中每个条目表示令牌对文档的相关性。由此产生的文档-术语矩阵DTM)—或作为术语-文档矩阵的转置—用于比较文档之间或基于其令牌内容的查询向量的相似性,因此,找到干草堆中的大头针。它提供了有用的功能来对文档进行分类,例如在我们的情感分析示例中。

然而,这种文档模型产生了高维度数据和非常稀疏的数据,但它很少总结内容或接近理解内容是什么。在本章中,我们将使用无监督机器学习从文档中提取隐藏的主题,使用主题建模。这些主题可以以自动化方式为大量文档提供详细的见解。它们非常有用,可以理解干草堆本身,并允许我们基于文档与各种主题的关联度对文档进行标记。

主题模型生成复杂且可解释的文本特征,可成为从大量文档中提取交易信号的第一步。它们加快了文档的审阅,帮助识别和聚类类似的文档,并支持预测建模。

应用程序包括无监督地发现公司披露或收入电话抄本、客户评论或合同中潜在有见地的主题。此外,文档-主题关联有助于通过分配例如情感度量或更直接的后续相关资产收益来进行标记。

更具体地说,阅读完本章后,您将了解:

  • 主题建模的演变,它的成就以及为什么它很重要

  • 使用潜在语义索引LSI)降低 DTM 的维度

  • 使用概率隐含语义分析pLSA)提取主题

  • 潜在狄利克雷分配LDA)如何改进 pLSA 成为最流行的主题模型

  • 可视化和评估主题建模结果

  • 使用 sklearn 和 Gensim 运行 LDA

  • 如何将主题建模应用于收入电话和财经新闻文章的集合

您可以在 GitHub 存储库的相应目录中找到此章节的代码示例和其他资源链接。笔记本包括图像的彩色版本。

学习潜在主题 – 目标和方法

主题建模发现了捕捉文档集合中超越个别单词的语义信息的隐藏主题。它旨在解决一个关键挑战,即机器学习算法从文本数据中学习时,超越了“实际写了什么”这个词汇层次,达到了“意图是什么”的语义层次。生成的主题可用于根据其与各种主题的关联情况对文档进行注释。

在实际应用中,主题模型自动总结大量的文档,以便于组织和管理,同时也便于搜索和推荐。同时,它使人类能够理解文档到达了一定程度,以至于人类可以解释主题的描述。

主题模型还缓解了经常困扰 BOW 模型的维度灾难;用高维稀疏向量表示文档可能使相似度度量嘈杂,导致距离测量不准确,并导致文本分类模型的过拟合。

此外,BOW 模型失去了上下文以及语义信息,因为它忽略了单词顺序。它还无法捕捉同义词(多个单词具有相同的含义)或多义性(一个单词具有多个含义)。由于后者,当文档没有按搜索或比较时使用的术语进行索引时,文档检索或相似性搜索可能会失去意义。

BOW 模型的这些缺点引发了一个问题:我们如何从数据中学习到有意义的主题,从而促进与文献数据的更有成效的交互?

主题模型初次尝试改进向量空间模型(于 1970 年代中期开发)时,应用线性代数来降低 DTM 的维度。这种方法类似于我们在第十三章使用无监督学习进行数据驱动风险因子和资产配置中讨论的主成分分析算法。虽然有效,但没有基准模型很难评估这些模型的结果。作为回应,出现了概率模型,假设存在明确的文档生成过程,并提供算法来反向工程该过程并恢复潜在主题。

以下表格突出了模型演变的关键里程碑,我们将在接下来的章节中更详细地讨论它们:

模型 年份 描述
潜在语义索引(LSI) 1988 通过减少词空间的维度捕捉语义文档-术语关系
概率潜在语义分析(pLSA) 1999 反向工程一个生成过程,假设单词生成一个主题,文档是主题的混合
潜在狄利克雷分配(LDA) 2003 为文档添加了一个生成过程:三级分层贝叶斯模型

潜在语义索引

潜在语义索引LSI)—也称为潜在语义分析LSA)—旨在改进省略了包含查询词同义词的相关文档的查询结果(Dumais 等人,1988 年)。其目标是建模文档与术语之间的关系,以便可以预测术语应与文档关联,即使由于单词使用的变异性,没有观察到这种关联。

LSI 使用线性代数来找到给定数量k的潜在主题,通过分解 DTM。更具体地说,它使用奇异值分解SVD)来找到使用k个奇异值和向量的最佳低秩 DTM 近似。换句话说,LSI 建立在我们在第十三章使用无监督学习的数据驱动风险因子和资产配置中遇到的一些降维技术上。作者还尝试过分层聚类,但发现这对于此目的来说太受限制了。

在这种情况下,SVD 识别一组未相关的索引变量或因子,通过其因子值的向量表示每个术语和文档。 图 15.1说明了 SVD 如何将 DTM 分解为三个矩阵:包含正交奇异向量的两个矩阵和具有奇异值的对角矩阵,该奇异值用作缩放因子。

假设输入 DTM 中存在一些相关性,则奇异值会衰减。因此,选择T个最大奇异值会产生原始 DTM 的低维近似,且丢失的信息相对较少。在压缩版本中,原本有N个条目的行或列只有T < N个条目。

DTM 的 LSI 分解可以解释如图 15.1所示:

  • 第一个矩阵表示文档与主题之间的关系。

  • 对角矩阵通过其语料库强度对主题进行缩放。

  • 第三个矩阵建模了术语-主题关系。

图 15.1:LSI 和 SVD

将第一个两个矩阵相乘产生的矩阵的行对应于原始文档投影到潜在主题空间中的位置。

如何使用 sklearn 实现 LSI

我们将使用上一章介绍的 BBC 文章数据来说明 LSI,因为它们足够小,可以快速训练,并且允许我们将主题分配与类别标签进行比较。有关其他实施细节,请参阅笔记本latent_semantic_indexing

我们首先加载文档,并创建一个包含 50 篇文章的训练和(分层)测试集。然后,我们使用TfidfVectorizer对数据进行向量化,以获得加权 DTM 计数,并过滤出出现在不到 1%或超过 25%的文档中的词语,以及常见的停用词,以获得大约 2,900 个词汇:

vectorizer = TfidfVectorizer(max_df=.25, min_df=.01, 
                             stop_words='english', 
                             binary=False)
train_dtm = vectorizer.fit_transform(train_docs.article)
test_dtm = vectorizer.transform(test_docs.article) 

我们使用 scikit-learn 的TruncatedSVD类,它只计算k个最大的奇异值,以减少 DTM 的维度。确定性的arpack算法提供了一个精确的解,但默认的“随机化”实现对于大矩阵更有效。

我们计算了五个主题以匹配五个类别,这解释了总 DTM 方差的仅 5.4%,因此使用更多主题是合理的:

svd = TruncatedSVD(n_components=5, n_iter=5, random_state=42)
svd.fit(train_dtm)
svd.explained_variance_ratio_.sum()
0.05382357286057269 

LSI 为 DTM 确定了一个新的正交基,将排名降低到所需主题的数量。训练过的svd对象的.transform()方法将文档投影到新的主题空间中。这个空间由减少文档向量维度而产生,并且对应于本节前面所示的转换:

train_doc_topics = svd.transform(train_dtm)
train_doc_topics.shape
(2175, 5) 

我们可以对文章进行采样,以查看其在主题空间中的位置。我们选择了一个与主题 1 和 2 最(积极)相关的“政治”文章:

i = randint(0, len(train_docs))
train_docs.iloc[i, :2].append(pd.Series(doc_topics[i], index=topic_labels))
Category                                     Politics
Heading     What the election should really be about?
Topic 1                                          0.33
Topic 2                                          0.18
Topic 3                                          0.12
Topic 4                                          0.02
Topic 5                                          0.06 

此示例的主题分配与每个类别的平均主题权重一致,如图 15.2所示(“政治”是最右边的条)。它们说明了 LSI 如何将k个主题表达为k维空间中的方向(笔记本包括每个类别的平均主题分配在二维空间中的投影)。

每个类别都有明确的定义,测试分配与训练分配匹配。但是,权重既有正值又有负值,这使得解释主题更加困难。

一个视频游戏的屏幕截图 自动生成的描述

图 15.2:训练和测试数据的 LSI 主题权重

我们还可以显示与每个主题最相关的单词(绝对值)。主题似乎捕捉到了一些语义信息,但并没有明显区分(参见图 15.3)。

图 15.3:LSI 主题的前 10 个单词

优缺点

LSI 的优点包括消除噪音和缓解维度诅咒。它还捕获了一些语义方面,如同义词,并通过它们的主题关联来聚类文档和术语。此外,它不需要对文档语言有所了解,并且信息检索查询和文档比较都很容易。

然而,LSI 的结果很难解释,因为主题是具有正值和负值的词向量。此外,没有底层模型可以允许拟合的评估,也没有在选择要使用的维度或主题数量时提供指导。

概率隐含语义分析

概率隐含语义分析pLSA)以统计视角看待 LSI/LSA,并创建一个生成模型来解决 LSA 缺乏理论基础的问题(Hofmann 2001)。

pLSA 明确地将词 w 出现在文档 d 中的概率建模为条件独立的多项式分布的混合,其中涉及主题 t

对于词-文档共现的形成,有对称和不对称的两种表述。前者假设单词和文档都是由潜在主题类生成的。相反,不对称模型假设在给定文档的情况下选择主题,并且在给定主题的情况下产生单词。

主题数量是在训练之前选择的超参数,并不是从数据中学习得到的。

图 15.4 中的板块表示法描述了概率模型中的统计依赖关系。更具体地说,它对称编码了刚才描述的不对称模型的关系。每个矩形代表多个项目:外部块代表 M 个文档,而内部阴影矩形象征着每个文档的 N 个单词。我们只观察文档及其内容;模型推断出隐藏或潜在的主题分布:

图 15.4:pLSA 模型的统计依赖关系的板块表示法

现在让我们看看如何在实践中实现这个模型。

如何使用 sklearn 实现 pLSA

pLSA 相当于使用 Kullback-Leibler 散度目标的非负矩阵分解NMF)。因此,我们可以使用 sklearn.decomposition.NMF 类来实现这个模型,按照 LSI 示例。

使用由 TfidfVectorizer 产生的 DTM 的相同训练-测试拆分,我们这样适配 pLSA:

nmf = NMF(n_components=n_components, 
          random_state=42, 
          solver='mu',
          beta_loss='kullback-leibler', 
          max_iter=1000)
nmf.fit(train_dtm) 

我们得到了一个重建误差的度量,它是对之前解释的方差度量的替代:

nmf.reconstruction_err_
316.2609400385988 

由于其概率性质,pLSA 仅产生正主题权重,这导致了更直接的主题-类别关系,如 图 15.5 所示,适用于测试和训练集:

图 15.5:pLSA 对训练和测试数据的主题权重

我们还注意到,描述每个主题的词列表开始变得更有意义;例如,“娱乐”类别与主题 4 最直接关联,其中包括“电影”,“明星”等词,正如您在图 15.6 中所看到的:

图 15.6:pLSA 的每个主题的前几个词

优点和局限性

使用概率模型的好处是,我们现在可以通过评估它们在训练期间学习的参数给出的新文档的概率来比较不同模型的性能。这也意味着结果具有清晰的概率解释。此外,pLSA 捕捉到了更多的语义信息,包括一词多义。

另一方面,与 LSI 相比,pLSA 增加了计算复杂性,并且该算法可能仅产生局部而不是全局最大值。最后,它不会为新文档产生生成模型,因为它将它们视为给定的。

潜在狄利克雷分配

潜在狄利克雷分配LDA)通过为主题添加一个生成过程(Blei、Ng 和 Jordan,2003)扩展了 pLSA。它是最流行的主题模型,因为它倾向于生成人类可以关联的有意义的主题,可以将主题分配给新文档,并且是可扩展的。LDA 模型的变体可以包括元数据,如作者或图像数据,或者学习分层主题。

LDA 的工作原理

LDA 是一个假设主题是单词概率分布、文档是主题分布的分层贝叶斯模型。更具体地说,该模型假设主题遵循稀疏狄利克雷分布,这意味着文档仅反映了一小部分主题,而主题仅频繁使用了有限数量的术语。

狄利克雷分布

狄利克雷分布产生可以用作离散概率分布的概率向量。也就是说,它随机生成一定数量的值,这些值为正并总和为一。它有一个正实值参数 ,它控制概率的集中度。值越接近零,意味着只有少数值将为正,并且接收大部分概率质量。图 15.7 说明了 = 0.1 时大小为 10 的三次绘制:

图 15.7:来自狄利克雷分布的三次绘制

笔记本 dirichlet_distribution 包含一个模拟,让您可以尝试不同的参数值。

生成模型

当作者将文章添加到文档集时,LDA 主题模型假定以下生成过程:

  1. 用由狄利克雷概率定义的比例随机混合一小部分主题。

  2. 对文本中的每个单词,根据文档-主题概率选择其中一个主题。

  3. 根据主题的单词列表中的主题-单词概率选择一个词。

因此,文章内容取决于每个主题的权重以及构成每个主题的术语。狄利克雷分布控制文档的主题和主题的词的选择。它编码了一个文档仅涵盖少数主题的想法,而每个主题仅使用少量频繁的单词。

图 15.8 中 LDA 模型的板符号总结了这些关系,并突出显示了关键的模型参数:

图 15.8:LDA 模型的统计依赖关系,以板块符号表示

反向工程过程

生成过程显然是虚构的,但事实证明是有用的,因为它允许恢复各种分布。LDA 算法逆向工程了想象作者的工作,并得出了对文档-主题-词关系进行简洁描述的总结:

  • 每个主题对文档的百分比贡献

  • 每个单词与主题的概率关联

LDA 解决了从文档体和它们包含的单词中恢复分布的贝叶斯推理问题,通过逆向工程所假定的内容生成过程。 Blei 等人(2003 年)的原始论文使用变分贝叶斯VB)来近似后验分布。替代方案包括吉布斯采样和期望传播。我们将简要介绍 sklearn 和 Gensim 库的实现。

如何评估 LDA 主题

无监督主题模型不能保证结果是有意义的或可解释的,并且没有客观的度量来评估结果的质量,就像在监督学习中一样。人类主题评估被认为是黄金标准,但可能昂贵,并且不易大规模获得。

更客观地评估结果的两个选项包括困惑度,它在未见文档上评估模型,以及主题连贯性度量,旨在评估所发现模式的语义质量。

困惑度

困惑度,当应用于 LDA 时,衡量模型恢复的主题-词概率分布对未见文本文档样本的预测能力。它基于这个分布p的熵H(p),并针对标记集w计算:

接近零的度量意味着分布在预测样本方面更好。

主题连贯性

主题连贯性衡量主题模型结果的语义一致性,即人类是否会将与主题相关的单词及其概率视为有意义。

为此,它通过测量与主题最相关的单词之间的语义相似度来对每个主题进行评分。更具体地说,连贯性度量基于观察到的定义一个主题的单词集合W的概率。

有两个连贯性度量被设计用于 LDA,并且已经显示与主题质量的人类判断相一致,即 UMass 和 UCI 度量。

UCI 度量(Stevens 等,2012 年)将词对的分数定义为两个不同的(顶部)主题词w[i],w[j]之间的点间互信息PMI)的和w以及平滑因子的乘积:

概率是根据滑动窗口在外部语料库(如维基百科)上的词共现频率计算的,因此可以将这个度量视为与语义基准的外部比较。

与此相反,UMass 指标(Mimno 等人,2011 年)使用训练语料库中来自多个文档D的共现性来计算一致性得分:

与将模型结果与外部真实值进行比较不同,此度量反映了内在一致性。两种度量方法都经过评估,与人类判断很好地一致(Röder、Both 和 Hinneburg,2015 年)。在这两种情况下,接近零的值意味着主题更一致。

如何使用 sklearn 实现 LDA

我们将像以前一样使用 BBC 数据,并使用 sklearn 的decomposition.LatentDirichletAllocation类训练一个具有五个主题的 LDA 模型(有关参数的详细信息,请参阅 sklearn 文档和笔记本lda_with_sklearn中的实现细节):

lda_opt = LatentDirichletAllocation(n_components=5, 
                                    n_jobs=-1, 
                                    max_iter=500,
                                    learning_method='batch', 
                                    evaluate_every=5,
                                    verbose=1, 
                                    random_state=42)
ldat.fit(train_dtm)
LatentDirichletAllocation(batch_size=128, doc_topic_prior=None,
             evaluate_every=5, learning_decay=0.7, learning_method='batch',
             learning_offset=10.0, max_doc_update_iter=100, max_iter=500,
             mean_change_tol=0.001, n_components=5, n_jobs=-1,
             n_topics=None, perp_tol=0.1, random_state=42,
             topic_word_prior=None, total_samples=1000000.0, verbose=1) 

该模型在训练期间跟踪样本内困惑度,并在此度量停止改善时停止迭代。我们可以像往常一样使用 sklearn 对象进行持久化和加载结果:

joblib.dump(lda, model_path / 'lda_opt.pkl')
lda_opt = joblib.load(model_path / 'lda_opt.pkl') 

如何使用 pyLDAvis 可视化 LDA 结果

话题可视化有助于使用人类判断评估话题质量。 pyLDAvis 是 LDAvis 的 Python 版本,由 R 和D3.js(Sievert 和 Shirley,2014 年)开发。我们将介绍关键概念;每个 LDA 应用笔记本都包含示例。

pyLDAvis 显示了主题之间的全局关系,同时通过检查与每个单独主题最密切关联的术语以及与每个术语相关联的主题,促进了其语义评估。它还解决了语料库中频繁出现的术语往往支配了定义主题的单词分布的挑战。

为此,LDAVis 引入了术语w对主题t相关性 r。相关性通过计算两个指标的加权平均值产生了对话题的术语的灵活排名:

  • 话题t与术语w的关联程度,表示为条件概率pw | t

  • 显著性或提升,它衡量了术语w对主题 t 的频率pw | t)与其在所有文档中的总体频率pw)的比较

更具体地说,我们可以计算术语w和主题t的相关性r,给定用户定义的权重,如下所示:

该工具允许用户交互地更改以调整相关性,这会更新术语的排名。用户研究发现产生最合理的结果。

如何使用 Gensim 实现 LDA

Gensim 是一个专门的自然语言处理NLP)库,具有快速的 LDA 实现和许多附加功能。我们还将在下一章关于词向量的笔记本lda_with_gensim中使用它(有关详细信息,请参阅安装目录中的相关说明)。

我们将由 sklearn 的CountVectorizerTfIdfVectorizer生成的 DTM 转换为 Gensim 数据结构,如下所示:

train_corpus = Sparse2Corpus(train_dtm, documents_columns=False)
test_corpus = Sparse2Corpus(test_dtm, documents_columns=False)
id2word = pd.Series(vectorizer.get_feature_names()).to_dict() 

Gensim 的 LDA 算法包括许多设置:

LdaModel(corpus=None, 
       num_topics=100, 
       id2word=None, 
       distributed=False, 
       chunksize=2000,  # No of doc per training chunk.
       passes=1,        # No of passes through corpus during training
       update_every=1,  # No of docs to be iterated through per update
       alpha='symmetric', 
       eta=None,        # a-priori belief on word probability
       decay=0.5,      # % of lambda forgotten when new doc is examined
       offset=1.0,     # controls slow down of first few iterations.
       eval_every=10,  # how often estimate log perplexity (costly)
       iterations=50,  # Max. of iterations through the corpus
       gamma_threshold=0.001, # Min. change in gamma to continue
       minimum_probability=0.01, # Filter topics with lower probability
       random_state=None, 
       ns_conf=None, 
       minimum_phi_value=0.01, # lower bound on term probabilities
       per_word_topics=False,  #  Compute most word-topic probabilities
       callbacks=None, 
       dtype=<class 'numpy.float32'>) 

Gensim 还提供了一个LdaMulticore模型进行并行训练,可以利用 Python 的多进程功能加快训练速度。

模型训练只需要实例化LdaModel,如下所示:

lda_gensim = LdaModel(corpus=train_corpus,
                      num_topics=5,
                      id2word=id2word) 

Gensim 评估主题一致性,如前一节所介绍的,并显示每个主题的最重要单词:

coherence = lda_gensim.top_topics(corpus=train_corpus, coherence='u_mass') 

我们可以如下显示结果:

topic_coherence = []
topic_words = pd.DataFrame()
for t in range(len(coherence)):
    label = topic_labels[t]
    topic_coherence.append(coherence[t][1])
    df = pd.DataFrame(coherence[t][0], columns=[(label, 'prob'),
                                                (label, 'term')])
    df[(label, 'prob')] = df[(label, 'prob')].apply(
                              lambda x: '{:.2%}'.format(x))
    topic_words = pd.concat([topic_words, df], axis=1)

topic_words.columns = pd.MultiIndex.from_tuples(topic_words.columns)
pd.set_option('expand_frame_repr', False)
print(topic_words.head()) 

这显示了每个主题的顶级单词:

主题 1 主题 2 主题 3 主题 4 主题 5
概率 术语 概率 术语 概率
0.55% 在线 0.90% 最佳 1.04%
0.51% 网站 0.87% 游戏 0.98%
0.46% 游戏 0.62% 0.51%
0.45% 0.61% 0.48%
0.44% 使用 0.56% 0.48%

图 15.9的左侧面板显示了主题一致性分数,突显了主题质量的衰减(至少部分是由于相对较小的数据集):

图 15.9:主题一致性和测试集分配

右侧面板显示了我们训练模型的 50 篇文章的测试集的评估。模型对四个错误,准确率为 92%。

对盈利电话中讨论的主题进行建模

第三章金融的替代数据 - 类别和用例中,我们学习了如何从 SeekingAlpha 网站抓取盈利电话数据。在本节中,我们将使用这个数据源进行主题建模。我使用了 2018 年至 2019 年之间的约 700 份盈利电话转录样本。这是一个相当小的数据集;对于实际应用,我们需要一个更大的数据集。

目录earnings_calls中包含了本节中使用的代码示例的多个文件。有关加载、探索和预处理数据的详细信息,请参阅笔记本lda_earnings_calls,以及用于描述下一步实验的run_experiments.py文件。

数据预处理

转录包括公司代表的个别声明,操作员和分析师的问答环节。我们将这些声明中的每一条都视为单独的文档,忽略操作员的声明,以获取 32,047 个项目,平均字数和中位数分别为 137 和 62:

documents = []
for transcript in earnings_path.iterdir():
    content = pd.read_csv(transcript / 'content.csv')
    documents.extend(content.loc[(content.speaker!='Operator') & (content.content.str.len() > 5), 'content'].tolist())
len(documents)
32047 

我们使用 spaCy 对这些文档进行预处理,如第十三章使用无监督学习进行数据驱动的风险因素和资产配置中所示(参考笔记本),并将清理和词形还原后的文本存储为一个新的文本文件。

图 15.10 所示,探索最常见的标记揭示出领域特定的停用词,如 "year" 和 "quarter",我们在第二步中去除,同时过滤掉少于 10 个词的语句,剩余约 22,582 个。

图 15.10:最常见的收益电话标记

模型训练和评估

为了说明,我们创建了一个包含出现在 0.5 到 25% 文档中的术语的 DTM,结果为 1,529 个特征。现在我们继续使用 25 个语料库训练 15 个主题模型。在 4 核 i7 上,这需要两分钟多一点。

图 15.11 所示,每个主题的前 10 个词识别出几个明显的主题,从明显的财务信息到临床试验(主题 5)、中国和关税问题(主题 9)以及技术问题(主题 11)。

图 15.11:收益电话主题中最重要的词语

使用 pyLDAvis 的相关度指标,将无条件频率相对于提升的权重设置为 0.6,主题定义变得更加直观,如 图 15.12 所示,关于中国和贸易战的第 7 个主题:

图 15.12:pyLDAVis 的交互式主题探索器

该笔记本还说明了如何根据主题关联查找文档。在这种情况下,分析师可以审查相关陈述以了解细微差别,使用情感分析进一步处理特定主题的文本数据,或者根据市场价格派生标签。

运行实验

为了说明不同参数设置的影响,我们运行了几百个实验,针对不同的 DTM 约束和模型参数。更具体地,我们让 min_dfmax_df 参数分别从 50-500 个词和 10 到 100% 的文档变化,交替使用二进制和绝对计数。然后,我们使用 1 和 25 个语料库训练 LDA 模型,主题从 3 到 50 个。

图 15.13 中的图表以主题连贯性(较高为较好)和困惑度(较低为较好)的形式呈现了结果。连贯性在 25-30 个主题后下降,困惑度同样增加。

图 15.13:LDA 超参数设置对主题质量的影响

笔记本中包含量化参数与结果之间关系的回归结果。我们通常使用绝对计数和较小的词汇量能获得更好的结果。

与财经新闻相关的主题建模

笔记本 lda_financial_news 包含了应用于 2018 年前五个月超过 306,000 篇财经新闻文章的 LDA 示例。这些数据集已经发布在 Kaggle 上,文章来源于 CNBC、路透社、华尔街日报等。笔记本包含下载说明。

我们根据文章标题选择了最相关的 120,000 篇文章,共计 5400 万个标记,平均每篇文章 429 个单词。为了为 LDA 模型准备数据,我们依赖 spaCy 来删除数字和标点,并对结果进行词形还原。

图 15.14 突出显示了剩余的最常见标记和文章长度分布,其中中位数长度为 231 个标记;第 90 百分位是 642 个单词。

图 15.14:金融新闻数据的语料库统计

图 15.15 中,我们展示了一个使用 3,570 个标记的词汇表的模型的结果,基于 min_df=0.005 和 max_df=0.1,采用单次遍历以避免长时间训练 15 个主题。我们可以使用训练后的 LdaModeltop_topics 属性来获取每个主题最可能的词(详细信息请参阅笔记本)。

图 15.15:金融新闻主题的前 15 个词

这些主题概述了与时期相关的几个问题,包括 Brexit(主题 8)、朝鲜(主题 4)和特斯拉(主题 14)。

Gensim 提供了 LdaMultiCore 实现,允许使用 Python 的多进程模块进行并行训练,并且在使用四个工作线程时性能提高了 50%。但是,由于 I/O 瓶颈,使用更多工作线程并不会进一步减少训练时间。

摘要

在本章中,我们探讨了使用主题建模来深入了解大量文档内容的用途。我们涵盖了使用 DTM 的降维技术将文档投射到潜在主题空间中的潜在语义索引。虽然在解决由高维单词向量引起的维度灾难方面很有效,但它并不捕捉太多语义信息。概率模型对文档、主题和单词之间的相互作用做出了明确的假设,允许算法逆向工程文档生成过程,并在新文档上评估模型拟合度。我们了解到 LDA 能够提取出合理的主题,让我们以自动化的方式对大量文本获得高层次的理解,同时以有针对性的方式识别相关文档。

在下一章中,我们将学习如何训练神经网络,将单词嵌入到一个捕捉重要语义信息的高维向量空间中,并且可以使用生成的单词向量作为高质量的文本特征。

第十六章:用于收益电话和 SEC 申报的词嵌入

在前两章中,我们使用了词袋模型将文本数据转换为数值格式。结果是稀疏的、固定长度的向量,表示高维度的词空间中的文档。这使得可以评估文档的相似性,并创建特征来训练模型,以便分类文档的内容或对其中表达的情感进行评分。然而,这些向量忽略了术语使用的上下文,因此,即使两个句子包含相同的单词但顺序不同,它们也将由相同的向量编码,即使它们的含义完全不同。

本章介绍了一类使用神经网络来学习词或段落的向量表示的替代算法。这些向量是密集的而不是稀疏的,具有几百个实值条目,并称为嵌入,因为它们在连续向量空间中为每个语义单元分配一个位置。它们是通过训练模型来从上下文预测标记而产生的,因此类似的用法暗示了类似的嵌入向量。此外,嵌入通过它们的相对位置传达了词之间的关系等语义方面的信息。因此,它们是解决需要语义信息的深度学习模型任务的强大特征,例如机器翻译、问答或维护对话。

要基于文本数据制定交易策略,我们通常对文档的含义感兴趣,而不是单个标记。例如,我们可能希望创建一个数据集,其中包含代表推文或新闻文章的特征,并带有情感信息(参见第十四章用于交易的文本数据 - 情感分析),或者是发布后给定时段内资产的收益。尽管词袋模型在对文本数据进行编码时丢失了大量信息,但它的优点在于表示了整个文档。然而,词嵌入已经进一步发展,以表示不止单个标记。例如,doc2vec扩展采用了加权词嵌入。最近,注意力机制出现了,用于产生更具上下文敏感性的句子表示,从而导致了变压器架构,例如BERT模型系列,在许多自然语言任务上性能显著提高。

具体来说,在完成本章和伴随的笔记本后,您将了解以下内容:

  • 什么是词嵌入,它们如何工作以及为什么它们捕获语义信息

  • 如何获取和使用预训练的词向量

  • 哪些网络架构最有效地训练 word2vec 模型

  • 如何使用 Keras、Gensim 和 TensorFlow 训练 word2vec 模型

  • 可视化和评估词向量的质量

  • 如何训练一个 word2vec 模型来预测 SEC 申报对股价走势的影响

  • doc2vec 如何扩展 word2vec 并用于情感分析

  • 为什么 transformer 的注意力机制对自然语言处理产生了如此大的影响

  • 如何在金融数据上微调预训练的 BERT 模型并提取高质量的嵌入

您可以在本章的 GitHub 目录中找到代码示例和额外资源的链接。本章使用神经网络和深度学习;如果不熟悉,您可能需要先阅读第十七章交易的深度学习,介绍了关键概念和库。

单词嵌入如何编码语义

词袋模型将文档表示为稀疏的、高维度的向量,反映了它们包含的标记。而词嵌入则将标记表示为稠密、低维度的向量,以便于单词的相对位置反映它们在上下文中的使用方式。它们体现了语言学中的分布假设,该假设认为单词最好是通过其周围的上下文来定义。

单词向量能够捕捉许多语义方面;不仅会为同义词分配附近的嵌入,而且单词可以具有多个相似度程度。例如,“driver”一词可能类似于“motorist”或“factor”。此外,嵌入还编码了词对之间的关系,如类比(东京是日本巴黎是法国,或者went 是 go 的过去式saw 是 see 的过去式),我们将在本节后面进行说明。

嵌入是通过训练神经网络来预测单词与其上下文的关系或反之得到的。在本节中,我们将介绍这些模型的工作原理,并介绍成功的方法,包括 word2vec、doc2vec 以及更近期的 transformer 系列模型。

神经语言模型如何学习上下文中的用法

单词嵌入来自于训练一个浅层神经网络来预测给定上下文的单词。而传统的语言模型将上下文定义为目标单词之前的单词,而单词嵌入模型使用包围目标的对称窗口中的单词。相比之下,词袋模型使用整个文档作为上下文,并依赖(加权的)计数来捕捉单词的共现关系。

早期的神经语言模型使用了增加了计算复杂度的非线性隐藏层。而由 Mikolov、Sutskever 等人(2013)介绍的 word2vec 及其扩展简化了架构,使其能够在大型数据集上进行训练。例如,维基百科语料库包含超过 20 亿个标记。(有关前馈网络的详细信息,请参阅第十七章交易的深度学习。)

word2vec – 可扩展的单词和短语嵌入

word2vec 模型是一个两层的神经网络,它以文本语料库作为输入,并为该语料库中的单词输出一组嵌入向量。有两种不同的架构,如下图所示,以有效地使用浅层神经网络学习单词向量(Mikolov、Chen 等,2013):

  • 连续词袋CBOW)模型使用上下文词向量的平均值作为输入来预测目标词,因此它们的顺序并不重要。CBOW 训练速度更快,对于频繁出现的术语可能略微更准确,但对不常见的词注意力较少。

  • 相反,跳字SG)模型使用目标词来预测从上下文中采样的词。它在小型数据集上表现良好,并且即使对于罕见的词或短语也能找到良好的表示。

图 16.1:连续词袋与跳字处理逻辑

模型接收一个嵌入向量作为输入,并与另一个嵌入向量计算点积。请注意,假设向量已被规范化,则当向量相等时点积被最大化(绝对值),当它们正交时则被最小化。

在训练期间,反向传播算法根据基于分类错误的目标函数计算的损失调整嵌入权重。我们将在下一节中看到 word2vec 如何计算损失。

训练通过在文档上滑动上下文窗口进行,通常被分段为句子。对语料库的每次完整迭代称为一个时代。根据数据,可能需要几十个时代才能使向量质量收敛。

跳字模型隐式因式分解一个包含各个单词和上下文对的点间互信息的词-上下文矩阵(Levy 和 Goldberg,2014)。

模型目标 – 简化 softmax

Word2vec 模型旨在预测一个潜在非常庞大的词汇中的单个词。神经网络通常在最后一层使用 softmax 函数作为输出单元实现多类目标,因为它将任意数量的实值映射到相等数量的概率。softmax 函数定义如下,其中 h 指代嵌入,v 指代输入向量,c 是单词 w 的上下文:

然而,由于 softmax 的复杂度与类别数量成比例,因为分母需要计算整个词汇表中所有单词的点积以标准化概率。Word2vec 通过使用 softmax 的修改版本或基于采样的逼近来提高效率:

  • 分层 softmax 将词汇组织为具有单词作为叶节点的二叉树。到达每个节点的唯一路径可用于计算单词概率(Morin 和 Bengio,2005)。

  • 噪声对比估计NCE)对上下文之外的“噪声词”进行采样,并将多类任务近似为二元分类问题。随着样本数量的增加,NCE 导数逼近 softmax 梯度,但仅需 25 个样本即可获得与 softmax 相似的收敛速度增加 45 倍的收敛速度(Mnih 和 Kavukcuoglu,2013)。

  • 负采样NEG)省略了噪声词样本以近似 NCE,并直接最大化目标词的概率。因此,NEG 优化了嵌入向量的语义质量(相似用法的相似向量),而不是在测试集上的准确性。然而,与分层 softmax 目标(Mikolov 等人,2013 年)相比,它可能产生较少频繁词的较差表示。

自动化短语检测

预处理通常涉及短语检测,即识别通常一起使用并应该接收单个向量表示的标记(例如,纽约市;参见第十三章数据驱动的风险因素和无监督学习的资产配置中的 n-gram 讨论)。

原始 word2vec 作者(Mikolov 等人,2013 年)使用一种简单的提升评分方法,如果两个词w[i],w[j]的联合出现超过给定的阈值相对于每个词的个别出现,通过折扣因子δ进行校正,则将它们识别为一个二元组:

评分器可以反复应用以识别连续更长的短语。

另一种选择是标准化的逐点互信息分数,这更准确,但计算成本更高。它使用相对词频P(w),在+1 和-1 之间变化:

使用语义算术评估嵌入

词袋模型创建反映标记在文档中存在和相关性的文档向量。正如第十五章主题建模 - 总结财经新闻中所讨论的那样,潜在语义分析减少了这些向量的维度,并在此过程中确定了可以解释为潜在概念的内容。潜在狄利克雷分配将文档和术语表示为包含潜在主题权重的向量。

word2vec 产生的单词和短语向量没有明确的含义。但是,嵌入将类似的用法编码为模型创建的潜在空间中的接近。嵌入还捕捉语义关系,以便通过添加和减去单词向量来表示类比。

图 16.2显示了从“巴黎”指向“法国”的向量(它测量它们嵌入向量之间的差异)如何反映“首都”的关系。伦敦与英国之间的类似关系对应于相同的向量:术语“英国”的嵌入非常接近通过将“首都”的向量添加到术语“伦敦”的嵌入中获得的位置:

图 16.2:嵌入向量算术

正如单词可以在不同的上下文中使用一样,它们可以以不同的方式与其他单词相关联,而这些关系对应于潜在空间中的不同方向。因此,如果训练数据允许,嵌入应反映出几种类型的类比。

word2vec 的作者提供了一个包含 14 个类别的超过 25,000 个关系列表,涵盖地理、语法和句法以及家庭关系的方面,以评估嵌入向量的质量。正如前面的图表所示,该测试验证了目标词“UK”与添加代表类似关系“巴黎:法国”的向量到目标的补集“伦敦”之间的最近距离。

以下表格显示了示例数,并说明了一些类比类别。该测试检查* d 的嵌入距离 c +(b-a)*确定的位置有多接近。有关实现细节,请参阅evaluating_embeddings笔记本。

类别 # 示例 a b c d
首都-国家 506 雅典 希腊 巴格达 伊拉克
城市-州 4,242 芝加哥 伊利诺伊州 休斯顿 德克萨斯州
过去时 1,560 跳舞 跳舞 减少 减少
复数 1,332 香蕉 香蕉 鸟类
比较 1,332 更坏 更大
相反 812 可接受 不可接受 察觉到 未察觉到
最高级 1,122 最坏 最大
复数(动词) 870 减少 减少 描述 描述
货币 866 阿尔及利亚 阿尔及利亚第纳尔 安哥拉 安哥拉宽扎
家庭 506 男孩 女孩 兄弟 姐妹

与其他无监督学习技术类似,学习嵌入向量的目标是为其他任务生成特征,例如文本分类或情感分析。有几种获得给定文档语料库的嵌入向量的选项:

  • 使用从通用大语料库(如维基百科或 Google 新闻)学习的预训练嵌入

  • 使用反映感兴趣领域的文档来训练您自己的模型

后续文本建模任务的内容越不通用和更专业化,第二种方法就越可取。然而,高质量的单词嵌入需要数据丰富的信息性文档,其中包含数亿字的单词。

首先,我们将看看如何使用预训练向量,然后演示如何使用金融新闻和 SEC 备案数据构建自己的 word2vec 模型的示例。

如何使用预训练单词向量

有几个预训练单词嵌入的来源。流行的选项包括斯坦福的 GloVE 和 spaCy 内置的向量(有关详细信息,请参阅using_pretrained_vectors笔记本)。在本节中,我们将重点放在 GloVe 上。

GloVe – 用于单词表示的全局向量

GloVe(全球单词表示向量,Pennington、Socher 和 Manning,2014)是斯坦福 NLP 实验室开发的无监督算法,它从聚合的全局词-词共现统计中学习单词的向量表示(请参阅 GitHub 上链接的资源)。可用以下网络规模的预训练向量:

  • Common Crawl,包含 420 亿或 840 亿令牌和 190 万或 220 万令牌的词汇量

  • 维基百科 2014 + Gigaword 5,拥有 60 亿个标记和 40 万个标记的词汇表

  • Twitter 使用了 20 亿条推文,27 亿个标记,以及一个包含 120 万个标记的词汇表。

我们可以使用 Gensim 将文本文件转换为向量,使用 glove2word2vec 然后将它们加载到 KeyedVector 对象中:

from gensim.models import Word2Vec, KeyedVectors
from gensim.scripts.glove2word2vec import glove2word2vec
glove2word2vec(glove_input_file=glove_file, word2vec_output_file=w2v_file)
model = KeyedVectors.load_word2vec_format(w2v_file, binary=False) 

Gensim 使用了前面描述的 word2vec 类比测试,使用了作者提供的文本文件来评估词向量。为此,该库具有 wv.accuracy 函数,我们使用它传递类比文件的路径,指示词向量是否以二进制格式存储,以及是否要忽略大小写。我们还可以将词汇表限制为最常见的以加快测试速度。

accuracy = model.wv.accuracy(analogies_path,
                             restrict_vocab=300000,
                             case_insensitive=True) 

在维基百科语料库上训练的词向量覆盖了所有类比,并在各类别之间存在一定的变化,总体准确率为 75.44%:

类别 # 样本 准确度 类别 # 样本 准确度
国家首都 506 94.86% 比较级 1,332 88.21%
非洲和南美洲首都 8,372 96.46% 对立词 756 28.57%
城市-州份 4,242 60.00% 最高级 1,056 74.62%
货币 752 17.42% 现在分词 1,056 69.98%
家庭 506 88.14% 过去时 1,560 61.15%
国籍 1,640 92.50% 复数 1,332 78.08%
形容词-副词 992 22.58% 复数动词 870 58.51%

图 16.3 比较了三种 GloVe 源在 10 万个最常见标记上的性能。它显示了 Common Crawl 向量的准确度略高,达到了 78%,覆盖了约 80% 的类比。而 Twitter 向量的覆盖率仅为 25%,准确度为 56.4%。

图 16.3: GloVe 在 word2vec 类比上的准确度

图 16.4 将在维基百科语料库上训练的 word2vec 模型的 300 维嵌入投影到两个维度上使用 PCA,测试了来自以下类别的 24,400 个以上的类比,准确率超过 73.5%:

图 16.4: 选定类比嵌入的二维可视化

自定义金融新闻嵌入

许多任务需要领域特定词汇的嵌入,而预训练于通用语料库的模型可能无法捕捉到这些词汇。标准的 word2vec 模型无法为词汇表中不存在的词汇分配向量,而是使用一个默认向量,降低了它们的预测价值。

例如,在处理行业特定文件时,词汇表或其用法可能随着新技术或产品的出现而发生变化。因此,嵌入也需要相应地演变。此外,像公司收益发布这样的文件使用了细微的语言,预训练于维基百科文章的 GloVe 向量可能无法正确反映这些语言特点。

在本节中,我们将使用金融新闻训练和评估领域特定的嵌入。我们首先展示了如何为这项任务预处理数据,然后演示了第一节中概述的 skip-gram 架构的工作原理,并最终可视化结果。我们还将介绍替代的更快的训练方法。

预处理 - 句子检测和 n-gram

为了说明 word2vec 网络架构,我们将使用包含超过 12 万 5 千篇相关文章的金融新闻数据集,该数据集我们在第十五章 主题建模 - 总结金融新闻 中介绍了。我们将按照该章节中的 lda_financial_news.ipynb 笔记本中概述的方式加载数据。financial_news_preprocessing.ipynb 笔记本包含了本节的代码示例。

我们使用 spaCy 内置的句子边界检测来将每篇文章分割成句子,去除较少信息的项目,例如数字和标点符号,并保留结果(如果长度在 6 到 99 个标记之间):

def clean_doc(d):
    doc = []
    for sent in d.sents:
        s = [t.text.lower() for t in sent if not
        any([t.is_digit, not t.is_alpha, t.is_punct, t.is_space])]
        if len(s) > 5 or len(sent) < 100:
            doc.append(' '.join(s))
    return doc
nlp = English()
sentencizer = nlp.create_pipe("sentencizer")
nlp.add_pipe(sentencizer)
clean_articles = []
iter_articles = (article for article in articles)
for i, doc in enumerate(nlp.pipe(iter_articles, batch_size=100, n_process=8), 1):
    clean_articles.extend(clean_doc(doc)) 

我们最终得到了 243 万个句子,平均每个句子包含 15 个标记。

接下来,我们创建 n-gram 来捕捉复合术语。 Gensim 允许我们根据组件的联合与个别出现的相对频率来识别 n-gram。Phrases 模块对标记进行评分,并且 Phraser 类相应地转换文本数据。

它将我们的句子列表转换为一个新的数据集,我们可以按如下方式写入文件:

sentences = LineSentence((data_path / f'articles_clean.txt').as_posix())
phrases = Phrases(sentences=sentences,
                          min_count=10,  # ignore terms with a lower count
                          threshold=0.5,  # only phrases with higher score
                          delimiter=b'_',  # how to join ngram tokens
                          scoring='npmi')  # alternative: default
grams = Phraser(phrases)
sentences = grams[sentences]
with (data_path / f'articles_ngrams.txt').open('w') as f:
        for sentence in sentences:
            f.write(' '.join(sentence) + '\n') 

该笔记本演示了如何使用 2-gram 文件作为输入来重复此过程以创建 3-gram。我们最终得到了大约 2 万 5 千个 2-gram 和 1 万 5 千个 3-或 4-gram。检查结果显示,得分最高的术语是公司或个人的名称,这表明我们可能需要加强我们的初始清洁标准。有关数据集的其他详细信息,请参阅笔记本。

TensorFlow 2 中的 skip-gram 架构

在本节中,我们将演示如何使用 TensorFlow 2 的 Keras 接口构建一个 word2vec 模型,我们将在下一章中更详细地介绍。financial_news_word2vec_tensorflow 笔记本包含了代码示例和其他实现细节。

我们首先对文档进行标记化,并为词汇表中的每个项目分配唯一的 ID。首先,我们从上一节创建的句子中抽样一部分来限制训练时间:

SAMPLE_SIZE=.5
sentences = file_path.read_text().split('\n')
words = ' '.join(np.random.choice(sentences, size=int(SAMLE_SIZE* l en(sentences)), replace=False)).split() 

我们需要至少 10 次出现在语料库中,保留包含 31,300 个标记的词汇表,并从以下步骤开始:

  1. 提取前 n 个最常见的单词以学习嵌入。

  2. 使用唯一整数索引这些 n 个单词。

  3. 创建一个 {index: word} 字典。

  4. 用它们的索引替换 n 个词,并在其他地方使用虚拟值 'UNK'

    # Get (token, count) tuples for tokens meeting MIN_FREQ
    MIN_FREQ = 10
    token_counts = [t for t in Counter(words).most_common() if t[1] >= MIN_FREQ]
    tokens, counts = list(zip(*token_counts))
    # create id-token dicts & reverse dicts
    id_to_token = pd.Series(tokens, index=range(1, len(tokens) + 1)).to_dict()
    id_to_token.update({0: 'UNK'})
    token_to_id = {t:i for i, t in id_to_token.items()}
    data = [token_to_id.get(word, 0) for word in words] 
    

我们最终得到了 1740 万个标记和接近 6 万个标记的词汇表,包括长达 3 个词的组合。词汇表涵盖了大约 72.5% 的类比。

噪声对比估计 - 创建验证样本

Keras 包括一个 make_sampling_table 方法,允许我们创建一个训练集,其中包含上下文和噪声词的一对对应标记,根据它们的语料库频率进行采样。较低的因子会增加选择不常见词汇的概率;笔记本中的图表显示,0.1 的值将采样限制在前 10,000 个标记:

SAMPLING_FACTOR =  1e-4
sampling_table = make_sampling_table(vocab_size,
                                     sampling_factor=SAMPLING_FACTOR) 

生成目标-上下文词对

要训练我们的模型,我们需要一对一对的标记,其中一个代表目标,另一个从周围上下文窗口中选择,如之前在 图 16.1 的右侧面板中所示。我们可以使用 Keras 的 skipgrams() 函数如下所示:

pairs, labels = skipgrams(sequence=data,
                          vocabulary_size=vocab_size,
                          window_size=WINDOW_SIZE,
                          sampling_table=sampling_table,
                          negative_samples=1.0,
                          shuffle=True) 

结果是 1.204 亿个上下文-目标对,正负样本均匀分布。负样本是根据我们在前一步中创建的 sampling_table 概率生成的。前五个目标和上下文词 ID 与它们匹配的标签如下所示:

pd.DataFrame({'target': target_word[:5],
              'context': context_word[:5],
              'label': labels[:5]})
   target context label
0   30867    2117     1
1     196     359     1
2   17960   32467     0
3     314    1721     1
4   28387    7811     0 

创建 word2vec 模型层

word2vec 模型包括以下内容:

  • 一个接收表示目标-上下文对的两个标量值的输入层

  • 一个共享的嵌入层,计算目标和上下文词的向量的点积

  • 一个 sigmoid 输出层

输入层 有两个组件,一个用于目标-上下文对的每个元素:

input_target = Input((1,), name='target_input')
input_context = Input((1,), name='context_input') 

共享的嵌入层 包含一个向量,每个词汇元素根据目标和上下文标记的索引进行选择:

embedding = Embedding(input_dim=vocab_size,
                      output_dim=EMBEDDING_SIZE,
                      input_length=1,
                      name='embedding_layer')
target = embedding(input_target)
target = Reshape((EMBEDDING_SIZE, 1), name='target_embedding')(target)
context = embedding(input_context)
context = Reshape((EMBEDDING_SIZE, 1), name='context_embedding')(context) 

输出层 通过它们的点积测量两个嵌入向量的相似性,并使用 sigmoid 函数转换结果,我们在 第七章,线性模型——从风险因素到收益预测 中讨论逻辑回归时遇到过这个函数:

# similarity measure
dot_product = Dot(axes=1)([target, context])
dot_product = Reshape((1,), name='similarity')(dot_product)
output = Dense(units=1, activation='sigmoid', name='output')(dot_product) 

这个 skip-gram 模型包含一个 200 维的嵌入层,每个词汇项都将假设不同的值。结果是,我们得到了 59,617 x 200 个可训练参数,再加上两个用于 sigmoid 输出的参数。

在每次迭代中,模型计算上下文和目标嵌入向量的点积,将结果通过 sigmoid 传递以产生概率,并根据损失的梯度调整嵌入。

使用 TensorBoard 可视化嵌入

TensorBoard 是一个可视化工具,允许将嵌入向量投影到二维或三维空间中,以探索单词和短语的位置。在加载我们创建的嵌入元数据文件后(参考笔记本),您还可以搜索特定术语以查看并探索其邻居,使用 UMAP、t-SNE 或 PCA 将其投影到二维或三维空间中(参见 第十三章,使用无监督学习进行数据驱动的风险因素和资产配置)。有关以下截图的更高分辨率彩色版本,请参考笔记本:

图 16.5:3D 嵌入和元数据可视化

如何使用 Gensim 更快地训练嵌入

TensorFlow 的实现在其体系结构方面非常透明,但速度不是特别快。自然语言处理(NLP)库 Gensim,我们在上一章中也用于主题建模,提供了更好的性能,并且更接近原始作者提供的基于 C 的 word2vec 实现。

使用非常简单。我们首先创建一个句子生成器,它只需将我们在预处理步骤中生成的文件名作为输入(我们将再次使用 3-grams):

sentence_path = data_path / FILE_NAME
sentences = LineSentence(str(sentence_path)) 

在第二步中,我们配置 word2vec 模型,使用熟悉的参数,涉及嵌入向量和上下文窗口的大小,最小标记频率以及负样本数量等:

model = Word2Vec(sentences,
                 sg=1, # set to 1 for skip-gram; CBOW otherwise
                 size=300,
                 window=5,
                 min_count=20,
                 negative=15,
                 workers=8,
                 iter=EPOCHS,
                 alpha=0.05) 

在现代 4 核 i7 处理器上,一次训练大约需要 2 分钟。

我们可以持久化模型和词向量,或仅持久化词向量,如下所示:

# persist model
model.save(str(gensim_path / 'word2vec.model'))
# persist word vectors
model.wv.save(str(gensim_path / 'word_vectors.bin')) 

我们可以验证模型性能,并继续训练,直到对结果满意为止:

model.train(sentences, epochs=1, total_examples=model.corpus_count) 

在这种情况下,再训练六个额外的周期会产生最佳结果,所有涵盖词汇表的类比的准确率为 41.75%。图 16.6的左侧面板显示了正确/错误的预测和每个类别的准确度分布。

Gensim 还允许我们评估自定义语义代数。我们可以检查流行的"woman"+"king"-"man" ~ "queen"示例如下:

most_sim = best_model.wv.most_similar(positive=['woman', 'king'], negative=['man'], topn=10) 

图中的右侧面板显示,“queen”是第三个标记,紧随“monarch”和不太明显的“lewis”之后,然后是几个王室成员:

图 16.6:类别和特定示例的类比准确度

我们还可以评估与给定目标最相似的标记,以更好地理解嵌入特征。我们基于对数语料库频率进行随机选择:

counter = Counter(sentence_path.read_text().split())
most_common = pd.DataFrame(counter.most_common(), columns=['token', 'count'])
most_common['p'] = np.log(most_common['count'])/np.log(most_common['count']).sum()similars = pd.DataFrame()
for token in np.random.choice(most_common.token, size=10, p=most_common.p):
    similars[token] = [s[0] for s in best_model.wv.most_similar(token)] 

下表举例说明了包含多个 n-gram 的结果:

目标 最接近的匹配
0 1
档案 概要
减持 转让
就绪性 训练
军火库 核武器
供应中断 中断

我们现在将继续开发一个与实际交易更密切相关的应用程序,使用 SEC 文件。

用于利用 SEC 文件进行交易的 word2vec

在本节中,我们将使用 Gensim 从年度 SEC 文件中学习单词和短语向量,以说明词嵌入对算法交易的潜在价值。在接下来的章节中,我们将将这些向量与价格回报结合为特征,训练神经网络从安全文件的内容中预测股票价格。

具体来说,我们将使用一个包含来自2013-2016 年间的超过 22,000 份 10-K 年度报告的数据集,这些报告由超过 6,500 家上市公司提交,并包含财务信息和管理评论(请参阅第二章市场和基本数据-来源和技术)。

对应于 11,000 份备案的大约 3,000 家公司,我们有股价数据来标记用于预测建模的数据。(在sec-filings文件夹中的sec_preprocessing笔记本中查看数据源详细信息和下载说明以及预处理代码示例。)

预处理-句子检测和 n-grams

每个备案都是一个单独的文本文件,主索引包含备案元数据。我们提取了最具信息量的部分,即:

  • 项目 1 和 1A:业务和风险因素

  • 项目 7:管理层讨论

  • 项目 7a:关于市场风险的披露

sec_preprocessing笔记本展示了如何使用 spaCy 解析和标记文本,类似于第十四章中的方法。我们不对标记进行词形还原,以保留单词用法的细微差别。

自动短语检测

与前一节一样,我们使用 Gensim 来检测由多个标记组成的短语,或 n-grams。笔记本显示,最常见的二元组包括common_stockunited_statescash_flowsreal_estateinterest_rates

我们最终得到了一个词汇表,其中包含略多于 201,000 个标记,中位数频率为 7,表明我们可以通过增加训练 word2vec 模型时的最小频率来消除相当大的噪音。

使用回报标记备案以预测盈利惊喜

数据集附带了与这 10,000 个文件相关的股票代码和备案日期的列表。我们可以利用这些信息选择围绕备案发布期间的某一时期的股价。目标是训练一个使用给定备案的词向量作为输入来预测备案后回报的模型。

下面的代码示例显示了如何使用 1 个月的回报来标记单个备案:

with pd.HDFStore(DATA_FOLDER / 'assets.h5') as store:
    prices = store['quandl/wiki/prices'].adj_close
sec = pd.read_csv('sec_path/filing_index.csv').rename(columns=str.lower)
sec.date_filed = pd.to_datetime(sec.date_filed)
sec = sec.loc[sec.ticker.isin(prices.columns), ['ticker', 'date_filed']]
price_data = []
for ticker, date in sec.values.tolist():
    target = date + relativedelta(months=1)
    s = prices.loc[date: target, ticker]
    price_data.append(s.iloc[-1] / s.iloc[0] - 1)
df = pd.DataFrame(price_data,
                  columns=['returns'],
                  index=sec.index) 

当我们在接下来的章节中使用深度学习架构时,我们将回到这一点。

模型训练

gensim.models.word2vec类实现了先前介绍的 skip-gram 和 CBOW 架构。笔记本word2vec包含了额外的实现细节。

为了促进内存高效的文本摄取,LineSentence类从提供的文本文件中创建一个包含在单个句子中的生成器:

sentence_path = Path('data', 'ngrams', f'ngrams_2.txt')
sentences = LineSentence(sentence_path) 

Word2Vec类提供了本章中介绍的配置选项:

model = Word2Vec(sentences,
                 sg=1,          # 1=skip-gram; otherwise CBOW
                 hs=0,          # hier. softmax if 1, neg. sampling if 0
                 size=300,      # Vector dimensionality
                 window=3,      # Max dist. btw target and context word
                 min_count=50,  # Ignore words with lower frequency
                 negative=10,   # noise word count for negative sampling
                 workers=8,     # no threads
                 iter=1,        # no epochs = iterations over corpus
                 alpha=0.025,   # initial learning rate
                 min_alpha=0.0001 # final learning rate
                ) 

笔记本显示了如何持久保存和重新加载模型以继续训练,或者如何单独存储嵌入向量,例如用于机器学习模型中。

模型评估

基本功能包括识别相似的单词:

sims=model.wv.most_similar(positive=['iphone'], restrict_vocab=15000)
                  term   similarity
0                 ipad    0.795460
1              android    0.694014
2           smartphone    0.665732 

我们还可以根据正面和负面的贡献来验证单个类比:

model.wv.most_similar(positive=['france', 'london'],
                      negative=['paris'],
                      restrict_vocab=15000)
             term  similarity
0  united_kingdom    0.606630
1         germany    0.585644
2     netherlands    0.578868 

参数设置的性能影响

我们可以使用类比来评估不同参数设置的影响。以下结果非常突出(请参阅models文件夹中的详细结果):

  • 负采样优于分层 softmax,同时训练速度更快。

  • skip-gram 架构优于 CBOW。

  • 不同的min_count设置影响较小;中间值 50 的性能最佳。

使用负采样和min_count为 50 的性能最佳的 skip-gram 模型进行进一步实验,结果如下:

  • 小于 5 的上下文窗口减少了性能。

  • 更高的负采样率提高了性能,但训练速度较慢。

  • 更大的向量提高了性能,大小为 600 的向量在 38.5%的准确率时表现最佳。

使用 doc2vec 嵌入进行情感分析

文本分类需要组合多个单词嵌入。常见方法是对文档中每个单词的嵌入向量进行平均。这使用了所有嵌入的信息,并有效地使用向量加法到达嵌入空间中的不同位置。然而,有关单词顺序的相关信息丢失了。

相反,由 word2vec 作者在发布其原始贡献后不久开发的文档嵌入模型 doc2vec 直接为文本片段(如段落或产品评论)生成嵌入。与 word2vec 类似,doc2vec 也有两种变体:

  • 分布式词袋DBOW)模型对应于 word2vec 的 CBOW 模型。文档向量是通过训练网络来预测目标词,该网络基于上下文单词向量和文档的文档向量的合成任务。

  • 分布式记忆DM)模型对应于 word2vec 的 skip-gram 架构。文档向量是通过训练神经网络使用整个文档的文档向量来预测目标词而产生的。

Gensim 的Doc2Vec类实现了这个算法。我们将通过将其应用于我们在第十四章中介绍的 Yelp 情感数据集来说明 doc2vec 的用法。为了加快训练速度,我们将数据限制为具有相关星级评分的 50 万 Yelp 评论的分层随机样本。doc2vec_yelp_sentiment笔记本包含了本节的代码示例。

从 Yelp 情感数据创建 doc2vec 输入

我们加载了包含 600 万条评论的合并 Yelp 数据集,如第十四章用于交易的文本数据-情感分析中所创建的,并对每个星级评分的评论进行了 10 万次采样:

df = pd.read_parquet('data_path / 'user_reviews.parquet').loc[:, ['stars', 
                                                                  'text']]
stars = range(1, 6)
sample = pd.concat([df[df.stars==s].sample(n=100000) for s in stars]) 

我们使用 nltk 的RegexpTokenizer进行简单快速的文本清洗:

tokenizer = RegexpTokenizer(r'\w+')
stopword_set = set(stopwords.words('english'))
def clean(review):
    tokens = tokenizer.tokenize(review)
    return ' '.join([t for t in tokens if t not in stopword_set])
sample.text = sample.text.str.lower().apply(clean) 

在我们过滤掉长度小于 10 个标记的评论后,我们还剩下 485,825 个样本。图 16.6的左面板显示了每个评论的标记数量的分布。

gensim.models.Doc2Vec类以TaggedDocument格式处理文档,其中包含标记化的文档以及一个唯一的标记,允许在训练后访问文档向量:

sample = pd.read_parquet('yelp_sample.parquet')
sentences = []
for i, (stars, text) in df.iterrows():
    sentences.append(TaggedDocument(words=text.split(), tags=[i])) 

训练 doc2vec 模型

训练界面的工作方式与 word2vec 类似,并且还允许持续训练和持久化:

model = Doc2Vec(documents=sentences,
                dm=1,           # 1=distributed memory, 0=dist.BOW
                epochs=5,
                size=300,       # vector size
                window=5,       # max. distance betw. target and context
                min_count=50,   # ignore tokens w. lower frequency
                negative=5,     # negative training samples
                dm_concat=0,    # 1=concatenate vectors, 0=sum
                dbow_words=0,   # 1=train word vectors as well
                workers=4)
model.save((results_path / 'sample.model').as_posix()) 

我们可以查询 n 个与给定标记最相似的术语作为评估生成的单词向量的一种快速方法如下:

model.most_similar('good') 

图 16.7 的右侧面板显示了返回的标记及其相似性:

图 16.7:每个评论中标记数量的直方图(左)和与标记 'good' 最相似的术语

使用文档向量训练分类器

现在,我们可以访问文档向量以为情感分类器创建特征:

y = sample.stars.sub(1)
X = np.zeros(shape=(len(y), size)) # size=300
for i in range(len(sample)):
    X[i] = model.docvecs[i]
X.shape
(485825, 300) 

我们像往常一样创建训练集和测试集:

X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=y) 

现在,我们继续训练一个 RandomForestClassifier,一个 LightGBM 梯度提升模型和一个多项逻辑回归。我们对随机森林使用了 500 棵树:

rf = RandomForestClassifier(n_jobs=-1, n_estimators=500)
rf.fit(X_train, y_train)
rf_pred = rf.predict(X_test) 

我们使用了 LightGBM 分类器的提前停止,但它运行了完整的 5000 轮因为它持续提高了验证性能:

train_data = lgb.Dataset(data=X_train, label=y_train)
test_data = train_data.create_valid(X_test, label=y_test)
params = {'objective': 'multiclass',
          'num_classes': 5}
lgb_model = lgb.train(params=params,
                      train_set=train_data,
                      num_boost_round=5000,
                      valid_sets=[train_data, test_data],
                      early_stopping_rounds=25,
                      verbose_eval=50)
# generate multiclass predictions
lgb_pred = np.argmax(lgb_model.predict(X_test), axis=1) 

最后,我们建立了一个多项逻辑回归模型如下:

lr = LogisticRegression(multi_class='multinomial', solver='lbfgs',
                        class_weight='balanced')
lr.fit(X_train, y_train)
lr_pred = lr.predict(X_test) 

当我们计算每个模型在验证集上的准确性时,梯度提升表现明显更好,达到 62.24%。图 16.8 显示了每个模型的混淆矩阵和准确性:

图 16.8:备选模型的混淆矩阵和测试准确性

第十四章 交易文本数据 - 情感分析 中,情感分类的结果为 LightGBM 提供了更好的准确性(73.6%),但我们使用了完整的数据集并包含了额外的特征。您可能想要测试增加样本量或调整模型参数是否使 doc2vec 的性能同样良好。

学到的教训和下一步

此示例应用情感分析使用了 doc2vec 来处理产品评论而不是财务文件。我们选择产品评论是因为很难找到足够大的用于从头开始训练单词嵌入并且还具有有用的情感标签或足够信息让我们分配这些标签,例如资产回报等的财务文本数据。

虽然产品评论可以帮助我们展示工作流程,但我们需要记住重要的结构差异:产品评论通常较短、非正式,并且针对一个特定对象。相比之下,许多财务文件更长、更正式,目标对象可能明确标识也可能不明确。财务新闻文章可能涉及多个目标,而企业披露可能有明确的来源,也可能讨论竞争对手。例如,分析师报告也可能同时讨论同一对象或主题的积极和消极方面。

简而言之,财务文件中表达的情感的解释通常需要更复杂、更细致、更细粒度的方法,从不同方面建立对内容含义的理解。决策者通常也关心模型是如何得出结论的。

这些挑战尚未解决,仍然是非常活跃的研究领域,其中最大的困难之一是适合的数据源的稀缺性。然而,自 2018 年以来显著提高了各种 NLP 任务性能的最新突破表明,金融情感分析在未来几年也可能变得更加健壮。我们将在接下来转向这些创新。

新的前沿 – 预训练的变压器模型

Word2vec 和 GloVe 嵌入捕捉到比词袋方法更多的语义信息。但是,它们只允许每个令牌有一个固定长度的表示,不区分上下文特定的用法。为了解决诸如同一个词的多重含义(称为多义性)等未解决的问题,出现了几种新模型,这些模型建立在旨在学习更多上下文化单词嵌入的注意力机制上(Vaswani 等,2017)。这些模型的关键特征如下:

  • 使用双向语言模型同时处理文本的左到右和右到左以获得更丰富的上下文表示

  • 使用半监督预训练在大型通用语料库上学习通用语言方面的嵌入和网络权重,这些可以用于特定任务的微调(一种我们将在第十八章用于金融时间序列和卫星图像的 CNNs中更详细讨论的迁移学习形式)

在本节中,我们简要描述了注意力机制,概述了最近的变压器模型——从变压器中的双向编码器表示BERT)开始——如何利用它来提高关键 NLP 任务的性能,引用了几个预训练语言模型的来源,并解释了如何将它们用于金融情感分析。

注意力是你所需要的一切

注意力机制明确地建模了句子中单词之间的关系,以更好地整合上下文。它最初被应用于机器翻译(Bahdanau,Cho 和 Bengio,2016),但此后已成为各种任务的神经语言模型的核心组成部分。

直到 2017 年,循环神经网络RNNs),它们按顺序从左到右或从右到左处理文本,代表了自然语言处理任务(如翻译)的最新技术。例如,谷歌自 2016 年末起在生产中就采用了这样的模型。顺序处理意味着需要多个步骤来语义连接远距离位置的单词,并且排除了并行处理,而这在现代专用硬件(如 GPU)上会大大加速计算。(关于 RNNs 的更多信息,请参阅第十九章多元时间序列和情感分析的 RNNs。)

相比之下,Transformer 模型,由开创性论文 Attention is all you need(Vaswani 等人,2017)引入,只需一个恒定数量的步骤来识别语义相关的单词。它依赖于一种自注意力机制,可以捕捉句子中所有单词之间的联系,而不考虑它们的相对位置。该模型通过给每个句子中的其他单词分配一个注意力分数来学习单词的表示,该分数决定其他单词应该对表示的贡献程度。然后,这些分数将指导所有单词表示的加权平均值,输入到一个全连接网络中,以生成目标单词的新表示。

Transformer 模型采用了一种编码器-解码器的架构,其中包括多个层,每个层并行使用了多个注意力机制(称为头部)。它在各种翻译任务上取得了巨大的性能提升,并且更重要的是,激发了一波新的研究,致力于神经语言模型解决更广泛范围的任务。在 GitHub 上链接的资源中包含了关于注意力机制如何工作的各种优秀视觉解释,所以我们在这里不会详细介绍。

BERT – 迈向更普适的语言模型

2018 年,Google 发布了 BERT 模型,全称为 Bidirectional Encoder Representations from Transformers(Devlin 等人,2019)。对于自然语言理解任务,它在十一项任务上取得了突破性的成果,从问答和命名实体识别到释义和情感分析,都得到了 通用语言理解评估GLUE)基准的衡量(请参考 GitHub 获取任务描述和排行榜链接)。

BERT 引入的新思想引发了一系列新的研究,产生了数十项超越非专业人员的改进,很快超过了由 DeepMind 设计的更具挑战性的 SuperGLUE 基准(Wang 等人,2019)。因此,2018 年现在被认为是自然语言处理研究的一个转折点;现在,Google 搜索和微软的必应都在使用 BERT 的变体来解释用户查询并提供更准确的结果。

我们将简要概述 BERT 的关键创新,并提供如何开始使用它及其后续增强版的指示,其中包括几个提供预训练模型的开源库。

关键创新 – 更深的注意力和预训练

BERT 模型基于 两个关键思想,即前一节中描述的 transformer 架构无监督预训练,这样它就不需要为每个新任务从头开始训练;相反,它的权重被微调:

  • BERT 通过使用 12 或 24 层(取决于架构),每层有 12 或 16 个注意头,将注意机制提升到一个新的(更深)水平。这导致最多 24 × 16 = 384 个注意机制来学习特定于上下文的嵌入。

  • BERT 使用无监督的、双向的预训练来提前在两个任务上学习其权重:遮盖语言建模(在左右上下文中给出缺失的单词)和下一句预测(预测一句话是否跟在另一句话后面)。

无上下文模型(如 word2vec 或 GloVe)为词汇表中的每个单词生成一个单一的嵌入:单词“bank”在“bank account”和“river bank”中具有相同的无上下文表示。相比之下,BERT 学习根据句子中的其他单词来表示每个单词。作为双向模型,BERT 能够表示句子“I accessed the bank account”中的单词“bank”,不仅基于“我访问了”作为单向上下文模型,还基于“account”。

BERT 及其后继版本可以在通用语料库(如维基百科)上进行预训练,然后调整其最终层以适应特定任务,并微调其权重。因此,您可以使用具有数十亿参数的大规模、最先进的模型,而只需支付几个小时而不是几天或几周的培训成本。几个库提供了这样的预训练模型,您可以构建一个定制的情感分类器,以适应您选择的数据集。

使用预训练的最先进模型

本节描述的最新 NLP 突破展示了如何利用足够大的网络从未标记的文本中获取语言知识,这些 Transformer 体系结构减少了对单词顺序和上下文的假设;相反,它们从大量数据中学习语言的更微妙的理解,使用数亿甚至数十亿的参数。

我们将重点介绍几个使预训练网络和优秀的 Python 教程可用的库。

Hugging Face Transformers 库

Hugging Face 是一家美国初创公司,开发旨在提供个性化 AI 驱动通信的聊天机器人应用。2019 年底,该公司筹集了 1500 万美元,以进一步开发其非常成功的开源 NLP 库 Transformers。

该库提供了用于自然语言理解和生成的通用架构,拥有超过 32 个预训练模型,涵盖 100 多种语言,并在 TensorFlow 2 和 PyTorch 之间具有深层的互操作性。它有很好的文档。

spacy-transformers 库包含了用于在 spaCy 管道中方便地包含预训练变换器模型的包装器。有关更多信息,请参阅 GitHub 上的参考链接。

AllenNLP

AllenNLP 由微软联合创始人保罗·艾伦创建并维护,与华盛顿大学的研究人员密切合作。它被设计为一个用于在各种语言任务上开发最先进深度学习模型的研究库,基于 PyTorch 构建。

它提供了从问答到句子标注等关键任务的解决方案,包括阅读理解、命名实体识别和情感分析。预训练的RoBERTa模型(BERT 的更强大版本;Liu 等,2019 年)在斯坦福情感树库上实现了超过 95%的准确率,并且只需几行代码即可使用(参见 GitHub 上的文档链接)。

交易文本数据——经验教训与下一步计划

正如在“使用 doc2vec 嵌入进行情感分析”一节末尾所强调的那样,金融文件存在重要的结构特征,这些特征通常会使其解释变得复杂,并削弱基于简单词典的方法。

在最近一项金融情感分析调查中,Man、Luo 和 Lin(2019 年)发现,大多数现有方法只能识别高级极性,如积极、消极或中性。然而,导致实际决策的实际应用通常需要更细致和透明的分析。此外,缺乏具有相关标签的大型金融文本数据集限制了使用传统机器学习方法或神经网络进行情感分析的潜力。

刚才描述的预训练方法,原则上能够更深入地理解文本信息,因此具有相当大的潜力。然而,使用变换器进行的大多数应用研究都集中在诸如翻译、问答、逻辑或对话系统等自然语言处理任务上。与金融数据相关的应用仍处于初级阶段(例如,参见 Araci 2019)。考虑到预训练模型的可用性以及它们从金融文本数据中提取更有价值信息的潜力,这种情况可能很快就会改变。

总结

在本章中,我们讨论了一种利用浅层神经网络进行无监督机器学习的新型生成文本特征的方法。我们看到了由此产生的词嵌入如何捕捉到一些有趣的语义方面,超越了单个标记的含义,捕捉到了它们被使用的一些上下文。我们还介绍了如何使用类比和线性代数评估单词向量的质量。

我们使用 Keras 构建了生成这些特征的网络架构,并将更高性能的 Gensim 实现应用于金融新闻和美国证券交易委员会的备案文件。尽管数据集相对较小,但 word2vec 嵌入确实捕捉到了有意义的关系。我们还展示了如何通过股价数据进行适当标记,从而形成监督学习的基础。

我们应用了 doc2vec 算法,该算法生成的是文档而不是令牌向量,以构建基于 Yelp 商业评论的情感分类器。虽然这不太可能产生可交易的信号,但它说明了如何从相关文本数据中提取特征并训练模型来预测可能对交易策略有信息意义的结果的过程。

最后,我们概述了最近的研究突破,承诺通过可预先训练的架构的可用性来产生更强大的自然语言模型,这些模型仅需要微调即可。然而,对金融数据的应用仍处于研究前沿。

在下一章中,我们将深入探讨本书的最后部分,该部分涵盖了各种深度学习架构如何对算法交易有用。

第十七章:交易的深度学习

本章开启了第四部分,涵盖了几种深度学习DL)建模技术如何对投资和交易有用。DL 已经在许多领域取得了许多突破,从图像和语音识别到机器人和智能代理,引起了广泛关注,并重振了对人工智能AI)的大规模研究。人们对快速发展有着很高的期望,并且预计将会出现更多解决困难实际问题的解决方案。

在本章中,我们将介绍前馈神经网络,以介绍与后续章节中涵盖的各种 DL 架构相关的神经网络工作要素。具体来说,我们将演示如何使用反向传播算法高效训练大型模型,并管理过拟合的风险。我们还将展示如何使用流行的 TensorFlow 2 和 PyTorch 框架,这些框架将贯穿第四部分。

最后,我们将基于由深度前馈神经网络生成的信号开发、回测和评估交易策略。我们将设计和调整神经网络,并分析关键超参数选择如何影响其性能。

总之,在阅读本章并审阅随附的笔记本后,您将了解:

  • 深度学习如何解决复杂领域的 AI 挑战

  • 推动 DL 走向当前流行的关键创新

  • 前馈网络如何从数据中学习表示

  • 在 Python 中设计和训练深度神经网络NNs

  • 使用 Keras、TensorFlow 和 PyTorch 实现深度神经网络

  • 构建和调整深度神经网络以预测资产回报

  • 设计和回测基于深度神经网络信号的交易策略

在接下来的章节中,我们将在此基础上设计各种适用于不同投资应用的架构,特别关注替代文本和图像数据。

这些包括针对序列数据(如时间序列或自然语言)量身定制的循环神经网络RNNs),以及特别适用于图像数据但也可以与时间序列数据一起使用的卷积神经网络CNNs)。我们还将涵盖深度无监督学习,包括自动编码器和生成对抗网络GANs),以及强化学习来训练能够与环境进行交互式学习的代理程序。

您可以在 GitHub 存储库的相应目录中找到本章的代码示例和附加资源链接。笔记本中包括图片的彩色版本。

深度学习 - 新技术和重要性

第二部分中涵盖的机器学习ML)算法在解决各种重要问题方面表现良好,包括文本数据,如第三部分所示。然而,它们在解决诸如识别语音或对图像中的对象进行分类等核心人工智能问题上成功较少。这些限制促使了深度学习的发展,最近的深度学习突破大大促进了对人工智能的兴趣再度增长。有关包含并扩展本节许多观点的全面介绍,请参见 Goodfellow、Bengio 和 Courville(2016),或者看看 LeCun、Bengio 和 Hinton(2015)的简短版本。

在本节中,我们概述了深度学习如何克服其他机器学习算法的许多限制。这些限制特别限制了在需要复杂努力提取信息特征的高维和非结构化数据上的性能。

我们在第二第三部分中介绍的机器学习技术最适合处理具有明确定义特征的结构化数据。例如,我们看到如何使用第十四章中的文档-文本矩阵将文本数据转换为表格数据,交易的文本数据-情感分析。深度学习通过学习数据的表示来克服设计信息特征的挑战,可能需要手动进行,从而更好地捕捉其与结果相关的特征。

更具体地说,我们将看到深度学习如何学习数据的分层表示,以及为什么这种方法对于高维、非结构化数据效果很好。我们将描述神经网络如何使用多层、深度架构来组成一组嵌套函数并发现分层结构。这些函数根据前一层的学习计算数据的连续和越来越抽象的表示。我们还将看看反向传播算法如何调整网络参数,以便这些表示最好地满足模型的目标。

我们还将简要概述深度学习如何融入人工智能的演变以及旨在实现当前人工智能目标的各种方法。

分层特征驯服了高维数据

正如第二部分中所讨论的那样,监督学习的关键挑战是从训练数据推广到新样本。随着数据的维度增加,泛化变得指数级困难。我们在第十三章中遇到了这些困难的根本原因,即无监督学习的数据驱动风险因素和资产配置的维度诅咒

这个诅咒的一个方面是,体积随着维度的增加而呈指数增长:对于边长为 10 的超立方体,随着维度从三增加到四,体积从 10³增加到 10⁴。相反,对于给定样本大小,数据密度会呈指数级下降。换句话说,为了保持一定的密度,所需的观察次数呈指数增长。

另一个方面是,当特征与输出之间的功能关系被允许跨越越来越多的维度变化时,它们变得更加复杂。正如第六章机器学习过程中讨论的那样,ML 算法在高维空间中学习任意函数时会遇到困难,因为候选者数量呈指数级增长,而可用于推断关系的数据密度同时下降。为了缓解这个问题,算法假设目标函数属于某个特定的类,并对在解决当前问题时在该类中寻找最佳解的搜索施加约束。

此外,算法通常假设新点的输出应与附近训练点的输出相似。这种先验平滑性假设或局部恒定性假设,即学习的函数在小区域内不会发生太大变化,正如 k 最近邻算法所示(参见第六章机器学习过程)。然而,随着维度数量的增加,数据密度指数级下降,训练样本之间的距离自然上升。因此,随着目标函数的潜在复杂性增加,附近训练示例的概念变得不太有意义。

对于传统的 ML 算法,所需参数和训练样本的数量通常与算法能够区分的输入空间中的区域数量成正比。DL 旨在通过假设特征的层次结构生成数据,从而克服从有限数量的训练点学习指数数量的区域的挑战。

DL 作为表示学习

许多人工智能任务,如图像或语音识别,需要关于世界的知识。其中一个关键挑战是对这些知识进行编码,以便计算机可以利用它。几十年来,ML 系统的发展需要相当的领域专业知识,以将原始数据(如图像像素)转换为学习算法可以用来检测或分类模式的内部表示。

同样,ML 算法对交易策略增加了多少价值,很大程度上取决于我们能够工程化特征,以表示数据中的预测信息,以便算法可以处理它。理想情况下,特征应捕获结果的独立驱动因素,正如在第四章金融特征工程-如何研究 Alpha 因子第二部分第三部分中讨论的,在设计和评估捕获交易信号的因子时。

与依赖手工设计的特征不同,表示学习使 ML 算法能够自动发现对于检测或分类模式最有用的数据表示。DL 将这种技术与关于特征性质的特定假设相结合。有关更多信息,请参见 Bengio、Courville 和 Vincent(2013)。

DL 如何从数据中提取层次特征

DL 背后的核心思想是一个多层次的特征层次结构生成了数据。因此,DL 模型编码了目标函数由一组嵌套的简单函数组成的先验信念。这一假设允许在给定数量的训练样本的情况下,区分的区域数量呈指数增长。

换句话说,DL 是一种从数据中提取概念层次结构的表示学习方法。它通过组合简单但非线性的函数来学习这种层次结构表示,这些函数逐步地将一个级别(从输入数据开始)的表示转换为稍微更抽象的高级表示。通过组合足够多的这些转换,DL 能够学习非常复杂的函数。

应用于分类任务时,例如,更高层次的表示往往会放大对区分对象最有帮助的数据方面,同时抑制无关的变化源。正如我们将在第十八章更详细地看到的,用于金融时间序列和卫星图像的 CNNs,原始图像数据只是像素值的二维或三维数组。表示的第一层通常学习侧重于特定方向和位置的边缘的存在或缺失的特征。第二层通常学习依赖于特定边缘排列的主题,而不考虑它们位置的小变化。接下来的层可能会组装这些主题以表示相关对象的部分,随后的层将检测到对象作为这些部分的组合。

DL 的关键突破在于一个通用的学习算法可以提取适合于对高维、非结构化数据进行建模的层次特征,而这种方式的可扩展性比人类工程学无限得多。因此,DL 的崛起与非结构化图像或文本数据的大规模可用性并不奇怪。在这些数据源在替代数据中也占据重要地位的程度上,DL 对算法交易变得高度相关。

好消息和坏消息 - 通用逼近定理

通用逼近定理正式化了 NNs 捕捉输入和输出数据之间任意关系的能力。George Cybenko(1989)证明了使用 sigmoid 激活函数的单层 NNs 可以表示Rn的闭合和有界子集上的任何连续函数。Kurt Hornik(1991)进一步表明,能够进行分层特征表示的不是特定形状的激活函数,而是多层次架构,这进而使得 NNs 能够逼近通用函数。

然而,该定理并不能帮助我们确定表示特定目标函数所需的网络架构。我们将在本章的最后一节中看到,有许多参数需要优化,包括网络的宽度和深度、神经元之间的连接数量以及激活函数的类型。

此外,能够表示任意函数并不意味着网络实际上可以学习给定函数的参数。过去 20 多年来,反向传播,即用于神经网络的最流行的学习算法之一,才在规模上变得有效。不幸的是,考虑到优化问题的高度非线性性质,不能保证它会找到绝对最佳解而不仅仅是相对好的解决方案。

深度学习与机器学习和人工智能的关系

人工智能有着悠久的历史,至少可以追溯到 20 世纪 50 年代作为一个学术领域,而作为人类探究的主题则更久远,但自那时以来,经历了几次热情的高涨和低落(有关深入调查,请参见尼尔森,2009 年)。机器学习是一个重要的子领域,在统计学等相关学科中有着悠久的历史,并在 20 世纪 80 年代变得突出起来。正如我们刚刚讨论的那样,并且在图 17.1中所示,深度学习是一种表示学习,本身是机器学习的一个子领域。

人工智能的最初目标是实现通用人工智能,即被认为需要人类级智能来解决的问题,并对世界进行推理和逻辑推断,并自动改进自己的能力。不涉及机器学习的人工智能应用包括编码有关世界的信息的知识库,以及用于逻辑操作的语言。

在历史上,大量的人工智能工作都致力于开发基于规则的系统,旨在捕获专家知识和决策规则,但是由于过度复杂而硬编码这些规则的尝试经常失败。相比之下,机器学习意味着从数据中学习规则的概率方法,并旨在规避人为设计的基于规则系统的限制。它还涉及到更窄、任务特定目标的转变。

下图勾勒了各种人工智能子领域之间的关系,概述了它们的目标,并突出了它们在时间线上的相关性。

图 17.1:人工智能时间线和子领域

在接下来的部分中,我们将看到如何实际构建神经网络。

设计神经网络

深度学习依赖于神经网络,它由一些关键构建模块组成,而这些模块又可以以多种方式配置。在本节中,我们介绍了神经网络的工作原理,并阐述了用于设计不同架构的最重要组成部分。

(人工)神经网络最初受到了生物学习模型的启发,例如人脑,要么试图模仿其工作原理并取得类似的成功,要么通过模拟来更好地理解。当前的神经网络研究不太依赖于神经科学,至少因为我们对大脑的理解尚未达到足够的细致程度。另一个约束是总体规模:即使从 20 世纪 50 年代开始,神经网络中使用的神经元数量每年都以指数倍增长,它们也只会在 2050 年左右达到人脑的规模。

我们还将解释反向传播,通常简称为反向传播,如何使用梯度信息(损失函数对参数的偏导数值)根据训练误差调整所有神经网络参数。各种非线性模块的组合意味着目标函数的优化可能非常具有挑战性。我们还介绍了旨在加速学习过程的反向传播的改进。

一个简单的前馈神经网络架构

在本节中,我们介绍了基于多层感知器MLP)的前馈神经网络feedforward NNs),它由一个或多个连接输入与输出层的隐藏层组成。在前馈神经网络中,信息只从输入流向输出,因此它们可以被表示为有向无环图,如下图所示。相比之下,循环神经网络RNNs;见第十九章用于多元时间序列和情感分析的 RNNs)包括从输出回到输入的循环,以跟踪或记忆过去的模式和事件。

我们将首先描述前馈神经网络的架构以及如何使用 NumPy 实现它。然后我们将解释反向传播如何学习神经网络的权重,并在 Python 中实现它,以训练一个二分类网络,即使类别不是线性可分的也能产生完美的结果。有关实现细节,请参见笔记本build_and_train_feedforward_nn

前馈神经网络由多个组成,每个层接收一份输入数据样本并产生一个输出。变换链始于输入层,将源数据传递给几个内部或隐藏层之一,以输出层结束,该层计算与样本输出值进行比较的结果。

隐藏层和输出层由节点或神经元组成。全连接或密集层的节点连接到上一层的一些或所有节点。网络架构可以通过其深度(由隐藏层的数量衡量)、每个层的宽度和节点数量来总结。

每个连接都有一个用于计算输入值的线性组合的权重。一层也可以有一个偏差节点,它始终输出 1,并由后续层中的节点使用,类似于线性回归中的常数。训练阶段的目标是学习这些权重的值,以优化网络的预测性能。

每个隐藏层节点计算前一层的输出和权重的点积激活函数转换结果,成为后续层的输入。此转换通常是非线性的(就像逻辑回归中使用的 Sigmoid 函数一样;参见 第七章线性模型 - 从风险因素到回报预测,关于线性模型),以便网络可以学习非线性关系;我们将在下一节讨论常见的激活函数。输出层计算最后一个隐藏层的输出与其权重的线性组合,并使用与 ML 问题类型匹配的激活函数。

因此,网络输出的计算从输入中流经一系列嵌套函数,并称为前向传播图 17.2说明了一个具有二维输入向量、宽度为三的隐藏层和两个输出层节点的单层前馈 NN。这种架构足够简单,因此我们仍然可以轻松地绘制它,并且说明关键概念。

图 17.2:具有一个隐藏层的前馈架构

网络图显示,每个隐藏层节点(不包括偏差)都有三个权重,一个用于输入层偏差,两个用于每个两个输入变量。同样,每个输出层节点都有四个权重来计算隐藏层偏差和激活的乘积和。总共有 17 个要学习的参数。

右侧的前向传播面板列出了隐藏层和输出层中一个示例节点 ho 的计算,分别表示隐藏层和输出层。隐藏层中的第一个节点对其权重和输入的线性组合 z 应用 Sigmoid 函数,类似于逻辑回归。因此,隐藏层同时运行三个逻辑回归,并且反向传播算法确保它们的参数很可能不同,以最好地通知后续层。

输出层使用softmax激活函数(参见 第六章机器学习过程),该函数将逻辑 Sigmoid 函数推广到多个类别。它调整了隐藏层输出与其权重的点积,以表示类别的概率(在这种情况下仅为两个以简化演示)。

前向传播也可以表示为嵌套函数,其中 h 再次表示隐藏层,o 表示输出层以产生 NN 对输出的估计:

关键设计选择

一些神经网络设计选择与其他监督学习模型相似。例如,输出取决于 ML 问题的类型,如回归、分类或排名。给定输出,我们需要选择一个成本函数来衡量预测成功和失败,并选择一个算法来优化网络参数以最小化成本。

NN 特定的选择包括层数和每层节点的数量,不同层节点之间的连接以及激活函数的类型。

一个关键问题是训练效率:激活的功能形式可以促进或阻碍可用于反向传播算法的梯度信息的流动,该算法根据训练错误调整权重。具有大输入值范围的平坦区域的函数具有非常低的梯度,当参数值停留在这种范围内时,可以阻碍训练进度。

一些架构添加了跳跃连接,建立了超出相邻层的直接链接,以促进梯度信息的流动。另一方面,有意省略连接可以减少参数数量,限制网络的容量,并可能降低泛化误差,同时也减少了计算成本。

隐藏单元和激活函数

除了 sigmoid 函数之外,还有几种非线性激活函数被成功使用。它们的设计仍然是一个研究领域,因为它们是允许 NN 学习非线性关系的关键元素。它们也对训练过程有关键影响,因为它们的导数决定了错误如何转化为权重调整。

一个非常流行的激活函数是修正线性单元ReLU)。激活被计算为g(z) = max(0, z),对于给定的激活z,结果形式类似于看涨期权的支付。当单元处于活跃状态时,导数是常数。ReLU 通常与需要存在偏置节点的仿射输入变换结合使用。它们的发现极大地改善了前馈网络的性能,与 S 型单元相比,它们经常被推荐为默认选项。有几个 ReLU 扩展旨在解决 ReLU 在不活动时学习梯度下降时的限制和它们的梯度为零的问题(Goodfellow、Bengio 和 Courville,2016)。

对于逻辑函数σ的另一种选择是双曲正切函数 tanh,它产生在范围[-1, 1]的输出值。它们是密切相关的,因为。两个函数都受到饱和的影响,因为它们的梯度在非常低和高的输入值时变得非常小。然而,tanh 通常表现更好,因为它更接近恒等函数,所以对于小的激活值,网络的行为更像是一个线性模型,这反过来又促进了训练。

输出单元和成本函数

NN 输出格式和成本函数的选择取决于监督学习问题的类型:

  • 回归问题使用线性输出单元,计算其权重与最终隐藏层激活的点积,通常与均方误差成本一起使用。

  • 二元分类使用 sigmoid 输出单元来模拟伯努利分布,就像逻辑回归一样,其中隐藏激活作为输入

  • 多类问题依赖于 softmax 单元,它们推广了逻辑 sigmoid 并模拟了超过两个类别的离散分布,正如之前展示的那样

二元和多类问题通常使用交叉熵损失,与均方误差相比,这显着提高了训练效果(有关损失函数的其他信息,请参见第六章机器学习过程)。

如何正则化深度 NN

NN 近似任意函数的容量的缺点是过度拟合的风险大大增加。对过度拟合的最佳保护是在更大的数据集上训练模型。数据增强,例如创建图像的略微修改版本,是一个强大的替代方法。为此目的生成合成金融训练数据是一个活跃的研究领域,我们将在第二十章自编码器用于条件风险因素和资产定价中讨论这一点(例如,Fu 等人 2019 年)。

作为获取更多数据的替代或补充,正则化可以帮助减轻过度拟合的风险。在本书中到目前为止讨论的所有模型中,都有一些形式的正则化,它修改学习算法以减少其泛化误差,而不会对其训练误差产生负面影响。示例包括添加到岭和套索回归目标中的惩罚以及用于决策树和基于树的集成模型的分割或深度约束。

经常,正则化采用对参数值的软约束形式,以权衡一些额外的偏差以获得较低的方差。一个常见的实际发现是,具有最低泛化误差的模型不是具有精确正确参数大小的模型,而是一个经过很好正则化的更大的模型。可以结合使用的流行的 NN 正则化技术包括参数范数惩罚,提前停止和丢弃。

参数范数惩罚

我们在第七章线性模型-从风险因素到收益预测中遇到了参数范数惩罚,分别用作L1 和 L2 正则化的套索和岭回归。在 NN 上下文中,参数范数惩罚通过添加一个代表参数的 L1 或 L2 范数的项来类似地修改目标函数,权重由需要调整的超参数加权。对于 NN,偏置参数通常不受限制,只有权重。

L1 正则化可以通过将权重减少到零来产生稀疏的参数估计。相比之下,L2 正则化保留了参数显著减少成本函数的方向。惩罚或超参数的值可以在各个层之间变化,但添加的调整复杂性很快变得令人难以承受。

早停止

我们在第十二章 提升您的交易策略中遇到了早停止作为一种正则化技术。它可能是最常见的神经网络正则化方法,因为它既有效又简单:它监视模型在验证集上的性能,并在一定数量的观察次数内停止训练,以防止过拟合。

早停止可以被看作是有效的超参数选择,它可以自动确定正确的正则化量,而参数惩罚则需要超参数调整来确定理想的权重衰减。只要注意避免前瞻性偏见:当早停止使用不可用于策略实施的样本外数据时,回测结果将过度正面。

Dropout

Dropout 是指在前向或后向传播过程中以给定概率随机省略个别单元。因此,这些被省略的单元不会对训练误差做出贡献,也不会接收更新。

该技术计算成本低廉,不限制模型或训练过程的选择。虽然需要更多迭代才能达到相同的学习量,但由于计算成本较低,每次迭代速度更快。Dropout 通过防止单元在训练过程中弥补其他单元的错误来降低过拟合的风险。

更快地训练 - 深度学习的优化

反向传播是指计算目标函数相对于我们希望更新的内部参数的梯度,并利用这些信息来更新参数值。梯度是有用的,因为它指示导致成本函数最大增加的参数变化方向。因此,根据负梯度调整参数会产生最佳的成本减少,至少对于非常接近观察样本的区域来说是这样。有关关键梯度下降优化算法的出色概述,请参阅 Ruder(2017)。

训练深度神经网络可能会耗费大量时间,这是由于非凸目标函数和可能庞大的参数数量所导致的。几个挑战可能会显著延迟收敛,找到一个糟糕的最优解,或者导致振荡或偏离目标:

  • 局部极小值 可能会阻止收敛到全局最优解并导致性能差。

  • 具有低梯度的平坦区域 可能不是局部最小值,也可能阻止收敛,但很可能远离全局最优解。

  • 由于乘以几个大权重而导致的具有高梯度的陡峭区域可能会导致过度调整

  • RNN 中的深层结构或长期依赖性需要在反向传播过程中乘以许多权重,导致梯度消失,使得至少部分 NN 接收到少量或没有更新

已经开发了几种算法来解决其中一些挑战,即随机梯度下降的变体和使用自适应学习率的方法。虽然自适应学习率并没有单一的最佳算法,但已经显示出一些希望。

随机梯度下降

梯度下降通过梯度信息迭代地调整这些参数。对于给定的参数 ,基本梯度下降规则通过损失函数相对于该参数的负梯度乘以学习速率 来调整该值:

梯度可以对所有训练数据、随机批量数据或单个观测(称为在线学习)进行评估。随机样本产生随机梯度下降SGD),如果随机样本在整个训练过程中对梯度方向是无偏估计,则通常导致更快的收敛。

然而,存在许多挑战:很难预先定义一个能够促进有效收敛的学习率或速率调度——太低的速率会延长过程,而太高的速率可能会导致反复超调和围绕最小值振荡甚至发散。此外,相同的学习率可能不适用于所有参数,即在所有变化方向上都不适用。

动量

基本梯度下降的一个流行改进是将动量添加到加速收敛到局部最小值。动量的图示经常使用一个位于细长峡谷中心的局部最优值的例子(实际上,维度要比三维高得多)。它意味着一个位于深而窄的峡谷内的最小值,该峡谷的壁非常陡峭,其中一侧的梯度很大,另一侧朝着该区域底部的局部最小值的斜坡要缓得多。梯度下降自然而然地沿着陡峭的梯度走,将反复调整上下峡谷的墙壁,向着最小值的方向移动得更慢。

动量旨在通过跟踪最近的方向并通过最近的梯度和当前计算值的加权平均来调整参数来解决这种情况。它使用动量项 来衡量最新调整对此迭代更新 v[t] 的贡献:

Nesterov momentum 是对普通动量的简单改进。在这里,梯度项不是在当前参数空间位置计算!,而是从一个中间位置计算。其目标是纠正动量项过度偏离或指向错误方向(Sutskever 等人,2013)。

自适应学习率

选择适当的学习率非常具有挑战性,正如前一小节中所强调的随机梯度下降。与此同时,它是最重要的参数之一,它强烈影响训练时间和泛化性能。

虽然动量解决了一些学习率的问题,但是以引入另一个超参数,动量率 为代价。几种算法旨在根据梯度信息在整个训练过程中自适应地调整学习率。

AdaGrad

AdaGrad 累积所有历史的、参数特定的梯度信息,并继续根据给定参数的平方累积梯度来反比例地重新缩放学习率。其目标是减缓已经大量变化的参数的变化,鼓励还没有变化的参数进行调整。

AdaGrad 设计用于在凸函数上表现良好,在 DL 上表现不佳,因为它可能会根据早期梯度信息过快地降低学习率。

RMSProp

RMSProp 修改了 AdaGrad 以使用累积梯度信息的指数加权平均值。其目标是更加强调最近的梯度。它还引入了一个新的超参数,用于控制移动平均的长度。

RMSProp 是一种流行的算法,通常表现良好,由我们稍后将介绍的各种库提供,并在实践中经常使用。

Adam

Adam 代表 自适应动量估计,将 RMSProp 的一些方面与动量结合起来。它被认为是相当稳健的,并经常用作默认的优化算法(Kingma 和 Ba,2014)。

Adam 有几个带有建议的默认值的超参数,可能会从一些调整中受益:

  • alpha:学习率或步长确定更新权重的程度,较大(较小)的值在更新速度之前加快(减慢)学习;许多库使用默认值 0.001

  • beta[1]:第一矩估计的指数衰减率;通常设置为 0.9

  • beta[2]:第二矩估计的指数衰减率;通常设置为 0.999

  • epsilon:一个非常小的数字,用于防止除零;通常设置为 1e-8

总结 - 如何调整关键的超参数

超参数优化旨在调整模型的容量,使其匹配数据输入之间的关系的复杂性。过多的容量会增加过拟合的可能性,需要更多的数据,将额外的信息引入到学习过程中,减小模型的大小,或更积极地使用刚刚描述的各种正则化工具。

主要的诊断工具是描述在第六章机器学习过程中的训练和验证错误的行为:如果验证错误恶化,而训练错误继续下降,那么模型就是过度拟合的,因为其容量太高。另一方面,如果性能不符合预期,可能需要增加模型的大小。

参数优化最重要的方面是架构本身,因为它很大程度上决定了参数的数量:其他条件相同,更多或更宽的隐藏层会增加容量。正如前面提到的,最佳性能通常与具有过量容量但使用像 dropout 或 L1/L2 惩罚这样的机制进行了良好正则化的模型相关联。

除了平衡模型大小和正则化之外,调整学习率也很重要,因为它可能会破坏优化过程并降低有效模型容量。自适应优化算法提供了一个很好的起点,就像 Adam 描述的那样,这是最流行的选项。

Python 从头开始的神经网络

为了更好地理解 NNs 的工作原理,我们将使用矩阵代数来表述单层架构和图 17.2中显示的前向传播计算,并使用 NumPy 实现它。你可以在笔记本build_and_train_feedforward_nn中找到代码示例。

输入层

图 17.2中显示的架构设计用于表示两个不同类别Y的二维输入数据X。以矩阵形式,XY的形状都是

我们将使用 scikit-learn 的make_circles函数生成 50,000 个随机二进制样本,形成两个半径不同的同心圆,以便类别不是线性可分的:

N = 50000
factor = 0.1
noise = 0.1
X, y = make_circles(n_samples=N, shuffle=True,
                   factor=factor, noise=noise) 

然后将一维输出转换为二维数组:

Y = np.zeros((N, 2))
for c in [0, 1]:
   Y[y == c, c] = 1
'Shape of: X: (50000, 2) | Y: (50000, 2) | y: (50000,)' 

图 17.3显示了数据的散点图,很明显不是线性可分的:

图 17.3:二元分类的合成数据

隐藏层

隐藏层h使用权重 W^h 将二维输入投影到三维空间,并通过偏置向量 b^h 将结果平移。为了执行这个仿射变换,隐藏层权重由一个矩阵 W^h 表示,而隐藏层偏置向量由一个三维向量表示:

隐藏层激活 H 是通过将输入数据与加入偏置向量后的权重的点积应用于 sigmoid 函数而得到的:

要使用 NumPy 实现隐藏层,我们首先定义 logistic sigmoid 函数:

def logistic(z):
   """Logistic function."""
   return 1 / (1 + np.exp(-z)) 

然后我们定义一个函数,根据相关的输入、权重和偏置值计算隐藏层激活:

def hidden_layer(input_data, weights, bias):
   """Compute hidden activations"""
   return logistic(input_data @ weights + bias) 

输出层

输出层使用一个 权重矩阵 W^o 和一个二维偏置向量 b^o 将三维隐藏层激活 H 压缩回两维:

隐藏层输出的线性组合导致一个 矩阵 Z^o:

输出层激活由 softmax 函数 计算,该函数将 Z^o 规范化以符合离散概率分布的惯例:

我们在 Python 中创建一个 softmax 函数如下所示:

def softmax(z):
   """Softmax function"""
   return np.exp(z) / np.sum(np.exp(z), axis=1, keepdims=True) 

如此定义,输出层激活取决于隐藏层激活和输出层权重和偏置:

def output_layer(hidden_activations, weights, bias):
   """Compute the output y_hat"""
   return softmax(hidden_activations @ weights + bias) 

现在我们拥有了集成层并直接从输入计算 NN 输出所需的所有组件。

正向传播

forward_prop 函数将前述操作组合起来,从输入数据中产生输出激活作为权重和偏置的函数:

def forward_prop(data, hidden_weights, hidden_bias, output_weights, output_bias):
   """Neural network as function."""
   hidden_activations = hidden_layer(data, hidden_weights, hidden_bias)
   return output_layer(hidden_activations, output_weights, output_bias) 

predict 函数根据权重、偏置和输入数据产生二元类别预测:

def predict(data, hidden_weights, hidden_bias, output_weights, output_bias):
   """Predicts class 0 or 1"""
   y_pred_proba = forward_prop(data,
                               hidden_weights,
                               hidden_bias,
                               output_weights,
                               output_bias)
   return np.around(y_pred_proba) 

交叉熵损失函数

最后一块是根据给定标签评估 NN 输出的损失函数。损失函数 J 使用交叉熵损失 ,它对每个类别 c 的预测与实际结果的偏差进行求和:

在 Python 中,它的形式如下:

def loss(y_hat, y_true):
   """Cross-entropy"""
   return - (y_true * np.log(y_hat)).sum() 

如何使用 Python 实现反向传播

要使用反向传播更新神经网络的权重和偏置值,我们需要计算损失函数的梯度。梯度表示损失函数相对于目标参数的偏导数。

如何计算梯度

NN 组成一组嵌套函数,如前所述。因此,使用微积分的链式法则计算损失函数相对于内部隐藏参数的梯度。

对于标量值,给定函数 z = h(x) 和 y = o(h(x)) = o (z),我们使用链式法则计算 y 相对于 x 的导数如下:

对于向量,有 ,使得隐藏层 h 从 R^n 映射到 R^m,z = h(x),y = o (z),我们得到:

我们可以使用矩阵表示更简洁地表达这一点,使用 h 的雅可比矩阵

它包含了对于 z 的每个 m 组件相对于每个 n 输入 x 的偏导数。y 相对于 x 的梯度 包含了所有的偏导数,因此可以写成:

损失函数的梯度

交叉熵损失函数 J 对于每个输出层激活 i = 1, ..., N 的导数是一个非常简单的表达式(详见笔记本),如下左侧为标量值,右侧为矩阵表示:

我们相应地定义loss_gradient函数:

def loss_gradient(y_hat, y_true):
   """output layer gradient"""
   return y_hat - y_true 

输出层梯度

要将更新传播回输出层权重,我们使用损失函数 J 对于权重矩阵的梯度:

和偏置项的梯度:

我们现在可以相应地定义output_weight_gradientoutput_bias_gradient,两者都以损失梯度 作为输入:

def output_weight_gradient(H, loss_grad):
   """Gradients for the output layer weights"""
   return  H.T @ loss_grad
def output_bias_gradient(loss_grad):
   """Gradients for the output layer bias"""
   return np.sum(loss_grad, axis=0, keepdims=True) 

隐藏层梯度

损失函数对于隐藏层值的梯度计算如下,其中 表示逐元素的矩阵乘积:

我们定义一个hidden_layer_gradient函数来编码这个结果:

def hidden_layer_gradient(H, out_weights, loss_grad):
   """Error at the hidden layer.
   H * (1-H) * (E . Wo^T)"""
   return H * (1 - H) * (loss_grad @ out_weights.T) 

隐藏层权重和偏置的梯度为:

相应的函数是:

def hidden_weight_gradient(X, hidden_layer_grad):
   """Gradient for the weight parameters at the hidden layer"""
   return X.T @ hidden_layer_grad

def hidden_bias_gradient(hidden_layer_grad):
   """Gradient for the bias parameters at the output layer"""
   return np.sum(hidden_layer_grad, axis=0, keepdims=True) 

将所有内容整合起来

为了准备训练我们的网络,我们创建一个函数,该函数结合了先前的梯度定义,并从训练数据和标签以及当前的权重和偏置值计算相关的权重和偏置更新:

def compute_gradients(X, y_true, w_h, b_h, w_o, b_o):
   """Evaluate gradients for parameter updates"""
   # Compute hidden and output layer activations
   hidden_activations = hidden_layer(X, w_h, b_h)
   y_hat = output_layer(hidden_activations, w_o, b_o)
   # Compute the output layer gradients
   loss_grad = loss_gradient(y_hat, y_true)
   out_weight_grad = output_weight_gradient(hidden_activations, loss_grad)
   out_bias_grad = output_bias_gradient(loss_grad)
   # Compute the hidden layer gradients
   hidden_layer_grad = hidden_layer_gradient(hidden_activations,
                                             w_o, loss_grad)
   hidden_weight_grad = hidden_weight_gradient(X, hidden_layer_grad)
   hidden_bias_grad = hidden_bias_gradient(hidden_layer_grad)
   return [hidden_weight_grad, hidden_bias_grad, out_weight_grad, out_bias_grad] 

测试梯度

笔记本包含一个测试函数,该函数将先前使用多元微积分解析导出的梯度与我们通过轻微扰动单个参数获得的数值估计进行比较。测试函数验证了输出值的变化与分析梯度估计的变化类似。

使用 Python 实现动量更新

要将动量合并到参数更新中,定义一个update_momentum函数,该函数将我们刚刚使用的compute_gradients函数的结果与每个参数矩阵的最新动量更新组合起来:

def update_momentum(X, y_true, param_list, Ms, momentum_term, learning_rate):
   """Compute updates with momentum."""
   gradients = compute_gradients(X, y_true, *param_list)
   return [momentum_term * momentum - learning_rate * grads
           for momentum, grads in zip(Ms, gradients)] 

update_params函数执行实际的更新:

def update_params(param_list, Ms):
   """Update the parameters."""
   return [P + M for P, M in zip(param_list, Ms)] 

训练网络

要训练网络,我们首先使用标准正态分布随机初始化所有网络参数(参见笔记本)。对于给定的迭代次数或周期,我们运行动量更新并计算训练损失如下:

def train_network(iterations=1000, lr=.01, mf=.1):
   # Initialize weights and biases
   param_list = list(initialize_weights())
   # Momentum Matrices = [MWh, Mbh, MWo, Mbo]
   Ms = [np.zeros_like(M) for M in param_list]
   train_loss = [loss(forward_prop(X, *param_list), Y)]
   for i in range(iterations):
       # Update the moments and the parameters
       Ms = update_momentum(X, Y, param_list, Ms, mf, lr)
       param_list = update_params(param_list, Ms)
       train_loss.append(loss(forward_prop(X, *param_list), Y))
   return param_list, train_loss 

图 17.4 绘制了使用动量项为 0.5 和学习率为 1e-4 的 50,000 个训练样本进行 50,000 次迭代的训练损失。它显示损失需要超过 5,000 次迭代才开始下降,但然后下降速度非常快。我们没有使用 SGD,这可能会显著加速收敛。

图 17.4:每次迭代的训练损失

图 17.5 中的图表展示了具有三维隐藏层的神经网络从二维数据中学习的函数,这些数据有两个不是线性可分的类。左侧面板显示了源数据和决策边界,它误分类了非常少的数据点,并且随着持续训练将进一步改善。

中央面板显示了隐藏层学习的输入数据的表示。网络学习权重,以便将输入从二维投影到三维,从而使得两个类能够线性分离。右侧图显示了输出层如何以 0.5 的输出维度值作为线性分离的截止值:

图 17.5:可视化神经网络学习的函数

总结:我们已经看到一个非常简单的网络,只有一个包含三个节点的隐藏层和总共 17 个参数,能够学习如何使用反向传播和带动量的梯度下降来解决非线性分类问题。

我们接下来将回顾如何使用流行的 DL 库,这些库有助于设计复杂的架构并进行快速训练,同时使用复杂技术来防止过拟合并评估结果。

流行的深度学习库

目前最流行的 DL 库是 TensorFlow(由 Google 支持)、Keras(由 Francois Chollet 领导,现在在 Google)和 PyTorch(由 Facebook 支持)。开发非常活跃,截至 2020 年 3 月,PyTorch 版本为 1.4,TensorFlow 版本为 2.2。TensorFlow 2.0 将 Keras 作为其主要接口,有效地将两个库合并为一个。

所有的库都提供了我们在本章中讨论过的设计选择、正则化方法和反向传播优化。它们还能够在一个或多个图形处理单元GPU)上进行快速训练。这些库在重点上略有不同,TensorFlow 最初设计用于在生产中部署,在工业界很普遍,而 PyTorch 在学术研究者中很受欢迎;然而,接口正在逐渐趋同。

我们将使用与上一节相同的网络架构和数据集来说明 TensorFlow 和 PyTorch 的使用。

利用 GPU 加速

DL 非常计算密集,而且好的结果通常需要大型数据集。因此,模型训练和评估可能会变得非常耗时。GPU 高度优化了深度学习模型所需的矩阵运算,并且往往具有更多的处理能力,使得加速 10 倍或更多不罕见。

所有流行的深度学习库都支持使用 GPU,并且一些还允许在多个 GPU 上进行并行训练。最常见的 GPU 类型由 NVIDIA 生产,配置需要安装和设置 CUDA 环境。这个过程不断发展,取决于您的计算环境,可能会有一定挑战。

利用 GPU 的更简单方法是通过 Docker 虚拟化平台。有大量的镜像可供您在由 Docker 管理的本地容器中运行,避免了您可能遇到的许多驱动程序和版本冲突。TensorFlow 在其网站上提供了 Docker 镜像,也可以与 Keras 一起使用。

在 GitHub 上查看 DL 笔记本和安装目录中的参考和相关说明。

如何使用 TensorFlow 2

TensorFlow 在 2015 年 9 月发布后不久成为了领先的深度学习库,在 PyTorch 之前一年。TensorFlow 2 简化了随着时间推移变得越来越复杂的 API,通过将 Keras API 作为其主要接口。

Keras 被设计为高级 API,以加速使用 TensorFlow、Theano 或 CNTK 等计算后端设计和训练深度神经网络的迭代工作流程。它在 2017 年被整合到 TensorFlow 中。您还可以结合两个库的代码,以利用 Keras 的高级抽象以及定制的 TensorFlow 图操作。

此外,TensorFlow 采用了即时执行。以前,您需要为编译成优化操作的完整计算图进行定义。运行编译后的图形需要配置会话并提供所需的数据。在即时执行下,您可以像常规 Python 代码一样逐行运行 TensorFlow 操作。

Keras 支持稍微简单的 Sequential API 和更灵活的 Functional API。我们将在此介绍前者,并在后续章节中的更复杂示例中使用 Functional API。

要创建模型,我们只需要实例化一个Sequential对象,并提供一个包含标准层序列及其配置的列表,包括单元数、激活函数类型或名称。

第一个隐藏层需要关于它从输入层通过input_shape参数接收到的矩阵中特征数的信息。在我们的简单案例中,只有两个。Keras 通过我们稍后将传递给本节中的fit方法的batch_size参数在训练期间推断需要处理的行数。TensorFlow 通过前一层的units参数推断接收到的输入的大小:

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Activation
model = Sequential([
    Dense(units=3, input_shape=(2,), name='hidden'),
    Activation('sigmoid', name='logistic'),
    Dense(2, name='output'),
    Activation('softmax', name='softmax'),
]) 

Keras API 提供了许多标准构建模块,包括循环和卷积层,各种正则化选项,一系列损失函数和优化器,以及预处理,可视化和日志记录(请参阅 GitHub 上的 TensorFlow 文档链接以供参考)。它也是可扩展的。

模型的summary方法生成对网络架构的简明描述,包括层类型和形状的列表以及参数数量:

model.summary()
Layer (type)                 Output Shape              Param #   
=================================================================
hidden (Dense)               (None, 3)                 9         
_________________________________________________________________
logistic (Activation)        (None, 3)                 0         
_________________________________________________________________
output (Dense)               (None, 2)                 8         
_________________________________________________________________
softmax (Activation)         (None, 2)                 0         
=================================================================
Total params: 17
Trainable params: 17
Non-trainable params: 0 

接下来,我们编译 Sequential 模型以配置学习过程。为此,我们定义优化器,损失函数以及一种或多种在训练期间监视的性能指标:

model.compile(optimizer='rmsprop',
             loss='binary_crossentropy',
             metrics=['accuracy']) 

Keras 使用回调函数来在训练期间启用某些功能,例如在 TensorBoard 中记录信息以供交互式显示(见下一节):

tb_callback = TensorBoard(log_dir='./tensorboard',
                         histogram_freq=1,
                         write_graph=True,
                         write_images=True) 

要训练模型,我们调用它的fit方法,并在训练数据之外传递多个参数:

model.fit(X, Y,
         epochs=25,
         validation_split=.2,
         batch_size=128,
         verbose=1,
         callbacks=[tb_callback]) 

请参阅笔记本以可视化决策边界,它类似于我们先前的手动网络实现的结果。不过,使用 TensorFlow 进行训练速度快了几个数量级。

如何使用 TensorBoard

TensorBoard 是 TensorFlow 附带的一套优秀的可视化工具,包括可视化工具以简化对 NNs 的理解,调试和优化。

您可以使用它来可视化计算图,绘制各种执行和性能指标,甚至可视化网络处理的图像数据。它还允许比较不同的训练运行。

运行how_to_use_tensorflow笔记本时,需要安装 TensorFlow,然后可以从命令行启动 TensorBoard:

tensorboard --logdir=/full_path_to_your_logs ## e.g. ./tensorboard 

或者,您可以首先加载扩展程序,然后通过引用log目录类似地启动 TensorBoard,在您的笔记本中使用它:

%load_ext tensorboard
%tensorboard --logdir tensorboard/ 

首先,可视化包括训练和验证指标(请参阅图 17.6的左面板)。

此外,您还可以查看各个时期的权重和偏差的直方图(图 17.6 的右面板;时期从后到前演变)。这很有用,因为它允许您监视反向传播是否成功地在学习过程中调整权重以及它们是否收敛。

权重的值应该在多个时期内从它们的初始化值改变并最终稳定:

图 17.6:TensorBoard 学习过程可视化

TensorBoard 还允许您显示和交互式探索网络的计算图,通过单击各个节点从高级结构向下钻取到底层操作。我们简单示例架构的可视化(请参阅笔记本)已经包含了许多组件,但在调试时非常有用。有关更详细的参考,请参阅 GitHub 上更详细的教程链接。

如何使用 PyTorch 1.4

PyTorch 是在由 Yann LeCunn 领导的Facebook AI 研究FAIR)团队开发的,并于 2016 年 9 月发布了第一个 alpha 版本。它与 NumPy 等 Python 库深度集成,可以用于扩展其功能,具有强大的 GPU 加速和使用其 autograd 系统进行自动微分。通过更低级别的 API,它提供比 Keras 更细粒度的控制,并且主要用作深度学习研究平台,但也可以在启用 GPU 计算的同时替代 NumPy。

它采用即时执行,与 Theano 或 TensorFlow 等使用静态计算图的方式形成对比。与最初为了快速但静态执行而定义和编译网络不同,它依赖于其 autograd 包来自动对张量操作进行微分;也就是说,它在“飞行中”计算梯度,以便更轻松地部分修改网络结构。这称为按运行定义,意味着反向传播是由代码运行方式定义的,这又意味着每次迭代都可能不同。PyTorch 文档提供了关于此的详细教程。

结合结果灵活性和直观的 Python 首选界面以及执行速度,这导致了它的迅速普及和众多支持库的开发,这些支持库扩展了其功能。

让我们通过实现我们的简单网络架构来看看 PyTorch 和 autograd 如何工作(详细信息请参见how_to_use_pytorch笔记本)。

如何创建 PyTorch 的 DataLoader

我们首先将 NumPy 或 pandas 输入数据转换为torch张量。从 NumPy 到 PyTorch 的转换非常简单:

import torch
X_tensor = torch.from_numpy(X)
y_tensor = torch.from_numpy(y)
X_tensor.shape, y_tensor.shape
(torch.Size([50000, 2]), torch.Size([50000])) 

我们可以使用这些 PyTorch 张量首先实例化一个TensorDataset,然后在第二步实例化一个包含有关batch_size信息的DataLoader

import torch.utils.data as utils
dataset = utils.TensorDataset(X_tensor,y_tensor)
dataloader = utils.DataLoader(dataset,
                              batch_size=batch_size,
                              shuffle=True) 

如何定义神经网络架构

PyTorch 使用Net()类定义了一个 NN 架构。其核心元素是forward函数。autograd 自动定义了相应的backward函数来计算梯度。

任何合法的张量操作都可以用于forward函数,提供了设计灵活性的记录。在我们的简单情况下,我们只是在初始化其属性后通过功能输入输出关系链接张量:

import torch.nn as nn
class Net(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes):
        super(Net, self).__init__()  # Inherited from nn.Module
        self.fc1 = nn.Linear(input_size, hidden_size)  
        self.logistic = nn.LogSigmoid()                          
        self.fc2 = nn.Linear(hidden_size, num_classes)
        self.softmax = nn.Softmax(dim=1)

    def forward(self, x):
        """Forward pass: stacking each layer together"""
        out = self.fc1(x)
        out = self.logistic(out)
        out = self.fc2(out)
        out = self.softmax(out)
        return out 

然后我们实例化一个Net()对象,并且可以按照以下方式检查其架构:

net = Net(input_size, hidden_size, num_classes)
net
Net(
  (fc1): Linear(in_features=2, out_features=3, bias=True)
  (logistic): LogSigmoid()
  (fc2): Linear(in_features=3, out_features=2, bias=True)
  (softmax): Softmax()
) 

为了说明即时执行,我们还可以检查第一个张量中初始化的参数:

list(net.parameters())[0]
Parameter containing:
tensor([[ 0.3008, -0.2117],
        [-0.5846, -0.1690],
        [-0.6639,  0.1887]], requires_grad=True) 

要启用 GPU 处理,您可以使用net.cuda()。请参阅 PyTorch 文档以将张量放置在 CPU 和/或一个或多个 GPU 单元上。

我们还需要定义损失函数和优化器,使用一些内置选项:

criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(net.parameters(), lr=learning_rate) 

如何训练模型

模型训练包括对每个 epoch 的外循环,即对训练数据的每次传递,以及对 DataLoader 产生的批次的内循环。这执行学习算法的前向和后向传递。需要小心地调整数据类型以满足各种对象和函数的要求;例如,标签需要是整数,特征应该是 float 类型:

for epoch in range(num_epochs):
    print(epoch)
    for i, (features, label) in enumerate(dataloader):

        features = Variable(features.float())         
        label = Variable(label.long())
        # Initialize the hidden weights
        optimizer.zero_grad()  

        # Forward pass: compute output given features
        outputs = net(features)

        # Compute the loss
        loss = criterion(outputs, label)
        # Backward pass: compute the gradients
        loss.backward()
        # Update the weights
        optimizer.step() 

笔记本还包含一个示例,使用 livelossplot 包绘制损失,这是由 Keras 提供的开箱即用的功能。

如何评估模型预测

要从我们训练的模型中获得预测,我们传递特征数据并将预测转换为 NumPy 数组。我们获得了每个类别的 softmax 概率:

test_value = Variable(torch.from_numpy(X)).float()
prediction = net(test_value).data.numpy()
Prediction.shape
(50000, 2) 

从这里开始,我们可以像以前一样继续计算损失指标或可视化结果,再次生成我们之前找到的决策边界的一个版本。

可选方案

对深度学习的巨大兴趣导致了几个竞争性库的开发,这些库促进了神经网络的设计和训练。最突出的包括以下示例(还请参阅 GitHub 上的参考资料)。

Apache MXNet

MXNet,由 Apache 基金会孵化,是一个用于训练和部署深度神经网络的开源深度学习软件框架。它专注于可扩展性和快速模型训练。他们包括了 Gluon 高级接口,使得原型设计、训练和部署深度学习模型变得容易。MXNet 已被亚马逊选为 AWS 上的深度学习工具。

Microsoft Cognitive Toolkit(CNTK)

Cognitive Toolkit,以前称为 CNTK,是微软对深度学习库的贡献。它将神经网络描述为通过有向图的一系列计算步骤,类似于 TensorFlow。在这个有向图中,叶节点代表输入值或网络参数,而其他节点代表对它们的输入进行的矩阵操作。CNTK 允许用户构建和组合从深度前馈神经网络、卷积网络到循环网络(RNNs/LSTMs)的流行模型架构。

fastai

fastai 库旨在使用现代最佳实践简化训练快速而准确的神经网络。这些实践是从该公司对深度学习的研究中产生的,该公司提供了免费的软件和相关课程。fastai 支持处理图像、文本、表格和协同过滤数据的模型。

为长短策略优化神经网络

在实践中,我们需要探索对神经网络架构的设计选项以及我们如何训练它的变化,因为我们从一开始就无法确定哪种配置最适合数据。在本节中,我们将探讨使用在 第十二章 中开发的数据集预测每日股票回报的简单前馈神经网络的各种架构(请参见该章节的 GitHub 目录中的笔记本 preparing_the_model_data)。

为此,我们将定义一个函数,根据几个架构输入参数返回一个 TensorFlow 模型,并使用我们在第七章线性模型 - 从风险因素到收益预测中介绍的MultipleTimeSeriesCV交叉验证备选设计。为了评估模型预测的信号质量,我们构建了一个基于模型集成的简单基于排名的多头空头策略,在样本内交叉验证期间表现最佳的模型基础上。为了限制假发现的风险,我们然后评估该策略在样本外测试期间的表现。

有关详细信息,请参见optimizing_a_NN_architecture_for_trading笔记本。

工程特征以预测每日股票收益

为了开发我们的交易策略,我们使用了从 2010 年到 2017 年的八年期间的 995 只美国股票的日收益。我们将使用在第十二章提升您的交易策略中开发的特征,其中包括波动率和动量因子,以及带有横截面和部门排名的滞后收益。我们按如下方式加载数据:

data = pd.read_hdf('../12_gradient_boosting_machines/data/data.h5', 
                   'model_data').dropna()
outcomes = data.filter(like='fwd').columns.tolist()
lookahead = 1
outcome= f'r{lookahead:02}_fwd'
X = data.loc[idx[:, :'2017'], :].drop(outcomes, axis=1)
y = data.loc[idx[:, :'2017'], outcome] 

定义一个神经网络架构框架

为了自动化生成我们的 TensorFlow 模型,我们创建了一个函数,根据后续可以在交叉验证迭代期间传递的参数来构建和编译模型。

以下的make_model函数说明了如何灵活定义搜索过程的各种架构元素。dense_layers参数将网络的深度和宽度定义为整数列表。我们还使用dropout进行正则化,表示为在[0,1]范围内的浮点数,用于定义在训练迭代中排除给定单元的概率:

def make_model(dense_layers, activation, dropout):
    '''Creates a multi-layer perceptron model

    dense_layers: List of layer sizes; one number per layer
    '''
    model = Sequential()
    for i, layer_size in enumerate(dense_layers, 1):
        if i == 1:
            model.add(Dense(layer_size, input_dim=X_cv.shape[1]))
            model.add(Activation(activation))
        else:
            model.add(Dense(layer_size))
            model.add(Activation(activation))
    model.add(Dropout(dropout))
    model.add(Dense(1))
    model.compile(loss='mean_squared_error',
                  optimizer='Adam')
    return model 

现在我们可以转向交叉验证过程,评估各种神经网络架构。

交叉验证设计选项以调整 NN

我们使用MultipleTimeSeriesCV将数据分割为滚动训练和验证集,包括 24 * 12 个月的数据,同时保留最后 12 * 21 天的数据(从 2016 年 11 月 30 日开始)作为保留测试。我们对每个模型进行 48 个 21 天期的训练,并在 3 个 21 天期内评估其结果,这意味着在交叉验证和测试期间共有 12 个拆分:

n_splits = 12
train_period_length=21 * 12 * 4
test_period_length=21 * 3
cv = MultipleTimeSeriesCV(n_splits=n_splits,
                          train_period_length=train_period_length,
                          test_period_length=test_period_length,
                          lookahead=lookahead) 

接下来,我们为交叉验证定义一组配置。这些包括两个隐藏层和 dropout 概率的几个选项;我们只会使用 tanh 激活,因为一次试验没有显示出与 ReLU 相比的显著差异。(我们也可以尝试不同的优化器。但我建议您不要运行这个实验,以限制已经是计算密集型工作的内容):

dense_layer_opts = [(16, 8), (32, 16), (32, 32), (64, 32)]
dropout_opts = [0, .1, .2]
param_grid = list(product(dense_layer_opts, activation_opts, dropout_opts))
np.random.shuffle(param_grid)
len(param_grid)
12 

要运行交叉验证,我们定义一个函数,根据MultipleTimeSeriesCV生成的整数索引来生成训练和验证数据,如下所示:

def get_train_valid_data(X, y, train_idx, test_idx):
    x_train, y_train = X.iloc[train_idx, :], y.iloc[train_idx]
    x_val, y_val = X.iloc[test_idx, :], y.iloc[test_idx]
    return x_train, y_train, x_val, y_val 

在交叉验证期间,我们使用之前定义的网格中的一组参数训练一个模型 20 个时期。每个时期结束后,我们存储一个包含学习权重的checkpoint,这样我们就可以重新加载它们,以快速生成最佳配置的预测,而无需重新训练。

每个时期结束后,我们计算并存储验证集的信息系数IC)按天计算:

ic = []
scaler = StandardScaler()
for params in param_grid:
    dense_layers, activation, dropout = params
    for batch_size in [64, 256]:
        checkpoint_path = checkpoint_dir / str(dense_layers) / activation /
                          str(dropout) / str(batch_size)
        for fold, (train_idx, test_idx) in enumerate(cv.split(X_cv)):
            x_train, y_train, x_val, y_val = get_train_valid_data(X_cv, y_cv,
                                             train_idx, test_idx)
            x_train = scaler.fit_transform(x_train)
            x_val = scaler.transform(x_val)
            preds = y_val.to_frame('actual')
            r = pd.DataFrame(index=y_val.groupby(level='date').size().index)
            model = make_model(dense_layers, activation, dropout)
            for epoch in range(20):            
                model.fit(x_train, y_train,
                          batch_size=batch_size,
                          epochs=1, validation_data=(x_val, y_val))
                model.save_weights(
                    (checkpoint_path / f'ckpt_{fold}_{epoch}').as_posix())
                preds[epoch] = model.predict(x_val).squeeze()
                r[epoch] = preds.groupby(level='date').apply(lambda x: spearmanr(x.actual, x[epoch])[0]).to_frame(epoch)
            ic.append(r.assign(dense_layers=str(dense_layers), 
                               activation=activation, 
                               dropout=dropout,
                               batch_size=batch_size,
                               fold=fold)) 

使用 NVIDIA GTX 1080 GPU,20 个时期的批处理大小为 64 个样本的计算时间超过 1 小时,而批处理大小为 256 个样本则约为 20 分钟。

评估预测性能

让我们首先看一下在交叉验证期间实现了最高中位数日 IC 的五个模型。以下代码计算这些值:

dates = sorted(ic.index.unique())
cv_period = 24 * 21
cv_dates = dates[:cv_period]
ic_cv = ic.loc[cv_dates]
(ic_cv.drop('fold', axis=1).groupby(params).median().stack()
 .to_frame('ic').reset_index().rename(columns={'level_3': 'epoch'})
 .nlargest(n=5, columns='ic')) 

结果表显示,使用 32 个单位的 32 个架构在两层中以及在第一/第二层中分别使用 16/8 的架构表现最佳。这些模型还使用了dropout,并且使用给定数量的时期对所有折叠进行了 64 个样本的批处理训练。中位数 IC 值在 0.0236 和 0.0246 之间变化:

稠密层 丢失率 批次大小 时期 IC
(32, 32) 0.1 64 7 0.0246
(16, 8) 0.2 64 14 0.0241
(16, 8) 0.1 64 3 0.0238
(32, 32) 0.1 64 10 0.0237
(16, 8) 0.2 256 3 0.0236

接下来,我们将看看参数选择如何影响预测性能。

首先,我们通过时期可视化不同配置的每折日信息系数(平均值),以了解训练持续时间如何影响预测准确性。然而,在图 17.7中的图表突出显示出一些明确的模式;IC 在模型之间变化很小,并且在时期之间并没有特别系统地变化:

图 17.7:各种模型配置的信息系数

为了获得更具统计意义的见解,我们使用普通最小二乘法OLS)进行线性回归(参见第七章线性模型 - 从风险因素到收益预测),使用关于层、丢失率和批次大小选择以及每个时期的虚拟变量:

data = pd.melt(ic, id_vars=params, var_name='epoch', value_name='ic')
data = pd.get_dummies(data, columns=['epoch'] + params, drop_first=True)
model = sm.OLS(endog=data.ic, exog=sm.add_constant(data.drop('ic', axis=1))) 

图 17.8中的图表绘制了每个回归系数的置信区间;如果不包含零,则系数在百分之五的水平上是显著的。y 轴上的 IC 值反映了与舍弃每个虚拟变量类别的配置的样本平均值相对差异(0.0027,p 值:0.017)。

在所有配置中,批处理大小为 256 和丢失率为 0.2 对性能产生了显著(但微小)的正面影响。类似地,训练七个时期产生了略微优越的结果。根据 F 统计量,回归总体上是显著的,但 R2 值非常低,接近零,强调了数据中噪音相对于参数选择传递的信号的高程度。

图 17.8:OLS 系数和置信区间

基于集成信号回测策略

要将我们的 NN 模型转换为交易策略,我们生成预测,评估其信号质量,创建定义如何根据这些预测进行交易的规则,并回测实施这些规则的策略的性能。请参阅笔记本backtesting_with_zipline以获取本节中的代码示例。

集成预测以产生可交易信号

为了减少预测的方差并对样本内过拟合进行套期保值,我们结合了在前一节表中列出的三个最佳模型的预测,并平均了结果。

为此,我们定义以下generate_predictions()函数,该函数接收模型参数作为输入,加载所需时期模型的权重,并为交叉验证和样本外期间创建预测(这里仅显示关键内容以节省空间):

def generate_predictions(dense_layers, activation, dropout,
                         batch_size, epoch):
    checkpoint_dir = Path('logs')
    checkpoint_path = checkpoint_dir / dense_layers / activation /
                      str(dropout) / str(batch_size)

    for fold, (train_idx, test_idx) in enumerate(cv.split(X_cv)):
        x_train, y_train, x_val, y_val = get_train_valid_data(X_cv, y_cv, 
                                                              train_idx, 
                                                              test_idx)
        x_val = scaler.fit(x_train).transform(x_val)
        model = make_model(dense_layers, activation, dropout, input_dim)
        status = model.load_weights(
            (checkpoint_path / f'ckpt_{fold}_{epoch}').as_posix())
        status.expect_partial()
        predictions.append(pd.Series(model.predict(x_val).squeeze(), 
                                     index=y_val.index))
    return pd.concat(predictions) 

我们使用 Alphalens 和 Zipline 回测来存储评估结果。

使用 Alphalens 评估信号质量

为了对集成模型预测的信号内容有所了解,我们使用 Alphalens 计算了根据预测分位数区分的五个等权重投资组合的回报差异(见图 17.9)。在一个交易日的持有期间,最高分位和最低分位之间的差距约为 8 个基点,这意味着 alpha 为 0.094,beta 为 0.107:

图 17.9:信号质量评估

使用 Zipline 回测策略

基于 Alphalens 分析,我们的策略将为具有最高正预测回报和最低负预测回报的 50 只股票输入长和短头寸,只要每边至少有 10 个选项。该策略每天进行交易。

图 17.10中的图表显示,该策略在样本内和样本外表现良好(在交易成本之前):

图 17.10:样本内和样本外回测表现

它在 36 个月的期间内产生了年化收益率为 22.8%,在样本内 24 个月为 16.5%,在样本外 12 个月为 35.7%。夏普比率在样本内为 0.72,在样本外为 2.15,提供了 0.18(0.29)的 alpha 和 0.24(0.16)的 beta 在/样本外。

如何进一步改进结果

相对简单的架构产生了一些有希望的结果。要进一步提高性能,首先可以添加新功能和更多数据到模型中。

或者,您可以使用更复杂的架构,包括适用于顺序数据的 RNN 和 CNN,而香草前馈 NN 并不设计捕获特征的有序性。

我们将在下一章中转向这些专用架构。

摘要

在本章中,我们将 DL(深度学习)介绍为一种从高维、非结构化数据中提取层次特征的表征学习形式。我们看到了如何使用 NumPy 设计、训练和正则化前馈神经网络。我们演示了如何使用流行的 DL 库 PyTorch 和 TensorFlow,这些库适用于从快速原型到生产部署的用例。

最重要的是,我们使用 TensorFlow 设计和调优了一个神经网络(NN),能够在样本内和样本外期间生成可交易的信号,从而获得了可观的回报。

在下一章中,我们将探讨 CNNs,它们特别适用于图像数据,但也适用于序列数据。

第十八章:用于金融时间序列和卫星图像的 CNNs

在本章中,我们介绍了一系列我们将在 第四部分 中涵盖的专业深度学习架构中的第一个。深度卷积神经网络CNNs)已在各种计算机视觉任务中实现了超人类的表现,如分类图像和视频以及检测和识别图像中的对象。CNNs 也可以从具有与图像数据相似特征的时间序列数据中提取信号,并已成功应用于语音识别(Abdel-Hamid 等人,2014 年)。此外,它们已被证明在各个领域的时间序列分类中提供了最先进的性能(Ismail Fawaz 等人,2019 年)。

CNNs 是根据一种称为卷积的线性代数操作命名的,该操作在其至少一个层次中替代了前馈网络(在上一章中讨论过)中典型的矩阵乘法。我们将展示卷积的工作原理以及它们为什么特别适合具有某种规则结构的数据,通常在图像中发现,但也存在于时间序列中。

CNN 架构的研究进展非常迅速,不断涌现出可以提高基准性能的新架构。我们将描述一组成功应用中一直使用的构建块,并演示如何通过使用对于输入更接近的 CNN 层的预训练权重来加快学习,同时微调最终层以完成特定任务。我们还将说明如何将 CNNs 用于特定的计算机视觉任务,如目标检测

CNNs 可以通过从图像或(多个)时间序列数据中生成信号来构建交易策略

  • 卫星数据可以通过农业区域、矿区或像油轮这样的交通网络的航拍图像来预示未来商品趋势,包括某些作物或原材料的供应。例如,来自购物中心的监控摄像头视频可以用来追踪和预测消费者活动。

  • 时间序列数据涵盖了非常广泛的数据来源,CNNs 利用其与图像的结构相似性已被证明可以提供高质量的分类结果。

我们将创建一个基于 CNN 预测的交易策略,该策略使用经过特意格式化的像图像一样的时间序列数据,并演示如何构建一个用于分类卫星图像的 CNN。

更具体地,在本章中,您将学习以下内容:

  • 如何使用几个构建块有效地模拟网格状数据的 CNNs

  • 使用 TensorFlow 对图像和时间序列数据进行训练、调整和正则化的 CNNs

  • 使用迁移学习来简化 CNNs,即使数据较少

  • 使用格式化为图像的时间序列数据的 CNN 对收益预测设计交易策略

  • 如何分类卫星图像

你可以在对应的 GitHub 仓库目录中找到本章的代码示例和其他资源链接。笔记本包括图像的彩色版本。

如何训练卷积神经网络来建模网格数据

卷积神经网络在概念上类似于前馈 神经网络NNs):它们由带有权重和偏置的单元组成,训练过程调整这些参数,以根据损失函数优化给定输入的网络输出。它们最常用于分类。每个单元使用其参数对输入数据或从其他单元接收到的激活应用线性操作,通常跟随非线性变换。

整体网络模型了一个 可微分函数,它将原始数据,如图像像素,映射到类概率,使用类似 softmax 的输出激活函数。卷积神经网络使用诸如交叉熵损失之类的目标函数来用单个指标衡量输出的质量。它们还依赖于相对于网络参数的损失的梯度,通过反向传播进行学习。

全连接层的前馈神经网络在具有大量像素值的高维图像数据上不易扩展。即使是我们将在下一节中使用的 CIFAR-10 数据集中包含的低分辨率图像也包含 32×32 像素,每个像素由 8 位表示多达 256 种不同的颜色值。例如,对于 RGB 色彩模型的红、绿和蓝三个通道,完全连接的输入层中的单个单元意味着 32 × 32 × 3 = 3,072 个权重。对于标准分辨率 640×480 像素来说,单个输入单元已经产生接近 100 万个权重。具有几层有意义宽度的深度结构很快导致参数数量激增,在训练期间过拟合几乎是肯定的。

全连接的前馈神经网络对输入数据的局部结构不做任何假设,因此对特征进行任意重排序对训练结果没有影响。相比之下,卷积神经网络做出了 关键假设,即 数据具有网格状拓扑结构,并且 局部结构很重要。换句话说,它们编码了这样一种假设:输入具有图像数据通常具有的结构:像素形成一个二维网格,可能有多个通道来表示颜色信号的组成部分。此外,附近像素的值可能比遥远的数据点更相关于检测边缘和角落等关键特征。自然地,最初的卷积神经网络应用,如手写识别,集中在图像数据上。

随着时间的推移,研究人员逐渐认识到时间序列数据中的类似特征,扩大了 CNN 的有效使用范围。时间序列数据包含在时间轴上创建的间隔的测量值,形成沿时间轴的一维网格,例如给定股票的滞后回报。 还可以具有第二个维度,该维度具有此股票和相同时间段的其他特征。 最后,我们可以使用第三维来表示其他股票。

超出图像的常见 CNN 用例还包括音频数据,无论是在时间域中的一维波形还是在傅里叶变换后的频率域中的二维频谱。CNN 也在 AlphaGo 中扮演了关键角色,这是第一个击败人类的围棋算法,在那里它们评估了网格状棋盘上的不同位置。

编码网格状拓扑假设的最重要元素是给 CNN 命名的卷积操作,与池化相结合。我们将看到,关于输入和输出数据之间的功能关系的具体假设意味着 CNN 需要更少的参数并更有效地计算。

在本节中,我们将解释卷积和池化层如何学习提取局部特征的过滤器,以及为什么这些操作特别适用于具有刚刚描述的结构的数据。最先进的 CNN 将许多这些基本构建块组合在一起,以实现上一章描述的分层表示学习。我们最后将描述过去十年中关键的架构创新,这些创新带来了巨大的性能改进。

从手工编码到从数据中学习过滤器

对于图像数据,这种局部结构传统上推动了开发手工编码过滤器的发展,这些过滤器提取这样的模式以供机器学习ML)模型使用。

图 18.1展示了设计用于检测特定边缘的简单过滤器的效果。笔记本filter_example.ipynb演示了如何在卷积网络中使用手工编码的过滤器,并可视化图像的结果转换。 过滤器是一个矩阵中排列的简单的[-1, 1]模式,显示在图的右上方。 在每个过滤器下面,显示其效果; 它们有点微妙,将在随附笔记本中更容易看到。

图 18.1:应用于图像的基本边缘过滤器的结果

相比之下,卷积层旨在从数据中学习这种局部特征表示。一个关键的见解是将它们的输入,称为接受域,限制在输入的一个小区域内,以便它捕获反映常见模式如边缘或角落的基本像素组合。这样的模式可能出现在图像的任何地方,因此 CNN 也需要识别不同位置和可能有小变化的类似模式。

随后的层次学习合成这些局部特征,以检测高阶特征。GitHub 上的链接资源包括一些我们在下一节关于参考架构中介绍的深度架构学习到的卷积神经网络卷积核的可视化示例。

卷积层中元素的操作方式

卷积层整合了三种架构思想,使得学习到的特征表示在某种程度上对移位、尺度变化和失真具有不变性:

  • 稀疏连接而不是密集连接

  • 权重共享

  • 空间或时间下采样

此外,卷积层允许输入的大小可变。我们将逐步介绍一个典型的卷积层,并依次描述每个思想。

图 18.2概述了通常在三维卷积层中发生的一系列操作,假设图像数据输入具有高度、宽度和深度三个维度,或者通道数。像素值的范围取决于位表示,例如,8 位表示为[0, 255]。或者,宽度轴可以表示时间,高度可以表示不同的特征,通道可以捕捉到关于不同对象的观察,例如股票。

图 18.2:二维卷积层中的典型操作

连续的计算通过卷积、探测器和池化阶段处理输入,我们在接下来的三节中描述了这些阶段。在图 18.2中描绘的例子中,卷积层接收三维输入并产生相同维度的输出。

当代的卷积神经网络由多个这样大小不同的层次组成,这些层次要么堆叠在一起,要么在不同的分支上并行运行。随着每一层的增加,网络可以检测到更高级别、更抽象的特征。

卷积阶段 - 提取局部特征

第一阶段应用一个滤波器,也称为,到输入图像的重叠块上。滤波器是比输入要小得多的矩阵,因此其感受野限制在几个连续值(如像素或时间序列值)上。因此,它专注于局部模式,并且相对于完全连接的层,大大减少了参数和计算的数量。

一个完整的卷积层具有多个按深度切片组织的特征图(如图 18.2所示),以便每一层可以提取多个特征。

从滤波器到特征图

在扫描输入时,内核与其感受野覆盖的每个输入段进行卷积。卷积操作简单地是过滤器权重和匹配输入区域的值之间的点积,两者都已重塑为向量。每次卷积因此产生一个数字,整个扫描产生一个特征图。由于相同向量的点积最大化,特征图指示了每个输入区域的激活程度。

图 18.3说明了使用给定值扫描 输入并使用 过滤器的结果,以及特征图右上角的激活是如何由扁平化的输入区域和内核的点积产生的:

图 18.3:从卷积到特征图

最重要的是,过滤器值是卷积层的参数,在训练期间从数据中学习以最小化选择的损失函数。换句话说,CNN 通过找到激活对所需任务最有用的输入模式的内核值来学习有用的特征表示。

如何扫描输入 – 步幅和填充

步幅定义了用于扫描输入的步长,即水平和垂直移动的像素数。较小的步幅可以扫描更多(重叠)区域,但计算成本更高。当过滤器与输入不完全匹配并且在扫描过程中部分跨越图像边界时,通常使用四种选项:

  • 有效卷积:丢弃图像和过滤器不完全匹配的扫描

  • 相同卷积:使用零填充输入以生成相同大小的特征图

  • 全卷积:对输入进行零填充,以便每个像素被扫描相同次数,包括边界像素(以避免过度采样接近中心的像素)

  • 因果性:仅在左侧对输入进行零填充,以使输出不依赖于以后的输入;为时间序列数据保持时间顺序

选择取决于数据的性质和有用特征可能位于的位置。它们与深度切片的数量结合使用,确定了卷积阶段的输出大小。Andrew Karpathy 的斯坦福讲义笔记(参见 GitHub)包含使用 NumPy 的有用示例。

用于稳健特征和快速计算的参数共享

由于失真或位移,显着特征的位置可能会变化。此外,整个图像可能都可以使用基本特征检测器。CNN 通过在给定深度切片中共享或绑定过滤器的权重来编码这些假设。

因此,每个深度切片都专门针对某种模式,参数数量进一步减少。然而,当图像在空间上居中且关键模式不太可能在输入区域中均匀分布时,权重共享效果较差。

探测器阶段 - 添加非线性

特征图通常通过非线性变换。我们在上一章中遇到的修正线性单元ReLU)是一个常用函数。ReLU 通过零逐元素地替换负激活,减轻了在其他激活函数中发现的梯度消失的风险,如 tanh(参见第十七章用于交易的深度学习)。

一种流行的替代方法是softplus 函数

与 ReLU 相比,它在任何地方都具有导数,即我们用于逻辑回归的 sigmoid 函数(参见第七章线性模型 - 从风险因子到收益预测)。

池化阶段 - 对特征图进行降采样

卷积层的最后阶段可能会对特征图的输入表示进行降采样以执行以下操作:

  • 减少其维度并防止过拟合

  • 降低计算成本

  • 启用基本的平移不变性

这假设特征的精确位置不仅对于识别模式的重要性较小,而且甚至可能是有害的,因为对于目标的不同实例,它可能会有所不同。池化通过降低特征图的空间分辨率来简化位置信息,这是使位置信息不那么精确的一种简单方式。但是,此步骤是可选的,许多架构仅对一些层使用池化或根本不使用。

一种常见的池化操作是最大池化,它仅使用(通常)非重叠子区域中的最大激活值。例如,对于一个小的特征图, 最大池化输出每个四个非重叠区域的最大值。较少见的池化运算符使用平均值或中位数。池化不会添加或学习新参数,但输入窗口的大小和可能的步幅是额外的超参数。

CNN 架构的演变 - 关键创新

在过去的二十年中,几种 CNN 架构通过引入重要的创新推动了性能边界。随着大数据以 ImageNet(Fei-Fei 2015)的形式到来,预测性能的增长速度显著加快,其中包括通过亚马逊的 Mechanical Turk 由人类分配到 20,000 个类别的 1400 万张图像。ImageNet 大规模视觉识别挑战赛ILSVRC)成为围绕稍小一些的来自 1000 个类别的 120 万张图像的 CNN 进展的焦点。

对于实际原因,熟悉主导这些比赛的参考架构是有用的。正如我们将在下一节关于处理图像数据的 CNNs 中看到的那样,它们为标准任务提供了一个很好的起点。此外,迁移学习使我们能够在成功的架构上构建预训练权重,从而解决许多计算机视觉任务。迁移学习不仅加快了架构选择和训练的速度,还使得在更小的数据集上成功应用成为可能。

另外,许多出版物提到了这些架构,并且它们经常作为量身定制的网络的基础,用于分割或定位任务。我们将在关于图像分类和迁移学习的章节进一步描述一些里程碑式的架构。

性能突破和网络规模

图 18.4的左侧绘制了各种网络架构的计算成本与 top-1 准确度之间的关系。它表明参数数量与性能之间存在正相关关系,但同时也显示出更多参数的边际效益下降,架构设计和创新也很重要。

右侧绘制了所有网络的每个参数的 top-1 准确度。几种新的架构针对了在诸如手机等性能较低的设备上的用例。虽然它们没有达到最先进的性能,但它们找到了更有效的实现方式。有关这些架构及其图表背后的分析的更多详细信息,请参阅 GitHub 上的资源。

图 18.4:预测性能和计算复杂度

学到

从 20 年的 CNN 架构发展中学到的一些教训,特别是自 2012 年以来,包括以下内容:

  • 较小的卷积滤波器表现更好(可能除了在第一层),因为几个小滤波器可以以较低的计算成本替代一个更大的滤波器。

  • 1 × 1 卷积减少了特征图的维度,使得网络能够整体学习更多的数量。

  • 跳跃连接能够通过网络创建多条路径,并使得能够训练容量更大的 CNNs。

用于卫星图像和对象检测的 CNNs

在本节中,我们演示了如何解决关键的计算机视觉任务,例如图像分类和对象检测。正如在介绍和第三章金融替代数据 - 类别和用例中所述,图像数据可以通过提供关于未来趋势、变化的基本面或与目标资产类别或投资范围相关的特定事件的线索,来为交易策略提供信息。流行的示例包括利用卫星图像来获取关于农产品供应、消费者和经济活动,或制造业或原材料供应链状况的线索。具体任务可能包括以下内容,例如:

  • 图像分类:识别某些作物的耕种土地是否正在扩大,或者预测收获的质量和数量

  • 对象检测:计算某条运输路线上的油轮数量或停车场中汽车的数量,或者识别购物中心中购物者的位置

在本节中,我们将演示如何设计 CNN 来自动提取此类信息,既可以从流行的架构开始,也可以通过迁移学习微调预训练权重以适应给定任务。我们还将演示如何在给定场景中检测对象。

我们将介绍用于这些任务的关键 CNN 架构,解释它们为什么效果好,并展示如何使用 TensorFlow 2 对它们进行训练。我们还将演示如何获取预训练权重并进行微调。不幸的是,直接与交易策略相关信息的卫星图像非常昂贵且不容易获得。然而,我们将演示如何使用 EuroSat 数据集来构建一个识别不同土地用途的分类器。这篇简要介绍关于计算机视觉的 CNN 旨在演示如何处理常见任务,这些任务可能在设计基于图像的交易策略时需要解决。

所有我们在上一章介绍的库都支持卷积层;我们将专注于 TensorFlow 2 的 Keras 接口。我们首先将使用 MNIST 手写数字数据集来说明 LeNet5 架构。接下来,我们将演示如何在 CIFAR-10 上使用 AlexNet 进行数据增强,这是原始 ImageNet 的简化版本。然后,我们将继续使用最先进的架构进行迁移学习,然后将所学应用于实际卫星图像。最后,我们将以实际场景中的对象检测示例结束。

LeNet5 – 第一个具有工业应用的 CNN

Yann LeCun,现任 Facebook AI 研究总监,是 CNN 发展的领先先驱之一。在经历了从 1980 年代开始的数次迭代后,1998 年,LeNet5 成为了第一个现代 CNN,在真实应用中引入了几个今天仍然相关的架构元素。

LeNet5 发表在一篇非常有启发性的论文中,《基于梯度的学习应用于文档识别》(LeCun 等人,1989 年),该论文阐述了许多核心概念。最重要的是,它提出了卷积与可学习滤波器相结合在多个位置提取相关特征的见解,而参数却很少。考虑到当时的计算资源有限,效率至关重要。

LeNet5 设计用于识别支票上的手写字,并被几家银行使用。它为分类准确率建立了一个新的基准,MNIST 手写数字数据集的结果为 99.2%。它由三个卷积层组成,每个卷积层都包含非线性 tanh 变换、池化操作和一个全连接的输出层。在卷积层中,特征图的数量增加,而它们的维度减小。它总共有 60,850 个可训练参数(Lecun 等人,1998 年)。

卷积神经网络的“Hello World”–手写数字分类

在本节中,我们将实现一个稍微简化的 LeNet5 版本,以演示如何使用 TensorFlow 实现 CNN。原始的 MNIST 数据集包含 60,000 个灰度图像,每个图像的分辨率为像素,每个图像包含一个从 0 到 9 的单个手写数字。一个很好的替代品是更具挑战性但结构相似的 Fashion MNIST 数据集,我们在第十三章中遇到了它,数据驱动的风险因子和无监督学习的资产配置。有关实现细节,请参阅digit_classification_with_lenet5笔记本。

我们可以直接在 Keras 中加载它:

from tensorflow.keras.datasets import mnist
(X_train, y_train), (X_test, y_test) = mnist.load_data()
X_train.shape, X_test.shape
((60000, 28, 28), (10000, 28, 28)) 

图 18.5显示了数据集中的前十个图像,并突出显示了相同数字实例之间的显着变化。右侧显示了单个图像的像素值范围从 0 到 255:

图 18.5:MNIST 样本图像

我们将像素值重新缩放到范围[0, 1]以规范化训练数据,并促进反向传播过程,并将数据转换为 32 位浮点数,这减少了内存需求和计算成本,同时为我们的用例提供了足够的精度:

X_train = X_train.astype('float32')/255
X_test = X_test.astype('float32')/255 

定义 LeNet5 架构

我们可以定义一个简化版的 LeNet5,省略了包含径向基函数的原始最终层,使用默认的“valid”填充和单步跨度,除非另有定义:

lenet5 = Sequential([
    Conv2D(filters=6, kernel_size=5, activation='relu', 
           input_shape=(28, 28, 1), name='CONV1'),
    AveragePooling2D(pool_size=(2, 2), strides=(1, 1), 
                     padding='valid', name='POOL1'),
    Conv2D(filters=16, kernel_size=(5, 5), activation='tanh', name='CONV2'),
    AveragePooling2D(pool_size=(2, 2), strides=(2, 2), name='POOL2'),
    Conv2D(filters=120, kernel_size=(5, 5), activation='tanh', name='CONV3'),
    Flatten(name='FLAT'),
    Dense(units=84, activation='tanh', name='FC6'),
    Dense(units=10, activation='softmax', name='FC7')
]) 

摘要表明,所定义的模型具有超过 300,000 个参数:

Layer (type)                 Output Shape              Param #   
CONV1 (Conv2D)               (None, 24, 24, 6)         156       
POOL1 (AveragePooling2D)     (None, 23, 23, 6)         0         
CONV2 (Conv2D)               (None, 19, 19, 16)        2416      
_________________________________________________________________
POOL2 (AveragePooling2D)     (None, 9, 9, 16)          0         
_________________________________________________________________
CONV3 (Conv2D)               (None, 5, 5, 120)         48120     
_________________________________________________________________
FLAT (Flatten)               (None, 3000)              0         
_________________________________________________________________
FC6 (Dense)                  (None, 84)                252084    
________________________________________________________________
FC7 (Dense)                  (None, 10)                850       =================================================================
Total params: 303,626
Trainable params: 303,626 

我们使用sparse_crossentropy_loss进行编译,它接受整数而不是 one-hot 编码的标签和原始的随机梯度优化器:

lenet5.compile(loss='sparse_categorical_crossentropy',
               optimizer='SGD',
               metrics=['accuracy']) 

训练和评估模型

现在我们准备训练模型。模型期望四维输入,因此我们相应地进行了 reshape。我们使用标准的批量大小 32 和 80:20 的训练-验证分割。此外,我们利用检查点来存储模型权重,如果验证错误改进,并确保数据集被随机洗牌。我们还定义了一个early_stopping回调,以在验证准确性不再改进 20 次迭代后中断训练:

lenet_history = lenet5.fit(X_train.reshape(-1, 28, 28, 1),
                          y_train,
                          batch_size=32,
                          epochs=100,
                          validation_split=0.2, # use 0 to train on all data
                          callbacks=[checkpointer, early_stopping],
                          verbose=1,
                          shuffle=True) 

训练历史记录了在 81 个周期之后的最后一次改进,这需要在单个 GPU 上花费约 4 分钟。这个样本运行的测试准确率为 99.09%,几乎与原始 LeNet5 的结果完全相同:

accuracy = lenet5.evaluate(X_test.reshape(-1, 28, 28, 1), y_test, verbose=0)[1]
print('Test accuracy: {:.2%}'.format(accuracy))
Test accuracy: 99.09% 

为了比较,一个简单的两层前馈网络只能达到 "仅" 97.04% 的测试准确度(见笔记本)。实际上,LeNet5 在 MNIST 上的改进是有限的。非神经方法也已经实现了大于或等于 99% 的分类精度,包括 K 最近邻和支持向量机。CNN 在处理更具挑战性的数据集时表现得非常出色,接下来我们将看到。

AlexNet – 重新点燃了深度学习研究

AlexNet,由 Alex Krizhevsky、Ilya Sutskever 和 Geoff Hinton 在多伦多大学开发,显著降低了错误率,并在 2012 年 ILSVRC 中显著优于亚军,将前五错误率从 26% 降低到 16% (Krizhevsky, Sutskever 和 Hinton 2012)。这一突破引发了机器学习研究的复兴,并将计算机视觉的深度学习牢牢地放在了全球技术地图上。

AlexNet 的架构类似于 LeNet,但更深更宽。它通常被认为是发现了深度的重要性,约 6000 万参数,比 LeNet5 多了 1000 倍,这证明了计算能力的增加,尤其是 GPU 的使用,以及更大的数据集。

它采用了卷积堆叠而不是将每个卷积与池化阶段相结合,成功地使用了dropout进行正则化和ReLU进行高效的非线性变换。它还使用数据增强来增加训练样本的数量,添加了权重衰减,并使用了更高效的卷积实现。它还通过在两个 GPU 上分布网络来加速训练。

笔记本 image_classification_with_alexnet.ipynb 中有一个稍微简化了的 AlexNet 版本,专门针对 CIFAR-10 数据集,该数据集包含了原始的 1000 类中的 10 类共 60000 张图像。它已经从原始的 像素分辨率压缩到了 像素,但仍然具有三个色彩通道。

有关实现详细信息,请参阅笔记本 image_classification_with_alexnet;这里我们将跳过一些重复的步骤。

使用图像增强预处理 CIFAR-10 数据

CIFAR-10 也可以使用 TensorFlow 的 Keras 接口下载,并且我们将像在上一节中使用 MNIST 时那样重新调整像素值并对十个类标签进行 one-hot 编码。

我们首先对 50000 个训练样本进行了 45 个周期的两层前馈网络训练,以达到 45.78% 的测试准确度。我们还尝试了一个具有 528000 个参数的三层卷积网络,其测试准确度达到了 74.51% (见笔记本)。

提高性能的常见技巧是通过创建合成数据人为增加训练集的大小。这涉及随机移动或水平翻转图像,或向图像中引入噪声。TensorFlow 包含一个用于此目的的 ImageDataGenerator 类。我们可以按如下配置并拟合训练数据:

from tensorflow.keras.preprocessing.image import ImageDataGenerator
datagen = ImageDataGenerator(
    width_shift_range=0.1,   # randomly horizontal shift
    height_shift_range=0.1,  # randomly vertical shift
    horizontal_flip=True)    # randomly horizontal flip
datagen.fit(X_train) 

结果显示,增强图像(低分辨率 32×32)如预期地已以各种方式被改变:

图 18.6:原始和增强样本

经过在更大的、增强的数据上训练后,三层 CNN 的测试准确率略有提高,达到了 76.71%。

定义模型架构

我们需要将 AlexNet 架构调整到与竞赛中使用的 ImageNet 样本相比,CIFAR-10 图像的较低维度上。为此,我们保持原始滤波器的数量,但将它们变小(有关实施细节,请参见笔记本)。

摘要(见笔记本)显示了五个卷积层,后跟两个频繁使用批量标准化的全连接层,共计 2150 万参数。

比较 AlexNet 性能

除了 AlexNet,我们还训练了一个两层前馈 NN 和一个三层 CNN,后者有和没有图像增强。经过 100 个时期(如果验证准确率在 20 个回合内没有改善,则提前停止),我们获得了四个模型的交叉验证轨迹和测试准确率,如图 18.7所示:

图 18.7:CIFAR-10 的验证性能和测试准确率

在经过大约 35 个时期后,AlexNet 在测试准确率方面达到了最高的 79.33%,紧随其后的是在更大的数据集上训练时间更长的增强图像的较浅的 CNN,其测试准确率为 78.29%。在这个更复杂的数据集上,前馈 NN 的表现远不如在 MNIST 上,测试准确率为 43.05%。

迁移学习-更少数据的更快训练

在实践中,有时我们没有足够的数据来随机初始化地从头训练 CNN。迁移学习是一种机器学习技术,它重新利用了在一个数据集上训练的模型来完成另一个任务。如果成功,它可以带来更好的性能和更快的训练,需要的标记数据比在目标任务上从头训练神经网络要少。

转移学习的替代方法

CNN 的迁移学习方法依赖于在一个非常大的数据集(如 ImageNet)上进行预训练。其目标是使卷积滤波器提取的特征表示泛化到新图像。在第二步中,它利用结果要么初始化和重新训练一个新的 CNN,要么将其用作处理感兴趣任务的新网络的输入。

如前所述,CNN 架构通常使用一系列卷积层来检测分层模式,然后添加一个或多个全连接层将卷积激活映射到结果类别或值。馈入完全连接部分的最后一个卷积层的输出称为瓶颈特征。我们通常在应用 ReLU 激活函数后,可以将预训练网络的瓶颈特征用作新的全连接网络的输入。

换句话说,我们冻结卷积层,并替换网络的密集部分。另一个好处是我们可以使用不同尺寸的输入,因为是密集层限制了输入大小。

或者,我们可以将瓶颈特征用作输入到不同的机器学习算法。例如,在 AlexNet 架构中,瓶颈层为每个输入图像计算一个包含 4,096 个条目的向量。然后,我们将此向量用作新模型的特征。

我们还可以进一步,不仅使用新数据替换和重新训练最终层,而且微调预训练的 CNN 的权重。为了实现这一点,我们继续训练,要么仅针对较后面的层,同时冻结一些较早的层的权重,要么对所有层进行训练。动机可能是保留更低层学到的更通用的模式,例如边缘或颜色斑块检测器,同时允许 CNN 的后续层适应新任务的细节。例如,ImageNet 包含各种各样的狗品种,这可能导致特征表示特定于区分这些类别。

建立在最先进的架构之上

转移学习使我们能够利用表现出色的架构,而无需进行可能相当耗费 GPU 和数据的训练。我们简要概述了一些其他流行架构的关键特性,这些架构是流行的起点。

VGGNet – 更深和更小的滤波器

ILSVRC 2014 的亚军是由牛津大学的视觉几何组(VGG,Simonyan 2015)开发的。它展示了更小的连续卷积滤波器的有效性,并强调了深度对于强大性能的重要性。VGG16 包含 16 个卷积和全连接层,仅执行卷积和池化操作(见图 18.5)。

VGG16 有1.4 亿个参数,这增加了训练和推断的计算成本以及内存需求。然而,大多数参数在后来被发现不是必需的全连接层中,因此删除它们大大减少了参数数量,而不会对性能产生负面影响。

GoogLeNet – 通过 Inception 减少参数

谷歌的 Christian Szegedy 使用更高效的 CNN 实现降低了计算成本,以促进规模化的实际应用。由于 Inception 模块,GoogLeNet(Szegedy 等人,2015)仅具有 400 万参数,而相比之下,AlexNet 具有 6000 万个参数,VGG16 具有 1.4 亿个参数。

Inception 模块建立在使用 卷积来压缩深层卷积滤波器堆栈从而降低计算成本的 网络内网络概念 上。该模块使用并行的 滤波器,将后两者与 卷积结合起来,以降低前一层传递的滤波器的维度。

此外,它使用平均池化代替完全连接的层来消除许多不太重要的参数。最近还有几个增强版本,最新的是 Inception-v4。

ResNet – 超越人类性能的快捷连接

残差网络 (ResNet) 结构是在微软开发的,获得了 ILSVRC 2015 奖。它将 top-5 错误推到了 3.7%,低于人类在这项任务上的性能水平,约为 5%(He et al. 2015)。

它引入了跳过多个层的身份快捷连接,克服了训练深度网络的一些挑战,使得可以使用数百甚至超过一千个层。它还大量使用批量归一化,据显示可以允许更高的学习速率,并对权重初始化更宽容。该架构还省略了完全连接的最终层。

正如上一章所提到的,深度网络的训练面临着臭名昭著的梯度消失挑战:随着梯度传播到较早的层,小权重的重复乘法会使梯度朝零收缩。因此,增加深度可能会限制学习。

跳过两个或多个层的快捷连接已成为 CNN 架构中最流行的发展之一,并引发了大量研究工作来改进和解释其性能。有关更多信息,请参见 GitHub 上的参考资料。

在实践中使用 VGG16 进行迁移学习

现代 CNN 在多个 GPU 上对 ImageNet 进行训练可能需要数周,但幸运的是,许多研究人员分享他们的最终权重。例如,TensorFlow 2 包含了几种先前讨论过的参考架构的预训练模型,即 VGG16 及其更大的版本 VGG19、ResNet50、InceptionV3 和 InceptionResNetV2,以及 MobileNet、DenseNet、NASNet 和 MobileNetV2。

如何提取瓶颈特征

笔记本bottleneck_features.ipynb说明了如何下载预训练的 VGG16 模型,无论是带有最终层以生成预测,还是不带最终层,如图 18.8所示,以提取由瓶颈特征产生的输出:

图 18.8:VGG16 架构

TensorFlow 2 使下载和使用预训练模型变得非常简单:

from tensorflow.keras.applications.vgg16 import VGG16
vgg16 = VGG16()
vgg16.summary()
Layer (type)                 Output Shape              Param #   
input_1 (InputLayer)         (None, 224, 224, 3)       0            
… several layers omitted... 
block5_conv4 (Conv2D)        (None, 14, 14, 512)       2359808   
_________________________________________________________________
block5_pool (MaxPooling2D)   (None, 7, 7, 512)         0         
_________________________________________________________________
flatten (Flatten)            (None, 25088)             0         
fc1 (Dense)                  (None, 4096)              102764544 
fc2 (Dense)                  (None, 4096)              16781312  
predictions (Dense)          (None, 1000)              4097000   
Total params: 138,357,544
Trainable params: 138,357,544 

您可以像使用任何其他 Keras 模型一样使用此模型进行预测:我们传入七个样本图像,并获得每个 1,000 个 ImageNet 类别的类概率:

y_pred = vgg16.predict(img_input)
Y_pred.shape
(7, 1000) 

要排除完全连接的层,只需添加关键字include_top=False。 现在,预测由最终卷积层block5_pool输出,并匹配该层的形状:

vgg16 = VGG16(include_top=False)
vgg16.predict(img_input).shape
(7, 7, 7, 512) 

通过省略完全连接的层并仅保留卷积模块,我们不再被迫使用模型的固定输入大小,例如原始的 ImageNet 格式。 相反,我们可以将模型适应任意输入大小。

如何微调预训练模型

我们将演示如何冻结预训练模型的一些或所有层,并使用具有不同格式的新完全连接的层和数据进行继续训练(请参阅笔记本transfer_learning.ipynb中的代码示例,改编自 TensorFlow 2 教程)。

我们使用在 TensorFlow 内置的猫与狗图像上使用 ImageNet 预训练的 VGG16 权重(请参阅有关如何获取数据集的笔记本)。

预处理将所有图像调整大小为像素。 我们在实例化预训练的 VGG16 实例时指示新的输入大小,然后冻结所有权重:

vgg16 = VGG16(input_shape=IMG_SHAPE, include_top=False, weights='imagenet')
vgg16.trainable = False
vgg16.summary()
Layer (type)                 Output Shape              Param #   
... omitted layers...
block5_conv3 (Conv2D)        (None, 10, 10, 512)         2359808   
block5_pool (MaxPooling2D)   (None, 5, 5, 512)         0         
Total params: 14,714,688
Trainable params: 0
Non-trainable params: 14,714,688 

对于 32 个样本图像的模型输出形状现在与无头模型中的最后一个卷积层的形状相匹配:

feature_batch = vgg16(image_batch)
Feature_batch.shape
TensorShape([32, 5, 5, 512]) 

我们可以使用顺序 API 或功能 API 将新层附加到无头模型。 对于 Sequential API,添加GlobalAveragePooling2DDenseDropout层的操作如下:

global_average_layer = GlobalAveragePooling2D()
dense_layer = Dense(64, activation='relu')
dropout = Dropout(0.5)
prediction_layer = Dense(1, activation='sigmoid')
seq_model = tf.keras.Sequential([vgg16, 
                                 global_average_layer, 
                                 dense_layer, 
                                 dropout, 
                                 prediction_layer])
seq_model.compile(loss = tf.keras.losses.BinaryCrossentropy(from logits=True),
                       optimizer = 'Adam', 
                       metrics=["accuracy"]) 

我们将from_logits=True设置为BinaryCrossentropy损失,因为模型提供了线性输出。 摘要显示了新模型如何结合预训练的 VGG16 卷积层和新的最终层:

seq_model.summary()
Layer (type)                 Output Shape              Param #   
vgg16 (Model)                (None, 5, 5, 512)         14714688  
global_average_pooling2d (Gl (None, 512)               0         
dense_7 (Dense)              (None, 64)                32832     
dropout_3 (Dropout)          (None, 64)                0         
dense_8 (Dense)              (None, 1)                 65        
Total params: 14,747,585
Trainable params: 11,831,937
Non-trainable params: 2,915,648 

请参阅 Functional API 版本的笔记本。

在训练新的最终层之前,预训练的 VGG16 提供了 48.75%的验证准确性。 现在,我们将模型训练 10 个时期,仅调整最终层的权重:

history = transfer_model.fit(train_batches,
                            epochs=initial_epochs,
                            validation_data=validation_batches) 

10 个时期将验证准确性提高到 94%以上。 为了微调模型,我们可以解冻 VGG16 模型并继续训练。 请注意,只有在训练新的最终层之后才应该这样做:随机初始化的分类层可能会产生大的梯度更新,从而可能消除预训练结果。

要解冻模型的部分,我们选择一个层,然后将权重设置为trainable; 在这种情况下,选择 VGG16 架构中总共 19 个层之一的第 12 层:

vgg16.trainable = True
len(vgg16.layers)
19
# Fine-tune from this layer onward
start_fine_tuning_at = 12
# Freeze all the layers before the 'fine_tune_at' layer
for layer in vgg16.layers[:start_fine_tuning_at]:
    layer.trainable =  False 

现在只需重新编译模型,并使用早停继续进行最多 50 个时期的训练,从第 10 个时期开始,如下所示:

fine_tune_epochs = 50
total_epochs = initial_epochs + fine_tune_epochs
history_fine_tune = transfer_model.fit(train_batches,
                                     epochs=total_epochs,
                                     initial_epoch=history.epoch[-1],
                                     validation_data=validation_batches,
                                     callbacks=[early_stopping]) 

图 18.9 显示了验证准确率如何显著增加,经过另外 22 个时期后达到 97.89%:

图 18.9:交叉验证性能:准确度和交叉熵损失

当训练数据有限时,迁移学习是一种重要技术,这在实践中经常发生。虽然猫和狗不太可能产生可交易的信号,但迁移学习肯定可以帮助提高对相关替代数据集(例如我们将要处理的卫星图像)的预测准确性。

使用迁移学习对卫星图像进行分类

用于替代数据的卫星图像在其中扮演着重要角色(见第三章金融领域的替代数据 - 类别和用例)。例如,商品交易商可能依赖卫星图像来预测某些作物或资源的供应,方法是监控农场、矿场或油轮航行的活动。

EuroSat 数据集

为了说明如何处理这种类型的数据,我们加载了包含在 TensorFlow 2 数据集中的 EuroSat 数据集(Helber 等人,2019)。EuroSat 数据集包括约 27,000 张图像,格式为 ,代表 10 种不同类型的土地利用。图 18.10 显示了每个标签的一个示例:

图 18.10:数据集中包含的十种土地利用类型

类似数据的时间序列可以用来跟踪耕种、工业和住宅区域的相对大小,或者特定作物的状态,以预测收获量或质量,例如葡萄酒。

对非常深的 CNN 进行微调 - DenseNet201

黄等人(2018)根据这样一个洞见开发了一个新的架构,名为密集连接,即如果 CNN 包含接近输入和接近输出的层之间的较短连接,则可以更深,更准确,更容易训练。

一种被标记为DenseNet201的架构以前向传播的方式连接每一层到每一层。它使用所有前一层的特征映射作为输入,而每一层的特征映射成为所有后续层的输入。

我们从 tensorflow.keras.applications 下载 DenseNet201 架构,并用以下稠密层替换其最终层,其中包含批量归一化以减轻这个具有超过 700 层的非常深层网络中的梯度爆炸或消失:

Layer (type)                 Output Shape              Param #   
densenet201 (Model)          (None, 1920)              18321984  
batch_normalization (BatchNo (None, 1920)              7680      
dense (Dense)                (None, 2048)              3934208   
batch_normalization_1 (Batch (None, 2048)              8192      
dense_1 (Dense)              (None, 2048)              4196352   
batch_normalization_2 (Batch (None, 2048)              8192      
dense_2 (Dense)              (None, 2048)              4196352   
batch_normalization_3 (Batch (None, 2048)              8192      
dense_3 (Dense)              (None, 2048)              4196352   
batch_normalization_4 (Batch (None, 2048)              8192      
dense_4 (Dense)              (None, 10)                20490     
Total params: 34,906,186
Trainable params: 34,656,906
Non-trainable params: 249,280 

模型训练和结果评估

我们将训练图像的 10%用于验证目的,并在 10 个时期后获得 97.96%的最佳外样分类准确度。这超过了原始论文中引用的最佳 ResNet-50 架构的性能,其分割比为 90-10。

图 18.11:交叉验证性能

从相对较小的训练集中进行增强很可能会获得额外的性能提升。

目标检测和分割

图像分类是一项基本的计算机视觉任务,它要求根据图像中包含的某些对象对图像进行标记。许多实际应用,包括投资和交易策略,都需要额外的信息:

  • 目标检测任务不仅需要识别所有感兴趣对象,还需要这些对象的空间位置,通常使用边界框。已开发了几种算法来克服暴力滑动窗口方法的低效率,包括区域提议方法(R-CNN;例如,参见 Ren 等人 2015)和你只需要看一次YOLO)实时目标检测算法(Redmon 2016)。

  • 对象分割任务更进一步,需要一个类别标签和输入图像中每个对象的轮廓。这对于计算图像中的物体数量,例如油船、个人或汽车,并评估活动水平可能会有所帮助。

  • 语义分割,也称为场景解析,进行密集预测,为图像中的每个像素分配一个类别标签。因此,图像被分割成语义区域,每个像素被分配到其包围的对象或区域。

目标检测需要区分几类对象的能力,并决定图像中存在多少个以及哪些对象。

实践中的目标检测

一个著名的例子是 Ian Goodfellow 从 Google 的 街景房屋号码SVHN)数据集(Goodfellow 2014)中识别房屋号码。它要求模型识别以下内容:

  • 最多五位数字中有多少位构成门牌号

  • 每个组件的正确数字

  • 数字构成的正确顺序

我们将展示如何预处理不规则形状的源图像,调整 VGG16 架构以产生多个输出,并在微调预训练权重以解决任务之前,训练最终层。

对源图像进行预处理

笔记本 svhn_preprocessing.ipynb 包含用于生成简化的、裁剪的数据集的代码,该数据集使用边界框信息创建包含数字的规则形状的图像;原始图像具有任意形状(Netzer 2011)。

图 18.12:SVHN 数据集的裁剪样本图像

SVHN 数据集包含最多五位数的门牌号,并且如果数字不存在,则使用类别 10。然而,由于包含五位数字的示例非常少,我们将图像限制为仅包含最多四位数字的图像。

使用自定义最终层的迁移学习

笔记本 svhn_object_detection.ipynb 说明了如何应用迁移学习到基于 VGG16 架构的深度 CNN,如前一节所述。我们将描述如何创建产生多个输出的新最终层,以满足三个 SVHN 任务目标,包括一个预测有多少个数字,并且按照它们出现的顺序为每个数字的值进行预测。

在原始数据集上表现最佳的架构具有八个卷积层和两个最终全连接层。我们将使用迁移学习,离开 VGG16 架构。与之前一样,我们导入在 ImageNet 权重上预训练的 VGG16 网络,删除卷积块后的层,冻结权重,并使用功能 API 创建新的密集和预测层,如下所示:

vgg16 = VGG16(input_shape=IMG_SHAPE, include_top=False, weights='imagenet')
vgg16.trainable = False
x = vgg16.output
x = Flatten()(x)
x = BatchNormalization()(x)
x = Dense(256)(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
x = Dense(128)(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
n_digits = Dense(SEQ_LENGTH, activation='softmax', name='n_digits')(x)
digit1 = Dense(N_CLASSES-1, activation='softmax', name='d1')(x)
digit2 = Dense(N_CLASSES, activation='softmax', name='d2')(x)
digit3 = Dense(N_CLASSES, activation='softmax', name='d3')(x)
digit4 = Dense(N_CLASSES, activation='softmax', name='d4')(x)
predictions = Concatenate()([n_digits, digit1, digit2, digit3, digit4]) 

预测层将数字数量 n_digits 的四类输出与预测该位置存在的数字的四个输出组合在一起。

创建自定义损失函数和评估指标

自定义输出要求我们定义一个损失函数,该函数捕捉到模型达到其目标的程度。我们还希望以一种反映出针对特定标签定制的预测准确性的方式来测量准确性。

对于自定义损失,我们计算五个分类输出上的交叉熵的平均值,即数字的数量及其相应的值:

def weighted_entropy(y_true, y_pred):
    cce = tf.keras.losses.SparseCategoricalCrossentropy()
    n_digits = y_pred[:, :SEQ_LENGTH]
    digits = {}
    for digit, (start, end) in digit_pos.items():
        digits[digit] = y_pred[:, start:end]
    return (cce(y_true[:, 0], n_digits) +
            cce(y_true[:, 1], digits[1]) +
            cce(y_true[:, 2], digits[2]) +
            cce(y_true[:, 3], digits[3]) +
            cce(y_true[:, 4], digits[4])) / 5 

为了衡量预测准确性,我们将五个预测与相应的标签值进行比较,并计算样本批次中正确匹配的比例的平均值:

def weighted_accuracy(y_true, y_pred):
    n_digits_pred = K.argmax(y_pred[:, :SEQ_LENGTH], axis=1)
    digit_preds = {}
    for digit, (start, end) in digit_pos.items():
        digit_preds[digit] = K.argmax(y_pred[:, start:end], axis=1)
    preds = tf.dtypes.cast(tf.stack((n_digits_pred,
                                     digit_preds[1],
                                     digit_preds[2],
                                     digit_preds[3],
                                     digit_preds[4]), axis=1), tf.float32)
    return K.mean(K.sum(tf.dtypes.cast(K.equal(y_true, preds), tf.int64), axis=1) / 5) 

最后,我们集成了基础层和最终层,并按以下方式使用自定义损失和准确度指标编译模型:

model = Model(inputs=vgg16.input, outputs=predictions)
model.compile(optimizer='adam',
              loss=weighted_entropy,
              metrics=[weighted_accuracy]) 

微调 VGG16 权重和最终层

我们对新的最终层进行了 14 个周期的训练,并继续微调所有 VGG16 权重,如上一节所述,另外再进行了 23 个时期的微调(在这两种情况下都使用了提前停止)。

以下图表显示了整个训练周期的训练和验证准确性以及损失。当我们在初始训练期间解冻 VGG16 权重后,准确性会下降,然后再提高,达到 94.52% 的验证性能:

图 18.13:交叉验证性能

有关其他实现细节和结果评估,请参见笔记本。

吸取的教训

我们可以使用仅含有小型训练集的方法达到相当高的准确性水平。但是,最先进的性能只有 1.02% 的错误率(benchmarks.ai/svhn)。为了更接近,最重要的步骤是增加训练数据的量。

有两种简单的方法可以实现这一点:我们可以包括额外数据集中包含的更多样本,并且我们可以使用图像增强(参见AlexNet:重燃深度学习研究一节)。目前表现最佳的方法在很大程度上依赖于从数据中学习到的增强(Cubuk 2019)。

用于时间序列数据的 CNN - 预测收益

CNN 最初是用于处理图像数据的,并在各种计算机视觉任务上取得了超人的表现。如第一节讨论的那样,时间序列数据具有类似图像的网格结构,CNN 已成功应用于一维、二维和三维时间数据的表示。

如果数据符合模型的关键假设,即本地模式或关系有助于预测结果,那么将 CNN 应用于时间序列很可能会取得成功。在时间序列的上下文中,本地模式可能是自相关或在相关间隔处的类似非线性关系。在第二和第三维度上,本地模式暗示着多变量序列的不同组件之间或不同股票的这些序列之间存在系统关系。由于本地性很重要,因此数据的组织方式非常重要,与前馈网络相反,在前馈网络中,对任何维度的元素进行洗牌不会对学习过程产生负面影响。

在这一部分,我们提供了一个相对简单的例子,使用一维卷积来模拟自回归过程(参见第九章波动率预测和统计套利的时间序列模型),根据滞后的收益预测未来收益。然后,我们复制了一篇最近的研究论文,通过将多变量时间序列数据格式化为图像来预测收益,取得了良好的结果。我们还将开发和测试基于预测信号的交易策略。

具有一维卷积的自回归 CNN

我们将使用单变量自回归资产收益模型为 CNN 引入时间序列用例。更具体地说,模型接收最近的 12 个月的收益,并使用单层一维卷积来预测随后的一个月。

必需的步骤如下:

  1. 创建滚动的 12 个月滞后收益及相应的结果

  2. 定义模型架构

  3. 训练模型和评估结果

在接下来的几节中,我们将依次描述每个步骤;笔记本time_series_prediction包含了本节的代码示例。

数据预处理

首先,我们将选择自 2000 年以来所有 Quandl Wiki 股票的调整收盘价如下:

prices = (pd.read_hdf('../data/assets.h5', 'quandl/wiki/prices')
          .adj_close
          .unstack().loc['2000':])
prices.info()
DatetimeIndex: 2896 entries, 2007-01-01 to 2018-03-27
Columns: 3199 entries, A to ZUMZ 

接下来,我们将价格数据重新采样为月末频率,计算收益,并将超过 100%的月收益设置为缺失值,因为它们很可能表示数据错误。然后,我们放弃具有缺失观测值的股票,保留了每个 215 观测值的 1,511 只股票:

returns = (prices
           .resample('M')
           .last()
           .pct_change()
           .dropna(how='all')
           .loc['2000': '2017']
           .dropna(axis=1)
           .sort_index(ascending=False))
# remove outliers likely representing data errors
returns = returns.where(returns<1).dropna(axis=1)
returns.info()
DatetimeIndex: 215 entries, 2017-12-31 to 2000-02-29
Columns: 1511 entries, A to ZQK 

要创建滚动的 12 个滞后月收益及其对应的结果,我们遍历滚动的 13 个月片段,并在将每个片段的转置分配给索引后,将其附加到列表中。完成循环后,我们按以下方式连接列表中的数据框:

n = len(returns)
nlags = 12
lags = list(range(1, nlags + 1))
cnn_data = []
for i in range(n-nlags-1):
    df = returns.iloc[i:i+nlags+1]        # select outcome and lags
    date = df.index.max()                 # use outcome date
    cnn_data.append(df.reset_index(drop=True)  # append transposed series
                    .transpose()
                    .assign(date=date)
                    .set_index('date', append=True)
                    .sort_index(1, ascending=True))
cnn_data = (pd.concat(cnn_data)
            .rename(columns={0: 'label'})
            .sort_index()) 

我们最终得到了 2001-2017 年期间超过 305,000 对结果和滞后回报:

cnn_data.info(null_counts=True)
MultiIndex: 305222 entries, ('A', Timestamp('2001-03-31 00:00:00')) to 
                            ('ZQK', Timestamp('2017-12-31 00:00:00'))
Data columns (total 13 columns):
... 

当我们计算每个滞后回报和结果的信息系数时,我们发现只有滞后 5 不具有统计显著性:

图 18.14:关于滞后回报的信息系数

定义模型架构

现在我们将使用 TensorFlow 的 Keras 接口来定义模型架构。我们将一维卷积层与最大池化和批归一化结合起来产生一个实值标量输出:

model = Sequential([Conv1D(filters=32,
                           kernel_size=4,
                           activation='relu',
                           padding='causal',
                           input_shape=(12, 1),
                           use_bias=True,
                           kernel_regularizer=regularizers.l1_l2(l1=1e-5,
                                                                 l2=1e-5)),
                    MaxPooling1D(pool_size=4),
                    Flatten(),
                    BatchNormalization(),
                    Dense(1, activation='linear')]) 

一维卷积计算长度为 4 的(经过正则化的)向量与长度为 12 的每个输入序列的滑动点积,使用因果填充来保持时间顺序(参见如何扫描输入:步幅和填充部分)。结果得到的 32 个特征映射与输入具有相同的长度,即 12,而最大池化以 4 个一组的方式将其减少为长度为 3 的 32 个向量。

模型输出加权平均值加上长度为 96 的压平且归一化的单个向量的偏差,并且有 449 个可训练参数:

Layer (type)                 Output Shape              Param #   
conv1d (Conv1D)              (None, 12, 32)            160       
max_pooling1d (MaxPooling1D) (None, 3, 32)             0         
flatten (Flatten)            (None, 96)                0         
batch_normalization (BatchNo (None, 96)                384       
dense (Dense)                (None, 1)                 97        
Total params: 641
Trainable params: 449
Non-trainable params: 192 

笔记本将模型生成和随后的编译封装到一个get_model()函数中,该函数对模型配置进行参数化以便进行实验。

模型训练和性能评估

我们对每个股票的五年数据进行模型训练,以预测此期后的第一个月,并使用我们在第七章中开发的MultipleTimeSeriesCV重复此过程 36 次。请查看笔记本,了解遵循前一章节中展示的模式的训练循环。

我们使用早停法来简化阐述,只进行五个时期的训练,从而产生积极的偏差,使结果仅具有说明性质。训练长度从 1 到 27 时期不等,中位数为 5 时期,这表明模型通常只能从过去回报中学习非常有限的系统信息。因此,精选结果会产生大约 4 的累积平均信息系数,如图 18.15所示:

图 18.15:(有偏差的)最佳时期的样本外信息系数

我们现在将进入一个更复杂的示例,使用 CNN 处理多个时间序列数据。

CNN-TA – 将时间序列聚类成 2D 格式

为了利用时间序列数据的网格结构,我们可以使用 CNN 架构处理单变量和多变量时间序列。在后一种情况下,我们将不同的时间序列视为通道,类似于不同的颜色信号。

另一种方法将一系列 alpha 因子转换为二维格式,以利用 CNN 检测局部模式的能力。Sezer 和 Ozbayoglu(2018)提出了CNN-TA,它计算不同间隔的 15 个技术指标,并使用分层聚类(参见 第十三章数据驱动的风险因素和基于无监督学习的资产配置)在二维网格中定位行为相似的指标。

作者训练了一个类似于我们之前使用的 CIFAR-10 示例的 CNN,以预测在给定日期购买、持有或出售资产。他们将 CNN 的性能与“买入并持有”及其他模型进行比较,并发现它在使用道琼斯 30 只股票和 2007-2017 年间交易最多的九只 ETF 的每日价格序列时胜过了所有的替代方案。

在本节中,我们使用每日美国股票价格数据尝试这种方法,并演示如何计算并转换类似一组指标为图像格式。然后,我们训练一个 CNN 来预测每日回报,并根据结果信号评估一个简单的多空策略。

在不同间隔创建技术指标

我们首先从 Quandl Wiki 数据集中按美元交易额选取了 2007-2017 年滚动五年期的 500 只美国股票的宇宙。有关本节中的代码示例和一些额外的实现细节,请参阅笔记本engineer_cnn_features.ipynb

我们的特征包括 15 个技术指标和风险因子,我们计算这些指标以获取 15 个不同的间隔,然后将它们排列在一个 网格中。以下表列出了一些技术指标;此外,我们还使用以下度量标准(请参见 附录以获取更多信息)。

  • 加权和指数移动平均线WMAEMA)的收盘价

  • 收盘价变化率ROC

  • Chande 动量振荡器CMO

  • Chaikin A/D 振荡器ADOSC

  • 平均方向运动指数ADX

图 8.16:技术指标

对于每个指标,我们将时间段从 6 变化到 20 以获取 15 个不同的测量。例如,以下代码示例计算相对强度指数RSI):

T = list(range(6, 21))
for t in T:
    universe[f'{t:02}_RSI'] = universe.groupby(level='symbol').close.apply(RSI, timeperiod=t) 

对于需要多个输入的归一化平均真实波幅NATR),计算如下:

for t in T:
    universe[f'{t:02}_NATR'] = universe.groupby(
                        level='symbol', group_keys=False).apply(
                        lambda x: NATR(x.high, x.low, x.close, timeperiod=t)) 

有关更多详细信息,请参阅 TA-Lib 文档。

计算不同时间范围的滚动因子贝塔

我们还使用五个法玛-法 rench 风险因子(Fama 和 French,2015;参见 第四章金融特征工程-如何研究 Alpha 因子)。它们反映了股票回报对被证明对股票回报产生影响的因素的敏感性。我们通过计算股票每日回报与旨在反映底层驱动因素的投资组合回报之间的滚动 OLS 回归系数来捕获这些因素:

  • 股权风险溢价:美国股票的市值加权回报减去 1 个月期美国国债利率

  • 规模SMB):归类为小型(按市值)的股票的回报与大型股票之间的差异

  • 价值HML):具有账面市值比的股票的回报与具有价值的股票之间的差异

  • 投资CMA):具有保守投资支出的公司与具有激进支出的公司之间的回报差异

  • 盈利能力RMW):类似地,对具有强大盈利能力的股票与具有指标的股票之间的回报差异进行比较。

我们使用 pandas_datareader 从 Kenneth French 的数据库中获取数据(见 第四章金融特征工程 - 如何研究阿尔法因子):

import pandas_datareader.data as web
factor_data = (web.DataReader('F-F_Research_Data_5_Factors_2x3_daily', 
                              'famafrench', start=START)[0]) 

接下来,我们使用 statsmodels 的 RollingOLS() 在不同长度的窗口期内运行回归,范围从 15 天到 90 天不等。我们在 .fit() 方法上设置 params_only 参数以加快计算速度,并使用拟合的 factor_model.params 属性捕获系数:

factors = [Mkt-RF, 'SMB', 'HML', 'RMW', 'CMA']
windows = list(range(15, 90, 5))
for window in windows:
    betas = []
    for symbol, data in universe.groupby(level='symbol'):
        model_data = data[[ret]].merge(factor_data, on='date').dropna()
        model_data[ret] -= model_data.RF
        rolling_ols = RollingOLS(endog=model_data[ret], 
                                 exog=sm.add_constant(model_data[factors]), 
                                                      window=window)
        factor_model = rolling_ols.fit(params_only=True).params.drop('const',  
                                                                     axis=1)
        result = factor_model.assign(symbol=symbol).set_index('symbol', 
                                                              append=True)
        betas.append(result)
    betas = pd.concat(betas).rename(columns=lambda x: f'{window:02}_{x}')
    universe = universe.join(betas) 

基于互信息的特征选择

下一步是从 20 个候选项中选择最相关的 15 个特征来填充 15×15 的输入网格。以下步骤的代码示例在笔记本 convert_cnn_features_to_image_format 中。

为此,我们估计每个指标和 15 个间隔相对于我们的目标(一天后的回报)的互信息。如 第四章金融特征工程 - 如何研究阿尔法因子 中所讨论的,scikit-learn 提供了 mutual_info_regression() 函数,使这一过程变得简单,尽管耗时和占用内存。为加速此过程,我们随机抽样了 100,000 个观察结果:

df = features.join(targets[target]).dropna().sample(n=100000)
X = df.drop(target, axis=1)
y = df[target]
mi[t] = pd.Series(mutual_info_regression(X=X, y=y), index=X.columns) 

图 18.16 的左侧面板显示了每个指标在 15 个间隔上的平均互信息。从该指标的角度来看,NATR、PPO 和 Bollinger Bands 最重要:

图 18.17:时间序列的互信息和二维网格布局

分层特征聚类

图 18.16 的右侧面板绘制了我们将输入到 CNN 中的 15×15 二维特征网格。如本章的第一节所讨论的,CNN 依赖于通常在图像中找到的相关模式的局部性,其中附近的像素密切相关,并且一个像素到下一个像素的变化通常是渐进的。

为了以类似的方式组织我们的指标,我们将遵循 Sezer 和 Ozbayoglu 的方法应用分层聚类。目标是识别行为类似的特征,并相应地对网格的列和行进行排序。

我们可以构建在第十三章中介绍的 SciPy 的pairwise_distance()linkage()dendrogram()函数之上,以及其他形式的聚类。我们创建一个辅助函数,对输入进行逐列标准化,以避免由于比例差异而扭曲特征之间的距离,并使用将聚类合并以最小化方差的 Ward 标准。该函数返回树状图中叶节点的顺序,进而显示了较大聚类的逐步形成:

def cluster_features(data, labels, ax, title):
    data = StandardScaler().fit_transform(data)
    pairwise_distance = pdist(data)
    Z = linkage(data, 'ward')
    dend = dendrogram(Z,
                      labels=labels,
                      orientation='top',
                      leaf_rotation=0.,
                      leaf_font_size=8.,
                      ax=ax)
    return dend['ivl'] 

为了获得列中技术指标的优化顺序和行中不同间隔的不同顺序,我们使用 NumPy 的.reshape()方法确保我们想要聚类的维度出现在我们传递给cluster_features()的二维数组的列中:

labels = sorted(best_features)
col_order = cluster_features(features.dropna().values.reshape(-1, 15).T, 
                             labels)
labels = list(range(1, 16))
row_order = cluster_features(
    features.dropna().values.reshape(-1, 15, 15).transpose((0, 2, 1)).reshape(-1, 15).T, labels) 

图 18.18显示了行和列特征的树状图:

图 18.18:行和列特征的树状图

我们相应地重新排序特征,并将结果存储为 CNN 的输入,我们将在下一步中创建。

创建和训练一个卷积神经网络

现在我们准备按照前一节中概述的步骤设计、训练和评估一个 CNN。笔记本cnn_for_trading.ipynb包含相关的代码示例。

我们再次紧密跟随作者的步伐,创建了一个 CNN,其中包含 2 个卷积层,卷积核尺寸分别为 3 和 16、32 个过滤器,然后是一个大小为 2 的最大池化层。我们将最后一层过滤器的输出展平,并将结果的 1,568 个输出连接到大小为 32 的密集层,对输入和输出连接分别应用 25%和 50%的丢弃概率以减轻过拟合。以下表格总结了包含 55,041 个可训练参数的 CNN 结构:

Layer (type)                 Output Shape              Param #   
CONV1 (Conv2D)               (None, 15, 15, 16)        160       
CONV2 (Conv2D)               (None, 15, 15, 32)        4640      
POOL1 (MaxPooling2D)         (None, 7, 7, 32)          0         
DROP1 (Dropout)              (None, 7, 7, 32)          0         
FLAT1 (Flatten)              (None, 1568)              0         
FC1 (Dense)                  (None, 32)                50208     
DROP2 (Dropout)              (None, 32)                0         
FC2 (Dense)                  (None, 1)                 33        
Total params: 55,041
Trainable params: 55,041
Non-trainable params: 0 

我们使用第七章中介绍的MutipleTimeSeriesCV训练和验证集索引生成器对模型进行交叉验证,该章节名为线性模型 - 从风险因素到回报预测。我们在训练期间提供了 5 年的交易日数据,每次以 64 个随机样本的批次进行,并使用随后的 3 个月进行验证,涵盖了 2014 年至 2017 年。

我们将特征缩放到[-1, 1]范围内,并再次使用 NumPy 的.reshape()方法创建所需的格式:

def get_train_valid_data(X, y, train_idx, test_idx):
    x_train, y_train = X.iloc[train_idx, :], y.iloc[train_idx]
    x_val, y_val = X.iloc[test_idx, :], y.iloc[test_idx]
    scaler = MinMaxScaler(feature_range=(-1, 1))
    x_train = scaler.fit_transform(x_train)
    x_val = scaler.transform(x_val)
    return (x_train.reshape(-1, size, size, 1), y_train,
            x_val.reshape(-1, size, size, 1), y_val) 

训练和验证遵循第十七章中介绍的流程,用于交易的深度学习,依靠检查点来在每个时期存储权重,并在不需要昂贵的重新训练的情况下生成表现最佳迭代的预测。

为了评估模型的预测准确性,我们计算验证集的每日信息系数IC),如下所示:

checkpoint_path = Path('models', 'cnn_ts')
for fold, (train_idx, test_idx) in enumerate(cv.split(features)):
    X_train, y_train, X_val, y_val = get_train_valid_data(features, target, train_idx, test_idx)
    preds = y_val.to_frame('actual')
    r = pd.DataFrame(index=y_val.index.unique(level='date')).sort_index()
    model = make_model(filter1=16, act1='relu', filter2=32, 
                       act2='relu', do1=.25, do2=.5, dense=32)
    for epoch in range(n_epochs):            
        model.fit(X_train, y_train,
                  batch_size=batch_size,
                  validation_data=(X_val, y_val),
                  epochs=1, verbose=0, shuffle=True)
        model.save_weights(
            (checkpoint_path / f'ckpt_{fold}_{epoch}').as_posix())
        preds[epoch] = model.predict(X_val).squeeze()
        r[epoch] = preds.groupby(level='date').apply(
            lambda x: spearmanr(x.actual, x[epoch])[0]).to_frame(epoch) 

我们使用随机梯度下降Nesterov动量(见 第十七章交易的深度学习)对模型进行最多 10 个时期的训练,并发现表现最佳的时期,即第 8 和第 9 个时期,达到每日平均 IC 约为 0.009(低)。

组装最佳模型以生成可交易信号

为了减少测试期预测的方差,我们生成并对在交叉验证期间表现最佳的 3 个模型的预测进行平均,这里对应于训练 4、8 和 9 个时期。与前面的时间序列示例一样,相对较短的训练期强调了金融时间序列中信号数量较少,与例如图像数据中包含的系统信息相比,数量较少。

generate_predictions() 函数重新加载模型权重并返回目标周期的预测结果:

def generate_predictions(epoch):
    predictions = []
    for fold, (train_idx, test_idx) in enumerate(cv.split(features)):
        X_train, y_train, X_val, y_val = get_train_valid_data(
            features, target, train_idx, test_idx)
        preds = y_val.to_frame('actual')
        model = make_model(filter1=16, act1='relu', filter2=32, 
                       act2='relu', do1=.25, do2=.5, dense=32)
        status = model.load_weights(
            (checkpoint_path / f'ckpt_{fold}_{epoch}').as_posix())
        status.expect_partial()
        predictions.append(pd.Series(model.predict(X_val).squeeze(), 
                                     index=y_val.index))
    return pd.concat(predictions)   
preds = {}
for i, epoch in enumerate(ic.drop('fold', axis=1).mean().nlargest(3).index):
    preds[i] = generate_predictions(epoch) 

我们存储预测结果,并继续根据这些每日回报预测进行交易策略的回测。

回测长短交易策略

为了了解信号质量,我们计算根据信号五分位数选择的等权重投资组合之间的差距,使用 Alphalens(见 第四章金融特征工程 - 如何研究 Alpha 因子)。

图 18.19 显示,对于一天的投资视角,这种天真的策略在 2013 年至 2017 年期间每天大约可以获得超过四个基点的收益:

图 18.19: Alphalens 信号质量评估

我们将这个稍微令人鼓舞的结果转化为一个简单的策略,根据收益预测最高(最低)的 25 只股票进入多头(空头)头寸,每日进行交易。图 18.20 显示,这个策略在很大程度上与标准普尔 500 指数相竞争(左侧面板),在回测期间累积回报率为 35.6%,夏普比率为 0.53(未考虑交易成本;右侧面板)。

图 18.20: 样本内外回测表现

总结与经验教训

看起来 CNN 能够从转换为二维网格的 alpha 因子的时间序列中提取有意义的信息。对不同体系结构和训练参数的实验表明,结果并不十分稳健,稍微修改就可能显著降低性能。

调整尝试也凸显了成功训练深度 NN 的困难,特别是当信噪比较低时:太复杂的网络或错误的优化器可能导致 CNN 陷入一个始终预测常数值的局部最优解。

要改善结果并获得接近作者所取得的结果(使用不同的结果)的性能,最重要的步骤是重新审视特征。有许多替代方案来计算有限技术指标的不同间隔。任何适当数量的时间序列特征都可以按照n×m的矩形格式排列,并从 CNN 学习本地模式的能力中受益。选择n个指标和m个间隔只是为了更容易地组织二维网格的行和列。试试看吧!

此外,作者采取了一种分类方法来算法标记的买入、持有和卖出结果(请参阅文章了解计算的概要),而我们的实验则将回归应用于每日收益。图 18.18中的 Alphalens 图表表明,更长的持有期(特别是 10 天)可能效果更好,因此还有调整策略或切换到分类方法的空间。

总结

在本章中,我们介绍了 CNN,这是一种专门设计的神经网络架构,它借鉴了我们对人类视觉(有限)理解的线索,并且在类似网格的数据上表现特别好。我们涵盖了卷积或互相关的中心操作,推动了滤波器的发现,这些滤波器反过来检测出有助于解决手头任务的特征。

我们回顾了几种最先进的架构,它们是很好的起点,特别是因为迁移学习使我们能够重复使用预训练的权重,并减少否则相当计算和数据密集型的训练工作。我们还看到 Keras 使得实现和训练各种深度 CNN 架构相对简单。

在下一章中,我们将注意力转向专门设计用于序列数据的循环神经网络,例如时间序列数据,这对于投资和交易至关重要。

第十九章:用于多变量时间序列和情感分析的 RNNs

上一章展示了卷积神经网络CNNs)是如何设计来学习代表网格状数据的空间结构的特征,尤其是图像,但也包括时间序列。本章介绍了专门用于序列数据的循环神经网络RNNs),其中模式随时间演变,学习通常需要记忆先前的数据点。

前馈神经网络FFNNs)将每个样本的特征向量视为独立且同分布。因此,它们在评估当前观察时不考虑先前的数据点。换句话说,它们没有记忆。

CNNs 使用的一维和二维卷积滤波器可以提取特征,这些特征是通常是一小部分相邻数据点的函数。然而,它们只允许浅层参数共享:每个输出都是将相同的滤波器应用于相关时间步和特征的结果。

RNN 模型的主要创新在于每个输出都是先前输出和新信息的函数。因此,RNNs 可以将先前观察到的信息纳入到它们使用当前特征向量进行的计算中。这种循环形式使得参数共享在更深的计算图中变得可能(Goodfellow,Bengio 和 Courville,2016 年)。在本章中,您将遇到长短期记忆LSTM)单元和门控循环单元GRUs),旨在克服学习长程依赖性所关联的梯度消失的挑战,在这种情况下,错误需要在许多连接上传播。

成功的 RNN 使用案例包括各种需要将一个或多个输入序列映射到一个或多个输出序列并突出显示自然语言应用的任务。我们将探讨如何将 RNNs 应用于单变量和多变量时间序列,以使用市场或基本数据预测资产价格。我们还将介绍如何使用词嵌入来利用替代文本数据,这些数据在第十六章中介绍了用于盈利电话和 SEC 文件的词嵌入,以对文档中表达的情感进行分类。最后,我们将使用 SEC 文件的最具信息量的部分来学习词嵌入,并预测文件提交日期前后的回报。

更具体地说,在本章中,您将学习以下内容:

  • 循环连接如何使 RNNs 记忆模式并建模隐藏状态

  • 展开和分析 RNNs 的计算图

  • 门控单元如何从数据中学习调节 RNN 内存以实现长程依赖性

  • 在 Python 中设计和训练用于单变量和多变量时间序列的 RNNs

  • 如何学习词嵌入或使用预训练的词向量进行情感分析与 RNNs

  • 使用自定义词嵌入构建双向 RNN 以预测股票回报

你可以在本章的 GitHub 存储库目录中找到代码示例和其他资源。

递归神经网络的工作原理

RNN 假定输入数据已经生成为一个序列,以便以前的数据点影响当前观察结果,并且对预测后续值具有相关性。因此,它们可以对比 FFNN 和 CNN 更复杂的输入输出关系进行建模,后者设计为使用给定数量的计算步骤将一个输入向量映射到一个输出向量。相反,RNN 可以对最佳表示为向量序列的任务的数据进行建模,其中输入、输出或两者都是序列。有关概述,请参阅 Goodfellow、Bengio 和 Courville(2016)的第十章

图 19.1中的图表灵感来自 Andrew Karpathy 的 2015 年博客文章递归神经网络的不合理有效性(请参阅 GitHub 获取链接),它说明了通过一个或多个神经网络层进行的非线性转换从输入到输出向量的映射:

图 19.1:各种类型的序列到序列模型

左侧面板显示了固定大小向量之间的一对一映射,这在上述最后两章中涵盖的 FFN 和 CNN 中很典型。其他三个面板显示了通过对新输入和上一次迭代产生的状态应用循环转换将输入向量映射到输出向量的各种 RNN 应用。输入到 RNN 的x向量也称为上下文

这些向量是按时间索引的,通常是交易相关应用所需,但它们也可以由不同的顺序值标记。通用的序列到序列映射任务和示例应用包括:

  • 一对多:例如,图像字幕生成接受单个像素向量(与上一章节中相同)并将其映射到一系列单词。

  • 多对一:情感分析接受一系列单词或标记(参见第十四章用于交易的文本数据 - 情感分析)并将其映射到一个输出标量或向量。

  • 多对多:机器翻译或视频帧标记将输入向量序列映射到输出向量序列,无论是同步(如所示)还是异步方式。多变量时间序列的多步预测也将几个输入向量映射到几个输出向量。

请注意,输入和输出序列可以是任意长度的,因为从数据中学习的固定但学习的循环转换可以应用任意次数。

正如 CNN 能轻松扩展到大尺寸图像,而且一些 CNN 可以处理可变大小的图像,RNN 能够扩展到比不适用于基于序列任务的网络更长的序列。大多数 RNN 也可以处理可变长度的序列。

展开具有循环的计算图

RNN 被称为循环的,因为它们以一种方式将相同的转换应用于序列的每个元素,RNN 的输出取决于先前迭代的结果。因此,RNN 保持一个内部状态,它捕获了序列中以前元素的信息,就像内存一样。

图 19.2显示了在训练期间学习两个权重矩阵的单个隐藏 RNN 单元暗示的计算图

  • W[hh]:应用于前一个隐藏状态 h[t-1]

  • W[hx]:应用于当前输入 x[t]

RNN 的输出 y[t] 是使用诸如 tanh 或 ReLU 激活函数的两个矩阵乘法之和的非线性转换:

图 19.2:具有单个隐藏单元的 RNN 的计算图的循环和展开视图

方程的右侧显示了展开在图右侧面板中所示的循环关系的影响。它突出显示了重复的线性代数转换以及将来自过去序列元素的信息与当前输入或上下文相结合的隐藏状态。另一种表述将上下文向量连接到仅第一个隐藏状态;我们将概述修改此基线架构的其他选项。

时间反向传播

在前述图中展开的计算图突出显示了学习过程必然涵盖给定输入序列的所有时间步。在训练期间更新权重的反向传播算法涉及沿着展开的计算图进行从左到右的前向传递,然后沿相反方向进行后向传递。

正如在第十七章“交易的深度学习”中所讨论的那样,反向传播算法评估损失函数并计算其相对于参数的梯度,以相应地更新权重。在 RNN 上下文中,反向传播从计算图的右侧向左侧运行,从最终时间步更新参数一直到初始时间步。因此,该算法被称为时间反向传播(Werbos 1990)。

它突出了 RNN 通过在任意数量的序列元素之间共享参数来建模长程依赖关系的能力,同时保持相应的状态。另一方面,由于其固有的顺序性质,它的计算成本相当高,每个时间步的计算都无法并行化。

替代 RNN 架构

就像我们在前两章中介绍的 FFNN 和 CNN 架构一样,RNN 可以通过各种方式进行优化,以捕捉输入和输出数据之间的动态关系。

除了修改隐藏状态之间的重复连接之外,替代方法还包括重复输出关系、双向循环神经网络和编码器-解码器架构。请参阅 GitHub 获取背景参考,以补充本简要摘要。

输出重复和教师强迫

减少隐藏状态重复计算复杂性的一种方法是将单元的隐藏状态连接到前一个单元的输出而不是其隐藏状态。由此产生的 RNN 的容量低于前面讨论的架构,但是不同的时间步骤现在是解耦的,可以并行训练。

然而,要成功学习相关的过去信息,训练输出样本需要反映这些信息,以便通过反向传播调整网络参数。在资产回报与它们的滞后值无关的程度上,金融数据可能不符合此要求。沿着输入向量一起使用先前的结果值被称为教师强迫(Williams 和 Zipser,1989 年)。

输出到后续隐藏状态的连接也可以与隐藏循环结合使用。然而,训练需要通过时间反向传播,并且不能并行运行。

双向循环神经网络

对于一些任务来说,使输出不仅依赖于过去的序列元素,而且还依赖于未来元素可以是现实的和有益的(Schuster 和 Paliwal,1997 年)。机器翻译或语音和手写识别是其中一些例子,后续序列元素既具有信息量又实际可用于消除竞争性输出的歧义。

对于一维序列,双向循环神经网络结合了向前移动的 RNN 和向后扫描序列的另一个 RNN。因此,输出将取决于序列的过去和未来。在自然语言和音乐领域的应用(Sigtia 等,2014 年)非常成功(见第十六章Word Embeddings for Earnings Calls and SEC Filings,以及本章最后一个使用 SEC 文件的示例)。

双向循环神经网络也可以与二维图像数据一起使用。在这种情况下,一对 RNN 在每个维度中执行序列的前向和后向处理。

编码器-解码器架构、注意力和变压器

到目前为止讨论的架构假设输入和输出序列具有相等的长度。编码器-解码器架构,也称为序列到序列seq2seq)架构,放宽了这一假设,并且已经成为具有这种特征的机器翻译和其他应用非常流行的架构(Prabhavalkar 等,2017 年)。

编码器是将输入空间映射到不同空间,也称为潜在空间的 RNN,而解码器功能是将编码后的输入映射到目标空间(Cho 等人,2014 年)。在下一章中,我们将介绍使用各种深度学习架构在无监督设置中学习特征表示的自动编码器。

编码器和解码器 RNN 是联合训练的,以使最终编码器隐藏状态的输入成为解码器的输入,后者又学习匹配训练样本。

注意力机制解决了使用固定大小的编码器输入时的限制,当输入序列本身变化时。该机制将原始文本数据转换为分布式表示(见第十六章用于财报电话和 SEC 备案的词嵌入),存储结果,并使用这些特征向量的加权平均值作为上下文。模型通过学习来学习权重,并且在不同的输入元素之间交替放置更多的权重或关注

最近的transformer架构放弃了重复和卷积,完全依赖于这种注意力机制来学习输入输出映射。它在机器翻译任务上取得了卓越的质量,同时需要更少的训练时间,部分原因是它可以并行化(Vaswani 等人,2017 年)。

如何设计深度 RNNs

图 19.2中的展开的计算图显示,每个转换都涉及线性矩阵操作,然后是一个非线性变换,可以共同由单个网络层表示。

在前两章中,我们看到增加深度允许 FFNNs,特别是 CNNs,学习更有用的分层表示。RNNs 也从将输入输出映射分解为多个层次中受益。对于 RNNs,此映射通常转换为:

  • 将当前隐藏状态的输入和先前隐藏状态传入当前隐藏状态

  • 将隐藏状态传入输出

一种常见的方法是在彼此之上堆叠循环层,以便它们学习输入数据的分层时间表示。这意味着较低层可能捕获较高频率的模式,由较高层合成为对于分类或回归任务有用的低频特征。我们将在下一节中演示这种方法。

不太流行的替代方案包括在输入到隐藏状态的连接上添加层,隐藏状态之间的连接,或从隐藏状态到输出的连接。这些设计使用跳过连接来避免时间步长之间最短路径增加并且训练变得更加困难的情况。

学习长程依赖的挑战

理论上,RNN 可以利用任意长的序列中的信息。然而,在实践中,它们仅限于查看几个步骤。更具体地说,RNN 难以从远离当前观察的时间步中获得有用的上下文信息(Hochreiter 等,2001)。

根本问题是在反向传播过程中在许多时间步骤上对梯度的重复乘法的影响。结果,梯度往往会消失并朝零减小(典型情况),或者爆炸并朝无穷大增长(较少发生,但使优化变得非常困难)。

即使参数允许稳定性并且网络能够存储记忆,由于许多雅各比矩阵的乘法,长期交互的权重将指数级地变小,这些矩阵包含梯度信息。实验表明,随机梯度下降在仅具有 10 或 20 个元素的序列上训练 RNN 面临严重挑战。

已经引入了几种 RNN 设计技术来解决这一挑战,包括回声状态网络(Jaeger,2001)和渗漏单元(Hihi 和 Bengio,1996)。后者在不同的时间尺度上运行,将模型的一部分集中在更高频率的表示上,另一部分集中在更低频率的表示上,以有意识地从数据中学习和组合不同的方面。其他策略包括跳过时间步的连接或集成来自不同频率的信号的单元。

最成功的方法使用被训练来调节单元在当前状态中保持多少过去信息以及何时重置或忘记这些信息的门控单元。因此,它们能够在数百个时间步长上学习依赖关系。最流行的例子包括长短期记忆LSTM)单元和门控循环单元GRUs)。Chung 等人(2014)的实证比较发现,这两种单元都优于简单的循环单元,如双曲正切单元,并且在各种语音和音乐建模任务上表现同样出色。

长短期记忆 - 学会忘记多少

具有 LSTM 结构的 RNN 拥有更复杂的单元,这些单元保持内部状态。它们包含门来跟踪输入序列元素之间的依赖关系,并相应调整单元的状态。这些门之间以循环方式连接,而不是我们之前遇到的隐藏单元。它们旨在通过让梯度无变化地通过以解决由于可能非常小或非常大的值的重复乘法而导致的梯度消失和梯度爆炸的问题(Hochreiter 和 Schmidhuber,1996)。

图 19.3中的图示显示了未展开的 LSTM 单元的信息流,并概述了其典型的门控机制:

图 19.3:通过展开的 LSTM 单元的信息流

一个典型的 LSTM 单元结合了四个参数化层,这些层相互作用并通过转换和传递向量来与细胞状态交互。这些层通常涉及输入门、输出门和遗忘门,但也有可能有额外的门或缺少其中一些机制的变体。图 19.4 中的白色节点标识逐元素操作,灰色元素表示在训练期间学习的具有权重和偏差参数的层:

img/B15439_19_04.png

19.4:LSTM 单元的逻辑和数学原理

细胞状态 c 沿着细胞顶部的水平连接传递。细胞状态与各种门的交互导致一系列的循环决策:

  1. 遗忘门 控制应该清空多少细胞状态以调节网络的记忆。它接收先前的隐藏状态,h[t-1],和当前的输入,x[t],作为输入,计算一个 sigmoid 激活,并将结果值 f[t](已归一化为 [0, 1] 范围)与细胞状态相乘,相应地减少或保持它。

  2. 输入门 也从 h[t-1] 和 x[t] 计算一个 sigmoid 激活,产生更新候选值。在 [-1, 1] 范围内的 tan[h] 激活会乘以更新候选值 u[t],并根据结果的符号将其加或减到细胞状态中。

  3. 输出门 使用 sigmoid 激活 o[t] 过滤更新的细胞状态,并使用 tan[h] 激活将其乘以归一化到 [-1, 1] 范围的细胞状态。

门控循环单元

GRUs 通过省略输出门简化了 LSTM 单元。已经证明它们在某些语言建模任务上能够达到类似的性能,但在较小的数据集上表现更好。

GRUs 旨在使每个循环单元自适应地捕获不同时间尺度的依赖关系。与 LSTM 单元类似,GRU 具有调节单元内信息流的门控单元,但丢弃了单独的记忆单元(有关更多细节,请参阅 GitHub 上的参考资料)。

使用 TensorFlow 2 进行时间序列的 RNN

在本节中,我们说明如何使用 TensorFlow 2 库构建各种场景的循环神经网络。第一组模型包括对单变量和多变量时间序列进行回归和分类。第二组任务侧重于文本数据,用于情感分析,使用转换为词嵌入的文本数据(参见 第十六章用于收益电话和 SEC 提交的词嵌入)。

更具体地说,我们将首先演示如何准备时间序列数据,使用单个 LSTM 层预测单变量时间序列的下一个值来预测股票指数值。

接下来,我们将构建一个具有三个不同输入的深度循环神经网络(RNN)来分类资产价格的变动。为此,我们将结合一个具有两层的堆叠 LSTM,学习的嵌入和独热编码的分类数据。最后,我们将演示如何使用 RNN 建模多变量时间序列

单变量回归–预测标准普尔 500

在本小节中,我们将预测标准普尔 500 指数的值(有关实现细节,请参阅univariate_time_series_regression笔记本)。

我们将从联邦储备银行的数据服务(FRED;请参阅第二章市场和基本数据–来源和技术)获取 2010-2019 年的数据:

sp500 = web.DataReader('SP500', 'fred', start='2010', end='2020').dropna()
sp500.info()
DatetimeIndex: 2463 entries, 2010-03-22 to 2019-12-31
Data columns (total 1 columns):
 #   Column  Non-Null Count  Dtype
---  ------  --------------  -----  
 0   SP500   2463 non-null   float64 

我们通过使用 scikit-learn 的MinMaxScaler()类将数据缩放到[0, 1]区间来预处理数据:

from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
sp500_scaled = pd.Series(scaler.fit_transform(sp500).squeeze(), 
                         index=sp500.index) 

如何为 RNN 整理时间序列数据的形状

我们生成 63 个连续交易日的序列,大约三个月,并使用具有 20 个隐藏单元的单个 LSTM 层预测一步的缩放指数值。

每个 LSTM 层的输入必须具有三个维度,即:

  • 批量大小:一个序列是一个样本。一个批次包含一个或多个样本。

  • 时间步长:一个时间步是样本中的一个单独观察。

  • 特征:一个特征是一个时间步的一个观察。

下图可视化了输入张量的形状:

图 19.5:RNN 输入张量的三个维度

我们的 S&P 500 样本有 2,463 个观测值或时间步长。我们将使用每个窗口为 63 个观测值的重叠序列。使用大小为T = 5 的更简单的窗口来说明这种自回归序列模式,我们获得了每个输出与其前五个滞后相关联的输入输出对,如下表所示:

图 19.6:具有 T=5 大小窗口的输入输出对

我们可以使用create_univariate_rnn_data()函数堆叠我们使用滚动窗口选择的重叠序列:

def create_univariate_rnn_data(data, window_size):
    y = data[window_size:]
    data = data.values.reshape(-1, 1) # make 2D
    n = data.shape[0]
    X = np.hstack(tuple([data[i: n-j, :] for i, j in enumerate(range(
                                                     window_size, 0, -1))]))
    return pd.DataFrame(X, index=y.index), y 

我们使用window_size=63对经过缩放的股票指数应用此函数以获得具有样本数量 x 时间步长数量的二维数据集的形状:

X, y = create_univariate_rnn_data(sp500_scaled, window_size=63)
X.shape
(2356, 63) 

我们将使用 2019 年的数据作为我们的测试集,并重塑特征以添加必要的第三维:

X_train = X[:'2018'].values.reshape(-1, window_size, 1)
y_train = y[:'2018']
# keep the last year for testing
X_test = X['2019'].values.reshape(-1, window_size, 1)
y_test = y['2019'] 

如何定义具有单个 LSTM 层的两层 RNN

现在我们已经从时间序列创建了自回归输入/输出对并将这些对分成训练集和测试集,我们可以定义我们的 RNN 架构了。TensorFlow 2 的 Keras 接口使得构建具有以下规格的两个隐藏层的 RNN 非常简单:

  • 层 1:具有 10 个隐藏单元的 LSTM 模块(带有input_shape = (window_size,1);在训练期间我们将在省略的第一维中定义batch_size

  • 层 2:具有单个单元和线性激活的完全连接模块

  • 损失mean_squared_error以匹配回归目标

只需几行代码就可以创建计算图:

rnn = Sequential([
    LSTM(units=10,
         input_shape=(window_size, n_features), name='LSTM'),
    Dense(1, name='Output')
]) 

总结显示模型有 491 个参数:

rnn.summary()
Layer (type)                 Output Shape              Param #   
LSTM (LSTM)                  (None, 10)                480       
Output (Dense)               (None, 1)                 11        
Total params: 491
Trainable params: 491 

训练和评估模型

我们使用默认设置为 RNN 推荐的 RMSProp 优化器进行训练,并为这个回归问题编译模型 mean_squared_error

optimizer = keras.optimizers.RMSprop(lr=0.001,
                                     rho=0.9,
                                     epsilon=1e-08,
                                     decay=0.0)
rnn.compile(loss='mean_squared_error', optimizer=optimizer) 

我们定义一个 EarlyStopping 回调函数,并训练模型 500 次:

early_stopping = EarlyStopping(monitor='val_loss', 
                              patience=50,
                              restore_best_weights=True)
lstm_training = rnn.fit(X_train,
                       y_train,
                       epochs=500,
                       batch_size=20,
                       validation_data=(X_test, y_test),
                       callbacks=[checkpointer, early_stopping],
                       verbose=1) 

训练在 138 个周期后停止。图 19.7 中的损失历史显示了训练和验证 RMSE 的 5 个周期滚动平均值,突出显示了最佳周期,并显示损失为 0.998 百分比:

loss_history = pd.DataFrame(lstm_training.history).pow(.5)
loss_history.index += 1
best_rmse = loss_history.val_loss.min()
best_epoch = loss_history.val_loss.idxmin()
loss_history.columns=['Training RMSE', 'Validation RMSE']
title = f'Best Validation RMSE: {best_rmse:.4%}'
loss_history.rolling(5).mean().plot(logy=True, lw=2, title=title, ax=ax) 

图 19.7

图 19.7:交叉验证性能

重新缩放预测

我们使用 MinMaxScaler()inverse_transform() 方法将模型预测重新缩放到原始标普 500 范围内的值:

test_predict_scaled = rnn.predict(X_test)
test_predict = (pd.Series(scaler.inverse_transform(test_predict_scaled)
                          .squeeze(), 
                          index=y_test.index)) 

图 19.8 中的四个图表说明了基于跟踪 2019 年标普 500 数据的重新缩放预测的预测性能,测试信息系数IC)为 0.9889:

图 19.8

图 19.8:RNN 在标普 500 预测上的性能

堆叠 LSTM - 预测价格变动和回报

我们现在将通过堆叠两个 LSTM 层使用 Quandl 股价数据构建一个更深层的模型(有关实施详细信息,请参见 stacked_lstm_with_feature_embeddings.ipynb 笔记本)。此外,我们将包含不是顺序的特征,即标识股票和月份的指标变量。

图 19.9 概述了演示如何将不同数据源结合在一个深度神经网络中的架构。例如,您可以添加技术或基本特征,而不是或者另外添加一个独热编码的月份:

图 19.9

图 19.9:带有额外特征的堆叠 LSTM 架构

准备数据 - 如何创建每周股票回报

我们加载 Quandl 调整后的股票价格数据(请参阅 GitHub 上如何获取源数据的说明),如下所示(参见 build_dataset.ipynb 笔记本):

prices = (pd.read_hdf('../data/assets.h5', 'quandl/wiki/prices')
          .adj_close
          .unstack().loc['2007':])
prices.info()
DatetimeIndex: 2896 entries, 2007-01-01 to 2018-03-27
Columns: 3199 entries, A to ZUMZ 

我们首先为 2008-17 年期间具有完整数据的接近 2,500 只股票生成每周回报:

returns = (prices
           .resample('W')
           .last()
           .pct_change()
           .loc['2008': '2017']
           .dropna(axis=1)
           .sort_index(ascending=False))
returns.info()
DatetimeIndex: 2576 entries, 2017-12-29 to 2008-01-01
Columns: 2489 entries, A to ZUMZ 

我们如下所示创建并堆叠了每只股票和每周 52 周回报的滚动序列:

n = len(returns)
T = 52
tcols = list(range(T))
tickers = returns.columns
data = pd.DataFrame()
for i in range(n-T-1):
    df = returns.iloc[i:i+T+1]
    date = df.index.max()    
    data = pd.concat([data, (df.reset_index(drop=True).T
                             .assign(date=date, ticker=tickers)
                             .set_index(['ticker', 'date']))]) 

我们将在 1 和 99 百分位水平处截尾异常值,并创建一个二进制标签,指示周回报是否为正:

data[tcols] = (data[tcols].apply(lambda x: x.clip(lower=x.quantile(.01),
                                                  upper=x.quantile(.99))))
data['label'] = (data['fwd_returns'] > 0).astype(int) 

因此,我们获得了超过 2,400 只股票的 1.16 百万观测值,每只股票都有 52 周的滞后回报(加上标签):

data.shape
(1167341, 53) 

现在我们准备好创建额外的特征,将数据分割成训练和测试集,并将其带入 LSTM 所需的三维格式。

如何在 RNN 格式中创建多个输入

此示例说明了如何组合几个输入数据源,即:

  • 52 周滞后回报的滚动序列

  • 为每个 12 个月份的独热编码指示变量

  • 整数编码的股票代码数值

以下代码生成了两个额外的特征:

data['month'] = data.index.get_level_values('date').month
data = pd.get_dummies(data, columns=['month'], prefix='month')
data['ticker'] = pd.factorize(data.index.get_level_values('ticker'))[0] 

接下来,我们创建了一个覆盖 2009 年至 2016 年期间的训练集和一个包含 2017 年数据的单独的测试集,这是最后一个完整的数据年份:

train_data = data[:'2016']
test_data = data['2017'] 

对于训练和测试数据集,我们生成一个包含三个输入数组的列表,如 图 19.9 所示:

  • 滞后收益序列(使用 图 19.5 中描述的格式)

  • 将整数编码的股票代码作为一维数组

  • 将月份虚拟变量作为每月一列的二维数组

window_size=52
sequence = list(range(1, window_size+1))
X_train = [
    train_data.loc[:, sequence].values.reshape(-1, window_size , 1),
    train_data.ticker,
    train_data.filter(like='month')
]
y_train = train_data.label
[x.shape for x in X_train], y_train.shape
[(1035424, 52, 1), (1035424,), (1035424, 12)], (1035424,) 

如何使用 Keras 的函数式 API 定义架构

Keras 的函数式 API 使得设计像本节开头概述的具有多个输入的架构(或多个输出,如 第十八章 中的 SVHN 示例中所述,用于金融时间序列和卫星图像的 CNNs)变得容易。这个例子说明了一个具有三个输入的网络:

  1. 两个分别具有 25 和 10 个单元的 堆叠 LSTM 层

  2. 一个学习股票的 10 维实值表示的 嵌入层

  3. 一个 one-hot 编码的月份表示

我们首先定义了三个输入及其各自的形状:

n_features = 1
returns = Input(shape=(window_size, n_features), name='Returns')
tickers = Input(shape=(1,), name='Tickers')
months = Input(shape=(12,), name='Months') 

要定义 堆叠 LSTM 层,我们将第一层的return_sequences关键字设置为True。这确保第一层以预期的三维输入格式生成输出。请注意,我们还使用了辍学正则化以及函数式 API 如何将一个层的张量输出传递给后续层的输入:

lstm1 = LSTM(units=lstm1_units,
             input_shape=(window_size, n_features),
             name='LSTM1',
             dropout=.2,
             return_sequences=True)(returns)
lstm_model = LSTM(units=lstm2_units,
             dropout=.2,
             name='LSTM2')(lstm1) 

TensorFlow 2 中有关 RNN 的指南强调了只有在使用大多数 LSTM 设置的默认值时才支持 GPU(www.tensorflow.org/guide/keras/rnn)。

嵌入层需要:

  • input_dim关键字,它定义了该层将学习多少个嵌入

  • output_dim关键字,它定义了嵌入的大小

  • input_length参数,它设置传递给该层的元素数量(在这里,每个样本只有一个股票代码)

嵌入层的目标是学习向量表示,以捕获特征值相对于彼此的相对位置与结果的关系。我们将选择一个五维嵌入来将嵌入层与 LSTM 层和我们需要重塑(或扁平化)的月份虚拟变量结合起来:

ticker_embedding = Embedding(input_dim=n_tickers,
                             output_dim=5,
                             input_length=1)(tickers)
ticker_embedding = Reshape(target_shape=(5,))(ticker_embedding) 

现在我们可以连接三个张量,然后进行BatchNormalization

merged = concatenate([lstm_model, ticker_embedding, months], name='Merged')
bn = BatchNormalization()(merged) 

最终的全连接层学习将这些堆叠 LSTM 层、股票代码嵌入和月份指标映射到反映接下来一周的正面或负面收益的二元结果。我们通过定义其输入和输出来制定完整的 RNN,使用刚刚定义的隐式数据流:

hidden_dense = Dense(10, name='FC1')(bn)
output = Dense(1, name='Output', activation='sigmoid')(hidden_dense)
rnn = Model(inputs=[returns, tickers, months], outputs=output) 

摘要使用 16,984 个参数对这个稍微复杂的架构进行了概述:

Layer (type)                    Output Shape         Param #     Connected to
Returns (InputLayer)            [(None, 52, 1)]      0
Tickers (InputLayer)            [(None, 1)]          0
LSTM1 (LSTM)                    (None, 52, 25)       2700        Returns[0][0]
embedding (Embedding)           (None, 1, 5)         12445       Tickers[0][0]
LSTM2 (LSTM)                    (None, 10)           1440        LSTM1[0][0]
reshape (Reshape)               (None, 5)           0          embedding[0][0]
Months (InputLayer)             [(None, 12)]         0
Merged (Concatenate)            (None, 27)           0           LSTM2[0][0]
                                                                 reshape[0][0]
                                                                 Months[0][0]
batch_normalization (BatchNorma (None, 27)           108         Merged[0][0]
FC1 (Dense)                     (None, 10)           280         
atch_normalization[0][0]
Output (Dense)                  (None, 1)            11          FC1[0][0]
Total params: 16,984
Trainable params: 16,930
Non-trainable params: 54 

我们使用建议的 RMSProp 优化器和默认设置编译模型,并计算我们将用于提前停止的 AUC 指标:

optimizer = tf.keras.optimizers.RMSprop(lr=0.001,
                                        rho=0.9,
                                        epsilon=1e-08,
                                        decay=0.0)
rnn.compile(loss='binary_crossentropy',
            optimizer=optimizer,
            metrics=['accuracy', 
                     tf.keras.metrics.AUC(name='AUC')]) 

我们使用提前停止来对模型进行 50 个周期的训练:

result = rnn.fit(X_train,
                 y_train,
                 epochs=50,
                 batch_size=32,
                 validation_data=(X_test, y_test),
                 callbacks=[early_stopping]) 

下面的图表显示,训练在 8 个周期后停止,每个周期在单个 GPU 上大约需要三分钟。这导致最佳模型的测试 AUC 为 0.6816,测试准确度为 0.6193:

图 19.10:堆叠的 LSTM 分类 - 交叉验证性能

测试预测和实际每周回报的 IC 为 0.32。

预测回报率而不是价格方向性变化

stacked_lstm_with_feature_embeddings_regression.ipynb笔记本演示了如何将模型调整为回归任务,即预测回报率而不是二元价格变化。

所需的更改很小;只需执行以下操作:

  1. 选择fwd_returns结果,而不是二元label

  2. 将模型输出转换为线性(默认值),而不是sigmoid

  3. 将损失更新为均方误差(并提及提前停止)。

  4. 删除或更新可选指标以匹配回归任务。

在其他情况下使用相同的训练参数(除了 Adam 优化器使用默认设置在这种情况下产生更好的结果),验证损失在九个周期内改善。平均每周 IC 为 3.32,整个时期为 6.68,显著性水平为 1%。预测回报率最高和最低五分之一的股票之间的平均每周回报率差略高于 20 个基点:

图 19.11:堆叠的 LSTM 回归模型 - 预测性能

用于宏观数据的多变量时间序列回归

到目前为止,我们的建模工作仅限于单个时间序列。RNN 非常适合多变量时间序列,并且是我们在第九章用于波动率预测和统计套利的时间序列模型中介绍的向量自回归VAR)模型的非线性替代品。有关实施详细信息,请参阅multivariate_timeseries笔记本。

加载情绪和工业生产数据

我们将展示如何使用相同的数据集对多个时间序列进行 RNN 建模和预测,该数据集是我们用于 VAR 示例的。它包括联邦储备局 FRED 服务提供的 40 年来每月的消费者情绪和工业生产数据观测:

df = web.DataReader(['UMCSENT', 'IPGMFN'], 'fred', '1980', '2019-12').dropna()
df.columns = ['sentiment', 'ip']
df.info()
DatetimeIndex: 480 entries, 1980-01-01 to 2019-12-01
Data columns (total 2 columns):
sentiment    480 non-null float64
ip           480 non-null float64 

使数据平稳化并调整比例

我们应用相同的转换 - 年度差异对两个系列,工业生产的先前对数转换 - 来实现平稳性(有关详细信息,请参阅第九章用于波动率预测和统计套利的时间序列模型)。我们还将其重新缩放为[0,1]范围,以确保网络在训练期间给予两个系列相等的权重:

df_transformed = (pd.DataFrame({'ip': np.log(df.ip).diff(12),
                               'sentiment': df.sentiment.diff(12)}).dropna())
df_transformed = df_transformed.apply(minmax_scale) 

图 19.12显示了原始和转换后的宏观时间序列:

图 19.12:原始和转换后的时间序列

创建多变量 RNN 输入

create_multivariate_rnn_data()函数将包含多个时间序列的数据集转换为 TensorFlow 的 RNN 层所需的三维形状,形状为n_samples × window_size × n_series

def create_multivariate_rnn_data(data, window_size):
    y = data[window_size:]
    n = data.shape[0]
    X = np.stack([data[i: j] for i, j in enumerate(range(window_size, n))],
                 axis=0)
    return X, y 

window_size值为 18,确保第二维中的条目是相应输出变量的滞后 18 个月。因此,我们获得了每个特征的 RNN 模型输入如下:

X, y = create_multivariate_rnn_data(df_transformed, window_size=window_size)
X.shape, y.shape
((450, 18, 2), (450, 2)) 

最后,我们将数据分为训练集和测试集,使用最后 24 个月来测试外样本性能:

test_size = 24
train_size = X.shape[0]-test_size
X_train, y_train = X[:train_size], y[:train_size]
X_test, y_test = X[train_size:], y[train_size:]
X_train.shape, X_test.shape
((426, 18, 2), (24, 18, 2)) 

定义和训练模型

鉴于数据集相对较小,我们使用比前一个示例更简单的 RNN 架构。它具有一个具有 12 个单元的 LSTM 层,后跟具有 6 个单元的完全连接层。输出层有两个单元,分别用于每个时间序列。

我们使用均方绝对损失和推荐的 RMSProp 优化器进行编译:

n_features = output_size = 2
lstm_units = 12
dense_units = 6
rnn = Sequential([
    LSTM(units=lstm_units,
         dropout=.1,
         recurrent_dropout=.1,
         input_shape=(window_size, n_features), name='LSTM',
         return_sequences=False),
    Dense(dense_units, name='FC'),
    Dense(output_size, name='Output')
])
rnn.compile(loss='mae', optimizer='RMSProp') 

该模型仍然具有 812 个参数,而第九章《时间序列模型用于波动率预测和统计套利》中的VAR(1,1)模型只有 10 个参数:

Layer (type)                 Output Shape              Param #   
LSTM (LSTM)                  (None, 12)                720       
FC (Dense)                   (None, 6)                 78        
Output (Dense)               (None, 2)                 14        
Total params: 812
Trainable params: 812 

我们使用批处理大小为 20 进行 100 个周期的训练,使用提前停止:

result = rnn.fit(X_train,
                y_train,
                epochs=100,
                batch_size=20,
                shuffle=False,
                validation_data=(X_test, y_test),
                callbacks=[checkpointer, early_stopping],
                verbose=1) 

在经过 62 个周期后,训练提前停止,测试 MAE 为 0.034,比相同任务的 VAR 模型的测试 MAE 提高了近 25%,为 0.043。

但是,这两个结果并不完全可比,因为 RNN 生成了 18 个 1 步预测,而 VAR 模型使用其自身的预测作为其外样本预测的输入。您可能需要调整 VAR 设置以获得可比较的预测并比较性能。

图 19.13突出显示了训练和验证误差,以及两个系列的外样本预测:

图 19.13:具有多个宏观系列的 RNN 的交叉验证和测试结果

用于文本数据的 RNN

RNN 通常应用于各种自然语言处理任务,从机器翻译到情感分析,我们在本书第三部分已经遇到过。在本节中,我们将说明如何将 RNN 应用于文本数据以检测正面或负面情感(可轻松扩展为更细粒度的情感评分),以及预测股票回报。

具体来说,我们将使用单词嵌入来表示文档中的标记。我们在第十六章《用于收入电话和 SEC 文件的单词嵌入》中介绍了单词嵌入。它们是将标记转换为密集的实值向量的绝佳技术,因为单词在嵌入空间中的相对位置编码了它们在训练文档中的有用语义方面。

我们在前面的堆叠 RNN 示例中看到,TensorFlow 具有内置的嵌入层,允许我们训练特定于手头任务的向量表示。或者,我们可以使用预训练的向量。我们将在以下三个部分中演示这两种方法。

具有情感分类嵌入的 LSTM

这个例子展示了如何在分类任务中训练 RNN 来学习自定义的嵌入向量。这与 word2vec 模型不同,后者在优化相邻标记的预测时学习向量,从而能够捕捉单词之间某些语义关系(参见 第十六章用于盈利电话和 SEC 文件的单词嵌入)。学习预测情感的词向量意味着嵌入将反映出一个标记与其相关联的结果之间的关系。

加载 IMDB 电影评论数据

为了使数据易于处理,我们将使用 IMDB 评论数据集来说明这个用例,该数据集包含 50,000 条正面和负面电影评论,均匀分布在一个训练集和一个测试集中,并且每个数据集中的标签都是平衡的。词汇表包含 88,586 个标记。或者,您可以使用规模更大的 Yelp 评论数据(将文本转换为数值序列后;请参阅下一节关于使用预训练嵌入或 TensorFlow 2 文档)。

数据集已经捆绑到 TensorFlow 中,并可以加载,以便每个评论表示为整数编码的序列。我们可以通过使用 skip_top 过滤掉频繁和可能不太信息丰富的单词,以及长度超过 maxlen 的句子,来限制词汇量至 num_words。我们还可以选择 oov_char 值,它代表了我们基于频率排除词汇表的标记:

from tensorflow.keras.datasets import imdb
vocab_size = 20000
(X_train, y_train), (X_test, y_test) = imdb.load_data(seed=42, 
                                                      skip_top=0,
                                                      maxlen=None, 
                                                      oov_char=2, 
                                                      index_from=3,
                                                      num_words=vocab_size) 

在第二步中,将整数列表转换为固定大小的数组,我们可以堆叠并提供作为输入到我们的 RNN。pad_sequence 函数生成相等长度的数组,截断并填充以符合 maxlen

maxlen = 100
X_train_padded = pad_sequences(X_train, 
                        truncating='pre', 
                        padding='pre', 
                        maxlen=maxlen) 

定义嵌入和 RNN 架构

现在我们可以设置我们的 RNN 架构了。第一层学习单词嵌入。我们使用以下内容定义嵌入维度,与以前一样:

  • input_dim 关键字设置了我们需要嵌入的标记数量

  • output_dim 关键字定义了每个嵌入的大小。

  • input_len 参数指定每个输入序列的长度

请注意,这次我们使用的是 GRU 单元,它们训练更快,对少量数据表现更好。我们还使用了循环丢失以进行正则化:

embedding_size = 100
rnn = Sequential([
    Embedding(input_dim=vocab_size, 
              output_dim= embedding_size, 
              input_length=maxlen),
    GRU(units=32,
        dropout=0.2, # comment out to use optimized GPU implementation
        recurrent_dropout=0.2),
    Dense(1, activation='sigmoid')
]) 

结果模型具有超过 200 万个可训练参数:

Layer (type)                 Output Shape              Param #   
embedding (Embedding)        (None, 100, 100)          2000000   
gru (GRU)                    (None, 32)                12864     
dense (Dense)                (None, 1)                 33        
Total params: 2,012,897
Trainable params: 2,012,897 

我们编译模型以使用 AUC 指标,并使用提前停止训练:

rnn.fit(X_train_padded, 
       y_train, 
       batch_size=32, 
       epochs=25, 
       validation_data=(X_test_padded, y_test),
       callbacks=[early_stopping],
       verbose=1) 

在 12 个时期后停止训练,并恢复最佳模型的权重,找到高达 0.9393 的高测试 AUC:

y_score = rnn.predict(X_test_padded)
roc_auc_score(y_score=y_score.squeeze(), y_true=y_test)
0.9393289376 

图 19.14显示了交叉验证性能,包括准确度和 AUC:

图 19.14:使用自定义嵌入对 IMDB 数据进行 RNN 的交叉验证

预训练的词向量进行情感分析

第十六章用于收益电话和 SEC 备案的词嵌入中,我们讨论了如何学习特定领域的词嵌入。Word2vec 和相关的学习算法产生高质量的词向量,但需要大量的数据集。因此,研究小组通常会共享在大型数据集上训练的词向量,类似于我们在上一章的迁移学习部分遇到的预训练深度学习模型的权重。

我们现在将说明如何使用斯坦福 NLP 小组提供的预训练全局词向量表示GloVe)与 IMDB 评论数据集一起使用(请参阅 GitHub 获取参考资料和sentiment_analysis_pretrained_embeddings笔记本获取实现细节)。

文本数据的预处理

我们将从源代码加载 IMDB 数据集,手动预处理它(请参阅笔记本)。TensorFlow 提供了一个Tokenizer,我们将使用它将文本文档转换为整数编码的序列:

num_words = 10000
t = Tokenizer(num_words=num_words,
              lower=True, 
              oov_token=2)
t.fit_on_texts(train_data.review)
vocab_size = len(t.word_index) + 1
train_data_encoded = t.texts_to_sequences(train_data.review)
test_data_encoded = t.texts_to_sequences(test_data.review) 

我们还使用pad_sequences函数将列表(长度不等的列表)转换为填充和截断数组的堆叠集,用于训练和测试数据:

max_length = 100
X_train_padded = pad_sequences(train_data_encoded, 
                               maxlen=max_length, 
                               padding='post',
                               truncating='post')
y_train = train_data['label']
X_train_padded.shape
(25000, 100) 

加载预训练的 GloVe 嵌入

我们下载并解压了 GloVe 数据到代码中指示的位置,并将创建一个字典,将 GloVe 令牌映射到 100 维的实值向量:

glove_path = Path('data/glove/glove.6B.100d.txt')
embeddings_index = dict()
for line in glove_path.open(encoding='latin1'):
    values = line.split()
    word = values[0]
    coefs = np.asarray(values[1:], dtype='float32')
    embeddings_index[word] = coefs 

我们使用大约 340,000 个词向量来创建一个与词汇表匹配的嵌入矩阵,以便 RNN 可以通过令牌索引访问嵌入:

embedding_matrix = np.zeros((vocab_size, 100))
for word, i in t.word_index.items():
    embedding_vector = embeddings_index.get(word)
    if embedding_vector is not None:
        embedding_matrix[i] = embedding_vector 

使用冻结权重定义体系结构

与上一个示例中的 RNN 设置不同之处在于,我们将把嵌入矩阵传递给嵌入层,并将其设置为不可训练,以便权重在训练期间保持不变:

rnn = Sequential([
    Embedding(input_dim=vocab_size,
              output_dim=embedding_size,
              input_length=max_length,
              weights=[embedding_matrix],
              trainable=False),
    GRU(units=32,  dropout=0.2, recurrent_dropout=0.2),
    Dense(1, activation='sigmoid')]) 

从这里开始,我们像以前一样进行。训练持续了 32 个时期,如图 19.15所示,我们获得了 0.9106 的测试 AUC 分数。这比我们在前几节中为这个领域学习的自定义嵌入的结果稍差,突显了训练自己的词嵌入的价值:

图 19.15:具有多个宏系列的 RNN 的交叉验证和测试结果

您可能希望将这些技术应用于我们在第三部分使用的更大的金融文本数据集。

从 SEC 备案嵌入预测收益

第十六章用于收益电话和 SEC 备案的词嵌入中,我们讨论了产品评论和金融文本数据之间的重要区别。虽然前者对于说明重要的工作流程很有用,在本节中,我们将处理更具挑战性但也更相关的金融文件。具体来说,我们将使用在第十六章用于收益电话和 SEC 备案的词嵌入中介绍的 SEC 备案数据,学习用于预测相关股票披露的单词嵌入,从发布前到一周后。

sec_filings_return_prediction 笔记本包含本节的代码示例。请参阅 第十六章盈利电话和美国证券交易委员会申报文件的词嵌入 中的 sec_preprocessing 笔记本,并查看 GitHub 上数据文件夹中的说明以获取数据。

使用 yfinance 获取源股票价格数据

2013-16 期间有 22,631 份文件。我们使用 yfinance 获取了相关 6,630 个股票的股票价格数据,因为它比 Quandl 的 WIKI 数据具有更高的覆盖率。我们使用文件索引(请参阅 第十六章盈利电话和美国证券交易委员会申报文件的词嵌入)中的股票符号和申报日期下载申报日期前三个月和后一个月的每日调整股票价格,以获取价格数据和未成功的股票:

yf_data, missing = [], []
for i, (symbol, dates) in enumerate(filing_index.groupby('ticker').date_filed, 
                                    1):
    ticker = yf.Ticker(symbol)
    for idx, date in dates.to_dict().items():
        start = date - timedelta(days=93)
        end = date + timedelta(days=31)
        df = ticker.history(start=start, end=end)
        if df.empty:
            missing.append(symbol)
        else:
            yf_data.append(df.assign(ticker=symbol, filing=idx)) 

我们获取了 3,954 个股票的数据,并使用 Quandl Wiki 数据获取了几百个缺失股票的价格(请参阅笔记本),最终得到了 4,762 个符号的 16,758 份文件。

预处理 SEC 申报数据

与产品评论相比,金融文本文档往往更长,结构更为正式。此外,在这种情况下,我们依赖于来自 EDGAR 的数据,该数据需要解析 XBRL 源(请参阅 第二章市场和基本数据-来源和技术),可能存在包含除所需部分以外的其他材料的错误。我们在预处理过程中采取了几个步骤来处理异常值,并将文本数据格式化为模型所需的相等长度的整数序列:

  1. 删除所有包含少于 5 个或多于 50 个令牌的句子;这影响约百分之 5 的句子。

  2. 创建了 28,599 个二元组,10,032 个三元组和 2,372 个具有 4 个元素的 n 元组。

  3. 将文件转换为表示令牌频率排名的整数序列,删除少于 100 个令牌的文件,并将序列截断为 20,000 个元素。

图 19.16 强调了剩余的 16,538 份文件的一些语料库统计信息,共有 179,214,369 个令牌,其中约有 204,206 个是唯一的。左面板显示了以对数-对数尺度表示的令牌频率分布;最常见的术语,“百万”,“业务”,“公司”和“产品”每个都出现了超过 100 万次。通常情况下,有一个非常长的尾巴,其中 60% 的令牌出现次数少于 25 次。

中央面板显示了约为 10 个令牌的句子长度的分布。最后,右侧面板显示了约为 20,000 的申报长度的分布,由于截断而呈现高峰:

图 19.16: 具有多个宏观系列的 RNN 的交叉验证和测试结果

为 RNN 模型准备数据

现在我们需要一个模型预测的结果。我们将计算(在某种程度上是任意的)文件当天(如果没有该日期的价格,则为前一天)的五天后回报,假设申报发生在市场闭市后。显然,这种假设可能是错误的,强调了第二章市场和基本数据-来源和技术和第三章金融替代数据-类别和用例中强调的即时数据的必要性。我们将忽略使用免费数据的隐藏成本问题。

我们计算正向回报如下,删除周回报低于 50 或高于 100%的异常值:

fwd_return = {}
for filing in filings:
    date_filed = filing_index.at[filing, 'date_filed']
    price_data = prices[prices.filing==filing].close.sort_index()

    try:
        r = (price_data
             .pct_change(periods=5)
             .shift(-5)
             .loc[:date_filed]
             .iloc[-1])
    except:
        continue
    if not np.isnan(r) and -.5 < r < 1:
        fwd_return[filing] = r 

这给我们留下了 16,355 个数据点。现在我们将这些结果与它们的匹配的申报序列组合起来,并将回报列表转换为 NumPy 数组:

y, X = [], []
for filing_id, fwd_ret in fwd_return.items():
    X.append(np.load(vector_path / f'{filing_id}.npy') + 2)
    y.append(fwd_ret)
y = np.array(y) 

最后,我们创建一个 90:10 的训练/测试分割,并使用本节中第一个示例中引入的pad_sequences函数生成每个长度为 20,000 的固定长度序列:

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.1)
X_train = pad_sequences(X_train, 
                        truncating='pre', 
                        padding='pre', 
                        maxlen=maxlen)
X_test = pad_sequences(X_test, 
                       truncating='pre', 
                       padding='pre', 
                       maxlen=maxlen)
X_train.shape, X_test.shape
((14719, 20000), (1636, 20000)) 

构建、训练和评估 RNN 模型

现在我们可以定义我们的 RNN 架构。第一层学习单词嵌入。我们将嵌入维度如前所述设置如下:

  • input_dim关键字到词汇表的大小

  • output_dim关键词到每个嵌入的大小

  • input_length参数是每个输入序列将要多长

对于递归层,我们使用双向 GRU 单元扫描文本向前和向后,并连接生成的输出。我们还在线性输出之前添加了五个单位的稠密层的批归一化和 dropout 进行正则化:

embedding_size = 100
input_dim = X_train.max() + 1
rnn = Sequential([
    Embedding(input_dim=input_dim, 
              output_dim=embedding_size, 
              input_length=maxlen,
             name='EMB'),
    BatchNormalization(name='BN1'),
    Bidirectional(GRU(32), name='BD1'),
    BatchNormalization(name='BN2'),
    Dropout(.1, name='DO1'),
    Dense(5, name='D'),
    Dense(1, activation='linear', name='OUT')]) 

结果模型有超过 250 万个可训练参数:

rnn.summary()
Layer (type)                 Output Shape              Param #   
EMB (Embedding)              (None, 20000, 100)        2500000   
BN1 (BatchNormalization)     (None, 20000, 100)        400       
BD1 (Bidirectional)          (None, 64)                25728     
BN2 (BatchNormalization)     (None, 64)                256       
DO1 (Dropout)                (None, 64)                0         
D (Dense)                    (None, 5)                 325       
OUT (Dense)                  (None, 1)                 6         
Total params: 2,526,715
Trainable params: 2,526,387
Non-trainable params: 328 

我们使用 Adam 优化器进行编译,针对这个回归任务的均方损失,同时跟踪损失的平方根和绝对误差的均值作为可选指标:

rnn.compile(loss='mse', 
            optimizer='Adam',
            metrics=[RootMeanSquaredError(name='RMSE'),
                     MeanAbsoluteError(name='MAE')]) 

使用提前停止,我们每次对 32 个观测值的批次进行最多 100 个时期的训练:

early_stopping = EarlyStopping(monitor='val_MAE', 
                               patience=5,
                               restore_best_weights=True)
training = rnn.fit(X_train,
                   y_train,
                   batch_size=32,
                   epochs=100,
                   validation_data=(X_test, y_test),
                   callbacks=[early_stopping],
                   verbose=1) 

均方误差仅在 4 个时期内改善,如图 19.17左侧面板所示:

图 19.17:使用 SEC 申报预测每周回报的 RNN 的交叉验证测试结果

在测试集上,最佳模型达到了高度显著的 IC 值为 6.02:

y_score = rnn.predict(X_test)
rho, p = spearmanr(y_score.squeeze(), y_test)
print(f'{rho*100:.2f} ({p:.2%})')
6.02 (1.48%) 

吸取的教训和下一步

该模型能够仅使用文本数据生成显著优于随机的回报预测。既有一些警告表明应谨慎对待结果,也有理由相信我们可以改进这个实验的结果。

一方面,股价数据和解析后的 SEC 申报的质量都远非完美。尚不清楚价格数据问题是否会积极或消极地偏向结果,但它们肯定会增加误差边际。更加仔细地解析和清理 SEC 申报很可能会通过消除噪音来改善结果。

另一方面,有许多优化可能会改善结果。从文本输入开始,我们并没有尝试解析提交内容,只是选择了某些部分;去除模板语言或尝试选择最有意义的陈述可能具有价值。我们还对提交的最大长度和词汇量大小进行了相当武断的选择,这些选择可能会重新审视。我们还可以缩短或延长每周的预测时段。此外,我们可以从嵌入的大小到层数和大小以及正则化程度等多个方面对模型架构进行细化。

最根本的是,我们可以将文本输入与更丰富的互补特征集合相结合,正如在前一节中展示的那样,使用具有多个输入的堆叠 LSTM。最后,我们肯定会需要更大的提交集。

总结

在本章中,我们介绍了专门针对序列数据定制的 RNN 架构。我们讨论了 RNN 的工作原理,分析了计算图,并了解了 RNN 如何通过多个步骤实现参数共享,以捕获长程依赖关系,而 FFNNs 和 CNNs 则不太适合。

我们还回顾了梯度消失和梯度爆炸的挑战,并了解了门控单元(如长短期记忆单元)如何使 RNN 能够学习数百个时间步长的依赖关系。最后,我们将 RNN 应用于算法交易中常见的挑战,例如使用 SEC 提交进行单变量和多变量时间序列预测以及情感分析。

下一章中,我们将介绍无监督的深度学习技术,如自动编码器和生成对抗网络,以及它们在投资和交易策略中的应用。

第二十章:自编码器用于条件风险因子和资产定价

本章展示了无监督学习如何利用深度学习进行交易。更具体地说,我们将讨论数十年来存在但最近引起新关注的自编码器

无监督学习解决了实际机器学习挑战,比如有标签数据的有限可用性和维度灾难,后者需要指数级更多的样本才能成功地从具有许多特征的复杂现实数据中学习。在概念层面上,无监督学习更类似于人类学习和常识的发展,而不是监督学习和强化学习,我们将在下一章中介绍。它也被称为预测学习,因为它旨在从数据中发现结构和规律,以便可以预测缺失的输入,也就是从观察到的部分填补空白。

自编码器是一个神经网络NN)训练成为复制输入同时学习数据的新表示的神经网络,由隐藏层的参数编码。自编码器长期以来一直用于非线性降维和流形学习(见第十三章使用无监督学习进行数据驱动的风险因子和资产配置)。各种设计利用了我们在最后三章中介绍的前馈、卷积和递归网络架构。我们将看到自编码器如何支撑一个交易策略:我们将构建一个深度神经网络,该网络使用自编码器来提取风险因子并预测股票回报,条件是一系列股票属性(Gu、Kelly 和 Xiu 2020)。

更具体地说,在本章中,您将学习以下内容:

  • 哪些类型的自编码器具有实际用途以及它们的工作原理

  • 使用 Python 构建和训练自编码器

  • 使用自编码器提取考虑资产特征以预测回报的数据驱动风险因子

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和其他资源的链接。笔记本包括图像的彩色版本。

用于非线性特征提取的自编码器

第十七章交易的深度学习 中,我们看到神经网络如何通过提取对给定任务有用的分层特征表示而成功进行监督学习。例如,卷积神经网络CNNs)从类似网格的数据中学习和合成越来越复杂的模式,例如,在图像中识别或检测对象,或者对时间序列进行分类。

与之相反,自编码器是专门设计用来学习一个新表示的神经网络,该表示以一种有助于解决另一个任务的方式对输入进行编码。为此,训练强制网络重现输入。由于自编码器通常使用相同的数据作为输入和输出,它们也被视为自监督学习的一种实例。在这个过程中,隐藏层的参数h成为表示输入的编码,类似于第十六章《用于收益电话和 SEC 文件的词嵌入》中介绍的 word2vec 模型。

更具体地说,网络可以被视为由一个编码器函数h=f(x)和一个解码器函数g组成,编码器函数从输入x中学习隐藏层的参数,解码器函数学习从编码h中重构输入。而不是学习身份函数:

自编码器不是简单地复制输入,而是使用约束来强制隐藏层优先编码数据的哪些方面。目标是获得实用价值的表示。

自编码器也可以被视为前馈神经网络的特例(见第十七章,用于交易的深度学习),并且可以使用相同的技术进行训练。与其他模型一样,过量的容量会导致过拟合,阻止自编码器产生超出训练样本的通用编码。有关更多背景信息,请参阅 Goodfellow、Bengio 和 Courville (2016)的第 1415章。

泛化线性降维

传统用例包括降维,通过限制隐藏层的大小从而创建一个“瓶颈”,使其执行有损压缩。这样的自编码器被称为欠完备,其目的是通过最小化形式为L的损失函数来学习数据的最显著属性:

我们将在下一节中探讨的一个示例损失函数仅仅是在输入图像的像素值及其重构上计算的均方误差。当我们构建用于交易的条件自编码器时,我们还将使用这个损失函数来从金融特征的时间序列中提取风险因子。

与主成分分析(PCA;见第十三章,用于无监督学习的数据驱动风险因子和资产配置)等线性降维方法不同,欠完备自编码器使用非线性激活函数;否则,它们学习与 PCA 相同的子空间。因此,它们可以被视为 PCA 的非线性推广,能够学习更广泛的编码。

图 20.1说明了具有三个隐藏层的欠完备前馈自编码器的编码器-解码器逻辑:编码器和解码器各有一个隐藏层,再加上包含编码的共享编码器输出/解码器输入层。这三个隐藏层使用非线性激活函数,如修正线性单元ReLU)、sigmoidtanh(参见第十七章交易的深度学习),并且比网络要重建的输入单元更少。

图 20.1:欠完备编码器-解码器架构

根据任务的不同,一个具有单个编码器和解码器层的简单自编码器可能是足够的。然而,具有额外层的更深的自编码器可以有几个优点,就像对其他神经网络一样。这些优点包括学习更复杂的编码、实现更好的压缩,并且在更少的计算和更少的训练样本的情况下完成,但会受到过拟合的固有风险的影响。

用于图像压缩的卷积自编码器

第十八章所讨论的,用于金融时间序列和卫星图像的 CNN,全连接前馈架构不适合捕获具有网格结构的数据的局部相关性。相反,自编码器也可以使用卷积层来学习分层特征表示。卷积自编码器利用卷积和参数共享来学习层次化模式和特征,而不受其位置、平移或大小变化的影响。

我们将在下面为图像数据演示卷积自编码器的不同实现。或者,卷积自编码器也可以应用于网格形式排列的多变量时间序列数据,如第十八章所示,用于金融时间序列和卫星图像的 CNN

通过正则化自编码器管理过拟合

神经网络表示复杂函数的强大能力要求对编码器和解码器的容量进行严格控制,以提取信号而不是噪声,从而使编码更适用于下游任务。换句话说,当网络太容易重新创建输入时,它无法仅学习数据的最有趣的方面,并提高使用编码作为输入的机器学习模型的性能。

与其他具有给定任务的过度容量的模型一样,正则化可以帮助解决过拟合挑战,通过约束自编码器的学习过程并强制其产生有用的表示(例如,参见第七章线性模型-从风险因素到回报预测,关于线性模型的正则化,以及第十七章用于交易的深度学习,关于神经网络)。 理想情况下,我们可以将模型的容量精确匹配到数据分布的复杂性。 在实践中,最佳模型通常结合(有限的)过剩容量和适当的正则化。 为此,我们将一个依赖于编码层h的权重的稀疏度惩罚添加到训练目标中:

我们在本章稍后探讨的一种常见方法是使用L1 正则化,它在损失函数中添加了一种惩罚,即权重的绝对值之和。 L1 范数会导致稀疏编码,因为它会强制将参数的值设为零,如果它们不能捕获数据中的独立变化(参见第七章线性模型-从风险因素到回报预测)。 因此,即使是隐藏层维度比输入高的超完备自编码器也可能学会学习信号内容。

使用去噪自编码器修复损坏的数据

到目前为止,我们讨论的自编码器设计用于尽管容量有限但重现输入。 另一种方法是训练带有损坏输入的自编码器以输出所需的原始数据点。 在这种情况下,自编码器最小化损失L

损坏的输入是防止网络学习身份函数而不是从数据中提取信号或显著特征的另一种方法。 已经证明去噪自编码器学会了原始数据的数据生成过程,并且在生成建模中变得流行,其中目标是学习产生输入的概率分布(Vincent 等,2008)。

用于时间序列特征的 Seq2seq 自编码器

循环神经网络RNNs)已经发展用于具有数据点之间长期依赖关系的顺序数据,可能覆盖长距离(第十九章用于多元时间序列和情感分析的 RNNs)。 类似地,序列到序列(seq2seq)自编码器旨在学习适应序列生成数据性质的表示(Srivastava,Mansimov 和 Salakhutdinov,2016)。

Seq2seq 自编码器基于 RNN 组件,如长短期记忆LSTM)或门控循环单元。 它们学习顺序数据的表示,并已成功应用于视频,文本,音频和时间序列数据。

如上一章所述,编码器-解码器架构允许 RNN 处理具有可变长度的输入和输出序列。这些架构支撑了许多复杂序列预测任务的进展,如语音识别和文本翻译,并且越来越多地应用于(金融)时间序列。在高层次上,它们的工作原理如下:

  1. LSTM 编码器逐步处理输入序列以学习隐藏状态。

  2. 此状态成为序列的学习表示,以固定长度的向量形式呈现。

  3. LSTM 解码器接收此状态作为输入,并使用它来生成输出序列。

请参见 GitHub 上链接的参考示例,了解构建序列到序列自动编码器以压缩时间序列数据检测时间序列中的异常的示例,以便例如监管机构发现潜在的非法交易活动。

使用变分自动编码器进行生成建模

变分自动编码器VAE)是最近发展起来的(Kingma 和 Welling,2014),专注于生成建模。与给定数据学习预测器的判别模型相反,生成模型旨在解决更一般的问题,即学习所有变量的联合概率分布。如果成功,它可以模拟数据首次生成的方式。学习数据生成过程非常有价值:它揭示了潜在的因果关系,并支持半监督学习,以有效地从小型标记数据集推广到大型未标记数据集。

更具体地说,VAEs 旨在学习模型负责输入数据的潜在(意思是未观察到)变量。请注意,在第十五章主题建模 - 总结财务新闻第十六章用于盈利电话和 SEC 备案的词嵌入中,我们遇到了潜在变量。

就像迄今讨论的自动编码器一样,VAEs 不允许网络学习任意函数,只要它忠实地重现输入即可。相反,它们旨在学习生成输入数据的概率分布的参数。

换句话说,VAEs 是生成模型,因为如果成功,您可以通过从 VAE 学习的分布中抽样来生成新的数据点。

VAE 的操作比迄今讨论的自动编码器更复杂,因为它涉及随机反向传播,即对随机变量的导数,并且细节超出了本书的范围。它们能够学习没有正则化的高容量输入编码,这是有用的,因为模型旨在最大化训练数据的概率,而不是复制输入。有关详细介绍,请参见 Kingma 和 Welling(2019)。

variational_autoencoder.ipynb 笔记本包含了一个应用于时尚 MNIST 数据集的样本 VAE 实现,该实现改编自 François Chollet 的 Keras 教程,以适配 TensorFlow 2。GitHub 上链接的资源包含一个 VAE 教程,其中包含指向 PyTorch 和 TensorFlow 2 实现的参考资料以及许多其他参考文献。参见 Wang 等人(2019)的应用,该应用将 VAE 与使用 LSTM 的 RNN 结合起来,并在期货市场中表现出优于各种基准模型的效果。

使用 TensorFlow 2 实现自编码器

在本节中,我们将说明如何使用 TensorFlow 2 的 Keras 接口来实现上一节介绍的几种自编码器模型。我们首先加载和准备一个图像数据集,我们将在本节中始终使用该数据集。我们将使用图像而不是金融时间序列,因为这样更容易可视化编码过程的结果。下一节将说明如何将自编码器与金融数据结合起来,作为更复杂架构的一部分,该架构可以作为交易策略的基础。

准备好数据后,我们将继续构建使用深度前馈网络、稀疏约束和卷积的自编码器,并将后者应用于图像去噪。

如何准备数据

为了说明,我们将使用时尚 MNIST 数据集,这是由 Lecun 等人(1998)与 LeNet 结合使用的经典 MNIST 手写数字数据集的现代替代品。我们还在 第十三章使用无监督学习进行数据驱动风险因素和资产配置 中依赖于该数据集。

Keras 使得很容易访问具有分辨率为 28 × 28 像素的 60,000 个训练和 10,000 个测试灰度样本:

from tensorflow.keras.datasets import fashion_mnist
(X_train, y_train), (X_test, y_test) = fashion_mnist.load_data()
X_train.shape, X_test.shape
((60000, 28, 28), (10000, 28, 28)) 

数据包含来自 10 类的服装物品。图 20.2 绘制了每个类别的一个样本图像:

图 20.2:时尚 MNIST 样本图像

我们重新塑造数据,使每个图像由一个扁平的一维像素向量表示,其中有 28 × 28 = 784 个元素,规范化到 [0, 1] 范围内:

image_size = 28              # pixels per side
input_size = image_size ** 2 # 784
def data_prep(x, size=input_size):
    return x.reshape(-1, size).astype('float32')/255
X_train_scaled = data_prep(X_train)
X_test_scaled = data_prep(X_test)
X_train_scaled.shape, X_test_scaled.shape
((60000, 784), (10000, 784)) 

单层前馈自编码器

我们从一个具有单个隐藏层的普通前馈自编码器开始,以说明使用 Functional Keras API 的一般设计方法,并建立性能基线。

第一步是使用 784 个元素的扁平图像向量的占位符:

input_ = Input(shape=(input_size,), name='Input') 

模型的编码器部分由一个全连接层组成,用于学习输入的新压缩表示。我们使用 32 个单元,压缩比为 24.5:

encoding_size = 32 # compression factor: 784 / 32 = 24.5
encoding = Dense(units=encoding_size,
                 activation='relu',
                 name='Encoder')(input_) 

解码部分将压缩的数据一次性重构为其原始大小:

decoding = Dense(units=input_size,
                 activation='sigmoid',
                 name='Decoder')(encoding) 

我们使用链式输入和输出元素实例化 Model 类,这些元素隐含地定义了计算图,如下所示:

autoencoder = Model(inputs=input_,
                    outputs=decoding,
                    name='Autoencoder') 

因此,所定义的编码器-解码器计算使用了近 51,000 个参数:

Layer (type)                 Output Shape              Param #   
Input (InputLayer)           (None, 784)               0         
Encoder (Dense)              (None, 32)                25120     
Decoder (Dense)              (None, 784)               25872     
Total params: 50,992
Trainable params: 50,992
Non-trainable params: 0 

Functional API 允许我们使用模型链的部分作为单独的编码器和解码器模型,这些模型使用训练期间学到的自编码器的参数。

定义编码器

编码器仅使用输入和隐藏层,总参数约为一半:

encoder = Model(inputs=input_, outputs=encoding, name='Encoder')
encoder.summary()
Layer (type)                 Output Shape              Param #   
Input (InputLayer)           (None, 784)               0         
Encoder (Dense)              (None, 32)                25120     
Total params: 25,120
Trainable params: 25,120
Non-trainable params: 0 

不久我们将看到,一旦训练了自动编码器,我们就可以使用编码器来压缩数据。

定义解码器

解码器由最后一个自动编码器层组成,由编码数据的占位符提供:

encoded_input = Input(shape=(encoding_size,), name='Decoder_Input')
decoder_layer = autoencoder.layers-1
decoder = Model(inputs=encoded_input, outputs=decoder_layer)
decoder.summary()
Layer (type)                 Output Shape              Param #   
Decoder_Input (InputLayer)   (None, 32)                0         
Decoder (Dense)              (None, 784)               25872     
Total params: 25,872
Trainable params: 25,872
Non-trainable params: 0 

训练模型

我们编译模型以使用 Adam 优化器(参见第十七章交易的深度学习)来最小化输入数据和自动编码器实现的复制之间的均方误差。为了确保自动编码器学会复制输入,我们使用相同的输入和输出数据来训练模型:

autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x=X_train_scaled, y=X_train_scaled,
                epochs=100, batch_size=32,
                shuffle=True, validation_split=.1,
                callbacks=[tb_callback, early_stopping, checkpointer]) 

评估结果

训练在一定的 20 个周期后停止,测试 RMSE 为 0.1121:

mse = autoencoder.evaluate(x=X_test_scaled, y=X_test_scaled)
f'MSE: {mse:.4f} | RMSE {mse**.5:.4f}'
'MSE: 0.0126 | RMSE 0.1121' 

要对数据进行编码,我们使用刚刚定义的编码器如下:

encoded_test_img = encoder.predict(X_test_scaled)
Encoded_test_img.shape
(10000, 32) 

解码器获取压缩数据,并根据自动编码器的训练结果重现输出:

decoded_test_img = decoder.predict(encoded_test_img)
decoded_test_img.shape
(10000, 784) 

图 20.3显示了 10 张原始图像及其经自动编码器重建后的图像,并展示了压缩后的损失:

图 20.3:示例时尚 MNIST 图像,原始和重建

带稀疏约束的前馈自动编码器

添加正则化相当简单。我们可以使用 Keras 的activity_regularizer将其应用于密集编码器层,如下所示:

encoding_l1 = Dense(units=encoding_size,
                    activation='relu',
                    activity_regularizer=regularizers.l1(10e-5),
                    name='Encoder_L1')(input_) 

输入和解码层保持不变。在这个例子中,压缩因子为 24.5,正则化对性能产生了负面影响,测试 RMSE 为 0.1229。

深度前馈自动编码器

为了说明向自动编码器添加深度的好处,我们将构建一个三层前馈模型,依次将输入从 784 压缩到 128、64 和 32 个单元:

input_ = Input(shape=(input_size,))
x = Dense(128, activation='relu', name='Encoding1')(input_)
x = Dense(64, activation='relu', name='Encoding2')(x)
encoding_deep = Dense(32, activation='relu', name='Encoding3')(x)
x = Dense(64, activation='relu', name='Decoding1')(encoding_deep)
x = Dense(128, activation='relu', name='Decoding2')(x)
decoding_deep = Dense(input_size, activation='sigmoid', name='Decoding3')(x)
autoencoder_deep = Model(input_, decoding_deep) 

结果模型有超过 222,000 个参数,比之前的单层模型的容量多四倍多:

Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         (None, 784)               0         
_________________________________________________________________
Encoding1 (Dense)            (None, 128)               100480    
_________________________________________________________________
Encoding2 (Dense)            (None, 64)                8256      
_________________________________________________________________
Encoding3 (Dense)            (None, 32)                2080      
_________________________________________________________________
Decoding1 (Dense)            (None, 64)                2112      
_________________________________________________________________
Decoding2 (Dense)            (None, 128)               8320      
_________________________________________________________________
Decoding3 (Dense)            (None, 784)               101136    
=================================================================
Total params: 222,384
Trainable params: 222,384
Non-trainable params: 0 

训练在 45 个周期后停止,并将测试 RMSE 减少了 14%,达到了 0.097。由于分辨率较低,难以明显注意到更好的重建。

可视化编码

我们可以使用流形学习技术t-分布随机邻域嵌入t-SNE;参见第十三章使用无监督学习的数据驱动风险因子和资产配置)来可视化和评估自动编码器隐藏层学习到的编码的质量。

如果编码成功捕捉到数据的显著特征,那么数据的压缩表示仍应显示与区分观察的 10 个类别对齐的结构。我们使用刚刚训练的深度编码器的输出来获取测试集的 32 维表示:

tsne = TSNE(perplexity=25, n_iter=5000)
train_embed = tsne.fit_transform(encoder_deep.predict(X_train_scaled)) 

图 20.4显示出 10 个类别很好地分离,表明编码对于作为保存数据关键特征的较低维度表示是有用的(请参见variational_autoencoder.ipynb笔记本以获取彩色版本):

图 20.4:Fashion MNIST 自编码器嵌入的 t-SNE 可视化

卷积自编码器

第十八章《金融时间序列和卫星图像的 CNN》,关于 CNN 的见解表明我们应该将卷积层合并到自编码器中,以提取具有图像数据网格结构特征的信息。

我们定义了一个三层编码器,它使用 32、16 和 8 个滤波器的 2D 卷积,分别使用 ReLU 激活和'same'填充以保持输入大小。第三层的结果编码大小为,比之前的示例要高:

x = Conv2D(filters=32,
           kernel_size=(3, 3),
           activation='relu',
           padding='same',
           name='Encoding_Conv_1')(input_)
x = MaxPooling2D(pool_size=(2, 2), padding='same', name='Encoding_Max_1')(x)
x = Conv2D(filters=16,
           kernel_size=(3, 3),
           activation='relu',
           padding='same',
           name='Encoding_Conv_2')(x)
x = MaxPooling2D(pool_size=(2, 2), padding='same', name='Encoding_Max_2')(x)
x = Conv2D(filters=8,
           kernel_size=(3, 3),
           activation='relu',
           padding='same',
           name='Encoding_Conv_3')(x)
encoded_conv = MaxPooling2D(pool_size=(2, 2),
                            padding='same',
                            name='Encoding_Max_3')(x) 

我们还定义了一个相匹配的解码器,它反转了滤波器数量,并使用 2D 上采样而不是最大池化来反转滤波器大小的减小。三层自编码器有 12,785 个参数,略多于深度自编码器容量的 5%。

训练在 67 个时代后停止,并导致测试 RMSE 进一步降低了 9%,这是由于卷积滤波器能够更有效地从图像数据中学习以及较大的编码大小的能力的结合。

去噪自编码器

自编码器应用于去噪任务只影响训练阶段。在本例中,我们向 Fashion MNIST 数据添加了来自标准正态分布的噪声,同时保持像素值在[0, 1]范围内,如下所示:

def add_noise(x, noise_factor=.3):
    return np.clip(x  + noise_factor * np.random.normal(size=x.shape), 0, 1)
X_train_noisy = add_noise(X_train_scaled)
X_test_noisy = add_noise(X_test_scaled) 

然后,我们继续在嘈杂的输入上训练卷积自编码器,目标是学习如何生成未损坏的原始图像:

autoencoder_denoise.fit(x=X_train_noisy,
                        y=X_train_scaled,
                        ...) 

60 个时代后的测试 RMSE 为 0.0931,毫不奇怪地比以前高。图 20.5显示,从上到下,原始图像以及嘈杂和去噪版本。它说明了自编码器成功地从嘈杂的图像中产生了与从原始图像中产生的相似的压缩编码:

图 20.5:去噪输入和输出示例

用于交易的有条件自编码器

Gu、Kelly 和 Xiu(GKX,2019)最近的研究开发了一种基于证券对风险因素的暴露的资产定价模型。当我们在第十三章《数据驱动的风险因素和无监督学习的资产配置》中介绍 PCA 以及在第四章《金融特征工程-如何研究 Alpha 因子》中介绍的风险因素模型时,它建立在我们讨论的数据驱动风险因素概念上。他们的目标是表明因子模型用于捕捉“异常”系统驱动因素的资产特征只是无法直接测量的风险因素的时间变化暴露的代理。在这种情况下,“异常”是超过由暴露于总体市场风险的回报(请参阅第五章《投资组合优化和绩效评估》中对资本资产定价模型的讨论)所解释的回报。

第四章第七章讨论的Fama-French 因子模型通过指定风险因素如公司规模来解释回报,基于对超过聚合市场风险所致平均股票回报的经验观察。鉴于这些特定的风险因素,这些模型能够通过相应设计的组合度量投资者为承担因子风险而获得的回报:按规模分类股票,购买最小的五分位数,卖出最大的五分位数,并计算回报。观察到的风险因素回报然后允许线性回归估计资产对这些因子的敏感性(称为因子加载),从而有助于基于(较少的)因子回报的预测来预测(许多)资产的回报。

相比之下,GKX 将风险因素视为潜在的或不可观察的,在许多资产之间驱动协方差,从而阻止投资者通过分散化来避免暴露。因此,投资者需要一种调整的回报,就像任何价格一样来实现均衡,进而提供不再异常的回报差异的经济合理性。在这种观点中,风险因素纯粹是统计性质的,而潜在的经济力量可以是任意的和多变的来源。

在另一篇最近的论文中(Kelly, Pruitt, and Su, 2019),Kelly——在耶鲁大学教授金融学,与 AQR 合作,并是将机器学习应用于交易的先驱之一——及其合著者开发了一个线性模型称为工具化主成分分析(IPCA),以从数据中估计潜在的风险因素和资产的因子加载。 IPCA 将 PCA 扩展到包括资产特征作为协变量,并产生时变的因子加载。通过将资产暴露于可观察的资产特征的因素上,IPCA 的目标是回答是否有一组共同的潜在风险因素来解释观察到的异常,而不是是否有一个特定的可观察因素可以这样做。

GKX 创建了一个条件自编码器架构,以反映线性 Fama-French 模型和 IPCA 方法所忽略的回报动态的非线性性质。结果是一个深度神经网络,它同时使用自编码器学习给定数量的不可观测因素的溢价,并使用前馈网络基于广泛的时间变化资产特征学习大量股票的因子加载。该模型成功地解释和预测资产回报。它展示了一个在统计上和经济上都显著的关系,当转化为类似于我们在本书中使用的例子的长短十分位差异策略时,产生了具有吸引力的夏普比率。

在本节中,我们将创建这个模型的简化版本,以演示如何利用自动编码器生成可交易的信号。为此,我们将使用 yfinance 在 1990 年至 2019 年期间构建一个接近 4,000 只美国股票的新数据集,因为它提供了一些额外的信息,有助于计算资产特征。我们会采取一些捷径,比如使用较少的资产和仅最重要的特征。我们还会省略一些实现细节,以简化表达。我们将重点介绍最重要的差异,以便您相应地增强模型。

我们将首先展示如何准备数据,然后解释、构建和训练模型并评估其预测性能。请参阅上述参考资料以了解更多有关理论和实现的背景。

获取股票价格和元数据信息

GKX 参考实现使用了来自 1957 年至 2016 年的中心研究安全价格(CRSP)的超过 30,000 只美国股票的股价和公司特征数据,频率为每月一次。它计算了 94 个指标,其中包括了一系列在以往学术研究中被建议用于预测收益的资产属性,这些属性在 Green、Hand 和 Zhang(2017 年)中列出,他们旨在验证这些说法。

由于我们无法获得高质量但昂贵的 CRSP 数据,我们利用了 yfinance(见第二章市场和基本数据-来源和技术)从 Yahoo Finance 下载价格和元数据。选择免费数据有一些缺点,包括:

  • 缺乏关于调整的质量控制

  • 存活偏差,因为我们无法获取不再上市的股票的数据

  • 在股票数量和历史长度方面范围较小

build_us_stock_dataset.ipynb 笔记本包含了本节的相关代码示例。

要获取数据,我们使用 pandas-datareader(见第二章市场和基本数据-来源和技术)从 NASDAQ 获取了 8,882 个当前交易符号的列表:

from pandas_datareader.nasdaq_trader import get_nasdaq_symbols
traded_symbols = get_nasdaq_symbols() 

我们移除 ETF,并为其余部分创建 yfinance Ticker()对象。

import yfinance as yf
tickers = yf.Tickers(traded_symbols[~traded_symbols.ETF].index.to_list()) 

每个股票的.info属性包含从 Yahoo Finance 抓取的数据点,从未偿还的股票数量和其他基本面到最新的市值;覆盖范围因证券而异:

info = []
for ticker in tickers.tickers:
    info.append(pd.Series(ticker.info).to_frame(ticker.ticker))
info = pd.concat(info, axis=1).dropna(how='all').T
info = info.apply(pd.to_numeric, errors='ignore') 

对于具有元数据的股票,我们同时下载调整后和未调整的价格,后者包括像股票分割和股息支付之类的企业行动,我们可以使用这些信息创建一个 Zipline bundle 用于策略回测(见第八章ML4T 工作流程-从模型到策略回测)。

我们获得了 4,314 支股票的调整后的 OHLCV 数据,具体如下:

prices_adj = []
with pd.HDFStore('chunks.h5') as store:
    for i, chunk in enumerate(chunks(tickers, 100)):
        print(i, end=' ', flush=True)
        prices_adj.append(yf.download(chunk,
                                      period='max',
                                      auto_adjust=True).stack(-1))
prices_adj = (pd.concat(prices_adj)
              .dropna(how='all', axis=1)
              .rename(columns=str.lower)
              .swaplevel())
prices_adj.index.names = ['ticker', 'date'] 

在没有关于基础价格数据和股票分割调整的任何质量控制的情况下,我们删除了具有可疑值的股票,如日回报超过 100%或低于-100%的股票:

df = prices_adj.close.unstack('ticker')
pmax = df.pct_change().max()
pmin = df.pct_change().min()
to_drop = pmax[pmax > 1].index.union(pmin[pmin<-1].index) 

这样做会移除大约 10%的股票,使我们在 1990-2019 年期间拥有接近 3900 个资产。

计算预测性资产特征

GKX 根据 Green 等人(2017 年)测试了 94 个资产属性,并确定了 20 个最具影响力的指标,同时断言特征重要性随后迅速下降。这 20 个最重要的股票特征分为三类,即:

  • 价格趋势,包括(行业)动量、短期和长期逆转,或最近的最大收益

  • 流动性,例如周转率、美元成交量或市值

  • 风险测量,例如,总体和特异性回报波动率或市场贝塔

在这 20 个指标中,我们将分析限制在 16 个指标上,这些指标我们已经或者可以近似计算相关的输入。 conditional_autoencoder_for_trading_data.ipynb 笔记本演示了如何计算相关指标。我们在本节中突出了一些例子;另请参阅附录Alpha 因子库

一些指标需要诸如行业、市值和流通股数等信息,因此我们将我们的股价数据集限制在具有相关元数据的证券上:

tickers_with_metadata = (metadata[metadata.sector.isin(sectors) & 
                                 metadata.marketcap.notnull() &
                                 metadata.sharesoutstanding.notnull() & 
                                (metadata.sharesoutstanding > 0)]
                                 .index.drop(tickers_with_errors)) 

我们以每周而不是每月的回报频率运行我们的分析,以弥补时间周期减少 50%和股票数量减少约 80%的情况。我们得到的每周回报如下所示:

returns = (prices.close
           .unstack('ticker')
           .resample('W-FRI').last()
           .sort_index().pct_change().iloc[1:]) 

大多数指标都相当容易计算。股票动量,即截止到当前日期前 1 个月的 11 个月累积股票回报,可以如下推导:

MONTH = 21
mom12m = (close
            .pct_change(periods=11 * MONTH)
            .shift(MONTH)
            .resample('W-FRI')
            .last()
            .stack()
            .to_frame('mom12m')) 

Amihud 流动性测量是股票绝对收益与其交易额的比率,以滚动的 21 天平均值表示:

dv = close.mul(volume)
ill = (close.pct_change().abs()
       .div(dv)
       .rolling(21)
       .mean()
       .resample('W-FRI').last()
       .stack()
       .to_frame('ill')) 

特异性波动性被测量为最近三年等权重市场指数收益的回归残差的标准差。我们使用statsmodels进行这个计算密集型指标的计算:

index = close.resample('W-FRI').last().pct_change().mean(1).to_frame('x')
def get_ols_residuals(y, x=index):
    df = x.join(y.to_frame('y')).dropna()
    model = sm.OLS(endog=df.y, exog=sm.add_constant(df[['x']]))
    result = model.fit()
    return result.resid.std()
idiovol = (returns.apply(lambda x: x.rolling(3 * 52)
                         .apply(get_ols_residuals))) 

对于市场贝塔,我们可以使用 statsmodels 的RollingOLS类,周资产收益作为结果,等权重指数作为输入:

def get_market_beta(y, x=index):
    df = x.join(y.to_frame('y')).dropna()
    model = RollingOLS(endog=df.y, 
                       exog=sm.add_constant(df[['x']]),
                       window=3*52)
    return model.fit(params_only=True).params['x']
beta = (returns.dropna(thresh=3*52, axis=1)
        .apply(get_market_beta).stack().to_frame('beta')) 

我们最终在 1990-2019 年期间的大约 3800 个证券上得到了约 1600 万次观察结果。图 20.6显示了每周股票收益数量的直方图(左侧面板)和每种特征的观察数量分布的箱线图:

图 20.6:随时间和每种 - 股票特征的股票数目

为了限制离群值的影响,我们遵循 GKX 并对特征进行等级标准化,使其落在[-1, 1]的区间内:

data.loc[:, characteristics] = (data.loc[:, characteristics]
                                .groupby(level='date')
                                .apply(lambda x:
                                      pd.DataFrame(quantile_transform(
                                      x, 
                                      copy=True, 
                                      n_quantiles=x.shape[0]),
                                      columns=characteristics,
                                        index=x.index.get_level_values('ticker'))
                                      )
                               .mul(2).sub(1)) 

由于神经网络无法处理缺失数据,我们将缺失值设置为-2,该值位于每周收益和特征的范围之外。

作者采用额外的方法来避免过度权重的小市值股票,例如市值加权最小二乘回归。他们还通过考虑特征的保守报告滞后来调整数据窥探偏差。

创建条件自编码器架构

GKX 提出的条件自编码器允许考虑到变化的资产特征的时变回报分布。为此,作者将我们在本章第一节中讨论的标准自编码器架构扩展,以允许特征来塑造编码。

图 20.7说明了该架构将结果(资产回报,顶部)建模为资产特征(左侧输入)和再次个体资产回报(右侧输入)的函数。作者允许资产回报是个体股票回报或根据资产特征从样本中的股票组成的组合,类似于我们在第四章中讨论的法玛-法 rench 因子组合投资组合,并在本节的介绍中总结(因此从股票到组合的虚线)。我们将使用个体股票回报;有关使用组合而不是个体股票的详细信息,请参阅 GKX。

图 20.7:GKX 设计的条件自编码器架构

左侧的前馈神经网络模拟了作为其P特征(输入)的函数的N个个体股票的K因子载荷(beta 输出)。在我们的案例中,N约为 3,800,P等于 16。作者尝试了最多三个隐藏层,分别具有 32、16 和 8 个单元,并发现两个层表现最佳。由于特征数量较少,我们仅使用了一个类似的层,并发现 8 个单元最有效。

当以个体资产回报作为输入时,该架构的右侧是传统的自编码器,因为它将N个资产回报映射到它们自己。作者以这种方式使用它来衡量导出的因子如何解释同时发生的回报。此外,他们使用自编码器通过使用来自期间t-1 的输入回报和期间t的输出回报来预测未来回报。我们将重点关注该架构用于预测的用途,强调自编码器是本章第一节中提到的前馈神经网络的特殊情况。

模型输出是左侧的因子载荷与右侧的因子溢价的点积。作者在范围为 2-6 的K值上进行了实验,与已建立的因子模型类似。

要使用 TensorFlow 2 创建此架构,我们使用 Functional Keras API 并定义一个make_model()函数,该函数自动化了模型编译过程如下:

def make_model(hidden_units=8, n_factors=3):
    input_beta = Input((n_tickers, n_characteristics), name='input_beta')
    input_factor = Input((n_tickers,), name='input_factor')
    hidden_layer = Dense(units=hidden_units,
                         activation='relu',
                         name='hidden_layer')(input_beta)
    batch_norm = BatchNormalization(name='batch_norm')(hidden_layer)

    output_beta = Dense(units=n_factors, name='output_beta')(batch_norm)
    output_factor = Dense(units=n_factors,
                          name='output_factor')(input_factor)
    output = Dot(axes=(2,1),
                 name='output_layer')([output_beta, output_factor])
    model = Model(inputs=[input_beta, input_factor], outputs=output)
    model.compile(loss='mse', optimizer='adam')
    return model 

我们遵循作者的做法,使用批量归一化并编译模型以在此回归任务中使用均方误差和 Adam 优化器。该模型有 12,418 个参数(请参阅笔记本)。

作者使用了额外的正则化技术,例如对网络权重的 L1 惩罚,并结合了具有相同架构但使用不同随机种子的各种网络的结果。他们还使用了提前停止。

我们使用 20 年的数据进行交叉验证,用五个对应于 2015-2019 年的折叠来预测下一年的每周回报。我们通过计算验证集的信息系数(IC)来评估从 2 到 6 个因子 K 和 8、16 或 32 个隐藏层单元的组合:

factor_opts = [2, 3, 4, 5, 6]
unit_opts = [8, 16, 32]
param_grid = list(product(unit_opts, factor_opts))
for units, n_factors in param_grid:
    scores = []
    model = make_model(hidden_units=units, n_factors=n_factors)
    for fold, (train_idx, val_idx) in enumerate(cv.split(data)):
        X1_train, X2_train, y_train, X1_val, X2_val, y_val = \
            get_train_valid_data(data, train_idx, val_idx)
        for epoch in range(250):         
            model.fit([X1_train, X2_train], y_train,
                      batch_size=batch_size,
                      validation_data=([X1_val, X2_val], y_val),
                      epochs=epoch + 1,
                      initial_epoch=epoch, 
                      verbose=0, shuffle=True)
            result = (pd.DataFrame({'y_pred': model.predict([X1_val,
                                                             X2_val])
                                   .reshape(-1),
                                    'y_true': y_val.stack().values},
                                  index=y_val.stack().index)
                      .replace(-2, np.nan).dropna())
            r0 = spearmanr(result.y_true, result.y_pred)[0]
            r1 = result.groupby(level='date').apply(lambda x: 
                                                    spearmanr(x.y_pred, 
                                                              x.y_true)[0])
            scores.append([units, n_factors, fold, epoch, r0, r1.mean(),
                           r1.std(), r1.median()]) 

图 20.8 绘制了五因子计数和三种隐藏层大小组合在五个年度交叉验证折叠中每个时期的验证 IC 的平均值。上面的面板显示了 52 周的 IC,下面的面板显示了平均每周的 IC(有关彩色版本,请参阅笔记本):

图 20.8:所有因子和隐藏层大小组合的交叉验证性能

结果表明,更多的因子和较少的隐藏层单元效果更好;特别是,具有八个单元的四和六个因子在 0.02-0.03 的整体 IC 值范围内表现最佳。

为了评估模型预测性能的经济意义,我们生成了一个具有八个单元的四因子模型的预测,训练了 15 个周期。然后,我们使用 Alphalens 来计算预测的五分位等权重投资组合之间的价差,同时忽略交易成本(请参阅 alphalens_analysis.ipynb 笔记本)。

图 20.9 显示了持有期从 5 天到 21 天的平均价差。对于较短的期限,这也反映了预测视野,底部和顶部十分位数之间的价差约为 10 个基点:

图 20.9:预测五分位的平均期间差异

为了评估预测性能如何随时间转化为回报,我们绘制了类似投资组合的累积回报图,以及分别投资于前半部分和后半部分的多空投资组合的累积回报:

图 20.10:基于五分位和多空组合的累积回报

结果显示了五分位投资组合之间的显著差距,以及长期以来更广泛的多空投资组合的正累积回报。这支持了条件自编码器模型可能有助于盈利交易策略的假设。

学到的经验和下一步计划

条件自编码器结合了我们在第十三章《数据驱动的风险因素和使用无监督学习进行资产配置》中使用 PCA 探索的数据驱动风险因素的非线性版本,以及在第四章第七章中讨论的建模回报的风险因素方法。它说明了深度神经网络架构如何能够灵活适应各种任务,以及自编码器和前馈神经网络之间的流动边界。

从数据源到架构的众多简化指向了几个改进途径。除了获取更多质量更好的数据,并且还可以计算出额外特征的数据外,以下修改是一个起点——当然还有许多其他途径:

  • 尝试与周频以外的数据频率,以及年度以外的预测时段,其中较短的周期还将增加训练数据的数量。

  • 修改模型架构,特别是在使用更多数据的情况下,可能会推翻这样一个发现:一个更小的隐藏层会更好地估计因子载荷。

摘要

在本章中,我们介绍了无监督学习如何利用深度学习。自动编码器学习复杂的、非线性的特征表示,能够显著压缩复杂数据而损失很少信息。因此,它们对于应对与具有许多特征的丰富数据相关的维度灾难特别有用,尤其是具有替代数据的常见数据集。我们还看到如何使用 TensorFlow 2 实现各种类型的自动编码器。

最重要的是,我们实现了最近的学术研究,从数据中提取数据驱动的风险因素来预测回报。与我们在第十三章中对这一挑战的线性方法不同,基于数据驱动的风险因素和无监督学习的资产配置,自动编码器捕捉非线性关系。此外,深度学习的灵活性使我们能够将许多关键资产特征纳入模型中,以建模更敏感的因素,有助于预测回报。

在下一章中,我们将重点研究生成对抗网络,它们常被称为人工智能最令人兴奋的最新发展之一,并看看它们如何能够创建合成的训练数据。

第二十一章:用于合成时间序列数据的生成对抗网络

在上一章关于自编码器的介绍之后,本章将介绍第二个无监督深度学习技术:生成对抗网络GANs)。与自编码器一样,GANs 补充了第十三章介绍的降维和聚类方法,即《基于数据的风险因素和无监督学习的资产配置》

GANs 是由 Goodfellow 等人于 2014 年发明的。Yann LeCun 称 GANs 是“过去十年中人工智能中最激动人心的想法。” 一个 GAN 在竞争环境中训练两个神经网络,称为生成器判别器。生成器旨在生成使判别器无法与给定类别的训练数据区分的样本。其结果是一种生成模型,能够产生代表某个特定目标分布的合成样本,但是这些样本是人工生成的,因此成本较低。

GANs 在许多领域产生了大量的研究和成功的应用。虽然最初应用于图像,但 Esteban、Hyland 和 Rätsch(2017)将 GANs 应用于医学领域以生成合成时间序列数据。随后进行了与金融数据的实验(Koshiyama、Firoozye 和 Treleaven 2019;Wiese 等人 2019;Zhou 等人 2018;Fu 等人 2019),以探索 GANs 是否能够生成模拟替代资产价格轨迹的数据,以训练监督或强化算法,或进行交易策略的回测。我们将复制 2019 年 NeurIPS 由 Yoon、Jarrett 和 van der Schaar(2019)提出的时间序列 GAN,以说明该方法并展示结果。

具体来说,在本章中,您将学习以下内容:

  • GANs 的工作原理、其用处以及如何应用于交易

  • 使用 TensorFlow 2 设计和训练 GANs

  • 生成合成金融数据以扩展用于训练 ML 模型和回测的输入

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和额外资源的链接。笔记本包括图像的彩色版本。

使用 GANs 创建合成数据

本书主要关注接收输入数据并预测结果的监督学习算法,我们可以将其与基本事实进行比较以评估其性能。这样的算法也称为判别模型,因为它们学会区分不同的输出值。

GANs 是像我们在上一章遇到的变分自编码器那样的生成模型的一个实例。如前所述,生成模型使用从某个分布 p[data] 中抽取的样本的训练集,并学习表示该数据生成分布的估计 p[model]。

正如介绍中提到的,GAN 被认为是最近最激动人心的机器学习创新之一,因为它们似乎能够生成高质量的样本,忠实地模仿一系列输入数据。这在需要监督学习所需的标记数据缺失或成本过高的情况下非常具有吸引力。

GAN(生成对抗网络)引发了一波研究热潮,最初集中于生成惊人逼真的图像。最近,出现了产生合成时间序列的 GAN 实例,这对于交易具有重要潜力,因为历史市场数据的有限可用性是回测过拟合风险的主要驱动因素。

在本节中,我们将更详细地解释生成模型和对抗训练的工作原理,并审查各种 GAN 架构。在下一节中,我们将演示如何使用 TensorFlow 2 设计和训练 GAN。在最后一节中,我们将描述如何调整 GAN,使其生成合成时间序列数据。

比较生成模型和判别模型

判别模型学习如何区分在给定输入数据X的情况下的不同结果y。换句话说,它们学习给定数据的结果的概率:p(y | X)。另一方面,生成模型学习输入和结果的联合分布p(y, X)。虽然生成模型可以使用贝叶斯定理作为判别模型来计算哪个类别最有可能(参见第十章贝叶斯机器学习 - 动态夏普比率和对冲交易),但通常似乎更可取地直接解决预测问题,而不是先解决更一般的生成挑战(Ng 和 Jordan,2002)。

GAN 具有生成目标:它们生成复杂的输出,例如逼真的图像,给定甚至可以是随机数的简单输入。它们通过对可能输出的概率分布进行建模来实现这一点。这个概率分布可以有很多维度,例如图像中的每个像素,文档中的每个字符或标记,或者时间序列中的每个值。因此,模型可以生成很可能代表输出类别的输出。

理查德·费曼的引述“我无法创建的,我就无法理解”强调了对建模生成分布的重要性,这是迈向更一般人工智能的重要一步,类似于人类学习,后者使用更少的样本就能成功。

生成模型除了能够从给定分布生成额外样本之外,还有几个用例。例如,它们可以被纳入基于模型的强化学习RL)算法中(请参见下一章)。生成模型也可以应用于时间序列数据,以模拟可供规划在 RL 或监督学习中使用的备选过去或可能的未来轨迹,包括用于设计交易算法。其他用例包括半监督学习,其中 GAN 可以通过特征匹配来为缺失标签分配比当前方法少得多的训练样本。

对抗训练 - 一个零和游戏的欺诈行为

GANs 的关键创新是学习数据生成概率分布的新方法。该算法建立了两个神经网络之间的竞争性或对抗性游戏,称为生成器鉴别器

生成器的目标是将随机噪声输入转换成特定类别对象的虚假实例,例如人脸图像或股票价格时间序列。鉴别器则旨在将生成器的欺骗性输出与包含目标对象真实样本的训练数据集区分开来。整个 GAN 的目标是使两个网络在各自的任务上变得更好,以便生成器产生的输出机器无法再与原始数据区分开来(在此时我们不再需要鉴别器,因为它不再必要,可以丢弃它)。

图 21.1说明了使用通用 GAN 架构进行对抗训练,该架构旨在生成图像。我们假设生成器使用深度 CNN 架构(例如我们在上一章讨论的卷积自动编码器中的 VGG16 示例),它像我们之前讨论的卷积自动编码器的解码器部分一样被反转。生成器接收具有随机像素值的输入图像,并产生传递给鉴别器网络的输出图像,鉴别器网络使用镜像 CNN 架构。鉴别器网络还接收代表目标分布的真实样本,并预测输入是真实还是伪造的概率。学习通过将鉴别器和生成器损失的梯度反向传播到各自网络的参数来进行:

图 21.1:GAN 架构

最近的 GAN 实验室是一个很棒的交互式工具,灵感来自 TensorFlow Playground,它允许用户设计 GAN 并可视化学习过程和性能随时间的各个方面(请参见 GitHub 上的资源链接)。

GAN 架构动物园的快速演变

自 2014 年 Goodfellow 等人发表论文以来,GANs 吸引了大量关注,并引发了相应的研究热潮。

大部分工作是将原始架构进行细化,以适应不同的领域和任务,并扩展以包含额外的信息并创建条件生成对抗网络。额外的研究集中在改进这个具有挑战性的训练过程的方法上,该过程需要在两个网络之间实现稳定的博弈均衡,而每个网络都可能很难单独训练。

生成对抗网络的应用领域已经变得更加多样化,超出了我们在这里可以覆盖的范围;请参阅 Creswell 等人(2018 年)和 Pan 等人(2019 年)的最新调查,以及 Odena(2019 年)的未解问题清单。

深度卷积生成对抗网络用于表示学习

深度卷积生成对抗网络DCGANs)受到了卷积神经网络成功应用于网格数据的监督学习的启发(Radford,Metz 和 Chintala,2016)。该架构通过开发基于对抗训练的特征提取器将生成对抗网络应用于无监督学习,更易于训练并生成质量更高的图像。现在被认为是基线实现,有大量的开源示例可用(请参阅 GitHub 上的参考资料)。

一个 DCGAN 网络以均匀分布的随机数作为输入,并输出分辨率为 64×64 像素的彩色图像。随着输入的逐渐变化,生成的图像也会随之变化。该网络由标准的卷积神经网络组件组成,包括反卷积层,这些层与上一章节中的卷积自编码器示例中的卷积层相反,或者全连接层。

作者进行了详尽的实验,并提出了一些建议,例如在两个网络中都使用批标准化和 ReLU 激活。我们将在本章后面探讨 TensorFlow 的实现。

用于图像到图像转换的条件生成对抗网络

条件生成对抗网络cGANs)将附加的标签信息引入训练过程中,从而提高了输出的质量,并且能对输出进行一定程度的控制。

cGANs 通过向鉴别器添加第三个输入改变了之前显示的基线架构,该输入包含类别标签。例如,这些标签在生成图像时可以传达性别或头发颜色信息。

扩展包括生成对抗性的何处网络GAWWN;Reed 等,2016),它不仅使用边界框信息生成合成图像,还将物体放置在给定位置。

生成对抗网络应用于图像和时间序列数据

除了对原始架构进行大量的扩展和修改之外,还出现了许多应用于图像以及序列数据(如语音和音乐)的应用。图像应用特别多样,从图像混合和超分辨率到视频生成和人体姿势识别等。此外,生成对抗网络已被用于提高监督学习的性能。

我们将看一些显著的例子,然后更仔细地研究可能与算法交易和投资特别相关的时间序列数据应用。参见 Alqahtani,Kavakli-Thorne 和 Kumar(2019)进行最近调查,并参考 GitHub 引用获取额外资源。

CycleGAN – 无配对图像到图像的翻译

监督图像到图像的翻译旨在学习对齐的输入和输出图像之间的映射关系。当无配对图像可用时,CycleGAN 解决了这个任务,并将图像从一个域转换为匹配另一个域。

流行的例子包括将马的“绘画”合成为斑马,反之亦然。它还包括通过从任意风景照片生成印象派印刷的逼真样本(Zhu 等,2018 年)来转换风格。

StackGAN – 文本到照片图像合成

GAN 早期应用之一是根据文本生成图像。 堆叠 GAN,通常简称为StackGAN,使用句子作为输入,并生成与描述匹配的多个图像。

该架构分为两个阶段,第一阶段产生形状和颜色的低分辨率草图,第二阶段将结果增强为具有照片逼真细节的高分辨率图像(Zhang 等,2017 年)。

SRGAN – 照片逼真的单图像超分辨率

超分辨率旨在从低分辨率输入产生更高分辨率的逼真图像。应用于此任务的 GAN 具有深度 CNN 架构,使用批归一化,ReLU 和跳跃连接,如 ResNet 中所遇到的,以产生令人印象深刻的结果,这些结果已经找到商业应用(Ledig 等,2017 年)。

使用递归条件 GANs 合成合成时间序列

递归 GANsRGANs)和递归条件 GANsRCGANs)是两种旨在合成逼真的实值多变量时间序列的模型架构(Esteban,Hyland 和 Rätsch,2017)。作者针对医疗领域的应用,但该方法可能非常有价值,可以克服历史市场数据的限制。

RGANs 依赖于递归神经网络RNNs)作为生成器和鉴别器。 RCGANs 根据 cGANs 的精神添加辅助信息(参见前面的图像到图像的有条件 GAN部分)。

作者成功生成了视觉上和数量上令人信服的逼真样本。此外,他们通过使用合成数据来训练模型来评估合成数据的质量,包括合成标签,在真实测试集上预测性能只有轻微下降。作者还演示了成功应用 RCGANs 到一个早期预警系统,使用了一份来自重症监护病房的 17,000 名患者的医疗数据集。因此,作者阐明了 RCGANs 能够生成对监督训练有用的时间序列数据。我们将在本章的TimeGAN – 对合成金融数据进行对抗训练部分中应用这种方法到金融市场数据。

如何使用 TensorFlow 2 构建 GAN

为了说明使用 Python 实现 GAN,我们将使用本节早期讨论的 DCGAN 示例来合成来自 Fashion-MNIST 数据集的图像,我们在第十三章使用无监督学习进行数据驱动风险因子和资产配置中首次遇到该数据集。

有关实现细节和参考,请参见笔记本 deep_convolutional_generative_adversarial_network

构建生成器网络

生成器和判别器都使用类似图 20.1所示的深度 CNN 架构,但层数较少。生成器使用一个全连接输入层,然后是三个卷积层,如下所定义的 build_generator() 函数所示,该函数返回一个 Keras 模型实例:

def build_generator():
    return Sequential([Dense(7 * 7 * 256, 
                             use_bias=False,
                             input_shape=(100,), 
                             name='IN'),
                       BatchNormalization(name='BN1'),
                       LeakyReLU(name='RELU1'),
                       Reshape((7, 7, 256), name='SHAPE1'),
                       Conv2DTranspose(128, (5, 5), 
                                       strides=(1, 1),
                                       padding='same', 
                                       use_bias=False,
                                       name='CONV1'),
                       BatchNormalization(name='BN2'),
                       LeakyReLU(name='RELU2'),
                       Conv2DTranspose(64, (5, 5), 
                                       strides=(2, 2),
                                       padding='same',
                                       use_bias=False,
                                       name='CONV2'),
                       BatchNormalization(name='BN3'),
                       LeakyReLU(name='RELU3'),
                       Conv2DTranspose(1, (5, 5), 
                                       strides=(2, 2),
                                       padding='same', 
                                       use_bias=False,
                                       activation='tanh', 
                                       name='CONV3')],
                      name='Generator') 

生成器接受 100 个一维随机值作为输入,并产生宽高为 28 像素的图像,因此包含 784 个数据点。

对此函数返回的模型调用 .summary() 方法显示,该网络有超过 2.3 百万个参数(有关详细信息,请参见笔记本,包括训练前生成器输出的可视化)。

创建判别器网络

判别器网络使用两个卷积层将来自生成器的输入转换为单个输出值。该模型有大约 212,000 个参数:

def build_discriminator():
    return Sequential([Conv2D(64, (5, 5), 
                              strides=(2, 2), 
                              padding='same',
                              input_shape=[28, 28, 1], 
                              name='CONV1'),
                       LeakyReLU(name='RELU1'),
                       Dropout(0.3, name='DO1'),
                       Conv2D(128, (5, 5), 
                              strides=(2, 2),
                              padding='same', 
                              name='CONV2'),
                       LeakyReLU(name='RELU2'),
                       Dropout(0.3, name='DO2'),
                       Flatten(name='FLAT'),
                       Dense(1, name='OUT')],
                      name='Discriminator') 

图 21.2 描述了随机输入是如何从生成器流向判别器的,以及各个网络组件的输入和输出形状:

图 21.2: DCGAN TensorFlow 2 模型架构

设置对抗训练过程

现在我们已经构建了生成器和判别器模型,我们将设计并执行对抗训练过程。为此,我们将定义以下内容:

  • 反映它们之间竞争性互动的两个模型的损失函数

  • 运行反向传播算法的单个训练步骤

  • 训练循环重复训练步骤,直到模型性能符合我们的期望

定义生成器和判别器损失函数

生成器损失反映了鉴别器对假输入的决定。如果鉴别器误将生成器生成的图像误认为是真实图像,则生成器损失会很低;反之,则会很高;在创建训练步骤时,我们将定义这两个模型之间的交互。

生成器损失由二元交叉熵损失函数度量,如下所示:

cross_entropy = BinaryCrossentropy(from_logits=True)
def generator_loss(fake_output):
    return cross_entropy(tf.ones_like(fake_output), fake_output) 

鉴别器接收真实图像和假图像作为输入。它为每个图像计算损失,并试图通过最小化这两种类型输入的总和来准确识别它们:

def discriminator_loss(true_output, fake_output):
    true_loss = cross_entropy(tf.ones_like(true_output), true_output)
    fake_loss = cross_entropy(tf.zeros_like(fake_output), fake_output)
    return true_loss + fake_loss 

为了训练这两个模型,我们为每个模型分配了一个 Adam 优化器,其学习率低于默认值:

gen_optimizer = Adam(1e-4)
dis_optimizer = Adam(1e-4) 

核心 - 设计训练步骤

每个训练步骤实现一轮随机梯度下降,使用 Adam 优化器。它包括五个步骤:

  1. 向每个模型提供小批量输入

  2. 获取当前权重的模型输出

  3. 根据模型的目标和输出计算损失

  4. 根据每个模型权重的损失获取梯度

  5. 根据优化器的算法应用梯度

函数train_step()执行这五个步骤。我们使用@tf.function装饰器通过将其编译为 TensorFlow 操作来加速执行,而不是依赖急切执行(有关详细信息,请参阅 TensorFlow 文档):

@tf.function
def train_step(images):
    # generate the random input for the generator
    noise = tf.random.normal([BATCH_SIZE, noise_dim])
    with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape:     
        # get the generator output
        generated_img = generator(noise, training=True)
        # collect discriminator decisions regarding real and fake input
        true_output = discriminator(images, training=True)
        fake_output = discriminator(generated_img, training=True)
        # compute the loss for each model
        gen_loss = generator_loss(fake_output)
        disc_loss = discriminator_loss(true_output, fake_output)
    # compute the gradients for each loss with respect to the model variables
    grad_generator = gen_tape.gradient(gen_loss,
                                       generator.trainable_variables)
    grad_discriminator = disc_tape.gradient(disc_loss,
                                            discriminator.trainable_variables)
    # apply the gradient to complete the backpropagation step
    gen_optimizer.apply_gradients(zip(grad_generator,
                                      generator.trainable_variables))
    dis_optimizer.apply_gradients(zip(grad_discriminator,
                                      discriminator.trainable_variables)) 

组合在一起 - 训练循环

一旦我们正确定义了训练步骤,实现训练循环非常简单。它由一个简单的for循环组成,在每次迭代期间,我们将一批新的真实图像传递给训练步骤。我们还将抽样一些合成图像,并偶尔保存模型权重。

请注意,我们使用tqdm包跟踪进度,该包在训练期间显示完成的百分比:

def train(dataset, epochs, save_every=10):
    for epoch in tqdm(range(epochs)):
        for img_batch in dataset:
            train_step(img_batch)
        # produce images for the GIF as we go
        display.clear_output(wait=True)
        generate_and_save_images(generator, epoch + 1, seed)
        # Save the model every 10 EPOCHS
        if (epoch + 1) % save_every == 0:
            checkpoint.save(file_prefix=checkpoint_prefix)
        # Generator after final epoch
    display.clear_output(wait=True)
    generate_and_save_images(generator, epochs, seed)
train(train_set, EPOCHS) 

评估结果

在仅需几分钟的 100 轮迭代之后,从随机噪声创建的合成图像明显开始类似于原始图像,您可以在图 21.3中看到(请参阅笔记本以获取最佳的视觉质量):

图 21.3:合成 Fashion-MNIST 图像的样本

笔记本还创建了一个动态的 GIF 图像,可视化合成图像的质量在训练期间如何改善。

现在我们了解了如何使用 TensorFlow 2 构建和训练 GAN,我们将转向一个更复杂的示例,该示例从股价数据生成合成时间序列。

用于合成金融数据的 TimeGAN

生成合成时间序列数据面临着特定的挑战,这些挑战超出了设计用于图像的 GAN 时所遇到的挑战。除了在任何给定点上的变量分布外,例如像素值或大量股票的价格,用于时间序列数据的生成模型还应该学习塑造观测序列之间如何跟随的时间动态。(也参见第九章用于波动预测和统计套利的时间序列模型中的讨论)。

Yoon、Jarrett 和 van der Schaar 在 2019 年 12 月的 NeurIPS 上提出的非常新颖且有前景的研究,引入了一种新型的时间序列生成对抗网络TimeGAN)框架,旨在通过结合监督和无监督训练来解释时间相关性。该模型在优化监督和对抗目标的同时学习时间序列嵌入空间,这些目标鼓励模型在训练期间从历史数据中采样时遵循观察到的动态。作者对各种时间序列(包括历史股票价格)进行了模型测试,并发现合成数据的质量明显优于现有替代品。

在本节中,我们将概述这个复杂模型的工作原理,重点介绍建立在以前 DCGAN 示例基础上的关键实现步骤,并展示如何评估生成时间序列的质量。更多信息请参阅论文。

学习跨特征和时间生成数据

用于时间序列数据的成功生成模型需要捕捉每个时间点上特征的横截面分布以及这些特征随时间的纵向关系。用我们刚讨论的图像上下文来表达,模型不仅需要学习一个真实图像是什么样子,还需要学习一个图像如何从前一个图像演变而来,就像视频一样。

结合对抗和监督训练

正如第一节中提到的那样,以前生成时间序列数据的尝试,如 RGAN 和 RCGAN,依赖于 RNN(请参阅第十九章用于多变量时间序列和情感分析的 RNN)充当生成器和判别器的角色。TimeGAN 通过将 DCGAN 示例中的无监督对抗损失应用于真实和合成序列,并与相对于原始数据的逐步监督损失结合,明确地结合了时间序列的自回归特性。其目标是奖励模型学习存在于历史数据中的从一个时间点到下一个时间点的转换分布

此外,TimeGAN 包括一个嵌入网络,将时间序列特征映射到较低维度的潜在空间,以降低对抗空间的复杂性。其动机是捕捉通常具有较低维度的时间动态的驱动因素。(还请参阅第十三章中的流形学习讨论,使用无监督学习的数据驱动风险因素和资产配置,以及第二十章中的非线性降维讨论,用于条件风险因素和资产定价的自编码器)。

TimeGAN 架构的一个关键元素是,生成器和嵌入(或自动编码器)网络都负责最小化监督损失,这个损失度量模型学习动态关系的好坏。因此,模型学习了一个潜在空间,有助于生成器忠实地再现历史数据中观察到的时间关系。除了时间序列数据,模型还可以处理静态数据,即随时间不变或随时间变化较少的数据。

TimeGAN 架构的四个组成部分

TimeGAN 架构将对抗网络与自动编码器结合在一起,因此有四个网络组件,如 图 21.4 所示:

  1. 自动编码器:嵌入和恢复网络

  2. 对抗网络:序列生成器和序列鉴别器组件

作者强调通过三种不同的损失函数来进行自动编码器和对抗网络的联合训练重建损失优化自动编码器,无监督损失训练对抗网络,监督损失强制执行时间动态。由于这一关键见解,TimeGAN 同时学会了编码特征、生成表示和在时间上迭代。更具体地说,嵌入网络创建潜在空间,对抗网络在此空间内运作,监督损失同步了真实数据和合成数据的潜在动态。

图 21.4:TimeGAN 架构的组件

自动编码器的嵌入和恢复组件将特征空间映射到潜在空间,反之亦然。这有助于对抗网络学习低维空间中的时间动态。作者使用堆叠 RNN 和前馈网络实现了嵌入和恢复网络。然而,只要它们是自回归的,并且尊重数据的时间顺序,这些选择可以灵活地适应手头的任务。

对抗网络的生成器和鉴别器元素与 DCGAN 不同,不仅因为它们作用于序列数据,而且因为合成特征是在模型同时学习的潜在空间中生成的。作者选择了 RNN 作为生成器,选择了具有前向输出层的双向 RNN 作为鉴别器。

自动编码器和对抗网络的联合训练

图 21.4 中显示的三个损失函数驱动了刚刚描述的网络元素的联合优化,同时在真实和随机生成的时间序列上训练。更详细地说,它们旨在实现以下目标:

  • 重建损失是我们在第二十章中对自动编码器的讨论中熟悉的;它比较重构的编码数据与原始数据的相似程度。

  • 无监督损失反映了生成器和鉴别器之间的竞争交互,如 DCGAN 示例中所述;生成器旨在最小化鉴别器将其输出分类为伪造的概率,而鉴别器旨在优化对真实和伪造输入的正确分类。

  • 监督损失捕捉生成器在接收编码的真实数据进行前序列时,在潜在空间中逼近实际的下一个时间步骤的程度。

训练分为三个阶段

  1. 在实际时间序列上训练自动编码器以优化重建。

  2. 使用实时时间序列优化监督损失,以捕捉历史数据的时间动态。

  3. 联合训练四个组件,同时最小化所有三个损失函数。

TimeGAN 包括用于加权复合损失函数组件的几个超参数;然而,作者发现网络对这些设置的敏感性要小于人们可能预期的,考虑到 GAN 训练的困难。事实上,他们在训练过程中没有发现重大挑战,并建议嵌入任务用于正则化对抗学习,因为它降低了其维度,而监督损失则约束了生成器的逐步动态。

现在我们转向使用 TensorFlow 2 实现 TimeGAN;请参阅论文以了解该方法的数学和方法的深入解释。

使用 TensorFlow 2 实现 TimeGAN

在本节中,我们将实现刚刚描述的 TimeGAN 架构。作者提供了使用 TensorFlow 1 的示例代码,我们将其移植到 TensorFlow 2。构建和训练 TimeGAN 需要几个步骤:

  1. 选择和准备真实和随机时间序列输入

  2. 创建关键的 TimeGAN 模型组件

  3. 定义在三个训练阶段使用的各种损失函数和训练步骤。

  4. 运行训练循环并记录结果。

  5. 生成合成时间序列并评估结果。

我们将逐步介绍每个步骤的关键项目;请参阅笔记本TimeGAN_TF2以获取本节中的代码示例(除非另有说明),以及其他实现细节。

准备真实和随机输入系列

作者使用了从 Yahoo Finance 下载的 15 年每日谷歌股票价格作为金融数据的 TimeGAN 适用性示例,包括六个特征,即开盘价、最高价、最低价、收盘价和调整后的收盘价以及交易量。我们将使用近 20 年的六个不同股票的调整后的收盘价,因为它引入了较高的变异性。我们将按照原始论文的要求,针对 24 个时间步长生成合成序列。

在 Quandl Wiki 数据集中历史最悠久的股票中,有一些是以归一化格式显示的,即从 1.0 开始,在 图 21.5 中显示。我们从 2000 年至 2017 年检索调整后的收盘价,并获得 4,000 多个观察结果。系列之间的相关系数从 GE 和 CAT 的 0.01 到 DIS 和 KO 的 0.94 不等。

图 21.5:TimeGAN 输入-六个真实股票价格系列

我们使用 scikit-learn 的 MinMaxScaler 类将每个系列缩放到范围 [0, 1],稍后我们将使用它来重新缩放合成数据:

df = pd.read_hdf(hdf_store, 'data/real')
scaler = MinMaxScaler()
scaled_data = scaler.fit_transform(df).astype(np.float32) 

在下一步中,我们创建包含六个系列的 24 个连续数据点的重叠序列的滚动窗口:

data = []
for i in range(len(df) - seq_len):
    data.append(scaled_data[i:i + seq_len])
n_series = len(data) 

然后,我们从 NumPy 数组列表创建一个 tf.data.Dataset 实例,确保数据在训练时被洗牌,并设置批量大小为 128:

real_series = (tf.data.Dataset
               .from_tensor_slices(data)
               .shuffle(buffer_size=n_windows)
               .batch(batch_size))
real_series_iter = iter(real_series.repeat()) 

我们还需要一个随机时间序列生成器,它会生成模拟数据,每个时间序列有 24 个观测值,直到训练结束。

为此,我们将创建一个生成器,它随机均匀地抽取所需数据,并将结果输入到第二个 tf.data.Dataset 实例中。我们将设置此数据集以产生所需大小的批量,并为必要的时间重复该过程:

def make_random_data():
    while True:
        yield np.random.uniform(low=0, high=1, size=(seq_len, n_seq))
random_series = iter(tf.data.Dataset
                     .from_generator(make_random_data,
                                     output_types=tf.float32)
                     .batch(batch_size)
                     .repeat()) 

现在我们将继续定义并实例化 TimeGAN 模型组件。

创建 TimeGAN 模型组件

我们现在将创建两个自编码器组件和两个对抗网络元素,以及鼓励生成器学习历史价格系列的监督员。

我们将按照作者的示例代码创建具有三个隐藏层的 RNN,每个隐藏层有 24 个 GRU 单元,除了监督员,它只使用两个隐藏层。以下的 make_rnn 函数自动创建网络:

def make_rnn(n_layers, hidden_units, output_units, name):
    return Sequential([GRU(units=hidden_units,
                           return_sequences=True,
                           name=f'GRU_{i + 1}') for i in range(n_layers)] +
                      [Dense(units=output_units,
                             activation='sigmoid',
                             name='OUT')], name=name) 

自编码器嵌入器 和我们在这里实例化的恢复网络组成:

embedder = make_rnn(n_layers=3, 
                    hidden_units=hidden_dim, 
                    output_units=hidden_dim, 
                    name='Embedder')
recovery = make_rnn(n_layers=3, 
                    hidden_units=hidden_dim, 
                    output_units=n_seq, 
                    name='Recovery') 

然后我们像这样创建生成器、鉴别器和监督员:

generator = make_rnn(n_layers=3, 
                     hidden_units=hidden_dim, 
                     output_units=hidden_dim, 
                     name='Generator')
discriminator = make_rnn(n_layers=3, 
                         hidden_units=hidden_dim, 
                         output_units=1, 
                         name='Discriminator')
supervisor = make_rnn(n_layers=2, 
                      hidden_units=hidden_dim, 
                      output_units=hidden_dim, 
                      name='Supervisor') 

我们还定义了两个通用损失函数,即 MeanSquaredErrorBinaryCrossEntropy,稍后我们将使用它们来创建三个阶段中的各种特定损失函数:

mse = MeanSquaredError()
bce = BinaryCrossentropy() 

现在是时候开始训练过程了。

第 1 阶段训练 - 使用真实数据的自编码器

自编码器整合了嵌入器和恢复函数,就像我们在上一章中看到的那样:

H = embedder(X)
X_tilde = recovery(H)
autoencoder = Model(inputs=X,
                    outputs=X_tilde,
                    name='Autoencoder')
autoencoder.summary()
Model: "Autoencoder"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
RealData (InputLayer)        [(None, 24, 6)]           0         
_________________________________________________________________
Embedder (Sequential)        (None, 24, 24)            10104     
_________________________________________________________________
Recovery (Sequential)        (None, 24, 6)             10950     
=================================================================
Trainable params: 21,054 

它有 21,054 个参数。我们现在将为这个训练阶段实例化优化器并定义训练步骤。它遵循了与 DCGAN 示例引入的模式,使用 tf.GradientTape 来记录生成重构损失的操作。这允许我们依赖于自动微分引擎来获取相对于驱动 反向传播 的可训练嵌入器和恢复网络权重的梯度:

autoencoder_optimizer = Adam()
@tf.function
def train_autoencoder_init(x):
    with tf.GradientTape() as tape:
        x_tilde = autoencoder(x)
        embedding_loss_t0 = mse(x, x_tilde)
        e_loss_0 = 10 * tf.sqrt(embedding_loss_t0)
    var_list = embedder.trainable_variables + recovery.trainable_variables
    gradients = tape.gradient(e_loss_0, var_list)
    autoencoder_optimizer.apply_gradients(zip(gradients, var_list))
    return tf.sqrt(embedding_loss_t0) 

重建损失简单地将自动编码器的输出与其输入进行比较。我们使用这个训练循环进行 10,000 步训练,只需一分钟多一点时间,并记录步骤损失以在 TensorBoard 上进行监控:

for step in tqdm(range(train_steps)):
    X_ = next(real_series_iter)
    step_e_loss_t0 = train_autoencoder_init(X_)
    with writer.as_default():
        tf.summary.scalar('Loss Autoencoder Init', step_e_loss_t0, step=step) 

第二阶段训练 – 使用真实数据进行监督学习

我们已经创建了监督模型,所以我们只需要实例化优化器并定义训练步骤如下:

supervisor_optimizer = Adam()
@tf.function
def train_supervisor(x):
    with tf.GradientTape() as tape:
        h = embedder(x)
        h_hat_supervised = supervisor(h)
        g_loss_s = mse(h[:, 1:, :], h_hat_supervised[:, 1:, :])
    var_list = supervisor.trainable_variables
    gradients = tape.gradient(g_loss_s, var_list)
    supervisor_optimizer.apply_gradients(zip(gradients, var_list))
    return g_loss_s 

在这种情况下,损失比较监督器的输出与嵌入序列的下一个时间步,以便它学习历史价格序列的时间动态;训练循环的工作方式与前一章中的自动编码器示例类似。

第三阶段训练 – 使用真实数据和随机数据进行联合训练

联合训练涉及所有四个网络组件以及监督器。它使用多个损失函数和基本组件的组合来实现潜在空间嵌入、过渡动态和合成数据生成的同时学习。

我们将突出几个显著的例子;请查看笔记本以获取包含我们将在此省略的一些重复步骤的完整实现。

为了确保生成器能够忠实地复制时间序列,TimeGAN 包含一个时刻损失,当合成数据的均值和方差偏离真实版本时会受到惩罚:

def get_generator_moment_loss(y_true, y_pred):
    y_true_mean, y_true_var = tf.nn.moments(x=y_true, axes=[0])
    y_pred_mean, y_pred_var = tf.nn.moments(x=y_pred, axes=[0])
    g_loss_mean = tf.reduce_mean(tf.abs(y_true_mean - y_pred_mean))
    g_loss_var = tf.reduce_mean(tf.abs(tf.sqrt(y_true_var + 1e-6) - 
                                       tf.sqrt(y_pred_var + 1e-6)))
    return g_loss_mean + g_loss_var 

产生合成数据的端到端模型涉及生成器、监督器和恢复组件。它的定义如下,并且有接近 30,000 个可训练参数:

E_hat = generator(Z)
H_hat = supervisor(E_hat)
X_hat = recovery(H_hat)
synthetic_data = Model(inputs=Z,
                       outputs=X_hat,
                       name='SyntheticData')
Model: "SyntheticData"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
RandomData (InputLayer)      [(None, 24, 6)]           0         
_________________________________________________________________
Generator (Sequential)       (None, 24, 24)            10104     
_________________________________________________________________
Supervisor (Sequential)      (None, 24, 24)            7800      
_________________________________________________________________
Recovery (Sequential)        (None, 24, 6)             10950     
=================================================================
Trainable params: 28,854 

联合训练涉及自动编码器、生成器和鉴别器的三个优化器:

generator_optimizer = Adam()
discriminator_optimizer = Adam()
embedding_optimizer = Adam() 

生成器的训练步骤说明了使用四个损失函数和相应的网络组件组合来实现本节开头所概述的所需学习的用途:

@tf.function
def train_generator(x, z):
    with tf.GradientTape() as tape:
        y_fake = adversarial_supervised(z)
        generator_loss_unsupervised = bce(y_true=tf.ones_like(y_fake),
                                          y_pred=y_fake)
        y_fake_e = adversarial_emb(z)
        generator_loss_unsupervised_e = bce(y_true=tf.ones_like(y_fake_e),
                                            y_pred=y_fake_e)
        h = embedder(x)
        h_hat_supervised = supervisor(h)
        generator_loss_supervised = mse(h[:, 1:, :], 
                                        h_hat_supervised[:, 1:, :])
        x_hat = synthetic_data(z)
        generator_moment_loss = get_generator_moment_loss(x, x_hat)
        generator_loss = (generator_loss_unsupervised +
                          generator_loss_unsupervised_e +
                          100 * tf.sqrt(generator_loss_supervised) +
                          100 * generator_moment_loss)
    var_list = generator.trainable_variables + supervisor.trainable_variables
    gradients = tape.gradient(generator_loss, var_list)
    generator_optimizer.apply_gradients(zip(gradients, var_list))
    return (generator_loss_unsupervised, generator_loss_supervised,
            generator_moment_loss) 

最后,联合训练循环将各种训练步骤汇集起来,并建立在阶段 1 和 2 的学习基础上,以在真实数据和随机数据上训练 TimeGAN 组件。我们在不到 40 分钟内运行这个循环 10,000 次迭代:

for step in range(train_steps):
    # Train generator (twice as often as discriminator)
    for kk in range(2):
        X_ = next(real_series_iter)
        Z_ = next(random_series)
        # Train generator
        step_g_loss_u, step_g_loss_s, step_g_loss_v = train_generator(X_, Z_)
        # Train embedder
        step_e_loss_t0 = train_embedder(X_)
    X_ = next(real_series_iter)
    Z_ = next(random_series)
    step_d_loss = get_discriminator_loss(X_, Z_)
    if step_d_loss > 0.15:
        step_d_loss = train_discriminator(X_, Z_)
    if step % 1000 == 0:
        print(f'{step:6,.0f} | d_loss: {step_d_loss:6.4f} | '
              f'g_loss_u: {step_g_loss_u:6.4f} | '
              f'g_loss_s: {step_g_loss_s:6.4f} | '
              f'g_loss_v: {step_g_loss_v:6.4f} | '
              f'e_loss_t0: {step_e_loss_t0:6.4f}')
    with writer.as_default():
        tf.summary.scalar('G Loss S', step_g_loss_s, step=step)
        tf.summary.scalar('G Loss U', step_g_loss_u, step=step)
        tf.summary.scalar('G Loss V', step_g_loss_v, step=step)
        tf.summary.scalar('E Loss T0', step_e_loss_t0, step=step)
        tf.summary.scalar('D Loss', step_d_loss, step=step) 

现在我们终于可以生成合成的时间序列了!

生成合成的时间序列

为了评估 TimeGAN 的结果,我们将通过绘制随机输入并将其馈送到前面描述的 synthetic_data 网络来生成合成时间序列。更具体地说,我们将创建大致与真实数据集中重叠窗口数量相同的 24 个观察值的人工系列,涵盖六个股票:

generated_data = []
for i in range(int(n_windows / batch_size)):
    Z_ = next(random_series)
    d = synthetic_data(Z_)
    generated_data.append(d)
len(generated_data)
35 

结果是包含 128 个样本的 35 批次,每个样本的维度为 24×6,我们按以下方式堆叠:

generated_data = np.array(np.vstack(generated_data))
generated_data.shape
(4480, 24, 6) 

我们可以使用训练好的 MinMaxScaler 将合成输出恢复到输入序列的比例:

generated_data = (scaler.inverse_transform(generated_data
                                           .reshape(-1, n_seq))
                  .reshape(-1, seq_len, n_seq)) 

图 21.6 显示了六个合成系列和相应的真实系列的样本。合成数据通常反映了与其真实对应物不太相似的行为变化,并且经过重新缩放后,大致(由于随机输入)匹配其范围:

图 21.6:TimeGAN 输出——六个合成价格序列及其真实对应物

现在是时候更深入地评估合成数据的质量了。

评估合成时间序列数据的质量

TimeGAN 的作者根据三个实用标准评估生成数据的质量:

  • 多样性:合成样本的分布应大致与真实数据相匹配。

  • 忠实度:样本序列应与真实数据无法区分。

  • 有用性:合成数据应与其真实对应物一样有用于解决预测任务。

他们应用了三种方法来评估合成数据是否实际具有这些特征:

  • 可视化:为了定性多样性评估多样性,我们使用降维技术——主成分分析PCA)和 t-SNE(见第十三章使用无监督学习进行数据驱动的风险因子和资产配置)——来直观地检查合成样本的分布与原始数据的相似程度。

  • 区分分数:作为忠实度的定量评估,时间序列分类器的测试错误(例如两层 LSTM,见第十八章金融时间序列和卫星图像的 CNNs)让我们评估真实和合成时间序列是否可以区分,或者实际上是无法区分的。

  • 预测分数:作为有用性的定量衡量,我们可以比较在训练了基于实际数据或合成数据的序列预测模型后,预测下一个时间步骤的测试错误。

我们将在接下来的章节中应用并讨论每种方法的结果。有关代码示例和额外细节,请参阅笔记本 evaluating_synthetic_data

评估多样性——使用 PCA 和 t-SNE 进行可视化。

为了可视化具有 24 个时间步长和六个特征的真实和合成序列,我们将降低它们的维度,以便可以将它们绘制在二维平面上。为此,我们将抽样 250 个归一化的具有六个特征的序列,然后将它们重塑为维度为 1,500×24 的数据(仅展示真实数据的步骤;有关合成数据,请参阅笔记本):

# same steps to create real sequences for training
real_data = get_real_data()
# reload synthetic data
synthetic_data = np.load('generated_data.npy')
synthetic_data.shape
(4480, 24, 6)
# ensure same number of sequences
real_data = real_data[:synthetic_data.shape[0]]
sample_size = 250
idx = np.random.permutation(len(real_data))[:sample_size]
real_sample = np.asarray(real_data)[idx]
real_sample_2d = real_sample.reshape(-1, seq_len)
real_sample_2d.shape
(1500, 24) 

PCA 是一种线性方法,它确定一个新的基底,其中相互正交的向量依次捕获数据中的最大方差方向。我们将使用真实数据计算前两个分量,然后将真实和合成样本都投影到新的坐标系上:

pca = PCA(n_components=2)
pca.fit(real_sample_2d)
pca_real = (pd.DataFrame(pca.transform(real_sample_2d))
            .assign(Data='Real'))
pca_synthetic = (pd.DataFrame(pca.transform(synthetic_sample_2d))
                 .assign(Data='Synthetic')) 

t-SNE 是一种非线性流形学习方法,用于可视化高维数据。它将数据点之间的相似性转换为联合概率,并旨在最小化低维嵌入和高维数据之间的 Kullback-Leibler 散度(参见第十三章使用无监督学习进行数据驱动的风险因子和资产配置)。我们计算组合的真实和合成数据的 t-SNE 如下所示:

tsne_data = np.concatenate((real_sample_2d,  
                            synthetic_sample_2d), axis=0)
tsne = TSNE(n_components=2, perplexity=40)
tsne_result = tsne.fit_transform(tsne_data) 

图 21.7 显示了用于定性评估真实和合成数据分布相似性的 PCA 和 t-SNE 结果。两种方法都显示了明显相似的模式和显著重叠,表明合成数据捕获了真实数据特征的重要方面。

图 21.7: 两个维度中真实和合成数据的 250 个样本

评估保真度 – 时间序列分类性能

可视化仅提供了定性印象。为了定量评估合成数据的保真度,我们将训练一个时间序列分类器来区分真实数据和伪造数据,并评估其在保留的测试集上的性能。

更具体地说,我们将选择滚动序列的前 80% 进行训练,将最后 20% 作为测试集,如下所示:

synthetic_data.shape
(4480, 24, 6)
n_series = synthetic_data.shape[0]
idx = np.arange(n_series)
n_train = int(.8*n_series)
train_idx, test_idx = idx[:n_train], idx[n_train:]
train_data = np.vstack((real_data[train_idx], 
                        synthetic_data[train_idx]))
test_data = np.vstack((real_data[test_idx], 
                       synthetic_data[test_idx]))
n_train, n_test = len(train_idx), len(test_idx)
train_labels = np.concatenate((np.ones(n_train),
                               np.zeros(n_train)))
test_labels = np.concatenate((np.ones(n_test),
                              np.zeros(n_test))) 

然后,我们将创建一个简单的 RNN,它有六个单元,接收形状为 24×6 的真实和合成系列的小批量,并使用 sigmoid 激活。我们将使用二元交叉熵损失和 Adam 优化器进行优化,同时跟踪 AUC 和准确度指标:

ts_classifier = Sequential([GRU(6, input_shape=(24, 6), name='GRU'),
                            Dense(1, activation='sigmoid', name='OUT')])
ts_classifier.compile(loss='binary_crossentropy',
                      optimizer='adam',
                      metrics=[AUC(name='AUC'), 'accuracy'])
Model: "Time Series Classifier"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
GRU (GRU)                    (None, 6)                 252       
_________________________________________________________________
OUT (Dense)                  (None, 1)                 7         
=================================================================
Total params: 259
Trainable params: 259 

模型有 259 个可训练参数。我们将在 128 个随机选择的样本的批次上进行 250 个时期的训练,并跟踪验证性能:

result = ts_classifier.fit(x=train_data,
                           y=train_labels,
                           validation_data=(test_data, test_labels),
                           epochs=250, batch_size=128) 

训练完成后,对测试集的评估结果表明,平衡测试集的分类错误率接近 56%,AUC 非常低,仅为 0.15:

ts_classifier.evaluate(x=test_data, y=test_labels)
56/56 [==============================] - 0s 2ms/step - loss: 3.7510 - AUC: 0.1596 - accuracy: 0.4403 

图 21.8 绘制了训练和测试数据的准确度和 AUC 性能指标在 250 个训练时期上的情况:

图 21.8: 250 个时期的时间序列分类器的训练和测试性能

图表显示,该模型无法学习区分真实数据和合成数据的差异,并将其推广到测试集。这一结果表明,合成数据的质量符合保真标准。

评估实用性 – 在合成数据上训练,在真实数据上测试

最后,我们想知道在解决预测问题时合成数据的实用性如何。为此,我们将交替在合成数据和真实数据上训练一个时间序列预测模型,以预测下一个时间步,并比较在由真实数据创建的测试集上的性能。

具体来说,我们将选择每个序列的前 23 个时间步作为输入,最后一个时间步作为输出。与前面的分类示例相同,我们将使用相同的时间拆分将真实数据分为训练集和测试集:

real_data.shape, synthetic_data.shape
((4480, 24, 6), (4480, 24, 6))
real_train_data = real_data[train_idx, :23, :]
real_train_label = real_data[train_idx, -1, :]
real_test_data = real_data[test_idx, :23, :]
real_test_label = real_data[test_idx, -1, :]
real_train_data.shape, real_train_label.shape
((3584, 23, 6), (3584, 6)) 

我们将选择完整的合成数据进行训练,因为丰富性是我们首次生成它的原因之一:

synthetic_train = synthetic_data[:, :23, :]
synthetic_label = synthetic_data[:, -1, :]
synthetic_train.shape, synthetic_label.shape
((4480, 23, 6), (4480, 6)) 

我们将创建一个具有 12 个 GRU 单元的一层 RNN,用于预测六个股价系列的最后时间步,并因此具有六个线性输出单元。该模型使用 Adam 优化器来最小化平均绝对误差(MAE):

def get_model():
    model = Sequential([GRU(12, input_shape=(seq_len-1, n_seq)),
                        Dense(6)])
    model.compile(optimizer=Adam(), 
                  loss=MeanAbsoluteError(name='MAE'))
    return model 

我们将分别使用合成和真实数据对模型进行两次训练,使用真实的测试集来评估样本外表现。合成数据的训练工作如下;真实数据的训练工作类似(请参见笔记本):

ts_regression = get_model()
synthetic_result = ts_regression.fit(x=synthetic_train,
                                     y=synthetic_label,
                                     validation_data=(
                                         real_test_data, 
                                         real_test_label),
                                     epochs=100,
                                     batch_size=128) 

图 21.9 绘制了两种模型在训练集和测试集上的 MAE(以对数刻度绘制,以便我们可以发现差异)。结果表明,在合成数据集上训练后,MAE 稍微更低:

图 21.9:时间序列预测模型在 100 个时期内的训练和测试性能

结果表明,合成训练数据确实可能是有用的。在预测六个股票的下一个日收盘价的特定预测任务中,一个简单的模型在合成 TimeGAN 数据上的训练效果与在真实数据上的训练效果相同或更好。

得到的经验教训和下一步计划

我们在整本书中都遇到了过度拟合的永恒问题,这意味着生成有用的合成数据的能力可能会非常有价值。TimeGAN 的例子在这方面证明了谨慎的乐观态度。与此同时,还有一些警告:我们为少数资产以日频率生成了价格数据。实际上,我们可能对更多资产的回报感兴趣,可能是以更高的频率。横截面和时间动态肯定会变得更加复杂,并且可能需要对 TimeGAN 的架构和训练过程进行调整。

然而,实验的这些限制,虽然有希望,但意味着自然的下一步:我们需要将范围扩展到包含除价格以外的其他信息的更高维时间序列,并且还需要在更复杂的模型环境中测试它们的有用性,包括特征工程。合成训练数据的这些都是非常早期的阶段,但这个例子应该能让您追求自己的研究议程,朝着更加现实的解决方案前进。

摘要

在本章中,我们介绍了 GAN,它们学习输入数据上的概率分布,因此能够生成代表目标数据的合成样本。

虽然这个非常新的创新有许多实际应用,但如果在医学领域生成时间序列训练数据的成功能够转移到金融市场数据上,那么它们可能对算法交易特别有价值。我们学习了如何使用 TensorFlow 设置对抗性训练。我们还探讨了 TimeGAN,这是一个最近的例子,专门用于生成合成时间序列数据。

在下一章中,我们将重点关注强化学习,在这里我们将构建与它们(市场)环境交互学习的代理。

第二十二章:深度强化学习 - 构建交易代理

在本章中,我们将介绍强化学习RL),它与我们迄今为止涵盖的监督和无监督算法的机器学习ML)方法不同。RL 吸引了极大的关注,因为它是一些最令人兴奋的人工智能突破的主要推动力,比如 AlphaGo。AlphaGo 的创造者、Google 拥有的 DeepMind 的首席 RL 研究员 David Silver 最近荣获了 2019 年的重要 ACM 计算奖,以表彰其在计算机游戏中取得的突破性进展。我们将看到,RL 的交互式和在线特性使其特别适合于交易和投资领域。

RL 模型通过与通常具有不完全信息的随机环境交互的代理进行目标导向学习。RL 的目标是通过从奖励信号中学习状态和动作的价值来自动化代理如何做出决策以实现长期目标。最终目标是推导出一个策略,该策略编码了行为规则并将状态映射到动作。

RL 被认为是最接近人类学习的方法,它是通过在现实世界中采取行动并观察后果而产生的。它与监督学习不同,因为它根据一个标量奖励信号一次优化代理的行为体验,而不是通过从正确标记的、代表性的目标概念样本中泛化。此外,RL 不仅仅停留在做出预测。相反,它采用了端到端的目标导向决策视角,包括动作及其后果。

在本章中,您将学习如何制定 RL 问题并应用各种解决方法。我们将涵盖基于模型和无模型方法,介绍 OpenAI Gym 环境,并将深度学习与 RL 结合起来,训练一个在复杂环境中导航的代理。最后,我们将向您展示如何通过建模与金融市场互动的代理来调整 RL,以优化其利润目标。

更具体地说,在阅读本章后,您将能够:

  • 定义马尔可夫决策问题MDP

  • 使用值迭代和策略迭代来解决 MDP

  • 在具有离散状态和动作的环境中应用 Q 学习

  • 在连续环境中构建和训练一个深度 Q 学习代理

  • 使用 OpenAI Gym 训练 RL 交易代理

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和额外资源的链接。笔记本包括图像的彩色版本。

强化学习系统的要素

强化学习问题具有几个元素,使它们与我们迄今为止涵盖的机器学习设置有所不同。以下两个部分概述了定义和解决强化学习问题所需的关键特征,通过学习一个自动化决策的策略。我们将使用符号和通常遵循强化学习:导论(Sutton 和 Barto 2018)以及 David Silver 的 UCL 强化学习课程 (www.davidsilver.uk/teaching/),这些都是进一步研究的推荐材料,超出了本章范围的简要概述。

强化学习问题旨在解决优化代理目标的行动,考虑到对环境的一些观察。环境向代理提供其状态的信息,为动作分配奖励,并使代理转移到新状态,受到代理可能知道的概率分布的约束。它可能是完全或部分可观察的,还可能包含其他代理。环境的结构对代理学习给定任务的能力有着很大的影响,通常需要大量的前期设计工作来促进训练过程。

强化学习问题基于环境状态和代理动作空间的复杂性而有所不同,可以是离散的或连续的。连续的动作和状态,除非离散化,否则需要机器学习来近似状态、动作和其价值之间的函数关系。它们还需要泛化,因为代理在训练期间几乎肯定只经历了潜在无限数量的状态和动作的子集。

解决复杂的决策问题通常需要一个简化的模型,将关键方面隔离出来。图 22.1突显了强化学习问题的显著特征。这些特征通常包括:

  • 代理对环境状态的观察

  • 一组可供代理选择的动作

  • 管理代理决策的策略

图 22.1

图 22.1:强化学习系统的组成部分

此外,环境会发出奖励信号(可能是负数),因为代理的动作导致了状态转换到一个新状态。在其核心,代理通常学习一个价值函数,它指导其对可用动作的判断。代理的目标函数处理奖励信号,并将价值判断转化为最优策略。

策略 - 将状态转换为动作

在任何时刻,策略定义了代理的行为。它将代理可能遇到的任何状态映射到一个或多个动作。在具有有限状态和动作数量的环境中,策略可以是一个简单的查找表,在训练期间填充。

借助连续状态和动作,策略采用机器学习可以帮助近似的函数形式。策略也可能涉及大量计算,例如 AlphaZero,它使用树搜索来决定给定游戏状态的最佳动作。策略也可能是随机的,并给定一个状态,为动作分配概率。

奖励——从动作中学习

奖励信号是环境在每个时间步发送给代理程序的单个值。代理的目标通常是最大化随时间接收到的总奖励。奖励也可以是状态和动作的随机函数。它们通常被折扣以促进收敛并反映价值的时间衰减。

奖励是代理程序了解其在给定状态下决策价值的唯一途径,并相应调整策略。由于奖励信号对代理程序学习的重要影响,因此奖励信号通常是设计强化学习系统中最具挑战性的部分。

奖励需要清楚地传达代理应该完成的任务(而不是如何完成),可能需要领域知识来正确编码这些信息。例如,交易代理的开发可能需要为买入、持有和卖出决策定义奖励。这些可能仅限于利润和损失,但也可能需要包括波动率和风险考虑,例如回撤。

价值函数——长期选择的最优解

奖励提供了对动作的即时反馈。然而,解决强化学习问题需要能够从长远角度创造价值的决策。这就是价值函数的作用所在:它总结了状态或给定状态下动作的效用,以长期奖励的形式。

换句话说,状态的价值是代理程序在未来在该状态下可以期望获得的总奖励。即时奖励可能是未来奖励的良好代理,但代理还需要考虑到低奖励后面很可能出现的更好结果(或者相反)。

因此,值估计旨在预测未来的奖励。奖励是关键输入,而进行值估计的目标是获得更多的奖励。然而,强化学习方法专注于学习准确的值,以便在有效利用(通常有限的)经验的同时做出良好的决策。

还有一些强化学习方法不依赖于值函数,例如基因算法或模拟退火等随机优化方法,这些方法旨在通过有效地探索策略空间来找到最佳行为。然而,目前对强化学习的兴趣主要受到直接或间接估计状态和动作价值的方法驱动。

策略梯度方法是一种依赖于参数化、可微分策略的新发展,可以直接使用梯度下降优化目标(Sutton 等人,2000)。请参阅 GitHub 上的资源,其中包括超出本章范围的关键论文和算法的摘要。

有模型还是无模型 - 三思而后行?

基于模型的强化学习方法学习环境的模型,以使代理能够通过预测其行动的后果来提前规划。例如,这样的模型可以用于根据当前状态和行动预测下一个状态和奖励。这是规划的基础,即通过考虑未来可能发生的情况来决定最佳行动方案。

相反,更简单的无模型方法是通过试错学习的。现代强化学习方法涵盖了从低级试错方法到高级、深思熟虑的规划的整个范围。正确的方法取决于环境的复杂性和可学习性。

如何解决强化学习问题

强化学习方法旨在通过经验学习如何采取能够实现长期目标的行动。为此,代理与环境通过一系列离散的时间步骤进行交互,通过先前部分中描述的行动、状态观测和奖励的接口进行交互。

解决强化学习问题的关键挑战

解决强化学习问题需要解决两个独特的挑战:信用分配问题和探索-利用权衡。

信用分配

在强化学习中,奖励信号可能比导致结果的行动晚得多,使得将行动与其后果联系起来变得复杂。例如,当代理人多次采取 100 个不同的持仓并进行交易时,如果它只了解到投资组合收益,它如何意识到某些持仓表现比其他持仓好得多呢?

信用分配问题是在考虑到这些延迟的情况下准确估计给定状态下行动的利益和成本的挑战。强化学习算法需要找到一种方法,将积极和消极结果的信用分配给可能参与产生这些结果的许多决策。

探索与利用

强化学习的动态和交互性意味着代理需要在经历所有相关轨迹之前估计状态和行动的价值。虽然它可以在任何阶段选择一个动作,但这些决定是基于不完全的学习,但会为代理生成其行为的最佳选择的第一手见解。

对行动价值的部分可见性会导致只利用过去(成功的)经验而不是探索未知领域的决策的风险。这些选择限制了代理的暴露,并阻止它学习最优策略。

RL 算法需要平衡这种探索-利用的权衡——太少的探索可能会产生偏见的值估计和次优策略,而太少的利用则会阻止学习的发生。

解决强化学习问题的基本方法

解决 RL 问题有许多方法,所有这些方法都涉及找到代理的最优行为规则:

  • DP 方法做出了完全了解环境的常常不切实际的假设,但它们是大多数其他方法的概念基础。

  • 蒙特卡洛(MC)方法通过对整个状态-动作-奖励序列进行采样来学习环境以及不同决策的成本和收益。

  • TD 学习通过从更短的序列中学习显著提高了样本效率。为此,它依赖于引导,即根据其自身的先前估计来优化其估计。

当一个 RL 问题包括明确定义的转移概率以及有限数量的状态和动作时,它可以被构建为一个有限 MDP,对于这个 MDP,DP 可以计算出一个精确的解。当前 RL 理论的大部分关注点都集中在有限 MDP 上,但实际应用需要更一般的设置。未知的转移概率需要高效的采样来学习它们的分布。

对于连续状态和/或动作空间的方法通常利用机器学习来近似值函数或策略函数。它们集成了监督学习,特别是前四章讨论的深度学习方法。然而,在 RL 环境中,这些方法面临着明显的挑战:

  • 奖励信号不直接反映目标概念,就像标记的训练样本一样。

  • 观察的分布取决于代理的动作和策略,策略本身是学习过程的主题。

以下各节将介绍和演示各种解决方法。我们将从值迭代和策略迭代的 DP 方法开始,这些方法仅限于已知转移概率的有限 MDP。正如我们将在接下来的部分看到的那样,它们是 Q-learning 的基础,Q-learning 基于 TD 学习,并且不需要关于转移概率的信息。它的目标与 DP 类似,但计算量较少,而且不需要假设环境的完美模型。最后,我们将扩展范围到连续状态,并介绍深度 Q-learning。

解决动态规划问题

有限 MDP 是一个简单但基本的框架。我们将介绍代理人试图优化的奖励轨迹,定义用于制定优化问题的策略和值函数,以及构成解决方法基础的贝尔曼方程。

有限马尔可夫决策问题

MDPs 将代理-环境交互框架化为在构成情节的一系列时间步 t =1,…,T 上的顺序决策问题。时间步骤被假定为离散的,但该框架可以扩展到连续时间。

MDP 提供的抽象使其应用在许多背景下都能轻松适应。时间步骤可以在任意间隔,动作和状态可以采用可以数值化表示的任何形式。

马尔可夫性质意味着当前状态完全描述了过程,即过程没有记忆。当尝试预测过程的未来时,来自过去状态的信息不添加价值。由于这些属性,该框架已用于建模资产价格,这些资产受到在第五章中讨论的有效市场假设的影响,即投资组合优化与绩效评估

状态、动作和奖励序列

MDPs 的运行方式如下:在每一步 t,代理观察环境的状态 并选择一个动作 ,其中 SA 分别是状态和动作的集合。在下一个时间步 t+1,代理接收到一个奖励 并转移到状态 S[t][+1]。随着时间的推移,MDP 产生了一条轨迹 S[0],A[0],R[1],S[1],A[1],R[1],……,直到代理达到终止状态并结束该情节。

有限的 MDP 具有有限数量的动作 A,状态 S 和奖励 R,包括对这些元素的明确定义的离散概率分布。由于马尔可夫性质,这些分布仅依赖于先前的状态和动作。

轨迹的概率性意味着代理最大化未来奖励的期望总和。此外,奖励通常使用因子 进行折现,以反映其时间价值。对于不是周期性的任务,而是无限期进行的任务,需要使用严格小于 1 的折现因子,以避免无限奖励并确保收敛。因此,代理最大化折现的未来回报总和 R[t],表示为 G[t]:

这种关系也可以递归地定义,因为从第二步开始的求和与G[t][+1]折现一次是相同的:

我们将在后面看到,这种递归关系经常用于制定强化学习算法。

值函数——如何估计长期回报

如前所述,一个策略 将所有状态映射到动作的概率分布,以便在状态 S[t] 中选择动作 A[t] 的概率可以表示为 。值函数估计每个状态或状态-动作对的长期回报。找到将状态映射到动作的最佳策略是至关重要的。

对于策略,状态值函数给出了特定状态s的长期价值v,作为代理从s开始然后始终遵循策略的预期回报G。它的定义如下,其中是指当代理遵循策略时的预期值:

同样,我们可以计算状态动作值函数 q(s,a),作为在状态s开始,采取行动,然后始终遵循策略的预期回报:

贝尔曼方程

贝尔曼方程定义了所有状态sS中的值函数与任何其后继状态s′之间的递归关系,其遵循策略。它们通过将值函数分解为即时奖励和下一状态的折现值来实现这一点:

这个方程表示,对于给定策略,一个状态的值必须等于其在该策略下的后继状态的预期值,加上到达该后继状态时所获得的预期奖励。

这意味着,如果我们知道当前可用操作的后继状态的值,我们可以向前看一步,计算当前状态的预期值。由于它对所有状态S都成立,该表达式定义了一组方程。对于,也存在类似的关系。

图 22.2总结了这种递归关系:在当前状态下,代理根据策略选择一个动作a。环境通过分配一个取决于结果新状态s′的奖励来做出响应:

图 22.2:贝尔曼方程表达的递归关系

从值函数到最优策略

强化学习问题的解是一个优化累积奖励的策略。策略和值函数紧密相关:一个最优策略为每个状态或状态-动作对提供的值估计至少与任何其他策略的值相同,因为该值是给定策略下的累积奖励。因此,最优值函数隐式定义了最优策略并解决了 MDP。

最优值函数 也满足前一节中的贝尔曼方程。这些贝尔曼最优方程可以省略对策略的显式引用,因为它被 隐含。对于 ,递归关系将当前值等同于选择当前状态中最佳动作的即时奖励之和,以及后继状态的期望折现值:

对于最佳状态-动作值函数 ,贝尔曼最优方程将当前状态-动作值分解为隐含当前动作的奖励与所有后继状态中最佳动作的期望值的折现期望值之和:

最优性条件暗示了最佳策略是始终选择最大化贪婪方式中的期望值的动作,即仅考虑单个时间步骤的结果。

由前两个表达式定义的最优性条件由于 max 操作符是非线性的,缺乏封闭形式的解。相反,MDP 解决方案依赖于迭代解法 - 如策略和值迭代或 Q-learning,我们将在下一节中介绍。

策略迭代

DP 是一种解决可以分解为具有递归结构并允许重复使用中间结果的较小、重叠子问题的一般方法。由于递归贝尔曼最优方程和值函数的累积特性,MDP 符合这一要求。更具体地说,最优性原理适用于最优策略,因为最优策略包括选择最优动作然后遵循最优策略。

DP 需要了解 MDP 的转移概率。通常情况下并非如此,但许多更一般情况下的方法都采用类似于 DP 的方法,并从数据中学习缺失的信息。

DP 对于估计值函数的预测任务和专注于最佳决策并输出策略的控制任务非常有用(在此过程中也估计值函数)。

找到最优策略的策略迭代算法重复以下两个步骤,直到策略收敛,即不再发生变化超过给定阈值:

  1. 策略评估:根据当前策略更新值函数。

  2. 策略改进:更新策略,使动作最大化期望的一步值。

策略评估依赖于贝尔曼方程来估计值函数。更具体地说,它选择由当前策略确定的动作,并将导致的奖励以及下一个状态的折现值相加,以更新当前状态的值。

策略改进又改变了策略,使得对于每个状态,策略产生下一状态中产生最高价值的动作。此改进称为贪婪,因为它仅考虑了单个时间步的回报。策略迭代总是收敛到最优策略,并且通常在相对较少的迭代中实现。

值迭代

策略迭代需要在每次迭代后评估所有状态的策略。对于基于搜索树的策略,例如,评估可能是昂贵的,正如前面讨论的那样。

值迭代通过简化此过程来实现,将策略评估和改进步骤折叠在一起。在每个时间步长,它遍历所有状态并基于下一个状态的当前值估计选择最佳的贪婪动作。然后,它使用贝尔曼最优方程所暗示的一步展望来更新当前状态的值函数。

价值函数的相应更新规则 几乎与策略评估更新相同;它只是在可用动作上增加了最大化:

当价值函数收敛并输出从其值函数估计得出的贪婪策略时,算法停止。它也保证收敛到最优策略。

泛化策略迭代

在实践中,有几种方法可以截断策略迭代;例如,在改进之前评估策略k次。这意味着max操作符将仅在每第k次迭代时应用。

大多数强化学习算法估计值和策略函数,并依赖于策略评估和改进的交互来收敛到解决方案,如图 22.3所示。通常方法是相对于值函数改进策略,同时调整值函数使其匹配策略:

图 22.3:策略评估和改进的收敛

收敛要求值函数与策略一致,而策略又需要在相对于值函数的贪婪行为中稳定。因此,只有当找到一个相对于其自身评估函数是贪婪的策略时,这两个过程才会稳定。这意味着贝尔曼最优方程成立,因此策略和值函数是最优的。

Python 中的动态规划

在本节中,我们将把值迭代和策略迭代应用到一个玩具环境中,该环境由一个网格组成,如图 22.4所示,具有以下特征:

  • 状态:11 个状态表示为二维坐标。一个字段不可访问,最右列的顶部两个状态是终止状态,即它们结束了该回合。

  • 动作:向上、向下、向左或向右移动一步。环境是随机的,因此动作可能会产生意外的结果。对于每个动作,有 80%的概率移动到预期状态,并且有 10%的概率移动到相邻方向(例如,向右或向左而不是向上,或者向上/向下而不是向右)。

  • 奖励:如左图所示,除了终止状态的+1/-1 奖励外,每个状态都会产生-.02 的奖励。

图 22.4:3×4 网格世界奖励,值函数和最优策略

设置网格世界

我们将开始定义环境参数:

grid_size = (3, 4)
blocked_cell = (1, 1)
baseline_reward = -0.02
absorbing_cells = {(0, 3): 1, (1, 3): -1}
actions = ['L', 'U', 'R', 'D']
num_actions = len(actions)
probs = [.1, .8, .1, 0] 

我们经常需要在 1D 和 2D 表示之间进行转换,因此我们将为此定义两个辅助函数;状态是一维的,而单元格是相应的 2D 坐标:

to_1d = lambda x: np.ravel_multi_index(x, grid_size)
to_2d = lambda x: np.unravel_index(x, grid_size) 

此外,我们将预先计算一些数据点以使代码更简洁:

num_states = np.product(grid_size)
cells = list(np.ndindex(grid_size))
states = list(range(len(cells)))
cell_state = dict(zip(cells, states))
state_cell= dict(zip(states, cells))
absorbing_states = {to_1d(s):r for s, r in absorbing_cells.items()}
blocked_state = to_1d(blocked_cell) 

我们存储每个状态的奖励:

state_rewards = np.full(num_states, baseline_reward)
state_rewards[blocked_state] = 0
for state, reward in absorbing_states.items():
    state_rewards[state] = reward
state_rewards
array([-0.02, -0.02, -0.02,  1\.  , -0.02,  0\.  , -0.02, -1\.  , -0.02,
       -0.02, -0.02, -0.02]) 

为了考虑到概率环境,我们还需要计算给定动作的实际移动的概率分布:

action_outcomes = {}
for i, action in enumerate(actions):
    probs_ = dict(zip([actions[j % 4] for j in range(i, 
                                               num_actions + i)], probs))
    action_outcomes[actions[(i + 1) % 4]] = probs_
Action_outcomes
{'U': {'L': 0.1, 'U': 0.8, 'R': 0.1, 'D': 0},
 'R': {'U': 0.1, 'R': 0.8, 'D': 0.1, 'L': 0},
 'D': {'R': 0.1, 'D': 0.8, 'L': 0.1, 'U': 0},
 'L': {'D': 0.1, 'L': 0.8, 'U': 0.1, 'R': 0}} 

现在,我们准备计算转移矩阵,这是 MDP 的关键输入。

计算转移矩阵

转移矩阵 定义了对于每个先前状态和动作 A,以及每个状态 S 的结束概率!。我们将演示pymdptoolbox并使用其中一种可用于指定转移和奖励的格式。对于转移概率,我们将创建一个具有维度的 NumPy 数组。

我们首先计算每个起始单元格和移动的目标单元格:

def get_new_cell(state, move):
    cell = to_2d(state)
    if actions[move] == 'U':
        return cell[0] - 1, cell[1]
    elif actions[move] == 'D':
        return cell[0] + 1, cell[1]
    elif actions[move] == 'R':
        return cell[0], cell[1] + 1
    elif actions[move] == 'L':
        return cell[0], cell[1] - 1 

以下函数使用开始stateactionoutcome参数来填充转移概率和奖励:

def update_transitions_and_rewards(state, action, outcome):
    if state in absorbing_states.keys() or state == blocked_state:
        transitions[action, state, state] = 1
    else:
        new_cell = get_new_cell(state, outcome)
        p = action_outcomes[actions[action]][actions[outcome]]
        if new_cell not in cells or new_cell == blocked_cell:
            transitions[action, state, state] += p
            rewards[action, state, state] = baseline_reward
        else:
            new_state= to_1d(new_cell)
            transitions[action, state, new_state] = p
            rewards[action, state, new_state] = state_rewards[new_state] 

我们通过创建占位数据结构并迭代的笛卡尔积来生成转移和奖励值,如下所示:

rewards = np.zeros(shape=(num_actions, num_states, num_states))
transitions = np.zeros((num_actions, num_states, num_states))
actions_ = list(range(num_actions))
for action, outcome, state in product(actions_, actions_, states):
    update_transitions_and_rewards(state, action, outcome)
rewards.shape, transitions.shape
((4,12,12), (4,12,12)) 

实现值迭代算法

我们首先创建值迭代算法,稍微简单一些,因为它在单个步骤中实现策略评估和改进。我们捕获需要更新值函数的状态,排除了值为 0 的终止状态(由于缺乏奖励,+1/-1 分配给起始状态),并跳过阻塞的单元格:

skip_states = list(absorbing_states.keys())+[blocked_state]
states_to_update = [s for s in states if s not in skip_states] 

然后,我们初始化值函数,并设置折扣因子 gamma 和收敛阈值epsilon

V = np.random.rand(num_states)
V[skip_states] = 0
gamma = .99
epsilon = 1e-5 

算法使用贝尔曼最优方程更新值函数,如前所述,并在V的 L1 范数绝对值小于 epsilon 时终止:

while True:
    V_ = np.copy(V)
    for state in states_to_update:
        q_sa = np.sum(transitions[:, state] * (rewards[:, state] + gamma* V), 
                      axis=1)
        V[state] = np.max(q_sa)
    if np.sum(np.fabs(V - V_)) < epsilon:
        break 

该算法在 16 次迭代和 0.0117 秒内收敛。它产生以下最优值估计,连同隐含的最优策略,如本节之前的图 22.4右图所示:

pd.DataFrame(V.reshape(grid_size))
         0         1         2         3
0.884143  0.925054  0.961986  0.000000
1  0.848181  0.000000  0.714643  0.000000
2  0.808344  0.773327  0.736099  0.516082 

定义并运行策略迭代

政策迭代包括单独的评估和改进步骤。我们通过选择最大化预期奖励和下一个状态值的和的动作来定义改进部分。请注意,我们临时填充终端状态的奖励以避免忽略会导致我们到达那里的动作:

def policy_improvement(value, transitions):
    for state, reward in absorbing_states.items():
        value[state] = reward
    return np.argmax(np.sum(transitions * value, 2),0) 

我们像以前一样初始化值函数,并且还包括一个随机起始策略:

pi = np.random.choice(list(range(num_actions)), size=num_states) 

该算法在贪婪选择的动作的政策评估和政策改进之间交替,直到策略稳定为止:

iterations = 0
converged = False
while not converged:
    pi_ = np.copy(pi)
    for state in states_to_update:
        action = policy[state]
        V[state] = np.dot(transitions[action, state], 
                                      rewards[action, state] + gamma* V)
        pi = policy_improvement(V.copy(), transitions)
    if np.array_equal(pi_, pi):
        converged = True
    iterations += 1 

政策迭代在仅三次迭代后收敛。在算法找到最优值函数之前,策略会稳定下来,而最优策略略有不同,最明显的是建议在负终端状态旁边的场地上“向上”而不是更安全的“向左”。通过缩紧收敛标准(例如,要求几轮稳定的策略或为值函数添加阈值),可以避免这种情况。

使用 pymdptoolbox 解决 MDP 问题

我们也可以使用 Python 库pymdptoolbox来解决 MDP 问题,其中包括一些其他算法,包括 Q-learning。

要运行值迭代,只需在调用.run()方法之前,使用所需的配置选项、奖励和转移矩阵实例化相应的对象:

vi = mdp.ValueIteration(transitions=transitions,
                        reward=rewards,
                        discount=gamma,
                        epsilon=epsilon)
vi.run() 

值函数估计与上一节的结果相匹配:

np.allclose(V.reshape(grid_size), np.asarray(vi.V).reshape(grid_size)) 

政策迭代工作方式类似:

pi = mdp.PolicyIteration(transitions=transitions,
                        reward=rewards,
                        discount=gamma,
                        max_iter=1000)
pi.run() 

它也产生相同的策略,但是值函数会根据运行而变化,并且在策略收敛之前不需要达到最优值。

吸取的教训

我们之前在图 22.4中看到的右侧面板显示了值迭代产生的最优值估计以及相应的贪婪策略。负奖励与环境的不确定性相结合,产生了一个最优策略,涉及远离负终端状态。

结果对奖励和折扣因子都很敏感。负状态的成本影响周围字段的策略,您应修改相应笔记本中的示例以识别改变最优动作选择的阈值水平。

Q-learning - 边走边学习寻找最优策略

Q-learning 是早期的强化学习突破,由克里斯·沃特金斯(Chris Watkins)为他的博士论文开发(www.cs.rhul.ac.uk/~chrisw/new_thesis.pdf)(1989)。它引入了增量动态规划来学习控制 MDP,而不知道或建模我们在前一节中用于值和策略迭代的转移和奖励矩阵。 3 年后进行了收敛证明(Christopher J.C.H. Watkins 和 Dayan 1992)。

Q 学习直接优化动作值函数q以逼近*q。学习进行“离策略”,即,算法不需要仅根据值函数隐含的策略选择动作。然而,收敛需要所有状态-动作对在整个训练过程中持续更新。确保这一点的一种简单方法是通过-贪婪策略**。

探索与利用 – -贪婪策略

一个-贪婪策略是一种简单的策略,它确保在给定状态下探索新的动作,同时也利用了学习经验。它通过随机选择动作来实现这一点。一个-贪婪策略以概率随机选择一个动作,否则选择值函数最优的动作。

Q 学习算法

该算法在随机初始化后的给定数量的剧集中不断改进状态-动作值函数。在每个时间步长,它根据一个-贪婪策略选择一个动作,并使用学习率来更新值函数,如下所示:

请注意,该算法不会根据转移概率计算期望值。相反,它从-贪婪策略产生的奖励R[t]和下一个状态的折现值函数的当前估计中学习Q函数。

使用估计值函数来改进这个估计本身被称为自举。Q 学习算法是时间差TD学习算法的一部分。TD 学习不会等待收到剧集的最终奖励。相反,它使用更接近最终奖励的中间状态的值来更新其估计。在这种情况下,中间状态是一步。

如何使用 Python 训练一个 Q 学习智能体

在本节中,我们将演示如何使用上一节中的状态网格构建一个 Q 学习智能体。我们将训练智能体进行 2,500 个剧集,使用学习速率进行-贪婪策略(有关详细信息,请参见笔记本gridworld_q_learning.ipynb):

max_episodes = 2500
alpha = .1
epsilon = .05 

然后,我们将随机初始化状态-动作值函数作为 NumPy 数组,维度为状态数×动作数

Q = np.random.rand(num_states, num_actions)
Q[skip_states] = 0 

该算法生成 2,500 个从随机位置开始并根据-贪婪策略进行的剧集,直到终止,根据 Q 学习规则更新值函数:

for episode in range(max_episodes):
    state = np.random.choice([s for s in states if s not in skip_states])
    while not state in absorbing_states.keys():
        if np.random.rand() < epsilon:
            action = np.random.choice(num_actions)
        else:
            action = np.argmax(Q[state])
        next_state = np.random.choice(states, p=transitions[action, state])
        reward = rewards[action, state, next_state]
        Q[state, action] += alpha * (reward + 
                            gamma * np.max(Q[next_state])-Q[state, action])
        state = next_state 

每个情节需要 0.6 秒,并收敛到与前一节中值迭代示例结果相当接近的值函数。pymdptoolbox实现与以前的示例类似(详情请参见笔记本)。

使用 OpenAI Gym 进行交易的深度 RL

在前一节中,我们看到了 Q 学习如何让我们在具有离散状态和离散动作的环境中使用基于贝尔曼方程的迭代更新来学习最优状态-动作值函数*q**。

在本节中,我们将强化学习(RL)迈向真实世界,将算法升级为连续状态(同时保持动作离散)。这意味着我们不能再使用简单地填充数组状态-动作值的表格解决方案。相反,我们将看到如何使用神经网络来近似 q,从而得到深度 Q 网络。在介绍深度 Q 学习算法之前,我们将首先讨论深度学习与 RL 的整合,以及各种加速其收敛并使其更加健壮的改进。

连续状态还意味着更复杂的环境。我们将演示如何使用 OpenAI Gym,一个用于设计和比较 RL 算法的工具包。首先,我们将通过训练一个深度 Q 学习代理程序来演示工作流程,以在月球着陆器环境中导航一个玩具飞船。然后,我们将继续自定义 OpenAI Gym,设计一个模拟交易情境的环境,其中代理可以买卖股票,并与市场竞争。

使用神经网络进行值函数近似

连续状态和/或动作空间意味着无限数量的转换,使得不可能像前一节那样制表状态-动作值。相反,我们通过学习连续参数化映射来近似 Q 函数。

受到在其他领域中 NN 成功的启发,我们在Part 4中讨论过,深度 NN 也因近似值函数而变得流行起来。然而,在 RL 环境中,数据由模型与使用(可能是随机的)策略与环境进行交互生成,面临着不同的挑战

  • 对于连续状态,代理将无法访问大多数状态,因此需要进行泛化。

  • 在监督学习中,旨在从独立同分布且具有代表性且正确标记的样本中概括出来,而在强化学习(RL)环境中,每个时间步只有一个样本,因此学习需要在线进行。

  • 此外,当连续状态时,样本可能高度相关,当连续状态相似且行为分布在状态和动作上不是固定的,而是由于代理的学习而发生变化时,样本可能高度相关。

我们将介绍几种已开发的技术来解决这些额外的挑战。

深度 Q 学习算法及其扩展

深度 Q 学习通过深度神经网络估计给定状态的可用动作的价值。DeepMind 在 使用深度强化学习玩 Atari 游戏(Mnih 等人,2013)中介绍了这项技术,代理程序仅从像素输入中学习玩游戏。

深度 Q 学习算法通过学习一组权重 的多层深度 Q 网络DQN)来近似动作价值函数 q,该函数将状态映射到动作,使得

该算法应用基于损失函数的梯度下降,计算目标 DQN 的估计之间的平方差:

并根据当前状态-动作对的动作价值估计 来学习网络参数:

目标和当前估计都依赖于 DQN 权重,突显了与监督学习的区别,在监督学习中,目标在训练之前是固定的。

该 Q 学习算法不计算完整梯度,而是使用随机梯度下降SGD)并在每个时间步 i 后更新权重 。为了探索状态-动作空间,代理程序使用一个 -贪婪策略,以概率选择一个随机动作,否则按照最高预测 q 值选择动作。

基本的DQN 架构已经得到改进,以使学习过程更加高效,并改善最终结果;Hessel 等人(2017)将这些创新组合成Rainbow 代理,并展示了每个创新如何显著提高 Atari 基准测试的性能。以下各小节总结了其中一些创新。

(优先)经验回放 – 关注过去的错误

经验回放存储代理程序经历的状态、动作、奖励和下一个状态转换的历史记录。它从这些经验中随机抽样小批量,在每个时间步更新网络权重,然后代理程序选择一个ε-贪婪动作。

经验回放提高了样本效率,减少了在线学习期间收集的样本的自相关性,并限制了由当前权重产生的反馈,这些反馈可能导致局部最小值或发散(Lin 和 Mitchell 1992)。

此技术后来被进一步改进,以优先考虑从学习角度更重要的经验。Schaul 等人(2015)通过 TD 误差的大小来近似转换的价值,该误差捕捉了该事件对代理程序的“惊讶程度”。实际上,它使用其关联的 TD 误差而不是均匀概率对历史状态转换进行抽样。

目标网络 – 解耦学习过程

为了进一步削弱当前网络参数对 NN 权重更新的反馈循环,DeepMind 在 Human-level control through deep reinforcement learning(Mnih et al. 2015)中将算法扩展为使用缓慢变化的目标网络。

目标网络具有与 Q 网络相同的架构,但其权重为 ,仅在每隔 步更新一次,当它们从 Q 网络复制并保持不变时。目标网络生成 TD 目标预测,即它取代 Q 网络来估计:

双深度 Q 学习 – 分离行动和预测

Q-learning 存在过高估计行动价值的问题,因为它有意地采样最大估计行动价值。

如果这种偏见不是均匀应用并且改变行动偏好,它可能会对学习过程和结果的政策产生负面影响,就像 Deep Reinforcement Learning with Double Q-learning(van Hasselt, Guez, and Silver 2015)中所示的那样。

为了将行动价值的估计与行动的选择分离,双深度 Q 学习DDQN)算法使用一个网络的权重 来选择给定下一个状态的最佳行动,以及另一个网络的权重 来提供相应的行动价值估计:

一个选项是在每次迭代时随机选择两个相同网络中的一个进行训练,以使它们的权重不同。更有效的替代方法是依靠目标网络提供

介绍 OpenAI Gym

OpenAI Gym 是一个提供标准化环境以测试和基准 RL 算法的 RL 平台,使用 Python。也可以扩展该平台并注册自定义环境。

Lunar Lander v2LL)环境要求代理根据离散行动空间和包括位置、方向和速度在内的低维状态观察来控制其在二维中的运动。在每个时间步长,环境提供新状态的观察和正面或负面的奖励。每个事件最多包含 1,000 个时间步。图 22.5 展示了我们稍后将训练的代理在经过 250 个事件后成功着陆时的选定帧:

图 22.5:月球着陆器(Lunar Lander)事件期间 RL 代理的行为

更具体地说,代理观察到状态的八个方面,包括六个连续和两个离散元素。根据观察到的元素,代理知道自己的位置、方向和移动速度,以及是否(部分)着陆。然而,它不知道应该朝哪个方向移动,也不能观察环境的内部状态以了解规则来控制其运动。

在每个时间步长,智能体使用四种离散动作来控制其运动。它可以什么都不做(继续当前路径),启动主引擎(减少向下运动),或使用相应的方向引擎向左或向右转向。没有燃料限制。

目标是在坐标(0,0)的着陆垫上的两个旗帜之间着陆智能体,但也可以在垫子外着陆。智能体朝着垫子移动,积累的奖励在 100-140 之间,具体取决于着陆点。然而,远离目标的移动会抵消智能体通过朝着垫子移动而获得的奖励。每条腿的接地都会增加 10 分,而使用主引擎会消耗-0.3 点。

如果智能体着陆或坠毁,则一集结束,分别添加或减去 100 分,或在 1000 个时间步之后结束。解决 LL 需要在 100 个连续集合上平均获得至少 200 的累积奖励。

如何使用 TensorFlow 2 实现 DDQN

笔记本03_lunar_lander_deep_q_learning使用 TensorFlow 2 实现了一个 DDQN 代理程序,该程序学习解决 OpenAI Gym 的月球着陆器2.0(LL)环境。笔记本03_lunar_lander_deep_q_learning包含了在第一版中讨论的 TensorFlow 1 实现,运行速度显著更快,因为它不依赖于急切执行,并且更快地收敛。本节重点介绍了实现的关键元素;更详细的细节请参阅笔记本。

创建 DDQN 代理

我们将DDQNAgent创建为一个 Python 类,以将学习和执行逻辑与关键配置参数和性能跟踪集成在一起。

代理的__init__()方法接受以下信息作为参数:

  • 环境特征,比如状态观测的维度数量以及智能体可用的动作数量。

  • ε-贪婪策略的随机探索衰减。

  • 神经网络架构训练和目标网络更新的参数。

    class DDQNAgent:
        def __init__(self, state_dim, num_actions, gamma,
                     epsilon_start, epsilon_end, epsilon_decay_steps,
                     epsilon_exp_decay,replay_capacity, learning_rate,
                     architecture, l2_reg, tau, batch_size,
                     log_dir='results'): 
    

将 DDQN 架构调整为月球着陆器

首次将 DDQN 架构应用于具有高维图像观察的雅达利领域,并依赖于卷积层。LL 的较低维状态表示使得全连接层成为更好的选择(见第十七章交易深度学习)。

更具体地说,网络将八个输入映射到四个输出,表示每个动作的 Q 值,因此只需进行一次前向传递即可计算动作值。DQN 使用 Adam 优化器对先前的损失函数进行训练。代理的 DQN 使用每个具有 256 单元的三个密集连接层和 L2 活动正则化。通过 TensorFlow Docker 镜像使用 GPU 可以显著加快 NN 训练性能(见第十七章第十八章金融时间序列和卫星图像的 CNN)。

DDQNAgent类的build_model()方法根据architecture参数创建主要的在线和缓慢移动的目标网络,该参数指定了层的数量和它们的单元数量。

对于主要的在线网络,我们将trainable设置为True,对于目标网络,我们将其设置为False。这是因为我们只是周期性地将在线 NN 的权重复制以更新目标网络:

 def build_model(self, trainable=True):
        layers = []
        for i, units in enumerate(self.architecture, 1):
            layers.append(Dense(units=units,
                                input_dim=self.state_dim if i == 1 else None,
                                activation='relu',
                                kernel_regularizer=l2(self.l2_reg),
                                trainable=trainable))
        layers.append(Dense(units=self.num_actions, 
                            trainable=trainable))
        model = Sequential(layers)
        model.compile(loss='mean_squared_error',
                      optimizer=Adam(lr=self.learning_rate))
        return model 

记忆转换和重播体验

为了启用经验重播,代理记忆每个状态转换,以便在训练期间随机抽样小批量。memorize_transition()方法接收环境提供的当前和下一个状态的观察、代理的动作、奖励以及指示情节是否完成的标志。

它跟踪奖励历史和每个情节的长度,在每个周期结束时对 epsilon 进行指数衰减,并将状态转换信息存储在缓冲区中:

 def memorize_transition(self, s, a, r, s_prime, not_done):
        if not_done:
            self.episode_reward += r
            self.episode_length += 1
        else:
            self.episodes += 1
            self.rewards_history.append(self.episode_reward)
            self.steps_per_episode.append(self.episode_length)
            self.episode_reward, self.episode_length = 0, 0
        self.experience.append((s, a, r, s_prime, not_done)) 

一旦有足够的样本创建完整的批次,记忆的重播就开始了。experience_replay()方法使用在线网络预测下一个状态的 Q 值,并选择最佳动作。然后,它从目标网络中选择这些动作的预测q值,以得到 TDtargets

接下来,它使用单个批次的当前状态观察作为输入,TD 目标作为输出,并将均方误差作为损失函数训练主要网络。最后,它每隔步更新一次目标网络的权重:

 def experience_replay(self):
        if self.batch_size > len(self.experience):
            return
        # sample minibatch from experience
        minibatch = map(np.array, zip(*sample(self.experience, 
                                              self.batch_size)))
        states, actions, rewards, next_states, not_done = minibatch
        # predict next Q values to select best action
        next_q_values = self.online_network.predict_on_batch(next_states)
        best_actions = tf.argmax(next_q_values, axis=1)
        # predict the TD target
        next_q_values_target = self.target_network.predict_on_batch(
            next_states)
        target_q_values = tf.gather_nd(next_q_values_target,
                                       tf.stack((self.idx, tf.cast(
                                          best_actions, tf.int32)), axis=1))
        targets = rewards + not_done * self.gamma * target_q_values
        # predict q values
        q_values = self.online_network.predict_on_batch(states)
        q_values[[self.idx, actions]] = targets
        # train model
        loss = self.online_network.train_on_batch(x=states, y=q_values)
        self.losses.append(loss)
        if self.total_steps % self.tau == 0:
            self.update_target()
    def update_target(self):
        self.target_network.set_weights(self.online_network.get_weights()) 

笔记本包含ε-贪心策略和目标网络权重更新的其他实现细节。

设置 OpenAI 环境

我们将首先实例化并从 LL 环境中提取关键参数:

env = gym.make('LunarLander-v2')
state_dim = env.observation_space.shape[0]  # number of dimensions in state
num_actions = env.action_space.n  # number of actions
max_episode_steps = env.spec.max_episode_steps  # max number of steps per episode
env.seed(42) 

我们还将使用内置的包装器,允许周期性地存储显示代理性能的视频:

from gym import wrappers
env = wrappers.Monitor(env,
                       directory=monitor_path.as_posix(),
                       video_callable=lambda count: count % video_freq == 0,
                      force=True) 

在没有显示器的服务器或 Docker 容器上运行时,您可以使用pyvirtualdisplay

关键的超参数选择

代理的性能对几个超参数非常敏感。我们将从折扣率和学习率开始:

gamma=.99,  # discount factor
learning_rate=1e-4  # learning rate 

我们将每隔 100 个时间步更新目标网络,在回放内存中存储最多 100 万个过去的情节,并从内存中对训练代理进行 1,024 个小批量的抽样:

tau=100  # target network update frequency
replay_capacity=int(1e6)
batch_size = 1024 

ε-贪心策略从纯探索开始,线性衰减至,然后在 250 个情节后以指数衰减:

epsilon_start=1.0
epsilon_end=0.01
epsilon_linear_steps=250
epsilon_exp_decay=0.99 

笔记本包含训练循环,包括经验重播、SGD 和缓慢的目标网络更新。

月球着陆器的学习表现

前述的超参数设置使得代理能够在约 300 个情节内使用 TensorFlow 1 实现解决环境。

图 22.6的左侧面板显示了剧集奖励及其 100 个周期移动平均值。右侧面板显示了探索的衰减和每个剧集的步数。通常有一段约 100 个剧集的拉伸,每个剧集通常需要 1,000 个时间步长,而代理减少探索并在开始相当一致地着陆之前“学会如何飞行”:

图 22.6:DDQN 代理在月球着陆环境中的表现

创建一个简单的交易代理

在本节和以下节中,我们将调整深度 RL 方法来设计一个学习如何交易单个资产的代理。为了训练代理,我们将建立一个简单的环境,其中包含一组有限的操作,具有连续观测的相对较低维度状态和其他参数。

更具体地说,环境使用随机开始日期对单个标的的股票价格时间序列进行抽样,以模拟一个交易期,默认情况下包含 252 天或 1 年。每个状态观察为代理人提供了不同滞后期的历史回报以及一些技术指标,如相对强度指数RSI)。

代理可以选择三种操作

  • 买入:将所有资本投资于股票的多头头寸。

  • 平仓:仅持有现金。

  • 卖空:做出等于资本金额的空头头寸。

环境考虑交易成本,默认设置为 10 个基点,并在没有交易的情况下每周期扣除一个基点。代理的奖励包括每日回报减去交易成本。

环境跟踪代理人投资组合(由单只股票组成)的净资产价值NAV),并将其与市场投资组合进行比较,后者无摩擦地交易以提高代理人的门槛。

一个剧集从起始 NAV 为 1 单位现金开始:

  • 如果 NAV 降至 0,剧集以损失结束。

  • 如果 NAV 达到 2.0,代理人就赢了。

此设置限制了复杂性,因为它专注于单只股票,并从头寸大小抽象出来,以避免需要连续操作或更多离散操作,以及更复杂的簿记。但是,它对于演示如何定制环境并允许扩展是有用的。

如何设计自定义 OpenAI 交易环境

要构建一个学习如何交易的代理,我们需要创建一个市场环境,提供价格和其他信息,提供相关的行动,并跟踪投资组合以相应地奖励代理人。有关构建大规模、现实世界模拟环境的努力的描述,请参见 Byrd、Hybinette 和 Balch(2019)。

OpenAI Gym 允许设计、注册和使用符合其体系结构的环境,如文档中所述。文件trading_env.py包含以下代码示例,除非另有说明。

交易环境由三个类组成,这些类相互作用以促进代理的活动。 DataSource 类加载时间序列,生成一些特征,并在每个时间步骤将最新观察结果提供给代理。 TradingSimulator 跟踪位置、交易和成本,以及性能。它还实现并记录了买入持有基准策略的结果。 TradingEnvironment 本身编排整个过程。我们将依次简要描述每个类;有关实现细节,请参阅脚本。

设计 DataSource

首先,我们编写一个 DataSource 类来加载和预处理历史股票数据,以创建用于状态观察和奖励的信息。在本例中,我们将保持非常简单,为代理提供一支股票的历史数据。或者,您可以将许多股票合并成一个时间序列,例如,以训练代理交易标准普尔 500 成分股。

我们将加载从早期到 2018 年的 Quandl 数据集中一个股票的调整价格和成交量信息,本例中为 AAPL:

class DataSource:
    """Data source for TradingEnvironment
    Loads & preprocesses daily price & volume data
    Provides data for each new episode.
    """
    def __init__(self, trading_days=252, ticker='AAPL'):
        self.ticker = ticker
        self.trading_days = trading_days
    def load_data(self):
        idx = pd.IndexSlice
        with pd.HDFStore('../data/assets.h5') as store:
            df = (store['quandl/wiki/prices']
                  .loc[idx[:, self.ticker],
                       ['adj_close', 'adj_volume', 'adj_low', 'adj_high']])
        df.columns = ['close', 'volume', 'low', 'high']
        return df 

preprocess_data() 方法创建多个特征并对其进行归一化。最近的日回报起着两个作用:

  • 当前状态的观察元素

  • 上个周期的交易成本净额,以及根据仓位大小而定的奖励

方法采取了以下步骤,其中包括(有关技术指标的详细信息,请参见 附录):

def preprocess_data(self):
"""calculate returns and percentiles, then removes missing values"""
   self.data['returns'] = self.data.close.pct_change()
   self.data['ret_2'] = self.data.close.pct_change(2)
   self.data['ret_5'] = self.data.close.pct_change(5)
   self.data['rsi'] = talib.STOCHRSI(self.data.close)[1]
   self.data['atr'] = talib.ATR(self.data.high, 
                                self.data.low, self.data.close)
   self.data = (self.data.replace((np.inf, -np.inf), np.nan)
                .drop(['high', 'low', 'close'], axis=1)
                .dropna())
   if self.normalize:
       self.data = pd.DataFrame(scale(self.data),
                                columns=self.data.columns,
                                index=self.data.index) 

DataSource 类跟踪每一集的进度,在每个时间步骤为 TradingEnvironment 提供新鲜数据,并在每一集结束时发出信号:

def take_step(self):
    """Returns data for current trading day and done signal"""
    obs = self.data.iloc[self.offset + self.step].values
    self.step += 1
    done = self.step > self.trading_days
    return obs, done 

TradingSimulator

交易模拟器计算代理的奖励并跟踪代理和“市场”的净资产价值,后者执行具有再投资的买入持有策略。它还跟踪仓位和市场回报,计算交易成本并记录结果。

该类最重要的方法是 take_step 方法,根据当前位置、最新的股票回报和交易成本计算代理的奖励(略有简化;有关完整细节,请参阅脚本):

def take_step(self, action, market_return):
    """ Calculates NAVs, trading costs and reward
        based on an action and latest market return
        returns the reward and an activity summary"""
    start_position = self.positions[max(0, self.step - 1)]
    start_nav = self.navs[max(0, self.step - 1)]
    start_market_nav = self.market_navs[max(0, self.step - 1)]
    self.market_returns[self.step] = market_return
    self.actions[self.step] = action
    end_position = action - 1 # short, neutral, long
    n_trades = end_position – start_position
    self.positions[self.step] = end_position
    self.trades[self.step] = n_trades
    time_cost = 0 if n_trades else self.time_cost_bps
    self.costs[self.step] = abs(n_trades) * self.trading_cost_bps + time_cost
    if self.step > 0:
        reward = start_position * market_return - self.costs[self.step-1]
        self.strategy_returns[self.step] = reward
        self.navs[self.step] = start_nav * (1 + 
                                            self.strategy_returns[self.step])
        self.market_navs[self.step] = start_market_nav * (1 + 
                                            self.market_returns[self.step])
    self.step += 1
    return reward 

TradingEnvironment

TradingEnvironment 类是 gym.Env 的子类,驱动环境动态。它实例化 DataSourceTradingSimulator 对象,并设置动作和状态空间的维度,后者取决于 DataSource 定义的特征范围:

class TradingEnvironment(gym.Env):
    """A simple trading environment for reinforcement learning.
    Provides daily observations for a stock price series
    An episode is defined as a sequence of 252 trading days with random start
    Each day is a 'step' that allows the agent to choose one of three actions.
    """
    def __init__(self, trading_days=252, trading_cost_bps=1e-3,
                 time_cost_bps=1e-4, ticker='AAPL'):
        self.data_source = DataSource(trading_days=self.trading_days,
                                      ticker=ticker)
        self.simulator = TradingSimulator(
                steps=self.trading_days,
                trading_cost_bps=self.trading_cost_bps,
                time_cost_bps=self.time_cost_bps)
        self.action_space = spaces.Discrete(3)
        self.observation_space = spaces.Box(self.data_source.min_values,
                                            self.data_source.max_values) 

TradingEnvironment 的两个关键方法是 .reset().step()。前者初始化 DataSourceTradingSimulator 实例,如下所示:

def reset(self):
    """Resets DataSource and TradingSimulator; returns first observation"""
    self.data_source.reset()
    self.simulator.reset()
    return self.data_source.take_step()[0] 

每个时间步骤依赖于 DataSourceTradingSimulator 提供状态观察并奖励最近的动作:

def step(self, action):
    """Returns state observation, reward, done and info"""
    assert self.action_space.contains(action), 
      '{} {} invalid'.format(action, type(action))
    observation, done = self.data_source.take_step()
    reward, info = self.simulator.take_step(action=action,
                                            market_return=observation[0])
    return observation, reward, done, info 

注册和参数化自定义环境

在使用自定义环境之前,就像对待月球着陆器环境一样,我们需要将其注册到 gym 包中,提供关于 entry_point 的信息,即模块和类,并定义每个剧集的最大步数(以下步骤发生在 q_learning_for_trading 笔记本中):

from gym.envs.registration import register
register(
        id='trading-v0',
        entry_point='trading_env:TradingEnvironment',
        max_episode_steps=252) 

我们可以使用所需的交易成本和股票代码实例化环境:

trading_environment = gym.make('trading-v0')
trading_environment.env.trading_cost_bps = 1e-3
trading_environment.env.time_cost_bps = 1e-4
trading_environment.env.ticker = 'AAPL'
trading_environment.seed(42) 

股票市场上的深度 Q 学习

笔记本 q_learning_for_trading 包含 DDQN 代理训练代码;我们只会突出显示与先前示例有显著不同的地方。

调整和训练 DDQN 代理

我们将使用相同的 DDQN 代理,但简化 NN 架构为每层 64 个单元的两层,并添加了用于正则化的 dropout。在线网络有 5059 个可训练参数:

Layer (type)                 Output Shape              Param #   
Dense_1 (Dense)              (None, 64)                704       
Dense_2 (Dense)              (None, 64)                4160      
dropout (Dropout)            (None, 64)                0         
Output (Dense)               (None, 3)                 195       
Total params: 5,059
Trainable params: 5,059 

训练循环与自定义环境的交互方式与月球着陆器案例非常相似。当剧集处于活动状态时,代理根据其当前策略采取行动,并在记忆当前转换后使用经验回放来训练在线网络。以下代码突出显示了关键步骤:

for episode in range(1, max_episodes + 1):
    this_state = trading_environment.reset()
    for episode_step in range(max_episode_steps):
        action = ddqn.epsilon_greedy_policy(this_state.reshape(-1, 
                                                               state_dim))
        next_state, reward, done, _ = trading_environment.step(action)

        ddqn.memorize_transition(this_state, action,
                                 reward, next_state,
                                 0.0 if done else 1.0)
        ddqn.experience_replay()
        if done:
            break
        this_state = next_state
trading_environment.close() 

我们让探索持续进行 2000 个 1 年的交易周期,相当于约 500,000 个时间步;我们在 500 个周期内使用 ε 的线性衰减从 1.0 到 0.1,之后以指数衰减因子 0.995 进行指数衰减。

基准 DDQN 代理的表现

为了比较 DDQN 代理的表现,我们不仅追踪买入持有策略,还生成一个随机代理的表现。

图 22.7 显示了 2000 个训练周期(左侧面板)中最近 100 个剧集的三个累积回报值的滚动平均值,以及代理超过买入持有期的最近 100 个剧集的份额(右侧面板)。它使用了 AAPL 股票数据,其中包含约 9000 个每日价格和交易量观测值:

图 22.7:交易代理的表现相对于市场

这显示了代理在 500 个剧集后的表现稳步提高,从随机代理的水平开始,并在实验结束时开始超过买入持有策略超过一半的时间。

学到的经验

这个相对简单的代理程序除了最新的市场数据和奖励信号外,没有使用其他信息,与我们在本书其他部分介绍的机器学习模型相比。尽管如此,它学会了盈利,并且在训练了 2000 年的数据之后,它的表现与市场相似(在 GPU 上只需花费一小部分时间)。

请记住,只使用一支股票也会增加过拟合数据的风险——相当多。您可以使用保存的模型在新数据上测试您训练过的代理(请参阅月球着陆器的笔记本)。

总之,我们演示了建立 RL 交易环境的机制,并尝试了一个使用少量技术指标的基本代理。你应该尝试扩展环境和代理,例如从多个资产中选择、确定头寸大小和管理风险。

强化学习通常被认为是算法交易中最有前途的方法,因为它最准确地模拟了投资者所面临的任务。然而,我们大大简化的示例说明了创建一个真实环境的巨大挑战。此外,已在其他领域取得了令人印象深刻突破的深度强化学习可能会面临更大的障碍,因为金融数据的噪声性质使得基于延迟奖励学习价值函数更加困难。

尽管如此,对这一主题的巨大兴趣使得机构投资者很可能正在进行规模更大的实验,这些实验可能会产生实质性的结果。这本书范围之外的一个有趣的补充方法是逆强化学习,它旨在确定一个代理的奖励函数(例如,一个人类交易者)给出其观察到的行为;参见 Arora 和 Doshi(2019)进行调查以及 Roa-Vicens 等人(2019)在限价订单簿环境中应用的应用。

摘要

在本章中,我们介绍了一类不同的机器学习问题,重点是通过与环境交互的代理自动化决策。我们介绍了定义 RL 问题和各种解决方法所需的关键特征。

我们看到了如何将 RL 问题框定为有限马尔可夫决策问题,并且如何使用价值和策略迭代来计算解决方案。然后我们转向更现实的情况,其中转移概率和奖励对代理来说是未知的,并且看到了 Q-learning 如何基于马尔可夫决策问题中由贝尔曼最优方程定义的关键递归关系。我们看到了如何使用 Python 来解决简单 MDP 和更复杂环境的 RL 问题,并使用 Q-learning。

然后我们扩大了范围到连续状态,并将深度 Q-learning 算法应用于更复杂的 Lunar Lander 环境。最后,我们使用 OpenAI Gym 平台设计了一个简单的交易环境,并演示了如何训练代理学习在交易单个股票时如何盈利。

在下一章中,我们将从本书的旅程中得出一些结论和关键收获,并提出一些步骤供您考虑,以便在继续发展您的机器学习用于交易的技能时使用。

第二十三章:结论和下一步

我们这本书的目标是让你能够将机器学习ML)应用于各种数据源,并提取信号,为交易策略增加价值。为此,我们对投资过程采取了更全面的视角,从构思到策略评估,并引入了机器学习作为该过程的一个重要元素,以ML4T 工作流的形式。

在展示了从基础到高级的广泛范围的机器学习算法的使用时,我们看到了机器学习如何在设计、测试和执行策略的过程中的多个步骤中添加价值。然而,在很大程度上,我们着重于核心机器学习价值主张,即从比人类专家能够系统地从更大量的数据中提取可操作信息的能力。

这个价值主张随着数字数据的爆炸而真正获得了流行,数字数据使得利用计算能力从越来越多样化的信息集中提取价值变得更有前景和必要。然而,机器学习的应用仍然需要大量的人为干预和领域专业知识来定义目标、选择和筛选数据、设计和优化模型,并适当地利用结果。

用于交易机器学习的领域特定方面包括金融数据的性质和金融市场的环境。当信噪比通常很低时,使用具有高学习能力的强大模型需要特别小心以避免过度拟合。此外,交易的竞争性意味着模式迅速演变,因为信号衰减,需要额外关注性能监控和模型维护。

在这个总结性章节中,我们将简要总结本书中学到的关键工具、应用和经验教训,以避免在如此多的细节之后失去大局观。然后,我们将确定一些我们没有涉及但对你扩展我们介绍的许多机器学习技术并在日常使用中变得高效是值得关注的领域。

总之,在本章中,我们将:

  • 回顾关键收获和经验教训

  • 指出建立在本书技术基础上的下一步

  • 建议将机器学习纳入你的投资过程中的方法

关键收获和经验教训

本书的一个核心目标是演示使用机器学习从数据中提取信号以指导交易策略的工作流程。图 23.1 概述了这个用于交易的机器学习工作流程。本节总结的关键收获与我们在构建针对金融市场大型数据集的复杂预测模型时遇到的具体挑战相关:

图 23.1:利用机器学习进行交易的关键要素

在进行交易机器学习实践时需要牢记以下重要见解:

  • 数据是唯一最重要的要素,需要仔细的采集和处理。

  • 领域专业知识是实现数据价值和避免使用机器学习中一些陷阱的关键。

  • 机器学习提供了您可以调整和组合以创建解决方案的工具

  • 模型目标和性能诊断的选择对于朝着最佳系统进行有效迭代至关重要。

  • 回测过度拟合是一个需要引起重视的巨大挑战。

  • 黑盒模型的透明度可以帮助建立信心并促进怀疑论者对机器学习的接受。

我们将对这些想法稍作详细阐述。

数据是唯一最重要的要素

机器学习在交易和其他领域的崛起在很大程度上补充了我们详细讨论的数据爆炸。我们在第二章市场和基本数据-来源和技术中阐述了如何访问和处理这些数据来源,历来是量化投资的支柱。在第三章金融替代数据-类别和用例中,我们提出了一个评估替代数据潜在价值的标准框架。

一个关键的洞察是,像深度神经网络这样的最先进的机器学习技术之所以成功,是因为它们的预测性能随着更多数据的使用而持续改进。另一方面,模型和数据复杂性需要匹配以平衡偏差-方差权衡,这在数据的噪声与信号比越高时变得更具挑战性。管理数据质量和整合数据集是实现潜在价值的关键步骤。

新的石油?原始和中间数据的质量控制

就像石油一样,这些天一个常见的比较,数据从其原始形式通过多个阶段的管道流向可以为交易策略提供动力的精炼产品。对最终产品质量的仔细关注对于获得所期望的效益至关重要。

有时,您会获得原始形式的数据并控制所需的众多转换。更常见的情况是,您处理一个中间产品,并应明确此时数据到底测量了什么。

与石油不同,随着数据来源的不断增加,通常没有客观的质量标准。相反,质量取决于其信号内容,而信号内容又取决于您的投资目标。对新数据集的成本效益评估需要一个高效的工作流程,包括我们稍后将讨论的适当基础设施。

数据整合-整体大于部分之和

投资策略的数据价值往往取决于结合市场、基本面和替代数据的互补来源。我们看到,机器学习算法(如基于树的集成或神经网络)的预测能力部分是由于它们能够检测到非线性关系,特别是变量之间的相互作用效应

调节变量影响的能力作为其他模型特征的函数取决于捕获目标结果不同方面的数据输入。资产价格与宏观基本面、社会情绪、信用卡支付和卫星数据的结合可能会产生更可靠的预测,比单独使用每个来源要可靠得多(前提是数据量足够大以学习隐藏的关系)。

多来源数据的处理增加了正确标记的挑战。准确分配能够准确反映历史出版的时间戳至关重要。否则,通过在数据实际可用之前使用算法进行测试,我们引入了前瞻性偏差。例如,第三方数据可能具有需要调整以反映信息实际对于实时算法而言可用的时间戳。

领域专业知识 - 区分信号和噪音

我们强调,信息丰富的数据是成功的机器学习应用的必要条件。然而,领域专业知识同样至关重要,用于定义战略方向、选择相关数据、构建信息丰富的特征以及设计健壮的模型。

在任何领域,从业者都对关键结果的驱动因素和它们之间的关系有着自己的理论。金融领域以大量的可用数量研究为特点,包括理论和实证研究。然而,马科斯·洛佩斯·德·普拉多和其他人(科克兰 2011)批评了大多数实证结果:基于普遍的数据挖掘得出的数百个变量中发现预测信号的声明往往不具有抗干扰性,并且不适应实验设置的变化。换句话说,统计显著性往往是大规模的试错而不是真正的系统关系的结果,这与“如果你折磨数据足够长时间,它就会招供”的说法相一致。

一方面,人们对金融市场的运作方式有着深入的理解。这应该为数据的选择和使用以及依赖机器学习的策略的理论提供支持。一个重要的原因是优先考虑更有可能成功的想法,并避免导致不可靠结果的多重测试陷阱。我们在第四章金融特征工程 - 如何研究 Alpha 因子,以及第五章投资组合优化和绩效评估中概述了关键想法。

另一方面,新颖的机器学习技术可能会发现有关金融结果驱动因素的新假设,这将为理论提供信息,并应进行独立测试。

除了原始数据外,特征工程通常是使信号对算法有用的关键。利用几十年来对推动理论和实证收益的风险因素的研究是优先考虑更可能反映相关信息的数据转换的良好起点。

然而,只有创造性的特征工程才能产生能够随时间在市场上竞争的创新策略。即使对于新的 Alpha 因子,一个能够解释它们如何运作的引人注目的叙述,考虑到市场动态和投资者行为的已建立的观念,也将为资本配置提供更多的信心。

由于假发现和过度拟合的风险使得在测试之前优先考虑策略变得更加必要,而不是“听任数据说话”。我们在第七章线性模型 - 从风险因素到收益预测中介绍了如何调整夏普比率,以考虑实验的数量。

ML 是解决数据问题的工具包

ML 提供了可以应用于许多用例的算法解决方案和技术。本书的第 234部分介绍了 ML 作为一套多样化工具,可以为策略过程的各个步骤增加价值,包括:

  • 想法生成和 Alpha 因子研究

  • 信号聚合和投资组合优化

  • 策略测试

  • 交易执行

  • 策略评估

此外,ML 算法被设计为进一步发展、适应和组合,以解决不同背景下的新问题。因此,除了能够将其应用于数据进行富有成效的实验和研究之外,了解这些算法背后的关键概念和思想也很重要,这在第六章机器学习流程中有所阐述,并在图 23.2中进行了总结:

图 23.2:ML 工作流程

此外,最佳结果通常是通过人在环路解决方案实现的,这些解决方案将人类与 ML 工具结合起来。在第一章从想法到执行的交易机器学习中,我们介绍了量化基本面投资风格,其中酌情性和算法交易相结合。这种方法可能会变得更加重要,并依赖于灵活和创造性地应用我们介绍的基本工具及其对各种数据集的扩展。

模型诊断有助于加快优化速度

第六章机器学习流程中,我们概述了最重要的 ML 特定概念。ML 算法通过对功能形式进行假设来学习输入数据和目标之间的关系。如果学习基于噪声而不是信号,预测性能将受到影响。

当然,我们今天并不知道如何从明天的结果的角度分离信号和噪音。谨慎的交叉验证可以避免前瞻偏差,而健壮的模型诊断,如学习曲线和优化验证测试,则可以帮助缓解这一基本挑战,并校准算法的选择或配置。通过定义专注的模型目标,以及对于复杂模型,区分由于优化算法问题和目标本身而导致的性能缺陷,可以使这项任务变得更加容易。

没有免费午餐

没有系统,无论是计算机程序还是人类,都无法可靠地预测超出其在训练过程中观察到的新示例的结果。唯一的出路是具有一些额外的先验知识或做出超出训练示例的假设。我们在本书的第七章中涵盖了从线性模型到非线性集成模型的广泛算法,线性模型-从风险因素到回报预测,以及在本书第十一章随机森林-一种日本股票的长短策略,以及第十二章提升您的交易策略,以及本书第四部分的各个章节中的神经网络。

我们看到线性模型做出了一个强烈的假设,即输入和输出之间的关系具有非常简单的形式,而非线性模型如梯度提升或神经网络旨在学习更复杂的函数。虽然很明显简单的模型在大多数情况下会失败,但复杂的模型并不总是更好。如果真实关系是线性的,但数据有噪音,那么复杂模型将学习将噪音作为其假定存在的复杂关系的一部分。这就是"没有免费午餐"定理的基本思想,它指出没有一种算法在所有任务中都是普遍优越的。在某些情况下良好的拟合会以其他地方性能不佳为代价。

用于调整算法选择的关键工具是数据探索和基于对模型做出的假设的理解进行的实验。

管理偏差-方差权衡

在调整算法以适应数据时的一个关键挑战是偏差和方差之间的权衡,它们都会使预测误差超出数据的自然噪声。一个简单的模型如果不能充分捕捉数据中的关系,将会出现欠拟合并表现出偏差,即会产生系统性错误的预测。一个过于复杂的模型会过拟合并学习噪音,除了信号之外,还会显示出不同样本的很大方差。

在模型选择和优化过程的任何给定迭代中,诊断这种权衡的关键工具是学习曲线。它显示训练和验证误差如何取决于样本大小。这使我们能够在不同选项之间做出决策以提高性能:调整模型的复杂度或获取更多数据点。

训练误差越接近人类表现或另一个基准,模型过度拟合的可能性就越大。低验证误差告诉我们我们很幸运找到了一个好模型。如果验证错误率高,我们没有。然而,如果随着训练规模的增加而持续下降,则可能需要更多数据。如果训练误差高,更多数据不太可能有所帮助,我们应该添加特征或使用更灵活的算法。

定义目标模型目标

机器学习过程中的第一步之一是定义算法要优化的目标。有时,选择很简单,比如在回归问题中。分类任务可能更困难,例如,当我们关心精确率和召回率时。将冲突的目标整合成一个单一指标,如 F1 分数,有助于集中优化工作。我们还可以包括需要满足的条件而不是优化的条件。我们还看到,强化学习完全取决于定义正确的奖励函数来指导代理的学习过程。

优化验证测试

安德鲁·吴强调了由学习算法或优化算法问题导致的性能不足之间的区别。像神经网络这样的复杂模型假设非线性关系,并且优化算法的搜索过程可能会导致局部最优而不是全局最优。

如果模型未能正确翻译一个短语,例如,测试会比较正确预测的分数和搜索算法发现的解决方案的分数。如果学习算法为正确解决方案打分更高,则需要改进搜索算法。否则,学习算法正在优化错误的目标。

谨防回测过度拟合

我们在整本书中反复讨论了由于过度拟合历史数据而产生的虚假发现的风险。第五章投资组合优化和绩效评估,关于策略评估,概述了主要驱动因素和潜在的解决方案。与网络规模的图像或文本数据相比,低信噪比和相对较小的数据集(与网络规模的图像或文本数据相比)使得这一挑战在交易领域尤为严重。意识到这一点至关重要,因为数据的易获得性和应用机器学习的工具增加了风险。

没有简单的答案,因为风险是不可避免的。然而,我们提出了调整回测指标以考虑重复试验的方法,例如调整后的夏普比率。当努力实现实时交易策略时,分阶段的模拟交易和在市场执行过程中密切监控的绩效需要成为实施过程的一部分。

如何从黑盒模型中获得见解

深度神经网络和复杂集成模型可能会引起怀疑,因为它们被视为不可解释的黑盒模型,特别是在回测过拟合风险的背景下。我们在第十二章《提升您的交易策略》中介绍了几种方法,以了解这些模型是如何进行预测的。

除了传统的特征重要性度量之外,最近的博弈论创新SHapley Additive exPlanationsSHAP)是理解复杂模型机制的重要一步。SHAP 值允许精确地将特征及其值归因于预测,从而更容易在特定理论下验证模型的逻辑,以针对给定的投资目标理解市场行为。除了解释外,准确的特征重要性评分和预测归因还可以深入洞察投资结果的驱动因素。

另一方面,围绕模型预测透明度的重要性存在一些争议。深度学习的发明者之一 Geoffrey Hinton 认为,人类决策的原因经常是模糊的。也许机器应该根据其结果进行评估,就像我们对待投资经理一样。

实践中的交易 ML

随着您将众多工具和技术整合到投资和交易流程中,有许多方面可以成为您努力的焦点。如果您的目标是做出更好的决策,您应选择与您当前技能集相符合但又雄心勃勃的项目。这将帮助您开发一个以高效工具为支撑的工作流程,并获得实践经验。

我们将简要列出一些有助于扩展本书所涵盖的 Python 生态系统的工具。它们包括大数据技术,最终将成为规模实施基于 ML 的交易策略所必需的。我们还将列出一些允许您使用 Python 实施交易策略的平台,可能会提供对数据源、ML 算法和库的访问。最后,我们将指出采用 ML 作为组织的良好实践。

数据管理技术

数据在 ML4T 过程中的核心作用需要熟悉一系列技术,以便在规模化地存储、转换和分析数据,其中包括使用云服务,如亚马逊云服务、微软 Azure 和谷歌云。

数据库系统

数据存储意味着使用数据库。从历史上看,这些通常是使用 SQL 以良好定义的表格式存储和检索数据的关系数据库管理系统RDBMSes)。这些数据库包括来自商业提供商如 Oracle 和 Microsoft 以及开源实现如 PostgreSQL 和 MySQL 的数据库。最近,出现了非关系型的替代方案,通常被统称为 NoSQL,但其种类千差万别,包括:

  • 键值存储:快速读/写访问对象。我们在第二章市场和基本数据 - 来源和技术中介绍了 HDF5 格式,它可以方便快速访问 pandas DataFrame。

  • 列存储:利用列中数据的同质性以促进压缩和更快的基于列的操作,如聚合。这在流行的亚马逊 Redshift 数据仓库解决方案中使用,以及 Apache Parquet、Cassandra 和 Google 的 Big Table 中使用。

  • 文档存储:旨在存储不符合关系数据库所需的严格模式定义的数据。这在使用 JSON 或 XML 格式的 Web 应用程序中很受欢迎,在第四章金融特征工程 - 如何研究 Alpha 因子中我们遇到了它。例如,它在 MongoDB 中使用。

  • 图数据库:设计用于存储具有节点和边缘的网络,并专门用于查询有关网络指标和关系的查询。它在 Neo4J 和 Apache Giraph 中使用。

已经有一些趋同于关系数据库系统建立的惯例。Python 生态系统便于与许多标准数据源进行交互,并提供快速的 HDF5 和 Parquet 格式,正如本书中所示。

从 Hadoop 到 Spark 的大数据技术

数百吉字节及以上规模的数据管理需要使用多台形成集群的机器并行进行读取、写入和计算操作。换句话说,您需要一个在多台机器上以集成方式运行的分布式系统。

Hadoop 生态系统已成为一个开源软件框架,用于使用 Google 开发的 MapReduce 编程模型对大数据进行分布式存储和处理。该生态系统已在 Apache 基金会的支持下多样化发展,今天包括许多涵盖大规模数据管理不同方面的项目。

Hadoop 中的关键工具包括:

  • Apache Pig:一种数据处理语言,由 Yahoo 开发,用于使用 MapReduce 实现大规模提取-转换-加载ETL)管道。

  • Apache Hive:针对 PB 级数据的交互式 SQL 查询的事实标准。它是在 Facebook 开发的。

  • Apache HBASE:一种用于实时读/写访问的 NoSQL 数据库,可线性扩展到数十亿行和数百万列。它可以使用各种不同的模式组合数据源。

Apache Spark 已成为集群上交互式分析最流行的平台。MapReduce 框架允许并行计算,但需要重复从磁盘读取/写入操作以确保数据冗余。由于弹性分布式数据RDD)结构,Spark 已经极大地加速了规模化的计算,这种结构允许高度优化的内存计算。这包括对优化所需的迭代计算,例如,对于许多机器学习算法的梯度下降。幸运的是,Spark DataFrame 接口是以 pandas 为目标设计的,因此你的技能相对顺利地转移。

机器学习工具

我们在本书中涵盖了 Python 生态系统的许多库。Python 已经发展成为数据科学和机器学习的首选语言。开源库的集合不断多样化和成熟,构建在强大的科学计算库 NumPy 和 SciPy 的稳健核心之上。

流行的 pandas 库在推广 Python 用于数据科学方面做出了重大贡献,并且随着 2020 年 1.0 版本的发布而逐渐成熟。scikit-learn 接口已成为现代专业机器学习库(如 XGBoost 或 LightGBM)的标准,这些库通常与工作流自动化工具(如 GridSearchCV 和 Pipeline)进行交互,而我们在整本书中已经多次使用过。

有几个提供简化机器学习工作流程的供应商:

  • H2O.ai 提供了与 ML 自动化集成的 H2O 平台。它允许用户将数千个潜在模型拟合到其数据中,以探索数据中的模式。它在 Python、R 和 Java 中都有接口。

  • Datarobot 旨在通过提供一个平台来快速构建和部署云端或本地的预测模型,自动化模型开发过程。

  • Dataiku 是一个协作数据科学平台,旨在帮助分析师和工程师探索、原型设计、构建和交付自己的数据产品。

还有几个由公司领导的开源倡议,这些公司基于并扩展了 Python 生态系统:

  • 量化对冲基金 TwoSigma 通过 BeakerX 项目为 Jupyter Notebook 环境贡献了量化分析工具。

  • Bloomberg 已将 Jupyter Notebook 集成到其终端中,以便于对其金融数据进行交互式分析。

在线交易平台

开发使用机器学习的交易策略的主要选项是在线平台,这些平台通常寻找并分配资金给成功的交易策略。流行的解决方案包括 Quantopian、Quantconnect 和 QuantRocket。较新的 Alpha Trading Labs 则专注于高频交易。此外,Interactive Brokers(IB) 提供了一个 Python API,你可以使用它来开发自己的交易解决方案。

Quantopian

我们介绍了 Quantopian 平台,并演示了如何使用其研究和交易环境来分析和测试交易策略与历史数据相比。Quantopian 使用 Python,并提供大量教育资料。

Quantopian 主办比赛,招募算法以构建其众包对冲基金投资组合。它为获胜算法提供资金支持。实时交易在 2017 年 9 月停止,但该平台仍提供大量历史数据,并吸引了活跃的开发者和交易者社区。这是一个讨论想法和从他人学习的良好起点。

QuantConnect

QuantConnect 是另一个开源的、社区驱动的算法交易平台,与 Quantopian 竞争。它还提供一个 IDE,用于使用 Python 和其他语言对算法策略进行回测和实时交易。

QuantConnect 还拥有来自世界各地的充满活力的全球社区,并提供对包括股票、期货、外汇和加密货币在内的众多资产类别的访问。它提供与各种经纪商的实时交易集成,例如 IB、OANDA 和 GDAX。

QuantRocket

QuantRocket 是一个基于 Python 的平台,用于研究、回测和运行自动量化交易策略。它提供数据收集工具、多个数据供应商、研究环境、多个回测引擎,以及通过 IB 进行实时和模拟交易。QuantRocket 以其支持国际股票交易而自豪,并以其灵活性脱颖而出(但 Quantopian 也在朝这个方向努力)。

QuantRocket 支持多种引擎——它自己的 Moonshot,以及用户选择的第三方引擎。虽然 QuantRocket 没有传统的集成开发环境(IDE),但与 Jupyter 的集成良好,可以产生类似的效果。QuantRocket 提供免费版本,可访问示例数据,但在 2020 年初写作时,访问更广泛的功能集的费用从每月 $29 起。

结论

我们首先强调了数字数据的爆炸增长和 ML 作为投资和交易策略的战略能力的出现。这种动态反映了金融之外的全球业务和技术趋势,并且更有可能持续而不是停滞或逆转。许多投资公司刚刚开始利用人工智能工具的范围,就像个人正在获得相关技能,业务流程正在适应这些新的创造价值机会一样,正如介绍章节所概述的那样。

未来还有许多令人兴奋的 ML 应用于交易的发展即将到来,可能会推动目前的动能。它们可能在未来几年变得相关,并包括 ML 过程的自动化、生成合成训练数据以及量子计算的出现。该领域的非凡活力意味着这本身就足以填满一本书,而旅程将继续保持令人兴奋的状态。

第二十四章:Alpha 因子库

在本书中,我们已经描述了如何从市场、基本和替代数据中提取特征,以构建 机器学习 (ML) 模型,为交易策略提供信号。特征的智能设计,包括适当的预处理和去噪,通常是实现有效策略的关键。本附录综合了关于特征工程的一些经验教训,并提供了有关这一重要主题的额外信息。

第四章金融特征工程 - 如何研究 Alpha 因子,总结了学者和从业者长期以来努力识别有助于可靠预测资产回报的信息或变量。这项研究从单因素资本资产定价模型发展到了一个“新因子动物园”(Cochrane,2011)。因子动物园包含了自 1970 年以来 anomalies 文献中呈现的数百种公司特征和证券价格指标,被认为是股票回报的统计显著预测因子(参见 Green,Hand 和 Zhang,2017)的摘要。

第四章金融特征工程 - 如何研究 Alpha 因子,按照它们所代表的基础风险将因子分类,以及投资者将在市场回报之上获得的奖励。这些类别包括价值与增长、质量和情绪,以及波动性、动量和流动性。在本书中,我们使用了众多指标来捕捉这些风险因素。本附录扩展了这些例子,并收集了流行指标,以便您将其用作策略开发的参考或灵感。它还向您展示如何计算这些指标,并包括一些评估这些指标的步骤。

为此,我们将重点放在 TA-Lib 实施的广泛指标范围上(参见 第四章金融特征工程 - 如何研究 Alpha 因子)以及 101 Formulaic Alphas 论文(Kakushadze,2016)上,该论文呈现了实际量化交易因子的使用情况,平均持有期为 0.6-6.4 天。为了方便复制,我们将限制此次审查的指标,使其依赖于易于获取的市场数据。尽管存在这一限制,但潜在有用数据源和特征的广泛且迅速发展的范围意味着此概述远非全面。

在本章中,我们将使用 P[t] 表示资产在时间 t 的收盘价,V[t] 表示交易量。必要时,上标,如 ,用于区分开盘、最高、最低或收盘价。r[t] 表示时间 t 的简单回报率。

分别指的是从 t-dt 的价格和回报的时间序列。

TA-Lib 中实施的常见 alpha 因子

TA-Lib 库被广泛用于由交易软件开发人员执行金融市场数据的技术分析。它包括来自多个类别的 150 多种常用指标,范围从重叠研究,包括移动平均线和布林带,到统计函数,如线性回归。以下表格总结了主要类别:

函数组 # 指标
重叠研究 17
动量指标 30
成交量指标 3
波动性指标 3
价格变换 4
周期指标 5
数学运算符 11
数学变换 15
统计函数 9

还有 60 多种旨在识别交易者常用的蜡烛图形式的函数,这些函数依赖于对图表的视觉检查。鉴于关于它们预测能力的混合证据(霍顿 2009 年;马歇尔、扬和罗斯 2006 年),以及使用本书中涵盖的 ML 算法从数据中学习此类模式的目标,我们将重点放在前面表中列出的类别上。具体来说,在本节中,我们将重点放在移动平均线、重叠研究、动量、成交量和流动性、波动性以及基本风险因素上。

查看笔记本common_alpha_factors以获取本节中的代码示例和有关 TA-Lib 指标的其他实现细节。我们将演示如何计算个别股票以及 2007 年至 2016 年期间交易量最大的 500 支美国股票样本的选定指标(请参阅笔记本sample_selection以准备这个较大数据集)。

一个关键的基本模块 - 移动平均线

许多指标允许使用不同类型的移动平均MAs)进行计算。它们在平滑系列和对新发展的反应之间进行不同的权衡。您可以将它们用作自己指标的构建块,或者通过修改其构建中使用的 MA 类型来修改现有指标的行为,正如我们将在下一节中演示的那样。以下表格列出了可用的 MA 类型,用于计算它们的 TA-Lib 函数以及您可以传递给其他指标以选择给定类型的代码:

移动平均 函数 代码
简单 SMA 0
指数 EMA 1
加权 WMA 2
双指数 DEMA 3
三重指数 TEMA 4
三角形 TRIMA 5
考夫曼自适应 KAMA 6
MESA 自适应 MAMA 7

在本节的其余部分,我们将简要概述它们的定义并可视化它们的不同行为。

简单移动平均

对于长度为N的窗口的价格系列P[t],在时间t简单移动平均SMA)对窗口内的每个数据点进行平均:

指数移动平均

对于长度为 N 的价格序列 P[t],在时间 t指数移动平均EMA),记为 EMA[t],被递归地定义为当前价格和最近的前一个 EMA[t][-1] 的加权平均,其中权重为 ,定义如下:

加权移动平均

对于长度为 N 的价格序列 P[t],在时间 t加权移动平均WMA)被计算为使每个数据点的权重对应于其在窗口内的索引:

双重指数移动平均

对于时间 t 的价格序列 P[t] 的双重指数移动平均DEMA),记为 DEMA[t],基于 EMA 设计得更快地对价格变化做出反应。它计算为当前 EMA 的两倍差异与应用于当前 EMA 的 EMA 之间的差异,标记为

由于计算使用了 EMA[2],DEMA 需要 个样本才能开始生成值。

三重指数移动平均

对于时间 t 的价格序列 P[t] 的三重指数移动平均TEMA),记为 TEMA[t],也基于 EMA,但设计得更快地对价格变化做出反应,并指示短期价格走势。它计算为当前 EMA 与应用于当前 EMA 的三倍差异之间的三倍差异,标记为 EMA[2] 的差异,加上应用于 EMA[2] 的 EMA,标记为 EMA[3]:

由于计算使用了 EMA[3],DEMA 需要 个样本才能开始生成值。

三角形移动平均

对于时间 t 的价格序列 P[t] 的窗口长度为 N三角形移动平均TRIMA),记为 TRIMA(N)[t],是最后 N 个 SMA(N)[t] 值的加权平均。换句话说,它将 SMA 应用于 SMA 值的时间序列:

考夫曼自适应移动平均

考夫曼自适应移动平均KAMA)的计算旨在考虑市场波动性的变化。有关这种稍微复杂的计算的详细说明,请参见笔记本中的资源链接。

MESA 自适应移动平均

MESA 自适应移动平均MAMA)是一种根据相位变化速率(由希尔伯特变换鉴别器测量,参见 TA-Lib 文档)调整到价格变动的指数移动平均。除了价格序列外,MAMA 还接受两个额外参数,fastlimitslowlimit,用于控制在计算 MAMA 时应用于 EMA 的最大和最小 alpha 值。

移动平均的可视比较

图 A.1 说明了不同移动平均线在平滑时间序列和适应最近变化方面的行为差异。所有时间序列均计算为 21 天的移动窗口(有关详细信息和彩色图片,请参阅笔记本):

图 A.1:AAPL 收盘价的移动平均线比较

重叠研究 – 价格和波动趋势

TA-Lib 包括几个旨在捕捉近期趋势的指标,如下表所示:

函数 名称
BBANDS 布林带
HT_TRENDLINE 希尔伯特变换 – 瞬时趋势线
MAVP 可变周期移动平均线
MA 移动平均线
SAR 抛物线 SAR
SAREXT 扩展抛物线 SAR

MAMAVP 函数是前文描述的各种移动平均线的包装器。我们将在本节中重点介绍一些示例;更多信息和可视化内容请参阅笔记本。

布林带

布林带将一个移动平均线与代表移动标准差的上下带相结合。我们可以通过提供输入价格系列、移动窗口的长度、上下带的乘数和移动平均线的类型来获得三个时间序列,如下所示:

s = talib.BBANDS(df.close,     # No. of periods (2 to 100000)
                 timeperiod=20,
                 nbdevup=2,    # Deviation multiplier for lower band
                 nbdevdn=2,    # Deviation multiplier for upper band
                 matype=1)     # default: SMA 

对于 2012 年的 AAPL 收盘价格样本,我们可以如下绘制结果:

bb_bands = ['upper', 'middle', 'lower']
df = price_sample.loc['2012', ['close']]
df = df.assign(**dict(zip(bb_bands, s)))
ax = df.loc[:, ['close'] + bb_bands].plot(figsize=(16, 5), lw=1); 

上述代码的结果如下图所示:

图 A.2:AAPL 2012 年收盘价的布林带

发明该概念的约翰·波林格还根据三条线与当前价格之间的关系定义了 20 多个交易规则(参见第四章金融特征工程 – 如何研究阿尔法因子)。例如,外部带之间的距离较小意味着近期价格波动减少,这反过来被解释为未来更大的波动和价格变动。

我们可以通过形成上下带之间以及它们与收盘价之间的比率来标准化布林带的特定于安全性的值,如下所示:

fig, ax = plt.subplots(figsize=(16,5))
df.upper.div(df.close).plot(ax=ax, label='bb_up')
df.lower.div(df.close).plot(ax=ax, label='bb_low')
df.upper.div(df.lower).plot(ax=ax, label='bb_squeeze')
plt.legend()
fig.tight_layout(); 

下图显示了结果归一化的时间序列:

图 A.3:归一化的布林带指标

下面的函数可与 pandas 的 .groupby().apply() 方法一起使用,以计算 500 只股票的指标,如下所示:

def compute_bb_indicators(close, timeperiod=20, matype=0):
    high, mid, low = talib.BBANDS(close, 
                                  timeperiod=20,
                                  matype=matype)
    bb_up = high / close -1
    bb_low = low / close -1
    squeeze = (high - low) / close
    return pd.DataFrame({'BB_UP': bb_up, 
                         'BB_LOW': bb_low, 
                         'BB_SQUEEZE': squeeze}, 
                        index=close.index)
data = (data.join(data
                  .groupby(level='ticker')
                  .close
                  .apply(compute_bb_indicators))) 

图 A.4 绘制了跨越 500 只股票的每个指标值的分布(在第 1 和第 99 百分位处截断,因此图中出现了尖峰):

图 A.4:归一化的布林带指标分布

抛物线 SAR

抛物线 SAR旨在识别趋势反转。它是一种趋势跟踪(滞后)指标,可用于设置跟踪止损或确定入场或退出点。通常,在价格图表中表示为靠近价格条的一组点。一般来说,当这些点位于价格上方时,表示向下趋势;当点位于价格下方时,表示向上趋势。点的方向变化可解释为交易信号。但是,在平盘或区间市场中,该指标的可靠性较低。其计算如下:

极值点EP)是在每个趋势期间保留的记录,代表当前上涨趋势中达到的最高值或下跌趋势中的最低值。在每个周期内,如果观察到新的最大值(或最小值),则 EP 将使用该值进行更新。

α值代表加速因子,通常最初设置为 0.02。每当记录新的 EP 时,此因子增加α。然后,速率将加快,直至 SAR 收敛于价格。为了防止它变得太大,通常将加速因子的最大值设置为 0.20。

我们可以按以下方式计算和绘制样本收盘价序列:

df = price_sample.loc['2012', ['close', 'high', 'low']]
df['SAR'] = talib.SAR(df.high, df.low, 
                      acceleration=0.02, # common value
                      maximum=0.2)       
df[['close', 'SAR']].plot(figsize=(16, 4), style=['-', '--']); 

上述代码生成以下图表:

图 A.5:AAPL 股价的抛物线 SAR

动量指标

第四章金融特征工程 - 如何研究阿尔法因子,介绍了动量作为历史上表现最佳的风险因子之一,并列举了几个旨在识别相应价格趋势的指标。这些指标包括相对强弱指数RSI),以及价格动量价格加速度

因子 描述 计算
相对强弱指数(RSI) RSI 比较了股票近期价格变动的幅度,以识别股票是否处于超买或超卖状态。较高的 RSI(通常超过 70)表示超买,较低的 RSI(通常低于 30)表示超卖。首先,它分别计算过去给定数量(通常为 14)的交易日内上涨()和下跌价格()的平均价格变动。
价格动量 该因子计算给定数量的前期交易日 d 的总回报。在学术文献中,通常使用最近 12 个月,除了最近一个月之外,因为经常观察到短期逆转效应。但是,也广泛使用较短的期间。
价格加速度 价格加速度使用线性回归系数 计算价格趋势的梯度,时间跨度包括较长和较短的期间,例如,1 年和 3 个月的交易日,比较斜率变化作为价格加速度的一种度量。

TA-Lib 实现了 30 个动量指标;其中最重要的指标列在下表中。我们将介绍一些选定的示例;更多信息请参见笔记本 common_alpha_factors

函数 名称
PLUS_DM/MINUS_DM 正/负方向运动
PLUS_DI/MINUS_DI 正/负方向指标
DX 方向运动指数
ADX 平均趋向指数
ADXR 平均趋向指数评级
APO/PPO 绝对/百分比价格振荡器
AROON/AROONOSC 阿隆/阿隆振荡器
BOP 力量平衡指数
CCI 商品通道指数
CMO Chande 动量振荡器
MACD 移动平均线收敛/发散
MFI 资金流指标
MOM 动量
RSI 相对强弱指数
STOCH 随机指标
ULTOSC 终极振荡器
WILLR 威廉指数

其中一些指标密切相关且相互构建,如下例所示。

平均趋向运动指标

平均趋向指数ADX)结合了另外两个指标,即正向和负向方向指标(PLUS_DIMINUS_DI),它们依次基于正向和负向方向运动(PLUS_DMMINUS_DM)。有关更多细节,请参阅笔记本。

正/负方向运动

对于价格序列 P[t],其中每日最高价为 ,每日最低价为 ,方向性运动跟踪时间段 T 内价格变动的绝对大小,计算方法如下:

我们可以计算并绘制 AAPL 股票 2012-13 年的 2 年价格序列的此指标:

df = price_sample.loc['2012': '2013', ['high', 'low', 'close']]
df['PLUS_DM'] = talib.PLUS_DM(df.high, df.low, timeperiod=10)
df['MINUS_DM'] = talib.MINUS_DM(df.high, df.low, timeperiod=10) 

以下图形可视化了结果时间序列:

图 A.6:AAPL 股票价格的 PLUS_DM/MINUS_DM

正/负方向指数

PLUS_DIMINUS_DI 分别是 PLUS_DMMINUS_DM 的简单 MA,每个 MA 都除以平均真实范围ATR)。有关更多细节,请参阅本章后面的波动性指标部分。

简单 MA 是在给定的周期内计算的。ATR 是真实范围的平滑平均。

平均趋向指数

最后,平均趋向指数ADX)是绝对值的简单 MA,该值等于 PLUS_DIMINUS_DI 之间的差异除以它们的和:

它的值在 0-100 范围内振荡,通常如下解释:

ADX 值 趋势强度
0-25 不存在或弱趋势
25-50 强趋势
50-75 非常强趋势
75-100 极强趋势

我们按照之前的示例,为我们的 AAPL 样本系列计算 ADX 时间序列,如下所示:

df['ADX'] = talib.ADX(df.high, 
                      df.low, 
                      df.close, 
                      timeperiod=14) 

下图可视化了 2007-2016 年间的结果:

图 A.7:AAPL 股价系列的 ADX

阿隆振荡器

阿隆指标测量了一段时间内的高点之间的时间和低点之间的时间。它计算了 AROON_UPAROON_DWN 指标,如下所示:

阿隆振荡器只是 AROON_UPAROON_DOWN 指标之间的差异,它在 -100 到 100 的范围内移动,如下图所示为 AAPL 价格系列:

图 A.8:AAPL 股价系列的阿隆振荡器

买卖力道

买卖力道BOP)旨在通过评估每一方对价格的影响来衡量市场上买方相对卖方的力量。它的计算方法是收盘价与开盘价之差除以最高价与最低价之差:

商品通道指数

商品通道指数CCI)测量当前 典型 价格与历史平均价格之间的差异。典型价格由当前的最低、最高和收盘价的平均值计算而得。正(负)CCI 表示价格高于(低于)历史平均值。计算方法如下:

移动平均收敛差离

移动平均收敛差离MACD)是一种非常流行的趋势跟踪(滞后)动量指标,显示了证券价格的两个 MAs 之间的关系。它的计算方法是从 26 日 EMA 中减去 12 日 EMA。

TA-Lib 实现返回 MACD 值及其信号线,即 MACD 的 9 日 EMA。此外,MACD-Histogram 衡量了指标与其信号线之间的距离。以下图表显示了结果:

图 A.9:AAPL 股价系列的三个 MACD 系列

随机相对强度指数

随机相对强度指数StochRSI)基于本节开头描述的 RSI,并意图识别交叉点以及超买和超卖条件。它比较当前 RSI 到给定时间段 T 内最低 RSI 的距离,与 RSI 在此期间所假设的最大值范围。计算方法如下:

TA-Lib 实现比 Chande 和 Kroll(1993)的原始未平滑随机 RSI 版本提供了更多的灵活性。要计算原始指标,请保持 timeperiodfastk_period 相等。

返回值fastk是未平滑的 RSI。 fastd_period用于计算平滑的 StochRSI,返回为fastd。如果您不关心 StochRSI 的平滑,请将fastd_period设置为 1,并忽略fasytd输出:

fastk, fastd = talib.STOCHRSI(df.close,
                              timeperiod=14, 
                              fastk_period=14, 
                              fastd_period=3, 
                              fastd_matype=0)
df['fastk'] = fastk
df['fastd'] = fastd 

图 A.10 绘制了收盘价以及平滑和未平滑的随机 RSI:

图 A.10:AAPL 股价的平滑和未平滑的 StochRSI 序列

随机振荡器

随机振荡器是一种动量指标,它将某一安全性的特定收盘价与一段时间内的价格范围进行比较。随机振荡器基于收盘价应该确认趋势的想法。对于随机(STOCH),有四条不同的线:K(Fast)、*D*(Fast)、K(Slow)和*D*(Slow)。 D是信号线,通常画在其对应的K函数上:

是过去 期的极端值。当使用相同周期时, 是等价的。我们得到以下图表中显示的序列,如下所示:

slowk, slowd = talib.STOCH(df.high,
                           df.low,
                           df.close,
                           fastk_period=14,
                           slowk_period=3,
                           slowk_matype=0,
                           slowd_period=3,
                           slowd_matype=0)
df['STOCH'] = slowd / slowk 

图 A.11:AAPL 股价的 STOCH 序列

终极振荡器

终极振荡器(ULTOSC)衡量了当前收盘价与前三个时间框架的最低价之间的平均差异——默认值为 7、14 和 28——以避免对短期价格变动做出过度反应,并结合短期、中期和长期市场趋势。

首先计算买入压力 BP[t],然后将其在三个周期 T[1]、T[2]和 T[3]上归一化到真实范围(TR[t])上求和:

ULTOSC 然后按照以下方式计算为三个周期的加权平均值:

以下图表显示了此结果:

图 A.12:AAPL 股价的 ULTOSC 序列

威廉姆斯百分比 R

威廉姆斯百分比 R,也称为威廉姆斯百分比范围,是一个动量指标,其数值在 0 到-100 之间变动,用于衡量超买和超卖水平,以识别进入和退出点。它类似于随机振荡器,并将当前收盘价与过去T周期(通常为 14 个周期)内的最高价()和最低价()范围进行比较。指标计算如下,并且结果显示在以下图表中:

图 A.13:AAPL 股价的 WILLR 序列

交易量和流动性指标

专注于成交量和流动性的风险因素包括换手率、美元交易量或市值等指标。TA-Lib 实现了三个指标,其中前两个密切相关:

功能 名称
AD 蔡金 A/D 线
ADOSC 蔡金 A/D 振荡器
OBV 能量潮指标

还请参阅第二十章用于条件风险因素和资产定价的自动编码器,在那里我们使用阿米胡德流动性指标来衡量绝对收益和美元交易量之间的滚动平均比率。

蔡金累积/分布线和振荡器

蔡金涨跌线AD)或累积/分布AD)线是一种基于成交量的指标,旨在衡量资产流入和流出的累积资金流量。该指标假设通过收盘价相对于周期内的最高价和最低价的位置来确定买入或卖出压力的程度。当股票收盘在周期范围的上半部分(下半部分)时,存在买入(卖出)压力。其目的是在指标与证券价格背离时发出方向变化的信号。

A/D 线是每个周期资金流量量MFV)的累积总和。计算方法如下:

  1. 资金流指数MFI)计算为收盘价与高低范围的关系

  2. 将 MFI 乘以周期的交易量 V[t] 来得到 MFV

  3. 获取 A/D 线作为 MFV 的累积总和:

蔡金 A/D 振荡器ADOSC)是应用于蔡金 AD 线的 MACD 指标。蔡金振荡器旨在预测 AD 线的变化。

它被计算为 AD 线的 3 天 EMA 与 10 天 EMA 之间的差值。下图显示了 ADOSC 系列:

图 A.14:AAPL 股价的 ADOSC 系列

能量潮指标

能量潮指标OBV)是一个累积动量指标,它将成交量与价格变化联系起来。它假设 OBV 的变化先于价格变化,因为智慧资金可以通过上升的 OBV 流入证券而被看到。当公众跟随时,证券和 OBV 都会上升。

当证券收盘价高于前一次收盘价时,将当前交易量加到上次 OBV[t][-1] 上(当证券收盘价低于前一次收盘价时,从上次 OBV[t][-1] 中减去当前交易量)来计算当前的 OBV[t]:

波动性指标

波动性指标包括股票特定的度量,如资产价格和收益的滚动(归一化)标准偏差。它还包括更广泛的市场指标,如芝加哥期权交易所的波动率指数VIX),该指数基于标普 500 期权的隐含波动率。

TA-Lib 实现了真实波动幅度指标的归一化和平均版本。

真实波动幅度

平均真实波幅ATR)指标显示了市场的波动性。它由 Wilder(1978 年)引入,并被用作许多其他指标的组成部分。它旨在预测趋势变化,其数值越高,趋势变化的可能性越大;指标值越低,当前趋势越弱。

ATR 被计算为真实范围(TRANGE)的周期T的简单移动平均,其将波动性定义为最近交易范围的绝对值:

结果系列如下图所示:

图 A.15:AAPL 股票价格的 ATR 系列

标准化平均真实波幅

TA-Lib 还提供了一种标准化的 ATR,允许跨资产进行比较。标准化平均真实波幅NATR)的计算方法如下:

标准化使 ATR 对于长期分析更为相关,在价格大幅变动和跨市场或跨证券比较时尤为重要。

基本风险因素

风险的常用度量包括资产回报对旨在代表基本因素的投资组合回报的敞口。我们介绍了法玛和弗伦奇(2015 年)的五因子模型,并展示了如何使用两状态法玛-麦克贝斯回归估计因子负载和风险因子溢价,详情见第七章线性模型-从风险因素到回报预测

为了估计证券价格与五因子模型中包括公司规模、价值与成长动态、投资政策和盈利能力等因素之间的关系,除了广义市场外,我们可以使用 Kenneth French 的数据库提供的投资组合回报作为滚动线性回归中的外生变量。

以下示例使用pandas_datareader模块访问数据(详见第二章市场和基本数据-来源和技术)。然后,它计算了 21、63 和 252 个交易日的窗口的回归系数:

factor_data = (web.DataReader('F-F_Research_Data_5_Factors_2x3_daily', 'famafrench', 
                              start=2005)[0].rename(columns={'Mkt-RF': 'MARKET'}))
factor_data.index.names = ['date']
factors = factor_data.columns[:-1]
t = 1
ret = f'ret_{t:02}'
windows = [21, 63, 252]
for window in windows:
    print(window)
    betas = []
    for ticker, df in data.groupby('ticker', group_keys=False):
        model_data = df[[ret]].merge(factor_data, on='date').dropna()
        model_data[ret] -= model_data.RF
        rolling_ols = RollingOLS(endog=model_data[ret], 
                                 exog=sm.add_constant(model_data[factors]),
                                 window=window)
        factor_model = rolling_ols.fit(params_only=True).params.rename(
            columns={'const':'ALPHA'})
        result = factor_model.assign(ticker=ticker).set_index(
            'ticker', append=True).swaplevel()
        betas.append(result)
    betas = pd.concat(betas).rename(columns=lambda x: f'{x}_{window:02}')
    data = data.join(betas) 

刚刚描述的风险因素通常被称为智能贝塔因子(见第一章用于交易的机器学习-从想法到执行)。此外,对冲基金已经开始使用从大规模数据挖掘练习中派生出的 alpha 因子,我们现在将转向这些因子。

WorldQuant 寻找公式阿尔法

我们在第一章用于交易的机器学习-从想法到执行中介绍了 WorldQuant,作为一种众包投资策略的趋势的一部分。WorldQuant 维护着一个虚拟研究中心,全球量化分析师在此竞争,以确定阿尔法。这些阿尔法是以计算表达式形式的交易信号,帮助预测价格变动,就像前一节描述的通用因子一样。

这些公式 alpha将从数据中提取信号的机制转换为代码,并且它们可以单独开发和测试,目标是将它们的信息整合到更广泛的自动化策略中(Tulchinsky 2019)。正如本书中反复强调的那样,在大型数据集中挖掘信号容易出现多重测试偏差和假发现。尽管存在这些重要的警告,这种方法代表了对前一节中介绍的更传统特征的现代替代方案。

Kakushadze(2016)提供了 101 个这种 alpha 的示例,其中 80% 当时被用于真实的交易系统中。它定义了一系列操作横截面或时间序列数据的函数,并且可以组合使用,例如以嵌套形式。

笔记本 101_formulaic_alphas 展示了如何使用 pandas 和 NumPy 实现这些函数,并且还说明了如何计算大约 80 个这些公式 alpha 的示例,其中我们有输入数据(例如,我们缺乏准确的历史部门信息)。

横截面和时间序列函数

Kakushadze(2016)提出的公式 alpha 的构建块是相对简单的表达式,它们计算纵向或横截面数据,并且可以很容易地使用 pandas 和 NumPy 实现。

横截面函数包括排名和缩放,以及对收益进行分组归一化,其中分组意味着在不同粒度级别上表示部门信息:

我们可以将排名函数直接转换为 pandas 表达式,使用 DataFrame 作为参数,格式为 周期数 × 标的证券数,如下所示:

def rank(df):
    """Return the cross-sectional percentile rank
     Args:
         :param df: tickers in columns, sorted dates in rows.
     Returns:
         pd.DataFrame: the ranked values
     """
    return df.rank(axis=1, pct=True) 

还有一些可能会很熟悉的时间序列函数:

函数 定义
ts_{O}(x, d) 对过去 d 天的时间序列应用运算符 O;非整数天数 d 被转换为向下取整的天数 d
ts_lag(x, d) xd 天前的值。
ts_delta(x, d) 今天的 x 的值与 d 天前的值之间的差异。
ts_rank(x, d) 过去 d 天的排名。
ts_mean(x, d) 过去 d 天的简单移动平均值。
ts_weighted_mean(x, d) 过去 d 天的加权移动平均值,线性衰减权重 dd – 1,…,1(重新缩放为总和为 1)。
ts_sum(x, d) 过去 d 天的滚动求和。
ts_product(x, d) 过去 d 天的滚动乘积。
ts_stddev(x, d) 过去 d 天的移动标准差。
ts_max(x, d), ts_min(x, d) 过去 d 天的滚动最大值/最小值。
ts_argmax(x, d), ts_argmin(x, d) ts_max(x, d), ts_min(x, c) 的日期。
ts_correlation(x, y, d) 过去 dxy 的相关性。

这些时间序列函数也很容易使用 pandas 的滚动窗口功能来实现。例如,对于滚动加权平均值,我们可以结合 pandas 和 TA-Lib,就像在前一节中演示的那样:

def ts_weighted_mean(df, period=10):
    """
    Linear weighted moving average implementation.
    :param df: a pandas DataFrame.
    :param period: the LWMA period
    :return: a pandas DataFrame with the LWMA.
    """
    return (df.apply(lambda x: WMA(x, timeperiod=period))) 

为了创建滚动相关函数,我们提供了两个包含不同股票的时间序列的 DataFrame 列:

def ts_corr(x, y, window=10):
    """
    Wrapper function to estimate rolling correlations.
    :param x, y: pandas DataFrames.
    :param window: the rolling window.
    :return: DataFrame with time-series min for past 'window' days.
    """
    return x.rolling(window).corr(y) 

此外,这些表达式使用了常见的运算符,正如我们将在转向每个结合了前述几个函数的公式α时看到的那样。

公式化的α表达式

为了说明α表达式的计算,我们需要使用前一节中从 2007 年到 2016 年的 500 支交易最多的股票的样本创建以下输入表格(有关数据准备的详细信息,请参见笔记本sample_selection)。每个表格包含了个股的时间序列列:

变量 描述
returns 每日收盘至收盘收益
openclosehighlowvolume 每日价格和交易量数据的标准定义
vwap 每日成交量加权平均价格
adv(d) 过去d天的平均每日美元交易额

我们的数据不包括许多α表达式所需的每日成交量加权平均价格。为了能够演示它们的计算,我们使用每日开盘、最高、最低和收盘价格的简单平均值来非常粗略地近似这个值。

与前一节中介绍的常见α相反,公式α没有对其所代表的风险敞口进行经济解释。我们将展示几个简单编号的实例。

Alpha 001

第一个α表达式的公式如下所示:

rank(ts_argmax(power(((returns < 0) ? ts_std(returns, 20) : close), 2.), 5)) 

三元运算符a ? b : ca评估为true时执行b,否则执行c。因此,如果每日收益为正,它会计算 20 天滚动标准差的平方;否则,它会计算当前收盘价的平方。然后,它会按照显示此值最大的日期的指数对资产进行排名。

使用cr分别表示收盘价和收益输入,α可以使用前述函数和 pandas 方法在 Python 中进行转换,如下所示:

def alpha001(c, r):
    """(rank(ts_argmax(power(((returns < 0)
        ? ts_std(returns, 20)
        : close), 2.), 5)) -0.5)"""
    c[r < 0] = ts_std(r, 20)
    return (rank(ts_argmax(power(c, 2), 5)).mul(-.5)
            .stack().swaplevel()) 

对于 500 支股票的 10 年样本,Alpha 001 值的分布及其与一日前向收益的关系如下所示:

图 A.16:Alpha 001 直方图和散点图

信息系数IC)相当低,但在-0.0099 处具有统计显著性,互信息MI)估计值为 0.0129(有关实现细节,请参见第四章金融特征工程 - 如何研究α因子,和笔记本101_formulaic_alphas)。

Alpha 054

我们的第二个表达式是收盘价和最低价之间的差异与收盘价和最高价之间的差异的比率,分别乘以开盘价和收盘价,然后再乘以五次方:

-(low - close) * power(open, 5) / ((low - high) * power(close, 5)) 

同样,转换为 pandas 很简单。我们使用ohl和 c 来表示包含 500 列中每个股票的相应价格系列的数据帧:

def alpha054(o, h, l, c):
    """-(low - close) * power(open, 5) / ((low - high) * power(close, 5))"""
    return (l.sub(c).mul(o.pow(5)).mul(-1)
            .div(l.sub(h).replace(0, -0.0001).mul(c ** 5))
            .stack('ticker')
            .swaplevel()) 

在这种情况下,IC 为 0.025,而 MI 得分为 0.005。

现在我们将看看这些不同类型的 alpha 因子在单变量和多变量视角下的比较情况。

双变量和多变量因素评估

为了评估众多因素,我们依赖于本书介绍的各种性能指标,包括以下内容:

  • 有关某一因素与一日前收益率的信号内容的双变量测量

  • 使用所有因素来预测一日前收益率的梯度提升模型的多变量特征重要性度量

  • 根据 Alphalens 使用因子分位数投资的投资组合的财务表现

我们将首先讨论双变量度量,然后转向多变量度量;我们将通过比较结果来结束。有关相关代码示例和其他探索性分析,请参阅笔记本factor_evaluation,我们将在此处省略因素之间的相关性。

信息系数和互信息

我们将使用以下双变量度量标准,这些度量标准在第四章金融特征工程 - 如何研究 Alpha 因子中进行了介绍:

  • IC 以斯皮尔曼等级相关性衡量

  • 使用由 scikit-learn 提供的mutual_info_regression计算的 MI 分数

MI 分数使用了 100,000 个观察样本来限制最近邻计算的计算成本。除此之外,这两个度量标准都很容易计算,并且已经被反复使用;有关实施细节,请参阅笔记本。然而,我们将看到它们可以产生非常不同的结果。

特征重要性和 SHAP 值

为了衡量给定所有其他可用因素的特征的预测相关性,我们可以使用默认设置的 LightGBM 梯度提升模型来训练,以使用所有(大约)130 个因素来预测前瞻性收益。该模型使用 8.5 年的数据训练 104 棵树,使用提前停止。我们将获得最后一年数据的测试预测,这将产生 3.40 的全局 IC 和 2.01 的日均值。

接下来,我们将计算特征重要性和SHapley 加法解释SHAP)值,如第十二章提升您的交易策略中所述;有关详细信息,请参阅笔记本。 图 A.17中的影响图突出显示了 20 个最重要特征的值如何相对于模型的默认输出正面或负面地影响模型的预测。以 SHAP 值衡量,alphas 054 和 001 位于前五因素之列:

图 A.17:常见和公式化 alpha 的 SHAP 值

现在,让我们比较不同指标如何评价我们的因素。

比较 - 每个指标的前 25 个特征

在 SHAP 值和传统特征重要性之间的排名相关性,后者是特征对模型损失函数减少的加权贡献,为 0.89。SHAP 值与两个单变量指标之间的相关性也很大,约为 0.5。

有趣的是,MI 和 IC 在特征排名上存在显著差异,相关性仅为 0.16,如下图所示:

图 A.18:性能指标的排名相关性

图 A.19显示了根据每个指标的前 25 个特征。除了 MI 分数喜欢 "常见" alpha 因子之外,两个来源的特征都排名很高:

图 A.19:每种性能指标的前 25 个特征

目前尚不清楚为什么 MI 与其他指标不一致,以及为什么它分配高分的特征在梯度提升模型中发挥了重要作用。一个可能的解释是计算仅使用了 10% 的样本,分数似乎对样本大小敏感。

金融业绩-Alphalens

最后,我们主要关心 alpha 因子发出的交易信号的价值。如第四章介绍的,金融特征工程-如何研究 alpha 因子,并反复演示,Alphalens 对因子性能进行单独评估。

笔记本 alphalens_analysis 让您选择一个单独的因子,并计算根据因子分位值投资的投资组合在给定时间段内的表现如何。

图 A.20中的示例显示了 Alpha 54 的结果;尽管头部和底部五分位组合在每日基础上取得了 1.5 bps 的平均收益,但长短组合的累积收益为负值:

图 A.20:Alpha 54 的 Alphalens 性能指标

随意使用笔记本作为模板,更系统地评估样本因子或您自己选择的其他因子。

posted @ 2024-05-09 17:01  绝不原创的飞龙  阅读(147)  评论(0编辑  收藏  举报