Python-金融秘籍第二版-全-
Python 金融秘籍第二版(全)
原文:
zh.annas-archive.org/md5/25334ce178953792df9684c784953114
译者:飞龙
前言
在过去几年中,我们在数据科学领域看到了惊人的增长。几乎每天都会有一些新的进展,例如,发布的研究论文宣布新的或改进的机器学习或深度学习算法,或者为最流行的编程语言之一发布的新库。
过去,许多这些进展并没有进入主流媒体。但这一点也在迅速改变。最近的一些例子包括 AlphaGo 程序击败 18 次围棋世界冠军,利用深度学习生成从未存在过的逼真人类面孔,或者通过 DALL-E 2 或 Stable Diffusion 等模型从文本描述生成美丽的数字艺术。
另一个最近的、惊人的发展例子是 OpenAI 的 ChatGPT。这是一个语言模型,能够让我们进行自然流畅的对话。该模型能够跟踪过去的问题并对其进行跟进,承认错误或拒绝不当请求。更重要的是,它不仅限于自然语言,我们还可以要求它编写各种编程语言的实际代码片段。
除了那些值得关注的成就外,在过去几十年里,人工智能几乎已经被各行各业采用。我们随处可见它的身影,例如我们在 Netflix 上获得的推荐,或者我们收到的关于一个我们近期没有使用的在线商店提供额外折扣的邮件。因此,世界各地的企业都在使用人工智能,通过以下方式获得竞争优势:
做出更好的、基于数据的决策
通过高效的目标定位或精准的推荐来提高利润
通过及早识别面临风险的客户来减少客户流失
自动化重复性任务,AI 能够比人类员工更快速(且可能更精确)地完成这些任务
同样的人工智能革命也在影响着金融行业。根据《福布斯》2020 年一篇文章的报道,“70%的金融服务公司正在使用机器学习来预测现金流事件、微调信用评分并检测欺诈行为”。此外,数据科学的各个方面也被用于算法交易、机器人顾问服务、个性化银行服务、流程自动化等。
本书提供了一本基于食谱的指南,介绍如何使用现代 Python 库解决金融领域中的各种任务。因此,我们尽量减少需要编写的代码量,利用成熟且经过“实战验证”的库,这些库在许多行业中被专业人士广泛使用。虽然本书假设读者具备一定的基础知识,并不会从理论角度解释所有概念,但它提供了相关的参考资料,帮助读者深入了解各个主题。
在本书的前言中,您将了解到本书的内容概述、组织结构以及在实践中如何实现目标的过程中所需的内容。我希望您能享受这段旅程!
本书适合谁阅读
本书面向数据分析师、金融分析师、数据科学家或机器学习工程师,他们希望学习如何在金融环境中实施广泛的任务。本书假设读者对金融市场和交易策略有一定的了解,并且能够熟练使用 Python 及其面向数据科学的流行库(例如pandas
、numpy
和scikit-learn
)。
本书将帮助读者正确地使用金融领域中的高级数据分析方法,避免潜在的陷阱和常见错误,并为他们可能试图解决的问题得出正确的结论。此外,由于数据科学和金融领域在不断变化和扩展,本书还包含了学术论文和其他相关资源的参考,旨在拓宽对所涵盖主题的理解。
本书的内容
第一章,获取金融数据,介绍了几种最受欢迎的高质量金融数据来源,包括 Yahoo Finance、Nasdaq Data Link、Intrinio 和 Alpha Vantage。重点介绍了如何利用专用的 Python 库并处理数据以便进一步分析。
第二章,数据预处理,描述了用于预处理数据的各种技术。它描述了从获取数据到使用数据构建机器学习模型或研究交易策略之间的关键步骤。因此,它涵盖了诸如将价格转换为收益、根据通货膨胀调整价格、填补缺失值或将交易数据聚合成各种类型的柱状图等主题。
第三章,可视化金融时间序列,专注于可视化金融(以及其他)时间序列数据。通过绘制数据,我们可以直观地识别一些模式,如趋势、季节性和拐点,并可以使用统计测试进一步确认这些模式。在这一阶段获得的洞察可以帮助我们在选择建模方法时做出更好的决策。
第四章,探索金融时间序列数据,展示了如何使用各种算法和统计测试自动识别时间序列数据中的潜在问题,例如异常值的存在。此外,它还涉及分析数据中是否存在趋势或其他模式,如均值回归。最后,它探讨了资产收益的风格化事实。这些概念在处理金融数据时至关重要,因为我们希望确保构建的模型/策略能够准确捕捉资产收益的动态。
第五章,技术分析与构建交互式仪表板,通过展示如何计算一些最流行的技术指标并自动识别蜡烛图数据中的模式,解释了 Python 中技术分析的基础知识。它还演示了如何创建基于 Streamlit 的 Web 应用程序,使我们能够以交互方式可视化和检查预定义的技术分析指标。
第六章,时间序列分析与预测,介绍了时间序列建模的基础知识。它首先研究了时间序列的构建块以及如何使用各种分解方法将其分离。然后,讨论了平稳性概念、如何进行平稳性检验以及在原始序列不平稳的情况下如何使其平稳。最后,展示了如何使用两种最广泛应用于时间序列建模的统计方法——指数平滑法和 ARIMA 类模型。
第七章,基于机器学习的时间序列预测方法,首先解释了验证时间序列模型的不同方法。接着,概述了特征工程的方法。它还介绍了一种自动特征提取工具,可以通过几行代码生成数百或数千个特征。此外,解释了简约回归的概念以及如何使用 Meta 的流行算法 Prophet。本章最后介绍了其中一个流行的时间序列预测 AutoML 框架。
第八章,多因子模型,涵盖了各种因子模型的估计,从最简单的单因子模型(CAPM)开始,然后扩展到更高级的三因子、四因子和五因子模型。
第九章,使用 GARCH 类模型建模波动率,重点介绍了波动率和条件异方差性的概念。它展示了如何使用单变量和多变量 GARCH 模型,这些模型是建模和预测波动率的最流行方法之一。
第十章,金融中的蒙特卡洛模拟,解释了如何使用蒙特卡洛方法进行各种任务,例如模拟股票价格、定价没有封闭解的衍生品(美式/另类期权),或估计投资组合的不确定性(例如,通过计算风险价值和预期短缺)。
第十一章,资产配置,首先解释了最基本的资产配置策略,并在此基础上展示了如何评估投资组合的表现。接着,它展示了三种不同的获取有效前沿的方法。最后,它探讨了层次风险平价,这是基于图论和机器学习相结合的一种新型资产配置方法。
第十二章,回测交易策略,介绍了如何使用两种方法(矢量化方法和事件驱动方法),借助流行的 Python 库运行各种交易策略的回测。为此,使用了一些基于流行技术指标或均值-方差投资组合优化构建的策略实例。
第十三章,应用机器学习:识别信用违约,展示了如何处理预测贷款违约这一实际的机器学习任务。它涵盖了机器学习项目的整个范围,从收集和清洗数据到构建和调优分类器。本章的重要收获是理解机器学习项目的通用方法,这些方法可以应用到许多不同的任务中,无论是客户流失预测还是估算新房地产在某个社区的价格。
第十四章,机器学习项目的高级概念,延续了前一章中介绍的工作流程,并展示了机器学习项目 MVP 阶段的可能扩展。它首先介绍了更先进的分类器。接着,讨论了对类别特征进行编码的替代方法,并介绍了几种处理不平衡数据的方法。
此外,它展示了如何创建堆叠集成的机器学习模型,并利用贝叶斯超参数调优来改进传统的网格搜索。它还探讨了各种计算特征重要性的方法,并利用这些信息来选择最具信息量的预测因子。最后,它简要介绍了快速发展的可解释 AI 领域。
第十五章,金融中的深度学习,描述了如何将一些近期的神经网络架构应用于金融领域的两个潜在使用案例——预测信用卡违约(一个分类任务)和时间序列预测。
为了最大化从本书中获得的收益
在本书中,我们尝试为读者提供一个关于金融领域各种技术的高层次概览,同时重点关注这些方法的实际应用。这就是为什么我们特别强调展示如何使用各种流行的 Python 库,来使分析师或数据科学家的工作更加轻松,并减少出错的可能性。
由于学习任何东西的最佳方式是通过实践,我们强烈鼓励读者尝试使用提供的代码示例(代码可以在附带的 GitHub 仓库中找到),将这些技术应用到不同的数据集上,并探索可能的扩展(其中一些在食谱的另见部分中提到)。
为了深入了解理论基础,我们提供了进一步阅读的参考资料。这些参考资料还包括一些更为高级的技术,超出了本书的范围。
下载示例代码文件
本书的代码包托管在 GitHub 上,网址是 github.com/PacktPublishing/Python-for-Finance-Cookbook-2E
。我们还提供了来自我们丰富图书和视频目录的其他代码包,地址是 github.com/PacktPublishing/
。赶紧去看看吧!
下载彩色图像
我们还提供了一份包含本书中截图/图表的彩色图片的 PDF 文件。你可以在此下载:packt.link/JnpTe
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、Python 库的名称、数据库表名称、文件夹名称、文件名、文件扩展名和路径名。例如:“我们还可以使用get_by_id
函数来下载特定的 CPI 系列。”
一段代码块以以下方式显示:
def realized_volatility(x):
return np.sqrt(np.sum(x**2))
任何命令行输入或输出如下所示:
Downloaded 2769 rows of data.
粗体:表示新术语或重要词汇。例如:“成交量柱是解决这一问题的尝试。”
信息框显示如下。
小贴士和技巧以这种方式展示。
此外,在每个 Jupyter Notebook 的最开始(可以在本书的 GitHub 仓库中找到),我们运行几个单元格,导入并设置 matplotlib
的绘图功能。为了简洁起见,后续我们不再提及这一点。因此,任何时候都可以假设已经执行了以下命令。
首先,我们(可选地)使用以下代码增加了生成图形的分辨率:
%config InlineBackend.figure_format = "retina"
然后我们执行第二段代码:
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
from pandas.core.common import SettingWithCopyWarning
warnings.simplefilter(action="ignore", category=FutureWarning)
warnings.simplefilter(action="ignore", category=SettingWithCopyWarning)
# feel free to modify, for example, change the context to "notebook"
sns.set_theme(context="talk", style="whitegrid",
palette="colorblind", color_codes=True,
rc={"figure.figsize": [12, 8]})
在此单元格中,我们导入了 matplotlib
、warnings
和 seaborn
。然后,我们禁用了部分警告并设置了绘图风格。在某些章节中,为了提高图形的可读性(尤其是黑白图),我们可能会调整这些设置。
第一章:获取金融数据
本书的第一章专门讲解任何数据科学/量化金融项目中非常重要(有些人可能会说是最重要)的部分——数据收集。根据著名的谚语“垃圾进,垃圾出”,我们应该努力获取尽可能高质量的数据,并将其正确预处理,以便后续与统计学和机器学习算法一起使用。原因很简单——我们的分析结果高度依赖输入数据,任何复杂的模型都无法弥补这一点。这也是为什么在我们的分析中,我们应该能够利用自己(或他人)对经济/金融领域的理解,来为某些数据提供动机,例如建模股票收益。
在本书第一版的读者中,最常见的报告问题之一就是获取高质量数据。这也是为什么在本章中,我们花了更多时间探索不同的金融数据来源。虽然这些供应商中的许多提供相似的信息(如价格、基本面等),但它们也提供了可以通过其 API 下载的额外独特数据。例如,公司相关的新闻文章或预计算的技术指标。因此,我们将在不同的配方中下载不同类型的数据。不过,一定要检查库/API 的文档,因为其供应商很可能还提供标准数据,如价格等。
额外的示例也包含在 Jupyter notebooks 中,你可以在附带的 GitHub 仓库中找到它们。
本章的数据来源是经过精心挑选的,目的是不仅展示如何使用 Python 库轻松收集高质量数据,还展示了收集到的数据可以有多种形式和大小。
有时我们会得到一个格式良好的 pandas
DataFrame,而其他时候它可能是 JSON 格式,甚至是需要处理后作为 CSV 加载的字节数据。希望这些技巧能充分帮助你准备好处理你可能在网上遇到的任何类型的数据。
在阅读本章时需要记住的一点是,不同来源的数据有所不同。这意味着我们从两个供应商下载的价格很可能会有所不同,因为这些供应商从不同的来源获取数据,并可能使用不同的方法来调整价格以应对企业行为。最佳实践是找到一个你最信任的数据来源(例如,基于网上的评价),然后用它下载你需要的数据。另一个需要记住的点是,当构建算法交易策略时,我们用于建模的数据应该与用于执行交易的实时数据流一致。
本章没有涉及一种重要的数据类型——替代数据。替代数据是指任何可以用来预测资产价格的见解的数据类型。替代数据可以包括卫星图像(例如,追踪航运路线或某个区域的发展)、传感器数据、网站流量数据、客户评论等。虽然有许多专门提供替代数据的供应商(例如 Quandl/Nasdaq Data Link),你也可以通过网页抓取访问公开的可用信息来获得一些替代数据。例如,你可以从 Amazon 或 Yelp 抓取客户评论。然而,这通常是较大的项目,遗憾的是超出了本书的范围。此外,你需要确保抓取特定网站的数据不会违反其条款和条件!
使用本章提到的供应商,你可以免费获取相当多的信息。但大多数供应商也提供付费服务。记得在注册任何服务之前,务必对数据供应商实际提供的数据以及你的需求进行详细调查。
在本章中,我们将介绍以下内容:
从 Yahoo Finance 获取数据
从 Nasdaq Data Link 获取数据
从 Intrinio 获取数据
从 Alpha Vantage 获取数据
从 CoinGecko 获取数据
从 Yahoo Finance 获取数据
Yahoo Finance 是最受欢迎的免费金融数据来源之一。它不仅包含不同频率(每日、每周和每月)的历史和当前股票价格,还包括一些计算指标,如 beta(衡量单一资产相对于整个市场波动性的指标)、基本面数据、收益信息/日历等。
在很长一段时间里,从 Yahoo Finance 下载数据的首选工具是 pandas-datareader
库。该库的目标是从多个来源提取数据,并以 pandas
数据框的形式存储。然而,在 Yahoo Finance API 进行一些更改后,该功能已被弃用。熟悉这个库是很有帮助的,因为它简化了从 FRED(联邦储备经济数据)、Fama/French 数据库或世界银行等来源下载数据的过程。这些数据可能对不同类型的分析很有帮助,接下来的章节中将介绍其中一些内容。
目前,下载历史股票价格的最简单最快方式是使用 yfinance
库(以前叫做 fix_yahoo_finance
)。
就本章的内容而言,我们感兴趣的是下载 2011 到 2021 年期间的苹果公司股票价格。
如何操作……
执行以下步骤从 Yahoo Finance 下载数据:
导入库:
import pandas as pd import yfinance as yf
下载数据:
df = yf.download("AAPL", start="2011-01-01", end="2021-12-31", progress=False)
检查下载的数据:
print(f"Downloaded {len(df)} rows of data.") df
运行代码会生成以下数据框的预览:
图 1.1:展示下载的股票价格数据框的预览
请求的结果是一个pandas
DataFrame(2,769 行),包含日常开盘、最高、最低和收盘(OHLC)价格,以及调整后的收盘价和成交量。
Yahoo Finance 会自动调整股票拆分的收盘价,即当公司将其现有股票分割成多个新股票时,通常是为了提高股票的流动性。调整后的收盘价不仅考虑了拆股,还考虑了股息。
它是如何工作的…
download
函数非常直观。在最基本的情况下,我们只需要提供股票代码(符号),它将尝试下载自 1950 年以来的所有可用数据。
在前面的例子中,我们下载了一个特定范围(2011 到 2021 年)的每日数据。
download
函数的一些附加功能包括:
我们可以通过提供股票代码列表(
["AAPL", "MSFT"]
)或多个股票代码的字符串("AAPL MSFT"
)来一次性下载多个股票的信息。我们可以设置
auto_adjust=True
来仅下载已调整的价格。我们还可以通过设置
actions='inline'
来下载股息和股票拆分信息。这些操作也可以用于手动调整价格或进行其他分析。指定
progress=False
将禁用进度条。interval
参数可以用于下载不同频率的数据。只要请求的时间段小于 60 天,我们也可以下载日内数据。
还有更多...
yfinance
还提供了一种通过Ticker
类下载数据的替代方式。首先,我们需要实例化该类的对象:
aapl_data = yf.Ticker("AAPL")
要下载历史价格数据,我们可以使用history
方法:
aapl_data.history()
默认情况下,该方法会下载最近一个月的数据。我们可以使用与download
函数相同的参数来指定范围和频率。
使用Ticker
类的主要优点是我们可以下载比仅价格更多的信息。一些可用的方法包括:
info
—输出一个包含股票及其公司详细信息的 JSON 对象,例如公司的全名、简短的业务总结、上市的交易所,以及一系列财务指标,如贝塔系数actions
—输出公司行动信息,例如股息和拆股major_holders
—显示主要持股人的名字institutional_holders
—显示机构持有者calendar
—显示即将到来的事件,例如季度财报earnings
/quarterly_earnings
—显示过去几年/季度的盈利信息financials
/quarterly_financials
—包含财务信息,如税前收入、净收入、毛利润、EBIT 等
请参阅相应的 Jupyter 笔记本,以获取更多这些方法的示例和输出。
另见
欲查看可下载数据的完整列表,请参考yfinance
的 GitHub 仓库(github.com/ranaroussi/yfinance
)。
你可以查看一些其他库,用于从 Yahoo Finance 下载数据:
yahoofinancials
——与yfinance
类似,这个库提供从 Yahoo Finance 下载各种数据的功能。最大区别在于,所有下载的数据都以 JSON 格式返回。yahoo_earnings_calendar
——一个小型库,专门用于下载财报日历。
从 Nasdaq Data Link 获取数据
替代数据可以是任何被认为是非市场数据的内容,例如农业商品的天气数据、追踪石油运输的卫星图像,甚至是反映公司服务表现的客户反馈。使用替代数据的背后理念是获取一个“信息优势”,然后可以用来生成 alpha。简而言之,alpha是一个衡量表现的指标,描述投资策略、交易员或投资组合经理超越市场的能力。
Quandl曾是投资专业人士(包括量化基金和投资银行)提供替代数据产品的领先供应商。最近,它被纳斯达克收购,现在是 Nasdaq Data Link 服务的一部分。新平台的目标是提供统一的可信数据和分析源。它提供了一种通过专门的 Python 库轻松下载数据的方式。
获取金融数据的一个好起点是 WIKI Prices 数据库,其中包含 3,000 家美国上市公司的股票价格、股息和拆股信息。这个数据库的缺点是自 2018 年 4 月起不再支持(意味着没有最新数据)。然而,对于获取历史数据或学习如何访问数据库,它已经足够。
我们使用与前一个示例中相同的例子——我们下载了苹果公司从 2011 年到 2021 年的股价。
做好准备
在下载数据之前,我们需要在 Nasdaq Data Link 创建一个账户(data.nasdaq.com/
),然后验证我们的电子邮件地址(否则在下载数据时可能会出现异常)。我们可以在个人资料中找到自己的 API 密钥(data.nasdaq.com/account/profile
)。
如何操作…
执行以下步骤从 Nasdaq Data Link 下载数据:
导入库:
import pandas as pd import nasdaqdatalink
使用你的个人 API 密钥进行认证:
nasdaqdatalink.ApiConfig.api_key = "YOUR_KEY_HERE"
你需要将
YOUR_KEY_HERE
替换为你自己的 API 密钥。下载数据:
df = nasdaqdatalink.get(dataset="WIKI/AAPL", start_date="2011-01-01", end_date="2021-12-31")
检查下载的数据:
print(f"Downloaded {len(df)} rows of data.") df.head()
运行代码后将生成以下 DataFrame 预览:
图 1.2:下载的价格信息预览
请求的结果是一个 DataFrame(1,818 行),包含每日 OHLC 价格、调整后的价格、股息和潜在的股票拆分。正如我们在介绍中提到的,这些数据是有限的,仅提供到 2018 年 4 月——最后一条观察数据实际上来自 2018 年 3 月 27 日。
工作原理…
导入所需库后的第一步是使用 API 密钥进行身份验证。当提供数据集参数时,我们使用了以下结构:DATASET/TICKER。
我们应该保持 API 密钥的安全和私密,即不要在公共代码库或任何其他地方分享它们。确保密钥保持私密的一种方法是创建一个环境变量(如何操作取决于你的操作系统),然后在 Python 中加载它。为此,我们可以使用 os
模块。要加载 NASDAQ_KEY
变量,我们可以使用以下代码:os.environ.get("NASDAQ_KEY")
。
有关 get
函数的更多细节如下:
我们可以通过使用类似
["WIKI/AAPL", "WIKI/MSFT"]
的列表一次指定多个数据集。collapse
参数可用于定义频率(可选项包括每日、每周、每月、每季度或每年)。transform
参数可用于在下载数据之前对数据进行一些基本的计算。例如,我们可以计算逐行变化(diff
)、逐行百分比变化(rdiff
)、累计和(cumul
)或将系列标准化为从 100 开始(normalize
)。自然地,我们也可以使用pandas
轻松完成完全相同的操作。
还有更多内容...
纳斯达克数据链接区分了两种用于下载数据的 API 调用。我们之前使用的 get
函数被归类为时间序列 API 调用。我们还可以使用带有 get_table
函数的表格 API 调用。
使用
get_table
函数下载多个股票代码的数据:COLUMNS = ["ticker", "date", "adj_close"] df = nasdaqdatalink.get_table("WIKI/PRICES", ticker=["AAPL", "MSFT", "INTC"], qopts={"columns": COLUMNS}, date={"gte": "2011-01-01", "lte": "2021-12-31"}, paginate=True) df.head()
运行代码会生成以下 DataFrame 预览:
图 1.3:下载的价格数据预览
这个函数调用比我们之前使用
get
函数时要复杂一些。我们首先指定了要使用的表格。然后,我们提供了一个股票代码的列表。接下来,我们指定了我们感兴趣的表格列。我们还提供了日期范围,其中gte
代表 大于或等于,而lte
代表 小于或等于。最后,我们还指明了希望使用分页。tables API 每次调用的行数限制为 10,000 行。然而,通过在函数调用中使用paginate=True
,我们将该限制扩展到 1,000,000 行。将数据从长格式透视到宽格式:
df = df.set_index("date") df_wide = df.pivot(columns="ticker") df_wide.head()
运行代码会生成以下 DataFrame 预览:
图 1.4:透视后的 DataFrame 预览
get_tables
函数的输出是长格式。然而,为了使我们的分析更简便,我们可能更感兴趣的是宽格式。为了重塑数据,我们首先将 date
列设置为索引,然后使用 pd.DataFrame
的 pivot
方法。
请记住,这不是唯一的做法,pandas
至少包含一些有用的方法/函数,可以用来将数据从长格式重塑为宽格式,反之亦然。
另请参见
docs.data.nasdaq.com/docs/python
——Python 的nasdaqdatalink
库的文档。data.nasdaq.com/publishers/zacks
——Zacks 投资研究是一个提供各种可能与您的项目相关的金融数据的供应商。请记住,这些数据不是免费的(你总是可以在购买访问权限之前查看数据的预览)。data.nasdaq.com/publishers
——所有可用数据提供商的列表。
从 Intrinio 获取数据
另一个有趣的金融数据来源是 Intrinio,它提供对其免费(有限制)数据库的访问。以下列表仅展示了我们可以通过 Intrinio 下载的一些有趣的数据点:
日内历史数据
实时股票/期权价格
财务报表数据和基本面
公司新闻
与收益相关的信息
IPO(首次公开募股)
经济数据,如国内生产总值(GDP)、失业率、联邦基金利率等。
30+技术指标
大部分数据是免费的,但对 API 调用频率有一定限制。仅美国股票和 ETF 的实时价格数据需要另一种订阅。
在这个示例中,我们遵循了前面的例子,下载了 2011 到 2021 年的苹果股票价格。这是因为 API 返回的数据不仅仅是一个pandas
数据框,且需要进行一些有趣的预处理。
准备工作
在下载数据之前,我们需要在intrinio.com
注册以获取 API 密钥。
请参见以下链接(docs.intrinio.com/developer-sandbox
)以了解沙盒 API 密钥(免费版)包含的信息。
如何操作……
执行以下步骤从 Intrinio 下载数据:
导入库:
import intrinio_sdk as intrinio import pandas as pd
使用你的个人 API 密钥进行身份验证,并选择 API:
intrinio.ApiClient().set_api_key("YOUR_KEY_HERE") security_api = intrinio.SecurityApi()
你需要将
YOUR_KEY_HERE
替换为你自己的 API 密钥。请求数据:
r = security_api.get_security_stock_prices( identifier="AAPL", start_date="2011-01-01", end_date="2021-12-31", frequency="daily", page_size=10000 )
将结果转换为数据框:
df = ( pd.DataFrame(r.stock_prices_dict) .sort_values("date") .set_index("date") )
检查数据:
print(f"Downloaded {df.shape[0]} rows of data.") df.head()
输出结果如下:
图 1.5:下载的价格信息预览
结果数据框包含 OHLC 价格和交易量,以及它们的调整值。不过,这还不是全部,我们不得不删除一些额外的列,以便让表格适合页面。数据框还包含信息,如拆分比例、股息、价值变化、百分比变化,以及 52 周滚动最高和最低值。
如何运作……
导入所需的库后,第一步是使用 API 密钥进行身份验证。然后,我们选择了要在本食谱中使用的 API——在股票价格的情况下,它是SecurityApi
。
为了下载数据,我们使用了SecurityApi
类的get_security_stock_prices
方法。我们可以指定的参数如下:
identifier
—股票代码或其他可接受的标识符start_date
/end_date
—这些是不言自明的frequency
—我们关注的数据频率(可选项:日频、周频、月频、季频或年频)page_size
—定义每页返回的观察数据数量;我们将其设置为一个较大的数字,以便在一次请求中收集所有所需的数据,无需使用next_page
令牌
API 返回一个类似 JSON 的对象。我们访问了响应的字典形式,然后将其转换为 DataFrame。我们还使用pandas
DataFrame 的set_index
方法将日期设为索引。
还有更多...
在本节中,我们展示了 Intrinio 的一些更有趣的功能。
免费层级并不包括所有信息。有关我们可以免费下载哪些数据的更详细概述,请参阅以下文档页面:docs.intrinio.com/developer-sandbox
。
获取可口可乐的实时股价
您可以使用之前定义的security_api
来获取实时股价:
security_api.get_security_realtime_price("KO")
代码片段的输出是以下 JSON:
{'ask_price': 57.57,
'ask_size': 114.0,
'bid_price': 57.0,
'bid_size': 1.0,
'close_price': None,
'exchange_volume': 349353.0,
'high_price': 57.55,
'last_price': 57.09,
'last_size': None,
'last_time': datetime.datetime(2021, 7, 30, 21, 45, 38, tzinfo=tzutc()),
'low_price': 48.13,
'market_volume': None,
'open_price': 56.91,
'security': {'composite_figi': 'BBG000BMX289',
'exchange_ticker': 'KO:UN',
'figi': 'BBG000BMX4N8',
'id': 'sec_X7m9Zy',
'ticker': 'KO'},
'source': 'bats_delayed',
'updated_on': datetime.datetime(2021, 7, 30, 22, 0, 40, 758000, tzinfo=tzutc())}
下载与可口可乐相关的新闻文章
生成交易信号的一个潜在方式是聚合市场对某个公司的情绪。我们可以通过分析新闻文章或推文来做到这一点。如果情绪是积极的,我们可以做多,反之亦然。以下,我们展示了如何下载关于可口可乐的新闻文章:
r = intrinio.CompanyApi().get_company_news(
identifier="KO",
page_size=100
)
df = pd.DataFrame(r.news_dict)
df.head()
这段代码返回以下 DataFrame:
图 1.6:可口可乐公司相关新闻的预览
查找与搜索短语相关的公司
运行以下代码片段会返回一份公司列表,这些公司是 Intrinio 的 Thea AI 根据提供的查询字符串识别的:
r = intrinio.CompanyApi().recognize_company("Intel")
df = pd.DataFrame(r.companies_dict)
df
如我们所见,除了明显的搜索结果外,还有相当多的公司在其名称中也包含了“intel”这一词。
图 1.7:“intel”这一词相关公司的预览
获取可口可乐的日内股价
我们还可以使用以下代码片段来获取日内价格:
response = (
security_api.get_security_intraday_prices(identifier="KO",
start_date="2021-01-02",
end_date="2021-01-05",
page_size=1000)
)
df = pd.DataFrame(response.intraday_prices_dict)
df
这将返回一个包含日内价格数据的 DataFrame。
图 1.8:下载的日内价格预览
获取可口可乐最新的财报
security_api
的另一个有趣用途是获取最新的财报记录。我们可以使用以下代码片段来实现:
r = security_api.get_security_latest_earnings_record(identifier="KO")
print(r)
API 调用的输出包含了大量有用的信息。例如,我们可以看到财报电话会议发生的具体时间。这些信息可能用于实施在市场开盘时执行的交易策略。
图 1.9:可口可乐最新的财报
另见
docs.intrinio.com/documentation/api_v2/getting_started
—探索 API 的起点docs.intrinio.com/developer-sandbox
—免费沙盒环境中包含内容的概述docs.intrinio.com/documentation/python
—Python SDK 的详细文档
从 Alpha Vantage 获取数据
Alpha Vantage 是另一家流行的数据供应商,提供高质量的金融数据。通过其 API,我们可以下载以下内容:
股票价格,包括日内价格和实时价格(需付费访问)
基本面数据:收益、损益表、现金流、财报日历、IPO 日历
外汇和加密货币汇率
经济指标,如实际 GDP、联邦基金利率、消费者价格指数和消费者信心指数
50+ 种技术指标
在本教程中,我们展示了如何下载与加密货币相关的部分数据。我们从历史每日比特币价格开始,然后展示如何查询实时加密货币汇率。
准备工作
在下载数据之前,我们需要在 www.alphavantage.co/support/#api-key
注册并获取 API 密钥。在某些限制范围内(每分钟 5 次 API 请求;每天 500 次 API 请求),API 及所有端点是免费的(不包括实时股票价格)。
如何操作……
执行以下步骤以从 Alpha Vantage 下载数据:
导入库:
from alpha_vantage.cryptocurrencies import CryptoCurrencies
使用您的个人 API 密钥进行身份验证并选择 API:
ALPHA_VANTAGE_API_KEY = "YOUR_KEY_HERE" crypto_api = CryptoCurrencies(key=ALPHA_VANTAGE_API_KEY, output_format= "pandas")
下载以欧元表示的比特币每日价格:
data, meta_data = crypto_api.get_digital_currency_daily( symbol="BTC", market="EUR" )
meta_data
对象包含一些关于查询详细信息的有用信息。您可以在下面看到:{'1\. Information': 'Daily Prices and Volumes for Digital Currency', '2\. Digital Currency Code': 'BTC', '3\. Digital Currency Name': 'Bitcoin', '4\. Market Code': 'EUR', '5\. Market Name': 'Euro', '6\. Last Refreshed': '2022-08-25 00:00:00', '7\. Time Zone': 'UTC'}
data
DataFrame 包含所有请求的信息。我们获取了 1,000 个每日 OHLC(开盘、最高、最低、收盘)价格、交易量和市值。值得注意的是,所有的 OHLC 价格都以两种货币提供:欧元(EUR,按照我们的请求)和美元(USD,默认货币)。图 1.10:下载的价格、交易量和市值预览
下载实时汇率:
crypto_api.get_digital_currency_exchange_rate( from_currency="BTC", to_currency="USD" )[0].transpose()
运行该命令将返回当前汇率的 DataFrame:
图 1.11:BTC-USD 汇率
它是如何工作的……
在导入alpha_vantage
库后,我们需要使用个人 API 密钥进行身份验证。我们在实例化CryptoCurrencies
类对象时完成了这一操作。同时,我们指定了希望以pandas
DataFrame 形式获取输出。其他可能的格式包括 JSON 和 CSV。
在步骤 3中,我们使用get_digital_currency_daily
方法下载了每日比特币价格。此外,我们指定了希望获取欧元(EUR)价格。默认情况下,该方法将返回请求的欧元价格以及其美元(USD)等价价格。
最后,我们使用get_digital_currency_exchange_rate
方法下载了实时的 BTC/USD 汇率。
还有更多...
到目前为止,我们使用了alpha_vantage
库作为中介来从 Alpha Vantage 下载信息。然而,数据提供商的功能发展速度快于第三方库,学习访问其 API 的其他方式可能会很有趣。
导入所需的库:
import requests import pandas as pd from io import BytesIO
下载比特币的日内数据:
AV_API_URL = "https://www.alphavantage.co/query" parameters = { "function": "CRYPTO_INTRADAY", "symbol": "ETH", "market": "USD", "interval": "30min", "outputsize": "full", "apikey": ALPHA_VANTAGE_API_KEY } r = requests.get(AV_API_URL, params=parameters) data = r.json() df = ( pd.DataFrame(data["Time Series Crypto (30min)"]) .transpose() ) df
运行上述代码片段将返回以下下载的 DataFrame 预览:
图 1.12:显示包含比特币日内价格的 DataFrame 预览
我们首先定义了用于请求信息的基础 URL。然后,定义了一个字典,包含请求的附加参数,包括个人 API 密钥。在我们的函数调用中,我们指定了要下载以美元表示的日内 ETH 价格,并且每 30 分钟采样一次。我们还指明了需要完整的输出(通过指定
outputsize
参数)。另一个选项是compact
输出,它会下载最新的 100 个观察值。在准备好请求的参数后,我们使用了
requests
库中的get
函数。我们提供了基础 URL 和parameters
字典作为参数。获取请求响应后,我们可以通过json
方法以 JSON 格式访问它。最后,我们将感兴趣的元素转换为pandas
DataFrame。Alpha Vantage 的文档展示了另一种稍有不同的下载数据方法,即通过创建一个包含所有参数的长 URL。自然,这也是一种可能性,但上面介绍的选项稍显简洁。要查看文档中展示的完全相同的请求 URL,您可以运行
r.request.url
。下载未来三个月内的收益公告:
AV_API_URL = "https://www.alphavantage.co/query" parameters = { "function": "EARNINGS_CALENDAR", "horizon": "3month", "apikey": ALPHA_VANTAGE_API_KEY } r = requests.get(AV_API_URL, params=parameters) pd.read_csv(BytesIO(r.content))
运行代码片段将返回以下输出:
图 1.13:显示包含下载的收益信息的 DataFrame 预览
获取 API 请求的响应与之前的示例非常相似,但处理输出的方法却大不相同。
r.content
的输出是一个bytes
对象,包含查询结果的文本。为了模拟一个内存中的普通文件,我们可以使用io
模块中的BytesIO
类。然后,我们可以使用pd.read_csv
函数正常加载这个模拟的文件。
在随附的笔记本中,我们展示了 Alpha Vantage 的更多功能,例如获取季度收益数据、下载即将上市的 IPO 日历,并使用alpha_vantage
的TimeSeries
模块下载股票价格数据。
另见
www.alphavantage.co/
—Alpha Vantage 主页github.com/RomelTorres/alpha_vantage
—用于访问 Alpha Vantage 数据的第三方库的 GitHub 仓库
从 CoinGecko 获取数据
我们将介绍的最后一个数据源完全专注于加密货币。CoinGecko 是一个流行的数据供应商和加密货币跟踪网站,您可以在上面找到实时汇率、历史数据、交易所信息、即将举行的事件、交易量等更多内容。
我们可以列举出 CoinGecko 的一些优点:
完全免费,无需注册 API 密钥
除了价格,它还提供有关加密货币的更新和新闻
它覆盖了许多币种,不仅仅是最流行的币种
在这个食谱中,我们下载了比特币过去 14 天的 OHLC 数据。
如何做……
执行以下步骤从 CoinGecko 下载数据:
导入库:
from pycoingecko import CoinGeckoAPI from datetime import datetime import pandas as pd
实例化 CoinGecko API:
cg = CoinGeckoAPI()
获取比特币过去 14 天的 OHLC 价格:
ohlc = cg.get_coin_ohlc_by_id( id="bitcoin", vs_currency="usd", days="14" ) ohlc_df = pd.DataFrame(ohlc) ohlc_df.columns = ["date", "open", "high", "low", "close"] ohlc_df["date"] = pd.to_datetime(ohlc_df["date"], unit="ms") ohlc_df
运行上面的代码片段将返回以下 DataFrame:
图 1.14:包含请求的比特币价格的 DataFrame 预览
在前面的表格中,我们可以看到我们已获得请求的 14 天数据,数据采样间隔为 4 小时。
它是如何工作的……
在导入库之后,我们实例化了 CoinGeckoAPI
对象。然后,使用它的 get_coin_ohlc_by_id
方法,我们下载了过去 14 天的 BTC/USD 汇率数据。值得一提的是,API 有一些限制:
我们只能下载预定义天数的数据。我们可以选择以下选项之一:
1
/7
/14
/30
/90
/180
/365
/max
。OHLC 蜡烛图的采样频率根据请求的时间范围而变化。对于 1 天或 2 天的请求,它们每 30 分钟采样一次;对于 3 到 30 天的请求,每 4 小时采样一次;超过 30 天的数据,则每 4 天采样一次。
get_coin_ohlc_by_id
的输出是一个列表的列表,我们可以将其转换为 pandas
DataFrame。我们必须手动创建列名,因为 API 并未提供这些信息。
还有更多内容……
我们已经看到,使用 CoinGecko API 获取 OHLC 价格相比其他供应商可能稍微复杂一些。然而,CoinGecko 还有其他有趣的信息,我们可以通过其 API 下载。在本节中,我们展示了一些可能性。
获取前 7 个热门币种
我们可以使用 CoinGecko 获取前 7 个热门币种——排名是基于过去 24 小时内在 CoinGecko 上的搜索次数。在下载这些信息时,我们还会获取币种的符号、市场资本排名以及最新的 BTC 价格:
trending_coins = cg.get_search_trending()
(
pd.DataFrame([coin["item"] for coin in trending_coins["coins"]])
.drop(columns=["thumb", "small", "large"])
)
使用上面的代码片段,我们获得了以下 DataFrame:
图 1.15:包含 7 个热门币种及其相关信息的 DataFrame 预览
获取比特币当前的美元价格
我们还可以提取当前加密货币在各种货币中的价格:
cg.get_price(ids="bitcoin", vs_currencies="usd")
运行上述代码片段会返回比特币的实时价格:
{'bitcoin': {'usd': 47312}}
在随附的笔记本中,我们展示了pycoingecko
的一些其他功能,例如以除美元外的不同货币获取加密货币价格、下载 CoinGecko 上支持的所有加密货币列表(超过 9,000 种货币)、获取每种加密货币的详细市场数据(市值、24 小时交易量、历史最高价等),以及加载最受欢迎的交易所列表。
另见
你可以在这里找到pycoingecko
库的文档:github.com/man-c/pycoingecko
。
概述
在本章中,我们介绍了一些最受欢迎的金融数据来源。但这仅仅是冰山一角。下面,你可以找到其他一些可能更适合你需求的有趣数据源。
其他数据源包括:
IEX Cloud (
iexcloud.io/
)—一个提供各种金融数据的平台。该平台的一个显著特点是基于 Stocktwits 上的活动(Stocktwits 是一个投资者和交易者的在线社区)提供的每日和每分钟情绪评分。然而,该 API 仅在付费计划中可用。你可以使用官方 Python 库pyex
来访问 IEX Cloud 的数据。Tiingo (
www.tiingo.com/
)和tiingo
库。CryptoCompare (
www.cryptocompare.com/
)—该平台通过 API 提供广泛的与加密货币相关的数据。这个数据供应商的独特之处在于他们提供了订单簿数据。Twelve Data (
twelvedata.com/
)。polygon.io (
polygon.io/
)—一个可靠的数据供应商,提供实时和历史数据(股票、外汇和加密货币)。被 Google、Robinhood 和 Revolut 等公司信任。Shrimpy (
www.shrimpy.io/
)和shrimpy-python
—Shrimpy 开发者 API 的官方 Python 库。
在下一章,我们将学习如何对下载的数据进行预处理,以便进一步分析。
加入我们的 Discord 社区!
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
第二章:数据预处理
你经常会听到数据科学行业中的说法,数据科学家通常花费大约 80% 的时间在获取数据、处理数据、清理数据等方面。只有剩下的 20% 的时间才会用于建模,而建模通常被认为是最有趣的部分。在上一章中,我们已经学会了如何从各种来源下载数据。在我们从数据中提取实际洞察之前,我们仍然需要经过几个步骤。
在本章中,我们将讨论数据预处理,即在使用数据之前应用于数据的一般整理/操作。目标不仅是提高模型的表现,还要确保基于这些数据的任何分析的有效性。在本章中,我们将专注于金融时间序列,而在后续章节中,我们还将展示如何处理其他类型的数据。
在本章中,我们将涵盖以下内容:
转换价格为收益率
调整收益率以考虑通货膨胀
改变时间序列数据的频率
填补缺失数据的不同方法
更换货币
聚合交易数据的不同方法
转换价格为收益率
许多用于时间序列建模的模型和方法要求时间序列是平稳的。我们将在第六章《时间序列分析与预测》中深入讨论这个话题,然而,现在我们可以对其有一个快速的了解。
平稳性假设一个过程的统计量(如序列的均值和方差)随时间不变。基于这一假设,我们可以建立旨在预测该过程未来值的模型。
然而,资产价格通常是非平稳的。它们的统计量不仅随时间变化,还可以观察到一些趋势(随时间变化的一般模式)或季节性(在固定时间间隔内重复的模式)。通过将价格转换为收益率,我们试图使时间序列平稳。
使用收益率而非价格的另一个好处是规范化。这意味着我们可以轻松比较不同的收益率序列,而使用原始股价则不那么简单,因为一只股票可能从 $10 开始,而另一只股票则从 $1,000 开始。
有两种类型的收益率:
简单收益率:它们在资产之间进行聚合——一个投资组合的简单收益率是该投资组合中各个资产收益率的加权和。简单收益率定义为:
R[t] = (P[t] - P[t-1])/P[t-1] = P[t]/P[t-1] -1
对数收益率:它们在时间上进行聚合。通过一个例子更容易理解——给定一个月的对数收益率是该月内各个日期对数收益率的总和。对数收益率定义为:
r[t] = log(P[t]/P[t-1]) = log(P[t]) - log(P[t-1])
P[t] 是时间 t 时资产的价格。在前面的例子中,我们没有考虑股息,股息显然会影响收益率,并且需要对公式做出一些小的修改。
在处理股票价格时的最佳实践是使用调整后的值,因为它们考虑了可能的公司行为,例如股票分割。
通常来说,对数回报比简单回报更为常用。可能最重要的原因是,如果我们假设股票价格服从对数正态分布(虽然对于特定时间序列,这可能成立也可能不成立),那么对数回报将服从正态分布。而正态分布与许多经典的时间序列建模统计方法非常契合。此外,对于日常/日内数据,简单回报和对数回报之间的差异通常非常小,这与对数回报通常小于简单回报的普遍规律相符。
在本食谱中,我们展示了如何使用苹果公司股票价格计算两种类型的回报。
如何操作……
执行以下步骤下载苹果公司股票价格并计算简单回报/对数回报:
导入库:
import pandas as pd import numpy as np import yfinance as yf
下载数据并只保留调整后的收盘价:
df = yf.download("AAPL", start="2010-01-01", end="2020-12-31", progress=False) df = df.loc[:, ["Adj Close"]]
使用调整后的收盘价计算简单回报和对数回报:
df["simple_rtn"] = df["Adj Close"].pct_change() df["log_rtn"] = np.log(df["Adj Close"]/df["Adj Close"].shift(1))
检查输出结果:
df.head()
结果 DataFrame 如下所示:
图 2.1:包含苹果公司调整后收盘价和简单/对数回报的 DataFrame 片段
第一行将始终包含NaN(不是数字)值,因为没有前一个价格可以用来计算回报。
它是如何工作的……
在步骤 2中,我们从 Yahoo Finance 下载了价格数据,并且只保留了调整后的收盘价,用于回报的计算。
为了计算简单回报,我们使用了 pandas Series/DataFrame 的pct_change
方法。它计算当前元素与前一个元素之间的百分比变化(我们可以指定滞后数,但在此特定情况下,默认值1
就足够了)。请注意,前一个元素是指给定行上方的元素。如果我们处理的是时间序列数据,需要确保数据按时间索引排序。
为了计算对数回报,我们遵循了本食谱介绍中的公式。当我们将系列中的每个元素除以其滞后值时,使用了shift
方法,滞后值为1
,以访问前一个元素。最后,我们使用np.log
函数对除法结果取自然对数。
调整回报以考虑通货膨胀
在进行不同类型的分析时,特别是长期分析时,我们可能需要考虑通货膨胀。通货膨胀是指经济中价格水平普遍上升的现象。换句话说,就是货币购买力的下降。这就是为什么我们可能希望将通货膨胀与股价上涨分开考虑,股价上涨可能是由于公司的增长或发展等因素。
我们当然可以直接调整股票价格,但在本教程中,我们将重点介绍调整收益并计算实际收益。我们可以使用以下公式来实现:
其中 R^r[t] 是实际收益,R[t] 是时间 t 的简单收益, 代表通货膨胀率。
对于这个示例,我们使用了 2010 到 2020 年期间的苹果公司股票价格(与之前的教程中一样下载)。
如何操作…
执行以下步骤以调整收益以应对通货膨胀:
导入库并进行认证:
import pandas as pd import nasdaqdatalink nasdaqdatalink.ApiConfig.api_key = "YOUR_KEY_HERE"
将日度价格重新采样为月度:
df = df.resample("M").last()
从 Nasdaq Data Link 下载通货膨胀数据:
df_cpi = ( nasdaqdatalink.get(dataset="RATEINF/CPI_USA", start_date="2009-12-01", end_date="2020-12-31") .rename(columns={"Value": "cpi"}) ) df_cpi
运行代码后会生成如下表格:
图 2.2:包含消费者物价指数(CPI)值的 DataFrame 片段
将通货膨胀数据与价格连接:
df = df.join(df_cpi, how="left")
计算简单收益和通货膨胀率:
df["simple_rtn"] = df["Adj Close"].pct_change() df["inflation_rate"] = df["cpi"].pct_change()
调整收益以应对通货膨胀并计算实际收益:
df["real_rtn"] = ( (df["simple_rtn"] + 1) / (df["inflation_rate"] + 1) - 1 ) df.head()
运行代码后会生成如下表格:
图 2.3:包含计算后的通货膨胀调整收益的 DataFrame 片段
工作原理…
首先,我们导入了相关库并通过 Nasdaq Data Link 进行了认证,用于下载与通货膨胀相关的数据。然后,我们需要将苹果公司的股票价格重新采样为月度频率,因为通货膨胀数据是按月提供的。为此,我们将 resample
方法与 last
方法进行了链式调用。这样,我们就获取了给定月份的最后一个价格。
在第 3 步中,我们从 Nasdaq Data Link 下载了每月的消费者物价指数(CPI)值。它是衡量一篮子消费者商品和服务(如食品、交通等)加权平均价格的指标。
然后,我们使用左连接合并了两个数据集(价格和 CPI)。左连接是一种用于合并表格的操作,返回左表中的所有行以及右表中匹配的行,同时将不匹配的行留空。
默认情况下,join
方法使用表格的索引来执行实际的连接。我们可以使用 on
参数来指定其他需要使用的列。
在将所有数据放入一个 DataFrame 中后,我们使用了 pct_change
方法来计算简单收益和通货膨胀率。最后,我们使用介绍中提供的公式来计算实际收益。
还有更多内容…
我们已经探索了如何从 Nasdaq Data Link 下载通货膨胀数据。或者,我们可以使用一个名为 cpi
的便捷库。
导入库:
import cpi
此时,我们可能会遇到以下警告:
StaleDataWarning: CPI data is out of date
如果是这种情况,我们只需运行以下代码行来更新数据:
cpi.update()
获取默认的 CPI 系列:
cpi_series = cpi.series.get()
在这里,我们下载默认的 CPI 指数(
CUUR0000SA0:美国城市平均水平,所有城市消费者,未经季节性调整的所有商品
),它适用于大多数情况。或者,我们可以提供items
和area
参数来下载更具针对性的系列。我们还可以使用get_by_id
函数下载特定的 CPI 系列。将对象转换为
pandas
DataFrame:df_cpi_2 = cpi_series.to_dataframe()
筛选 DataFrame 并查看前 12 个观测值:
df_cpi_2.query("period_type == 'monthly' and year >= 2010") \ .loc[:, ["date", "value"]] \ .set_index("date") \ .head(12)
运行代码生成的输出如下:
图 2.4:包含 CPI 下载值的 DataFrame 的前 12 个值
在这一步,我们使用了一些过滤操作,将数据与之前从 Nasdaq Data Link 下载的数据进行比较。我们使用query
方法仅保留 2010 年及以后的月度数据。为了便于比较,我们只显示了两列选定的列和前 12 个观测值。
在后续章节中,我们还将使用cpi
库,通过inflate
函数直接对价格进行通胀调整。
另见
github.com/palewire/cpi
—cpi
库的 GitHub 仓库
改变时间序列数据的频率
在处理时间序列,尤其是金融时间序列时,我们常常需要改变数据的频率(周期性)。例如,我们接收到的是每日的 OHLC 价格,但我们的算法需要使用每周数据。或者我们有每日的替代数据,并且想将其与我们的实时日内数据流进行匹配。
改变频率的一般规则可以分解为以下几点:
将对数回报乘以或除以时间段数。
将波动率乘以或除以时间段数的平方根。
对于任何具有独立增量的过程(例如几何布朗运动),对数回报的方差与时间成正比。例如,r[t3] - r[t1]的方差将是以下两种方差之和:r[t2]−r[t1]和 r[t3]−r[t2],假设t[1]≤t[2]≤t[3]。在这种情况下,当我们假设过程的参数随时间不变(同质性)时,我们得出方差与时间间隔长度成正比的结论。在实践中,这意味着标准差(波动率)与时间的平方根成正比。
在本示例中,我们展示了如何使用日回报计算 Apple 的月度实际波动率,然后将其年化。我们在分析投资的风险调整绩效时,常常会遇到年化波动率。
实际波动率的公式如下:
实际波动率通常用于计算基于日内回报的每日波动率。
我们需要采取的步骤如下:
下载数据并计算对数回报
计算各月的实际波动率
通过乘以
将值年化,因为我们正在将月度值转换为年度值。
准备就绪
我们假设您已经按照之前的步骤操作,并且拥有一个名为df
的 DataFrame,其中包含一个log_rtn
列,时间戳作为索引。
如何操作…
执行以下步骤,计算并年化月度实现的波动率:
导入库:
import pandas as pd import numpy as np
定义计算实现波动率的函数:
def realized_volatility(x): return np.sqrt(np.sum(x**2))
计算月度实现波动率:
df_rv = ( df.groupby(pd.Grouper(freq="M")) .apply(realized_volatility) .rename(columns={"log_rtn": "rv"}) )
年化值:
df_rv.rv = df_rv["rv"] * np.sqrt(12)
绘制结果:
fig, ax = plt.subplots(2, 1, sharex=True) ax[0].plot(df) ax[0].set_title("Apple's log returns (2000-2012)") ax[1].plot(df_rv) ax[1].set_title("Annualized realized volatility") plt.show()
执行该代码片段会生成以下图表:
图 2.5:苹果的对数收益率序列及相应的实现波动率(年化)
我们可以看到,波动率的尖峰与一些极端收益率(可能是异常值)相吻合。
它是如何工作的…
通常,我们可以使用pandas
DataFrame 的resample
方法。假设我们想要计算每月的平均收益率,我们可以使用df["log_rtn"].resample("M").mean()
。
使用resample
方法时,我们可以使用pandas
的任何内置聚合函数,如mean
、sum
、min
和max
。然而,我们面临的情况有些复杂,因此我们首先定义了一个名为realized_volatility
的辅助函数。因为我们希望使用自定义函数进行聚合,所以我们通过组合使用groupby
、Grouper
和apply
来复制resample
的行为。
我们展示了最基本的结果可视化(有关时间序列可视化的详细信息,请参见第三章,金融时间序列的可视化)。
缺失数据插补的不同方法
在处理任何时间序列时,可能会出现一些数据丢失的情况,原因多种多样(有人忘记输入数据、数据库出现随机问题等)。一种可行的解决方案是丢弃缺失值的观测值。然而,假设我们正在同时分析多个时间序列,并且只有其中一个序列由于某些随机错误缺失了一个值。我们是否还要因为这个单一的缺失值而删除其他可能有价值的信息?可能不需要。而且还有许多其他情景,我们更倾向于以某种方式处理缺失值,而不是丢弃这些观测值。
插补缺失时间序列数据的两种最简单方法是:
向后填充——用下一个已知的值填充缺失值
向前填充——用之前已知的值填充缺失值
在这个食谱中,我们展示了如何使用这些技术来轻松处理 CPI 时间序列中的缺失值。
如何操作…
执行以下步骤,尝试不同的缺失数据插补方法:
导入库:
import pandas as pd import numpy as np import nasdaqdatalink
从 Nasdaq Data Link 下载通胀数据:
nasdaqdatalink.ApiConfig.api_key = "YOUR_KEY_HERE" df = ( nasdaqdatalink.get(dataset="RATEINF/CPI_USA", start_date="2015-01-01", end_date="2020-12-31") .rename(columns={"Value": "cpi"}) )
随机引入五个缺失值:
np.random.seed(42) rand_indices = np.random.choice(df.index, 5, replace=False) df["cpi_missing"] = df.loc[:, "cpi"] df.loc[rand_indices, "cpi_missing"] = np.nan df.head()
在下表中,我们可以看到已成功将缺失值引入数据中:
图 2.6:包含下载的 CPI 数据和添加缺失值的 DataFrame 预览
使用不同方法填充缺失值:
for method in ["bfill", "ffill"]: df[f"method_{method}"] = ( df[["cpi_missing"]].fillna(method=method) )
通过显示我们创建缺失值的行来检查结果:
df.loc[rand_indices].sort_index()
运行代码会产生以下输出:
图 2.7:插补缺失值后的 DataFrame 预览
我们可以看到,后向填充成功填充了我们创建的所有缺失值。然而,前向填充未能填充一个值。这是因为这是序列中的第一个数据点,因此没有可供前向填充的值。
绘制 2015 到 2016 年间的结果:
df.loc[:"2017-01-01"] \ .drop(columns=["cpi_missing"]) \ .plot(title="Different ways of filling missing values");
运行该代码片段会生成以下图表:
图 2.8:后向填充与前向填充在 CPI 时间序列中的对比
在图 2.8中,我们可以清楚地看到前向填充和后向填充在实践中的应用效果。
它是如何工作的……
在导入库之后,我们从 Nasdaq Data Link 下载了 6 年的月度 CPI 数据。然后,我们从 DataFrame 中随机选择了 5 个索引,人工创建了缺失值。为此,我们将这些值替换为 NaN。
在步骤 4中,我们对时间序列应用了两种不同的插补方法。我们使用了pandas
DataFrame 的fillna
方法,并指定method
参数为bfill
(后向填充)或ffill
(前向填充)。我们将插补后的序列保存为新列,以便清晰比较结果。请记住,fillna
方法会替换缺失值,并保持其他值不变。
我们本可以指定填充缺失数据的方法,比如0
或999
等数值。然而,在时间序列数据的情况下,使用任意数值可能没有多大意义,因此不推荐这样做。
我们使用np.random.seed(42)
使实验结果具有可重复性。每次运行此单元时,都会得到相同的随机数。你可以使用任何数字作为种子,并且每次随机选择的结果都会不同。
在步骤 5中,我们检查了插补后的值。为了简洁起见,我们只显示了随机选择的索引。我们使用sort_index
方法按日期对其进行了排序。这样,我们可以清楚地看到,第一个值没有使用前向填充进行填充,因为它是时间序列中的第一个观测值。
最后,我们绘制了 2015 到 2016 年所有时间序列的图表。在图表中,我们可以清楚地看到后向填充和前向填充如何填补缺失值。
还有更多……
在这个示例中,我们探讨了一些简单的填补缺失数据的方法。另一种可能性是使用插值方法,而插值有许多不同的方式。因此,在本例中,我们将使用线性插值。有关插值方法的更多信息,请参考pandas
文档(链接在另见部分中提供)。
使用线性插值填充缺失值:
df["method_interpolate"] = df[["cpi_missing"]].interpolate()
检查结果:
df.loc[rand_indices].sort_index()
运行代码片段会生成以下输出:
图 2.9:使用线性插值填补缺失值后的 DataFrame 预览
不幸的是,线性插值也无法处理位于时间序列开始位置的缺失值。
绘制结果:
df.loc[:"2017-01-01"] \ .drop(columns=["cpi_missing"]) \ .plot(title="Different ways of filling missing values");
运行代码片段会生成以下图形:
图 2.10:CPI 时间序列中向后填充和向前填充的比较,包括插值
在图 2.10中,我们可以看到线性插值是如何通过一条直线连接已知的观测值来填补缺失值的。
在这个示例中,我们探讨了如何为时间序列数据填补缺失值。然而,这些并不是所有可能的方法。例如,我们本可以使用最后几条观测值的移动平均来填补任何缺失的值。实际上,有许多可能的方法可以选择。在第十三章,应用机器学习:识别信用违约,我们将展示如何处理其他类型数据集中的缺失值问题。
另见
pandas.pydata.org/docs/reference/api/pandas.DataFrame.interpolate.html
—在这里,你可以看到pandas
中所有可用的插值方法。
货币转换
另一个在金融任务中相当常见的预处理步骤是货币转换。假设你有一个由多种资产组成的投资组合,这些资产的定价使用不同的货币,并且你想要计算整个投资组合的总价值。最简单的例子可能是美国股票和欧洲股票。
在这个示例中,我们展示了如何轻松地将股票价格从 USD 转换为 EUR。然而,完全相同的步骤也可以用于转换任何货币对。
如何操作...
执行以下步骤将股票价格从 USD 转换为 EUR:
导入所需库:
import pandas as pd import yfinance as yf from forex_python.converter import CurrencyRates
下载 2020 年 1 月份的 Apple OHLC 价格:
df = yf.download("AAPL", start="2020-01-01", end="2020-01-31", progress=False) df = df.drop(columns=["Adj Close", "Volume"])
实例化
CurrencyRates
对象:c = CurrencyRates()
下载每个所需日期的 USD/EUR 汇率:
df["usd_eur"] = [c.get_rate("USD", "EUR", date) for date in df.index]
将 USD 价格转换为 EUR:
for column in df.columns[:-1]: df[f"{column}_EUR"] = df[column] * df["usd_eur"] df.head()
运行代码片段会生成以下预览:
图 2.11:包含原始 USD 价格和转换为 EUR 价格的 DataFrame 预览
我们可以看到,所有四列价格已经成功地转换为 EUR。
它是如何工作的…
在 第 1 步 中,我们导入了所需的库。然后,我们使用之前介绍过的 yfinance
库下载了 2020 年 1 月的 Apple OHLC 数据。
在 第 3 步 中,我们实例化了 forex-python
库中的 CurrencyRates
对象。该库底层使用了 Forex API(theforexapi.com
),这是一个免费的 API,用于访问欧洲中央银行发布的当前和历史外汇汇率。
在 第 4 步 中,我们使用 get_rate
方法下载了所有日期的 USD/EUR 汇率,这些日期与股票价格 DataFrame 中的日期匹配。为了高效地执行此操作,我们使用了列表推导并将输出存储在新列中。该库和当前实现的一个潜在缺点是,我们需要单独下载每个汇率,这对于大型 DataFrame 来说可能不具备可扩展性。
在使用该库时,有时会遇到以下错误:RatesNotAvailableError: Currency Rates Source Not Ready
。最可能的原因是你尝试获取周末的汇率。最简单的解决方案是跳过这些天的列表推导/for
循环,并使用之前介绍的某种方法填补缺失值。
在最后一步,我们遍历了初始 DataFrame 的列(除了汇率列),并将 USD 价格与汇率相乘。我们将结果存储在新列中,列名带有 _EUR
下标。
还有更多内容…
使用 forex_python
库,我们可以轻松地一次性下载多种货币的汇率。为此,我们可以使用 get_rates
方法。在下面的代码片段中,我们下载了 USD 到 31 种可用货币的当前汇率。我们自然可以指定感兴趣的日期,就像之前那样。
获取当前 USD 汇率到 31 种可用货币:
usd_rates = c.get_rates("USD") usd_rates
前五条记录如下所示:
{'EUR': 0.8441668073611345, 'JPY': 110.00337666722943, 'BGN': 1.651021441836907, 'CZK': 21.426641904440316, 'DKK': 6.277224379537396, }
在这个食谱中,我们主要关注了
forex_python
库,因为它非常方便且灵活。然而,我们也可以从许多不同的来源下载历史汇率,并得出相同的结果(根据数据提供商的不同,可能会有一些误差)。在 第一章《获取金融数据》中描述的许多数据提供商提供历史汇率。下面,我们展示如何使用 Yahoo Finance 获取这些汇率。从 Yahoo Finance 下载 USD/EUR 汇率:
df = yf.download("USDEUR=X", start="2000-01-01", end="2010-12-31", progress=False) df.head()
运行代码片段会得到以下输出:
图 2.12:下载的汇率 DataFrame 预览
在图 2.12中,我们可以看到此数据源的一个限制——该货币对的数据仅自 2003 年 12 月以来可用。另外,Yahoo Finance 提供的是外汇汇率的 OHLC 变种。为了得到一个用于转换的单一数值,可以选择四个值中的任意一个(取决于使用场景),或者计算中间值(低值与高值之间的中间值)。
另见
github.com/MicroPyramid/forex-python
—forex-python
库的 GitHub 仓库
不同的交易数据聚合方式
在深入构建机器学习模型或设计交易策略之前,我们不仅需要可靠的数据,还需要将其聚合成便于进一步分析和适合我们选择的模型的格式。条形图这一术语指的是一种数据表示方式,包含了任何金融资产价格波动的基本信息。我们已经在第一章《获取金融数据》中看到过一种条形图形式,我们在其中探讨了如何从各种来源下载金融数据。
在那里,我们下载了按某一时间段(如月、日或日内频率)采样的 OHLCV 数据。这是最常见的金融时间序列数据聚合方式,称为时间条。
按时间采样金融时间序列有一些缺点:
时间条掩盖了市场中实际的活动率——它们往往在低活动期(例如中午)过度采样,在高活动期(例如接近市场开盘和收盘时)则采样不足。
如今,市场越来越多地由交易算法和机器人控制,因此它们不再遵循人类的日间周期。
基于时间的条形图提供的统计特性较差(例如序列相关性、异方差性和回报的非正态性)。
鉴于这是最流行的聚合方式,也是最容易获取的一种,它也可能受到操纵的影响(例如冰山订单)。
冰山订单是将大额订单分割成较小的限价单,以隐藏实际的订单数量。它们被称为“冰山订单”,因为可见的订单只是“冰山一角”,而大量的限价单正在等待,随时准备下单。
为了克服这些问题并获得竞争优势,从业者还使用其他类型的聚合方式。理想情况下,他们希望得到一种每个条形图包含相同数量信息的条形图表示。它们正在使用的一些替代方法包括:
刻度条——命名源于金融市场中交易通常被称为“刻度”(ticks)。这种聚合方式下,我们每当发生预定数量的交易时,就会采样一个 OHLCV 条形图。
成交量条——我们每当发生预定量的交易(可以用任何单位衡量,例如股票、币种等)时,就会采样一个条形图。
美元条形图——我们每当交换预定的美元金额时就取一个条形图。自然,我们也可以使用其他任何货币。
每种聚合形式都有其优缺点,我们应当注意。
交易条形图提供了一种更好的方式来跟踪市场中的实际活动及其波动性。然而,潜在的问题是,一笔交易可能包含某种资产的任意数量单位。因此,购买一股股票与购买 10,000 股股票的订单被视为相同。
成交量条形图试图克服这一问题。然而,它们自身也存在问题。它们不能准确反映资产价格发生重大变化或股票拆分时的情况。这使得它们在面对受此类事件影响的不同时间段时不太可靠。
这时第三种类型的条形图——美元条形图就派上用场了。它通常被认为是聚合价格数据的最稳健方式。首先,美元条形图有助于弥补价格波动的差距,尤其对像加密货币这样高度波动的市场尤为重要。其次,以美元为单位的采样有助于保持信息的一致性。第二个原因是,美元条形图不受证券未平仓量的影响,因此不会受到诸如股票拆分、公司回购、新股发行等操作的影响。
在本例中,我们将学习如何使用来自 Binance(最流行的加密货币交易所之一)的交易数据创建上述提到的四种类型的条形图。我们选择使用加密货币数据,因为与股票数据等其他资产类别相比,它更容易获得(且免费)。然而,所展示的方法对于其他资产类别同样适用。
如何操作…
执行以下步骤,从 Binance 下载交易数据并将其聚合为四种不同类型的条形图:
导入库:
from binance.spot import Spot as Client import pandas as pd import numpy as np
实例化 Binance 客户端并下载最后 500 笔
BTCEUR
交易:spot_client = Client(base_url="https://api3.binance.com") r = spot_client.trades("BTCEUR")
将下载的交易数据处理为
pandas
DataFrame:df = ( pd.DataFrame(r) .drop(columns=["isBuyerMaker", "isBestMatch"]) ) df["time"] = pd.to_datetime(df["time"], unit="ms") for column in ["price", "qty", "quoteQty"]: df[column] = pd.to_numeric(df[column]) df
执行代码将返回以下 DataFrame:
图 2.13:包含最后 500 笔 BTC-EUR 交易的 DataFrame
我们可以看到,
BTCEUR
市场中的 500 笔交易发生在大约九分钟的时间内。对于更受欢迎的市场,这一时间窗口可以显著缩短。qty
列包含交易的 BTC 数量,而quoteQty
列包含交易数量的 EUR 价格,这与将price
列与qty
列相乘相同。定义一个函数,将原始交易信息聚合成条形图:
def get_bars(df, add_time=False): ohlc = df["price"].ohlc() vwap = ( df.apply(lambda x: np.average(x["price"], weights=x["qty"])) .to_frame("vwap") ) vol = df["qty"].sum().to_frame("vol") cnt = df["qty"].size().to_frame("cnt") if add_time: time = df["time"].last().to_frame("time") res = pd.concat([time, ohlc, vwap, vol, cnt], axis=1) else: res = pd.concat([ohlc, vwap, vol, cnt], axis=1) return res
获取时间条形图:
df_grouped_time = df.groupby(pd.Grouper(key="time", freq="1Min")) time_bars = get_bars(df_grouped_time) time_bars
运行代码生成以下时间条形图:
图 2.14:带有时间条形图的 DataFrame 预览
获取交易条形图:
bar_size = 50 df["tick_group"] = ( pd.Series(list(range(len(df)))) .div(bar_size) .apply(np.floor) .astype(int) .values ) df_grouped_ticks = df.groupby("tick_group") tick_bars = get_bars(df_grouped_ticks, add_time=True) tick_bars
运行代码生成以下交易条形图:
图 2.15:带有交易条形图的 DataFrame 预览
我们可以看到每个组包含的交易正好是 50 笔,正如我们所期望的那样。
获取成交量条形图:
bar_size = 1 df["cum_qty"] = df["qty"].cumsum() df["vol_group"] = ( df["cum_qty"] .div(bar_size) .apply(np.floor) .astype(int) .values ) df_grouped_ticks = df.groupby("vol_group") volume_bars = get_bars(df_grouped_ticks, add_time=True) volume_bars
运行代码生成以下成交量条形图:
图 2.16:带有成交量条的 DataFrame 预览
我们可以看到所有条形图的成交量大致相同。最后一根条形图稍微小一些,因为在 500 笔交易中总成交量不足。
获取美元条形图:
bar_size = 50000 df["cum_value"] = df["quoteQty"].cumsum() df["value_group"] = ( df["cum_value"] .div(bar_size) .apply(np.floor) .astype(int) .values ) df_grouped_ticks = df.groupby("value_group") dollar_bars = get_bars(df_grouped_ticks, add_time=True) dollar_bars
运行代码生成以下美元条形图:
图 2.17:带有美元条形图的 DataFrame 预览
它是如何工作的……
在导入库之后,我们实例化了 Binance 客户端,并使用 Binance 客户端的 trades
方法下载了 BTCEUR
市场中最近的 500 笔交易。我们故意选择了这个市场,因为它不像 BTCUSD
那样流行,默认的 500 笔交易实际上跨越了几分钟。我们可以通过 limit
参数将交易数量增加到 1,000 笔。
我们使用了最简单的方式来下载最近的 500 笔交易。然而,我们可以做得更好,通过重建更长时间段内的交易记录。为此,我们可以使用 historical_trades
方法。该方法包含一个额外的参数 fromId
,我们可以使用该参数指定从哪个特定交易开始下载。然后,我们可以通过使用最后已知的 ID 将这些 API 调用链式连接,重新构建较长时间段的交易历史。然而,要做到这一点,我们需要拥有 Binance 账户,创建个人 API 密钥,并将其提供给 Client
类。
在 步骤 3 中,我们为进一步分析准备了数据,即将 Binance 客户端的响应转换为 pandas
DataFrame,删除了我们不使用的两列,将 time
列转换为 datetime
,并将包含价格和数量的列转换为数值型,因为它们最初是以 object
类型表示的,即字符串。
然后,我们定义了一个辅助函数来计算每个组的条形图。该函数的输入必须是 DataFrameGroupBy
对象,也就是将 groupby
方法应用于 pandas
DataFrame 后的输出。这是因为该函数计算了一些聚合统计值:
使用
ohlc
方法的 OHLC 值。通过应用
np.average
方法并使用交易数量作为weights
参数来计算 成交量加权平均价格(VWAP)。总成交量为交易数量的总和。
使用
size
方法获取每个条形图中的交易数量。可选地,该函数还返回条形图的时间戳,时间戳就是该组的最后一个时间戳。
所有这些都是单独的 DataFrame,最终我们使用 pd.concat
函数将它们连接起来。
在步骤 5中,我们计算了时间条形图。我们必须使用groupby
方法结合pd.Grouper
。我们指定要在time
列上创建分组,并使用一分钟的频率。然后,我们将DataFrameGroupBy
对象传递给我们的get_bars
函数,它返回了时间条形图。
在步骤 6中,我们计算了 tick bars。这个过程与时间条有所不同,因为我们首先需要创建一个列来对交易进行分组。这个想法是将交易按 50 为一组进行分组(这个数字是任意的,应根据分析逻辑来决定)。为了创建这样的分组,我们将行号除以选定的条形大小,向下取整(使用np.floor
),并将结果转换为整数。然后,我们使用新创建的列对交易进行分组,并应用get_bars
函数。
在步骤 7中,我们计算了成交量条形图。这个过程与 tick bars 相似。不同之处在于创建分组列,这次是基于已成交量的累积和。我们选择了 1 BTC 的条形大小。
最后一步是计算美元条形图。这个过程几乎与成交量条形图相同,但我们通过对quoteQty
列应用累积和来创建分组列,而不是之前使用的qty
列。
还有更多……
本食谱中列举的条形类型并不完全。例如,De Prado(2018)建议使用不平衡条形图,它在买卖活动不平衡时采样数据,因为这可能意味着市场参与者之间的信息不对称。这些条形图的逻辑是,市场参与者要么大量购买某一资产,要么大量出售,但他们不会同时做这两件事。因此,在不平衡事件发生时进行采样,有助于聚焦于大幅波动,并减少对没有有趣活动的时期的关注。
另见
De Prado, M. L. (2018). 金融机器学习进展。约翰·威利与儿子公司。
github.com/binance/binance-connector-python
——用于连接到 Binance API 的库的 GitHub 仓库
总结
在本章中,我们学习了如何预处理金融时间序列数据。我们首先展示了如何计算回报并可能进行通胀调整。接着,我们介绍了几种常见的填补缺失值的方法。最后,我们解释了不同的交易数据聚合方法,以及为什么选择正确的聚合方式很重要。
我们应始终重视此步骤,因为我们不仅希望提高模型的表现,还要确保任何分析的有效性。在下一章中,我们将继续处理预处理的数据,并学习如何创建时间序列可视化。
第三章:可视化金融时间序列
俗话说,一图胜千言,这在数据科学领域非常适用。我们可以使用不同类型的图表,不仅仅是为了探索数据,还能讲述基于数据的故事。
在处理金融时间序列数据时,快速绘制序列就能带来许多有价值的见解,例如:
序列是连续的吗?
是否存在意外的缺失值?
一些值看起来像是离群值吗?
是否有任何模式我们可以快速识别并用于进一步分析?
当然,这些仅仅是一些可能有助于我们分析的潜在问题。数据可视化的主要目标是在任何项目开始时让你熟悉数据,进一步了解它。只有这样,我们才能进行适当的统计分析并建立预测序列未来值的机器学习模型。
关于数据可视化,Python 提供了各种库,可以完成这项工作,涵盖不同复杂度(包括学习曲线)和输出质量的差异。一些最受欢迎的可视化库包括:
matplotlib
seaborn
plotly
altair
plotnine
—这个库基于 R 的ggplot
,所以对于那些也熟悉 R 的人来说特别有兴趣。bokeh
在本章中,我们将使用上面提到的许多库。我们认为使用最合适的工具来完成工作是有道理的,因此如果一个库用一行代码就能创建某个图表,而另一个库则需要 20 行代码,那么选择就非常明确。你很可能可以使用任何一个提到的库来创建本章展示的所有可视化。
如果你需要创建一个非常自定义的图表,而这种图表在最流行的库中没有现成的,那么matplotlib
应该是你的选择,因为你几乎可以用它创建任何图表。
在本章中,我们将介绍以下几种配方:
时间序列数据的基本可视化
可视化季节性模式
创建交互式可视化
创建蜡烛图
时间序列数据的基本可视化
可视化时间序列数据的最常见起点是简单的折线图,也就是连接时间序列(y 轴)随时间变化(x 轴)的值的线条。我们可以利用此图快速识别数据中的潜在问题,并查看是否有任何明显的模式。
在本节中,我们将展示创建折线图的最简单方法。为此,我们将下载 2020 年微软的股价。
如何做到……
执行以下步骤以下载、预处理并绘制微软的股价和回报系列:
导入库:
import pandas as pd import numpy as np import yfinance as yf
下载 2020 年微软的股价并计算简单回报:
df = yf.download("MSFT", start="2020-01-01", end="2020-12-31", auto_adjust=False, progress=False) df["simple_rtn"] = df["Adj Close"].pct_change() df = df.dropna()
我们删除了通过计算百分比变化引入的
NaN
值,这只影响第一行。绘制调整后的收盘价:
df["Adj Close"].plot(title="MSFT stock in 2020")
执行上述一行代码会生成以下图表:
图 3.1:微软 2020 年的调整后股票价格
将调整后的收盘价和简单收益绘制在同一张图表中:
( df[["Adj Close", "simple_rtn"]] .plot(subplots=True, sharex=True, title="MSFT stock in 2020") )
运行代码会生成以下图表:
图 3.2:微软 2020 年的调整后股票价格和简单收益
在图 3.2中,我们可以清楚地看到 2020 年初的下跌——这是由 COVID-19 大流行开始引起的——导致收益的波动性(变化性)增加。我们将在接下来的章节中更熟悉波动性。
它是如何工作的……
在导入库之后,我们从 2020 年开始下载了微软的股票价格,并使用调整后的收盘价计算了简单收益。
然后,我们使用pandas
DataFrame 的plot
方法快速创建了一个折线图。我们指定的唯一参数是图表的标题。需要记住的是,我们在从 DataFrame 中子集化出一列数据(实际上是pd.Series
对象)后才使用plot
方法,日期自动被选作 x 轴,因为它们是 DataFrame/Series 的索引。
我们也可以使用更明确的表示法来创建完全相同的图表:
df.plot.line(y="Adj Close", title="MSFT stock in 2020")
plot
方法绝不仅限于创建折线图(默认图表类型)。我们还可以创建直方图、条形图、散点图、饼图等等。要选择这些图表类型,我们需要指定kind
参数,并选择相应的图表类型。请记住,对于某些类型的图表(如散点图),我们可能需要显式提供两个轴的值。
在第 4 步中,我们创建了一个包含两个子图的图表。我们首先选择了感兴趣的列(价格和收益),然后使用plot
方法,指定我们要创建子图,并且这些子图应共享 x 轴。
还有更多内容……
还有许多有趣的内容值得提及,关于创建折线图,但我们将只涵盖以下两点,因为它们在实践中可能是最有用的。
首先,我们可以使用matplotlib
的面向对象接口创建一个类似于前一个的图表:
fig, ax = plt.subplots(2, 1, sharex=True)
df["Adj Close"].plot(ax=ax[0])
ax[0].set(title="MSFT time series",
ylabel="Stock price ($)")
df["simple_rtn"].plot(ax=ax[1])
ax[1].set(ylabel="Return (%)")
plt.show()
运行代码会生成以下图表:
图 3.3:微软 2020 年的调整后股票价格和简单收益
尽管它与之前的图表非常相似,我们在上面加入了一些更多的细节,例如 y 轴标签。
这里有一点非常重要,并且在以后也会非常有用,那就是matplotlib
的面向对象接口。在调用plt.subplots
时,我们指示希望在单列中创建两个子图,并且还指定了它们将共享 x 轴。但真正关键的是函数的输出,即:
一个名为
fig
的Figure
类实例。我们可以将其视为绘图的容器。一个名为
ax
的Axes
类实例(不要与图表的 x 轴和 y 轴混淆)。这些是所有请求的子图。在我们的例子中,我们有两个这样的子图。
图 3.4 展示了图形和坐标轴之间的关系:
图 3.4:matplotlib 中的图形与坐标轴的关系
对于任何图形,我们可以在某种矩阵形式中安排任意数量的子图。我们还可以创建更复杂的配置,其中顶行可能是一个宽大的子图,而底行可能由两个较小的子图组成,每个子图的大小是大子图的一半。
在构建上面的图表时,我们仍然使用了pandas
DataFrame 的plot
方法。不同之处在于,我们明确指定了要在图形中放置子图的位置。我们通过提供ax
参数来实现这一点。当然,我们也可以使用matplotlib
的函数来创建图表,但我们希望节省几行代码。
另一个值得提及的事项是,我们可以将pandas
的绘图后端更改为其他一些库,例如plotly
。我们可以使用以下代码片段实现:
df["Adj Close"].plot(title="MSFT stock in 2020", backend="plotly")
运行代码生成以下交互式图表:
图 3.5:微软 2020 年调整后的股价,使用 plotly 可视化
不幸的是,使用plotly
后端的优势在打印中是看不出来的。在笔记本中,您可以将鼠标悬停在图表上查看精确的数值(以及我们在工具提示中包含的任何其他信息)、放大特定时间段、筛选多条线(如果有的话)等更多功能。请参阅随附的笔记本(在 GitHub 上提供)以测试可视化的交互式功能。
在更改plot
方法的后端时,我们应当注意两点:
我们需要安装相应的库。
一些后端在
plot
方法的某些功能上存在问题,最显著的是subplots
参数。
为了生成前面的图表,我们在创建图表时指定了绘图后端。这意味着,下一个我们创建的图表如果没有明确指定,将使用默认的后端(matplotlib
)。我们可以使用以下代码片段更改整个会话/笔记本的绘图后端:pd.options.plotting.backend = "plotly"
。
另见
matplotlib.org/stable/index.html
—matplotlib
的文档是关于该库的宝贵资料库,特别包含了如何创建自定义可视化的有用教程和提示。
可视化季节性模式
正如我们将在第六章《时间序列分析与预测》中所学到的那样,季节性在时间序列分析中起着非常重要的作用。我们所说的季节性是指在一定时间间隔(通常小于一年)内会重复出现的模式。例如,想象一下冰淇淋的销售,夏季销售通常会达到高峰,而冬季则会下降。这些模式每年都会出现。我们展示了如何使用稍微调整过的折线图来高效地研究这些模式。
在本节中,我们将视觉化调查 2014-2019 年间美国失业率的季节性模式。
如何操作……
执行以下步骤以创建显示季节性模式的折线图:
导入库并进行身份验证:
import pandas as pd import nasdaqdatalink import seaborn as sns nasdaqdatalink.ApiConfig.api_key = "YOUR_KEY_HERE"
从 Nasdaq 数据链接下载并显示失业数据:
df = ( nasdaqdatalink.get(dataset="FRED/UNRATENSA", start_date="2014-01-01", end_date="2019-12-31") .rename(columns={"Value": "unemp_rate"}) ) df.plot(title="Unemployment rate in years 2014-2019")
运行代码会生成以下图表:
图 3.6:2014 至 2019 年美国失业率
失业率表示失业人数占劳动力人口的百分比。该值未做季节性调整,因此我们可以尝试找出一些模式。
在图 3.6中,我们已经可以观察到一些季节性(重复性)模式,例如,每年失业率似乎在 1 月达到最高。
创建包含
year
和month
的新列:df["year"] = df.index.year df["month"] = df.index.strftime("%b")
创建季节性图:
sns.lineplot(data=df, x="month", y="unemp_rate", hue="year", style="year", legend="full", palette="colorblind") plt.title("Unemployment rate - Seasonal plot") plt.legend(bbox_to_anchor=(1.05, 1), loc=2)
运行代码的结果如下图所示:
图 3.7:失业率的季节性图
通过展示每年各月的失业率,我们可以清楚地看到一些季节性模式。例如,最高失业率出现在 1 月,而最低失业率出现在 12 月。此外,似乎每年夏季失业率都有持续上升的趋势。
工作原理……
在第一步中,我们导入了库并与 Nasdaq 数据链接进行了身份验证。第二步,我们下载了 2014-2019 年的失业数据。为了方便起见,我们将Value
列重命名为unemp_rate
。
在第 3 步中,我们创建了两个新列,从索引中提取了年份和月份名称(索引为DatetimeIndex
类型)。
在最后一步中,我们使用了sns.lineplot
函数来创建季节性折线图。我们指定了要在 x 轴上使用月份,并将每一年绘制为一条独立的线(使用hue
参数)。
我们也可以使用其他库创建类似的图表。我们使用了seaborn
(这是matplotlib
的封装)来展示该库。通常,如果你希望在图表中包括一些统计信息,例如在散点图上绘制最佳拟合线,推荐使用seaborn
。
还有更多……
我们已经调查了在图表中调查季节性最简单的方法。在这一部分,我们还将讨论一些其他的可视化方法,这些方法能揭示更多关于季节性模式的信息。
导入库:
from statsmodels.graphics.tsaplots import month_plot, quarter_plot import plotly.express as px
创建月份图:
month_plot(df["unemp_rate"], ylabel="Unemployment rate (%)") plt.title("Unemployment rate - Month plot")
运行代码生成以下图表:
图 3.8:失业率的月度图
月度图是一个简单但富有信息的可视化图表。对于每个月,它绘制了一条独立的线,展示了失业率随时间的变化(虽然没有明确显示时间点)。此外,红色的水平线表示这些月份的平均值。
我们可以通过分析图 3.8得出一些结论:
通过查看平均值,我们可以看到之前描述的模式——在 1 月失业率最高,然后失业率下降,接着在夏季几个月反弹,最后在年底继续下降。
多年来,失业率逐渐下降;然而,在 2019 年,下降幅度似乎比之前几年要小。我们可以通过观察 7 月和 8 月的线条角度来看到这一点。
创建季度图:
quarter_plot(df["unemp_rate"].resample("Q").mean(), ylabel="Unemployment rate (%)") plt.title("Unemployment rate - Quarter plot")
运行代码生成以下图表:
图 3.9:失业率的季度图
季度图与月度图非常相似,唯一的区别是我们在 x 轴上使用季度而不是月份。为了得到这个图表,我们必须通过取每个季度的平均值来重新采样每月的失业率。我们也可以取最后一个值。
使用
plotly.express
创建极坐标季节性图:fig = px.line_polar( df, r="unemp_rate", theta="month", color="year", line_close=True, title="Unemployment rate - Polar seasonal plot", width=600, height=500, range_r=[3, 7] ) fig.show()
运行代码生成以下交互式图表:
图 3.10:失业率的极坐标季节性图
最后,我们创建了季节性图的一种变体,其中我们将线条绘制在极坐标平面上。这意味着极坐标图将数据可视化在径向和角度轴上。我们手动限制了径向范围,设置了range_r=[3, 7]
。否则,图表会从 0 开始,且较难看出线条之间的差异。
我们可以得出的结论与常规季节性图类似,但可能需要一段时间才能适应这种表示方式。例如,通过查看 2014 年,我们可以立即看到失业率在第一季度最高。
创建交互式可视化
在第一个食谱中,我们简要预览了如何在 Python 中创建交互式可视化。在本食谱中,我们将展示如何使用三种不同的库:cufflinks
、plotly
和bokeh
来创建交互式折线图。当然,这些并不是唯一可以用来创建交互式可视化的库。另一个你可能想进一步了解的流行库是altair
。
plotly
库建立在d3.js(一个用于在网页浏览器中创建交互式可视化的 JavaScript 库)之上,因其能够创建高质量的图表并具有高度的交互性(检查观察值、查看某一点的工具提示、缩放等)而闻名。Plotly 还是负责开发该库的公司,并提供我们的可视化托管服务。我们可以创建无限数量的离线可视化,并且可以创建少量的免费可视化分享到网上(每个可视化每天有查看次数限制)。
cufflinks
是一个建立在plotly
之上的包装库。在plotly.express
作为plotly
框架的一部分发布之前,它已经被发布。cufflinks
的主要优势是:
它使绘图比纯粹的
plotly
更容易。它使我们能够直接在
pandas
DataFrame 上创建plotly
可视化。它包含了一系列有趣的专业可视化图表,包括一个针对定量金融的特殊类(我们将在下一节中介绍)。
最后,bokeh
是另一个用于创建交互式可视化的库,特别面向现代网页浏览器。通过使用bokeh
,我们可以创建美观的交互式图形,从简单的折线图到复杂的交互式仪表板,支持流式数据集。bokeh
的可视化由 JavaScript 驱动,但实际的 JavaScript 知识并不是创建可视化的必要条件。
在本节中,我们将使用 2020 年的微软股票价格创建一些交互式折线图。
如何实现…
执行以下步骤以下载微软的股票价格并创建交互式可视化:
导入库并初始化笔记本显示:
import pandas as pd import yfinance as yf import cufflinks as cf from plotly.offline import iplot, init_notebook_mode import plotly.express as px import pandas_bokeh cf.go_offline() pandas_bokeh.output_notebook()
下载 2020 年的微软股票价格并计算简单收益:
df = yf.download("MSFT", start="2020-01-01", end="2020-12-31", auto_adjust=False, progress=False) df["simple_rtn"] = df["Adj Close"].pct_change() df = df.loc[:, ["Adj Close", "simple_rtn"]].dropna() df = df.dropna()
使用
cufflinks
创建图表:df.iplot(subplots=True, shape=(2,1), shared_xaxes=True, title="MSFT time series")
运行代码会生成以下图表:
图 3.11:使用 cufflinks 的时间序列可视化示例
使用
cufflinks
和plotly
生成的图表时,我们可以将鼠标悬停在折线图上,查看包含观察日期和确切值(或任何其他可用信息)的工具提示。我们还可以选择图表的某个部分进行缩放,以便更方便地进行分析。使用
bokeh
创建图表:df["Adj Close"].plot_bokeh(kind="line", rangetool=True, title="MSFT time series")
执行代码会生成以下图表:
图 3.12:使用 Bokeh 可视化的微软调整后的股票价格
默认情况下,
bokeh
图表不仅具有工具提示和缩放功能,还包括范围滑块。我们可以使用它轻松缩小希望在图表中查看的日期范围。使用
plotly.express
创建图表:fig = px.line(data_frame=df, y="Adj Close", title="MSFT time series") fig.show()
运行代码会产生以下可视化效果:
图 3.13:使用 plotly 的时间序列可视化示例
在图 3.13中,您可以看到交互式工具提示的示例,这对于识别分析时间序列中的特定观测值非常有用。
它是如何工作的……
在第一步中,我们导入了库并初始化了bokeh
的notebook
显示和cufflinks
的离线模式。然后,我们下载了 2020 年微软的股价数据,使用调整后的收盘价计算了简单收益率,并仅保留了这两列以供进一步绘图。
在第三步中,我们使用cufflinks
创建了第一个交互式可视化。如介绍中所提到的,得益于cufflinks
,我们可以直接在pandas
DataFrame 上使用iplot
方法。它的工作方式类似于原始的plot
方法。在这里,我们指示要在一列中创建子图,并共享 x 轴。该库处理了其余的部分,并创建了一个漂亮且互动性强的可视化。
在步骤 4中,我们使用bokeh
创建了一个折线图。我们没有使用纯bokeh
库,而是使用了一个围绕 pandas 的官方封装——pandas_bokeh
。得益于此,我们可以直接在pandas
DataFrame 上访问plot_bokeh
方法,从而简化了图表创建的过程。
最后,我们使用了plotly.express
框架,它现在是plotly
库的一部分(之前是一个独立的库)。使用px.line
函数,我们可以轻松地创建一个简单但交互性强的折线图。
还有更多…
在使用可视化讲述故事或向利益相关者或非技术观众展示分析结果时,有一些技巧可以提高图表传达给定信息的能力。注释就是其中一种技巧,我们可以轻松地将它们添加到plotly
生成的图表中(我们也可以在其他库中做到这一点)。
我们在下面展示了所需的步骤:
导入库:
from datetime import date
为
plotly
图表定义注释:selected_date_1 = date(2020, 2, 19) selected_date_2 = date(2020, 3, 23) first_annotation = { "x": selected_date_1, "y": df.query(f"index == '{selected_date_1}'")["Adj Close"].squeeze(), "arrowhead": 5, "text": "COVID decline starting", "font": {"size": 15, "color": "red"}, } second_annotation = { "x": selected_date_2, "y": df.query(f"index == '{selected_date_2}'")["Adj Close"].squeeze(), "arrowhead": 5, "text": "COVID recovery starting", "font": {"size": 15, "color": "green"}, "ax": 150, "ay": 10 }
字典包含了一些值得解释的元素:
x
/y
—注释在 x 轴和 y 轴上的位置text
—注释的文本font
—字体的格式arrowhead
—我们希望使用的箭头形状ax
/ay
—从指定点开始的 x 轴和 y 轴上的偏移量
我们经常使用偏移量来确保注释不会与彼此或图表的其他元素重叠。
定义完注释后,我们可以简单地将它们添加到图表中。
更新图表的布局并显示它:
fig.update_layout( {"annotations": [first_annotation, second_annotation]} ) fig.show()
运行代码片段会生成以下图表:
图 3.14:带有注释的时间序列可视化
使用注释,我们标记了市场因 COVID-19 大流行而开始下跌的日期,以及开始恢复和再次上涨的日期。用于注释的日期是通过查看图表简单选取的。
另见
bokeh.org/
—有关bokeh
的更多信息。altair-viz.github.io/
—你还可以查看altair
,这是另一个流行的 Python 交互式可视化库。plotly.com/python/
—plotly
的 Python 文档。该库也可用于其他编程语言,如 R、MATLAB 或 Julia。
创建蜡烛图
蜡烛图是一种金融图表,用于描述给定证券的价格波动。单个蜡烛图(通常对应一天,但也可以是其他频率)结合了开盘价、最高价、最低价和收盘价(OHLC)。
看涨蜡烛图的元素(在给定时间段内收盘价高于开盘价)如图 3.15所示:
图 3.15:看涨蜡烛图示意图
对于看跌蜡烛图,我们应该交换开盘价和收盘价的位置。通常,我们还会将蜡烛的颜色改为红色。
与前面介绍的图表相比,蜡烛图传达的信息比简单的调整后收盘价折线图要多得多。这就是为什么它们常用于实际交易平台,交易者通过它们识别模式并做出交易决策的原因。
在这个配方中,我们还添加了移动平均线(它是最基本的技术指标之一),以及表示成交量的柱状图。
准备就绪
在本配方中,我们将下载 Twitter 2018 年的(调整后的)股价。我们将使用 Yahoo Finance 下载数据,正如第一章《获取金融数据》中所描述的那样。按照以下步骤获取绘图所需的数据:
导入库:
import pandas as pd import yfinance as yf
下载调整后的价格:
df = yf.download("TWTR", start="2018-01-01", end="2018-12-31", progress=False, auto_adjust=True)
如何实现…
执行以下步骤以创建交互式蜡烛图:
导入库:
import cufflinks as cf from plotly.offline import iplot cf.go_offline()
使用 Twitter 的股价创建蜡烛图:
qf = cf.QuantFig( df, title="Twitter's Stock Price", legend="top", name="Twitter's stock prices in 2018" )
向图表添加成交量和移动平均线:
qf.add_volume() qf.add_sma(periods=20, column="Close", color="red") qf.add_ema(periods=20, color="green")
显示图表:
qf.iplot()
我们可以观察到以下图表(在笔记本中是交互式的):
图 3.16:2018 年 Twitter 股价的蜡烛图
在图表中,我们可以看到指数移动平均(EMA)比简单移动平均(SMA)对价格变化的适应速度更快。图表中的一些不连续性是由于我们使用的是日数据,并且周末/节假日没有数据。
工作原理…
在第一步中,我们导入了所需的库,并指定我们希望使用cufflinks
和plotly
的离线模式。
作为每次运行cf.go_offline()
的替代方法,我们也可以通过运行cf.set_config_file(offline=True)
来修改设置,始终使用离线模式。然后,我们可以使用cf.get_config_file()
查看设置。
在步骤 2中,我们通过传入包含输入数据的 DataFrame 以及一些参数(如标题和图例位置),创建了一个QuantFig
对象的实例。之后,我们本可以直接运行QuantFig
的iplot
方法来创建一个简单的蜡烛图。
在步骤 3中,我们通过使用add_sma
/add_ema
方法添加了两条移动平均线。我们决定考虑 20 个周期(在本例中为天数)。默认情况下,平均值是使用close
列计算的,但我们可以通过提供column
参数来更改此设置。
两条移动平均线的区别在于,指数加权移动平均线对最近的价格赋予了更多的权重。通过这样做,它对新信息更为敏感,并且能更快地对整体趋势的变化做出反应。
最后,我们使用iplot
方法显示了图表。
还有更多…
正如本章引言所提到的,通常在 Python 中执行相同任务有多种方式,通常使用不同的库。我们还将展示如何使用纯plotly
(如果你不想使用像cufflinks
这样的封装库)和mplfinance
(matplotlib
的一个独立扩展,专门用于绘制金融数据)来创建蜡烛图:
导入库:
import plotly.graph_objects as go import mplfinance as mpf
使用纯
plotly
创建蜡烛图:fig = go.Figure(data= go.Candlestick(x=df.index, open=df["Open"], high=df["High"], low=df["Low"], close=df["Close"]) ) fig.update_layout( title="Twitter's stock prices in 2018", yaxis_title="Price ($)" ) fig.show()
运行代码片段会生成以下图表:
图 3.17:使用 plotly 生成的蜡烛图示例
这段代码有点长,但实际上非常简洁。我们需要传入一个
go.Candlestick
类的对象作为图形的data
参数,图形则通过go.Figure
来定义。然后,我们使用update_layout
方法添加了标题和 y 轴标签。plotly
实现的蜡烛图的便利之处在于,它配有一个范围滑块,我们可以用它交互式地缩小显示的蜡烛图范围,从而更详细地查看我们感兴趣的时间段。使用
mplfinance
创建蜡烛图:mpf.plot(df, type="candle", mav=(10, 20), volume=True, style="yahoo", title="Twitter's stock prices in 2018", figsize=(8, 4))
运行代码生成了以下图表:
图 3.18:使用 mplfinance 生成的蜡烛图示例
我们使用了mav
参数来指示我们想要创建两条移动平均线,分别为 10 天和 20 天的平均线。不幸的是,目前无法添加指数加权的变体。不过,我们可以使用mpf.make_addplot
辅助函数向图形中添加额外的图表。我们还指示希望使用类似于 Yahoo Finance 风格的样式。
你可以使用命令mpf.available_styles()
来显示所有可用的样式。
另见
一些有用的参考资料:
github.com/santosjorge/cufflinks
—cufflinks
的 GitHub 仓库github.com/santosjorge/cufflinks/blob/master/cufflinks/quant_figure.py
——cufflinks
的源代码可能对获取更多关于可用方法(不同指标和设置)的信息有帮助。github.com/matplotlib/mplfinance
——mplfinance
的 GitHub 代码库。github.com/matplotlib/mplfinance/blob/master/examples/addplot.ipynb
——这是一个包含如何向mplfinance
生成的图表中添加额外信息的示例的 Notebook。
摘要
在本章中,我们介绍了可视化金融(以及非金融)时间序列的各种方法。绘制数据对于熟悉分析的时间序列非常有帮助。我们可以识别一些模式(例如,趋势或变更点),这些模式可能需要通过统计测试进行验证。数据可视化还可以帮助我们发现序列中的一些异常值(极端值)。这将引出下一章的主题,即自动模式识别和异常值检测。
第四章:探索金融时间序列数据
在前几章中,我们学习了如何预处理和可视化探索金融时间序列数据。这次,我们将使用算法和/或统计测试来自动识别潜在问题(如异常值),并分析数据是否存在趋势或其他模式(例如均值回归)。
我们还将深入探讨资产收益的典型事实。与异常值检测一起,这些方法在处理金融数据时尤为重要。当我们想基于资产价格构建模型/策略时,必须确保它们能够准确捕捉收益的动态变化。
尽管如此,本章中描述的大多数技术不仅限于金融时间序列,也可以在其他领域有效使用。
本章中,我们将涵盖以下方法:
使用滚动统计进行异常值检测
使用汉佩尔滤波器检测异常值
检测时间序列中的变化点
检测时间序列中的趋势
使用赫斯特指数检测时间序列中的模式
调查资产收益的典型事实
使用滚动统计进行异常值检测
在处理任何类型的数据时,我们经常遇到与大多数数据明显不同的观察值,即异常值。在金融领域,它们可能是由于价格错误、金融市场发生重大事件或数据处理管道中的错误引起的。许多机器学习算法和统计方法可能会受到异常值的严重影响,从而导致错误或有偏的结果。因此,在创建任何模型之前,我们应该识别并处理异常值。
在本章中,我们将重点讨论点异常检测,即调查给定观察值是否与其他值相比显得突出。有不同的算法可以识别整个数据序列为异常。
在此方法中,我们介绍了一种相对简单的类似滤波的方法,通过滚动均值和标准差来检测异常值。我们将使用 2019 年至 2020 年期间的特斯拉股价数据。
如何做…
执行以下步骤,使用滚动统计检测异常值并在图表中标出:
导入库:
import pandas as pd import yfinance as yf
下载 2019 年至 2020 年特斯拉的股价,并计算简单收益:
df = yf.download("TSLA", start="2019-01-01", end="2020-12-31", progress=False) df["rtn"] = df["Adj Close"].pct_change() df = df[["rtn"]].copy()
计算 21 天滚动均值和标准差:
df_rolling = df[["rtn"]].rolling(window=21) \ .agg(["mean", "std"]) df_rolling.columns = df_rolling.columns.droplevel()
将滚动数据重新合并到初始 DataFrame 中:
df = df.join(df_rolling)
计算上下限阈值:
N_SIGMAS = 3 df["upper"] = df["mean"] + N_SIGMAS * df["std"] df["lower"] = df["mean"] - N_SIGMAS * df["std"]
使用先前计算的阈值识别异常值:
df["outlier"] = ( (df["rtn"] > df["upper"]) | (df["rtn"] < df["lower"]) )
将收益与阈值一起绘制,并标出异常值:
fig, ax = plt.subplots() df[["rtn", "upper", "lower"]].plot(ax=ax) ax.scatter(df.loc[df["outlier"]].index, df.loc[df["outlier"], "rtn"], color="black", label="outlier") ax.set_title("Tesla's stock returns") ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.show()
运行代码片段生成以下图表:
图 4.1:使用过滤算法识别的异常值
在图表中,我们可以观察到标记为黑点的异常值,并与用于确定这些异常值的阈值一起显示。需要注意的一点是,当两个大(绝对值上)收益值彼此接近时,算法将第一个识别为异常值,而将第二个识别为常规观测值。这可能是因为第一个异常值进入了滚动窗口并影响了移动平均/标准差。我们可以在 2020 年第一季度看到类似的情况。
我们还应该意识到所谓的“幽灵效应”。当一个单一的异常值进入滚动窗口时,它会在该异常值停留在窗口内的时间内,膨胀滚动统计量的值。
它是如何工作的……
在导入库之后,我们下载了特斯拉的股票价格,计算了收益,并仅保留了一个列——收益列,以便进一步分析。
为了识别异常值,我们首先通过使用 21 天滚动窗口来计算移动统计量。我们选择 21 作为窗口大小,因为这是一个月内的平均交易天数,在本例中,我们使用的是日数据。然而,我们可以选择不同的值,这样移动平均值将对变化作出更快或更慢的反应。如果我们认为对特定情况更有意义,也可以使用(指数)加权移动平均。为了实现移动指标,我们使用了rolling
方法和agg
方法结合的pandas
DataFrame。计算完统计量后,我们删除了MultiIndex
中的一个级别,以简化分析。
在应用滚动窗口时,我们使用了之前 21 个观测值来计算统计量。因此,第 22 行的数据可以得到第一个值。通过这种方法,我们避免了将未来的信息“泄露”到算法中。然而,可能存在一些情况下,我们并不介意这种信息泄露。在这些情况下,我们可能想使用居中窗口。这样,使用相同的窗口大小,我们将考虑过去 10 个观测值、当前值和接下来的 10 个未来数据点。为了做到这一点,我们可以使用rolling
方法的center
参数。
在步骤 4中,我们将滚动统计量重新合并到原始 DataFrame 中。然后,我们创建了包含上下决策阈值的额外列。我们决定使用滚动平均值上下方 3 个标准差作为边界。任何超出这些边界的观察值都被视为异常值。我们应该记住,过滤算法的逻辑是基于股票收益服从正态分布的假设。稍后在本章中,我们将看到这个假设在经验上并不成立。在步骤 6中,我们将该条件作为一个单独的列进行编码。
在最后一步,我们可视化了收益序列,并标出了上下决策阈值,同时用黑点标记了异常值。为了使图表更易读,我们将图例移出了绘图区域。
在实际应用中,我们不仅需要识别异常值,还需要对其进行处理,例如将它们限制在最大/最小可接受值内,使用插值值替换它们,或采用其他可能的处理方法。
还有更多内容…
定义函数
在本示例中,我们演示了如何将识别异常值的所有步骤作为对 DataFrame 的单独操作来执行。然而,我们可以快速将所有步骤封装到一个通用函数中,以处理更多的使用场景。下面是一个如何做到这一点的示例:
def identify_outliers(df, column, window_size, n_sigmas):
"""Function for identifying outliers using rolling statistics"""
df = df[[column]].copy()
df_rolling = df.rolling(window=window_size) \
.agg(["mean", "std"])
df_rolling.columns = df_rolling.columns.droplevel()
df = df.join(df_rolling)
df["upper"] = df["mean"] + n_sigmas * df["std"]
df["lower"] = df["mean"] - n_sigmas * df["std"]
return ((df[column] > df["upper"]) | (df[column] < df["lower"]))
该函数返回一个 pd.Series
,其中包含布尔标志,指示给定观测值是否为异常值。使用函数的一个额外好处是,我们可以轻松地通过不同的参数(例如窗口大小和用于创建阈值的标准差倍数)进行实验。
温莎化
另一种处理异常值的流行方法是 温莎化。它基于通过替换数据中的异常值来限制它们对任何潜在计算的影响。通过一个示例可以更容易理解温莎化。90% 的温莎化将会把前 5% 的值替换为第 95 百分位数。同样,底部 5% 的值会被替换为第 5 百分位数。我们可以在 scipy
库中找到相应的 winsorize
函数。
使用 Hampel 滤波器进行异常值检测
我们将介绍另一种用于时间序列异常值检测的算法——Hampel 滤波器。它的目标是识别并可能替换给定序列中的异常值。它使用一个大小为 2x 的居中滑动窗口(给定 x 个前后观测值)遍历整个序列。
对于每个滑动窗口,算法计算中位数和中位数绝对偏差(标准差的一种形式)。
为了使中位数绝对偏差成为标准差的一致估计量,我们必须将其乘以一个常数缩放因子 k,该因子依赖于分布。对于高斯分布,它大约是 1.4826。
类似于之前介绍的算法,如果观测值与窗口的中位数的差异超过了预定的标准差倍数,我们就将其视为异常值。然后,我们可以用窗口的中位数替换这个观测值。
我们可以通过调整算法超参数的不同设置来进行实验。例如,较高的标准差阈值使得滤波器更加宽容,而较低的标准差阈值则会使更多的数据点被归类为异常值。
在本示例中,我们将使用 Hampel 滤波器查看 2019 年至 2020 年特斯拉股价的时间序列中是否有任何观测值可以视为异常值。
如何操作…
执行以下步骤使用 Hampel 滤波器识别异常值:
导入库:
import yfinance as yf from sktime.transformations.series.outlier_detection import HampelFilter
下载 2019 至 2020 年的特斯拉股价并计算简单收益:
df = yf.download("TSLA", start="2019-01-01", end="2020-12-31", progress=False) df["rtn"] = df["Adj Close"].pct_change()
实例化
HampelFilter
类并使用它来检测异常值:hampel_detector = HampelFilter(window_length=10, return_bool=True) df["outlier"] = hampel_detector.fit_transform(df["Adj Close"])
绘制特斯拉的股价并标记异常值:
fig, ax = plt.subplots() df[["Adj Close"]].plot(ax=ax) ax.scatter(df.loc[df["outlier"]].index, df.loc[df["outlier"], "Adj Close"], color="black", label="outlier") ax.set_title("Tesla's stock price") ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.show()
运行代码生成了以下图形:
图 4.2:特斯拉股价及使用汉普尔滤波器识别的异常值
使用汉普尔滤波器,我们识别了七个异常值。乍一看,可能会觉得有点意外,甚至有些反直觉,因为 2020 年 9 月左右最大的波动(涨幅和跌幅)并未被检测到,而是之后出现的一些较小波动被识别为异常值。我们需要记住,这个滤波器使用的是一个中心窗口,因此,在观察波动峰值处的数据时,算法也会查看前后各五个数据点,这其中也包含了一些高值。
它是如何工作的…
前两步是相当标准的——我们导入了库,下载了股票价格,并计算了简单收益。
在第 3 步中,我们实例化了HampelFilter
类的对象。我们使用了来自sktime
库的滤波器实现,后者我们将在第七章,基于机器学习的时间序列预测方法中进一步探讨。我们指定了使用长度为 10 的窗口(前 5 个观察值和后 5 个观察值),并要求滤波器返回一个布尔标志,指示观察值是否为异常值。return_bool
的默认设置会返回一个新序列,其中异常值会被替换为 NaN。这是因为sktime
的作者建议使用该滤波器来识别并去除异常值,然后使用一个配套的Imputer
类来填充缺失值。
sktime
使用的方法与scikit-learn
中可用的方法类似,因此我们首先需要将转换器对象fit
到数据上,然后使用transform
来获得指示观察值是否为异常值的标志。这里,我们通过将fit_transform
方法应用于调整后的收盘价,完成了两步操作。
请参阅第十三章,应用机器学习:识别信用违约,了解更多关于使用scikit-learn
的 fit/transform API 的信息。
在上一步中,我们绘制了股票价格的折线图,并将异常值标记为黑点。
还有更多内容…
为了对比,我们还可以将相同的滤波器应用于使用调整后的收盘价计算的收益。这样我们可以看到算法是否会识别不同的观察值为异常值:
识别股票收益中的异常值:
df["outlier_rtn"] = hampel_detector.fit_transform(df["rtn"])
由于我们已经实例化了
HampelFilter
,所以不需要再次实例化。我们只需将其拟合到新数据(收益)并进行转换,以获得布尔标志。绘制特斯拉的每日收益并标记异常值:
fig, ax = plt.subplots() df[["rtn"]].plot(ax=ax) ax.scatter(df.loc[df["outlier_rtn"]].index, df.loc[df["outlier_rtn"], "rtn"], color="black", label="outlier") ax.set_title("Tesla's stock returns") ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.show()
运行代码生成了以下图形:
图 4.3:特斯拉的股市收益及使用汉普尔滤波器识别的异常值
我们可以立即看到,使用收益而非价格时,算法检测到了更多的异常值。
调查价格和收益中识别出的异常值的重叠情况:
df.query("outlier == True and outlier_rtn == True")
图 4.4:使用价格和收益率识别为离群点的日期
基于价格和收益率,只有一个日期被识别为离群点。
另请参见
异常/离群点检测是数据科学中的一个完整领域,存在许多识别可疑观测值的方法。我们已经介绍了两种特别适用于时间序列问题的算法。然而,通常情况下,有很多可能的异常检测方法。我们将在第十三章中介绍用于时间序列以外数据的离群点检测方法,应用机器学习:识别信用违约。其中一些方法也可以用于时间序列。
这里有一些有趣的异常/离群点检测库:
github.com/linkedin/luminol
—由 LinkedIn 创建的一个库;不幸的是,它目前不再积极维护github.com/twitter/AnomalyDetection
—这个由 Twitter 创建的 R 包非常著名,且已由一些个人贡献者移植到 Python
其他一些参考资料:
Hampel F. R. 1974. “影响曲线及其在稳健估计中的作用。” 美国统计学会期刊,69: 382-393—介绍 Hampel 滤波器的论文
www.sktime.org/en/latest/index.html
—sktime
的文档
检测时间序列中的变化点
变化点可以定义为某一时刻,过程或时间序列的概率分布发生变化的点,例如,当序列中的均值发生变化时。
在本例中,我们将使用CUSUM(累积和)方法来检测时间序列中的均值变化。该方法在实现中有两个步骤:
查找变化点——首先在给定时间序列的中间初始化一个变化点,然后基于选定的点执行 CUSUM 方法。接下来的变化点通过寻找前一个 CUSUM 时间序列的最大值或最小值来定位(具体取决于我们想要找到的变化点方向)。我们将继续这个过程,直到找到一个稳定的变化点或超过最大迭代次数。
测试其统计显著性——使用对数似然比检验来测试给定时间序列的均值是否在识别出的变化点处发生了变化。原假设认为序列的均值没有变化。
关于算法实现的进一步说明:
该算法可用于检测向上和向下的变化点。
该算法最多可以找到一个向上和一个向下的变化点。
默认情况下,仅在拒绝原假设时才会报告变化点。
在底层,使用高斯分布来计算 CUSUM 时间序列值并执行假设检验。
在这个案例中,我们将应用 CUSUM 算法来识别 2020 年苹果股票价格中的变化点。
如何实现…
执行以下步骤以检测苹果公司股票价格中的变化点:
导入库:
import yfinance as yf from kats.detectors.cusum_detection import CUSUMDetector from kats.consts import TimeSeriesData
下载 2020 年苹果公司的股票价格:
df = yf.download("AAPL", start="2020-01-01", end="2020-12-31", progress=False)
仅保留调整后的收盘价格,重置索引,并重命名列:
df = df[["Adj Close"]].reset_index(drop=False) df.columns = ["time", "price"]
将 DataFrame 转换为
TimeSeriesData
对象:tsd = TimeSeriesData(df)
实例化并运行变化点检测器:
cusum_detector = CUSUMDetector(tsd) change_points = cusum_detector.detector( change_directions=["increase"] ) cusum_detector.plot(change_points)
运行代码会生成以下图表:
图 4.5:CUSUM 算法检测到的变化点
我们看到算法选择了最大的跳跃作为变化点。
更详细地调查检测到的变化点:
point, meta = change_points[0] point
返回以下关于检测到的变化点的信息:
TimeSeriesChangePoint(start_time: 2020-07-30 00:00:00, end_time: 2020-07-30 00:00:00, confidence: 1.0)
识别出的变化点发生在 7 月 30 日,确实股票价格从当日的$95.4 跃升至次日的$105.4,主要由于强劲的季度财报。
它是如何工作的…
在第一步中,我们导入了所需的库。为了检测变化点,我们使用了 Facebook 的kats
库。接着,我们获取了 2020 年苹果的股票价格。对于这个分析,我们使用了调整后的收盘价格。
为了使用kats
,我们需要将数据转换为特定格式。因此,在步骤 3中,我们仅保留了调整后的收盘价格,重置了索引而没有删除它(因为我们需要该列),并且重命名了列。需要记住的一点是,包含日期/时间的列必须命名为time
。在步骤 4中,我们将 DataFrame 转换为TimeSeriesData
对象,这是kats
使用的表示形式。
在步骤 5中,我们使用先前创建的数据实例化了CUSUMDetector
。我们没有更改任何默认设置。然后,我们使用detector
方法识别变化点。对于这个分析,我们只关心上升的变化点,因此我们指定了change_directions
参数。最后,我们使用cusum_detector
对象的plot
方法绘制检测到的变化点。需要注意的一点是,我们必须将已识别的变化点作为输入提供给该方法。
在最后一步,我们进一步检查了检测到的变化点。返回的对象是一个包含两个元素的列表:TimeSeriesChangePoint
对象,包含关于变化点的信息,如变化点的日期和算法的置信度,以及一个元数据对象。通过使用后者的__dict__
方法,我们可以获取有关该点的更多信息:变化的方向、变化点前后的均值、似然比检验的 p 值等。
还有更多…
该库还提供了更多有关变点检测的有趣功能。我们将只介绍其中的两种,强烈鼓励你自行进一步探索。
限制检测窗口
第一个方法是限制我们想要查找变点的窗口。我们可以通过 detector
方法的 interest_window
参数来做到这一点。下面,我们只在第 200 到第 250 个观测值之间查找变点(提醒:这是一个交易年份,而非完整的日历年,因此只有大约 252 个观测值)。
缩小我们想要查找变点的窗口:
change_points = cusum_detector.detector(change_directions=["increase"],
interest_window=[200, 250])
cusum_detector.plot(change_points)
我们可以在以下图表中看到修改后的结果:
图 4.6:在序列中的第 200^(th) 到第 250^(th) 个观测值之间识别到变点
除了已识别的变点外,我们还可以看到我们选择的窗口。
使用不同的变点检测算法
kats
库还包含其他有趣的变点检测算法。其之一是 RobustStatDetector
。不深入讲解算法本身,它在识别兴趣点之前会使用移动平均对数据进行平滑处理。该算法的另一个有趣特点是,它可以在一次运行中检测多个变点。
使用另一个算法检测变点(RobustStatDetector
):
from kats.detectors.robust_stat_detection import RobustStatDetector
robust_detector = RobustStatDetector(tsd)
change_points = robust_detector.detector()
robust_detector.plot(change_points)
运行该代码片段会生成以下图表:
图 4.7:使用 RobustStatDetector 识别变点
这次,算法比之前的尝试多检测到了两个变点。
kats
库提供的另一个有趣算法是贝叶斯在线变点检测(BOCPD),我们在参见部分提供了相关参考。
参见
github.com/facebookresearch/Kats
—Facebook Kats 的 GitHub 仓库Page, E. S. 1954. “连续检验方案。”Biometrika 41(1): 100–115
Adams, R. P., & MacKay, D. J. (2007). 贝叶斯在线变点检测。arXiv 预印本 arXiv:0710.3742
时间序列中的趋势检测
在之前的示例中,我们介绍了变点检测。另一类算法可以用于趋势检测,即识别时间序列中的显著且持续的变化。
kats
库提供了一种基于非参数Mann-Kendall(MK)检验的趋势检测算法。该算法会在指定大小的窗口上迭代执行 MK 检验,并返回每个窗口的起始点,在这些窗口中,MK 检验结果显示出统计显著性。
为了检测窗口中是否存在显著的趋势,测试会检查时间序列中的增减是否单调,而不是值变化的幅度。MK 测试使用了一种叫做肯德尔 Tau 的统计量,它的范围从-1 到 1。我们可以这样解读这些值:
-1 表示完全单调下降
1 表示完全单调增加
0 表示序列中没有方向性的趋势
默认情况下,算法只会返回统计上显著的时间段。
你可能会想,为什么要使用算法来检测趋势,当在图表上它们很容易就能看出来呢?这是非常正确的;然而,我们应该记住,使用这些算法的目的是一次性查看多个序列和时间段。我们希望能够大规模地检测趋势,例如,在成百上千的时间序列中找到上升趋势。
在本食谱中,我们将使用趋势检测算法来研究 2020 年 NVIDIA 股票价格中是否存在显著的上升趋势的时间段。
如何实现……
执行以下步骤以检测 2020 年 NVIDIA 股票价格中的上升趋势:
导入库:
import yfinance as yf from kats.consts import TimeSeriesData from kats.detectors.trend_mk import MKDetector
下载 2020 年 NVIDIA 的股票价格:
df = yf.download("NVDA", start="2020-01-01", end="2020-12-31", progress=False)
仅保留调整后的收盘价,重置索引并重命名列:
df = df[["Adj Close"]].reset_index(drop=False) df.columns = ["time", "price"]
将 DataFrame 转换为
TimeSeriesData
对象:tsd = TimeSeriesData(df)
实例化并运行趋势检测器:
trend_detector = MKDetector(tsd, threshold=0.9) time_points = trend_detector.detector( direction="up", window_size=30 )
绘制检测到的时间点:
trend_detector.plot(time_points)
运行这行代码会得到以下图表:
图 4.8:识别出的上升趋势起点
在图 4.8中,我们可以看到许多时间段,之间有一些间隔。需要知道的重要信息是,红色的竖条并不是检测到的窗口,而是许多紧挨在一起的检测到的趋势起点。对我们的数据运行选择的算法配置后,识别出了 95 个上升趋势的时间段,这些时间段显然有很大的重叠。
它是如何工作的……
前四个步骤与之前的做法非常相似,唯一的不同是这次我们下载了 2020 年 NVIDIA 的股票价格。有关准备数据以便使用kats
库的更多信息,请参考之前的食谱。
在步骤 5中,我们实例化了趋势检测器(MKDetector
类),并提供了数据,同时将 Tau 系数的阈值更改为 0.9。这样,我们只获得趋势强度更高的时间段。接着,我们使用detector
方法查找时间点。我们感兴趣的是 30 天窗口内的上升趋势(direction="up"
)。
还有其他一些检测器的参数可以调整。例如,我们可以通过使用freq
参数来指定数据中是否存在季节性。
在步骤 6中,我们绘制了结果。我们还可以详细检查 95 个检测到的点中的每一个。返回的time_points
对象是一个元组列表,每个元组包含一个TimeSeriesChangePoint
对象(包含检测到的趋势期的开始日期)和该点的元数据。在我们的例子中,我们寻找的是 30 天窗口内的上升趋势期。自然地,由于我们识别了多个点,每个点都是该期间的开始,因此这些上升趋势的期间会有很多重叠。如图所示,许多被识别的点是连续的。
另见
Mann, H. B. 1945. “反趋势的非参数检验。”《经济计量学》13: 245-259。
Kendall, M. G. 1948. 秩相关方法。Griffin。
使用赫斯特指数检测时间序列中的模式
在金融领域,许多交易策略基于以下之一:
动量——投资者试图利用现有市场趋势的持续性来确定其持仓
均值回归——投资者假设股票回报和波动性等属性会随着时间的推移回归到其长期平均水平(也称为奥恩斯坦-乌伦贝克过程)
虽然我们可以通过视觉检查相对容易地将时间序列分类为两类之一,但这种方法显然不适用大规模分析。这就是为什么我们可以使用赫斯特指数等方法来识别给定的时间序列(不一定是金融类的)是否呈趋势性、均值回归或仅仅是随机游走。
随机游走是一个过程,其中路径由一系列随机步骤组成。应用于股价时,这表明股价的变化具有相同的分布,并且相互独立。这意味着,股票价格的过去走势(或趋势)不能用来预测其未来走势。欲了解更多信息,请参见第十章,金融中的蒙特卡罗模拟。
赫斯特指数 (H) 是衡量时间序列长期记忆的一个指标,也就是说,它衡量该序列偏离随机游走的程度。赫斯特指数的值在 0 和 1 之间,具有以下解释:
H < 0.5——一个序列是均值回归的。值越接近 0,均值回归过程越强。
H = 0.5——一个序列是几何随机游走。
H > 0.5——一个序列是趋势性的。值越接近 1,趋势越强。
计算赫斯特指数有几种方法。在这个例子中,我们将专注于基于估算扩散行为速率的方法,该方法基于对数价格的方差。对于实际例子,我们将使用 20 年的标准普尔 500 每日价格数据。
如何做……
执行以下步骤,检查标准普尔 500 指数的价格是否呈现趋势性、均值回归性或是随机游走的例子:
导入库:
import yfinance as yf import numpy as np import pandas as pd
下载 2000 到 2019 年间标准普尔 500 的历史价格:
df = yf.download("^GSPC", start="2000-01-01", end="2019-12-31", progress=False) df["Adj Close"].plot(title="S&P 500 (years 2000-2019)")
运行代码生成如下图:
图 4.9:2000 至 2019 年标准普尔 500 指数
我们绘制数据,以便对计算出的赫斯特指数有初步的直觉。
定义一个计算赫斯特指数的函数:
def get_hurst_exponent(ts, max_lag=20): """Returns the Hurst Exponent of the time series""" lags = range(2, max_lag) tau = [np.std(np.subtract(ts[lag:], ts[:-lag])) for lag in lags] hurst_exp = np.polyfit(np.log(lags), np.log(tau), 1)[0] return hurst_exp
使用不同的
max_lag
参数值计算赫斯特指数的值:for lag in [20, 100, 250, 500, 1000]: hurst_exp = get_hurst_exponent(df["Adj Close"].values, lag) print(f"Hurst exponent with {lag} lags: {hurst_exp:.4f}")
这将返回以下内容:
Hurst exponent with 20 lags: 0.4478 Hurst exponent with 100 lags: 0.4512 Hurst exponent with 250 lags: 0.4917 Hurst exponent with 500 lags: 0.5265 Hurst exponent with 1000 lags: 0.5180
我们包含的滞后期数越多,越接近得出标准普尔 500 序列是随机游走的结论。
将数据缩小到 2005 至 2007 年,并重新计算赫斯特指数:
shorter_series = df.loc["2005":"2007", "Adj Close"].values for lag in [20, 100, 250, 500]: hurst_exp = get_hurst_exponent(shorter_series, lag) print(f"Hurst exponent with {lag} lags: {hurst_exp:.4f}")
这将返回以下内容:
Hurst exponent with 20 lags: 0.3989 Hurst exponent with 100 lags: 0.3215 Hurst exponent with 250 lags: 0.2507 Hurst exponent with 500 lags: 0.1258
看起来 2005 到 2007 年期间的序列具有均值回归特性。作为参考,讨论的时间序列如下所示:
图 4.10:2005 至 2007 年标准普尔 500 指数
它是如何工作的……
在导入所需的库之后,我们从雅虎财经下载了 20 年的每日标准普尔 500 指数价格。从图表来看,很难判断该时间序列是纯粹的趋势型、均值回归型,还是随机游走型。尤其是在序列的后半段,似乎有一个明显的上升趋势。
在步骤 3中,我们定义了一个用于计算赫斯特指数的函数。对于这种方法,我们需要提供用于计算的最大滞后期数。正如我们稍后将看到的,这个参数对结果有很大影响。
赫斯特指数的计算可以总结为两个步骤:
对于考虑的每个滞后期,我们计算差分序列的标准差(我们将在第六章“时间序列分析与预测”中更详细地讨论差分)。
计算滞后期与标准差的对数图的斜率,以获取赫斯特指数。
在步骤 4中,我们计算并打印了不同max_lag
参数值范围内的赫斯特指数。对于较低的参数值,序列可以被视为稍微具有均值回归特性。随着参数值的增加,解释倾向于认为该序列是一个随机游走。
在步骤 5中,我们进行了类似的实验,但这次是在一个限制的时间序列上进行的。我们只查看了 2005 至 2007 年的数据。由于在限制时间序列中没有足够的观察值,我们还不得不去除max_lag
为 1000 的情况。正如我们所见,结果比之前有所变化,从max_lag
为 20 时的 0.4 变化为 500 滞后期时的 0.13。
在使用赫斯特指数进行分析时,我们应记住,结果可能会因以下因素而有所不同:
我们用来计算赫斯特指数的方法
max_lag
参数的值我们正在观察的时期——局部模式可能与全局模式有很大不同
还有更多……
正如我们在介绍中提到的,计算赫斯特指数的方法有多种。另一个相当流行的方法是使用重标范围(R/S)分析。简要的文献回顾表明,与自相关分析、方差比率分析等其他方法相比,使用 R/S 统计量能取得更好的结果。该方法的一个可能缺点是它对短期依赖关系非常敏感。
要实现基于重标范围分析的赫斯特指数,您可以查看 hurst
库。
另见
github.com/Mottl/hurst
——hurst
库的仓库Hurst, H. E. 1951 年。“水库的长期储存能力。” ASCE Transactions 116(1): 770–808
Kroha, P., & Skoula, M. 2018 年 3 月. 赫斯特指数与基于市场时间序列的交易信号。载于 ICEIS (1): 371–378
研究资产收益的典型事实
典型事实是许多实证资产收益(跨时间和市场)中存在的统计性质。了解这些性质很重要,因为当我们构建应能表示资产价格动态的模型时,模型应能捕捉/复现这些特性。
在这个案例中,我们使用 2000 到 2020 年间的日度标准普尔 500 指数收益数据来研究五个典型事实。
准备工作
由于这是一个较长的案例,并包含更多的子部分,我们将在本节中导入所需的库并准备数据:
导入所需的库:
import pandas as pd import numpy as np import yfinance as yf import seaborn as sns import scipy.stats as scs import statsmodels.api as sm import statsmodels.tsa.api as smt
下载标准普尔 500 指数数据并计算收益:
df = yf.download("^GSPC", start="2000-01-01", end="2020-12-31", progress=False) df = df[["Adj Close"]].rename( columns={"Adj Close": "adj_close"} ) df["log_rtn"] = np.log(df["adj_close"]/df["adj_close"].shift(1)) df = df[["adj_close", "log_rtn"]].dropna()
如何实现…
在本节中,我们将依次研究标准普尔 500 指数收益系列中的五个典型事实:
事实 1:收益的非高斯分布
文献中观察到,(日度)资产收益呈现出以下特征:
负偏度(第三矩):大负收益出现的频率大于大正收益
过度峰度(四阶矩):大(和小)收益比在正态分布下预期的发生得更频繁
矩是描述概率分布的一组统计量。前四个矩如下:期望值(均值)、方差、偏度和峰度。
运行以下步骤,通过绘制收益的直方图和分位数-分位数(Q-Q)图,来研究第一个事实的存在。
使用观测收益的均值和标准差计算正态概率密度函数(PDF):
r_range = np.linspace(min(df["log_rtn"]), max(df["log_rtn"]), num=1000) mu = df["log_rtn"].mean() sigma = df["log_rtn"].std() norm_pdf = scs.norm.pdf(r_range, loc=mu, scale=sigma)
绘制直方图和 Q-Q 图:
fig, ax = plt.subplots(1, 2, figsize=(16, 8)) # histogram sns.distplot(df.log_rtn, kde=False, norm_hist=True, ax=ax[0]) ax[0].set_title("Distribution of S&P 500 returns", fontsize=16) ax[0].plot(r_range, norm_pdf, "g", lw=2, label=f"N({mu:.2f}, {sigma**2:.4f})") ax[0].legend(loc="upper left") # Q-Q plot qq = sm.qqplot(df.log_rtn.values, line="s", ax=ax[1]) ax[1].set_title("Q-Q plot", fontsize=16) plt.show()
执行代码后会得到以下图表:
图 4.11:使用直方图和 Q-Q 图可视化标准普尔 500 指数收益的分布
我们可以使用直方图(展示分布的形状)和 Q-Q 图来评估对数收益的正态性。此外,我们还可以打印总结统计量(请参考 GitHub 仓库中的代码):
---------- Descriptive Statistics ----------
Range of dates: 2000-01-03 – 2020-12-30
Number of observations: 5283
Mean: 0.0002
Median: 0.0006
Min: -0.1277
Max: 0.1096
Standard Deviation: 0.0126
Skewness: -0.3931
Kurtosis: 10.9531
Jarque-Bera statistic: 26489.07 with p-value: 0.00
通过查看均值、标准差、偏度和峰度等度量,我们可以推断它们偏离了我们在正态分布下的预期。标准正态分布的四个矩分别是 0、1、0 和 0。此外,Jarque-Bera 正态性检验使我们有理由拒绝原假设,并表明该分布在 99%的置信水平下是正态的。
回报不服从正态分布这一事实至关重要,因为许多统计模型和方法假设随机变量服从正态分布。
事实 2:波动率聚集
波动率聚集是指价格的大幅变动往往会被随后的大幅变动(高波动期)所跟随,而价格的小幅变动则会被小幅变动(低波动期)所跟随。
运行以下代码以通过绘制对数回报序列来调查第二个事实:
(
df["log_rtn"]
.plot(title="Daily S&P 500 returns", figsize=(10, 6))
)
执行代码后会得到以下图表:
图 4.12:标准普尔 500 指数回报中的波动率聚集示例
我们可以观察到波动率的明显聚集——高正回报和负回报的时期。波动率并非恒定,而且它的变化有一些模式,这一点在我们尝试预测波动率时非常有用,例如使用 GARCH 模型。更多信息,请参考第九章,使用 GARCH 类模型进行波动率建模。
事实 3:回报中不存在自相关
自相关(也称为序列相关性)衡量的是给定时间序列与其滞后版本在连续时间间隔中的相似程度。
在下文中,我们通过陈述回报中不存在自相关来调查第三个事实:
定义用于创建自相关图的参数:
N_LAGS = 50 SIGNIFICANCE_LEVEL = 0.05
运行以下代码以创建对数收益的自相关函数(ACF)图:
acf = smt.graphics.plot_acf(df["log_rtn"], lags=N_LAGS, alpha=SIGNIFICANCE_LEVEL) plt.show()
执行该代码片段后会得到以下图表:
图 4.13:标准普尔 500 指数回报的自相关函数图
只有少数值位于置信区间之外(我们不看滞后期 0),并且可以认为是统计上显著的。我们可以假设已经验证了对数收益序列中不存在自相关。
事实 4:平方/绝对回报中存在小且递减的自相关
虽然我们期望回报序列中没有自相关,但通过实证研究已经证明,在回报的简单非线性函数中,例如绝对值或平方回报,能够观察到小而缓慢衰减的自相关(也称为持久性)。这一现象与我们已经研究过的现象有关,即波动率聚集。
平方收益的自相关函数是衡量波动性聚集的常用指标。它也被称为 ARCH 效应,因为它是(G)ARCH 模型的关键组成部分,我们将在第九章《使用 GARCH 类模型建模波动性》中讨论。然而,我们应该记住,这一特性是无模型的,并且不仅仅与 GARCH 类模型相关。
我们可以通过创建平方和绝对收益的自相关函数(ACF)图来研究第四个事实:
fig, ax = plt.subplots(2, 1, figsize=(12, 10))
smt.graphics.plot_acf(df["log_rtn"]**2, lags=N_LAGS,
alpha=SIGNIFICANCE_LEVEL, ax=ax[0])
ax[0].set(title="Autocorrelation Plots",
ylabel="Squared Returns")
smt.graphics.plot_acf(np.abs(df["log_rtn"]), lags=N_LAGS,
alpha=SIGNIFICANCE_LEVEL, ax=ax[1])
ax[1].set(ylabel="Absolute Returns",
xlabel="Lag")
plt.show()
执行代码后生成以下图表:
图 4.14:平方和绝对收益的 ACF 图
我们可以观察到平方和绝对收益的自相关值较小且逐渐减小,这与第四个风格化事实一致。
事实 5:杠杆效应
杠杆效应指的是资产波动性的多数衡量标准与其收益呈负相关的事实。
执行以下步骤来调查标准普尔 500 指数收益序列中杠杆效应的存在:
计算波动性度量作为移动标准差:
df["moving_std_252"] = df[["log_rtn"]].rolling(window=252).std() df["moving_std_21"] = df[["log_rtn"]].rolling(window=21).std()
绘制所有序列:
fig, ax = plt.subplots(3, 1, figsize=(18, 15), sharex=True) df["adj_close"].plot(ax=ax[0]) ax[0].set(title="S&P 500 time series", ylabel="Price ($)") df["log_rtn"].plot(ax=ax[1]) ax[1].set(ylabel="Log returns") df["rolling_std_252"].plot(ax=ax[2], color="r", label="Rolling Volatility 252d") df["rolling_std_21"].plot(ax=ax[2], color="g", label="Rolling Volatility 21d") ax[2].set(ylabel="Moving Volatility", xlabel="Date") ax[2].legend() plt.show()
我们现在可以通过直观地将价格序列与(滚动)波动性指标进行比较来研究杠杆效应:
图 4.15:标准普尔 500 指数收益的滚动波动性指标
在图 4.15中,我们可以观察到一个模式,即价格下跌时波动性增加,价格上涨时波动性减小。这个观察结果符合该事实的定义。
它是如何工作的……
在本节中,我们描述了我们用于调查标准普尔 500 指数对数收益序列中风格化事实存在性的几种方法。
事实 1:收益的非高斯分布
我们将把调查这一事实分为三部分。
收益的直方图
调查这一事实的第一步是通过可视化收益分布来绘制直方图。为此,我们使用了sns.distplot
,同时设置kde=False
(这不会使用高斯核密度估计)和norm_hist=True
(此图显示密度而不是计数)。
为了查看我们的直方图与高斯分布之间的差异,我们叠加了一条线,表示考虑的收益序列的均值和标准差所对应的高斯分布的 PDF。
首先,我们使用np.linspace
指定了计算 PDF 的范围(我们设置了 1000 个点;通常来说,点数越多,线条越平滑),然后使用scs.norm.pdf
函数计算 PDF。默认参数对应标准正态分布,即均值为零,方差为单位方差。因此,我们分别将loc
和scale
参数指定为样本均值和标准差。
为了验证之前提到的模式,我们应该查看以下内容:
负偏度:分布的左尾较长,而分布的质量集中在分布的右侧
过度峰度:肥尾和尖峰分布
第二点在我们的图中更容易观察到,因为在概率密度函数(PDF)上有一个明显的峰值,并且我们在尾部看到更多的质量。
Q-Q 图
在检查完直方图后,我们查看了 Q-Q 图,在该图中我们通过将两个分布(理论分布和观测分布)的分位数相互比较来绘制它们。在我们的案例中,理论分布是高斯分布(正态分布),而观测分布来自标准普尔 500 指数的收益。
为了获得该图,我们使用了 sm.qqplot
函数。如果经验分布是正态分布,则绝大多数点将位于红线上。然而,我们看到并非如此,因为图的左侧的点比预期的高斯分布中的点更负(即,较低的经验分位数比预期值小)(由线条表示)。这意味着收益分布的左尾比高斯分布的左尾更重。对右尾也可以得出类似的结论,它比正态分布下的右尾更重。
描述性统计
最后一部分涉及查看一些统计数据。我们使用 pandas
Series/DataFrame 的适当方法进行了计算。我们立即看到收益表现出负偏度和过度峰度。我们还进行了 Jarque-Bera 检验(scs.jarque_bera
),以验证收益是否符合高斯分布。由于 p 值为零,我们拒绝了样本数据具有与高斯分布相匹配的偏度和峰度的零假设。
pandas
对峰度的实现是文献中所称的过度峰度或费舍尔峰度。使用此度量,高斯分布的过度峰度为 0,而标准峰度为 3。这不应与样式化事实的过度峰度名称混淆,后者仅指峰度高于正态分布的情况。
事实 2:波动率聚集
另一个我们在调查样式化事实时应当注意的因素是波动率聚集——高收益期与低收益期交替出现,这表明波动率不是恒定的。为了快速调查这一事实,我们使用 pandas
DataFrame 的 plot
方法绘制了收益图。
事实 3:收益中不存在自相关
为了调查收益中是否存在显著的自相关,我们使用 statsmodels
库的 plot_acf
创建了自相关图。我们检查了 50 个滞后,并使用默认的 alpha=0.05
,这意味着我们还绘制了 95% 置信区间。超出该区间的值可以被认为是统计显著的。
事实 4:平方/绝对收益中的小且递减的自相关
为了验证这一事实,我们还使用了statsmodels
库中的plot_acf
函数。然而,这一次,我们将其应用于平方和绝对收益。
事实 5:杠杆效应
这一事实表明,大多数资产波动性的度量与其收益率呈负相关。为了验证这一点,我们使用了移动标准差(通过pandas
DataFrame 的rolling
方法计算)作为历史波动性的度量。我们使用了 21 天和 252 天的窗口,分别对应一个月和一年的交易数据。
还有更多……
我们提出了另一种调查杠杆效应(事实 5)的方法。为此,我们使用了 VIX(CBOE 波动率指数),这是衡量股票市场对波动性预期的一个流行指标。这个度量是由标准普尔 500 指数的期权价格隐含出来的。我们采取以下步骤:
下载并预处理标准普尔 500 指数和 VIX 的价格数据:
df = yf.download(["^GSPC", "^VIX"], start="2000-01-01", end="2020-12-31", progress=False) df = df[["Adj Close"]] df.columns = df.columns.droplevel(0) df = df.rename(columns={"^GSPC": "sp500", "^VIX": "vix"})
计算对数收益(我们同样可以使用简单收益):
df["log_rtn"] = np.log(df["sp500"] / df["sp500"].shift(1)) df["vol_rtn"] = np.log(df["vix"] / df["vix"].shift(1)) df.dropna(how="any", axis=0, inplace=True)
绘制一个散点图,将收益率放在坐标轴上,并拟合回归线以识别趋势:
corr_coeff = df.log_rtn.corr(df.vol_rtn) ax = sns.regplot(x="log_rtn", y="vol_rtn", data=df, line_kws={"color": "red"}) ax.set(title=f"S&P 500 vs. VIX ($\\rho$ = {corr_coeff:.2f})", ylabel="VIX log returns", xlabel="S&P 500 log returns") plt.show()
我们还计算了两条序列之间的相关系数,并将其包含在标题中:
图 4.16:调查标准普尔 500 指数和 VIX 收益之间的关系
我们可以看到,回归线的负斜率以及两条序列之间的强负相关性确认了收益序列中杠杆效应的存在。
另见
更多信息,请参阅以下内容:
- Cont, R. 2001. “资产收益的经验属性:典型事实与统计问题。” 定量金融,1(2): 223
总结
在本章中,我们学习了如何使用一系列算法和统计检验自动识别金融时间序列中的潜在模式和问题(例如,异常值)。有了它们的帮助,我们可以将分析规模扩大到任意数量的资产,而不必手动检查每一条时间序列。
我们还解释了资产收益的典型事实。这些事实非常重要,因为许多模型或策略假设感兴趣变量的某种分布。最常见的假设是正态分布。正如我们所见,经验资产收益并非呈正态分布。因此,在处理这种时间序列时,我们必须采取一些预防措施,以确保我们的分析有效。
在下一章中,我们将探讨广受欢迎的技术分析领域,并查看通过分析资产价格的模式,我们能获得哪些见解。
加入我们的 Discord!
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描以下二维码:
第五章:技术分析与构建交互式仪表板
本章我们将介绍如何在 Python 中进行技术分析(TA)的基础知识。简而言之,技术分析是一种通过研究过去的市场数据(特别是价格本身和交易量)来确定(预测)资产价格未来走势,并识别投资机会的方法论。
我们首先展示如何计算一些最流行的技术分析指标(并提供如何使用选定的 Python 库计算其他指标的提示)。此外,我们还展示如何从可靠的金融数据提供商那里下载预先计算好的技术指标。我们还涉及技术分析的一个子领域——蜡烛图形态识别。
在本章的最后,我们展示如何创建一个 Web 应用,使我们能够以交互的方式可视化和检查预定义的技术分析指标。然后,我们将这个应用部署到云端,使任何人都能随时随地访问。
在本章中,我们介绍以下几个任务:
计算最流行的技术指标
下载技术指标
识别蜡烛图形态
使用 Streamlit 构建交互式技术分析 Web 应用
部署技术分析应用
计算最流行的技术指标
有数百种不同的技术指标,交易者用它们来决定是否进入或退出某个仓位。在本节中,我们将学习如何使用TA-Lib
库轻松计算其中一些技术指标,TA-Lib
是最流行的此类任务库。我们将从简要介绍几种精选指标开始。
布林带是一种统计方法,用于推导某一资产的价格和波动性随时间变化的信息。为了获得布林带,我们需要计算时间序列(价格)的移动平均和标准差,使用指定的窗口(通常为 20 天)。然后,我们将上轨/下轨设置为K倍(通常为 2)移动标准差,位于移动平均线的上方/下方。布林带的解释非常简单:带宽随着波动性的增加而扩大,随着波动性的减少而收缩。
使用 2 个标准差作为布林带的默认设置与关于收益率正态分布的(经验性错误的)假设有关。在高斯分布下,我们假设使用 2 个标准差时,95%的收益率会落在布林带内。
相对强弱指数(RSI)是一种指标,利用资产的收盘价来识别超卖/超买的状态。通常,RSI 是使用 14 日周期计算的,并且在 0 到 100 的范围内测量(它是一个振荡器)。交易者通常在资产超卖时买入(如果 RSI 低于 30),在资产超买时卖出(如果 RSI 高于 70)。更极端的高/低水平,如 80-20,较少使用,同时意味着更强的动量。
最后考虑的指标是移动平均收敛/发散(MACD)。它是一个动量指标,显示了给定资产价格的两条指数移动平均线(EMA)之间的关系,通常是 26 日和 12 日的 EMA。MACD 线是快速(短期)和慢速(长期)EMA 之间的差值。最后,我们将 MACD 信号线计算为 MACD 线的 9 日 EMA。交易者可以利用这些线的交叉作为交易信号。例如,当 MACD 线从下方穿越信号线时,可以视为买入信号。
自然地,大多数指标并不是单独使用的,交易者在做出决策之前会参考多个信号。此外,所有指标都可以进一步调整(通过改变其参数),以实现特定的目标。我们将在另一个章节中讨论基于技术指标的交易策略回测。
如何进行…
执行以下步骤,使用 2020 年的 IBM 股票价格计算一些最流行的技术指标:
导入库:
import pandas as pd import yfinance as yf import talib
TA-Lib
与大多数 Python 库不同,其安装过程略有不同。有关如何操作的更多信息,请参阅另见部分提供的 GitHub 仓库。下载 2020 年的 IBM 股票价格:
df = yf.download("IBM", start="2020-01-01", end="2020-12-31", progress=False, auto_adjust=True)
计算并绘制简单移动平均线(SMA):
df["sma_20"] = talib.SMA(df["Close"], timeperiod=20) ( df[["Close", "sma_20"]] .plot(title="20-day Simple Moving Average (SMA)") )
运行代码片段生成以下图表:
图 5.1:IBM 的收盘价和 20 日 SMA
计算并绘制布林带:
df["bb_up"], df["bb_mid"], df["bb_low"] = talib.BBANDS(df["Close"]) fig, ax = plt.subplots() ( df.loc[:, ["Close", "bb_up", "bb_mid", "bb_low"]] .plot(ax=ax, title="Bollinger Bands") ) ax.fill_between(df.index, df["bb_low"], df["bb_up"], color="gray", alpha=.4)
运行代码片段生成以下图表:
图 5.2:IBM 的收盘价和布林带
计算并绘制 RSI:
df["rsi"] = talib.RSI(df["Close"]) fig, ax = plt.subplots() df["rsi"].plot(ax=ax, title="Relative Strength Index (RSI)") ax.hlines(y=30, xmin=df.index.min(), xmax=df.index.max(), color="red") ax.hlines(y=70, xmin=df.index.min(), xmax=df.index.max(), color="red") plt.show()
运行代码片段生成以下图表:
图 5.3:使用 IBM 收盘价计算的 RSI
计算并绘制 MACD:
df["macd"], df["macdsignal"], df["macdhist"] = talib.MACD( df["Close"], fastperiod=12, slowperiod=26, signalperiod=9 ) fig, ax = plt.subplots(2, 1, sharex=True) ( df[["macd", "macdsignal"]]. plot(ax=ax[0], title="Moving Average Convergence Divergence (MACD)") ) ax[1].bar(df.index, df["macdhist"].values, label="macd_hist") ax[1].legend()
运行代码片段生成以下图表:
图 5.4:使用 IBM 收盘价计算的 MACD
到目前为止,我们已经计算了技术指标并将其绘制出来。在接下来的章节中,我们将更多地讨论它们的含义,并基于这些指标构建交易策略。
它是如何工作的…
导入库后,我们下载了 2020 年的 IBM 股票价格。
在第 3 步中,我们使用SMA
函数计算了 20 日简单移动平均线。自然地,我们也可以通过使用pandas
数据框的rolling
方法来计算相同的指标。
在第 4 步中,我们计算了布林带。BBANDS
函数返回了三个对象(上限、下限和移动平均线),我们将它们分配到了 DataFrame 的不同列中。
在下一步中,我们使用默认设置计算了 RSI。我们绘制了这个指标,并添加了两条水平线(使用ax.hlines
创建),表示常用的决策阈值。
在最后一步,我们也使用默认的 EMA 周期数计算了 MACD。MACD
函数也返回了三个对象:MACD 线、信号线和 MACD 直方图,这实际上是前两者的差值。我们将它们分别绘制在不同的图表上,这是交易平台上最常见的做法。
还有更多内容…
TA-Lib
是一个非常棒的库,在计算技术指标方面是黄金标准。然而,市面上也有一些替代库,正在逐渐获得关注。其中一个叫做ta
。与TA-Lib
(一个 C++库的封装)相比,ta
是使用pandas
编写的,这使得探索代码库变得更加容易。
尽管它的功能不如TA-Lib
那样广泛,但它的一个独特功能是可以在一行代码中计算所有 30 多个可用的指标。这在我们想要为机器学习模型计算大量潜在特征时绝对很有用。
执行以下步骤,用一行代码计算 30 多个技术指标:
导入库:
from ta import add_all_ta_features
丢弃之前计算的指标,仅保留所需的列:
df = df[["Open", "High", "Low", "Close", "Volume"]].copy()
计算
ta
库中所有可用的技术指标:df = add_all_ta_features(df, open="Open", high="High", low="Low", close="Close", volume="Volume")
最终的 DataFrame 包含 88 列,其中 83 列是通过一次函数调用添加的。
另见
以下是TA-Lib
、ta
及其他一些有助于技术分析的有趣库的 GitHub 仓库链接:
github.com/mrjbq7/ta-lib
——TA-lib
的 GitHub 仓库。请参考此资源了解更多关于库的安装细节。
下载技术指标
我们已经在第一章,获取财务数据中提到过,某些数据提供商不仅提供历史股价,还提供一些最流行的技术指标。在本食谱中,我们将展示如何下载 IBM 股票的 RSI 指标,并且可以将其与我们在上一节中使用TA-Lib
库计算的 RSI 进行直接对比。
如何操作…
执行以下步骤从 Alpha Vantage 下载计算好的 IBM RSI:
导入库:
from alpha_vantage.techindicators import TechIndicators
实例化
TechIndicators
类并进行身份验证:ta_api = TechIndicators(key="YOUR_KEY_HERE", output_format="pandas")
下载 IBM 股票的 RSI:
rsi_df, rsi_meta = ta_api.get_rsi(symbol="IBM", time_period=14)
绘制下载的 RSI:
fig, ax = plt.subplots() rsi_df.plot(ax=ax, title="RSI downloaded from Alpha Vantage") ax.hlines(y=30, xmin=rsi_df.index.min(), xmax=rsi_df.index.max(), color="red") ax.hlines(y=70, xmin=rsi_df.index.min(), xmax=rsi_df.index.max(), color="red")
运行代码片段生成以下图表:
图 5.5:下载的 IBM 股票价格的 RSI
下载的 DataFrame 包含了从 1999 年 11 月到最新日期的 RSI 值。
探索元数据对象:
rsi_meta
通过显示元数据对象,我们可以看到以下请求的详细信息:
{'1: Symbol': 'IBM', '2: Indicator': 'Relative Strength Index (RSI)', '3: Last Refreshed': '2022-02-25', '4: Interval': 'daily', '5: Time Period': 14, '6: Series Type': 'close', '7: Time Zone': 'US/Eastern Time'}
工作原理…
在导入库之后,我们实例化了TechIndicators
类,该类可以用来下载任何可用的技术指标(通过该类的方法)。在此过程中,我们提供了 API 密钥,并表明希望以pandas
DataFrame 的形式接收输出结果。
在第 3 步中,我们使用get_rsi
方法下载了 IBM 股票的 RSI。在此步骤中,我们指定了希望使用过去 14 天的数据来计算指标。
下载计算指标时需要注意的一点是数据供应商的定价政策。在撰写本文时,Alpha Vantage 的 RSI 端点是免费的,而 MACD 则是付费端点,需要购买付费计划。
令人有些惊讶的是,我们无法指定感兴趣的日期范围。我们可以在第 4 步中清楚地看到这一点,在该步骤中,我们看到数据点可以追溯到 1999 年 11 月。我们还绘制了 RSI 线,就像我们在之前的食谱中做的一样。
在最后一步,我们探讨了请求的元数据,其中包含了 RSI 的参数、我们请求的股票代码、最新的刷新日期,以及用于计算指标的价格序列(在本例中是收盘价)。
还有更多…
Alpha Vantage 并不是唯一提供技术指标访问的数据供应商。另一个供应商是 Intrinio。我们在下文演示了如何通过其 API 下载 MACD:
导入库:
import intrinio_sdk as intrinio import pandas as pd
使用个人 API 密钥进行身份验证并选择 API:
intrinio.ApiClient().set_api_key("YOUR_KEY_HERE") security_api = intrinio.SecurityApi()
请求 2020 年 IBM 股票的 MACD:
r = security_api.get_security_price_technicals_macd( identifier="IBM", fast_period=12, slow_period=26, signal_period=9, price_key="close", start_date="2020-01-01", end_date="2020-12-31", page_size=500 )
使用 Intrinio 时,我们实际上可以指定想要下载指标的周期。
将请求的输出转换为
pandas
DataFrame:macd_df = ( pd.DataFrame(r.technicals_dict) .sort_values("date_time") .set_index("date_time") ) macd_df.index = pd.to_datetime(macd_df.index).date
绘制 MACD:
fig, ax = plt.subplots(2, 1, sharex=True) ( macd_df[["macd_line", "signal_line"]] .plot(ax=ax[0], title="MACD downloaded from Intrinio") ) ax[1].bar(df.index, macd_df["macd_histogram"].values, label="macd_hist") ax[1].legend()
运行代码片段生成以下图表:
图 5.6:下载的 IBM 股票价格的 MACD
识别蜡烛图模式
在本章中,我们已经介绍了一些最受欢迎的技术指标。另一个可以用于做出交易决策的技术分析领域是蜡烛图模式识别。总体来说,有数百种蜡烛图模式可以用来判断价格的方向和动能。
与所有技术分析方法类似,在使用模式识别时,我们需要牢记几点。首先,模式只在给定图表的限制条件下有效(在指定的频率下:例如日内、日线、周线等)。其次,模式的预测效能在模式完成后会迅速下降,通常在几个(3–5)K 线之后效果减弱。第三,在现代电子环境中,通过分析蜡烛图模式识别出的许多信号可能不再可靠。部分大玩家也能通过制造虚假的蜡烛图模式设下陷阱,诱使其他市场参与者跟进。
Bulkowski(2021)根据预期结果将模式分为两类:
反转模式—此类模式预测价格方向的变化
延续模式—此类模式预测当前趋势的延续
在这个实例中,我们尝试在比特币小时价格中识别三线反转模式。该模式属于延续型模式。其看跌变体(在整体看跌趋势中识别)由三根蜡烛线组成,每根蜡烛的低点都低于前一根。该模式的第四根蜡烛在第三根蜡烛的低点或更低处开盘,但随后大幅反转并收盘在系列中第一根蜡烛的最高点之上。
如何操作……
执行以下步骤以识别比特币小时蜡烛图中的三线反转模式:
导入库:
import pandas as pd import yfinance as yf import talib import mplfinance as mpf
下载过去 9 个月的比特币小时价格:
df = yf.download("BTC-USD", period="9mo", interval="1h", progress=False)
识别三线反转模式:
df["3_line_strike"] = talib.CDL3LINESTRIKE( df["Open"], df["High"], df["Low"], df["Close"] )
定位并绘制看跌模式:
df[df["3_line_strike"] == -100].head()
图 5.7:看跌三线反转模式的前五个观察点
mpf.plot(df["2021-07-16 05:00:00":"2021-07-16 16:00:00"], type="candle")
执行代码片段后会返回以下图表:
图 5.8:识别出的看跌三线反转模式
定位并绘制看涨模式:
df[df["3_line_strike"] == 100]
图 5.9:看涨三线反转模式的前五个观察点
mpf.plot(df["2021-07-10 10:00:00":"2021-07-10 23:00:00"], type="candle")
执行代码片段后会返回以下图表:
图 5.10:识别出的看涨三线反转模式
我们可以利用识别出的模式来创建交易策略。例如,看跌三线反转通常表示一次小幅回调,随后会继续出现看跌趋势。
工作原理……
在导入库后,我们使用yfinance
库下载了过去 3 个月的比特币小时价格。
在步骤 3中,我们使用TA-Lib
库来识别三线反转模式(通过CDL3LINESTRIKE
函数)。我们需要单独提供 OHLC(开盘价、最高价、最低价、收盘价)数据作为该函数的输入。我们将函数的输出存储在一个新列中。对于该函数,有三种可能的输出:
100
—表示该模式的看涨变体0
—未检测到模式-100
—表示该模式的看跌变体
库的作者警告,用户应考虑当三线打击模式出现在相同方向的趋势中时,其具有显著性(库未验证此点)。
某些函数可能有额外的输出。一些模式还具有-200/200 的值(例如,Hikkake 模式),每当模式中有额外确认时,就会出现这些值。
在步骤 4中,我们筛选了 DataFrame 中的看跌模式。它被识别了六次,我们选择了2021-07-16 12:00:00
的那个模式。然后,我们将该模式与一些相邻的蜡烛图一起绘制。
在步骤 5中,我们重复了相同的过程,这次是针对一个看涨模式。
还有更多……
如果我们希望将已识别的模式作为模型/策略的特征,可能值得尝试一次性识别所有可能的模式。我们可以通过执行以下步骤来实现:
获取所有可用的模式名称:
candle_names = talib.get_function_groups()["Pattern Recognition"]
遍历模式列表并尝试识别所有模式:
for candle in candle_names: df[candle] = getattr(talib, candle)(df["Open"], df["High"], df["Low"], df["Close"])
检查模式的汇总统计:
with pd.option_context("display.max_rows", len(candle_names)): display(df[candle_names].describe().transpose().round(2))
为了简洁起见,我们仅展示返回的 DataFrame 中的前 10 行:
图 5.11:已识别蜡烛图模式的汇总统计
我们可以看到,有些模式从未被识别(最小值和最大值为零),而其他模式则有一个或两个变体(看涨或看跌)。在 GitHub 上提供的笔记本中,我们还尝试了根据此表的输出识别晚星模式。
另见
sourceforge.net/p/ta-lib/code/HEAD/tree/trunk/ta-lib/c/src/ta_func/
Bulkowski, T. N. 2021 《图表模式百科全书》。John Wiley & Sons,2021 年。
使用 Streamlit 构建技术分析的互动网页应用
在本章中,我们已经介绍了技术分析的基础知识,这些知识可以帮助交易者做出决策。然而,直到现在,一切都是相对静态的——我们下载数据,计算指标,绘制图表,如果我们想更换资产或日期范围,就必须重复所有步骤。那么,是否有更好、更互动的方式来解决这个问题呢?
这正是 Streamlit 发挥作用的地方。Streamlit 是一个开源框架(以及一个同名公司,类似于 Plotly),它允许我们仅使用 Python 在几分钟内构建互动网页应用。以下是 Streamlit 的亮点:
它易于学习,并能非常快速地生成结果
它仅限于 Python;无需前端开发经验
它允许我们专注于应用的纯数据/机器学习部分
我们可以使用 Streamlit 的托管服务来部署我们的应用
在本示例中,我们将构建一个用于技术分析的互动应用程序。你将能够选择任何标准普尔 500 指数的成分股,并快速、互动地进行简单的分析。此外,你还可以轻松扩展该应用程序,添加更多的功能,例如不同的指标和资产,甚至可以在应用程序中嵌入交易策略的回测功能。
准备就绪
本示例与其他示例略有不同。我们的应用程序代码“存在”于一个单一的 Python 脚本(technical_analysis_app.py
)中,代码大约有一百行。一个非常基础的应用程序可以更加简洁,但我们希望展示一些 Streamlit 最有趣的功能,即使它们对于构建基础的技术分析应用程序并非绝对必要。
通常,Streamlit 按照自上而下的顺序执行代码,这使得将解释与本书使用的结构相适配更加容易。因此,本示例中的步骤并不是本身的步骤——它们不能/不应该单独执行。相反,它们是应用程序所有组件的逐步演示。在构建自己的应用程序或扩展此应用程序时,你可以根据需要自由更改步骤的顺序(只要它们与 Streamlit 框架一致)。
如何操作……
以下步骤都位于 technical_analysis_app.py
文件中:
导入库:
import yfinance as yf import streamlit as st import datetime import pandas as pd import cufflinks as cf from plotly.offline import iplot cf.go_offline()
定义一个从维基百科下载标准普尔 500 指数成分股列表的函数:
@st.cache def get_sp500_components(): df = pd.read_html("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies") df = df[0] tickers = df["Symbol"].to_list() tickers_companies_dict = dict( zip(df["Symbol"], df["Security"]) ) return tickers, tickers_companies_dict
定义一个使用
yfinance
下载历史股票价格的函数:@st.cache def load_data(symbol, start, end): return yf.download(symbol, start, end)
定义一个将下载的数据存储为 CSV 文件的函数:
@st.cache def convert_df_to_csv(df): return df.to_csv().encode("utf-8")
定义侧边栏用于选择股票代码和日期的部分:
st.sidebar.header("Stock Parameters") available_tickers, tickers_companies_dict = get_sp500_components() ticker = st.sidebar.selectbox( "Ticker", available_tickers, format_func=tickers_companies_dict.get ) start_date = st.sidebar.date_input( "Start date", datetime.date(2019, 1, 1) ) end_date = st.sidebar.date_input( "End date", datetime.date.today() ) if start_date > end_date: st.sidebar.error("The end date must fall after the start date")
定义侧边栏用于调整技术分析详细参数的部分:
st.sidebar.header("Technical Analysis Parameters") volume_flag = st.sidebar.checkbox(label="Add volume")
添加一个带有 SMA 参数的展开器:
exp_sma = st.sidebar.expander("SMA") sma_flag = exp_sma.checkbox(label="Add SMA") sma_periods= exp_sma.number_input( label="SMA Periods", min_value=1, max_value=50, value=20, step=1 )
添加一个带有布林带参数的展开器:
exp_bb = st.sidebar.expander("Bollinger Bands") bb_flag = exp_bb.checkbox(label="Add Bollinger Bands") bb_periods= exp_bb.number_input(label="BB Periods", min_value=1, max_value=50, value=20, step=1) bb_std= exp_bb.number_input(label="# of standard deviations", min_value=1, max_value=4, value=2, step=1)
添加一个带有 RSI 参数的展开器:
exp_rsi = st.sidebar.expander("Relative Strength Index") rsi_flag = exp_rsi.checkbox(label="Add RSI") rsi_periods= exp_rsi.number_input( label="RSI Periods", min_value=1, max_value=50, value=20, step=1 ) rsi_upper= exp_rsi.number_input(label="RSI Upper", min_value=50, max_value=90, value=70, step=1) rsi_lower= exp_rsi.number_input(label="RSI Lower", min_value=10, max_value=50, value=30, step=1)
在应用程序的主体中指定标题和附加文本:
st.title("A simple web app for technical analysis") st.write(""" ### User manual * you can select any company from the S&P 500 constituents """)
加载历史股票价格:
df = load_data(ticker, start_date, end_date)
添加一个带有下载数据预览的展开器:
data_exp = st.expander("Preview data") available_cols = df.columns.tolist() columns_to_show = data_exp.multiselect( "Columns", available_cols, default=available_cols ) data_exp.dataframe(df[columns_to_show]) csv_file = convert_df_to_csv(df[columns_to_show]) data_exp.download_button( label="Download selected as CSV", data=csv_file, file_name=f"{ticker}_stock_prices.csv", mime="text/csv", )
使用选定的技术分析指标创建蜡烛图:
title_str = f"{tickers_companies_dict[ticker]}'s stock price" qf = cf.QuantFig(df, title=title_str) if volume_flag: qf.add_volume() if sma_flag: qf.add_sma(periods=sma_periods) if bb_flag: qf.add_bollinger_bands(periods=bb_periods, boll_std=bb_std) if rsi_flag: qf.add_rsi(periods=rsi_periods, rsi_upper=rsi_upper, rsi_lower=rsi_lower, showbands=True) fig = qf.iplot(asFigure=True) st.plotly_chart(fig)
要运行应用程序,打开终端,导航到
technical_analysis_app.py
脚本所在的目录,并运行以下命令:streamlit run technical_analysis_app.py
运行代码后,Streamlit 应用程序将在默认浏览器中打开。应用程序的默认屏幕如下所示:
图 5.12:我们的技术分析应用程序在浏览器中的展示
应用程序对输入完全响应——每当你更改侧边栏或应用程序主体中的输入时,显示的内容将相应调整。实际上,我们甚至可以进一步扩展,将应用程序连接到经纪商的 API。这样,我们可以在应用程序中分析模式,并根据分析结果创建订单。
它是如何工作的……
如 准备工作 部分所述,这个食谱结构有所不同。步骤实际上是定义我们构建的应用的一系列元素。在深入细节之前,应用代码库的一般结构如下:
导入和设置 (步骤 1)
数据加载函数 (步骤 2–4)
侧边栏 (步骤 5–9)
应用程序的主体 (步骤 10–13)
在第一步中,我们导入了所需的库。对于技术分析部分,我们决定使用一个库,它能在尽可能少的代码行内可视化一组技术指标。这就是我们选择 cufflinks
的原因,它在 第三章 《可视化金融时间序列》 中介绍。然而,如果你需要计算更多的指标,你可以使用任何其他库并自己绘制图表。
在 步骤 2 中,我们定义了一个函数,用于从 Wikipedia 加载标准普尔 500 指数成分股的列表。我们使用 pd.read_html
直接从表格中下载信息并保存为 DataFrame。该函数返回两个元素:一个有效股票代码的列表和一个包含股票代码及其对应公司名称的字典。
你肯定注意到了我们在定义函数时使用了 @st.cache
装饰器。我们不会详细讨论装饰器的一般内容,但会介绍这个装饰器的作用,因为它在使用 Streamlit 构建应用时非常有用。该装饰器表明,应用应该缓存之前获取的数据以供后续使用。因此,如果我们刷新页面或再次调用函数,数据将不会重新下载/处理(除非发生某些条件)。通过这种方式,我们可以显著提高 Web 应用的响应速度并降低最终用户的等待时间。
在幕后,Streamlit 跟踪以下信息,以确定是否需要重新获取数据:
我们在调用函数时提供的输入参数
函数中使用的任何外部变量的值
被调用函数的主体
在缓存函数内部调用的任何函数的主体
简而言之,如果这是 Streamlit 第一次看到某种组合的这四个元素,它将执行函数并将其输出存储在本地缓存中。如果下次函数被调用时遇到完全相同的一组元素,它将跳过执行并返回上次执行的缓存输出。
步骤 3 和 4 包含非常小的函数。第一个用于通过 yfinance
库从 Yahoo Finance 获取历史股票价格。接下来的步骤将 DataFrame 的输出保存为 CSV 文件,并将其编码为 UTF-8。
在步骤 5中,我们开始着手开发应用程序的侧边栏,用于存储应用程序的参数配置。首先需要注意的是,所有计划放置在侧边栏中的元素都通过st.sidebar
调用(与我们在定义主界面元素和其他功能时使用的st
不同)。在这一步中,我们做了以下工作:
我们指定了标题。
我们下载了可用票证的列表。
我们创建了一个下拉选择框,用于选择可用的票证。我们还通过将包含符号-名称对的字典传递给
format_func
参数来提供额外的格式化。我们允许用户选择分析的开始和结束日期。使用
date_input
会显示一个交互式日历,用户可以从中选择日期。我们通过使用
if
语句结合st.sidebar.error
来处理无效的日期组合(开始日期晚于结束日期)。这将暂停应用程序的执行,直到错误被解决,也就是说,直到提供正确的输入。
此步骤的结果如下所示:
图 5.13:侧边栏的一部分,我们可以在其中选择票证和开始/结束日期
在步骤 6中,我们在侧边栏中添加了另一个标题,并使用st.checkbox
创建了一个复选框。如果选中,该变量将保存True
值,未选中则为False
。
在步骤 7中,我们开始配置技术指标。为了保持应用程序的简洁,我们使用了展开器(st.expander
)。展开器是可折叠的框,通过点击加号图标可以触发展开。在其中,我们存储了两个元素:
一个复选框,用于指示是否要显示 SMA。
一个数字字段,用于指定移动平均的周期数。对于该元素,我们使用了 Streamlit 的
number_input
对象。我们提供了标签、最小/最大值、默认值和步长(当我们按下相应的按钮时,可以逐步增加/减少字段的值)。
使用展开器时,我们首先在侧边栏中实例化了一个展开器,使用exp_sma = st.sidebar.expander("SMA")
。然后,当我们想要向展开器中添加元素时,例如复选框,我们使用以下语法:sma_flag = exp_sma.checkbox(label="添加 SMA")
。这样,它就被直接添加到了展开器中,而不仅仅是侧边栏。
步骤 8和步骤 9非常相似。我们为应用程序中想要包括的其他技术指标——布林带和 RSI——创建了两个展开器。
步骤 7 到 9的代码生成了应用程序侧边栏的以下部分:
图 5.14:侧边栏的一部分,我们可以在其中修改所选指标的参数
然后,我们继续定义了应用程序的主体。在步骤 10中,我们使用st.title
添加了应用程序的标题,并使用st.write
添加了用户手册。在使用后者功能时,我们可以提供一个 Markdown 格式的文本输入。对于这一部分,我们使用了副标题(由###
表示)并创建了一个项目符号列表(由*
表示)。为了简洁起见,我们没有包含书中的所有文字,但你可以在书籍的 GitHub 仓库中找到它。
在步骤 11中,我们根据侧边栏的输入下载了历史股票价格。我们在这里还可以做的是下载给定股票的完整日期范围,然后使用侧边栏的起始/结束日期来筛选出感兴趣的时间段。这样,我们就不需要每次更改起始/结束日期时重新下载数据。
在步骤 12中,我们定义了另一个展开面板,这次是在应用程序的主体中。首先,我们添加了一个多选字段(st.multiselect
),从中我们可以选择从下载的历史价格中可用的任何列。然后,我们使用st.dataframe
显示选定的 DataFrame 列,以便进一步检查。最后,我们添加了将选定数据(包括列选择)作为 CSV 文件下载的功能。为此,我们使用了convert_df_to_csv
函数,并结合使用st.download_button
。
步骤 12负责生成应用程序的以下部分:
图 5.15:应用程序的部分,在这里我们可以检查包含价格的 DataFrame 并将其作为 CSV 下载
在应用程序的最后一步,我们定义了要显示的图表。没有任何技术分析输入时,应用程序将使用cufflinks
显示一个蜡烛图。我们实例化了QuantFig
对象,然后根据侧边栏的输入向其添加元素。每个布尔标志触发一个单独的命令,向图表中添加一个元素。为了显示交互式图表,我们使用了st.plotly_chart
,它与plotly
图表配合使用(cufflinks
是plotly
的一个包装器)。
对于其他可视化库,有不同的命令来嵌入可视化。例如,对于matplotlib
,我们会使用st.pyplot
。我们还可以使用st.altair_chart
显示用 Altair 创建的图表。
还有更多……
在本书的第一版中,我们介绍了一种不同的方法来创建用于技术分析的交互式仪表盘。我们没有使用 Streamlit,而是使用ipywidgets
在 Jupyter 笔记本中构建仪表盘。
通常来说,Streamlit 可能是这个特定任务的更好工具,特别是当我们想要部署应用程序(将在下一个食谱中介绍)并与他人共享时。然而,ipywidgets
在其他项目中仍然很有用,这些项目可以在笔记本内本地运行。这就是为什么你可以在随附的 GitHub 仓库中找到用于创建非常相似仪表盘(在笔记本内)的代码。
另见
部署技术分析应用
在前面的教程中,我们创建了一个完整的技术分析 Web 应用,能够轻松在本地运行和使用。然而,这并不是最终目标,因为我们可能希望从任何地方访问该应用,或者与朋友或同事分享它。因此,下一步是将应用部署到云端。
在这个教程中,我们展示了如何使用 Streamlit(该公司)的服务部署应用。
准备工作
要将应用部署到 Streamlit Cloud,我们需要在该平台上创建一个账户(forms.streamlit.io/community-sign-up
)。你还需要一个 GitHub 账户来托管应用的代码。
如何操作…
执行以下步骤将 Streamlit 应用部署到云端:
在 GitHub 上托管应用的代码库:
图 5.16:托管在公共 GitHub 仓库中的应用代码库
在此步骤中,记得托管整个应用的代码库,它可能分布在多个文件中。此外,请包含某种形式的依赖列表。在我们的例子中,这就是
requirements.txt
文件。访问
share.streamlit.io/
并登录。你可能需要将 GitHub 账户与 Streamlit 账户连接,并授权它访问你 GitHub 账户的某些权限。点击 New app 按钮。
提供所需的详细信息:个人资料中的仓库名称、分支以及包含应用的文件:
图 5.17:创建应用所需提供的信息
- 点击 Deploy!。
现在,你可以访问提供的链接来使用该应用。
它是如何工作的…
在第一步中,我们将应用的代码托管在了一个公共的 GitHub 仓库中。如果你是 Git 或 GitHub 的新手,请参考另见部分的链接获取更多信息。在写作时,Streamlit 应用的代码托管只支持 GitHub,无法使用其他版本控制提供商(如 GitLab 或 BitBucket)。文件的最低要求是应用的脚本(在我们的例子中是technical_analysis_app.py
)和某种形式的依赖列表。最简单的依赖列表是一个包含所有所需库的 requirements.txt
文本文件。如果你使用不同的依赖管理工具(如 conda
、pipenv
或 poetry
),你需要提供相应的文件。
如果你希望在应用中使用多个库,创建包含这些库的 requirements 文件最简单的方法是在虚拟环境激活时运行 pip freeze > requirements.txt
。
接下来的步骤都非常直观,因为 Streamlit 的平台非常易于使用。需要提到的是,在步骤 4中,我们还可以提供一些更高级的设置。这些设置包括:
您希望应用使用的 Python 版本。
Secrets 字段,您可以在其中存储一些环境变量和秘密信息,如 API 密钥。通常来说,最好不要将用户名、API 密钥和其他秘密信息存储在公开的 GitHub 仓库中。如果您的应用正在从某个提供商或内部数据库获取数据,可以将凭据安全地存储在该字段中,这些凭据将在运行时加密并安全地传递给您的应用。
还有更多内容……
在本教程中,我们展示了如何将我们的 Web 应用部署到 Streamlit Cloud。虽然这是最简单的方法,但它并不是唯一的选择。另一个选择是将应用部署到 Heroku,Heroku 是一种平台即服务(PaaS)类型的平台,能够让你在云端完全构建、运行和操作应用程序。
另见
www.heroku.com/
—关于 Heroku 服务的更多信息docs.streamlit.io/streamlit-cloud
—关于如何部署应用程序以及最佳实践的更多细节docs.github.com/en/get-started/quickstart/hello-world
—关于如何使用 GitHub 的教程
总结
在本章中,我们学习了技术分析。我们从计算一些最流行的技术指标(并下载了预计算的指标)开始:SMA、RSI 和 MACD。我们还探索了如何识别蜡烛图中的图形模式。最后,我们学习了如何创建和部署一个用于技术分析的互动应用。
在后续章节中,我们将通过创建和回测基于我们已经学过的技术指标的交易策略,将这些知识付诸实践。
第六章:时间序列分析与预测
时间序列在工业和研究领域无处不在。我们可以在商业、技术、医疗、能源、金融等领域找到时间序列的例子。我们主要关注的是金融领域,因为时间维度与交易以及许多金融/经济指标密切相关。然而,几乎每个企业都会生成某种时间序列数据,例如,企业随时间变化的利润或任何其他度量的 KPI。因此,本章和下一章介绍的技术可以用于你在工作中可能遇到的任何时间序列分析任务。
时间序列建模或预测通常可以从不同的角度进行。最流行的两种方法是统计方法和机器学习方法。此外,我们还将在第十五章中介绍一些使用深度学习进行时间序列预测的示例,《金融中的深度学习》。
在过去,当我们没有强大的计算能力且时间序列数据不够精细(因为数据并不是随处随时收集)时,统计方法主导了这一领域。近年来,情况发生了变化,基于机器学习的方法在生产环境中的时间序列模型中占据主导地位。然而,这并不意味着经典的统计方法已经不再相关——事实上,远非如此。在数据量非常少的情况下(例如,只有 3 年的月度数据),统计方法仍然能够产生最先进的结果,而机器学习模型可能无法从中学习出规律。此外,我们可以看到,统计方法在最近几届 M-Competitions(由 Spyros Makridakis 发起的最大时间序列预测竞赛)中赢得了不少奖项。
本章将介绍时间序列建模的基础知识。我们首先解释时间序列的组成部分以及如何通过分解方法将其分离。然后,我们将讲解平稳性概念——为什么它很重要,如何进行平稳性检验,最终如果原始序列不是平稳的,如何使其平稳。
接下来,我们将介绍两种最广泛使用的统计方法——指数平滑法和 ARIMA 类模型。在这两种方法中,我们都会展示如何拟合模型、评估模型的拟合优度,并预测时间序列的未来值。
本章内容包括以下几个部分:
时间序列分解
测试时间序列的平稳性
校正时间序列的平稳性
使用指数平滑法对时间序列建模
使用 ARIMA 类模型对时间序列建模
使用 auto-ARIMA 寻找最佳拟合的 ARIMA 模型
时间序列分解
时间序列分解的目标之一是通过将序列分解为多个组件来增加我们对数据的理解。它提供了关于建模复杂性以及为了准确捕捉/建模每个组件应采取哪些方法的见解。
一个例子可以更好地阐明这些可能性。我们可以想象一个具有明确趋势的时间序列,可能是增长或下降。一方面,我们可以使用分解方法提取趋势组件,并在建模剩余序列之前将其从时间序列中移除。这有助于使时间序列平稳(有关更多细节,请参见下面的步骤)。然后,在考虑了其余组件之后,我们可以将其重新加回来。另一方面,我们也可以提供足够的数据或适当的特征,供算法自行建模趋势。
时间序列的组件可以分为两种类型:系统性和非系统性。系统性组件的特点是具有一致性,可以描述并建模。相反,非系统性组件无法直接建模。
以下是系统性组件:
水平—序列中的平均值。
趋势—对趋势的估计,即在任何给定时刻,连续时间点之间的值变化。它可以与序列的斜率(增加/减少)相关联。换句话说,它是时间序列在长时间段内的总体方向。
季节性—由重复的短期周期(具有固定且已知的周期)引起的均值偏差。
以下是非系统性组件:
- 噪声—序列中的随机变化。它包括从时间序列中移除其他组件后观察到的所有波动。
传统的时间序列分解方法通常使用两种类型的模型之一:加法模型和乘法模型。
加法模型可以通过以下特点进行描述:
模型形式—
线性模型—随时间变化的幅度一致
趋势是线性的(直线)
具有相同频率(宽度)和幅度(高度)的线性季节性变化
乘法模型可以通过以下特点进行描述:
模型形式—
非线性模型—随时间变化的幅度不一致,例如指数型变化
一条曲线型的非线性趋势
具有随时间变化的频率和幅度增加/减少的非线性季节性
为了让事情变得更有趣,我们可以找到具有加法和乘法特征组合的时间序列,例如,具有加法趋势和乘法季节性的序列。
请参考下图以可视化可能的组合。虽然现实世界的问题从来没有那么简单(数据噪声和不断变化的模式),但这些抽象模型提供了一个简单的框架,我们可以用它来分析我们的时间序列,然后再尝试进行建模/预测。
图 6.1:趋势和季节性加法与乘法变体
有时我们可能不想(或者由于某些模型假设不能)使用乘法模型。一个可能的解决方案是通过对数变换将乘法模型转换为加法模型:
在本例中,我们将展示如何进行美国月度失业率的时间序列分解,这些数据是从纳斯达克数据链接下载的。
如何操作...
执行以下步骤进行时间序列分解:
导入库并进行身份验证:
import pandas as pd import nasdaqdatalink import seaborn as sns from statsmodels.tsa.seasonal import seasonal_decompose nasdaqdatalink.ApiConfig.api_key = "YOUR_KEY_HERE"
下载 2010 年至 2019 年间的美国月度失业率数据:
df = ( nasdaqdatalink.get(dataset="FRED/UNRATENSA", start_date="2010-01-01", end_date="2019-12-31") .rename(columns={"Value": "unemp_rate"}) )
在图 6.2中,我们可以看到时间序列中一些明显的季节性模式。我们没有将最新数据包含在本次分析中,因为 COVID-19 大流行对失业率时间序列中的任何可观察模式造成了相当突兀的变化。我们没有展示生成图表所使用的代码,因为它与第三章《可视化金融时间序列》中的代码非常相似。
图 6.2:2010 至 2019 年间美国失业率的季节性图
添加滚动平均和标准差:
WINDOW_SIZE = 12 df["rolling_mean"] = df["unemp_rate"].rolling(window=WINDOW_SIZE).mean() df["rolling_std"] = df["unemp_rate"].rolling(window=WINDOW_SIZE).std() df.plot(title="Unemployment rate")
运行代码片段会生成以下图表:
图 6.3:美国失业率及其滚动平均和标准差
从图 6.3的分析中,我们可以推断出趋势和季节性成分似乎呈现线性模式。因此,我们将在下一步中使用加法分解。
使用加法模型进行季节性分解:
decomposition_results = seasonal_decompose(df["unemp_rate"], model="additive") ( decomposition_results .plot() .suptitle("Additive Decomposition") )
运行代码片段会生成以下图表:
图 6.4:美国失业率的季节性分解(使用加法模型)
在分解图中,我们可以看到提取的成分序列:趋势、季节性和随机(残差)。为了评估分解是否合理,我们可以查看随机成分。如果没有明显的模式(换句话说,随机成分确实是随机的,并且随时间变化一致),那么分解结果是合理的。在这种情况下,看起来残差的方差在数据集的前半部分略高。这可能表明一个常量的季节性模式不足以准确捕捉所分析时间序列的季节性成分。
它是如何工作的...
在步骤 2中下载数据后,我们使用pandas
DataFrame 的rolling
方法计算滚动统计。我们指定了 12 个月的窗口大小,因为我们处理的是月度数据。
我们使用statsmodels
库中的seasonal_decompose
函数进行经典分解。在此过程中,我们指明了希望使用的模型类型——可能的值是additive
(加法)和multiplicative
(乘法)。
当使用seasonal_decompose
处理数字数组时,除非我们使用pandas
Series 对象,否则必须指定观测的频率(freq
参数)。如果存在缺失值或希望为时间序列开始和结束处的缺失周期外推残差,我们可以传递额外的参数extrapolate_trend='freq'
。
还有更多……
我们在本配方中使用的季节性分解是最基本的方法。它有一些缺点:
由于该算法使用居中移动平均来估计趋势,运行分解时,时间序列的开头和结尾会出现趋势线(和残差)的缺失值。
使用此方法估算的季节性模式假设每年都会重复一次。不言而喻,这个假设非常强,特别是对于较长时间序列。
趋势线有过度平滑数据的倾向,这会导致趋势线无法充分响应剧烈或突发的波动。
该方法对数据中的潜在异常值不稳健。
随着时间的推移,出现了几种替代的时间序列分解方法。在这一节中,我们还将介绍使用LOESS(STL 分解)进行的季节性和趋势分解,这在statsmodels
库中得到了实现。
LOESS 代表局部估计散点平滑,它是一种估计非线性关系的方法。
我们不会深入探讨 STL 分解的工作原理;然而,了解它相较于其他方法的优势是有意义的:
STL 可以处理任何类型的季节性(不像其他一些方法那样仅限于月度或季度)。
用户可以控制趋势的平滑度。
季节性成分可能随时间变化(变化率可以由用户控制)。
对异常值更稳健——趋势和季节性成分的估计不会受到异常值的影响,尽管它们的影响仍然会在残差成分中可见。
自然地,它并不是万能的解决方案,并且也有一些自身的缺点。例如,STL 仅能用于加法分解,并且不会自动考虑交易日/日历变化。
STL 分解的一个最近变种可以处理多个季节性。例如,一组小时数据的时间序列可以展现出每日/每周/每月的季节性。这种方法被称为使用 LOESS 的多重季节性趋势分解(MSTL),你可以在另见部分找到相关文献。
我们可以通过以下代码片段执行 STL 分解:
from statsmodels.tsa.seasonal import STL
stl_decomposition = STL(df[["unemp_rate"]]).fit()
stl_decomposition.plot() \
.suptitle("STL Decomposition")
运行代码会生成以下图表:
图 6.5:美国失业时间序列的 STL 分解
我们可以看到,STL 分解和经典分解的分解图非常相似。然而,在图 6.5中存在一些细微差别,显示了 STL 分解相对于经典分解的优势。首先,趋势估计中没有缺失值。其次,季节成分随时间缓慢变化。例如,当你查看不同年份一月的数据时,可以清晰地看到这一点。
STL
中seasonal
参数的默认值设置为 7,但该方法的作者建议使用更大的值(必须是大于或等于 7 的奇数)。在内部,该参数的值表示在估算季节成分的每个值时所使用的连续年份数。选择的值越大,季节成分越平滑。反过来,这导致时间序列中观察到的波动较少被归因于季节成分。trend
参数的解释类似,不过它表示用于估算趋势成分的连续观察值数量。
我们还提到过,STL 分解的一个优点是其对离群值的更高鲁棒性。我们可以使用robust
参数来启用数据依赖的加权函数。它在估计 LOESS 时重新加权观察值,在这种情况下,LOESS 变成了LOWESS(局部加权散点图平滑)。在使用鲁棒估计时,模型能够容忍在残差成分图中可见的较大误差。
在图 6.6中,你可以看到将两种 STL 分解方法与美国失业数据进行比较——有无鲁棒估计的对比。有关生成该图的代码,请参阅书籍 GitHub 库中的笔记本。
图 6.6:使用鲁棒估计在 STL 分解过程中的效果
我们可以清楚地观察到使用鲁棒估计的效果——较大的误差被容忍,且分析时间序列的前几年中季节性成分的形态有所不同。在这种情况下,鲁棒方法和非鲁棒方法哪个更好并没有明确的答案;这完全取决于我们想用分解做什么。本节中介绍的季节性分解方法也可以作为简单的异常值检测算法。例如,我们可以对序列进行分解,提取残差,并在残差超出 3 倍 四分位距 (IQR) 时将观测值标记为异常值。kats
库在其 OutlierDetector
类中提供了这种算法的实现。
其他可用的季节性分解方法包括:
ARIMA 时间序列中的季节性提取 (SEATS) 分解。
X11 分解—这种分解变体为所有观测值创建一个趋势-周期成分,并允许季节性成分随时间缓慢变化。
霍德里克-普雷斯科特滤波器—虽然该方法实际上不是一种季节性分解方法,但它是一种数据平滑技术,用于去除与商业周期相关的短期波动。通过去除这些波动,我们可以揭示长期趋势。HP 滤波器常用于宏观经济学中。你可以在
statsmodels
的hpfilter
函数中找到它的实现。
另见
关于时间序列分解的有用参考资料:
Bandara, K., Hyndman, R. J., & Bergmeir, C. 2021. “MSTL:一种用于具有多种季节性模式的时间序列的季节趋势分解算法。” arXiv 预印本 arXiv:2107.13462。
Cleveland, R. B., Cleveland, W. S., McRae, J. E., & Terpenning, I. J. 1990. “基于 LOESS 的季节性趋势分解程序,” 官方统计期刊 6(1): 3–73\。
Hyndman, R.J. & Athanasopoulos, G. 2021. 预测:原理与实践,第 3 版,OTexts:澳大利亚墨尔本。OTexts.com/fpp3
Sutcliffe, A. 1993. X11 时间序列分解与抽样误差。澳大利亚统计局。
时间序列平稳性检验
时间序列分析中最重要的概念之一是 平稳性。简单来说,平稳时间序列是一个其属性不依赖于观察时刻的序列。换句话说,平稳性意味着某一时间序列的 数据生成过程 (DGP) 的统计属性随时间不变。
因此,我们不应该在平稳时间序列中看到任何趋势或季节性模式,因为它们的存在违反了平稳性假设。另一方面,白噪声过程是平稳的,因为无论何时观察,它的表现都几乎相同。
一个没有趋势和季节性但具有周期性行为的时间序列仍然可以是平稳的,因为这些周期的长度并不固定。因此,除非我们明确观察一个时间序列,否则无法确定周期的峰值和谷值的位置。
更正式地说,平稳性有多种定义,有些在假设上更严格。对于实际使用的情况,我们可以使用所谓的弱平稳性(或协方差平稳性)。为了将时间序列分类为(协方差)平稳,它必须满足以下三个条件:
序列的均值必须是恒定的
序列的方差必须是有限且恒定的
相隔相同时间的期数之间的协方差必须是恒定的
平稳性是时间序列的一个理想特征,因为它使得建模和未来预测变得更加可行。这是因为平稳序列比非平稳序列更容易预测,因为其统计特性在未来与过去相同。
非平稳数据的一些缺点包括:
方差可能会被模型错误指定
模型拟合较差,导致预测较差
我们无法利用数据中有价值的时间依赖模式
虽然平稳性是时间序列的一个理想特性,但并不是所有统计模型都适用。我们希望在使用某种自回归模型(如 AR、ARMA、ARIMA 等)进行建模时,时间序列是平稳的。然而,也有一些模型不依赖于平稳时间序列,举例来说,依赖时间序列分解的方法(如指数平滑法或 Facebook 的 Prophet)。
在这个示例中,我们将向你展示如何测试时间序列的平稳性。为此,我们将使用以下方法:
扩展的迪基-福勒(ADF)检验
Kwiatkowski-Phillips-Schmidt-Shin(KPSS)检验
(部分)自相关函数(PACF/ACF)的图形
我们将研究 2010 至 2019 年间的月度失业率的平稳性。
准备工作
我们将使用在 时间序列分解 示例中使用的相同数据。在展示失业率滚动均值和标准差的图表中(图 6.3),我们已经看到了随时间的负趋势,暗示了非平稳性。
如何操作...
执行以下步骤来测试美国月度失业率的时间序列是否平稳:
导入库:
import pandas as pd from statsmodels.graphics.tsaplots import plot_acf, plot_pacf from statsmodels.tsa.stattools import adfuller, kpss
定义运行 ADF 检验的函数:
def adf_test(x): indices = ["Test Statistic", "p-value", "# of Lags Used", "# of Observations Used"] adf_test = adfuller(x, autolag="AIC") results = pd.Series(adf_test[0:4], index=indices) for key, value in adf_test[4].items(): results[f"Critical Value ({key})"] = value return results
定义好函数后,我们可以运行测试:
adf_test(df["unemp_rate"])
运行该代码片段将生成以下摘要:
Test Statistic -2.053411 p-value 0.263656 # of Lags Used 12.000000 # of Observations Used 107.000000 Critical Value (1%) -3.492996 Critical Value (5%) -2.888955 Critical Value (10%) -2.581393
ADF 检验的原假设指出时间序列不是平稳的。当 p 值为
0.26
(或等效地,测试统计量大于选定置信水平的临界值)时,我们没有理由拒绝原假设,这意味着我们可以得出结论,序列不是平稳的。定义一个用于运行 KPSS 检验的函数:
def kpss_test(x, h0_type="c"): indices = ["Test Statistic", "p-value", "# of Lags"] kpss_test = kpss(x, regression=h0_type) results = pd.Series(kpss_test[0:3], index=indices) for key, value in kpss_test[3].items(): results[f"Critical Value ({key})"] = value return results
定义好函数后,我们可以运行检验:
kpss_test(df["unemp_rate"])
运行代码片段会生成以下总结:
Test Statistic 1.799224 p-value 0.010000 # of Lags 6.000000 Critical Value (10%) 0.347000 Critical Value (5%) 0.463000 Critical Value (2.5%) 0.574000 Critical Value (1%) 0.739000
KPSS 检验的原假设指出时间序列是平稳的。当 p 值为
0.01
(或测试统计量大于选定的临界值)时,我们有理由拒绝原假设,支持替代假设,表明序列不是平稳的。生成 ACF/PACF 图:
N_LAGS = 40 SIGNIFICANCE_LEVEL = 0.05 fig, ax = plt.subplots(2, 1) plot_acf(df["unemp_rate"], ax=ax[0], lags=N_LAGS, alpha=SIGNIFICANCE_LEVEL) plot_pacf(df["unemp_rate"], ax=ax[1], lags=N_LAGS, alpha=SIGNIFICANCE_LEVEL)
运行代码片段会生成以下图表:
图 6.7:失业率的自相关和偏自相关图
在 ACF 图中,我们可以看到存在显著的自相关(超出 95% 置信区间,对应选定的 5% 显著性水平)。在 PACF 图中,滞后 1 和滞后 4 也存在一些显著的自相关。
它是如何工作的……
在步骤 2中,我们定义了一个用于运行 ADF 检验并打印结果的函数。我们在调用 adfuller
函数时指定了 autolag="AIC"
,因此考虑的滞后数是根据 赤池信息量准则(AIC)自动选择的。或者,我们可以手动选择滞后数。
对于 kpss
函数(步骤 3),我们指定了 regression
参数。值为 "c"
表示原假设认为序列是水平平稳的,而 "ct"
表示趋势平稳的(去除趋势后序列将变为水平平稳的)。
对于所有的检验和自相关图,我们选择了 5% 的显著性水平,这表示当原假设(H0)实际上为真时,拒绝原假设的概率。
还有更多……
在本示例中,我们使用了 statsmodels
库进行平稳性检验。然而,我们必须将其功能封装在自定义函数中,以便以清晰的方式呈现总结。或者,我们可以使用 arch
库中的平稳性检验(当我们在第九章《使用 GARCH 类模型建模波动率》深入探讨 GARCH 模型时,将详细介绍该库)。
我们可以使用以下代码片段进行 ADF 检验:
from arch.unitroot import ADF
adf = ADF(df["unemp_rate"])
print(adf.summary().as_text())
它返回一个格式良好的输出,包含所有相关信息:
Augmented Dickey-Fuller Results
=====================================
Test Statistic -2.053
P-value 0.264
Lags 12
-------------------------------------
Trend: Constant
Critical Values: -3.49 (1%), -2.89 (5%), -2.58 (10%)
Null Hypothesis: The process contains a unit root.
Alternative Hypothesis: The process is weakly stationary.
arch
库还包含更多的平稳性检验,包括:
Zivot-Andrews 检验(
statsmodels
中也可用)菲利普斯-佩龙(PP)检验(
statsmodels
中不可用)
ADF 和 KPSS 检验的一个潜在缺点是它们不考虑结构性断裂的可能性,即数据生成过程中的均值或其他参数的突变。Zivot-Andrews 检验允许序列中出现一次结构性断裂,并且其发生时间未知。
我们可以使用以下代码片段运行该测试:
from arch.unitroot import ZivotAndrews
za = ZivotAndrews(df["unemp_rate"])
print(za.summary().as_text())
这将生成摘要:
Zivot-Andrews Results
=====================================
Test Statistic -2.551
P-value 0.982
Lags 12
-------------------------------------
Trend: Constant
Critical Values: -5.28 (1%), -4.81 (5%), -4.57 (10%)
Null Hypothesis: The process contains a unit root with a single structural break.
Alternative Hypothesis: The process is trend and break stationary.
根据测试的 p 值,我们无法拒绝原假设,即该过程是非平稳的。
另见
有关其他平稳性检验的更多信息,请参考:
Phillips, P. C. B. & P. Perron, 1988. “在时间序列回归中测试单位根,” Biometrika 75: 335-346。
Zivot, E. & Andrews, D.W.K., 1992. “关于大崩盘、石油价格冲击和单位根假设的进一步证据,” Journal of Business & Economic Studies, 10: 251-270。
纠正时间序列的平稳性
在前一个食谱中,我们学习了如何调查一个给定的时间序列是否平稳。在本食谱中,我们将研究如何通过以下一种(或多种)变换方法将非平稳时间序列转化为平稳序列:
通货膨胀调整——使用消费者物价指数(CPI)来调整货币序列中的通货膨胀
应用自然对数——使潜在的指数趋势更接近线性,并减少时间序列的方差
差分——计算当前观测值与滞后值(当前观测值之前 x 时间点的观测值)之间的差异
对于本次练习,我们将使用 2000 年至 2010 年的月度黄金价格。我们故意选择这个样本,因为在此期间黄金价格呈现持续上涨的趋势——该序列显然是非平稳的。
如何操作...
执行以下步骤将序列从非平稳转换为平稳:
导入库并验证并更新通货膨胀数据:
import pandas as pd import numpy as np import nasdaqdatalink import cpi from datetime import date from chapter_6_utils import test_autocorrelation nasdaqdatalink.ApiConfig.api_key = "YOUR_KEY_HERE"
在本食谱中,我们将使用
test_autocorrelation
辅助函数,它结合了我们在前一个食谱中介绍的组件——ADF 和 KPSS 检验,以及 ACF/PACF 图。下载黄金价格并重新采样为月度数据:
df = ( nasdaqdatalink.get(dataset="WGC/GOLD_MONAVG_USD", start_date="2000-01-01", end_date="2010-12-31") .rename(columns={"Value": "price"}) .resample("M") .last() )
我们可以使用
test_autocorrelation
辅助函数来测试序列是否平稳。我们在笔记本中(可在 GitHub 上找到)已经这样做过了,结果表明,月度黄金价格的时间序列确实是非平稳的。对黄金价格进行通货膨胀调整(调整到 2010-12-31 的美元值),并绘制结果:
DEFL_DATE = date(2010, 12, 31) df["dt_index"] = pd.to_datetime(df.index) df["price_deflated"] = df.apply( lambda x: cpi.inflate(x["price"], x["dt_index"], DEFL_DATE), axis=1 ) ( df.loc[:, ["price", "price_deflated"]] .plot(title="Gold Price (deflated)") )
运行代码片段会生成以下图表:
图 6.8:月度黄金价格与调整通货膨胀后的时间序列
我们还可以将黄金价格调整到另一个时间点,只要整个序列中的时间点一致即可。
对调整通货膨胀后的序列应用自然对数,并将其与滚动指标一起绘制:
WINDOW = 12 selected_columns = ["price_log", "rolling_mean_log", "rolling_std_log"] df["price_log"] = np.log(df.price_deflated) df["rolling_mean_log"] = df.price_log.rolling(WINDOW) \ .mean() df["rolling_std_log"] = df.price_log.rolling(WINDOW) \ .std() ( df[selected_columns] .plot(title="Gold Price (deflated + logged)", subplots=True) )
运行代码片段会生成以下图表:
图 6.9:应用通货紧缩和自然对数后的时间序列,以及其滚动统计数据
从之前的图中,我们可以看到对数转换成功地完成了它的任务,即使指数趋势变为线性。
使用
test_autocorrelation
(本章的辅助函数)来检查序列是否变为平稳:fig = test_autocorrelation(df["price_log"])
运行该代码片段会生成以下图表:
图 6.10:转化后的时间序列的自相关和偏自相关图
我们还打印了统计测试结果:
ADF test statistic: 1.04 (p-val: 0.99) KPSS test statistic: 1.93 (p-val: 0.01)
在检查了统计测试结果和自相关/偏自相关图后,我们可以得出结论,通货紧缩和自然算法不足以使月度黄金价格的时间序列变为平稳。
对序列应用差分并绘制结果:
selected_columns = ["price_log_diff", "roll_mean_log_diff", "roll_std_log_diff"] df["price_log_diff"] = df.price_log.diff(1) df["roll_mean_log_diff"] = df.price_log_diff.rolling(WINDOW) \ .mean() df["roll_std_log_diff"] = df.price_log_diff.rolling(WINDOW) \ .std() df[selected_columns].plot(title="Gold Price (deflated + log + diff)")
运行该代码片段会生成以下图表:
图 6.11:应用三种类型转换后的时间序列,以及其滚动统计数据
转化后的黄金价格看起来是平稳的——该序列围绕 0 波动,没有明显的趋势,且方差大致恒定。
测试序列是否变为平稳:
fig = test_autocorrelation(df["price_log_diff"].dropna())
运行该代码片段会生成以下图表:
图 6.12:转化后的时间序列的自相关和偏自相关图
我们还打印了统计测试结果:
ADF test statistic: -10.87 (p-val: 0.00)
KPSS test statistic: 0.30 (p-val: 0.10)
在应用第一次差分后,序列在 5%的显著性水平下变得平稳(根据两个测试)。在自相关/偏自相关图中,我们可以看到在滞后期 11、22 和 39 处有几个显著的函数值。这可能表明某种季节性,或者仅仅是一个虚假的信号。使用 5%的显著性水平意味着 5%的值可能位于 95%的置信区间之外——即使潜在过程没有显示任何自相关或偏自相关。
它是如何工作的...
在导入库、进行身份验证并可能更新 CPI 数据后,我们从纳斯达克数据链接下载了月度黄金价格。序列中有一些重复值。例如,2000-04-28 和 2000-04-30 都有相同的值。为了解决这个问题,我们将数据重新采样为月度频率,取每个月的最后一个可用值。
通过这样做,我们仅仅删除了每个月中的潜在重复项,而没有更改任何实际的数值。在步骤 3中,我们使用了cpi
库,通过考虑美元的通货膨胀来使时间序列进行通货紧缩。该库依赖于美国劳工统计局推荐的 CPI-U 指数。为了使其工作,我们创建了一个包含日期的人工索引列,日期作为datetime.date
类的对象。inflate
函数接受以下参数:
value
—我们希望调整的美元价值。year_or_month
—美元价值的日期来源。to
——可选参数,表示我们希望调整到的日期。如果不提供此参数,函数将调整到最近的年份。
在步骤 4中,我们对所有值应用了自然对数(np.log
),将看似指数趋势的值转化为线性趋势。此操作应用于已被通货膨胀调整的价格。
作为最后的变换,我们使用了pandas
DataFrame 的diff
方法,计算时间点t和t-1之间的差值(默认设置对应的是一阶差分)。我们可以通过更改period
参数来指定不同的周期数。
还有更多...
所考虑的黄金价格没有明显的季节性。然而,如果数据集显示出季节性模式,还是有一些潜在的解决方案:
通过差分进行调整——不使用一阶差分,而是使用高阶差分。例如,如果每月数据中存在年度季节性,可以使用
diff(12)
。通过建模进行调整——我们可以直接对季节性进行建模,然后将其从序列中去除。一种可能性是从
seasonal_decompose
函数或其他更高级的自动分解算法中提取季节性成分。在这种情况下,如果使用加法模型,我们应该从序列中减去季节性成分;如果模型是乘法的,则应除以季节性成分。另一个解决方案是使用np.polyfit()
拟合选择的时间序列的最佳多项式,然后从原始序列中减去它。
Box-Cox 变换是我们可以对时间序列数据进行的另一种调整方法。它结合了不同的指数变换函数,使分布更接近正态(高斯)分布。我们可以使用scipy
库中的boxcox
函数,它可以帮助我们自动找到最佳拟合的lambda
参数值。需要注意的一个条件是序列中的所有值必须为正数,因此在计算一阶差分或任何可能引入负值的变换后,不应使用该变换。
一个名为pmdarima
的库(关于这个库的更多内容可以在以下的食谱中找到)包含了两个函数,它们使用统计测试来确定我们应该对序列进行多少次差分,以实现平稳性(并去除季节性,即季节平稳性)。
我们可以使用以下测试来检查平稳性:ADF、KPSS 和 Phillips–Perron:
from pmdarima.arima import ndiffs, nsdiffs
print(f"Suggested # of differences (ADF): {ndiffs(df['price'], test='adf')}")
print(f"Suggested # of differences (KPSS): {ndiffs(df['price'], test='kpss')}")
print(f"Suggested # of differences (PP): {ndiffs(df['price'], test='pp')}")
运行代码片段会返回以下结果:
Suggested # of differences (ADF): 1
Suggested # of differences (KPSS): 2
Suggested # of differences (PP): 1
对于 KPSS 测试,我们还可以指定要检验的零假设类型。默认情况下是水平平稳性(null="level"
)。测试的结果,或更准确地说,差分的需求,表明未进行任何差分的序列是非平稳的。
该库还包含两个季节差分的测试:
Osborn, Chui, Smith 和 Birchenhall(OCSB)
卡诺瓦-汉森(CH)
为了运行它们,我们还需要指定数据的频率。在我们的例子中,它是 12,因为我们使用的是月度数据:
print(f"Suggested # of differences (OSCB): {nsdiffs(df['price'], m=12,
test='ocsb')}")
print(f"Suggested # of differences (CH): {nsdiffs(df['price'], m=12, test='ch')}")
输出结果如下:
Suggested # of differences (OSCB): 0
Suggested # of differences (CH): 0
结果表明黄金价格没有季节性。
使用指数平滑法建模时间序列
指数平滑法是两大类经典预测模型之一。其基本思想是,预测值仅仅是过去观测值的加权平均。当计算这些平均值时,更加重视最近的观测值。为了实现这一点,权重随着时间的推移呈指数衰减。这些模型适用于非平稳数据,即具有趋势和/或季节性的数据显示。平滑方法受到欢迎,因为它们计算速度快(需要的计算量不多),并且在预测准确性方面相对可靠。
总的来说,指数平滑法可以通过ETS 框架(误差、趋势和季节性)来定义,因为它们在平滑计算中结合了基本成分。如同季节性分解的情况一样,这些成分可以通过加法、乘法组合,或直接从模型中省略。
请参见*《预测:原理与实践》*(Hyndman 和 Athanasopoulos)了解更多关于指数平滑方法的分类信息。
最简单的模型称为简单指数平滑法(SES)。这种模型最适合于时间序列没有趋势或季节性的情况。它们也适用于数据点较少的序列。
该模型由一个平滑参数进行参数化,其值介于 0 和 1 之间。值越大,越重视最近的观测值。当
= 0 时,未来的预测值等于训练数据的平均值。当
= 1 时,所有预测值都与训练集中的最后一个观测值相同。
使用 SES 方法生成的预测是平坦的,也就是说,无论时间跨度多长,所有的预测值都相同(对应于最后的水平成分)。这就是为什么这种方法仅适用于没有趋势或季节性的序列。
霍尔特线性趋势法(也称为霍尔特双重指数平滑法)是单指数平滑法(SES)的扩展,通过将趋势成分加入模型的规格来考虑数据中的趋势。因此,当数据中存在趋势时,应使用此模型,但它仍然无法处理季节性。
霍尔特模型的一个问题是,趋势在未来是恒定的,这意味着它会无限增加或减少。这就是为什么模型的扩展通过添加衰减参数来平滑趋势,。它使得趋势在未来趋向一个常数值,从而有效地将其平滑。
的值很少小于
0.8
,因为对于较小的值,的衰减效应非常强。最佳实践是将
的值限制在
0.8
到0.98
之间。当 = 1 时,衰减模型等同于没有衰减的模型。
最后,我们将介绍霍尔特方法的扩展——霍尔特-温特季节性平滑(也称为霍尔特-温特三重指数平滑)。顾名思义,它考虑了时间序列中的季节性。简单来说,该方法最适用于既有趋势又有季节性的数据显示。
该模型有两种变体,分别具有加法或乘法季节性。在加法季节性模型中,季节性变化在整个时间序列中大致保持不变。而在乘法季节性模型中,变化会随着时间的推移而按比例变化。
在这个例子中,我们将展示如何将所涵盖的平滑方法应用于美国月度失业率(具有趋势和季节性的非平稳数据)。我们将对 2010 至 2018 年的数据进行模型拟合,并对 2019 年进行预测。
准备工作
我们将使用在时间序列分解实例中使用的相同数据。
如何操作...
执行以下步骤,以使用指数平滑方法创建美国失业率的预测:
导入库:
import pandas as pd from datetime import date from statsmodels.tsa.holtwinters import (ExponentialSmoothing, SimpleExpSmoothing, Holt)
创建训练/测试数据集:
TEST_LENGTH = 12 df.index.freq = "MS" df_train = df.iloc[:-TEST_LENGTH] df_test = df[-TEST_LENGTH:]
拟合两个 SES 模型并计算预测:
ses_1 = SimpleExpSmoothing(df_train).fit(smoothing_level=0.5) ses_forecast_1 = ses_1.forecast(TEST_LENGTH) ses_2 = SimpleExpSmoothing(df_train).fit() ses_forecast_2 = ses_2.forecast(TEST_LENGTH) ses_1.params_formatted
运行代码片段会生成以下表格:
图 6.13:第一个 SES 模型拟合系数的值
我们可以使用
summary
方法打印拟合模型的更详细摘要。将预测结果与拟合值结合并绘制它们:
ses_df = df.copy() ses_df["ses_1"] = ses_1.fittedvalues.append(ses_forecast_1) ses_df["ses_2"] = ses_2.fittedvalues.append(ses_forecast_2) opt_alpha = ses_2.model.params["smoothing_level"] fig, ax = plt.subplots() ses_df["2017":].plot(style=["-",":","--"], ax=ax, title="Simple Exponential Smoothing") labels = [ "unemp_rate", r"$\alpha=0.2$", r'$\alpha={0:.2f}$'.format(opt_alpha), ] ax.legend(labels)
运行代码片段会生成以下图表:
图 6.14:使用 SES 建模时间序列
在图 6.14中,我们可以看到我们在本例介绍中描述的 SES 特性——预测是一个平坦的线条。我们还可以看到通过优化程序选择的最优值为 1。立刻可以看到选择此值的后果:模型的拟合线实际上是观察到的价格线右移的结果,且预测仅仅是最后一个观察值。
拟合三种霍尔特线性趋势模型变体并计算预测:
# Holt's model with linear trend hs_1 = Holt(df_train).fit() hs_forecast_1 = hs_1.forecast(TEST_LENGTH) # Holt's model with exponential trend hs_2 = Holt(df_train, exponential=True).fit() hs_forecast_2 = hs_2.forecast(TEST_LENGTH) # Holt's model with exponential trend and damping hs_3 = Holt(df_train, exponential=False, damped_trend=True).fit() hs_forecast_3 = hs_3.forecast(TEST_LENGTH)
将原始序列与模型的预测一起绘制:
hs_df = df.copy() hs_df["hs_1"] = hs_1.fittedvalues.append(hs_forecast_1) hs_df["hs_2"] = hs_2.fittedvalues.append(hs_forecast_2) hs_df["hs_3"] = hs_3.fittedvalues.append(hs_forecast_3) fig, ax = plt.subplots() hs_df["2017":].plot(style=["-",":","--", "-."], ax=ax, title="Holt's Double Exponential Smoothing") labels = [ "unemp_rate", "Linear trend", "Exponential trend", "Exponential trend (damped)", ] ax.legend(labels)
运行代码片段会生成以下图表:
图 6.15:使用霍尔特双指数平滑法建模时间序列
我们已经能够观察到改善,因为与 SES 预测相比,线条不再是平的。
另外值得一提的是,在 SES 的情况下,我们优化了单一参数
alpha
(smoothing_level
),而在这里我们不仅优化了beta
(smoothing_trend
),还可能优化了phi
(damping_trend
)。拟合两种 Holt-Winters 三重指数平滑法模型并计算预测值:
SEASONAL_PERIODS = 12 # Holt-Winters' model with exponential trend hw_1 = ExponentialSmoothing(df_train, trend="mul", seasonal="add", seasonal_periods=SEASONAL_PERIODS).fit() hw_forecast_1 = hw_1.forecast(TEST_LENGTH) # Holt-Winters' model with exponential trend and damping hw_2 = ExponentialSmoothing(df_train, trend="mul", seasonal="add", seasonal_periods=SEASONAL_PERIODS, damped_trend=True).fit() hw_forecast_2 = hw_2.forecast(TEST_LENGTH)
将原始序列与模型结果一起绘制:
hw_df = df.copy() hw_df["hw_1"] = hw_1.fittedvalues.append(hw_forecast_1) hw_df["hw_2"] = hw_2.fittedvalues.append(hw_forecast_2) fig, ax = plt.subplots() hw_df["2017":].plot( style=["-",":","--"], ax=ax, title="Holt-Winters' Triple Exponential Smoothing" ) phi = hw_2.model.params["damping_trend"] labels = [ "unemp_rate", "Seasonal Smoothing", f"Seasonal Smoothing (damped with $\phi={phi:.2f}$)" ] ax.legend(labels)
运行代码片段生成了以下图表:
图 6.16:使用 Holt-Winters 三重指数平滑法建模时间序列
在前面的图表中,我们可以看到季节性模式现在也被纳入了预测中。
它是如何工作的……
在导入库之后,我们使用 SimpleExpSmoothing
类及其 fit
方法拟合了两个不同的 SES 模型。为了拟合模型,我们仅使用了训练数据。我们本可以手动选择平滑参数(smoothing_level
)的值,但最佳实践是让 statsmodels
为最佳拟合优化该参数。这个优化通过最小化残差(误差)的平方和来完成。我们使用 forecast
方法创建了预测值,该方法需要我们希望预测的周期数(在我们的案例中,周期数等于测试集的长度)。
在步骤 3中,我们将拟合值(通过拟合模型的 fittedvalues
属性访问)和预测值与观察到的失业率一起放入了一个 pandas
DataFrame 中。然后我们将所有序列进行可视化。为了使图表更易读,我们将数据限制为覆盖训练集和测试集的最后两年。
在步骤 5中,我们使用了 Holt
类(这是一个包装器,封装了更通用的 ExponentialSmoothing
类)来拟合 Holt 的线性趋势模型。默认情况下,模型中的趋势是线性的,但我们可以通过指定 exponential=True
来使其变为指数型,并通过 damped_trend=True
添加阻尼。与 SES 的情况类似,使用 fit
方法且不带任何参数将运行优化例程,以确定参数的最佳值。在步骤 6中,我们再次将所有拟合值和预测结果放入一个 DataFrame 中,并可视化了结果。
在步骤 7中,我们估计了两种 Holt-Winters 三重指数平滑法模型。这个模型没有单独的类,但我们可以通过添加 seasonal
和 seasonal_periods
参数来调整 ExponentialSmoothing
类。根据 ETS 模型的分类法,我们应该指出这些模型具有加性季节性成分。在步骤 8中,我们再次将所有拟合值和预测结果放入一个 DataFrame 中,并将结果以折线图的形式可视化。
在创建ExponentialSmoothing
类的实例时,我们还可以传递use_boxcox
参数,以便自动将 Box-Cox 变换应用于分析的时间序列。或者,我们可以通过传递"log"
字符串来使用对数变换。
还有更多...
在本案例中,我们拟合了各种指数平滑模型来预测月度失业率。每次,我们都会指定感兴趣的模型类型,并且大多数时候,我们让statsmodels
找到最合适的参数。
然而,我们也可以采用不同的方法,即使用一个名为 AutoETS 的过程。简单来说,该过程的目标是根据我们预先提供的一些约束,找到最适合的 ETS 模型。你可以在另见部分提到的参考文献中详细了解 AutoETS 过程的工作原理。
AutoETS 过程可以在sktime
库中使用,该库/框架受到scikit-learn
的启发,但重点是时间序列分析/预测。
执行以下步骤以使用 AutoETS 方法找到最佳 ETS 模型:
导入库:
from sktime.forecasting.ets import AutoETS from sklearn.metrics import mean_absolute_percentage_error
拟合
AutoETS
模型:auto_ets = AutoETS(auto=True, n_jobs=-1, sp=12) auto_ets.fit(df_train.to_period()) auto_ets_fcst = auto_ets.predict(fh=list(range(1, 13)))
将模型的预测添加到 Holt-Winters 预测图中:
auto_ets_df = hw_df.to_period().copy() auto_ets_df["auto_ets"] = ( auto_ets ._fitted_forecaster .fittedvalues .append(auto_ets_fcst["unemp_rate"]) ) fig, ax = plt.subplots() auto_ets_df["2017":].plot( style=["-",":","--","-."], ax=ax, title="Holt-Winters' models vs. AutoETS" ) labels = [ "unemp_rate", "Seasonal Smoothing", f"Seasonal Smoothing (damped with $\phi={phi:.2f}$)", "AutoETS", ] ax.legend(labels)
运行代码段生成以下图表:
图 6.17:AutoETS 预测结果与 Holt-Winters 方法结果的比较图
在图 6.17中,我们可以看到 Holt-Winters 模型和 AutoETS 的样本内拟合非常相似。至于预测,它们确实有所不同,很难说哪一个更好地预测了失业率。
这就是为什么在下一步中我们计算平均绝对百分比误差(MAPE)的原因,MAPE 是时间序列预测(及其他领域)中常用的评估指标。
计算 Holt-Winters 预测和 AutoETS 的 MAPE:
fcst_dict = { "Seasonal Smoothing": hw_forecast_1, "Seasonal Smoothing (damped)": hw_forecast_2, "AutoETS": auto_ets_fcst, } print("MAPEs ----") for key, value in fcst_dict.items(): mape = mean_absolute_percentage_error(df_test, value) print(f"{key}: {100 * mape:.2f}%")
运行代码段生成以下摘要:
MAPEs ----
Seasonal Smoothing: 1.81%
Seasonal Smoothing (damped): 6.53%
AutoETS: 1.78%
我们可以看到,Holt-Winters 方法和 AutoETS 方法的准确性评分(通过 MAPE 衡量)非常相似。
另见
请参阅以下参考文献,了解有关 ETS 方法的更多信息:
Hyndman, R. J., Akram, Md., & Archibald, 2008. “指数平滑模型的可接受参数空间,” 统计数学年刊,60(2):407–426。
Hyndman, R. J., Koehler, A.B., Snyder, R.D., & Grose, S., 2002. “使用指数平滑方法进行自动预测的状态空间框架,” 国际预测期刊,18(3):439–454。
Hyndman, R. J & Koehler, A. B., 2006. “重新审视预测准确性度量,” 国际预测期刊,22(4):679-688。
Hyndman, R. J., Koehler, A.B., Ord, J.K., & Snyder, R.D. 2008. 使用指数平滑进行预测:状态空间方法,Springer-Verlag.
www.exponentialsmoothing.net
。Hyndman, R. J. & Athanasopoulos, G. 2021. 预测:原理与实践,第 3 版,OTexts:澳大利亚墨尔本。OTexts.com/fpp3。
Winters, P.R. 1960. “通过指数加权移动平均法预测销售额,”管理科学 6(3):324–342。
使用 ARIMA 类模型建模时间序列
ARIMA 模型是一类统计模型,用于分析和预测时间序列数据。它们通过描述数据中的自相关关系来实现这一目标。ARIMA 代表自回归积分滑动平均模型,是一种比 ARMA 模型更为复杂的扩展形式。附加的积分部分旨在确保序列的平稳性。因为与指数平滑模型不同,ARIMA 模型要求时间序列必须是平稳的。接下来我们将简要介绍模型的构建模块。
AR(自回归)模型:
这种模型使用观测值与其 p 个滞后值之间的关系。
在金融背景下,自回归模型试图解释动量和均值回归效应。
I(积分):
在这种情况下,积分指的是对原始时间序列进行差分(将当前期值减去前一期的值)以使其平稳。
负责积分的参数是 d(称为差分的度/阶),表示我们需要应用差分的次数。
MA(移动平均)模型:
这种模型使用观测值与白噪声项之间的关系(过去 q 个观测值中发生的冲击)。
在金融背景下,移动平均模型试图解释影响观察到的时间序列的不可预测的冲击(观察到的残差)。这种冲击的例子可能包括自然灾害、与某公司相关的突发新闻等。
MA 模型中的白噪声项是不可观察的。由于这一点,我们无法使用 普通最小二乘法(OLS)来拟合 ARIMA 模型。相反,我们必须使用诸如 最大似然估计(MLE)等迭代估计方法。
所有这些组件共同作用,并在常用的符号表示法中直接指定:ARIMA (p,d,q)。一般来说,我们应尽量将 ARIMA 参数的值保持尽可能小,以避免不必要的复杂性并防止过拟合训练数据。一个可能的经验法则是将 d ⇐ 2,而 p 和 q 不应大于 5. 此外,通常情况下,模型中的某一项(AR 或 MA)将占主导地位,导致另一项的参数值相对较小。
ARIMA 模型非常灵活,通过适当设置它们的超参数,我们可以得到一些特殊的情况:
ARIMA (0,0,0):白噪声
ARIMA (0,1,0) 无常数项:随机游走
ARIMA (p,0,q):ARMA(p, q)
ARIMA (p,0,0):AR(p) 模型
ARIMA (0,0,q):MA(q) 模型
ARIMA (0,1,2):衰减霍尔特模型
ARIMA (0,1,1) 无常数项:SES 模型
ARIMA (0,2,2):霍尔特的线性法与加性误差
ARIMA 模型在工业界仍然非常流行,因为它们提供了接近最先进的性能(主要用于短期预测),特别是在处理小型数据集时。在这种情况下,更高级的机器学习和深度学习模型无法展现其真正的强大能力。
ARIMA 模型在金融领域的已知弱点之一是它们无法捕捉到波动性聚集现象,这在大多数金融资产中都有观察到。
在本食谱中,我们将通过所有必要的步骤来正确估计 ARIMA 模型,并学习如何验证它是否适合数据。对于这个例子,我们将再次使用 2010 到 2019 年间的美国月度失业率数据。
准备就绪
我们将使用与时间序列分解食谱中相同的数据。
如何操作...
执行以下步骤,使用 ARIMA 模型创建美国失业率的预测:
导入库:
import pandas as pd import numpy as np from statsmodels.tsa.arima.model import ARIMA from chapter_6_utils import test_autocorrelation from sklearn.metrics import mean_absolute_percentage_error
创建训练/测试集拆分:
TEST_LENGTH = 12 df_train = df.iloc[:-TEST_LENGTH] df_test = df.iloc[-TEST_LENGTH:]
我们像之前的做法一样创建训练/测试集拆分。通过这种方式,我们可以比较两种模型的性能。
应用对数变换并计算第一差分:
df_train["unemp_rate_log"] = np.log(df_train["unemp_rate"]) df_train["first_diff"] = df_train["unemp_rate_log"].diff() df_train.plot(subplots=True, title="Original vs transformed series")
运行代码片段生成以下图形:
图 6.18:应用变换以实现平稳性
测试差分系列的平稳性:
fig = test_autocorrelation(df_train["first_diff"].dropna())
运行函数产生以下输出:
ADF test statistic: -2.97 (p-val: 0.04) KPSS test statistic: 0.04 (p-val: 0.10)
通过分析测试结果,我们可以得出结论,经过对数转换后的系列的第一差分是平稳的。我们还观察相应的自相关图。
图 6.19:对数转换系列的第一差分的自相关图
拟合两个不同的 ARIMA 模型并打印其总结:
arima_111 = ARIMA( df_train["unemp_rate_log"], order=(1, 1, 1) ).fit() arima_111.summary()
运行代码片段生成以下总结:
图 6.20:拟合的 ARIMA(1,1,1) 模型总结
第一个模型是普通的 ARIMA(1,1,1)。对于第二个模型,我们使用 ARIMA(2,1,2)。
arima_212 = ARIMA( df_train["unemp_rate_log"], order=(2, 1, 2) ).fit() arima_212.summary()
运行代码片段生成以下总结:
图 6.21:拟合的 ARIMA(2,1,2) 模型总结
将拟合值与预测值结合:
df["pred_111_log"] = ( arima_111 .fittedvalues .append(arima_111.forecast(TEST_LENGTH)) ) df["pred_111"] = np.exp(df["pred_111_log"]) df["pred_212_log"] = ( arima_212 .fittedvalues .append(arima_212.forecast(TEST_LENGTH)) ) df["pred_212"] = np.exp(df["pred_212_log"]) df
运行代码片段生成以下表格:
图 6.22:ARIMA 模型的预测——原始值与转换回原始尺度的值
绘制预测并计算 MAPE:
( df[["unemp_rate", "pred_111", "pred_212"]] .iloc[1:] .plot(title="ARIMA forecast of the US unemployment rate") )
运行代码片段生成以下图形:
图 6.23:两个 ARIMA 模型的预测与拟合值
现在我们也放大测试集,以清晰地看到预测结果:
( df[["unemp_rate", "pred_111", "pred_212"]] .iloc[-TEST_LENGTH:] .plot(title="Zooming in on the out-of-sample forecast") )
运行代码片段生成以下图形:
图 6.24:两个 ARIMA 模型的预测
在 图 6.24 中,我们可以看到 ARIMA(1,1,1) 的预测几乎是直线,而 ARIMA(2,1,2) 更好地捕捉到了原始序列的模式。
现在我们计算 MAPEs:
mape_111 = mean_absolute_percentage_error( df["unemp_rate"].iloc[-TEST_LENGTH:], df["pred_111"].iloc[-TEST_LENGTH:] ) mape_212 = mean_absolute_percentage_error( df["unemp_rate"].iloc[-TEST_LENGTH:], df["pred_212"].iloc[-TEST_LENGTH:] ) print(f"MAPE of ARIMA(1,1,1): {100 * mape_111:.2f}%") print(f"MAPE of ARIMA(2,1,2): {100 * mape_212:.2f}%")
运行该代码片段生成以下输出:
MAPE of ARIMA(1,1,1): 9.14% MAPE of ARIMA(2,1,2): 5.08%
提取预测结果及其相应的置信区间,并将它们一起绘制:
preds_df = arima_212.get_forecast(TEST_LENGTH).summary_frame() preds_df.columns = ["fcst", "fcst_se", "ci_lower", "ci_upper"] plot_df = df_test[["unemp_rate"]].join(np.exp(preds_df)) fig, ax = plt.subplots() ( plot_df[["unemp_rate", "fcst"]] .plot(ax=ax, title="ARIMA(2,1,2) forecast with confidence intervals") ) ax.fill_between(plot_df.index, plot_df["ci_lower"], plot_df["ci_upper"], alpha=0.3, facecolor="g") ax.legend(loc="upper left")
运行该代码片段生成以下图形:
图 6.25:ARIMA(2,1,2) 模型的预测结果及其置信区间
我们可以看到,预测结果跟随观察值的形状。此外,我们还可以看到置信区间呈典型的圆锥形模式——预测的时间范围越长,置信区间越宽,这与不确定性的增加相对应。
它是如何工作的...
在 步骤 2 中创建训练集和测试集后,我们对训练数据应用了对数变换和第一次差分。
如果我们想对给定的序列应用多次差分操作,我们应使用 np.diff
函数,因为它实现了递归差分。使用 DataFrame/Series 的 diff
方法并设置 periods
> 1 会得到当前观察值与前 periods
个时期的观察值之差。
在 步骤 4 中,我们测试了对对数变换后的序列进行第一次差分后的平稳性。为此,我们使用了自定义的 test_autocorrelation
函数。通过查看统计测试的输出,我们看到该序列在 5% 的显著性水平下是平稳的。
查看 ACF/PACF 图时,我们还可以清晰地看到年度季节性模式(在滞后 12 和 24 时)。
在 步骤 5 中,我们拟合了两个 ARIMA 模型:ARIMA(1,1,1) 和 ARIMA(2,1,2)。首先,序列在进行第一次差分后变得平稳,因此我们知道积分阶数是 d=1。通常,我们可以使用以下一组“规则”来确定 p 和 q 的值。
确定 AR 模型的阶数:
ACF 显示显著的自相关系数直到滞后 p,然后逐渐衰减。
由于 PACF 仅描述观察值与其滞后值之间的直接关系,我们预计在滞后 p 之外不会有显著的相关性。
确定 MA 模型的阶数:
PACF 显示显著的自相关系数直到滞后 q,然后逐渐衰减。
ACF 显示显著的自相关系数直到滞后 q,然后会出现急剧下降。
关于 ARIMA 阶数的手动调整,Hyndman 和 Athanasopoulos(2018)警告说,如果 p 和 q 都为正,ACF/PACF 图可能在确定 ARIMA 模型的规格时不太有用。在下一篇食谱中,我们将介绍一种自动方法来确定 ARIMA 超参数的最优值。
在步骤 6中,我们将原始序列与两个模型的预测结果结合起来。我们从 ARIMA 模型中提取了拟合值,并将 2019 年的预测附加到序列的末尾。由于我们将模型拟合于对数转换后的序列,因此我们需要使用指数函数(np.exp
)来逆转转换。
当处理可能包含 0 值的序列时,最好使用 np.log1p
和 np.exp1m
。这样,我们可以避免对 0 取对数时可能出现的错误。
在步骤 7中,我们绘制了预测图并计算了平均绝对百分比误差。ARIMA(2,1,2)提供的预测比简单的 ARIMA(1,1,1)要好得多。
在步骤 8中,我们将拟合 ARIMA 模型的 get_forecast
方法与 summary_frame
方法结合起来,获得预测值及其相应的置信区间。我们必须使用 get_forecast
方法,因为 forecast
方法仅返回点预测,而没有任何附加信息。最后,我们重命名了列,并将它们与原始序列一起绘制。
还有更多...
我们已经拟合了 ARIMA 模型并探索了其预测的准确性。然而,我们也可以研究拟合模型的拟合优度标准。我们可以通过深入分析模型对训练数据的拟合情况,而不只是关注外样本性能。我们通过查看拟合 ARIMA 模型的残差来实现这一点。
首先,我们绘制了拟合 ARIMA(2,1,2) 模型的残差诊断图:
arima_212.plot_diagnostics(figsize=(18, 14), lags=25)
运行这段代码会生成以下图形:
图 6.26:拟合的 ARIMA(2,1,2) 模型的诊断图
下面我们将介绍每个图表的解释:
标准化残差随时间变化(左上角)——残差应该表现得像白噪声,也就是说,应该看不到明显的模式。此外,残差应该具有零均值和一致的方差。在我们的案例中,似乎负值比正值更多,因此均值也可能为负。
直方图和 KDE 估计(右上角)——残差的 KDE 曲线应与标准正态分布(标记为 N(0,1))的曲线非常相似。我们可以看到,在我们的模型中情况并非如此,因为分布向负值偏移。
Q-Q 图(左下角)——大多数数据点应位于一条直线上。这表明理论分布(标准正态分布)的分位数与经验分位数匹配。如果与对角线有显著偏离,意味着经验分布是偏斜的。
自相关图(右下角)——在这里,我们观察的是残差的自相关函数图。我们期望一个拟合良好的 ARIMA 模型的残差不应具有自相关性。在我们的案例中,我们可以清楚地看到在滞后期 12 和 24 存在相关的残差。这表明模型没有捕捉到数据中的季节性模式。
为了继续研究残差的自相关性,我们还可以应用 Ljung-Box 检验以测试是否存在自相关。为此,我们可以使用拟合的 ARIMA 模型中的 test_serial_correlation
方法。或者,我们也可以使用 statsmodels
中的 acorr_ljungbox
函数。
ljung_box_results = arima_212.test_serial_correlation(method="ljungbox")
ljung_box_pvals = ljung_box_results[0][1]
fig, ax = plt.subplots(1, figsize=[16, 5])
sns.scatterplot(x=range(len(ljung_box_pvals)),
y=ljung_box_pvals,
ax=ax)
ax.axhline(0.05, ls="--", c="r")
ax.set(title="Ljung-Box test's results",
xlabel="Lag",
ylabel="p-value")
运行代码片段会生成以下图表:
图 6.27:Ljung-Box 检验结果——残差中无自相关
所有返回的 p 值都低于 5% 的显著性水平,这意味着我们应该拒绝原假设,认为残差中不存在自相关。这是合理的,因为我们已经观察到由于模型缺少季节性模式,造成了显著的年度相关性。
我们还需要记住,在执行 Ljung-Box 检验时要考虑的滞后数。不同的资料来源建议考虑不同的滞后数。statsmodels
中的默认值是 min(10, nobs // 5)
,适用于非季节性模型,而季节性时间序列的默认值为 min(2*m, nobs // 5)
,其中 m
表示季节周期。其他常用的变体包括 min(20, nobs − 1)
和 ln(nobs)
。在我们的案例中,我们没有使用季节性模型,所以默认值是 10。但正如我们所知,数据确实表现出了季节性模式,因此我们应该考虑更多的滞后数。
拟合的 ARIMA 模型还包含 test_normality
和 test_heteroskedasticity
方法,我们可以用它们进一步评估模型的拟合度。我们将这些内容作为练习留给读者自行探索。
另见
请参考以下资料,了解更多有关拟合 ARIMA 模型的信息,并获取手动选择模型正确阶数的有用规则集合:
有关 Ljung-Box 检验的更多信息:
使用 auto-ARIMA 寻找最适合的 ARIMA 模型
正如我们在前面的教程中看到的,ARIMA 模型的表现会根据所选的超参数(p,d 和 q)有很大差异。我们可以根据直觉、统计检验和 ACF/PACF 图来尽力选择这些参数。然而,实际操作中这可能会变得相当困难。
这就是为什么在本教程中我们引入了 auto-ARIMA,这是一种自动化方法,用于寻找 ARIMA 类模型(包括 ARIMAX 和 SARIMA 等变体)的最佳超参数。
不深入探讨算法的技术细节,它首先使用 KPSS 测试确定差分的次数。然后,算法使用逐步搜索方法遍历模型空间,寻找一个拟合效果更好的模型。比较模型时,常用的评估指标是 赤池信息量准则(AIC)。该指标提供了模型拟合度与简洁性之间的权衡——AIC 处理了过拟合和欠拟合的风险。当我们比较多个模型时,AIC 值越低,模型越好。有关 auto-ARIMA 方法的更完整描述,请参阅 另见 部分提到的资料。
auto-ARIMA 框架也适用于 ARIMA 模型的扩展:
ARIMAX—向模型中添加外生变量。
SARIMA(季节性 ARIMA)—扩展 ARIMA 以考虑时间序列中的季节性。其完整规格为 SARIMA(p,d,q)(P,D,Q)m,其中大写的参数类似于原始参数,但它们指的是时间序列中的季节性成分。m 代表季节性的周期。
在本方案中,我们将再次使用 2010 到 2019 年间的美国月度失业率数据。
准备工作
我们将使用与 时间序列分解 方案中相同的数据。
如何操作...
执行以下步骤,使用 auto-ARIMA 方法找到最佳拟合 ARIMA 模型:
导入库:
import pandas as pd import pmdarima as pm from sklearn.metrics import mean_absolute_percentage_error
创建训练/测试集分割:
TEST_LENGTH = 12 df_train = df.iloc[:-TEST_LENGTH] df_test = df.iloc[-TEST_LENGTH:]
使用 auto-ARIMA 方法找到 ARIMA 模型的最佳超参数:
auto_arima = pm.auto_arima(df_train, test="adf", seasonal=False, with_intercept=False, stepwise=True, suppress_warnings=True, trace=True) auto_arima.summary()
执行代码片段会生成以下摘要:
图 6.28:使用 auto-ARIMA 方法识别的最佳拟合 ARIMA 模型的摘要
该程序指示最佳拟合 ARIMA 模型为 ARIMA(2,1,2)。但正如你所看到的,图 6.28 和 图 6.21 中的结果是不同的。这是因为在后者的情况下,我们将 ARIMA(2,1,2) 模型拟合到对数变换后的系列,而在本方案中,我们没有应用对数变换。
由于我们设置了
trace=True
,我们还可以看到关于过程中拟合的模型的以下信息:Performing stepwise search to minimize aic ARIMA(2,1,2)(0,0,0)[0] : AIC=7.411, Time=0.24 sec ARIMA(0,1,0)(0,0,0)[0] : AIC=77.864, Time=0.01 sec ARIMA(1,1,0)(0,0,0)[0] : AIC=77.461, Time=0.01 sec ARIMA(0,1,1)(0,0,0)[0] : AIC=75.688, Time=0.01 sec ARIMA(1,1,2)(0,0,0)[0] : AIC=68.551, Time=0.01 sec ARIMA(2,1,1)(0,0,0)[0] : AIC=54.321, Time=0.03 sec ARIMA(3,1,2)(0,0,0)[0] : AIC=7.458, Time=0.07 sec ARIMA(2,1,3)(0,0,0)[0] : AIC=inf, Time=0.07 sec ARIMA(1,1,1)(0,0,0)[0] : AIC=78.507, Time=0.02 sec ARIMA(1,1,3)(0,0,0)[0] : AIC=60.069, Time=0.02 sec ARIMA(3,1,1)(0,0,0)[0] : AIC=41.703, Time=0.02 sec ARIMA(3,1,3)(0,0,0)[0] : AIC=10.527, Time=0.10 sec ARIMA(2,1,2)(0,0,0)[0] intercept : AIC=inf, Time=0.08 sec Best model: ARIMA(2,1,2)(0,0,0)[0] Total fit time: 0.740 seconds
类似于使用
statsmodels
库估算的 ARIMA 模型,在pmdarima
(它实际上是statsmodels
的一个封装)中,我们也可以使用plot_diagnostics
方法通过查看残差来分析模型的拟合情况:auto_arima.plot_diagnostics(figsize=(18, 14), lags=25)
执行代码片段会生成以下图形:
图 6.29:最佳拟合 ARIMA 模型的诊断图
类似于 图 6.26 中的诊断图,这个 ARIMA(2,1,2) 模型也未能很好地捕捉到年度季节性模式——我们可以从自相关图中清楚地看到这一点。
使用 auto-ARIMA 方法找到 SARIMA 模型的最佳超参数:
auto_sarima = pm.auto_arima(df_train, test="adf", seasonal=True, m=12, with_intercept=False, stepwise=True, suppress_warnings=True, trace=True) auto_sarima.summary()
执行代码片段会生成以下摘要:
图 6.30:使用 auto-ARIMA 程序识别的最佳拟合 SARIMA 模型的总结。
就像我们之前做的那样,我们还将查看各种残差图:
auto_sarima.plot_diagnostics(figsize=(18, 14), lags=25)
执行代码片段会生成以下图形:
图 6.31:最佳拟合 SARIMA 模型的诊断图。
我们可以清楚地看到,SARIMA 模型比 ARIMA(2,1,2) 模型拟合得更好。
计算两个模型的预测值并绘制它们:
df_test["auto_arima"] = auto_arima.predict(TEST_LENGTH) df_test["auto_sarima"] = auto_sarima.predict(TEST_LENGTH) df_test.plot(title="Forecasts of the best ARIMA/SARIMA models")
执行代码片段会生成以下图形:
图 6.32:使用 auto-ARIMA 程序识别的 ARIMA 和 SARIMA 模型的预测结果。
不足为奇的是,SARIMA 模型比 ARIMA 模型更好地捕捉到季节性模式。这一点也反映在下面计算的性能指标中。我们还计算了 MAPEs:
mape_auto_arima = mean_absolute_percentage_error(
df_test["unemp_rate"],
df_test["auto_arima"]
)
mape_auto_sarima = mean_absolute_percentage_error(
df_test["unemp_rate"],
df_test["auto_sarima"]
)
print(f"MAPE of auto-ARIMA: {100*mape_auto_arima:.2f}%")
print(f"MAPE of auto-SARIMA: {100*mape_auto_sarima:.2f}%")
执行代码片段会生成以下输出:
MAPE of auto-ARIMA: 6.17%
MAPE of auto-SARIMA: 5.70%
它是如何工作的……
导入库后,我们创建了训练集和测试集,就像在之前的示例中一样。
在步骤 3中,我们使用 auto_arima
函数来找到 ARIMA 模型的最佳超参数。在使用时,我们指定:
我们希望使用增广的迪基-富勒检验作为平稳性检验,而不是 KPSS 检验。
我们关闭了季节性,以便拟合一个 ARIMA 模型,而不是 SARIMA。
我们希望估算一个没有截距的模型,这也是在
statsmodels
中估算 ARIMA 时的默认设置(在ARIMA
类的trend
参数下)。我们想使用逐步算法来识别最佳超参数。当我们将这个设置为
False
时,函数将执行穷尽网格搜索(尝试所有可能的超参数组合),类似于scikit-learn
的GridSearchCV
类。当使用这种情况时,我们可以指定n_jobs
参数来指定可以并行拟合的模型数量。
我们还可以尝试许多不同的设置,例如:
选择搜索的超参数起始值。
限制搜索中参数的最大值。
选择不同的统计测试来确定差异的数量(也包括季节性差异)。
选择一个超出样本的评估期(
out_of_sample_size
)。这将使算法在数据的某个时间点(最后一个观测值减去out_of_sample_size
)之前拟合模型,并在保留集上进行评估。当我们更关心预测性能而非训练数据的拟合时,这种选择最佳模型的方法可能更为合适。我们可以限制拟合模型的最大时间或尝试的最大超参数组合数量。当估算季节性模型时,尤其是在更细粒度的数据(例如,每周数据)上,这一点尤其有用,因为这种情况往往需要较长时间来拟合。
在步骤 4中,我们使用auto_arima
函数找到了最佳的 SARIMA 模型。为此,我们指定了seasonal=True
,并通过设置m=12
表示我们正在处理月度数据。
最后,我们使用predict
方法计算了两个模型的预测结果,将它们与真实值一起绘制,并计算了 MAPE 值。
还有更多内容...
我们可以使用pmdarima
库中的 auto-ARIMA 框架来估计更复杂的模型或整个管道,这些管道包括变换目标变量或添加新特征。在本节中,我们展示了如何执行这些操作。
我们从导入更多的类开始:
from pmdarima.pipeline import Pipeline
from pmdarima.preprocessing import FourierFeaturizer
from pmdarima.preprocessing import LogEndogTransformer
from pmdarima import arima
对于第一个模型,我们训练了一个带有附加特征(外生变量)的 ARIMA 模型。作为实验,我们尝试提供指示给定观测来自哪个月份的特征。如果这样有效,我们可能就不需要估计 SARIMA 模型来捕捉年度季节性。
我们使用pd.get_dummies
函数创建虚拟变量。每一列包含一个布尔标志,指示该观测是否来自给定月份。
我们还需要从新的 DataFrame 中删除第一列,以避免虚拟变量陷阱(完美多重共线性)。我们为训练集和测试集都添加了这些新变量:
month_dummies = pd.get_dummies(
df.index.month,
prefix="month_",
drop_first=True
)
month_dummies.index = df.index
df = df.join(month_dummies)
df_train = df.iloc[:-TEST_LENGTH]
df_test = df.iloc[-TEST_LENGTH:]
然后我们使用auto_arima
函数来找到最佳拟合的模型。与本食谱的步骤 3相比,唯一的变化是我们必须通过exogenous
参数指定外生变量。我们指定了除目标列以外的所有列。或者,我们也可以将附加变量保存在一个具有与目标相同索引的单独对象中:
auto_arimax = pm.auto_arima(
df_train[["unemp_rate"]],
exogenous=df_train.drop(columns=["unemp_rate"]),
test="adf",
seasonal=False,
with_intercept=False,
stepwise=True,
suppress_warnings=True,
trace=True
)
auto_arimax.summary()
执行该代码片段生成以下摘要:
图 6.33:带外生变量的 ARIMA 模型的摘要
我们还通过使用plot_diagnostics
方法查看残差图。似乎通过包含虚拟变量,解决了与年度季节性相关的自相关问题。
图 6.34:带外生变量的 ARIMA 模型的诊断图
最后,我们还展示了如何创建整个数据转换和建模管道,该管道同样能找到最佳拟合的 ARIMA 模型。我们的管道包含三个步骤:
我们对目标变量进行了对数变换。
我们使用
FourierFeaturizer
创建了新的特征——解释傅里叶级数超出了本书的范围。实际上,使用傅里叶变换可以让我们在不使用季节性模型本身的情况下,考虑到季节性时间序列中的季节性。为了提供更多背景信息,这与我们使用月份虚拟变量所做的类似。FourierFeaturizer
类提供了分解后的季节性傅里叶项,作为外生特征数组。我们需要指定季节性周期* m *。我们使用 auto-ARIMA 过程找到最佳拟合的模型。请记住,当使用管道时,我们必须使用
AutoARIMA
类,而不是pm.auto_arima
函数。这两个提供相同的功能,只是这次我们必须使用类,以使其与Pipeline
功能兼容。
auto_arima_pipe = Pipeline([
("log_transform", LogEndogTransformer()),
("fourier", FourierFeaturizer(m=12)),
("arima", arima.AutoARIMA(stepwise=True, trace=1,
error_action="warn",
test="adf", seasonal=False,
with_intercept=False,
suppress_warnings=True))
])
auto_arima_pipe.fit(df_train[["unemp_rate"]])
在拟合管道时生成的日志中,我们可以看到选择的最佳模型是:
Best model: ARIMA(4,1,0)(0,0,0)[0] intercept
使用管道的最大优点是我们不需要自己执行所有步骤。我们只需定义一个管道,然后将时间序列作为输入提供给fit
方法。通常,管道(包括我们将在第十三章《应用机器学习:信用违约识别》中看到的scikit-learn
中的管道)是一个非常有用的功能,帮助我们:
使代码具有可重用性
定义在数据上执行的操作的明确顺序
在创建特征和拆分数据时,避免潜在的数据泄漏
使用管道的一个潜在缺点是某些操作不再那么容易追踪(中间结果没有作为单独的对象存储),并且访问管道中特定元素的难度稍微增加。例如,我们不能运行auto_arima_pipe.summary()
来获取拟合的 ARIMA 模型的摘要。
在下面,我们使用predict
方法创建预测。关于这一步,有几个值得注意的地方:
我们创建了一个新的 DataFrame,只包含目标。这样做是为了移除在本食谱中之前创建的额外列。
在使用拟合的 ARIMAX 模型的
predict
方法时,我们还需要提供预测所需的外生变量。它们作为X
参数传递。当我们使用转换目标变量的管道的
predict
方法时,返回的预测(或拟合值)是以原始输入的相同尺度表示的。在我们的案例中,幕后发生了以下过程。首先,对原始时间序列进行了对数变换。然后,添加了新的特征。接下来,我们从模型中获得预测(仍然是在对数变换的尺度上)。最后,使用指数函数将预测转换回原始尺度。results_df = df_test[["unemp_rate"]].copy() results_df["auto_arimax"] = auto_arimax.predict( TEST_LENGTH, X=df_test.drop(columns=["unemp_rate"]) ) results_df["auto_arima_pipe"] = auto_arima_pipe.predict(TEST_LENGTH) results_df.plot(title="Forecasts of the ARIMAX/pipe models")
运行代码生成了以下图表:
图 6.35:ARIMAX 模型和 ARIMA 管道的预测
作为参考,我们还添加了这些预测的评分:
MAPE of auto-ARIMAX: 6.88%
MAPE of auto-pipe: 4.61%
在本章中我们尝试的所有 ARIMA 模型中,管道模型表现最好。然而,它的表现仍然明显逊色于指数平滑方法。
在使用pmdarima
库中 ARIMA 模型/管道的predict
方法时,我们可以将return_conf_int
参数设置为True
。这样,方法不仅会返回点预测,还会返回相应的置信区间。
另请参见
Hyndman, R. J. & Athanasopoulos, G. 2021. “ARIMA 建模在 Fable 中的应用。” 见于 Forecasting: Principles and Practice,第 3 版,OTexts:墨尔本,澳大利亚。OTexts.com/fpp3。访问日期:2022-05-08 –
otexts.com/fpp3/arima-r.html
。Hyndman, R. J. & Khandakar, Y., 2008. “自动时间序列预测:R 的 forecast 包,” Journal of Statistical Software,27:1-22。
总结
在这一章中,我们涵盖了时间序列分析与预测的经典(统计学)方法。我们学习了如何将任何时间序列分解为趋势、季节性和残差组成部分。这一步骤在更好地理解所探讨的时间序列时非常有帮助。我们也可以直接将其用于建模目的。
然后,我们解释了如何测试时间序列是否平稳,因为一些统计模型(例如 ARIMA)要求数据是平稳的。我们还解释了如何将非平稳的时间序列转换为平稳序列的步骤。
最后,我们探讨了两种最受欢迎的时间序列预测统计方法——指数平滑方法和 ARIMA 模型。我们还简要介绍了更现代的方法来估计这些模型,这些方法涉及自动调参和超参数选择。
在下一章中,我们将探讨基于机器学习的方法进行时间序列预测。
第七章:基于机器学习的时间序列预测方法
在上一章中,我们简要介绍了时间序列分析,并展示了如何使用统计方法(如 ARIMA 和 ETS)进行时间序列预测。尽管这些方法仍然非常流行,但它们有些过时。在这一章中,我们将重点介绍基于机器学习的时间序列预测方法。
我们首先解释不同的时间序列模型验证方法。然后,我们转向机器学习模型的输入,即特征。我们概述了几种特征工程方法,并介绍了一种自动特征提取工具,它能够为我们生成数百或数千个特征。
在讨论完这两个话题后,我们引入了简化回归的概念,它使我们能够将时间序列预测问题重新框定为常规回归问题。因此,它允许我们使用流行且经过验证的回归算法(如scikit-learn
、XGBoost
、LightGBM
等)来进行时间序列预测。接下来,我们还展示了如何使用 Meta 的 Prophet 算法。最后,我们通过介绍一种流行的 AutoML 工具来结束本章,该工具允许我们仅用几行代码训练和调优数十种机器学习模型。
本章我们将涵盖以下内容:
时间序列的验证方法
时间序列的特征工程
将时间序列预测视为简化回归
使用 Meta 的 Prophet 进行预测
使用 PyCaret 进行时间序列预测的 AutoML 方法
时间序列的验证方法
在上一章中,我们训练了一些统计模型来预测时间序列的未来值。为了评估这些模型的性能,我们最初将数据分为训练集和测试集。然而,这绝对不是验证模型的唯一方法。
一种非常流行的评估模型性能的方法叫做交叉验证。它特别适用于选择模型的最佳超参数集或为我们试图解决的问题选择最佳模型。交叉验证是一种技术,它通过提供多次模型性能估计,帮助我们获得模型泛化误差的可靠估计。因此,交叉验证在处理较小数据集时非常有用。
基本的交叉验证方案被称为k 折交叉验证,在这种方法中,我们将训练数据随机划分为k个子集。然后,我们使用k−1 个子集训练模型,并在第k个子集上评估模型的性能。我们重复这个过程k次,并对结果的分数进行平均。图 7.1展示了这一过程。
图 7.1:k 折交叉验证的示意图
正如你可能已经意识到的那样,k-折交叉验证并不适用于评估时间序列模型,因为它没有保留时间的顺序。例如,在第一轮中,我们使用最后 4 个折叠的数据进行模型训练,并使用第一个折叠进行评估。
由于k-折交叉验证对于标准回归和分类任务非常有用,我们将在第十三章《应用机器学习:信用违约识别》中对其进行更深入的讨论。
Bergmeir et al.(2018)表明,在纯自回归模型的情况下,如果所考虑的模型具有无相关的误差,使用标准k-折交叉验证是可行的。
幸运的是,我们可以相当容易地将k-折交叉验证的概念适应到时间序列领域。由此产生的方法称为前向滚动验证。在这种验证方案中,我们通过一次增加(或多个)折叠来扩展/滑动训练窗口。
图 7.2 说明了前向滚动验证的扩展窗口变种,这也被称为锚定前向滚动验证。如你所见,我们在逐步增加训练集的大小,同时保持下一个折叠作为验证集。
图 7.2:带扩展窗口的前向滚动验证
这种方法带有一定的偏差——在较早的轮次中,我们使用的历史数据比后期的训练数据要少得多,这使得来自不同轮次的误差不能直接比较。例如,在验证的前几轮中,模型可能没有足够的训练数据来正确学习季节性模式。
解决这个问题的一种尝试可能是使用滑动窗口方法,而不是扩展窗口方法。结果是,所有模型都使用相同数量的数据进行训练,因此误差是直接可比的。图 7.3 说明了这一过程。
图 7.3:带滑动窗口的前向滚动验证
当我们有大量训练数据时(并且每个滑动窗口提供足够的数据供模型学习模式)或当我们不需要回顾太远的过去来学习用于预测未来的相关模式时,我们可以使用这种方法。
我们可以使用嵌套交叉验证方法,同时调整模型的超参数,以获得更准确的误差估计。在嵌套交叉验证中,有一个外部循环用于估计模型的性能,而内部循环则用于超参数调整。我们在另请参阅部分提供了一些有用的参考资料。
在这个实例中,我们展示了如何使用前向滚动验证(使用扩展窗口和滑动窗口)来评估美国失业率的预测。
如何执行…
执行以下步骤来使用前向滚动验证计算模型的性能:
导入库并进行身份验证:
import pandas as pd import numpy as np from sklearn.model_selection import TimeSeriesSplit, cross_validate from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_absolute_percentage_error import nasdaqdatalink nasdaqdatalink.ApiConfig.api_key = "YOUR_KEY_HERE"
下载 2010 年至 2019 年期间的美国月度失业率:
df = ( nasdaqdatalink.get(dataset="FRED/UNRATENSA", start_date="2010-01-01", end_date="2019-12-31") .rename(columns={"Value": "unemp_rate"}) ) df.plot(title="Unemployment rate (US) - monthly")
执行代码段会生成以下图表:
图 7.4:美国月度失业率
创建简单特征:
df["linear_trend"] = range(len(df)) df["month"] = df.index.month
由于我们避免使用自回归特征,并且我们知道所有特征的未来值,因此我们能够进行任意长时间范围的预测。
对月份特征使用独热编码:
month_dummies = pd.get_dummies( df["month"], drop_first=True, prefix="month" ) df = df.join(month_dummies) \ .drop(columns=["month"])
将目标与特征分开:
X = df.copy() y = X.pop("unemp_rate")
定义扩展窗口的前向交叉验证并打印折叠的索引:
expanding_cv = TimeSeriesSplit(n_splits=5, test_size=12) for fold, (train_ind, valid_ind) in enumerate(expanding_cv.split(X)): print(f"Fold {fold} ----") print(f"Train indices: {train_ind}") print(f"Valid indices: {valid_ind}")
执行代码段会生成以下日志:
Fold 0 ---- Train indices: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59] Valid indices: [60 61 62 63 64 65 66 67 68 69 70 71] Fold 1 ---- Train indices: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71] Valid indices: [72 73 74 75 76 77 78 79 80 81 82 83] Fold 2 ---- Train indices: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83] Valid indices: [84 85 86 87 88 89 90 91 92 93 94 95] Fold 3 ---- Train indices: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95] Valid indices: [96 97 98 99 100 101 102 103 104 105 106 107] Fold 4 ---- Train indices: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107] Valid indices: [108 109 110 111 112 113 114 115 116 117 118 119]
通过分析日志并记住我们正在使用按月的数据,我们可以看到在第一次迭代中,模型将使用五年的数据进行训练,并使用第六年进行评估。在第二轮中,模型将使用前六年的数据进行训练,并使用第七年进行评估,依此类推。
使用扩展窗口验证评估模型的性能:
scores = [] for train_ind, valid_ind in expanding_cv.split(X): lr = LinearRegression() lr.fit(X.iloc[train_ind], y.iloc[train_ind]) y_pred = lr.predict(X.iloc[valid_ind]) scores.append( mean_absolute_percentage_error(y.iloc[valid_ind], y_pred) ) print(f"Scores: {scores}") print(f"Avg. score: {np.mean(scores)}")
执行代码段会生成以下输出:
Scores: [0.03705079312389441, 0.07828415627306308, 0.11981060282173006, 0.16829494012910876, 0.25460459651634165] Avg. score: 0.1316090177728276
通过交叉验证轮次的平均性能(通过 MAPE 衡量)为 13.2%。
我们可以轻松地使用
scikit-learn
中的cross_validate
函数,而不是手动迭代分割:cv_scores = cross_validate( LinearRegression(), X, y, cv=expanding_cv, scoring=["neg_mean_absolute_percentage_error", "neg_root_mean_squared_error"] ) pd.DataFrame(cv_scores)
执行代码段会生成以下输出:
图 7.5:使用扩展窗口的前向交叉验证中每一轮验证的得分
通过查看得分,我们发现它们与我们手动迭代交叉验证分割时获得的得分完全相同(除了负号)。
定义滑动窗口验证并打印折叠的索引:
sliding_cv = TimeSeriesSplit( n_splits=5, test_size=12, max_train_size=60 ) for fold, (train_ind, valid_ind) in enumerate(sliding_cv.split(X)): print(f"Fold {fold} ----") print(f"Train indices: {train_ind}") print(f"Valid indices: {valid_ind}")
执行代码段会生成以下输出:
Fold 0 ---- Train indices: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59] Valid indices: [60 61 62 63 64 65 66 67 68 69 70 71] Fold 1 ---- Train indices: [12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71] Valid indices: [72 73 74 75 76 77 78 79 80 81 82 83] Fold 2 ---- Train indices: [24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83] Valid indices: [84 85 86 87 88 89 90 91 92 93 94 95] Fold 3 ---- Train indices: [36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95] Valid indices: [96 97 98 99 100 101 102 103 104 105 106 107] Fold 4 ---- Train indices: [48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107] Valid indices: [108 109 110 111 112 113 114 115 116 117 118 119]
通过分析日志,我们可以看到以下内容:
每次,模型将使用恰好五年的数据进行训练。
在交叉验证轮次之间,我们按 12 个月移动。
验证折叠与我们在使用扩展窗口验证时看到的折叠相对应。因此,我们可以轻松比较得分,以查看哪种方法更好。
使用滑动窗口验证评估模型的性能:
cv_scores = cross_validate( LinearRegression(), X, y, cv=sliding_cv, scoring=["neg_mean_absolute_percentage_error", "neg_root_mean_squared_error"] ) pd.DataFrame(cv_scores)
执行代码段会生成以下输出:
图 7.6:使用滑动窗口的前向交叉验证中每一轮验证的得分
通过聚合 MAPE,我们得到了 9.98%的平均得分。看来在每次迭代中使用 5 年的数据比使用扩展窗口更能获得更好的平均得分。一个可能的结论是,在这种特定情况下,更多的数据并不会导致更好的模型。相反,当只使用最新的数据点时,我们可以获得更好的模型。
它是如何工作的……
首先,我们导入了所需的库并进行了 Nasdaq Data Link 的身份验证。在第二步中,我们下载了美国的月度失业率。这是我们在上一章中使用的相同时间序列。
在步骤 3中,我们创建了两个简单的特征:
线性趋势,简单来说,就是有序时间序列的序数行号。根据对图 7.4的检查,我们看到失业率的整体趋势是下降的。我们希望这个特征能够捕捉到这种模式。
月份索引,用于标识给定的观测值来自哪个日历月份。
在步骤 4中,我们使用get_dummies
函数对月份特征进行了独热编码。我们在第十三章,应用机器学习:识别信用违约和第十四章,机器学习项目的高级概念中详细讲解了独热编码。简而言之,我们创建了新的列,每一列都是一个布尔标志,表示给定的观测值是否来自某个月份。此外,我们删除了第一列,以避免完美的多重共线性(即著名的虚拟变量陷阱)。
在步骤 5中,我们使用pandas
DataFrame 的pop
方法将特征与目标分开。
在步骤 6中,我们使用scikit-learn
中的TimeSeriesSplit
类定义了前向验证。我们指定了要进行 5 次分割,并且测试集的大小应为 12 个月。理想情况下,验证方案应当反映模型的实际使用情况。在这种情况下,我们可以说机器学习模型将用于预测未来 12 个月的月度失业率。
然后,我们使用for
循环打印每一轮交叉验证中使用的训练和验证索引。TimeSeriesSplit
类的split
方法返回的索引是序数的,但我们可以轻松将其映射到实际的时间序列索引上。
我们决定不使用自回归特征,因为没有这些特征,我们可以预测未来任意长的时间。自然地,使用 AR 特征我们也可以做到这一点,但我们需要适当地处理它们。这种规范对于这个用例来说更为简便。
在步骤 7中,我们使用了一个非常相似的for
循环,这次是评估模型的性能。在每次循环中,我们使用该轮的训练数据训练线性回归模型,为对应的验证集创建预测,最后计算性能指标,以 MAPE 表示。我们将交叉验证得分添加到一个列表中,然后计算所有 5 轮交叉验证的平均性能。
我们可以使用scikit-learn
库中的cross_validate
函数,而不是使用自定义的for
循环。使用它的一个潜在优点是,它会自动计算模型拟合和预测步骤所花费的时间。我们展示了如何使用这种方法获得 MAPE 和 MSE 得分。
使用cross_validate
函数(或其他scikit-learn
功能,如网格搜索)时需要注意的一点是,我们必须提供度量标准的名称,例如"neg_mean_absolute_percentage_error"
。这是scikit-learn
的metrics
模块中使用的约定,即得分值较高比较低的得分值更好。因此,由于我们希望最小化这些度量标准,它们被取反。
以下是用于评估时间序列预测准确性的最常见度量标准列表:
均方误差(MSE)——机器学习中最常见的度量标准之一。由于单位不是很直观(与原始预测的单位不同),我们可以使用 MSE 来比较各种模型在同一数据集上的相对表现。
均方根误差(RMSE)——通过取 MSE 的平方根,这个度量现在与原始时间序列处于相同的尺度。
平均绝对误差(MAE)——我们不是取平方,而是取误差的绝对值。因此,MAE 与原始时间序列具有相同的尺度。而且,MAE 对异常值的容忍度更高,因为在计算平均值时,每个观测值被赋予相同的权重。而对于平方度量,异常值的惩罚更加显著。
平均绝对百分比误差(MAPE)——与 MAE 非常相似,但以百分比表示。因此,对于许多业务相关人员来说,这更容易理解。然而,它有一个严重的缺点——当实际值为零时,度量标准会假设将误差除以实际值,而这是数学上不可行的。
自然,这些只是选定度量标准中的一部分。强烈建议深入研究这些度量标准,以全面理解它们的优缺点。例如,RMSE 通常作为优化度量标准被偏好,因为平方比绝对值更容易处理,特别是在数学优化需要求导时。
在步骤 8和9中,我们展示了如何使用滑动窗口方法创建验证方案。唯一的不同是,我们在实例化TimeSeriesSplit
类时指定了max_train_size
参数。
有时候我们可能会对在交叉验证中创建训练集和验证集之间的间隔感兴趣。例如,在第一次迭代中,训练应使用前五个值进行,然后评估应在第七个值上进行。我们可以通过使用TimeSeriesSplit
类的gap
参数轻松地实现这种场景。
还有更多…
在本教程中,我们描述了验证时间序列模型的标准方法。然而,实际上有许多更高级的验证方法。实际上,其中大多数来自金融领域,因为基于金融时间序列验证模型在多个方面更为复杂。我们在下面简要提到了一些更高级的方法,以及它们试图解决的挑战。
TimeSeriesSplit
的一个局限性是它只能在记录级别工作,无法处理分组。假设我们有一个日度股票回报的数据集。根据我们的交易算法的规定,我们是在每周或每月的级别上评估模型的性能,并且观察值不应该在每周/每月的分组之间重叠。图 7.7通过使用训练组大小为 3,验证组大小为 1 来说明这一概念。
图 7.7:分组时间序列验证的架构
为了考虑这种观察值的分组(按周或按月),我们需要使用分组时间序列验证,这是scikit-learn
的TimeSeriesSplit
和GroupKFold
的结合体。互联网上有许多实现这种概念的例子,其中之一可以在mlxtend
库中找到。
为了更好地说明预测金融时间序列和评估模型性能时可能出现的问题,我们必须扩展与时间序列相关的思维模型。这样的时间序列实际上对每个观察值都有两个时间戳:
一个预测或交易时间戳——当机器学习模型做出预测时,我们可能会开盘交易。
一个评估或事件时间戳——当预测/交易的响应变得可用时,我们实际上可以计算预测误差。
例如,我们可以有一个分类模型,用来预测某只股票在接下来的 5 个工作日内价格是上涨还是下跌,变化幅度为X。基于这个预测,我们做出交易决策。我们可能会选择做多。在接下来的 5 天内,可能会发生很多事情。价格可能会或可能不会变动X,止损或止盈机制可能会被触发,我们也可能会直接平仓,或者有其他各种可能的结果。因此,我们实际上只能在评估时间戳进行预测评估,在这个例子中,是 5 个工作日后。
这样的框架存在将测试集信息泄露到训练集中的风险。因此,这很可能会夸大模型的性能。因此,我们需要确保所有数据都是基于时间点的,意味着在模型使用数据时,数据在那个时间点是可用的。
例如,在训练/验证分割点附近,可能会有一些训练样本,其评估时间晚于验证样本的预测时间。这些重叠的样本很可能是相关的,换句话说,不太可能是独立的,这会导致集合之间的信息泄露。
为了解决前瞻偏差,我们可以应用清洗。其思路是从训练集中删除任何评估时间晚于验证集最早预测时间的样本。换句话说,我们去除那些事件时间与验证集预测时间重叠的观测值。图 7.8展示了一个示例。
图 7.8:清洗的示例
你可以在金融机器学习的进展(De Prado,2018)或timeseriescv
库中找到运行带清洗的步进交叉验证的代码。
单独进行清洗可能不足以消除所有泄漏,因为样本之间可能存在较长时间跨度的相关性。我们可以尝试通过应用禁运来解决这一问题,禁运进一步排除那些跟随验证样本的训练样本。如果一个训练样本的预测时间落在禁运期内,我们会直接从训练集中删除该观测值。我们根据手头的问题估计禁运期所需的大小。图 7.9展示了同时应用清洗和禁运的例子。
图 7.9:清洗和禁运的示例
有关清洗和禁运的更多细节(以及它们在 Python 中的实现),请参考金融机器学习的进展(De Prado,2018)。
De Prado(2018)还介绍了组合清洗交叉验证算法,该算法将清洗和禁运的概念与回测结合(我们在第十二章,回测交易策略中讲解回测交易策略)以及交叉验证。
另见
Bergmeir, C., & Benítez, J. M. 2012. “关于使用交叉验证进行时间序列预测器评估,”信息科学,191: 192-213。
Bergmeir, C., Hyndman, R. J., & Koo, B. 2018. “关于交叉验证在评估自回归时间序列预测中的有效性的一些说明,”计算统计与数据分析,120: 70-83。
De Prado, M. L. 2018. 金融机器学习的进展。John Wiley & Sons。
Hewamalage, H., Ackermann, K., & Bergmeir, C. 2022. 数据科学家的预测评估:常见陷阱与最佳实践。arXiv 预印本 arXiv:2203.10716。
Tashman, L. J. 2000. “样本外预测准确性测试:分析与回顾,”国际预测学杂志,16(4): 437-450。
Varma, S., & Simon, R. 2006. “使用交叉验证进行模型选择时的误差估计偏差,”BMC 生物信息学,7(1): 1-8。
时间序列的特征工程
在上一章中,我们仅使用时间序列作为输入训练了一些统计模型。另一方面,当我们从机器学习(ML)角度进行时间序列预测时,特征工程变得至关重要。在时间序列的背景下,特征工程意味着创建有用的变量(无论是从时间序列本身还是使用其时间戳生成),以帮助获得准确的预测。自然,特征工程不仅对纯机器学习模型很重要,我们还可以利用它为统计模型丰富外部回归变量,例如,在 ARIMAX 模型中。
正如我们提到的,创建特征的方法有很多种,关键在于对数据集的深刻理解。特征工程的示例包括:
从时间戳中提取相关信息。例如,我们可以提取年份、季度、月份、周数或星期几。
基于时间戳添加有关特殊日期的相关信息。例如,在零售行业中,我们可能希望添加有关所有假期的信息。要获取特定国家的假期日历,我们可以使用
holidays
库。添加目标变量的滞后值,类似于 AR 模型。
基于聚合值(如最小值、最大值、均值、中位数或标准差)在滚动或扩展窗口内创建特征。
计算技术指标。
在某种程度上,特征生成仅受数据、你的创造力或可用时间的限制。在本教程中,我们展示了如何基于时间序列的时间戳创建一组特征。
首先,我们提取月份信息,并将其编码为虚拟变量(独热编码)。在时间序列的上下文中,这种方法的最大问题是缺乏时间的周期性连续性。通过一个例子可以更容易理解这一点。
想象一个使用能源消耗数据的场景。如果我们使用观察到的消耗月份信息,直观上讲,相邻两个月之间应该存在某种联系,例如,12 月与 1 月之间,或 1 月与 2 月之间的联系。相比之下,时间相隔较远的月份之间的联系可能较弱,例如,1 月与 7 月之间的联系。相同的逻辑也适用于其他与时间相关的信息,例如,某一天内的小时。
我们提出了两种将这些信息作为特征纳入的可能方式。第一种方式基于三角函数(正弦和余弦变换)。第二种方式使用径向基函数来编码类似的信息。
在本教程中,我们使用了 2017 年至 2019 年的模拟每日数据。我们选择模拟数据,因为本教程的主要目的是展示不同类型的时间信息编码如何影响模型。使用遵循清晰模式的模拟数据更容易展示这一点。自然,本教程中展示的特征工程方法可以应用于任何时间序列。
如何实现…
执行以下步骤以创建与时间相关的特征,并使用它们作为输入拟合线性模型:
导入库:
import numpy as np import pandas as pd from datetime import date from sklearn.linear_model import LinearRegression from sklearn.preprocessing import FunctionTransformer from sklego.preprocessing import RepeatingBasisFunction
生成具有重复模式的时间序列:
np.random.seed(42) range_of_dates = pd.date_range(start="2017-01-01", end="2019-12-31") X = pd.DataFrame(index=range_of_dates) X["day_nr"] = range(len(X)) X["day_of_year"] = X.index.day_of_year signal_1 = 2 + 3 * np.sin(X["day_nr"] / 365 * 2 * np.pi) signal_2 = 2 * np.sin(X["day_nr"] / 365 * 4 * np.pi + 365/2) noise = np.random.normal(0, 0.81, len(X)) y = signal_1 + signal_2 + noise y.name = "y" y.plot(title="Generated time series")
执行代码片段会生成以下图表:
图 7.10:生成的具有重复模式的时间序列
由于添加了正弦曲线和一些随机噪声,我们获得了一个具有重复模式的时间序列,且该模式在多年间反复出现。
将时间序列存储在一个新的 DataFrame 中:
results_df = y.to_frame() results_df.columns = ["y_true"]
将月份信息编码为虚拟特征:
X_1 = pd.get_dummies( X.index.month, drop_first=True, prefix="month" ) X_1.index = X.index X_1
执行代码片段会生成以下预览,显示带有虚拟编码的月份特征的 DataFrame:
图 7.11:虚拟编码的月份特征预览
拟合线性回归模型并绘制样本内预测:
model_1 = LinearRegression().fit(X_1, y) results_df["y_pred_1"] = model_1.predict(X_1) ( results_df[["y_true", "y_pred_1"]] .plot(title="Fit using month dummies") )
执行代码片段会生成以下图表:
图 7.12:使用月份虚拟特征进行线性回归得到的拟合
我们可以清晰地看到拟合的阶梯状模式,对应于月份特征的 12 个唯一值。拟合的锯齿状是由虚拟特征的不连续性引起的。在其他方法中,我们尝试克服这个问题。
定义用于创建周期性编码的函数:
def sin_transformer(period): return FunctionTransformer(lambda x: np.sin(x / period * 2 * np.pi)) def cos_transformer(period): return FunctionTransformer(lambda x: np.cos(x / period * 2 * np.pi))
使用周期性编码对月份和日期信息进行编码:
X_2 = X.copy() X_2["month"] = X_2.index.month X_2["month_sin"] = sin_transformer(12).fit_transform(X_2)["month"] X_2["month_cos"] = cos_transformer(12).fit_transform(X_2)["month"] X_2["day_sin"] = ( sin_transformer(365).fit_transform(X_2)["day_of_year"] ) X_2["day_cos"] = ( cos_transformer(365).fit_transform(X_2)["day_of_year"] ) fig, ax = plt.subplots(2, 1, sharex=True, figsize=(16,8)) X_2[["month_sin", "month_cos"]].plot(ax=ax[0]) ax[0].legend(loc="center left", bbox_to_anchor=(1, 0.5)) X_2[["day_sin", "day_cos"]].plot(ax=ax[1]) ax[1].legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.suptitle("Cyclical encoding with sine/cosine transformation")
执行代码片段会生成以下图表:
图 7.13:使用正弦/余弦变换的周期性编码
从图 7.13中,我们可以得出两个结论:
使用月份进行编码时,曲线呈阶梯状。使用每日频率时,曲线则平滑得多。
图表说明了使用两条曲线而不是一条曲线的必要性。由于这些曲线具有重复(周期性)模式,如果我们通过图表为某一年绘制一条水平直线,线会与曲线相交两次。因此,单一的曲线不足以让模型理解观察点的时间,因为存在两种可能性。幸运的是,使用两条曲线时没有这个问题。
为了清楚地看到通过此转换获得的周期性表示,我们可以将正弦和余弦值绘制在一个散点图上,以表示某一年:
( X_2[X_2.index.year == 2017] .plot( kind="scatter", x="month_sin", y="month_cos", figsize=(8, 8), title="Cyclical encoding using sine/cosine transformations" ) )
执行代码片段会生成以下图表:
图 7.14:时间的周期性表示
在图 7.14中,我们可以看到没有重叠的值。因此,两个曲线可以用来确定给定观察点的时间。
使用每日正弦/余弦特征拟合模型:
X_2 = X_2[["day_sin", "day_cos"]] model_2 = LinearRegression().fit(X_2, y) results_df["y_pred_2"] = model_2.predict(X_2) ( results_df[["y_true", "y_pred_2"]] .plot(title="Fit using sine/cosine features") )
执行代码片段会生成以下图表:
图 7.15:使用周期性特征进行线性回归得到的拟合
使用径向基函数创建特征:
rbf = RepeatingBasisFunction(n_periods=12, column="day_of_year", input_range=(1,365), remainder="drop") rbf.fit(X) X_3 = pd.DataFrame(index=X.index, data=rbf.transform(X)) X_3.plot(subplots=True, sharex=True, title="Radial Basis Functions", legend=False, figsize=(14, 10))
执行代码片段会生成以下图表:
图 7.16:使用径向基函数创建的特征的可视化
图 7.16 展示了我们使用径向基函数和天数作为输入创建的 12 条曲线。每条曲线告诉我们与一年中特定日期的接近程度。例如,第一条曲线测量了与 1 月 1 日的距离。因此,我们可以观察到每年第一天的峰值,然后随着日期的远离,值对称下降。
基函数在输入范围上等间隔分布。我们选择创建 12 条曲线,因为我们希望径向基曲线类似于月份。这样,每个函数展示了与该月第一天的近似距离。由于月份的天数不等,所得到的距离是近似值。
使用 RBF 特征拟合模型:
model_3 = LinearRegression().fit(X_3, y) results_df["y_pred_3"] = model_3.predict(X_3) ( results_df[["y_true", "y_pred_3"]] .plot(title="Fit using RBF features") )
执行代码段会生成以下图表:
图 7.17:使用线性回归拟合的 RBF 编码特征的拟合结果
我们可以清楚地看到,使用 RBF 特征获得了迄今为止最好的拟合效果。
它是如何工作的……
在导入库之后,我们通过将两条信号线(使用正弦曲线创建)和一些随机噪声结合起来生成了人工时间序列。我们创建的时间序列跨越了三年的时间(2017 到 2019)。然后,我们创建了两列以供后续使用:
day_nr
—表示时间流逝的数字索引,等同于顺序行号。day_of_year
—年份中的天数。
在 步骤 3 中,我们将生成的时间序列存储在一个单独的 DataFrame 中。这样做是为了将模型的预测值存储在该 DataFrame 中。
在 步骤 4 中,我们使用 pd.get_dummies
方法创建了月份虚拟变量。有关这种方法的更多细节,请参考之前的食谱。
在 步骤 5 中,我们对特征拟合了线性回归模型,并使用拟合模型的 predict
方法获取拟合值。为了进行预测,我们使用与训练相同的数据集,因为我们只关心样本内的拟合结果。
在 步骤 6 中,我们定义了用于通过正弦和余弦函数获取循环编码的函数。我们创建了两个单独的函数,但这只是个人偏好问题,也可以创建一个函数来同时生成这两个特征。函数的 period
参数对应可用的周期数。例如,在编码月份时,我们使用 12;在编码日期时,我们使用 365 或 366。
在 步骤 7 中,我们使用循环编码对月份和日期信息进行了编码。我们已经有了包含天数的 day_of_year
列,所以只需要从 DatetimeIndex
中提取月份编号。然后,我们创建了四个带有循环编码的列。
在 步骤 8 中,我们删除了所有列,保留了年份天数的循环编码列。然后,我们拟合了线性回归模型,计算了拟合值并绘制了结果。
循环编码有一个潜在的重要缺点,这在使用基于树的模型时尤为明显。根据设计,基于树的模型在每次划分时基于单一特征进行分裂。正如我们之前所解释的,正弦/余弦特征应该同时考虑,以便正确识别时间点。
在第 9 步中,我们实例化了 RepeatingBasisFunction
类,它作为一个 scikit-learn
转换器工作。我们指定了要基于 day_of_year
列生成 12 条 RBF 曲线,并且输入范围是从 1 到 365(样本中没有闰年)。此外,我们指定了 remainder="drop"
,这将删除所有在转换前输入 DataFrame 中的其他列。或者,我们也可以将值指定为 "passthrough"
,这样既保留旧的特征,又保留新的特征。
值得一提的是,使用径向基函数时,我们可以调整两个关键的超参数:
n_periods—
径向基函数的数量。width
—这个超参数负责创建 RBF 时钟形曲线的形状。
我们可以使用类似网格搜索的方法来识别给定数据集的超参数的最优值。有关网格搜索过程的更多信息,请参阅第十三章,应用机器学习:识别信用违约。
在第 10 步中,我们再次拟合了模型,这次使用了 RBF 特征作为输入。
还有更多内容……
在这个案例中,我们展示了如何手动创建与时间相关的特征。当然,这些只是我们可以创建的成千上万种特征中的一小部分。幸运的是,有一些 Python 库可以简化特征工程/提取的过程。
我们将展示其中的两个方法。第一个方法来自 sktime
库,这是一个全面的库,相当于 scikit-learn
在时间序列中的应用。第二个方法则利用了名为 tsfresh
的库。该库允许我们通过几行代码自动生成数百或数千个特征。在后台,它结合了统计学、时间序列分析、物理学和信号处理中的一些已建立算法。
我们将在以下步骤中展示如何使用这两种方法。
导入库:
from sktime.transformations.series.date import DateTimeFeatures from tsfresh import extract_features from tsfresh.feature_extraction import settings from tsfresh.utilities.dataframe_functions import roll_time_series
使用
sktime
提取日期时间特征:dt_features = DateTimeFeatures( ts_freq="D", feature_scope="comprehensive" ) features_df_1 = dt_features.fit_transform(y) features_df_1.head()
执行该代码段生成以下包含提取特征的 DataFrame 预览:
图 7.18:提取特征的 DataFrame 预览
在图中,我们可以看到提取的特征。根据我们想要使用的机器学习算法,我们可能需要进一步对这些特征进行编码,例如使用虚拟变量。
在实例化
DateTimeFeatures
类时,我们提供了feature_scope
参数。在此情况下,我们生成了一个全面的特征集。我们也可以选择"minimal"
或"efficient"
特征集。提取的特征是基于
pandas
的DatetimeIndex
。有关从该索引中可以提取的所有特征的详细列表,请参阅pandas
的文档。使用
tsfresh
准备数据集进行特征提取:df = y.to_frame().reset_index(drop=False) df.columns = ["date", "y"] df["series_id"] = "a"
为了使用特征提取算法,除了时间序列本身外,我们的 DataFrame 必须包含一个日期列(或时间的顺序编码)和一个 ID 列。后者是必须的,因为 DataFrame 可能包含多个时间序列(以长格式存储)。例如,我们可以有一个包含标准普尔 500 指数所有成分股日常股价的 DataFrame。
创建一个汇总后的 DataFrame 以进行特征提取:
df_rolled = roll_time_series( df, column_id="series_id", column_sort="date", max_timeshift=30, min_timeshift=7 ).drop(columns=["series_id"]) df_rolled
执行该代码片段会生成汇总 DataFrame 的以下预览:
图 7.19:汇总 DataFrame 的预览
我们使用滑动窗口来汇总 DataFrame,因为我们希望实现以下目标:
计算对时间序列预测有意义的汇总特征。例如,我们可以计算过去 10 天的最小值/最大值,或者计算 20 天简单移动平均(SMA)技术指标。每次计算时,这些计算都涉及一个时间窗口,因为使用单一观测值来计算这些汇总值显然没有意义。
为所有可用的时间点提取特征,以便我们能够轻松地将它们插入到我们的机器学习预测模型中。通过这种方式,我们基本上一次性创建了整个训练数据集。
为此,我们使用
roll_time_series
函数创建了一个汇总后的 DataFrame,之后该 DataFrame 将用于特征提取。我们指定了最小和最大窗口大小。在我们的情况下,我们将丢弃短于 7 天的窗口,并使用最多 30 天的窗口。在图 7.19中,我们可以看到新添加的
id
列。如我们所见,多个观测值在id
列中具有相同的值。例如,值(a, 2017-01-08 00:00:00)
表示我们正在使用该特定数据点来提取标记为a
的时间序列的特征(我们在上一步中人为创建了这个 ID),时间点包括到 2017-01-08 为止的过去 30 天。准备好汇总 DataFrame 后,我们可以提取特征。提取最小特征集:
settings_minimal = settings.MinimalFCParameters() settings_minimal
执行该代码片段会生成以下输出:
{'sum_values': None, 'median': None, 'mean': None, 'length': None, 'standard_deviation': None, 'variance': None, 'maximum': None, 'minimum': None}
在字典中,我们可以看到所有将要创建的特征。
None
值表示该特征没有额外的超参数。我们选择提取最小集的特征,因为其他特征会消耗大量时间。或者,我们可以使用settings.EfficientFCParameters
或settings.ComprehensiveFCParameters
来生成数百或数千个特征。使用以下代码片段,我们实际上提取特征:
features_df_2 = extract_features( df_rolled, column_id="id", column_sort="date", default_fc_parameters=settings_minimal )
清理索引并检查特征:
features_df_2 = ( features_df_2 .set_index( features_df_2.index.map(lambda x: x[1]), drop=True ) ) features_df_2.index.name = "last_date" features_df_2.head(25)
执行该代码片段会生成以下输出:
图 7.20:使用 tsfresh 生成的特征预览
在图 7.20中,我们可以看到最小窗口长度为 8,而最大窗口长度为 31。这个设计是有意为之,因为我们表示希望使用最小大小 7,这相当于过去 7 天加上当前一天。最大值也是类似的。
sktime
也为tsfresh
提供了一个封装。我们可以通过使用sktime
的TSFreshFeatureExtractor
类访问特征生成算法。
同时值得一提的是,tsfresh
还有三个非常有趣的特性:
基于假设检验的特征选择算法。由于该库能够生成数百或数千个特征,因此选择与我们使用场景相关的特征至关重要。为此,库使用了fresh算法,即基于可扩展假设检验的特征提取。
通过使用多处理本地机器或使用 Spark 或 Dask 集群(当数据无法适配单台机器时),处理大数据集的特征生成和选择的能力。
它提供了转换器类(例如,
FeatureAugmenter
或FeatureSelector
),我们可以将它们与scikit-learn
管道一起使用。我们在第十三章中讨论了管道,应用机器学习:识别信用违约。tsfresh
只是用于时间序列数据自动特征生成的可用库之一。其他库包括feature_engine
和tsflex
。
时间序列预测作为简化回归
迄今为止,我们大多使用专用的时间序列模型来进行预测任务。另一方面,尝试使用通常用于解决回归任务的其他算法也会很有趣。通过这种方式,我们可能会提高模型的表现。
使用这些模型的原因之一是它们的灵活性。例如,我们可以超越单变量设置,也就是,我们可以通过各种附加特征丰富我们的数据集。我们在之前的食谱中涵盖了一些特征工程的方法。或者,我们可以添加历史上已证明与我们预测目标相关的外部回归变量,如时间序列。
当添加额外的时间序列作为外部回归变量时,我们应当小心它们的可用性。如果我们不知道它们的未来值,我们可以使用它们的滞后值,或者单独预测它们并将其反馈到初始模型中。
由于时间序列数据的时间依赖性(与时间序列的滞后值相关),我们不能直接使用回归模型进行时间序列预测。首先,我们需要将这类时间数据转换为监督学习问题,然后才能应用传统的回归算法。这个过程被称为简化,它将某些学习任务(时间序列预测)分解为更简单的任务。然后,这些任务可以重新组合,提供对原始任务的解决方案。换句话说,简化是指使用一个算法或模型来解决一个它最初并未为之设计的学习任务。因此,在简化回归中,我们实际上是将预测任务转化为表格回归问题。
在实际操作中,简化方法使用滑动窗口将时间序列拆分成固定长度的窗口。通过一个例子可以更容易理解简化方法是如何工作的。假设有一个从 1 到 100 的连续数字的时间序列。然后,我们使用一个长度为 5 的滑动窗口。第一个窗口包含 1 到 4 的观测值作为特征,第 5 个观测值作为目标。第二个窗口使用 2 到 5 的观测值作为特征,第 6 个观测值作为目标。以此类推。一旦我们将所有这些窗口堆叠在一起,就得到了一种表格格式的数据,允许我们使用传统的回归算法进行时间序列预测。图 7.21展示了简化过程。
图 7.21:简化过程示意图
还值得一提的是,使用简化回归时存在一些细微差别。例如,简化回归模型失去了时间序列模型的典型特征,即失去了时间的概念。因此,它们无法处理趋势和季节性。这也是为什么通常先去趋势和去季节化数据,再进行简化回归是有用的。直观上,这类似于仅建模 AR 项。首先去季节化和去趋势数据,使得我们可以更容易找到一个更合适的模型,因为我们没有在 AR 项的基础上考虑趋势和季节性。
在这个例子中,我们展示了使用美国失业率数据集进行简化回归的过程示例。
准备工作
在这个例子中,我们使用的是已经熟悉的美国失业率时间序列。为了简便起见,我们不重复数据下载的步骤。你可以在附带的笔记本中找到相关代码。对于接下来的内容,假设下载的数据已存储在一个名为y
的 DataFrame 中。
如何操作…
执行以下步骤,以使用简化回归对美国失业率进行 12 步预测:
导入所需的库:
from sktime.utils.plotting import plot_series from sktime.forecasting.model_selection import ( temporal_train_test_split, ExpandingWindowSplitter ) from sktime.forecasting.base import ForecastingHorizon from sktime.forecasting.compose import ( make_reduction, TransformedTargetForecaster, EnsembleForecaster ) from sktime.performance_metrics.forecasting import ( mean_absolute_percentage_error ) from sktime.transformations.series.detrend import ( Deseasonalizer, Detrender ) from sktime.forecasting.trend import PolynomialTrendForecaster from sktime.forecasting.model_evaluation import evaluate from sktime.forecasting.arima import AutoARIMA from sklearn.ensemble import RandomForestRegressor
将时间序列分成训练集和测试集:
y_train, y_test = temporal_train_test_split( y, test_size=12 ) plot_series( y_train, y_test, labels=["y_train", "y_test"] )
执行该代码段生成如下图表:
图 7.22:将时间序列划分为训练集和测试集
设置预测视野为 12 个月:
fh = ForecastingHorizon(y_test.index, is_relative=False) fh
执行代码片段生成如下输出:
ForecastingHorizon(['2019-01', '2019-02', '2019-03', '2019-04', '2019-05', '2019-06', '2019-07', '2019-08', '2019-09', '2019-10', '2019-11', '2019-12'], dtype='period[M]', is_relative=False)
每当我们使用这个
fh
对象进行预测时,我们将为 2019 年的 12 个月创建预测。实例化降维回归模型,拟合数据并生成预测:
regressor = RandomForestRegressor(random_state=42) rf_forecaster = make_reduction( estimator=regressor, strategy="recursive", window_length=12 ) rf_forecaster.fit(y_train) y_pred_1 = rf_forecaster.predict(fh)
评估预测的性能:
mape_1 = mean_absolute_percentage_error( y_test, y_pred_1, symmetric=False ) fig, ax = plot_series( y_train["2016":], y_test, y_pred_1, labels=["y_train", "y_test", "y_pred"] ) ax.set_title(f"MAPE: {100*mape_1:.2f}%")
执行代码片段生成如下图:
图 7.23:使用降维随机森林的预测与实际值对比
几乎平坦的预测结果很可能与我们在引言中提到的降维回归方法的缺点有关。通过将数据重塑为表格格式,我们实际上丧失了趋势和季节性的信息。为了考虑这些因素,我们可以先对时间序列进行去季节性和去趋势处理,然后再使用降维回归方法。
对时间序列进行去季节性处理:
deseasonalizer = Deseasonalizer(model="additive", sp=12) y_deseas = deseasonalizer.fit_transform(y_train) plot_series( y_train, y_deseas, labels=["y_train", "y_deseas"] )
执行代码片段生成如下图:
图 7.24:原始时间序列和去季节性后的时间序列
为了提供更多背景信息,我们可以绘制提取的季节性成分:
plot_series( deseasonalizer.seasonal_, labels=["seasonal_component"] )
执行代码片段生成如下图:
图 7.25:提取的季节性成分
在分析图 7.25时,我们不应过于关注 x 轴标签,因为提取的季节性模式在每年都是相同的。
去趋势时间序列:
forecaster = PolynomialTrendForecaster(degree=1) transformer = Detrender(forecaster=forecaster) y_detrend = transformer.fit_transform(y_deseas) # in-sample predictions forecaster = PolynomialTrendForecaster(degree=1) y_in_sample = ( forecaster .fit(y_deseas) .predict(fh=-np.arange(len(y_deseas))) ) plot_series( y_deseas, y_in_sample, y_detrend, labels=["y_deseas", "linear trend", "resids"] )
执行代码片段生成如下图:
图 7.26:去季节化时间序列与拟合的线性趋势及相应的残差
在图 7.26中,我们可以看到 3 条线:
来自上一步的去季节性时间序列
拟合到去季节性时间序列的线性趋势
残差,是通过从去季节性时间序列中减去拟合的线性趋势得到的
将各个组件组合成一个管道,拟合到原始时间序列,并获得预测结果:
rf_pipe = TransformedTargetForecaster( steps = [ ("deseasonalize", Deseasonalizer(model="additive", sp=12)), ("detrend", Detrender( forecaster=PolynomialTrendForecaster(degree=1) )), ("forecast", rf_forecaster), ] ) rf_pipe.fit(y_train) y_pred_2 = rf_pipe.predict(fh)
评估管道的预测结果:
mape_2 = mean_absolute_percentage_error( y_test, y_pred_2, symmetric=False ) fig, ax = plot_series( y_train["2016":], y_test, y_pred_2, labels=["y_train", "y_test", "y_pred"] ) ax.set_title(f"MAPE: {100*mape_2:.2f}%")
执行代码片段生成如下图:
图 7.27:包含去季节性和去趋势处理后的管道拟合,在执行降维回归之前
通过分析图 7.27,我们可以得出以下结论:
使用管道获得的预测形态与实际值更为相似——它捕捉了趋势和季节性成分。
使用 MAPE 测量的误差似乎比图 7.23中几乎平坦的预测线还要差。
使用扩展窗口交叉验证评估性能:
cv = ExpandingWindowSplitter( fh=list(range(1,13)), initial_window=12*5, step_length=12 ) cv_df = evaluate( forecaster=rf_pipe, y=y, cv=cv, strategy="refit", return_data=True ) cv_df
执行代码片段生成如下数据框:
图 7.28:包含交叉验证结果的数据框
此外,我们可以调查在交叉验证过程中用于训练和评估管道的日期范围:
for ind, row in cv_df.iterrows(): print(f"Fold {ind} ----") print(f"Training: {row['y_train'].index.min()} - {row['y_train'].index.max()}") print(f"Training: {row['y_test'].index.min()} - {row['y_test'].index.max()}")
执行代码片段生成如下输出:
Fold 0 ---- Training: 2010-01 - 2014-12 Training: 2015-01 - 2015-12 Fold 1 ---- Training: 2010-01 - 2015-12 Training: 2016-01 - 2016-12 Fold 2 ---- Training: 2010-01 - 2016-12 Training: 2017-01 - 2017-12 Fold 3 ---- Training: 2010-01 - 2017-12 Training: 2018-01 - 2018-12 Fold 4 ---- Training: 2010-01 - 2018-12 Training: 2019-01 - 2019-12
实际上,我们创建了一个 5 折交叉验证,其中扩展窗口在各折之间按 12 个月增长,并且我们始终使用接下来的 12 个月进行评估。
绘制交叉验证折叠的预测结果:
n_fold = len(cv_df) plot_series( y, *[cv_df["y_pred"].iloc[x] for x in range(n_fold)], markers=["o", *["."] * n_fold], labels=["y_true"] + [f"cv: {x}" for x in range(n_fold)] )
执行该代码片段会生成以下图表:
图 7.29:每个交叉验证折叠的预测结果与实际结果对比图
使用 RF 管道和 AutoARIMA 创建一个集成预测:
ensemble = EnsembleForecaster( forecasters = [ ("autoarima", AutoARIMA(sp=12)), ("rf_pipe", rf_pipe) ] ) ensemble.fit(y_train) y_pred_3 = ensemble.predict(fh)
在这个例子中,我们直接将 AutoARIMA 模型拟合到原始时间序列上。然而,我们也可以先对时间序列进行季节性调整和去趋势处理,然后再拟合模型。在这种情况下,指明季节周期可能就不再是必须的(这取决于季节性在经典分解中去除的效果如何)。
评估集成模型的预测结果:
mape_3 = mean_absolute_percentage_error( y_test, y_pred_3, symmetric=False ) fig, ax = plot_series( y_train["2016":], y_test, y_pred_3, labels=["y_train", "y_test", "y_pred"] ) ax.set_title(f"MAPE: {100*mape_3:.2f}%")
执行该代码片段会生成以下图表:
图 7.30:集成模型拟合,包括减少版回归管道和 AutoARIMA
如我们在图 7.30中看到的,将这两个模型进行集成,相较于减少版的随机森林管道,能显著提高性能。
它是如何工作的…
在导入库后,我们使用了temporal_train_test_split
函数将数据分为训练集和测试集。我们保留了最后 12 个观测值(整个 2019 年)作为测试集。我们还使用plot_series
函数绘制了时间序列图,这在我们希望在同一图表中绘制多个时间序列时特别有用。
在步骤 3中,我们定义了ForecastingHorizon
。在sktime
中,预测时段可以是一个值数组,值可以是相对的(表示与训练数据中最新时间点的时间差)或绝对的(表示特定的时间点)。在我们的例子中,我们使用了绝对值,通过提供测试集的索引并设置is_relative=False
。
另一方面,预测时段的相对值包括一个步骤列表,列出了我们希望获取预测的时间点。相对时段在进行滚动预测时非常有用,因为每次添加新数据时我们都可以重复使用它。
在步骤 4中,我们将一个简化的回归模型拟合到训练数据中。为此,我们使用了make_reduction
函数并提供了三个参数。estimator
参数用于指明我们希望在简化回归设置中使用的回归模型。在这种情况下,我们选择了随机森林(更多关于随机森林算法的细节可以参考第十四章,机器学习项目的高级概念)。window_length
表示用于创建简化回归任务的过去观测值数量,也就是将时间序列转化为表格数据集。最后,strategy
参数决定了多步预测的生成方式。我们可以选择以下策略之一来获得多步预测:
直接法
—此策略假设为每一个预测的时间段创建一个单独的模型。在我们的案例中,我们预测 12 步的未来。这意味着该策略将创建 12 个单独的模型来获取预测结果。递归法
—此策略假设拟合一个单步预测模型。然而,为了生成预测,它使用上一个时间步的输出作为下一个时间步的输入。例如,为了获取未来第二个观测值的预测,它会将未来第一个观测值的预测结果作为特征集的一部分。多输出法
—在此策略中,我们使用一个模型来预测整个预测时间段内的所有值。此策略依赖于具有一次性预测整个序列能力的模型。
在定义了简化回归模型之后,我们使用fit
方法将其拟合到训练数据上,并使用predict
方法获得预测结果。对于后者,我们需要提供作为参数的预测时间段对象。或者,我们也可以提供一个步骤的列表/数组,来获取相应的预测。
在步骤 5中,我们通过计算 MAPE 得分并将预测值与实际值进行对比绘图来评估预测效果。为了计算误差指标,我们使用了sktime
的mean_absolute_percentage_error
函数。使用sktime
实现的额外好处是,我们可以通过在调用该函数时指定symmetric=True
,轻松计算对称 MAPE(sMAPE)。
在这一点上,我们注意到,简化回归模型存在引言中提到的问题——它没有捕捉到时间序列的趋势和季节性。因此,在接下来的步骤中,我们展示了如何在使用简化回归方法之前对时间序列进行去季节性和去趋势化处理。
在步骤 6中,我们对原始时间序列进行了去季节性处理。首先,我们实例化了Deseasonalizer
转换器。我们通过提供sp=12
来指明存在月度季节性,并选择了加性季节性,因为季节性模式的幅度似乎随着时间变化不大。在后台,Deseasonalizer
类执行了在statsmodels
库中提供的季节性分解(我们在上一章的时间序列分解食谱中讨论过),并去除了时间序列中的季节性成分。为了在一步操作中拟合转换器并获得去季节性后的时间序列,我们使用了fit_transform
方法。拟合转换器后,可以通过访问seasonal_
属性来检查季节性成分。
在步骤 7中,我们从去季节化的时间序列中移除了趋势。首先,我们实例化了PolynomialTrendForecaster
类,并指定degree=1
。通过这种方式,我们表示我们对线性趋势感兴趣。然后,我们将实例化的类传递给Detrender
变换器。使用我们已经熟悉的fit_transform
方法,我们从去季节化的时间序列中去除了趋势。
在步骤 8中,我们将所有步骤结合成一个管道。我们实例化了TransformedTargetForecaster
类,它用于在我们首先变换时间序列然后再拟合机器学习模型以进行预测时使用。作为steps
参数,我们提供了一个包含元组的列表,每个元组包含步骤名称和用于执行该步骤的变换器/估计器。在这个管道中,我们串联了去季节化、去趋势处理,以及我们在步骤 4中已使用的减少版随机森林模型。然后,我们将整个管道拟合到训练数据上,并获得预测结果。在步骤 9中,我们通过计算 MAPE 并绘制预测与实际值的对比图来评估管道的性能。
在这个例子中,我们仅专注于使用原始时间序列创建模型。当然,我们也可以使用其他特征进行预测。sktime
还提供了创建包含相关变换的回归器管道的功能。然后,我们应该使用ForecastingPipeline
类将给定的变换器应用到 X(特征)上。我们还可能希望对 X 应用某些变换,对y
(目标)应用其他变换。在这种情况下,我们可以将包含需要应用于y
的任何变换器的TransformedTargetForecaster
作为ForecastingPipeline
的一步传入。
在步骤 10中,我们进行了额外的评估步骤。我们使用了向前滚动交叉验证,采用扩展窗口来评估模型的性能。为了定义交叉验证方案,我们使用了ExpandingWindowSplitter
类。作为输入,我们需要提供:
fh
—预测的时间范围。由于我们想评估 12 步 ahead 的预测,因此我们提供了一个从 1 到 12 的整数列表。initial_window
—初始训练窗口的长度。我们将其设置为 60,表示 5 年的训练数据。step_length
—此值表示扩展窗口每次实际扩展的周期数。我们将其设置为 12,因此每个折叠都会增加一年的训练数据。
定义验证方案后,我们使用了evaluate
函数来评估步骤 8中定义的管道的性能。在使用evaluate
函数时,我们还必须指定strategy
参数,用于定义在窗口扩展时如何获取新数据。选项如下:
refit
—在每个训练窗口中,模型都会被重新拟合。update
—预测器使用窗口中的新训练数据进行更新,但不会重新拟合。no-update_params
——模型在第一个训练窗口中拟合,然后在没有重新拟合或更新模型的情况下重复使用。
在步骤 11中,我们使用了plot_series
函数,并结合列表推导来绘制原始时间序列和在每个验证折叠中获得的预测。
在最后两步中,我们创建并评估了一个集成模型。首先,我们实例化了EnsembleForecaster
类,并提供了包含模型名称及其相应类/定义的元组列表。对于这个集成模型,我们结合了带有月度季节性的 AutoARIMA 模型(一个 SARIMA 模型)和在步骤 8中定义的降维随机森林管道。此外,我们使用了aggfunc
参数的默认值"mean"
。该参数决定了用于生成最终预测的聚合策略。在此案例中,集成模型的预测是单个模型预测的平均值。其他选项包括使用中位数、最小值或最大值。
在实例化模型后,我们使用了已经熟悉的fit
和predict
方法来拟合模型并获得预测结果。
还有更多……
在本教程中,我们介绍了使用sktime
进行降维回归。如前所述,sktime
是一个框架,提供了在处理时间序列时可能需要的所有工具。以下是使用sktime
及其功能的一些优点:
该库不仅适用于时间序列预测,还适用于回归、分类和聚类任务。此外,它还提供了特征提取功能。
sktime
提供了一些简单的模型,这些模型在创建基准时非常有用。例如,我们可以使用NaiveForecaster
模型来创建预测,该预测仅仅是最后一个已知值。或者,我们可以使用最后一个已知的季节性值,例如,2019 年 1 月的预测将是 2018 年 1 月时序数据的值。它提供了一个统一的 API,作为许多流行时间序列库的封装器,如
statsmodels
、pmdarima
、tbats
或 Meta 的 Prophet。要查看所有可用的预测模型,我们可以执行all_estimators("forecaster", as_dataframe=True)
命令。通过使用降维,能够使用所有与
scikit-learn
API 兼容的估算器进行预测。sktime
提供了带有时间交叉验证的超参数调优功能。此外,我们还可以调优与降维过程相关的超参数,如滞后数量或窗口长度。该库提供了广泛的性能评估指标(在
scikit-learn
中不可用),并允许我们轻松创建自定义评分器。该库扩展了
scikit-learn
的管道功能,允许将多个转换器(如去趋势、去季节性等)与预测算法结合使用。该库提供了 AutoML 功能,可以自动从众多模型及其超参数中确定最佳预测器。
参见
- Löning, M., Bagnall, A., Ganesh, S., Kazakov, V., Lines, J., & Király, F. J. 2019. sktime: A Unified Interface for Machine Learning with Time Series. arXiv preprint arXiv:1909.07872.
使用 Meta 的 Prophet 进行预测
在前面的示例中,我们展示了如何重新构造时间序列预测问题,以便使用常用于回归任务的流行机器学习模型。这次,我们展示的是一个专门为时间序列预测设计的模型。
Prophet 是由 Facebook(现为 Meta)在 2017 年推出的,从那时起,它已经成为一个非常流行的时间序列预测工具。它流行的一些原因:
大多数情况下,它能够直接提供合理的结果/预测。
它是为预测与业务相关的时间序列而设计的。
它最适用于具有强季节性成分的每日时间序列,并且至少需要一些季节的训练数据。
它可以建模任意数量的季节性(例如按小时、每日、每周、每月、每季度或每年)。
该算法对缺失数据和趋势变化具有相当强的鲁棒性(它通过自动变化点检测来应对这一点)。
它能够轻松地考虑假期和特殊事件。
与自回归模型(如 ARIMA)相比,它不需要平稳时间序列。
我们可以通过调整模型的易于理解的超参数,运用业务/领域知识来调整预测。
我们可以使用额外的回归量来提高模型的预测性能。
自然地,这个模型并不完美,存在一些问题。在 参见 部分,我们列出了一些参考资料,展示了该模型的弱点。
Prophet 的创建者将时间序列预测问题视为一个曲线拟合的练习(这在数据科学社区引发了不少争议),而不是明确地分析时间序列中每个观测值的时间依赖性。因此,Prophet 是一个加性模型(属于广义加性模型或 GAMs 的一种形式),可以表示如下:
其中:
g(t) — 增长项,具有分段线性、逻辑或平坦形式。趋势成分模型捕捉时间序列中的非周期性变化。
h(t) — 描述假期和特殊日期的影响(这些日期可能不规则出现)。它们作为虚拟变量添加到模型中。
s(t) — 描述使用傅里叶级数建模的各种季节性模式。
— 误差项,假设其服从正态分布。
逻辑增长趋势特别适用于建模饱和(或受限)增长。例如,当我们预测某个国家的客户数量时,我们不应预测超过该国人口总数的客户数量。使用 Prophet,我们还可以考虑饱和的最小值。
广义加性模型(GAM)是简单却强大的模型,正在获得越来越多的关注。它们假设各个特征与目标之间的关系遵循平滑模式。这些关系可以是线性的,也可以是非线性的。然后,这些关系可以同时估计并加和,生成模型的预测值。例如,将季节性建模为加性组件与 Holt-Winters 指数平滑方法中的做法相同。Prophet 使用的 GAM 公式有其优势。首先,它易于分解。其次,它能容纳新的组件,例如,当我们识别出新的季节性来源时。
Prophet 的另一个重要特点是,在估计趋势的过程中包括变化点,这使得趋势曲线更加灵活。由于变化点的存在,趋势可以调整为适应模式中的突变,例如 COVID 疫情引起的销售模式变化。Prophet 具有自动检测变化点的程序,但也可以接受手动输入日期。
Prophet 是使用贝叶斯方法估计的(得益于使用 Stan,它是一个用 C++编写的统计推断编程语言),该方法允许自动选择变化点,并使用马尔科夫链蒙特卡罗(MCMC)或最大后验估计(MAP)等方法创建置信区间。
在本节中,我们展示了如何使用 2015 至 2019 年的数据预测每日黄金价格。虽然我们非常清楚该模型不太可能准确预测黄金价格,但我们将其作为训练和使用模型的示例。
如何操作…
执行以下步骤来使用 Prophet 模型预测每日黄金价格:
导入库并使用纳斯达克数据链接进行身份验证:
import pandas as pd import nasdaqdatalink from prophet import Prophet from prophet.plot import add_changepoints_to_plot nasdaqdatalink.ApiConfig.api_key = "YOUR_KEY_HERE"
下载每日黄金价格:
df = nasdaqdatalink.get( dataset="WGC/GOLD_DAILY_USD", start_date="2015-01-01", end_date="2019-12-31" ) df.plot(title="Daily gold prices (2015-2019)")
执行该代码片段会生成以下图表:
图 7.31:2015 年至 2019 年的每日黄金价格
重命名列:
df = df.reset_index(drop=False) df.columns = ["ds", "y"]
将系列分为训练集和测试集:
train_indices = df["ds"] < "2019-10-01" df_train = df.loc[train_indices].dropna() df_test = ( df .loc[~train_indices] .reset_index(drop=True) )
我们任意选择了
2019
年的最后一个季度作为测试集。因此,我们将创建一个预测未来约 60 个观测值的模型。创建模型实例并将其拟合到数据:
prophet = Prophet(changepoint_range=0.9) prophet.add_country_holidays(country_name="US") prophet.add_seasonality( name="monthly", period=30.5, fourier_order=5 ) prophet.fit(df_train)
预测 2019 年第四季度的黄金价格并绘制结果:
df_future = prophet.make_future_dataframe( periods=len(df_test), freq="B" ) df_pred = prophet.predict(df_future) prophet.plot(df_pred)
执行该代码片段会生成以下图表:
图 7.32:使用 Prophet 获得的预测
为了解释该图,我们需要知道:
黑色的点是黄金价格的实际观测值。
代表拟合的蓝线与观测值并不完全匹配,因为模型对数据中的噪音进行了平滑处理(这也减少了过拟合的可能性)。
Prophet 尝试量化不确定性,这通过拟合线周围的浅蓝色区间表示。该区间的计算假设未来趋势变化的平均频率和幅度将与历史数据中的趋势变化相同。
还可以使用
plotly
创建交互式图表。为此,我们需要使用plot_plotly
函数,而不是plot
方法。此外,值得一提的是,预测数据框包含了很多可能有用的列:
df_pred.columns
使用代码片段,我们可以看到所有的列:
['ds', 'trend', 'yhat_lower', 'yhat_upper', 'trend_lower', 'trend_upper', 'Christmas Day', 'Christmas Day_lower', 'Christmas Day_upper', 'Christmas Day (Observed)', 'Christmas Day (Observed)_lower', 'Christmas Day (Observed)_upper', 'Columbus Day', 'Columbus Day_lower', 'Columbus Day_upper', 'Independence Day', 'Independence Day_lower', 'Independence Day_upper', 'Independence Day (Observed)', 'Independence Day (Observed)_lower', 'Independence Day (Observed)_upper', 'Labor Day', 'Labor Day_lower', 'Labor Day_upper', 'Martin Luther King Jr. Day', 'Martin Luther King Jr. Day_lower', 'Martin Luther King Jr. Day_upper', 'Memorial Day', 'Memorial Day_lower', 'Memorial Day_upper', 'New Year's Day', 'New Year's Day_lower', 'New Year's Day_upper', 'New Year's Day (Observed)', 'New Year's Day (Observed)_lower', 'New Year's Day (Observed)_upper', 'Thanksgiving', 'Thanksgiving_lower', 'Thanksgiving_upper', 'Veterans Day', 'Veterans Day_lower', 'Veterans Day_upper', 'Veterans Day (Observed)', 'Veterans Day (Observed)_lower', 'Veterans Day (Observed)_upper', 'Washington's Birthday', 'Washington's Birthday_lower', 'Washington's Birthday_upper', 'additive_terms', 'additive_terms_lower', 'additive_terms_upper', 'holidays', 'holidays_lower', 'holidays_upper', 'monthly', 'monthly_lower', 'monthly_upper', 'weekly', 'weekly_lower', 'weekly_upper', 'yearly', 'yearly_lower', 'yearly_upper', 'multiplicative_terms', 'multiplicative_terms_lower', 'multiplicative_terms_upper', 'yhat']
通过分析列表,我们可以看到 Prophet 模型返回的所有组件。自然地,我们看到了预测(
yhat
)及其对应的置信区间('yhat_lower'
和'yhat_upper'
)。此外,我们还看到了模型的所有个别组件(如趋势、假期效应和季节性),以及它们的置信区间。这些可能对我们有用,考虑到以下几个方面:由于 Prophet 是一种加性模型,我们可以将所有组件相加,得到最终的预测结果。因此,我们可以将这些值视为一种特征重要性,可以用来解释预测结果。
我们还可以使用 Prophet 模型来获取这些组件的值,然后将它们作为特征输入到另一个模型(例如基于树的模型)中。
向图表中添加变化点:
fig = prophet.plot(df_pred) a = add_changepoints_to_plot( fig.gca(), prophet, df_pred )
执行代码片段会生成以下图表:
图 7.33:模型的拟合与识别出的变化点
我们还可以使用拟合后的 Prophet 模型的
changepoints
方法查找被识别为变化点的确切日期。检查时间序列的分解:
prophet.plot_components(df_pred)
执行代码片段会生成以下图表:
图 7.34:展示 Prophet 模型个别组件的分解图
我们没有花太多时间检查组件,因为黄金价格的时间序列可能没有太多季节性影响,或者不应受到美国假期的影响。这尤其适用于假期,因为股市在主要假期时会休市。因此,这些假期的影响可能会在假期前后由市场反映出来。正如我们之前提到的,我们对此有所了解,我们只是想展示 Prophet 是如何工作的。
有一点需要注意的是,周季节性在星期六和星期天之间明显不同。这是由于黄金价格数据是在工作日收集的。因此,我们可以安全地忽略周末的模式。
然而,有趣的是观察趋势组件,我们可以在图 7.33中看到它,并且与检测到的变化点一起呈现。
将测试集与预测结果合并:
SELECTED_COLS = [ "ds", "yhat", "yhat_lower", "yhat_upper" ] df_pred = ( df_pred .loc[:, SELECTED_COLS] .reset_index(drop=True) ) df_test = df_test.merge(df_pred, on=["ds"], how="left") df_test["ds"] = pd.to_datetime(df_test["ds"]) df_test = df_test.set_index("ds")
绘制测试值与预测值的对比图:
fig, ax = plt.subplots(1, 1) PLOT_COLS = [ "y", "yhat", "yhat_lower", "yhat_upper" ] ax = sns.lineplot(data=df_test[PLOT_COLS]) ax.fill_between( df_test.index, df_test["yhat_lower"], df_test["yhat_upper"], alpha=0.3 ) ax.set( title="Gold Price - actual vs. predicted", xlabel="Date", ylabel="Gold Price ($)" )
执行代码片段生成了以下图表:
图 7.35:预测值与实际值对比
正如我们在图 7.35中看到的,模型的预测结果偏差较大。实际上,80% 置信区间(默认设置,我们可以通过 interval_width
超参数来更改)几乎没有捕捉到任何实际值。
它是如何工作的…
在导入库之后,我们从 Nasdaq Data Link 下载了每日黄金价格数据。
在步骤 3中,我们重命名了数据框的列,以使其与 Prophet 兼容。该算法需要两列数据:
ds
—表示时间戳y
—目标变量
在步骤 4中,我们将数据框拆分为训练集和测试集。我们任意选择了 2019 年第四季度作为测试集。
在步骤 5中,我们实例化了 Prophet 模型。期间,我们指定了一些设置:
我们将
changepoint_range
设置为0.9
,这意味着算法可以在训练数据集的前 90% 中识别变化点。默认情况下,Prophet 会在时间序列的前 80% 中添加 25 个变化点。在这种情况下,我们希望捕捉到较新的趋势。我们使用
add_seasonality
方法并按照 Prophet 文档建议的值添加了月度季节性。指定period
为30.5
意味着我们期望模式大约每 30.5 天重复一次。另一个参数—fourier_order
—可以用来指定用于构建特定季节性成分(在此情况下为月度季节性)的 Fourier 项数。通常来说,阶数越高,季节性成分越灵活。我们使用
add_country_holidays
方法将美国假期添加到模型中。我们使用的是默认日历(通过holidays
库可用),但也可以添加日历中没有的自定义事件。例如,黑色星期五就是一个例子。还值得一提的是,在提供自定义事件时,我们还可以指定是否预期周围的日期也会受到影响。例如,在零售场景中,我们可能会预期圣诞节后几天的客流/销售会较低。另一方面,我们也许会预期圣诞节前夕会出现销售高峰。
然后,我们使用 fit
方法拟合了模型。
在步骤 6中,我们使用拟合后的模型获得了预测结果。为了使用 Prophet 创建预测,我们需要使用 make_future_dataframe
方法创建一个特殊的数据框。在此过程中,我们指定了希望预测的测试集长度(默认情况下以天数为单位),并且我们希望使用工作日。这一点很重要,因为我们没有周末的黄金价格数据。然后,我们使用拟合模型的 predict
方法生成预测结果。
在步骤 7中,我们使用add_changepoints_to_plot
函数将识别出的变更点添加到图表中。这里有一点需要注意的是,我们必须使用所创建图形的gca
方法来获取其当前坐标轴。我们必须使用它来正确识别我们想要将变更点添加到哪个图表中。
在步骤 8中,我们检查了模型的各个组件。为此,我们使用了plot_components
方法,并将预测数据框作为方法的参数。
在步骤 9中,我们将测试集与预测数据框合并。我们使用了左连接,它返回左表(测试集)中的所有行以及右表(预测数据框)中匹配的行,而未匹配的行则为空。
最后,我们绘制了预测结果(连同置信区间)和真实值,以便直观地评估模型的性能。
还有更多…
Prophet 提供了很多有趣的功能。虽然在一个单一的实例中提到所有这些功能显然太多,但我们想强调两点。
内置交叉验证
为了正确评估模型的表现(并可能调整其超参数),我们确实需要一个验证框架。Prophet 在其cross_validation
函数中实现了我们已经熟悉的前向交叉验证。在这一小节中,我们展示了如何使用它:
导入库:
from prophet.diagnostics import (cross_validation, performance_metrics) from prophet.plot import plot_cross_validation_metric
运行 Prophet 的交叉验证:
df_cv = cross_validation( prophet, initial="756 days", period="60 days", horizon = "60 days" ) df_cv
我们已经指定我们想要:
初始窗口包含 3 年的数据(一年大约有 252 个交易日)
预测期为 60 天
每 60 天计算一次预测
执行该代码段会生成以下输出:
图 7.36:Prophet 交叉验证的输出
数据框包含了预测值(包括置信区间)和实际值,针对一组
cutoff
日期(用于生成预测的训练集中的最后一个时间点)和ds
日期(用于生成预测的验证集中的日期)。换句话说,这个过程为每个介于cutoff
和cutoff + horizon
之间的观察点生成预测。算法还告诉我们它将要做什么:
Making 16 forecasts with cutoffs between 2017-02-12 00:00:00 and 2019-08-01 00:00:00
计算聚合性能指标:
df_p = performance_metrics(df_cv) df_p
执行该代码段会生成以下输出:
图 7.37:性能概览的前 10 行
图 7.37展示了包含我们交叉验证结果的聚合性能得分的前 10 行数据框。根据我们的交叉验证方案,整个数据框包含了直到
60 天
的所有预测期。请参阅 Prophet 文档,了解由
performance_metrics
函数生成的聚合性能指标背后的确切逻辑。绘制 MAPE 得分:
plot_cross_validation_metric(df_cv, metric="mape")
执行该代码段会生成以下图表:
图 7.38:不同预测期的 MAPE 得分
图 7.38 中的点表示交叉验证数据框中每个预测的绝对百分比误差。蓝线表示 MAPE。平均值是在点的滚动窗口上计算的。有关滚动窗口的更多信息,请参阅 Prophet 的文档。
调整模型
如我们已经看到的,Prophet 有相当多的可调超参数。该库的作者建议以下超参数可能值得调优,以实现更好的拟合:
changepoint_prior_scale
—可能是最具影响力的超参数,它决定了趋势的灵活性。特别是,趋势在趋势变化点的变化程度。过小的值会使趋势变得不那么灵活,可能导致趋势欠拟合,而过大的值可能会导致趋势过拟合(并可能捕捉到年度季节性)。seasonality_prior_scale
—一个控制季节性项灵活性的超参数。较大的值允许季节性拟合显著的波动,而较小的值则会收缩季节性的幅度。默认值为 10,基本上不进行正则化。holidays_prior_scale
—与seasonality_prior_scale
非常相似,但它控制拟合假期效应的灵活性。seasonality_mode
—我们可以选择加法季节性或乘法季节性。选择此项的最佳方法是检查时间序列,看看季节性波动的幅度是否随着时间的推移而增大。changepoint_range
—此参数对应于算法可以识别变化点的时间序列百分比。确定此超参数的良好值的一条经验法则是查看模型在训练数据的最后 1−changepoint_range
百分比中的拟合情况。如果模型在这一部分的表现不佳,我们可能需要增加该超参数的值。
与其他情况一样,我们可能希望使用像网格搜索(结合交叉验证)这样的过程来识别最佳的超参数集,同时尽量避免/最小化对训练数据的过拟合风险。
另见
Rafferty, G. 2021. 使用 Facebook Prophet 进行时间序列预测。Packt Publishing Ltd.
Taylor, S. J., & Letham, B. 2018. “大规模预测,” 美国统计学家,72(1):37-45。
使用 PyCaret 进行时间序列预测的 AutoML
我们已经花了一些时间解释了如何构建时间序列预测的机器学习模型,如何创建相关特征,以及如何使用专门的模型(例如 Meta 的 Prophet)来完成任务。将本章以对前述所有部分的扩展——一个 AutoML 工具来结束,是再合适不过了。
可用的工具之一是 PyCaret,它是一个开源、低代码的机器学习库。该工具的目标是自动化机器学习工作流程。通过 PyCaret,我们可以仅用几行代码训练和调优众多流行的机器学习模型。虽然它最初是为经典回归和分类任务构建的,但它也有一个专门的时间序列模块,我们将在本例中介绍。
PyCaret 库本质上是多个流行的机器学习库和框架(如 scikit-learn
、XGBoost、LightGBM、CatBoost、Optuna、Hyperopt 等)的封装。更准确地说,PyCaret 的时间序列模块建立在 sktime
提供的功能之上,例如它的降维框架和管道能力。
在本例中,我们将使用 PyCaret 库来寻找最适合预测美国月度失业率的模型。
准备工作
在本例中,我们将使用在前面几个例子中已经使用过的相同数据集。你可以在《时间序列验证方法》一节中找到有关如何下载和准备时间序列的更多信息。
操作步骤…
执行以下步骤,使用 PyCaret 预测美国失业率:
导入库:
from pycaret.datasets import get_data from pycaret.time_series import TSForecastingExperiment
设置实验:
exp = TSForecastingExperiment() exp.setup(df, fh=6, fold=5, session_id=42)
执行代码片段后生成以下实验总结:
图 7.39:PyCaret 实验总结
我们可以看到,该库自动将最后 6 个观测值作为测试集,并识别出所提供时间序列中的月度季节性。
使用可视化工具探索时间序列:
exp.plot_model( plot="diagnostics", fig_kwargs={"height": 800, "width": 1000} )
执行代码片段后生成以下图表:
图 7.40:时间序列的诊断图
虽然大部分图表已经很熟悉,但新的图表是周期图。我们可以结合快速傅里叶变换图来研究分析时间序列的频率成分。虽然这可能超出了本书的范围,但我们可以提到以下解释这些图表的要点:
在 0 附近的峰值可能表示需要对时间序列进行差分。这可能表明是一个平稳的 ARMA 过程。
在某个频率及其倍数上出现峰值表示季节性。最低的这些频率称为基本频率。其倒数即为模型的季节周期。例如,基本频率为 0.0833 时,对应的季节周期为 12,因为 1/0.0833 = 12。
使用以下代码片段,我们可以可视化将在实验中使用的交叉验证方案:
exp.plot_model(plot="cv")
执行代码片段后生成以下图表:
图 7.41:使用扩展窗口进行的 5 折走步交叉验证示例
在随附的笔记本中,我们还展示了一些其他可用的图表,例如,季节性分解、快速傅里叶变换(FFT)等。
对时间序列进行统计检验:
exp.check_stats()
执行该代码片段会生成以下 DataFrame,展示各种测试结果:
图 7.42:包含各种统计测试结果的 DataFrame
我们还可以仅执行所有测试的子集。例如,我们可以使用以下代码片段执行摘要测试:
exp.check_stats(test="summary")
找出五个最适合的管道:
best_pipelines = exp.compare_models( sort="MAPE", turbo=False, n_select=5 )
执行该代码片段会生成以下 DataFrame,展示性能概览:
图 7.43:所有拟合模型的交叉验证得分的 DataFrame
检查
best_pipelines
对象将打印出最佳管道:[BATS(show_warnings=False, sp=12, use_box_cox=True), TBATS(show_warnings=False, sp=[12], use_box_cox=True), AutoARIMA(random_state=42, sp=12, suppress_warnings=True), ProphetPeriodPatched(), ThetaForecaster(sp=12)]
调优最佳管道:
best_pipelines_tuned = [ exp.tune_model(model) for model in best_pipelines ] best_pipelines_tuned
调优后,表现最佳的管道如下:
[BATS(show_warnings=False, sp=12, use_box_cox=True), TBATS(show_warnings=False, sp=[12], use_box_cox=True, use_damped_trend=True, use_trend=True), AutoARIMA(random_state=42, sp=12, suppress_warnings=True), ProphetPeriodPatched(changepoint_prior_scale=0.016439324494196616, holidays_prior_scale=0.01095960453692584, seasonality_prior_scale=7.886714129990491), ThetaForecaster(sp=12)]
调用
tune_model
方法还会打印出每个调优后模型的交叉验证性能摘要。为简洁起见,我们这里不打印出来。然而,你可以查看随附的笔记本,看看调优后性能的变化。混合这五个调优后的管道:
blended_model = exp.blend_models( best_pipelines_tuned, method="mean" )
使用混合模型创建预测并绘制预测图:
y_pred = exp.predict_model(blended_model)
执行该代码片段还会生成测试集的性能摘要:
图 7.44:使用测试集预测计算的得分
然后,我们绘制测试集的预测图:
exp.plot_model(estimator=blended_model)
执行该代码片段会生成以下图表:
图 7.45:时间序列及对测试集所做的预测
完成模型:
final_model = exp.finalize_model(blended_model) exp.plot_model(final_model)
执行该代码片段会生成以下图表:
图 7.46:2020 年前 6 个月的样本外预测
仅凭图表来看,似乎预测是合理的,并且包含了一个清晰可识别的季节性模式。我们还可以生成并打印出我们在图表中已经看到的预测:
y_pred = exp.predict_model(final_model)
print(y_pred)
执行该代码片段会生成接下来 6 个月的预测结果:
y_pred
2020-01 3.8437
2020-02 3.6852
2020-03 3.4731
2020-04 3.0444
2020-05 3.0711
2020-06 3.4585
它是如何工作的……
导入库后,我们设置了实验。首先,我们实例化了 TSForecastingExperiment
类的一个对象。然后,我们使用 setup
方法为 DataFrame 提供时间序列、预测时间范围、交叉验证折数以及会话 ID。在我们的实验中,我们指定了要预测未来 6 个月,并且我们希望使用 5 折滚动验证,采用扩展窗口(默认变体)。也可以使用滑动窗口。
PyCaret 提供了两种 API:函数式 API 和面向对象式 API(使用类)。在本例中,我们展示了后者。
在设置实验时,我们还可以指示是否希望对目标时间序列应用某些转换。我们可以选择以下选项之一:"box-cox"
、"log"
、"sqrt"
、"exp"
、"cos"
。
要从实验中提取训练集和测试集,我们可以使用以下命令:exp.get_config("y_train")
和 exp.get_config("y_test")
。
在步骤 3中,我们使用TSForecastingExperiment
对象的plot_model
方法对时间序列进行了快速的探索性数据分析(EDA)。为了生成不同的图表,我们只需更改该方法的plot
参数。
在步骤 4中,我们使用TSForecastingExperiment
类的check_stats
方法检查了多种统计检验。
在步骤 5中,我们使用compare_models
方法训练了一系列统计学和机器学习模型,并使用选定的交叉验证方案评估它们的表现。我们指示要根据 MAPE 分数选择五个最佳的管道。我们设置了turbo=False
,以便训练那些可能需要更多时间来训练的模型(例如,Prophet、BATS 和 TBATS)。
PyCaret 使用管道的概念,因为有时“模型”实际上是由多个步骤构建的。例如,我们可能会先去趋势并去季节化时间序列,然后再拟合回归模型。例如,Random Forest w/ Cond. Deseasonalize & Detrending
模型是一个sktime
管道,它首先对时间序列进行条件去季节化。然后,应用去趋势化,最后拟合减少的随机森林。去季节化的条件部分是首先通过统计测试检查时间序列中是否存在季节性。如果检测到季节性,则应用去季节化。
在这一阶段,有一些值得注意的事项:
我们可以使用
pull
方法提取带有性能比较的 DataFrame。我们可以使用
models
方法打印所有可用模型的列表,以及它们的引用(指向原始库,因为 PyCaret 是一个包装器),并指示模型是否需要更多时间进行训练,并且是否被turbo
标志隐藏。我们还可以决定是否只训练某些模型(使用
compare_models
方法的include
参数),或者是否训练所有模型,除了选择的几个(使用exclude
参数)。
在步骤 6中,我们对最佳管道进行了调优。为此,我们使用列表推导式遍历已识别的管道,然后使用tune_model
方法进行超参数调优。默认情况下,它使用随机网格搜索(在第十三章《应用机器学习:识别信用违约》中有更多介绍),并使用库的作者提供的超参数网格。这些参数作为一个良好的起点,如果我们想调整它们,可以很容易地做到。
在步骤 7中,我们创建了一个集成模型,它是五个最佳管道(调优后的版本)的组合。我们决定采用各个模型生成的预测值的均值。或者,我们也可以使用中位数或投票。后者是一种投票机制,每个模型根据提供的权重进行加权。例如,我们可以根据交叉验证误差创建权重,即误差越小,权重越大。
在步骤 8中,我们使用混合模型创建了预测。为此,我们使用了predict_model
方法,并将混合模型作为该方法的参数。在此时,predict_model
方法会为测试集生成预测。
我们还使用了已熟悉的plot_model
方法来创建图表。当提供一个模型时,plot_model
方法可以展示模型的样本内拟合情况、测试集上的预测、样本外预测或模型的残差。
类似于plot_model
方法的情况,我们也可以结合已创建的模型使用check_stats
方法。当我们传入估计器时,该方法会对模型的残差进行统计检验。
在步骤 9中,我们使用finalize_model
方法最终确定了模型。正如我们在步骤 8中所见,我们获得的预测是针对测试集的。在 PyCaret 的术语中,最终确定模型意味着我们将之前阶段的模型(不更改已选超参数)带入,并使用整个数据集(包括训练集和测试集)重新训练该模型。这样,我们就可以为未来创建预测。
在最终确定模型后,我们使用相同的predict_model
和plot_model
方法来创建并绘制 2020 年前 6 个月的预测(这些数据不在我们的数据集内)。调用这些方法时,我们将最终确定的模型作为estimator
参数传入。
还有更多内容……
PyCaret 是一个非常多功能的库,我们仅仅触及了它所提供的表面功能。为了简洁起见,我们只提到它的一些特性:
成熟的分类和回归 AutoML 能力。在这个教程中,我们只使用了时间序列模块。
时间序列的异常检测。
与 MLFlow 的集成,用于实验日志记录。
使用时间序列模块,我们可以轻松地训练单一模型,而不是所有可用模型。我们可以通过
create_model
方法做到这一点。作为estimator
参数,我们需要传入模型的名称。我们可以通过models
方法获取可用模型的名称。此外,依据所选模型,我们可能还需要传递一些额外的参数。例如,我们可能需要指定 ARIMA 模型的阶数参数。正如我们在可用模型列表中看到的,除了经典的统计模型外,PyCaret 还提供了使用简化回归方法选择的机器学习模型。这些模型还会去趋势化并有条件地去季节化时间序列,从而使回归模型更容易捕捉数据的自回归特性。
你也许还想探索一下autots
库,它是另一个用于时间序列预测的 AutoML 工具。
摘要
在这一章中,我们介绍了基于机器学习的时间序列预测方法。我们首先全面概述了与时间序列领域相关的验证方法。此外,其中一些方法是为了应对金融领域中验证时间序列预测的复杂性而设计的。
然后,我们探索了特征工程和降维回归的概念,这使我们能够使用任何回归算法来进行时间序列预测任务。最后,我们介绍了 Meta 的 Prophet 算法和 PyCaret——一个低代码工具,能够自动化机器学习工作流程。
在探索时间序列预测时,我们尝试介绍了最相关的 Python 库。然而,还有很多其他有趣的库值得一提。你可以在下面找到其中一些:
autots
——AutoTS 是另一个时间序列预测的 AutoML 库。darts
——类似于sktime
,它提供了一个完整的时间序列工作框架。该库包含了各种模型,从经典的 ARIMA 模型到用于时间序列预测的各种流行神经网络架构。greykite
——LinkedIn 的 Greykite 时间序列预测库,包括其 Silverkite 算法。kats
——Meta 开发的时间序列分析工具包。该库尝试提供一个一站式的时间序列分析平台,包括检测(例如变点)、预测、特征提取等任务。merlion
——Salesforce 的机器学习库,用于时间序列分析。orbit
——Uber 的贝叶斯时间序列预测和推理库。statsforecast
——这个库提供了一些流行的时间序列预测模型(例如 autoARIMA 和 ETS),并通过numba
进一步优化以提高性能。stumpy
——一个高效计算矩阵配置文件的库,可用于许多时间序列相关任务。tslearn
——一个用于时间序列分析的工具包。tfp.sts
——TensorFlow Probability 中的一个库,用于使用结构化时间序列模型进行预测。
加入我们的 Discord 社区!
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本发布——请扫描下面的二维码:
第八章:多因子模型
本章专注于估算各种因子模型。因子是过去与(未来)股票回报相关的变量/属性,预计在未来仍然包含相同的预测信号。
这些风险因子可以被视为理解(预期)回报横截面的一种工具。这也是为什么各种因子模型被用来通过一个或多个因子解释某一资产或投资组合的超额回报(相对于无风险利率)。我们可以把这些因子看作是驱动超额回报的风险源。每个因子都有一个风险溢价,整体投资组合/资产的回报是这些溢价的加权平均值。
因子模型在投资组合管理中扮演着至关重要的角色,主要原因是:
它们可以用来识别可以加入投资组合的有趣资产,这反过来应当能带来表现更好的投资组合。
估算投资组合/资产对各因子的暴露有助于更好的风险管理。
我们可以使用这些模型来评估添加新风险因子的潜在增值。
它们使得投资组合优化变得更容易,因为通过较少的因素总结多个资产的收益,减少了估算协方差矩阵所需的数据量。
它们可以用来评估投资组合经理的表现——无论表现(相对于基准)是由于资产选择和交易时机,还是来自于对已知回报驱动因素(因子)的暴露。
本章结束时,我们将构建一些最流行的因子模型。我们将从最简单但非常流行的单因子模型开始(当考虑的因子是市场回报时,这与资本资产定价模型相同),然后解释如何估算更先进的三因子、四因子和五因子模型。我们还将涵盖这些因子代表什么的解释,并提供一个关于它们如何构建的高层次概述。
在本章中,我们将涵盖以下内容:
估算资本资产定价模型(CAPM)
估算 Fama-French 三因子模型
估算资产组合的滚动三因子模型
估算四因子和五因子模型
使用 Fama-MacBeth 回归估算横截面因子模型
估算资本资产定价模型(CAPM)
在本篇中,我们将学习如何估算著名的资本资产定价模型(CAPM)并获取β系数。该模型表示了风险资产的预期回报与市场风险(也称为系统性风险或不可分散风险)之间的关系。CAPM 可以视为一个单因子模型,基于该模型构建了更复杂的因子模型。
CAPM 由以下方程表示:
这里,E(r[i]) 表示资产 i 的预期回报,r[f] 是无风险利率(例如政府债券),E(r[m]) 是市场的预期回报,而 是贝塔系数。
贝塔值可以解释为资产回报对市场波动的敏感程度。以下是该系数的可能解释:
⇐ -1:资产与基准的方向相反,并且波动幅度大于基准的负值。
-1 <
< 0:资产与基准的方向相反。
= 0:资产价格波动与市场基准之间没有相关性。
0 <
< 1:资产与市场方向相同,但波动幅度较小。一个例子可能是那些不太容易受到日常波动影响的公司股票。
= 1:资产与市场在同一方向上以相同幅度波动。
> 1:资产与市场方向相同,但波动幅度较大。一个例子可能是那些对日常市场新闻非常敏感的公司股票。
CAPM 也可以表示为:
在此模型中,方程的左侧可以解释为风险溢价,而右侧包含市场溢价。同一方程还可以进一步重构为:
这里, 和
。
在这个示例中,我们以亚马逊为例,假设标准普尔 500 指数代表市场。我们使用 5 年(2016 至 2020 年)的月度数据来估计贝塔值。在当前情况下,风险无风险利率非常低,因此为简化起见,我们假设它等于零。
如何操作...
执行以下步骤在 Python 中实现 CAPM:
导入所需的库:
import pandas as pd import yfinance as yf import statsmodels.api as sm
指定风险资产、基准和时间范围:
RISKY_ASSET = "AMZN" MARKET_BENCHMARK = "^GSPC" START_DATE = "2016-01-01" END_DATE = "2020-12-31"
从 Yahoo Finance 下载所需的数据:
df = yf.download([RISKY_ASSET, MARKET_BENCHMARK], start=START_DATE, end=END_DATE, adjusted=True, progress=False)
将数据重采样为月度数据并计算简单收益:
X = ( df["Adj Close"] .rename(columns={RISKY_ASSET: "asset", MARKET_BENCHMARK: "market"}) .resample("M") .last() .pct_change() .dropna() )
使用协方差方法计算贝塔值:
covariance = X.cov().iloc[0,1] benchmark_variance = X.market.var() beta = covariance / benchmark_variance
代码的结果是
beta = 1.2035
。准备输入数据并通过线性回归估计 CAPM:
# separate target y = X.pop("asset") # add constant X = sm.add_constant(X) # define and fit the regression model capm_model = sm.OLS(y, X).fit() # print results print(capm_model.summary())
图 8.1 显示了 CAPM 模型估计结果:
图 8.1:使用 OLS 估计的 CAPM 概述
这些结果表明贝塔值(这里代表市场)为 1.2,这意味着亚马逊的回报波动性是市场的 20%(由标准普尔 500 指数代理)。换句话说,亚马逊的(超额)回报预计将是市场(超额)回报的 1.2 倍。截距值相对较小,在 5% 显著性水平下统计上不显著。
它是如何工作的...
首先,我们指定了要使用的资产(亚马逊和标准普尔 500)及时间框架。在第 3 步中,我们从 Yahoo Finance 下载了数据。然后,我们只保留了每月最后一个可用的价格,并计算了每月回报率,即后续观测值之间的百分比变化。
在第 5 步中,我们计算了β值,它是风险资产与基准之间的协方差与基准方差的比率。
在第 6 步中,我们使用pandas
DataFrame 的pop
方法将目标(亚马逊的股票回报)和特征(标准普尔 500 回报)分离。之后,我们使用add_constant
函数向特征中添加常数(实际上是向 DataFrame 中添加了一列 1)。
向回归中添加截距的想法是研究——在估计模型之后——截距(在 CAPM 模型中也称为詹森的阿尔法)是否为零。如果它是正值并且显著,意味着——假设 CAPM 模型成立——该资产或投资组合产生了异常高的风险调整回报。这个问题有两种可能的含义:要么市场是无效的,要么有其他尚未发现的风险因子应该被纳入模型中。这个问题被称为联合假设问题。
我们也可以使用公式表示法,它会自动添加常数。为此,我们必须导入statsmodels.formula.api
作为smf
,然后运行稍微修改过的代码行:capm_model = smf.ols(formula="asset ~ market", data=X).fit()
。这两种方法的结果是相同的。你可以在随附的 Jupyter notebook 中找到完整的代码。
最后,我们进行了 OLS 回归并打印了总结。这里,我们可以看到市场变量(即 CAPM β值)的系数等于在第 5 步中通过资产与市场之间的协方差计算得出的β值。
还有更多内容...
在上述示例中,我们假设没有无风险利率,这是现在做出的合理假设。然而,也可能存在我们希望考虑非零无风险利率的情况。为此,我们可以使用以下几种方法之一。
使用 Kenneth French 教授网站的数据
市场溢价(r[m] - r[f])和无风险利率(由一个月期国库券近似)可以从 Kenneth French 教授的网站下载(有关链接,请参见本食谱的另请参阅部分)。
请记住,Prof. French 使用的市场基准的定义与标准普尔 500 指数不同——有关详细描述,请参见他的网站。有关如何轻松下载数据的说明,请参考实施法马-法 rench 三因子模型的食谱。
使用 13 周期国债券
第二个选择是通过例如 13 周期(3 个月)国债券(Yahoo Finance 代码:^IRX
)来近似无风险利率。
按照以下步骤学习如何下载数据并将其转换为适当的无风险利率:
定义周期的天数长度:
N_DAYS = 90
从雅虎财经下载数据:
df_rf = yf.download("^IRX", start=START_DATE, end=END_DATE, progress=False)
将数据重新采样为按月频率(每月取最后一个值):
rf = df_rf.resample("M").last().Close / 100
计算无风险回报(以日值表示),并将值转换为月度:
rf = ( 1 / (1 - rf * N_DAYS / 360) )**(1 / N_DAYS) rf = (rf ** 30) - 1
绘制计算出的无风险利率图表:
rf.plot(title="Risk-free rate (13-Week Treasury Bill)")
图 8.2显示了无风险利率随时间变化的可视化图:
图 8.2:使用 13 周期国库券计算的无风险利率
使用 FRED 数据库中的 3 个月期国库券
最后一种方法是使用 3 个月期国库券(次级市场利率)来近似无风险利率,数据可以从美联储经济数据(FRED)数据库下载。
按照以下步骤学习如何下载数据并将其转换为月度无风险利率:
导入库:
import pandas_datareader.data as web
从 FRED 数据库下载数据:
rf = web.DataReader( "TB3MS", "fred", start=START_DATE, end=END_DATE )
将获得的无风险利率转换为月度值:
rf = (1 + (rf / 100)) ** (1 / 12) - 1
绘制计算出的无风险利率图表:
rf.plot(title="Risk-free rate (3-Month Treasury Bill)")
我们可以通过比较无风险利率的图表来对比两种方法的结果:
图 8.3:使用 3 个月期国库券计算的无风险利率
上述分析让我们得出结论,图表看起来非常相似。
另见
额外的资源可以在此处找到:
Sharpe, W. F., “资本资产定价:在风险条件下的市场均衡理论,” 金融学杂志,19,3(1964):425–442。
Prof. Kenneth French 网站上的无风险利率数据:
mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_Factors_CSV.zip
。
估算 Fama-French 三因子模型
在他们的著名论文中,Fama 和 French 通过增加两个额外的因子扩展了 CAPM 模型,用以解释资产或投资组合的超额回报。他们考虑的因子包括:
市场因子(MKT):它衡量市场的超额回报,类似于资本资产定价模型(CAPM)中的回报。
规模因子(SMB;小市值减大市值):它衡量的是小市值股票相对于大市值股票的超额回报。
价值因子(HML;高市账比减低市账比):它衡量价值股相对于成长股的超额回报。价值股具有较高的账面市值比,而成长股的市账比较低。
请参见另见部分,了解因子的计算方法。
该模型可以表示如下:
或者以更简单的形式:
这里,E(r[i])表示资产i的预期回报,r[f]是无风险利率(如政府债券),而是截距项。包括截距项的原因是为了确保它的值为 0。这证明三因子模型正确地评估了超额回报与因子之间的关系。
在统计上显著且不为零的截距项的情况下,模型可能无法正确评估资产/投资组合的回报。然而,作者表示,即使模型未能通过统计检验,三因子模型依然是“相当正确的”。
由于这种方法的流行,这些因素被统称为法马-弗伦奇因素或三因子模型。它们在学术界和行业中被广泛接受作为股市基准,并且经常被用来评估投资表现。
在这个示例中,我们使用 2016 年到 2020 年的 5 年苹果股票的月度回报来估计三因子模型。
如何做...
按照以下步骤在 Python 中实现三因子模型:
导入库:
import pandas as pd import yfinance as yf import statsmodels.formula.api as smf import pandas_datareader.data as web
定义参数:
RISKY_ASSET = "AAPL" START_DATE = "2016-01-01" END_DATE = "2020-12-31"
下载包含风险因子的数据集:
ff_dict = web.DataReader("F-F_Research_Data_Factors", "famafrench", start=START_DATE, end=END_DATE)
下载的字典包含三个元素:请求时间框架内的月度因子(索引为
0
)、相应的年度因子(索引为1
)以及数据集的简短描述(索引为DESCR
)。选择合适的数据集并将值除以 100:
factor_3_df = ff_dict[0].rename(columns={"Mkt-RF": "MKT"}) \ .div(100) factor_3_df.head()
结果数据应如下所示:
图 8.4:下载的因子预览
下载风险资产的价格:
asset_df = yf.download(RISKY_ASSET, start=START_DATE, end=END_DATE, adjusted=True)
计算风险资产的月度回报:
y = asset_df["Adj Close"].resample("M") \ .last() \ .pct_change() \ .dropna() y.index = y.index.to_period("m") y.name = "rtn"
合并数据集并计算超额回报:
factor_3_df = factor_3_df.join(y) factor_3_df["excess_rtn"] = ( factor_3_df["rtn"] - factor_3_df["RF"] )
估计三因子模型:
ff_model = smf.ols(formula="excess_rtn ~ MKT + SMB + HML", data=factor_3_df).fit() print(ff_model.summary())
三因子模型的结果如下所示:
图 8.5:估计的三因子模型摘要
在解释三因子模型的结果时,我们应关注两个问题:
截距是否为正且在统计上显著
哪些因素在统计上是显著的,并且它们的方向是否与过去的结果(例如,基于文献研究)或我们的假设一致
在我们的案例中,截距项为正,但在 5%的显著性水平下并不显著。在风险因子中,只有 SMB 因子不显著。然而,仍需要进行详细的文献研究,以便对这些因子及其影响方向提出假设。
我们还可以查看回归摘要中呈现的 F 统计量,它用于检验回归的联合显著性。原假设认为,除了截距项之外,所有特征(在此情况下为因子)的系数值均为 0。我们可以看到,相应的p值远小于 0.05,这使我们有理由在 5%的显著性水平下拒绝原假设。
它是如何工作的...
在前两步中,我们导入了所需的库并定义了参数——风险资产(苹果股票)和考虑的时间范围。
在步骤 3中,我们使用 pandas_datareader
库的功能下载了数据。我们必须指定要使用的数据集(有关检查可用数据集的信息,请参见*还有更多...*部分)和读取器(famafrench
),以及起始/结束日期(默认情况下,web.DataReader
下载的是过去 5 年的数据)。
在步骤 4中,我们仅选择了包含月度数据的数据集(在下载的字典中索引为0
),重命名了包含 MKT 因子的列,并将所有值除以 100。我们这样做是为了正确编码百分比;例如,数据集中值为 3.45 的表示 3.45%。
在步骤 5和步骤 6中,我们下载并处理了苹果股票的价格。通过计算月末价格的百分比变化,我们得到了月度收益。在步骤 6中,我们还将指数的格式更改为%Y-%m
(例如,2000-12
),因为 Fama-French 因子包含的是这种格式的日期。然后,在步骤 7中,我们将两个数据集合并。
最后,在步骤 8中,我们使用公式符号运行了回归——在执行时,我们不需要手动添加截距。值得一提的是,MKT 变量的系数将不等于 CAPM 的贝塔,因为模型中还有其他因子,且这些因子对超额收益的影响分布不同。
还有更多...
我们可以使用以下代码片段查看哪些来自 Fama-French 类别的数据集可供通过pandas_datareader
下载。为了简洁起见,我们只展示了大约 300 个可用数据集中的 5 个:
from pandas_datareader.famafrench import get_available_datasets
get_available_datasets()[:5]
运行代码片段会返回以下列表:
['F-F_Research_Data_Factors',
'F-F_Research_Data_Factors_weekly',
'F-F_Research_Data_Factors_daily',
'F-F_Research_Data_5_Factors_2x3',
'F-F_Research_Data_5_Factors_2x3_daily']
在本书的前一版中,我们还展示了如何使用简单的 Bash 命令直接从 French 教授的网站下载 CSV 文件,并在 Jupyter notebook 中执行。您可以在随附的 notebook 中找到解释如何操作的代码。
另见
其他资源:
有关所有因子是如何计算的详细信息,请参考 French 教授的网站:
mba.tuck.dartmouth.edu/pages/faculty/ken.french/Data_Library/f-f_factors.html
Fama, E. F. 和 French, K. R.,“股票和债券收益中的共同风险因素”,《金融经济学期刊》,33 卷,1 期(1993 年):3-56
在资产组合上估计滚动三因子模型
在本食谱中,我们学习如何以滚动的方式估算三因子模型。我们所说的滚动是指,我们始终考虑一个固定大小的估算窗口(在本例中为 60 个月),并将其按期滚动整个数据集。进行此类实验的一个潜在原因是为了测试结果的稳定性。或者,我们也可以使用扩展窗口进行此实验。
与之前的食谱不同,这一次我们使用的是投资组合回报,而不是单一资产。为了简化,我们假设我们的配置策略是将投资组合总价值的相等份额分配到以下股票:亚马逊、谷歌、苹果和微软。对于这项实验,我们使用的是 2010 到 2020 年的股票价格。
如何做...
按照以下步骤在 Python 中实现滚动三因子模型:
导入库:
import pandas as pd import numpy as np import yfinance as yf import statsmodels.formula.api as smf import pandas_datareader.data as web
定义参数:
ASSETS = ["AMZN", "GOOG", "AAPL", "MSFT"] WEIGHTS = [0.25, 0.25, 0.25, 0.25] START_DATE = "2010-01-01" END_DATE = "2020-12-31"
下载因子相关数据:
factor_3_df = web.DataReader("F-F_Research_Data_Factors", "famafrench", start=START_DATE, end=END_DATE)[0] factor_3_df = factor_3_df.div(100)
从 Yahoo Finance 下载风险资产的价格:
asset_df = yf.download(ASSETS, start=START_DATE, end=END_DATE, adjusted=True, progress=False)
计算风险资产的月度回报:
asset_df = asset_df["Adj Close"].resample("M") \ .last() \ .pct_change() \ .dropna() asset_df.index = asset_df.index.to_period("m")
计算投资组合回报:
asset_df["portfolio_returns"] = np.matmul( asset_df[ASSETS].values, WEIGHTS )
合并数据集:
factor_3_df = asset_df.join(factor_3_df).drop(ASSETS, axis=1) factor_3_df.columns = ["portf_rtn", "mkt", "smb", "hml", "rf"] factor_3_df["portf_ex_rtn"] = ( factor_3_df["portf_rtn"] - factor_3_df["rf"] )
定义一个滚动n因子模型的函数:
def rolling_factor_model(input_data, formula, window_size): coeffs = [] for start_ind in range(len(input_data) - window_size + 1): end_ind = start_ind + window_size ff_model = smf.ols( formula=formula, data=input_data[start_ind:end_ind] ).fit() coeffs.append(ff_model.params) coeffs_df = pd.DataFrame( coeffs, index=input_data.index[window_size - 1:] ) return coeffs_df
如需带有输入/输出说明的版本,请参考本书的 GitHub 仓库。
估算滚动三因子模型并绘制结果:
MODEL_FORMULA = "portf_ex_rtn ~ mkt + smb + hml" results_df = rolling_factor_model(factor_3_df, MODEL_FORMULA, window_size=60) ( results_df .plot(title = "Rolling Fama-French Three-Factor model", style=["-", "--", "-.", ":"]) .legend(loc="center left",bbox_to_anchor=(1.0, 0.5)) )
执行代码会生成以下图表:
图 8.6:滚动三因子模型的系数
通过检查前面的图表,我们可以看到以下内容:
截距几乎保持不变,且非常接近 0。
因子之间存在一定的波动性,但没有突如其来的反转或意外的跳跃。
它是如何工作的...
在步骤 3和步骤 4中,我们使用pandas_datareader
和yfinance
下载了数据。这与我们在估计 Fama-French 三因子模型的食谱中做的非常相似,因此在这一点上我们不会详细讲解。
在步骤 6中,我们将投资组合回报计算为投资组合成分(在步骤 5中计算)的加权平均值。这是可能的,因为我们正在使用简单回报——更多细节请参考第二章《数据预处理》中的将价格转换为回报食谱。请记住,这种简单的方法假设在每个月结束时,我们的资产配置始终与所指示的权重相同。这个可以通过投资组合再平衡来实现,即在指定时间段后调整配置,以始终匹配预定的权重分布。
之后,我们在第 7 步中合并了这两个数据集。在第 8 步中,我们定义了一个函数,用于使用滚动窗口估算n因子模型。其主要思想是遍历我们在前面步骤中准备的 DataFrame,并为每个月估算法马-法 rench 模型,使用过去五年的数据(60 个月)。通过适当地切片输入的 DataFrame,我们确保从第 60 个月开始估算模型,以确保我们始终有一个完整的观察窗口。
正确的软件工程最佳实践建议编写一些断言,以确保输入的类型符合我们的预期,或者输入的 DataFrame 包含必要的列。然而,为了简洁起见,我们在此没有进行这些操作。
最后,我们将定义的函数应用于准备好的 DataFrame,并绘制了结果。
估算四因子和五因子模型
在本食谱中,我们实现了法马-法 rench 三因子模型的两个扩展。
首先,Carhart 的四因子模型:该扩展的基本假设是,在短时间内,赢家股票会继续是赢家,而输家股票会继续是输家。一个用来分类赢家和输家的标准可能是过去 12 个月的累计总回报。确定两组之后,我们在一定的持有期内做多赢家,做空输家。
动量因子(WML;赢家减去输家)衡量过去 12 个月赢家股票相对于输家股票的超额回报(有关动量因子计算的参考,请参见本食谱中的另见部分)。
四因子模型可以表达为如下:
第二个扩展是法马-法 rench 的五因子模型。法马和法 rench 通过添加两个因子扩展了他们的三因子模型:
盈利能力因子(RMW;强盈利减弱盈利)衡量高利润率(强盈利能力)公司的超额回报,相对于那些利润较低(弱盈利能力)的公司。
投资因子(CMA;保守投资减激进投资)衡量投资政策保守(低投资)的公司相对于那些投资更多(激进投资)公司的超额回报。
五因子模型可以表达为如下:
与所有因子模型一样,如果风险因子的暴露捕捉到所有预期回报的可能变化,那么所有资产/投资组合的截距()应该等于零。
在本食谱中,我们使用四因子和五因子模型解释了 2016 年至 2020 年期间亚马逊的月度回报。
如何操作...
按照以下步骤在 Python 中实现四因子和五因子模型:
导入库:
import pandas as pd import yfinance as yf import statsmodels.formula.api as smf import pandas_datareader.data as web
指定风险资产和时间范围:
RISKY_ASSET = "AMZN" START_DATE = "2016-01-01" END_DATE = "2020-12-31"
从法马教授的官方网站下载风险因子:
# three factors factor_3_df = web.DataReader("F-F_Research_Data_Factors", "famafrench", start=START_DATE, end=END_DATE)[0] # momentum factor momentum_df = web.DataReader("F-F_Momentum_Factor", "famafrench", start=START_DATE, end=END_DATE)[0] # five factors factor_5_df = web.DataReader("F-F_Research_Data_5_Factors_2x3", "famafrench", start=START_DATE, end=END_DATE)[0]
从 Yahoo Finance 下载风险资产的数据:
asset_df = yf.download(RISKY_ASSET, start=START_DATE, end=END_DATE, adjusted=True, progress=False)
计算月度回报:
y = asset_df["Adj Close"].resample("M") \ .last() \ .pct_change() \ .dropna() y.index = y.index.to_period("m") y.name = "rtn"
合并四因子模型的数据集:
# join all datasets on the index factor_4_df = factor_3_df.join(momentum_df).join(y) # rename columns factor_4_df.columns = ["mkt", "smb", "hml", "rf", "mom", "rtn"] # divide everything (except returns) by 100 factor_4_df.loc[:, factor_4_df.columns != "rtn"] /= 100 # calculate excess returns factor_4_df["excess_rtn"] = ( factor_4_df["rtn"] - factor_4_df["rf"] )
合并五因子模型的数据集:
# join all datasets on the index factor_5_df = factor_5_df.join(y) # rename columns factor_5_df.columns = [ "mkt", "smb", "hml", "rmw", "cma", "rf", "rtn" ] # divide everything (except returns) by 100 factor_5_df.loc[:, factor_5_df.columns != "rtn"] /= 100 # calculate excess returns factor_5_df["excess_rtn"] = ( factor_5_df["rtn"] - factor_5_df["rf"] )
估计四因子模型:
four_factor_model = smf.ols( formula="excess_rtn ~ mkt + smb + hml + mom", data=factor_4_df ).fit() print(four_factor_model.summary())
图 8.7 显示了结果:
图 8.7:四因子模型估计结果的总结
估计五因子模型:
five_factor_model = smf.ols( formula="excess_rtn ~ mkt + smb + hml + rmw + cma", data=factor_5_df ).fit() print(five_factor_model.summary())
图 8.8 显示了结果:
图 8.8:五因子模型估计结果的总结
根据五因子模型,亚马逊的超额收益与大多数因素(除了市场因素)呈负相关。这里,我们提供了一个系数解释的例子:市场因素增加 1 个百分点,导致超额收益增加 0.015 个百分点。换句话说,对于市场因素的 1%收益,我们可以预期我们的投资组合(亚马逊股票)将超过无风险利率回报 1.5117 * 1%。
类似于三因子模型,如果五因子模型完全解释了超额股票收益,估计的截距应该在统计上与零无显著差异(对于所考虑的问题来说,确实是这种情况)。
它是如何工作的...
在步骤 2中,我们定义了参数——所考虑的股票的代码和时间范围。
在步骤 3中,我们使用pandas_datareader
下载了所需的数据集,它为我们提供了一种方便的方式来下载与风险因子相关的数据,而无需手动下载 CSV 文件。有关此过程的更多信息,请参阅估计法马-法兰西三因子模型的食谱。
在步骤 4和步骤 5中,我们下载了亚马逊的股票价格,并使用先前解释的方法计算了月度回报。
在步骤 6和步骤 7中,我们合并了所有数据集,重命名了列,并计算了超额收益。当使用join
方法而不指定连接条件(即on
参数)时,默认情况下使用 DataFrame 的索引进行连接。
这样,我们准备了四因子和五因子模型所需的所有输入。我们还需要将从法兰西教授网站下载的所有数据除以 100,以得到正确的尺度。
五因子数据集中的 SMB 因子计算方法与三因子数据集中的计算方法不同。有关更多详细信息,请参阅本食谱中“另请参见”部分的链接。
在步骤 8和步骤 9中,我们使用statsmodels
库中的 OLS 回归的函数形式来估计模型。函数形式会自动将截距添加到回归方程中。
另请参见
有关因子计算的详细信息,请参见以下链接:
动量因子:
mba.tuck.dartmouth.edu/pages/faculty/ken.french/Data_Library/det_mom_factor.html
五因子模型:
mba.tuck.dartmouth.edu/pages/faculty/ken.french/Data_Library/f-f_5_factors_2x3.html
对于介绍四因子和五因子模型的论文,请参考以下链接:
Carhart, M. M. (1997), “On Persistence in Mutual Fund Performance,” The Journal of Finance, 52, 1 (1997): 57-82
Fama, E. F. 和 French, K. R. 2015 年. “五因子资产定价模型,”《金融经济学杂志》,116(1): 1-22:
doi.org/10.1016/j.jfineco.2014.10.010
使用 Fama-MacBeth 回归估算横截面因子模型
在之前的步骤中,我们已经涵盖了使用单一资产或投资组合作为因变量来估算不同因子模型。然而,我们也可以使用横截面(面板)数据来一次估算多个资产的因子模型。
按照这种方法,我们可以:
估算投资组合对风险因素的敞口,并了解这些因素对投资组合回报的影响
通过了解市场为某一因子的暴露支付的溢价,理解承担特定风险的价值
知道风险溢价后,只要我们能够近似该投资组合对风险因素的敞口,就可以估算任何投资组合的回报。
在估计横截面回归时,由于某些线性回归假设可能不成立,我们可能会遇到多种问题。可能遇到的问题包括:
异方差性和序列相关性,导致残差的协方差
多重共线性
测量误差
为了解决这些问题,我们可以使用一种叫做Fama-MacBeth 回归的技术,这是一种专门设计的两步法,用于估计市场对某些风险因素暴露所奖励的溢价。
步骤如下:
- 通过估计N(投资组合/资产的数量)次序列回归,获取因子载荷:
- 通过估算T(时期的数量)横截面回归,每个时期一个,获得风险溢价:
在本步骤中,我们使用五个风险因子和 12 个行业投资组合的回报来估算 Fama-MacBeth 回归,数据也可以在法兰西教授的网站上获得。
如何操作……
执行以下步骤来估算 Fama-MacBeth 回归:
导入库:
import pandas as pd import pandas_datareader.data as web from linearmodels.asset_pricing import LinearFactorModel
指定时间范围:
START_DATE = "2010" END_DATE = "2020-12"
从法兰西教授的网站下载并调整风险因素:
factor_5_df = ( web.DataReader("F-F_Research_Data_5_Factors_2x3", "famafrench", start=START_DATE, end=END_DATE)[0] .div(100) )
从法兰西教授的网站下载并调整 12 个行业投资组合的回报:
portfolio_df = ( web.DataReader("12_Industry_Portfolios", "famafrench", start=START_DATE, end=END_DATE)[0] .div(100) .sub(factor_5_df["RF"], axis=0) )
从因子数据集中去除无风险利率:
factor_5_df = factor_5_df.drop("RF", axis=1)
估算 Fama-MacBeth 回归并打印摘要:
five_factor_model = LinearFactorModel( portfolios=portfolio_df, factors=factor_5_df ) result = five_factor_model.fit() print(result)
运行代码片段将生成以下摘要:
图 8.9:Fama-MacBeth 回归的结果
表中的结果是来自T横截面回归的平均风险溢价。
我们还可以打印完整的总结(包括风险溢价和每个投资组合的因子负荷)。为此,我们需要运行以下代码:
print(result.full_summary)
它是如何工作的……
在前两步中,我们导入了所需的库,并定义了我们的练习的开始和结束日期。总共,我们将使用 11 年的月度数据,共计 132 个变量观察值(记作T)。对于结束日期,我们必须指定2020-12
。仅使用2020
将导致下载的数据集在 2020 年 1 月结束。
在步骤 3中,我们使用pandas_datareader
下载了五因子数据集。我们通过将数值除以 100 来调整这些值,以表示百分比。
在步骤 4中,我们从法兰西教授的网站下载了 12 个行业投资组合的回报数据(更多数据集详情请参见另见部分中的链接)。我们还通过将数值除以 100 来调整这些值,并通过从每个投资组合数据集的列中减去无风险利率(该数据可在因子数据集中找到)来计算超额回报。由于时间段完全匹配,我们可以轻松地使用sub
方法进行计算。
在步骤 5中,我们去除了无风险利率,因为我们将不再使用它,而且没有冗余列的数据框(DataFrame)将使得估计 Fama-MacBeth 回归模型更加简便。
在最后一步中,我们实例化了LinearFactorModel
类的对象,并将两个数据集作为参数提供。然后,我们使用fit
方法估计了模型。最后,我们打印了总结。
你可能会注意到linearmodels
和scikit-learn
之间存在一些小差异。在后者中,我们在调用fit
方法时提供数据。而在linearmodels
中,我们必须在创建LinearFactorModel
类的实例时提供数据。
在linearmodels
中,你还可以使用公式符号(就像我们在使用statsmodels
估计因子模型时做的那样)。为此,我们需要使用from_formula
方法。一个例子可能如下所示:LinearFactorModel.from_formula(formula, data)
,其中formula
是包含公式的字符串,而data
是一个包含投资组合/资产和因子的对象。
还有更多……
我们已经使用linearmodels
库估计了 Fama-MacBeth 回归。然而,亲自手动执行这两个步骤可能有助于加深我们对该过程的理解。
执行以下步骤,将 Fama-MacBeth 程序的两个步骤分别进行:
导入库:
from statsmodels.api import OLS, add_constant
对于 Fama-MacBeth 回归的第一步,估计因子负荷:
factor_loadings = [] for portfolio in portfolio_df: reg_1 = OLS( endog=portfolio_df.loc[:, portfolio], exog=add_constant(factor_5_df) ).fit() factor_loadings.append(reg_1.params.drop("const"))
将因子负荷存储在数据框中:
factor_load_df = pd.DataFrame( factor_loadings, columns=factor_5_df.columns, index=portfolio_df.columns ) factor_load_df.head()
运行代码会生成包含因子负荷的以下表格:
图 8.10:Fama-MacBeth 回归的第一步——估计的因子负荷
我们可以将这些数字与
linearmodels
库的完整总结的输出进行比较。对于 Fama-MacBeth 回归的第二步,估计风险溢价:
risk_premia = [] for period in portfolio_df.index: reg_2 = OLS( endog=portfolio_df.loc[period, factor_load_df.index], exog=factor_load_df ).fit() risk_premia.append(reg_2.params)
将风险溢价存储在 DataFrame 中:
risk_premia_df = pd.DataFrame( risk_premia, index=portfolio_df.index, columns=factor_load_df.columns.tolist()) risk_premia_df.head()
运行代码会生成以下包含风险溢价随时间变化的表格:
图 8.11:Fama-MacBeth 回归的第二步——随时间变化的估计风险溢价
计算平均风险溢价:
risk_premia_df.mean()
运行代码片段返回:
Mkt-RF 0.012341 SMB -0.006291 HML -0.008927 RMW -0.000908 CMA -0.002484
上述计算的风险溢价与从 linearmodels
库中得到的结果相匹配。
另见
linearmodels
库的文档是学习面板回归模型的一个很好的资源(不仅仅是这些,它还包含工具来处理工具变量模型等),并展示了如何在 Python 中实现这些模型:bashtage.github.io/linearmodels/index.html
12 个行业投资组合数据集的描述:
mba.tuck.dartmouth.edu/pages/faculty/ken.french/data_library/det_12_ind_port.html
关于 Fama-MacBeth 程序的进一步阅读:
Fama, E. F., 和 MacBeth, J. D., “风险、回报与均衡:实证检验,” 《政治经济学杂志》, 81, 3 (1973): 607-636
Fama, E. F., “市场效率、长期收益与行为金融,” 《金融经济学杂志》, 49, 3 (1998): 283-306
总结
在本章中,我们构建了一些最流行的因子模型。我们从最简单的单因子模型(CAPM)开始,然后解释了如何处理更复杂的三因子、四因子和五因子模型。我们还描述了如何使用 Fama-MacBeth 回归估计多资产的因子模型,并使用适当的横截面(面板)数据。
第九章:使用 GARCH 类模型对波动性建模
在第六章,时间序列分析与预测中,我们探讨了多种时间序列建模方法。然而,像ARIMA(自回归积分滑动平均)这样的模型无法解释随时间变化的非恒定波动性(异方差性)。我们已经解释过,一些变换(如对数变换或 Box-Cox 变换)可以用于调整轻微的波动性变化,但我们希望更进一步,对其进行建模。
本章中,我们专注于条件异方差性,这是一种当波动性增加时,与波动性的进一步增加相关联的现象。一个例子可能有助于理解这一概念。假设由于某些与公司相关的突发新闻,某项资产的价格大幅下跌。这种突然的价格下跌可能会触发某些投资基金的风险管理工具,导致它们开始卖出股票,以应对前期价格下跌。这可能会导致价格进一步暴跌。条件异方差性在研究资产回报的风格化事实这一章节中也表现得十分明显,我们展示了回报率呈现波动性聚集现象。
我们想简要说明本章的动机。波动性是金融中一个极其重要的概念,它与风险同义,并在量化金融中有广泛应用。首先,它在期权定价中至关重要,因为布莱克-斯科尔斯模型依赖于标的资产的波动性。其次,波动性对风险管理有重大影响,它被用来计算投资组合的风险价值(VaR)、夏普比率等多个指标。第三,波动性也出现在交易中。通常,交易者基于对资产价格上升或下降的预测来做决策。然而,我们也可以根据预测是否存在任何方向的波动来进行交易,即是否会有波动性。波动性交易在某些全球事件(例如疫情)导致市场剧烈波动时尤为吸引人。对于波动性交易者来说,一个可能感兴趣的产品是波动性指数(VIX),它基于标普 500 指数的波动。
在本章结束时,我们将介绍一系列 GARCH(广义自回归条件异方差)模型——包括单变量和多变量模型——它们是建模和预测波动性最流行的方法之一。掌握了基础知识后,实施更高级的模型变得相当简单。我们已经提到过波动性在金融中的重要性,通过了解如何对其进行建模,我们可以使用这些预测来替代许多实际应用中,风险管理或衍生品估值中之前使用的简单预测。
在本章中,我们将涵盖以下内容:
使用 ARCH 模型对股票收益波动性建模
使用 GARCH 模型对股票收益波动性建模
使用 GARCH 模型预测波动性
使用 CCC-GARCH 模型进行多元波动性预测
使用 DCC-GARCH 预测条件协方差矩阵
使用 ARCH 模型对股票收益波动性建模
在本篇中,我们通过自回归条件异方差(ARCH)模型来研究股票收益条件波动性的建模问题。
简单来说,ARCH 模型将误差项的方差表示为过去误差的函数。更准确地说,它假设误差的方差遵循自回归模型。ARCH 方法的整个逻辑可以通过以下方程表示:
第一个方程将收益序列表示为期望收益μ和意外收益的组合!。!具有白噪声特性——条件均值为零,并且具有时变的条件方差!。
误差项是序列无相关的,但不需要是序列独立的,因为它们可能表现出条件异方差性。
也被称为均值修正收益、误差项、创新或——最常见的——残差。
通常,ARCH(以及 GARCH)模型应仅对应用于原始时间序列的其他模型的残差进行拟合。在估计波动性模型时,我们可以假设均值过程的不同规格,例如:
在第二个方程中,我们将误差序列表示为一个随机成分!和一个条件标准差!,后者控制残差的典型大小。随机成分也可以解释为标准化残差。
第三个方程展示了 ARCH 公式,其中!和!。关于 ARCH 模型的一些重要点包括:
ARCH 模型明确识别了时间序列的无条件方差和条件方差之间的差异。
它将条件方差建模为过去残差(误差)的函数,这些残差来自均值过程。
它假设无条件方差在时间上是恒定的。
可以使用普通最小二乘法(OLS)方法估计 ARCH 模型。
我们必须在模型中指定先前残差的数量(q)——类似于 AR 模型。
残差应呈现出离散白噪声的观察形式——零均值且平稳(没有趋势或季节性效应,即没有明显的序列相关性)。
在原始 ARCH 符号中,以及在 Python 的arch
库中,滞后超参数用p表示。然而,我们使用q作为相应的符号,与接下来介绍的 GARCH 符号一致。
ARCH 模型的最大优点是,它所生成的波动率估计呈现出过度峰度(相比正态分布有更胖的尾部),这与股票回报的经验观察一致。当然,它也有缺点。第一个缺点是,该模型假设正负波动冲击的效应相同,但实际上并非如此。其次,它没有解释波动率的变化。这也是为什么该模型可能会过度预测波动率,因为它对回报序列中大规模孤立冲击的响应较慢。
在本食谱中,我们将 ARCH(1)模型拟合到 2015 至 2021 年间谷歌的每日股票回报数据。
如何操作...
执行以下步骤来拟合 ARCH(1)模型:
导入库:
import pandas as pd import yfinance as yf from arch import arch_model
指定风险资产和时间跨度:
RISKY_ASSET = "GOOG" START_DATE = "2015-01-01" END_DATE = "2021-12-31"
从 Yahoo Finance 下载数据:
df = yf.download(RISKY_ASSET, start=START_DATE, end=END_DATE, adjusted=True)
计算每日回报:
returns = 100 * df["Adj Close"].pct_change().dropna() returns.name = "asset_returns" returns.plot( title=f"{RISKY_ASSET} returns: {START_DATE} - {END_DATE}" )
运行代码后生成以下图表:
图 9.1:2015 至 2021 年间谷歌的简单回报
在图表中,我们可以观察到一些突如其来的尖峰和明显的波动率聚集现象。
指定 ARCH 模型:
model = arch_model(returns, mean="Zero", vol="ARCH", p=1, q=0)
估计模型并打印摘要:
fitted_model = model.fit(disp="off") print(fitted_model.summary())
运行代码后返回以下摘要:
Zero Mean - ARCH Model Results =================================================================== Dep. Variable: asset_returns R-squared: 0.000 Mean Model: Zero Mean Adj. R-squared: .001 Vol Model: ARCH Log-Likelihood: -3302.93 Distribution: Normal AIC: 6609.85 Method: Maximum BIC: 6620.80 Likelihood No. Observations: 1762 Date: Wed, Jun 08 2022 Df Residuals: 1762 Time: 22:25:16 Df Model: 0 Volatility Model =================================================================== coef std err t P>|t| 95.0% Conf. Int. ------------------------------------------------------------------- omega 1.8625 0.166 11.248 2.359e-29 [ 1.538, 2.187] alpha[1] 0.3788 0.112 3.374 7.421e-04 [ 0.159, 0.599] ===================================================================
绘制残差和条件波动率:
fitted_model.plot(annualize="D")
运行代码后生成以下图表:
图 9.2:标准化残差和拟合 ARCH 模型的年化条件波动率
我们可以观察到一些标准化残差值较大(绝对值大),并且对应于高波动期。
它是如何工作的...
在步骤 2到4中,我们下载了谷歌的每日股票价格并计算了简单回报。在使用 ARCH/GARCH 模型时,当数值非常小的时候,可能会出现收敛警告。这是由于scipy
库中底层优化算法的不稳定性。为了解决这个问题,我们将回报乘以 100,转化为百分比表示。
在步骤 5中,我们定义了 ARCH(1)模型。对于均值模型,我们选择了零均值方法,这对于许多流动性较高的金融资产是适用的。另一个可行的选择是常数均值。我们可以选择这些方法,而不是例如 ARMA 模型,因为回报序列的序列依赖性可能非常有限。
在步骤 6中,我们使用fit
方法拟合了模型。此外,我们将disp="off"
传递给fit
方法,以抑制优化步骤中的输出。为了使用arch
库拟合模型,我们需要采取与熟悉的scikit-learn
方法类似的步骤:我们首先定义模型,然后将其拟合到数据上。一个区别是,在arch
中,我们在创建模型实例时就必须提供数据对象,而不是像在scikit-learn
中那样将数据传递给fit
方法。然后,我们使用summary
方法打印了模型的摘要。
在步骤 7中,我们还通过绘制图形检查了标准化残差和条件波动率序列。标准化残差是通过将残差除以条件波动率来计算的。我们将annualize="D"
传递给plot
方法,以便将条件波动率序列从日数据年化。
还有更多内容...
关于 ARCH 模型的几点注意事项:
选择零均值过程在处理来自单独估计模型的残差时非常有用。
为了检测 ARCH 效应,我们可以观察某个模型(如 ARIMA 模型)残差平方的自相关图。我们需要确保这些残差的均值为零。我们可以使用偏自相关函数(PACF)图来推断q的值,这与 AR 模型的处理方法类似(更多细节请参阅使用 ARIMA 类模型建模时间序列的相关内容)。
为了检验模型的有效性,我们可以检查标准化残差和标准化残差的平方是否没有序列自相关(例如,使用
statsmodels
中的acorr_ljungbox
函数,进行 Ljung-Box 或 Box-Pierce 检验)。或者,我们可以使用拉格朗日乘子检验(LM 检验,也称为恩格尔自回归条件异方差性检验)来确保模型捕捉到所有 ARCH 效应。为此,我们可以使用statsmodels
中的het_arch
函数。
在以下代码片段中,我们使用 LM 检验对 ARCH 模型的残差进行检验:
from statsmodels.stats.diagnostic import het_arch
het_arch(fitted_model.resid)
运行代码会返回以下元组:
(98.10927835448403,
1.3015895084238874e-16,
10.327662606705564,
4.2124269229123006e-17)
元组中的前两个值是 LM 检验统计量及其对应的 p 值。后两个是 F 检验的 f 统计量(另一种检测 ARCH 效应的方法)及其对应的 p 值。我们可以看到,这两个 p 值都低于常见的显著性水平 0.05,这使我们能够拒绝原假设,即残差是同方差的。这意味着 ARCH(1)模型未能捕捉到残差中的所有 ARCH 效应。
het_arch
函数的文档建议,如果残差来自回归模型,则应修正该模型中估计的参数数量。例如,如果残差来自 ARMA(2, 1)模型,则应将额外的参数ddof = 3
传递给het_arch
函数,其中ddof
表示自由度。
另见
这里有更多资源:
- Engle, R. F. 1982 年,“具有英国通货膨胀方差估计的自回归条件异方差模型”,Econometrica,50(4): 987-1007
使用 GARCH 模型对股票收益的波动性进行建模
在本教程中,我们展示了如何使用 ARCH 模型的扩展——广义自回归条件异方差(GARCH)模型。GARCH 可以看作是应用于时间序列方差的 ARMA 模型——ARCH 模型已经表达了 AR 部分,而 GARCH 则额外加入了移动平均部分。
GARCH 模型的方程可以表示为:
尽管其解释与前述 ARCH 模型非常相似,但不同之处在于最后一个方程,其中可以看到一个额外的组件。参数受到约束,以满足以下条件:,并且
。
在 GARCH 模型中,系数有额外的约束。例如,在 GARCH(1,1)模型中,必须小于 1。否则,模型将不稳定。
GARCH 模型的两个超参数可以描述为:
p: 滞后方差的数量
q: 来自均值过程的滞后残差误差的数量
GARCH(0, q)模型等价于 ARCH(q)模型。
推断 ARCH/GARCH 模型的滞后阶数的一种方法是使用来自预测原始时间序列均值的模型的平方残差。由于残差围绕零中心分布,它们的平方对应于它们的方差。我们可以检查平方残差的 ACF/PACF 图,以识别序列方差自相关的模式(类似于我们在识别 ARMA/ARIMA 模型阶数时所做的)。
一般来说,GARCH 模型继承了 ARCH 模型的优点和缺点,不同之处在于它更好地捕捉到过去冲击的影响。请参见更多内容部分,了解一些弥补原始模型缺点的 GARCH 模型扩展。
在本教程中,我们将 GARCH(1,1)模型应用于与之前教程相同的数据,以清晰地突出两种建模方法之间的差异。
如何做到……
执行以下步骤以在 Python 中估计 GARCH(1,1)模型:
指定 GARCH 模型:
model = arch_model(returns, mean="Zero", vol="GARCH", p=1, q=1)
估计模型并打印摘要:
fitted_model = model.fit(disp="off") print(fitted_model.summary())
运行代码后返回以下摘要:
Zero Mean - GARCH Model Results ==================================================================== Dep. Variable: asset_returns R-squared: 0.000 Mean Model: Zero Mean Adj. R-squared: 0.001 Vol Model: GARCH Log-Likelihood: -3246.71 Distribution: Normal AIC: 6499.42 Method: Maximum BIC: 6515.84 Likelihood No. Observations: 1762 Date: Wed, Jun 08 2022 Df Residuals: 1762 Time: 22:37:27 Df Model: 0 Volatility Model =================================================================== coef std err t P>|t| 95.0% Conf. Int. ------------------------------------------------------------------- omega 0.2864 0.186 1.539 0.124 [-7.844e-02, 0.651] alpha[1] 0.1697 9.007e-02 1.884 5.962e-02 [-6.879e-03, 0.346] beta[1] 0.7346 0.128 5.757 8.538e-09 [ 0.485, 0.985] ===================================================================
根据 市场风险分析,在稳定市场中,参数的常见取值范围为
和
。然而,我们应该记住,尽管这些范围不太可能严格适用,但它们已经为我们提供了一些我们应期望的值的线索。
我们可以看到,与 ARCH 模型相比,log-likelihood 增加了,这意味着 GARCH 模型更好地拟合了数据。然而,我们在得出这样的结论时应谨慎。每当我们增加更多预测变量时(如我们在 GARCH 中所做的),log-likelihood 很可能会增加。如果预测变量的数量发生变化,我们应进行似然比检验,以比较两个嵌套回归模型的拟合优度标准。
绘制残差和条件波动率:
fitted_model.plot(annualize="D")
在下图中,我们可以观察到将额外的成分(滞后的条件波动率)纳入模型规范后的效果:
图 9.3:拟合的 GARCH 模型的标准化残差和年化条件波动率
使用 ARCH 时,条件波动率序列会出现许多峰值,然后立即回落到较低水平。在 GARCH 的情况下,由于模型还包括滞后的条件波动率,因此它需要更多的时间才能回到峰值前观察到的水平。
它是如何工作的...
在这个案例中,我们使用了与之前相同的数据来比较 ARCH 和 GARCH 模型的结果。有关下载数据的更多信息,请参阅 建模股票收益波动率(ARCH 模型) 中的 步骤 1 至 4。
由于 arch
库的便利性,调整先前用于拟合 ARCH 模型的代码变得非常容易。为了估计 GARCH 模型,我们必须指定想要使用的波动率模型类型,并设置一个额外的参数:q=1
。
为了比较,我们将均值过程保持为零均值过程。
还有更多...
在本章中,我们已经使用了两个模型来解释并可能预测时间序列的条件波动率。然而,GARCH 模型有许多扩展版本,以及不同的配置,供我们实验以找到最适合的数据模型。
在 GARCH 框架中,除了超参数(例如在普通 GARCH 模型中 p 和 q),我们还可以修改接下来描述的模型。
条件均值模型
如前所述,我们将 GARCH 类模型应用于在拟合其他模型之后获得的残差。均值模型的一些流行选择包括:
零均值
常数均值
ARIMA 模型的任何变体(包括可能的季节性调整,以及外部回归变量)——文献中一些流行的选择包括 ARMA 甚至 AR 模型
回归模型
在建模条件均值时,我们需要注意一件事。例如,我们可能先对时间序列拟合 ARMA 模型,然后对第一个模型的残差拟合 GARCH 模型。然而,这并不是首选方法。因为通常情况下,ARMA 估计值是不一致的(或者在仅有 AR 项而没有 MA 项的情况下,即使一致也不高效),这也会影响后续的 GARCH 估计。由于第一个模型(ARMA/ARIMA)假设条件同方差性,而我们在第二步中用 GARCH 模型显式建模条件异方差性,因此不一致性会出现。正因为如此,首选方法是同时估计两个模型,例如使用arch
库(或者 R 的rugarch
包)。
条件波动性模型
GARCH 框架有许多扩展。以下是一些常见的模型:
GJR-GARCH:GARCH 模型的一种变体,考虑了回报的非对称性(负回报对波动性的影响通常比正回报更强)
EGARCH:指数 GARCH
TGARCH:阈值 GARCH
FIGARCH:分数积分 GARCH,用于非平稳数据
GARCH-MIDAS:在这一类模型中,波动性被分解为短期 GARCH 成分和由额外解释变量驱动的长期成分
多元 GARCH 模型,例如 CCC-/DCC-GARCH
前三种模型使用略微不同的方法将非对称性引入条件波动性规范。这与负面冲击对波动性的影响通常强于正面冲击的观点一致。
误差分布
在资产回报的样式化事实研究一节中,我们看到回报的分布并非正态分布(偏斜,且具有重尾)。因此,除了高斯分布外,其他分布可能更适合 GARCH 模型中的误差。
一些可能的选择包括:
学生 t 分布
偏斜 t 分布(Hansen, 1994 年)
广义误差分布(GED)
偏斜广义误差分布(SGED)
arch
库不仅提供了上述大部分模型和分布,它还允许使用自定义的波动性模型/误差分布(只要它们符合预定义格式)。有关更多信息,请参考出色的文档。
参见
额外资源可在此处获得:
Alexander, C. 2008 年。市场风险分析,实用金融计量经济学(第 2 卷)。约翰·威利父子公司。
Bollerslev, T., 1986 年。“广义自回归条件异方差模型。计量经济学杂志,31(3):307–327。:
doi.org/10.1016/0304-4076(86)90063-1
Glosten, L. R., Jagannathan, R., and Runkle, D. E., 1993. “股票的名义超额收益与波动率的关系,”金融学报,48 (5): 1779–1801: https://doi.org/10.1111/j.1540-6261.1993.tb05128.x
Hansen, B. E., 1994. “自回归条件密度估计,”国际经济评论,35(3): 705–730: https://doi.org/10.2307/2527081
arch
库的文档—arch.readthedocs.io/en/latest/index.html
使用 GARCH 模型预测波动率
在之前的案例中,我们已经看到了如何将 ARCH/GARCH 模型拟合到收益序列。然而,使用 ARCH 类模型最有趣/相关的情况是预测波动率的未来值。
使用 GARCH 类模型预测波动率有三种方法:
分析法——由于 ARCH 类模型的固有结构,分析预测总是可用的,适用于一步前的预测。多步分析预测可以通过前向递归获得;然而,这仅适用于残差平方线性模型(如 GARCH 或异质 ARCH 模型)。
模拟法——基于模拟的预测使用 ARCH 类模型的结构,通过假设的残差分布向前模拟可能的波动率路径。换句话说,它们使用随机数生成器(假设特定分布)来抽取标准化残差。该方法创建了x条可能的波动率路径,然后将其平均作为最终预测。基于模拟的预测总是适用于任何时间范围。随着模拟次数向无穷增加,基于模拟的预测将趋近于分析预测。
自助法(也称为过滤历史模拟法)——这些预测与基于模拟的预测非常相似,不同之处在于它们使用实际输入数据和估计参数生成(准确来说,是有放回抽样)标准化的残差。该方法在生成预测之前,所需的样本内数据量极少。
由于 ARCH 类模型的规格,第一次的样本外预测将始终是固定的,无论我们使用哪种方法。
在本案例中,我们将 2015 至 2020 年微软的股票收益拟合到一个 GARCH(1,1)模型,并使用学生 t 分布的残差。然后,我们为 2021 年的每一天创建了 3 步前的预测。
如何操作...
执行以下步骤以使用 GARCH 模型创建 3 步前的波动率预测:
导入库:
import pandas as pd import yfinance as yf from datetime import datetime from arch import arch_model
从 Yahoo Finance 下载数据并计算简单收益:
df = yf.download("MSFT", start="2015-01-01", end="2021-12-31", adjusted=True) returns = 100 * df["Adj Close"].pct_change().dropna() returns.name = "asset_returns"
指定 GARCH 模型:
model = arch_model(returns, mean="Zero", vol="GARCH", dist="t", p=1, q=1)
定义分割日期并拟合模型:
SPLIT_DATE = datetime(2021, 1, 1) fitted_model = model.fit(last_obs=SPLIT_DATE, disp="off")
创建并检查分析预测:
forecasts_analytical = fitted_model.forecast(horizon=3, start=SPLIT_DATE, reindex=False) forecasts_analytical.variance.plot( title="Analytical forecasts for different horizons" )
运行代码片段生成以下图表:
图 9.4:1、2 和 3 步的分析预测
使用下面的代码片段,我们可以检查生成的预测结果。
forecasts_analytical.variance
图 9.5:展示 1、2、3 期分析预测的表格
每一列包含在索引所指示的日期生成的h步预测。当预测生成时,
Date
列中的日期对应于用于生成预测的最后一个数据点。例如,日期为 2021-01-08 的列包含了 1 月 9 日、10 日和 11 日的预测。这些预测是使用直到 1 月 8 日(包括这一天)的数据生成的。创建并检查模拟预测:
forecasts_simulation = fitted_model.forecast( horizon=3, start=SPLIT_DATE, method="simulation", reindex=False ) forecasts_simulation.variance.plot( title="Simulation forecasts for different horizons" )
运行代码片段后生成以下图表:
图 9.6:基于模拟的 1、2、3 期预测
创建并检查自助法预测:
forecasts_bootstrap = fitted_model.forecast(horizon=3, start=SPLIT_DATE, method="bootstrap", reindex=False) forecasts_bootstrap.variance.plot( title="Bootstrap forecasts for different horizons" )
运行代码片段后生成以下图表:
图 9.7:基于自助法的 1、2、3 期预测
检查这三个图表后得出的结论是,三种不同方法得到的波动率预测形态非常相似。
它是如何工作的...
在前两个步骤中,我们导入了所需的库,并下载了 2015 年至 2021 年间微软的股票价格。我们计算了简单收益率,并将值乘以 100,以避免在优化过程中出现潜在的收敛问题。
在步骤 3中,我们指定了我们的 GARCH 模型,即零均值的 GARCH(1, 1),且残差服从学生 t 分布。
在步骤 4中,我们定义了一个日期(datetime
对象),用于拆分训练集和测试集。然后,我们使用fit
方法拟合模型。这一次,我们指定了last_obs
参数,表示训练集的结束时间。我们传入了datetime(2021, 1, 1)
,这意味着实际用于训练的最后一个观测值是 2020 年 12 月的最后一天。
在步骤 5中,我们使用拟合好的 GARCH 模型的forecast
方法创建了分析预测。我们指定了预测的期数和起始日期(即与我们在拟合模型时提供的last_obs
相同)。然后,我们为每个期数绘制了预测图。
通常,使用forecast
方法会返回一个ARCHModelForecast
对象,其中包含四个我们可能需要的主要属性:
mean
—条件均值的预测variance
—过程的条件方差预测residual_variance
—残差方差的预测。当模型具有均值动态时,例如 AR 过程,residual_variance
的值将与variance
中的值不同(对于期数大于 1 的情况)。simulations
—一个对象,包含了生成预测所用的各个单独的模拟结果(仅适用于模拟和自助法方法)。
在步骤 6和步骤 7中,我们使用模拟和自助法生成了类比的 3 步 ahead 预测。我们只需向forecast
方法中添加可选的method
参数,指示我们希望使用哪种预测方法。默认情况下,这些方法使用 1,000 次模拟来生成预测,但我们可以根据需要更改此数字。
还有更多内容...
我们可以很容易地直观比较使用不同预测方法得到的预测差异。在这种情况下,我们希望比较 2020 年内分析法和自助法的预测。我们选择 2020 年作为训练样本中使用的最后一年。
执行以下步骤以比较 2020 年的 10 步 ahead 波动率预测:
导入所需的库:
import numpy as np
使用分析法和自助法估算 2020 年的 10 步 ahead 波动率预测:
FCST_HORIZON = 10 vol_analytic = ( fitted_model.forecast(horizon=FCST_HORIZON, start=datetime(2020, 1, 1), reindex=False) .residual_variance["2020"] .apply(np.sqrt) ) vol_bootstrap = ( fitted_model.forecast(horizon=FCST_HORIZON, start=datetime(2020, 1, 1), method="bootstrap", reindex=False) .residual_variance["2020"] .apply(np.sqrt) )
在创建预测时,我们改变了预测的时间范围和开始日期。我们从拟合模型中恢复了残差方差,过滤出 2020 年内的预测,然后取平方根以将方差转换为波动率。
获取 2020 年的条件波动率:
vol = fitted_model.conditional_volatility["2020"]
创建刺猬图:
ax = vol.plot( title="Comparison of analytical vs bootstrap volatility forecasts", alpha=0.5 ) ind = vol.index for i in range(0, 240, 10): vol_a = vol_analytic.iloc[i] vol_b = vol_bootstrap.iloc[i] start_loc = ind.get_loc(vol_a.name) new_ind = ind[(start_loc+1):(start_loc+FCST_HORIZON+1)] vol_a.index = new_ind vol_b.index = new_ind ax.plot(vol_a, color="r") ax.plot(vol_b, color="g") labels = ["Volatility", "Analytical Forecast", "Bootstrap Forecast"] legend = ax.legend(labels)
运行该代码片段生成了以下图表:
图 9.8:分析方法与基于自助法的波动率预测比较
刺猬图是一种有用的可视化方法,用于展示两种预测方法在较长时间段内的差异。在本例中,我们每 10 天绘制一次 10 步 ahead 预测。
有趣的是,2020 年 3 月发生了波动率的峰值。我们可以看到,在接近峰值时,GARCH 模型预测未来几天波动率将下降。为了更好地理解这个预测是如何生成的,我们可以参考基础数据。通过检查包含观察到的波动率和预测的 DataFrame,我们可以得出结论:峰值发生在 3 月 17 日,而绘制的预测是使用直到 3 月 16 日的数据生成的。
在逐个检查单一波动率模型时,使用拟合后的arch_model
的hedgehog_plot
方法来创建类似的图表可能会更容易。
使用 CCC-GARCH 模型进行多变量波动率预测
在本章中,我们已经考虑了多个单变量条件波动率模型。这就是为什么在这个方法中,我们转向了多变量设置。作为起点,我们考虑了 Bollerslev 的常数条件相关 GARCH(CCC-GARCH)模型。其背后的思想相当简单。该模型由N个单变量 GARCH 模型组成,通过一个常数条件相关矩阵R相互关联。
和以前一样,我们从模型的规格开始:
在第一个方程中,我们表示回报序列。与先前教程中展示的表示方式的主要区别在于,这次我们考虑的是多变量回报。这就是为什么r[t]实际上是一个回报向量r[t] = (r[1t], …, r[nt])。均值和误差项的表示方式类似。为了突出这一点,当考虑向量或矩阵时,我们使用粗体字。
第二个方程显示,误差项来自一个均值为零的多变量正态分布,且具有条件协方差矩阵!(大小为 N x N)。
条件协方差矩阵的元素定义为:
对角线:
非对角线:
第三个方程展示了条件协方差矩阵的分解。D[t]表示包含对角线上的条件标准差的矩阵,R是相关性矩阵。
该模型的关键思想如下:
在本教程中,我们估计了三家美国科技公司股票回报序列的 CCC-GARCH 模型。有关 CCC-GARCH 模型估计的更多细节,请参阅*它是如何工作的...*部分。
如何操作...
执行以下步骤,在 Python 中估计 CCC-GARCH 模型:
导入库:
import pandas as pd import numpy as np import yfinance as yf from arch import arch_model
指定风险资产和时间范围:
RISKY_ASSETS = ["GOOG", "MSFT", "AAPL"] START_DATE = "2015-01-01" END_DATE = "2021-12-31"
从 Yahoo Finance 下载数据:
df = yf.download(RISKY_ASSETS, start=START_DATE, end=END_DATE, adjusted=True)
计算每日回报:
returns = 100 * df["Adj Close"].pct_change().dropna() returns.plot( subplots=True, title=f"Stock returns: {START_DATE} - {END_DATE}" )
运行代码片段生成如下图:
图 9.9:苹果、谷歌和微软的简单回报
定义用于存储对象的列表:
coeffs = [] cond_vol = [] std_resids = [] models = []
估计单变量 GARCH 模型:
for asset in returns.columns: model = arch_model(returns[asset], mean="Constant", vol="GARCH", p=1, q=1) model = model.fit(update_freq=0, disp="off"); coeffs.append(model.params) cond_vol.append(model.conditional_volatility) std_resids.append(model.std_resid) models.append(model)
将结果存储在 DataFrames 中:
coeffs_df = pd.DataFrame(coeffs, index=returns.columns) cond_vol_df = ( pd.DataFrame(cond_vol) .transpose() .set_axis(returns.columns, axis="columns") ) std_resids_df = ( pd.DataFrame(std_resids) .transpose() .set_axis(returns.columns axis="columns") )
下表包含每个回报序列的估计系数:
图 9.10:估计的单变量 GARCH 模型系数
计算常数条件相关性矩阵(R):
R = ( std_resids_df .transpose() .dot(std_resids_df) .div(len(std_resids_df)) )
计算条件协方差矩阵的单步预测:
# define objects diag = [] D = np.zeros((len(RISKY_ASSETS), len(RISKY_ASSETS))) # populate the list with conditional variances for model in models: diag.append(model.forecast(horizon=1).variance.iloc[-1, 0]) # take the square root to obtain volatility from variance diag = np.sqrt(diag) # fill the diagonal of D with values from diag np.fill_diagonal(D, diag) # calculate the conditional covariance matrix H = np.matmul(np.matmul(D, R.values), D)
计算的单步预测如下所示:
array([[2.39962391, 1.00627878, 1.19839517],
[1.00627878, 1.51608369, 1.12048865],
[1.19839517, 1.12048865, 1.87399738]])
我们可以将此矩阵与使用更复杂的 DCC-GARCH 模型得到的矩阵进行比较,后者将在下一个教程中介绍。
它是如何工作的...
在步骤 2和步骤 3中,我们下载了谷歌、微软和苹果的每日股价。然后,我们计算了简单回报,并将其乘以 100,以避免遇到收敛错误。
在步骤 5中,我们定义了空列表,用于存储后续阶段所需的元素:GARCH 系数、条件波动性、标准化残差以及模型本身(用于预测)。
在步骤 6中,我们遍历了包含股票回报率的 DataFrame 列,并为每个序列拟合了单变量 GARCH 模型。我们将结果存储在预定义的列表中。然后,我们对数据进行了处理,以便将残差等对象存储在 DataFrame 中,从而方便后续操作。
在步骤 8中,我们计算了常数条件相关性矩阵 (R) 作为 z[t] 的无条件相关性矩阵:
这里,z[t] 表示来自单变量 GARCH 模型的时间 t 标准化残差。
在最后一步,我们获得了单步预测的条件协方差矩阵 H[t+1]。为此,我们进行了以下操作:
我们使用
np.zeros
创建了一个全零矩阵 D[t+1]。我们将单步预测的条件方差(来自单变量 GARCH 模型)存储在一个名为
diag
的列表中。使用
np.fill_diagonal
,我们将名为diag
的列表中的元素放置到矩阵 D[t+1] 的对角线上。根据引言中的方程 3,我们使用矩阵乘法(
np.matmul
)获得了单步预测。
另见
额外资源请见此处:
- Bollerslev, T.1990. “建模短期名义汇率的相干性:一种多元广义 ARCH 方法”,经济学与统计学评论,72(3):498–505:https://doi.org/10.2307/2109358
使用 DCC-GARCH 预测条件协方差矩阵
在这个食谱中,我们介绍了 CCC-GARCH 模型的扩展:Engle 的 动态条件相关性 GARCH(DCC-GARCH)模型。两者的主要区别在于,后者的条件相关性矩阵随着时间变化——我们使用 R[t] 而不是 R。
在估计过程中存在一些细节差异,但大体框架与 CCC-GARCH 模型相似:
估计单变量 GARCH 模型的条件波动率
估计 DCC 模型的条件相关性
在估计 DCC 模型的第二步中,我们使用了一个新的矩阵 Q[t],代表代理相关过程。
第一个方程描述了条件相关性矩阵 R[t] 与代理过程 Q[t] 之间的关系。第二个方程表示代理过程的动态变化。最后一个方程显示了 的定义,它被定义为来自单变量 GARCH 模型的标准化残差的无条件相关性矩阵。
这种 DCC 模型的表示使用了一种叫做 相关性目标的方法。它意味着我们实际上将需要估计的参数数量减少到两个: 和
。这类似于在单变量 GARCH 模型中的波动率目标,后续会在 更多内容... 部分进行详细描述。
截至目前,没有可以用来估计 DCC-GARCH 模型的 Python 库。一种解决方案是从头开始编写这样的库。另一种更节省时间的解决方案是使用成熟的 R 包来完成这一任务。这就是为什么在本配方中,我们还介绍了如何在一个 Jupyter 笔记本中高效地让 Python 和 R 一起工作(这也可以在普通的.py
脚本中完成)。rpy2
库是两种语言之间的接口。它不仅允许我们在同一个笔记本中运行 R 和 Python,还可以在两个环境之间传递对象。
在本配方中,我们使用了与前一个配方相同的数据,以便突出方法和结果的差异。
准备工作
有关如何轻松安装 R 的详细信息,请参考以下资源:
如果您使用conda
作为包管理器,设置过程可以大大简化。如果您仅通过conda install rpy2
命令安装rpy2
,包管理器会自动安装最新版本的 R 及其他一些必要的依赖项。
在执行以下代码之前,请确保运行了前面配方中的代码,以确保数据可用。
如何操作...
执行以下步骤以在 Python 中估计 DCC-GARCH 模型(使用 R):
使用
rpy2
建立 Python 和 R 之间的连接:%load_ext rpy2.ipython
安装
rmgarch
R 包并加载它:%%R install.packages('rmgarch', repos = "http://cran.us.r-project.org") library(rmgarch)
我们只需要安装一次
rmgarch
包。安装后,您可以安全地注释掉以install.packages
开头的那行代码。将数据集导入 R:
%%R -i returns print(head(returns))
使用前面的命令,我们打印 R
data.frame
的前五行:AAPL GOOG MSFT 2015-01-02 00:00:00 -0.951253138 -0.3020489 0.6673615 2015-01-05 00:00:00 -2.817148406 -2.0845731 -0.9195739 2015-01-06 00:00:00 0.009416247 -2.3177049 -1.4677364 2015-01-07 00:00:00 1.402220689 -0.1713264 1.2705295 2015-01-08 00:00:00 3.842214047 0.3153082 2.9418228
定义模型规范:
%%R # define GARCH(1,1) model univariate_spec <- ugarchspec( mean.model = list(armaOrder = c(0,0)), variance.model = list(garchOrder = c(1,1), model = "sGARCH"), distribution.model = "norm" ) # define DCC(1,1) model n <- dim(returns)[2] dcc_spec <- dccspec( uspec = multispec(replicate(n, univariate_spec)), dccOrder = c(1,1), distribution = "mvnorm" )
估计模型:
%%R dcc_fit <- dccfit(dcc_spec, data=returns) dcc_fit
以下表格包含了模型的规范总结、估计的系数以及一些拟合优度标准的选择:
*---------------------------------* * DCC GARCH Fit * *---------------------------------* Distribution : mvnorm Model : DCC(1,1) No. Parameters : 17 [VAR GARCH DCC UncQ] : [0+12+2+3] No. Series : 3 No. Obs. : 1762 Log-Likelihood : -8818.787 Av.Log-Likelihood : -5 Optimal Parameters -------------------------------------------------------------------- Estimate Std. Error t value Pr(>|t|) [AAPL].mu 0.189285 0.037040 5.1102 0.000000 [AAPL].omega 0.176370 0.051204 3.4445 0.000572 [AAPL].alpha1 0.134726 0.026084 5.1651 0.000000 [AAPL].beta1 0.811601 0.029763 27.2691 0.000000 [GOOG].mu 0.125177 0.040152 3.1176 0.001823 [GOOG].omega 0.305000 0.163809 1.8619 0.062614 [GOOG].alpha1 0.183387 0.089046 2.0595 0.039449 [GOOG].beta1 0.715766 0.112531 6.3606 0.000000 [MSFT].mu 0.149371 0.030686 4.8677 0.000001 [MSFT].omega 0.269463 0.086732 3.1068 0.001891 [MSFT].alpha1 0.214566 0.052722 4.0698 0.000047 [MSFT].beta1 0.698830 0.055597 12.5695 0.000000 [Joint]dcca1 0.060145 0.016934 3.5518 0.000383 [Joint]dccb1 0.793072 0.059999 13.2180 0.000000 Information Criteria --------------------- Akaike 10.029 Bayes 10.082 Shibata 10.029 Hannan-Quinn 10.049
计算五步 ahead 的预测值:
forecasts <- dccforecast(dcc_fit, n.ahead = 5)
获取预测结果:
%%R # conditional covariance matrix forecasts@mforecast$H # conditional correlation matrix forecasts@mforecast$R # proxy correlation process forecasts@mforecast$Q # conditional mean forecasts forecasts@mforecast$mu
下图显示了条件协方差矩阵的五步 ahead 预测值:
[[1]]
, , 1
[,1] [,2] [,3]
[1,] 2.397337 1.086898 1.337702
[2,] 1.086898 1.515434 1.145010
[3,] 1.337702 1.145010 1.874023
, , 2
[,1] [,2] [,3]
[1,] 2.445035 1.138809 1.367728
[2,] 1.138809 1.667607 1.231062
[3,] 1.367728 1.231062 1.981190
, , 3
[,1] [,2] [,3]
[1,] 2.490173 1.184169 1.395189
[2,] 1.184169 1.804434 1.308254
[3,] 1.395189 1.308254 2.079076
, , 4
[,1] [,2] [,3]
[1,] 2.532888 1.224255 1.420526
[2,] 1.224255 1.927462 1.377669
[3,] 1.420526 1.377669 2.168484
, , 5
[,1] [,2] [,3]
[1,] 2.573311 1.259997 1.444060
[2,] 1.259997 2.038083 1.440206
[3,] 1.444060 1.440206 2.250150
我们现在可以将这个预测(第一步)与使用更简单的 CCC-GARCH 模型获得的预测进行比较。对于 CCC-GARCH 和 DCC-GARCH 模型,单步 ahead 的条件协方差预测值非常相似。
它是如何工作的...
在本配方中,我们使用了与前一个配方相同的数据,以便比较 CCC-GARCH 和 DCC-GARCH 模型的结果。有关如何下载数据的更多信息,请参考前一个配方中的步骤 1至4。
为了同时使用 Python 和 R,我们使用了 rpy2
库。在本食谱中,我们展示了如何将该库与 Jupyter Notebook 结合使用。有关如何在 .py
脚本中使用该库的更多详情,请参阅官方文档。此外,我们不会深入讨论 R 代码的细节,因为这超出了本书的范围。
在第 1 步中,除了加载任何库,我们还必须使用以下魔法命令:%load_ext rpy2.ipython
。它使我们能够通过在 Notebook 单元格前添加 %%R
来运行 R 代码。因此,请假设本章中的任何代码块都是单独的 Notebook 单元格(更多信息,请参见随附 GitHub 仓库中的 Jupyter Notebook)。
在第 2 步中,我们必须安装所需的 R 依赖项。为此,我们使用了 install.packages
函数,并指定了我们想要使用的仓库。
在第 3 步中,我们将 pandas
DataFrame 移动到 R 环境中。为此,我们在使用 %%R
魔法命令时传递了额外的代码 -i returns
。我们本可以在随后的任何步骤中导入数据。
当你想将 Python 对象移到 R 中,进行一些操作/建模,然后将最终结果移回 Python 时,你可以使用以下语法:%%R -i input_object -o output_object
。
在第 4 步中,我们定义了 DCC-GARCH 模型的规格。首先,我们使用 ugarchspec
定义了单变量 GARCH 规格(用于条件波动率估计)。该函数来自一个名为 rugarch
的包,这是单变量 GARCH 建模的框架。通过不指定 ARMA 参数,我们选择了一个常数均值模型。对于波动率,我们使用了 GARCH(1,1) 模型,且创新项服从正态分布。其次,我们还指定了 DCC 模型。为此,我们:
为每个收益系列复制了单变量规格——在此案例中,共有三个。
指定了 DCC 模型的阶数——在此案例中为 DCC(1,1)。
指定了多元分布——在此案例中为多元正态分布。
我们可以通过调用 dcc_spec
对象来查看规格的摘要。
在第 5 步中,我们通过调用 dccfit
函数并将规格和数据作为参数来估计模型。之后,我们使用 dccforecast
函数获得了五步预测,这个函数返回了嵌套对象,如下所示:
H
:条件协方差矩阵R
:条件相关矩阵Q
:相关矩阵的代理过程mu
:条件均值
它们每个都包含了五步预测,存储在列表中。
还有更多内容……
在这一部分,我们还希望讨论一些估计 GARCH 模型的更多细节。
估计详情
在估计 DCC-GARCH 模型的第一步中,我们可以额外使用一种称为方差目标的方法。其思想是减少我们在 GARCH 模型中需要估计的参数数量。
为此,我们可以稍微修改 GARCH 方程。原始方程如下所示:
无条件波动率定义为:
我们现在可以将其代入 GARCH 方程,得到以下结果:
在最后一步,我们用收益率的样本方差替换了无条件波动率:
通过这样做,我们为每个 GARCH 方程减少了一个参数需要估计。此外,模型所隐含的无条件方差保证等于无条件样本方差。为了在实际中使用方差目标化,我们在 ugarchspec
函数调用中添加了一个额外的参数:ugarchspec(..., variance.targeting = TRUE)
。
单变量和多变量 GARCH 模型
同时值得一提的是,rugarch
和 rmgarch
可以很好地配合使用,因为它们都是由同一位作者开发的,并作为一个统一的框架创建,用于在 R 中估计 GARCH 模型。在估计 DCC-GARCH 模型的第一步中,我们已经在使用 ugarchspec
函数时积累了一些经验。关于这个包,还有更多可以探索的内容。
并行化多变量 GARCH 模型的估计
最后,DCC-GARCH 模型的估计过程可以轻松地并行化,借助 parallel
R 包。
为了通过并行化可能加速计算,我们重用了该配方中的大部分代码,并添加了几行额外的代码。首先,我们必须使用 parallel
包中的 makePSOCKcluster
设置一个集群,并指示我们希望使用三个核心。然后,我们使用 multifit
定义并行化的规格。最后,我们拟合了 DCC-GARCH 模型。与之前使用的代码相比,这里的区别在于我们额外传递了 fit
和 cluster
参数到函数调用中。当我们完成估计时,我们停止集群。下面是完整的代码片段:
%%R
# parallelized DCC-GARCH(1,1)
library("parallel")
# set up the cluster
cl <- makePSOCKcluster(3)
# define parallelizable specification
parallel_fit <- multifit(multispec(replicate(n, univariate_spec)),
returns,
cluster = cl)
# fit the DCC-GARCH model
dcc_fit <- dccfit(dcc_spec,
data = returns,
fit.control = list(eval.se = TRUE),
fit = parallel_fit,
cluster = cl)
# stop the cluster
stopCluster(cl)
使用上述代码,我们可以显著加速 DCC-GARCH 模型的估计。当处理大量数据时,性能提升尤为明显。此外,结合 parallel
包和 multifit
的方法,也可以用于加速 rugarch
和 rmgarch
包中各种 GARCH 和 ARIMA 模型的计算。
参见
其他资源:
Engle, R.F., 2002. “动态条件相关性:一类简单的多变量广义自回归条件异方差模型,”《商业与经济统计学》, 20(3): 339–350:
doi.org/10.1198/073500102288618487
Ghalanos, A. (2019).
rmgarch
模型:背景和属性。 (版本 1.3–0):cran.r-project.org/web/packages/rmgarch/vignettes/The_rmgarch_models.pdf
rpy2
的文档:rpy2.github.io/
摘要
波动性建模和预测近年来受到了广泛关注,主要因为它们在金融市场中的重要性。本章介绍了 GARCH 模型(包括单变量和多变量模型)在波动性预测中的实际应用。通过了解如何使用 GARCH 类模型进行波动性建模,我们可以用更准确的波动性预测来替代许多实际应用中的简单估计,例如风险管理、波动性交易和衍生品估值。
我们重点介绍了 GARCH 模型,因为它能够捕捉波动性聚集现象。然而,波动性建模还有其他方法。例如,状态切换模型假设数据中存在某些重复的模式(状态)。因此,我们应该能够通过使用基于过去观察的参数估计来预测未来的状态。
第十章:金融中的蒙特卡罗模拟
蒙特卡罗模拟是一类计算算法,通过反复随机抽样解决任何具有概率解释的问题。在金融领域,蒙特卡罗方法之所以流行,是因为它们可以准确估计积分。蒙特卡罗模拟的主要思想是生成大量的样本路径(可能的情景/结果),通常是在给定时间段内进行。然后将时间区间分成若干个时间步骤,这一过程被称为离散化。其目标是近似描述金融工具定价所发生的连续时间。
所有这些模拟的样本路径结果可以用来计算一些指标,例如事件发生的频率、在最后一步时工具的平均值等。历史上,蒙特卡罗方法的主要问题是它需要大量的计算能力来计算所有考虑的情景。如今,这不再是大问题,因为我们可以在台式电脑或笔记本电脑上运行相当先进的模拟,如果计算能力不足,我们还可以使用云计算及其更强大的处理器。
在本章结束时,我们将看到如何在各种场景和任务中使用蒙特卡罗方法。在其中一些任务中,我们将从头开始创建模拟,而在其他任务中,我们将使用现代 Python 库来简化过程。由于该方法的灵活性,蒙特卡罗是计算金融学中最重要的技术之一。它可以适应各种问题,比如定价没有封闭解的衍生品(如美式期权/奇异期权)、债券估值(例如,零息债券)、估算投资组合的不确定性(例如,通过计算风险价值和预期亏损)、以及在风险管理中进行压力测试。本章中,我们将向你展示如何解决其中一些问题。
在本章中,我们将覆盖以下内容:
使用几何布朗运动模拟股票价格动态
使用模拟定价欧洲期权
使用最小二乘蒙特卡罗定价美式期权
使用 QuantLib 定价美式期权
定价障碍期权
使用蒙特卡罗估算风险价值
使用几何布朗运动模拟股票价格动态
模拟股票价格在许多衍生品定价中起着至关重要的作用,尤其是期权。由于价格波动的随机性,这些模拟依赖于随机微分方程(SDEs)。当一个随机过程满足以下 SDE 时,它被认为遵循几何布朗运动(GBM):
这里我们有以下内容:
S[t]—股票价格
—漂移系数,即给定时间段内的平均回报或瞬时预期回报
—扩散系数,即漂移中的波动性有多大
W[t] —布朗运动
d—表示在所考虑的时间增量内变量的变化,而 dt 是时间的变化
我们不会过多探讨布朗运动的性质,因为这超出了本书的范围。简而言之,布朗增量是通过标准正态随机变量 与时间增量的平方根的乘积来计算的。
另一种说法是,布朗增量来自于 ,其中 t 是时间增量。我们通过对布朗增量求累积和来获得布朗路径。
上述 SDE 是少数几个具有封闭解的方程之一:
其中 S[0] = S(0) 是过程的初始值,在本例中是股票的初始价格。前述方程展示了时间 t 的股价与初始股价之间的关系。
对于模拟,我们可以使用以下递归公式:
其中 Z[i] 是标准正态随机变量,i = 0, 1, …, T-1 是时间索引。之所以可以这样指定,是因为 W 的增量是独立的且服从正态分布。欲了解公式的来源,请参考欧拉离散化方法。
GBM 是一种不考虑均值回归和时间相关波动性的过程。因此,它通常用于股票,而不用于债券价格,后者往往显示长期回归面值的特性。
在本食谱中,我们使用蒙特卡罗方法和 GBM 来模拟 IBM 未来一个月的股价——使用 2021 年的数据,我们将模拟 2022 年 1 月的可能路径。
如何实现……
执行以下步骤来模拟 IBM 未来一个月的股价:
导入库:
import numpy as np import pandas as pd import yfinance as yf
从雅虎财经下载 IBM 的股价:
df = yf.download("IBM", start="2021-01-01", end="2022-01-31", adjusted=True)
计算并绘制每日回报:
returns = df["Adj Close"].pct_change().dropna() returns.plot(title="IBM's returns")
运行该代码片段会生成以下图表:
图 10.1:IBM 的简单回报
将数据分为训练集和测试集:
train = returns["2021"] test = returns["2022"]
指定模拟的参数:
T = len(test) N = len(test) S_0 = df.loc[train.index[-1], "Adj Close"] N_SIM = 100 mu = train.mean() sigma = train.std()
定义用于模拟的函数:
def simulate_gbm(s_0, mu, sigma, n_sims, T, N, random_seed=42): np.random.seed(random_seed) dt = T/N dW = np.random.normal(scale=np.sqrt(dt), size=(n_sims, N)) W = np.cumsum(dW, axis=1) time_step = np.linspace(dt, T, N) time_steps = np.broadcast_to(time_step, (n_sims, N)) S_t = ( s_0 * np.exp((mu - 0.5 * sigma**2) * time_steps + sigma * W) ) S_t = np.insert(S_t, 0, s_0, axis=1) return S_t
运行模拟并将结果存储在 DataFrame 中:
gbm_simulations = simulate_gbm(S_0, mu, sigma, N_SIM, T, N) sim_df = pd.DataFrame(np.transpose(gbm_simulations), index=train.index[-1:].union(test.index))
创建一个包含每个时间步长的平均值以及对应实际股价的 DataFrame:
res_df = sim_df.mean(axis=1).to_frame() res_df = res_df.join(df["Adj Close"]) res_df.columns = ["simulation_average", "adj_close_price"]
绘制模拟结果:
ax = sim_df.plot( alpha=0.3, legend=False, title="Simulation's results" ) res_df.plot(ax=ax, color = ["red", "blue"])
在图 10.2中,我们观察到预测的股价(每个时间步长的模拟平均值)呈现出轻微的上升趋势。这可以归因于正漂移项
= 0.07%。然而,由于模拟次数非常少,我们应对这一结论持保留态度。
图 10.2:模拟路径及其平均值
请记住,这种可视化只适用于合理数量的样本路径。在实际情况中,我们希望使用远远超过 100 条的样本路径。蒙特卡洛模拟的一般方法是,样本路径越多,结果越准确/可靠。
它是如何工作的...
在步骤 2和步骤 3中,我们下载了 IBM 的股票价格并计算了简单收益率。在接下来的步骤中,我们将数据划分为训练集和测试集。虽然这里没有显式地训练任何模型,但我们使用训练集计算了收益率的平均值和标准差。然后,我们将这些值作为漂移项(mu
)和扩散项(sigma
)系数用于我们的模拟。此外,在步骤 5中,我们定义了以下参数:
T
: 预测时域;在本例中,即测试集中的天数。N
: 预测时域中的时间增量数。对于我们的模拟,我们保持N
=T
。S_0
: 初始价格。在此模拟中,我们使用训练集中的最后一个观测值。N_SIM
: 模拟路径的数量。
蒙特卡洛模拟使用了一种称为离散化的过程。其思想是通过将考虑的时间范围划分为大量的离散区间,来近似金融资产的连续定价。这就是为什么除了考虑预测时域外,我们还需要指定要融入时域的时间增量数。
在步骤 6中,我们定义了运行模拟的函数。为此类问题定义一个函数/类是良好的实践,因为它在后续步骤中也会派上用场。该函数执行以下步骤:
定义时间增量(
dt
)和布朗增量(dW
)。在布朗增量矩阵中(大小:N_SIM
×N
),每一行描述一个样本路径。通过对每行进行累加求和(
np.cumsum
),计算布朗运动路径(W
)。创建一个包含时间步长(
time_steps
)的矩阵。为此,我们创建了一个在区间内均匀分布的值数组(即模拟的时域)。为此,我们使用了np.linspace
函数。之后,我们使用np.broadcast_to
将该数组广播到目标形状。使用闭式公式计算每个时间点的股票价格。
将初始值插入到每行的第一个位置。
没有显式需要广播包含时间步的向量。它本应自动广播以匹配所需的维度(W
的维度)。通过手动处理,我们可以更好地控制操作,从而使代码更易于调试。我们还应该意识到,在像 R 这样的语言中,是没有自动广播的。
在函数定义中,我们可以将漂移项表示为(mu - 0.5 * sigma ** 2) * time_steps
,而扩散项则是sigma * W
。此外,在定义这个函数时,我们采用了向量化的方法。通过这样做,我们避免了编写任何for
循环,因为在大规模模拟的情况下,for
循环效率低下。
为了确保结果可复现,在模拟路径之前使用np.random.seed
。
在步骤 7中,我们进行了模拟并将结果(样本路径)存储在一个 DataFrame 中。在此过程中,我们对数据进行了转置,以便每条路径占据一列,这样更方便使用pandas
DataFrame 的plot
方法。为了获得合适的索引,我们使用了DatetimeIndex
的union
方法,将训练集最后一个观测值的索引和测试集的索引连接起来。
在步骤 8中,我们计算了每个时间点的预测股票价格,方法是对所有模拟结果求平均,并将这些结果存储在 DataFrame 中。然后,我们还将每个日期的实际股票价格合并进去。
在最后一步,我们可视化了模拟得到的样本路径。在可视化模拟路径时,我们选择了alpha=0.3
来使线条变得透明。这样更容易看到表示预测(平均)路径和实际路径的两条线。
还有更多……
有一些统计方法可以使蒙特卡罗模拟的工作更为简便(更高的准确度,更快的计算速度)。其中之一是一个叫做对立变量的方差减少方法。在这种方法中,我们通过在一对随机抽样之间引入负相关性,来减少估计量的方差。具体来说:在创建样本路径时,对于每一个,我们还取对立值,即
。
这种方法的优点包括:
为了生成N条路径,减少(减少一半)需要抽取的标准正态样本数
样本路径方差的减少,同时提高了准确性
我们在改进版的simulate_gbm
函数中实现了这种方法。此外,我们通过将大部分计算合并成一行,使得函数变得更简短。
在我们实现这些变化之前,我们对初始版本的函数进行了计时:
%timeit gbm_simulations = simulate_gbm(S_0, mu, sigma, N_SIM, T, N)
得分如下:
71 µs ± 126 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
新函数定义如下:
def simulate_gbm(s_0, mu, sigma, n_sims, T, N, random_seed=42,
antithetic_var=False):
np.random.seed(random_seed)
# time increment
dt = T/N
# Brownian
if antithetic_var:
dW_ant = np.random.normal(scale = np.sqrt(dt),
size=(int(n_sims/2), N + 1))
dW = np.concatenate((dW_ant, -dW_ant), axis=0)
else:
dW = np.random.normal(scale = np.sqrt(dt),
size=(n_sims, N + 1))
# simulate the evolution of the process
S_t = s_0 * np.exp(np.cumsum((mu - 0.5*sigma**2)*dt + sigma*dW,
axis=1))
S_t[:, 0] = s_0
return S_t
首先,我们在没有对立变量的情况下运行了模拟:
%timeit gbm_simulations = simulate_gbm(S_0, mu, sigma, N_SIM, T, N)
得分如下:
50.3 µs ± 275 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
然后,我们在使用对立变量的情况下运行了模拟:
%timeit gbm_simulations = simulate_gbm(S_0, mu, sigma, N_SIM, T, N, antithetic_var=True)
得分如下:
38.2 µs ± 623 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
我们成功地使得函数运行得更快。如果你对纯性能感兴趣,可以使用 Numba、Cython 或多线程进一步加速这些模拟。
其他可能的方差减少技术包括控制变量和公共随机数。
另请参见
在本例中,我们展示了如何使用几何布朗运动模拟股票价格。然而,还有其他可以使用的随机过程,其中一些包括:
跳跃扩散模型:Merton, R. “当标的股票回报不连续时的期权定价”,Journal of Financial Economics, 3, 3 (1976):125–144
平方根扩散模型:Cox, John, Jonathan Ingersoll 和 Stephen Ross,“利率期限结构理论”,Econometrica, 53, 2 (1985):385–407
随机波动率模型:Heston, S. L., “带有随机波动率的期权闭式解法及其在债券和货币期权中的应用”,The Review of Financial Studies, 6(2):327-343。
使用模拟定价欧洲期权
期权是一种衍生工具,因为其价格与标的证券的价格(例如股票)相关联。购买期权合约赋予了在特定日期前以设定价格(称为行权价)买入或卖出标的资产的权利,但不是义务。期权受欢迎的主要原因是它们可以对冲资产价格朝不利方向波动的风险。
在本示例中,我们将重点介绍一种期权类型,即欧洲期权。欧洲看涨/看跌期权赋予我们在特定到期日(通常表示为T)买入/卖出某项资产的权利(但同样没有义务)。
期权估值有许多可能的方法,例如使用:
解析公式(只有某些类型的期权有此公式)
二项树方法
有限差分
蒙特卡洛模拟
欧洲期权是一个例外,因为它们的估值存在解析公式,这对于更复杂的衍生品(如美式期权或奇异期权)并不适用。
为了使用蒙特卡洛模拟定价期权,我们采用风险中性估值方法,在这种方法下,衍生品的公允价值是其未来支付的期望值。换句话说,我们假设期权溢价以与无风险利率相同的速度增长,并使用无风险利率对其进行贴现到现值。对于每条模拟路径,我们计算期权到期时的支付额,取所有路径的平均值,然后将其贴现到现值。
在本示例中,我们展示了如何编写黑-斯科尔斯模型的闭式解法,并使用蒙特卡洛模拟方法。为了简化,我们使用虚拟的输入数据,但实际数据也可以类比使用。
如何操作...
执行以下步骤,使用解析公式和蒙特卡洛模拟定价欧洲期权:
导入库:
import numpy as np from scipy.stats import norm from chapter_10_utils import simulate_gbm
在本示例中,我们使用在前一个示例中定义的
simulate_gbm
函数。为了方便起见,我们将其存储在一个单独的.py
脚本中,并从中导入。定义期权的估值参数:
S_0 = 100 K = 100 r = 0.05 sigma = 0.50 T = 1 N = 252 dt = T / N N_SIMS = 1_000_000 discount_factor = np.exp(-r * T)
使用解析解法准备估值函数:
def black_scholes_analytical(S_0, K, T, r, sigma, type="call"): d1 = ( np.log(S_0 / K) + (r + 0.5*sigma**2) * T) / (sigma*np.sqrt(T) ) d2 = d1 - sigma * np.sqrt(T) if type == "call": N_d1 = norm.cdf(d1, 0, 1) N_d2 = norm.cdf(d2, 0, 1) val = S_0 * N_d1 - K * np.exp(-r * T) * N_d2 elif type == "put": N_d1 = norm.cdf(-d1, 0, 1) N_d2 = norm.cdf(-d2, 0, 1) val = K * np.exp(-r * T) * N_d2 - S_0 * N_d1 else: raise ValueError("Wrong input for type!") return val
使用指定参数估值看涨期权:
black_scholes_analytical(S_0=S_0, K=K, T=T, r=r, sigma=sigma, type="call")
使用指定参数计算欧洲看涨期权的价格为
21.7926
。使用
simulate_gbm
函数模拟股票路径:gbm_sims = simulate_gbm(s_0=S_0, mu=r, sigma=sigma, n_sims=N_SIMS, T=T, N=N)
计算期权的溢价:
premium = ( discount_factor * np.mean(np.maximum(0, gbm_sims[:, -1] - K)) ) premium
计算出的期权溢价为 21.7562。请记住,我们在 simulate_gbm
函数中使用了固定的随机种子,以便获得可重复的结果。一般来说,在进行模拟时,我们可以预期结果中会有一定的随机性。
在这里,我们可以看到,通过蒙特卡洛模拟计算出的期权溢价与 Black-Scholes 模型的封闭解计算出的期权溢价接近。为了提高模拟的精度,我们可以增加模拟路径的数量(使用 N_SIMS
参数)。
工作原理...
在 第 2 步 中,我们定义了用于本方法的参数:
S_0
:初始股票价格K
:执行价,即在到期时我们可以买入/卖出的价格r
:年化无风险利率sigma
:标的股票波动率(年化)T
:到期时间(以年为单位)N
:模拟的时间增量数量N_SIMS
:模拟的样本路径数量discount_factor
:折扣因子,用于计算未来收益的现值
在 第 3 步 中,我们定义了一个函数,通过使用 Black-Scholes 模型的封闭解来计算期权溢价(适用于非派息股票)。我们在 第 4 步 中使用它来计算蒙特卡洛模拟的基准。
看涨期权和看跌期权的解析解定义如下:
其中 N() 代表标准正态分布的累积分布函数(CDF),T - t 是以年为单位表示的到期时间。公式 1 表示欧式看涨期权的价格公式,而公式 2 则表示欧式看跌期权的价格。非正式地,公式 1 中的两个项可以理解为:
股票的当前价格,按行使期权买入股票的概率加权 (N(d[1]))——换句话说,可能获得的收益
行使期权(执行价)的折扣价格,按行使期权的概率加权 (N(d[2]))——换句话说,就是我们将要支付的金额
在 第 5 步 中,我们使用了之前方法中的 GBM 模拟函数,获得了 1,000,000 条潜在的标的资产路径。为了计算期权溢价,我们只看了每条路径的终值,并按以下方式计算收益:
max(S[T] - K, 0) 用于看涨期权
max(K - S[T], 0) 用于看跌期权
在 第 6 步 中,我们取了收益的平均值,并使用折扣因子将其折算到当前价值。
还有更多...
改进估值函数,通过蒙特卡洛模拟
在前面的步骤中,我们展示了如何重复使用 GBM 模拟来计算欧洲看涨期权溢价。然而,我们可以让计算更快,因为在欧洲期权中,我们只关心终端股票价格,过程中的中间步骤不重要。这就是为什么我们只需要模拟时间T时的价格,并使用这些值来计算预期的收益。我们通过一个例子展示如何做到这一点,例子是使用与之前相同参数的欧洲看跌期权。
我们从使用解析公式计算期权溢价开始:
black_scholes_analytical(S_0=S_0, K=K, T=T, r=r, sigma=sigma, type="put")
计算得出的期权溢价为 16.9155
。
然后,我们定义了修改后的模拟函数,它仅关注模拟路径的终端值:
def european_option_simulation(S_0, K, T, r, sigma, n_sims,
type="call", random_seed=42):
np.random.seed(random_seed)
rv = np.random.normal(0, 1, size=n_sims)
S_T = S_0 * np.exp((r - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * rv)
if type == "call":
payoff = np.maximum(0, S_T - K)
elif type == "put":
payoff = np.maximum(0, K - S_T)
else:
raise ValueError("Wrong input for type!")
premium = np.mean(payoff) * np.exp(-r * T)
return premium
然后,我们运行模拟:
european_option_simulation(S_0, K, T, r, sigma, N_SIMS, type="put")
结果值为 16.9482,接近前一个值。进一步增加模拟路径的数量应该会提高估值的准确性。
使用希腊字母衡量价格敏感度
在讨论期权定价时,还值得一提著名的希腊字母——表示金融衍生品价格对一个基础参数变化的敏感度。这些敏感度通常用希腊字母表示,因此得名。以下是五个最常见的敏感度:
Delta (
):期权理论价值对基础资产价格变化的敏感度。
Vega (
):期权理论价值对基础资产波动率的敏感度。
Theta (
):期权理论价值对期权到期时间变化的敏感度。
Rho (
):期权理论价值对利率变化的敏感度。
Gamma (
):这是一个二阶希腊字母的例子,它表示期权的 delta (
) 对基础资产价格变化的敏感度。
下表展示了如何使用我们已经用来计算期权溢价的值,表示欧洲看涨期权和看跌期权的希腊字母:
What | Calls | Puts | |
---|---|---|---|
delta | ![]() |
![]() |
![]() |
gamma | ![]() |
![]() |
|
vega | ![]() |
![]() |
|
theta | ![]() |
![]() |
![]() |
rho | ![]() |
![]() |
![]() |
N'() 符号表示标准正态分布的概率密度函数(PDF)。如你所见,希腊字母实际上是模型价格(在这种情况下是欧洲看涨期权或看跌期权)相对于模型参数之一的偏导数。我们还应记住,希腊字母因模型不同而有所不同。
使用最小二乘蒙特卡罗法对美式期权定价
在这个方法中,我们学习如何对美式期权进行估值。欧洲期权和美式期权的主要区别在于,美式期权可以在到期日前的任何时间行使——基本上,只要标的资产的价格对期权持有者有利,便可以行使。
这种行为给估值带来了额外的复杂性,并且该问题没有封闭形式的解。在使用蒙特卡罗模拟时,我们不能仅仅看每个样本路径上的终端价值,因为期权的行使可以发生在路径的任何地方。因此,我们需要采用一种更复杂的方法,称为最小二乘蒙特卡罗法(LSMC),该方法由 Longstaff 和 Schwartz(2001)提出。
首先,将时间轴从 [0, T] 离散化为有限个等距区间,提前行使只能发生在这些特定的时间步骤上。实际上,美式期权被近似为百慕大期权。对于任何时间步 t,当即时行使的收益大于继续价值时,会进行提前行使。
这可以通过以下公式表示:
这里,h[t](s) 代表期权的收益(也称为期权的内在价值,按欧洲期权的方式计算),C[t](s) 是期权的继续价值,其定义为:
这里,r 是无风险利率,dt 是时间增量,且 是给定标的价格下的风险中性期望。继续价值基本上是指在某个时间点不行使期权的期望回报。
在使用蒙特卡罗模拟时,我们可以为每条路径 i 和时间 t 定义继续价值 e^(-rdt)V[t+dt,i]。直接使用该值是不可能的,因为这意味着完美的预见性。因此,LSMC 算法使用线性回归来估算期望的继续价值。在该算法中,我们将折现后的未来价值(从保持期权中获得)回归到现货价格(时间 t 的价格)的一组基函数上。最简单的方法是使用 x 次多项式回归。基函数的其他选项包括勒让德多项式、厄米特多项式、切比雪夫多项式、盖根鲍尔多项式或雅可比多项式。
我们将这个算法从后向前迭代(从时间T-1到 0),在最后一步取平均折现值作为期权溢价。欧洲期权的溢价代表了美国期权溢价的下限。两者之间的差异通常被称为提前行使溢价。
如何操作...
执行以下步骤,通过最小二乘蒙特卡罗方法定价美国期权:
导入库:
import numpy as np from chapter_10_utils import (simulate_gbm, black_scholes_analytical, lsmc_american_option)
定义期权的参数:
S_0 = 36 K = 40 r = 0.06 sigma = 0.2 T = 1 # 1 year N = 50 dt = T / N N_SIMS = 10 ** 5 discount_factor = np.exp(-r * dt) OPTION_TYPE = "put" POLY_DEGREE = 5
使用 GBM 模拟股票价格:
gbm_sims = simulate_gbm(s_0=S_0, mu=r, sigma=sigma, n_sims=N_SIMS, T=T, N=N)
计算支付矩阵:
payoff_matrix = np.maximum(K - gbm_sims, np.zeros_like(gbm_sims))
定义价值矩阵并填充最后一列(时间T):
value_matrix = np.zeros_like(payoff_matrix) value_matrix[:, -1] = payoff_matrix[:, -1]
迭代计算给定时间内的继续价值和价值向量:
for t in range(N - 1, 0 , -1): regression = np.polyfit( gbm_sims[:, t], value_matrix[:, t + 1] * discount_factor, POLY_DEGREE ) continuation_value = np.polyval(regression, gbm_sims[:, t]) value_matrix[:, t] = np.where( payoff_matrix[:, t] > continuation_value, payoff_matrix[:, t], value_matrix[:, t + 1] * discount_factor )
计算期权的溢价:
option_premium = np.mean(value_matrix[:, 1] * discount_factor) option_premium
指定的美国看跌期权的溢价为
4.465
。计算具有相同参数的欧洲看跌期权的溢价:
black_scholes_analytical(S_0=S_0, K=K, T=T, r=r, sigma=sigma, type="put")
具有相同参数的欧洲看跌期权价格为
3.84
。作为额外的检查,计算美国和欧洲看涨期权的价格:
european_call_price = black_scholes_analytical( S_0=S_0, K=K, T=T, r=r, sigma=sigma ) american_call_price = lsmc_american_option( S_0=S_0, K=K, T=T, N=N, r=r, sigma=sigma, n_sims=N_SIMS, option_type="call", poly_degree=POLY_DEGREE ) print(f"European call's price: {european_call_price:.3f}") print(f"American call's price: {american_call_price:.3f}")
欧洲看涨期权的价格为2.17
,而美国看涨期权的价格(使用 100,000 次模拟)为2.10
。
它是如何工作的...
在第 2 步中,我们再次定义了所考虑的美国期权的参数。为了比较,我们采用了 Longstaff 和 Schwartz(2001)使用的相同值。在第 3 步中,我们使用之前配方中的simulate_gbm
函数模拟了股票的演变。然后,我们使用与欧洲期权相同的公式计算了看跌期权的支付矩阵。
在第 5 步中,我们准备了一个期权价值的时间矩阵,我们将其定义为与支付矩阵大小相同的零矩阵。我们用支付矩阵的最后一列填充价值矩阵的最后一列,因为在最后一步没有进一步的计算要进行——支付等于欧洲期权。
在第 6 步中,我们从时间T-1到 0 执行了算法的反向部分。在每一步中,我们将预期的继续价值估计为横截面的线性回归。我们使用np.polyfit
将 5 次多项式拟合到数据中。
然后,我们在特定值处评估多项式(使用np.polyval
),这与从线性回归中获得拟合值相同。我们将预期的继续价值与支付进行比较,以判断是否应该行使期权。如果支付值高于继续价值的预期值,我们将其设定为支付值。否则,我们将其设定为折现的下一步值。我们使用np.where
进行此选择。
也可以使用scikit-learn
进行多项式拟合。为此,需要将LinearRegression
与PolynomialFeatures
结合使用。
在算法的第 7 步中,我们通过取折现后的t = 1值向量的平均值来获得期权溢价。
在最后两步中,我们进行了某些有效性检查。首先,我们计算了具有相同参数的欧洲看跌期权的溢价。其次,我们重复了所有步骤,获取了具有相同参数的美式和欧式看涨期权的溢价。为了简化操作,我们将整个 LSMC 算法放入一个函数中,该函数可在本书的 GitHub 仓库中找到。
对于看涨期权,美式和欧式期权的溢价应相等,因为在没有股息的情况下,行使期权永远不是最优选择。我们的结果非常接近,但通过增加模拟样本路径的数量,可以获得更准确的价格。
原则上,Longstaff-Schwartz 算法应该低估美式期权的价值,因为通过基函数近似的延续价值只是一个近似。因此,该算法并不总是能做出正确的行权决策。这意味着期权的价值将低于最优行权的情况。
另见
额外资源可在此处获取:
Longstaff, F. A., & Schwartz, E. S. 2001. “通过模拟估值美式期权:一种简单的最小二乘方法,” 金融研究评论,14(1):113-147
Broadie, M., Glasserman, P., & Jain, G. 1997. “使用随机树方法对美式期权进行估值的替代方法。对美式期权价格的增强蒙特卡洛估计,” 衍生品期刊,5:25-44。
使用 QuantLib 定价美式期权
在上一条食谱中,我们展示了如何手动编码 Longstaff-Schwartz 算法。然而,我们也可以使用已经存在的框架来进行衍生品估值。其中最受欢迎的之一是 QuantLib。它是一个开源的 C++ 库,提供用于金融工具估值的工具。通过使用 简化包装器和接口生成器(SWIG),可以在 Python(以及一些其他编程语言,如 R 或 Julia)中使用 QuantLib。在本条食谱中,我们展示了如何对上一条食谱中定价的相同美式看跌期权进行定价,但该库本身还有许多更有趣的功能值得探索。
准备工作
执行上一条食谱中的步骤 2,以获取我们将使用 QuantLib 估值的美式看跌期权的参数。
操作步骤...
执行以下步骤以使用 QuantLib 估值美式期权:
导入库:
import QuantLib as ql
指定日历和日期计数方式:
calendar = ql.UnitedStates() day_counter = ql.ActualActual()
指定期权的估值日期和到期日期:
valuation_date = ql.Date(1, 1, 2020) expiry_date = ql.Date(1, 1, 2021) ql.Settings.instance().evaluationDate = valuation_date
定义期权类型(看涨/看跌)、行权方式(美式)和支付:
if OPTION_TYPE == "call": option_type_ql = ql.Option.Call elif OPTION_TYPE == "put": option_type_ql = ql.Option.Put exercise = ql.AmericanExercise(valuation_date, expiry_date) payoff = ql.PlainVanillaPayoff(option_type_ql, K)
准备与市场相关的数据:
u = ql.SimpleQuote(S_0) r = ql.SimpleQuote(r) sigma = ql.SimpleQuote(sigma)
指定与市场相关的曲线:
underlying = ql.QuoteHandle(u) volatility = ql.BlackConstantVol(0, ql.TARGET(), ql.QuoteHandle(sigma), day_counter) risk_free_rate = ql.FlatForward(0, ql.TARGET(), ql.QuoteHandle(r), day_counter)
将与市场相关的数据插入到 Black-Scholes 过程:
bs_process = ql.BlackScholesProcess( underlying, ql.YieldTermStructureHandle(risk_free_rate), ql.BlackVolTermStructureHandle(volatility), )
实例化美式期权的蒙特卡洛引擎:
engine = ql.MCAmericanEngine( bs_process, "PseudoRandom", timeSteps=N, polynomOrder=POLY_DEGREE, seedCalibration=42, requiredSamples=N_SIMS )
实例化
option
对象并设置其定价引擎:option = ql.VanillaOption(payoff, exercise) option.setPricingEngine(engine)
计算期权溢价:
option_premium_ql = option.NPV() option_premium_ql
美式看跌期权的价值为 4.457
。
它是如何工作的...
由于我们想将获得的结果与之前食谱中的结果进行比较,因此我们使用了与之前相同的设置。为了简洁起见,我们在此不展示所有代码,但我们应该运行之前食谱中的步骤 2。
在步骤 2中,我们指定了日历和日计数约定。日计数约定决定了各种金融工具(如债券)随时间积累利息的方式。actual/actual
约定意味着我们使用实际经过的天数和实际的一年天数,即 365 天或 366 天。还有许多其他约定,如actual/365 (fixed)
、actual/360
等。
在步骤 3中,我们选择了两个日期——估值日期和到期日期——因为我们关注的是定价一个将在一年后到期的期权。重要的是要将ql.Settings.instance().evaluationDate
设置为所考虑的估值日期,以确保计算正确进行。在这种情况下,日期仅决定时间的流逝,意味着期权将在一年内到期。如果我们使用不同的日期但它们之间的间隔相同,我们会得到相同的结果(由于模拟的随机组件,会有一些误差)。
我们可以通过运行以下代码来检查到期时间(以年为单位):
T = day_counter.yearFraction(valuation_date, expiry_date)
print(f'Time to expiry in years: {T}')
执行代码片段后返回以下内容:
Time to expiry in years: 1.0
接下来,我们定义了期权类型(看涨/看跌)、行使类型(欧式、美式或百慕大式)以及支付方式(普通)。在步骤 5中,我们准备了市场数据。我们将值用引号包围(ql.SimpleQuote
),这样这些值就可以更改,并且这些更改会被正确地注册到工具中。这是计算希腊字母(后续内容…)中的一个重要步骤。
在步骤 6中,我们定义了相关的曲线。简而言之,TARGET
是一个包含哪些天是节假日的日历。
在此步骤中,我们指定了Black-Scholes(BS)过程的三个重要组成部分,它们是:
标的资产的价格
波动率,假设它是恒定的
无风险利率,假设它随时间保持不变
我们将所有这些对象传递给了 Black-Scholes 过程(ql.BlackScholesProcess
),该过程在步骤 7中定义。然后,我们将该过程对象传递给用于定价美式期权的特殊引擎,该引擎使用蒙特卡洛模拟(针对不同类型的期权和定价方法有许多预定义的引擎)。此时,我们提供了所需的模拟次数、离散化的时间步数以及 LSMC 算法中多项式的度/阶数。此外,我们还提供了随机种子(seedCalibration
),以确保结果可复现。
在步骤 9中,我们通过提供之前定义的支付方式和行使方式类型,创建了一个ql.VanillaOption
实例。我们还通过setPricingEngine
方法将定价引擎设置为在步骤 8中定义的引擎。
最后,我们使用NPV
方法得出了期权的价格。
我们可以看到,使用 QuantLib 得到的期权溢价与之前计算的结果非常相似,这进一步验证了我们的结果。这里需要注意的关键点是,针对各种不同衍生品的估值流程大致相似,因此熟悉这一流程非常重要。我们也可以通过替换几个类,将其用于蒙特卡洛模拟来定价欧洲期权。
QuantLib 还允许我们使用方差减少技术,如反向值或控制变量。
还有更多…
现在我们已经完成了前面的步骤,可以计算希腊字母。如前所述,希腊字母表示衍生品价格对某一标的参数(如标的资产价格、到期时间等)变化的敏感度。
当有用于计算希腊字母的解析公式时(例如,当标的 QuantLib 引擎使用解析公式时),我们只需运行代码,例如option.delta()
,即可访问希腊字母。然而,在使用二叉树或模拟等方法估值时,没有解析公式,我们会收到错误提示(RuntimeError: delta not provided
)。这并不意味着无法计算它,而是我们需要采用数值微分法并自行计算。
在这个例子中,我们只提取 delta。因此,相关的双侧公式是:
这里,P(S)是给定标的资产价格S下的工具价格;h是一个非常小的增量。
运行以下代码块来计算 delta:
u_0 = u.value() # original value
h = 0.01
u.setValue(u_0 + h)
P_plus_h = option.NPV()
u.setValue(u_0 - h)
P_minus_h = option.NPV()
u.setValue(u_0) # set back to the original value
delta = (P_plus_h - P_minus_h) / (2 * h)
delta 的最简单解释是,期权的 delta 为-1.36,意味着如果标的股票的价格每股上涨 1 美元,期权每股将下降 1.36 美元;否则,一切保持不变。
定价障碍期权
障碍期权是一种属于外来期权类别的期权。这是因为它们比普通的欧洲期权或美式期权更为复杂。障碍期权是一种路径依赖型期权,因为它们的收益以及价值都取决于标的资产的价格路径。
更准确地说,收益取决于标的资产是否达到或超过了预定的价格阈值。障碍期权通常分为以下几类:
一种敲出期权,即如果标的资产的价格超过某个阈值,期权将变得毫无价值
一种敲入期权,即期权在标的资产价格达到某个阈值之前没有任何价值
考虑到上述障碍期权的类别,我们可以处理以下几类:
向上出场:期权开始时处于激活状态,当标的资产价格上涨到障碍水平时,期权变为无效(失效)
向上入场:期权开始时处于非激活状态,当标的资产价格上涨到障碍水平时,期权变为激活状态(击中)
向下出场:期权开始时处于激活状态,当标的资产价格下跌到障碍水平时,期权变为击中(失效)
向下入场:期权开始时处于非激活状态,当标的资产价格下跌到障碍水平时,期权变为激活状态
除了上述描述的行为外,障碍期权的表现类似于标准的看涨和看跌期权。
在这个实例中,我们使用蒙特卡罗模拟为“向上入场”欧式看涨期权定价,标的资产当前交易价格为 55 美元,行权价为 60 美元,障碍水平为 65 美元,距离到期时间为 1 年。
如何操作……
执行以下步骤来为“向上入场”欧式看涨期权定价:
导入库:
import numpy as np from chapter_10_utils import simulate_gbm
定义估值的参数:
S_0 = 55 K = 60 BARRIER = 65 r = 0.06 sigma = 0.2 T = 1 N = 252 dt = T / N N_SIMS = 10 ** 5 OPTION_TYPE = "call" discount_factor = np.exp(-r * T)
使用
simulate_gbm
函数模拟股票路径:gbm_sims = simulate_gbm(s_0=S_0, mu=r, sigma=sigma, n_sims=N_SIMS, T=T, N=N)
计算每条路径的最大值:
max_value_per_path = np.max(gbm_sims, axis=1)
计算支付:
payoff = np.where(max_value_per_path > BARRIER, np.maximum(0, gbm_sims[:, -1] - K), 0)
计算期权的溢价:
premium = discount_factor * np.mean(payoff) premium
所考虑的“向上入场”欧式看涨期权的溢价为3.6267
。
它是如何工作的……
在前两步中,我们导入了库(包括已在本章中使用的辅助函数simulate_gbm
),并定义了估值的参数。
在步骤 3中,我们使用几何布朗运动模拟了 100,000 条可能的路径。然后,我们计算了每条路径中标的资产的最高价格。因为我们处理的是“向上入场”期权,我们只需要知道标的资产的最高价格是否达到了障碍水平。如果达到了,那么期权在到期时的支付将等同于一个普通的欧式看涨期权的支付。如果障碍水平未达到,那么该路径的支付将为零。我们在步骤 5中编码了这个支付条件。
最后,我们按照之前处理欧式看涨期权的方式进行操作——取平均支付并使用折现因子进行折现。
我们可以对障碍期权的价格建立一些直觉。例如,“向上出场”障碍期权的价格应该低于其普通等效期权的价格。这是因为,除了“向上出场”障碍期权可能会在到期前被击出这一额外的风险外,两者的支付是相同的。这个额外的风险应该反映在“向上出场”障碍期权的较低价格中,与其普通对等期权相比。
在这个实例中,我们手动为“向上入场”欧式看涨期权定价。然而,我们也可以使用 QuantLib 库来完成此任务。由于这将与之前的实例中有大量代码重复,因此我们在本书中没有展示。
但是强烈建议你查看 GitHub 上附带的笔记本中使用 QuantLib 的解决方案。我们只是提到,通过 QuantLib 解决方案得到的期权溢价为 3.6457,与我们手动计算的结果非常接近。差异可以归因于模拟的随机成分。
还有更多…
障碍期权的估值比较复杂,因为这些金融工具是路径依赖的。我们已经提到过如何使用蒙特卡罗模拟来定价此类期权;然而,还有几种替代方法:
使用静态复制投资组合(普通期权)来模拟障碍期权在到期时以及在障碍期内几个离散时间点的价值。然后,可以使用 Black-Scholes 模型对这些期权进行定价。通过这种方法,我们可以获得所有类型障碍期权的封闭形式价格和复制策略。
使用二项树方法进行期权定价。
使用偏微分方程(PDE),并可能将其与有限差分法结合使用。
使用蒙特卡罗模拟估算风险价值
风险价值(VaR)是一个非常重要的金融指标,用于衡量与某个头寸、投资组合等相关的风险。通常缩写为 VaR,不要与向量自回归(缩写为 VAR)混淆。VaR 报告在特定置信水平下,在正常市场条件下某个时间段内的最坏预期损失。理解 VaR 最简单的方式是通过一个例子。假设我们的投资组合 1 天 95% VaR 为 $100。这意味着在正常市场条件下,95% 的时间里,我们持有该投资组合 1 天不会损失超过 $100。
通常,VaR 给出的损失是以正值(绝对值)呈现的。这就是为什么在这个例子中,VaR 为 $100 表示损失不超过 $100。不过,负 VaR 也是可能的,它表示有较高的获利概率。例如,1 天 95% VaR 为 $-100 表示我们的投资组合在接下来的 1 天内有 95% 的概率会赚取超过 $100。
计算 VaR 的方法有很多,包括:
参数法(方差-协方差)
历史模拟法
蒙特卡罗模拟
在本方案中,我们仅考虑最后一种方法。我们假设我们持有一个由两种资产(英特尔和 AMD 的股票)组成的投资组合,并且我们想计算 1 天的风险价值。
如何做...
执行以下步骤,通过蒙特卡罗模拟估算风险价值:
导入库:
import numpy as np import pandas as pd import yfinance as yf import seaborn as sns
定义将用于此方案的参数:
RISKY_ASSETS = ["AMD", "INTC"] SHARES = [5, 5] START_DATE = "2020-01-01" END_DATE = "2020-12-31" T = 1 N_SIMS = 10 ** 5
从 Yahoo Finance 下载价格数据:
df = yf.download(RISKY_ASSETS, start=START_DATE, end=END_DATE, adjusted=True)
计算每日收益:
returns = df["Adj Close"].pct_change().dropna() returns.plot(title="Intel's and AMD's daily stock returns in 2020")
运行代码片段后,得到以下图形。
图 10.3:2020 年英特尔和 AMD 的简单收益
此外,我们计算了两组数据之间的皮尔逊相关系数(使用
corr
方法),其值为0.5
。计算协方差矩阵:
cov_mat = returns.cov()
对协方差矩阵进行 Cholesky 分解:
chol_mat = np.linalg.cholesky(cov_mat)
从标准正态分布中抽取相关的随机数:
rv = np.random.normal(size=(N_SIMS, len(RISKY_ASSETS))) correlated_rv = np.transpose( np.matmul(chol_mat, np.transpose(rv)) )
定义将用于模拟的指标:
r = np.mean(returns, axis=0).values sigma = np.std(returns, axis=0).values S_0 = df["Adj Close"].values[-1, :] P_0 = np.sum(SHARES * S_0)
计算所考虑股票的终端价格:
S_T = S_0 * np.exp((r - 0.5 * sigma ** 2) * T + sigma * np.sqrt(T) * correlated_rv)
计算终端投资组合价值和投资组合收益率:
P_T = np.sum(SHARES * S_T, axis=1) P_diff = P_T - P_0
计算所选置信度水平的 VaR:
P_diff_sorted = np.sort(P_diff) percentiles = [0.01, 0.1, 1.] var = np.percentile(P_diff_sorted, percentiles) for x, y in zip(percentiles, var): print(f'1-day VaR with {100-x}% confidence: ${-y:.2f}')
运行代码片段将生成如下输出:
1-day VaR with 99.99% confidence: $2.04 1-day VaR with 99.9% confidence: $1.48 1-day VaR with 99.0% confidence: $0.86
在图表中展示结果:
ax = sns.distplot(P_diff, kde=False) ax.set_title("""Distribution of possible 1-day changes in portfolio value 1-day 99% VaR""", fontsize=16) ax.axvline(var[2], 0, 10000)
运行代码片段将生成如下图形:
图 10.4:投资组合价值可能变化的 1 天分布和 1 天 99% VaR
图 10.4 展示了未来 1 天投资组合可能价值变化的分布。我们用垂直线表示 99%的风险价值(VaR)。
它是如何工作的...
在步骤 2 到 步骤 4 中,我们下载了 2020 年 Intel 和 AMD 的每日股票价格,提取了调整后的收盘价,并将其转换为简单收益率。我们还定义了几个参数,例如模拟次数和我们投资组合中持有的股票数量。
计算 VaR 有两种方法:
根据价格计算 VaR:通过使用股票数量和资产价格,我们可以计算出投资组合当前的价值以及未来 X 天的可能价值。
根据收益率计算 VaR:通过使用投资组合中每个资产的百分比权重和资产的预期收益率,我们可以计算出未来 X 天的预期投资组合收益率。然后,我们可以根据该收益率和当前投资组合价值,将 VaR 表示为美元金额。
蒙特卡洛方法用于确定资产价格,通过从标准正态分布中抽取随机变量。在计算投资组合 VaR 时,我们需要考虑到投资组合中的资产可能存在相关性。为此,在步骤 5 到 步骤 7 中,我们生成了相关的随机变量。具体步骤是:首先,我们计算了历史协方差矩阵;然后,我们对其进行了 Cholesky 分解,并将得到的矩阵与包含随机变量的矩阵相乘。
另一种使随机变量相关的方法是使用奇异值分解(SVD)替代 Cholesky 分解。我们可以使用的函数是np.linalg.svd
。
在步骤 8中,我们计算了资产收益的历史平均值、伴随的标准差、最后已知的股票价格和初始投资组合价值。在步骤 9中,我们应用了几何布朗运动 SDE 的解析解,计算了两只资产的可能 1 天后的股票价格。
为了计算投资组合 VaR,我们计算了可能的 1 天前投资组合价值及其相应差异(P[T] - P[0])。然后,我们将这些差异按升序排列。X%的 VaR 就是排序后的投资组合差异的(1-X)百分位。
银行通常会计算 1 天和 10 天 VaR。为了计算后者,它们可以通过 1 天的步长(离散化)模拟其资产在 10 天期间的价值。然而,它们也可以计算 1 天 VaR,并将其乘以 10 的平方根。如果这样做可以降低资本要求,银行可能会觉得这种方法更有利。
还有更多...
如我们所提到的,计算风险价值的方法有多种,每种方法都有一套潜在的缺点,其中一些包括:
假设使用参数化分布(方差-协方差方法)
假设日收益/损失是独立同分布的(IID)
未能捕捉足够的尾部风险
没有考虑所谓的黑天鹅事件(除非它们已经出现在历史样本中)
历史 VaR 可能对新的市场条件适应较慢
历史模拟方法假设过去的回报足以评估未来的风险(与之前的观点相关)
最近在使用深度学习技术进行 VaR 估计方面有一些有趣的进展,例如,生成对抗网络(GANs)。
VaR 的另一个普遍缺点是,当潜在损失超过 VaR 设定的阈值时,它无法提供关于损失大小的信息。这时预期短缺(也称为条件 VaR 或预期尾部损失)就派上用场了。它简单地说明了在最坏的 X%的情境下,预期损失是多少。
计算预期短缺有很多方法,但我们展示了一种与 VaR 易于连接且可以通过蒙特卡洛模拟进行估算的方法。
以双资产投资组合为例,我们希望知道以下内容:如果损失超过 VaR,它将有多大?为了得到这个数字,我们需要筛选出所有高于 VaR 给定值的损失,并通过取平均值来计算它们的预期值。
我们可以使用以下代码片段来实现这一点:
var = np.percentile(P_diff_sorted, 5)
expected_shortfall = P_diff_sorted[P_diff_sorted<=var].mean()
请记住,计算预期短缺时,我们只使用了为计算 VaR 而进行的所有模拟中的一小部分。在图 10.4中,我们只考虑 VaR 线左侧的观察值。这就是为什么,为了得到合理的预期短缺结果,整体样本必须足够大的原因。
1 天 95%VaR 为$0.29,而伴随的预期短缺为$0.64。我们可以这样解读这些结果:如果损失超过 95% VaR,我们可以预期持有投资组合 1 天将损失$0.64。
总结
在本章中,我们介绍了蒙特卡洛模拟,这是一种在许多金融任务中都非常有用的多功能工具。我们演示了如何利用它们通过几何布朗运动模拟股票价格,定价各种类型的期权(欧洲期权、美式期权和障碍期权),以及计算风险价值(Value-at-Risk)。
然而,在本章中,我们仅仅触及了蒙特卡洛模拟所有可能应用的皮毛。在接下来的章节中,我们还将展示如何使用它们来获得用于资产配置的有效前沿。
加入我们的 Discord 社区!
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
第十一章:资产配置
资产配置是每个投资者都必须面对的最重要的决策,且没有一种“万能”解决方案适用于所有投资者。资产配置是指将投资者的总投资金额分配到特定资产上(无论是股票、期权、债券或其他任何金融工具)。在进行配置时,投资者希望平衡风险和潜在的回报。同时,配置还依赖于诸如个人目标(预期回报)、风险承受能力(投资者愿意接受的风险程度)或投资期限(短期或长期投资)等因素。
资产配置中的关键框架是现代投资组合理论(MPT,也称为均值-方差分析)。该理论由诺贝尔奖获得者哈里·马科维茨提出,描述了风险厌恶型投资者如何构建投资组合,以在给定的风险水平下最大化预期回报(利润)。MPT 的主要见解是,投资者不应单独评估某一资产的表现(例如预期回报或波动性),而应调查其对投资组合整体表现的影响。
MPT 与多样化的概念密切相关,多样化的基本含义是拥有不同种类的资产能够降低风险,因为某一特定证券的亏损或收益对整体投资组合的表现影响较小。另一个需要注意的关键概念是,虽然投资组合的回报是各个资产回报的加权平均,但对于风险(波动性)并非如此。这是因为波动性还受到资产之间相关性的影响。有趣的是,通过优化资产配置,有可能拥有一个波动性低于组合中所有资产的最低单一资产波动性的投资组合。原则上,资产之间的相关性越低,对于多样化越有利。如果资产之间具有完全的负相关性,我们可以将所有的风险进行多样化。
现代投资组合理论的主要假设是:
投资者是理性的,旨在最大化回报,同时尽量避免风险。
投资者共同的目标是最大化预期回报。
所有投资者对潜在投资拥有相同的信息水平。
手续费、税费和交易成本未被考虑在内。
投资者可以以无风险利率借贷资金(没有限制)。
在本章中,我们从最基本的资产配置策略开始,在此基础上学习如何评估投资组合的表现(同样适用于单个资产)。随后,我们展示三种不同的方法来获得有效前沿,同时放宽 MPT 的一些假设。学习如何解决优化问题的一个主要好处是,它们可以很容易地进行重构,例如,通过优化不同的目标函数或添加特定的权重约束。这只需要对代码进行轻微修改,而框架的大部分内容保持不变。最后,我们探索一种基于图论和机器学习相结合的新型资产配置方法——层次风险平价。
本章涵盖以下配方:
评估等权重投资组合的表现
使用蒙特卡罗模拟找到有效前沿
使用
SciPy
优化找到有效前沿使用
CVXPY
通过凸优化找到有效前沿使用层次风险平价寻找最优投资组合
评估等权重投资组合的表现
我们从检查最基本的资产配置策略开始:等权重(1/n)投资组合。其思路是对所有考虑的资产分配相等的权重,从而实现投资组合的多元化。尽管听起来很简单,但 DeMiguel、Garlappi 和 Uppal(2007)表明,使用更先进的资产配置策略可能很难超越1/n投资组合的表现。
本配方的目标是展示如何创建由 FAANG 公司(Facebook/Meta、Amazon、Apple、Netflix 和 Google/Alphabet)组成的1/n投资组合,计算其回报,并使用quantstats
库快速获得所有相关的投资组合评估指标,以便生成一张快速汇总的报告。历史上,快速汇总报告是简明(通常是单页)的文件,总结了公开公司重要的信息。
如何操作...
执行以下步骤来创建并评估1/n投资组合:
导入库:
import yfinance as yf import numpy as np import pandas as pd import quantstats as qs
定义考虑的资产并从 Yahoo Finance 下载其价格:
ASSETS = ["META", "AMZN", "AAPL", "NFLX", "GOOG"] n_assets = len(ASSETS) prices_df = yf.download(ASSETS, start="2020-01-01", end="2021-12-31", adjusted=True)
计算单个资产回报:
returns = prices_df["Adj Close"].pct_change().dropna()
定义权重:
portfolio_weights = n_assets * [1 / n_assets]
计算投资组合回报:
portfolio_returns = pd.Series( np.dot(portfolio_weights, returns.T), index=returns.index )
生成基本的表现评估图:
qs.plots.snapshot(portfolio_returns, title="1/n portfolio's performance", grayscale=True)
执行此代码片段生成以下图形:
图 11.1:1/n 投资组合的选定评估指标
创建的快照包括累积投资组合回报、显示回撤期的水下图(我们将在*如何运作...*部分进行解释)和每日回报。
计算基本的投资组合评估指标:
qs.reports.metrics(portfolio_returns, benchmark="SPY", mode="basic", prepare_returns=False)
执行此代码片段会返回我们投资组合和基准的以下指标:
图 11.2:1/n 投资组合与标准普尔 500 基准的表现评估指标
我们将在接下来的章节中描述图 11.2中呈现的一些指标。
它是如何工作的...
在步骤 1到步骤 3中,我们遵循了已经建立的方法——导入库,设置参数,下载了 2020 年至 2021 年期间 FAANG 公司(Facebook, Apple, Amazon, Netflix, Google)的股票价格,并使用调整后的收盘价计算了简单回报。
在步骤 4中,我们创建了一个权重列表,每个权重等于1/n_assets
,其中n_assets
是我们希望在投资组合中包含的资产数量。接下来,我们将投资组合的回报计算为投资组合权重与资产回报转置矩阵的矩阵乘法(也称为点积)。为了转置矩阵,我们使用了pandas
DataFrame 的T
方法。然后,我们将投资组合回报存储为pandas
Series 对象,因为这是后续步骤的输入。
在本书的第一版中,我们使用pyfolio
库探索了1/n投资组合的表现。然而,自那时以来,负责该库的公司(Quantopian)已关闭,且该库不再积极维护。尽管如此,该库仍然可以使用,正如我们在书籍的 GitHub 仓库中提供的附加笔记本所展示的那样。或者,你也可以使用pyfolio-reloaded
,这是原始库的一个分支,由《机器学习与算法交易》的作者 Stefan Jansen 维护。
在步骤 6中,我们使用quantstats
库生成了一个包含基本投资组合评估图的图表。虽然我们已经熟悉了显示每日回报的图表,但另外两个是新的:
累计回报图:它展示了投资组合价值随时间的变化。
水下图:该图从悲观的角度呈现投资情况,重点关注亏损。它展示了所有回撤期及其持续时间,即直到价值反弹至新高的时刻。我们可以从中得到的一个洞察是亏损期持续了多长时间。
最后,我们生成了投资组合评估指标。在此过程中,我们还提供了一个基准。我们选择了 SPY,这是一个交易所交易基金(ETF),旨在跟踪标准普尔 500 指数。我们可以将基准提供为股票代码或包含价格/回报的pandas
DataFrame/Series。该库可以处理这两种选项,我们可以通过prepare_returns
参数指示是否希望通过价格计算回报。
在图 11.2中我们看到的最重要的指标是:
夏普比率:这是最受欢迎的绩效评估指标之一,衡量单位标准差的超额回报(超过无风险利率)。如果没有提供无风险利率,默认假设其为 0%。夏普比率越大,投资组合的风险调整后绩效越好。
Sortino 比率:Sharpe 比率的修改版,其中分母中的标准差被下行偏差所取代。
Omega 比率:一个基于概率加权的收益与损失的比率,针对一个确定的回报目标阈值(默认设置为 0)。与 Sharpe 比率的主要区别在于,Omega 比率通过构造方式考虑了回报分布的所有时刻,而后者仅考虑了前两个(均值和方差)。
最大回撤:投资组合的下行风险指标,它衡量的是投资过程中最大峰值到谷值的损失(以百分比表示)。最大回撤越小,越好。
尾部 比率:日回报的 95 百分位与 5 百分位之间的比率(绝对值)。尾部比率约为 0.8 意味着损失约为利润的 1.25 倍。
下行偏差类似于标准差;然而,它只考虑负回报——它丢弃了所有的正向变化。它还允许我们定义不同水平的最低可接受回报(取决于投资者),低于该阈值的回报将用于计算下行偏差。
还有更多内容...
到目前为止,我们主要生成了quantstats
库中可用的基本图表和指标。然而,这个库提供了更多的功能。
完整的分析报告
quantstats
允许我们生成一个完整的 HTML 报告,包含所有可用的图表和指标(包括与基准的比较)。我们可以使用以下命令创建这样的报告:
qs.reports.html(portfolio_returns,
benchmark="SPY",
title="1/n portfolio",
download_filename="EW portfolio evaluation.html")
执行它会生成一个 HTML 文件,其中包含我们等权重投资组合的详尽分析报告,并与 SPY 进行比较。请参阅 GitHub 上的EW portfolio evaluation.html
文件。
首先,让我们解释一些在生成报告中看到的新的但相关的指标:
Calmar 比率:这个比率定义为平均年化复合回报率除以同一时期的最大回撤。比率越高越好。
偏斜度:偏斜度衡量的是非对称性的程度,即给定的分布(这里指的是投资组合回报)的偏斜程度,相对于正态分布的偏斜程度。负偏斜度(左偏分布)意味着大幅负回报出现的频率高于大幅正回报。
峰度:它衡量的是分布尾部的极值。具有大峰度的分布表现为尾部数据超过高斯分布的尾部,意味着大幅和小幅回报出现的频率更高。
Alpha:它描述了一个策略击败市场的能力。换句话说,它是投资组合超越基准回报的超额回报。
Beta:它衡量投资组合的整体系统性风险。换句话说,它是投资组合波动性与整个市场系统性风险的对比度量。投资组合的 beta 等于该投资组合中所有个别资产 beta 系数的加权平均值。
指标还包括了 10 个最差回撤。这些指标显示了每个回撤的严重程度、恢复日期以及回撤持续时间。这些信息补充了我们之前提到的水下图表的分析。
图 11.3:评估期间的 10 个最差回撤
然后,报告还包含了一些新的图表,下面我们进行解释:
- 滚动夏普比率:与其报告一个随时间变化的数值,不如看看夏普比率的稳定性。这就是为什么下图呈现了基于滚动计算的该指标,使用了 6 个月的数据。
图 11.4:滚动(6 个月)夏普比率
- 五个最差回撤期也在单独的图表中进行了可视化。有关回撤开始和结束的确切日期,请参阅图 11.3。值得一提的是,回撤期是叠加在累积回报图上的。通过这种方式,我们可以清楚地确认回撤的定义,即我们的投资组合从峰值回落多少,之后再回升到峰值水平。
图 11.5:评估期间五个最差回撤期
- 展示月度回报分布的直方图,包括 核密度估计(KDE)和平均值。这有助于分析回报的分布。在图表中,我们可以看到,评估期间的月度平均回报为正。
图 11.6:月度回报的分布(直方图 + KDE)
- 一个热图,总结了某些月份/年份的回报情况。
图 11.7:呈现年际月度回报的热图
- 一个分位数图,显示回报在不同频率下的分布。
图 11.8:将回报聚合到不同频率的分位数图
在生成综合 HTML 报告之前,我们使用 qs.reports.plots
和 qs.reports.metrics
函数生成了基本的图表和指标。我们还可以通过适当指定 mode
参数,使用这些函数获取与报告中相同的指标/图表。要获取所有指标,我们应传递 "full"
而不是默认值 "basic"
。
丰富 pandas DataFrame/Series 的新方法
quantstats
库的另一个有趣特性是,它可以通过新的方法扩展pandas
DataFrame 或 Series,用于计算库中提供的所有指标。为此,我们首先需要执行以下命令:
qs.extend_pandas()
然后,我们可以直接从包含回报序列的 DataFrame 中访问这些方法。例如,我们可以使用以下代码快速计算夏普比率和索提诺比率:
print(f"Sharpe ratio: {portfolio_returns.sharpe():.2f}")
print(f"Sortino ratio: {portfolio_returns.sortino():.2f}")
返回如下:
Sharpe ratio: 1.36
Sortino ratio: 1.96
这些数值与我们之前使用qs.reports.metrics
函数计算的结果一致。有关可用方法的完整列表,可以运行以下代码:
[method for method in dir(qs.stats) if method[0] != "_"]
另见
这里有更多资源可供参考:
- DeMiguel, V., Garlappi, L., & Uppal, R. 2007, “Optimal versus naive diversification: how inefficient is the 1/N portfolio strategy?” The Review of Financial Studies, 22(5): 1915-1953:
doi.org/10.1093/rfs/hhm075
使用蒙特卡洛模拟找到有效前沿
根据现代投资组合理论,有效前沿是在风险-收益谱上最优投资组合的集合。这意味着位于前沿上的投资组合:
提供在给定风险水平下的最高预期回报
在给定预期回报水平下提供最低的风险水平
所有位于有效前沿曲线下方的投资组合都被视为次优投资组合,因此最好选择位于前沿上的投资组合。
在本教程中,我们展示了如何通过蒙特卡洛模拟找到有效前沿。在展示基于优化的更优雅方法之前,我们采用了一种暴力破解方法,通过随机分配权重来构建数千个投资组合。然后,我们可以计算这些投资组合的表现(预期回报/波动率),并利用这些数值来确定有效前沿。为了进行这个练习,我们使用了四家美国科技公司 2021 年的回报数据。
如何做到这一点……
执行以下步骤,通过蒙特卡洛模拟找到有效前沿:
导入库:
import yfinance as yf import numpy as np import pandas as pd
设置参数:
N_PORTFOLIOS = 10 ** 5 N_DAYS = 252 ASSETS = ["META", "TSLA", "TWTR", "MSFT"] ASSETS.sort() n_assets = len(ASSETS)
从 Yahoo Finance 下载股票价格:
prices_df = yf.download(ASSETS, start="2021-01-01", end="2021-12-31", adjusted=True)
计算年化平均回报和相应的标准差:
returns_df = prices_df["Adj Close"].pct_change().dropna() avg_returns = returns_df.mean() * N_DAYS cov_mat = returns_df.cov() * N_DAYS
模拟随机投资组合权重:
np.random.seed(42) weights = np.random.random(size=(N_PORTFOLIOS, n_assets)) weights /= np.sum(weights, axis=1)[:, np.newaxis]
计算投资组合指标:
portf_rtns = np.dot(weights, avg_returns) portf_vol = [] for i in range(0, len(weights)): vol = np.sqrt( np.dot(weights[i].T, np.dot(cov_mat, weights[i])) ) portf_vol.append(vol) portf_vol = np.array(portf_vol) portf_sharpe_ratio = portf_rtns / portf_vol
创建一个包含所有数据的 DataFrame:
portf_results_df = pd.DataFrame( {"returns": portf_rtns, "volatility": portf_vol, "sharpe_ratio": portf_sharpe_ratio} )
DataFrame 的样式如下:
图 11.9:每个生成投资组合的选定指标
定位创建有效前沿的点:
N_POINTS = 100 ef_rtn_list = [] ef_vol_list = [] possible_ef_rtns = np.linspace(portf_results_df["returns"].min(), portf_results_df["returns"].max(), N_POINTS) possible_ef_rtns = np.round(possible_ef_rtns, 2) portf_rtns = np.round(portf_rtns, 2) for rtn in possible_ef_rtns: if rtn in portf_rtns: ef_rtn_list.append(rtn) matched_ind = np.where(portf_rtns == rtn) ef_vol_list.append(np.min(portf_vol[matched_ind]))
绘制有效前沿:
MARKERS = ["o", "X", "d", "*"] fig, ax = plt.subplots() portf_results_df.plot(kind="scatter", x="volatility", y="returns", c="sharpe_ratio", cmap="RdYlGn", edgecolors="black", ax=ax) ax.set(xlabel="Volatility", ylabel="Expected Returns", title="Efficient Frontier") ax.plot(ef_vol_list, ef_rtn_list, "b--") for asset_index in range(n_assets): ax.scatter(x=np.sqrt(cov_mat.iloc[asset_index, asset_index]), y=avg_returns[asset_index], marker=MARKERS[asset_index], s=150, color="black", label=ASSETS[asset_index]) ax.legend() plt.show()
执行该代码段会生成包含所有随机创建投资组合的图表,其中四个点表示单个资产,有效前沿也被标出。
图 11.10:通过蒙特卡洛模拟确定的有效前沿
在图 11.10中,我们可以看到有效前沿典型的子弹状形态。
我们从分析有效前沿中可以得出的一些见解:
在效率前沿的线的左侧的任何部分都是不可实现的,因为我们无法在如此低的波动水平下获得相应的预期回报。
仅由微软股票组成的投资组合的表现非常接近效率前沿。
理想情况下,我们应该寻找一个提供卓越回报的投资组合,并且其综合标准差低于各个资产的标准差。例如,我们不应考虑仅由 Meta 股票组成的投资组合(它不是高效的),而应该考虑位于效率前沿上方的投资组合。因为后者在相同的预期波动水平下提供了更好的预期回报。
它是如何工作的……
在第 2 步中,我们定义了这个方案所用的参数,例如考虑的时间框架、我们希望用于构建投资组合的资产,以及模拟的次数。这里需要注意的一点是,我们还运行了ASSETS.sort()
来按字母顺序对列表进行排序。这一点在解释结果时非常重要,因为当使用yfinance
库从 Yahoo Finance 下载数据时,获取的价格是按字母顺序排列的,而不是按照提供的列表中的顺序。下载了股票价格后,我们使用pct_change
方法计算了简单回报,并删除了包含 NaN 值的第一行。
在评估潜在的投资组合时,我们需要计算平均(预期)年回报和相应的协方差矩阵。我们通过使用 DataFrame 的mean
和cov
方法得到了这些数据。我们还将这两个指标年化,方法是将它们乘以 252(每年交易日的平均数量)。
我们需要协方差矩阵,因为在计算投资组合波动率时,还需要考虑资产之间的相关性。为了获得显著的多样化效果,资产之间的相关性应该是低的正相关或负相关。
在第 5 步中,我们计算了随机投资组合的权重。根据现代投资组合理论的假设(参见章节介绍作为参考),权重需要为正,并且总和为 1。为了实现这一点,我们首先使用np.random.random
生成了一个随机数矩阵(值介于 0 和 1 之间)。矩阵的大小为N_SIMULATIONS
行,n_assets
列。为了确保权重的总和为 1,我们将矩阵的每一行除以该行的总和。
在第 6 步中,我们计算了投资组合的指标——回报、标准差和夏普比率。为了计算预期的年投资组合回报,我们需要将权重与先前计算的年均值相乘。对于标准差,我们需要使用以下公式:,其中
是权重向量,
是历史协方差矩阵。我们通过
for
循环迭代了所有模拟的投资组合。
在这种情况下,for
循环实现实际上比矢量化矩阵的等价实现更快:np.diag(np.sqrt(np.dot(weights, np.dot(cov_mat, weights.T))))
。原因在于需要计算的对角线外元素数量迅速增加,而这些元素对于我们关注的度量没有影响。对于较少数量的模拟(约 100 个),这种方法比for
循环更快。
在这个示例中,我们假设无风险利率为 0%,因此投资组合的夏普比率可以通过投资组合回报除以投资组合的波动率来计算。另一种可能的方法是计算 2021 年期间的平均年化无风险利率,并使用投资组合的超额回报来计算该比率。
在寻找最优资产配置并评估其表现时需要记住的一点是,我们是在进行历史优化。我们使用过去的表现来选择最适合的配置,前提是市场条件没有发生变化。正如我们所知,市场条件很少保持不变,因此过去的表现并不总是能预测未来的表现。
最后三个步骤导致了结果的可视化。首先,我们将所有相关的度量放入一个pandas
DataFrame 中。其次,我们识别了有效前沿的点。为此,我们从样本中创建了一个预期回报的数组。我们使用了np.linspace
,其中最小值和最大值来自于计算出的投资组合回报。我们将数字四舍五入到小数点后两位,以使计算更加平滑。对于每个预期回报,我们找到了最小观察波动率。在没有匹配的情况下,比如当线性空间上均匀分布的点没有匹配时,我们跳过了该点。
在最后一步,我们将模拟的投资组合、单个资产以及近似的有效前沿绘制在同一个图表中。前沿的形状有些参差不齐,这在仅使用一些在极端区域不常见的模拟值时是可以预期的。此外,我们根据夏普比率的值给表示模拟投资组合的点着色。根据夏普比率的定义,图表的左上部分显示了一个“甜点”区域,那里具有每单位预期波动率下的最高预期回报。
你可以在matplotlib
文档中找到可用的色图。根据实际问题的需要,可能会有更合适的色图(顺序色图、发散色图、定性色图等)。
还有更多内容...
在模拟了 100,000 个随机投资组合之后,我们还可以调查哪个组合具有最高的夏普比率(每单位风险的最大预期回报,也称为切线投资组合)或最小波动率。为了在模拟的投资组合中找到这些组合,我们使用了np.argmin
和np.argmax
函数,它们分别返回数组中最小/最大值的索引。
代码如下:
max_sharpe_ind = np.argmax(portf_results_df["sharpe_ratio"])
max_sharpe_portf = portf_results_df.loc[max_sharpe_ind]
min_vol_ind = np.argmin(portf_results_df["volatility"])
min_vol_portf = portf_results_df.loc[min_vol_ind]
我们还可以调查这些组合的组成部分及其预期表现。在这里,我们只关注结果,但用于生成总结的代码可以在本书的 GitHub 仓库中找到。
最大夏普比率组合将大部分资源(约 95%)分配给微软,几乎没有分配给 Twitter。这是因为 Twitter 2021 年的年化平均回报是负的:
Maximum Sharpe Ratio portfolio ----
Performance
returns: 45.14% volatility: 20.95% sharpe_ratio: 215.46%
Weights
META: 2.60% MSFT: 95.17% TSLA: 2.04% TWTR: 0.19%
最小波动率组合将约 78% 的权重分配给微软,因为它是波动率最低的股票(可以通过查看协方差矩阵来检查):
Minimum Volatility portfolio ----
Performance
returns: 40.05% volatility: 20.46% sharpe_ratio: 195.76%
Weights
META: 17.35% MSFT: 78.16% TSLA: 0.23% TWTR: 4.26%
最后,我们将在有效前沿图上标出这两个组合。为此,我们添加了两个额外的散点图,每个图上有一个点对应于所选组合。然后,我们使用marker
参数定义标记的形状,并使用s
参数定义标记的大小。我们增加了标记的大小,以便在所有其他点中更容易看到这些组合。
代码如下:
fig, ax = plt.subplots()
portf_results_df.plot(kind="scatter", x="volatility",
y="returns", c="sharpe_ratio",
cmap="RdYlGn", edgecolors="black",
ax=ax)
ax.scatter(x=max_sharpe_portf["volatility"],
y=max_sharpe_portf["returns"],
c="black", marker="*",
s=200, label="Max Sharpe Ratio")
ax.scatter(x=min_vol_portf["volatility"],
y=min_vol_portf["returns"],
c="black", marker="P",
s=200, label="Minimum Volatility")
ax.set(xlabel="Volatility", ylabel="Expected Returns",
title="Efficient Frontier")
ax.legend()
plt.show()
执行该代码段将生成以下图形:
图 11.11:具有全球最小波动率和最大夏普比率组合的有效前沿
我们没有绘制单个资产和有效前沿的线条,以避免图表变得过于杂乱。该图与我们在分析图 11.10时建立的直觉一致。首先,最小波动率组合位于前沿的最左侧部分,对应于最低的预期波动率。其次,最大夏普比率组合位于图表的左上方,在这个区域,预期回报与波动率的比率最高。
使用 SciPy 优化找到有效前沿
在前面的例子中,使用蒙特卡洛模拟寻找有效前沿,我们采用了基于蒙特卡洛模拟的蛮力方法来可视化有效前沿。在本例中,我们使用了一种更精细的方法来找到前沿。
根据定义,有效前沿是由一组组合组成,这些组合在特定波动率下提供最高的预期组合回报,或者在某一预期回报水平下提供最低的风险(波动率)。我们可以利用这一事实,并在数值优化中加以应用。
优化的目标是通过调整目标变量,并考虑一些边界和约束(这些约束会影响目标变量),找到目标函数的最佳(最优)值。在这个例子中,目标函数是返回组合波动率的函数,目标变量是组合权重。
数学上,这个问题可以表示为:
在这里,是权重向量,
是协方差矩阵,
是回报向量,而
是预期投资组合回报。
为了找到高效前沿,我们对用于寻找最佳投资组合权重的优化过程进行迭代,涵盖了预期投资组合回报的范围。
在这个流程中,我们使用与之前相同的数据集,以展示两种方法获得的结果相似。
做好准备
这个流程要求运行使用蒙特卡洛模拟寻找高效前沿流程中的所有代码。
如何做到……
执行以下步骤,通过使用SciPy
进行优化来找到高效前沿:
导入库:
import numpy as np import scipy.optimize as sco from chapter_11_utils import print_portfolio_summary
定义计算投资组合回报和波动率的函数:
def get_portf_rtn(w, avg_rtns): return np.sum(avg_rtns * w) def get_portf_vol(w, avg_rtns, cov_mat): return np.sqrt(np.dot(w.T, np.dot(cov_mat, w)))
定义计算高效前沿的函数:
def get_efficient_frontier(avg_rtns, cov_mat, rtns_range): efficient_portfolios = [] n_assets = len(avg_returns) args = (avg_returns, cov_mat) bounds = tuple((0,1) for asset in range(n_assets)) initial_guess = n_assets * [1. / n_assets, ] for ret in rtns_range: constr = ( {"type": "eq", "fun": lambda x: get_portf_rtn(x, avg_rtns) - ret}, {"type": "eq", "fun": lambda x: np.sum(x) - 1} ) ef_portf = sco.minimize(get_portf_vol, initial_guess, args=args, method="SLSQP", constraints=constr, bounds=bounds) efficient_portfolios.append(ef_portf) return efficient_portfolios
定义考虑的预期投资组合回报范围:
rtns_range = np.linspace(-0.1, 0.55, 200)
计算高效前沿:
efficient_portfolios = get_efficient_frontier(avg_returns, cov_mat, rtns_range)
提取高效投资组合的波动率:
vols_range = [x["fun"] for x in efficient_portfolios]
绘制计算出的高效前沿图,并与模拟的投资组合一起展示:
fig, ax = plt.subplots() portf_results_df.plot(kind="scatter", x="volatility", y="returns", c="sharpe_ratio", cmap="RdYlGn", edgecolors="black", ax=ax) ax.plot(vols_range, rtns_range, "b--", linewidth=3) ax.set(xlabel="Volatility", ylabel="Expected Returns", title="Efficient Frontier") plt.show()
下图展示了使用数值优化计算出的高效前沿的图形:
图 11.12:通过数值优化识别的高效前沿,与之前生成的随机投资组合一起展示
我们看到高效前沿的形状与使用蒙特卡洛模拟得到的形状非常相似。唯一的区别是这条线更加平滑。
确定最小波动率投资组合:
min_vol_ind = np.argmin(vols_range) min_vol_portf_rtn = rtns_range[min_vol_ind] min_vol_portf_vol = efficient_portfolios[min_vol_ind]["fun"] min_vol_portf = { "Return": min_vol_portf_rtn, "Volatility": min_vol_portf_vol, "Sharpe Ratio": (min_vol_portf_rtn / min_vol_portf_vol) }
打印性能总结:
print_portfolio_summary(min_vol_portf, efficient_portfolios[min_vol_ind]["x"], ASSETS, name="Minimum Volatility")
运行代码片段后得到以下摘要:
Minimum Volatility portfolio ----
Performance
Return: 40.30% Volatility: 20.45% Sharpe Ratio: 197.10%
Weights
META: 15.98% MSFT: 79.82% TSLA: 0.00% TWTR: 4.20%
最小波动率投资组合通过主要投资于微软和 Meta,而完全不投资特斯拉来实现。
它是如何工作的……
如引言中所述,我们延续了上一个流程的例子。这就是为什么我们必须从那里运行步骤 1到步骤 4(为简洁起见,这里不展示)以获取所有所需的数据。作为额外的前提,我们需要从SciPy
导入优化模块。
在步骤 2中,我们定义了两个函数,给定历史数据和投资组合权重,它们分别返回预期的投资组合回报和波动率。我们必须定义这些函数,而不是直接计算这些指标,因为我们将在优化过程中后续使用它们。算法会反复尝试不同的权重,并且需要能够使用目标变量(权重)的当前值来得到它试图优化的指标。
在步骤 3中,我们定义了一个名为get_efficient_frontier
的函数。它的目标是返回一个包含高效投资组合的列表,给定历史指标和考虑的预期投资组合回报范围。这是该流程中最重要的步骤,包含了很多细节。我们依次描述该函数的逻辑:
该函数的轮廓是,它针对考虑的范围内每个预期的投资组合回报运行优化程序,并将得到的最优投资组合存储在一个列表中。
在
for
循环外,我们定义了几个对象并将其传递给优化器:传递给目标函数的参数。在这种情况下,这些参数是历史平均回报和协方差矩阵。我们优化的函数必须接受这些参数作为输入。这就是为什么我们将回报传递给
get_portf_vol
函数(在步骤 2中定义),即使这些回报在计算中不必要并且在函数内没有使用。bounds
(一个嵌套元组)——对于每个目标变量(权重),我们提供一个包含边界值的元组,即最小和最大允许值。在这种情况下,值的范围从 0 到 1(根据现代投资组合理论,权重不能为负)。initial_guess
,即目标变量的初始猜测。使用初始猜测的目的是让优化运行得更快、更高效。在这种情况下,猜测是等权重配置。
在
for
循环内部,我们定义了用于优化的最后一个元素——约束。我们定义了两个约束:预期的投资组合回报必须等于提供的值。
权重之和必须等于 1。
第一个约束是为什么约束的元组在循环中定义的原因。那是因为循环遍历了预期的投资组合回报范围,对于每个值,我们找到了最优的风险水平。
我们使用序列最小二乘法编程(SLSQP)算法运行优化器,该算法通常用于一般的最小化问题。对于需要最小化的函数,我们传递之前定义的
get_portfolio_vol
函数。
优化器将等式(eq
)约束设置为 0。这就是为什么预期的约束np.sum(weights) == 1
被表达为np.sum(weights) - 1 == 0
。
在步骤 4和步骤 5中,我们定义了预期投资组合回报的范围(基于我们在之前的步骤中经验性观察到的范围),并运行了优化函数。
在步骤 6中,我们遍历了有效投资组合的列表,并提取了最优的波动率。我们通过访问scipy.optimize.OptimizeResult
对象中的fun
元素来提取波动率。这个元素代表优化后的目标函数,在此情况下是投资组合的波动率。
在步骤 7中,我们在之前的图表上叠加了计算得到的有效前沿,之前的图表来自于通过蒙特卡罗模拟寻找有效前沿。所有模拟的投资组合都位于有效前沿上方或下方,这正是我们预期的结果。
在步骤 8和步骤 9中,我们确定了最小波动率投资组合,打印了性能指标,并展示了投资组合的权重(从有效前沿提取)。
现在我们可以比较两个最小波动率投资组合:一个是通过蒙特卡洛模拟得到的,另一个是通过优化得到的。配置中的主要模式相同——将大部分可用资源分配给 Meta 和微软。我们还可以看到,优化策略的波动率略低。这意味着,在 10 万个投资组合中,我们并没有模拟出实际的最小波动率投资组合(在考虑的预期投资组合回报范围内)。
还有更多内容...
我们还可以使用优化方法来找到生成具有最高预期夏普比率的投资组合的权重,即切线投资组合。为此,我们首先需要重新定义目标函数,现在的目标函数是夏普比率的负值。之所以使用负值,是因为优化算法是最小化问题。我们可以通过改变目标函数的符号,轻松将最大化问题转化为最小化问题:
定义新的目标函数(负夏普比率):
def neg_sharpe_ratio(w, avg_rtns, cov_mat, rf_rate): portf_returns = np.sum(avg_rtns * w) portf_volatility = np.sqrt(np.dot(w.T, np.dot(cov_mat, w))) portf_sharpe_ratio = ( (portf_returns - rf_rate) / portf_volatility ) return -portf_sharpe_ratio
第二步与我们之前处理有效前沿时非常相似,只不过这次没有使用
for
循环,因为我们只需寻找一组权重。我们在参数中包括无风险利率(尽管我们假设其为 0%,为了简化计算),并且只使用一个约束条件——目标变量的总和必须等于 1。查找优化后的投资组合:
n_assets = len(avg_returns) RF_RATE = 0 args = (avg_returns, cov_mat, RF_RATE) constraints = ({"type": "eq", "fun": lambda x: np.sum(x) - 1}) bounds = tuple((0,1) for asset in range(n_assets)) initial_guess = n_assets * [1. / n_assets] max_sharpe_portf = sco.minimize(neg_sharpe_ratio, x0=initial_guess, args=args, method="SLSQP", bounds=bounds, constraints=constraints)
提取最大夏普比率投资组合的信息:
max_sharpe_portf_w = max_sharpe_portf["x"] max_sharpe_portf = { "Return": get_portf_rtn(max_sharpe_portf_w, avg_returns), "Volatility": get_portf_vol(max_sharpe_portf_w, avg_returns, cov_mat), "Sharpe Ratio": -max_sharpe_portf["fun"] }
打印性能总结:
print_portfolio_summary(max_sharpe_portf, max_sharpe_portf_w, ASSETS, name="Maximum Sharpe Ratio")
运行代码片段将打印出以下最大化夏普比率的投资组合总结:
Maximum Sharpe Ratio portfolio ----
Performance
Return: 45.90% Volatility: 21.17% Sharpe Ratio: 216.80%
Weights
META: 0.00% MSFT: 96.27% TSLA: 3.73% TWTR: 0.00%
为了实现最大夏普比率,投资者应将大部分资金投资于微软(>96%的配比),并且将 Meta 和 Twitter 的配比设置为 0%。
另见
- Markowitz, H., 1952 年。“投资组合选择”,《金融学杂志》,7(1): 77–91
使用 CVXPY 进行凸优化寻找有效前沿
在之前的方案中,使用优化和 SciPy 寻找有效前沿,我们使用SciPy
库的数值优化方法找到了有效前沿。我们以投资组合波动率作为希望最小化的度量指标。然而,也可以稍微不同地表述同样的问题,利用凸优化来找到有效前沿。
我们可以将均值-方差优化问题重新构建为一个风险厌恶框架,其中投资者希望最大化风险调整后的回报:
这里,是风险厌恶参数,约束条件指定权重总和必须为 1,并且不允许卖空。
的值越高,投资者的风险厌恶程度越高。
卖空意味着借入一种资产并在公开市场上出售,之后以更低的价格回购该资产。我们的收益是偿还初始借款后的差额。在本食谱中,我们使用与前两个食谱相同的数据,以确保结果具有可比性。
准备工作
本食谱需要运行前面食谱中的所有代码:
使用蒙特卡洛模拟寻找有效前沿
使用 SciPy 优化寻找有效前沿
如何做...
执行以下步骤,使用凸优化寻找有效前沿:
导入库:
import cvxpy as cp
将年化平均收益和协方差矩阵转换为
numpy
数组:avg_returns = avg_returns.values cov_mat = cov_mat.values
设置优化问题:
weights = cp.Variable(n_assets) gamma_par = cp.Parameter(nonneg=True) portf_rtn_cvx = avg_returns @ weights portf_vol_cvx = cp.quad_form(weights, cov_mat) objective_function = cp.Maximize( portf_rtn_cvx - gamma_par.*.portf_vol_cvx ) problem = cp.Problem( objective_function, [cp.sum(weights) == 1, weights >= 0] )
计算有效前沿:
N_POINTS = 25 portf_rtn_cvx_ef = [] portf_vol_cvx_ef = [] weights_ef = [] gamma_range = np.logspace(-3, 3, num=N_POINTS) for gamma in gamma_range: gamma_par.value = gamma problem.solve() portf_vol_cvx_ef.append(cp.sqrt(portf_vol_cvx).value) portf_rtn_cvx_ef.append(portf_rtn_cvx.value) weights_ef.append(weights.value)
绘制不同风险厌恶参数值下的资产配置:
weights_df = pd.DataFrame(weights_ef, columns=ASSETS, index=np.round(gamma_range, 3)) ax = weights_df.plot(kind="bar", stacked=True) ax.set(title="Weights allocation per risk-aversion level", xlabel=r"$\gamma$", ylabel="weight") ax.legend(bbox_to_anchor=(1,1))
在图 11.13中,我们可以看到在不同风险厌恶参数范围内的资产配置(
):
图 11.13:不同风险厌恶水平下的资产配置
在图 11.13中,我们可以看到,对于非常小的
值,投资者会将 100%的资金分配给特斯拉。随着风险厌恶度的增加,分配给特斯拉的比例逐渐减小,更多的资金被分配到微软和其他资产上。在该参数的另一端,投资者将不再分配任何资金给特斯拉。
绘制有效前沿,并标出各个资产:
fig, ax = plt.subplots() ax.plot(portf_vol_cvx_ef, portf_rtn_cvx_ef, "g-") for asset_index in range(n_assets): plt.scatter(x=np.sqrt(cov_mat[asset_index, asset_index]), y=avg_returns[asset_index], marker=MARKERS[asset_index], label=ASSETS[asset_index], s=150) ax.set(title="Efficient Frontier", xlabel="Volatility", ylabel="Expected Returns") ax.legend()
图11.14展示了通过解决凸优化问题得到的有效前沿。
图 11.14:通过解决凸优化问题识别的有效前沿
生成的前沿与图 11.10中的前沿类似(该前沿是通过蒙特卡洛模拟生成的)。当时,我们已经确定由微软股票组成的投资组合非常接近有效前沿。现在,我们也可以对完全由特斯拉股票组成的投资组合做出相同的判断。在使用蒙特卡洛模拟时,我们在收益/波动性平面的那部分没有足够的观测数据,因此无法为该投资组合绘制有效前沿线。在*更多内容...*部分,我们还将此前沿与上一部分使用SciPy
库得到的前沿进行比较。
工作原理...
正如在引言中提到的,我们延续了前两个食谱中的示例。因此,我们必须运行步骤 1到步骤 4(“使用蒙特卡洛模拟寻找有效前沿”食谱中的步骤,未在此展示以简化内容)以获取所有所需数据。作为额外步骤,我们需要导入cvxpy
凸优化库。我们还将历史平均收益和协方差矩阵转换为numpy
数组。
在步骤 3中,我们设置了优化问题。我们首先定义了目标变量(weights
),风险厌恶参数(gamma_par
,其中“par”用于突出它是优化程序的参数),投资组合的回报和波动性(都使用之前定义的 weights
对象),最后是目标函数——我们希望最大化的风险调整回报。然后,我们创建了 cp.Problem
对象,并将目标函数和约束列表作为参数传入。
我们使用 cp.quad_form(x, y)
来表示以下乘法:x^Tyx。
在步骤 4中,我们通过求解多个风险厌恶参数值的凸优化问题找到了有效前沿。为了定义考虑的值,我们使用 np.logspace
函数获取了 25 个值的 。对于每个参数值,我们通过运行
problem.solve()
找到了最优解。我们将感兴趣的值存储在专用的列表中。
np.logspace
类似于 np.linspace
;不同之处在于,前者在对数刻度上均匀分布数字,而不是在线性刻度上。
在步骤 5中,我们根据不同的风险厌恶水平绘制了资产配置。最后,我们绘制了有效前沿,并展示了各个单独的资产。
还有更多……
比较资产配置问题的两种形式的结果
我们还可以绘制两个有效前沿以供比较——一个是通过最小化波动性以达到预期回报水平计算的,另一个是使用凸优化并最大化风险调整回报的:
x_lim = [0.2, 0.6]
y_lim = [0.4, 0.6]
fig, ax = plt.subplots(1, 2)
ax[0].plot(vols_range, rtns_range, "g-", linewidth=3)
ax[0].set(title="Efficient Frontier - Minimized Volatility",
xlabel="Volatility",
ylabel="Expected Returns",
xlim=x_lim,
ylim=y_lim)
ax[1].plot(portf_vol_cvx_ef, portf_rtn_cvx_ef, "g-", linewidth=3)
ax[1].set(title="Efficient Frontier - Maximized Risk-Adjusted Return",
xlabel="Volatility",
ylabel="Expected Returns",
xlim=x_lim,
ylim=y_lim)
执行代码片段生成了以下图表:
图 11.15:通过最小化波动性以达到预期回报水平(左侧)和通过最大化风险调整回报(右侧)生成的有效前沿的比较
如我们所见,生成的有效前沿非常相似,只有一些细微的差异。首先,使用最小化方法获得的有效前沿更平滑,因为我们使用了更多的点来计算前沿。其次,右侧的前沿定义了一个稍大范围的可能波动性/回报对。
允许杠杆
另一个我们可以纳入分析的有趣概念是最大允许杠杆。我们用最大杠杆约束替代了权重上的非负性约束,使用了向量的范数。
在接下来的代码片段中,我们只展示了在步骤 3中定义的内容之上添加的部分:
max_leverage = cp.Parameter()
prob_with_leverage = cp.Problem(objective_function,
[cp.sum(weights) == 1,
cp.norm(weights, 1) <= max_leverage])
在接下来的代码片段中,我们修改了代码,这次包含了两个循环——一个遍历风险厌恶参数的潜在值,另一个指示最大允许的杠杆。最大杠杆为 1(意味着没有杠杆)时,结果类似于之前的优化问题(只是这次没有非负性约束)。
我们还重新定义了占位符对象(用于存储结果),使其为二维矩阵(np.ndarrays
)或在权重的情况下包含第三维。
LEVERAGE_RANGE = [1, 2, 5]
len_leverage = len(LEVERAGE_RANGE)
N_POINTS = 25
portf_vol_l = np.zeros((N_POINTS, len_leverage))
portf_rtn_l = np.zeros(( N_POINTS, len_leverage))
weights_ef = np.zeros((len_leverage, N_POINTS, n_assets))
for lev_ind, leverage in enumerate(LEVERAGE_RANGE):
for gamma_ind in range(N_POINTS):
max_leverage.value = leverage
gamma_par.value = gamma_range[gamma_ind]
prob_with_leverage.solve()
portf_vol_l[gamma_ind, lev_ind] = cp.sqrt(portf_vol_cvx).value
portf_rtn_l[gamma_ind, lev_ind] = portf_rtn_cvx.value
weights_ef[lev_ind, gamma_ind, :] = weights.value
在以下代码片段中,我们绘制了不同最大杠杆水平下的有效前沿。我们可以清晰地看到,较高的杠杆提高了回报,同时也带来了更大的波动性。
fig, ax = plt.subplots()
for leverage_index, leverage in enumerate(LEVERAGE_RANGE):
plt.plot(portf_vol_l[:, leverage_index],
portf_rtn_l[:, leverage_index],
label=f"{leverage}")
ax.set(title="Efficient Frontier for different max leverage",
xlabel="Volatility",
ylabel="Expected Returns")
ax.legend(title="Max leverage")
执行代码将生成以下图形。
图 11.16:不同最大杠杆值下的有效前沿
最后,我们还重新绘制了显示不同风险厌恶水平下权重分配的图表。在最大杠杆为 1 的情况下,不允许卖空。
fig, ax = plt.subplots(len_leverage, 1, sharex=True)
for ax_index in range(len_leverage):
weights_df = pd.DataFrame(weights_ef[ax_index],
columns=ASSETS,
index=np.round(gamma_range, 3))
weights_df.plot(kind="bar",
stacked=True,
ax=ax[ax_index],
legend=None)
ax[ax_index].set(
ylabel=(f"max_leverage = {LEVERAGE_RANGE[ax_index]}"
"\n weight")
)
ax[len_leverage - 1].set(xlabel=r"$\gamma$")
ax[0].legend(bbox_to_anchor=(1,1))
ax[0].set_title("Weights allocation per risk aversion level",
fontsize=16)
执行代码片段将生成以下图形。
图 11.17:不同风险厌恶水平和最大杠杆下的资产配置
我们可以发现一个明显的模式:随着风险厌恶的增加,投资者完全停止使用杠杆,并趋向于在所有最大允许杠杆水平下趋向相似的配置。
使用层次风险平价(Hierarchical Risk Parity)找到最优投资组合
De Prado(2018)解释道,二次优化器往往会提供不可靠的解决方案,原因在于其不稳定性、集中性和表现不佳。所有这些问题的主要原因是需要对协方差矩阵进行求逆,而当矩阵数值条件不良时,求逆容易导致较大的误差。他还提到了马科维茨的诅咒,这意味着投资之间相关性越大,对多样化的需求越强,而这又会导致投资组合权重估计误差的加大。
一个潜在的解决方案是引入层次结构,这意味着小的估计误差将不再导致完全不同的配置。这是因为二次优化器可以完全自由地重新调整权重(除非施加了某些明确的约束条件)。
层次风险平价(HRP)是一种创新的投资组合优化方法,它结合了图论和机器学习技术,以便根据协方差矩阵中的信息构建多样化的投资组合。从高层次上看,该算法的工作原理如下:
根据资产的相关性(协方差矩阵)计算距离矩阵。
使用层次聚类(基于距离矩阵)将资产聚类成树形结构。
在树的每个分支中计算最小方差投资组合。
遍历树的各个层次,并合并每个节点上的投资组合。
有关算法的更详细描述,请参见 De Prado(2018)。
我们还提到了一些 HRP 方法的优点:
它充分利用协方差矩阵中的信息,并且不需要对其进行求逆。
它将聚类的资产视为互补关系,而不是替代关系。
算法产生的权重更加稳定且具有鲁棒性。
通过可视化帮助,可以直观地理解这个解决方案。
我们可以添加额外的约束条件。
文献表明,该方法在样本外表现优于经典的均值-方差方法。
在这个示例中,我们应用了层次风险平价算法,从美国十大科技公司股票中构建一个投资组合。
如何操作…
执行以下步骤,以使用 HRP 找到最佳资产配置:
导入库:
import yfinance as yf import pandas as pd from pypfopt.expected_returns import returns_from_prices from pypfopt.hierarchical_portfolio import HRPOpt from pypfopt.discrete_allocation import (DiscreteAllocation, get_latest_prices) from pypfopt import plotting
下载美国十大科技公司的股票价格:
ASSETS = ["AAPL", "MSFT", "AMZN", "GOOG", "META", "V", "NVDA", "MA", "PYPL", "NFLX"] prices_df = yf.download(ASSETS, start="2021-01-01", end="2021-12-31", adjusted=True) prices_df = prices_df["Adj Close"]
从价格计算回报:
rtn_df = returns_from_prices(prices_df)
使用层次风险平价找到最佳配置:
hrp = HRPOpt(returns=rtn_df) hrp.optimize()
显示(已清洗的)权重:
weights = hrp.clean_weights() print(weights)
这会返回以下投资组合权重:
OrderedDict([('AAPL', 0.12992), ('AMZN', 0.156), ('META', 0.08134), ('GOOG', 0.08532), ('MA', 0.10028), ('MSFT', 0.1083), ('NFLX', 0.10164), ('NVDA', 0.04466), ('PYPL', 0.05326), ('V', 0.13928)])
计算投资组合的表现:
hrp.portfolio_performance(verbose=True, risk_free_rate=0);
该方法返回以下评估指标:
Expected annual return: 23.3% Annual volatility: 19.2% Sharpe Ratio: 1.21
可视化用于寻找投资组合权重的层次聚类:
fig, ax = plt.subplots() plotting.plot_dendrogram(hrp, ax=ax) ax.set_title("Dendogram of cluster formation") plt.show()
运行该代码片段会生成如下图表:
图 11.18:可视化聚类过程的树状图
在图 11.18中,我们可以看到 Visa 和 MasterCard 等公司被聚集在一起。在图中,y 轴表示需要合并的两个叶子节点之间的距离。
这是有道理的,因为如果我们想投资像 Visa 这样的美国上市信用卡公司,我们可能会考虑增加或减少对另一家非常相似公司(如 MasterCard)的配置。同样,对于 Google 和 Microsoft 也是如此,尽管这两家公司之间的差异较大。这正是将层次结构应用于资产之间的相关性的核心思想。
使用 50,000 美元找到需要购买的股票数量:
latest_prices = get_latest_prices(prices_df) allocation_finder = DiscreteAllocation(weights, latest_prices, total_portfolio_value=50000) allocation, leftover = allocation_finder.lp_portfolio() print(allocation) print(leftover)
运行该代码片段会打印出建议购买的股票数量和剩余现金的字典:
{'AAPL': 36, 'AMZN': 2, 'META': 12, 'GOOG': 2, 'MA': 14, 'MSFT': 16, 'NFLX': 8, 'NVDA': 7, 'PYPL': 14, 'V': 31}
12.54937744140625
它是如何工作的…
在导入库之后,我们下载了 2021 年美国十大科技公司的股票价格。在第 3 步中,我们使用returns_from_prices
函数创建了一个包含每日股票回报的 DataFrame。
在第 4 步中,我们实例化了HRPOpt
对象并将股票回报传入作为输入。然后,我们使用optimize
方法来找到最优的权重。一个好奇的读者可能会注意到,在描述该算法时,我们提到它是基于协方差矩阵的,而我们却使用了回报序列作为输入。实际上,当我们传入returns
参数时,该类会为我们计算协方差矩阵。或者,我们也可以直接使用cov_matrix
参数传入协方差矩阵。
直接传递协方差矩阵时,我们可以通过使用协方差矩阵的替代形式而非样本协方差来获益。例如,我们可以使用 Ledoit-Wolf 收缩法或oracle 近似收缩(OAS)。你可以在另见部分找到这些方法的参考资料。
然后,我们使用clean_weights
方法显示了清理后的权重。它是一个辅助方法,可以将权重四舍五入到 5 位小数(可以调整),并将低于某一阈值的权重设置为 0。在第 6 步中,我们使用portfolio_performance
方法计算了投资组合的预期表现。在此过程中,我们将默认的无风险利率更改为 0%。
在第 7 步中,我们使用plot_dendogram
函数绘制了层次聚类的结果。该函数生成的图形非常有助于理解算法的工作原理以及哪些资产被聚类在一起。
在第 8 步中,我们根据计算出的权重进行了离散配置。我们假设有 50,000 美元,并希望尽可能多地使用 HRP 权重进行配置。首先,我们从下载的价格中恢复了最新的价格,即 2021-12-30 的价格。然后,我们通过提供权重、最新价格和预算,实例化了DiscreteAllocation
类的一个对象。最后,我们使用lp_portfolio
方法,通过线性规划计算我们应该购买多少股票,同时确保在预算范围内。输出结果为两个对象:一个包含资产及对应股票数量的字典,以及剩余的资金。
线性规划的替代方法是采用贪婪迭代搜索,可以通过greedy_portfolios
方法实现。
还有更多...
PyPortfolioOpt
提供的功能远不止我们所涵盖的内容。例如,它大大简化了获取有效前沿的过程。我们可以通过以下步骤来计算它:
导入库:
from pypfopt.expected_returns import mean_historical_return from pypfopt.risk_models import CovarianceShrinkage from pypfopt.efficient_frontier import EfficientFrontier from pypfopt.plotting import plot_efficient_frontier
获取预期收益和协方差矩阵:
mu = mean_historical_return(prices_df) S = CovarianceShrinkage(prices_df).ledoit_wolf()
正如我们在本章中多次提到的,均值-方差优化需要两个组成部分:资产的预期收益和它们的协方差矩阵。
PyPortfolioOpt
提供了多种计算这两者的方式。虽然我们已经提到过协方差矩阵的替代方法,但你可以使用以下方法来计算预期收益:历史均值收益、指数加权历史均值收益和资本资产定价模型(CAPM)收益估算。在这里,我们计算了历史均值和 Ledoit-Wolf 收缩估计的协方差矩阵。查找并绘制有效前沿:
ef = EfficientFrontier(mu, S) fig, ax = plt.subplots() plot_efficient_frontier(ef, ax=ax, show_assets=True) ax.set_title("Efficient Frontier")
运行该代码片段会生成以下图形:
图 11.19:使用 Ledoit-Wolf 收缩估计的协方差矩阵获得的有效前沿
确定切线投资组合:
ef = EfficientFrontier(mu, S) weights = ef.max_sharpe(risk_free_rate=0) print(ef.clean_weights())
这将返回以下投资组合权重:
OrderedDict([('AAPL', 0.0), ('AMZN', 0.0), ('META', 0.0), ('GOOG', 0.55146), ('MA', 0.0), ('MSFT', 0.11808), ('NFLX', 0.0), ('NVDA', 0.33046), ('PYPL', 0.0), ('V', 0.0)])
EfficientFrontier
类不仅仅用于识别切线投资组合。我们还可以使用以下方法:
min_volatility
:找到具有最小波动率的投资组合。max_quadratic_utility
:在给定风险厌恶程度的情况下,寻找最大化二次效用的组合。这与我们在前一个方法中介绍的方法相同。efficient_risk
:寻找一个在给定目标风险下最大化收益的组合。efficient_return
:寻找一个最小化风险的组合,以达到给定的目标收益。
对于最后两个选项,我们可以生成市场中性组合,即权重和为零的组合。
正如我们之前提到的,我们展示的功能只是冰山一角。使用该库,我们还可以探索以下内容:
融入行业约束:假设你希望拥有一个来自各个行业的股票组合,同时保持一些条件,例如,至少有 20%的资金投入到科技行业。
优化交易成本:如果我们已经有一个组合并且想要重新平衡,完全重新平衡可能会非常昂贵(而且正如我们之前讨论的,均值-方差优化的不稳定性可能是一个很大的缺点)。在这种情况下,我们可以增加一个额外的目标,以在尽量降低交易成本的同时重新平衡组合。
在优化组合时使用 L2 正则化:通过使用正则化,我们可以防止许多权重归零的情况。我们可以尝试不同的 gamma 参数值,找到最适合我们的配置。你可能已经通过著名的岭回归算法熟悉了 L2 正则化。
使用 Black-Litterman 模型可以得到比仅使用历史平均收益更稳定的预期收益模型。这是一种贝叶斯方法,通过将收益的先验估计与对某些资产的看法结合,得出后验的预期收益估计。
在 GitHub 上的笔记本中,你还可以找到一些简短的例子,演示如何在允许做空或使用 L2 正则化的情况下找到有效前沿。
你还可以尝试不使用预期收益。文献表明,由于难以准确估计预期收益,最小方差组合在样本外的表现通常优于最大夏普比率组合。
另请参见
与该方法相关的附加资源:
Black, F; & Litterman, R. 1991. “Combining investor views with market equilibrium,” The Journal of Fixed Income, 1, (2): 7-18: https://doi.org/10.3905/jfi.1991.408013
Black, F., & Litterman, R. 1992. “Global portfolio optimization,” Financial Analysts Journal, 48(5): 28-43
Chen, Y., Wiesel, A., Eldar, Y. C., & Hero, A. O. 2010. “Shrinkage Algorithms for MMSE Covariance Estimation,” IEEE Transactions on Signal Processing, 58(10): 5016-5029:
doi.org/10.1109/TSP.2010.2053029
De Prado, M. L. 2016. “构建超越样本外表现的多元化投资组合,” 投资组合管理期刊, 42(4): 59-69: https://doi.org/10.3905/jpm.2016.42.4.059.
De Prado, M. L. 2018. 金融机器学习的进展。John Wiley & Sons
Ledoit, O., & Wolf, M. 2003 “Improved estimation of the covariance matrix of stock returns with an application to portfolio selection,” 实证金融期刊, 10(5): 603-621
Ledoit, O., & Wolf, M. 2004. “Honey, I shrunk the sample covariance matrix,” 投资组合管理期刊, 30(4): 110-119: https://doi.org/10.3905/jpm.2004.110
总结
在本章中,我们学习了资产配置的相关内容。我们从最简单的等权重投资组合开始,证明了即使采用先进的优化技术,想要超越这种组合也相当困难。然后,我们探讨了通过均值-方差优化来计算有效前沿的各种方法。最后,我们还简单介绍了资产配置领域的一些最新进展,即分层风险平价算法。
如果你想深入了解如何用 Python 进行资产配置,以下参考资料可能会对你有帮助:
Riskfolio-Lib
(github.com/dcajasn/Riskfolio-Lib
): 另一个流行的投资组合优化库,包含多种算法和评估指标。deepdow
(github.com/jankrepl/deepdow
): 一个将投资组合优化与深度学习相结合的 Python 库。
在下一章中,我们将介绍多种回测交易和资产配置策略的方法。
第十二章:回测交易策略
在前面的章节中,我们获得了创建交易策略所需的知识。一方面,我们可以利用技术分析来识别交易机会;另一方面,我们可以使用书中已覆盖的其他一些技术。我们可以尝试使用因子模型或波动率预测的知识,或者使用投资组合优化技术来确定我们投资的最佳资产数量。仍然缺少的一个关键点是评估如果我们在过去实施这样的策略,它会如何表现。这就是回测的目标,本章将深入探讨这一点。
回测可以被描述为对我们的交易策略进行的现实模拟,通过使用历史数据来评估其表现。其基本思想是,回测的表现应能代表未来当策略真正应用于市场时的表现。当然,这种情况并不总是如此,我们在实验时应该牢记这一点。
回测有多种方法,但我们应该始终记住,回测应该真实地反映市场如何运作、交易如何执行、可用的订单是什么等等。例如,忘记考虑交易成本可能会迅速将一个“有利可图”的策略变成一个失败的实验。
我们已经提到过,在不断变化的金融市场中,预测的普遍不确定性。然而,还有一些实施方面的因素可能会影响回测结果,增加将样本内表现与可推广的模式混淆的风险。我们简要提到以下一些因素:
前瞻偏差:这种潜在的缺陷出现在我们使用历史数据开发交易策略时,数据在实际使用之前就已知或可用。一些例子包括在财务报告发布后进行的修正、股票拆分或反向拆分。
存活偏差:这种偏差出现在我们仅使用当前仍然活跃/可交易的证券数据进行回测时。通过这样做,我们忽略了那些随着时间的推移消失的资产(例如破产、退市、收购等)。大多数时候,这些资产表现不佳,而我们的策略可能会因未能包括这些资产而发生偏差,因为这些资产在过去仍然可在市场中被选择。
异常值检测与处理:主要的挑战是辨别那些不代表分析期的异常值,而不是那些市场行为中不可或缺的一部分。
代表性样本周期:由于回测的目标是提供对未来表现的指示,因此样本数据应该反映当前的市场行为,并且可能还要反映未来的市场行为。如果在这一部分花费的时间不够,我们可能会错过一些关键的市场环境特征,比如波动性(极端事件过少/过多)或交易量(数据点过少)。
随着时间推移实现投资目标和约束:有时,一个策略可能在评估期的最后阶段表现良好。然而,在它活跃的某些阶段,可能会导致不可接受的高损失或高波动性。我们可以通过使用滚动绩效/风险指标来跟踪这些情况,例如风险价值(value-at-risk)或夏普比率/索提诺比率(Sharpe/Sortino ratio)。
现实的交易环境:我们已经提到过,忽略交易成本可能会极大地影响回测的最终结果。更重要的是,现实中的交易还涉及更多的复杂因素。例如,可能无法在任何时候或以目标价格执行所有交易。需要考虑的一些因素包括滑点(交易预期价格与实际执行价格之间的差异)、空头头寸的对手方可用性、经纪费用等。现实环境还考虑到一个事实,即我们可能基于某一日的收盘价做出交易决策,但交易可能(有可能)会基于下一交易日的开盘价执行。由于价格差异较大,我们准备的订单可能无法执行。
多重测试:在进行多个回测时,我们可能会发现虚假的结果,或者某个策略过度拟合了测试样本,导致产生不太可能在实际交易中有效的异常正面结果。此外,我们可能会在策略设计中泄露出关于什么有效、什么无效的先验知识,这可能导致进一步的过度拟合。我们可以考虑的一些方法包括:报告试验次数、计算最小回测长度、使用某种最优停止规则,或者计算考虑多重测试影响的指标(例如,通货膨胀的夏普比率)。
在本章中,我们展示了如何使用两种方法——向量化和事件驱动——对各种交易策略进行回测。我们稍后会详细介绍每种方法,但现在我们可以说,第一种方法适合于快速测试,以了解策略是否有潜力。另一方面,第二种方法更适合于进行彻底且严格的测试,因为它试图考虑上述提到的许多潜在问题。
本章的关键学习内容是如何使用流行的 Python 库来设置回测。我们将展示一些基于流行技术指标构建的策略示例,或使用均值方差投资组合优化的策略。掌握这些知识后,你可以回测任何自己想到的策略。
本章介绍了以下几个回测示例:
使用 pandas 进行矢量化回测
使用 backtrader 进行事件驱动回测
回测基于 RSI 的多空策略
回测基于布林带的买卖策略
使用加密数据回测移动平均交叉策略
回测均值方差投资组合优化
使用 pandas 进行矢量化回测
正如我们在本章的介绍中提到的,回测有两种方法。较简单的一种叫做矢量化回测。在这种方法中,我们将信号向量/矩阵(包含我们是开仓还是平仓的指标)与回报向量相乘。通过这样做,我们可以计算某一时间段内的表现。
由于其简单性,这种方法无法处理我们在介绍中提到的许多问题,例如:
我们需要手动对齐时间戳,以避免未来数据偏倚。
没有明确的头寸大小控制。
所有的性能度量都在回测的最后手动计算。
像止损这样的风险管理规则不容易纳入。
因此,如果我们处理的是简单的交易策略并希望用少量代码探索其初步潜力,我们应该主要使用矢量化回测。
在这个示例中,我们回测了一个非常简单的策略,规则集如下:
当收盘价高于 20 日简单移动平均线(SMA)时,我们会开仓做多。
当收盘价跌破 20 日 SMA 时,我们会平仓。
不允许卖空。
该策略与单位无关(我们可以持有 1 股或 1000 股),因为我们只关心价格的百分比变化
我们使用苹果公司股票及其 2016 到 2021 年的历史价格对该策略进行回测。
如何实现…
执行以下步骤以使用矢量化方法回测一个简单的策略:
导入相关库:
import pandas as pd import yfinance as yf import numpy as np
下载 2016 到 2021 年间苹果公司的股票价格,并只保留调整后的收盘价:
df = yf.download("AAPL", start="2016-01-01", end="2021-12-31", progress=False) df = df[["Adj Close"]]
计算收盘价的对数收益率和 20 日 SMA:
df["log_rtn"] = df["Adj Close"].apply(np.log).diff(1) df["sma_20"] = df["Adj Close"].rolling(window=20).mean()
创建一个头寸指标:
df["position"] = (df["Adj Close"] > df["sma_20"]).astype(int)
使用以下代码片段,我们计算了进入多头头寸的次数:
sum((df["position"] == 1) & (df["position"].shift(1) == 0))
答案是 56。
可视化 2021 年的策略:
fig, ax = plt.subplots(2, sharex=True) df.loc["2021", ["Adj Close", "sma_20"]].plot(ax=ax[0]) df.loc["2021", "position"].plot(ax=ax[1]) ax[0].set_title("Preview of our strategy in 2021")
执行该代码片段会生成以下图形:
图 12.1:基于简单移动平均线的交易策略预览
在 图 12.1 中,我们可以清楚地看到策略的运作——当收盘价高于 20 日 SMA 时,我们确实持有仓位。这由包含持仓信息的列中的值 1 所表示。
计算该策略的每日和累计收益:
df["strategy_rtn"] = df["position"].shift(1) * df["log_rtn"] df["strategy_rtn_cum"] = ( df["strategy_rtn"].cumsum().apply(np.exp) )
添加买入并持有策略进行比较:
df["bh_rtn_cum"] = df["log_rtn"].cumsum().apply(np.exp)
绘制策略的累计收益:
( df[["bh_rtn_cum", "strategy_rtn_cum"]] .plot(title="Cumulative returns") )
执行该代码片段生成了如下图表:
图 12.2:我们的策略和买入并持有基准的累计收益
在 图 12.2 中,我们可以看到两种策略的累计收益。初步结论可能是,简单策略在考虑的时间段内表现优于买入并持有策略。然而,这种简化的回测形式并未考虑许多关键方面(例如,使用收盘价交易、假设没有滑点和交易成本等),这些因素可能会显著改变最终结果。在 更多内容... 部分,我们将看到当我们仅考虑交易成本时,结果如何迅速发生变化。
它是如何工作的……
一开始,我们导入了相关库并下载了 2016 到 2021 年间苹果公司的股票价格。我们只保留了调整后的收盘价用于回测。
在 第 3 步 中,我们计算了对数收益和 20 日 SMA。为了计算该技术指标,我们使用了 pandas
DataFrame 的 rolling
方法。然而,我们也可以使用之前探讨过的 TA-Lib
库来实现。
我们计算了对数收益,因为它具有随着时间累加的便利性。如果我们持有仓位 10 天并关注该仓位的最终收益,我们可以简单地将这 10 天的对数收益相加。如需更多信息,请参见 第二章,数据预处理。
在 第 4 步 中,我们创建了一个列,用于标明我们是否有开仓(仅多头)或者没有开仓。正如我们决定的那样,当收盘价高于 20 日 SMA 时,我们开仓。当收盘价低于 SMA 时,我们平仓。我们还将该列编码为整数。在 第 5 步 中,我们绘制了收盘价、20 日 SMA 和包含持仓标志的列。为了使图表更加易读,我们只绘制了 2021 年的数据。
第 6 步 是向量化回测中最重要的一步。在这一部分,我们计算了策略的每日和累计收益。为了计算每日收益,我们将当天的对数收益与平移后的持仓标志相乘。为了避免未来数据偏差,持仓向量被平移了 1 天。换句话说,标志是利用截至并包括时间 t 的所有信息生成的。我们只能利用这些信息在下一个交易日(即时间 t+1)开仓。
一位好奇的读者可能已经发现我们回测中出现的另一种偏差。我们正确地假设只能在下一个交易日买入,然而,日志回报是按我们在 t+1 日使用 t 日的收盘价买入来计算的,这在某些市场条件下可能非常不准确。我们将在接下来的示例中看到如何通过事件驱动回测来克服这个问题。
然后,我们使用了 cumsum
方法计算日志回报的累计和,这对应于累计回报。最后,我们通过 apply
方法应用了指数函数。
在 步骤 7 中,我们计算了买入持有策略的累计回报。对于这个策略,我们只是使用了日志回报进行计算,省略了将回报与持仓标志相乘的步骤。
在最后一步,我们绘制了两种策略的累计回报。
还有更多...
从最初的回测结果来看,简单策略的表现优于买入持有策略。但我们也看到,在这 6 年中,我们已经进场 56 次。总交易次数翻倍,因为我们还退出了这些仓位。根据经纪商的不同,这可能导致相当可观的交易成本。
由于交易成本通常以固定百分比报价,我们可以简单地计算投资组合在连续时间步之间的变化量,基于此计算交易成本,然后直接从策略回报中减去这些成本。
在下面的步骤中,我们展示了如何在向量化回测中考虑交易成本。为了简单起见,我们假设交易成本为 1%。
执行以下步骤以在向量化回测中考虑交易成本:
计算每日交易成本:
TRANSACTION_COST = 0.01 df["tc"] = df["position"].diff(1).abs() * TRANSACTION_COST
在这个代码片段中,我们计算了投资组合是否发生变化(绝对值,因为我们可能会进场或退出仓位),然后将该值乘以以百分比表示的交易成本。
计算考虑交易成本后的策略表现:
df["strategy_rtn_cum_tc"] = ( (df["strategy_rtn"] - df["tc"]).cumsum().apply(np.exp) )
绘制所有策略的累计回报:
STRATEGY_COLS = ["bh_rtn_cum", "strategy_rtn_cum", "strategy_rtn_cum_tc"] ( df .loc[:, STRATEGY_COLS] .plot(title="Cumulative returns") )
执行代码片段会生成以下图形:
图 12.3:所有策略的累计回报,包括考虑交易成本的策略
在考虑交易成本后,表现明显下降,甚至不如买入持有策略。而且为了公平起见,我们也应该考虑买入持有策略中的初始和终端交易成本,因为我们必须进行一次买入和一次卖出。
使用 backtrader 进行事件驱动回测
回测的第二种方法称为事件驱动回测。在这种方法中,回测引擎模拟交易环境的时间维度(你可以把它看作一个遍历时间的 for
循环,顺序执行所有操作)。这对回测施加了更多结构,包括使用历史日历来定义交易实际执行的时间、价格何时可用等。
基于事件驱动的回测旨在模拟执行某个策略时遇到的所有操作和约束,同时比向量化方法提供更多灵活性。例如,这种方法允许模拟订单执行中的潜在延迟、滑点成本等。在理想情况下,为事件驱动回测编写的策略可以轻松转换为适用于实时交易引擎的策略。
目前,有相当多的事件驱动回测库可供 Python 使用。本章介绍了其中一个最流行的库——backtrader
。该框架的主要特性包括:
提供大量可用的技术指标(
backtrader
还提供了对流行的 TA-Lib 库的封装)和绩效衡量标准。容易构建和应用新的指标。
提供多个数据源(包括 Yahoo Finance 和 Nasdaq Data Link),并支持加载外部文件。
模拟许多真实经纪商的方面,如不同类型的订单(市价单、限价单、止损单)、滑点、佣金、做多/做空等。
对价格、技术指标、交易信号、绩效等进行全面和互动式的可视化。
与选定的经纪商进行实时交易。
对于这个食谱,我们考虑了一个基于简单移动平均的基础策略。实际上,它几乎与我们在前一个食谱中使用向量化方法回测的策略完全相同。该策略的逻辑如下:
当收盘价高于 20 日均线时,买入一股。
当收盘价低于 20 日均线且我们持有股票时,卖出它。
我们在任何给定时间只能持有最多一股。
不允许卖空。
我们使用 2021 年的苹果股票价格进行该策略的回测。
准备就绪
在这个食谱中(以及本章的其余部分),我们将使用两个用于打印日志的辅助函数——get_action_log_string
和 get_result_log_string
。此外,我们将使用一个自定义的 MyBuySell
观察者来以不同颜色显示持仓标记。你可以在 GitHub 上的 strategy_utils.py
文件中找到这些辅助函数的定义。
在编写本文时,PyPI(Python 包索引)上提供的 backtrader
版本并非最新版本。通过简单的 pip install backtrader
命令安装时,会安装一个包含一些问题的版本,例如,无法正确加载来自 Yahoo Finance 的数据。为了解决这个问题,您应该从 GitHub 安装最新版本。您可以使用以下代码片段来完成安装:
pip install git+https://github.com/mementum/backtrader.git#egg=backtrader
如何实现...
执行以下步骤来使用事件驱动的方法回测一个简单的策略:
导入库:
from datetime import datetime import backtrader as bt from backtrader_strategies.strategy_utils import *
从 Yahoo Finance 下载数据:
data = bt.feeds.YahooFinanceData(dataname="AAPL", fromdate=datetime(2021, 1, 1), todate=datetime(2021, 12, 31))
为了使代码更具可读性,我们首先展示定义交易策略的类的一般轮廓,然后在以下子步骤中介绍各个方法。
策略的模板如下所示:
class SmaStrategy(bt.Strategy): params = (("ma_period", 20), ) def __init__(self): # some code def log(self, txt): # some code def notify_order(self, order): # some code def notify_trade(self, trade): # some code def next(self): # some code def start(self): # some code def stop(self): # some code
__init__
方法定义如下:def __init__(self): # keep track of close price in the series self.data_close = self.datas[0].close # keep track of pending orders self.order = None # add a simple moving average indicator self.sma = bt.ind.SMA(self.datas[0], period=self.params.ma_period)
log
方法定义如下:def log(self, txt): dt = self.datas[0].datetime.date(0).isoformat() print(f"{dt}: {txt}")
notify_order
方法定义如下:def notify_order(self, order): if order.status in [order.Submitted, order.Accepted]: # order already submitted/accepted # no action required return # report executed order if order.status in [order.Completed]: direction = "b" if order.isbuy() else "s" log_str = get_action_log_string( dir=direction, action="e", price=order.executed.price, size=order.executed.size, cost=order.executed.value, commission=order.executed.comm ) self.log(log_str) # report failed order elif order.status in [order.Canceled, order.Margin, order.Rejected]: self.log("Order Failed") # reset order -> no pending order self.order = None
notify_trade
方法定义如下:def notify_trade(self, trade): if not trade.isclosed: return self.log( get_result_log_string( gross=trade.pnl, net=trade.pnlcomm ) )
next
方法定义如下:def next(self): # do nothing if an order is pending if self.order: return # check if there is already a position if not self.position: # buy condition if self.data_close[0] > self.sma[0]: self.log( get_action_log_string( "b", "c", self.data_close[0], 1 ) ) self.order = self.buy() else: # sell condition if self.data_close[0] < self.sma[0]: self.log( get_action_log_string( "s", "c", self.data_close[0], 1 ) ) self.order = self.sell()
start
和stop
方法定义如下:def start(self): print(f"Initial Portfolio Value: {self.broker.get_value():.2f}") def stop(self): print(f"Final Portfolio Value: {self.broker.get_value():.2f}")
设置回测:
cerebro = bt.Cerebro(stdstats=False) cerebro.adddata(data) cerebro.broker.setcash(1000.0) cerebro.addstrategy(SmaStrategy) cerebro.addobserver(MyBuySell) cerebro.addobserver(bt.observers.Value)
运行回测:
cerebro.run()
运行该代码片段会生成以下(简化的)日志:
Initial Portfolio Value: 1000.00 2021-02-01: BUY CREATED - Price: 133.15, Size: 1.00 2021-02-02: BUY EXECUTED - Price: 134.73, Size: 1.00, Cost: 134.73, Commission: 0.00 2021-02-11: SELL CREATED - Price: 134.33, Size: 1.00 2021-02-12: SELL EXECUTED - Price: 133.56, Size: -1.00, Cost: 134.73, Commission: 0.00 2021-02-12: OPERATION RESULT - Gross: -1.17, Net: -1.17 2021-03-16: BUY CREATED - Price: 124.83, Size: 1.00 2021-03-17: BUY EXECUTED - Price: 123.32, Size: 1.00, Cost: 123.32, Commission: 0.00 ... 2021-11-11: OPERATION RESULT - Gross: 5.39, Net: 5.39 2021-11-12: BUY CREATED - Price: 149.80, Size: 1.00 2021-11-15: BUY EXECUTED - Price: 150.18, Size: 1.00, Cost: 150.18, Commission: 0.00 Final Portfolio Value: 1048.01
日志包含关于所有已创建和执行的交易的信息,以及在头寸被关闭时的操作结果。
绘制结果:
cerebro.plot(iplot=True, volume=False)
运行该代码片段会生成以下图表:
图 12.4:回测期间我们策略行为/表现的总结
在 图 12.4 中,我们可以看到苹果公司的股票价格、20 日简单移动平均线(SMA)、买卖订单,以及我们投资组合价值随时间的变化。正如我们所见,该策略在回测期间赚取了 48 美元。在考虑绩效时,请记住,该策略仅操作单一股票,同时将大部分可用资源保持为现金。
它是如何工作的...
使用 backtrader
的关键概念是,回测的核心大脑是 Cerebro
,通过使用不同的方法,我们为它提供历史数据、设计的交易策略、我们希望计算的附加指标(例如投资期内的投资组合价值,或者整体的夏普比率)、佣金/滑点信息等。
创建策略有两种方式:使用信号(bt.Signal
)或定义完整的策略(bt.Strategy
)。这两种方式会产生相同的结果,然而,较长的方式(通过 bt.Strategy
创建)会提供更多关于实际发生的操作的日志记录。这使得调试更容易,并且能够跟踪所有操作(日志的详细程度取决于我们的需求)。因此,我们在本篇中首先展示这种方法。
您可以在本书的 GitHub 仓库中找到使用信号方法构建的等效策略。
在 步骤 1 中导入库和辅助函数之后,我们使用 bt.feeds.YahooFinanceData
函数从 Yahoo Finance 下载了价格数据。
你还可以从 CSV 文件、pandas
DataFrame、纳斯达克数据链接(Nasdaq Data Link)以及其他来源添加数据。有关可用选项的列表,请参考bt.feeds
的文档。我们在 GitHub 的 Notebook 中展示了如何从pandas
DataFrame 加载数据。
在步骤 3中,我们将交易策略定义为继承自bt.Strategy
的类。在类中,我们定义了以下方法(我们实际上是覆盖了这些方法,以便根据我们的需求量身定制):
__init__
:在此方法中,我们定义了希望跟踪的对象。在我们的示例中,这些对象包括收盘价、订单的占位符和技术分析指标(SMA)。log
:此方法用于日志记录,记录日期和提供的字符串。我们使用辅助函数get_action_log_string
和get_result_log_string
来创建包含各种订单相关信息的字符串。notify_order
:此方法报告订单(仓位)的状态。通常,在第t天,指标可以根据收盘价建议开盘/平仓(假设我们使用的是日数据)。然后,市场订单将在下一个交易日(使用t+1时刻的开盘价)执行。然而,无法保证订单会被执行,因为它可能会被取消或我们可能没有足够的现金。此方法还通过设置self.order = None
来取消任何挂单。notify_trade
:此方法报告交易结果(在仓位关闭后)。next
:此方法包含了交易策略的逻辑。首先,我们检查是否已有挂单,如果有则不做任何操作。第二个检查是查看是否已有仓位(由我们的策略强制执行,这不是必须的),如果没有仓位,我们检查收盘价是否高于移动平均线。如果结果是正面的,我们将进入日志并使用self.order = self.buy()
下达买入订单。这也是我们选择购买数量(我们想要购买的资产数量)的地方。默认值为 1(等同于使用self.buy(size=1)
)。start
/stop
:这些方法在回测的开始/结束时执行,可以用于报告投资组合的价值等操作。
在步骤 4中,我们设置了回测,即执行了一系列与 Cerebro 相关的操作:
我们创建了
bt.Cerebro
的实例并设置stdstats=False
,以抑制许多默认的图表元素。这样,我们避免了输出的冗余,而是手动选择了感兴趣的元素(观察者和指标)。我们使用
adddata
方法添加了数据。我们使用
broker
的setcash
方法设置了可用资金的数量。我们使用
addstrategy
方法添加了策略。我们使用
addobserver
方法添加了观察者。我们选择了两个观察者:自定义的BuySell
观察者,用于在图表上显示买入/卖出决策(由绿色和红色三角形表示),以及Value
观察者,用于跟踪投资组合价值随时间的变化。
最后一步是通过 cerebro.run()
运行回测,并通过 cerebro.plot()
绘制结果。在这一步骤中,我们禁用了显示交易量图表,以避免图表杂乱。
关于使用 backtrader
进行回测的几点补充说明:
根据设计,
Cerebro
应该只使用一次。如果我们想要运行另一个回测,应该创建一个新的实例,而不是在开始计算后再往其中添加内容。通常,使用
bt.Signal
构建的策略只使用一个信号。然而,我们可以通过使用bt.SignalStrategy
来基于不同的条件组合多个信号。如果我们没有特别指定,所有订单都将以一个单位的资产进行。
backtrader
会自动处理热身期。在此期间,无法进行交易,直到有足够的数据点来计算 20 天的简单移动平均线(SMA)。当同时考虑多个指标时,backtrader
会自动选择最长的必要周期。
还有更多...
值得一提的是,backtrader
具有参数优化功能,以下代码展示了这一功能。该代码是本策略的修改版本,我们优化了用于计算 SMA 的天数。
在调整策略参数值时,您可以创建一个简化版的策略,不记录过多信息(例如起始值、创建/执行订单等)。您可以在 sma_strategy_optimization.py
脚本中找到修改后策略的示例。
以下列表提供了代码修改的详细信息(我们只展示相关部分,因为大部分代码与之前使用的代码相同):
我们不使用
cerebro.addstrategy
,而是使用cerebro.optstrategy
,并提供定义的策略对象和参数值范围:cerebro.optstrategy(SmaStrategy, ma_period=range(10, 31))
我们修改了
stop
方法,使其也记录ma_period
参数的考虑值。在运行扩展回测时,我们增加了 CPU 核心数:
cerebro.run(maxcpus=4)
我们在以下总结中展示了结果(请记住,当使用多个核心时,参数的顺序可能会被打乱):
2021-12-30: (ma_period = 10) --- Terminal Value: 1018.82
2021-12-30: (ma_period = 11) --- Terminal Value: 1022.45
2021-12-30: (ma_period = 12) --- Terminal Value: 1022.96
2021-12-30: (ma_period = 13) --- Terminal Value: 1032.44
2021-12-30: (ma_period = 14) --- Terminal Value: 1027.37
2021-12-30: (ma_period = 15) --- Terminal Value: 1030.53
2021-12-30: (ma_period = 16) --- Terminal Value: 1033.03
2021-12-30: (ma_period = 17) --- Terminal Value: 1038.95
2021-12-30: (ma_period = 18) --- Terminal Value: 1043.48
2021-12-30: (ma_period = 19) --- Terminal Value: 1046.68
2021-12-30: (ma_period = 20) --- Terminal Value: 1048.01
2021-12-30: (ma_period = 21) --- Terminal Value: 1044.00
2021-12-30: (ma_period = 22) --- Terminal Value: 1046.98
2021-12-30: (ma_period = 23) --- Terminal Value: 1048.62
2021-12-30: (ma_period = 24) --- Terminal Value: 1051.08
2021-12-30: (ma_period = 25) --- Terminal Value: 1052.44
2021-12-30: (ma_period = 26) --- Terminal Value: 1051.30
2021-12-30: (ma_period = 27) --- Terminal Value: 1054.78
2021-12-30: (ma_period = 28) --- Terminal Value: 1052.75
2021-12-30: (ma_period = 29) --- Terminal Value: 1045.74
2021-12-30: (ma_period = 30) --- Terminal Value: 1047.60
我们发现,当使用 27 天计算 SMA 时,策略表现最佳。
我们应该始终牢记,调整策略的超参数会带来更高的过拟合风险!
另见
您可以参考以下书籍,以获取有关算法交易和构建成功交易策略的更多信息:
- Chan, E. (2013). Algorithmic Trading: Winning Strategies and Their Rationale (第 625 卷)。John Wiley & Sons 出版社。
基于 RSI 的多空策略回测
相对强弱指数 (RSI) 是一个利用资产的收盘价来识别超买/超卖状态的指标。通常,RSI 使用 14 天的时间段进行计算,范围从 0 到 100(它是一个振荡器)。交易者通常在 RSI 低于 30 时买入资产(超卖),在 RSI 高于 70 时卖出资产(超买)。较极端的高/低水平,如 80-20,使用得较少,并且通常意味着更强的市场动能。
在这个示例中,我们构建了一个遵循以下规则的交易策略:
我们可以同时做多仓和空仓。
计算 RSI 时,我们使用 14 个周期(交易日)。
当 RSI 突破下限(标准值为 30)向上时,进入多仓;当 RSI 大于中位数(值为 50)时,退出仓位。
当 RSI 突破上限(标准值为 70)向下时,进入空仓;当 RSI 小于 50 时,退出仓位。
一次只能开一个仓位。
我们在 2021 年对 Meta 的股票进行策略评估,并应用 0.1%的佣金。
如何操作……
执行以下步骤以实现并回测基于 RSI 的策略:
导入库:
from datetime import datetime import backtrader as bt from backtrader_strategies.strategy_utils import *
基于
bt.SignalStrategy
定义信号策略:class RsiSignalStrategy(bt.SignalStrategy): params = dict(rsi_periods=14, rsi_upper=70, rsi_lower=30, rsi_mid=50) def __init__(self): # add RSI indicator rsi = bt.indicators.RSI(period=self.p.rsi_periods, upperband=self.p.rsi_upper, lowerband=self.p.rsi_lower) # add RSI from TA-lib just for reference bt.talib.RSI(self.data, plotname="TA_RSI") # long condition (with exit) rsi_signal_long = bt.ind.CrossUp( rsi, self.p.rsi_lower, plot=False ) self.signal_add(bt.SIGNAL_LONG, rsi_signal_long) self.signal_add( bt.SIGNAL_LONGEXIT, -(rsi > self.p.rsi_mid) ) # short condition (with exit) rsi_signal_short = -bt.ind.CrossDown( rsi, self.p.rsi_upper, plot=False ) self.signal_add(bt.SIGNAL_SHORT, rsi_signal_short) self.signal_add( bt.SIGNAL_SHORTEXIT, rsi < self.p.rsi_mid )
下载数据:
data = bt.feeds.YahooFinanceData(dataname="META", fromdate=datetime(2021, 1, 1), todate=datetime(2021, 12, 31))
设置并运行回测:
cerebro = bt.Cerebro(stdstats=False) cerebro.addstrategy(RsiSignalStrategy) cerebro.adddata(data) cerebro.addsizer(bt.sizers.SizerFix, stake=1) cerebro.broker.setcash(1000.0) cerebro.broker.setcommission(commission=0.001) cerebro.addobserver(MyBuySell) cerebro.addobserver(bt.observers.Value) print( f"Starting Portfolio Value: {cerebro.broker.getvalue():.2f}" ) cerebro.run() print( f"Final Portfolio Value: {cerebro.broker.getvalue():.2f}" )
运行代码片段后,我们看到以下输出:
Starting Portfolio Value: 1000.00 Final Portfolio Value: 1042.56
绘制结果:
cerebro.plot(iplot=True, volume=False)
运行代码片段生成如下图表:
图 12.5:我们策略在回测期间的行为/表现总结
我们观察成对的三角形。每对中的第一个三角形表示开仓(如果三角形是绿色且朝上,则开多仓;如果三角形是红色且朝下,则开空仓)。接下来的相反方向的三角形表示平仓。我们可以将开仓和平仓与图表下方的 RSI 对照。有时,同色的三角形会连续出现。这是因为 RSI 在开仓线附近波动,多次穿越该线。但实际的开仓仅发生在信号首次出现时(默认情况下,所有回测不进行累积)。
它是如何工作的……
在这个示例中,我们展示了在backtrader
中定义策略的第二种方法,即使用信号。信号表现为一个数字,例如当前数据点与某个技术分析指标的差值。如果信号为正,表示开多仓(买入)。如果信号为负,表示开空仓(卖出)。信号值为 0 表示没有信号。
导入库和辅助函数后,我们使用bt.SignalStrategy
定义了交易策略。由于这是一个涉及多个信号(各种进出场条件)的策略,我们不得不使用bt.SignalStrategy
而不是简单的bt.Signal
。首先,我们定义了指标(RSI),并选择了相应的参数。我们还添加了第二个 RSI 指标实例,仅仅是为了展示backtrader
提供了一个简便的方式来使用流行的 TA-Lib 库中的指标(必须安装该库才能使代码正常工作)。该交易策略并不依赖于第二个指标——它仅用于参考绘图。通常来说,我们可以添加任意数量的指标。
即使仅添加指标作为参考,它们的存在也会影响“预热期”。例如,如果我们额外包括了一个 200 日 SMA 指标,则在 SMA 指标至少有一个值之前,任何交易都不会执行。
下一步是定义信号。为此,我们使用了bt.CrossUp
/bt.CrossDown
指标,当第一个系列(价格)从下方/上方穿越第二个系列(RSI 的上限或下限)时,分别返回 1。为了进入空仓,我们通过在bt.CrossDown
指标前加上负号来使信号变为负值。
我们可以通过在函数调用中添加plot=False
来禁用任何指标的打印。
以下是可用信号类型的描述:
LONGSHORT
: 此类型同时考虑了来自信号的多仓和空仓指示。LONG
: 正向信号表示开多仓;负向信号用于平多仓。SHORT
: 负向信号表示开空仓;正向信号用于平空仓。LONGEXIT
: 负向信号用于平多仓。SHORTEXIT
: 正向信号用于平空仓。
平仓可能更为复杂,这反过来允许用户构建更复杂的策略。我们在下面描述了其逻辑:
LONG
: 如果出现LONGEXIT
信号,则用于平多仓,而不是上面提到的行为。如果出现SHORT
信号且没有LONGEXIT
信号,则使用SHORT
信号先平多仓,然后再开空仓。SHORT
: 如果出现SHORTEXIT
信号,则用于平空仓,而不是上面提到的行为。如果出现LONG
信号且没有SHORTEXIT
信号,则使用LONG
信号先平空仓,然后再开多仓。
正如你可能已经意识到的,信号会在每个时间点计算(如图表底部所示),这实际上会创建一个连续的开盘/平仓信号流(信号值为 0 的情况不太可能发生)。因此,backtrader
默认禁用累积(即使已有仓位,也不断开新仓)和并发(在没有收到经纪商反馈之前生成新订单)。
在定义策略的最后一步,我们通过使用signal_add
方法跟踪所有信号。对于平仓,我们使用的条件(RSI 值高于/低于 50)会产生一个布尔值,当退出多头仓位时,我们必须将其取反:在 Python 中,-True
与-1
的意义相同。
在步骤 3中,我们下载了 2021 年 Meta 的股票价格。
然后,我们设置了回测。大部分步骤应该已经很熟悉了,因此我们只关注新的部分:
使用
addsizer
方法添加一个 Sizer——在这一点上我们不必这么做,因为backtrader
默认使用 1 的头寸,也就是说,每次交易会买卖 1 单位资产。然而,我们希望展示在使用信号法创建交易策略时,在哪个时刻可以修改订单大小。使用
broker
的setcommission
方法将佣金设置为 0.1%。我们还在回测运行前后访问并打印了投资组合的当前价值。为此,我们使用了
broker
的getvalue
方法。
在最后一步,我们绘制了回测结果。
还有更多……
在这个示例中,我们向回测框架引入了几个新概念——Sizer 和佣金。使用这两个组件,我们可以进行更多有趣的实验。
全部押注
之前,我们的简单策略仅仅是以单个单位的资产进行多头或空头操作。然而,我们可以轻松修改这一行为,利用所有可用的现金。我们只需通过addsizer
方法添加AllInSizer
Sizer:
cerebro = bt.Cerebro(stdstats=False)
cerebro.addstrategy(RsiSignalStrategy)
cerebro.adddata(data)
cerebro.addsizer(bt.sizers.AllInSizer)
cerebro.broker.setcash(1000.0)
cerebro.broker.setcommission(commission=0.001)
cerebro.addobserver(bt.observers.Value)
print(f"Starting Portfolio Value: {cerebro.broker.getvalue():.2f}")
cerebro.run()
print(f"Final Portfolio Value: {cerebro.broker.getvalue():.2f}")
运行回测生成了以下结果:
Starting Portfolio Value: 1000.00
Final Portfolio Value: 1183.95
结果显然比我们每次只使用单个单位时的表现要好。
每股固定佣金
在我们对基于 RSI 的策略进行初步回测时,我们使用了 0.1%的佣金费用。然而,一些经纪商可能有不同的佣金方案,例如,每股固定佣金。
为了融入这些信息,我们需要定义一个自定义类来存储佣金方案。我们可以从bt.CommInfoBase
继承并添加所需的信息:
class FixedCommissionShare(bt.CommInfoBase):
"""
Scheme with fixed commission per share
"""
params = (
("commission", 0.03),
("stocklike", True),
("commtype", bt.CommInfoBase.COMM_FIXED),
)
def _getcommission(self, size, price, pseudoexec):
return abs(size) * self.p.commission
定义中的最重要的方面是每股固定佣金为 0.03 美元以及在_getcommission
方法中计算佣金的方式。我们取大小的绝对值,并将其乘以固定佣金。
然后,我们可以轻松地将这些信息输入回测。在前面的“全部投入”策略示例的基础上,代码如下所示:
cerebro = bt.Cerebro(stdstats=False)
cerebro.addstrategy(RsiSignalStrategy)
cerebro.adddata(data)
cerebro.addsizer(bt.sizers.AllInSizer)
cerebro.broker.setcash(1000.0)
cerebro.broker.addcommissioninfo(FixedCommissionShare())
cerebro.addobserver(bt.observers.Value)
print(f"Starting Portfolio Value: {cerebro.broker.getvalue():.2f}")
cerebro.run()
print(f"Final Portfolio Value: {cerebro.broker.getvalue():.2f}")
结果如下:
Starting Portfolio Value: 1000.00
Final Portfolio Value: 1189.94
这些数字得出结论:0.01%的佣金实际上比每股 3 美分还要高。
每单固定佣金
其他经纪商可能会提供每单固定的佣金。在以下代码片段中,我们定义了一个自定义佣金方案,每单支付 2.5 美元,不管订单大小。
我们更改了commission
参数的值以及在_getcommission
方法中佣金的计算方式。这一次,该方法始终返回我们之前指定的 2.5 美元:
class FixedCommissionOrder(bt.CommInfoBase):
"""
Scheme with fixed commission per order
"""
params = (
("commission", 2.5),
("stocklike", True),
("commtype", bt.CommInfoBase.COMM_FIXED),
)
def _getcommission(self, size, price, pseudoexec):
return self.p.commission
我们不包括回测设置,因为它几乎与之前的相同。我们只需要通过addcommissioninfo
方法传递一个不同的类。回测结果是:
Starting Portfolio Value: 1000.00
Final Portfolio Value: 1174.70
另见
以下是一些有用的backtrader
文档参考:
要了解更多关于资金分配器的信息:
www.backtrader.com/docu/sizers-reference/
要了解更多关于佣金方案和可用参数的信息:
www.backtrader.com/docu/commission-schemes/commission-schemes/
基于布林带的买卖策略回测
布林带是一种统计方法,用于推导某一资产在一段时间内的价格和波动性信息。为了得到布林带,我们需要计算时间序列(价格)的移动平均和标准差,使用指定的窗口(通常为 20 天)。然后,我们将上/下布林带设置为移动标准差的 K 倍(通常为 2),分别位于移动平均值的上下方。
布林带的解释非常简单:带宽随着波动性的增加而变宽,随着波动性的减少而收窄。
在这个示例中,我们构建了一个简单的交易策略,利用布林带识别超买和超卖的水平,然后基于这些区域进行交易。策略规则如下:
当价格向上突破下布林带时,进行买入。
当价格向下突破上布林带时,卖出(仅当持有股票时)。
全部投入策略——在创建买入订单时,尽可能购买尽量多的股票。
不允许卖空。
我们评估了 2021 年微软股票的策略。此外,我们将佣金设置为 0.1%。
如何实现……
执行以下步骤来实现并回测一个基于布林带的策略:
导入库:
import backtrader as bt import datetime import pandas as pd from backtrader_strategies.strategy_utils import *
为了让代码更具可读性,我们首先展示定义交易策略的类的大致框架,然后在接下来的子步骤中介绍各个方法。
基于布林带定义策略:
class BollingerBandStrategy(bt.Strategy): params = (("period", 20), ("devfactor", 2.0),) def __init__(self): # some code def log(self, txt): # some code def notify_order(self, order): # some code def notify_trade(self, trade): # some code def next_open(self): # some code def start(self): print(f"Initial Portfolio Value: {self.broker.get_value():.2f}") def stop(self): print(f"Final Portfolio Value: {self.broker.get_value():.2f}")
使用策略方法定义策略时,有相当多的样板代码。因此,在以下子步骤中,我们只提及与之前解释的不同的方法。你也可以在本书的 GitHub 仓库中找到策略的完整代码:
__init__
方法定义如下:def __init__(self): # keep track of prices self.data_close = self.datas[0].close self.data_open = self.datas[0].open # keep track of pending orders self.order = None # add Bollinger Bands indicator and track buy/sell # signals self.b_band = bt.ind.BollingerBands( self.datas[0], period=self.p.period, devfactor=self.p.devfactor ) self.buy_signal = bt.ind.CrossOver( self.datas[0], self.b_band.lines.bot, plotname="buy_signal" ) self.sell_signal = bt.ind.CrossOver( self.datas[0], self.b_band.lines.top, plotname="sell_signal" )
next_open
方法定义如下:def next_open(self): if not self.position: if self.buy_signal > 0: # calculate the max number of shares ("all-in") size = int( self.broker.getcash() / self.datas[0].open ) # buy order log_str = get_action_log_string( "b", "c", price=self.data_close[0], size=size, cash=self.broker.getcash(), open=self.data_open[0], close=self.data_close[0] ) self.log(log_str) self.order = self.buy(size=size) else: if self.sell_signal < 0: # sell order log_str = get_action_log_string( "s", "c", self.data_close[0], self.position.size ) self.log(log_str) self.order = self.sell(size=self.position.size)
下载数据:
data = bt.feeds.YahooFinanceData( dataname="MSFT", fromdate=datetime.datetime(2021, 1, 1), todate=datetime.datetime(2021, 12, 31) )
设置回测:
cerebro = bt.Cerebro(stdstats=False, cheat_on_open=True) cerebro.addstrategy(BollingerBandStrategy) cerebro.adddata(data) cerebro.broker.setcash(10000.0) cerebro.broker.setcommission(commission=0.001) cerebro.addobserver(MyBuySell) cerebro.addobserver(bt.observers.Value) cerebro.addanalyzer( bt.analyzers.Returns, _name="returns" ) cerebro.addanalyzer( bt.analyzers.TimeReturn, _name="time_return" )
运行回测:
backtest_result = cerebro.run()
运行回测生成以下(简化版)日志:
Initial Portfolio Value: 10000.00 2021-03-01: BUY CREATED - Price: 235.03, Size: 42.00, Cash: 10000.00, Open: 233.99, Close: 235.03 2021-03-01: BUY EXECUTED - Price: 233.99, Size: 42.00, Cost: 9827.58, Commission: 9.83 2021-04-13: SELL CREATED - Price: 256.40, Size: 42.00 2021-04-13: SELL EXECUTED - Price: 255.18, Size: -42.00, Cost: 9827.58, Commission: 10.72 2021-04-13: OPERATION RESULT - Gross: 889.98, Net: 869.43 … 2021-12-07: BUY CREATED - Price: 334.23, Size: 37.00, Cash: 12397.10, Open: 330.96, Close: 334.23 2021-12-07: BUY EXECUTED - Price: 330.96, Size: 37.00, Cost: 12245.52, Commission: 12.25 Final Portfolio Value: 12668.27
绘制结果:
cerebro.plot(iplot=True, volume=False)
运行代码片段会生成以下图表:
图 12.6:我们策略在回测期间的行为/表现总结
我们可以看到,即使考虑到佣金成本,策略也能赚钱。投资组合价值中的平坦期代表我们没有持仓的时期。
调查不同的回报度量:
backtest_result[0].analyzers.returns.get_analysis()
运行代码生成以下输出:
OrderedDict([('rtot', 0.2365156915893157), ('ravg', 0.0009422935919893056), ('rnorm', 0.2680217199688534), ('rnorm100', 26.80217199688534)])
提取每日投资组合回报并绘制:
returns_dict = ( backtest_result[0].analyzers.time_return.get_analysis() ) returns_df = ( pd.DataFrame(list(returns_dict.items()), columns = ["date", "return"]) .set_index("date") ) returns_df.plot(title="Strategy's daily returns")
图 12.7:基于布林带的策略的每日投资组合回报
我们可以看到,投资组合回报中的平坦期(见图 12.7)与我们没有持仓的时期相对应,正如在图 12.6中所示。
它是如何工作的...
创建基于布林带的策略所用的代码与之前配方中的代码有很多相似之处。这就是为什么我们只讨论新颖之处,并将更多细节参考给*基于事件驱动的回测(使用 backtrader)*配方的原因。
由于我们在这个策略中进行了全力投资,因此我们必须使用一种名为cheat_on_open
的方法。这意味着我们使用第 t天的收盘价计算信号,但根据第 t+1天的开盘价计算我们希望购买的股份数量。为此,在实例化Cerebro
对象时,我们需要设置cheat_on_open=True
。
因此,我们还在Strategy
类中定义了一个next_open
方法,而不是使用next
。这明确地向Cerebro
表明我们在开盘时进行了作弊。在创建潜在的买单之前,我们手动计算了使用第 t+1 天的开盘价可以购买的最大股份数量。
在基于布林带计算买入/卖出信号时,我们使用了CrossOver
指标。它返回了以下内容:
如果第一组数据(价格)上穿第二组数据(指标),则返回 1
如果第一组数据(价格)下穿第二组数据(指标),则返回-1
我们还可以使用CrossUp
和CrossDown
函数,当我们只希望考虑单向穿越时。买入信号如下:self.buy_signal = bt.ind.CrossUp(self.datas[0], self.b_band.lines.bot)
。
最后的补充内容包括使用分析器——backtrader
对象,帮助评估投资组合的表现。在此配方中,我们使用了两个分析器:
Returns
:一组不同的对数收益率,计算覆盖整个时间范围:总复合收益率、整个期间的平均收益率和年化收益率。TimeReturn
:一组随时间变化的收益率(使用提供的时间范围,在本例中为每日数据)。
我们可以通过添加一个同名观察器来获得与TimeReturn
分析器相同的结果:cerebro.addobserver(bt.observers.TimeReturn)
。唯一的区别是观察器会显示在主要结果图表上,这并非总是我们所希望的。
还有更多内容……
我们已经看到如何从回测中提取每日收益率。这为将这些信息与quantstats
库的功能结合提供了一个绝佳机会。使用以下代码片段,我们可以计算多种指标,详细评估我们的投资组合表现。此外,我们还会将策略表现与简单的买入并持有策略进行对比(为了简化,买入并持有策略没有包括交易成本):
import quantstats as qs
qs.reports.metrics(returns_df,
benchmark="MSFT",
mode="basic")
运行该代码片段将生成以下报告:
Strategy Benchmark
------------------ ---------- -----------
Start Period 2021-01-04 2021-01-04
End Period 2021-12-30 2021-12-30
Risk-Free Rate 0.0% 0.0%
Time in Market 42.0% 100.0%
Cumulative Return 26.68% 57.18%
CAGR﹪ 27.1% 58.17%
Sharpe 1.65 2.27
Sortino 2.68 3.63
Sortino/√2 1.9 2.57
Omega 1.52 1.52
为简洁起见,我们只展示报告中可用的几条主要信息。
在第十一章,资产配置中,我们提到过,quantstats
的一个替代库是pyfolio
。后者的潜在缺点是它已经不再积极维护。然而,pyfolio
与backtrader
的集成非常好。我们可以轻松添加一个专用分析器(bt.analyzers.PyFolio
)。有关实现的示例,请参见本书的 GitHub 仓库。
使用加密数据回测移动平均交叉策略
到目前为止,我们已经创建并回测了几种股票策略。在本篇中,我们讨论了另一类流行的资产——加密货币。处理加密数据时有一些关键的区别:
加密货币可以进行 24/7 交易。
加密货币可以使用部分单位进行交易。
由于我们希望回测尽可能接近真实交易,因此我们应当在回测中考虑这些加密货币特有的特点。幸运的是,backtrader
框架非常灵活,我们可以稍微调整已有的方法来处理这种新资产类别。
一些经纪商也允许购买股票的部分股份。
在本篇中,我们回测了一种移动平均交叉策略,规则如下:
我们只关心比特币,并使用来自 2021 年的每日数据。
我们使用两种不同的移动平均线,窗口期分别为 20 天(快速)和 50 天(慢速)。
如果快速移动平均线向上穿越慢速移动平均线,我们将把 70%的可用现金分配用于购买比特币。
如果快速移动平均线下穿慢速移动平均线,我们会卖出所有持有的比特币。
不允许进行卖空交易。
如何实现……
执行以下步骤来实现并回测基于移动平均交叉的策略:
导入所需的库:
import backtrader as bt import datetime import pandas as pd from backtrader_strategies.strategy_utils import *
定义允许进行部分交易的佣金方案:
class FractionalTradesCommission(bt.CommissionInfo): def getsize(self, price, cash): """Returns the fractional size""" return self.p.leverage * (cash / price)
为了提高代码的可读性,我们首先展示定义交易策略的类的大纲,然后在以下的子步骤中介绍各个独立的方法。
定义 SMA 交叉策略:
class SMACrossoverStrategy(bt.Strategy): params = ( ("ma_fast", 20), ("ma_slow", 50), ("target_perc", 0.7) ) def __init__(self): # some code def log(self, txt): # some code def notify_order(self, order): # some code def notify_trade(self, trade): # some code def next(self): # some code def start(self): print(f"Initial Portfolio Value: {self.broker.get_value():.2f}") def stop(self): print(f"Final Portfolio Value: {self.broker.get_value():.2f}")
__init__
方法定义如下:def __init__(self): # keep track of close price in the series self.data_close = self.datas[0].close # keep track of pending orders self.order = None # calculate the SMAs and get the crossover signal self.fast_ma = bt.indicators.MovingAverageSimple( self.datas[0], period=self.params.ma_fast ) self.slow_ma = bt.indicators.MovingAverageSimple( self.datas[0], period=self.params.ma_slow ) self.ma_crossover = bt.indicators.CrossOver(self.fast_ma, self.slow_ma)
next
方法定义如下:def next(self): if self.order: # pending order execution. Waiting in orderbook return if not self.position: if self.ma_crossover > 0: self.order = self.order_target_percent( target=self.params.target_perc ) log_str = get_action_log_string( "b", "c", price=self.data_close[0], size=self.order.size, cash=self.broker.getcash(), open=self.data_open[0], close=self.data_close[0] ) self.log(log_str) else: if self.ma_crossover < 0: # sell order log_str = get_action_log_string( "s", "c", self.data_close[0], self.position.size ) self.log(log_str) self.order = ( self.order_target_percent(target=0) )
下载
BTC-USD
数据:data = bt.feeds.YahooFinanceData( dataname="BTC-USD", fromdate=datetime.datetime(2020, 1, 1), todate=datetime.datetime(2021, 12, 31) )
设置回测:
cerebro = bt.Cerebro(stdstats=False) cerebro.addstrategy(SMACrossoverStrategy) cerebro.adddata(data) cerebro.broker.setcash(10000.0) cerebro.broker.addcommissioninfo( FractionalTradesCommission(commission=0.001) ) cerebro.addobserver(MyBuySell) cerebro.addobserver(bt.observers.Value) cerebro.addanalyzer( bt.analyzers.TimeReturn, _name="time_return" )
运行回测:
backtest_result = cerebro.run()
运行代码片段会生成以下(简化的)日志:
Initial Portfolio Value: 10000.00 2020-04-19: BUY CREATED - Price: 7189.42, Size: 0.97, Cash: 10000.00, Open: 7260.92, Close: 7189.42 2020-04-20: BUY EXECUTED - Price: 7186.87, Size: 0.97, Cost: 6997.52, Commission: 7.00 2020-06-29: SELL CREATED - Price: 9190.85, Size: 0.97 2020-06-30: SELL EXECUTED - Price: 9185.58, Size: -0.97, Cost: 6997.52, Commission: 8.94 2020-06-30: OPERATION RESULT - Gross: 1946.05, Net: 1930.11 … Final Portfolio Value: 43547.99
在完整日志的摘录中,我们可以看到现在我们正使用分数仓位进行操作。此外,策略已经产生了相当可观的回报——我们大约将初始投资组合的价值翻了四倍。
绘制结果:
cerebro.plot(iplot=True, volume=False)
运行代码片段会生成以下图表:
图 12.8:我们的策略在回测期间的行为/表现总结
我们已经证明,使用我们的策略,我们获得了超过 300%的回报。然而,我们也可以在图 12.8中看到,出色的表现可能仅仅是因为在考虑的期间内,比特币(BTC)价格的大幅上涨。
使用与之前示例中相同的代码,我们可以将我们的策略与简单的买入并持有策略进行比较。通过这种方式,我们可以验证我们的主动策略与静态基准的表现差异。下面展示的是简化的表现对比,代码可以在书中的 GitHub 仓库找到。
Strategy Benchmark
------------------ ---------- -----------
Start Period 2020-01-01 2020-01-01
End Period 2021-12-30 2021-12-30
Risk-Free Rate 0.0% 0.0%
Time in Market 57.0% 100.0%
Cumulative Return 335.48% 555.24%
CAGR﹪ 108.89% 156.31%
Sharpe 1.6 1.35
Sortino 2.63 1.97
Sortino/√2 1.86 1.4
Omega 1.46 1.46
不幸的是,我们的策略在分析的时间框架内并没有超过基准。这证实了我们最初的怀疑,即优异的表现与在考虑的期间内比特币价格的上涨有关。
它是如何工作的…
在导入库之后,我们定义了一个自定义的佣金方案,以允许分数股份。在之前创建自定义佣金方案时,我们是从bt.CommInfoBase
继承,并修改了_getcommission
方法。这一次,我们从bt.CommissionInfo
继承,并修改了getsize
方法,以根据可用现金和资产价格返回分数值。
在步骤 3(及其子步骤)中,我们定义了移动平均交叉策略。通过这个示例,大部分代码应该已经非常熟悉。我们在这里应用的一个新概念是不同类型的订单,也就是order_target_percent
。使用这种类型的订单表示我们希望给定资产在我们的投资组合中占有 X%的比例。
这是一种非常方便的方法,因为我们将精确的订单大小计算交给了backtrader
。如果在发出订单时,我们低于指定的目标百分比,我们将购买更多的资产;如果我们超过了该比例,我们将卖出一部分资产。
为了退出仓位,我们表示希望比特币(BTC)在我们的投资组合中占 0%,这相当于卖出我们所有持有的比特币。通过使用目标为零的order_target_percent
,我们无需跟踪/访问当前持有的单位数量。
在步骤 4中,我们下载了 2021 年每日的 BTC 价格(以美元计)。在接下来的步骤中,我们设置了回测,运行了回测,并绘制了结果。唯一值得提到的是,我们需要使用addcommissioninfo
方法添加自定义佣金方案(包含部分股份逻辑)。
还有更多内容…
在本示例中,我们介绍了目标订单。backtrader
提供了三种类型的目标订单:
order_target_percent
:表示我们希望在给定资产中拥有的当前投资组合价值的百分比。order_target_size
:表示我们希望在投资组合中拥有的给定资产的目标单位数。order_target_value
:表示我们希望在投资组合中拥有的资产目标金额(以货币单位表示)。
目标订单在我们知道给定资产的目标百分比/价值/数量时非常有用,但不想花额外的时间计算是否应该购买更多单位或卖出它们以达到目标。
还有一件关于部分股份的重要事情需要提到。在这个示例中,我们定义了一个自定义的佣金方案,考虑了部分股份的情况,然后使用目标订单来买入/卖出资产。这样,当引擎计算出为了达到目标需要交易的单位数量时,它知道可以使用部分值。
然而,还有一种不需要定义自定义佣金方案的方式来使用部分股份。我们只需手动计算我们想要买入/卖出的股份数量,并创建一个给定份额的订单。在前一个示例中,我们做了类似的操作,但那时我们将潜在的部分值四舍五入为整数。有关手动部分订单大小计算的 SMA 交叉策略实现,请参考本书的 GitHub 仓库。
对均值方差投资组合优化的回测
在前一章中,我们讨论了资产配置和均值方差优化。将均值方差优化与回测结合起来将是一个有趣的练习,特别是因为它涉及同时处理多个资产。
在这个示例中,我们回测了以下配置策略:
我们考虑 FAANG 股票。
每周五市场收盘后,我们找到切线投资组合(最大化 Sharpe 比率)。然后,在市场周一开盘时,我们创建目标订单来匹配计算出的最佳权重。
我们假设需要至少 252 个数据点来计算预期收益和协方差矩阵(使用 Ledoit-Wolf 方法)。
对于这个练习,我们下载了 2020 到 2021 年的 FAANG 股票价格。由于我们为计算权重设置的预热期,实际的交易只发生在 2021 年。
准备工作
由于在这个示例中我们将处理部分股份,我们需要使用在前一个示例中定义的自定义佣金方案(FractionalTradesCommission
)。
如何操作…
执行以下步骤来实现并回测基于均值-方差投资组合优化的策略:
导入库:
from datetime import datetime import backtrader as bt import pandas as pd from pypfopt.expected_returns import mean_historical_return from pypfopt.risk_models import CovarianceShrinkage from pypfopt.efficient_frontier import EfficientFrontier from backtrader_strategies.strategy_utils import *
为了提高代码的可读性,我们首先展示定义交易策略的类的一般框架,然后在以下子步骤中引入各个方法。
定义策略:
class MeanVariancePortfStrategy(bt.Strategy): params = (("n_periods", 252), ) def __init__(self): # track number of days self.day_counter = 0 def log(self, txt): dt = self.datas[0].datetime.date(0).isoformat() print(f"{dt}: {txt}") def notify_order(self, order): # some code def notify_trade(self, trade): # some code def next(self): # some code def start(self): print(f"Initial Portfolio Value: {self.broker.get_value():.2f}") def stop(self): print(f"Final Portfolio Value: {self.broker.get_value():.2f}")
next
方法定义如下:def next(self): # check if we have enough data points self.day_counter += 1 if self.day_counter < self.p.n_periods: return # check if the date is a Friday today = self.datas[0].datetime.date() if today.weekday() != 4: return # find and print the current allocation current_portf = {} for data in self.datas: current_portf[data._name] = ( self.positions[data].size * data.close[0] ) portf_df = pd.DataFrame(current_portf, index=[0]) print(f"Current allocation as of {today}") print(portf_df / portf_df.sum(axis=1).squeeze()) # extract the past price data for each asset price_dict = {} for data in self.datas: price_dict[data._name] = ( data.close.get(0, self.p.n_periods+1) ) prices_df = pd.DataFrame(price_dict) # find the optimal portfolio weights mu = mean_historical_return(prices_df) S = CovarianceShrinkage(prices_df).ledoit_wolf() ef = EfficientFrontier(mu, S) weights = ef.max_sharpe(risk_free_rate=0) print(f"Optimal allocation identified on {today}") print(pd.DataFrame(ef.clean_weights(), index=[0])) # create orders for allocation in list(ef.clean_weights().items()): self.order_target_percent(data=allocation[0], target=allocation[1])
下载 FAANG 股票的价格并将数据源存储在列表中:
TICKERS = ["META", "AMZN", "AAPL", "NFLX", "GOOG"] data_list = [] for ticker in TICKERS: data = bt.feeds.YahooFinanceData( dataname=ticker, fromdate=datetime(2020, 1, 1), todate=datetime(2021, 12, 31) ) data_list.append(data)
设置回测:
cerebro = bt.Cerebro(stdstats=False) cerebro.addstrategy(MeanVariancePortfStrategy) for ind, ticker in enumerate(TICKERS): cerebro.adddata(data_list[ind], name=ticker) cerebro.broker.setcash(1000.0) cerebro.broker.addcommissioninfo( FractionalTradesCommission(commission=0) ) cerebro.addobserver(MyBuySell) cerebro.addobserver(bt.observers.Value)
运行回测:
backtest_result = cerebro.run()
运行回测后会生成如下日志:
Initial Portfolio Value: 1000.00
Current allocation as of 2021-01-08
META AMZN AAPL NFLX GOOG
0 NaN NaN NaN NaN NaN
Optimal allocation identified on 2021-01-08
META AMZN AAPL NFLX GOOG
0 0.0 0.69394 0.30606 0.0 0.0
2021-01-11: Order Failed: AAPL
2021-01-11: BUY EXECUTED - Price: 157.40, Size: 4.36, Asset: AMZN, Cost: 686.40, Commission: 0.00
Current allocation as of 2021-01-15
META AMZN AAPL NFLX GOOG
0 0.0 1.0 0.0 0.0 0.0
Optimal allocation identified on 2021-01-15
META AMZN AAPL NFLX GOOG
0 0.0 0.81862 0.18138 0.0 0.0
2021-01-19: BUY EXECUTED - Price: 155.35, Size: 0.86, Asset: AMZN, Cost: 134.08, Commission: 0.00
2021-01-19: Order Failed: AAPL
Current allocation as of 2021-01-22
META AMZN AAPL NFLX GOOG
0 0.0 1.0 0.0 0.0 0.0
Optimal allocation identified on 2021-01-22
META AMZN AAPL NFLX GOOG
0 0.0 0.75501 0.24499 0.0 0.0
2021-01-25: SELL EXECUTED - Price: 166.43, Size: -0.46, Asset: AMZN, Cost: 71.68, Commission: 0.00
2021-01-25: Order Failed: AAPL
...
0 0.0 0.0 0.00943 0.0 0.99057
2021-12-20: Order Failed: GOOG
2021-12-20: SELL EXECUTED - Price: 167.82, Size: -0.68, Asset: AAPL, Cost: 110.92, Commission: 0.00
Final Portfolio Value: 1287.22
我们不会花时间评估策略,因为这与我们在前一个示例中做的非常相似。因此,我们将其作为潜在的练习留给读者。测试该策略的表现是否优于基准1/n投资组合也是一个有趣的思路。
值得一提的是,一些订单没有成功执行。我们将在下一节中描述原因。
它是如何工作的……
在导入库后,我们使用均值-方差优化定义了策略。在__init__
方法中,我们定义了一个计数器,用来判断是否有足够的数据点来执行优化过程。选择 252 天是随意的,你可以尝试不同的值。
在next
方法中,有多个新的组件:
我们首先将天数计数器加 1,并检查是否有足够的观察数据。如果没有,我们就简单地跳到下一个交易日。
我们从价格数据中提取当前日期并检查是否为星期五。如果不是,我们就继续到下一个交易日。
我们通过访问每个资产的头寸大小并将其乘以给定日期的收盘价来计算当前的配置。最后,我们将每个资产的价值除以总投资组合的价值,并打印权重。
我们需要提取每只股票的最后 252 个数据点来进行优化过程。
self.datas
对象是一个可迭代的集合,包含我们在设置回测时传递给Cerebro
的所有数据源。我们创建一个字典,并用包含 252 个数据点的数组填充它。然后,我们使用get
方法提取这些数据。接着,我们从字典中创建一个包含价格的pandas
数据框。我们使用
pypfopt
库找到了最大化夏普比率的权重。更多细节请参考前一章节。我们还打印了新的权重。对于每个资产,我们使用
order_target_percent
方法下达目标订单,目标是最优投资组合权重。由于这次我们使用多个资产,因此需要指明为哪个资产下单。我们通过指定data
参数来实现这一点。
在背后,backtrader
使用array
模块来存储类似矩阵的对象。
在第 3 步中,我们创建了一个包含所有数据源的列表。我们简单地遍历了 FAANG 股票的代码,下载了每只股票的数据,并将该对象添加到列表中。
在第 4 步中,我们设置了回测。许多步骤现在已经非常熟悉,包括设置分数股的佣金方案。新的部分是添加数据,我们通过已经涵盖过的adddata
方法,逐步添加每个下载的数据源。在这一过程中,我们还需要使用name
参数提供数据源的名称。
在最后一步,我们运行了回测。正如我们之前提到的,新的情况是订单失败。这是因为我们在周五使用收盘价计算投资组合权重,并在同一天准备订单。然而,在周一的市场开盘时,价格发生了变化,导致并非所有订单都能执行。我们尝试使用分数股和将佣金设置为 0 来解决这一问题,但价格差异可能仍然过大,使得这种简单方法无法正常工作。一种可能的解决方案是始终保留一些现金,以应对潜在的价格差异。
为此,我们可以假设用我们投资组合的约 90%的价值购买股票,而将剩余部分保持为现金。为了实现这一点,我们可以使用order_target_value
方法。我们可以使用投资组合的权重和投资组合价值的 90%来计算每个资产的目标价值。或者,我们也可以使用pypfopt
中的DiscreteAllocation
方法,正如我们在前一章提到的那样。
摘要
在本章中,我们深入探讨了回测的话题。我们从较简单的方式——矢量化回测开始。尽管它不像事件驱动方法那样严格和稳健,但由于其矢量化特性,通常实现和执行速度更快。之后,我们将事件驱动回测框架的探索与前几章获得的知识结合起来,例如计算各种技术指标和寻找最优的投资组合权重。
我们花了最多时间使用backtrader
库,因为它在实现各种场景时具有广泛的应用和灵活性。然而,市场上有许多其他的回测库。你可能还想研究以下内容:
vectorbt
(github.com/polakowo/vectorbt
): 一个基于pandas
的库,用于大规模高效回测交易策略。该库的作者还提供了一个专业版(收费),具有更多功能和更高性能。bt
(github.com/pmorissette/bt
): 一个提供基于可复用和灵活模块的框架的库,模块包含策略的逻辑。它支持多种工具,并输出详细的统计数据和图表。backtesting.py
(github.com/kernc/backtesting.py
): 一个建立在backtrader
之上的回测框架。fastquant
(github.com/enzoampil/fastquant
): 一个围绕backtrader
的包装库,旨在减少为流行交易策略(例如移动平均交叉)运行回测时需要编写的样板代码量。zipline
(github.com/quantopian/zipline
/github.com/stefan-jansen/zipline-reloaded
): 该库曾是最受欢迎的回测库(基于 GitHub 星标),也可能是最复杂的开源回测库之一。然而,正如我们已经提到的,Quantopian 已经关闭,该库也不再维护。你可以使用由 Stefan Jansen 维护的分支(zipline-reloaded
)。
回测是一个非常有趣的领域,值得深入学习。以下是一些非常有趣的参考资料,介绍了更稳健的回测方法:
Bailey, D. H., Borwein, J., Lopez de Prado, M., & Zhu, Q. J. (2016). “回测过拟合的概率。” 计算金融杂志,待刊。
Bailey, D. H., & De Prado, M. L. (2014). “调整夏普比率:修正选择偏差、回测过拟合和非正态性。” 投资组合管理杂志, 40 (5), 94-107。
Bailey, D. H., Borwein, J., Lopez de Prado, M., & Zhu, Q. J. (2014). “伪数学与金融江湖术士:回测过拟合对样本外表现的影响。” 美国数学会通报, 61 (5), 458-471。
De Prado, M. L. (2018). 金融机器学习的进展. 约翰·威利与子公司。
第十三章:应用机器学习:识别信用违约
近年来,我们见证了机器学习在解决传统商业问题方面越来越受到欢迎。时不时就会有新的算法发布,超越当前的最先进技术。各行各业的企业试图利用机器学习的强大能力来改进其核心功能,似乎是自然而然的事情。
在本章开始具体讨论我们将专注的任务之前,我们首先简要介绍一下机器学习领域。机器学习可以分为两个主要方向:监督学习和无监督学习。在监督学习中,我们有一个目标变量(标签),我们尽力预测其尽可能准确的值。在无监督学习中,没有目标变量,我们试图利用不同的技术从数据中提取一些洞见。
我们还可以进一步将监督学习问题细分为回归问题(目标变量是连续数值,比如收入或房价)和分类问题(目标是类别,可能是二分类或多分类)。无监督学习的一个例子是聚类,通常用于客户细分。
在本章中,我们解决的是一个金融行业中的二分类问题。我们使用的数据集贡献自 UCI 机器学习库,这是一个非常流行的数据存储库。本章使用的数据集是在 2005 年 10 月由一家台湾银行收集的。研究的动机是——当时——越来越多的银行开始向愿意的客户提供信用(无论是现金还是信用卡)。此外,越来越多的人无论其还款能力如何,积累了大量债务。这一切导致了部分人无法偿还未结清的债务,换句话说,他们违约了。
该研究的目标是利用一些基本的客户信息(如性别、年龄和教育水平),结合他们的过往还款历史,来预测哪些客户可能会违约。该设置可以描述如下——使用前 6 个月的还款历史(2005 年 4 月到 9 月),我们尝试预测该客户是否会在 2005 年 10 月违约。自然,这样的研究可以推广到预测客户是否会在下个月、下个季度等时段违约。
在本章结束时,你将熟悉一个机器学习任务的实际操作流程,从数据收集和清理到构建和调优分类器。另一个收获是理解机器学习项目的一般方法,这可以应用于许多不同的任务,无论是客户流失预测,还是估算某个区域新房地产的价格。
在本章中,我们将重点介绍以下内容:
加载数据与管理数据类型
探索性数据分析
将数据分为训练集和测试集
识别和处理缺失值
编码类别变量
拟合决策树分类器
使用管道组织项目
使用网格搜索和交叉验证调优超参数
加载数据并管理数据类型
在本教程中,我们展示了如何将数据集从 CSV 文件加载到 Python 中。相同的原则也可以应用于其他文件格式,只要它们被 pandas
支持。一些常见的格式包括 Parquet、JSON、XLM、Excel 和 Feather。
pandas
拥有非常一致的 API,这使得查找其函数变得更加容易。例如,所有用于从各种来源加载数据的函数都有 pd.read_xxx
这样的语法,其中 xxx
应替换为文件格式。
我们还展示了如何通过某些数据类型转换显著减少 DataFrame 在我们计算机内存中的大小。这在处理大型数据集(GB 或 TB 级别)时尤为重要,因为如果不优化其使用,它们可能根本无法适应内存。
为了呈现更现实的场景(包括杂乱的数据、缺失值等),我们对原始数据集应用了一些转换。有关这些更改的更多信息,请参阅随附的 GitHub 仓库。
如何实现...
执行以下步骤将数据集从 CSV 文件加载到 Python 中:
导入库:
import pandas as pd
从 CSV 文件加载数据:
df = pd.read_csv("../Datasets/credit_card_default.csv", na_values="") df
运行代码片段会生成数据集的以下预览:
图 13.1:数据集预览。并非所有列都被显示
该 DataFrame 有 30,000 行和 24 列。它包含数字型和类别型变量的混合。
查看 DataFrame 的摘要:
df.info()
运行代码片段会生成以下摘要:
RangeIndex: 30000 entries, 0 to 29999 Data columns (total 24 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 limit_bal 30000 non-null int64 1 sex 29850 non-null object 2 education 29850 non-null object 3 marriage 29850 non-null object 4 age 29850 non-null float64 5 payment_status_sep 30000 non-null object 6 payment_status_aug 30000 non-null object 7 payment_status_jul 30000 non-null object 8 payment_status_jun 30000 non-null object 9 payment_status_may 30000 non-null object 10 payment_status_apr 30000 non-null object 11 bill_statement_sep 30000 non-null int64 12 bill_statement_aug 30000 non-null int64 13 bill_statement_jul 30000 non-null int64 14 bill_statement_jun 30000 non-null int64 15 bill_statement_may 30000 non-null int64 16 bill_statement_apr 30000 non-null int64 17 previous_payment_sep 30000 non-null int64 18 previous_payment_aug 30000 non-null int64 19 previous_payment_jul 30000 non-null int64 20 previous_payment_jun 30000 non-null int64 21 previous_payment_may 30000 non-null int64 22 previous_payment_apr 30000 non-null int64 23 default_payment_next_month 30000 non-null int64 dtypes: float64(1), int64(14), object(9) memory usage: 5.5+ MB
在摘要中,我们可以看到关于列及其数据类型、非空(换句话说,非缺失)值的数量、内存使用情况等信息。
我们还可以观察到几种不同的数据类型:浮点数(如 3.42)、整数和对象。最后一种是
pandas
对字符串变量的表示。紧挨着float
和int
的数字表示该类型用于表示特定值时所使用的位数。默认类型使用 64 位(或 8 字节)的内存。基本的
int8
类型覆盖的整数范围是:-128 到 127。uint8
表示无符号整数,覆盖相同的范围,但仅包含非负值,即 0 到 255。通过了解特定数据类型覆盖的值范围(请参见 另见 部分中的链接),我们可以尝试优化内存分配。例如,对于表示购买月份(范围为 1-12 的数字)这样的特征,使用默认的int64
类型没有意义,因为一个更小的数据类型就足够了。定义一个函数来检查 DataFrame 的准确内存使用情况:
def get_df_memory_usage(df, top_columns=5): print("Memory usage ----") memory_per_column = df.memory_usage(deep=True) / (1024 ** 2) print(f"Top {top_columns} columns by memory (MB):") print(memory_per_column.sort_values(ascending=False) \ .head(top_columns)) print(f"Total size: {memory_per_column.sum():.2f} MB")
我们现在可以将该函数应用于我们的 DataFrame:
get_df_memory_usage(df, 5)
运行代码片段会生成以下输出:
Memory usage ---- Top 5 columns by memory (MB): education 1.965001 payment_status_sep 1.954342 payment_status_aug 1.920288 payment_status_jul 1.916343 payment_status_jun 1.904229 dtype: float64 Total size: 20.47 MB
在输出中,我们可以看到
info
方法报告的 5.5+ MB 实际上几乎是 4 倍的值。虽然这在当前机器的能力范围内仍然非常小,但本章中展示的节省内存的原则同样适用于以 GB 为单位测量的 DataFrame。将数据类型为
object
的列转换为category
类型:object_columns = df.select_dtypes(include="object").columns df[object_columns] = df[object_columns].astype("category") get_df_memory_usage(df)
运行代码片段会生成以下概览:
Memory usage ---- Top 5 columns by memory (MB): bill_statement_sep 0.228882 bill_statement_aug 0.228882 previous_payment_apr 0.228882 previous_payment_may 0.228882 previous_payment_jun 0.228882 dtype: float64 Total size: 3.70 MB
仅通过将
object
列转换为pandas
原生的分类表示,我们成功地将 DataFrame 的大小减少了约 80%!将数字列降级为整数:
numeric_columns = df.select_dtypes(include="number").columns for col in numeric_columns: df[col] = pd.to_numeric(df[col], downcast="integer") get_df_memory_usage(df)
运行代码片段会生成以下概览:
Memory usage ---- Top 5 columns by memory (MB): age 0.228882 bill_statement_sep 0.114441 limit_bal 0.114441 previous_payment_jun 0.114441 previous_payment_jul 0.114441 dtype: float64 Total size: 2.01 MB
在总结中,我们可以看到,在进行几次数据类型转换后,占用最多内存的列是包含顾客年龄的那一列(你可以在
df.info()
的输出中看到这一点,这里为了简洁未显示)。这是因为它使用了float
数据类型,并且未对float
列应用integer
类型的降级。使用
float
数据类型降级age
列:df["age"] = pd.to_numeric(df["age"], downcast="float") get_df_memory_usage(df)
运行代码片段会生成以下概览:
Memory usage ----
Top 5 columns by memory (MB):
bill_statement_sep 0.114441
limit_bal 0.114441
previous_payment_jun 0.114441
previous_payment_jul 0.114441
previous_payment_aug 0.114441
dtype: float64
Total size: 1.90 MB
通过各种数据类型转换,我们将 DataFrame 的内存大小从 20.5 MB 减少到了 1.9 MB,减少了 91%。
它是如何工作的...
导入pandas
后,我们使用pd.read_csv
函数加载了 CSV 文件。在此过程中,我们指示空字符串应视为缺失值。
在步骤 3中,我们展示了 DataFrame 的摘要以检查其内容。为了更好地理解数据集,我们提供了变量的简化描述:
limit_bal
—授予的信用额度(新台币)sex
—生物性别education
—教育水平marriage
—婚姻状况age
—顾客的年龄payment_status_{month}
—前 6 个月中某个月的支付状态bill_statement_{month}
—前 6 个月中某个月的账单金额(新台币)previous_payment_{month}
—前 6 个月中某个月的支付金额(新台币)default_payment_next_month
—目标变量,表示顾客是否在下个月出现违约
一般来说,pandas
会尽可能高效地加载和存储数据。它会自动分配数据类型(我们可以通过pandas
DataFrame 的dtypes
方法查看)。然而,有些技巧可以显著改善内存分配,这无疑使得处理更大的表格(以百 MB 甚至 GB 为单位)更加容易和高效。
在步骤 4中,我们定义了一个函数来检查 DataFrame 的确切内存使用情况。memory_usage
方法返回一个pandas
的 Series,列出每个 DataFrame 列的内存使用量(以字节为单位)。我们将输出转换为 MB,以便更易理解。
在使用memory_usage
方法时,我们指定了deep=True
。这是因为与其他数据类型(dtypes)不同,object
数据类型对于每个单元格并没有固定的内存分配。换句话说,由于object
数据类型通常对应文本,它的内存使用量取决于每个单元格中的字符数。直观来说,字符串中的字符越多,该单元格使用的内存就越多。
在步骤 5中,我们利用了一种特殊的数据类型category
来减少 DataFrame 的内存使用。其基本思想是将字符串变量编码为整数,pandas
使用一个特殊的映射字典将其解码回原始形式。这个方法在处理有限的不同值时尤其有效,例如某些教育水平、原籍国家等。为了节省内存,我们首先使用select_dtypes
方法识别出所有object
数据类型的列。然后,我们将这些列的数据类型从object
更改为category
。这一过程是通过astype
方法完成的。
我们应该知道什么时候使用category
数据类型从内存角度上来说是有利的。一个经验法则是,当唯一观测值与总观测值的比例低于 50%时,使用该数据类型。
在步骤 6中,我们使用select_dtypes
方法识别了所有数值列。然后,通过一个for
循环遍历已识别的列,使用pd.to_numeric
函数将值转换为数值。虽然看起来有些奇怪,因为我们首先识别了数值列,然后又将它们转换为数值,但关键点在于该函数的downcast
参数。通过传递"integer"
值,我们通过将默认的int64
数据类型降级为更小的替代类型(int32
和int8
),优化了所有整数列的内存使用。
尽管我们将该函数应用于所有数值列,但只有包含整数的列成功进行了转换。这就是为什么在步骤 7中,我们额外将包含客户年龄的float
列进行了降级处理。
还有更多方法……
在本教程中,我们提到如何优化pandas
DataFrame 的内存使用。我们首先将数据加载到 Python 中,然后检查了各列,最后我们将一些列的数据类型转换以减少内存使用。然而,这种方法可能并不总是可行,因为数据可能根本无法适配内存。
如果是这种情况,我们还可以尝试以下方法:
按块读取数据集(通过使用
pd.read_csv
的chunk
参数)。例如,我们可以仅加载前 100 行数据。只读取我们实际需要的列(通过使用
pd.read_csv
的usecols
参数)。在加载数据时,使用
column_dtypes
参数定义每一列的数据类型。
举例来说,我们可以使用以下代码片段加载数据集,并在加载时指定选中的三列应具有 category
数据类型:
column_dtypes = {
"education": "category",
"marriage": "category",
"sex": "category"
}
df_cat = pd.read_csv("../Datasets/credit_card_default.csv",
na_values="", dtype=column_dtypes)
如果以上方法都无效,我们也不应该放弃。虽然 pandas
无疑是 Python 中操作表格数据的黄金标准,但我们可以借助一些专门为此类情况构建的替代库。下面是你在处理大数据量时可以使用的一些库的列表:
Dask
:一个开源的分布式计算库。它可以同时在单台机器或者 CPU 集群上运行多个计算任务。库内部将一个大数据处理任务拆分成多个小任务,然后由numpy
或pandas
处理。最后一步,库会将结果重新组合成一个一致的整体。Modin
:一个旨在通过自动将计算任务分配到系统中所有可用 CPU 核心上来并行化pandas
DataFrame 的库。该库将现有的 DataFrame 分割成不同的部分,使得每个部分可以被发送到不同的 CPU 核心处理。Vaex
:一个开源的 DataFrame 库,专门用于懒加载的外部数据框架。Vaex 通过结合懒加载评估和内存映射的概念,能够在几乎不占用 RAM 的情况下,检查和操作任意大小的数据集。datatable
:一个开源库,用于操作二维表格数据。在许多方面,它与pandas
相似,特别强调速度和数据量(最多支持 100 GB),并且可以在单节点机器上运行。如果你曾使用过 R,可能已经熟悉相关的data.table
包,这是 R 用户在进行大数据快速聚合时的首选工具。cuDF
:一个 GPU DataFrame 库,是 NVIDIA RAPIDS 生态系统的一部分,RAPIDS 是一个涉及多个开源库并利用 GPU 强大计算能力的数据科学生态系统。cuDF
允许我们使用类似pandas
的 API,在无需深入了解 CUDA 编程的情况下,享受性能提升的好处。polars
:一个开源的 DataFrame 库,通过利用 Rust 编程语言和 Apache Arrow 作为内存模型,达到了惊人的计算速度。
另见
额外资源:
Dua, D. 和 Graff, C.(2019)。UCI 机器学习库 [
archive.ics.uci.edu/ml
]。加利福尼亚州尔湾:加利福尼亚大学信息与计算机科学学院。Yeh, I. C. 和 Lien, C. H.(2009)。"数据挖掘技术比较对信用卡客户违约概率预测准确性的影响。" Expert Systems with Applications, 36(2), 2473-2480。
doi.org/10.1016/j.eswa.2007.12.020
。Python 中使用的不同数据类型列表:
numpy.org/doc/stable/user/basics.types.html#.
探索性数据分析
数据科学项目的第二步是进行探索性数据分析(EDA)。通过这样做,我们可以了解需要处理的数据。这也是我们检验自己领域知识深度的阶段。例如,我们所服务的公司可能假设其大多数客户年龄在 18 到 25 岁之间。但事实真的是这样吗?在进行 EDA 时,我们可能会发现一些自己无法理解的模式,这时就可以成为与利益相关者讨论的起点。
在进行 EDA 时,我们可以尝试回答以下问题:
我们到底拥有何种类型的数据,应该如何处理不同的数据类型?
变量的分布情况如何?
数据中是否存在离群值,我们该如何处理它们?
是否需要进行任何变换?例如,一些模型在处理(或要求)服从正态分布的变量时表现更好,因此我们可能需要使用诸如对数变换之类的技术。
不同群体(例如性别或教育水平)之间的分布是否存在差异?
我们是否有缺失数据?这些数据的频率是多少?它们出现在哪些变量中?
某些变量之间是否存在线性关系(相关性)?
我们是否可以使用现有的变量集创建新的特征?例如,可能从时间戳中推导出小时/分钟,或者从日期中推导出星期几,等等。
是否有一些变量可以删除,因为它们与分析无关?例如,随机生成的客户标识符。
自然地,这个列表并不详尽,进行分析时可能会引发比最初更多的问题。EDA 在所有数据科学项目中都极为重要,因为它使分析师能够深入理解数据,有助于提出更好的问题,并且更容易选择适合所处理数据类型的建模方法。
在实际案例中,通常先对所有相关特征进行单变量分析(一次分析一个特征),以便深入理解它们。然后,可以进行多变量分析,即比较每组的分布,相关性等。为了简洁起见,我们这里只展示对某些特征的选定分析方法,但强烈建议进行更深入的分析。
准备就绪
我们继续探索在上一个步骤中加载的数据。
如何进行...
执行以下步骤以进行贷款违约数据集的探索性数据分析(EDA):
导入所需的库:
import pandas as pd import numpy as np import seaborn as sns
获取数值变量的摘要统计:
df.describe().transpose().round(2)
运行该代码片段会生成以下摘要表格:
图 13.2:数值变量的摘要统计
获取分类变量的摘要统计:
df.describe(include="object").transpose()
运行代码片段生成了以下汇总表:
图 13.3:分类变量的汇总统计
绘制年龄分布并按性别划分:
ax = sns.kdeplot(data=df, x="age", hue="sex", common_norm=False, fill=True) ax.set_title("Distribution of age")
运行代码片段生成了以下图:
图 13.4:按性别分组的年龄 KDE 图
通过分析核密度估计(KDE)图,我们可以看出,每个性别的分布形态差异不大。女性样本的年龄略微偏小。
创建选定变量的对角图:
COLS_TO_PLOT = ["age", "limit_bal", "previous_payment_sep"] pair_plot = sns.pairplot(df[COLS_TO_PLOT], kind="reg", diag_kind="kde", height=4, plot_kws={"line_kws":{"color":"red"}}) pair_plot.fig.suptitle("Pairplot of selected variables")
运行代码片段生成了以下图:
图 13.5:带有 KDE 图的对角图和每个散点图中的回归线拟合
我们可以从创建的对角图中得出一些观察结果:
previous_payment_sep
的分布高度偏斜——它有一个非常长的尾巴。与前述内容相关,我们可以在散点图中观察到
previous_payment_sep
的极端值。从散点图中很难得出结论,因为每个散点图中都有 30,000 个观察值。当绘制如此大量的数据时,我们可以使用透明标记来更好地可视化某些区域的观察密度。
离群值可能对回归线产生显著影响。
此外,我们可以通过指定
hue
参数来区分性别:pair_plot = sns.pairplot(data=df, x_vars=COLS_TO_PLOT, y_vars=COLS_TO_PLOT, hue="sex", height=4) pair_plot.fig.suptitle("Pairplot of selected variables")
运行代码片段生成了以下图:
图 13.6:每个性别分别标记的对角图
尽管通过性别划分后的对角图能提供更多的见解,但由于绘制数据量庞大,散点图仍然相当难以解读。
作为潜在解决方案,我们可以从整个数据集中随机抽样,并只绘制选定的观察值。该方法的一个可能缺点是,我们可能会遗漏一些具有极端值(离群值)的观察数据。
分析年龄与信用额度余额之间的关系:
ax = sns.jointplot(data=df, x="age", y="limit_bal", hue="sex", height=10) ax.fig.suptitle("Age vs. limit balance")
运行代码片段生成了以下图:
图 13.7:显示年龄与信用额度余额关系的联合图,按性别分组
联合图包含了大量有用的信息。首先,我们可以在散点图中看到两个变量之间的关系。接下来,我们还可以使用沿坐标轴的 KDE 图来分别调查两个变量的分布(我们也可以选择绘制直方图)。
定义并运行一个绘制相关性热图的函数:
def plot_correlation_matrix(corr_mat): sns.set(style="white") mask = np.zeros_like(corr_mat, dtype=bool) mask[np.triu_indices_from(mask)] = True fig, ax = plt.subplots() cmap = sns.diverging_palette(240, 10, n=9, as_cmap=True) sns.heatmap(corr_mat, mask=mask, cmap=cmap, vmax=.3, center=0, square=True, linewidths=.5, cbar_kws={"shrink": .5}, ax=ax) ax.set_title("Correlation Matrix", fontsize=16) sns.set(style="darkgrid") corr_mat = df.select_dtypes(include="number").corr() plot_correlation_matrix(corr_mat)
运行代码片段生成了以下图:
图 13.8:数值特征的相关性热图
我们可以看到,年龄似乎与其他特征没有显著的相关性。
使用箱型图分析分组后的年龄分布:
ax = sns.boxplot(data=df, y="age", x="marriage", hue="sex") ax.set_title("Distribution of age")
运行代码片段生成了以下图:
图 13.9:按婚姻状况和性别分组的年龄分布
从分布来看,婚姻状况组内似乎相似,男性的中位数年龄总是较高。
绘制每个性别和教育水平的信用额度分布:
ax = sns.violinplot(x="education", y="limit_bal", hue="sex", split=True, data=df) ax.set_title( "Distribution of limit balance per education level", fontsize=16 )
运行代码片段会生成以下图表:
图 13.10:按教育水平和性别划分的信用额度分布
检查图表可以揭示一些有趣的模式:
最大的余额出现在研究生教育水平的组别中。
每个教育水平的分布形状不同:研究生水平类似于其他类别,而高中水平则与大学水平相似。
总体来说,性别之间的差异较小。
调查按性别和教育水平划分的目标变量分布:
ax = sns.countplot("default_payment_next_month", hue="sex", data=df, orient="h") ax.set_title("Distribution of the target variable", fontsize=16)
运行代码片段会生成以下图表:
图 13.11:按性别划分的目标变量分布
通过分析图表,我们可以得出结论:男性客户的违约比例较高。
调查每个教育水平的违约百分比:
ax = df.groupby("education")["default_payment_next_month"] \ .value_counts(normalize=True) \ .unstack() \ .plot(kind="barh", stacked="True") ax.set_title("Percentage of default per education level", fontsize=16) ax.legend(title="Default", bbox_to_anchor=(1,1))
运行代码片段会生成以下图表:
图 13.12:按教育水平划分的违约百分比
相对而言,大多数违约发生在高中教育的客户中,而违约最少的则出现在其他类别中。
它是如何工作的...
在前一节中,我们已经探索了两个在开始探索性数据分析时非常有用的 DataFrame 方法:shape
和info
。我们可以使用它们快速了解数据集的形状(行数和列数)、每个特征的数据类型等。
在本节中,我们主要使用了seaborn
库,因为它是探索数据时最常用的库。然而,我们也可以使用其他绘图库。pandas
DataFrame 的plot
方法非常强大,可以快速可视化数据。作为替代,我们也可以使用plotly
(及其plotly.express
模块)来创建完全交互的数据可视化。
在本节中,我们通过使用pandas
DataFrame 中的一种非常简单但强大的方法——describe
,开始了分析。它打印了所有数值变量的汇总统计信息,如计数、均值、最小值/最大值和四分位数。通过检查这些指标,我们可以推断出某个特征的值范围,或者分布是否偏斜(通过查看均值和中位数的差异)。此外,我们还可以轻松发现不合常理的值,例如负数或过年轻/年老的年龄。
我们可以通过传递额外的参数(例如,percentiles=[.99]
)在describe
方法中包含更多的百分位数。在这种情况下,我们添加了第 99 百分位。
计数度量表示非空观察值的数量,因此它也是确定哪些数值特征包含缺失值的一种方法。另一种检查缺失值存在的方法是运行df.isnull().sum()
。有关缺失值的更多信息,请参见识别和处理缺失值的配方。
在第 3 步中,我们在调用describe
方法时添加了include="object"
参数,以便单独检查分类特征。输出与数值特征不同:我们可以看到计数、唯一类别的数量、最常见的类别以及它在数据集中出现的次数。
我们可以使用include="all"
来显示所有特征的汇总度量——只有给定数据类型可用的度量会出现,其余则会填充为NA
值。
在第 4 步中,我们展示了调查变量分布的一种方法,在这种情况下是顾客的年龄。为此,我们创建了一个 KDE 图。它是一种可视化变量分布的方法,非常类似于传统的直方图。KDE 通过在一个或多个维度中使用连续的概率密度曲线来表示数据。与直方图相比,它的一个优点是生成的图形更加简洁,且更容易解释,特别是在同时考虑多个分布时。
关于 KDE 图,常见的困惑来源于密度轴的单位。一般而言,核密度估计结果是一个概率分布。然而,曲线在每一点的高度给出的是密度,而不是概率。我们可以通过对密度在某一范围内进行积分来获得概率。KDE 曲线已被归一化,使得所有可能值的积分总和等于 1。这意味着密度轴的尺度取决于数据值。更进一步地,如果我们在一个图中处理多个类别,我们可以决定如何归一化密度。如果我们使用common_norm=True
,每个密度都会根据观察值的数量进行缩放,使得所有曲线下的总面积之和为 1。否则,每个类别的密度会独立归一化。
与直方图一起,KDE 图是检查单一特征分布的最流行方法之一。要创建直方图,我们可以使用sns.histplot
函数。或者,我们也可以使用pandas
DataFrame 的plot
方法,并指定kind="hist"
。我们在附带的 Jupyter 笔记本中展示了创建直方图的示例(可在 GitHub 上找到)。
通过使用成对图(pairplot),可以扩展此分析。它创建一个图矩阵,其中对角线显示单变量直方图或核密度估计图(KDE),而非对角线的图为两特征的散点图。通过这种方式,我们还可以尝试查看两个特征之间是否存在关系。为了更容易识别潜在的关系,我们还添加了回归线。
在我们的例子中,我们只绘制了三个特征。这是因为对于 30,000 个观测值,绘制所有数值列的图表可能会耗费相当长的时间,更不用说在一个矩阵中包含如此多小图时会导致图表难以读取。当使用成对图时,我们还可以指定hue
参数来为某一类别(如性别或教育水平)进行拆分。
我们还可以通过联合图(sns.jointplot
)来放大查看两个变量之间的关系。这是一种结合了散点图和核密度估计图或直方图的图,既可以分析双变量关系,也可以分析单变量分布。在步骤 6中,我们分析了年龄与限额余额之间的关系。
在步骤 7中,我们定义了一个绘制热图表示相关矩阵的函数。在该函数中,我们使用了一些操作来遮蔽上三角矩阵和对角线(相关矩阵的所有对角元素都为 1)。这样,输出结果更容易解读。使用annot
参数的sns.heatmap
,我们可以在热图中添加底层数字。然而,当分析的特征数量过多时,我们应该避免这么做,否则数字将变得难以阅读。
为了计算相关性,我们使用了 DataFrame 的corr
方法,默认计算皮尔逊相关系数。我们只对数值特征进行了此操作。对于分类特征,也有计算相关性的方法;我们在*更多内容...*部分提到了一些方法。检查相关性至关重要,特别是在使用假设特征线性独立的机器学习算法时。
在步骤 8中,我们使用箱型图来研究按婚姻状况和性别划分的年龄分布。箱型图(也叫箱形图)通过一种便于比较分类变量各个层级之间的分布方式呈现数据分布。箱型图通过 5 个数值摘要展示数据分布信息:
中位数(第 50 百分位数)—由箱体内的水平黑线表示。
四分位距(IQR)—由箱体表示。它表示第一四分位数(25 百分位数)和第三四分位数(75 百分位数)之间的范围。
须须线—由从箱体延伸出的线表示。须须线的极值(标记为水平线)定义为第一四分位数 - 1.5 IQR 和第三四分位数 + 1.5 IQR。
我们可以使用箱型图从数据中获取以下见解:
标记在须外的点可以视为异常值。这种方法被称为Tukey’s fences,是最简单的异常值检测技术之一。简而言之,它假设位于[Q1 - 1.5 IQR, Q3 + 1.5 IQR]范围之外的观测值为异常值。
分布的潜在偏斜度。当中位数接近箱子的下限,而上须比下须长时,可以观察到右偏(正偏)的分布。反之,左偏分布则相反。图 13.13展示了这一点。
图 13.13:使用箱线图确定分布的偏斜度
在步骤 9中,我们使用小提琴图来研究限额余额特征在教育水平和性别上的分布。我们通过使用sns.violinplot
来创建这些图表。我们用x
参数表示教育水平,并且设置了hue="sex"
和split=True
。这样,小提琴的每一半就代表不同的性别。
一般来说,小提琴图与箱线图非常相似,我们可以在其中找到以下信息:
中位数,用白色点表示。
四分位距,表示为小提琴中央的黑色条形。
下邻值和上邻值,由从条形延伸出的黑线表示。下邻值定义为第一四分位数 - 1.5 IQR,而上邻值定义为第三四分位数 + 1.5 IQR。同样,我们可以使用邻值作为简单的异常值检测技术。
小提琴图是箱线图和核密度估计图(KDE 图)的结合。与箱线图相比,小提琴图的一个明显优点是它能够清晰地展示分布的形状。当处理多峰分布(具有多个峰值的分布)时,尤其有用,比如在研究生教育类别中的限额余额小提琴图。
在最后两步中,我们分析了目标变量(违约)在性别和教育水平上的分布。在第一种情况下,我们使用了sns.countplot
来显示每种性别下两种可能结果的出现次数。在第二种情况下,我们选择了不同的方法。我们想要绘制每个教育水平的违约百分比,因为比较不同组之间的百分比比比较名义值更容易。为此,我们首先按教育水平分组,选择感兴趣的变量,计算每组的百分比(使用value_counts(normalize=True)
方法),去除多重索引(通过 unstack),然后使用已经熟悉的plot
方法生成图表。
还有更多内容...
在这个示例中,我们介绍了一些可能的方法来调查手头的数据。然而,每次进行探索性数据分析(EDA)时,我们需要编写许多行代码(其中有相当多是模板代码)。幸运的是,有一个 Python 库简化了这一过程。这个库叫做 pandas_profiling
,只需一行代码,它就能生成数据集的全面概述,并以 HTML 报告的形式呈现。
要生成报告,我们需要运行以下代码:
from pandas_profiling import ProfileReport
profile = ProfileReport(df, title="Loan Default Dataset EDA")
profile
我们还可以通过 pandas_profiling
新添加的 profile_report
方法,基于一个 pandas
DataFrame 创建个人资料。
出于实际考虑,我们可能更倾向于将报告保存为 HTML 文件,并在浏览器中查看,而不是在 Jupyter notebook 中查看。我们可以使用以下代码片段轻松实现:
profile.to_file("loan_default_eda.html")
报告非常详尽,包含了许多有用的信息。请参见以下图例作为示例。
图 13.14:深入分析限额平衡特征的示例
为了简洁起见,我们将只讨论报告中的选定部分:
概述提供了有关 DataFrame 的信息(特征/行的数量、缺失值、重复行、内存大小、按数据类型的划分)。
警告我们关于数据中潜在问题的警报,包括重复行的高比例、高度相关(且可能冗余)的特征、具有高比例零值的特征、高度偏斜的特征等。
不同的相关性度量:Spearman’s
、Pearson’s r、Kendall’s
、Cramér’s V 和 Phik (
)。最后一个特别有趣,因为它是一个最近开发的相关系数,可以在分类、顺序和区间变量之间始终如一地工作。此外,它还能够捕捉非线性依赖性。有关该度量的参考论文,请参见 See also 部分。
详细分析缺失值。
对每个特征的详细单变量分析(更多细节可以通过点击报告中的 Toggle details 查看)。
pandas-profiling
是 Python 库生态系统中最流行的自动化 EDA 工具,但它绝对不是唯一的。你还可以使用以下工具进行调查:
sweetviz
—github.com/fbdesignpro/sweetviz
autoviz
—github.com/AutoViML/AutoViz
dataprep
—github.com/sfu-db/dataprep
每个工具对 EDA 的处理方式有所不同。因此,最好是探索它们所有,并选择最适合你需求的工具。
参见
关于 Phik 的更多信息(),请参阅以下论文:
- Baak, M., Koopman, R., Snoek, H., & Klous, S. (2020). “一种新的相关系数,适用于具有皮尔逊特征的分类、序数和区间变量。” 计算统计与数据分析,152,107043。
doi.org/10.1016/j.csda.2020.107043
。
将数据分为训练集和测试集
完成 EDA 后,下一步是将数据集分为训练集和测试集。这个思路是将数据分为两个独立的数据集:
训练集——在这部分数据上,我们训练机器学习模型
测试集——这部分数据在训练过程中没有被模型看到,用于评估模型的性能
通过这种方式拆分数据,我们希望防止过拟合。过拟合是指当模型在训练数据上找到过多模式,并且仅在这些数据上表现良好时发生的现象。换句话说,它无法泛化到看不见的数据。
这是分析中的一个非常重要的步骤,因为如果操作不当,可能会引入偏差,例如数据泄漏。数据泄漏是指在训练阶段,模型观察到它本不应该接触到的信息。我们接下来举个例子。一个常见的情况是使用特征的均值填补缺失值。如果我们在拆分数据之前就进行了填补,测试集中的数据也会被用来计算均值,从而引入泄漏。这就是为什么正确的顺序应该是先将数据拆分为训练集和测试集,然后进行填补,使用在训练集上观察到的数据。同样的规则也适用于识别异常值的设置。
此外,拆分数据确保了一致性,因为未来看不见的数据(在我们的案例中,是模型将要评分的新客户)将与测试集中的数据以相同的方式处理。
如何操作...
执行以下步骤将数据集分为训练集和测试集:
导入库:
import pandas as pd from sklearn.model_selection import train_test_split
将目标与特征分开:
X = df.copy() y = X.pop("default_payment_next_month")
将数据分为训练集和测试集:
X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 )
在不打乱顺序的情况下将数据分为训练集和测试集:
X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, shuffle=False )
使用分层法将数据分为训练集和测试集:
X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 )
验证目标比例是否保持一致:
print("Target distribution - train") print(y_train.value_counts(normalize=True).values) print("Target distribution - test") print(y_test.value_counts(normalize=True).values)
运行代码片段会生成以下输出:
Target distribution - train
[0.77879167 0.22120833]
Target distribution - test
[0.77883333 0.22116667]
在两个数据集中,支付违约的比例大约为 22.12%。
它是如何工作的...
在导入库之后,我们使用pandas
DataFrame 的pop
方法将目标与特征分开。
在步骤 3中,我们展示了如何进行最基本的拆分。我们将X
和y
对象传递给了train_test_split
函数。此外,我们还指定了测试集的大小,以所有观察值的一个比例表示。为了可重复性,我们还指定了随机状态。我们必须将函数的输出分配给四个新对象。
在步骤 4中,我们采用了不同的方法。通过指定test_size=0.2
和shuffle=False
,我们将数据的前 80%分配给训练集,剩下的 20%分配给测试集。当我们希望保持观察数据的顺序时,这种方法很有用。
在步骤 5中,我们还通过传递目标变量(stratify=y
)指定了分层划分的参数。使用分层划分数据意味着训练集和测试集将具有几乎相同的指定变量分布。这个参数在处理不平衡数据时非常重要,例如欺诈检测的情况。如果 99%的数据是正常的,只有 1%的数据是欺诈案件,随机划分可能导致训练集中没有欺诈案例。因此,在处理不平衡数据时,正确划分数据至关重要。
在最后一步,我们验证了分层的训练/测试划分是否在两个数据集中产生了相同的违约比例。为此,我们使用了pandas
DataFrame 的value_counts
方法。
在本章的其余部分,我们将使用从分层划分中获得的数据。
还有更多内容...
将数据划分为三个数据集也是常见做法:训练集、验证集和测试集。验证集用于频繁评估和调整模型的超参数。假设我们想训练一个决策树分类器,并找到max_depth
超参数的最优值,该超参数决定树的最大深度。
为此,我们可以多次使用训练集训练模型,每次使用不同的超参数值。然后,我们可以使用验证集评估所有这些模型的表现。我们选择其中表现最好的模型,并最终在测试集上评估其性能。
在以下代码片段中,我们演示了使用相同的train_test_split
函数创建训练集-验证集-测试集划分的一种可能方法:
import numpy as np
# define the size of the validation and test sets
VALID_SIZE = 0.1
TEST_SIZE = 0.2
# create the initial split - training and temp
X_train, X_temp, y_train, y_temp = train_test_split(
X, y,
test_size=(VALID_SIZE + TEST_SIZE),
stratify=y,
random_state=42
)
# calculate the new test size
new_test_size = np.around(TEST_SIZE / (VALID_SIZE + TEST_SIZE), 2)
# create the valid and test sets
X_valid, X_test, y_valid, y_test = train_test_split(
X_temp, y_temp,
test_size=new_test_size,
stratify=y_temp,
random_state=42
)
我们基本上执行了两次train_test_split
。重要的是,我们必须调整test_size
输入的大小,以确保最初定义的比例(70-10-20)得以保持。
我们还验证了所有操作是否按计划进行:数据集的大小是否与预定的划分一致,且每个数据集中的违约比例是否相同。我们使用以下代码片段来完成验证:
print("Percentage of data in each set ----")
print(f"Train: {100 * len(X_train) / len(X):.2f}%")
print(f"Valid: {100 * len(X_valid) / len(X):.2f}%")
print(f"Test: {100 * len(X_test) / len(X):.2f}%")
print("")
print("Class distribution in each set ----")
print(f"Train: {y_train.value_counts(normalize=True).values}")
print(f"Valid: {y_valid.value_counts(normalize=True).values}")
print(f"Test: {y_test.value_counts(normalize=True).values}")
执行代码片段将生成以下输出:
Percentage of data in each set ----
Train: 70.00%
Valid: 9.90%
Test: 20.10%
Class distribution in each set ----
Train: [0.77879899 0.22120101]
Valid: [0.77878788 0.22121212]
Test: [0.77880948 0.22119052]
我们已经验证原始数据集确实按预期的 70-10-20 比例进行了拆分,并且由于分层,违约(目标变量)的分布得以保持。有时,我们没有足够的数据将其拆分成三组,要么是因为我们总共有的数据样本不够,要么是因为数据高度不平衡,导致我们会从训练集中移除有价值的训练样本。因此,实践中经常使用一种叫做交叉验证的方法,具体内容请参见 使用网格搜索和交叉验证调整超参数 配方。
识别和处理缺失值
在大多数实际情况中,我们并不处理干净、完整的数据。我们可能会遇到的一个潜在问题就是缺失值。我们可以根据缺失值发生的原因对其进行分类:
完全随机缺失(MCAR)——缺失数据的原因与其他数据无关。一个例子可能是受访者在调查中不小心漏掉了一个问题。
随机缺失(MAR)——缺失数据的原因可以从另一列(或多列)数据中推断出来。例如,某个调查问题的缺失回答在某种程度上可以由性别、年龄、生活方式等其他因素条件性地确定。
非随机缺失(MNAR)——缺失值背后存在某种潜在原因。例如,收入非常高的人往往不愿透露收入。
结构性缺失数据——通常是 MNAR 的一个子集,数据缺失是由于某种逻辑原因。例如,当一个表示配偶年龄的变量缺失时,我们可以推测该人没有配偶。
一些机器学习算法可以处理缺失数据,例如,决策树可以将缺失值视为一个独立且独特的类别。然而,许多算法要么无法做到这一点,要么它们的流行实现(如 scikit-learn
)并未包含此功能。
我们应该只对特征进行填充,而不是目标变量!
一些常见的处理缺失值的解决方案包括:
删除包含一个或多个缺失值的观测值——虽然这是最简单的方法,但并不总是最佳选择,特别是在数据集较小的情况下。我们还需要注意,即使每个特征中只有很小一部分缺失值,它们也不一定出现在相同的观测(行)中,因此我们可能需要删除的行数会远高于预期。此外,在数据缺失并非随机的情况下,删除这些观测可能会引入偏差。
如果某一列(特征)大部分值都缺失,我们可以选择删除整列。然而,我们需要小心,因为这可能已经是我们模型的一个有信息的信号。
使用远远超出可能范围的值来替换缺失值,这样像决策树这样的算法可以将其视为特殊值,表示缺失数据。
在处理时间序列时,我们可以使用前向填充(取缺失值之前的最后一个已知观测值)、后向填充(取缺失值之后的第一个已知观测值)或插值法(线性或更高级的插值)。
热备填充法——在这个简单的算法中,我们首先选择一个或多个与包含缺失值的特征相关的其他特征。然后,我们按这些选定特征对数据集的行进行排序。最后,我们从上到下遍历行,将每个缺失值替换为同一特征中前一个非缺失的值。
使用聚合指标替换缺失值——对于连续数据,我们可以使用均值(当数据中没有明显的异常值时)或中位数(当数据中存在异常值时)。对于分类变量,我们可以使用众数(集合中最常见的值)。均值/中位数填充的潜在缺点包括减少数据集的方差并扭曲填充特征与数据集其余部分之间的相关性。
使用按组计算的聚合指标替换缺失值——例如,在处理与身体相关的指标时,我们可以按性别计算均值或中位数,以更准确地替代缺失数据。
基于机器学习的方法——我们可以将考虑的特征作为目标,使用完整的数据训练一个模型并预测缺失观测值的值。
通常,探索缺失值是探索性数据分析(EDA)的一部分。在分析使用pandas_profiling
生成的报告时,我们简要提到了这一点。但我们故意在现在之前没有详细讨论,因为在训练/测试集拆分后进行任何类型的缺失值填充非常关键。否则,我们会导致数据泄漏。
在这个方案中,我们展示了如何识别数据中的缺失值以及如何填补它们。
准备工作
对于这个方案,我们假设已经有了前一个方案“将数据拆分为训练集和测试集”中的分层训练/测试集拆分输出。
如何做到这一点...
执行以下步骤以调查并处理数据集中的缺失值:
导入库:
import pandas as pd import missingno as msno from sklearn.impute import SimpleImputer
检查 DataFrame 的信息:
X.info()
执行该代码段会生成以下摘要(缩略版):
RangeIndex: 30000 entries, 0 to 29999 Data columns (total 23 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 limit_bal 30000 non-null int64 1 sex 29850 non-null object 2 education 29850 non-null object 3 marriage 29850 non-null object 4 age 29850 non-null float64 5 payment_status_sep 30000 non-null object 6 payment_status_aug 30000 non-null object 7 payment_status_jul 30000 non-null object
我们的数据集有更多的列,但缺失值仅存在于摘要中可见的四列中。为了简洁起见,我们没有包括其余的输出。
可视化 DataFrame 的空值情况:
msno.matrix(X)
运行这行代码会生成以下图表:
图 13.15:贷款违约数据集的空值矩阵图
在列中可见的白色条形代表缺失值。我们应该记住,在处理大数据集且仅有少量缺失值时,这些白色条形可能相当难以察觉。
图表右侧的线条描述了数据完整性的形状。这两组数字表示数据集中的最大和最小空值数量。当一个观察值没有缺失值时,线条将位于最右边的位置,并且值等于数据集中的列数(此例中为 23)。随着缺失值数量在一个观察值中开始增加,线条会向左移动。空值为 21 表示数据集中有一行包含 2 个缺失值,因为该数据集的最大值是 23(列数)。
按数据类型定义包含缺失值的列:
NUM_FEATURES = ["age"] CAT_FEATURES = ["sex", "education", "marriage"]
填充数值特征:
for col in NUM_FEATURES: num_imputer = SimpleImputer(strategy="median") num_imputer.fit(X_train[[col]]) X_train.loc[:, col] = num_imputer.transform(X_train[[col]]) X_test.loc[:, col] = num_imputer.transform(X_test[[col]])
填充类别特征:
for col in CAT_FEATURES: cat_imputer = SimpleImputer(strategy="most_frequent") cat_imputer.fit(X_train[[col]]) X_train.loc[:, col] = cat_imputer.transform(X_train[[col]]) X_test.loc[:, col] = cat_imputer.transform(X_test[[col]])
我们可以通过info
方法验证训练集和测试集都不包含缺失值。
它是如何工作的...
在步骤 1中,我们导入了所需的库。然后,我们使用pandas
DataFrame 的info
方法查看列的信息,如其类型和非空观测值的数量。总观测值数与非空观测值数的差值对应缺失观测值的数量。检查每列缺失值数量的另一种方式是运行X.isnull().sum()
。
除了填充,我们也可以删除包含缺失值的观测值(甚至是列)。要删除所有包含任何缺失值的行,我们可以使用X_train.dropna(how="any", inplace=True)
。在我们的示例中,缺失值的数量不大,但在实际数据集中,缺失值可能会很多,或者数据集太小,分析人员无法删除观测值。或者,我们还可以指定dropna
方法的thresh
参数,指明一个观测值(行)需要在多少列中缺失值才会被删除。
在步骤 3中,我们利用missingno
库可视化了 DataFrame 的空值情况。
在步骤 4中,我们定义了包含我们希望填充的特征的列表,每种数据类型一个列表。这样做的原因是,数值特征的填充策略与类别特征的填充策略不同。对于基本的填充,我们使用了来自scikit-learn
的SimpleImputer
类。
在步骤 5中,我们遍历了数值特征(在此案例中仅为年龄特征),并使用中位数来替换缺失值。在循环内,我们定义了具有正确策略("median"
)的填充对象,将其拟合到训练数据的指定列,并对训练数据和测试数据进行了转换。这样,中位数的估算仅使用了训练数据,从而防止了潜在的数据泄露。
在本节中,我们使用scikit-learn
处理缺失值的填充方法。然而,我们也可以手动处理。为此,对于每个包含缺失值的列(无论是在训练集还是测试集),我们需要使用训练集计算给定的统计量(均值/中位数/众数),例如,age_median = X_train.age.median()
。然后,我们需要使用这个中位数来填充年龄列中的缺失值(在训练集和测试集中都使用fillna
方法)。我们将在书籍的 GitHub 仓库中的 Jupyter 笔记本里展示如何操作。
步骤 6与步骤 5类似,都是使用相同的方法遍历分类列。不同之处在于所选的策略——我们使用了给定列中最频繁的值("most_frequent"
)。该策略适用于分类特征和数值特征。在后者情况下,它对应的是众数。
还有更多内容…
在处理缺失值时,还有一些值得注意的事项。
在 missingno 库中有更多的可视化工具
在本节中,我们已经覆盖了数据集中缺失值的空值矩阵表示。然而,missingno
库提供了更多有用的可视化工具:
msno.bar
——生成一个条形图,表示每一列的空值情况。这可能比空值矩阵更容易快速解释。msno.heatmap
——可视化空值相关性,也就是一个特征的存在/缺失如何影响另一个特征的存在。空值相关性的解释与标准的皮尔逊相关系数非常相似。它的取值范围从-1(当一个特征出现时,另一个特征肯定不出现)到 0(特征的出现或缺失彼此之间没有任何影响),再到 1(如果一个特征出现,则另一个特征也一定会出现)。msno.dendrogram
——帮助我们更好地理解变量之间的缺失相关性。在底层,它使用层次聚类方法,通过空值相关性将特征进行分箱。
图 13.16:空值树状图示例
为了解释该图,我们需要从上到下分析它。首先,我们应该查看聚类叶节点,它们在距离为零时被连接在一起。这些特征可以完全预测彼此的存在,也就是说,当一个特征存在时,另一个特征可能总是缺失,或者它们可能总是同时存在或同时缺失,依此类推。距离接近零的聚类叶节点能够很好地预测彼此。
在我们的例子中,树状图将每个观测值中都存在的特征联系在一起。我们对此非常确定,因为我们设计上仅在四个特征中引入了缺失值。
基于机器学习的缺失值填充方法
在本教程中,我们提到过如何填补缺失值。像用一个大值或均值/中值/众数替换缺失值的方法被称为单次填补方法,因为它们用一个特定的值来替代缺失值。另一方面,还有多次填补方法,其中之一是链式方程的多重填补(MICE)。
简而言之,该算法运行多个回归模型,每个缺失值是基于非缺失数据点的条件值来确定的。使用基于机器学习的方法进行填补的潜在好处是减少了单次填补方法带来的偏差。MICE 算法可以在scikit-learn
中找到,命名为IterativeImputer
。
另外,我们可以使用最近邻填补(在scikit-learn
的KNNImputer
中实现)。KNN 填补的基本假设是,缺失的值可以通过来自最接近观测值的同一特征的其他观测值来近似。观测值之间的接近度是通过其他特征和某种距离度量来确定的,例如欧几里得距离。
由于该算法使用 KNN,它也有一些缺点:
需要调节超参数k以获得最佳性能
我们需要对数据进行标准化,并预处理类别特征
我们需要选择一个合适的距离度量(特别是在我们有类别和数值特征混合的情况下)
该算法对异常值和数据中的噪声较为敏感
由于需要计算每一对观测值之间的距离,因此计算开销可能较大
另一种可用的基于机器学习的算法叫做MissForest(可在missingpy
库中找到)。简而言之,该算法首先用中值或众数填补缺失值。然后,使用随机森林模型来预测缺失的特征,模型通过其他已知特征训练得到。该模型使用我们知道目标值的观测数据进行训练(即在第一步中未填补的观测数据),然后对缺失特征的观测数据进行预测。在下一步中,初始的中值/众数预测将被来自随机森林模型的预测值替代。这个过程会循环多次,每次迭代都试图改进前一次的结果。当满足某个停止准则或耗尽允许的迭代次数时,算法停止。
MissForest 的优点:
可以处理数值型和类别型特征中的缺失值
不需要数据预处理(如标准化)
对噪声数据具有鲁棒性,因为随机森林几乎不使用无信息特征
非参数化——它不对特征之间的关系做任何假设(而 MICE 假设线性关系)
可以利用特征之间的非线性和交互效应来提高插补性能。
MissForest 的缺点:
插补时间随观测值数量、特征数量以及包含缺失值的特征数量的增加而增加。
与随机森林类似,解释起来不太容易。
这是一种算法,而不是我们可以存储在某个地方(例如,作为 pickle 文件)并在需要时重新使用的模型对象,用于插补缺失值。
另见
额外的资源可以在这里找到:
Azur, M. J., Stuart, E. A., Frangakis, C., & Leaf, P. J. (2011). “链式方程法的多重插补:它是什么?它是如何工作的?” 国际精神病学研究方法杂志,20(1),40-49\。
doi.org/10.1002/mpr.329
。Buck, S. F. (1960). “一种适用于电子计算机的多元数据缺失值估算方法。” 皇家统计学会学报:B 系列(方法论),22(2),302-306\。
www.jstor.org/stable/2984099
。Stekhoven, D. J. & Bühlmann, P. (2012). “MissForest——适用于混合数据类型的非参数缺失值插补。” 生物信息学,28(1),112-118。
van Buuren, S. & Groothuis-Oudshoorn, K. (2011). “MICE:基于链式方程的多重插补(R 语言)。” 统计软件杂志 45 (3): 1–67\。
Van Buuren, S. (2018). 缺失数据的灵活插补。CRC 出版社。
miceforest
——一个用于快速、内存高效的 MICE 与 LightGBM 的 Python 库。missingpy
——一个 Python 库,包含 MissForest 算法的实现。
编码类别变量
在之前的配方中,我们看到一些特征是类别变量(最初表示为object
或category
数据类型)。然而,大多数机器学习算法只能处理数字数据。这就是为什么我们需要将类别特征编码为与机器学习模型兼容的表示形式。
编码类别特征的第一种方法叫做标签编码。在这种方法中,我们用不同的数字值替代特征的类别值。例如,对于三种不同的类别,我们使用以下表示:[0, 1, 2]。
这与转换为pandas
中的category
数据类型的结果非常相似。假设我们有一个名为df_cat
的 DataFrame,它有一个叫做feature_1
的特征。这个特征被编码为category
数据类型。我们可以通过运行df_cat["feature_1"].cat.codes
来访问类别的编码值。此外,我们可以通过运行dict(zip(df_cat["feature_1"].cat.codes, df_cat["feature_1"]))
来恢复映射。我们也可以使用pd.factorize
函数得到一个非常相似的表示。
标签编码的一个潜在问题是,它会在类别之间引入一种关系,而实际中这种关系可能并不存在。在一个三类的例子中,关系如下:0 < 1 < 2。如果这些类别是例如国家,这就没有多大意义。然而,这对表示某种顺序的特征(有序变量)来说是有效的。例如,标签编码可以很好地用于服务评级,评级范围为差-中等-好。
为了解决前述问题,我们可以使用独热编码。在这种方法中,对于特征的每个类别,我们都会创建一个新的列(有时称为虚拟变量),通过二进制编码表示某行是否属于该类别。该方法的一个潜在缺点是,它会显著增加数据集的维度(维度灾难)。首先,这会增加过拟合的风险,特别是在数据集中观测值不多时。其次,高维数据集对于任何基于距离的算法(例如 k-最近邻)来说都是一个重大问题,因为在高维情况下,多个维度会导致所有观测值彼此之间看起来等距。这自然会使基于距离的模型变得无效。
另一个需要注意的问题是,创建虚拟变量会给数据集引入一种冗余的形式。事实上,如果某个特征有三个类别,我们只需要两个虚拟变量就能完全表示它。因为如果一个观测值不是其中两个,它就必须是第三个。这通常被称为虚拟变量陷阱,最佳实践是总是从这种编码中删除一列(称为参考值)。在没有正则化的线性模型中,这一点尤其重要。
总结一下,我们应该避免使用标签编码,因为它会给数据引入虚假的顺序,这可能导致错误的结论。基于树的方法(决策树、随机森林等)可以与分类数据和标签编码一起工作。然而,对于线性回归、计算特征之间距离度量的模型(例如 k-means 聚类或 k-最近邻)或人工神经网络(ANN)等算法,独热编码是分类特征的自然表示。
准备就绪
对于本食谱,我们假设我们已经得到了前一个食谱识别和处理缺失值的填充训练集和测试集的输出。
如何操作...
执行以下步骤,使用标签编码和独热编码对分类变量进行编码:
导入库:
import pandas as pd from sklearn.preprocessing import LabelEncoder, OneHotEncoder from sklearn.compose import ColumnTransformer
使用标签编码器对选定的列进行编码:
COL = "education" X_train_copy = X_train.copy() X_test_copy = X_test.copy() label_enc = LabelEncoder() label_enc.fit(X_train_copy[COL]) X_train_copy.loc[:, COL] = label_enc.transform(X_train_copy[COL]) X_test_copy.loc[:, COL] = label_enc.transform(X_test_copy[COL]) X_test_copy[COL].head()
运行该代码片段会生成转换后的列的以下预览:
6907 3 24575 0 26766 3 2156 0 3179 3 Name: education, dtype: int64
我们创建了
X_train
和X_test
的副本,仅仅是为了展示如何使用LabelEncoder
,但我们不希望修改我们稍后打算使用的实际数据框。我们可以通过使用
classes_
属性访问在已拟合的LabelEncoder
中存储的标签。选择进行独热编码的类别特征:
cat_features = X_train.select_dtypes(include="object") \ .columns \ .to_list() cat_features
我们将对以下列应用独热编码:
['sex', 'education', 'marriage', 'payment_status_sep', 'payment_status_aug', 'payment_status_jul', 'payment_status_jun', 'payment_status_may', 'payment_status_apr']
实例化
OneHotEncoder
对象:one_hot_encoder = OneHotEncoder(sparse=False, handle_unknown="error", drop="first")
使用独热编码器创建列转换器:
one_hot_transformer = ColumnTransformer( [("one_hot", one_hot_encoder, cat_features)], remainder="passthrough", verbose_feature_names_out=False )
适配转换器:
one_hot_transformer.fit(X_train)
执行代码片段会打印出以下列转换器的预览:
图 13.17:使用独热编码的列转换器预览
对训练集和测试集应用转换:
col_names = one_hot_transformer.get_feature_names_out() X_train_ohe = pd.DataFrame( one_hot_transformer.transform(X_train), columns=col_names, index=X_train.index ) X_test_ohe = pd.DataFrame(one_hot_transformer.transform(X_test), columns=col_names, index=X_test.index)
如我们之前所提到的,独热编码可能带来增加数据集维度的缺点。在我们的例子中,我们一开始有 23 列,应用独热编码后,最终得到了 72 列。
它是如何工作的...
首先,我们导入了所需的库。第二步中,我们选择了要进行标签编码的列,实例化了LabelEncoder
,将其拟合到训练数据上,并转换了训练集和测试集。我们不想保留标签编码,因此我们对数据框进行了副本操作。
我们展示了使用标签编码,因为它是可用选项之一,但它有相当严重的缺点。因此在实际应用中,我们应避免使用它。此外,scikit-learn
的文档警告我们以下声明:此转换器应当用于编码目标值,即 y,而非输入 X。
在步骤 3中,我们开始为独热编码做准备,通过创建一个包含所有类别特征的列表。我们使用select_dtypes
方法选择所有object
数据类型的特征。
在步骤 4中,我们创建了OneHotEncoder
的实例。我们指定不希望使用稀疏矩阵(这是一种特殊的数据类型,适用于存储具有较高零值比例的矩阵),我们删除了每个特征的第一列(以避免虚拟变量陷阱),并指定了当编码器在应用转换时遇到未知值时该如何处理(handle_unknown='error'
)。
在步骤 5中,我们定义了ColumnTransformer
,这是一种方便的方法,可以将相同的转换(在本例中是独热编码器)应用于多列。我们传递了一个步骤列表,其中每个步骤都由一个元组定义。在这个例子中,它是一个包含步骤名称("one_hot"
)、要应用的转换以及我们希望应用该转换的特征的元组。
在创建ColumnTransformer
时,我们还指定了另一个参数remainder="passthrough"
,这实际上仅对指定的列进行了拟合和转换,同时保持其余部分不变。remainder
参数的默认值是"drop"
,会丢弃未使用的列。我们还将verbose_feature_names_out
参数的值设置为False
。这样,稍后使用get_feature_names_out
方法时,它将不会在所有特征名称前加上生成该特征的转换器名称。
如果我们没有更改它,某些特征将具有"one_hot__"
前缀,而其他特征将具有"remainder__"
前缀。
在步骤 6中,我们使用fit
方法将列转换器拟合到训练数据。最后,我们使用transform
方法对训练集和测试集应用了转换。由于transform
方法返回的是numpy
数组而不是pandas
DataFrame,我们必须自己进行转换。我们首先使用get_feature_names_out
提取特征名称。然后,我们使用转换后的特征、新的列名和旧的索引(以保持顺序)创建了一个pandas
DataFrame。
类似于处理缺失值或检测异常值,我们仅将所有转换器(包括独热编码)拟合到训练数据,然后将转换应用于训练集和测试集。通过这种方式,我们可以避免潜在的数据泄漏。
还有更多内容...
我们还想提及一些关于类别变量编码的其他事项。
使用 pandas 进行独热编码
除了scikit-learn
,我们还可以使用pd.get_dummies
对类别特征进行独热编码。示例语法如下:
pd.get_dummies(X_train, prefix_sep="_", drop_first=True)
了解这种替代方法是有益的,因为它可能更易于使用(列名会自动处理),特别是在创建快速概念验证(PoC)时。然而,在将代码投入生产时,最佳方法是使用scikit-learn
的变体,并在管道中创建虚拟变量。
为 OneHotEncoder 指定可能的类别
在创建ColumnTransformer
时,我们本可以另外提供一个包含所有考虑特征的可能类别的列表。以下是一个简化的示例:
one_hot_encoder = OneHotEncoder(
categories=[["Male", "Female", "Unknown"]],
sparse=False,
handle_unknown="error",
drop="first"
)
one_hot_transformer = ColumnTransformer(
[("one_hot", one_hot_encoder, ["sex"])]
)
one_hot_transformer.fit(X_train)
one_hot_transformer.get_feature_names_out()
执行该代码片段将返回以下内容:
array(['one_hot__sex_Female', 'one_hot__sex_Unknown'], dtype=object)
通过传递一个包含每个特征可能类别的列表(列表的列表),我们考虑到了一种可能性,即某个特定值在训练集中可能没有出现,但可能会出现在测试集中(或在生产环境中新观测值的批次中)。如果是这种情况,我们就会遇到错误。
在前面的代码块中,我们向表示性别的列添加了一个名为"Unknown"
的额外类别。因此,我们会为该类别生成一个额外的“虚拟”列。男性类别被作为参考类别而被删除。
类别编码器库
除了使用pandas
和scikit-learn
,我们还可以使用另一个名为Category Encoders
的库。它属于一组与scikit-learn
兼容的库,并提供了一些编码器,采用类似的 fit-transform 方法。这也是为什么它可以与ColumnTransformer
和Pipeline
一起使用的原因。
我们展示了一个替代的一热编码实现。
导入库:
import category_encoders as ce
创建编码器对象:
one_hot_encoder_ce = ce.OneHotEncoder(use_cat_names=True)
此外,我们可以指定一个名为drop_invariant
的参数,表示我们希望丢弃没有方差的列,例如仅填充一个唯一值的列。这可以帮助减少特征数量。
拟合编码器,并转换数据:
one_hot_encoder_ce.fit(X_train)
X_train_ce = one_hot_encoder_ce.transform(X_train)
这种一热编码器的实现会自动编码仅包含字符串的列(除非我们通过将类别列的列表传递给cols
参数来指定只编码部分列)。默认情况下,它还返回一个pandas
DataFrame(与scikit-learn
实现中的numpy
数组相比),并调整了列名。这种实现的唯一缺点是,它不允许丢弃每个特征的一个冗余虚拟变量列。
关于一热编码和基于决策树的算法的警告
尽管基于回归的模型自然能处理一热编码特征的 OR 条件,但决策树算法却不那么简单。从理论上讲,决策树可以在不需要编码的情况下处理类别特征。
然而,scikit-learn
中流行的实现仍然要求所有特征必须是数值型的。简单来说,这种方法偏向于连续数值型特征,而不是一热编码的虚拟变量,因为单个虚拟变量只能将特征信息的一部分带入模型。一个可能的解决方案是使用另一种编码方式(标签/目标编码)或使用能够处理类别特征的实现,例如h2o
库中的随机森林或 LightGBM 模型。
拟合决策树分类器
决策树分类器是一种相对简单但非常重要的机器学习算法,既可以用于回归问题,也可以用于分类问题。这个名字来源于模型创建一组规则(例如,if x_1 > 50 and x_2 < 10 then y = 'default'
),这些规则加起来可以被可视化为一棵树。决策树通过在某个值处反复划分特征,将特征空间划分为若干较小的区域。为此,它们使用贪心算法(结合一些启发式方法)来找到一个分裂点,以最小化子节点的总体杂质。在分类任务中,杂质通过基尼杂质或熵来衡量,而在回归问题中,树使用均方误差或均绝对误差作为衡量标准。
在二分类问题中,算法试图获得包含尽可能多来自同一类别的观测数据的节点,从而最小化不纯度。分类问题中,终端节点(叶子节点)的预测是基于众数,而回归问题则是基于均值。
决策树是许多复杂算法的基础,例如随机森林、梯度提升树、XGBoost、LightGBM、CatBoost 等。
决策树的优点包括以下几点:
以树形结构轻松可视化——具有很高的可解释性
快速的训练和预测阶段
需要调整的超参数相对较少
支持数值型和分类特征
可以处理数据中的非线性关系
通过特征工程可以进一步改善,但并不需要强制这样做
不需要对特征进行缩放或标准化
通过选择划分样本的特征,整合其特征选择的版本
非参数模型——不对特征/目标的分布做任何假设
另一方面,决策树的缺点包括以下几点:
不稳定性——决策树对输入数据中的噪声非常敏感。数据中的一个小变化可能会显著改变模型。
过拟合——如果我们没有提供最大值或停止准则,决策树可能会过度生长,导致模型的泛化能力差。
决策树只能进行内插,但不能进行外推——对于训练数据的特征空间边界之外的观测数据,它们做出恒定的预测。
底层的贪心算法无法保证选择全局最优的决策树。
类别不平衡可能导致决策树出现偏差。
决策树中类别变量的信息增益(熵的减少)会导致具有较多类别的特征结果偏向。
准备工作
对于这个食谱,我们假设已经得到了前一个食谱《编码分类变量》中的一热编码训练集和测试集的输出。
如何做…
执行以下步骤来拟合决策树分类器:
导入库:
from sklearn.tree import DecisionTreeClassifier, plot_tree from sklearn import metrics from chapter_13_utils import performance_evaluation_report
在本食谱及随后的食谱中,我们将使用
performance_evaluation_report
辅助函数。它绘制了用于评估二分类模型的有用指标(混淆矩阵、ROC 曲线)。此外,它还返回一个字典,包含更多的评估指标,我们将在工作原理部分进行介绍。创建模型实例,将其拟合到训练数据,并生成预测:
tree_classifier = DecisionTreeClassifier(random_state=42) tree_classifier.fit(X_train_ohe, y_train) y_pred = tree_classifier.predict(X_test_ohe)
评估结果:
LABELS = ["No Default", "Default"] tree_perf = performance_evaluation_report(tree_classifier, X_test_ohe, y_test, labels=LABELS, show_plot=True)
执行代码片段会生成以下图表:
图 13.18:拟合后的决策树分类器的性能评估报告
tree_perf
对象是一个字典,包含更多相关的评估指标,可以进一步帮助我们评估模型的性能。我们在下面展示这些指标:{'accuracy': 0.7141666666666666, 'precision': 0.3656509695290859, 'recall': 0.39788997739261495, 'specificity': 0.8039803124331265, 'f1_score': 0.3810898592565861, 'cohens_kappa': 0.1956931046277427, 'matthews_corr_coeff': 0.1959883714391891, 'roc_auc': 0.601583581287813, 'pr_auc': 0.44877724015824927, 'average_precision': 0.2789754297204212}
要获取更多关于评估指标解释的见解,请参考*它是如何工作的……*部分。
绘制拟合决策树的前几层:
plot_tree(tree_classifier, max_depth=3, fontsize=10)
执行代码片段会生成以下图形:
图 13.19:拟合的决策树,最大深度限制为 3
使用单行代码,我们已经能够可视化出大量信息。我们决定只绘制决策树的 3 层,因为拟合的树实际上达到了 44 层的深度。正如我们所提到的,不限制max_depth
超参数可能会导致这种情况,这也很可能会导致过拟合。
在树中,我们可以看到以下信息:
用于拆分树的特征以及拆分的值。不幸的是,使用默认设置时,我们只看到列号,而不是特征名称。我们将很快修正这个问题。
基尼不纯度的值。
每个节点/叶子中的样本数量。
每个节点/叶子中各类的观察数量。
我们可以通过plot_tree
函数的几个附加参数向图形中添加更多信息:
plot_tree(
tree_classifier,
max_depth=2,
feature_names=X_train_ohe.columns,
class_names=["No default", "Default"],
rounded=True,
filled=True,
fontsize=10
)
执行代码片段会生成以下图形:
图 13.20:拟合的决策树,最大深度限制为 2
在图 13.20中,我们看到了一些额外的信息:
用于创建拆分的特征名称
每个节点/叶子中占主导地位的类别名称
可视化决策树有许多好处。首先,我们可以深入了解哪些特征用于创建模型(这可能是特征重要性的一种衡量标准),以及哪些值用于创建拆分。前提是这些特征具有清晰的解释,这可以作为一种理智检查,看看我们关于数据和所考虑问题的初步假设是否成立,是否符合常识或领域知识。它还可以帮助向业务利益相关者呈现清晰、一致的故事,他们可以很容易地理解这种简单的模型表示。我们将在下一章深入讨论特征重要性和模型可解释性。
它是如何工作的……
在步骤 2中,我们使用了典型的scikit-learn
方法来训练机器学习模型。首先,我们创建了DecisionTreeClassifier
类的对象(使用所有默认设置和固定的随机状态)。然后,我们使用fit
方法将模型拟合到训练数据(需要传入特征和目标)。最后,我们通过predict
方法获得预测结果。
使用predict
方法会返回一个预测类别的数组(在本例中是 0 或 1)。然而,也有一些情况我们对预测的概率或分数感兴趣。为了获取这些值,我们可以使用predict_proba
方法,它返回一个n_test_observations
行,n_classes
列的数组。每一行包含所有可能类别的概率(这些概率的总和为 1)。在二分类的情况下,当对应的概率超过 50%时,predict
方法会自动将正类分配给该观察值。
在步骤 3中,我们评估了模型的表现。我们使用了一个自定义函数来展示所有的结果。我们不会深入探讨它的具体细节,因为它是标准的,且是通过使用scikit-learn
库中的metrics
模块的函数构建的。有关该函数的详细描述,请参考附带的 GitHub 仓库。
混淆矩阵总结了所有可能的预测值与实际目标值之间的组合。可能的值如下:
真正阳性(TP):模型预测为违约,且客户违约了。
假阳性(FP):模型预测为违约,但客户未违约。
真正阴性(TN):模型预测为好客户,且客户未违约。
假阴性(FN):模型预测为好客户,但客户违约了。
在上面呈现的场景中,我们假设违约是由正类表示的。这并不意味着结果(客户违约)是好或正面的,仅仅表示某事件发生了。通常情况下,多数类是“无关”的情况,会被赋予负标签。这是数据科学项目中的典型约定。
使用以上呈现的值,我们可以进一步构建多个评估标准:
准确率 [表示为 (TP + TN) / (TP + FP + TN + FN)]——衡量模型正确预测观察值类别的整体能力。
精确度 [表示为 TP / (TP + FP)]——衡量所有正类预测(在我们的案例中是违约)中,实际为正类的比例。在我们的项目中,它回答了这个问题:在所有违约预测中,实际违约的客户有多少?或者换句话说:当模型预测违约时,它的准确率有多高?
召回率 [表示为 TP / (TP + FN)]——衡量所有正类样本中被正确预测的比例。也叫做敏感度或真正阳性率。在我们的案例中,它回答了这个问题:我们正确预测了多少实际发生的违约事件?
F-1 分数—精确度和召回率的调和平均数。使用调和平均数而非算术平均数的原因是它考虑了两个分数之间的协调性(相似性)。因此,它惩罚极端结果,并避免高度不平衡的值。例如,一个精确度为 1 而召回率为 0 的分类器,在使用简单平均时得分为 0.5,而在使用调和平均时得分为 0。
特异性 [表示为 TN / (TN + FP)]—衡量负类案例(没有违约的客户)中实际上没有违约的比例。理解特异性的一个有用方法是将其视为负类的召回率。
理解这些指标背后的细微差别对于正确评估模型性能非常重要。在类别不平衡的情况下,准确率可能会非常具有误导性。假设 99%的数据不是欺诈的,只有 1%是欺诈的。那么,一个将每个观察值都分类为非欺诈的天真模型可以达到 99%的准确率,但实际上它是毫无价值的。这就是为什么在这种情况下,我们应该参考精确度或召回率:
当我们尽力实现尽可能高的精确度时,我们将减少假阳性,但代价是增加假阴性。当假阳性的代价很高时,我们应该优化精确度,例如在垃圾邮件检测中。
在优化召回率时,我们将减少假阴性,但代价是增加假阳性。当假阴性的代价很高时,我们应该优化召回率,例如在欺诈检测中。
关于哪种指标最好,没有一刀切的规则。我们试图优化的指标应根据使用场景来选择。
第二张图包含接收者操作特征(ROC)曲线。ROC 曲线展示了不同概率阈值下真实正类率(TPR,召回率)与假阳性率(FPR,即 1 减去特异性)之间的权衡。概率阈值决定了当预测概率超过某一值时,我们判断观察结果属于正类(默认值为 50%)。
一个理想的分类器将具有 0 的假阳性率和 1 的真实正类率。因此,ROC 图中的最佳点是图中的(0,1)点。一个有技巧的模型曲线将尽可能接近这个点。另一方面,一个没有技巧的模型将会有一条接近对角线(45°)的线。为了更好地理解 ROC 曲线,请考虑以下内容:
假设我们将决策阈值设置为 0,也就是说,所有观察结果都被分类为违约。这得出两个结论。首先,没有实际的违约被预测为负类(假阴性),这意味着真实正类率(召回率)为 1。其次,没有良好的客户被分类为正类(真实负类),这意味着假阳性率也为 1。这对应于 ROC 曲线的右上角。
让我们假设另一种极端情况,假设决策阈值为 1,也就是说,所有客户都被分类为优质客户(无违约,即负类)。由于完全没有正预测,这将导致以下结论:首先,没有真正例(TPR = 0)。其次,没有假正例(FPR = 0)。这种情况对应于曲线的左下角。
因此,曲线上的所有点都对应于分类器在两个极端(0 和 1)之间的各个阈值下的得分。该曲线应接近理想点,即真正例率为 1,假正例率为 0。也就是说,所有违约客户都不被分类为优质客户,所有优质客户都不被分类为可能违约。换句话说,这是一个完美的分类器。
如果性能接近对角线,说明模型对违约和非违约客户的分类大致相同,等同于随机猜测。换句话说,这个分类器与随机猜测一样好。
一个位于对角线下方的模型是可能的,实际上比“无技能”模型更好,因为它的预测可以简单地反转,从而获得更好的性能。
总结模型性能时,我们可以通过一个数字来查看ROC 曲线下的面积(AUC)。它是衡量所有可能决策阈值下的综合性能的指标。AUC 的值介于 0 和 1 之间,它告诉我们模型区分各类的能力。AUC 为 0 的模型总是错误的,而 AUC 为 1 的模型总是正确的。AUC 为 0.5 表示模型没有任何技能,几乎等同于随机猜测。
我们可以用概率的角度来解读 AUC。简而言之,它表示正类概率与负类概率的分离程度。AUC 代表一个模型将一个随机正类样本排得比一个随机负类样本更高的概率。
一个例子可能会更容易理解。假设我们有一些来自模型的预测结果,这些预测结果按得分/概率升序排列。图 13.21 展示了这一点。AUC 为 75% 意味着,如果我们随机选择一个正类样本和一个负类样本,那么它们以 75% 的概率会被正确排序,也就是说,随机的正类样本位于随机负类样本的右侧。
图 13.21:按预测得分/概率排序的模型输出
实际上,我们可能使用 ROC 曲线来选择一个阈值,从而在假正例和假负例之间取得适当的平衡。此外,AUC 是比较不同模型性能差异的一个好指标。
在上一步中,我们使用plot_tree
函数可视化了决策树。
还有更多...
我们已经涵盖了使用机器学习模型(在我们的案例中是决策树)解决二分类任务的基础内容,并且讲解了最常用的分类评估指标。然而,仍然有一些有趣的主题值得至少提及。
更深入地探讨分类评估指标
我们已经深入探讨了一个常见的评估指标——ROC 曲线。它的一个问题是,在处理(严重)类别不平衡时,它在评估模型表现时失去了可信度。在这种情况下,我们应该使用另一种曲线——精确率-召回率曲线。这是因为,在计算精确率和召回率时,我们不使用真正负类,而只考虑少数类(即正类)的正确预测。
我们首先提取预测得分/概率,并计算不同阈值下的精确率和召回率:
y_pred_prob = tree_classifier.predict_proba(X_test_ohe)[:, 1]
precision, recall, _ = metrics.precision_recall_curve(y_test,
y_pred_prob)
由于我们实际上并不需要阈值,我们将该函数的输出替换为下划线。
计算完所需元素后,我们可以绘制该曲线:
ax = plt.subplot()
ax.plot(recall, precision,
label=f"PR-AUC = {metrics.auc(recall, precision):.2f}")
ax.set(title="Precision-Recall Curve",
xlabel="Recall",
ylabel="Precision")
ax.legend()
执行代码片段后会生成以下图表:
图 13.22:拟合的决策树分类器的精确率-召回率曲线
类似于 ROC 曲线,我们可以如下分析精确率-召回率曲线:
曲线中的每个点都对应一个不同决策阈值下的精确率和召回率值。
当决策阈值为 0 时,精确率 = 0,召回率 = 1。
当决策阈值为 1 时,精确率 = 1,召回率 = 0。
作为总结性指标,我们可以通过近似计算精确率-召回率曲线下的面积。
PR-AUC 的范围从 0 到 1,其中 1 表示完美的模型。
一个 PR-AUC 为 1 的模型可以识别所有正类样本(完美召回率),同时不会错误地将任何负类样本标记为正类(完美精确率)。完美的点位于(1, 1),即图表的右上角。
我们可以认为那些弯向(1, 1)点的模型是有技巧的。
图 13.22 中的 PR 曲线可能存在一个潜在问题,那就是由于在绘制每个阈值的精确率和召回率时进行的插值,它可能会显得过于乐观。通过以下代码片段,可以获得更为现实的表示:
ax = metrics.PrecisionRecallDisplay.from_estimator(
tree_classifier, X_test_ohe, y_test
)
ax.ax_.set_title("Precision-Recall Curve")
执行代码片段后会生成以下图表:
图 13.23:拟合的决策树分类器的更为现实的精确率-召回率曲线
首先,我们可以看到,尽管形状不同,但我们可以轻松识别出图形的模式以及插值实际的作用。我们可以想象将图表的极端点与单个点(两个指标的值大约为 0.4)连接起来,这样就能得到通过插值得到的形状。
其次,我们还可以看到得分大幅下降(从 0.45 降至 0.28)。在第一个案例中,我们通过 PR 曲线的梯形插值法计算得分(auc(precision, recall)
在scikit-learn
中)。在第二个案例中,得分实际上是另一种指标——平均精度。平均精度将精确度-召回曲线总结为每个阈值下精确度的加权平均,其中权重是通过从前一个阈值到当前阈值的召回率增加量来计算的。
尽管这两种指标在许多情况下产生非常相似的估计值,但它们在本质上是不同的。第一种方法使用了过于乐观的线性插值,并且其影响可能在数据高度偏斜/不平衡时更为明显。
我们已经介绍过 F1 得分,它是精确度和召回率的调和平均数。实际上,它是一个更一般的指标的特例,这个指标被称为-得分,其中
因子定义了召回率的权重,而精确度的权重为 1。为了确保权重的和为 1,两者都通过
进行归一化。这样的得分定义意味着以下几点:
——将更多权重放在召回率上
——与 F1 得分相同,因此召回率和精确度被视为同等重要
——将更多权重放在精确度上
使用精确度、召回率或 F1 得分的一些潜在陷阱包括这些指标是非对称的,也就是说,它们侧重于正类。通过查看它们的公式,我们可以清楚地看到它们从未考虑真实负类。这正是Matthew 相关系数(也叫phi 系数)试图克服的问题:
分析公式揭示了以下几点:
在计算得分时,混淆矩阵的所有元素都被考虑在内
该公式类似于用于计算皮尔逊相关系数的公式
MCC 将真实类别和预测类别视为两个二元变量,并有效地计算它们的相关系数
MCC 的值介于-1(分类器总是错误分类)和 1(完美分类器)之间。值为 0 表示分类器不比随机猜测好。总体而言,由于 MCC 是一个对称指标,要获得高值,分类器必须在预测正类和负类时都表现良好。
由于 MCC 不像 F1 得分那样直观且容易解释,它可能是一个较好的指标,尤其是在低精度和低召回率的代价未知或无法量化时。在这种情况下,MCC 可能比 F1 得分更好,因为它提供了一个更平衡(对称)的分类器评估。
使用 dtreeviz 可视化决策树
scikit-learn
中的默认绘图功能绝对可以认为足够好,用于可视化决策树。然而,我们可以通过使用 dtreeviz
库将其提升到一个新层次。
首先,我们导入库:
from dtreeviz.trees import *
然后,我们训练一个最大深度为 3 的较小决策树。这样做只是为了使可视化更易于阅读。不幸的是,dtreeviz
没有选项仅绘制树的 x 层级:
small_tree = DecisionTreeClassifier(max_depth=3,
random_state=42)
small_tree.fit(X_train_ohe, y_train)
最后,我们绘制树:
viz = dtreeviz(small_tree,
x_data=X_train_ohe,
y_data=y_train,
feature_names=X_train_ohe.columns,
target_name="Default",
class_names=["No", "Yes"],
title="Decision Tree - Loan default dataset")
viz
运行代码片段生成以下图表:
图 13.24:使用 dtreeviz 可视化的决策树
与之前生成的图相比,使用dtreeviz
创建的图表额外展示了用于分割的特征分布(针对每个类别分别显示),并附有分割值。此外,叶节点以饼图的形式呈现。
如需更多使用 dtreeviz
的示例,包括在树的所有分割中添加一个跟踪特定观测值的路径,请参考书籍 GitHub 仓库中的笔记本。
另见
使用 ROC-AUC 作为性能评估指标的危险性信息:
Lobo, J. M., Jiménez‐Valverde, A., & Real, R. (2008). “AUC:一个误导性的预测分布模型性能度量。” 全球生态学与生物地理学,17(2),145-151。
Sokolova, M. & Lapalme, G. (2009). “分类任务性能度量的系统分析。” 信息处理与管理,45(4),427-437。
关于精确度-召回率曲线的更多信息:
- Davis, J. & Goadrich, M. (2006 年 6 月). “精确度-召回率与 ROC 曲线的关系。”发表于 第 23 届国际机器学习会议论文集(第 233-240 页)。
关于决策树的附加资源:
Breiman, L., Friedman, J., Olshen, R., & Stone, C. (1984) 分类与回归树。Chapman & Hall,Wadsworth,New York。
Breiman, L. (2017). 分类与回归树。Routledge。
使用管道组织项目
在之前的示例中,我们展示了构建机器学习模型所需的所有步骤——从加载数据、将其划分为训练集和测试集、填补缺失值、编码分类特征,到最终拟合决策树分类器。
该过程需要按照一定顺序执行多个步骤,在进行大量管道修改时有时会变得复杂。这就是 scikit-learn
引入管道的原因。通过使用管道,我们可以依次将一系列转换应用于数据,然后训练给定的估算器(模型)。
需要注意的一个重要点是,管道的中间步骤必须具有 fit
和 transform
方法,而最终的估算器只需要 fit
方法。
在scikit-learn
的术语中,我们将包含fit
和transform
方法的对象称为变换器。我们用它们来清洗和预处理数据。一个例子是我们已经讨论过的OneHotEncoder
。类似地,我们使用估计器一词来指代包含fit
和predict
方法的对象。它们是机器学习模型,例如DecisionTreeClassifier
。
使用管道有几个好处:
流程更加容易阅读和理解——对给定列执行的操作链条清晰可见。
使避免数据泄漏变得更加容易,例如,在缩放训练集并使用交叉验证时。
步骤的顺序由管道强制执行。
提高了可重复性。
在本配方中,我们展示了如何创建整个项目的管道,从加载数据到训练分类器。
如何实现...
执行以下步骤来构建项目的管道:
导入库:
import pandas as pd from sklearn.model_selection import train_test_split from sklearn.impute import SimpleImputer from sklearn.preprocessing import OneHotEncoder from sklearn.compose import ColumnTransformer from sklearn.tree import DecisionTreeClassifier from sklearn.pipeline import Pipeline from chapter_13_utils import performance_evaluation_report
加载数据,分离目标变量,并创建分层的训练-测试集:
df = pd.read_csv("../Datasets/credit_card_default.csv", na_values="") X = df.copy() y = X.pop("default_payment_next_month") X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 )
准备数值/类别特征的列表:
num_features = X_train.select_dtypes(include="number") \ .columns \ .to_list() cat_features = X_train.select_dtypes(include="object") \ .columns \ .to_list()
定义数值管道:
num_pipeline = Pipeline(steps=[ ("imputer", SimpleImputer(strategy="median")) ])
定义类别管道:
cat_list = [ list(X_train[col].dropna().unique()) for col in cat_features ] cat_pipeline = Pipeline(steps=[ ("imputer", SimpleImputer(strategy="most_frequent")), ("onehot", OneHotEncoder(categories=cat_list, sparse=False, handle_unknown="error", drop="first")) ])
定义
ColumnTransformer
对象:preprocessor = ColumnTransformer( transformers=[ ("numerical", num_pipeline, num_features), ("categorical", cat_pipeline, cat_features) ], remainder="drop" )
定义包括决策树模型的完整管道:
dec_tree = DecisionTreeClassifier(random_state=42) tree_pipeline = Pipeline(steps=[ ("preprocessor", preprocessor), ("classifier", dec_tree) ])
将管道拟合到数据:
tree_pipeline.fit(X_train, y_train)
执行该代码段会生成管道的以下预览:
图 13.25:管道的预览
评估整个管道的性能:
LABELS = ["No Default", "Default"] tree_perf = performance_evaluation_report(tree_pipeline, X_test, y_test, labels=LABELS, show_plot=True)
执行该代码段会生成以下图表:
图 13.26:拟合管道的性能评估报告
我们看到,模型的性能与我们通过单独执行所有步骤所取得的结果非常相似。考虑到变化如此之小,这正是我们预期要实现的。
它是如何工作的...
在步骤 1中,我们导入了所需的库。列表可能看起来有些令人畏惧,但这是因为我们需要结合多个在前面的配方中使用的函数/类。
在步骤 2中,我们从 CSV 文件加载数据,将目标变量与特征分开,最后创建了一个分层的训练-测试集。然后,我们还创建了两个列表,分别包含数值特征和类别特征的名称。这样做是因为我们将根据特征的数据类型应用不同的变换。为了选择适当的列,我们使用了select_dtypes
方法。
在步骤 4中,我们定义了第一个Pipeline
,其中包含了我们希望应用于数值特征的变换。事实上,我们只想使用中位数值填补特征的缺失值。在创建Pipeline
类的实例时,我们提供了一个包含步骤的元组列表,每个元组由步骤的名称(便于识别和访问)和我们希望使用的类组成。在这种情况下,我们使用的是在识别和处理缺失值配方中涉及的SimpleImputer
类。
在步骤 5中,我们为类别特征准备了一个类似的流水线。不过,这一次,我们链式操作了两个不同的操作——插补器(使用最频繁的值)和独热编码器。对于编码器,我们还指定了一个名为cat_list
的列表,其中列出了所有可能的类别。我们仅基于X_train
提供了这些信息。这样做是为了为下一个步骤做准备,在这个步骤中,我们引入了交叉验证,期间可能会出现某些随机抽样不包含所有可用类别的情况。
在步骤 6中,我们定义了ColumnTransformer
对象。通常,当我们希望对不同组的列/特征应用不同的转换时,会使用ColumnTransformer
。在我们的案例中,我们为数值特征和类别特征分别定义了不同的流水线。同样,我们传递了一个元组列表,每个元组包含一个名称、我们之前定义的流水线之一和一个需要应用转换的列列表。我们还指定了remainder="drop"
,以删除未应用任何转换的额外列。在这种情况下,所有特征都应用了转换,因此没有列被删除。需要注意的是,ColumnTransformer
返回的是numpy
数组,而不是pandas
数据框!
scikit-learn
中还有一个有用的类是FeatureUnion
。当我们希望以不同的方式转换相同的输入数据,并将这些输出作为特征使用时,可以使用它。例如,我们可能正在处理文本数据,并希望应用两种变换:TF-IDF(词频-逆文档频率)向量化和提取文本的长度。这些输出应该附加到原始数据框中,以便我们将它们作为模型的特征。
在步骤 7中,我们再次使用了Pipeline
将preprocessor
(之前定义的ColumnTransformer
对象)与决策树分类器链式连接(为了可重复性,我们将随机状态设置为 42)。最后两步涉及将整个流水线拟合到数据,并使用自定义函数评估其性能。
performance_evaluation_report
函数的构建方式使其可以与任何具有predict
和predict_proba
方法的估计器或Pipeline
一起使用。这些方法用于获取预测值及其对应的分数/概率。
还有更多...
向流水线添加自定义变换器
在本教程中,我们展示了如何为数据科学项目创建整个流水线。然而,还有许多其他的变换可以作为预处理步骤应用于数据。其中一些包括:
缩放数值特征:换句话说,由于不同特征的量度范围不同,这会给模型带来偏差,因此需要对特征进行缩放。当我们处理那些计算特征间某种距离的模型(如 k 最近邻)或线性模型时,应该特别关注特征缩放。
scikit-learn
中一些常用的缩放选项包括StandardScaler
和MinMaxScaler
。离散化连续变量:我们可以将一个连续变量(例如年龄)转换为有限数量的区间(例如:<25 岁、26-50 岁、>51 岁)。当我们想要创建特定的区间时,可以使用
pd.cut
函数,而pd.qcut
可以用于基于分位数进行划分。转换/移除异常值:在进行探索性数据分析(EDA)时,我们经常会看到一些极端的特征值,这些值可能是由于某种错误造成的(例如,在年龄中多加了一位数字),或者它们与其他数据点不兼容(例如,在一群中产阶级公民中有一位千万富翁)。这样的异常值会影响模型的结果,因此处理它们是一种良好的实践。一个解决方案是将它们完全移除,但这样做可能会影响模型的泛化能力。我们也可以将它们调整为更接近常规值的范围。
基于决策树的机器学习模型不需要任何缩放处理。
在这个示例中,我们展示了如何创建一个自定义变换器来检测并修改异常值。我们应用一个简单的经验法则——我们将超过/低于均值加减 3 倍标准差的值限制在范围内。我们为此任务创建了一个专门的变换器,这样我们就可以将异常值处理整合到之前建立的管道中:
从
sklearn
导入基本的估算器和变换器类:from sklearn.base import BaseEstimator, TransformerMixin import numpy as np
为了使自定义变换器与
scikit-learn
的管道兼容,它必须具有诸如fit
、transform
、fit_transform
、get_params
和set_params
等方法。我们可以手动定义所有这些内容,但更具吸引力的做法是使用 Python 的类继承来简化过程。这就是为什么我们从
scikit-learn
导入了BaseEstimator
和TransformerMixin
类的原因。通过继承TransformerMixin
,我们无需再指定fit_transform
方法,而继承BaseEstimator
则自动提供了get_params
和set_params
方法。作为一种学习体验,深入了解
scikit-learn
中一些更流行的变换器/估算器的代码是非常有意义的。通过这样做,我们可以学到很多面向对象编程的最佳实践,并观察(并欣赏)这些类是如何始终如一地遵循相同的指导原则/原则的。定义
OutlierRemover
类:class OutlierRemover(BaseEstimator, TransformerMixin): def __init__(self, n_std=3): self.n_std = n_std def fit(self, X, y = None): if np.isnan(X).any(axis=None): raise ValueError("""Missing values in the array! Please remove them.""") mean_vec = np.mean(X, axis=0) std_vec = np.std(X, axis=0) self.upper_band_ = pd.Series( mean_vec + self.n_std * std_vec ) self.upper_band_ = ( self.upper_band_.to_frame().transpose() ) self.lower_band_ = pd.Series( mean_vec - self.n_std * std_vec ) self.lower_band_ = ( self.lower_band_.to_frame().transpose() ) self.n_features_ = len(self.upper_band_.columns) return self def transform(self, X, y = None): X_copy = pd.DataFrame(X.copy()) upper_band = pd.concat( [self.upper_band_] * len(X_copy), ignore_index=True ) lower_band = pd.concat( [self.lower_band_] * len(X_copy), ignore_index=True ) X_copy[X_copy >= upper_band] = upper_band X_copy[X_copy <= lower_band] = lower_band return X_copy.values
该类可以分解为以下几个组件:
在
__init__
方法中,我们存储了确定观察值是否会被视为异常值的标准差数量(默认为 3)。在
fit
方法中,我们存储了作为异常值的上下限阈值,以及一般的特征数量。在
transform
方法中,我们对所有超过fit
方法中确定的阈值的值进行了限制。
或者,我们也可以使用
pandas
DataFrame 的clip
方法来限制极端值。该类的一个已知限制是它无法处理缺失值。这就是为什么当存在缺失值时我们会抛出
ValueError
的原因。此外,我们在插补后使用OutlierRemover
来避免这个问题。当然,我们也可以在转换器中处理缺失值,但这会使代码更长且可读性更差。我们将此作为留给读者的练习。请参考scikit-learn
中SimpleImputer
的定义,了解如何在构建转换器时掩盖缺失值的示例。将
OutlierRemover
添加到数值管道中:num_pipeline = Pipeline(steps=[ ("imputer", SimpleImputer(strategy="median")), ("outliers", OutlierRemover()) ])
执行管道的其余部分以比较结果:
preprocessor = ColumnTransformer( transformers=[ ("numerical", num_pipeline, num_features), ("categorical", cat_pipeline, cat_features) ], remainder="drop" ) dec_tree = DecisionTreeClassifier(random_state=42) tree_pipeline = Pipeline(steps=[("preprocessor", preprocessor), ("classifier", dec_tree)]) tree_pipeline.fit(X_train, y_train) tree_perf = performance_evaluation_report(tree_pipeline, X_test, y_test, labels=LABELS, show_plot=True)
执行该代码片段会生成以下图表:
图 13.27:拟合后的管道性能评估报告(包括处理异常值)
包括异常值限制转换并未对整个管道的性能产生任何显著变化。
访问管道的元素
虽然管道使我们的项目更易于复现并减少数据泄露的风险,但它也有一个小缺点。访问管道元素以进行进一步检查或替换变得有点困难。让我们通过几个例子来说明。
我们通过以下代码片段开始显示整个管道,以字典形式表示:
tree_pipeline.named_steps
使用该结构(为简洁起见,此处未打印),我们可以通过我们分配给它的名称访问管道末端的机器学习模型:
tree_pipeline.named_steps["classifier"]
当我们想深入了解ColumnTransformer
时,事情变得有点复杂。假设我们想检查拟合后的OutlierRemover
的上带限(在upper_bands_
属性下)。为此,我们必须使用以下代码片段:
(
tree_pipeline
.named_steps["preprocessor"]
.named_transformers_["numerical"]["outliers"]
.upper_band_
)
首先,我们遵循了与访问管道末端估计器时相同的方法。这一次,我们只使用包含ColumnTransformer
的步骤名称。然后,我们使用named_transformers_
属性访问转换器的更深层次。我们选择了数值管道,然后使用它们相应的名称选择了异常值处理步骤。最后,我们访问了自定义转换器的上带限。
在访问ColumnTransformer
的步骤时,我们本可以使用transformers_
属性,而不是named_transformers_
。然而,那样的话,输出将是一个元组列表(与我们在定义ColumnTransformer
时手动提供的相同),我们必须使用整数索引来访问其元素。我们在 GitHub 上提供的笔记本中展示了如何使用transformers_
属性访问上层数据。
使用网格搜索和交叉验证调整超参数
在之前的示例中,我们使用了决策树模型来预测客户是否会违约。正如我们所见,树的深度达到了 44 级,这使得我们无法将其绘制出来。然而,这也可能意味着模型过拟合了训练数据,在未见过的数据上表现不佳。最大深度实际上是决策树的超参数之一,我们可以通过调整它来在欠拟合和过拟合之间找到平衡(偏差-方差权衡),以提高性能。
首先,我们概述一些超参数的属性:
模型的外部特征
未基于数据进行估算
可以视为模型的设置
在训练阶段之前设置
调整它们可以提高性能
我们还可以考虑一些参数的属性:
模型的内部特征
基于数据进行估算,例如线性回归的系数
在训练阶段学习到的
在调整模型的超参数时,我们希望评估其在未用于训练的数据上的表现。在将数据划分为训练集和测试集的示例中,我们提到可以创建一个额外的验证集。验证集专门用于调整模型的超参数,在最终使用测试集评估之前。然而,创建验证集是有代价的:用于训练(并可能用于测试)的数据被牺牲掉,这在处理小数据集时尤其有害。
这就是交叉验证变得如此流行的原因。它是一种技术,能够帮助我们可靠地估计模型的泛化误差。通过一个例子,最容易理解它是如何工作的。在进行k折交叉验证时,我们将训练数据随机拆分为k折。然后,我们使用k-1 折进行训练,并在第k折上评估模型的表现。我们重复这个过程k次,并对结果得分取平均值。
交叉验证的一个潜在缺点是计算成本,尤其是在与网格搜索调参一起使用时。
图 13.28:5 折交叉验证过程示意图
我们已经提到过网格搜索是一种用于调整超参数的技术。其基本思路是创建所有可能的超参数组合网格,并使用每一种组合训练模型。由于其详尽的暴力搜索方法,这种方法可以保证在网格中找到最优的参数。缺点是,当增加更多的参数或考虑更多的值时,网格的大小呈指数级增长。如果我们还使用交叉验证,所需的模型拟合和预测数量将显著增加!
让我们通过一个例子来说明,假设我们正在训练一个具有两个超参数的模型:a 和 b。我们定义一个覆盖以下超参数值的网格:{"a": [1, 2, 3], "b": [5, 6]}
。这意味着我们的网格中有 6 种独特的超参数组合,算法将会进行 6 次模型拟合。如果我们还使用 5 折交叉验证程序,这将导致在网格搜索过程中拟合 30 个独特的模型!
作为解决网格搜索中遇到问题的潜在方案,我们还可以使用随机搜索(也称为随机化网格搜索)。在这种方法中,我们选择一组随机的超参数,训练模型(也使用交叉验证),返回评分,并重复整个过程,直到达到预定义的迭代次数或计算时间限制。对于非常大的网格,随机搜索优于网格搜索。因为前者可以探索更广泛的超参数空间,通常会在更短的时间内找到与最优超参数集(通过详尽的网格搜索获得)非常相似的超参数集。唯一的问题是:多少次迭代足以找到一个好的解决方案?不幸的是,无法简单回答这个问题。大多数情况下,它由可用的资源来决定。
准备工作
在这个示例中,我们使用了在使用管道组织项目食谱中创建的决策树管道,包括*更多内容…*部分中的异常值处理。
如何操作...
执行以下步骤以在我们在使用管道组织项目食谱中创建的决策树管道上运行网格搜索和随机搜索:
导入库:
from sklearn.model_selection import ( GridSearchCV, cross_val_score, RandomizedSearchCV, cross_validate, StratifiedKFold ) from sklearn import metrics
定义交叉验证方案:
k_fold = StratifiedKFold(5, shuffle=True, random_state=42)
使用交叉验证评估管道:
cross_val_score(tree_pipeline, X_train, y_train, cv=k_fold)
执行代码片段会返回一个包含估计器默认评分(准确度)值的数组:
array([0.72333333, 0.72958333, 0.71375, 0.723125, 0.72])
为交叉验证添加额外的度量:
cv_scores = cross_validate( tree_pipeline, X_train, y_train, cv=k_fold, scoring=["accuracy", "precision", "recall", "roc_auc"] ) pd.DataFrame(cv_scores)
执行代码片段会生成以下表格:
图 13.29:5 折交叉验证的结果
在图 13.29中,我们可以看到每个 5 折交叉验证的 4 个请求的度量值。这些度量值在每个测试折中非常相似,这表明使用分层拆分的交叉验证如预期般有效。
定义参数网格:
param_grid = { "classifier__criterion": ["entropy", "gini"], "classifier__max_depth": range(3, 11), "classifier__min_samples_leaf": range(2, 11), "preprocessor__numerical__outliers__n_std": [3, 4] }
运行详尽的网格搜索:
classifier_gs = GridSearchCV(tree_pipeline, param_grid, scoring="recall", cv=k_fold, n_jobs=-1, verbose=1) classifier_gs.fit(X_train, y_train)
下面我们可以看到,通过详尽搜索将拟合多少个模型:
Fitting 5 folds for each of 288 candidates, totalling 1440 fits
从详尽的网格搜索中得到的最佳模型如下:
Best parameters: {'classifier__criterion': 'gini', 'classifier__max_depth': 10, 'classifier__min_samples_leaf': 7, 'preprocessor__numerical__outliers__n_std': 4} Recall (Training set): 0.3858 Recall (Test set): 0.3775
评估调优后的管道性能:
LABELS = ["No Default", "Default"] tree_gs_perf = performance_evaluation_report( classifier_gs, X_test, y_test, labels=LABELS, show_plot=True )
执行该代码段会生成以下图表:
图 13.30:由详尽网格搜索识别出的最佳管道的性能评估报告
运行随机网格搜索:
classifier_rs = RandomizedSearchCV(tree_pipeline, param_grid, scoring="recall", cv=k_fold, n_jobs=-1, verbose=1, n_iter=100, random_state=42) classifier_rs.fit(X_train, y_train) print(f"Best parameters: {classifier_rs.best_params_}") print(f"Recall (Training set): {classifier_rs.best_score_:.4f}") print(f"Recall (Test set): {metrics.recall_score(y_test, classifier_rs.predict(X_test)):.4f}")
下面我们可以看到,随机搜索将训练比详尽搜索更少的模型:
Fitting 5 folds for each of 100 candidates, totalling 500 fits
从随机网格搜索中得到的最佳模型如下:
Best parameters: {'preprocessor__numerical__outliers__n_std': 3, 'classifier__min_samples_leaf': 7, 'classifier__max_depth': 10, 'classifier__criterion': 'gini'} Recall (Training set): 0.3854 Recall (Test set): 0.3760
在随机搜索中,我们查看了 100 组随机超参数组合,这大约覆盖了详尽搜索中的 1/3 的可能性。尽管随机搜索没有找到与详尽搜索相同的最佳模型,但两者在训练集和测试集上的性能非常相似。
它是如何工作的...
在步骤 2中,我们定义了 5 折交叉验证方案。由于数据本身没有固有的顺序,我们使用了洗牌并指定了随机种子以确保可重复性。分层抽样确保每一折在目标变量的类别分布上保持相似。这种设置在处理类别不平衡的问题时尤为重要。
在步骤 3中,我们使用cross_val_score
函数评估了在使用管道组织项目这一食谱中创建的管道。我们将估计器(整个管道)、训练数据和交叉验证方案作为参数传递给该函数。
我们也可以为cv
参数提供一个数字(默认值为 5)——在分类问题中,它会自动应用分层k折交叉验证。然而,通过提供自定义方案,我们也确保了定义了随机种子并且结果是可重复的。
我们可以明显观察到使用管道的另一个优势——在执行交叉验证时我们不会泄漏任何信息。如果没有管道,我们会使用训练数据拟合我们的变换器(例如,StandardScaler
),然后分别对训练集和测试集进行变换。这样,我们就不会泄漏测试集中的任何信息。然而,如果在这种已变换的训练集上进行交叉验证,我们仍然会泄漏一些信息。因为用于验证的折叠是利用整个训练集的信息进行变换的。
在步骤 4中,我们通过使用cross_validate
函数扩展了交叉验证。这个函数在多个评估标准上提供了更多的灵活性(我们使用了准确率、精确度、召回率和 ROC AUC)。此外,它还记录了训练和推断步骤所花费的时间。我们以pandas
数据框的形式打印了结果,以便于阅读。默认情况下,该函数的输出是一个字典。
在步骤 5中,我们定义了用于网格搜索的参数网格。这里需要记住的一个重要点是,使用Pipeline
对象时的命名规范。网格字典中的键是由步骤/模型的名称与超参数名称通过双下划线连接构成的。在这个例子中,我们在决策树分类器的三个超参数上进行了搜索:
criterion
—用于确定分裂的度量,可以是熵或基尼重要性。max_depth
—树的最大深度。min_samples_leaf
—叶子节点中的最小观察值数。它可以防止在叶子节点中创建样本数量过少的树。
此外,我们还通过使用均值的三倍或四倍标准差来进行异常值变换,来指示一个观察值是否为异常值。请注意名称的构造,名称中包含了以下几个信息:
preprocessor
—管道中的步骤。numerical
—它在ColumnTransformer
中的哪个管道内。outliers
—我们访问的那个内部管道的步骤。n_std
—我们希望指定的超参数名称。
当仅调整估算器(模型)时,我们应直接使用超参数的名称。
我们决定根据召回率选择表现最佳的决策树模型,即模型正确识别的所有违约事件的百分比。当我们处理不平衡类别时,这一评估指标无疑非常有用,例如在预测违约或欺诈时。在现实生活中,假阴性(预测没有违约时,实际上用户违约了)和假阳性(预测一个好客户违约)通常有不同的成本。为了预测违约,我们决定可以接受更多的假阳性成本,以换取减少假阴性的数量(漏掉的违约)。
在步骤 6中,我们创建了GridSearchCV
类的一个实例。我们将管道和参数网格作为输入提供。我们还指定了召回率作为用于选择最佳模型的评分指标(这里可以使用不同的指标)。我们还使用了自定义的交叉验证方案,并指定我们希望使用所有可用的核心来加速计算(n_jobs=-1
)。
在使用scikit-learn
的网格搜索类时,我们实际上可以提供多个评估指标(可以通过列表或字典指定)。当我们希望对拟合的模型进行更深入的分析时,这一点非常有帮助。我们需要记住的是,当使用多个指标时,必须使用refit
参数来指定应使用哪个指标来确定最佳的超参数组合。
然后我们使用了GridSearchCV
对象的fit
方法,就像在scikit-learn
中使用其他估算器一样。从输出结果中,我们看到网格包含了 288 种不同的超参数组合。对于每一组,我们都进行了五次模型拟合(5 折交叉验证)。
GridSearchCV
的默认设置refit=True
意味着,在整个网格搜索完成后,最佳模型会自动再次进行拟合,这次是使用整个训练集。然后,我们可以直接通过运行classifier_gs.predict(X_test)
来使用这个估算器(根据指定的标准进行识别)进行推断。
在第 8 步中,我们创建了一个随机化网格搜索实例。它类似于常规网格搜索,不同之处在于我们指定了最大迭代次数。在这种情况下,我们从参数网格中测试了 100 种不同的组合,大约是所有可用组合的 1/3。
穷举法和随机化法网格搜索之间还有一个额外的区别。在后者中,我们可以提供一个超参数分布,而不是一组离散的值。例如,假设我们有一个描述 0 到 1 之间比率的超参数。在穷举法网格搜索中,我们可能会指定以下值:[0, 0.2, 0.4, 0.6, 0.8, 1]
。在随机化搜索中,我们可以使用相同的值,搜索会从列表中随机(均匀地)选取一个值(无法保证所有值都会被测试)。或者,我们可能更倾向于从均匀分布(限制在 0 到 1 之间的值)中随机抽取一个值作为超参数的值。
在幕后,scikit-learn
应用了以下逻辑。如果所有超参数都以列表形式呈现,算法会执行不放回的抽样。如果至少有一个超参数是通过分布表示的,则会改为使用有放回的抽样。
还有更多...
使用逐步减半实现更快的搜索
对于每一组候选超参数,穷举法和随机法网格搜索都会使用所有可用数据来训练一个模型/管道。scikit-learn
还提供了一种叫做“逐步减半网格搜索”的方法,它基于逐步减半的思想。
该算法的工作原理如下。首先,使用可用训练数据的小子集拟合所有候选模型(通常使用有限的资源)。然后,挑选出表现最好的候选模型。在接下来的步骤中,这些表现最好的候选模型将使用更大的训练数据子集进行重新训练。这些步骤会不断重复,直到找到最佳的超参数组合。在这种方法中,每次迭代后,候选超参数的数量会减少,而训练数据的大小(资源)会增加。
逐次减半网格搜索的默认行为是将训练数据作为资源。然而,我们也可以使用我们尝试调整的估计器的另一个超参数,只要它接受正整数值。例如,我们可以使用随机森林模型的树木数量(n_estimators
)作为每次迭代中增加的资源。
算法的速度取决于两个超参数:
min_resources
—任何候选者允许使用的最小资源量。实际上,这对应于第一次迭代中使用的资源数量。factor
—缩减参数。factor
的倒数(1 /factor
)决定了每次迭代中作为最佳模型被选择的候选者比例。factor
与上一迭代的资源数量的乘积决定了当前迭代的资源数量。
虽然手动进行这些计算以充分利用大部分资源可能看起来有些令人望而生畏,但scikit-learn
通过min_resources
参数的"exhaust"
值使得这一过程变得更加简单。这样,算法将为我们确定第一次迭代中使用的资源数量,以便最后一次迭代使用尽可能多的资源。在默认情况下,它将导致最后一次迭代使用尽可能多的训练数据。
与随机网格搜索类似,scikit-learn
还提供了随机化的逐次减半网格搜索。与我们之前描述的唯一区别是,在一开始,会从参数空间中随机抽取固定数量的候选者。这个数量由n_candidates
参数决定。
下面我们展示如何使用HalvingGridSearchCV
。首先,在导入之前,我们需要明确允许使用实验特性(未来,当该特性不再是实验性的时,这一步可能会变得多余):
from sklearn.experimental import enable_halving_search_cv
from sklearn.model_selection import HalvingGridSearchCV
然后,我们为我们的决策树管道找到最佳的超参数:
classifier_sh = HalvingGridSearchCV(tree_pipeline, param_grid,
scoring="recall", cv=k_fold,
n_jobs=-1, verbose=1,
min_resources="exhaust", factor=3)
classifier_sh.fit(X_train, y_train)
我们可以在以下日志中看到逐次减半算法在实践中的表现:
n_iterations: 6
n_required_iterations: 6
n_possible_iterations: 6
min_resources_: 98
max_resources_: 24000
aggressive_elimination: False
factor: 3
----------
iter: 0
n_candidates: 288
n_resources: 98
Fitting 5 folds for each of 288 candidates, totalling 1440 fits
----------
iter: 1
n_candidates: 96
n_resources: 294
Fitting 5 folds for each of 96 candidates, totalling 480 fits
----------
iter: 2
n_candidates: 32
n_resources: 882
Fitting 5 folds for each of 32 candidates, totalling 160 fits
----------
iter: 3
n_candidates: 11
n_resources: 2646
Fitting 5 folds for each of 11 candidates, totalling 55 fits
----------
iter: 4
n_candidates: 4
n_resources: 7938
Fitting 5 folds for each of 4 candidates, totalling 20 fits
----------
iter: 5
n_candidates: 2
n_resources: 23814
Fitting 5 folds for each of 2 candidates, totalling 10 fits
如前所述,max_resources
是由训练数据的大小决定的,也就是 24,000 个观测值。然后,算法计算出它需要从 98 的样本大小开始,以便在结束时获得尽可能大的样本。在这种情况下,在最后一次迭代中,算法使用了 23,814 个训练观测值。
在下表中,我们可以看到每个我们在本节中介绍的 3 种网格搜索方法选择的超参数值。它们非常相似,测试集上的性能也是如此(具体的比较可以在 GitHub 上的笔记本中查看)。我们将所有这些算法的拟合时间比较留给读者作为练习。
图 13.31:通过详尽、随机化和二分网格搜索识别的最佳超参数值
使用多个分类器进行网格搜索
我们还可以创建一个包含多个分类器的网格。这样,我们可以看到哪个模型在我们的数据上表现最好。为此,我们首先从scikit-learn
导入另一个分类器。我们将使用著名的随机森林:
from sklearn.ensemble import RandomForestClassifier
我们选择了这个模型,因为它是一个决策树集成,因此也不需要对数据进行进一步的预处理。例如,如果我们想使用一个简单的逻辑回归分类器(带正则化),我们还应该通过在数值预处理管道中添加一个额外步骤来对特征进行缩放(标准化/归一化)。我们将在下一章中更详细地介绍随机森林模型。
再次,我们需要定义参数网格。这一次,它是一个包含多个字典的列表——每个分类器一个字典。决策树的超参数与之前相同,我们选择了随机森林的最简单超参数,因为这些超参数不需要额外的解释。
值得一提的是,如果我们想调整管道中的其他超参数,我们需要在列表中的每个字典中指定它们。这就是为什么preprocessor__numerical__outliers__n_std
在下面的代码片段中出现了两次:
param_grid = [
{"classifier": [RandomForestClassifier(random_state=42)],
"classifier__n_estimators": np.linspace(100, 500, 10, dtype=int),
"classifier__max_depth": range(3, 11),
"preprocessor__numerical__outliers__n_std": [3, 4]},
{"classifier": [DecisionTreeClassifier(random_state=42)],
"classifier__criterion": ["entropy", "gini"],
"classifier__max_depth": range(3, 11),
"classifier__min_samples_leaf": range(2, 11),
"preprocessor__numerical__outliers__n_std": [3, 4]}
]
其余的过程和之前完全相同:
classifier_gs_2 = GridSearchCV(tree_pipeline, param_grid,
scoring="recall", cv=k_fold,
n_jobs=-1, verbose=1)
classifier_gs_2.fit(X_train, y_train)
print(f"Best parameters: {classifier_gs_2.best_params_}")
print(f"Recall (Training set): {classifier_gs_2.best_score_:.4f}")
print(f"Recall (Test set): {metrics.recall_score(y_test, classifier_gs_2.predict(X_test)):.4f}")
运行代码片段会生成以下输出:
Best parameters: {'classifier': DecisionTreeClassifier(max_depth=10, min_samples_leaf=7, random_state=42), 'classifier__criterion': 'gini', 'classifier__max_depth': 10, 'classifier__min_samples_leaf': 7, 'preprocessor__numerical__outliers__n_std': 4}
Recall (Training set): 0.3858
Recall (Test set): 0.3775
结果表明,经过调整的决策树表现优于树的集成。正如我们将在下一章看到的,我们可以通过对随机森林分类器进行更多的调整来轻松改变结果。毕竟,我们只调整了可用的多个超参数中的两个。
我们可以使用以下代码片段来提取并打印所有考虑的超参数/分类器组合,从最佳的那个开始:
pd.DataFrame(classifier_gs_2.cv_results_).sort_values("rank_test_score")
另见
关于随机化搜索过程的额外资源可以在这里找到:
- Bergstra, J. & Bengio, Y. (2012). “随机搜索用于超参数优化。” 机器学习研究期刊, 13(2 月), 281-305.
www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf
.
总结
在本章中,我们已经涵盖了处理任何机器学习项目所需的基础知识,这些知识不仅限于金融领域。我们做了以下几件事:
导入数据并优化其内存使用
彻底探索了数据(特征分布、缺失值和类别不平衡),这应该已经提供了一些关于潜在特征工程的思路
识别了数据集中的缺失值并进行了填充
学会了如何编码类别变量,使其能被机器学习模型正确解读
使用最流行且最成熟的机器学习库——
scikit-learn
,拟合了一个决策树分类器学会了如何使用管道组织我们的整个代码库
学会了如何调整模型的超参数,以挤压出一些额外的性能,并找到欠拟合和过拟合之间的平衡。
理解这些步骤及其意义至关重要,因为它们可以应用于任何数据科学项目,而不仅仅是二元分类问题。例如,对于回归问题(如预测房价),步骤几乎是相同的。我们将使用略微不同的估算器(虽然大多数估算器适用于分类和回归),并使用不同的指标评估性能(如 MSE、RMSE、MAE、MAPE 等)。但基本原则不变。
如果你有兴趣将本章的知识付诸实践,我们推荐以下资源,供你寻找下一个项目的数据:
Google 数据集:
datasetsearch.research.google.com/
Kaggle:
www.kaggle.com/datasets
UCI 机器学习库:
archive.ics.uci.edu/ml/index.php
在下一章中,我们将介绍一些有助于进一步改进初始模型的技术。我们将涵盖包括更复杂的分类器、贝叶斯超参数调优、处理类别不平衡、探索特征重要性和选择等内容。
加入我们的 Discord 群组!
要加入本书的 Discord 社区,在这里你可以分享反馈、向作者提问并了解新版本发布,请扫描下面的二维码:
第十四章:机器学习项目的高级概念
在上一章中,我们介绍了解决现实问题的可能工作流程,使用机器学习从数据清洗、模型训练和调优,到最后评估其表现。然而,这通常并不是项目的终点。在那个项目中,我们使用了一个简单的决策树分类器,它通常可以作为基准或最小可行产品(MVP)。在本章中,我们将介绍一些更高级的概念,这些概念有助于提升项目的价值,并使其更容易被业务利益相关者采纳。
在创建了最小可行产品(MVP)作为基准之后,我们希望提高模型的表现。在尝试改善模型时,我们还应该平衡欠拟合和过拟合。实现这一点有几种方法,其中包括:
收集更多数据(观测数据)
添加更多特征——无论是通过收集额外的数据(例如,使用外部数据源)还是通过特征工程使用当前可用的信息
使用更复杂的模型
只选择相关特征
调整超参数
有一个常见的刻板印象认为,数据科学家在一个项目中花费 80%的时间收集和清理数据,剩下的 20%才用于实际的建模。按照这一刻板印象,增加更多数据可能会大大提高模型的表现,尤其是在处理分类问题中的不平衡类别时。但寻找额外的数据(无论是观测数据还是特征)并不总是可行,或者可能会变得非常复杂。那么,另一个解决方案可能是使用更复杂的模型,或者调整超参数以挤压出一些额外的性能。
本章开始时,我们将介绍如何使用更先进的分类器,这些分类器同样基于决策树。其中一些(如 XGBoost 和 LightGBM)在机器学习竞赛中常常被用来获胜(例如 Kaggle 上的竞赛)。此外,我们还介绍了堆叠多个机器学习模型的概念,以进一步提高预测性能。
另一个常见的现实问题是处理不平衡数据,也就是当某一类别(如违约或欺诈)在实际中很少出现时。这使得训练一个准确捕捉少数类别观测值的模型变得特别困难。我们介绍了几种处理类别不平衡的常见方法,并在信用卡欺诈数据集上比较了它们的表现,其中少数类别仅占所有观测值的 0.17%。
然后,我们还扩展了超参数调优的内容,这在前一章中已有解释。之前,我们使用了穷尽的网格搜索或随机搜索,这两种方法都是在无信息的情况下进行的。这意味着在选择下一个要探索的超参数集时,并没有底层逻辑。这一次,我们介绍了贝叶斯优化方法,在这种方法中,过去的尝试被用来选择下一个要探索的超参数集。这种方法可以显著加速我们项目的调优阶段。
在许多行业(尤其是金融行业),理解模型预测背后的逻辑至关重要。例如,银行可能在法律上被要求提供拒绝信用请求的实际理由,或者它可以通过预测哪些客户可能违约来尝试限制损失。为了更好地理解模型,我们探讨了确定特征重要性和模型可解释性的各种方法。后者在处理复杂模型时尤为重要,因为这些模型通常被认为是黑箱,即无法解释的。我们还可以利用这些见解,仅选择最相关的特征,这可以进一步提高模型的性能。
在本章中,我们介绍以下几种方法:
探索集成分类器
探索编码分类特征的替代方法
探讨处理不平衡数据的不同方法
利用群众智慧的堆叠集成模型
贝叶斯超参数优化
探讨特征重要性
探索特征选择技术
探索可解释的人工智能技术
探索集成分类器
在第十三章,应用机器学习:识别信用违约中,我们学习了如何构建一个完整的机器学习管道,其中包含预处理步骤(填补缺失值、编码分类特征等)和机器学习模型。我们的任务是预测客户违约,即无法偿还债务。我们使用了决策树模型作为分类器。
决策树被认为是简单模型,它们的一个缺点是对训练数据的过拟合。它们属于高方差模型,这意味着对训练数据的微小变化会极大地影响树的结构和预测结果。为了克服这些问题,决策树可以作为更复杂模型的构建块。集成模型通过结合多个基础模型(例如决策树)的预测,以提高最终模型的泛化能力和鲁棒性。这样,它们将最初的高方差估计器转变为低方差的综合估计器。
从高层次来看,我们可以将集成模型分为两组:
平均法—多个模型独立估计,然后将它们的预测结果平均。其基本原理是,组合模型比单一模型更好,因为其方差减少了。示例:随机森林和极度随机化树。
提升方法—在这种方法中,多个基本估计器被顺序构建,每个估计器都试图减少组合估计器的偏差。其基本假设是,多个弱模型的组合会产生一个强大的集成模型。示例:梯度提升树、XGBoost、LightGBM 和 CatBoost。
在这个食谱中,我们使用了一些集成模型来尝试提升决策树方法的性能。由于这些模型基于决策树,关于特征缩放(不需要显式进行)的相同原则适用,因此我们可以重用之前创建的大部分管道。
准备工作
在这个食谱中,我们基于上一章的通过管道组织项目食谱中的内容,创建了默认的预测管道,从加载数据到训练分类器。
在这个食谱中,我们使用的是不包含异常值去除程序的变体。我们将用更复杂的集成模型替换最后一步(分类器)。此外,我们首先将决策树管道拟合到数据中,以获得基线模型用于性能比较。为了方便起见,我们在本章附带的笔记本中重申了所有必需的步骤。
如何实现…
执行以下步骤以训练集成分类器:
导入库:
from sklearn.ensemble import (RandomForestClassifier, GradientBoostingClassifier) from xgboost.sklearn import XGBClassifier from lightgbm import LGBMClassifier from chapter_14_utils import performance_evaluation_report
在本章中,我们还使用了已经熟悉的
performance_evaluation_report
辅助函数。定义并拟合随机森林管道:
rf = RandomForestClassifier(random_state=42) rf_pipeline = Pipeline( steps=[("preprocessor", preprocessor), ("classifier", rf)] ) rf_pipeline.fit(X_train, y_train) rf_perf = performance_evaluation_report(rf_pipeline, X_test, y_test, labels=LABELS, show_plot=True, show_pr_curve=True)
随机森林的性能可以通过以下图表总结:
图 14.1:随机森林模型的性能评估
定义并拟合梯度提升树管道:
gbt = GradientBoostingClassifier(random_state=42) gbt_pipeline = Pipeline( steps=[("preprocessor", preprocessor), ("classifier", gbt)] ) gbt_pipeline.fit(X_train, y_train) gbt_perf = performance_evaluation_report(gbt_pipeline, X_test, y_test, labels=LABELS, show_plot=True, show_pr_curve=True)
梯度提升树的性能可以通过以下图表总结:
图 14.2:梯度提升树模型的性能评估
定义并拟合 XGBoost 管道:
xgb = XGBClassifier(random_state=42) xgb_pipeline = Pipeline( steps=[("preprocessor", preprocessor), ("classifier", xgb)] ) xgb_pipeline.fit(X_train, y_train) xgb_perf = performance_evaluation_report(xgb_pipeline, X_test, y_test, labels=LABELS, show_plot=True, show_pr_curve=True)
XGBoost 的性能可以通过以下图表总结:
图 14.3:XGBoost 模型的性能评估
定义并拟合 LightGBM 管道:
lgbm = LGBMClassifier(random_state=42) lgbm_pipeline = Pipeline( steps=[("preprocessor", preprocessor), ("classifier", lgbm)] ) lgbm_pipeline.fit(X_train, y_train) lgbm_perf = performance_evaluation_report(lgbm_pipeline, X_test, y_test, labels=LABELS, show_plot=True, show_pr_curve=True)
LightGBM 的性能可以通过以下图表总结:
图 14.4:LightGBM 模型的性能评估
从报告来看,所有考虑的模型的 ROC 曲线和精确率-召回率曲线形状非常相似。我们将在*更多内容…*部分查看各个模型的得分。
它是如何工作的…
本示例展示了使用不同分类器是多么简单,只要我们希望使用它们的默认设置。在第一步中,我们从各自的库中导入了分类器。
在这个示例中,我们使用了scikit-learn
API,结合了像 XGBoost 或 LightGBM 这样的库。然而,我们也可以使用它们的原生方法来训练模型,这可能需要一些额外的工作,例如将pandas
DataFrame 转换为这些库接受的格式。使用原生方法可以带来一些额外的好处,例如可以访问某些超参数或配置设置。
在步骤 2到步骤 5中,我们为每个分类器创建了一个独立的流水线。我们将已经建立的ColumnTransformer
预处理器与相应的分类器结合在一起。然后,我们将每个流水线拟合到训练数据并展示了性能评估报告。
一些考虑的集成模型在fit
方法中提供了额外的功能(而不是在实例化类时设置超参数)。例如,使用 LightGBM 的fit
方法时,我们可以传入分类特征的名称/索引。这样,算法就知道如何使用其自身的方法处理这些特征,而无需显式地进行独热编码。同样,我们还可以使用各种可用的回调函数。
多亏了现代 Python 库,拟合所有考虑的分类器变得异常简单。我们只需将流水线中的模型类替换为另一个模型类。考虑到尝试不同模型是多么简单,理解这些模型的工作原理以及它们的优缺点是非常重要的。这就是为什么下面我们提供了对所考虑算法的简要介绍。
随机森林
随机森林是一个集成模型的例子,即它训练多个模型(决策树),并利用这些模型来进行预测。在回归问题中,它取所有树的平均值;在分类问题中,它使用多数投票。随机森林不仅仅是训练多棵树并汇总它们的结果。
首先,它使用Bagging(自助聚合法)——每棵树都在所有可用观测值的子集上进行训练。这些观测值是通过有放回的随机抽样得到的,因此——除非另行指定——每棵树使用的观测值总数与训练集中的总观测值数相同。即使单棵树可能会因为 Bagging 的原因在特定数据集上有较高的方差,但整个森林的方差将会较低,而不会增加偏差。此外,这种方法还可以减少数据中异常值的影响,因为它们不会出现在所有的树中。为了增加更多的随机性,每棵树只考虑所有特征的一个子集来创建每个分裂。我们可以使用一个专门的超参数来控制这个数字。
多亏了这两种机制,森林中的树彼此之间没有关联,并且是独立构建的。后者使得树构建步骤可以并行化。
随机森林提供了复杂度与性能之间的良好平衡。通常——即使没有任何调优——我们也能比使用更简单的算法(如决策树或线性/逻辑回归)获得更好的性能。这是因为随机森林具有较低的偏差(由于其灵活性)和较小的方差(由于聚合多个模型的预测)。
梯度提升树
梯度提升树是另一种集成模型。其思想是训练许多弱学习器(具有高偏差的浅层决策树/树桩),并将它们组合起来以获得一个强学习器。与随机森林相比,梯度提升树是一个顺序/迭代算法。在提升中,我们从第一个弱学习器开始,每个后续的学习器都试图从前一个学习器的错误中学习。它们通过拟合前一个模型的残差(误差项)来实现这一点。
我们创建一组弱学习器而不是强学习器的原因是,在强学习器的情况下,错误/标注错误的数据点很可能是数据中的噪音,因此整体模型最终会对训练数据发生过拟合。
梯度这个术语来源于树是使用梯度下降构建的,而梯度下降是一种优化算法。简而言之,它利用损失函数的梯度(斜率)来最小化整体损失并实现最佳性能。损失函数表示实际值和预测值之间的差异。实际上,为了在梯度提升树中执行梯度下降过程,我们会将这样一棵树添加到模型中,使其遵循梯度。换句话说,这样的树会降低损失函数的值。
我们可以通过以下步骤描述提升过程:
该过程从一个简单的估算开始(均值、中位数等)。
一棵树被拟合到该预测的误差。
预测通过使用树的预测值进行调整。然而,它并不会完全调整,而只是调整到一定程度(基于学习率超参数)。
另一棵树被拟合到更新后的预测误差,并且预测值像之前的步骤那样进一步调整。
算法会继续迭代地减少误差,直到达到指定的轮次(或其他停止准则)。
最终预测是初始预测与所有调整(按学习率加权的误差预测值)之和。
与随机森林相比,梯度提升树使用所有可用数据来训练模型。然而,我们可以通过使用subsample
超参数对每棵树进行不重复的随机采样。这样,我们就得到了随机梯度提升树。此外,类似于随机森林,我们可以让树在进行分裂时只考虑特征的一个子集。
XGBoost
**极端梯度提升(XGBoost)**是梯度提升树的一个实现,融合了一系列改进,从而提供了更优的性能(无论是在评估指标还是估计时间上)。自发布以来,该算法已成功用于赢得许多数据科学竞赛。
在本食谱中,我们仅提供了 XGBoost 一些可辨识特性的概述。欲了解更详细的概述,请参考原始论文(Chen et al. (2016))或文档。XGBoost 的关键概念如下:
XGBoost 将预排序算法与基于直方图的算法相结合,用于计算最佳分裂。这解决了梯度提升树的一个重大低效问题,即在创建新分支时,算法需要考虑所有可能的分裂的潜在损失(特别是在考虑数百或数千个特征时,尤其重要)。
该算法使用牛顿-拉夫森方法来近似损失函数,这使我们能够使用更广泛的损失函数。
XGBoost 有一个额外的随机化参数,用于减少树与树之间的相关性。
XGBoost 结合了 Lasso(L1)和 Ridge(L2)正则化,以防止过拟合。
它提供了一种更高效的树修剪方法。
XGBoost 有一个名为单调约束的特性——该算法牺牲一些准确性并增加训练时间,以提高模型的可解释性。
XGBoost 不接受类别特征作为输入——我们必须对它们进行某种编码。
该算法可以处理数据中的缺失值。
LightGBM
LightGBM由微软发布,是另一种赢得比赛的梯度提升树实现。由于一些改进,LightGBM 的性能与 XGBoost 相似,但训练时间更快。其主要特点包括:
速度的差异是由树的生长方式造成的。一般来说,算法(例如 XGBoost)采用层级(水平)方式。另一方面,LightBGM 采用叶子生长方式(垂直)。叶子生长算法选择具有最大损失函数减少的叶子。这类算法通常比层级方式更快收敛;然而,它们更容易过拟合(尤其是在小数据集上)。
LightGBM 采用一种名为基于梯度的单边采样(GOSS)的技术,来过滤出用于寻找最佳切分值的数据实例。直观地说,梯度较小的观测值已经得到了较好的训练,而梯度较大的观测值还有更多改进空间。GOSS 保留了梯度较大的实例,并且从梯度较小的观测值中随机采样。
LightGBM 使用独特特征捆绑(EFB)技术来利用稀疏数据集,并将互斥的特征(它们在同一时刻永远不会同时为零)捆绑在一起。这有助于减少特征空间的复杂性(维度)。
该算法使用基于直方图的方法将连续特征值分桶到离散的区间,以加速训练并减少内存使用。
后来,叶节点算法也被添加到了 XGBoost 中。要使用它,我们需要将grow_policy
设置为"lossguide"
。
还有更多...
在本节中,我们展示了如何使用选定的集成分类器来提高我们预测客户违约贷款的能力。更有趣的是,这些模型有数十个超参数可以调整,这可能会显著提高(或降低)它们的性能。
为了简便起见,我们将在这里不讨论这些模型的超参数调优。我们建议您查阅附带的 Jupyter 笔记本,里面简要介绍了如何使用随机网格搜索方法来调优这些模型。在这里,我们仅呈现一个包含结果的表格。我们可以比较默认设置下模型的性能与调优后的模型性能。
图 14.5:比较不同分类器性能的表格
对于使用随机搜索调整的模型(包括名称中带有_rs
后缀的模型),我们使用了 100 组随机的超参数集。由于所考虑的问题涉及不平衡的数据(少数类约占 20%),因此我们通过召回率来评估模型的性能。
看起来基本的决策树在测试集上达到了最佳的召回率。这是以牺牲比更复杂模型低得多的精度为代价的。这就是为什么决策树的 F1 得分(精度与召回率的调和均值)最低的原因。我们可以看到,默认的 LightGBM 模型在测试集上达到了最佳的 F1 得分。
这些结果并不意味着更复杂的模型就较差——它们可能只是需要更多的调优或者一组不同的超参数。例如,集成模型强制设定了树的最大深度(由相应的超参数决定),而决策树没有这样的限制,并且它的深度达到了 37。模型越复杂,越需要更多的努力才能“做对”。
有许多不同的集成分类器可供实验。一些可能性包括:
AdaBoost——第一种提升算法。
极端随机树——该算法提供了比随机森林更强的随机性。与随机森林类似,在进行分裂时会考虑特征的随机子集。然而,与寻找最具区分性的阈值不同,每个特征的阈值是随机抽取的。然后,从这些随机阈值中选择最好的作为分裂规则。这种方法通常可以减少模型的方差,同时略微增加其偏差。
CatBoost——另一种提升算法(由 Yandex 开发),它特别强调处理分类特征并在少量超参数调整下实现高性能。
NGBoost——从非常高的层次看,这个模型通过使用自然梯度将不确定性估计引入梯度提升中。
基于直方图的梯度提升——一种在
scikit-learn
中提供的梯度提升树变体,灵感来源于 LightGBM。通过将连续特征离散化(分箱)为预定数量的唯一值,它们加速了训练过程。
虽然一些算法首先引入了某些特性,但其他流行的梯度提升树实现通常也会采用这些特性。例如,基于直方图的连续特征离散化方法。虽然它是在 LightGBM 中引入的,但后来也被添加到了 XGBoost 中。对于生长树的叶子方向方法也是如此。
另见
我们提供了更多关于本食谱中提到的算法的资源:
Breiman, L. 2001. “随机森林。” 机器学习 45(1): 5–32。
Chen, T., & Guestrin, C. 2016 年 8 月。Xgboost:一个可扩展的树提升系统。在 第 22 届国际知识发现与数据挖掘会议论文集,785–794。ACM。
Duan, T., Anand, A., Ding, D. Y., Thai, K. K., Basu, S., Ng, A., & Schuler, A. 2020 年 11 月。Ngboost:用于概率预测的自然梯度提升。在 国际机器学习会议,2690–2700。PMLR。
Freund, Y., & Schapire, R. E. 1996 年 7 月。关于一种新提升算法的实验。在 国际机器学习会议,96:148–156。
Freund, Y., & Schapire, R. E. 1997. “在线学习的决策理论推广及其在提升中的应用。” 计算机与系统科学学报,55(1),119–139。
Friedman, J. H. 2001. “贪婪函数逼近:一种梯度提升机。” 统计年鉴,29(5): 1189–1232。
Friedman, J. H. 2002. “随机梯度提升。” 计算统计与数据分析,38(4): 367–378。
Ke, G., Meng, Q., Finley, T., Wang, T., Chen, W., Ma, W., ... & Liu, T. Y. 2017. “Lightgbm:一个高效的梯度提升决策树。” 在 神经信息处理系统。
Prokhorenkova, L., Gusev, G., Vorobev, A., Dorogush, A. V., & Gulin, A. 2018. CatBoost:具有分类特征的无偏提升。在 神经信息处理系统。
探索编码分类特征的替代方法
在前一章中,我们介绍了独热编码(one-hot encoding)作为编码分类特征的标准解决方案,使得机器学习算法能够理解这些特征。回顾一下,独热编码将分类变量转换为多个二进制列,其中值为 1 表示该行属于某个类别,值为 0 则表示不属于。
这种方法的最大缺点是数据集维度迅速扩展。例如,如果我们有一个特征表示观察数据来自美国的哪个州,那么对该特征进行独热编码将会创建 50 个新列(如果去掉参考值,则为 49 列)。
使用独热编码的其他问题包括:
创建这么多布尔特征会给数据集引入稀疏性,而决策树对此处理不佳。
决策树的分裂算法将所有的独热编码虚拟变量视为独立特征。这意味着当决策树使用其中一个虚拟变量进行分裂时,每次分裂的纯度增益较小。因此,决策树不太可能在接近根节点时选择某个虚拟变量。
与前一点相关,连续特征的特征重要性通常高于独热编码的虚拟变量,因为一个虚拟变量最多只能将其对应的分类特征的部分信息引入模型。
梯度提升树(Gradient Boosted Trees)不擅长处理高基数特征,因为基本学习器的深度有限。
处理连续变量时,分裂算法会对样本进行排序,并且可以在任何位置对这个排序后的列表进行分裂。而二进制特征只能在一个地方进行分裂,具有k个唯一类别的分类特征则可以有种分裂方式。
我们通过一个示例来说明连续特征的优势。假设分裂算法将一个连续特征在值为 10 的位置进行分裂,分成两组:“小于 10”和“大于等于 10”。在下一次分裂时,它可以进一步分裂这两组中的任意一组,例如,“小于 6”和“大于等于 6”。而对于二进制特征来说,这是不可能的,因为我们最多只能用它将数据分成“是”或“否”两组。图 14.6展示了使用或不使用独热编码所创建的决策树之间可能的差异。
图 14.6:没有独热编码的密集决策树(左)和具有独热编码的稀疏决策树(右)示例
这些缺点,以及其他一些因素,促使了几种替代分类特征编码方法的发展。在本节中,我们将介绍其中的三种方法。
第一种方法称为目标编码(也叫均值编码)。在这种方法中,针对分类特征应用如下转换,具体取决于目标变量的类型:
类别目标——某个特征会被替换为在给定特定类别下目标的后验概率与所有训练数据中目标的先验概率的混合。
连续目标——某个特征会被替换为在给定特定类别下目标的期望值与所有训练数据中目标的期望值的混合。
实际应用中,最简单的情况假设每个特征中的类别都会被该类别目标值的均值替换。图 14.7 展示了这一点。
图 14.7:目标编码示例
目标编码能更直接地表示类别特征与目标之间的关系,同时不会添加任何新列。这也是它在数据科学竞赛中非常流行的原因。
不幸的是,它并不是编码类别特征的万灵药,并且带有一些缺点:
该方法非常容易过拟合。因此,它假设类别均值与全局均值的混合/平滑。特别是当某些类别非常罕见时,我们应该特别小心。
这与过拟合的风险相关,我们实际上是在将目标信息泄露到特征中。
实际应用中,当我们拥有高基数特征并且使用某种形式的梯度提升树作为机器学习模型时,目标编码效果相当好。
我们讨论的第二种方法叫做留一法编码(Leave One Out Encoding, LOOE),它与目标编码非常相似。它通过在计算类别平均值时排除当前行的目标值来尝试减少过拟合。这样,算法就避免了按行泄露。这个方法的另一个结果是,相同类别在多个观察值中可以在编码列中具有不同的值。图 14.8 展示了这一点。
图 14.8:留一法编码示例
使用 LOOE,机器学习模型不仅会接触到每个编码类别的相同值(如目标编码中那样),还会接触到一系列值。这就是为什么它应该学会更好地泛化。
最后我们讨论的编码方法叫做证据权重(Weight of Evidence, WoE)编码。这种方法特别有趣,因为它起源于信用评分领域,在那里它被用来提高违约概率估算。它被用来区分违约客户与成功偿还贷款的客户。
证据权重(Weight of Evidence, WoE)源自逻辑回归。与 WoE 来源相同的另一个有用指标叫做信息值(Information Value, IV)。它衡量一个特征为预测提供了多少信息。换句话说,它帮助根据特征在模型中的重要性对变量进行排序。
证据权重表示独立变量相对于目标变量的预测能力。换句话说,它衡量证据在多大程度上支持或削弱一个假设。它定义为赔率比的自然对数:
图 14.9 说明了计算过程。
图 14.9:WoE 编码示例
尽管编码源于信用评分,但这并不意味着它只能在类似情况下使用。我们可以将优秀客户视为非事件或负类,而将差的客户视为事件或正类。该方法的一个限制是,与前两者不同,它只能用于二元类别目标。
WoE(证据权重)在历史上也用于编码类别特征。例如,在信用评分数据集中,我们可以将连续特征如年龄分箱为离散区间:20-29 岁、30-39 岁、40-49 岁,依此类推,然后计算这些类别的 WoE。选择多少个区间用于编码,取决于具体应用和特征的分布情况。
在本篇教程中,我们将展示如何在实践中使用这三种编码器,使用我们之前已经用过的默认数据集。
准备就绪
在本篇教程中,我们使用之前教程中使用的管道。作为估算器,我们使用随机森林分类器。为了方便起见,我们在本章附带的 Jupyter 笔记本中重述了所有必要步骤。
使用独热编码的随机森林管道在测试集上的召回率为0.3542
。我们将尝试通过其他编码方法来提高这个分数。
如何操作…
执行以下步骤以使用不同的类别编码器拟合机器学习管道:
导入库:
import category_encoders as ce from sklearn.base import clone
使用目标编码拟合管道:
pipeline_target_enc = clone(rf_pipeline) pipeline_target_enc.set_params( preprocessor__categorical__cat_encoding=ce.TargetEncoder() ) pipeline_target_enc.fit(X_train, y_train) target_enc_perf = performance_evaluation_report( pipeline_target_enc, X_test, y_test, labels=LABELS, show_plot=True, show_pr_curve=True ) print(f"Recall: {target_enc_perf['recall']:.4f}")
执行代码片段会生成以下图表:
图 14.10:使用目标编码进行管道性能评估
使用此管道获得的召回率为
0.3677
。这使得分数提高了略超过 1 个百分点。使用“留一编码”拟合管道:
pipeline_loo_enc = clone(rf_pipeline) pipeline_loo_enc.set_params( preprocessor__categorical__cat_encoding=ce.LeaveOneOutEncoder() ) pipeline_loo_enc.fit(X_train, y_train) loo_enc_perf = performance_evaluation_report( pipeline_loo_enc, X_test, y_test, labels=LABELS, show_plot=True, show_pr_curve=True ) print(f"Recall: {loo_enc_perf['recall']:.4f}")
执行代码片段会生成以下图表:
图 14.11:使用“留一编码”进行管道性能评估
使用此管道获得的召回率为
0.1462
,明显低于目标编码方法。使用证据权重编码拟合管道:
pipeline_woe_enc = clone(rf_pipeline) pipeline_woe_enc.set_params( preprocessor__categorical__cat_encoding=ce.WOEEncoder() ) pipeline_woe_enc.fit(X_train, y_train) woe_enc_perf = performance_evaluation_report( pipeline_woe_enc, X_test, y_test, labels=LABELS, show_plot=True, show_pr_curve=True ) print(f"Recall: {woe_enc_perf['recall']:.4f}")
执行代码片段会生成以下图表:
图 14.12:使用证据权重编码进行管道性能评估
使用此管道获得的召回率为0.3708
,相较于目标编码有小幅提升。
它是如何工作的……
首先,我们执行了准备工作部分的代码,即实例化了一个使用独热编码和随机森林作为分类器的管道。
在导入库后,我们使用clone
函数克隆了整个管道。然后,我们使用set_params
方法将OneHotEncoder
替换为TargetEncoder
。正如调优管道的超参数时,我们必须使用相同的双下划线表示法来访问管道中的特定元素。编码器位于preprocessor__categorical__cat_encoding
下。接着,我们使用fit
方法拟合管道,并通过performance_evaluation_report
辅助函数打印评估结果。
正如我们在介绍中提到的,目标编码容易导致过拟合。这就是为什么算法不仅仅用相应的平均值替换类别,而是能够将后验概率与先验概率(全局平均)结合起来的原因。我们可以通过两个超参数来控制这种混合:min_samples_leaf
和 smoothing
。
在步骤 3和4中,我们按照与目标编码相同的步骤操作,但分别将编码器替换为LeaveOneOutEncoder
和WOEEncoder
。
和目标编码一样,其他编码器也使用目标来构建编码,因此也容易出现过拟合。幸运的是,它们也提供了一些防止过拟合的措施。
在 LOOE 的情况下,我们可以向编码中添加正态分布噪声以减少过拟合。我们可以通过sigma
参数控制用于生成噪声的正态分布的标准差。值得一提的是,随机噪声仅添加到训练数据中,测试集的转换不受影响。仅通过向我们的管道中添加随机噪声(sigma = 0.05
),我们可以将测量的召回率从0.1462
提高到大约0.35
(具体取决于随机数生成)。
同样,我们可以为 WoE 编码器添加随机噪声。我们通过randomized
(布尔标志)和sigma
(正态分布的标准差)参数来控制噪声。此外,还有regularization
参数,它可以防止由于除零错误而导致的错误。
还有更多……
编码分类变量是一个非常广泛的活跃研究领域,不时会发布新的方法。在切换主题之前,我们还想讨论一些相关概念。
使用 k 折目标编码处理数据泄露
我们已经提到了一些减少目标编码器过拟合问题的方法。Kaggle 从业者中非常流行的一个解决方案是使用 k 折目标编码。这个想法类似于 k 折交叉验证,它允许我们使用所有可用的训练数据。我们首先将数据划分为 k 个折叠——这些折叠可以是分层的,也可以是完全随机的,具体取决于应用场景。然后,我们用除第 l 个折叠之外的所有折叠计算出的目标均值来替换第 l 个折叠中的观察值。这样,我们就避免了同一折叠中目标泄漏的问题。
一位好奇的读者可能已经注意到,LOOE 是 k 折目标编码的一种特殊情况,其中 k 等于训练数据集中的观察数量。
更多编码器
category_encoders
库提供了近 20 种不同的分类特征编码转换器。除了我们已经提到的编码器外,你还可以探索以下内容:
有序编码——与标签编码非常相似;然而,它确保编码保留特征的有序性质。例如,坏 < 中立 < 好的层级关系被保留下来。
计数编码器(频率编码器)——将特征的每个类别映射到属于该类别的观察数。
总和编码器——将给定类别的目标均值与目标的总体均值进行比较。
Helmert 编码器——将某个类别的均值与后续级别的均值进行比较。如果我们有类别 [A, B, C],算法会先比较 A 与 B 和 C,然后再比较 B 与 C。此种编码在类别特征的级别有顺序的情况下非常有用,例如从低到高的顺序。
反向差异编码器——类似于 Helmert 编码器,不同之处在于它将当前类别的均值与前一个类别的均值进行比较。
M-估计编码器——目标编码器的简化版本,只有一个可调参数(负责正则化强度)。
James-Stein 编码器——一种目标编码的变体,旨在通过将类别的均值收缩到中心/全局均值来提高估计精度。它的单一超参数控制着收缩的强度(在这个上下文中,这与正则化相同)——超参数值越大,全球均值的权重越大(这可能导致欠拟合)。另一方面,减少超参数值可能会导致过拟合。通常,最佳值是通过交叉验证来确定的。该方法的最大缺点是,James-Stein 估计器仅适用于正态分布,而这并不适用于任何二元分类问题。
二进制编码器—将类别转换为二进制数字,每个数字都有一个单独的列。得益于这种编码方法,我们生成的列数远少于 OHE。例如,对于一个具有 100 个独特类别的类别特征,二进制编码只需创建 7 个特征,而 OHE 则需要 100 个。
哈希编码器—使用哈希函数(通常用于数据加密)来转换类别特征。其结果与 OHE 相似,但特征更少(我们可以通过编码器的超参数来控制这一点)。它有两个显著的缺点。首先,编码会导致信息丢失,因为算法将所有可用类别转换为更少的特征。第二个问题称为碰撞,发生在我们将潜在的大量类别转换为较小的特征集时。此时,不同的类别可能会被相同的哈希值表示。
Catboost 编码器—一种改进的 Leave One Out 编码变种,旨在克服目标泄漏问题。
另请参见
- Micci-Barreca, D. 2001. “分类与预测问题中高基数类别属性的预处理方案。” ACM SIGKDD Explorations Newsletter 3(1): 27–32.
调查处理不平衡数据的不同方法
在处理分类任务时,一个非常常见的问题是类别不平衡,即当一个类别的样本数量远少于另一个类别时(这也可以扩展到多类别问题)。通常,当两个类别的比例不是 1:1 时,我们就面临不平衡问题。在某些情况下,轻微的不平衡并不是大问题,但在一些行业或问题中,我们可能会遇到 100:1、1000:1 或甚至更极端的比例。
处理高度不平衡的类别可能导致机器学习模型的性能较差。这是因为大多数算法隐式地假设类别分布是平衡的。它们通过旨在最小化总体预测误差来实现这一点,而根据定义,少数类对总体误差的贡献非常小。因此,在不平衡数据上训练的分类器会偏向多数类。
解决类别不平衡的潜在解决方案之一是对数据进行重采样。总体而言,我们可以对多数类进行欠采样,对少数类进行过采样,或者将这两种方法结合起来。然而,这只是一个大致的思路。实际上,有很多处理重采样的方法,下面我们描述了几种常见的方法。
在使用重采样技术时,我们只对训练数据进行重采样!测试数据保持不变。
图 14.13:多数类的欠采样与少数类的过采样
最简单的欠采样方法称为随机欠采样。在这种方法中,我们对多数类进行欠采样,也就是说,从多数类中随机抽取样本(默认情况下,无放回抽样),直到类别平衡(比例为 1:1 或其他所需的比例)。该方法最大的问题是由于丢弃大量数据(通常是整个训练数据集的大部分)而导致信息丢失。因此,在欠采样数据上训练的模型可能会表现得较差。另一个可能的影响是分类器偏向,导致更多的假阳性,因为重采样后训练集和测试集的分布不一致。
类似地,最简单的过采样方法称为随机过采样。在这种方法中,我们从少数类中进行多次有放回的抽样,直到达到期望的比例。该方法通常比随机欠采样效果更好,因为没有因丢弃训练数据而导致信息丢失。然而,随机过采样存在过拟合的风险,因为它通过复制少数类的观察值来增加数据。
合成少数类过采样技术(SMOTE)是一种更先进的过采样算法,它通过少数类创建新的合成观察值。通过这种方式,它克服了前面提到的过拟合问题。
为了创建合成样本,算法从少数类中选取一个观察值,识别其k-最近邻(使用k-NN 算法),然后在连接(插值)观察值和最近邻的线段上创建新的观察值。然后,该过程会重复进行,直到其他少数类观察值的样本也得到平衡。
除了减少过拟合问题外,SMOTE 不会丢失任何信息,因为它不会丢弃属于多数类的观察值。然而,SMOTE 可能会无意中向数据中引入更多噪声,并导致类别重叠。这是因为在创建合成观察值时,它没有考虑到多数类的观察值。此外,该算法在高维数据上效果不佳(由于维度灾难)。最后,SMOTE 的基本变种仅适用于数值特征。然而,SMOTE 的扩展(在*更多内容...*部分提到)可以处理分类特征。
考虑的最后一种过采样技术叫做自适应合成采样(ADASYN),它是 SMOTE 算法的一种改进。在 ADASYN 中,为某个少数类点创建的观测值数量是由密度分布决定的(而不是像 SMOTE 中那样为所有点提供统一的权重)。这种自适应特性使得 ADASYN 能够为来自难以学习的邻域的观测值生成更多的合成样本。例如,如果存在许多与少数类观察值特征值非常相似的多数类观察值,则该少数类观察值会变得难以学习。我们可以通过仅考虑两个特征的情况来更容易地理解这种情况。在散点图中,这样的少数类观察值可能会被许多多数类观察值包围。
还有两个额外的要点值得提及:
与 SMOTE 不同,合成点并不局限于两点之间的线性插值。它们还可以位于由三个或更多观测值创建的平面上。
在创建合成观测值后,算法会加入少量随机噪声以增加方差,从而使得样本更加真实。
ADASYN 的潜在缺点包括:
由于其自适应性,算法的精度可能会下降(产生更多的假阳性)。这意味着该算法可能会在具有大量多数类观测值的区域生成更多观测值。这些合成数据可能与多数类观测值非常相似,从而可能导致更多的假阳性。
处理稀疏分布的少数类观测值时,某些邻域可能仅包含一个或极少数的点。
重采样并不是解决类别不平衡问题的唯一潜在方案。另一个方法是基于调整类别权重,从而增加少数类的权重。在后台,类别权重会被纳入到损失函数的计算中。实际上,这意味着将少数类观测值分类错误会显著增加损失函数的值,而多数类观测值分类错误的影响则较小。
在这个示例中,我们展示了一个信用卡欺诈问题的例子,其中欺诈类在整个样本中的比例仅为 0.17%。在这种情况下,收集更多的数据(特别是欺诈类数据)可能根本不可行,我们需要依赖其他技术来帮助我们提升模型的性能。
准备工作
在进入编码部分之前,我们简要描述了本次练习中选用的数据集。你可以从 Kaggle 下载该数据集(链接在另请参见部分)。
数据集包含了 2013 年 9 月欧洲持卡人在两天内进行的信用卡交易信息。由于保密原因,几乎所有特征(28 个中的 30 个)都通过使用主成分分析(PCA)进行了匿名化。唯一两个有明确解释的特征是Time
(每笔交易与数据集中第一笔交易之间的秒数)和Amount
(交易金额)。
最后,数据集严重失衡,正类在所有交易中只占 0.173%。准确地说,在 284,807 笔交易中,有 492 笔被识别为欺诈交易。
如何实现...
执行以下步骤以研究不同处理类别失衡的方法:
导入库:
import pandas as pd from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.preprocessing import RobustScaler from imblearn.over_sampling import RandomOverSampler, SMOTE, ADASYN from imblearn.under_sampling import RandomUnderSampler from imblearn.ensemble import BalancedRandomForestClassifier from chapter_14_utils import performance_evaluation_report
加载和准备数据:
RANDOM_STATE = 42 df = pd.read_csv("../Datasets/credit_card_fraud.csv") X = df.copy().drop(columns=["Time"]) y = X.pop("Class") X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=RANDOM_STATE )
使用
y.value_counts(normalize=True)
我们可以确认正类在 0.173%的观察值中出现。使用
RobustScaler
对特征进行缩放:robust_scaler = RobustScaler() X_train = robust_scaler.fit_transform(X_train) X_test = robust_scaler.transform(X_test)
训练基准模型:
rf = RandomForestClassifier( random_state=RANDOM_STATE, n_jobs=-1 ) rf.fit(X_train, y_train)
对训练数据进行欠采样,并训练一个随机森林分类器:
rus = RandomUnderSampler(random_state=RANDOM_STATE) X_rus, y_rus = rus.fit_resample(X_train, y_train) rf.fit(X_rus, y_rus) rf_rus_perf = performance_evaluation_report(rf, X_test, y_test)
随机欠采样后,类别的比例如下:
{0: 394, 1: 394}
。对训练数据进行过采样,并训练一个随机森林分类器:
ros = RandomOverSampler(random_state=RANDOM_STATE) X_ros, y_ros = ros.fit_resample(X_train, y_train) rf.fit(X_ros, y_ros) rf_ros_perf = performance_evaluation_report(rf, X_test, y_test)
随机过采样后,类别的比例如下:
{0: 227451, 1: 227451}
。使用 SMOTE 对训练数据进行过采样:
smote = SMOTE(random_state=RANDOM_STATE) X_smote, y_smote = smote.fit_resample(X_train, y_train) rf.fit(X_smote, y_smote) rf_smote_perf = peformance_evaluation_report( rf, X_test, y_test, )
使用 SMOTE 过采样后,类别的比例如下:
{0: 227451, 1: 227451}
。使用 ADASYN 对训练数据进行过采样:
adasyn = ADASYN(random_state=RANDOM_STATE) X_adasyn, y_adasyn = adasyn.fit_resample(X_train, y_train) rf.fit(X_adasyn, y_adasyn) rf_adasyn_perf = performance_evaluation_report( rf, X_test, y_test, )
使用 ADASYN 过采样后,类别的比例如下:
{0: 227451, 1: 227449}
。在随机森林分类器中使用样本权重:
rf_cw = RandomForestClassifier(random_state=RANDOM_STATE, class_weight="balanced", n_jobs=-1) rf_cw.fit(X_train, y_train) rf_cw_perf = performance_evaluation_report( rf_cw, X_test, y_test, )
训练
BalancedRandomForestClassifier
:balanced_rf = BalancedRandomForestClassifier( random_state=RANDOM_STATE ) balanced_rf.fit(X_train, y_train) balanced_rf_perf = performance_evaluation_report( balanced_rf, X_test, y_test, )
使用平衡类别训练
BalancedRandomForestClassifier
:balanced_rf_cw = BalancedRandomForestClassifier( random_state=RANDOM_STATE, class_weight="balanced", n_jobs=-1 ) balanced_rf_cw.fit(X_train, y_train) balanced_rf_cw_perf = performance_evaluation_report( balanced_rf_cw, X_test, y_test, )
将结果合并到一个 DataFrame 中:
performance_results = { "random_forest": rf_perf, "undersampled rf": rf_rus_perf, "oversampled_rf": rf_ros_perf, "smote": rf_smote_perf, "adasyn": rf_adasyn_perf, "random_forest_cw": rf_cw_perf, "balanced_random_forest": balanced_rf_perf, "balanced_random_forest_cw": balanced_rf_cw_perf, } pd.DataFrame(performance_results).round(4).T
执行该代码片段将打印以下表格:
图 14.14:处理失衡数据的各种方法的性能评估指标
在图 14.14中,我们可以看到我们在本食谱中尝试的各种方法的性能评估。由于我们面临的是一个严重失衡的问题(正类占所有观察值的 0.17%),我们可以清楚地观察到准确率悖论的情况。许多模型的准确率约为 99.9%,但它们仍然未能检测出欺诈案件,而欺诈案件才是最重要的。
准确率悖论指的是当将准确率作为评估指标时,会给人留下一个非常好的分类器印象(如 90%的得分,甚至是 99.9%),而实际上它只是反映了类别的分布情况。
考虑到这一点,我们使用了考虑类别不平衡的评估指标来比较模型的表现。在查看精确度时,表现最佳的方案是使用类别权重的随机森林。当将召回率作为最重要的评估指标时,表现最好的方法是先进行欠采样然后使用随机森林模型,或者使用平衡随机森林模型。在 F1 分数方面,最好的方法似乎是原始的随机森林模型。
还需要指出的是,本实验中没有进行超参数调优,这可能会提升所有方法的性能。
它是如何工作的...
在导入库后,我们从 CSV 文件加载了信用卡欺诈数据集。在同一步骤中,我们额外删除了Time
特征,使用pop
方法将目标与特征分开,并创建了一个 80-20 的分层训练-测试集拆分。在处理类别不平衡时,记得使用分层抽样非常重要。
在本教程中,我们仅专注于处理不平衡数据。因此,我们没有涵盖任何探索性数据分析(EDA)、特征工程等内容。由于所有特征都是数值型的,我们不需要进行特殊的编码。
我们所做的唯一预处理步骤是使用RobustScaler
对所有特征进行缩放。虽然随机森林不需要显式的特征缩放,但一些重采样方法在底层使用了k-最近邻算法。而对于这种基于距离的算法,特征缩放是很重要的。我们只使用训练数据来拟合缩放器,然后对训练集和测试集进行转换。
在步骤 4中,我们拟合了一个原始的随机森林模型,并将其作为更复杂方法的基准。
在步骤 5中,我们使用了imblearn
库中的RandomUnderSampler
类,随机欠采样多数类以匹配少数类样本的大小。方便的是,imblearn
中的类遵循了scikit-learn
的 API 风格。因此,我们首先定义了类及其参数(我们只设置了random_state
)。然后,我们应用了fit_resample
方法来获得欠采样的数据。我们重新使用了随机森林对象,基于欠采样数据训练模型,并存储了结果以供后续比较。
步骤 6与步骤 5类似,唯一的区别是使用RandomOverSampler
来随机过采样少数类,以匹配多数类的样本大小。
在步骤 7和步骤 8中,我们应用了 SMOTE 和 ADASYN 变体的过采样方法。由于imblearn
库使得应用不同的采样方法变得非常简单,我们不会深入描述该过程。
在所有提到的重采样方法中,我们实际上可以通过向sampling_strategy
参数传递一个浮动值来指定类别之间的期望比例。该数字表示少数类样本与多数类样本的观察数之比。
在步骤 9中,我们没有重采样训练数据,而是使用了RandomForestClassifier
的class_weight
超参数来解决类别不平衡问题。通过传递"balanced"
,算法会自动分配与训练数据中类别频率成反比的权重。
使用class_weight
超参数有不同的可能方法。传递"balanced_subsample"
将得到与"balanced"
类似的权重分配;然而,权重是基于每棵树的自助样本计算的。或者,我们可以传递一个包含期望权重的字典。一种确定权重的方法是使用sklearn.utils.class_weight
中的compute_class_weight
函数。
imblearn
库还提供了一些流行分类器的修改版本。在步骤 10和步骤 11中,我们使用了修改过的随机森林分类器,即平衡随机森林。不同之处在于,在平衡随机森林中,算法会随机欠采样每个自助样本,以平衡类别。实际上,其 API 与普通的scikit-learn
实现几乎相同(包括可调节的超参数)。
在最后一步,我们将所有结果合并成一个单独的 DataFrame 并展示了结果。
还有更多...
在本教程中,我们仅介绍了一些可用的重采样方法。以下是更多的一些可能性。
欠采样:
NearMiss—这个名称指的是一组欠采样方法,基本上是基于最近邻算法的启发式规则。它们基于从多数类和少数类的观察之间的距离来选择要保留的多数类观察。其余的则被删除,以实现类别平衡。例如,NearMiss-1 方法选择那些与三个位于少数类的观察点距离最小的多数类观察。
编辑最近邻—这种方法删除任何多数类的观察,该观察的类别与其三个最近邻中的至少两个的类别不同。其基本思想是删除那些位于类别边界附近的多数类实例。
Tomek 链接—在这个欠采样启发式方法中,我们首先识别出所有最接近的观察对(它们是最近邻)且属于不同类别的对。这些对称为 Tomek 链接。然后,从这些对中,我们删除属于多数类的观察。其基本思想是通过从 Tomek 链接中删除这些观察,我们可以增加类别之间的分离度。
过采样:
SMOTE-NC(用于名义和连续特征的合成少数类过采样技术)—SMOTE 的变体,适用于包含数值和类别特征的数据集。普通的 SMOTE 可能会为独热编码特征创建不合逻辑的值。
边界 SMOTE——这种 SMOTE 算法的变种会在两个类别之间的决策边界上创建新的合成观察点,因为这些点更容易被错误分类。
SVM SMOTE——SMOTE 的一种变体,使用 SVM 算法来指示哪些观察点应被用于生成新的合成观察点。
K-means SMOTE——在这种方法中,我们首先应用k-均值聚类来识别具有大量少数类观察点的聚类。然后,将原始 SMOTE 应用于选定的聚类,每个聚类都会生成新的合成观察点。
另外,我们可以结合欠采样和过采样方法。其基本思想是,首先使用过采样方法创建重复或人工观察点,然后使用欠采样方法减少噪声或删除不必要的观察点。
例如,我们可以先使用 SMOTE 对数据进行过采样,然后使用随机下采样进行欠采样。imbalanced-learn
提供了两种组合重采样方法——SMOTE 后接 Tomek 链接或编辑最近邻。
在本食谱中,我们只涵盖了可用方法的一小部分。在切换话题之前,我们想提一些关于解决不平衡类问题的通用注意事项:
不要在测试集上应用欠采样/过采样。
在评估不平衡数据问题时,使用考虑类不平衡的度量标准,例如精确度、召回率、F1 分数、Cohen's kappa 或 PR-AUC。
在创建交叉验证的折叠时使用分层采样。
在交叉验证过程中引入欠采样/过采样,而不是之前。这样做会导致高估模型的性能!
在使用
imbalanced-learn
库创建具有重采样的管道时,我们还需要使用imbalanced-learn
的管道变种。这是因为重采样器使用fit_resample
方法,而不是scikit-learn
管道所需的fit_transform
方法。考虑从不同的角度框架问题。例如,我们可以将任务视为一个异常检测问题,而不是分类任务。然后,我们可以使用不同的技术,例如孤立森林。
尝试选择不同于默认 50%的概率阈值,以可能调优模型性能。我们可以使用使用不平衡数据集训练的模型绘制假阳性率和假阴性率与决策阈值的关系图,而不是重新平衡数据集。然后,我们可以选择一个在性能上最适合我们需求的阈值。
我们使用决策阈值来确定在哪个概率或得分(分类器的输出)上,我们认为给定的观察属于正类。默认情况下,这个值是 0.5。
另请参见
我们在本食谱中使用的数据集可以在 Kaggle 上找到:
额外资源可在此处获取:
Chawla, N. V., Bowyer, K. W., Hall, L. O., & Kegelmeyer, W. P. 2002. “SMOTE: 合成少数类过采样技术。” 人工智能研究期刊 16: 321–357.
Chawla, N. V. 2009. “面向不平衡数据集的数据挖掘:概述。” 数据挖掘与知识发现手册:875–886.
Chen, C., Liaw, A., & Breiman, L. 2004. “使用随机森林学习不平衡数据。” 加利福尼亚大学伯克利分校 110: 1–12.
Elor, Y., & Averbuch-Elor, H. 2022. “是使用 SMOTE,还是不使用 SMOTE?” arXiv 预印本 arXiv:2201.08528.
Han, H., Wang, W. Y., & Mao, B. H. 2005 年 8 月。边界-SMOTE:一种在不平衡数据集学习中的新过采样方法。在 智能计算国际会议,878–887. Springer,柏林,海德堡。
He, H., Bai, Y., Garcia, E. A., & Li, S. 2008 年 6 月。ADASYN:用于不平衡学习的自适应合成采样方法。在 2008 年 IEEE 国际神经网络联合会议(IEEE 世界计算智能大会),1322–1328. IEEE.
Le Borgne, Y.-A., Siblini, W., Lebichot, B., & Bontempi, G. 2022. 可重复的机器学习在信用卡欺诈检测中的应用——实践手册。
Liu, F. T., Ting, K. M., & Zhou, Z. H. 2008 年 12 月。隔离森林。在 2008 年第八届 IEEE 国际数据挖掘会议,413–422. IEEE.
Mani, I., & Zhang, I. 2003 年 8 月。kNN 方法用于不平衡数据分布:一个涉及信息提取的案例研究。在 从不平衡数据集学习研讨会论文集,126: 1–7. ICML.
Nguyen, H. M., Cooper, E. W., & Kamei, K. 2009 年 11 月。边界过采样用于不平衡数据分类。在 计算智能与应用国际研讨会论文集,2009(1): 24–29. IEEE SMC 广岛分会。
Pozzolo, A.D. 等. 2015. 使用欠采样进行概率校准以应对不平衡分类,2015 年 IEEE 计算智能学会年会。
Tomek, I. (1976). CNN 的两种修改,IEEE 系统 人类与通信学报,6: 769-772.
Wilson, D. L. (1972). “使用编辑数据的最近邻规则的渐近性质。” IEEE 系统、人类与控制论学报 3: 408–421.
利用集体智慧与堆叠集成
堆叠(堆叠泛化)是指创建潜在异质的机器学习模型集成的一种技术。堆叠集成的架构包括至少两个基础模型(称为第 0 层模型)和一个元模型(第 1 层模型),后者将基础模型的预测进行组合。下图展示了一个包含两个基础模型的示例。
图 14.15:具有两个基础学习器的堆叠集成的高级结构图
堆叠的目标是将一系列表现良好的模型的能力结合起来,获得的预测结果有可能比集成中任何单一模型的性能更好。这是可能的,因为堆叠集成试图利用基础模型的不同优势。因此,基础模型通常应该是复杂和多样的。例如,我们可以使用线性模型、决策树、各种集成方法、k 近邻、支持向量机、神经网络等等。
堆叠可能比之前介绍的集成方法(如自助法、提升法等)更难理解,因为在分割数据、处理潜在的过拟合和数据泄漏时,堆叠有至少几种变体。在本食谱中,我们遵循scikit-learn
库中使用的方法。
创建堆叠集成的方法可以通过三个步骤来描述。我们假设已经有了代表性的训练集和测试集。
步骤 1:训练层次 0 模型
这一过程的本质是,每个层次 0 模型都在完整的训练数据集上进行训练,然后这些模型被用来生成预测。
然后,我们需要考虑一些关于集成的事项。首先,我们必须选择想要使用的预测类型。对于回归问题,这很简单,因为我们没有其他选择。然而,在处理分类问题时,我们可以使用预测的类别或预测的概率/分数。
其次,我们可以仅使用预测结果(无论选择了哪个变体)作为层次 1 模型的特征,或者将原始特征集与层次 0 模型的预测结果结合。在实践中,结合特征通常会效果更好。当然,这在很大程度上取决于使用场景和考虑的数据集。
步骤 2:训练层次 1 模型
层次 1 模型(或元模型)通常相当简单,理想情况下可以提供对层次 0 模型所做预测的平滑解释。这就是为什么线性模型通常被选用于此任务的原因。
融合一词通常指的是使用简单的线性模型作为层次 1 模型。这是因为层次 1 模型的预测是层次 0 模型预测的加权平均值(或融合)。
在这个步骤中,层次 1 模型使用前一步的特征(可能仅是预测结果,或者与最初的特征集结合)以及某种交叉验证方案进行训练。后者用于选择元模型的超参数和/或考虑用于集成的基础模型集。
图 14.16:具有两个基础学习器的堆叠集成的低级结构图
在scikit-learn
的堆叠方法中,我们假设任何基础模型可能会过拟合,这可能是由于算法本身或其超参数的某种组合导致的。但如果确实如此,应该通过其他没有同样问题的基础模型来进行补偿。这就是为什么交叉验证应用于调优元模型,而不是基础模型。
在选择最佳的超参数/基础学习器后,最终估计器将在整个训练数据集上进行训练。
步骤 3:对未见过的数据进行预测
这个步骤是最简单的,因为我们本质上是将所有基础模型拟合到新的观测数据上,以获得预测结果,这些预测结果随后由元模型用于生成堆叠集成的最终预测。
在这个食谱中,我们创建了一个堆叠模型集成,应用于信用卡欺诈数据集。
如何实现...
执行以下步骤以创建堆叠集成:
导入库:
import pandas as pd from sklearn.model_selection import (train_test_split, StratifiedKFold) from sklearn.metrics import recall_score from sklearn.preprocessing import RobustScaler from sklearn.svm import SVC from sklearn.naive_bayes import GaussianNB from sklearn.tree import DecisionTreeClassifier from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.ensemble import StackingClassifier
加载并预处理数据:
RANDOM_STATE = 42 df = pd.read_csv("../Datasets/credit_card_fraud.csv") X = df.copy().drop(columns=["Time"]) y = X.pop("Class") X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=RANDOM_STATE ) robust_scaler = RobustScaler() X_train = robust_scaler.fit_transform(X_train) X_test = robust_scaler.transform(X_test)
定义基础模型列表:
base_models = [ ("dec_tree", DecisionTreeClassifier()), ("log_reg", LogisticRegression()), ("svc", SVC()), ("naive_bayes", GaussianNB()) ]
在随附的 Jupyter 笔记本中,我们指定了所有适用模型的随机状态。这里为了简洁起见,省略了这一部分。
训练选定的模型并使用测试集计算召回率:
for model_tuple in base_models: clf = model_tuple[1] if "n_jobs" in clf.get_params().keys(): clf.set_params(n_jobs=-1) clf.fit(X_train, y_train) recall = recall_score(y_test, clf.predict(X_test)) print(f"{model_tuple[0]}'s recall score: {recall:.4f}")
执行代码片段会生成以下输出:
dec_tree's recall score: 0.7551 log_reg's recall score: 0.6531 svc's recall score: 0.7041 naive_bayes's recall score: 0.8469
在考虑的模型中,朴素贝叶斯分类器在测试集上达到了最佳的召回率。
定义、拟合并评估堆叠集成:
cv_scheme = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE) meta_model = LogisticRegression(random_state=RANDOM_STATE) stack_clf = StackingClassifier( base_models, final_estimator=meta_model, cv=cv_scheme, n_jobs=-1 ) stack_clf.fit(X_train, y_train) recall = recall_score(y_test, stack_clf.predict(X_test)) print(f"The stacked ensemble's recall score: {recall:.4f}")
执行代码片段会生成以下输出:
The stacked ensemble's recall score: 0.7449
我们的堆叠集成的得分比最好的单个模型还要差。然而,我们可以尝试进一步改善集成。例如,我们可以允许集成使用初始特征作为元模型,并将逻辑回归元模型替换为随机森林分类器。
使用额外的特征和更复杂的元模型来改进堆叠集成:
meta_model = RandomForestClassifier(random_state=RANDOM_STATE) stack_clf = StackingClassifier( base_models, final_estimator=meta_model, cv=cv_scheme, passthrough=True, n_jobs=-1 ) stack_clf.fit(X_train, y_train)
第二个堆叠集成的召回率为0.8571
,优于最好的单个模型。
如何工作...
在步骤 1中,我们导入了所需的库。然后,我们加载了信用卡欺诈数据集,将目标变量与特征分开,删除了Time
特征,将数据拆分为训练集和测试集(使用分层拆分),最后,使用RobustScaler
对数据进行了缩放。尽管树模型不需要这种转换,但我们使用了各种分类器(每个分类器对输入数据有不同的假设)作为基础模型。为了简单起见,我们没有调查特征的不同属性,比如正态性。有关这些处理步骤的更多细节,请参阅之前的食谱。
在步骤 3中,我们定义了一组用于堆叠集成的基础学习器。我们决定使用几个简单的分类器,如决策树、朴素贝叶斯分类器、支持向量分类器和逻辑回归。为了简洁起见,我们这里不描述所选分类器的属性。
在准备基础学习器列表时,我们还可以提供整个管道,而不仅仅是估计器。当只有某些机器学习模型需要专门处理特征预处理(如特征缩放或编码分类变量)时,这一点非常有用。
在步骤 4中,我们遍历了分类器列表,使用默认设置将每个模型拟合到训练数据,并使用测试集计算召回率得分。此外,如果估计器具有n_jobs
参数,我们将其设置为-1
,以便使用所有可用核心进行计算。通过这种方式,我们可以加速模型的训练,前提是我们的机器有多个核心/线程可用。本步骤的目标是研究各个基础模型的性能,以便将它们与堆叠集成进行比较。
在步骤 5中,我们首先定义了元模型(逻辑回归)和 5 折分层交叉验证方案。然后,我们通过提供基础分类器列表、交叉验证方案和元模型来实例化StackingClassifier
。在scikit-learn
的堆叠实现中,基础学习器使用整个训练集进行拟合。然后,为了避免过拟合并提高模型的泛化能力,元估计器使用选定的交叉验证方案对模型进行训练,使用的是外样本。准确来说,它使用cross_val_predict
来完成这项任务。
这种方法的一个可能缺点是,仅对元学习器应用交叉验证可能导致基础学习器的过拟合。不同的库(在更多内容部分中提到)采用不同的堆叠集成交叉验证方法。
在最后一步,我们尝试通过修改堆叠集成的两个特征来提高其性能。首先,我们将一级模型从逻辑回归更改为随机森林分类器。其次,我们允许一级模型使用由零级基础模型使用的特征。为此,我们在实例化StackingClassifier
时,将passthrough
参数设置为True
。
更多内容...
为了更好地理解堆叠集成,我们可以查看步骤 1的输出,即用于训练一级模型的数据。为了获得这些数据,我们可以使用拟合后的StackedClassifier
的transform
方法。或者,当分类器没有拟合时,我们可以使用熟悉的fit_transform
方法。在我们的案例中,我们查看堆叠集成,使用预测和原始数据作为特征:
level_0_names = [f"{model[0]}_pred" for model in base_models]
level_0_df = pd.DataFrame(
stack_clf.transform(X_train),
columns=level_0_names + list(X.columns)
)
level_0_df.head()
执行这段代码会生成如下表格(简写版):
图 14.17:堆叠集成中一级模型输入的预览
我们可以看到前四列对应于基础学习器做出的预测。在这些预测旁边,我们可以看到其余的特征,也就是基础学习器用于生成预测的特征。
还值得一提的是,当使用StackingClassifier
时,我们可以将基础模型的不同输出作为一级模型的输入。例如,我们可以使用预测的概率/得分或预测的标签。使用stack_method
参数的默认设置,分类器会尝试使用以下类型的输出(按此特定顺序):predict_proba
、decision_function
和predict
。
如果我们使用stack_method="predict"
,我们会看到四列零和一,对应于模型的类别预测(使用默认的 0.5 决策阈值)。
在本配方中,我们展示了一个堆叠集成的简单示例。我们可以尝试进一步改进它的多种方式。一些可能的扩展包括:
向堆叠集成中添加更多层
使用更多样化的模型,例如 k-NN、增强树、神经网络等
调整基础分类器和/或元模型的超参数
scikit-learn
的ensemble
模块还包含一个VotingClassifier
,它可以聚合多个分类器的预测结果。VotingClassifier
使用两种可用的投票方案之一。第一种是hard
投票,即简单的多数投票。soft
投票方案使用预测概率的和的argmax
来预测类别标签。
还有其他库提供堆叠功能:
vecstack
mlxtend
h2o
这些库在堆叠方法上也有所不同,例如它们如何划分数据或如何处理潜在的过拟合和数据泄漏问题。有关更多详细信息,请参阅相应的文档。
另见
其他资源可以在此处获得:
Raschka, S. 2018. “MLxtend: 为 Python 的科学计算堆栈提供机器学习和数据科学工具及扩展。” The Journal of Open Source Software 3(24): 638。
Wolpert, D. H. 1992. “堆叠泛化”。Neural networks 5(2): 241–259。
贝叶斯超参数优化
在上一章的使用网格搜索和交叉验证调优超参数配方中,我们描述了如何使用不同形式的网格搜索来找到模型的最佳超参数。在本配方中,我们介绍了一种基于贝叶斯方法找到最优超参数集的替代方法。
贝叶斯方法的主要动机在于,无论是网格搜索还是随机化搜索,都会做出无知的选择,要么是通过对所有组合的穷举搜索,要么是通过随机抽样。这样,它们会花费大量时间评估那些远未达到最佳性能的组合,从而基本上浪费了时间。这就是为什么贝叶斯方法会根据已知信息选择下一个需要评估的超参数集合,从而减少寻找最佳集合所花费的时间。可以说,贝叶斯方法通过在选择要研究的超参数时投入更多时间,从而限制了评估目标函数所花费的时间,最终在计算上更加高效。
贝叶斯方法的一个形式化是基于序列模型的优化(SMBO)。从非常高层次看,SMBO 利用替代模型和获取函数,通过迭代(因此称为“序列”)选择搜索空间中最有前景的超参数,以逼近实际的目标函数。
在贝叶斯超参数优化(HPO)的背景下,真实目标函数通常是已训练机器学习模型的交叉验证误差。计算这些目标函数可能非常昂贵,可能需要数小时(甚至数天)才能计算完成。这就是为什么在 SMBO 中我们创建了替代模型,它是一个基于历史评估构建的目标函数的概率模型。它将输入值(超参数)映射到真实目标函数的得分概率。因此,我们可以将其视为对真实目标函数的近似。在我们采用的方法中(即hyperopt
库所使用的方法),替代模型是通过树状****帕尔岑估计器(TPE)构建的。其他可能的选择包括高斯过程或随机森林回归。
在每次迭代中,我们首先将替代模型拟合到迄今为止对目标函数的所有观察数据。然后,我们应用获取函数(例如期望改进)来根据超参数的预期效用确定下一组超参数。从直观上讲,这种方法利用过去评估的历史数据,为下一次迭代做出最佳选择。与过去表现良好的值接近的超参数,较可能提升整体性能,而那些历史上表现不佳的值则不太可能带来改进。获取函数还在超参数空间的探索新领域和利用已知能提供良好结果的领域之间定义了一种平衡。
贝叶斯优化的简化步骤如下:
创建真实目标函数的替代模型。
找到在替代模型中表现最好的超参数集合。
使用该集合评估真实目标函数。
使用评估真实目标的结果更新替代模型。
重复步骤 2-4,直到达到停止准则(指定的最大迭代次数或时间量)。
从这些步骤中可以看出,算法运行的时间越长,代理函数就越接近真实目标函数。这是因为每次迭代都会根据真实目标函数的评估来更新,因此每次运行时都会“少一些错误”。
正如我们已经提到的,贝叶斯超参数优化的最大优势是它减少了寻找最优参数集的时间。这一点在参数数量较多且评估真实目标计算代价高的情况下尤其重要。然而,它也有一些可能的缺点:
SMBO 程序的一些步骤无法并行执行,因为算法会根据过去的结果顺序选择一组超参数。
为超参数选择合适的分布/尺度可能会很棘手。
探索与开发的偏差——当算法找到局部最优解时,它可能会集中在该解附近的超参数值上,而不是探索在搜索空间中远离它的潜在新值。随机搜索不会遇到这个问题,因为它不会集中于任何值。
超参数的值是独立选择的。例如,在梯度提升树中,建议联合考虑学习率和估计器的数量,以避免过拟合并减少计算时间。TPE 无法发现这种关系。在我们知道有这种关系的情况下,可以通过使用不同的选择来定义搜索空间,从而部分解决这个问题。
在这简短的介绍中,我们提供了该方法论的高层次概述。然而,关于代理模型、获取函数等方面还有很多内容需要涵盖。因此,我们在另见部分参考了更多论文,以便进行更深入的解释。
在本例中,我们使用贝叶斯超参数优化来调整 LightGBM 模型。我们选择这个模型,因为它在性能和训练时间之间提供了非常好的平衡。我们将使用已经熟悉的信用卡欺诈数据集,这是一个高度不平衡的数据集。
如何实现...
执行以下步骤以运行 LightGBM 模型的贝叶斯超参数优化:
加载库:
import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.model_selection import (cross_val_score, StratifiedKFold) from lightgbm import LGBMClassifier from hyperopt import hp, fmin, tpe, STATUS_OK, Trials, space_eval from hyperopt.pyll import scope from hyperopt.pyll.stochastic import sample from chapter_14_utils import performance_evaluation_report
定义后续使用的参数:
N_FOLDS = 5 MAX_EVALS = 200 RANDOM_STATE = 42 EVAL_METRIC = "recall"
加载并准备数据:
df = pd.read_csv("../Datasets/credit_card_fraud.csv") X = df.copy().drop(columns=["Time"]) y = X.pop("Class") X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=RANDOM_STATE )
使用默认超参数训练基准 LightGBM 模型:
clf = LGBMClassifier(random_state=RANDOM_STATE) clf.fit(X_train, y_train) benchmark_perf = performance_evaluation_report( clf, X_test, y_test, show_plot=True, show_pr_curve=True ) print(f'Recall: {benchmark_perf["recall"]:.4f}')
执行代码片段会生成以下图形:
图 14.18:基准 LightGBM 模型的性能评估
此外,我们了解到基准模型在测试集上的召回率得分为
0.4286
。定义目标函数:
def objective(params, n_folds=N_FOLDS, random_state=RANDOM_STATE, metric=EVAL_METRIC): model = LGBMClassifier(**params, random_state=random_state) k_fold = StratifiedKFold(n_folds, shuffle=True, random_state=random_state) scores = cross_val_score(model, X_train, y_train, cv=k_fold, scoring=metric) loss = -1 * scores.mean() return {"loss": loss, "params": params, "status": STATUS_OK}
定义搜索空间:
search_space = { "n_estimators": hp.choice("n_estimators", [50, 100, 250, 500]), "boosting_type": hp.choice( "boosting_type", ["gbdt", "dart", "goss"] ), "is_unbalance": hp.choice("is_unbalance", [True, False]), "max_depth": scope.int(hp.uniform("max_depth", 3, 20)), "num_leaves": scope.int(hp.quniform("num_leaves", 5, 100, 1)), "min_child_samples": scope.int( hp.quniform("min_child_samples", 20, 500, 5) ), "colsample_bytree": hp.uniform("colsample_bytree", 0.3, 1.0), "learning_rate": hp.loguniform( "learning_rate", np.log(0.01), np.log(0.5) ), "reg_alpha": hp.uniform("reg_alpha", 0.0, 1.0), "reg_lambda": hp.uniform("reg_lambda", 0.0, 1.0), }
我们可以使用
sample
函数从样本空间中生成一个单一的抽样:sample(search_space)
执行代码片段将打印以下字典:
{'boosting_type': 'gbdt', 'colsample_bytree': 0.5718346953027432, 'is_unbalance': False, 'learning_rate': 0.44862566076557925, 'max_depth': 3, 'min_child_samples': 75, 'n_estimators': 250, 'num_leaves': 96, 'reg_alpha': 0.31830737977056545, 'reg_lambda': 0.637449220342909}
使用贝叶斯 HPO 寻找最佳超参数:
trials = Trials() best_set = fmin(fn=objective, space=search_space, algo=tpe.suggest, max_evals=MAX_EVALS, trials=trials, rstate=np.random.default_rng(RANDOM_STATE))
检查最佳超参数集:
space_eval(search_space , best_set)
执行代码片段将打印出最佳超参数的列表:
{'boosting_type': 'dart', 'colsample_bytree': 0.8764301395665521, 'is_unbalance': True, 'learning_rate': 0.019245717855584647, 'max_depth': 19, 'min_child_samples': 160, 'n_estimators': 50, 'num_leaves': 16, 'reg_alpha': 0.3902317904740905, 'reg_lambda': 0.48349252432635764}
使用最佳超参数拟合新模型:
tuned_lgbm = LGBMClassifier( **space_eval(search_space, best_set), random_state=RANDOM_STATE ) tuned_lgbm.fit(X_train, y_train)
在测试集上评估拟合的模型:
tuned_perf = performance_evaluation_report( tuned_lgbm, X_test, y_test, show_plot=True, show_pr_curve=True ) print(f'Recall: {tuned_perf["recall"]:.4f}')
执行代码片段将生成以下图表:
图 14.19:调优后的 LightGBM 模型的性能评估
我们可以看到,调优后的模型在测试集上表现更好。为了更具体地说明,它的召回率得分为0.8980
,而基准值为0.4286
。
它是如何工作的……
加载所需库后,我们定义了一组在本配方中使用的参数:交叉验证的折数、优化过程中的最大迭代次数、随机状态和用于优化的指标。
在第 3 步中,我们导入了数据集并创建了训练集和测试集。我们在之前的配方中描述了一些预处理步骤,更多信息请参考这些内容。接着,我们使用默认超参数训练了基准的 LightGBM 模型。
使用 LightGBM 时,我们实际上可以定义几个随机种子。每个树的装袋和特征子集选择都有各自的种子。此外,还有一个deterministic
标志,我们可以指定它。为了使结果完全可重复,我们还应该确保这些额外的设置被正确指定。
在第 5 步中,我们定义了真实目标函数(贝叶斯优化将为其创建代理函数)。该函数将超参数集作为输入,并使用分层的 5 折交叉验证计算要最小化的损失值。在欺诈检测的情况下,我们希望尽可能多地检测到欺诈,即使这意味着产生更多的假阳性。因此,我们选择召回率作为关注的指标。由于优化器将最小化该函数,我们将其乘以-1,以将问题转化为最大化问题。该函数必须返回一个单一值(损失)或一个包含至少两个键值对的字典:
loss
—真实目标函数的值。status
—指示损失值是否正确计算的指标。它可以是STATUS_OK
或STATUS_FAIL
。
此外,我们还返回了用于评估目标函数的超参数集。在*更多内容…*部分我们将回到这一点。
我们使用cross_val_score
函数计算验证得分。然而,在某些情况下,我们可能希望手动遍历由StratifiedKFold
创建的折。例如,我们希望访问 LightGBM 原生 API 的更多功能,例如早期停止。
在第 6 步中,我们定义了超参数网格。搜索空间被定义为一个字典,但与为GridSearchCV
定义的空间相比,我们使用了hyperopt
的内置函数,例如以下内容:
hp.choice(label,
list)
—返回所指定选项中的一个。hp.uniform(label,
lower_value,
upper_value)
—在两个值之间的均匀分布。hp.quniform(label,
low,
high,
q)
—在两个值之间的量化(或离散)均匀分布。实际上,这意味着我们得到的是均匀分布、间隔均匀(由q
确定)的整数。hp.loguniform(label,
low,
high)
—返回值的对数是均匀分布的。换句话说,返回的数字在对数尺度上是均匀分布的。这种分布对于探索跨越多个数量级变化的值非常有用。例如,在调节学习率时,我们希望测试像 0.001、0.01、0.1 和 1 这样的值,而不是在 0 和 1 之间均匀分布的值集。hp.randint(label,
upper_value)
—返回一个范围在[0, upper_value)
之间的随机整数。
请记住,在这个设置中,我们必须将超参数的名称(上面代码片段中的 label
)定义两次。此外,在某些情况下,我们希望强制值为整数,可以使用 scope.int
。
在 步骤 7 中,我们运行了贝叶斯优化,以寻找最佳的超参数集。首先,我们定义了 Trials
对象,用于存储搜索的历史记录。我们甚至可以使用它来恢复搜索或扩展已经完成的搜索,也就是说,通过使用已经存储的历史记录来增加迭代次数。
其次,我们通过传递目标函数、搜索空间、代理模型、最大迭代次数和 trials
对象(用于存储历史记录)来运行优化。有关调节 TPE 算法的更多细节,请参阅 hyperopt
的文档。此外,我们还设置了 rstate
的值,它是 hyperopt
中相当于 random_state
的设置。我们可以轻松地将 trials
对象存储到 pickle 文件中,以便以后使用。为此,我们可以使用 pickle.dump
和 pickle.load
函数。
运行贝叶斯优化(Bayesian HPO)后,trials
对象包含了许多有趣且有用的信息。我们可以通过 trials.best_trial
找到最佳的超参数集,而 trials.results
则包含了所有探索过的超参数集。我们将在 还有更多内容… 部分使用这些信息。
在 步骤 8 中,我们检查了最佳的超参数集。我们不仅仅是打印字典,而是必须使用 space_eval
函数。这是因为仅打印字典时,我们会看到任何分类特征的索引,而不是它们的名称。例如,打印 best_set
字典时,我们可能会看到 0
,而不是 boosting_type
超参数中的 'gbdt'
。
在最后两步中,我们使用确定的超参数训练了一个 LightGBM 分类器,并在测试集上评估了它的性能。
还有更多内容...
仍然有很多有趣且有用的内容需要提及关于贝叶斯超参数优化的内容。我们尝试在以下小节中进行介绍。为了简洁起见,我们不在此展示所有代码。如需完整的代码示例,请参考书籍 GitHub 仓库中的 Jupyter notebook。
条件超参数空间
条件超参数空间在我们想尝试不同的机器学习模型时非常有用,每个模型都有完全不同的超参数。或者,有些超参数彼此之间根本不兼容,在调优模型时需要考虑这一点。
对于 LightGBM,一个例子可能是以下的超参数组合:boosting_type
和 subsample
/subsample_freq
。提升类型 "goss"
与子采样不兼容,也就是说,在每次迭代中仅选择一部分训练样本进行训练。这就是为什么在使用 GOSS 时,我们希望将 subsample
设置为 1,但在其他情况下进行调优。subsample_freq
是一个补充性超参数,决定我们在每 n- 次迭代中应使用多少频率的子采样。
我们在以下代码片段中使用 hp.choice
定义了一个条件搜索空间:
conditional_search_space = {
"boosting_type": hp.choice("boosting_type", [
{"boosting_type": "gbdt",
"subsample": hp.uniform("gdbt_subsample", 0.5, 1),
"subsample_freq": scope.int(
hp.uniform("gdbt_subsample_freq", 1, 20)
)},
{"boosting_type": "dart",
"subsample": hp.uniform("dart_subsample", 0.5, 1),
"subsample_freq": scope.int(
hp.uniform("dart_subsample_freq", 1, 20)
)},
{"boosting_type": "goss",
"subsample": 1.0,
"subsample_freq": 0},
]),
"n_estimators": hp.choice("n_estimators", [50, 100, 250, 500]),
}
下面是从该空间中提取的一个示例:
{'boosting_type': {'boosting_type': 'dart',
'subsample': 0.9301284507624732,
'subsample_freq': 17},
'n_estimators': 250}
在我们能够使用这种抽取值进行贝叶斯超参数优化之前,还有一步需要完成。由于搜索空间最初是嵌套的,我们需要将抽取的样本分配到字典中的顶层键。我们可以通过以下代码片段来实现:
# draw from the search space
params = sample(conditional_search_space)
# retrieve the conditional parameters, set to default if missing
subsample = params["boosting_type"].get("subsample", 1.0)
subsample_freq = params["boosting_type"].get("subsample_freq", 0)
# fill in the params dict with the conditional values
params["boosting_type"] = params["boosting_type"]["boosting_type"]
params["subsample"] = subsample
params["subsample_freq"] = subsample_freq
params
get
方法从字典中提取所请求键的值,如果请求的键不存在,则返回默认值。
执行该代码片段会返回一个格式正确的字典:
{'boosting_type': 'dart',
'n_estimators': 250
'subsample': 0.9301284507624732,
'subsample_freq': 17}
最后,我们应该将清理字典的代码放入目标函数中,然后将其传递给优化过程。
在 Jupyter notebook 中,我们还使用条件搜索空间对 LightGBM 进行了调优。它在测试集上达到了 0.8980
的召回率,和没有使用条件搜索空间的模型得分相同。
图 14.20:使用条件搜索空间调优后的 LightGBM 模型的性能评估
深入探索已探索的超参数
我们已经提到过,hyperopt
提供了多种分布供我们进行采样。当我们实际看到这些分布的样子时,理解起来会更加容易。首先,我们检查学习率的分布。我们将其指定为:
hp.loguniform("learning_rate", np.log(0.01), np.log(0.5))
在下图中,我们可以看到从学习率的对数均匀分布中抽取的 10,000 个随机值的 核密度估计(KDE)图。
图 14.21:学习率的分布
正如预期的那样,我们可以看到分布在几个数量级的观测值上赋予了更多的权重。
下一个值得检查的分布是我们为min_child_samples
超参数使用的量化均匀分布。我们将其定义为:
scope.int(hp.quniform("min_child_samples", 20, 500, 5))
在下图中,我们可以看到分布反映了我们为其设置的假设,即均匀分布的整数是均匀分布的。在我们的例子中,我们每次采样一个间隔为 5 的整数。为了保持图表的可读性,我们只显示了前 20 个条形图。但完整的分布范围为 500,正如我们所指定的那样。
图 14.22:min_child_samples
超参数的分布
到目前为止,我们只查看了搜索空间中可用的信息。然而,我们还可以从Trials
对象中推导出更多信息,它存储了整个贝叶斯 HPO 过程的历史记录,即探索了哪些超参数,以及相应的得分是多少。
在这一部分,我们使用了包含搜索历史的Trials
对象,使用不包含条件boosting_type
调优的搜索空间。为了便于探索这些数据,我们准备了一个包含每次迭代所需信息的 DataFrame:超参数和损失函数的值。我们可以从trials.results
中提取这些信息。这也是我们在定义objective
函数时额外传递params
对象到最终字典中的原因。
最初,超参数作为字典存储在一列中。我们可以使用json_normalize
函数将其拆分为单独的列:
from pandas.io.json import json_normalize
results_df = pd.DataFrame(trials.results)
params_df = json_normalize(results_df["params"])
results_df = pd.concat([results_df.drop("params", axis=1), params_df],
axis=1)
results_df["iteration"] = np.arange(len(results_df)) + 1
results_df.sort_values("loss")
执行该代码段后,会打印出以下表格:
图 14.23:包含所有探索的超参数组合及其对应损失的 DataFrame 片段
为了简洁起见,我们只打印了几个可用的列。利用这些信息,我们可以进一步探索优化过程,以便找到最佳的超参数组合。例如,我们可以看到最佳得分是在第 151 次迭代时取得的(DataFrame 的第一行索引为150
,而 Python 的索引从0
开始)。
在下图中,我们绘制了colsample_bytree
超参数的两种分布:一种是我们定义的用于采样的先验分布,另一种是在贝叶斯优化过程中实际采样的分布。此外,我们还绘制了超参数随迭代的变化,并添加了回归线以指示变化的方向。
在左侧图中,我们可以看到colsample_bytree
的后验分布集中在右侧,表明考虑的值处于较高范围。通过检查 KDE 图,我们发现对于大于 1 的值似乎存在非零密度,而这些值是不允许的。
这只是使用绘图方法时的产物;在Trials
对象中我们可以确认在优化过程中没有采样到任何超过 1.0 的值。在右侧的图中,colsample_bytree
的值似乎散布在允许的范围内。通过观察回归线,似乎存在一定的上升趋势。
图 14.24:colsample_bytree
超参数的分布
最后,我们可以观察损失随迭代的演变。损失表示平均召回率的负值(来自训练集上的 5 折交叉验证)。最低值(对应最大平均召回率)为-0.90
,发生在第 151 次迭代中。除了一些例外,损失在-0.75
到-0.85
之间相对稳定。
图 14.25:损失(平均召回率)随迭代的演变。最佳迭代用星号标记
其他流行的超参数优化库
hyperopt
是最流行的超参数优化 Python 库之一。然而,它绝对不是唯一的。下面是一些流行的替代方案:
optuna
——一个提供广泛超参数调优功能的库,包括详尽的网格搜索、随机搜索、贝叶斯超参数优化和进化算法。scikit-optimize
——一个提供BayesSearchCV
类的库,BayesSearchCV
是scikit-learn
的GridSearchCV
的贝叶斯替代品。hyperopt-sklearn
——hyperopt
的衍生库,提供scikit-learn
中机器学习算法的模型选择。它允许在预处理步骤和机器学习模型之间搜索最佳选项,从而涵盖了整个机器学习管道的范围。该库涵盖了几乎所有在scikit-learn
中可用的分类器/回归器/预处理变换器。ray[tune]
——Ray 是一个开源的通用分布式计算框架。我们可以使用它的tune
模块进行分布式超参数调优。也可以将tune
的分布式计算能力与其他成熟的库(如hyperopt
或optuna
)结合使用。Tpot
——TPOT 是一个使用遗传编程优化机器学习管道的 AutoML 工具。bayesian-optimization
——一个提供通用贝叶斯全局优化的库,采用高斯过程。smac
——SMAC 是一个用于优化任意算法参数的通用工具,包括机器学习模型的超参数优化。
另见
其他资源可以在这里找到:
Bergstra, J. S., Bardenet, R., Bengio, Y., & Kégl, B. 2011. 超参数优化算法。载于《神经信息处理系统进展》:2546–2554。
Bergstra, J., Yamins, D., & Cox, D. D. 2013 年 6 月。Hyperopt:一个用于优化机器学习算法超参数的 Python 库。载于《第 12 届 Python 科学会议论文集》:13–20。
Bergstra, J., Yamins, D., Cox, D. D. 2013. 使模型搜索成为科学:视觉架构的数百维度的超参数优化。第 30 届国际机器学习大会(ICML 2013)论文集。
Claesen, M., & De Moor, B. 2015. “机器学习中的超参数搜索。” arXiv 预印本 arXiv:1502.02127。
Falkner, S., Klein, A., & Hutter, F. 2018 年 7 月。BOHB:大规模的稳健高效的超参数优化。在 国际机器学习大会:1437–1446。PMLR。
Hutter, F., Kotthoff, L., & Vanschoren, J. 2019. 自动化机器学习:方法、系统、挑战:219。Springer Nature。
Klein, A., Falkner, S., Bartels, S., Hennig, P., & Hutter, F. 2017 年 4 月。大规模数据集上的机器学习超参数的快速贝叶斯优化。在 人工智能与统计学:528–536。PMLR。
Komer B., Bergstra J., & Eliasmith C. 2014. “Hyperopt-Sklearn:Scikit-learn 的自动化超参数配置” Proc. SciPy。
Li, L., Jamieson, K., Rostamizadeh, A., Gonina, E., Hardt, M., Recht, B., & Talwalkar, A. 2018. 大规模并行超参数调优:
doi.org/10.48550/arXiv.1810.05934
Shahriari, B., Swersky, K., Wang, Z., Adams, R. P., & De Freitas, N. 2015. 从循环中去除人类:贝叶斯优化综述。IEEE会议录,104(1):148–175。
Snoek, J., Larochelle, H., & Adams, R. P. 2012. 机器学习算法的实用贝叶斯优化。神经信息处理系统进展:25。
调查特征重要性
我们已经花费了相当多的时间来创建整个管道并调优模型,以实现更好的性能。然而,同样重要——甚至在某些情况下更重要——的是模型的可解释性。这意味着不仅要给出准确的预测,还需要能够解释其背后的原因。例如,我们可以查看客户流失的案例。了解客户离开的实际预测因素可能有助于改善整体服务,并有可能让他们停留更长时间。
在金融环境中,银行通常使用机器学习来预测客户偿还信用或贷款的能力。在许多情况下,他们必须为自己的推理提供正当理由,即如果他们拒绝了一份信用申请,他们需要确切知道为什么这位客户的申请没有被批准。对于非常复杂的模型来说,这可能是困难的,甚至是不可能的。
通过了解特征的重要性,我们可以从多个方面受益:
通过理解模型的逻辑,我们可以理论上验证其正确性(如果某个合理的特征是一个好的预测因素),同时也能通过只关注重要变量来尝试改进模型。
我们可以使用特征重要性来保留前 x个最重要的特征(这些特征贡献了指定百分比的总重要性),这不仅能通过去除潜在的噪音提高性能,还能缩短训练时间。
在一些现实案例中,为了可解释性,牺牲一些准确性(或其他任何性能指标)是合理的。
同时需要注意的是,模型的准确性(就指定的性能指标而言)越高,特征重要性就越可靠。这就是为什么我们在调整模型后会调查特征的重要性。请注意,我们还应考虑过拟合,因为过拟合的模型不会返回可靠的特征重要性。
在这个配方中,我们展示了如何在随机森林分类器的示例中计算特征重要性。然而,大多数方法都是与模型无关的。在其他情况下,通常也有等效的方法(例如在 XGBoost 和 LightGBM 的情况下)。我们会在*更多内容...*部分提到其中的一些方法。我们简要介绍了计算特征重要性的三种选定方法。
不纯度的平均减少(MDI):这是随机森林(在scikit-learn
中使用的默认特征重要性),也称为基尼重要性。正如我们所知,决策树使用一种不纯度度量(基尼指数/熵/MSE)来创建最佳分裂。在训练决策树时,我们可以计算每个特征在减少加权不纯度方面的贡献。为了计算整个森林的特征重要性,算法会计算所有树的不纯度减少的平均值。
在使用基于不纯度的指标时,我们应该关注变量的排名(相对值),而不是特征重要性的绝对值(这些值也已归一化,使其总和为 1)。
以下是这种方法的优点:
快速计算
易于获取
以下是这种方法的缺点:
偏倚—它倾向于高估连续(数值型)特征或高卡方类别变量的重要性。这有时会导致荒谬的情况,其中一个额外的随机变量(与当前问题无关)在特征重要性排名中得分很高。
基于不纯度的特征重要性是基于训练集计算的,并不能反映模型对未见数据的泛化能力。
丢列特征重要性:这种方法背后的理念非常简单。我们将一个包含所有特征的模型与一个去掉某个特征的模型进行比较,进行训练和推断。我们对所有特征都重复这个过程。
以下是这种方法的优点:
- 通常被认为是最准确/最可靠的特征重要性度量
以下是这种方法的缺点:
- 由于对每个数据集变体进行重新训练,可能会导致最高的计算成本
置换特征重要性:这种方法通过观察每个预测变量的随机重排如何影响模型性能来直接测量特征重要性。置换过程破坏了特征与目标之间的关系。因此,模型性能的下降反映了模型在多大程度上依赖于某个特定特征。如果在重排特征后,性能的下降较小,则说明该特征本身并不是非常重要。相反,如果性能下降显著,则可以认为该特征对模型来说是重要的。
算法的步骤如下:
训练基准模型并记录感兴趣的得分。
随机打乱(重排)某个特征的值,然后使用整个数据集(包含已重排的特征)进行预测并记录得分。特征重要性是基准得分与重排数据集得分之间的差异。
对所有特征重复第二步。
在评估模型性能时,我们可以使用训练数据或验证/测试集。使用后两者中的任意一个的额外好处是能够获得有关模型泛化能力的洞察。例如,某些在训练集上重要但在验证集上不重要的特征,可能实际上会导致模型过拟合。有关该话题的更多讨论,请参阅可解释机器学习书籍(在另见部分中有参考)。
以下是这种方法的优点:
模型无关
合理高效——无需在每一步都重新训练模型
重排操作保持了变量的分布
以下是这种方法的缺点:
在计算上比默认特征重要性更为昂贵
当特征高度相关时,可能会产生不可靠的重要性(请参阅 Strobl et al. 以获取详细解释)
在本方案中,我们将使用在探索集成分类器方案中已经探索过的信用卡违约数据集来探讨特征重要性。
准备工作
对于这个方案,我们使用了拟合的随机森林管道(称为rf_pipeline
),该管道来自探索集成分类器方案。请参阅 Jupyter 笔记本中的这一步骤,以查看此处未包含的所有初始步骤,避免重复。
如何做到……
执行以下步骤以评估随机森林模型的特征重要性:
导入所需的库:
import numpy as np import pandas as pd from sklearn.inspection import permutation_importance from sklearn.metrics import recall_score from sklearn.base import clone
从拟合管道中提取分类器和预处理器:
rf_classifier = rf_pipeline.named_steps["classifier"] preprocessor = rf_pipeline.named_steps["preprocessor"]
从预处理变换器中恢复特征名称,并转换训练/测试集:
feat_names = list(preprocessor.get_feature_names_out()) X_train_preprocessed = pd.DataFrame( preprocessor.transform(X_train), columns=feat_names ) X_test_preprocessed = pd.DataFrame( preprocessor.transform(X_test), columns=feat_names )
提取 MDI 特征重要性并计算累计重要性:
rf_feat_imp = pd.DataFrame(rf_classifier.feature_importances_, index=feat_names, columns=["mdi"]) rf_feat_imp["mdi_cumul"] = np.cumsum( rf_feat_imp .sort_values("mdi", ascending=False) .loc[:, "mdi"] ).loc[feat_names]
定义一个函数,用于绘制按重要性排序的前* x *个特征:
def plot_most_important_features(feat_imp, title, n_features=10, bottom=False): if bottom: indicator = "Bottom" feat_imp = feat_imp.sort_values(ascending=True) else: indicator = "Top" feat_imp = feat_imp.sort_values(ascending=False) ax = feat_imp.head(n_features).plot.barh() ax.invert_yaxis() ax.set(title=f"{title} ({indicator} {n_features})", xlabel="Importance", ylabel="Feature") return ax
我们使用以下函数:
plot_most_important_features(rf_feat_imp["mdi"], title="MDI Importance")
执行代码片段生成以下图表:
图 14.26:使用 MDI 指标计算的前 10 个最重要特征
最重要的特征是分类特征,表示 7 月和 9 月的支付状态。在这四个特征之后,我们可以看到连续特征,如
limit_balance
、age
、各种账单声明和之前的付款。绘制特征的重要性累积图:
x_values = range(len(feat_names)) fig, ax = plt.subplots() ax.plot(x_values, rf_feat_imp["mdi_cumul"].sort_values(), "b-") ax.hlines(y=0.95, xmin=0, xmax=len(x_values), color="g", linestyles="dashed") ax.set(title="Cumulative MDI Importance", xlabel="# Features", ylabel="Importance")
执行该代码片段会生成以下图形:
图 14.27:累积 MDI 重要性
前 10 个特征占总重要性的 86.23%,而前 17 个特征占总重要性的 95%。
使用训练集计算并绘制置换重要性:
perm_result_train = permutation_importance( rf_classifier, X_train_preprocessed, y_train, n_repeats=25, scoring="recall", random_state=42, n_jobs=-1 ) rf_feat_imp["perm_imp_train"] = ( perm_result_train["importances_mean"] ) plot_most_important_features( rf_feat_imp["perm_imp_train"], title="Permutation importance - training set" )
执行该代码片段会生成以下图形:
图 14.28:根据训练集计算的置换重要性排名前 10 的特征
我们可以看到,最重要特征的集合与 MDI 重要性相比发生了重新排列。现在最重要的特征是
payment_status_sep_Unknown
,这是payment_status_sep
分类特征中的一个未定义标签(在原文中没有明确赋予意义)。我们还可以看到,age
不在使用这种方法确定的前 10 个最重要特征之中。使用测试集计算并绘制置换重要性:
perm_result_test = permutation_importance( rf_classifier, X_test_preprocessed, y_test, n_repeats=25, scoring="recall", random_state=42, n_jobs=-1 ) rf_feat_imp["perm_imp_test"] = ( perm_result_test["importances_mean"] ) plot_most_important_features( rf_feat_imp["perm_imp_test"], title="Permutation importance - test set" )
执行该代码片段会生成以下图形:
图 14.29:根据测试集计算的置换重要性排名前 10 的特征
通过查看这些图形,我们可以得出结论,使用训练集和测试集选择的四个最重要特征是相同的。其他特征则略有调整。
如果我们发现使用训练集和测试集计算的特征重要性有显著差异,应该调查模型是否存在过拟合的情况。为了解决这个问题,我们可能需要应用某种形式的正则化。在这种情况下,我们可以尝试增加
min_samples_leaf
超参数的值。定义一个计算移除列特征重要性的函数:
def drop_col_feat_imp(model, X, y, metric, random_state=42): model_clone = clone(model) model_clone.random_state = random_state model_clone.fit(X, y) benchmark_score = metric(y, model_clone.predict(X)) importances = [] for ind, col in enumerate(X.columns): print(f"Dropping {col} ({ind+1}/{len(X.columns)})") model_clone = clone(model) model_clone.random_state = random_state model_clone.fit(X.drop(col, axis=1), y) drop_col_score = metric( y, model_clone.predict(X.drop(col, axis=1)) ) importances.append(benchmark_score - drop_col_score) return importances
有两点值得注意:
我们固定了
random_state
,因为我们特别关注的是移除一个特征所导致的性能变化。因此,在估计过程中,我们控制了变异性的来源。在此实现中,我们使用训练数据进行评估。我们将修改该函数以接受额外的评估对象作为练习留给读者。
计算并绘制移除列特征重要性:
rf_feat_imp["drop_column_imp"] = drop_col_feat_imp( rf_classifier.set_params(**{"n_jobs": -1}), X_train_preprocessed, y_train, metric=recall_score, random_state=42 )
首先,绘制最重要的前 10 个特征:
plot_most_important_features( rf_feat_imp["drop_column_imp"], title="Drop column importance" )
执行该代码片段会生成以下图形:
图 14.30:根据移除列特征重要性计算的前 10 个最重要特征
使用删除列特征重要性(在训练数据上评估),最重要的特征是
payment_status_sep_Unknown
。通过置换特征重要性计算得出的最重要特征也是这个。然后,绘制 10 个最不重要的特征:
plot_most_important_features( rf_feat_imp["drop_column_imp"], title="Drop column importance", bottom=True )
执行该代码段将生成以下图表:
图 14.31:根据删除列特征重要性评估的 10 个最不重要的特征
在删除列特征重要性的情况下,负的特征重要性意味着从模型中删除某个特征实际上会提高模型的性能。这种情况在所考虑的度量标准将较高的值视为更好的时候是成立的。
我们可以使用这些结果来移除具有负面重要性的特征,从而可能提高模型的性能和/或减少训练时间。
它是如何工作的...
在步骤 1中,我们导入了所需的库。接着,我们从管道中提取了分类器和ColumnTransformer
预处理器。在这个示例中,我们使用了调优后的随机森林分类器(使用探索集成分类器示例中确定的超参数)。
在步骤 3中,我们首先使用get_feature_names_out
方法从预处理器中提取了列名。然后,通过应用预处理器的转换,我们准备了训练集和测试集。
在步骤 4中,我们使用拟合后的随机森林分类器的feature_importances_
属性提取了 MDI 特征重要性。值被自动归一化,使其加起来等于1
。此外,我们计算了累积特征重要性。
在步骤 5中,我们定义了一个辅助函数来绘制最重要/最不重要的特征,并绘制了通过计算平均减少不纯度得到的前 10 个最重要的特征。
在步骤 6中,我们绘制了所有特征的累积重要性。通过此图表,我们可以决定是否希望减少模型中的特征数量,以考虑总重要性的某一百分比。通过这样做,我们可能会减少模型的训练时间。
在步骤 7中,我们使用scikit-learn
中的permutation_importance
函数计算了置换特征重要性。我们决定使用召回率作为评分标准,并将n_repeats
参数设置为25
,这样算法就会将每个特征重新排列25
次。该过程的输出是一个包含三个元素的字典:原始特征重要性、每个特征的平均值和相应的标准差。此外,在使用permutation_importance
时,我们可以通过提供选定的度量标准列表来同时评估多个指标。
我们决定使用scikit-learn
的置换特征重要性实现。然而,也有其他可用的选项,例如在rfpimp
或eli5
库中。前者还包含删除列特征重要性。
在第 8 步中,我们计算并评估了置换特征重要性,这一次使用了测试集。
我们在介绍中提到过,当数据集中存在相关特征时,置换重要性可能会返回不可靠的得分,也就是说,重要性得分会分散到相关特征上。我们可以尝试以下方法来克服这个问题:
将相关特征组进行置换。
rfpimp
在importances
函数中提供了此功能。我们可以对特征的 Spearman 等级相关性进行层次聚类,选择一个阈值,然后仅保留每个识别出的簇中的单个特征。
在第 9 步中,我们定义了一个计算删除列特征重要性的函数。首先,我们使用所有特征训练并评估了基准模型。作为评分指标,我们选择了召回率。然后,我们使用scikit-learn
的clone
函数创建了一个与基准模型完全相同的模型副本。接着,我们迭代地在没有某个特征的数据集上训练模型,计算所选的评估指标,并存储得分差异。
在第 10 步中,我们应用了删除列特征重要性函数并绘制了结果,包括最重要和最不重要的特征。
还有更多...
我们已经提到过,scikit-learn
的随机森林默认的特征重要性是 MDI/Gini 重要性。值得一提的是,流行的提升算法(我们在探索集成分类器一节中提到过)也适配了已拟合模型的feature_importances_
属性。然而,根据不同的算法,它们使用了不同的特征重要性度量标准。
对于 XGBoost,我们有以下几种可能性:
weight
—衡量特征在所有树中用于分裂数据的次数。类似于 Gini 重要性,但它不考虑样本数量。gain
—衡量使用特征时在树中获得的平均增益。直观地,我们可以将其视为 Gini 重要性度量,其中 Gini 不纯度被梯度提升模型的目标所取代。cover
—衡量在树中使用该特征时的平均覆盖率。覆盖率定义为受到分裂影响的样本数。
cover
方法可以克服weight
方法的潜在问题之一——仅仅计算分裂次数可能具有误导性,因为有些分裂可能只影响少数几个观测值,因此并不是真正相关的。
对于 LightGBM,我们有以下几种可能性:
split
—衡量特征在模型中使用的次数。gain
—衡量使用特征时分裂的总增益。
另请参见
更多资源可以在此处找到:
Altmann, A., Toloşi, L., Sander, O., & Lengauer, T. 2010。“置换重要性:一种修正的特征重要性度量。”生物信息学, 26(10): 1340–1347。
Louppe, G. 2014. “理解随机森林:从理论到实践。” arXiv 预印本 arXiv:1407.7502。
Molnar, C. 2020. 可解释的机器学习:
christophm.github.io/interpretable-ml-book/
Hastie, T., Tibshirani, R., Friedman, J. H., & Friedman, J. H. 2009. 统计学习的元素:数据挖掘、推理与预测, 2: 1–758. 纽约:Springer。
Hooker, G., Mentch, L., & Zhou, S. 2021. “无限制排列强迫外推:变量重要性至少需要一个模型,否则没有自由的变量重要性。” 统计与计算, 31(6): 1–16。
Parr, T., Turgutlu, K., Csiszar, C., & Howard, J. 2018. 小心默认的随机森林重要性度量。2018 年 3 月 26 日。
explained.ai/rf-importance/
。Strobl, C., Boulesteix, A. L., Kneib, T., Augustin, T., & Zeileis, A. 2008. “随机森林的条件变量重要性。” BMC 生物信息学, 9(1): 307。
Strobl, C., Boulesteix, A. L., Zeileis, A., & Hothorn, T. 2007. “随机森林变量重要性度量中的偏差:插图、来源及解决方案。” BMC 生物信息学, 8(1): 1–21。
探索特征选择技术
在前一个教程中,我们展示了如何评估用于训练机器学习模型的特征的重要性。我们可以利用这些知识进行特征选择,即仅保留最相关的特征,并舍弃其余的特征。
特征选择是任何机器学习项目中至关重要的一部分。首先,它允许我们剔除那些完全不相关或对模型的预测能力贡献不大的特征。这可以在多个方面为我们带来好处。可能最重要的好处是,这些不重要的特征实际上可能会对我们模型的性能产生负面影响,因为它们引入了噪声并导致过拟合。正如我们之前所确定的——垃圾进,垃圾出。此外,减少特征通常意味着更短的训练时间,并帮助我们避免维度灾难。
其次,我们应该遵循奥卡姆剃刀原则,保持我们的模型简单且可解释。当我们拥有适量的特征时,解释模型中实际发生的事情会更容易。这对机器学习项目获得利益相关者的支持至关重要。
我们已经确定了特征选择的为什么。现在是时候探讨怎么做了。从高层次来看,特征选择方法可以分为三类:
过滤方法—一类通用的单变量方法,它们指定某个统计度量,然后基于该度量过滤特征。这个方法组不涉及任何特定的机器学习算法,因此其特点是(通常)较低的计算时间,并且不易过拟合。这个方法组的潜在缺点是它们会单独评估目标变量与每个特征之间的关系,这可能导致它们忽视特征之间的重要关系。示例包括相关性、卡方检验、方差分析(ANOVA)、信息增益、方差阈值等。
包装方法—这一类方法将特征选择视为一个搜索问题,即它使用某些程序反复评估特定的机器学习模型,并使用不同的特征集合来寻找最佳的特征集。其特点是计算成本最高,且过拟合的可能性也最高。示例包括前向选择、后向消除、逐步选择、递归特征消除等。
嵌入方法—这一类方法使用具有内置特征选择的机器学习算法,例如带正则化的 Lasso 或随机森林。通过使用这些隐式的特征选择方法,算法试图防止过拟合。在计算复杂度方面,这种方法通常介于过滤方法和包装方法之间。
在本节中,我们将应用一系列特征选择方法来处理信用卡欺诈数据集。我们认为这个数据集是一个很好的示例,特别是因为许多特征已被匿名化,我们并不知道它们背后的确切含义。因此,也很可能其中一些特征对模型的性能贡献并不大。
准备工作
在本节中,我们将使用在研究不同处理不平衡数据的方法中介绍的信用卡欺诈数据集。为了方便起见,我们在本节中已包含了来自随附 Jupyter 笔记本的所有必要准备步骤。
应用特征选择方法的另一个有趣挑战是 BNP Paribas Cardif Claims Management(数据集可以在 Kaggle 上找到——链接见另见部分)。与本节中使用的数据集类似,它包含 131 个匿名特征。
如何操作……
执行以下步骤来尝试不同的特征选择方法:
导入库:
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import recall_score from sklearn.feature_selection import (RFE, RFECV, SelectKBest, SelectFromModel, mutual_info_classif) from sklearn.model_selection import StratifiedKFold
训练基准模型:
rf = RandomForestClassifier(random_state=RANDOM_STATE, n_jobs=-1) rf.fit(X_train, y_train) recall_train = recall_score(y_train, rf.predict(X_train)) recall_test = recall_score(y_test, rf.predict(X_test)) print(f"Recall score training: {recall_train:.4f}") print(f"Recall score test: {recall_test:.4f}")
执行代码片段后会生成以下输出:
Recall score training: 1.0000 Recall score test: 0.8265
从召回率得分来看,模型显然对训练数据过拟合。通常,我们应该尝试解决这个问题。然而,为了简化操作,我们假设该模型已经足够好,可以继续进行。
使用互信息选择最佳特征:
scores = [] n_features_list = list(range(2, len(X_train.columns)+1)) for n_feat in n_features_list: print(f"Keeping {n_feat} most important features") mi_selector = SelectKBest(mutual_info_classif, k=n_feat) X_train_new = mi_selector.fit_transform(X_train, y_train) X_test_new = mi_selector.transform(X_test) rf.fit(X_train_new, y_train) recall_scores = [ recall_score(y_train, rf.predict(X_train_new)), recall_score(y_test, rf.predict(X_test_new)) ] scores.append(recall_scores) mi_scores_df = pd.DataFrame( scores, columns=["train_score", "test_score"], index=n_features_list )
使用下一个代码片段,我们绘制结果:
( mi_scores_df["test_score"] .plot(kind="bar", title="Feature selection using Mutual Information", xlabel="# of features", ylabel="Recall (test set)") )
执行代码片段后会生成以下图表:
图 14.32:模型的性能与所选特征数量的关系。特征是使用互信息准则选择的
通过检查图表,我们可以看到,使用
8
、9
、10
和12
个特征时,在测试集上达到了最佳的召回得分。由于我们追求简洁,最终决定选择8
个特征。使用以下代码片段,我们提取出 8 个最重要特征的名称:mi_selector = SelectKBest(mutual_info_classif, k=8) mi_selector.fit(X_train, y_train) print(f"Most importance features according to MI: {mi_selector.get_feature_names_out()}")
执行代码片段会返回以下输出:
Most importance features according to MI: ['V3' 'V4' 'V10' 'V11' 'V12' 'V14' 'V16' 'V17']
使用 MDI 特征重要性选择最佳特征,重新训练模型,并评估其性能:
rf_selector = SelectFromModel(rf) rf_selector.fit(X_train, y_train) mdi_features = X_train.columns[rf_selector.get_support()] rf.fit(X_train[mdi_features], y_train) recall_train = recall_score( y_train, rf.predict(X_train[mdi_features]) ) recall_test = recall_score(y_test, rf.predict(X_test[mdi_features])) print(f"Recall score training: {recall_train:.4f}") print(f"Recall score test: {recall_test:.4f}")
执行代码片段会生成以下输出:
Recall score training: 1.0000 Recall score test: 0.8367
使用以下代码片段,我们提取用于特征选择的阈值和最相关的特征:
print(f"MDI importance threshold: {rf_selector.threshold_:.4f}") print(f"Most importance features according to MI: {rf_selector.get_feature_names_out()}")
这生成了以下输出:
MDI importance threshold: 0.0345 Most importance features according to MDI: ['V10' 'V11' 'V12' 'V14' 'V16' 'V17']
阈值对应于 RF 模型的平均特征重要性。
使用类似于步骤 3中的循环,我们可以生成一个条形图,展示模型的性能与保留特征数量的关系。我们根据 MDI 迭代地选择前 k 个特征。为了避免重复,这里不包含代码(代码可以在随附的 Jupyter notebook 中找到)。通过分析图表,我们可以看到,模型在使用
10
个特征时取得了最佳得分,这比前一种方法更多。图 14.33:模型的性能与所选特征数量的关系。特征是使用均值减少不纯度特征重要性选择的
使用递归特征消除法(Recursive Feature Elimination)选择最佳的 10 个特征:
rfe = RFE(estimator=rf, n_features_to_select=10, verbose=1) rfe.fit(X_train, y_train)
为了避免重复,我们展示了最重要的特征及其附带的分数,而没有包括代码,因为它与我们在前面步骤中所涉及的内容几乎相同:
Most importance features according to RFE: ['V4' 'V7' 'V9' 'V10' 'V11' 'V12' 'V14' 'V16' 'V17' 'V26'] Recall score training: 1.0000 Recall score test: 0.8367
使用带交叉验证的递归特征消除法选择最佳特征:
k_fold = StratifiedKFold(5, shuffle=True, random_state=42) rfe_cv = RFECV(estimator=rf, step=1, cv=k_fold, min_features_to_select=5, scoring="recall", verbose=1, n_jobs=-1) rfe_cv.fit(X_train, y_train)
下面展示了特征选择的结果:
Most importance features according to RFECV: ['V1' 'V4' 'V6' 'V7' 'V9' 'V10' 'V11' 'V12' 'V14' 'V15' 'V16' 'V17' 'V18' 'V20' 'V21' 'V26'] Recall score training: 1.0000 Recall score test: 0.8265
该方法导致选择了
16
个特征。总体而言,在考虑的各个方法中,每个方法都有6
个特征:V10
、V11
、V12
、V14
、V16
和V17
。此外,使用以下代码片段,我们可以可视化交叉验证得分,也就是每个考虑的特征保留数量下,
5
折交叉验证的平均召回率。由于我们选择在RFECV
过程中至少保留5
个特征,因此必须将5
加到数据框的索引中:cv_results_df = pd.DataFrame(rfe_cv.cv_results_) cv_results_df.index += 5 ( cv_results_df["mean_test_score"] .plot(title="Average CV score over iterations", xlabel="# of features retained", ylabel="Avg. recall") )
执行代码片段会生成以下图表:
图 14.34:RFE 过程每一步的平均交叉验证得分
检查图表确认,使用 16 个特征时获得了最高的平均召回率。
在评估特征选择的好处时,我们应考虑两种情况。在更显而易见的一种情况下,当我们去除一些特征时,模型的表现得到了改善。这无需进一步解释。第二种情况则更为有趣。去除特征后,我们可能会得到与初始表现非常相似或者稍差的结果。然而,这并不一定意味着我们失败了。假设我们去除了约 60%的特征,同时保持了相同的表现。这可能已经是一个重要的改进,具体改进的程度取决于数据集和模型,可能会将训练时间减少几个小时甚至几天。此外,这样的模型也会更容易解释。
它是如何工作的……
导入所需的库后,我们训练了一个基准随机森林分类器,并打印了训练集和测试集的召回率分数。
在第 3 步中,我们应用了考虑的第一个特征选择方法。这是一个属于单变量过滤器类别的特征选择技术示例。作为统计标准,我们使用了互信息分数(Mutual Information score)。为了计算该指标,我们使用了mutual_info_classif
函数,该函数来自scikit-learn
,仅适用于分类目标和数值特征。因此,任何分类特征都需要提前进行适当的编码。幸运的是,在这个数据集中我们只有连续的数值特征。
互信息(MI)分数是两个随机变量之间互依赖性的度量。当分数为零时,表示这两个变量是独立的。分数越高,变量之间的依赖性越强。通常,计算 MI 需要了解每个特征的概率分布,而我们通常并不清楚这些分布。这就是为什么scikit-learn
的实现使用基于 k-最近邻距离的非参数近似法的原因。使用 MI 的一个优点是它能够捕捉特征之间的非线性关系。
接下来,我们将 MI 标准与SelectKBest
类结合使用,该类允许我们根据任意指标选择k个最佳特征。使用这种方法时,我们通常无法提前知道希望保留多少个特征。因此,我们遍历了所有可能的值(从2
到29
,后者是数据集中特征的总数)。SelectKBest
类采用了熟悉的fit
/transform
方法。在每次迭代中,我们将该类拟合到训练数据(此步骤需要特征和目标变量)上,然后对训练集和测试集进行转换。转换的结果是根据 MI 标准仅保留最重要的k个特征。接着,我们再次使用仅包含选定特征的训练数据来拟合随机森林分类器,并记录相关的召回率分数。
scikit-learn
使我们可以轻松地将不同的度量与SelectKBest
类一起使用。例如,我们可以使用以下评分函数:
f_classif
—ANOVA F 值,用于估算两个变量之间的线性依赖程度。F 统计量是通过计算组间变异性与组内变异性的比值来得出的。在这种情况下,组即为目标的类别。该方法的一个潜在缺点是它仅考虑了线性关系。chi2
—卡方统计量。该度量仅适用于非负特征,如布尔值或频率,或者更一般地,适用于分类特征。直观地说,它评估一个特征是否与目标变量独立。如果是这样,它在分类观察值时也不提供有用信息。
除了选择最优的 k 个特征外,scikit-learn
的feature_selection
模块还提供了其他类,允许根据最高得分的百分位、假阳性率测试、估计的假发现率或家族错误率来选择特征。
在步骤 4中,我们探索了嵌入式特征选择技术的一个示例。在这一组方法中,特征选择作为模型构建阶段的一部分进行。我们使用了SelectFromModel
类,根据模型的内置特征重要性度量(在此情况下为 MDI 特征重要性)来选择最佳特征。在实例化该类时,我们可以提供threshold
参数来确定用于选择最相关特征的阈值。特征的权重/系数高于该阈值的将被保留在模型中。我们还可以使用“mean
”(默认值)和“median
”关键字,使用所有特征重要性的均值/中位数作为阈值。我们还可以将这些关键字与缩放因子结合使用,例如,"1.5*mean"
。通过使用max_features
参数,我们可以确定允许选择的最大特征数量。
SelectFromModel
类适用于任何具有feature_importances_
(例如,随机森林、XGBoost、LightGBM 等)或coef_
(例如,线性回归、逻辑回归和 Lasso)属性的估算器。
在此步骤中,我们演示了两种恢复已选择特征的方法。第一种是get_support
方法,它返回一个包含布尔标志的列表,指示给定的特征是否被选择。第二种是get_feature_names_out
方法,它直接返回所选特征的名称。在拟合随机森林分类器时,我们手动选择了训练数据集的列。然而,我们也可以使用已拟合的SelectFromModel
类的transform
方法,自动提取相关特征并以numpy
数组的形式返回。
在第 5 步中,我们使用了包装方法的示例。递归特征消除(RFE)是一种递归训练机器学习模型、计算特征重要性(通过coef_
或feature_importances_
),并删除最不重要的特征的算法。
该过程首先使用所有特征训练模型。然后,从数据集中删除最不重要的特征。接下来,使用减少后的特征集重新训练模型,并再次删除最不重要的特征。这个过程会重复,直到达到所需的特征数量。在实例化RFE
类时,我们提供了随机森林估计器以及要选择的特征数量。此外,我们还可以提供step
参数,它决定了每次迭代中要删除多少个特征。
RFE 可能是一个计算开销较大的算法,尤其是在特征集较大且进行交叉验证时。因此,使用 RFE 之前,最好先应用一些其他的特征选择技术。例如,我们可以使用筛选方法,去除一些相关性较高的特征。
如我们之前所提到的,我们很少知道最佳的特征数量。这就是为什么在第 6 步中我们尝试解决这个缺点。通过将 RFE 与交叉验证结合使用,我们可以通过 RFE 过程自动确定保留的最佳特征数量。为此,我们使用了RFECV
类并提供了一些额外的输入。我们必须指定交叉验证方案(5 折分层交叉验证,因为我们处理的是不平衡数据集)、评分指标(召回率)以及保留的最小特征数量。对于最后一个参数,我们任意选择了 5。
最后,为了更深入地探讨交叉验证得分,我们通过已拟合的RFECV
类的cv_results_
属性访问了每个折的交叉验证得分。
还有更多…
其他一些可用的方法
我们已经提到了一些单变量筛选方法。其他一些值得注意的方法包括:
方差阈值—这种方法仅删除方差低于指定阈值的特征。因此,它可以用来去除常量特征和准常量特征。后者指的是那些几乎所有值都相同,变化性非常小的特征。根据定义,这种方法仅查看特征,而不考虑目标值。
基于相关性—有多种方式可以衡量相关性,因此我们只关注这种方法的基本逻辑。首先,我们确定特征与目标之间的相关性。然后我们可以选择一个阈值,高于该阈值的特征将保留用于建模。
然后,我们还应考虑去除那些高度相关的特征。我们应该识别这些特征组,并从每个组中只保留一个特征在我们的数据集中。另一种方法是使用方差膨胀因子(VIF)来判断多重共线性,并根据较高的 VIF 值去除特征。VIF 可以在statsmodels
中使用。
我们在这个方法中没有考虑使用相关性作为标准,因为信用卡欺诈数据集中的特征是 PCA 的结果。因此,按定义,它们是正交的,也就是不相关的。
也有多变量筛选方法可用。例如,最大相关最小冗余(MRMR)是一类算法,旨在识别与目标变量高度相关且相互冗余较小的特征子集。
我们还可以探索以下包装技术:
前向特征选择—我们从没有特征开始。我们单独测试每个特征,看看哪个特征最能改善模型。然后,我们将该特征添加到我们的特征集。接着,我们依次训练模型,添加第二个特征。类似地,在此步骤中,我们再次单独测试所有剩余特征。我们选择最佳的特征并将其添加到已选择的特征池中。我们继续一次一个地添加特征,直到达到停止标准(最大特征数或没有进一步改进)。传统上,添加的特征是根据特征的 p 值来决定的。然而,现代库使用交叉验证度量的改进作为选择标准。
向后特征选择—类似于前一种方法,但我们从所有特征开始,逐一去除每个特征,直到没有进一步的改进(或所有特征都是统计显著的)。该方法与 RFE 的不同之处在于,它不使用系数或特征重要性来选择要去除的特征。相反,它通过交叉验证得分的差异来优化性能提升。
穷举特征选择—简单来说,在这种暴力方法中,我们尝试所有可能的特征组合。自然地,这是所有包装技术中计算开销最大的一种,因为特征组合的数量随着特征数量的增加而指数级增长。例如,如果我们有 3 个特征,我们需要测试 7 个组合。假设我们有特征
a
、b
和c
,我们需要测试以下组合:[a, b, c, ab, ac, bc, abc]。逐步选择——一种结合了前向和后向特征选择的混合方法。该过程从零特征开始,并使用最低显著性 p 值逐一添加特征。在每一步添加时,过程还会检查当前特征中是否有任何在统计学上不显著的特征。如果有,这些特征将从特征集中过滤掉,算法继续进行下一步添加。该过程允许最终模型只包含统计学上显著的特征。
前两种方法已在scikit-learn
中实现。或者,你可以在mlxtend
库中找到所有四种方法。
我们还应该提到一些关于上述包装器技术的注意事项:
最佳特征数量取决于机器学习算法。
由于其迭代性质,它们能够检测特征之间的某些交互作用。
这些方法通常会为给定的机器学习算法提供最佳表现的特征子集。
它们的计算成本最高,因为它们采用贪婪算法,并多次重训练模型。
作为最后一种包装器方法,我们将提到Boruta算法。不深入细节,它创建了一组影像特征(原始特征的置换副本),并使用一个简单的启发式规则选择特征:如果某个特征的表现优于所有随机化特征中的最佳者,那么该特征是有用的。整个过程会重复多次,直到算法返回最佳特征集。该算法兼容scikit-learn
的ensemble
模块中的机器学习模型,以及 XGBoost 和 LightGBM 等算法。有关该算法的更多细节,请参见另见部分中的论文。Boruta 算法已在boruta
库中实现。
最后,值得一提的是,我们还可以结合多种特征选择方法,以提高其可靠性。例如,我们可以使用几种方法选择特征,然后最终选择所有方法中出现过的特征。
结合特征选择和超参数调优
正如我们已经确定的那样,我们无法事先知道应保留的最佳特征数量。因此,我们可能希望将特征选择与超参数调优相结合,并将保留的特征数量视为另一个超参数。
我们可以通过使用scikit-learn
中的pipelines
和GridSearchCV
轻松实现:
from sklearn.pipeline import Pipeline
from sklearn.model_selection import GridSearchCV
pipeline = Pipeline(
[
("selector", SelectKBest(mutual_info_classif)),
("model", rf)
]
)
param_grid = {
"selector__k": [5, 10, 20, 29],
"model__n_estimators": [10, 50, 100, 200]
}
gs = GridSearchCV(
estimator=pipeline,
param_grid=param_grid,
n_jobs=-1,
scoring="recall",
cv=k_fold,
verbose=1
)
gs.fit(X_train, y_train)
print(f"Best hyperparameters: {gs.best_params_}")
执行代码片段将返回最佳的超参数集:
Best hyperparameters: {'model__n_estimators': 50, 'selector__k': 20}
当将过滤特征选择方法与交叉验证结合使用时,我们应该在交叉验证过程中进行特征过滤。否则,我们就会使用所有可用的观测值来选择特征,从而引入偏差。
需要记住的一点是,在交叉验证的不同折中选择的特征可能不同。假设我们有一个5
折交叉验证过程,并选择了3
个特征。可能在某些5
折交叉验证轮次中,这3
个选定的特征不会重叠。然而,它们不应差异太大,因为我们假设数据中的整体模式和特征的分布在各个折之间非常相似。
另见
该主题的附加参考文献:
Bommert, A., Sun, X., Bischl, B., Rahnenführer, J., & Lang, M. 2020. “高维分类数据中特征选择过滤方法的基准。” 计算统计与数据分析,143:106839。
Ding, C., & Peng, H. 2005. “来自微阵列基因表达数据的最小冗余特征选择。” 生物信息学与计算生物学杂志,3(2):185–205。
Kira, K., & Rendell, L. A. 1992. 特征选择的实用方法。发表于机器学习论文集,1992:249–256. Morgan Kaufmann。
Kira, K., & Rendell, L. A. 1992 年 7 月。特征选择问题:传统方法与新算法。发表于 Aaai, 2(1992a):129-134。
Kuhn, M., & Johnson, K. 2019. 特征工程与选择:预测模型的实用方法。CRC 出版社。
Kursa M., & Rudnicki W. 2010 年 9 月。“使用 Boruta 包进行特征选择” 统计软件杂志,36(11):1-13。
Urbanowicz, RJ., 等. 2018. “基于 Relief 的特征选择:介绍与回顾。” 生物医学信息学杂志,85:189–203。
Yu, L., & Liu, H. 2003. 高维数据的特征选择:一种快速的基于相关性的过滤方法。发表于第 20 届国际机器学习会议(ICML-03):856–863。
Zhao, Z., Anand, R., & Wang, M. 2019 年 10 月。用于营销机器学习平台的最大相关性和最小冗余特征选择方法。发表于2019 IEEE 国际数据科学与高级分析会议(DSAA):442–452. IEEE。
你可以在准备就绪部分找到提到的附加数据集:
探索可解释的 AI 技术
在之前的一些案例中,我们探讨了特征重要性,作为更好理解模型内部工作原理的一种手段。尽管在线性回归的情况下,这可能是一个相对简单的任务,但随着模型复杂度的增加,这一任务变得愈加困难。
机器学习/深度学习领域的一个大趋势是可解释 AI(XAI)。它指的是允许我们更好地理解黑箱模型预测的各种技术。尽管目前的 XAI 方法无法将黑箱模型转变为完全可解释的模型(或白箱模型),但它们无疑有助于我们更好地理解为什么模型在给定的特征集上返回特定的预测。
拥有可解释 AI 模型的一些好处如下:
建立对模型的信任——如果模型的推理(通过其解释)符合常识或人类专家的信念,它可以增强对模型预测的信任
促进模型或项目在业务利益相关者中的采纳
通过提供模型决策过程的推理,提供有助于人类决策的见解
使调试变得更加容易
可以引导未来数据收集或特征工程的方向
在提到具体的 XAI 技术之前,值得澄清可解释性和可解释性之间的区别。可解释性可以看作是可解释性的一个更强版本。它提供基于因果关系的模型预测解释。另一方面,可解释性用于理解黑箱模型的预测,这些模型可能是不可解释的。具体来说,XAI 技术可以用来解释模型预测过程中的发生情况,但它们无法因果性地证明为什么做出某个预测。
在这个方案中,我们介绍了三种 XAI 技术。请参见*更多内容...*部分以了解更多可用的方法。
第一种技术被称为个体条件期望(ICE),它是一种局部且与模型无关的可解释性方法。局部部分指的是这项技术描述了在观察级别上特征(们)的影响。ICE 通常以图表的形式呈现,展示了当给定特征的值发生变化时,观察值的预测如何变化。
为了获得数据集中单个观察值及其特征的 ICE 值,我们需要创建多个该观察值的副本。在所有副本中,保持其他特征的值不变(除了被考虑的特征),同时将感兴趣特征的值替换为网格中的值。最常见的做法是,网格包含该特征在整个数据集中的所有不同值(对于所有观察值)。然后,我们使用(黑箱)模型对每个修改过的原始观察值副本进行预测。这些预测结果将绘制成 ICE 曲线。
优点:
计算简单,直观理解曲线表示的内容
ICE 可以揭示异质关系,即当特征对目标的影响方向因被探索特征值的区间不同而不同。
缺点:
我们一次只能有意义地展示一个特征。
绘制许多 ICE 曲线(对于多个观测值)可能会使图表过于拥挤,难以解读。
ICE 假设特征之间是独立的——当特征相关时,曲线中的某些点实际上可能是无效数据点(根据联合特征分布,它们可能是极不可能出现的,或者根本不可能出现)。
第二种方法叫做部分依赖图(PDP),它与 ICE 密切相关。它也是一种模型无关的方法,但它是全局的。这意味着 PDP 描述的是特征对目标的影响,考虑的是整个数据集的上下文。
PDP 呈现特征对预测的边际效应。直观地讲,我们可以将部分依赖看作是目标的预期响应作为感兴趣特征的函数的映射。它还可以显示特征与目标之间的关系是线性还是非线性的。就 PDP 的计算而言,它实际上是所有 ICE 曲线的平均值。
优点:
与 ICE 类似,PDP 计算起来很容易,且直观地可以理解曲线代表的含义。
如果感兴趣的特征与其他特征不相关,那么 PDP 就能完美地表示所选特征对预测的影响(平均而言)。
PDP 的计算具有因果解释(在模型内部)——通过观察特征变化所引起的预测变化,我们分析特征与预测之间的因果关系。
缺点:
PDP 还假设特征之间是独立的。
PDP 可能会掩盖由交互作用产生的异质关系。例如,我们可能会观察到目标与某个特征之间存在线性关系。然而,ICE 曲线可能显示出该模式存在例外情况,例如,在某些特征的范围内,目标保持不变。
PDP 可以用于分析至多两个特征。
我们在本节中讨论的最后一种 XAI 技术叫做SHapley Additive exPlanations(SHAP)。它是一个模型无关的框架,通过结合博弈论和局部解释来解释预测结果。
本方法涉及的具体方法论和计算超出了本书的范围。我们可以简要提到,Shapley 值是博弈论中使用的一种方法,涉及对游戏中合作的参与者进行公平的收益和成本分配。由于每个玩家对联盟的贡献不同,Shapley 值确保每个参与者根据他们的贡献获得公平的份额。
我们可以将其与机器学习(ML)设置进行比较,其中特征是玩家,合作游戏是创建机器学习模型的预测,而收益是实例的平均预测与所有实例的平均预测之间的差异。因此,对于某个特征的 Shapley 值的解释如下:该特征为该观察的预测贡献了x,与数据集的平均预测相比。
在讲解完 Shapley 值后,现在该解释 SHAP 是什么了。它是一种解释任何机器学习/深度学习模型输出的方法。SHAP 结合了最优信用分配和局部解释,利用了 Shapley 值(源自博弈论)及其扩展。
SHAP 提供了以下内容:
这是一种计算 Shapley 值的计算效率高且理论上稳健的方法,适用于机器学习模型(理想情况下只需要训练模型一次)。
KernelSHAP——一种替代的基于核的 Shapley 值估计方法,灵感来源于局部替代模型。
TreeSHAP——一种高效的基于树的模型估计方法。
基于 Shapley 值的各种全局解释方法。
为了更好地理解 SHAP,建议同时了解 LIME。请参考*更多内容…*部分以获取简要描述。
优势:
Shapley 值具有坚实的理论基础(效率、公平、虚拟和加法公理)。Lundberg 等(2017)解释了这些公理与 SHAP 值的对应属性(即局部准确性、缺失性和一致性)之间的微小差异。
由于其高效性,SHAP 可能是唯一一个能将预测公平分配到特征值中的框架。
SHAP 提供了全局可解释性——它展示了特征的重要性、特征之间的依赖关系、相互作用,以及某个特征是否对模型预测产生正面或负面影响。
SHAP 提供了局部可解释性——尽管许多技术仅关注整体可解释性,我们可以为每个单独的预测计算 SHAP 值,以了解特征是如何影响该特定预测的。
SHAP 可以用于解释多种模型,包括线性模型、基于树的模型和神经网络。
TreeSHAP(针对基于树的模型的快速实现)使得在实际应用中使用这种方法变得可行。
劣势:
计算时间——考虑的特征数量越多,特征的可能组合数量呈指数增长,这反过来增加了计算 SHAP 值的时间。因此,我们需要依赖近似方法。
与置换特征重要性相似,SHAP 值对特征之间的高度相关性敏感。如果出现这种情况,这些特征对模型评分的影响可能会在这些特征之间被任意分配,从而使我们认为它们的重要性低于实际情况。另外,相关特征可能会导致使用不现实/不可能的特征组合。
由于 Shapley 值并未提供预测模型(如 LIME 的情况),因此不能用来陈述输入变化如何对应预测变化。例如,我们不能说“如果特征 Y 的值增加 50 个单位,那么预测概率将增加 1 个百分点”。
KernelSHAP 运行较慢,且与其他基于置换的解释方法类似,忽略了特征之间的依赖关系。
准备工作
在本教程中,我们将使用我们在调查不同处理不平衡数据方法教程中介绍的信用卡欺诈数据集。为了方便起见,我们已经将所有必要的准备步骤包含在了伴随的 Jupyter notebook 的这一部分中。
如何操作…
执行以下步骤以调查对训练在信用卡欺诈数据集上的 XGBoost 模型预测进行解释的不同方法:
导入库:
from xgboost import XGBClassifier from sklearn.metrics import recall_score from sklearn.inspection import (partial_dependence, PartialDependenceDisplay) import shap
训练机器学习模型:
xgb = XGBClassifier(random_state=RANDOM_STATE, n_jobs=-1) xgb.fit(X_train, y_train) recall_train = recall_score(y_train, xgb.predict(X_train)) recall_test = recall_score(y_test, xgb.predict(X_test)) print(f"Recall score training: {recall_train:.4f}") print(f"Recall score test: {recall_test:.4f}")
执行代码片段会生成如下输出:
Recall score training: 1.0000 Recall score test: 0.8163
我们可以得出结论,模型对训练数据过拟合,理想情况下我们应该尝试通过例如在训练 XGBoost 模型时使用更强的正则化来解决这个问题。为了保持练习的简洁性,我们假设模型已准备好进行进一步分析。
与调查特征重要性类似,我们应首先确保模型在验证集/测试集上有令人满意的表现,然后再开始解释其预测。
绘制 ICE 曲线:
PartialDependenceDisplay.from_estimator( xgb, X_train, features=["V4"], kind="individual", subsample=5000, line_kw={"linewidth": 2}, random_state=RANDOM_STATE ) plt.title("ICE curves of V4")
执行代码片段会生成如下图表:
图 14.35:使用来自训练数据的 5,000 个随机样本创建的 V4 特征的 ICE 图
图 14.35 展示了
V4
特征的 ICE 曲线,这些曲线是使用来自训练数据的5,000
个随机观测值计算得出的。从图中可以看到,绝大多数观测值都位于0
附近,而少数曲线则显示出预测概率的显著变化。图表底部的黑色标记表示特征值的百分位数。默认情况下,ICE 图和 PDP 被限制在特征值的第 5 和第 95 百分位数;然而,我们可以通过
percentiles
参数更改这一设置。ICE 曲线的一个潜在问题是,很难看出曲线是否在不同观察值之间有所不同,因为它们的预测值起点不同。一个解决方案是将曲线集中在某一点,并仅显示与该点相比的预测差异。
绘制集中 ICE 曲线:
PartialDependenceDisplay.from_estimator( xgb, X_train, features=["V4"], kind="individual", subsample=5000, centered=True, line_kw={"linewidth": 2}, random_state=RANDOM_STATE ) plt.title("Centered ICE curves of V4")
执行代码片段后会生成如下图表:
图 14.36:使用训练数据中 5,000 个随机样本创建的 V4 特征的集中 ICE 图
集中 ICE 曲线的解释仅略有不同。我们不再直接观察特征值变化对预测的影响,而是观察相对于平均预测的预测值变化。这种方式使得分析预测值变化的方向变得更加容易。
生成部分依赖图:
PartialDependenceDisplay.from_estimator( xgb, X_train, features=["V4"], random_state=RANDOM_STATE ) plt.title("Partial Dependence Plot of V4")
执行代码片段后会生成如下图表:
图 14.37:使用训练数据准备的 V4 特征的部分依赖图
通过分析该图表,平均来看,随着
V4
特征的增加,预测概率似乎只有非常小的增加。与 ICE 曲线类似,我们也可以对 PDP 进行集中处理。
为了获得更多的见解,我们可以生成 PDP 并结合 ICE 曲线。我们可以使用以下代码片段来实现:
PartialDependenceDisplay.from_estimator( xgb, X_train, features=["V4"], kind="both", subsample=5000, ice_lines_kw={"linewidth": 2}, pd_line_kw={"color": "red"}, random_state=RANDOM_STATE ) plt.title("Partial Dependence Plot of V4, together with ICE curves")
执行代码片段后会生成如下图表:
图 14.38:V4 特征的部分依赖图(使用训练数据准备),并结合 ICE 曲线展示
如我们所见,部分依赖(PD)线几乎在 0 处水平。由于尺度的差异(请参阅图 14.37),在这样的图中,PD 线几乎没有任何意义。为了使图表更加易读或更容易解释,我们可以尝试使用
plt.ylim
函数限制 y 轴的范围。通过这种方式,我们可以集中关注 ICE 曲线大多数集中区域,同时忽略那些远离大部分曲线的少数曲线。然而,我们应该记住,这些异常值曲线对于分析也同样重要。生成两个特征的单独 PDP 以及一个联合 PDP:
fig, ax = plt.subplots(figsize=(20, 8)) PartialDependenceDisplay.from_estimator( xgb, X_train.sample(20000, random_state=RANDOM_STATE), features=["V4", "V8", ("V4", "V8")], centered=True, ax=ax ) ax.set_title("Centered Partial Dependence Plots of V4 and V8")
执行代码片段后会生成如下图表:
图 14.39:V4 和 V8 特征的集中部分依赖图,分别和联合展示
通过联合绘制两个特征的 PDP,我们能够可视化它们之间的交互关系。通过观察图 14.39,我们可以得出结论:
V4
特征更为重要,因为在最右侧图中,绝大多数线条是垂直于V4
轴并且与V8
轴平行的。然而,由V8
特征决定的决策线会有所偏移,例如,在0.25
值附近。实例化一个解释器并计算 SHAP 值:
explainer = shap.TreeExplainer(xgb) shap_values = explainer.shap_values(X) explainer_x = explainer(X)
shap_values
对象是一个284807
行29
列的numpy
数组,包含计算得到的 SHAP 值。生成 SHAP 汇总图:
shap.summary_plot(shap_values, X)
执行代码片段后会生成如下图表:
图 14.40:使用 SHAP 值计算的汇总图
在查看汇总图时,我们需要注意以下几点:
特征按所有观察值的 SHAP 值绝对值的总和排序。
点的颜色显示该特征对于该观察值是否具有高或低的值。
图表中的横向位置显示该特征值的效应是导致更高还是更低的预测。
默认情况下,图表显示的是
20
个最重要的特征。我们可以使用max_display
参数调整这个数值。重叠的点在y轴方向上进行了抖动。因此,我们可以感知每个特征的 SHAP 值的分布情况。
与其他特征重要性度量(例如置换重要性)相比,这种类型的图表的优势在于它包含更多的信息,可以帮助理解全局特征重要性。例如,假设某个特征的重要性适中。使用此图,我们可以查看该中等重要性的特征值是否对某些观察的预测有较大影响,但通常对其他预测没有影响。或者它可能对所有预测都有中等大小的影响。
在讨论了总体考虑因素之后,让我们提及一些来自图 14.40的观察结果:
总体而言,
V4
特征(最重要的特征)较高的值有助于提高预测值,而较低的值则导致较低的预测值(观察结果更不可能为欺诈行为)。V14
特征对预测的整体影响是负面的,但对于一些特征值较低的观察,其结果却导致更高的预测值。
另外,我们可以使用条形图展示相同的信息。这样,我们就可以关注特征的重要性汇总,而忽略对特征效应的深入理解:
shap.summary_plot(shap_values, X, plot_type="bar")
执行该代码片段会生成以下图表:
图 14.41:使用 SHAP 值计算的汇总图(条形图)
自然地,特征的顺序(它们的重要性)与图 14.40中的顺序相同。我们可以将这个图作为替代的置换特征重要性。然而,我们应该牢记其中的基本区别。置换特征重要性基于模型性能的下降(使用选择的度量标准来衡量),而 SHAP 则基于特征归因的大小。
我们可以使用以下命令获取汇总图的更简洁表示:
shap.plots.bar(explainer_x)
。定位属于正类和负类的观察结果:
negative_ind = y[y == 0].index[0] positive_ind = y[y == 1].index[0]
解释这些观察结果:
shap.force_plot( explainer.expected_value, shap_values[negative_ind, :], X.iloc[negative_ind, :] )
执行该代码片段会生成以下图表:
图 14.42:解释属于负类的观察值的(简化版)力图
简而言之,力图展示了特征如何将预测从基准值(平均预测)推向实际预测。由于该图包含了更多的信息且过宽,无法适应页面,因此我们仅展示了最相关的部分。请参考随附的 Jupyter 笔记本以查看完整图表。
以下是我们基于 图 14.42 可以做出的一些观察:
基准值 (-8.589) 是整个数据集的平均预测值。
f(x) = -13.37 是此观察值的预测结果。
我们可以将箭头解释为给定特征对预测结果的影响。红色箭头表示预测结果的增加,蓝色箭头表示预测结果的减少。箭头的大小对应于特征影响的大小。特征名称旁边的值显示了特征的值。
如果我们将红色箭头的总长度从蓝色箭头的总长度中减去,就可以得到从基准值到最终预测的距离。
因此,我们可以看到,相较于平均预测,导致预测减少的最大因素是特征
V14
的值 -0.3112。
然后我们对正类观察值执行相同的步骤:
shap.force_plot( explainer.expected_value, shap_values[positive_ind, :], X.iloc[positive_ind, :] )
执行该代码片段生成以下图表:
图 14.43:解释属于正类的观察值的(简化版)力图
与 图 14.42 相比,我们可以清楚地看到蓝色特征(负面影响预测,标记为 lower)与红色特征(标记为 higher)之间的失衡。我们还可以看到,两个图形具有相同的基准值,因为这是数据集的平均预测值。
为正类观察值创建瀑布图:
shap.plots.waterfall(explainer(X)[positive_ind])
执行该代码片段生成以下图表:
图 14.44:解释来自正类的观察值的瀑布图
检查 图 14.44 可以发现它与 图 14.43 有许多相似之处,因为这两个图表使用稍有不同的可视化方式解释了同一个观察值。因此,解读瀑布图的大部分见解与力图相同。一些细微差别包括:
图表的底部从基准值开始(模型的平均预测值)。然后,每一行显示了每个特征对该特定观察值模型最终预测的正面或负面贡献。
SHAP 通过其边际输出解释 XGBoost 分类器。这意味着 x 轴上的单位是对数几率单位。负值表示该观察值为欺诈行为的概率低于
0.5
。最不重要的特征被合并为一个联合项。我们可以使用该函数的
max_display
参数来控制这一点。
创建
V4
特征的依赖图:shap.dependence_plot("V4", shap_values, X)
执行该代码片段生成以下图表:
图 14.45:展示 V4 和 V12 特征之间依赖关系的依赖图
关于依赖图的一些要点:
它可能是最简单的全局解释图。
这种类型的图是部分依赖图的替代方案。虽然 PDP 显示了平均效应,SHAP 依赖图还额外展示了y轴上的方差。因此,它包含了关于效应分布的信息。
该图展示了特征值(x轴)与该特征的 SHAP 值(y轴)在数据集中所有观测值中的关系。每个点代表一个单独的观测值。
由于我们正在解释一个 XGBoost 分类模型,y轴的单位是属于欺诈案件的对数几率。
颜色对应于可能与我们指定的特征存在交互效应的第二个特征。它由
shap
库自动选择。文档中指出,如果两个特征之间存在交互效应,它将以一种独特的垂直颜色模式显示。换句话说,我们应该注意观察在同一x轴值上,不同颜色之间是否有明显的垂直扩展。
为了完成分析,我们可以提到从图 14.45中得到的潜在结论。不幸的是,这将不会非常直观,因为特征已经被匿名化。
例如,假设我们查看特征V4
值大约为 5 的观测值。对于这些样本,特征V12
值较低的观测值比特征V12
值较高的观测值更有可能是欺诈的。
它是如何工作的…
在导入库之后,我们训练了一个 XGBoost 模型来检测信用卡欺诈。
在第 3 步中,我们使用PartialDependenceDisplay
类绘制了 ICE 曲线。我们必须提供拟合的模型、数据集(我们使用了训练集)和感兴趣的特征。此外,我们还提供了subsample
参数,指定了用于绘制 ICE 曲线的数据集中的随机观测值数量。由于数据集有超过200,000个观测值,我们任意选择了5,000个曲线作为可管理的绘制数量。
我们提到过,计算 ICE 曲线时使用的网格通常由数据集中的所有唯一值组成。scikit-learn
默认创建一个等距网格,覆盖特征的极值范围。我们可以使用grid_resolution
参数自定义网格的密度。
PartialDependenceDisplay
的from_estimator
方法也接受kind
参数,可以取以下值:
kind="individual"
—该方法将绘制 ICE 曲线。kind="average"
—该方法将显示部分依赖图(PDP)。kind="both"
—该方法将显示 PDP 和 ICE 曲线。
在第 4 步中,我们绘制了相同的 ICE 曲线;然而,我们将它们居中于原点。我们通过将centered
参数设置为True
来实现这一点。这实际上是从目标向量中减去平均目标值,并将目标值居中于0
。
在第 5 步中,我们绘制了部分依赖图,同样使用了PartialDependenceDisplay.from_estimator
。由于 PDP 是默认值,我们无需指定kind
参数。我们还展示了在同一图中绘制 PDP 和 ICE 曲线的结果。由于绘制双向 PDP 需要相当长的时间,我们从训练集中随机抽取了(不放回)20,000个样本。
需要注意的是,PartialDependenceDisplay
将分类特征视为数值特征。
部分依赖图(PDP)也可以在pdpbox
库中找到。
在第 6 步中,我们使用相同的PartialDependenceDisplay
功能创建了一个更复杂的图形。在一个图中,我们绘制了两个特征(V4
和V8
)的单独 PD 图,以及它们的联合(也叫双向)PD 图。为了获得最后一个图,我们需要将这两个感兴趣的特征作为元组提供。通过指定features=["V4", "V8", ("V4", "V8")]
,我们表明希望绘制两个单独的 PD 图,然后绘制这两个特征的联合图。当然,没有必要将所有3
个图都绘制在同一图中。我们可以使用features=[("V4", "V8")]
只绘制联合 PDP。
另一个有趣的角度是叠加两个部分依赖图,它们是针对相同特征但使用不同的机器学习模型计算的。然后,我们可以比较不同模型之间对预测的预期影响是否相似。
我们集中于绘制 ICE 曲线和部分依赖线。然而,我们也可以在不自动绘制它们的情况下计算这些值。为此,我们可以使用partial_dependence
函数。它返回一个包含3
个元素的字典:用于创建评估网格的值、数据集中所有样本的所有网格点的预测值(用于 ICE 曲线)以及每个网格点的预测值的平均值(用于 PDP)。
在第 7 步中,我们实例化了explainer
对象,这是用于通过shap
库解释任何机器学习/深度学习模型的主要类。更准确地说,我们使用了TreeExplainer
类,因为我们尝试解释的是 XGBoost 模型,即基于树的模型。然后,我们使用实例化的explainer
的shap_values
方法计算了 SHAP 值。为了说明模型的预测,我们使用了整个数据集。在这一点上,我们也可以选择使用训练集或验证/测试集。
根据定义,SHAP 值的计算非常复杂(属于 NP 难问题)。然而,得益于线性模型的简单性,我们可以从部分依赖图中读取 SHAP 值。有关此主题的更多信息,请参阅shap
文档。
在第 8 步中,我们首先使用全局解释方法。我们使用shap.summary_plot
函数生成了两种版本的总结图。第一个是每个特征的 SHAP 值的密度散点图。它结合了整体特征重要性和特征效应。我们可以利用这些信息评估每个特征对模型预测的影响(也包括观察级别的影响)。
第二个图表是一个条形图,显示了整个数据集中绝对 SHAP 值的平均值。在这两种情况下,我们都可以使用图表来推断通过 SHAP 值计算的特征重要性;然而,第一个图表提供了更多的信息。为了生成这个图表,我们在调用shap.summary_plot
函数时必须额外传入plot_type="bar"
参数。
在查看了全局解释后,我们希望查看局部解释。为了使分析更有趣,我们想要展示属于正类和负类观察的解释。这就是为什么在第 9 步中我们识别了这些观察的索引。
在第 10 步中,我们使用shap.force_plot
来解释两个观察的观察级预测。在调用该函数时,我们必须提供三个输入:
基准值(整个数据集的平均预测值),该值在解释器对象中可用(
explainer.expected_value
)特定观察的 SHAP 值
特定观察的特征值
在第 11 步中,我们还创建了一个观察级别的图表来解释预测结果;然而,我们使用了略微不同的表示方法。我们创建了一个瀑布图(使用shap.plots.waterfall
函数)来解释正向观察。值得一提的是,该函数需要一个Explanation
对象的单行作为输入。
在最后一步,我们使用shap.dependence_plot
函数创建了一个 SHAP 依赖图(全局级别的解释)。我们需要提供感兴趣的特征、SHAP 值和特征值。作为被考虑的特征,我们选择了V4
,因为它在总结图中被确定为最重要的特征。第二个特征(V12
)由库自动确定。
还有更多内容……
在本节中,我们只提供了 XAI 领域的一个初步了解。随着可解释方法在实践者和企业中的重要性日益增加,这一领域也在不断发展壮大。
另一种流行的 XAI 技术被称为 LIME,代表局部可解释模型无关解释。它是一种观察级别的方法,用于以可解释且忠实的方式解释任何模型的预测结果。为了获得解释,LIME 通过可解释模型(如带正则化的线性模型)在局部近似选择的难以解释的模型。可解释模型是在原始观察的微小扰动(带有附加噪声)上进行训练,从而提供良好的局部近似。
Treeinterpreter 是另一种观察级别的 XAI 方法,适用于解释随机森林模型。其思路是利用底层的树结构来解释每个特征对最终结果的贡献。预测被定义为每个特征贡献的总和,以及由基于整个训练集的初始节点给出的平均值。通过这种方法,我们可以观察预测值如何沿着决策树中的预测路径(每次分裂后)变化,并结合导致分裂的特征信息,也就是预测变化的原因。
自然地,还有许多其他可用的方法,例如:
假设不变轮廓
分解图
累积局部效应(ALE)
全局代理模型
反事实解释
锚点
我们建议调查以下专注于人工智能可解释性的 Python 库:
shapash
—将 SHAP/LIME 的各种可视化结果编译为交互式仪表板 Web 应用。explainerdashboard
—准备一个仪表板 Web 应用程序,用于解释与scikit-learn
兼容的机器学习模型。该仪表板涵盖模型性能、特征重要性、特征对单个预测的贡献、“如果”分析、PDP、SHAP 值、单个决策树的可视化等。dalex
—该库涵盖了各种 XAI 方法,包括变量重要性、PDP 和 ALE 图、分解图和 SHAP 瀑布图等。interpret
—InterpretML 库由微软创建。它涵盖了流行的黑箱模型解释方法(如 PDP、SHAP、LIME 等),并允许训练所谓的玻璃箱模型,这些模型是可解释的。例如,ExplainableBoostingClassifier
被设计为完全可解释,但同时提供与最先进算法相似的准确性。eli5
—一个可解释性库,提供各种全局和局部解释。它还涵盖文本解释(由 LIME 提供支持)和置换特征重要性。alibi
—一个专注于模型检查和解释的库。它涵盖了诸如锚点解释、集成梯度、反事实示例、对比解释方法以及累积局部效应等方法。
另见
额外资源可以在此获取:
Biecek, P., & Burzykowski, T. 2021. 解释性模型分析:探索、解释和检查预测模型。Chapman and Hall/CRC。
Friedman, J. H. 2001. “贪婪函数逼近:梯度提升机。” 统计年鉴:1189–1232。
Goldstein, A., Kapelner, A., Bleich, J., & Pitkin, E. 2015. “窥视黑盒:通过个体条件期望的可视化图形展示统计学习。” 计算与图形统计学杂志,24(1):44–65。
Hastie, T., Tibshirani, R., Friedman, J. H., & Friedman, J. H. 2009. 统计学习要素:数据挖掘、推理与预测,2:1–758)。纽约:Springer。
Lundberg, S. M., Erion, G., Chen, H., DeGrave, A., Prutkin, J. M., Nair, B., ... & Lee, S. I. 2020. “从局部解释到全局理解:树的可解释 AI。” 自然机器智能,2(1):56–67。
Lundberg, S. M., Erion, G. G., & Lee, S. I. 2018. “树集成方法的一致性个性化特征归因。” arXiv 预印本 arXiv:1802.03888。
Lundberg, S. M., & Lee, S. I. 2017. 一种统一的模型预测解释方法。神经信息处理系统进展,30。
Molnar, C. 2020. 可解释的机器学习。
christophm.github.io/interpretable-ml-book/
。Ribeiro, M.T., Singh, S., & Guestrin, C. 2016. “我为什么要相信你?:解释任何分类器的预测。” 第 22 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集。ACM。
Saabas, A. 解释随机森林。
blog.datadive.net/interpreting-random-forests/
。
总结
在本章中,我们涵盖了各种有助于改进几乎所有机器学习(ML)或深度学习(DL)项目的有用概念。我们从探讨更复杂的分类器开始(这些分类器也有相应的回归问题变种),考虑了对分类特征编码的替代方法,创建了堆叠集成,并研究了可能解决类别不平衡的方案。我们还展示了如何使用贝叶斯方法进行超参数调优,以便比使用更流行但信息不足的网格搜索方法更快地找到最优的超参数组合。
我们还深入探讨了特征重要性和 AI 可解释性的话题。通过这种方式,我们可以更好地理解所谓的黑盒模型中发生的事情。这不仅对从事 ML/DL 项目的人至关重要,对任何业务相关方也同样如此。此外,我们可以将这些见解与特征选择技术结合起来,可能进一步提升模型的性能或减少其训练时间。
自然,数据科学领域不断发展,每天都有越来越多有用的工具可供使用。我们无法涵盖所有工具,但在下面您可以找到一份简短的库/工具列表,您可能会在项目中找到有用的资源:
DagsHub
—一个类似于 GitHub 的平台,但专门为数据科学家和机器学习从业者量身定制。通过集成 Git、DVC、MLFlow 和 Label Studio 等强大的开源工具,并为用户完成 DevOps 的繁重工作,您可以轻松地构建、管理和扩展您的机器学习项目——一切都在一个地方。deepchecks
—一个开源的 Python 库,用于测试机器学习/深度学习模型和数据。我们可以在整个项目中使用该库进行各种测试和验证需求;例如,我们可以验证数据的完整性,检查特征和目标的分布,确认数据分割是否有效,并评估模型的性能。DVC
—一个开源的机器学习项目版本控制系统。通过DVC(数据版本控制),我们可以将不同版本的数据(无论是表格数据、图片还是其他类型)和模型信息存储在 Git 中,同时将实际数据存储在其他地方(如 AWS、GCS、Google Drive 等云存储)。使用 DVC,我们还可以创建可复现的数据管道,同时存储数据集的中间版本。为了简化使用,DVC 采用与 Git 相同的语法。MLFlow
—一个开源平台,用于管理机器学习生命周期。它涵盖了实验、可复现性、部署和模型注册等方面。nannyML
—一个开源的 Python 库,用于部署后数据科学。我们可以使用它来识别数据漂移(训练模型时使用的数据与生产环境推断时特征分布的变化)或估计在没有真实标签的情况下模型的性能。后者对于那些真实标签在长时间后才可获得的项目尤其有趣,例如,预测贷款违约后几个月的情况。pycaret
—一个开源的低代码 Python 库,自动化机器学习工作流中的许多组件。例如,我们可以通过极少的代码训练和调优多个分类或回归任务的机器学习模型。它还包含用于异常检测或时间序列预测的独立模块。
第十五章:金融中的深度学习
近年来,我们看到深度学习技术在许多领域取得了惊人的成功。深度神经网络成功应用于传统机器学习算法无法成功的任务——大规模图像分类、自动驾驶、以及在围棋或经典视频游戏(从超级马里奥到星际争霸 II)中超越人类的表现。几乎每年,我们都能看到一种新型网络的推出,它在某些方面打破了性能记录并取得了最先进的(SOTA)成果。
随着图形处理单元(GPU)的持续改进,涉及 CPU/GPU 的免费计算资源(如 Google Colab、Kaggle 等)的出现,以及各种框架的快速发展,深度学习在研究人员和从业者中越来越受到关注,他们希望将这些技术应用到自己的业务案例中。
本章将展示深度学习在金融领域的两个可能应用场景——预测信用卡违约(分类任务)和时间序列预测。深度学习在处理语音、音频和视频等序列数据时表现出色。这也是它自然适用于处理时间序列数据(包括单变量和多变量)的原因。金融时间序列通常表现得非常不稳定且复杂,这也是建模它们的挑战所在。深度学习方法特别适合这一任务,因为它们不对底层数据的分布做任何假设,并且能够对噪声具有较强的鲁棒性。
在本书的第一版中,我们重点介绍了用于时间序列预测的传统神经网络架构(CNN、RNN、LSTM 和 GRU)及其在 PyTorch 中的实现。在本书中,我们将使用更复杂的架构,并借助专用的 Python 库来实现。得益于这些库,我们不必重新创建神经网络的逻辑,可以专注于预测挑战。
在本章中,我们介绍以下几种方法:
探索 fastai 的 Tabular Learner
探索 Google 的 TabNet
使用亚马逊 DeepAR 进行时间序列预测
使用 NeuralProphet 进行时间序列预测
探索 fastai 的 Tabular Learner
深度学习通常不与表格或结构化数据联系在一起,因为这类数据涉及一些可能的问题:
我们应该如何以神经网络能够理解的方式表示特征?在表格数据中,我们通常处理数值型和类别型特征,因此我们需要正确表示这两种类型的输入。
我们如何使用特征交互,包括特征之间以及与目标之间的交互?
我们如何有效地对数据进行采样?表格数据集通常比用于计算机视觉或 NLP 问题的典型数据集要小。没有简单的方法可以应用数据增强,例如图像的随机裁剪或旋转。此外,也没有通用的大型数据集具备一些普适的属性,我们可以基于这些属性轻松地应用迁移学习。
我们如何解释神经网络的预测结果?
这就是为什么实践者倾向于使用传统的机器学习方法(通常基于某种梯度提升树)来处理涉及结构化数据的任务。然而,使用深度学习处理结构化数据的一个潜在优势是,它需要的特征工程和领域知识要少得多。
在这个食谱中,我们展示了如何成功地使用深度学习处理表格数据。为此,我们使用了流行的fastai
库,该库建立在 PyTorch 之上。
使用fastai
库的一些优点包括:
它提供了一些 API,可以大大简化与人工神经网络(ANNs)的工作——从加载和批处理数据到训练模型。
它结合了经实验证明有效的最佳方法,用于深度学习处理各种任务,如图像分类、自然语言处理(NLP)和表格数据(包括分类和回归问题)。
它自动处理数据预处理——我们只需定义要应用的操作。
fastai
的一个亮点是使用实体嵌入(或嵌入层)处理分类数据。通过使用它,模型可以学习分类特征观测值之间潜在的有意义的关系。你可以把嵌入看作是潜在特征。对于每个分类列,都会有一个可训练的嵌入矩阵,每个唯一值都被映射到一个指定的向量。幸运的是,fastai
为我们完成了所有这些工作。
使用实体嵌入有很多优点。首先,它减少了内存使用,并且比使用独热编码加速了神经网络的训练。其次,它将相似的值映射到嵌入空间中彼此接近的位置,这揭示了分类变量的内在特性。第三,这种技术对于具有许多高基数特征的数据集尤其有用,而其他方法往往会导致过拟合。
在这个食谱中,我们将深度学习应用于一个基于信用卡违约数据集的分类问题。我们已经在第十三章,应用机器学习:识别信用卡违约中使用过这个数据集。
如何做…
执行以下步骤来训练一个神经网络,用于分类违约客户:
导入库:
from fastai.tabular.all import * from sklearn.model_selection import train_test_split from chapter_15_utils import performance_evaluation_report_fastai import pandas as pd
从 CSV 文件加载数据集:
df = pd.read_csv("../Datasets/credit_card_default.csv", na_values="")
定义目标、分类/数值特征列表和预处理步骤:
TARGET = "default_payment_next_month" cat_features = list(df.select_dtypes("object").columns) num_features = list(df.select_dtypes("number").columns) num_features.remove(TARGET) preprocessing = [FillMissing, Categorify, Normalize]
定义用于创建训练集和验证集的分割器:
splits = RandomSplitter(valid_pct=0.2, seed=42)(range_of(df)) splits
执行这个代码片段会生成以下数据集预览:
((#24000) [27362,16258,19716,9066,1258,23042,18939,24443,4328,4976...], (#6000) [7542,10109,19114,5209,9270,15555,12970,10207,13694,1745...])
创建
TabularPandas
数据集:tabular_df = TabularPandas( df, procs=preprocessing, cat_names=cat_features, cont_names=num_features, y_names=TARGET, y_block=CategoryBlock(), splits=splits ) PREVIEW_COLS = ["sex", "education", "marriage", "payment_status_sep", "age_na", "limit_bal", "age", "bill_statement_sep"] tabular_df.xs.iloc[:5][PREVIEW_COLS]
执行代码片段会生成以下数据集的预览:
图 15.1:编码数据集的预览
我们仅打印了少量列,以保持数据框的可读性。我们可以观察到以下内容:
类别列使用标签编码器进行编码
连续列已经被标准化
含有缺失值的连续列(如
age
)有一个额外的列,表示该特定值在插补前是否缺失。
从
TabularPandas
数据集中定义DataLoaders
对象:data_loader = tabular_df.dataloaders(bs=64, drop_last=True) data_loader.show_batch()
执行代码片段会生成以下批次的预览:
图 15.2:
DataLoaders
对象中一个批次的预览正如我们在图 15.2中看到的,这里的特征处于其原始表示形式。
定义所选择的指标和表格学习器:
recall = Recall() precision = Precision() learn = tabular_learner( data_loader, [500, 200], metrics=[accuracy, recall, precision] ) learn.model
执行代码片段会打印出模型的架构:
TabularModel( (embeds): ModuleList( (0): Embedding(3, 3) (1): Embedding(5, 4) (2): Embedding(4, 3) (3): Embedding(11, 6) (4): Embedding(11, 6) (5): Embedding(11, 6) (6): Embedding(11, 6) (7): Embedding(10, 6) (8): Embedding(10, 6) (9): Embedding(3, 3) ) (emb_drop): Dropout(p=0.0, inplace=False) (bn_cont): BatchNorm1d(14, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (layers): Sequential( (0): LinBnDrop( (0): Linear(in_features=63, out_features=500, bias=False) (1): ReLU(inplace=True) (2): BatchNorm1d(500, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) ) (1): LinBnDrop( (0): Linear(in_features=500, out_features=200, bias=False) (1): ReLU(inplace=True) (2): BatchNorm1d(200, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) ) (2): LinBnDrop( (0): Linear(in_features=200, out_features=2, bias=True) ) ) )
为了提供对嵌入的解释,
Embedding(11
,6)
表示创建了一个类别嵌入,输入值为 11 个,输出潜在特征为 6 个。查找建议的学习率:
learn.lr_find()
执行代码片段会生成以下图表:
图 15.3:我们模型的建议学习率
它还会打印出以下输出,显示建议学习率的精确值:
SuggestedLRs(valley=0.0010000000474974513)
训练表格学习器:
learn.fit(n_epoch=25, lr=1e-3, wd=0.2)
在模型训练过程中,我们可以观察到每个训练周期后性能的更新。以下是代码片段:
图 15.4:表格学习器训练的前 10 个周期
在前 10 个训练周期中,损失值仍然有些不稳定,随着时间的推移有时上升/下降。评估指标也是如此。
绘制损失值图表:
learn.recorder.plot_loss()
执行代码片段会生成以下图表:
图 15.5:训练和验证损失随训练时间(批次)的变化
我们可以观察到验证损失有所平稳,偶尔有些波动。这可能意味着模型对于我们的数据来说有些过于复杂,我们可能需要减少隐藏层的大小。
定义验证集的
DataLoaders
:valid_data_loader = learn.dls.test_dl(df.loc[list(splits[1])])
在验证集上评估性能:
learn.validate(dl=valid_data_loader)
执行代码片段会生成以下输出:
(#4)[0.424113571643829,0.8248333334922,0.36228482003129,0.66237482117310]
这些是验证集的指标:损失,准确率,召回率和精度。
获取验证集的预测结果:
preds, y_true = learn.get_preds(dl=valid_data_loader)
y_true
包含验证集中的实际标签。preds
对象是一个包含预测概率的张量。其内容如下:tensor([[0.8092, 0.1908], [0.9339, 0.0661], [0.8631, 0.1369], ..., [0.9249, 0.0751], [0.8556, 0.1444], [0.8670, 0.1330]])
为了获取预测的类别,我们可以使用以下命令:
preds.argmax(dim=-1)
检查性能评估指标:
perf = performance_evaluation_report_fastai( learn, valid_data_loader, show_plot=True )
执行代码片段会生成以下图表:
图 15.6:表格学习器在验证集上的性能评估
perf
对象是一个字典,包含各种评估指标。由于篇幅原因,我们在这里没有展示它,但我们也可以看到,准确率、精确度和召回率的值与我们在第 12 步中看到的相同。
它是如何工作的……
在第 2 步中,我们使用 read_csv
函数将数据集加载到 Python 中。在此过程中,我们指明了哪些符号表示缺失值。
在第 3 步中,我们识别了因变量(目标)以及数值特征和类别特征。为此,我们使用了 select_dtypes
方法并指定了要提取的数据类型。我们将特征存储在列表中。我们还需要将因变量从包含数值特征的列表中移除。最后,我们创建了一个包含所有要应用于数据的转换的列表。我们选择了以下内容:
FillMissing
:缺失值将根据数据类型进行填充。在类别变量的情况下,缺失值将成为一个独立的类别。在连续特征的情况下,缺失值将使用该特征值的中位数(默认方法)、众数或常量值进行填充。此外,还会添加一个额外的列,标记该值是否缺失。Categorify
:将类别特征映射为它们的整数表示。Normalize
:特征值被转换,使其均值为零,方差为单位。这使得训练神经网络变得更容易。
需要注意的是,相同的转换将同时应用于训练集和验证集。为了防止数据泄漏,转换仅基于训练集进行。
在第 4 步中,我们定义了用于创建训练集和验证集的分割。我们使用了 RandomSplitter
类,它在后台进行分层分割。我们指明了希望按照 80-20 的比例进行数据分割。此外,在实例化分割器之后,我们还需要使用 range_of
函数,该函数返回一个包含 DataFrame 所有索引的列表。
在第 5 步中,我们创建了一个 TabularPandas
数据集。它是对 pandas
DataFrame 的封装,添加了一些方便的实用工具——它处理所有的预处理和分割。在实例化 TabularPandas
类时,我们提供了原始 DataFrame、包含所有预处理步骤的列表、目标和类别/连续特征的名称,以及我们在第 4 步中定义的分割器对象。我们还指定了 y_block=CategoryBlock()
。当我们处理分类问题且目标已被编码为二进制表示(零和一的列)时,必须这样做。否则,它可能会与回归问题混淆。
我们可以轻松地将一个TabularPandas
对象转换为常规的pandas
DataFrame。我们可以使用xs
方法提取特征,使用ys
方法提取目标。此外,我们可以使用cats
和conts
方法分别提取类别特征和连续特征。如果我们直接在TabularPandas
对象上使用这四个方法中的任何一个,将会提取整个数据集。或者,我们可以使用train
和valid
访问器仅提取其中一个数据集。例如,要从名为tabular_df
的TabularPandas
对象中提取验证集特征,我们可以使用以下代码:
tabular_df.valid.xs
在步骤 6中,我们将TabularPandas
对象转换为DataLoaders
对象。为此,我们使用了TabularPandas
数据集的dataloaders
方法。此外,我们指定了批量大小为 64,并要求丢弃最后一个不完整的批量。我们使用show_batch
方法显示了一个示例批量。
我们本来也可以直接从 CSV 文件创建一个DataLoaders
对象,而不是转换一个pandas
DataFrame。为此,我们可以使用TabularDataLoaders.from_csv
功能。
在步骤 7中,我们使用tabular_learner
定义了学习器。首先,我们实例化了额外的度量标准:精确度和召回率。在使用fastai
时,度量标准以类的形式表示(类名为大写),我们需要先实例化它们,然后再将其传递给学习器。
然后,我们实例化了学习器。这是我们定义网络架构的地方。我们决定使用一个有两个隐藏层的网络,分别有 500 个和 200 个神经元。选择网络架构通常被认为是一门艺术而非科学,可能需要大量的试验和错误。另一种常见的方法是使用之前有人使用过且有效的架构,例如基于学术论文、Kaggle 竞赛、博客文章等。至于度量标准,我们希望考虑准确率,以及前面提到的精确度和召回率。
与机器学习一样,防止神经网络过拟合至关重要。我们希望网络能够推广到新的数据。解决过拟合的一些常见技术包括以下几种:
权重衰减(Weight decay):每次更新权重时,它们都会乘以一个小于 1 的因子(通常的经验法则是使用 0.01 到 0.1 之间的值)。
丢弃法(Dropout):在训练神经网络时,对于每个小批量,某些激活值会被随机丢弃。丢弃法也可以用于类别特征的嵌入向量的连接。
批量归一化(Batch normalization):该技术通过确保少数异常输入不会对训练后的网络产生过大的影响,从而减少过拟合。
然后,我们检查了模型的架构。在输出中,我们首先看到了分类嵌入层及其对应的 dropout,或者在本例中,缺少 dropout。接下来,在(layers)
部分,我们看到了输入层(63 个输入特征和 500 个输出特征),接着是ReLU(修正线性单元)激活函数和批量归一化。潜在的 dropout 在LinBnDrop
层中控制。对于第二个隐藏层,重复了相同的步骤,最后一个线性层产生了类别概率。
fastai
使用一个规则来确定嵌入层的大小。这个规则是通过经验选择的,它选择 600 和 1.6 乘以某个变量的基数的 0.56 次方中的较小值。要手动计算嵌入层的大小,可以使用get_emb_sz
函数。如果没有手动指定大小,tabular_learner
会在后台自动处理。
在第 8 步中,我们尝试确定“合适的”学习率。fastai
提供了一个辅助方法lr_find
,帮助简化这一过程。该方法开始训练网络并逐步提高学习率——从非常低的学习率开始,逐渐增加到非常高的学习率。然后,它会绘制学习率与损失值的关系图,并显示建议的学习率值。我们应该选择一个值,它位于最小值之前,但损失仍然在改善(减少)时的点。
在第 9 步中,我们使用学习器的fit
方法训练了神经网络。我们将简要描述训练算法。整个训练集被划分为批次。对于每个批次,网络用来进行预测,并将预测结果与目标值进行比较,从而计算误差。然后,误差被用来更新网络中的权重。一个 epoch指的是完整地遍历所有批次,换句话说,就是用整个数据集进行训练。在我们的案例中,我们训练了 25 个 epoch。此外,我们还指定了学习率和权重衰减。在第 10 步中,我们绘制了批次中的训练和验证损失。
不深入细节,默认情况下,fastai
使用(展平后的)交叉熵损失函数(用于分类任务)和**Adam(自适应矩估计)**作为优化器。报告的训练和验证损失来自于损失函数,评估指标(如召回率)在训练过程中并未使用。
在第 11 步中,我们定义了一个验证数据加载器。为了确定验证集的索引,我们从分割器中提取了它们。在下一步中,我们使用学习器对象的validate
方法评估神经网络在验证集上的表现。作为方法的输入,我们传递了验证数据加载器。
在第 13 步中,我们使用了get_preds
方法来获取验证集的预测结果。为了从preds
对象中获取预测,我们需要使用argmax
方法。
最后,我们使用了稍作修改的辅助函数(在前几章中使用过)来恢复评估指标,如精确度和召回率。
还有更多……
fastai
在表格数据集上的一些显著特点包括:
在训练神经网络时使用回调。回调用于在训练循环中的不同时间插入自定义代码/逻辑,例如,在每个 epoch 开始时或在拟合过程开始时。
fastai
提供了一个辅助函数add_datepart
,用于从包含日期的列(例如购买日期)中提取各种特征。提取的特征可能包括星期几、月份几号,以及一个表示月/季/年开始或结束的布尔值。我们可以使用拟合的表格学习器的
predict
方法,直接预测源数据框中单行的类别。我们可以使用
fit_one_cycle
方法,代替fit
方法。该方法采用超收敛策略,其基本思想是通过变化的学习率来训练网络。它从较低值开始,逐渐增加到指定的最大值,再回到较低值。这种方法被认为比选择单一学习率效果更好。由于我们处理的是一个相对较小的数据集和简单的模型,因此可以轻松地在 CPU 上训练神经网络。
fastai
自然支持使用 GPU。有关如何使用 GPU 的更多信息,请参见fastai
的文档。使用自定义索引进行训练和验证集的划分。当我们处理类别不平衡等问题时,这个功能特别有用,可以确保训练集和验证集包含相似的类别比例。我们可以将
IndexSplitter
与scikit-learn
的StratifiedKFold
结合使用。以下代码片段展示了实现示例:from sklearn.model_selection import StratifiedKFold X = df.copy() y = X.pop(TARGET) strat_split = StratifiedKFold( n_splits=5, shuffle=True, random_state=42 ) train_ind, test_ind = next(strat_split.split(X, y)) ind_splits = IndexSplitter(valid_idx=list(test_ind))(range_of(df)) tabular_df = TabularPandas( df, procs=preprocessing, cat_names=cat_features, cont_names=num_features, y_names=TARGET, y_block=CategoryBlock(), splits=ind_splits )
另见
关于fastai
的更多信息,我们推荐以下资源:
fastai
课程网站:course.fast.ai/
。Howard, J., & Gugger, S. 2020. 使用 fastai 和 PyTorch 进行深度学习编程。O'Reilly Media。
github.com/fastai/fastbook
。
额外的资源可以在这里找到:
Guo, C., & Berkhahn, F. 2016. 类别变量的实体嵌入。arXiv 预印本 arXiv:1604.06737。
Ioffe, S., & Szegedy, C. 2015. 批量归一化:通过减少内部协变量偏移加速深度网络训练。arXiv 预印本 arXiv:1502.03167。
Krogh, A., & Hertz, J. A. 1991. “简单的权重衰减可以改善泛化能力。” 见于神经信息处理系统的进展:9950-957。
Ryan, M. 2020. 结构化数据的深度学习。Simon 和 Schuster。
Shwartz-Ziv, R., & Armon, A. 2022. “表格数据:深度学习并不是你所需要的一切”,信息融合,81:84-90。
Smith, L. N. 2018. 一种有纪律的方法来调整神经网络的超参数:第一部分 - 学习率、批量大小、动量和权重衰减。arXiv 预印本 arXiv:1803.09820。
Smith, L. N., & Topin, N. 2019 年 5 月。超收敛:使用大学习率快速训练神经网络。在人工智能与机器学习在多领域操作应用中的应用(1100612)中。国际光学与光子学学会。
Srivastava, N., Hinton, G., Krizhevsky, A., Sutskever, I., & Salakhutdinov, R. 2014. “Dropout: 防止神经网络过拟合的一种简单方法”,机器学习研究期刊,15(1):1929-1958。
探索谷歌的 TabNet
另一种使用神经网络建模表格数据的方法是谷歌的TabNet。由于 TabNet 是一个复杂的模型,我们不会深入描述它的架构。关于这一点,请参阅原始论文(见另见部分)。相反,我们提供一个 TabNet 主要特性的高层次概述:
TabNet 使用原始的表格数据,不需要任何预处理。
TabNet 中使用的优化过程基于梯度下降。
TabNet 结合了神经网络拟合复杂函数的能力和基于树的算法的特征选择特性。通过使用顺序注意力在每个决策步骤中选择特征,TabNet 能够专注于仅从最有用的特征中学习。
TabNet 的架构包含两个关键的构建模块:特征变换器和注意力变换器。前者将特征处理为更有用的表示。后者在下一步中选择最相关的特征进行处理。
TabNet 还具有另一个有趣的组成部分——输入特征的可学习掩码。该掩码应该是稀疏的,也就是说,它应选择一小部分特征来解决预测任务。与决策树(以及其他基于树的模型)不同,由掩码启用的特征选择允许软决策。实际上,这意味着决策可以在更大的值范围内做出,而不是基于单一的阈值。
TabNet 的特征选择是按实例进行的,即可以为训练数据中的每个观测(行)选择不同的特征。
TabNet 也非常独特,因为它使用单一的深度学习架构来同时进行特征选择和推理。
与绝大多数深度学习模型不同,TabNet 是可解释的(在某种程度上)。所有的设计选择使 TabNet 能够提供局部和全局的可解释性。局部可解释性让我们能够可视化特征的重要性,并了解它们是如何为单行数据组合的。全局可解释性则提供了每个特征对训练模型的贡献的汇总度量(基于整个数据集)。
在这个例子中,我们展示了如何将 TabNet(其 PyTorch 实现)应用于我们在前一个例子中讨论的相同信用卡违约数据集。
如何做到……
执行以下步骤以使用信用卡欺诈数据集训练一个 TabNet 分类器:
导入库:
from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder from sklearn.metrics import recall_score from pytorch_tabnet.tab_model import TabNetClassifier from pytorch_tabnet.metrics import Metric import torch import pandas as pd import numpy as np
从 CSV 文件加载数据集:
df = pd.read_csv("../Datasets/credit_card_default.csv", na_values="")
将目标从特征中分离,并创建包含数值/类别特征的列表:
X = df.copy() y = X.pop("default_payment_next_month") cat_features = list(X.select_dtypes("object").columns) num_features = list(X.select_dtypes("number").columns)
填充类别特征的缺失值,使用
LabelEncoder
进行编码,并存储每个特征的唯一类别数量:cat_dims = {} for col in cat_features: label_encoder = LabelEncoder() X[col] = X[col].fillna("Missing") X[col] = label_encoder.fit_transform(X[col].values) cat_dims[col] = len(label_encoder.classes_) cat_dims
执行代码片段生成以下输出:
{'sex': 3, 'education': 5, 'marriage': 4, 'payment_status_sep': 10, 'payment_status_aug': 10, 'payment_status_jul': 10, 'payment_status_jun': 10, 'payment_status_may': 9, 'payment_status_apr': 9}
基于 EDA(探索性数据分析),我们可能会认为
sex
特征只有两个唯一值。然而,由于我们使用Missing
类别填充了缺失值,因此有三个唯一可能的值。使用 70-15-15 的比例创建训练/验证/测试集划分:
# create the initial split - training and temp X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.3, stratify=y, random_state=42 ) # create the valid and test sets X_valid, X_test, y_valid, y_test = train_test_split( X_temp, y_temp, test_size=0.5, stratify=y_temp, random_state=42 )
填充所有数据集中的数值特征的缺失值:
for col in num_features: imp_mean = X_train[col].mean() X_train[col] = X_train[col].fillna(imp_mean) X_valid[col] = X_valid[col].fillna(imp_mean) X_test[col] = X_test[col].fillna(imp_mean)
准备包含类别特征索引和唯一类别数量的列表:
features = X.columns.to_list() cat_ind = [features.index(feat) for feat in cat_features] cat_dims = list(cat_dims.values())
定义自定义召回率指标:
class Recall(Metric): def __init__(self): self._name = "recall" self._maximize = True def __call__(self, y_true, y_score): y_pred = np.argmax(y_score, axis=1) return recall_score(y_true, y_pred)
定义 TabNet 的参数并实例化分类器:
tabnet_params = { "cat_idxs": cat_ind, "cat_dims": cat_dims, "optimizer_fn": torch.optim.Adam, "optimizer_params": dict(lr=2e-2), "scheduler_params": { "step_size":20, "gamma":0.9 }, "scheduler_fn": torch.optim.lr_scheduler.StepLR, "mask_type": "sparsemax", "seed": 42, } tabnet = TabNetClassifier(**tabnet_params)
训练 TabNet 分类器:
tabnet.fit( X_train=X_train.values, y_train=y_train.values, eval_set=[ (X_train.values, y_train.values), (X_valid.values, y_valid.values) ], eval_name=["train", "valid"], eval_metric=["auc", Recall], max_epochs=200, patience=20, batch_size=1024, virtual_batch_size=128, weights=1, )
下面我们可以看到训练过程中的简略日志:
epoch 0 | loss: 0.69867 | train_auc: 0.61461 | train_recall: 0.3789 | valid_auc: 0.62232 | valid_recall: 0.37286 | 0:00:01s epoch 1 | loss: 0.62342 | train_auc: 0.70538 | train_recall: 0.51539 | valid_auc: 0.69053 | valid_recall: 0.48744 | 0:00:02s epoch 2 | loss: 0.59902 | train_auc: 0.71777 | train_recall: 0.51625 | valid_auc: 0.71667 | valid_recall: 0.48643 | 0:00:03s epoch 3 | loss: 0.59629 | train_auc: 0.73428 | train_recall: 0.5268 | valid_auc: 0.72767 | valid_recall: 0.49447 | 0:00:04s … epoch 42 | loss: 0.56028 | train_auc: 0.78509 | train_recall: 0.6028 | valid_auc: 0.76955 | valid_recall: 0.58191 | 0:00:47s epoch 43 | loss: 0.56235 | train_auc: 0.7891 | train_recall: 0.55651 | valid_auc: 0.77126 | valid_recall: 0.5407 | 0:00:48s Early stopping occurred at epoch 43 with best_epoch = 23 and best_valid_recall = 0.6191 Best weights from best epoch are automatically used!
准备历史记录 DataFrame 并绘制每个 epoch 的分数:
history_df = pd.DataFrame(tabnet.history.history)
然后,我们开始绘制每个 epoch 的损失值:
history_df["loss"].plot(title="Loss over epochs")
执行代码片段生成以下图表:
图 15.7:训练损失随 epoch 变化
然后,以类似的方式,我们生成了一个展示每个 epoch 召回率的图。为了简洁起见,我们没有包括生成图表的代码。
图 15.8:训练和验证召回率随 epoch 变化
为测试集创建预测并评估其性能:
y_pred = tabnet.predict(X_test.values) print(f"Best validation score: {tabnet.best_cost:.4f}") print(f"Test set score: {recall_score(y_test, y_pred):.4f}")
执行代码片段生成以下输出:
Best validation score: 0.6191 Test set score: 0.6275
如我们所见,测试集的表现略优于使用验证集计算的召回率。
提取并绘制全局特征重要性:
tabnet_feat_imp = pd.Series(tabnet.feature_importances_, index=X_train.columns) ( tabnet_feat_imp .nlargest(20) .sort_values() .plot(kind="barh", title="TabNet's feature importances") )
执行代码片段生成以下图表:
图 15.9:从拟合的 TabNet 分类器中提取的全局特征重要性值
根据 TabNet,预测 10 月违约的最重要特征是 9 月、7 月和 5 月的支付状态。另一个重要特征是信用额度余额。
在此时有两点值得注意。首先,最重要的特征与我们在第十四章 高级机器学习项目概念中的调查特征重要性方法中识别的特征相似。其次,特征重要性是按特征层面计算的,而非按特征和类别层面计算的,正如我们在使用类别特征的独热编码时所看到的那样。
它是如何工作的…
导入库后,我们从 CSV 文件加载数据集。然后,我们将目标从特征中分离,并提取类别特征和数值特征的名称。我们将其存储为列表。
在步骤 4中,我们对分类特征进行了几项操作。首先,我们用一个新的类别——Missing
来填补任何缺失的值。然后,我们使用scikit-learn
的LabelEncoder
对每一列分类特征进行编码。在此过程中,我们创建了一个字典,记录了每个分类特征的唯一类别数量(包括为缺失值新创建的类别)。
在步骤 5中,我们使用train_test_split
函数创建了训练/验证/测试数据集划分。我们决定采用 70-15-15 的比例进行划分。由于数据集不平衡(少数类大约在 22%的观测中可见),因此我们在划分数据时使用了分层抽样。
在步骤 6中,我们为数值特征填补了缺失值。我们使用训练集计算的平均值来填充缺失值。
在步骤 7中,我们准备了两个列表。第一个列表包含了分类特征的数值索引,而第二个列表则包含了每个分类特征的唯一类别数量。确保这两个列表对齐至关重要,以便特征的索引与该特征的唯一类别数量一一对应。
在步骤 8中,我们创建了一个自定义的召回率度量。pytorch-tabnet
提供了一些度量(对于分类问题,包括准确率、ROC AUC 和均衡准确率),但我们也可以轻松定义更多度量。为了创建自定义度量,我们进行了以下操作:
我们定义了一个继承自
Metric
类的类。在
__init__
方法中,我们定义了度量的名称(如训练日志中所示),并指明了目标是否是最大化该度量。对于召回率而言,目标就是最大化该度量。在
__call__
方法中,我们使用scikit-learn
的recall_score
函数计算召回率的值。但首先,我们需要将包含每个类别预测概率的数组转换为一个包含预测类别的对象。我们通过使用np.argmax
函数来实现这一点。
在步骤 9中,我们定义了一些 TabNet 的超参数并实例化了该模型。pytorch-tabnet
提供了一个类似于scikit-learn
的 API,用于训练 TabNet 模型,无论是分类任务还是回归任务。因此,我们不需要精通 PyTorch 就能训练模型。首先,我们定义了一个字典,包含了模型的超参数。
通常,一些超参数是在模型级别定义的(在实例化类时传递给类),而其他超参数则是在拟合级别定义的(在使用fit
方法时传递给模型)。此时,我们定义了模型的超参数:
分类特征的索引及其对应的唯一类别数量
选择的优化器:ADAM
学习率调度器
掩码类型
随机种子
在所有这些参数中,学习率调度器可能需要一些澄清。根据 TabNet 的文档,我们使用了逐步衰减的学习率。为此,我们指定了torch.optim.lr_scheduler.StepLR
作为调度器函数。然后,我们提供了一些其他参数。最初,我们在optimizer_params
中将学习率设置为0.02
。接着,我们在scheduler_params
中定义了逐步衰减的参数。我们指定每经过 20 个 epoch,就应用一个衰减率0.9
。在实践中,这意味着每经过 20 个 epoch,学习率将变为 0.9 乘以 0.02,等于 0.018。衰减过程在每 20 个 epoch 后继续进行。
完成这些步骤后,我们通过指定的超参数实例化了TabNetClassifier
类。默认情况下,TabNet 使用交叉熵损失函数进行分类问题,使用均方误差(MSE)进行回归任务。
在第 10 步中,我们使用fit
方法训练了TabNetClassifier
。我们提供了相当多的参数:
训练数据
评估集——在此案例中,我们使用了训练集和验证集,这样每个 epoch 后,我们可以看到两个数据集的计算指标。
评估集的名称
用于评估的指标——我们使用了 ROC AUC 和在第 8 步中定义的自定义召回指标。
最大 epoch 数
patience参数,表示如果我们在X个连续的 epoch 中没有看到评估指标的改善,训练将停止,并且我们将使用最佳 epoch 的权重进行预测。
批量大小和虚拟批量大小(用于幽灵批量归一化;更多细节请参见*更多内容...*部分)
weights
参数,仅适用于分类问题。它与采样有关,在处理类别不平衡时非常有帮助。将其设置为0
表示没有采样。将其设置为1
则启用基于类别发生频率的反向比例进行加权采样。最后,我们可以提供一个包含自定义类别权重的字典。
需要注意的是,TabNet 的训练中,我们提供的数据集必须是numpy
数组,而不是pandas
DataFrame。因此,我们使用values
方法从 DataFrame 中提取数组。使用numpy
数组的需求也是我们需要定义类别特征的数值索引,而不能提供特征名称列表的原因。
与许多神经网络架构相比,TabNet 使用了相对较大的批量大小。原始论文建议,我们可以使用总训练观测数的 10%作为批量大小。还建议虚拟批量大小应小于批量大小,且后者可以整除前者。
在步骤 11中,我们从拟合的 TabNet 模型的history
属性中提取了训练信息。它包含了训练日志中显示的相同信息,即每个 epoch 的损失、学习率和评估指标。然后,我们绘制了每个 epoch 的损失和召回率图。
在步骤 12中,我们使用 predict
方法创建了预测。与训练步骤类似,我们也需要将输入特征提供为 numpy
数组。和 scikit-learn
中一样,predict
方法返回预测的类别,而我们可以使用 predict_proba
方法获取类别概率。我们还使用 scikit-learn
的 recall_score
函数计算了测试集上的召回率。
在最后一步,我们提取了全局特征重要性值。与 scikit-learn
模型类似,它们存储在拟合模型的 feature_importances_
属性下。然后,我们绘制了最重要的 20 个特征。值得注意的是,全局特征重要性值是标准化的,它们的总和为 1。
还有更多……
这里有一些关于 TabNet 和其在 PyTorch 中实现的有趣点:
TabNet 使用幽灵批量归一化来训练大批量数据,并同时提供更好的泛化能力。该过程的基本思想是将输入批次分割成大小相等的子批次(由虚拟批次大小参数确定)。然后,我们对这些子批次应用相同的批量归一化层。
pytorch-tabnet
允许我们在训练过程中应用自定义的数据增强管道。目前,库提供了对分类和回归任务使用 SMOTE 方法的功能。TabNet 可以作为无监督模型进行预训练,从而提高模型的表现。在预训练过程中,某些单元会被故意遮蔽,模型通过预测这些缺失(遮蔽)值,学习这些被遮蔽单元与相邻列之间的关系。然后,我们可以将这些权重用于有监督任务。通过学习特征之间的关系,无监督表示学习作为有监督学习任务的改进编码器模型。在预训练时,我们可以决定遮蔽多少比例的特征。
TabNet 使用sparsemax作为遮蔽函数。一般来说,sparsemax 是一种非线性归一化函数,其分布比流行的 softmax 函数更稀疏。这个函数使神经网络能够更有效地选择重要的特征。此外,该函数采用稀疏性正则化(其强度由超参数决定)来惩罚不够稀疏的遮蔽。
pytorch-tabnet
库还包含了EntMax
遮蔽函数。在本教程中,我们介绍了如何提取全局特征重要性。要提取局部重要性,我们可以使用拟合后的 TabNet 模型的
explain
方法。该方法返回两个元素:一个矩阵,包含每个观察值和特征的重要性,以及模型在特征选择中使用的注意力掩码。
另见
Arik, S. Ö., & Pfister, T. 2021 年 5 月。Tabnet:可解释的注意力表格学习。在AAAI 人工智能会议论文集,35(8):6679-6687。
描述上述论文中 TabNet 实现的原始代码库:
github.com/google-research/google-research/tree/master/tabnet
。
亚马逊 DeepAR 的时间序列预测
我们已经在第六章《时间序列分析与预测》和第七章《基于机器学习的时间序列预测方法》中讲解了时间序列分析和预测。这次,我们将看一个深度学习方法在时间序列预测中的应用示例。在本教程中,我们将介绍亚马逊的 DeepAR 模型。该模型最初是作为一个需求/销售预测工具开发的,旨在处理成百上千个库存单位(SKU)的规模。
DeepAR 的架构超出了本书的范围。因此,我们将只关注模型的一些关键特性。具体如下:
DeepAR 创建一个用于所有考虑的时间序列的全局模型。它在架构中实现了 LSTM 单元,这种架构允许同时使用成百上千个时间序列进行训练。该模型还使用了编码器-解码器结构,这是序列到序列模型中常见的做法。
DeepAR 允许使用与目标时间序列相关的一组协变量(外部回归量)。
该模型对特征工程的要求最小。它自动创建相关的时间序列特征(根据数据的粒度,这些特征可能包括月份中的天、年份中的天等),并且它从提供的协变量中学习时间序列的季节性模式。
DeepAR 提供基于蒙特卡罗采样的概率预测——它计算一致的分位数估计。
该模型能够通过学习相似的时间序列来为具有少量历史数据的时间序列生成预测。这是解决冷启动问题的一种潜在方案。
该模型可以使用各种似然函数。
在本教程中,我们将使用 2020 年和 2021 年的大约 100 个时间序列的日常股票价格来训练一个 DeepAR 模型。然后,我们将创建涵盖 2021 年最后 20 个工作日的 20 天前的预测。
在继续之前,我们想强调的是,使用股票价格的时间序列只是为了说明目的。深度学习模型在经过数百甚至数千个时间序列的训练后表现最佳。我们选择了股票价格作为示例,因为这些数据最容易下载。正如我们之前提到的,准确预测股票价格,尤其是在长时间预测的情况下,极其困难,甚至几乎不可能。
如何做…
执行以下步骤,使用股票价格作为输入时间序列来训练 DeepAR 模型:
导入库:
import pandas as pd import torch import yfinance as yf from random import sample, seed import pytorch_lightning as pl from pytorch_lightning.callbacks import EarlyStopping from pytorch_forecasting import DeepAR, TimeSeriesDataSet
下载标准普尔 500 指数成分股的股票代码,并从列表中随机抽取 100 个股票代码:
df = pd.read_html( "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies" ) df = df[0] seed(44) sampled_tickers = sample(df["Symbol"].to_list(), 100)
下载所选股票的历史股价:
raw_df = yf.download(sampled_tickers, start="2020-01-01", end="2021-12-31")
保留调整后的收盘价,并剔除缺失值的股票:
df = raw_df["Adj Close"] df = df.loc[:, ~df.isna().any()] selected_tickers = df.columns
在剔除在关注期内至少有一个缺失值的股票后,剩下了 98 只股票。
将数据格式从宽格式转换为长格式,并添加时间索引:
df = df.reset_index(drop=False) df = ( pd.melt(df, id_vars=["Date"], value_vars=selected_tickers, value_name="price" ).rename(columns={"variable": "ticker"}) ) df["time_idx"] = df.groupby("ticker").cumcount() df
执行代码片段后,会生成以下数据框架的预览:
图 15.10:DeepAR 模型输入数据框架的预览
定义用于设置模型训练的常量:
MAX_ENCODER_LENGTH = 40 MAX_PRED_LENGTH = 20 BATCH_SIZE = 128 MAX_EPOCHS = 30 training_cutoff = df["time_idx"].max() - MAX_PRED_LENGTH
定义训练集和验证集:
train_set = TimeSeriesDataSet( df[lambda x: x["time_idx"] <= training_cutoff], time_idx="time_idx", target="price", group_ids=["ticker"], time_varying_unknown_reals=["price"], max_encoder_length=MAX_ENCODER_LENGTH, max_prediction_length=MAX_PRED_LENGTH, ) valid_set = TimeSeriesDataSet.from_dataset( train_set, df, min_prediction_idx=training_cutoff+1 )
从数据集获取数据加载器:
train_dataloader = train_set.to_dataloader( train=True, batch_size=BATCH_SIZE ) valid_dataloader = valid_set.to_dataloader( train=False, batch_size=BATCH_SIZE )
定义 DeepAR 模型并找到建议的学习率:
pl.seed_everything(42) deep_ar = DeepAR.from_dataset( train_set, learning_rate=1e-2, hidden_size=30, rnn_layers=4 ) trainer = pl.Trainer(gradient_clip_val=1e-1) res = trainer.tuner.lr_find( deep_ar, train_dataloaders=train_dataloader, val_dataloaders=valid_dataloader, min_lr=1e-5, max_lr=1e0, early_stop_threshold=100, ) fig = res.plot(show=True, suggest=True)
执行代码片段后,会生成以下图表,其中红点表示建议的学习率。
图 15.11:训练 DeepAR 模型时建议的学习率
训练 DeepAR 模型:
pl.seed_everything(42) deep_ar.hparams.learning_rate = res.suggestion() early_stop_callback = EarlyStopping( monitor="val_loss", min_delta=1e-4, patience=10 ) trainer = pl.Trainer( max_epochs=MAX_EPOCHS, gradient_clip_val=0.1, callbacks=[early_stop_callback] ) trainer.fit( deep_ar, train_dataloaders=train_dataloader, val_dataloaders=valid_dataloader, )
从检查点中提取最佳的 DeepAR 模型:
best_model = DeepAR.load_from_checkpoint( trainer.checkpoint_callback.best_model_path )
创建验证集的预测并绘制其中的 5 个:
raw_predictions, x = best_model.predict( valid_dataloader, mode="raw", return_x=True, n_samples=100 ) tickers = valid_set.x_to_index(x)["ticker"] for idx in range(5): best_model.plot_prediction( x, raw_predictions, idx=idx, add_loss_to_title=True ) plt.suptitle(f"Ticker: {tickers.iloc[idx]}")
在代码片段中,我们生成了 100 个预测并绘制了其中 5 个以进行视觉检查。为了简洁起见,我们只展示了其中两个。但我们强烈建议检查更多图表,以更好地理解模型的表现。
图 15.12:DeepAR 对 ABMD 股票的预测
图 15.13:DeepAR 对 ADM 股票的预测
这些图表展示了 2021 年最后 20 个工作日两只股票的预测值,以及对应的分位数估计值。虽然预测结果表现一般,但我们可以看到,至少实际值位于提供的分位数估计范围内。
我们不会花更多时间评估模型及其预测的表现,因为主要目的是展示 DeepAR 模型是如何工作的,以及如何使用它生成预测。然而,我们会提到一些潜在的改进。首先,我们本可以训练更多的周期,因为我们没有检查模型的收敛性。我们使用了早停法,但在训练过程中并没有触发。其次,我们使用了相当多的任意值来定义网络的架构。在实际应用中,我们应该使用自己选择的超参数优化方法来识别适合我们任务的最佳值。
它是如何工作的……
在步骤 1中,我们导入了所需的库。为了使用 DeepAR 模型,我们决定使用 PyTorch Forecasting 库。它是建立在 PyTorch Lightning 之上的库,允许我们轻松使用最先进的深度学习模型进行时间序列预测。这些模型可以使用 GPU 进行训练,我们还可以参考 TensorBoard 查看训练日志。
在步骤 2中,我们下载了包含标准普尔 500 指数成分股的列表。然后,我们随机抽取了其中 100 只并将结果存储在列表中。我们随机抽取了股票代码,以加速训练。重复这个过程,使用所有的股票,肯定会对模型产生有益的影响。
在步骤 3中,我们使用yfinance
库下载了 2020 年和 2021 年的历史股票价格。在下一步中,我们需要进行进一步的预处理。我们只保留了调整后的收盘价,并移除了任何有缺失值的股票。
在步骤 5中,我们继续进行预处理。我们将数据框从宽格式转换为长格式,然后添加时间索引。DeepAR 的实现使用整数时间索引而不是日期,因此我们使用了cumcount
方法结合groupby
方法为每只考虑的股票创建了时间索引。
在步骤 6中,我们定义了用于训练过程的一些常数,例如编码器步骤的最大长度、我们希望预测的未来观测值数量、最大训练周期数等。我们还指定了哪个时间索引将训练与验证数据分开。
在步骤 7中,我们定义了训练集和验证集。我们使用了TimeSeriesDataSet
类来完成这一操作,该类的职责包括:
变量转换的处理
缺失值的处理
存储有关静态和时间变化变量的信息(包括已知和未来未知的)
随机子抽样
在定义训练数据集时,我们需要提供训练数据(使用先前定义的截止点进行过滤)、包含时间索引的列名称、目标、组 ID(在我们的案例中,这些是股票代码)、编码器长度和预测范围。
从 TimeSeriesDataSet
生成的每个样本都是完整时间序列的一个子序列。每个子序列包含给定时间序列的编码器和预测时间点。TimeSeriesDataSet
创建了一个索引,定义了哪些子序列存在并可以从中进行采样。
在步骤 8中,我们使用 TimeSeriesDataSet
的 to_dataloader
方法将数据集转换为数据加载器。
在步骤 9中,我们使用 DeepAR
类的 from_dataset
方法定义了 DeepAR 模型。这样,我们就不必重复在创建 TimeSeriesDataSet
对象时已经指定的内容。此外,我们还指定了学习率、隐藏层的大小以及 RNN 层数。后两者是 DeepAR 模型中最重要的超参数,应该使用一些超参数优化(HPO)框架进行调优,例如 Hyperopt 或 Optuna。然后,我们使用 PyTorch Lightning 的 Trainer
类来寻找最优的学习率。
默认情况下,DeepAR 模型使用高斯损失函数。根据任务的不同,我们可以使用一些其他替代方法。对于处理实数数据时,高斯分布是首选。对于正整数计数数据,我们可能需要使用负二项式似然。对于位于单位区间的数据,Beta 似然是一个不错的选择,而对于二值数据,Bernoulli 似然则是理想的选择。
在步骤 10中,我们使用确定的学习率训练了 DeepAR 模型。此外,我们还指定了早停回调函数,如果在 10 个 epoch 内验证损失没有显著(由我们定义)改善,训练将停止。
在步骤 11中,我们从检查点提取了最佳模型。然后,我们使用最佳模型通过 predict
方法生成预测。我们为验证数据加载器中可用的 100 个序列创建了预测。我们指出希望提取原始预测(此选项返回一个包含预测及附加信息(如相应的分位数等)的字典)以及生成这些预测所用的输入。然后,我们使用拟合的 DeepAR
模型的 plot_prediction
方法绘制了预测图。
还有更多内容……
PyTorch Forecasting 还允许我们轻松训练 DeepVAR 模型,它是 DeepAR 的多变量对应模型。最初,Salinas et al.(2019)将该模型称为 VEC-LSTM。
DeepAR 和 DeepVAR 都可以在亚马逊的 GluonTS 库中使用。
在本节中,我们展示了如何调整用于训练 DeepAR 模型的代码,改为训练 DeepVAR 模型:
导入库:
from pytorch_forecasting.metrics import MultivariateNormalDistributionLoss import seaborn as sns import numpy as np
再次定义数据加载器:
train_set = TimeSeriesDataSet( df[lambda x: x["time_idx"] <= training_cutoff], time_idx="time_idx", target="price", group_ids=["ticker"], static_categoricals=["ticker"], time_varying_unknown_reals=["price"], max_encoder_length=MAX_ENCODER_LENGTH, max_prediction_length=MAX_PRED_LENGTH, ) valid_set = TimeSeriesDataSet.from_dataset( train_set, df, min_prediction_idx=training_cutoff+1 ) train_dataloader = train_set.to_dataloader( train=True, batch_size=BATCH_SIZE, batch_sampler="synchronized" ) valid_dataloader = valid_set.to_dataloader( train=False, batch_size=BATCH_SIZE, batch_sampler="synchronized" )
在这一步有两个不同之处。首先,在创建训练数据集时,我们还指定了
static_categoricals
参数。因为我们要预测相关性,使用诸如股票代码等序列特征非常重要。第二,在创建数据加载器时,我们还必须指定batch_sampler="synchronized"
。使用这个选项可以确保传递给解码器的样本在时间上是对齐的。定义 DeepVAR 模型并找到学习率:
pl.seed_everything(42) deep_var = DeepAR.from_dataset( train_set, learning_rate=1e-2, hidden_size=30, rnn_layers=4, loss=MultivariateNormalDistributionLoss() ) trainer = pl.Trainer(gradient_clip_val=1e-1) res = trainer.tuner.lr_find( deep_var, train_dataloaders=train_dataloader, val_dataloaders=valid_dataloader, min_lr=1e-5, max_lr=1e0, early_stop_threshold=100, )
训练 DeepVAR 和 DeepAR 模型的最后一个区别是,对于前者,我们使用
MultivariateNormalDistributionLoss
作为损失,而不是默认的NormalDistributionLoss
。使用选择的学习率训练 DeepVAR 模型:
pl.seed_everything(42) deep_var.hparams.learning_rate = res.suggestion() early_stop_callback = EarlyStopping( monitor="val_loss", min_delta=1e-4, patience=10 ) trainer = pl.Trainer( max_epochs=MAX_EPOCHS, gradient_clip_val=0.1, callbacks=[early_stop_callback] ) trainer.fit( deep_var, train_dataloaders=train_dataloader, val_dataloaders=valid_dataloader, )
从检查点提取最佳 DeepVAR 模型:
best_model = DeepAR.load_from_checkpoint( trainer.checkpoint_callback.best_model_path )
提取相关性矩阵:
preds = best_model.predict(valid_dataloader, mode=("raw", "prediction"), n_samples=None) cov_matrix = ( best_model .loss .map_x_to_distribution(preds) .base_dist .covariance_matrix .mean(0) ) cov_diag_mult = ( torch.diag(cov_matrix)[None] * torch.diag(cov_matrix)[None].T ) corr_matrix = cov_matrix / torch.sqrt(cov_diag_mult)
绘制相关性矩阵及其分布:
mask = np.triu(np.ones_like(corr_matrix, dtype=bool)) fif, ax = plt.subplots() cmap = sns.diverging_palette(230, 20, as_cmap=True) sns.heatmap( corr_matrix, mask=mask, cmap=cmap, vmax=.3, center=0, square=True, linewidths=.5, cbar_kws={"shrink": .5} ) ax.set_title("Correlation matrix")
执行该代码片段生成以下图形:
图 15.14:从 DeepVAR 提取的相关性矩阵
为了更好地理解相关性的分布,我们绘制了其直方图:
plt.hist(corr_matrix[corr_matrix < 1].numpy())
执行该代码片段生成以下图形:
图 15.15:直方图展示了提取的相关性分布
在查看直方图时,请记住我们是基于相关性矩阵创建了直方图。这意味着我们实际上对每个值进行了两次计数。
另见
Salinas, D., Flunkert, V., Gasthaus, J., & Januschowski, T. 2020. “DeepAR: 基于自回归递归网络的概率预测”,国际预测学杂志,36(3):1181-1191。
Salinas, D., Bohlke-Schneider, M., Callot, L., Medico, R., & Gasthaus, J. 2019. 高维多元预测与低秩高斯哥普拉过程。神经信息处理系统进展,32。
使用 NeuralProphet 进行时间序列预测
在第七章,基于机器学习的时间序列预测方法中,我们介绍了 Meta(前身为 Facebook)创建的 Prophet 算法。在本食谱中,我们将探讨该算法的扩展——NeuralProphet。
简单回顾一下,Prophet 的作者强调了模型的良好性能、可解释性和易用性作为其主要优势。NeuralProphet 的作者也考虑到了这一点,并在其方法中保留了 Prophet 的所有优势,同时加入了新组件,提升了准确性和可扩展性。
原始 Prophet 算法的批评包括其僵化的参数结构(基于广义线性模型)以及它作为一种“曲线拟合器”不足以适应局部模式。
传统上,时间序列模型使用时间序列的滞后值来预测未来的值。Prophet 的创造者将时间序列预测重新定义为曲线拟合问题,算法试图找到趋势的函数形式。
在接下来的内容中,我们简要提到 NeuralProphet 的一些重要新增功能:
NeuralProphet 在 Prophet 规范中引入了自回归项。
自回归通过**自回归网络(AR-Net)**实现。AR-Net 是一个神经网络,用于模拟时间序列信号中的自回归过程。虽然传统 AR 模型和 AR-Net 的输入相同,但后者能够在比前者更大规模下操作。
NeuralProphet 使用 PyTorch 作为后端,而 Prophet 算法使用 Stan。这使得训练速度更快,并带来其他一些好处。
滞后回归变量(特征)通过前馈神经网络建模。
该算法可以与自定义的损失函数和评估指标一起使用。
该库广泛使用正则化,并且我们可以将其应用于模型的各个组件:趋势、季节性、假期、自回归项等。尤其对于 AR 项,使用正则化可以让我们在不担心训练时间迅速增加的情况下使用更多的滞后值。
实际上,NeuralProphet 支持几种 AR 项的配置:
线性 AR——一个没有偏置项和激活函数的单层神经网络。本质上,它将特定的滞后值回归到特定的预测步长。由于其简洁性,它的解释相对简单。
深度 AR——在这种形式下,AR 项通过具有指定数量隐藏层和 ReLU 激活函数的全连接神经网络建模。尽管它增加了复杂性、延长了训练时间并且失去了可解释性,但这种配置通常比线性模型提供更高的预测精度。
稀疏 AR——我们可以结合高阶 AR(有更多先前时间步的值)和正则化项。
所提到的每种配置可以应用于目标变量和协变量。
总结一下,NeuralProphet 由以下几个组件构成:
趋势
季节性
假期和特殊事件
自回归
滞后回归——滞后值的协变量通过前馈神经网络内部建模
未来回归——类似于事件/假期,这是我们已知的未来回归值(无论是给定值还是我们对这些值有单独的预测)
在这个示例中,我们将 NeuralProphet 的几种配置拟合到 2010 至 2021 年的每日 S&P 500 股价时间序列中。与之前的示例类似,我们选择资产价格的时间序列是因为数据的可获取性以及其每日频率。使用机器学习/深度学习预测股价可能非常困难,甚至几乎不可能,因此本练习的目的是展示如何使用 NeuralProphet 算法,而不是创造最精确的预测。
如何操作...
执行以下步骤,将 NeuralProphet 算法的几种配置拟合到每日 S&P 500 股价的时间序列中:
导入库:
import yfinance as yf import pandas as pd from neuralprophet import NeuralProphet from neuralprophet.utils import set_random_seed
下载标准普尔 500 指数的历史价格并准备 DataFrame,以便使用 NeuralProphet 进行建模:
df = yf.download("^GSPC", start="2010-01-01", end="2021-12-31") df = df[["Adj Close"]].reset_index(drop=False) df.columns = ["ds", "y"]
创建训练/测试集拆分:
TEST_LENGTH = 60 df_train = df.iloc[:-TEST_LENGTH] df_test = df.iloc[-TEST_LENGTH:]
训练默认的 Prophet 模型并绘制评估指标:
set_random_seed(42) model = NeuralProphet(changepoints_range=0.95) metrics = model.fit(df_train, freq="B") ( metrics .drop(columns=["RegLoss"]) .plot(title="Evaluation metrics during training", subplots=True) )
执行这段代码会生成以下图表:
图 15.16:NeuralProphet 训练过程中每个周期的评估指标
计算预测值并绘制拟合结果:
pred_df = model.predict(df) pred_df.plot(x="ds", y=["y", "yhat1"], title="S&P 500 - forecast vs ground truth")
执行这段代码会生成以下图表:
图 15.17:NeuralProphet 模型的拟合与整个时间序列的实际值对比
如我们所见,模型的拟合线遵循整体上升趋势(甚至随着时间的推移调整增长速度),但它忽略了极端周期,并未跟随局部尺度上的变化。
此外,我们还可以放大与测试集对应的时间段:
( pred_df .iloc[-TEST_LENGTH:] .plot(x="ds", y=["y", "yhat1"], title="S&P 500 - forecast vs ground truth") )
执行这段代码会生成以下图表:
图 15.18:NeuralProphet 模型的拟合与测试集中的实际值对比
从图表得出的结论与整体拟合的结论非常相似——模型遵循上升趋势,但没有捕捉到局部模式。
为了评估测试集的表现,我们可以使用以下命令:
model.test(df_test)
。向 NeuralProphet 中添加 AR 组件:
set_random_seed(42) model = NeuralProphet( changepoints_range=0.95, n_lags=10, ar_reg=1, ) metrics = model.fit(df_train, freq="B") pred_df = model.predict(df) pred_df.plot(x="ds", y=["y", "yhat1"], title="S&P 500 - forecast vs ground truth")
执行这段代码会生成以下图表:
图 15.19:NeuralProphet 模型的拟合与整个时间序列的实际值对比
该拟合效果比之前的要好得多。再次,我们更仔细地查看测试集:
( pred_df .iloc[-TEST_LENGTH:] .plot(x="ds", y=["y", "yhat1"], title="S&P 500 - forecast vs ground truth") )
执行这段代码会生成以下图表:
图 15.20:NeuralProphet 模型的拟合与测试集中的实际值对比
我们可以看到一个既熟悉又令人担忧的模式——预测结果滞后于原始序列。这里的意思是,预测值非常接近最后一个已知值。换句话说,预测线与真实值线相似,只不过在时间轴上向右偏移了一个或多个周期。
向 NeuralProphet 中添加 AR-Net:
set_random_seed(42) model = NeuralProphet( changepoints_range=0.95, n_lags=10, ar_reg=1, num_hidden_layers=3, d_hidden=32, ) metrics = model.fit(df_train, freq="B") pred_df = model.predict(df) ( pred_df .iloc[-TEST_LENGTH:] .plot(x="ds", y=["y", "yhat1"], title="S&P 500 - forecast vs ground truth") )
执行这段代码会生成以下图表:
图 15.21:NeuralProphet 模型的拟合与测试集中的实际值对比
我们可以看到,使用 AR-Net 后,预测图比没有使用 AR-Net 时的效果更好。尽管模式仍然看起来相差一个周期,但它们不像之前那样过拟合。
绘制模型的组件和参数:
model.plot_components(model.predict(df_train))
执行这段代码会生成以下图表:
图 15.22:拟合的 NeuralProphet 模型组件(包括 AR-Net)
在这些图表中,我们可以看到一些模式:
一个上升趋势,并且有几个已识别的变化点。
4 月底的季节性高峰和 9 月底及 10 月初的季节性低谷。
在工作日内没有出现意外的模式。然而,重要的是要记住,我们不应查看星期六和星期日的周季节性值。由于我们处理的是仅在工作日提供的每日数据,因此预测也应仅针对工作日进行,因为周内季节性不会很好地估算周末的数据。
查看股票价格的年度季节性可以揭示一些有趣的模式。最著名的模式之一是 1 月效应,它涉及股票价格在 1 月份可能的季节性上涨。通常,这被归因于资产购买的增加,通常发生在 12 月的价格下跌之后,投资者倾向于为了税收目的出售部分资产。
接着,我们还绘制了模型的参数:
model.plot_parameters()
执行这段代码会生成以下图表:
图 15.23:拟合的 NeuralProphet 模型参数(包括 AR-Net)
由于组件和参数的图表之间有很多重叠,因此我们只关注新的元素。首先,我们可以查看描绘趋势变化幅度的图表。我们可以将其与图 15.22中的趋势组件图表一起考虑。接着,我们可以看到变化率是如何与多年来的趋势相对应的。其次,似乎滞后期 2 是所有考虑的 10 个滞后期中最相关的。
它是如何工作的……
在导入库之后,我们从 2010 年到 2021 年下载了标准普尔 500 指数的每日价格。我们只保留了调整后的收盘价,并将 DataFrame 转换为 Prophet 和 NeuralProphet 都能识别的格式,即一个包含名为ds
的时间列和目标时间序列y
的 DataFrame。
在步骤 3中,我们将测试集大小设置为 60,并将 DataFrame 切分为训练集和测试集。
NeuralProphet 还支持在训练模型时使用验证集。我们可以在调用fit
方法时添加它。
在步骤 4中,我们实例化了几乎默认的 NeuralProphet 模型。我们调整的唯一超参数是changepoints_range
。我们将其值从默认的0.9
增加到0.95
,这意味着模型可以在前 95%的数据中识别变点,其余部分保持不变,以确保最终趋势的一致性。我们之所以增加默认值,是因为我们将关注相对短期的预测。
在步骤 5中,我们使用predict
方法和整个时间序列作为输入来计算预测值。这样,我们得到了拟合值(样本内拟合)和测试集的样本外预测值。此时,我们也可以使用make_future_dataframe
方法,这在原始 Prophet 库中是熟悉的。
在第 6 步中,我们添加了线性 AR 项。我们通过n_lags
参数指定了考虑的滞后期数。此外,我们通过将ar_reg
设置为1
,添加了 AR 项的正则化。我们本可以指定学习率。然而,当我们不提供值时,库会使用学习率范围测试来找到最佳值。
设置 AR 项的正则化时(这适用于库中的所有正则化),值为零表示不进行正则化。较小的值(例如,0.001 到 1 之间)表示弱正则化。在 AR 项的情况下,这意味着会有更多非零的 AR 系数。较大的值(例如,1 到 100 之间)会显著限制非零系数的数量。
在第 7 步中,我们将 AR 项的使用从线性 AR 扩展到了 AR-Net。我们保持其他超参数与第 6 步相同,但我们指定了要使用多少个隐藏层(num_hidden_layers
)以及它们的大小(d_hidden
)。
在最后一步,我们使用plot_components
方法绘制了 NeuralProphet 的组件,并使用plot_parameters
方法绘制了模型的参数。
还有更多……
我们刚刚介绍了使用 NeuralProphet 的基础知识。在本节中,我们将提到该库的其他一些功能。
添加节假日和特殊事件
原始 Prophet 算法中非常受欢迎的一个功能,在 NeuralProphet 中也同样可以使用,就是能够轻松添加节假日和特殊日期。例如,在零售工作中,我们可以添加体育赛事(例如世界锦标赛或超级碗)或黑色星期五,后者并非官方假期。在以下代码片段中,我们基于 AR-Net 将美国的节假日添加到我们的模型中:
set_random_seed(42)
model = NeuralProphet(
changepoints_range=0.95,
n_lags=10,
ar_reg=1,
num_hidden_layers=3,
d_hidden=32,
)
model = model.add_country_holidays(
"US", lower_window=-1, upper_window=1
)
metrics = model.fit(df_train, freq="B")
此外,我们指定节假日也会影响周围的日期,即节假日前一天和节假日后一天。如果我们考虑某些日期的前期准备和后期回落,这一功能可能尤其重要。例如,在零售领域,我们可能希望指定圣诞节前的一个时期,因为那时人们通常会购买礼物。
通过检查组件图,我们可以看到节假日对时间的影响。
图 15.24:拟合后的 NeuralProphet 的节假日组件
此外,我们可以检查参数图,从中获取更多关于特定节假日(及其周围日期)影响的见解。
在这种情况下,我们一次性添加了所有的美国节假日。因此,所有节假日也都有相同的周围日期范围(节假日前一天和节假日后一天)。然而,我们也可以手动创建一个包含自定义节假日的 DataFrame,并在特定事件级别指定周围的天数,而不是全局设置。
下一步预测与多步预测
使用 NeuralProphet 进行多步预测有两种方法:
我们可以递归地创建一步预测。该过程如下:我们预测下一步,将预测值添加到数据中,然后预测下一步。我们重复此过程,直到达到所需的预测时长。
我们可以直接预测多个步骤。
默认情况下,NeuralProphet 将使用第一种方法。然而,我们可以通过指定 NeuralProphet
类的 n_forecasts
超参数来使用第二种方法:
model = NeuralProphet(
n_lags=10,
n_forecasts=10,
ar_reg=1,
learning_rate=0.01
)
metrics = model.fit(df_train, freq="B")
pred_df = model.predict(df)
pred_df.tail()
下面我们只展示结果 DataFrame 的一部分。
图 15.25:包含 10 步预测的 DataFrame 预览
这次,DataFrame 每行将包含 10 个预测值:yhat1
、yhat2
、…
、yhat10
。要学习如何解读该表,我们可以查看图 15.25中展示的最后一行。yhat2
值对应于 2021-12-30
的预测,预测时间为该日期之前的 2 天。因此,yhat
后面的数字表示预测的时间跨度(在此案例中以天为单位)。
或者,我们可以调换这一过程。通过在调用 predict
方法时指定 raw=True
,我们可以获得基于行日期的预测,而不是预测该日期的预测值:
pred_df = model.predict(df, raw=True, decompose=False)
pred_df.tail()
执行该代码片段生成了以下 DataFrame 预览:
图 15.26:包含前 5 个 10 步预测的 DataFrame 预览
我们可以轻松地在两个表格中跟踪某些预测,看看它们的结构如何不同。
当绘制多步预测时,我们将看到多条线——每条线都来自不同的预测日期:
pred_df = model.predict(df_test)
model.plot(pred_df)
ax = plt.gca()
ax.legend(loc='center left', bbox_to_anchor=(1, 0.5))
ax.set_title("10-day ahead multi-step forecast")
执行该代码片段会生成以下图表:
图 15.27:10 天后的多步预测
由于线条重叠,图表很难读取。我们可以使用highlight_nth_step_ahead_of_each_forecast
方法突出显示为某一步预测的内容。以下代码片段演示了如何操作:
model = model.highlight_nth_step_ahead_of_each_forecast(1)
model.plot(pred_df)
ax = plt.gca()
ax.set_title("Step 1 of the 10-day ahead multi-step forecast")
执行该代码片段会生成以下图表:
图 15.28:10 天多步预测的第一步
在分析图 15.28后,我们可以得出结论:模型在预测上仍然存在困难,预测值非常接近最后已知值。
其他功能
NeuralProphet 还包含一些其他有趣的功能,包括:
广泛的交叉验证和基准测试功能
模型的组成部分,如假期/事件、季节性或未来回归因子,既可以是加法的,也可以是乘法的。
默认的损失函数是 Huber 损失,但我们可以将其更改为其他流行的损失函数。
另见
Triebe, O., Laptev, N., & Rajagopal, R. 2019. Ar-net: 一种简单的自回归神经网络用于时间序列预测。arXiv 预印本 arXiv:1911.12436。
Triebe, O., Hewamalage, H., Pilyugina, P., Laptev, N., Bergmeir, C., & Rajagopal, R. 2021. Neuralprophet: 大规模可解释的预测。arXiv 预印本 arXiv:2111.15397。
摘要
在本章中,我们探讨了如何同时使用深度学习处理表格数据和时间序列数据。我们没有从零开始构建神经网络,而是使用了现代的 Python 库,这些库为我们处理了大部分繁重的工作。
正如我们已经提到的,深度学习是一个快速发展的领域,每天都有新的神经网络架构发表。因此,在一个章节中很难仅仅触及冰山一角。这就是为什么我们现在会引导您了解一些流行且具有影响力的方法/库,您可能会想要自己探索。
表格数据
以下是一些相关的论文和 Python 库,它们绝对是进一步探索使用深度学习处理表格数据这一主题的好起点。
进一步阅读:
Huang, X., Khetan, A., Cvitkovic, M., & Karnin, Z. 2020. Tabtransformer: 使用上下文嵌入进行表格数据建模。arXiv 预印本 arXiv:2012.06678。
Popov, S., Morozov, S., & Babenko, A. 2019. 神经无知决策集成方法用于深度学习表格数据。arXiv 预印本 arXiv:1909.06312。
库:
pytorch_tabular
—这个库提供了一个框架,用于在表格数据上应用深度学习模型。它提供了像 TabNet、TabTransformer、FT Transformer 和带类别嵌入的前馈网络等模型。pytorch-widedeep
—基于谷歌的 Wide and Deep 算法的库。它不仅使我们能够使用深度学习处理表格数据,还方便了将文本和图像与相应的表格数据结合起来。
时间序列
在本章中,我们介绍了两种基于深度学习的时间序列预测方法——DeepAR 和 NeuralProphet。我们强烈推荐您还可以查阅以下有关时间序列分析和预测的资源。
进一步阅读:
Chen, Y., Kang, Y., Chen, Y., & Wang, Z. (2020). “基于时序卷积神经网络的概率预测”, 神经计算,399: 491-501。
Gallicchio, C., Micheli, A., & Pedrelli, L. 2018. “深度回声状态网络的设计”, 神经网络,108: 33-47。
Kazemi, S. M., Goel, R., Eghbali, S., Ramanan, J., Sahota, J., Thakur, S., ... & Brubaker, M. 2019. Time2vec: 学习时间的向量表示。arXiv 预印本 arXiv:1907.05321。
Lea, C., Flynn, M. D., Vidal, R., Reiter, A., & Hager, G. D. 2017. 时序卷积网络用于动作分割和检测。见于 IEEE 计算机视觉与模式识别会议论文集,156-165。
Lim, B., Arık, S. Ö., Loeff, N., & Pfister, T. 2021. “用于可解释的多时间跨度时间序列预测的时序融合变换器”, 国际预测期刊,37(4): 1748-1764。
Oreshkin, B. N., Carpov, D., Chapados, N., & Bengio, Y. 2019. N-BEATS:用于可解释时间序列预测的神经基扩展分析。arXiv 预印本 arXiv:1905.10437。
库:
tsai
—这是一个建立在 PyTorch 和fastai
之上的深度学习库,专注于各种时间序列相关的任务,包括分类、回归、预测和填补。除了 LSTM 或 GRU 等传统方法外,它还实现了一些最先进的架构,如 ResNet、InceptionTime、TabTransformer 和 Rocket。gluonts
—一个用于使用深度学习进行概率时间序列建模的 Python 库。它包含像 DeepAR、DeepVAR、N-BEATS、Temporal Fusion Transformer、WaveNet 等模型。darts
—一个多功能的时间序列预测库,使用多种方法,从统计模型如 ARIMA 到深度神经网络。它包含了 N-BEATS、Temporal Fusion Transformer 和时间卷积神经网络等模型的实现。
其他领域
在本章中,我们重点展示了深度学习在表格数据和时间序列预测中的应用。然而,还有许多其他的应用案例和最新进展。例如,FinBERT 是一个预训练的 NLP 模型,用于分析财务文本的情感,如财报电话会议的记录。
另一方面,我们可以利用生成对抗网络的最新进展,为我们的模型生成合成数据。以下,我们提到了一些有趣的起点,供进一步探索深度学习在金融背景下的应用。
进一步阅读:
Araci, D. 2019. Finbert:使用预训练语言模型进行财务情感分析。arXiv 预印本 arXiv:1908.10063。
Cao, J., Chen, J., Hull, J., & Poulos, Z. 2021. “使用强化学习进行衍生品的深度对冲”,金融数据科学杂志,3(1):10-27。
Xie, J., Girshick, R., & Farhadi, A. 2016 年 6 月。无监督深度嵌入用于聚类分析。在国际机器学习大会,478-487. PMLR。
Yoon, J., Jarrett, D., & Van der Schaar, M. 2019. 时间序列生成对抗网络。神经信息处理系统的进展,32。
库:
tensortrade
—提供一个强化学习框架,用于训练、评估和部署交易代理。FinRL
—一个包含多种强化学习应用的生态系统,专注于金融领域。它涵盖了最先进的算法、加密货币交易或高频交易等金融应用,以及更多内容。ydata-synthetic
—一个用于生成合成表格数据和时间序列数据的库,使用的是最先进的生成模型,例如 TimeGAN。sdv
—该名称代表合成数据库,顾名思义,它是另一个用于生成合成数据的库,涵盖表格、关系型和时间序列数据。transformers
—这是一个 Python 库,使我们能够访问一系列预训练的变换器模型(例如,FinBERT)。这个库背后的公司叫做 Hugging Face,它提供一个平台,允许用户构建、训练和部署机器学习/深度学习模型。autogluon
—这个库为表格数据、文本和图像提供了 AutoML。它包含了多种最先进的机器学习和深度学习模型。
加入我们,和我们一起在 Discord 上交流!
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问,并了解最新的版本——请扫描下面的二维码: