Pandas-数据分析实用指南第二版-全-
Pandas 数据分析实用指南第二版(全)
原文:
annas-archive.org/md5/ef72ddf5930d597094f1662f9e78e83e译者:飞龙
前言
数据科学通常被描述为一个跨学科的领域,其中编程技能、统计知识和领域知识相交。它已经迅速成为我们社会中最热门的领域之一,懂得如何处理数据已成为当今职业生涯中的必备技能。不论行业、角色或项目如何,数据技能的需求都很高,而学习数据分析是产生影响的关键。
数据科学领域涉及许多不同的方面:数据分析师更侧重于提取业务洞察,而数据科学家则更侧重于将机器学习技术应用于业务问题。数据工程师则专注于设计、构建和维护供数据分析师和数据科学家使用的数据管道。机器学习工程师与数据科学家的技能集有很多相似之处,并且像数据工程师一样,他们是熟练的软件工程师。数据科学领域涵盖了许多领域,但对于所有这些领域,数据分析都是基础构建模块。本书将为你提供入门所需的技能,无论你的旅程将带你走向何方。
传统的数据科学技能集包括了解如何从各种来源(如数据库和 API)收集数据并进行处理。Python 是一种流行的数据科学语言,提供了收集和处理数据以及构建生产级数据产品的手段。由于它是开源的,通过利用他人编写的库来解决常见的数据任务和问题,使得开始进行数据科学变得容易。
Pandas 是与 Python 中的数据科学同义的强大且流行的库。本书将通过使用 Pandas 在现实世界的数据集上进行数据分析,为你提供动手实践的入门,包括涉及股市、模拟黑客攻击、天气趋势、地震、葡萄酒和天文数据的实际案例。Pandas 通过使我们能够高效地处理表格数据,简化了数据处理和可视化的过程。
一旦我们掌握了如何进行数据分析,我们将探索许多应用。我们将构建 Python 包,并尝试进行股票分析、异常检测、回归、聚类和分类,同时借助常用于数据可视化、数据处理和机器学习的额外库,如 Matplotlib、Seaborn、NumPy 和 Scikit-learn。在你完成本书后,你将能充分准备好,开展自己的 Python 数据科学项目。
本书适用对象
本书面向那些有不同经验背景的人,旨在学习 Python 中的数据科学,可能是为了应用于项目、与数据科学家合作和/或与软件工程师一起进行机器学习生产代码的工作。如果你的背景与以下之一(或两个)相似,你将从本书中获得最大的收益:
-
你在其他语言(如 R、SAS 或 MATLAB)中有数据科学的经验,并希望学习 pandas,将你的工作流程迁移到 Python。
-
你有一定的 Python 经验,并希望学习如何使用 Python 进行数据科学。
本书内容
第一章,数据分析简介,教你数据分析的基本原理,为你打下统计学基础,并指导你如何设置环境以便在 Python 中处理数据并使用 Jupyter Notebooks。
第二章,操作 Pandas DataFrame,介绍了 pandas 库,并展示了如何处理 DataFrame 的基础知识。
第三章,使用 Pandas 进行数据整理,讨论了数据操作的过程,展示了如何探索 API 获取数据,并引导你通过 pandas 进行数据清洗和重塑。
第四章,聚合 Pandas DataFrame,教你如何查询和合并 DataFrame,如何对它们执行复杂的操作,包括滚动计算和聚合,以及如何有效处理时间序列数据。
第五章,使用 Pandas 和 Matplotlib 可视化数据,展示了如何在 Python 中创建你自己的数据可视化,首先使用 matplotlib 库,然后直接从 pandas 对象中创建。
第六章,使用 Seaborn 绘图及自定义技术,继续讨论数据可视化,教你如何使用 seaborn 库来可视化你的长格式数据,并为你提供自定义可视化的工具,使其达到可用于展示的效果。
第七章,金融分析 – 比特币与股票市场,带你了解如何创建一个用于分析股票的 Python 包,并结合从 第一章,数据分析简介,到 第六章,使用 Seaborn 绘图及自定义技术,所学的所有内容,并将其应用于金融领域。
第八章,基于规则的异常检测,介绍了如何模拟数据并应用从 第一章,数据分析简介,到 第六章,使用 Seaborn 绘图及自定义技术,所学的所有知识,通过基于规则的异常检测策略来捕捉试图认证进入网站的黑客。
第九章,在 Python 中入门机器学习,介绍了机器学习以及如何使用scikit-learn库构建模型。
第十章,更好的预测 - 优化模型,向你展示了调整和提高机器学习模型性能的策略。
第十一章,机器学习异常检测,重新探讨了通过机器学习技术进行登录尝试数据的异常检测,同时让你了解实际工作流程的样子。
第十二章,前路漫漫,讲解了提升技能和进一步探索的资源。
为了最大程度地从本书中受益
你应该熟悉 Python,特别是 Python 3 及以上版本。你还需要掌握如何编写函数和基本脚本,理解标准编程概念,如变量、数据类型和控制流程(if/else、for/while 循环),并能将 Python 用作函数式编程语言。具备一些面向对象编程的基础知识会有所帮助,但并不是必需的。如果你的 Python 水平尚未达到这一程度,Python 文档中有一个有用的教程,能帮助你迅速入门:docs.python.org/3/tutorial/index.html。
本书的配套代码可以在 GitHub 上找到,地址为github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition。为了最大程度地从本书中受益,建议你在阅读每一章时,在 Jupyter 笔记本中进行跟随操作。我们将在第一章,数据分析导论中介绍如何设置环境并获取这些文件。请注意,如果需要,还可以参考 Python 101 笔记本,它提供了一个速成课程/复习资料:github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/blob/master/ch_01/python_101.ipynb。
最后,务必完成每章末尾的练习。有些练习可能相当具有挑战性,但它们会让你对材料的理解更加深入。每章练习的解答可以在github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/solutions中找到,位于各自的文件夹内。
下载彩色图像
我们还提供了一份 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。你可以在这里下载:static.packt-cdn.com/downloads/9781800563452_ColorImages.pdf。
使用的约定
本书中使用了一些文本约定。
文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL 和用户输入。以下是一个例子:“使用pip安装requirements.txt文件中的包。”
一段代码会如下所示。该行的开头将以>>>为前缀,接下来的行将以...为前缀:
>>> df = pd.read_csv(
... 'data/fb_2018.csv', index_col='date', parse_dates=True
... )
>>> df.head()
任何没有前缀>>>或...的代码我们不会执行——它仅供参考:
try:
del df['ones']
except KeyError:
pass # handle the error here
当我们希望将你的注意力引导到代码块的某一部分时,相关的行或项会被加粗显示:
>>> df.price.plot(
... title='Price over Time', ylim=(0, None)
... )
结果将显示在没有任何前缀的行中:
>>> pd.Series(np.random.rand(2), name='random')
0 0.235793
1 0.257935
Name: random, dtype: float64
任何命令行输入或输出都如下所示:
# Windows:
C:\path\of\your\choosing> mkdir pandas_exercises
# Linux, Mac, and shorthand:
$ mkdir pandas_exercises
加粗:表示新术语、重要词汇或屏幕上看到的词。例如,菜单或对话框中的词会以这种方式出现在文本中。以下是一个例子:“使用文件浏览器窗格,双击ch_01文件夹,该文件夹包含我们将用来验证安装的 Jupyter Notebook。”
提示或重要注意事项
以这种方式显示。
联系我们
我们始终欢迎读者的反馈。
customercare@packtpub.com。
勘误表:尽管我们已尽力确保内容的准确性,但错误仍然会发生。如果你在本书中发现错误,我们将不胜感激,如果你能向我们报告。请访问www.packtpub.com/support/errata,选择你的书籍,点击“勘误提交表单”链接,并填写相关细节。
copyright@packt.com,并链接到该材料。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且有兴趣写作或为书籍做贡献,请访问authors.packtpub.com。
评审
请留下评论。一旦你阅读并使用了本书,为什么不在你购买的站点上留下评论呢?潜在的读者可以看到并利用你的公正意见来做出购买决策,我们在 Packt 可以了解你对我们产品的看法,而我们的作者也能看到你对他们书籍的反馈。谢谢!
欲了解更多关于 Packt 的信息,请访问packt.com。
第一部分:开始使用 Pandas
我们的旅程从数据分析和统计学的介绍开始,这将为我们在本书中覆盖的概念奠定坚实的基础。接下来,我们将设置我们的 Python 数据科学环境,这个环境包含了我们在完成示例时所需的一切,并开始学习 pandas 的基础知识。
本节包括以下章节:
-
第一章,数据分析介绍
-
第二章,使用 Pandas DataFrames
第一章:第一章:数据分析简介
在我们开始使用 pandas 进行数据分析的实践介绍之前,我们需要学习数据分析的基础知识。那些曾经查看过软件库文档的人都知道,如果你不知道自己在找什么,它可能会让人感到压倒性的复杂。因此,掌握不仅是编码方面的技能,还需要掌握分析数据所需的思维方式和工作流程,这将对未来提升我们的技能集非常有帮助。
与科学方法类似,数据科学也有一些常见的工作流程,当我们想进行分析并展示结果时,可以遵循这些流程。这个过程的核心是统计学,它为我们提供了描述数据、做出预测以及得出结论的方法。由于不要求具备统计学的先验知识,本章将让我们接触到在本书中将要使用的统计概念,以及可以进一步探索的领域。
在掌握基础知识之后,我们将为本书的剩余部分设置我们的 Python 环境。Python 是一门强大的语言,其用途远远超出了数据科学:例如构建 web 应用程序、软件开发和网页抓取等。为了在项目之间有效地工作,我们需要学习如何创建虚拟环境,这样可以将每个项目的依赖关系隔离开来。最后,我们将学习如何使用 Jupyter Notebooks,以便跟随书中的内容进行实践。
本章将涵盖以下主题:
-
数据分析的基础
-
统计基础
-
设置虚拟环境
本章材料
本书的所有文件都可以在 GitHub 上找到:github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition。虽然不一定需要 GitHub 账户来完成本书中的内容,但创建一个账户是个好主意,因为它可以作为任何数据/编程项目的作品集。此外,使用 Git 将提供一个版本控制系统,并使协作变得更容易。
提示
阅读这篇文章,了解一些 Git 基础:www.freecodecamp.org/news/learn-the-basics-of-git-in-under-10-minutes-da548267cc91/。
为了获取文件的本地副本,我们有几个选项(按从最不实用到最实用的顺序排列):
-
下载 ZIP 文件并在本地解压文件。
-
直接克隆仓库,而不是先 fork。
-
先 fork 仓库然后克隆它。
本书为每一章都提供了练习;因此,建议那些希望将自己的解答与原始内容一起保存在 GitHub 上的读者fork仓库并克隆fork 后的版本。当我们 fork 一个仓库时,GitHub 会在我们自己的个人资料下创建一个包含原始仓库最新版本的仓库。然后,任何时候我们对自己的版本做出更改,都可以将更改推送回去。请注意,如果我们只是克隆仓库,将无法享受到这一点。
启动此过程的相关按钮在以下截图中已被圈出:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_1.1_B16834.jpg)
图 1.1 – 获取本地代码副本以便跟随
重要提示
克隆过程将把文件复制到当前工作目录中的一个名为Hands-On-Data-Analysis-with-Pandas-2nd-edition的文件夹中。为了创建一个文件夹来放置这个仓库,我们可以使用mkdir my_folder && cd my_folder。这将创建一个名为my_folder的新文件夹(目录),然后将当前目录更改为该文件夹,之后我们就可以克隆仓库。我们可以通过在命令之间添加&&来将这两个命令(以及任何数量的命令)连接起来。这可以理解为然后(前提是第一个命令成功执行)。
这个仓库为每一章提供了文件夹。本章的材料可以在github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_01找到。虽然本章大部分内容不涉及编程,但你可以在 GitHub 网站上跟随introduction_to_data_analysis.ipynb笔记本,直到我们在本章末尾设置环境为止。设置完成后,我们将使用check_your_environment.ipynb笔记本来熟悉 Jupyter 笔记本并运行一些检查,确保一切都为本书的其余部分做好准备。
由于用于生成这些笔记本内容的代码并不是本章的主要内容,因此大部分代码已被分离到visual_aids包中,该包用于创建视觉效果以便在本书中解释概念,还有check_environment.py文件。如果你选择查看这些文件,不必感到不知所措;本书将涵盖所有与数据科学相关的内容。
每一章都有练习;然而,仅此一章中有一个exercises.ipynb笔记本,其中包含生成一些初始数据的代码。完成这些练习需要具备基本的 Python 知识。对于想复习基础的读者,请确保运行本章材料中附带的python_101.ipynb笔记本,进行快速入门。对于更正式的介绍,官方的 Python 教程是一个很好的起点:docs.python.org/3/tutorial/index.html。
数据分析基础
数据分析是一个高度迭代的过程,包括收集、准备(清洗)、探索性数据分析(EDA)和得出结论。在分析过程中,我们将频繁回顾这些步骤。下图展示了一个通用的工作流程:

图 1.2 – 数据分析工作流程
在接下来的几节中,我们将概述每个步骤,首先是数据收集。实际上,这个过程通常偏向于数据准备阶段。调查发现,尽管数据科学家最不喜欢数据准备工作,但它占据了他们 80%的工作时间(www.forbes.com/sites/gilpress/2016/03/23/data-preparation-most-time-consuming-least-enjoyable-data-science-task-survey-says/)。这一数据准备步骤正是pandas大放异彩的地方。
数据收集
数据收集是任何数据分析的自然第一步——我们无法分析没有的数据。实际上,我们的分析可以在我们拥有数据之前就开始。当我们决定想要调查或分析什么时,我们必须考虑可以收集哪些对分析有用的数据。虽然数据可以来自任何地方,但在本书中我们将探讨以下几种数据来源:
-
使用网页抓取从网站的 HTML 中提取数据(通常使用 Python 包,如
selenium、requests、scrapy和beautifulsoup) -
cURL或requestsPython 包 -
数据库(可以通过 SQL 或其他数据库查询语言提取数据)
-
提供数据下载的互联网资源,如政府网站或雅虎财经
-
日志文件
重要说明
第二章,使用 Pandas DataFrame,将教会我们如何处理上述数据来源。第十二章,前方的道路,提供了许多寻找数据源的资源。
我们周围充满了数据,因此可能性是无限的。然而,重要的是要确保我们收集的数据有助于我们得出结论。例如,如果我们试图确定温度较低时热巧克力的销售量是否更高,我们应该收集每天售出的热巧克力数量和当天的温度数据。虽然了解人们为获取热巧克力旅行的距离可能很有趣,但这与我们的分析无关。
在开始分析之前,不要过于担心找到完美的数据。很可能,总会有一些我们想要从初始数据集中添加/删除、重新格式化、与其他数据合并或以某种方式更改的内容。这就是数据清理发挥作用的地方。
数据清理
数据清理是准备数据并将其转换为可以用于分析的格式的过程。数据的一个不幸现实是,它通常是“脏”的,这意味着在使用之前需要进行清理(准备)。以下是我们可能遇到的一些数据问题:
-
100而不是1000,或是打字错误。此外,可能会记录多个相同条目的版本,比如New York City、NYC和nyc。 -
计算机错误:也许我们有一段时间没有记录条目(缺失数据)。
-
意外值:也许记录数据的人决定用问号表示数字列中的缺失值,这样该列中的所有条目就会被当作文本处理,而不是数字值。
-
信息不完整:想象一下一个带有可选问题的调查;并不是每个人都会回答这些问题,因此我们会有缺失数据,但这并非由于计算机或人为错误。
-
分辨率:数据可能是按秒收集的,而我们需要按小时的数据来进行分析。
-
领域的相关性:通常,数据是作为某个过程的产物被收集或生成的,而不是专门为我们的分析而收集。为了将其转化为可用状态,我们需要对其进行清理。
-
数据的格式:数据可能以不利于分析的格式记录,这需要我们对其进行重塑。
-
数据记录过程中的配置错误:来自配置错误的跟踪器和/或 Web 钩子的数据可能会缺失字段或以错误的顺序传递。
大多数数据质量问题是可以解决的,但有些问题是无法解决的,比如当数据是按天收集的,而我们需要按小时的数据。这时,我们有责任仔细检查我们的数据,处理任何问题,以确保我们的分析不被扭曲。我们将在第三章《使用 Pandas 的数据清理》和第四章《Pandas 数据框架的聚合》中详细讨论这个过程。
一旦我们对数据进行了初始清理,我们就准备好进行 EDA 了。请注意,在 EDA 过程中,我们可能需要一些额外的数据整理:这两个步骤是高度交织在一起的。
探索性数据分析
在 EDA 过程中,我们使用可视化和总结统计来更好地理解数据。由于人脑擅长发现视觉模式,数据可视化对于任何分析都是至关重要的。事实上,某些数据的特征只能在图表中观察到。根据我们的数据,我们可能创建图表来查看感兴趣的变量随时间的变化情况,比较每个类别包含多少观察结果,查找异常值,查看连续和离散变量的分布等等。在第五章,使用 Pandas 和 Matplotlib 可视化数据,以及第六章,使用 Seaborn 和自定义技术绘图,我们将学习如何为 EDA 和演示创建这些图表。
重要提示
数据可视化非常强大;不幸的是,它们经常会误导。一个常见问题源于y轴的刻度,因为大多数绘图工具默认会放大以展示近距离的模式。软件很难知道每种可能图形的合适轴限制;因此,在展示结果之前,我们的工作是适当调整坐标轴。您可以阅读更多有关图表可能误导的方法,详见venngage.com/blog/misleading-graphs/。
在我们之前看到的工作流程图(图 1.2)中,EDA 和数据整理共享一个框。这是因为它们密切相关:
-
在进行 EDA 之前,数据需要准备好。
-
在 EDA 过程中创建的可视化可能表明需要进行额外的数据清理。
-
数据整理使用总结统计来查找潜在的数据问题,而 EDA 则用于理解数据。当我们进行 EDA 时,不正确的清理将扭曲研究结果。此外,需要数据整理技能来跨数据子集获取总结统计。
在计算总结统计时,我们必须牢记我们收集到的数据类型。数据可以是定量的(可测量的数量)或分类的(描述、分组或类别)。在这些数据类别中,我们有进一步的细分,可以让我们知道可以在其上执行哪些操作。
例如,分类数据可以是 on = 1 / off = 0。请注意,on 大于 off 的事实是没有意义的,因为我们任意选择这些数字来表示 on 和 off 的状态。当类别之间有排名时,它们是 low < medium < high。
定量数据可以使用区间尺度或比例尺度。区间尺度包括像温度这样的量。我们可以用摄氏度来测量温度,并比较两个城市的温度,但说一个城市的温度是另一个城市的两倍并没有意义。因此,区间尺度的值可以通过加法/减法进行有意义的比较,但不能通过乘法/除法进行比较。比例尺度则是那些可以通过比率(乘法和除法)进行有意义比较的值。例如,价格、大小和数量都属于比例尺度。
完成 EDA 后,我们可以通过得出结论来决定接下来的步骤。
得出结论
在我们收集了分析所需的数据、清理了数据并进行了深入的 EDA(探索性数据分析)之后,就到了得出结论的阶段。这时,我们总结 EDA 中的发现,并决定接下来的步骤:
-
在可视化数据时,我们是否注意到了任何模式或关系?
-
我们是否可以从数据中做出准确的预测?继续对数据进行建模是否有意义?
-
我们是否需要处理缺失的数据点?如何处理?
-
数据的分布情况如何?
-
数据是否能帮助我们回答问题或为我们正在调查的问题提供洞察?
-
我们是否需要收集新的或额外的数据?
如果我们决定对数据进行建模,这将涉及到机器学习和统计学。虽然严格来说,这不属于数据分析范畴,但通常是下一步,我们将在第九章,Python 中的机器学习入门,和第十章,做出更好的预测——优化模型中讨论。此外,我们将在第十一章,机器学习异常检测中看到这个过程在实践中的应用。作为参考,附录中的机器学习工作流部分提供了一张完整的工作流图,展示了从数据分析到机器学习的全过程。第七章,金融分析——比特币与股市,和第八章,基于规则的异常检测,将集中讨论从数据分析中得出结论,而不是构建模型。
下一节将回顾统计学内容;有统计学基础的读者可以跳过并直接阅读设置虚拟环境部分。
统计学基础
当我们想要对所分析的数据做出观察时,我们常常(如果不是总是的话)以某种方式借助统计学。我们所拥有的数据被称为样本,它是从(并且是)总体中观察到的一个子集。统计学有两大类:描述性统计和推断性统计。通过描述性统计,顾名思义,我们的目的是描述样本。推断性统计则是利用样本统计量来推断或推测有关总体的某些信息,比如潜在的分布情况。
重要提示
样本统计量被用作总体参数的估计量,这意味着我们必须量化它们的偏差和方差。对此有多种方法;一些方法会对分布的形状做出假设(参数法),而另一些则不会(非参数法)。这些内容远远超出了本书的范围,但了解它们是有益的。
通常,分析的目标是为数据创造一个故事;不幸的是,统计数据非常容易被误用。这正是某句名言的主题:
"有三种谎言:谎言、可恶的谎言和统计数据。"
— 本杰明·迪斯雷利
这在推断性统计中尤为突出,推断性统计在许多科学研究和论文中用于展示研究者发现的显著性。这是一个更为高级的话题,因为本书并非统计学书籍,我们将仅简要介绍推断性统计背后的一些工具和原理,读者可以进一步深入学习。我们将专注于描述性统计,帮助解释我们正在分析的数据。
抽样
在我们尝试进行任何分析之前,有一个重要的事情需要记住:我们的样本必须是随机样本,且能够代表总体。这意味着数据必须是无偏采样的(例如,如果我们在询问人们是否喜欢某个体育队,我们不能只问该队的球迷),同时我们应该确保样本中包含(理想情况下)总体中所有不同群体的成员(在体育队的例子中,我们不能只问男性)。
当我们讨论在第九章中关于机器学习的内容时,在 Python 中入门机器学习,我们需要对数据进行抽样,而数据本身就是一个初步的样本。这称为重采样。根据数据的不同,我们需要选择不同的抽样方法。通常,我们最好的选择是简单随机抽样:我们使用随机数生成器随机挑选行。当数据中有不同的组时,我们希望我们的样本是分层随机抽样,这种方法会保持数据中各组的比例。在某些情况下,我们没有足够的数据来使用上述的抽样策略,因此我们可能会采用有放回的随机抽样(自助法);这称为自助样本。请注意,我们的基础样本需要是随机样本,否则我们可能会增加估计量的偏差(如果是便利样本,由于某些行在数据中出现的频率较高,我们可能会更频繁地选择这些行,但在真实的总体中,这些行的出现频率可能没有那么高)。我们将在第八章中看到自助法的一个例子,基于规则的异常检测。
重要提示
讨论自助法背后的理论及其后果远远超出了本书的范围,但可以通过观看这个视频来了解基本概念:www.youtube.com/watch?v=gcPIyeqymOU。
你可以在www.khanacademy.org/math/statistics-probability/designing-studies/sampling-methods-stats/a/sampling-methods-review阅读更多关于抽样方法的信息,以及它们的优缺点。
描述性统计
我们将从描述性统计的单变量统计开始讨论;单变量意味着这些统计量是从一个(单)变量计算出来的。本节中的所有内容都可以扩展到整个数据集,但统计量将是按我们记录的每个变量来计算的(意味着如果我们有 100 个速度和距离的配对观测值,我们可以计算整个数据集的平均值,这将给出平均速度和平均距离的统计数据)。
描述性统计用于描述和/或总结我们正在处理的数据。我们可以通过集中趋势的度量开始数据的总结,它描述了大多数数据集中在哪个区域,并且通过离散度或分散度的度量来表示数据值的分布范围。
集中趋势的度量
集中趋势的度量描述了我们数据分布的中心位置。有三种常用的统计量作为中心的度量:均值、中位数和众数。每种方法都有其独特的优点,取决于我们处理的数据类型。
均值
也许最常见的用于总结数据的统计量是平均值,或(0 + 1 + 1 + 2 + 9)/5:

我们用xi 来表示变量X的第i个观察值。注意,变量本身用大写字母表示,而具体的观察值则用小写字母表示。Σ(希腊大写字母Sigma)用于表示求和,在均值的公式中,它的求和范围从1到n,其中n是观察值的数量。
关于均值,有一点需要注意,那就是它对异常值(由与我们分布不同的生成过程产生的值)非常敏感。在前面的例子中,我们只处理了五个数据值;然而,9 远大于其他数字,并把均值拉高了,几乎比除了 9 以外的所有值都要高。在我们怀疑数据中存在异常值时,可能更倾向于使用中位数作为我们的集中趋势度量。
中位数
与均值不同,中位数对异常值具有较强的鲁棒性。以美国的收入为例;最高的 1%收入远高于其他人群,这会使均值偏高,从而扭曲对平均收入的认知。然而,中位数能更好地代表平均收入,因为它是我们数据的第 50 百分位数;这意味着 50%的数据值大于中位数,50%的数据值小于中位数。
提示
i百分位数是指数据中有i%的观察值小于该值,因此 99 百分位数是X中的值,表示 99%的x小于它。
中位数是通过从一个有序数列中取中间值来计算的;如果数据的个数是偶数,则取中间两个值的平均值。如果我们再次使用数字 0、1、1、2 和 9,那么我们的中位数是 1。请注意,这个数据集的均值和中位数是不同的;然而,取决于数据的分布,它们可能是相同的。
众数
众数是数据中最常见的值(如果我们再次使用数字 0、1、1、2 和 9,那么 1 就是众数)。在实践中,我们常常会听到类似“分布是双峰的或多峰的”(与单峰分布相对)的说法,这表示数据的分布有两个或更多的最常见值。这并不一定意味着它们每个出现的次数相同,而是它们比其他值的出现次数显著多。如下面的图所示,单峰分布只有一个众数(位于0),双峰分布有两个众数(分别位于-2和3),而多峰分布有多个众数(分别位于-2、0.4和3):

图 1.3 – 可视化连续数据的众数
理解众数的概念在描述连续分布时非常有用;然而,在大多数情况下,当我们描述连续数据时,我们会使用均值或中位数作为中心趋势的度量。而在处理分类数据时,我们通常会使用众数。
分散度的度量
知道分布的中心在哪里,只是帮助我们部分总结数据分布——我们还需要知道数据如何围绕中心分布,以及它们之间的距离有多远。分散度的度量告诉我们数据的分布情况;这将指示我们的分布是狭窄(低分散)还是宽广(分布很广)。与中心趋势的度量一样,我们有多种方式来描述分布的分散度,选择哪种方式取决于情况和数据。
范围
范围是最小值(最小值)和最大值(最大值)之间的距离。范围的单位与数据的单位相同。因此,除非两个数据分布的单位相同且测量的是相同的事物,否则我们不能比较它们的范围并说一个比另一个更分散:

从范围的定义中,我们可以看出,为什么它并不总是衡量数据分散度的最佳方式。它给出了数据的上下界限;然而,如果数据中存在异常值,范围就会变得没有意义。
另一个关于范围的问题是,它没有告诉我们数据如何在其中心周围分散;它实际上只是告诉我们整个数据集的分散程度。这就引出了方差的问题。
方差
方差描述了观察值与其平均值(均值)之间的分散程度。总体方差表示为σ²(读作西格玛平方),样本方差表示为s²。它是通过计算离均值的平均平方距离来得出的。注意,必须对这些距离进行平方,这样均值以下的距离就不会与均值以上的距离相互抵消。
如果我们希望样本方差成为总体方差的无偏估计量,我们需要除以n - 1而不是n,以弥补使用样本均值而非总体均值的偏差;这就是贝塞尔修正(en.wikipedia.org/wiki/Bessel%27s_correction)。大多数统计工具默认会给出样本方差,因为获取整个总体的数据是非常罕见的:

方差给我们提供了一个带有平方单位的统计量。这意味着,如果我们从以美元(\()表示的收入数据开始,那么我们的方差将是以美元平方(\)²)为单位的。当我们试图了解数据的分布时,这并不十分有用;我们可以使用幅度(大小)本身来查看某个事物的分布情况(大值 = 大范围),但除此之外,我们需要一个单位与数据相同的分布度量。为此,我们使用标准差。
标准差
我们可以使用标准差来查看数据点离均值有多远,平均而言。小的标准差意味着值接近均值,而大的标准差意味着值分散得更广。这与我们想象的分布曲线有关:标准差越小,曲线的峰值越窄(0.5);标准差越大,曲线的峰值越宽(2):

图 1.4 – 使用标准差来量化分布的扩散
标准差仅仅是方差的平方根。通过执行这个操作,我们得到的统计量使用的单位可以让我们再次理解(以收入为例,使用$作为单位):

请注意,人口标准差用σ表示,样本标准差用s表示。
变异系数
当我们从方差转到标准差时,我们的目的是得到一个更具意义的单位;然而,如果我们想将一个数据集的分散度与另一个数据集进行比较,我们需要再次使用相同的单位。解决这一问题的一种方法是计算变异系数(CV),它是无单位的。变异系数是标准差与均值的比值:

我们将在第七章《金融分析——比特币与股市》中使用这个指标;由于变异系数是无单位的,我们可以用它来比较不同资产的波动性。
四分位距
到目前为止,除了范围,我们讨论了基于均值的分散度量;现在,我们将探讨如何使用中位数作为集中趋势的度量来描述数据的扩散。如前所述,中位数是第 50 百分位数或第 2四分位数(Q2)。百分位数和四分位数都是分位数——将数据划分为包含相同比例数据的相等组的值。百分位数将数据分成 100 个部分,而四分位数将其分成四个部分(25%、50%、75%和 100%)。
由于分位数整齐地划分了数据,并且我们知道每个部分中有多少数据,它们是帮助我们量化数据分布的理想选择。一个常见的度量是四分位距(IQR),即第三四分位数和第一四分位数之间的距离:

IQR 给出了围绕中位数的数据显示的分布并且量化了分布中 50%数据的离散度。当检查数据是否存在异常值时,它也很有用,我们将在第八章中讨论,“基于规则的异常检测”。此外,IQR 可以用来计算一个无单位的离散度度量,我们接下来会讨论。
四分位数离散系数
就像当我们使用均值作为中心趋向度时会有变异系数一样,当我们使用中位数作为中心度量时,也有四分位离散系数。这个统计量也是无单位的,因此可以用来比较不同的数据集。它通过将半四分位距(IQR 的一半)除以中位数(第一四分位数和第三四分位数之间的中点)来计算:

我们将在第七章,“金融分析——比特币与股市”中再次看到这个度量,当时我们会评估股票的波动性。现在,让我们看看如何使用中心趋向度和离散度度量来总结数据。
总结数据
我们已经看到了许多可以用来通过数据的中心和离散度来总结数据的描述性统计量;实际上,在深入一些其他前述的度量指标之前,查看5 数概括并可视化分布被证明是有帮助的第一步。正如其名称所示,5 数概括提供了五个描述性统计量来总结我们的数据:

图 1.5 – 5 数概括
箱型图(或称箱线图)是 5 数概括的可视化表示。中位数用盒子中的粗线表示。盒子的顶部是 Q3,底部是 Q1。盒子两侧的线(胡须)延伸到最小值和最大值。根据我们绘图工具使用的约定,尽管如此,它们可能只延伸到某个统计量;任何超出这些统计量的值都被标记为异常值(使用点表示)。对于本书而言,胡须的下界为Q1 – 1.5 * IQR,上界为Q3 + 1.5 * IQR,这被称为Tukey 箱型图:

图 1.6 – Tukey 箱型图
虽然箱型图是了解数据分布的一个很好的工具,但它不能显示每个四分位数内部的分布情况。为了这个目的,我们使用直方图来处理离散变量(例如:人数或书籍数量),使用核密度估计(KDEs)来处理连续变量(例如:身高或时间)。虽然我们可以在离散变量上使用 KDE,但这容易让人产生混淆。直方图适用于离散和连续变量;然而,在这两种情况下,我们必须记住,选择的分箱数量会轻易改变我们看到的分布形状。
制作直方图时,会创建若干个等宽的分箱,并为每个分箱的值添加相应高度的条形。以下图为一个具有 10 个分箱的直方图,展示了与图 1.6中生成箱型图的数据相同的三个中心趋势度量:

图 1.7 – 直方图示例
重要提示
实际操作中,我们需要调整分箱数量以找到最佳值。然而,我们必须小心,因为这可能会误导分布的形状。
KDE 类似于直方图,不同之处在于,KDE 不是为数据创建分箱,而是绘制一个平滑的曲线,它是分布概率密度函数(PDF)的估计。PDF 适用于连续变量,并告诉我们概率在各个值之间的分布。PDF 的值越高,表示对应值的概率越大:

图 1.8 – 带有标记中心位置的 KDE
当分布开始偏斜,且一侧尾部较长时,均值中心度量容易被拉向那一侧。非对称的分布会表现出一定的偏度。左偏(负偏)分布具有左侧长尾;右偏(正偏)分布具有右侧长尾。在负偏的情况下,均值会小于中位数,而在正偏的情况下则相反。当没有偏度时,均值和中位数相等:

图 1.9 – 可视化偏度
重要提示
另一个统计量是峰度,它比较分布中心的密度与尾部的密度。偏度和峰度都可以通过 SciPy 包进行计算。
我们数据中的每一列都是一个随机变量,因为每次观察时,我们会根据潜在的分布获得一个值——它不是静态的。当我们对获得X或更小值的概率感兴趣时,我们使用累积分布函数(CDF),它是概率密度函数(PDF)的积分(曲线下的面积):


随机变量X小于或等于特定值x的概率记作P(X ≤ x)。对于连续变量,获得精确值x的概率是 0。这是因为该概率将是从x到x的 PDF 积分(曲线下宽度为零的面积),即为 0:

为了实现可视化,我们可以从样本中估计累积分布函数(CDF),称为经验累积分布函数(ECDF)。由于这是累积的,在X轴上的值等于x时,Y值表示的是累积概率P(X ≤ x)。让我们以P(X ≤ 50),P(X = 50)和P(X > 50)为例进行可视化:

图 1.10 – 可视化累积分布函数
除了检查数据的分布外,我们可能还需要使用概率分布进行模拟等用途(如在第八章中讨论的基于规则的异常检测)或假设检验(见推断统计*部分);让我们来看一些我们可能会遇到的分布。
常见分布
尽管有许多概率分布,每个分布都有特定的应用场景,但有一些我们会经常遇到。高斯分布或正态分布呈钟形曲线,参数化由其均值(μ)和标准差(σ)。标准正态分布(Z)的均值为 0,标准差为 1。许多自然现象遵循正态分布,如身高。请注意,测试一个分布是否符合正态分布并非易事——有关更多信息,请参见进一步阅读部分。
泊松分布是一种离散分布,通常用来模拟到达事件。到达之间的时间可以通过指数分布来建模。两者都由它们的均值λ(λ)定义。均匀分布在其区间内对每个值赋予相同的概率。我们经常使用它来生成随机数。当我们生成一个随机数以模拟一次成功/失败的结果时,这叫做伯努利试验。它通过成功概率(p)进行参数化。当我们多次进行同样的实验(n)时,总成功次数便是一个二项式随机变量。伯努利分布和二项式分布都是离散的。
我们可以可视化离散和连续分布;然而,离散分布给我们提供了一个概率质量函数(PMF)而不是概率密度函数(PDF):

图 1.11 – 可视化一些常用的分布
我们将在第八章,基于规则的异常检测中使用这些分布,当我们模拟一些登录尝试数据以进行异常检测时。
缩放数据
为了比较来自不同分布的变量,我们必须对数据进行缩放,我们可以通过使用最小-最大缩放来实现。我们取每个数据点,减去数据集的最小值,然后除以范围。这将标准化我们的数据(将其缩放到[0, 1]的范围内):

这不是缩放数据的唯一方式;我们还可以使用均值和标准差。在这种情况下,我们将从每个观察值中减去均值,然后除以标准差来标准化数据。这给出了我们所知道的Z-分数:

我们得到了一个均值为 0 且标准差(和方差)为 1 的标准化分布。Z-分数告诉我们每个观察结果与均值相差了多少个标准差;均值的 Z-分数为 0,而一个比均值低 0.5 个标准差的观察结果的 Z-分数为-0.5。
当然,还有其他方式来缩放我们的数据,我们最终选择的方式将取决于我们的数据及其用途。通过牢记中心趋势和离散度的测量,您将能够确定如何在遇到的任何其他方法中进行数据缩放。
量化变量之间的关系
在前面的章节中,我们处理的是单变量统计,并且只能对我们关注的变量做出某些描述。通过多变量统计,我们试图量化变量之间的关系,并尝试预测未来的行为。
协方差是一种用于量化变量之间关系的统计量,它显示一个变量随着另一个变量的变化(也称为它们的联合方差):

重要提示
E[X] 对于我们来说是一个新的符号。它被读作X 的期望值或X 的期望,通过将X的所有可能值乘以它们的概率相加来计算——它是X的长期平均值。
协方差的大小不容易解释,但它的符号告诉我们变量是正相关还是负相关。然而,我们也希望量化变量之间关系的强度,这就引出了相关性。相关性告诉我们变量如何在方向(相同或相反)和大小(关系的强度)上共同变化。为了找到相关性,我们通过将协方差除以变量标准差的乘积来计算皮尔逊相关系数,其符号为ρ(希腊字母rho):

这使得协方差标准化,并产生一个介于-1 和 1 之间的统计量,便于描述相关性的方向(符号)和强度(大小)。相关系数为 1 被称为完美正相关(线性相关),-1 则为完美负相关。接近 0 的值表示没有相关性。如果相关系数接近 1 的绝对值,那么变量被认为是强相关的;而接近 0.5 的相关系数则表示变量间的相关性较弱。
让我们通过散点图来看一些例子。在图 1.12的最左边的子图(ρ = 0.11)中,我们看到变量之间没有相关性:它们看起来像是没有模式的随机噪声。下一个图(ρ = -0.52)有弱的负相关性:我们可以看到,随着x变量的增加,y变量下降,尽管仍有一些随机性。第三个图(ρ = 0.87)有强的正相关性:x和y一起增加。最右边的图(ρ = -0.99)有接近完美的负相关性:随着x的增加,y减少。我们还可以看到点如何形成一条直线:

图 1.12 – 比较相关系数
为了快速估计两个变量之间关系的强度和方向(并判断是否存在关系),我们通常会使用散点图,而不是计算精确的相关系数。这是因为以下几个原因:
-
在可视化中寻找模式更容易,但通过查看数字和表格得出相同的结论则需要更多的工作。
-
我们可能会看到变量之间似乎有关联,但它们可能不是线性相关的。查看视觉表现可以很容易地判断我们的数据是否实际上是二次的、指数的、对数的或其他非线性函数。
以下两个图都显示了强正相关的数据,但通过观察散点图,显然这些数据并不是线性的。左边的是对数型的,而右边的是指数型的:

图 1.13 – 相关系数可能会误导人
很重要的一点是,尽管我们可能发现X和Y之间存在相关性,但这并不意味着X 导致 Y,或者Y 导致 X。可能存在某个Z实际上同时引起了这两者;也许X导致某个中介事件,从而导致Y,或者这其实只是巧合。请记住,我们往往没有足够的信息来报告因果关系——相关性并不意味着因果关系。
提示
一定要查看 Tyler Vigen 的虚假相关性博客(www.tylervigen.com/spurious-correlations),里面有一些有趣的相关性。
总结统计量的陷阱
有一个非常有趣的数据集,展示了当我们仅使用总结统计量和相关系数来描述数据时,我们必须多么小心。它还向我们展示了绘图并非可选项。安斯科姆四重奏是一个包含四个不同数据集的集合,它们具有相同的总结统计量和相关系数,但当被绘制出来时,很明显它们并不相似:

图 1.14 – 总结统计量可能会误导人
请注意,图 1.14 中的每个图都具有相同的最佳拟合线,其方程为y = 0.50x + 3.00。在下一节中,我们将高层次地讨论这个直线是如何创建的,以及它代表了什么意义。
重要提示
总结统计量在我们了解数据时非常有帮助,但要小心仅仅依赖它们。记住,统计数据可能会误导人;在得出任何结论或继续分析之前,务必先绘制数据图表。你可以在en.wikipedia.org/wiki/Anscombe%27s_quartet了解更多关于安斯科姆四重奏的内容。此外,也要查看Datasaurus Dozen,这是 13 个数据集,它们具有相同的总结统计量,访问地址为www.autodeskresearch.com/publications/samestats。
预测与预报
假设我们最喜欢的冰淇淋店请求我们帮助预测他们在某一天能卖出多少冰淇淋。他们相信外面的温度对他们的销售有很大的影响,因此他们收集了在不同温度下售出冰淇淋的数量。我们同意帮助他们,第一步就是绘制他们收集的数据的散点图:

图 1.15 – 不同温度下冰淇淋销售的观察结果
我们可以在散点图中观察到一个上升趋势:在较高温度下,卖出的冰激凌更多。然而,为了帮助冰激凌店,我们需要找到一种方法来从这些数据中做出预测。我们可以使用一种叫做回归分析的技术,通过一个方程来描述温度和冰激凌销售量之间的关系。通过这个方程,我们将能够预测在某一温度下的冰激凌销售量。
重要说明
请记住,相关性并不意味着因果关系。人们可能会在气温升高时购买冰激凌,但气温升高并不一定导致人们购买冰激凌。
在第九章《Python 中的机器学习入门》中,我们将深入讨论回归分析,因此本讨论将仅为概述。回归有许多种类型,会产生不同的方程,例如线性回归(我们将在这个例子中使用)和逻辑回归。我们的第一步是确定因变量,即我们想要预测的量(冰激凌销售量),以及我们将用来预测它的变量,这些被称为自变量。虽然我们可以有许多自变量,但我们的冰激凌销售例子只有一个自变量:温度。因此,我们将使用简单线性回归将温度和销售量之间的关系建模为一条直线:

图 1.16 – 拟合冰激凌销售数据的直线
上一个散点图中的回归线得出了以下关系方程:

假设今天温度是 35°C——我们将把这个值代入方程中的温度。结果预测冰激凌店将销售 24.54 个冰激凌。这个预测值位于前图中的红线旁边。注意,冰激凌店实际上不能卖部分冰激凌。
在将模型交给冰激凌店之前,重要的是要讨论我们得到的回归线中的虚线和实线部分之间的区别。当我们使用回归线的实线部分进行预测时,我们正在使用插值,这意味着我们将预测回归所建立时的温度下的冰激凌销售量。另一方面,如果我们试图预测在 45°C 时会卖出多少个冰激凌,这就是外推(虚线部分),因为在我们进行回归时并没有包括这么高的温度。外推可能非常危险,因为许多趋势并不会无限延续。人们可能会决定由于温度过高而不外出,这意味着他们将不会销售预测的 39.54 个冰激凌,而是会销售零个。
在处理时间序列时,我们的术语略有不同:我们通常会根据过去的值来预测未来的值。预测是时间序列的一种预测类型。然而,在尝试对时间序列建模之前,我们通常会使用一个叫做时间序列分解的过程,将时间序列分解成多个组成部分,这些组成部分可以以加法或乘法的方式组合,并可作为模型的组成部分。
趋势组件描述了时间序列在长期内的行为,而不考虑季节性或周期性效应。通过趋势,我们可以对时间序列的长期变化做出广泛的判断,比如地球人口在增加或某只股票的价值停滞不前。季节性组件解释了时间序列中与季节相关的系统性变化。例如,纽约市街头的冰淇淋车在夏季数量较多,冬季则几乎消失;这种模式每年都会重复,不管每年夏天的实际数量是否相同。最后,周期性组件解释了时间序列中其他无法用季节性或趋势解释的异常或不规则波动;例如,飓风可能会导致冰淇淋车数量在短期内减少,因为在户外不安全。由于周期性成分的不可预测性,这一部分很难通过预测来预见。
我们可以使用 Python 来分解时间序列为趋势、季节性和噪声或残差。周期性成分被包含在噪声中(随机且不可预测的数据);在我们去除时间序列中的趋势和季节性后,剩下的就是残差:

图 1.17 – 时间序列分解的示例
在构建时间序列预测模型时,一些常见的方法包括指数平滑法和 ARIMA 模型。ARIMA代表自回归(AR)、差分(I)、移动平均(MA)。自回归模型利用了一个观察值在时间t时与先前某个观察值之间的相关性,例如时间t - 1时的观察值。在第五章,《使用 Pandas 和 Matplotlib 可视化数据》中,我们将探讨一些技术来判断一个时间序列是否具有自回归性;需要注意的是,并不是所有的时间序列都有自回归性。差分部分涉及差分数据,即数据从一个时间点到另一个时间点的变化。例如,如果我们关心的是滞后(时间间隔)为 1 的情况,那么差分数据就是时间t的值减去时间t - 1的值。最后,移动平均部分使用滑动窗口计算最后x个观察值的平均值,其中x是滑动窗口的长度。例如,如果我们有一个 3 期的移动平均,那么当我们获得所有的数据直到时间 5 时,我们的移动平均计算只会使用时间 3、4 和 5 来预测时间 6 的值。在第七章,《金融分析——比特币与股市》中,我们将构建一个 ARIMA 模型。
移动平均给过去的每一个时间段赋予相等的权重。在实际操作中,这并不总是对我们的数据一个现实的期望。有时候,所有过去的数值都很重要,但它们对未来数据点的影响不同。对于这些情况,我们可以使用指数平滑法,它使我们能够对最近的数值赋予更多权重,对更远的数值赋予较少的权重,从而预测未来的数据。
请注意,我们不仅限于预测数字;事实上,根据数据的不同,我们的预测也可以是类别性的——例如预测某种口味的冰淇淋在某一天销售量最多,或者判断一封邮件是否为垃圾邮件。这类预测将在第九章,《Python 机器学习入门》中介绍。
推断统计学
如前所述,推断统计学处理的是从我们拥有的样本数据中推断或推导出关于总体的结论。在我们得出结论时,必须注意我们是进行的观察性研究还是实验。对于观察性研究,独立变量不受研究者控制,因此我们是观察参与研究的人(比如吸烟研究——我们不能强迫人们吸烟)。我们不能控制独立变量意味着我们不能得出因果关系的结论。
通过实验,我们能够直接影响自变量,并将受试者随机分配到对照组和实验组,例如 A/B 测试(适用于网站重设计到广告文案等各种场景)。请注意,对照组不接受治疗;他们可能会接受安慰剂(具体取决于研究的内容)。这种设置的理想方式是双盲,即负责施治的研究人员不知道哪个治疗是安慰剂,也不知道哪个受试者属于哪个组别。
重要提示
我们经常会看到贝叶斯推断和频率推断的相关内容。这两者基于两种不同的概率处理方式。频率学派统计学侧重于事件发生的频率,而贝叶斯统计学则在确定事件概率时使用信念的程度。在第十一章,机器学习中的异常检测中,我们会看到贝叶斯统计学的一个例子。你可以在www.probabilisticworld.com/frequentist-bayesian-approaches-inferential-statistics/了解更多关于这两种方法的差异。
推论统计学为我们提供了将样本数据的理解转化为对总体的推断的工具。记住,我们之前讨论的样本统计量是总体参数的估计量。我们的估计量需要置信区间,它提供一个点估计以及围绕点估计的误差范围。这是一个范围,表示真实的总体参数在某个置信水平下的可能取值范围。在 95%的置信水平下,95%从总体中随机抽取的样本计算出的置信区间包含真实的总体参数。通常,统计学中会选择 95%作为置信水平,虽然 90%和 99%也很常见;置信水平越高,区间越宽。
假设检验允许我们测试真实总体参数是否小于、大于或不等于某个值在某个显著性水平(称为α)下。执行假设检验的过程始于陈述我们的初始假设或零假设:例如,真实总体均值为 0。我们选择一个统计显著性水平,通常为 5%,这是在零假设为真时拒绝零假设的概率。然后,我们计算测试统计量的临界值,这将取决于我们拥有的数据量以及我们正在测试的统计量类型(例如一个总体的平均值或候选人得票比例)。将临界值与来自我们数据的测试统计量进行比较,并根据结果,我们要么拒绝要么不拒绝零假设。假设检验与置信区间密切相关。显著性水平相当于 1 减去置信水平。这意味着如果零假设值不在置信区间内,则结果在统计上是显著的。
重要提示
在选择计算置信区间的方法或假设检验的适当检验统计量时,我们必须注意许多事项。这超出了本书的范围,请参阅本章末尾的Further reading部分的链接获取更多信息。此外,请务必查看一些假设检验中使用的 p 值的失误,例如 p-hacking,详见en.wikipedia.org/wiki/Misuse_of_p-values。
现在我们已经概述了统计学和数据分析,准备开始本书的 Python 部分。让我们从设置虚拟环境开始。
设置虚拟环境
本书使用 Python 3.7.3 编写,但代码应适用于所有主要操作系统上的 Python 3.7.1+。在本节中,我们将讲解如何设置虚拟环境,以便跟随本书的内容。如果您的计算机尚未安装 Python,请首先阅读有关虚拟环境的以下部分,然后决定是否安装 Anaconda,因为它也会安装 Python。要安装不带 Anaconda 的 Python,请从www.python.org/downloads/下载,并按照venv部分而不是conda部分操作。
重要提示
要检查 Python 是否已安装,请在 Windows 命令行上运行where python3或在 Linux/macOS 上运行which python3。如果返回结果为空,请尝试仅使用python(而不是python3)运行。如果已安装 Python,请通过运行python3 --version来检查版本。请注意,如果python3可用,则应在整本书中使用它(反之亦然,如果python3不可用,则使用python)。
虚拟环境
大多数情况下,当我们想在电脑上安装软件时,我们只需下载它,但编程语言的特性要求包不断更新并依赖于其他特定版本,这可能会导致一些问题。比如,我们有一天在做一个项目时需要一个特定版本的 Python 包(比如 0.9.1),但第二天我们在做另一个分析时需要同一个包的最新版本(比如 1.1.0),以便访问一些更新的功能。听起来好像不会有什么问题,对吧?但是,如果这个更新导致了第一个项目或我们项目中依赖该包的其他包出现了兼容性问题怎么办呢?这是一个足够常见的问题,已经有了解决方案来防止这种情况:虚拟环境。
虚拟环境允许我们为每个项目创建独立的环境。每个环境只会安装它所需的包。这样可以方便地与他人共享我们的环境,安装多个版本的相同包用于不同的项目而不相互干扰,并避免安装更新包或有其他依赖关系的包时带来的意外副作用。为我们工作的任何项目创建一个专用的虚拟环境是一个好习惯。
我们将讨论两种常见的设置方式,你可以决定哪种最适合。注意,本节中的所有代码将在命令行中执行。
venv
Python 3 自带venv模块,它将根据我们选择的路径创建一个虚拟环境。设置和使用开发环境的过程如下(安装了 Python 之后):
-
为项目创建一个文件夹。
-
使用
venv在此文件夹中创建环境。 -
激活环境。
-
使用
pip在环境中安装 Python 包。 -
完成后停用环境。
实际上,我们将为每个项目创建独立的环境,因此我们的第一步是为所有项目文件创建一个目录。我们可以使用mkdir命令来完成这项工作。创建完成后,我们将使用cd命令切换到新创建的目录。由于我们已经获得了项目文件(从章节材料部分获得的指示),以下内容仅供参考。要创建一个新目录并进入该目录,我们可以使用以下命令:
$ mkdir my_project && cd my_project
提示
cd <path>将当前目录更改为<path>指定的路径,路径可以是绝对路径(完整路径)或相对路径(从当前目录到目标目录的路径)。
在继续之前,使用cd命令导航到包含本书仓库的目录。注意,路径将取决于它被克隆/下载的位置:
$ cd path/to/Hands-On-Data-Analysis-with-Pandas-2nd-edition
由于操作系统之间在剩余步骤上有所不同,我们将分别讲解 Windows 和 Linux/macOS。请注意,如果你的系统同时安装了 Python 2 和 Python 3,确保在以下命令中使用python3,而不是python。
Windows
为了创建本书的虚拟环境,我们将使用标准库中的venv模块。请注意,我们必须为环境提供一个名称(book_env)。记住,如果你的 Windows 设置将python与 Python 3 相关联,那么在以下命令中使用python而不是python3:
C:\...> python3 -m venv book_env
现在,我们在之前克隆/下载的仓库文件夹中,有一个名为book_env的虚拟环境文件夹。为了使用该环境,我们需要激活它:
C:\...> %cd%\book_env\Scripts\activate.bat
提示
Windows 用当前目录的路径替换%cd%,这使我们不必输入完整的路径直到book_env部分。
请注意,在我们激活虚拟环境后,可以在命令行提示符前看到(book_env),这表明我们已经进入该环境:
(book_env) C:\...>
当我们使用完环境后,只需将其停用:
(book_env) C:\...> deactivate
在环境中安装的任何软件包在环境外部是不存在的。请注意,我们在命令行提示符前不再看到(book_env)。你可以在 Python 文档中阅读更多关于venv的信息:docs.python.org/3/library/venv.html。
虚拟环境创建完成后,激活它,然后转到安装所需的 Python 包部分进行下一步操作。
Linux/macOS
为了创建本书的虚拟环境,我们将使用标准库中的venv模块。请注意,我们必须为环境提供一个名称(book_env):
$ python3 -m venv book_env
现在,我们在之前克隆/下载的仓库文件夹中,有一个名为book_env的虚拟环境文件夹。为了使用该环境,我们需要激活它:
$ source book_env/bin/activate
请注意,在我们激活虚拟环境后,可以在命令行提示符前看到(book_env),这表明我们已经进入该环境:
(book_env) $
当我们使用完环境后,只需将其停用:
(book_env) $ deactivate
在环境中安装的任何软件包在环境外部是不存在的。请注意,我们在命令行提示符前不再看到(book_env)。你可以在 Python 文档中阅读更多关于venv的信息:docs.python.org/3/library/venv.html。
虚拟环境创建完成后,激活它,然后转到安装所需的 Python 包部分进行下一步操作。
conda
Anaconda 提供了一种专门为数据科学设置 Python 环境的方法。它包含了本书中将使用的一些包,以及一些可能对本书未涉及的任务有用的其他包(同时也解决了可能难以安装的 Python 外部依赖问题)。Anaconda 使用conda作为环境和包管理器,而不是pip,尽管仍然可以使用pip安装包(前提是使用 Anaconda 自带的pip)。需要注意的是,有些包可能无法通过conda获得,在这种情况下,我们需要使用pip。可以查阅conda文档中的这个页面,比较conda、pip和venv的命令:conda.io/projects/conda/en/latest/commands.html#conda-vs-pip-vs-virtualenv-commands。
重要提示
请注意,Anaconda 的安装非常大(尽管 Miniconda 版本要轻得多)。那些用于数据科学以外目的的 Python 用户,可能更倾向于使用我们之前讨论的venv方法,以便更好地控制安装内容。
Anaconda 还可以与 Spyder 的venv选项一起打包使用。
你可以在 Anaconda 的官方文档中阅读更多关于 Anaconda 及其安装的内容:
一旦安装了 Anaconda 或 Miniconda,确认是否正确安装,可以通过在命令行运行conda -V来显示版本。请注意,在 Windows 上,所有conda命令必须在Anaconda Prompt中运行(而不是Command Prompt)。
为本书创建一个新的conda环境,命名为book_env,可以运行以下命令:
(base) $ conda create --name book_env
运行conda env list将显示系统上的所有conda环境,其中现在包括book_env。当前活动的环境将有一个星号(*)标记—默认情况下,base环境将处于活动状态,直到我们激活另一个环境:
(base) $ conda env list
# conda environments:
#
base * /miniconda3
book_env /miniconda3/envs/book_env
要激活book_env环境,我们运行以下命令:
(base) $ conda activate book_env
请注意,在我们激活虚拟环境后,可以在命令行提示符前看到(book_env);这表明我们已经进入该环境:
(book_env) $
使用完环境后,我们可以停用它:
(book_env) $ conda deactivate
在环境中安装的任何包都只存在于该环境中。请注意,我们的命令行提示符前不再有(book_env)。你可以在www.freecodecamp.org/news/why-you-need-python-environments-and-how-to-manage-them-with-conda-85f155f4353c/阅读更多关于如何使用conda管理虚拟环境的内容。
在下一节中,我们将安装跟随本书所需的 Python 包,因此请现在确保激活虚拟环境。
安装所需的 Python 包
我们可以利用 Python 标准库做很多事情;然而,我们经常会发现需要安装并使用外部包来扩展功能。仓库中的requirements.txt文件包含了我们需要安装的所有包,以便跟随本书进行学习。该文件会位于当前目录中,也可以通过github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/blob/master/requirements.txt找到。我们可以通过在调用pip3 install时使用-r标志,一次性安装多个包,这种方式的优势是方便共享。
在安装任何东西之前,请确保激活你用venv或conda创建的虚拟环境。请注意,如果在运行以下命令之前没有激活虚拟环境,包将会安装到虚拟环境外部:
(book_env) $ pip3 install -r requirements.txt
提示
如果你遇到任何问题,可以在github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/issues上报告。
为什么选择 pandas?
在 Python 数据科学领域,pandas库几乎是无处不在的。它建立在 NumPy 库之上,允许我们对单一类型数据的数组进行高效的数学运算。Pandas 将这一点扩展到了数据框(dataframes),可以将其视为数据表。我们将在第二章,《使用 Pandas 数据框》一章中正式介绍数据框。
除了高效的运算外,pandas还提供了matplotlib绘图库,使得我们无需编写大量matplotlib代码就能轻松创建各种图表。我们始终可以通过matplotlib调整图表,但对于快速可视化数据,我们只需要在pandas中写一行代码即可。我们将在第五章,《使用 Pandas 和 Matplotlib 可视化数据》一章,以及第六章,《使用 Seaborn 绘图和定制技巧》一章中进一步探索这一功能。
重要说明
封装函数是围绕另一个库的代码而编写的,它们隐藏了一些复杂性,并为重复该功能留下了更简单的接口。这是面向对象编程(OOP)的核心原则之一,称为抽象,它减少了代码的复杂性和重复。本书中我们将创建自己的封装函数。
除了pandas,这本书还使用了 Jupyter Notebooks。虽然你可以选择不使用它们,但熟悉 Jupyter Notebooks 非常重要,因为它们在数据领域非常常见。作为介绍,我们将在下一节使用 Jupyter Notebook 验证我们的设置。
Jupyter Notebooks
本书的每一章都包含用于跟随的 Jupyter Notebooks。Jupyter Notebooks 在 Python 数据科学中无处不在,因为它们使得在探索环境中编写和测试代码变得非常简单。我们可以逐块执行代码,并将生成的结果直接打印到笔记本中相应的代码下方。此外,我们可以使用Markdown为我们的工作添加文本说明。Jupyter Notebooks 可以轻松打包和共享;它们可以推送到 GitHub(在那里将被渲染),转换为 HTML 或 PDF,发送给其他人,或进行演示。
启动 JupyterLab
JupyterLab 是一个 IDE,允许我们创建 Jupyter Notebooks 和 Python 脚本,与终端交互,创建文本文档,引用文档等等,所有这些功能都可以在我们本地机器的清晰 Web 界面上完成。在真正成为高级用户之前,有很多键盘快捷键需要掌握,但界面非常直观。在创建环境时,我们已经安装了运行 JupyterLab 所需的一切,因此让我们快速浏览 IDE,确保我们的环境设置正确。首先,激活我们的环境,然后启动 JupyterLab:
(book_env) $ jupyter lab
然后会在默认浏览器中启动一个窗口,显示 JupyterLab。我们将看到启动器选项卡和左侧的文件浏览器面板:

图 1.18 – 启动 JupyterLab
使用文件浏览器面板,在ch_01文件夹中双击,其中包含我们用来验证设置的 Jupyter Notebook。
验证虚拟环境设置
打开checking_your_setup.ipynb笔记本,位于ch_01文件夹中,如下截图所示:

图 1.19 – 验证虚拟环境设置
重要说明
内核是在 Jupyter Notebook 中运行和检查我们代码的进程。请注意,我们不限于运行 Python 代码 —— 我们也可以运行 R、Julia、Scala 和其他语言的内核。默认情况下,我们将使用 IPython 内核来运行 Python。在本书中,我们将更深入地学习 IPython。
点击前面截图中指示的代码单元格,然后通过点击播放(▶)按钮来运行它。如果所有内容都显示为绿色,则环境已经设置好了。但是,如果情况不是这样,请从虚拟环境中运行以下命令,为 Jupyter 创建一个带有 book_env 虚拟环境的特殊核心:
(book_env) $ ipython kernel install --user --name=book_env
这在 Jupyter Notebook 中的 book_env 核心中添加了一个额外的选项:

图 1.20 – 选择不同的核心
需要注意的是,当内核运行时,Jupyter Notebooks 将保留我们为变量分配的值,并且在我们保存文件时,Out[#] 单元格中的结果也将被保存。关闭文件并不会停止内核,关闭浏览器中的 JupyterLab 标签页也不会停止它。
关闭 JupyterLab
关闭浏览器中的 JupyterLab 不会停止 JupyterLab 或正在运行的内核(我们也不会重新获得命令行界面)。要完全关闭 JupyterLab,我们需要在终端中按下 Ctrl + C 几次(这是一个键盘中断信号,让 JupyterLab 知道我们要关闭它),直到我们重新获得提示符:
...
[I 17:36:53.166 LabApp] Interrupted...
[I 17:36:53.168 LabApp] Shutting down 1 kernel
[I 17:36:53.770 LabApp] Kernel shutdown: a38e1[...]b44f
(book_env) $
欲了解更多关于 Jupyter 的信息,包括教程,请访问 jupyter.org/。在 jupyterlab.readthedocs.io/en/stable/ 上了解更多关于 JupyterLab 的信息。
总结
在本章中,我们了解了数据分析的主要过程:数据收集、数据整理、探索性数据分析(EDA)和得出结论。接着我们概述了描述性统计,并学习了如何描述数据的中心趋势和分布;如何用五数总结、箱线图、直方图和核密度估计来数值和视觉上总结数据;如何缩放我们的数据;以及如何量化数据集中变量之间的关系。
我们初步介绍了预测和时间序列分析。然后,我们简要概述了推断统计学的一些核心主题,这些主题可以在掌握本书内容后进一步探索。请注意,本章中的所有示例都是关于一个或两个变量的,而现实生活中的数据往往是高维的。第十章,做出更好的预测 – 优化模型,将涉及一些解决这个问题的方法。最后,我们为本书建立了虚拟环境,并学习了如何使用 Jupyter Notebooks。
现在我们已经打下了坚实的基础,下一章我们将开始在 Python 中处理数据。
练习
运行 introduction_to_data_analysis.ipynb 笔记本来复习本章内容,再复习 python_101.ipynb 笔记本(如果需要),然后完成以下练习,练习在 JupyterLab 中处理数据和计算汇总统计信息:
-
探索 JupyterLab 界面,了解一些可用的快捷键。现在不用担心记住它们(最终,它们会变成第二天性,节省你很多时间)——只要熟悉使用 Jupyter Notebooks。
-
所有数据都是正态分布的吗?请解释为什么或为什么不。
-
在什么情况下使用中位数而不是均值作为中心度量更有意义?
-
运行
exercises.ipynb笔记本中第一个单元格的代码。它将给你一个包含 100 个值的列表,你将在本章的其他练习中使用这些值。确保将这些值视为总体的样本。 -
使用练习 4中的数据,在不导入任何
statistics模块(标准库中的docs.python.org/3/library/statistics.html)的情况下计算以下统计数据,然后确认你的结果与使用statistics模块时得到的结果是否一致(在可能的情况下):a) 平均值
b) 中位数
c) 众数(提示:查看标准库中
collections模块的Counter类,docs.python.org/3/library/collections.html#collections.Counter)d) 样本方差
e) 样本标准差
-
使用练习 4中的数据,适当使用
statistics模块中的函数计算以下统计数据:a) 范围
b) 变异系数
c) 四分位数间距
d) 四分位差系数
-
使用以下策略对练习 4中创建的数据进行缩放:
a) 最小-最大缩放(归一化)
b) 标准化
-
使用练习 7中的缩放数据,计算以下内容:
a) 标准化和归一化数据之间的协方差
b) 标准化和归一化数据之间的皮尔逊相关系数(这实际上是 1,但由于过程中四舍五入,结果会稍微小一点)
进一步阅读
以下是一些资源,可以帮助你更熟悉 Jupyter:
-
Jupyter Notebook 基础:
nbviewer.jupyter.org/github/jupyter/notebook/blob/master/docs/source/examples/Notebook/Notebook%20Basics.ipynb -
JupyterLab 简介:
blog.jupyter.org/jupyterlab-is-ready-for-users-5a6f039b8906 -
学习 Markdown,使你的 Jupyter Notebooks 准备好演示:
medium.com/ibm-data-science-experience/markdown-for-jupyter-notebooks-cheatsheet-386c05aeebed -
28 个 Jupyter Notebook 技巧、窍门和快捷键:
www.dataquest.io/blog/jupyter-notebook-tips-tricks-shortcuts/
一些资源用于学习更多高级统计概念(我们这里不会涉及),并且仔细应用这些概念,如下所示:
-
Python 中的正态性检验温和入门:
machinelearningmastery.com/a-gentle-introduction-to-normality-tests-in-python/ -
假设检验的工作原理:置信区间和置信水平:
statisticsbyjim.com/hypothesis-testing/hypothesis-tests-confidence-intervals-levels/ -
Udacity 的推断统计学入门(用数据进行预测):
www.udacity.com/course/intro-to-inferential-statistics--ud201 -
第 4 课:置信区间(宾州州立大学初级统计学):
online.stat.psu.edu/stat200/lesson/4 -
理论透视:概率与统计的视觉化介绍:
seeing-theory.brown.edu/index.html -
统计学误区:亚历克斯·赖因哈特的完整指南:
www.statisticsdonewrong.com/
第二章:第二章:使用 Pandas DataFrame
是时候开始我们的 pandas 之旅了。本章将让我们熟悉在进行数据分析时使用 pandas 执行一些基本但强大的操作。
我们将从介绍主要的 pandas 开始。数据结构为我们提供了一种组织、管理和存储数据的格式。了解 pandas 数据结构在解决问题或查找如何对数据执行某项操作时将无比有帮助。请记住,这些数据结构与标准 Python 数据结构不同,原因是它们是为特定的分析任务而创建的。我们必须记住,某个方法可能只能在特定的数据结构上使用,因此我们需要能够识别最适合我们要解决的问题的数据结构。
接下来,我们将把第一个数据集导入 Python。我们将学习如何从 API 获取数据、从其他 Python 数据结构创建 DataFrame 对象、读取文件并与数据库进行交互。起初,你可能会想,为什么我们需要从其他 Python 数据结构创建 DataFrame 对象;然而,如果我们想要快速测试某些内容、创建自己的数据、从 API 拉取数据,或者重新利用其他项目中的 Python 代码,那么我们会发现这些知识是不可或缺的。最后,我们将掌握检查、描述、过滤和总结数据的方法。
本章将涵盖以下主题:
-
Pandas 数据结构
-
从文件、API 请求、SQL 查询和其他 Python 对象创建 DataFrame 对象
-
检查 DataFrame 对象并计算总结统计量
-
通过选择、切片、索引和过滤获取数据的子集
-
添加和删除数据
本章内容
本章中我们将使用的文件可以在 GitHub 仓库中找到,地址是 github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_02。我们将使用来自 data/ 目录的地震数据。
本章中会使用四个 CSV 文件和一个 SQLite 数据库文件,它们将在不同的时间点被使用。earthquakes.csv文件包含从 USGS API 拉取的 2018 年 9 月 18 日到 10 月 13 日的数据。对于数据结构的讨论,我们将使用example_data.csv文件,该文件包含五行数据,并且是earthquakes.csv文件中的列的子集。tsunamis.csv文件是earthquakes.csv文件中所有伴随海啸的地震数据的子集,时间范围为上述日期。quakes.db文件包含一个 SQLite 数据库,其中有一个表存储着海啸数据。我们将利用这个数据库学习如何使用pandas从数据库中读取和写入数据。最后,parsed.csv文件将用于本章结尾的练习,我们也将在本章中演示如何创建它。
本章的伴随代码已被分成六个 Jupyter Notebooks,按照使用顺序编号。它们包含了我们在本章中将运行的代码片段,以及任何需要为本文本进行裁剪的命令的完整输出。每次需要切换笔记本时,文本会指示进行切换。
在1-pandas_data_structures.ipynb笔记本中,我们将开始学习主要的pandas数据结构。之后,我们将在2-creating_dataframes.ipynb笔记本中讨论创建DataFrame对象的各种方式。我们将在3-making_dataframes_from_api_requests.ipynb笔记本中继续讨论此话题,探索 USGS API 以收集数据供pandas使用。学习完如何收集数据后,我们将开始学习如何在4-inspecting_dataframes.ipynb笔记本中检查数据。然后,在5-subsetting_data.ipynb笔记本中,我们将讨论各种选择和过滤数据的方式。最后,我们将在6-adding_and_removing_data.ipynb笔记本中学习如何添加和删除数据。让我们开始吧。
Pandas 数据结构
Python 本身已经提供了几种数据结构,如元组、列表和字典。Pandas 提供了两种主要的数据结构来帮助处理数据:Series和DataFrame。Series和DataFrame数据结构中各自包含了另一种pandas数据结构——Index,我们也需要了解它。然而,为了理解这些数据结构,我们首先需要了解 NumPy(numpy.org/doc/stable/),它提供了pandas所依赖的 n 维数组。
前述的数据结构以 Python CapWords风格实现,而对象则采用snake_case书写。(更多 Python 风格指南请参见www.python.org/dev/peps/pep-0008/。)
我们使用pandas函数将 CSV 文件读取为DataFrame类的对象,但我们使用DataFrame对象的方法对其执行操作,例如删除列或计算汇总统计数据。使用pandas时,我们通常希望访问pandas对象的属性,如维度、列名、数据类型以及是否为空。
重要提示
在本书的其余部分,我们将DataFrame对象称为 dataframe,Series对象称为 series,Index对象称为 index/indices,除非我们明确指的是类本身。
对于本节内容,我们将在1-pandas_data_structures.ipynb笔记本中进行操作。首先,我们将导入numpy并使用它读取example_data.csv文件的内容到一个numpy.array对象中。数据来自美国地质调查局(USGS)的地震 API(来源:earthquake.usgs.gov/fdsnws/event/1/)。请注意,这是我们唯一一次使用 NumPy 读取文件,并且这样做仅仅是为了演示;重要的是要查看 NumPy 表示数据的方式:
>>> import numpy as np
>>> data = np.genfromtxt(
... 'data/example_data.csv', delimiter=';',
... names=True, dtype=None, encoding='UTF'
... )
>>> data
array([('2018-10-13 11:10:23.560',
'262km NW of Ozernovskiy, Russia',
'mww', 6.7, 'green', 1),
('2018-10-13 04:34:15.580',
'25km E of Bitung, Indonesia', 'mww', 5.2, 'green', 0),
('2018-10-13 00:13:46.220', '42km WNW of Sola, Vanuatu',
'mww', 5.7, 'green', 0),
('2018-10-12 21:09:49.240',
'13km E of Nueva Concepcion, Guatemala',
'mww', 5.7, 'green', 0),
('2018-10-12 02:52:03.620',
'128km SE of Kimbe, Papua New Guinea',
'mww', 5.6, 'green', 1)],
dtype=[('time', '<U23'), ('place', '<U37'),
('magType', '<U3'), ('mag', '<f8'),
('alert', '<U5'), ('tsunami', '<i8')])
现在我们将数据存储在一个 NumPy 数组中。通过使用shape和dtype属性,我们可以分别获取数组的维度信息和其中包含的数据类型:
>>> data.shape
(5,)
>>> data.dtype
dtype([('time', '<U23'), ('place', '<U37'), ('magType', '<U3'),
('mag', '<f8'), ('alert', '<U5'), ('tsunami', '<i8')])
数组中的每个条目都是 CSV 文件中的一行。NumPy 数组包含单一的数据类型(不同于允许混合类型的列表);这使得快速的矢量化操作成为可能。当我们读取数据时,我们得到了一个numpy.void对象的数组,它用于存储灵活的类型。这是因为 NumPy 必须为每一行存储多种不同的数据类型:四个字符串,一个浮点数和一个整数。不幸的是,这意味着我们不能利用 NumPy 为单一数据类型对象提供的性能提升。
假设我们想找出最大幅度——我们可以使用numpy.void对象。这会创建一个列表,意味着我们可以使用max()函数来找出最大值。我们还可以使用%%timeit %)来查看这个实现所花费的时间(时间会有所不同):
>>> %%timeit
>>> max([row[3] for row in data])
9.74 µs ± 177 ns per loop
(mean ± std. dev. of 7 runs, 100000 loops each)
请注意,每当我们编写一个只有一行内容的for循环,或者想要对初始列表的成员执行某个操作时,应该使用列表推导式。这是一个相对简单的列表推导式,但我们可以通过添加if...else语句使其更加复杂。列表推导式是我们工具箱中一个非常强大的工具。更多信息可以参考 Python 文档:https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions。
提示
IPython (ipython.readthedocs.io/en/stable/index.html) 提供了一个 Python 的交互式 Shell。Jupyter 笔记本是建立在 IPython 之上的。虽然本书不要求掌握 IPython,但熟悉一些 IPython 的功能会有所帮助。IPython 在其文档中提供了一个教程,链接是 ipython.readthedocs.io/en/stable/interactive/。
如果我们为每一列创建一个 NumPy 数组,那么这项操作将变得更加简单(且更高效)。为了实现这一点,我们将使用字典推导式 (www.python.org/dev/peps/pep-0274/) 来创建一个字典,其中键是列名,值是包含数据的 NumPy 数组。同样,重要的部分在于数据现在是如何使用 NumPy 表示的:
>>> array_dict = {
... col: np.array([row[i] for row in data])
... for i, col in enumerate(data.dtype.names)
... }
>>> array_dict
{'time': array(['2018-10-13 11:10:23.560',
'2018-10-13 04:34:15.580', '2018-10-13 00:13:46.220',
'2018-10-12 21:09:49.240', '2018-10-12 02:52:03.620'],
dtype='<U23'),
'place': array(['262km NW of Ozernovskiy, Russia',
'25km E of Bitung, Indonesia',
'42km WNW of Sola, Vanuatu',
'13km E of Nueva Concepcion, Guatemala',
'128km SE of Kimbe, Papua New Guinea'], dtype='<U37'),
'magType': array(['mww', 'mww', 'mww', 'mww', 'mww'],
dtype='<U3'),
'mag': array([6.7, 5.2, 5.7, 5.7, 5.6]),
'alert': array(['green', 'green', 'green', 'green', 'green'],
dtype='<U5'),
'tsunami': array([1, 0, 0, 0, 1])}
现在,获取最大值的幅度仅仅是选择mag键并在 NumPy 数组上调用max()方法。这比列表推导式的实现速度快近两倍,尤其是处理仅有五个条目的数据时——想象一下,第一个尝试在大数据集上的表现将会有多糟糕:
>>> %%timeit
>>> array_dict['mag'].max()
5.22 µs ± 100 ns per loop
(mean ± std. dev. of 7 runs, 100000 loops each)
然而,这种表示方式还有其他问题。假设我们想获取最大幅度的地震的所有信息;我们该如何操作呢?我们需要找到最大值的索引,然后对于字典中的每一个键,获取该索引。结果现在是一个包含字符串的 NumPy 数组(我们的数值已被转换),并且我们现在处于之前看到的格式:
>>> np.array([
... value[array_dict['mag'].argmax()]
... for key, value in array_dict.items()
... ])
array(['2018-10-13 11:10:23.560',
'262km NW of Ozernovskiy, Russia',
'mww', '6.7', 'green', '1'], dtype='<U31')
考虑如何按幅度从小到大排序数据。在第一种表示方式中,我们需要通过检查第三个索引来对行进行排序。而在第二种表示方式中,我们需要确定mag列的索引顺序,然后按照这些相同的索引排序所有其他数组。显然,同时操作多个包含不同数据类型的 NumPy 数组有些繁琐;然而,pandas是在 NumPy 数组之上构建的,可以让这一过程变得更加简单。让我们从Series数据结构的概述开始,探索pandas。
Series
Series类提供了一种数据结构,用于存储单一类型的数组,就像 NumPy 数组一样。然而,它还提供了一些额外的功能。这个一维表示可以被看作是电子表格中的一列。我们为我们的列命名,而其中的数据是相同类型的(因为我们测量的是相同的变量):
>>> import pandas as pd
>>> place = pd.Series(array_dict['place'], name='place')
>>> place
0 262km NW of Ozernovskiy, Russia
1 25km E of Bitung, Indonesia
2 42km WNW of Sola, Vanuatu
3 13km E of Nueva Concepcion, Guatemala
4 128km SE of Kimbe, Papua New Guinea
Name: place, dtype: object
注意结果左侧的数字;这些数字对应于原始数据集中行号(由于 Python 中的计数是从 0 开始的,因此行号比实际行号少 1)。这些行号构成了索引,我们将在接下来的部分讨论。行号旁边是行的实际值,在本示例中,它是一个字符串,指示地震发生的地点。请注意,在 Series 对象的名称旁边,我们有 dtype: object;这表示 place 的数据类型是 object。在 pandas 中,字符串会被分类为 object。
要访问 Series 对象的属性,我们使用 <object>.<attribute_name> 这种属性表示法。以下是我们将要访问的一些常用属性。注意,dtype 和 shape 是可用的,正如我们在 NumPy 数组中看到的那样:

图 2.1 – 常用的系列属性
重要提示
大多数情况下,pandas 对象使用 NumPy 数组来表示其内部数据。然而,对于某些数据类型,pandas 在 NumPy 的基础上构建了自己的数组(https://pandas.pydata.org/pandas-docs/stable/reference/arrays.html)。因此,根据数据类型,values 方法返回的可能是 pandas.array 或 numpy.array 对象。因此,如果我们需要确保获得特定类型的数据,建议使用 array 属性或 to_numpy() 方法,而不是 values。
请务必将 pandas.Series 文档(pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.html)收藏以便以后参考。它包含有关如何创建 Series 对象、所有可用属性和方法的完整列表,以及源代码链接。在了解了 Series 类的高层次介绍后,我们可以继续学习 Index 类。
索引
Index 类的引入使得 Series 类比 NumPy 数组更为强大。Index 类为我们提供了行标签,使得我们可以通过行号选择数据。根据索引的类型,我们可以提供行号、日期,甚至字符串来选择行。它在数据条目的标识中起着关键作用,并在 pandas 中的多种操作中被使用,正如我们在本书中将要看到的那样。我们可以通过 index 属性访问索引:
>>> place_index = place.index
>>> place_index
RangeIndex(start=0, stop=5, step=1)
注意,这是一个 RangeIndex 对象。它的值从 0 开始,到 4 结束。步长为 1 表明索引值之间的差距为 1,意味着我们有该范围内的所有整数。默认的索引类是 RangeIndex;但是,我们可以更改索引,正如我们将在第三章 《数据清理与 Pandas》中讨论的那样。通常,我们要么使用行号的 Index 对象,要么使用日期(时间)的 Index 对象。
与Series对象一样,我们可以通过values属性访问底层数据。请注意,这个Index对象是基于一个 NumPy 数组构建的:
>>> place_index.values
array([0, 1, 2, 3, 4], dtype=int64)
Index对象的一些有用属性包括:

图 2.2 – 常用的索引属性
NumPy 和pandas都支持算术运算,这些运算将按元素逐一执行。NumPy 会使用数组中的位置来进行运算:
>>> np.array([1, 1, 1]) + np.array([-1, 0, 1])
array([0, 1, 2])
在pandas中,这种按元素逐一执行的算术运算是基于匹配的索引值进行的。如果我们将一个索引从0到4的Series对象(存储在x中)与另一个索引从1到5的y对象相加,只有当索引对齐时,我们才会得到结果(1到4)。在第三章,使用 Pandas 进行数据整理中,我们将讨论一些方法来改变和对齐索引,这样我们就可以执行这些类型的操作而不丢失数据:
>>> numbers = np.linspace(0, 10, num=5) # [0, 2.5, 5, 7.5, 10]
>>> x = pd.Series(numbers) # index is [0, 1, 2, 3, 4]
>>> y = pd.Series(numbers, index=pd.Index([1, 2, 3, 4, 5]))
>>> x + y
0 NaN
1 2.5
2 7.5
3 12.5
4 17.5
5 NaN
dtype: float64
现在我们已经了解了Series和Index类的基础知识,接下来我们可以学习DataFrame类。请注意,关于Index类的更多信息可以在相应的文档中找到:pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Index.html。
数据框
在Series类中,我们本质上处理的是电子表格的列,数据类型都是相同的。DataFrame类是在Series类基础上构建的,可以拥有多个列,每列都有其自己的数据类型;我们可以将其看作是代表整个电子表格。我们可以将我们从示例数据中构建的 NumPy 表示形式转化为DataFrame对象:
>>> df = pd.DataFrame(array_dict)
>>> df
这给我们提供了一个由六个系列组成的数据框。请注意time列前面的那一列;它是行的Index对象。在创建DataFrame对象时,pandas会将所有的系列对齐到相同的索引。在这种情况下,它仅仅是行号,但我们也可以轻松地使用time列作为索引,这将启用一些额外的pandas功能,正如我们在第四章,聚合 Pandas 数据框中将看到的那样:

图 2.3 – 我们的第一个数据框
我们的列每一列都有单一的数据类型,但它们并非都具有相同的数据类型:
>>> df.dtypes
time object
place object
magType object
mag float64
alert object
tsunami int64
dtype: object
数据框的值看起来与我们最初的 NumPy 表示非常相似:
>>> df.values
array([['2018-10-13 11:10:23.560',
'262km NW of Ozernovskiy, Russia',
'mww', 6.7, 'green', 1],
['2018-10-13 04:34:15.580',
'25km E of Bitung, Indonesia', 'mww', 5.2, 'green', 0],
['2018-10-13 00:13:46.220', '42km WNW of Sola, Vanuatu',
'mww', 5.7, 'green', 0],
['2018-10-12 21:09:49.240',
'13km E of Nueva Concepcion, Guatemala',
'mww', 5.7, 'green', 0],
['2018-10-12 02:52:03.620','128 km SE of Kimbe,
Papua New Guinea', 'mww', 5.6, 'green', 1]],
dtype=object)
我们可以通过columns属性访问列名。请注意,它们实际上也存储在一个Index对象中:
>>> df.columns
Index(['time', 'place', 'magType', 'mag', 'alert', 'tsunami'],
dtype='object')
以下是一些常用的数据框属性:

图 2.4 – 常用的数据框属性
请注意,我们也可以对数据框执行算术运算。例如,我们可以将df加到它自己上,这将对数值列进行求和,并将字符串列进行连接:
>>> df + df
Pandas 只有在索引和列都匹配时才会执行操作。在这里,pandas将字符串类型的列(time、place、magType 和 alert)在数据框之间进行了合并。而数值类型的列(mag 和 tsunami)则进行了求和:

图 2.5 – 添加数据框
关于DataFrame对象以及可以直接对其执行的所有操作的更多信息,请参考官方文档:pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html;请务必将其添加书签以备将来参考。现在,我们已经准备好开始学习如何从各种来源创建DataFrame对象。
创建 pandas DataFrame
现在我们已经了解了将要使用的数据结构,接下来可以讨论创建它们的不同方式。然而,在深入代码之前,了解如何直接从 Python 获取帮助是非常重要的。如果我们在使用 Python 时遇到不确定的地方,可以使用内置的help()函数。我们只需要运行help(),并传入我们想查看文档的包、模块、类、对象、方法或函数。当然,我们也可以在线查找文档;然而,在大多数情况下,help()与在线文档是等效的,因为它们用于生成文档。
假设我们首先运行了import pandas as pd,然后可以运行help(pd)来显示有关pandas包的信息;help(pd.DataFrame)来查看所有关于DataFrame对象的方法和属性(注意,我们也可以传入一个DataFrame对象);help(pd.read_csv)以了解有关pandas读取 CSV 文件到 Python 中的函数及其使用方法。我们还可以尝试使用dir()函数和__dict__属性,它们将分别为我们提供可用项的列表或字典;不过,它们可能没有help()函数那么有用。
此外,我们还可以使用?和??来获取帮助,这得益于 IPython,它是 Jupyter Notebooks 强大功能的一部分。与help()函数不同,我们可以在想要了解更多的内容后加上问号,就像在问 Python 一个问题一样;例如,pd.read_csv?和pd.read_csv??。这三者会输出略有不同的信息:help()会提供文档字符串;?会提供文档字符串,并根据我们的查询增加一些附加信息;而??会提供更多信息,且在可能的情况下,还会显示源代码。
现在,让我们转到下一个笔记本文件2-creating_dataframes.ipynb,并导入我们即将使用的包。我们将使用 Python 标准库中的datetime,以及第三方包numpy和pandas:
>>> import datetime as dt
>>> import numpy as np
>>> import pandas as pd
重要提示
我们通过将pandas包引入并为其指定别名pd,这是导入pandas最常见的方式。事实上,我们只能用pd来引用它,因为那是我们导入到命名空间中的别名。包需要在使用之前导入;安装将所需的文件放在我们的计算机上,但为了节省内存,Python 不会在启动时加载所有已安装的包——只有我们明确告诉它加载的包。
现在我们已经准备好开始使用pandas了。首先,我们将学习如何从其他 Python 对象创建pandas对象。接着,我们将学习如何从平面文件、数据库中的表格以及 API 请求的响应中创建pandas对象。
从 Python 对象
在讲解如何从 Python 对象创建DataFrame对象的所有方法之前,我们应该先了解如何创建Series对象。记住,Series对象本质上是DataFrame对象中的一列,因此,一旦我们掌握了这一点,理解如何创建DataFrame对象应该就不难了。假设我们想创建一个包含五个介于0和1之间的随机数的序列,我们可以使用 NumPy 生成随机数数组,并从中创建序列。
提示
NumPy 使得生成数值数据变得非常简单。除了生成随机数外,我们还可以使用np.linspace()函数在某个范围内生成均匀分布的数值;使用np.arange()函数获取一系列整数;使用np.random.normal()函数从标准正态分布中抽样;以及使用np.zeros()函数轻松创建全零数组,使用np.ones()函数创建全一数组。本书中我们将会一直使用 NumPy。
为了确保结果是可重复的,我们将在这里设置种子。任何具有类似列表结构的Series对象(例如 NumPy 数组):
>>> np.random.seed(0) # set a seed for reproducibility
>>> pd.Series(np.random.rand(5), name='random')
0 0.548814
1 0.715189
2 0.602763
3 0.544883
4 0.423655
Name: random, dtype: float64
创建DataFrame对象是创建Series对象的扩展;它由一个或多个系列组成,每个系列都会有不同的名称。这让我们联想到 Python 中的字典结构:键是列名,值是列的内容。注意,如果我们想将一个单独的Series对象转换为DataFrame对象,可以使用它的to_frame()方法。
提示
在计算机科学中,__init__()方法。当我们运行pd.Series()时,Python 会调用pd.Series.__init__(),该方法包含实例化新Series对象的指令。我们将在第七章中进一步了解__init__()方法,金融分析 – 比特币与股票市场。
由于列可以是不同的数据类型,让我们通过这个例子来做一些有趣的事情。我们将创建一个包含三列、每列有五个观察值的DataFrame对象:
-
random:五个介于0和1之间的随机数,作为一个 NumPy 数组 -
text:一个包含五个字符串或None的列表 -
truth:一个包含五个随机布尔值的列表
我们还将使用pd.date_range()函数创建一个DatetimeIndex对象。该索引将包含五个日期(periods=5),日期之间相隔一天(freq='1D'),并以 2019 年 4 月 21 日(end)为结束日期,索引名称为date。请注意,关于pd.date_range()函数接受的频率值的更多信息,请参见pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#offset-aliases。
我们所需要做的,就是将列打包成字典,使用所需的列名作为键,并在调用pd.DataFrame()构造函数时传入该字典。索引通过index参数传递:
>>> np.random.seed(0) # set seed so result is reproducible
>>> pd.DataFrame(
... {
... 'random': np.random.rand(5),
... 'text': ['hot', 'warm', 'cool', 'cold', None],
... 'truth': [np.random.choice([True, False])
... for _ in range(5)]
... },
... index=pd.date_range(
... end=dt.date(2019, 4, 21),
... freq='1D', periods=5, name='date'
... )
... )
重要提示
按照约定,我们使用_来存放在循环中我们不关心的变量。在这里,我们使用range()作为计数器,其值不重要。有关_在 Python 中作用的更多信息,请参见hackernoon.com/understanding-the-underscore-of-python-309d1a029edc。
在索引中包含日期,使得通过日期(甚至日期范围)选择条目变得容易,正如我们在第三章《Pandas 数据处理》中将看到的那样:

图 2.6 – 从字典创建数据框
在数据不是字典而是字典列表的情况下,我们仍然可以使用pd.DataFrame()。这种格式的数据通常来自 API。当数据以这种格式时,列表中的每个条目将是一个字典,字典的键是列名,字典的值是该索引处该列的值:
>>> pd.DataFrame([
... {'mag': 5.2, 'place': 'California'},
... {'mag': 1.2, 'place': 'Alaska'},
... {'mag': 0.2, 'place': 'California'},
... ])
这将给我们一个包含三行(每个列表条目对应一行)和两列(每个字典的键对应一列)的数据框:

图 2.7 – 从字典列表创建数据框
事实上,pd.DataFrame()也适用于元组列表。注意,我们还可以通过columns参数将列名作为列表传入:
>>> list_of_tuples = [(n, n**2, n**3) for n in range(5)]
>>> list_of_tuples
[(0, 0, 0), (1, 1, 1), (2, 4, 8), (3, 9, 27), (4, 16, 64)]
>>> pd.DataFrame(
... list_of_tuples,
... columns=['n', 'n_squared', 'n_cubed']
... )
每个元组被当作记录处理,并成为数据框中的一行:

图 2.8 – 从元组列表创建数据框
我们还可以选择使用pd.DataFrame()与 NumPy 数组:
>>> pd.DataFrame(
... np.array([
... [0, 0, 0],
... [1, 1, 1],
... [2, 4, 8],
... [3, 9, 27],
... [4, 16, 64]
... ]), columns=['n', 'n_squared', 'n_cubed']
... )
这样会将数组中的每个条目按行堆叠到数据框中,得到的结果与图 2.8完全相同。
从文件
我们想要分析的数据大多数来自 Python 之外。在很多情况下,我们可能会从数据库或网站获得一个数据转储,然后将其带入 Python 进行筛选。数据转储之所以得名,是因为它包含大量数据(可能是非常详细的层次),且最初往往不加区分;因此,它们可能显得笨重。
通常,这些数据转储会以文本文件(.txt)或 CSV 文件(.csv)的形式出现。Pandas 提供了许多读取不同类型文件的方法,因此我们只需查找匹配我们文件格式的方法即可。我们的地震数据是 CSV 文件,因此我们使用pd.read_csv()函数来读取它。然而,在尝试读取之前,我们应始终先进行初步检查;这将帮助我们确定是否需要传递其他参数,比如sep来指定分隔符,或names来在文件没有表头行的情况下手动提供列名。
重要提示
Windows 用户:根据您的设置,接下来的代码块中的命令可能无法正常工作。如果遇到问题,笔记本中有替代方法。
我们可以直接在 Jupyter Notebook 中进行尽职调查,得益于 IPython,只需在命令前加上!,表示这些命令将作为 Shell 命令执行。首先,我们应该检查文件的大小,既要检查行数,也要检查字节数。要检查行数,我们使用wc工具(单词计数)并加上-l标志来计算行数。我们文件中有 9,333 行:
>>> !wc -l data/earthquakes.csv
9333 data/earthquakes.csv
现在,让我们检查一下文件的大小。为此,我们将使用ls命令查看data目录中的文件列表。我们可以添加-lh标志,以便以易于阅读的格式获取文件信息。最后,我们将此输出发送到grep工具,它将帮助我们筛选出我们想要的文件。这告诉我们,earthquakes.csv文件的大小为 3.4 MB:
>>> !ls -lh data | grep earthquakes.csv
-rw-r--r-- 1 stefanie stefanie 3.4M ... earthquakes.csv
请注意,IPython 还允许我们将命令的结果捕获到 Python 变量中,因此,如果我们不熟悉管道符(|)或grep,我们可以这样做:
>>> files = !ls -lh data
>>> [file for file in files if 'earthquake' in file]
['-rw-r--r-- 1 stefanie stefanie 3.4M ... earthquakes.csv']
现在,让我们看一下文件的顶部几行,看看文件是否包含表头。我们将使用head工具,并通过-n标志指定行数。这告诉我们,第一行包含数据的表头,并且数据是以逗号分隔的(仅仅因为文件扩展名是.csv并不意味着它是逗号分隔的):
>>> !head -n 2 data/earthquakes.csv
alert,cdi,code,detail,dmin,felt,gap,ids,mag,magType,mmi,net,nst,place,rms,sig,sources,status,time,title,tsunami,type,types,tz,updated,url
,,37389218,https://earthquake.usgs.gov/[...],0.008693,,85.0,",ci37389218,",1.35,ml,,ci,26.0,"9km NE of Aguanga, CA",0.19,28,",ci,",automatic,1539475168010,"M 1.4 - 9km NE of Aguanga, CA",0,earthquake,",geoserve,nearby-cities,origin,phase-data,",-480.0,1539475395144,https://earthquake.usgs.gov/earthquakes/eventpage/ci37389218
请注意,我们还应该检查文件的底部几行,以确保没有多余的数据需要通过tail工具忽略。这个文件没有问题,因此结果不会在此处重复;不过,笔记本中包含了结果。
最后,我们可能对查看数据中的列数感兴趣。虽然我们可以仅通过计算head命令结果的第一行中的字段数来实现,但我们也可以选择使用awk工具(用于模式扫描和处理)来计算列数。-F标志允许我们指定分隔符(在这种情况下是逗号)。然后,我们指定对文件中的每个记录执行的操作。我们选择打印NF,这是一个预定义变量,其值是当前记录中字段的数量。在这里,我们在打印之后立即使用exit,以便只打印文件中第一行的字段数,然后停止。这看起来有点复杂,但这绝不是我们需要记住的内容:
>>> !awk -F',' '{print NF; exit}' data/earthquakes.csv
26
由于我们知道文件的第一行包含标题,并且该文件是逗号分隔的,我们也可以通过使用head获取标题并用 Python 解析它们来计算列数:
>>> headers = !head -n 1 data/earthquakes.csv
>>> len(headers[0].split(','))
26
重要说明
直接在 Jupyter Notebook 中运行 Shell 命令极大地简化了我们的工作流程。然而,如果我们没有命令行的经验,最初学习这些命令可能会很复杂。IPython 的文档提供了一些关于运行 Shell 命令的有用信息,您可以在ipython.readthedocs.io/en/stable/interactive/reference.html#system-shell-access找到。
总结一下,我们现在知道文件大小为 3.4MB,使用逗号分隔,共有 26 列和 9,333 行,第一行是标题。这意味着我们可以使用带有默认设置的pd.read_csv()函数:
>>> df = pd.read_csv('earthquakes.csv')
请注意,我们不仅仅局限于从本地机器上的文件读取数据;文件路径也可以是 URL。例如,我们可以从 GitHub 读取相同的 CSV 文件:
>>> df = pd.read_csv(
... 'https://github.com/stefmolin/'
... 'Hands-On-Data-Analysis-with-Pandas-2nd-edition'
... '/blob/master/ch_02/data/earthquakes.csv?raw=True'
... )
Pandas 通常非常擅长根据输入数据自动判断需要使用的选项,因此我们通常不需要为此调用添加额外的参数;然而,若有需要,仍有许多选项可以使用,其中包括以下几种:

图 2.9 – 读取文件时有用的参数
本书中,我们将处理 CSV 文件;但请注意,我们也可以使用read_excel()函数读取 Excel 文件,使用read_json()函数读取json文件,或者使用带有sep参数的read_csv()函数来处理不同的分隔符。
如果我们不学习如何将数据框保存到文件中,以便与他人分享,那将是失职。为了将数据框写入 CSV 文件,我们调用其to_csv()方法。在这里我们必须小心;如果数据框的索引只是行号,我们可能不想将其写入文件(对数据的使用者没有意义),但这是默认设置。我们可以通过传入index=False来写入不包含索引的数据:
>>> df.to_csv('output.csv', index=False)
与从文件中读取数据一样,Series 和 DataFrame 对象也有方法将数据写入 Excel(to_excel())和 JSON 文件(to_json())。请注意,虽然我们使用 pandas 中的函数来读取数据,但我们必须使用方法来写入数据;读取函数创建了我们想要处理的 pandas 对象,而写入方法则是我们使用 pandas 对象执行的操作。
提示
上述读取和写入的文件路径是 /home/myuser/learning/hands_on_pandas/data.csv,而我们当前的工作目录是 /home/myuser/learning/hands_on_pandas,因此我们可以简单地使用 data.csv 的相对路径作为文件路径。
Pandas 提供了从许多其他数据源读取和写入的功能,包括数据库,我们接下来会讨论这些内容;pickle 文件(包含序列化的 Python 对象——有关更多信息,请参见 进一步阅读 部分);以及 HTML 页面。请务必查看 pandas 文档中的以下资源,以获取完整的功能列表:pandas.pydata.org/pandas-docs/stable/user_guide/io.html。
从数据库中读取
Pandas 可以与 SQLite 数据库进行交互,而无需安装任何额外的软件包;不过,若要与其他类型的数据库进行交互,则需要安装 SQLAlchemy 包。与 SQLite 数据库的交互可以通过使用 Python 标准库中的 sqlite3 模块打开数据库连接来实现,然后使用 pd.read_sql() 函数查询数据库,或在 DataFrame 对象上使用 to_sql() 方法将数据写入数据库。
在我们从数据库中读取数据之前,先来写入数据。我们只需在我们的 DataFrame 上调用 to_sql(),并告诉它要写入哪个表,使用哪个数据库连接,以及如果表已存在该如何处理。本书 GitHub 仓库中的这一章节文件夹里已经有一个 SQLite 数据库:data/quakes.db。请注意,要创建一个新的数据库,我们可以将 'data/quakes.db' 更改为新数据库文件的路径。现在让我们把 data/tsunamis.csv 文件中的海啸数据写入名为 tsunamis 的数据库表中,如果表已存在,则替换它:
>>> import sqlite3
>>> with sqlite3.connect('data/quakes.db') as connection:
... pd.read_csv('data/tsunamis.csv').to_sql(
... 'tsunamis', connection, index=False,
... if_exists='replace'
... )
查询数据库与写入数据库一样简单。请注意,这需要了解 pandas 与 SQL 的对比关系,并且可以参考 第四章,聚合 Pandas DataFrames,了解一些 pandas 操作与 SQL 语句的关系示例。
让我们查询数据库中的完整tsunamis表。当我们编写 SQL 查询时,首先声明我们要选择的列,在本例中是所有列,因此我们写"SELECT *"。接下来,我们声明要从哪个表中选择数据,在我们这里是tsunamis,因此我们写"FROM tsunamis"。这就是我们完整的查询(当然,它可以比这更复杂)。要实际查询数据库,我们使用pd.read_sql(),传入查询和数据库连接:
>>> import sqlite3
>>> with sqlite3.connect('data/quakes.db') as connection:
... tsunamis = \
... pd.read_sql('SELECT * FROM tsunamis', connection)
>>> tsunamis.head()
我们现在在数据框中已经有了海啸数据:

图 2.10 – 从数据库读取数据
重要说明
我们在两个代码块中创建的connection对象是with语句的一个示例,自动在代码块执行后进行清理(在本例中是关闭连接)。这使得清理工作变得简单,并确保我们不会留下任何未完成的工作。一定要查看标准库中的contextlib,它提供了使用with语句和上下文管理器的工具。文档请参考 docs.python.org/3/library/contextlib.html。
来自 API
我们现在可以轻松地从 Python 中的数据或从获得的文件中创建Series和DataFrame对象,但如何从在线资源(如 API)获取数据呢?无法保证每个数据源都会以相同的格式提供数据,因此我们必须在方法上保持灵活,并能够检查数据源以找到合适的导入方法。在本节中,我们将从 USGS API 请求一些地震数据,并查看如何从结果中创建数据框。在第三章《使用 Pandas 进行数据清理》中,我们将使用另一个 API 收集天气数据。
在本节中,我们将在3-making_dataframes_from_api_requests.ipynb笔记本中工作,因此我们需要再次导入所需的包。与之前的笔记本一样,我们需要pandas和datetime,但我们还需要requests包来发起 API 请求:
>>> import datetime as dt
>>> import pandas as pd
>>> import requests
接下来,我们将向 USGS API 发起GET请求,获取一个 JSON 负载(包含请求或响应数据的类似字典的响应),并指定geojson格式。我们将请求过去 30 天的地震数据(可以使用dt.timedelta对datetime对象进行运算)。请注意,我们将yesterday作为日期范围的结束日期,因为 API 尚未提供今天的完整数据:
>>> yesterday = dt.date.today() - dt.timedelta(days=1)
>>> api = 'https://earthquake.usgs.gov/fdsnws/event/1/query'
>>> payload = {
... 'format': 'geojson',
... 'starttime': yesterday - dt.timedelta(days=30),
... 'endtime': yesterday
... }
>>> response = requests.get(api, params=payload)
重要说明
GET 是一种 HTTP 方法。这个操作告诉服务器我们想要读取一些数据。不同的 API 可能要求我们使用不同的方法来获取数据;有些会要求我们发送 POST 请求,在其中进行身份验证。你可以在nordicapis.com/ultimate-guide-to-all-9-standard-http-methods/上了解更多关于 API 请求和 HTTP 方法的信息。
在我们尝试从中创建 dataframe 之前,应该先确认我们的请求是否成功。我们可以通过检查response对象的status_code属性来做到这一点。状态码及其含义的列表可以在en.wikipedia.org/wiki/List_of_HTTP_status_codes找到。200响应将表示一切正常:
>>> response.status_code
200
我们的请求成功了,接下来让我们看看我们得到的数据是什么样的。我们请求了一个 JSON 负载,它本质上是一个字典,因此我们可以使用字典方法来获取更多关于它结构的信息。这将是大量的数据;因此,我们不想只是将它打印到屏幕上进行检查。我们需要从 HTTP 响应(存储在response变量中)中提取 JSON 负载,然后查看键以查看结果数据的主要部分:
>>> earthquake_json = response.json()
>>> earthquake_json.keys()
dict_keys(['type', 'metadata', 'features', 'bbox'])
我们可以检查这些键对应的值是什么样的数据;其中一个将是我们需要的数据。metadata部分告诉我们一些关于请求的信息。虽然这些信息确实有用,但它不是我们现在需要的:
>>> earthquake_json['metadata']
{'generated': 1604267813000,
'url': 'https://earthquake.usgs.gov/fdsnws/event/1/query?
format=geojson&starttime=2020-10-01&endtime=2020-10-31',
'title': 'USGS Earthquakes',
'status': 200,
'api': '1.10.3',
'count': 13706}
features 键看起来很有前景;如果它确实包含了我们所有的数据,我们应该检查它的数据类型,以避免试图将所有内容打印到屏幕上:
>>> type(earthquake_json['features'])
list
这个键包含一个列表,所以让我们查看第一个条目,看看这是不是我们想要的数据。请注意,USGS 数据可能会随着更多关于地震信息的披露而被修改或添加,因此查询相同的日期范围可能会得到不同数量的结果。基于这个原因,以下是一个条目的示例:
>>> earthquake_json['features'][0]
{'type': 'Feature',
'properties': {'mag': 1,
'place': '50 km ENE of Susitna North, Alaska',
'time': 1604102395919, 'updated': 1604103325550, 'tz': None,
'url': 'https://earthquake.usgs.gov/earthquakes/eventpage/ak020dz5f85a',
'detail': 'https://earthquake.usgs.gov/fdsnws/event/1/query?eventid=ak020dz5f85a&format=geojson',
'felt': None, 'cdi': None, 'mmi': None, 'alert': None,
'status': 'reviewed', 'tsunami': 0, 'sig': 15, 'net': 'ak',
'code': '020dz5f85a', 'ids': ',ak020dz5f85a,',
'sources': ',ak,', 'types': ',origin,phase-data,',
'nst': None, 'dmin': None, 'rms': 1.36, 'gap': None,
'magType': 'ml', 'type': 'earthquake',
'title': 'M 1.0 - 50 km ENE of Susitna North, Alaska'},
'geometry': {'type': 'Point', 'coordinates': [-148.9807, 62.3533, 5]},
'id': 'ak020dz5f85a'}
这绝对是我们需要的数据,但我们需要全部数据吗?仔细检查后,我们只关心properties字典中的内容。现在,我们面临一个问题,因为我们有一个字典的列表,而我们只需要从中提取一个特定的键。我们该如何提取这些信息,以便构建我们的 dataframe 呢?我们可以使用列表推导式从features列表中的每个字典中隔离出properties部分:
>>> earthquake_properties_data = [
... quake['properties']
... for quake in earthquake_json['features']
... ]
最后,我们准备创建我们的 dataframe。Pandas 已经知道如何处理这种格式的数据(字典列表),因此我们只需要在调用pd.DataFrame()时传入数据:
>>> df = pd.DataFrame(earthquake_properties_data)
现在我们知道如何从各种数据源创建 dataframes,我们可以开始学习如何操作它们。
检查一个 DataFrame 对象
我们读取数据时应该做的第一件事就是检查它;我们需要确保数据框不为空,并且行数据符合预期。我们的主要目标是验证数据是否正确读取,并且所有数据都存在;然而,这次初步检查还会帮助我们了解应将数据处理工作重点放在哪里。在本节中,我们将探索如何在4-inspecting_dataframes.ipynb笔记本中检查数据框。
由于这是一个新笔记本,我们必须再次处理设置。此次,我们需要导入pandas和numpy,并读取包含地震数据的 CSV 文件:
>>> import numpy as np
>>> import pandas as pd
>>> df = pd.read_csv('data/earthquakes.csv')
检查数据
首先,我们要确保数据框中确实有数据。我们可以检查empty属性来了解情况:
>>> df.empty
False
到目前为止,一切顺利;我们有数据。接下来,我们应检查读取了多少数据;我们想知道观察数(行数)和变量数(列数)。为此,我们使用shape属性。我们的数据包含 9,332 个观察值和 26 个变量,这与我们最初检查文件时的结果一致:
>>> df.shape
(9332, 26)
现在,让我们使用columns属性查看数据集中列的名称:
>>> df.columns
Index(['alert', 'cdi', 'code', 'detail', 'dmin', 'felt', 'gap',
'ids', 'mag', 'magType', 'mmi', 'net', 'nst', 'place',
'rms', 'sig', 'sources', 'status', 'time', 'title',
'tsunami', 'type', 'types', 'tz', 'updated', 'url'],
dtype='object')
重要提示
拥有列的列表并不意味着我们知道每一列的含义。特别是在数据来自互联网的情况下,在得出结论之前,务必查阅列的含义。有关geojson格式中字段的信息,包括每个字段在 JSON 负载中的含义(以及一些示例值),可以在美国地质调查局(USGS)网站上的earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php找到。
我们知道数据的维度,但它实际是什么样的呢?为此,我们可以使用head()和tail()方法,分别查看顶部和底部的行。默认情况下,这将显示五行数据,但我们可以通过传入不同的数字来更改这一设置。让我们看看前几行数据:
>>> df.head()
以下是我们使用head()方法获得的前五行:

图 2.11 – 检查数据框的前五行
要获取最后两行,我们使用tail()方法并传入2作为行数:
>>> df.tail(2)
以下是结果:

图 2.12 – 检查数据框的底部两行
提示
默认情况下,当我们在 Jupyter Notebook 中打印包含许多列的数据框时,只有一部分列会显示出来。这是因为pandas有一个显示列数的限制。我们可以使用pd.set_option('display.max_columns', <new_value>)来修改此行为。有关更多信息,请查阅pandas.pydata.org/pandas-docs/stable/user_guide/options.html。该文档中还包含了一些示例命令。
我们可以使用dtypes属性查看各列的数据类型,这样可以轻松地发现哪些列被错误地存储为不正确的类型。(记住,字符串会被存储为object。)这里,time列被存储为整数,这是我们将在第三章《数据清洗与 Pandas》中学习如何修复的问题:
>>> df.dtypes
alert object
...
mag float64
magType object
...
time int64
title object
tsunami int64
...
tz float64
updated int64
url object
dtype: object
最后,我们可以使用info()方法查看每列中有多少非空条目,并获取关于索引的信息。pandas通常会将对象类型的值表示为None,而NaN(float或integer类型的列)表示缺失值:
>>> df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 9332 entries, 0 to 9331
Data columns (total 26 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 alert 59 non-null object
...
8 mag 9331 non-null float64
9 magType 9331 non-null object
...
18 time 9332 non-null int64
19 title 9332 non-null object
20 tsunami 9332 non-null int64
...
23 tz 9331 non-null float64
24 updated 9332 non-null int64
25 url 9332 non-null object
dtypes: float64(9), int64(4), object(13)
memory usage: 1.9+ MB
在初步检查之后,我们已经了解了数据的结构,现在可以开始尝试理解数据的含义。
描述和总结数据
到目前为止,我们已经检查了从地震数据创建的DataFrame对象的结构,但除了几行数据的样子,我们对数据一无所知。接下来的步骤是计算总结统计数据,这将帮助我们更好地了解数据。Pandas 提供了几种方法来轻松实现这一点;其中一种方法是describe(),如果我们只对某一列感兴趣,它也适用于Series对象。让我们获取数据中数字列的总结:
>>> df.describe()
这会为我们提供 5 个数字总结,以及数字列的计数、均值和标准差:

图 2.13 – 计算总结统计数据
提示
如果我们想要不同的百分位数,可以通过percentiles参数传递它们。例如,如果我们只想要 5%和 95%的百分位数,我们可以运行df.describe(percentiles=[0.05, 0.95])。请注意,我们仍然会得到第 50 个百分位数的结果,因为那是中位数。
默认情况下,describe()不会提供关于object类型列的任何信息,但我们可以提供include='all'作为参数,或者单独运行它来查看np.object类型的数据:
>>> df.describe(include=np.object)
当描述非数字数据时,我们仍然可以得到非空出现的计数(count);然而,除了其他总结统计数据外,我们会得到唯一值的数量(unique)、众数(top)以及众数出现的次数(freq):

图 2.14 – 类别列的总结统计数据
重要提示
describe() 方法只会为非空值提供摘要统计信息。这意味着,如果我们有 100 行数据,其中一半是空值,那么平均值将是 50 个非空行的总和除以 50。
使用 describe() 方法可以轻松获取数据的快照,但有时我们只想要某个特定的统计数据,不论是针对某一列还是所有列。Pandas 也使得这变得非常简单。下表列出了适用于 Series 和 DataFrame 对象的方法:

Figure 2.15 – 对系列和数据框架的有用计算方法
提示
Python 使得计算某个条件为 True 的次数变得容易。在底层,True 计算为 1,False 计算为 0。因此,我们可以对布尔值序列运行 sum() 方法,得到 True 输出的计数。
对于 Series 对象,我们有一些额外的方法来描述我们的数据:
-
unique(): 返回列中的不同值。 -
value_counts(): 返回给定列中每个唯一值出现的频率表,或者,当传入normalize=True时,返回每个唯一值出现的百分比。 -
mode(): 返回列中最常见的值。
查阅 USGS API 文档中的 alert 字段(可以在 earthquake.usgs.gov/data/comcat/data-eventterms.php#alert 找到)告诉我们,alert 字段的值可以是 'green'、'yellow'、'orange' 或 'red'(当字段被填充时),并且 alert 列中的警报级别是两个唯一值的字符串,其中最常见的值是 'green',但也有许多空值。那么,另一个唯一值是什么呢?
>>> df.alert.unique()
array([nan, 'green', 'red'], dtype=object)
现在我们了解了该字段的含义以及数据中包含的值,我们预计 'green' 的数量会远远大于 'red';我们可以通过使用 value_counts() 来检查我们的直觉,得到一个频率表。注意,我们只会得到非空条目的计数:
>>> df.alert.value_counts()
green 58
red 1
Name: alert, dtype: int64
请注意,Index 对象也有多个方法,能够帮助我们描述和总结数据:

Figure 2.16 – 对索引的有用方法
当我们使用 unique() 和 value_counts() 时,我们已经预览了如何选择数据的子集。现在,让我们更详细地讨论选择、切片、索引和过滤。
获取数据的子集
到目前为止,我们已经学习了如何处理和总结整个数据;然而,我们通常会对对数据子集进行操作和/或分析感兴趣。我们可能希望从数据中提取许多类型的子集,比如选择特定的列或行,或者当满足特定条件时选择某些列或行。为了获取数据的子集,我们需要熟悉选择、切片、索引和过滤等操作。
在本节中,我们将在5-subsetting_data.ipynb笔记本中进行操作。我们的设置如下:
>>> import pandas as pd
>>> df = pd.read_csv('data/earthquakes.csv')
选择列
在前一部分,我们看到了列选择的例子,当时我们查看了alert列中的唯一值;我们作为数据框的属性访问了这个列。记住,列是一个Series对象,因此,例如,选择地震数据中的mag列将给我们返回一个包含地震震级的Series对象:
>>> df.mag
0 1.35
1 1.29
2 3.42
3 0.44
4 2.16
...
9327 0.62
9328 1.00
9329 2.40
9330 1.10
9331 0.66
Name: mag, Length: 9332, dtype: float64
Pandas 为我们提供了几种选择列的方法。使用字典式的符号来选择列是替代属性符号选择列的一种方法:
>>> df['mag']
0 1.35
1 1.29
2 3.42
3 0.44
4 2.16
...
9327 0.62
9328 1.00
9329 2.40
9330 1.10
9331 0.66
Name: mag, Length: 9332, dtype: float64
提示
我们还可以使用get()方法来选择列。这样做的好处是,如果列不存在,不会抛出错误,而且可以提供一个备选值,默认值是None。例如,如果我们调用df.get('event', False),它将返回False,因为我们没有event列。
请注意,我们并不局限于一次只选择一列。通过将列表传递给字典查找,我们可以选择多列,从而获得一个DataFrame对象,它是原始数据框的一个子集:
>>> df[['mag', 'title']]
这样我们就得到了来自原始数据框的完整mag和title列:

图 2.17 – 选择数据框的多列
字符串方法是选择列的一种非常强大的方式。例如,如果我们想选择所有以mag开头的列,并同时选择title和time列,我们可以这样做:
>>> df[
... ['title', 'time']
... + [col for col in df.columns if col.startswith('mag')]
... ]
我们得到了一个由四列组成的数据框,这些列符合我们的筛选条件。注意,返回的列顺序是我们要求的顺序,而不是它们最初出现的顺序。这意味着如果我们想要重新排序列,所要做的就是按照希望的顺序选择它们:

图 2.18 – 根据列名选择列
让我们来分析这个例子。我们使用列表推导式遍历数据框中的每一列,只保留那些列名以mag开头的列:
>>> [col for col in df.columns if col.startswith('mag')]
['mag', 'magType']
然后,我们将这个结果与另外两个我们想要保留的列(title和time)合并:
>>> ['title', 'time'] \
... + [col for col in df.columns if col.startswith('mag')]
['title', 'time', 'mag', 'magType']
最后,我们能够使用这个列表在数据框上执行实际的列选择操作,最终得到了图 2.18中的数据框:
>>> df[
... ['title', 'time']
... + [col for col in df.columns if col.startswith('mag')]
... ]
提示
字符串方法的完整列表可以在 Python 3 文档中找到:docs.python.org/3/library/stdtypes.html#string-methods。
切片
当我们想要从数据框中提取特定的行(切片)时,我们使用DataFrame切片,切片的方式与其他 Python 对象(如列表和元组)类似,第一个索引是包含的,最后一个索引是不包含的:
>>> df[100:103]
当指定切片100:103时,我们会返回行100、101和102:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_2.19_B16834.jpg)
图 2.19 – 切片数据框以提取特定行
我们可以通过使用链式操作来结合行和列的选择:
>>> df[['title', 'time']][100:103]
首先,我们选择了所有行中的title和time列,然后提取了索引为100、101和102的行:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_2.20_B16834.jpg)
图 2.20 – 使用链式操作选择特定行和列
在前面的例子中,我们选择了列,然后切片了行,但顺序并不重要:
>>> df[100:103][['title', 'time']].equals(
... df[['title', 'time']][100:103]
... )
True
提示
请注意,我们可以对索引中的任何内容进行切片;然而,确定我们想要的最后一个字符串或日期后面的内容会很困难,因此在使用pandas时,切片日期和字符串的方式与整数切片不同,并且包含两个端点。只要我们提供的字符串可以解析为datetime对象,日期切片就能正常工作。在第三章《使用 Pandas 进行数据清洗》中,我们将看到一些相关示例,并学习如何更改作为索引的内容,从而使这种类型的切片成为可能。
如果我们决定使用链式操作来更新数据中的值,我们会发现pandas会抱怨我们没有正确执行(即使它能正常工作)。这是在提醒我们,使用顺序选择来设置数据可能不会得到我们预期的结果。(更多信息请参见pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy。)
让我们触发这个警告,以便更好地理解它。我们将尝试更新一些地震事件的title列,使其变为小写:
>>> df[110:113]['title'] = df[110:113]['title'].str.lower()
/.../book_env/lib/python3.7/[...]:1: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
"""Entry point for launching an IPython kernel.
正如警告所示,成为一个有效的pandas用户,不仅仅是知道如何选择和切片—我们还必须掌握索引。由于这只是一个警告,我们的值已经更新,但这并不总是如此:
>>> df[110:113]['title']
110 m 1.1 - 35km s of ester, alaska
111 m 1.9 - 93km wnw of arctic village, alaska
112 m 0.9 - 20km wsw of smith valley, nevada
Name: title, dtype: object
现在,让我们讨论如何使用索引正确设置值。
索引
Pandas 的索引操作为我们提供了一种单一方法,来选择我们想要的行和列。我们可以使用loc[]和iloc[],分别通过标签或整数索引来选择数据子集。记住它们的区别的好方法是将它们想象为location(位置)与integer location(整数位置)。对于所有的索引方法,我们先提供行索引器,再提供列索引器,两者之间用逗号分隔:
df.loc[row_indexer, column_indexer]
注意,使用loc[]时,如警告信息所示,我们不再触发pandas的任何警告。我们还将结束索引从113改为112,因为loc[]是包含端点的:
>>> df.loc[110:112, 'title'] = \
... df.loc[110:112, 'title'].str.lower()
>>> df.loc[110:112, 'title']
110 m 1.1 - 35km s of ester, alaska
111 m 1.9 - 93km wnw of arctic village, alaska
112 m 0.9 - 20km wsw of smith valley, nevada
Name: title, dtype: object
如果我们使用:作为行(列)索引器,就可以选择所有的行(列),就像普通的 Python 切片一样。让我们使用loc[]选择title列的所有行:
>>> df.loc[:,'title']
0 M 1.4 - 9km NE of Aguanga, CA
1 M 1.3 - 9km NE of Aguanga, CA
2 M 3.4 - 8km NE of Aguanga, CA
3 M 0.4 - 9km NE of Aguanga, CA
4 M 2.2 - 10km NW of Avenal, CA
...
9327 M 0.6 - 9km ENE of Mammoth Lakes, CA
9328 M 1.0 - 3km W of Julian, CA
9329 M 2.4 - 35km NNE of Hatillo, Puerto Rico
9330 M 1.1 - 9km NE of Aguanga, CA
9331 M 0.7 - 9km NE of Aguanga, CA
Name: title, Length: 9332, dtype: object
我们可以同时选择多行和多列,使用loc[]:
>>> df.loc[10:15, ['title', 'mag']]
这让我们仅选择10到15行的title和mag列:

图 2.21 – 使用索引选择特定的行和列
如我们所见,使用loc[]时,结束索引是包含的。但iloc[]则不是这样:
>>> df.iloc[10:15, [19, 8]]
观察我们如何需要提供一个整数列表来选择相同的列;这些是列的编号(从0开始)。使用iloc[]时,我们丢失了索引为15的行;这是因为iloc[]使用的整数切片在结束索引上是排除的,类似于 Python 切片语法:

图 2.22 – 通过位置选择特定的行和列
然而,我们并不限于只对行使用切片语法;列同样适用:
>>> df.iloc[10:15, 6:10]
通过切片,我们可以轻松地抓取相邻的行和列:

图 2.23 – 通过位置选择相邻行和列的范围
使用loc[]时,切片操作也可以在列名上进行。这给我们提供了多种实现相同结果的方式:
>>> df.iloc[10:15, 6:10].equals(df.loc[10:14, 'gap':'magType'])
True
要查找标量值,我们使用at[]和iat[],它们更快。让我们选择记录在索引为10的行中的地震幅度(mag列):
>>> df.at[10, 'mag']
0.5
"幅度"列的列索引为8;因此,我们也可以通过iat[]查找幅度:
>>> df.iat[10, 8]
0.5
到目前为止,我们已经学习了如何使用行/列名称和范围来获取数据子集,但如何只获取符合某些条件的数据呢?为此,我们需要学习如何过滤数据。
过滤
Pandas 为我们提供了几种过滤数据的方式,包括True/False值;pandas可以使用这些值来为我们选择适当的行/列。创建布尔掩码的方式几乎是无限的——我们只需要一些返回每行布尔值的代码。例如,我们可以查看mag列中震级大于 2 的条目:
>>> df.mag > 2
0 False
1 False
2 True
3 False
...
9328 False
9329 True
9330 False
9331 False
Name: mag, Length: 9332, dtype: bool
尽管我们可以在整个数据框上运行此操作,但由于我们的地震数据包含不同类型的列,这样做可能不太有用。然而,我们可以使用这种策略来获取一个子集,其中地震的震级大于或等于 7.0:
>>> df[df.mag >= 7.0]
我们得到的结果数据框只有两行:

图 2.24 – 使用布尔掩码过滤
不过,我们得到了很多不需要的列。我们本可以将列选择附加到最后一个代码片段的末尾;然而,loc[]同样可以处理布尔掩码:
>>> df.loc[
... df.mag >= 7.0,
... ['alert', 'mag', 'magType', 'title', 'tsunami', 'type']
... ]
以下数据框已经过滤,只包含相关列:

图 2.25 – 使用布尔掩码进行索引
我们也不局限于只使用一个条件。让我们筛选出带有红色警报和海啸的地震。为了组合多个条件,我们需要将每个条件用括号括起来,并使用&来要求两个条件都为真:
>>> df.loc[
... (df.tsunami == 1) & (df.alert == 'red'),
... ['alert', 'mag', 'magType', 'title', 'tsunami', 'type']
... ]
数据中只有一个地震满足我们的标准:

图 2.26 – 使用 AND 组合过滤条件
如果我们想要至少一个条件为真,则可以使用|:
>>> df.loc[
... (df.tsunami == 1) | (df.alert == 'red'),
... ['alert', 'mag', 'magType', 'title', 'tsunami', 'type']
... ]
请注意,这个过滤器要宽松得多,因为虽然两个条件都可以为真,但我们只要求其中一个为真:

图 2.27 – 使用 OR 组合过滤条件
重要提示
在创建布尔掩码时,我们必须使用位运算符(&、|、~)而不是逻辑运算符(and、or、not)。记住这一点的一个好方法是:我们希望对我们正在测试的系列中的每一项返回一个布尔值,而不是返回单一的布尔值。例如,在地震数据中,如果我们想选择震级大于 1.5 的行,那么我们希望每一行都有一个布尔值,表示该行是否应该被选中。如果我们只希望对数据得到一个单一的值,或许是为了总结它,我们可以使用any()/all()将布尔系列压缩成一个可以与逻辑运算符一起使用的布尔值。我们将在第四章《聚合 Pandas 数据框》中使用any()和all()方法。
在前面两个示例中,我们的条件涉及到相等性;然而,我们并不局限于此。让我们选择所有在阿拉斯加的地震数据,其中alert列具有非空值:
>>> df.loc[
... (df.place.str.contains('Alaska'))
... & (df.alert.notnull()),
... ['alert', 'mag', 'magType', 'title', 'tsunami', 'type']
... ]
所有阿拉斯加的地震,alert值为green,其中一些伴随有海啸,最大震级为 5.1:

图 2.28 – 使用非数字列创建布尔掩码
让我们来分解一下我们是如何得到这个的。Series对象有一些字符串方法,可以通过str属性访问。利用这一点,我们可以创建一个布尔掩码,表示place列中包含单词Alaska的所有行:
df.place.str.contains('Alaska')
为了获取alert列不为 null 的所有行,我们使用了Series对象的notnull()方法(这同样适用于DataFrame对象),以创建一个布尔掩码,表示alert列不为 null 的所有行:
df.alert.notnull()
提示
我们可以使用~,也称为True值和False的反转。所以,df.alert.notnull()和~df.alert.isnull()是等价的。
然后,像我们之前做的那样,我们使用&运算符将两个条件结合起来,完成我们的掩码:
(df.place.str.contains('Alaska')) & (df.alert.notnull())
请注意,我们不仅限于检查每一行是否包含文本;我们还可以使用正则表达式。r字符出现在引号外面;这样,Python 就知道这是一个\)字符,而不是在尝试转义紧随其后的字符(例如,当我们使用\n表示换行符时,而不是字母n)。这使得它非常适合与正则表达式一起使用。Python 标准库中的re模块(docs.python.org/3/library/re.html)处理正则表达式操作;然而,pandas允许我们直接使用正则表达式。
使用正则表达式,让我们选择所有震级至少为 3.8 的加利福尼亚地震。我们需要选择place列中以CA或California结尾的条目,因为数据不一致(我们将在下一节中学习如何解决这个问题)。$字符表示结束,'CA$'给我们的是以CA结尾的条目,因此我们可以使用'CA|California$'来获取以任一项结尾的条目:
>>> df.loc[
... (df.place.str.contains(r'CA|California$'))
... & (df.mag > 3.8),
... ['alert', 'mag', 'magType', 'title', 'tsunami', 'type']
... ]
在我们研究的时间段内,加利福尼亚只有两次震级超过 3.8 的地震:

图 2.29 – 使用正则表达式进行过滤
提示
正则表达式功能非常强大,但不幸的是,也很难正确编写。通常,抓取一些示例行进行解析并使用网站测试它们会很有帮助。请注意,正则表达式有很多种类型,因此务必选择 Python 类型。这个网站支持 Python 类型的正则表达式,并且还提供了一个不错的备忘单: https://regex101.com/。
如果我们想获取震级在 6.5 和 7.5 之间的所有地震怎么办?我们可以使用两个布尔掩码——一个检查震级是否大于或等于 6.5,另一个检查震级是否小于或等于 7.5——然后用 & 运算符将它们结合起来。幸运的是,pandas 使得创建这种类型的掩码变得更容易,它提供了 between() 方法:
>>> df.loc[
... df.mag.between(6.5, 7.5),
... ['alert', 'mag', 'magType', 'title', 'tsunami', 'type']
... ]
结果包含所有震级在 [6.5, 7.5] 范围内的地震——默认情况下包括两个端点,但我们可以传入 inclusive=False 来更改这一点:

图 2.30 – 使用数值范围进行过滤
我们可以使用 isin() 方法创建一个布尔掩码,用于匹配某个值是否出现在值列表中。这意味着我们不必为每个可能匹配的值编写一个掩码,然后使用 | 将它们连接起来。让我们利用这一点来过滤 magType 列,这一列表示用于量化地震震级的测量方法。我们将查看使用 mw 或 mwb 震级类型测量的地震:
>>> df.loc[
... df.magType.isin(['mw', 'mwb']),
... ['alert', 'mag', 'magType', 'title', 'tsunami', 'type']
... ]
我们有两个震级采用 mwb 测量类型的地震,四个震级采用 mw 测量类型的地震:

图 2.31 – 使用列表中的成员关系进行过滤
到目前为止,我们一直在基于特定的值进行过滤,但假设我们想查看最低震级和最高震级地震的所有数据。与其先找到 mag 列的最小值和最大值,再创建布尔掩码,不如让 pandas 给我们这些值出现的索引,并轻松地过滤出完整的行。我们可以分别使用 idxmin() 和 idxmax() 来获取最小值和最大值的索引。让我们抓取最低震级和最高震级地震的行号:
>>> [df.mag.idxmin(), df.mag.idxmax()]
[2409, 5263]
我们可以使用这些索引来抓取相应的行:
>>> df.loc[
... [df.mag.idxmin(), df.mag.idxmax()],
... ['alert', 'mag', 'magType', 'title', 'tsunami', 'type']
... ]
最小震级的地震发生在阿拉斯加,最大震级的地震发生在印度尼西亚,并伴随海啸。我们将在 第五章,《使用 Pandas 和 Matplotlib 可视化数据》,以及 第六章,《使用 Seaborn 绘图与自定义技术》中讨论印度尼西亚的地震:

图 2.32 – 过滤以隔离包含列的最小值和最大值的行
重要说明
请注意,filter() 方法并不是像我们在本节中所做的那样根据值来过滤数据;相反,它可以根据行或列的名称来子集化数据。有关 DataFrame 和 Series 对象的示例,请参见笔记本。
添加和移除数据
在前面的章节中,我们经常选择列的子集,但如果某些列/行对我们不有用,我们应该直接删除它们。我们也常常根据mag列的值来选择数据;然而,如果我们创建了一个新列,用于存储布尔值以便后续选择,那么我们只需要计算一次掩码。非常少情况下,我们会遇到既不想添加也不想删除数据的情况。
在我们开始添加和删除数据之前,理解一个重要概念非常关键:虽然大多数方法会返回一个新的DataFrame对象,但有些方法是就地修改数据的。如果我们编写一个函数,传入一个数据框并修改它,那么它也会改变原始的数据框。如果我们遇到这种情况,即不想改变原始数据,而是希望返回一个已经修改过的数据副本,那么我们必须在做任何修改之前确保复制我们的数据框:
df_to_modify = df.copy()
重要提示
默认情况下,df.copy()会创建一个deep=False的浅拷贝,对浅拷贝的修改会影响原数据框,反之亦然。我们通常希望使用深拷贝,因为我们可以修改深拷贝而不影响原始数据。更多信息可以参考文档:pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.copy.html。
现在,让我们转向最后一个笔记本6-adding_and_removing_data.ipynb,并为本章剩余部分做准备。我们将再次使用地震数据,但这次我们只读取一部分列:
>>> import pandas as pd
>>> df = pd.read_csv(
... 'data/earthquakes.csv',
... usecols=[
... 'time', 'title', 'place', 'magType',
... 'mag', 'alert', 'tsunami'
... ]
... )
创建新数据
创建新列可以通过与变量赋值相同的方式来实现。例如,我们可以创建一列来表示数据的来源;由于我们所有的数据都来自同一来源,我们可以利用广播将这一列的每一行都设置为相同的值:
>>> df['source'] = 'USGS API'
>>> df.head()
新列被创建在原始列的右侧,并且每一行的值都是USGS API:


图 2.33 – 添加新列
重要提示
我们不能通过属性符号(df.source)创建新列,因为数据框还没有这个属性,因此必须使用字典符号(df['source'])。
我们不仅仅限于将一个值广播到整列;我们可以让这一列存储布尔逻辑结果或数学公式。例如,如果我们有关于距离和时间的数据,我们可以创建一列速度,它是通过将距离列除以时间列得到的结果。在我们的地震数据中,我们可以创建一列,告诉我们地震的震级是否为负数:
>>> df['mag_negative'] = df.mag < 0
>>> df.head()
请注意,新列已添加到右侧:


图 2.34 – 在新列中存储布尔掩码
在前一部分中,我们看到place列存在一些数据一致性问题——同一个实体有多个名称。在某些情况下,加利福尼亚的地震标记为CA,而在其他情况下标记为California。不言而喻,这会引起混淆,如果我们没有仔细检查数据,可能会导致问题。例如,仅选择CA时,我们错过了 124 个标记为California的地震。这并不是唯一存在问题的地方(Nevada和NV也都有)。通过使用正则表达式提取place列中逗号后的所有内容,我们可以亲眼看到一些问题:
>>> df.place.str.extract(r', (.*$)')[0].sort_values().unique()
array(['Afghanistan', 'Alaska', 'Argentina', 'Arizona',
'Arkansas', 'Australia', 'Azerbaijan', 'B.C., MX',
'Barbuda', 'Bolivia', ..., 'CA', 'California', 'Canada',
'Chile', ..., 'East Timor', 'Ecuador', 'Ecuador region',
..., 'Mexico', 'Missouri', 'Montana', 'NV', 'Nevada',
..., 'Yemen', nan], dtype=object)
如果我们想将国家及其附近的任何地方视为一个整体实体,我们还需要做一些额外的工作(参见Ecuador和Ecuador region)。此外,我们通过查看逗号后面的信息来解析位置的简单尝试显然失败了;这是因为在某些情况下,我们并没有逗号。我们需要改变解析的方式。
这是一个df.place.unique(),我们可以简单地查看并推断如何正确地匹配这些名称。然后,我们可以使用replace()方法根据需要替换place列中的模式:
>>> df['parsed_place'] = df.place.str.replace(
... r'.* of ', '', regex=True # remove <x> of <x>
... ).str.replace(
... 'the ', '' # remove "the "
... ).str.replace(
... r'CA$', 'California', regex=True # fix California
... ).str.replace(
... r'NV$', 'Nevada', regex=True # fix Nevada
... ).str.replace(
... r'MX$', 'Mexico', regex=True # fix Mexico
... ).str.replace(
... r' region$', '', regex=True # fix " region" endings
... ).str.replace(
... 'northern ', '' # remove "northern "
... ).str.replace(
... 'Fiji Islands', 'Fiji' # line up the Fiji places
... ).str.replace( # remove anything else extraneous from start
... r'^.*, ', '', regex=True
... ).str.strip() # remove any extra spaces
现在,我们可以检查剩下的解析地点。请注意,关于South Georgia and South Sandwich Islands和South Sandwich Islands,可能还有更多需要修正的地方。我们可以通过另一次调用replace()来解决这个问题;然而,这表明实体识别确实可能相当具有挑战性:
>>> df.parsed_place.sort_values().unique()
array([..., 'California', 'Canada', 'Carlsberg Ridge', ...,
'Dominican Republic', 'East Timor', 'Ecuador',
'El Salvador', 'Fiji', 'Greece', ...,
'Mexico', 'Mid-Indian Ridge', 'Missouri', 'Montana',
'Nevada', 'New Caledonia', ...,
'South Georgia and South Sandwich Islands',
'South Sandwich Islands', ..., 'Yemen'], dtype=object)
重要提示
在实践中,实体识别可能是一个极其困难的问题,我们可能会尝试使用自然语言处理(NLP)算法来帮助我们。虽然这超出了本书的范围,但可以在 https://www.kdnuggets.com/2018/12/introduction-named-entity-recognition.html 上找到更多信息。
Pandas 还提供了一种通过一次方法调用创建多个新列的方式。使用assign()方法,参数是我们想要创建(或覆盖)的列名,而值是这些列的数据。我们将创建两个新列;一个列将告诉我们地震是否发生在加利福尼亚,另一个列将告诉我们地震是否发生在阿拉斯加。我们不仅仅展示前五行(这些地震都发生在加利福尼亚),我们将使用sample()随机选择五行:
>>> df.assign(
... in_ca=df.parsed_place.str.endswith('California'),
... in_alaska=df.parsed_place.str.endswith('Alaska')
... ).sample(5, random_state=0)
请注意,assign()并不会改变我们的原始数据框;相反,它返回一个包含新列的DataFrame对象。如果我们想用这个新的数据框替换原来的数据框,我们只需使用变量赋值将assign()的结果存储在df中(例如,df = df.assign(...)):

图 2.35 – 一次创建多个新列
assign() 方法也接受 assign(),它会将数据框传递到 lambda 函数作为 x,然后我们可以在这里进行操作。这使得我们可以利用在 assign() 中创建的列来计算其他列。例如,让我们再次创建 in_ca 和 in_alaska 列,这次还会创建一个新列 neither,如果 in_ca 和 in_alaska 都是 False,那么 neither 就为 True:
>>> df.assign(
... in_ca=df.parsed_place == 'California',
... in_alaska=df.parsed_place == 'Alaska',
... neither=lambda x: ~x.in_ca & ~x.in_alaska
... ).sample(5, random_state=0)
记住,~ 是按位取反运算符,所以这允许我们为每一行创建一个列,其结果是 NOT in_ca AND NOT in_alaska:

图 2.36 – 使用 lambda 函数一次性创建多个新列
提示
在使用 pandas 时,熟悉 lambda 函数至关重要,因为它们可以与许多功能一起使用,并且会显著提高代码的质量和可读性。在本书中,我们将看到许多可以使用 lambda 函数的场景。
现在我们已经了解了如何添加新列,让我们来看一下如何添加新行。假设我们正在处理两个不同的数据框:一个包含地震和海啸的数据,另一个则是没有海啸的地震数据:
>>> tsunami = df[df.tsunami == 1]
>>> no_tsunami = df[df.tsunami == 0]
>>> tsunami.shape, no_tsunami.shape
((61, 10), (9271, 10))
如果我们想查看所有的地震数据,我们可能需要将两个数据框合并成一个。要将行追加到数据框的底部,我们可以使用 pd.concat() 或者数据框本身的 append() 方法。concat() 函数允许我们指定操作的轴——0 表示将行追加到底部,1 表示将数据追加到最后一列的右侧,依据的是连接列表中最左边的 pandas 对象。让我们使用 pd.concat() 并保持默认的 axis=0 来处理行:
>>> pd.concat([tsunami, no_tsunami]).shape
(9332, 10) # 61 rows + 9271 rows
请注意,之前的结果等同于在数据框上运行 append() 方法。它仍然返回一个新的 DataFrame 对象,但避免了我们需要记住哪个轴是哪个,因为 append() 实际上是 concat() 函数的一个包装器:
>>> tsunami.append(no_tsunami).shape
(9332, 10) # 61 rows + 9271 rows
到目前为止,我们一直在处理 CSV 文件中的部分列,但假设我们现在想处理读取数据时忽略的一些列。由于我们已经在这个笔记本中添加了新列,所以我们不想重新读取文件并再次执行这些操作。相反,我们将沿列方向(axis=1)进行合并,添加回我们缺失的内容:
>>> additional_columns = pd.read_csv(
... 'data/earthquakes.csv', usecols=['tz', 'felt', 'ids']
... )
>>> pd.concat([df.head(2), additional_columns.head(2)], axis=1)
由于数据框的索引对齐,附加的列被放置在原始列的右侧:

图 2.37 – 按照匹配的索引连接列
concat()函数使用索引来确定如何连接值。如果它们不对齐,这将生成额外的行,因为pandas不知道如何对齐它们。假设我们忘记了原始 DataFrame 的索引是行号,并且我们通过将time列设置为索引来读取了其他列:
>>> additional_columns = pd.read_csv(
... 'data/earthquakes.csv',
... usecols=['tz', 'felt', 'ids', 'time'],
... index_col='time'
... )
>>> pd.concat([df.head(2), additional_columns.head(2)], axis=1)
尽管额外的列包含了前两行的数据,pandas仍然为它们创建了一个新行,因为索引不匹配。在第三章,使用 Pandas 进行数据清洗中,我们将看到如何重置索引和设置索引,这两种方法都可以解决这个问题:

图 2.38 – 连接具有不匹配索引的列
重要提示
在第四章,聚合 Pandas DataFrame中,我们将讨论合并操作,这将处理一些在增加 DataFrame 列时遇到的问题。通常,我们会使用concat()或append()来添加行,但会使用merge()或join()来添加列。
假设我们想连接tsunami和no_tsunami这两个 DataFrame,但no_tsunami DataFrame 多了一列(假设我们向其中添加了一个名为type的新列)。join参数指定了如何处理列名重叠(在底部添加时)或行名重叠(在右侧连接时)。默认情况下,这是outer,所以我们会保留所有内容;但是,如果使用inner,我们只会保留它们共有的部分:
>>> pd.concat(
... [
... tsunami.head(2),
... no_tsunami.head(2).assign(type='earthquake')
... ],
... join='inner'
... )
注意,no_tsunami DataFrame 中的type列没有出现,因为它在tsunami DataFrame 中不存在。不过,看看索引;这些是原始 DataFrame 的行号,在我们将其分为tsunami和no_tsunami之前:

图 2.39 – 添加行并仅保留共享列
如果索引没有实际意义,我们还可以传入ignore_index来获取连续的索引值:
>>> pd.concat(
... [
... tsunami.head(2),
... no_tsunami.head(2).assign(type='earthquake')
... ],
... join='inner', ignore_index=True
... )
现在索引是连续的,行号与原始 DataFrame 不再匹配:

图 2.40 – 添加行并重置索引
确保查阅pandas文档以获取有关concat()函数和其他数据合并操作的更多信息,我们将在第四章,聚合 Pandas DataFrame中讨论这些内容:http://pandas.pydata.org/pandas-docs/stable/user_guide/merging.html#concatenating-objects。
删除不需要的数据
在将数据添加到我们的数据框后,我们可以看到有删除不需要数据的需求。我们需要一种方法来撤销我们的错误并去除那些我们不打算使用的数据。和添加数据一样,我们可以使用字典语法删除不需要的列,就像从字典中删除键一样。del df['<column_name>'] 和 df.pop('<column_name>') 都可以工作,前提是确实有一个名为该列的列;否则,我们会得到一个 KeyError。这里的区别在于,虽然 del 会立即删除它,pop() 会返回我们正在删除的列。记住,这两个操作都会修改原始数据框,因此请小心使用它们。
让我们使用字典语法删除 source 列。注意,它不再出现在 df.columns 的结果中:
>>> del df['source']
>>> df.columns
Index(['alert', 'mag', 'magType', 'place', 'time', 'title',
'tsunami', 'mag_negative', 'parsed_place'],
dtype='object')
注意,如果我们不确定列是否存在,应该将我们的列删除代码放在 try...except 块中:
try:
del df['source']
except KeyError:
pass # handle the error here
之前,我们创建了 mag_negative 列来过滤数据框;然而,我们现在不再希望将这个列包含在数据框中。我们可以使用 pop() 获取 mag_negative 列的系列,这样我们可以将它作为布尔掩码稍后使用,而不必将其保留在数据框中:
>>> mag_negative = df.pop('mag_negative')
>>> df.columns
Index(['alert', 'mag', 'magType', 'place', 'time', 'title',
'tsunami', 'parsed_place'],
dtype='object')
我们现在在 mag_negative 变量中有一个布尔掩码,它曾经是 df 中的一列:
>>> mag_negative.value_counts()
False 8841
True 491
Name: mag_negative, dtype: int64
由于我们使用 pop() 移除了 mag_negative 系列而不是删除它,我们仍然可以使用它来过滤数据框:
>>> df[mag_negative].head()
这样我们就得到了具有负震级的地震数据。由于我们还调用了 head(),因此返回的是前五个这样的地震数据:

图 2.41 – 使用弹出的列作为布尔掩码
DataFrame 对象有一个 drop() 方法,用于删除多行或多列,可以原地操作(覆盖原始数据框而不需要重新赋值)或返回一个新的 DataFrame 对象。要删除行,我们传入索引列表。让我们删除前两行:
>>> df.drop([0, 1]).head(2)
请注意,索引从 2 开始,因为我们删除了 0 和 1:

图 2.42 – 删除特定的行
默认情况下,drop() 假设我们要删除的是行(axis=0)。如果我们想删除列,我们可以传入 axis=1,或者使用 columns 参数指定我们要删除的列名列表。让我们再删除一些列:
>>> cols_to_drop = [
... col for col in df.columns
... if col not in [
... 'alert', 'mag', 'title', 'time', 'tsunami'
... ]
... ]
>>> df.drop(columns=cols_to_drop).head()
这会删除所有不在我们想保留的列表中的列:

图 2.43 – 删除特定的列
无论我们决定将 axis=1 传递给 drop() 还是使用 columns 参数,我们的结果都是等效的:
>>> df.drop(columns=cols_to_drop).equals(
... df.drop(cols_to_drop, axis=1)
... )
True
默认情况下,drop() 会返回一个新的 DataFrame 对象;然而,如果我们确实想从原始数据框中删除数据,我们可以传入 inplace=True,这将避免我们需要将结果重新赋值回数据框。结果与 图 2.43 中的相同:
>>> df.drop(columns=cols_to_drop, inplace=True)
>>> df.head()
使用就地操作时要始终小心。在某些情况下,可能可以撤销它们;然而,在其他情况下,可能需要从头开始并重新创建DataFrame。
总结
在本章中,我们学习了如何使用pandas进行数据分析中的数据收集部分,并使用统计数据描述我们的数据,这将在得出结论阶段时派上用场。我们学习了pandas库的主要数据结构,以及我们可以对其执行的一些操作。接下来,我们学习了如何从多种来源创建DataFrame对象,包括平面文件和 API 请求。通过使用地震数据,我们讨论了如何总结我们的数据并从中计算统计数据。随后,我们讲解了如何通过选择、切片、索引和过滤来提取数据子集。最后,我们练习了如何添加和删除DataFrame中的列和行。
这些任务也是我们pandas工作流的核心,并为接下来几章关于数据清理、聚合和数据可视化的新主题奠定了基础。请确保在继续之前完成下一节提供的练习。
练习
使用data/parsed.csv文件和本章的材料,完成以下练习以练习你的pandas技能:
-
使用
mb震级类型计算日本地震的 95 百分位数。 -
找出印度尼西亚与海啸相关的地震百分比。
-
计算内华达州地震的汇总统计。
-
添加一列,指示地震是否发生在环太平洋火山带上的国家或美国州。使用阿拉斯加、南极洲(查找 Antarctic)、玻利维亚、加利福尼亚、加拿大、智利、哥斯达黎加、厄瓜多尔、斐济、危地马拉、印度尼西亚、日本、克麦得岛、墨西哥(注意不要选择新墨西哥州)、新西兰、秘鲁、菲律宾、俄罗斯、台湾、汤加和华盛顿。
-
计算环太平洋火山带内外地震的数量。
-
计算环太平洋火山带上的海啸数量。
进一步阅读
具有 R 和/或 SQL 背景的人可能会发现查看pandas语法的比较会有所帮助:
-
与 R / R 库的比较: https://pandas.pydata.org/pandas-docs/stable/getting_started/comparison/comparison_with_r.html
-
与 SQL 的比较: https://pandas.pydata.org/pandas-docs/stable/comparison_with_sql.html
-
SQL 查询: https://pandas.pydata.org/pandas-docs/stable/getting_started/comparison/comparison_with_sql.html
以下是一些关于处理序列化数据的资源:
-
Python 中的 Pickle: 对象序列化: https://www.datacamp.com/community/tutorials/pickle-python-tutorial
-
将 RData/RDS 文件读取到 pandas.DataFrame 对象中(pyreader): https://github.com/ofajardo/pyreadr
以下是一些关于使用 API 的附加资源:
-
requests 包文档: https://requests.readthedocs.io/en/master/
-
HTTP 方法: https://restfulapi.net/http-methods/
要了解更多关于正则表达式的知识,请参考以下资源:
-
《精通 Python 正则表达式》 作者:Félix López, Víctor Romero: https://www.packtpub.com/application-development/mastering-python-regular-expressions
-
正则表达式教程 — 学习如何使用正则表达式: https://www.regular-expressions.info/tutorial.html
第二章:使用 Pandas 进行数据分析
现在我们已经对pandas库有所了解,理解了数据分析的内容,并且知道了收集数据的多种方式,接下来我们将重点学习进行数据清洗和探索性数据分析所需的技能。本节将提供我们在 Python 中操作、重塑、总结、聚合和可视化数据所需的工具。
本节包括以下章节:
-
第三章,使用 Pandas 进行数据清洗
-
第四章,聚合 Pandas DataFrame
-
第五章,使用 Pandas 和 Matplotlib 进行数据可视化
-
第六章,使用 Seaborn 绘图与定制技术
第四章:第三章:使用 Pandas 进行数据整理
在上一章中,我们学习了主要的pandas数据结构,如何用收集到的数据创建DataFrame对象,以及检查、总结、筛选、选择和处理DataFrame对象的各种方法。现在,我们已经熟练掌握了初步数据收集和检查阶段,可以开始进入数据整理的世界。
如第一章,《数据分析简介》中所提到的,准备数据进行分析通常是从事数据工作的人耗费最多时间的部分,而且往往是最不令人愉快的部分。幸运的是,pandas非常适合处理这些任务,通过掌握本书中介绍的技能,我们将能够更快地进入更有趣的部分。
需要注意的是,数据整理并非我们在分析中只做一次的工作;很可能在完成一次数据整理并转向其他分析任务(如数据可视化)后,我们会发现仍然需要进行额外的数据整理。我们对数据越熟悉,就越能为分析做好准备。形成一种直觉,了解数据应该是什么类型、我们需要将数据转换成什么格式来进行可视化,以及我们需要收集哪些数据点来进行分析,这一点至关重要。这需要经验积累,因此我们必须在每次处理自己数据时,实践本章中将涉及的技能。
由于这是一个非常庞大的话题,关于数据整理的内容将在本章和第四章,《聚合 Pandas 数据框》中分开讲解。本章将概述数据整理,然后探索requests库。接着,我们将讨论一些数据整理任务,这些任务涉及为初步分析和可视化准备数据(我们将在第五章,《使用 Pandas 和 Matplotlib 进行数据可视化》以及第六章,《使用 Seaborn 绘图和定制技巧》中学习到的内容)。我们将针对与聚合和数据集合并相关的更高级的数据整理内容,在第四章,《聚合 Pandas 数据框》中进行讲解。
本章将涵盖以下主题:
-
理解数据整理
-
探索 API 查找并收集温度数据
-
清理数据
-
数据重塑
-
处理重复、缺失或无效数据
本章材料
本章的材料可以在 GitHub 上找到,链接为 github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_03。这里有五个笔记本,我们将按顺序进行学习,并有两个目录,data/ 和 exercises/,分别包含了上述笔记本和章节末尾练习所需的所有 CSV 文件。data/ 目录中包含以下文件:

图 3.1 – 本章使用的数据集解析
我们将在 1-wide_vs_long.ipynb 笔记本中开始,讨论宽格式和长格式数据的区别。接下来,我们将在 2-using_the_weather_api.ipynb 笔记本中从 NCEI API 获取每日气温数据,API 地址为 www.ncdc.noaa.gov/cdo-web/webservices/v2。我们将使用的 全球历史气候网络–每日(GHCND)数据集的文档可以在 www1.ncdc.noaa.gov/pub/data/cdo/documentation/GHCND_documentation.pdf 找到。
重要提示
NCEI 是 国家海洋和大气管理局(NOAA)的一部分。如 API 的 URL 所示,该资源是在 NCEI 被称为 NCDC 时创建的。如果将来该资源的 URL 发生变化,可以搜索 NCEI weather API 来找到更新后的 URL。
在 3-cleaning_data.ipynb 笔记本中,我们将学习如何对温度数据和一些财务数据进行初步清理,这些财务数据是通过我们将在 第七章 中构建的 stock_analysis 包收集的,财务分析——比特币与股市。然后,我们将在 4-reshaping_data.ipynb 笔记本中探讨如何重塑数据。最后,在 5-handling_data_issues.ipynb 笔记本中,我们将学习如何使用一些脏数据(可在 data/dirty_data.csv 中找到)处理重复、缺失或无效数据的策略。文本中将指示何时切换笔记本。
理解数据处理
就像任何专业领域一样,数据分析充满了术语,初学者往往很难理解这些行话——本章的主题也不例外。当我们进行数据清洗时,我们将输入数据从其原始状态转化为可以进行有意义分析的格式。数据操作是指这一过程的另一种说法。没有固定的操作清单;唯一的目标是,经过清洗后的数据对我们来说比开始时更有用。在实践中,数据清洗过程通常涉及以下三项常见任务:
-
数据清理
-
数据转换
-
数据丰富化
应该注意的是,这些任务没有固定的顺序,我们很可能会在数据清洗过程中多次执行每一项。这一观点引出了一个有趣的难题:如果我们需要清洗数据以为分析做准备,难道不能以某种方式清洗数据,让它告诉我们它在说什么,而不是我们去了解它在说什么吗?
“如果你对数据施加足够的压力,它就会承认任何事情。”
— 罗纳德·科斯,诺贝尔经济学奖得主
从事数据工作的人会发现,通过操作数据很容易扭曲事实。然而,我们的责任是尽最大努力避免欺骗,通过时刻关注我们操作对数据完整性产生的影响,并且向分析使用者解释我们得出结论的过程,让他们也能作出自己的判断。
数据清理
一旦我们收集到数据,将其导入DataFrame对象,并运用在第二章中讨论的技巧熟悉数据后,我们将需要进行一些数据清理。初步的数据清理通常能为我们提供开始探索数据所需的最基本条件。一些必须掌握的基本数据清理任务包括:
-
重命名
-
排序与重新排序
-
数据类型转换
-
处理重复数据
-
处理缺失或无效数据
-
筛选出所需的数据子集
数据清理是数据清洗的最佳起点,因为将数据存储为正确的数据类型和易于引用的名称将为许多探索途径提供便利,如总结统计、排序和筛选。由于我们在第二章中已经讲解过筛选内容,与 Pandas DataFrame 的合作,本章将重点讨论上一节提到的其他主题。
数据转换
经常在一些初步数据清洗之后,我们会进入数据转换阶段,但完全有可能我们的数据集在当前的形式下无法使用,我们必须在进行任何数据清理之前对其进行重构。在数据转换中,我们专注于改变数据的结构,以促进后续分析;这通常涉及改变哪些数据沿行展示,哪些数据沿列展示。
我们将遇到的大多数数据都是宽格式或长格式;这两种格式各有其优点,了解我们需要哪一种格式进行分析是非常重要的。通常,人们会以宽格式记录和呈现数据,但有些可视化方法需要数据采用长格式:

图 3.2 – (左)宽格式与(右)长格式
宽格式更适合分析和数据库设计,而长格式被认为是一种不良设计,因为每一列应该代表一个数据类型并具有单一含义。然而,在某些情况下,当需要在关系型数据库中添加新字段(或删除旧字段)时,数据库管理员可能会选择使用长格式,这样可以避免每次都修改所有表格。这样,他们可以为数据库用户提供固定的模式,同时根据需要更新数据库中的数据。在构建 API 时,如果需要灵活性,可能会选择长格式。比如,API 可能会提供一种通用的响应格式(例如日期、字段名和字段值),以支持来自数据库的各种表格。这也可能与如何根据 API 所使用的数据库存储数据来简化响应的构建过程相关。由于我们将遇到这两种格式的数据,理解如何处理它们并在两者之间转换是非常重要的。
现在,让我们导航到1-wide_vs_long.ipynb笔记本,查看一些示例。首先,我们将导入pandas和matplotlib(这些将帮助我们说明每种格式在可视化方面的优缺点,相关内容将在第五章《使用 Pandas 和 Matplotlib 进行数据可视化》和第六章《使用 Seaborn 和自定义技巧进行绘图》中讨论),并读取包含宽格式和长格式数据的 CSV 文件:
>>> import matplotlib.pyplot as plt
>>> import pandas as pd
>>> wide_df = \
... pd.read_csv('data/wide_data.csv', parse_dates=['date'])
>>> long_df = pd.read_csv(
... 'data/long_data.csv',
... usecols=['date', 'datatype', 'value'],
... parse_dates=['date']
... )[['date', 'datatype', 'value']] # sort columns
宽格式数据
对于宽格式数据,我们用各自的列来表示变量的测量值,每一行代表这些变量的一个观测值。这使得我们可以轻松地比较不同观测中的变量,获取汇总统计数据,执行操作并展示我们的数据;然而,一些可视化方法无法使用这种数据格式,因为它们可能依赖于长格式来拆分、调整大小和/或着色图表内容。
让我们查看wide_df中宽格式数据的前六条记录:
>>> wide_df.head(6)
每列包含特定类别温度数据的前六条观测记录,单位是摄氏度——最高温度 (TMAX)、最低温度 (TMIN)、和观测时温度 (TOBS),其频率为每日:

图 3.3 – 宽格式温度数据
在处理宽格式数据时,我们可以通过使用describe()方法轻松获得该数据的总结统计信息。请注意,虽然旧版本的pandas将datetimes视为分类数据,但pandas正朝着将其视为数值型数据的方向发展,因此我们传入datetime_is_numeric=True来抑制警告:
>>> wide_df.describe(include='all', datetime_is_numeric=True)
几乎不费力气,我们就能获得日期、最高温度、最低温度和观测时温度的总结统计信息:

图 3.4 – 宽格式温度数据的总结统计
正如我们之前讨论的,前面表格中的总结数据容易获得,并且富有信息。这种格式也可以用pandas轻松绘制,只要我们告诉它确切需要绘制的内容:
>>> wide_df.plot(
... x='date', y=['TMAX', 'TMIN', 'TOBS'], figsize=(15, 5),
... title='Temperature in NYC in October 2018'
... ).set_ylabel('Temperature in Celsius')
>>> plt.show()
pandas将每日最高温度、最低温度和观测时温度绘制成单一折线图的三条线:

图 3.5 – 绘制宽格式温度数据
重要提示
现在不用担心理解可视化代码,它的目的是仅仅展示这些数据格式如何使某些任务更简单或更困难。我们将在第五章中讲解如何使用pandas和matplotlib进行数据可视化,使用 Pandas 和 Matplotlib 可视化数据。
长格式数据
长格式数据每一行代表一个变量的观测值;这意味着,如果我们有三个每天测量的变量,每天的观测将有三行数据。长格式的设置可以通过将变量的列名转化为单一列来实现,这一列的值就是变量名称,然后将变量的值放在另一列。
我们可以查看long_df中长格式数据的前六行,看看宽格式数据和长格式数据之间的差异:
>>> long_df.head(6)
注意,现在我们为每个日期有三条记录,并且数据类型列告诉我们值列中数据的含义:

图 3.6 – 长格式温度数据
如果我们像处理宽格式数据一样尝试获取总结统计,结果将不那么有用:
>>> long_df.describe(include='all', datetime_is_numeric=True)
value 列展示了汇总统计数据,但这是对每日最高气温、最低气温和观测时气温的汇总。最大值是每日最高气温的最大值,最小值是每日最低气温的最小值。这意味着这些汇总数据并不十分有用:

图 3.7 – 长格式温度数据的汇总统计
这种格式并不容易理解,当然也不应该是我们展示数据的方式;然而,它使得创建可视化变得容易,利用我们的绘图库可以根据变量的名称为线条着色、根据某个变量的值调整点的大小,并且进行分面。Pandas 期望绘图数据为宽格式数据,因此,为了轻松绘制出我们用宽格式数据绘制的相同图表,我们必须使用另一个绘图库,叫做 seaborn,我们将在第六章中讲解,使用 Seaborn 绘图与定制技术:
>>> import seaborn as sns
>>> sns.set(rc={'figure.figsize': (15, 5)}, style='white')
>>> ax = sns.lineplot(
... data=long_df, x='date', y='value', hue='datatype'
... )
>>> ax.set_ylabel('Temperature in Celsius')
>>> ax.set_title('Temperature in NYC in October 2018')
>>> plt.show()
Seaborn 可以根据 datatype 列对数据进行子集化,给我们展示每日最高气温、最低气温和观测时的气温的单独线条:

图 3.8 – 绘制长格式温度数据
Seaborn 允许我们指定用于 hue 的列,这样可以根据温度类型为图 3.8中的线条着色。不过,我们并不局限于此;对于长格式数据,我们可以轻松地对图表进行分面:
>>> sns.set(
... rc={'figure.figsize': (20, 10)},
... style='white', font_scale=2
... )
>>> g = sns.FacetGrid(long_df, col='datatype', height=10)
>>> g = g.map(plt.plot, 'date', 'value')
>>> g.set_titles(size=25)
>>> g.set_xticklabels(rotation=45)
>>> plt.show()
Seaborn 可以使用长格式数据为 datatype 列中的每个不同值创建子图:

图 3.9 – 绘制长格式温度数据的子集
重要提示
虽然使用 pandas 和 matplotlib 的子图可以创建类似于之前图表的图形,但更复杂的分面组合将使得使用 seaborn 变得极其简便。我们将在第六章中讲解 seaborn,使用 Seaborn 绘图与定制技术。
在重塑数据部分,我们将讲解如何通过“melting”将数据从宽格式转换为长格式,以及如何通过“pivoting”将数据从长格式转换为宽格式。此外,我们还将学习如何转置数据,即翻转列和行。
数据丰富
一旦我们拥有了格式化良好的清理数据用于分析,我们可能会发现需要稍微丰富一下数据。数据丰富通过某种方式向数据添加更多内容,从而提升数据的质量。这个过程在建模和机器学习中非常重要,它是特征工程过程的一部分(我们将在第十章中提到,做出更好的预测——优化模型)。
当我们想要丰富数据时,我们可以合并新数据与原始数据(通过附加新行或列)或使用原始数据创建新数据。以下是使用原始数据增强数据的几种方法:
-
添加新列:使用现有列中的数据,通过函数计算出新值。
-
分箱:将连续数据或具有许多不同值的离散数据转换为区间,这使得列变为离散的,同时让我们能够控制列中可能值的数量。
-
聚合:将数据汇总并概括。
-
重采样:在特定的时间间隔内聚合时间序列数据。
现在我们已经理解了数据整理的概念,接下来让我们收集一些数据来进行操作。请注意,在本章中我们将讨论数据清理和转换,而数据丰富将在第四章中讨论,内容包括聚合 Pandas 数据框。
探索 API 以查找并收集温度数据
在第二章中,使用 Pandas 数据框,我们处理了数据收集以及如何进行初步检查和筛选数据;这通常会给我们一些启示,告诉我们在进一步分析之前需要解决的事项。由于本章内容建立在这些技能的基础上,我们也将在这里练习其中的一些技能。首先,我们将开始探索 NCEI 提供的天气 API。接下来,在下一节中,我们将学习如何使用之前从该 API 获取的温度数据进行数据整理。
重要提示
要使用 NCEI API,您需要填写此表格并提供您的电子邮件地址以申请一个令牌:www.ncdc.noaa.gov/cdo-web/token。
在本节中,我们将在2-using_the_weather_api.ipynb笔记本中请求 NCEI API 的温度数据。正如我们在第二章中学到的,使用 Pandas 数据框,我们可以使用requests库与 API 进行交互。在下面的代码块中,我们导入了requests库,并创建了一个便捷函数来向特定端点发出请求,并附带我们的令牌。要使用此函数,我们需要提供一个令牌,具体如下所示(以粗体显示):
>>> import requests
>>> def make_request(endpoint, payload=None):
... """
... Make a request to a specific endpoint on the
... weather API passing headers and optional payload.
... Parameters:
... - endpoint: The endpoint of the API you want to
... make a GET request to.
... - payload: A dictionary of data to pass along
... with the request.
...
... Returns:
... A response object.
... """
... return requests.get(
... 'https://www.ncdc.noaa.gov/cdo-web/'
... f'api/v2/{endpoint}',
... headers={'token': 'PASTE_YOUR_TOKEN_HERE'},
... params=payload
... )
小贴士
这个函数使用了format()方法:'api/v2/{}'.format(endpoint)。
要使用make_request()函数,我们需要学习如何构建请求。NCEI 提供了一个有用的入门页面(www.ncdc.noaa.gov/cdo-web/webservices/v2#gettingStarted),该页面向我们展示了如何构建请求;我们可以通过页面上的选项卡逐步确定查询中需要哪些过滤条件。requests库负责将我们的搜索参数字典(作为payload传递)转换为2018-08-28的start和2019-04-15的end,最终得到?start=2018-08-28&end=2019-04-15,就像网站上的示例一样。这个 API 提供了许多不同的端点,供我们探索所提供的内容,并构建我们最终的实际数据集请求。我们将从使用datasets端点查找我们想查询的数据集 ID(datasetid)开始。让我们检查哪些数据集在 2018 年 10 月 1 日至今天之间有数据:
>>> response = \
... make_request('datasets', {'startdate': '2018-10-01'})
请记住,我们需要检查status_code属性,以确保请求成功。或者,我们可以使用ok属性来获取布尔指示,查看是否一切按预期进行:
>>> response.status_code
200
>>> response.ok
True
提示
API 限制我们每秒最多 5 个请求,每天最多 10,000 个请求。如果超过这些限制,状态码将显示客户端错误(意味着错误似乎是由我们引起的)。客户端错误的状态码通常在 400 系列;例如,404 表示请求的资源无法找到,400 表示服务器无法理解我们的请求(或拒绝处理)。有时,服务器在处理我们的请求时遇到问题,在这种情况下,我们会看到 500 系列的状态码。您可以在restfulapi.net/http-status-codes/找到常见状态码及其含义的列表。
一旦我们得到响应,就可以使用json()方法获取有效载荷。然后,我们可以使用字典方法来确定我们想查看的部分:
>>> payload = response.json()
>>> payload.keys()
dict_keys(['metadata', 'results'])
metadata部分的 JSON 有效载荷告诉我们有关结果的信息,而results部分包含实际的结果。让我们看看我们收到了多少数据,这样我们就知道是否可以打印结果,或者是否应该尝试限制输出:
>>> payload['metadata']
{'resultset': {'offset': 1, 'count': 11, 'limit': 25}}
我们收到了 11 行数据,因此让我们看看results部分的 JSON 有效载荷包含哪些字段。results键包含一个字典列表。如果我们选择第一个字典,可以查看键以了解数据包含哪些字段。然后,我们可以将输出缩减到我们关心的字段:
>>> payload['results'][0].keys()
dict_keys(['uid', 'mindate', 'maxdate', 'name',
'datacoverage', 'id'])
对于我们的目的,我们希望查看数据集的 ID 和名称,因此让我们使用列表推导式仅查看这些:
>>> [(data['id'], data['name']) for data in payload['results']]
[('GHCND', 'Daily Summaries'),
('GSOM', 'Global Summary of the Month'),
('GSOY', 'Global Summary of the Year'),
('NEXRAD2', 'Weather Radar (Level II)'),
('NEXRAD3', 'Weather Radar (Level III)'),
('NORMAL_ANN', 'Normals Annual/Seasonal'),
('NORMAL_DLY', 'Normals Daily'),
('NORMAL_HLY', 'Normals Hourly'),
('NORMAL_MLY', 'Normals Monthly'),
('PRECIP_15', 'Precipitation 15 Minute'),
('PRECIP_HLY', 'Precipitation Hourly')]
结果中的第一个条目就是我们要找的。现在我们有了datasetid的值(GHCND),我们继续识别一个datacategoryid,我们需要使用datacategories端点请求温度数据。在这里,我们可以打印 JSON 负载,因为它并不是很大(仅九个条目):
>>> response = make_request(
... 'datacategories', payload={'datasetid': 'GHCND'}
... )
>>> response.status_code
200
>>> response.json()['results']
[{'name': 'Evaporation', 'id': 'EVAP'},
{'name': 'Land', 'id': 'LAND'},
{'name': 'Precipitation', 'id': 'PRCP'},
{'name': 'Sky cover & clouds', 'id': 'SKY'},
{'name': 'Sunshine', 'id': 'SUN'},
{'name': 'Air Temperature', 'id': 'TEMP'},
{'name': 'Water', 'id': 'WATER'},
{'name': 'Wind', 'id': 'WIND'},
{'name': 'Weather Type', 'id': 'WXTYPE'}]
根据先前的结果,我们知道我们想要TEMP的值作为datacategoryid。接下来,我们使用此值通过datatypes端点识别我们想要的数据类型。我们将再次使用列表推导式仅打印名称和 ID;这仍然是一个相当大的列表,因此输出已经被缩短。
>>> response = make_request(
... 'datatypes',
... payload={'datacategoryid': 'TEMP', 'limit': 100}
... )
>>> response.status_code
200
>>> [(datatype['id'], datatype['name'])
... for datatype in response.json()['results']]
[('CDSD', 'Cooling Degree Days Season to Date'),
...,
('TAVG', 'Average Temperature.'),
('TMAX', 'Maximum temperature'),
('TMIN', 'Minimum temperature'),
('TOBS', 'Temperature at the time of observation')]
我们正在寻找TAVG、TMAX和TMIN数据类型。现在我们已经准备好请求所有位置的温度数据,我们需要将其缩小到特定位置。要确定locationcategoryid的值,我们必须使用locationcategories端点:
>>> response = make_request(
... 'locationcategories', payload={'datasetid': 'GHCND'}
... )
>>> response.status_code
200
注意我们可以使用来自 Python 标准库的pprint(docs.python.org/3/library/pprint.html)来以更易读的格式打印我们的 JSON 负载:
>>> import pprint
>>> pprint.pprint(response.json())
{'metadata': {
'resultset': {'count': 12, 'limit': 25, 'offset': 1}},
'results': [{'id': 'CITY', 'name': 'City'},
{'id': 'CLIM_DIV', 'name': 'Climate Division'},
{'id': 'CLIM_REG', 'name': 'Climate Region'},
{'id': 'CNTRY', 'name': 'Country'},
{'id': 'CNTY', 'name': 'County'},
...,
{'id': 'ST', 'name': 'State'},
{'id': 'US_TERR', 'name': 'US Territory'},
{'id': 'ZIP', 'name': 'Zip Code'}]}
我们想要查看纽约市,因此对于locationcategoryid过滤器,CITY是正确的值。我们正在使用 API 上的二分搜索来搜索字段的笔记本;二分搜索是一种更有效的有序列表搜索方法。因为我们知道字段可以按字母顺序排序,并且 API 提供了有关请求的元数据,我们知道 API 对于给定字段有多少项,并且可以告诉我们是否已经通过了我们正在寻找的项。
每次请求时,我们获取中间条目并将其与我们的目标字母顺序比较;如果结果在我们的目标之前出现,我们查看大于我们刚获取的数据的一半;否则,我们查看较小的一半。每次,我们都将数据切成一半,因此当我们获取中间条目进行测试时,我们越来越接近我们寻找的值(见图 3.10):
>>> def get_item(name, what, endpoint, start=1, end=None):
... """
... Grab the JSON payload using binary search.
...
... Parameters:
... - name: The item to look for.
... - what: Dictionary specifying what item `name` is.
... - endpoint: Where to look for the item.
... - start: The position to start at. We don't need
... to touch this, but the function will manipulate
... this with recursion.
... - end: The last position of the items. Used to
... find the midpoint, but like `start` this is not
... something we need to worry about.
...
... Returns: Dictionary of the information for the item
... if found, otherwise an empty dictionary.
... """
... # find the midpoint to cut the data in half each time
... mid = (start + (end or 1)) // 2
...
... # lowercase the name so this is not case-sensitive
... name = name.lower()
... # define the payload we will send with each request
... payload = {
... 'datasetid': 'GHCND', 'sortfield': 'name',
... 'offset': mid, # we'll change the offset each time
... 'limit': 1 # we only want one value back
... }
...
... # make request adding additional filters from `what`
... response = make_request(endpoint, {**payload, **what})
...
... if response.ok:
... payload = response.json()
...
... # if ok, grab the end index from the response
... # metadata the first time through
... end = end or \
... payload['metadata']['resultset']['count']
...
... # grab the lowercase version of the current name
... current_name = \
... payload['results'][0]['name'].lower()
...
... # if what we are searching for is in the current
... # name, we have found our item
... if name in current_name:
... # return the found item
... return payload['results'][0]
... else:
... if start >= end:
... # if start index is greater than or equal
... # to end index, we couldn't find it
... return {}
... elif name < current_name:
... # name comes before the current name in the
... # alphabet => search further to the left
... return get_item(name, what, endpoint,
... start, mid - 1)
... elif name > current_name:
... # name comes after the current name in the
... # alphabet => search further to the right
... return get_item(name, what, endpoint,
... mid + 1, end)
... else:
... # response wasn't ok, use code to determine why
... print('Response not OK, '
... f'status: {response.status_code}')
这是算法的递归实现,这意味着我们从内部调用函数自身;我们在这样做时必须非常小心,以定义一个基本条件,以便最终停止并避免进入无限循环。可以以迭代方式实现此功能。有关二分搜索和递归的更多信息,请参阅本章末尾的进一步阅读部分。
重要说明
在传统的二分搜索实现中,查找我们搜索的列表的长度是微不足道的。使用 API,我们必须发出一个请求来获取计数;因此,我们必须请求第一个条目(偏移量为 1)来确定方向。这意味着与我们开始时所需的相比,我们在这里多做了一个额外的请求。
现在,让我们使用二分查找实现来查找纽约市的 ID,这将作为后续查询中 locationid 的值:
>>> nyc = get_item(
... 'New York', {'locationcategoryid': 'CITY'}, 'locations'
... )
>>> nyc
{'mindate': '1869-01-01',
'maxdate': '2021-01-14',
'name': 'New York, NY US',
'datacoverage': 1,
'id': 'CITY:US360019'}
通过在这里使用二分查找,我们只用了 8 次请求就找到了 纽约,尽管它位于 1,983 个条目的中间!做个对比,使用线性查找,我们在找到它之前会查看 1,254 个条目。在下面的图示中,我们可以看到二分查找是如何系统性地排除位置列表中的部分内容的,这在数轴上用黑色表示(白色表示该部分仍可能包含所需值):

图 3.10 – 二分查找定位纽约市
提示
一些 API(如 NCEI API)限制我们在某些时间段内可以进行的请求次数,因此我们必须聪明地进行请求。当查找一个非常长的有序列表时,想一想二分查找。
可选地,我们可以深入挖掘收集数据的站点 ID。这是最细粒度的层次。再次使用二分查找,我们可以获取中央公园站点的站点 ID:
>>> central_park = get_item(
... 'NY City Central Park',
... {'locationid': nyc['id']}, 'stations'
... )
>>> central_park
{'elevation': 42.7,
'mindate': '1869-01-01',
'maxdate': '2020-01-13',
'latitude': 40.77898,
'name': 'NY CITY CENTRAL PARK, NY US',
'datacoverage': 1,
'id': 'GHCND:USW00094728',
'elevationUnit': 'METERS',
'longitude': -73.96925}
现在,让我们请求 2018 年 10 月来自中央公园的纽约市温度数据(摄氏度)。为此,我们将使用 data 端点,并提供在探索 API 过程中收集的所有参数:
>>> response = make_request(
... 'data',
... {'datasetid': 'GHCND',
... 'stationid': central_park['id'],
... 'locationid': nyc['id'],
... 'startdate': '2018-10-01',
... 'enddate': '2018-10-31',
... 'datatypeid': ['TAVG', 'TMAX', 'TMIN'],
... 'units': 'metric',
... 'limit': 1000}
... )
>>> response.status_code
200
最后,我们将创建一个 DataFrame 对象;由于 JSON 数据负载中的 results 部分是字典列表,我们可以直接将其传递给 pd.DataFrame():
>>> import pandas as pd
>>> df = pd.DataFrame(response.json()['results'])
>>> df.head()
我们得到了长格式的数据。datatype 列是正在测量的温度变量,value 列包含测得的温度:

图 3.11 – 从 NCEI API 获取的数据
提示
我们可以使用之前的代码将本节中处理过的任何 JSON 响应转换为 DataFrame 对象,如果我们觉得这样更方便。但需要强调的是,JSON 数据负载几乎在所有 API 中都很常见(作为 Python 用户,我们应该熟悉类似字典的对象),因此,熟悉它们不会有什么坏处。
我们请求了 TAVG、TMAX 和 TMIN,但注意到我们没有得到 TAVG。这是因为尽管中央公园站点在 API 中列出了提供平均温度的选项,但它并没有记录该数据——现实世界中的数据是有缺陷的:
>>> df.datatype.unique()
array(['TMAX', 'TMIN'], dtype=object)
>>> if get_item(
... 'NY City Central Park',
... {'locationid': nyc['id'], 'datatypeid': 'TAVG'},
... 'stations'
... ):
... print('Found!')
Found!
计划 B 的时候到了:让我们改用拉瓜迪亚机场作为本章剩余部分的站点。或者,我们本可以获取覆盖整个纽约市的所有站点的数据;不过,由于这会导致一些温度测量数据每天有多个条目,我们不会在这里这么做——要处理这些数据,我们需要一些将在 第四章 中介绍的技能,聚合 Pandas DataFrames。
从 LaGuardia 机场站收集天气数据的过程与从中央公园站收集数据的过程相同,但为了简洁起见,我们将在下一个笔记本中讨论清洗数据时再读取 LaGuardia 的数据。请注意,当前笔记本底部的单元格包含用于收集这些数据的代码。
清洗数据
接下来,让我们转到3-cleaning_data.ipynb笔记本讨论数据清洗。和往常一样,我们将从导入pandas并读取数据开始。在这一部分,我们将使用nyc_temperatures.csv文件,该文件包含 2018 年 10 月纽约市 LaGuardia 机场站的每日最高气温(TMAX)、最低气温(TMIN)和平均气温(TAVG):
>>> import pandas as pd
>>> df = pd.read_csv('data/nyc_temperatures.csv')
>>> df.head()
我们从 API 获取的是长格式数据;对于我们的分析,我们需要宽格式数据,但我们将在本章稍后的数据透视部分讨论这个问题:

图 3.12 – 纽约市温度数据
目前,我们将专注于对数据进行一些小的调整,使其更易于使用:重命名列、将每一列转换为最合适的数据类型、排序和重新索引。通常,这也是过滤数据的时机,但我们在从 API 请求数据时已经进行了过滤;有关使用pandas过滤的回顾,请参考第二章,使用 Pandas DataFrame。
重命名列
由于我们使用的 API 端点可以返回任何单位和类别的数据,因此它将该列命名为value。我们只提取了摄氏度的温度数据,因此所有观测值的单位都相同。这意味着我们可以重命名value列,以便明确我们正在处理的数据:
>>> df.columns
Index(['date', 'datatype', 'station', 'attributes', 'value'],
dtype='object')
DataFrame类有一个rename()方法,该方法接收一个字典,将旧列名映射到新列名。除了重命名value列外,我们还将attributes列重命名为flags,因为 API 文档提到该列包含有关数据收集的信息标志:
>>> df.rename(
... columns={'value': 'temp_C', 'attributes': 'flags'},
... inplace=True
... )
大多数时候,pandas会返回一个新的DataFrame对象;然而,由于我们传递了inplace=True,原始数据框被直接更新了。使用原地操作时要小心,因为它们可能难以或不可能撤销。我们的列现在已经有了新名字:
>>> df.columns
Index(['date', 'datatype', 'station', 'flags', 'temp_C'],
dtype='object')
提示
Series和Index对象也可以使用它们的rename()方法重命名。只需传入新名称。例如,如果我们有一个名为temperature的Series对象,并且我们想将其重命名为temp_C,我们可以运行temperature.rename('temp_C')。变量仍然叫做temperature,但 Series 本身的数据名称将变为temp_C。
我们还可以使用rename()对列名进行转换。例如,我们可以将所有列名转换为大写:
>>> df.rename(str.upper, axis='columns').columns
Index(['DATE', 'DATATYPE', 'STATION', 'FLAGS', 'TEMP_C'],
dtype='object')
该方法甚至允许我们重命名索引的值,尽管目前我们还不需要这样做,因为我们的索引只是数字。然而,作为参考,只需将前面代码中的 axis='columns' 改为 axis='rows' 即可。
类型转换
现在,列名已经能够准确指示它们所包含的数据,我们可以检查它们所持有的数据类型。在之前使用 head() 方法查看数据框的前几行时,我们应该已经对数据类型有了一些直观的理解。通过类型转换,我们的目标是将当前的数据类型与我们认为应该是的类型进行对比;我们将更改数据的表示方式。
请注意,有时我们可能会遇到认为应该是某种类型的数据,比如日期,但它实际上存储为字符串;这可能有很合理的原因——数据可能丢失了。在这种情况下,存储为文本的缺失数据(例如 ? 或 N/A)会被 pandas 在读取时作为字符串处理。使用 dtypes 属性查看数据框时,它将被标记为 object 类型。如果我们尝试转换(或强制转换)这些列,要么会出现错误,要么结果不符合预期。例如,如果我们有小数点数字的字符串,但尝试将其转换为整数,就会出现错误,因为 Python 知道它们不是整数;然而,如果我们尝试将小数数字转换为整数,就会丢失小数点后的任何信息。
话虽如此,让我们检查一下温度数据中的数据类型。请注意,date 列实际上并没有以日期时间格式存储:
>>> df.dtypes
date object
datatype object
station object
flags object
temp_C float64
dtype: object
我们可以使用 pd.to_datetime() 函数将其转换为日期时间:
>>> df.loc[:,'date'] = pd.to_datetime(df.date)
>>> df.dtypes
date datetime64[ns]
datatype object
station object
flags object
temp_C float64
dtype: object
现在好多了。现在,当我们总结 date 列时,可以得到有用的信息:
>>> df.date.describe(datetime_is_numeric=True)
count 93
mean 2018-10-16 00:00:00
min 2018-10-01 00:00:00
25% 2018-10-08 00:00:00
50% 2018-10-16 00:00:00
75% 2018-10-24 00:00:00
max 2018-10-31 00:00:00
Name: date, dtype: object
处理日期可能会比较棘手,因为它们有很多不同的格式和时区;幸运的是,pandas 提供了更多我们可以用来处理转换日期时间对象的方法。例如,在处理 DatetimeIndex 对象时,如果我们需要跟踪时区,可以使用 tz_localize() 方法将我们的日期时间与时区关联:
>>> pd.date_range(start='2018-10-25', periods=2, freq='D')\
... .tz_localize('EST')
DatetimeIndex(['2018-10-25 00:00:00-05:00',
'2018-10-26 00:00:00-05:00'],
dtype='datetime64[ns, EST]', freq=None)
这同样适用于具有 DatetimeIndex 类型索引的 Series 和 DataFrame 对象。我们可以再次读取 CSV 文件,这次指定 date 列为索引,并将 CSV 文件中的所有日期解析为日期时间:
>>> eastern = pd.read_csv(
... 'data/nyc_temperatures.csv',
... index_col='date', parse_dates=True
... ).tz_localize('EST')
>>> eastern.head()
在这个例子中,我们不得不重新读取文件,因为我们还没有学习如何更改数据的索引(将在本章稍后的重新排序、重新索引和排序数据部分讲解)。请注意,我们已经将东部标准时间偏移(UTC-05:00)添加到了索引中的日期时间:

图 3.13 – 索引中的时区感知日期
我们可以使用tz_convert()方法将时区转换为其他时区。让我们将数据转换为 UTC 时区:
>>> eastern.tz_convert('UTC').head()
现在,偏移量是 UTC(+00:00),但请注意,日期的时间部分现在是上午 5 点;这次转换考虑了-05:00 的偏移:

图 3.14 – 将数据转换为另一个时区
我们也可以使用to_period()方法截断日期时间,如果我们不关心完整的日期,这个方法非常有用。例如,如果我们想按月汇总数据,我们可以将索引截断到仅包含月份和年份,然后进行汇总。由于我们将在第四章《聚合 Pandas DataFrame》中讨论聚合方法,我们这里只做截断。请注意,我们首先去除时区信息,以避免pandas的警告,提示PeriodArray类没有时区信息,因此会丢失。这是因为PeriodIndex对象的底层数据是存储为PeriodArray对象:
>>> eastern.tz_localize(None).to_period('M').index
PeriodIndex(['2018-10', '2018-10', ..., '2018-10', '2018-10'],
dtype='period[M]', name='date', freq='M')
我们可以使用to_timestamp()方法将我们的PeriodIndex对象转换为DatetimeIndex对象;然而,所有的日期时间现在都从每月的第一天开始:
>>> eastern.tz_localize(None)\
... .to_period('M').to_timestamp().index
DatetimeIndex(['2018-10-01', '2018-10-01', '2018-10-01', ...,
'2018-10-01', '2018-10-01', '2018-10-01'],
dtype='datetime64[ns]', name='date', freq=None)
或者,我们可以使用assign()方法来处理任何类型转换,通过将列名作为命名参数传递,并将其新值作为该参数的值传递给方法调用。在实践中,这样做会更有益,因为我们可以在一次调用中执行许多任务,并使用我们在该调用中创建的列来计算额外的列。例如,我们将date列转换为日期时间,并为华氏温度(temp_F)添加一个新列。assign()方法返回一个新的DataFrame对象,因此如果我们想保留它,必须记得将其分配给一个变量。在这里,我们将创建一个新的对象。请注意,我们原始的日期转换已经修改了该列,因此为了说明我们可以使用assign(),我们需要再次读取数据:
>>> df = pd.read_csv('data/nyc_temperatures.csv').rename(
... columns={'value': 'temp_C', 'attributes': 'flags'}
... )
>>> new_df = df.assign(
... date=pd.to_datetime(df.date),
... temp_F=(df.temp_C * 9/5) + 32
... )
>>> new_df.dtypes
date datetime64[ns]
datatype object
station object
flags object
temp_C float64
temp_F float64
dtype: object
>>> new_df.head()
我们现在在date列中有日期时间,并且有了一个新列temp_F:

图 3.15 – 同时进行类型转换和列创建
此外,我们可以使用astype()方法一次转换一列。例如,假设我们只关心每个整数的温度,但不想进行四舍五入。在这种情况下,我们只是想去掉小数点后的信息。为此,我们可以将浮动值转换为整数。此次,我们将使用temp_F列创建temp_F_whole列,即使在调用assign()之前,df中并没有这个列。结合assign()使用 lambda 函数是非常常见(且有用)的:
>>> df = df.assign(
... date=lambda x: pd.to_datetime(x.date),
... temp_C_whole=lambda x: x.temp_C.astype('int'),
... temp_F=lambda x: (x.temp_C * 9/5) + 32,
... temp_F_whole=lambda x: x.temp_F.astype('int')
... )
>>> df.head()
注意,如果我们使用 lambda 函数,我们可以引用刚刚创建的列。还需要提到的是,我们不必知道是将列转换为浮动数值还是整数:我们可以使用pd.to_numeric(),如果数据中有小数,它会将数据转换为浮动数;如果所有数字都是整数,它将转换为整数(显然,如果数据根本不是数字,仍然会出现错误):

图 3.16 – 使用 lambda 函数创建列
最后,我们有两列当前存储为字符串的数据,可以用更适合此数据集的方式来表示。station和datatype列分别只有一个和三个不同的值,这意味着我们在内存使用上并不高效,因为我们将它们存储为字符串。这样可能会在后续的分析中出现问题。Pandas 能够将列定义为类别,并且其他包可以处理这些数据,提供有意义的统计信息,并正确使用它们。类别变量可以取几个值中的一个;例如,血型就是一个类别变量——人们只能有 A 型、B 型、AB 型或 O 型中的一种。
回到温度数据,我们的station列只有一个值,datatype列只有三个不同的值(TAVG、TMAX、TMIN)。我们可以使用astype()方法将它们转换为类别,并查看汇总统计信息:
>>> df_with_categories = df.assign(
... station=df.station.astype('category'),
... datatype=df.datatype.astype('category')
... )
>>> df_with_categories.dtypes
date datetime64[ns]
datatype category
station category
flags object
temp_C float64
temp_C_whole int64
temp_F float64
temp_F_whole int64
dtype: object
>>> df_with_categories.describe(include='category')
类别的汇总统计信息与字符串的汇总统计类似。我们可以看到非空条目的数量(count)、唯一值的数量(unique)、众数(top)以及众数的出现次数(freq):

图 3.17 – 类别列的汇总统计
我们刚刚创建的类别没有顺序,但是pandas确实支持这一点:
>>> pd.Categorical(
... ['med', 'med', 'low', 'high'],
... categories=['low', 'med', 'high'],
... ordered=True
... )
['med', 'med', 'low', 'high']
Categories (3, object): ['low' < 'med' < 'high']
当我们的数据框中的列被存储为适当的类型时,它为探索其他领域打开了更多的可能性,比如计算统计数据、汇总数据和排序值。例如,取决于我们的数据源,数字数据可能被表示为字符串,在这种情况下,如果尝试按值进行排序,排序结果将按字典顺序重新排列,意味着结果可能是 1、10、11、2,而不是 1、2、10、11(数字排序)。类似地,如果日期以除 YYYY-MM-DD 格式以外的字符串表示,排序时可能会导致非按时间顺序排列;但是,通过使用pd.to_datetime()转换日期字符串,我们可以按任何格式提供的日期进行按时间排序。类型转换使得我们可以根据数值而非初始的字符串表示,重新排序数字数据和日期。
重新排序、重新索引和排序数据
我们经常需要根据一个或多个列的值对数据进行排序。例如,如果我们想找到 2018 年 10 月在纽约市达到最高温度的日期;我们可以按 temp_C(或 temp_F)列降序排序,并使用 head() 选择我们想查看的天数。为了实现这一点,我们可以使用 sort_values() 方法。让我们看看前 10 天:
>>> df[df.datatype == 'TMAX']\
... .sort_values(by='temp_C', ascending=False).head(10)
根据 LaGuardia 站的数据,这表明 2018 年 10 月 7 日和 10 月 10 日的温度达到了 2018 年 10 月的最高值。我们还在 10 月 2 日和 4 日、10 月 1 日和 9 日、10 月 5 日和 8 日之间存在平局,但请注意,日期并不总是按顺序排列——10 日排在 7 日之后,但 4 日排在 2 日之前:

图 3.18 – 排序数据以找到最温暖的天数
sort_values() 方法可以与列名列表一起使用,以打破平局。提供列的顺序将决定排序顺序,每个后续的列将用于打破平局。例如,确保在打破平局时按升序排列日期:
>>> df[df.datatype == 'TMAX'].sort_values(
... by=['temp_C', 'date'], ascending=[False, True]
... ).head(10)
由于我们按升序排序,在平局的情况下,年份较早的日期会排在年份较晚的日期之前。请注意,尽管 10 月 2 日和 4 日的温度读数相同,但现在 10 月 2 日排在 10 月 4 日之前:

图 3.19 – 使用多个列进行排序以打破平局
提示
在 pandas 中,索引与行相关联——当我们删除行、筛选或执行任何返回部分行的操作时,我们的索引可能会看起来不按顺序(正如我们在之前的示例中看到的)。此时,索引仅代表数据中的行号,因此我们可能希望更改索引的值,使第一个条目出现在索引 0 位置。为了让 pandas 自动执行此操作,我们可以将 ignore_index=True 传递给 sort_values()。
Pandas 还提供了一种额外的方式来查看排序值的子集;我们可以使用 nlargest() 按照特定标准抓取具有最大值的 n 行,使用 nsmallest() 抓取具有最小值的 n 行,无需事先对数据进行排序。两者都接受列名列表或单列的字符串。让我们这次抓取按平均温度排序的前 10 天:
>>> df[df.datatype == 'TAVG'].nlargest(n=10, columns='temp_C')
我们找到了 10 月份最温暖的天数(平均温度):

图 3.20 – 排序以找到平均温度最高的 10 天
我们不仅限于对值进行排序;如果需要,我们甚至可以按字母顺序排列列,并按索引值对行进行排序。对于这些任务,我们可以使用sort_index()方法。默认情况下,sort_index()会针对行进行操作,以便我们在执行打乱操作后对索引进行排序。例如,sample()方法会随机选择若干行,这将导致索引混乱,所以我们可以使用sort_index()对它们进行排序:
>>> df.sample(5, random_state=0).index
Int64Index([2, 30, 55, 16, 13], dtype='int64')
>>> df.sample(5, random_state=0).sort_index().index
Int64Index([2, 13, 16, 30, 55], dtype='int64')
小贴士
如果我们希望sample()的结果是可重复的,可以传入random_state参数。种子初始化一个伪随机数生成器,只要使用相同的种子,结果就会是相同的。
当我们需要操作列时,必须传入axis=1;默认是操作行(axis=0)。请注意,这个参数在许多pandas方法和函数(包括sample())中都存在,因此理解其含义非常重要。我们可以利用这一点按字母顺序对数据框的列进行排序:
>>> df.sort_index(axis=1).head()
将列按字母顺序排列在使用loc[]时很有用,因为我们可以指定一系列具有相似名称的列;例如,现在我们可以使用df.loc[:,'station':'temp_F_whole']轻松获取所有温度列及站点信息:

图 3.21 – 按名称对列进行排序
重要提示
sort_index()和sort_values()都会返回新的DataFrame对象。我们必须传入inplace=True来更新正在处理的数据框。
sort_index()方法还可以帮助我们在测试两个数据框是否相等时获得准确的答案。Pandas 会检查,在数据相同的情况下,两个数据框的索引值是否也相同。如果我们按摄氏温度对数据框进行排序,并检查其是否与原数据框相等,pandas会告诉我们它们不相等。我们必须先对索引进行排序,才能看到它们是相同的:
>>> df.equals(df.sort_values(by='temp_C'))
False
>>> df.equals(df.sort_values(by='temp_C').sort_index())
True
有时,我们并不关心数字索引,但希望使用其他列中的一个(或多个)作为索引。在这种情况下,我们可以使用set_index()方法。让我们将date列设置为索引:
>>> df.set_index('date', inplace=True)
>>> df.head()
请注意,date列已移到最左侧,作为索引的位置,我们不再有数字索引:

图 3.22 – 将日期列设置为索引
小贴士
我们还可以提供一个列的列表,将其作为索引使用。这将创建一个MultiIndex对象,其中列表中的第一个元素是最外层级,最后一个是最内层级。我们将在数据框的透视部分进一步讨论这一点。
将索引设置为日期时间格式,让我们能够利用日期时间切片和索引功能,正如我们在第二章《处理 Pandas 数据框》中简要讨论的那样。只要我们提供pandas能够理解的日期格式,就可以提取数据。要选择 2018 年的所有数据,我们可以使用df.loc['2018'];要选择 2018 年第四季度的数据,我们可以使用df.loc['2018-Q4'];而要选择 10 月的数据,我们可以使用df.loc['2018-10']。这些也可以组合起来构建范围。请注意,在使用范围时,loc[]是可选的:
>>> df['2018-10-11':'2018-10-12']
这为我们提供了从 2018 年 10 月 11 日到 2018 年 10 月 12 日(包括这两个端点)的数据:

图 3.23 – 选择日期范围
我们可以使用reset_index()方法恢复date列:
>>> df['2018-10-11':'2018-10-12'].reset_index()
现在我们的索引从0开始,日期被放在一个叫做date的列中。如果我们有一些数据不想丢失在索引中,比如日期,但需要像没有在索引中一样进行操作,这种做法尤其有用:

图 3.24 – 重置索引
在某些情况下,我们可能有一个想要继续使用的索引,但需要将其对齐到某些特定的值。为此,我们可以使用reindex()方法。我们提供一个要对齐数据的索引,它会相应地调整索引。请注意,这个新索引不一定是数据的一部分——我们只是有一个索引,并希望将当前数据与之匹配。
作为一个例子,我们将使用sp500.csv文件中的 S&P 500 股票数据。它将date列作为索引并解析日期:
>>> sp = pd.read_csv(
... 'data/sp500.csv', index_col='date', parse_dates=True
... ).drop(columns=['adj_close']) # not using this column
让我们看看数据的样子,并为每一行标注星期几,以便理解索引包含的内容。我们可以轻松地从类型为DatetimeIndex的索引中提取日期部分。在提取日期部分时,pandas会给出我们所需的数值表示;如果我们需要字符串版本,我们应该先看看是否已经有现成的方法,而不是自己编写转换函数。在这种情况下,方法是day_name():
>>> sp.head(10)\
... .assign(day_of_week=lambda x: x.index.day_name())
小贴士
我们也可以通过系列来做这件事,但首先,我们需要访问dt属性。例如,如果在sp数据框中有一个date列,我们可以通过sp.date.dt.month提取月份。你可以在pandas.pydata.org/pandas-docs/stable/reference/series.html#datetimelike-properties找到可以访问的完整列表。
由于股市在周末(和假期)关闭,我们只有工作日的数据:

图 3.25 – S&P 500 OHLC 数据
如果我们正在分析一个包括标准普尔 500 指数和像比特币这样在周末交易的资产组合,我们需要为标准普尔 500 指数的每一天提供数据。否则,在查看我们投资组合的每日价值时,我们会看到市场休市时每天的巨大跌幅。为了说明这一点,让我们从 bitcoin.csv 文件中读取比特币数据,并将标准普尔 500 指数和比特币的数据结合成一个投资组合。比特币数据还包含 OHLC 数据和交易量,但它有一列叫做 market_cap 的数据,我们不需要,因此我们首先需要删除这列:
>>> bitcoin = pd.read_csv(
... 'data/bitcoin.csv', index_col='date', parse_dates=True
... ).drop(columns=['market_cap'])
要分析投资组合,我们需要按天汇总数据;这是第四章的内容,汇总 Pandas DataFrame,所以现在不用过于担心汇总是如何执行的——只需知道我们按天将数据进行求和。例如,每天的收盘价将是标准普尔 500 指数收盘价和比特币收盘价的总和:
# every day's closing price = S&P 500 close + Bitcoin close
# (same for other metrics)
>>> portfolio = pd.concat([sp, bitcoin], sort=False)\
... .groupby(level='date').sum()
>>> portfolio.head(10).assign(
... day_of_week=lambda x: x.index.day_name()
... )
现在,如果我们检查我们的投资组合,我们会看到每周的每一天都有数据;到目前为止,一切正常:

图 3.26 – 标准普尔 500 指数和比特币的投资组合
然而,这种方法有一个问题,通过可视化展示会更容易看出。绘图将在第五章中详细介绍,使用 Pandas 和 Matplotlib 可视化数据,以及第六章,使用 Seaborn 绘图与自定义技术,因此暂时不用担心细节:
>>> import matplotlib.pyplot as plt # module for plotting
>>> from matplotlib.ticker import StrMethodFormatter
# plot the closing price from Q4 2017 through Q2 2018
>>> ax = portfolio['2017-Q4':'2018-Q2'].plot(
... y='close', figsize=(15, 5), legend=False,
... title='Bitcoin + S&P 500 value without accounting '
... 'for different indices'
... )
# formatting
>>> ax.set_ylabel('price')
>>> ax.yaxis\
... .set_major_formatter(StrMethodFormatter('${x:,.0f}'))
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
# show the plot
>>> plt.show()
注意这里有一个周期性模式吗?它在每个市场关闭的日子都会下降,因为汇总时只能用比特币数据来填充那些天:

图 3.27 – 未考虑股市休市的投资组合收盘价
显然,这是一个问题;资产的价值不会因为市场关闭而降至零。如果我们希望 pandas 为我们填补缺失的数据,我们需要使用 reindex() 方法将标准普尔 500 指数的数据与比特币的索引重新对齐,并传递以下策略之一给 method 参数:
-
'ffill':该方法将值向前填充。在前面的示例中,这会将股市休市的那几天填充为股市上次开盘时的数据。 -
'bfill':该方法将值向后填充,这将导致将未来的数据传递到过去的日期,这意味着在这里并不是正确的选择。 -
'nearest':该方法根据最接近缺失行的行来填充,在这个例子中,这将导致周日获取下一个周一的数据,而周六获取前一个周五的数据。
前向填充似乎是最好的选择,但由于我们不确定,我们首先会查看数据的几行,看看它的效果:
>>> sp.reindex(bitcoin.index, method='ffill').head(10)\
... .assign(day_of_week=lambda x: x.index.day_name())
你注意到这个有问题吗?嗯,volume(成交量)列让它看起来像我们用前向填充的那些天,实际上是市场开放的日期:

图 3.28 – 前向填充缺失值的日期
提示
compare() 方法将展示在标记相同的数据框中(相同的索引和列)不同的值;我们可以使用它来在这里进行前向填充时,隔离数据中的变化。在笔记本中有一个示例。
理想情况下,我们只希望在股市关闭时保持股票的值——成交量应该为零。为了以不同的方式处理每列中的 NaN 值,我们将使用 assign() 方法。为了用 0 填充 volume(成交量)列中的任何 NaN 值,我们将使用 fillna() 方法,这部分我们将在本章稍后的 处理重复、缺失或无效数据 部分详细介绍。fillna() 方法还允许我们传入一个方法而不是一个值,这样我们就可以对 close(收盘价)列进行前向填充,而这个列是我们之前尝试中唯一合适的列。最后,我们可以对其余的列使用 np.where() 函数,这使得我们可以构建一个向量化的 if...else。它的形式如下:
np.where(boolean condition, value if True, value if False)
pandas 中,我们应避免编写循环,而应该使用向量化操作以提高性能。NumPy 函数设计用于操作数组,因此它们非常适合用于高性能的 pandas 代码。这将使我们能够轻松地将 open(开盘价)、high(最高价)或 low(最低价)列中的任何 NaN 值替换为同一天 close(收盘价)列中的值。由于这些操作发生在处理完 close 列之后,我们将可以使用前向填充的 close 值来填充其他列中需要的地方:
>>> import numpy as np
>>> sp_reindexed = sp.reindex(bitcoin.index).assign(
... # volume is 0 when the market is closed
... volume=lambda x: x.volume.fillna(0),
... # carry this forward
... close=lambda x: x.close.fillna(method='ffill'),
... # take the closing price if these aren't available
... open=lambda x: \
... np.where(x.open.isnull(), x.close, x.open),
... high=lambda x: \
... np.where(x.high.isnull(), x.close, x.high),
... low=lambda x: np.where(x.low.isnull(), x.close, x.low)
... )
>>> sp_reindexed.head(10).assign(
... day_of_week=lambda x: x.index.day_name()
... )
在 1 月 7 日星期六和 1 月 8 日星期日,我们现在的成交量为零。OHLC(开盘、最高、最低、收盘)价格都等于 1 月 6 日星期五的收盘价:

图 3.29 – 根据每列的特定策略重新索引 S&P 500 数据
提示
在这里,我们使用 np.where() 来引入一个我们将在本书中反复看到的函数,并且让它更容易理解发生了什么,但请注意,np.where(x.open.isnull(), x.close, x.open) 可以被 combine_first() 方法替代,在这个用例中它等同于 x.open.combine_first(x.close)。我们将在本章稍后的 处理重复、缺失或无效数据 部分中使用 combine_first() 方法。
现在,让我们使用重新索引后的标准普尔 500 数据重建投资组合,并使用可视化将其与之前的尝试进行比较(再次说明,不用担心绘图代码,这部分内容将在第五章“使用 Pandas 和 Matplotlib 可视化数据”以及第六章“使用 Seaborn 和自定义技术绘图”中详细讲解):
# every day's closing price = S&P 500 close adjusted for
# market closure + Bitcoin close (same for other metrics)
>>> fixed_portfolio = sp_reindexed + bitcoin
# plot the reindexed portfolio's close (Q4 2017 - Q2 2018)
>>> ax = fixed_portfolio['2017-Q4':'2018-Q2'].plot(
... y='close', figsize=(15, 5), linewidth=2,
... label='reindexed portfolio of S&P 500 + Bitcoin',
... title='Reindexed portfolio vs.'
... 'portfolio with mismatched indices'
... )
# add line for original portfolio for comparison
>>> portfolio['2017-Q4':'2018-Q2'].plot(
... y='close', ax=ax, linestyle='--',
... label='portfolio of S&P 500 + Bitcoin w/o reindexing'
... )
# formatting
>>> ax.set_ylabel('price')
>>> ax.yaxis\
... .set_major_formatter(StrMethodFormatter('${x:,.0f}'))
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
# show the plot
>>> plt.show()
橙色虚线是我们最初尝试研究投资组合的结果(未重新索引),而蓝色实线是我们刚刚使用重新索引以及为每列采用不同填充策略构建的投资组合。请在第七章“金融分析——比特币与股市”的练习中牢记此策略:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_3.30_B16834.jpg)
图 3.30 – 可视化重新索引的效果
提示
我们还可以使用reindex()方法重新排序行。例如,如果我们的数据存储在x中,那么x.reindex([32, 20, 11])将返回一个新的DataFrame对象,包含三行:32、20 和 11(按此顺序)。也可以通过axis=1沿列进行此操作(默认值是axis=0,用于行)。
现在,让我们将注意力转向数据重构。回想一下,我们首先需要通过datatype列筛选温度数据,然后进行排序以找出最热的日子;重构数据将使这一过程更加简便,并使我们能够聚合和总结数据。
数据重构
数据并不总是以最方便我们分析的格式提供给我们。因此,我们需要能够根据我们想要进行的分析,将数据重构为宽格式或长格式。对于许多分析,我们希望使用宽格式数据,以便能够轻松查看汇总统计信息,并以这种格式分享我们的结果。
然而,数据重构并不总是像从长格式到宽格式或反之那样简单。请考虑以下来自练习部分的数据:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_3.31_B16834.jpg)
图 3.31 – 一些列为长格式,一些列为宽格式的数据
有时数据的某些列可能是宽格式的(对这些数据使用describe()并没有帮助,除非我们先使用pandas进行筛选——我们需要使用seaborn。另外,我们也可以将数据重构为适合该可视化的格式。
现在我们理解了重构数据的动机,接下来让我们转到下一个笔记本4-reshaping_data.ipynb。我们将从导入pandas并读取long_data.csv文件开始,添加华氏温度列(temp_F),并进行一些我们刚刚学到的数据清理操作:
>>> import pandas as pd
>>> long_df = pd.read_csv(
... 'data/long_data.csv',
... usecols=['date', 'datatype', 'value']
... ).rename(columns={'value': 'temp_C'}).assign(
... date=lambda x: pd.to_datetime(x.date),
... temp_F=lambda x: (x.temp_C * 9/5) + 32
... )
我们的长格式数据如下所示:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_3.32_B16834.jpg)
图 3.32 – 长格式的温度数据
在本节中,我们将讨论数据的转置、透视和熔化。请注意,重塑数据后,我们通常会重新访问数据清理任务,因为数据可能已发生变化,或者我们可能需要修改之前无法轻易访问的内容。例如,如果在长格式下,所有值都变成了字符串,我们可能需要进行类型转换,但在宽格式下,一些列显然是数字类型的。
转置数据框(DataFrames)
虽然我们大多数时候只会使用宽格式或长格式,pandas提供了重构数据的方式,我们可以根据需要调整,包括执行转置(将行和列交换),这在打印数据框部分内容时可能有助于更好地利用显示区域:
>>> long_df.set_index('date').head(6).T
请注意,索引现在已转换为列,而列名已变为索引:

图 3.33 – 转置后的温度数据
这可能一开始不太显而易见有多有用,但在本书中,我们会看到多次使用这种方法;例如,在第七章中,财务分析——比特币与股票市场,以及在第九章中,Python 中的机器学习入门,我们都会使用它来让内容更容易显示,并为机器学习构建特定的可视化。
数据框透视
我们的pivot()方法执行了这种数据框(DataFrame)对象的重构。为了进行数据透视,我们需要告诉pandas哪个列包含当前的值(通过values参数),以及哪个列将成为宽格式下的列名(通过columns参数)。我们还可以选择提供新的索引(通过index参数)。让我们将数据透视为宽格式,其中每一列都代表一个摄氏温度测量,并使用日期作为索引:
>>> pivoted_df = long_df.pivot(
... index='date', columns='datatype', values='temp_C'
... )
>>> pivoted_df.head()
在我们初始的数据框中,有一个datatype列,其中只包含TMAX、TMIN或TOBS作为字符串。现在,这些已经变成了列名,因为我们传入了columns='datatype'。通过传入index='date',date列成为了我们的索引,而无需运行set_index()。最后,对于每个date和datatype的组合,temp_C列中的值是对应的摄氏温度:

图 3.34 – 将长格式温度数据透视为宽格式
正如我们在本章开头讨论的那样,数据处于宽格式时,我们可以轻松地使用describe()方法获取有意义的汇总统计:
>>> pivoted_df.describe()
我们可以看到,对于所有三种温度测量,我们有 31 个观测值,并且本月的温度变化范围很广(最高日最高气温为 26.7°C,最低日最低气温为-1.1°C):

图 3.35 – 透视温度数据的汇总统计
不过,我们失去了华氏温度。如果我们想保留它,可以将多个列提供给values:
>>> pivoted_df = long_df.pivot(
... index='date', columns='datatype',
... values=['temp_C', 'temp_F']
... )
>>> pivoted_df.head()
然而,我们现在在列名上方增加了一个额外的层级。这被称为层级索引:

图 3.36 – 使用多个值列进行透视
使用这个层级索引,如果我们想选择华氏温度中的TMIN,我们首先需要选择temp_F,然后选择TMIN:
>>> pivoted_df['temp_F']['TMIN'].head()
date
2018-10-01 48.02
2018-10-02 57.02
2018-10-03 60.08
2018-10-04 53.06
2018-10-05 53.06
Name: TMIN, dtype: float64
重要提示
在需要在透视时进行聚合(因为索引中存在重复值)的情况下,我们可以使用pivot_table()方法,我们将在第四章中讨论,聚合 Pandas 数据框。
在本章中,我们一直使用单一索引;然而,我们可以使用set_index()从任意数量的列创建索引。这将给我们一个MultiIndex类型的索引,其中最外层对应于提供给set_index()的列表中的第一个元素:
>>> multi_index_df = long_df.set_index(['date', 'datatype'])
>>> multi_index_df.head().index
MultiIndex([('2018-10-01', 'TMAX'),
('2018-10-01', 'TMIN'),
('2018-10-01', 'TOBS'),
('2018-10-02', 'TMAX'),
('2018-10-02', 'TMIN')],
names=['date', 'datatype'])
>>> multi_index_df.head()
请注意,现在我们在索引中有两个层级——date是最外层,datatype是最内层:

图 3.37 – 操作多级索引
pivot()方法期望数据只有一列可以设置为索引;如果我们有多级索引,我们应该改用unstack()方法。我们可以在multi_index_df上使用unstack(),并得到与之前类似的结果。顺序在这里很重要,因为默认情况下,unstack()会将索引的最内层移到列中;在这种情况下,这意味着我们将保留date层级在索引中,并将datatype层级移到列名中。要解开不同层级,只需传入要解开的层级的索引,0 表示最左侧,-1 表示最右侧,或者传入该层级的名称(如果有)。这里,我们将使用默认设置:
>>> unstacked_df = multi_index_df.unstack()
>>> unstacked_df.head()
在multi_index_df中,我们将datatype作为索引的最内层,因此,在使用unstack()后,它出现在列中。注意,我们再次在列中有了层级索引。在第四章中,聚合 Pandas 数据框,我们将讨论如何将其压缩回单一层级的列:

图 3.38 – 解开多级索引以转换数据
unstack()方法的一个额外好处是,它允许我们指定如何填充在数据重塑过程中出现的缺失值。为此,我们可以使用fill_value参数。假设我们只得到 2018 年 10 月 1 日的TAVG数据。我们可以将其附加到long_df,并将索引设置为date和datatype列,正如我们之前所做的:
>>> extra_data = long_df.append([{
... 'datatype': 'TAVG',
... 'date': '2018-10-01',
... 'temp_C': 10,
... 'temp_F': 50
... }]).set_index(['date', 'datatype']).sort_index()
>>> extra_data['2018-10-01':'2018-10-02']
我们现在有了 2018 年 10 月 1 日的四个温度测量值,但剩余的日期只有三个:

图 3.39 – 向数据中引入额外的温度测量
使用 unstack(),正如我们之前所做的,将会导致大部分 TAVG 数据变为 NaN 值:
>>> extra_data.unstack().head()
看一下我们解压栈后的 TAVG 列:

图 3.40 – 解压栈可能会导致空值
为了应对这个问题,我们可以传入适当的 fill_value。然而,我们仅能为此传入一个值,而非策略(正如我们在讨论重建索引时所看到的),因此,虽然在这个案例中没有合适的值,我们可以使用 -40 来说明这种方法是如何工作的:
>>> extra_data.unstack(fill_value=-40).head()
现在,NaN 值已经被 -40.0 替换。然而,值得注意的是,现在 temp_C 和 temp_F 都有相同的温度读数。实际上,这就是我们选择 -40 作为 fill_value 的原因;这是摄氏度和华氏度相等的温度,因此我们不会因为它们是相同的数字而混淆人们;比如 0(因为 0°C = 32°F 和 0°F = -17.78°C)。由于这个温度也远低于纽约市的测量温度,并且低于我们所有数据的 TMIN,它更可能被认为是数据输入错误或数据缺失的信号,而不是如果我们使用了 0 的情况。请注意,实际上,如果我们与他人共享数据时,最好明确指出缺失的数据,并保留 NaN 值:

图 3.41 – 使用默认值解压栈以填补缺失的组合
总结来说,当我们有一个多级索引并希望将其中一个或多个级别移动到列时,unstack() 应该是我们的首选方法;然而,如果我们仅使用单一索引,pivot() 方法的语法可能更容易正确指定,因为哪个数据会出现在何处更加明确。
熔化数据框
要从宽格式转为长格式,我们需要 wide_data.csv 文件:
>>> wide_df = pd.read_csv('data/wide_data.csv')
>>> wide_df.head()
我们的宽格式数据包含一个日期列和每个温度测量列:

图 3.42 – 宽格式温度数据
我们可以使用 melt() 方法进行灵活的重塑—使我们能够将其转为长格式,类似于从 API 获取的数据。重塑需要我们指定以下内容:
-
哪一列(们)能唯一标识宽格式数据中的一行,使用
id_vars参数 -
哪一列(们)包含
value_vars参数的变量(们)
可选地,我们还可以指定如何命名包含变量名的列(var_name)和包含变量值的列(value_name)。默认情况下,这些列名将分别为 variable 和 value。
现在,让我们使用 melt() 方法将宽格式数据转换为长格式:
>>> melted_df = wide_df.melt(
... id_vars='date', value_vars=['TMAX', 'TMIN', 'TOBS'],
... value_name='temp_C', var_name='measurement'
... )
>>> melted_df.head()
date 列是我们行的标识符,因此我们将其作为 id_vars 提供。我们将 TMAX、TMIN 和 TOBS 列中的值转换为一个单独的列,列中包含温度(value_vars),并将它们的列名作为测量列的值(var_name='measurement')。最后,我们将值列命名为(value_name='temp_C')。现在我们只有三列;日期、摄氏度的温度读数(temp_C),以及一个列,指示该行的 temp_C 单元格中的温度测量(measurement):


图 3.43 – 将宽格式的温度数据转化为长格式
就像我们有另一种方法通过 unstack() 方法对数据进行透视一样,我们也有另一种方法通过 stack() 方法对数据进行熔化。该方法会将列透视到索引的最内层(导致生成 MultiIndex 类型的索引),因此我们在调用该方法之前需要仔细检查索引。它还允许我们在选择时删除没有数据的行/列组合。我们可以通过以下方式获得与 melt() 方法相似的输出:
>>> wide_df.set_index('date', inplace=True)
>>> stacked_series = wide_df.stack() # put datatypes in index
>>> stacked_series.head()
date
2018-10-01 TMAX 21.1
TMIN 8.9
TOBS 13.9
2018-10-02 TMAX 23.9
TMIN 13.9
dtype: float64
注意,结果返回的是一个 Series 对象,因此我们需要重新创建 DataFrame 对象。我们可以使用 to_frame() 方法,并传入一个名称,用于在数据框中作为列名:
>>> stacked_df = stacked_series.to_frame('values')
>>> stacked_df.head()
现在,我们有一个包含多层索引的 DataFrame,其中包含 date 和 datatype,values 作为唯一列。然而,注意到的是,只有索引中的 date 部分有名称:


图 3.44 – 堆叠数据以将温度数据转化为长格式
最初,我们使用 set_index() 将索引设置为 date 列,因为我们不想对其进行熔化;这形成了多层索引的第一层。然后,stack() 方法将 TMAX、TMIN 和 TOBS 列移动到索引的第二层。然而,这一层从未命名,因此显示为 None,但我们知道这一层应该命名为 datatype:
>>> stacked_df.head().index
MultiIndex([('2018-10-01', 'TMAX'),
('2018-10-01', 'TMIN'),
('2018-10-01', 'TOBS'),
('2018-10-02', 'TMAX'),
('2018-10-02', 'TMIN')],
names=['date', None])
我们可以使用 set_names() 方法来处理这个问题:
>>> stacked_df.index\
... .set_names(['date', 'datatype'], inplace=True)
>>> stacked_df.index.names
FrozenList(['date', 'datatype'])
现在我们已经了解了数据清洗和重塑的基础知识,接下来我们将通过一个示例来展示如何将这些技巧结合使用,处理包含各种问题的数据。
处理重复、缺失或无效数据
到目前为止,我们讨论的是可以改变数据表示方式而不会带来后果的事情。然而,我们还没有讨论数据清理中一个非常重要的部分:如何处理看起来是重复的、无效的或缺失的数据。这部分与数据清理的其他内容分开讨论,因为它是一个需要我们做一些初步数据清理、重塑数据,并最终处理这些潜在问题的例子;这也是一个相当庞大的话题。
我们将在 5-handling_data_issues.ipynb 笔记本中工作,并使用 dirty_data.csv 文件。让我们先导入 pandas 并读取数据:
>>> import pandas as pd
>>> df = pd.read_csv('data/dirty_data.csv')
dirty_data.csv 文件包含来自天气 API 的宽格式数据,经过修改以引入许多我们在实际中会遇到的常见数据问题。它包含以下字段:
-
PRCP: 降水量(毫米) -
SNOW: 降雪量(毫米) -
SNWD: 雪深(毫米) -
TMAX: 日最高温度(摄氏度) -
TMIN: 日最低温度(摄氏度) -
TOBS: 观察时的温度(摄氏度) -
WESF: 雪的水当量(毫米)
本节分为两部分。在第一部分,我们将讨论一些揭示数据集问题的策略,在第二部分,我们将学习如何减轻数据集中的一些问题。
寻找问题数据
在第二章《与 Pandas DataFrames 一起工作》中,我们学习了获取数据时检查数据的重要性;并非巧合的是,许多检查数据的方法将帮助我们发现这些问题。调用 head() 和 tail() 来检查数据的结果始终是一个好的第一步:
>>> df.head()
实际上,head() 和 tail() 并不像我们将在本节讨论的其他方法那样强大,但我们仍然可以通过从这里开始获取一些有用的信息。我们的数据是宽格式的,快速浏览一下,我们可以看到一些潜在的问题。有时,station 字段记录为 ?,而有时则记录为站点 ID。我们有些雪深度 (SNWD) 的值是负无穷大 (-inf),同时 TMAX 也有一些非常高的温度。最后,我们可以看到多个列中有许多 NaN 值,包括 inclement_weather 列,这列似乎还包含布尔值:

图 3.45 – 脏数据
使用 describe(),我们可以查看是否有缺失数据,并通过五数概括(5-number summary)来发现潜在的问题:
>>> df.describe()
SNWD列似乎没什么用,而TMAX列看起来也不可靠。为了提供参考,太阳光球的温度大约是 5,505°C,因此我们肯定不会在纽约市(或者地球上的任何地方)看到如此高的气温。这很可能意味着当TMAX列的数据不可用时,它被设置为一个荒谬的大数字。它之所以能通过describe()函数得到的总结统计结果被识别出来,正是因为这个值过大。如果这些未知值是通过其他值来编码的,比如 40°C,那么我们就不能确定这是不是实际的数据:

图 3.46 – 脏数据的总结统计
我们可以使用info()方法查看是否有缺失值,并检查我们的列是否具有预期的数据类型。这样做时,我们立即发现两个问题:我们有 765 行数据,但其中五列的非空值条目远少于其他列。该输出还告诉我们inclement_weather列的数据类型不是布尔值,尽管从名称上我们可能以为是。注意,当我们使用head()时,station列中看到的?值在这里没有出现——从多个角度检查数据非常重要:
>>> df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 765 entries, 0 to 764
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 date 765 non-null object
1 station 765 non-null object
2 PRCP 765 non-null float64
3 SNOW 577 non-null float64
4 SNWD 577 non-null float64
5 TMAX 765 non-null float64
6 TMIN 765 non-null float64
7 TOBS 398 non-null float64
8 WESF 11 non-null float64
9 inclement_weather 408 non-null object
dtypes: float64(7), object(3)
memory usage: 59.9+ KB
现在,让我们找出这些空值。Series和DataFrame对象提供了两个方法来实现这一点:isnull()和isna()。请注意,如果我们在DataFrame对象上使用该方法,结果将告诉我们哪些行完全为空值,而这并不是我们想要的。在这种情况下,我们想检查那些在SNOW、SNWD、TOBS、WESF或inclement_weather列中含有空值的行。这意味着我们需要结合每列的检查,并使用|(按位或)运算符:
>>> contain_nulls = df[
... df.SNOW.isna() | df.SNWD.isna() | df.TOBS.isna()
... | df.WESF.isna() | df.inclement_weather.isna()
... ]
>>> contain_nulls.shape[0]
765
>>> contain_nulls.head(10)
如果我们查看contain_nulls数据框的shape属性,我们会看到每一行都包含一些空数据。查看前 10 行时,我们可以看到每行中都有一些NaN值:

图 3.47 – 脏数据中的含有空值的行
提示
默认情况下,我们在本章早些时候讨论的sort_values()方法会将任何NaN值放在最后。如果我们希望将它们放在最前面,可以通过传递na_position='first'来改变这种行为,这在数据排序列含有空值时,查找数据模式也会很有帮助。
请注意,我们无法检查列的值是否等于NaN,因为NaN与任何值都不相等:
>>> import numpy as np
>>> df[df.inclement_weather == 'NaN'].shape[0] # doesn't work
0
>>> df[df.inclement_weather == np.nan].shape[0] # doesn't work
0
我们必须使用上述选项(isna()/isnull()):
>>> df[df.inclement_weather.isna()].shape[0] # works
357
请注意,inf和-inf实际上是np.inf和-np.inf。因此,我们可以通过以下方式找到包含inf或-inf值的行数:
>>> df[df.SNWD.isin([-np.inf, np.inf])].shape[0]
577
这仅仅告诉我们关于单一列的信息,因此我们可以编写一个函数,使用字典推导式返回每列中无限值的数量:
>>> def get_inf_count(df):
... """Find the number of inf/-inf values per column"""
... return {
... col: df[
... df[col].isin([np.inf, -np.inf])
... ].shape[0] for col in df.columns
... }
使用我们的函数,我们发现SNWD列是唯一一个具有无限值的列,但该列中的大多数值都是无限的:
>>> get_inf_count(df)
{'date': 0, 'station': 0, 'PRCP': 0, 'SNOW': 0, 'SNWD': 577,
'TMAX': 0, 'TMIN': 0, 'TOBS': 0, 'WESF': 0,
'inclement_weather': 0}
在我们决定如何处理雪深的无限值之前,我们应该查看降雪(SNOW)的总结统计信息,因为这在确定雪深(SNWD)时占据了很大一部分。为此,我们可以创建一个包含两列的数据框,其中一列包含当雪深为np.inf时的降雪列的总结统计信息,另一列则包含当雪深为-np.inf时的总结统计信息。此外,我们将使用T属性对数据进行转置,以便更容易查看:
>>> pd.DataFrame({
... 'np.inf Snow Depth':
... df[df.SNWD == np.inf].SNOW.describe(),
... '-np.inf Snow Depth':
... df[df.SNWD == -np.inf].SNOW.describe()
... }).T
当没有降雪时,雪深被记录为负无穷;然而,我们不能确定这是不是只是巧合。如果我们只是处理这个固定的日期范围,我们可以将其视为雪深为0或NaN,因为没有降雪。不幸的是,我们无法对正无穷的条目做出任何假设。它们肯定不是真正的无穷大,但我们无法决定它们应该是什么,因此最好还是将其忽略,或者不看这一列:

Figure 3.48 – 当雪深为无穷大时的降雪总结统计信息
我们正在处理一年的数据,但不知为何我们有 765 行数据,所以我们应该检查一下原因。我们尚未检查的唯一列是date和station列。我们可以使用describe()方法查看它们的总结统计信息:
>>> df.describe(include='object')
在 765 行数据中,date列只有 324 个唯一值(意味着某些日期缺失),有些日期出现了多达八次(station列,最常见的值为?,当我们之前使用head()查看时(Figure 3.45),我们知道那是另一个值;不过如果我们没有使用unique(),我们可以查看所有唯一值)。我们还知道?出现了 367 次(765 - 398),无需使用value_counts():

Figure 3.49 – 脏数据中非数值列的总结统计信息
在实际操作中,我们可能不知道为什么有时站点被记录为?——这可能是故意的,表示他们没有该站点,或者是记录软件的错误,或者是由于意外遗漏导致被编码为?。我们如何处理这种情况将是一个判断问题,正如我们将在下一部分讨论的那样。
当我们看到有 765 行数据和两个不同的站点 ID 值时,我们可能会假设每天有两条记录——每个站点一条。然而,这样只能解释 730 行数据,而且我们现在也知道一些日期缺失了。我们来看看能否找到任何可能的重复数据来解释这个问题。我们可以使用duplicated()方法的结果作为布尔掩码来查找重复的行:
>>> df[df.duplicated()].shape[0]
284
根据我们试图实现的目标不同,我们可能会以不同的方式处理重复项。返回的行可以用keep参数进行修改。默认情况下,它是'first',对于每个出现超过一次的行,我们只会得到额外的行(除了第一个)。但是,如果我们传入keep=False,我们将得到所有出现超过一次的行,而不仅仅是每个额外的出现:
>>> df[df.duplicated(keep=False)].shape[0]
482
还有一个subset参数(第一个位置参数),它允许我们只专注于某些列的重复项。使用这个参数,我们可以看到当date和station列重复时,其余数据也重复了,因为我们得到了与之前相同的结果。然而,我们不知道这是否真的是一个问题:
>>> df[df.duplicated(['date', 'station'])].shape[0]
284
现在,让我们检查一些重复的行:
>>> df[df.duplicated()].head()
只看前五行就能看到一些行至少重复了三次。请记住,duplicated()的默认行为是不显示第一次出现,这意味着第 1 行和第 2 行在数据中有另一个匹配值(第 5 行和第 6 行也是如此):

图 3.50 – 检查重复数据
现在我们知道如何在我们的数据中找到问题了,让我们学习一些可以尝试解决它们的方法。请注意,这里没有万能药,通常都要了解我们正在处理的数据并做出判断。
缓解问题
我们的数据处于一个不理想的状态,尽管我们可以努力改善它,但最佳的行动计划并不总是明显的。也许当面对这类数据问题时,我们可以做的最简单的事情就是删除重复的行。然而,评估这样一个决定可能对我们的分析产生的后果至关重要。即使在看起来我们处理的数据是从一个有额外列的更大数据集中收集而来,从而使我们所有的数据都是不同的,我们也不能确定移除这些列是导致剩余数据重复的原因——我们需要查阅数据的来源和任何可用的文档。
由于我们知道两个站点都属于纽约市,我们可能决定删除station列——它们可能只是在收集不同的数据。如果我们然后决定使用date列删除重复行,并保留非?站点的数据,在重复的情况下,我们将失去所有关于WESF列的数据,因为?站点是唯一报告WESF测量值的站点:
>>> df[df.WESF.notna()].station.unique()
array(['?'], dtype=object)
在这种情况下,一个令人满意的解决方案可能是执行以下操作:
-
对
date列进行类型转换:>>> df.date = pd.to_datetime(df.date) -
将
WESF列保存为一个系列:>>> station_qm_wesf = df[df.station == '?']\ ... .drop_duplicates('date').set_index('date').WESF -
按
station列降序排序数据框,以将没有 ID(?)的站点放在最后:>>> df.sort_values( ... 'station', ascending=False, inplace=True ... ) -
删除基于日期的重复行,保留首次出现的行,即那些
station列有 ID 的行(如果该站点有测量数据)。需要注意的是,drop_duplicates()可以原地操作,但如果我们要做的事情比较复杂,最好不要一开始就使用原地操作:>>> df_deduped = df.drop_duplicates('date') -
丢弃
station列,并将索引设置为date列(这样它就能与WESF数据匹配):>>> df_deduped = df_deduped.drop(columns='station')\ ... .set_index('date').sort_index() -
使用
combine_first()方法更新WESF列为?。由于df_deduped和station_qm_wesf都使用日期作为索引,因此值将正确匹配到相应的日期:>>> df_deduped = df_deduped.assign(WESF= ... lambda x: x.WESF.combine_first(station_qm_wesf) ... )
这可能听起来有些复杂,但主要是因为我们还没有学习聚合的内容。在第四章《聚合 Pandas DataFrame》中,我们将学习另一种方法来处理这个问题。让我们通过上述实现查看结果:
>>> df_deduped.shape
(324, 8)
>>> df_deduped.head()
现在,我们剩下 324 行——每行代表数据中的一个独特日期。我们通过将WESF列与其他站点的数据并排放置,成功保存了WESF列:

图 3.51 – 使用数据整理保持 WESF 列中的信息
提示
我们还可以指定保留最后一项数据而不是第一项,或者使用keep参数丢弃所有重复项,就像我们使用duplicated()检查重复项时一样。记住,duplicated()方法可以帮助我们在去重任务中做干运行,以确认最终结果。
现在,让我们处理空值数据。我们可以选择丢弃空值、用任意值替换,或使用周围的数据进行填充。每个选项都有其影响。如果我们丢弃数据,我们的分析将只基于部分数据;如果我们丢弃了大部分行,这将对结果产生很大影响。更改数据值时,我们可能会影响分析的结果。
要删除包含任何空值的行(这不必在行的所有列中都为真,因此要小心),我们使用dropna()方法;在我们的例子中,这会留下 4 行数据:
>>> df_deduped.dropna().shape
(4, 8)
我们可以通过how参数更改默认行为,仅在所有列都为空时才删除行,但这样做并不会删除任何内容:
>>> df_deduped.dropna(how='all').shape # default is 'any'
(324, 8)
幸运的是,我们还可以使用部分列来确定需要丢弃的内容。假设我们想查看雪的数据;我们很可能希望确保数据中包含SNOW、SNWD和inclement_weather的值。这可以通过subset参数来实现:
>>> df_deduped.dropna(
... how='all', subset=['inclement_weather', 'SNOW', 'SNWD']
... ).shape
(293, 8)
请注意,此操作也可以沿着列进行,并且我们可以通过thresh参数设置必须观察到的空值数量阈值,以决定是否丢弃数据。例如,如果我们要求至少 75%的行必须为空才能丢弃该列,那么我们将丢弃WESF列:
>>> df_deduped.dropna(
... axis='columns',
... thresh=df_deduped.shape[0] * .75 # 75% of rows
... ).columns
Index(['PRCP', 'SNOW', 'SNWD', 'TMAX', 'TMIN', 'TOBS',
'inclement_weather'],
dtype='object')
由于我们有很多空值,我们可能更关心保留这些空值,并可能找到一种更好的方式来表示它们。如果我们替换空数据,必须在决定填充什么内容时谨慎;用其他值填充我们没有的数据可能会导致后续产生奇怪的结果,因此我们必须首先思考如何使用这些数据。
要用其他数据填充空值,我们使用fillna()方法,它允许我们指定一个值或填充策略。我们首先讨论用单一值填充的情况。WESF列大多是空值,但由于它是以毫升为单位的测量,当没有降雪的水当量时,它的值为NaN,因此我们可以用零来填充这些空值。请注意,这可以就地完成(同样,作为一般规则,我们应该谨慎使用就地操作):
>>> df_deduped.loc[:,'WESF'].fillna(0, inplace=True)
>>> df_deduped.head()
WESF列不再包含NaN值:

图 3.52 – 填充 WESF 列中的空值
到目前为止,我们已经做了所有不扭曲数据的工作。我们知道缺少日期,但如果重新索引,我们不知道如何填充结果中的NaN值。对于天气数据,我们不能假设某天下雪了就意味着第二天也会下雪,或者温度会相同。因此,请注意,以下示例仅供说明用途——只是因为我们能做某件事,并不意味着我们应该这样做。正确的解决方案很可能取决于领域和我们希望解决的问题。
话虽如此,让我们试着解决一些剩余的温度数据问题。我们知道当TMAX表示太阳的温度时,必须是因为没有测量值,因此我们将其替换为NaN。对于TMIN,目前使用-40°C 作为占位符,尽管纽约市记录的最低温度是 1934 年 2 月 9 日的-15°F(-26.1°C)(www.weather.gov/media/okx/Climate/CentralPark/extremes.pdf),我们也会进行同样的处理:
>>> df_deduped = df_deduped.assign(
... TMAX=lambda x: x.TMAX.replace(5505, np.nan),
... TMIN=lambda x: x.TMIN.replace(-40, np.nan)
... )
我们还会假设温度在不同日期之间不会剧烈变化。请注意,这其实是一个很大的假设,但它将帮助我们理解当我们通过method参数提供策略时,fillna()方法如何工作:'ffill'向前填充或'bfill'向后填充。注意我们没有像在重新索引时那样使用'nearest'选项,这本来是最佳选择;因此,为了说明这种方法的工作原理,我们将使用向前填充:
>>> df_deduped.assign(
... TMAX=lambda x: x.TMAX.fillna(method='ffill'),
... TMIN=lambda x: x.TMIN.fillna(method='ffill')
... ).head()
查看 1 月 1 日和 4 日的TMAX和TMIN列。1 日的值都是NaN,因为我们没有之前的数据可以向前填充,但 4 日现在的值和 3 日相同:

图 3.53 – 向前填充空值
如果我们想处理SNWD列中的空值和无限值,我们可以使用np.nan_to_num()函数;它会将NaN转换为 0,将inf/-inf转换为非常大的正/负有限数字,从而使得机器学习模型(在第九章**, 用 Python 入门机器学习中讨论)可以从这些数据中学习:
>>> df_deduped.assign(
... SNWD=lambda x: np.nan_to_num(x.SNWD)
... ).head()
然而,这对于我们的用例来说意义不大。对于-np.inf的情况,我们可以选择将SNWD设置为 0,因为我们已经看到这些天没有降雪。然而,我们不知道该如何处理np.inf,而且较大的正数无疑使得数据更难以解读:

图 3.54 – 替换无限值
根据我们处理的数据,我们可能选择使用clip()方法作为np.nan_to_num()函数的替代方法。clip()方法使得我们能够将值限制在特定的最小值和/或最大值阈值内。由于雪深不能为负值,我们可以使用clip()强制设定下限为零。为了展示上限如何工作,我们将使用降雪量(SNOW)作为估算值:
>>> df_deduped.assign(
... SNWD=lambda x: x.SNWD.clip(0, x.SNOW)
... ).head()
1 月 1 日至 3 日的SNWD值现在为0,而不是-inf,1 月 4 日和 5 日的SNWD值从inf变为当日的SNOW值:

图 3.55 – 将值限制在阈值内
我们的最后一个策略是插补。当我们用从数据中得出的新值替代缺失值时,使用汇总统计数据或其他观测值的数据,这种方法叫做插补。例如,我们可以用平均值来替代温度值。不幸的是,如果我们仅在十月月底缺失数据,而我们用剩余月份的平均值替代,这可能会偏向极端值,在这个例子中是十月初的较高温度。就像本节中讨论的其他内容一样,我们必须小心谨慎,考虑我们的行为可能带来的任何后果或副作用。
我们可以将插补与fillna()方法结合使用。例如,我们可以用TMAX和TMIN的中位数来填充NaN值,用TMIN和TMAX的平均值来填充TOBS(在插补后):
>>> df_deduped.assign(
... TMAX=lambda x: x.TMAX.fillna(x.TMAX.median()),
... TMIN=lambda x: x.TMIN.fillna(x.TMIN.median()),
... # average of TMAX and TMIN
... TOBS=lambda x: x.TOBS.fillna((x.TMAX + x.TMIN) / 2)
... ).head()
从 1 月 1 日和 4 日数据的变化可以看出,最大和最小温度的中位数分别是 14.4°C 和 5.6°C。这意味着,当我们插补TOBS且数据中没有TMAX和TMIN时,我们得到 10°C:

图 3.56 – 使用汇总统计数据插补缺失值
如果我们想对所有列进行相同的计算,我们应该使用apply()方法,而不是assign(),因为这样可以避免为每一列重复写相同的计算。例如,让我们用滚动 7 天中位数填充所有缺失值,并将计算所需的周期数设置为零,以确保我们不会引入额外的空值。我们将在第四章《聚合 Pandas 数据框》中详细讲解滚动计算和apply()方法,所以这里只是一个预览:
>>> df_deduped.apply(lambda x:
... # Rolling 7-day median (covered in chapter 4).
... # we set min_periods (# of periods required for
... # calculation) to 0 so we always get a result
... x.fillna(x.rolling(7, min_periods=0).median())
... ).head(10)
这里很难看出我们的填补值在哪里——温度会随着每天的变化波动相当大。我们知道 1 月 4 日的数据缺失,正如我们之前的尝试所示;使用这种策略时,我们填补的温度比周围的温度要低。在实际情况下,那天的温度稍微温暖一些(大约-3°C):

图 3.57 – 使用滚动中位数填补缺失值
重要提示
在进行填补时,必须小心谨慎。如果我们选择了错误的策略,可能会弄得一团糟。
另一种填补缺失数据的方法是让pandas使用interpolate()方法计算出这些值。默认情况下,它会执行线性插值,假设所有行是均匀间隔的。我们的数据是每日数据,虽然有些天的数据缺失,因此只需要先重新索引即可。我们可以将其与apply()方法结合,来一次性插值所有列:
>>> df_deduped.reindex(
... pd.date_range('2018-01-01', '2018-12-31', freq='D')
... ).apply(lambda x: x.interpolate()).head(10)
看看 1 月 9 日,这是我们之前没有的数据——TMAX、TMIN和TOBS的值是前一天(1 月 8 日)和后一天(1 月 10 日)的平均值:

图 3.58 – 插值缺失值
可以通过method参数指定不同的插值策略;务必查看interpolate()方法的文档,了解可用的选项。
摘要
恭喜你完成了本章!数据整理可能不是分析流程中最令人兴奋的部分,但我们将花费大量时间在这上面,因此最好熟悉pandas提供的功能。
在本章中,我们进一步了解了数据整理的概念(不仅仅是数据科学的流行术语),并亲身体验了如何清洗和重塑数据。通过使用requests库,我们再次练习了如何使用 API 提取感兴趣的数据;然后,我们使用pandas开始了数据整理的介绍,下一章我们将继续深入探讨这一主题。最后,我们学习了如何处理重复、缺失和无效的数据点,并讨论了这些决策的后果。
基于这些概念,在下一章中,我们将学习如何聚合数据框并处理时间序列数据。在继续之前,请务必完成本章末的练习。
练习
使用我们到目前为止在本书中学到的知识和exercises/目录中的数据完成以下练习:
-
我们希望查看我们将在第七章中构建的
stock_analysis包的数据,金融分析 - 比特币与股票市场)。将它们合并成一个文件,并将 FAANG 数据的数据框存储为faang,以便进行接下来的练习:a) 读取
aapl.csv、amzn.csv、fb.csv、goog.csv和nflx.csv文件。b) 向每个数据框中添加一个名为
ticker的列,表示它对应的股票代码(例如,苹果的股票代码是 AAPL);这是查询股票的方式。在这个例子中,文件名恰好是股票代码。c) 将它们合并成一个单一的数据框。
d) 将结果保存为名为
faang.csv的 CSV 文件。 -
使用
faang,通过类型转换将date列的值转换为日期时间格式,并将volume列的值转换为整数。然后,按date和ticker进行排序。 -
找出
faang中volume值最小的七行数据。 -
现在,数据介于长格式和宽格式之间。使用
melt()将其完全转化为长格式。提示:date和ticker是我们的 ID 变量(它们唯一标识每一行)。我们需要将其他列进行转化,以避免有单独的open、high、low、close和volume列。 -
假设我们发现 2018 年 7 月 26 日数据记录出现了故障。我们应该如何处理这个问题?注意,此练习不需要编写代码。
-
covid19_cases.csv文件。b) 使用
dateRep列中的数据和pd.to_datetime()函数创建一个date列。c) 将
date列设置为索引并对索引进行排序。d) 将所有出现的
United_States_of_America和United_Kingdom分别替换为USA和UK。提示:replace()方法可以在整个数据框上运行。e) 使用
countriesAndTerritories列,将清洗后的 COVID-19 数据筛选为阿根廷、巴西、中国、哥伦比亚、印度、意大利、墨西哥、秘鲁、俄罗斯、西班牙、土耳其、英国和美国的数据。f) 将数据透视,使索引包含日期,列包含国家名称,值为病例数(即
cases列)。确保将NaN值填充为0。 -
为了高效地确定每个国家的病例总数,我们需要在第四章中学习到的聚合技能,即聚合 Pandas 数据框,因此 ECDC 数据在
covid19_cases.csv文件中已为我们进行了聚合,并保存在covid19_total_cases.csv文件中。该文件包含每个国家的病例总数。使用这些数据查找 COVID-19 病例总数最多的 20 个国家。提示:在读取 CSV 文件时,传入index_col='cases',并注意,在提取国家信息之前,先转置数据将会很有帮助。
深入阅读
查看以下资源,了解更多关于本章涵盖的主题信息:
-
关系型数据库设计快速入门教程:
www.ntu.edu.sg/home/ehchua/programming/sql/relational_database_design.html -
二分查找:
www.khanacademy.org/computing/computer-science/algorithms/binary-search/a/binary-search -
递归如何工作—通过流程图和视频讲解:
www.freecodecamp.org/news/how-recursion-works-explained-with-flowcharts-and-a-video-de61f40cb7f9/ -
Python f-strings:
realpython.com/python-f-strings/ -
整洁数据(Hadley Wickham 文章):
www.jstatsoft.org/article/view/v059i10 -
伟大的 Web API 设计的 5 个黄金法则:
www.toptal.com/api-developers/5-golden-rules-for-designing-a-great-web-api
第五章:第四章:聚合 Pandas DataFrames
在本章中,我们将继续探讨第三章中的数据清理内容,使用 Pandas 进行数据清理,通过讨论数据的丰富和聚合。包括一些重要技能,如合并数据框、创建新列、执行窗口计算以及按组进行聚合。计算聚合和总结有助于我们从数据中得出结论。
我们还将探讨pandas在处理时间序列数据时的额外功能,超出我们在前几章中介绍的时间序列切片内容,包括如何通过聚合对数据进行汇总,以及如何根据一天中的时间选择数据。我们将遇到的大部分数据都是时间序列数据,因此有效地处理时间序列数据至关重要。当然,高效地执行这些操作也很重要,因此我们还将回顾如何编写高效的pandas代码。
本章将帮助我们熟练使用DataFrame对象进行分析。因此,这些主题相比之前的内容更为高级,可能需要反复阅读几遍,所以请确保跟随带有额外示例的笔记本进行学习。
本章将涵盖以下主题:
-
在
DataFrame上执行类似数据库的操作 -
使用
DataFrame操作来丰富数据 -
聚合数据
-
处理时间序列数据
本章资料
本章的资料可以在 GitHub 上找到,网址为github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_04。我们将通过四个笔记本来学习,每个笔记本按使用顺序编号,文本会提示你切换。我们将从1-querying_and_merging.ipynb笔记本开始,学习如何查询和合并数据框。然后,我们将进入2-dataframe_operations.ipynb笔记本,讨论通过操作如分箱、窗口函数和管道来丰富数据。在这一部分,我们还将使用window_calc.py Python 文件,该文件包含一个使用管道进行窗口计算的函数。
提示
understanding_window_calculations.ipynb笔记本包含一些交互式可视化,用于帮助理解窗口函数。可能需要一些额外的设置,但笔记本中有说明。
接下来,在3-aggregations.ipynb笔记本中,我们将讨论聚合、透视表和交叉表。最后,我们将重点介绍pandas在处理时间序列数据时提供的额外功能,这些内容将在4-time_series.ipynb笔记本中讨论。请注意,我们不会讲解0-weather_data_collection.ipynb笔记本;然而,对于有兴趣的人,它包含了从国家环境信息中心(NCEI)API 收集数据的代码,API 链接可见于www.ncdc.noaa.gov/cdo-web/webservices/v2。
在本章中,我们将使用多种数据集,所有数据集均可以在data/目录中找到:

图 4.1 – 本章使用的数据集
请注意,exercises/目录包含了完成本章末尾练习所需的 CSV 文件。更多关于这些数据集的信息可以在exercises/README.md文件中找到。
在 DataFrame 上执行数据库风格的操作
DataFrame对象类似于数据库中的表:每个对象都有一个我们用来引用它的名称,由行组成,并包含特定数据类型的列。因此,pandas允许我们在其上执行数据库风格的操作。传统上,数据库支持至少四种操作,称为CRUD:Create(创建)、Read(读取)、Update(更新)和Delete(删除)。
一种数据库查询语言——在本节中将讨论的大多数是pandas操作,它可能有助于熟悉 SQL 的人理解。许多数据专业人员都对基础的 SQL 有所了解,因此请参考进一步阅读部分以获取提供更正式介绍的资源。
在本节中,我们将在1-querying_and_merging.ipynb笔记本中进行操作。我们将从导入库并读取纽约市天气数据的 CSV 文件开始:
>>> import pandas as pd
>>> weather = pd.read_csv('data/nyc_weather_2018.csv')
>>> weather.head()
这是长格式数据——我们有多个不同的天气观测数据,涵盖了 2018 年纽约市各个站点的每日数据:

图 4.2 – 纽约市天气数据
在 第二章,使用 Pandas DataFrame 中,我们介绍了如何创建 DataFrame;这相当于 SQL 中的 "CREATE TABLE ..." 语句。当我们在 第二章,使用 Pandas DataFrame 和 第三章,使用 Pandas 进行数据清洗 中讨论选择和过滤时,我们主要关注的是从 DataFrame 中读取数据,这等同于 SQL 中的 SELECT(选择列)和 WHERE(按布尔条件过滤)子句。我们在讨论处理缺失数据时执行了更新(SQL 中的 UPDATE)和删除(SQL 中的 DELETE FROM)操作,这些内容出现在 第三章,使用 Pandas 进行数据清洗 中。除了这些基本的 CRUD 操作外,本节还介绍了 pandas 在实现查询 DataFrame 对象方面的概念。
查询 DataFrame
Pandas 提供了 query() 方法,使我们能够轻松编写复杂的过滤器,而无需使用布尔掩码。其语法类似于 SQL 语句中的 WHERE 子句。为了说明这一点,让我们查询所有 SNOW 列值大于零且站点 ID 包含 US1NY 的站点的天气数据:
>>> snow_data = weather.query(
... 'datatype == "SNOW" and value > 0 '
... 'and station.str.contains("US1NY")'
... )
>>> snow_data.head()
每一行都是某个日期和站点组合下的雪观测数据。注意,1 月 4 日的数据差异很大——有些站点的降雪量比其他站点多:

图 4.3 – 查询雪的天气数据观测值
这个查询在 SQL 中等价于以下语句。注意,SELECT * 会选择表中的所有列(在这里是我们的 DataFrame):
SELECT * FROM weather
WHERE
datatype == "SNOW" AND value > 0 AND station LIKE "%US1NY%";
在 第二章,使用 Pandas DataFrame 中,我们学习了如何使用布尔掩码得到相同的结果:
>>> weather[
... (weather.datatype == 'SNOW') & (weather.value > 0)
... & weather.station.str.contains('US1NY')
... ].equals(snow_data)
True
大部分情况下,我们选择使用哪种方式主要取决于个人偏好;然而,如果我们的 DataFrame 名称很长,我们可能会更喜欢使用 query() 方法。在前面的例子中,我们不得不额外输入三次 DataFrame 的名称来使用掩码。
提示
在使用 query() 方法时,我们可以使用布尔逻辑操作符(and、or、not)和按位操作符(&、|、~)。
合并 DataFrame
当我们在 第二章,使用 Pandas DataFrame 中讨论通过 pd.concat() 函数和 append() 方法将 DataFrame 堆叠在一起时,我们实际上在执行 SQL 中的 UNION ALL 语句(如果删除重复项,我们就是在执行 UNION,正如我们在 第三章,使用 Pandas 进行数据清洗 中所看到的那样)。合并 DataFrame 涉及如何按行将它们对齐。
在数据库中,合并通常被称为连接。连接有四种类型:全连接(外连接)、左连接、右连接和内连接。这些连接类型告诉我们,如何根据连接两边只有一方有的值来影响结果。这是一个更容易通过图示来理解的概念,所以我们来看一些维恩图,并对天气数据进行一些示例连接。在这里,较深的区域表示我们在执行连接后留下的数据:

图 4.4 – 理解连接类型
我们一直在处理来自众多气象站的数据,但除了它们的 ID 外,我们对这些站点一无所知。如果能够了解每个气象站的具体位置,将有助于更好地理解同一天纽约市天气读数之间的差异。当我们查询雪量数据时,我们看到 1 月 4 日的读数存在相当大的变化(见图 4.3)。这很可能是由于气象站的位置不同。位于更高海拔或更北方的站点可能会记录更多的降雪。根据它们与纽约市的距离,它们可能正经历某个地方的暴风雪,例如康涅狄格州或北新泽西。
NCEI API 的stations端点提供了我们所需的所有气象站信息。这些信息存储在weather_stations.csv文件中,并且也存在于 SQLite 数据库中的stations表中。我们可以将这些数据读取到一个数据框中:
>>> station_info = pd.read_csv('data/weather_stations.csv')
>>> station_info.head()
供参考,纽约市中央公园的坐标是 40.7829° N, 73.9654° W(纬度 40.7829, 经度-73.9654),纽约市的海拔为 10 米。记录纽约市数据的前五个站点不在纽约州。这些位于新泽西州的站点在纽约市的西南,而位于康涅狄格州的站点则位于纽约市的东北:

图 4.5 – 气象站数据集
连接要求我们指定如何匹配数据。weather数据框架与station_info数据框架唯一共有的数据是气象站 ID。然而,包含这些信息的列名并不相同:在weather数据框架中,这一列被称为station,而在station_info数据框架中,它被称为id。在我们进行连接之前,先获取一些关于有多少个不同气象站以及每个数据框架中有多少条记录的信息:
>>> station_info.id.describe()
count 279
unique 279
top GHCND:US1NJBG0029
freq 1
Name: id, dtype: object
>>> weather.station.describe()
count 78780
unique 110
top GHCND:USW00094789
freq 4270
Name: station, dtype: object
数据框架中唯一站点数量的差异告诉我们,它们并不包含完全相同的站点。根据我们选择的连接类型,我们可能会丢失一些数据。因此,在连接前后查看行数是很重要的。我们可以在describe()中查看这一点,但不需要仅仅为了获取行数而运行它。相反,我们可以使用shape属性,它会返回一个元组,格式为(行数,列数)。要选择行,我们只需获取索引为0的值(列数为1):
>>> station_info.shape[0], weather.shape[0] # 0=rows, 1=cols
(279, 78780)
由于我们将频繁检查行数,所以编写一个函数来为任意数量的数据框提供行数更为合适。*dfs参数将所有输入收集成一个元组,我们可以通过列表推导式遍历这个元组来获取行数:
>>> def get_row_count(*dfs):
... return [df.shape[0] for df in dfs]
>>> get_row_count(station_info, weather)
[279, 78780]
现在我们知道,天气数据有 78,780 行,站点信息数据有 279 行,我们可以开始查看不同类型的连接。我们将从内连接开始,内连接会产生最少的行数(除非两个数据框在连接列上有完全相同的值,在这种情况下,所有的连接结果都是等价的)。在weather.station列和station_info.id列上连接,我们将只获得station_info中存在的站点的天气数据。
我们将使用merge()方法来执行连接(默认是内连接),通过提供左右数据框,并指定要连接的列名。由于站点 ID 列在不同数据框中命名不同,我们必须使用left_on和right_on来指定列名。左侧数据框是我们调用merge()的方法所在的数据框,而右侧数据框是作为参数传递进来的:
>>> inner_join = weather.merge(
... station_info, left_on='station', right_on='id'
... )
>>> inner_join.sample(5, random_state=0)
请注意,我们有五个额外的列,它们被添加到了右侧。这些列来自station_info数据框。这个操作也保留了station和id列,它们是完全相同的:

图 4.6 – 天气数据集和站点数据集的内连接结果
为了去除station和id列中的重复信息,我们可以在连接前重命名其中一列。因此,我们只需要为on参数提供一个值,因为这两列将共享相同的名称:
>>> weather.merge(
... station_info.rename(dict(id='station'), axis=1),
... on='station'
... ).sample(5, random_state=0)
由于这两列共享相同的名称,所以在连接时我们只会得到一列数据:

图 4.7 – 匹配连接列的名称以防止结果中的重复数据
提示
我们可以通过将列名列表传递给on参数,或者传递给left_on和right_on参数来进行多列连接。
请记住,我们在station_info数据框中有 279 个唯一的站点,但在天气数据中只有 110 个唯一的站点。当我们执行内连接时,所有没有天气观测数据的站点都丢失了。如果我们不想丢失某一侧的数据框的行,可以改为执行左连接或右连接。左连接要求我们将希望保留的行所在的数据框(即使它们在另一个数据框中不存在)放在左侧,将另一个数据框放在右侧;右连接则是相反的操作:
>>> left_join = station_info.merge(
... weather, left_on='id', right_on='station', how='left'
... )
>>> right_join = weather.merge(
... station_info, left_on='station', right_on='id',
... how='right'
... )
>>> right_join[right_join.datatype.isna()].head() # see nulls
在另一个数据框没有数据的地方,我们会得到 null 值。我们可能需要调查为什么这些站点没有关联的天气数据。或者,我们的分析可能涉及确定每个站点的数据可用性,所以获得 null 值不一定是个问题:

图 4.8 – 不使用内连接时可能引入 null 值
因为我们将station_info数据框放在左边用于左连接,右边用于右连接,所以这里的结果是等效的。在两种情况下,我们都选择保留station_info数据框中所有的站点,并接受天气观测值为 null。为了证明它们是等效的,我们需要将列按相同顺序排列,重置索引,并排序数据:
>>> left_join.sort_index(axis=1)\
... .sort_values(['date', 'station'], ignore_index=True)\
... .equals(right_join.sort_index(axis=1).sort_values(
... ['date', 'station'], ignore_index=True
... ))
True
请注意,在左连接和右连接中我们有额外的行,因为我们保留了所有没有天气观测值的站点:
>>> get_row_count(inner_join, left_join, right_join)
[78780, 78949, 78949]
最后一种连接类型是US1NY作为站点 ID,因为我们认为测量 NYC 天气的站点必须标注为此。这意味着,内连接会丢失来自康涅狄格州和新泽西州站点的观测数据,而左连接或右连接则可能导致站点信息或天气数据丢失。外连接将保留所有数据。我们还会传入indicator=True,为结果数据框添加一列,指示每一行数据来自哪个数据框:
>>> outer_join = weather.merge(
... station_info[station_info.id.str.contains('US1NY')],
... left_on='station', right_on='id',
... how='outer', indicator=True
... )
# view effect of outer join
>>> pd.concat([
... outer_join.query(f'_merge == "{kind}"')\
... .sample(2, random_state=0)
... for kind in outer_join._merge.unique()
... ]).sort_index()
站点 ID 中的US1NY导致了站点信息列为 null。底部的两行是来自纽约的站点,但没有提供 NYC 的天气观测数据。这个连接保留了所有数据,通常会引入 null 值,不同于内连接,内连接不会引入 null 值:

图 4.9 – 外连接保留所有数据
前述的连接等同于 SQL 语句,形式如下,其中我们只需将<JOIN_TYPE>替换为(INNER) JOIN、LEFT JOIN、RIGHT JOIN或FULL OUTER JOIN,以适应所需的连接类型:
SELECT *
FROM left_table
<JOIN_TYPE> right_table
ON left_table.<col> == right_table.<col>;
连接数据框使得处理第三章中脏数据变得更容易,Pandas 数据清洗也因此变得更简单。记住,我们有来自两个不同站点的数据:一个有有效的站点 ID,另一个是?。?站点是唯一一个记录雪的水当量(WESF)的站点。现在我们了解了连接数据框的方式,我们可以通过日期将有效站点 ID 的数据与我们缺失的?站点的数据连接起来。首先,我们需要读取 CSV 文件,并将date列设为索引。然后,我们将删除重复数据和SNWD列(雪深),因为我们发现SNWD列在大多数情况下没有提供有用信息(无论是有雪还是没有雪,值都是无限大):
>>> dirty_data = pd.read_csv(
... 'data/dirty_data.csv', index_col='date'
... ).drop_duplicates().drop(columns='SNWD')
>>> dirty_data.head()
我们的起始数据看起来是这样的:

图 4.10 – 上一章中的脏数据
现在,我们需要为每个站点创建一个数据框。为了减少输出,我们将删除一些额外的列:
>>> valid_station = dirty_data.query('station != "?"')\
... .drop(columns=['WESF', 'station'])
>>> station_with_wesf = dirty_data.query('station == "?"')\
... .drop(columns=['station', 'TOBS', 'TMIN', 'TMAX'])
这次,我们要连接的列(日期)实际上是索引,因此我们将传入left_index来表示左侧数据框要使用的列是索引,接着传入right_index来表示右侧数据框的相应列也是索引。我们将执行左连接,确保不会丢失任何有效站点的行,并且在可能的情况下,用?站点的观测数据补充它们:
>>> valid_station.merge(
... station_with_wesf, how='left',
... left_index=True, right_index=True
... ).query('WESF > 0').head()
对于数据框中共有的所有列,但未参与连接的列,现在我们有了两个版本。来自左侧数据框的版本在列名后添加了_x后缀,来自右侧数据框的版本则在列名后添加了_y后缀:

图 4.11 – 来自不同站点的天气数据合并
我们可以通过suffixes参数提供自定义的后缀。让我们只为?站点使用一个后缀:
>>> valid_station.merge(
... station_with_wesf, how='left',
... left_index=True, right_index=True,
... suffixes=('', '_?')
... ).query('WESF > 0').head()
由于我们为左侧后缀指定了空字符串,来自左侧数据框的列保持其原始名称。然而,右侧后缀_?被添加到了来自右侧数据框的列名中:

图 4.12 – 为不参与连接的共享列指定后缀
当我们在索引上进行连接时,一种更简单的方法是使用join()方法,而不是merge()。它也默认为内连接,但可以通过how参数更改此行为,就像merge()一样。join()方法将始终使用左侧数据框的索引进行连接,但如果传递右侧数据框的列名给on参数,它也可以使用右侧数据框中的列。需要注意的是,后缀现在通过lsuffix指定左侧数据框的后缀,rsuffix指定右侧数据框的后缀。这将产生与之前示例相同的结果(图 4.12):
>>> valid_station.join(
... station_with_wesf, how='left', rsuffix='_?'
... ).query('WESF > 0').head()
需要记住的一件重要事情是,连接操作可能相当消耗资源,因此在执行连接之前,弄清楚行会发生什么通常是有益的。如果我们还不知道想要哪种类型的连接,使用这种方式可以帮助我们得到一些思路。我们可以在计划连接的索引上使用集合操作来弄清楚这一点。
请记住,集合的数学定义是不同对象的集合。按照定义,索引是一个集合。集合操作通常通过维恩图来解释:

图 4.13 – 集合操作
重要提示
请注意,set也是 Python 标准库中可用的一种类型。集合的一个常见用途是去除列表中的重复项。有关 Python 中集合的更多信息,请参见文档:docs.python.org/3/library/stdtypes.html#set-types-set-frozenset。
让我们使用weather和station_info数据框来说明集合操作。首先,我们必须将索引设置为用于连接操作的列:
>>> weather.set_index('station', inplace=True)
>>> station_info.set_index('id', inplace=True)
要查看内连接后将保留的内容,我们可以取索引的交集,这将显示我们重叠的站点:
>>> weather.index.intersection(station_info.index)
Index(['GHCND:US1CTFR0039', ..., 'GHCND:USW1NYQN0029'],
dtype='object', length=110)
正如我们在执行内连接时看到的那样,我们只得到了有天气观测的站点信息。但这并没有告诉我们丢失了什么;为此,我们需要找到集合差异,即减去两个集合,得到第一个索引中不在第二个索引中的值。通过集合差异,我们可以轻松看到,在执行内连接时,我们并没有丢失天气数据中的任何行,但我们丢失了 169 个没有天气观测的站点:
>>> weather.index.difference(station_info.index)
Index([], dtype='object')
>>> station_info.index.difference(weather.index)
Index(['GHCND:US1CTFR0022', ..., 'GHCND:USW00014786'],
dtype='object', length=169)
请注意,这个输出还告诉我们左连接和右连接的结果如何。为了避免丢失行,我们希望将station_info数据框放在连接的同一侧(左连接时在左边,右连接时在右边)。
提示
我们可以使用symmetric_difference()方法对参与连接的数据框的索引进行操作,查看从两侧丢失的内容:index_1.symmetric_difference(index_2)。结果将是仅在其中一个索引中存在的值。笔记本中有一个示例。
最后,我们可以使用weather数据框,它包含了重复出现的站点信息,因为这些站点提供每日测量值,所以在进行并集操作之前,我们会调用unique()方法查看我们将保留的站点数量:
>>> weather.index.unique().union(station_info.index)
Index(['GHCND:US1CTFR0022', ..., 'GHCND:USW00094789'],
dtype='object', length=279)
本章最后的进一步阅读部分包含了一些关于集合操作的资源,以及pandas与 SQL 的对比。目前,让我们继续进行数据丰富化操作。
使用 DataFrame 操作来丰富数据
现在我们已经讨论了如何查询和合并DataFrame对象,接下来让我们学习如何在这些对象上执行复杂操作,创建和修改列和行。在本节中,我们将在2-dataframe_operations.ipynb笔记本中使用天气数据,以及 2018 年 Facebook 股票的交易量和每日开盘价、最高价、最低价和收盘价。让我们导入所需的库并读取数据:
>>> import numpy as np
>>> import pandas as pd
>>> weather = pd.read_csv(
... 'data/nyc_weather_2018.csv', parse_dates=['date']
... )
>>> fb = pd.read_csv(
... 'data/fb_2018.csv', index_col='date', parse_dates=True
... )
我们将首先回顾总结整行和整列的操作,然后再学习分箱、在行和列上应用函数,以及窗口计算,这些操作是在一定数量的观测值上对数据进行汇总(例如,移动平均)。
算术和统计
Pandas 提供了几种计算统计数据和执行数学运算的方法,包括比较、整除和取模运算。这些方法使我们在定义计算时更加灵活,允许我们指定在哪个轴上执行计算(当对DataFrame对象进行操作时)。默认情况下,计算将在列上执行(axis=1或axis='columns'),列通常包含单一变量的单一数据类型的观测值;但是,我们也可以传入axis=0或axis='index'来沿着行执行计算。
在本节中,我们将使用这些方法中的一些来创建新列并修改数据,看看如何利用新数据得出一些初步结论。完整的列表可以在pandas.pydata.org/pandas-docs/stable/reference/series.html#binary-operator-functions找到。
首先,让我们创建一列 Facebook 股票交易量的 Z 分数,并利用它找出 Z 分数绝对值大于三的日期。这些值距离均值超过三倍标准差,可能是异常值(具体取决于数据)。回想一下我们在第一章《数据分析导论》中讨论的 Z 分数,我们通过减去均值并除以标准差来计算 Z 分数。我们将不使用减法和除法的数学运算符,而是分别使用sub()和div()方法:
>>> fb.assign(
... abs_z_score_volume=lambda x: x.volume \
... .sub(x.volume.mean()).div(x.volume.std()).abs()
... ).query('abs_z_score_volume > 3')
2018 年有五天的交易量 Z 分数绝对值大于三。这些日期将会在本章的后续内容中频繁出现,因为它们标志着 Facebook 股价的一些问题点:

图 4.14 – 添加 Z 分数列
另外两个非常有用的方法是rank()和pct_change(),它们分别用于对列的值进行排名(并将排名存储在新列中)和计算不同时间段之间的百分比变化。通过将这两者结合使用,我们可以看到 Facebook 股票在前一天与五天内交易量变化百分比最大的一天:
>>> fb.assign(
... volume_pct_change=fb.volume.pct_change(),
... pct_change_rank=lambda x: \
... x.volume_pct_change.abs().rank(ascending=False)
... ).nsmallest(5, 'pct_change_rank')
交易量变化百分比最大的一天是 2018 年 1 月 12 日,这恰好与 2018 年震撼股市的多个 Facebook 丑闻之一重合(www.cnbc.com/2018/11/20/facebooks-scandals-in-2018-effect-on-stock.html)。当时 Facebook 公布了新闻源的变化,优先显示来自用户朋友的内容,而非他们所关注的品牌。考虑到 Facebook 的收入大部分来自广告(2017 年约为 89%,来源:www.investopedia.com/ask/answers/120114/how-does-facebook-fb-make-money.asp),这引发了恐慌,许多人抛售股票,导致交易量大幅上升,并使股票价格下跌:

图 4.15 – 按交易量变化百分比对交易日进行排名
我们可以使用切片方法查看这一公告前后的变化:
>>> fb['2018-01-11':'2018-01-12']
请注意,我们如何能够将前几章所学的所有内容结合起来,从数据中获取有趣的见解。我们能够筛选出一整年的股票数据,并找到一些对 Facebook 股票产生巨大影响的日期(无论是好是坏):

图 4.16 – 公布新闻源变化前后 Facebook 股票数据
最后,我们可以使用聚合布尔操作来检查数据框。例如,我们可以使用any()方法看到 Facebook 股票在 2018 年内从未有过低于 $215 的日最低价:
>>> (fb > 215).any()
open True
high True
low False
close True
volume True
dtype: bool
如果我们想查看某列中的所有行是否符合标准,可以使用all()方法。该方法告诉我们,Facebook 至少有一天的开盘价、最高价、最低价和收盘价小于或等于 $215:
>>> (fb > 215).all()
open False
high False
low False
close False
volume True
dtype: bool
现在,让我们看看如何使用分箱法来划分数据,而不是使用具体的数值,例如在any()和all()示例中的 $215。
分箱法
有时候,使用类别而不是具体数值进行分析更加方便。一个常见的例子是年龄分析——通常我们不想查看每个年龄的数据,比如 25 岁和 26 岁之间的差异;然而,我们很可能会对 25-34 岁组与 35-44 岁组之间的比较感兴趣。这就是所谓的分箱或离散化(从连续数据转为离散数据);我们将数据按照其所属的范围放入不同的箱(或桶)中。通过这样做,我们可以大幅减少数据中的不同数值,并使分析变得更容易。
重要提示
虽然对数据进行分箱可以使某些分析部分变得更简单,但请记住,它会减少该字段中的信息,因为粒度被降低了。
我们可以做的一件有趣的事情是观察哪些日期的交易量较高,并查看这些日期是否有关于 Facebook 的新闻,或者是否有股价的大幅波动。不幸的是,几乎不可能有两天的交易量是相同的;事实上,我们可以确认,在数据中,没有两天的交易量是相同的:
>>> (fb.volume.value_counts() > 1).sum()
0
请记住,fb.volume.value_counts()会告诉我们每个唯一volume值的出现次数。然后,我们可以创建一个布尔掩码,判断该次数是否大于 1,并对其进行求和(True会被计算为1,False则为0)。另外,我们也可以使用any()代替sum(),这样做会告诉我们,如果至少有一个交易量发生了多次,返回True,否则返回False,而不是告诉我们有多少个唯一的volume值出现超过一次。
显然,我们需要为交易量创建一些区间,以便查看高交易量的日期,但我们如何决定哪个区间是合适的呢?一种方法是使用pd.cut()函数基于数值进行分箱。首先,我们应该决定要创建多少个区间——三个位数似乎是一个好的分割,因为我们可以将这些区间分别标记为低、中和高。接下来,我们需要确定每个区间的宽度;pandas会尽可能简化这个过程,所以如果我们想要等宽的区间,只需要指定我们想要的区间数(否则,我们必须指定每个区间的上限作为列表):
>>> volume_binned = pd.cut(
... fb.volume, bins=3, labels=['low', 'med', 'high']
... )
>>> volume_binned.value_counts()
low 240
med 8
high 3
Name: volume, dtype: int64
提示
请注意,我们在这里为每个区间提供了标签;如果我们不这样做,系统会根据包含的数值区间为每个区间标记标签,这可能对我们有用,也可能没有用,取决于我们的应用。如果我们想同时标记区间的值并在后续查看区间,可以在调用pd.cut()时传入retbins=True。然后,我们可以通过返回的元组的第一个元素访问分箱数据,第二个元素则是区间范围。
看起来绝大多数交易日都属于低交易量区间;请记住,这一切都是相对的,因为我们将最小和最大交易量之间的范围进行了均匀分割。现在让我们看一下这三天高交易量的交易数据:
>>> fb[volume_binned == 'high']\
... .sort_values('volume', ascending=False)
即使在高交易量的日子里,我们也能看到 2018 年 7 月 26 日的交易量远高于 3 月的其他两个日期(交易量增加了近 4000 万股):

图 4.17 – 高交易量区间内的 Facebook 股票数据
实际上,通过搜索引擎查询 Facebook 股票价格 2018 年 7 月 26 日 可以发现,Facebook 在 7 月 25 日股市收盘后宣布了其收益和令人失望的用户增长,随后发生了大量盘后抛售。当第二天股市开盘时,股票从 25 日收盘的 $217.50 跌至 26 日开盘时的 $174.89。让我们提取这些数据:
>>> fb['2018-07-25':'2018-07-26']
不仅股票价格大幅下跌,而且交易量也飙升,增加了超过 1 亿股。所有这些导致了 Facebook 市值约 1200 亿美元的损失(www.marketwatch.com/story/facebook-stock-crushed-after-revenue-user-growth-miss-2018-07-25):

图 4.18 – 2018 年 Facebook 股票数据,直至最高交易量那天
如果我们查看另外两天被标记为高交易量的交易日,会发现有大量的信息说明原因。这两天都与 Facebook 的丑闻有关。剑桥分析公司(Cambridge Analytica)的政治数据隐私丑闻于 2018 年 3 月 17 日星期六爆发,因此与该信息相关的交易直到 3 月 19 日星期一才开始(www.nytimes.com/2018/03/19/technology/facebook-cambridge-analytica-explained.html):
>>> fb['2018-03-16':'2018-03-20']
一旦在接下来的几天里披露了关于事件严重性的更多信息,情况变得更糟:

图 4.19 – 剑桥分析丑闻爆发时的 Facebook 股票数据
至于第三个高交易量的交易日(2018 年 3 月 26 日),美国联邦贸易委员会(FTC)启动了对剑桥分析丑闻的调查,因此 Facebook 的困境持续下去(www.cnbc.com/2018/03/26/ftc-confirms-facebook-data-breach-investigation.html)。
如果我们查看中等交易量组内的一些日期,会发现许多都属于我们刚刚讨论的三个交易事件。这迫使我们重新审视最初创建区间的方式。也许等宽区间并不是答案?大多数日期的交易量相对接近;然而,少数几天导致区间宽度较大,这使得每个区间内的日期数量不均衡:

图 4.20 – 可视化等宽区间
如果我们希望每个区间有相同数量的观测值,可以使用 pd.qcut() 函数基于均匀间隔的分位数来划分区间。我们可以将交易量划分为四分位数,从而将观测值均匀分配到宽度不同的区间中,得到q4区间中的 63 个最高交易量的天数:
>>> volume_qbinned = pd.qcut(
... fb.volume, q=4, labels=['q1', 'q2', 'q3', 'q4']
... )
>>> volume_qbinned.value_counts()
q1 63
q2 63
q4 63
q3 62
Name: volume, dtype: int64
请注意,这些区间现在不再覆盖相同的交易量范围:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.21_B16834.jpg)
图 4.21 – 基于四分位数可视化区间
小贴士
在这两个例子中,我们让 pandas 计算区间范围;然而,pd.cut() 和 pd.qcut() 都允许我们将每个区间的上界指定为列表。
应用函数
到目前为止,我们对数据进行的大多数操作都是针对单独列进行的。当我们希望在数据框架的所有列上运行相同的代码时,可以使用 apply() 方法,使代码更加简洁。请注意,这个操作不会就地执行。
在开始之前,让我们先隔离中央公园站的天气观测数据,并将数据透视:
>>> central_park_weather = weather.query(
... 'station == "GHCND:USW00094728"'
... ).pivot(index='date', columns='datatype', values='value')
让我们计算 2018 年 10 月中央公园的TMIN(最低气温)、TMAX(最高气温)和PRCP(降水量)观测值的 Z 分数。重要的是,我们不要试图跨全年的数据计算 Z 分数。纽约市有四个季节,什么是正常的天气取决于我们查看的是哪个季节。通过将计算范围限制在 10 月份,我们可以看看 10 月是否有任何天气与其他天差异很大:
>>> oct_weather_z_scores = central_park_weather\
... .loc['2018-10', ['TMIN', 'TMAX', 'PRCP']]\
... .apply(lambda x: x.sub(x.mean()).div(x.std()))
>>> oct_weather_z_scores.describe().T
TMIN 和 TMAX 似乎没有任何与 10 月其余时间显著不同的数值,但 PRCP 确实有:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.22_B16834.jpg)
图 4.22 – 一次计算多个列的 Z 分数
我们可以使用 query() 提取该日期的值:
>>> oct_weather_z_scores.query('PRCP > 3').PRCP
date
2018-10-27 3.936167
Name: PRCP, dtype: float64
如果我们查看 10 月份降水量的汇总统计数据,我们可以看到,这一天的降水量远远超过其他日子:
>>> central_park_weather.loc['2018-10', 'PRCP'].describe()
count 31.000000
mean 2.941935
std 7.458542
min 0.000000
25% 0.000000
50% 0.000000
75% 1.150000
max 32.300000
Name: PRCP, dtype: float64
apply() 方法让我们可以对整个列或行一次性运行矢量化操作。我们可以应用几乎任何我们能想到的函数,只要这些操作在数据的所有列(或行)上都是有效的。例如,我们可以使用之前讨论的 pd.cut() 和 pd.qcut() 分箱函数,将每一列分成若干区间(前提是我们希望分成相同数量的区间或数值范围)。请注意,如果我们要应用的函数不是矢量化的,还可以使用 applymap() 方法。或者,我们可以使用 np.vectorize() 将函数矢量化,以便与 apply() 一起使用。有关示例,请参考笔记本。
Pandas 确实提供了一些用于迭代数据框的功能,包括iteritems()、itertuples()和iterrows()方法;然而,除非我们完全找不到其他解决方案,否则应避免使用这些方法。Pandas 和 NumPy 是为向量化操作设计的,这些操作要快得多,因为它们是用高效的 C 代码编写的;通过编写一个循环逐个迭代元素,我们让计算变得更加复杂,因为 Python 实现整数和浮点数的方式。举个例子,看看完成一个简单操作(将数字10加到每个浮点数值上)所需的时间,使用iteritems()时,它会随着行数的增加线性增长,而使用向量化操作时,无论行数多大,所需时间几乎保持不变:

图 4.23 – 向量化与迭代操作
到目前为止,我们使用的所有函数和方法都涉及整个行或列;然而,有时我们更关心进行窗口计算,它们使用数据的一部分。
窗口计算
Pandas 使得对窗口或行/列范围进行计算成为可能。在这一部分中,我们将讨论几种构建这些窗口的方法。根据窗口类型的不同,我们可以得到数据的不同视角。
滚动窗口
当我们的索引类型为DatetimeIndex时,我们可以按日期部分指定窗口(例如,2H表示两小时,3D表示三天);否则,我们可以指定期数的整数值。例如,如果我们对滚动 3 天窗口内的降水量感兴趣;用我们目前所学的知识来实现这一点会显得相当繁琐(而且可能效率低下)。幸运的是,我们可以使用rolling()方法轻松获取这些信息:
>>> central_park_weather.loc['2018-10'].assign(
... rolling_PRCP=lambda x: x.PRCP.rolling('3D').sum()
... )[['PRCP', 'rolling_PRCP']].head(7).T
在执行滚动 3 天总和后,每个日期将显示该日期和前两天的降水量总和:

图 4.24 – 滚动 3 天总降水量
提示
如果我们想使用日期进行滚动计算,但索引中没有日期,我们可以将日期列的名称传递给rolling()调用中的on参数。相反,如果我们想使用行号的整数索引,我们可以直接传递一个整数作为窗口;例如,rolling(3)表示一个 3 行的窗口。
要更改聚合,只需在rolling()的结果上调用不同的方法;例如,mean()表示平均值,max()表示最大值。滚动计算也可以应用于所有列一次:
>>> central_park_weather.loc['2018-10']\
... .rolling('3D').mean().head(7).iloc[:,:6]
这会给我们提供来自中央公园的所有天气观测数据的 3 天滚动平均值:

图 4.25 – 所有天气观测数据的滚动 3 天平均值
若要对不同的列应用不同的聚合操作,我们可以使用agg()方法,它允许我们为每列指定预定义的或自定义的聚合函数。我们只需传入一个字典,将列映射到需要执行的聚合操作。让我们来找出滚动的 3 天最高气温(TMAX)、最低气温(TMIN)、平均风速(AWND)和总降水量(PRCP)。然后,我们将其与原始数据进行合并,以便进行比较:
>>> central_park_weather\
... ['2018-10-01':'2018-10-07'].rolling('3D').agg({
... 'TMAX': 'max', 'TMIN': 'min',
... 'AWND': 'mean', 'PRCP': 'sum'
... }).join( # join with original data for comparison
... central_park_weather[['TMAX', 'TMIN', 'AWND', 'PRCP']],
... lsuffix='_rolling'
... ).sort_index(axis=1) # put rolling calcs next to originals
使用agg(),我们能够为每列计算不同的滚动聚合操作:

图 4.26 – 对每列使用不同的滚动计算
小提示
我们还可以通过额外的努力使用变宽窗口:我们可以创建BaseIndexer的子类,并在get_window_bounds()方法中提供确定窗口边界的逻辑(更多信息请参考pandas.pydata.org/pandas-docs/stable/user_guide/computation.html#custom-window-rolling),或者我们可以使用pandas.api.indexers模块中的预定义类。我们当前使用的笔记本中包含了使用VariableOffsetWindowIndexer类执行 3 个工作日滚动计算的示例。
使用滚动计算时,我们有一个滑动窗口,在这个窗口上我们计算我们的函数;然而,在某些情况下,我们更关心函数在所有数据点上的输出,这时我们使用扩展窗口。
扩展窗口
扩展计算将为我们提供聚合函数的累积值。我们使用expanding()方法进行扩展窗口计算;像cumsum()和cummax()这样的函数会使用扩展窗口进行计算。直接使用expanding()的优点是额外的灵活性:我们不局限于预定义的聚合方法,并且可以通过min_periods参数指定计算开始前的最小周期数(默认为 1)。使用中央公园的天气数据,让我们使用expanding()方法来计算当月至今的平均降水量:
>>> central_park_weather.loc['2018-06'].assign(
... TOTAL_PRCP=lambda x: x.PRCP.cumsum(),
... AVG_PRCP=lambda x: x.PRCP.expanding().mean()
... ).head(10)[['PRCP', 'TOTAL_PRCP', 'AVG_PRCP']].T
请注意,虽然没有计算累积平均值的方法,但我们可以使用expanding()方法来计算它。AVG_PRCP列中的值是TOTAL_PRCP列中的值除以处理的天数:

图 4.27 – 计算当月至今的平均降水量
正如我们在使用rolling()时做的那样,我们可以通过agg()方法提供列特定的聚合操作。让我们来找出扩展后的最高气温、最低气温、平均风速和总降水量。注意,我们还可以将 NumPy 函数传递给agg():
>>> central_park_weather\
... ['2018-10-01':'2018-10-07'].expanding().agg({
... 'TMAX': np.max, 'TMIN': np.min,
... 'AWND': np.mean, 'PRCP': np.sum
... }).join(
... central_park_weather[['TMAX', 'TMIN', 'AWND', 'PRCP']],
... lsuffix='_expanding'
... ).sort_index(axis=1)
我们再次将窗口计算与原始数据结合进行比较:

图 4.28 – 对每列执行不同的扩展窗口计算
滚动窗口和扩展窗口在执行计算时,都会平等地权重窗口中的所有观测值,但有时我们希望对较新的值赋予更多的重视。一种选择是对观测值进行指数加权。
指数加权移动窗口
Pandas 还提供了ewm()方法,用于进行指数加权移动计算。正如在第一章《数据分析导论》中讨论的那样,我们可以使用span参数指定用于 EWMA 计算的周期数:
>>> central_park_weather.assign(
... AVG=lambda x: x.TMAX.rolling('30D').mean(),
... EWMA=lambda x: x.TMAX.ewm(span=30).mean()
... ).loc['2018-09-29':'2018-10-08', ['TMAX', 'EWMA', 'AVG']].T
与滚动平均不同,EWMA 对最近的观测值赋予更高的权重,因此 10 月 7 日的温度突变对 EWMA 的影响大于对滚动平均的影响:

图 4.29 – 使用移动平均平滑数据
提示
查看understanding_window_calculations.ipynb笔记本,其中包含了一些用于理解窗口函数的交互式可视化。这可能需要一些额外的设置,但相关说明已包含在笔记本中。
管道
管道使得将多个操作链接在一起变得更加简便,这些操作期望pandas数据结构作为它们的第一个参数。通过使用管道,我们可以构建复杂的工作流,而不需要编写高度嵌套且难以阅读的代码。通常,管道让我们能够将像f(g(h(data), 20), x=True)这样的表达式转变为以下形式,使其更易读:
data.pipe(h)\ # first call h(data)
.pipe(g, 20)\ # call g on the result with positional arg 20
.pipe(f, x=True) # call f on result with keyword arg x=True
假设我们希望通过调用此函数,打印 Facebook 数据框某个子集的维度,并进行一些格式化:
>>> def get_info(df):
... return '%d rows, %d cols and max closing Z-score: %d'
... % (*df.shape, df.close.max())
然而,在调用函数之前,我们需要计算所有列的 Z 得分。一种方法如下:
>>> get_info(fb.loc['2018-Q1']\
... .apply(lambda x: (x - x.mean())/x.std()))
另外,我们可以在计算完 Z 得分后,将数据框传递给这个函数:
>>> fb.loc['2018-Q1'].apply(lambda x: (x - x.mean())/x.std())\
... .pipe(get_info)
管道还可以使编写可重用代码变得更容易。在本书中的多个代码片段中,我们看到了将一个函数传递给另一个函数的概念,例如我们将 NumPy 函数传递给apply(),并在每一列上执行它。我们可以使用管道将该功能扩展到pandas数据结构的方法:
>>> fb.pipe(pd.DataFrame.rolling, '20D').mean().equals(
... fb.rolling('20D').mean()
... ) # the pipe is calling pd.DataFrame.rolling(fb, '20D')
True
为了说明这如何为我们带来好处,让我们看一个函数,它将给出我们选择的窗口计算结果。该函数位于window_calc.py文件中。我们将导入该函数,并使用??从 IPython 查看函数定义:
>>> from window_calc import window_calc
>>> window_calc??
Signature: window_calc(df, func, agg_dict, *args, **kwargs)
Source:
def window_calc(df, func, agg_dict, *args, **kwargs):
"""
Run a window calculation of your choice on the data.
Parameters:
- df: The `DataFrame` object to run the calculation on.
- func: The window calculation method that takes `df`
as the first argument.
- agg_dict: Information to pass to `agg()`, could be
a dictionary mapping the columns to the aggregation
function to use, a string name for the function,
or the function itself.
- args: Positional arguments to pass to `func`.
- kwargs: Keyword arguments to pass to `func`.
Returns:
A new `DataFrame` object.
"""
return df.pipe(func, *args, **kwargs).agg(agg_dict)
File: ~/.../ch_04/window_calc.py
Type: function
我们的window_calc()函数接受数据框架、需要执行的函数(只要它的第一个参数是数据框架),以及如何聚合结果的信息,还可以传递任何可选的参数,然后返回一个包含窗口计算结果的新数据框架。让我们使用这个函数来计算 Facebook 股票数据的扩展中位数:
>>> window_calc(fb, pd.DataFrame.expanding, np.median).head()
注意,expanding()方法不需要我们指定任何参数,因此我们只需要传入pd.DataFrame.expanding(不带括号),并附带需要进行的聚合操作,作为数据框架上的窗口计算:

图 4.30 – 使用管道进行扩展窗口计算
window_calc()函数还接受*args和**kwargs;这些是可选参数,如果提供,它们会在按名称传递时被 Python 收集到kwargs中(例如span=20),如果未提供(按位置传递),则会收集到args中。然后可以使用*表示args,**表示kwargs。我们需要这种行为,以便使用ewm()方法计算 Facebook 股票收盘价的指数加权移动平均(EWMA):
>>> window_calc(fb, pd.DataFrame.ewm, 'mean', span=3).head()
在前面的示例中,我们不得不使用**kwargs,因为span参数并不是ewm()接收的第一个参数,我们不想传递它前面的那些参数:

图 4.31 – 使用管道进行指数加权窗口计算
为了计算中央公园的 3 天滚动天气聚合,我们利用了*args,因为我们知道窗口是rolling()的第一个参数:
>>> window_calc(
... central_park_weather.loc['2018-10'],
... pd.DataFrame.rolling,
... {'TMAX': 'max', 'TMIN': 'min',
... 'AWND': 'mean', 'PRCP': 'sum'},
... '3D'
... ).head()
我们能够对每一列进行不同的聚合,因为我们传入了一个字典,而不是一个单一的值:

图 4.32 – 使用管道进行滚动窗口计算
注意,我们是如何能够为窗口计算创建一致的 API,而调用者无需弄清楚在窗口函数之后需要调用哪个聚合方法。这隐藏了一些实现细节,同时使得使用更加方便。我们将在第七章中构建的StockVisualizer类的某些功能中使用这个函数,金融分析 - 比特币与股市。
聚合数据
在我们讨论窗口计算和管道操作的上一节中,已经对聚合有了初步了解。在这里,我们将专注于通过聚合来汇总数据帧,这将改变我们数据帧的形状(通常是通过减少行数)。我们还看到,利用 NumPy 的矢量化函数在pandas数据结构上进行聚合是多么容易。这正是 NumPy 最擅长的:在数值数组上执行高效的数学计算。
NumPy 与聚合数据框非常搭配,因为它为我们提供了一种通过不同的预写函数汇总数据的简便方法;通常,在进行聚合时,我们只需要使用 NumPy 函数,因为大多数我们自己编写的函数已经由 NumPy 实现。我们已经看到了一些常用的 NumPy 聚合函数,比如np.sum()、np.mean()、np.min()和np.max();然而,我们不仅限于数值操作——我们还可以在字符串上使用诸如np.unique()之类的函数。总是检查 NumPy 是否已经提供了所需的函数,再决定是否自己实现。
本节中,我们将在3-aggregations.ipynb笔记本中进行操作。让我们导入pandas和numpy,并读取我们将要处理的数据:
>>> import numpy as np
>>> import pandas as pd
>>> fb = pd.read_csv(
... 'data/fb_2018.csv', index_col='date', parse_dates=True
... ).assign(trading_volume=lambda x: pd.cut(
... x.volume, bins=3, labels=['low', 'med', 'high']
... ))
>>> weather = pd.read_csv(
... 'data/weather_by_station.csv',
... index_col='date', parse_dates=True
... )
请注意,本节的天气数据已经与部分站点数据合并:

图 4.33 – 本节合并的天气与站点数据
在进行任何计算之前,我们先确保数据不会以科学计数法显示。我们将修改浮动数值的显示格式。我们将应用的格式是.2f,该格式会提供一个小数点后两位的浮动数值:
>>> pd.set_option('display.float_format', lambda x: '%.2f' % x)
首先,我们将查看如何汇总完整数据集,然后再进行按组汇总,并构建数据透视表和交叉表。
汇总数据帧
当我们讨论窗口计算时,我们看到可以在rolling()、expanding()或ewm()的结果上运行agg()方法;然而,我们也可以像对待数据框一样直接调用它。唯一的区别是,通过这种方式执行的聚合将在所有数据上进行,这意味着我们将只得到一个包含整体结果的系列。让我们像在窗口计算中一样对 Facebook 的股票数据进行聚合。请注意,对于trading_volume列(它包含pd.cut()生成的交易量区间),我们不会得到任何结果;这是因为我们没有为该列指定聚合操作:
>>> fb.agg({
... 'open': np.mean, 'high': np.max, 'low': np.min,
... 'close': np.mean, 'volume': np.sum
... })
open 171.45
high 218.62
low 123.02
close 171.51
volume 6949682394.00
dtype: float64
我们可以使用聚合函数轻松找到 2018 年中央公园的总降雪量和降水量。在这种情况下,由于我们将在两者上执行求和操作,我们可以使用agg('sum')或直接调用sum():
>>> weather.query('station == "GHCND:USW00094728"')\
... .pivot(columns='datatype', values='value')\
... [['SNOW', 'PRCP']].sum()
datatype
SNOW 1007.00
PRCP 1665.30
dtype: float64
此外,我们还可以为每个要聚合的列提供多个函数。正如我们之前所见,当每列只有一个聚合函数时,我们会得到一个Series对象。若每列有多个聚合函数,pandas将返回一个DataFrame对象。这个数据框的索引会告诉我们正在为哪个列计算哪种指标:
>>> fb.agg({
... 'open': 'mean',
... 'high': ['min', 'max'],
... 'low': ['min', 'max'],
... 'close': 'mean'
... })
这将产生一个数据框,其中行表示应用于数据列的聚合函数。请注意,对于我们没有明确要求的任何聚合与列的组合,结果会是空值:

图 4.34 – 每列执行多个聚合
到目前为止,我们已经学会了如何在特定窗口和整个数据框上进行聚合;然而,真正的强大之处在于按组别进行聚合。这使我们能够计算如每月、每个站点的总降水量,以及我们为每个交易量区间创建的股票 OHLC 平均价格等内容。
按组聚合
要按组计算聚合,我们必须首先在数据框上调用groupby()方法,并提供我们想用来确定不同组的列。让我们来看一下我们用pd.cut()创建的每个交易量区间的股票数据点的平均值;记住,这些是三个等宽区间:
>>> fb.groupby('trading_volume').mean()
较大交易量的 OHLC 平均价格较小,这是预期之中的,因为高交易量区间的三个日期是抛售日:

图 4.35 – 按组聚合
在运行groupby()后,我们还可以选择特定的列进行聚合:
>>> fb.groupby('trading_volume')\
... ['close'].agg(['min', 'max', 'mean'])
这给我们带来了每个交易量区间的收盘价聚合:

图 4.36 – 按组聚合特定列
如果我们需要更精细地控制每个列的聚合方式,我们可以再次使用agg()方法,并提供一个将列映射到聚合函数的字典。如同之前一样,我们可以为每一列提供多个函数;不过结果会看起来有些不同:
>>> fb_agg = fb.groupby('trading_volume').agg({
... 'open': 'mean', 'high': ['min', 'max'],
... 'low': ['min', 'max'], 'close': 'mean'
... })
>>> fb_agg
现在,我们的列有了层级索引。记住,这意味着,如果我们想选择中等交易量区间的最低低价,我们需要使用fb_agg.loc['med', 'low']['min']:

图 4.37 – 按组进行每列的多个聚合
列存储在一个MultiIndex对象中:
>>> fb_agg.columns
MultiIndex([( 'open', 'mean'),
( 'high', 'min'),
( 'high', 'max'),
( 'low', 'min'),
( 'low', 'max'),
('close', 'mean')],
)
我们可以使用列表推导来移除这个层级,而是将列名转换为<column>_<agg>的形式。在每次迭代中,我们将从MultiIndex对象中获取一个元组的层级,这些层级可以组合成一个单一的字符串来去除层级:
>>> fb_agg.columns = ['_'.join(col_agg)
... for col_agg in fb_agg.columns]
>>> fb_agg.head()
这将把列中的层级替换为单一层级:

图 4.38 – 扁平化层级索引
假设我们想查看所有站点每天的平均降水量。我们需要按日期进行分组,但日期在索引中。在这种情况下,我们有几种选择:
-
重采样,我们将在本章后面的处理时间序列数据部分讲解。
-
重置索引,并使用从索引中创建的日期列。
-
将
level=0传递给groupby(),表示分组应在索引的最外层进行。 -
使用
Grouper对象。
在这里,我们将level=0传递给groupby(),但请注意,我们也可以传入level='date',因为我们的索引已经命名。这将给我们一个跨所有站点的平均降水量观察结果,这可能比仅查看某个站点的数据更能帮助我们了解天气。由于结果是一个单列的DataFrame对象,我们调用squeeze()将其转换为Series对象:
>>> weather.loc['2018-10'].query('datatype == "PRCP"')\
... .groupby(level=0).mean().head().squeeze()
date
2018-10-01 0.01
2018-10-02 2.23
2018-10-03 19.69
2018-10-04 0.32
2018-10-05 0.96
Name: value, dtype: float64
我们还可以一次性按多个类别进行分组。让我们找出每个站点每季度的降水总量。在这里,我们需要使用Grouper对象将频率从日度聚合到季度,而不是将level=0传递给groupby()。由于这将创建多层级索引,我们还将使用unstack()在聚合完成后将内层级(季度)放置在列中:
>>> weather.query('datatype == "PRCP"').groupby(
... ['station_name', pd.Grouper(freq='Q')]
... ).sum().unstack().sample(5, random_state=1)
对于这个结果,有许多可能的后续分析。我们可以查看哪些站点接收到的降水最多/最少。我们还可以回到每个站点的位置信息和海拔数据,看看这些是否影响降水。我们还可以查看哪个季度在所有站点中降水最多/最少:

图 4.39 – 按包含日期的列进行聚合
提示
groupby()方法返回的DataFrameGroupBy对象具有一个filter()方法,允许我们过滤组。我们可以使用它来从聚合中排除某些组。只需传入一个返回布尔值的函数,针对每个组的子集(True表示包含该组,False表示排除该组)。示例可以在笔记本中查看。
让我们看看哪个月份降水最多。首先,我们需要按天进行分组,并对各个站点的降水量求平均。然后,我们可以按月分组并求和。最后,我们将使用nlargest()来获取降水量最多的前五个月:
>>> weather.query('datatype == "PRCP"')\
... .groupby(level=0).mean()\
... .groupby(pd.Grouper(freq='M')).sum().value.nlargest()
date
2018-11-30 210.59
2018-09-30 193.09
2018-08-31 192.45
2018-07-31 160.98
2018-02-28 158.11
Name: value, dtype: float64
也许前面的结果让人感到惊讶。俗话说,四月的阵雨带来五月的花;然而,四月并未出现在前五名中(五月也没有)。雪也会计入降水量,但这并不能解释为何夏季的降水量会高于四月。让我们查找在某月降水量占比较大的天数,看看四月是否会出现在那里。
为此,我们需要计算每个气象站的日均降水量,并计算每月的总降水量;这将作为分母。然而,为了将每日的值除以其所在月份的总值,我们需要一个维度相等的Series对象。这意味着我们需要使用transform()方法,它会对数据执行指定的计算,并始终返回一个与起始对象维度相同的对象。因此,我们可以在Series对象上调用它,并始终返回一个Series对象,无论聚合函数本身返回的是什么:
>>> weather.query('datatype == "PRCP"')\
... .rename(dict(value='prcp'), axis=1)\
... .groupby(level=0).mean()\
... .groupby(pd.Grouper(freq='M'))\
... .transform(np.sum)['2018-01-28':'2018-02-03']
与其对一月和二月分别得到一个总和,不如注意到我们在一月的条目中得到了相同的值,而在二月的条目中得到了不同的值。注意,二月的值是我们在前面结果中找到的那个值:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.40_B16834.jpg)
图 4.40 – 用于计算月度降水百分比的分母
我们可以在数据框中创建这一列,以便轻松计算每天发生的月度降水百分比。然后,我们可以使用nlargest()方法提取最大的值:
>>> weather.query('datatype == "PRCP"')\
... .rename(dict(value='prcp'), axis=1)\
... .groupby(level=0).mean()\
... .assign(
... total_prcp_in_month=lambda x: x.groupby(
... pd.Grouper(freq='M')).transform(np.sum),
... pct_monthly_prcp=lambda x: \
... x.prcp.div(x.total_prcp_in_month)
... ).nlargest(5, 'pct_monthly_prcp')
在按月降水量排序的前四和第五天中,它们加起来占了四月降水量的 50%以上。这两天也是连续的:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.41_B16834.jpg)
图 4.41 – 计算每天发生的月度降水百分比
重要提示
transform()方法同样适用于DataFrame对象,在这种情况下它将返回一个DataFrame对象。我们可以使用它一次性轻松地标准化所有列。示例可以在笔记本中找到。
数据透视表和交叉表
本节的最后,我们将讨论一些pandas函数,这些函数能够将我们的数据聚合为一些常见格式。我们之前讨论的聚合方法会给我们最大的自定义空间;然而,pandas还提供了一些函数,可以快速生成数据透视表和交叉表,格式上符合常见标准。
为了生成数据透视表,我们必须指定按什么进行分组,并可选择指定要聚合的列子集和/或聚合方式(默认情况下为平均)。让我们创建一个 Facebook 按交易量分组的平均 OHLC 数据的透视表:
>>> fb.pivot_table(columns='trading_volume')
由于我们传入了columns='trading_volume',trading_volume列中的不同值被放置在了列中。原始数据框中的列则转到了索引。请注意,列的索引有一个名称(trading_volume):

图 4.42 – 每个交易量区间的列平均值透视表
提示
如果我们将trading_volume作为index参数传入,得到的将是图 4.42的转置,这也是使用groupby()时得到的与图 4.35完全相同的输出。
使用pivot()方法时,我们无法处理多级索引或具有重复值的索引。正因为如此,我们无法将天气数据放入宽格式。pivot_table()方法解决了这个问题。为此,我们需要将date和station信息放入索引中,将datatype列的不同值放入列中。值将来自value列。对于任何重叠的组合(如果有),我们将使用中位数进行聚合:
>>> weather.reset_index().pivot_table(
... index=['date', 'station', 'station_name'],
... columns='datatype',
... values='value',
... aggfunc='median'
... ).reset_index().tail()
在重置索引后,我们得到了宽格式的数据。最后一步是重命名索引:

图 4.43 – 每种数据类型、站点和日期的中位数值透视表
我们可以使用pd.crosstab()函数来创建一个频率表。例如,如果我们想查看每个月 Facebook 股票的低、中、高交易量交易天数,可以使用交叉表。语法非常简单;我们将行和列标签分别传递给index和columns参数。默认情况下,单元格中的值将是计数:
>>> pd.crosstab(
... index=fb.trading_volume, columns=fb.index.month,
... colnames=['month'] # name the columns index
... )
这样可以方便地查看 Facebook 股票在不同月份的高交易量:

图 4.44 – 显示每个月、每个交易量区间的交易天数的交叉表
提示
我们可以通过传入normalize='rows'/normalize='columns'来将输出标准化为行/列总计的百分比。示例见笔记本。
要更改聚合函数,我们可以为values提供一个参数,然后指定aggfunc。为了说明这一点,让我们计算每个月每个交易量区间的平均收盘价,而不是上一个示例中的计数:
>>> pd.crosstab(
... index=fb.trading_volume, columns=fb.index.month,
... colnames=['month'], values=fb.close, aggfunc=np.mean
... )
现在我们得到了每个月、每个交易量区间的平均收盘价,数据中没有该组合时则显示为 null 值:

图 4.45 – 使用平均值而非计数的交叉表
我们还可以使用margins参数来获取行和列的小计。让我们统计每个月每个站点记录到雪的次数,并包括小计:
>>> snow_data = weather.query('datatype == "SNOW"')
>>> pd.crosstab(
... index=snow_data.station_name,
... columns=snow_data.index.month,
... colnames=['month'],
... values=snow_data.value,
... aggfunc=lambda x: (x > 0).sum(),
... margins=True, # show row and column subtotals
... margins_name='total observations of snow' # subtotals
... )
在底部的行中,我们展示了每月的总降雪观测数据,而在最右边的列中,我们列出了 2018 年每个站点的总降雪观测数据:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.46_B16834.jpg)
图 4.46 – 统计每个月每个站点降雪天数的交叉表
通过查看少数几个站点,我们可以发现,尽管它们都提供了纽约市的天气信息,但它们并不共享天气的每个方面。根据我们选择查看的站点,我们可能会添加或减少与纽约市实际发生的雪量。
使用时间序列数据
使用时间序列数据时,我们可以进行一些额外的操作,涵盖从选择和筛选到聚合的各个方面。我们将在 4-time_series.ipynb 笔记本中探索一些这种功能。我们先从读取前面章节中的 Facebook 数据开始:
>>> import numpy as np
>>> import pandas as pd
>>> fb = pd.read_csv(
... 'data/fb_2018.csv', index_col='date', parse_dates=True
... ).assign(trading_volume=lambda x: pd.cut(
... x.volume, bins=3, labels=['low', 'med', 'high']
... ))
本节将从讨论时间序列数据的选择和筛选开始,接着讲解数据的平移、差分、重采样,最后讲解基于时间的数据合并。请注意,设置日期(或日期时间)列作为索引非常重要,这将使我们能够利用接下来要讲解的额外功能。某些操作无需设置索引也能工作,但为了确保分析过程的顺利进行,建议使用 DatetimeIndex 类型的索引。
基于时间的选择和筛选
让我们先快速回顾一下日期时间切片和索引的内容。我们可以通过索引轻松提取特定年份的数据:fb.loc['2018']。对于我们的股票数据,由于只有 2018 年的数据,因此会返回整个数据框;不过,我们也可以过滤出某个月的数据(例如 fb.loc['2018-10']) 或者某个日期范围的数据。请注意,对于范围的选择,使用 loc[] 是可选的:
>>> fb['2018-10-11':'2018-10-15']
我们仅能获得三天的数据,因为股市在周末休市:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.47_B16834.jpg)
图 4.47 – 基于日期范围选择数据
请记住,日期范围也可以使用其他频率提供,例如按月或按季度:
>>> fb.loc['2018-q1'].equals(fb['2018-01':'2018-03'])
True
当目标是日期范围的开始或结束时,pandas 提供了一些额外的方法来选择指定时间单位内的第一行或最后一行数据。我们可以使用 first() 方法和 1W 偏移量来选择 2018 年的第一周股价数据:
>>> fb.first('1W')
2018 年 1 月 1 日是节假日,股市休市。而且那天是周一,因此这一周只有四天:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.48_B16834.jpg)
图 4.48 – 2018 年首周 Facebook 股票数据
我们也可以对最近的日期执行类似的操作。选择数据中的最后一周,只需将 first() 方法替换为 last() 方法即可:
>>> fb.last('1W')
由于 2018 年 12 月 31 日是星期一,最后一周只包含一天:

图 4.49 – 2018 年最后一周的 Facebook 股票数据
在处理每日股票数据时,我们只有股市开放日期的数据。假设我们将数据重新索引,以便为每一天添加一行:
>>> fb_reindexed = fb.reindex(
... pd.date_range('2018-01-01', '2018-12-31', freq='D')
... )
重新索引的数据将在 1 月 1 日和其他市场关闭的日期上显示所有空值。我们可以结合使用 first()、isna() 和 all() 方法来确认这一点。这里,我们还将使用 squeeze() 方法将通过调用 first('1D').isna() 得到的 1 行 DataFrame 对象转换为 Series 对象,以便调用 all() 时返回单一值:
>>> fb_reindexed.first('1D').isna().squeeze().all()
True
我们可以使用 first_valid_index() 方法获取数据中第一个非空条目的索引,它将是数据中的第一个交易日。要获取最后一个交易日,我们可以使用 last_valid_index() 方法。对于 2018 年第一季度,交易的第一天是 1 月 2 日,最后一天是 3 月 29 日:
>>> fb_reindexed.loc['2018-Q1'].first_valid_index()
Timestamp('2018-01-02 00:00:00', freq='D')
>>> fb_reindexed.loc['2018-Q1'].last_valid_index()
Timestamp('2018-03-29 00:00:00', freq='D')
如果我们想知道 2018 年 3 月 31 日 Facebook 的股票价格,最初的想法可能是使用索引来获取它。然而,如果我们尝试通过 loc[] (fb_reindexed.loc['2018-03-31']) 来获取,我们将得到空值,因为那天股市并未开放。如果我们改用 asof() 方法,它将返回在我们要求的日期之前的最近非空数据,在本例中是 3 月 29 日。因此,如果我们想查看 Facebook 在每个月的最后一天的表现,可以使用 asof(),而无需先检查当天股市是否开放:
>>> fb_reindexed.asof('2018-03-31')
open 155.15
high 161.42
low 154.14
close 159.79
volume 59434293.00
trading_volume low
Name: 2018-03-31 00:00:00, dtype: object
在接下来的几个例子中,我们除了日期之外还需要时间信息。到目前为止,我们处理的数据集缺少时间组件,因此我们将切换到来自 Nasdaq.com 的 2019 年 5 月 20 日至 2019 年 5 月 24 日的按分钟划分的 Facebook 股票数据。为了正确解析日期时间,我们需要将一个 lambda 函数作为 date_parser 参数传入,因为这些数据并非标准格式(例如,2019 年 5 月 20 日 9:30 AM 表示为 2019-05-20 09-30);该 lambda 函数将指定如何将 date 字段中的数据转换为日期时间:
>>> stock_data_per_minute = pd.read_csv(
... 'data/fb_week_of_may_20_per_minute.csv',
... index_col='date', parse_dates=True,
... date_parser=lambda x: \
... pd.to_datetime(x, format='%Y-%m-%d %H-%M')
... )
>>> stock_data_per_minute.head()
我们有每分钟的 OHLC 数据,以及每分钟的成交量数据:

图 4.50 – Facebook 股票数据按分钟划分
重要说明
为了正确解析非标准格式的日期时间,我们需要指定其格式。有关可用代码的参考,请查阅 Python 文档:docs.python.org/3/library/datetime.html#strftime-strptime-behavior。
我们可以使用first()和last()结合agg()将这些数据转换为每日的粒度。为了获取真实的开盘价,我们需要每天获取第一个观察值;相反,对于真实的收盘价,我们需要每天获取最后一个观察值。高点和低点将分别是它们每天各自列的最大和最小值。成交量将是每日的总和:
>>> stock_data_per_minute.groupby(pd.Grouper(freq='1D')).agg({
... 'open': 'first',
... 'high': 'max',
... 'low': 'min',
... 'close': 'last',
... 'volume': 'sum'
... })
这将数据升级到每日频率:

Figure 4.51 – 将数据从分钟级别升级到日级别
接下来我们将讨论的两种方法帮助我们基于日期时间的时间部分选择数据。at_time()方法允许我们隔离日期时间的时间部分是我们指定的时间的行。通过运行at_time('9:30'),我们可以抓取所有开盘价格(股票市场在上午 9:30 开盘):
>>> stock_data_per_minute.at_time('9:30')
这告诉我们每天开盘铃响时的股票数据是怎样的:

Figure 4.52 – 每天市场开盘时的股票数据
我们可以使用between_time()方法来抓取时间部分位于两个时间之间(默认包含端点)的所有行。如果我们想要逐日查看某个时间范围内的数据,这种方法非常有用。让我们抓取每天交易的最后两分钟内的所有行(15:59 - 16:00):
>>> stock_data_per_minute.between_time('15:59', '16:00')
看起来最后一分钟(16:00)每天的交易量显著高于前一分钟(15:59)。也许人们会在闭市前赶紧进行交易:

Figure 4.53 – 每天交易最后两分钟的股票数据
我们可能会想知道这是否也发生在前两分钟内。人们是否在前一天晚上进行交易,然后在市场开盘时执行?更改前面的代码以回答这个问题是微不足道的。相反,让我们看看在讨论的这周中,平均而言,更多的股票是在开盘后的前 30 分钟内交易还是在最后 30 分钟内交易。我们可以结合between_time()和groupby()来回答这个问题。此外,我们需要使用filter()来排除聚合中的组。被排除的组是不在我们想要的时间范围内的时间:
>>> shares_traded_in_first_30_min = stock_data_per_minute\
... .between_time('9:30', '10:00')\
... .groupby(pd.Grouper(freq='1D'))\
... .filter(lambda x: (x.volume > 0).all())\
... .volume.mean()
>>> shares_traded_in_last_30_min = stock_data_per_minute\
... .between_time('15:30', '16:00')\
... .groupby(pd.Grouper(freq='1D'))\
... .filter(lambda x: (x.volume > 0).all())\
... .volume.mean()
在讨论的这周中,开盘时间周围的平均交易量比收盘时间多了 18,593 笔:
>>> shares_traded_in_first_30_min \
... - shares_traded_in_last_30_min
18592.967741935485
小贴士
我们可以对DatetimeIndex对象使用normalize()方法或在首次访问Series对象的dt属性后使用它,以将所有日期时间规范化为午夜。当时间对我们的数据没有添加价值时,这非常有用。笔记本中有此类示例。
通过股票数据,我们可以获得每分钟或每天的价格快照(具体取决于数据粒度),但我们可能更关心将时间段之间的变化作为时间序列显示,而不是聚合数据。为此,我们需要学习如何创建滞后数据。
滞后数据的偏移
我们可以使用shift()方法创建滞后数据。默认情况下,偏移量为一个周期,但它可以是任何整数(正数或负数)。我们来使用shift()方法创建一个新列,表示每日 Facebook 股票的前一日收盘价。通过这个新列,我们可以计算由于盘后交易导致的价格变化(即一天市场收盘后至下一天市场开盘前的交易):
>>> fb.assign(
... prior_close=lambda x: x.close.shift(),
... after_hours_change_in_price=lambda x: \
... x.open - x.prior_close,
... abs_change=lambda x: \
... x.after_hours_change_in_price.abs()
... ).nlargest(5, 'abs_change')
这给我们展示了受盘后交易影响最大的日期:

图 4.54 – 使用滞后数据计算盘后股价变化
提示
若要从索引中的日期时间添加/减去时间,可以考虑使用Timedelta对象。笔记本中有相关示例。
在之前的例子中,我们使用了偏移后的数据来计算跨列的变化。然而,如果我们感兴趣的不是盘后交易,而是 Facebook 股价的每日变化,我们将计算收盘价与偏移后收盘价之间的差异。Pandas 使这变得比这更简单,我们稍后会看到。
差分数据
我们已经讨论过如何使用shift()方法创建滞后数据。然而,通常我们关心的是值从一个时间周期到下一个时间周期的变化。为此,pandas提供了diff()方法。默认情况下,它会计算从时间周期t-1到时间周期t的变化:

请注意,这相当于从原始数据中减去shift()的结果:
>>> (fb.drop(columns='trading_volume')
... - fb.drop(columns='trading_volume').shift()
... ).equals(fb.drop(columns='trading_volume').diff())
True
我们可以使用diff()轻松计算 Facebook 股票数据的逐日变化:
>>> fb.drop(columns='trading_volume').diff().head()
对于年的前几个交易日,我们可以看到股价上涨,而成交量每天减少:

图 4.55 – 计算逐日变化
提示
要指定用于计算差异的周期数,只需向diff()传递一个整数。请注意,这个数字可以是负数。笔记本中有相关示例。
重采样
有时,数据的粒度不适合我们的分析。假设我们有 2018 年全年的每分钟数据,粒度和数据的性质可能使得绘图无用。因此,我们需要将数据汇总到一个较低粒度的频率:

图 4.56 – 重采样可以用于汇总细粒度数据
假设我们拥有图 4.50中的一整年的数据(Facebook 股票按分钟显示)。这种粒度可能超出了我们的需求,在这种情况下,我们可以使用 resample() 方法将时间序列数据聚合为不同的粒度。使用 resample() 时,我们只需要告诉它如何汇总数据,并可选地调用聚合方法。例如,我们可以将这些按分钟的股票数据重采样为每日频率,并指定如何聚合每一列:
>>> stock_data_per_minute.resample('1D').agg({
... 'open': 'first',
... 'high': 'max',
... 'low': 'min',
... 'close': 'last',
... 'volume': 'sum'
... })
这与我们在基于时间的选择和过滤部分得到的结果相当(图 4.51):

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.57_B16834.jpg)
图 4.57 – 将每分钟的数据重采样为日数据
我们可以重采样为 pandas 支持的任何频率(更多信息可以参考文档:pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html)。让我们将每日的 Facebook 股票数据重采样为季度平均值:
>>> fb.resample('Q').mean()
这给出了股票的季度平均表现。2018 年第四季度显然很糟糕:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.58_B16834.jpg)
图 4.58 – 重采样为季度平均值
为了进一步分析,我们可以使用 apply() 方法查看季度开始和结束时的差异。我们还需要在基于时间的选择和过滤部分使用 first() 和 last() 方法:
>>> fb.drop(columns='trading_volume').resample('Q').apply(
... lambda x: x.last('1D').values - x.first('1D').values
... )
Facebook 的股票价格在除第二季度外的所有季度都出现了下降:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.57_B16834.jpg)
图 4.59 – 总结 Facebook 2018 年每个季度的股票表现
请考虑 melted_stock_data.csv 中按分钟融化的股票数据:
>>> melted_stock_data = pd.read_csv(
... 'data/melted_stock_data.csv',
... index_col='date', parse_dates=True
... )
>>> melted_stock_data.head()
OHLC 格式使得分析股票数据变得更加容易,但如果数据只有一列,则会变得更加复杂:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.60_B16834.jpg)
图 4.60 – 按分钟显示股票价格
我们在调用 resample() 后得到的 Resampler 对象有一个 ohlc() 方法,我们可以使用它来获取我们习惯看到的 OHLC 数据:
>>> melted_stock_data.resample('1D').ohlc()['price']
由于原始数据中的列叫做 price,我们在调用 ohlc() 后选择它,这样我们就能对数据进行透视处理。否则,我们会在列中得到一个层次化索引:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.61_B16834.jpg)
图 4.61 – 将按分钟的股票价格重采样以形成每日 OHLC 数据
在之前的示例中,我们使用 asfreq() 来避免对结果进行聚合:
>>> fb.resample('6H').asfreq().head()
请注意,当我们在比已有数据更细粒度的情况下进行重采样时,它将引入 NaN 值:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_4.58_B16834.jpg)
图 4.62 – 向上采样增加了数据的粒度,并会引入空值
以下是处理NaN值的一些方法。为了简洁起见,示例在笔记本中:
-
在
resample()之后使用pad()进行前向填充。 -
在
resample()之后调用fillna(),正如我们在第三章《使用 Pandas 进行数据清洗》中看到的,当我们处理缺失值时。 -
使用
asfreq()后接assign()来单独处理每一列。
到目前为止,我们一直在处理存储在单个DataFrame对象中的时间序列数据,但我们可能希望合并多个时间序列。虽然在合并 DataFrame部分讨论的技术适用于时间序列,pandas提供了更多的功能来合并时间序列,这样我们就可以基于接近的匹配进行合并,而不需要完全匹配。接下来我们将讨论这些内容。
合并时间序列
时间序列通常精确到秒,甚至更为细致,这意味着如果条目没有相同的日期时间,合并可能会非常困难。Pandas 通过两个附加的合并函数解决了这个问题。当我们想要配对接近时间的观测值时,可以使用pd.merge_asof(),根据附近的键进行匹配,而不是像我们在连接中那样使用相等的键。另一方面,如果我们想匹配相等的键并交错没有匹配项的键,可以使用pd.merge_ordered()。
为了说明这些方法的工作原理,我们将使用stocks.db SQLite 数据库中的fb_prices和aapl_prices表。这些表分别包含 Facebook 和 Apple 股票的价格,以及记录价格时的时间戳。请注意,Apple 数据是在 2020 年 8 月股票拆分之前收集的(www.marketwatch.com/story/3-things-to-know-about-apples-stock-split-2020-08-28)。让我们从数据库中读取这些表:
>>> import sqlite3
>>> with sqlite3.connect('data/stocks.db') as connection:
... fb_prices = pd.read_sql(
... 'SELECT * FROM fb_prices', connection,
... index_col='date', parse_dates=['date']
... )
... aapl_prices = pd.read_sql(
... 'SELECT * FROM aapl_prices', connection,
... index_col='date', parse_dates=['date']
... )
Facebook 的数据是按分钟粒度的;然而,Apple 的数据是按(虚构的)秒粒度的:
>>> fb_prices.index.second.unique()
Int64Index([0], dtype='int64', name='date')
>>> aapl_prices.index.second.unique()
Int64Index([ 0, 52, ..., 37, 28], dtype='int64', name='date')
如果我们使用merge()或join(),只有当 Apple 的价格位于整分钟时,我们才会得到两者的匹配值。相反,为了对齐这些数据,我们可以执行as of合并。为了处理这种不匹配情况,我们将指定合并时使用最近的分钟(direction='nearest'),并要求匹配只能发生在相差不超过 30 秒的时间之间(tolerance)。这将把 Apple 数据与最接近的分钟对齐,因此9:31:52将与9:32匹配,9:37:07将与9:37匹配。由于时间位于索引中,我们像在merge()中一样,传入left_index和right_index:
>>> pd.merge_asof(
... fb_prices, aapl_prices,
... left_index=True, right_index=True,
... # merge with nearest minute
... direction='nearest',
... tolerance=pd.Timedelta(30, unit='s')
... ).head()
这类似于左连接;然而,在匹配键时我们更加宽松。需要注意的是,如果多个苹果数据条目匹配相同的分钟,这个函数只会保留最接近的一个。我们在9:31处得到一个空值,因为苹果在9:31的条目是9:31:52,当使用nearest时它会被放置到9:32:

图 4.63 – 在 30 秒容忍度下合并时间序列数据
如果我们不希望使用左连接的行为,可以改用pd.merge_ordered()函数。这将允许我们指定连接类型,默认情况下为'outer'。然而,我们需要重置索引才能在日期时间上进行连接:
>>> pd.merge_ordered(
... fb_prices.reset_index(), aapl_prices.reset_index()
... ).set_index('date').head()
这种策略会在时间不完全匹配时给我们空值,但至少会对它们进行排序:

图 4.64 – 对时间序列数据执行严格的合并并对其进行排序
提示
我们可以将fill_method='ffill'传递给pd.merge_ordered(),以在一个值后填充第一个NaN,但它不会继续传播;另外,我们可以链式调用fillna()。笔记本中有一个示例。
pd.merge_ordered()函数还使得按组合并成为可能,因此请务必查看文档以获取更多信息。
概述
在本章中,我们讨论了如何连接数据框,如何使用集合操作确定每种连接类型丢失的数据,以及如何像查询数据库一样查询数据框。接着我们讲解了一些更复杂的列变换,例如分箱和排名,以及如何使用apply()方法高效地进行这些操作。我们还学习了在编写高效pandas代码时矢量化操作的重要性。随后,我们探索了窗口计算和使用管道来使代码更简洁。关于窗口计算的讨论为聚合整个数据框和按组聚合奠定了基础。我们还讨论了如何生成透视表和交叉表。最后,我们介绍了pandas中针对时间序列的特定功能,涵盖了从选择、聚合到合并等各个方面。
在下一章中,我们将讨论可视化,pandas通过提供一个封装器来实现这一功能,封装了matplotlib。数据清洗将在准备数据以进行可视化时发挥关键作用,因此在继续之前,一定要完成下一节提供的练习。
练习
使用exercises/文件夹中的 CSV 文件以及我们到目前为止在本书中学到的内容,完成以下练习:
-
使用
earthquakes.csv文件,选择所有日本的地震,并使用mb震级类型筛选震级为 4.9 或更大的地震。 -
创建每个地震震级完整数字的箱子(例如,第一个箱子是(0, 1],第二个箱子是(1, 2],以此类推),使用
ml震级类型并计算每个箱子中的数量。 -
使用
faang.csv文件,按股票代码分组并重采样为月频率。进行以下聚合:a) 开盘价的均值
b) 最高价的最大值
c) 最低价的最小值
d) 收盘价的均值
e) 成交量总和
-
构建一个交叉表,展示地震数据中
tsunami列和magType列之间的关系。不要显示频次计数,而是显示每个组合观察到的最大震级。将震级类型放在列中。 -
计算 FAANG 数据中每个股票的 OHLC 数据的滚动 60 天聚合值。使用与练习3相同的聚合方法。
-
创建一个 FAANG 数据的透视表,比较股票。将股票代码放入行中,显示 OHLC 和成交量数据的平均值。
-
使用
apply()方法计算 2018 年第四季度亚马逊数据(ticker为 AMZN)每个数值列的 Z 分数。 -
添加事件描述:
a) 创建一个数据框,包含以下三列:
ticker,date和event。这些列应包含以下值:i)
ticker:'FB'ii)
date:['2018-07-25', '2018-03-19', '2018-03-20']iii)
event:['公布财报后用户增长令人失望。', '剑桥分析丑闻', 'FTC 调查']b) 将索引设置为
['date', 'ticker']。c) 使用外连接将此数据与 FAANG 数据合并。
-
对 FAANG 数据使用
transform()方法,将所有的值表示为数据中第一个日期的值。为此,将每个股票的所有值除以该股票在数据中第一个日期的值。这被称为transform()可以接受一个函数名称。 -
covid19_cases.csv文件。ii) 通过解析
dateRep列为 datetime 格式来创建一个date列。iii) 将
date列设置为索引。iv) 使用
replace()方法将所有United_States_of_America和United_Kingdom替换为USA和UK。v) 排序索引。
b) 对于病例最多的五个国家(累计),找出病例数最多的那一天。
c) 找出数据中最后一周五个疫情病例最多的国家的 COVID-19 病例 7 天平均变化。
d) 找出中国以外的每个国家首次出现病例的日期。
e) 按累计病例数使用百分位数对国家进行排名。
进一步阅读
查看以下资源,了解本章中涉及的主题:
-
SQL 入门:查询和管理数据:
www.khanacademy.org/computing/computer-programming/sql -
(Pandas)与 SQL 的比较:
pandas.pydata.org/pandas-docs/stable/getting_started/comparison/comparison_with_sql.html -
集合运算:
www.probabilitycourse.com/chapter1/1_2_2_set_operations.php -
Python 中的args 和**kwargs 解释*:
pythontips.com/2013/08/04/args-and-kwargs-in-python-explained/
第六章:第五章:使用 Pandas 和 Matplotlib 可视化数据
到目前为止,我们一直在处理严格的表格格式数据。然而,人类大脑擅长识别视觉模式;因此,我们的自然下一步是学习如何将数据可视化。可视化使得我们更容易发现数据中的异常,并向他人解释我们的发现。然而,我们不应将数据可视化仅仅保留给向他人呈现结论的人,因为在我们的探索性数据分析中,数据可视化对于帮助我们迅速且全面地理解数据至关重要。
有许多种类的可视化远超我们之前可能见过的内容。在本章中,我们将介绍最常见的图表类型,如折线图、直方图、散点图和条形图,以及基于这些类型的一些其他图表。我们不会讨论饼图——它们通常很难正确阅读,而且有更好的方式传达我们的观点。
Python 有许多用于创建可视化的库,但主要用于数据分析(以及其他目的)的是 matplotlib。matplotlib 库刚开始学习时可能有点棘手,但幸运的是,pandas 为一些 matplotlib 功能提供了自己的封装,使我们能够创建许多不同类型的可视化,而不需要写一行 matplotlib 代码(或者至少,写得非常少)。对于那些不在 pandas 或 matplotlib 内置的更复杂图表类型,我们有 seaborn 库,我们将在下一章讨论。有了这三个工具,我们应该能够创建大多数(如果不是全部)我们所需要的可视化。动画和交互式图表超出了本书的范围,但你可以在进一步阅读部分找到更多信息。
在本章中,我们将覆盖以下主题:
-
matplotlib 简介
-
使用 pandas 绘图
-
pandas.plotting 模块
本章材料
本章的材料可以在 GitHub 上找到,链接:github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_05。我们将使用三个数据集,这些数据集都可以在 data/ 目录中找到。在 fb_stock_prices_2018.csv 文件中,我们有 Facebook 股票从 2018 年 1 月到 12 月的每日开盘价、最高价、最低价和收盘价,以及交易量。这些数据是通过 stock_analysis 包获取的,我们将在第七章,金融分析 - 比特币与股票市场中构建该包。股市在周末休市,因此我们只有交易日的数据。
earthquakes.csv文件包含从mag列收集的地震数据,包括震级(mag列)、震中测量的尺度(magType列)、发生的时间(time列)和地点(place列),以及标明地震发生所在州或国家的parsed_place列(我们在第二章,《使用 Pandas DataFrame》中添加了此列)。其他不必要的列已被删除。
在covid19_cases.csv文件中,我们有来自欧洲疾病预防控制中心(ECDC)提供的全球各国每日新增 COVID-19 确诊病例数数据集的导出文件,可以通过www.ecdc.europa.eu/en/publications-data/download-todays-data-geographic-distribution-covid-19-cases-worldwide获取。对于脚本化或自动化收集此数据,ECDC 通过opendata.ecdc.europa.eu/covid19/casedistribution/csv提供当天的 CSV 文件。我们将使用的快照数据收集日期为 2020 年 9 月 19 日,包含了 2019 年 12 月 31 日至 2020 年 9 月 18 日各国新增 COVID-19 病例数,并包含 2020 年 9 月 19 日的部分数据。在本章中,我们将查看 2020 年 1 月 18 日至 2020 年 9 月 18 日这 8 个月的数据。
在本章中,我们将通过三个笔记本进行学习。这些笔记本按使用顺序编号——每个笔记本对应本章的一个主要部分。我们将在1-introducing_matplotlib.ipynb笔记本中介绍 Python 绘图,首先介绍matplotlib。然后,我们将在2-plotting_with_pandas.ipynb笔记本中学习如何使用pandas创建可视化。最后,我们将在3-pandas_plotting_module.ipynb笔记本中探索pandas提供的一些额外绘图选项。在需要切换笔记本时,系统会提示您。
matplotlib 介绍
pandas和seaborn的绘图功能由matplotlib提供支持:这两个包为matplotlib中的底层功能提供了封装。因此,我们只需编写最少的代码,就能拥有丰富的可视化选项;然而,这也有代价:我们在创作时的灵活性有所降低。
我们可能会发现pandas或seaborn的实现无法完全满足我们的需求,实际上,使用它们创建图形后可能无法覆盖某些特定设置,这意味着我们将不得不使用matplotlib来完成一些工作。此外,许多对最终可视化效果的微调将通过matplotlib命令来处理,我们将在下一章中讨论这些内容。因此,了解matplotlib的工作原理将对我们大有裨益。
基础知识
matplotlib包相当大,因为它涵盖了很多功能。幸运的是,对于我们大多数的绘图任务,我们只需要pyplot模块,它提供了类似 MATLAB 的绘图框架。偶尔,我们会需要导入其他模块来处理其他任务,比如动画、改变样式或修改默认参数;我们将在下一章看到一些示例。
我们将只导入pyplot模块,而不是导入整个matplotlib包,使用点(.)符号;这样可以减少输入量,并且避免在内存中占用不需要的代码空间。请注意,pyplot通常会被别名为plt:
import matplotlib.pyplot as plt
在我们查看第一个图形之前,先来了解如何实际查看它们。Matplotlib 将通过绘图命令来创建我们的可视化;然而,直到我们请求查看它之前,我们是看不到可视化的。这样做是为了让我们在准备好最终版本之前,能够不断通过额外的代码调整可视化效果。除非我们保存对图形的引用,否则一旦它被显示出来,我们将需要重新创建它以进行更改。这是因为对上一个图形的引用已经被销毁,以释放内存资源。
Matplotlib 使用plt.show()函数来显示可视化。每创建一次可视化都必须调用它。当使用 Python Shell 时,它还会阻止其他代码的执行,直到窗口被关闭,因为它是一个阻塞函数。在 Jupyter Notebooks 中,我们只需使用一次%matplotlib inline,我们的可视化将在执行包含可视化代码的单元时自动显示。魔法命令(或简称magics)在 Jupyter Notebook 单元内作为常规代码执行。如果到目前为止你还不热衷于使用 Jupyter Notebooks,并希望现在设置它,你可以参考第一章,数据分析入门。
重要提示
%matplotlib inline 魔法命令将图表的静态图像嵌入到我们的笔记本中。另一个常见的选项是 %matplotlib notebook 魔法命令。它通过允许进行缩放和调整大小等操作,为图表提供了一定程度的交互性,但请注意,如果你使用 JupyterLab,还需要进行一些额外的设置,而且可能会出现一些困惑的错误,具体取决于笔记本中运行的代码。欲了解更多信息,请查阅此文章:medium.com/@1522933668924/using-matplotlib-in-jupyter-notebooks-comparing-methods-and-some-tips-python-c38e85b40ba1。
让我们在 1-introducing_matplotlib.ipynb 笔记本中创建我们的第一个图表,使用本章仓库中的 fb_stock_prices_2018.csv 文件中的 Facebook 股票价格数据。首先,我们需要导入 pyplot 和 pandas(在这个示例中,我们将使用 plt.show(),因此不需要在这里运行魔法命令):
>>> import matplotlib.pyplot as plt
>>> import pandas as pd
接下来,我们读取 CSV 文件,并将 date 列指定为索引,因为我们已经知道数据的格式,来自前面的章节:
>>> fb = pd.read_csv(
... 'data/fb_stock_prices_2018.csv',
... index_col='date',
... parse_dates=True
... )
为了理解 Facebook 股票随时间的变化,我们可以创建一条显示每日开盘价的折线图。对于这个任务,我们将使用plt.plot()函数,分别提供用于 x 轴和 y 轴的数据。接着,我们会调用plt.show()来显示图表:
>>> plt.plot(fb.index, fb.open)
>>> plt.show()
结果如下图所示:

图 5.1 – 使用 matplotlib 绘制的第一个图表
如果我们想要展示这个可视化图表,我们需要返回并添加轴标签、图表标题、图例(如果适用),并可能调整 y 轴的范围;这些内容将在下一章讨论如何格式化和自定义图表外观时进行讲解。至少,Pandas 和 seaborn 会为我们处理一些部分。
在本书的其余部分,我们将使用 %matplotlib inline 魔法命令(记住,这个命令仅在 Jupyter Notebook 中有效),所以在绘图代码后,我们将不再调用 plt.show()。以下代码与前面的代码块产生相同的输出:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import pandas as pd
>>> fb = pd.read_csv(
... 'data/fb_stock_prices_2018.csv',
... index_col='date',
... parse_dates=True
... )
>>> plt.plot(fb.index, fb.open)
重要提示
如果你在使用 Jupyter Notebook,请确保现在运行 %matplotlib inline 魔法命令。这将确保本章其余部分的绘图代码能够自动显示输出。
我们还可以使用plt.plot()函数生成散点图,只要在绘图时指定格式字符串作为第三个参数。格式字符串的形式为'[marker][linestyle][color]';例如,'--k'表示黑色虚线。由于我们不希望散点图中显示线条,所以省略了linestyle部分。我们可以使用'or'格式字符串来绘制红色圆形散点图;其中,o代表圆形,r代表红色。以下代码生成了一个高价与低价的散点图。请注意,我们可以将数据框传递给data参数,然后使用列名字符串,而不是将序列作为x和y传递:
>>> plt.plot('high', 'low', 'or', data=fb.head(20))
除了大幅波动的日子,我们期望这些点呈现出一条直线的形式,因为高价和低价不会相差太远。这在大多数情况下是正确的,但要小心自动生成的刻度——x 轴和 y 轴并未完全对齐:

图 5.2 – 使用 matplotlib 创建散点图
请注意,指定格式字符串时有一定的灵活性。例如,形如'[color][marker][linestyle]'的格式字符串是有效的,除非它具有歧义。下表展示了如何为各种绘图样式构建格式字符串的示例;完整的选项列表可以在文档的注释部分找到,地址为matplotlib.org/api/_as_gen/matplotlib.pyplot.plot.html:

图 5.3 – matplotlib 的样式快捷方式
格式字符串是一种方便的方式,可以一次性指定多个选项,幸运的是,正如我们在使用 pandas 绘图部分中将看到的,它同样适用于pandas中的plot()方法。然而,如果我们宁愿单独指定每个选项,也可以使用color、linestyle和marker参数;可以在文档中查看我们可以作为关键字参数传递给plt.plot()的值——pandas会将这些参数传递给matplotlib。
提示
作为为每个绘制变量定义样式的替代方案,可以尝试使用matplotlib团队提供的cycler,来指定matplotlib应当在哪些组合之间循环(matplotlib.org/gallery/color/color_cycler.html)。我们将在第七章中看到这个例子的应用,金融分析 – 比特币与股市。
要使用matplotlib创建直方图,我们需要使用hist()函数。让我们使用ml震级类型测量的earthquakes.csv文件中的地震震级数据,制作一个直方图:
>>> quakes = pd.read_csv('data/earthquakes.csv')
>>> plt.hist(quakes.query('magType == "ml"').mag)
由此产生的直方图可以帮助我们了解使用ml测量技术时,预期地震震级的范围:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_5.4_B16834.jpg)
图 5.4 – 使用 matplotlib 绘制直方图
正如我们可以猜到的那样,震级通常较小,分布看起来相对正常。然而,关于直方图,有一点需要注意——箱子大小很重要。在某些情况下,我们可以通过改变数据被分成的箱子数量,来改变直方图所表示的分布。例如,如果我们使用不同数量的箱子制作两个直方图,分布会看起来不同:
>>> x = quakes.query('magType == "ml"').mag
>>> fig, axes = plt.subplots(1, 2, figsize=(10, 3))
>>> for ax, bins in zip(axes, [7, 35]):
... ax.hist(x, bins=bins)
... ax.set_title(f'bins param: {bins}')
请注意,左侧子图中的分布似乎是单峰的,而右侧子图中的分布看起来是双峰的:

图 5.5 – 不同的箱子大小会大幅改变直方图
提示
关于选择箱子数量的一些常见经验法则,可以参考en.wikipedia.org/wiki/Histogram#Number_of_bins_and_width。然而,请注意,在某些情况下,蜜蜂群图可能比直方图更容易解释;这一点可以通过seaborn来实现,我们将在第六章中看到,使用 Seaborn 进行绘图及自定义技巧。
从这个例子中,还有几个额外的注意事项,我们将在下一节的图形组件中讨论:
-
我们可以制作子图。
-
在
pyplot中绘制函数也可以作为matplotlib对象的方法使用,例如Figure和Axes对象。
关于基本用法的最后一件事,我们会发现保存图像为图片非常方便——我们不应该仅限于在 Python 中展示图形。我们可以通过传递保存图像的路径,使用plt.savefig()函数来保存最后一张图像;例如,plt.savefig('my_plot.png')。请注意,如果在保存之前调用了plt.show(),那么文件将是空的,因为在调用plt.show()之后,最后一张图形的引用将会消失(matplotlib会关闭Figure对象以释放内存中的资源)。通过使用%matplotlib inline魔法命令,我们可以在同一个单元格中同时查看并保存图像。
图形组件
在之前使用plt.plot()的例子中,我们不需要创建Figure对象——matplotlib在后台为我们处理了它。然而,正如我们在创建图 5.5时看到的那样,任何超出基本图形的内容都需要稍微更多的工作,包括我们自己创建Figure对象。Figure类是matplotlib可视化的顶层对象。它包含Axes对象,而Axes对象本身又包含其他绘图对象,如线条和刻度。对于子图来说,Figure对象包含具有附加功能的Axes对象。
我们使用plt.figure()函数来创建Figure对象;这些对象在添加图表之前不会有任何Axes对象:
>>> fig = plt.figure()
<Figure size 432x288 with 0 Axes>
plt.subplots()函数创建一个带有Axes对象的Figure对象,用于指定排列方式的子图。如果我们请求plt.subplots()创建一行一列,它将返回一个包含一个Axes对象的Figure对象。这在编写根据输入生成子图布局的函数时非常有用,因为我们不需要为单个子图处理特殊情况。这里,我们将指定一行两列的排列方式;这将返回一个(Figure, Axes)元组,我们可以对其进行解包:
>>> fig, axes = plt.subplots(1, 2)
使用%matplotlib inline魔法命令时,我们会看到创建的图表:

图 5.6 – 创建子图
使用plt.subplots()的替代方法是,在运行plt.figure()之后,使用Figure对象上的add_axes()方法。add_axes()方法接受一个列表,形式为[left, bottom, width, height],表示子图在图形中占据的区域,它是图形维度的比例:
>>> fig = plt.figure(figsize=(3, 3))
>>> outside = fig.add_axes([0.1, 0.1, 0.9, 0.9])
>>> inside = fig.add_axes([0.7, 0.7, 0.25, 0.25])
这使得可以在图表内部创建子图:

图 5.7 – 使用 matplotlib 绘制带有嵌套图的图表
如果我们的目标是将所有图表分开但不一定是相同大小的,我们可以使用Figure对象上的add_gridspec()方法来创建子图的网格。然后,我们可以运行add_subplot(),传入网格中给定子图应该占据的区域:
>>> fig = plt.figure(figsize=(8, 8))
>>> gs = fig.add_gridspec(3, 3)
>>> top_left = fig.add_subplot(gs[0, 0])
>>> mid_left = fig.add_subplot(gs[1, 0])
>>> top_right = fig.add_subplot(gs[:2, 1:])
>>> bottom = fig.add_subplot(gs[2,:])
这将导致以下布局:

图 5.8 – 使用 matplotlib 构建自定义图表布局
在上一节中,我们讨论了如何使用plt.savefig()保存可视化,但我们也可以使用Figure对象上的savefig()方法:
>>> fig.savefig('empty.png')
这一点非常重要,因为使用plt.<func>()时,我们只能访问最后一个Figure对象;然而,如果我们保存对Figure对象的引用,我们就可以操作其中任何一个,不管它们是在什么时候创建的。此外,这也预示了本章中你会注意到的一个重要概念:Figure和Axes对象具有与其pyplot函数对应项相似或相同的方法名称。
尽管能够引用我们创建的所有Figure对象非常方便,但在完成工作后关闭它们是一个好习惯,这样我们就不会浪费任何资源。这可以通过plt.close()函数来实现。如果我们不传入任何参数,它将关闭最后一个Figure对象;但是,我们可以传入特定的Figure对象,以便仅关闭该对象,或者传入'all'来关闭我们打开的所有Figure对象:
>>> plt.close('all')
直接操作Figure和Axes对象非常重要,因为这可以让你对结果的可视化进行更精细的控制。下一章中会更加明显地体现这一点。
其他选项
我们的一些可视化图表看起来有些压缩。为了解决这个问题,我们可以在调用plt.figure()或plt.subplots()时传入figsize的值。我们用一个(宽度, 高度)的元组来指定尺寸,单位是英寸。我们将会看到的pandas的plot()方法也接受figsize参数,所以请记住这一点:
>>> fig = plt.figure(figsize=(10, 4))
<Figure size 720x288 with 0 Axes>
>>> fig, axes = plt.subplots(1, 2, figsize=(10, 4))
注意,这些子图比我们没有指定figsize时的图 5.6中的子图更接近正方形:

图 5.9 – 指定绘图大小
为我们的每个图表单独指定figsize参数还不算太麻烦。然而,如果我们发现每次都需要调整为相同的尺寸,有一个更好的替代方法。Matplotlib 将其默认设置保存在rcParams中,rcParams像一个字典一样运作,这意味着我们可以轻松覆盖会话中的某些设置,并在重启 Python 会话时恢复默认值。由于该字典中有许多选项(写作时超过 300 个),让我们随便选择一些,以了解可用的选项:
>>> import random
>>> import matplotlib as mpl
>>> rcparams_list = list(mpl.rcParams.keys())
>>> random.seed(20) # make this repeatable
>>> random.shuffle(rcparams_list)
>>> sorted(rcparams_list[:20])
['axes.axisbelow',
'axes.formatter.limits',
'boxplot.vertical',
'contour.corner_mask',
'date.autoformatter.month',
'legend.labelspacing',
'lines.dashed_pattern',
'lines.dotted_pattern',
'lines.scale_dashes',
'lines.solid_capstyle',
'lines.solid_joinstyle',
'mathtext.tt',
'patch.linewidth',
'pdf.fonttype',
'savefig.jpeg_quality',
'svg.fonttype',
'text.latex.preview',
'toolbar',
'ytick.labelright',
'ytick.minor.size']
如你所见,这里有许多选项可以调整。让我们检查一下当前figsize的默认值是什么:
>>> mpl.rcParams['figure.figsize']
[6.0, 4.0]
要为当前会话更改此设置,只需将其设置为新值:
>>> mpl.rcParams['figure.figsize'] = (300, 10)
>>> mpl.rcParams['figure.figsize']
[300.0, 10.0]
在继续之前,让我们使用mpl.rcdefaults()函数恢复默认设置。figsize的默认值实际上与我们之前的不同;这是因为%matplotlib inline在首次运行时会为一些与绘图相关的参数设置不同的值(github.com/ipython/ipykernel/blob/master/ipykernel/pylab/config.py#L42-L56):
>>> mpl.rcdefaults()
>>> mpl.rcParams['figure.figsize']
[6.8, 4.8]
请注意,如果我们知道其组(在本例中是figure)和参数名称(figsize),也可以使用plt.rc()函数更新特定的设置。正如我们之前所做的,我们可以使用plt.rcdefaults()来重置默认值:
# change `figsize` default to (20, 20)
>>> plt.rc('figure', figsize=(20, 20))
>>> plt.rcdefaults() # reset the default
提示
如果我们发现每次启动 Python 时都需要做相同的更改,那么我们应该考虑读取配置文件,而不是每次更新默认值。有关更多信息,请参考mpl.rc_file()函数。
使用 pandas 进行绘图
Series 和 DataFrame 对象都有一个 plot() 方法,可以让我们创建几种不同类型的图表,并控制一些格式方面的内容,如子图布局、图形大小、标题以及是否共享子图之间的坐标轴。这使得绘制数据变得更加方便,因为通过一次方法调用就可以完成大部分用于创建可展示图表的工作。实际上,pandas 在背后调用了多个 matplotlib 方法来生成图表。plot() 方法中一些常用的参数包括以下内容:

图 5.10 – 常用的 pandas 绘图参数
与我们在讨论 matplotlib 时看到的每种图表类型都有单独的函数不同,pandas 的 plot() 方法允许我们使用 kind 参数来指定我们想要的图表类型。图表类型的选择将决定哪些其他参数是必需的。我们可以使用 plot() 方法返回的 Axes 对象进一步修改图表。
让我们在 2-plotting_with_pandas.ipynb 笔记本中探索这个功能。在我们开始之前,我们需要处理本节的导入,并读取将要使用的数据(Facebook 股票价格、地震数据和 COVID-19 病例数据):
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import pandas as pd
>>> fb = pd.read_csv(
... 'data/fb_stock_prices_2018.csv',
... index_col='date',
... parse_dates=True
... )
>>> quakes = pd.read_csv('data/earthquakes.csv')
>>> covid = pd.read_csv('data/covid19_cases.csv').assign(
... date=lambda x: \
... pd.to_datetime(x.dateRep, format='%d/%m/%Y')
... ).set_index('date').replace(
... 'United_States_of_America', 'USA'
... ).sort_index()['2020-01-18':'2020-09-18']
在接下来的几节中,我们将讨论如何为特定的分析目标生成合适的可视化图表,比如展示随时间的变化或数据中变量之间的关系。请注意,在可能的情况下,图表已被样式化,使其可以在本书中以黑白形式进行解读。
随时间演变
在处理时间序列数据时(例如存储在 fb 变量中的 Facebook 股票数据),我们通常希望展示数据随时间的变化。为此,我们使用折线图,在某些情况下使用条形图(在计数与频率部分中介绍)。对于折线图,我们只需在 plot() 中提供 kind='line',并指定哪些列作为 x 和 y。请注意,我们实际上不需要为 x 提供列,因为 pandas 默认使用索引(这也使得生成 Series 对象的折线图成为可能)。此外,注意我们可以像在 matplotlib 图表中那样,为 style 参数提供格式字符串:
>>> fb.plot(
... kind='line', y='open', figsize=(10, 5), style='-b',
... legend=False, title='Evolution of Facebook Open Price'
... )
这给我们一个与 matplotlib 类似的图表;然而,在这次方法调用中,我们只为这个图表指定了图形的大小,关闭了图例,并为其设置了标题:

图 5.11 – 使用 pandas 绘制的第一个图表
与 matplotlib 一样,我们不必使用样式格式字符串——相反,我们可以将每个组件与其关联的关键字分开传递。例如,以下代码给出的结果与之前的结果相同:
fb.plot(
kind='line', y='open', figsize=(10, 5),
color='blue', linestyle='solid',
legend=False, title='Evolution of Facebook Open Price'
)
我们在使用 plot() 方法时并不局限于一次绘制一条线;我们也可以传递一个列列表来绘制,并单独设置样式。请注意,实际上我们不需要指定 kind='line',因为这是默认值:
>>> fb.first('1W').plot(
... y=['open', 'high', 'low', 'close'],
... style=['o-b', '--r', ':k', '.-g'],
... title='Facebook OHLC Prices during '
... '1st Week of Trading 2018'
... ).autoscale() # add space between data and axes
这将生成以下图形,其中每条线的样式不同:

图 5.12 – 绘制多个列
此外,我们可以轻松地让 pandas 在同一调用中绘制所有列。x 和 y 参数可以接受单个列名或它们的列表;如果我们不提供任何内容,pandas 将使用所有列。请注意,当 kind='line' 时,列必须作为 y 参数传递;然而,其他绘图类型也支持将列列表传递给 x。在这种情况下,要求子图而不是将所有线条绘制在同一图中可能会更有帮助。让我们将 Facebook 数据中的所有列作为折线图进行可视化:
>>> fb.plot(
... kind='line', subplots=True, layout=(3, 2),
... figsize=(15, 10), title='Facebook Stock 2018'
... )
使用 layout 参数,我们告诉 pandas 如何排列我们的子图(三行两列):

图 5.13 – 使用 pandas 创建子图
请注意,子图自动共享 x 轴,因为它们共享一个索引。y 轴没有共享,因为 volume 时间序列的尺度不同。我们可以通过在某些绘图类型中将 sharex 或 sharey 参数与布尔值一起传递给 plot() 来改变这种行为。默认情况下会渲染图例,因此对于每个子图,我们在图例中有一个单独的项目,表示其包含的数据。在这种情况下,我们没有通过 title 参数提供子图标题列表,因为图例已起到了这个作用;然而,我们为整个图形传递了一个单一字符串作为标题。总结一下,当处理子图时,我们在标题方面有两种选择:
-
传递一个字符串作为整个图形的标题。
-
传递一个字符串列表,用作每个子图的标题。
有时,我们希望制作子图,每个子图包含一些变量供比较。可以通过首先使用 plt.subplots() 创建子图,然后将 Axes 对象提供给 ax 参数来实现这一点。为了说明这一点,让我们来看一下中国、西班牙、意大利、美国、巴西和印度的 COVID-19 每日新增病例数据。这是长格式数据,因此我们必须首先将其透视,使日期(我们在读取 CSV 文件时设置为索引)成为透视表的索引,国家(countriesAndTerritories)成为列。由于这些值波动较大,我们将使用 第四章 中介绍的 rolling() 方法绘制新增病例的 7 天移动平均值,聚合 Pandas 数据框:
>>> new_cases_rolling_average = covid.pivot_table(
... index=covid.index,
... columns='countriesAndTerritories',
... values='cases'
... ).rolling(7).mean()
我们不会为每个国家创建单独的图表(这会使比较变得更困难),也不会将它们全部绘制在一起(这样会使较小的值难以看到),而是将病例数量相似的国家绘制在同一个子图中。我们还将使用不同的线条样式,以便在黑白图中区分它们:
>>> fig, axes = plt.subplots(1, 3, figsize=(15, 5))
>>> new_cases_rolling_average[['China']]\
... .plot(ax=axes[0], style='-.c')
>>> new_cases_rolling_average[['Italy', 'Spain']].plot(
... ax=axes[1], style=['-', '--'],
... title='7-day rolling average of new '
... 'COVID-19 cases\n(source: ECDC)'
... )
>>> new_cases_rolling_average[['Brazil', 'India', 'USA']]\
... .plot(ax=axes[2], style=['--', ':', '-'])
通过直接使用matplotlib生成每个子图的Axes对象,我们获得了更多的布局灵活性:

图 5.14 – 控制每个子图中绘制的数据
在前面的图中,我们能够比较病例数量相似的国家,但由于比例问题,我们无法将所有国家都绘制在同一个子图中。解决这个问题的一种方法是使用面积图,这样我们就能在可视化整体 7 天滚动平均的新增 COVID-19 病例的同时,看到每个国家对总数的贡献。为了提高可读性,我们将意大利和西班牙归为一组,并为美国、巴西和印度以外的国家创建另一个类别:
>>> cols = [
... col for col in new_cases_rolling_average.columns
... if col not in [
... 'USA', 'Brazil', 'India', 'Italy & Spain'
... ]
... ]
>>> new_cases_rolling_average.assign(
... **{'Italy & Spain': lambda x: x.Italy + x.Spain}
... ).sort_index(axis=1).assign(
... Other=lambda x: x[cols].sum(axis=1)
... ).drop(columns=cols).plot(
... kind='area', figsize=(15, 5),
... title='7-day rolling average of new '
... 'COVID-19 cases\n(source: ECDC)'
... )
对于那些以黑白查看结果图的人,巴西是底层,印度在其上方,以此类推。图表区域的合计高度表示总体值,而给定阴影区域的高度表示该国的值。这表明,超过一半的每日新增病例集中在巴西、印度、意大利、西班牙和美国:

图 5.15 – 创建面积图
另一种可视化随时间演变的方法是查看随时间累积的和。我们将绘制中国、西班牙、意大利、美国、巴西和印度的 COVID-19 累计病例数,使用ax参数再次创建子图。为了计算随时间的累计和,我们按位置(countriesAndTerritories)和日期分组,日期是我们的索引,因此我们使用pd.Grouper();这次,我们将使用groupby()和unstack()将数据透视为宽格式,用于绘图:
>>> fig, axes = plt.subplots(1, 3, figsize=(15, 3))
>>> cumulative_covid_cases = covid.groupby(
... ['countriesAndTerritories', pd.Grouper(freq='1D')]
... ).cases.sum().unstack(0).apply('cumsum')
>>> cumulative_covid_cases[['China']]\
... .plot(ax=axes[0], style='-.c')
>>> cumulative_covid_cases[['Italy', 'Spain']].plot(
... ax=axes[1], style=['-', '--'],
... title='Cumulative COVID-19 Cases\n(source: ECDC)'
... )
>>> cumulative_covid_cases[['Brazil', 'India', 'USA']]\
... .plot(ax=axes[2], style=['--', ':', '-'])
观察累计 COVID-19 病例数据表明,尽管中国和意大利似乎已经控制了 COVID-19 病例,但西班牙、美国、巴西和印度仍在挣扎:

图 5.16 – 随时间绘制累计和
重要说明
我们在这一部分多次使用了虚线和点线,以确保生成的图表可以在黑白模式下进行解读;然而,请注意,当以彩色方式展示这些图表时,接受默认的颜色和线条样式就足够了。通常,不同的线条样式表示数据类型的差异——例如,我们可以使用实线来表示时间演变,使用虚线来表示滚动平均值。
变量之间的关系
当我们想要可视化变量之间的关系时,我们通常从散点图开始,散点图展示了不同 x 变量值下的 y 变量值。这使我们非常容易发现相关性和可能的非线性关系。在上一章,当我们查看 Facebook 股票数据时,我们看到高交易量的天数似乎与股价的大幅下跌相关。我们可以使用散点图来可视化这种关系:
>>> fb.assign(
... max_abs_change=fb.high - fb.low
... ).plot(
... kind='scatter', x='volume', y='max_abs_change',
... title='Facebook Daily High - Low vs. Volume Traded'
... )
似乎存在某种关系,但它似乎不是线性的:

图 5.17 – 使用 pandas 绘制散点图
让我们试着取交易量的对数。为此,我们有几个选择:
-
创建一个新的列,将交易量取对数,使用
np.log()。 -
通过将
logx=True传递给plot()方法或调用plt.xscale('log')来对 x 轴使用对数刻度。
在这种情况下,最有意义的是仅仅改变数据的显示方式,因为我们并不会使用新的列:
>>> fb.assign(
... max_abs_change=fb.high - fb.low
... ).plot(
... kind='scatter', x='volume', y='max_abs_change',
... title='Facebook Daily High - '
... 'Low vs. log(Volume Traded)',
... logx=True
... )
修改 x 轴刻度后,我们得到如下散点图:

图 5.18 – 对 x 轴应用对数刻度
提示
pandas 中的 plot() 方法有三个参数用于对数刻度:logx/logy 用于单轴调整,loglog 用于同时设置两个轴为对数刻度。
散点图的一个问题是,很难分辨给定区域内点的集中程度,因为它们只是简单地叠加在一起。我们可以使用 alpha 参数来控制点的透明度;这个参数的值范围从 0 到 1,其中 0 表示完全透明,1 表示完全不透明。默认情况下,它们是完全不透明的(值为 1);然而,如果我们使它们更透明,我们应该能够看到一些重叠:
>>> fb.assign(
... max_abs_change=fb.high - fb.low
... ).plot(
... kind='scatter', x='volume', y='max_abs_change',
... title='Facebook Daily High - '
... 'Low vs. log(Volume Traded)',
... logx=True, alpha=0.25
... )
现在我们可以开始看出图表左下区域的点密度,但仍然相对较难:

图 5.19 – 修改透明度以可视化重叠
幸运的是,我们还有另一种可用的图表类型:hexbin。六边形图通过将图表划分为一个六边形网格,并根据每个六边形内的点密度来进行着色,形成一个二维直方图。让我们将数据以六边形图的形式展示:
>>> fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... ).plot(
... kind='hexbin',
... x='log_volume',
... y='max_abs_change',
... title='Facebook Daily High - '
... 'Low vs. log(Volume Traded)',
... colormap='gray_r',
... gridsize=20,
... sharex=False # bug fix to keep the x-axis label
... )
侧边的颜色条表示颜色与该 bin 中点数之间的关系。我们选择的色图(gray_r)使得高密度的 bins 颜色较深(趋向黑色),低密度的 bins 颜色较浅(趋向白色)。通过传入 gridsize=20,我们指定在 x 轴上使用 20 个六边形,并让 pandas 确定在 y 轴上使用多少个,以使它们大致呈规则形状;不过,我们也可以传入一个元组来选择两个方向上的数量。增大 gridsize 的值会使得 bins 更难以辨识,而减小则会导致 bins 更满,占用更多的空间——我们需要找到一个平衡点:

图 5.20 – 使用 pandas 绘制 hexbins
最后,如果我们只想可视化变量之间的相关性,我们可以绘制一个相关矩阵。使用 pandas 和 matplotlib 中的 plt.matshow() 或 plt.imshow() 函数。由于需要在同一单元格中运行大量代码,我们将在代码块后立即讨论每个部分的目的:
>>> fig, ax = plt.subplots(figsize=(20, 10))
# calculate the correlation matrix
>>> fb_corr = fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... ).corr()
# create the heatmap and colorbar
>>> im = ax.matshow(fb_corr, cmap='seismic')
>>> im.set_clim(-1, 1)
>>> fig.colorbar(im)
# label the ticks with the column names
>>> labels = [col.lower() for col in fb_corr.columns]
>>> ax.set_xticks(ax.get_xticks()[1:-1])
>>> ax.set_xtickabels(labels, rotation=45)
>>> ax.set_yticks(ax.get_yticks()[1:-1])
>>> ax.set_yticklabels(labels)
# include the value of the correlation coefficient in the boxes
>>> for (i, j), coef in np.ndenumerate(fb_corr):
... ax.text(
... i, j, fr'$\rho$ = {coef:.2f}',
... ha='center', va='center',
... color='white', fontsize=14
... )
使用 seismic 色图,然后将颜色刻度的限制设置为[-1, 1],因为相关系数的范围就是这些:
im = ax.matshow(fb_corr, cmap='seismic')
im.set_clim(-1, 1) # set the bounds of the color scale
fig.colorbar(im) # add the colorbar to the figure
为了能够读取生成的热图,我们需要用数据中变量的名称标记行和列:
labels = [col.lower() for col in fb_corr.columns]
ax.set_xticks(ax.get_xticks()[1:-1]) # to handle matplotlib bug
ax.set_xticklabels(labels, rotation=45)
ax.set_yticks(ax.get_yticks()[1:-1]) # to handle matplotlib bug
ax.set_yticklabels(labels)
虽然颜色刻度可以帮助我们区分弱相关和强相关,但通常也很有帮助的是在热图上注释实际的相关系数。这可以通过在包含图形的 Axes 对象上使用 text() 方法来实现。对于这个图形,我们放置了白色、居中对齐的文本,表示每对变量组合的皮尔逊相关系数的值:
# iterate over the matrix
for (i, j), coef in np.ndenumerate(fb_corr):
ax.text(
i, j,
fr'$\rho$ = {coef:.2f}', # raw (r), format (f) string
ha='center', va='center',
color='white', fontsize=14
)
这将生成一个带注释的热图,展示 Facebook 数据集中的变量之间的相关性:

图 5.21 – 将相关性可视化为热图
在图 5.21中,我们可以清楚地看到 OHLC 时间序列之间存在较强的正相关性,以及交易量和最大绝对变化值之间的正相关性。然而,这些组之间存在较弱的负相关性。此外,我们还可以看到,对交易量取对数确实增加了与max_abs_change的相关系数,从 0.64 增加到 0.73。在下一章讨论 seaborn 时,我们将学习一种更简单的生成热图的方法,并更详细地讲解注释。
分布
通常,我们希望可视化数据的分布,以了解数据所呈现的值。根据数据类型的不同,我们可能会选择使用直方图、核密度估计(KDEs)、箱型图或经验累积分布函数(ECDFs)。在处理离散数据时,直方图是一个很好的起点。让我们来看一下 Facebook 股票的每日交易量直方图:
>>> fb.volume.plot(
... kind='hist',
... title='Histogram of Daily Volume Traded '
... 'in Facebook Stock'
... )
>>> plt.xlabel('Volume traded') # label x-axis (see ch 6)
这是一个很好的实际数据示例,数据显然不是正态分布的。交易量偏右,右侧有一个长尾。回想一下在第四章,聚合 Pandas DataFrames 中,我们讨论了分箱并查看了低、中、高交易量时,几乎所有数据都落在低交易量区间,这与我们在此直方图中看到的情况一致:

图 5.22 – 使用 pandas 创建直方图
提示
与 matplotlib 中的 plt.hist() 函数类似,我们可以通过 bins 参数为箱数提供自定义值。但是,我们必须小心,确保不会误导数据分布。
我们还可以在同一图表上绘制多个直方图,以比较不同的分布,方法是使用 ax 参数为每个图表指定相同的 Axes 对象。在这种情况下,我们必须使用 alpha 参数以便看到任何重叠。由于我们有许多不同的地震测量方法(magType 列),我们可能会想要比较它们所产生的不同震级范围:
>>> fig, axes = plt.subplots(figsize=(8, 5))
>>> for magtype in quakes.magType.unique():
... data = quakes.query(f'magType == "{magtype}"').mag
... if not data.empty:
... data.plot(
... kind='hist',
... ax=axes,
... alpha=0.4,
... label=magtype,
... legend=True,
... title='Comparing histograms '
... 'of earthquake magnitude by magType'
... )
>>> plt.xlabel('magnitude') # label x-axis (discussed in ch 6)
这表明 ml 是最常见的 magType,其次是 md,它们的震级范围相似;然而,第三常见的 mb 震级更高:

图 5.23 – 使用 pandas 绘制重叠直方图
在处理连续数据(如股票价格)时,我们可以使用 KDE。让我们看看 Facebook 股票的日最高价的 KDE。请注意,我们可以传递 kind='kde' 或 kind='density':
>>> fb.high.plot(
... kind='kde',
... title='KDE of Daily High Price for Facebook Stock'
... )
>>> plt.xlabel('Price ($)') # label x-axis (discussed in ch 6)
得到的密度曲线有一些左偏:

图 5.24 – 使用 pandas 可视化 KDE
我们可能还想将 KDE 可视化叠加在直方图上。Pandas 允许我们传递希望绘制的 Axes 对象,并且在创建可视化后会返回该对象,这使得操作变得非常简单:
>>> ax = fb.high.plot(kind='hist', density=True, alpha=0.5)
>>> fb.high.plot(
... ax=ax, kind='kde', color='blue',
... title='Distribution of Facebook Stock\'s '
... 'Daily High Price in 2018'
... )
>>> plt.xlabel('Price ($)') # label x-axis (discussed in ch 6)
请注意,当我们生成直方图时,必须传入density=True,以确保直方图和 KDE 的y轴处于相同的尺度。否则,KDE 会变得太小,无法看到。然后,直方图会以密度作为y轴进行绘制,这样我们就可以更好地理解 KDE 是如何形成其形状的。我们还增加了直方图的透明度,以便能够看到上面叠加的 KDE 线。请注意,如果我们移除 KDE 调用中的color='blue'部分,我们就不需要更改直方图调用中的alpha值,因为 KDE 和直方图会使用不同的颜色;我们将它们都绘制为蓝色,因为它们表示的是相同的数据:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_5.25_B16834.jpg)
图 5.25 – 使用 pandas 结合 KDE 和直方图
KDE 展示了一个估计的概率密度函数(PDF),它告诉我们概率是如何在数据值上分布的。然而,在某些情况下,我们更关心的是获取某个值以下(或以上)的概率,我们可以通过累积分布函数(CDF)来查看。
重要提示
使用 CDF 时,x变量的值沿着x轴分布,而获取最多某个x值的累积概率沿着y轴分布。这个累积概率介于 0 和 1 之间,并写作P(X ≤ x),其中小写(x)是用于比较的值,大写(X)是随机变量X。更多信息请参考www.itl.nist.gov/div898/handbook/eda/section3/eda362.htm。
使用statsmodels包,我们可以估算 CDF 并得到ml震级类型:
>>> from statsmodels.distributions.empirical_distribution \
... import ECDF
>>> ecdf = ECDF(quakes.query('magType == "ml"').mag)
>>> plt.plot(ecdf.x, ecdf.y)
# axis labels (we will cover this in chapter 6)
>>> plt.xlabel('mag') # add x-axis label
>>> plt.ylabel('cumulative probability') # add y-axis label
# add title (we will cover this in chapter 6)
>>> plt.title('ECDF of earthquake magnitude with magType ml')
这会产生以下 ECDF:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_5.26_B16834.jpg)
图 5.26 – 可视化 ECDF
这在我们进行 EDA 时非常有用,可以帮助我们更好地理解数据。然而,我们必须小心如何解释这些结果以及如何向他人解释,特别是如果我们选择这么做的话。在这里,我们可以看到,如果该分布确实代表了总体,使用该测量技术测得的地震ml震级小于或等于3的概率为98%:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_5.27_B16834.jpg)
图 5.27 – 解释 ECDF
最后,我们可以使用箱线图来可视化潜在的离群值和通过四分位数描述的分布。举个例子,我们来可视化 Facebook 股票在整个数据集中的 OHLC 价格:
>>> fb.iloc[:,:4].plot(
... kind='box',
... title='Facebook OHLC Prices Box Plot'
... )
>>> plt.ylabel('price ($)') # label x-axis (discussed in ch 6)
请注意,我们确实失去了一些在其他图中得到的信息。我们不再能够了解分布中点的密度;通过箱线图,我们更关注的是五数概括:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_5.28_B16834.jpg)
图 5.28 – 使用 pandas 创建箱线图
小贴士
我们可以通过传递notch=True来创建带缺口的箱线图。缺口标记了中位数的 95%置信区间,这在比较组之间的差异时很有帮助。笔记本中有一个示例。
我们还可以在调用groupby()之后调用boxplot()方法。让我们来看一下在根据交易量计算时,箱线图如何变化:
>>> fb.assign(
... volume_bin=\
... pd.cut(fb.volume, 3, labels=['low', 'med', 'high'])
... ).groupby('volume_bin').boxplot(
... column=['open', 'high', 'low', 'close'],
... layout=(1, 3), figsize=(12, 3)
... )
>>> plt.suptitle(
... 'Facebook OHLC Box Plots by Volume Traded', y=1.1
... )
记得从第四章,“汇总 Pandas 数据框”中,我们知道大多数天数都落在低交易量范围内,因此我们可以预期在这里会看到更多的波动,因为股票数据随时间变化的情况:

图 5.29 – 使用 pandas 绘制每组箱线图
我们还可以使用这种方法查看根据使用的magType,地震震级的分布,并与 USGS 网站上预期的范围进行比较(www.usgs.gov/natural-hazards/earthquake-hazards/science/magnitude-types):
>>> quakes[['mag', 'magType']]\
... .groupby('magType')\
... .boxplot(figsize=(15, 8), subplots=False)
# formatting (covered in chapter 6)
>>> plt.title('Earthquake Magnitude Box Plots by magType')
>>> plt.ylabel('magnitude')
美国地质调查局(USGS)网站提到了一些情况下无法使用某些测量技术,以及每种测量技术适用的幅度范围(当超出该范围时,使用其他技术)。在这里,我们可以看到这些技术一起覆盖了广泛的幅度范围,但没有一种技术能够覆盖所有的情况:

图 5.30 – 单一图表中每组的箱线图
重要提示
虽然直方图、KDE、ECDF 和箱线图都是展示数据分布的方式,但我们看到每种可视化方法展示了数据的不同方面。在得出结论之前,从多个角度可视化数据是很重要的。
计数和频率
在处理分类数据时,我们可以创建条形图来显示数据的计数或特定值的频率。条形图可以是垂直的(kind='bar')或水平的(kind='barh')。当我们有许多类别或类别之间有某种顺序时(例如,随着时间的演变),垂直条形图非常有用。水平条形图便于比较每个类别的大小,并为长类别名称提供足够的空间(无需旋转它们)。
我们可以使用水平条形图来查看quakes数据框中哪些地方发生了最多的地震。首先,我们对parsed_place系列调用value_counts()方法,提取出发生地震次数最多的前 15 个地方。接下来,我们反转顺序,以便在条形图中将最小的值排在上面,这样我们将得到按地震次数排序的条形图。注意,我们也可以将反转排序作为value_counts()的参数,但由于我们仍然需要提取前 15 名,因此我们将两者结合在一个iloc调用中:
>>> quakes.parsed_place.value_counts().iloc[14::-1,].plot(
... kind='barh', figsize=(10, 5),
... title='Top 15 Places for Earthquakes '
... '(September 18, 2018 - October 13, 2018)'
... )
>>> plt.xlabel('earthquakes') # label x-axis (see ch 6)
请记住,切片表示法是[start:stop:step],在本例中,由于步长是负数,顺序被反转;我们从索引14(第 15 个条目)开始,每次都朝着索引0靠近。通过传递kind='barh',我们可以得到水平条形图,显示出该数据集中大多数地震发生在阿拉斯加。也许看到在如此短的时间内发生的地震数量令人惊讶,但许多地震的震级很小,以至于人们根本感觉不到:

图 5.31 – 使用 pandas 绘制水平条形图
我们的数据还包含了地震是否伴随海啸的信息。我们可以使用groupby()来绘制一个条形图,展示在我们数据中时间段内遭遇海啸的前 10 个地方:
>>> quakes.groupby(
... 'parsed_place'
... ).tsunami.sum().sort_values().iloc[-10:,].plot(
... kind='barh', figsize=(10, 5),
... title='Top 10 Places for Tsunamis '
... '(September 18, 2018 - October 13, 2018)'
... )
>>> plt.xlabel('tsunamis') # label x-axis (discussed in ch 6)
请注意,这次我们使用了iloc[-10:,],它从第 10 大值开始(因为sort_values()默认按升序排序),一直到最大值,从而得到前 10 个数据。这里我们可以看到,在这段时间内,印尼发生的海啸数量远远超过其他地区:

图 5.32 – 按组计算结果的绘制
看到这样的数据后,我们可能会想进一步探究印尼每天发生的海啸数量。我们可以通过线图或使用kind='bar'的垂直条形图来可视化这种随时间变化的情况。这里我们将使用条形图,以避免插值点:
>>> indonesia_quakes = quakes.query(
... 'parsed_place == "Indonesia"'
... ).assign(
... time=lambda x: pd.to_datetime(x.time, unit='ms'),
... earthquake=1
... ).set_index('time').resample('1D').sum()
# format the datetimes in the index for the x-axis
>>> indonesia_quakes.index = \
... indonesia_quakes.index.strftime('%b\n%d')
>>> indonesia_quakes.plot(
... y=['earthquake', 'tsunami'], kind='bar', rot=0,
... figsize=(15, 3), label=['earthquakes', 'tsunamis'],
... title='Earthquakes and Tsunamis in Indonesia '
... '(September 18, 2018 - October 13, 2018)'
... )
# label the axes (discussed in chapter 6)
>>> plt.xlabel('date')
>>> plt.ylabel('count')
2018 年 9 月 28 日,我们可以看到印尼的地震和海啸出现了激增;在这一天,发生了一次 7.5 级地震,引发了毁灭性的海啸:

图 5.33 – 随时间变化的计数比较
我们还可以通过使用groupby()和unstack()从单列的值中创建分组条形图。这使得我们能够为列中的每个独特值生成条形图。让我们用这种方法查看海啸与地震同时发生的频率,作为一个百分比。我们可以使用apply()方法,如我们在第四章《聚合 Pandas DataFrames》中所学,沿着axis=1(逐行应用)。为了说明,我们将查看海啸伴随地震发生比例最高的七个地方:
>>> quakes.groupby(['parsed_place', 'tsunami']).mag.count()\
... .unstack().apply(lambda x: x / x.sum(), axis=1)\
... .rename(columns={0: 'no', 1: 'yes'})\
... .sort_values('yes', ascending=False)[7::-1]\
... .plot.barh(
... title='Frequency of a tsunami accompanying '
... 'an earthquake'
... )
# move legend to the right of the plot; label axes
>>> plt.legend(title='tsunami?', bbox_to_anchor=(1, 0.65))
>>> plt.xlabel('percentage of earthquakes')
>>> plt.ylabel('')
圣诞岛在这段时间内发生了 1 次地震,但伴随了海啸。相比之下,巴布亚新几内亚约 40%的地震都伴随了海啸:

图 5.34 – 按组绘制的条形图
提示
在保存前面的图表时,较长的类别名称可能会被截断;如果是这种情况,尝试在保存之前运行plt.tight_layout()。
现在,让我们使用垂直条形图来查看哪些地震震级测量方法最为常见,方法是使用kind='bar':
>>> quakes.magType.value_counts().plot(
... kind='bar', rot=0,
... title='Earthquakes Recorded per magType'
... )
# label the axes (discussed in ch 6)
>>> plt.xlabel('magType')
>>> plt.ylabel('earthquakes')
看起来ml是测量地震震级时最常用的方法。这是有道理的,因为它是由理查德·里希特和古滕贝格在 1935 年定义的原始震级关系,用于测量局部地震,这一点可以参考我们使用的数据集中关于magType字段的 USGS 页面(www.usgs.gov/natural-hazards/earthquake-hazards/science/magnitude-types):

图 5.35 – 比较类别计数
假设我们想要查看某一震级的地震数量,并按magType区分它们。这样一个图表可以在一个图中展示多个信息:
-
哪些震级在
magType中最常出现。 -
每种
magType对应的震级的相对范围。 -
magType的最常见值。
为此,我们可以制作一个堆叠条形图。首先,我们将所有震级四舍五入到最接近的整数。这意味着所有地震都会标记为小数点前的震级部分(例如,5.5 被标记为 5,就像 5.7、5.2 和 5.0 一样)。接下来,我们需要创建一个透视表,将震级放入索引,将震级类型放入列中;我们将计算各个值对应的地震数量:
>>> pivot = quakes.assign(
... mag_bin=lambda x: np.floor(x.mag)
... ).pivot_table(
... index='mag_bin',
... columns='magType',
... values='mag',
... aggfunc='count'
... )
一旦我们有了透视表,就可以通过在绘制时传入stacked=True来创建堆叠条形图:
>>> pivot.plot.bar(
... stacked=True,
... rot=0,
... title='Earthquakes by integer magnitude and magType'
... )
>>> plt.ylabel('earthquakes') # label axes (discussed in ch 6)
这将产生以下图表,显示大多数地震使用ml震级类型,并且震级低于 4:

图 5.36 – 堆叠条形图
其他条形图相比于ml显得较小,这使得我们很难看清哪些震级类型将较高的震级赋给了地震。为了解决这个问题,我们可以制作一个标准化堆叠条形图。我们将不再显示每种震级和magType组合的地震数量,而是显示每种震级下,使用每种magType的地震百分比:
>>> normalized_pivot = \
... pivot.fillna(0).apply(lambda x: x / x.sum(), axis=1)
...
>>> ax = normalized_pivot.plot.bar(
... stacked=True, rot=0, figsize=(10, 5),
... title='Percentage of earthquakes by integer magnitude '
... 'for each magType'
... )
>>> ax.legend(bbox_to_anchor=(1, 0.8)) # move legend
>>> plt.ylabel('percentage') # label axes (discussed in ch 6)
现在,我们可以轻松看到mww产生较高的震级,而ml似乎分布在震级范围的较低端:

图 5.37 – 标准化堆叠条形图
请注意,我们也可以使用groupby()方法和unstack()方法来实现这个策略。让我们重新查看伴随地震的海啸频率图,但这次我们不使用分组条形图,而是将其堆叠显示:
>>> quakes.groupby(['parsed_place', 'tsunami']).mag.count()\
... .unstack().apply(lambda x: x / x.sum(), axis=1)\
... .rename(columns={0: 'no', 1: 'yes'})\
... .sort_values('yes', ascending=False)[7::-1]\
... .plot.barh(
... title='Frequency of a tsunami accompanying '
... 'an earthquake',
... stacked=True
... )
# move legend to the right of the plot
>>> plt.legend(title='tsunami?', bbox_to_anchor=(1, 0.65))
# label the axes (discussed in chapter 6)
>>> plt.xlabel('percentage of earthquakes')
>>> plt.ylabel('')
这个堆叠条形图使得我们很容易比较不同地方的海啸频率:

图 5.38 – 按组归类的标准化堆叠条形图
类别数据限制了我们可以使用的图表类型,但也有一些替代方案可以替代条形图。在下一章的利用 seaborn 进行高级绘图部分,我们将详细介绍它们;现在,让我们先来看看 pandas.plotting 模块。
pandas.plotting 模块
在使用 pandas 绘图部分,我们讲解了 pandas 提供的标准图表类型。 然而,pandas 也有一个模块(名为 plotting),其中包含一些可以在数据上使用的特殊图表。请注意,由于它们的构成和返回方式,这些图表的自定义选项可能更为有限。
在这一部分,我们将在 3-pandas_plotting_module.ipynb 笔记本中进行操作。像往常一样,我们将从导入库和读取数据开始;这里只使用 Facebook 数据:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import pandas as pd
>>> fb = pd.read_csv(
... 'data/fb_stock_prices_2018.csv',
... index_col='date',
... parse_dates=True
... )
现在,让我们来了解一下 pandas.plotting 模块中提供的一些图表,学习如何将这些可视化结果应用于我们的探索性数据分析(EDA)。
散点矩阵
在本章的前面,我们讨论了如何使用散点图来展示变量之间的关系。通常,我们希望查看数据中每一对变量的散点图,这可能会很繁琐。pandas.plotting 模块包含了 scatter_matrix() 函数,这使得这个过程变得更加容易。我们可以用它来查看 Facebook 股票价格数据中各列组合的散点图:
>>> from pandas.plotting import scatter_matrix
>>> scatter_matrix(fb, figsize=(10, 10))
这样会得到以下的绘图矩阵,这在机器学习中通常用于查看哪些变量在构建模型时可能有用。我们可以很容易地看到,开盘价、最高价、最低价和收盘价之间有强烈的正相关关系:

图 5.39 – Pandas 散点矩阵
默认情况下,在对角线上,列与自己配对时,我们会得到它的直方图。或者,我们可以通过传入 diagonal='kde' 来请求 KDE:
>>> scatter_matrix(fb, figsize=(10, 10), diagonal='kde')
这样得到的散点矩阵在对角线处是 KDE,而不是直方图:

图 5.40 – 带有 KDE 的散点矩阵
尽管散点矩阵可以方便地检查变量之间的关系,但有时我们更关心自相关性,即时间序列与其滞后版本之间的相关性。可视化自相关性的一种方法是使用滞后图。
滞后图
我们可以使用 data[:-1](去掉最后一项)和 data[1:](从第二项到最后一项)。
如果我们的数据是随机的,这个图表将没有任何模式。让我们用 NumPy 生成一些随机数据来测试这一点:
>>> from pandas.plotting import lag_plot
>>> np.random.seed(0) # make this repeatable
>>> lag_plot(pd.Series(np.random.random(size=200)))
随机数据点并未显示出任何模式,只有随机噪声:

图 5.41 – 随机噪声的滞后图
对于我们的股票数据,我们知道某一天的价格是由前一天的情况决定的;因此,我们预计在滞后图中会看到一种模式。让我们使用 Facebook 股票的收盘价来测试我们的直觉是否正确:
>>> lag_plot(fb.close)
正如预期的那样,这导致了一个线性模式:

图 5.42 – Facebook 股票价格的滞后图
我们还可以指定用于滞后的周期数。默认滞后为 1,但我们可以通过 lag 参数更改它。例如,我们可以使用 lag=5 比较每个值与前一周的值(记住,股票数据仅包含工作日的数据,因为市场在周末关闭):
>>> lag_plot(fb.close, lag=5)
这仍然产生了强相关性,但与图 5.42相比,它看起来明显较弱:

图 5.43 – 自定义滞后图的周期数
虽然滞后图帮助我们可视化自相关,但它们并不能告诉我们数据包含多少个自相关周期。为此,我们可以使用自相关图。
自相关图
Pandas 提供了一种额外的方法来查找我们数据中的自相关,使用 autocorrelation_plot() 函数,它通过滞后的数量来显示自相关。随机数据的自相关值接近零。
正如我们讨论滞后图时所做的那样,让我们首先检查一下使用 NumPy 生成的随机数据是什么样子的:
>>> from pandas.plotting import autocorrelation_plot
>>> np.random.seed(0) # make this repeatable
>>> autocorrelation_plot(pd.Series(np.random.random(size=200)))
确实,自相关接近零,且该线位于置信带内(99% 是虚线;95% 是实线):

图 5.44 – 随机数据的自相关图
让我们探索一下 Facebook 股票收盘价的自相关图,因为滞后图显示了几个自相关周期:
>>> autocorrelation_plot(fb.close)
在这里,我们可以看到,在变为噪声之前,许多滞后周期存在自相关:

图 5.45 – Facebook 股票价格的自相关图
提示
回想一下 第一章,数据分析导论,ARIMA 模型中的一个组成部分是自回归成分。自相关图可以帮助确定要使用的时间滞后数。我们将在 第七章,金融分析 – 比特币与股市 中构建一个 ARIMA 模型。
自助法图
Pandas 还提供了一个绘图功能,用于评估常见汇总统计量的不确定性,通过 samples 和 size 参数分别计算汇总统计量。然后,它将返回结果的可视化图像。
让我们看看交易量数据的汇总统计的不确定性情况:
>>> from pandas.plotting import bootstrap_plot
>>> fig = bootstrap_plot(
... fb.volume, fig=plt.figure(figsize=(10, 6))
... )
这将生成以下子图,我们可以用它来评估均值、中位数和中值范围(区间中点)的不确定性:

图 5.46 – Pandas bootstrap 图
这是pandas.plotting模块中一些函数的示例。完整列表请查看pandas.pydata.org/pandas-docs/stable/reference/plotting.html。
总结
完成本章后,我们已经能够使用pandas和matplotlib快速创建各种可视化图表。我们现在理解了matplotlib的基本原理以及图表的主要组成部分。此外,我们讨论了不同类型的图表以及在什么情况下使用它们——数据可视化的一个关键部分是选择合适的图表。请务必参考附录中的选择合适的可视化部分以备将来参考。
请注意,最佳的可视化实践不仅适用于图表类型,还适用于图表的格式设置,我们将在下一章讨论此内容。除此之外,我们将在此基础上进一步讨论使用seaborn的其他图表,以及如何使用matplotlib自定义我们的图表。请务必完成章节末的练习,以便在继续前进之前练习绘图,因为我们将在此章节的内容上进行扩展。
练习
使用到目前为止在本书中所学的内容创建以下可视化。请使用本章data/目录中的数据:
-
使用
pandas绘制 Facebook 收盘价的 20 天滚动最低值。 -
创建 Facebook 股票开盘到收盘价变化的直方图和 KDE。
-
使用地震数据,为印度尼西亚使用的每个
magType绘制箱线图。 -
绘制一条线图,表示 Facebook 每周的最高价和最低价之间的差异。这应为单条线。
-
绘制巴西、中国、印度、意大利、西班牙和美国每日新增 COVID-19 病例的 14 天移动平均值:
a) 首先,使用
diff()方法,该方法在《第四章》的处理时间序列数据部分介绍,用于计算每日新增病例的变化。然后,使用rolling()计算 14 天的移动平均值。b) 创建三个子图:一个显示中国;一个显示西班牙和意大利;另一个显示巴西、印度和美国。
-
使用
matplotlib和pandas,创建并排显示的两个子图,展示盘后交易对 Facebook 股票价格的影响:a) 第一个子图将包含每日开盘价与前一天收盘价之间的差值线图(请务必查看第四章中的处理时间序列数据部分,聚合 Pandas 数据框,以便轻松实现)。
b) 第二个子图将是一个条形图,显示这一变动的月度净效应,使用
resample()。c) 奖励 #1:根据股价是上涨(绿色)还是下跌(红色)来为条形图着色。
d) 奖励 #2:修改条形图的 x 轴,以显示月份的三字母缩写。
进一步阅读
请查看以下资源,获取本章讨论概念的更多信息:
-
数据可视化 – 最佳实践与基础:
www.toptal.com/designers/data-visualization/data-visualization-best-practices -
如何在 Python 中创建动画图形(使用 matplotlib):
towardsdatascience.com/how-to-create-animated-graphs-in-python-bb619cc2dec1 -
使用 JavaScript 进行交互式绘图(D3.js):
d3js.org/ -
Python 动画入门(使用 plotly):
plot.ly/python/animations/ -
IPython: 内置魔法命令:
ipython.readthedocs.io/en/stable/interactive/magics.html -
诚信的重要性:绘图参数如何影响解读:
www.t4g.com/insights/plot-parameters-influence-interpretation/ -
5 个用于创建交互式绘图的 Python 库:
mode.com/blog/python-interactive-plot-libraries/
第七章:第六章:使用 Seaborn 绘图及定制技巧
在上一章中,我们学习了如何使用matplotlib和pandas在宽格式数据上创建多种不同的可视化。在本章中,我们将看到如何使用seaborn从长格式数据中制作可视化,并如何定制我们的图表以提高它们的可解释性。请记住,人类大脑擅长在视觉表示中发现模式;通过制作清晰且有意义的数据可视化,我们可以帮助他人(更不用说我们自己)理解数据所传达的信息。
Seaborn 能够绘制我们在上一章中创建的许多相同类型的图表;然而,它也可以快速处理长格式数据,使我们能够使用数据的子集将额外的信息编码到可视化中,如不同类别的面板和/或颜色。我们将回顾上一章中的一些实现,展示如何使用seaborn使其变得更加简便(或更加美观),例如热图和配对图(seaborn的散点矩阵图等价物)。此外,我们将探索seaborn提供的一些新图表类型,以解决其他图表类型可能面临的问题。
之后,我们将转换思路,开始讨论如何定制我们数据可视化的外观。我们将逐步讲解如何创建注释、添加参考线、正确标注图表、控制使用的调色板,并根据需求调整坐标轴。这是我们使可视化准备好呈现给他人的最后一步。
在本章中,我们将涵盖以下主题:
-
利用 seaborn 进行更高级的绘图类型
-
使用 matplotlib 格式化图表
-
定制可视化
本章材料
本章的资料可以在 GitHub 上找到,网址是github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_06。我们将再次使用三个数据集,所有数据集都可以在data/目录中找到。在fb_stock_prices_2018.csv文件中,我们有 Facebook 在 2018 年所有交易日的股价数据。这些数据包括 OHLC 数据(开盘价、最高价、最低价和收盘价),以及成交量。这些数据是通过stock_analysis包收集的,我们将在第七章中构建该包,金融分析 - 比特币与股市。股市在周末休市,因此我们只有交易日的数据。
earthquakes.csv 文件包含从 mag 列提取的地震数据,包括它的震级(magType 列)、发生时间(time 列)和地点(place 列);我们还包含了 parsed_place 列,表示地震发生的州或国家(我们在 第二章《使用 Pandas 数据框架》时添加了这个列)。其他不必要的列已被删除。
在 covid19_cases.csv 文件中,我们有一个来自欧洲疾病预防控制中心(ECDC)提供的 全球各国每日新增 COVID-19 报告病例数 数据集的导出,数据集可以在 www.ecdc.europa.eu/en/publications-data/download-todays-data-geographic-distribution-covid-19-cases-worldwide 找到。为了实现此数据的脚本化或自动化收集,ECDC 提供了当天的 CSV 文件下载链接:opendata.ecdc.europa.eu/covid19/casedistribution/csv。我们将使用的快照是 2020 年 9 月 19 日收集的,包含了 2019 年 12 月 31 日至 2020 年 9 月 18 日的每个国家新增 COVID-19 病例数,并包含部分 2020 年 9 月 19 日的数据。在本章中,我们将查看 2020 年 1 月 18 日至 2020 年 9 月 18 日这 8 个月的数据。
在本章中,我们将使用三个 Jupyter 笔记本。它们按使用顺序进行编号。我们将首先在 1-introduction_to_seaborn.ipynb 笔记本中探索 seaborn 的功能。接下来,我们将在 2-formatting_plots.ipynb 笔记本中讨论如何格式化和标记我们的图表。最后,在 3-customizing_visualizations.ipynb 笔记本中,我们将学习如何添加参考线、阴影区域、包括注释,并自定义我们的可视化效果。文本会提示我们何时切换笔记本。
提示
附加的 covid19_cases_map.ipynb 笔记本通过一个示例演示了如何使用全球 COVID-19 病例数据在地图上绘制数据。它可以帮助你入门 Python 中的地图绘制,并在一定程度上构建了我们将在本章讨论的格式化内容。
此外,我们有两个 Python(.py)文件,包含我们将在本章中使用的函数:viz.py 和 color_utils.py。让我们首先通过探索 seaborn 开始。
使用 seaborn 进行高级绘图
如我们在上一章中看到的,pandas 提供了大多数我们想要创建的可视化实现;然而,还有一个库 seaborn,它提供了更多的功能,可以创建更复杂的可视化,并且比 pandas 更容易处理长格式数据的可视化。这些可视化通常比 matplotlib 生成的标准可视化效果要好看得多。
本节内容我们将使用 1-introduction_to_seaborn.ipynb notebook。首先,我们需要导入 seaborn,通常将其别名为 sns:
>>> import seaborn as sns
我们还需要导入 numpy、matplotlib.pyplot 和 pandas,然后读取 Facebook 股票价格和地震数据的 CSV 文件:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import pandas as pd
>>> fb = pd.read_csv(
... 'data/fb_stock_prices_2018.csv',
... index_col='date',
... parse_dates=True
... )
>>> quakes = pd.read_csv('data/earthquakes.csv')
虽然 seaborn 提供了许多我们在上一章中讨论的图表类型的替代方案,但大多数情况下,我们将仅介绍 seaborn 使得可能的新的图表类型,其余的学习可以作为练习。更多使用 seaborn API 的可用函数可以参考 seaborn.pydata.org/api.html。
类别数据
2018 年 9 月 28 日,印尼发生了一次毁灭性的海啸;它是在印尼帕卢附近发生了 7.5 级地震后发生的 (www.livescience.com/63721-tsunami-earthquake-indonesia.html)。让我们创建一个可视化图表,了解印尼使用了哪些震级类型,记录的震级范围,以及有多少地震伴随了海啸。为此,我们需要一种方法来绘制一个变量是类别型(magType),另一个是数值型(mag)的关系图。
重要提示
有关不同震级类型的信息,请访问 www.usgs.gov/natural-hazards/earthquake-hazards/science/magnitude-types。
当我们在第五章“使用 Pandas 和 Matplotlib 可视化数据”中讨论散点图时,我们限制了两个变量都必须是数值型;然而,使用 seaborn,我们可以使用另外两种图表类型,使得一个变量是类别型,另一个是数值型。第一个是 stripplot() 函数,它将数据点绘制成代表各类别的条带。第二个是 swarmplot() 函数,我们稍后会看到。
让我们使用 stripplot() 创建这个可视化。我们将发生在印尼的地震子集传递给 data 参数,并指定将 magType 放置在 x 轴(x),将震级放置在 y 轴(y),并根据地震是否伴随海啸(hue)为数据点上色:
>>> sns.stripplot(
... x='magType',
... y='mag',
... hue='tsunami',
... data=quakes.query('parsed_place == "Indonesia"')
... )
通过查看生成的图表,我们可以看到该地震是 mww 列中最高的橙色点(如果没有使用提供的 Jupyter Notebook,别忘了调用 plt.show()):

图 6.1 – Seaborn 的条形图
大部分情况下,海啸发生在较高震级的地震中,正如我们所预期的那样;然而,由于在较低震级区域有大量点的集中,我们无法清晰地看到所有的点。我们可以尝试调整 jitter 参数,它控制要添加多少随机噪声来减少重叠,或者调整 alpha 参数以控制透明度,正如我们在上一章所做的那样;幸运的是,还有一个函数 swarmplot(),它会尽可能减少重叠,因此我们将使用这个函数:
>>> sns.swarmplot(
... x='magType',
... y='mag',
... hue='tsunami',
... data=quakes.query('parsed_place == "Indonesia"'),
... size=3.5 # point size
... )
mb 列:

图 6.2 – Seaborn 的蜂群图
在上一章的 使用 pandas 绘图 部分中,我们讨论了如何可视化分布,并介绍了箱形图。Seaborn 为大数据集提供了增强型箱形图,它展示了更多的分位数,以便提供关于分布形状的更多信息,特别是尾部部分。让我们使用增强型箱形图来比较不同震级类型的地震震中,就像我们在 第五章 中所做的那样,使用 Pandas 和 Matplotlib 可视化数据:
>>> sns.boxenplot(
... x='magType', y='mag', data=quakes[['magType', 'mag']]
... )
>>> plt.title('Comparing earthquake magnitude by magType')
这将产生以下图表:

图 6.3 – Seaborn 的增强型箱形图
提示
增强型箱形图首次出现在 Heike Hofmann、Karen Kafadar 和 Hadley Wickham 合著的论文 Letter-value plots: Boxplots for large data 中,您可以在 vita.had.co.nz/papers/letter-value-plot.html 找到该文。
箱形图非常适合可视化数据的分位数,但我们失去了关于分布的信息。正如我们所见,增强型箱形图是解决这个问题的一种方法——另一种策略是使用小提琴图,它结合了核密度估计(即基础分布的估计)和箱形图:
>>> fig, axes = plt.subplots(figsize=(10, 5))
>>> sns.violinplot(
... x='magType', y='mag', data=quakes[['magType', 'mag']],
... ax=axes, scale='width' # all violins have same width
... )
>>> plt.title('Comparing earthquake magnitude by magType')
箱形图部分穿过每个小提琴图的中心;然后,在以箱形图作为 x 轴的基础上,分别在两侧绘制 核密度估计(KDE)。由于它是对称的,我们可以从箱形图的任一侧读取 KDE:

图 6.4 – Seaborn 的小提琴图
seaborn 文档还根据绘图数据类型列出了不同的绘图函数;完整的分类图表列表可以在seaborn.pydata.org/api.html#categorical-plots找到。一定要查看 countplot() 和 barplot() 函数,它们是我们在上一章使用 pandas 创建条形图的变体。
相关性和热图
如约定的那样,今天我们将学习一个比在 第五章 中使用 Pandas 和 Matplotlib 可视化数据时更简单的热图生成方法。这一次,我们将使用 seaborn,它提供了 heatmap() 函数,帮助我们更轻松地生成这种可视化图表:
>>> sns.heatmap(
... fb.sort_index().assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... ).corr(),
... annot=True,
... center=0,
... vmin=-1,
... vmax=1
... )
提示
在使用 seaborn 时,我们仍然可以使用 matplotlib 中的函数,如 plt.savefig() 和 plt.tight_layout()。请注意,如果 plt.tight_layout() 存在问题,可以改为将 bbox_inches='tight' 传递给 plt.savefig()。
我们传入 center=0,这样 seaborn 会将 0(无相关性)放置在它使用的色图的中心。为了将色标的范围设置为相关系数的范围,我们还需要提供 vmin=-1 和 vmax=1。注意,我们还传入了 annot=True,这样每个框内会显示相关系数——我们可以通过一次函数调用,既获得数值数据又获得可视化数据:

图 6.5 – Seaborn 的热图
Seaborn 还为我们提供了 pandas.plotting 模块中提供的 scatter_matrix() 函数的替代方案,叫做 pairplot()。我们可以使用这个函数将 Facebook 数据中各列之间的相关性以散点图的形式展示,而不是热图:
>>> sns.pairplot(fb)
这个结果使我们能够轻松理解 OHLC 各列之间在热图中几乎完美的正相关关系,同时还展示了沿对角线的每一列的直方图:

图 6.6 – Seaborn 的配对图
Facebook 在 2018 年下半年表现显著不如上半年,因此我们可能想了解数据在每个季度的分布变化情况。与 pandas.plotting.scatter_matrix() 函数类似,我们可以使用 diag_kind 参数来指定对角线的处理方式;然而,与 pandas 不同的是,我们可以轻松地通过 hue 参数基于其他数据为图形着色。为此,我们只需要添加 quarter 列,并将其提供给 hue 参数:
>>> sns.pairplot(
... fb.assign(quarter=lambda x: x.index.quarter),
... diag_kind='kde', hue='quarter'
... )
我们现在可以看到,OHLC 各列的分布在第一季度的标准差较小(因此方差也较小),而股价在第四季度大幅下跌(分布向左偏移):

图 6.7 – 利用数据来确定绘图颜色
提示
我们还可以将 kind='reg' 传递给 pairplot() 来显示回归线。
如果我们只想比较两个变量,可以使用jointplot(),它会给我们一个散点图,并在两侧展示每个变量的分布。让我们再次查看交易量的对数与 Facebook 股票的日内最高价和最低价差异之间的关联,就像我们在第五章中所做的那样,使用 Pandas 和 Matplotlib 可视化数据:
>>> sns.jointplot(
... x='log_volume',
... y='max_abs_change',
... data=fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... )
... )
使用kind参数的默认值,我们会得到分布的直方图,并在中心显示一个普通的散点图:

图 6.8 – Seaborn 的联合图
Seaborn 为kind参数提供了许多替代选项。例如,我们可以使用 hexbins,因为当我们使用散点图时,会有显著的重叠:
>>> sns.jointplot(
... x='log_volume',
... y='max_abs_change',
... kind='hex',
... data=fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... )
... )
我们现在可以看到左下角有大量的点集中:

图 6.9 – 使用 hexbins 的联合图
另一种查看值集中度的方法是使用kind='kde',这会给我们一个等高线图,以表示联合密度估计,并同时展示每个变量的 KDEs:
>>> sns.jointplot(
... x='log_volume',
... y='max_abs_change',
... kind='kde',
... data=fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... )
... )
等高线图中的每条曲线包含给定密度的点:

图 6.10 – 联合分布图
此外,我们还可以在中心绘制回归图,并在两侧获得 KDEs 和直方图:
>>> sns.jointplot(
... x='log_volume',
... y='max_abs_change',
... kind='reg',
... data=fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... )
... )
这导致回归线通过散点图绘制,并且在回归线周围绘制了一个较浅颜色的置信带:

图 6.11 – 带有线性回归和 KDEs 的联合图
关系看起来是线性的,但我们应该查看kind='resid':
>>> sns.jointplot(
... x='log_volume',
... y='max_abs_change',
... kind='resid',
... data=fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... )
... )
# update y-axis label (discussed next section)
>>> plt.ylabel('residuals')
注意,随着交易量的增加,残差似乎越来越远离零,这可能意味着这不是建模这种关系的正确方式:

图 6.12 – 显示线性回归残差的联合图
我们刚刚看到,我们可以使用jointplot()来生成回归图或残差图;自然,seaborn提供了直接生成这些图形的函数,无需创建整个联合图。接下来我们来讨论这些。
回归图
regplot()函数会计算回归线并绘制它,而residplot()函数会计算回归并仅绘制残差。我们可以编写一个函数将这两者结合起来,但首先需要一些准备工作。
我们的函数将绘制任意两列的所有排列(与组合不同,排列的顺序很重要,例如,(open, close)不等同于(close, open))。这使我们能够将每一列作为回归变量和因变量来看待;由于我们不知道关系的方向,因此在调用函数后让查看者自行决定。这会生成许多子图,因此我们将创建一个只包含来自 Facebook 数据的少数几列的新数据框。
我们将查看交易量的对数(log_volume)和 Facebook 股票的日最高价与最低价之间的差异(max_abs_change)。我们使用assign()来创建这些新列,并将它们保存在一个名为fb_reg_data的新数据框中:
>>> fb_reg_data = fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low
... ).iloc[:,-2:]
接下来,我们需要导入itertools,它是 Python 标准库的一部分(docs.python.org/3/library/itertools.html)。在编写绘图函数时,itertools非常有用;它可以非常轻松地创建高效的迭代器,用于排列、组合和无限循环或重复等操作:
>>> import itertools
可迭代对象是可以被迭代的对象。当我们启动一个循环时,会从可迭代对象中创建一个迭代器。每次迭代时,迭代器提供它的下一个值,直到耗尽;这意味着,一旦我们完成了一次对所有项的迭代,就没有剩余的元素,它不能再次使用。迭代器是可迭代对象,但并非所有可迭代对象都是迭代器。不是迭代器的可迭代对象可以被重复使用。
使用itertools时返回的迭代器只能使用一次:
>>> iterator = itertools.repeat("I'm an iterator", 1)
>>> for i in iterator:
... print(f'-->{i}')
-->I'm an iterator
>>> print(
... 'This printed once because the iterator '
... 'has been exhausted'
... )
This printed once because the iterator has been exhausted
>>> for i in iterator:
... print(f'-->{i}')
另一方面,列表是一个可迭代对象;我们可以编写一个循环遍历列表中的所有元素,之后仍然可以得到一个列表用于后续使用:
>>> iterable = list(itertools.repeat("I'm an iterable", 1))
>>> for i in iterable:
... print(f'-->{i}')
-->I'm an iterable
>>> print('This prints again because it\'s an iterable:')
This prints again because it's an iterable:
>>> for i in iterable:
... print(f'-->{i}')
-->I'm an iterable
现在我们对itertools和迭代器有了一些了解,接下来我们来编写回归和残差排列图的函数:
def reg_resid_plots(data):
"""
Using `seaborn`, plot the regression and residuals plots
side-by-side for every permutation of 2 columns in data.
Parameters:
- data: A `pandas.DataFrame` object
Returns:
A matplotlib `Axes` object.
"""
num_cols = data.shape[1]
permutation_count = num_cols * (num_cols - 1)
fig, ax = \
plt.subplots(permutation_count, 2, figsize=(15, 8))
for (x, y), axes, color in zip(
itertools.permutations(data.columns, 2),
ax,
itertools.cycle(['royalblue', 'darkorange'])
):
for subplot, func in zip(
axes, (sns.regplot, sns.residplot)
):
func(x=x, y=y, data=data, ax=subplot, color=color)
if func == sns.residplot:
subplot.set_ylabel('residuals')
return fig.axes
在这个函数中,我们可以看到到目前为止本章以及上一章中涉及的所有内容都已融合在一起;我们计算需要多少个子图,并且由于每种排列会有两个图表,我们只需要排列的数量来确定行数。我们利用了zip()函数,它可以一次性从多个可迭代对象中获取值并以元组形式返回,再通过元组解包轻松地遍历排列元组和二维的Axes对象数组。花些时间确保你理解这里发生了什么;本章末尾的进一步阅读部分也有关于zip()和元组解包的资源。
重要提示
如果我们提供不同长度的可迭代对象给zip(),我们将只得到与最短长度相等数量的元组。因此,我们可以使用无限迭代器,如使用itertools.repeat()时获得的,它会无限次重复相同的值(当我们没有指定重复次数时),以及itertools.cycle(),它会在所有提供的值之间无限循环。
调用我们的函数非常简单,只需要一个参数:
>>> from viz import reg_resid_plots
>>> reg_resid_plots(fb_reg_data)
第一行的子集是我们之前在联合图中看到的,而第二行则是翻转x和y变量时的回归:

图 6.13 – Seaborn 线性回归和残差图
提示
regplot()函数通过order和logistic参数分别支持多项式回归和逻辑回归。
Seaborn 还使得在数据的不同子集上绘制回归变得简单,我们可以使用lmplot()来分割回归图。我们可以使用hue、col和row来分割回归图,分别通过给定列的值进行着色、为每个值创建一个新列以及为每个值创建一个新行。
我们看到 Facebook 的表现因每个季度而异,因此让我们使用 Facebook 股票数据计算每个季度的回归,使用交易量和每日最高与最低价格之间的差异,看看这种关系是否也发生变化:
>>> sns.lmplot(
... x='log_volume',
... y='max_abs_change',
... col='quarter',
... data=fb.assign(
... log_volume=np.log(fb.volume),
... max_abs_change=fb.high - fb.low,
... quarter=lambda x: x.index.quarter
... )
... )
请注意,第四季度的回归线比前几个季度的斜率要陡得多:

图 6.14 – Seaborn 带有子集的线性回归图
请注意,运行lmplot()的结果是一个FacetGrid对象,这是seaborn的一个强大功能。接下来,我们将讨论如何在其中直接使用任何图形进行绘制。
分面
分面允许我们在子图上绘制数据的子集(分面)。我们已经通过一些seaborn函数看到了一些分面;然而,我们也可以轻松地为自己制作分面,以便与任何绘图函数一起使用。让我们创建一个可视化,比较印尼和巴布亚新几内亚的地震震级分布,看看是否发生了海啸。
首先,我们使用将要使用的数据创建一个FacetGrid对象,并通过row和col参数定义如何对子集进行划分:
>>> g = sns.FacetGrid(
... quakes.query(
... 'parsed_place.isin('
... '["Indonesia", "Papua New Guinea"]) '
... 'and magType == "mb"'
... ),
... row='tsunami',
... col='parsed_place',
... height=4
... )
然后,我们使用FacetGrid.map()方法对每个子集运行绘图函数,并传递必要的参数。我们将使用sns.histplot()函数为位置和海啸数据子集制作带有 KDE 的直方图:
>>> g = g.map(sns.histplot, 'mag', kde=True)
对于这两个位置,我们可以看到,当地震震级达到 5.0 或更大时,发生了海啸:

图 6.15 – 使用分面网格绘图
这结束了我们关于seaborn绘图功能的讨论;不过,我鼓励你查看 API(seaborn.pydata.org/api.html)以了解更多功能。此外,在绘制数据时,务必查阅附录中的选择合适的可视化方式部分作为参考。
使用 matplotlib 格式化图表
使我们的可视化图表具有表现力的一个重要部分是选择正确的图表类型,并且为其添加清晰的标签,以便易于解读。通过精心调整最终的可视化外观,我们使其更容易阅读和理解。
现在,让我们转到2-formatting_plots.ipynb笔记本,运行设置代码导入所需的包,并读取 Facebook 股票数据和 COVID-19 每日新增病例数据:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import pandas as pd
>>> fb = pd.read_csv(
... 'data/fb_stock_prices_2018.csv',
... index_col='date',
... parse_dates=True
... )
>>> covid = pd.read_csv('data/covid19_cases.csv').assign(
... date=lambda x: \
... pd.to_datetime(x.dateRep, format='%d/%m/%Y')
... ).set_index('date').replace(
... 'United_States_of_America', 'USA'
... ).sort_index()['2020-01-18':'2020-09-18']
在接下来的几个章节中,我们将讨论如何为图表添加标题、坐标轴标签和图例,以及如何自定义坐标轴。请注意,本节中的所有内容需要在运行plt.show()之前调用,或者如果使用%matplotlib inline魔法命令,则需要在同一个 Jupyter Notebook 单元格中调用。
标题和标签
迄今为止,我们创建的某些可视化图表没有标题或坐标轴标签。我们知道图中的内容,但如果我们要向他人展示这些图表,可能会引起一些混淆。为标签和标题提供明确的说明是一种良好的做法。
我们看到,当使用pandas绘图时,可以通过将title参数传递给plot()方法来添加标题,但我们也可以通过matplotlib的plt.title()来实现这一点。请注意,我们可以将x/y值传递给plt.title()以控制文本的位置。我们还可以更改字体及其大小。为坐标轴添加标签也同样简单;我们可以使用plt.xlabel()和plt.ylabel()。让我们绘制 Facebook 的收盘价,并使用matplotlib添加标签:
>>> fb.close.plot()
>>> plt.title('FB Closing Price')
>>> plt.xlabel('date')
>>> plt.ylabel('price ($)')
这将导致以下图表:

图 6.16 – 使用 matplotlib 为图表添加标签
在处理子图时,我们需要采取不同的方法。为了直观地了解这一点,让我们绘制 Facebook 股票的 OHLC 数据的子图,并使用plt.title()为整个图表添加标题,同时使用plt.ylabel()为每个子图的y-轴添加标签:
>>> fb.iloc[:,:4]\
... .plot(subplots=True, layout=(2, 2), figsize=(12, 5))
>>> plt.title('Facebook 2018 Stock Data')
>>> plt.ylabel('price ($)')
使用plt.title()将标题放置在最后一个子图上,而不是像我们预期的那样为整个图表添加标题。y-轴标签也会出现同样的问题:

图 6.17 – 为子图添加标签可能会引起混淆
在子图的情况下,我们希望给整个图表添加标题;因此,我们使用 plt.suptitle()。相反,我们希望给每个子图添加 y-轴标签,因此我们在 plot() 返回的每个 Axes 对象上使用 set_ylabel() 方法。请注意,Axes 对象会以与子图布局相同维度的 NumPy 数组返回,因此为了更方便地迭代,我们调用 flatten():
>>> axes = fb.iloc[:,:4]\
... .plot(subplots=True, layout=(2, 2), figsize=(12, 5))
>>> plt.suptitle('Facebook 2018 Stock Data')
>>> for ax in axes.flatten():
... ax.set_ylabel('price ($)')
这样会为整个图表添加一个标题,并为每个子图添加y-轴标签:

图 6.18 – 标注子图
请注意,Figure 类也有一个 suptitle() 方法,而 Axes 类的 set() 方法允许我们标注坐标轴、设置图表标题等,所有这些都可以通过一次调用来完成,例如,set(xlabel='…', ylabel='…', title='…', …)。根据我们想做的事情,我们可能需要直接调用 Figure 或 Axes 对象的方法,因此了解这些方法很重要。
图例
Matplotlib 使得可以通过 plt.legend() 函数和 Axes.legend() 方法控制图例的许多方面。例如,我们可以指定图例的位置,并格式化图例的外观,包括自定义字体、颜色等。plt.legend() 函数和 Axes.legend() 方法也可以用于在图表最初没有图例的情况下显示图例。以下是一些常用参数的示例:

图 6.19 – 图例格式化的有用参数
图例将使用每个绘制对象的标签。如果我们不希望某个对象显示图例,可以将它的标签设置为空字符串。但是,如果我们只是想修改某个对象的显示名称,可以通过 label 参数传递它的显示名称。我们来绘制 Facebook 股票的收盘价和 20 天移动平均线,使用 label 参数为图例提供描述性名称:
>>> fb.assign(
... ma=lambda x: x.close.rolling(20).mean()
... ).plot(
... y=['close', 'ma'],
... title='FB closing price in 2018',
... label=['closing price', '20D moving average'],
... style=['-', '--']
... )
>>> plt.legend(loc='lower left')
>>> plt.ylabel('price ($)')
默认情况下,matplotlib 会尝试为图表找到最佳位置,但有时它会遮挡图表的部分内容,就像在这个例子中一样。因此,我们选择将图例放在图表的左下角。请注意,图例中的文本是我们在 plot() 的 label 参数中提供的内容:

图 6.20 – 移动图例
请注意,我们传递了一个字符串给 loc 参数来指定图例的位置;我们也可以传递代码作为整数或元组,表示图例框左下角的 (x, y) 坐标。下表包含了可能的位置信息字符串:

图 6.21 – 常见图例位置
现在我们来看看如何使用 framealpha、ncol 和 title 参数来设置图例的样式。我们将绘制 2020 年 1 月 18 日至 2020 年 9 月 18 日期间,巴西、中国、意大利、西班牙和美国的世界每日新增 COVID-19 病例占比。此外,我们还会移除图表的顶部和右侧框线,使其看起来更简洁:
>>> new_cases = covid.reset_index().pivot(
... index='date',
... columns='countriesAndTerritories',
... values='cases'
... ).fillna(0)
>>> pct_new_cases = new_cases.apply(
... lambda x: x / new_cases.apply('sum', axis=1), axis=0
... )[
... ['Italy', 'China', 'Spain', 'USA', 'India', 'Brazil']
... ].sort_index(axis=1).fillna(0)
>>> ax = pct_new_cases.plot(
... figsize=(12, 7),
... style=['-'] * 3 + ['--', ':', '-.'],
... title='Percentage of the World\'s New COVID-19 Cases'
... '\n(source: ECDC)'
... )
>>> ax.legend(title='Country', framealpha=0.5, ncol=2)
>>> ax.set_xlabel('')
>>> ax.set_ylabel('percentage of the world\'s COVID-19 cases')
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
我们的图例已整齐地排列为两列,并且包含了一个标题。我们还增加了图例边框的透明度:

图 6.22 – 格式化图例
提示
不要被试图记住所有可用选项而感到不知所措。如果我们不试图学习每一种可能的自定义,而是根据需要查找与我们视觉化目标相匹配的功能,反而会更容易。
格式化轴
在第一章《数据分析简介》中,我们讨论了如果我们不小心,轴的限制可能会导致误导性的图表。我们可以通过将轴的限制作为元组传递给 xlim/ylim 参数来使用 pandas 的 plot() 方法。或者,使用 matplotlib 时,我们可以通过 plt.xlim()/plt.ylim() 函数或 Axes 对象上的 set_xlim()/set_ylim() 方法调整每个轴的限制。我们分别传递最小值和最大值;如果我们想保持自动生成的限制,可以传入 None。让我们修改之前的图表,将世界各国每日新增 COVID-19 病例的百分比的 y 轴从零开始:
>>> ax = pct_new_cases.plot(
... figsize=(12, 7),
... style=['-'] * 3 + ['--', ':', '-.'],
... title='Percentage of the World\'s New COVID-19 Cases'
... '\n(source: ECDC)'
... )
>>> ax.legend(framealpha=0.5, ncol=2)
>>> ax.set_xlabel('')
>>> ax.set_ylabel('percentage of the world\'s COVID-19 cases')
>>> ax.set_ylim(0, None)
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
请注意,y 轴现在从零开始:

图 6.23 – 使用 matplotlib 更新轴限制
如果我们想改变轴的刻度,可以使用 plt.xscale()/plt.yscale() 并传入我们想要的刻度类型。例如,plt.yscale('log') 将会为 y 轴使用对数刻度;我们在前一章中已经学过如何使用 pandas 实现这一点。
我们还可以通过将刻度位置和标签传递给 plt.xticks() 或 plt.yticks() 来控制显示哪些刻度线以及它们的标签。请注意,我们也可以调用这些函数来获取刻度位置和标签。例如,由于我们的数据从每个月的 18 日开始和结束,让我们将前一个图表中的刻度线移到每个月的 18 日,然后相应地标记刻度:
>>> ax = pct_new_cases.plot(
... figsize=(12, 7),
... style=['-'] * 3 + ['--', ':', '-.'],
... title='Percentage of the World\'s New COVID-19 Cases'
... '\n(source: ECDC)'
... )
>>> tick_locs = covid.index[covid.index.day == 18].unique()
>>> tick_labels = \
... [loc.strftime('%b %d\n%Y') for loc in tick_locs]
>>> plt.xticks(tick_locs, tick_labels)
>>> ax.legend(framealpha=0.5, ncol=2)
>>> ax.set_xlabel('')
>>> ax.set_ylabel('percentage of the world\'s COVID-19 cases')
>>> ax.set_ylim(0, None)
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
移动刻度线后,图表的第一个数据点(2020 年 1 月 18 日)和最后一个数据点(2020 年 9 月 18 日)都有刻度标签:

图 6.24 – 编辑刻度标签
我们当前将百分比表示为小数,但可能希望将标签格式化为使用百分号。请注意,不需要使用plt.yticks()函数来做到这一点;相反,我们可以使用matplotlib.ticker模块中的PercentFormatter类:
>>> from matplotlib.ticker import PercentFormatter
>>> ax = pct_new_cases.plot(
... figsize=(12, 7),
... style=['-'] * 3 + ['--', ':', '-.'],
... title='Percentage of the World\'s New COVID-19 Cases'
... '\n(source: ECDC)'
... )
>>> tick_locs = covid.index[covid.index.day == 18].unique()
>>> tick_labels = \
... [loc.strftime('%b %d\n%Y') for loc in tick_locs]
>>> plt.xticks(tick_locs, tick_labels)
>>> ax.legend(framealpha=0.5, ncol=2)
>>> ax.set_xlabel('')
>>> ax.set_ylabel('percentage of the world\'s COVID-19 cases')
>>> ax.set_ylim(0, None)
>>> ax.yaxis.set_major_formatter(PercentFormatter(xmax=1))
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
通过指定xmax=1,我们表示我们的值应该先除以 1(因为它们已经是百分比),然后乘以 100 并附加百分号。这将导致y轴上显示百分比:

图 6.25 – 将刻度标签格式化为百分比
另一个有用的格式化器是EngFormatter类,它会自动将数字格式化为千位、百万位等,采用工程计数法。让我们用它来绘制每个大洲的累计 COVID-19 病例(单位:百万):
>>> from matplotlib.ticker import EngFormatter
>>> ax = covid.query('continentExp != "Other"').groupby([
... 'continentExp', pd.Grouper(freq='1D')
... ]).cases.sum().unstack(0).apply('cumsum').plot(
... style=['-', '-', '--', ':', '-.'],
... title='Cumulative COVID-19 Cases per Continent'
... '\n(source: ECDC)'
... )
>>> ax.legend(title='', loc='center left')
>>> ax.set(xlabel='', ylabel='total COVID-19 cases')
>>> ax.yaxis.set_major_formatter(EngFormatter())
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
请注意,我们不需要将累计病例数除以 100 万来得到这些数字——我们传递给set_major_formatter()的EngFormatter对象自动计算出应该使用百万(M)单位来表示数据:

图 6.26 – 使用工程计数法格式化刻度标签
PercentFormatter和EngFormatter类都可以格式化刻度标签,但有时我们希望更改刻度的位置,而不是格式化它们。实现这一点的一种方法是使用MultipleLocator类,它可以轻松地将刻度设置为我们选择的倍数。为了演示我们如何使用它,来看一下 2020 年 4 月 18 日至 2020 年 9 月 18 日新西兰的每日新增 COVID-19 病例:
>>> ax = new_cases.New_Zealand['2020-04-18':'2020-09-18'].plot(
... title='Daily new COVID-19 cases in New Zealand'
... '\n(source: ECDC)'
... )
>>> ax.set(xlabel='', ylabel='new COVID-19 cases')
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
如果不干预刻度位置,matplotlib将以 2.5 为间隔显示刻度。我们知道没有半个病例,因此最好以整数刻度显示该数据:

图 6.27 – 默认刻度位置
我们通过使用MultipleLocator类来修正这个问题。在这里,我们并没有格式化轴标签,而是控制显示哪些标签;因此,我们必须调用set_major_locator()方法,而不是set_major_formatter():
>>> from matplotlib.ticker import MultipleLocator
>>> ax = new_cases.New_Zealand['2020-04-18':'2020-09-18'].plot(
... title='Daily new COVID-19 cases in New Zealand'
... '\n(source: ECDC)'
... )
>>> ax.set(xlabel='', ylabel='new COVID-19 cases')
>>> ax.yaxis.set_major_locator(MultipleLocator(base=3))
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
由于我们传入了base=3,因此我们的y轴现在包含每隔三的整数:

图 6.28 – 使用整数刻度位置
这些只是matplotlib.ticker模块提供的三个功能,因此我强烈建议你查看文档以获取更多信息。在本章末尾的进一步阅读部分中也有相关链接。
自定义可视化
到目前为止,我们学到的所有创建数据可视化的代码都是为了制作可视化本身。现在我们已经打下了坚实的基础,准备学习如何添加参考线、控制颜色和纹理,以及添加注释。
在3-customizing_visualizations.ipynb笔记本中,让我们处理导入库并读取 Facebook 股票价格和地震数据集:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import pandas as pd
>>> fb = pd.read_csv(
... 'data/fb_stock_prices_2018.csv',
... index_col='date',
... parse_dates=True
... )
>>> quakes = pd.read_csv('data/earthquakes.csv')
提示
更改绘图样式是改变其外观和感觉的一种简单方法,无需单独设置每个方面。要设置seaborn的样式,可以使用sns.set_style()。对于matplotlib,我们可以使用plt.style.use()来指定我们要使用的样式表。这些样式会应用于该会话中创建的所有可视化。如果我们只想为某个单一图表设置样式,可以使用sns.set_context()或plt.style.context()。可以在前述函数的文档中找到seaborn的可用样式,或在matplotlib中查看plt.style.available中的值。
添加参考线
很常见,我们希望在图表上突出显示某个特定的值,可能是一个边界或转折点。我们可能关心这条线是否被突破,或是否作为一个分界线。在金融领域,可能会在股票价格的折线图上绘制水平参考线,标记出支撑位和阻力位。
支撑位是一个预期下行趋势将会反转的价格水平,因为股票此时处于一个买家更倾向于购买的价格区间,推动价格向上并远离此点。相对地,阻力位是一个预期上行趋势将会反转的价格水平,因为该价格是一个吸引人的卖出点;因此,价格会下跌并远离此点。当然,这并不意味着这些水平永远不会被突破。由于我们有 Facebook 的股票数据,让我们在收盘价的折线图上添加支撑位和阻力位参考线。
重要提示
计算支撑位和阻力位的方法超出了本章的范围,但在第七章,财务分析——比特币与股票市场中,将包括一些使用枢轴点计算这些的代码。此外,请务必查看进一步阅读部分,以获得关于支撑位和阻力位的更深入介绍。
我们的两条水平参考线将分别位于支撑位$124.46 和阻力位$138.53。这两个数字是通过使用stock_analysis包计算得出的,我们将在第七章,财务分析——比特币与股票市场中构建该包。我们只需要创建StockAnalyzer类的一个实例来计算这些指标:
>>> from stock_analysis import StockAnalyzer
>>> fb_analyzer = StockAnalyzer(fb)
>>> support, resistance = (
... getattr(fb_analyzer, stat)(level=3)
... for stat in ['support', 'resistance']
... )
>>> support, resistance
(124.4566666666667, 138.5266666666667)
我们将使用 plt.axhline() 函数来完成这项任务,但请注意,这也适用于 Axes 对象。记住,我们提供给 label 参数的文本将会出现在图例中:
>>> fb.close['2018-12']\
... .plot(title='FB Closing Price December 2018')
>>> plt.axhline(
... y=resistance, color='r', linestyle='--',
... label=f'resistance (${resistance:,.2f})'
... )
>>> plt.axhline(
... y=support, color='g', linestyle='--',
... label=f'support (${support:,.2f})'
... )
>>> plt.ylabel('price ($)')
>>> plt.legend()
我们应该已经熟悉之前章节中的 f-string 格式,但请注意这里在变量名之后的额外文本(:,.2f)。支持位和阻力位分别以浮动点存储在 support 和 resistance 变量中。冒号(:)位于 format_spec 前面,它告诉 Python 如何格式化该变量;在这种情况下,我们将其格式化为小数(f),以逗号作为千位分隔符(,),并且小数点后保留两位精度(.2)。这种格式化也适用于 format() 方法,在这种情况下,它将类似于 '{:,.2f}'.format(resistance)。这种格式化使得图表中的图例更加直观:

图 6.29 – 使用 matplotlib 创建水平参考线
重要提示
拥有个人投资账户的人在寻找基于股票达到某一价格点来下限价单或止损单时,可能会发现一些关于支撑位和阻力位的文献,因为这些可以帮助判断目标价格的可行性。此外,交易者也可能使用这些参考线来分析股票的动能,并决定是否是时候买入/卖出股票。
回到地震数据,我们将使用 plt.axvline() 绘制垂直参考线,用于表示印尼地震震级分布中的标准差个数。位于 GitHub 仓库中 viz.py 模块的 std_from_mean_kde() 函数使用 itertools 来轻松生成我们需要绘制的颜色和值的组合:
import itertools
def std_from_mean_kde(data):
"""
Plot the KDE along with vertical reference lines
for each standard deviation from the mean.
Parameters:
- data: `pandas.Series` with numeric data
Returns:
Matplotlib `Axes` object.
"""
mean_mag, std_mean = data.mean(), data.std()
ax = data.plot(kind='kde')
ax.axvline(mean_mag, color='b', alpha=0.2, label='mean')
colors = ['green', 'orange', 'red']
multipliers = [1, 2, 3]
signs = ['-', '+']
linestyles = [':', '-.', '--']
for sign, (color, multiplier, style) in itertools.product(
signs, zip(colors, multipliers, linestyles)
):
adjustment = multiplier * std_mean
if sign == '-':
value = mean_mag – adjustment
label = '{} {}{}{}'.format(
r'$\mu$', r'$\pm$', multiplier, r'$\sigma$'
)
else:
value = mean_mag + adjustment
label = None # label each color only once
ax.axvline(
value, color=color, linestyle=style,
label=label, alpha=0.5
)
ax.legend()
return ax
itertools 中的 product() 函数将为我们提供来自任意数量可迭代对象的所有组合。在这里,我们将颜色、乘数和线型打包在一起,因为我们总是希望乘数为 1 时使用绿色虚线;乘数为 2 时使用橙色点划线;乘数为 3 时使用红色虚线。当 product() 使用这些元组时,我们得到的是正负符号的所有组合。为了避免图例过于拥挤,我们仅使用 ± 符号为每种颜色标注一次。由于在每次迭代中字符串和元组之间有组合,我们在 for 语句中解包元组,以便更容易使用。
提示
我们可以使用 LaTeX 数学符号(www.latex-project.org/)为我们的图表标注,只要我们遵循一定的模式。首先,我们必须通过在字符串前加上 r 字符来将其标记为 raw。然后,我们必须用 $ 符号将 LaTeX 包围。例如,我们在前面的代码中使用了 r'$\mu$' 来表示希腊字母 μ。
我们将使用std_from_mean_kde()函数,看看印度尼西亚地震震级的估算分布中哪些部分位于均值的一个、两个或三个标准差内:
>>> from viz import std_from_mean_kde
>>> ax = std_from_mean_kde(
... quakes.query(
... 'magType == "mb" and parsed_place == "Indonesia"'
... ).mag
... )
>>> ax.set_title('mb magnitude distribution in Indonesia')
>>> ax.set_xlabel('mb earthquake magnitude')
请注意,KDE 呈右偏分布——右侧的尾部更长,均值位于众数的右侧:

图 6.30 – 包含垂直参考线
小提示
要绘制任意斜率的直线,只需将线段的两个端点作为两个x值和两个y值(例如,[0, 2] 和 [2, 0])传递给plt.plot(),使用相同的Axes对象。对于非直线,np.linspace()可以用来创建在start, stop)区间内均匀分布的点,这些点可以作为x值并计算相应的y值。作为提醒,指定范围时,方括号表示包含端点,圆括号表示不包含端点,因此[0, 1)表示从 0 到接近 1 但不包括 1。我们在使用pd.cut()和pd.qcut()时,如果不命名桶,就会看到这种情况。
填充区域
在某些情况下,参考线本身并不那么有趣,但两条参考线之间的区域更有意义;为此,我们有axvspan()和axhspan()。让我们重新审视 Facebook 股票收盘价的支撑位和阻力位。我们可以使用axhspan()来填充两者之间的区域:
>>> ax = fb.close.plot(title='FB Closing Price')
>>> ax.axhspan(support, resistance, alpha=0.2)
>>> plt.ylabel('Price ($)')
请注意,阴影区域的颜色由facecolor参数决定。在这个例子中,我们接受了默认值:
![图 6.31 – 添加一个水平阴影区域
图 6.31 – 添加一个水平阴影区域
当我们感兴趣的是填充两条曲线之间的区域时,可以使用plt.fill_between()和plt.fill_betweenx()函数。plt.fill_between()函数接受一组x值和两组y值;如果需要相反的效果,可以使用plt.fill_betweenx()。让我们使用plt.fill_between()填充 Facebook 每个交易日的高价和低价之间的区域:
>>> fb_q4 = fb.loc['2018-Q4']
>>> plt.fill_between(fb_q4.index, fb_q4.high, fb_q4.low)
>>> plt.xticks([
... '2018-10-01', '2018-11-01', '2018-12-01', '2019-01-01'
... ])
>>> plt.xlabel('date')
>>> plt.ylabel('price ($)')
>>> plt.title(
... 'FB differential between high and low price Q4 2018'
... )
这能让我们更清楚地了解某一天价格的波动情况;垂直距离越高,波动越大:

图 6.32 – 在两条曲线之间填充阴影
通过为where参数提供布尔掩码,我们可以指定何时填充曲线之间的区域。让我们只填充上一个例子中的 12 月。我们将在整个时间段内为高价曲线和低价曲线添加虚线,以便查看发生了什么:
>>> fb_q4 = fb.loc['2018-Q4']
>>> plt.fill_between(
... fb_q4.index, fb_q4.high, fb_q4.low,
... where=fb_q4.index.month == 12,
... color='khaki', label='December differential'
... )
>>> plt.plot(fb_q4.index, fb_q4.high, '--', label='daily high')
>>> plt.plot(fb_q4.index, fb_q4.low, '--', label='daily low')
>>> plt.xticks([
... '2018-10-01', '2018-11-01', '2018-12-01', '2019-01-01'
... ])
>>> plt.xlabel('date')
>>> plt.ylabel('price ($)')
>>> plt.legend()
>>> plt.title(
... 'FB differential between high and low price Q4 2018'
... )
这将产生以下图表:

图 6.33 – 在两条曲线之间选择性地填充阴影
通过参考线和阴影区域,我们能够引起对特定区域的注意,甚至可以在图例中标注它们,但在用文字解释这些区域时,我们的选择有限。现在,让我们讨论如何为我们的图表添加更多的上下文注释。
注释
我们经常需要在可视化中标注特定的点,以便指出事件,例如 Facebook 股票因某些新闻事件而下跌的日期,或者标注一些重要的值以供比较。例如,让我们使用 plt.annotate() 函数标注支撑位和阻力位:
>>> ax = fb.close.plot(
... title='FB Closing Price 2018',
... figsize=(15, 3)
... )
>>> ax.set_ylabel('price ($)')
>>> ax.axhspan(support, resistance, alpha=0.2)
>>> plt.annotate(
... f'support\n(${support:,.2f})',
... xy=('2018-12-31', support),
... xytext=('2019-01-21', support),
... arrowprops={'arrowstyle': '->'}
... )
>>> plt.annotate(
... f'resistance\n(${resistance:,.2f})',
... xy=('2018-12-23', resistance)
... )
>>> for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
请注意,注释有所不同;当我们注释阻力位时,只提供了注释文本和通过 xy 参数注释的点的坐标。然而,当我们注释支撑位时,我们还为 xytext 和 arrowprops 参数提供了值;这使得我们可以将文本放置在不同于数据出现位置的地方,并添加箭头指示数据出现的位置。通过这种方式,我们避免了将标签遮挡在最后几天的数据上:

图 6.34 – 包含注释
arrowprops 参数为我们提供了相当多的定制选项,可以选择我们想要的箭头类型,尽管要做到完美可能有些困难。举个例子,让我们用百分比的下降幅度标注出 Facebook 在七月价格的大幅下跌:
>>> close_price = fb.loc['2018-07-25', 'close']
>>> open_price = fb.loc['2018-07-26', 'open']
>>> pct_drop = (open_price - close_price) / close_price
>>> fb.close.plot(title='FB Closing Price 2018', alpha=0.5)
>>> plt.annotate(
... f'{pct_drop:.2%}', va='center',
... xy=('2018-07-27', (open_price + close_price) / 2),
... xytext=('2018-08-20', (open_price + close_price) / 2),
... arrowprops=dict(arrowstyle='-,widthB=4.0,lengthB=0.2')
... )
>>> plt.ylabel('price ($)')
请注意,我们能够通过在 f-string 的格式说明符中使用 .2% 将 pct_drop 变量格式化为具有两位精度的百分比。此外,通过指定 va='center',我们告诉 matplotlib 将我们的注释垂直居中显示在箭头的中间:

图 6.36 – 改变线条颜色
或者,我们可以将值以 RGB 或 color 参数的元组给出。如果我们不提供 alpha 值,默认值为不透明的 1。这里需要注意的一件事是,虽然这些数值以 [0, 255] 范围呈现,但 matplotlib 要求它们在 [0, 1] 范围内,因此我们必须将每个值除以 255。以下代码与前面的示例相同,只是我们使用 RGB 元组而不是十六进制码:
fb.plot(
y='open',
figsize=(5, 3),
color=(128 / 255, 0, 1),
legend=False,
title='Evolution of FB Opening Price in 2018'
)
plt.ylabel('price ($)')
在前一章中,我们看到了几个示例,我们在绘制变化数据时需要许多不同的颜色,但这些颜色从哪里来?嗯,matplotlib 有许多颜色映射用于此目的。
颜色映射
而不是必须预先指定我们要使用的所有颜色,matplotlib 可以使用一个颜色映射并循环遍历其中的颜色。在前一章中讨论热图时,我们考虑了根据给定任务使用适当的颜色映射类别的重要性。以下表格显示了三种类型的颜色映射,每种都有其自己的用途:

图 6.37 – 颜色映射类型
提示
浏览颜色名称、十六进制和 RGB 值,请访问 www.color-hex.com/,并在 matplotlib.org/gallery/color/colormap_reference.html 上找到颜色映射的完整颜色光谱。
在 Python 中,我们可以通过运行以下代码获取所有可用色图的列表:
>>> from matplotlib import cm
>>> cm.datad.keys()
dict_keys(['Blues', 'BrBG', 'BuGn', 'BuPu', 'CMRmap', 'GnBu',
'Greens', 'Greys', 'OrRd', 'Oranges', 'PRGn',
'PiYG', 'PuBu', 'PuBuGn', 'PuOr', 'PuRd', 'Purples',
'RdBu', 'RdGy', 'RdPu', 'RdYlBu', 'RdYlGn',
'Reds', ..., 'Blues_r', 'BrBG_r', 'BuGn_r', ...])
注意,有些色图出现了两次,其中一个是反向的,名称后缀带有 _r。这非常有用,因为我们无需将数据反转,就能将值映射到我们想要的颜色。Pandas 接受这些色图作为字符串或 matplotlib 色图,可以通过 plot() 方法的 colormap 参数传入 'coolwarm_r'、cm.get_cmap('coolwarm_r') 或 cm.coolwarm_r,得到相同的结果。
让我们使用 coolwarm_r 色图来展示 Facebook 股票的收盘价如何在 20 天滚动最小值和最大值之间波动:
>>> ax = fb.assign(
... rolling_min=lambda x: x.low.rolling(20).min(),
... rolling_max=lambda x: x.high.rolling(20).max()
... ).plot(
... y=['rolling_max', 'rolling_min'],
... colormap='coolwarm_r',
... label=['20D rolling max', '20D rolling min'],
... style=[':', '--'],
... figsize=(12, 3),
... title='FB closing price in 2018 oscillating between '
... '20-day rolling minimum and maximum price'
... )
>>> ax.plot(
... fb.close, 'purple', alpha=0.25, label='closing price'
... )
>>> plt.legend()
>>> plt.ylabel('price ($)')
注意,使用反转的色图将红色表示为热性能(滚动最大值),蓝色表示为冷性能(滚动最小值)是多么简单,而不是试图确保 pandas 首先绘制滚动最小值:

图 6.38 – 使用色图
colormap 对象是一个可调用的,这意味着我们可以传递[0, 1]范围内的值,它会告诉我们该点在色图上的 RGBA 值,我们可以将其用于 color 参数。这使得我们能更精确地控制从色图中使用的颜色。我们可以使用这种技巧来控制色图如何在我们的数据上展开。例如,我们可以请求 ocean 色图的中点,并将其用于 color 参数:
>>> cm.get_cmap('ocean')(.5)
(0.0, 0.2529411764705882, 0.5019607843137255, 1.0)
提示
在 covid19_cases_map.ipynb 笔记本中有一个示例,展示了如何将色图作为可调用对象使用,在该示例中,COVID-19 的病例数被映射到颜色上,颜色越深表示病例数越多。
尽管有大量的色图可供选择,我们可能还是需要创建自己的色图。也许我们有自己喜欢使用的颜色调色板,或者有某些需求需要使用特定的色彩方案。我们可以使用 matplotlib 创建自己的色图。让我们创建一个混合色图,它从紫色(#800080)到黄色(#FFFF00),中间是橙色(#FFA500)。我们所需要的所有功能都在 color_utils.py 中。如果我们从与该文件相同的目录运行 Python,我们可以这样导入这些函数:
>>> import color_utils
首先,我们需要将这些十六进制颜色转换为 RGB 等效值,这正是 hex_to_rgb_color_list() 函数所做的。请注意,这个函数还可以处理当 RGB 值的两个数字使用相同的十六进制数字时的简写十六进制代码(例如,#F1D 是 #FF11DD 的简写形式):
import re
def hex_to_rgb_color_list(colors):
"""
Take color or list of hex code colors and convert them
to RGB colors in the range [0,1].
Parameters:
- colors: Color or list of color strings as hex codes
Returns:
The color or list of colors in RGB representation.
"""
if isinstance(colors, str):
colors = [colors]
for i, color in enumerate(
[color.replace('#', '') for color in colors]
):
hex_length = len(color)
if hex_length not in [3, 6]:
raise ValueError(
'Colors must be of the form #FFFFFF or #FFF'
)
regex = '.' * (hex_length // 3)
colors[i] = [
int(val * (6 // hex_length), 16) / 255
for val in re.findall(regex, color)
]
return colors[0] if len(colors) == 1 else colors
提示
看一下 enumerate() 函数;它允许我们在迭代时获取索引和值,而不必在循环中查找值。另外,注意 Python 如何通过 int() 函数指定基数,轻松地将十进制数转换为十六进制数。(记住 // 是整数除法——我们必须这样做,因为 int() 期望的是整数,而不是浮点数。)
我们需要的下一个函数是将这些 RGB 颜色转换为色图值的函数。此函数需要执行以下操作:
-
创建一个具有 256 个槽位的 4D NumPy 数组用于颜色定义。请注意,我们不想改变透明度,因此我们将保持第四维(alpha)不变。
-
对于每个维度(红色、绿色和蓝色),使用
np.linspace()函数在目标颜色之间创建均匀过渡(即,从颜色 1 的红色分量过渡到颜色 2 的红色分量,再到颜色 3 的红色分量,以此类推,然后重复此过程处理绿色分量,最后是蓝色分量)。 -
返回一个
ListedColormap对象,我们可以在绘图时使用它。
这就是 blended_cmap() 函数的功能:
from matplotlib.colors import ListedColormap
import numpy as np
def blended_cmap(rgb_color_list):
"""
Create a colormap blending from one color to the other.
Parameters:
- rgb_color_list: List of colors represented as
[R, G, B] values in the range [0, 1], like
[[0, 0, 0], [1, 1, 1]], for black and white.
Returns:
A matplotlib `ListedColormap` object
"""
if not isinstance(rgb_color_list, list):
raise ValueError('Colors must be passed as a list.')
elif len(rgb_color_list) < 2:
raise ValueError('Must specify at least 2 colors.')
elif (
not isinstance(rgb_color_list[0], list)
or not isinstance(rgb_color_list[1], list)
) or (
(len(rgb_color_list[0]) != 3
or len(rgb_color_list[1]) != 3)
):
raise ValueError(
'Each color should be a list of size 3.'
)
N, entries = 256, 4 # red, green, blue, alpha
rgbas = np.ones((N, entries))
segment_count = len(rgb_color_list) – 1
segment_size = N // segment_count
remainder = N % segment_count # need to add this back later
for i in range(entries - 1): # we don't alter alphas
updates = []
for seg in range(1, segment_count + 1):
# handle uneven splits due to remainder
offset = 0 if not remainder or seg > 1 \
else remainder
updates.append(np.linspace(
start=rgb_color_list[seg - 1][i],
stop=rgb_color_list[seg][i],
num=segment_size + offset
))
rgbas[:,i] = np.concatenate(updates)
return ListedColormap(rgbas)
我们可以使用 draw_cmap() 函数绘制色条,帮助我们可视化我们的色图:
import matplotlib.pyplot as plt
def draw_cmap(cmap, values=np.array([[0, 1]]), **kwargs):
"""
Draw a colorbar for visualizing a colormap.
Parameters:
- cmap: A matplotlib colormap
- values: Values to use for the colormap
- kwargs: Keyword arguments to pass to `plt.colorbar()`
Returns:
A matplotlib `Colorbar` object, which you can save
with: `plt.savefig(<file_name>, bbox_inches='tight')`
"""
img = plt.imshow(values, cmap=cmap)
cbar = plt.colorbar(**kwargs)
img.axes.remove()
return cbar
这个函数使我们可以轻松地为任何可视化添加一个带有自定义色图的色条;covid19_cases_map.ipynb 笔记本中有一个示例,展示了如何使用 COVID-19 病例在世界地图上绘制。现在,让我们使用这些函数来创建并可视化我们的色图。我们将通过导入模块来使用它们(我们之前已经做过了):
>>> my_colors = ['#800080', '#FFA500', '#FFFF00']
>>> rgbs = color_utils.hex_to_rgb_color_list(my_colors)
>>> my_cmap = color_utils.blended_cmap(rgbs)
>>> color_utils.draw_cmap(my_cmap, orientation='horizontal')
这将导致显示我们的色图的色条:

图 6.39 – 自定义混合色图
提示
Seaborn 还提供了额外的颜色调色板,以及一些实用工具,帮助用户选择色图并交互式地为 matplotlib 创建自定义色图,可以在 Jupyter Notebook 中使用。更多信息请查看 选择颜色调色板 教程(seaborn.pydata.org/tutorial/color_palettes.html),该笔记本中也包含了一个简短的示例。
正如我们在创建的色条中看到的,这些色图能够显示不同的颜色渐变,以捕捉连续值。如果我们仅希望每条线在折线图中显示为不同的颜色,我们很可能希望在不同的颜色之间进行循环。为此,我们可以使用 itertools.cycle() 与一个颜色列表;它们不会被混合,但我们可以无限循环,因为它是一个无限迭代器。我们在本章早些时候使用了这种技术来为回归残差图定义自己的颜色:
>>> import itertools
>>> colors = itertools.cycle(['#ffffff', '#f0f0f0', '#000000'])
>>> colors
<itertools.cycle at 0x1fe4f300>
>>> next(colors)
'#ffffff'
更简单的情况是,我们在某个地方有一个颜色列表,但与其将其放入我们的绘图代码并在内存中存储另一个副本,不如写一个简单的 return,它使用 yield。以下代码片段展示了这种情况的一个模拟示例,类似于 itertools 解决方案;然而,它并不是无限的。这只是说明了我们可以在 Python 中找到多种方式来做同一件事;我们必须找到最适合我们需求的实现:
from my_plotting_module import master_color_list
def color_generator():
yield from master_color_list
使用matplotlib时,另一种选择是实例化一个ListedColormap对象,并传入颜色列表,同时为N定义一个较大的值,以确保颜色足够多次重复(如果不提供,它将只经过一次颜色列表):
>>> from matplotlib.colors import ListedColormap
>>> red_black = ListedColormap(['red', 'black'], N=2000)
>>> [red_black(i) for i in range(3)]
[(1.0, 0.0, 0.0, 1.0),
(0.0, 0.0, 0.0, 1.0),
(1.0, 0.0, 0.0, 1.0)]
注意,我们还可以使用matplotlib团队的cycler,它通过允许我们定义颜色、线条样式、标记、线宽等的组合来增加额外的灵活性,能够循环使用这些组合。API 文档详细介绍了可用功能,您可以在matplotlib.org/cycler/找到。我们将在第七章《金融分析——比特币与股市》中看到一个例子。
条件着色
颜色映射使得根据数据中的值变化颜色变得简单,但如果我们只想在特定条件满足时使用特定颜色该怎么办?在这种情况下,我们需要围绕颜色选择构建一个函数。
我们可以编写一个生成器,根据数据确定绘图颜色,并且仅在请求时计算它。假设我们想要根据年份(从 1992 年到 200018 年,没错,这不是打字错误)是否为闰年来分配颜色,并区分哪些年份不是闰年(例如,我们希望为那些能被 100 整除但不能被 400 整除的年份指定特殊颜色,因为它们不是闰年)。显然,我们不想在内存中保留如此庞大的列表,所以我们创建一个生成器按需计算颜色:
def color_generator():
for year in range(1992, 200019): # integers [1992, 200019)
if year % 100 == 0 and year % 400 != 0:
# special case (divisible by 100 but not 400)
color = '#f0f0f0'
elif year % 4 == 0:
# leap year (divisible by 4)
color = '#000000'
else:
color = '#ffffff'
yield color
重要提示
取余运算符(%)返回除法操作的余数。例如,4 % 2 等于 0,因为 4 可以被 2 整除。然而,由于 4 不能被 3 整除,4 % 3 不为 0,它是 1,因为我们可以将 3 放入 4 一次,剩下 1(4 - 3)。取余运算符可以用来检查一个数字是否能被另一个数字整除,通常用于判断数字是奇数还是偶数。这里,我们使用它来查看是否满足闰年的条件(这些条件依赖于能否被整除)。
由于我们将year_colors定义为生成器,Python 将记住我们在此函数中的位置,并在调用next()时恢复执行:
>>> year_colors = color_generator()
>>> year_colors
<generator object color_generator at 0x7bef148dfed0>
>>> next(year_colors)
'#000000'
更简单的生成器可以通过生成器表达式来编写。例如,如果我们不再关心特殊情况,可以使用以下代码:
>>> year_colors = (
... '#ffffff'
... if (not year % 100 and year % 400) or year % 4
... else '#000000' for year in range(1992, 200019)
... )
>>> year_colors
<generator object <genexpr> at 0x7bef14415138>
>>> next(year_colors)
'#000000'
对于不来自 Python 的人来说,我们之前代码片段中的布尔条件实际上是数字(year % 400 的结果是一个整数),这可能会让人感到奇怪。这是利用了 Python 的真值/假值,即具有零值(例如数字0)或为空(如[]或'')的值被视为假值。因此,在第一个生成器中,我们写了 year % 400 != 0 来准确显示发生了什么,而 year % 400 的更多含义是:如果没有余数(即结果为 0),语句将被评估为 False,反之亦然。显然,在某些时候,我们必须在可读性和 Pythonic 之间做出选择,但了解如何编写 Pythonic 代码是很重要的,因为它通常会更高效。
提示
在 Python 中运行 import this 来查看Python 之禅,它给出了关于如何写 Pythonic 代码的一些思路。
现在我们已经了解了一些在 matplotlib 中使用颜色的方法,让我们考虑另一种让数据更突出的方法。根据我们要绘制的内容或可视化的使用场景(例如黑白打印),使用纹理与颜色一起或代替颜色可能会更有意义。
纹理
除了定制我们在可视化中使用的颜色,matplotlib 还使得在各种绘图函数中包含纹理成为可能。这是通过 hatch 参数实现的,pandas 会为我们传递该参数。让我们绘制一个 2018 年 Q4 Facebook 股票每周交易量的条形图,并使用纹理条形图:
>>> weekly_volume_traded = fb.loc['2018-Q4']\
... .groupby(pd.Grouper(freq='W')).volume.sum()
>>> weekly_volume_traded.index = \
... weekly_volume_traded.index.strftime('W %W')
>>> ax = weekly_volume_traded.plot(
... kind='bar',
... hatch='*',
... color='lightgray',
... title='Volume traded per week in Q4 2018'
... )
>>> ax.set(
... xlabel='week number',
... ylabel='volume traded'
... )
使用 hatch='*',我们的条形图将填充星号。请注意,我们还为每个条形图设置了颜色,因此这里有很多灵活性:

图 6.40 – 使用纹理条形图
纹理还可以组合起来形成新的图案,并通过重复来增强效果。让我们回顾一下 plt.fill_between() 的示例,其中我们仅为 12 月部分上色(图 6.33)。这次我们将使用纹理来区分每个月,而不仅仅是为 12 月添加阴影;我们将用环形纹理填充 10 月,用斜线填充 11 月,用小点填充 12 月:
>>> import calendar
>>> fb_q4 = fb.loc['2018-Q4']
>>> for texture, month in zip(
... ['oo', '/\\/\\', '...'], [10, 11, 12]
... ):
... plt.fill_between(
... fb_q4.index, fb_q4.high, fb_q4.low,
... hatch=texture, facecolor='white',
... where=fb_q4.index.month == month,
... label=f'{calendar.month_name[month]} differential'
... )
>>> plt.plot(fb_q4.index, fb_q4.high, '--', label='daily high')
>>> plt.plot(fb_q4.index, fb_q4.low, '--', label='daily low')
>>> plt.xticks([
... '2018-10-01', '2018-11-01', '2018-12-01', '2019-01-01'
... ])
>>> plt.xlabel('date')
>>> plt.ylabel('price ($)')
>>> plt.title(
... 'FB differential between high and low price Q4 2018'
... )
>>> plt.legend()
使用 hatch='o' 会生成细环,因此我们使用 'oo' 来为 10 月生成更粗的环形纹理。对于 11 月,我们希望得到交叉图案,因此我们结合了两个正斜杠和两个反斜杠(我们实际上用了四个反斜杠,因为它们需要转义)。为了在 12 月实现小点纹理,我们使用了三个句点——添加得越多,纹理就越密集:

图 6.41 – 结合纹理
这就是我们对图表定制化的讨论总结。这并非完整的讨论,因此请确保探索 matplotlib API,了解更多内容。
总结
呼,真多啊!我们学习了如何使用matplotlib、pandas和seaborn创建令人印象深刻且自定义的可视化图表。我们讨论了如何使用seaborn绘制其他类型的图表,并清晰地展示一些常见图表。现在,我们可以轻松地创建自己的颜色映射、标注图表、添加参考线和阴影区域、调整坐标轴/图例/标题,并控制可视化外观的大部分方面。我们还体验了使用itertools并创建我们自己的生成器。
花些时间练习我们讨论的内容,完成章节末的练习。在下一章中,我们将把所学的知识应用到金融领域,创建自己的 Python 包,并将比特币与股票市场进行比较。
练习
使用我们迄今为止在本书中学到的知识和本章的数据,创建以下可视化图表。确保为图表添加标题、轴标签和图例(适当时)。
-
使用
seaborn创建热图,显示地震震级与是否发生海啸之间的相关系数,地震测量使用mb震级类型。 -
创建一个 Facebook 交易量和收盘价格的箱线图,并绘制 Tukey 围栏范围的参考线,乘数为 1.5。边界将位于Q1 − 1.5 × IQR和Q3 + 1.5 × IQR。确保使用数据的
quantile()方法以简化这一过程。(选择你喜欢的图表方向,但确保使用子图。) -
绘制全球累计 COVID-19 病例的变化趋势,并在病例超过 100 万的日期上添加一条虚线。确保y轴的刻度标签相应地格式化。
-
使用
axvspan()在收盘价的折线图中,从'2018-07-25'到'2018-07-31'标记 Facebook 价格的大幅下降区域。 -
使用 Facebook 股价数据,在收盘价的折线图上标注以下三个事件:
a) 2018 年 7 月 25 日收盘后宣布用户增长令人失望
b) 剑桥分析公司丑闻爆发 2018 年 3 月 19 日(当时影响了市场)
c) FTC 启动调查 2018 年 3 月 20 日
-
修改
reg_resid_plots()函数,使用matplotlib的颜色映射,而不是在两种颜色之间循环。记住,在这种情况下,我们应该选择定性颜色映射或创建自己的颜色映射。
进一步阅读
查看以下资源,了解更多关于本章所涉及主题的信息:
-
选择颜色映射(Colormaps):
matplotlib.org/tutorials/colors/colormaps.html -
控制图形美学(seaborn):
seaborn.pydata.org/tutorial/aesthetics.html -
使用样式表和 rcParams 自定义 Matplotlib:
matplotlib.org/tutorials/introductory/customizing.html -
格式化字符串语法:
docs.python.org/3/library/string.html#format-string-syntax -
生成器表达式(PEP 289):
www.python.org/dev/peps/pep-0289/ -
信息仪表板设计:用于一目了然监控的数据展示(第二版),Stephen Few 著:
www.amazon.com/Information-Dashboard-Design-At-Glance/dp/1938377001/ -
Matplotlib 命名颜色:
matplotlib.org/examples/color/named_colors.html -
多重赋值和元组拆包提高 Python 代码可读性:
treyhunner.com/2018/03/tuple-unpacking-improves-python-code-readability/ -
Python: range 不是一个迭代器!:
treyhunner.com/2018/02/python-range-is-not-an-iterator/ -
Python zip() 函数:
www.journaldev.com/15891/python-zip-function -
Seaborn API 参考:
seaborn.pydata.org/api.html -
给我看数字:设计表格和图表以便一目了然,Stephen Few 著:
www.amazon.com/gp/product/0970601972/ -
样式表参考(Matplotlib):
matplotlib.org/gallery/style_sheets/style_sheets_reference.html -
支撑位与阻力位基础知识:
www.investopedia.com/trading/support-and-resistance-basics/ -
迭代器协议:Python 中 "for 循环" 是如何工作的:
treyhunner.com/2016/12/python-iterator-protocol-how-for-loops-work/ -
定量信息的视觉展示,Edward R. Tufte 著:
www.amazon.com/Visual-Display-Quantitative-Information/dp/1930824130 -
刻度格式化器:
matplotlib.org/gallery/ticks_and_spines/tick-formatters.html -
什么是 Pythonic?:
stackoverflow.com/questions/25011078/what-does-pythonic-mean
第三部分:应用——使用 Pandas 进行真实世界分析
现在是时候将我们迄今为止所学的内容整合起来了。在本节中,我们将使用一些真实世界的数据集,进行从头到尾的分析,结合前几章所讲的所有概念,并在过程中引入一些新材料。
本节包括以下章节:
-
第七章,金融分析——比特币与股票市场
-
第八章,基于规则的异常检测
第八章:第七章:金融分析——比特币与股票市场
是时候转变思路并开始开发一个应用程序了。在本章中,我们将通过分析比特币和股票市场来探索一个金融应用。 本章将建立在我们迄今为止学到的所有知识基础上——我们将从互联网提取数据;进行一些探索性数据分析;使用pandas、seaborn和matplotlib创建可视化;计算分析金融工具性能的关键指标;并初步体验模型构建。请注意,我们这里并不是要学习金融分析,而是通过介绍如何将本书中学到的技能应用于金融分析。
本章也是本书中标准工作流程的一次突破。到目前为止,我们一直将 Python 作为一种功能性编程语言来使用。然而,Python 也支持面向对象编程,例如StockReader类(用于获取数据)、Visualizer类(用于可视化金融资产)、StockAnalyzer类(用于计算金融指标)以及StockModeler类(用于建模金融数据)。由于我们需要大量代码来使分析过程简洁且易于复现,我们将构建一个 Python 包来存放这些类。代码将在文中展示并逐步解释;然而,我们不需要自己键入或运行它——确保阅读本章的本章材料部分,正确设置环境。
本章将具有一定挑战性,可能需要多读几遍;然而,它将教会你最佳实践,并且在这里学到的技能将大大提高你的编程能力,这些技能会迅速带来回报。一个主要的收获应该是,面向对象编程(OOP)在打包分析任务方面非常有帮助。每个类应该有一个单一的目标,并且要有良好的文档。如果我们有许多类,应该将它们分布到不同的文件中,创建一个包。这样,其他人就能很容易地安装/使用它们,我们也能标准化在整个项目中执行某些任务的方式。举个例子,我们不应该让项目中的每个合作者都写自己的数据库连接函数。标准化且良好文档化的代码将节省很多后续麻烦。
本章将涵盖以下主题:
-
构建一个 Python 包
-
收集金融数据
-
进行探索性数据分析
-
对金融工具进行技术分析
-
使用历史数据建模性能
本章材料
本章中,我们将创建自己的股票分析包。这使得我们非常容易分发我们的代码,并且其他人也能使用我们的代码。该包的最终产品已上传至 GitHub:github.com/stefmolin/stock-analysis/tree/2nd_edition。Python 的包管理工具 pip 可以从 GitHub 安装包,也可以本地构建包;因此,我们可以选择以下任意方式继续操作:
-
如果我们不打算修改源代码以供个人使用,可以从 GitHub 安装。
-
Fork 并克隆仓库,然后在本地机器上安装它,以便修改代码。
如果我们希望直接从 GitHub 安装,这里不需要做任何操作,因为在第一章中设置环境时已经完成了安装,数据分析入门;不过,作为参考,我们可以执行以下操作来从 GitHub 安装包:
(book_env) $ pip3 install \
git+https://github.com/stefmolin/stock-analysis.git@2nd_edition
小贴士
URL 中的@2nd_edition部分告诉pip安装标记为2nd_edition的版本。要安装特定分支上的版本,只需将其替换为@<branch_name>。例如,如果我们希望安装在名为dev的分支上开发的代码,可以使用@dev。当然,务必先检查该分支是否存在。我们还可以使用提交哈希值以相同方式抓取特定的提交。有关更多信息,请访问pip.pypa.io/en/latest/reference/pip_install/#git。
若要在可编辑模式下本地安装—即任何更改会自动在本地反映,而无需重新安装—我们使用-e标志。请在我们在第一章中创建的虚拟环境的命令行中运行以下命令。请注意,这将克隆该包的最新版本,可能与书中的版本不同(即带有2nd_edition标签的版本):
(book_env) $ git clone \
git@github.com:stefmolin/stock-analysis.git
(book_env) $ pip3 install -r stock-analysis/requirements.txt
(book_env) $ pip3 install -e stock-analysis
重要提示
本示例使用的是通过 SSH 克隆git clone;如果尚未设置 SSH 密钥,请改为通过 HTTPS 克隆,使用如下 URL:https://github.com/stefmolin/stock-analysis.git。或者,可以按照 GitHub 上的说明首先生成 SSH 密钥。如果你有兴趣只克隆带有2nd_edition标签的版本,请参考这个 Stack Overflow 贴文:stackoverflow.com/questions/20280726/how-to-git-clone-a-specific-tag。
我们将在本章中使用此包。本书仓库中本章的目录包含我们实际分析时将使用的financial_analysis.ipynb笔记本,地址为github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_07。data/文件夹包含备份文件,以防数据源自发布以来有所更改,或使用StockReader类收集数据时出现错误;如果发生这种情况,只需读取 CSV 文件并按照本章剩余内容进行操作。同样,exercises/文件夹也包含练习的备份文件。
重要说明
如果我们在使用 Jupyter Notebook 时修改了一个以可编辑模式安装的包中的文件,我们需要重新启动内核或打开一个新的 Python shell 并重新导入该包。这是因为 Python 会在导入后缓存它。其他选项包括使用importlib.reload()或 IPython 的autoreload扩展(ipython.readthedocs.io/en/stable/config/extensions/autoreload.html)。
构建一个 Python 包
构建包被视为良好的编码实践,因为它允许编写模块化代码并实现重用。使用matplotlib绘制图形时,我们不需要知道我们调用的函数内部到底在做什么——只需了解输入和输出是什么,就足以在其基础上进行构建。
包结构
window_calc.py来自第四章,聚合 Pandas DataFrame,以及viz.py来自第六章,使用 Seaborn 绘图与自定义技巧,这两个都是模块。包是由组织成目录的模块集合。包也可以被导入,但当我们导入一个包时,我们可以访问其中的某些模块,这样就不必单独导入每一个模块。这还允许我们构建可以相互导入的模块,而不必维护一个非常大的模块。
为了将模块转化为包,我们按照以下步骤进行:
-
创建一个名为包的目录(本章使用
stock_analysis)。 -
将模块放置在上述目录中。
-
添加一个
__init__.py文件,其中包含导入包时要执行的任何 Python 代码(这个文件可以为空,也经常是空的)。 -
在包的顶层目录(此处为
stock_analysis)的同级别创建一个setup.py文件,它将向pip提供有关如何安装该包的指令。有关创建此文件的详细信息,请参见进一步阅读部分。
一旦上述步骤完成,可以使用 pip 安装该包。请注意,尽管我们的包仅包含一个目录,但我们可以根据需要构建包含多个子包的包。这些子包的创建与创建包时类似,唯一不同的是它们不需要 setup.py 文件:
-
在主包目录(或某个其他子包内)创建一个子包目录。
-
将子包的模块放入该目录。
-
添加
__init__.py文件,其中包含当导入子包时应执行的代码(此文件可以为空)。
一个包含单个子包的包的目录层次结构大致如下所示:
repo_folder
|-- <package_name>
| |-- __init__.py
| |-- some_module.py
| `-- <subpackage_name>
| |-- __init__.py
| |-- another_module.py
| `-- last_module.py
`-- setup.py
构建包时需要注意的其他事项包括以下内容:
-
为仓库编写README文件,以便他人了解它包含的内容(见:
www.makeareadme.com/)。 -
pylint包,网址:www.pylint.org/)。 -
添加测试,确保代码修改不会破坏任何功能,并且代码能够按预期工作(请查看
pytest包,网址:docs.pytest.org/en/latest/)。
stock_analysis 包概述
在本章中,我们将使用迄今为止讨论的各种 Python 包以及 Python 标准库,创建一个名为 stock_analysis 的 Python 包。该包位于 stock-analysis 仓库中(github.com/stefmolin/stock-analysis),其结构如下:

图 7.1 – stock-analysis 仓库的结构
我们包中的模块将包含用于进行资产技术分析的自定义类。类应为单一目的而设计,这样有助于构建、使用和调试,特别是当出现问题时。因此,我们将构建多个类,以覆盖财务分析的各个方面。我们需要为以下每个目的创建一个类:

图 7.2 – stock_analysis 包的主要主题和类
可视化包中模块之间的交互及每个类所提供的功能是很有帮助的。为此,我们可以构建统一建模语言(UML)图。
UML 图
utils.py 用于工具函数:

图 7.3 – stock_analysis 包的模块依赖关系
提示
pylint包附带了pyreverse,它可以生成 UML 图。如果已安装graphviz(www.graphviz.org/download/),从命令行运行以下命令可以生成 PNG 文件,显示模块之间的关系以及类的 UML 图(前提是已克隆代码库并安装了pylint):pyreverse -o png stock_analysis
stock_analysis包中类的 UML 图如下所示:

图 7.4 – stock_analysis包中类的 UML 图
每个框的顶部部分包含类名;中间部分包含该类的属性;底部部分包含该类中定义的任何方法。注意从AssetGroupVisualizer和StockVisualizer类指向Visualizer类的箭头吗?这意味着这两个类都是Visualizer的一种类型。对于AssetGroupVisualizer和StockVisualizer类所显示的方法,它们在这些类中与Visualizer类中的定义不同。我们将在探索性数据分析部分更深入地探讨这一点。在本章的其余部分,我们将更详细地讨论stock_analysis包中的每个类,并利用它们的功能对金融资产进行技术分析。
收集金融数据
在第二章《使用 Pandas DataFrame》中,以及第三章《使用 Pandas 进行数据清洗》中,我们使用 API 收集数据;然而,还有其他方式可以从互联网上收集数据。我们可以使用pandas提供的pd.read_html()函数,它会为页面上找到的每个 HTML 表格返回一个 DataFrame。对于经济和金融数据,另一种选择是pandas_datareader包,它被stock_analysis包中的StockReader类用于收集金融数据。
重要说明
如果本章使用的数据源发生了变化,或者在使用StockReader类收集数据时遇到错误,可以读取data/文件夹中的 CSV 文件作为替代,以便继续跟随文本进行操作;例如:
pd.read_csv('data/bitcoin.csv', index_col='date', parse_dates=True)
StockReader 类
由于我们将在相同的日期范围内收集各种资产的数据,创建一个隐藏所有实现细节的类是有意义的,因此避免大量复制粘贴(以及潜在的错误)。为此,我们将建立StockReader类,它将使得收集比特币、股票和股票市场指数的数据变得更容易。我们可以简单地通过提供我们分析所需的日期范围创建StockReader类的实例,然后使用它提供的方法获取任何我们喜欢的数据。以下的 UML 图表提供了实现的高层次概述:

图 7.5 – StockReader 类的 UML 图表
UML 图表告诉我们StockReader类提供了一个可用股票代码(available_tickers)的属性,并且可以执行以下操作:
-
使用
get_bitcoin_data()方法以所需货币拉取比特币数据。 -
使用
get_forex_rates()方法拉取每日外汇汇率数据。 -
使用
get_index_data()方法拉取股票市场上特定指数(如标准普尔 500 指数)的数据。 -
使用
get_index_ticker()方法查找特定指数的股票市场符号(例如,在 Yahoo! Finance 上的标准普尔 500 指数的^GSPC)。 -
使用
get_risk_free_rate_of_return()方法收集无风险收益率。 -
使用
get_ticker_data()方法拉取股票市场上特定股票(比如 Netflix 的 NFLX)的数据。
现在我们理解了为什么需要这门课程,并对其结构有了高层次的概述,我们可以继续查看代码。由于在stock_analysis/stock_reader.py模块中有大量代码需要审查,我们将逐个部分地分解这个文件。请注意,这可能会改变缩进级别,因此请查看文件本身以获取完整版本。
模块的第一行是关于模块本身的help(),它将出现在顶部附近。这描述了我们模块的目的。接着是我们将需要的任何导入:
"""Gather select stock data."""
import datetime as dt
import re
import pandas as pd
import pandas_datareader.data as web
from .utils import label_sanitizer
注意,import语句按照PEP 8(Python 风格指南,网址为 https://www.python.org/dev/peps/pep-0008/)进行了三组组织,规定它们应按以下顺序排列:
-
标准库导入(
datetime和re) -
第三方库(
pandas和pandas_datareader) -
来自
stock_analysis包中另一个模块的相对导入(.utils)
在我们的导入之后,我们定义了StockReader类。首先,我们创建一个字典,将指数的股票代码映射到描述性名称_index_tickers中。注意,我们的类还有一个文档字符串,定义了它的目的。在这里,我们只会列出一些可用的股票代码:
class StockReader:
"""Class for reading financial data from websites."""
_index_tickers = {'S&P 500': '^GSPC', 'Dow Jones': '^DJI',
'NASDAQ': '^IXIC'}
在构建类时,有许多特殊方法(俗称dunder 方法,因为它们的名称以双下划线开头和结尾),我们可以提供这些方法来定制类在与语言操作符一起使用时的行为:
-
初始化对象(
__init__())。 -
使对象可以进行排序比较(
__eq__()、__lt__()、__gt__()等)。 -
执行对象的算术运算(
__add__()、__sub__()、__mul__()等)。 -
能够使用内建的 Python 函数,例如
len()(__len__())。 -
获取对象的字符串表示,用于
print()函数(__repr__()和__str__())。 -
支持迭代和索引(
__getitem__()、__iter__()和__next__())。
幸运的是,我们不需要每次创建类时都编写所有这些功能。在大多数情况下,我们只需要__init__()方法,它会在我们创建对象时执行。(有关特殊方法的更多信息,请访问dbader.org/blog/python-dunder-methods和docs.python.org/3/reference/datamodel.html#special-method-names.)
StockReader类的对象持有数据采集的开始和结束日期,因此我们将其放入__init__()方法中。我们解析调用者传入的日期,以便允许使用任何日期分隔符;例如,我们将能够处理 Python datetime对象的输入;形如'YYYYMMDD'的字符串;或使用任何与非数字正则表达式(\D)匹配的分隔符表示日期的字符串,例如'YYYY|MM|DD'或'YYYY/MM/DD'。如果有分隔符,我们将其替换为空字符串,以便在我们的方法中使用'YYYYMMDD'格式构建日期时间。此外,如果调用者给定的开始日期等于或晚于结束日期,我们将引发ValueError:
def __init__(self, start, end=None):
"""
Create a `StockReader` object for reading across
a given date range.
Parameters:
- start: The first date to include, as a datetime
object or a string in the format 'YYYYMMDD'.
- end: The last date to include, as a datetime
object or string in the format 'YYYYMMDD'.
Defaults to today if not provided.
"""
self.start, self.end = map(
lambda x: x.strftime('%Y%m%d')\
if isinstance(x, dt.date)\
else re.sub(r'\D', '', x),
[start, end or dt.date.today()]
)
if self.start >= self.end:
raise ValueError('`start` must be before `end`')
请注意,我们没有在__init__()方法中定义_index_tickers,该方法在对象创建时被调用,因为我们只需要为从这个类创建的所有对象保留一份该信息。_index_tickers类属性是私有的(按约定,前面有一个下划线),这意味着,除非该类的用户知道它的名称,否则他们不会轻易找到它(请注意,方法也可以是私有的)。这样做的目的是为了保护它(尽管不能完全保证)并且因为用户并不需要直接访问它(它是为了类的内部工作)。相反,我们将提供一个属性,我们可以像访问属性一样访问它,还会提供一个类方法,用于获取映射到字典中给定键的值。
提示
类方法是可以在类本身上使用的方法,无需事先创建类的实例。这与我们到目前为止看到的实例方法相对。实例方法是与类的实例一起使用的,用于特定于该实例的操作。我们通常不需要类方法,但如果我们有共享于所有实例的数据,创建类方法比实例方法更有意义。
由于_index_tickers是私有的,我们希望为类的用户提供查看可用项的简便方法。因此,我们将为_index_tickers的键创建一个属性。为此,我们使用@property装饰器。@property和@classmethod)装饰器,并编写我们自己的装饰器以清理并标准化跨方法收集数据的结果(@label_sanitizer)。要使用装饰器,我们将其放在函数或方法定义之上:
@property
def available_tickers(self):
"""Indices whose tickers are supported."""
return list(self._index_tickers.keys())
此外,我们通过类方法提供获取股票代码的方式,因为我们的股票代码存储在类变量中。按照惯例,类方法将cls作为第一个参数,而实例方法将self作为第一个参数:
@classmethod
def get_index_ticker(cls, index):
"""
Get the ticker of the specified index, if known.
Parameters:
- index: The name of the index; check
`available_tickers` for full list which includes:
- 'S&P 500' for S&P 500,
- 'Dow Jones' for Dow Jones Industrial Average,
- 'NASDAQ' for NASDAQ Composite Index
Returns:
The ticker as a string if known, otherwise `None`.
"""
try:
index = index.upper()
except AttributeError:
raise ValueError('`index` must be a string')
return cls._index_tickers.get(index, None)
提示
如果我们想要禁止代码中的某些操作,我们可以检查并根据需要raise错误;这允许我们提供更具信息性的错误消息,或者在重新引发错误之前,简单地附加一些额外的操作(通过raise没有表达式)。如果我们希望在某些事情出错时运行特定代码,则使用try...except块:我们将可能出问题的代码放在try中,并将遇到问题时该做的事情放在except子句中。
当我们进入金融工具的技术分析部分时,我们将需要无风险回报率来计算一些指标。这是指没有金融损失风险的投资回报率;在实际操作中,我们使用 10 年期美国国债收益率。由于这个利率会依赖于我们分析的日期范围,因此我们将把这个功能添加到StockReader类中,避免自己查找。我们将使用pandas_datareader包从圣路易斯联邦储备银行收集数据(fred.stlouisfed.org/series/DGS10),提供选择返回我们研究的日期范围的每日利率(用于分析数据本身),或者仅返回最后一个利率(如果我们需要单一值进行计算):
def get_risk_free_rate_of_return(self, last=True):
"""
Get risk-free rate of return w/ 10-year US T-bill
from FRED (https://fred.stlouisfed.org/series/DGS10)
Parameter:
- last: If `True`, return the rate on the last
date in the date range else, return a `Series`
object for the rate each day in the date range.
Returns:
A single value or a `pandas.Series` object.
"""
data = web.DataReader(
'DGS10', 'fred', start=self.start, end=self.end
)
data.index.rename('date', inplace=True)
data = data.squeeze()
return data.asof(self.end) \
if last and isinstance(data, pd.Series) else data
剩余的方法代码用pass替代,表示告诉 Python 什么都不做(并提醒我们稍后更新),以便代码可以按原样运行。我们将在下一节编写以下方法:
@label_sanitizer
def get_ticker_data(self, ticker):
pass
def get_index_data(self, index):
pass
def get_bitcoin_data(self, currency_code):
pass
@label_sanitizer
def get_forex_rates(self, from_currency, to_currency,
**kwargs):
pass
重要提示
由于我们不会查看外汇汇率,本章不涉及get_forex_rates()方法;然而,这个方法提供了如何使用pandas_datareader包的另一个示例,因此我鼓励你看看它。请注意,为了使用这个方法,你需要从 AlphaVantage 获取一个免费的 API 密钥,网址是www.alphavantage.co/support/#api-key。
get_ticker_data()和get_forex_rates()方法都使用了@label_sanitizer装饰器,这样可以将我们从不同来源收到的数据统一为相同的列名,从而避免我们后续清理数据。@label_sanitizer装饰器定义在stock_analysis/utils.py模块中。和之前一样,我们首先来看一下utils模块的文档字符串和导入:
"""Utility functions for stock analysis."""
from functools import wraps
import re
import pandas as pd
接下来,我们有_sanitize_label()函数,它将清理单个标签。请注意,我们在函数名前加了下划线,因为我们不打算让包的用户直接使用它——它是为我们的装饰器使用的:
def _sanitize_label(label):
"""
Clean up a label by removing non-letter, non-space
characters and putting in all lowercase with underscores
replacing spaces.
Parameters:
- label: The text you want to fix.
Returns:
The sanitized label.
"""
return re.sub(r'[^\w\s]', '', label)\
.lower().replace(' ', '_')
最后,我们定义了@label_sanitizer装饰器,它是一个清理我们从互联网上获得的数据的列名和索引名的函数。没有这个装饰器,我们收集的数据中的列名可能会有一些意外的字符,比如星号或空格,使得数据难以使用。通过使用这个装饰器,方法将始终返回一个清理过列名的数据框,省去了我们的一步:
def label_sanitizer(method):
"""
Decorator around a method that returns a dataframe to
clean up all labels in said dataframe (column names and
index name) by using `_sanitize_label()`.
Parameters:
- method: The method to wrap.
Returns:
A decorated method or function.
"""
@wraps(method) # keep original docstring for help()
def method_wrapper(self, *args, **kwargs):
df = method(self, *args, **kwargs)
# fix the column names
df.columns = [
_sanitize_label(col) for col in df.columns
]
# fix the index name
df.index.rename(
_sanitize_label(df.index.name), inplace=True
)
return df
return method_wrapper
请注意,label_sanitizer()函数的定义中也有一个装饰器。来自标准库functools模块的@wraps装饰器会将装饰过的函数/方法的文档字符串与原始文档字符串相同;这是必要的,因为装饰操作实际上会创建一个新函数/方法,从而使得help()函数变得无效,除非我们进行干预。
小贴士
使用@label_sanitizer语法的方式是method = label_sanitizer(method)。不过,两者都是有效的。
现在我们已经理解了装饰器,准备好完成StockReader类的构建。请注意,我们还将为stock_analysis包中的其他类使用并创建附加的装饰器,因此在继续之前,请确保你对这些装饰器已经足够熟悉。
从 Yahoo! Finance 收集历史数据
我们的数据收集的基础将是get_ticker_data()方法。它使用pandas_datareader包从 Yahoo! Finance 获取数据:
@label_sanitizer
def get_ticker_data(self, ticker):
"""
Get historical OHLC data for given date range and ticker.
Parameter:
- ticker: The stock symbol to lookup as a string.
Returns: A `pandas.DataFrame` object with the stock data.
"""
return web.get_data_yahoo(ticker, self.start, self.end)
重要提示
过去,pandas_datareader和 Yahoo! Finance API 曾出现过一些问题,导致pandas_datareader开发者通过web.DataReader()函数(pandas-datareader.readthedocs.io/en/latest/whatsnew.html#v0-6-0-january-24-2018)弃用了对它的支持;因此,我们必须使用他们的替代方法:web.get_data_yahoo()。
要收集股票市场指数的数据,我们可以使用get_index_data()方法,该方法首先查找指数的股票代码,然后调用我们刚刚定义的get_ticker_data()方法。请注意,由于get_ticker_data()方法使用了@label_sanitizer装饰器,因此get_index_data()方法不需要使用@label_sanitizer装饰器:
def get_index_data(self, index):
"""
Get historical OHLC data from Yahoo! Finance
for the chosen index for given date range.
Parameter:
- index: String representing the index you want
data for, supported indices include:
- 'S&P 500' for S&P 500,
- 'Dow Jones' for Dow Jones Industrial Average,
- 'NASDAQ' for NASDAQ Composite Index
Returns:
A `pandas.DataFrame` object with the index data.
"""
if index not in self.available_tickers:
raise ValueError(
'Index not supported. Available tickers'
f"are: {', '.join(self.available_tickers)}"
)
return self.get_ticker_data(self.get_index_ticker(index))
Yahoo! Finance 也提供比特币的数据;然而,我们必须选择一个货币来使用。get_bitcoin_data()方法接受一个货币代码来创建 Yahoo! Finance 搜索的符号(例如,BTC-USD 表示以美元计价的比特币数据)。实际的数据收集仍然由get_ticker_data()方法处理:
def get_bitcoin_data(self, currency_code):
"""
Get bitcoin historical OHLC data for given date range.
Parameter:
- currency_code: The currency to collect the bitcoin
data in, e.g. USD or GBP.
Returns:
A `pandas.DataFrame` object with the bitcoin data.
"""
return self\
.get_ticker_data(f'BTC-{currency_code}')\
.loc[self.start:self.end] # clip dates
此时,StockReader类已经可以使用,因此让我们在financial_analysis.ipynb笔记本中开始工作,并导入将用于本章其余部分的stock_analysis包:
>>> import stock_analysis
当我们导入stock_analysis包时,Python 会运行stock_analysis/__init__.py文件:
"""Classes for making technical stock analysis easier."""
from .stock_analyzer import StockAnalyzer, AssetGroupAnalyzer
from .stock_modeler import StockModeler
from .stock_reader import StockReader
from .stock_visualizer import \
StockVisualizer, AssetGroupVisualizer
重要提示
stock_analysis/__init__.py文件中的代码使我们更容易访问包中的类——例如,我们不需要运行stock_analysis.stock_reader.StockReader(),只需运行stock_analysis.StockReader()即可创建一个StockReader对象。
接下来,我们将通过提供数据收集的开始日期和(可选的)结束日期,创建StockReader类的实例。我们将使用 2019-2020 年的数据。请注意,当我们运行此代码时,Python 会调用StockReader.__init__()方法:
>>> reader = \
... stock_analysis.StockReader('2019-01-01', '2020-12-31')
现在,我们将收集Facebook、Apple、Amazon、Netflix 和 Google(FAANG)、S&P 500 和比特币数据。由于我们使用的所有股票的价格都是以美元计价的,因此我们会请求以美元计价的比特币数据。请注意,我们使用了生成器表达式和多重赋值来获取每个 FAANG 股票的数据框:
>>> fb, aapl, amzn, nflx, goog = (
... reader.get_ticker_data(ticker)
... for ticker in ['FB', 'AAPL', 'AMZN', 'NFLX', 'GOOG']
... )
>>> sp = reader.get_index_data('S&P 500')
>>> bitcoin = reader.get_bitcoin_data('USD')
提示
确保运行help(stock_analysis.StockReader)或help(reader),以查看所有已定义的方法和属性。输出会清楚地标明哪些方法是类方法,并将属性列在底部的data descriptors部分。这是熟悉新代码的重要步骤。
探索性数据分析
现在我们有了数据,我们想要熟悉它。正如我们在第五章《使用 Pandas 和 Matplotlib 进行数据可视化》和第六章《使用 Seaborn 绘图与自定义技巧》中看到的,创建好的可视化需要了解matplotlib,并且——根据数据格式和可视化的最终目标——还需要了解seaborn。就像我们在StockReader类中做的那样,我们希望让用户更容易地可视化单个资产和资产组,因此,我们不会指望我们包的用户(以及可能的合作者)精通matplotlib和seaborn,而是将围绕这些功能创建包装器。这意味着用户只需能够使用stock_analysis包来可视化他们的财务数据。此外,我们能够为可视化的外观设定标准,避免在进行每次新分析时复制和粘贴大量代码,从而带来一致性和效率的提升。
为了使这一切成为可能,我们在stock_analysis/stock_visualizer.py中有Visualizer类。这个文件中有三个类:
-
Visualizer:这是定义Visualizer对象功能的基类。大多数方法是抽象的,这意味着从这个父类继承的子类(子类)需要重写这些方法并实现代码;这些方法定义了对象应该做什么,但不涉及具体细节。 -
StockVisualizer:这是我们将用来可视化单个资产的子类。 -
AssetGroupVisualizer:这是我们将用来通过groupby()操作可视化多个资产的子类。
在讨论这些类的代码之前,让我们先来看一下stock_analysis/utils.py文件中的一些附加函数,这些函数将帮助我们创建这些资产组并为 EDA 目的描述它们。对于这些函数,我们需要导入pandas:
import pandas as pd
group_stocks()函数接受一个字典,字典将资产的名称映射到该资产的数据框,并输出一个新的数据框,包含输入数据框的所有数据以及一列新数据,标明数据属于哪个资产:
def group_stocks(mapping):
"""
Create a new dataframe with many assets and a new column
indicating the asset that row's data belongs to.
Parameters:
- mapping: A key-value mapping of the form
{asset_name: asset_df}
Returns:
A new `pandas.DataFrame` object
"""
group_df = pd.DataFrame()
for stock, stock_data in mapping.items():
df = stock_data.copy(deep=True)
df['name'] = stock
group_df = group_df.append(df, sort=True)
group_df.index = pd.to_datetime(group_df.index)
return group_df
由于在整个包中,我们将有许多方法和函数期望它们的数据框具有特定格式,因此我们将构建一个新的装饰器:@validate_df。这个装饰器检查传递给给定方法或函数的输入是否为DataFrame类型的对象,并且至少包含装饰器columns参数指定的列。我们将以set对象的形式提供这些列。这样我们就可以检查我们需要的列与输入数据中的列之间的集合差异(参见第四章,聚合 Pandas 数据框,了解集合操作)。如果数据框包含我们要求的列(至少),那么集合差异将为空,这意味着数据框通过了测试。如果违反了这些条件,装饰器将抛出ValueError。
让我们来看一下在stock_analysis/utils.py文件中是如何定义的:
def validate_df(columns, instance_method=True):
"""
Decorator that raises a `ValueError` if input isn't a
`DataFrame` or doesn't contain the proper columns. Note
the `DataFrame` must be the first positional argument
passed to this method.
Parameters:
- columns: A set of required column names.
For example, {'open', 'high', 'low', 'close'}.
- instance_method: Whether or not the item being
decorated is an instance method. Pass `False` to
decorate static methods and functions.
Returns:
A decorated method or function.
"""
def method_wrapper(method):
@wraps(method)
def validate_wrapper(self, *args, **kwargs):
# functions and static methods don't pass self so
# self is the 1st positional argument in that case
df = (self, *args)[0 if not instance_method else 1]
if not isinstance(df, pd.DataFrame):
raise ValueError(
'Must pass in a pandas `DataFrame`'
)
if columns.difference(df.columns):
raise ValueError(
'Dataframe must contain the following'
f' columns: {columns}'
)
return method(self, *args, **kwargs)
return validate_wrapper
return method_wrapper
使用group_stocks()函数创建的组可以通过describe_group()函数在一个输出中进行描述。group_stocks()函数添加了一个名为name的列,而describe_group()会查找这个列,因此我们使用@validate_df装饰器确保格式正确,然后再尝试运行该函数:
@validate_df(columns={'name'}, instance_method=False)
def describe_group(data):
"""
Run `describe()` on the asset group.
Parameters:
- data: Grouped data resulting from `group_stocks()`
Returns:
The transpose of the grouped description statistics.
"""
return data.groupby('name').describe().T
让我们使用group_stocks()函数为我们的分析创建一些资产组:
>>> from stock_analysis.utils import \
... group_stocks, describe_group
>>> faang = group_stocks({
... 'Facebook': fb, 'Apple': aapl, 'Amazon': amzn,
... 'Netflix': nflx, 'Google': goog
... })
>>> faang_sp = group_stocks({
... 'Facebook': fb, 'Apple': aapl, 'Amazon': amzn,
... 'Netflix': nflx, 'Google': goog, 'S&P 500': sp
... })
>>> all_assets = group_stocks({
... 'Bitcoin': bitcoin, 'S&P 500': sp, 'Facebook': fb,
... 'Apple': aapl, 'Amazon': amzn, 'Netflix': nflx,
... 'Google': goog
... })
使用这些组,describe()的输出在比较时要比分别对每个数据框运行它更有信息量。describe_group()函数通过groupby()来运行describe()。这使得查看不同资产的收盘价汇总更加方便:
>>> describe_group(all_assets).loc['close',]
一眼看去,我们可以看到比特币的数据比其他资产更多。这是因为比特币的价格每天都会变化,而股票数据只包含交易日的数据。我们还可以从中得出一个结论,即比特币不仅波动性大,而且价值远高于其他资产:
![图 7.6 – 各金融工具的收盘价汇总统计]
](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_7.6_B16834.jpg)
图 7.6 – 各金融工具的收盘价汇总统计
如果我们不想单独查看每个资产,我们可以将它们组合成一个投资组合,视其为一个单一资产。stock_analysis/utils.py中的make_portfolio()函数按日期对数据进行分组并对所有列求和,从而得出我们投资组合的总股价和交易量:
@validate_df(columns=set(), instance_method=False)
def make_portfolio(data, date_level='date'):
"""
Make a portfolio of assets by grouping by date and
summing all columns.
Note: the caller is responsible for making sure the
dates line up across assets and handling when they don't.
"""
return data.groupby(level=date_level).sum()
该函数假设资产是以相同的频率进行交易的。比特币每天交易,而股市则不是。因此,如果我们的投资组合是比特币和股市的混合体,在使用此函数之前,我们需要决定如何处理这一差异;请参考我们在第三章中关于重新索引的讨论,使用 Pandas 进行数据清洗,以寻找可能的策略。我们将在本章末尾的练习中使用此函数,构建一个由 FAANG 股票组成的投资组合,这些股票的交易频率相同,以便观察盘后交易对整个 FAANG 股票的影响。
可视化器类族
正如我们从前几章中了解到的那样,可视化将使我们的分析变得更加轻松,因此让我们开始讨论 stock_analysis/stock_visualizer.py 中的 Visualizer 类。首先,我们将定义我们的基础类 Visualizer。以下的 UML 图告诉我们这是我们的基础类,因为它有箭头指向它,这些箭头来自子类(AssetGroupVisualizer 和 StockVisualizer):

图 7.7 – 可视化器类层次结构
图 7.7 还告诉我们将为本节中的每个类定义哪些方法。这包括可视化盘后交易影响(after_hours_trades())和资产价格随时间变化(evolution_over_time())的方法,我们将用它们来进行资产的可视化比较。
我们以文档字符串和导入开始模块。对于我们的可视化,我们将需要 matplotlib、numpy、pandas 和 seaborn,以及 mplfinance(一个用于金融可视化的 matplotlib 派生包):
"""Visualize financial instruments."""
import math
import matplotlib.pyplot as plt
import mplfinance as mpf
import numpy as np
import pandas as pd
import seaborn as sns
from .utils import validate_df
接下来,我们开始定义 Visualizer 类。这个类将保存将用于可视化的数据,因此我们将其放入 __init__() 方法中:
class Visualizer:
"""Base visualizer class not intended for direct use."""
@validate_df(columns={'open', 'high', 'low', 'close'})
def __init__(self, df):
"""Store the input data as an attribute."""
self.data = df
该基础类将为我们提供调用 matplotlib 函数所需的功能;静态方法不依赖于类的数据。我们使用 @staticmethod 装饰器定义 add_reference_line() 方法,用于添加水平或垂直线(以及介于两者之间的任何内容);注意,我们没有将 self 或 cls 作为第一个参数:
@staticmethod
def add_reference_line(ax, x=None, y=None, **kwargs):
"""
Static method for adding reference lines to plots.
Parameters:
- ax: `Axes` object to add the reference line to.
- x, y: The x, y value to draw the line at as a
single value or numpy array-like structure.
- For horizontal: pass only `y`
- For vertical: pass only `x`
- For AB line: pass both `x` and `y`
- kwargs: Additional keyword args. to pass down.
Returns:
The matplotlib `Axes` object passed in.
"""
try:
# numpy array-like structures -> AB line
if x.shape and y.shape:
ax.plot(x, y, **kwargs)
except:
# error triggers if x or y isn't array-like
try:
if not x and not y:
raise ValueError(
'You must provide an `x` or a `y`'
)
elif x and not y:
ax.axvline(x, **kwargs) # vertical line
elif not x and y:
ax.axhline(y, **kwargs) # horizontal line
except:
raise ValueError(
'If providing only `x` or `y`, '
'it must be a single value'
)
ax.legend()
return ax
提示
有关类方法、静态方法和抽象方法的更多信息,请参见进一步阅读部分。
shade_region() 静态方法用于向图表添加阴影区域,它类似于 add_reference_line() 静态方法:
@staticmethod
def shade_region(ax, x=tuple(), y=tuple(), **kwargs):
"""
Static method for shading a region on a plot.
Parameters:
- ax: `Axes` object to add the shaded region to.
- x: Tuple with the `xmin` and `xmax` bounds for
the rectangle drawn vertically.
- y: Tuple with the `ymin` and `ymax` bounds for
the rectangle drawn horizontally.
- kwargs: Additional keyword args. to pass down.
Returns:
The matplotlib `Axes` object passed in.
"""
if not x and not y:
raise ValueError(
'You must provide an x or a y min/max tuple'
)
elif x and y:
raise ValueError('You can only provide x or y.')
elif x and not y:
ax.axvspan(*x, **kwargs) # vertical region
elif not x and y:
ax.axhspan(*y, **kwargs) # horizontal region
return ax
由于我们希望我们的绘图功能具有灵活性,我们将定义一个静态方法,使得我们可以轻松地绘制一个或多个项目,而无需事先检查项目的数量。这将在我们使用 Visualizer 类作为基础构建的类中得到应用:
@staticmethod
def _iter_handler(items):
"""
Static method for making a list out of an item if
it isn't a list or tuple already.
Parameters:
- items: The variable to make sure it is a list.
Returns: The input as a list or tuple.
"""
if not isinstance(items, (list, tuple)):
items = [items]
return items
我们希望支持单一资产和资产组的窗口函数;然而,这一实现会有所不同,因此我们将在超类中定义一个抽象方法(一个没有实现的方法),子类将重写它以提供具体实现:
def _window_calc(self, column, periods, name, func,
named_arg, **kwargs):
"""
To be implemented by subclasses. Defines how to add
lines resulting from window calculations.
"""
raise NotImplementedError('To be implemented by '
'subclasses.')
这样我们就可以定义依赖于_window_calc()的功能,但不需要知道具体实现,只需要知道结果。moving_average()方法使用_window_calc()将移动平均线添加到图表中:
def moving_average(self, column, periods, **kwargs):
"""
Add line(s) for the moving average of a column.
Parameters:
- column: The name of the column to plot.
- periods: The rule or list of rules for
resampling, like '20D' for 20-day periods.
- kwargs: Additional arguments to pass down.
Returns: A matplotlib `Axes` object.
"""
return self._window_calc(
column, periods, name='MA', named_arg='rule',
func=pd.DataFrame.resample, **kwargs
)
类似地,我们定义了exp_smoothing()方法,它将使用_window_calc()将指数平滑的移动平均线添加到图表中:
def exp_smoothing(self, column, periods, **kwargs):
"""
Add line(s) for the exponentially smoothed moving
average of a column.
Parameters:
- column: The name of the column to plot.
- periods: The span or list of spans for,
smoothing like 20 for 20-day periods.
- kwargs: Additional arguments to pass down.
Returns:
A matplotlib `Axes` object.
"""
return self._window_calc(
column, periods, name='EWMA',
func=pd.DataFrame.ewm, named_arg='span', **kwargs
)
请注意,虽然我们有方法可以将移动平均和指数平滑移动平均添加到列的图表中,但它们都调用了_window_calc(),该方法在此处未定义。这是因为每个子类都会有自己的_window_calc()实现,而它们将继承顶层方法,无需重写moving_average()或exp_smoothing()。
重要提示
请记住,以单个下划线(_)开头的方法是 Python 对该类对象的help()版本。我们将_window_calc()作为私有方法创建,因为Visualizer类的用户只需要调用moving_average()和exp_smoothing()。
最后,我们将为所有子类添加占位符方法。这些是抽象方法,将由每个子类单独定义,因为实现会根据我们是可视化单一资产还是资产组而有所不同。为了简洁,以下是该类中定义的一部分抽象方法:
def evolution_over_time(self, column, **kwargs):
"""Creates line plots."""
raise NotImplementedError('To be implemented by '
'subclasses.')
def after_hours_trades(self):
"""Show the effect of after-hours trading."""
raise NotImplementedError('To be implemented by '
'subclasses.')
def pairplot(self, **kwargs):
"""Create pairplots."""
raise NotImplementedError('To be implemented by '
'subclasses.')
子类还将定义它们特有的方法,并/或根据需要重写Visualizer类的实现。它们没有重写的方法将会继承。通过使用Visualizer来定义所有Visualizers应做的事情,然后提供更具体的版本,例如仅处理单一资产的StockVisualizer类。
可视化股票
我们将通过继承Visualizer类来开始实现StockVisualizer类;我们选择不重写__init__()方法,因为StockVisualizer类只会有一个数据框作为属性。相反,我们将为需要添加(该类特有的)或重写的方法提供实现。
重要提示
为了简洁起见,我们只涵盖一部分功能;然而,我强烈建议你阅读完整的代码库并在笔记本中测试功能。
我们将重写的第一个方法是evolution_over_time(),它将创建一个随着时间变化的列的折线图:
class StockVisualizer(Visualizer):
"""Visualizer for a single stock."""
def evolution_over_time(self, column, **kwargs):
"""
Visualize the evolution over time of a column.
Parameters:
- column: The name of the column to visualize.
- kwargs: Additional arguments to pass down.
Returns:
A matplotlib `Axes` object.
"""
return self.data.plot.line(y=column, **kwargs)
接下来,我们将使用 mplfinance 创建一个蜡烛图,这是一种将 OHLC 数据一起可视化的方法。每一行 OHLC 时间序列将被绘制为一根蜡烛。当蜡烛图为黑色时,资产的收盘价低于开盘价(表示亏损);当蜡烛图为白色时,资产的收盘价高于开盘价,如下图所示:

图 7.8 – 理解蜡烛图
candlestick() 方法还提供了重新采样数据、显示交易量和绘制特定日期范围的选项:
def candlestick(self, date_range=None, resample=None,
volume=False, **kwargs):
"""
Create a candlestick plot for the OHLC data.
Parameters:
- date_range: String or `slice()` of dates to
pass to `loc[]`, if `None` the plot will be
for the full range of the data.
- resample: The offset to use for resampling
the data, if desired.
- volume: Whether to show a bar plot for volume
traded under the candlesticks
- kwargs: Keyword args for `mplfinance.plot()`
"""
if not date_range:
date_range = slice(
self.data.index.min(), self.data.index.max()
)
plot_data = self.data.loc[date_range]
if resample:
agg_dict = {
'open': 'first', 'close': 'last',
'high': 'max', 'low': 'min', 'volume': 'sum'
}
plot_data = plot_data.resample(resample).agg({
col: agg_dict[col] for col in plot_data.columns
if col in agg_dict
})
mpf.plot(
plot_data, type='candle', volume=volume, **kwargs
)
现在,我们添加 after_hours_trades() 方法,帮助我们可视化盘后交易对单个资产的影响,亏损部分用红色条形图表示,盈利部分用绿色条形图表示:
def after_hours_trades(self):
"""
Visualize the effect of after-hours trading.
Returns: A matplotlib `Axes` object.
"""
after_hours = self.data.open - self.data.close.shift()
monthly_effect = after_hours.resample('1M').sum()
fig, axes = plt.subplots(1, 2, figsize=(15, 3))
after_hours.plot(
ax=axes[0],
title='After-hours trading\n'
'(Open Price - Prior Day\'s Close)'
).set_ylabel('price')
monthly_effect.index = \
monthly_effect.index.strftime('%Y-%b')
monthly_effect.plot(
ax=axes[1], kind='bar', rot=90,
title='After-hours trading monthly effect',
color=np.where(monthly_effect >= 0, 'g', 'r')
).axhline(0, color='black', linewidth=1)
axes[1].set_ylabel('price')
return axes
接下来,我们将添加一个静态方法,让我们可以填充两条曲线之间的区域。fill_between() 方法将使用 plt.fill_between() 根据哪条曲线较高来为区域上色,绿色或红色:
@staticmethod
def fill_between(y1, y2, title, label_higher, label_lower,
figsize, legend_x):
"""
Visualize the difference between assets.
Parameters:
- y1, y2: Data to plot, filling y2 - y1.
- title: The title for the plot.
- label_higher: Label for when y2 > y1.
- label_lower: Label for when y2 <= y1.
- figsize: (width, height) for the plot dimensions.
- legend_x: Where to place legend below the plot.
Returns: A matplotlib `Axes` object.
"""
is_higher = y2 - y1 > 0
fig = plt.figure(figsize=figsize)
for exclude_mask, color, label in zip(
(is_higher, np.invert(is_higher)),
('g', 'r'),
(label_higher, label_lower)
):
plt.fill_between(
y2.index, y2, y1, figure=fig,
where=exclude_mask, color=color, label=label
)
plt.suptitle(title)
plt.legend(
bbox_to_anchor=(legend_x, -0.1),
framealpha=0, ncol=2
)
for spine in ['top', 'right']:
fig.axes[0].spines[spine].set_visible(False)
return fig.axes[0]
open_to_close() 方法将帮助我们通过 fill_between() 静态方法可视化每日开盘价与收盘价之间的差异。如果收盘价高于开盘价,我们会将区域涂成绿色;如果相反,则涂成红色:
def open_to_close(self, figsize=(10, 4)):
"""
Visualize the daily change in price from open to close.
Parameters:
- figsize: (width, height) of plot
Returns:
A matplotlib `Axes` object.
"""
ax = self.fill_between(
self.data.open, self.data.close,
figsize=figsize, legend_x=0.67,
title='Daily price change (open to close)',
label_higher='price rose', label_lower='price fell'
)
ax.set_ylabel('price')
return ax
除了可视化单个资产的开盘价与收盘价之间的差异外,我们还希望比较不同资产之间的价格。fill_between_other() 方法将帮助我们可视化我们为某个资产创建的可视化工具与另一个资产之间的差异,再次使用 fill_between()。当可视化工具中的资产价格高于另一个资产时,我们会将差异部分标为绿色,低于另一个资产时则标为红色:
def fill_between_other(self, other_df, figsize=(10, 4)):
"""
Visualize difference in closing price between assets.
Parameters:
- other_df: The other asset's data.
- figsize: (width, height) for the plot.
Returns:
A matplotlib `Axes` object.
"""
ax = self.fill_between(
other_df.open, self.data.close, figsize=figsize,
legend_x=0.7, label_higher='asset is higher',
label_lower='asset is lower',
title='Differential between asset price '
'(this - other)'
)
ax.set_ylabel('price')
return ax
终于到了重写 _window_calc() 方法的时候,它定义了如何根据单个资产的窗口计算来添加参考线。注意,我们如何能够使用 pipe() 方法(在 第四章 中介绍,聚合 Pandas DataFrame)使我们的窗口计算图与不同的函数兼容,以及如何使用 _iter_handler() 方法使我们的循环在不检查是否有多个参考线需要绘制的情况下工作:
def _window_calc(self, column, periods, name, func,
named_arg, **kwargs):
"""
Helper method for plotting a series and adding
reference lines using a window calculation.
Parameters:
- column: The name of the column to plot.
- periods: The rule/span or list of them to pass
to the resampling/smoothing function, like '20D'
for 20-day periods (resampling) or 20 for a
20-day span (smoothing)
- name: The name of the window calculation (to
show in the legend).
- func: The window calculation function.
- named_arg: The name of the argument `periods`
is being passed as.
- kwargs: Additional arguments to pass down.
Returns:
A matplotlib `Axes` object.
"""
ax = self.data.plot(y=column, **kwargs)
for period in self._iter_handler(periods):
self.data[column].pipe(
func, **{named_arg: period}
).mean().plot(
ax=ax, linestyle='--',
label=f"""{period if isinstance(
period, str
) else str(period) + 'D'} {name}"""
)
plt.legend()
return ax
到目前为止,每个可视化都涉及单一资产的数据;然而,有时我们希望能够可视化资产之间的关系,因此我们将围绕 seaborn 的 jointplot() 函数构建一个封装器:
def jointplot(self, other, column, **kwargs):
"""
Generate a seaborn jointplot for given column in
this asset compared to another asset.
Parameters:
- other: The other asset's dataframe.
- column: Column to use for the comparison.
- kwargs: Keyword arguments to pass down.
Returns: A seaborn jointplot
"""
return sns.jointplot(
x=self.data[column], y=other[column], **kwargs
)
观察资产之间关系的另一种方式是相关矩阵。DataFrame 对象有一个 corrwith() 方法,可以计算每列与另一个数据框中相同列(按名称)的相关系数。这并不能填充热图所需的矩阵,正如我们在前几章所看到的;它实际上是对角线。correlation_heatmap() 方法创建一个矩阵供 sns.heatmap() 函数使用,并用相关系数填充对角线;然后,它通过遮罩确保仅显示对角线。此外,在计算相关性时,我们将使用每列的每日百分比变化,以处理规模差异(例如,苹果股票价格与亚马逊股票价格之间的差异):
def correlation_heatmap(self, other):
"""
Plot the correlations between this asset and another
one with a heatmap.
Parameters:
- other: The other dataframe.
Returns: A seaborn heatmap
"""
corrs = \
self.data.pct_change().corrwith(other.pct_change())
corrs = corrs[~pd.isnull(corrs)]
size = len(corrs)
matrix = np.zeros((size, size), float)
for i, corr in zip(range(size), corrs):
matrix[i][i] = corr
# create mask to only show diagonal
mask = np.ones_like(matrix)
np.fill_diagonal(mask, 0)
return sns.heatmap(
matrix, annot=True, center=0, vmin=-1, vmax=1,
mask=mask, xticklabels=self.data.columns,
yticklabels=self.data.columns
)
现在我们已经了解了 StockVisualizer 类的一些功能,我们可以开始进行探索性分析。让我们创建一个 StockVisualizer 对象,对 Netflix 股票数据进行一些 EDA:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> netflix_viz = stock_analysis.StockVisualizer(nflx)
一旦我们用 Netflix 数据框初始化了 StockVisualizer 对象,我们就可以生成许多不同类型的图表。我们不会一一举例说明这个对象可以做什么(这留给你自己去实验),但让我们看看随时间变化的收盘价与一些移动平均线,以研究趋势:
>>> ax = netflix_viz.moving_average('close', ['30D', '90D'])
>>> netflix_viz.shade_region(
... ax, x=('2019-10-01', '2020-07-01'),
... color='blue', alpha=0.1
... )
>>> ax.set(title='Netflix Closing Price', ylabel='price ($)')
这些移动平均线给我们提供了股价曲线的平滑版本。请注意,在阴影区域内,90 日移动平均线像是股票价格的天花板:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_7.9_B16834.jpg)
图 7.9 – Netflix 股票价格与移动平均线
交易者根据手头任务的不同,尝试使用不同周期的移动平均线,例如预测股价上涨(股价上涨)并在股价下跌前做出计划性退出(股价下跌)。其他用途包括通过找到支撑线和阻力线来自动计算支撑位和阻力位(我们在 第六章 ,使用 Seaborn 进行绘图与自定义技巧 中首次看到),通过找到支撑线支撑数据的部分,或找到作为数据天花板的部分。当股价接近支撑位时,价格通常会足够吸引人,促使人们买入,从而推动股价上涨(从支撑位向阻力位移动)。然而,当股价达到阻力位时,通常会促使人们卖出,导致股价下跌(从阻力位远离,向支撑位靠近)。
图 7.10 展示了支撑位(绿色)和阻力位(红色)如何分别作为股票价格的下限和上限;一旦价格触及这些边界之一,它通常会因为股票买卖双方的行动而反弹到相反方向:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_7.10_B16834.jpg)
图 7.10 – 2018 年 Netflix 股票的支撑位和阻力位示例
通常,指数加权移动平均线(EWMA)可以提供更好的趋势,因为我们可以对最近的值给予更多的权重。让我们来看看对数据进行指数平滑处理的效果:
>>> ax = netflix_viz.exp_smoothing('close', [30, 90])
>>> netflix_viz.shade_region(
... ax, x=('2020-04-01', '2020-10-01'),
... color='blue', alpha=0.1
... )
>>> ax.set(title='Netflix Closing Price', ylabel='price ($)')
90 天的 EWMA 看起来在阴影区域内充当了支撑位:

图 7.11 – Netflix 股票价格与 EWMAs
提示
该笔记本包含了一个用于交互式可视化移动平均线和指数加权移动平均线(EWMA)的单元格。我们可以使用这些类型的可视化来确定计算的最佳窗口。请注意,使用此单元格可能需要一些额外的设置,但相关设置已在单元格上方标明。
在 第五章《使用 Pandas 和 Matplotlib 可视化数据》的练习中,我们编写了生成可视化图表的代码,展示了盘后交易对 Facebook 的影响;StockVisualizer 类也具备这个功能。我们使用 after_hours_trades() 方法来查看 Netflix 的盘后交易表现:
>>> netflix_viz.after_hours_trades()
Netflix 在 2019 年第三季度的盘后交易表现不佳:

图 7.12 – 可视化盘后交易对 Netflix 股票的影响
我们可以使用蜡烛图来研究 OHLC 数据。我们将使用 candlestick() 方法为 Netflix 创建一个蜡烛图,并同时绘制交易量的条形图。我们还将把数据重新采样为 2 周的时间间隔,以便更清晰地展示蜡烛图:
>>> netflix_viz.candlestick(
... resample='2W', volume=True, xrotation=90,
... datetime_format='%Y-%b –'
... )
从 图 7.8 中记得,当蜡烛图的实体为白色时,意味着股票价值上涨。请注意,大多数情况下,交易量的尖峰伴随着股票价值的上涨:

图 7.13 – 带有交易量的蜡烛图
提示
交易者使用蜡烛图来寻找并分析资产表现中的模式,这些模式可以帮助做出交易决策。查看这篇文章,了解蜡烛图以及交易者常见的分析模式:www.investopedia.com/trading/candlestick-charting-what-is-it/。
在继续之前,我们需要重置图表的样式。mplfinance 包提供了许多可用的样式选项,因此我们暂时返回到我们熟悉的样式:
>>> import matplotlib as mpl
>>> mpl.rcdefaults()
>>> %matplotlib inline
在之前的章节中,我们已经单独看过一只股票(Facebook),所以我们接下来将从另一个角度进行比较,把 Netflix 和其他股票做对比。我们使用 jointplot() 方法来看 Netflix 与标普 500 的对比:
>>> netflix_viz.jointplot(sp, 'close')
如果我们看一下图表,它们似乎有较弱的正相关关系。在金融分析中,我们可以计算一个叫做beta的指标,表示某个资产与一个指数(例如 S&P 500)之间的相关性。我们将在本章后面的金融工具的技术分析部分计算 beta:

图 7.14 – 将 Netflix 与 S&P 500 进行比较
我们可以使用correlation_heatmap()方法,将 Netflix 和 Amazon 之间的相关性可视化为热力图,使用每列的日百分比变化:
>>> netflix_viz.correlation_heatmap(amzn)
Netflix 和 Amazon 之间存在弱正相关,但仅在 OHLC 数据中:

图 7.15 – Netflix 与 Amazon 的相关性热力图
最后,我们可以使用fill_between_other()方法查看另一项资产与 Netflix 相比在价格上的增长(或下降)。在这里,我们将 Netflix 与特斯拉进行比较,看看一个股票超过另一个股票的例子:
>>> tsla = reader.get_ticker_data('TSLA')
>>> change_date = (tsla.close > nflx.close).idxmax()
>>> ax = netflix_viz.fill_between_other(tsla)
>>> netflix_viz.add_reference_line(
... ax, x=change_date, color='k', linestyle=':', alpha=0.5,
... label=f'TSLA > NFLX {change_date:%Y-%m-%d}'
... )
请注意,随着阴影区域接近参考线,其高度逐渐缩小——这表示 Netflix 股票和特斯拉股票之间的差异随着时间的推移而减小。在 2020 年 11 月 11 日,当特斯拉超过 Netflix 时,阴影区域的颜色发生变化(从绿色变为红色),并开始增高,因为特斯拉拉大了差距:

图 7.16 – Netflix 与特斯拉的股票价格差异
到目前为止,我们讨论了如何可视化单一资产——在这种情况下是 Netflix——接下来我们将继续,看看如何使用AssetGroupVisualizer类在资产组之间进行一些 EDA 分析。
可视化多个资产
就像之前那样,我们将从Visualizer类继承并编写我们的文档字符串。请注意,AssetGroupVisualizer类还会跟踪用于groupby()操作的列,因此我们会重写__init__()方法;由于这个更改是对已有代码的补充,因此我们也会调用父类的__init__()方法:
class AssetGroupVisualizer(Visualizer):
"""Visualizes groups of assets in a single dataframe."""
# override for group visuals
def __init__(self, df, group_by='name'):
"""This object keeps track of the group by column."""
super().__init__(df)
self.group_by = group_by
接下来,我们定义evolution_over_time()方法,以便在单个图中绘制资产组中所有资产的相同列,进行对比分析。由于我们的数据形状不同,这次我们将使用seaborn:
def evolution_over_time(self, column, **kwargs):
"""
Visualize the evolution over time for all assets.
Parameters:
- column: The name of the column to visualize.
- kwargs: Additional arguments to pass down.
Returns: A matplotlib `Axes` object.
"""
if 'ax' not in kwargs:
fig, ax = plt.subplots(1, 1, figsize=(10, 4))
else:
ax = kwargs.pop('ax')
return sns.lineplot(
x=self.data.index, y=column, hue=self.group_by,
data=self.data, ax=ax, **kwargs
)
当使用seaborn或仅绘制单个资产时,我们不需要担心子图的布局;然而,对于一些其他资产组的可视化,我们需要一种方法来自动确定合理的子图布局。为此,我们将添加_get_layout()方法,该方法将为给定数量的子图生成所需的Figure和Axes对象(由资产组中唯一资产的数量决定):
def _get_layout(self):
"""
Helper method for getting an autolayout of subplots.
Returns: `Figure` and `Axes` objects to plot with.
"""
subplots_needed = self.data[self.group_by].nunique()
rows = math.ceil(subplots_needed / 2)
fig, axes = \
plt.subplots(rows, 2, figsize=(15, 5 * rows))
if rows > 1:
axes = axes.flatten()
if subplots_needed < len(axes):
# remove excess axes from autolayout
for i in range(subplots_needed, len(axes)):
# can't use comprehension here
fig.delaxes(axes[i])
return fig, axes
现在,我们需要定义 _window_calc() 如何与资产组一起工作。我们需要使用 _get_layout() 方法为组中的每个资产构建子图:
def _window_calc(self, column, periods, name, func,
named_arg, **kwargs):
"""
Helper method for plotting a series and adding
reference lines using a window calculation.
Parameters:
- column: The name of the column to plot.
- periods: The rule/span or list of them to pass
to the resampling/smoothing function, like '20D'
for 20-day periods (resampling) or 20 for a
20-day span (smoothing)
- name: The name of the window calculation (to
show in the legend).
- func: The window calculation function.
- named_arg: The name of the argument `periods`
is being passed as.
- kwargs: Additional arguments to pass down.
Returns:
A matplotlib `Axes` object.
"""
fig, axes = self._get_layout()
for ax, asset_name in zip(
axes, self.data[self.group_by].unique()
):
subset = self.data.query(
f'{self.group_by} == "{asset_name}"'
)
ax = subset.plot(
y=column, ax=ax, label=asset_name, **kwargs
)
for period in self._iter_handler(periods):
subset[column].pipe(
func, **{named_arg: period}
).mean().plot(
ax=ax, linestyle='--',
label=f"""{period if isinstance(
period, str
) else str(period) + 'D'} {name}"""
)
ax.legend()
plt.tight_layout()
return ax
我们可以重写 after_hours_trades() 来可视化盘后交易对资产组的影响,方法是使用子图并遍历组中的资产:
def after_hours_trades(self):
"""
Visualize the effect of after-hours trading.
Returns: A matplotlib `Axes` object.
"""
num_categories = self.data[self.group_by].nunique()
fig, axes = plt.subplots(
num_categories, 2, figsize=(15, 3 * num_categories)
)
for ax, (name, data) in zip(
axes, self.data.groupby(self.group_by)
):
after_hours = data.open - data.close.shift()
monthly_effect = after_hours.resample('1M').sum()
after_hours.plot(
ax=ax[0],
title=f'{name} Open Price - Prior Day\'s Close'
).set_ylabel('price')
monthly_effect.index = \
monthly_effect.index.strftime('%Y-%b')
monthly_effect.plot(
ax=ax[1], kind='bar', rot=90,
color=np.where(monthly_effect >= 0, 'g', 'r'),
title=f'{name} after-hours trading '
'monthly effect'
).axhline(0, color='black', linewidth=1)
ax[1].set_ylabel('price')
plt.tight_layout()
return axes
使用 StockVisualizer 类,我们能够生成两只资产收盘价之间的联合图,但在这里我们可以重写 pairplot(),以便查看资产组中资产之间收盘价的关系:
def pairplot(self, **kwargs):
"""
Generate a seaborn pairplot for this asset group.
Parameters:
- kwargs: Keyword arguments to pass down.
Returns: A seaborn pairplot
"""
return sns.pairplot(
self.data.pivot_table(
values='close', index=self.data.index,
columns=self.group_by
), diag_kind='kde', **kwargs
)
最后,我们添加 heatmap() 方法,它生成所有资产组中收盘价之间相关性的热图:
def heatmap(self, pct_change=True, **kwargs):
"""
Generate a heatmap for correlations between assets.
Parameters:
- pct_change: Whether to show the correlations
of the daily percent change in price.
- kwargs: Keyword arguments to pass down.
Returns: A seaborn heatmap
"""
pivot = self.data.pivot_table(
values='close', index=self.data.index,
columns=self.group_by
)
if pct_change:
pivot = pivot.pct_change()
return sns.heatmap(
pivot.corr(), annot=True, center=0,
vmin=-1, vmax=1, **kwargs
)
我们可以使用 heatmap() 方法查看资产之间的日变化百分比对比。这将处理资产间的规模差异(谷歌和亚马逊的股价远高于 Facebook 和苹果,这意味着几美元的涨幅对 Facebook 和苹果来说影响更大):
>>> all_assets_viz = \
... stock_analysis.AssetGroupVisualizer(all_assets)
>>> all_assets_viz.heatmap()
苹果与标准普尔 500 指数、Facebook 与谷歌之间有最强的相关性,而比特币与任何资产都没有相关性:

图 7.17 – 资产价格之间的相关性
为了简洁起见,避免展示所有可视化资产组的方法(这些方法会生成大量图形),我将把这部分留给你在笔记本中查看并尝试。不过,让我们结合这些 Visualizers 来看看所有资产随时间的演变:
>>> faang_sp_viz = \
... stock_analysis.AssetGroupVisualizer(faang_sp)
>>> bitcoin_viz = stock_analysis.StockVisualizer(bitcoin)
>>> fig, axes = plt.subplots(1, 2, figsize=(15, 5))
>>> faang_sp_viz.evolution_over_time(
... 'close', ax=axes[0], style=faang_sp_viz.group_by
... )
>>> bitcoin_viz.evolution_over_time(
... 'close', ax=axes[1], label='Bitcoin'
... )
请注意,比特币在 2020 年底大幅上涨(查看 y 轴的刻度),而亚马逊在 2020 年也经历了显著增长:

图 7.18 – 资产价格随时间的变化
现在我们对数据有了充分的了解,我们可以开始查看一些指标了。请注意,虽然我们只查看并使用了部分代码,我鼓励你在本章的笔记本中尝试所有 Visualizer 类的方法;练习题也将提供额外的机会来使用它们。
金融工具的技术分析
在资产的技术分析中,计算一些指标(如累计回报和波动率)来比较不同资产之间的差异。与本章前两部分一样,我们将编写一个包含类的模块来帮助我们。我们将需要 StockAnalyzer 类来分析单一资产的技术指标,和 AssetGroupAnalyzer 类来分析资产组的技术指标。这些类位于 stock_analysis/stock_analyzer.py 文件中。
与其他模块一样,我们将从文档字符串和导入开始:
"""Classes for technical analysis of assets."""
import math
from .utils import validate_df
StockAnalyzer 类
对于单个资产的分析,我们将构建 StockAnalyzer 类来计算给定资产的指标。下图 UML 显示了它提供的所有指标:

图 7.19 – StockAnalyzer 类的结构
StockAnalyzer 实例将使用我们希望进行技术分析的资产数据进行初始化。这意味着我们的 __init__() 方法需要接受数据作为参数:
class StockAnalyzer:
"""Provides metrics for technical analysis of a stock."""
@validate_df(columns={'open', 'high', 'low', 'close'})
def __init__(self, df):
"""Create a `StockAnalyzer` object with OHLC data"""
self.data = df
我们的大部分技术分析计算将依赖于股票的收盘价,因此,为了避免在所有方法中都写 self.data.close,我们将创建一个属性,使我们能够通过 self.close 访问它。这使得我们的代码更加简洁和易于理解:
@property
def close(self):
"""Get the close column of the data."""
return self.data.close
一些计算还需要 close 列的百分比变化,因此我们将为其创建一个属性,方便访问:
@property
def pct_change(self):
"""Get the percent change of the close column."""
return self.close.pct_change()
由于我们将使用枢轴点来计算支撑和阻力水平,枢轴点是数据中最后一天的最高价、最低价和收盘价的平均值,因此我们也会为它创建一个属性:
@property
def pivot_point(self):
"""Calculate the pivot point."""
return (self.last_close + self.last_high
+ self.last_low) / 3
请注意,我们还使用了其他属性——self.last_close、self.last_high 和 self.last_low——这些属性通过在数据上使用 last() 方法定义,然后选择相应的列并使用 iat[] 获取对应的价格:
@property
def last_close(self):
"""Get the value of the last close in the data."""
return self.data.last('1D').close.iat[0]
@property
def last_high(self):
"""Get the value of the last high in the data."""
return self.data.last('1D').high.iat[0]
@property
def last_low(self):
"""Get the value of the last low in the data."""
return self.data.last('1D').low.iat[0]
现在,我们拥有了计算支撑和阻力所需的一切。我们将在三个不同的水平上计算每个值,其中第一个水平离收盘价最近,第三个水平最远。因此,第一个水平是最具限制性的水平,第三个水平则是最不具限制性的。我们将 resistance() 方法定义如下,允许调用者指定计算的级别:
def resistance(self, level=1):
"""Calculate the resistance at the given level."""
if level == 1:
res = (2 * self.pivot_point) - self.last_low
elif level == 2:
res = self.pivot_point \
+ (self.last_high - self.last_low)
elif level == 3:
res = self.last_high \
+ 2 * (self.pivot_point - self.last_low)
else:
raise ValueError('Not a valid level.')
return res
support() 方法的定义方式类似:
def support(self, level=1):
"""Calculate the support at the given level."""
if level == 1:
sup = (2 * self.pivot_point) - self.last_high
elif level == 2:
sup = self.pivot_point \
- (self.last_high - self.last_low)
elif level == 3:
sup = self.last_low \
- 2 * (self.last_high - self.pivot_point)
else:
raise ValueError('Not a valid level.')
return sup
接下来,我们将创建用于分析资产波动性的方法。首先,我们将计算收盘价百分比变化的日标准差,计算时需要指定交易期数。为了确保我们不会使用超出数据中期数的交易期数,我们将定义一个属性,其中包含可以用于此参数的最大值:
@property
def _max_periods(self):
"""Get the number of trading periods in the data."""
return self.data.shape[0]
现在我们已经得到了最大值,我们可以定义 daily_std() 方法,该方法计算每日百分比变化的日标准差:
def daily_std(self, periods=252):
"""
Calculate daily standard deviation of percent change.
Parameters:
- periods: The number of periods to use for the
calculation; default is 252 for the trading days
in a year. Note if you provide a number greater
than the number of trading periods in the data,
`self._max_periods` will be used instead.
Returns: The standard deviation
"""
return self.pct_change\
[min(periods, self._max_periods) * -1:].std()
虽然 daily_std() 本身很有用,但我们可以更进一步,通过将日标准差乘以一年中交易期数的平方根来计算年化波动性,我们假设一年有 252 个交易日:
def annualized_volatility(self):
"""Calculate the annualized volatility."""
return self.daily_std() * math.sqrt(252)
此外,我们可以使用 rolling() 方法来查看滚动波动性:
def volatility(self, periods=252):
"""Calculate the rolling volatility.
Parameters:
- periods: The number of periods to use for the
calculation; default is 252 for the trading
days in a year. Note if you provide a number
greater than the number of trading periods in the
data, `self._max_periods` will be used instead.
Returns: A `pandas.Series` object.
"""
periods = min(periods, self._max_periods)
return self.close.rolling(periods).std()\
/ math.sqrt(periods)
我们经常需要比较不同资产,因此我们提供了 corr_with() 方法,使用每日百分比变化来计算它们之间的相关性:
def corr_with(self, other):
"""Calculate the correlations between dataframes.
Parameters:
- other: The other dataframe.
Returns: A `pandas.Series` object
"""
return \
self.data.pct_change().corrwith(other.pct_change())
接下来,我们定义一些用于比较资产分散程度的指标。在第一章《数据分析入门》中,我们讨论了变异系数(cv()方法)和分位数离散系数(qcd()方法),我们可以利用这些指标来实现此目标,接下来我们将添加这两者:
def cv(self):
"""
Calculate the coefficient of variation for the asset.
The lower this is, the better the risk/return tradeoff.
"""
return self.close.std() / self.close.mean()
def qcd(self):
"""Calculate the quantile coefficient of dispersion."""
q1, q3 = self.close.quantile([0.25, 0.75])
return (q3 - q1) / (q3 + q1)
此外,我们还希望有一种方法来量化资产相对于指数的波动性,例如标准普尔 500 指数(S&P 500),为此我们计算beta()方法,允许用户指定用作基准的指数:
def beta(self, index):
"""
Calculate the beta of the asset.
Parameters:
- index: The data for the index to compare to.
Returns:
Beta, a float.
"""
index_change = index.close.pct_change()
beta = self.pct_change.cov(index_change)\
/ index_change.var()
return beta
接下来,我们定义一个方法来计算资产的累积回报率,作为一个系列。这被定义为一加上收盘价百分比变化的累积乘积:
def cumulative_returns(self):
"""Calculate cumulative returns for plotting."""
return (1 + self.pct_change).cumprod()
接下来我们需要支持的几个指标要求计算投资组合的回报。为了简化问题,我们假设每股没有分红,因此投资组合的回报是从起始价格到结束价格的百分比变化,覆盖的数据时间段内。我们将其定义为静态方法,因为我们需要为一个指数计算该值,而不仅仅是self.data中存储的数据:
@staticmethod
def portfolio_return(df):
"""
Calculate return assuming no distribution per share.
Parameters:
- df: The asset's dataframe.
Returns: The return, as a float.
"""
start, end = df.close[0], df.close[-1]
return (end - start) / start
虽然 beta 可以让我们将资产的波动性与指数进行比较,但alpha使我们能够将资产的回报与指数的回报进行比较。为此,我们还需要无风险回报率,即没有财务损失风险的投资的回报率;在实际操作中,我们通常使用美国国债作为参考。计算 alpha 需要计算指数和资产的投资组合回报以及 beta:
def alpha(self, index, r_f):
"""
Calculates the asset's alpha.
Parameters:
- index: The index to compare to.
- r_f: The risk-free rate of return.
Returns: Alpha, as a float.
"""
r_f /= 100
r_m = self.portfolio_return(index)
beta = self.beta(index)
r = self.portfolio_return(self.data)
alpha = r - r_f - beta * (r_m - r_f)
return alpha
提示
r_f by 100 before storing the result back in r_f. It's shorthand for r_f = r_f / 100. Python also has these operators for other arithmetic functions—for example, +=, -=, *=, and %=.
我们还希望添加一些方法,告诉我们资产是否处于熊市或牛市,即在过去 2 个月内,股票价格分别下跌或上涨了 20%以上:
def is_bear_market(self):
"""
Determine if a stock is in a bear market, meaning its
return in the last 2 months is a decline of 20% or more
"""
return \
self.portfolio_return(self.data.last('2M')) <= -.2
def is_bull_market(self):
"""
Determine if a stock is in a bull market, meaning its
return in the last 2 months is an increase of >= 20%.
"""
return \
self.portfolio_return(self.data.last('2M')) >= .2
最后,我们将添加一个方法来计算夏普比率,该比率告诉我们在承担投资波动性时,相对于无风险回报率,我们所获得的超额回报:
def sharpe_ratio(self, r_f):
"""
Calculates the asset's Sharpe ratio.
Parameters:
- r_f: The risk-free rate of return.
Returns:
The Sharpe ratio, as a float.
"""
return (
self.cumulative_returns().last('1D').iat[0] - r_f
) / self.cumulative_returns().std()
花些时间消化本模块中的代码,因为我们将在之前讨论的基础上继续构建。我们不会使用所有这些指标进行技术分析,但我鼓励你在本章的笔记本中尝试这些方法。
AssetGroupAnalyzer类
本节中我们将使用的所有计算都定义在StockAnalyzer类中;然而,为了避免对每个要比较的资产都进行计算,我们还将创建AssetGroupAnalyzer类(在同一个模块中),该类能够为一组资产提供这些指标。
StockAnalyzer和AssetGroupAnalyzer类将共享它们的大部分功能,这为它们的继承设计提供了强有力的论据;然而,有时——如在这种情况下——组合设计更有意义。当对象包含其他类的实例时,这就是AssetGroupAnalyzer类的情况:

图 7.20 – AssetGroupAnalyzer 类的结构
我们通过提供资产的数据框和分组列的名称(如果不是 name)来创建 AssetGroupAnalyzer 实例。初始化时,会调用 _composition_handler() 方法来创建 StockAnalyzer 对象的字典(每个资产一个):
class AssetGroupAnalyzer:
"""Analyzes many assets in a dataframe."""
@validate_df(columns={'open', 'high', 'low', 'close'})
def __init__(self, df, group_by='name'):
"""
Create an `AssetGroupAnalyzer` object with a
dataframe of OHLC data and column to group by.
"""
self.data = df
if group_by not in self.data.columns:
raise ValueError(
f'`group_by` column "{group_by}" not in df.'
)
self.group_by = group_by
self.analyzers = self._composition_handler()
def _composition_handler(self):
"""
Create a dictionary mapping each group to its analyzer,
taking advantage of composition instead of inheritance.
"""
return {
group: StockAnalyzer(data)
for group, data in self.data.groupby(self.group_by)
}
AssetGroupAnalyzer 类只有一个公共方法,analyze()—所有实际的计算都委托给存储在 analyzers 属性中的 StockAnalyzer 对象:
def analyze(self, func_name, **kwargs):
"""
Run a `StockAnalyzer` method on all assets.
Parameters:
- func_name: The name of the method to run.
- kwargs: Additional arguments to pass down.
Returns:
A dictionary mapping each asset to the result
of the calculation of that function.
"""
if not hasattr(StockAnalyzer, func_name):
raise ValueError(
f'StockAnalyzer has no "{func_name}" method.'
)
if not kwargs:
kwargs = {}
return {
group: getattr(analyzer, func_name)(**kwargs)
for group, analyzer in self.analyzers.items()
}
使用继承时,在这种情况下,所有方法都必须被重写,因为它们无法处理 groupby() 操作。相反,使用组合时,只需要为每个资产创建 StockAnalyzer 对象,并使用字典推导式来进行计算。另一个很棒的地方是,使用 getattr() 时,无需在 AssetGroupAnalyzer 类中镜像方法,因为 analyze() 可以通过名称使用 StockAnalyzer 对象来抓取方法。
比较资产
让我们使用 AssetGroupAnalyzer 类来比较我们收集的所有资产数据。与之前的章节一样,我们不会在这里使用 StockAnalyzer 类中的所有方法,因此请务必自己尝试:
>>> all_assets_analyzer = \
... stock_analysis.AssetGroupAnalyzer(all_assets)
记住在第一章《数据分析导论》中提到,变异系数(CV)是标准差与均值的比率;这有助于我们比较资产收盘价的变化程度,即使它们的均值差异较大(例如,亚马逊和苹果)。CV 还可以用来比较投资的波动性与预期回报,并量化风险与回报的权衡。让我们使用 CV 来查看哪种资产的收盘价波动最大:
>>> all_assets_analyzer.analyze('cv')
{'Amazon': 0.2658012522278963,
'Apple': 0.36991905161737615,
'Bitcoin': 0.43597652683008137,
'Facebook': 0.19056336194852783,
'Google': 0.15038618497328074,
'Netflix': 0.20344854330432688,
'S&P 500': 0.09536374658108937}
比特币有着最广泛的价格波动,这应该不是什么惊讶的事。与其使用收盘价,不如使用每日的百分比变化来计算年化波动率。这涉及到计算过去一年中百分比变化的标准差,并将其乘以一年中交易日数量的平方根(代码假设为 252)。通过使用百分比变化,相对于资产价格的较大价格波动会受到更严厉的惩罚。使用年化波动率时,Facebook 看起来比我们使用 CV 时波动性更大(尽管仍然不是最波动的资产):
>>> all_assets_analyzer.analyze('annualized_volatility')
{'Amazon': 0.3851099077041784,
'Apple': 0.4670809643500882,
'Bitcoin': 0.4635140114227397,
'Facebook': 0.45943066572169544,
'Google': 0.3833720603377728,
'Netflix': 0.4626772090887299,
'S&P 500': 0.34491195196047003}
鉴于所有资产在数据集末尾都获得了增值,接下来让我们检查这些资产是否进入了牛市,即在过去 2 个月内,资产的回报增长达到了 20% 或更高:
>>> all_assets_analyzer.analyze('is_bull_market')
{'Amazon': False,
'Apple': True,
'Bitcoin': True,
'Facebook': False,
'Google': False,
'Netflix': False,
'S&P 500': False}
看起来苹果和比特币在 2020 年 11 月和 12 月表现相当突出。其他资产的表现似乎不太好;然而,它们都没有进入熊市(我们可以通过将 'is_bear_market' 传递给 analyze() 来确认这一点)。另一种分析波动性的方法是通过计算贝塔值来将资产与指数进行比较。大于 1 的正值表示波动性高于该指数,而小于-1 的负值则表示与该指数的反向关系:
>>> all_assets_analyzer.analyze('beta', index=sp)
{'Amazon': 0.7563691182389207,
'Apple': 1.173273501105916,
'Bitcoin': 0.3716024282483362,
'Facebook': 1.024592821854751,
'Google': 0.98620762504024,
'Netflix': 0.7408228073823271,
'S&P 500': 1.0000000000000002}
使用之前结果中的贝塔值,我们可以看到与标准普尔 500 指数相比,苹果的波动性最大,这意味着如果这是我们的投资组合(暂时不考虑比特币),添加苹果股票会增加投资组合的风险。然而,我们知道比特币与标准普尔 500 指数之间没有相关性(请参见图 7.17中的相关性热图),所以这个低贝塔值是具有误导性的。
我们将要查看的最后一个指标是 r_f);我们通常使用美国国债的回报率作为这个数字。利率可以在www.treasury.gov/resource-center/data-chart-center/interest-rates/pages/TextView.aspx?data=yield上查找;或者,我们可以使用 StockReader 对象(reader)来为我们收集这个数据。让我们使用标准普尔 500 指数作为基准,比较这些资产的阿尔法值:
>>> r_f = reader.get_risk_free_rate_of_return() # 0.93
>>> all_assets_analyzer.analyze('alpha', index=sp, r_f=r_f)
{'Amazon': 0.7383391908270172,
'Apple': 1.7801122522388666,
'Bitcoin': 6.355297988074054,
'Facebook': 0.5048625273190841,
'Google': 0.18537197824248092,
'Netflix': 0.6500392764754642,
'S&P 500': -1.1102230246251565e-16}
所有资产的表现都超过了标准普尔 500 指数,而标准普尔 500 作为一个由 500 只股票组成的投资组合,其风险较低、回报较低,这是由于Cycler对象的存在(matplotlib.org/cycler/),该对象改变颜色和线条样式:
>>> from cycler import cycler
>>> bw_viz_cycler = (
... cycler(color=[plt.get_cmap('tab10')(x/10)
... for x in range(10)])
... + cycler(linestyle=['dashed', 'solid', 'dashdot',
... 'dotted', 'solid'] * 2))
>>> fig, axes = plt.subplots(1, 2, figsize=(15, 5))
>>> axes[0].set_prop_cycle(bw_viz_cycler)
>>> cumulative_returns = \
... all_assets_analyzer.analyze('cumulative_returns')
>>> for name, data in cumulative_returns.items():
... data.plot(
... ax=axes[1] if name == 'Bitcoin' else axes[0],
... label=name, legend=True
... )
>>> fig.suptitle('Cumulative Returns')
尽管 2020 年初面临困境,但所有资产都获得了价值。请注意,比特币子图的 y 轴范围是从 0 到 7(右侧子图),而股市子图(左侧)的范围则覆盖了该范围的一半:

图 7.21 – 所有资产的累计回报
现在我们已经对如何分析金融工具有了良好的理解,接下来我们来尝试预测未来的表现。
使用历史数据建模表现
本节的目标是让我们了解如何构建一些模型;因此,以下示例并不意味着是最佳模型,而是为了学习目的的简单且相对快速的实现。再次提醒,stock_analysis 包中有一个适用于本节任务的类:StockModeler。
重要提示
要完全理解本节的统计元素和建模一般原理,我们需要对统计学有一个扎实的理解;然而,本讨论的目的是展示如何将建模技术应用于金融数据,而不深入探讨背后的数学原理。
StockModeler 类
StockModeler 类将使我们更容易构建和评估一些简单的金融模型,而无需直接与 statsmodels 包进行交互。此外,我们将减少生成模型所需的步骤。以下 UML 图显示这是一个相当简单的类。请注意,我们没有属性,因为 StockModeler 是一个静态类(意味着我们不实例化它):

图 7.22 – StockModeler 类的结构
StockModeler 类定义在 stock_analysis/stock_modeler.py 中,并具有构建模型和进行一些初步性能分析的方法。像往常一样,我们以文档字符串和导入开始模块:
"""Simple time series modeling for stocks."""
import matplotlib.pyplot as plt
import pandas as pd
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.seasonal import seasonal_decompose
import statsmodels.api as sm
from .utils import validate_df
接下来,我们将开始 StockModeler 类,并在有人尝试实例化它时引发错误:
class StockModeler:
"""Static methods for modeling stocks."""
def __init__(self):
raise NotImplementedError(
"This class must be used statically: "
"don't instantiate it."
)
我们希望这个类支持的任务之一是时间序列分解,我们在第一章《数据分析简介》中讨论过这个内容。我们从 statsmodels 导入了 seasonal_decompose() 函数,所以我们只需在 decompose() 方法中对收盘价调用它:
@staticmethod
@validate_df(columns={'close'}, instance_method=False)
def decompose(df, period, model='additive'):
"""
Decompose the closing price of the stock into
trend, seasonal, and remainder components.
Parameters:
- df: The dataframe containing the stock closing
price as `close` and with a time index.
- period: The number of periods in the frequency.
- model: How to compute the decomposition
('additive' or 'multiplicative')
Returns:
A `statsmodels` decomposition object.
"""
return seasonal_decompose(
df.close, model=model, period=period
)
请注意,我们对 decompose() 方法有两个装饰器。最上面的装饰器应用于它下面装饰器的结果。在这个例子中,我们有以下内容:
staticmethod(
validate_df(
decompose, columns={'close'}, instance_method=False
)
)
此外,我们还希望支持创建 ARIMA 模型,我们在第一章《数据分析简介》中也讨论过这一点。ARIMA 模型使用 ARIMA(p, d, q) 符号,其中 p 是 AR 模型的时间滞后数(或阶数),d 是从数据中减去的过去值的数量(即 I 模型),q 是 MA 模型中使用的周期数。因此,ARIMA(1, 1, 1) 是一个包含一个自回归部分时间滞后、数据差分一次以及一个 1 期的移动平均的模型。如果我们有任何阶数为零的情况,可以去掉它们——例如,ARIMA(1, 0, 1) 等同于 ARMA(1, 1),而 ARIMA(0, 0, 3) 等同于 MA(3)。季节性 ARIMA 模型写作 ARIMA(p, d, q)(P, D, Q)m,其中 m 是季节性模型中的周期数,P、D 和 Q 是季节性 ARIMA 模型的阶数。StockModeler.arima() 方法不支持季节性组件(为了简化)并且接收 p、d 和 q 作为参数,但为了避免混淆,我们会根据它们所代表的 ARIMA 特性给它们命名——例如,ar 代表自回归(p)。此外,我们还将使我们的静态方法在返回之前提供拟合模型的选项:
@staticmethod
@validate_df(columns={'close'}, instance_method=False)
def arima(df, *, ar, i, ma, fit=True, freq='B'):
"""
Create an ARIMA object for modeling time series.
Parameters:
- df: The dataframe containing the stock closing
price as `close` and with a time index.
- ar: The autoregressive order (p).
- i: The differenced order (q).
- ma: The moving average order (d).
- fit: Whether to return the fitted model
- freq: Frequency of the time series
Returns:
A `statsmodels` ARIMA object which you can use
to fit and predict.
"""
arima_model = ARIMA(
df.close.asfreq(freq).fillna(method='ffill'),
order=(ar, i, ma)
)
return arima_model.fit() if fit else arima_model
提示
注意方法签名(df, *, ar, i, ma, ...)中有一个星号(*)。这强制要求在调用方法时,列出的参数必须作为关键字参数传递。这是一种确保使用者明确表达他们需求的好方式。
为了配合这一点,我们需要一种评估 ARIMA 模型预测结果的方法,因此我们将添加 arima_predictions() 静态方法。我们还将提供将预测结果返回为 Series 对象或图表的选项:
@staticmethod
@validate_df(columns={'close'}, instance_method=False)
def arima_predictions(df, arima_model_fitted, start, end,
plot=True, **kwargs):
"""
Get ARIMA predictions as a `Series` object or plot.
Parameters:
- df: The dataframe for the stock.
- arima_model_fitted: The fitted ARIMA model.
- start: The start date for the predictions.
- end: The end date for the predictions.
- plot: Whether to plot the result, default is
`True` meaning the plot is returned instead of
the `Series` object containing the predictions.
- kwargs: Additional arguments to pass down.
Returns:
A matplotlib `Axes` object or predictions
depending on the value of the `plot` argument.
"""
predictions = \
arima_model_fitted.predict(start=start, end=end)
if plot:
ax = df.close.plot(**kwargs)
predictions.plot(
ax=ax, style='r:', label='arima predictions'
)
ax.legend()
return ax if plot else predictions
类似于我们为 ARIMA 模型构建的内容,我们还将提供 regression() 方法,用于构建以 1 的滞后期为基础的收盘价线性回归模型。为此,我们将再次使用 statsmodels(在 第九章《在 Python 中入门机器学习》中,我们将使用 scikit-learn 进行线性回归):
@staticmethod
@validate_df(columns={'close'}, instance_method=False)
def regression(df):
"""
Create linear regression of time series with lag=1.
Parameters:
- df: The dataframe with the stock data.
Returns:
X, Y, and the fitted model
"""
X = df.close.shift().dropna()
Y = df.close[1:]
return X, Y, sm.OLS(Y, X).fit()
与 arima_predictions() 方法类似,我们希望提供一种方式来查看模型的预测结果,可以选择以 Series 对象或图表的形式呈现。与 ARIMA 模型不同,它每次只预测一个值。因此,我们将从最后一个收盘价后的第二天开始进行预测,并通过迭代使用前一个预测值来预测下一个值。为了处理这一切,我们将编写 regression_predictions() 方法:
@staticmethod
@validate_df(columns={'close'}, instance_method=False)
def regression_predictions(df, model, start, end,
plot=True, **kwargs):
"""
Get linear regression predictions as a `pandas.Series`
object or plot.
Parameters:
- df: The dataframe for the stock.
- model: The fitted linear regression model.
- start: The start date for the predictions.
- end: The end date for the predictions.
- plot: Whether to plot the result, default is
`True` meaning the plot is returned instead of
the `Series` object containing the predictions.
- kwargs: Additional arguments to pass down.
Returns:
A matplotlib `Axes` object or predictions
depending on the value of the `plot` argument.
"""
predictions = pd.Series(
index=pd.date_range(start, end), name='close'
)
last = df.last('1D').close
for i, date in enumerate(predictions.index):
if not i:
pred = model.predict(last)
else:
pred = model.predict(predictions.iloc[i - 1])
predictions.loc[date] = pred[0]
if plot:
ax = df.close.plot(**kwargs)
predictions.plot(
ax=ax, style='r:',
label='regression predictions'
)
ax.legend()
return ax if plot else predictions
最后,对于 ARIMA 和线性回归模型,我们都希望可视化预测中的误差,或者 resid 属性,它将给出残差;我们只需要将它们绘制为散点图来检查其方差,并使用 KDE 检查其均值。为此,我们将添加 plot_residuals() 方法:
@staticmethod
def plot_residuals(model_fitted, freq='B'):
"""
Visualize the residuals from the model.
Parameters:
- model_fitted: The fitted model
- freq: Frequency that the predictions were
made on. Default is 'B' (business day).
Returns:
A matplotlib `Axes` object.
"""
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
residuals = pd.Series(
model_fitted.resid.asfreq(freq), name='residuals'
)
residuals.plot(
style='bo', ax=axes[0], title='Residuals'
)
axes[0].set(xlabel='Date', ylabel='Residual')
residuals.plot(
kind='kde', ax=axes[1], title='Residuals KDE'
)
axes[1].set_xlabel('Residual')
return axes
现在,让我们使用 Netflix 数据再次体验一下 StockModeler 类。
时间序列分解
如在 第一章《数据分析简介》中提到的,时间序列可以利用指定的频率分解为趋势、季节性和剩余成分。这可以通过 statsmodels 包实现,StockModeler.decompose() 正在使用该包:
>>> from stock_analysis import StockModeler
>>> decomposition = StockModeler.decompose(nflx, 20)
>>> fig = decomposition.plot()
>>> fig.suptitle(
... 'Netflix Stock Price Time Series Decomposition', y=1
... )
>>> fig.set_figheight(6)
>>> fig.set_figwidth(10)
>>> fig.tight_layout()
这将返回 Netflix 股票价格的分解图,频率为 20 个交易日:

图 7.23 – Netflix 股票价格随时间变化的时间序列分解
对于更复杂的模型,我们可以先进行分解,然后围绕这些组成部分构建我们的模型。然而,这超出了本章的范围,因此我们将继续讨论 ARIMA 模型。
ARIMA
正如我们在第一章《数据分析导论》中讨论的那样,ARIMA 模型包含自回归、差分和滑动平均部分。它们也可以使用statsmodels包来构建,StockModeler.arima()方法正是利用这个包;此方法会根据提供的规格返回一个拟合的 ARIMA 模型。这里,我们将使用%%capture魔法命令来避免显示 ARIMA 模型拟合过程中产生的任何警告,因为我们正在构建一个简单的模型来探索其功能:
>>> %%capture
>>> arima_model = StockModeler.arima(nflx, ar=10, i=1, ma=5)
提示
我们选择这些值是因为它们在合理的时间内运行。实际上,我们可以使用在第五章《使用 Pandas 和 Matplotlib 进行数据可视化》中介绍的pandas.plotting模块中的autocorrelation_plot()函数,帮助找到一个合适的ar值。
一旦模型拟合完成,我们可以通过模型的summary()方法获取相关信息:
>>> print(arima_model.summary())
该摘要相当全面,我们在解释时应参考文档;然而,本文可能是更易理解的入门介绍:medium.com/analytics-vidhya/interpreting-arma-model-results-in-statsmodels-for-absolute-beginners-a4d22253ad1c。需要注意的是,解释此摘要需要扎实的统计学基础:

图 7.24 – ARIMA 模型的摘要
为了分析这个模型,我们可以用一种更简单的方法,通过查看StockModeler.plot_residuals()方法来帮助我们可视化地检查残差:
>>> StockModeler.plot_residuals(arima_model)
尽管残差的均值为 0(右侧子图),它们是异方差的——注意到它们的方差随时间增加(左侧子图):

图 7.25 – 评估我们 ARIMA 模型的残差
提示
当我们查看图 7.24中的模型摘要时,statsmodels使用默认显著性水平 0.05 进行了异方差性的统计检验。检验统计量标记为异方差性(H),p 值标记为Prob(H)(双侧)。需要注意的是,结果在统计上是显著的(p 值小于或等于显著性水平),这意味着我们的残差不太可能是同方差的。
作为构建 ARIMA 模型的替代方案,StockModeler类还提供了使用线性回归来模拟金融工具收盘价的选项。
使用 statsmodels 进行线性回归
StockModeler.regression()方法使用statsmodels构建一个以前一天收盘价为自变量的线性回归模型,预测收盘价:
>>> X, Y, lm = StockModeler.regression(nflx)
>>> print(lm.summary())
再次,summary()方法会给我们模型拟合的统计信息:

图 7.26 – 我们的线性回归模型总结
提示
查看这篇文章以获取如何解读总结的指导:medium.com/swlh/interpreting-linear-regression-through-statsmodels-summary-4796d359035a。
调整后的 R²使得这个模型看起来非常好,因为它接近 1(在第九章,《Python 机器学习入门》中,我们将进一步讨论这个指标);然而,我们知道这只是因为股票数据高度自相关,所以让我们再次查看残差:
>>> StockModeler.plot_residuals(lm)
这个模型也存在异方差性问题:

图 7.27 – 评估我们线性回归模型的残差
现在让我们看看 ARIMA 模型和线性回归模型在预测 Netflix 股票收盘价方面,哪一个表现更好。
比较模型
为了比较我们的模型,我们需要在一些新的数据上测试它们的预测。让我们收集 2021 年 1 月前两周 Netflix 股票的每日收盘价,并使用StockModeler类中的预测方法来可视化我们的模型预测与现实的对比:
>>> import datetime as dt
>>> start = dt.date(2021, 1, 1)
>>> end = dt.date(2021, 1, 14)
>>> jan = stock_analysis.StockReader(start, end)\
... .get_ticker_data('NFLX')
>>> fig, axes = plt.subplots(1, 2, figsize=(15, 5))
>>> arima_ax = StockModeler.arima_predictions(
... nflx, arima_model, start=start, end=end,
... ax=axes[0], title='ARIMA', color='b'
... )
>>> jan.close.plot(
... ax=arima_ax, style='b--', label='actual close'
... )
>>> arima_ax.legend()
>>> arima_ax.set_ylabel('price ($)')
>>> linear_reg = StockModeler.regression_predictions(
... nflx, lm, start=start, end=end,
... ax=axes[1], title='Linear Regression', color='b'
... )
>>> jan.close.plot(
... ax=linear_reg, style='b--', label='actual close'
... )
>>> linear_reg.legend()
>>> linear_reg.set_ylabel('price ($)')
ARIMA 模型的预测看起来更符合我们预期的模式,但鉴于股市的不可预测性,两个模型都远离了 2021 年 1 月前两周实际发生的情况:

图 7.28 – 模型预测与现实对比
正如我们所看到的,预测股票表现并不容易,即使是短短几天的预测也很困难。有许多数据没有被这些模型捕捉到,比如新闻报道、法规以及管理层的变化等。无论模型看起来拟合得多好,都要小心相信这些预测,因为它们仅仅是外推,而且有很多随机性没有被考虑在内。
为了进一步说明这一点,请看以下通过随机游走和股票数据生成的一组图表。只有一组是真实数据,但是哪一组呢?答案会在图表之后出现,所以在查看之前一定要猜一下:

图 7.29 – 真实还是伪造的股票数据?
这些时间序列都来源于同一个点(2019 年 7 月 1 日微软的收盘价),但只有A是真实的股票数据——B、C和D都是随机游走。很难(或者不可能)分辨,对吧?
总结
在这一章中,我们看到构建 Python 包来进行分析应用程序的开发,可以让其他人非常容易地进行他们自己的分析并复现我们的结果,同时也让我们为未来的分析创建可重复的工作流程。
我们在本章创建的stock_analysis包包含了多个类,用于从互联网收集股票数据(StockReader);可视化单个资产或它们的组合(Visualizer家族);计算单个资产或它们组合的指标以进行比较(分别为StockAnalyzer和AssetGroupAnalyzer);以及使用分解、ARIMA 和线性回归进行时间序列建模(StockModeler)。我们还首次了解了如何在StockModeler类中使用statsmodels包。本章展示了我们迄今为止在本书中学习的pandas、matplotlib、seaborn和numpy功能如何结合在一起,以及这些库如何与其他包协同工作以实现自定义应用。我强烈建议你重新阅读stock_analysis包中的代码,并测试一些我们在本章中未涵盖的方法,以确保你掌握了相关概念。
在下一章中,我们将进行另一个应用案例,学习如何构建一个登录尝试的模拟器,并尝试基于规则的异常检测。
练习
使用stock_analysis包完成以下练习。除非另有说明,数据应使用 2019 年到 2020 年末的数据。如果使用StockReader类收集数据时遇到问题,可以在exercises/目录中找到备份的 CSV 文件:
-
使用
StockAnalyzer和StockVisualizer类,计算并绘制 Netflix 收盘价的三个支撑位和阻力位。 -
使用
StockVisualizer类,查看盘后交易对 FAANG 股票的影响:a) 作为单独的股票
b) 使用来自
stock_analysis.utils模块的make_portfolio()函数作为组合 -
使用
StockVisualizer.open_to_close()方法,创建一个图表,显示 FAANG 股票每个交易日的开盘价和收盘价之间的区域:如果价格下降,用红色填充;如果价格上涨,用绿色填充。作为附加任务,对比特币和 S&P 500 的组合也做同样的操作。 -
共同基金和
AssetGroupAnalyzer类。 -
编写一个函数,返回一个包含以下列的单行数据框:
alpha、beta、sharpe_ratio、annualized_volatility、is_bear_market和is_bull_market,这些列分别包含通过StockAnalyzer类对给定股票运行相应方法的结果。在AssetGroupAnalyzer.analyze()方法中使用的字典推导式和getattr()函数将会很有用。 -
使用
StockModeler类,在 2019 年 1 月 1 日至 2020 年 11 月 30 日的 S&P 500 数据上构建 ARIMA 模型,并使用该模型预测 2020 年 12 月的表现。务必检查残差,并将预测表现与实际表现进行比较。 -
请求 AlphaVantage 的 API 密钥 (
www.alphavantage.co/support/#api-key),并使用你之前为收集数据创建的StockReader对象,通过get_forex_rates()方法收集从 USD 到 JPY 的每日外汇汇率。使用 2019 年 2 月到 2020 年 1 月的数据,构建一个蜡烛图,并将数据重采样为每周一次。提示:查看标准库中的slice()函数(docs.python.org/3/library/functions.html#slice),以提供日期范围。
深入阅读
查看以下资源,以获取更多关于本章内容的信息:
-
Python 函数装饰器指南:
www.thecodeship.com/patterns/guide-to-python-function-decorators/ -
Python 中的类和继承入门:
www.jesshamrick.com/2011/05/18/an-introduction-to-classes-and-inheritance-in-python/ -
变异系数(CV):
www.investopedia.com/terms/c/coefficientofvariation.asp -
类(Python 文档):
docs.python.org/3/tutorial/classes.html -
盘后交易如何影响股票价格:
www.investopedia.com/ask/answers/05/saleafterhours.asp -
如何创建一个 Python 包:
www.pythoncentral.io/how-to-create-a-python-package/ -
如何在 Python 中创建 ARIMA 模型进行时间序列预测:
machinelearningmastery.com/arima-for-time-series-forecasting-with-python/ -
使用 statsmodels 的线性回归(Python):
datatofish.com/statsmodels-linear-regression/ -
面向对象编程:
python.swaroopch.com/oop.html -
支撑和阻力基础知识:
www.investopedia.com/trading/support-and-resistance-basics/ -
如何在 Python 中使用静态方法、类方法或抽象方法的权威指南:
julien.danjou.info/guide-python-static-class-abstract-methods/
第九章:第八章:基于规则的异常检测
是时候抓住一些试图通过暴力破解攻击访问网站的黑客了——他们通过尝试一堆用户名和密码组合,直到成功登录为止。这种攻击非常嘈杂,因此为我们提供了大量的数据点用于异常检测,即寻找由与我们认为典型活动不同的过程产生的数据。黑客将被模拟,并不会像现实中那样狡猾,但这将为我们提供良好的异常检测实践机会。
我们将创建一个包,处理登录尝试的模拟,以便生成本章的数据。知道如何进行模拟是我们工具箱中一个至关重要的技能。有时,用精确的数学解法解决问题很困难;然而,定义系统中小组件如何工作可能比较容易。在这种情况下,我们可以模拟小组件并模拟整个系统的行为。模拟结果为我们提供了一个近似解,这可能足以满足我们的目的。
我们将利用基于规则的异常检测来识别模拟数据中的可疑活动。在本章结束时,我们将了解如何使用从各种概率分布生成的随机数来模拟数据,进一步了解 Python 标准库,积累更多构建 Python 包的经验,练习进行探索性数据分析,并初步接触异常检测。
本章将涵盖以下主题:
-
模拟登录尝试以创建本章的数据集
-
执行探索性数据分析以理解模拟数据
-
使用规则和基准线进行异常检测
本章材料
我们将构建一个模拟包来生成本章的数据;该包在 GitHub 上的地址是 github.com/stefmolin/login-attempt-simulator/tree/2nd_edition。在我们设置环境时,这个包已从 GitHub 安装,相关内容在第一章,数据分析导论中有提及;然而,你也可以按照第七章,金融分析——比特币与股市中的说明,安装一个可以编辑的版本。
本章节的代码库可以在github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_08找到,包含了我们实际分析中使用的笔记本(anomaly_detection.ipynb)、我们将在logs/文件夹中处理的数据文件、用于模拟的数据(位于user_data/文件夹),以及包含 Python 脚本的simulate.py文件,我们可以在命令行运行该脚本来模拟本章节的数据。
模拟登录尝试
由于我们很难从泄露数据中找到登录尝试数据(通常因为其敏感性不予共享),我们将进行模拟。模拟需要对统计建模有深入的理解,能够估计某些事件的概率,并根据需要识别适当的假设进行简化。为了运行模拟,我们将构建一个 Python 包(login_attempt_simulator)来模拟登录过程,要求正确的用户名和密码(没有任何额外的身份验证措施,如双重身份验证),以及一个可以在命令行运行的脚本(simulate.py),我们将在本节中讨论这两个内容。
假设
在我们进入处理模拟的代码之前,需要理解一些假设。进行模拟时不可能控制所有可能的变量,因此我们必须确定一些简化假设以便开始。
模拟器对网站有效用户做出以下假设:
-
有效用户按照泊松过程到达,小时的到达率取决于星期几和一天中的时间。泊松过程将每单位时间内的到达(我们的模拟将使用小时)建模为一个均值为λ(lambda)的泊松分布。到达时间服从指数分布,均值为 1/λ。
-
有效用户从 1 到 3 个 IP 地址(每个连接到互联网的设备的唯一标识符)连接,这些 IP 地址由 4 个随机整数组成,范围为[0, 255],并由句点分隔。尽管极为不可能,但两个有效用户可能会共享一个 IP 地址。
-
有效用户在输入凭证时不太可能犯很多错误。
重要提示
到达时间具有无记忆性特性,这意味着两个连续到达之间的时间不会影响随后的到达时间。
模拟器对黑客做出以下假设:
-
黑客试图避免账户锁定,只测试少量的用户名和密码组合,而不是进行全面的字典攻击(对于每个用户,尝试黑客在字典中所有可能的密码)。然而,他们在尝试之间不会添加延迟。
-
由于黑客不想造成拒绝服务攻击,他们限制攻击的频率,每次只进行一次尝试。
-
黑客知道系统中存在的账户数量,并且对用户名的格式有一定了解,但他们只能猜测具体的用户名。他们会选择尝试猜测所有 133 个用户名,或者其中的某些子集。
-
每次攻击都是独立的,也就是说每次攻击都是由单个黑客执行的,而且一个黑客从不进行多次攻击。
-
黑客不会共享哪些用户名-密码组合是正确的。
-
攻击是随机发生的。
-
每个黑客将使用一个唯一的 IP 地址,该地址与有效用户的 IP 地址生成方式相同。然而,我们的模拟器能够改变这个 IP 地址,这是我们将在第十一章《机器学习异常检测》中探讨的功能,目的是让这个场景变得更具挑战性。
-
虽然可能性极小,但黑客的 IP 地址可能与有效用户相同,甚至黑客可能是有效用户。
我们也在抽象化密码猜测的一些复杂性;相反,我们使用随机数字来决定密码是否被猜测正确——这意味着我们没有考虑网站如何存储密码,可能是明文(希望不是)、哈希值(对明文密码进行不可逆转换,使得无需存储实际密码即可进行验证)或加盐哈希值(有关这方面的文章请参见进一步阅读部分)。实际上,黑客可能会获取存储的密码并离线破解它们(请参阅进一步阅读部分结尾处关于彩虹表的文章),在这种情况下,本章讨论的技术可能不会很有帮助,因为日志中不会记录他们的尝试。请记住,本次模拟中的黑客非常显眼。
login_attempt_simulator 包
这个包比上一章中的stock_analysis包要轻量得多;我们只有三个文件:
login_attempt_simulator
|-- __init__.py
|-- login_attempt_simulator.py
`-- utils.py
接下来,我们将在以下章节中逐一讲解这些文件。请注意,部分文档字符串已被删除以简洁起见;请查看文件本身以获取完整的文档。
辅助函数
让我们从utils.py函数开始讨论,它们是我们模拟器类的辅助工具。首先,我们为模块创建文档字符串,并处理导入:
"""Utility functions for the login attempt simulator."""
import ipaddress
import itertools
import json
import random
import string
接下来,我们定义make_user_base()函数,它为我们的 Web 应用程序创建用户库。该函数通过将英语字母表中的一个小写字母与函数内列表中的每个姓氏结合,来创建一个包含用户名的文件,并添加一些管理员账户;这样就形成了一个包含 133 个账户的用户库。通过写入文件,我们确保每次运行模拟时不需要重新生成,而是可以简单地从中读取数据以便将来进行模拟:
def make_user_base(out_file):
"""Generate a user base and save it to a file."""
with open(out_file, 'w') as user_base:
for first, last in itertools.product(
string.ascii_lowercase,
['smith', 'jones', 'kim', 'lopez', 'brown']
): # makes 130 accounts
user_base.write(first + last + '\n')
# adds 3 more accounts
for account in ['admin', 'master', 'dba']:
user_base.write(account + '\n')
由于我们需要在模拟器中使用这个用户库,我们还写了一个函数来读取用户库文件并将其转化为列表。get_valid_users() 函数将由 make_user_base() 函数写入的文件重新读取到 Python 列表中:
def get_valid_users(user_base_file):
"""Read in users from the user base file."""
with open(user_base_file, 'r') as file:
return [user.strip() for user in file.readlines()]
random_ip_generator() 函数生成随机的 IP 地址,格式为 xxx.xxx.xxx.xxx,其中 x 是 [0, 255] 范围内的整数。我们使用 Python 标准库中的 ipaddress 模块(docs.python.org/3/library/ipaddress.html)来避免分配私有 IP 地址:
def random_ip_generator():
"""Randomly generate a fake IP address."""
try:
ip_address = ipaddress.IPv4Address('%d.%d.%d.%d' %
tuple(random.randint(0, 255) for _ in range(4))
)
except ipaddress.AddressValueError:
ip_address = random_ip_generator()
return str(ip_address) if ip_address.is_global \
else random_ip_generator()
每个用户将有几个尝试登录的 IP 地址。assign_ip_addresses() 函数为每个用户映射 1 到 3 个随机 IP 地址,创建一个字典:
def assign_ip_addresses(user_list):
"""Assign users 1-3 fake IP addresses."""
return {
user: [
random_ip_generator()
for _ in range(random.randint(1, 3))
] for user in user_list
}
save_user_ips() 和 read_user_ips() 函数分别将用户-IP 地址映射保存到 JSON 文件中,并将其重新读取到字典文件中:
def save_user_ips(user_ip_dict, file):
"""Save the user-IP address mapping to a JSON file."""
with open(file, 'w') as file:
json.dump(user_ip_dict, file)
def read_user_ips(file):
"""Read in the JSON file of the user-IP address mapping."""
with open(file, 'r') as file:
return json.loads(file.read())
提示
Python 标准库有许多有用的模块,尽管我们可能不常用,但它们绝对值得了解。在这里,我们使用 json 模块将字典保存到 JSON 文件,并稍后读取它们。我们使用 ipaddress 模块处理 IP 地址,使用 string 模块获取字母表中的字符,而不需要一一输入。
LoginAttemptSimulator 类
login_attempt_simulator.py 文件中的 LoginAttemptSimulator 类负责执行模拟的重负荷工作,包含所有随机数生成逻辑。像往常一样,我们从模块的文档字符串和导入语句开始:
"""Simulator of login attempts from valid users and hackers."""
import calendar
import datetime as dt
from functools import partial
import math
import random
import string
import numpy as np
import pandas as pd
from .utils import random_ip_generator, read_user_ips
接下来,我们开始定义 LoginAttemptSimulator 类及其文档字符串,同时为存储常量定义一些类变量。我们这样做是为了避免使用魔法数字(即代码中看似没有意义的数字)以及避免在多个地方使用字符串时出现拼写错误。请注意,这些信息仅用于我们的日志;Web 应用程序不会向最终用户显示认证尝试失败的原因(也不应该显示):
class LoginAttemptSimulator:
"""Simulate login attempts from valid users + attackers."""
ATTEMPTS_BEFORE_LOCKOUT = 3
ACCOUNT_LOCKED = 'error_account_locked'
WRONG_USERNAME = 'error_wrong_username'
WRONG_PASSWORD = 'error_wrong_password'
重要提示
请注意我们如何使用类变量来存储常量,例如错误信息,这样就不会在代码中犯拼写错误。这样,每次使用这些错误信息时,文本都会保持一致,从而保持数据的整洁。在 Python 中,常量通常采用全大写字母形式(www.python.org/dev/peps/pep-0008/#constants)。
__init__() 方法将处理模拟器的设置,例如从指定的文件读取用户库、初始化日志、存储成功概率,并根据需要确定模拟的开始和结束日期:
def __init__(self, user_base_json_file, start, end=None, *,
attacker_success_probs=[.25, .45],
valid_user_success_probs=[.87, .93, .95],
seed=None):
# user, ip address dictionary
self.user_base = read_user_ips(user_base_json_file)
self.users = [user for user in self.user_base.keys()]
self.start = start
self.end = end if end else self.start + \
dt.timedelta(days=random.uniform(1, 50))
self.hacker_success_likelihoods = \
attacker_success_probs
self.valid_user_success_likelihoods = \
valid_user_success_probs
self.log = pd.DataFrame(columns=[
'datetime', 'source_ip', 'username',
'success', 'failure_reason'
])
self.hack_log = \
pd.DataFrame(columns=['start', 'end', 'source_ip'])
self.locked_accounts = []
# set seeds for random numbers from random and numpy:
random.seed(seed)
np.random.seed(seed)
_record() 方法将每次尝试的结果追加到日志中,记录其来源的 IP 地址、用户名、时间、是否成功以及失败的原因(如果有的话):
def _record(self, when, source_ip, username, success,
failure_reason):
"""
Record the outcome of a login attempt.
Parameters:
- when: The datetime of the event.
- source_ip: IP address the attempt came from.
- username: The username used in the attempt.
- success: Whether the attempt succeeded (Boolean).
- failure_reason: Reason for the failure.
Returns:
None, the `log` attribute is updated.
"""
self.log = self.log.append({
'datetime': when,
'source_ip': source_ip,
'username': username,
'success': success,
'failure_reason': failure_reason
}, ignore_index=True)
_attempt_login() 方法处理判断登录尝试是否成功的逻辑:

图 8.1 – 模拟逻辑
我们提供输入正确用户名的概率(username_accuracy)以及每次尝试成功输入密码的概率(success_likelihoods)。尝试次数是允许的最大尝试次数与成功概率列表长度(success_likelihoods)中的最小值。每次尝试的结果会通过 functools 传递给 _record(),它允许我们创建函数,将某些参数固定为特定值(这样就不必不断传递相同的值):
def _attempt_login(self, when, source_ip, username,
username_accuracy, success_likelihoods):
"""
Simulates a login attempt, allowing for account
lockouts, and recording the results.
Parameters:
- when: The datetime to start trying.
- source_ip: IP address the attempt came from.
- username: The username being used in the attempt.
- username_accuracy: Prob. username is correct.
- success_likelihoods: List of probabilities that
password is correct (one per attempt).
Returns:
The datetime after trying.
"""
current = when
recorder = partial(self._record, source_ip=source_ip)
if random.random() > username_accuracy:
correct_username = username
username = self._distort_username(username)
if username not in self.locked_accounts:
tries = len(success_likelihoods)
for i in range(
min(tries, self.ATTEMPTS_BEFORE_LOCKOUT)
):
current += dt.timedelta(seconds=1)
if username not in self.users:
recorder(
when=current, username=username,
success=False,
failure_reason=self.WRONG_USERNAME
)
if random.random() <= username_accuracy:
username = correct_username
continue
if random.random() <= success_likelihoods[i]:
recorder(
when=current, username=username,
success=True, failure_reason=None
)
break
else:
recorder(
when=current, username=username,
success=False,
failure_reason=self.WRONG_PASSWORD
)
else:
if tries >= self.ATTEMPTS_BEFORE_LOCKOUT \
and username in self.users:
self.locked_accounts.append(username)
else:
recorder(
when=current, username=username, success=False,
failure_reason=self.ACCOUNT_LOCKED
)
if random.random() >= .5: # unlock account randomly
self.locked_accounts.remove(username)
return current
_valid_user_attempts_login() 和 _hacker_attempts_login() 方法是围绕 _attempt_login() 方法的封装,分别处理有效用户和黑客的概率调整。注意,尽管两者都使用高斯(正态)分布来确定用户名的准确性,但有效用户的分布具有更高的均值和更低的标准差,这意味着他们在尝试登录时更有可能提供正确的用户名。这是因为,虽然有效用户可能会打错字(偶尔发生),但黑客则是在猜测:
def _hacker_attempts_login(self, when, source_ip,
username):
"""Simulates a login attempt from an attacker."""
return self._attempt_login(
when=when, source_ip=source_ip, username=username,
username_accuracy=random.gauss(mu=0.35, sigma=0.5),
success_likelihoods=self.hacker_success_likelihoods
)
def _valid_user_attempts_login(self, when, username):
"""Simulates a login attempt from a valid user."""
return self._attempt_login(
when=when, username=username,
source_ip=random.choice(self.user_base[username]),
username_accuracy=\
random.gauss(mu=1.01, sigma=0.01),
success_likelihoods=\
self.valid_user_success_likelihoods
)
当模拟器判断用户名无法正确提供时,它会调用 _distort_username() 方法,该方法会随机决定从有效用户名中省略一个字母或将一个字母替换为另一个字母。虽然黑客因猜测而输入错误的用户名(而不是由于打字错误),我们在这里抽象化处理这一细节,以便使用一个统一的函数来为有效用户和黑客引入用户名错误:
@staticmethod
def _distort_username(username):
"""
Alters the username to allow for wrong username login
failures. Randomly removes a letter or replaces a
letter in a valid username.
"""
username = list(username)
change_index = random.randint(0, len(username) - 1)
if random.random() < .5: # remove random letter
username.pop(change_index)
else: # randomly replace a single letter
username[change_index] = \
random.choice(string.ascii_lowercase)
return ''.join(username)
我们使用 _valid_user_arrivals() 方法来生成给定小时内到达的用户数量和使用泊松分布和指数分布生成的到达间隔时间:
@staticmethod
def _valid_user_arrivals(when):
"""
Static method for simulating Poisson process of
arrivals (users wanting to log in). Lambda for the
Poisson varies depending upon the day and time of week.
"""
is_weekday = when.weekday() not in (
calendar.SATURDAY, calendar.SUNDAY
)
late_night = when.hour < 5 or when.hour >= 11
work_time = is_weekday \
and (when.hour >= 9 or when.hour <= 17)
if work_time:
# hours 9-5 on work days get higher lambda
poisson_lambda = random.triangular(1.5, 5, 2.75)
elif late_night:
# hours in middle of night get lower lambda
poisson_lambda = random.uniform(0.0, 5.0)
else:
poisson_lambda = random.uniform(1.5, 4.25)
hourly_arrivals = np.random.poisson(poisson_lambda)
interarrival_times = np.random.exponential(
1/poisson_lambda, size=hourly_arrivals
)
return hourly_arrivals, interarrival_times
重要提示
我们使用 numpy 而不是 random 来从指数分布中生成随机数,因为我们可以一次请求多个值(每个值对应泊松过程确定的每小时到达数)。此外,random 不提供泊松分布,因此我们需要 numpy。
我们的模拟使用了多种不同的分布,因此查看它们的样子可能会有所帮助。以下子图展示了我们使用的每种分布的示例。注意,泊松分布的绘制方式不同。这是因为泊松分布是离散的。因此,我们通常用它来模拟到达情况——在这里,我们用它来模拟尝试登录的用户到达情况。离散分布有一个概率质量函数(PMF),而不是概率密度函数(PDF):

图 8.2 – 模拟中使用的分布
_hack() 方法为黑客生成一个随机的 IP 地址,并对给定的用户列表进行暴力攻击:
def _hack(self, when, user_list, vary_ips):
"""
Simulate an attack by a random hacker.
Parameters:
- when: The datetime to start the attack.
- user_list: The list of users to try to hack.
- vary_ips: Whether or not to vary the IP address.
Returns:
Initial IP address and the end time for recording.
"""
hacker_ip = random_ip_generator()
random.shuffle(user_list)
for user in user_list:
when = self._hacker_attempts_login(
when=when, username=user,
source_ip=random_ip_generator() if vary_ips \
else hacker_ip
)
return hacker_ip, when
现在我们已经具备了执行仿真主要部分的功能,我们编写 simulate() 方法将所有内容整合在一起:
def simulate(self, *, attack_prob, try_all_users_prob,
vary_ips):
"""
Simulate login attempts.
Parameters:
- attack_probs: Probability of attack in given hour
- try_all_users_prob: Prob. hacker will try to
guess credentials for all users vs random subset.
- vary_ips: Whether to vary the IP address.
"""
hours_in_date_range = math.floor(
(self.end - self.start).total_seconds() / 60 / 60
)
for offset in range(hours_in_date_range + 1):
current = self.start + dt.timedelta(hours=offset)
# simulate hacker
if random.random() < attack_prob:
attack_start = current \
+ dt.timedelta(hours=random.random())
source_ip, end_time = self._hack(
when=attack_start,
user_list=self.users if \
random.random() < try_all_users_prob \
else random.sample(
self.users,
random.randint(0, len(self.users))
),
vary_ips=vary_ips
)
self.hack_log = self.hack_log.append(
dict(
start=attack_start, end=end_time,
source_ip=source_ip
), ignore_index=True
)
# simulate valid users
hourly_arrivals, interarrival_times = \
self._valid_user_arrivals(current)
random_user = random.choice(self.users)
random_ip = \
random.choice(self.user_base[random_user])
for i in range(hourly_arrivals):
current += \
dt.timedelta(hours=interarrival_times[i])
current = self._valid_user_attempts_login(
current, random_user
)
我们希望将日志保存为 CSV 文件,因此我们将 _save() 方法作为静态方法添加,以减少两个保存方法中的代码重复。save_log() 方法将保存登录尝试,而 save_hack_log() 方法将保存攻击记录:
@staticmethod
def _save(data, filename, sort_column):
"""Sort data by the datetime and save to a CSV file."""
data.sort_values(sort_column)\
.to_csv(filename, index=False)
def save_log(self, filename):
"""Save the login attempts log to a CSV file."""
self._save(self.log, filename, 'datetime')
def save_hack_log(self, filename):
"""Save the record of the attacks to a CSV file."""
self._save(self.hack_log, filename, 'start')
注意到这个类中有很多私有方法,这是因为该类的用户只需要能够创建该类的实例(__init__()),按小时进行仿真(simulate()),并保存输出(save_log() 和 save_hack_log())——所有其他方法仅供该类的对象内部使用。后台的这些方法将处理大部分工作。
最后,我们有 __init__.py 文件,这使得这个目录变成一个包,同时也为我们提供了一种更容易的方式来导入主类:
"""Package for simulating login data."""
from .login_attempt_simulator import LoginAttemptSimulator
现在我们已经了解了仿真器是如何工作的,接下来我们将讨论如何运行仿真以收集登录尝试的数据。
从命令行进行仿真
我们可以将模拟登录尝试的代码包装成一个可以轻松通过命令行运行的脚本,而不是每次都写代码。Python 标准库中有 argparse 模块(docs.python.org/3/library/argparse.html),允许我们为脚本指定可以从命令行提供的参数。
让我们来看看 simulate.py 文件,看看如何做到这一点。我们从导入开始:
"""Script for simulating login attempts."""
import argparse
import datetime as dt
import os
import logging
import random
import login_attempt_simulator as sim
为了在命令行中使用时提供状态更新,我们将使用标准库中的 logging 模块设置日志消息(docs.python.org/3/library/logging.html):
# Logging configuration
FORMAT = '[%(levelname)s] [ %(name)s ] %(message)s'
logging.basicConfig(level=logging.INFO, format=FORMAT)
logger = logging.getLogger(os.path.basename(__file__))
接下来,我们定义了一些实用函数,用于生成我们在仿真过程中读取和写入数据时需要的文件路径:
def get_simulation_file_path(path_provided, directory,
default_file):
"""Get filepath, make directory if necessary."""
if path_provided:
file = path_provided
else:
if not os.path.exists(directory):
os.mkdir(directory)
file = os.path.join(directory, default_file)
return file
def get_user_base_file_path(path_provided, default_file):
"""Get the path for a user_data directory file."""
return get_simulation_file_path(
path_provided, 'user_data', default_file
)
def get_log_file_path(path_provided, default_file):
"""Get the path for a logs directory file."""
return get_simulation_file_path(
path_provided, 'logs', default_file
)
这个脚本的最大部分定义了可以传递的命令行参数——我们将允许用户指定是否要创建一个新的用户基础,设置种子,仿真开始时间,仿真时长,以及保存所有文件的位置。实际的仿真通过我们构建的包在几行代码内完成。这个部分只有在运行该模块时才会执行,而不是在导入时:
if __name__ == '__main__':
# command-line argument parsing
parser = argparse.ArgumentParser()
parser.add_argument(
'days', type=float,
help='number of days to simulate from start'
)
parser.add_argument(
'start_date', type=str,
help="datetime to start in the form 'YYYY-MM-DD(...)'"
)
parser.add_argument(
'-m', '--make', action='store_true',
help='make user base'
)
parser.add_argument(
'-s', '--seed', type=int,
help='set a seed for reproducibility'
)
parser.add_argument(
'-u', '--userbase',
help='file to write the user base to'
)
parser.add_argument(
'-i', '--ip',
help='file to write user-IP address map to'
)
parser.add_argument(
'-l', '--log', help='file to write the attempt log to'
)
parser.add_argument(
'-hl', '--hacklog',
help='file to write the hack log to'
)
提示
if __name__ == '__main__' 块中放置的代码只有在该模块作为脚本运行时才会被执行。这样,我们就可以在不运行仿真的情况下导入模块中定义的函数。
定义好参数后,我们需要解析它们才能使用:
args = parser.parse_args()
一旦我们解析了命令行参数,就检查是否需要生成用户基础数据或读取它:
user_ip_mapping_file = \
get_user_base_file_path(args.ip, 'user_ips.json')
if args.make:
logger.warning(
'Creating new user base, mapping IP addresses.'
)
user_base_file = get_user_base_file_path(
args.userbase, 'user_base.txt'
)
# seed the creation of user base
random.seed(args.seed)
# create usernames and write to file
sim.utils.make_user_base(user_base_file)
# create 1 or more IP addresses per user, save mapping
valid_users = sim.utils.get_valid_users(user_base_file)
sim.utils.save_user_ips(
sim.utils.assign_ip_addresses(valid_users),
user_ip_mapping_file
)
之后,我们从命令行参数中解析起始日期,并通过将持续时间添加到起始日期来确定结束日期:
try:
start = \
dt.datetime(*map(int, args.start_date.split('-')))
except TypeError:
logger.error('Start date must be in "YYYY-MM-DD" form')
raise
except ValueError:
logger.warning(
f'Could not interpret {args.start_date}, '
'using January 1, 2020 at 12AM as start instead'
)
start = dt.datetime(2020, 1, 1)
end = start + dt.timedelta(days=args.days)
提示
try clause and multiple except clauses. We can specify how to handle specific errors occurring during code execution (called except clause. In this case, we have the logger object print a more helpful message for the user, and then re-raise the same exception (because we don't intend to handle it) by simply writing raise. This ends the program—the user can then try again with valid input. Try triggering this exception to see how much more useful this is. One thing to keep in mind, though, is that order matters—be sure to handle specific exceptions before having a general except clause; otherwise, the code specific to each exception type will never trigger. Also, note that using except without providing a specific exception will catch everything, even exceptions not meant to be caught.
最后,我们运行实际的模拟并将结果写入指定的文件(或默认路径)。我们将某一小时内发生攻击的概率设置为 10%(attack_prob),黑客尝试猜测所有用户名的概率设置为 20%(try_all_users_prob),并让黑客在所有尝试中使用相同的 IP 地址(vary_ips):
try:
logger.info(f'Simulating {args.days} days...')
simulator = sim.LoginAttemptSimulator(
user_ip_mapping_file, start, end, seed=args.seed
)
simulator.simulate(
attack_prob=0.1, try_all_users_prob=0.2,
vary_ips=False
)
# save logs
logger.info('Saving logs')
simulator.save_hack_log(
get_log_file_path(args.hacklog, 'attacks.csv')
)
simulator.save_log(
get_log_file_path(args.log, 'log.csv')
)
logger.info('All done!')
except:
logger.error('Oops! Something went wrong...')
raise
提示
请注意,我们使用了logger对象在脚本中打印有用的信息到屏幕;这将帮助脚本用户了解进程进行到什么阶段。这些信息有不同的严重级别(我们在这里使用的是INFO、WARNING和ERROR),允许它们用于调试(DEBUG级别),并且在代码进入生产阶段后,打印的最小级别可以提高到INFO,这样就不会再打印DEBUG信息。这比简单的print()语句要强大得多,因为我们不需要担心在进入生产环境时删除它们,或者在开发继续进行时再将这些信息加回去。
现在让我们来看看如何运行这个脚本。我们知道simulate.py可以在命令行上运行,但我们如何查看需要传递哪些参数呢?很简单——我们只需在调用时添加帮助标志(-h或--help):
(book_env) $ python3 simulate.py -h
usage: simulate.py [-h] [-m] [-s SEED] [-u USERBASE] [-i IP]
[-l LOG] [-hl HACKLOG]
days start_date
positional arguments:
days number of days to simulate from start
start_date datetime to start in the form
'YYYY-MM-DD' or 'YYYY-MM-DD-HH'
optional arguments:
-h, --help show this help message and exit
-m, --make make user base
-s SEED, --seed SEED set a seed for reproducibility
-u USERBASE, --userbase USERBASE
file to write the user base to
-i IP, --ip IP file to write the user-IP address
map to
-l LOG, --log LOG file to write the attempt log to
-hl HACKLOG, --hacklog HACKLOG
file to write the hack log to
重要提示
请注意,当我们使用argparse添加其他参数时,并没有指定help参数;它是由argparse自动创建的。
一旦我们知道可以传递哪些参数,并决定了我们想要提供哪些参数,我们就可以运行模拟了。让我们模拟从 2018 年 11 月 1 日 12 点开始的 30 天,并让脚本创建所需的用户基础和 IP 地址映射:
(book_env) $ python3 simulate.py -ms 0 30 '2018-11-01'
[WARNING] [ simulate.py ] Creating new user base and mapping IP addresses to them.
[INFO] [ simulate.py ] Simulating 30.0 days...
[INFO] [ simulate.py ] Saving logs
[INFO] [ simulate.py ] All done!
提示
由于我们设置了种子(-s 0),因此此模拟的输出是可重现的。只需移除种子或更改它,就可以得到不同的结果。
Python 模块也可以作为脚本运行。与导入模块不同,当我们将模块作为脚本运行时,if __name__ == '__main__'下方的任何代码也会被执行,这意味着我们不总是需要编写单独的脚本。我们构建的大多数模块只定义了函数和类,因此作为脚本运行并不会有任何效果;然而,我们在第一章《数据分析简介》中创建虚拟环境时,正是这种方式。前面的代码块因此等同于以下命令:
# leave off the .py
(book_env) $ python3 -m simulate -ms 0 30 "2018-11-01"
现在我们已经有了模拟数据,开始进行分析吧。
探索性数据分析
在这种情况下,我们有幸可以访问标记数据(logs/attacks.csv),并将其用于研究如何区分合法用户和攻击者。然而,这种情况在我们离开研究阶段并进入应用阶段时往往无法实现。在第十一章《机器学习异常检测》中,我们将重新审视这一场景,但从没有标记数据开始,以增加挑战。像往常一样,我们先进行导入并读取数据:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import pandas as pd
>>> import seaborn as sns
>>> log = pd.read_csv(
... 'logs/log.csv', index_col='datetime', parse_dates=True
... )
登录尝试数据框(log)包含了每次尝试的日期和时间(datetime列)、来源的 IP 地址(source_ip)、使用的用户名(username)、是否成功(success)以及如果失败的话失败原因(failure_reason):

图 8.3 – 登录尝试数据示例
在处理这些数据时,我们需要思考什么是正常活动,什么是黑客活动。两者之间的任何显著差异都可能被用来识别黑客。我们预计合法用户的成功率较高,最常见的失败原因是密码错误。我们预计用户会从不同的 IP 地址登录(手机、家用电脑、工作电脑以及其他可能的设备),并且有可能人们共享设备。由于我们不了解该网络应用程序的性质,我们不能说多次登录是否正常。我们也不知道这些数据的时区,因此不能对登录时间做出任何推断。理论上,我们可以查看这些 IP 地址来自哪些国家,但由于有方法可以隐藏 IP 地址,因此我们不会走这条路。基于现有数据,我们可以选择以下几种可行的方式:
-
调查登录尝试和失败的任何异常波动(无论是总体还是按 IP 地址统计)。
-
检查失败原因是用户名错误的情况。
-
查看每个 IP 地址的失败率。
-
查找尝试使用多个不同用户名登录的 IP 地址。
另一个需要注意的点是,我们希望尽早标记异常行为,而不是等到最后。等待一个月才标记某个行为的价值较低(随着时间推移,价值迅速下降),因此我们需要找到更早标记异常的方式;例如,使用每小时的频率。由于我们处于研究阶段,可以使用一些标记数据:
>>> attacks = pd.read_csv(
... 'logs/attacks.csv',
... converters={
... 'start': np.datetime64,
... 'end': np.datetime64
... }
... ) # make start and end columns datetimes but not the index
这些数据是针对网络应用程序(attacks)的攻击记录。它包含了攻击开始的日期和时间(start)、攻击结束的日期和时间(end)以及与攻击相关的 IP 地址(source_ip):

图 8.4 – 标记数据示例
使用shape属性,我们可以看到 72 次攻击和 12,836 次来自有效用户和恶意用户的登录尝试,使用nunique(),我们看到 22%的 IP 地址与攻击相关:
>>> attacks.shape, log.shape
((72, 3), (12836, 4))
>>> attacks.source_ip.nunique() / log.source_ip.nunique()
0.22018348623853212
重要提示
通常情况下,仅凭这些数据很难知道攻击发生的具体时间——攻击可以在不被发现的情况下持续很长时间,即使是这样,也并不容易将攻击者的行为与正常用户的行为区分开来。
我们的数据相当干净(毕竟我们就是为这个目的设计的),所以让我们看看通过执行一些探索性数据分析(EDA)是否能发现有趣的东西。首先,让我们看看每小时的登录尝试数量:
>>> log.assign(attempts=1).attempts.resample('1H').sum()\
... .plot(figsize=(15, 5), title='hourly attempts')\
... .set(xlabel='datetime', ylabel='attempts')
几个小时的登录尝试出现了非常大的峰值,这可能是攻击发生的时间。使用这张图,我们可以报告登录尝试活动较高的小时数,但仅此而已:

图 8.5 – 每小时登录尝试
另一个有趣的探索方向是查看每个 IP 地址的尝试次数。我们可以通过运行以下命令来实现这一点:
>>> log.source_ip.value_counts().describe()
count 327.000000
mean 39.253823
std 69.279330
min 1.000000
25% 5.000000
50% 10.000000
75% 22.500000
max 257.000000
Name: source_ip, dtype: float64
这些数据显然有一些异常值,它们将每个 IP 地址的尝试次数拉得很高。让我们创建一些图表来更好地评估这一点:
>>> fig, axes = plt.subplots(1, 2, figsize=(15, 5))
>>> log.source_ip.value_counts()\
... .plot(kind='box', ax=axes[0]).set_ylabel('attempts')
>>> log.source_ip.value_counts()\
... .plot(kind='hist', bins=50, ax=axes[1])\
... .set_xlabel('attempts')
>>> fig.suptitle('Attempts per IP Address')
每个 IP 地址的尝试分布是有效用户和攻击者分布的总和。直方图显示这个分布是双峰的,但仅凭这张图我们无法确定所有高尝试次数的 IP 地址是否都是黑客:

图 8.6 – 每个 IP 地址的登录尝试分布
由于我们可以访问每次攻击的详细信息,我们可以检查直方图的右侧部分是否是黑客的分布。根据尝试次数,黑客的 IP 地址占前列 IP 地址的 88.9%:
>>> num_hackers = attacks.source_ip.nunique()
>>> log.source_ip.value_counts().index[:num_hackers]\
... .isin(attacks.source_ip).sum() / num_hackers
0.8888888888888888
我们可以在此停止,并标记出每个月尝试次数最多的 IP 地址列表,但我们更可能需要一个更具鲁棒性的解决方案,因为黑客每次都可能更改 IP 地址来避免检测。理想情况下,我们还希望在不等待完整一个月的数据的情况下就能检测到攻击。然而,通过查看每个 IP 地址的每小时尝试次数,遗憾的是,似乎并没有提供太多信息:
>>> log.assign(attempts=1).groupby('source_ip').attempts\
... .resample('1H').sum().unstack().mean()\
... .plot(
... figsize=(15, 5),
... title='average hourly attempts per IP address'
... ).set_ylabel('average hourly attempts per IP address')
请回想一下来自第一章的内容,数据分析简介,其中提到均值对异常值不具鲁棒性。如果攻击者进行了多次尝试,异常值会将每个 IP 地址的平均每小时登录尝试次数拉高。我们可以在这条线图中看到几个大的峰值,但注意到它们中的许多只有两到三次。我们真的能期望一个用户只通过一个 IP 地址访问 Web 应用程序吗?这很可能不是一个现实的假设:

图 8.7 – 每个 IP 地址的平均每小时登录尝试次数
所以,如果我们不能仅依赖 IP 地址(毕竟,黑客可能足够聪明,能够将攻击分布到多个不同的地址上),我们还能尝试什么呢?或许黑客在成功登录方面遇到了更多困难:
>>> log[log.source_ip.isin(attacks.source_ip)]\
... .success.value_counts(normalize=True)
False 0.831801
True 0.168199
Name: success, dtype: float64
黑客成功的概率只有 17%,但合法用户的成功率有多高呢?这些信息对于确定网站正常行为的基准值非常重要。正如我们所预期的,合法用户的成功率要高得多:
>>> log[~log.source_ip.isin(attacks.source_ip)]\
... .success.value_counts(normalize=True)
True 0.873957
False 0.126043
Name: success, dtype: float64
由于日志中包含了登录失败的原因,我们可以使用交叉表来查看黑客和合法用户未能成功登录的原因。这里的任何差异都可能帮助我们区分这两组用户:
>>> pd.crosstab(
... index=pd.Series(
... log.source_ip.isin(attacks.source_ip),
... name='is_hacker'
... ), columns=log.failure_reason
... )
合法用户有时会输入错误的密码或用户名,但黑客在同时输入正确的用户名和密码时会遇到更多问题:

图 8.8 – 登录失败尝试的原因
合法用户在输入凭证时不会犯太多错误,因此如果黑客尝试登录的次数很多,并且涉及多个用户,我们可以标记这种行为。为了确认这一点,我们可以查看每个用户的平均每小时尝试次数:
>>> log.assign(attempts=1).groupby('username').attempts\
... .resample('1H').sum().unstack().mean()\
... .plot(figsize=(15, 5),
... title='average hourly attempts per user')\
... .set_ylabel('average hourly attempts per user')
大多数情况下,每个用户名每小时的尝试次数不到一次。这个指标的波动也不能保证就是攻击的迹象。或许是网站正在进行闪购活动;在这种情况下,我们很可能会看到由合法用户引起的这个指标的激增:

图 8.9 – 每个用户名的平均每小时登录尝试次数
根据我们的发现,错误率似乎是检测攻击的最有用指标,因此我们将研究错误率高的 IP 地址。为此,我们可以创建一个透视表来计算一些有用的指标:
>>> pivot = log.pivot_table(
... values='success', index=log.source_ip,
... columns=log.failure_reason.fillna('success'),
... aggfunc='count', fill_value=0
... )
>>> pivot.insert(0, 'attempts', pivot.sum(axis=1))
>>> pivot = pivot.sort_values('attempts', ascending=False)\
... .assign(
... success_rate=lambda x: x.success / x.attempts,
... error_rate=lambda x: 1 - x.success_rate
... )
>>> pivot.head()
提示
insert()方法允许我们将新创建的attempts列插入到当前数据框的特定位置,并且是就地操作。我们创建了attempts列,它是错误和成功的总和(我们将failure_reason列中的NaN值用success填充,以便在此处进行计数),通过在axis=1方向上进行求和。
这生成了以下按尝试次数排序的透视表(从最多到最少):

图 8.10 – 每个 IP 地址的度量
我们知道某些 IP 地址正在进行多次尝试,因此值得调查每个 IP 地址尝试登录的用户名数量;我们预计合法用户只会从少数几个 IP 地址登录,并且不会与许多其他用户共享他们的 IP 地址。这可以通过分组和聚合来确定:
>>> log.groupby('source_ip').agg(dict(username='nunique'))\
... .username.value_counts().describe()
count 53.000000
mean 6.169811
std 34.562505
min 1.000000
25% 1.000000
50% 1.000000
75% 2.000000
max 253.000000
Name: username, dtype: float64
这看起来确实是隔离恶意用户的好策略。大多数 IP 地址由两名或更少的用户使用,但最大值为 253 个。虽然这个标准可能有助于我们识别一些攻击者,但如果黑客足够聪明,可以在攻击过程中不断更换 IP 地址,这个标准就无法起到作用了。
在我们继续讨论异常检测方法之前,先看看我们是否能通过视觉识别出黑客。让我们为每个 IP 地址的成功次数和尝试次数绘制散点图:
>>> pivot.plot(
... kind='scatter', x='attempts', y='success', alpha=0.25,
... title='successes vs. attempts by IP address'
... )
似乎有几个明显的聚类。在图的左下角,我们看到一些点形成了一条成功与尝试呈一对一关系的线。右上部分则包含一个较为稀疏的聚类,尝试次数较高,成功次数适中。由于我们使用了alpha参数来控制透明度,我们可以看到,似乎连接这两个聚类的点迹并不密集。即便没有坐标轴的比例尺,我们也能预测左下角的聚类是普通用户,右上角的聚类是黑客(因为我们假设普通用户比黑客多,而且普通用户的成功率较高)。然而,中间的点则更难判断:

图 8.11 – 每个 IP 地址的成功与尝试的散点图
在不做任何假设的情况下,我们可以绘制一条边界线,将中间的点与其最近的聚类分组:
>>> ax = pivot.plot(
... kind='scatter', x='attempts', y='success', alpha=0.25,
... title='successes vs. attempts by IP address'
... )
>>> plt.axvline(
... 125, label='sample boundary',
... color='red', linestyle='--'
... )
>>> plt.legend(loc='lower right')
当然,在缺乏标注数据的情况下,评估这个决策边界的有效性是困难的:

图 8.12 – 可视化决策边界
幸运的是,我们有黑客使用的 IP 地址数据,因为我们已经获得了标注数据来进行研究,所以我们可以使用seaborn来实际看到这种分离:
>>> fig, axes = plt.subplots(1, 2, figsize=(15, 5))
>>> for ax in axes:
... sns.scatterplot(
... y=pivot.success, x=pivot.attempts,
... hue=pivot.assign(
... is_hacker=\
... lambda x: x.index.isin(attacks.source_ip)
... ).is_hacker,
... ax=ax, alpha=0.5
... )
... for spine in ['top', 'right']: # make less boxy
... ax.spines[spine].set_visible(False)
>>> axes[1].set_xscale('log')
>>> plt.suptitle('successes vs. attempts by IP address')
我们关于存在两个明显聚类的直觉完全正确。然而,中间区域的判断则要复杂得多。左侧的蓝色(较深)点似乎呈向上排列,而左侧的橙色(较浅)点则跟随一条通向橙色聚类的线条。通过绘制尝试次数的对数,我们能更好地将橙色中间点和蓝色点分开:

图 8.13 – 使用标注数据来验证我们的直觉
记住,我们还可以使用箱型图来检查是否存在异常值,异常值会显示为点。让我们看看每个 IP 地址的成功与尝试分布情况:
>>> pivot[['attempts', 'success']].plot(
... kind='box', subplots=True, figsize=(10, 3),
... title='stats per IP address'
... )
被标记为离群值的点与我们之前绘制的散点图右上角的点重合:

图 8.14 – 检查异常值
现在我们已经对数据有了充分的了解,我们准备学习如何实现一些简单的异常检测策略。
实现基于规则的异常检测
是时候抓住那些黑客了。在前一节的 EDA(探索性数据分析)之后,我们已经对如何进行此操作有了一定的了解。实际上,这要困难得多,因为它涉及更多的维度,但我们在这里简化了处理过程。我们希望找到那些尝试次数过多但成功率较低的 IP 地址,以及那些使用比我们认为正常的更多独特用户名进行登录尝试的 IP 地址(异常行为)。为了实现这一点,我们将采用基于阈值的规则作为我们进行异常检测的第一步;接下来,在第十一章《机器学习异常检测》中,我们将探讨一些机器学习技术,并重新审视这一场景。
由于我们有兴趣标记可疑的 IP 地址,我们将安排数据,以便每个 IP 地址有每小时的聚合数据(如果该小时内有活动):
>>> hourly_ip_logs = log.assign(
... failures=lambda x: np.invert(x.success)
... ).groupby('source_ip').resample('1H').agg({
... 'username': 'nunique', 'success': 'sum',
... 'failures': 'sum'
... }).assign(
... attempts=lambda x: x.success + x.failures,
... success_rate=lambda x: x.success / x.attempts,
... failure_rate=lambda x: 1 - x.success_rate
... ).dropna().reset_index()
提示
np.invert()函数是一个轻松翻转布尔值的方法。它将True转换为False,将False转换为True,适用于 NumPy 数组结构。
聚合后的数据如下所示:

图 8.15 – 每个 IP 地址的每小时聚合数据
最简单的基于规则的异常检测方法是计算阈值,并检查数据是否超出该阈值。这可能意味着值低于某个下限阈值,或超过某个上限阈值。由于我们关注的是登录尝试,我们对高于正常水平的值感兴趣。因此,我们将计算上限阈值,并将其与我们的数据进行比较。
百分比差异
假设我们对网站上正常的登录尝试活动(排除黑客的影响)有一定了解,我们可以通过某个百分比的偏差来标记那些与此偏离的值。为了计算这个基准,我们可以随机抽取一些 IP 地址(每小时重复抽取),并计算它们的平均登录尝试次数。由于数据量较少(每个小时大约有 50 个独特的 IP 地址可供选择),我们采用自助法(bootstrap)。
为了实现这一点,我们可以编写一个函数,该函数接受我们刚刚创建的聚合数据框,并传入每一列的数据统计名称作为计算阈值的起始点:
>>> def get_baselines(hourly_ip_logs, func, *args, **kwargs):
... """
... Calculate hourly bootstrapped statistic per column.
...
... Parameters:
... - hourly_ip_logs: Data to sample from.
... - func: Statistic to calculate.
... - args: Additional positional arguments for `func`
... - kwargs: Additional keyword arguments for `func`
...
... Returns:
... `DataFrame` of hourly bootstrapped statistics
... """
... if isinstance(func, str):
... func = getattr(pd.DataFrame, func)
...
... return hourly_ip_logs.assign(
... hour=lambda x: x.datetime.dt.hour
... ).groupby('hour').apply(
... lambda x: x\
... .sample(10, random_state=0, replace=True)\
... .pipe(func, *args, **kwargs, numeric_only=True)
... )
重要提示
random_state is used with sample() for reproducibility; however, in practice, we will probably not want to always pick the same rows.
请注意,如果我们在apply()内使用sample(),在按我们想要抽样的列分组后,我们可以为所有组(这里是小时)获得大小相等的样本。这意味着我们在每小时对每列进行有放回的抽样,选择 10 行。我们必须按小时进行抽样,因为如果进行简单的随机抽样,可能会没有每小时的统计数据。让我们使用get_baselines()通过均值计算列基准:
>>> averages = get_baselines(hourly_ip_logs, 'mean')
>>> averages.shape
(24, 7)
提示
如果我们想进行分层随机抽样,可以将get_baselines()函数中的10替换为x.shape[0] * pct,其中pct是我们希望从每个组中抽取的百分比。
每列都包含通过随机选择的 10 个 IP 地址估算正常行为的每小时均值。然而,这种方法并不能保证我们不会将黑客活动混入基准计算中。例如,我们来看一下故障率基准值最高的六个小时:
>>> averages.nlargest(6, 'failure_rate')
我们可能会发现很难在19、23或14点钟时标记任何活动为异常,因为这些小时的故障率和尝试过的独特用户名都很高:

图 8.16 – 使用均值计算的每小时基准
为了应对这个问题,我们可以通过让排名前* x *%的值在基准计算中无效来修剪我们的摘要统计信息。我们将从每个小时的数据中移除超过第 95 百分位的数据。首先,我们编写一个函数,用于修剪某个小时内超出给定分位数的行:
>>> def trim(x, quantile):
... """
... Remove rows with entries for the username, attempts,
... or failure_rate columns above a given quantile.
... """
... mask = (
... (x.username <= x.username.quantile(quantile)) &
... (x.attempts <= x.attempts.quantile(quantile)) &
... (x.failure_rate
... <= x.failure_rate.quantile(quantile))
... )
... return x[mask]
接下来,我们将按小时对 IP 地址数据进行分组,并应用我们的修剪函数。由于我们将使用自举函数,因此需要清理一些由此操作产生的多余列,所以我们删除hour列,重置索引,然后移除分组列和旧的索引:
>>> trimmed_hourly_logs = hourly_ip_logs\
... .assign(hour=lambda x: x.datetime.dt.hour)\
... .groupby('hour').apply(lambda x: trim(x, 0.95))\
... .drop(columns='hour').reset_index().iloc[:,2:]
现在,我们可以使用get_baselines()函数,通过平均值和修剪后的数据来获取我们的基准:
>>> averages = get_baselines(trimmed_hourly_logs, 'mean')
>>> averages.iloc[[19, 23, 3, 11, 14, 16]]
经过修剪后的基准在19、23和14小时与图 8.16相比有了显著变化:

图 8.17 – 使用均值修剪后的每小时基准
现在我们已经有了基准,接下来我们来写一个函数,负责从基准和每列的百分比差异中计算阈值,并返回被标记为黑客的 IP 地址:
>>> def pct_change_threshold(hourly_ip_logs, baselines,
... pcts=None):
... """
... Return flagged IP addresses based on thresholds.
...
... Parameters:
... - hourly_ip_logs: Aggregated data per IP address.
... - baselines: Hourly baselines per column in data.
... - pcts: Dictionary of custom percentages per column
... for calculating upper bound thresholds
... (baseline * pct). If not provided, pct will be 1
...
... Returns: `Series` containing the IP addresses flagged.
... """
... pcts = {} if not pcts else pcts
...
... return hourly_ip_logs.assign(
... hour=lambda x: x.datetime.dt.hour
... ).join(
... baselines, on='hour', rsuffix='_baseline'
... ).assign(
... too_many_users=lambda x: x.username_baseline \
... * pcts.get('username', 1) <= x.username,
... too_many_attempts=lambda x: x.attempts_baseline \
... * pcts.get('attempts', 1) <= x.attempts,
... high_failure_rate=lambda x: \
... x.failure_rate_baseline \
... * pcts.get('failure_rate', 1) <= x.failure_rate
... ).query(
... 'too_many_users and too_many_attempts '
... 'and high_failure_rate'
... ).source_ip.drop_duplicates()
pct_change_threshold()函数使用一系列链式操作来返回被标记的 IP 地址:
-
首先,它将基准与
hour列上的每小时 IP 地址日志连接。由于所有基准列的名称与每小时 IP 地址日志相同,并且我们不希望根据这些名称进行连接,因此我们在列名后添加'_baseline'后缀。 -
之后,所有需要检查是否超出阈值的数据都在同一个数据框中。我们使用
assign()方法创建三个新的布尔列,表示我们每个条件(过多用户、过多尝试和高失败率)是否被违反。 -
然后,我们链式调用
query()方法,这样我们就可以轻松选择所有布尔列值为True的行(注意我们无需显式地写出<column> == True)。 -
最后,我们确保只返回 IP 地址,并删除任何重复项,以防相同的 IP 地址在多个小时内被标记。
为了使用这个函数,我们需要选择每个基线的百分比差异。默认情况下,这将是基线的 100%,而基线作为平均值,可能会标记出过多的 IP 地址。因此,让我们选择将每个标准的 IP 地址提高 25%的阈值:
>>> pct_from_mean_ips = pct_change_threshold(
... hourly_ip_logs, averages,
... {key: 1.25 for key in [
... 'username', 'attempts', 'failure_rate'
... ]}
... )
提示
我们使用的百分比存储在字典中,字典的键是对应列名,值是百分比。如果函数调用者没有提供这些值,我们会使用默认的 100%,因为我们使用get()方法从字典中选择。
这些规则标记了 73 个 IP 地址:
>>> pct_from_mean_ips.nunique()
73
重要提示
在实际操作中,我们可能不会对用于计算基线的条目运行此规则,因为它们的行为会影响基线的定义。
Tukey fence
正如我们在第一章《数据分析简介》中讨论的那样,均值对离群值并不稳健。如果我们觉得有很多离群值影响了我们的基线,我们可以返回到百分比差异,尝试使用中位数,或考虑使用Tukey fence。记住,在之前的章节中,我们提到 Tukey fence 的边界来自第一四分位数和第三四分位数,以及四分位间距(IQR)。因为我们只关心超出上限的值,这就解决了均值的问题,前提是离群值占数据量的比例小于 25%。我们可以使用以下公式来计算上限:

我们的get_baselines()函数仍然会帮助我们,但我们需要做一些额外的处理。我们将编写一个函数来计算 Tukey fence 的上限,并让我们测试不同的乘数(k)值。注意,我们在这里也可以选择使用 Tukey fence 的百分比:
>>> def tukey_fence_test(trimmed_data, logs, k, pct=None):
... """
... See which IP addresses get flagged with a Tukey fence
... with multiplier k and optional percent differences.
...
... Parameters:
... - trimmed_data: Data for calculating the baselines
... - logs: The data to test
... - k: The multiplier for the IQR
... - pct: Dictionary of percentages per column for use
... with `pct_change_threshold()`
...
... Returns:
... `pandas.Series` of flagged IP addresses
... """
... q3 = get_baselines(trimmed_data, 'quantile', .75)\
... .drop(columns=['hour'])
...
... q1 = get_baselines(trimmed_data, 'quantile', .25)\
... .drop(columns=['hour'])
...
... iqr = q3 - q1
... upper_bound = (q3 + k * iqr).reset_index()
...
... return pct_change_threshold(logs, upper_bound, pct)
让我们使用tukey_fence_test()函数,利用3的 IQR 乘数,抓取超出 Tukey fence 上限的 IP 地址:
>>> tukey_fence_ips = tukey_fence_test(
... trimmed_hourly_logs, hourly_ip_logs, k=3
... )
使用这种方法,我们标记了 83 个 IP 地址:
>>> tukey_fence_ips.nunique()
83
重要提示
我们在这里使用了 3 的乘数。然而,根据应用场景,我们可能会看到使用 1.5,以便更加宽松。实际上,我们可以使用任何数字;找到最佳值可能需要一些试错过程。
Z 得分
记住,在第一章《数据分析导论》中,我们还可以计算 Z 分数,并标记距离均值一定标准差的 IP 地址。我们之前写的pct_change_threshold()函数在这种情况下不适用,因为我们不仅仅是在与基线进行比较。相反,我们需要从所有值中减去基线的均值,并除以基线的标准差,因此我们必须重新设计我们的方法。
让我们编写一个新函数z_score_test(),通过任何标准差数量的均值上方作为截断值来执行 Z 分数测试。首先,我们将使用get_baselines()函数,通过修剪后的数据计算每小时的基线标准差。然后,我们将标准差和均值合并,添加后缀。这样,我们就可以将pct_change_threshold()的逻辑应用于这个任务:
>>> def z_score_test(trimmed_data, logs, cutoff):
... """
... See which IP addresses get flagged with a Z-score
... greater than or equal to a cutoff value.
...
... Parameters:
... - trimmed_data: Data for calculating the baselines
... - logs: The data to test
... - cutoff: Flag row when z_score >= cutoff
...
... Returns:
... `pandas.Series` of flagged IP addresses
... """
... std_dev = get_baselines(trimmed_data, 'std')\
... .drop(columns=['hour'])
... averages = get_baselines(trimmed_data, 'mean')\
... .drop(columns=['hour'])
...
... return logs.assign(hour=lambda x: x.datetime.dt.hour)\
... .join(std_dev.join(
... averages, lsuffix='_std', rsuffix='_mean'
... ), on='hour')\
... .assign(
... too_many_users=lambda x: (
... x.username - x.username_mean
... )/x.username_std >= cutoff,
... too_many_attempts=lambda x: (
... x.attempts - x.attempts_mean
... )/x.attempts_std >= cutoff,
... high_failure_rate=lambda x: (
... x.failure_rate - x.failure_rate_mean
... )/x.failure_rate_std >= cutoff
... ).query(
... 'too_many_users and too_many_attempts '
... 'and high_failure_rate'
... ).source_ip.drop_duplicates()
让我们使用一个截断值为距离均值三倍标准差或更多的函数:
>>> z_score_ips = \
... z_score_test(trimmed_hourly_logs, hourly_ip_logs, 3)
使用此方法,我们标记了 62 个 IP 地址:
>>> z_score_ips.nunique()
62
重要提示
在实际应用中,Z 分数的截断值也是我们需要调整的一个参数。
性能评估
因此,我们现在有一系列的 IP 地址,分别对应于每组规则,但我们希望了解每种方法的效果如何(假设我们可以实际检查)。在这种情况下,我们拥有用于研究的攻击者 IP 地址,因此可以查看每种方法标记了多少个正确的地址——这在实践中并不简单;相反,我们可以标记过去发现的恶意地址,并在未来关注类似的行为。
这是一个包含两类的分类问题;我们希望将每个 IP 地址分类为有效用户或恶意用户。这使得我们有四种可能的结果,可以通过混淆矩阵来可视化:

图 8.18 – 混淆矩阵
在此应用中,这些结果的含义如下:
-
真正例 (TP):我们的方法标记它为恶意的,且它确实是恶意的。
-
真负例 (TN):我们的方法没有标记它,而且它不是恶意的。
-
假正例 (FP):我们的方法标记它为恶意的,但它其实不是恶意的。
-
假负例 (FN):我们的方法没有标记它,但它实际上是恶意的。
真正例和真负例表明我们的方法效果不错,但假正例和假负例是可能需要改进的地方(请记住,这永远不会是完美的)。现在让我们编写一个函数,帮助我们确定每种方法的表现:
>>> def evaluate(alerted_ips, attack_ips, log_ips):
... """
... Calculate true positives (TP), false positives (FP),
... true negatives (TN), and false negatives (FN) for
... IP addresses flagged as suspicious.
...
... Parameters:
... - alerted_ips: `Series` of flagged IP addresses
... - attack_ips: `Series` of attacker IP addresses
... - log_ips: `Series` of all IP addresses seen
...
... Returns:
... Tuple of the form (TP, FP, TN, FN)
... """
... tp = alerted_ips.isin(attack_ips).sum()
... tn = np.invert(np.isin(
... log_ips[~log_ips.isin(alerted_ips)].unique(),
... attack_ips
... )).sum()
... fp = np.invert(alerted_ips.isin(attack_ips)).sum()
... fn = np.invert(attack_ips.isin(alerted_ips)).sum()
... return tp, fp, tn, fn
在我们开始计算指标之前,先写一个部分函数,这样我们就不需要不断传递攻击者 IP 地址(attacks.source_ip)和日志中的 IP 地址(pivot.index)。记住,部分函数允许我们固定某些参数的值,然后稍后调用该函数:
>>> from functools import partial
>>> scores = partial(
... evaluate, attack_ips=attacks.source_ip,
... log_ips=pivot.index
... )
现在,让我们使用这个来计算一些指标以衡量我们的表现。一个常见的指标是假阳性率(FPR),它告诉我们假警报率。它是通过将假阳性与实际为负的所有情况的比例计算得出的:

假发现率(FDR)是另一种看待假警报的方式,它告诉我们不正确的正例的百分比:

让我们来看看我们使用与均值差异百分比的方法计算的假阳性率(FPR)和假发现率(FDR):
>>> tp, fp, tn, fn = scores(pct_from_mean_ips)
>>> fp / (fp + tn), fp / (fp + tp)
(0.00392156862745098, 0.0136986301369863)
另一个感兴趣的指标是假阴性率(FNR),它告诉我们我们未能检测到的内容(漏检率)。它通过将假阴性与实际为正的所有情况的比例计算得出:

观察假阴性的一种替代方式是假遗漏率(FOR),它告诉我们错误标记为负例的案例百分比:

我们的均值差异百分比方法没有假阴性,因此 FNR 和 FOR 都是零:
>>> fn / (fn + tp), fn / (fn + tn)
(0.0, 0.0)
这里通常存在一个权衡——我们是否想尽可能捕捉到更多的黑客活动,并冒着标记有效用户的风险(通过关注 FNR/FOR),还是我们希望避免给有效用户带来不便,冒着错过黑客活动的风险(通过最小化 FPR/FDR)?这些问题很难回答,将取决于具体领域,因为假阳性的代价不一定等于(甚至远低于)假阴性的代价。
提示
我们将在第九章中讨论更多可以用于评估我们性能的指标,《Python 中的机器学习入门》。
现在让我们编写一个函数来处理所有这些计算:
>>> def classification_stats(tp, fp, tn, fn):
... """Calculate metrics"""
... return {
... 'FPR': fp / (fp + tn), 'FDR': fp / (fp + tp),
... 'FNR': fn / (fn + tp), 'FOR': fn / (fn + tn)
... }
我们现在可以使用evaluate()函数的结果来计算我们的指标。对于均值差异百分比的方法,我们得到以下输出:
>>> classification_stats(tp, fp, tn, fn)
{'FPR': 0.00392156862745098, 'FDR': 0.0136986301369863,
'FNR': 0.0, 'FOR': 0.0}
看起来我们的三个标准表现得相当不错。如果我们担心在计算基准时会选择黑客 IP 地址,但又不想修剪数据,我们本可以使用中位数代替均值来运行:
>>> medians = get_baselines(hourly_ip_logs, 'median')
>>> pct_from_median_ips = pct_change_threshold(
... hourly_ip_logs, medians,
... {key: 1.25 for key in
... ['username', 'attempts', 'failure_rate']}
... )
使用中位数时,我们达到了与均值相似的表现。然而,在这种情况下,我们不需要提前修剪数据。这是因为中位数对离群值具有鲁棒性,意味着选择某个小时内的单个黑客 IP 地址并不会像均值那样影响该小时的基准:
>>> tp, fp, tn, fn = scores(pct_from_median_ips)
>>> classification_stats(tp, fp, tn, fn)
{'FPR': 0.00784313725490196, 'FDR': 0.02702702702702703,
'FNR': 0.0, 'FOR': 0.0}
为了比较讨论过的每种方法,我们可以使用字典推导式来填充一个DataFrame对象,包含性能指标:
>>> pd.DataFrame({
... method: classification_stats(*scores(ips))
... for method, ips in {
... 'means': pct_from_mean_ips,
... 'medians': pct_from_median_ips,
... 'Tukey fence': tukey_fence_ips,
... 'Z-scores': z_score_ips
... }.items()
... })
提示
scores()函数返回一个元组(tp, fp, tn, fn),但classification_stats()函数期望四个参数。然而,由于score()以classification_stats()期望的顺序返回这些值,我们可以使用*来解包元组并将这些值作为四个位置参数传递。
均值受异常值的影响,但一旦我们对数据进行修剪,它就成了一个可行的方法。我们不需要修剪数据就能处理中位数;中位数的有效性取决于数据中异常值的比例低于 50%。Tukey 围栏法进一步通过使用第三四分位数,并假设数据中异常值的比例低于 25%,将此方法做得更为严格。Z-score 方法也会受到异常值的影响,因为它使用均值;然而,经过修剪的数据使我们能够通过适当的三倍标准差阈值实现良好的性能:

图 8.19 – 性能比较
最终,我们在实际应用中使用哪种方法将取决于假阳性与假阴性带来的成本——是当什么都没有问题时发出警报更糟,还是当有问题时保持沉默更糟?在这种情况下,我们倾向于尽量减少假阴性,因为我们不想错过任何重要信息。
重要提示
异常检测的另一个常见应用是工业环境中的质量或过程控制,例如监控工厂设备的性能和产量。过程控制使用基于阈值和基于模式的规则来判断系统是否失控。这些方法可以用于判断基础数据的分布是否发生了变化,这可能是后续问题的前兆。西电规则和纳尔逊规则是常见的规则。两者的参考资料可以在本章末的进一步阅读部分找到。
总结
在我们的第二个应用章节中,我们学习了如何在 Python 中模拟事件,并额外接触了编写包的技巧。我们还学习了如何编写可以从命令行运行的 Python 脚本,这些脚本被用来运行我们的登录尝试数据模拟。接着,我们对模拟数据进行了探索性数据分析(EDA),以查看是否能够找出哪些因素使得黑客活动容易被识别。
这让我们聚焦于每小时每个 IP 地址尝试验证的不同用户名数量,以及尝试次数和失败率。利用这些指标,我们能够绘制出一个散点图,图中显示了两个不同的点群,另外还有一些连接两个点群的点;显然,这些点代表了有效用户和恶意用户群体,其中一些黑客并不像其他人那样显眼。
最后,我们开始制定规则,以标记出因可疑活动而被怀疑为黑客的 IP 地址。首先,我们使用pandas将数据按小时汇总成每个 IP 地址的数据。然后,我们编写函数,修剪出大于 95 百分位的数据,并计算每小时给定统计数据的基线,基于与均值和中位数的百分比差异、超出 Tukey 围栏上限以及使用 Z 分数来创建规则。我们发现,制定有效规则取决于仔细调整参数:均值和中位数差异的百分比、Tukey 围栏的乘数以及 Z 分数的阈值。为了确定哪个规则表现最好,我们使用了漏报率、假漏报率、假发现率和误报率。
在接下来的两章中,我们将介绍使用scikit-learn进行 Python 中的机器学习,并在第十一章《机器学习异常检测》中,我们将回顾这个场景,通过机器学习进行异常检测。
练习
完成以下练习以巩固本章所涵盖的概念:
-
对 2018 年 12 月的数据进行模拟,并将结果保存到新的日志文件中,而无需重新创建用户基础。确保运行
python3 simulate.py -h来查看命令行参数。将种子值设置为27。这些数据将用于剩余的练习。 -
找出每个 IP 地址的唯一用户名数、尝试次数、成功次数、失败次数,以及成功/失败率,使用的是从练习1中模拟的数据。
-
创建两个子图,左侧是失败次数与尝试次数的关系,右侧是失败率与唯一用户名数的关系。为结果图绘制决策边界。确保按是否为黑客 IP 地址为每个数据点着色。
-
构建一个基于中位数百分比差异的规则,标记一个 IP 地址,如果其失败次数和尝试次数都为各自中位数的五倍,或者唯一用户名数为中位数的五倍。确保使用一个小时的时间窗口。记得使用
get_baselines()函数计算基线所需的指标。 -
使用本章中的
evaluate()和classification_stats()函数计算指标,以评估这些规则的执行效果。
进一步阅读
查看以下资源,获取更多关于本章所讨论主题的信息:
-
引导法温和入门:
machinelearningmastery.com/a-gentle-introduction-to-the-bootstrap-method/ -
引导法简介:
towardsdatascience.com/an-introduction-to-the-bootstrap-method-58bcb51b4d60 -
向哈希算法中添加盐:存储密码的更好方式:
auth0.com/blog/adding-salt-to-hashing-a-better-way-to-store-passwords/ -
分类准确率不足:更多可以使用的性能衡量标准:
machinelearningmastery.com/classification-accuracy-is-not-enough-more-performance-measures-you-can-use/ -
离线密码破解:攻击与最佳防御:
www.alpinesecurity.com/blog/offline-password-cracking-the-attack-and-the-best-defense-against-it -
Python 中的概率分布:
www.datacamp.com/community/tutorials/probability-distributions-python -
彩虹表:你密码的噩梦:
www.lifewire.com/rainbow-tables-your-passwords-worst-nightmare-2487288 -
RFC 1597(私有互联网的地址分配):
www.faqs.org/rfcs/rfc1597.html -
抽样技术:
towardsdatascience.com/sampling-techniques-a4e34111d808
第四部分:使用 Scikit-Learn 入门机器学习
到目前为止,我们在本书中主要集中在使用pandas进行数据分析任务,但 Python 可以做的机器学习远不止这些。接下来的三章将作为在 Python 中使用scikit-learn进行机器学习的入门——这并不意味着我们将放弃到目前为止的所有内容。如我们所见,pandas是快速探索、清理、可视化和分析数据的必备工具——在进行任何机器学习之前,这些工作仍然是必不可少的。我们不会深入探讨理论,而是展示如何在 Python 中轻松实现机器学习任务,如聚类、分类和回归。
本节包括以下章节:
-
第九章,在 Python 中入门机器学习
-
第十章,做出更好的预测—优化模型
-
第十一章,机器学习中的异常检测
第十章:第九章:在 Python 中入门机器学习
本章将让我们了解机器学习的术语及其常见任务。之后,我们将学习如何准备数据以供机器学习模型使用。我们已经讨论过数据清洗,但仅限于供人类使用——机器学习模型需要使用scikit-learn构建预处理管道,以简化这一过程,因为我们的模型好坏取决于其训练的数据。
接下来,我们将讲解如何使用scikit-learn构建模型并评估其表现。Scikit-learn 具有非常友好的 API,因此一旦我们了解如何构建一个模型,就能构建任意数量的模型。我们不会深入探讨模型背后的数学原理,因为这方面有专门的书籍, 本章的目标是作为该主题的入门介绍。到本章结束时,我们将能够识别我们希望解决的是什么问题,以及可以帮助我们的算法类型,并且如何实现它们。
本章将涵盖以下主题:
-
机器学习领域概览
-
使用前几章学习的技能进行探索性数据分析
-
预处理数据以便用于机器学习模型
-
使用聚类帮助理解无标签数据
-
学习回归何时合适以及如何使用 scikit-learn 实现回归
-
理解分类任务并学习如何使用逻辑回归
本章材料
在本章中,我们将使用三个数据集。前两个数据集来自于 UCI 机器学习数据集库(archive.ics.uci.edu/ml/index.php)提供的关于葡萄酒质量的数据,这些数据由 P. Cortez、A. Cerdeira、F. Almeida、T. Matos 和 J. Reis 捐赠,其中包含了不同葡萄酒样本的化学性质信息,以及来自葡萄酒专家盲测小组对其质量的评分。这些文件可以在 GitHub 仓库中本章文件夹下的data/文件夹中找到,分别为红葡萄酒和白葡萄酒的winequality-red.csv和winequality-white.csv。
我们的第三个数据集是使用开放系外行星目录(Open Exoplanet Catalogue)数据库收集的,您可以在github.com/OpenExoplanetCatalogue/open_exoplanet_catalogue/找到该数据库。该数据库提供了在planet_data_collection.ipynb笔记本中的数据,GitHub 上包含了用于将这些信息解析为 CSV 文件的代码,我们将在本章中使用这些文件;虽然我们不会详细讨论这部分内容,但我鼓励您去查看它。数据文件也可以在data/文件夹中找到。我们将在本章中使用planets.csv,不过为进行练习和进一步探索,还提供了其他层级的解析数据。这些文件包括binaries.csv(包含双星的数据)、stars.csv(包含单颗星的数据)和systems.csv(包含行星系统的数据)。
我们将使用red_wine.ipynb笔记本来预测红酒质量,使用wine.ipynb笔记本根据红酒的化学特性将其分类为红酒或白酒,使用planets_ml.ipynb笔记本构建回归模型来预测行星的年长度,并进行聚类分析以找到相似的行星群体。我们将使用preprocessing.ipynb笔记本进行预处理部分的工作。
回到第一章,数据分析导论,当我们设置环境时,我们安装了一个来自 GitHub 的名为ml_utils的包。这个包包含了我们将在接下来的三章机器学习中使用的工具函数和类。与前两章不同,我们不会讨论如何构建这个包;不过,感兴趣的人可以浏览github.com/stefmolin/ml-utils/tree/2nd_edition中的代码,并按照第七章,金融分析——比特币与股市,中的说明,以可编辑模式安装它。
以下是数据源的参考链接:
-
开放系外行星目录(Open Exoplanet Catalogue)数据库,可在
github.com/OpenExoplanetCatalogue/open_exoplanet_catalogue/#data-structure找到。 -
P. Cortez, A. Cerdeira, F. Almeida, T. Matos 和 J. Reis. 通过数据挖掘物理化学特性来建模葡萄酒偏好. 见《决策支持系统》,Elsevier,47(4):547-553, 2009. 该文档可在线查看:
archive.ics.uci.edu/ml/datasets/Wine+Quality。 -
Dua, D. 和 Karra Taniskidou, E. (2017). UCI 机器学习库 [
archive.ics.uci.edu/ml/index.php]。加利福尼亚州尔湾:加利福尼亚大学信息与计算机科学学院。
机器学习概览
机器学习是人工智能(AI)的一个子集,其中算法可以从输入数据中学习预测值,而无需明确教授规则。这些算法依赖统计学进行推断,并在学习过程中使用所学内容来做出预测。
申请贷款、使用搜索引擎、通过语音命令让机器人吸尘器清洁特定房间——机器学习在我们周围随处可见。这是因为它可以用于许多目的,例如 AI 助手(如 Alexa、Siri 或 Google Assistant)的语音识别、通过探索周围环境绘制楼层图、判断谁会违约贷款、确定哪些搜索结果相关,甚至进行绘画(www.boredpanda.com/computer-deep-learning-algorithm-painting-masters/)。
机器学习模型可以随着时间的推移适应输入的变化,并且在做出决策时,减少每次都需要人工干预的情况。想象一下申请贷款或信用卡额度提升;银行或信用卡公司会依赖机器学习算法,查看申请人的信用评分及历史记录,以确定是否批准申请。很可能,只有当模型预测出申请人有较高的可信度时,银行才会在此时批准申请。如果模型不能做出如此确定的判断,则可以将决策交给人工处理。这减少了员工需要筛选的申请数量,确保只有边缘案例需要人工处理,同时也能为非边缘案例提供更快的响应(处理过程几乎可以瞬时完成)。
这里需要特别提到的一点是,用于贷款批准等任务的模型,根据法律规定,必须具备可解释性。必须有一种方式向申请人解释他们被拒绝的原因——有时,技术之外的原因可能会影响和限制我们使用的方案或数据。
机器学习的类型
机器学习通常分为三类:无监督学习、监督学习和强化学习。当我们没有标签数据指示每个数据点应对应的模型输出时,我们使用无监督学习。在许多情况下,收集标签数据成本高昂或根本不可行,因此会使用无监督学习。需要注意的是,优化这些模型的性能更为困难,因为我们不知道它们的表现如何。如果我们可以访问标签数据,就可以使用监督学习;这使得评估和改进我们的模型变得更加容易,因为我们可以根据模型与真实标签的比较来计算其性能指标。
提示
由于无监督学习旨在从数据中找到意义,而不依赖正确答案,它可以在数据分析过程中或在进行有监督学习之前用于更好地理解数据。
强化学习关注的是如何对来自环境的反馈做出反应;这通常用于机器人和游戏中的人工智能等应用。尽管这一部分超出了本书的范围,但在进一步阅读部分有相关资源可以获取更多信息。
请注意,并非所有的机器学习方法都可以完全归入上述类别。例如,深度学习旨在使用神经网络等方法学习数据表示。深度学习方法通常被视为“黑箱”,这使得它们在某些需要可解释模型的领域中的应用受限;然而,它们已被广泛应用于语音识别和图像分类等任务中。深度学习超出了本书的讨论范围,但了解它也是机器学习的一部分是很有帮助的。
重要提示
可解释机器学习是当前一个活跃的研究领域。有关更多信息,请查看进一步阅读部分的资源。
常见任务
最常见的机器学习任务有聚类、分类和回归。在聚类中,我们希望将数据划分为不同的组,目标是确保组内的数据密切相关且组与组之间相互分离。聚类可以以无监督的方式进行,以帮助更好地理解数据,或者以有监督的方式进行,尝试预测数据属于哪个组(本质上是分类)。需要注意的是,聚类也可以用于无监督的预测;然而,我们仍然需要解读每个簇的含义。从聚类中获得的标签甚至可以作为有监督学习的输入,帮助模型学习如何将观察结果映射到各个组,这种方法被称为半监督学习。
分类,如我们在上一章所讨论的,旨在为数据分配一个类标签,例如良性或恶意。这听起来像是将数据分配到某个簇中,然而,我们并不关心被标为良性的数据之间的相似度,只需要将它们标记为良性。由于我们是将数据分配到某个类别或类中,因此这类模型用于预测离散标签。而回归则是用于预测数值型数据,例如房价或图书销量;它用于建模变量之间的关系强度和大小。两者都可以作为无监督或有监督学习来进行;然而,有监督模型通常表现得更好。
Python 中的机器学习
现在我们知道了什么是机器学习,接下来我们需要了解如何构建自己的模型。Python 提供了许多用于构建机器学习模型的包;我们需要关注的一些库包括以下内容:
-
scikit-learn:易于使用(也容易学习),它提供了一个一致的 Python 机器学习 API(scikit-learn.org/stable/index.html) -
statsmodels:一个统计建模库,提供统计测试功能(www.statsmodels.org/stable/index.html) -
tensorflow:由 Google 开发的机器学习库,具有更快的计算速度(www.tensorflow.org/) -
keras:用于运行深度学习的高级 API,支持如 TensorFlow 等库(keras.io/) -
pytorch:由 Facebook 开发的深度学习库(pytorch.org)提示
这些库大多数使用了 NumPy 和 SciPy,SciPy 是基于 NumPy 构建的一个库,用于统计学、数学和工程目的。SciPy 可以用于处理线性代数、插值、积分和聚类算法等内容。更多关于 SciPy 的信息可以在
docs.scipy.org/doc/scipy/reference/tutorial/general.html找到。
在本书中,我们将使用scikit-learn,因为它的 API 用户友好。在scikit-learn中,我们的基类是fit()方法。我们使用transform()方法—将数据转换为可以由predict()方法使用的形式。score()方法也是常用的。仅了解这四个方法,我们就能轻松构建scikit-learn提供的任何机器学习模型。有关该设计模式的更多信息,请参考scikit-learn.org/stable/developers/develop.html。
探索性数据分析
正如我们在本书中学到的那样,第一步应该进行一些探索性数据分析(EDA),以便熟悉我们的数据。为了简洁起见,本节将包括每个笔记本中可用的 EDA 的一个子集—请确保查看相关笔记本,以获取完整版本。
提示
虽然我们将使用pandas代码来执行 EDA,但也请查看pandas-profiling包(github.com/pandas-profiling/pandas-profiling),它可以通过交互式 HTML 报告快速执行数据的初步 EDA。
让我们从导入库开始,这将在本章中我们使用的所有笔记本中保持一致:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import pandas as pd
>>> import seaborn as sns
我们将在进行行星数据分析之前,首先对红酒质量数据进行探索性数据分析(EDA)。
红酒质量数据
让我们读取红酒数据并利用本书中学到的技巧进行一些 EDA:
>>> red_wine = pd.read_csv('data/winequality-red.csv')
我们有关于红酒的 11 种不同化学属性的数据,以及一个列出参与盲品测试的酒类专家评分的列。我们可以通过观察这些化学属性来预测质量评分:

图 9.1 – 红葡萄酒数据集
让我们看看quality列的分布情况:
>>> def plot_quality_scores(df, kind):
... ax = df.quality.value_counts().sort_index().plot.barh(
... title=f'{kind.title()} Wine Quality Scores',
... figsize=(12, 3)
... )
... ax.axes.invert_yaxis()
... for bar in ax.patches:
... ax.text(
... bar.get_width(),
... bar.get_y() + bar.get_height()/2,
... f'{bar.get_width()/df.shape[0]:.1%}',
... verticalalignment='center'
... )
... plt.xlabel('count of wines')
... plt.ylabel('quality score')
...
... for spine in ['top', 'right']:
... ax.spines[spine].set_visible(False)
...
... return ax
>>> plot_quality_scores(red_wine, 'red')
数据集中的信息显示quality评分从 0(非常差)到 10(非常好)不等;然而,我们的数据中只有这个范围中间的值。对于这个数据集,一个有趣的任务是看我们是否能预测高质量的红葡萄酒(质量评分为 7 或更高):

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_9.2_B16834.jpg)
图 9.2 – 红葡萄酒质量评分分布
所有数据都是数值型数据,所以我们不需要担心处理文本值;并且数据中没有缺失值:
>>> red_wine.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1599 entries, 0 to 1598
Data columns (total 12 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 fixed acidity 1599 non-null float64
1 volatile acidity 1599 non-null float64
2 citric acid 1599 non-null float64
3 residual sugar 1599 non-null float64
4 chlorides 1599 non-null float64
5 free sulfur dioxide 1599 non-null float64
6 total sulfur dioxide 1599 non-null float64
7 density 1599 non-null float64
8 pH 1599 non-null float64
9 sulphates 1599 non-null float64
10 alcohol 1599 non-null float64
11 quality 1599 non-null int64
dtypes: float64(11), int64(1)
memory usage: 150.0 KB
我们可以使用describe()来了解每一列数据的尺度:
>>> red_wine.describe()
结果表明,如果我们的模型使用任何距离度量方法,我们肯定需要对数据进行缩放,因为我们的各列数据范围不一致:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_9.3_B16834.jpg)
图 9.3 – 红葡萄酒数据集的总结统计
最后,让我们使用pd.cut()将高质量的红葡萄酒(大约占数据的 14%)分箱,方便后续使用:
>>> red_wine['high_quality'] = pd.cut(
... red_wine.quality, bins=[0, 6, 10], labels=[0, 1]
... )
>>> red_wine.high_quality.value_counts(normalize=True)
0 0.86429
1 0.13571
Name: high_quality, dtype: float64
重要说明
为了简洁起见,我们在此停止了 EDA(探索性数据分析);然而,在尝试任何建模之前,我们应该确保充分探索数据,并咨询领域专家。特别需要注意的一点是变量之间的相关性以及我们试图预测的目标(在此案例中是高质量的红酒)。与目标变量(高质量红酒)有强相关性的变量可能是模型中很好的特征。然而,请注意,相关性并不意味着因果关系。我们已经学到了几种使用可视化来查找相关性的方法:我们在第五章《使用 Pandas 和 Matplotlib 进行数据可视化》中讨论过的散点矩阵,以及在第六章《使用 Seaborn 和定制化技巧绘图》中的热力图和对比图。对比图已包含在red_wine.ipynb笔记本中。
白葡萄酒和红葡萄酒的化学性质数据
现在,让我们将红葡萄酒和白葡萄酒的数据一起查看。由于数据来自不同的文件,我们需要读取两个文件并将其合并为一个数据框。白葡萄酒文件实际上是分号(;)分隔的,因此我们必须在pd.read_csv()中提供sep参数:
>>> red_wine = pd.read_csv('data/winequality-red.csv')
>>> white_wine = \
... pd.read_csv('data/winequality-white.csv', sep=';')
我们还可以查看白葡萄酒的质量评分,就像我们查看红葡萄酒一样,我们会发现白葡萄酒的评分普遍较高。这可能让我们质疑评委是否更偏好白葡萄酒,从而在评分中产生偏差。就目前而言,所使用的评分系统似乎相当主观:

](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/hsn-da-pd-2e/img/Figure_9.4_B16834.jpg)
图 9.4 – 白葡萄酒质量评分分布
这两个数据框具有相同的列,因此我们可以直接合并它们。在这里,我们使用pd.concat()将白葡萄酒数据叠加在红葡萄酒数据之上,并在添加一个列以标识每个观测所属的葡萄酒类型后进行操作:
>>> wine = pd.concat([
... white_wine.assign(kind='white'),
... red_wine.assign(kind='red')
... ])
>>> wine.sample(5, random_state=10)
就像我们处理红葡萄酒数据集一样,我们可以运行info()来检查是否需要执行类型转换或是否有任何缺失数据;幸运的是,这里我们也不需要。我们的组合葡萄酒数据集如下所示:

图 9.5 – 组合葡萄酒数据集
使用value_counts(),我们可以看到数据中白葡萄酒比红葡萄酒多得多:
>>> wine.kind.value_counts()
white 4898
red 1599
Name: kind, dtype: int64
最后,让我们使用seaborn来查看每种化学性质按葡萄酒类型分组的箱线图。这可以帮助我们识别在建立区分红葡萄酒和白葡萄酒模型时将有帮助的特征(模型输入):
>>> import math
>>> chemical_properties = [col for col in wine.columns
... if col not in ['quality', 'kind']]
>>> melted = \
... wine.drop(columns='quality').melt(id_vars=['kind'])
>>> fig, axes = plt.subplots(
... math.ceil(len(chemical_properties) / 4), 4,
... figsize=(15, 10)
... )
>>> axes = axes.flatten()
>>> for prop, ax in zip(chemical_properties, axes):
... sns.boxplot(
... data=melted[melted.variable.isin([prop])],
... x='variable', y='value', hue='kind', ax=ax
... ).set_xlabel('')
>>> for ax in axes[len(chemical_properties):]:
... ax.remove() # remove the extra subplots
>>> plt.suptitle(
... 'Comparing Chemical Properties of Red and White Wines'
... )
>>> plt.tight_layout()
根据以下结果,我们可能会考虑在构建模型时使用固定酸度、挥发性酸度、总二氧化硫和硫酸盐,因为它们在红葡萄酒和白葡萄酒中的分布似乎不同:

图 9.6 – 在化学水平上比较红葡萄酒和白葡萄酒
提示
比较不同类别之间变量的分布可以帮助我们选择模型的特征。如果我们发现某个变量在不同类别之间的分布非常不同,那么这个变量可能非常有用并应该包含在我们的模型中。在进行建模之前,我们必须对数据进行深入探索。一定要使用我们在第五章中介绍的可视化工具,使用 Pandas 和 Matplotlib 可视化数据,以及第六章,使用 Seaborn 和自定义技术绘图,因为它们对这个过程非常有价值。
当我们检查模型犯的错误预测时,我们将在第十章中回到这个可视化,做出更好的预测 – 优化模型。现在,让我们看看我们将要处理的另一个数据集。
行星和外行星数据
外行星简单地是指绕着我们太阳系之外的恒星运转的行星,因此从现在开始我们将统称它们为行星。现在让我们读取我们的行星数据:
>>> planets = pd.read_csv('data/planets.csv')
我们可以根据它们的轨道找到相似行星的聚类,并尝试预测轨道周期(行星一年有多长,以地球日计算):

图 9.7 – 行星数据集
我们可以构建相关矩阵热力图来帮助找到最佳特征:
>>> fig = plt.figure(figsize=(7, 7))
>>> sns.heatmap(
... planets.drop(columns='discoveryyear').corr(),
... center=0, vmin=-1, vmax=1, square=True, annot=True,
... cbar_kws={'shrink': 0.8}
... )
热力图显示,行星轨道的半长轴与其周期的长度高度正相关,这是有道理的,因为半长轴(以及离心率)有助于定义行星围绕恒星的轨道路径:

图 9.8 – 行星数据集特征间的相关性
为了预测period,我们可能需要观察semimajoraxis、mass和eccentricity。轨道的离心率量化了轨道与完美圆形的偏离程度:

图 9.9 – 理解离心率
让我们看看我们所拥有的轨道形状:
>>> planets.eccentricity.min(), planets.eccentricity.max()
(0.0, 0.956) # circular and elliptical eccentricities
>>> planets.eccentricity.hist()
>>> plt.xlabel('eccentricity')
>>> plt.ylabel('frequency')
>>> plt.title('Orbit Eccentricities')
看起来几乎所有的轨道都是椭圆形的,这是我们预期的,因为这些是行星:

图 9.10 – 轨道离心率的分布
椭圆是一个拉长的圆形,具有两个轴:长轴和短轴,分别是最长和最短的轴。半长轴是长轴的一半。与圆形相比,轴类似于直径,穿过整个形状,而半轴类似于半径,是直径的一半。以下是当行星围绕一颗位于其椭圆轨道中心的恒星运行时的情况(由于其他天体的引力,实际情况是恒星可以位于轨道路径中的任何位置):

图 9.11 – 理解半长轴
现在我们理解了这些列的含义,让我们继续进行更多的探索性数据分析(EDA)。这个数据并不像我们的酒类数据那样干净——当我们能够直接接触到数据时,一切显得更加容易。尽管我们知道大部分的period值,我们只拥有一小部分行星的eccentricity、semimajoraxis或mass数据:
>>> planets[[
... 'period', 'eccentricity', 'semimajoraxis', 'mass'
... ]].info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4094 entries, 0 to 4093
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 period 3930 non-null float64
1 eccentricity 1388 non-null float64
2 semimajoraxis 1704 non-null float64
3 mass 1659 non-null float64
dtypes: float64(4)
memory usage: 128.1 KB
如果我们丢弃任何一个列为空的数据,那么剩下的将只有大约 30%:
>>> planets[[
... 'period', 'eccentricity', 'semimajoraxis', 'mass'
... ]].dropna().shape
(1222, 4)
如果我们仅仅是想找到一种预测年份长度的方法(当我们有这些值时),以便更好地了解它们之间的关系,那么我们不一定需要担心丢弃缺失数据。这里的插补可能会对我们的模型造成更大的负面影响。至少所有数据都已正确编码为十进制数(float64);然而,让我们检查是否需要做一些缩放(如果我们的模型对大小差异敏感,缩放将是有益的):
>>> planets[[
... 'period', 'eccentricity', 'semimajoraxis', 'mass'
... ]].describe()
这向我们展示了,根据我们的模型,我们肯定需要做一些缩放,因为period列中的值远大于其他列:

图 9.12 – 行星数据集的总结统计
我们还可以查看一些散点图。注意,存在一个list列,表示行星所属的组别,如太阳系或有争议。我们可能希望查看周期(以及与恒星的距离)是否对其产生影响:
>>> sns.scatterplot(
... x=planets.semimajoraxis, y=planets.period,
... hue=planets.list, alpha=0.5
... )
>>> plt.title('period vs. semimajoraxis')
>>> plt.legend(title='')
这些有争议的行星似乎分布在各处,并且它们的半长轴和周期较大。或许它们之所以有争议,是因为它们距离恒星非常远:

图 9.13 – 行星周期与半长轴
不幸的是,我们可以看到周期的尺度使得这张图很难阅读,因此我们可以尝试对y-轴进行对数变换,以便在左下角更密集的区域中获得更多的分离。我们这次仅标出太阳系中的行星:
>>> fig, ax = plt.subplots(1, 1, figsize=(10, 10))
>>> in_solar_system = (planets.list == 'Solar System')\
... .rename('in solar system?')
>>> sns.scatterplot(
... x=planets.semimajoraxis, y=planets.period,
... hue=in_solar_system, ax=ax
... )
>>> ax.set_yscale('log')
>>> solar_system = planets[planets.list == 'Solar System']
>>> for planet in solar_system.name:
... data = solar_system.query(f'name == "{planet}"')
... ax.annotate(
... planet,
... (data.semimajoraxis, data.period),
... (7 + data.semimajoraxis, data.period),
... arrowprops=dict(arrowstyle='->')
... )
>>> ax.set_title('log(orbital period) vs. semi-major axis')
确实有很多行星藏在图表的左下角。现在,我们可以看到许多行星的年周期比水星的 88 地球年还要短:

图 9.14 – 我们的太阳系与外行星的对比
既然我们对将要使用的数据有了一些了解,让我们学习如何为机器学习模型准备这些数据。
数据预处理
在这一部分,我们将在preprocessing.ipynb笔记本中工作,然后再返回到用于 EDA 的笔记本。我们将从导入库和读取数据开始:
>>> import numpy as np
>>> import pandas as pd
>>> planets = pd.read_csv('data/planets.csv')
>>> red_wine = pd.read_csv('data/winequality-red.csv')
>>> wine = pd.concat([
... pd.read_csv(
... 'data/winequality-white.csv', sep=';'
... ).assign(kind='white'),
... red_wine.assign(kind='red')
... ])
机器学习模型遵循“垃圾进,垃圾出”的原则。我们必须确保在最优版本的数据上训练我们的模型(让它学习)。这意味着什么将取决于我们选择的模型。例如,使用距离度量来计算观测值相似度的模型,如果我们的特征尺度差异很大,容易混淆。除非我们正在处理自然语言处理(NLP)问题,试图理解单词的意义,否则我们的模型对文本值没有用处——甚至无法解释它们。缺失或无效数据也会造成问题;我们必须决定是丢弃它们还是填补它们。我们在将数据提供给模型学习之前所做的所有调整统称为数据预处理。
训练集与测试集
到目前为止,机器学习听起来相当不错——我们可以构建一个模型来学习为我们执行任务。那么,应该将所有数据都提供给它,让它学得更好,对吗?不幸的是,事情并没有那么简单。如果我们将所有数据提供给模型,就有可能导致过拟合,意味着它将无法很好地推广到新的数据点,因为它是针对样本而不是总体进行拟合的。另一方面,如果我们不给模型足够的数据,它将欠拟合,无法捕捉数据中的潜在信息。
提示
当一个模型适应数据中的随机性时,我们称它为适应数据中的噪声。
另一个需要考虑的问题是,如果我们用所有数据来训练模型,我们该如何评估其性能呢?如果我们在用于训练的数据上进行测试,我们会高估模型的表现,因为模型总是在训练数据上表现得更好。因此,必须将数据分成训练集和测试集。为了做到这一点,我们可以将数据框进行洗牌,并选择前 x% 的行作为训练集,其余部分作为测试集:
shuffled = \
planets.reindex(np.random.permutation(planets.index))
train_end_index = int(np.ceil(shuffled.shape[0] * .75))
training = shuffled.iloc[:train_end_index,]
testing = shuffled.iloc[train_end_index:,]
这样做是可行的,但每次都要写这么多内容确实有些麻烦。幸运的是,scikit-learn 在 model_selection 模块中为我们提供了 train_test_split() 函数,这是一个更加稳健、易于使用的解决方案。它要求我们事先将输入数据(X)和输出数据(y)分开。在这里,我们将选择 75% 的数据作为训练集(X_train,y_train),剩下的 25% 用于测试集(X_test,y_test)。我们将设置一个随机种子(random_state=0),以确保数据拆分是可重复的:
>>> from sklearn.model_selection import train_test_split
>>> X = planets[['eccentricity', 'semimajoraxis', 'mass']]
>>> y = planets.period
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, test_size=0.25, random_state=0
... )
虽然没有明确的标准来定义测试集的理想大小,但通常的经验法则是占数据的 10% 到 30%。不过,如果我们的数据量较少,我们会选择 10% 的测试集,以确保有足够的数据用于学习。相反,如果数据量很大,我们可能会选择 30% 作为测试集,因为我们不仅不希望出现过拟合,还希望给模型提供足够的数据来证明它的价值。需要注意的是,这条经验法则有一个重大警告:我们使用的训练数据越多,回报递减越明显。如果我们有大量数据,可能会用不到 70% 的数据进行训练,因为计算成本可能会大幅上升,而改进幅度却微乎其微,甚至可能导致过拟合的风险增加。
重要提示
在构建需要调优的模型时,我们将数据分成训练集、验证集和测试集。我们将在第十章中介绍验证集内容,使预测更准确 - 优化模型。
现在让我们看看训练集和测试集的维度。由于我们使用了三个特征(eccentricity,semimajoraxis,和 mass),X_train 和 X_test 将有三列。y_train 和 y_test 每个将只有一列。训练数据中的 X 和 y 观察值数量将相等,测试集也是如此:
>>> X.shape, y.shape # original data
((4094, 3), (4094,))
>>> X_train.shape, y_train.shape # training data
((3070, 3), (3070,))
>>> X_test.shape, y_test.shape # testing data
((1024, 3), (1024,))
X_train 和 X_test 被返回为数据框,因为我们传递它们时就是这种格式。如果我们直接处理 NumPy 数据,我们将得到 NumPy 数组或 ndarray 格式的数据。我们将在 数据预处理 部分使用这些数据进行其他示例演示,因此让我们先来看一下 X_train 数据框的前五行。现在不用担心 NaN 值;我们将在 填补缺失值 部分讨论处理这些值的不同方法:
>>> X_train.head()
eccentricity semimajoraxis mass
1390 NaN NaN NaN
2837 NaN NaN NaN
3619 NaN 0.0701 NaN
1867 NaN NaN NaN
1869 NaN NaN NaN
y_train和y_test都是序列,因为这就是我们传递给train_test_split()函数的内容。如果我们传入的是 NumPy 数组,那我们将得到的是返回的结果。y_train和y_test中的行必须与X_train和X_test中的行分别对齐。让我们通过查看y_train的前五行来确认这一点:
>>> y_train.head()
1390 1.434742
2837 51.079263
3619 7.171000
1867 51.111024
1869 62.869161
Name: period, dtype: float64
确实,一切如预期的那样对齐。请注意,对于我们的葡萄酒模型,我们需要使用分层采样,这也可以通过在train_test_split()中传递用于分层的值来完成。我们将在分类部分看到这一点。现在,让我们继续进行剩余的预处理工作。
缩放和居中数据
我们已经看到我们的数据框中有具有非常不同尺度的列;如果我们想使用任何计算距离度量的模型(例如我们将在本章讨论的 k-means,或者用于标准化(通过计算 Z 分数进行缩放)和最小-最大缩放(将数据规范化为[0, 1]范围)等的preprocessing模块)。
重要提示
我们应该检查我们所构建的模型的要求,以查看数据是否需要缩放。
对于标准化缩放,我们使用StandardScaler类。fit_transform()方法将fit()(它计算出将数据居中和缩放所需的均值和标准差)与transform()(它将转换应用于数据)结合起来。请注意,当实例化StandardScaler对象时,我们可以选择不减去均值或不除以标准差,通过分别将False传递给with_mean或with_std参数。默认情况下,两者都是True:
>>> from sklearn.preprocessing import StandardScaler
>>> standardized = StandardScaler().fit_transform(X_train)
# examine some of the non-NaN values
>>> standardized[~np.isnan(standardized)][:30]
array([-5.43618156e-02, 1.43278593e+00, 1.95196592e+00,
4.51498477e-03, -1.96265630e-01, 7.79591646e-02,
...,
-2.25664815e-02, 9.91013258e-01, -7.48808523e-01,
-4.99260165e-02, -8.59044215e-01, -5.49264158e-02])
经过此转换后,数据中的e表示小数点移动的位置。对于+符号,我们将小数点向右移动指定的位数;对于-符号,我们将小数点向左移动。因此,1.00e+00等同于1,2.89e-02等同于0.0289,而2.89e+02等同于289。转换后的行星数据大多介于-3 和 3 之间,因为现在所有数据都是 Z 分数。
其他缩放器可以使用相同的语法。让我们使用MinMaxScaler类将行星数据转换为范围[0, 1]:
>>> from sklearn.preprocessing import MinMaxScaler
>>> normalized = MinMaxScaler().fit_transform(X_train)
# examine some of the non-NaN values
>>> normalized[~np.isnan(normalized)][:30]
array([2.28055906e-05, 1.24474091e-01, 5.33472803e-01,
1.71374569e-03, 1.83543340e-02, 1.77824268e-01,
...,
9.35966714e-04, 9.56961137e-02, 2.09205021e-02,
1.50201619e-04, 0.00000000e+00, 6.59028789e-06])
提示
另一种选择是RobustScaler类,它使用中位数和 IQR 进行抗异常值缩放。笔记本中有这个的示例。更多预处理类可以在scikit-learn.org/stable/modules/classes.html#module-sklearn.preprocessing找到。
编码数据
到目前为止,我们讨论的所有缩放器都处理了数值数据的预处理,但我们该如何处理类别数据呢?我们需要将类别编码为整数值。这里有几个选择,取决于类别代表什么。如果我们的类别是二进制的(例如0/1、True/False或yes/no),那么0是一个选项,1是另一个选项。我们可以通过np.where()函数轻松做到这一点。让我们将葡萄酒数据的kind字段编码为红酒为1,白酒为0:
>>> np.where(wine.kind == 'red', 1, 0)
array([0, 0, 0, ..., 1, 1, 1])
这实际上是一个告诉我们酒是否为红酒的列。记住,在我们创建wine数据框时,我们将红酒数据拼接到了白酒数据的底部,因此np.where()将对顶部行返回零,对底部行返回一,就像我们在之前的结果中看到的那样。
提示
我们还可以使用scikit-learn的LabelBinarizer类来编码kind字段。请注意,如果我们的数据实际上是连续的,但我们希望将其视为二进制类别值,我们可以使用Binarizer类并提供一个阈值,或者使用pd.cut()/pd.qcut()。在笔记本中有这些的示例。
如果我们的类别是有序的,我们可能希望分别使用0、1和2。这样做的好处是我们可以使用回归技术来预测质量,或者可以将其作为模型中的特征来预测其他内容;该模型能够利用“高于中等,中等高于低质量”这一事实。我们可以使用LabelEncoder类来实现这一点。请注意,标签将根据字母顺序创建,因此按字母顺序排列的第一个类别将是0:
>>> from sklearn.preprocessing import LabelEncoder
>>> pd.Series(LabelEncoder().fit_transform(pd.cut(
... red_wine.quality,
... bins=[-1, 3, 6, 10],
... labels=['0-3 (low)', '4-6 (med)', '7-10 (high)']
... ))).value_counts()
1 1372
2 217
0 10
dtype: int64
重要说明
Scikit-learn 提供了OrdinalEncoder类,但我们的数据格式不正确——它期望的是二维数据(如DataFrame或ndarray对象),而我们这里使用的是一维的Series对象。我们仍然需要确保类别事先是按照正确的顺序排列的。
然而,请注意,序数编码可能会导致潜在的数据问题。在我们的示例中,如果高质量葡萄酒现在是2,而中等质量葡萄酒是1,模型可能会解释为2 * med = high。这实际上在不同质量等级之间隐性地创建了关联,这可能是我们不认同的。
另一种更安全的方法是执行is_low和is_med,这两个变量仅取0或1。通过这两个变量,我们可以自动知道酒的质量是否很高(当is_low = is_med = 0时)。这些被称为1,该行属于该组;在我们关于葡萄酒质量类别的示例中,如果is_low为1,则该行属于低质量组。这可以通过pd.get_dummies()函数和drop_first参数来实现,后者会移除冗余列。
让我们使用独热编码对行星数据中的 list 列进行编码,因为这些类别没有固有的顺序。在进行任何转换之前,先看一下我们数据中的列表:
>>> planets.list.value_counts()
Confirmed planets 3972
Controversial 97
Retracted planet candidate 11
Solar System 9
Kepler Objects of Interest 4
Planets in binary systems, S-type 1
Name: list, dtype: int64
如果我们希望将行星列表包含到我们的模型中,可以使用 pd.get_dummies() 函数创建虚拟变量:
>>> pd.get_dummies(planets.list).head()
这会将我们的单一序列转换为以下数据框架,其中虚拟变量按数据中出现的顺序创建:

图 9.15 – 独热编码
正如我们之前讨论的那样,这些列中的一个是多余的,因为其余列的值可以用来推断多余列的值。一些模型可能会受到这些列之间高相关性的显著影响(这就是 drop_first 参数的作用):
>>> pd.get_dummies(planets.list, drop_first=True).head()
注意到先前结果中的第一列已经被删除,但我们仍然可以确定,除了最后一行外,其他行都在确认的行星列表中:

图 9.16 – 在独热编码后删除冗余列
请注意,我们可以通过使用 LabelBinarizer 类及其 fit_transform() 方法在行星列表上得到类似的结果。这不会删除冗余特征,因此我们再次可以看到第一列属于确认行星列表,在以下结果中以粗体显示:
>>> from sklearn.preprocessing import LabelBinarizer
>>> LabelBinarizer().fit_transform(planets.list)
array([[1, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0],
...,
[1, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0]])
重要说明
Scikit-learn 提供了 OneHotEncoder 类,但我们的数据格式不正确——它期望数据以 2D 数组的形式出现,而我们的数据是 1D 的。我们将在附加转换器部分看到如何使用这个类的示例。
填充
我们已经知道行星数据中有一些缺失值,因此让我们讨论 scikit-learn 提供的几种处理缺失值的选项,这些选项可以在 impute 模块中找到:用某个值填充(使用常数或汇总统计)、根据相似观察值填充,以及指示缺失的部分。
回到探索性数据分析部分,我们对我们计划建模的行星数据使用了 dropna()。假设我们不想删除这些数据,而是希望尝试填充缺失值。我们的数据最后几行在 semimajoraxis 列上有缺失值:
>>> planets[['semimajoraxis', 'mass', 'eccentricity']].tail()
semimajoraxis mass eccentricity
4089 0.08150 1.9000 0.000
4090 0.04421 0.7090 0.038
4091 NaN 0.3334 0.310
4092 NaN 0.4000 0.270
4093 NaN 0.4200 0.160
我们可以使用 SimpleImputer 类来填充这些缺失值,默认情况下将使用均值进行填充:
>>> from sklearn.impute import SimpleImputer
>>> SimpleImputer().fit_transform(
... planets[['semimajoraxis', 'mass', 'eccentricity']]
... )
array([[ 1.29 , 19.4 , 0.231 ],
[ 1.54 , 11.2 , 0.08 ],
[ 0.83 , 4.8 , 0. ],
...,
[ 5.83796389, 0.3334 , 0.31 ],
[ 5.83796389, 0.4 , 0.27 ],
[ 5.83796389, 0.42 , 0.16 ]])
平均值似乎不是一个好的策略,因为我们知道的行星可能有一些共同点,显然像行星属于哪个系统及其轨道等特征,可以作为缺失数据点的好指示符。我们可以选择为strategy参数提供除均值之外的方法;当前,它可以是median(中位数)、most_frequent(最频繁值)或constant(指定值通过fill_value)。这些方法对我们来说都不太合适;然而,scikit-learn还提供了KNNImputer类,用于基于相似的观察值填充缺失值。默认情况下,它使用五个最近的邻居,并运行 k-NN 算法,我们将在第十章《做出更好的预测——优化模型》中讨论这一点,使用那些没有缺失的特征。
>>> from sklearn.impute import KNNImputer
>>> KNNImputer().fit_transform(
... planets[['semimajoraxis', 'mass', 'eccentricity']]
... )
array([[ 1.29 , 19.4 , 0.231 ],
[ 1.54 , 11.2 , 0.08 ],
[ 0.83 , 4.8 , 0. ],
...,
[ 0.404726, 0.3334 , 0.31 ],
[ 0.85486 , 0.4 , 0.27 ],
[ 0.15324 , 0.42 , 0.16 ]])
注意,底部三行的每一行现在都有一个唯一的值填充给半长轴。这是因为质量和偏心率被用来找到相似的行星,并基于这些行星来填充半长轴。虽然这比使用SimpleImputer类处理行星数据要好,但填充数据仍然存在风险。
在某些情况下,我们可能更关心的是标记缺失数据的位置,并将其作为我们模型中的一个特征,而不是对数据进行填充。这可以通过MissingIndicator类来实现:
>>> from sklearn.impute import MissingIndicator
>>> MissingIndicator().fit_transform(
... planets[['semimajoraxis', 'mass', 'eccentricity']]
... )
array([[False, False, False],
[False, False, False],
[False, False, False],
...,
[ True, False, False],
[ True, False, False],
[ True, False, False]])
当我们关注我们将讨论的最后一组预处理器时,注意到它们都有一个fit_transform()方法,以及fit()和transform()方法。这种 API 设计决策使得我们很容易了解如何使用新类,也是scikit-learn如此容易学习和使用的原因之一——它非常一致。
额外的转换器
如果我们想进行数学运算,而不是对数据进行缩放或编码,比如取平方根或对数怎么办?preprocessing模块也有一些类可以执行此类操作。虽然有一些类执行特定的转换,例如QuantileTransformer类,但我们将重点关注FunctionTransformer类,它允许我们提供一个任意的函数来使用:
>>> from sklearn.preprocessing import FunctionTransformer
>>> FunctionTransformer(
... np.abs, validate=True
... ).fit_transform(X_train.dropna())
array([[0.51 , 4.94 , 1.45 ],
[0.17 , 0.64 , 0.85 ],
[0.08 , 0.03727, 1.192 ],
...,
[0.295 , 4.46 , 1.8 ],
[0.34 , 0.0652 , 0.0087 ],
[0.3 , 1.26 , 0.5 ]])
这里,我们取了每个数字的绝对值。注意validate=True参数;FunctionTransformer类知道scikit-learn模型不会接受NaN值、无穷大值或缺失值,因此如果返回这些值,它会抛出一个错误。为此,我们在这里也运行了dropna()。
注意,对于缩放、编码、填充和转换数据,所有我们传递的数据都被转换了。如果我们有不同数据类型的特征,可以使用ColumnTransformer类在一次调用中将转换映射到一个列(或一组列):
>>> from sklearn.compose import ColumnTransformer
>>> from sklearn.impute import KNNImputer
>>> from sklearn.preprocessing import (
... MinMaxScaler, StandardScaler
... )
>>> ColumnTransformer([
... ('impute', KNNImputer(), [0]),
... ('standard_scale', StandardScaler(), [1]),
... ('min_max', MinMaxScaler(), [2])
... ]).fit_transform(X_train)[10:15]
array([[ 0.17 , -0.04747176, 0.0107594 ],
[ 0.08 , -0.05475873, 0.01508851],
[ 0.15585591, nan, 0.13924042],
[ 0.15585591, nan, nan],
[ 0. , -0.05475111, 0.00478471]])
还有一个make_column_transformer()函数,它会为我们命名转换器。我们来创建一个ColumnTransformer对象,它将对分类数据和数值数据进行不同的处理:
>>> from sklearn.compose import make_column_transformer
>>> from sklearn.preprocessing import (
... OneHotEncoder, StandardScaler
... )
>>> categorical = [
... col for col in planets.columns
... if col in [
... 'list', 'name', 'description',
... 'discoverymethod', 'lastupdate'
... ]
... ]
>>> numeric = [
... col for col in planets.columns
... if col not in categorical
... ]
>>> make_column_transformer(
... (StandardScaler(), numeric),
... (OneHotEncoder(sparse=False), categorical)
... ).fit_transform(planets.dropna())
array([[ 3.09267587, -0.2351423 , -0.40487424, ...,
0. , 0. ],
[ 1.432445 , -0.24215395, -0.28360905, ...,
0. , 0. ],
[ 0.13665505, -0.24208849, -0.62800218, ...,
0. , 0. ],
...,
[-0.83289954, -0.76197788, -0.84918988, ...,
1. , 0. ],
[ 0.25813535, 0.38683239, -0.92873984, ...,
0. , 0. ],
[-0.26827931, -0.21657671, -0.70076129, ...,
0. , 1. ]])
提示
我们在实例化OneHotEncoder对象时传递sparse=False,以便我们能看到结果。实际上,我们不需要这样做,因为scikit-learn模型知道如何处理 NumPy 稀疏矩阵。
构建数据管道
确实,在预处理数据时似乎涉及了很多步骤,并且它们需要按照正确的顺序应用于训练和测试数据——这非常繁琐。幸运的是,scikit-learn提供了创建管道的功能,能够简化预处理过程,并确保训练集和测试集以相同的方式处理。这可以避免例如在标准化时计算所有数据的均值,然后再将数据划分为训练集和测试集的情况,这样会创建一个看起来表现更好的模型,而实际表现可能不如预期。
重要提示
当使用来自训练集外部的信息(例如,使用完整数据集计算均值进行标准化)来训练模型时,称为数据泄漏。
我们在构建第一个模型之前学习管道,因为管道确保模型正确构建。管道可以包含所有预处理步骤和模型本身。构建管道就像定义步骤并命名它们一样简单:
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.linear_model import LinearRegression
>>> Pipeline([
... ('scale', StandardScaler()), ('lr', LinearRegression())
... ])
Pipeline(steps=[('scale', StandardScaler()),
('lr', LinearRegression())])
我们不仅限于在模型中使用管道——它们也可以用于其他scikit-learn对象,例如ColumnTransformer对象。这使得我们能够首先在半长轴数据(索引为0的列)上使用 k-NN 填充,然后标准化结果。然后,我们可以将其作为管道的一部分,从而在构建模型时提供巨大的灵活性:
>>> from sklearn.compose import ColumnTransformer
>>> from sklearn.impute import KNNImputer
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import (
... MinMaxScaler, StandardScaler
... )
>>> ColumnTransformer([
... ('impute', Pipeline([
... ('impute', KNNImputer()),
... ('scale', StandardScaler())
... ]), [0]),
... ('standard_scale', StandardScaler(), [1]),
... ('min_max', MinMaxScaler(), [2])
... ]).fit_transform(X_train)[10:15]
array([[ 0.13531604, -0.04747176, 0.0107594 ],
[-0.7257111 , -0.05475873, 0.01508851],
[ 0. , nan, 0.13924042],
[ 0. , nan, nan],
[-1.49106856, -0.05475111, 0.00478471]])
就像ColumnTransformer类一样,我们有一个函数,可以在不必命名步骤的情况下为我们创建管道。让我们再创建一个管道,这次我们将使用make_pipeline()函数:
>>> from sklearn.pipeline import make_pipeline
>>> make_pipeline(StandardScaler(), LinearRegression())
Pipeline(steps=[('standardscaler', StandardScaler()),
('linearregression', LinearRegression())])
请注意,步骤已自动命名为类名的小写版本。正如我们将在下一章看到的那样,命名步骤将使得通过名称优化模型参数变得更容易。scikit-learn API 的一致性还将使我们能够使用该管道来拟合模型,并使用相同的对象进行预测,这一点我们将在下一节看到。
聚类
我们使用聚类将数据点划分为相似点的组。每个组中的点比其他组中的点更像自己组的成员。聚类通常用于推荐系统(想象一下 Netflix 是如何根据其他观看过相似内容的人推荐你观看的内容)和市场细分等任务。
例如,假设我们在一家在线零售商工作,想要对网站用户进行细分以便进行更有针对性的营销工作;我们可以收集关于在网站上花费的时间、页面访问量、浏览过的产品、购买的产品等数据。然后,我们可以让一个无监督的聚类算法找到具有相似行为的用户群体;如果我们划分为三个群体,可以根据每个群体的行为为其命名:

图 9.17 – 将网站用户聚类为三组
由于我们可以使用聚类进行无监督学习,因此我们需要解释创建的群体,然后尝试为每个群体衍生出一个有意义的名称。如果我们的聚类算法在前述的散点图中识别出了这三个群体,我们可能能够做出以下行为观察:
-
频繁购买的客户(第 0 组):购买很多并查看许多产品。
-
偶尔购买的客户(第 1 组):有过一些购买,但少于最常购买的客户。
-
浏览者(第 2 组):访问了网站,但还没有购买任何东西。
一旦识别出这些群体,营销团队就可以针对每个群体采取不同的营销策略;显然,频繁的客户对盈利有更大的贡献,但如果他们已经购买了很多,也许营销预算更应该用来增加偶尔购买的客户的购买量,或者将浏览者转化为偶尔购买的客户。
重要提示
决定要创建多少个群体显然会影响这些群体后续的解释,这意味着这并不是一个简单的决定。在尝试猜测将数据分成多少个群体之前,我们至少应该可视化我们的数据并获得一些领域知识。
另外,如果我们知道某些数据的群体标签用于训练目的,聚类也可以以有监督的方式使用。假设我们收集了关于登录活动的数据,就像在第八章中提到的,基于规则的异常检测,但我们有一些关于攻击者活动的样本;我们可以为所有活动收集这些数据点,然后使用聚类算法将其分配到有效用户群体或攻击者群体。由于我们有标签,我们可以调整输入变量和/或使用的聚类算法,以最佳方式将这些群体与其真实群体对齐。
k-means
scikit-learn 提供的聚类算法可以在 cluster 模块的文档中找到,地址是 scikit-learn.org/stable/modules/classes.html#module-sklearn.cluster。我们将查看 k-means,它通过距离质心(簇的中心点)最近的组来迭代地分配点,形成 k 个组。由于该模型使用距离计算,因此我们必须事先了解数据的尺度对结果的影响;然后可以决定是否需要对某些列进行缩放。
重要说明
测量空间中点之间距离的方法有很多种。通常情况下,欧几里得距离或直线距离是默认的度量方法;然而,另一种常见的度量是曼哈顿距离,可以被看作是城市街区距离。
当我们将所有行星的周期与半长轴绘制在一起,并使用周期的对数刻度时,我们看到了行星沿弧线的良好分离。我们将使用 k-means 来找出在这条弧线上具有相似轨道的行星组。
根据轨道特征分组行星
正如我们在 数据预处理 部分讨论的那样,我们可以构建一个管道来缩放数据并建模。在这里,我们的模型将是一个 KMeans 对象,它将行星分为八个簇(对应我们太阳系中的行星数量——抱歉,冥王星)。由于 k-means 算法随机选择其初始质心,除非我们指定种子,否则可能会得到不同的聚类结果。因此,我们还提供了 random_state=0 以确保可复现性:
>>> from sklearn.cluster import KMeans
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> kmeans_pipeline = Pipeline([
... ('scale', StandardScaler()),
... ('kmeans', KMeans(8, random_state=0))
... ])
一旦我们有了我们的管道,就可以将其拟合到所有数据上,因为我们不打算进行任何预测(在这种情况下)——我们只是想找出相似的行星:
>>> kmeans_data = planets[['semimajoraxis', 'period']].dropna()
>>> kmeans_pipeline.fit(kmeans_data)
Pipeline(steps=[('scale', StandardScaler()),
('kmeans', KMeans(random_state=0))])
一旦我们将模型拟合到数据上,就可以使用 predict() 方法为每个点获取簇标签(使用的是我们之前使用过的数据)。让我们来看看 k-means 所识别的簇:
>>> fig, ax = plt.subplots(1, 1, figsize=(7, 7))
>>> sns.scatterplot(
... x=kmeans_data.semimajoraxis,
... y=kmeans_data.period,
... hue=kmeans_pipeline.predict(kmeans_data),
... ax=ax, palette='Accent'
... )
>>> ax.set_yscale('log')
>>> solar_system = planets[planets.list == 'Solar System']
>>> for planet in solar_system.name:
... data = solar_system.query(f'name == "{planet}"')
... ax.annotate(
... planet,
... (data.semimajoraxis, data.period),
... (7 + data.semimajoraxis, data.period),
... arrowprops=dict(arrowstyle='->')
... )
>>> ax.get_legend().remove()
>>> ax.set_title('KMeans Clusters')
水星和金星落入了同一个簇,地球和火星也属于同一簇。木星、土星和天王星分别属于不同的簇,而海王星和冥王星共享一个簇:

图 9.18 – 通过 k-means 算法识别的八个行星簇
我们在这里随意选择了八个聚类,因为这是我们太阳系中的行星数量。理想情况下,我们应该对真实的分组情况有一些领域知识,或者需要选择一个特定的数量。例如,假设我们要将婚礼宾客分配到五张桌子上,以确保他们相处融洽,那么我们的 k 就是 5;如果我们可以在用户群体上运行三个营销活动,那么我们的 k 就是 3。如果我们无法直观地判断数据中将有多少个组,一条经验法则是尝试观察数的平方根,但这可能会导致不可管理的聚类数量。因此,如果在我们的数据上创建许多 k-means 模型不会太耗时,我们可以使用肘部点方法。
用于确定 k 的肘部点方法
肘部点方法涉及使用多个不同 k 值创建多个模型,并绘制每个模型的惯性(聚类内平方和)与聚类数量的关系图。我们希望最小化点到其聚类中心的平方距离总和,同时避免创建过多的聚类。
ml_utils.elbow_point 模块包含我们的 elbow_point() 函数,已在此处复现:
import matplotlib.pyplot as plt
def elbow_point(data, pipeline, kmeans_step_name='kmeans',
k_range=range(1, 11), ax=None):
"""
Plot the elbow point to find an appropriate k for
k-means clustering.
Parameters:
- data: The features to use
- pipeline: The scikit-learn pipeline with `KMeans`
- kmeans_step_name: Name of `KMeans` step in pipeline
- k_range: The values of `k` to try
- ax: Matplotlib `Axes` to plot on.
Returns:
A matplotlib `Axes` object
"""
scores = []
for k in k_range:
pipeline.named_steps[kmeans_step_name].n_clusters = k
pipeline.fit(data)
# score is -1*inertia so we multiply by -1 for inertia
scores.append(pipeline.score(data) * -1)
if not ax:
fig, ax = plt.subplots()
ax.plot(k_range, scores, 'bo-')
ax.set_xlabel('k')
ax.set_ylabel('inertias')
ax.set_title('Elbow Point Plot')
return ax
让我们使用肘部点方法来找到一个合适的 k 值:
>>> from ml_utils.elbow_point import elbow_point
>>> ax = elbow_point(
... kmeans_data,
... Pipeline([
... ('scale', StandardScaler()),
... ('kmeans', KMeans(random_state=0))
... ])
... )
>>> ax.annotate(
... 'possible appropriate values for k', xy=(2, 900),
... xytext=(2.5, 1500), arrowprops=dict(arrowstyle='->')
... )
>>> ax.annotate(
... '', xy=(3, 3480), xytext=(4.4, 1450),
... arrowprops=dict(arrowstyle='->')
... )
我们看到收益递减的点是一个合适的k值,在这里可能是二或三:

图 9.19 – 解释肘部点图
如果我们只创建两个聚类,我们将行星分为一组包含大多数行星(橙色),另一组位于右上方只有少数几个(蓝色),这些可能是离群点:

图 9.20 – 通过 k-means 方法识别的两个行星聚类
请注意,虽然这可能是一个合适的聚类数量,但它没有提供像之前的尝试那么多的信息。如果我们想了解与我们太阳系中每个行星相似的行星,我们需要使用更大的k。
解释质心并可视化聚类空间
由于在聚类之前我们已经标准化了数据,因此可以查看模型的cluster_centers_属性。蓝色聚类的中心位于(18.9, 20.9),以(半长轴,周期)格式表示;请记住,这些是 Z 分数,因此这些值与其余数据相差较远。另一方面,橙色聚类的中心位于(-0.035, -0.038)。
让我们构建一个可视化,展示标定输入数据的质心位置以及聚类距离空间(其中点表示到其聚类中心的距离)。首先,我们将在一个更大的图表中设置一个较小的子图布局:
>>> fig = plt.figure(figsize=(8, 6))
>>> outside = fig.add_axes([0.1, 0.1, 0.9, 0.9])
>>> inside = fig.add_axes([0.6, 0.2, 0.35, 0.35])
接下来,我们获取输入数据的缩放版本,以及这些数据点与它们所属聚类的质心之间的距离。我们可以使用transform()和fit_transform()(即先调用fit()再调用transform())方法将输入数据转换到聚类距离空间。返回的是 NumPy 的ndarrays,其中外部数组中的每个值表示一个点的坐标:
>>> scaled = kmeans_pipeline_2.named_steps['scale']\
... .fit_transform(kmeans_data)
>>> cluster_distances = kmeans_pipeline_2\
... .fit_transform(kmeans_data)
由于我们知道外部数组中的每个数组将以半长轴作为第一个元素,周期作为第二个元素,因此我们使用[:,0]来选择所有半长轴值,使用[:,1]来选择所有周期值。这些将作为我们散点图的 x 和 y。请注意,我们实际上不需要调用predict()来获取数据的聚类标签,因为我们想要的是我们用来训练模型的数据的标签;这意味着我们可以使用KMeans对象的labels_属性:
>>> for ax, data, title, axes_labels in zip(
... [outside, inside], [scaled, cluster_distances],
... ['Visualizing Clusters', 'Cluster Distance Space'],
... ['standardized', 'distance to centroid']
... ):
... sns.scatterplot(
... x=data[:,0], y=data[:,1], ax=ax, alpha=0.75, s=100,
... hue=kmeans_pipeline_2.named_steps['kmeans'].labels_
... )
...
... ax.get_legend().remove()
... ax.set_title(title)
... ax.set_xlabel(f'semimajoraxis ({axes_labels})')
... ax.set_ylabel(f'period ({axes_labels})')
... ax.set_ylim(-1, None)
最后,我们在外部图中标注了质心的位置,图中显示了缩放后的数据:
>>> cluster_centers = kmeans_pipeline_2\
... .named_steps['kmeans'].cluster_centers_
>>> for color, centroid in zip(
... ['blue', 'orange'], cluster_centers
... ):
... outside.plot(*centroid, color=color, marker='x')
... outside.annotate(
... f'{color} center', xy=centroid,
... xytext=centroid + [0, 5],
... arrowprops=dict(arrowstyle='->')
... )
在结果图中,我们可以清楚地看到,三个蓝色点与其他点有明显不同,它们是第二个聚类的唯一成员:

图 9.21 – 可视化聚类距离空间中的行星
到目前为止,我们一直在使用transform()或组合方法(如fit_predict()或fit_transform()),但并不是所有模型都支持这些方法。在回归和分类部分,我们将看到一个略有不同的工作流程。通常,大多数scikit-learn对象将根据它们的用途支持以下方法:

图 9.22 – scikit-learn 模型 API 的通用参考
现在我们已经构建了一些模型,准备进行下一步:量化它们的表现。scikit-learn中的metrics模块包含了用于评估聚类、回归和分类任务的各种度量指标;可以在scikit-learn.org/stable/modules/classes.html#module-sklearn.metrics查看 API 中列出的函数。接下来我们讨论如何评估一个无监督聚类模型。
评估聚类结果
评估我们聚类结果的最重要标准是它们对我们所要做的事情有用;我们使用肘部法则选择了一个合适的k值,但这并不像原始模型中的八个聚类那样对我们有用。也就是说,在量化表现时,我们需要选择与我们所做学习类型相匹配的度量指标。
当我们知道数据的真实聚类时,我们可以检查聚类模型是否将数据点正确地归类到真实聚类中。我们模型所给出的聚类标签可能与真实标签不同——唯一重要的是,真实聚类中的点也应该被归为同一聚类。一个这样的指标是Fowlkes-Mallows 指数,我们将在章节末的练习中看到它。
在行星数据集上,我们进行了无监督聚类,因为我们没有每个数据点的标签,因此无法评估模型与这些标签的匹配情况。这意味着我们必须使用评估聚类本身方面的指标,比如它们之间的距离和聚类内点的紧密度。我们可以比较多个指标,获得对性能的更全面评估。
一种这样的方法是称为轮廓系数,它有助于量化聚类分离度。其计算方法是将聚类中每两点之间距离的均值(a)与给定聚类与最接近的不同聚类之间的点距离的均值(b)相减,再除以两者中的最大值:

该指标返回的值范围为[-1, 1],其中-1 表示最差(聚类分配错误),1 表示最佳;值接近 0 则表示聚类重叠。这个数字越高,聚类越明确(分离度越大):
>>> from sklearn.metrics import silhouette_score
>>> silhouette_score(
... kmeans_data, kmeans_pipeline.predict(kmeans_data)
... )
0.7579771626036678
另一个我们可以用来评估聚类结果的评分是聚类内距离(同一聚类内点之间的距离)与聚类间距离(不同聚类间点之间的距离)之比,这被称为Davies-Bouldin 评分。值越接近零,表示聚类间的分离越好:
>>> from sklearn.metrics import davies_bouldin_score
>>> davies_bouldin_score(
... kmeans_data, kmeans_pipeline.predict(kmeans_data)
... )
0.4632311032231894
我们将在这里讨论的最后一个无监督聚类指标是Calinski 和 Harabasz 评分,或称方差比准则,它是聚类内的离散度与聚类间离散度的比值。较高的值表示聚类更为明确(更为分离):
>>> from sklearn.metrics import calinski_harabasz_score
>>> calinski_harabasz_score(
... kmeans_data, kmeans_pipeline.predict(kmeans_data)
... )
21207.276781867335
要查看scikit-learn提供的完整聚类评估指标列表(包括监督聚类)以及何时使用它们,请查阅他们指南中的聚类性能评估部分,网址:scikit-learn.org/stable/modules/clustering.html#clustering-evaluation。
回归
在行星数据集中,我们希望预测年份的长度,这是一个数值型的变量,因此我们将使用回归。正如本章开头所提到的,回归是一种用于建模自变量(我们的X数据)与因变量(通常称为y数据)之间关系的强度和大小的技术,我们希望通过这种技术来进行预测。
线性回归
Scikit-learn 提供了许多可以处理回归任务的算法,从决策树到线性回归,根据各种算法类别分布在不同模块中。然而,通常最好的起点是线性回归,它可以在linear_model模块中找到。在简单线性回归中,我们将数据拟合到以下形式的直线上:

这里,epsilon (ε)是误差项,betas (β)是系数。
重要说明
我们从模型中得到的系数是那些最小化代价函数的系数,即最小化观察值(y)与模型预测值(ŷ,发音为y-hat)之间的误差。我们的模型给出了这些系数的估计值,我们将其表示为
(发音为beta-hat)。
然而,如果我们想要建模其他关系,我们需要使用多元线性回归,它包含多个回归变量:

scikit-learn中的线性回归使用最小二乘法(OLS),该方法得到的系数最小化平方误差之和(即y与ŷ之间的距离)。这些系数可以通过闭式解法找到,或者通过优化方法估计,例如梯度下降法,后者使用负梯度(通过偏导数计算的最陡上升方向)来确定下一个要尝试的系数(有关更多信息,请参见进一步阅读部分中的链接)。我们将在第十一章中使用梯度下降法,主题为机器学习中的异常检测。
重要说明
线性回归对数据做出了一些假设,我们在选择使用这种技术时必须牢记这些假设。它假设残差服从正态分布且同方差,并且没有多重共线性(回归变量之间的高度相关性)。
现在我们对线性回归的工作原理有了些了解,让我们建立一个模型来预测行星的轨道周期。
预测行星的年长度
在我们建立模型之前,我们必须从用于预测的列(semimajoraxis、mass和eccentricity)中分离出要预测的列(period):
>>> data = planets[
... ['semimajoraxis', 'period', 'mass', 'eccentricity']
... ].dropna()
>>> X = data[['semimajoraxis', 'mass', 'eccentricity']]
>>> y = data.period
这是一个监督任务。我们希望能够通过行星的半长轴、质量和轨道偏心率来预测一个行星的年长度,并且我们已有大多数行星的周期数据。让我们将数据按 75/25 的比例分为训练集和测试集,以便评估该模型对年长度的预测效果:
>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, test_size=0.25, random_state=0
... )
一旦我们将数据分成训练集和测试集,我们就可以创建并拟合模型:
>>> from sklearn.linear_model import LinearRegression
>>> lm = LinearRegression().fit(X_train, y_train)
这个拟合模型可以用来检查估计的系数,也可以用来预测给定一组自变量时因变量的值。我们将在接下来的两节中讨论这两种使用情况。
解释线性回归方程
从线性回归模型中得出的方程给出了系数,用以量化变量之间的关系。在尝试解释这些系数时,必须小心,如果我们处理的是多个回归变量的情况。对于多重共线性,我们无法解释这些系数,因为我们无法保持所有其他回归变量不变,以便检查单个回归变量的影响。
幸运的是,我们用于行星数据的回归变量是没有相关性的,正如我们在探索性数据分析部分(图 9.8)中通过相关矩阵热图所看到的那样。那么,让我们从拟合的线性模型对象中获取截距和系数:
# get intercept
>>> lm.intercept_
-622.9909910671811
# get coefficients
>>> [(col, coef) for col, coef in
... zip(X_train.columns, lm.coef_)]
[('semimajoraxis', 1880.4365990440929),
('mass', -90.18675916509196),
('eccentricity', -3201.078059333091)]
这给出了我们行星年长度的线性回归模型方程:

为了更完整地解释这一点,我们需要了解所有单位是什么:
-
period(年长度):地球日 -
semimajoraxis:天文单位(AUs) -
mass:木星质量(行星质量除以木星质量) -
eccentricity:无提示
一个天文单位是地球与太阳之间的平均距离,约为 149,597,870,700 米。
这个特定模型中的截距没有任何意义:如果行星的半长轴为零,质量为零,且偏心率为完美圆形,则它的一年将是-623 个地球日。一个行星必须具有非负、非零的周期、半长轴和质量,因此这显然没有意义。然而,我们可以解释其他系数。方程告诉我们,在保持质量和偏心率不变的情况下,增加一个天文单位(AU)到半长轴距离将使一年长度增加 1,880 个地球日。在保持半长轴和偏心率不变的情况下,每增加一个木星质量将使一年长度减少 90.2 个地球日。
从一个完美的圆形轨道(eccentricity=0)到一个几乎是抛物线逃逸轨道(eccentricity=1),将使一年长度减少 3,201 个地球日;注意,这些数值对于这个项来说是近似值,因为对于抛物线逃逸轨道,行星将永远不会回来,因此这个方程就没有意义了。实际上,如果我们试图在偏心率大于或等于 1 的情况下使用这个方程,我们将进行外推,因为训练数据中没有这样的值。这是外推不起作用的一个明确例子。方程告诉我们,偏心率越大,一年就越短,但一旦偏心率达到 1 或更大,行星就永远不再回来(它们已经达到逃逸轨道),因此一年是无限的。
训练数据中的所有偏心率值都在 0, 1)范围内,因此我们正在进行插值(使用训练数据中的范围预测周期值)。这意味着,只要我们想要预测的行星的偏心率也在[0, 1)范围内,我们就可以使用这个模型进行预测。
做出预测
现在我们大致了解了每个回归器的效果,让我们使用模型对测试集中的行星进行年限预测:
>>> preds = lm.predict(X_test)
让我们通过绘制实际值和预测值来可视化我们的表现:
>>> fig, axes = plt.subplots(1, 1, figsize=(5, 3))
>>> axes.plot(
... X_test.semimajoraxis, y_test, 'ob',
... label='actuals', alpha=0.5
... )
>>> axes.plot(
... X_test.semimajoraxis, preds, 'or',
... label='predictions', alpha=0.5
... )
>>> axes.set(xlabel='semimajoraxis', ylabel='period')
>>> axes.legend()
>>> axes.set_title('Linear Regression Results')
预测值似乎与实际值非常接近,并遵循类似的模式:
![图 9.23 – 预测值与实际值
图 9.23 – 预测值与实际值
提示
尝试仅使用semimajoraxis回归器来运行此回归。数据可能需要进行一些重塑,但这将展示随着我们添加eccentricity和mass,性能的改善。在实践中,我们通常需要构建多个模型版本,直到找到一个我们满意的。
我们可以检查它们的相关性,看看我们的模型与真实关系的契合度:
>>> np.corrcoef(y_test, preds)[0][1]
0.9692104355988059
我们的预测值与实际值的相关性非常强(相关系数为 0.97)。需要注意的是,相关系数可以告诉我们模型与实际数据是否同步变化;但它无法告诉我们在数量级上是否存在偏差。为此,我们将使用下一节讨论的度量标准。
评估回归结果
在评估回归模型时,我们关注的是模型能够捕捉到数据方差的程度,以及预测的准确性。我们可以结合度量标准和可视化方法来评估模型在这两个方面的表现。
分析残差
每当我们使用线性回归时,都应当可视化我们的残差,即实际值与模型预测值之间的差异;正如我们在第七章《金融分析——比特币与股市》中学到的那样,残差应围绕零居中且具有同方差性(即整个数据的方差相似)。我们可以使用核密度估计来评估残差是否围绕零居中,并使用散点图来检查它们是否具有同方差性。
让我们看一下ml_utils.regression中的工具函数,它将创建这些子图来检查残差:
import matplotlib.pyplot as plt
import numpy as np
def plot_residuals(y_test, preds):
"""
Plot residuals to evaluate regression.
Parameters:
- y_test: The true values for y
- preds: The predicted values for y
Returns:
Subplots of residual scatter plot and residual KDE
"""
residuals = y_test – preds
fig, axes = plt.subplots(1, 2, figsize=(15, 3))
axes[0].scatter(np.arange(residuals.shape[0]), residuals)
axes[0].set(xlabel='Observation', ylabel='Residual')
residuals.plot(kind='kde', ax=axes[1])
axes[1].set_xlabel('Residual')
plt.suptitle('Residuals')
return axes
现在,让我们查看这次线性回归的残差:
>>> from ml_utils.regression import plot_residuals
>>> plot_residuals(y_test, preds)
看起来我们的预测值没有明显的模式(左侧子图),这是好事;然而,它们并没有完全围绕零对称,且分布有负偏(右侧子图)。这些负残差出现在预测年份长于实际年份时:

图 9.24 – 检查残差
提示
如果我们在残差中发现模式,说明我们的数据不是线性的,这时可视化残差可能会帮助我们规划下一步的操作。这可能意味着需要采用多项式回归或对数据进行对数变换等策略。
度量标准
除了检查残差外,我们还应该计算一些指标来评估回归模型。或许最常见的指标是R²(读作R 方),也叫决定系数,它量化了我们能从自变量中预测的因变量方差的比例。它通过从 1 中减去残差平方和与总平方和的比值来计算:

提示
西格玛(Σ)表示总和。y值的平均值表示为ȳ(读作y-bar)。预测值表示为ŷ(读作y-hat)。
该值将在[0, 1]范围内,值越高越好。scikit-learn中的LinearRegression类对象使用 R2 作为评分方法。因此,我们可以简单地使用score()方法来计算它:
>>> lm.score(X_test, y_test)
0.9209013475842684
我们也可以通过metrics模块获取 R2 值:
>>> from sklearn.metrics import r2_score
>>> r2_score(y_test, preds)
0.9209013475842684
该模型具有非常好的 R2 值;然而,记住有许多因素会影响周期,比如恒星和其他行星,它们会对相关行星施加引力。尽管有这样的抽象简化,我们的简化方法表现得相当不错,因为行星的轨道周期在很大程度上由必须行驶的距离决定,我们通过使用半长轴数据来考虑这一点。
但是,R2 存在一个问题;我们可以不断增加回归变量,这会使我们的模型变得越来越复杂,同时也会提高 R2 值。我们需要一个可以惩罚模型复杂度的指标。为此,我们有调整后的 R2,它只有在新增的回归变量比随机预期的更好时,才会增加:

不幸的是,scikit-learn没有提供这个指标;然而,我们可以很容易地自己实现。ml_utils.regression模块包含一个计算调整后的 R2 值的函数。让我们来看一下:
from sklearn.metrics import r2_score
def adjusted_r2(model, X, y):
"""
Calculate the adjusted R².
Parameters:
- model: Estimator object with a `predict()` method
- X: The values to use for prediction.
- y: The true values for scoring.
Returns:
The adjusted R² score.
"""
r2 = r2_score(y, model.predict(X))
n_obs, n_regressors = X.shape
adj_r2 = \
1 - (1 - r2) * (n_obs - 1)/(n_obs - n_regressors - 1)
return adj_r2
调整后的 R2 值总是低于 R2 值。通过使用adjusted_r2()函数,我们可以看到调整后的 R2 值略低于 R2 值:
>>> from ml_utils.regression import adjusted_r2
>>> adjusted_r2(lm, X_test, y_test)
0.9201155993814631
不幸的是,R2(以及调整后的 R2)值并不能告诉我们关于预测误差的信息,甚至无法告诉我们是否正确地指定了模型。回想一下我们在第一章中讨论过的安斯科姆四重奏,数据分析导论。这四个不同的数据集具有相同的汇总统计值。尽管其中一些数据集并未呈现线性关系,它们在使用线性回归线拟合时 R2 值也相同(0.67):

图 9.25 – R2 可能会误导
另一个由scikit-learn提供的指标是解释方差得分,它告诉我们模型所解释的方差百分比。我们希望这个值尽可能接近 1:

我们可以看到,模型解释了 92%的方差:
>>> from sklearn.metrics import explained_variance_score
>>> explained_variance_score(y_test, preds)
0.9220144218429371
在评估回归模型时,我们不仅限于观察方差;我们还可以查看误差本身的大小。本节接下来讨论的所有其他度量标准都以我们用于预测的相同单位(这里是地球日)来表示误差,因此我们可以理解误差大小的含义。
平均绝对误差(MAE)告诉我们模型在两个方向上所犯的平均误差。其值范围从 0 到∞(无穷大),值越小越好:

通过使用scikit-learn函数,我们可以看到我们的平均绝对误差(MAE)为 1,369 地球日:
>>> from sklearn.metrics import mean_absolute_error
>>> mean_absolute_error(y_test, preds)
1369.441817073533
均方根误差(RMSE)允许对差的预测进行进一步惩罚:

Scikit-learn 提供了均方误差(MSE)函数,它是前述方程中平方根部分的计算结果;因此,我们只需对结果取平方根即可。我们会在不希望出现大误差的情况下使用此度量:
>>> from sklearn.metrics import mean_squared_error
>>> np.sqrt(mean_squared_error(y_test, preds))
3248.499961928374
这些基于均值的度量方法的替代是中位数绝对误差,即残差的中位数。当我们的残差中有少量异常值时,可以使用此方法,旨在更准确地描述大部分误差。请注意,对于我们的数据,这个值比 MAE 要小:
>>> from sklearn.metrics import median_absolute_error
>>> median_absolute_error(y_test, preds)
759.8613358335442
还有一个mean_squared_log_error()函数,它只能用于非负值。当某些预测值为负时,这会使我们无法使用此函数。负预测值的出现是因为半长轴非常小(小于 1),这是回归方程中唯一带有正系数的部分。如果半长轴不足以平衡方程的其余部分,预测值将为负,从而自动成为错误的预测。有关scikit-learn提供的回归度量标准的完整列表,请查看scikit-learn.org/stable/modules/classes.html#regression-metrics。
分类
分类的目标是通过一组离散标签来确定如何标记数据。这听起来可能与有监督聚类相似;然而,在这种情况下,我们不关心组内成员在空间上有多接近。相反,我们关注的是如何为它们分配正确的类别标签。记住,在第八章,基于规则的异常检测中,当我们将 IP 地址分类为有效用户或攻击者时,我们并不关心 IP 地址的聚类是否定义得很好——我们只关心找到攻击者。
就像回归问题一样,scikit-learn 提供了许多用于分类任务的算法。这些算法分布在不同的模块中,但通常会在分类任务的名称后加上 Classifier,而回归任务则加上 Regressor。一些常见的方法有逻辑回归、支持向量机(SVM)、k-NN、决策树和随机森林;这里我们将讨论逻辑回归。
逻辑回归
逻辑回归是一种使用线性回归来解决分类任务的方法。它使用逻辑 sigmoid 函数返回在 [0, 1] 范围内的概率,这些概率可以映射到类别标签:

图 9.26 – 逻辑 sigmoid 函数
让我们使用逻辑回归将红酒分为高质量或低质量,并根据其化学性质将酒分为红酒或白酒。我们可以像处理前一节中的线性回归一样使用逻辑回归,使用 scikit-learn 的 linear_model 模块。就像线性回归问题一样,我们将使用监督学习方法,因此需要将数据划分为测试集和训练集。
提示
虽然本节讨论的示例都是二分类问题(两类),scikit-learn 同样支持多分类问题。构建多分类模型的过程与二分类几乎相同,但可能需要传递一个额外的参数,以便模型知道有多个类别。你将在本章末的练习中有机会构建一个多分类模型。
预测红酒质量
我们在本章开始时创建了 high_quality 列,但请记住,红酒中的高质量比例严重失衡。因此,在划分数据时,我们将按该列进行分层抽样,确保训练集和测试集保持数据中高质量和低质量酒的比例(大约 14% 是高质量):
>>> from sklearn.model_selection import train_test_split
>>> red_y = red_wine.pop('high_quality')
>>> red_X = red_wine.drop(columns='quality')
>>> r_X_train, r_X_test, \
... r_y_train, r_y_test = train_test_split(
... red_X, red_y, test_size=0.1, random_state=0,
... stratify=red_y
... )
让我们创建一个管道,首先标准化所有数据,然后构建一个逻辑回归模型。我们将提供种子(random_state=0)以确保结果可复现,并将 class_weight='balanced' 传递给 scikit-learn,让它计算类别的权重,因为我们有类别不平衡问题:
>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.linear_model import LogisticRegression
>>> red_quality_lr = Pipeline([
... ('scale', StandardScaler()),
... ('lr', LogisticRegression(
... class_weight='balanced', random_state=0
... ))
... ])
类别权重决定了模型对每个类别错误预测的惩罚程度。通过选择平衡的权重,对较小类别的错误预测将承担更大的权重,且权重与该类别在数据中的频率成反比。这些权重用于正则化,我们将在 第十章 中进一步讨论,优化模型以做出更好的预测。
一旦我们有了管道,就可以通过 fit() 方法将其拟合到数据上:
>>> red_quality_lr.fit(r_X_train, r_y_train)
Pipeline(steps=[('scale', StandardScaler()),
('lr', LogisticRegression(
class_weight='balanced',
random_state=0))])
最后,我们可以使用我们在训练数据上拟合的模型来预测测试数据中的红酒质量:
>>> quality_preds = red_quality_lr.predict(r_X_test)
提示
Scikit-learn 使得切换模型变得容易,因为我们可以依赖它们拥有相同的方法,例如score()、fit()和predict()。在某些情况下,我们还可以使用predict_proba()来获得概率,或者使用decision_function()来评估通过模型推导出的方程点,而不是使用predict()。
在我们继续评估该模型的性能之前,让我们使用完整的葡萄酒数据集构建另一个分类模型。
通过化学属性确定葡萄酒类型
我们想知道是否仅通过化学属性就能区分红酒和白酒。为了测试这一点,我们将构建第二个逻辑回归模型,它将预测葡萄酒是红酒还是白酒。首先,让我们将数据分为测试集和训练集:
>>> from sklearn.model_selection import train_test_split
>>> wine_y = np.where(wine.kind == 'red', 1, 0)
>>> wine_X = wine.drop(columns=['quality', 'kind'])
>>> w_X_train, w_X_test, \
... w_y_train, w_y_test = train_test_split(
... wine_X, wine_y, test_size=0.25,
... random_state=0, stratify=wine_y
... )
我们将再次在管道中使用逻辑回归:
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> white_or_red = Pipeline([
... ('scale', StandardScaler()),
... ('lr', LogisticRegression(random_state=0))
... ]).fit(w_X_train, w_y_train)
最后,我们将保存对测试集中每个观察值的葡萄酒类型预测结果:
>>> kind_preds = white_or_red.predict(w_X_test)
现在我们已经得到了两个逻辑回归模型的预测结果,使用它们各自的测试集,我们准备好评估它们的性能了。
评估分类结果
我们通过查看每个类别在数据中被模型预测得多好来评估分类模型的性能。正类是我们感兴趣的类别,所有其他类别被视为负类。在我们的红酒分类中,正类是高质量,负类是低质量。尽管我们的问题只是二分类问题,但本节中讨论的指标也适用于多分类问题。
混淆矩阵
正如我们在第八章中讨论的,基于规则的异常检测,可以通过将预测标签与实际标签进行比较来使用混淆矩阵评估分类问题:

图 9.27 – 使用混淆矩阵评估分类结果
每个预测结果可以有四种可能的结果,基于它与实际值的匹配情况:
-
真正类(TP):正确预测为正类
-
假阳性(FP):错误地预测为正类
-
真负类(TN):正确预测为非正类
-
假阴性(FN):错误地预测为非正类
重要提示
假阳性也被称为I 型错误,而假阴性则是II 型错误。对于某一特定分类器,减少一种错误会导致另一种错误的增加。
Scikit-learn 提供了confusion_matrix()函数,我们可以将其与seaborn中的heatmap()函数结合,用来可视化我们的混淆矩阵。在ml_utils.classification模块中,confusion_matrix_visual()函数为我们处理了这个操作:
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
from sklearn.metrics import confusion_matrix
def confusion_matrix_visual(y_true, y_pred, class_labels,
normalize=False, flip=False,
ax=None, title=None, **kwargs):
"""
Create a confusion matrix heatmap
Parameters:
- y_test: The true values for y
- preds: The predicted values for y
- class_labels: What to label the classes.
- normalize: Whether to plot the values as percentages.
- flip: Whether to flip the confusion matrix. This is
helpful to get TP in the top left corner and TN in
the bottom right when dealing with binary
classification with labels True and False.
- ax: The matplotlib `Axes` object to plot on.
- title: The title for the confusion matrix
- kwargs: Additional keyword arguments to pass down.
Returns: A matplotlib `Axes` object.
"""
mat = confusion_matrix(y_true, y_pred)
if normalize:
fmt, mat = '.2%', mat / mat.sum()
else:
fmt = 'd'
if flip:
class_labels = class_labels[::-1]
mat = np.flip(mat)
axes = sns.heatmap(
mat.T, square=True, annot=True, fmt=fmt,
cbar=True, cmap=plt.cm.Blues, ax=ax, **kwargs
)
axes.set(xlabel='Actual', ylabel='Model Prediction')
tick_marks = np.arange(len(class_labels)) + 0.5
axes.set_xticks(tick_marks)
axes.set_xticklabels(class_labels)
axes.set_yticks(tick_marks)
axes.set_yticklabels(class_labels, rotation=0)
axes.set_title(title or 'Confusion Matrix')
return axes
让我们调用混淆矩阵可视化函数,看看我们在每个分类模型中的表现如何。首先,我们将看看模型如何识别高质量的红葡萄酒:
>>> from ml_utils.classification import confusion_matrix_visual
>>> confusion_matrix_visual(
... r_y_test, quality_preds, ['low', 'high']
... )
使用混淆矩阵,我们可以看到模型在持续准确地找到高质量红葡萄酒时遇到了困难(底行):

图 9.28 – 红葡萄酒质量模型的结果
现在,让我们看看white_or_red模型预测葡萄酒类型的效果:
>>> from ml_utils.classification import confusion_matrix_visual
>>> confusion_matrix_visual(
... w_y_test, kind_preds, ['white', 'red']
... )
看起来这个模型的表现要容易得多,几乎没有错误预测:
`

图 9.29 – 白葡萄酒或红葡萄酒模型的结果
现在我们理解了混淆矩阵的组成,可以利用它来计算其他的性能指标。
分类指标
使用混淆矩阵中的值,我们可以计算一些指标来帮助评估分类器的性能。最佳指标取决于我们构建模型的目标以及类别是否平衡。本节中的公式是从我们从混淆矩阵中得到的数据推导出来的,其中TP表示真正例数,TN表示真反例数,依此类推。
准确率和错误率
当我们的类别大致相同时,可以使用准确率,它会给出正确分类值的百分比:

sklearn.metrics中的accuracy_score()函数会根据公式计算准确率;然而,我们模型的score()方法也会给出准确率(但并非总是如此,正如我们将在第十章中看到的,做出更好的预测 – 优化模型):
>>> red_quality_lr.score(r_X_test, r_y_test)
0.775
由于准确率是我们正确分类的百分比(即我们的成功率),因此我们的错误率(即错误分类的百分比)可以通过以下方式计算:

我们的准确率分数告诉我们,红葡萄酒中有 77.5%被正确分类到相应的质量等级。相反,zero_one_loss()函数给出了误分类的百分比,对于红葡萄酒质量模型来说是 22.5%:
>>> from sklearn.metrics import zero_one_loss
>>> zero_one_loss(r_y_test, quality_preds)
0.22499999999999998
请注意,虽然这两个指标都容易计算和理解,但它们需要一个阈值。默认情况下,阈值是 50%,但我们可以在使用scikit-learn中的predict_proba()方法预测类别时,使用任何我们希望的概率作为截止值。此外,在类别不平衡的情况下,准确率和错误率可能会产生误导。
精确度和召回率
当我们存在类别不平衡时,准确率可能成为衡量性能的一个不可靠指标。例如,如果我们有一个 99/1 的两类分布,其中稀有事件 B 是我们的正类,我们可以通过将所有数据都分类为 A 来构建一个 99% 准确率的模型。这一问题源于真正负类数量非常大,并且它们会出现在分子(以及分母)中,使得结果看起来比实际情况更好。显然,如果模型根本没有识别 B 类的功能,我们就不应该费心构建该模型;因此,我们需要不同的指标来避免这种行为。为此,我们使用精度和召回率而不是准确率。精度是指真阳性与所有被标记为阳性的结果的比率:

召回率给出了真正阳性率(TPR),即真阳性与所有实际为阳性的样本的比率:

在 A 类和 B 类 99/1 分布的情况下,如果模型将所有数据都分类为 A,则对正类 B 的召回率为 0%(精度将无法定义——0/0)。精度和召回率在类别不平衡的情况下提供了更好的评估模型性能的方法。它们能正确告诉我们模型对我们使用场景的价值很低。
Scikit-learn 提供了 classification_report() 函数,可以为我们计算精度和召回率。除了为每个类别标签计算这些指标外,它还计算了宏观平均值(各类别之间的无权平均)和加权平均值(根据每个类别的观察数加权后的平均值)。支持列表示使用标记数据属于每个类别的观察数量。
分类报告表明,我们的模型在找到低质量红酒方面表现良好,但在高质量红酒的表现上则不尽如人意:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(r_y_test, quality_preds))
precision recall f1-score support
0 0.95 0.78 0.86 138
1 0.35 0.73 0.47 22
accuracy 0.78 160
macro avg 0.65 0.75 0.66 160
weighted avg 0.86 0.78 0.80 160
鉴于质量评分是非常主观的,并不一定与化学性质相关,因此这个简单模型的表现不佳也就不足为奇。另一方面,红酒和白酒之间的化学性质不同,因此这些信息对于 white_or_red 模型来说更加有用。正如我们可以想象的,基于 white_or_red 模型的混淆矩阵,评估指标表现良好:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(w_y_test, kind_preds))
precision recall f1-score support
0 0.99 1.00 0.99 1225
1 0.99 0.98 0.98 400
accuracy 0.99 1625
macro avg 0.99 0.99 0.99 1625
weighted avg 0.99 0.99 0.99 1625
就像准确率一样,精度和召回率也容易计算和理解,但需要设定阈值。此外,精度和召回率各自只考虑了混淆矩阵的一半:

图 9.30 – 精度和召回率的混淆矩阵覆盖
通常,在最大化召回率和最大化精度之间存在权衡,我们必须决定哪个对我们更为重要。这种偏好可以通过 F 分数来量化。
F 分数
分类报告还包括F1 得分,它帮助我们通过调和均值平衡精度和召回率:

重要提示
调和均值是算术均值的倒数,通常用于处理比率,能够得到比算术均值更准确的平均值(相比于算术均值的比率)。精度和召回率都是在[0, 1]范围内的比例,我们可以将其视为比率。
Fβ 得分,发音为F-beta,是 F 得分的更一般化公式。通过调整β值,我们可以更重视精度(β介于 0 和 1 之间)或召回率(β大于 1),其中β表示召回率相对于精度的重要性:

一些常用的β值如下:
-
F0.5 得分:精度是召回率的两倍重要
-
F1 得分:调和均值(同等重要)
-
F2 得分:召回率是精度的两倍重要
F 得分也很容易计算,且依赖于阈值。然而,它并不考虑真正的负类,且由于精度和召回率之间的权衡关系,优化起来较为困难。请注意,在处理大类不平衡时,我们通常更关心正确预测正类样本,这意味着我们可能对真正负类不那么感兴趣,因此使用忽略它们的指标通常不是问题。
提示
精度、召回率、F1 得分和 Fβ得分的函数可以在sklearn.metrics模块中找到。
敏感度和特异度
在精度与召回率的权衡关系中,我们还有另一对可以用来说明我们在分类问题中力求实现的微妙平衡的指标:敏感度和特异度。
敏感度是真正的正类率,或者叫召回率,我们之前提到过。而特异度是真正的负类率,即将所有应被分类为负类的样本中,真正负类所占的比例:

请注意,敏感度和特异度一起考虑了完整的混淆矩阵:

图 9.31 – 敏感度和特异度的混淆矩阵覆盖率
我们希望同时最大化敏感度和特异度;然而,我们可以通过减少将样本分类为正类的次数来轻松最大化特异度,这样会导致敏感度降低。Scikit-learn 没有提供特异度作为评估指标—它更偏向精度和召回率—然而,我们可以通过编写函数或使用scikit-learn中的make_scorer()函数轻松自定义这个指标。我们在这里讨论它们,是因为它们构成了敏感度-特异度图或 ROC 曲线的基础,ROC 曲线是接下来章节的主题。
ROC 曲线
除了使用指标评估分类问题外,我们还可以借助可视化。通过绘制真正率(敏感度)与假正率(1 - 特异性)的关系,我们得到了scikit-learn中的predict_proba()方法。假设我们将阈值设为 60%——我们需要predict_proba()返回大于或等于 0.6 的值来预测正类(predict()方法使用 0.5 作为切割点)。
scikit-learn中的roc_curve()函数通过模型确定的观察值属于给定类别的概率,在从 0 到 100% 的阈值下计算假正率和真正率。然后我们可以绘制这个曲线,目标是最大化曲线下的面积(AUC),其范围在 [0, 1] 之间;低于 0.5 的值比猜测还差,而良好的分数则大于 0.8。请注意,当提到 ROC 曲线下的面积时,AUC 也可以写作AUROC。AUROC 总结了模型在各个阈值下的表现。
以下是一些良好的 ROC 曲线示例。虚线代表随机猜测(没有预测价值),用作基准;低于虚线的表示比猜测更差。我们希望处于左上角:

图 9.32 – 比较 ROC 曲线
ml_utils.classification模块包含一个绘制 ROC 曲线的函数。让我们来看看它:
import matplotlib.pyplot as plt
from sklearn.metrics import auc, roc_curve
def plot_roc(y_test, preds, ax=None):
"""
Plot ROC curve to evaluate classification.
Parameters:
- y_test: The true values for y
- preds: The predicted values for y as probabilities
- ax: The `Axes` object to plot on
Returns:
A matplotlib `Axes` object.
"""
if not ax:
fig, ax = plt.subplots(1, 1)
fpr, tpr, thresholds = roc_curve(y_test, preds)
ax.plot(
[0, 1], [0, 1], color='navy', lw=2,
linestyle='--', label='baseline'
)
ax.plot(fpr, tpr, color='red', lw=2, label='model')
ax.legend(loc='lower right')
ax.set_title('ROC curve')
ax.set_xlabel('False Positive Rate (FPR)')
ax.set_ylabel('True Positive Rate (TPR)')
ax.annotate(
f'AUC: {auc(fpr, tpr):.2}', xy=(0.5, 0),
horizontalalignment='center'
)
return ax
正如我们所想,我们的white_or_red模型会有一个非常好的 ROC 曲线。让我们通过调用plot_roc()函数来看看它的效果。因为我们需要传递每个条目属于正类的概率,所以我们需要使用predict_proba()方法而不是predict()。这样我们就能得到每个观察值属于各类的概率。
在这里,对于w_X_test中的每一行,我们得到一个 NumPy 数组[P(white), P(red)]。因此,我们使用切片来选择红酒的概率用于 ROC 曲线([:,1]):
>>> from ml_utils.classification import plot_roc
>>> plot_roc(
... w_y_test, white_or_red.predict_proba(w_X_test)[:,1]
... )
正如我们所预期的那样,white_or_red模型的 ROC 曲线非常好,AUC 接近 1:

图 9.33 – 白酒或红酒模型的 ROC 曲线
根据我们观察过的其他指标,我们不期望红酒质量预测模型有很好的 ROC 曲线。让我们调用函数来看看红酒质量模型的 ROC 曲线长什么样:
>>> from ml_utils.classification import plot_roc
>>> plot_roc(
... r_y_test, red_quality_lr.predict_proba(r_X_test)[:,1]
... )
这个 ROC 曲线不如前一个好,正如预期的那样:

图 9.34 – 红酒质量模型的 ROC 曲线
我们的 AUROC 是 0.85;然而,请注意,AUROC 在类别不平衡的情况下提供乐观的估计(因为它考虑了真正负类)。因此,我们还应该查看精准率-召回率曲线。
精准率-召回率曲线
当面临类别不平衡时,我们使用精确度-召回率曲线而不是 ROC 曲线。该曲线显示了在不同概率阈值下的精确度与召回率的关系。基线是数据中属于正类的百分比的水平线。我们希望我们的曲线位于这条线之上,且精确度-召回率曲线下的面积(AUPR)大于该百分比(值越高越好)。
ml_utils.classification模块包含plot_pr_curve()函数,用于绘制精确度-召回率曲线并提供 AUPR:
import matplotlib.pyplot as plt
from sklearn.metrics import (
auc, average_precision_score, precision_recall_curve
)
def plot_pr_curve(y_test, preds, positive_class=1, ax=None):
"""
Plot precision-recall curve to evaluate classification.
Parameters:
- y_test: The true values for y
- preds: The predicted values for y as probabilities
- positive_class: Label for positive class in the data
- ax: The matplotlib `Axes` object to plot on
Returns: A matplotlib `Axes` object.
"""
precision, recall, thresholds = \
precision_recall_curve(y_test, preds)
if not ax:
fig, ax = plt.subplots()
ax.axhline(
sum(y_test == positive_class) / len(y_test),
color='navy', lw=2, linestyle='--', label='baseline'
)
ax.plot(
recall, precision, color='red', lw=2, label='model'
)
ax.legend()
ax.set_title(
'Precision-recall curve\n'
f"""AP: {average_precision_score(
y_test, preds, pos_label=positive_class
):.2} | """
f'AUC: {auc(recall, precision):.2}'
)
ax.set(xlabel='Recall', ylabel='Precision')
ax.set_xlim(-0.05, 1.05)
ax.set_ylim(-0.05, 1.05)
return ax
由于scikit-learn中 AUC 计算的实现使用了插值方法,可能会给出过于乐观的结果,因此我们的函数还计算了平均精确度(AP),它将精确度-召回率曲线总结为在各个阈值下实现的精确度得分(Pn)的加权平均值。加权值来源于一个阈值和下一个阈值之间召回率(Rn)的变化。值的范围在 0 到 1 之间,值越高越好:

让我们来看看红酒质量模型的精确度-召回率曲线:
>>> from ml_utils.classification import plot_pr_curve
>>> plot_pr_curve(
... r_y_test, red_quality_lr.predict_proba(r_X_test)[:,1]
... )
这仍然表明我们的模型优于随机猜测的基线;然而,我们在这里获得的性能读数似乎更符合我们在分类报告中看到的平庸表现。我们还可以看到,从召回率 0.2 到 0.4 的过程中,模型的精确度大幅下降。在这里,精确度和召回率之间的权衡是显而易见的,我们很可能会选择优化其中一个:

图 9.35 – 红酒质量模型的精确度-召回率曲线
由于我们在高质量和低质量红酒之间存在类别不平衡(高质量红酒少于 14%),我们必须选择优化精确度还是召回率。我们的选择将取决于我们在葡萄酒行业中所服务的对象。如果我们以生产高质量红酒而闻名,并且我们要为评论家提供酒样进行评审,我们希望确保挑选出最好的酒,并宁愿错过一些好酒(假阴性),也不愿让低质量的酒被模型误分类为高质量酒(假阳性)影响我们的声誉。然而,如果我们的目标是从销售酒品中获得最佳利润,我们就不希望以低质量酒的价格出售高质量酒(假阴性),因此我们宁愿为一些低质量酒定高价(假阳性)。
请注意,我们本可以轻松地将所有内容分类为低质量,以免让人失望,或者将其分类为高质量,以最大化销售利润;然而,这并不太实际。显然,我们需要在假阳性和假阴性之间找到一个可接受的平衡。为此,我们需要量化这两极之间的权衡,以确定哪些对我们更重要。然后,我们可以使用精度-召回曲线找到一个符合我们精度和召回率目标的阈值。在第十一章,机器学习异常检测中,我们将通过一个示例来讲解这一点。
现在,让我们来看一下白酒或红酒分类器的精度-召回曲线:
>>> from ml_utils.classification import plot_pr_curve
>>> plot_pr_curve(
... w_y_test, white_or_red.predict_proba(w_X_test)[:,1]
... )
请注意,这条曲线位于右上角。使用这个模型,我们可以实现高精度和高召回率:

图 9.36 – 白酒或红酒模型的精度-召回曲线
正如我们在红酒质量模型中看到的,AUPR 在类别不平衡时表现非常好。然而,它无法跨数据集进行比较,计算成本高,且难以优化。请注意,这只是我们可以用来评估分类问题的指标的一个子集。所有scikit-learn提供的分类指标可以在scikit-learn.org/stable/modules/classes.html#classification-metrics找到。
总结
本章作为 Python 机器学习的入门介绍。我们讨论了常用的学习类型和任务术语。接着,我们使用本书中学到的技能进行了 EDA,了解了酒类和行星数据集。这为我们构建模型提供了一些思路。在尝试构建模型之前,彻底探索数据是至关重要的。
接下来,我们学习了如何为机器学习模型准备数据,以及在建模之前将数据拆分为训练集和测试集的重要性。为了高效准备数据,我们在scikit-learn中使用了管道,将从预处理到模型的所有步骤进行打包。
我们使用无监督的 k-means 算法,基于行星的半长轴和周期对行星进行聚类;我们还讨论了如何使用肘部法则来找到合适的k值。接着,我们转向有监督学习,构建了一个线性回归模型,利用行星的半长轴、轨道偏心率和质量来预测其周期。我们学习了如何解释模型系数以及如何评估模型的预测结果。最后,我们进入了分类问题,识别高质量的红酒(存在类别不平衡),并根据它们的化学特性区分红酒和白酒。通过使用精确度、召回率、F1 得分、混淆矩阵、ROC 曲线和精确度-召回率曲线,我们讨论了如何评估分类模型。
需要记住的是,机器学习模型对底层数据做出假设,尽管本章没有深入讨论机器学习的数学原理,但我们应当理解,违反这些假设会带来一定的后果。在实际操作中,构建模型时,我们需要对统计学和领域知识有充分的理解。我们看到,评估模型的指标有很多,每个指标都有其优缺点,具体哪种指标更好取决于问题的类型;我们必须小心选择适合当前任务的评估指标。
在下一章中,我们将学习如何调优我们的模型以提升其性能,因此,在继续之前,确保完成这些练习以巩固本章内容。
练习
练习使用scikit-learn构建和评估机器学习模型,完成以下练习:
-
构建一个聚类模型,通过化学特性区分红酒和白酒:
a) 合并红酒和白酒数据集(分别是
data/winequality-red.csv和data/winequality-white.csv),并添加一个表示酒的种类(红酒或白酒)的列。b) 执行一些初步的 EDA(探索性数据分析)。
c) 构建并拟合一个管道(pipeline),对数据进行标准化处理,然后使用 k-means 聚类算法将数据分成两个聚类。注意不要使用
quality列。d) 使用 Fowlkes-Mallows 指数(
fowlkes_mallows_score()函数在sklearn.metrics中)评估 k-means 算法在区分红酒和白酒时的效果。e) 找出每个聚类的中心。
-
预测恒星温度:
a) 使用
data/stars.csv文件,执行一些初步的 EDA,然后构建一个线性回归模型,对所有数值列进行分析,以预测恒星的温度。b) 使用初始数据的 75%来训练模型。
c) 计算模型的 R2 和 RMSE(均方根误差)。
d) 找出每个回归器的系数和线性回归方程的截距。
e) 使用
ml_utils.regression模块中的plot_residuals()函数可视化残差。 -
分类那些年比地球更短的行星:
a) 使用
data/planets.csv文件,构建一个逻辑回归模型,将eccentricity、semimajoraxis和mass列作为回归变量。你需要创建一个新的列作为y(比地球年份短)。b) 找出准确率分数。
c) 使用
scikit-learn中的classification_report()函数查看每个类别的精度、召回率和 F1 分数。d) 使用
ml_utils.classification模块中的plot_roc()函数绘制 ROC 曲线。e) 使用
ml_utils.classification模块中的confusion_matrix_visual()函数创建混淆矩阵。 -
白葡萄酒质量的多类别分类:
a) 使用
data/winequality-white.csv文件,对白葡萄酒数据进行初步的 EDA。一定要查看每种质量评分的葡萄酒数量。b) 构建一个管道以标准化数据并拟合一个多类别的逻辑回归模型。将
multi_class='multinomial'和max_iter=1000传递给LogisticRegression构造函数。c) 查看你模型的分类报告。
d) 使用
ml_utils.classification模块中的confusion_matrix_visual()函数创建混淆矩阵。这对于多类别分类问题可以直接使用。e) 扩展
plot_roc()函数以支持多个类别标签。为此,你需要为每个类别标签(这里是质量分数)创建一个 ROC 曲线,其中真正例是正确预测该质量分数,假正例是预测任何其他质量分数。请注意,ml_utils已有此功能,但你可以尝试自己实现。f) 扩展
plot_pr_curve()函数以支持多个类别标签,方法类似于e)部分。但为每个类别创建单独的子图。请注意,ml_utils已有此功能,但你可以尝试自己实现。 -
我们已经看到
scikit-learnAPI 的易用性,使得切换我们模型中使用的算法变得轻松。使用支持向量机(SVM)重建本章中创建的红酒质量模型,而不是使用逻辑回归。我们虽然没有讨论这个模型,但你仍然可以在scikit-learn中使用它。可以参考进一步阅读部分的链接,了解更多关于该算法的信息。以下是本练习的一些指导:a) 你需要使用
scikit-learn中的SVC(支持向量分类器)类,详情请见scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html。b) 在
SVC构造函数中使用C=5作为参数。c) 在
SVC构造函数中传递probability=True,以便使用predict_proba()方法。d) 首先使用
StandardScaler类构建管道,然后使用SVC类。e) 确保查看模型的分类报告、精度-召回率曲线和混淆矩阵。
进一步阅读
查看以下资源,获取有关本章所涵盖主题的更多信息:
-
深度强化学习入门指南:
pathmind.com/wiki/deep-reinforcement-learning -
梯度下降和线性回归简介:
spin.atomicobject.com/2014/06/24/gradient-descent-linear-regression/ -
多元线性回归的假设:
www.statisticssolutions.com/assumptions-of-multiple-linear-regression/ -
可解释的机器学习指南 – 破解深度学习黑箱迷思的技巧:
towardsdatascience.com/guide-to-interpretable-machine-learning-d40e8a64b6cf -
深入讲解: k-Means:
jakevdp.github.io/PythonDataScienceHandbook/05.11-k-means.html -
可解释的机器学习 – 使黑箱模型可解释的指南:
christophm.github.io/interpretable-ml-book/ -
可解释的机器学习 – 从任何机器学习模型中提取人类可理解的洞察:
towardsdatascience.com/interpretable-machine-learning-1dec0f2f3e6b -
MAE 和 RMSE – 哪个指标更好?:
medium.com/human-in-a-machine-world/mae-and-rmse-which-metric-is-better-e60ac3bde13d -
模型评估:量化预测质量:
scikit-learn.org/stable/modules/model_evaluation.html -
Scikit-learn 常见术语和 API 元素词汇表:
scikit-learn.org/stable/glossary.html#glossary -
Scikit-learn 用户指南:
scikit-learn.org/stable/user_guide.html -
Seeing Theory 第六章**: 回归分析:
seeing-theory.brown.edu/index.html#secondPage/chapter6 -
简单的强化学习入门指南及其实现:
www.analyticsvidhya.com/blog/2017/01/introduction-to-reinforcement-learning-implementation/ -
支持向量机——机器学习算法简介:
towardsdatascience.com/support-vector-machine-introduction-to-machine-learning-algorithms-934a444fca47 -
数据科学家需要了解的 5 种聚类算法:
towardsdatascience.com/the-5-clustering-algorithms-data-scientists-need-to-know-a36d136ef68
第十一章:第十章:做出更好的预测——优化模型
在前一章中,我们学习了如何构建和评估机器学习模型。然而,我们没有涉及如果我们想要提高模型性能时可以做些什么。当然,我们可以尝试使用不同的模型,看看它是否表现更好——除非有法律要求或需要能够解释其工作原理的要求,必须使用特定的方法。我们希望确保使用我们能做到的最佳版本的模型,为此,我们需要讨论如何调整我们的模型。
本章将介绍使用scikit-learn优化机器学习模型性能的技术,作为第九章《Python 中的机器学习入门》的延续。然而,需要注意的是,优化并非万能药。我们完全有可能尝试所有能想到的方法,仍然得到一个预测能力较差的模型;这正是建模的特点。
不过不要灰心——如果模型不起作用,考虑一下收集到的数据是否足够回答问题,以及所选择的算法是否适合当前任务。通常,学科领域的专业知识在构建机器学习模型时至关重要,因为它帮助我们确定哪些数据点是相关的,并利用已知的变量之间的相互作用。
特别地,将涵盖以下主题:
-
使用网格搜索进行超参数调整
-
特征工程
-
构建集成模型,结合多个估计器
-
检查分类预测置信度
-
解决类别不平衡问题
-
使用正则化惩罚高回归系数
本章资料
在本章中,我们将使用三个数据集。前两个数据集来自 P. Cortez、A. Cerdeira、F. Almeida、T. Matos 和 J. Reis 捐赠给 UCI 机器学习数据仓库的关于葡萄酒质量的数据(archive.ics.uci.edu/ml/index.php)。数据包含了各种葡萄酒样本的化学属性信息,以及来自一组葡萄酒专家盲品评审会对其质量的评分。这些文件可以在本章的 GitHub 仓库中的data/文件夹下找到,文件名为winequality-red.csv和winequality-white.csv,分别对应红葡萄酒和白葡萄酒。
我们的第三个数据集是使用开放系外行星目录数据库收集的,网址是:github.com/OpenExoplanetCatalogue/open_exoplanet_catalogue/,该数据库提供 XML 格式的数据。解析后的行星数据可以在data/planets.csv文件中找到。对于练习,我们还将使用来自第九章的恒星温度数据,Python 中的机器学习入门,可以在data/stars.csv文件中找到。
作为参考,以下数据源被使用:
-
开放系外行星目录数据库,可在
github.com/OpenExoplanetCatalogue/open_exoplanet_catalogue/#data-structure找到。 -
P. Cortez, A. Cerdeira, F. Almeida, T. Matos 和 J. Reis. 通过物理化学性质的数据挖掘建模葡萄酒偏好。在《决策支持系统》,Elsevier,47(4):547-553,2009 年。 可在线获取:
archive.ics.uci.edu/ml/datasets/Wine+Quality。 -
Dua, D. 和 Karra Taniskidou, E. (2017). UCI 机器学习库 (
archive.ics.uci.edu/ml/index.php)。加利福尼亚州尔湾市:加利福尼亚大学信息与计算机科学学院。
我们将使用red_wine.ipynb笔记本来预测红葡萄酒的质量,使用wine.ipynb根据葡萄酒的化学性质来区分红白葡萄酒,使用planets_ml.ipynb笔记本构建回归模型来预测行星的年份长度(以地球日为单位)。
在开始之前,让我们处理导入并读取数据:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import pandas as pd
>>> import seaborn as sns
>>> planets = pd.read_csv('data/planets.csv')
>>> red_wine = pd.read_csv('data/winequality-red.csv')
>>> white_wine = \
... pd.read_csv('data/winequality-white.csv', sep=';')
>>> wine = pd.concat([
... white_wine.assign(kind='white'),
... red_wine.assign(kind='red')
... ])
>>> red_wine['high_quality'] = pd.cut(
... red_wine.quality, bins=[0, 6, 10], labels=[0, 1]
... )
让我们也为红葡萄酒质量、葡萄酒类型通过化学性质以及行星模型创建训练和测试集:
>>> from sklearn.model_selection import train_test_split
>>> red_y = red_wine.pop('high_quality')
>>> red_X = red_wine.drop(columns='quality')
>>> r_X_train, r_X_test, \
... r_y_train, r_y_test = train_test_split(
... red_X, red_y, test_size=0.1, random_state=0,
... stratify=red_y
... )
>>> wine_y = np.where(wine.kind == 'red', 1, 0)
>>> wine_X = wine.drop(columns=['quality', 'kind'])
>>> w_X_train, w_X_test, \
... w_y_train, w_y_test = train_test_split(
... wine_X, wine_y, test_size=0.25,
... random_state=0, stratify=wine_y
... )
>>> data = planets[
... ['semimajoraxis', 'period', 'mass', 'eccentricity']
... ].dropna()
>>> planets_X = data[
... ['semimajoraxis', 'mass', 'eccentricity']
... ]
>>> planets_y = data.period
>>> pl_X_train, pl_X_test, \
... pl_y_train, pl_y_test = train_test_split(
... planets_X, planets_y, test_size=0.25, random_state=0
... )
重要提示
请记住,我们将在每个数据集对应的专用笔记本中工作,因此,虽然设置代码都在同一个代码块中,以便在书中更容易跟随,但请确保在与相应数据相关的笔记本中工作。
使用网格搜索进行超参数调优
毋庸置疑,您已经注意到我们在实例化模型类时可以提供各种参数。这些模型参数不是由数据本身派生出来的,而是被称为超参数。其中一些示例是正则化项,我们将在本章后面讨论,另一些是权重。通过模型调优的过程,我们希望通过调整这些超参数来优化模型的性能。
我们如何知道自己选择的是优化模型性能的最佳值呢?一种方法是使用一种称为 网格搜索 的技术来调优这些超参数。网格搜索允许我们定义一个搜索空间,并测试该空间中所有超参数的组合,保留那些导致最佳模型的组合。我们定义的评分标准将决定最佳模型。
还记得我们在 第九章 中讨论的肘部法则吗?用于寻找 k-means 聚类中 k 的一个合适值?我们可以使用类似的可视化方法来找到最佳的超参数值。这将涉及将训练数据拆分成 train_test_split()。在这里,我们将使用红酒质量数据集:
>>> from sklearn.model_selection import train_test_split
>>> r_X_train_new, r_X_validate,\
... r_y_train_new, r_y_validate = train_test_split(
... r_X_train, r_y_train, test_size=0.3,
... random_state=0, stratify=r_y_train
... )
接下来,我们可以对所有想要测试的超参数值多次构建模型,并根据我们最关心的指标对它们进行评分。让我们尝试找到 C 的一个合适值,C 是正则化强度的倒数,它决定了逻辑回归中惩罚项的权重,并将在本章末的 正则化 部分进行更深入的讨论;我们调节这个超参数来减少过拟合:
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.metrics import f1_score
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import MinMaxScaler
# we will try 10 values from 10^-1 to 10¹ for C
>>> inv_regularization_strengths = \
... np.logspace(-1, 1, num=10)
>>> scores = []
>>> for inv_reg_strength in inv_regularization_strengths:
... pipeline = Pipeline([
... ('scale', MinMaxScaler()),
... ('lr', LogisticRegression(
... class_weight='balanced', random_state=0,
... C=inv_reg_strength
... ))
... ]).fit(r_X_train_new, r_y_train_new)
... scores.append(f1_score(
... pipeline.predict(r_X_validate), r_y_validate
... ))
提示
在这里,我们使用 np.logspace() 来获得我们要尝试的 C 的取值范围。要使用此函数,我们提供起始和停止的指数,并与基数(默认值为 10)一起使用。所以 np.logspace(-1, 1, num=10) 会给我们 10 个均匀分布的数字,范围从 10^-1 到 10¹。
然后,结果绘制如下:
>>> plt.plot(inv_regularization_strengths, scores, 'o-')
>>> plt.xscale('log')
>>> plt.xlabel('inverse of regularization strength (C)')
>>> plt.ylabel(r'$F_1$ score')
>>> plt.title(
... r'$F_1$ score vs. '
... 'Inverse of Regularization Strength'
... )
使用生成的图表,我们可以选择最大化我们性能的值:

图 10.1 – 寻找最佳超参数
Scikit-learn 提供了 GridSearchCV 类,该类位于 model_selection 模块中,可以更轻松地执行这种全面的搜索。以 CV 结尾的类利用 交叉验证,这意味着它们将训练数据划分为子集,其中一些子集将作为验证集来评分模型(在模型拟合之前无需使用测试数据)。
一种常见的交叉验证方法是 k 折交叉验证,它将训练数据划分为 k 个子集,并将模型训练 k 次,每次留下一个子集作为验证集。模型的得分将是 k 个验证集的平均值。我们最初的尝试是 1 折交叉验证。当 k=3 时,这个过程如下图所示:

图 10.2 – 理解 k 折交叉验证
提示
在处理分类问题时,scikit-learn将实现分层 k 折交叉验证。这确保了每个类的样本百分比将在折叠之间得到保持。如果没有分层,可能会出现某些验证集看到某个类的样本数量不成比例(过低或过高),这会扭曲结果。
GridSearchCV使用交叉验证来找到搜索空间中的最佳超参数,无需使用测试数据。请记住,测试数据不应以任何方式影响训练过程——无论是在训练模型时,还是在调整超参数时——否则模型将无法很好地进行泛化。这是因为我们可能会选择在测试集上表现最好的超参数,从而无法在未见过的数据上进行测试,并高估我们的性能。
为了使用GridSearchCV,我们需要提供一个模型(或管道)和一个搜索空间,搜索空间将是一个字典,将要调优的超参数(按名称)映射到要尝试的值的列表。可选地,我们还可以提供要使用的评分指标,以及要用于交叉验证的折叠数。我们可以通过在超参数名称前加上步骤名称并用两个下划线分隔来调优管道中的任何步骤。例如,如果我们有一个名为lr的逻辑回归步骤,并且想要调优C,我们在搜索空间字典中使用lr__C作为键。请注意,如果我们的模型有任何预处理步骤,必须使用管道。
让我们使用GridSearchCV进行红酒质量的逻辑回归搜索,看看是否需要为我们的模型添加截距项,并寻找正则化强度的逆(C)的最佳值。我们将使用 F1 得分宏平均作为评分指标。请注意,由于 API 的一致性,GridSearchCV可以像基础模型一样使用相同的方法进行评分、拟合和预测。默认情况下,网格搜索将按顺序进行,但GridSearchCV能够并行执行多个搜索,从而大大加速这一过程:
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import MinMaxScaler
>>> pipeline = Pipeline([
... ('scale', MinMaxScaler()),
... ('lr', LogisticRegression(class_weight='balanced',
... random_state=0))
... ])
>>> search_space = {
... 'lr__C': np.logspace(-1, 1, num=10),
... 'lr__fit_intercept': [True, False]
... }
>>> lr_grid = GridSearchCV(
... pipeline, search_space, scoring='f1_macro', cv=5
... ).fit(r_X_train, r_y_train)
一旦网格搜索完成,我们可以通过best_params_属性从搜索空间中提取最佳超参数。请注意,这一结果与我们 1 折交叉验证的尝试不同,因为每个折叠的结果已经被平均起来,以找到整体最佳的超参数,而不仅仅是针对单一折叠的超参数:
# best values of `C` and `fit_intercept` in search space
>>> lr_grid.best_params_
{'lr__C': 3.593813663804626, 'lr__fit_intercept': True}
提示
我们还可以通过best_estimator_属性从网格搜索中检索最佳版本的管道。如果我们想查看最佳估计器(模型)的得分,可以从best_score_属性中获取;请注意,这将是我们在scoring参数中指定的得分。
我们的 F1 得分宏平均值现在已经高于我们在第九章,用 Python 进行机器学习入门中取得的成绩:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(
... r_y_test, lr_grid.predict(r_X_test)
... ))
precision recall f1-score support
0 0.94 0.80 0.87 138
1 0.36 0.68 0.47 22
accuracy 0.79 160
macro avg 0.65 0.74 0.67 160
weighted avg 0.86 0.79 0.81 160
请注意,cv参数不一定非得是整数——如果我们希望使用除默认的 k 折回归交叉验证或分类分层 k 折交叉验证之外的方法,我们可以提供在scikit-learn.org/stable/modules/classes.html#splitter-classes中提到的分割器类。例如,在处理时间序列时,我们可以使用TimeSeriesSplit作为交叉验证对象,以处理连续样本并避免打乱顺序。Scikit-learn 在scikit-learn.org/stable/auto_examples/model_selection/plot_cv_indices.html展示了交叉验证类的比较。
让我们在红酒质量模型上测试RepeatedStratifiedKFold,而不是默认的StratifiedKFold,它会默认重复进行 10 次分层 k 折交叉验证。我们要做的就是将第一个GridSearchCV示例中传递的cv参数改为RepeatedStratifiedKFold对象。请注意——尽管使用的是相同的管道、搜索空间和评分指标——由于交叉验证过程的变化,我们的best_params_值不同:
>>> from sklearn.model_selection import RepeatedStratifiedKFold
>>> lr_grid = GridSearchCV(
... pipeline, search_space, scoring='f1_macro',
... cv=RepeatedStratifiedKFold(random_state=0)
... ).fit(r_X_train, r_y_train)
>>> print('Best parameters (CV score=%.2f):\n %s' % (
... lr_grid.best_score_, lr_grid.best_params_
... )) # f1 macro score
Best parameters (CV score=0.69):
{'lr__C': 5.994842503189409, 'lr__fit_intercept': True}
除了交叉验证,GridSearchCV还允许我们通过scoring参数指定我们希望优化的指标。这可以是一个字符串,表示评分的名称(如前面代码块中的做法),前提是它在scikit-learn.org/stable/modules/model_evaluation.html#common-cases-predefined-values的列表中;否则,我们可以直接传递函数本身,或者使用sklearn.metrics中的make_scorer()函数自定义评分函数。我们甚至可以为网格搜索提供一个评分函数字典(格式为{name: function}),只要我们通过refit参数指定我们希望用来优化的评分函数的名称。因此,我们可以使用网格搜索来找到能够最大化我们在上一章讨论的指标上的性能的超参数。
重要提示
训练模型所需的时间也是我们需要评估并优化的因素。如果我们需要两倍的训练时间才能获得一个额外的正确分类,可能就不值得这么做。如果我们有一个叫做grid的GridSearchCV对象,我们可以通过运行grid.cv_results_['mean_fit_time']来查看平均拟合时间。
我们可以使用GridSearchCV在管道中的任何步骤上搜索最佳参数。例如,我们可以在行星数据集上使用预处理和线性回归的管道进行网格搜索(类似于我们在第九章,《Python 机器学习入门》中建立行星年长度模型时的做法),同时最小化平均绝对误差(MAE),而不是默认的 R2:
>>> from sklearn.linear_model import LinearRegression
>>> from sklearn.metrics import \
... make_scorer, mean_squared_error
>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> model_pipeline = Pipeline([
... ('scale', StandardScaler()),
... ('lr', LinearRegression())
... ])
>>> search_space = {
... 'scale__with_mean': [True, False],
... 'scale__with_std': [True, False],
... 'lr__fit_intercept': [True, False],
... 'lr__normalize': [True, False]
... }
>>> grid = GridSearchCV(
... model_pipeline, search_space, cv=5,
... scoring={
... 'r_squared': 'r2',
... 'mse': 'neg_mean_squared_error',
... 'mae': 'neg_mean_absolute_error',
... 'rmse': make_scorer(
... lambda x, y: \
... -np.sqrt(mean_squared_error(x, y))
... )
... }, refit='mae'
... ).fit(pl_X_train, pl_y_train)
请注意,我们正在使用除 R2 外所有指标的负值。这是因为GridSearchCV将尝试最大化得分,而我们希望最小化错误。让我们检查此网格中缩放和线性回归的最佳参数:
>>> print('Best parameters (CV score=%.2f):\n%s' % (
... grid.best_score_, grid.best_params_
... )) # MAE score * -1
Best parameters (CV score=-1215.99):
{'lr__fit_intercept': False, 'lr__normalize': True,
'scale__with_mean': False, 'scale__with_std': True}
调整后模型的 MAE 比《Python 机器学习入门》第九章中的 MAE 减少了 120 个地球日:
>>> from sklearn.metrics import mean_absolute_error
>>> mean_absolute_error(pl_y_test, grid.predict(pl_X_test))
1248.3690943844194
需要注意的是,虽然模型可能训练速度很快,但我们不应创建大而精细的搜索空间;在实践中,最好从几个不同的分布值开始,然后检查结果,看哪些区域值得进行更深入的搜索。例如,假设我们要调整C超参数。在第一次尝试中,我们可以查看np.logspace(-1, 1)的结果。如果我们发现C的最佳值在极端值之一,我们可以再查看该值以上/以下的值。如果最佳值在范围内,我们可以查看该值周围的几个值。可以迭代执行此过程,直到不再看到额外的改进。或者,我们可以使用RandomizedSearchCV,它将在搜索空间中尝试 10 个随机组合(默认情况下),并找到最佳估算器(模型)。我们可以使用n_iter参数更改此数字。
重要说明
由于调整超参数的过程要求我们多次训练模型,我们必须考虑模型的时间复杂度。训练时间长的模型在使用交叉验证时将非常昂贵。这可能会导致我们缩小搜索空间。
特征工程
当我们试图提高性能时,我们也可以考虑通过特征工程的过程向模型提供最佳特征(模型输入)。在《Python 机器学习入门》第九章的数据预处理部分,当我们对数据进行缩放、编码和填充时,介绍了特征转换。不幸的是,特征转换可能会使我们想在模型中使用的数据元素变得不那么显著,比如特定特征的未缩放平均值。针对这种情况,我们可以创建一个具有此值的新特征;这些以及其他新特征是在特征构建(有时称为特征创建)过程中添加的。
特征选择是确定哪些特征用于训练模型的过程。这可以通过手动操作或通过其他过程,如机器学习来完成。当我们选择模型的特征时,我们希望选择对因变量有影响的特征,同时避免不必要地增加问题的复杂性。使用许多特征构建的模型增加了复杂性,但不幸的是,这些模型更容易拟合噪声,因为我们的数据在高维空间中是稀疏的。这被称为维度灾难。当模型学到了训练数据中的噪声时,它会很难对未见过的数据进行泛化;这称为过拟合。通过限制模型使用的特征数量,特征选择有助于解决过拟合问题。
特征提取是我们可以解决维度灾难的另一种方式。在特征提取过程中,我们通过变换构造特征的组合,从而减少数据的维度。这些新特征可以替代原始特征,从而减少问题的维度。这个过程称为降维,它还包括一些技术,利用少量的成分(比原始特征少)来解释数据中大部分的方差。特征提取通常用于图像识别问题,因为任务的维度是图像中的像素总数。例如,网站上的方形广告是 350x350 像素(这是最常见的尺寸之一),因此,使用该尺寸图像进行的图像识别任务具有 122,500 个维度。
提示
彻底的探索性数据分析(EDA)和领域知识是特征工程的必备条件。
特征工程是整本书的主题;然而,由于它是一个更为高级的话题,我们在本节中仅介绍几种技巧。在进一步阅读部分有一本关于该主题的好书,它还涉及使用机器学习进行特征学习。
交互项和多项式特性
我们在第九章的数据预处理部分讨论了虚拟变量的使用;然而,我们仅仅考虑了该变量单独的影响。在我们尝试使用化学特性预测红葡萄酒质量的模型中,我们分别考虑了每个特性。然而,考虑这些特性之间的相互作用是否会产生影响也很重要。也许当柠檬酸和固定酸度都较高或都较低时,葡萄酒的质量与其中一个较高、另一个较低时会有所不同。为了捕捉这种影响,我们需要添加一个交互项,即特性之间的乘积。
我们还可能希望通过特征构造来增加某个特征在模型中的作用;我们可以通过添加多项式特征来实现这一点,这些特征是由该特征构造的。这涉及到添加原始特征的更高阶次,所以我们可以在模型中拥有柠檬酸、柠檬酸²、柠檬酸³,依此类推。
提示
我们可以通过使用交互项和多项式特征来推广线性模型,因为它们允许我们对非线性项的线性关系建模。由于线性模型在存在多个或非线性决策边界(分隔类别的表面或超表面)时往往表现不佳,这可以提高模型的性能。
Scikit-learn 提供了preprocessing模块中的PolynomialFeatures类,用于轻松创建交互项和多项式特征。在构建包含类别特征和连续特征的模型时,这非常有用。只需指定度数,我们就可以得到所有小于或等于该度数的特征组合。较高的度数会大幅增加模型的复杂度,并可能导致过拟合。
如果我们使用degree=2,我们可以将柠檬酸和固定酸度转换为以下内容,其中1是可以作为模型截距项使用的偏置项:

通过在PolynomialFeatures对象上调用fit_transform()方法,我们可以生成这些特征:
>>> from sklearn.preprocessing import PolynomialFeatures
>>> PolynomialFeatures(degree=2).fit_transform(
... r_X_train[['citric acid', 'fixed acidity']]
... )
array([[1.000e+00, 5.500e-01, 9.900e+00, 3.025e-01,
5.445e+00, 9.801e+01],
[1.000e+00, 4.600e-01, 7.400e+00, 2.116e-01,
3.404e+00, 5.476e+01],
[1.000e+00, 4.100e-01, 8.900e+00, 1.681e-01,
3.649e+00, 7.921e+01],
...,
[1.000e+00, 1.200e-01, 7.000e+00, 1.440e-02,
8.400e-01, 4.900e+01],
[1.000e+00, 3.100e-01, 7.600e+00, 9.610e-02,
2.356e+00, 5.776e+01],
[1.000e+00, 2.600e-01, 7.700e+00, 6.760e-02,
2.002e+00, 5.929e+01]])
让我们分析前面代码块中数组的第一行(加粗部分),以了解我们是如何得到这些值的:

图 10.3 – 检查所创建的交互项和多项式特征
如果我们只对交互变量(此处为柠檬酸 × 固定酸度)感兴趣,可以指定interaction_only=True。在这种情况下,我们还不希望有偏置项,因此也指定include_bias=False。这将为我们提供原始变量及其交互项:
>>> PolynomialFeatures(
... degree=2, include_bias=False, interaction_only=True
... ).fit_transform(
... r_X_train[['citric acid', 'fixed acidity']]
... )
array([[0.55 , 9.9 , 5.445],
[0.46 , 7.4 , 3.404],
[0.41 , 8.9 , 3.649],
...,
[0.12 , 7. , 0.84 ],
[0.31 , 7.6 , 2.356],
[0.26 , 7.7 , 2.002]])
我们可以将这些多项式特征添加到我们的管道中:
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import (
... MinMaxScaler, PolynomialFeatures
... )
>>> pipeline = Pipeline([
... ('poly', PolynomialFeatures(degree=2)),
... ('scale', MinMaxScaler()),
... ('lr', LogisticRegression(
... class_weight='balanced', random_state=0
... ))
... ]).fit(r_X_train, r_y_train)
请注意,这个模型比我们在第九章《在 Python 中入门机器学习》使用的模型稍微更好,因为我们添加了这些额外的项:
>>> from sklearn.metrics import classification_report
>>> preds = pipeline.predict(r_X_test)
>>> print(classification_report(r_y_test, preds))
precision recall f1-score support
0 0.95 0.79 0.86 138
1 0.36 0.73 0.48 22
accuracy 0.78 160
macro avg 0.65 0.76 0.67 160
weighted avg 0.87 0.78 0.81 160
添加多项式特征和交互项会增加我们数据的维度,这可能并不理想。有时,与其寻找创造更多特征的方法,我们更倾向于寻找合并特征并减少数据维度的方式。
降维
降维是减少训练模型时所用特征数量的过程。这是为了在不大幅牺牲性能的情况下,降低训练模型的计算复杂度。我们可以选择仅在特征的子集上进行训练(特征选择);然而,如果我们认为这些特征中存在一些有价值的信息,即便这些信息很小,我们也可以寻找提取所需信息的方法。
一种常见的特征选择策略是丢弃方差较低的特征。这些特征不太具有信息性,因为它们在数据中大部分值相同。Scikit-learn 提供了VarianceThreshold类,用于根据最小方差阈值进行特征选择。默认情况下,它会丢弃方差为零的特征;但是,我们可以提供自己的阈值。我们将在预测葡萄酒是红酒还是白酒的模型上进行特征选择,基于其化学成分。由于我们没有方差为零的特征,我们将选择保留方差大于 0.01 的特征:
>>> from sklearn.feature_selection import VarianceThreshold
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> white_or_red_min_var = Pipeline([
... ('feature_selection',
...VarianceThreshold(threshold=0.01)),
... ('scale', StandardScaler()),
... ('lr', LogisticRegression(random_state=0))
... ]).fit(w_X_train, w_y_train)
这移除了两个方差较低的特征。我们可以通过VarianceThreshold对象的get_support()方法返回的布尔掩码来获取它们的名称,该掩码指示了保留下来的特征:
>>> w_X_train.columns[
... ~white_or_red_min_var.named_steps[
... 'feature_selection'
... ].get_support()
... ]
Index(['chlorides', 'density'], dtype='object')
仅使用 11 个特征中的 9 个,我们的表现几乎没有受到影响:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(
... w_y_test, white_or_red_min_var.predict(w_X_test)
... ))
precision recall f1-score support
0 0.98 0.99 0.99 1225
1 0.98 0.95 0.96 400
accuracy 0.98 1625
macro avg 0.98 0.97 0.97 1625
weighted avg 0.98 0.98 0.98 1625
提示
查看feature_selection模块中其他特征选择的选项,地址为scikit-learn.org/stable/modules/classes.html#module-sklearn.feature_selection。
如果我们认为所有特征都有价值,我们可以选择使用特征提取,而不是完全丢弃它们。主成分分析(PCA)通过将高维数据投影到低维空间,从而进行特征提取,降低维度。作为回报,我们得到最大化解释方差的n个组件。由于这对数据的尺度非常敏感,因此我们需要事先进行一些预处理。
让我们看看ml_utils.pca模块中的pca_scatter()函数,它可以帮助我们在降至二维时可视化数据:
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import MinMaxScaler
def pca_scatter(X, labels, cbar_label, cmap='brg'):
"""
Create a 2D scatter plot from 2 PCA components of X
Parameters:
- X: The X data for PCA
- labels: The y values
- cbar_label: The label for the colorbar
- cmap: Name of the colormap to use.
Returns:
Matplotlib `Axes` object
"""
pca = Pipeline([
('scale', MinMaxScaler()),
('pca', PCA(2, random_state=0))
]).fit(X)
data, classes = pca.transform(X), np.unique(labels)
ax = plt.scatter(
data[:, 0], data[:, 1],
c=labels, edgecolor='none', alpha=0.5,
cmap=plt.cm.get_cmap(cmap, classes.shape[0])
)
plt.xlabel('component 1')
plt.ylabel('component 2')
cbar = plt.colorbar()
cbar.set_label(cbar_label)
cbar.set_ticks(classes)
plt.legend([
'explained variance\n'
'comp. 1: {:.3}\ncomp. 2: {:.3}'.format(
*pca.named_steps['pca'].explained_variance_ratio_
)
])
return ax
让我们用两个 PCA 组件来可视化葡萄酒数据,看看是否能找到将红酒与白酒分开的方法:
>>> from ml_utils.pca import pca_scatter
>>> pca_scatter(wine_X, wine_y, 'wine is red?')
>>> plt.title('Wine Kind PCA (2 components)')
大多数红酒位于顶部的亮绿色点块中,而白酒则位于底部的蓝色点块中。从视觉上来看,我们可以看到如何分开它们,但仍然有一些重叠:

图 10.4 – 使用两个 PCA 组件按类型分离葡萄酒
提示
PCA 组件将是线性无关的,因为它们是通过正交变换(垂直扩展到更高维度)得到的。线性回归假设回归变量(输入数据)之间没有相关性,因此这有助于解决多重共线性问题。
注意从之前图表的图例中得到的每个组件的解释方差——这些组件解释了葡萄酒数据中超过 50%的方差。接下来,我们来看看使用三维是否能改善分离效果。ml_utils.pca模块中的pca_scatter_3d()函数使用了mpl_toolkits,这是matplotlib中用于 3D 可视化的工具包:
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from sklearn.decomposition import PCA
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import MinMaxScaler
def pca_scatter_3d(X, labels, cbar_label, cmap='brg',
elev=10, azim=15):
"""
Create a 3D scatter plot from 3 PCA components of X
Parameters:
- X: The X data for PCA
- labels: The y values
- cbar_label: The label for the colorbar
- cmap: Name of the colormap to use.
- elev: The degrees of elevation to view the plot from.
- azim: The azimuth angle on the xy plane (rotation
around the z-axis).
Returns:
Matplotlib `Axes` object
"""
pca = Pipeline([
('scale', MinMaxScaler()),
('pca', PCA(3, random_state=0))
]).fit(X)
data, classes = pca.transform(X), np.unique(labels)
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
p = ax.scatter3D(
data[:, 0], data[:, 1], data[:, 2],
alpha=0.5, c=labels,
cmap=plt.cm.get_cmap(cmap, classes.shape[0])
)
ax.view_init(elev=elev, azim=azim)
ax.set_xlabel('component 1')
ax.set_ylabel('component 2')
ax.set_zlabel('component 3')
cbar = fig.colorbar(p, pad=0.1)
cbar.set_ticks(classes)
cbar.set_label(cbar_label)
plt.legend([
'explained variance\ncomp. 1: {:.3}\n'
'comp. 2: {:.3}\ncomp. 3: {:.3}'.format(
*pca.named_steps['pca'].explained_variance_ratio_
)
])
return ax
让我们再次使用 3D 可视化函数查看酒类数据,看看使用三个 PCA 组件时白酒和红酒是否更容易分开:
>>> from ml_utils.pca import pca_scatter_3d
>>> pca_scatter_3d(
... wine_X, wine_y, 'wine is red?', elev=20, azim=-10
... )
>>> plt.suptitle('Wine Type PCA (3 components)')
从这个角度看,我们似乎可以从绿色(右侧)点集划分出一部分,尽管仍然有一些点在错误的区域:

图 10.5 – 使用三个 PCA 组件按类型区分酒类
重要提示
PCA 执行线性降维。可以查看 t-SNE 和 Isomap 进行流形学习以实现非线性降维。
我们可以使用 ml_utils.pca 模块中的 pca_explained_variance_plot() 函数来可视化随着 PCA 组件数量变化的累积解释方差:
import matplotlib.pyplot as plt
import numpy as np
def pca_explained_variance_plot(pca_model, ax=None):
"""
Plot the cumulative explained variance of PCA components.
Parameters:
- pca_model: The PCA model that has been fit already
- ax: Matplotlib `Axes` object to plot on.
Returns:
A matplotlib `Axes` object
"""
if not ax:
fig, ax = plt.subplots()
ax.plot(
np.append(
0, pca_model.explained_variance_ratio_.cumsum()
), 'o-'
)
ax.set_title(
'Total Explained Variance Ratio for PCA Components'
)
ax.set_xlabel('PCA components used')
ax.set_ylabel('cumulative explained variance ratio')
return ax
我们可以将管道中的 PCA 部分传递给此函数,以查看累积解释方差:
>>> from sklearn.decomposition import PCA
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import MinMaxScaler
>>> from ml_utils.pca import pca_explained_variance_plot
>>> pipeline = Pipeline([
... ('normalize', MinMaxScaler()),
... ('pca', PCA(8, random_state=0))
... ]).fit(w_X_train, w_y_train)
>>> pca_explained_variance_plot(pipeline.named_steps['pca'])
前四个 PCA 组件解释了约 80% 的方差:

图 10.6 – PCA 组件的解释方差
我们还可以使用肘部法则来找到适合的 PCA 组件数量,就像在 第九章《Python 机器学习入门》中使用 k-means 一样。为此,我们需要确保 ml_utils.pca 模块包含 pca_scree_plot() 函数来创建这个可视化:
import matplotlib.pyplot as plt
import numpy as np
def pca_scree_plot(pca_model, ax=None):
"""
Plot explained variance of each consecutive PCA component.
Parameters:
- pca_model: The PCA model that has been fit already
- ax: Matplotlib `Axes` object to plot on.
Returns: A matplotlib `Axes` object
"""
if not ax:
fig, ax = plt.subplots()
values = pca_model.explained_variance_
ax.plot(np.arange(1, values.size + 1), values, 'o-')
ax.set_title('Scree Plot for PCA Components')
ax.set_xlabel('component')
ax.set_ylabel('explained variance')
return ax
我们可以将管道中的 PCA 部分传递给此函数,以查看每个 PCA 组件解释的方差:
>>> from sklearn.decomposition import PCA
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import MinMaxScaler
>>> from ml_utils.pca import pca_scree_plot
>>> pipeline = Pipeline([
... ('normalize', MinMaxScaler()),
... ('pca', PCA(8, random_state=0))
... ]).fit(w_X_train, w_y_train)
>>> pca_scree_plot(pipeline.named_steps['pca'])
螺旋图告诉我们应该尝试四个 PCA 组件,因为之后的组件回报递减:

图 10.7 – 每增加一个 PCA 组件后回报递减(第四个组件之后)
我们可以在这些四个 PCA 特征上建立一个模型,这个过程叫做 元学习,即管道中的最后一个模型是基于其他模型的输出而非原始数据进行训练:
>>> from sklearn.decomposition import PCA
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import MinMaxScaler
>>> from sklearn.linear_model import LogisticRegression
>>> pipeline = Pipeline([
... ('normalize', MinMaxScaler()),
... ('pca', PCA(4, random_state=0)),
... ('lr', LogisticRegression(
... class_weight='balanced', random_state=0
... ))
... ]).fit(w_X_train, w_y_train)
我们的新模型表现几乎与使用 11 个特征的原始逻辑回归一样好,只用了通过 PCA 制作的 4 个特征:
>>> from sklearn.metrics import classification_report
>>> preds = pipeline.predict(w_X_test)
>>> print(classification_report(w_y_test, preds))
precision recall f1-score support
0 0.99 0.99 0.99 1225
1 0.96 0.96 0.96 400
accuracy 0.98 1625
macro avg 0.98 0.98 0.98 1625
weighted avg 0.98 0.98 0.98 1625
在进行降维处理后,我们不再拥有最初的所有特征——毕竟,减少特征数量就是目的。然而,我们可能希望对特征子集应用不同的特征工程技术;为了实现这一点,我们需要理解特征联合。
特征联合
我们可能希望在来自各种来源的特征上建立模型,例如 PCA,以及选择特征的子集。为此,scikit-learn 提供了 pipeline 模块中的 FeatureUnion 类。这还允许我们一次执行多个特征工程技术,例如特征提取后跟特征变换,当我们将其与管道结合时。
创建 FeatureUnion 对象就像创建一个管道一样,但我们传递的是要进行的变换,而不是按顺序传递步骤。这些变换将在结果中并排堆叠。让我们使用交互项的特征联合,并选择方差大于 0.01 的特征来预测红酒质量:
>>> from sklearn.feature_selection import VarianceThreshold
>>> from sklearn.pipeline import FeatureUnion, Pipeline
>>> from sklearn.preprocessing import (
... MinMaxScaler, PolynomialFeatures
... )
>>> from sklearn.linear_model import LogisticRegression
>>> combined_features = FeatureUnion([
... ('variance', VarianceThreshold(threshold=0.01)),
... ('poly', PolynomialFeatures(
... degree=2, include_bias=False, interaction_only=True
... ))
... ])
>>> pipeline = Pipeline([
... ('normalize', MinMaxScaler()),
... ('feature_union', combined_features),
... ('lr', LogisticRegression(
... class_weight='balanced', random_state=0
... ))
... ]).fit(r_X_train, r_y_train)
为了说明发生的变换,让我们检查一下红酒质量数据集训练集的第一行,在 FeatureUnion 对象进行变换后的结果。由于我们看到方差阈值结果产生了九个特征,我们知道它们是结果 NumPy 数组中的前九项,剩下的是交互项:
>>> pipeline.named_steps['feature_union']\
... .transform(r_X_train)[0]
array([9.900000e+00, 3.500000e-01, 5.500000e-01, 5.000000e+00,
1.400000e+01, 9.971000e-01, 3.260000e+00, 1.060000e+01,
9.900000e+00, 3.500000e-01, 5.500000e-01, 2.100000e+00,
6.200000e-02, 5.000000e+00, 1.400000e+01, 9.971000e-01,
..., 3.455600e+01, 8.374000e+00])
我们还可以查看分类报告,看到 F1 分数有了微小的提升:
>>> from sklearn.metrics import classification_report
>>> preds = pipeline.predict(r_X_test)
>>> print(classification_report(r_y_test, preds))
precision recall f1-score support
0 0.94 0.80 0.87 138
1 0.36 0.68 0.47 22
accuracy 0.79 160
macro avg 0.65 0.74 0.67 160
weighted avg 0.86 0.79 0.81 160
在这个例子中,我们选择了具有大于 0.01 方差的特征,假设如果特征没有许多不同的值,它可能不会那么有用。我们可以使用机器学习模型来帮助确定哪些特征是重要的,而不是仅仅做出这个假设。
特征重要性
决策树递归地对数据进行划分,决定每次划分时使用哪些特征。它们是贪婪学习者,意味着它们每次都寻找可以进行的最大划分;这并不一定是查看树输出时的最佳划分。我们可以使用决策树来评估特征重要性,这决定了树在决策节点上如何划分数据。这些特征重要性可以帮助我们进行特征选择。请注意,特征重要性加起来会等于 1,值越大越好。让我们使用决策树来看看如何在化学层面上将红酒和白酒分开:
>>> from sklearn.tree import DecisionTreeClassifier
>>> dt = DecisionTreeClassifier(random_state=0).fit(
... w_X_train, w_y_train
... )
>>> pd.DataFrame([(col, coef) for col, coef in zip(
... w_X_train.columns, dt.feature_importances_
... )], columns=['feature', 'importance']
... ).set_index('feature').sort_values(
... 'importance', ascending=False
... ).T
这表明,在区分红酒和白酒时,最重要的化学特性是总二氧化硫和氯化物:

图 10.8 – 在预测酒的类型时,每种化学特性的重要性
提示
使用由特征重要性指示的最重要特征,我们可以尝试构建一个更简单的模型(通过使用更少的特征)。如果可能,我们希望简化我们的模型,而不牺牲太多性能。有关示例,请参见 wine.ipynb 笔记本。
如果我们训练另一棵最大深度为二的决策树,我们可以可视化树的顶部(如果不限制深度,树太大而无法可视化):
>>> from sklearn.tree import export_graphviz
>>> import graphviz
>>> graphviz.Source(export_graphviz(
... DecisionTreeClassifier(
... max_depth=2, random_state=0
... ).fit(w_X_train, w_y_train),
... feature_names=w_X_train.columns
... ))
重要提示
需要安装 Graphviz 软件(如果尚未安装)以可视化树结构。可以在graphviz.gitlab.io/download/下载,安装指南在graphviz.readthedocs.io/en/stable/manual.html#installation。请注意,在安装后需要重新启动内核。否则,将out_file='tree.dot'传递给export_graphviz()函数,然后在命令行中运行dot -T png tree.dot -o tree.png生成 PNG 文件。作为替代,scikit-learn提供了plot_tree()函数,它使用matplotlib;请参考笔记本中的示例。
这导致以下树形结构,首先在总二氧化硫上进行分割(具有最高的特征重要性),然后在第二级上进行氯化物分割。每个节点上的信息告诉我们分割的标准(顶部行),成本函数的值(gini),该节点处的样本数目(samples),以及每个类别中的样本数目(values):

图 10.9 – 基于化学性质预测葡萄酒类型的决策树
我们还可以将决策树应用于回归问题。让我们使用DecisionTreeRegressor类来查找行星数据的特征重要性:
>>> from sklearn.tree import DecisionTreeRegressor
>>> dt = DecisionTreeRegressor(random_state=0).fit(
... pl_X_train, pl_y_train
... )
>>> [(col, coef) for col, coef in zip(
... pl_X_train.columns, dt.feature_importances_
... )]
[('semimajoraxis', 0.9969449557611615),
('mass', 0.0015380986260574154),
('eccentricity', 0.0015169456127809738)]
基本上,半长轴是周期长度的主要决定因素,这一点我们已经知道,但是如果我们可视化一棵树,我们就能看到为什么。前四个分割都基于半长轴:

图 10.10 – 预测行星周期的决策树
决策树可以使用scikit-learn文档提供的提示来解决决策树使用时的过拟合和其他潜在问题,参见scikit-learn.org/stable/modules/tree.html#tips-on-practical-use。在讨论集成方法时,请记住这一点。
集成方法
集成方法结合了许多模型(通常是弱模型),创建一个更强的模型,可以最小化观察和预测值之间的平均误差(偏差),或者改进其对未见数据的泛化能力(最小化方差)。我们必须在可能增加方差的复杂模型之间取得平衡,因为它们倾向于过拟合,并且可能具有高偏差的简单模型之间取得平衡,因为这些倾向于欠拟合。这被称为偏差-方差权衡,在以下子图中有所说明:

图 10.11 – 偏差-方差权衡
集成方法可以分为三类:提升、装袋和堆叠。提升训练许多弱学习器,它们从彼此的错误中学习,减少偏差,从而使学习器更强。另一方面,装袋使用自助聚合方法,在数据的自助样本上训练多个模型,并将结果聚合在一起(分类时使用投票,回归时使用平均值),从而减少方差。我们还可以通过投票将不同类型的模型组合在一起。堆叠是一种集成技术,其中我们将多种不同的模型类型结合在一起,使用某些模型的输出作为其他模型的输入;这样做是为了提高预测的准确性。我们在本章的降维部分中,结合了 PCA 和逻辑回归,这就是堆叠的一个例子。
随机森林
决策树容易过拟合,尤其是当我们没有设置限制以控制树的生长深度(使用max_depth和min_samples_leaf参数)时。我们可以通过oob_score参数来解决这个过拟合问题。
重要提示
min_samples_leaf参数要求树的最终节点(或叶子)上有最少的样本数;这可以防止决策树过度拟合,直到每个叶子只有一个观测值。
每棵树还会得到特征的子集(随机特征选择),其默认值为特征数量的平方根(即max_features参数)。这有助于解决维度灾难。然而,作为后果,随机森林不如构成它的决策树那样容易解释。然而,我们仍然可以像在决策树中一样,从随机森林中提取特征重要性。
我们可以使用ensemble模块中的RandomForestClassifier类来构建一个随机森林(其中包含n_estimators棵树),用于高质量红酒的分类:
>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.model_selection import GridSearchCV
>>> rf = RandomForestClassifier(
... n_estimators=100, random_state=0
... )
>>> search_space = {
... 'max_depth': [4, 8], # keep trees small
... 'min_samples_leaf': [4, 6]
... }
>>> rf_grid = GridSearchCV(
... rf, search_space, cv=5, scoring='precision'
... ).fit(r_X_train, r_y_train)
>>> rf_grid.score(r_X_test, r_y_test)
0.6
请注意,我们使用随机森林的精度已经远远超过了第九章中我们得到的 0.35 精度,Python 中的机器学习入门。随机森林对离群点具有鲁棒性,并且能够建模非线性决策边界以分隔类别,这可能解释了这种显著改进的部分原因。
梯度提升
提升方法旨在改进前一模型的错误。实现这一点的一种方式是沿着损失函数下降最快的方向移动。由于梯度(导数的多变量推广)是最陡的上升方向,因此可以通过计算负梯度来实现这一点,这样就得到了最陡的下降方向,也就是当前结果的损失函数中最佳的改进方向。这种技术称为梯度下降。
重要提示
尽管梯度下降听起来不错,但它也存在一些潜在问题。可能会陷入局部最小值(在某一地区的损失函数最小值);算法会停止,认为我们找到了最优解,实际上我们并没有找到,因为我们希望得到全局最小值(整个区域的最小值)。
Scikit-learn 的 ensemble 模块提供了 GradientBoostingClassifier 和 GradientBoostingRegressor 类,用于通过决策树实现梯度提升。这些树将通过梯度下降来提高性能。请注意,梯度提升树对噪声训练数据比随机森林更敏感。此外,我们必须考虑到,构建所有树所需的额外时间是线性串行的,而不像随机森林那样可以并行训练。
让我们使用网格搜索和梯度提升来训练另一个模型,用于对红葡萄酒质量数据进行分类。除了搜索 max_depth 和 min_samples_leaf 参数的最佳值外,我们还将搜索 learning_rate 参数的最佳值,这个参数决定了每棵树在最终估计器中的贡献:
>>> from sklearn.ensemble import GradientBoostingClassifier
>>> from sklearn.model_selection import GridSearchCV
>>> gb = GradientBoostingClassifier(
... n_estimators=100, random_state=0
... )
>>> search_space = {
... 'max_depth': [4, 8], # keep trees small
... 'min_samples_leaf': [4, 6],
... 'learning_rate': [0.1, 0.5, 1]
... }
>>> gb_grid = GridSearchCV(
... gb, search_space, cv=5, scoring='f1_macro'
... ).fit(r_X_train, r_y_train)
我们通过梯度提升获得的 F1 宏观评分优于在 第九章 《Python 机器学习入门》 中使用逻辑回归得到的 0.66 分:
>>> gb_grid.score(r_X_test, r_y_test)
0.7226024272287617
无论是袋装方法还是提升方法,都给我们带来了比逻辑回归模型更好的表现;然而,我们可能会发现这些模型并不总是达成一致,且通过让模型投票再做最终预测,我们有可能进一步提升性能。
投票
在尝试不同的分类模型时,使用 Cohen's kappa 评分来衡量它们的一致性可能会很有趣。我们可以使用 sklearn.metrics 模块中的 cohen_kappa_score() 函数来实现这一点。该评分从完全不一致(-1)到完全一致(1)。我们的提升和袋装预测有很高的一致性:
>>> from sklearn.metrics import cohen_kappa_score
>>> cohen_kappa_score(
... rf_grid.predict(r_X_test), gb_grid.predict(r_X_test)
... )
0.7185929648241206
有时,我们找不到一个适用于所有数据的单一模型,因此我们可能需要找到一种方法,将各种模型的意见结合起来做出最终决策。Scikit-learn 提供了 VotingClassifier 类,用于聚合分类任务中的模型意见。我们可以选择指定投票类型,其中 hard 代表多数规则,而 soft 将预测具有最高概率总和的类别。
举个例子,让我们为每种投票类型创建一个分类器,使用本章中的三种估计器(模型)——逻辑回归、随机森林和梯度提升。由于我们将运行 fit(),我们传入每次网格搜索中得到的最佳估计器(best_estimator_)。这样可以避免不必要地重新运行每次网格搜索,也能加速模型训练:
>>> from sklearn.ensemble import VotingClassifier
>>> majority_rules = VotingClassifier(
... [('lr', lr_grid.best_estimator_),
... ('rf', rf_grid.best_estimator_),
... ('gb', gb_grid.best_estimator_)],
... voting='hard'
... ).fit(r_X_train, r_y_train)
>>> max_probabilities = VotingClassifier(
... [('lr', lr_grid.best_estimator_),
... ('rf', rf_grid.best_estimator_),
... ('gb', gb_grid.best_estimator_)],
... voting='soft'
... ).fit(r_X_train, r_y_train)
我们的majority_rules分类器要求至少有三个模型中的两个达成一致,而max_probabilities分类器则让每个模型根据其预测的概率进行投票。我们可以使用classification_report()函数来衡量它们在测试数据上的表现,该函数告诉我们,majority_rules在精确度方面稍优于max_probabilities,而这两个分类器都比我们尝试的其他模型表现得更好:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(
... r_y_test, majority_rules.predict(r_X_test)
... ))
precision recall f1-score support
0 0.92 0.95 0.93 138
1 0.59 0.45 0.51 22
accuracy 0.88 160
macro avg 0.75 0.70 0.72 160
weighted avg 0.87 0.88 0.87 160
>>> print(classification_report(
... r_y_test, max_probabilities.predict(r_X_test)
... ))
precision recall f1-score support
0 0.92 0.93 0.92 138
1 0.52 0.50 0.51 22
accuracy 0.87 160
macro avg 0.72 0.71 0.72 160
weighted avg 0.87 0.87 0.87 160
VotingClassifier类中的另一个重要选项是weights参数,它允许我们在投票时对某些估计器给予更多或更少的重视。例如,如果我们将weights=[1, 2, 2]传递给majority_rules,我们就给随机森林和梯度提升估计器的预测赋予了更多的权重。为了确定哪些模型(如果有的话)应该给予更多的权重,我们可以查看单个模型的表现和预测信心水平。
检查分类预测的信心水平
正如我们在集成方法中所看到的,当我们了解模型的优缺点时,可以采用策略来尝试提升性能。我们可能有两个模型来对某些事物进行分类,但它们很可能不会对所有事情达成一致。然而,假设我们知道其中一个在边缘情况上表现更好,而另一个在常见情况下更为准确。在这种情况下,我们很可能会希望研究一个投票分类器来提高我们的性能。那么,我们怎么知道模型在不同情况下的表现呢?
通过查看模型预测的观察值属于某一类别的概率,我们可以深入了解模型在正确预测和出错时的信心程度。我们可以运用pandas数据处理技能来快速完成这项任务。让我们来看一下我们在第九章,Python 机器学习入门中,原始white_or_red模型在预测时的信心水平:
>>> prediction_probabilities = pd.DataFrame(
... white_or_red.predict_proba(w_X_test),
... columns=['prob_white', 'prob_red']
... ).assign(
... is_red=w_y_test == 1,
... pred_white=lambda x: x.prob_white >= 0.5,
... pred_red=lambda x: np.invert(x.pred_white),
... correct=lambda x: (np.invert(x.is_red) & x.pred_white)
... | (x.is_red & x.pred_red)
... )
提示
我们可以通过使用predict_proba()方法,而不是predict(),来调整模型预测的概率阈值。这将为我们提供观察值属于每个类别的概率。然后我们可以将其与我们的自定义阈值进行比较。例如,我们可以使用 75%的阈值:white_or_red.predict_proba(w_X_test)[:,1] >= .75。
确定这一阈值的一种方法是确定我们可以接受的假阳性率,然后使用sklearn.metrics模块中的roc_curve()函数中的数据,找到导致该假阳性率的阈值。另一种方法是找到精确度-召回率曲线上的一个满意点,然后从precision_recall_curve()函数中得到该阈值。我们将在第十一章,《机器学习异常检测》中详细讨论一个例子。
让我们使用 seaborn 绘制一个图,展示模型正确时与错误时预测概率的分布。displot() 函数使得绘制 核密度估计 (KDE) 并叠加在直方图上变得非常简单。在这里,我们还将添加一个 地毯图,它显示了每个预测的具体位置:
>>> g = sns.displot(
... data=prediction_probabilities, x='prob_red',
... rug=True, kde=True, bins=20, col='correct',
... facet_kws={'sharey': True}
... )
>>> g.set_axis_labels('probability wine is red', None)
>>> plt.suptitle('Prediction Confidence', y=1.05)
正确预测的 KDE 是双峰的,峰值分别接近 0 和接近 1,这意味着模型在正确时非常有信心,而由于它大多数时候是正确的,所以总体上它是非常自信的。正确预测的 KDE 在 0 处的峰值远高于在 1 处的峰值,因为数据中白葡萄酒远多于红葡萄酒。请注意,KDE 显示的概率可能小于零或大于一。因此,我们添加了直方图来确认我们看到的形状是有意义的。正确预测的直方图在分布的中间部分没有多少数据,因此我们加入了地毯图,以更好地观察哪些概率被预测出来。错误的预测数据点不多,但似乎分布很广,因为当模型预测错误时,错得很离谱:

图 10.12 – 模型正确与错误时的预测置信度
这个结果告诉我们,可能需要研究那些被错误分类的酒的化学属性。它们可能是异常值,因此才会欺骗模型。我们可以修改第九章中 Python 机器学习入门 部分的 探索性数据分析 章节中的酒类箱线图,看看是否有什么特别之处(图 9.6)。
首先,我们隔离错误分类酒的化学属性:
>>> incorrect = w_X_test.assign(is_red=w_y_test).iloc[
... prediction_probabilities.query('not correct').index
... ]
接着,我们在 Axes 对象上添加一些 scatter() 调用,将这些酒标记在之前的箱线图上:
>>> import math
>>> chemical_properties = [col for col in wine.columns
... if col not in ['quality', 'kind']]
>>> melted = \
... wine.drop(columns='quality').melt(id_vars=['kind'])
>>> fig, axes = plt.subplots(
... math.ceil(len(chemical_properties) / 4), 4,
... figsize=(15, 10)
... )
>>> axes = axes.flatten()
>>> for prop, ax in zip(chemical_properties, axes):
... sns.boxplot(
... data=melted[melted.variable.isin([prop])],
... x='variable', y='value', hue='kind', ax=ax,
... palette={'white': 'lightyellow', 'red': 'orchid'},
... saturation=0.5, fliersize=2
... ).set_xlabel('')
... for _, wrong in incorrect.iterrows():
... # _ is convention for collecting info we won't use
... x_coord = -0.2 if not wrong['is_red'] else 0.2
... ax.scatter(
... x_coord, wrong[prop], marker='x',
... color='red', s=50
... )
>>> for ax in axes[len(chemical_properties):]:
... ax.remove()
>>> plt.suptitle(
... 'Comparing Chemical Properties of Red and White Wines'
... '\n(classification errors are red x\'s)'
... )
>>> plt.tight_layout() # clean up layout
这导致每个被错误分类的酒都被标记为红色 X。在每个子图中,左边箱线图上的点是白葡萄酒,右边箱线图上的点是红葡萄酒。似乎其中一些可能是因为某些特征的异常值—比如高残糖或二氧化硫的红葡萄酒,以及高挥发酸的白葡萄酒:

图 10.13 – 检查错误预测是否为异常值
尽管数据中白葡萄酒远多于红葡萄酒,我们的模型仍然能够很好地区分它们。这并非总是如此。有时,为了提高性能,我们需要处理类别不平衡问题。
处理类别不平衡
当我们面临数据中的类别不平衡问题时,可能希望在构建模型之前尝试平衡训练数据。为此,我们可以使用以下其中一种不平衡抽样技术:
-
对少数类进行上采样。
-
对多数类进行下采样。
在上采样的情况下,我们从少数类中挑选出更多的样本,以便接近多数类的样本数量;这可能涉及到自助法(bootstrapping)或生成与现有数据中值相似的新数据(使用机器学习算法,如最近邻)。另一方面,下采样则通过减少从多数类中采样的数量,来减少整体数据量。是否使用上采样或下采样将取决于我们最初的数据量,有时还需要考虑计算成本。实际上,在尝试使用任何这些方法之前,我们应该先尝试在类别不平衡的情况下构建模型。重要的是不要过早进行优化;更何况,通过先构建模型,我们可以将我们的不平衡抽样尝试与其作为基线进行比较。
重要提示
如果我们数据中的少数类不能真正代表整体人群的全貌,可能会出现巨大的性能问题。因此,最初收集数据的方法应该是我们熟知的,并在进行建模之前经过仔细评估。如果我们不小心,可能会轻易构建出无法对新数据进行泛化的模型,无论我们如何处理类别不平衡问题。
在我们探索任何不平衡抽样技术之前,让我们使用k-最近邻(k-NN)分类器创建一个基线模型,它会根据数据在 n 维空间中 k 个最近邻的类别来对观察结果进行分类(我们的红酒质量数据是 11 维的)。为了便于比较,本节中的所有模型将使用相同数量的邻居;然而,采样技术可能会导致不同的值表现更好。我们将使用 5 个邻居:
>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn = KNeighborsClassifier(n_neighbors=5).fit(
... r_X_train, r_y_train
... )
>>> knn_preds = knn.predict(r_X_test)
我们的 k-NN 模型训练速度很快,因为它是一个%%timeit魔法命令,用于估算训练所需的平均时间。请注意,这将多次训练模型,因此对于计算密集型模型来说,这可能不是最好的计时策略:
>>> %%timeit
>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn = KNeighborsClassifier(n_neighbors=5).fit(
... r_X_train, r_y_train
... )
3.24 ms ± 599 µs per loop
(mean ± std. dev. of 7 runs, 100 loops each)
让我们将这个结果与训练支持向量机(SVM)进行比较,SVM 将数据投影到更高维度,以寻找能够分隔类别的超平面。超平面是 n 维空间中平面的等价物,就像平面是二维空间中直线的等价物一样。SVM 通常对异常值具有鲁棒性,并且可以建模非线性决策边界;然而,SVM 的训练速度很慢,因此这将是一个很好的比较:
>>> %%timeit
>>> from sklearn.svm import SVC
>>> svc = SVC(gamma='auto').fit(r_X_train, r_y_train)
153 ms ± 6.7 ms per loop
(mean ± std. dev. of 7 runs, 1 loop each)
现在我们有了基线模型,并且对它的工作方式有了了解,让我们看看基线 k-NN 模型的表现:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(r_y_test, knn_preds))
precision recall f1-score support
0 0.91 0.93 0.92 138
1 0.50 0.41 0.45 22
accuracy 0.86 160
macro avg 0.70 0.67 0.69 160
weighted avg 0.85 0.86 0.86 160
使用这个性能基准,我们准备尝试不平衡采样。我们将使用由scikit-learn社区提供的imblearn包。它提供了使用多种策略进行过采样和欠采样的实现,并且与scikit-learn一样容易使用,因为它们遵循相同的 API 约定。相关文档可以在imbalanced-learn.readthedocs.io/en/stable/api.html找到。
欠采样
正如我们之前提到的,欠采样将减少可用于训练模型的数据量。这意味着只有在我们拥有足够的数据,并且可以接受丢弃部分数据的情况下,才应尝试欠采样。既然我们本就没有太多数据,让我们看看红酒质量数据会发生什么。
我们将使用imblearn中的RandomUnderSampler类对训练集中的低质量红酒进行随机欠采样:
>>> from imblearn.under_sampling import RandomUnderSampler
>>> X_train_undersampled, y_train_undersampled = \
... RandomUnderSampler(random_state=0)\
... .fit_resample(r_X_train, r_y_train)
我们将训练数据中约 14%的高质量红酒提高到 50%;然而,请注意,这一变化是以 1,049 个训练样本为代价的(这超过了我们训练数据的一半):
# before
>>> r_y_train.value_counts()
0 1244
1 195
Name: high_quality, dtype: int64
# after
>>> pd.Series(y_train_undersampled).value_counts().sort_index()
0 195
1 195
dtype: int64
使用欠采样数据拟合模型与之前没有什么不同:
>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn_undersampled = KNeighborsClassifier(n_neighbors=5)\
... .fit(X_train_undersampled, y_train_undersampled)
>>> knn_undersampled_preds = knn_undersampled.predict(r_X_test)
通过分类报告,我们可以看到欠采样绝对不是一种改进——我们几乎没有足够的数据用于这个模型:
>>> from sklearn.metrics import classification_report
>>> print(
... classification_report(r_y_test, knn_undersampled_preds)
... )
precision recall f1-score support
0 0.93 0.65 0.77 138
1 0.24 0.68 0.35 22
accuracy 0.66 160
macro avg 0.58 0.67 0.56 160
weighted avg 0.83 0.66 0.71 160
在数据本就有限的情况下,欠采样显然不可行。在这里,我们失去了超过一半原本已经很少的数据。模型需要足够的数据来进行学习,因此接下来我们尝试对少数类进行过采样。
过采样
很明显,在数据集较小的情况下,欠采样不会带来好处。相反,我们可以尝试对少数类(在这种情况下是高质量的红酒)进行过采样。我们不打算使用RandomOverSampler类进行随机过采样,而是将使用合成少数类过采样技术(SMOTE)通过 k-NN 算法生成与高质量红酒相似的新(合成)红酒。这样做的前提是假设我们收集到的红酒化学性质数据确实会影响红酒的质量评分。
重要提示
imblearn中的 SMOTE 实现来源于这篇论文:
N. V. Chawla, K. W. Bowyer, L. O.Hall, W. P. Kegelmeyer, SMOTE: 合成少数类过采样技术,人工智能研究期刊,321-357,2002,可在arxiv.org/pdf/1106.1813.pdf找到。
让我们使用 SMOTE 和五个最近邻来对训练数据中的高质量红酒进行过采样:
>>> from imblearn.over_sampling import SMOTE
>>> X_train_oversampled, y_train_oversampled = SMOTE(
... k_neighbors=5, random_state=0
... ).fit_resample(r_X_train, r_y_train)
由于我们进行了过采样,我们将获得比之前更多的数据,增加了 1,049 个高质量红酒样本:
# before
>>> r_y_train.value_counts()
0 1244
1 195
Name: high_quality, dtype: int64
# after
>>> pd.Series(y_train_oversampled).value_counts().sort_index()
0 1244
1 1244
dtype: int64
再次强调,我们将使用过采样数据来拟合一个 k-NN 模型:
>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn_oversampled = KNeighborsClassifier(n_neighbors=5)\
... .fit(X_train_oversampled, y_train_oversampled)
>>> knn_oversampled_preds = knn_oversampled.predict(r_X_test)
过采样的表现明显优于欠采样,但除非我们希望最大化召回率,否则最好还是坚持使用原先的 k-NN 策略:
>>> from sklearn.metrics import classification_report
>>> print(
... classification_report(r_y_test, knn_oversampled_preds)
... )
precision recall f1-score support
0 0.96 0.78 0.86 138
1 0.37 0.82 0.51 22
accuracy 0.78 160
macro avg 0.67 0.80 0.68 160
weighted avg 0.88 0.78 0.81 160
由于 SMOTE 生成的是合成数据,我们必须仔细考虑这可能对模型带来的副作用。如果我们不能假设某一类的所有值都能代表整个群体的全面特征,并且这种特征不会随时间改变,那么我们不能期望 SMOTE 会有效。
正则化
在进行回归分析时,我们可能会向回归方程中添加一个惩罚项,以减少过拟合,方法是惩罚模型在系数选择上做出的某些决策,这称为正则化。我们希望找到能够最小化这个惩罚项的系数。其核心思想是将那些对减少模型误差贡献不大的特征的系数收缩到零。常见的正则化技术有岭回归、LASSO(最小绝对收缩和选择算子)回归和弹性网络回归,后者结合了 LASSO 和岭回归惩罚项。需要注意的是,由于这些技术依赖于系数的大小,因此数据应在使用这些方法前进行缩放。
岭回归,也称为 L2 正则化,通过将系数的平方和加到代价函数中(回归在拟合时要最小化的目标),来惩罚高系数(
),具体表现为以下的惩罚项:

这个惩罚项也会乘以 λ(lambda),它表示惩罚的大小。当 λ 为零时,我们得到的是普通最小二乘回归,如之前所述。
重要提示
还记得 LogisticRegression 类中的 C 参数吗?默认情况下,LogisticRegression 类将使用 L2 惩罚项,其中 C 是 1/λ。不过,它也支持 L1 惩罚,但仅在某些求解器中可用。
LASSO 回归,也称为 L1 正则化,通过将系数的绝对值之和加到代价函数中,将系数压缩为零。与 L2 正则化相比,这种方法更具鲁棒性,因为它对极端值的敏感性较低:

由于 LASSO 回归将某些特征的系数压缩为零(即这些特征不会对模型产生贡献),因此它被认为执行了特征选择。
重要提示
L1 和 L2 惩罚也被称为L1 和 L2 范数(对一个向量的数学变换,使其处于区间 0, ∞)内),分别表示为 
Scikit-learn 实现了岭回归、LASSO 回归和弹性网回归,分别使用Ridge、Lasso和ElasticNet类,它们的使用方式与LinearRegression类相同。每种方法都有一个CV版本(RidgeCV、LassoCV和ElasticNetCV),具备内置的交叉验证功能。在使用这些模型的所有默认设置时,我们发现 LASSO 在使用行星数据预测地球年长度时表现最佳:
>>> from sklearn.linear_model import Ridge, Lasso, ElasticNet
>>> ridge, lasso, elastic = Ridge(), Lasso(), ElasticNet()
>>> for model in [ridge, lasso, elastic]:
... model.fit(pl_X_train, pl_y_train)
... print(
... f'{model.__class__.__name__}: ' # get model name
... f'{model.score(pl_X_test, pl_y_test):.4}'
... )
Ridge: 0.9206
Lasso: 0.9208
ElasticNet: 0.9047
请注意,这些scikit-learn类具有一个alpha参数,它与前面方程中的λ(而不是α)对应。对于ElasticNet,方程中的α与l1_ratio参数对应,默认值为 50%的 LASSO。在实际应用中,这两个超参数都是通过交叉验证确定的。
总结
在本章中,我们回顾了可以用来提升模型性能的各种技术。我们学习了如何使用网格搜索在搜索空间中找到最佳超参数,以及如何使用GridSearchCV根据我们选择的评分指标来调整模型。这意味着我们不必接受模型score()方法中的默认设置,而可以根据我们的需求进行自定义。
在特征工程的讨论中,我们学习了如何使用 PCA 和特征选择等技术来降低数据的维度。我们看到了如何使用PolynomialFeatures类为具有分类和数值特征的模型添加交互项。然后,我们学习了如何使用FeatureUnion类将转化后的特征添加到我们的训练数据中。此外,我们还了解了决策树如何通过特征重要性帮助我们理解数据中哪些特征对分类或回归任务的贡献最大。这帮助我们看到了二氧化硫和氯化物在化学层面区分红葡萄酒和白葡萄酒中的重要性,以及行星半长轴在确定其周期中的重要性。
随后,我们研究了随机森林、梯度提升和投票分类器,讨论了集成方法以及它们如何通过装袋、提升和投票策略解决偏差-方差权衡问题。我们还了解了如何使用 Cohen's kappa 评分衡量分类器之间的协议一致性。这样我们就能审视我们white_or_red葡萄酒分类器在正确和错误预测中的信心。一旦我们了解了模型表现的细节,就可以尝试通过适当的集成方法来改进模型,以发挥其优势并减少其弱点。
然后,我们学习了如何使用imblearn包在面对类别不平衡时实现过采样和欠采样策略。我们尝试使用这种方法来提高预测红酒质量评分的能力。在这个例子中,我们接触了 k-NN 算法以及处理小数据集建模时的问题。最后,我们学习了如何使用正则化来惩罚高系数,并通过岭回归(L2 范数)、LASSO(L1 范数)和弹性网惩罚来减少回归中的过拟合;记住,LASSO 通常作为特征选择的方法,因为它会将系数压缩为零。
在下一章中,我们将重新审视模拟的登录尝试数据,并使用机器学习来检测异常。我们还将看到如何在实践中应用无监督学习和监督学习。
练习
完成以下练习,练习本章中涵盖的技能。务必查阅附录中的机器学习工作流部分,以便回顾构建模型的过程:
-
使用弹性网线性回归预测恒星温度,如下所示:
a) 使用
data/stars.csv文件,构建一个流水线,先使用MinMaxScaler对象对数据进行归一化,然后使用所有数字列进行弹性网线性回归,预测恒星的温度。b) 在流水线中进行网格搜索,以找到
alpha、l1_ratio和fit_intercept在你选择的搜索空间中的最佳值。c) 在 75%的初始数据上训练模型。
d) 计算模型的 R2 值。
e) 查找每个回归器的系数和截距。
f) 使用
ml_utils.regression模块中的plot_residuals()函数可视化残差。 -
使用支持向量机和特征联合执行白葡萄酒质量的多类分类,如下所示:
a) 使用
data/winequality-white.csv文件,构建一个流水线来标准化数据,然后在sklearn.feature_selection模块中选择一个你喜欢的特征选择方法,接着创建交互项和特征选择方法的特征联合,并使用支持向量机(SVC类)。b) 在 85%的数据上运行网格搜索,找到
include_bias参数(PolynomialFeatures)和C参数(SVC)在你选择的搜索空间中的最佳值,scoring='f1_macro'。c) 查看你模型的分类报告。
d) 使用
ml_utils.classification模块中的confusion_matrix_visual()函数创建混淆矩阵。e) 使用
ml_utils.classification模块中的plot_multiclass_pr_curve()函数绘制多类数据的精度-召回曲线。 -
使用 k-NN 和过采样执行白葡萄酒质量的多类分类,如下所示:
a) 使用
data/winequality-white.csv文件,创建一个包含 85%数据的训练集和测试集。按照quality进行分层。b) 使用
imblearn中的RandomOverSampler类对少数类质量分数进行过采样。c) 构建一个管道来标准化数据并运行 k-NN。
d) 在你的管道上使用过采样的数据进行网格搜索,选择一个搜索空间,以找到 k-NN 的
n_neighbors参数的最佳值,并使用scoring='f1_macro'。e) 查看你的模型的分类报告。
f) 使用
ml_utils.classification模块中的confusion_matrix_visual()函数创建混淆矩阵。g) 使用
ml_utils.classification模块中的plot_multiclass_pr_curve()函数为多类数据绘制精确度-召回率曲线。 -
葡萄酒类型(红葡萄酒或白葡萄酒)能否帮助确定质量分数?
a) 使用
data/winequality-white.csv和data/winequality-red.csv文件,创建一个包含连接数据的数据框,并添加一个列,指示数据属于哪种葡萄酒类型(红葡萄酒或白葡萄酒)。b) 创建一个测试集和训练集,其中 75%的数据用于训练集。按照
quality进行分层抽样。c) 使用
ColumnTransformer对象构建一个管道,对数值数据进行标准化,同时对葡萄酒类型列进行独热编码(类似于is_red和is_white,每个列包含二值),然后训练一个随机森林模型。d) 在你的管道上运行网格搜索,选择一个搜索空间,以找到随机森林的
max_depth参数的最佳值,并使用scoring='f1_macro'。e) 查看随机森林的特征重要性。
f) 查看你的模型的分类报告。
g) 使用
ml_utils.classification模块中的plot_multiclass_roc()函数为多类数据绘制 ROC 曲线。h) 使用
ml_utils.classification模块中的confusion_matrix_visual()函数创建混淆矩阵。 -
创建一个多类分类器,通过以下步骤使用多数规则投票来预测葡萄酒质量:
a) 使用
data/winequality-white.csv和data/winequality-red.csv文件,创建一个包含连接数据的数据框,并添加一个列,指示数据属于哪种葡萄酒类型(红葡萄酒或白葡萄酒)。b) 创建一个测试集和训练集,其中 75%的数据用于训练集。按照
quality进行分层抽样。c) 为以下每个模型构建一个管道:随机森林、梯度提升、k-NN、逻辑回归和朴素贝叶斯(
GaussianNB)。该管道应使用ColumnTransformer对象对数值数据进行标准化,同时对葡萄酒类型列进行独热编码(类似于is_red和is_white,每个列包含二值),然后构建模型。请注意,我们将在第十一章中讨论朴素贝叶斯,机器学习异常检测。d) 在每个管道上运行网格搜索,除了朴素贝叶斯(对其运行
fit()即可),在你选择的搜索空间上使用scoring='f1_macro'来查找以下参数的最佳值:i)
max_depthii)
max_depthiii)
n_neighborsiv)
C使用
scikit-learn中的metrics模块里的cohen_kappa_score()函数找出每对模型之间的一致性水平。请注意,您可以通过 Python 标准库中的itertools模块的combinations()函数轻松获取所有的组合。构建一个投票分类器,使用五个模型并基于多数规则(
voting='hard')进行构建,同时将朴素贝叶斯模型的权重设置为其他模型的一半。查看模型的分类报告。
使用来自
ml_utils.classification模块的confusion_matrix_visual()函数创建混淆矩阵。
进一步阅读
查阅以下资源以获取更多关于本章涉及主题的信息:
-
梯度提升算法在机器学习中的温和入门:
machinelearningmastery.com/gentle-introduction-gradient-boosting-algorithm-machine-learning/ -
Kaggler 实践中的模型堆叠指南:
datasciblog.github.io/2016/12/27/a-kagglers-guide-to-model-stacking-in-practice/ -
选择合适的估算器:
scikit-learn.org/stable/tutorial/machine_learning_map/index.html -
交叉验证:评估估算器的性能:
scikit-learn.org/stable/modules/cross_validation.html -
决策树在机器学习中的应用:
towardsdatascience.com/decision-trees-in-machine-learning-641b9c4e8052 -
集成学习提升机器学习结果:
blog.statsbot.co/ensemble-learning-d1dcd548e936 -
Divya Susarla 和 Sinan Ozdemir 的特征工程简易教程:
www.packtpub.com/big-data-and-business-intelligence/feature-engineering-made-easy -
特征选择:
scikit-learn.org/stable/modules/feature_selection.html#feature-selection -
梯度提升与随机森林:
medium.com/@aravanshad/gradient-boosting-versus-random-forest-cfa3fa8f0d80 -
机器学习中的超参数优化:
www.datacamp.com/community/tutorials/parameter-optimization-machine-learning-models -
L1 范数与 L2 范数:
www.kaggle.com/residentmario/l1-norms-versus-l2-norms -
现代机器学习算法:优势与劣势:
elitedatascience.com/machine-learning-algorithms -
机器学习中的正则化:
towardsdatascience.com/regularization-in-machine-learning-76441ddcf99a -
由 Jerome H. Friedman、Robert Tibshirani 和 Trevor Hastie 撰写的统计学习要素:
web.stanford.edu/~hastie/ElemStatLearn/
第十二章:第十一章:机器学习异常检测
对于我们的最终应用章节,我们将重新审视登录尝试中的异常检测。假设我们为一家公司工作,该公司在 2018 年初推出了其 Web 应用程序。自推出以来,此 Web 应用程序一直收集所有登录尝试的日志事件。我们知道尝试是从哪个 IP 地址发起的,尝试的结果是什么,何时进行的以及输入了哪个用户名。我们不知道的是尝试是由我们的有效用户之一还是由一个不良方尝试。
我们的公司正在扩展,并且由于数据泄露似乎每天都在新闻中,已创建了一个信息安全部门来监控流量。CEO 看到我们在第八章中识别黑客的基于规则的方法,基于规则的异常检测,并对我们的举措感到好奇,但希望我们超越使用规则和阈值来执行如此重要的任务。我们被委托开发一个机器学习模型,用于 Web 应用程序登录尝试的异常检测。
由于这将需要大量数据,我们已获得从 2018 年 1 月 1 日至 2018 年 12 月 31 日的所有日志的访问权限。此外,新成立的安全运营中心(SOC)现在将审核所有这些流量,并根据他们的调查指示哪些时间段包含恶意用户。由于 SOC 成员是专业领域的专家,这些数据对我们来说将非常宝贵。我们将能够利用他们提供的标记数据构建未来使用的监督学习模型;然而,他们需要一些时间来筛选所有的流量,因此在他们为我们准备好之前,我们应该开始一些无监督学习。
在本章中,我们将涵盖以下主题:
-
探索模拟登录尝试数据
-
利用无监督方法进行异常检测
-
实施监督异常检测
-
结合在线学习的反馈循环
章节材料
此章节的材料可在github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_11找到。在本章中,我们将重新审视尝试登录数据;但是,simulate.py脚本已更新,以允许附加命令行参数。这次我们不会运行模拟,但请确保查看脚本并查看生成数据文件和为本章创建数据库所遵循的过程,详见0-simulating_the_data.ipynb笔记本。user_data/目录包含此模拟使用的文件,但在本章中我们不会直接使用它们。
本章将使用的模拟日志数据可以在logs/目录下找到。logs_2018.csv和hackers_2018.csv文件分别是 2018 年所有模拟中的登录尝试日志和黑客活动记录。带有hackers前缀的文件被视为我们将从 SOC 接收到的标记数据,因此我们一开始假设没有这些数据。文件名中包含2019而非2018的文件是模拟 2019 年第一季度的数据,而不是整年的数据。此外,CSV 文件已写入logs.db SQLite 数据库。logs表包含来自logs_2018.csv和logs_2019.csv的数据;attacks表包含来自hackers_2018.csv和hackers_2019.csv的数据。
仿真参数每月都不同,在大多数月份中,黑客会针对每个尝试登录的用户名更换 IP 地址。这将使我们在第八章中的方法,基于规则的异常检测,变得无效,因为我们曾试图寻找那些有大量尝试和高失败率的 IP 地址。如果黑客现在更换他们的 IP 地址,我们就不会有与他们关联的多次尝试。因此,我们将无法用这种策略标记他们,所以我们必须找到另一种方法来应对:

图 11.1 – 仿真参数
重要提示
merge_logs.py文件包含合并各个仿真日志的 Python 代码,run_simulations.sh包含用于运行整个过程的 Bash 脚本。这些文件提供完整性,但我们不需要使用它们(也不需要关心 Bash)。
本章的工作流程被拆分成几个笔记本,每个笔记本前面都有一个数字,表示它们的顺序。在获得标记数据之前,我们将在1-EDA_unlabeled_data.ipynb笔记本中进行一些 EDA 分析,然后转到2-unsupervised_anomaly_detection.ipynb笔记本,尝试一些无监督异常检测方法。获得标记数据后,我们将在3-EDA_labeled_data.ipynb笔记本中进行一些额外的 EDA 分析,然后转到4-supervised_anomaly_detection.ipynb笔记本进行有监督方法的实验。最后,我们将使用5-online_learning.ipynb笔记本来讨论在线学习。像往常一样,文本中会指示何时切换笔记本。
探索模拟登录尝试数据
我们还没有标签化的数据,但我们仍然可以检查数据,看看是否有一些显著的特点。这些数据与第八章中的数据不同,基于规则的异常检测。在这个模拟中,黑客更加聪明——他们不总是尝试很多用户,也不会每次都使用相同的 IP 地址。我们来看看是否可以通过在1-EDA_unlabeled_data.ipynb笔记本中进行一些 EDA,找出一些有助于异常检测的特征。
像往常一样,我们从导入开始。这些导入在所有笔记本中都是相同的,所以只在这一部分中重复:
>>> %matplotlib inline
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import pandas as pd
>>> import seaborn as sns
接下来,我们从 SQLite 数据库中的logs表中读取 2018 年的日志:
>>> import sqlite3
>>> with sqlite3.connect('logs/logs.db') as conn:
... logs_2018 = pd.read_sql(
... """
... SELECT *
... FROM logs
... WHERE
... datetime BETWEEN "2018-01-01" AND "2019-01-01";
...""",
... conn, parse_dates=['datetime'],
... index_col='datetime'
... )
提示
如果我们正在使用的环境中已安装 SQLAlchemy 包(如我们所用环境),我们可以选择通过pd.read_sql()提供数据库,而无需使用with语句。在我们的案例中,这将是sqlite:///logs/logs.db,其中sqlite是方言,logs/logs.db是文件的路径。请注意,路径中有三个连续的/字符。
我们的数据如下所示:

图 11.2 - 2018 年登录尝试日志
我们的数据类型将与第八章中的数据类型相同,基于规则的异常检测,唯一的例外是success列。SQLite 不支持布尔值,因此这个列在写入数据到数据库时被转换为其原始形式的二进制表示(存储为整数):
>>> logs_2018.dtypes
source_ip object
username object
success int64
failure_reason object
dtype: object
重要提示
我们在这里使用 SQLite 数据库,因为 Python 标准库已经提供了连接工具(sqlite3)。如果我们想使用其他类型的数据库,如 MySQL 或 PostgreSQL,就需要安装 SQLAlchemy(并且可能需要安装其他包,具体取决于数据库方言)。更多信息可以参考pandas.pydata.org/pandas-docs/stable/user_guide/io.html#sql-queries。有关 SQLAlchemy 的教程,请查看本章末尾的进一步阅读部分。
使用info()方法,我们可以看到failure_reason是唯一一个包含空值的列。当尝试成功时,这个字段为空。在构建模型时,我们还应该关注数据的内存使用情况。某些模型可能需要增加数据的维度,这可能会迅速导致内存占用过大:
>>> logs_2018.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 38700 entries,
2018-01-01 00:05:32.988414 to 2018-12-31 23:29:42.482166
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 source_ip 38700 non-null object
1 username 38700 non-null object
2 success 38700 non-null int64
3 failure_reason 11368 non-null object
dtypes: int64(1), object(3)
memory usage: 1.5+ MB
运行 describe() 方法告诉我们,失败的最常见原因是提供了错误的密码。我们还可以看到,尝试的独立用户名数量(1,797)远超过我们的用户基数(133),这表明存在可疑活动。最多尝试的 IP 地址进行了 314 次尝试,但由于这不足以每天一次(记住,我们是在看 2018 年的全年数据),所以我们无法做出任何假设:
>>> logs_2018.describe(include='all')
source_ip username success failure_reason
count 38700 38700 38700.000000 11368
unique 4956 1797 NaN 3
top 168.123.156.81 wlopez NaN error_wrong_password
freq 314 387 NaN 6646
mean NaN NaN 0.706253 NaN
std NaN NaN 0.455483 NaN
min NaN NaN 0.000000 NaN
25% NaN NaN 0.000000 NaN
50% NaN NaN 1.000000 NaN
75% NaN NaN 1.000000 NaN
max NaN NaN 1.000000 NaN
我们可以查看每个 IP 地址的尝试登录的独立用户名,正如在 第八章《基于规则的异常检测》中所示,这显示大多数 IP 地址只有几个用户名,但至少有一个包含了很多:
>>> logs_2018.groupby('source_ip')\
... .agg(dict(username='nunique'))\
... .username.describe()
count 4956.000000
mean 1.146287
std 1.916782
min 1.000000
25% 1.000000
50% 1.000000
75% 1.000000
max 129.000000
Name: username, dtype: float64
让我们计算每个 IP 地址的指标:
>>> pivot = logs_2018.pivot_table(
... values='success', index='source_ip',
... columns=logs_2018.failure_reason.fillna('success'),
... aggfunc='count', fill_value=0
... )
>>> pivot.insert(0, 'attempts', pivot.sum(axis=1))
>>> pivot = pivot\
... .sort_values('attempts', ascending=False)\
... .assign(
... success_rate=lambda x: x.success / x.attempts,
... error_rate=lambda x: 1 - x.success_rate
... )
>>> pivot.head()
尝试次数最多的前五个 IP 地址似乎是有效用户,因为它们的成功率相对较高:

图 11.3 – 每个 IP 地址的指标
让我们使用这个数据框绘制每个 IP 地址的成功与尝试次数,以查看是否存在我们可以利用的模式来区分有效活动与恶意活动:
>>> pivot.plot(
... kind='scatter', x='attempts', y='success',
... title='successes vs. attempts by IP address',
... alpha=0.25
... )
底部似乎有一些不属于该组的点,但请注意坐标轴的刻度并没有完全对齐。大多数点都沿着一条线分布,这条线的尝试与成功的比例稍微低于 1:1。回想一下,本章的模拟比我们在 第八章《基于规则的异常检测》中使用的模拟更具现实性;因此,如果我们将 图 8.11 与这个图进行比较,可以观察到在这里将有效活动与恶意活动分开要困难得多:

图 11.4 – 每个 IP 地址的成功与尝试次数散点图
记住,这是一个二分类问题,我们希望找到一种方法来区分有效用户和攻击者的登录活动。我们希望构建一个模型,学习一些决策边界,将有效用户与攻击者分开。由于有效用户更有可能正确输入密码,尝试与成功之间的关系将更接近 1:1,相对于攻击者。因此,我们可以想象分隔边界看起来像这样:

图 11.5 – 可能的决策边界
现在,问题是,这两组中哪个是攻击者?如果更多的 IP 地址是攻击者(因为他们为每个尝试的用户名使用不同的 IP 地址),那么有效用户将被视为异常值,而攻击者将被视为“内点”,并且通过箱型图来看。我们来创建一个看看是否真的是这样:
>>> pivot[['attempts', 'success']].plot(
... kind='box', subplots=True, figsize=(10, 3),
... title='stats per IP address'
... )
确实,情况好像是这样。我们的有效用户成功的次数比攻击者更多,因为他们只使用了 1-3 个不同的 IP 地址:

图 11.6 – 使用每个 IP 地址的度量寻找离群值
显然,像这样查看数据并没有太大帮助,所以让我们看看能否通过更细粒度的方式来帮助我们。让我们可视化 2018 年 1 月每分钟内的登录尝试分布、用户名数量和每个 IP 地址的失败次数:
>>> from matplotlib.ticker import MultipleLocator
>>> ax = logs_2018.loc['2018-01'].assign(
... failures=lambda x: 1 - x.success
... ).groupby('source_ip').resample('1min').agg({
... 'username': 'nunique',
... 'success': 'sum',
... 'failures': 'sum'
... }).assign(
... attempts=lambda x: x.success + x.failures
... ).dropna().query('attempts > 0').reset_index().plot(
... y=['attempts', 'username', 'failures'], kind='hist',
... subplots=True, layout=(1, 3), figsize=(20, 3),
... title='January 2018 distributions of minutely stats'
... 'by IP address'
... )
>>> for axes in ax.flatten():
... axes.xaxis.set_major_locator(MultipleLocator(1))
看起来大多数 IP 地址只有一个用户名关联;不过,也有一些 IP 地址在尝试时出现了多个失败:

图 11.7 – 每分钟每个 IP 地址的度量分布
也许将唯一用户名和失败次数结合起来,能够提供一些不依赖于 IP 地址恒定性的特征。让我们可视化 2018 年每分钟内带有失败的用户名数量:
>>> logs_2018.loc['2018'].assign(
... failures=lambda x: 1 - x.success
... ).query('failures > 0').resample('1min').agg(
... {'username': 'nunique', 'failures': 'sum'}
... ).dropna().rename(
... columns={'username': 'usernames_with_failures'}
... ).usernames_with_failures.plot(
... title='usernames with failures per minute in 2018',
... figsize=(15, 3)
... ).set_ylabel('usernames with failures')
这看起来很有前景;我们肯定应该关注带有失败的用户名的峰值。这可能是我们网站的问题,或者是恶意攻击:

图 11.8 – 随时间变化的带有失败的用户名
在对我们将要处理的数据进行了彻底探索后,我们已经有了一些思路,知道在构建机器学习模型时可以使用哪些特征。由于我们还没有标签数据,接下来让我们尝试一些无监督模型。
利用无监督的异常检测方法
如果黑客与我们的有效用户显著不同,且容易被识别,使用无监督方法可能会非常有效。在我们获得标签数据之前,或者如果标签数据很难收集或不保证能代表我们希望标记的完整范围时,这是一个很好的起点。请注意,在大多数情况下,我们没有标签数据,因此熟悉一些无监督方法至关重要。
在我们初步的数据探索分析(EDA)中,我们将每分钟内尝试登录失败的用户名数量作为异常检测的特征。接下来,我们将测试一些无监督的异常检测算法,以这个特征为出发点。Scikit-learn 提供了几种这样的算法。在本节中,我们将重点讨论隔离森林(isolation forest)和局部离群因子(local outlier factor);另一种方法,使用一类支持向量机(SVM),在习题部分有介绍。
在我们尝试这些方法之前,我们需要准备训练数据。由于 SOC 将首先传输 2018 年 1 月的标签数据,我们将仅使用 2018 年 1 月的逐分钟数据进行无监督模型。我们的特征将是星期几(独热编码)、一天中的小时(独热编码)以及失败用户名的数量。如果需要复习独热编码,可以参考《第九章,Python 中的机器学习入门》中的编码数据部分。
让我们转到2-unsupervised_anomaly_detection.ipynb笔记本,并编写一个实用函数,轻松获取这些数据:
>>> def get_X(log, day):
... """
... Get data we can use for the X
...
... Parameters:
... - log: The logs dataframe
... - day: A day or single value we can use as a
... datetime index slice
...
... Returns:
... A `pandas.DataFrame` object
... """
... return pd.get_dummies(
... log.loc[day].assign(
... failures=lambda x: 1 - x.success
... ).query('failures > 0').resample('1min').agg(
... {'username': 'nunique', 'failures': 'sum'}
... ).dropna().rename(
... columns={'username': 'usernames_with_failures'}
... ).assign(
... day_of_week=lambda x: x.index.dayofweek,
... hour=lambda x: x.index.hour
... ).drop(columns=['failures']),
... columns=['day_of_week', 'hour']
... )
现在,我们可以抓取 1 月的数据并将其存储在X中:
>>> X = get_X(logs_2018, '2018-01')
>>> X.columns
Index(['usernames_with_failures', 'day_of_week_0',
'day_of_week_1', 'day_of_week_2', 'day_of_week_3',
'day_of_week_4', 'day_of_week_5', 'day_of_week_6',
'hour_0', 'hour_1', ..., 'hour_22', 'hour_23'],
dtype='object')
隔离森林
隔离森林算法利用分割技术将异常值从其余数据中隔离开来,因此可以用于异常检测。从内部实现来看,它是一个随机森林,其中的分割是在随机选择的特征上进行的。选择该特征在最大值和最小值之间的一个随机值来进行分割。请注意,这个范围是基于树中该节点特征的范围,而不是起始数据的范围。
森林中的一棵树大致如下所示:

图 11.9 – 隔离森林中单棵树的示例
从森林中每棵树的顶部到包含给定点的叶节点的路径长度平均值,用于对点进行异常值或内点的评分。异常值的路径较短,因为它们通常会位于某个分割的某一侧,并且与其他点的相似性较低。相反,具有许多公共维度的点需要更多的分割来将其分开。
重要提示
更多关于此算法的信息,请访问scikit-learn.org/stable/modules/outlier_detection.html#isolation-forest。
让我们实现一个带有管道的隔离森林模型,首先对我们的数据进行标准化处理:
>>> from sklearn.ensemble import IsolationForest
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> iso_forest_pipeline = Pipeline([
... ('scale', StandardScaler()),
... ('iforest', IsolationForest(
... random_state=0, contamination=0.05
... ))
... ]).fit(X)
我们需要指定预计为异常值的数据比例(contamination),我们估计为 5%;选择这个值会有些困难,因为我们没有标签数据。有一个auto选项可以为我们确定一个值,但在这种情况下,它没有给出任何异常值,所以很明显这个值不是我们想要的。实际上,我们可以对数据进行统计分析来确定一个初始值,或者咨询领域专家。
predict()方法可用于检查每个数据点是否为异常值。scikit-learn中实现的异常检测算法通常会返回1或-1,分别表示该点为内点或异常值:
>>> isolation_forest_preds = iso_forest_pipeline.predict(X)
>>> pd.Series(np.where(
... isolation_forest_preds == -1, 'outlier', 'inlier'
... )).value_counts()
inlier 42556
outlier 2001
dtype: int64
由于我们还没有标签数据,我们稍后会回来评估;现在,让我们来看一下本章讨论的第二个无监督算法。
局部异常因子
虽然内点通常位于数据集的密集区域(这里是 32 维空间),但异常值往往位于稀疏且更为孤立的区域,附近的点较少。局部异常因子(LOF)算法会寻找这些稀疏的区域来识别异常值。它根据每个点周围密度与其最近邻居的密度比率来对所有点进行评分。被认为是正常的点,其密度与邻居相似;而那些周围点较少的点则被视为异常。
重要说明
有关该算法的更多信息,请访问scikit-learn.org/stable/modules/outlier_detection.html#local-outlier-factor。
我们再构建一个管道,不过将隔离森林换成 LOF。请注意,我们必须猜测n_neighbors参数的最佳值,因为如果没有标记数据,GridSearchCV没有任何模型评分的依据。我们使用该参数的默认值,即20:
>>> from sklearn.neighbors import LocalOutlierFactor
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> lof_pipeline = Pipeline([
... ('scale', StandardScaler()),
... ('lof', LocalOutlierFactor())
... ]).fit(X)
现在,让我们看看这次有多少个异常值。LOF 没有predict()方法,因此我们必须检查 LOF 对象的negative_outlier_factor_属性,查看我们拟合的数据点的评分:
>>> lof_preds = lof_pipeline.named_steps['lof']\
... .negative_outlier_factor_
>>> lof_preds
array([-1.33898756e+10, -1.00000000e+00, -1.00000000e+00, ...,
-1.00000000e+00, -1.00000000e+00, -1.11582297e+10])
LOF 和隔离森林之间还有另一个区别:negative_outlier_factor_属性的值不是严格的-1或1。事实上,它们可以是任何数字——看一下前一个结果中的第一个和最后一个值,你会发现它们远小于-1。这意味着我们不能像使用隔离森林时那样,使用方法来计算内点和异常值。相反,我们需要将negative_outlier_factor_属性与 LOF 模型的offset_属性进行比较,后者告诉我们在训练过程中(使用contamination参数)由 LOF 模型确定的截断值:
>>> pd.Series(np.where(
... lof_preds < lof_pipeline.named_steps['lof'].offset_,
... 'outlier', 'inlier'
... )).value_counts()
inlier 44248
outlier 309
dtype: int64
现在我们有了两个无监督模型,我们需要比较它们,看看哪个对我们的利益相关者更有益。
比较模型
与隔离森林相比,LOF 表示的异常值较少,但它们可能并不完全一致。正如我们在第十章《做出更好的预测 – 优化模型》中学到的那样,我们可以使用sklearn.metrics中的cohen_kappa_score()函数来检查它们的一致性水平:
>>> from sklearn.metrics import cohen_kappa_score
>>> is_lof_outlier = np.where(
... lof_preds < lof_pipeline.named_steps['lof'].offset_,
... 'outlier', 'inlier'
... )
>>> is_iso_outlier = np.where(
... isolation_forest_preds == -1, 'outlier', 'inlier'
... )
>>> cohen_kappa_score(is_lof_outlier, is_iso_outlier)
0.25862517997335677
它们的一致性水平较低,表明哪些数据点是异常值并不是显而易见的。然而,没有标记数据,我们真的无法判断哪个更好。我们需要与结果的使用者合作,确定哪个模型能提供最有用的数据。幸运的是,SOC 刚刚发送了 2018 年 1 月的标记数据,因此我们可以确定哪个模型更好,并让他们开始使用,直到我们准备好监督学习模型。
首先,我们将读取他们写入数据库的标记数据,位于attacks表,并添加一些列,指示攻击开始的分钟、持续时间以及攻击结束的时间:
>>> with sqlite3.connect('logs/logs.db') as conn:
... hackers_jan_2018 = pd.read_sql(
... """
... SELECT *
... FROM attacks
... WHERE start BETWEEN "2018-01-01" AND "2018-02-01";
... """, conn, parse_dates=['start', 'end']
... ).assign(
... duration=lambda x: x.end - x.start,
... start_floor=lambda x: x.start.dt.floor('min'),
... end_ceil=lambda x: x.end.dt.ceil('min')
... )
>>> hackers_jan_2018.shape
(7, 6)
请注意,SOC 只有每次攻击涉及的单一 IP 地址,因此我们不再依赖它。相反,SOC 希望我们告诉他们在何时有可疑活动,以便他们进一步调查。还请注意,尽管攻击持续时间短,但我们按分钟的数据意味着每次攻击都会触发许多警报:

Figure 11.10 – 用于评估我们模型的标记数据
使用start_floor和end_ceil列,我们可以创建一个时间范围,并检查我们标记为异常值的数据是否落在该范围内。为此,我们将使用以下函数:
>>> def get_y(datetimes, hackers, resolution='1min'):
... """
... Get data we can use for the y (whether or not a
... hacker attempted a log in during that time).
...
... Parameters:
... - datetimes: The datetimes to check for hackers
... - hackers: The dataframe indicating when the
... attacks started and stopped
... - resolution: The granularity of the datetime.
... Default is 1 minute.
...
... Returns: `pandas.Series` of Booleans.
... """
... date_ranges = hackers.apply(
... lambda x: pd.date_range(
... x.start_floor, x.end_ceil, freq=resolution
... ),
... axis=1
... )
... dates = pd.Series(dtype='object')
... for date_range in date_ranges:
... dates = pd.concat([dates, date_range.to_series()])
... return datetimes.isin(dates)
现在,让我们找出X数据中发生黑客活动的时间:
>>> is_hacker = \
... get_y(X.reset_index().datetime, hackers_jan_2018)
我们现在拥有了制作分类报告和混淆矩阵所需的一切。由于我们将频繁传入is_hacker系列,我们将制作一些部分函数,以减少打字量:
>>> from functools import partial
>>> from sklearn.metrics import classification_report
>>> from ml_utils.classification import confusion_matrix_visual
>>> report = partial(classification_report, is_hacker)
>>> conf_matrix = partial(
... confusion_matrix_visual, is_hacker,
... class_labels=[False, True]
... )
我们从分类报告开始,它们表明隔离森林在召回率方面要好得多:
>>> iso_forest_predicts_hacker = isolation_forest_preds == - 1
>>> print(report(iso_forest_predicts_hacker)) # iso. forest
precision recall f1-score support
False 1.00 0.96 0.98 44519
True 0.02 0.82 0.03 38
accuracy 0.96 44557
macro avg 0.51 0.89 0.50 44557
weighted avg 1.00 0.96 0.98 44557
>>> lof_predicts_hacker = \
... lof_preds < lof_pipeline.named_steps['lof'].offset_
>>> print(report(lof_predicts_hacker)) # LOF
precision recall f1-score support
False 1.00 0.99 1.00 44519
True 0.03 0.26 0.06 38
accuracy 0.99 44557
macro avg 0.52 0.63 0.53 44557
weighted avg 1.00 0.99 1.00 44557
为了更好地理解分类报告中的结果,让我们为我们的无监督方法创建混淆矩阵,并将它们并排放置以进行比较:
>>> fig, axes = plt.subplots(1, 2, figsize=(15, 5))
>>> conf_matrix(
... iso_forest_predicts_hacker,
... ax=axes[0], title='Isolation Forest'
... )
>>> conf_matrix(
... lof_predicts_hacker,
... ax=axes[1], title='Local Outlier Factor'
... )
隔离森林的真阳性更多,假阳性也更多,但它的假阴性较少:

Figure 11.11 – 我们的无监督模型的混淆矩阵
SOC 告知我们,假阴性比假阳性更为昂贵。然而,他们希望我们能控制假阳性,以避免因过多的错误警报而影响团队工作。这告诉我们,召回率(真阳性率(TPR))作为性能指标比精度更为重要。SOC 希望我们把目标定为至少 70%的召回率。
由于我们有一个非常大的类别不平衡,假阳性率(FPR)对我们来说并不会提供太多信息。记住,FPR 是假阳性与假阳性和真阴性之和的比率(即所有属于负类的部分)。由于攻击事件的稀有性,我们将拥有非常多的真阴性,因此 FPR 会保持在一个非常低的水平。因此,SOC 决定的次要指标是达到85%以上的精度。
隔离森林模型超出了我们的召回率目标,但精确度过低。由于我们能够获取一些标记数据,现在可以使用监督学习来找到发生可疑活动的分钟数(请注意,这并不总是适用)。让我们看看能否利用这些额外信息更精确地找到感兴趣的分钟。
实现监督学习的异常检测
SOC 已经完成了 2018 年数据的标记,因此我们应该重新审视我们的 EDA,确保我们查看每分钟失败用户名数量的计划能有效地区分数据。这部分 EDA 在3-EDA_labeled_data.ipynb笔记本中。经过一些数据清洗后,我们成功创建了以下散点图,这表明这一策略确实能够有效地区分可疑活动:

图 11.12 – 确认我们的特征能够帮助形成决策边界
在4-supervised_anomaly_detection.ipynb笔记本中,我们将创建一些监督学习模型。这次我们需要读取 2018 年的所有标记数据。请注意,读取日志的代码被省略了,因为它与上一节中的代码相同:
>>> with sqlite3.connect('logs/logs.db') as conn:
... hackers_2018 = pd.read_sql(
... """
... SELECT *
... FROM attacks
... WHERE start BETWEEN "2018-01-01" AND "2019-01-01";
... """, conn, parse_dates=['start', 'end']
... ).assign(
... duration=lambda x: x.end - x.start,
... start_floor=lambda x: x.start.dt.floor('min'),
... end_ceil=lambda x: x.end.dt.ceil('min')
... )
然而,在构建模型之前,让我们创建一个新函数,它可以同时生成X和y。get_X_y()函数将使用我们之前创建的get_X()和get_y()函数,返回X和y:
>>> def get_X_y(log, day, hackers):
... """
... Get the X, y data to build a model with.
...
... Parameters:
... - log: The logs dataframe
... - day: A day or single value we can use as a
... datetime index slice
... - hackers: The dataframe indicating when the
... attacks started and stopped
...
... Returns:
... X, y tuple where X is a `pandas.DataFrame` object
... and y is a `pandas.Series` object
... """
... X = get_X(log, day)
... y = get_y(X.reset_index().datetime, hackers)
... return X, y
现在,让我们使用 2018 年 1 月的数据创建一个训练集,并使用 2018 年 2 月的数据创建一个测试集,使用我们的新函数:
>>> X_train, y_train = \
... get_X_y(logs_2018, '2018-01', hackers_2018)
>>> X_test, y_test = \
... get_X_y(logs_2018, '2018-02', hackers_2018)
重要提示
尽管我们面临着非常大的类别不平衡问题,但我们不会直接去平衡训练集。尝试模型时避免过早优化是至关重要的。如果我们构建的模型发现受到了类别不平衡的影响,那么我们可以尝试这些技术。记住,对过采样/欠采样技术要非常小心,因为有些方法对数据做出假设,这些假设并不总是适用或现实的。以 SMOTE 为例——我们真的能指望未来的攻击者和我们数据中的攻击者相似吗?
让我们使用这些数据来构建一些监督异常检测模型。请记住,SOC 已经为我们设定了召回率(至少 70%)和精确度(85%或更高)的性能要求,因此我们将使用这些指标来评估我们的模型。
基准化
我们的第一步是构建一些基准模型,以确保我们的机器学习算法优于一些简单的模型,并且具有预测价值。我们将构建两个这样的模型:
-
一个虚拟分类器,它将根据数据中的分层来预测标签。
-
一个朴素贝叶斯模型,它将利用贝叶斯定理预测标签。
虚拟分类器
一个虚拟分类器将为我们提供一个与我们在 ROC 曲线中绘制的基准相当的模型。结果将故意很差。我们永远不会使用这个分类器来实际进行预测;而是可以用它来查看我们构建的模型是否比随机猜测策略更好。在 dummy 模块中,scikit-learn 提供了 DummyClassifier 类,正是为了这个目的。
使用 strategy 参数,我们可以指定虚拟分类器将如何做出预测。以下是一些有趣的选项:
-
uniform: 分类器每次都会猜测观察结果是否属于黑客攻击尝试。 -
most_frequent: 分类器将始终预测最频繁的标签,在我们的案例中,这将导致永远不会标记任何内容为恶意。这将达到较高的准确度,但毫无用处,因为少数类才是我们关心的类别。 -
stratified: 分类器将使用训练数据中的类别分布,并将这种比例保持在其猜测中。
让我们使用 stratified 策略构建一个虚拟分类器:
>>> from sklearn.dummy import DummyClassifier
>>> dummy_model = DummyClassifier(
... strategy='stratified', random_state=0
... ).fit(X_train, y_train)
>>> dummy_preds = dummy_model.predict(X_test)
现在我们已经有了第一个基准模型,接下来让我们衡量其性能以便进行比较。我们将同时使用 ROC 曲线和精度-召回曲线来展示类别不平衡如何让 ROC 曲线对性能过于乐观。为了减少输入,我们将再次创建一些部分:
>>> from functools import partial
>>> from sklearn.metrics import classification_report
>>> from ml_utils.classification import (
... confusion_matrix_visual, plot_pr_curve, plot_roc
... )
>>> report = partial(classification_report, y_test)
>>> roc = partial(plot_roc, y_test)
>>> pr_curve = partial(plot_pr_curve, y_test)
>>> conf_matrix = partial(
... confusion_matrix_visual, y_test,
... class_labels=[False, True]
... )
回想一下我们在 第九章 《用 Python 开始机器学习》中最初讨论的 ROC 曲线,斜线代表虚拟模型的随机猜测。如果我们的性能不比这条线好,那么我们的模型就没有预测价值。我们刚刚创建的虚拟模型相当于这条线。让我们使用子图来可视化基准 ROC 曲线、精度-召回曲线和混淆矩阵:
>>> fig, axes = plt.subplots(1, 3, figsize=(20, 5))
>>> roc(dummy_model.predict_proba(X_test)[:,1], ax=axes[0])
>>> conf_matrix(dummy_preds, ax=axes[1])
>>> pr_curve(
... dummy_model.predict_proba(X_test)[:,1], ax=axes[2]
... )
>>> plt.suptitle('Dummy Classifier with Stratified Strategy')
虚拟分类器无法标记任何攻击者。ROC 曲线(TPR 与 FPR)表明,虚拟模型没有预测价值,其 曲线下面积 (AUC) 为 0.5。请注意,精度-召回曲线下的面积几乎为零:

图 11.13 – 使用虚拟分类器进行基准测试
由于我们有非常严重的类别不平衡,分层随机猜测策略应该在少数类上表现糟糕,而在多数类上表现非常好。我们可以通过查看分类报告来观察这一点:
>>> print(report(dummy_preds))
precision recall f1-score support
False 1.00 1.00 1.00 39958
True 0.00 0.00 0.00 5
accuracy 1.00 39963
macro avg 0.50 0.50 0.50 39963
weighted avg 1.00 1.00 1.00 39963
Naive Bayes
我们的最后一个基准模型将是朴素贝叶斯分类器。在我们讨论这个模型之前,我们需要回顾几个概率学的概念。第一个是条件概率。当处理两个事件 A 和 B 时,事件 A 在事件 B 发生的条件下发生的概率叫做 条件概率,记作 P(A|B)。当事件 A 和 B 是独立的,即 B 的发生并不告诉我们 A 的发生与否,反之亦然,P(A|B) 就等于 P(A)。
条件概率被定义为联合概率,即 A 和 B 同时发生的概率(这两个事件的交集),写作 P(A ∩ B),除以 B 发生的概率(前提是这个概率不为零):

这个方程可以重新排列如下:

A ∩ B 的联合概率等同于 B ∩ A;因此,我们得到以下方程:

由此,我们可以将第一个方程改为使用条件概率而不是联合概率。这给出了贝叶斯定理:

在使用前述方程时,P(A) 被称为先验概率,即事件 A 发生的初步信念程度。在考虑事件 B 发生后,这个初步信念会被更新;这个更新后的概率表示为 P(A|B),称为后验概率。事件 A 给定时事件 B 的似然是 P(B|A)。事件 B 发生所支持的事件 A 发生的信念为:

我们来看一个例子——假设我们正在构建一个垃圾邮件过滤器,并且发现 10%的电子邮件是垃圾邮件。这 10%是我们的先验,或者P(spam)。我们想知道在邮件中包含单词free的情况下,刚收到的邮件是垃圾邮件的概率——我们想要找的是P(spam|free)。为了找到这个概率,我们需要知道在邮件是垃圾邮件的情况下,单词free出现在邮件中的概率,或者P(free|spam),以及单词free出现在邮件中的概率,或者P(free)。
假设我们得知 12%的电子邮件包含单词free,而 20%被判定为垃圾邮件的电子邮件包含单词free。将这些信息代入前面的方程中,我们可以看到,一旦我们知道一封电子邮件包含单词free,我们认为它是垃圾邮件的概率从 10%增加到 16.7%,这就是我们的后验概率:

贝叶斯定理可以在一种称为X特征的分类器中得到应用,给定y变量(即 P(xi|y,x1...xn) 等同于 P(xi|y))。它们被称为朴素分类器,因为这种假设通常是错误的;然而,这些分类器在构建垃圾邮件过滤器时通常表现良好。
假设我们在电子邮件中也找到了多个美元符号和单词处方,我们想知道它是垃圾邮件的概率。虽然这些特征可能相互依赖,但朴素贝叶斯模型会将它们视为条件独立。这意味着我们现在的后验概率方程如下:

假设我们发现 5% 的垃圾邮件包含多个美元符号,55% 的垃圾邮件包含单词 prescription,25% 的邮件包含多个美元符号,而单词 prescription 在 2% 的邮件中出现。 这意味着,考虑到邮件中有 free 和 prescription 以及多个美元符号,我们认为它是垃圾邮件的概率从 10% 提升到了 91.7%:

现在我们已经理解了该算法的基础,接下来构建一个 Naive Bayes 分类器。请注意,scikit-learn 提供了各种 Naive Bayes 分类器,这些分类器在假定特征似然的分布上有所不同,我们将其定义为 P(xi|y,x1...xn)。我们将使用假设特征是正态分布的版本,即 GaussianNB:
>>> from sklearn.naive_bayes import GaussianNB
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> nb_pipeline = Pipeline([
... ('scale', StandardScaler()),
... ('nb', GaussianNB())
... ]).fit(X_train, y_train)
>>> nb_preds = nb_pipeline.predict(X_test)
我们可以从模型中获取类的先验信息,在这个例子中,告诉我们包含正常活动的分钟数的先验概率为 99.91%,异常活动的先验概率为 0.09%:
>>> nb_pipeline.named_steps['nb'].class_prior_
array([9.99147160e-01, 8.52840182e-04])
Naive Bayes 是一个很好的基线模型,因为我们不需要调整任何超参数,而且它训练速度很快。让我们看看它在测试数据上的表现(2018 年 2 月):
>>> fig, axes = plt.subplots(1, 3, figsize=(20, 5))
>>> roc(nb_pipeline.predict_proba(X_test)[:,1], ax=axes[0])
>>> conf_matrix(nb_preds, ax=axes[1])
>>> pr_curve(
... nb_pipeline.predict_proba(X_test)[:,1], ax=axes[2]
... )
>>> plt.suptitle('Naive Bayes Classifier')
Naive Bayes 分类器找到了所有五个攻击者,并且在 ROC 曲线和精确度-召回曲线中都超过了基线(虚线),这意味着该模型具有一定的预测价值:

图 11.14 – Naive Bayes 分类器的性能
不幸的是,我们触发了大量的假阳性(8,218)。在 2 月份,大约每 1,644 次攻击分类中,就有 1 次确实是攻击。这导致了用户对这些分类变得不敏感。他们可能会选择始终忽略我们的分类,因为这些分类太嘈杂,从而错过了真正的问题。这种权衡可以通过分类报告中的指标来捕捉:
>>> print(report(nb_preds))
precision recall f1-score support
False 1.00 0.79 0.89 39958
True 0.00 1.00 0.00 5
accuracy 0.79 39963
macro avg 0.50 0.90 0.44 39963
weighted avg 1.00 0.79 0.89 39963
尽管 Naive Bayes 分类器的表现超过了虚拟分类器,但它未能满足我们利益相关者的要求。由于有大量假阳性,目标类的精确度接近零。召回率高于精确度,因为该模型对于假阴性比假阳性更敏感(因为它不是很挑剔)。这使得 F1 分数为零。现在,让我们尝试超越这些基线模型。
逻辑回归
由于逻辑回归是另一个简单的模型,我们接下来试试看。在第九章《使用 Python 进行机器学习入门》中,我们已经使用逻辑回归解决了分类问题,因此我们已经知道它是如何工作的。正如我们在第十章《做出更好的预测——优化模型》中所学到的,我们将使用网格搜索来在期望的搜索空间中找到正则化超参数的最佳值,并使用recall_macro作为评分标准。请记住,假阴性会带来很大的代价,因此我们重点关注召回率。_macro后缀表示我们希望计算正负类的召回率的平均值,而不是总体召回率(因为类别不平衡)。
提示
如果我们确切知道召回率对我们来说比精确度更重要,我们可以用sklearn.metrics中的make_scorer()函数自定义评分器来替代。在我们正在使用的笔记本中有一个示例。
在使用网格搜索时,scikit-learn可能会在每次迭代时打印警告。因此,为了避免我们需要滚动查看所有信息,我们将使用%%capture魔法命令来捕获所有将被打印的内容,以保持笔记本的整洁:
>>> %%capture
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> lr_pipeline = Pipeline([
... ('scale', StandardScaler()),
... ('lr', LogisticRegression(random_state=0))
... ])
>>> search_space = {'lr__C': [0.1, 0.5, 1, 2]}
>>> lr_grid = GridSearchCV(
... lr_pipeline, search_space, scoring='recall_macro', cv=5
... ).fit(X_train, y_train)
>>> lr_preds = lr_grid.predict(X_test)
提示
使用%%capture,所有的错误和输出默认都会被捕获。我们可以选择使用--no-stderr仅隐藏错误,使用--no-stdout仅隐藏输出。这些选项在%%capture后面使用;例如,%%capture --no-stderr。
如果我们想要隐藏特定的错误,可以改用warnings模块。例如,在从warnings模块导入filterwarnings后,我们可以运行以下命令来忽略未来的弃用警告:filterwarnings('ignore', category=DeprecationWarning)
现在我们已经训练好了逻辑回归模型,让我们检查一下性能:
>>> fig, axes = plt.subplots(1, 3, figsize=(20, 5))
>>> roc(lr_grid.predict_proba(X_test)[:,1], ax=axes[0])
>>> conf_matrix(lr_preds, ax=axes[1])
>>> pr_curve(lr_grid.predict_proba(X_test)[:,1], ax=axes[2])
>>> plt.suptitle('Logistic Regression Classifier')
该模型没有假阳性,表现明显优于基准模型。ROC 曲线显著更接近左上角,精确率-召回率曲线也更接近右上角。注意,ROC 曲线对性能的评估略显乐观:

图 11.15 – 使用逻辑回归的性能
该模型满足 SOC 的要求。我们的召回率至少为 70%,精确度至少为 85%:
>>> print(report(lr_preds))
precision recall f1-score support
False 1.00 1.00 1.00 39958
True 1.00 0.80 0.89 5
accuracy 1.00 39963
macro avg 1.00 0.90 0.94 39963
weighted avg 1.00 1.00 1.00 39963
SOC 已经为我们提供了 2019 年 1 月和 2 月的数据,他们希望我们更新模型。不幸的是,我们的模型已经训练完毕,因此我们可以选择从头开始重新构建模型,或者忽略这些新数据。理想情况下,我们应该建立一个具有反馈循环的模型,以便能够融入这些(以及未来的)新数据。在下一节中,我们将讨论如何做到这一点。
将在线学习与反馈循环相结合
到目前为止,我们构建的模型存在一些重大问题。与我们在第九章,Python 中的机器学习入门,和 第十章 ,优化模型 – 做出更好的预测 中使用的数据不同,我们不应该期望攻击者的行为随着时间的推移保持静态。同时,我们能够在内存中存储的数据量也有限,这限制了我们可以用来训练模型的数据量。因此,我们现在将构建一个在线学习模型,用于标记每分钟失败次数异常的用户名。在线学习模型会不断更新(通过流式传输的近实时更新,或批量更新)。这使得我们能够在新数据到达时进行学习,然后将其丢弃(以保持内存空间)。
此外,模型可以随着时间的推移发展,并适应数据底层分布的变化。我们还将为模型提供反馈,以便它在学习过程中能够确保对攻击者行为的变化保持鲁棒性。这就是scikit-learn支持这种行为的原因;因此,我们仅能使用提供partial_fit()方法的模型(没有这个方法的模型需要用新数据从头开始训练)。
提示
scikit-learn将实现partial_fit()方法的模型称为增量学习器。更多信息,包括哪些模型支持此方法,可以在scikit-learn.org/stable/computing/scaling_strategies.html#incremental-learning中找到。
我们的数据目前是按分钟汇总的,然后传递给模型,因此这将是批量学习,而非流式学习;但是请注意,如果我们将其投入生产环境,我们可以每分钟更新一次模型(如果需要的话)。
创建 PartialFitPipeline 子类
我们在第九章,Python 中的机器学习入门中看到,Pipeline类使得简化我们的机器学习流程变得轻而易举,但不幸的是,我们无法在partial_fit()方法中使用它。为了绕过这个问题,我们可以创建自己的PartialFitPipeline类,它是Pipeline类的子类,但支持调用partial_fit()。PartialFitPipeline类位于ml_utils.partial_fit_pipeline模块中。
我们只需从sklearn.pipeline.Pipeline继承,并定义一个新的方法——partial_fit()——该方法会对除最后一步之外的所有步骤调用fit_transform(),并对最后一步调用partial_fit():
from sklearn.pipeline import Pipeline
class PartialFitPipeline(Pipeline):
"""
Subclass of sklearn.pipeline.Pipeline that supports the
`partial_fit()` method.
"""
def partial_fit(self, X, y):
"""
Run `partial_fit()` for online learning estimators
when used in a pipeline.
"""
# for all but last step
for _, step in self.steps[:-1]: # (name, object) tuples
X = step.fit_transform(X)
# grab object from tuple position 1 for partial_fit()
self.steps[-1][1].partial_fit(X, y)
return self
现在我们已经有了PartialFitPipeline类,剩下的最后一部分就是选择一个能够进行在线学习的模型。
随机梯度下降分类器
我们的逻辑回归模型表现良好——它满足了召回率和精确度的要求。然而,LogisticRegression 类不支持在线学习,因为它计算系数的方法是一个封闭解。我们可以选择使用优化算法,如梯度下降,来代替计算系数;这样就可以实现在线学习。
我们可以训练一个新的逻辑回归模型,使用SGDClassifier类,而不是使用不同的增量学习器。它使用随机梯度下降(SGD)来优化我们选择的损失函数。在这个示例中,我们将使用对数损失,它让我们得到一个通过 SGD 找到系数的逻辑回归模型。
与标准梯度下降优化方法需要查看所有样本或批次来估算梯度不同,SGD 通过随机选择样本(随机性)来减少计算成本。模型从每个样本学习的多少取决于学习率,早期更新的影响大于后期更新。SGD 的单次迭代过程如下:
-
洗牌训练数据。
-
对于训练数据中的每个样本,估计梯度并根据学习率所确定的递减强度更新模型。
-
重复步骤 2,直到所有样本都已使用。
在机器学习中,我们使用epoch来表示完整训练集使用的次数。我们刚才概述的 SGD 过程是一个单一的 epoch。当我们训练多个 epoch 时,我们会重复前述步骤,直到达到预定的 epoch 次数,并每次从上次停止的地方继续。
现在我们理解了 SGD 的工作原理,准备好构建我们的模型了。以下是我们在向 SOC 展示之前将遵循的过程概述:

图 11.16 – 准备我们的在线学习模型的过程
现在让我们转到5-online_learning.ipynb笔记本,构建我们的在线学习模型。
构建我们的初始模型
首先,我们将使用get_X_y()函数,利用 2018 年的完整数据获取X和y训练数据:
>>> X_2018, y_2018 = get_X_y(logs_2018, '2018', hackers_2018)
由于我们将以批量更新该模型,我们的测试集将始终是我们当前预测所使用的数据。更新之后,它将成为训练集并用于更新模型。让我们构建一个基于 2018 年标注数据训练的初始模型。请注意,PartialFitPipeline对象的创建方式与我们创建Pipeline对象的方式相同:
>>> from sklearn.linear_model import SGDClassifier
>>> from sklearn.preprocessing import StandardScaler
>>> from ml_utils.partial_fit_pipeline import \
... PartialFitPipeline
>>> model = PartialFitPipeline([
... ('scale', StandardScaler()),
... ('sgd', SGDClassifier(
...random_state=10, max_iter=1000,
... tol=1e-3, loss='log', average=1000,
... learning_rate='adaptive', eta0=0.01
... ))
... ]).fit(X_2018, y_2018)
我们的管道首先会对数据进行标准化,然后将其传递给模型。我们使用fit()方法开始构建我们的模型,这样我们就能为稍后使用partial_fit()进行更新奠定一个良好的起点。max_iter参数定义了训练的迭代次数。tol参数(容忍度)指定了停止迭代的条件,当当前迭代的损失大于前一次损失减去容忍度时(或者我们已经达到max_iter次迭代),迭代就会停止。我们指定了loss='log'以使用逻辑回归;然而,损失函数有许多其他选项,包括线性 SVM 的默认值'hinge'。
在这里,我们还传入了average参数的值,告诉SGDClassifier对象在看到 1,000 个样本后将系数存储为结果的平均值;注意,这个参数是可选的,默认情况下不会进行计算。可以通过以下方式检查这些系数:
>>> [(col, coef) for col, coef in
... zip(X_2018.columns, model.named_steps['sgd'].coef_[0])]
[('usernames_with_failures', 0.9415581997027198),
('day_of_week_0', 0.05040751530926895),
...,
('hour_23', -0.02176726532333003)]
最后,我们传入了eta0=0.01作为初始学习率,并指定只有在连续若干个 epoch 内未能改善损失超过容忍度时,才调整学习率(learning_rate='adaptive')。这个连续的 epoch 数量由n_iter_no_change参数定义,默认为 5,因为我们没有明确设置它。
评估模型
由于我们现在有了 2019 年 1 月和 2 月的标注数据,我们可以评估模型在每个月的表现。首先,我们从数据库中读取 2019 年的数据:
>>> with sqlite3.connect('logs/logs.db') as conn:
... logs_2019 = pd.read_sql(
... """
... SELECT *
... FROM logs
... WHERE
... datetime BETWEEN "2019-01-01" AND "2020-01-01";
... """,
... conn, parse_dates=['datetime'],
... index_col='datetime'
... )
... hackers_2019 = pd.read_sql(
... """
... SELECT *
... FROM attacks
... WHERE start BETWEEN "2019-01-01" AND "2020-01-01";
... """,
... conn, parse_dates=['start', 'end']
... ).assign(
... start_floor=lambda x: x.start.dt.floor('min'),
... end_ceil=lambda x: x.end.dt.ceil('min')
... )
接下来,我们将 1 月 2019 年的数据提取出来:
>>> X_jan, y_jan = get_X_y(logs_2019, '2019-01', hackers_2019)
分类报告显示这个模型表现得相当不错,但我们对正类的召回率低于目标值:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(y_jan, model.predict(X_jan)))
precision recall f1-score support
False 1.00 1.00 1.00 44559
True 1.00 0.64 0.78 44
accuracy 1.00 44603
macro avg 1.00 0.82 0.89 44603
weighted avg 1.00 1.00 1.00 44603
记住,我们的利益相关者已经指定,召回率(TPR)必须至少达到 70%,精确度必须至少达到 85%。让我们写一个函数,展示 ROC 曲线、混淆矩阵和精确度-召回曲线,并指出我们需要达到的区域以及我们当前的位置:
>>> from ml_utils.classification import (
... confusion_matrix_visual, plot_pr_curve, plot_roc
... )
>>> def plot_performance(model, X, y, threshold=None,
... title=None, show_target=True):
... """
... Plot ROC, confusion matrix, and precision-recall curve.
...
... Parameters:
... - model: The model object to use for prediction.
... - X: The features to pass in for prediction.
... - y: The actuals to evaluate the prediction.
... - threshold: Value to use as when predicting
... probabilities.
... - title: A title for the subplots.
... - show_target: Whether to show the target regions.
...
... Returns:
... Matplotlib `Axes` object.
... """
... fig, axes = plt.subplots(1, 3, figsize=(20, 5))
... # plot each visualization
... plot_roc(y, model.predict_proba(X)[:,1], ax=axes[0])
... confusion_matrix_visual(
... y,
... model.predict_proba(X)[:,1] >= (threshold or 0.5),
... class_labels=[False, True], ax=axes[1]
... )
... plot_pr_curve(
... y, model.predict_proba(X)[:,1], ax=axes[2]
... )
...
... # show the target regions if desired
... if show_target:
... axes[0]\
... .axvspan(0, 0.1, color='lightgreen', alpha=0.5)
... axes[0]\
... .axhspan(0.7, 1, color='lightgreen', alpha=0.5)
... axes[0].annotate(
... 'region with acceptable\nFPR and TPR',
... xy=(0.1, 0.7), xytext=(0.17, 0.65),
... arrowprops=dict(arrowstyle='->')
... )
...
... axes[2]\
... .axvspan(0.7, 1, color='lightgreen', alpha=0.5)
... axes[2].axhspan(
... 0.85, 1, color='lightgreen', alpha=0.5
... )
... axes[2].annotate(
... 'region with acceptable\nprecision and recall',
... xy=(0.7, 0.85), xytext=(0.3, 0.6),
... arrowprops=dict(arrowstyle='->')
... )
...
... # mark the current performance
... tn, fn, fp, tp = \
... [int(x.get_text()) for x in axes[1].texts]
... precision, recall = tp / (tp + fp), tp / (tp + fn)
... fpr = fp / (fp + tn)
...
... prefix = 'current performance' if not threshold \
... else f'chosen threshold: {threshold:.2%}'
... axes[0].annotate(
... f'{prefix}\n- FPR={fpr:.2%}'
... f'\n- TPR={recall:.2%}',
... xy=(fpr, recall), xytext=(0.05, 0.45),
... arrowprops=dict(arrowstyle='->')
... )
... axes[2].annotate(
... f'{prefix}\n- precision={precision:.2%}'
... f'\n- recall={recall:.2%}',
... xy=(recall, precision), xytext=(0.2, 0.85),
... arrowprops=dict(arrowstyle='->')
... )
...
... if title: # show the title if specified
... plt.suptitle(title)
...
... return axes
现在,让我们调用该函数,看看我们的表现如何:
>>> axes = plot_performance(
... model, X_jan, y_jan,
... title='Stochastic Gradient Descent Classifier '
... '(Tested on January 2019 Data)'
... )
请注意,我们目前没有达到利益相关者的要求;我们的性能没有达到目标区域:

图 11.17 – 使用默认阈值的模型性能
我们的召回率(TPR)为 63.64%,没有达到 70%或更高的目标。默认情况下,当我们使用predict()方法时,概率阈值是 50%。如果我们目标是特定的精确度/召回率或 TPR/FPR 区域,可能需要调整阈值并使用predict_proba()来获得理想的性能。
ml_utils.classification模块包含find_threshold_roc()和find_threshold_pr()函数,它们分别帮助我们在 ROC 曲线或精确度-召回率曲线上选择阈值。由于我们针对的是特定的精确度/召回率区域,因此我们将使用后者。该函数也使用了scikit-learn中的precision_recall_curve()函数,但与其绘制精确度和召回率数据不同,我们用它来选择满足我们标准的阈值:
from sklearn.metrics import precision_recall_curve
def find_threshold_pr(y_test, y_preds, *, min_precision,
min_recall):
"""
Find the threshold to use with `predict_proba()` for
classification based on the minimum acceptable precision
and the minimum acceptable recall.
Parameters:
- y_test: The actual labels.
- y_preds: The predicted labels.
- min_precision: The minimum acceptable precision.
- min_recall: The minimum acceptable recall.
Returns: The thresholds that meet the criteria.
"""
precision, recall, thresholds = \
precision_recall_curve(y_test, y_preds)
# precision and recall have one extra value at the end
# for plotting -- needs to be removed to make a mask
return thresholds[
(precision[:-1] >= min_precision) &
(recall[:-1] >= min_recall)
]
重要提示
该笔记本还展示了一个为 TPR/FPR 目标寻找阈值的例子。我们当前的目标精确度/召回率恰好与目标至少 70%的 TPR(召回率)和最多 10%的 FPR 相同,从而得出了相同的阈值。
让我们使用这个函数找到一个符合我们利益相关者规格的阈值。我们选择位于目标区域内的最大概率值来挑选最不敏感的候选阈值:
>>> from ml_utils.classification import find_threshold_pr
>>> threshold = find_threshold_pr(
... y_jan, model.predict_proba(X_jan)[:,1],
... min_precision=0.85, min_recall=0.7
... ).max()
>>> threshold
0.0051533333839830974
这个结果告诉我们,如果我们将有 0.52%机会属于正类的结果标记出来,就可以达到所需的精确度和召回率。毫无疑问,这看起来像是一个非常低的概率,或者模型并不完全自信,但我们可以这样思考:如果模型认为登录活动有任何微小的可能性是可疑的,我们希望知道。让我们看看使用这个阈值时我们的性能表现如何:
>>> axes = plot_performance(
... model, X_jan, y_jan, threshold=threshold,
... title='Stochastic Gradient Descent Classifier '
... '(Tested on January 2019 Data)'
... )
这个阈值使我们的召回率达到了 70.45%,满足了我们的利益相关者要求。我们的精确度也在可接受范围内:

图 11.18 – 使用自定义阈值的模型性能
使用自定义阈值后,我们正确地识别出了另外三个案例,减少了我们的假阴性,这对 SOC 来说是非常昂贵的。在这里,这一改进并没有导致额外的假阳性,但请记住,在减少假阴性(类型 II 错误)和减少假阳性(类型 I 错误)之间通常存在权衡。在某些情况下,我们对类型 I 错误的容忍度非常低(FPR 必须非常小),而在其他情况下,我们更关心找到所有的正类案例(TPR 必须很高)。在信息安全中,我们对假阴性的容忍度较低,因为它们代价非常高;因此,我们将继续使用自定义阈值。
重要提示
有时,模型性能的要求可能并不现实。与利益相关者保持开放的沟通,解释问题并在必要时讨论放宽标准是非常重要的。
更新模型
持续更新将帮助模型适应随着时间推移黑客行为的变化。既然我们已经评估了 1 月的预测结果,就可以使用这些结果来更新模型。为此,我们使用partial_fit()方法和 1 月的标注数据,这将对 1 月的数据运行一个训练周期:
>>> model.partial_fit(X_jan, y_jan)
我们的模型现在已更新,因此我们可以开始测试它在 2 月数据上的表现。让我们首先获取 2 月的数据:
>>> X_feb, y_feb = get_X_y(logs_2019, '2019-02', hackers_2019)
2 月的攻击较少,但我们捕获了更高比例的攻击(80%):
>>> print(classification_report(
... y_feb, model.predict_proba(X_feb)[:,1] >= threshold
... ))
precision recall f1-score support
False 1.00 1.00 1.00 40248
True 1.00 0.80 0.89 10
accuracy 1.00 40258
macro avg 1.00 0.90 0.94 40258
weighted avg 1.00 1.00 1.00 40258
让我们看看 2 月的性能图,看看它们是如何变化的:
>>> axes = plot_performance(
... model, X_feb, y_feb, threshold=threshold,
... title='Stochastic Gradient Descent Classifier '
... '(Tested on February 2019 Data)'
... )
注意,精确度-召回率曲线下的面积有所增加,并且更多的曲线处于目标区域:

图 11.19 – 一次更新后的模型性能
展示我们的结果
SOC 已经完成了 3 月数据的处理。他们希望我们将他们对 2 月预测的反馈融入我们的模型中,然后为他们提供 3 月数据的预测,以便他们进行审核。他们将基于每一分钟的表现,使用分类报告、ROC 曲线、混淆矩阵和精确度-召回率曲线来评估我们的表现。现在是时候测试我们的模型了。
首先,我们需要更新 2 月数据的模型:
>>> model.partial_fit(X_feb, y_feb)
接下来,我们获取 3 月数据并做出预测,使用 0.52%的阈值:
>>> X_march, y_march = \
... get_X_y(logs_2019, '2019-03', hackers_2019)
>>> march_2019_preds = \
... model.predict_proba(X_march)[:,1] >= threshold
我们的分类报告看起来不错。我们的召回率为 76%,精确度为 88%,F1 分数也很稳定:
>>> from sklearn.metrics import classification_report
>>> print(classification_report(y_march, march_2019_preds))
precision recall f1-score support
False 1.00 1.00 1.00 44154
True 0.88 0.76 0.81 29
accuracy 1.00 44183
macro avg 0.94 0.88 0.91 44183
weighted avg 1.00 1.00 1.00 44183
现在,让我们看看图表的表现:
>>> axes = plot_performance(
... model, X_march, y_march, threshold=threshold,
... title='Stochastic Gradient Descent Classifier '
... '(Tested on March 2019 Data)'
... )
现在,ROC 曲线的 AUC 略有上升,而精确度-召回率曲线的 AUC 下降了:

图 11.20 – 两次更新后的模型性能
进一步改进
SOC 对我们的结果很满意,现在希望我们每分钟提供一次预测。他们还承诺将在一小时内提供反馈。我们在此不实现这个请求,但我们将简要讨论如何操作。
我们一直在使用批处理更新模型每月的数据;然而,为了提供利益相关者所需的内容,我们需要通过执行以下操作来缩短反馈周期:
-
每分钟运行
predict_proba()并将预测结果发送给我们的利益相关者。这需要设置一个过程,将日志每分钟传递给预处理函数,然后再传递给模型本身。 -
通过约定的媒介将结果交付给我们的利益相关者。
-
使用
partial_fit()每小时更新模型,利用我们从利益相关者处收到的反馈(当我们确定如何让他们与我们共享这些信息时)。
在实施上述操作之后,剩下的就是将模型投入生产,并确定每个人需要遵守的更新和预测频率。
总结
在实践中,检测攻击者并不容易。现实中的黑客远比本模拟中的更狡猾。攻击事件也更加少见,导致了极大的类别不平衡。建立能够捕获所有攻击的机器学习模型几乎是不可能的。这就是为什么与具有领域知识的人合作如此重要;他们可以通过真正理解数据及其特殊性,帮助我们从模型中挤出更多的性能。无论我们在机器学习领域有多经验丰富,我们都不应该拒绝来自那些经常与相关数据打交道的人的帮助。
我们最初的异常检测尝试是无监督的,因为我们在等待来自领域专家的标注数据。我们使用scikit-learn尝试了 LOF 和孤立森林方法。一旦我们收到了标注数据和利益相关者的性能要求,我们确定孤立森林模型更适合我们的数据。
然而,我们并没有止步于此。由于我们刚刚获得了标注数据,我们尝试了监督学习方法。我们学习了如何使用虚拟分类器和朴素贝叶斯构建基准模型。然后,我们重新审视了逻辑回归,看看它是否能帮助我们。我们的逻辑回归模型表现良好;然而,由于它使用封闭式解法来找到系数,我们无法在不重新训练模型的情况下融入反馈循环。
这一限制促使我们构建了一个在线学习模型,该模型不断更新。首先,我们必须创建一个子类,以便让管道使用partial_fit()方法。接着,我们尝试了带有对数损失的 SGD 分类。我们能够一次性训练整整一年的数据,然后在收到新的标注数据时更新我们的模型。这使得模型能够随时间调整特征分布的变化。
在下一章,我们将回顾整本书中学到的内容,并介绍一些额外的资源,用于查找数据以及在 Python 中处理数据。
练习
完成以下练习,练习机器学习工作流程,并接触一些额外的异常检测策略:
-
一类 SVM 也是一个可以用于无监督离群点检测的模型。使用默认参数构建一类 SVM,使用一个包含
StandardScaler对象和OneClassSVM对象的管道。就像我们为孤立森林所做的那样,在 2018 年 1 月的数据上训练模型,并对同一数据进行预测。计算该模型识别出的内点和外点的数量。 -
使用 2018 年的分钟级数据,在对数据进行
StandardScaler标准化后,构建一个包含两个簇的 k-means 模型。使用 SQLite 数据库(logs/logs.db)中attacks表的标注数据,查看这个模型是否能够获得一个好的 Fowlkes-Mallows 分数(使用sklearn.metrics中的fowlkes_mallows_score()函数)。 -
评估随机森林分类器在监督异常检测中的表现。将
n_estimators设置为100,并使用其余默认设置,包括预测阈值。使用 2018 年 1 月的数据进行训练,使用 2018 年 2 月的数据进行测试。 -
partial_fit()方法在GridSearchCV类中不可用。相反,我们可以使用它的fit()方法和一个具有partial_fit()方法(或PartialFitPipeline对象)的模型,在我们的搜索空间中找到最佳超参数。然后,我们可以从网格搜索中获取最佳模型(best_estimator_),并在其上使用partial_fit()。尝试使用sklearn.linear_model模块中的PassiveAggressiveClassifier类和一个PartialFitPipeline对象。这个在线学习分类器在做出正确预测时是被动的,但在做出错误预测时会积极纠正自己。无需担心选择自定义阈值。请确保按照以下步骤操作:a) 使用 2018 年 1 月的数据进行初始训练并执行网格搜索。
b) 使用
best_estimator_属性获取调优后的模型。c) 使用 2018 年 2 月的数据评估最佳估计器。
d) 使用 2018 年 2 月的数据进行更新。
e) 使用 2018 年 3 月至 6 月的数据评估最终模型。
深入阅读
查阅以下资源以获取更多关于本章内容的信息:
-
大规模部署 scikit-learn 模型:
towardsdatascience.com/deploying-scikit-learn-models-at-scale-f632f86477b8 -
局部异常因子用于异常检测:
towardsdatascience.com/local-outlier-factor-for-anomaly-detection-cc0c770d2ebe -
模型持久化 (来自 scikit-learn 用户指南):
scikit-learn.org/stable/modules/model_persistence.html -
新颖性与异常值检测 (来自 scikit-learn 用户指南):
scikit-learn.org/stable/modules/outlier_detection.html -
朴素贝叶斯 (来自 scikit-learn 用户指南):
scikit-learn.org/stable/modules/naive_bayes.html -
使用孤立森林进行异常值检测:
towardsdatascience.com/outlier-detection-with-isolation-forest-3d190448d45e -
被动攻击算法 (视频讲解):
www.youtube.com/watch?v=uxGDwyPWNkU -
Python 上下文管理器与“with”语句:
blog.ramosly.com/python-context-managers-and-the-with-statement-8f53d4d9f87 -
Seeing Theory – 第五章**, 贝叶斯推断:
seeing-theory.brown.edu/index.html#secondPage/chapter5 -
SQLAlchemy — Python 教程:
towardsdatascience.com/sqlalchemy-python-tutorial-79a577141a91 -
随机梯度下降 (来自 scikit-learn 用户指南):
scikit-learn.org/stable/modules/sgd.html -
计算扩展策略:更大的数据 (来自 scikit-learn 用户指南):
scikit-learn.org/stable/computing/scaling_strategies.html -
不公平硬币贝叶斯模拟:
github.com/xofbd/unfair-coin-bayes
第五部分:附加资源
在本节的最后部分,我们将回顾本书中涉及的所有内容,并为您提供一些额外的书籍、网络资源和文档,帮助您深入探讨各种数据科学话题,并练习您的技能。
本节包含以下章节:
- 第十二章,前进的道路
第十三章:第十二章:未来之路
在本书中,我们已经涵盖了大量的内容,现在你已经能够完全使用 Python 进行数据分析和机器学习任务了。我们从学习一些基础的统计学知识开始,了解如何为数据科学设置 Python 环境。然后,我们学习了如何使用pandas的基础知识,以及如何将数据导入 Python。通过这些知识,我们能够使用 API、从文件读取数据以及查询数据库来获取分析所需的数据。
在我们收集完数据后,我们学习了如何进行数据清洗(数据整理),以便清理数据并将其转换为可用格式。接下来,我们学习了如何处理时间序列数据,如何合并来自不同来源的数据并进行聚合。一旦我们掌握了数据整理的技巧,我们就开始了数据可视化的学习,使用pandas、matplotlib和seaborn创建了各种类型的图表,并且我们还学习了如何自定义这些图表。
凭借这些知识,我们能够进行一些真实世界的分析,查看比特币和 FAANG 股票的金融数据,并尝试检测黑客是否通过暴力攻击尝试登录 Web 应用程序。此外,我们还学习了如何构建自己的 Python 包、编写自己的类以及模拟数据。
最后,我们介绍了使用scikit-learn进行机器学习的基本概念。我们讨论了如何构建模型流水线,从数据预处理到模型拟合。随后,我们讨论了如何评估模型的性能以及如何改进模型性能。我们的机器学习讨论最终以使用机器学习模型检测黑客通过暴力破解攻击尝试访问 Web 应用程序为高潮。
现在,既然你已经获得了所有这些知识,重要的是要培养它,确保自己能够牢记。也就是说,你必须抓住每一个练习的机会。本章提供了以下资源,帮助你继续你的数据科学之旅:
-
各种主题的数据资源
-
用于练习处理数据的网站和服务
-
提升 Python 技能的编码挑战和教育内容
数据资源
和任何技能一样,要提高我们就需要练习,对我们来说,这意味着需要找到数据来进行练习。没有最好的数据集可以用于练习;每个人应该找到自己感兴趣的、想要探索的数据。虽然本节内容并不全面,但它包含了各种主题的数据资源,希望每个人都能找到自己想使用的资源。
小贴士
不确定要寻找什么样的数据?你对某个感兴趣的话题有没有好奇过什么问题?是否有关于该话题的数据已经被收集,并且你可以访问这些数据吗?让你的好奇心引导你。
Python 包
seaborn 和 scikit-learn 都提供了内置的示例数据集,你可以用来练习书中所学的内容,并尝试新的技术。这些数据集通常非常干净,因而容易操作。一旦你熟练掌握了这些技术,就可以使用接下来章节中提到的其他资源来寻找数据,这些数据集会更接近实际数据。
Seaborn
Seaborn 提供了 load_dataset() 函数,该函数从一个小型 GitHub 仓库中的 CSV 文件读取数据,这些数据用于 seaborn 文档中的示例。因此,值得注意的是,这些数据集可能会有所变化。你可以直接从仓库中获取数据:github.com/mwaskom/seaborn-data。
Scikit-learn
Scikit-learn 包含一个 datasets 模块,可以用来生成随机数据集以测试算法,或者导入一些机器学习社区中流行的数据集。请务必查看文档以获取更多信息:
-
为机器学习任务生成随机数据集:
scikit-learn.org/stable/modules/classes.html#samples-generator -
加载支持的数据集:
scikit-learn.org/stable/modules/classes.html#loaders
还有 fetch_openml() 函数,它位于 sklearn.datasets 模块中,可以通过名称从 OpenML (www.openml.org/) 获取数据集,OpenML 包含许多免费用于机器学习的数据集。
搜索数据
以下是一些你可以用来搜索各种主题数据的地方:
-
DataHub:
datahub.io/search -
Google 数据集搜索:
datasetsearch.research.google.com/ -
Amazon Web Services 的开放数据:
registry.opendata.aws/ -
OpenML:
www.openml.org -
斯坦福大学收集的数据集 SNAP 库:
snap.stanford.edu/data/index.html -
UCI 机器学习数据集库:
archive.ics.uci.edu/ml/index.php
APIs
我们已经看到,使用 APIs 收集数据是多么方便;以下是一些可能对你有帮助的数据收集 APIs:
-
Facebook API:
developers.facebook.com/docs/graph-api -
NOAA 气候数据 API:
www.ncdc.noaa.gov/cdo-web/webservices/v2 -
NYTimes API:
developer.nytimes.com/ -
OpenWeatherMap API:
openweathermap.org/api -
Twitter API:
developer.twitter.com/en/docs -
USGS 地震 API:
earthquake.usgs.gov/fdsnws/event/1/
网站
本节包含了通过网站可以访问的各种主题的精选数据资源。获取数据进行分析可能只需下载 CSV 文件,或者需要使用pandas解析 HTML。如果你必须进行网页抓取(确保你已经尝试了本书中讨论的方式),请确保你没有违反该网站的使用条款。
金融
在本书中我们多次使用了金融数据。如果你对进一步的金融分析感兴趣,除了我们在第七章中讨论的pandas_datareader包之外,金融分析——比特币与股票市场,可以参考以下资源:
-
Google Finance:
google.com/finance -
NASDAQ 历史股价:
www.nasdaq.com/market-activity/quotes/historical -
Quandl:
www.quandl.com -
雅虎财经:
finance.yahoo.com
政府数据
政府数据通常向公众开放。以下资源包含一些政府提供的数据:
-
欧盟开放数据:
data.europa.eu/euodp/en/data -
英国政府数据:
data.gov.uk/ -
联合国数据:
data.un.org/ -
美国人口普查数据:
census.gov/data.html -
美国政府数据:
www.data.gov/
健康与经济
来自全球的经济、医疗和社会数据可在以下网站上获取:
-
Gapminder:
www.gapminder.org/data/ -
世界卫生组织:
www.who.int/data/gho
以下是有关 COVID-19 大流行的附加数据资源:
-
美国 COVID-19 数据(NYTimes):
github.com/nytimes/covid-19-data -
约翰·霍普金斯大学系统科学与工程中心(CSSE)COVID-19 数据存储库:
github.com/CSSEGISandData/COVID-19 -
COVID-19 大流行(ECDC):
www.ecdc.europa.eu/en/covid-19-pandemic -
开放 COVID-19 数据集:
researchdata.wisc.edu/open-covid-19-datasets/
社交网络
对于那些对基于文本的数据或图形数据感兴趣的人,可以查看以下社交网络资源:
-
Twitter 数据资源列表:
github.com/shaypal5/awesome-twitter-data
体育
对于体育迷,查看以下网站,这些网站提供所有你最喜欢运动员的统计数据的数据库和网页:
-
棒球数据库(练习与数据库的操作):
www.seanlahman.com/baseball-archive/statistics/ -
棒球运动员统计数据:
www.baseball-reference.com/players/ -
篮球运动员统计数据:
www.basketball-reference.com/players/ -
橄榄球(美式足球)运动员统计数据:
www.pro-football-reference.com/players/ -
足球(足球)统计数据:
www.whoscored.com/Statistics -
冰球运动员统计数据:
www.hockey-reference.com/players/
杂项
以下资源的主题各异,但如果到目前为止没有任何内容引起你的兴趣,请务必查看这些资源:
-
Amazon 评论数据:
snap.stanford.edu/data/web-Amazon.html -
从维基百科提取的数据:
wiki.dbpedia.org/develop/datasets -
Google 趋势:
trends.google.com/trends/ -
MovieLens 电影数据:
grouplens.org/datasets/movielens/ -
Yahoo Webscope(数据集参考库):
webscope.sandbox.yahoo.com/
练习与数据的处理
在本书中,我们已经使用了来自不同来源的各种数据集,并提供了逐步的操作指导。然而,这并不意味着到此为止。本节专门介绍了一些可以用于继续指导学习的资源,并最终朝着为预定义问题构建模型的方向努力。
Kaggle (www.kaggle.com/) 提供数据科学学习内容、社区成员共享的数据集以及公司发布的竞赛——或许你对 Netflix 推荐竞赛有些印象 (www.kaggle.com/netflix-inc/netflix-prize-data)?这些竞赛是你练习机器学习技能并在社区中(尤其是潜在雇主面前)增加曝光的绝佳方式。
重要提示
Kaggle 并非唯一可以参与数据科学竞赛的平台。其他一些平台可以参考 towardsdatascience.com/top-competitive-data-science-platforms-other-than-kaggle-2995e9dad93c。
DataCamp (www.datacamp.com/),虽然并非完全免费,提供各种 Python 数据科学课程。课程包括教学视频和填空式编程练习题,帮助你逐步加深对相关主题的理解。
Python 练习
我们在本书中已经看到,在 Python 中处理数据不仅仅是 pandas、matplotlib 和 numpy;我们可以通过增强 Python 编程能力来改进我们的工作流程。拥有强大的 Python 技能,我们可以使用 Flask 构建 Web 应用程序,向 API 发起请求,高效地遍历组合或排列,并寻找加速代码的方式。虽然本书没有专门关注这些技能的提升,但以下是一些免费的资源,供你练习 Python 并培养编程思维:
-
HackerRank:
www.hackerrank.com -
Codewars:
www.codewars.com -
LeetCode:
www.leetcode.com -
CodinGame:
www.codingame.com
Python Morsels (www.pythonmorsels.com/) 提供每周 Python 练习,帮助你学习编写更 Pythonic 的代码,并更熟悉 Python 标准库。练习的难度不一,但可以根据需要调整难度。
另一个很棒的资源是 Pramp (www.pramp.com),它可以让你与随机分配的同行一起进行编程面试练习。你的同行会用一个随机问题对你进行面试,并评估你如何处理面试、你的代码以及你如何解释自己。30 分钟后,轮到你去面试你的同行。
可汗学院 (www.khanacademy.org/) 是一个学习某一主题的绝佳资源。如果你想了解计算机科学算法或机器学习算法背后的数学(如线性代数和微积分),那么这里是一个很好的起点。
最后,LinkedIn 学习平台 (www.linkedin.com/learning/) 提供了许多关于广泛主题的视频课程,包括 Python、数据科学和机器学习。新用户可以享受一个月的免费试用期。考虑参加学习 Python 3 标准库课程 (www.linkedin.com/learning/learning-the-python-3-standard-library),提升你的 Python 技能;正如我们在本书中所见,掌握标准库帮助我们编写更简洁高效的代码。
总结
本章为你提供了许多可以找到数据集的地方,涵盖了各种主题。此外,你还了解了可以参加课程、完成教程、练习机器学习和提高 Python 技能的各种网站。保持技能的锋利并保持好奇心非常重要,因此,无论你对什么感兴趣,都要寻找数据并进行自己的分析。这些都可以作为你的数据作品集,上传到 GitHub 账户。

感谢你阅读这本书!我希望你能从中获得与这两只数据分析熊猫一样多的收获。
练习
本章的练习是开放性的——没有提供解决方案。它们旨在为你提供一些思路,以便你能独立开始:
-
通过参与 Kaggle 上的 Titanic 挑战(
www.kaggle.com/c/titanic),来练习机器学习分类。 -
通过参与 Kaggle 上的房价挑战(
www.kaggle.com/c/house-prices-advanced-regression-techniques),来练习机器学习回归技术。 -
对你感兴趣的事物进行分析。一些有趣的想法包括以下几个:
a) 预测 Instagram 上的点赞数:
towardsdatascience.com/predict-the-number-of-likes-on-instagram-a7ec5c020203b) 分析 NJ 交通列车的延误情况:
medium.com/@pranavbadami/how-data-can-help-fix-nj-transit-c0d15c0660fec) 利用可视化解决数据科学问题:
towardsdatascience.com/solving-a-data-science-challenge-the-visual-way-355cfabcb1c5 -
在本章的Python 实践部分的任一网站上完成五个挑战。例如,你可以尝试以下挑战:
a) 找到两个数,使它们的和恰好为特定值:
leetcode.com/problems/two-sum/b) 验证信用卡号:
www.hackerrank.com/challenges/validating-credit-card-number/problem
进一步阅读
你可以查阅以下博客和文章,以保持对 Python 和数据科学的最新了解:
-
Armin Ronacher 的博客(Flask 作者):
lucumr.pocoo.org/ -
Data Science Central:
www.datasciencecentral.com/ -
Medium 上的数据科学专题:
medium.com/topic/data-science -
Kaggle 博客:
medium.com/kaggle-blog -
KD Nuggets:
www.kdnuggets.com/websites/blogs.html -
Medium 上的机器学习专题:
medium.com/topic/machine-learning -
Planet Python:
planetpython.org/ -
Medium 上的编程专题:
medium.com/topic/programming -
Python 小贴士:
book.pythontips.com/en/latest/index.html -
Python 3 每周模块:
pymotw.com/3/ -
Towards Data Science:
towardsdatascience.com/ -
Trey Hunner 的博客(Python Morsels 的创始人):
treyhunner.com/blog/archives/
以下资源包含了构建自定义scikit-learn类的信息:
-
构建 scikit-learn 转换器:
dreisbach.us/articles/building-scikit-learn-compatible-transformers/ -
在 scikit-learn 中创建自己的估计器:
danielhnyk.cz/creating-your-own-estimator-scikit-learn/ -
Scikit-learn BaseEstimator:
scikit-learn.org/stable/modules/generated/sklearn.base.BaseEstimator.html -
Scikit-learn 自定义估计器:
scikit-learn.org/stable/developers/develop.html#developing-scikit-learn-estimators -
Scikit-learn TransformerMixin:
scikit-learn.org/stable/modules/generated/sklearn.base.TransformerMixin.html#sklearn.base.TransformerMixin
适用于 Python 数据科学栈的编码备忘单可以在这里找到:
-
Jupyter Notebook 备忘单:
s3.amazonaws.com/assets.datacamp.com/blog_assets/Jupyter_Notebook_Cheat_Sheet.pdf -
Jupyter Notebook 快捷键:
www.cheatography.com/weidadeyue/cheat-sheets/jupyter-notebook/pdf_bw/ -
Matplotlib 备忘单:
s3.amazonaws.com/assets.datacamp.com/blog_assets/Python_Matplotlib_Cheat_Sheet.pdf -
NumPy 备忘单:
s3.amazonaws.com/assets.datacamp.com/blog_assets/Numpy_Python_Cheat_Sheet.pdf -
Pandas 备忘单:
pandas.pydata.org/Pandas_Cheat_Sheet.pdf -
Scikit-Learn 备忘单:
s3.amazonaws.com/assets.datacamp.com/blog_assets/Scikit_Learn_Cheat_Sheet_Python.pdf
机器学习算法、数学、概率和统计的备忘单可以在这里找到:
-
微积分备忘单:
ml-cheatsheet.readthedocs.io/en/latest/calculus.html -
4 页线性代数:
minireference.com/static/tutorials/linear_algebra_in_4_pages.pdf -
概率与统计备忘单:
web.mit.edu/~csvoss/Public/usabo/stats_handout.pdf -
Python 中的 15 种统计假设检验(备忘单):
machinelearningmastery.com/statistical-hypothesis-tests-in-python-cheat-sheet/
若想了解更多机器学习算法、线性代数、微积分、概率和统计的资源,可以参考以下内容:
-
傅里叶变换互动指南:
betterexplained.com/articles/an-interactive-guide-to-the-fourier-transform/ -
Joseph Blitzstein 和 Jessica Hwang 的《概率导论》:
www.amazon.com/Introduction-Probability-Chapman-Statistical-Science/dp/1138369918 -
Gareth James、Daniela Witten、Trevor Hastie 和 Robert Tibshirani 的《统计学习导论》:
www.statlearning.com/ -
傅里叶变换(scipy.fft):
docs.scipy.org/doc/scipy/reference/tutorial/fft.html -
用 numpy 的傅里叶变换找到时间序列最可能的周期性?(StackOverflow 问题):
stackoverflow.com/questions/44803225/find-likeliest-periodicity-for-time-series-with-numpys-fourier-transform -
数值计算很有趣(GitHub):
github.com/eka-foundation/numerical-computing-is-fun -
黑客的概率编程与贝叶斯方法(GitHub):
github.com/CamDavidsonPilon/Probabilistic-Programming-and-Bayesian-Methods-for-Hackers -
理论可视化(概率与统计的视觉介绍):
seeing-theory.brown.edu/index.html -
思考统计:Python 中的探索性数据分析:
greenteapress.com/thinkstats2/html/index.html
关于 Python 和编程的一些杂项阅读资料可以在这里找到:
-
定义自定义魔法命令(IPython):
ipython.readthedocs.io/en/stable/config/custommagics.html -
Flask 教程(用 Python 构建 Web 应用程序):
flask.palletsprojects.com/en/1.1.x/tutorial/ -
IPython 教程:
ipython.readthedocs.io/en/stable/interactive/
相关的 MOOC 和视频可以在这里找到:
-
高级优化(哈佛大学):
online-learning.harvard.edu/course/advanced-optimization -
线性代数——基础到前沿(edX):
www.edx.org/course/linear-algebra-foundations-to-frontiers -
机器学习(与 Andrew Ng 一起的 Coursera 课程):
www.coursera.org/learn/machine-learning -
机器学习数学(Coursera):
www.coursera.org/specializations/mathematics-machine-learning -
Statistics 110(哈佛大学)在 YouTube 上:
www.youtube.com/playlist?list=PL2SOU6wwxB0uwwH80KTQ6ht66KWxbzTIo -
《统计学习(斯坦福)》:
online.stanford.edu/courses/sohs-ystatslearning-statistical-learning
以下书籍对于获取 Python 语言各个方面的经验非常有帮助:
-
《用 Python 自动化无聊的事情》by Al Sweigart:
automatetheboringstuff.com/ -
《Python3 硬核学习》by Zed A. Shaw:
learnpythonthehardway.org/python3/preface.html
Python 机器学习书籍和培训资源可以在这里找到:
-
《动手学机器学习:Scikit-Learn 与 TensorFlow Jupyter 笔记本》:
github.com/ageron/handson-ml -
《Python 机器学习导论:数据科学家的指南》by Andreas C. Müller 和 Sarah Guido:
www.amazon.com/Introduction-Machine-Learning-Python-Scientists/dp/1449369413 -
来自 Scikit-learn 核心开发者 Andreas Müller 的 ML 训练资源(会议上讲授的培训):
github.com/amueller?tab=repositories&q=ml-training&type=&language=&sort= -
《Python 机器学习》第三版 by Sebastian Raschka 和 Vahid Mirjalili:
www.packtpub.com/product/python-machine-learning-third-edition/9781789955750
以下资源介绍了机器学习模型中的偏见和公平性概念,以及缓解偏见的工具:
-
AI 公平性 360(IBM):
developer.ibm.com/technologies/artificial-intelligence/projects/ai-fairness-360/ -
《数学毁灭武器》by Cathy O'Neil:
www.amazon.com/Weapons-Math-Destruction-Increases-Inequality/dp/0553418815 -
《What-If Tool(Google)》:
pair-code.github.io/what-if-tool/
获取关于交互式和动画可视化的资源可以在这里找到:
-
《Holoviews 入门》:
coderzcolumn.com/tutorials/data-science/getting-started-with-holoviews-basic-plotting -
《如何在 Python 中创建动画图表》:
towardsdatascience.com/how-to-create-animated-graphs-in-python-bb619cc2dec1 -
Python 中的交互式数据可视化与 Bokeh:
realpython.com/python-data-visualization-bokeh/ -
PyViz 教程: https://pyviz.org/tutorials/index.html
第十四章:解答
每章习题的解答可以在github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/solutions的相应文件夹中找到。
附录
数据分析工作流程
下图展示了一个通用的数据分析工作流程,从数据收集和处理到得出结论并决定下一步行动:

图 1 – 通用的数据分析工作流程
选择适当的可视化方式
在创建数据可视化时,选择适当的图表类型至关重要;以下图示可以帮助选择正确的可视化方式:

图 2 – 选择适当可视化方式的流程图
机器学习工作流程
下图总结了从数据收集和数据分析到模型训练与评估的机器学习模型构建工作流程:

图 3 – 构建机器学习模型过程概述

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助你规划个人发展并推动职业生涯。欲了解更多信息,请访问我们的网站。
第十五章:为什么要订阅?
-
通过来自超过 4,000 位行业专家的实用电子书和视频,减少学习时间,增加编程时间
-
通过为你量身定制的技能计划,提升你的学习效果
-
每月获得一本免费的电子书或视频
-
完全可搜索,方便快速获取重要信息
-
复制、粘贴、打印和收藏内容
你知道 Packt 提供每本书的电子书版本,支持 PDF 和 ePub 格式吗?你可以在 packt.com 升级为电子书版本,作为纸质书用户,你还可以享受电子书版的折扣。有关更多详情,请通过 customercare@packtpub.com 与我们联系。
在 www.packt.com 上,你还可以阅读一系列免费的技术文章,注册各种免费的新闻通讯,并获得 Packt 图书和电子书的独家折扣和优惠。
你可能会喜欢的其他书籍
如果你喜欢这本书,可能会对 Packt 的这些其他书籍感兴趣:
Python 数据分析(第三版)
Avinash Navlani, Armando Fandango, Ivan Idris
ISBN:978-1-78995-524-8
-
探索数据科学及其各种过程模型
-
使用 NumPy 和 pandas 进行数据操作,以便进行汇总、清洗和处理缺失值
-
使用 Matplotlib、Seaborn 和 Bokeh 创建交互式可视化
-
检索、处理并存储各种格式的数据
-
使用 pandas 和 scikit-learn 了解数据预处理和特征工程
使用 scikit-learn 和科学 Python 工具包进行实践机器学习
Tarek Amr
ISBN:978-1-83882-604-8
-
了解何时使用监督学习、无监督学习或强化学习算法
-
了解如何收集和准备数据,进行机器学习任务
-
处理不平衡数据,并优化算法以达到偏差和方差的平衡
-
应用监督和无监督算法,克服各种机器学习挑战
-
使用最佳实践调优算法的超参数,攻击 web 和数据库服务器以提取数据
-
了解如何使用神经网络进行分类和回归
-
构建、评估并将你的机器学习解决方案部署到生产环境
Packt 正在寻找像你这样的作者
如果您有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天就申请。我们已经与成千上万的开发者和技术专家合作,帮助他们与全球技术社区分享见解。您可以提交一般申请,申请我们正在招募作者的热门话题,或提交您自己的创意。
留下评论 - 让其他读者知道您的想法
请通过在购买该书的网站上留下评论,与他人分享您对这本书的看法。如果您是从亚马逊购买的这本书,请在该书的亚马逊页面上留下您的诚实评价。这非常重要,因为其他潜在读者可以通过您的公正意见来做出购买决策,我们也能了解顾客对我们产品的看法,而我们的作者可以看到您对他们与 Packt 合作创作的书籍的反馈。这将只占用您几分钟的时间,但对其他潜在顾客、我们的作者以及 Packt 来说都非常有价值。谢谢!


浙公网安备 33010602011771号