Python-数据科学手册第二版-全-
Python 数据科学手册第二版(全)
原文:
zh.annas-archive.org/md5/051facaf2908ae8198253e3a14b09ec1
译者:飞龙
前言
什么是数据科学?
这是一本关于使用 Python 进行数据科学的书籍,这立即引发了一个问题:什么是数据科学?这是一个令人惊讶地难以定义的问题,特别是考虑到这个术语已经如此普遍。激烈的批评者们曾将其视为一个多余的标签(毕竟,哪门科学不涉及数据?)或者一个简单的流行语,只存在于简历中或吸引过度热衷的技术招聘者的眼球。
在我看来,这些批评忽略了一些重要的东西。尽管数据科学带有被夸大的光环,但它或许是我们在工业和学术界越来越重要的许多应用中拥有的跨学科技能集合的最佳标签。跨学科的部分至关重要:在我看来,关于数据科学最好的现有定义可以通过德鲁·康威于 2010 年 9 月在他的博客上首次发布的数据科学维恩图来说明(图 P-1)。
虽然一些交集标签有些玩味,但这张图表捕捉到了人们说“数据科学”时我认为的本质:它本质上是一门跨学科的学科。数据科学包括三个明确且有重叠的领域:统计学家的技能,他们知道如何对数据集进行建模和总结(这些数据集越来越大);计算机科学家的技能,能够设计和使用算法来高效存储、处理和可视化这些数据;以及领域专业知识,我们可以称之为在一个学科中的“经典”训练,既需要制定正确的问题,又需要将其答案放入背景中。
在这种情况下,我鼓励您把数据科学看作不是要学习的新知识领域,而是可以应用于您当前专业领域的一组新技能。无论您是在报告选举结果、预测股票回报、优化在线广告点击、识别显微镜照片中的微生物、寻找新的天文物体类别,还是在任何其他领域处理数据,本书的目标是赋予您提出和回答有关所选主题领域的新问题的能力。
图 P-1. 德鲁·康威的数据科学维恩图(来源:德鲁·康威,获得授权使用)
这本书是为谁写的?
在我在华盛顿大学以及各种技术会议和聚会上的教学中,我经常听到一个最常见的问题:“我应该如何学习 Python?” 提问的人通常是技术背景的学生、开发人员或研究人员,他们通常已经具有编写代码和使用计算和数值工具的坚实背景。大多数这些人不是想学习 Python 本身,而是想学习这门语言,以便将其作为数据密集型和计算科学的工具使用。尽管针对这一受众的大量视频、博客文章和教程在线上都可以找到,但长期以来我一直对这个问题缺乏一个单一的好答案感到沮丧;这就是这本书的灵感来源。
本书不旨在介绍 Python 或编程的基础知识;我假设读者已经熟悉 Python 语言,包括定义函数、赋值变量、调用对象的方法、控制程序流程以及其他基本任务。相反,它旨在帮助 Python 用户学习如何有效地使用 Python 的数据科学堆栈——如以下章节中提到的库和相关工具——来有效地存储、操作和从数据中获取见解。
为什么选择 Python?
过去几十年间,Python 已经成为科学计算任务的一流工具,包括大数据集的分析和可视化。这可能让 Python 语言的早期支持者感到惊讶:语言本身并不是专门设计用于数据分析或科学计算。Python 在数据科学中的实用性主要源于第三方包的大规模和活跃生态系统:NumPy用于处理同质数组数据,Pandas用于处理异构和标记数据,SciPy用于常见的科学计算任务,Matplotlib用于出版质量的可视化,IPython用于交互式执行和代码共享,Scikit-Learn用于机器学习,以及许多其他工具,这些工具将在接下来的页面中提到。
如果您正在寻找关于 Python 语言本身的指南,我建议参考本书的姊妹项目 Python 旋风之旅。这份简短的报告为熟悉一种或多种其他编程语言的数据科学家提供了 Python 语言基本功能的概览。
书籍大纲
本书的每个编号部分都专注于贡献 Python 数据科学故事基础部分的特定包或工具,并分为短小而自包含的章节,每章讨论一个单一概念:
-
第一部分,“Jupyter: 超越普通 Python” 介绍了 IPython 和 Jupyter。这些包提供了许多使用 Python 的数据科学家工作的计算环境。
-
第二部分,“NumPy 简介”专注于 NumPy 库,它提供了
ndarray
,用于 Python 中密集数据数组的高效存储和操作。 -
第三部分,“使用 Pandas 进行数据操作”介绍了 Pandas 库,它提供了
DataFrame
,用于 Python 中标记/列数据的高效存储和操作。 -
第四部分,“使用 Matplotlib 进行可视化”专注于 Matplotlib,这是一个提供灵活数据可视化能力的 Python 库。
-
第五部分,“机器学习”侧重于 Scikit-Learn 库,它提供了最重要和已建立的机器学习算法的高效且干净的 Python 实现。
PyData 世界显然比这六个软件包要大得多,而且它每天都在增长。考虑到这一点,我在本书中的每一个地方都尽力提供有关其他有趣工作、项目和软件包的参考信息,它们正在推动 Python 所能做的事情的边界。尽管如此,我专注的软件包目前对 Python 数据科学领域的许多工作至关重要,我预计它们在生态系统继续围绕它们增长的同时将继续保持重要性。
安装考虑事项
安装 Python 及其科学计算库套件是很简单的。本节将概述在设置计算机时需要考虑的一些事项。
尽管有各种安装 Python 的方式,但我建议用于数据科学的一个是 Anaconda 发行版,无论您使用 Windows、Linux 还是 macOS,它的工作方式都类似。Anaconda 发行版有两种版本:
-
Miniconda提供了 Python 解释器本身,以及一个名为conda的命令行工具,它作为一个跨平台的 Python 软件包管理器,类似于 Linux 用户熟悉的 apt 或 yum 工具。
-
Anaconda包含 Python 和 conda,并且还捆绑了一套其他预装的面向科学计算的软件包。由于这一捆绑包的大小,安装过程将消耗几个千兆字节的磁盘空间。
Anaconda 中包含的任何软件包也可以手动安装在 Miniconda 之上;因此,我建议从 Miniconda 开始。
要开始,请下载并安装 Miniconda 包 —— 确保选择带有 Python 3 的版本 —— 然后安装本书中使用的核心包:
[~]$ conda install numpy pandas scikit-learn matplotlib seaborn jupyter
在整个文本中,我们还将使用 Python 科学生态系统中的其他更专业的工具;安装通常只需输入conda install *packagename*
。如果您遇到默认 conda 频道中不可用的软件包,请务必查看conda-forge,这是一个广泛的、社区驱动的 conda 软件包存储库。
要了解 conda 的更多信息,包括关于创建和使用 conda 环境的信息(我强烈推荐),请参阅conda 的在线文档。
本书中使用的约定
本书使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
常量宽度
用于程序列表以及段落内引用程序元素,例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
常量宽度粗体
显示用户应该按照字面意义输入的命令或其他文本。
常量宽度斜体
显示应替换为用户提供值或由上下文确定值的文本。
注意
这个元素表示一个一般的注意事项。
使用代码示例
补充材料(代码示例、图表等)可在http://github.com/jakevdp/PythonDataScienceHandbook下载。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
这本书旨在帮助您完成工作。一般情况下,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了大部分代码,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O'Reilly 书籍中的示例则需要许可。引用本书并引用示例代码来回答问题无需许可。将本书中大量示例代码整合到产品文档中则需要许可。
我们感谢您的致谢,但一般情况下不需要署名。致谢通常包括书名、作者、出版社和 ISBN。例如:"Python 数据科学手册,第 2 版,作者 Jake VanderPlas(O'Reilly)。版权所有 2023 年 Jake VanderPlas,978-1-098-12122-8。"
如果您觉得您对代码示例的使用超出了公平使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。
O'Reilly 在线学习
注意
超过 40 年来,O'Reilly Media已经为企业提供技术和商业培训、知识和见解,帮助公司取得成功。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台让您随时访问现场培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多个出版商的大量文本和视频。更多信息,请访问https://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送至出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为本书建立了一个网页,列出勘误、示例以及任何额外信息。您可以访问该网页:https://oreil.ly/python-data-science-handbook。
发送电子邮件至bookquestions@oreilly.com以评论或询问有关本书的技术问题。
关于我们的书籍和课程的新闻和信息,请访问https://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media。
在 Twitter 上关注我们:https://twitter.com/oreillymedia。
在 YouTube 上关注我们:https://youtube.com/oreillymedia。
第一部分:Jupyter:超越普通 Python
Python 有很多开发环境的选择,人们经常问我在自己的工作中使用哪一个。我的答案有时会让人惊讶:我偏爱的环境是IPython加上一个文本编辑器(在我这里,取决于我的心情,可能是 Emacs 或 VSCode)。Jupyter 起源于 IPython shell,由 Fernando Perez 在 2001 年创建,作为增强版 Python 解释器,并已发展成为一个旨在提供“研究计算整个生命周期工具”的项目,用 Perez 的话来说。如果 Python 是我们数据科学任务的引擎,你可以把 Jupyter 看作是交互控制面板。
作为 Python 的一个有用的交互界面,Jupyter还为语言提供了一些有用的语法增强;我们将在这里介绍其中最有用的一些增强功能。也许 Jupyter 项目提供的最熟悉的界面是 Jupyter Notebook,这是一个基于浏览器的环境,非常适合开发、协作、共享,甚至是发布数据科学结果。作为笔记本格式有用性的一个例子,无需看得更远,就在你正在阅读的这页:这本书的整个手稿都是作为一组 Jupyter 笔记本来撰写的。
本书的这一部分将首先逐步介绍一些对数据科学实践有用的 Jupyter 和 IPython 特性,特别是它们提供的超出 Python 标准特性的语法。接下来,我们将更深入地讨论一些更有用的魔术命令,这些命令可以加快创建和使用数据科学代码中的常见任务。最后,我们将涉及一些使笔记本在理解数据和共享结果方面变得有用的特性。
第一章:在 IPython 和 Jupyter 中开始
在编写数据科学的 Python 代码时,我通常会在三种工作模式之间切换:我使用 IPython shell 尝试短命令序列,使用 Jupyter Notebook 进行更长时间的交互分析和与他人共享内容,并使用诸如 Emacs 或 VSCode 的交互式开发环境(IDE)创建可重复使用的 Python 包。本章重点介绍前两种模式:IPython shell 和 Jupyter Notebook。虽然软件开发中使用 IDE 是数据科学家工具箱中的重要第三工具,但我们在此不会直接讨论它。
启动 IPython Shell
此书大部分的文本,包括本部分,都不是设计用于被被动吸收的。我建议您在阅读时跟随并尝试所涵盖的工具和语法:通过这样做建立的肌肉记忆将比仅仅阅读要有用得多。首先通过在命令行上键入 ipython
来启动 IPython 解释器;或者,如果您安装了像 Anaconda 或 EPD 这样的发行版,可能会有一个特定于您系统的启动器。
一旦完成此操作,您应该看到如下提示:
Python 3.9.2 (v3.9.2:1a79785e3e, Feb 19 2021, 09:06:10)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.21.0 -- An enhanced Interactive Python. Type '?' for help.
In [1]:
准备好了,现在可以跟着进行。
启动 Jupyter Notebook
Jupyter Notebook 是一个基于浏览器的图形界面,用于 IPython shell,并在此基础上提供了丰富的动态显示功能。除了执行 Python/IPython 语句外,笔记本还允许用户包括格式化文本、静态和动态可视化、数学方程、JavaScript 小部件等。此外,这些文档可以以一种方式保存,让其他人能够打开它们并在自己的系统上执行代码。
尽管您将通过 Web 浏览器窗口查看和编辑 Jupyter 笔记本,但它们必须连接到正在运行的 Python 进程以执行代码。您可以通过在系统 shell 中运行以下命令来启动此进程(称为“内核”):
$ jupyter lab
此命令启动一个本地 Web 服务器,该服务器将对您的浏览器可见。它立即输出一个显示其正在执行的日志;该日志看起来会像这样:
$ jupyter lab
[ServerApp] Serving notebooks from local directory: /Users/jakevdp/ \
PythonDataScienceHandbook
[ServerApp] Jupyter Server 1.4.1 is running at:
[ServerApp] http://localhost:8888/lab?token=dd852649
[ServerApp] Use Control-C to stop this server and shut down all kernels
(twice to skip confirmation).
执行命令后,默认浏览器应自动打开并导航到列出的本地 URL;确切的地址将取决于您的系统。如果浏览器未自动打开,您可以手动打开一个窗口并输入此地址(http://localhost:8888/lab/ 作为示例)。
IPython 中的帮助和文档
如果您在本章中不阅读其他部分,请阅读此部分:我发现在我的日常工作流程中,讨论的工具是 IPython 最具变革性的贡献。
当技术上熟悉的人被要求帮助朋友、家人或同事解决计算机问题时,大多数时候这不是知道答案的问题,而是知道如何快速找到未知答案的问题。在数据科学中也是如此:可搜索的网络资源,如在线文档、邮件列表线程和 Stack Overflow 答案,包含了丰富的信息,甚至(尤其是?)关于您之前搜索过的主题。成为数据科学的有效从业者,不仅仅是记住每种可能情况下应该使用的工具或命令,更重要的是学会如何有效地查找您不知道的信息,无论是通过网络搜索引擎还是其他方式。
IPython/Jupyter 最有用的功能之一是缩小用户与文档之间的差距,帮助用户有效地完成工作。尽管网络搜索在回答复杂问题方面仍然发挥着作用,但仅通过 IPython 就能找到大量信息。IPython 可以在几个按键中帮助回答的问题的一些示例包括:
-
我如何调用这个函数?它有哪些参数和选项?
-
Python 对象的源代码是什么样子的?
-
我导入的这个包里面有什么?
-
这个对象有哪些属性或方法?
在 IPython shell 和 Jupyter Notebook 中提供的工具可以快速访问这些信息,主要包括使用 ?
字符查看文档、使用 ??
字符查看源代码,以及使用 Tab 键进行自动完成。
使用 ?
访问文档
Python 语言及其数据科学生态系统是为用户设计的,其中一个重要部分是访问文档。每个 Python 对象都包含对称为 docstring 的字符串的引用,大多数情况下将包含对象的简明摘要及其使用方法。Python 有一个内置的 help
函数,可以访问这些信息并打印结果。例如,要查看内置 len
函数的文档,请执行以下操作:
In [1]: help(len)
Help on built-in function len in module builtins:
len(obj, /)
Return the number of items in a container.
根据您的解释器不同,此信息可能会显示为内联文本或在单独的弹出窗口中。
因为查找对象帮助是如此常见和有用,IPython 和 Jupyter 引入了 ?
字符作为访问此文档和其他相关信息的简写:
In [2]: len?
Signature: len(obj, /)
Docstring: Return the number of items in a container.
Type: builtin_function_or_method
此表示法适用于几乎任何内容,包括对象方法:
In [3]: L = [1, 2, 3]
In [4]: L.insert?
Signature: L.insert(index, object, /)
Docstring: Insert object before index.
Type: builtin_function_or_method
或者甚至是对象本身,具有其类型的文档:
In [5]: L?
Type: list
String form: [1, 2, 3]
Length: 3
Docstring:
Built-in mutable sequence.
If no argument is given, the constructor creates a new empty list.
The argument must be an iterable if specified.
更重要的是,即使是您自己创建的函数或其他对象,这也同样适用!在这里,我们将定义一个带有文档字符串的小函数:
In [6]: def square(a):
....: """Return the square of a."""
....: return a ** 2
....:
请注意,为我们的函数创建文档字符串时,我们只需将字符串字面值放在第一行。由于文档字符串通常是多行的,按照惯例,我们使用了 Python 的三引号符号来表示多行字符串。
现在我们将使用 ?
来找到这个文档字符串:
In [7]: square?
Signature: square(a)
Docstring: Return the square of a.
File: <ipython-input-6>
Type: function
通过 docstrings 快速访问文档是你应该养成的习惯之一,这是你应该始终将这样的内联文档添加到你编写的代码中的原因之一。
使用 ?? 访问源代码
因为 Python 语言非常易读,通过阅读你感兴趣的对象的源代码通常可以获得更深入的见解。IPython 和 Jupyter 通过双问号 (??
) 提供了直接查看源代码的快捷方式:
In [8]: square??
Signature: square(a)
Source:
def square(a):
"""Return the square of a."""
return a ** 2
File: <ipython-input-6>
Type: function
对于像这样的简单函数,双问号可以快速了解其底层细节。
如果你经常使用这个功能,你会注意到有时 ??
后缀不会显示任何源代码:这通常是因为所讨论的对象不是用 Python 实现的,而是用 C 或其他编译扩展语言实现的。如果是这种情况,??
后缀会给出与 ?
后缀相同的输出。你会在许多 Python 内置对象和类型中特别发现这种情况,包括前面提到的 len
函数:
In [9]: len??
Signature: len(obj, /)
Docstring: Return the number of items in a container.
Type: builtin_function_or_method
使用 ?
和/或 ??
是查找任何 Python 函数或模块功能的强大快速方式。
使用 Tab 补全探索模块
另一个有用的界面是使用 Tab 键自动完成和探索对象、模块和命名空间的内容。在接下来的示例中,我将使用 <TAB>
来指示何时按 Tab 键。
对象内容的 Tab 补全
每个 Python 对象都有与之关联的各种属性和方法。与前面提到的 help
函数类似,Python 有一个内置的 dir
函数,返回这些属性和方法的列表,但实际使用中,Tab 补全接口更容易使用。要查看对象的所有可用属性列表,可以输入对象名称,后跟句点 (.
) 字符和 Tab 键:
In [10]: L.<TAB>
append() count insert reverse
clear extend pop sort
copy index remove
要缩小列表,可以输入名称的第一个或几个字符,然后按 Tab 键查找匹配的属性和方法:
In [10]: L.c<TAB>
clear() count()
copy()
In [10]: L.co<TAB>
copy() count()
如果只有一个选项,按 Tab 键将为您完成该行。例如,以下内容将立即替换为 L.count
:
In [10]: L.cou<TAB>
尽管 Python 没有严格强制区分公共/外部属性和私有/内部属性的区别,按照惯例,前置下划线用于表示后者。为了清晰起见,默认情况下省略了这些私有方法和特殊方法,但可以通过显式输入下划线来列出它们:
In [10]: L._<TAB>
__add__ __delattr__ __eq__
__class__ __delitem__ __format__()
__class_getitem__() __dir__() __ge__ >
__contains__ __doc__ __getattribute__
为简洁起见,我只显示了输出的前几列。大多数都是 Python 的特殊双下划线方法(通常被昵称为“dunder”方法)。
导入时的 Tab 补全
在从包中导入对象时,Tab 补全也非常有用。在这里,我们将用它来查找以 co
开头的 itertools
包中的所有可能导入:
In [10]: from itertools import co<TAB>
combinations() compress()
combinations_with_replacement() count()
同样,您可以使用 tab 补全来查看系统上可用的导入(这将根据哪些第三方脚本和模块对您的 Python 会话可见而变化):
In [10]: import <TAB>
abc anyio
activate_this appdirs
aifc appnope >
antigravity argon2
In [10]: import h<TAB>
hashlib html
heapq http
hmac
超出 tab 补全:通配符匹配
如果您知道对象或属性名称的前几个字符,tab 补全是有用的,但如果您想要匹配名称中间或末尾的字符,则帮助不大。对于这种用例,IPython 和 Jupyter 提供了使用*
字符进行名称通配符匹配的方法。
例如,我们可以使用这个来列出命名空间中名称以Warning
结尾的每个对象:
In [10]: *Warning?
BytesWarning RuntimeWarning
DeprecationWarning SyntaxWarning
FutureWarning UnicodeWarning
ImportWarning UserWarning
PendingDeprecationWarning Warning
ResourceWarning
注意,*
字符匹配任何字符串,包括空字符串。
同样,假设我们正在寻找一个字符串方法,其中包含单词find
在其名称中的某处。我们可以这样搜索:
In [11]: str.*find*?
str.find
str.rfind
我发现这种灵活的通配符搜索可以帮助我在了解新包或重新熟悉熟悉的包时找到特定的命令很有用。
IPython Shell 中的键盘快捷键
如果您在计算机上花费了任何时间,您可能已经发现在工作流程中使用键盘快捷键的用途。最熟悉的可能是 Cmd-c 和 Cmd-v(或 Ctrl-c 和 Ctrl-v),用于在各种程序和系统中复制和粘贴。高级用户往往会走得更远:流行的文本编辑器如 Emacs、Vim 和其他编辑器通过复杂的按键组合为用户提供了一系列不可思议的操作。
IPython shell 不会走这么远,但在输入命令时提供了许多快速导航的键盘快捷键。虽然其中一些快捷键确实在基于浏览器的笔记本中起作用,但本节主要讨论 IPython shell 中的快捷键。
一旦您习惯了这些,它们可以非常有用,可以快速执行某些命令,而无需将手从“home”键盘位置移开。如果您是 Emacs 用户或者有 Linux 风格 shell 的使用经验,则以下内容将非常熟悉。我将这些快捷键分为几个类别:导航快捷键、文本输入快捷键、命令历史快捷键和其他快捷键。
导航快捷键
虽然使用左右箭头键在行内向前向后移动是很明显的,但还有其他选项不需要移动手从键盘的“home”位置:
按键 | 动作 |
---|---|
Ctrl-a | 将光标移动到行首 |
Ctrl-e | 将光标移动到行尾 |
Ctrl-b 或左箭头键 | 将光标向后移动一个字符 |
Ctrl-f 或右箭头键 | 将光标向前移动一个字符 |
文本输入快捷键
虽然每个人都习惯使用退格键删除前一个字符,但经常需要稍微扭动手指才能按到该键,而且它一次只能删除一个字符。在 IPython 中,有几个快捷键可用于删除您正在输入的文本的某些部分;其中最有用的是删除整行文本的命令。如果你发现自己使用 Ctrl-b 和 Ctrl-d 的组合来删除前一个字符,而不是按退格键,那么你将会知道这些快捷键已经变得本能了!
按键 | 动作 |
---|---|
退格键 | 删除行内前一个字符 |
Ctrl-d | 删除行内下一个字符 |
Ctrl-k | 从光标处剪切文本到行尾 |
Ctrl-u | 从行首剪切文本到光标处 |
Ctrl-y | 拷贝(即粘贴)之前被剪切的文本 |
Ctrl-t | 转置(即交换)前两个字符 |
命令历史快捷键
或许在这里讨论的最有影响力的快捷键是 IPython 提供的用于导航命令历史记录的快捷键。此命令历史记录超出了您当前的 IPython 会话:您的整个命令历史记录存储在 IPython 配置文件目录中的 SQLite 数据库中。
访问先前的命令最简单的方法是使用上箭头和下箭头键来浏览历史记录,但还有其他选项:
按键 | 动作 |
---|---|
Ctrl-p(或向上箭头键) | 访问历史记录中的上一个命令 |
Ctrl-n(或向下箭头键) | 访问历史记录中的下一个命令 |
Ctrl-r | 通过命令历史记录进行逆向搜索 |
逆向搜索选项可能特别有用。回想一下,早些时候我们定义了一个名为 square
的函数。让我们从一个新的 IPython shell 中反向搜索我们的 Python 历史记录,并再次找到这个定义。当您在 IPython 终端中按 Ctrl-r 时,您将看到以下提示:
In [1]:
(reverse-i-search)`':
如果您在此提示符下开始键入字符,IPython 将自动填充最近的命令(如果有),与这些字符匹配的:
In [1]:
(reverse-i-search)`sqa': square??
在任何时候,您都可以添加更多字符以完善搜索,或者再次按 Ctrl-r 以进一步搜索与查询匹配的另一个命令。如果您之前跟随操作,再按两次 Ctrl-r 会得到以下结果:
In [1]:
(reverse-i-search)`sqa': def square(a):
"""Return the square of a"""
return a ** 2
找到您要查找的命令后,按回车键搜索将结束。然后,您可以使用检索到的命令并继续会话:
In [1]: def square(a):
"""Return the square of a"""
return a ** 2
In [2]: square(2)
Out[2]: 4
请注意,您可以使用 Ctrl-p/Ctrl-n 或上/下箭头键以类似的方式搜索历史记录,但只能通过匹配行开头的字符来搜索。也就是说,如果您键入 def
然后按 Ctrl-p,它将找到您历史记录中以字符 def
开头的最近的命令(如果有的话)。
杂项快捷键
最后,还有一些杂项快捷键不属于前面提到的任何类别,但仍然值得知道:
按键 | 动作 |
---|---|
Ctrl-l | 清除终端屏幕 |
Ctrl-c | 中断当前 Python 命令 |
Ctrl-d | 退出 IPython 会话 |
特别是 Ctrl-c 快捷键在你不小心启动了一个运行时间非常长的任务时非常有用。
虽然这里讨论的一些快捷键起初可能显得有点晦涩,但是通过实践,它们很快就会变得自动化。一旦你培养了那种肌肉记忆,我相信你甚至会希望它们在其他情境中也能用到。
第二章:增强交互功能
IPython 和 Jupyter 的大部分功能来自它们提供的额外交互工具。本章将涵盖其中一些工具,包括所谓的魔术命令,用于探索输入和输出历史记录的工具,以及与 Shell 交互的工具。
IPython 魔术命令
前一章展示了 IPython 如何让您高效、交互地使用和探索 Python。在这里,我们将开始讨论 IPython 在正常 Python 语法之上添加的一些增强功能。这些在 IPython 中被称为魔术命令,以%
字符为前缀。这些魔术命令设计精炼地解决标准数据分析中的各种常见问题。魔术命令有两种类型:行魔术,以单个%
前缀表示,作用于单行输入;单元格魔术,以双%%
前缀表示,作用于多行输入。我将在这里展示和讨论一些简短的示例,并稍后回到更专注地讨论几个有用的魔术命令。
运行外部代码:%run
随着您开始开发更多的代码,您可能会发现自己在 IPython 中进行交互式探索,以及在文本编辑器中存储希望重用的代码。与其在新窗口中运行此代码,不如在您的 IPython 会话中运行更方便。可以通过%run
魔术命令完成此操作。
例如,假设您创建了一个myscript.py文件,其中包含以下内容:
# file: myscript.py
def square(x):
"""square a number"""
return x ** 2
for N in range(1, 4):
print(f"{N} squared is {square(N)}")
你可以从你的 IPython 会话中执行以下操作:
In [1]: %run myscript.py
1 squared is 1
2 squared is 4
3 squared is 9
还要注意,在运行此脚本后,其中定义的任何函数都可以在您的 IPython 会话中使用:
In [2]: square(5)
Out[2]: 25
有几种选项可以微调代码的运行方式;您可以通过在 IPython 解释器中键入%run?
来查看正常的文档。
代码执行时间:%timeit
另一个有用的魔术函数示例是%timeit
,它将自动确定其后的单行 Python 语句的执行时间。例如,我们可能希望检查列表理解的性能:
In [3]: %timeit L = [n ** 2 for n in range(1000)]
430 µs ± 3.21 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
%timeit
的好处在于对于短命令,它会自动执行多次运行以获得更稳健的结果。对于多行语句,添加第二个%
符号将其转换为可以处理多行输入的单元格魔术。例如,这里是使用for
循环的等效构造:
In [4]: %%timeit
...: L = []
...: for n in range(1000):
...: L.append(n ** 2)
...:
484 µs ± 5.67 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
我们可以立即看到,在这种情况下,列表理解比等效的for
循环结构快大约 10%。我们将在“分析和计时代码”中探索%timeit
和其他计时和分析代码的方法。
魔术函数帮助:?,%magic 和%lsmagic
与普通的 Python 函数类似,IPython 的魔术函数有文档字符串,可以以标准方式访问这些有用的文档。例如,要查阅 %timeit
魔术函数的文档,只需输入以下内容:
In [5]: %timeit?
类似地,还可以访问其他函数的文档。要访问可用魔术函数的一般描述,包括一些示例,请输入以下内容:
In [6]: %magic
要快速简单地列出所有可用的魔术函数,请输入以下内容:
In [7]: %lsmagic
最后,我将提到,如果你愿意的话,定义自己的魔术函数非常简单。我不会在这里讨论它,但如果你有兴趣,可以参考 “更多 IPython 资源” 中列出的参考资料。
输入和输出历史
正如之前所见,IPython shell 允许你使用上下箭头键(或等效的 Ctrl-p/Ctrl-n 快捷键)访问先前的命令。此外,在 shell 和笔记本中,IPython 还提供了几种获取先前命令输出以及命令字符串版本的方法。我们将在这里探讨这些方法。
IPython 的 In 和 Out 对象
现在我想象你已经开始熟悉 IPython 使用的 In [1]:
/Out[1]:
样式的提示了。但事实证明,这些不仅仅是漂亮的装饰:它们提供了一种方法,让你可以访问当前会话中的先前输入和输出。假设我们开始一个看起来像这样的会话:
In [1]: import math
In [2]: math.sin(2)
Out[2]: 0.9092974268256817
In [3]: math.cos(2)
Out[3]: -0.4161468365471424
我们已经导入了内置的 math
包,然后计算了数字 2 的正弦和余弦。这些输入和输出显示在带有 In
/Out
标签的 shell 中,但是 IPython 实际上创建了一些名为 In
和 Out
的 Python 变量,这些变量会自动更新以反映这些历史记录:
In [4]: In
Out[4]: ['', 'import math', 'math.sin(2)', 'math.cos(2)', 'In']
In [5]: Out
Out[5]:
{2: 0.9092974268256817,
3: -0.4161468365471424,
4: ['', 'import math', 'math.sin(2)', 'math.cos(2)', 'In', 'Out']}
In
对象是一个列表,按顺序保存命令(列表的第一项是一个占位符,以便 In [1]
可以引用第一条命令):
In [6]: print(In[1])
import math
Out
对象不是一个列表,而是一个将输入编号映射到它们的输出(如果有的话)的字典:
In [7]: print(Out[2])
.9092974268256817
注意,并非所有操作都有输出:例如,import
语句和 print
语句不会影响输出。后者可能会令人惊讶,但如果考虑到 print
是一个返回 None
的函数,这是有道理的;为简洁起见,任何返回 None
的命令都不会添加到 Out
中。
当你想要与过去的结果交互时,这可能会很有用。例如,让我们使用先前计算的结果来检查 sin(2) ** 2
和 cos(2) ** 2
的总和:
In [8]: Out[2] ** 2 + Out[3] ** 2
Out[8]: 1.0
结果是 1.0
,这符合我们对这个著名的三角恒等式的预期。在这种情况下,使用这些先前的结果可能是不必要的,但如果你执行了一个非常昂贵的计算并且忘记将结果赋给一个变量,这可能会变得非常方便。
下划线快捷键和先前的输出
标准 Python shell 仅包含一种简单的快捷方式用于访问先前的输出:变量 _
(即一个下划线)会随先前的输出更新。这在 IPython 中也适用:
In [9]: print(_)
.0
但是 IPython 进一步扩展了这一点 — 您可以使用双下划线访问倒数第二个输出,并使用三个下划线访问倒数第三个输出(跳过任何没有输出的命令):
In [10]: print(__)
-0.4161468365471424
In [11]: print(___)
.9092974268256817
IPython 到此为止:超过三个下划线开始变得有点难以计数,此时通过行号更容易引用输出。
我还应该提一下另一个快捷方式 — Out[*X*]
的简写是 _*X*
(即一个下划线后跟行号):
In [12]: Out[2]
Out[12]: 0.9092974268256817
In [13]: _2
Out[13]: 0.9092974268256817
禁止输出
有时候,您可能希望抑制语句的输出(这在我们将在第四部分探索的绘图命令中可能最常见)。或者,您执行的命令会产生一个您不希望存储在输出历史记录中的结果,也许是因为在其他引用被移除时它可以被释放。抑制命令的输出最简单的方法是在行尾添加分号:
In [14]: math.sin(2) + math.cos(2);
结果将在不显示在屏幕上或存储在 Out
字典中的情况下计算:
In [15]: 14 in Out
Out[15]: False
相关的魔术命令
要一次访问一批先前的输入,%history
魔术命令非常有帮助。以下是如何打印前四个输入:
In [16]: %history -n 1-3
1: import math
2: math.sin(2)
3: math.cos(2)
如往常一样,您可以输入 %history?
以获取更多信息并查看可用选项的描述(有关 ?
功能的详细信息,请参见第一章)。其他有用的魔术命令包括 %rerun
,它将重新执行命令历史记录的某些部分,以及 %save
,它将命令历史记录的某个集合保存到文件中。
IPython 和 Shell 命令
在与标准 Python 解释器的交互工作时,一个令人沮丧的地方是需要在多个窗口之间切换以访问 Python 工具和系统命令行工具。IPython 弥合了这一差距,并为您提供了在 IPython 终端内直接执行 shell 命令的语法。这是通过感叹号实现的:在 !
后出现的任何内容将不会由 Python 内核执行,而是由系统命令行执行。
以下讨论假定您正在使用类 Unix 系统,如 Linux 或 macOS。接下来的一些示例在 Windows 上会失败,因为 Windows 默认使用不同类型的 shell,但如果您使用Windows 子系统 for Linux,这里的示例应该能正常运行。如果您对 shell 命令不熟悉,我建议您查看由始终优秀的软件教程基金会组织的Unix shell 教程。
Shell 快速入门
对于如何使用 shell/终端/命令行的全面介绍远远超出了本章的范围,但对于未接触过的人,我将在这里进行一个快速介绍。shell 是一种与计算机进行文本交互的方式。自从 20 世纪 80 年代中期以来,当微软和苹果推出了他们现在无处不在的图形操作系统的第一个版本时,大多数计算机用户通过熟悉的菜单选择和拖放操作与他们的操作系统交互。但是操作系统在这些图形用户界面之前就存在,主要通过文本输入的序列来控制:在提示符下,用户会输入一个命令,计算机会执行用户告诉它做的事情。那些早期的提示系统是今天大多数数据科学家仍在使用的 shell 和终端的前身。
对于不熟悉 shell 的人可能会问,为什么要费这个劲,当很多相同的结果可以通过简单点击图标和菜单来实现呢?一个 shell 用户可能会反问:为什么要去寻找图标和菜单项,而不是通过输入命令来更轻松地完成任务呢?虽然这听起来像是典型的技术偏好僵局,但当超越基本任务时,很快就会明显地感觉到 shell 在控制高级任务方面提供了更多的控制权——尽管学习曲线确实可能令人望而生畏。
例如,这里是一个 Linux/macOS shell 会话的示例,用户在其系统上探索、创建和修改目录和文件(osx:~ $
是提示符,$
之后的所有内容是键入的命令;以#
开头的文本仅用作描述,而不是您实际要键入的内容):
osx:~ $ echo "hello world" # echo is like Python's print function
hello world
osx:~ $ pwd # pwd = print working directory
/home/jake # This is the "path" that we're sitting in
osx:~ $ ls # ls = list working directory contents
notebooks projects
osx:~ $ cd projects/ # cd = change directory
osx:projects $ pwd
/home/jake/projects
osx:projects $ ls
datasci_book mpld3 myproject.txt
osx:projects $ mkdir myproject # mkdir = make new directory
osx:projects $ cd myproject/
osx:myproject $ mv ../myproject.txt ./ # mv = move file. Here we're moving the
# file myproject.txt from one directory
# up (../) to the current directory (./).
osx:myproject $ ls
myproject.txt
请注意,所有这些只是通过键入命令而不是点击图标和菜单来执行熟悉操作(导航目录结构、创建目录、移动文件等)的一种紧凑方式。仅仅几个命令(pwd
、ls
、cd
、mkdir
和cp
)就可以完成许多最常见的文件操作,但当您超越这些基础操作时,shell 方法真正显示其强大之处。
IPython 中的 Shell 命令
任何标准的 shell 命令都可以通过在其前面加上!
字符直接在 IPython 中使用。例如,ls
、pwd
和echo
命令可以如下运行:
In [1]: !ls
myproject.txt
In [2]: !pwd
/home/jake/projects/myproject
In [3]: !echo "printing from the shell"
printing from the shell
向 Shell 传递值和从 Shell 获取值
Shell 命令不仅可以从 IPython 中调用,还可以与 IPython 命名空间交互。例如,您可以使用赋值操作符=
将任何 shell 命令的输出保存到 Python 列表中:
In [4]: contents = !ls
In [5]: print(contents)
['myproject.txt']
In [6]: directory = !pwd
In [7]: print(directory)
['/Users/jakevdp/notebooks/tmp/myproject']
这些结果不会以列表的形式返回,而是以 IPython 中定义的特殊的 shell 返回类型返回:
In [8]: type(directory)
IPython.utils.text.SList
这看起来和行为很像 Python 列表,但具有额外的功能,比如grep
和fields
方法以及允许您以方便的方式搜索、过滤和显示结果的s
、n
和p
属性。有关这些信息,您可以使用 IPython 内置的帮助功能。
可以使用 {*varname*}
语法将 Python 变量传递到 shell 中,实现双向通信:
In [9]: message = "hello from Python"
In [10]: !echo {message}
hello from Python
大括号中包含变量名,这个变量名在 shell 命令中会被替换为变量的内容。
与 Shell 相关的魔术命令
如果你在 IPython 的 shell 命令中尝试了一段时间,你可能会注意到无法使用 !cd
来导航文件系统:
In [11]: !pwd
/home/jake/projects/myproject
In [12]: !cd ..
In [13]: !pwd
/home/jake/projects/myproject
这是因为笔记本中的 shell 命令是在一个临时子 shell 中执行的,这个 shell 并不保留命令之间的状态。如果你想更持久地改变工作目录,可以使用 %cd
魔术命令:
In [14]: %cd ..
/home/jake/projects
实际上,默认情况下你甚至可以不带 %
符号使用这些功能:
In [15]: cd myproject
/home/jake/projects/myproject
这被称为自动魔术函数,可以通过 %automagic
魔术函数切换执行这些命令时是否需要显式 %
符号。
除了 %cd
,还有其他可用的类似 shell 的魔术函数,如 %cat
、%cp
、%env
、%ls
、%man
、%mkdir
、%more
、%mv
、%pwd
、%rm
和 %rmdir
,如果 automagic
打开,这些命令都可以不带 %
符号使用。这使得你几乎可以像处理普通 shell 一样处理 IPython 提示符:
In [16]: mkdir tmp
In [17]: ls
myproject.txt tmp/
In [18]: cp myproject.txt tmp/
In [19]: ls tmp
myproject.txt
In [20]: rm -r tmp
在同一个终端窗口中访问 shell,与你的 Python 会话结合得更自然,减少了上下文切换。
第三章:调试和性能分析
除了前一章中讨论的增强交互工具外,Jupyter 还提供了许多探索和理解正在运行的代码的方式,例如通过跟踪逻辑错误或意外的慢执行。本章将讨论其中一些工具。
错误和调试
代码开发和数据分析始终需要一些试验和错误,而 IPython 包含了简化此过程的工具。本节将简要介绍控制 Python 异常报告的一些选项,然后探讨代码中调试错误的工具。
控制异常:%xmode
大多数情况下,当 Python 脚本失败时,它会引发异常。当解释器遇到这些异常时,可以从 Python 内部访问导致错误的原因,该信息可以在 traceback 中找到。通过 %xmode
魔术函数,IPython 允许您控制引发异常时打印的信息量。考虑以下代码:
In [1]: def func1(a, b):
return a / b
def func2(x):
a = x
b = x - 1
return func1(a, b)
In [2]: func2(1)
ZeroDivisionError Traceback (most recent call last)
<ipython-input-2-b2e110f6fc8f> in <module>()
----> 1 func2(1)
<ipython-input-1-d849e34d61fb> in func2(x)
5 a = x
6 b = x - 1
----> 7 return func1(a, b)
<ipython-input-1-d849e34d61fb> in func1(a, b)
1 def func1(a, b):
----> 2 return a / b
3
4 def func2(x):
5 a = x
ZeroDivisionError: division by zero
调用 func2
导致错误,并阅读打印的跟踪让我们能够准确地看到发生了什么。在默认模式下,此跟踪包括几行显示导致错误的每个步骤的上下文。使用 %xmode
魔术函数(缩写为 exception mode),我们可以更改打印的信息内容。
%xmode
接受一个参数,即模式,有三种可能性:Plain
、Context
和 Verbose
。默认是 Context
,给出类似刚才显示的输出。Plain
更紧凑,提供较少信息:
In [3]: %xmode Plain
Out[3]: Exception reporting mode: Plain
In [4]: func2(1)
Traceback (most recent call last):
File "<ipython-input-4-b2e110f6fc8f>", line 1, in <module>
func2(1)
File "<ipython-input-1-d849e34d61fb>", line 7, in func2
return func1(a, b)
File "<ipython-input-1-d849e34d61fb>", line 2, in func1
return a / b
ZeroDivisionError: division by zero
Verbose
模式添加了一些额外信息,包括调用的任何函数的参数:
In [5]: %xmode Verbose
Out[5]: Exception reporting mode: Verbose
In [6]: func2(1)
ZeroDivisionError Traceback (most recent call last)
<ipython-input-6-b2e110f6fc8f> in <module>()
----> 1 func2(1)
global func2 = <function func2 at 0x103729320>
<ipython-input-1-d849e34d61fb> in func2(x=1)
5 a = x
6 b = x - 1
----> 7 return func1(a, b)
global func1 = <function func1 at 0x1037294d0>
a = 1
b = 0
<ipython-input-1-d849e34d61fb> in func1(a=1, b=0)
1 def func1(a, b):
----> 2 return a / b
a = 1
b = 0
3
4 def func2(x):
5 a = x
ZeroDivisionError: division by zero
这些额外信息可以帮助您更准确地找出异常发生的原因。那么为什么不始终使用 Verbose
模式?随着代码变得复杂,这种回溯可能会变得非常长。根据情况,有时 Plain
或 Context
模式的简洁性更容易处理。
调试:当仅仅阅读跟踪不足以解决问题时
用于交互式调试的标准 Python 工具是 pdb
,即 Python 调试器。此调试器允许用户逐行步进代码,以查看可能导致更复杂错误的原因。其 IPython 增强版本是 ipdb
,即 IPython 调试器。
启动和使用这两个调试器有许多方法;我们在此处不会全面涵盖它们。请参考这两个实用工具的在线文档以了解更多信息。
在 IPython 中,也许最方便的调试接口是 %debug
魔术命令。如果在异常发生后调用它,它将自动在异常点打开一个交互式调试提示符。ipdb
提示符允许您查看堆栈的当前状态,探索可用变量,甚至运行 Python 命令!
让我们查看最近的异常,然后执行一些基本任务。我们将打印 a
和 b
的值,然后输入 quit
退出调试会话:
In [7]: %debug <ipython-input-1-d849e34d61fb>(2)func1()
1 def func1(a, b):
----> 2 return a / b
3
ipdb> print(a)
1
ipdb> print(b)
0
ipdb> quit
交互式调试器提供的远不止这些——我们甚至可以在堆栈中上下跳转,并探索那里的变量值:
In [8]: %debug <ipython-input-1-d849e34d61fb>(2)func1()
1 def func1(a, b):
----> 2 return a / b
3
ipdb> up <ipython-input-1-d849e34d61fb>(7)func2()
5 a = x
6 b = x - 1
----> 7 return func1(a, b)
ipdb> print(x)
1
ipdb> up <ipython-input-6-b2e110f6fc8f>(1)<module>()
----> 1 func2(1)
ipdb> down <ipython-input-1-d849e34d61fb>(7)func2()
5 a = x
6 b = x - 1
----> 7 return func1(a, b)
ipdb> quit
这使我们不仅可以快速找出错误的原因,还可以查看导致错误的函数调用。
如果您希望调试器在引发异常时自动启动,可以使用 %pdb
魔术函数来打开这种自动行为:
In [9]: %xmode Plain
%pdb on
func2(1)
Exception reporting mode: Plain
Automatic pdb calling has been turned ON
ZeroDivisionError: division by zero <ipython-input-1-d849e34d61fb>(2)func1()
1 def func1(a, b):
----> 2 return a / b
3
ipdb> print(b)
0
ipdb> quit
最后,如果您有一个希望以交互模式从头开始运行的脚本,可以使用命令 %run -d
运行它,并使用 next
命令逐行交互式地执行代码。
除了我在这里展示的常见和有用的命令外,还有许多可用于交互式调试的命令。表 3-1 包含了一些更多的描述。
表 3-1. 调试命令的部分列表
命令 | 描述 |
---|---|
l(ist) |
显示文件中的当前位置 |
h(elp) |
显示命令列表,或查找特定命令的帮助信息 |
q(uit) |
退出调试器和程序 |
c(ontinue) |
退出调试器,继续程序执行 |
n(ext) |
进入程序的下一步 |
<enter> |
重复上一个命令 |
p(rint) |
打印变量 |
s(tep) |
进入子例程 |
r(eturn) |
从子例程中返回 |
欲了解更多信息,请在调试器中使用 help
命令,或查看 ipdb
的在线文档。
代码分析和计时
在开发代码和创建数据处理流水线的过程中,通常可以在各种实现之间做出权衡。在开发算法的早期阶段,过于担心这些事情可能会适得其反。正如唐纳德·克努特所说,“我们应该忘记小的效率,大约 97%的时间:过早优化是一切邪恶的根源。”
但是一旦您的代码运行正常,深入了解其效率可能会有所帮助。有时检查特定命令或一系列命令的执行时间很有用;其他时候,检查多行过程并确定复杂操作系列中的瓶颈位置也很有用。IPython 提供了广泛的功能来进行此类代码的时间和性能分析。这里我们将讨论以下 IPython 魔术命令:
%time
时间单个语句的执行时间
%timeit
多次执行单个语句以获得更准确的时间
%prun
使用分析器运行代码
%lprun
使用逐行分析器运行代码
%memit
测量单个语句的内存使用情况
%mprun
使用逐行内存分析器运行代码
最后四个命令未捆绑在 IPython 中;要使用它们,您需要获取line_profiler
和memory_profiler
扩展,我们将在以下部分讨论它们。
计时代码片段:%timeit
和%time
我们在“IPython 魔法命令”的介绍中看到了%timeit
行魔法和%%timeit
单元魔法;这些可以用来计时代码片段的重复执行。
In [1]: %timeit sum(range(100))
1.53 µs ± 47.8 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
请注意,由于此操作非常快,%timeit
会自动执行大量重复。对于较慢的命令,%timeit
会自动调整并执行较少的重复:
In [2]: %%timeit
total = 0
for i in range(1000):
for j in range(1000):
total += i * (-1) ** j
536 ms ± 15.9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
有时重复操作并不是最佳选择。例如,如果我们有一个需要排序的列表,我们可能会被重复的操作误导;排序一个已经排序好的列表比排序一个未排序的列表快得多,因此重复操作会扭曲结果:
In [3]: import random
L = [random.random() for i in range(100000)]
%timeit L.sort()
Out[3]: 1.71 ms ± 334 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
对于此操作,%time
魔法函数可能是一个更好的选择。当命令运行时间较长时,短暂的系统相关延迟不太可能影响结果。让我们计时一个未排序和一个已排序列表的排序过程:
In [4]: import random
L = [random.random() for i in range(100000)]
print("sorting an unsorted list:")
%time L.sort()
Out[4]: sorting an unsorted list:
CPU times: user 31.3 ms, sys: 686 µs, total: 32 ms
Wall time: 33.3 ms
In [5]: print("sorting an already sorted list:")
%time L.sort()
Out[5]: sorting an already sorted list:
CPU times: user 5.19 ms, sys: 268 µs, total: 5.46 ms
Wall time: 14.1 ms
注意排序好的列表比无序列表排序快得多,但请注意,即使对于排序好的列表,使用%time
比%timeit
花费的时间也更长!这是因为%timeit
在幕后做了一些聪明的事情,防止系统调用干扰计时。例如,它阻止了未使用的 Python 对象的清理(称为垃圾收集),否则可能会影响计时。因此,%timeit
的结果通常明显比%time
的结果快。
对于%time
,与%timeit
一样,使用%%
单元魔法语法允许对多行脚本进行计时:
In [6]: %%time
total = 0
for i in range(1000):
for j in range(1000):
total += i * (-1) ** j
CPU times: user 655 ms, sys: 5.68 ms, total: 661 ms
Wall time: 710 ms
要获取有关%time
和%timeit
以及它们可用选项的更多信息,请使用 IPython 帮助功能(例如,在 IPython 提示符处键入%time?
)。
全脚本分析: %prun
一个程序由许多单个语句组成,有时在上下文中计时这些语句比单独计时它们更重要。Python 包含一个内置的代码分析器(可以在 Python 文档中了解),但 IPython 提供了一个更方便的方式来使用此分析器,即魔法函数%prun
。
举例来说,我们定义一个简单的函数来进行一些计算:
In [7]: def sum_of_lists(N):
total = 0
for i in range(5):
L = [j ^ (j >> i) for j in range(N)]
total += sum(L)
return total
现在我们可以调用一个函数调用来查看%prun
的分析结果:
In [8]: %prun sum_of_lists(1000000)
14 function calls in 0.932 seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
5 0.808 0.162 0.808 0.162 <ipython-input-7-f105717832a2>:4(<listcomp>)
5 0.066 0.013 0.066 0.013 {built-in method builtins.sum}
1 0.044 0.044 0.918 0.918 <ipython-input-7-f105717832a2>:1
> (sum_of_lists)
1 0.014 0.014 0.932 0.932 <string>:1(<module>)
1 0.000 0.000 0.932 0.932 {built-in method builtins.exec}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler'
> objects}
结果是一个表,按每次函数调用的总时间顺序显示执行在哪里花费了最多的时间。在这种情况下,大部分执行时间都花费在sum_of_lists
内的列表推导式中。从这里开始,我们可以考虑如何改进算法的性能。
要获取有关%prun
以及其可用选项的更多信息,请使用 IPython 帮助功能(即,在 IPython 提示符处键入%prun?
)。
逐行分析:%lprun
%prun
的函数级性能分析很有用,但有时逐行分析报告更方便。这不是 Python 或 IPython 内置的功能,但可以通过安装line_profiler
包来实现这一点。首先使用 Python 的包装工具pip
安装line_profiler
包:
$ pip install line_profiler
接下来,您可以使用 IPython 来加载line_profiler
IPython 扩展,这是该软件包的一部分:
In [9]: %load_ext line_profiler
现在,%lprun
命令将对任何函数进行逐行分析。在这种情况下,我们需要明确告诉它我们感兴趣的是哪些函数:
In [10]: %lprun -f sum_of_lists sum_of_lists(5000)
Timer unit: 1e-06 s
Total time: 0.014803 s
File: <ipython-input-7-f105717832a2>
Function: sum_of_lists at line 1
Line # Hits Time Per Hit % Time Line Contents
==============================================================
1 def sum_of_lists(N):
2 1 6.0 6.0 0.0 total = 0
3 6 13.0 2.2 0.1 for i in range(5):
4 5 14242.0 2848.4 96.2 L = [j ^ (j >> i) for j
5 5 541.0 108.2 3.7 total += sum(L)
6 1 1.0 1.0 0.0 return total
顶部的信息为我们提供了阅读结果的关键:时间以微秒为单位报告,我们可以看到程序在哪里花费了最多的时间。此时,我们可以使用这些信息来修改脚本的某些方面,使其在我们所需的用例中表现更好。
要获取关于%lprun
及其可用选项的更多信息,请使用 IPython 的帮助功能(例如,在 IPython 提示符处键入%lprun?
)。
内存使用分析:%memit
和%mprun
分析的另一个方面是操作使用的内存量。可以使用另一个 IPython 扩展memory_profiler
来评估这一点。与line_profiler
一样,我们首先使用pip
安装该扩展:
$ pip install memory_profiler
然后我们可以使用 IPython 来加载它:
In [11]: %load_ext memory_profiler
内存分析扩展包含两个有用的魔术函数:%memit
(提供了%timeit
的内存测量等价物)和%mprun
(提供了%lprun
的内存测量等价物)。%memit
魔术函数可以非常简单地使用:
In [12]: %memit sum_of_lists(1000000)
peak memory: 141.70 MiB, increment: 75.65 MiB
我们看到这个函数使用了约 140 MB 的内存。
对于逐行描述内存使用情况,我们可以使用%mprun
魔术函数。不幸的是,这仅适用于在单独模块中定义的函数,而不适用于笔记本本身,因此我们将首先使用%%file
单元格魔术创建一个简单的模块mprun_demo.py
,其中包含我们的sum_of_lists
函数,并添加一个额外的功能,以使我们的内存分析结果更清晰:
In [13]: %%file mprun_demo.py
def sum_of_lists(N):
total = 0
for i in range(5):
L = [j ^ (j >> i) for j in range(N)]
total += sum(L)
del L # remove reference to L
return total
Overwriting mprun_demo.py
现在我们可以导入这个函数的新版本并运行内存行分析:
In [14]: from mprun_demo import sum_of_lists
%mprun -f sum_of_lists sum_of_lists(1000000)
Filename: /Users/jakevdp/github/jakevdp/PythonDataScienceHandbook/notebooks_v2/
> m prun_demo.py
Line # Mem usage Increment Occurrences Line Contents
============================================================
1 66.7 MiB 66.7 MiB 1 def sum_of_lists(N):
2 66.7 MiB 0.0 MiB 1 total = 0
3 75.1 MiB 8.4 MiB 6 for i in range(5):
4 105.9 MiB 30.8 MiB 5000015 L = [j ^ (j >> i) for j
5 109.8 MiB 3.8 MiB 5 total += sum(L)
6 75.1 MiB -34.6 MiB 5 del L # remove reference to L
7 66.9 MiB -8.2 MiB 1 return total
在这里,Increment
列告诉我们每行如何影响总内存预算:注意,当我们创建和删除列表L
时,我们增加了约 30 MB 的内存使用量。这是在 Python 解释器本身的后台内存使用量之上的额外内存使用。
要获取关于%memit
和%mprun
及其可用选项的更多信息,请使用 IPython 的帮助功能(例如,在 IPython 提示符处键入%memit?
)。
更多 IPython 资源
在这一系列章节中,我们只是初步介绍了使用 IPython 来支持数据科学任务的表面知识。更多信息可以在印刷品和网络上找到,这里我将列出一些其他可能对你有帮助的资源。
网络资源
IPython 网站提供了文档、示例、教程以及各种其他资源的链接。
此网站显示任何可在互联网上找到的 Jupyter 笔记本的静态渲染。首页展示了一些示例笔记本,您可以浏览看看其他人是如何使用 IPython 的!
由 nbviewer 提供支持的这个不断增长的笔记本列表展示了您可以用 IPython 进行的数值分析的深度和广度。它涵盖了从简短的示例和教程到完整的课程和笔记本格式的书籍!
视频教程
在互联网上搜索,您会发现许多关于 IPython 的视频教程。我特别推荐从 PyCon、SciPy 和 PyData 大会上获取教程,由 IPython 和 Jupyter 的两位主要创作者和维护者 Fernando Perez 和 Brian Granger 提供。
书籍
Wes McKinney 的书包括一章介绍如何将 IPython 用作数据科学家的工具。尽管其中很多内容与我们在这里讨论的内容重叠,但另一个视角总是有帮助的。
学习 IPython 进行交互计算与数据可视化(Packt)
Cyrille Rossant 编写的这本简短书籍,为使用 IPython 进行数据分析提供了良好的入门。
IPython 交互计算与可视化食谱(Packt)
同样由 Cyrille Rossant 编写,这本书是更长、更高级的 IPython 数据科学使用指南。尽管书名中提到的是 IPython,但它也深入探讨了广泛的数据科学主题。
最后,提醒您可以自己寻找帮助:IPython 基于 ?
的帮助功能(详见第一章)在您善加利用且经常使用时非常有用。当您浏览这里和其他地方的示例时,可以用它来熟悉 IPython 提供的所有工具。
第二部分:NumPy 入门
本书的这一部分,连同第三部分,概述了在 Python 中有效加载、存储和操作内存中数据的技术。这个主题非常广泛:数据集可以来自各种来源,以及各种格式,包括文档集合、图像集合、声音片段集合、数字测量集合,或者几乎任何其他类型的数据。尽管看似多样化,但许多数据集本质上都可以表示为数字数组。
例如,图像——特别是数字图像——可以简单地被视为表示整个区域内像素亮度的二维数组。声音片段可以被视为与时间的强度一维数组。文本可以以各种方式转换为数字表示,比如表示特定单词或词组频率的二进制数字。无论数据是什么,使其可分析的第一步将是将其转换为数字数组。(我们将在第四十章中讨论此过程的一些具体示例。)
因此,对数字数组的有效存储和操作对于进行数据科学的过程是绝对基本的。我们现在将看看 Python 专门用于处理这种数字数组的专用工具:NumPy 软件包和 Pandas 软件包(在第三部分中讨论)。
本书的这一部分将详细介绍 NumPy。NumPy(缩写为 Numerical Python)提供了一个高效的接口来存储和操作密集数据缓冲区。在某些方面,NumPy 数组类似于 Python 的内置 list
类型,但是随着数组大小的增长,NumPy 数组提供了更高效的存储和数据操作。NumPy 数组几乎构成了 Python 数据科学工具生态系统的全部核心,因此花时间学习如何有效地使用 NumPy 对无论你感兴趣的数据科学的哪个方面都是有价值的。
如果你遵循了前言中的建议并安装了 Anaconda 栈,那么你已经安装并准备好使用 NumPy 了。如果你更喜欢自己动手,你可以访问NumPy.org并按照那里的安装说明进行操作。一旦你安装完成,你就可以导入 NumPy 并检查版本:
In [1]: import numpy
numpy.__version__
Out[1]: '1.21.2'
对于本章讨论的软件包的组件,我建议使用 NumPy 版本 1.8 或更高版本。按照惯例,在 SciPy/PyData 世界中,大多数人都会使用 np
作为 NumPy 的别名导入:
In [2]: import numpy as np
在本章以及书中的其余部分中,你会发现这是我们导入和使用 NumPy 的方式。
第四章:理解 Python 中的数据类型
有效的数据驱动科学和计算需要理解数据如何存储和操作。本章概述了在 Python 语言本身中如何处理数据数组,并介绍了 NumPy 在此基础上的改进。理解这种差异对于理解本书其余部分的材料至关重要。
Python 的用户经常被其易用性所吸引,其中一个因素是动态类型。而静态类型语言如 C 或 Java 要求每个变量都必须显式声明,而像 Python 这样的动态类型语言则跳过了这个规范。例如,在 C 语言中,您可能会这样指定特定的操作:
/* C code */
int result = 0;
for(int i=0; i<100; i++){
result += i;
}
而在 Python 中,等价的操作可以这样写:
# Python code
result = 0
for i in range(100):
result += i
注意一个主要区别:在 C 语言中,每个变量的数据类型都是显式声明的,而在 Python 中类型是动态推断的。这意味着,例如,我们可以将任何类型的数据分配给任何变量:
# Python code
x = 4
x = "four"
在这里,我们已经将x
的内容从整数变为字符串。在 C 语言中,相同操作会导致(依赖于编译器设置)编译错误或其他意外后果:
/* C code */
int x = 4;
x = "four"; // FAILS
这种灵活性是 Python 和其他动态类型语言方便和易于使用的一个要素。理解这是如何工作是学习如何有效和有效地分析数据的重要一环。但是,这种类型灵活性也指出了 Python 变量不仅仅是它们的值;它们还包含关于值的类型的额外信息。我们将在接下来的章节中更多地探讨这一点。
Python 整数不仅仅是一个整数
标准的 Python 实现是用 C 编写的。这意味着每个 Python 对象实际上是一个巧妙伪装的 C 结构,不仅包含其值,还包含其他信息。例如,当我们在 Python 中定义一个整数,如x = 10000
,x
不只是一个“原始”整数。它实际上是一个指向复合 C 结构的指针,该结构包含几个值。浏览 Python 3.10 源代码时,我们发现整数(long)类型定义实际上看起来是这样的(一旦 C 宏被展开):
struct _longobject {
long ob_refcnt;
PyTypeObject *ob_type;
size_t ob_size;
long ob_digit[1];
};
在 Python 3.10 中,一个单独的整数实际上包含四个部分:
-
ob_refcnt
,一个引用计数,帮助 Python 静默处理内存分配和释放 -
ob_type
,编码变量的类型 -
ob_size
指定了接下来的数据成员的大小。 -
ob_digit
包含了我们期望 Python 变量表示的实际整数值。
这意味着在 Python 中存储整数与在编译语言如 C 中的存储相比,会有一些额外开销,正如图 4-1 所示。
图 4-1. C 和 Python 整数的差异
这里,PyObject_HEAD
是包含引用计数、类型代码和之前提到的其他部分的结构的一部分。
注意这里的区别:C 整数本质上是一个标签,指向内存中的一个位置,其字节编码包含一个整数值。Python 整数是指向内存中包含所有 Python 对象信息的位置的指针,包括包含整数值的字节。Python 整数结构中的这些额外信息是允许 Python 如此自由和动态编码的原因。然而,Python 类型中的所有这些额外信息都是有代价的,特别是在结合许多这些对象的结构中尤为明显。
Python 列表不仅仅是一个列表
现在让我们考虑当我们使用一个包含许多 Python 对象的 Python 数据结构时会发生什么。Python 中标准的可变多元素容器是列表。我们可以按照以下方式创建一个整数列表:
In [1]: L = list(range(10))
L
Out[1]: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
In [2]: type(L[0])
Out[2]: int
或者,类似地,一个字符串列表:
In [3]: L2 = [str(c) for c in L]
L2
Out[3]: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
In [4]: type(L2[0])
Out[4]: str
由于 Python 的动态类型,我们甚至可以创建异构列表:
In [5]: L3 = [True, "2", 3.0, 4]
[type(item) for item in L3]
Out[5]: [bool, str, float, int]
但这种灵活性是有代价的:为了允许这些灵活的类型,列表中的每个项目都必须包含自己的类型、引用计数和其他信息。也就是说,每个项目都是一个完整的 Python 对象。在所有变量都是相同类型的特殊情况下,大部分信息是冗余的,因此将数据存储在固定类型的数组中可能更有效。动态类型列表和固定类型(NumPy 风格)数组之间的区别在 图 4-2 中有所说明。
图 4-2. C 和 Python 列表之间的区别
在实现级别上,数组基本上包含一个指向一个连续数据块的单个指针。另一方面,Python 列表包含一个指向指针块的指针,每个指针又指向一个完整的 Python 对象,就像我们之前看到的 Python 整数一样。再次强调列表的优势在于灵活性:因为每个列表元素都是一个包含数据和类型信息的完整结构,所以列表可以填充任何所需类型的数据。固定类型的 NumPy 风格数组缺乏这种灵活性,但对于存储和操作数据来说更加高效。
Python 中的固定类型数组
Python 提供了几种不同的选项来在高效的固定类型数据缓冲区中存储数据。内置的 array
模块(自 Python 3.3 起可用)可用于创建统一类型的密集数组:
In [6]: import array
L = list(range(10))
A = array.array('i', L)
A
Out[6]: array('i', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
这里,'i'
是指示内容为整数的类型代码。
然而,更有用的是 NumPy 包的 ndarray
对象。虽然 Python 的 array
对象提供了对基于数组的数据的有效存储,但 NumPy 在此基础上添加了对该数据的有效操作。我们将在后面的章节中探讨这些操作;接下来,我将向您展示创建 NumPy 数组的几种不同方法。
从 Python 列表创建数组
我们将从标准的 NumPy 导入开始,使用别名 np
:
In [7]: import numpy as np
现在我们可以使用 np.array
来从 Python 列表创建数组:
In [8]: # Integer array
np.array([1, 4, 2, 5, 3])
Out[8]: array([1, 4, 2, 5, 3])
请记住,与 Python 列表不同,NumPy 数组只能包含相同类型的数据。如果类型不匹配,NumPy 将根据其类型提升规则进行类型提升;在这里,整数被提升为浮点数:
In [9]: np.array([3.14, 4, 2, 3])
Out[9]: array([3.14, 4. , 2. , 3. ])
如果我们想要显式地设置结果数组的数据类型,可以使用 dtype
关键字:
In [10]: np.array([1, 2, 3, 4], dtype=np.float32)
Out[10]: array([1., 2., 3., 4.], dtype=float32)
最后,与 Python 列表不同,NumPy 数组可以是多维的。以下是使用列表的列表初始化多维数组的一种方法:
In [11]: # Nested lists result in multidimensional arrays
np.array([range(i, i + 3) for i in [2, 4, 6]])
Out[11]: array([[2, 3, 4],
[4, 5, 6],
[6, 7, 8]])
内部列表被视为生成的二维数组的行。
从头创建数组
特别是对于较大的数组,使用 NumPy 内置的函数从头创建数组更有效率。以下是几个示例:
In [12]: # Create a length-10 integer array filled with 0s
np.zeros(10, dtype=int)
Out[12]: array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
In [13]: # Create a 3x5 floating-point array filled with 1s
np.ones((3, 5), dtype=float)
Out[13]: array([[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.]])
In [14]: # Create a 3x5 array filled with 3.14
np.full((3, 5), 3.14)
Out[14]: array([[3.14, 3.14, 3.14, 3.14, 3.14],
[3.14, 3.14, 3.14, 3.14, 3.14],
[3.14, 3.14, 3.14, 3.14, 3.14]])
In [15]: # Create an array filled with a linear sequence
# starting at 0, ending at 20, stepping by 2
# (this is similar to the built-in range function)
np.arange(0, 20, 2)
Out[15]: array([ 0, 2, 4, 6, 8, 10, 12, 14, 16, 18])
In [16]: # Create an array of five values evenly spaced between 0 and 1
np.linspace(0, 1, 5)
Out[16]: array([0. , 0.25, 0.5 , 0.75, 1. ])
In [17]: # Create a 3x3 array of uniformly distributed
# pseudorandom values between 0 and 1
np.random.random((3, 3))
Out[17]: array([[0.09610171, 0.88193001, 0.70548015],
[0.35885395, 0.91670468, 0.8721031 ],
[0.73237865, 0.09708562, 0.52506779]])
In [18]: # Create a 3x3 array of normally distributed pseudorandom
# values with mean 0 and standard deviation 1
np.random.normal(0, 1, (3, 3))
Out[18]: array([[-0.46652655, -0.59158776, -1.05392451],
[-1.72634268, 0.03194069, -0.51048869],
[ 1.41240208, 1.77734462, -0.43820037]])
In [19]: # Create a 3x3 array of pseudorandom integers in the interval [0, 10)
np.random.randint(0, 10, (3, 3))
Out[19]: array([[4, 3, 8],
[6, 5, 0],
[1, 1, 4]])
In [20]: # Create a 3x3 identity matrix
np.eye(3)
Out[20]: array([[1., 0., 0.],
[0., 1., 0.],
[0., 0., 1.]])
In [21]: # Create an uninitialized array of three integers; the values will be
# whatever happens to already exist at that memory location
np.empty(3)
Out[21]: array([1., 1., 1.])
NumPy 标准数据类型
NumPy 数组包含单一类型的值,因此对这些类型及其限制有详细了解很重要。因为 NumPy 是用 C 语言构建的,所以这些类型对于使用 C、Fortran 和其他相关语言的用户来说应该很熟悉。
标准的 NumPy 数据类型列在 表 4-1 中。注意,在构建数组时,它们可以使用字符串来指定:
np.zeros(10, dtype='int16')
或者使用相关的 NumPy 对象:
np.zeros(10, dtype=np.int16)
可以进行更高级的类型规定,例如指定大端或小端数字;有关更多信息,请参阅 NumPy 文档。NumPy 还支持复合数据类型,这将在 第 12 章 中介绍。
表 4-1. 标准的 NumPy 数据类型
数据类型 | 描述 |
---|---|
bool_ |
存储为字节的布尔值(True 或 False) |
int_ |
默认整数类型(与 C 语言的 long 相同;通常是 int64 或 int32 ) |
intc |
与 C 语言 int 相同(通常是 int32 或 int64 ) |
intp |
用于索引的整数(与 C 语言的 ssize_t 相同;通常是 int32 或 int64 ) |
int8 |
字节(–128 到 127) |
int16 |
整数(–32768 到 32767) |
int32 |
整数(–2147483648 到 2147483647) |
int64 |
整数(–9223372036854775808 到 9223372036854775807) |
uint8 |
无符号整数(0 到 255) |
uint16 |
无符号整数(0 到 65535) |
uint32 |
无符号整数(0 到 4294967295) |
uint64 |
无符号整数(0 到 18446744073709551615) |
float_ |
float64 的简写 |
float16 |
半精度浮点数:符号位,5 位指数,10 位尾数 |
float32 |
单精度浮点数:符号位,8 位指数,23 位尾数 |
float64 |
双精度浮点数:符号位,11 位指数,52 位尾数 |
complex_ |
complex128 的简写 |
complex64 |
复数,由两个 32 位浮点数表示 |
complex128 |
复数,由两个 64 位浮点数表示 |
第五章:NumPy 数组的基础知识
在 Python 中对数据进行操作几乎等同于对 NumPy 数组进行操作:即使是像 Pandas(第 III 部分)这样的较新工具也是围绕 NumPy 数组构建的。本章将介绍使用 NumPy 数组操作来访问数据和子数组、拆分、重塑和连接数组的几个示例。虽然这里展示的操作类型可能看起来有些枯燥和迂腐,但它们构成了本书中许多其他示例的基础。好好地了解它们!
这里将涵盖一些基本数组操作的类别:
数组的属性
确定数组的大小、形状、内存消耗和数据类型
数组的索引
获取和设置单个数组元素的值
数组的切片
获取和设置大数组中的较小子数组
数组的重塑
更改给定数组的形状
数组的连接和拆分
将多个数组组合成一个数组,并将一个数组拆分成多个数组
NumPy 数组属性
首先让我们讨论一些有用的数组属性。我们将从定义一维、二维和三维随机数组开始。我们将使用 NumPy 的随机数生成器,我们将使用一个固定值来seed,以确保每次运行此代码时都生成相同的随机数组:
In [1]: import numpy as np
rng = np.random.default_rng(seed=1701) # seed for reproducibility
x1 = rng.integers(10, size=6) # one-dimensional array
x2 = rng.integers(10, size=(3, 4)) # two-dimensional array
x3 = rng.integers(10, size=(3, 4, 5)) # three-dimensional array
每个数组都有一些属性,包括ndim
(维数)、shape
(每个维度的大小)、size
(数组的总大小)和dtype
(每个元素的类型):
In [2]: print("x3 ndim: ", x3.ndim)
print("x3 shape:", x3.shape)
print("x3 size: ", x3.size)
print("dtype: ", x3.dtype)
Out[2]: x3 ndim: 3
x3 shape: (3, 4, 5)
x3 size: 60
dtype: int64
有关数据类型的更多讨论,请参阅第四章。
数组索引:访问单个元素
如果你熟悉 Python 标准列表的索引,那么在 NumPy 中进行索引将会感觉非常熟悉。在一维数组中,第i个值(从零开始计数)可以通过在方括号中指定所需索引来访问,就像在 Python 列表中一样:
In [3]: x1
Out[3]: array([9, 4, 0, 3, 8, 6])
In [4]: x1[0]
Out[4]: 9
In [5]: x1[4]
Out[5]: 8
要从数组的末尾进行索引,可以使用负索引:
In [6]: x1[-1]
Out[6]: 6
In [7]: x1[-2]
Out[7]: 8
在多维数组中,可以使用逗号分隔的(*行*, *列*)
元组来访问项目:
In [8]: x2
Out[8]: array([[3, 1, 3, 7],
[4, 0, 2, 3],
[0, 0, 6, 9]])
In [9]: x2[0, 0]
Out[9]: 3
In [10]: x2[2, 0]
Out[10]: 0
In [11]: x2[2, -1]
Out[11]: 9
值也可以使用任何前述的索引表示进行修改:
In [12]: x2[0, 0] = 12
x2
Out[12]: array([[12, 1, 3, 7],
[ 4, 0, 2, 3],
[ 0, 0, 6, 9]])
请记住,与 Python 列表不同,NumPy 数组具有固定的类型。这意味着,例如,如果你尝试将浮点值插入到整数数组中,该值将被静默截断。不要被这种行为所困扰!
In [13]: x1[0] = 3.14159 # this will be truncated!
x1
Out[13]: array([3, 4, 0, 3, 8, 6])
数组切片:访问子数组
正如我们可以使用方括号访问单个数组元素一样,我们也可以使用切片符号(由冒号(:
)字符标记)来访问子数组。NumPy 的切片语法遵循标准 Python 列表的语法;要访问数组x
的一个切片,使用以下方法:
x[start:stop:step]
如果其中任何一个未指定,它们将默认为start=0
、stop=<size of dimension>
、step=1
的值。让我们看一些在一维和多维数组中访问子数组的例子。
一维子数组
这里是一些访问一维子数组中元素的示例:
In [14]: x1
Out[14]: array([3, 4, 0, 3, 8, 6])
In [15]: x1[:3] # first three elements
Out[15]: array([3, 4, 0])
In [16]: x1[3:] # elements after index 3
Out[16]: array([3, 8, 6])
In [17]: x1[1:4] # middle subarray
Out[17]: array([4, 0, 3])
In [18]: x1[::2] # every second element
Out[18]: array([3, 0, 8])
In [19]: x1[1::2] # every second element, starting at index 1
Out[19]: array([4, 3, 6])
一个可能令人困惑的情况是当step
值为负时。在这种情况下,start
和stop
的默认值会被交换。这成为反转数组的便捷方式:
In [20]: x1[::-1] # all elements, reversed
Out[20]: array([6, 8, 3, 0, 4, 3])
In [21]: x1[4::-2] # every second element from index 4, reversed
Out[21]: array([8, 0, 3])
多维子数组
多维切片的工作方式相同,使用逗号分隔的多个切片。例如:
In [22]: x2
Out[22]: array([[12, 1, 3, 7],
[ 4, 0, 2, 3],
[ 0, 0, 6, 9]])
In [23]: x2[:2, :3] # first two rows & three columns
Out[23]: array([[12, 1, 3],
[ 4, 0, 2]])
In [24]: x2[:3, ::2] # three rows, every second column
Out[24]: array([[12, 3],
[ 4, 2],
[ 0, 6]])
In [25]: x2[::-1, ::-1] # all rows & columns, reversed
Out[25]: array([[ 9, 6, 0, 0],
[ 3, 2, 0, 4],
[ 7, 3, 1, 12]])
一个常见的例程是访问数组的单行或单列。这可以通过组合索引和切片来完成,使用一个冒号(:
)标记的空切片:
In [26]: x2[:, 0] # first column of x2
Out[26]: array([12, 4, 0])
In [27]: x2[0, :] # first row of x2
Out[27]: array([12, 1, 3, 7])
在行访问的情况下,可以省略空切片以获得更紧凑的语法:
In [28]: x2[0] # equivalent to x2[0, :]
Out[28]: array([12, 1, 3, 7])
子数组作为非副本视图
与 Python 列表切片不同,NumPy 数组切片返回的是数组数据的视图而不是副本。考虑我们之前的二维数组:
In [29]: print(x2)
Out[29]: [[12 1 3 7]
[ 4 0 2 3]
[ 0 0 6 9]]
让我们从中提取一个子数组:
In [30]: x2_sub = x2[:2, :2]
print(x2_sub)
Out[30]: [[12 1]
[ 4 0]]
现在,如果我们修改这个子数组,我们会看到原始数组也发生了改变!观察:
In [31]: x2_sub[0, 0] = 99
print(x2_sub)
Out[31]: [[99 1]
[ 4 0]]
In [32]: print(x2)
Out[32]: [[99 1 3 7]
[ 4 0 2 3]
[ 0 0 6 9]]
一些用户可能会觉得这很奇怪,但这可能是有利的:例如,在处理大型数据集时,我们可以访问和处理这些数据集的部分而无需复制底层数据缓冲区。
创建数组的副本
尽管数组视图具有这些特性,但有时将数据明确复制到数组或子数组中更有用。这可以使用copy
方法最容易地完成:
In [33]: x2_sub_copy = x2[:2, :2].copy()
print(x2_sub_copy)
Out[33]: [[99 1]
[ 4 0]]
如果我们现在修改这个子数组,原始数组不会受到影响:
In [34]: x2_sub_copy[0, 0] = 42
print(x2_sub_copy)
Out[34]: [[42 1]
[ 4 0]]
In [35]: print(x2)
Out[35]: [[99 1 3 7]
[ 4 0 2 3]
[ 0 0 6 9]]
数组的重塑
另一种有用的操作类型是数组的重塑,可以使用reshape
方法完成。例如,如果你想将数字 1 到 9 放在一个网格中,你可以这样做:
In [36]: grid = np.arange(1, 10).reshape(3, 3)
print(grid)
Out[36]: [[1 2 3]
[4 5 6]
[7 8 9]]
请注意,为了使其工作,初始数组的大小必须与重塑后数组的大小匹配,在大多数情况下,reshape
方法将返回初始数组的非副本视图。
一个常见的重塑操作是将一维数组转换为二维行或列矩阵:
In [37]: x = np.array([1, 2, 3])
x.reshape((1, 3)) # row vector via reshape
Out[37]: array([[1, 2, 3]])
In [38]: x.reshape((3, 1)) # column vector via reshape
Out[38]: array([[1],
[2],
[3]])
这样做的一个便利的简写是在切片语法中使用np.newaxis
:
In [39]: x[np.newaxis, :] # row vector via newaxis
Out[39]: array([[1, 2, 3]])
In [40]: x[:, np.newaxis] # column vector via newaxis
Out[40]: array([[1],
[2],
[3]])
这是本书剩余部分我们经常会利用的模式。
数组连接和拆分
所有前述的例程都在单个数组上工作。NumPy 还提供了将多个数组合并成一个数组的工具,以及将单个数组拆分成多个数组的工具。
数组的连接
在 NumPy 中,数组的连接或组合主要通过np.concatenate
、np.vstack
和np.hstack
这些例程来实现。np.concatenate
将一个数组的元组或列表作为其第一个参数,如下所示:
In [41]: x = np.array([1, 2, 3])
y = np.array([3, 2, 1])
np.concatenate([x, y])
Out[41]: array([1, 2, 3, 3, 2, 1])
你也可以一次连接多个数组:
In [42]: z = np.array([99, 99, 99])
print(np.concatenate([x, y, z]))
Out[42]: [ 1 2 3 3 2 1 99 99 99]
它也可以用于二维数组:
In [43]: grid = np.array([[1, 2, 3],
[4, 5, 6]])
In [44]: # concatenate along the first axis
np.concatenate([grid, grid])
Out[44]: array([[1, 2, 3],
[4, 5, 6],
[1, 2, 3],
[4, 5, 6]])
In [45]: # concatenate along the second axis (zero-indexed)
np.concatenate([grid, grid], axis=1)
Out[45]: array([[1, 2, 3, 1, 2, 3],
[4, 5, 6, 4, 5, 6]])
对于处理混合维度数组,使用 np.vstack
(垂直堆叠)和 np.hstack
(水平堆叠)函数可能更清晰:
In [46]: # vertically stack the arrays
np.vstack([x, grid])
Out[46]: array([[1, 2, 3],
[1, 2, 3],
[4, 5, 6]])
In [47]: # horizontally stack the arrays
y = np.array([[99],
[99]])
np.hstack([grid, y])
Out[47]: array([[ 1, 2, 3, 99],
[ 4, 5, 6, 99]])
类似地,对于高维数组,np.dstack
将沿第三轴堆叠数组。
数组的分割
连接的反操作是分割,由函数 np.split
、np.hsplit
和 np.vsplit
实现。对于每一个,我们可以传递一个给定分割点的索引列表:
In [48]: x = [1, 2, 3, 99, 99, 3, 2, 1]
x1, x2, x3 = np.split(x, [3, 5])
print(x1, x2, x3)
Out[48]: [1 2 3] [99 99] [3 2 1]
注意,N 个分割点会导致 N + 1 个子数组。相关的函数 np.hsplit
和 np.vsplit
类似:
In [49]: grid = np.arange(16).reshape((4, 4))
grid
Out[49]: array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11],
[12, 13, 14, 15]])
In [50]: upper, lower = np.vsplit(grid, [2])
print(upper)
print(lower)
Out[50]: [[0 1 2 3]
[4 5 6 7]]
[[ 8 9 10 11]
[12 13 14 15]]
In [51]: left, right = np.hsplit(grid, [2])
print(left)
print(right)
Out[51]: [[ 0 1]
[ 4 5]
[ 8 9]
[12 13]]
[[ 2 3]
[ 6 7]
[10 11]
[14 15]]
类似地,对于高维数组,np.dsplit
将沿第三轴分割数组。
第六章:NumPy 数组上的计算:通用函数
到目前为止,我们已经讨论了 NumPy 的一些基本要点。在接下来的几章中,我们将深入探讨 NumPy 在 Python 数据科学世界中如此重要的原因:因为它提供了一个简单灵活的接口来优化数据数组的计算。
NumPy 数组上的计算可能非常快,也可能非常慢。使其快速的关键在于使用向量化操作,通常通过 NumPy 的通用函数(ufuncs)实现。本章阐述了使用 NumPy ufuncs 的必要性,它可以使对数组元素的重复计算更加高效。然后介绍了 NumPy 包中许多常见和有用的算术 ufuncs。
循环的缓慢性
Python 的默认实现(称为 CPython)有时会非常慢地执行某些操作。这在一定程度上是由于语言的动态解释性质造成的;类型灵活,因此无法像 C 和 Fortran 语言那样将操作序列编译成高效的机器码。近年来,有各种尝试来解决这一弱点:著名的例子有 PyPy 项目,一个即时编译的 Python 实现;Cython 项目,它将 Python 代码转换为可编译的 C 代码;以及 Numba 项目,它将 Python 代码片段转换为快速的 LLVM 字节码。每种方法都有其优势和劣势,但可以肯定的是,这三种方法都还没有超越标准 CPython 引擎的普及度和影响力。
Python 的相对缓慢通常在需要重复执行许多小操作的情况下显现出来;例如,循环遍历数组以对每个元素进行操作。例如,假设我们有一个值数组,并且想要计算每个值的倒数。一个直接的方法可能如下所示:
In [1]: import numpy as np
rng = np.random.default_rng(seed=1701)
def compute_reciprocals(values):
output = np.empty(len(values))
for i in range(len(values)):
output[i] = 1.0 / values[i]
return output
values = rng.integers(1, 10, size=5)
compute_reciprocals(values)
Out[1]: array([0.11111111, 0.25 , 1. , 0.33333333, 0.125 ])
这种实现对于来自 C 或 Java 背景的人来说可能感觉相当自然。但是如果我们测量这段代码在大输入下的执行时间,我们会发现这个操作非常慢——也许出乎意料地慢!我们将使用 IPython 的 %timeit
魔术命令(在“分析和计时代码”中讨论)进行基准测试:
In [2]: big_array = rng.integers(1, 100, size=1000000)
%timeit compute_reciprocals(big_array)
Out[2]: 2.61 s ± 192 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
计算这些百万次操作并存储结果需要几秒钟!即使是手机的处理速度也以亿次数的每秒运算来衡量,这看起来几乎是荒谬地慢。事实证明,这里的瓶颈不是操作本身,而是 CPython 在每个循环周期中必须进行的类型检查和函数调度。每次计算倒数时,Python 首先检查对象的类型,并动态查找正确的函数来使用该类型。如果我们在编译代码中工作,这种类型规范将在代码执行之前已知,并且结果可以更有效地计算。
引入 Ufuncs
对于许多类型的操作,NumPy 提供了一个便利的接口,用于这种静态类型化的、编译的例程。这被称为向量化操作。对于像这里的逐元素除法这样的简单操作,向量化操作只需直接在数组对象上使用 Python 算术运算符即可。这种向量化方法旨在将循环推入 NumPy 底层的编译层,从而实现更快的执行。
比较以下两个操作的结果:
In [3]: print(compute_reciprocals(values))
print(1.0 / values)
Out[3]: [0.11111111 0.25 1. 0.33333333 0.125 ]
[0.11111111 0.25 1. 0.33333333 0.125 ]
查看我们大数组的执行时间,我们看到它完成的速度比 Python 循环快了几个数量级:
In [4]: %timeit (1.0 / big_array)
Out[4]: 2.54 ms ± 383 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
NumPy 中的向量化操作是通过 ufuncs 实现的,其主要目的是在 NumPy 数组中快速执行重复操作。Ufuncs 非常灵活——我们之前看到了标量和数组之间的操作,但我们也可以在两个数组之间进行操作:
In [5]: np.arange(5) / np.arange(1, 6)
Out[5]: array([0. , 0.5 , 0.66666667, 0.75 , 0.8 ])
并且 ufunc 操作不仅限于一维数组。它们也可以作用于多维数组:
In [6]: x = np.arange(9).reshape((3, 3))
2 ** x
Out[6]: array([[ 1, 2, 4],
[ 8, 16, 32],
[ 64, 128, 256]])
通过 ufunc 的向量化计算几乎总是比使用 Python 循环实现的对应计算更有效率,特别是数组增长时。在 NumPy 脚本中看到这样的循环时,您应该考虑是否可以用向量化表达式替代它。
探索 NumPy 的 Ufuncs
Ufuncs 有两种类型:一元 ufuncs,作用于单个输入,和二元 ufuncs,作用于两个输入。我们将在这里看到这两种类型的函数示例。
数组算术
NumPy 的 ufunc 使用起来非常自然,因为它们利用了 Python 的本机算术运算符。可以使用标准的加法、减法、乘法和除法:
In [7]: x = np.arange(4)
print("x =", x)
print("x + 5 =", x + 5)
print("x - 5 =", x - 5)
print("x * 2 =", x * 2)
print("x / 2 =", x / 2)
print("x // 2 =", x // 2) # floor division
Out[7]: x = [0 1 2 3]
x + 5 = [5 6 7 8]
x - 5 = [-5 -4 -3 -2]
x * 2 = [0 2 4 6]
x / 2 = [0. 0.5 1. 1.5]
x // 2 = [0 0 1 1]
还有一个一元 ufunc 用于求反,一个**
运算符用于指数运算,一个%
运算符用于求模:
In [8]: print("-x = ", -x)
print("x ** 2 = ", x ** 2)
print("x % 2 = ", x % 2)
Out[8]: -x = [ 0 -1 -2 -3]
x ** 2 = [0 1 4 9]
x % 2 = [0 1 0 1]
此外,这些操作可以随意组合在一起,而且遵循标准的操作顺序:
In [9]: -(0.5*x + 1) ** 2
Out[9]: array([-1. , -2.25, -4. , -6.25])
所有这些算术操作都只是围绕着 NumPy 中特定 ufunc 的方便包装。例如,+
运算符是add
ufunc 的一个包装器。
In [10]: np.add(x, 2)
Out[10]: array([2, 3, 4, 5])
表 6-1 列出了 NumPy 中实现的算术运算符。
表 6-1. NumPy 中实现的算术运算符
运算符 | 等效 ufunc | 描述 |
---|---|---|
+ |
np.add |
加法(例如,1 + 1 = 2 ) |
- |
np.subtract |
减法(例如,3 - 2 = 1 ) |
- |
np.negative |
一元取反(例如,-2 ) |
* |
np.multiply |
乘法(例如,2 * 3 = 6 ) |
/ |
np.divide |
除法(例如,3 / 2 = 1.5 ) |
// |
np.floor_divide |
地板除法(例如,3 // 2 = 1 ) |
** |
np.power |
指数运算(例如,2 ** 3 = 8 ) |
% |
np.mod |
取模/取余数(例如,9 % 4 = 1 ) |
此外,还有布尔/位运算符;我们将在第九章中探索这些。
绝对值
就像 NumPy 理解 Python 内置的算术运算符一样,它也理解 Python 内置的绝对值函数:
In [11]: x = np.array([-2, -1, 0, 1, 2])
abs(x)
Out[11]: array([2, 1, 0, 1, 2])
对应的 NumPy ufunc 是np.absolute
,也可以用别名np.abs
调用:
In [12]: np.absolute(x)
Out[12]: array([2, 1, 0, 1, 2])
In [13]: np.abs(x)
Out[13]: array([2, 1, 0, 1, 2])
这个 ufunc 也可以处理复杂数据,此时它返回幅度:
In [14]: x = np.array([3 - 4j, 4 - 3j, 2 + 0j, 0 + 1j])
np.abs(x)
Out[14]: array([5., 5., 2., 1.])
三角函数
NumPy 提供了大量有用的 ufunc,对于数据科学家来说,其中一些最有用的是三角函数。我们将从定义一组角度开始:
In [15]: theta = np.linspace(0, np.pi, 3)
现在我们可以在这些值上计算一些三角函数了:
In [16]: print("theta = ", theta)
print("sin(theta) = ", np.sin(theta))
print("cos(theta) = ", np.cos(theta))
print("tan(theta) = ", np.tan(theta))
Out[16]: theta = [0. 1.57079633 3.14159265]
sin(theta) = [0.0000000e+00 1.0000000e+00 1.2246468e-16]
cos(theta) = [ 1.000000e+00 6.123234e-17 -1.000000e+00]
tan(theta) = [ 0.00000000e+00 1.63312394e+16 -1.22464680e-16]
这些值计算精度达到了机器精度,这就是为什么应该是零的值并不总是确切为零。反三角函数也是可用的:
In [17]: x = [-1, 0, 1]
print("x = ", x)
print("arcsin(x) = ", np.arcsin(x))
print("arccos(x) = ", np.arccos(x))
print("arctan(x) = ", np.arctan(x))
Out[17]: x = [-1, 0, 1]
arcsin(x) = [-1.57079633 0. 1.57079633]
arccos(x) = [3.14159265 1.57079633 0. ]
arctan(x) = [-0.78539816 0. 0.78539816]
指数和对数
在 NumPy 的 ufunc 中还有其他常见的操作,如指数函数:
In [18]: x = [1, 2, 3]
print("x =", x)
print("e^x =", np.exp(x))
print("2^x =", np.exp2(x))
print("3^x =", np.power(3., x))
Out[18]: x = [1, 2, 3]
e^x = [ 2.71828183 7.3890561 20.08553692]
2^x = [2. 4. 8.]
3^x = [ 3. 9. 27.]
逆指数函数,对数函数也是可用的。基本的np.log
给出自然对数;如果你偏好计算以 2 为底或以 10 为底的对数,这些也是可用的:
In [19]: x = [1, 2, 4, 10]
print("x =", x)
print("ln(x) =", np.log(x))
print("log2(x) =", np.log2(x))
print("log10(x) =", np.log10(x))
Out[19]: x = [1, 2, 4, 10]
ln(x) = [0. 0.69314718 1.38629436 2.30258509]
log2(x) = [0. 1. 2. 3.32192809]
log10(x) = [0. 0.30103 0.60205999 1. ]
还有一些专门用于保持极小输入精度的版本:
In [20]: x = [0, 0.001, 0.01, 0.1]
print("exp(x) - 1 =", np.expm1(x))
print("log(1 + x) =", np.log1p(x))
Out[20]: exp(x) - 1 = [0. 0.0010005 0.01005017 0.10517092]
log(1 + x) = [0. 0.0009995 0.00995033 0.09531018]
当x
非常小时,这些函数比使用原始的np.log
或np.exp
给出更精确的值。
专用的 Ufuncs
NumPy 还有许多其他的 ufunc 可用,包括双曲三角函数,位运算,比较操作,弧度转角度的转换,取整和余数等等。查阅 NumPy 文档会发现许多有趣的功能。
另一个更专业的 ufunc 来源是子模块scipy.special
。如果你想在数据上计算一些不常见的数学函数,很可能它已经在scipy.special
中实现了。这里有太多函数无法一一列出,但以下代码片段展示了在统计上可能会遇到的一些函数:
In [21]: from scipy import special
In [22]: # Gamma functions (generalized factorials) and related functions
x = [1, 5, 10]
print("gamma(x) =", special.gamma(x))
print("ln|gamma(x)| =", special.gammaln(x))
print("beta(x, 2) =", special.beta(x, 2))
Out[22]: gamma(x) = [1.0000e+00 2.4000e+01 3.6288e+05]
ln|gamma(x)| = [ 0. 3.17805383 12.80182748]
beta(x, 2) = [0.5 0.03333333 0.00909091]
In [23]: # Error function (integral of Gaussian),
# its complement, and its inverse
x = np.array([0, 0.3, 0.7, 1.0])
print("erf(x) =", special.erf(x))
print("erfc(x) =", special.erfc(x))
print("erfinv(x) =", special.erfinv(x))
Out[23]: erf(x) = [0. 0.32862676 0.67780119 0.84270079]
erfc(x) = [1. 0.67137324 0.32219881 0.15729921]
erfinv(x) = [0. 0.27246271 0.73286908 inf]
NumPy 和scipy.special
中还有许多其他的 ufunc 可用。由于这些包的文档可以在线获取,因此通过类似“gamma function python”的网络搜索通常可以找到相关信息。
高级 Ufunc 功能
许多 NumPy 用户在不完全了解它们的全部特性的情况下就使用了 ufuncs。我在这里概述一些 ufunc 的专门特性。
指定输出
对于大型计算,有时指定计算结果存储的数组是很有用的。对于所有 ufunc,这可以通过函数的out
参数来完成:
In [24]: x = np.arange(5)
y = np.empty(5)
np.multiply(x, 10, out=y)
print(y)
Out[24]: [ 0. 10. 20. 30. 40.]
这甚至可以与数组视图一起使用。例如,我们可以将计算结果写入指定数组的每隔一个元素:
In [25]: y = np.zeros(10)
np.power(2, x, out=y[::2])
print(y)
Out[25]: [ 1. 0. 2. 0. 4. 0. 8. 0. 16. 0.]
如果我们改为写成y[::2] = 2 ** x
,这将导致创建一个临时数组来保存2 ** x
的结果,然后进行第二个操作将这些值复制到y
数组中。对于这样一个小的计算来说,这并不会有太大的区别,但是对于非常大的数组来说,通过谨慎使用out
参数可以节省内存。
聚合
对于二元 ufunc,聚合可以直接从对象中计算。例如,如果我们想要使用特定操作减少一个数组,我们可以使用任何 ufunc 的reduce
方法。reduce 会重复地将给定操作应用于数组的元素,直到只剩下一个单一的结果。
例如,对add
ufunc 调用reduce
将返回数组中所有元素的总和:
In [26]: x = np.arange(1, 6)
np.add.reduce(x)
Out[26]: 15
类似地,对multiply
ufunc 调用reduce
将得到数组所有元素的乘积:
In [27]: np.multiply.reduce(x)
Out[27]: 120
如果我们想要存储计算的所有中间结果,我们可以使用accumulate
:
In [28]: np.add.accumulate(x)
Out[28]: array([ 1, 3, 6, 10, 15])
In [29]: np.multiply.accumulate(x)
Out[29]: array([ 1, 2, 6, 24, 120])
请注意,对于这些特定情况,有专门的 NumPy 函数来计算结果(np.sum
,np.prod
,np.cumsum
,np.cumprod
),我们将在第七章中探讨。
外积
最后,任何 ufunc 都可以使用outer
方法计算两个不同输入的所有对的输出。这使您可以在一行中执行诸如创建乘法表之类的操作:
In [30]: x = np.arange(1, 6)
np.multiply.outer(x, x)
Out[30]: array([[ 1, 2, 3, 4, 5],
[ 2, 4, 6, 8, 10],
[ 3, 6, 9, 12, 15],
[ 4, 8, 12, 16, 20],
[ 5, 10, 15, 20, 25]])
ufunc.at
和ufunc.reduceat
方法同样也是非常有用的,我们将在第十章中探讨它们。
我们还将遇到 ufunc 能够在不同形状和大小的数组之间执行操作的能力,这一组操作被称为广播。这个主题非常重要,我们将专门为其设立一整章(参见第八章)。
Ufuncs:了解更多
更多有关通用函数的信息(包括可用函数的完整列表)可以在NumPy和SciPy文档网站上找到。
请回忆,您还可以通过在 IPython 中导入包并使用 IPython 的 tab 补全和帮助(?
)功能来直接访问信息,如第一章中所述。
第七章:聚合:最小值、最大值以及其他
探索任何数据集的第一步通常是计算各种摘要统计信息。也许最常见的摘要统计信息是均值和标准差,它们帮助你总结数据集中的“典型”值,但其他聚合也很有用(总和、乘积、中位数、最小值和最大值、分位数等)。
NumPy 具有用于处理数组的快速内置聚合函数;我们将在这里讨论并尝试其中一些。
对数组中的值求和
举个快速的例子,考虑计算数组中所有值的总和。Python 本身可以使用内置的sum
函数来完成这个操作:
In [1]: import numpy as np
rng = np.random.default_rng()
In [2]: L = rng.random(100)
sum(L)
Out[2]: 52.76825337322368
语法与 NumPy 的sum
函数非常相似,在最简单的情况下结果是相同的:
In [3]: np.sum(L)
Out[3]: 52.76825337322366
然而,由于它在编译代码中执行操作,NumPy 版本的操作速度要快得多:
In [4]: big_array = rng.random(1000000)
%timeit sum(big_array)
%timeit np.sum(big_array)
Out[4]: 89.9 ms ± 233 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
521 µs ± 8.37 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
不过要小心:sum
函数和np.sum
函数并不相同,这有时可能会导致混淆!特别是,它们的可选参数具有不同的含义(sum(x, 1)
将总和初始化为1
,而np.sum(x, 1)
沿着轴1
求和),而np.sum
能够识别多个数组维度,正如我们将在接下来的部分看到的。
最小值和最大值
同样,Python 内置了min
和max
函数,用于找到任意给定数组的最小值和最大值:
In [5]: min(big_array), max(big_array)
Out[5]: (2.0114398036064074e-07, 0.9999997912802653)
NumPy 的相应函数具有类似的语法,并且在操作上也更快:
In [6]: np.min(big_array), np.max(big_array)
Out[6]: (2.0114398036064074e-07, 0.9999997912802653)
In [7]: %timeit min(big_array)
%timeit np.min(big_array)
Out[7]: 72 ms ± 177 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
564 µs ± 3.11 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
对于min
、max
、sum
以及其他几个 NumPy 聚合函数,使用数组对象本身的方法可以简化语法:
In [8]: print(big_array.min(), big_array.max(), big_array.sum())
Out[8]: 2.0114398036064074e-07 0.9999997912802653 499854.0273321711
在操作 NumPy 数组时,尽可能确保使用 NumPy 版本的这些聚合函数!
多维度聚合
一种常见的聚合操作类型是沿着行或列进行聚合。假设你有一些数据存储在二维数组中:
In [9]: M = rng.integers(0, 10, (3, 4))
print(M)
Out[9]: [[0 3 1 2]
[1 9 7 0]
[4 8 3 7]]
NumPy 的聚合函数将应用于多维数组的所有元素:
In [10]: M.sum()
Out[10]: 45
聚合函数接受一个额外的参数,指定沿着哪个轴进行聚合计算。例如,我们可以通过指定axis=0
找到每列中的最小值:
In [11]: M.min(axis=0)
Out[11]: array([0, 3, 1, 0])
该函数返回四个值,对应于四列数字。
类似地,我们可以找到每行中的最大值:
In [12]: M.max(axis=1)
Out[12]: array([3, 9, 8])
此处指定轴的方式可能会令从其他语言转过来的用户感到困惑。axis
关键字指定将要折叠的数组维度,而不是将要返回的维度。因此,指定axis=0
意味着将折叠轴 0:对于二维数组,将在每列内进行聚合。
其他聚合函数
NumPy 提供了几个其他具有类似 API 的聚合函数,此外大多数函数还有一个NaN
安全的对应版本,用于在计算结果时忽略缺失值,这些值由特殊的 IEEE 浮点NaN
值标记(参见第十六章)。
表 7-1 提供了 NumPy 中可用的一些有用的聚合函数列表。
表 7-1. NumPy 中可用的聚合函数
函数名 | NaN 安全版本 | 描述 |
---|---|---|
np.sum |
np.nansum |
计算元素的总和 |
np.prod |
np.nanprod |
计算元素的乘积 |
np.mean |
np.nanmean |
计算元素的均值 |
np.std |
np.nanstd |
计算标准差 |
np.var |
np.nanvar |
计算方差 |
np.min |
np.nanmin |
查找最小值 |
np.max |
np.nanmax |
查找最大值 |
np.argmin |
np.nanargmin |
查找最小值的索引 |
np.argmax |
np.nanargmax |
查找最大值的索引 |
np.median |
np.nanmedian |
计算元素的中位数 |
np.percentile |
np.nanpercentile |
计算元素的基于排名的统计信息 |
np.any |
N/A | 评估是否有任何元素为真 |
np.all |
N/A | 评估所有元素是否为真 |
你将经常看到这些汇总统计信息在本书的其余部分中。
示例:美国总统的平均身高是多少?
NumPy 中可用的聚合函数可以作为一组值的汇总统计信息。作为一个小例子,让我们考虑所有美国总统的身高。这些数据包含在文件president_heights.csv中,这是一个标签和值的逗号分隔列表:
In [13]: !head -4 data/president_heights.csv
Out[13]: order,name,height(cm)
1,George Washington,189
2,John Adams,170
3,Thomas Jefferson,189
我们将使用 Pandas 包,在第 III 部分中更全面地探讨,读取文件并提取这些信息(注意,身高以厘米为单位):
In [14]: import pandas as pd
data = pd.read_csv('data/president_heights.csv')
heights = np.array(data['height(cm)'])
print(heights)
Out[14]: [189 170 189 163 183 171 185 168 173 183 173 173 175 178 183 193 178 173
174 183 183 168 170 178 182 180 183 178 182 188 175 179 183 193 182 183
177 185 188 188 182 185 191 182]
现在我们有了这个数据数组,我们可以计算各种汇总统计信息:
In [15]: print("Mean height: ", heights.mean())
print("Standard deviation:", heights.std())
print("Minimum height: ", heights.min())
print("Maximum height: ", heights.max())
Out[15]: Mean height: 180.04545454545453
Standard deviation: 6.983599441335736
Minimum height: 163
Maximum height: 193
请注意,在每种情况下,聚合操作将整个数组减少为一个单一的汇总值,这为我们提供了关于值分布的信息。我们可能还希望计算分位数:
In [16]: print("25th percentile: ", np.percentile(heights, 25))
print("Median: ", np.median(heights))
print("75th percentile: ", np.percentile(heights, 75))
Out[16]: 25th percentile: 174.75
Median: 182.0
75th percentile: 183.5
我们看到美国总统的中位身高为 182 厘米,几乎等于六英尺。
当然,有时更有用的是查看这些数据的视觉表示,我们可以使用 Matplotlib 工具来实现这一目标(我们将在第 IV 部分中更全面地讨论 Matplotlib)。例如,以下代码生成图 7-1:
In [17]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
In [18]: plt.hist(heights)
plt.title('Height Distribution of US Presidents')
plt.xlabel('height (cm)')
plt.ylabel('number');
图 7-1. 美国总统身高的直方图
第八章:数组上的计算:广播
我们在第六章看到 NumPy 的通用函数如何用于向量化操作,从而消除缓慢的 Python 循环。本章讨论广播:这是 NumPy 允许你在不同大小和形状的数组之间应用二元操作(如加法、减法、乘法等)的一组规则。
引入广播
请记住,对于相同大小的数组,二元操作是逐元素执行的:
In [1]: import numpy as np
In [2]: a = np.array([0, 1, 2])
b = np.array([5, 5, 5])
a + b
Out[2]: array([5, 6, 7])
广播允许在不同大小的数组上执行这些类型的二元操作,例如,我们可以很容易地将标量(将其视为零维数组)加到数组中:
In [3]: a + 5
Out[3]: array([5, 6, 7])
我们可以将其视为一种操作,将值5
拉伸或复制到数组[5, 5, 5]
中,并添加结果。
我们可以类似地将这个想法扩展到更高维度的数组。观察当我们将一个一维数组加到一个二维数组时的结果:
In [4]: M = np.ones((3, 3))
M
Out[4]: array([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]])
In [5]: M + a
Out[5]: array([[1., 2., 3.],
[1., 2., 3.],
[1., 2., 3.]])
在这里,一维数组a
通过第二个维度被拉伸或广播,以匹配M
的形状。
尽管这些示例相对容易理解,但更复杂的情况可能涉及广播两个数组。考虑以下例子:
In [6]: a = np.arange(3)
b = np.arange(3)[:, np.newaxis]
print(a)
print(b)
Out[6]: [0 1 2]
[[0]
[1]
[2]]
In [7]: a + b
Out[7]: array([[0, 1, 2],
[1, 2, 3],
[2, 3, 4]])
就像之前我们将一个值拉伸或广播到另一个形状相匹配的数组一样,这里我们拉伸了a
和b
,使它们匹配一个公共形状,结果是一个二维数组!这些示例的几何形状在图 8-1 中可视化。
浅色框表示广播的值。这种关于广播的思考方式可能会引发关于其内存使用效率的疑问,但不用担心:NumPy 广播实际上不会在内存中复制广播的值。尽管如此,这种思维模型在我们思考广播时仍然很有用。
图 8-1. NumPy 广播可视化(改编自 astroML 文档,并获得许可使用)^(1)
Broadcasting 规则
NumPy 中的广播遵循一组严格的规则来确定两个数组之间的交互:
规则 1
如果两个数组在它们的维数上不同,维数较少的数组的形状将在其前导(左)侧填充1
。
规则 2
如果两个数组在任何维度上的形状不匹配,则具有在该维度上形状等于 1 的数组将被拉伸以匹配另一个形状。
规则 3
如果在任何维度上大小不一致且都不等于 1,则会引发错误。
为了澄清这些规则,让我们详细考虑几个例子。
广播示例 1
假设我们想将一个二维数组加到一个一维数组中:
In [8]: M = np.ones((2, 3))
a = np.arange(3)
让我们考虑对这两个具有以下形状的数组进行操作:
-
M.shape
是(2, 3)
-
a.shape
是(3,)
我们看到按照规则 1,数组 a
的维度较少,因此我们在左侧用 1
填充它:
-
M.shape
仍然是(2, 3)
-
a.shape
变为(1, 3)
根据规则 2,我们现在看到第一个维度不匹配,所以我们拉伸这个维度以匹配:
-
M.shape
仍然是(2, 3)
-
a.shape
变为(2, 3)
现在形状匹配了,我们可以看到最终的形状将是 (2, 3)
:
In [9]: M + a
Out[9]: array([[1., 2., 3.],
[1., 2., 3.]])
广播示例 2
现在让我们看一个需要广播两个数组的例子:
In [10]: a = np.arange(3).reshape((3, 1))
b = np.arange(3)
再次,我们将确定数组的形状:
-
a.shape
是(3, 1)
-
b.shape
是(3,)
规则 1 表示我们必须用 1
填充 b
的形状:
-
a.shape
仍然是(3, 1)
-
b.shape
变为(1, 3)
规则 2 告诉我们,我们必须将每个 1
扩展到与另一个数组的相应大小匹配:
-
a.shape
变为(3, 3)
-
b.shape
变为(3, 3)
因为结果匹配,这些形状是兼容的。我们可以在这里看到:
In [11]: a + b
Out[11]: array([[0, 1, 2],
[1, 2, 3],
[2, 3, 4]])
广播示例 3
接下来,让我们看一个两个数组不兼容的例子:
In [12]: M = np.ones((3, 2))
a = np.arange(3)
这只是比第一个例子略有不同的情况:矩阵 M
被转置了。这对计算有什么影响?数组的形状如下:
-
M.shape
是(3, 2)
-
a.shape
是(3,)
再次,规则 1 告诉我们,我们必须用 1
填充 a
的形状:
-
M.shape
仍然是(3, 2)
-
a.shape
变为(1, 3)
根据规则 2,a
的第一个维度被拉伸以匹配 M
的维度:
-
M.shape
仍然是(3, 2)
-
a.shape
变为(3, 3)
现在我们遇到了规则 3——最终的形状不匹配,所以这两个数组是不兼容的,我们可以通过尝试这个操作来观察:
In [13]: M + a
ValueError: operands could not be broadcast together with shapes (3,2) (3,)
注意这里的潜在混淆:你可以想象通过在右侧而不是左侧用 1
填充 a
的形状来使 a
和 M
兼容。但这不是广播规则的工作方式!这种灵活性在某些情况下可能很有用,但它会导致潜在的歧义。如果你想要右侧填充,你可以通过显式地重新塑造数组来实现(我们将在第五章介绍 np.newaxis
关键字来实现这一点):
In [14]: a[:, np.newaxis].shape
Out[14]: (3, 1)
In [15]: M + a[:, np.newaxis]
Out[15]: array([[1., 1.],
[2., 2.],
[3., 3.]])
虽然我们在这里专注于 +
运算符,但这些广播规则适用于任何二元通用函数。例如,这是 logaddexp(a, b)
函数的示例,它计算 log(exp(a) + exp(b))
比朴素方法更精确:
In [16]: np.logaddexp(M, a[:, np.newaxis])
Out[16]: array([[1.31326169, 1.31326169],
[1.69314718, 1.69314718],
[2.31326169, 2.31326169]])
欲知更多关于多个可用的通用函数的信息,请参阅第六章。
实际广播应用
广播操作是本书中许多示例的核心。现在让我们看看它们在哪些情况下可以派上用场。
数组居中
在第 6 章中,我们看到 ufunc 允许 NumPy 用户避免显式编写缓慢的 Python 循环。广播扩展了这种能力。数据科学中经常见到的一个例子是从数据数组中减去逐行均值。假设我们有一个由 10 个观测组成的数组,每个观测包含 3 个值。按照标准惯例(参见第 38 章),我们将其存储在一个数组中:
In [17]: rng = np.random.default_rng(seed=1701)
X = rng.random((10, 3))
我们可以使用沿第一维度的 mean
聚合计算每列的均值:
In [18]: Xmean = X.mean(0)
Xmean
Out[18]: array([0.38503638, 0.36991443, 0.63896043])
现在我们可以通过减去均值来将 X
数组居中(这是一个广播操作):
In [19]: X_centered = X - Xmean
为了确保我们做得正确,我们可以检查居中数组的平均值是否接近零:
In [20]: X_centered.mean(0)
Out[20]: array([ 4.99600361e-17, -4.44089210e-17, 0.00000000e+00])
机器精度内,均值现在为零。
绘制二维函数
广播经常派上用场的一个地方是基于二维函数显示图像。如果我们想定义一个函数,可以使用广播来计算整个网格上的函数:
In [21]: # x and y have 50 steps from 0 to 5
x = np.linspace(0, 5, 50)
y = np.linspace(0, 5, 50)[:, np.newaxis]
z = np.sin(x) ** 10 + np.cos(10 + y * x) * np.cos(x)
我们将使用 Matplotlib 绘制这个二维数组,如图 8-2 所示(这些工具将在第 28 章中全面讨论):
In [22]: %matplotlib inline
import matplotlib.pyplot as plt
In [23]: plt.imshow(z, origin='lower', extent=[0, 5, 0, 5])
plt.colorbar();
图 8-2. 二维数组的可视化
结果是二维函数引人注目的可视化。
^(1) 生成此图的代码可在在线附录中找到。
第九章:比较、掩码和布尔逻辑
本章介绍了使用布尔掩码来检查和操作 NumPy 数组中的值。当你想要基于某些条件提取、修改、计数或以其他方式操作数组中的值时,掩码就会出现:例如,你可能希望计算大于某个特定值的所有值,或者删除所有超过某个阈值的异常值。在 NumPy 中,布尔掩码通常是实现这些任务的最高效方式。
示例:统计下雨天数
想象一下,你有一系列数据,代表了一个城市一年中每天的降水量。例如,在这里我们将加载西雅图市 2015 年的每日降雨统计数据,使用 Pandas(参见第三部分):
In [1]: import numpy as np
from vega_datasets import data
# Use DataFrame operations to extract rainfall as a NumPy array
rainfall_mm = np.array(
data.seattle_weather().set_index('date')['precipitation']['2015'])
len(rainfall_mm)
Out[1]: 365
数组包含 365 个值,从 2015 年 1 月 1 日到 12 月 31 日的每日降雨量(以毫米为单位)。
首先快速可视化,让我们来看一下图 9-1 中的下雨天数直方图,这是使用 Matplotlib 生成的(我们将在第四部分中详细探讨这个工具):
In [2]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
In [3]: plt.hist(rainfall_mm, 40);
图 9-1. 西雅图 2015 年降雨直方图
这个直方图给了我们一个关于数据外观的概括性的想法:尽管西雅图以多雨而闻名,2015 年西雅图大部分日子都几乎没有测得的降水量。但这并没有很好地传达一些我们想要看到的信息:例如,整年有多少天下雨?在那些下雨的日子里,平均降水量是多少?有多少天降水量超过 10 毫米?
其中一种方法是通过手工来回答这些问题:我们可以遍历数据,每次看到在某个期望范围内的值时增加一个计数器。但是出于本章讨论的原因,从编写代码的时间和计算结果的时间来看,这种方法非常低效。我们在第六章中看到,NumPy 的通用函数(ufuncs)可以用来替代循环,在数组上进行快速的逐元素算术操作;同样地,我们可以使用其他通用函数在数组上进行逐元素的比较,然后可以操作结果来回答我们的问题。暂且把数据放在一边,讨论一下 NumPy 中一些常用工具,使用掩码来快速回答这类问题。
比较运算符作为通用函数
第六章介绍了 ufuncs,特别关注算术运算符。我们看到,在数组上使用 +
, -
, *
, /
和其他运算符会导致逐元素操作。NumPy 还实现了比较运算符,如 <
(小于)和 >
(大于)作为逐元素的 ufuncs。这些比较运算符的结果始终是一个布尔数据类型的数组。标准的六种比较操作都是可用的:
In [4]: x = np.array([1, 2, 3, 4, 5])
In [5]: x < 3 # less than
Out[5]: array([ True, True, False, False, False])
In [6]: x > 3 # greater than
Out[6]: array([False, False, False, True, True])
In [7]: x <= 3 # less than or equal
Out[7]: array([ True, True, True, False, False])
In [8]: x >= 3 # greater than or equal
Out[8]: array([False, False, True, True, True])
In [9]: x != 3 # not equal
Out[9]: array([ True, True, False, True, True])
In [10]: x == 3 # equal
Out[10]: array([False, False, True, False, False])
还可以对两个数组进行逐元素比较,并包括复合表达式:
In [11]: (2 * x) == (x ** 2)
Out[11]: array([False, True, False, False, False])
就像算术运算符的情况一样,NumPy 中的比较运算符也被实现为 ufuncs;例如,当你写 x < 3
时,NumPy 内部使用 np.less(x, 3)
。这里显示了比较运算符及其等效的 ufuncs 的摘要:
操作符 | 等效的 ufunc | 操作符 | 等效的 ufunc |
---|---|---|---|
== |
np.equal |
!= |
np.not_equal |
< |
np.less |
<= |
np.less_equal |
> |
np.greater |
>= |
np.greater_equal |
就像算术 ufuncs 的情况一样,这些函数适用于任何大小和形状的数组。这里是一个二维数组的例子:
In [12]: rng = np.random.default_rng(seed=1701)
x = rng.integers(10, size=(3, 4))
x
Out[12]: array([[9, 4, 0, 3],
[8, 6, 3, 1],
[3, 7, 4, 0]])
In [13]: x < 6
Out[13]: array([[False, True, True, True],
[False, False, True, True],
[ True, False, True, True]])
在每种情况下,结果都是一个布尔数组,NumPy 提供了一些简单的模式来处理这些布尔结果。
使用布尔数组
给定一个布尔数组,你可以进行许多有用的操作。我们将使用我们之前创建的二维数组 x
:
In [14]: print(x)
Out[14]: [[9 4 0 3]
[8 6 3 1]
[3 7 4 0]]
计数条目
要计算布尔数组中 True
条目的数量,可以使用 np.count_nonzero
:
In [15]: # how many values less than 6?
np.count_nonzero(x < 6)
Out[15]: 8
我们看到有八个数组条目小于 6。获取这些信息的另一种方法是使用 np.sum
;在这种情况下,False
被解释为 0
,True
被解释为 1
:
In [16]: np.sum(x < 6)
Out[16]: 8
np.sum
的好处在于,与其他 NumPy 聚合函数一样,这种求和可以沿着行或列进行:
In [17]: # how many values less than 6 in each row?
np.sum(x < 6, axis=1)
Out[17]: array([3, 2, 3])
这会统计矩阵每行中小于 6 的值的数量。
如果我们有兴趣快速检查任何或所有的值是否为 True
,我们可以使用(你猜对了)np.any
或 np.all
:
In [18]: # are there any values greater than 8?
np.any(x > 8)
Out[18]: True
In [19]: # are there any values less than zero?
np.any(x < 0)
Out[19]: False
In [20]: # are all values less than 10?
np.all(x < 10)
Out[20]: True
In [21]: # are all values equal to 6?
np.all(x == 6)
Out[21]: False
np.all
和 np.any
也可以沿着特定的轴使用。例如:
In [22]: # are all values in each row less than 8?
np.all(x < 8, axis=1)
Out[22]: array([False, False, True])
这里第三行的所有元素都小于 8,而其他行则不是这样。
最后,一个快速的警告:如第七章中提到的,Python 中有内置的 sum
、any
和 all
函数。它们与 NumPy 版本的语法不同,特别是在多维数组上使用时可能会失败或产生意外的结果。确保在这些示例中使用 np.sum
、np.any
和 np.all
!
布尔运算符
我们已经看到如何计算例如所有降雨量小于 20 毫米的天数,或所有降雨量大于 10 毫米的天数。但是如果我们想知道降雨量大于 10 毫米且小于 20 毫米的天数有多少呢?我们可以用 Python 的位逻辑运算符 &
, |
, ^
, 和 ~
来实现这一点。与标准算术运算符一样,NumPy 将它们重载为 ufuncs,这些 ufuncs 在(通常是布尔)数组上逐元素工作。
例如,我们可以这样处理这种复合问题:
In [23]: np.sum((rainfall_mm > 10) & (rainfall_mm < 20))
Out[23]: 16
这告诉我们,有 16 天的降雨量在 10 到 20 毫米之间。
这里的括号很重要。由于操作符优先级规则,如果去掉括号,这个表达式将按照以下方式进行评估,从而导致错误:
rainfall_mm > (10 & rainfall_mm) < 20
让我们演示一个更复杂的表达式。使用德摩根定律,我们可以以不同的方式计算相同的结果:
In [24]: np.sum(~( (rainfall_mm <= 10) | (rainfall_mm >= 20) ))
Out[24]: 16
在数组上结合比较操作符和布尔操作符,可以进行广泛的高效逻辑操作。
以下表格总结了位运算布尔操作符及其等效的 ufuncs:
运算符 | 等效 ufunc | 运算符 | 等效 ufunc |
---|---|---|---|
& |
np.bitwise_and |
np.bitwise_or |
|
^ |
np.bitwise_xor |
~ |
np.bitwise_not |
使用这些工具,我们可以开始回答关于我们天气数据的许多问题。以下是将布尔操作与聚合结合时可以计算的一些结果示例:
In [25]: print("Number days without rain: ", np.sum(rainfall_mm == 0))
print("Number days with rain: ", np.sum(rainfall_mm != 0))
print("Days with more than 10 mm: ", np.sum(rainfall_mm > 10))
print("Rainy days with < 5 mm: ", np.sum((rainfall_mm > 0) &
(rainfall_mm < 5)))
Out[25]: Number days without rain: 221
Number days with rain: 144
Days with more than 10 mm: 34
Rainy days with < 5 mm: 83
布尔数组作为掩码
在前面的部分中,我们看过直接在布尔数组上计算的聚合。更强大的模式是使用布尔数组作为掩码,选择数据本身的特定子集。让我们回到之前的x
数组:
In [26]: x
Out[26]: array([[9, 4, 0, 3],
[8, 6, 3, 1],
[3, 7, 4, 0]])
假设我们想要一个数组,其中所有值都小于,比如说,5。我们可以轻松地为这个条件获取一个布尔数组,就像我们已经看到的那样:
In [27]: x < 5
Out[27]: array([[False, True, True, True],
[False, False, True, True],
[ True, False, True, True]])
现在,要从数组中选择这些值,我们可以简单地在这个布尔数组上进行索引;这称为掩码操作:
In [28]: x[x < 5]
Out[28]: array([4, 0, 3, 3, 1, 3, 4, 0])
返回的是一个填充有所有满足条件值的一维数组;换句话说,所有掩码数组为True
位置上的值。
然后我们可以自由地按照我们的意愿操作这些值。例如,我们可以计算我们西雅图降雨数据的一些相关统计信息:
In [29]: # construct a mask of all rainy days
rainy = (rainfall_mm > 0)
# construct a mask of all summer days (June 21st is the 172nd day)
days = np.arange(365)
summer = (days > 172) & (days < 262)
print("Median precip on rainy days in 2015 (mm): ",
np.median(rainfall_mm[rainy]))
print("Median precip on summer days in 2015 (mm): ",
np.median(rainfall_mm[summer]))
print("Maximum precip on summer days in 2015 (mm): ",
np.max(rainfall_mm[summer]))
print("Median precip on non-summer rainy days (mm):",
np.median(rainfall_mm[rainy & ~summer]))
Out[29]: Median precip on rainy days in 2015 (mm): 3.8
Median precip on summer days in 2015 (mm): 0.0
Maximum precip on summer days in 2015 (mm): 32.5
Median precip on non-summer rainy days (mm): 4.1
通过组合布尔操作、掩码操作和聚合,我们可以非常快速地回答关于我们数据集的这些问题。
使用关键词 and/or 与操作符 &/|
一个常见的混淆点是关键词 and
和 or
与操作符 &
和 |
之间的区别。什么情况下会使用其中一个而不是另一个?
区别在于:and
和 or
在整个对象上操作,而 &
和 |
在对象内的元素上操作。
当你使用and
或or
时,相当于要求 Python 将对象视为单个布尔实体。在 Python 中,所有非零整数都将被评估为True
。因此:
In [30]: bool(42), bool(0)
Out[30]: (True, False)
In [31]: bool(42 and 0)
Out[31]: False
In [32]: bool(42 or 0)
Out[32]: True
当你在整数上使用&
和|
时,表达式将作用于元素的位表示,对构成数字的各个位应用与或或:
In [33]: bin(42)
Out[33]: '0b101010'
In [34]: bin(59)
Out[34]: '0b111011'
In [35]: bin(42 & 59)
Out[35]: '0b101010'
In [36]: bin(42 | 59)
Out[36]: '0b111011'
注意,要产生结果,需要按顺序比较二进制表示的相应位。
当你在 NumPy 中有一个布尔值数组时,可以将其视为一个比特串,其中1 = True
,0 = False
,&
和|
将类似于前面的示例中的操作:
In [37]: A = np.array([1, 0, 1, 0, 1, 0], dtype=bool)
B = np.array([1, 1, 1, 0, 1, 1], dtype=bool)
A | B
Out[37]: array([ True, True, True, False, True, True])
但是,如果你在这些数组上使用or
,它将尝试评估整个数组对象的真假,这不是一个明确定义的值:
In [38]: A or B
ValueError: The truth value of an array with more than one element is
> ambiguous.
a.any() or a.all()
类似地,当对给定数组评估布尔表达式时,应该使用|
或&
而不是or
或and
:
In [39]: x = np.arange(10)
(x > 4) & (x < 8)
Out[39]: array([False, False, False, False, False, True, True, True, False,
False])
尝试评估整个数组的真假将会产生与我们之前看到的相同的ValueError
:
In [40]: (x > 4) and (x < 8)
ValueError: The truth value of an array with more than one element is
> ambiguous.
a.any() or a.all()
因此,请记住:and
和or
对整个对象执行单个布尔评估,而&
和|
对对象的内容(各个位或字节)执行多个布尔评估。对于布尔 NumPy 数组,后者几乎总是期望的操作。
第十章:Fancy 索引
前面的章节讨论了如何使用简单索引(例如 arr[0]
)、切片(例如 arr[:5]
)和布尔掩码(例如 arr[arr > 0]
)来访问和修改数组的部分内容。在本章中,我们将看看另一种数组索引方式,称为fancy或向量化索引,在这种方式中,我们传递数组索引替代单个标量。这使得我们能够非常快速地访问和修改数组值的复杂子集。
探索 Fancy 索引
Fancy 索引在概念上很简单:它意味着传递一个索引数组以一次访问多个数组元素。例如,考虑以下数组:
In [1]: import numpy as np
rng = np.random.default_rng(seed=1701)
x = rng.integers(100, size=10)
print(x)
Out[1]: [90 40 9 30 80 67 39 15 33 79]
假设我们要访问三个不同的元素。我们可以这样做:
In [2]: [x[3], x[7], x[2]]
Out[2]: [30, 15, 9]
或者,我们可以传递一个单一的索引列表或数组来获取相同的结果:
In [3]: ind = [3, 7, 4]
x[ind]
Out[3]: array([30, 15, 80])
当使用索引数组时,结果的形状反映了索引数组的形状而不是被索引数组的形状:
In [4]: ind = np.array([[3, 7],
[4, 5]])
x[ind]
Out[4]: array([[30, 15],
[80, 67]])
fancy 索引也适用于多维度。考虑以下数组:
In [5]: X = np.arange(12).reshape((3, 4))
X
Out[5]: array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
就像标准索引一样,第一个索引指的是行,第二个指的是列:
In [6]: row = np.array([0, 1, 2])
col = np.array([2, 1, 3])
X[row, col]
Out[6]: array([ 2, 5, 11])
注意结果中的第一个值是 X[0, 2]
,第二个是 X[1, 1]
,第三个是 X[2, 3]
。在 fancy 索引中索引的配对遵循所有广播规则,这些规则在第八章中已经提到。因此,例如,如果我们在索引中组合列向量和行向量,我们将得到一个二维结果:
In [7]: X[row[:, np.newaxis], col]
Out[7]: array([[ 2, 1, 3],
[ 6, 5, 7],
[10, 9, 11]])
在这里,每行的值与每列向量匹配,正如我们在算术操作的广播中看到的一样。例如:
In [8]: row[:, np.newaxis] * col
Out[8]: array([[0, 0, 0],
[2, 1, 3],
[4, 2, 6]])
使用 fancy 索引时,始终重要的是记住返回值反映了广播后的索引形状,而不是被索引数组的形状。
结合索引
对于更强大的操作,可以将 fancy 索引与我们之前看到的其他索引方案结合使用。例如,给定数组 X
:
In [9]: print(X)
Out[9]: [[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
我们可以将 fancy 索引与简单索引结合使用:
In [10]: X[2, [2, 0, 1]]
Out[10]: array([10, 8, 9])
我们还可以将 fancy 索引与切片结合使用:
In [11]: X[1:, [2, 0, 1]]
Out[11]: array([[ 6, 4, 5],
[10, 8, 9]])
并且我们可以将 fancy 索引与掩码结合使用:
In [12]: mask = np.array([True, False, True, False])
X[row[:, np.newaxis], mask]
Out[12]: array([[ 0, 2],
[ 4, 6],
[ 8, 10]])
所有这些索引选项的结合为有效访问和修改数组值提供了非常灵活的操作集。
示例:选择随机点
fancy 索引的一个常见用途是从矩阵中选择行的子集。例如,我们可能有一个表示 × 维度的矩阵,如从二维正态分布中抽取的以下点:
In [13]: mean = [0, 0]
cov = [[1, 2],
[2, 5]]
X = rng.multivariate_normal(mean, cov, 100)
X.shape
Out[13]: (100, 2)
使用我们将在第四部分讨论的绘图工具,我们可以将这些点可视化为散点图(Figure 10-1)。
In [14]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
plt.scatter(X[:, 0], X[:, 1]);
图 10-1。正态分布的点
让我们使用高级索引选择 20 个随机点。我们将首先选择 20 个随机索引,而不重复,然后使用这些索引来选择原始数组的一部分:
In [15]: indices = np.random.choice(X.shape[0], 20, replace=False)
indices
Out[15]: array([82, 84, 10, 55, 14, 33, 4, 16, 34, 92, 99, 64, 8, 76, 68, 18, 59,
80, 87, 90])
In [16]: selection = X[indices] # fancy indexing here
selection.shape
Out[16]: (20, 2)
现在,为了查看选定的点,请在选定点的位置上添加大圆圈(请参阅图 10-2)。
In [17]: plt.scatter(X[:, 0], X[:, 1], alpha=0.3)
plt.scatter(selection[:, 0], selection[:, 1],
facecolor='none', edgecolor='black', s=200);
图 10-2。点之间的随机选择
这种策略通常用于快速分割数据集,就像在统计模型验证中经常需要的训练/测试拆分那样(见第三十九章),以及回答统计问题的抽样方法。
使用高级索引修改值
正如高级索引可用于访问数组的部分一样,它也可以用于修改数组的部分。例如,想象一下我们有一个索引数组,我们想将数组中的相应项目设置为某个值:
In [18]: x = np.arange(10)
i = np.array([2, 1, 8, 4])
x[i] = 99
print(x)
Out[18]: [ 0 99 99 3 99 5 6 7 99 9]
我们可以使用任何赋值类型的运算符。例如:
In [19]: x[i] -= 10
print(x)
Out[19]: [ 0 89 89 3 89 5 6 7 89 9]
注意,使用这些操作的重复索引可能会导致一些可能意想不到的结果。请考虑以下情况:
In [20]: x = np.zeros(10)
x[[0, 0]] = [4, 6]
print(x)
Out[20]: [6. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
4 去哪了?此操作首先将x[0] = 4
赋值,然后是x[0] = 6
。结果,当然是x[0]
包含值 6。
够公平了,但请考虑以下操作:
In [21]: i = [2, 3, 3, 4, 4, 4]
x[i] += 1
x
Out[21]: array([6., 0., 1., 1., 1., 0., 0., 0., 0., 0.])
您可能期望x[3]
包含值 2,x[4]
包含值 3,因为这是每个索引重复的次数。为什么不是这种情况?从概念上讲,这是因为x[i] += 1
被理解为x[i] = x[i] + 1
的简写形式。x[i] + 1
被计算,然后结果被赋值给x
中的索引。考虑到这一点,增加发生的次数不是多次,而是分配,这导致了相当非直观的结果。
那么,如果您想要重复操作的其他行为呢?对此,您可以使用 ufuncs 的at
方法,并执行以下操作:
In [22]: x = np.zeros(10)
np.add.at(x, i, 1)
print(x)
Out[22]: [0. 0. 1. 2. 3. 0. 0. 0. 0. 0.]
at
方法在指定的索引(这里是i
)处以指定的值(这里是 1)进行给定操作的就地应用。另一种在精神上类似的方法是 ufuncs 的reduceat
方法,您可以在NumPy 文档中阅读有关它的信息。
例:分箱数据
您可以使用这些思想来有效地对数据进行自定义分组计算。例如,假设我们有 100 个值,并且想要快速找到它们在一个箱子数组中的位置。我们可以像这样使用ufunc.at
来计算:
In [23]: rng = np.random.default_rng(seed=1701)
x = rng.normal(size=100)
# compute a histogram by hand
bins = np.linspace(-5, 5, 20)
counts = np.zeros_like(bins)
# find the appropriate bin for each x
i = np.searchsorted(bins, x)
# add 1 to each of these bins
np.add.at(counts, i, 1)
现在,计数反映了每个箱中的点数——换句话说,是一个直方图(请参阅图 10-3)。
In [24]: # plot the results
plt.plot(bins, counts, drawstyle='steps');
图 10-3。手动计算的直方图
当然,每次想要绘制直方图时都这样做是很不方便的。这就是为什么 Matplotlib 提供了plt.hist
例程,它可以在一行代码中完成相同的操作:
plt.hist(x, bins, histtype='step');
这个函数将创建一个几乎与刚刚显示的图表完全相同的图。为了计算分箱,Matplotlib 使用了np.histogram
函数,这个函数与我们之前做过的计算非常相似。让我们在这里比较一下两者:
In [25]: print(f"NumPy histogram ({len(x)} points):")
%timeit counts, edges = np.histogram(x, bins)
print(f"Custom histogram ({len(x)} points):")
%timeit np.add.at(counts, np.searchsorted(bins, x), 1)
Out[25]: NumPy histogram (100 points):
33.8 µs ± 311 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
Custom histogram (100 points):
17.6 µs ± 113 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
我们自己的一行代码算法比 NumPy 中优化算法快两倍!这怎么可能呢?如果你深入查看np.histogram
的源代码(你可以在 IPython 中键入np.histogram??
来做到这一点),你会看到它比我们做的简单的搜索和计数要复杂得多;这是因为 NumPy 的算法更灵活,特别是在数据点数量变大时性能更好的设计:
In [26]: x = rng.normal(size=1000000)
print(f"NumPy histogram ({len(x)} points):")
%timeit counts, edges = np.histogram(x, bins)
print(f"Custom histogram ({len(x)} points):")
%timeit np.add.at(counts, np.searchsorted(bins, x), 1)
Out[26]: NumPy histogram (1000000 points):
84.4 ms ± 2.82 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
Custom histogram (1000000 points):
128 ms ± 2.04 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
这个比较显示的是算法效率几乎从来都不是一个简单的问题。一个对大数据集有效的算法并不总是小数据集的最佳选择,反之亦然(见第十一章)。但是自己编写此算法的优势在于,掌握了这些基本方法之后,天空就是你的极限:你不再局限于内置程序,而是可以创造自己的方法来探索数据。在数据密集型应用中高效使用 Python 的关键不仅在于了解像np.histogram
这样的通用方便函数及其适用时机,还在于知道如何在需要更具针对性行为时利用低级功能。
第十一章:数组排序
到目前为止,我们主要关注使用 NumPy 访问和操作数组数据的工具。本章涵盖了与 NumPy 数组中值排序相关的算法。这些算法是计算机科学导论课程的热门话题:如果你曾经参加过这样的课程,你可能曾经梦想过(或者,根据你的性格,噩梦)插入排序、选择排序、归并排序、快速排序、冒泡排序等等。所有这些方法都是完成相似任务的手段:对列表或数组中的值进行排序。
Python 有几个用于排序列表和其他可迭代对象的内置函数和方法。sorted
函数接受一个列表并返回其排序版本:
In [1]: L = [3, 1, 4, 1, 5, 9, 2, 6]
sorted(L) # returns a sorted copy
Out[1]: [1, 1, 2, 3, 4, 5, 6, 9]
相比之下,列表的sort
方法会就地对列表进行排序:
In [2]: L.sort() # acts in-place and returns None
print(L)
Out[2]: [1, 1, 2, 3, 4, 5, 6, 9]
Python 的排序方法非常灵活,可以处理任何可迭代对象。例如,这里我们对一个字符串进行排序:
In [3]: sorted('python')
Out[3]: ['h', 'n', 'o', 'p', 't', 'y']
这些内置的排序方法很方便,但正如前面讨论的那样,Python 值的动态性意味着它们的性能比专门设计用于均匀数组的例程要差。这就是 NumPy 排序例程的用武之地。
NumPy 中的快速排序: np.sort 和 np.argsort
np.sort
函数类似于 Python 的内置sorted
函数,并且能够高效地返回数组的排序副本:
In [4]: import numpy as np
x = np.array([2, 1, 4, 3, 5])
np.sort(x)
Out[4]: array([1, 2, 3, 4, 5])
类似于 Python 列表的sort
方法,你也可以使用数组的sort
方法原地对数组进行排序:
In [5]: x.sort()
print(x)
Out[5]: [1 2 3 4 5]
相关的函数是argsort
,它返回排序元素的索引:
In [6]: x = np.array([2, 1, 4, 3, 5])
i = np.argsort(x)
print(i)
Out[6]: [1 0 3 2 4]
结果的第一个元素给出了最小元素的索引,第二个值给出了第二小的索引,依此类推。如果需要的话,这些索引可以用(通过花式索引)来构造排序后的数组:
In [7]: x[i]
Out[7]: array([1, 2, 3, 4, 5])
你将在本章后面看到argsort
的应用。
沿行或列排序
NumPy 排序算法的一个有用特性是可以使用axis
参数沿着多维数组的特定行或列进行排序。例如:
In [8]: rng = np.random.default_rng(seed=42)
X = rng.integers(0, 10, (4, 6))
print(X)
Out[8]: [[0 7 6 4 4 8]
[0 6 2 0 5 9]
[7 7 7 7 5 1]
[8 4 5 3 1 9]]
In [9]: # sort each column of X
np.sort(X, axis=0)
Out[9]: array([[0, 4, 2, 0, 1, 1],
[0, 6, 5, 3, 4, 8],
[7, 7, 6, 4, 5, 9],
[8, 7, 7, 7, 5, 9]])
In [10]: # sort each row of X
np.sort(X, axis=1)
Out[10]: array([[0, 4, 4, 6, 7, 8],
[0, 0, 2, 5, 6, 9],
[1, 5, 7, 7, 7, 7],
[1, 3, 4, 5, 8, 9]])
请注意,这将把每行或列视为独立的数组,行或列值之间的任何关系都将丢失!
部分排序: 分区
有时候我们并不想对整个数组进行排序,而只是想找出数组中最小的k个值。NumPy 通过np.partition
函数实现了这一点。np.partition
接受一个数组和一个数k;结果是一个新数组,最小的k个值位于分区的左侧,剩余的值位于右侧:
In [11]: x = np.array([7, 2, 3, 1, 6, 5, 4])
np.partition(x, 3)
Out[11]: array([2, 1, 3, 4, 6, 5, 7])
注意结果数组中的前三个值是数组中最小的三个值,剩下的数组位置包含剩余的值。在这两个分区中,元素的顺序是任意的。
类似于排序,我们也可以沿着多维数组的任意轴进行分区:
In [12]: np.partition(X, 2, axis=1)
Out[12]: array([[0, 4, 4, 7, 6, 8],
[0, 0, 2, 6, 5, 9],
[1, 5, 7, 7, 7, 7],
[1, 3, 4, 5, 8, 9]])
结果是一个数组,其中每行的前两个槽包含该行的最小值,其余值填充其余槽位。
最后,就像有一个计算排序索引的np.argsort
函数一样,有一个计算分区索引的np.argpartition
函数。我们将在接下来的部分中看到这两者的作用。
示例:k-最近邻算法
让我们快速看看如何沿着多个轴使用argsort
函数来找到集合中每个点的最近邻居。我们将从在二维平面上创建的随机 10 个点集开始。按照标准约定,我们将这些点排列在一个数组中:
In [13]: X = rng.random((10, 2))
为了了解这些点的外观,让我们生成一个快速的散点图(见图 11-1)。
In [14]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
plt.scatter(X[:, 0], X[:, 1], s=100);
图 11-1. k-最近邻示例中的点的可视化
现在我们将计算每对点之间的距离。回想一下,两点之间的平方距离是每个维度上平方差的和;使用 NumPy 提供的高效广播(第八章)和聚合(第七章)例程,我们可以在一行代码中计算出平方距离矩阵:
In [15]: dist_sq = np.sum((X[:, np.newaxis] - X[np.newaxis, :]) ** 2, axis=-1)
这个操作包含了很多内容,如果你不熟悉 NumPy 的广播规则,可能会感到有些困惑。当你遇到这样的代码时,将其分解为各个步骤可能会很有用:
In [16]: # for each pair of points, compute differences in their coordinates
differences = X[:, np.newaxis] - X[np.newaxis, :]
differences.shape
Out[16]: (10, 10, 2)
In [17]: # square the coordinate differences
sq_differences = differences ** 2
sq_differences.shape
Out[17]: (10, 10, 2)
In [18]: # sum the coordinate differences to get the squared distance
dist_sq = sq_differences.sum(-1)
dist_sq.shape
Out[18]: (10, 10)
作为我们逻辑的快速检查,我们应该看到这个矩阵的对角线(即每个点与自身之间的距离集合)全为零:
In [19]: dist_sq.diagonal()
Out[19]: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
一旦转换为成对的平方距离,我们现在可以使用np.argsort
沿着每一行排序。最左边的列将给出最近邻居的索引:
In [20]: nearest = np.argsort(dist_sq, axis=1)
print(nearest)
Out[20]: [[0 9 3 5 4 8 1 6 2 7]
[1 7 2 6 4 8 3 0 9 5]
[2 7 1 6 4 3 8 0 9 5]
[3 0 4 5 9 6 1 2 8 7]
[4 6 3 1 2 7 0 5 9 8]
[5 9 3 0 4 6 8 1 2 7]
[6 4 2 1 7 3 0 5 9 8]
[7 2 1 6 4 3 8 0 9 5]
[8 0 1 9 3 4 7 2 6 5]
[9 0 5 3 4 8 6 1 2 7]]
注意,第一列按顺序给出了数字 0 到 9:这是因为每个点的最近邻居是它自己,这是我们预期的结果。
在这里使用完全排序,实际上做了比需要的更多的工作。如果我们只是对最近的个邻居感兴趣,我们只需对每一行进行分区,使得最小的个平方距离首先出现,剩余的距离填充数组的其余位置。我们可以使用np.argpartition
函数实现这一点:
In [21]: K = 2
nearest_partition = np.argpartition(dist_sq, K + 1, axis=1)
为了可视化这些邻居的网络,让我们快速绘制这些点以及代表从每个点到其两个最近邻居的连接的线条(见图 11-2)。
In [22]: plt.scatter(X[:, 0], X[:, 1], s=100)
# draw lines from each point to its two nearest neighbors
K = 2
for i in range(X.shape[0]):
for j in nearest_partition[i, :K+1]:
# plot a line from X[i] to X[j]
# use some zip magic to make it happen:
plt.plot(*zip(X[j], X[i]), color='black')
图 11-2. 每个点的最近邻居的可视化
每个图中的点都有线连接到其两个最近的邻居。乍一看,一些点有超过两条线连接可能会显得奇怪:这是因为如果点 A 是点 B 的两个最近邻之一,这并不一定意味着点 B 是点 A 的两个最近邻之一。
尽管这种方法的广播和行排序可能比编写循环不那么直观,但事实证明这是一种非常高效的在 Python 中处理这些数据的方法。您可能会尝试通过手动循环遍历数据并逐个排序每组邻居来执行相同类型的操作,但这几乎肯定会导致比我们使用的向量化版本更慢的算法。这种方法的美妙之处在于它以一种对输入数据大小不可知的方式编写:我们可以轻松地在任意维度中计算 100 个或 1,000,000 个点之间的邻居,代码看起来都一样。
最后,我要注意的是,在进行非常大的最近邻搜索时,有基于树的和/或近似算法可以扩展为或更好,而不是粗暴算法的。这种算法的一个例子是 KD-Tree,在 Scikit-Learn 中实现。
第十二章:结构化数据:NumPy 的结构化数组
虽然通常我们的数据可以用值的同类数组很好地表示,但有时情况并非如此。本章演示了 NumPy 的结构化数组和记录数组的使用,它们为复合异构数据提供了高效的存储。虽然这里展示的模式对于简单操作很有用,但像这样的情景通常适合使用 Pandas 的DataFrame
,我们将在第三部分中探讨。
In [1]: import numpy as np
假设我们有几类数据关于一些人(比如,姓名、年龄和体重),我们想要存储这些值以供在 Python 程序中使用。可以将它们分别存储在三个单独的数组中:
In [2]: name = ['Alice', 'Bob', 'Cathy', 'Doug']
age = [25, 45, 37, 19]
weight = [55.0, 85.5, 68.0, 61.5]
但这有点笨拙。这里没有告诉我们这三个数组是相关的;NumPy 的结构化数组允许我们通过使用单一结构更自然地存储所有这些数据。
回顾之前我们用这样的表达式创建了一个简单的数组:
In [3]: x = np.zeros(4, dtype=int)
我们可以类似地使用复合数据类型规范来创建结构化数组:
In [4]: # Use a compound data type for structured arrays
data = np.zeros(4, dtype={'names':('name', 'age', 'weight'),
'formats':('U10', 'i4', 'f8')})
print(data.dtype)
Out[4]: [('name', '<U10'), ('age', '<i4'), ('weight', '<f8')]
这里的'U10'
被翻译为“最大长度为 10 的 Unicode 字符串”,'i4'
被翻译为“4 字节(即 32 位)整数”,而'f8'
被翻译为“8 字节(即 64 位)浮点数”。我们将在下一节讨论这些类型代码的其他选项。
现在我们创建了一个空的容器数组,我们可以用我们的值列表填充这个数组:
In [5]: data['name'] = name
data['age'] = age
data['weight'] = weight
print(data)
Out[5]: [('Alice', 25, 55. ) ('Bob', 45, 85.5) ('Cathy', 37, 68. )
('Doug', 19, 61.5)]
正如我们所希望的那样,数据现在方便地排列在一个结构化数组中。
结构化数组的方便之处在于,我们现在既可以按索引也可以按名称引用值:
In [6]: # Get all names
data['name']
Out[6]: array(['Alice', 'Bob', 'Cathy', 'Doug'], dtype='<U10')
In [7]: # Get first row of data
data[0]
Out[7]: ('Alice', 25, 55.)
In [8]: # Get the name from the last row
data[-1]['name']
Out[8]: 'Doug'
使用布尔遮罩,我们甚至可以进行一些更复杂的操作,例如按年龄进行过滤:
In [9]: # Get names where age is under 30
data[data['age'] < 30]['name']
Out[9]: array(['Alice', 'Doug'], dtype='<U10')
如果您想要进行比这些更复杂的操作,您可能应该考虑 Pandas 包,详细介绍请参阅第四部分。正如您将看到的,Pandas 提供了一个DataFrame
对象,它是建立在 NumPy 数组上的结构,提供了许多有用的数据操作功能,类似于您在这里看到的,以及更多更多。
探索结构化数组的创建
结构化数组数据类型可以用多种方式指定。早些时候,我们看到了字典方法:
In [10]: np.dtype({'names':('name', 'age', 'weight'),
'formats':('U10', 'i4', 'f8')})
Out[10]: dtype([('name', '<U10'), ('age', '<i4'), ('weight', '<f8')])
为了清晰起见,数值类型可以使用 Python 类型或 NumPy dtype
来指定:
In [11]: np.dtype({'names':('name', 'age', 'weight'),
'formats':((np.str_, 10), int, np.float32)})
Out[11]: dtype([('name', '<U10'), ('age', '<i8'), ('weight', '<f4')])
复合类型也可以作为元组列表来指定:
In [12]: np.dtype([('name', 'S10'), ('age', 'i4'), ('weight', 'f8')])
Out[12]: dtype([('name', 'S10'), ('age', '<i4'), ('weight', '<f8')])
如果类型名称对您不重要,您可以单独在逗号分隔的字符串中指定这些类型:
In [13]: np.dtype('S10,i4,f8')
Out[13]: dtype([('f0', 'S10'), ('f1', '<i4'), ('f2', '<f8')])
缩短的字符串格式代码可能并不直观,但它们基于简单的原则构建。第一个(可选)字符 <
或 >
,表示“小端”或“大端”,分别指定了显著位的排序约定。接下来的字符指定数据类型:字符、字节、整数、浮点数等(参见表 12-1)。最后一个字符或多个字符表示对象的字节大小。
表 12-1. NumPy 数据类型
字符 | 描述 | 示例 |
---|---|---|
'b' |
字节 | np.dtype('b') |
'i' |
有符号整数 | np.dtype('i4') == np.int32 |
'u' |
无符号整数 | np.dtype('u1') == np.uint8 |
'f' |
浮点数 | np.dtype('f8') == np.int64 |
'c' |
复数浮点数 | np.dtype('c16') == np.complex128 |
'S' , 'a' |
字符串 | np.dtype('S5') |
'U' |
Unicode 字符串 | np.dtype('U') == np.str_ |
'V' |
原始数据(空) | np.dtype('V') == np.void |
更高级的复合类型
可以定义更高级的复合类型。例如,您可以创建每个元素包含值数组或矩阵的类型。在这里,我们将创建一个数据类型,其 mat
组件由一个 浮点矩阵组成:
In [14]: tp = np.dtype([('id', 'i8'), ('mat', 'f8', (3, 3))])
X = np.zeros(1, dtype=tp)
print(X[0])
print(X['mat'][0])
Out[14]: (0, [[0., 0., 0.], [0., 0., 0.], [0., 0., 0.]])
[[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]]
现在 X
数组中的每个元素包含一个 id
和一个 矩阵。为什么您会使用这个而不是简单的多维数组,或者可能是 Python 字典?一个原因是这个 NumPy dtype
直接映射到 C 结构定义,因此包含数组内容的缓冲区可以在适当编写的 C 程序中直接访问。如果您发现自己编写一个 Python 接口来操作结构化数据的传统 C 或 Fortran 库,结构化数组可以提供一个强大的接口。
记录数组:带有变化的结构化数组
NumPy 还提供了记录数组(np.recarray
类的实例),几乎与上述结构化数组相同,但有一个附加功能:可以将字段作为属性而不是字典键访问。回顾我们之前通过编写来访问样本数据集中的年龄:
In [15]: data['age']
Out[15]: array([25, 45, 37, 19], dtype=int32)
如果我们将数据视为记录数组,我们可以用稍少的按键操作访问它:
In [16]: data_rec = data.view(np.recarray)
data_rec.age
Out[16]: array([25, 45, 37, 19], dtype=int32)
缺点在于,对于记录数组,即使使用相同的语法,访问字段时也涉及一些额外的开销:
In [17]: %timeit data['age']
%timeit data_rec['age']
%timeit data_rec.age
Out[17]: 121 ns ± 1.4 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
2.41 µs ± 15.7 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
3.98 µs ± 20.5 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
是否更便利的表示形式值得(轻微的)开销将取决于您自己的应用程序。
转向 Pandas
本章节关于结构化数组和记录数组被特意放置在本书的这一部分的末尾,因为它很好地过渡到我们将要介绍的下一个包:Pandas。结构化数组在某些情况下非常有用,比如当你使用 NumPy 数组来映射到 C、Fortran 或其他语言的二进制数据格式时。但对于日常使用结构化数据,Pandas 包是一个更好的选择;我们将在接下来的章节深入探讨它。
第三部分:使用 Pandas 进行数据操作
在 第二部分 中,我们详细介绍了 NumPy 及其 ndarray
对象,该对象使得在 Python 中高效存储和操作密集类型数组成为可能。在这里,我们将通过深入研究 Pandas 库提供的数据结构来构建这些知识。Pandas 是一个建立在 NumPy 之上的较新的包,提供了 DataFrame
的高效实现。DataFrame
本质上是带有附加行和列标签的多维数组,通常具有异构类型和/或缺失数据。除了为标记数据提供方便的存储接口外,Pandas 还实现了许多强大的数据操作,这些操作对数据库框架和电子表格程序的用户来说都很熟悉。
正如我们所见,NumPy 的 ndarray
数据结构为在数值计算任务中通常看到的整洁、良好组织的数据类型提供了基本功能。虽然它非常适合这个目的,但当我们需要更多的灵活性时(例如,将标签附加到数据、处理缺失数据等)以及当尝试的操作无法很好地映射到逐元素广播时(例如,分组、数据透视等),其中每个操作都是分析周围许多形式的不太结构化数据中的重要组成部分时,其局限性变得明显。Pandas,特别是其 Series
和 DataFrame
对象,基于 NumPy 数组结构,并提供了有效的访问这些“数据清洗”任务的方法,这些任务占据了数据科学家大部分时间。
在本书的这一部分中,我们将重点介绍如何有效地使用 Series
、DataFrame
和相关结构的机制。我们将在适当的情况下使用从真实数据集中提取的示例,但这些示例并不一定是重点。
注意
在您的系统上安装 Pandas 需要 NumPy,如果您正在从源代码构建库,则需要适当的工具来编译 Pandas 构建的 C 和 Cython 源代码。有关安装过程的详细信息可以在 Pandas 文档 中找到。如果您遵循了 前言 中概述的建议并使用了 Anaconda 栈,您已经安装了 Pandas。
一旦安装了 Pandas,你就可以导入它并检查版本;以下是本书使用的版本:
In [1]: import pandas
pandas.__version__
Out[1]: '1.3.5'
就像我们通常将 NumPy 导入为别名 np
一样,我们将 Pandas 导入为别名 pd
:
In [2]: import pandas as pd
在本书的剩余部分将使用此导入约定。
第十三章:介绍 Pandas 对象
在非常基本的层面上,Pandas 对象可以被认为是 NumPy 结构化数组的增强版本,其中的行和列被标签而不是简单的整数索引所标识。正如我们将在本章课程中看到的那样,Pandas 在基本数据结构之上提供了一系列有用的工具、方法和功能,但几乎所有接下来的内容都需要理解这些结构。因此,在我们继续之前,让我们看看这三种基本的 Pandas 数据结构:Series
、DataFrame
和 Index
。
我们将从标准的 NumPy 和 Pandas 导入开始我们的代码会话:
In [1]: import numpy as np
import pandas as pd
Pandas Series 对象
Pandas Series
是一个索引数据的一维数组。它可以从列表或数组创建如下:
In [2]: data = pd.Series([0.25, 0.5, 0.75, 1.0])
data
Out[2]: 0 0.25
1 0.50
2 0.75
3 1.00
dtype: float64
Series
将一系列值与显式的索引序列结合在一起,我们可以使用 values
和 index
属性来访问。values
简单地是一个熟悉的 NumPy 数组:
In [3]: data.values
Out[3]: array([0.25, 0.5 , 0.75, 1. ])
index
是一个 pd.Index
类型的类似数组的对象,我们稍后会更详细地讨论:
In [4]: data.index
Out[4]: RangeIndex(start=0, stop=4, step=1)
与 NumPy 数组类似,数据可以通过相关的索引使用熟悉的 Python 方括号符号进行访问:
In [5]: data[1]
Out[5]: 0.5
In [6]: data[1:3]
Out[6]: 1 0.50
2 0.75
dtype: float64
正如我们将看到的,Pandas Series
比它模拟的一维 NumPy 数组要更加一般化和灵活。
作为广义的 NumPy 数组的 Series
从目前我们所见,Series
对象可能看起来基本上可以与一维 NumPy 数组互换。本质上的区别在于,虽然 NumPy 数组具有用于访问值的隐式定义整数索引,但 Pandas Series
具有与值关联的显式定义索引。
这种显式索引定义赋予了 Series
对象额外的能力。例如,索引不必是整数,而可以是任何所需类型的值。所以,如果我们希望,我们可以使用字符串作为索引:
In [7]: data = pd.Series([0.25, 0.5, 0.75, 1.0],
index=['a', 'b', 'c', 'd'])
data
Out[7]: a 0.25
b 0.50
c 0.75
d 1.00
dtype: float64
并且项目访问按预期工作:
In [8]: data['b']
Out[8]: 0.5
我们甚至可以使用非连续或非顺序的索引:
In [9]: data = pd.Series([0.25, 0.5, 0.75, 1.0],
index=[2, 5, 3, 7])
data
Out[9]: 2 0.25
5 0.50
3 0.75
7 1.00
dtype: float64
In [10]: data[5]
Out[10]: 0.5
作为专用字典的 Series
以这种方式,你可以把 Pandas 的 Series
想象成 Python 字典的一个特殊版本。字典是一个将任意键映射到一组任意值的结构,而 Series
是一个将类型化键映射到一组类型化值的结构。这种类型化很重要:正如 NumPy 数组背后的特定类型的编译代码使其在某些操作上比 Python 列表更高效一样,Pandas Series
的类型信息使其在某些操作上比 Python 字典更高效。
Series
-作为字典的类比可以通过直接从 Python 字典构造 Series
对象来更清晰地解释,例如根据 2020 年人口普查得到的五个最多人口的美国州:
In [11]: population_dict = {'California': 39538223, 'Texas': 29145505,
'Florida': 21538187, 'New York': 20201249,
'Pennsylvania': 13002700}
population = pd.Series(population_dict)
population
Out[11]: California 39538223
Texas 29145505
Florida 21538187
New York 20201249
Pennsylvania 13002700
dtype: int64
从这里,可以执行典型的字典式项目访问:
In [12]: population['California']
Out[12]: 39538223
不同于字典,Series
也支持数组式的操作,比如切片:
In [13]: population['California':'Florida']
Out[13]: California 39538223
Texas 29145505
Florida 21538187
dtype: int64
我们将在第十四章讨论 Pandas 索引和切片的一些怪癖。
构建 Series 对象
我们已经看到了几种从头开始构建 Pandas Series
的方法。所有这些方法都是以下版本的某种形式:
pd.Series(data, index=index)
其中index
是一个可选参数,data
可以是多个实体之一。
例如,data
可以是一个列表或 NumPy 数组,此时index
默认为整数序列:
In [14]: pd.Series([2, 4, 6])
Out[14]: 0 2
1 4
2 6
dtype: int64
或者data
可以是一个标量,它被重复以填充指定的索引:
In [15]: pd.Series(5, index=[100, 200, 300])
Out[15]: 100 5
200 5
300 5
dtype: int64
或者它可以是一个字典,此时index
默认为字典的键:
In [16]: pd.Series({2:'a', 1:'b', 3:'c'})
Out[16]: 2 a
1 b
3 c
dtype: object
在每种情况下,都可以显式设置索引以控制使用的键的顺序或子集:
In [17]: pd.Series({2:'a', 1:'b', 3:'c'}, index=[1, 2])
Out[17]: 1 b
2 a
dtype: object
Pandas DataFrame 对象
Pandas 中的下一个基本结构是DataFrame
。与前一节讨论的Series
对象类似,DataFrame
可以被视为 NumPy 数组的泛化,或者 Python 字典的特殊化。我们现在将看看每种观点。
DataFrame 作为广义的 NumPy 数组
如果Series
是具有显式索引的一维数组的类比,那么DataFrame
就是具有显式行和列索引的二维数组的类比。正如你可能把二维数组看作一系列对齐的一维列的有序序列一样,你可以把DataFrame
看作一系列对齐的Series
对象。这里,“对齐”意味着它们共享相同的索引。
为了演示这一点,让我们首先构建一个新的Series
,列出前一节讨论的五个州的面积(以平方公里为单位):
In [18]: area_dict = {'California': 423967, 'Texas': 695662, 'Florida': 170312,
'New York': 141297, 'Pennsylvania': 119280}
area = pd.Series(area_dict)
area
Out[18]: California 423967
Texas 695662
Florida 170312
New York 141297
Pennsylvania 119280
dtype: int64
现在,我们已经有了与之前的population
系列一起的信息,我们可以使用字典构造一个包含此信息的单个二维对象:
In [19]: states = pd.DataFrame({'population': population,
'area': area})
states
Out[19]: population area
California 39538223 423967
Texas 29145505 695662
Florida 21538187 170312
New York 20201249 141297
Pennsylvania 13002700 119280
与Series
对象类似,DataFrame
还有一个index
属性,用于访问索引标签:
In [20]: states.index
Out[20]: Index(['California', 'Texas', 'Florida', 'New York', 'Pennsylvania'],
> dtype='object')
此外,DataFrame
还有一个columns
属性,它是一个Index
对象,保存列标签:
In [21]: states.columns
Out[21]: Index(['population', 'area'], dtype='object')
因此,DataFrame
可以被视为二维 NumPy 数组的泛化,其中行和列都有用于访问数据的泛化索引。
DataFrame 作为特殊的字典
同样,我们也可以把DataFrame
视为字典的特殊情况。在字典将键映射到值的情况下,DataFrame
将列名映射到包含列数据的Series
。例如,请求'area'
属性将返回包含我们之前看到的面积的Series
对象:
In [22]: states['area']
Out[22]: California 423967
Texas 695662
Florida 170312
New York 141297
Pennsylvania 119280
Name: area, dtype: int64
注意这里可能的混淆点:在一个二维 NumPy 数组中,data[0]
将返回第一行。对于 DataFrame
,data['col0']
将返回第一列。因此,最好将 DataFrame
视为广义的字典,而不是广义的数组,尽管两种视角都是有用的。我们将在 第十四章 探讨更灵活的 DataFrame
索引方式。
构造 DataFrame 对象
Pandas DataFrame
可以以多种方式构建。这里我们将探讨几个例子。
从单个 Series 对象
DataFrame
是 Series
对象的集合,一个单列的 DataFrame
可以从一个单独的 Series
构建出来:
In [23]: pd.DataFrame(population, columns=['population'])
Out[23]: population
California 39538223
Texas 29145505
Florida 21538187
New York 20201249
Pennsylvania 13002700
从字典列表
任何字典列表都可以转换成 DataFrame
。我们将使用一个简单的列表推导来创建一些数据:
In [24]: data = [{'a': i, 'b': 2 * i}
for i in range(3)]
pd.DataFrame(data)
Out[24]: a b
0 0 0
1 1 2
2 2 4
即使字典中有些键是缺失的,Pandas 也会用 NaN
值(即“Not a Number”;参见 第十六章)来填充它们:
In [25]: pd.DataFrame([{'a': 1, 'b': 2}, {'b': 3, 'c': 4}])
Out[25]: a b c
0 1.0 2 NaN
1 NaN 3 4.0
从字典的 Series 对象
正如我们之前所看到的,一个 DataFrame
也可以从一个字典的 Series
对象构建出来:
In [26]: pd.DataFrame({'population': population,
'area': area})
Out[26]: population area
California 39538223 423967
Texas 29145505 695662
Florida 21538187 170312
New York 20201249 141297
Pennsylvania 13002700 119280
从二维 NumPy 数组
给定一个二维数据数组,我们可以创建一个带有指定列和索引名称的 DataFrame
。如果省略,将使用整数索引:
In [27]: pd.DataFrame(np.random.rand(3, 2),
columns=['foo', 'bar'],
index=['a', 'b', 'c'])
Out[27]: foo bar
a 0.471098 0.317396
b 0.614766 0.305971
c 0.533596 0.512377
从 NumPy 结构化数组
我们在 第十二章 中讨论了结构化数组。Pandas DataFrame
的操作方式与结构化数组非常相似,可以直接从结构化数组创建一个:
In [28]: A = np.zeros(3, dtype=[('A', 'i8'), ('B', 'f8')])
A
Out[28]: array([(0, 0.), (0, 0.), (0, 0.)], dtype=[('A', '<i8'), ('B', '<f8')])
In [29]: pd.DataFrame(A)
Out[29]: A B
0 0 0.0
1 0 0.0
2 0 0.0
Pandas Index 对象
正如你所见,Series
和 DataFrame
对象都包含了一个明确的索引,让你可以引用和修改数据。这个 Index
对象本身就是一个有趣的结构,它可以被看作是一个不可变数组或者一个有序集合(技术上是一个多重集合,因为 Index
对象可能包含重复的值)。这些视角在 Index
对象上的操作上产生了一些有趣的后果。举个简单的例子,让我们从一个整数列表构造一个 Index
:
In [30]: ind = pd.Index([2, 3, 5, 7, 11])
ind
Out[30]: Int64Index([2, 3, 5, 7, 11], dtype='int64')
作为不可变数组的 Index
Index
在许多方面都像一个数组。例如,我们可以使用标准的 Python 索引表示法来检索值或切片:
In [31]: ind[1]
Out[31]: 3
In [32]: ind[::2]
Out[32]: Int64Index([2, 5, 11], dtype='int64')
Index
对象也具有许多与 NumPy 数组相似的属性:
In [33]: print(ind.size, ind.shape, ind.ndim, ind.dtype)
Out[33]: 5 (5,) 1 int64
Index
对象和 NumPy 数组之间的一个区别是索引是不可变的——也就是说,它们不能通过正常的方式修改:
In [34]: ind[1] = 0
TypeError: Index does not support mutable operations
这种不可变性使得在多个 DataFrame
和数组之间共享索引更加安全,避免了因无意中修改索引而产生的副作用。
作为有序集合的 Index
Pandas 对象旨在简化诸如跨数据集连接等操作,这些操作依赖于集合算术的许多方面。Index
对象遵循 Python 内置 set
数据结构使用的许多约定,因此可以以熟悉的方式计算并集、交集、差集和其他组合:
In [35]: indA = pd.Index([1, 3, 5, 7, 9])
indB = pd.Index([2, 3, 5, 7, 11])
In [36]: indA.intersection(indB)
Out[36]: Int64Index([3, 5, 7], dtype='int64')
In [37]: indA.union(indB)
Out[37]: Int64Index([1, 2, 3, 5, 7, 9, 11], dtype='int64')
In [38]: indA.symmetric_difference(indB)
Out[38]: Int64Index([1, 2, 9, 11], dtype='int64')
第十四章:数据索引和选择
在第二部分中,我们详细讨论了访问、设置和修改 NumPy 数组中的值的方法和工具。这些包括索引(例如arr[2, 1]
)、切片(例如arr[:, 1:5]
)、掩码(例如arr[arr > 0]
)、花式索引(例如arr[0, [1, 5]]
)以及它们的组合(例如arr[:, [1, 5]]
)。在这里,我们将看一下类似的方法来访问和修改 Pandas Series
和DataFrame
对象中的值。如果你使用过 NumPy 模式,Pandas 中的相应模式会感觉非常熟悉,尽管有一些需要注意的怪癖。
我们将从一维Series
对象的简单情况开始,然后转向更复杂的二维DataFrame
对象。
Series 中的数据选择
正如你在前一章中看到的,Series
对象在许多方面都像一个一维 NumPy 数组,而在许多方面都像一个标准的 Python 字典。如果你记住这两个重叠的类比,将有助于你理解这些数组中的数据索引和选择模式。
Series
作为字典
像字典一样,Series
对象提供了从一组键到一组值的映射:
In [1]: import pandas as pd
data = pd.Series([0.25, 0.5, 0.75, 1.0],
index=['a', 'b', 'c', 'd'])
data
Out[1]: a 0.25
b 0.50
c 0.75
d 1.00
dtype: float64
In [2]: data['b']
Out[2]: 0.5
我们还可以使用类似字典的 Python 表达式和方法来查看键/索引和值:
In [3]: 'a' in data
Out[3]: True
In [4]: data.keys()
Out[4]: Index(['a', 'b', 'c', 'd'], dtype='object')
In [5]: list(data.items())
Out[5]: [('a', 0.25), ('b', 0.5), ('c', 0.75), ('d', 1.0)]
Series
对象也可以用类似字典的语法进行修改。就像你可以通过分配给新键来扩展字典一样,你可以通过分配给新索引值来扩展Series
:
In [6]: data['e'] = 1.25
data
Out[6]: a 0.25
b 0.50
c 0.75
d 1.00
e 1.25
dtype: float64
这种对象的易变性是一个方便的特性:在幕后,Pandas 正在做出关于内存布局和数据复制的决策,这可能需要进行,而用户通常不需要担心这些问题。
一维数组中的 Series
Series
建立在这种类似字典的接口上,并通过与 NumPy 数组相同的基本机制提供了数组样式的项目选择——即切片、掩码和花式索引。以下是这些的示例:
In [7]: # slicing by explicit index
data['a':'c']
Out[7]: a 0.25
b 0.50
c 0.75
dtype: float64
In [8]: # slicing by implicit integer index
data[0:2]
Out[8]: a 0.25
b 0.50
dtype: float64
In [9]: # masking
data[(data > 0.3) & (data < 0.8)]
Out[9]: b 0.50
c 0.75
dtype: float64
In [10]: # fancy indexing
data[['a', 'e']]
Out[10]: a 0.25
e 1.25
dtype: float64
其中,切片可能是最容易混淆的来源。请注意,当使用显式索引进行切片(例如data['a':'c']
)时,最终索引被包括在切片中,而当使用隐式索引进行切片(例如data[0:2]
)时,最终索引被排除在切片之外。
索引器:loc 和 iloc
如果你的Series
有一个明确的整数索引,那么像data[1]
这样的索引操作将使用明确的索引,而像data[1:3]
这样的切片操作将使用隐式的 Python 风格索引:
In [11]: data = pd.Series(['a', 'b', 'c'], index=[1, 3, 5])
data
Out[11]: 1 a
3 b
5 c
dtype: object
In [12]: # explicit index when indexing
data[1]
Out[12]: 'a'
In [13]: # implicit index when slicing
data[1:3]
Out[13]: 3 b
5 c
dtype: object
由于整数索引可能会导致混淆,Pandas 提供了一些特殊的索引器属性,明确地暴露了某些索引方案。这些不是功能性方法,而是属性,它们向Series
中的数据公开了特定的切片接口。
首先,loc
属性允许始终引用显式索引的索引和切片:
In [14]: data.loc[1]
Out[14]: 'a'
In [15]: data.loc[1:3]
Out[15]: 1 a
3 b
dtype: object
iloc
属性允许引用始终参考隐式 Python 样式索引的索引和切片:
In [16]: data.iloc[1]
Out[16]: 'b'
In [17]: data.iloc[1:3]
Out[17]: 3 b
5 c
dtype: object
Python 代码的一个指导原则是“明确优于隐式”。loc
和iloc
的显式特性使它们在保持代码清晰和可读性方面非常有帮助;特别是在整数索引的情况下,始终一致地使用它们可以防止由于混合索引/切片约定而导致的微妙错误。
数据框选择
回想一下,DataFrame
在许多方面都像一个二维或结构化数组,而在其他方面则像一个共享相同索引的Series
结构的字典。当我们探索在这种结构内进行数据选择时,这些类比可能会有所帮助。
DataFrame 作为字典
我们首先考虑的类比是将DataFrame
视为一组相关Series
对象的字典。让我们回到我们州的面积和人口的例子:
In [18]: area = pd.Series({'California': 423967, 'Texas': 695662,
'Florida': 170312, 'New York': 141297,
'Pennsylvania': 119280})
pop = pd.Series({'California': 39538223, 'Texas': 29145505,
'Florida': 21538187, 'New York': 20201249,
'Pennsylvania': 13002700})
data = pd.DataFrame({'area':area, 'pop':pop})
data
Out[18]: area pop
California 423967 39538223
Texas 695662 29145505
Florida 170312 21538187
New York 141297 20201249
Pennsylvania 119280 13002700
组成DataFrame
列的单个Series
可以通过列名的字典样式索引进行访问:
In [19]: data['area']
Out[19]: California 423967
Texas 695662
Florida 170312
New York 141297
Pennsylvania 119280
Name: area, dtype: int64
类似地,我们可以使用列名为字符串的属性样式访问:
In [20]: data.area
Out[20]: California 423967
Texas 695662
Florida 170312
New York 141297
Pennsylvania 119280
Name: area, dtype: int64
尽管这是一个有用的简写,但请记住,并非所有情况下都适用!例如,如果列名不是字符串,或者列名与DataFrame
的方法冲突,这种属性样式访问就不可能。例如,DataFrame
有一个pop
方法,所以data.pop
将指向这个方法而不是pop
列:
In [21]: data.pop is data["pop"]
Out[21]: False
特别地,你应该避免尝试通过属性进行列赋值(即,使用data['pop'] = z
而不是data.pop = z
)。
像之前讨论过的Series
对象一样,这种字典样式的语法也可以用来修改对象,比如在这种情况下添加一个新列:
In [22]: data['density'] = data['pop'] / data['area']
data
Out[22]: area pop density
California 423967 39538223 93.257784
Texas 695662 29145505 41.896072
Florida 170312 21538187 126.463121
New York 141297 20201249 142.970120
Pennsylvania 119280 13002700 109.009893
这展示了Series
对象之间按元素进行算术运算的简单语法预览;我们将在第十五章进一步深入探讨这个问题。
DataFrame 作为二维数组
正如前面提到的,我们也可以将DataFrame
视为增强的二维数组。我们可以使用values
属性查看原始的底层数据数组:
In [23]: data.values
Out[23]: array([[4.23967000e+05, 3.95382230e+07, 9.32577842e+01],
[6.95662000e+05, 2.91455050e+07, 4.18960717e+01],
[1.70312000e+05, 2.15381870e+07, 1.26463121e+02],
[1.41297000e+05, 2.02012490e+07, 1.42970120e+02],
[1.19280000e+05, 1.30027000e+07, 1.09009893e+02]])
在这个画面中,许多熟悉的类似数组的操作可以在DataFrame
本身上完成。例如,我们可以转置整个DataFrame
来交换行和列:
In [24]: data.T
Out[24]: California Texas Florida New York Pennsylvania
area 4.239670e+05 6.956620e+05 1.703120e+05 1.412970e+05 1.192800e+05
pop 3.953822e+07 2.914550e+07 2.153819e+07 2.020125e+07 1.300270e+07
density 9.325778e+01 4.189607e+01 1.264631e+02 1.429701e+02 1.090099e+02
然而,当涉及到DataFrame
对象的索引时,很明显,列的字典样式索引排除了我们简单将其视为 NumPy 数组的能力。特别是,将单个索引传递给数组会访问一行:
In [25]: data.values[0]
Out[25]: array([4.23967000e+05, 3.95382230e+07, 9.32577842e+01])
并且将一个单独的“索引”传递给DataFrame
会访问一列:
In [26]: data['area']
Out[26]: California 423967
Texas 695662
Florida 170312
New York 141297
Pennsylvania 119280
Name: area, dtype: int64
因此,对于数组样式的索引,我们需要另一种约定。在这里,Pandas 再次使用了前面提到的loc
和iloc
索引器。使用iloc
索引器,我们可以像使用简单的 NumPy 数组一样索引底层数组(使用隐式的 Python 风格索引),但结果中保持了DataFrame
的索引和列标签:
In [27]: data.iloc[:3, :2]
Out[27]: area pop
California 423967 39538223
Texas 695662 29145505
Florida 170312 21538187
同样地,使用loc
索引器,我们可以以类似于数组的样式索引底层数据,但使用显式的索引和列名:
In [28]: data.loc[:'Florida', :'pop']
Out[28]: area pop
California 423967 39538223
Texas 695662 29145505
Florida 170312 21538187
在这些索引器中,可以使用任何熟悉的类似于 NumPy 的数据访问模式。例如,在loc
索引器中,我们可以按以下方式组合遮罩和花式索引:
In [29]: data.loc[data.density > 120, ['pop', 'density']]
Out[29]: pop density
Florida 21538187 126.463121
New York 20201249 142.970120
任何这些索引约定也可以用于设置或修改值;这是通过与您在使用 NumPy 工作时习惯的标准方式完成的:
In [30]: data.iloc[0, 2] = 90
data
Out[30]: area pop density
California 423967 39538223 90.000000
Texas 695662 29145505 41.896072
Florida 170312 21538187 126.463121
New York 141297 20201249 142.970120
Pennsylvania 119280 13002700 109.009893
要提升您在 Pandas 数据操作中的熟练程度,我建议您花一些时间使用一个简单的DataFrame
,并探索这些不同索引方法允许的索引、切片、遮罩和花式索引类型。
额外的索引约定
还有一些额外的索引约定,可能与前面的讨论看似不符,但在实践中仍然很有用。首先,索引指的是列,而切片指的是行:
In [31]: data['Florida':'New York']
Out[31]: area pop density
Florida 170312 21538187 126.463121
New York 141297 20201249 142.970120
这种切片也可以通过数字而不是索引来引用行。
In [32]: data[1:3]
Out[32]: area pop density
Texas 695662 29145505 41.896072
Florida 170312 21538187 126.463121
类似地,直接的遮罩操作是按行而不是按列进行解释。
In [33]: data[data.density > 120]
Out[33]: area pop density
Florida 170312 21538187 126.463121
New York 141297 20201249 142.970120
这两种约定在语法上与 NumPy 数组上的约定类似,虽然它们可能不完全符合 Pandas 的约定模式,但由于它们的实际实用性,它们被包含了进来。
第十五章:在 Pandas 中操作数据
NumPy 的一个优点是它允许我们执行快速的逐元素操作,包括基本算术(加法、减法、乘法等)和更复杂的操作(三角函数、指数和对数函数等)。Pandas 从 NumPy 继承了许多这些功能,并且在第六章介绍的 ufuncs 对此至关重要。
然而,Pandas 还包括一些有用的技巧:对于像否定和三角函数这样的一元操作,这些 ufuncs 将在输出中 保留索引和列标签;对于像加法和乘法这样的二元操作,当将对象传递给 ufunc 时,Pandas 将自动 对齐索引。这意味着保持数据的上下文和组合来自不同来源的数据(这两个任务对于原始 NumPy 数组来说可能是错误的)在 Pandas 中基本上变得十分简单。我们还将看到在一维 Series
结构和二维 DataFrame
结构之间存在着明确定义的操作。
Ufuncs:索引保留
因为 Pandas 是设计用于与 NumPy 协作的,任何 NumPy 的 ufunc 都可以在 Pandas 的 Series
和 DataFrame
对象上使用。让我们先定义一个简单的 Series
和 DataFrame
来演示这一点:
In [1]: import pandas as pd
import numpy as np
In [2]: rng = np.random.default_rng(42)
ser = pd.Series(rng.integers(0, 10, 4))
ser
Out[2]: 0 0
1 7
2 6
3 4
dtype: int64
In [3]: df = pd.DataFrame(rng.integers(0, 10, (3, 4)),
columns=['A', 'B', 'C', 'D'])
df
Out[3]: A B C D
0 4 8 0 6
1 2 0 5 9
2 7 7 7 7
如果我们在这些对象中的任一对象上应用 NumPy 的 ufunc,结果将是另一个 Pandas 对象 并保留索引:
In [4]: np.exp(ser)
Out[4]: 0 1.000000
1 1096.633158
2 403.428793
3 54.598150
dtype: float64
对于更复杂的操作序列,情况也是如此:
In [5]: np.sin(df * np.pi / 4)
Out[5]: A B C D
0 1.224647e-16 -2.449294e-16 0.000000 -1.000000
1 1.000000e+00 0.000000e+00 -0.707107 0.707107
2 -7.071068e-01 -7.071068e-01 -0.707107 -0.707107
任何在第六章中讨论过的 ufunc 都可以以类似的方式使用。
Ufuncs:索引对齐
对于两个 Series
或 DataFrame
对象的二元操作,Pandas 将在执行操作的过程中对齐索引。这在处理不完整数据时非常方便,我们将在接下来的一些示例中看到。
Series 中的索引对齐
例如,假设我们正在结合两个不同的数据源,并希望仅找到按 面积 排名前三的美国州和按 人口 排名前三的美国州:
In [6]: area = pd.Series({'Alaska': 1723337, 'Texas': 695662,
'California': 423967}, name='area')
population = pd.Series({'California': 39538223, 'Texas': 29145505,
'Florida': 21538187}, name='population')
现在让我们来看看在进行人口密度计算时会发生什么:
In [7]: population / area
Out[7]: Alaska NaN
California 93.257784
Florida NaN
Texas 41.896072
dtype: float64
结果数组包含两个输入数组的索引的 并集,这可以直接从这些索引中确定:
In [8]: area.index.union(population.index)
Out[8]: Index(['Alaska', 'California', 'Florida', 'Texas'], dtype='object')
任何其中一个没有条目的项目都标记有 NaN
,即“不是数字”,这是 Pandas 标记缺失数据的方式(详见第十六章对缺失数据的进一步讨论)。对于 Python 内置的任何算术表达式,都会实现这种索引匹配;任何缺失值都将被 NaN
标记:
In [9]: A = pd.Series([2, 4, 6], index=[0, 1, 2])
B = pd.Series([1, 3, 5], index=[1, 2, 3])
A + B
Out[9]: 0 NaN
1 5.0
2 9.0
3 NaN
dtype: float64
如果不希望使用NaN
值,可以使用适当的对象方法修改填充值,而不是使用操作符。例如,调用A.add(B)
等效于调用A + B
,但允许可选地显式指定A
或B
中可能缺失元素的填充值:
In [10]: A.add(B, fill_value=0)
Out[10]: 0 2.0
1 5.0
2 9.0
3 5.0
dtype: float64
数据帧中的索引对齐
当对DataFrame
对象进行操作时,同时在列和索引上进行类似的对齐:
In [11]: A = pd.DataFrame(rng.integers(0, 20, (2, 2)),
columns=['a', 'b'])
A
Out[11]: a b
0 10 2
1 16 9
In [12]: B = pd.DataFrame(rng.integers(0, 10, (3, 3)),
columns=['b', 'a', 'c'])
B
Out[12]: b a c
0 5 3 1
1 9 7 6
2 4 8 5
In [13]: A + B
Out[12]: a b c
0 13.0 7.0 NaN
1 23.0 18.0 NaN
2 NaN NaN NaN
请注意,无论这两个对象中的顺序如何,索引都正确对齐,并且结果中的索引是排序的。与Series
一样,我们可以使用关联对象的算术方法,并传递任何希望用于替代缺失条目的fill_value
。这里我们将用A
中所有值的平均值填充:
In [14]: A.add(B, fill_value=A.values.mean())
Out[14]: a b c
0 13.00 7.00 10.25
1 23.00 18.00 15.25
2 17.25 13.25 14.25
表 15-1 列出了 Python 运算符及其相应的 Pandas 对象方法。
表 15-1。Python 运算符与 Pandas 方法的映射
Python 运算符 | Pandas 方法 |
---|---|
+ |
add |
- |
sub , subtract |
* |
mul , multiply |
/ |
truediv , div , divide |
// |
floordiv |
% |
mod |
** |
pow |
Ufuncs:DataFrame 与 Series 之间的操作
当对DataFrame
和Series
进行操作时,索引和列的对齐方式类似地保持,并且结果类似于二维数组和一维 NumPy 数组之间的操作。考虑一种常见的操作,即查找二维数组与其一行之间的差异:
In [15]: A = rng.integers(10, size=(3, 4))
A
Out[15]: array([[4, 4, 2, 0],
[5, 8, 0, 8],
[8, 2, 6, 1]])
In [16]: A - A[0]
Out[16]: array([[ 0, 0, 0, 0],
[ 1, 4, -2, 8],
[ 4, -2, 4, 1]])
根据 NumPy 的广播规则(参见第八章),二维数组与其一行之间的减法操作是逐行应用的。
在 Pandas 中,默认情况下也是逐行操作的约定:
In [17]: df = pd.DataFrame(A, columns=['Q', 'R', 'S', 'T'])
df - df.iloc[0]
Out[17]: Q R S T
0 0 0 0 0
1 1 4 -2 8
2 4 -2 4 1
如果您希望以列为单位进行操作,可以使用前面提到的对象方法,并指定axis
关键字:
In [18]: df.subtract(df['R'], axis=0)
Out[18]: Q R S T
0 0 0 -2 -4
1 -3 0 -8 0
2 6 0 4 -1
注意,像前面讨论过的操作一样,这些DataFrame
/Series
操作会自动对齐两个元素之间的索引:
In [19]: halfrow = df.iloc[0, ::2]
halfrow
Out[19]: Q 4
S 2
Name: 0, dtype: int64
In [20]: df - halfrow
Out[20]: Q R S T
0 0.0 NaN 0.0 NaN
1 1.0 NaN -2.0 NaN
2 4.0 NaN 4.0 NaN
这种索引和列的保留与对齐意味着在 Pandas 中对数据进行的操作将始终保持数据上下文,这可以防止在原始 NumPy 数组中处理异构和/或不对齐数据时可能出现的常见错误。
第十六章:处理缺失数据
许多教程中找到的数据与现实世界中的数据之间的区别在于,现实世界的数据很少是干净且同质的。特别是,许多有趣的数据集会有一些数据缺失。更复杂的是,不同的数据源可能以不同的方式指示缺失数据。
在本章中,我们将讨论一些有关缺失数据的一般考虑事项,看看 Pandas 如何选择表示它,并探索一些处理 Python 中缺失数据的内置 Pandas 工具。在本书中,我将通常将缺失数据总称为null、*NaN*
或NA值。
缺失数据约定中的权衡
已开发出许多方法来跟踪表格或DataFrame
中缺失数据的存在。通常,它们围绕两种策略之一展开:使用掩码全局指示缺失值,或选择指示缺失条目的哨兵值。
在掩码方法中,掩码可以是一个完全独立的布尔数组,也可以涉及在数据表示中占用一个比特来局部指示值的空状态。
在哨兵方法中,哨兵值可以是一些特定于数据的约定,比如用 -9999 表示缺失的整数值或一些罕见的比特模式,或者可以是一个更全局的约定,比如用NaN
(不是数字)表示缺失的浮点值,这是 IEEE 浮点规范的一部分。
这两种方法都不是没有权衡的。使用单独的掩码数组需要分配额外的布尔数组,这在存储和计算上都增加了开销。哨兵值会减少可以表示的有效值范围,并且可能需要额外的(通常是非优化的)CPU 和 GPU 算术逻辑,因为常见的特殊值如NaN
并不适用于所有数据类型。
就像大多数情况下没有普遍适用的最佳选择一样,不同的语言和系统使用不同的约定。例如,R 语言使用每种数据类型内的保留比特模式作为指示缺失数据的哨兵值,而 SciDB 系统使用附加到每个单元的额外字节来指示 NA 状态。
Pandas 中的缺失数据
Pandas 处理缺失值的方式受其对 NumPy 包的依赖限制,后者对非浮点数据类型没有内置的 NA 值概念。
也许 Pandas 本可以效仿 R 在指定每个单独数据类型的位模式以指示空值方面的领先地位,但是这种方法事实证明相当笨拙。虽然 R 只有 4 种主要数据类型,但是 NumPy 支持的数据类型远远超过这个数字:例如,虽然 R 只有一个整数类型,但是考虑到可用的位宽、符号性和编码的字节顺序,NumPy 支持 14 种基本整数类型。在所有可用的 NumPy 类型中保留特定的位模式将导致在各种类型的操作中特殊处理大量操作,很可能甚至需要新的 NumPy 软件包的分支。此外,对于较小的数据类型(如 8 位整数),牺牲一位用作掩码将显著减少其可以表示的值的范围。
由于这些限制和权衡,Pandas 有两种存储和操作空值的“模式”:
-
默认模式是使用基于哨兵值的缺失数据方案,哨兵值为
NaN
或None
,具体取决于数据类型。 -
或者,您可以选择使用 Pandas 提供的可空数据类型(dtypes)(稍后在本章讨论),这将导致创建一个伴随的掩码数组来跟踪缺失的条目。然后,这些缺失的条目将被呈现给用户作为特殊的
pd.NA
值。
无论哪种情况,Pandas API 提供的数据操作和操作将以可预测的方式处理和传播这些缺失的条目。但是为了对为什么会做出这些选择有一些直觉,让我们快速探讨一下None
、NaN
和NA
中固有的权衡。像往常一样,我们将从导入 NumPy 和 Pandas 开始:
In [1]: import numpy as np
import pandas as pd
None
作为哨兵值
对于某些数据类型,Pandas 使用None
作为哨兵值。None
是一个 Python 对象,这意味着包含None
的任何数组必须具有dtype=object
,即它必须是 Python 对象的序列。
例如,观察将None
传递给 NumPy 数组会发生什么:
In [2]: vals1 = np.array([1, None, 2, 3])
vals1
Out[2]: array([1, None, 2, 3], dtype=object)
这个dtype=object
意味着 NumPy 可以推断出数组内容的最佳公共类型表示是 Python 对象。以这种方式使用None
的缺点是数据的操作将在 Python 级别完成,其开销比通常对具有本地类型的数组所见的快速操作要大得多:
In [3]: %timeit np.arange(1E6, dtype=int).sum()
Out[3]: 2.73 ms ± 288 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [4]: %timeit np.arange(1E6, dtype=object).sum()
Out[4]: 92.1 ms ± 3.42 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
此外,因为 Python 不支持与None
的算术运算,像sum
或min
这样的聚合通常会导致错误:
In [5]: vals1.sum()
TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
因此,Pandas 在其数值数组中不使用None
作为哨兵值。
NaN
:缺失的数值数据
另一个缺失数据哨兵,NaN
,是不同的;它是一种特殊的浮点值,在所有使用标准 IEEE 浮点表示的系统中都被识别:
In [6]: vals2 = np.array([1, np.nan, 3, 4])
vals2
Out[6]: array([ 1., nan, 3., 4.])
请注意,NumPy 为此数组选择了本地的浮点类型:这意味着与之前的对象数组不同,此数组支持快速操作并推入编译代码中。请记住,NaN
有点像数据病毒——它会感染到任何它接触到的其他对象。
不管进行何种操作,带有NaN
的算术运算的结果都将是另一个NaN
:
In [7]: 1 + np.nan
Out[7]: nan
In [8]: 0 * np.nan
Out[8]: nan
这意味着对值的聚合是定义良好的(即,它们不会导致错误),但并不总是有用的:
In [9]: vals2.sum(), vals2.min(), vals2.max()
Out[9]: (nan, nan, nan)
也就是说,NumPy 确实提供了对NaN
敏感的聚合函数版本,将忽略这些缺失值:
In [10]: np.nansum(vals2), np.nanmin(vals2), np.nanmax(vals2)
Out[10]: (8.0, 1.0, 4.0)
NaN
的主要缺点是它是特定的浮点值;对于整数、字符串或其他类型,都没有等价的NaN
值。
Pandas 中的 NaN 和 None
NaN
和None
都有它们的用途,而且 Pandas 几乎可以在它们之间自由转换,视情况而定:
In [11]: pd.Series([1, np.nan, 2, None])
Out[11]: 0 1.0
1 NaN
2 2.0
3 NaN
dtype: float64
对于没有可用哨兵值的类型,当存在 NA 值时,Pandas 会自动进行类型转换。例如,如果我们将整数数组中的一个值设置为np.nan
,它将自动提升为浮点类型以容纳 NA:
In [12]: x = pd.Series(range(2), dtype=int)
x
Out[12]: 0 0
1 1
dtype: int64
In [13]: x[0] = None
x
Out[13]: 0 NaN
1 1.0
dtype: float64
请注意,除了将整数数组转换为浮点数外,Pandas 还会自动将None
转换为NaN
值。
虽然这种类型的魔法操作可能与像 R 这样的特定领域语言中的 NA 值的更统一方法相比显得有些投机,但是 Pandas 的哨兵/转换方法在实践中运作得非常好,并且据我经验,很少引起问题。
表 16-1 列出了引入 NA 值时 Pandas 中的提升转换规则。
表 16-1. Pandas 按类型处理 NA 值
类型 | 存储 NA 时的转换 | NA 哨兵值 |
---|---|---|
floating |
无变化 | np.nan |
object |
无变化 | None 或np.nan |
integer |
转换为float64 |
np.nan |
boolean |
转换为object |
None 或np.nan |
请记住,在 Pandas 中,字符串数据始终以object
类型存储。
Pandas 可空数据类型
在早期版本的 Pandas 中,NaN
和None
作为哨兵值是唯一可用的缺失数据表示。这引入的主要困难是隐式类型转换:例如,无法表示真正的整数数组带有缺失数据。
为了解决这个问题,Pandas 后来添加了可空数据类型,它们通过名称的大写区分于常规数据类型(例如,pd.Int32
与np.int32
)。为了向后兼容,只有在明确请求时才会使用这些可空数据类型。
例如,这是一个带有缺失数据的整数Series
,由包含所有三种可用缺失数据标记的列表创建:
In [14]: pd.Series([1, np.nan, 2, None, pd.NA], dtype='Int32')
Out[14]: 0 1
1 <NA>
2 2
3 <NA>
4 <NA>
dtype: Int32
这种表示可以在本章剩余的所有操作中与其他表示方法交替使用。
操作空值
正如我们所见,Pandas 将 None
、NaN
和 NA
视为基本可以互换,用于指示缺失或空值。为了促进这一约定,Pandas 提供了几种方法来检测、删除和替换 Pandas 数据结构中的空值。它们包括:
isnull
生成一个指示缺失值的布尔掩码
notnull
isnull
的反操作
dropna
返回数据的过滤版本
fillna
返回填充或插补了缺失值的数据副本
我们将以对这些程序的简要探索和演示来结束本章。
检测空值
Pandas 数据结构有两个有用的方法来检测空数据:isnull
和 notnull
。任何一个都将返回数据的布尔掩码。例如:
In [15]: data = pd.Series([1, np.nan, 'hello', None])
In [16]: data.isnull()
Out[16]: 0 False
1 True
2 False
3 True
dtype: bool
正如在 第十四章 中提到的那样,布尔掩码可以直接用作 Series
或 DataFrame
的索引:
In [17]: data[data.notnull()]
Out[17]: 0 1
2 hello
dtype: object
对于 DataFrame
对象,isnull()
和 notnull()
方法生成类似的布尔结果。
删除空值
除了这些掩码方法之外,还有方便的方法 dropna
(用于删除 NA 值)和 fillna
(用于填充 NA 值)。对于 Series
,结果是直接的:
In [18]: data.dropna()
Out[18]: 0 1
2 hello
dtype: object
对于 DataFrame
,有更多的选择。考虑以下 DataFrame
:
In [19]: df = pd.DataFrame([[1, np.nan, 2],
[2, 3, 5],
[np.nan, 4, 6]])
df
Out[19]: 0 1 2
0 1.0 NaN 2
1 2.0 3.0 5
2 NaN 4.0 6
我们不能从 DataFrame
中删除单个值;我们只能删除整行或整列。根据应用程序的不同,您可能需要其中一个,因此 dropna
包含了一些 DataFrame
的选项。
默认情况下,dropna
将删除任何存在空值的行:
In [20]: df.dropna()
Out[20]: 0 1 2
1 2.0 3.0 5
或者,您可以沿不同的轴删除 NA 值。使用 axis=1
或 axis='columns'
将删除包含空值的所有列:
In [21]: df.dropna(axis='columns')
Out[21]: 2
0 2
1 5
2 6
但是这样会丢掉一些好数据;您可能更感兴趣的是删除具有所有 NA 值或大多数 NA 值的行或列。这可以通过 how
或 thresh
参数进行指定,这些参数允许对允许通过的空值数量进行精细控制。
默认值为 how='any'
,这样任何包含空值的行或列都将被删除。您还可以指定 how='all'
,这样只会删除包含所有空值的行/列:
In [22]: df[3] = np.nan
df
Out[22]: 0 1 2 3
0 1.0 NaN 2 NaN
1 2.0 3.0 5 NaN
2 NaN 4.0 6 NaN
In [23]: df.dropna(axis='columns', how='all')
Out[23]: 0 1 2
0 1.0 NaN 2
1 2.0 3.0 5
2 NaN 4.0 6
对于更精细的控制,thresh
参数允许您指定保留行/列的最小非空值数:
In [24]: df.dropna(axis='rows', thresh=3)
Out[24]: 0 1 2 3
1 2.0 3.0 5 NaN
在这里,第一行和最后一行已被删除,因为它们各自只包含两个非空值。
填充空值
有时候,您不想丢弃 NA 值,而是希望用有效值替换它们。这个值可以是一个单独的数字,比如零,或者可能是一些从好的值中插补或插值出来的值。您可以使用 isnull
方法作为掩码来原地进行这个操作,但是因为这是一个常见的操作,Pandas 提供了 fillna
方法,它返回一个替换了空值的数组的副本。
考虑以下 Series
:
In [25]: data = pd.Series([1, np.nan, 2, None, 3], index=list('abcde'),
dtype='Int32')
data
Out[25]: a 1
b <NA>
c 2
d <NA>
e 3
dtype: Int32
我们可以用一个单一的值(如零)填充 NA 条目:
In [26]: data.fillna(0)
Out[26]: a 1
b 0
c 2
d 0
e 3
dtype: Int32
我们可以指定向前填充以向前传播上一个值:
In [27]: # forward fill
data.fillna(method='ffill')
Out[27]: a 1
b 1
c 2
d 2
e 3
dtype: Int32
或者我们可以指定向后填充以向后传播下一个值:
In [28]: # back fill
data.fillna(method='bfill')
Out[28]: a 1
b 2
c 2
d 3
e 3
dtype: Int32
对于DataFrame
,选项类似,但我们还可以指定填充应该沿着的axis
:
In [29]: df
Out[29]: 0 1 2 3
0 1.0 NaN 2 NaN
1 2.0 3.0 5 NaN
2 NaN 4.0 6 NaN
In [30]: df.fillna(method='ffill', axis=1)
Out[30]: 0 1 2 3
0 1.0 1.0 2.0 2.0
1 2.0 3.0 5.0 5.0
2 NaN 4.0 6.0 6.0
如果在向前填充时前一个值不可用,NA 值将保留。
第十七章:分层索引
到目前为止,我们主要关注存储在 Pandas Series
和 DataFrame
对象中的一维和二维数据。通常,超出这些维度存储更高维度的数据是有用的——也就是说,数据由超过一个或两个键索引。早期的 Pandas 版本提供了 Panel
和 Panel4D
对象,可以视为二维 DataFrame
的三维或四维类比,但在实践中使用起来有些笨拙。处理更高维数据的更常见模式是利用分层索引(也称为多重索引),在单个索引中包含多个索引级别。通过这种方式,高维数据可以在熟悉的一维 Series
和二维 DataFrame
对象中紧凑地表示。(如果你对带有 Pandas 风格灵活索引的真正的 N 维数组感兴趣,可以查看优秀的Xarray 包。)
在本章中,我们将探讨直接创建 MultiIndex
对象;在多重索引数据中进行索引、切片和计算统计信息时的考虑;以及在简单索引和分层索引数据表示之间进行转换的有用程序。
我们从标准导入开始:
In [1]: import pandas as pd
import numpy as np
一个多重索引的系列
让我们首先考虑如何在一维 Series
中表示二维数据。为了具体起见,我们将考虑一个数据系列,其中每个点都有一个字符和数值键。
不好的方法
假设你想要跟踪两个不同年份的州数据。使用我们已经介绍过的 Pandas 工具,你可能会简单地使用 Python 元组作为键:
In [2]: index = [('California', 2010), ('California', 2020),
('New York', 2010), ('New York', 2020),
('Texas', 2010), ('Texas', 2020)]
populations = [37253956, 39538223,
19378102, 20201249,
25145561, 29145505]
pop = pd.Series(populations, index=index)
pop
Out[2]: (California, 2010) 37253956
(California, 2020) 39538223
(New York, 2010) 19378102
(New York, 2020) 20201249
(Texas, 2010) 25145561
(Texas, 2020) 29145505
dtype: int64
使用这种索引方案,你可以直接根据这个元组索引或切片系列:
In [3]: pop[('California', 2020):('Texas', 2010)]
Out[3]: (California, 2020) 39538223
(New York, 2010) 19378102
(New York, 2020) 20201249
(Texas, 2010) 25145561
dtype: int64
但便利性到此为止。例如,如果你需要选择所有 2010 年的值,你将需要做一些混乱的(可能是缓慢的)整理来实现它:
In [4]: pop[[i for i in pop.index if i[1] == 2010]]
Out[4]: (California, 2010) 37253956
(New York, 2010) 19378102
(Texas, 2010) 25145561
dtype: int64
这会产生期望的结果,但不如我们在 Pandas 中已经喜爱的切片语法那样清晰(或对于大型数据集来说不够高效)。
更好的方法:Pandas 多重索引
幸运的是,Pandas 提供了更好的方法。我们基于元组的索引本质上是一个简单的多重索引,而 Pandas 的 MultiIndex
类型给了我们希望拥有的操作类型。我们可以从元组创建一个多重索引,如下所示:
In [5]: index = pd.MultiIndex.from_tuples(index)
MultiIndex
表示多个索引级别——在这种情况下,州名和年份——以及每个数据点的多个标签,这些标签编码了这些级别。
如果我们使用这个 MultiIndex
重新索引我们的系列,我们将看到数据的分层表示:
In [6]: pop = pop.reindex(index)
pop
Out[6]: California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
这里 Series 表示法的前两列显示了多个索引值,而第三列显示了数据。请注意,第一列中有些条目缺失:在这种多重索引表示中,任何空白条目表示与上一行相同的值。
现在,要访问所有第二个索引为 2020 的数据,我们可以使用 Pandas 切片表示法:
In [7]: pop[:, 2020]
Out[7]: California 39538223
New York 20201249
Texas 29145505
dtype: int64
结果是一个仅包含我们感兴趣键的单索引 Series。这种语法比我们最初使用的基于元组的多重索引解决方案更方便(并且操作效率更高!)。接下来我们将进一步讨论在具有分层索引数据上进行此类索引操作。
作为额外维度的 MultiIndex
您可能会注意到这里还有另一点:我们本可以使用带有索引和列标签的简单DataFrame
存储相同的数据。实际上,Pandas 就是考虑到这种等价性而构建的。unstack
方法将快速将一个多重索引的Series
转换为传统索引的DataFrame
:
In [8]: pop_df = pop.unstack()
pop_df
Out[8]: 2010 2020
California 37253956 39538223
New York 19378102 20201249
Texas 25145561 29145505
自然,stack
方法提供了相反的操作:
In [9]: pop_df.stack()
Out[9]: California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
看到这些,您可能会想知道为什么我们要费心处理分层索引。原因很简单:正如我们能够使用多重索引来操作一个维度的Series
中的二维数据一样,我们也可以用它来操作Series
或DataFrame
中的三维或更高维度的数据。多重索引中的每个额外级别代表了数据的一个额外维度;利用这个特性使我们在能够表示的数据类型上有了更大的灵活性。具体来说,我们可能希望为每个州在每年的人口(例如 18 岁以下人口)添加另一列人口统计数据;使用MultiIndex
,这就像在DataFrame
中添加另一列数据那样简单:
In [10]: pop_df = pd.DataFrame({'total': pop,
'under18': [9284094, 8898092,
4318033, 4181528,
6879014, 7432474]})
pop_df
Out[10]: total under18
California 2010 37253956 9284094
2020 39538223 8898092
New York 2010 19378102 4318033
2020 20201249 4181528
Texas 2010 25145561 6879014
2020 29145505 7432474
此外,所有在第十五章讨论的 ufunc 和其他功能也适用于层次索引。在此我们计算按年龄小于 18 岁人口的比例,给定上述数据:
In [11]: f_u18 = pop_df['under18'] / pop_df['total']
f_u18.unstack()
Out[11]: 2010 2020
California 0.249211 0.225050
New York 0.222831 0.206994
Texas 0.273568 0.255013
这使我们能够轻松快速地操作和探索甚至是高维数据。
MultiIndex 创建方法
构建一个多重索引的Series
或DataFrame
最直接的方法是简单地将两个或更多索引数组列表传递给构造函数。例如:
In [12]: df = pd.DataFrame(np.random.rand(4, 2),
index=[['a', 'a', 'b', 'b'], [1, 2, 1, 2]],
columns=['data1', 'data2'])
df
Out[12]: data1 data2
a 1 0.748464 0.561409
2 0.379199 0.622461
b 1 0.701679 0.687932
2 0.436200 0.950664
创建MultiIndex
的工作是在后台完成的。
类似地,如果您传递了适当的元组作为键的字典,Pandas 将自动识别并默认使用MultiIndex
:
In [13]: data = {('California', 2010): 37253956,
('California', 2020): 39538223,
('New York', 2010): 19378102,
('New York', 2020): 20201249,
('Texas', 2010): 25145561,
('Texas', 2020): 29145505}
pd.Series(data)
Out[13]: California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
尽管如此,有时明确创建MultiIndex
也是有用的;我们将看看几种方法来完成这个操作。
显式 MultiIndex 构造器
为了更灵活地构建索引,您可以使用pd.MultiIndex
类中提供的构造方法。例如,就像我们之前做的那样,您可以从给定每个级别索引值的简单数组列表构造一个MultiIndex
:
In [14]: pd.MultiIndex.from_arrays([['a', 'a', 'b', 'b'], [1, 2, 1, 2]])
Out[14]: MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
或者可以通过提供每个点的多重索引值的元组列表来构建它:
In [15]: pd.MultiIndex.from_tuples([('a', 1), ('a', 2), ('b', 1), ('b', 2)])
Out[15]: MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
甚至可以通过单个索引的笛卡尔积构建它:
In [16]: pd.MultiIndex.from_product([['a', 'b'], [1, 2]])
Out[16]: MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
同样地,可以直接使用其内部编码通过传递levels
(包含每个级别可用索引值的列表的列表)和codes
(引用这些标签的列表的列表)构造MultiIndex
:
In [17]: pd.MultiIndex(levels=[['a', 'b'], [1, 2]],
codes=[[0, 0, 1, 1], [0, 1, 0, 1]])
Out[17]: MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
在创建Series
或DataFrame
时,可以将任何这些对象作为index
参数传递,或者将其传递给现有Series
或DataFrame
的reindex
方法。
多重索引级别名称
有时候给MultiIndex
的级别命名会很方便。可以通过在任何先前讨论过的MultiIndex
构造函数中传递names
参数来实现,或者在事后设置索引的names
属性来完成:
In [18]: pop.index.names = ['state', 'year']
pop
Out[18]: state year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
对于更复杂的数据集,这是一种跟踪各种索引值意义的有用方法。
列的多重索引
在DataFrame
中,行和列是完全对称的,就像行可以具有多级索引一样,列也可以具有多级索引。考虑以下内容,这是一些(有些逼真的)医疗数据的模拟:
In [19]: # hierarchical indices and columns
index = pd.MultiIndex.from_product([[2013, 2014], [1, 2]],
names=['year', 'visit'])
columns = pd.MultiIndex.from_product([['Bob', 'Guido', 'Sue'],
['HR', 'Temp']],
names=['subject', 'type'])
# mock some data
data = np.round(np.random.randn(4, 6), 1)
data[:, ::2] *= 10
data += 37
# create the DataFrame
health_data = pd.DataFrame(data, index=index, columns=columns)
health_data
Out[19]: subject Bob Guido Sue
type HR Temp HR Temp HR Temp
year visit
2013 1 30.0 38.0 56.0 38.3 45.0 35.8
2 47.0 37.1 27.0 36.0 37.0 36.4
2014 1 51.0 35.9 24.0 36.7 32.0 36.2
2 49.0 36.3 48.0 39.2 31.0 35.7
这基本上是四维数据,维度包括主题、测量类型、年份和访问次数。有了这个设置,例如,我们可以通过人名索引顶级列,并获得一个只包含该人信息的完整DataFrame
:
In [20]: health_data['Guido']
Out[20]: type HR Temp
year visit
2013 1 56.0 38.3
2 27.0 36.0
2014 1 24.0 36.7
2 48.0 39.2
对MultiIndex
进行索引和切片
在MultiIndex
上进行索引和切片设计得很直观,如果将索引视为增加的维度会有所帮助。我们首先看一下如何对多重索引的序列进行索引,然后再看如何对多重索引的数据框进行索引。
多重索引的序列
考虑我们之前看到的州人口的多重索引Series
:
In [21]: pop
Out[21]: state year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
我们可以通过使用多个项进行索引来访问单个元素:
In [22]: pop['California', 2010]
Out[22]: 37253956
MultiIndex
还支持部分索引,即仅对索引中的一个级别进行索引。结果是另一个Series
,保留较低级别的索引:
In [23]: pop['California']
Out[23]: year
2010 37253956
2020 39538223
dtype: int64
可以进行部分切片,只要MultiIndex
是排序的(参见“排序和未排序索引”的讨论):
In [24]: poploc['california':'new york']
Out[24]: state year
california 2010 37253956
2020 39538223
new york 2010 19378102
2020 20201249
dtype: int64
对排序索引来说,可以通过在第一个索引中传递空切片来在较低级别执行部分索引:
In [25]: pop[:, 2010]
Out[25]: state
california 37253956
new york 19378102
texas 25145561
dtype: int64
其他类型的索引和选择(在第十四章中讨论)同样适用;例如,基于布尔掩码的选择:
In [26]: pop[pop > 22000000]
Out[26]: state year
California 2010 37253956
2020 39538223
Texas 2010 25145561
2020 29145505
dtype: int64
基于花式索引的选择也是有效的:
In [27]: pop[['California', 'Texas']]
Out[27]: state year
California 2010 37253956
2020 39538223
Texas 2010 25145561
2020 29145505
dtype: int64
多重索引的数据框
多重索引的DataFrame
表现方式类似。考虑之前的医疗玩具DataFrame
:
In [28]: health_data
Out[28]: subject Bob Guido Sue
type HR Temp HR Temp HR Temp
year visit
2013 1 30.0 38.0 56.0 38.3 45.0 35.8
2 47.0 37.1 27.0 36.0 37.0 36.4
2014 1 51.0 35.9 24.0 36.7 32.0 36.2
2 49.0 36.3 48.0 39.2 31.0 35.7
记住,在DataFrame
中,列是主要的,用于多重索引的Series
的语法适用于列。例如,我们可以通过简单的操作恢复 Guido 的心率数据:
In [29]: health_data['Guido', 'HR']
Out[29]: year visit
2013 1 56.0
2 27.0
2014 1 24.0
2 48.0
Name: (Guido, HR), dtype: float64
正如单索引情况一样,我们还可以使用在第十四章介绍的loc
、iloc
和ix
索引器。例如:
In [30]: health_data.iloc[:2, :2]
Out[30]: subject Bob
type HR Temp
year visit
2013 1 30.0 38.0
2 47.0 37.1
这些索引器提供了底层二维数据的类似数组的视图,但每个loc
或iloc
中的单个索引可以传递多个索引的元组。例如:
In [31]: health_data.loc[:, ('Bob', 'HR')]
Out[31]: year visit
2013 1 30.0
2 47.0
2014 1 51.0
2 49.0
Name: (Bob, HR), dtype: float64
在这些索引元组内部工作切片并不特别方便;尝试在元组内创建切片将导致语法错误:
In [32]: health_data.loc[(:, 1), (:, 'HR')]
SyntaxError: invalid syntax (3311942670.py, line 1)
您可以通过使用 Python 的内置slice
函数来明确构建所需的切片,但在这种情况下更好的方法是使用IndexSlice
对象,Pandas 专门为此提供。例如:
In [33]: idx = pd.IndexSlice
health_data.loc[idx[:, 1], idx[:, 'HR']]
Out[33]: subject Bob Guido Sue
type HR HR HR
year visit
2013 1 30.0 56.0 45.0
2014 1 51.0 24.0 32.0
如您所见,在多重索引的Series
和DataFrame
中与数据交互的方式有很多,并且与本书中的许多工具一样,熟悉它们的最佳方法是尝试它们!
重新排列多重索引
处理多重索引数据的关键之一是知道如何有效地转换数据。有许多操作将保留数据集中的所有信息,但为各种计算目的重新排列数据。我们在stack
和unstack
方法中看到了一个简短的示例,但在控制数据在层次索引和列之间重新排列方面,还有许多其他方法,我们将在这里探讨它们。
排序和未排序的索引
我之前简要提到过一个警告,但我应该在这里更加强调。如果索引未排序,则许多MultiIndex
切片操作将失败。让我们仔细看看。
我们将从创建一些简单的多重索引数据开始,其中索引未按词典顺序排序:
In [34]: index = pd.MultiIndex.from_product([['a', 'c', 'b'], [1, 2]])
data = pd.Series(np.random.rand(6), index=index)
data.index.names = ['char', 'int']
data
Out[34]: char int
a 1 0.280341
2 0.097290
c 1 0.206217
2 0.431771
b 1 0.100183
2 0.015851
dtype: float64
如果我们尝试对这个索引进行部分切片,将导致错误:
In [35]: try:
data['a':'b']
except KeyError as e:
print("KeyError", e)
KeyError 'Key length (1) was greater than MultiIndex lexsort depth (0)'
尽管从错误消息中不完全清楚,但这是由于MultiIndex
未排序造成的。出于各种原因,部分切片和其他类似操作要求MultiIndex
中的级别按排序(即词典)顺序排列。Pandas 提供了许多方便的例程来执行这种类型的排序,例如DataFrame
的sort_index
和sortlevel
方法。我们在这里将使用最简单的sort_index
:
In [36]: data = data.sort_index()
data
Out[36]: char int
a 1 0.280341
2 0.097290
b 1 0.100183
2 0.015851
c 1 0.206217
2 0.431771
dtype: float64
当索引以这种方式排序时,部分切片将按预期工作:
In [37]: data['a':'b']
Out[37]: char int
a 1 0.280341
2 0.097290
b 1 0.100183
2 0.015851
dtype: float64
堆叠和展开索引
正如我们之前简要看到的,可以将数据集从堆叠的多重索引转换为简单的二维表示,可选地指定要使用的级别:
In [38]: pop.unstack(level=0)
Out[38]: year 2010 2020
state
California 37253956 39538223
New York 19378102 20201249
Texas 25145561 29145505
In [39]: pop.unstack(level=1)
Out[39]: state year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
unstack
的相反操作是stack
,在这里可以用它来恢复原始系列:
In [40]: pop.unstack().stack()
Out[40]: state year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
索引设置和重置
重新排列分层数据的另一种方法是将索引标签转换为列;这可以通过reset_index
方法来实现。对人口字典调用此方法将导致一个DataFrame
,其中包含state
和year
列,这些列保存了以前在索引中的信息。为了清晰起见,我们可以选择指定数据的名称作为列的表示方式:
In [41]: pop_flat = pop.reset_index(name='population')
pop_flat
Out[41]: state year population
0 California 2010 37253956
1 California 2020 39538223
2 New York 2010 19378102
3 New York 2020 20201249
4 Texas 2010 25145561
5 Texas 2020 29145505
一个常见的模式是从列值构建一个MultiIndex
。这可以通过DataFrame
的set_index
方法来实现,该方法返回一个多重索引的DataFrame
:
In [42]: pop_flat.set_index(['state', 'year'])
Out[42]: population
state year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
在实践中,这种重新索引的方式是探索实际数据集时最有用的模式之一。
第十八章:组合数据集:concat 和 append
一些最有趣的数据研究来自于结合不同的数据源。这些操作可以涉及从两个不同数据集的非常简单的连接到更复杂的数据库风格的联接和合并,正确处理数据集之间的任何重叠。Series
和DataFrame
是专为这类操作而构建的,Pandas 包含使这种数据处理快速和简单的函数和方法。
在这里,我们将使用pd.concat
函数查看Series
和DataFrame
的简单连接;稍后我们将深入探讨 Pandas 中实现的更复杂的内存合并和连接。
我们从标准导入开始:
In [1]: import pandas as pd
import numpy as np
为方便起见,我们将定义这个函数,它创建一个特定形式的DataFrame
,在接下来的示例中将非常有用:
In [2]: def make_df(cols, ind):
"""Quickly make a DataFrame"""
data = {c: [str(c) + str(i) for i in ind]
for c in cols}
return pd.DataFrame(data, ind)
# example DataFrame
make_df('ABC', range(3))
Out[2]: A B C
0 A0 B0 C0
1 A1 B1 C1
2 A2 B2 C2
另外,我们将创建一个快速的类,允许我们将多个DataFrame
并排显示。该代码利用了特殊的_repr_html_
方法,IPython/Jupyter 用它来实现其丰富的对象显示:
In [3]: class display(object):
"""Display HTML representation of multiple objects"""
template = """<div style="float: left; padding: 10px;">
<p style='font-family:"Courier New", Courier, monospace'>{0}{1}
"""
def __init__(self, *args):
self.args = args
def _repr_html_(self):
return '\n'.join(self.template.format(a, eval(a)._repr_html_())
for a in self.args)
def __repr__(self):
return '\n\n'.join(a + '\n' + repr(eval(a))
for a in self.args)
随着我们在以下部分继续讨论,使用这个将会更加清晰。
回顾:NumPy 数组的连接
Series
和DataFrame
对象的连接行为与 NumPy 数组的连接类似,可以通过np.concatenate
函数完成,如第五章中所讨论的那样。记住,您可以使用它将两个或多个数组的内容合并为单个数组:
In [4]: x = [1, 2, 3]
y = [4, 5, 6]
z = [7, 8, 9]
np.concatenate([x, y, z])
Out[4]: array([1, 2, 3, 4, 5, 6, 7, 8, 9])
第一个参数是要连接的数组的列表或元组。此外,在多维数组的情况下,它接受一个axis
关键字,允许您指定沿其进行连接的轴:
In [5]: x = [[1, 2],
[3, 4]]
np.concatenate([x, x], axis=1)
Out[5]: array([[1, 2, 1, 2],
[3, 4, 3, 4]])
使用pd.concat
进行简单连接
pd.concat
函数提供了与np.concatenate
类似的语法,但包含我们稍后将讨论的多个选项:
# Signature in Pandas v1.3.5
pd.concat(objs, axis=0, join='outer', ignore_index=False, keys=None,
levels=None, names=None, verify_integrity=False,
sort=False, copy=True)
pd.concat
可用于简单连接Series
或DataFrame
对象,就像np.concatenate
可用于数组的简单连接一样:
In [6]: ser1 = pd.Series(['A', 'B', 'C'], index=[1, 2, 3])
ser2 = pd.Series(['D', 'E', 'F'], index=[4, 5, 6])
pd.concat([ser1, ser2])
Out[6]: 1 A
2 B
3 C
4 D
5 E
6 F
dtype: object
它还可以用于连接更高维度的对象,如DataFrame
:
In [7]: df1 = make_df('AB', [1, 2])
df2 = make_df('AB', [3, 4])
display('df1', 'df2', 'pd.concat([df1, df2])')
Out[7]: df1 df2 pd.concat([df1, df2])
A B A B A B
1 A1 B1 3 A3 B3 1 A1 B1
2 A2 B2 4 A4 B4 2 A2 B2
3 A3 B3
4 A4 B4
其默认行为是在DataFrame
内按行连接(即axis=0
)。与np.concatenate
类似,pd.concat
允许指定沿其进行连接的轴。考虑以下示例:
In [8]: df3 = make_df('AB', [0, 1])
df4 = make_df('CD', [0, 1])
display('df3', 'df4', "pd.concat([df3, df4], axis='columns')")
Out[8]: df3 df4 pd.concat([df3, df4], axis='columns')
A B C D A B C D
0 A0 B0 0 C0 D0 0 A0 B0 C0 D0
1 A1 B1 1 C1 D1 1 A1 B1 C1 D1
我们也可以等效地指定axis=1
;这里我们使用了更直观的axis='columns'
。
重复的索引
np.concatenate
和pd.concat
之间的一个重要区别是,Pandas 的连接保留索引,即使结果会有重复的索引!考虑以下简单示例:
In [9]: x = make_df('AB', [0, 1])
y = make_df('AB', [2, 3])
y.index = x.index # make indices match
display('x', 'y', 'pd.concat([x, y])')
Out[9]: x y pd.concat([x, y])
A B A B A B
0 A0 B0 0 A2 B2 0 A0 B0
1 A1 B1 1 A3 B3 1 A1 B1
0 A2 B2
1 A3 B3
注意结果中的重复索引。虽然这在DataFrame
中是有效的,但结果通常不理想。pd.concat
提供了几种处理方法。
将重复的索引视为错误处理
如果你想简单地验证pd.concat
的结果中的索引是否重叠,可以包含verify_integrity
标志。将其设置为True
,如果存在重复索引,连接将引发异常。以下是一个示例,为了清晰起见,我们将捕获并打印错误消息:
In [10]: try:
pd.concat([x, y], verify_integrity=True)
except ValueError as e:
print("ValueError:", e)
ValueError: Indexes have overlapping values: Int64Index([0, 1], dtype='int64')
忽略索引
有时索引本身并不重要,你更希望它被简单地忽略。可以使用ignore_index
标志指定此选项。将其设置为True
,连接将为结果的DataFrame
创建一个新的整数索引:
In [11]: display('x', 'y', 'pd.concat([x, y], ignore_index=True)')
Out[11]: x y pd.concat([x, y], ignore_index=True)
A B A B A B
0 A0 B0 0 A2 B2 0 A0 B0
1 A1 B1 1 A3 B3 1 A1 B1
2 A2 B2
3 A3 B3
添加 MultiIndex 键
另一个选项是使用keys
选项指定数据源的标签;结果将是一个具有层次索引的系列,其中包含数据:
In [12]: display('x', 'y', "pd.concat([x, y], keys=['x', 'y'])")
Out[12]: x y pd.concat([x, y], keys=['x', 'y'])
A B A B A B
0 A0 B0 0 A2 B2 x 0 A0 B0
1 A1 B1 1 A3 B3 1 A1 B1
y 0 A2 B2
1 A3 B3
我们可以使用第十七章中讨论的工具将这个多重索引的 DataFrame
转换为我们感兴趣的表示形式。
使用连接进行连接
在我们刚刚查看的短示例中,我们主要是连接具有共享列名的 DataFrame
。在实践中,来自不同来源的数据可能具有不同的列名集,pd.concat
在这种情况下提供了几个选项。考虑以下两个 DataFrame
的连接,它们具有一些(但不是全部!)共同的列:
In [13]: df5 = make_df('ABC', [1, 2])
df6 = make_df('BCD', [3, 4])
display('df5', 'df6', 'pd.concat([df5, df6])')
Out[13]: df5 df6 pd.concat([df5, df6])
A B C B C D A B C D
1 A1 B1 C1 3 B3 C3 D3 1 A1 B1 C1 NaN
2 A2 B2 C2 4 B4 C4 D4 2 A2 B2 C2 NaN
3 NaN B3 C3 D3
4 NaN B4 C4 D4
默认行为是用 NA 值填充无可用数据的条目。要更改这一点,可以调整concat
函数的join
参数。默认情况下,连接是输入列的并集(join='outer'
),但我们可以使用join='inner'
将其更改为列的交集:
In [14]: display('df5', 'df6',
"pd.concat([df5, df6], join='inner')")
Out[14]: df5 df6
A B C B C D
1 A1 B1 C1 3 B3 C3 D3
2 A2 B2 C2 4 B4 C4 D4
pd.concat([df5, df6], join='inner')
B C
1 B1 C1
2 B2 C2
3 B3 C3
4 B4 C4
另一个有用的模式是在连接之前使用reindex
方法对要丢弃的列进行更精细的控制:
In [15]: pd.concat([df5, df6.reindex(df5.columns, axis=1)])
Out[15]: A B C
1 A1 B1 C1
2 A2 B2 C2
3 NaN B3 C3
4 NaN B4 C4
append
方法
因为直接数组连接是如此常见,Series
和 DataFrame
对象具有一个append
方法,可以用更少的按键完成相同的操作。例如,可以使用 df1.append(df2)
替代 pd.concat([df1, df2])
:
In [16]: display('df1', 'df2', 'df1.append(df2)')
Out[16]: df1 df2 df1.append(df2)
A B A B A B
1 A1 B1 3 A3 B3 1 A1 B1
2 A2 B2 4 A4 B4 2 A2 B2
3 A3 B3
4 A4 B4
请注意,与 Python 列表的 append
和 extend
方法不同,Pandas 中的 append
方法不会修改原始对象;相反,它会创建一个包含组合数据的新对象。它也不是一种非常有效的方法,因为它涉及到新索引的创建 以及 数据缓冲区。因此,如果你计划进行多个 append
操作,通常最好建立一个 DataFrame
对象的列表,并一次性将它们全部传递给 concat
函数。
在下一章中,我们将介绍一种更强大的方法来组合来自多个来源的数据:pd.merge
中实现的数据库风格的合并/连接。有关 concat
、append
和相关功能的更多信息,请参阅Pandas 文档中的“Merge, Join, Concatenate and Compare”。
第十九章:合并数据集:merge 和 join
Pandas 提供的一个重要功能是其高性能、内存中的连接和合并操作,如果你曾经使用过数据库,可能对此有所了解。主要接口是pd.merge
函数,我们将看到几个示例,说明其实际操作方式。
为方便起见,在通常的导入之后,我们再次定义从上一章中定义的display
函数:
In [1]: import pandas as pd
import numpy as np
class display(object):
"""Display HTML representation of multiple objects"""
template = """<div style="float: left; padding: 10px;">
<p style='font-family:"Courier New", Courier, monospace'>{0}{1}
"""
def __init__(self, *args):
self.args = args
def _repr_html_(self):
return '\n'.join(self.template.format(a, eval(a)._repr_html_())
for a in self.args)
def __repr__(self):
return '\n\n'.join(a + '\n' + repr(eval(a))
for a in self.args)
关系代数
pd.merge
中实现的行为是所谓的关系代数的一个子集,这是一组操作关系数据的正式规则,形成了大多数数据库中可用操作的概念基础。关系代数方法的优势在于它提出了几个基本操作,这些操作成为任何数据集上更复杂操作的基础。通过在数据库或其他程序中高效实现这些基本操作的词汇,可以执行广泛范围的相当复杂的组合操作。
Pandas 在pd.merge
函数和Series
和DataFrame
对象的相关join
方法中实现了几个这些基本构建块。正如你将看到的,这些功能让你能够有效地链接来自不同来源的数据。
合并的类别
pd.merge
函数实现了几种类型的连接:一对一、多对一和多对多。通过对pd.merge
接口进行相同的调用来访问这三种连接类型;所执行的连接类型取决于输入数据的形式。我们将从三种合并类型的简单示例开始,并稍后讨论详细的选项。
一对一连接
或许最简单的合并类型是一对一连接,这在许多方面类似于你在第十八章中看到的逐列串联。作为具体示例,请考虑以下两个包含公司几名员工信息的DataFrame
对象:
In [2]: df1 = pd.DataFrame({'employee': ['Bob', 'Jake', 'Lisa', 'Sue'],
'group': ['Accounting', 'Engineering',
'Engineering', 'HR']})
df2 = pd.DataFrame({'employee': ['Lisa', 'Bob', 'Jake', 'Sue'],
'hire_date': [2004, 2008, 2012, 2014]})
display('df1', 'df2')
Out[2]: df1 df2
employee group employee hire_date
0 Bob Accounting 0 Lisa 2004
1 Jake Engineering 1 Bob 2008
2 Lisa Engineering 2 Jake 2012
3 Sue HR 3 Sue 2014
要将这些信息合并到一个DataFrame
中,我们可以使用pd.merge
函数:
In [3]: df3 = pd.merge(df1, df2)
df3
Out[3]: employee group hire_date
0 Bob Accounting 2008
1 Jake Engineering 2012
2 Lisa Engineering 2004
3 Sue HR 2014
pd.merge
函数会识别每个DataFrame
都有一个employee
列,并自动使用该列作为键进行连接。合并的结果是一个新的DataFrame
,它合并了两个输入的信息。请注意,每列中的条目顺序不一定保持一致:在这种情况下,df1
和df2
中的employee
列顺序不同,pd.merge
函数能够正确处理这一点。此外,请记住,一般情况下合并会丢弃索引,除非是通过索引进行合并(参见left_index
和right_index
关键字,稍后讨论)。
多对一连接
多对一连接是其中一个键列包含重复条目的连接。对于多对一情况,结果的DataFrame
将适当地保留这些重复条目。考虑以下多对一连接的示例:
In [4]: df4 = pd.DataFrame({'group': ['Accounting', 'Engineering', 'HR'],
'supervisor': ['Carly', 'Guido', 'Steve']})
display('df3', 'df4', 'pd.merge(df3, df4)')
Out[4]: df3 df4
employee group hire_date group supervisor
0 Bob Accounting 2008 0 Accounting Carly
1 Jake Engineering 2012 1 Engineering Guido
2 Lisa Engineering 2004 2 HR Steve
3 Sue HR 2014
pd.merge(df3, df4)
employee group hire_date supervisor
0 Bob Accounting 2008 Carly
1 Jake Engineering 2012 Guido
2 Lisa Engineering 2004 Guido
3 Sue HR 2014 Steve
结果的DataFrame
具有一个额外的列,其中“supervisor”信息重复出现在一个或多个位置,根据输入的要求。
多对多连接
多对多连接在概念上可能有点混乱,但仍然定义良好。如果左侧和右侧数组中的键列包含重复项,则结果是多对多合并。通过一个具体的例子可能更清楚。考虑以下例子,其中我们有一个显示特定组与一个或多个技能相关联的DataFrame
。
通过执行多对多连接,我们可以恢复与任何个人相关联的技能:
In [5]: df5 = pd.DataFrame({'group': ['Accounting', 'Accounting',
'Engineering', 'Engineering', 'HR', 'HR'],
'skills': ['math', 'spreadsheets', 'software', 'math',
'spreadsheets', 'organization']})
display('df1', 'df5', "pd.merge(df1, df5)")
Out[5]: df1 df5
employee group group skills
0 Bob Accounting 0 Accounting math
1 Jake Engineering 1 Accounting spreadsheets
2 Lisa Engineering 2 Engineering software
3 Sue HR 3 Engineering math
4 HR spreadsheets
5 HR organization
pd.merge(df1, df5)
employee group skills
0 Bob Accounting math
1 Bob Accounting spreadsheets
2 Jake Engineering software
3 Jake Engineering math
4 Lisa Engineering software
5 Lisa Engineering math
6 Sue HR spreadsheets
7 Sue HR organization
这三种类型的连接可以与其他 Pandas 工具一起使用,实现广泛的功能。但实际上,数据集很少像我们这里使用的那样干净。在下一节中,我们将考虑由pd.merge
提供的一些选项,这些选项使您能够调整连接操作的工作方式。
指定合并键
我们已经看到了pd.merge
的默认行为:它查找两个输入之间一个或多个匹配的列名,并将其用作键。然而,通常列名不会那么匹配,pd.merge
提供了多种选项来处理这种情况。
关键字 on
最简单的方法是使用on
关键字明确指定键列的名称,该关键字接受列名或列名列表:
In [6]: display('df1', 'df2', "pd.merge(df1, df2, on='employee')")
Out[6]: df1 df2
employee group employee hire_date
0 Bob Accounting 0 Lisa 2004
1 Jake Engineering 1 Bob 2008
2 Lisa Engineering 2 Jake 2012
3 Sue HR 3 Sue 2014
pd.merge(df1, df2, on='employee')
employee group hire_date
0 Bob Accounting 2008
1 Jake Engineering 2012
2 Lisa Engineering 2004
3 Sue HR 2014
此选项仅在左侧和右侧的DataFrame
都具有指定的列名时有效。
关键字 left_on 和 right_on
有时您可能希望合并两个具有不同列名的数据集;例如,我们可能有一个数据集,其中员工姓名标记为“name”而不是“employee”。在这种情况下,我们可以使用left_on
和right_on
关键字来指定这两个列名:
In [7]: df3 = pd.DataFrame({'name': ['Bob', 'Jake', 'Lisa', 'Sue'],
'salary': [70000, 80000, 120000, 90000]})
display('df1', 'df3', 'pd.merge(df1, df3, left_on="employee",
right_on="name")')
Out[7]: df1 df3
employee group name salary
0 Bob Accounting 0 Bob 70000
1 Jake Engineering 1 Jake 80000
2 Lisa Engineering 2 Lisa 120000
3 Sue HR 3 Sue 90000
pd.merge(df1, df3, left_on="employee", right_on="name")
employee group name salary
0 Bob Accounting Bob 70000
1 Jake Engineering Jake 80000
2 Lisa Engineering Lisa 120000
3 Sue HR Sue 90000
如果需要,可以使用DataFrame.drop()
方法删除多余的列:
In [8]: pd.merge(df1, df3, left_on="employee", right_on="name").drop('name', axis=1)
Out[8]: employee group salary
0 Bob Accounting 70000
1 Jake Engineering 80000
2 Lisa Engineering 120000
3 Sue HR 90000
左索引和右索引关键字
有时,而不是在列上进行合并,您可能希望在索引上进行合并。例如,您的数据可能如下所示:
In [9]: df1a = df1.set_index('employee')
df2a = df2.set_index('employee')
display('df1a', 'df2a')
Out[9]: df1a df2a
group hire_date
employee employee
Bob Accounting Lisa 2004
Jake Engineering Bob 2008
Lisa Engineering Jake 2012
Sue HR Sue 2014
您可以通过在pd.merge()
中指定left_index
和/或right_index
标志,将索引用作合并的键:
In [10]: display('df1a', 'df2a',
"pd.merge(df1a, df2a, left_index=True, right_index=True)")
Out[10]: df1a df2a
group hire_date
employee employee
Bob Accounting Lisa 2004
Jake Engineering Bob 2008
Lisa Engineering Jake 2012
Sue HR Sue 2014
pd.merge(df1a, df2a, left_index=True, right_index=True)
group hire_date
employee
Bob Accounting 2008
Jake Engineering 2012
Lisa Engineering 2004
Sue HR 2014
为方便起见,Pandas 包括DataFrame.join()
方法,它执行基于索引的合并而无需额外的关键字:
In [11]: df1a.join(df2a)
Out[11]: group hire_date
employee
Bob Accounting 2008
Jake Engineering 2012
Lisa Engineering 2004
Sue HR 2014
如果您希望混合索引和列,可以将left_index
与right_on
或left_on
与right_index
结合使用,以获得所需的行为:
In [12]: display('df1a', 'df3', "pd.merge(df1a, df3, left_index=True,
right_on='name')")
Out[12]: df1a df3
group name salary
employee 0 Bob 70000
Bob Accounting 1 Jake 80000
Jake Engineering 2 Lisa 120000
Lisa Engineering 3 Sue 90000
Sue HR
pd.merge(df1a, df3, left_index=True, right_on='name')
group name salary
0 Accounting Bob 70000
1 Engineering Jake 80000
2 Engineering Lisa 120000
3 HR Sue 90000
所有这些选项也适用于多个索引和/或多个列;这种行为的界面非常直观。有关更多信息,请参阅Pandas 文档中的“Merge, Join, and Concatenate”部分。
指定连接的集合算术
在所有前面的示例中,我们忽略了在执行连接时的一个重要考虑因素:连接中使用的集合算术类型。当一个值出现在一个键列中而不出现在另一个键列中时,就会出现这种情况。考虑这个例子:
In [13]: df6 = pd.DataFrame({'name': ['Peter', 'Paul', 'Mary'],
'food': ['fish', 'beans', 'bread']},
columns=['name', 'food'])
df7 = pd.DataFrame({'name': ['Mary', 'Joseph'],
'drink': ['wine', 'beer']},
columns=['name', 'drink'])
display('df6', 'df7', 'pd.merge(df6, df7)')
Out[13]: df6 df7
name food name drink
0 Peter fish 0 Mary wine
1 Paul beans 1 Joseph beer
2 Mary bread
pd.merge(df6, df7)
name food drink
0 Mary bread wine
在这里,我们已经合并了两个仅具有一个共同“name”条目的数据集:Mary。默认情况下,结果包含输入集合的交集;这称为内连接。我们可以使用how
关键字显式指定为"inner"
:
In [14]: pd.merge(df6, df7, how='inner')
Out[14]: name food drink
0 Mary bread wine
how
关键字的其他选项包括'outer'
、'left'
和'right'
。外连接返回输入列的并集并用 NA 填充缺失值:
In [15]: display('df6', 'df7', "pd.merge(df6, df7, how='outer')")
Out[15]: df6 df7
name food name drink
0 Peter fish 0 Mary wine
1 Paul beans 1 Joseph beer
2 Mary bread
pd.merge(df6, df7, how='outer')
name food drink
0 Peter fish NaN
1 Paul beans NaN
2 Mary bread wine
3 Joseph NaN beer
左连接和右连接分别返回左输入和右输入的连接。例如:
In [16]: display('df6', 'df7', "pd.merge(df6, df7, how='left')")
Out[16]: df6 df7
name food name drink
0 Peter fish 0 Mary wine
1 Paul beans 1 Joseph beer
2 Mary bread
pd.merge(df6, df7, how='left')
name food drink
0 Peter fish NaN
1 Paul beans NaN
2 Mary bread wine
现在输出行对应于左输入中的条目。使用how='right'
的方式也类似工作。
所有这些选项都可以直接应用于之前的任何连接类型。
重叠的列名:后缀关键字
最后,您可能会遇到两个输入DataFrame
具有冲突列名的情况。考虑这个例子:
In [17]: df8 = pd.DataFrame({'name': ['Bob', 'Jake', 'Lisa', 'Sue'],
'rank': [1, 2, 3, 4]})
df9 = pd.DataFrame({'name': ['Bob', 'Jake', 'Lisa', 'Sue'],
'rank': [3, 1, 4, 2]})
display('df8', 'df9', 'pd.merge(df8, df9, on="name")')
Out[17]: df8 df9
name rank name rank
0 Bob 1 0 Bob 3
1 Jake 2 1 Jake 1
2 Lisa 3 2 Lisa 4
3 Sue 4 3 Sue 2
pd.merge(df8, df9, on="name")
name rank_x rank_y
0 Bob 1 3
1 Jake 2 1
2 Lisa 3 4
3 Sue 4 2
因为输出将具有两个冲突的列名,merge
函数会自动附加后缀_x
和_y
以使输出列唯一。如果这些默认值不合适,可以使用suffixes
关键字指定自定义后缀:
In [18]: pd.merge(df8, df9, on="name", suffixes=["_L", "_R"])
Out[18]: name rank_L rank_R
0 Bob 1 3
1 Jake 2 1
2 Lisa 3 4
3 Sue 4 2
这些后缀适用于可能的任何连接模式,并且在多个重叠列的情况下也适用。
在第二十章中,我们将深入探讨关系代数。有关更多讨论,请参阅 Pandas 文档中的“Merge, Join, Concatenate and Compare”部分。
示例:美国各州数据
在合并数据来自不同来源时,合并和连接操作经常出现。在这里,我们将考虑一些关于美国各州及其人口数据的数据示例:
In [19]: # Following are commands to download the data
# repo = "https://raw.githubusercontent.com/jakevdp/data-USstates/master"
# !cd data && curl -O {repo}/state-population.csv
# !cd data && curl -O {repo}/state-areas.csv
# !cd data && curl -O {repo}/state-abbrevs.csv
让我们使用 Pandas 的read_csv
函数查看这三个数据集:
In [20]: pop = pd.read_csv('data/state-population.csv')
areas = pd.read_csv('data/state-areas.csv')
abbrevs = pd.read_csv('data/state-abbrevs.csv')
display('pop.head()', 'areas.head()', 'abbrevs.head()')
Out[20]: pop.head()
state/region ages year population
0 AL under18 2012 1117489.0
1 AL total 2012 4817528.0
2 AL under18 2010 1130966.0
3 AL total 2010 4785570.0
4 AL under18 2011 1125763.0
areas.head()
state area (sq. mi)
0 Alabama 52423
1 Alaska 656425
2 Arizona 114006
3 Arkansas 53182
4 California 163707
abbrevs.head()
state abbreviation
0 Alabama AL
1 Alaska AK
2 Arizona AZ
3 Arkansas AR
4 California CA
根据这些信息,假设我们想要计算一个相对简单的结果:按照 2010 年人口密度对美国各州和领地进行排名。显然,我们在这里有数据来找到这个结果,但我们需要合并数据集来实现这一点。
我们将从一个多对一的合并开始,这将使我们在人口DataFrame
中得到完整的州名。我们要基于pop
的state/region
列和abbrevs
的abbreviation
列进行合并。我们将使用how='outer'
以确保由于标签不匹配而丢失数据:
In [21]: merged = pd.merge(pop, abbrevs, how='outer',
left_on='state/region', right_on='abbreviation')
merged = merged.drop('abbreviation', axis=1) # drop duplicate info
merged.head()
Out[21]: state/region ages year population state
0 AL under18 2012 1117489.0 Alabama
1 AL total 2012 4817528.0 Alabama
2 AL under18 2010 1130966.0 Alabama
3 AL total 2010 4785570.0 Alabama
4 AL under18 2011 1125763.0 Alabama
让我们再次仔细检查是否存在任何不匹配,可以通过查找具有空值的行来完成:
In [22]: merged.isnull().any()
Out[22]: state/region False
ages False
year False
population True
state True
dtype: bool
一些population
值为 null;让我们找出它们是哪些!
In [23]: merged[merged['population'].isnull()].head()
Out[23]: state/region ages year population state
2448 PR under18 1990 NaN NaN
2449 PR total 1990 NaN NaN
2450 PR total 1991 NaN NaN
2451 PR under18 1991 NaN NaN
2452 PR total 1993 NaN NaN
所有空值人口数据似乎来自于 2000 年之前的波多黎各;这很可能是因为原始来源中没有这些数据。
更重要的是,我们看到一些新的state
条目也为空,这意味着在abbrevs
键中没有相应的条目!让我们找出哪些地区缺少这种匹配:
In [24]: merged.loc[merged['state'].isnull(), 'state/region'].unique()
Out[24]: array(['PR', 'USA'], dtype=object)
我们可以快速推断问题所在:我们的人口数据包括了波多黎各(PR)和整个美国(USA)的条目,而这些条目在州缩写键中并未出现。我们可以通过填写适当的条目来快速修复这些问题:
In [25]: merged.loc[merged['state/region'] == 'PR', 'state'] = 'Puerto Rico'
merged.loc[merged['state/region'] == 'USA', 'state'] = 'United States'
merged.isnull().any()
Out[25]: state/region False
ages False
year False
population True
state False
dtype: bool
state
列中不再有空值:我们已经准备就绪!
现在我们可以使用类似的过程将结果与区域数据合并。检查我们的结果时,我们将希望在state
列上进行连接:
In [26]: final = pd.merge(merged, areas, on='state', how='left')
final.head()
Out[26]: state/region ages year population state area (sq. mi)
0 AL under18 2012 1117489.0 Alabama 52423.0
1 AL total 2012 4817528.0 Alabama 52423.0
2 AL under18 2010 1130966.0 Alabama 52423.0
3 AL total 2010 4785570.0 Alabama 52423.0
4 AL under18 2011 1125763.0 Alabama 52423.0
再次,让我们检查是否存在空值以查看是否存在任何不匹配:
In [27]: final.isnull().any()
Out[27]: state/region False
ages False
year False
population True
state False
area (sq. mi) True
dtype: bool
area
列中有空值;我们可以查看这里被忽略的地区是哪些:
In [28]: final['state'][final['area (sq. mi)'].isnull()].unique()
Out[28]: array(['United States'], dtype=object)
我们发现我们的areas
DataFrame
中并不包含整个美国的面积。我们可以插入适当的值(例如使用所有州面积的总和),但在这种情况下,我们将仅删除空值,因为整个美国的人口密度与我们当前的讨论无关:
In [29]: final.dropna(inplace=True)
final.head()
Out[29]: state/region ages year population state area (sq. mi)
0 AL under18 2012 1117489.0 Alabama 52423.0
1 AL total 2012 4817528.0 Alabama 52423.0
2 AL under18 2010 1130966.0 Alabama 52423.0
3 AL total 2010 4785570.0 Alabama 52423.0
4 AL under18 2011 1125763.0 Alabama 52423.0
现在我们已经拥有了所有需要的数据。为了回答我们感兴趣的问题,让我们首先选择与 2010 年对应的数据部分和总人口。我们将使用query
函数来快速完成这一点(这需要安装 NumExpr 包,请参阅第二十四章):
In [30]: data2010 = final.query("year == 2010 & ages == 'total'")
data2010.head()
Out[30]: state/region ages year population state area (sq. mi)
3 AL total 2010 4785570.0 Alabama 52423.0
91 AK total 2010 713868.0 Alaska 656425.0
101 AZ total 2010 6408790.0 Arizona 114006.0
189 AR total 2010 2922280.0 Arkansas 53182.0
197 CA total 2010 37333601.0 California 163707.0
现在让我们计算人口密度并按顺序显示结果。我们将首先根据州重新索引我们的数据,然后计算结果:
In [31]: data2010.set_index('state', inplace=True)
density = data2010['population'] / data2010['area (sq. mi)']
In [32]: density.sort_values(ascending=False, inplace=True)
density.head()
Out[32]: state
District of Columbia 8898.897059
Puerto Rico 1058.665149
New Jersey 1009.253268
Rhode Island 681.339159
Connecticut 645.600649
dtype: float64
结果是美国各州,以及华盛顿特区和波多黎各,按照其 2010 年人口密度(每平方英里居民数)的排名。我们可以看到,数据集中迄今为止最密集的地区是华盛顿特区(即哥伦比亚特区);在各州中,密度最大的是新泽西州。
我们还可以检查列表的末尾:
In [33]: density.tail()
Out[33]: state
South Dakota 10.583512
North Dakota 9.537565
Montana 6.736171
Wyoming 5.768079
Alaska 1.087509
dtype: float64
我们看到迄今为止最稀疏的州是阿拉斯加,平均每平方英里略高于一名居民。
当尝试使用真实数据源回答问题时,这种数据合并是一项常见任务。希望这个例子给您提供了一些想法,展示了如何结合我们涵盖的工具来从数据中获取洞察!
第二十章:聚合和分组
许多数据分析任务的基本组成部分是高效的汇总:计算sum
、mean
、median
、min
和max
等聚合,其中单个数字总结了可能有很多数据集的各个方面。在本章中,我们将探索 Pandas 中的聚合,从类似于我们在 NumPy 数组上看到的简单操作到基于groupby
概念的更复杂的操作。
为了方便起见,我们将使用与前几章中相同的display
魔术函数:
In [1]: import numpy as np
import pandas as pd
class display(object):
"""Display HTML representation of multiple objects"""
template = """<div style="float: left; padding: 10px;">
<p style='font-family:"Courier New", Courier, monospace'>{0}{1}
"""
def __init__(self, *args):
self.args = args
def _repr_html_(self):
return '\n'.join(self.template.format(a, eval(a)._repr_html_())
for a in self.args)
def __repr__(self):
return '\n\n'.join(a + '\n' + repr(eval(a))
for a in self.args)
行星数据
在这里,我们将使用通过 Seaborn package(参见第三十六章)提供的 Planets 数据集。它提供了天文学家在其他恒星周围发现的行星的信息(被称为太阳系外行星或外行星)。可以通过简单的 Seaborn 命令下载:
In [2]: import seaborn as sns
planets = sns.load_dataset('planets')
planets.shape
Out[2]: (1035, 6)
In [3]: planets.head()
Out[3]: method number orbital_period mass distance year
0 Radial Velocity 1 269.300 7.10 77.40 2006
1 Radial Velocity 1 874.774 2.21 56.95 2008
2 Radial Velocity 1 763.000 2.60 19.84 2011
3 Radial Velocity 1 326.030 19.40 110.62 2007
4 Radial Velocity 1 516.220 10.50 119.47 2009
这些方法详细介绍了截止到 2014 年发现的一千多个太阳系外行星的情况。
Pandas 中的简单聚合
在第七章中,我们探讨了 NumPy 数组可用的一些数据聚合。对于 Pandas 的 Series,聚合返回一个单一值:
In [4]: rng = np.random.RandomState(42)
ser = pd.Series(rng.rand(5))
ser
Out[4]: 0 0.374540
1 0.950714
2 0.731994
3 0.598658
4 0.156019
dtype: float64
In [5]: ser.sum()
Out[5]: 2.811925491708157
In [6]: ser.mean()
Out[6]: 0.5623850983416314
对于DataFrame
,默认情况下,聚合返回每列的结果:
In [7]: df = pd.DataFrame({'A': rng.rand(5),
'B': rng.rand(5)})
df
Out[7]: A B
0 0.155995 0.020584
1 0.058084 0.969910
2 0.866176 0.832443
3 0.601115 0.212339
4 0.708073 0.181825
In [8]: df.mean()
Out[8]: A 0.477888
B 0.443420
dtype: float64
通过指定axis
参数,您可以在每行内进行聚合:
In [9]: df.mean(axis='columns')
Out[9]: 0 0.088290
1 0.513997
2 0.849309
3 0.406727
4 0.444949
dtype: float64
Pandas 的 Series 和 DataFrame 对象包含了第七章中提到的所有常见聚合;此外,还有一个方便的方法describe
,它为每列计算了几个常见聚合并返回结果。让我们在 Planets 数据上使用它,目前删除具有缺失值的行:
In [10]: planets.dropna().describe()
Out[10]: number orbital_period mass distance year
count 498.00000 498.000000 498.000000 498.000000 498.000000
mean 1.73494 835.778671 2.509320 52.068213 2007.377510
std 1.17572 1469.128259 3.636274 46.596041 4.167284
min 1.00000 1.328300 0.003600 1.350000 1989.000000
25% 1.00000 38.272250 0.212500 24.497500 2005.000000
50% 1.00000 357.000000 1.245000 39.940000 2009.000000
75% 2.00000 999.600000 2.867500 59.332500 2011.000000
max 6.00000 17337.500000 25.000000 354.000000 2014.000000
这种方法帮助我们了解数据集的整体属性。例如,在year
列中,我们可以看到尽管有外行星发现的年份可以追溯到 1989 年,但数据集中一半以上的行星直到 2010 年或之后才被发现。这在很大程度上要归功于开普勒任务,其目标是使用专门设计的空间望远镜在其他恒星周围寻找凌日行星。
表 20-1 总结了一些其他内置的 Pandas 聚合。
表 20-1。Pandas 聚合方法列表
聚合 | 返回 |
---|---|
count |
项目总数 |
first ,last |
第一个和最后一个项目 |
mean ,median |
平均值和中位数 |
min ,max |
最小和最大 |
std ,var |
标准差和方差 |
mad |
平均绝对偏差 |
prod |
所有项目的乘积 |
sum |
所有项目的和 |
这些都是DataFrame
和Series
对象的方法。
然而,要深入了解数据,简单的聚合通常是不够的。数据汇总的下一级是groupby
操作,它允许您快速高效地在数据子集上计算聚合。
groupby:分割、应用、组合
简单的聚合可以让你了解数据集的特征,但通常我们更希望在某些标签或索引上进行条件聚合:这在所谓的groupby
操作中实现。这个名字“group by”来自 SQL 数据库语言中的一个命令,但也许更具启发性的是,我们可以根据 Rstats 名人哈德利·维克姆首次提出的术语来思考它:分割、应用、组合。
分割、应用、组合
这个分割-应用-组合操作的典型示例,其中“应用”是一个求和聚合,如图 20-1 所示。
图 20-1 展示了groupby
操作的完成情况:
-
分割步骤涉及根据指定键的值拆分和分组
DataFrame
。 -
应用步骤涉及在各个组内计算某个函数,通常是一个聚合、转换或筛选。
-
合并步骤将这些操作的结果合并到输出数组中。
图 20-1. groupby
操作的视觉表示^(1)
虽然这当然可以通过一些组合使用先前介绍的掩码、聚合和合并命令来手动完成,但重要的认识是中间的分割不需要显式实例化。相反,groupby
可以(通常)在数据的单次遍历中执行此操作,沿途更新每个组的总和、平均值、计数、最小值或其他聚合。groupby
的威力在于它抽象出了这些步骤:用户不需要考虑计算在幕后是如何进行的,而是可以将操作作为一个整体来思考。
作为一个具体的例子,让我们看看如何使用 Pandas 来计算下表中所示的计算。我们将从创建输入DataFrame
开始:
In [11]: df = pd.DataFrame({'key': ['A', 'B', 'C', 'A', 'B', 'C'],
'data': range(6)}, columns=['key', 'data'])
df
Out[11]: key data
0 A 0
1 B 1
2 C 2
3 A 3
4 B 4
5 C 5
最基本的分割-应用-组合操作可以使用DataFrame
的groupby
方法来计算,传递所需键列的名称:
In [12]: df.groupby('key')
Out[12]: <pandas.core.groupby.generic.DataFrameGroupBy object at 0x11d241e20>
注意返回的是一个DataFrameGroupBy
对象,而不是一组DataFrame
对象。这个对象是魔术所在:你可以将它看作是DataFrame
的一个特殊视图,它准备好深入研究组,但在应用聚合之前不进行任何实际计算。这种“延迟评估”方法意味着常见的聚合可以以几乎对用户透明的方式高效实现。
要生成结果,我们可以对这个DataFrameGroupBy
对象应用聚合函数,它将执行适当的应用/合并步骤以生成所需的结果:
In [13]: df.groupby('key').sum()
Out[13]: data
key
A 3
B 5
C 7
这里sum
方法只是一种可能性;你可以应用大多数 Pandas 或 NumPy 聚合函数,以及大多数DataFrame
操作,正如你将在以下讨论中看到的那样。
GroupBy 对象
GroupBy
对象是一个灵活的抽象:在许多情况下,它可以简单地被视为DataFrame
的集合,尽管在内部执行更复杂的操作。让我们看一些使用行星数据的示例。
GroupBy
提供的可能是最重要的操作是aggregate、filter、transform和apply。我们将在下一节更详细地讨论每一个,但在此之前,让我们看一些可以与基本GroupBy
操作一起使用的其他功能。
列索引
GroupBy
对象支持与DataFrame
相同的列索引,返回一个修改过的GroupBy
对象。例如:
In [14]: planets.groupby('method')
Out[14]: <pandas.core.groupby.generic.DataFrameGroupBy object at 0x11d1bc820>
In [15]: planets.groupby('method')['orbital_period']
Out[15]: <pandas.core.groupby.generic.SeriesGroupBy object at 0x11d1bcd60>
在这里,我们通过引用其列名从原始的DataFrame
组中选择了一个特定的Series
组。与GroupBy
对象一样,直到我们对对象调用某些聚合函数之前,都不会进行任何计算:
In [16]: planets.groupby('method')['orbital_period'].median()
Out[16]: method
Astrometry 631.180000
Eclipse Timing Variations 4343.500000
Imaging 27500.000000
Microlensing 3300.000000
Orbital Brightness Modulation 0.342887
Pulsar Timing 66.541900
Pulsation Timing Variations 1170.000000
Radial Velocity 360.200000
Transit 5.714932
Transit Timing Variations 57.011000
Name: orbital_period, dtype: float64
这给出了每种方法对轨道周期(以天计)的一般尺度的概念。
对组进行迭代
GroupBy
对象支持直接在组上进行迭代,返回每个组作为Series
或DataFrame
:
In [17]: for (method, group) in planets.groupby('method'):
print("{0:30s} shape={1}".format(method, group.shape))
Out[17]: Astrometry shape=(2, 6)
Eclipse Timing Variations shape=(9, 6)
Imaging shape=(38, 6)
Microlensing shape=(23, 6)
Orbital Brightness Modulation shape=(3, 6)
Pulsar Timing shape=(5, 6)
Pulsation Timing Variations shape=(1, 6)
Radial Velocity shape=(553, 6)
Transit shape=(397, 6)
Transit Timing Variations shape=(4, 6)
这对于手动检查组以进行调试非常有用,但通常使用内置的apply
功能会更快,我们稍后将讨论此功能。
分派方法
通过一些 Python 类魔术,任何未明确由GroupBy
对象实现的方法都将被传递并在组上调用,无论它们是DataFrame
还是Series
对象。例如,使用describe
方法等效于在表示每个组的DataFrame
上调用describe
:
In [18]: planets.groupby('method')['year'].describe().unstack()
Out[18]: method
count Astrometry 2.0
Eclipse Timing Variations 9.0
Imaging 38.0
Microlensing 23.0
Orbital Brightness Modulation 3.0
...
max Pulsar Timing 2011.0
Pulsation Timing Variations 2007.0
Radial Velocity 2014.0
Transit 2014.0
Transit Timing Variations 2014.0
Length: 80, dtype: float64
查看这张表有助于我们更好地理解数据:例如,直到 2014 年,绝大多数行星是通过径向速度和凌日法发现的,尽管后者方法近年来变得更为普遍。最新的方法似乎是凌时差变化和轨道亮度调制,直到 2011 年才用于发现新行星。
注意,这些分派方法是应用在每个单独的组上的,并且结果然后在GroupBy
内组合并返回。同样地,任何有效的DataFrame
/Series
方法都可以在对应的GroupBy
对象上类似地调用。
聚合(Aggregate)、筛选(Filter)、转换(Transform)、应用(Apply)
前面的讨论侧重于合并操作的聚合,但还有更多可用选项。特别是,GroupBy
对象具有aggregate
、filter
、transform
和apply
方法,可以在组合并分组数据之前有效地实现多种有用的操作。
出于以下各小节的目的,我们将使用这个DataFrame
:
In [19]: rng = np.random.RandomState(0)
df = pd.DataFrame({'key': ['A', 'B', 'C', 'A', 'B', 'C'],
'data1': range(6),
'data2': rng.randint(0, 10, 6)},
columns = ['key', 'data1', 'data2'])
df
Out[19]: key data1 data2
0 A 0 5
1 B 1 0
2 C 2 3
3 A 3 3
4 B 4 7
5 C 5 9
聚合
现在你已经熟悉了使用sum
、median
等方法的GroupBy
聚合,但aggregate
方法允许更加灵活。它可以接受字符串、函数或函数列表,并一次计算所有聚合。以下是一个快速示例,结合了所有这些内容:
In [20]: df.groupby('key').aggregate(['min', np.median, max])
Out[20]: data1 data2
min median max min median max
key
A 0 1.5 3 3 4.0 5
B 1 2.5 4 0 3.5 7
C 2 3.5 5 3 6.0 9
另一种常见的模式是传递一个将列名映射到要应用于该列的操作的字典:
In [21]: df.groupby('key').aggregate({'data1': 'min',
'data2': 'max'})
Out[21]: data1 data2
key
A 0 5
B 1 7
C 2 9
过滤
过滤操作允许您根据组属性丢弃数据。例如,我们可能希望保留所有标准偏差大于某个临界值的组:
In [22]: def filter_func(x):
return x['data2'].std() > 4
display('df', "df.groupby('key').std()",
"df.groupby('key').filter(filter_func)")
Out[22]: df df.groupby('key').std()
key data1 data2 data1 data2
0 A 0 5 key
1 B 1 0 A 2.12132 1.414214
2 C 2 3 B 2.12132 4.949747
3 A 3 3 C 2.12132 4.242641
4 B 4 7
5 C 5 9
df.groupby('key').filter(filter_func)
key data1 data2
1 B 1 0
2 C 2 3
4 B 4 7
5 C 5 9
过滤函数应返回一个布尔值,指定组是否通过过滤。在这里,因为 A 组的标准偏差不大于 4,所以它从结果中被删除。
变换
虽然聚合必须返回数据的减少版本,但变换可以返回一些经过转换的完整数据以重新组合。对于这种转换,输出与输入的形状相同。一个常见的例子是通过减去组内均值来使数据居中:
In [23]: def center(x):
return x - x.mean()
df.groupby('key').transform(center)
Out[23]: data1 data2
0 -1.5 1.0
1 -1.5 -3.5
2 -1.5 -3.0
3 1.5 -1.0
4 1.5 3.5
5 1.5 3.0
应用方法
apply
方法允许您将任意函数应用于组结果。该函数应接受一个DataFrame
,并返回一个 Pandas 对象(例如DataFrame
、Series
)或一个标量;合并步骤的行为将根据返回的输出类型进行调整。
例如,这里是一个通过第一列的总和来归一化的apply
操作:
In [24]: def norm_by_data2(x):
# x is a DataFrame of group values
x['data1'] /= x['data2'].sum()
return x
df.groupby('key').apply(norm_by_data2)
Out[24]: key data1 data2
0 A 0.000000 5
1 B 0.142857 0
2 C 0.166667 3
3 A 0.375000 3
4 B 0.571429 7
5 C 0.416667 9
在GroupBy
中的apply
非常灵活:唯一的标准是函数接受DataFrame
并返回 Pandas 对象或标量。在中间您可以做任何事情!
指定分割密钥
在之前展示的简单示例中,我们根据单个列名拆分了DataFrame
。这只是定义组的许多选项之一,我们将在这里介绍一些其他的组规范选项。
提供分组键的列表、数组、系列或索引
密钥可以是与DataFrame
长度匹配的任何系列或列表。例如:
In [25]: L = [0, 1, 0, 1, 2, 0]
df.groupby(L).sum()
Out[25]: data1 data2
0 7 17
1 4 3
2 4 7
当然,这意味着还有另一种更冗长的方法来实现df.groupby('key')
:
In [26]: df.groupby(df['key']).sum()
Out[26]: data1 data2
key
A 3 8
B 5 7
C 7 12
映射索引到组的字典或系列
另一种方法是提供一个将索引值映射到组键的字典:
In [27]: df2 = df.set_index('key')
mapping = {'A': 'vowel', 'B': 'consonant', 'C': 'consonant'}
display('df2', 'df2.groupby(mapping).sum()')
Out[27]: df2 df2.groupby(mapping).sum()
data1 data2 data1 data2
key key
A 0 5 consonant 12 19
B 1 0 vowel 3 8
C 2 3
A 3 3
B 4 7
C 5 9
任何 Python 函数
类似于映射,您可以传递任何 Python 函数,该函数将输入索引值并输出组:
In [28]: df2.groupby(str.lower).mean()
Out[28]: data1 data2
key
a 1.5 4.0
b 2.5 3.5
c 3.5 6.0
有效密钥列表
此外,可以将任何前述密钥选择组合以在多索引上进行分组:
In [29]: df2.groupby([str.lower, mapping]).mean()
Out[29]: data1 data2
key key
a vowel 1.5 4.0
b consonant 2.5 3.5
c consonant 3.5 6.0
分组示例
举例来说,在几行 Python 代码中,我们可以将所有这些组合在一起,并按方法和十年计数发现的行星:
In [30]: decade = 10 * (planets['year'] // 10)
decade = decade.astype(str) + 's'
decade.name = 'decade'
planets.groupby(['method', decade])['number'].sum().unstack().fillna(0)
Out[30]: decade 1980s 1990s 2000s 2010s
method
Astrometry 0.0 0.0 0.0 2.0
Eclipse Timing Variations 0.0 0.0 5.0 10.0
Imaging 0.0 0.0 29.0 21.0
Microlensing 0.0 0.0 12.0 15.0
Orbital Brightness Modulation 0.0 0.0 0.0 5.0
Pulsar Timing 0.0 9.0 1.0 1.0
Pulsation Timing Variations 0.0 0.0 1.0 0.0
Radial Velocity 1.0 52.0 475.0 424.0
Transit 0.0 0.0 64.0 712.0
Transit Timing Variations 0.0 0.0 0.0 9.0
这显示了在查看现实数据集时结合许多我们到目前为止讨论过的操作的力量:我们很快就能粗略地了解在首次发现后的几年内如何检测到系外行星。
我建议深入研究这几行代码,并评估每个步骤,以确保您完全理解它们对结果的影响。这当然是一个有些复杂的例子,但理解这些部分将使您能够类似地探索自己的数据。
^(1) 生成此图的代码可以在在线附录中找到。
第二十一章:透视表
我们已经看到groupby
抽象可以帮助我们探索数据集中的关系。透视表是一种类似的操作,在电子表格和其他操作表格数据的程序中经常见到。透视表以简单的列数据作为输入,并将条目分组成二维表,提供数据的多维总结。透视表与groupby
之间的区别有时会引起混淆;我认为透视表基本上是groupby
聚合的多维版本。也就是说,你进行分割-应用-组合,但分割和组合发生在二维网格上,而不是一维索引上。
激励透视表
在这一部分的示例中,我们将使用泰坦尼克号乘客的数据库,该数据库可通过 Seaborn 库获取(见第三十六章):
In [1]: import numpy as np
import pandas as pd
import seaborn as sns
titanic = sns.load_dataset('titanic')
In [2]: titanic.head()
Out[2]: survived pclass sex age sibsp parch fare embarked class \
0 0 3 male 22.0 1 0 7.2500 S Third
1 1 1 female 38.0 1 0 71.2833 C First
2 1 3 female 26.0 0 0 7.9250 S Third
3 1 1 female 35.0 1 0 53.1000 S First
4 0 3 male 35.0 0 0 8.0500 S Third
who adult_male deck embark_town alive alone
0 man True NaN Southampton no False
1 woman False C Cherbourg yes False
2 woman False NaN Southampton yes True
3 woman False C Southampton yes False
4 man True NaN Southampton no True
正如输出所示,这包含了每位乘客的多个数据点,包括性别、年龄、阶级、支付的票价等等。
手动透视表
要开始了解更多关于这些数据的信息,我们可以根据性别、生存状态或两者的某种组合进行分组。如果你已经阅读了前一章节,你可能会想应用一个groupby
操作——例如,让我们来看看按性别划分的生存率:
In [3]: titanic.groupby('sex')[['survived']].mean()
Out[3]: survived
sex
female 0.742038
male 0.188908
这给了我们一些初步的见解:总体而言,船上四分之三的女性幸存下来,而只有五分之一的男性幸存!
这很有用,但我们可能希望再深入一步,查看性别和阶级的生存率。使用groupby
的术语,我们可以按以下过程进行操作:首先按阶级和性别进行分组,然后选择生存,应用均值聚合,组合结果组,并最后展开层次索引以显示隐藏的多维特性。在代码中:
In [4]: titanic.groupby(['sex', 'class'])['survived'].aggregate('mean').unstack()
Out[4]: class First Second Third
sex
female 0.968085 0.921053 0.500000
male 0.368852 0.157407 0.135447
这样我们可以更好地了解性别和阶级如何影响生存,但是代码看起来有点凌乱。虽然我们之前讨论的每个步骤在工具的背景下都是合理的,但是这长串代码并不特别易于阅读或使用。这种二维的groupby
很常见,因此 Pandas 包含了一个方便的例程pivot_table
,它简洁地处理这种多维聚合类型。
透视表语法
下面是使用DataFrame.pivot_table
方法进行的等效操作:
In [5]: titanic.pivot_table('survived', index='sex', columns='class', aggfunc='mean')
Out[5]: class First Second Third
sex
female 0.968085 0.921053 0.500000
male 0.368852 0.157407 0.135447
这比手动的groupby
方法更易读,并产生相同的结果。正如你可能期待的那样,在 20 世纪初的大西洋横渡邮轮上,生存率偏向于更高阶级和数据中记录为女性的人群。头等舱的女性几乎百分之百幸存下来(嗨,Rose!),而仅约五分之一的三等舱男性幸存下来(抱歉,Jack!)。
多级透视表
就像在groupby
中一样,透视表中的分组可以通过多个级别和多种选项来指定。例如,我们可能对年龄作为第三维度感兴趣。我们将使用pd.cut
函数对年龄进行分箱:
In [6]: age = pd.cut(titanic['age'], [0, 18, 80])
titanic.pivot_table('survived', ['sex', age], 'class')
Out[6]: class First Second Third
sex age
female (0, 18] 0.909091 1.000000 0.511628
(18, 80] 0.972973 0.900000 0.423729
male (0, 18] 0.800000 0.600000 0.215686
(18, 80] 0.375000 0.071429 0.133663
在处理列时我们可以应用相同的策略;让我们添加有关支付费用的信息,使用pd.qcut
自动计算分位数:
In [7]: fare = pd.qcut(titanic['fare'], 2)
titanic.pivot_table('survived', ['sex', age], [fare, 'class'])
Out[7]: fare (-0.001, 14.454] (14.454, 512.329] \
class First Second Third First
sex age
female (0, 18] NaN 1.000000 0.714286 0.909091
(18, 80] NaN 0.880000 0.444444 0.972973
male (0, 18] NaN 0.000000 0.260870 0.800000
(18, 80] 0.0 0.098039 0.125000 0.391304
fare
class Second Third
sex age
female (0, 18] 1.000000 0.318182
(18, 80] 0.914286 0.391304
male (0, 18] 0.818182 0.178571
(18, 80] 0.030303 0.192308
结果是具有分层索引的四维聚合(见第 17 章),显示了展示值之间关系的网格。
额外的透视表选项
DataFrame.pivot_table
方法的完整调用签名如下:
# call signature as of Pandas 1.3.5
DataFrame.pivot_table(data, values=None, index=None, columns=None,
aggfunc='mean', fill_value=None, margins=False,
dropna=True, margins_name='All', observed=False,
sort=True)
我们已经看到了前三个参数的示例;这里我们将看一些其余的选项。两个选项fill_value
和dropna
与缺失数据有关,非常直观;我不会在这里展示它们的示例。
aggfunc
关键字控制应用的聚合类型,默认为均值。与groupby
一样,聚合规范可以是表示几种常见选择之一的字符串('sum'
、'mean'
、'count'
、'min'
、'max'
等)或实现聚合的函数(例如,np.sum()
、min()
、sum()
等)。此外,它可以被指定为将列映射到任何所需选项之一的字典:
In [8]: titanic.pivot_table(index='sex', columns='class',
aggfunc={'survived':sum, 'fare':'mean'})
Out[8]: fare survived
class First Second Third First Second Third
sex
female 106.125798 21.970121 16.118810 91 70 72
male 67.226127 19.741782 12.661633 45 17 47
这里还要注意,我们省略了values
关键字;在为aggfunc
指定映射时,这将自动确定。
有时计算每个分组的总计是有用的。这可以通过margins
关键字来完成:
In [9]: titanic.pivot_table('survived', index='sex', columns='class', margins=True)
Out[9]: class First Second Third All
sex
female 0.968085 0.921053 0.500000 0.742038
male 0.368852 0.157407 0.135447 0.188908
All 0.629630 0.472826 0.242363 0.383838
在这里,这自动为我们提供了关于性别不可知的无关生存率、类别不可知的生存率以及 38%的总体生存率信息。边缘标签可以通过margins_name
关键字指定,默认为"All"
。
示例:出生率数据
另一个例子,让我们看看美国出生数据的免费可用数据(提供者为美国疾病控制中心(CDC)):data on births in the US,这些数据已经被安德鲁·格尔曼及其团队进行了相当深入的分析;例如,参见使用高斯过程进行信号处理的博客文章:^(1)
In [10]: # shell command to download the data:
# !cd data && curl -O \
# https://raw.githubusercontent.com/jakevdp/data-CDCbirths/master/births.csv
In [11]: births = pd.read_csv('data/births.csv')
看一下数据,我们可以看到它相对简单——它包含按日期和性别分组的出生数量:
In [12]: births.head()
Out[12]: year month day gender births
0 1969 1 1.0 F 4046
1 1969 1 1.0 M 4440
2 1969 1 2.0 F 4454
3 1969 1 2.0 M 4548
4 1969 1 3.0 F 4548
我们可以通过使用透视表来开始更深入地理解这些数据。让我们添加一个decade
列,然后看看男性和女性出生人数如何随着十年变化:
In [13]: births['decade'] = 10 * (births['year'] // 10)
births.pivot_table('births', index='decade', columns='gender',
aggfunc='sum')
Out[13]: gender F M
decade
1960 1753634 1846572
1970 16263075 17121550
1980 18310351 19243452
1990 19479454 20420553
2000 18229309 19106428
我们看到每个十年男性出生人数超过女性。为了更清楚地看到这一趋势,我们可以使用 Pandas 中的内置绘图工具来可视化每年的总出生人数,如图 21-1 所示(参见第 IV 部分关于使用 Matplotlib 绘图的讨论):
In [14]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
births.pivot_table(
'births', index='year', columns='gender', aggfunc='sum').plot()
plt.ylabel('total births per year');
图 21-1. 美国各年度出生总数及性别分布^(2)
通过一个简单的数据透视表和plot
方法,我们可以立即看到性别出生的年度趋势。通过肉眼观察,过去 50 年男性出生人数大约比女性出生人数多约 5%。
虽然这与数据透视表不一定有关,但我们可以利用到目前为止介绍的 Pandas 工具从这个数据集中提取一些更有趣的特征。我们必须首先稍微清理数据,删除由于输入错误的日期(例如 6 月 31 日)或缺失值(例如 6 月 99 日)引起的异常值。一种一次性删除所有这些异常值的简单方法是通过强大的 sigma 剪切操作:
In [15]: quartiles = np.percentile(births['births'], [25, 50, 75])
mu = quartiles[1]
sig = 0.74 * (quartiles[2] - quartiles[0])
这最后一行是样本标准差的健壮估计,其中的 0.74 来自高斯分布的四分位距(您可以在我与Željko Ivezić、Andrew J. Connolly 和 Alexander Gray 共同撰写的书籍Statistics, Data Mining, and Machine Learning in Astronomy(普林斯顿大学出版社)中了解更多有关 sigma 剪切操作的信息)。
使用query
方法(在第二十四章中进一步讨论)来过滤出超出这些值范围的出生行:
In [16]: births = births.query('(births > @mu - 5 * @sig) &
(births < @mu + 5 * @sig)')
接下来,我们将day
列设置为整数;此前它是一个字符串列,因为数据集中的一些列包含值'null'
:
In [17]: # set 'day' column to integer; it originally was a string due to nulls
births['day'] = births['day'].astype(int)
最后,我们可以结合日、月和年创建一个日期索引(见第二十三章)。这使得我们可以快速计算每一行对应的工作日:
In [18]: # create a datetime index from the year, month, day
births.index = pd.to_datetime(10000 * births.year +
100 * births.month +
births.day, format='%Y%m%d')
births['dayofweek'] = births.index.dayofweek
利用这一点,我们可以绘制几十年来每周的出生情况(见图 21-2)。
In [19]: import matplotlib.pyplot as plt
import matplotlib as mpl
births.pivot_table('births', index='dayofweek',
columns='decade', aggfunc='mean').plot()
plt.gca().set(xticks=range(7),
xticklabels=['Mon', 'Tues', 'Wed', 'Thurs',
'Fri', 'Sat', 'Sun'])
plt.ylabel('mean births by day');
显然,周末的出生率略低于工作日!请注意,1990 年代和 2000 年代缺失,因为从 1989 年开始,CDC 的数据仅包含出生月份。
另一个有趣的视图是按年中日期平均出生数量的图表。让我们首先分别按月份和日期分组数据:
In [20]: births_by_date = births.pivot_table('births',
[births.index.month, births.index.day])
births_by_date.head()
Out[20]: births
1 1 4009.225
2 4247.400
3 4500.900
4 4571.350
5 4603.625
图 21-2. 每周平均出生量按工作日和十年间隔^(3)
结果是一个月份和日期的多重索引。为了使其可视化,让我们通过将它们与一个虚拟的年份变量关联起来(确保选择一个闰年,以正确处理 2 月 29 日)将这些月份和日期转换为日期:
In [21]: from datetime import datetime
births_by_date.index = [datetime(2012, month, day)
for (month, day) in births_by_date.index]
births_by_date.head()
Out[21]: births
2012-01-01 4009.225
2012-01-02 4247.400
2012-01-03 4500.900
2012-01-04 4571.350
2012-01-05 4603.625
焦点放在月份和日期上,我们现在有一个反映每年平均出生数量的时间序列。从这个序列,我们可以使用plot
方法绘制数据。正如您可以在图 21-3 中看到的那样,它显示了一些有趣的趋势。
In [22]: # Plot the results
fig, ax = plt.subplots(figsize=(12, 4))
births_by_date.plot(ax=ax);
图 21-3. 每日平均出生量按日期^(4)
特别是,这张图的显著特征是美国假日(例如独立日、劳动节、感恩节、圣诞节、元旦)出生率的下降,尽管这可能反映了计划/引发出生的趋势,而不是对自然出生产生深刻的心理效应。关于这一趋势的更多讨论,请参见安德鲁·格尔曼的博客文章。我们将在第三十二章中使用 Matplotlib 的工具对这个图进行注释。
查看这个简短的例子,你可以看到到目前为止我们见过的许多 Python 和 Pandas 工具可以结合使用,从各种数据集中获取洞察力。我们将在后续章节中看到这些数据操作的更复杂应用!
^(1) 本节中使用的 CDC 数据集使用了出生时分配的性别,称之为“性别”,并将数据限制在男性和女性之间。尽管性别是独立于生物学的光谱,但为了一致性和清晰度,在讨论这个数据集时我将使用相同的术语。
^(2) 这个图的全彩版本可以在GitHub找到。
^(3) 这个图的全彩版本可以在GitHub找到。
^(4) 这个图的全尺寸版本可以在GitHub找到。
第二十二章:向量化字符串操作
Python 的一个优点是相对容易处理和操作字符串数据。Pandas 在此基础上构建,并提供了一套全面的向量化字符串操作,这是处理(即:清理)现实世界数据时必不可少的部分。在本章中,我们将逐步介绍一些 Pandas 字符串操作,然后看看如何使用它们部分清理从互联网收集的非常混乱的食谱数据集。
引入 Pandas 字符串操作
在之前的章节中,我们看到工具如 NumPy 和 Pandas 如何将算术操作泛化,以便我们可以轻松快速地在许多数组元素上执行相同的操作。例如:
In [1]: import numpy as np
x = np.array([2, 3, 5, 7, 11, 13])
x * 2
Out[1]: array([ 4, 6, 10, 14, 22, 26])
这种操作的向量化简化了操作数组数据的语法:我们不再需要担心数组的大小或形状,而只需关注我们想要进行的操作。对于字符串数组,NumPy 没有提供如此简单的访问方式,因此你只能使用更冗长的循环语法:
In [2]: data = ['peter', 'Paul', 'MARY', 'gUIDO']
[s.capitalize() for s in data]
Out[2]: ['Peter', 'Paul', 'Mary', 'Guido']
这可能足以处理一些数据,但如果有任何缺失值,它将会出错,因此这种方法需要额外的检查:
In [3]: data = ['peter', 'Paul', None, 'MARY', 'gUIDO']
[s if s is None else s.capitalize() for s in data]
Out[3]: ['Peter', 'Paul', None, 'Mary', 'Guido']
这种手动方法不仅冗长且不方便,还容易出错。
Pandas 包括功能来同时解决对向量化字符串操作的需求以及通过 Pandas Series
和Index
对象的str
属性正确处理缺失数据的需求。因此,例如,如果我们创建一个包含这些数据的 Pandas Series
,我们可以直接调用str.capitalize
方法,其中内置了缺失值处理:
In [4]: import pandas as pd
names = pd.Series(data)
names.str.capitalize()
Out[4]: 0 Peter
1 Paul
2 None
3 Mary
4 Guido
dtype: object
Pandas 字符串方法表
如果你对 Python 中的字符串操作有很好的理解,大部分 Pandas 字符串语法都足够直观,可能只需列出可用的方法就足够了。我们先从这里开始,然后深入探讨一些细微之处。本节的示例使用以下Series
对象:
In [5]: monte = pd.Series(['Graham Chapman', 'John Cleese', 'Terry Gilliam',
'Eric Idle', 'Terry Jones', 'Michael Palin'])
类似于 Python 字符串方法的方法
几乎所有 Python 内置的字符串方法都有与之对应的 Pandas 向量化字符串方法。以下 Pandas str
方法与 Python 字符串方法相对应:
len |
lower |
translate |
islower |
ljust |
---|---|---|---|---|
upper |
startswith |
isupper |
rjust |
find |
endswith |
isnumeric |
center |
rfind |
isalnum |
isdecimal |
zfill |
index |
isalpha |
split |
strip |
rindex |
isdigit |
rsplit |
rstrip |
capitalize |
isspace |
partition |
lstrip |
swapcase |
注意这些具有不同的返回值。一些像lower
这样的方法返回一系列字符串:
In [6]: monte.str.lower()
Out[6]: 0 graham chapman
1 john cleese
2 terry gilliam
3 eric idle
4 terry jones
5 michael palin
dtype: object
但有些返回数字:
In [7]: monte.str.len()
Out[7]: 0 14
1 11
2 13
3 9
4 11
5 13
dtype: int64
或者布尔值:
In [8]: monte.str.startswith('T')
Out[8]: 0 False
1 False
2 True
3 False
4 True
5 False
dtype: bool
还有一些方法返回每个元素的列表或其他复合值:
In [9]: monte.str.split()
Out[9]: 0 [Graham, Chapman]
1 [John, Cleese]
2 [Terry, Gilliam]
3 [Eric, Idle]
4 [Terry, Jones]
5 [Michael, Palin]
dtype: object
当我们继续讨论时,我们将看到这种系列列表对象的进一步操作。
使用正则表达式的方法
此外,还有几种方法接受正则表达式(regexps)来检查每个字符串元素的内容,并遵循 Python 内置 re
模块的一些 API 约定(参见 Table 22-1)。
Table 22-1. Pandas 方法与 Python re
模块函数的映射关系
方法 | 描述 |
---|---|
match |
对每个元素调用 re.match ,返回布尔值。 |
extract |
对每个元素调用 re.match ,返回匹配的字符串组。 |
findall |
对每个元素调用 re.findall |
replace |
用其他字符串替换模式的出现 |
contains |
对每个元素调用 re.search ,返回布尔值 |
count |
计算模式的出现次数 |
split |
等同于 str.split ,但接受正则表达式 |
rsplit |
等同于 str.rsplit ,但接受正则表达式 |
使用这些方法,我们可以进行各种操作。例如,通过请求每个元素开头的一组连续字符,我们可以从中提取每个元素的名字:
In [10]: monte.str.extract('([A-Za-z]+)', expand=False)
Out[10]: 0 Graham
1 John
2 Terry
3 Eric
4 Terry
5 Michael
dtype: object
或者我们可以做一些更复杂的事情,比如找出所有以辅音开头和结尾的名字,利用正则表达式的开头(^
)和结尾($
)字符:
In [11]: monte.str.findall(r'^[^AEIOU].*[^aeiou]$')
Out[11]: 0 [Graham Chapman]
1 []
2 [Terry Gilliam]
3 []
4 [Terry Jones]
5 [Michael Palin]
dtype: object
能够简洁地应用正则表达式于 Series
或 DataFrame
条目之上,为数据的分析和清理开辟了许多可能性。
杂项方法
最后,Table 22-2 列出了使其他便捷操作得以实现的杂项方法。
Table 22-2. 其他 Pandas 字符串方法
方法 | 描述 |
---|---|
get |
对每个元素进行索引 |
slice |
对每个元素进行切片 |
slice_replace |
用传递的值替换每个元素中的片段 |
cat |
连接字符串 |
repeat |
重复值 |
normalize |
返回字符串的 Unicode 形式 |
pad |
在字符串的左侧、右侧或两侧添加空格 |
wrap |
将长字符串分割成长度小于给定宽度的行 |
join |
将 Series 中每个元素的字符串用指定分隔符连接起来 |
get_dummies |
提取作为 DataFrame 的虚拟变量 |
向量化项访问和切片
特别是 get
和 slice
操作,使得可以从每个数组中进行向量化元素访问。例如,我们可以使用 str.slice(0, 3)
获取每个数组的前三个字符的片段。这种行为也可以通过 Python 的正常索引语法实现;例如,df.str.slice(0, 3)
相当于 df.str[0:3]
:
In [12]: monte.str[0:3]
Out[12]: 0 Gra
1 Joh
2 Ter
3 Eri
4 Ter
5 Mic
dtype: object
通过 df.str.get(i)
和 df.str[i]
进行的索引与之类似。
这些索引方法还允许您访问由 split
返回的数组的元素。例如,结合 split
和 str
索引,可以提取每个条目的姓氏:
In [13]: monte.str.split().str[-1]
Out[13]: 0 Chapman
1 Cleese
2 Gilliam
3 Idle
4 Jones
5 Palin
dtype: object
指标变量
另一种需要额外解释的方法是get_dummies
方法。当你的数据包含某种编码指示器时,这将非常有用。例如,我们可能有一个数据集,其中包含以代码形式的信息,比如 A = “出生在美国”,B = “出生在英国”,C = “喜欢奶酪”,D = “喜欢午餐肉”:
In [14]: full_monte = pd.DataFrame({'name': monte,
'info': ['B|C|D', 'B|D', 'A|C',
'B|D', 'B|C', 'B|C|D']})
full_monte
Out[14]: name info
0 Graham Chapman B|C|D
1 John Cleese B|D
2 Terry Gilliam A|C
3 Eric Idle B|D
4 Terry Jones B|C
5 Michael Palin B|C|D
get_dummies
例程允许我们将这些指示变量拆分成一个DataFrame
:
In [15]: full_monte['info'].str.get_dummies('|')
Out[15]: A B C D
0 0 1 1 1
1 0 1 0 1
2 1 0 1 0
3 0 1 0 1
4 0 1 1 0
5 0 1 1 1
借助这些操作作为构建块,您可以在清理数据时构建各种无穷无尽的字符串处理过程。
我们在这里不会进一步深入这些方法,但我鼓励您阅读 “处理文本数据” 在 Pandas 在线文档中,或参考 “进一步资源” 中列出的资源。
示例:食谱数据库
在清理混乱的现实世界数据时,这些向量化的字符串操作变得非常有用。这里我将通过一个例子详细介绍这一点,使用从网上各种来源编译的开放食谱数据库。我们的目标是将食谱数据解析成成分列表,以便我们可以根据手头上的一些成分快速找到一个食谱。用于编译这些脚本的代码可以在 GitHub 找到,并且数据库的最新版本链接也可以在那里找到。
这个数据库大小约为 30 MB,可以使用以下命令下载并解压:
In [16]: # repo = "https://raw.githubusercontent.com/jakevdp/open-recipe-data/master"
# !cd data && curl -O {repo}/recipeitems.json.gz
# !gunzip data/recipeitems.json.gz
数据库以 JSON 格式存在,因此我们将使用pd.read_json
来读取它(对于这个数据集,需要使用lines=True
,因为文件的每一行都是一个 JSON 条目):
In [17]: recipes = pd.read_json('data/recipeitems.json', lines=True)
recipes.shape
Out[17]: (173278, 17)
我们看到有将近 175,000 个食谱和 17 列。让我们看看一行,看看我们有什么:
In [18]: recipes.iloc[0]
Out[18]: _id {'$oid': '5160756b96cc62079cc2db15'}
name Drop Biscuits and Sausage Gravy
ingredients Biscuits\n3 cups All-purpose Flour\n2 Tablespo...
url http://thepioneerwoman.com/cooking/2013/03/dro...
image http://static.thepioneerwoman.com/cooking/file...
ts {'$date': 1365276011104}
cookTime PT30M
source thepioneerwoman
recipeYield 12
datePublished 2013-03-11
prepTime PT10M
description Late Saturday afternoon, after Marlboro Man ha...
totalTime NaN
creator NaN
recipeCategory NaN
dateModified NaN
recipeInstructions NaN
Name: 0, dtype: object
那里有很多信息,但其中大部分都是以非常混乱的形式存在的,这是从网上爬取数据的典型情况。特别是成分列表以字符串格式存在;我们需要仔细提取我们感兴趣的信息。让我们先仔细查看一下这些成分:
In [19]: recipes.ingredients.str.len().describe()
Out[19]: count 173278.000000
mean 244.617926
std 146.705285
min 0.000000
25% 147.000000
50% 221.000000
75% 314.000000
max 9067.000000
Name: ingredients, dtype: float64
成分列表平均长度为 250 个字符,最小为 0,最大接近 10,000 个字符!
出于好奇,让我们看看哪个食谱的成分列表最长:
In [20]: recipes.name[np.argmax(recipes.ingredients.str.len())]
Out[20]: 'Carrot Pineapple Spice & Brownie Layer Cake with Whipped Cream &
> Cream Cheese Frosting and Marzipan Carrots'
我们可以进行其他聚合探索;例如,我们可以查看有多少食谱是早餐食品(使用正则表达式语法匹配小写和大写字母):
In [21]: recipes.description.str.contains('[Bb]reakfast').sum()
Out[21]: 3524
或者有多少食谱将肉桂列为成分:
In [22]: recipes.ingredients.str.contains('[Cc]innamon').sum()
Out[22]: 10526
我们甚至可以查看是否有任何食谱将成分拼错为“cinamon”:
In [23]: recipes.ingredients.str.contains('[Cc]inamon').sum()
Out[23]: 11
这是 Pandas 字符串工具可以实现的数据探索类型。Python 在这类数据整理方面表现得非常出色。
一个简单的食谱推荐器
让我们再进一步,开始制作一个简单的菜谱推荐系统:给定一系列食材,我们希望找到使用所有这些食材的任何菜谱。虽然概念上很简单,但由于数据的异构性而变得复杂:例如,从每行提取一个干净的食材列表并不容易。因此,我们会稍微作弊一点:我们将从常见食材列表开始,然后简单地搜索是否在每个菜谱的食材列表中。为简单起见,我们暂时只使用香草和香料:
In [24]: spice_list = ['salt', 'pepper', 'oregano', 'sage', 'parsley',
'rosemary', 'tarragon', 'thyme', 'paprika', 'cumin']
然后,我们可以构建一个由True
和False
值组成的布尔DataFrame
,指示每种食材是否出现在列表中:
In [25]: import re
spice_df = pd.DataFrame({
spice: recipes.ingredients.str.contains(spice, re.IGNORECASE)
for spice in spice_list})
spice_df.head()
Out[25]: salt pepper oregano sage parsley rosemary tarragon thyme \
0 False False False True False False False False
1 False False False False False False False False
2 True True False False False False False False
3 False False False False False False False False
4 False False False False False False False False
paprika cumin
0 False False
1 False False
2 False True
3 False False
4 False False
现在,举个例子,假设我们想找到使用欧芹、辣椒粉和龙蒿的菜谱。我们可以使用DataFrame
的query
方法快速计算这一点,有关详细信息,请参阅 第二十四章:
In [26]: selection = spice_df.query('parsley & paprika & tarragon')
len(selection)
Out[26]: 10
我们只找到了这种组合的 10 个菜谱。让我们使用此选择返回的索引来发现这些菜谱的名称:
In [27]: recipes.name[selection.index]
Out[27]: 2069 All cremat with a Little Gem, dandelion and wa...
74964 Lobster with Thermidor butter
93768 Burton's Southern Fried Chicken with White Gravy
113926 Mijo's Slow Cooker Shredded Beef
137686 Asparagus Soup with Poached Eggs
140530 Fried Oyster Po’boys
158475 Lamb shank tagine with herb tabbouleh
158486 Southern fried chicken in buttermilk
163175 Fried Chicken Sliders with Pickles + Slaw
165243 Bar Tartine Cauliflower Salad
Name: name, dtype: object
现在我们已经将菜谱选择从 175,000 缩减到了 10,我们可以更加明智地决定晚餐要做什么了。
进一步探索菜谱
希望这个例子给了你一点关于 Pandas 字符串方法能够高效实现的数据清理操作类型的味道(嘿)。当然,构建一个健壮的菜谱推荐系统需要 很多 工作!从每个菜谱中提取完整的食材列表将是任务的重要部分;不幸的是,使用的各种格式的广泛变化使得这成为一个相对耗时的过程。这表明在数据科学中,清理和整理真实世界数据通常占据了大部分工作量—而 Pandas 提供了可以帮助您高效完成这项工作的工具。
第二十三章: 与时间序列一起工作
Pandas 最初是在财务建模的背景下开发的,因此您可能期望它包含大量用于处理日期、时间和时间索引数据的工具。 日期和时间数据有几种不同的形式,我们将在这里进行讨论:
时间戳
特定的时间点(例如,2021 年 7 月 4 日上午 7:00)。
时间间隔和周期
特定开始和结束点之间的一段时间;例如,2021 年 6 月份。 周期通常指每个间隔具有统一长度且不重叠的特殊时间间隔的情况(例如,由一天组成的 24 小时周期)。
时间差或持续时间
精确的时间长度(例如,22.56 秒的持续时间)。
本章将介绍如何在 Pandas 中处理这些类型的日期/时间数据。 这并不是 Python 或 Pandas 中可用的时间序列工具的完整指南,而是旨在作为用户如何处理时间序列的广泛概述。 我们将首先简要讨论 Python 中处理日期和时间的工具,然后更详细地讨论 Pandas 提供的工具。 最后,我们将回顾一些在 Pandas 中处理时间序列数据的简短示例。
Python 中的日期和时间
Python 世界中有许多可用于表示日期、时间、时间差和时间跨度的表示方法。 虽然 Pandas 提供的时间序列工具对数据科学应用最有用,但了解它们与 Python 中其他工具的关系是有帮助的。
本机 Python 日期和时间:datetime 和 dateutil
Python 用于处理日期和时间的基本对象位于内置的datetime
模块中。 除了第三方的dateutil
模块之外,您还可以使用此功能快速执行许多有用的日期和时间功能。 例如,您可以使用datetime
类型手动构建日期:
In [1]: from datetime import datetime
datetime(year=2021, month=7, day=4)
Out[1]: datetime.datetime(2021, 7, 4, 0, 0)
或者,使用dateutil
模块,您可以从各种字符串格式中解析日期:
In [2]: from dateutil import parser
date = parser.parse("4th of July, 2021")
date
Out[2]: datetime.datetime(2021, 7, 4, 0, 0)
一旦您有了datetime
对象,您可以执行一些操作,比如打印星期几:
In [3]: date.strftime('%A')
Out[3]: 'Sunday'
这里我们使用了用于打印日期的标准字符串格式代码之一('%A'
),您可以在 Python 的datetime
文档的strftime
部分中了解相关信息。 有关其他有用日期工具的文档,请查看dateutil
的在线文档。 还有一个相关的软件包需要注意,那就是pytz
,它包含了处理时间序列数据中最令人头疼的元素:时区。
datetime
和dateutil
的威力在于它们的灵活性和简单的语法:你可以使用这些对象及其内置方法轻松地执行几乎任何你感兴趣的操作。它们的局限性在于当你希望处理大量的日期和时间时:正如 Python 数值变量的列表与 NumPy 风格的类型化数值数组相比是次优的一样,Python datetime
对象的列表与编码日期的类型化数组相比也是次优的。
时间数组:NumPy 的 datetime64
NumPy 的datetime64
数据类型将日期编码为 64 位整数,因此允许以紧凑的方式表示日期数组并以有效的方式对其进行操作。datetime64
需要特定的输入格式:
In [4]: import numpy as np
date = np.array('2021-07-04', dtype=np.datetime64)
date
Out[4]: array('2021-07-04', dtype='datetime64[D]')
一旦我们将日期转换为这种形式,就可以快速对其进行向量化操作:
In [5]: date + np.arange(12)
Out[5]: array(['2021-07-04', '2021-07-05', '2021-07-06', '2021-07-07',
'2021-07-08', '2021-07-09', '2021-07-10', '2021-07-11',
'2021-07-12', '2021-07-13', '2021-07-14', '2021-07-15'],
dtype='datetime64[D]')
由于 NumPy datetime64
数组中的统一类型,这种操作比直接使用 Python 的datetime
对象要快得多,特别是在数组变大时(我们在第六章中介绍了这种向量化类型)。
datetime64
和相关的timedelta64
对象的一个细节是它们是建立在基本时间单位上的。因为datetime64
对象的精度限制为 64 位,所以可编码时间的范围是基本单位的倍。换句话说,datetime64
在时间分辨率和最大时间跨度之间存在权衡。
例如,如果您想要 1 纳秒的时间分辨率,您只有足够的信息来编码纳秒范围内的时间,或者不到 600 年。NumPy 将从输入中推断出所需的单位;例如,这里是一个基于天的datetime
:
In [6]: np.datetime64('2021-07-04')
Out[6]: numpy.datetime64('2021-07-04')
这是一个基于分钟的 datetime:
In [7]: np.datetime64('2021-07-04 12:00')
Out[7]: numpy.datetime64('2021-07-04T12:00')
您可以使用许多格式代码强制使用任何所需的基本单位;例如,这里我们将强制使用基于纳秒的时间:
In [8]: np.datetime64('2021-07-04 12:59:59.50', 'ns')
Out[8]: numpy.datetime64('2021-07-04T12:59:59.500000000')
表 23-1,摘自 NumPy datetime64
文档,列出了可用的格式代码及其可以编码的相对和绝对时间跨度。
表 23-1. 日期和时间代码描述
代码 | 意义 | 时间跨度(相对) | 时间跨度(绝对) |
---|---|---|---|
Y |
年 | ± 9.2e18 年 | [9.2e18 BC, 9.2e18 AD] |
M |
月 | ± 7.6e17 年 | [7.6e17 BC, 7.6e17 AD] |
W |
周 | ± 1.7e17 年 | [1.7e17 BC, 1.7e17 AD] |
D |
日 | ± 2.5e16 年 | [2.5e16 BC, 2.5e16 AD] |
h |
小时 | ± 1.0e15 年 | [1.0e15 BC, 1.0e15 AD] |
m |
分钟 | ± 1.7e13 年 | [1.7e13 BC, 1.7e13 AD] |
s |
秒 | ± 2.9e12 年 | [2.9e9 BC, 2.9e9 AD] |
ms |
毫秒 | ± 2.9e9 年 | [2.9e6 BC, 2.9e6 AD] |
us |
微秒 | ± 2.9e6 年 | [290301 BC, 294241 AD] |
ns |
纳秒 | ± 292 年 | [1678 AD, 2262 AD] |
ps |
皮秒 | ± 106 天 | [ 1969 年, 1970 年] |
fs |
飞秒 | ± 2.6 小时 | [ 1969 年, 1970 年] |
as |
阿秒 | ± 9.2 秒 | [ 1969 年, 1970 年] |
对于我们在现实世界中看到的数据类型,一个有用的默认值是datetime64[ns]
,因为它可以用适当的精度编码一系列现代日期。
最后,请注意,虽然datetime64
数据类型解决了内置 Python datetime
类型的一些不足之处,但它缺少许多datetime
和尤其是dateutil
提供的便利方法和函数。更多信息可以在NumPy 的datetime64
文档中找到。
Pandas 中的日期和时间:两者兼得
Pandas 在刚讨论的所有工具基础上构建了一个Timestamp
对象,它结合了datetime
和dateutil
的易用性以及numpy.datetime64
的高效存储和向量化接口。从这些Timestamp
对象中,Pandas 可以构建一个DatetimeIndex
,用于索引Series
或DataFrame
中的数据。
例如,我们可以使用 Pandas 工具重复之前的演示。我们可以解析一个灵活格式的字符串日期,并使用格式代码输出星期几,如下所示:
In [9]: import pandas as pd
date = pd.to_datetime("4th of July, 2021")
date
Out[9]: Timestamp('2021-07-04 00:00:00')
In [10]: date.strftime('%A')
Out[10]: 'Sunday'
另外,我们可以直接在同一个对象上进行 NumPy 风格的向量化操作:
In [11]: date + pd.to_timedelta(np.arange(12), 'D')
Out[11]: DatetimeIndex(['2021-07-04', '2021-07-05', '2021-07-06', '2021-07-07',
'2021-07-08', '2021-07-09', '2021-07-10', '2021-07-11',
'2021-07-12', '2021-07-13', '2021-07-14', '2021-07-15'],
dtype='datetime64[ns]', freq=None)
在接下来的章节中,我们将更仔细地学习使用 Pandas 提供的工具操作时间序列数据。
Pandas 时间序列:按时间索引
当您开始按时间戳索引数据时,Pandas 的时间序列工具就会变得非常有用。例如,我们可以构建一个具有时间索引数据的Series
对象:
In [12]: index = pd.DatetimeIndex(['2020-07-04', '2020-08-04',
'2021-07-04', '2021-08-04'])
data = pd.Series([0, 1, 2, 3], index=index)
data
Out[12]: 2020-07-04 0
2020-08-04 1
2021-07-04 2
2021-08-04 3
dtype: int64
现在我们已经将这些数据放入了一个Series
中,我们可以利用我们在之前章节中讨论过的任何Series
索引模式,传递可以强制转换为日期的值:
In [13]: data['2020-07-04':'2021-07-04']
Out[13]: 2020-07-04 0
2020-08-04 1
2021-07-04 2
dtype: int64
还有其他特殊的仅限日期索引操作,比如传递一个年份以获得该年份所有数据的切片:
In [14]: data['2021']
Out[14]: 2021-07-04 2
2021-08-04 3
dtype: int64
后面,我们将看到更多关于日期作为索引的便利性的例子。但首先,让我们更仔细地看一下可用的时间序列数据结构。
Pandas 时间序列数据结构
本节将介绍处理时间序列数据的基本 Pandas 数据结构:
-
对于时间戳,Pandas 提供了
Timestamp
类型。如前所述,这实际上是 Python 原生datetime
的替代品,但它基于更高效的numpy.datetime64
数据类型。相关的索引结构是DatetimeIndex
。 -
对于时间段,Pandas 提供了
Period
类型。这个类型基于numpy.datetime64
,用于编码固定频率间隔。相关的索引结构是PeriodIndex
。 -
对于时间差或持续时间,Pandas 提供了
Timedelta
类型。Timedelta
是 Python 原生的datetime.timedelta
类型的更高效替代品,基于numpy.timedelta64
。相关的索引结构是TimedeltaIndex
。
这些日期/时间对象中最基础的是Timestamp
和DatetimeIndex
对象。虽然可以直接调用这些类对象,但更常见的是使用pd.to_datetime
函数,该函数可以解析各种格式。将单个日期传递给pd.to_datetime
将产生一个Timestamp
;默认情况下传递一系列日期将产生一个DatetimeIndex
,正如你在这里看到的:
In [15]: dates = pd.to_datetime([datetime(2021, 7, 3), '4th of July, 2021',
'2021-Jul-6', '07-07-2021', '20210708'])
dates
Out[15]: DatetimeIndex(['2021-07-03', '2021-07-04', '2021-07-06', '2021-07-07',
'2021-07-08'],
dtype='datetime64[ns]', freq=None)
任何DatetimeIndex
都可以通过to_period
函数转换为PeriodIndex
,并增加一个频率代码;这里我们将使用'D'
表示每日频率:
In [16]: dates.to_period('D')
Out[16]: PeriodIndex(['2021-07-03', '2021-07-04', '2021-07-06', '2021-07-07',
'2021-07-08'],
dtype='period[D]')
当从一个日期中减去另一个日期时,会创建一个TimedeltaIndex
:
In [17]: dates - dates[0]
Out[17]: TimedeltaIndex(['0 days', '1 days', '3 days', '4 days', '5 days'],
> dtype='timedelta64[ns]', freq=None)
常规序列:pd.date_range
为了更方便地创建常规日期序列,Pandas 提供了几个专用函数:pd.date_range
用于时间戳,pd.period_range
用于周期,pd.timedelta_range
用于时间差。我们已经看到 Python 的range
和 NumPy 的np.arange
接受起始点、结束点和可选步长,并返回一个序列。类似地,pd.date_range
接受起始日期、结束日期和可选频率代码,以创建一系列常规日期:
In [18]: pd.date_range('2015-07-03', '2015-07-10')
Out[18]: DatetimeIndex(['2015-07-03', '2015-07-04', '2015-07-05', '2015-07-06',
'2015-07-07', '2015-07-08', '2015-07-09', '2015-07-10'],
dtype='datetime64[ns]', freq='D')
或者,日期范围可以不是起点和终点,而是起点和一定数量的周期:
In [19]: pd.date_range('2015-07-03', periods=8)
Out[19]: DatetimeIndex(['2015-07-03', '2015-07-04', '2015-07-05', '2015-07-06',
'2015-07-07', '2015-07-08', '2015-07-09', '2015-07-10'],
dtype='datetime64[ns]', freq='D')
可以通过修改freq
参数来调整间隔,默认为D
。例如,这里我们构建一个小时时间戳的范围:
In [20]: pd.date_range('2015-07-03', periods=8, freq='H')
Out[20]: DatetimeIndex(['2015-07-03 00:00:00', '2015-07-03 01:00:00',
'2015-07-03 02:00:00', '2015-07-03 03:00:00',
'2015-07-03 04:00:00', '2015-07-03 05:00:00',
'2015-07-03 06:00:00', '2015-07-03 07:00:00'],
dtype='datetime64[ns]', freq='H')
要创建Period
或Timedelta
值的常规序列,可以使用类似的pd.period_range
和pd.timedelta_range
函数。这里是一些月度周期:
In [21]: pd.period_range('2015-07', periods=8, freq='M')
Out[21]: PeriodIndex(['2015-07', '2015-08', '2015-09',
'2015-10', '2015-11', '2015-12',
'2016-01', '2016-02'],
dtype='period[M]')
以及一系列按小时增加的持续时间:
In [22]: pd.timedelta_range(0, periods=6, freq='H')
Out[22]: TimedeltaIndex(['0 days 00:00:00', '0 days 01:00:00', '0 days 02:00:00',
'0 days 03:00:00', '0 days 04:00:00', '0 days 05:00:00'],
dtype='timedelta64[ns]', freq='H')
所有这些都需要理解 Pandas 的频率代码,这些在下一节中总结。
频率和偏移量
Pandas 时间序列工具的基础是频率或日期偏移的概念。下表总结了主要的可用代码;与前面章节展示的D
(天)和H
(小时)代码一样,我们可以使用这些代码来指定任何所需的频率间隔。表 23-2 总结了主要可用的代码。
表 23-2. Pandas 频率代码列表
代码 | 描述 | 代码 | 描述 |
---|---|---|---|
D |
日历日 | B |
工作日 |
W |
周 | ||
M |
月末 | BM |
工作月末 |
Q |
季度末 | BQ |
商业季度末 |
A |
年末 | BA |
商业年末 |
H |
小时 | BH |
工作小时 |
T |
分钟 | ||
S |
秒 | ||
L |
毫秒 | ||
U |
微秒 | ||
N |
纳秒 |
月度、季度和年度频率均标记在指定期间的末尾。在任何这些频率代码后面添加S
后缀会使它们标记在开始而不是末尾(参见表 23-3)。
表 23-3. 开始索引频率代码列表
代码 | 描述 | 代码 | 描述 |
---|---|---|---|
MS |
月开始 | BMS |
工作日月开始 |
QS |
季度开始 | BQS |
工作日季度开始 |
AS |
年度开始 | BAS |
工作日年度开始 |
此外,您可以通过添加三个字母的月份代码作为后缀来更改用于标记任何季度或年度代码的月份:
-
Q-JAN
,BQ-FEB
,QS-MAR
,BQS-APR
等。 -
A-JAN
,BA-FEB
,AS-MAR
,BAS-APR
等。
同样地,周频率的分割点可以通过添加三个字母的工作日代码进行修改:W-SUN
, W-MON
, W-TUE
, W-WED
等。
此外,代码可以与数字结合以指定其他频率。例如,对于 2 小时 30 分钟的频率,我们可以将小时(H
)和分钟(T
)代码组合如下:
In [23]: pd.timedelta_range(0, periods=6, freq="2H30T")
Out[23]: TimedeltaIndex(['0 days 00:00:00', '0 days 02:30:00', '0 days 05:00:00',
'0 days 07:30:00', '0 days 10:00:00', '0 days 12:30:00'],
dtype='timedelta64[ns]', freq='150T')
所有这些短代码都指向 Pandas 时间序列偏移的特定实例,这些可以在pd.tseries.offsets
模块中找到。例如,我们可以直接创建工作日偏移量如下:
In [24]: from pandas.tseries.offsets import BDay
pd.date_range('2015-07-01', periods=6, freq=BDay())
Out[24]: DatetimeIndex(['2015-07-01', '2015-07-02', '2015-07-03', '2015-07-06',
'2015-07-07', '2015-07-08'],
dtype='datetime64[ns]', freq='B')
欲了解更多频率和偏移使用的讨论,请参阅 Pandas 文档的DateOffset
部分。
重新采样、移位和窗口操作
使用日期和时间作为索引以直观地组织和访问数据的能力是 Pandas 时间序列工具的重要组成部分。总体上索引数据的好处(在操作期间自动对齐,直观的数据切片和访问等)仍然适用,并且 Pandas 提供了几个额外的时间序列特定操作。
我们将以一些股价数据为例,来看一些具体的内容。由于 Pandas 主要在金融环境中开发,因此它包含了一些非常具体的金融数据工具。例如,配套的pandas-datareader
包(可通过pip install pandas-datareader
安装)知道如何从各种在线源导入数据。这里我们将加载部分标准普尔 500 指数的价格历史:
In [25]: from pandas_datareader import data
sp500 = data.DataReader('^GSPC', start='2018', end='2022',
data_source='yahoo')
sp500.head()
Out[25]: High Low Open Close Volume \
Date
2018-01-02 2695.889893 2682.360107 2683.729980 2695.810059 3367250000
2018-01-03 2714.370117 2697.770020 2697.850098 2713.060059 3538660000
2018-01-04 2729.290039 2719.070068 2719.310059 2723.989990 3695260000
2018-01-05 2743.449951 2727.919922 2731.330078 2743.149902 3236620000
2018-01-08 2748.510010 2737.600098 2742.669922 2747.709961 3242650000
Adj Close
Date
2018-01-02 2695.810059
2018-01-03 2713.060059
2018-01-04 2723.989990
2018-01-05 2743.149902
2018-01-08 2747.709961
简单起见,我们将仅使用收盘价:
In [26]: sp500 = sp500['Close']
我们可以使用plot
方法来可视化这一点,在正常的 Matplotlib 设置样板之后(参见第四部分);结果显示在图 23-1 中。
In [27]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
sp500.plot();
图 23-1. 标准普尔 500 指数随时间变化的收盘价
重新采样和转换频率
在处理时间序列数据时,一个常见的需求是在更高或更低的频率上重新采样。这可以使用resample
方法完成,或者更简单的asfreq
方法。两者之间的主要区别在于resample
基本上是数据聚合,而asfreq
基本上是数据选择。
让我们比较当我们对标准普尔 500 指数的收盘价数据进行降采样时,这两者返回的结果。这里我们将数据重新采样到商业年度末;结果显示在图 23-2 中。
In [28]: sp500.plot(alpha=0.5, style='-')
sp500.resample('BA').mean().plot(style=':')
sp500.asfreq('BA').plot(style='--');
plt.legend(['input', 'resample', 'asfreq'],
loc='upper left');
图 23-2. 标准普尔 500 指数收盘价的重新采样
注意区别:在每个点上,resample
报告的是前一年的平均值,而asfreq
报告的是年末的值。
对于上采样,resample
和asfreq
基本上是等效的,尽管resample
提供了更多的选项。在这种情况下,这两种方法的默认行为都是保留上采样点为空;即,填充为 NA 值。就像第十六章中讨论的pd.fillna
函数一样,asfreq
接受一个method
参数来指定如何填补值。在这里,我们将业务日数据重新采样为每日频率(即包括周末);图 23-3 显示了结果。
In [29]: fig, ax = plt.subplots(2, sharex=True)
data = sp500.iloc[:20]
data.asfreq('D').plot(ax=ax[0], marker='o')
data.asfreq('D', method='bfill').plot(ax=ax[1], style='-o')
data.asfreq('D', method='ffill').plot(ax=ax[1], style='--o')
ax[1].legend(["back-fill", "forward-fill"]);
图 23-3. 前向填充和后向填充插值的比较
因为 S&P 500 数据仅存在于工作日,顶部面板中的空白表示 NA 值。底部面板显示了填补空白的两种策略之间的差异:前向填充和后向填充。
时间偏移
另一个常见的时间序列特定操作是数据的时间偏移。为此,Pandas 提供了shift
方法,可以将数据按给定的条目数进行偏移。对于以固定频率采样的时间序列数据,这可以为我们提供探索时间趋势的方法。
例如,在这里我们将数据重新采样为每日值,并将其向前偏移 364 天,以计算 S&P 500 的一年投资回报率(参见图 23-4)。
In [30]: sp500 = sp500.asfreq('D', method='pad')
ROI = 100 * (sp500.shift(-365) - sp500) / sp500
ROI.plot()
plt.ylabel('% Return on Investment after 1 year');
图 23-4. 一年后的投资回报率
最糟糕的一年回报率约为 2019 年 3 月,随后的一年发生了与冠状病毒相关的市场崩盘。正如你所预料的,最佳的一年回报率出现在 2020 年 3 月,对于那些有足够远见或运气购买低位的人来说。
滚动窗口
计算滚动统计数据是 Pandas 实现的第三种时间序列特定操作。这可以通过Series
和DataFrame
对象的rolling
属性实现,它返回一个类似于groupby
操作所见的视图(参见第二十章)。
例如,我们可以查看股票价格的一年居中滚动均值和标准差(参见图 23-5)。
In [31]: rolling = sp500.rolling(365, center=True)
data = pd.DataFrame({'input': sp500,
'one-year rolling_mean': rolling.mean(),
'one-year rolling_median': rolling.median()})
ax = data.plot(style=['-', '--', ':'])
ax.lines[0].set_alpha(0.3)
图 23-5. S&P500 指数的滚动统计数据
与groupby
操作一样,aggregate
和apply
方法可以用于自定义滚动计算。
示例:可视化西雅图自行车计数
作为处理时间序列数据的更深入的例子,让我们来看看西雅图Fremont Bridge的自行车计数。这些数据来自于 2012 年底安装的自动自行车计数器,该计数器在桥的东西侧人行道上有感应传感器。小时自行车计数可从http://data.seattle.gov下载;Fremont Bridge 自行车计数数据集在交通类别下可用。
用于本书的 CSV 可以按以下方式下载:
In [32]: # url = ('https://raw.githubusercontent.com/jakevdp/'
# 'bicycle-data/main/FremontBridge.csv')
# !curl -O {url}
下载了这个数据集之后,我们可以使用 Pandas 将 CSV 输出读入DataFrame
。我们将指定Date
列作为索引,并希望这些日期能够自动解析:
In [33]: data = pd.read_csv('FremontBridge.csv', index_col='Date', parse_dates=True)
data.head()
Out[33]: Fremont Bridge Total Fremont Bridge East Sidewalk \
Date
2019-11-01 00:00:00 12.0 7.0
2019-11-01 01:00:00 7.0 0.0
2019-11-01 02:00:00 1.0 0.0
2019-11-01 03:00:00 6.0 6.0
2019-11-01 04:00:00 6.0 5.0
Fremont Bridge West Sidewalk
Date
2019-11-01 00:00:00 5.0
2019-11-01 01:00:00 7.0
2019-11-01 02:00:00 1.0
2019-11-01 03:00:00 0.0
2019-11-01 04:00:00 1.0
为了方便起见,我们将缩短列名:
In [34]: data.columns = ['Total', 'East', 'West']
现在让我们来看看这些数据的摘要统计信息:
In [35]: data.dropna().describe()
Out[35]: Total East West
count 147255.000000 147255.000000 147255.000000
mean 110.341462 50.077763 60.263699
std 140.422051 64.634038 87.252147
min 0.000000 0.000000 0.000000
25% 14.000000 6.000000 7.000000
50% 60.000000 28.000000 30.000000
75% 145.000000 68.000000 74.000000
max 1097.000000 698.000000 850.000000
数据可视化
我们可以通过可视化数据集来获得一些见解。让我们首先绘制原始数据(见图 23-6)。
In [36]: data.plot()
plt.ylabel('Hourly Bicycle Count');
图 23-6. 西雅图 Fremont Bridge 的小时自行车计数
~150,000 小时样本过于密集,我们无法理解太多内容。我们可以通过将数据重新采样到更粗的网格来获得更多见解。让我们按周重新采样(见图 23-7)。
In [37]: weekly = data.resample('W').sum()
weekly.plot(style=['-', ':', '--'])
plt.ylabel('Weekly bicycle count');
这揭示了一些趋势:正如你所预料的,夏季人们骑自行车的次数比冬季多,即使在特定季节内,自行车使用量也会随着周而变化(可能取决于天气;参见第四十二章,我们将进一步探讨这一点)。此外,COVID-19 大流行对通勤模式的影响非常明显,始于 2020 年初。
图 23-7. 西雅图 Fremont Bridge 每周自行车过桥次数
另一个处理数据聚合的便捷选项是使用滚动均值,利用pd.rolling_mean
函数。在这里,我们将研究数据的 30 天滚动均值,确保窗口居中(见图 23-8)。
In [38]: daily = data.resample('D').sum()
daily.rolling(30, center=True).sum().plot(style=['-', ':', '--'])
plt.ylabel('mean hourly count');
图 23-8. 每周自行车计数的滚动均值
结果的不平滑是由于窗口的硬截断造成的。我们可以使用窗口函数来获得更平滑的滚动均值,例如,使用高斯窗口,如图 23-9 所示。以下代码指定了窗口的宽度(这里是 50 天)和高斯窗口的宽度(这里是 10 天):
In [39]: daily.rolling(50, center=True,
win_type='gaussian').sum(std=10).plot(style=['-', ':', '--']);
图 23-9. 高斯平滑后的每周自行车计数
深入数据
尽管这些平滑的数据视图有助于了解数据的一般趋势,但它们隐藏了很多结构。例如,我们可能想要查看平均交通量随时间变化的情况。我们可以使用第 20 章中讨论的groupby
功能来实现这一点(见图 23-10)。
In [40]: by_time = data.groupby(data.index.time).mean()
hourly_ticks = 4 * 60 * 60 * np.arange(6)
by_time.plot(xticks=hourly_ticks, style=['-', ':', '--']);
图 23-10. 平均每小时自行车计数
每小时的交通量呈现出明显的双峰分布,大约在上午 8 点和下午 5 点左右。这很可能是通勤交通的强烈组成部分的证据。还有一个方向性的组成部分:根据数据显示,东侧人行道在上午通勤时段更多被使用,而西侧人行道在下午通勤时段更多被使用。
我们也许会对一周中不同日期的情况有所好奇。同样,我们可以通过简单的groupby
(见图 23-11)来做到这一点。
In [41]: by_weekday = data.groupby(data.index.dayofweek).mean()
by_weekday.index = ['Mon', 'Tues', 'Wed', 'Thurs', 'Fri', 'Sat', 'Sun']
by_weekday.plot(style=['-', ':', '--']);
图 23-11. 平均每日自行车计数
这显示了工作日和周末之间的明显区别,工作日每天穿过桥的平均骑行者大约是周六和周日的两倍。
鉴此,让我们进行复合groupby
,并查看工作日与周末的小时趋势。我们将首先按周末标志和时间段进行分组:
In [42]: weekend = np.where(data.index.weekday < 5, 'Weekday', 'Weekend')
by_time = data.groupby([weekend, data.index.time]).mean()
现在我们将使用一些 Matplotlib 工具,这些工具将在第 31 章中进行描述,以便将两个面板并排绘制,如图 23-12 所示。
In [43]: import matplotlib.pyplot as plt
fig, ax = plt.subplots(1, 2, figsize=(14, 5))
by_time.loc['Weekday'].plot(ax=ax[0], title='Weekdays',
xticks=hourly_ticks, style=['-', ':', '--'])
by_time.loc['Weekend'].plot(ax=ax[1], title='Weekends',
xticks=hourly_ticks, style=['-', ':', '--']);
图 23-12. 每小时平均自行车计数(按工作日和周末划分)
结果显示了工作日的双峰通勤模式和周末的单峰休闲模式。深入挖掘这些数据并详细分析天气、温度、年份和其他因素对人们通勤模式的影响可能会很有趣;有关详细讨论,请参阅我的博客文章《西雅图真的看到了自行车使用率的上升吗?》,该文章使用了这些数据的子集。我们还将在第 42 章中探讨这些数据集的建模背景。
第二十四章:高性能 Pandas:eval 和 query
正如我们在之前的章节中已经看到的,PyData 栈的强大建立在 NumPy 和 Pandas 将基本操作推送到低级编译代码中的能力上,通过直观的高级语法:例如 NumPy 中的向量化/广播操作,以及 Pandas 中的分组类型操作。虽然这些抽象对许多常见用例是高效和有效的,但它们经常依赖于临时中间对象的创建,这可能会导致计算时间和内存使用的不必要开销。
为了解决这个问题,Pandas 包括一些方法,允许您直接访问 C 速度操作,而无需昂贵地分配中间数组:eval
和 query
,这些方法依赖于 NumExpr 包。
激励查询和 eval:复合表达式
我们之前已经看到,NumPy 和 Pandas 支持快速的向量化操作;例如,当添加两个数组的元素时:
In [1]: import numpy as np
rng = np.random.default_rng(42)
x = rng.random(1000000)
y = rng.random(1000000)
%timeit x + y
Out[1]: 2.21 ms ± 142 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
如在 第六章 中讨论的,这比通过 Python 循环或理解式添加要快得多:
In [2]: %timeit np.fromiter((xi + yi for xi, yi in zip(x, y)),
dtype=x.dtype, count=len(x))
Out[2]: 263 ms ± 43.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
但是当计算复合表达式时,这种抽象可能变得不那么高效。例如,考虑以下表达式:
In [3]: mask = (x > 0.5) & (y < 0.5)
因为 NumPy 评估每个子表达式,这大致等同于以下内容:
In [4]: tmp1 = (x > 0.5)
tmp2 = (y < 0.5)
mask = tmp1 & tmp2
换句话说,每个中间步骤都显式地分配在内存中。如果 x
和 y
数组非常大,这可能导致显著的内存和计算开销。NumExpr 库使您能够逐个元素地计算这种复合表达式,而无需分配完整的中间数组。有关更多详细信息,请参阅 NumExpr 文档,但目前足以说,该库接受一个 字符串,该字符串给出您想计算的 NumPy 风格表达式:
In [5]: import numexpr
mask_numexpr = numexpr.evaluate('(x > 0.5) & (y < 0.5)')
np.all(mask == mask_numexpr)
Out[5]: True
这里的好处在于,NumExpr 以避免可能的临时数组方式评估表达式,因此对于长数组上的长序列计算比 NumPy 要高效得多。我们将在这里讨论的 Pandas eval
和 query
工具在概念上类似,并且本质上是 NumExpr 功能的 Pandas 特定包装。
pandas.eval 用于高效操作
Pandas 中的 eval
函数使用字符串表达式来高效地计算 DataFrame
对象上的操作。例如,考虑以下数据:
In [6]: import pandas as pd
nrows, ncols = 100000, 100
df1, df2, df3, df4 = (pd.DataFrame(rng.random((nrows, ncols)))
for i in range(4))
要使用典型的 Pandas 方法计算所有四个 DataFrame
的总和,我们只需写出总和:
In [7]: %timeit df1 + df2 + df3 + df4
Out[7]: 73.2 ms ± 6.72 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
可以通过构造字符串表达式来使用 pd.eval
计算相同的结果:
In [8]: %timeit pd.eval('df1 + df2 + df3 + df4')
Out[8]: 34 ms ± 4.2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
eval
版本的这个表达式大约快 50%(并且使用的内存要少得多),同时给出相同的结果:
In [9]: np.allclose(df1 + df2 + df3 + df4,
pd.eval('df1 + df2 + df3 + df4'))
Out[9]: True
pd.eval
支持广泛的操作。为了展示这些操作,我们将使用以下整数数据:
In [10]: df1, df2, df3, df4, df5 = (pd.DataFrame(rng.integers(0, 1000, (100, 3)))
for i in range(5))
下面是pd.eval
支持的操作的总结:
算术运算符
pd.eval
支持所有算术运算符。例如:
In [11]: result1 = -df1 * df2 / (df3 + df4) - df5
result2 = pd.eval('-df1 * df2 / (df3 + df4) - df5')
np.allclose(result1, result2)
Out[11]: True
比较运算符
pd.eval
支持所有比较运算符,包括链式表达式:
In [12]: result1 = (df1 < df2) & (df2 <= df3) & (df3 != df4)
result2 = pd.eval('df1 < df2 <= df3 != df4')
np.allclose(result1, result2)
Out[12]: True
位运算符
pd.eval
支持&
和|
位运算符:
In [13]: result1 = (df1 < 0.5) & (df2 < 0.5) | (df3 < df4)
result2 = pd.eval('(df1 < 0.5) & (df2 < 0.5) | (df3 < df4)')
np.allclose(result1, result2)
Out[13]: True
此外,它还支持在布尔表达式中使用字面量and
和or
:
In [14]: result3 = pd.eval('(df1 < 0.5) and (df2 < 0.5) or (df3 < df4)')
np.allclose(result1, result3)
Out[14]: True
对象属性和索引
pd.eval
支持通过obj.attr
语法和obj[index]
语法访问对象属性:
In [15]: result1 = df2.T[0] + df3.iloc[1]
result2 = pd.eval('df2.T[0] + df3.iloc[1]')
np.allclose(result1, result2)
Out[15]: True
其他操作
其他操作,例如函数调用、条件语句、循环和其他更复杂的构造,目前不在pd.eval
中实现。如果你想执行这些更复杂的表达式类型,可以使用 NumExpr 库本身。
DataFrame.eval 进行按列操作
就像 Pandas 有一个顶级的pd.eval
函数一样,DataFrame
对象也有一个eval
方法,功能类似。eval
方法的好处是可以按名称引用列。我们将用这个带标签的数组作为示例:
In [16]: df = pd.DataFrame(rng.random((1000, 3)), columns=['A', 'B', 'C'])
df.head()
Out[16]: A B C
0 0.850888 0.966709 0.958690
1 0.820126 0.385686 0.061402
2 0.059729 0.831768 0.652259
3 0.244774 0.140322 0.041711
4 0.818205 0.753384 0.578851
使用前面一节中的pd.eval
,我们可以像这样计算三个列的表达式:
In [17]: result1 = (df['A'] + df['B']) / (df['C'] - 1)
result2 = pd.eval("(df.A + df.B) / (df.C - 1)")
np.allclose(result1, result2)
Out[17]: True
DataFrame.eval
方法允许更简洁地评估列的表达式:
In [18]: result3 = df.eval('(A + B) / (C - 1)')
np.allclose(result1, result3)
Out[18]: True
请注意,在这里我们将列名视为评估表达式中的变量,结果正是我们希望的。
在 DataFrame.eval 中的赋值
除了刚才讨论的选项之外,DataFrame.eval
还允许对任何列进行赋值。让我们使用之前的DataFrame
,它有列'A'
,'B'
和'C'
:
In [19]: df.head()
Out[19]: A B C
0 0.850888 0.966709 0.958690
1 0.820126 0.385686 0.061402
2 0.059729 0.831768 0.652259
3 0.244774 0.140322 0.041711
4 0.818205 0.753384 0.578851
我们可以使用df.eval
创建一个新的列'D'
,并将其赋值为从其他列计算得到的值:
In [20]: df.eval('D = (A + B) / C', inplace=True)
df.head()
Out[20]: A B C D
0 0.850888 0.966709 0.958690 1.895916
1 0.820126 0.385686 0.061402 19.638139
2 0.059729 0.831768 0.652259 1.366782
3 0.244774 0.140322 0.041711 9.232370
4 0.818205 0.753384 0.578851 2.715013
以同样的方式,任何现有的列都可以被修改:
In [21]: df.eval('D = (A - B) / C', inplace=True)
df.head()
Out[21]: A B C D
0 0.850888 0.966709 0.958690 -0.120812
1 0.820126 0.385686 0.061402 7.075399
2 0.059729 0.831768 0.652259 -1.183638
3 0.244774 0.140322 0.041711 2.504142
4 0.818205 0.753384 0.578851 0.111982
DataFrame.eval 中的本地变量
DataFrame.eval
方法支持一种额外的语法,使其能够与本地 Python 变量一起使用。考虑以下内容:
In [22]: column_mean = df.mean(1)
result1 = df['A'] + column_mean
result2 = df.eval('A + @column_mean')
np.allclose(result1, result2)
Out[22]: True
这里的@
字符标记的是变量名而不是列名,并且让你能够高效地评估涉及两个“命名空间”的表达式:列的命名空间和 Python 对象的命名空间。请注意,这个@
字符只支持DataFrame.eval
方法,而不支持pandas.eval
函数,因为pandas.eval
函数只能访问一个(Python)命名空间。
DataFrame.query
方法
DataFrame
还有一个基于评估字符串的方法,叫做query
。考虑以下内容:
In [23]: result1 = df[(df.A < 0.5) & (df.B < 0.5)]
result2 = pd.eval('df[(df.A < 0.5) & (df.B < 0.5)]')
np.allclose(result1, result2)
Out[23]: True
正如我们在讨论DataFrame.eval
时使用的示例一样,这是一个涉及DataFrame
列的表达式。然而,它不能使用DataFrame.eval
语法表示!相反,对于这种类型的筛选操作,你可以使用query
方法:
In [24]: result2 = df.query('A < 0.5 and B < 0.5')
np.allclose(result1, result2)
Out[24]: True
与掩码表达式相比,这不仅是更有效的计算,而且更易于阅读和理解。请注意,query
方法还接受@
标志来标记本地变量:
In [25]: Cmean = df['C'].mean()
result1 = df[(df.A < Cmean) & (df.B < Cmean)]
result2 = df.query('A < @Cmean and B < @Cmean')
np.allclose(result1, result2)
Out[25]: True
性能:何时使用这些函数
在考虑是否使用eval
和query
时,有两个考虑因素:计算时间和内存使用。内存使用是最可预测的方面。正如前面提到的,涉及 NumPy 数组或 Pandas DataFrame
的每个复合表达式都会导致临时数组的隐式创建。例如,这个:
In [26]: x = df[(df.A < 0.5) & (df.B < 0.5)]
大致相当于这个:
In [27]: tmp1 = df.A < 0.5
tmp2 = df.B < 0.5
tmp3 = tmp1 & tmp2
x = df[tmp3]
如果临时DataFrame
的大小与您可用的系统内存(通常为几个千兆字节)相比显著,则使用eval
或query
表达式是个好主意。您可以使用以下命令检查数组的大约大小(以字节为单位):
In [28]: df.values.nbytes
Out[28]: 32000
就性能而言,即使您没有使用完系统内存,eval
可能会更快。问题在于您的临时对象与系统的 L1 或 L2 CPU 缓存大小(通常为几兆字节)相比如何;如果它们要大得多,那么eval
可以避免在不同内存缓存之间移动值时可能出现的某些潜在缓慢。实际上,我发现传统方法与eval
/query
方法之间的计算时间差异通常不显著——如果有什么的话,对于较小的数组来说,传统方法更快!eval
/query
的好处主要在于节省内存,以及它们有时提供的更清晰的语法。
我们在这里已经涵盖了关于eval
和query
的大部分细节;有关更多信息,请参阅 Pandas 文档。特别是,可以为运行这些查询指定不同的解析器和引擎;有关详细信息,请参阅文档中的“提升性能”部分。
更多资源
在本书的这一部分中,我们已经涵盖了有效使用 Pandas 进行数据分析的许多基础知识。但我们的讨论还有很多内容未涉及。要了解更多关于 Pandas 的信息,我推荐以下资源:
这是完整文档的首选来源。虽然文档中的示例通常基于小型生成的数据集,但选项的描述是全面的,并且通常非常有助于理解各种函数的使用。
由 Pandas 的原始创建者 Wes McKinney 撰写,这本书包含了比我们在本章中有空间讨论的 Pandas 包更多的细节。特别是,McKinney 深入探讨了用于时间序列的工具,这些工具是他作为金融顾问的核心内容。这本书还包含许多将 Pandas 应用于从实际数据集中获得洞察的有趣例子。
Pandas 开发者 Tom Augspurger 的这本简短电子书,简洁地概述了如何有效和惯用地使用 Pandas 库的全部功能。
从 PyCon 到 SciPy 再到 PyData,许多会议都有 Pandas 开发者和高级用户提供的教程。特别是 PyCon 的教程通常由经过严格筛选的优秀演讲者提供。
结合这些资源,再加上这些章节中的详细介绍,我希望你能够准备好使用 Pandas 解决任何遇到的数据分析问题!
第四部分:Matplotlib 可视化
现在我们将深入研究 Python 中用于可视化的 Matplotlib 包。Matplotlib 是一个建立在 NumPy 数组上的跨平台数据可视化库,旨在与更广泛的 SciPy 栈配合使用。它由 John Hunter 在 2002 年构思,最初作为 IPython 的补丁,用于通过 IPython 命令行从gnuplot
实现交互式 MATLAB 风格的绘图。当时,IPython 的创始人 Fernando Perez 正在忙于完成他的博士论文,没有时间几个月内审查该补丁。John 将此视为自己行动的信号,于是 Matplotlib 包诞生了,版本 0.1 于 2003 年发布。当它被采纳为太空望远镜科学研究所(背后是哈勃望远镜的人们)首选的绘图包,并得到财政支持以及大幅扩展其功能时,Matplotlib 得到了早期的推广。
Matplotlib 最重要的特点之一是其与多种操作系统和图形后端的良好兼容性。Matplotlib 支持数十种后端和输出类型,这意味着无论您使用哪种操作系统或希望使用哪种输出格式,它都能正常工作。这种跨平台、面面俱到的方法一直是 Matplotlib 的一大优势。它导致了大量用户的使用,进而促使了活跃的开发者基础以及 Matplotlib 在科学 Python 社区中强大的工具和普及率。
近年来,然而,Matplotlib 的界面和风格开始显得有些过时。像 R 语言中的ggplot
和ggvis
以及基于 D3js 和 HTML5 canvas 的 Web 可视化工具包,常使 Matplotlib 感觉笨重和老旧。尽管如此,我认为我们不能忽视 Matplotlib 作为一个经过良好测试的跨平台图形引擎的优势。最近的 Matplotlib 版本使得设置新的全局绘图样式相对容易(参见 第三十四章),人们一直在开发新的包,利用其强大的内部机制通过更清晰、更现代的 API 驱动 Matplotlib,例如 Seaborn(在 第三十六章 讨论),ggpy
,HoloViews,甚至 Pandas 本身可以作为 Matplotlib API 的封装器使用。即使有了这些封装器,深入了解 Matplotlib 的语法来调整最终的绘图输出仍然经常很有用。因此,我认为即使新工具意味着社区逐渐不再直接使用 Matplotlib API,Matplotlib 本身仍将保持数据可视化堆栈中不可或缺的一部分。
第二十五章:Matplotlib 一般提示
在我们深入研究使用 Matplotlib 创建可视化的详细信息之前,有几个有用的事情您应该了解如何使用这个包。
导入 Matplotlib
正如我们使用np
简写代表 NumPy 和pd
简写代表 Pandas 一样,我们将使用一些标准缩写来导入 Matplotlib:
In [1]: import matplotlib as mpl
import matplotlib.pyplot as plt
我们将最常用的是plt
接口,您将在本书的这一部分中看到。
设置样式
我们将使用plt.style
指令为我们的图形选择合适的美学样式。在这里,我们将设置classic
样式,确保我们创建的图使用经典的 Matplotlib 样式:
In [2]: plt.style.use('classic')
在本章中,我们将根据需要调整这种样式。有关样式表的更多信息,请参阅第三十四章。
显示还是不显示?如何显示您的图形
您看不到的可视化对您没有多大用处,但是您查看 Matplotlib 图形的方式取决于上下文。Matplotlib 的最佳用法因您如何使用它而异;大致上,适用的三种上下文是在脚本中使用 Matplotlib,在 IPython 终端中使用 Matplotlib 或在 Jupyter 笔记本中使用 Matplotlib。
从脚本绘图
如果您正在脚本中使用 Matplotlib,则函数plt.show
是您的好帮手。plt.show
启动一个事件循环,查找所有当前活动的Figure
对象,并打开一个或多个交互窗口来显示您的图形或图形。
因此,例如,您可能有一个名为myplot.py的文件,其中包含以下内容:
# file: myplot.py
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
plt.plot(x, np.sin(x))
plt.plot(x, np.cos(x))
plt.show()
然后,您可以从命令行提示符运行此脚本,将导致一个窗口打开,并显示您的图形:
$ python myplot.py
plt.show
命令在幕后做了很多工作,因为它必须与系统的交互式图形后端进行交互。此操作的详细信息在不同的系统甚至不同的安装中可能会有很大的差异,但是 Matplotlib 会尽力隐藏所有这些细节。
有一件事需要注意:plt.show
命令应该在每个 Python 会话中仅使用一次,并且最常见的情况是在脚本的最后。多个show
命令可能会导致不可预测的基于后端的行为,并且应该尽量避免。
从 IPython Shell 绘图
Matplotlib 在 IPython shell 中也可以无缝运行(请参阅第 I 部分)。IPython 是构建用于与 Matplotlib 很好配合的,如果您指定 Matplotlib 模式。要启用此模式,可以在启动ipython
后使用%matplotlib
魔术命令:
In [1]: %matplotlib
Using matplotlib backend: TkAgg
In [2]: import matplotlib.pyplot as plt
此时,任何plt
绘图命令都将导致一个图形窗口打开,并且可以运行进一步的命令来更新绘图。某些更改(例如修改已经绘制的线的属性)不会自动绘制:要强制更新,请使用plt.draw
。在 IPython 的 Matplotlib 模式中不需要使用plt.show
。
从 Jupyter 笔记本绘图
Jupyter Notebook 是一个基于浏览器的交互式数据分析工具,可以将叙述、代码、图形、HTML 元素等多种内容组合到一个可执行文档中(参见第 I 部分)。
在 Jupyter Notebook 中进行交互式绘图可以通过 %matplotlib
命令完成,其工作方式类似于 IPython Shell。您还可以选择直接在笔记本中嵌入图形,有两种可能的选项:
-
%matplotlib inline
将导致您的图形以静态图像嵌入到笔记本中。 -
%matplotlib notebook
将导致交互式图形嵌入到笔记本中。
对于本书,通常会使用默认设置,图形渲染为静态图像(见图 25-1 以查看此基本绘图示例的结果):
In [3]: %matplotlib inline
In [4]: import numpy as np
x = np.linspace(0, 10, 100)
fig = plt.figure()
plt.plot(x, np.sin(x), '-')
plt.plot(x, np.cos(x), '--');
图 25-1. 基本绘图示例
将图形保存到文件中
Matplotlib 的一个很好的特性是能够以多种格式保存图形。使用 savefig
命令可以保存图形。例如,要将前面的图形保存为 PNG 文件,可以运行以下命令:
In [5]: fig.savefig('my_figure.png')
现在我们在当前工作目录中有一个名为my_figure.png的文件:
In [6]: !ls -lh my_figure.png
Out[6]: -rw-r--r-- 1 jakevdp staff 26K Feb 1 06:15 my_figure.png
为了确认它包含我们认为包含的内容,让我们使用 IPython 的 Image
对象来显示此文件的内容(见图 25-2)。
In [7]: from IPython.display import Image
Image('my_figure.png')
图 25-2. 基本绘图的 PNG 渲染
在 savefig
中,文件格式根据给定文件名的扩展名推断。根据安装的后端程序,可以使用多种不同的文件格式。可以通过图形画布对象的以下方法找到系统支持的文件类型列表:
In [8]: fig.canvas.get_supported_filetypes()
Out[8]: {'eps': 'Encapsulated Postscript',
'jpg': 'Joint Photographic Experts Group',
'jpeg': 'Joint Photographic Experts Group',
'pdf': 'Portable Document Format',
'pgf': 'PGF code for LaTeX',
'png': 'Portable Network Graphics',
'ps': 'Postscript',
'raw': 'Raw RGBA bitmap',
'rgba': 'Raw RGBA bitmap',
'svg': 'Scalable Vector Graphics',
'svgz': 'Scalable Vector Graphics',
'tif': 'Tagged Image File Format',
'tiff': 'Tagged Image File Format'}
请注意,在保存图形时,不需要使用 plt.show
或前面讨论过的相关命令。
两个界面的价格
Matplotlib 的一个可能令人困惑的特性是其双界面:一个方便的基于状态的 MATLAB 风格界面和一个更强大的面向对象界面。在这里,我将快速介绍这两者之间的区别。
MATLAB 风格界面
Matplotlib 最初被构想为 MATLAB 用户的 Python 替代方案,其语法大部分反映了这一事实。MATLAB 风格的工具包含在 pyplot
(plt
) 接口中。例如,以下代码对 MATLAB 用户可能看起来非常熟悉(见图 25-3 显示的结果)。
In [9]: plt.figure() # create a plot figure
# create the first of two panels and set current axis
plt.subplot(2, 1, 1) # (rows, columns, panel number)
plt.plot(x, np.sin(x))
# create the second panel and set current axis
plt.subplot(2, 1, 2)
plt.plot(x, np.cos(x));
图 25-3. 使用 MATLAB 风格界面的子图
重要的是要认识到这种接口是有状态的:它跟踪“当前”图形和坐标轴,所有plt
命令都应用于这些对象。您可以使用plt.gcf
(获取当前图形)和plt.gca
(获取当前坐标轴)来获取对这些对象的引用。
虽然这种状态接口在简单绘图时快捷方便,但也容易遇到问题。例如,一旦创建了第二个面板,如何返回并向第一个面板添加内容?这在 MATLAB 风格接口中是可能的,但有点笨拙。幸运的是,有更好的方法。
面向对象接口
对于这些更复杂的情况以及当您希望对图形有更多控制时,可以使用面向对象的接口。与依赖“活跃”图形或坐标轴的概念不同,在面向对象的接口中,绘图函数是显式Figure
和Axes
对象的方法。要使用这种绘图风格重新创建之前的图形,如在图 25-4 中所示,可以执行以下操作:
In [10]: # First create a grid of plots
# ax will be an array of two Axes objects
fig, ax = plt.subplots(2)
# Call plot() method on the appropriate object
ax[0].plot(x, np.sin(x))
ax[1].plot(x, np.cos(x));
图 25-4. 使用面向对象接口的子图
对于更简单的绘图,使用哪种风格主要是偏好问题,但随着绘图变得更加复杂,面向对象的方法可能变得必不可少。在接下来的章节中,我们将根据方便性在 MATLAB 风格和面向对象接口之间切换。在大多数情况下,区别仅在于将plt.plot
切换为ax.plot
,但在接下来的章节中遇到的一些陷阱我将会特别提出。
第二十六章:简单线图
可能所有绘图中最简单的是单个函数 的可视化。在这里,我们将首次创建这种类型的简单绘图。如同接下来的所有章节一样,我们将从设置用于绘图的笔记本开始,并导入我们将使用的包:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
对于所有的 Matplotlib 图,我们首先创建一个图形和坐标轴。在它们最简单的形式下,可以像下面这样做(见图 26-1)。
In [2]: fig = plt.figure()
ax = plt.axes()
在 Matplotlib 中,figure(一个 plt.Figure
类的实例)可以被视为一个包含所有代表坐标轴、图形、文本和标签的对象的单个容器。axes(一个 plt.Axes
类的实例)就是我们看到的上述内容:一个带有刻度、网格和标签的边界框,最终将包含构成我们可视化的绘图元素。在本书的这一部分,我通常使用变量名 fig
表示一个图形实例,使用 ax
表示一个坐标轴实例或一组坐标轴实例。
图 26-1. 一个空的网格坐标轴
一旦我们创建了一个坐标轴,就可以使用 ax.plot
方法绘制一些数据。让我们从一个简单的正弦波开始,如 图 26-2 所示。
In [3]: fig = plt.figure()
ax = plt.axes()
x = np.linspace(0, 10, 1000)
ax.plot(x, np.sin(x));
图 26-2. 一个简单的正弦波
注意最后一行末尾的分号是有意为之:它抑制了从输出中显示绘图的文本表示。
或者,我们可以使用 PyLab 接口,让图形和坐标轴在后台自动创建(参见 第 IV 部分 讨论这两种接口);如 图 26-3 所示,结果是相同的。
In [4]: plt.plot(x, np.sin(x));
图 26-3. 通过面向对象接口的简单正弦波
如果我们想要创建一个包含多条线的单个图形(参见 图 26-4),我们可以简单地多次调用 plot
函数:
In [5]: plt.plot(x, np.sin(x))
plt.plot(x, np.cos(x));
这就是在 Matplotlib 中绘制简单函数的全部内容!现在我们将深入了解如何控制坐标轴和线条的外观的更多细节。
图 26-4. 多条线的重叠绘图
调整绘图:线条颜色和样式
你可能希望对图表进行的第一个调整是控制线条的颜色和样式。plt.plot
函数接受额外的参数来指定这些内容。要调整颜色,可以使用 color
关键字,接受一个表示几乎任何想象的颜色的字符串参数。颜色可以以多种方式指定;参见 图 26-5 来查看以下示例的输出:
In [6]: plt.plot(x, np.sin(x - 0), color='blue') # specify color by name
plt.plot(x, np.sin(x - 1), color='g') # short color code (rgbcmyk)
plt.plot(x, np.sin(x - 2), color='0.75') # grayscale between 0 and 1
plt.plot(x, np.sin(x - 3), color='#FFDD44') # hex code (RRGGBB, 00 to FF)
plt.plot(x, np.sin(x - 4), color=(1.0,0.2,0.3)) # RGB tuple, values 0 to 1
plt.plot(x, np.sin(x - 5), color='chartreuse'); # HTML color names supported
图 26-5. 控制绘图元素的颜色
如果未指定颜色,则 Matplotlib 将自动循环使用一组默认颜色来绘制多条线。
同样地,可以使用 linestyle
关键字来调整线条样式(参见 图 26-6)。
In [7]: plt.plot(x, x + 0, linestyle='solid')
plt.plot(x, x + 1, linestyle='dashed')
plt.plot(x, x + 2, linestyle='dashdot')
plt.plot(x, x + 3, linestyle='dotted');
# For short, you can use the following codes:
plt.plot(x, x + 4, linestyle='-') # solid
plt.plot(x, x + 5, linestyle='--') # dashed
plt.plot(x, x + 6, linestyle='-.') # dashdot
plt.plot(x, x + 7, linestyle=':'); # dotted
图 26-6. 各种线条样式的示例
虽然对于阅读你的代码的人来说可能不太清晰,但你可以通过将 linestyle
和 color
代码合并为单个非关键字参数传递给 plt.plot
函数来节省一些按键。 图 26-7 显示了结果。
In [8]: plt.plot(x, x + 0, '-g') # solid green
plt.plot(x, x + 1, '--c') # dashed cyan
plt.plot(x, x + 2, '-.k') # dashdot black
plt.plot(x, x + 3, ':r'); # dotted red
图 26-7. 使用简写语法控制颜色和样式
这些单字符颜色代码反映了 RGB(红/绿/蓝)和 CMYK(青/洋红/黄/黑)颜色系统中的标准缩写,通常用于数字彩色图形。
还有许多其他关键字参数可用于微调图表的外观;有关详细信息,请通过 IPython 的帮助工具阅读 plt.plot
函数的文档字符串(参见 第 1 章)。
调整图表:坐标轴限制
Matplotlib 在为你的图表选择默认的轴限制方面做得相当不错,但有时更精细的控制会更好。调整限制的最基本方法是使用 plt.xlim
和 plt.ylim
函数(参见 图 26-8)。
In [9]: plt.plot(x, np.sin(x))
plt.xlim(-1, 11)
plt.ylim(-1.5, 1.5);
图 26-8. 设置坐标轴限制的示例
如果因某种原因你希望任一轴显示反向,只需反转参数的顺序(参见 图 26-9)。
In [10]: plt.plot(x, np.sin(x))
plt.xlim(10, 0)
plt.ylim(1.2, -1.2);
图 26-9. 反转 y 轴的示例
一个有用的相关方法是 plt.axis
(请注意这里可能会导致 axes(带有 e)和 axis(带有 i)之间的潜在混淆),它允许更质量化地指定轴限制。例如,你可以自动收紧当前内容周围的边界,如 图 26-10 所示。
In [11]: plt.plot(x, np.sin(x))
plt.axis('tight');
图 26-10. “紧凑”布局的示例
或者,您可以指定希望有一个相等的轴比率,这样 x
中的一个单位在视觉上等同于 y
中的一个单位,如 Figure 26-11 所示。
In [12]: plt.plot(x, np.sin(x))
plt.axis('equal');
Figure 26-11. “equal” 布局示例,单位与输出分辨率匹配
其他轴选项包括 'on'
、'off'
、'square'
、'image'
等。有关更多信息,请参阅 plt.axis
文档字符串。
绘图标签
作为本章的最后一部分,我们将简要讨论绘图的标签:标题、坐标轴标签和简单图例。标题和坐标轴标签是最简单的标签——有方法可以快速设置它们(见 Figure 26-12)。
In [13]: plt.plot(x, np.sin(x))
plt.title("A Sine Curve")
plt.xlabel("x")
plt.ylabel("sin(x)");
Figure 26-12. 坐标轴标签和标题示例
可以使用函数的可选参数调整这些标签的位置、大小和样式,这些参数在文档字符串中有描述。
当在单个坐标轴中显示多行时,创建一个标签每种线型的图例是非常有用的。再次强调,Matplotlib 提供了一种内置的快速创建这种图例的方法;通过(你猜对了)plt.legend
方法来实现。虽然有几种有效的使用方法,但我发现最简单的方法是使用 plot
函数的 label
关键字来指定每条线的标签(见 Figure 26-13)。
In [14]: plt.plot(x, np.sin(x), '-g', label='sin(x)')
plt.plot(x, np.cos(x), ':b', label='cos(x)')
plt.axis('equal')
plt.legend();
Figure 26-13. 绘图图例示例
如您所见,plt.legend
函数跟踪线型和颜色,并将其与正确的标签匹配。有关指定和格式化绘图图例的更多信息,请参阅 plt.legend
文档字符串;此外,我们将在 第二十九章 中涵盖一些更高级的图例选项。
Matplotlib 的一些注意事项
虽然大多数 plt
函数可以直接转换为 ax
方法(plt.plot
→ ax.plot
,plt.legend
→ ax.legend
等),但并非所有命令都是如此。特别是用于设置限制、标签和标题的功能略有修改。为了在 MATLAB 风格函数和面向对象方法之间进行过渡,请进行以下更改:
-
plt.xlabel
→ax.set_xlabel
-
plt.ylabel
→ax.set_ylabel
-
plt.xlim
→ax.set_xlim
-
plt.ylim
→ax.set_ylim
-
plt.title
→ax.set_title
在面向对象的绘图接口中,与单独调用这些函数不同,通常更方便使用 ax.set
方法一次性设置所有这些属性(见 Figure 26-14)。
In [15]: ax = plt.axes()
ax.plot(x, np.sin(x))
ax.set(xlim=(0, 10), ylim=(-2, 2),
xlabel='x', ylabel='sin(x)',
title='A Simple Plot');
Figure 26-14. 使用 ax.set
一次性设置多个属性的示例
第二十七章:简单散点图
另一种常用的图表类型是简单的散点图,它与线图非常相似。点不是通过线段连接,而是分别用点、圆或其他形状表示。我们将从设置绘图笔记本和导入我们将使用的包开始:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
使用 plt.plot 创建散点图
在前一章中,我们使用 plt.plot
/ax.plot
来生成线图。事实证明,这个函数也可以生成散点图(参见 图 27-1)。
In [2]: x = np.linspace(0, 10, 30)
y = np.sin(x)
plt.plot(x, y, 'o', color='black');
图 27-1. 散点图示例
函数调用中的第三个参数是一个字符,代表用于绘图的符号类型。正如你可以指定 '-'
或 '--'
控制线条样式一样,标记样式也有自己一套简短的字符串代码。可用符号的完整列表可以在 plt.plot
的文档中或 Matplotlib 的在线文档中找到。大多数可能性都相当直观,并且其中一些更常见的示例在此处演示(参见 图 27-2)。
In [3]: rng = np.random.default_rng(0)
for marker in ['o', '.', ',', 'x', '+', 'v', '^', '<', '>', 's', 'd']:
plt.plot(rng.random(2), rng.random(2), marker, color='black',
label="marker='{0}'".format(marker))
plt.legend(numpoints=1, fontsize=13)
plt.xlim(0, 1.8);
图 27-2. 点数示例
进一步地,这些字符代码可以与线条和颜色代码一起使用,以绘制带有连接线的点(参见 图 27-3)。
In [4]: plt.plot(x, y, '-ok');
图 27-3. 结合线条和点标记的示例
plt.plot
的额外关键字参数可以指定线条和标记的多种属性,正如你可以在 图 27-4 中看到的。
In [5]: plt.plot(x, y, '-p', color='gray',
markersize=15, linewidth=4,
markerfacecolor='white',
markeredgecolor='gray',
markeredgewidth=2)
plt.ylim(-1.2, 1.2);
图 27-4. 自定义线条和点标记
这些选项使得 plt.plot
成为 Matplotlib 中二维图的主要工具。要了解所有可用选项的详细描述,请参考 plt.plot
文档。
使用 plt.scatter 创建散点图
创建散点图的第二种更强大的方法是 plt.scatter
函数,其用法与 plt.plot
函数非常相似(参见 图 27-5)。
In [6]: plt.scatter(x, y, marker='o');
图 27-5. 一个简单的散点图
plt.scatter
与 plt.plot
的主要区别在于,它可以用于创建散点图,其中可以单独控制或映射到数据的每个点的属性(大小、填充颜色、边缘颜色等)。
为了更好地观察重叠的结果,我们创建一个随机散点图,点具有多种颜色和大小。为了调整透明度,我们还会使用 alpha
关键字(参见 图 27-6)。
In [7]: rng = np.random.default_rng(0)
x = rng.normal(size=100)
y = rng.normal(size=100)
colors = rng.random(100)
sizes = 1000 * rng.random(100)
plt.scatter(x, y, c=colors, s=sizes, alpha=0.3)
plt.colorbar(); # show color scale
图 27-6. 在散点图中更改点的大小和颜色
注意,颜色参数自动映射到颜色比例(这里通过colorbar
命令显示),点的大小以像素表示。通过这种方式,可以利用点的颜色和大小来传达可视化信息,以便可视化多维数据。
例如,我们可以使用来自 Scikit-Learn 的鸢尾花数据集,其中每个样本是三种类型的花之一,其花瓣和萼片的大小已经被仔细测量(见图 27-7)。
In [8]: from sklearn.datasets import load_iris
iris = load_iris()
features = iris.data.T
plt.scatter(features[0], features[1], alpha=0.4,
s=100*features[3], c=iris.target, cmap='viridis')
plt.xlabel(iris.feature_names[0])
plt.ylabel(iris.feature_names[1]);
图 27-7. 使用点属性来编码鸢尾花数据的特征^(1)
我们可以看到,这个散点图使我们能够同时探索数据的四个不同维度:每个点的(x, y)位置对应于萼片的长度和宽度,点的大小与花瓣的宽度相关,颜色与特定种类的花相关。像这样的多颜色和多特征散点图既可以用于数据探索,也可以用于数据展示。
绘图与散点图:关于效率的一点说明
除了plt.plot
和plt.scatter
中提供的不同特性外,为什么你可能选择使用一个而不是另一个?虽然对于少量数据来说这并不重要,但是随着数据集超过几千个点,plt.plot
比plt.scatter
效率显著更高。原因在于,plt.scatter
可以为每个点渲染不同的大小和/或颜色,因此渲染器必须额外工作来构建每个点。而对于plt.plot
,每个点的标记是相同的,因此确定点的外观的工作仅需一次处理整个数据集。对于大数据集,这种差异可能导致性能大不相同,因此在处理大数据集时,应优先选择plt.plot
而不是plt.scatter
。
可视化不确定性
对于任何科学测量,准确地考虑不确定性几乎与准确报告数字本身同样重要,甚至更重要。例如,想象我正在使用一些天体物理观测来估计哈勃常数,即宇宙膨胀速率的本地测量。我知道当前文献建议的值约为 70 (km/s)/Mpc,而我的方法测量的值为 74 (km/s)/Mpc。这些值是否一致?基于这些信息,唯一正确的答案是:没有办法知道。
假设我将这些信息与报告的不确定性一起增加:当前文献建议的值为 70 ± 2.5 (km/s)/Mpc,而我的方法测量的值为 74 ± 5 (km/s)/Mpc。现在这些值是否一致?这是一个可以定量回答的问题。
在数据和结果的可视化中,有效地显示这些误差可以使绘图传达更完整的信息。
基本误差条
一种标准的可视化不确定性的方法是使用误差条。可以通过单个 Matplotlib 函数调用创建基本的误差条,如图 27-8 所示。
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
In [2]: x = np.linspace(0, 10, 50)
dy = 0.8
y = np.sin(x) + dy * np.random.randn(50)
plt.errorbar(x, y, yerr=dy, fmt='.k');
这里的 fmt
是一个控制线条和点的外观的格式代码,其语法与前一章和本章早些时候概述的 plt.plot
的简写相同。
图 27-8. 一个误差条示例
除了这些基本选项外,errorbar
函数还有许多选项可以微调输出结果。使用这些附加选项,您可以轻松定制误差条绘图的美学效果。特别是在拥挤的图中,我经常发现将误差条的颜色设为比点本身更浅是有帮助的(见图 27-9)。
In [3]: plt.errorbar(x, y, yerr=dy, fmt='o', color='black',
ecolor='lightgray', elinewidth=3, capsize=0);
图 27-9. 自定义误差条
除了这些选项外,您还可以指定水平误差条、单侧误差条和许多其他变体。有关可用选项的更多信息,请参阅 plt.errorbar
的文档字符串。
连续误差
在某些情况下,希望在连续量上显示误差条。虽然 Matplotlib 没有针对这种类型应用的内置便捷例程,但可以相对轻松地结合 plt.plot
和 plt.fill_between
这样的基本图形元素来得到有用的结果。
在这里,我们将执行简单的高斯过程回归,使用 Scikit-Learn API(详见第三十八章)。这是一种将非常灵活的非参数函数拟合到具有连续不确定度测量的数据的方法。我们目前不会深入讨论高斯过程回归的细节,而是专注于如何可视化这种连续误差测量:
In [4]: from sklearn.gaussian_process import GaussianProcessRegressor
# define the model and draw some data
model = lambda x: x * np.sin(x)
xdata = np.array([1, 3, 5, 6, 8])
ydata = model(xdata)
# Compute the Gaussian process fit
gp = GaussianProcessRegressor()
gp.fit(xdata[:, np.newaxis], ydata)
xfit = np.linspace(0, 10, 1000)
yfit, dyfit = gp.predict(xfit[:, np.newaxis], return_std=True)
现在我们有 xfit
、yfit
和 dyfit
,它们对我们数据的连续拟合进行了采样。我们可以像前面的部分一样将它们传递给 plt.errorbar
函数,但我们实际上不想绘制 1,000 个点和 1,000 个误差条。相反,我们可以使用 plt.fill_between
函数并使用浅色来可视化这个连续误差(见图 27-10)。
In [5]: # Visualize the result
plt.plot(xdata, ydata, 'or')
plt.plot(xfit, yfit, '-', color='gray')
plt.fill_between(xfit, yfit - dyfit, yfit + dyfit,
color='gray', alpha=0.2)
plt.xlim(0, 10);
图 27-10. 用填充区域表示连续不确定性
查看 fill_between
的调用签名:我们传递一个 x 值,然后是下限 y 值和上限 y 值,结果是这些区域之间的区域被填充。
得到的图形直观地展示了高斯过程回归算法的运行情况:在接近测量数据点的区域,模型受到强约束,这反映在较小的模型不确定性中。在远离测量数据点的区域,模型约束不强,模型不确定性增加。
欲了解更多关于plt.fill_between
(及其紧密相关的plt.fill
函数)可用选项的信息,请参阅函数文档字符串或 Matplotlib 文档。
最后,如果这对你来说有点太低级了,请参考第三十六章,在那里我们讨论了 Seaborn 包,它具有更简化的 API 来可视化这种连续误差条类型。
^(1) 这幅图的全彩版可在GitHub上找到。
第二十八章:密度和等高线图
有时候,使用等高线或彩色区域来在二维中显示三维数据是很有用的。Matplotlib 提供了三个函数可以帮助完成这个任务:plt.contour
用于等高线图,plt.contourf
用于填充等高线图,plt.imshow
用于显示图像。本章将讨论几个使用这些函数的示例。我们将从设置绘图笔记本和导入我们将使用的函数开始:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-white')
import numpy as np
可视化三维函数
我们的第一个例子演示了使用函数 绘制等高线图,这里选择了特定的 (我们在第八章中已经见过它,当时我们将其作为数组广播的示例):
In [2]: def f(x, y):
return np.sin(x) ** 10 + np.cos(10 + y * x) * np.cos(x)
可以使用 plt.contour
函数创建等高线图。它接受三个参数:x 值的网格,y 值的网格和 z 值的网格。x 和 y 值表示图表上的位置,z 值将由等高线级别表示。准备这样的数据最直接的方法之一是使用 np.meshgrid
函数,它从一维数组构建二维网格:
In [3]: x = np.linspace(0, 5, 50)
y = np.linspace(0, 5, 40)
X, Y = np.meshgrid(x, y)
Z = f(X, Y)
现在让我们看看这个标准的仅线条等高线图(见图 28-1)。
In [4]: plt.contour(X, Y, Z, colors='black');
图 28-1. 用等高线可视化三维数据
注意,当使用单一颜色时,负值用虚线表示,正值用实线表示。或者可以通过指定 cmap
参数来使用色图对线条进行颜色编码。在这里,我们还指定希望在数据范围内绘制更多线条,即 20 个等间距间隔,如图 28-2 所示。
In [5]: plt.contour(X, Y, Z, 20, cmap='RdGy');
图 28-2. 用彩色等高线可视化三维数据
这里我们选择了 RdGy
(缩写表示红灰)色图,这对于显示数据的正负变化(即围绕零的正负值)是一个不错的选择。Matplotlib 提供了多种色图可供选择,你可以通过在 IPython 中对 plt.cm
模块进行制表完成来轻松浏览:
plt.cm.<TAB>
我们的图表看起来更漂亮了,但是线条之间的空白可能有些分散注意力。我们可以通过切换到使用 plt.contourf
函数创建填充等高线图来改变这一点,它与 plt.contour
的语法基本相同。
此外,我们将添加一个 plt.colorbar
命令,它会创建一个带有标记颜色信息的额外坐标轴用于图表(参见图 28-3)。
In [6]: plt.contourf(X, Y, Z, 20, cmap='RdGy')
plt.colorbar();
图 28-3. 用填充等高线可视化三维数据
颜色条清楚地表明黑色区域为“峰值”,而红色区域为“谷底”。
这种绘图的一个潜在问题是有点斑驳:颜色步骤是离散的,而不是连续的,这并不总是期望的效果。可以通过将等高线的数量设置为一个非常大的数字来解决此问题,但这将导致绘图效率较低:Matplotlib 必须为每个级别渲染一个新的多边形。生成平滑表示的更好方法是使用 plt.imshow
函数,它提供了 interpolation
参数,以生成数据的平滑二维表示(见图 28-4)。
In [7]: plt.imshow(Z, extent=[0, 5, 0, 5], origin='lower', cmap='RdGy',
interpolation='gaussian', aspect='equal')
plt.colorbar();
图 28-4. 将三维数据表示为图像
使用 plt.imshow
有一些潜在的小问题:
-
它不接受 x 和 y 网格,因此您必须手动指定图中图像的 extent [xmin, xmax, ymin, ymax]。
-
默认情况下,它遵循标准图像数组定义,其中原点在左上角,而不是大多数等高线图中的左下角。在显示网格化数据时必须更改此设置。
-
它会自动调整轴的纵横比以匹配输入数据;可以使用
aspect
参数进行更改。
最后,有时将等高线图和图像图结合起来可能会很有用。例如,在这里我们将使用部分透明的背景图像(透明度通过 alpha
参数设置),并在等高线上标记标签,使用 plt.clabel
函数(见图 28-5)。
In [8]: contours = plt.contour(X, Y, Z, 3, colors='black')
plt.clabel(contours, inline=True, fontsize=8)
plt.imshow(Z, extent=[0, 5, 0, 5], origin='lower',
cmap='RdGy', alpha=0.5)
plt.colorbar();
图 28-5. 图像上标记的等高线
这三个函数的组合——plt.contour
、plt.contourf
和 plt.imshow
——在二维图中展示三维数据具有几乎无限的可能性。关于这些函数可用选项的更多信息,请参阅它们的文档字符串。如果您对这类数据的三维可视化感兴趣,请参阅第三十五章。
直方图、分箱和密度
简单直方图可以是理解数据集的一个很好的第一步。此前,我们看到了 Matplotlib 的直方图函数预览(见第九章),它一旦完成了常规的引入工作(见图 28-6)就能以一行代码创建一个基本的直方图。
In [1]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
plt.style.use('seaborn-white')
rng = np.random.default_rng(1701)
data = rng.normal(size=1000)
In [2]: plt.hist(data);
图 28-6. 简单直方图
hist
函数有许多选项可以调整计算和显示;这里有一个更加定制化的直方图示例,显示在 Figure 28-7 中。
In [3]: plt.hist(data, bins=30, density=True, alpha=0.5,
histtype='stepfilled', color='steelblue',
edgecolor='none');
图 28-7. 一个定制化的直方图
plt.hist
的文档字符串中包含更多有关其他可用定制选项的信息。当比较几个分布的直方图时,我发现使用histtype='stepfilled'
与一些透明度 alpha
的组合是很有帮助的(参见 Figure 28-8)。
In [4]: x1 = rng.normal(0, 0.8, 1000)
x2 = rng.normal(-2, 1, 1000)
x3 = rng.normal(3, 2, 1000)
kwargs = dict(histtype='stepfilled', alpha=0.3, density=True, bins=40)
plt.hist(x1, **kwargs)
plt.hist(x2, **kwargs)
plt.hist(x3, **kwargs);
图 28-8. 叠加多个直方图^(1)
如果您只对计算而不是显示直方图(即计算给定箱中点的数量)感兴趣,可以使用 np.histogram
函数:
In [5]: counts, bin_edges = np.histogram(data, bins=5)
print(counts)
Out[5]: [ 23 241 491 224 21]
二维直方图和分箱
就像我们通过将数轴划分为箱来创建一维直方图一样,我们也可以通过将点分配到二维箱中来创建二维直方图。我们将简要地查看几种方法来做到这一点。让我们首先定义一些数据——从多元高斯分布中抽取的 x
和 y
数组:
In [6]: mean = [0, 0]
cov = [[1, 1], [1, 2]]
x, y = rng.multivariate_normal(mean, cov, 10000).T
plt.hist2d:二维直方图
绘制二维直方图的一个简单方法是使用 Matplotlib 的 plt.hist2d
函数(见 Figure 28-9)。
In [7]: plt.hist2d(x, y, bins=30)
cb = plt.colorbar()
cb.set_label('counts in bin')
图 28-9. 使用 plt.hist2d 绘制的二维直方图
就像 plt.hist
一样,plt.hist2d
也有许多额外选项可以微调绘图和分箱,这些选项在函数的文档字符串中有详细说明。此外,就像 plt.hist
在 np.histogram
中有对应物一样,plt.hist2d
在 np.histogram2d
中也有对应物:
In [8]: counts, xedges, yedges = np.histogram2d(x, y, bins=30)
print(counts.shape)
Out[8]: (30, 30)
对于当有超过两个维度时的直方图分箱的泛化,参见 np.histogramdd
函数。
plt.hexbin:六边形分箱
二维直方图在轴上创建了一个方块的镶嵌图案。这种镶嵌的另一个自然形状是正六边形。为此,Matplotlib 提供了 plt.hexbin
程序,它表示在六边形网格内对二维数据集进行分箱(见 Figure 28-10)。
In [9]: plt.hexbin(x, y, gridsize=30)
cb = plt.colorbar(label='count in bin')
图 28-10. 使用 plt.hexbin 绘制的二维直方图
plt.hexbin
还有许多额外选项,包括指定每个点的权重和更改每个箱中输出到任何 NumPy 聚合(权重的平均值、权重的标准差等)。
核密度估计
在多维度中估计和表示密度的另一种常见方法是 核密度估计(KDE)。这将在 第四十九章 中更详细地讨论,但现在我只想简单提一下,KDE 可以被看作是一种在空间中“扩展”点并将结果加总以获得平滑函数的方法。scipy.stats
包中存在一种极快且简单的 KDE 实现。这里是使用 KDE 的一个快速示例(参见 图 28-11)。
In [10]: from scipy.stats import gaussian_kde
# fit an array of size [Ndim, Nsamples]
data = np.vstack([x, y])
kde = gaussian_kde(data)
# evaluate on a regular grid
xgrid = np.linspace(-3.5, 3.5, 40)
ygrid = np.linspace(-6, 6, 40)
Xgrid, Ygrid = np.meshgrid(xgrid, ygrid)
Z = kde.evaluate(np.vstack([Xgrid.ravel(), Ygrid.ravel()]))
# Plot the result as an image
plt.imshow(Z.reshape(Xgrid.shape),
origin='lower', aspect='auto',
extent=[-3.5, 3.5, -6, 6])
cb = plt.colorbar()
cb.set_label("density")
图 28-11. 一个分布的核密度表示
KDE 具有一个有效的平滑长度,可以有效地在详细度和平滑度之间调节(这是普遍的偏差-方差权衡的一个例子)。选择适当的平滑长度的文献非常广泛;gaussian_kde
使用一个经验法则来尝试找到输入数据的几乎最优平滑长度。
SciPy 生态系统中还提供了其他 KDE 实现,每种实现都有其各自的优缺点;例如,可以看到 sklearn.neighbors.KernelDensity
和 statsmodels.nonparametric.KDEMultivariate
。
对于基于 KDE 的可视化,使用 Matplotlib 往往显得冗长。Seaborn 库在 第三十六章 中讨论,为创建基于 KDE 的可视化提供了更紧凑的 API。
^(1) 这个图的全彩版可以在 GitHub 上找到。
第二十九章:自定义绘图图例
绘图图例赋予可视化含义,为各种绘图元素分配含义。我们之前看过如何创建简单的图例;现在我们将看看如何在 Matplotlib 中自定义图例的位置和美观性。
最简单的图例可以通过plt.legend
命令创建,该命令会自动为任何有标签的绘图元素创建图例(参见图 29-1)。
In [1]: import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
In [2]: %matplotlib inline
import numpy as np
In [3]: x = np.linspace(0, 10, 1000)
fig, ax = plt.subplots()
ax.plot(x, np.sin(x), '-b', label='Sine')
ax.plot(x, np.cos(x), '--r', label='Cosine')
ax.axis('equal')
leg = ax.legend()
图 29-1. 默认绘图图例
但我们可能希望以许多方式自定义这样一个图例。例如,我们可以指定位置并打开边框(参见图 29-2)。
In [4]: ax.legend(loc='upper left', frameon=True)
fig
图 29-2. 自定义绘图图例
我们可以使用ncol
命令来指定图例中的列数,如图 29-3 所示。
In [5]: ax.legend(loc='lower center', ncol=2)
fig
图 29-3. 一个两列图例
我们还可以使用圆角框(fancybox
)或添加阴影,更改框架的透明度(alpha 值)或更改文本周围的填充(参见图 29-4)。
In [6]: ax.legend(frameon=True, fancybox=True, framealpha=1,
shadow=True, borderpad=1)
fig
有关可用图例选项的更多信息,请参阅plt.legend
的文档字符串。
图 29-4. 一个带有 fancybox 样式的图例
选择图例元素
正如我们已经看到的,默认情况下,图例包括来自绘图的所有带标签的元素。如果这不是所需的,我们可以通过使用plot
命令返回的对象来微调图例中显示的元素和标签。plt.plot
能够一次创建多条线,并返回创建的线实例列表。将其中任何一条传递给plt.legend
将告诉它要识别哪些元素,以及我们想指定的标签(参见图 29-5)。
In [7]: y = np.sin(x[:, np.newaxis] + np.pi * np.arange(0, 2, 0.5))
lines = plt.plot(x, y)
# lines is a list of plt.Line2D instances
plt.legend(lines[:2], ['first', 'second'], frameon=True);
图 29-5. 图例元素的自定义^(1)
在实践中,我通常发现使用第一种方法更为清晰,即对你想在图例中显示的绘图元素应用标签(参见图 29-6)。
In [8]: plt.plot(x, y[:, 0], label='first')
plt.plot(x, y[:, 1], label='second')
plt.plot(x, y[:, 2:])
plt.legend(frameon=True);
请注意,图例会忽略所有未设置label
属性的元素。
图 29-6. 自定义图例元素的另一种方法^(2)
点的尺寸图例
有时默认的图例设置不足以满足给定的可视化需求。例如,也许您正在使用点的大小来标记数据的某些特征,并且希望创建反映这一点的图例。这里有一个示例,我们将使用点的大小来指示加利福尼亚城市的人口。我们希望一个指定点大小比例的图例,并通过绘制一些带标签的数据项而实现这一目标(参见图 29-7)。
In [9]: # Uncomment to download the data
# url = ('https://raw.githubusercontent.com/jakevdp/
# PythonDataScienceHandbook/''master/notebooks/data/
# california_cities.csv')
# !cd data && curl -O {url}
In [10]: import pandas as pd
cities = pd.read_csv('data/california_cities.csv')
# Extract the data we're interested in
lat, lon = cities['latd'], cities['longd']
population, area = cities['population_total'], cities['area_total_km2']
# Scatter the points, using size and color but no label
plt.scatter(lon, lat, label=None,
c=np.log10(population), cmap='viridis',
s=area, linewidth=0, alpha=0.5)
plt.axis('equal')
plt.xlabel('longitude')
plt.ylabel('latitude')
plt.colorbar(label='log$_{10}$(population)')
plt.clim(3, 7)
# Here we create a legend:
# we'll plot empty lists with the desired size and label
for area in [100, 300, 500]:
plt.scatter([], [], c='k', alpha=0.3, s=area,
label=str(area) + ' km$²$')
plt.legend(scatterpoints=1, frameon=False, labelspacing=1,
title='City Area')
plt.title('California Cities: Area and Population');
图 29-7. 加利福尼亚城市的位置、地理大小和人口
图例始终引用绘图上的某个对象,因此如果我们想显示特定的形状,我们需要绘制它。在这种情况下,我们想要的对象(灰色圆圈)不在绘图上,因此我们通过绘制空列表来伪造它们。请记住,图例仅列出具有指定标签的绘图元素。
通过绘制空列表,我们创建了带标签的绘图对象,这些对象被图例捕捉,现在我们的图例告诉我们一些有用的信息。这种策略对于创建更复杂的可视化效果很有用。
多个图例
有时在设计绘图时,您可能希望向同一坐标轴添加多个图例。不幸的是,Matplotlib 并不简化这一过程:通过标准的legend
接口,仅能为整个绘图创建一个图例。如果尝试使用plt.legend
或ax.legend
创建第二个图例,它将简单地覆盖第一个。我们可以通过从头开始创建新的图例艺术家(Artist
是 Matplotlib 用于视觉属性的基类),然后使用较低级别的ax.add_artist
方法手动将第二个艺术家添加到绘图中来解决此问题(参见图 29-8)。
In [11]: fig, ax = plt.subplots()
lines = []
styles = ['-', '--', '-.', ':']
x = np.linspace(0, 10, 1000)
for i in range(4):
lines += ax.plot(x, np.sin(x - i * np.pi / 2),
styles[i], color='black')
ax.axis('equal')
# Specify the lines and labels of the first legend
ax.legend(lines[:2], ['line A', 'line B'], loc='upper right')
# Create the second legend and add the artist manually
from matplotlib.legend import Legend
leg = Legend(ax, lines[2:], ['line C', 'line D'], loc='lower right')
ax.add_artist(leg);
这是查看组成任何 Matplotlib 绘图的低级艺术家对象的一瞥。如果您检查ax.legend
的源代码(请记住,您可以在 Jupyter 笔记本中使用ax.legend??
来执行此操作),您将看到该函数仅仅由一些逻辑组成,用于创建一个适当的Legend
艺术家,然后将其保存在legend_
属性中,并在绘制绘图时将其添加到图形中。
图 29-8. 分割图例
^(1) 该图的全彩版本可以在GitHub上找到。
^(2) 该图的全彩版本可以在GitHub上找到。
第三十章:定制色条
绘图图例识别离散点的离散标签。对于基于点、线或区域颜色的连续标签,带标签的色条是一个很好的工具。在 Matplotlib 中,色条被绘制为一个独立的轴,可以为绘图中颜色的含义提供关键。由于本书以黑白印刷,本章配有一个在线补充,您可以在其中查看全彩色的图表。我们将从设置用于绘图的笔记本和导入我们将使用的函数开始:
In [1]: import matplotlib.pyplot as plt
plt.style.use('seaborn-white')
In [2]: %matplotlib inline
import numpy as np
正如我们已经看到的几次,最简单的色条可以使用plt.colorbar
函数创建(参见图 30-1)。
In [3]: x = np.linspace(0, 10, 1000)
I = np.sin(x) * np.cos(x[:, np.newaxis])
plt.imshow(I)
plt.colorbar();
注意
全彩色图可以在GitHub 上的补充材料中找到。
现在我们将讨论几个关于定制这些色条并在各种情况下有效使用它们的想法。
图 30-1. 一个简单的色条图例
自定义色条
可以使用cmap
参数来指定色图,该参数用于创建可视化的绘图函数(参见图 30-2)。
In [4]: plt.imshow(I, cmap='Blues');
图 30-2. 一个蓝色调色板
可用色图的名称在plt.cm
命名空间中;使用 IPython 的制表完成功能将为您提供内置可能性的完整列表:
plt.cm.<TAB>
但是能够选择色图只是第一步:更重要的是如何决定选择哪种!选择实际上比您最初期望的要微妙得多。
选择色图
在可视化中选择颜色的全面处理超出了本书的范围,但是对于这个主题和其他主题的有趣阅读,请参阅 Nicholas Rougier、Michael Droettboom 和 Philip Bourne 的文章“Ten Simple Rules for Better Figures”。Matplotlib 的在线文档还有一个有趣的关于色图选择的讨论(https://oreil.ly/Ll1ir)。
总体而言,您应该了解三种不同类型的色图:
连续色图
这些由一系列连续的颜色组成(例如,binary
或viridis
)。
发散色图
这些通常包含两种不同的颜色,显示与平均值的正负偏差(例如,RdBu
或PuOr
)。
- 定性色图
这些颜色混合没有特定的顺序(例如,rainbow
或jet
)。
jet
色图是 Matplotlib 在 2.0 版本之前的默认色图的一个示例。它作为默认的状态非常不幸,因为定性色图通常不适合表示定量数据。其中一个问题是,定性色图通常不显示随着比例增加而亮度均匀递增的特性。
通过将 jet
色彩条转换为黑白图像,我们可以看到这一点(见 图 30-3)。
In [5]: from matplotlib.colors import LinearSegmentedColormap
def grayscale_cmap(cmap):
"""Return a grayscale version of the given colormap"""
cmap = plt.cm.get_cmap(cmap)
colors = cmap(np.arange(cmap.N))
# Convert RGBA to perceived grayscale luminance
# cf. http://alienryderflex.com/hsp.xhtml
RGB_weight = [0.299, 0.587, 0.114]
luminance = np.sqrt(np.dot(colors[:, :3] ** 2, RGB_weight))
colors[:, :3] = luminance[:, np.newaxis]
return LinearSegmentedColormap.from_list(
cmap.name + "_gray", colors, cmap.N)
def view_colormap(cmap):
"""Plot a colormap with its grayscale equivalent"""
cmap = plt.cm.get_cmap(cmap)
colors = cmap(np.arange(cmap.N))
cmap = grayscale_cmap(cmap)
grayscale = cmap(np.arange(cmap.N))
fig, ax = plt.subplots(2, figsize=(6, 2),
subplot_kw=dict(xticks=[], yticks=[]))
ax[0].imshow([colors], extent=[0, 10, 0, 1])
ax[1].imshow([grayscale], extent=[0, 10, 0, 1])
In [6]: view_colormap('jet')
图 30-3. jet
色彩映射及其不均匀的亮度比例
注意灰度图像中的明亮条纹。即使是全彩色,这种不均匀的亮度也意味着眼睛会被色彩范围的某些部分所吸引,这可能会强调数据集中不重要的部分。最好使用像viridis
(Matplotlib 2.0 的默认色彩映射)这样的色彩映射,它专门设计成在整个范围内具有均匀的亮度变化;因此,它不仅与我们的色彩感知相配,而且在灰度打印时也能很好地转化(参见 图 30-4)。
In [7]: view_colormap('viridis')
图 30-4. viridis
色彩映射及其均匀的亮度比例
对于其他情况,如显示与某个均值的正负偏差,双色彩色条如RdBu
(红-蓝)很有帮助。但是,正如您在 图 30-5 中所看到的,重要的是注意正/负信息在转换为灰度时会丢失!
In [8]: view_colormap('RdBu')
图 30-5. RdBu
色彩映射及其亮度
在接下来的示例中,我们将看到如何使用其中一些色彩映射。
Matplotlib 提供了大量的色彩映射选项;要查看它们的列表,可以使用 IPython 来探索 plt.cm
子模块。对于 Python 中更加原则性的颜色处理方法,可以参考 Seaborn 库中的工具和文档(见 第 36 章)。
色彩限制和扩展
Matplotlib 允许大范围的颜色条定制。颜色条本身只是 plt.Axes
的一个实例,因此我们之前看到的所有坐标轴和刻度格式化技巧都适用。颜色条具有一些有趣的灵活性:例如,我们可以缩小颜色限制,并通过设置 extend
属性在顶部和底部指示超出范围的值以三角箭头表示。例如,在显示受噪声影响的图像时(见 图 30-6),这可能会很有用。
In [9]: # make noise in 1% of the image pixels
speckles = (np.random.random(I.shape) < 0.01)
I[speckles] = np.random.normal(0, 3, np.count_nonzero(speckles))
plt.figure(figsize=(10, 3.5))
plt.subplot(1, 2, 1)
plt.imshow(I, cmap='RdBu')
plt.colorbar()
plt.subplot(1, 2, 2)
plt.imshow(I, cmap='RdBu')
plt.colorbar(extend='both')
plt.clim(-1, 1)
图 30-6. 指定色彩映射扩展^(1)
注意左侧面板中,默认的颜色限制对噪声像素做出了响应,并且噪声范围完全淹没了我们感兴趣的模式。在右侧面板中,我们手动设置了颜色限制,并添加了扩展以指示超出这些限制的值。结果是我们数据的可视化更加有用。
离散色彩条
色图默认是连续的,但有时您想要表示离散值。这样做的最简单方法是使用plt.cm.get_cmap
函数,传递一个合适的色图名称和所需的箱数(见图 30-7)。
In [10]: plt.imshow(I, cmap=plt.cm.get_cmap('Blues', 6))
plt.colorbar(extend='both')
plt.clim(-1, 1);
图 30-7. 一个离散的色图
色图的离散版本可以像任何其他色图一样使用。
例子:手写数字
这可以应用的一个例子是,让我们看一看 Scikit-Learn 中包含的手写数字数据集的一个有趣的可视化;它包括近 2000 个缩略图,显示各种手写数字。
现在,让我们从下载手写数字数据集和用plt.imshow
可视化几个示例图像开始(见图 30-8)。
In [11]: # load images of the digits 0 through 5 and visualize several of them
from sklearn.datasets import load_digits
digits = load_digits(n_class=6)
fig, ax = plt.subplots(8, 8, figsize=(6, 6))
for i, axi in enumerate(ax.flat):
axi.imshow(digits.images[i], cmap='binary')
axi.set(xticks=[], yticks=[])
图 30-8. 手写数字数据的样本
因为每个数字由其 64 个像素的色调定义,我们可以将每个数字视为位于 64 维空间中的一个点:每个维度表示一个像素的亮度。可视化这样高维数据可能会很困难,但处理这个任务的一种方法是使用流形学习等降维技术来减少数据的维度,同时保持感兴趣的关系。降维是无监督机器学习的一个例子,我们将在第三十七章中更详细地讨论它。
推迟讨论这些细节,让我们来看一看手写数字数据的二维流形学习投影(详情见第四十六章):
In [12]: # project the digits into 2 dimensions using Isomap
from sklearn.manifold import Isomap
iso = Isomap(n_components=2, n_neighbors=15)
projection = iso.fit_transform(digits.data)
我们将使用我们的离散色图来查看结果,设置ticks
和clim
以改善结果色条的美观度(见图 30-9)。
In [13]: # plot the results
plt.scatter(projection[:, 0], projection[:, 1], lw=0.1,
c=digits.target, cmap=plt.cm.get_cmap('plasma', 6))
plt.colorbar(ticks=range(6), label='digit value')
plt.clim(-0.5, 5.5)
图 30-9. 手写数字像素的流形嵌入
投影还向我们展示了数据集内部的一些关系:例如,在这个投影中,2 和 3 的范围几乎重叠,表明一些手写的 2 和 3 很难区分,可能更容易被自动分类算法混淆。而像 0 和 1 这样的值则分开得更远,可能更不容易混淆。
我们将在第五部分回到流形学习和数字分类。
^(1) 本图的完整版本可以在GitHub上找到。
第三十一章:多个子图
有时将数据的不同视图并排比较会很有帮助。为此,Matplotlib 有子图的概念:一组可以在单个图中存在的较小轴。这些子图可以是插图、网格图或其他更复杂的布局。在本章中,我们将探讨创建 Matplotlib 中子图的四种常规方法。我们将首先导入要使用的包:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-white')
import numpy as np
plt.axes:手动创建子图
创建轴的最基本方法是使用plt.axes
函数。正如我们之前看到的,默认情况下,这将创建一个填充整个图的标准轴对象。plt.axes
还接受一个可选参数,即在图坐标系中的四个数字列表([*left*, *bottom*, *width*, *height*]
),它们的取值范围从图的左下角的 0 到右上角的 1。
例如,我们可以通过将x和y位置设置为 0.65(即从图的宽度和高度的 65%开始)并将x和y范围设置为 0.2(即轴的大小为图宽度和高度的 20%)在另一个轴的右上角创建一个插图轴。图 31-1 显示了结果:
In [2]: ax1 = plt.axes() # standard axes
ax2 = plt.axes([0.65, 0.65, 0.2, 0.2])
图 31-1. 插图轴示例
在面向对象接口中,该命令的等效命令是fig.add_axes
。让我们使用它来创建两个垂直堆叠的轴,如图 31-2 所示。
In [3]: fig = plt.figure()
ax1 = fig.add_axes([0.1, 0.5, 0.8, 0.4],
xticklabels=[], ylim=(-1.2, 1.2))
ax2 = fig.add_axes([0.1, 0.1, 0.8, 0.4],
ylim=(-1.2, 1.2))
x = np.linspace(0, 10)
ax1.plot(np.sin(x))
ax2.plot(np.cos(x));
现在我们有两个轴(顶部没有刻度标签),它们紧挨着:上面板的底部(在位置 0.5 处)与下面板的顶部(在位置 0.1 + 0.4 处)相匹配。
图 31-2. 垂直堆叠轴示例
plt.subplot:简单的子图网格
对齐的列或行子图是一个常见的需求,Matplotlib 提供了几个便利函数来轻松创建它们。其中最低级别的是plt.subplot
,它在网格中创建一个单个子图。正如您所见,这个命令需要三个整数参数——行数、列数以及在此方案中要创建的图的索引,该索引从左上角到右下角依次排列(见图 31-3)。
In [4]: for i in range(1, 7):
plt.subplot(2, 3, i)
plt.text(0.5, 0.5, str((2, 3, i)),
fontsize=18, ha='center')
图 31-3. plt.subplot 示例
命令plt.subplots_adjust
可用于调整这些图之间的间距。以下代码使用了等效的面向对象的命令fig.add_subplot
;图 31-4 显示了结果:
In [5]: fig = plt.figure()
fig.subplots_adjust(hspace=0.4, wspace=0.4)
for i in range(1, 7):
ax = fig.add_subplot(2, 3, i)
ax.text(0.5, 0.5, str((2, 3, i)),
fontsize=18, ha='center')
在这里,我们使用了plt.subplots_adjust
的hspace
和wspace
参数,它们分别指定了图的高度和宽度上的间距,单位为子图大小的百分比(在本例中,空间为子图宽度和高度的 40%)。
图 31-4. 调整边距的 plt.subplot
plt.subplots:一次性创建整个网格
当创建大量子图网格时,特别是如果您希望隐藏内部图的 x 和 y 轴标签时,刚刚描述的方法很快变得繁琐。为此,plt.subplots
是更易于使用的工具(注意 subplots
末尾的 s
)。该函数不是创建单个子图,而是一行内创建完整的子图网格,返回它们的 NumPy 数组。参数是行数和列数,以及可选的关键字 sharex
和 sharey
,允许您指定不同轴之间的关系。
让我们创建一个 的子图网格,同一行内的所有轴共享其 y 轴比例,同一列内的所有轴共享其 x 轴比例(参见 图 31-5)。
In [6]: fig, ax = plt.subplots(2, 3, sharex='col', sharey='row')
图 31-5. plt.subplots 中的共享 x 和 y 轴
通过指定 sharex
和 sharey
,我们自动删除了网格内部的标签,使得绘图更清晰。生成的轴实例网格返回为 NumPy 数组,可以使用标准数组索引符号方便地指定所需的轴(参见 图 31-6)。
In [7]: # axes are in a two-dimensional array, indexed by [row, col]
for i in range(2):
for j in range(3):
ax[i, j].text(0.5, 0.5, str((i, j)),
fontsize=18, ha='center')
fig
相较于 plt.subplot
,plt.subplots
更符合 Python 的传统零起始索引,而 plt.subplot
使用 MATLAB 风格的一起始索引。
图 31-6. 在子图网格中标识绘图
plt.GridSpec:更复杂的排列
要超越常规网格,创建跨越多行和列的子图,plt.GridSpec
是最佳工具。 plt.GridSpec
本身不创建图形;它是一个方便的接口,被 plt.subplot
命令识别。例如,一个具有两行三列、指定宽度和高度空间的 GridSpec
如下所示:
In [8]: grid = plt.GridSpec(2, 3, wspace=0.4, hspace=0.3)
通过这种方式,我们可以使用熟悉的 Python 切片语法指定子图的位置和范围(参见 图 31-7)。
In [9]: plt.subplot(grid[0, 0])
plt.subplot(grid[0, 1:])
plt.subplot(grid[1, :2])
plt.subplot(grid[1, 2]);
图 31-7. 使用 plt.GridSpec
创建不规则子图
这种灵活的网格对齐方式有广泛的用途。我最常在创建像 图 31-8 中显示的多轴直方图图时使用它。
In [10]: # Create some normally distributed data
mean = [0, 0]
cov = [[1, 1], [1, 2]]
rng = np.random.default_rng(1701)
x, y = rng.multivariate_normal(mean, cov, 3000).T
# Set up the axes with GridSpec
fig = plt.figure(figsize=(6, 6))
grid = plt.GridSpec(4, 4, hspace=0.2, wspace=0.2)
main_ax = fig.add_subplot(grid[:-1, 1:])
y_hist = fig.add_subplot(grid[:-1, 0], xticklabels=[], sharey=main_ax)
x_hist = fig.add_subplot(grid[-1, 1:], yticklabels=[], sharex=main_ax)
# Scatter points on the main axes
main_ax.plot(x, y, 'ok', markersize=3, alpha=0.2)
# Histogram on the attached axes
x_hist.hist(x, 40, histtype='stepfilled',
orientation='vertical', color='gray')
x_hist.invert_yaxis()
y_hist.hist(y, 40, histtype='stepfilled',
orientation='horizontal', color='gray')
y_hist.invert_xaxis()
图 31-8. 使用 plt.GridSpec
可视化多维分布
这种分布类型以及其边距经常出现,Seaborn 包中有自己的绘图 API;详见 第 36 章。
第三十二章:文本和注释
创建良好的可视化图表涉及引导读者,使图表讲述一个故事。在某些情况下,可以完全通过视觉方式讲述这个故事,无需添加文本,但在其他情况下,小的文本提示和标签是必要的。也许你会使用的最基本的注释类型是坐标轴标签和标题,但选项远不止于此。让我们看看一些数据及其如何可视化和注释,以传达有趣的信息。我们将开始设置绘图笔记本并导入将要使用的函数:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
import matplotlib as mpl
plt.style.use('seaborn-whitegrid')
import numpy as np
import pandas as pd
示例:节假日对美国出生的影响
让我们回到之前处理的一些数据,在“例子:出生率数据”中,我们生成了一个绘制整个日历年平均出生的图表。我们将从那里使用相同的清理过程开始,并绘制结果(参见图 32-1)。
In [2]: # shell command to download the data:
# !cd data && curl -O \
# https://raw.githubusercontent.com/jakevdp/data-CDCbirths/master/
# births.csv
In [3]: from datetime import datetime
births = pd.read_csv('data/births.csv')
quartiles = np.percentile(births['births'], [25, 50, 75])
mu, sig = quartiles[1], 0.74 * (quartiles[2] - quartiles[0])
births = births.query('(births > @mu - 5 * @sig) &
(births < @mu + 5 * @sig)')
births['day'] = births['day'].astype(int)
births.index = pd.to_datetime(10000 * births.year +
100 * births.month +
births.day, format='%Y%m%d')
births_by_date = births.pivot_table('births',
[births.index.month, births.index.day])
births_by_date.index = [datetime(2012, month, day)
for (month, day) in births_by_date.index]
In [4]: fig, ax = plt.subplots(figsize=(12, 4))
births_by_date.plot(ax=ax);
图 32-1. 每日平均出生数按日期统计^(1)
当我们可视化这样的数据时,注释图表的特定特征通常很有用,以吸引读者的注意。可以使用 plt.text
/ax.text
函数手动完成此操作,该函数将文本放置在特定的 x/y 值处(参见图 32-2)。
In [5]: fig, ax = plt.subplots(figsize=(12, 4))
births_by_date.plot(ax=ax)
# Add labels to the plot
style = dict(size=10, color='gray')
ax.text('2012-1-1', 3950, "New Year's Day", **style)
ax.text('2012-7-4', 4250, "Independence Day", ha='center', **style)
ax.text('2012-9-4', 4850, "Labor Day", ha='center', **style)
ax.text('2012-10-31', 4600, "Halloween", ha='right', **style)
ax.text('2012-11-25', 4450, "Thanksgiving", ha='center', **style)
ax.text('2012-12-25', 3850, "Christmas ", ha='right', **style)
# Label the axes
ax.set(title='USA births by day of year (1969-1988)',
ylabel='average daily births')
# Format the x-axis with centered month labels
ax.xaxis.set_major_locator(mpl.dates.MonthLocator())
ax.xaxis.set_minor_locator(mpl.dates.MonthLocator(bymonthday=15))
ax.xaxis.set_major_formatter(plt.NullFormatter())
ax.xaxis.set_minor_formatter(mpl.dates.DateFormatter('%h'));
图 32-2. 按日期注释的每日平均出生数^(2)
ax.text
方法需要一个 x 位置、一个 y 位置和一个字符串,然后是可选的关键字,指定文本的颜色、大小、样式、对齐方式和其他属性。这里我们使用了 ha='right'
和 ha='center'
,其中 ha
是 水平对齐 的缩写。有关可用选项的更多信息,请参阅 plt.text
和 mpl.text.Text
的文档字符串。
转换和文本位置
在前面的示例中,我们将文本注释锚定在数据位置上。有时候,将文本锚定在轴或图的固定位置上,而与数据无关,更为可取。在 Matplotlib 中,通过修改 transform 来实现这一点。
Matplotlib 使用几种不同的坐标系统:数学上,位于 处的数据点对应于轴或图的特定位置,进而对应于屏幕上的特定像素。在数学上,这些坐标系统之间的转换相对简单,Matplotlib 在内部使用一组良好开发的工具来执行这些转换(这些工具可以在 matplotlib.transforms
子模块中探索)。
典型用户很少需要担心变换的细节,但在考虑在图上放置文本时,这些知识是有帮助的。在这种情况下,有三种预定义的变换可能会有所帮助:
ax.transData
与数据坐标相关联的变换
ax.transAxes
与轴相关联的变换(以轴尺寸为单位)
fig.transFigure
与图形相关联的变换(以图形尺寸为单位)
让我们看一个示例,使用这些变换在不同位置绘制文本(参见 图 32-3)。
In [6]: fig, ax = plt.subplots(facecolor='lightgray')
ax.axis([0, 10, 0, 10])
# transform=ax.transData is the default, but we'll specify it anyway
ax.text(1, 5, ". Data: (1, 5)", transform=ax.transData)
ax.text(0.5, 0.1, ". Axes: (0.5, 0.1)", transform=ax.transAxes)
ax.text(0.2, 0.2, ". Figure: (0.2, 0.2)", transform=fig.transFigure);
图 32-3. 比较 Matplotlib 的坐标系
Matplotlib 的默认文本对齐方式是使每个字符串开头的“.”大致标记指定的坐标位置。
transData
坐标提供与 x 和 y 轴标签关联的通常数据坐标。transAxes
坐标给出了从轴的左下角(白色框)开始的位置,作为总轴尺寸的一部分的分数。transFigure
坐标类似,但指定了从图的左下角(灰色框)开始的位置,作为总图尺寸的一部分的分数。
注意,现在如果我们更改坐标轴限制,只有 transData
坐标会受到影响,而其他坐标保持不变(参见 图 32-4)。
In [7]: ax.set_xlim(0, 2)
ax.set_ylim(-6, 6)
fig
图 32-4. 比较 Matplotlib 的坐标系
通过交互式更改坐标轴限制,可以更清楚地看到这种行为:如果您在笔记本中执行此代码,可以通过将 %matplotlib inline
更改为 %matplotlib notebook
并使用每个图的菜单与图进行交互来实现这一点。
箭头和注释
除了刻度和文本,另一个有用的注释标记是简单的箭头。
虽然有 plt.arrow
函数可用,但我不建议使用它:它创建的箭头是 SVG 对象,会受到绘图的不同纵横比的影响,使得难以正确使用。相反,我建议使用 plt.annotate
函数,它创建一些文本和箭头,并允许非常灵活地指定箭头。
这里演示了使用几种选项的 annotate
的示例(参见 图 32-5)。
In [8]: fig, ax = plt.subplots()
x = np.linspace(0, 20, 1000)
ax.plot(x, np.cos(x))
ax.axis('equal')
ax.annotate('local maximum', xy=(6.28, 1), xytext=(10, 4),
arrowprops=dict(facecolor='black', shrink=0.05))
ax.annotate('local minimum', xy=(5 * np.pi, -1), xytext=(2, -6),
arrowprops=dict(arrowstyle="->",
connectionstyle="angle3,angleA=0,angleB=-90"));
图 32-5. 注释示例
箭头样式由 arrowprops
字典控制,其中有许多可用选项。这些选项在 Matplotlib 的在线文档中有很好的记录,因此不重复在此介绍,更有用的是展示一些示例。让我们使用之前的出生率图来演示几种可能的选项(参见 图 32-6)。
In [9]: fig, ax = plt.subplots(figsize=(12, 4))
births_by_date.plot(ax=ax)
# Add labels to the plot
ax.annotate("New Year's Day", xy=('2012-1-1', 4100), xycoords='data',
xytext=(50, -30), textcoords='offset points',
arrowprops=dict(arrowstyle="->",
connectionstyle="arc3,rad=-0.2"))
ax.annotate("Independence Day", xy=('2012-7-4', 4250), xycoords='data',
bbox=dict(boxstyle="round", fc="none", ec="gray"),
xytext=(10, -40), textcoords='offset points', ha='center',
arrowprops=dict(arrowstyle="->"))
ax.annotate('Labor Day Weekend', xy=('2012-9-4', 4850), xycoords='data',
ha='center', xytext=(0, -20), textcoords='offset points')
ax.annotate('', xy=('2012-9-1', 4850), xytext=('2012-9-7', 4850),
xycoords='data', textcoords='data',
arrowprops={'arrowstyle': '|-|,widthA=0.2,widthB=0.2', })
ax.annotate('Halloween', xy=('2012-10-31', 4600), xycoords='data',
xytext=(-80, -40), textcoords='offset points',
arrowprops=dict(arrowstyle="fancy",
fc="0.6", ec="none",
connectionstyle="angle3,angleA=0,angleB=-90"))
ax.annotate('Thanksgiving', xy=('2012-11-25', 4500), xycoords='data',
xytext=(-120, -60), textcoords='offset points',
bbox=dict(boxstyle="round4,pad=.5", fc="0.9"),
arrowprops=dict(
arrowstyle="->",
connectionstyle="angle,angleA=0,angleB=80,rad=20"))
ax.annotate('Christmas', xy=('2012-12-25', 3850), xycoords='data',
xytext=(-30, 0), textcoords='offset points',
size=13, ha='right', va="center",
bbox=dict(boxstyle="round", alpha=0.1),
arrowprops=dict(arrowstyle="wedge,tail_width=0.5", alpha=0.1));
# Label the axes
ax.set(title='USA births by day of year (1969-1988)',
ylabel='average daily births')
# Format the x-axis with centered month labels
ax.xaxis.set_major_locator(mpl.dates.MonthLocator())
ax.xaxis.set_minor_locator(mpl.dates.MonthLocator(bymonthday=15))
ax.xaxis.set_major_formatter(plt.NullFormatter())
ax.xaxis.set_minor_formatter(mpl.dates.DateFormatter('%h'));
ax.set_ylim(3600, 5400);
各种选项使annotate
功能强大且灵活:您可以创建几乎任何箭头样式。不幸的是,这也意味着这些功能通常必须手动调整,这在生成出版质量的图形时可能非常耗时!最后,我要指出,前述样式混合绝不是展示数据的最佳实践,而是作为展示某些可用选项的示例。
更多关于可用箭头和注释样式的讨论和示例可以在 Matplotlib 的注释教程中找到。
图 32-6. 按天平均出生率的注释^(3)
^(1) 该图的完整版本可以在GitHub找到。
^(2) 该图的完整版本可以在GitHub找到。
^(3) 该图的完整版本可以在GitHub找到。
第三十三章:自定义刻度
Matplotlib 的默认刻度定位器和格式化程序通常在许多常见情况下是足够的,但并不是每种图表类型都是最佳选择。本章将给出几个示例,调整特定图表类型的刻度位置和格式化。
然而,在我们进入示例之前,让我们再多谈一些 Matplotlib 图表的对象层次结构。Matplotlib 的目标是让每个出现在图表上的东西都有一个 Python 对象来表示:例如,回想一下Figure
是包围所有图表元素的边界框。每个 Matplotlib 对象也可以作为子对象的容器:例如,每个Figure
可以包含一个或多个Axes
对象,每个Axes
对象又包含表示图表内容的其他对象。
刻度标记也不例外。每个轴都有属性xaxis
和yaxis
,这些属性又包含组成轴的线条、刻度和标签的所有属性。
主要和次要刻度
在每个轴上,有一个主要刻度和一个次要刻度的概念。顾名思义,主要刻度通常较大或更显著,而次要刻度通常较小。默认情况下,Matplotlib 很少使用次要刻度,但你可以在对数图中看到它们的一种情况(见图 33-1)。
In [1]: import matplotlib.pyplot as plt
plt.style.use('classic')
import numpy as np
%matplotlib inline
In [2]: ax = plt.axes(xscale='log', yscale='log')
ax.set(xlim=(1, 1E3), ylim=(1, 1E3))
ax.grid(True);
图 33-1. 对数刻度和标签的示例
在这张图表中,每个主要刻度显示一个大的刻度标记、标签和网格线,而每个次要刻度显示一个更小的刻度标记,没有标签或网格线。
这些刻度属性——即位置和标签——可以通过设置每个轴的formatter
和locator
对象来自定义。让我们来看看刚刚显示的图表的 x 轴:
In [3]: print(ax.xaxis.get_major_locator())
print(ax.xaxis.get_minor_locator())
Out[3]: <matplotlib.ticker.LogLocator object at 0x1129b9370>
<matplotlib.ticker.LogLocator object at 0x1129aaf70>
In [4]: print(ax.xaxis.get_major_formatter())
print(ax.xaxis.get_minor_formatter())
Out[4]: <matplotlib.ticker.LogFormatterSciNotation object at 0x1129aaa00>
<matplotlib.ticker.LogFormatterSciNotation object at 0x1129aac10>
我们看到主要和次要刻度标签的位置都由LogLocator
指定(这对于对数图是有意义的)。然而,次要刻度的标签由NullFormatter
格式化:这表示不会显示任何标签。
现在我们来看几个例子,设置这些定位器和格式化程序用于不同的图表。
隐藏刻度或标签
或许最常见的刻度/标签格式化操作是隐藏刻度或标签。可以使用plt.NullLocator
和plt.NullFormatter
来完成,如此处所示(见图 33-2)。
In [5]: ax = plt.axes()
rng = np.random.default_rng(1701)
ax.plot(rng.random(50))
ax.grid()
ax.yaxis.set_major_locator(plt.NullLocator())
ax.xaxis.set_major_formatter(plt.NullFormatter())
图 33-2. 隐藏刻度标签(x 轴)和隐藏刻度(y 轴)的图表
我们已删除了 x 轴的标签(但保留了刻度/网格线),并从 y 轴删除了刻度(因此也删除了标签和网格线)。在许多情况下没有刻度可能很有用,例如当您想展示一组图像网格时。例如,考虑包含不同人脸图像的 图 33-3,这是监督机器学习问题中经常使用的示例(例如,参见 第 43 章):
In [6]: fig, ax = plt.subplots(5, 5, figsize=(5, 5))
fig.subplots_adjust(hspace=0, wspace=0)
# Get some face data from Scikit-Learn
from sklearn.datasets import fetch_olivetti_faces
faces = fetch_olivetti_faces().images
for i in range(5):
for j in range(5):
ax[i, j].xaxis.set_major_locator(plt.NullLocator())
ax[i, j].yaxis.set_major_locator(plt.NullLocator())
ax[i, j].imshow(faces[10 * i + j], cmap='binary_r')
图 33-3. 在图像绘制中隐藏刻度
每个图像显示在自己的轴上,并且我们将刻度定位器设置为 null,因为刻度值(在这种情况下是像素数)不传达有关此特定可视化的相关信息。
减少或增加刻度的数量
默认设置的一个常见问题是较小的子图可能会有拥挤的标签。我们可以在这里显示的图网格中看到这一点(见图 33-4)。
In [7]: fig, ax = plt.subplots(4, 4, sharex=True, sharey=True)
图 33-4. 具有拥挤刻度的默认图
特别是对于 x 轴的刻度,数字几乎重叠在一起,使它们很难辨认。调整的一种方法是使用 plt.MaxNLocator
,它允许我们指定将显示的最大刻度数。在给定这个最大数目后,Matplotlib 将使用内部逻辑选择特定的刻度位置(见图 33-5)。
In [8]: # For every axis, set the x and y major locator
for axi in ax.flat:
axi.xaxis.set_major_locator(plt.MaxNLocator(3))
axi.yaxis.set_major_locator(plt.MaxNLocator(3))
fig
图 33-5. 自定义刻度数目
这样做会使事情更加清晰。如果您希望更加精确地控制定期间隔刻度的位置,还可以使用 plt.MultipleLocator
,我们将在以下部分讨论。
特别的刻度格式
Matplotlib 的默认刻度格式可能不尽如人意:它作为一个广泛的默认选项效果不错,但有时你可能想做些不同的事情。考虑这个正弦和余弦曲线的图(见图 33-6)。
In [9]: # Plot a sine and cosine curve
fig, ax = plt.subplots()
x = np.linspace(0, 3 * np.pi, 1000)
ax.plot(x, np.sin(x), lw=3, label='Sine')
ax.plot(x, np.cos(x), lw=3, label='Cosine')
# Set up grid, legend, and limits
ax.grid(True)
ax.legend(frameon=False)
ax.axis('equal')
ax.set_xlim(0, 3 * np.pi);
图 33-6. 具有整数刻度的默认图
注意
全彩色图像在 GitHub 的补充材料 上可用。
这里可能有一些我们想做的改变。首先,对于这些数据来说,在 π 的倍数间隔刻度和网格线更自然。我们可以通过设置 MultipleLocator
来实现这一点,它将刻度定位在我们提供的数字的倍数上。为了保险起见,我们将添加 π/2 和 π/4 的主要和次要刻度(见图 33-7)。
In [10]: ax.xaxis.set_major_locator(plt.MultipleLocator(np.pi / 2))
ax.xaxis.set_minor_locator(plt.MultipleLocator(np.pi / 4))
fig
图 33-7. π/2 和 π/4 的倍数处的刻度
但现在这些刻度标签看起来有点傻:我们可以看到它们是的倍数,但小数表示并不能立即传达这一点。为了解决这个问题,我们可以改变刻度的格式化方式。我们想要做的没有内置的格式化程序,所以我们将使用plt.FuncFormatter
,它接受一个用户定义的函数,可以对刻度输出进行细粒度的控制(见图 33-8)。
In [11]: def format_func(value, tick_number):
# find number of multiples of pi/2
N = int(np.round(2 * value / np.pi))
if N == 0:
return "0"
elif N == 1:
return r"$\pi/2$"
elif N == 2:
return r"$\pi$"
elif N % 2 > 0:
return rf"${N}\pi/2$"
else:
return rf"${N // 2}\pi$"
ax.xaxis.set_major_formatter(plt.FuncFormatter(format_func))
fig
这好多了!请注意,我们已经利用了 Matplotlib 的 LaTeX 支持,通过在字符串中加入美元符号来指定。这对于显示数学符号和公式非常方便:在这种情况下,"$\pi$"
被渲染为希腊字母 。
图 33-8. 自定义标签的刻度
概述格式化程序和定位器
我们已经看到了一些可用的格式化程序和定位器;我将通过列出所有内置定位器选项(表 33-1)和格式化程序选项(表 33-2)来结束本章。有关更多信息,请参阅 docstrings 或 Matplotlib 文档。以下每个都在plt
命名空间中可用。
表 33-1. Matplotlib 定位器选项
定位器类 | 描述 |
---|---|
NullLocator |
没有刻度 |
FixedLocator |
刻度位置固定 |
IndexLocator |
用于索引图的定位器(例如,当x = range(len(y))) 时 |
LinearLocator |
从最小到最大均匀间隔的刻度 |
LogLocator |
对数间隔的刻度,从最小到最大 |
MultipleLocator |
刻度和范围是基数的倍数 |
MaxNLocator |
在良好位置找到最大数量的刻度 |
AutoLocator |
(默认)带有简单默认值的MaxNLocator |
AutoMinorLocator |
次要刻度的定位器 |
表 33-2. Matplotlib 格式化程序选项
格式化程序类 | 描述 |
---|---|
NullFormatter |
刻度上没有标签 |
IndexFormatter |
从标签列表中设置字符串 |
FixedFormatter |
手动设置标签的字符串 |
FuncFormatter |
用户定义的函数设置标签 |
FormatStrFormatter |
使用每个值的格式字符串 |
ScalarFormatter |
标量值的默认格式化程序 |
LogFormatter |
对数轴的默认格式化程序 |
我们将在本书的其余部分看到更多这些示例。
第三十四章:自定义 Matplotlib:配置和样式表
虽然前面章节涵盖的许多主题都涉及逐个调整绘图元素的样式,但 Matplotlib 也提供了机制来一次性调整图表的整体样式。在本章中,我们将介绍一些 Matplotlib 的运行时配置(rc)选项,并查看包含一些漂亮的默认配置集的 stylesheets 功能。
手动绘图定制
在本书的这一部分中,你已经看到如何调整单个绘图设置,使其看起来比默认设置更好看一些。也可以为每个单独的图进行这些自定义。例如,这里是一个相当沉闷的默认直方图,如图 34-1 所示。
In [1]: import matplotlib.pyplot as plt
plt.style.use('classic')
import numpy as np
%matplotlib inline
In [2]: x = np.random.randn(1000)
plt.hist(x);
图 34-1. Matplotlib 默认样式下的直方图
我们可以手动调整它,使其成为一个视觉上更加愉悦的图,如你可以在图 34-2 中看到的那样。
In [3]: # use a gray background
fig = plt.figure(facecolor='white')
ax = plt.axes(facecolor='#E6E6E6')
ax.set_axisbelow(True)
# draw solid white gridlines
plt.grid(color='w', linestyle='solid')
# hide axis spines
for spine in ax.spines.values():
spine.set_visible(False)
# hide top and right ticks
ax.xaxis.tick_bottom()
ax.yaxis.tick_left()
# lighten ticks and labels
ax.tick_params(colors='gray', direction='out')
for tick in ax.get_xticklabels():
tick.set_color('gray')
for tick in ax.get_yticklabels():
tick.set_color('gray')
# control face and edge color of histogram
ax.hist(x, edgecolor='#E6E6E6', color='#EE6666');
图 34-2. 带有手动自定义的直方图
这看起来更好,你可能会认出其灵感来自 R 语言的 ggplot
可视化包。但这需要大量的工作!我们绝对不希望每次创建图表时都进行这些调整。幸运的是,有一种方法可以一次性调整这些默认设置,适用于所有图表。
更改默认设置:rcParams
每次 Matplotlib 加载时,它定义一个运行时配置,包含您创建的每个绘图元素的默认样式。可以随时使用 plt.rc
方便函数调整此配置。让我们看看如何修改 rc 参数,以便我们的默认图表看起来与之前类似。
我们可以使用 plt.rc
函数来更改其中一些设置:
In [4]: from matplotlib import cycler
colors = cycler('color',
['#EE6666', '#3388BB', '#9988DD',
'#EECC55', '#88BB44', '#FFBBBB'])
plt.rc('figure', facecolor='white')
plt.rc('axes', facecolor='#E6E6E6', edgecolor='none',
axisbelow=True, grid=True, prop_cycle=colors)
plt.rc('grid', color='w', linestyle='solid')
plt.rc('xtick', direction='out', color='gray')
plt.rc('ytick', direction='out', color='gray')
plt.rc('patch', edgecolor='#E6E6E6')
plt.rc('lines', linewidth=2)
有了这些设置定义,现在我们可以创建一个图表,并看到我们的设置如何生效(参见图 34-3)。
In [5]: plt.hist(x);
图 34-3. 使用 rc 设置的自定义直方图
让我们看看使用这些 rc 参数的简单线图的外观(参见图 34-4)。
In [6]: for i in range(4):
plt.plot(np.random.rand(10))
图 34-4. 具有自定义样式的线图
对于在屏幕上查看而不是打印的图表,我发现这比默认样式更具美感。如果你对我的审美感觉不同,好消息是你可以调整 rc 参数以适应自己的喜好!可选地,这些设置可以保存在 .matplotlibrc 文件中,你可以在 Matplotlib 文档 中了解更多。
样式表
调整整体图表样式的较新机制是通过 Matplotlib 的 style
模块,其中包括一些默认样式表,以及创建和打包自己的样式的功能。这些样式表的格式类似于之前提到的 .matplotlibrc 文件,但必须以 .mplstyle 扩展名命名。
即使您不打算创建自己的样式,您可能会在内置样式表中找到所需的内容。plt.style.available
包含可用样式的列表——为简洁起见,这里我只列出前五个:
In [7]: plt.style.available[:5]
Out[7]: ['Solarize_Light2', '_classic_test_patch', 'bmh', 'classic',
>'dark_background']
切换样式表的标准方法是调用 style.use
:
plt.style.use('*`stylename`*')
但请记住,这将改变 Python 会话的剩余部分的样式!或者,您可以使用样式上下文管理器,临时设置样式:
with plt.style.context('*`stylename`*'):
make_a_plot()
为了演示这些样式,让我们创建一个函数,用于制作两种基本类型的图表:
In [8]: def hist_and_lines():
np.random.seed(0)
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].hist(np.random.randn(1000))
for i in range(3):
ax[1].plot(np.random.rand(10))
ax[1].legend(['a', 'b', 'c'], loc='lower left')
我们将使用这些样式来探索使用各种内置样式的图表外观。
注意
全彩色图像可在 GitHub 上的补充材料 中找到。
默认风格
Matplotlib 的 default
风格在 2.0 版本中有更新;我们先来看看这个(见 Figure 34-5)。
In [9]: with plt.style.context('default'):
hist_and_lines()
Figure 34-5. Matplotlib 的 default
风格
FiveThiryEight 风格
fivethirtyeight
风格模仿了流行的 FiveThirtyEight 网站 上的图形。正如您在 Figure 34-6 中看到的那样,它以鲜明的颜色、粗线条和透明的坐标轴为特征:
In [10]: with plt.style.context('fivethirtyeight'):
hist_and_lines()
Figure 34-6. fivethirtyeight
风格
ggplot 风格
R 语言中的 ggplot
包是数据科学家中流行的可视化工具。Matplotlib 的 ggplot
风格模仿了该包的默认样式(见 Figure 34-7)。
In [11]: with plt.style.context('ggplot'):
hist_and_lines()
Figure 34-7. ggplot
风格
贝叶斯方法为黑客风格
有一本名为 Probabilistic Programming and Bayesian Methods for Hackers 的简短在线书籍,由 Cameron Davidson-Pilon 撰写,其中使用 Matplotlib 创建的图表,并使用一组漂亮的 rc 参数创建了一致且视觉上吸引人的风格。这种风格在 bmh
样式表中得以再现(见 Figure 34-8)。
In [12]: with plt.style.context('bmh'):
hist_and_lines()
Figure 34-8. bmh
风格
Dark Background 风格
对于在演示中使用的图像,与其使用浅色背景,不如使用深色背景更为有用。dark_background
风格提供了这种选择(见 Figure 34-9)。
In [13]: with plt.style.context('dark_background'):
hist_and_lines()
Figure 34-9. dark_background
风格
灰度风格
你可能会发现自己正在为不接受彩色图的印刷出版物准备图表。对此,grayscale
风格(见图 34-10)可能会很有用。
In [14]: with plt.style.context('grayscale'):
hist_and_lines()
图 34-10. grayscale
风格
Seaborn 风格
Matplotlib 也有几种受 Seaborn 库启发的样式表(详见第三十六章)。我发现这些设置非常不错,通常将它们作为自己数据探索的默认设置(见图 34-11)。
In [15]: with plt.style.context('seaborn-whitegrid'):
hist_and_lines()
图 34-11. seaborn
绘图风格
请花些时间探索内置选项,并找到一个适合你的风格!在本书中,当创建图表时,我通常会使用其中一种或多种风格约定。
第三十五章:Matplotlib 中的三维绘图
Matplotlib 最初仅设计用于二维绘图。在 1.0 版本发布时,一些三维绘图工具建立在 Matplotlib 的二维显示之上,结果是一组便利的(虽然有些受限)用于三维数据可视化的工具。通过导入mplot3d
工具包,可以启用三维绘图,这个工具包已经包含在主 Matplotlib 安装中:
In [1]: from mpl_toolkits import mplot3d
导入此子模块后,可以通过向任何常规坐标轴创建函数传递关键字projection='3d'
来创建三维坐标轴,如下所示(见 Figure 35-1)。
In [2]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
In [3]: fig = plt.figure()
ax = plt.axes(projection='3d')
有了这个三维坐标轴,我们现在可以绘制各种三维图类型。三维绘图是从交互式视图中查看图形而不是静态图像中受益良多的功能之一;请记住,在运行此代码时,要使用交互式图形,可以使用%matplotlib notebook
而不是%matplotlib inline
。
Figure 35-1. 一个空的三维坐标轴
三维点和线
最基本的三维图是由一组(x, y, z)三元组创建的线条或散点图集合。类比之前讨论的常见二维图,可以使用ax.plot3D
和ax.scatter3D
函数创建这些图。这些函数的调用签名几乎与它们的二维对应物完全相同,因此您可以参考第二十六章和第二十七章以获取有关控制输出的更多信息。在这里,我们将绘制一个三角螺旋线,以及一些随机绘制在该线附近的点(见 Figure 35-2)。
In [4]: ax = plt.axes(projection='3d')
# Data for a three-dimensional line
zline = np.linspace(0, 15, 1000)
xline = np.sin(zline)
yline = np.cos(zline)
ax.plot3D(xline, yline, zline, 'gray')
# Data for three-dimensional scattered points
zdata = 15 * np.random.random(100)
xdata = np.sin(zdata) + 0.1 * np.random.randn(100)
ydata = np.cos(zdata) + 0.1 * np.random.randn(100)
ax.scatter3D(xdata, ydata, zdata, c=zdata, cmap='Greens');
Figure 35-2. 三维中的点和线
请注意,散点的透明度已经调整,以便在页面上给人一种深度感。虽然在静态图像中有时难以看到三维效果,但交互式视图可以让您对点的布局有更好的直觉。
三维等高线图
类似于我们在 Chapter 28 中探讨的等高线图,mplot3d
包含使用相同输入创建三维浮雕图的工具。与ax.contour
类似,ax.contour3D
要求所有输入数据都以二维规则网格的形式提供,z数据在每个点进行评估。在这里,我们将展示一个三维正弦函数的等高线图(见 Figure 35-3)。
In [5]: def f(x, y):
return np.sin(np.sqrt(x ** 2 + y ** 2))
x = np.linspace(-6, 6, 30)
y = np.linspace(-6, 6, 30)
X, Y = np.meshgrid(x, y)
Z = f(X, Y)
In [6]: fig = plt.figure()
ax = plt.axes(projection='3d')
ax.contour3D(X, Y, Z, 40, cmap='binary')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z');
Figure 35-3. 一个三维等高线图
有时默认的视角不是最佳的,这时我们可以使用view_init
方法来设置仰角和方位角。在下面的例子中,可视化效果见图 35-4,我们将使用仰角 60 度(即相对于 x-y 平面上方 60 度)和方位角 35 度(即相对于 z 轴逆时针旋转 35 度):
In [7]: ax.view_init(60, 35)
fig
同样地,请注意,当使用 Matplotlib 的交互式后端时,可以通过点击和拖动来实现这种类型的旋转。
图 35-4. 调整三维绘图的视角角度
线框和表面绘图
另外两种适用于网格数据的三维绘图类型是线框图和表面绘图。它们接受值网格并将其投影到指定的三维表面上,可以使得最终的三维形态非常易于可视化。这里有一个使用线框的例子(见图 35-5)。
In [8]: fig = plt.figure()
ax = plt.axes(projection='3d')
ax.plot_wireframe(X, Y, Z)
ax.set_title('wireframe');
图 35-5. 一个线框图
表面绘图类似于线框图,但线框的每个面都是填充多边形。为填充的多边形添加颜色映射可以帮助感知所可视化表面的拓扑结构,正如您在图 35-6 中看到的那样。
In [9]: ax = plt.axes(projection='3d')
ax.plot_surface(X, Y, Z, rstride=1, cstride=1,
cmap='viridis', edgecolor='none')
ax.set_title('surface');
图 35-6. 一个三维表面绘图
尽管表面绘图的值网格需要是二维的,但不一定是矩形的。这里有一个创建部分极坐标网格的例子,当与surface3D
绘图结合使用时,可以为我们提供所可视化函数的一个切片(见图 35-7)。
In [10]: r = np.linspace(0, 6, 20)
theta = np.linspace(-0.9 * np.pi, 0.8 * np.pi, 40)
r, theta = np.meshgrid(r, theta)
X = r * np.sin(theta)
Y = r * np.cos(theta)
Z = f(X, Y)
ax = plt.axes(projection='3d')
ax.plot_surface(X, Y, Z, rstride=1, cstride=1,
cmap='viridis', edgecolor='none');
图 35-7. 一个极坐标表面绘图
表面三角剖分
对于某些应用程序,前述例程需要均匀采样的网格太过限制。在这些情况下,基于三角剖分的绘图就很有用了。如果我们不是从笛卡尔或极坐标网格中均匀绘制,而是有一组随机绘制呢?
In [11]: theta = 2 * np.pi * np.random.random(1000)
r = 6 * np.random.random(1000)
x = np.ravel(r * np.sin(theta))
y = np.ravel(r * np.cos(theta))
z = f(x, y)
我们可以创建一个散点图来了解我们正在抽样的表面,如图 35-8 所示。
In [12]: ax = plt.axes(projection='3d')
ax.scatter(x, y, z, c=z, cmap='viridis', linewidth=0.5);
图 35-8. 一个三维采样表面
这个点云留下了许多问题。在这种情况下帮助我们的函数是ax.plot_trisurf
,它通过首先在相邻点之间找到一组三角形来创建表面(请记住这里的x
、y
和z
是一维数组);结果如图 35-9 所示(见图 35-9):
In [13]: ax = plt.axes(projection='3d')
ax.plot_trisurf(x, y, z,
cmap='viridis', edgecolor='none');
结果当然不像使用网格绘制时那么干净,但这种三角剖分的灵活性允许一些非常有趣的三维绘图。例如,实际上可以使用这种方法绘制一个三维莫比乌斯带,我们接下来会看到。
第 35-9 图。一个三角形表面绘图
示例:可视化莫比乌斯带
一个莫比乌斯带类似于一条纸条在环上粘贴成一个半扭曲的带子,结果是一个只有一个面的对象!在这里,我们将使用 Matplotlib 的三维工具可视化这样的对象。创建莫比乌斯带的关键是考虑它的参数化:它是一个二维带子,所以我们需要两个内在维度。让我们称之为 ,它在环周围从 0 到 ,以及 ,它在带子宽度上从 -1 到 1:
In [14]: theta = np.linspace(0, 2 * np.pi, 30)
w = np.linspace(-0.25, 0.25, 8)
w, theta = np.meshgrid(w, theta)
现在从这个参数化中,我们必须确定嵌入带的 (x, y, z) 位置。
思考一下,我们可能会意识到有两个旋转正在发生:一个是环绕其中心的位置旋转(我们称之为 ),而另一个是带子围绕其轴线的扭曲(我们将其称为 )。对于一个莫比乌斯带,我们必须使带子在完整环的过程中进行半扭曲,即 :
In [15]: phi = 0.5 * theta
现在我们利用我们对三角函数的记忆来推导三维嵌入。我们定义 ,每个点到中心的距离,并使用它来找到嵌入的 坐标:
In [16]: # radius in x-y plane
r = 1 + w * np.cos(phi)
x = np.ravel(r * np.cos(theta))
y = np.ravel(r * np.sin(theta))
z = np.ravel(w * np.sin(phi))
最后,为了绘制这个对象,我们必须确保三角剖分是正确的。最好的方法是在基本参数化内定义三角剖分,然后让 Matplotlib 将这个三角剖分投影到莫比乌斯带的三维空间中。可以通过以下方式实现这一点(见 第 35-10 图)。
In [17]: # triangulate in the underlying parametrization
from matplotlib.tri import Triangulation
tri = Triangulation(np.ravel(w), np.ravel(theta))
ax = plt.axes(projection='3d')
ax.plot_trisurf(x, y, z, triangles=tri.triangles,
cmap='Greys', linewidths=0.2);
ax.set_xlim(-1, 1); ax.set_ylim(-1, 1); ax.set_zlim(-1, 1)
ax.axis('off');
第 35-10 图。可视化莫比乌斯带
结合所有这些技术,可以在 Matplotlib 中创建和显示各种三维对象和图案。
第三十六章:可视化与 Seaborn
Matplotlib 已经是 Python 科学可视化的核心工具数十年了,但即使是忠实的用户也会承认它经常留下很多不足之处。关于 Matplotlib 经常提到的几个抱怨有:
-
一个常见的早期抱怨,现在已经过时:在 2.0 版本之前,Matplotlib 的颜色和样式默认值有时很差,并显得过时。
-
Matplotlib 的 API 相对较低级。虽然可以进行复杂的统计可视化,但通常需要大量的样板代码。
-
Matplotlib 比 Pandas 早十多年,因此不设计用于与 Pandas 的
DataFrame
对象一起使用。为了可视化DataFrame
中的数据,必须提取每个Series
并经常将它们连接成正确的格式。更好的是有一个可以智能使用DataFrame
标签进行绘图的绘图库。
解决这些问题的一个答案是Seaborn。Seaborn 在 Matplotlib 之上提供了一个 API,提供了合理的绘图样式和颜色默认设置,定义了常见统计绘图类型的简单高级函数,并与 Pandas 提供的功能集成。
公平地说,Matplotlib 团队已经适应了不断变化的环境:它添加了在第三十四章讨论的plt.style
工具,并且 Matplotlib 开始更无缝地处理 Pandas 数据。但基于刚讨论的所有原因,Seaborn 仍然是一个有用的附加组件。
按照惯例,Seaborn 通常被导入为sns
:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd
sns.set() # seaborn's method to set its chart style
注意
全彩色图像可以在GitHub 上的补充材料中找到。
探索 Seaborn 绘图
Seaborn 的主要思想是提供高级命令来创建各种对统计数据探索有用的绘图类型,甚至一些统计模型拟合。
让我们看看 Seaborn 中提供的几个数据集和绘图类型。请注意,所有以下内容都可以使用原始的 Matplotlib 命令完成(实际上,Seaborn 在幕后确实这样做),但 Seaborn 的 API 更加方便。
直方图,KDE 和密度
在统计数据可视化中,您通常只想绘制变量的直方图和联合分布。我们已经看到在 Matplotlib 中这相对比较简单(见图 36-1)。
In [2]: data = np.random.multivariate_normal([0, 0], [[5, 2], [2, 2]], size=2000)
data = pd.DataFrame(data, columns=['x', 'y'])
for col in 'xy':
plt.hist(data[col], density=True, alpha=0.5)
图 36-1 直方图可视化分布
不仅仅是提供直方图作为可视化输出,我们还可以使用核密度估计获得分布的平滑估计(在第二十八章介绍),Seaborn 通过sns.kdeplot
来实现(参见图 36-2)。
In [3]: sns.kdeplot(data=data, shade=True);
图 36-2 核密度估计可视化分布
如果我们将x
和y
列传递给kdeplot
,我们将得到一个二维可视化的联合密度(见图 36-3)。
In [4]: sns.kdeplot(data=data, x='x', y='y');
图 36-3. 一个二维核密度图
我们可以使用sns.jointplot
一起查看联合分布和边缘分布,稍后在本章中我们将进一步探讨。
对角线图
当你将联合图推广到更大维度的数据集时,最终会得到对角线图。当您希望将所有值的所有对组合在一起时,这对于探索多维数据之间的相关性非常有用。
我们将使用众所周知的鸢尾花数据集演示这一点,该数据集列出了三种鸢尾花物种的花瓣和萼片的测量值:
In [5]: iris = sns.load_dataset("iris")
iris.head()
Out[5]: sepal_length sepal_width petal_length petal_width species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
2 4.7 3.2 1.3 0.2 setosa
3 4.6 3.1 1.5 0.2 setosa
4 5.0 3.6 1.4 0.2 setosa
将样本之间的多维关系可视化就像调用sns.pairplot
一样简单(见图 36-4)。
In [6]: sns.pairplot(iris, hue='species', height=2.5);
图 36-4. 显示四个变量之间关系的对角线图
分面直方图
有时查看数据的最佳方式是通过子集的直方图,如图 36-5 所示。Seaborn 的FacetGrid
使得这变得简单。我们将查看一些数据,显示餐厅员工根据各种指标数据获得的小费金额:^(1)
In [7]: tips = sns.load_dataset('tips')
tips.head()
Out[7]: total_bill tip sex smoker day time size
0 16.99 1.01 Female No Sun Dinner 2
1 10.34 1.66 Male No Sun Dinner 3
2 21.01 3.50 Male No Sun Dinner 3
3 23.68 3.31 Male No Sun Dinner 2
4 24.59 3.61 Female No Sun Dinner 4
In [8]: tips['tip_pct'] = 100 * tips['tip'] / tips['total_bill']
grid = sns.FacetGrid(tips, row="sex", col="time", margin_titles=True)
grid.map(plt.hist, "tip_pct", bins=np.linspace(0, 40, 15));
图 36-5. 一个分面直方图的示例
分面图为我们提供了一些关于数据集的快速见解:例如,我们看到它包含的关于晚餐时间男服务员的数据远远多于其他类别,并且典型的小费金额似乎在约 10%到 20%之间,两端都有一些异常值。
分类图
分类图也可以用于这种类型的可视化。这允许您查看由任何其他参数定义的箱中参数的分布,如图 36-6 所示。
In [9]: with sns.axes_style(style='ticks'):
g = sns.catplot(x="day", y="total_bill", hue="sex",
data=tips, kind="box")
g.set_axis_labels("Day", "Total Bill");
图 36-6. 一个因子图的示例,比较给定各种离散因子的分布
联合分布
类似于我们之前看到的对角线图,我们可以使用sns.jointplot
显示不同数据集之间的联合分布,以及相关的边缘分布(见图 36-7)。
In [10]: with sns.axes_style('white'):
sns.jointplot(x="total_bill", y="tip", data=tips, kind='hex')
图 36-7. 一个联合分布图
联合图甚至可以进行一些自动的核密度估计和回归,如图 36-8 所示。
In [11]: sns.jointplot(x="total_bill", y="tip", data=tips, kind='reg');
图 36-8. 带有回归拟合的联合分布图
条形图
可以使用sns.factorplot
来绘制时间序列。在下面的示例中,我们将使用我们在第二十章中首次看到的 Planets 数据集;参见图 36-9 的结果。
In [12]: planets = sns.load_dataset('planets')
planets.head()
Out[12]: method number orbital_period mass distance year
0 Radial Velocity 1 269.300 7.10 77.40 2006
1 Radial Velocity 1 874.774 2.21 56.95 2008
2 Radial Velocity 1 763.000 2.60 19.84 2011
3 Radial Velocity 1 326.030 19.40 110.62 2007
4 Radial Velocity 1 516.220 10.50 119.47 2009
In [13]: with sns.axes_style('white'):
g = sns.catplot(x="year", data=planets, aspect=2,
kind="count", color='steelblue')
g.set_xticklabels(step=5)
图 36-9。柱状图作为因子图的特例
通过查看每个行星的发现方法,我们可以更多地了解这些行星(参见图 36-10)。
In [14]: with sns.axes_style('white'):
g = sns.catplot(x="year", data=planets, aspect=4.0, kind='count',
hue='method', order=range(2001, 2015))
g.set_ylabels('Number of Planets Discovered')
图 36-10。按年份和类型发现的行星数量
有关使用 Seaborn 绘图的更多信息,请参见Seaborn 文档,特别是示例画廊。
示例:探索马拉松完成时间
在这里,我们将使用 Seaborn 来帮助可视化和理解马拉松的完成结果。^(2) 我从网络上的来源爬取了数据,进行了汇总并删除了任何识别信息,并将其放在了 GitHub 上,可以下载。^(3)
我们将从下载数据并加载到 Pandas 开始:
In [15]: # url = ('https://raw.githubusercontent.com/jakevdp/'
# 'marathon-data/master/marathon-data.csv')
# !cd data && curl -O {url}
In [16]: data = pd.read_csv('data/marathon-data.csv')
data.head()
Out[16]: age gender split final
0 33 M 01:05:38 02:08:51
1 32 M 01:06:26 02:09:28
2 31 M 01:06:49 02:10:42
3 38 M 01:06:16 02:13:45
4 31 M 01:06:32 02:13:59
请注意,Pandas 将时间列加载为 Python 字符串(类型为object
);我们可以通过查看DataFrame
的dtypes
属性来看到这一点:
In [17]: data.dtypes
Out[17]: age int64
gender object
split object
final object
dtype: object
让我们通过为时间提供一个转换器来修复这个问题。
In [18]: import datetime
def convert_time(s):
h, m, s = map(int, s.split(':'))
return datetime.timedelta(hours=h, minutes=m, seconds=s)
data = pd.read_csv('data/marathon-data.csv',
converters={'split':convert_time, 'final':convert_time})
data.head()
Out[18]: age gender split final
0 33 M 0 days 01:05:38 0 days 02:08:51
1 32 M 0 days 01:06:26 0 days 02:09:28
2 31 M 0 days 01:06:49 0 days 02:10:42
3 38 M 0 days 01:06:16 0 days 02:13:45
4 31 M 0 days 01:06:32 0 days 02:13:59
In [19]: data.dtypes
Out[19]: age int64
gender object
split timedelta64[ns]
final timedelta64[ns]
dtype: object
这将使时间数据更容易处理。为了我们的 Seaborn 绘图实用工具的目的,让我们接下来添加列,以秒为单位给出时间:
In [20]: data['split_sec'] = data['split'].view(int) / 1E9
data['final_sec'] = data['final'].view(int) / 1E9
data.head()
Out[20]: age gender split final split_sec final_sec
0 33 M 0 days 01:05:38 0 days 02:08:51 3938.0 7731.0
1 32 M 0 days 01:06:26 0 days 02:09:28 3986.0 7768.0
2 31 M 0 days 01:06:49 0 days 02:10:42 4009.0 7842.0
3 38 M 0 days 01:06:16 0 days 02:13:45 3976.0 8025.0
4 31 M 0 days 01:06:32 0 days 02:13:59 3992.0 8039.0
为了了解数据的外观,我们可以在数据上绘制一个jointplot
;图 36-11 显示了结果。
In [21]: with sns.axes_style('white'):
g = sns.jointplot(x='split_sec', y='final_sec', data=data, kind='hex')
g.ax_joint.plot(np.linspace(4000, 16000),
np.linspace(8000, 32000), ':k')
虚线显示了如果某人以完全稳定的速度跑完马拉松,其时间会在哪里。分布高于此线表明(正如您所料),大多数人在马拉松比赛过程中放慢了速度。如果您曾经参加过竞争性比赛,您会知道那些在比赛的第二半段跑得更快的人被称为“负分裂”比赛。
图 36-11。半马拉松第一半分裂与全马拉松完成时间之间的关系
让我们在数据中创建另一列,即分裂比例,用于衡量每位选手进行负分裂或正分裂比赛的程度。
In [22]: data['split_frac'] = 1 - 2 * data['split_sec'] / data['final_sec']
data.head()
Out[22]: age gender split final split_sec final_sec \
0 33 M 0 days 01:05:38 0 days 02:08:51 3938.0 7731.0
1 32 M 0 days 01:06:26 0 days 02:09:28 3986.0 7768.0
2 31 M 0 days 01:06:49 0 days 02:10:42 4009.0 7842.0
3 38 M 0 days 01:06:16 0 days 02:13:45 3976.0 8025.0
4 31 M 0 days 01:06:32 0 days 02:13:59 3992.0 8039.0
split_frac
0 -0.018756
1 -0.026262
2 -0.022443
3 0.009097
4 0.006842
在这个分裂差小于零的地方,这个人通过这个比例进行了负分裂比赛。让我们做一个这个分裂比例的分布图(参见图 36-12)。
In [23]: sns.displot(data['split_frac'], kde=False)
plt.axvline(0, color="k", linestyle="--");
图 36-12。分裂比例的分布;0.0 表示在相同时间内完成了第一半和第二半马拉松的跑步者
In [24]: sum(data.split_frac < 0)
Out[24]: 251
在将近 40,000 名参与者中,只有 250 人实现了负分裂的马拉松。
让我们看看这种分裂比例与其他变量是否有任何相关性。我们将使用PairGrid
来完成这个任务,它会绘制所有这些相关性的图表(见图 36-13)。
In [25]: g = sns.PairGrid(data, vars=['age', 'split_sec', 'final_sec', 'split_frac'],
hue='gender', palette='RdBu_r')
g.map(plt.scatter, alpha=0.8)
g.add_legend();
图 36-13. 马拉松数据集内部量之间的关系
分裂比例看起来与年龄没有特别相关性,但与最终时间相关:跑得更快的人往往在马拉松中有更接近均匀分裂的趋势。让我们来看一下按性别分隔的分裂比例直方图,显示在图 36-14 中。
In [26]: sns.kdeplot(data.split_frac[data.gender=='M'], label='men', shade=True)
sns.kdeplot(data.split_frac[data.gender=='W'], label='women', shade=True)
plt.xlabel('split_frac');
图 36-14. 按性别分布的分裂比例
这里有趣的是,有很多男性比女性更接近均匀分裂!在男性和女性中间几乎呈双峰分布。让我们看看是否可以通过年龄的函数来解析正在发生的事情。
比较分布的一个好方法是使用小提琴图,显示在图 36-15 中。
In [27]: sns.violinplot(x="gender", y="split_frac", data=data,
palette=["lightblue", "lightpink"]);
图 36-15. 显示按性别分裂比例的小提琴图
让我们深入一点,将这些小提琴图作为年龄的函数进行比较(见图 36-16)。我们将从创建一个新的列开始,该列指定每个人所在的年龄范围,按十年计算:
In [28]: data['age_dec'] = data.age.map(lambda age: 10 * (age // 10))
data.head()
Out[28]: age gender split final split_sec final_sec \
0 33 M 0 days 01:05:38 0 days 02:08:51 3938.0 7731.0
1 32 M 0 days 01:06:26 0 days 02:09:28 3986.0 7768.0
2 31 M 0 days 01:06:49 0 days 02:10:42 4009.0 7842.0
3 38 M 0 days 01:06:16 0 days 02:13:45 3976.0 8025.0
4 31 M 0 days 01:06:32 0 days 02:13:59 3992.0 8039.0
split_frac age_dec
0 -0.018756 30
1 -0.026262 30
2 -0.022443 30
3 0.009097 30
4 0.006842 30
In [29]: men = (data.gender == 'M')
women = (data.gender == 'W')
with sns.axes_style(style=None):
sns.violinplot(x="age_dec", y="split_frac", hue="gender", data=data,
split=True, inner="quartile",
palette=["lightblue", "lightpink"]);
图 36-16. 显示按性别和年龄分裂比例的小提琴图
我们可以看到男性和女性之间分布的不同之处:20 到 50 岁男性的分裂分布向较低分裂过度密集,而与同龄的女性(或者任何年龄段的女性)相比如此。
同样令人惊讶的是,80 岁的女性似乎在分裂时间方面表现出色,尽管这可能是一个小数量效应,因为该范围内的参与者寥寥无几:
In [30]: (data.age > 80).sum()
Out[30]: 7
回到有负分裂的男性:这些跑步者是谁?这种分裂比例是否与快速完成相关联?我们可以轻松地绘制这个图表。我们将使用regplot
,它会自动适应数据的线性回归模型(见图 36-17)。
In [31]: g = sns.lmplot(x='final_sec', y='split_frac', col='gender', data=data,
markers=".", scatter_kws=dict(color='c'))
g.map(plt.axhline, y=0.0, color="k", ls=":");
图 36-17. 按性别比较分裂比例与完成时间
显然,无论是男性还是女性,分裂较快的人往往是在大约 15,000 秒内或约 4 小时内完成的更快的跑步者。比这慢的人很少有快速的第二分裂。
进一步的资源
一本书的一部分永远无法涵盖 Matplotlib 中所有可用的特性和绘图类型。与其他包一样,IPython 的 Tab 键补全和帮助功能(参见 第一章)在探索 Matplotlib 的 API 时非常有帮助。此外,Matplotlib 的 在线文档 是一个有用的参考。特别是查看 Matplotlib 图库,展示了数百种不同的绘图样式缩略图,每个缩略图都链接到一个页面,展示了生成它的 Python 代码片段。这使你能够视觉检查和学习各种不同的绘图风格和可视化技术。
对于 Matplotlib 的书籍级处理,我推荐 Interactive Applications Using Matplotlib(Packt),作者是 Matplotlib 核心开发者 Ben Root。
其他 Python 可视化库
尽管 Matplotlib 是最显著的 Python 可视化库,但还有其他更现代的工具也值得探索。我将在这里简要提及其中一些:
-
Bokeh 是一个 JavaScript 可视化库,具有 Python 前端,创建高度交互式的可视化,能够处理非常大和/或流式数据集。
-
Plotly 是 Plotly 公司的代表性开源产品,与 Bokeh 类似。它正在积极开发中,并提供各种交互式图表类型。
-
HoloViews 是一个更为声明性的统一 API,用于在多种后端生成图表,包括 Bokeh 和 Matplotlib。
-
Vega 和 Vega-Lite 是声明性的图形表示,是多年数据可视化和交互研究的成果。参考渲染实现为 JavaScript,而 Altair package 提供了生成这些图表的 Python API。
Python 世界中的可视化景观在不断发展,我预计这份列表在本书出版时可能已经过时。此外,由于 Python 在许多领域中被广泛使用,你会发现许多为更具体用例构建的其他可视化工具。要跟踪所有这些工具可能有些困难,但了解这些广泛的可视化工具的好资源是 PyViz,一个开放的、社区驱动的网站,包含许多不同可视化工具的教程和示例。
^(1) 本节使用的餐厅员工数据将员工分为两性:女性和男性。生物性别并非二元的,但以下讨论和可视化受到此数据的限制。
^(2) 本节中使用的马拉松数据将跑步者分为两个性别:男性和女性。虽然性别是一个光谱,但以下讨论和可视化使用这个二元性别,因为它们依赖于数据。
^(3) 如果您有兴趣使用 Python 进行网络抓取,我推荐由 O'Reilly 的 Ryan Mitchell 撰写的 Web Scraping with Python。
第五部分:机器学习
这一最终部分是对机器学习这一非常广泛的主题的介绍,主要通过 Python 的Scikit-Learn 包来进行。您可以将机器学习视为一类算法,允许程序检测数据集中的特定模式,从而“学习”并从中推断。这并不意味着要对机器学习领域进行全面介绍;这是一个庞大的主题,需要比我们这里采取的更加技术化的方法。它也不是 Scikit-Learn 包使用的全面手册(对此,您可以参考“更多机器学习资源”中列出的资源)。相反,这里的目标是:
-
引入机器学习的基本词汇和概念
-
引入 Scikit-Learn API 并展示其使用示例
-
深入研究几种更重要的经典机器学习方法的细节,培养对它们如何工作以及何时何地适用的直觉
这部分内容大部分源自我在 PyCon、SciPy、PyData 及其他会议上多次举办的 Scikit-Learn 教程和研讨会。以下页面的任何清晰度可能都归功于多年来参与这些材料反馈的许多研讨会参与者和共同指导!
第三十七章:什么是机器学习?
在我们深入了解几种机器学习方法的细节之前,让我们先来看看机器学习的定义及其非定义部分。机器学习通常被归类为人工智能的一个子领域,但我发现这种分类可能会产生误导。机器学习的研究确实起源于这一背景的研究,但在数据科学应用机器学习方法时,将机器学习视为一种构建数据模型的手段更为有帮助。
在这种情况下,“学习”进入战场时,我们为这些模型提供可调参数,这些参数可以根据观察到的数据进行调整;通过这种方式,程序可以被认为是从数据中“学习”。一旦这些模型适应于先前看到的数据,它们就可以用于预测和理解新观察到的数据的各个方面。关于这种基于数学模型的“学习”与人脑展现的“学习”在多大程度上相似,我将留给读者更多哲学的探讨。
理解机器学习中的问题设置对有效使用这些工具至关重要,因此我们将从这里讨论的一些方法类型的广泛分类开始。
注
本章中所有图表均基于实际机器学习计算生成;其背后的代码可以在在线附录中找到。
机器学习的分类
机器学习可以分为两种主要类型:监督学习和无监督学习。
监督学习涉及对数据的测量特征与与数据相关的一些标签之间关系的建模;确定了此模型后,它可以用于对新的未知数据应用标签。有时这进一步细分为分类任务和回归任务:在分类中,标签是离散类别,而在回归中,标签是连续数量。您将在以下部分看到这两种类型的监督学习的例子。
无监督学习涉及对数据集的特征进行建模,而不参考任何标签。这些模型包括诸如聚类和降维等任务。聚类算法识别数据的不同组,而降维算法寻找数据更简洁的表示。您也将在以下部分看到这两种类型的无监督学习的例子。
此外,还有所谓的半监督学习方法,介于监督学习和无监督学习之间。半监督学习方法在只有不完整标签可用时通常很有用。
机器学习应用的定性例子
为了使这些想法更具体,让我们来看一些非常简单的机器学习任务示例。这些示例旨在给出本书这部分将要讨论的机器学习任务类型的直观非量化概述。在后面的章节中,我们将更深入地讨论特定的模型以及它们的使用方式。如果想预览这些更技术性的方面,可以在在线附录中找到生成这些图表的 Python 源代码。
分类:预测离散标签
首先,我们来看一个简单的分类任务,我们会获得一组带标签的点,并希望利用这些点来对一些未标记的点进行分类。
想象一下我们拥有的数据显示在图 37-1 中。这些数据是二维的:也就是说,每个点有两个特征,由点在平面上的(x,y)位置表示。此外,每个点有两个类别标签之一,这里由点的颜色表示。通过这些特征和标签,我们希望创建一个模型,让我们能够决定一个新点应该被标记为“蓝色”还是“红色”。
图 37-1. 用于分类的简单数据集
对于这样的分类任务,有许多可能的模型,但我们将从一个非常简单的模型开始。我们假设这两组数据可以通过在它们之间绘制一条直线来分开,这样,线的两边的点都属于同一组。这里的模型是声明“一条直线分隔类别”的定量版本,而模型参数则是描述该线在我们的数据中位置和方向的特定数值。这些模型参数的最佳值是从数据中学习得来的(这就是机器学习中的“学习”),通常称为训练模型。
图 37-2 展示了这个数据的训练模型的视觉表示。
图 37-2. 一个简单的分类模型
现在这个模型已经被训练好了,它可以推广到新的未标记数据上。换句话说,我们可以拿到新的数据集,通过这条线进行划分,并根据这个模型为新点分配标签(参见图 37-3)。这个阶段通常被称为预测。
图 37-3. 将分类模型应用于新数据
这是机器学习分类任务的基本概念,其中“分类”表示数据具有离散的类标签。乍一看,这可能显得微不足道:很容易看到我们的数据并绘制这样的分界线来完成分类。然而,机器学习方法的好处在于它能够推广到更大的数据集和更多的维度。例如,这类似于电子邮件自动垃圾邮件检测的任务。在这种情况下,我们可能会使用以下特征和标签:
-
特征 1, 特征 2 等 重要单词或短语的标准化计数(如“伟哥”,“延长保修”等)
-
标签 “垃圾邮件”或“非垃圾邮件”
对于训练集,这些标签可能是通过对一小部分代表性电子邮件的个别检查来确定的;对于其余的电子邮件,标签将使用模型确定。对于足够训练良好且特征构造良好的分类算法(通常有数千或数百万个单词或短语),这种方法非常有效。我们将在第四十一章中看到一个基于文本的分类的示例。
我们将详细讨论的一些重要分类算法包括高斯朴素贝叶斯(见第四十一章)、支持向量机(见第四十三章)和随机森林分类(见第四十四章)。
回归:预测连续标签
与分类算法的离散标签相比,我们接下来将看一个简单的回归任务,其中标签是连续的量。
考虑图 37-4 中显示的数据,其中包含一组具有连续标签的点。
图 37-4. 用于回归的简单数据集
就像分类示例一样,我们有二维数据:也就是说,每个数据点有两个描述特征。每个点的颜色代表该点的连续标签。
我们可以使用多种可能的回归模型来处理这类数据,但在这里我们将使用简单的线性回归模型来预测这些点。这个简单的模型假设,如果我们将标签视为第三个空间维度,我们可以将一个平面拟合到数据中。这是对将两个坐标数据拟合一条直线这一已知问题的更高级的泛化。
我们可以将这种设置视觉化,如图 37-5 所示。
图 37-5. 回归数据的三维视图
注意,这里的 特征 1–特征 2 平面与 Figure 37-4 中的二维图是相同的;然而,在这种情况下,我们通过颜色和三维轴位置表示了标签。从这个视角看,通过这三维数据拟合平面来预测任何输入参数的预期标签似乎是合理的。回到二维投影,当我们拟合这样一个平面时,我们得到了 Figure 37-6 中显示的结果。
Figure 37-6. 回归模型的表示
这个拟合平面为我们提供了预测新点标签所需的信息。从视觉上看,我们找到了在 Figure 37-7 中展示的结果。
Figure 37-7. 应用回归模型到新数据上
与分类示例一样,这个任务在低维度下可能看起来微不足道。但这些方法的力量在于它们可以在具有许多特征的数据中直接应用和评估。例如,这类似于通过望远镜观测到的星系的距离任务——在这种情况下,我们可能使用以下特征和标签:
-
特征 1、特征 2 等 每个星系在几个波长或颜色之一上的亮度
-
标签 星系的距离或红移
对于其中一小部分星系的距离可能通过独立的(通常更昂贵或复杂)观测来确定。然后可以使用适当的回归模型估计其余星系的距离,而无需在整个集合上使用更昂贵的观测。在天文学界,这被称为“光度红移”问题。
我们将讨论的一些重要回归算法包括线性回归(参见 Chapter 42)、支持向量机(参见 Chapter 43)和随机森林回归(参见 Chapter 44)。
聚类:推断未标记数据的标签
我们刚刚看到的分类和回归示例都是监督学习算法的例子,我们试图建立一个可以预测新数据标签的模型。无监督学习涉及描述数据而不涉及任何已知标签的模型。
无监督学习的一个常见情况是“聚类”,其中数据自动分配给一些离散的组。例如,我们可能有一些类似于 Figure 37-8 中所示的二维数据。
Figure 37-8. 聚类示例数据
通过目测,很明显每个点都属于一个明显的组。基于数据的内在结构,聚类模型将确定哪些点是相关的。使用非常快速和直观的k-means 算法(参见第四十七章),我们找到如图 37-9 所示的聚类。
图 37-9. 使用 k-means 聚类模型标记的数据
k-means 算法适配了一个模型,包括k个聚类中心;最优的中心被认为是最小化每个点到其分配中心距离的那些中心。再次强调,在二维数据中这可能看起来像是一个微不足道的练习,但随着数据变得更大更复杂,这样的聚类算法可以继续从数据集中提取有用信息。
我们将在第四十七章更深入地讨论k-means 算法。其他重要的聚类算法包括高斯混合模型(参见第四十八章)和谱聚类(参见Scikit-Learn 的聚类文档)。
降维:推断未标记数据的结构
降维是无监督算法的另一个示例,其中标签或其他信息是从数据集本身的结构中推断出来的。降维比我们之前看过的例子更加抽象,但通常它试图提取数据的一些低维表示,以某种方式保留完整数据集的相关特性。不同的降维例程以不同的方式衡量这些相关特性,正如我们将在第四十六章中看到的那样。
例如,考虑显示在图 37-10 中的数据。
图 37-10. 降维的示例数据
从视觉上看,很明显这些数据中存在一些结构:它们来自一个一维线,在二维空间内以螺旋的方式排列。从某种意义上说,你可以说这些数据“本质上”只有一维,尽管这些一维数据嵌入在二维空间中。在这种情况下,一个合适的降维模型应该对这种非线性嵌入结构敏感,并能够检测到这种较低维度的表示。
图 37-11 展示了 Isomap 算法的结果可视化,这是一种能够实现这一目标的流形学习算法。
请注意,颜色(代表提取的一维潜变量)沿螺旋线均匀变化,这表明算法确实检测到了我们肉眼看到的结构。与前面的例子一样,降维算法在高维情况下的作用变得更加明显。例如,我们可能希望可视化一个具有 100 或 1000 个特征的数据集中的重要关系。可视化 1000 维数据是一项挑战,我们可以通过使用降维技术将数据降低到 2 或 3 维来使其更易管理。
我们将讨论一些重要的降维算法,包括主成分分析(参见第四十五章)和各种流形学习算法,包括 Isomap 和局部线性嵌入(参见第四十六章)。
图 37-11. 通过降维学习得到的带标签数据
总结
在这里,我们看到了一些基本的机器学习方法的简单示例。不用说,有许多重要的实际细节我们没有详细讨论,但本章旨在让您了解机器学习方法可以解决哪些类型的问题的基本概念。
简而言之,我们看到了以下内容:
-
监督学习:基于标记的训练数据可以预测标签的模型。
-
分类:预测两个或更多离散类别标签的模型
-
回归:预测连续标签的模型
-
-
无监督学习:识别无标签数据中结构的模型
-
聚类:检测并识别数据中不同组的模型
-
降维:检测并识别高维数据中的低维结构的模型
-
在接下来的章节中,我们将深入探讨这些类别,并看到这些概念在哪些场景中更加有用。
第三十八章:介绍 Scikit-Learn
几个 Python 库提供了一系列机器学习算法的可靠实现。其中最著名的之一是Scikit-Learn,它提供了大量常见算法的高效版本。Scikit-Learn 具有清晰、统一和简化的 API,以及非常有用和完整的文档。统一性的好处在于,一旦你理解了 Scikit-Learn 一种类型模型的基本用法和语法,切换到新模型或算法就变得简单。
本章概述了 Scikit-Learn API。对这些 API 元素的扎实理解将为理解以下章节中关于机器学习算法和方法的深入实践讨论奠定基础。
我们将从 Scikit-Learn 中的数据表示开始讲起,然后深入到估计器 API,最后通过一个更有趣的示例,使用这些工具探索一组手写数字的图像。
Scikit-Learn 中的数据表示
机器学习是关于从数据中创建模型的;因此,我们将从讨论如何表示数据开始。在 Scikit-Learn 中理解数据的最佳方式是以表格的形式思考。
基本表格是一个二维的数据网格,其中行代表数据集中的单个元素,列代表与这些元素的每一个相关的数量。例如,考虑 1936 年由罗纳德·费舍尔著名分析的鸢尾花数据集。我们可以使用Seaborn 库以 Pandas DataFrame
的形式下载这个数据集,并查看前几个条目:
In [1]: import seaborn as sns
iris = sns.load_dataset('iris')
iris.head()
Out[1]: sepal_length sepal_width petal_length petal_width species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
2 4.7 3.2 1.3 0.2 setosa
3 4.6 3.1 1.5 0.2 setosa
4 5.0 3.6 1.4 0.2 setosa
这里的每一行数据都指的是单个观察到的花朵,行数是数据集中花朵的总数。通常,我们将矩阵的行称为样本,行数称为n_samples
。
同样,数据的每一列都指代描述每个样本的特定定量信息。通常,我们将矩阵的列称为特征,列数称为n_features
。
特征矩阵
表格布局清晰地表明信息可以被视为二维数字数组或矩阵,我们将其称为特征矩阵。按照惯例,这个矩阵通常存储在名为X
的变量中。特征矩阵被假定为二维的,形状为[n_samples, n_features]
,最常见的情况是包含在 NumPy 数组或 Pandas DataFrame
中,尽管一些 Scikit-Learn 模型也接受 SciPy 稀疏矩阵。
样本(即行)始终指代数据集描述的单个对象。例如,样本可以表示一朵花、一个人、一个文档、一个图像、一个声音文件、一个视频、一个天文物体,或者任何你可以用一组定量测量来描述的东西。
特征(即列)始终指的是以定量方式描述每个样本的不同观察结果。特征通常是实值,但在某些情况下可能是布尔值或离散值。
目标数组
除了特征矩阵 X
外,我们通常还使用一个标签或目标数组,按照惯例,我们通常称之为 y
。目标数组通常是一维的,长度为 n_samples
,通常包含在一个 NumPy 数组或 Pandas Series
中。目标数组可以具有连续的数值,也可以是离散的类别/标签。虽然一些 Scikit-Learn 估计器确实处理多个目标值的情况,形式为二维 [n_samples, n_targets]
目标数组,但我们主要处理的是一维目标数组的常见情况。
一个常见的困惑点是目标数组与其他特征列的区别。目标数组的显著特征是它通常是我们希望从特征中预测出来的数量:在统计术语中,它是因变量。例如,考虑到前述数据,我们可能希望构建一个能够基于其他测量预测花卉种类的模型;在这种情况下,species
列将被视为目标数组。
有了这个目标数组,我们可以使用 Seaborn(在 第三十六章 中讨论)方便地可视化数据(参见 图 38-1)。
In [2]: %matplotlib inline
import seaborn as sns
sns.pairplot(iris, hue='species', height=1.5);
图 38-1. 鸢尾花数据集的可视化^(1)
为了在 Scikit-Learn 中使用,我们将从 DataFrame
中提取特征矩阵和目标数组,我们可以使用一些在 第三部分 中讨论过的 Pandas DataFrame
操作来完成:
In [3]: X_iris = iris.drop('species', axis=1)
X_iris.shape
Out[3]: (150, 4)
In [4]: y_iris = iris['species']
y_iris.shape
Out[4]: (150,)
总结一下,特征和目标值的预期布局如 图 38-2 所示。
图 38-2. Scikit-Learn 的数据布局^(2)
有了这些数据正确格式化,我们可以继续考虑 Scikit-Learn 的估计器 API。
估计器 API
Scikit-Learn API 的设计遵循以下指导原则,如 Scikit-Learn API 论文 所述:
一致性
所有对象共享从有限一组方法中提取的共同接口,并提供一致的文档。
检查
所有指定的参数值都作为公共属性公开。
有限的对象层次
Python 类表示算法,数据集使用标准格式(NumPy 数组、Pandas DataFrame
对象、SciPy 稀疏矩阵),参数名称使用标准的 Python 字符串。
组成
许多机器学习任务可以表示为更基础算法的序列,并且 Scikit-Learn 在可能的情况下会利用这一点。
合理的默认值
当模型需要用户指定的参数时,库会定义一个合适的默认值。
在实践中,一旦理解了基本原则,这些原则使得 Scikit-Learn 非常易于使用。Scikit-Learn 中的每个机器学习算法都是通过估计器 API 实现的,该 API 为广泛的机器学习应用提供了一致的接口。
API 的基础知识
在使用 Scikit-Learn 估计器 API 的步骤中,最常见的步骤如下:
-
通过从 Scikit-Learn 导入适当的估计器类来选择一个模型类。
-
通过用所需值实例化这个类来选择模型超参数。
-
按照本章前面概述的方式,将数据安排为特征矩阵和目标向量。
-
通过调用模型实例的
fit
方法将模型拟合到您的数据中。 -
将模型应用于新数据:
-
对于监督学习,通常我们使用
predict
方法为未知数据预测标签。 -
对于无监督学习,我们经常使用
transform
或predict
方法来转换或推断数据的属性。
-
现在我们将逐步展示几个简单的示例,应用监督和无监督学习方法。
监督学习示例:简单线性回归
作为这一过程的示例,让我们考虑一个简单的线性回归——即,将一条直线拟合到数据(x,y)
的常见情况。我们将使用以下简单的数据作为我们回归示例的数据(见图 38-3)。
In [5]: import matplotlib.pyplot as plt
import numpy as np
rng = np.random.RandomState(42)
x = 10 * rng.rand(50)
y = 2 * x - 1 + rng.randn(50)
plt.scatter(x, y);
图 38-3. 线性回归数据
有了这些数据,我们可以使用前面提到的配方。我们将在接下来的几节中详细介绍这个过程。
1. 选择一个模型类
在 Scikit-Learn 中,每个模型类都由一个 Python 类表示。因此,例如,如果我们想计算一个简单的LinearRegression
模型,我们可以导入线性回归类:
In [6]: from sklearn.linear_model import LinearRegression
注意,还有其他更一般的线性回归模型存在;您可以在sklearn.linear_model
模块文档中了解更多信息。
2. 选择模型超参数
一个重要的点是,一个模型类并不等同于一个模型的实例。
一旦我们决定了我们的模型类,还有一些选项是开放给我们的。根据我们正在使用的模型类,我们可能需要回答以下一个或多个类似的问题:
-
我们想要拟合偏移量(即y-截距)吗?
-
我们希望模型被归一化吗?
-
我们想要预处理我们的特征以增加模型的灵活性吗?
-
我们希望在我们的模型中使用多少程度的正则化?
-
我们想使用多少个模型组件?
这些是在选择模型类别确定后必须做出的重要选择的示例。这些选择通常被表示为超参数,或者必须在将模型拟合到数据之前设置的参数。在 Scikit-Learn 中,通过在模型实例化时传递值来选择超参数。我们将探讨如何可以量化地选择超参数在第三十九章中。
对于我们的线性回归示例,我们可以实例化LinearRegression
类,并指定我们希望使用fit_intercept
超参数来拟合截距:
In [7]: model = LinearRegression(fit_intercept=True)
model
Out[7]: LinearRegression()
请记住,当实例化模型时,唯一的操作是存储这些超参数值。特别是,我们还没有将模型应用于任何数据:Scikit-Learn API 非常清楚地区分了模型选择和将模型应用于数据的行为。
3. 将数据排列成特征矩阵和目标向量
之前我们研究了 Scikit-Learn 的数据表示,这需要一个二维特征矩阵和一个一维目标数组。这里我们的目标变量y
已经是正确的形式(长度为n_samples
的数组),但我们需要对数据x
进行整理,使其成为大小为[n_samples, n_features]
的矩阵。
在这种情况下,这相当于简单地重新整理一维数组:
In [8]: X = x[:, np.newaxis]
X.shape
Out[8]: (50, 1)
4. 将模型拟合到数据
现在是将我们的模型应用于数据的时候了。这可以通过模型的fit
方法来完成:
In [9]: model.fit(X, y)
Out[9]: LinearRegression()
此fit
命令会导致进行许多依赖于模型的内部计算,并将这些计算的结果存储在用户可以探索的模型特定属性中。在 Scikit-Learn 中,按照惯例,在fit
过程中学习的所有模型参数都有尾随的下划线;例如,在这个线性模型中,我们有以下内容:
In [10]: model.coef_
Out[10]: array([1.9776566])
In [11]: model.intercept_
Out[11]: -0.9033107255311146
这两个参数表示对数据进行简单线性拟合的斜率和截距。将结果与数据定义进行比较,我们看到它们接近用于生成数据的值:斜率为 2,截距为-1。
经常出现的一个问题是关于内部模型参数的不确定性。一般来说,Scikit-Learn 不提供从内部模型参数本身得出结论的工具:解释模型参数更多是一个统计建模问题,而不是一个机器学习问题。机器学习更关注模型的预测。如果您想深入了解模型内的拟合参数含义,其他工具可用,包括statsmodels
Python 包。
5. 预测未知数据的标签
一旦模型训练完成,监督机器学习的主要任务就是基于其对未曾参与训练集的新数据的预测结果进行评估。在 Scikit-Learn 中,可以使用predict
方法来实现。为了本示例的目的,我们的“新数据”将是一组x值,并且我们会问模型预测什么y值:
In [12]: xfit = np.linspace(-1, 11)
与之前一样,我们需要将这些x值强制转换为[n_samples, n_features]
特征矩阵,之后我们可以将其馈送给模型:
In [13]: Xfit = xfit[:, np.newaxis]
yfit = model.predict(Xfit)
最后,让我们通过首先绘制原始数据,然后是模型拟合结果来可视化结果(参见图 38-4)。
In [14]: plt.scatter(x, y)
plt.plot(xfit, yfit);
图 38-4. 简单的线性回归拟合数据
通常通过将模型的结果与某些已知基准进行比较来评估模型的效果,我们将在下一个示例中看到。
监督学习示例:鸢尾花分类
让我们再看一个这个过程的例子,使用我们之前讨论过的鸢尾花数据集。我们的问题是这样的:在一个部分鸢尾花数据上训练的模型,我们能多好地预测剩余标签?
对于这个任务,我们将使用一个称为高斯朴素贝叶斯的简单生成模型,它假设每个类别都来自于一个轴对齐的高斯分布(更多细节请参见第四十一章)。由于它非常快速且没有需要选择的超参数,高斯朴素贝叶斯通常是用作基线分类的好模型,然后可以探索是否通过更复杂的模型找到改进。
我们希望评估模型在未见过的数据上的表现,因此我们将数据分为训练集和测试集。这可以手动完成,但使用train_test_split
实用函数更为方便:
In [15]: from sklearn.model_selection import train_test_split
Xtrain, Xtest, ytrain, ytest = train_test_split(X_iris, y_iris,
random_state=1)
数据整理完毕后,我们可以按照我们的步骤预测标签:
In [16]: from sklearn.naive_bayes import GaussianNB # 1\. choose model class
model = GaussianNB() # 2\. instantiate model
model.fit(Xtrain, ytrain) # 3\. fit model to data
y_model = model.predict(Xtest) # 4\. predict on new data
最后,我们可以使用accuracy_score
实用函数查看预测标签与其真实值匹配的比例:
In [17]: from sklearn.metrics import accuracy_score
accuracy_score(ytest, y_model)
Out[17]: 0.9736842105263158
准确率高达 97%,我们看到即使是这种非常天真的分类算法对这个特定数据集也是有效的!
无监督学习示例:鸢尾花维度
作为无监督学习问题的例子,让我们看看如何降低鸢尾花数据的维度,以便更容易地可视化它。回想一下,鸢尾花数据是四维的:每个样本记录了四个特征。
降维的任务集中在确定是否存在一个合适的低维表示,以保留数据的基本特征。通常,降维被用作辅助可视化数据的工具:毕竟,在二维中绘制数据比在四维或更多维度中更容易!
在这里,我们将使用 主成分分析(PCA;见 第四十五章),这是一种快速的线性降维技术。我们将要求模型返回两个组件——也就是数据的二维表示。
按照前面概述的步骤序列,我们有:
In [18]: from sklearn.decomposition import PCA # 1\. choose model class
model = PCA(n_components=2) # 2\. instantiate model
model.fit(X_iris) # 3\. fit model to data
X_2D = model.transform(X_iris) # 4\. transform the data
现在让我们绘制结果。一个快速的方法是将结果插入到原始的鸢尾DataFrame
中,并使用 Seaborn 的 lmplot
来显示结果(见 图 38-5)。
In [19]: iris['PCA1'] = X_2D[:, 0]
iris['PCA2'] = X_2D[:, 1]
sns.lmplot(x="PCA1", y="PCA2", hue='species', data=iris, fit_reg=False);
我们看到,在二维表示中,物种相当分离,即使 PCA 算法没有物种标签的知识!这向我们暗示,一个相对简单的分类对数据集可能是有效的,就像我们之前看到的那样。
图 38-5. 将 Iris 数据投影到二维空间^(3)
无监督学习示例:鸢尾花聚类
接下来让我们看一下将聚类应用到鸢尾数据上。聚类算法试图找到不同的数据组,而不考虑任何标签。在这里,我们将使用一个强大的聚类方法,称为 高斯混合模型(GMM),在 第四十八章 中有更详细的讨论。GMM 试图将数据建模为高斯斑点的集合。
我们可以按如下方式拟合高斯混合模型:
In [20]: from sklearn.mixture import GaussianMixture # 1\. choose model class
model = GaussianMixture(n_components=3,
covariance_type='full') # 2\. instantiate model
model.fit(X_iris) # 3\. fit model to data
y_gmm = model.predict(X_iris) # 4\. determine labels
与之前一样,我们将把集群标签添加到鸢尾DataFrame
中,并使用 Seaborn 绘制结果(见 图 38-6)。
In [21]: iris['cluster'] = y_gmm
sns.lmplot(x="PCA1", y="PCA2", data=iris, hue='species',
col='cluster', fit_reg=False);
图 38-6. Iris 数据中的 k-means 聚类^(4)
通过按簇号拆分数据,我们可以看到 GMM 算法已经完美地恢复了底层标签:setosa 物种在簇 0 中完美分离,而 versicolor 和 virginica 之间仍然存在少量混合。这意味着即使没有专家告诉我们单个花的物种标签,这些花的测量也是足够明显的,以至于我们可以使用简单的聚类算法自动识别出这些不同物种群!这种算法可能进一步给领域专家提供关于他们正在观察的样本之间关系的线索。
应用:探索手写数字
为了在一个更有趣的问题上演示这些原则,让我们考虑光学字符识别问题的一部分:手写数字的识别。在实际情况中,这个问题涉及到在图像中定位和识别字符。在这里,我们将采取捷径,使用 Scikit-Learn 的预格式化数字集,这些数字集内置于库中。
加载和可视化数字数据
我们可以使用 Scikit-Learn 的数据访问接口来查看这些数据:
In [22]: from sklearn.datasets import load_digits
digits = load_digits()
digits.images.shape
Out[22]: (1797, 8, 8)
图像数据是一个三维数组:每个样本由一个 8 × 8 的像素网格组成,共 1,797 个样本。让我们可视化其中的前一百个(参见图 38-7)。
In [23]: import matplotlib.pyplot as plt
fig, axes = plt.subplots(10, 10, figsize=(8, 8),
subplot_kw={'xticks':[], 'yticks':[]},
gridspec_kw=dict(hspace=0.1, wspace=0.1))
for i, ax in enumerate(axes.flat):
ax.imshow(digits.images[i], cmap='binary', interpolation='nearest')
ax.text(0.05, 0.05, str(digits.target[i]),
transform=ax.transAxes, color='green')
图 38-7. 手写数字数据;每个样本由一个 8 × 8 的像素网格表示
为了在 Scikit-Learn 中处理这些数据,我们需要一个二维的 [n_samples, n_features]
表示。我们可以通过将图像中的每个像素视为一个特征来实现这一点:即通过展开像素数组,使得我们有一个长度为 64 的数组,其中包含代表每个数字的像素值。此外,我们还需要目标数组,它给出了每个数字的预先确定标签。这两个量已经内置在 digits 数据集的 data
和 target
属性中了:
In [24]: X = digits.data
X.shape
Out[24]: (1797, 64)
In [25]: y = digits.target
y.shape
Out[25]: (1797,)
我们在这里看到有 1,797 个样本和 64 个特征。
无监督学习示例:降维
我们想在 64 维参数空间内可视化我们的点,但在这么高维空间中有效地可视化点是困难的。因此,我们将通过无监督方法减少维度。在这里,我们将使用一个称为 Isomap 的流形学习算法(参见第四十六章),将数据转换为二维:
In [26]: from sklearn.manifold import Isomap
iso = Isomap(n_components=2)
iso.fit(digits.data)
data_projected = iso.transform(digits.data)
print(data_projected.shape)
Out[26]: (1797, 2)
我们看到投影后的数据现在是二维的。让我们绘制这些数据,看看我们是否可以从它的结构中学到一些东西(参见图 38-8)。
In [27]: plt.scatter(data_projected[:, 0], data_projected[:, 1], c=digits.target,
edgecolor='none', alpha=0.5,
cmap=plt.cm.get_cmap('viridis', 10))
plt.colorbar(label='digit label', ticks=range(10))
plt.clim(-0.5, 9.5);
这个图表让我们对在较大的 64 维空间中各种数字的分离程度有了一些直观的认识。例如,零和一在参数空间中几乎没有重叠。直觉上这是有道理的:零在图像中间是空的,而一通常在图像中间有墨水。另一方面,一和四之间似乎有一个更或多或少连续的谱系:我们可以通过意识到有些人在一上画有“帽子”,这使它们看起来与四相似。
总体而言,尽管在边缘处有些混合,不同的组在参数空间中似乎被相当好地定位:这表明即使是非常简单的监督分类算法也应该在完整的高维数据集上表现适当。让我们试一试。
图 38-8. 数字数据的 Isomap 嵌入
数字分类
让我们对手写数字数据应用一个分类算法。与之前处理鸢尾花数据集时一样,我们将数据分为训练集和测试集,并拟合一个高斯朴素贝叶斯模型:
In [28]: Xtrain, Xtest, ytrain, ytest = train_test_split(X, y, random_state=0)
In [29]: from sklearn.naive_bayes import GaussianNB
model = GaussianNB()
model.fit(Xtrain, ytrain)
y_model = model.predict(Xtest)
现在我们有了模型的预测结果,我们可以通过比较测试集的真实值和预测值来评估其准确性:
In [30]: from sklearn.metrics import accuracy_score
accuracy_score(ytest, y_model)
Out[30]: 0.8333333333333334
即使是这个非常简单的模型,我们在数字分类上也达到了约 83%的准确率!但是,这个单一数字并不能告诉我们哪里出了错。一个很好的方法是使用混淆矩阵,我们可以用 Scikit-Learn 计算它,并用 Seaborn 绘制(参见图 38-9)。
In [31]: from sklearn.metrics import confusion_matrix
mat = confusion_matrix(ytest, y_model)
sns.heatmap(mat, square=True, annot=True, cbar=False, cmap='Blues')
plt.xlabel('predicted value')
plt.ylabel('true value');
这显示了误标记点的位置倾向:例如,这里的许多数字“2”被误分类为“1”或“8”。
图 38-9. 混淆矩阵显示分类器误分类的频率
另一种直观了解模型特性的方法是重新绘制输入数据及其预测标签。我们将使用绿色表示正确标签,红色表示错误标签;详见图 38-10。
In [32]: fig, axes = plt.subplots(10, 10, figsize=(8, 8),
subplot_kw={'xticks':[], 'yticks':[]},
gridspec_kw=dict(hspace=0.1, wspace=0.1))
test_images = Xtest.reshape(-1, 8, 8)
for i, ax in enumerate(axes.flat):
ax.imshow(test_images[i], cmap='binary', interpolation='nearest')
ax.text(0.05, 0.05, str(y_model[i]),
transform=ax.transAxes,
color='green' if (ytest[i] == y_model[i]) else 'red')
检查数据子集可以帮助我们了解算法在哪些地方可能表现不佳。为了超越我们的 83%分类成功率,我们可以转向更复杂的算法,如支持向量机(参见第 43 章)、随机森林(参见第 44 章)或其他分类方法。
图 38-10. 数据显示正确(绿色)和错误(红色)标签;查看这个图的彩色版本,请参阅书的在线版本
总结
在本章中,我们介绍了 Scikit-Learn 数据表示和估计器 API 的基本特性。无论使用何种类型的估计器,都保持着相同的导入/实例化/拟合/预测模式。掌握了这些信息,您可以探索 Scikit-Learn 文档,并在您的数据上尝试各种模型。
在下一章中,我们将探讨机器学习中可能最重要的主题:如何选择和验证您的模型。
^(1) 这个图的全尺寸、全彩色版本可以在GitHub上找到。
^(2) 可在在线附录中找到生成此图的代码。
^(3) 这个图的全彩色版本可以在GitHub上找到。
^(4) 这个图的全尺寸、全彩色版本可以在GitHub上找到。
第三十九章:超参数和模型验证
在上一章中,我们看到了应用监督机器学习模型的基本方法:
-
选择一个模型类别。
-
选择模型超参数。
-
将模型拟合到训练数据中。
-
使用模型来预测新数据的标签。
这两个部分——模型的选择和超参数的选择——可能是有效使用这些工具和技术的最重要部分。为了做出明智的选择,我们需要一种验证模型和超参数是否与数据相匹配的方法。虽然这听起来很简单,但要有效地做到这一点,你必须避免一些陷阱。
思考模型验证
原则上,模型验证非常简单:在选择了模型和其超参数之后,我们可以通过将其应用于一些训练数据并将预测结果与已知值进行比较来估计其有效性。
本节将首先展示一个关于模型验证的天真方法以及为什么它失败了,然后探讨使用保留集和交叉验证进行更健壮的模型评估。
错误的模型验证方法
让我们从在上一章中看到的鸢尾花数据集中采用天真的验证方法开始。我们将从加载数据开始:
In [1]: from sklearn.datasets import load_iris
iris = load_iris()
X = iris.data
y = iris.target
接下来,我们选择一个模型和超参数。在这里,我们将使用一个n最近邻分类器,其中n_neighbors=1
。这是一个非常简单和直观的模型,它表示“未知点的标签与其最近训练点的标签相同”:
In [2]: from sklearn.neighbors import KNeighborsClassifier
model = KNeighborsClassifier(n_neighbors=1)
然后,我们训练模型,并使用它来预测我们已经知道标签的数据的标签:
In [3]: model.fit(X, y)
y_model = model.predict(X)
最后,我们计算正确标记点的比例:
In [4]: from sklearn.metrics import accuracy_score
accuracy_score(y, y_model)
Out[4]: 1.0
我们看到了一个准确度得分为 1.0,这表明我们的模型 100%正确标记了所有点!但这真的是测量预期准确度吗?我们真的找到了一个我们预计每次都会 100%正确的模型吗?
正如你可能已经了解的那样,答案是否定的。事实上,这种方法包含一个根本性的缺陷:它在相同的数据上训练和评估模型。此外,这个最近邻模型是一个基于实例的估计器,它简单地存储训练数据,并通过将新数据与这些存储的点进行比较来预测标签:除了人为的情况外,它每次都会得到 100%的准确度!
正确的模型验证方法:保留集
那么可以做什么呢?通过使用所谓的保留集可以更好地了解模型的性能:也就是说,我们从模型的训练中保留一些数据子集,然后使用这个保留集来检查模型的性能。这种分割可以使用 Scikit-Learn 中的train_test_split
工具来完成:
In [5]: from sklearn.model_selection import train_test_split
# split the data with 50% in each set
X1, X2, y1, y2 = train_test_split(X, y, random_state=0,
train_size=0.5)
# fit the model on one set of data
model.fit(X1, y1)
# evaluate the model on the second set of data
y2_model = model.predict(X2)
accuracy_score(y2, y2_model)
Out[5]: 0.9066666666666666
我们在这里看到了一个更合理的结果:一对一最近邻分类器在这个保留集上的准确率约为 90%。保留集类似于未知数据,因为模型以前没有“看到”它。
通过交叉验证进行模型验证
使用留出法进行模型验证的一个缺点是我们已经失去了一部分数据用于模型训练。在前述情况下,一半的数据集并没有对模型的训练做出贡献!这并不是最优的,特别是如果初始训练数据集很小的情况下。
解决这个问题的一种方法是使用交叉验证;也就是说,进行一系列拟合,其中每个数据子集既用作训练集又用作验证集。从视觉上看,可能会像是 Figure 39-1 这样。
图 39-1. 二折交叉验证的可视化^(1)
在这里,我们进行了两个验证试验,交替使用数据的每一半作为留出集。使用之前的分割数据,我们可以这样实现:
In [6]: y2_model = model.fit(X1, y1).predict(X2)
y1_model = model.fit(X2, y2).predict(X1)
accuracy_score(y1, y1_model), accuracy_score(y2, y2_model)
Out[6]: (0.96, 0.9066666666666666)
出现的是两个准确度分数,我们可以结合(比如取平均值)来获得更好的全局模型性能衡量标准。这种特定形式的交叉验证是二折交叉验证——即,我们将数据分为两组,轮流将每一组作为验证集。
我们可以扩展这个想法,使用更多的试验和数据折叠——例如,Figure 39-2 展示了五折交叉验证的可视化描述。
图 39-2. 五折交叉验证的可视化^(2)
在这里,我们将数据分为五组,依次使用每一组来评估模型在其余四分之四的数据上的拟合情况。这样手工操作将会相当乏味,但我们可以使用 Scikit-Learn 的 cross_val_score
方便地完成:
In [7]: from sklearn.model_selection import cross_val_score
cross_val_score(model, X, y, cv=5)
Out[7]: array([0.96666667, 0.96666667, 0.93333333, 0.93333333, 1. ])
在不同的数据子集上重复验证可以更好地了解算法的性能。
Scikit-Learn 实现了许多在特定情况下有用的交叉验证方案;这些通过 model_selection
模块中的迭代器实现。例如,我们可能希望使用极端情况,即我们的折数等于数据点的数量:也就是说,在每次试验中我们训练所有点但排除一个。这种交叉验证被称为留一法交叉验证,可以如下使用:
In [8]: from sklearn.model_selection import LeaveOneOut
scores = cross_val_score(model, X, y, cv=LeaveOneOut())
scores
Out[8]: array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 0., 1., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 1., 1., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., 1., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])
因为我们有 150 个样本,留一法交叉验证会产生 150 次试验的分数,每个分数表示预测成功(1.0)或失败(0.0)。对这些分数求平均值可以估计误差率:
In [9]: scores.mean()
Out[9]: 0.96
其他交叉验证方案可以类似地使用。要了解 Scikit-Learn 中提供的内容,请使用 IPython 探索 sklearn.model_selection
子模块,或查看 Scikit-Learn 的交叉验证文档。
选择最佳模型
现在我们已经探讨了验证和交叉验证的基础知识,我们将更深入地讨论模型选择和超参数选择的问题。这些问题是机器学习实践中最重要的方面之一,但我发现这些信息在初学者机器学习教程中经常被忽略。
核心问题是以下问题:如果我们的估计器表现不佳,我们该如何前进? 有几种可能的答案:
-
使用一个更复杂/更灵活的模型。
-
使用一个不那么复杂/不那么灵活的模型。
-
收集更多训练样本。
-
收集更多数据以增加每个样本的特征。
对这个问题的答案通常是反直觉的。特别是,有时使用更复杂的模型会导致更差的结果,并且增加更多的训练样本可能不会改善您的结果!能够确定哪些步骤将改进您的模型是成功的机器学习从业者和不成功的区别。
偏差-方差折衷
从根本上讲,找到“最佳模型”就是在偏差和方差之间的折衷中找到一个甜蜜点。考虑图 39-3,它展示了对同一数据集的两个回归拟合。
图 39-3. 高偏差和高方差的回归模型^(3)
很明显,这两个模型都不特别适合数据,但它们以不同的方式失败。
左边的模型试图通过数据找到一条直线拟合。因为在这种情况下,一条直线不能准确地分割数据,所以直线模型永远无法很好地描述这个数据集。这样的模型被称为欠拟合数据:即它没有足够的灵活性来适当地考虑数据中的所有特征。另一种说法是,该模型具有高偏差。
右边的模型试图通过数据拟合高阶多项式。在这里,模型拟合具有足够的灵活性来几乎完美地描述数据中的细微特征,但即使它非常精确地描述了训练数据,其精确形式似乎更反映了数据的特定噪声属性,而不是生成数据的任何过程的固有属性。这样的模型被称为过拟合数据:即它具有如此高的灵活性,以至于模型最终解释了随机误差以及底层数据分布。另一种说法是,该模型具有高方差。
从另一个角度来看,考虑一下如果我们使用这两个模型来预测一些新数据的y值会发生什么。在图 39-4 中的图表中,红色/浅色点表示从训练集中省略的数据。
图 39-4. 高偏差和高方差模型中的训练和验证分数^(4)
这里的分数是分数,或者确定系数,它衡量模型相对于目标值简单平均的表现。 表示完美匹配, 表示模型不比简单取数据均值更好,负值则表示更差的模型。从这两个模型相关的分数中,我们可以得出一个更普遍的观察:
-
对于高偏差模型,模型在验证集上的表现与在训练集上的表现类似。
-
对于高方差模型,模型在验证集上的表现远远不及在训练集上的表现。
如果我们可以调整模型复杂度,我们会期望训练分数和验证分数表现如图 39-5 所示,通常称为验证曲线,我们可以看到以下特点:
-
训练分数始终高于验证分数。一般情况下都是如此:模型对已见数据的拟合程度比对未见数据的拟合程度更好。
-
对于非常低的模型复杂度(即高偏差模型),训练数据欠拟合,这意味着该模型对于训练数据和任何之前未见数据的预测都很差。
-
对于非常高的模型复杂度(即高方差模型),训练数据过拟合,这意味着模型对训练数据的预测非常好,但是对于任何之前未见数据都失败了。
-
对于某些中间值,验证曲线达到最大值。这种复杂度水平表明在偏差和方差之间有一个适当的权衡。
调整模型复杂度的方法因模型而异;在后面的章节中深入讨论各个模型时,我们将看到每个模型如何允许此类调整。
图 39-5. 模型复杂度、训练分数和验证分数之间关系的示意图^(5)
Scikit-Learn 中的验证曲线
让我们看一个使用交叉验证计算模型验证曲线的示例。这里我们将使用多项式回归模型,一个广义线性模型,其中多项式的次数是一个可调参数。例如,对于模型参数 和 :
三阶多项式对数据拟合出一个立方曲线;对于模型参数:
我们可以将这一概念推广到任意数量的多项式特征。在 Scikit-Learn 中,我们可以使用线性回归分类器结合多项式预处理器实现这一点。我们将使用管道将这些操作串联在一起(我们将在第 40 章中更全面地讨论多项式特征和管道):
In [10]: from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import make_pipeline
def PolynomialRegression(degree=2, **kwargs):
return make_pipeline(PolynomialFeatures(degree),
LinearRegression(**kwargs))
现在让我们创建一些数据来拟合我们的模型:
In [11]: import numpy as np
def make_data(N, err=1.0, rseed=1):
# randomly sample the data
rng = np.random.RandomState(rseed)
X = rng.rand(N, 1) ** 2
y = 10 - 1. / (X.ravel() + 0.1)
if err > 0:
y += err * rng.randn(N)
return X, y
X, y = make_data(40)
现在我们可以可视化我们的数据,以及几个不同阶数的多项式拟合(见图 39-6)。
In [12]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
X_test = np.linspace(-0.1, 1.1, 500)[:, None]
plt.scatter(X.ravel(), y, color='black')
axis = plt.axis()
for degree in [1, 3, 5]:
y_test = PolynomialRegression(degree).fit(X, y).predict(X_test)
plt.plot(X_test.ravel(), y_test, label='degree={0}'.format(degree))
plt.xlim(-0.1, 1.0)
plt.ylim(-2, 12)
plt.legend(loc='best');
在这种情况下控制模型复杂度的旋钮是多项式的阶数,它可以是任何非负整数。一个有用的问题是:哪个多项式阶数提供了偏差(欠拟合)和方差(过拟合)之间的合适权衡点?
图 39-6. 适合数据集的三个不同多项式模型^(6)
我们可以通过可视化特定数据和模型的验证曲线来取得进展;这可以通过 Scikit-Learn 提供的validation_curve
便捷程序直接完成。给定一个模型、数据、参数名称和探索范围,该函数将自动计算跨范围的训练分数和验证分数(见图 39-7)。
In [13]: from sklearn.model_selection import validation_curve
degree = np.arange(0, 21)
train_score, val_score = validation_curve(
PolynomialRegression(), X, y,
param_name='polynomialfeatures__degree',
param_range=degree, cv=7)
plt.plot(degree, np.median(train_score, 1),
color='blue', label='training score')
plt.plot(degree, np.median(val_score, 1),
color='red', label='validation score')
plt.legend(loc='best')
plt.ylim(0, 1)
plt.xlabel('degree')
plt.ylabel('score');
图 39-7. Figure 39-9 中数据的验证曲线
这清楚地展示了我们预期的定性行为:训练分数始终高于验证分数,训练分数随着模型复杂度的增加而单调改善,并且验证分数在模型过拟合后达到最大值然后下降。
从验证曲线中,我们可以确定在三阶多项式下找到了偏差和方差之间的最佳权衡点。我们可以按以下方式计算并展示这个拟合结果在原始数据上的表现(见图 39-8)。
In [14]: plt.scatter(X.ravel(), y)
lim = plt.axis()
y_test = PolynomialRegression(3).fit(X, y).predict(X_test)
plt.plot(X_test.ravel(), y_test);
plt.axis(lim);
图 39-8. Figure 39-6 中数据的交叉验证最优模型
注意,找到这个最优模型实际上并不需要我们计算训练分数,但是检查训练分数和验证分数之间的关系可以为我们提供模型性能的有用见解。
学习曲线
模型复杂度的一个重要方面是,最优模型通常取决于训练数据的大小。例如,让我们生成一个数据集,其点数是之前的五倍(见图 39-9)。
In [15]: X2, y2 = make_data(200)
plt.scatter(X2.ravel(), y2);
图 39-9. 展示学习曲线的数据
现在让我们复制前述代码,为这个更大的数据集绘制验证曲线;为了参考,我们也会在前面的结果上进行叠加(见图 39-10)。
In [16]: degree = np.arange(21)
train_score2, val_score2 = validation_curve(
PolynomialRegression(), X2, y2,
param_name='polynomialfeatures__degree',
param_range=degree, cv=7)
plt.plot(degree, np.median(train_score2, 1),
color='blue', label='training score')
plt.plot(degree, np.median(val_score2, 1),
color='red', label='validation score')
plt.plot(degree, np.median(train_score, 1),
color='blue', alpha=0.3, linestyle='dashed')
plt.plot(degree, np.median(val_score, 1),
color='red', alpha=0.3, linestyle='dashed')
plt.legend(loc='lower center')
plt.ylim(0, 1)
plt.xlabel('degree')
plt.ylabel('score');
实线显示新结果,而较淡的虚线显示较小数据集的结果。从验证曲线可以明显看出,较大的数据集可以支持更复杂的模型:这里的高峰可能在 6 阶左右,但即使是 20 阶模型也不会严重过拟合数据——验证和训练分数仍然非常接近。
图 39-10. 多项式模型拟合数据的学习曲线
因此,验证曲线的行为不仅仅取决于模型复杂度,还取决于训练点的数量。我们可以通过使用日益增大的数据子集来研究模型随训练点数量变化的行为,从而获得更深入的见解。有时,关于训练/验证分数与训练集大小的图称为学习曲线。
我们期望从学习曲线得到的一般行为是:
-
给定复杂度的模型会过拟合小数据集:这意味着训练分数会相对较高,而验证分数则相对较低。
-
给定复杂度的模型会欠拟合大数据集:这意味着训练分数会减少,但验证分数会增加。
-
除了偶然情况外,模型永远不会给验证集比训练集更好的分数:这意味着曲线应该会越来越接近,但永远不会交叉。
考虑到这些特征,我们期望学习曲线在质量上看起来像图 39-11 所示。
图 39-11. 示意图展示学习曲线的典型解释
学习曲线的显著特征是随着训练样本数量的增长而趋于特定分数。特别是,一旦您有足够的点使得特定模型收敛,增加更多的训练数据将不会帮助您!在这种情况下,提高模型性能的唯一方法是使用另一个(通常更复杂的)模型。
Scikit-Learn 提供了一个方便的实用工具来计算模型的学习曲线;在这里,我们将使用二阶多项式模型和九阶多项式模型来计算我们原始数据集的学习曲线(参见图 39-12)。
In [17]: from sklearn.model_selection import learning_curve
fig, ax = plt.subplots(1, 2, figsize=(16, 6))
fig.subplots_adjust(left=0.0625, right=0.95, wspace=0.1)
for i, degree in enumerate([2, 9]):
N, train_lc, val_lc = learning_curve(
PolynomialRegression(degree), X, y, cv=7,
train_sizes=np.linspace(0.3, 1, 25))
ax[i].plot(N, np.mean(train_lc, 1),
color='blue', label='training score')
ax[i].plot(N, np.mean(val_lc, 1),
color='red', label='validation score')
ax[i].hlines(np.mean([train_lc[-1], val_lc[-1]]), N[0],
N[-1], color='gray', linestyle='dashed')
ax[i].set_ylim(0, 1)
ax[i].set_xlim(N[0], N[-1])
ax[i].set_xlabel('training size')
ax[i].set_ylabel('score')
ax[i].set_title('degree = {0}'.format(degree), size=14)
ax[i].legend(loc='best')
图 39-12. 低复杂度模型的学习曲线(左)和高复杂度模型的学习曲线(右)^(9)
这是一种有价值的诊断工具,因为它直观地显示了模型如何对增加的训练数据做出反应。特别是当学习曲线已经收敛(即训练和验证曲线已经非常接近)时,增加更多的训练数据将不会显著改善拟合效果!这种情况在左侧面板中可见,对于二次模型的学习曲线。
增加收敛分数的唯一方法是使用不同(通常更复杂)的模型。我们在右侧面板中看到这一点:通过转向更复杂的模型,我们增加了收敛分数(由虚线指示),但以更高的模型方差为代价(由训练和验证分数的差异表示)。如果我们再添加更多数据点,更复杂模型的学习曲线最终会收敛。
为您选择的特定模型和数据集绘制学习曲线,可以帮助您做出关于如何继续改进分析的决定。
实线显示了新的结果,而较淡的虚线显示了之前较小数据集上的结果。从验证曲线可以清楚地看出,较大的数据集可以支持更复杂的模型:这里的峰值可能在 6 次多项式,但即使是 20 次多项式模型也没有严重过拟合数据——验证和训练分数仍然非常接近。
实践中的验证:网格搜索
前述讨论旨在让您直观地了解偏差和方差之间的权衡,以及其对模型复杂度和训练集大小的依赖。在实践中,模型通常有多个可以调整的参数,这意味着验证曲线和学习曲线的绘制从线条变为多维表面。在这些情况下,这样的可视化是困难的,我们更愿意找到能最大化验证分数的特定模型。
Scikit-Learn 提供了一些工具,使这种搜索更加方便:在这里,我们将考虑使用网格搜索来找到最优的多项式模型。我们将探索一个二维模型特性的网格,即多项式阶数和是否拟合截距的标志。可以使用 Scikit-Learn 的GridSearchCV
元估计器来设置这一点:
In [18]: from sklearn.model_selection import GridSearchCV
param_grid = {'polynomialfeatures__degree': np.arange(21),
'linearregression__fit_intercept': [True, False]}
grid = GridSearchCV(PolynomialRegression(), param_grid, cv=7)
请注意,像普通估算器一样,此方法尚未应用于任何数据。调用fit
方法将在每个网格点上拟合模型,并跟踪沿途的分数:
In [19]: grid.fit(X, y);
现在模型已经拟合,我们可以按如下方式获取最佳参数:
In [20]: grid.best_params_
Out[20]: {'linearregression__fit_intercept': False, 'polynomialfeatures__degree': 4}
最后,如果需要,我们可以使用最佳模型并展示我们的数据拟合结果,使用之前的代码(见 Figure 39-13)。
In [21]: model = grid.best_estimator_
plt.scatter(X.ravel(), y)
lim = plt.axis()
y_test = model.fit(X, y).predict(X_test)
plt.plot(X_test.ravel(), y_test);
plt.axis(lim);
图 39-13. 通过自动网格搜索确定的最佳拟合模型
GridSearchCV
中的其他选项包括指定自定义评分函数、并行化计算、执行随机搜索等。有关更多信息,请参阅第四十九章和第五十章的示例,或参考 Scikit-Learn 的网格搜索文档。
总结
在本章中,我们开始探讨模型验证和超参数优化的概念,重点是偏差-方差折衷的直观方面,以及在拟合模型到数据时如何发挥作用。特别是,我们发现在调整参数时使用验证集或交叉验证方法对于避免对更复杂/灵活的模型进行过拟合至关重要。
在后续章节中,我们将讨论特别有用的模型的详细信息,这些模型的调整以及这些自由参数如何影响模型复杂性。在阅读并了解这些机器学习方法时,请记住本章的教训!
^(1) 生成此图的代码可以在在线附录中找到。
^(2) 生成此图的代码可以在在线附录中找到。
^(3) 生成此图的代码可以在在线附录中找到。
^(4) 生成此图的代码可以在在线附录中找到。
^(5) 生成此图的代码可以在在线附录中找到。
^(6) 此图的全彩色版本可在GitHub上找到。
^(7) 此图的全彩色版本可在GitHub上找到。
^(8) 生成此图的代码可以在在线附录中找到。
^(9) 此图的全尺寸版本可在GitHub上找到。
第四十章:特征工程
前几章概述了机器学习的基本思想,但到目前为止的所有示例都假定您有数字数据以整洁的[n_samples, n_features]
格式。在现实世界中,数据很少以这种形式出现。考虑到这一点,实际应用机器学习的一个更重要的步骤之一是特征工程:即,利用您对问题的任何信息,并将其转换为您可以用来构建特征矩阵的数字。
在本章中,我们将涵盖几个常见的特征工程任务示例:我们将查看用于表示分类数据、文本和图像的特征。此外,我们还将讨论增加模型复杂性和填补缺失数据的派生特征。这个过程通常被称为向量化,因为它涉及将任意数据转换为行为良好的向量。
分类特征
一种常见的非数值数据类型是分类数据。例如,想象一下您正在探索一些关于房价的数据,除了像“价格”和“房间”这样的数值特征之外,还有“街区”信息。例如,您的数据可能如下所示:
In [1]: data = [
{'price': 850000, 'rooms': 4, 'neighborhood': 'Queen Anne'},
{'price': 700000, 'rooms': 3, 'neighborhood': 'Fremont'},
{'price': 650000, 'rooms': 3, 'neighborhood': 'Wallingford'},
{'price': 600000, 'rooms': 2, 'neighborhood': 'Fremont'}
]
您可能会被诱惑使用直接的数值映射来对这些数据进行编码:
In [2]: {'Queen Anne': 1, 'Fremont': 2, 'Wallingford': 3};
但事实证明,在 Scikit-Learn 中,这一般不是一个有用的方法。该软件包的模型假设数值特征反映了代数量,因此这样的映射会暗示,例如,Queen Anne < Fremont < Wallingford,甚至是Wallingford–Queen Anne = Fremont,这(除了小众的人口统计笑话)并没有多少意义。
在这种情况下,一个经过验证的技术是使用独热编码,它有效地创建额外的列,指示类别的存在或不存在,分别为 1 或 0。当您的数据采取字典列表的形式时,Scikit-Learn 的 DictVectorizer
将为您执行此操作:
In [3]: from sklearn.feature_extraction import DictVectorizer
vec = DictVectorizer(sparse=False, dtype=int)
vec.fit_transform(data)
Out[3]: array([[ 0, 1, 0, 850000, 4],
[ 1, 0, 0, 700000, 3],
[ 0, 0, 1, 650000, 3],
[ 1, 0, 0, 600000, 2]])
请注意,neighborhood
列已扩展为三个单独的列,表示三个街区标签,每一行都在与其街区相关联的列中具有 1。有了这些分类特征编码,您可以像正常情况下一样拟合一个 Scikit-Learn 模型。
要查看每一列的含义,您可以检查特征名称:
In [4]: vec.get_feature_names_out()
Out[4]: array(['neighborhood=Fremont', 'neighborhood=Queen Anne',
'neighborhood=Wallingford', 'price', 'rooms'], dtype=object)
这种方法有一个明显的缺点:如果您的类别有许多可能的值,这可能会大大增加数据集的大小。然而,因为编码数据主要包含零,所以稀疏输出可以是一个非常有效的解决方案:
In [5]: vec = DictVectorizer(sparse=True, dtype=int)
vec.fit_transform(data)
Out[5]: <4x5 sparse matrix of type '<class 'numpy.int64'>'
with 12 stored elements in Compressed Sparse Row format>
几乎所有的 Scikit-Learn 评估器都接受这种稀疏输入来拟合和评估模型。Scikit-Learn 包括的另外两个支持这种编码类型的工具是 sklearn.preprocessing.OneHotEncoder
和 sklearn.feature_extraction.FeatureHasher
。
文本特征
特征工程中另一个常见需求是将文本转换为一组代表性的数值。例如,大多数自动挖掘社交媒体数据都依赖于某种形式的文本编码为数字。编码这种类型数据的最简单方法之一是词频:你拿到每段文本,计算其中每个词的出现次数,并将结果放入表格中。
例如,考虑以下三个短语的集合:
In [6]: sample = ['problem of evil',
'evil queen',
'horizon problem']
对于基于词频的数据向量化,我们可以构建代表词语“问题”、“of”、“evil”等的单独列。虽然在这个简单的例子中手动操作是可能的,但可以通过使用 Scikit-Learn 的CountVectorizer
来避免这种单调的工作:
In [7]: from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer()
X = vec.fit_transform(sample)
X
Out[7]: <3x5 sparse matrix of type '<class 'numpy.int64'>'
with 7 stored elements in Compressed Sparse Row format>
结果是一个记录每个词出现次数的稀疏矩阵;如果我们将其转换为带有标记列的DataFrame
,检查起来就更容易了。
In [8]: import pandas as pd
pd.DataFrame(X.toarray(), columns=vec.get_feature_names_out())
Out[8]: evil horizon of problem queen
0 1 0 1 1 0
1 1 0 0 0 1
2 0 1 0 1 0
然而,使用简单的原始词频存在一些问题:它可能会导致对出现非常频繁的词给予过多权重,这在某些分类算法中可能不是最优的。修正这一问题的一种方法称为词频-逆文档频率(TF-IDF),它通过衡量词语在文档中出现的频率来加权词频。计算这些特征的语法与前面的例子类似:
实线显示了新结果,而较淡的虚线显示了之前较小数据集的结果。从验证曲线可以明显看出,较大的数据集可以支持更复杂的模型:这里的峰值可能在 6 次方附近,但即使是 20 次方的模型也没有严重过拟合数据——验证分数和训练分数保持非常接近。
In [9]: from sklearn.feature_extraction.text import TfidfVectorizer
vec = TfidfVectorizer()
X = vec.fit_transform(sample)
pd.DataFrame(X.toarray(), columns=vec.get_feature_names_out())
Out[9]: evil horizon of problem queen
0 0.517856 0.000000 0.680919 0.517856 0.000000
1 0.605349 0.000000 0.000000 0.000000 0.795961
2 0.000000 0.795961 0.000000 0.605349 0.000000
关于在分类问题中使用 TF-IDF 的示例,请参阅第四十一章。
图像特征
另一个常见需求是为机器学习分析适当编码图像。最简单的方法是我们在第三十八章中用于数字数据的方法:仅使用像素值本身。但根据应用程序的不同,这种方法可能不是最优的。
关于图像的特征提取技术的全面总结远超出本章的范围,但你可以在Scikit-Image 项目中找到许多标准方法的出色实现。关于如何同时使用 Scikit-Learn 和 Scikit-Image 的示例,请参阅第五十章。
派生特征
另一种有用的特征类型是从某些输入特征数学推导出来的特征。我们在第三十九章中看到了一个例子,当我们从输入数据构建多项式特征时。我们看到,我们可以将线性回归转换为多项式回归,而不是改变模型,而是通过转换输入!
例如,这些数据显然不能用一条直线很好地描述(见图 40-1):
In [10]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
x = np.array([1, 2, 3, 4, 5])
y = np.array([4, 2, 1, 3, 7])
plt.scatter(x, y);
图 40-1. 不能很好地用直线描述的数据
我们仍然可以使用LinearRegression
对数据进行线性拟合,并获得最优结果,如图 40-2 所示:
In [11]: from sklearn.linear_model import LinearRegression
X = x[:, np.newaxis]
model = LinearRegression().fit(X, y)
yfit = model.predict(X)
plt.scatter(x, y)
plt.plot(x, yfit);
图 40-2. 一条较差的直线拟合
但显然我们需要一个更复杂的模型来描述和之间的关系。
对此的一种方法是转换数据,添加额外的特征列以增强模型的灵活性。例如,我们可以这样向数据中添加多项式特征:
In [12]: from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=3, include_bias=False)
X2 = poly.fit_transform(X)
print(X2)
Out[12]: [[ 1. 1. 1.]
[ 2. 4. 8.]
[ 3. 9. 27.]
[ 4. 16. 64.]
[ 5. 25. 125.]]
派生的特征矩阵有一列表示,第二列表示,第三列表示。在这扩展输入上计算线性回归可以更接近我们的数据,如你在图 40-3 中所见:
In [13]: model = LinearRegression().fit(X2, y)
yfit = model.predict(X2)
plt.scatter(x, y)
plt.plot(x, yfit);
图 40-3. 对数据导出的多项式特征进行线性拟合
改进模型的一个思路不是改变模型本身,而是转换输入数据,这对许多更强大的机器学习方法至关重要。我们将在第四十二章进一步探讨这个想法,这是基函数回归的一个例子。更一般地说,这是强大技术集合——核方法的动机之一,我们将在第四十三章中探讨。
缺失数据的插补
特征工程中另一个常见需求是处理缺失数据。我们在第十六章中讨论了在DataFrame
对象中处理缺失数据的方法,并看到NaN
通常用于标记缺失值。例如,我们可能有一个数据集看起来像这样:
In [14]: from numpy import nan
X = np.array([[ nan, 0, 3 ],
[ 3, 7, 9 ],
[ 3, 5, 2 ],
[ 4, nan, 6 ],
[ 8, 8, 1 ]])
y = np.array([14, 16, -1, 8, -5])
当将典型的机器学习模型应用于这类数据时,我们需要首先用适当的填充值替换缺失值。这被称为缺失值的插补,策略从简单(例如,用列的平均值替换缺失值)到复杂(例如,使用矩阵完成或强健模型处理此类数据)。
高级方法往往非常依赖于特定应用场景,我们在这里不会深入讨论。对于使用均值、中位数或最频繁值的基本插补方法,Scikit-Learn 提供了SimpleImputer
类:
In [15]: from sklearn.impute import SimpleImputer
imp = SimpleImputer(strategy='mean')
X2 = imp.fit_transform(X)
X2
Out[15]: array([[4.5, 0. , 3. ],
[3. , 7. , 9. ],
[3. , 5. , 2. ],
[4. , 5. , 6. ],
[8. , 8. , 1. ]])
我们看到在结果数据中,两个缺失值已经被替换为该列其余值的平均值。这些填充的数据可以直接输入到例如LinearRegression
估算器中:
In [16]: model = LinearRegression().fit(X2, y)
model.predict(X2)
Out[16]: array([13.14869292, 14.3784627 , -1.15539732, 10.96606197, -5.33782027])
特征管道
使用任何上述示例,如果希望手动执行转换,尤其是希望串联多个步骤时,可能很快变得乏味。例如,我们可能希望一个处理管道看起来像这样:
-
使用均值填补缺失值。
-
将特征转换为二次项。
-
拟合线性回归模型。
为了简化这种类型的处理管道,Scikit-Learn 提供了一个Pipeline
对象,可以如下使用:
In [17]: from sklearn.pipeline import make_pipeline
model = make_pipeline(SimpleImputer(strategy='mean'),
PolynomialFeatures(degree=2),
LinearRegression())
这个管道看起来和操作起来像一个标准的 Scikit-Learn 对象,将所有指定的步骤应用于任何输入数据:
In [18]: model.fit(X, y) # X with missing values, from above
print(y)
print(model.predict(X))
Out[18]: [14 16 -1 8 -5]
[14. 16. -1. 8. -5.]
模型的所有步骤都是自动应用的。请注意,为了简单起见,在这个演示中,我们已经将模型应用于它训练过的数据;这就是为什么它能够完美地预测结果(详见第三十九章进一步讨论)。
有关 Scikit-Learn 管道实际操作的一些示例,请参阅有关朴素贝叶斯分类的以下章节,以及第四十二章和第四十三章。
第四十一章:深入:朴素贝叶斯分类
前四章概述了机器学习的概念。在第五部分的其余部分,我们将首先更详细地查看四种监督学习算法,然后是四种无监督学习算法。我们从第一个监督方法朴素贝叶斯分类开始。
朴素贝叶斯模型是一组极快速且简单的分类算法,通常适用于非常高维度的数据集。因为它们速度快、可调参数少,所以它们通常用作分类问题的快速基准线。本章将提供朴素贝叶斯分类器工作原理的直观解释,并在一些数据集上展示它们的几个例子。
贝叶斯分类
朴素贝叶斯分类器是建立在贝叶斯分类方法之上的。这些方法依赖于贝叶斯定理,该定理描述了统计量的条件概率关系。在贝叶斯分类中,我们感兴趣的是找到给定一些观察特征的标签的概率,可以写作。贝叶斯定理告诉我们如何用我们可以更直接计算的量来表达这一点:
如果我们试图在两个标签之间做出决策——让我们称它们为和——那么做出这个决定的一种方法是计算每个标签的后验概率的比率:
现在我们所需的只是一些模型,通过这些模型我们可以计算每个标签。这样的模型被称为生成模型,因为它指定了生成数据的假设随机过程。为每个标签指定这种生成模型是这样一个贝叶斯分类器训练的主要部分。对于这样一个训练步骤的一般版本来说,这是一个非常困难的任务,但是我们可以通过对这个模型形式做一些简化的假设来简化它。
这就是“朴素贝叶斯”中的“朴素”所在:如果我们对每个标签的生成模型作出非常朴素的假设,我们可以找到每个类别的生成模型的粗略近似,然后继续贝叶斯分类。不同类型的朴素贝叶斯分类器基于关于数据的不同朴素假设,我们将在接下来的几节中讨论其中一些。
我们从标准导入开始:
In [1]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
plt.style.use('seaborn-whitegrid')
高斯朴素贝叶斯
或许最容易理解的朴素贝叶斯分类器是高斯朴素贝叶斯。使用这个分类器,假设每个标签的数据都来自简单的高斯分布。想象一下我们有以下数据,显示在图 41-1 中:
In [2]: from sklearn.datasets import make_blobs
X, y = make_blobs(100, 2, centers=2, random_state=2, cluster_std=1.5)
plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='RdBu');
图 41-1. 高斯朴素贝叶斯分类数据^(1)
最简单的高斯模型假设数据由没有各维度间协方差的高斯分布描述。这个模型可以通过计算每个标签内点的均值和标准差来拟合,这是我们定义这种分布所需的所有内容。这种朴素高斯假设的结果显示在图 41-2 中。
图 41-2. 高斯朴素贝叶斯模型可视化^(2)
这里的椭圆代表每个标签的高斯生成模型,中心区域的概率更高。有了每个类别的生成模型,我们可以简单地计算任何数据点的似然,因此我们可以快速计算后验比率,并确定给定点最有可能的标签。
这个过程在 Scikit-Learn 的sklearn.naive_bayes.GaussianNB
估计器中实现:
In [3]: from sklearn.naive_bayes import GaussianNB
model = GaussianNB()
model.fit(X, y);
让我们生成一些新数据并预测标签:
In [4]: rng = np.random.RandomState(0)
Xnew = [-6, -14] + [14, 18] * rng.rand(2000, 2)
ynew = model.predict(Xnew)
现在我们可以绘制这些新数据,以了解决策边界的位置(见图 41-3)。
In [5]: plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='RdBu')
lim = plt.axis()
plt.scatter(Xnew[:, 0], Xnew[:, 1], c=ynew, s=20, cmap='RdBu', alpha=0.1)
plt.axis(lim);
图 41-3. 高斯朴素贝叶斯分类可视化
我们可以看到分类中有一个略微弯曲的边界——一般来说,高斯朴素贝叶斯模型产生的边界将是二次的。
这种贝叶斯形式主义的一个优点是它自然地允许概率分类,我们可以使用predict_proba
方法来计算:
In [6]: yprob = model.predict_proba(Xnew)
yprob[-8:].round(2)
Out[6]: array([[0.89, 0.11],
[1. , 0. ],
[1. , 0. ],
[1. , 0. ],
[1. , 0. ],
[1. , 0. ],
[0. , 1. ],
[0.15, 0.85]])
这些列分别给出了第一个和第二个标签的后验概率。如果您正在寻找分类中的不确定性估计,像这样的贝叶斯方法可能是一个很好的起点。
当然,最终的分类结果将仅仅与导致它的模型假设一样好,这就是为什么高斯朴素贝叶斯通常不会产生非常好的结果的原因。尽管如此,在许多情况下——特别是特征数量变得很大时——这种假设并不足以阻止高斯朴素贝叶斯成为一种可靠的方法。
多项式朴素贝叶斯
刚刚描述的高斯假设远非是可以用于指定每个标签生成分布的唯一简单假设。另一个有用的例子是多项式朴素贝叶斯,其中假设特征是从简单的多项分布生成的。多项分布描述了在多个类别中观察计数的概率,因此多项式朴素贝叶斯最适合表示计数或计数率的特征。
这个想法与之前完全相同,只是不再用最佳拟合的高斯来建模数据分布,而是用最佳拟合的多项分布来建模。
示例:文本分类
多项式朴素贝叶斯经常用于文本分类的一个场合,其中特征与要分类的文档中的单词计数或频率相关。我们在第四十章中讨论了从文本中提取这些特征;在这里,我们将使用通过 Scikit-Learn 提供的 20 个新闻组语料库中的稀疏单词计数特征来展示如何将这些简短文档分类到不同的类别中。
让我们下载数据并查看目标名称:
In [7]: from sklearn.datasets import fetch_20newsgroups
data = fetch_20newsgroups()
data.target_names
Out[7]: ['alt.atheism',
'comp.graphics',
'comp.os.ms-windows.misc',
'comp.sys.ibm.pc.hardware',
'comp.sys.mac.hardware',
'comp.windows.x',
'misc.forsale',
'rec.autos',
'rec.motorcycles',
'rec.sport.baseball',
'rec.sport.hockey',
'sci.crypt',
'sci.electronics',
'sci.med',
'sci.space',
'soc.religion.christian',
'talk.politics.guns',
'talk.politics.mideast',
'talk.politics.misc',
'talk.religion.misc']
为了简单起见,在这里我们将只选择其中几个类别并下载训练和测试集:
In [8]: categories = ['talk.religion.misc', 'soc.religion.christian',
'sci.space', 'comp.graphics']
train = fetch_20newsgroups(subset='train', categories=categories)
test = fetch_20newsgroups(subset='test', categories=categories)
这里是数据的一个代表性条目:
In [9]: print(train.data[5][48:])
Out[9]: Subject: Federal Hearing
Originator: dmcgee@uluhe
Organization: School of Ocean and Earth Science and Technology
Distribution: usa
Lines: 10
Fact or rumor....? Madalyn Murray O'Hare an atheist who eliminated the
use of the bible reading and prayer in public schools 15 years ago is now
going to appear before the FCC with a petition to stop the reading of the
Gospel on the airways of America. And she is also campaigning to remove
Christmas programs, songs, etc from the public schools. If it is true
then mail to Federal Communications Commission 1919 H Street Washington DC
20054 expressing your opposition to her request. Reference Petition number
2493.
为了将这些数据用于机器学习,我们需要将每个字符串的内容转换为一个数字向量。为此,我们将使用 TF-IDF 向量化器(在第四十章介绍),并创建一个管道,将其附加到多项式朴素贝叶斯分类器:
In [10]: from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline
model = make_pipeline(TfidfVectorizer(), MultinomialNB())
有了这个管道,我们可以将模型应用于训练数据,并预测测试数据的标签:
In [11]: model.fit(train.data, train.target)
labels = model.predict(test.data)
现在我们已经预测了测试数据的标签,我们可以评估它们以了解估计器的性能。例如,让我们看一下测试数据的真实标签和预测标签之间的混淆矩阵(参见图 41-4)。
In [12]: from sklearn.metrics import confusion_matrix
mat = confusion_matrix(test.target, labels)
sns.heatmap(mat.T, square=True, annot=True, fmt='d', cbar=False,
xticklabels=train.target_names, yticklabels=train.target_names,
cmap='Blues')
plt.xlabel('true label')
plt.ylabel('predicted label');
显然,即使这个非常简单的分类器可以成功地将关于空间讨论与计算机讨论分开,但它会在宗教讨论和基督教讨论之间感到困惑。这或许是可以预料的!
这里很酷的一点是,我们现在有工具来确定任何字符串的类别,只需使用此管道的predict
方法。下面是一个实用函数,用于返回单个字符串的预测结果:
In [13]: def predict_category(s, train=train, model=model):
pred = model.predict([s])
return train.target_names[pred[0]]
让我们试试它:
In [14]: predict_category('sending a payload to the ISS')
Out[14]: 'sci.space'
In [15]: predict_category('discussing the existence of God')
Out[15]: 'soc.religion.christian'
In [16]: predict_category('determining the screen resolution')
Out[16]: 'comp.graphics'
图 41-4. 多项式朴素贝叶斯文本分类器的混淆矩阵
请记住,这只是一个对字符串中每个单词(加权)频率的简单概率模型;尽管如此,结果令人印象深刻。即使是非常朴素的算法,在小心使用并在大量高维数据上训练时,也可以出奇地有效。
何时使用朴素贝叶斯
由于朴素贝叶斯分类器对数据做出如此严格的假设,它们通常不如更复杂的模型表现好。尽管如此,它们有几个优点:
-
它们在训练和预测时都非常快速。
-
它们提供直观的概率预测。
-
它们通常易于解释。
-
它们具有少量(如果有的话)可调参数。
这些优势意味着朴素贝叶斯分类器通常是作为初始基线分类的不错选择。如果它表现得合适,那么恭喜你:你已经拥有了一个非常快速、易于解释的分类器来解决你的问题。如果它表现不佳,那么你可以开始探索更复杂的模型,同时具备一些关于它们应该如何表现的基础知识。
朴素贝叶斯分类器在以下情况下表现特别好:
-
当朴素假设实际上与数据匹配时(在实践中非常罕见)
-
对于非常分离的类别,当模型复杂度不那么重要时
-
对于非常高维数据,当模型复杂度不那么重要时
最后两点看似不同,但实际上是相关的:随着数据集维度的增长,任何两个点在一起的可能性大大降低(毕竟,它们必须在每个维度上都很接近才能在总体上接近)。这意味着在高维空间中,簇通常比低维空间中更为分离,平均而言。基于这个原因,像这里讨论的简单分类器往往在维度增加时表现得同样或更好:一旦你有足够的数据,即使是简单模型也可以非常强大。
^(1) 此图的全彩版本可在GitHub上找到。
^(2) 生成此图的代码可在在线附录中找到。
第四十二章:深入解析:线性回归
就像朴素贝叶斯(讨论见第四十一章)对于分类任务是一个很好的起点一样,线性回归模型对于回归任务也是一个很好的起点。这样的模型很受欢迎,因为它们可以快速拟合并且易于解释。你已经熟悉了最简单形式的线性回归模型(即将直线拟合到二维数据),但是这样的模型可以扩展到对更复杂的数据行为进行建模。
在本章中,我们将首先快速了解这个众所周知问题背后的数学知识,然后再看看线性模型如何被泛化以解决数据中更复杂的模式。
我们从标准导入开始:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
简单线性回归
我们将从最熟悉的线性回归开始,即对数据进行直线拟合。直线拟合是一个形式为的模型:
其中通常被称为斜率,而通常被称为截距。
考虑以下数据,这些数据分布在一条斜率为 2,截距为-5 的直线周围(见图 42-1)。
In [2]: rng = np.random.RandomState(1)
x = 10 * rng.rand(50)
y = 2 * x - 5 + rng.randn(50)
plt.scatter(x, y);
图 42-1. 线性回归的数据
我们可以使用 Scikit-Learn 的LinearRegression
估计器来拟合这些数据并构建最佳拟合线,如图 42-2 所示。
In [3]: from sklearn.linear_model import LinearRegression
model = LinearRegression(fit_intercept=True)
model.fit(x[:, np.newaxis], y)
xfit = np.linspace(0, 10, 1000)
yfit = model.predict(xfit[:, np.newaxis])
plt.scatter(x, y)
plt.plot(xfit, yfit);
图 42-2. 一个简单的线性回归模型
数据的斜率和截距包含在模型的拟合参数中,在 Scikit-Learn 中始终以下划线结尾标记。这里的相关参数是coef_
和intercept_
:
In [4]: print("Model slope: ", model.coef_[0])
print("Model intercept:", model.intercept_)
Out[4]: Model slope: 2.0272088103606953
Model intercept: -4.998577085553204
我们看到结果与用于生成数据的值非常接近,这是我们所希望的。
然而,LinearRegression
估计器要比这更强大——除了简单的直线拟合外,它还可以处理形式为的多维线性模型:
其中有多个值。从几何上讲,这类似于在三维空间中对点拟合平面,或者在更高维度中对点拟合超平面。
这种回归的多维性使其更难以可视化,但我们可以通过构建一些示例数据,使用 NumPy 的矩阵乘法运算符来看到其中一个拟合的过程:
In [5]: rng = np.random.RandomState(1)
X = 10 * rng.rand(100, 3)
y = 0.5 + np.dot(X, [1.5, -2., 1.])
model.fit(X, y)
print(model.intercept_)
print(model.coef_)
Out[5]: 0.50000000000001
[ 1.5 -2. 1. ]
这里的数据是从三个随机值的线性组合构成的,线性回归恢复了用于构建数据的系数。
通过这种方式,我们可以使用单个 LinearRegression
评估器来拟合线条、平面或超平面到我们的数据。看起来这种方法仍然限制在变量之间严格的线性关系,但事实证明我们也可以放宽这一点。
基函数回归
你可以用的一个技巧是将线性回归适应变量之间的非线性关系,方法是根据 基函数 转换数据。我们之前已经见过这样的一个版本,在第三十九章和第四十章中使用的 PolynomialRegression
流水线中。这个想法是将我们的多维线性模型:
并从我们的单维输入 中构建 等等。也就是说,我们让 ,其中 是将我们的数据转换的某个函数。
例如,如果 ,我们的模型就会变成多项式回归:
注意,这仍然是 线性模型 —— 线性指的是系数 从不相乘或相除。我们所做的实质上是将我们的一维 值投影到更高的维度,这样线性拟合可以拟合 和 之间更复杂的关系。
多项式基函数
这种多项式投影非常有用,以至于它被内置到 Scikit-Learn 中,使用 PolynomialFeatures
变换器:
In [6]: from sklearn.preprocessing import PolynomialFeatures
x = np.array([2, 3, 4])
poly = PolynomialFeatures(3, include_bias=False)
poly.fit_transform(x[:, None])
Out[6]: array([[ 2., 4., 8.],
[ 3., 9., 27.],
[ 4., 16., 64.]])
我们在这里看到,变换器已经将我们的一维数组转换为一个三维数组,其中每列包含了指数化的值。这种新的、更高维的数据表示可以被插入到线性回归中。
正如我们在第四十章中看到的,实现这一点的最干净的方法是使用一个流水线。让我们用这种方式制作一个 7 次多项式模型:
In [7]: from sklearn.pipeline import make_pipeline
poly_model = make_pipeline(PolynomialFeatures(7),
LinearRegression())
通过这种转换,我们可以使用线性模型更好地拟合和之间更复杂的关系。例如,这里是带噪声的正弦波(参见图 42-3)。
In [8]: rng = np.random.RandomState(1)
x = 10 * rng.rand(50)
y = np.sin(x) + 0.1 * rng.randn(50)
poly_model.fit(x[:, np.newaxis], y)
yfit = poly_model.predict(xfit[:, np.newaxis])
plt.scatter(x, y)
plt.plot(xfit, yfit);
图 42-3. 对非线性训练数据进行线性多项式拟合
我们的线性模型,通过使用七阶多项式基函数,可以很好地拟合这些非线性数据!
高斯基函数
当然,也可以使用其他基函数。例如,一个有用的模式是拟合一个不是多项式基函数的模型,而是高斯基函数的总和。结果可能看起来像是图 42-4。
图 42-4. 高斯基函数拟合非线性数据^(1)
图中阴影区域是经过缩放的基函数,将它们相加后可以重现数据中的平滑曲线。这些高斯基函数没有内置到 Scikit-Learn 中,但我们可以编写一个自定义转换器来创建它们,如此处所示,并在图 42-5 中进行了说明(Scikit-Learn 的转换器是以 Python 类的形式实现的;查看 Scikit-Learn 的源代码是了解它们如何创建的好方法):
In [9]: from sklearn.base import BaseEstimator, TransformerMixin
class GaussianFeatures(BaseEstimator, TransformerMixin):
"""Uniformly spaced Gaussian features for one-dimensional input"""
def __init__(self, N, width_factor=2.0):
self.N = N
self.width_factor = width_factor
@staticmethod
def _gauss_basis(x, y, width, axis=None):
arg = (x - y) / width
return np.exp(-0.5 * np.sum(arg ** 2, axis))
def fit(self, X, y=None):
# create N centers spread along the data range
self.centers_ = np.linspace(X.min(), X.max(), self.N)
self.width_ = self.width_factor*(self.centers_[1]-self.centers_[0])
return self
def transform(self, X):
return self._gauss_basis(X[:, :, np.newaxis], self.centers_,
self.width_, axis=1)
gauss_model = make_pipeline(GaussianFeatures(20),
LinearRegression())
gauss_model.fit(x[:, np.newaxis], y)
yfit = gauss_model.predict(xfit[:, np.newaxis])
plt.scatter(x, y)
plt.plot(xfit, yfit)
plt.xlim(0, 10);
图 42-5. 使用自定义转换器计算的高斯基函数拟合
我包含了这个例子,只是为了明确指出多项式基函数并非魔法:如果你对数据生成过程有某种直觉,认为某种基函数可能更合适,你可以使用它。
正则化
将基函数引入线性回归中使模型更加灵活,但也很快会导致过拟合(参见第三十九章中的讨论)。例如,如果使用大量高斯基函数,图 42-6 展示了会发生什么:
In [10]: model = make_pipeline(GaussianFeatures(30),
LinearRegression())
model.fit(x[:, np.newaxis], y)
plt.scatter(x, y)
plt.plot(xfit, model.predict(xfit[:, np.newaxis]))
plt.xlim(0, 10)
plt.ylim(-1.5, 1.5);
图 42-6. 过度复杂的基函数模型会过拟合数据
将数据投影到 30 维基函数后,模型的灵活性大大增加,并在数据约束位置之间出现极值。如果我们绘制高斯基函数系数相对于它们位置的图表,我们可以看到这一现象,如图 42-7 所示。
In [11]: def basis_plot(model, title=None):
fig, ax = plt.subplots(2, sharex=True)
model.fit(x[:, np.newaxis], y)
ax[0].scatter(x, y)
ax[0].plot(xfit, model.predict(xfit[:, np.newaxis]))
ax[0].set(xlabel='x', ylabel='y', ylim=(-1.5, 1.5))
if title:
ax[0].set_title(title)
ax[1].plot(model.steps[0][1].centers_,
model.steps[1][1].coef_)
ax[1].set(xlabel='basis location',
ylabel='coefficient',
xlim=(0, 10))
model = make_pipeline(GaussianFeatures(30), LinearRegression())
basis_plot(model)
图 42-7. 过度复杂模型中高斯基函数的系数
此图的下部面板显示了每个位置的基函数的幅度。当基函数重叠时,这是典型的过拟合行为:相邻基函数的系数会急剧增加并相互抵消。我们知道这种行为是有问题的,如果我们可以通过惩罚模型参数的大值来明确限制这样的峰值,那就太好了。这样的惩罚被称为正则化,有几种形式。
岭回归(L[2]正则化)
或许最常见的正则化形式被称为岭回归或 正则化(有时也称为Tikhonov 正则化)。这通过对模型系数的平方和(2-范数)进行惩罚来实现。在这种情况下,模型拟合的惩罚将是:
其中是一个自由参数,用于控制惩罚的强度。这种类型的惩罚模型已经内置到 Scikit-Learn 中的Ridge
估计器中(参见 Figure 42-8)。
In [12]: from sklearn.linear_model import Ridge
model = make_pipeline(GaussianFeatures(30), Ridge(alpha=0.1))
basis_plot(model, title='Ridge Regression')
图 42-8. 岭(L[2])正则化应用于过度复杂的模型(与 Figure 42-7 进行比较)
参数本质上是一个控制生成模型复杂性的旋钮。在极限中,我们恢复了标准线性回归结果;在极限中,所有模型响应都将被抑制。岭回归的一个优点是它特别高效地计算—几乎没有比原始线性回归模型更多的计算成本。
套索回归(L[1]正则化)
另一种常见的正则化方法被称为套索回归或L[1]正则化,它涉及对回归系数的绝对值(1-范数)的惩罚:
尽管这在概念上与岭回归非常相似,但结果可能出奇地不同。例如,由于其构造,套索回归倾向于偏爱可能的稀疏模型:也就是说,它更倾向于将许多模型系数设为零。
如果我们使用 -归一化系数复制前面的示例,我们可以看到这种行为(参见 Figure 42-9)。
In [13]: from sklearn.linear_model import Lasso
model = make_pipeline(GaussianFeatures(30),
Lasso(alpha=0.001, max_iter=2000))
basis_plot(model, title='Lasso Regression')
图 42-9. 套索(L[1])正则化应用于过度复杂的模型(与 Figure 42-8 进行比较)
使用套索回归惩罚,大多数系数确实为零,功能行为由可用基函数的一小部分建模。与岭回归一样,参数调节惩罚的强度,应通过例如交叉验证确定(请参阅第三十九章讨论此问题)。
示例:预测自行车流量
举个例子,让我们看看是否能够预测西雅图弗里蒙特桥上的自行车出行次数,基于天气、季节和其他因素。我们在第二十三章已经看过这些数据,但在这里我们将自行车数据与另一个数据集结合,并尝试确定天气和季节因素——温度、降水和日照小时——对该走廊自行车流量的影响程度。幸运的是,美国国家海洋和大气管理局(NOAA)提供其日常气象站数据——我使用的是站点 ID USW00024233——我们可以轻松使用 Pandas 将这两个数据源连接起来。我们将执行简单的线性回归,将天气和其他信息与自行车计数关联起来,以估算这些参数中的任何变化如何影响给定日的骑行者数量。
特别是,这是 Scikit-Learn 工具如何在统计建模框架中使用的示例,其中假定模型的参数具有可解释的含义。正如前面讨论的那样,这不是机器学习中的标准方法,但对于某些模型是可能的。
让我们从加载两个数据集开始,以日期为索引:
In [14]: # url = 'https://raw.githubusercontent.com/jakevdp/bicycle-data/main'
# !curl -O {url}/FremontBridge.csv
# !curl -O {url}/SeattleWeather.csv
In [15]: import pandas as pd
counts = pd.read_csv('FremontBridge.csv',
index_col='Date', parse_dates=True)
weather = pd.read_csv('SeattleWeather.csv',
index_col='DATE', parse_dates=True)
为简单起见,让我们查看 2020 年之前的数据,以避免新冠肺炎大流行的影响,这显著影响了西雅图的通勤模式:
In [16]: counts = counts[counts.index < "2020-01-01"]
weather = weather[weather.index < "2020-01-01"]
接下来我们将计算每日自行车总流量,并将其放入独立的DataFrame
中:
In [17]: daily = counts.resample('d').sum()
daily['Total'] = daily.sum(axis=1)
daily = daily[['Total']] # remove other columns
我们之前看到使用模式通常从一天到另一天有所不同。让我们在数据中考虑这一点,通过添加指示星期几的二进制列:
In [18]: days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
for i in range(7):
daily[days[i]] = (daily.index.dayofweek == i).astype(float)
同样,我们可能期待骑行者在假日有不同的行为表现;让我们也加上一个指示器:
In [19]: from pandas.tseries.holiday import USFederalHolidayCalendar
cal = USFederalHolidayCalendar()
holidays = cal.holidays('2012', '2020')
daily = daily.join(pd.Series(1, index=holidays, name='holiday'))
daily['holiday'].fillna(0, inplace=True)
我们还可能怀疑白天的小时数会影响骑行的人数。让我们使用标准的天文计算来添加这些信息(参见图 42-10)。
In [20]: def hours_of_daylight(date, axis=23.44, latitude=47.61):
"""Compute the hours of daylight for the given date"""
days = (date - pd.datetime(2000, 12, 21)).days
m = (1. - np.tan(np.radians(latitude))
* np.tan(np.radians(axis) * np.cos(days * 2 * np.pi / 365.25)))
return 24. * np.degrees(np.arccos(1 - np.clip(m, 0, 2))) / 180.
daily['daylight_hrs'] = list(map(hours_of_daylight, daily.index))
daily[['daylight_hrs']].plot()
plt.ylim(8, 17)
Out[20]: (8.0, 17.0)
图 42-10. 西雅图的日照小时可视化
我们还可以将平均温度和总降水量添加到数据中。除了降水英寸外,让我们添加一个指示某一天是否干燥(降水量为零)的标志:
In [21]: weather['Temp (F)'] = 0.5 * (weather['TMIN'] + weather['TMAX'])
weather['Rainfall (in)'] = weather['PRCP']
weather['dry day'] = (weather['PRCP'] == 0).astype(int)
daily = daily.join(weather[['Rainfall (in)', 'Temp (F)', 'dry day']])
最后,让我们添加一个从第 1 天开始递增的计数器,并测量经过了多少年。这将让我们测量每日过境量的观察到的年增长或年减少:
In [22]: daily['annual'] = (daily.index - daily.index[0]).days / 365.
现在我们的数据已经整理好了,我们可以看一下:
In [23]: daily.head()
Out[23]: Total Mon Tue Wed Thu Fri Sat Sun holiday \
Date
2012-10-03 14084.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0
2012-10-04 13900.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0
2012-10-05 12592.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0
2012-10-06 8024.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
2012-10-07 8568.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0
daylight_hrs Rainfall (in) Temp (F) dry day annual
Date
2012-10-03 11.277359 0.0 56.0 1 0.000000
2012-10-04 11.219142 0.0 56.5 1 0.002740
2012-10-05 11.161038 0.0 59.5 1 0.005479
2012-10-06 11.103056 0.0 60.5 1 0.008219
2012-10-07 11.045208 0.0 60.5 1 0.010959
有了这些东西,我们可以选择要使用的列,并对我们的数据拟合一个线性回归模型。我们将设置fit_intercept=False
,因为每日标志基本上充当它们自己的特定于每天的截距:
In [24]: # Drop any rows with null values
daily.dropna(axis=0, how='any', inplace=True)
column_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
'holiday', 'daylight_hrs', 'Rainfall (in)',
'dry day', 'Temp (F)', 'annual']
X = daily[column_names]
y = daily['Total']
model = LinearRegression(fit_intercept=False)
model.fit(X, y)
daily['predicted'] = model.predict(X)
最后,我们可以通过视觉比较总的和预测的自行车流量(见图 42-11)。
In [25]: daily[['Total', 'predicted']].plot(alpha=0.5);
图 42-11. 我们模型对自行车流量的预测
从数据和模型预测不完全一致的事实来看,很明显我们已经错过了一些关键特征。我们的特征要么不完整(即,人们决定是否骑自行车去工作基于不止这些特征),要么有一些非线性关系我们没有考虑到(例如,也许在高温和低温下人们骑行较少)。然而,我们的粗略近似足以给我们一些见解,我们可以查看线性模型的系数以估算每个特征对每日自行车数量的贡献程度:
In [26]: params = pd.Series(model.coef_, index=X.columns)
params
Out[26]: Mon -3309.953439
Tue -2860.625060
Wed -2962.889892
Thu -3480.656444
Fri -4836.064503
Sat -10436.802843
Sun -10795.195718
holiday -5006.995232
daylight_hrs 409.146368
Rainfall (in) -2789.860745
dry day 2111.069565
Temp (F) 179.026296
annual 324.437749
dtype: float64
这些数字如果没有一些不确定性的度量,就很难解释。我们可以通过对数据进行自举重采样来快速计算这些不确定性:
In [27]: from sklearn.utils import resample
np.random.seed(1)
err = np.std([model.fit(*resample(X, y)).coef_
for i in range(1000)], 0)
有了这些误差估计,让我们再次查看结果:
In [28]: print(pd.DataFrame({'effect': params.round(0),
'uncertainty': err.round(0)}))
Out[28]: effect uncertainty
Mon -3310.0 265.0
Tue -2861.0 274.0
Wed -2963.0 268.0
Thu -3481.0 268.0
Fri -4836.0 261.0
Sat -10437.0 259.0
Sun -10795.0 267.0
holiday -5007.0 401.0
daylight_hrs 409.0 26.0
Rainfall (in) -2790.0 186.0
dry day 2111.0 101.0
Temp (F) 179.0 7.0
annual 324.0 22.0
这里的effect
列,粗略地说,显示了骑手数量如何受到所讨论特征变化的影响。例如,一周中的某一天就有明显的区别:周末的骑手要比工作日少几千人。我们还看到,每增加一个小时的阳光,会有 409 ± 26 人选择骑行;华氏度每增加一度,就会有 179 ± 7 人选择骑自行车;晴天意味着平均增加 2,111 ± 101 名骑手,而每英寸的降雨则导致 2,790 ± 186 名骑手选择另一种交通方式。一旦考虑了所有这些影响,我们就会看到每年新的日常骑手数量有一个适度的增加,为 324 ± 22 人。
我们的简单模型几乎肯定会缺少一些相关信息。例如,正如前面提到的,非线性效应(如降水和寒冷温度的影响)以及每个变量内的非线性趋势(例如对极冷和极热温度不愿意骑车的影响)无法在简单的线性模型中考虑。此外,我们还丢弃了一些更精细的信息(例如雨天早晨和雨天下午之间的差异),并且忽略了天之间的相关性(例如一个雨天星期二对星期三的可能影响,或者在连续多日雨天后出现意外晴天的影响)。这些都是潜在的有趣效应,如果你愿意,现在你已经有了开始探索它们的工具!
^(1) 生成这个图表的代码可以在在线附录中找到。
第四十三章:深入探讨支持向量机
支持向量机(SVMs)是一种特别强大和灵活的监督算法类,适用于分类和回归。在本章中,我们将探讨 SVM 背后的直觉及其在分类问题中的应用。
我们从标准导入开始:
In [1]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
from scipy import stats
注
全尺寸、全彩色图像可在GitHub 的补充材料中找到。
激励支持向量机
作为我们讨论贝叶斯分类的一部分(参见第四十一章),我们了解到了描述每个潜在类分布的简单模型,并尝试使用它来概率地确定新点的标签。那是一个生成分类的例子;在这里,我们将考虑判别分类。也就是说,我们不再模拟每个类,而是简单地找到一个(在二维中为线或曲线,在多维中为流形),将类彼此分开。
作为一个例子,考虑一个分类任务的简单情况,其中两类点是完全分开的(见图 43-1)。
In [2]: from sklearn.datasets import make_blobs
X, y = make_blobs(n_samples=50, centers=2,
random_state=0, cluster_std=0.60)
plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn');
图 43-1. 分类简单数据
线性判别分类器将尝试画一条直线分隔两组数据,并因此创建一个分类模型。对于像这样的二维数据,我们可以手动完成这项任务。但我们立即看到了一个问题:存在不止一条可能完全区分这两类的分界线!
我们可以如下绘制其中一些;图 43-2 展示了结果:
In [3]: xfit = np.linspace(-1, 3.5)
plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn')
plt.plot([0.6], [2.1], 'x', color='red', markeredgewidth=2, markersize=10)
for m, b in [(1, 0.65), (0.5, 1.6), (-0.2, 2.9)]:
plt.plot(xfit, m * xfit + b, '-k')
plt.xlim(-1, 3.5);
图 43-2. 我们数据的三个完美线性判别分类器
这是三个非常不同的分隔符,尽管如此,它们完全可以区分这些样本。根据你选择的分界线,新的数据点(例如,在这个图中用“X”标记的点)将被分配不同的标签!显然,我们简单的“在类之间画一条线”的直觉不够好,我们需要更深入地思考。
支持向量机:最大化边缘
支持向量机提供了一种改进方法。其直觉是:与其简单地在类之间画一条零宽度线,我们可以在每条线周围绘制一定宽度的边缘,直到最近的点。这是一个展示的例子(见图 43-3)。
In [4]: xfit = np.linspace(-1, 3.5)
plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn')
for m, b, d in [(1, 0.65, 0.33), (0.5, 1.6, 0.55), (-0.2, 2.9, 0.2)]:
yfit = m * xfit + b
plt.plot(xfit, yfit, '-k')
plt.fill_between(xfit, yfit - d, yfit + d, edgecolor='none',
color='lightgray', alpha=0.5)
plt.xlim(-1, 3.5);
最大化这个边缘的线就是我们将选择作为最优模型的线。
图 43-3. 判别分类器内“边缘”的可视化
适配支持向量机
现在让我们看看对这些数据进行实际拟合的结果:我们将使用 Scikit-Learn 的支持向量分类器(SVC
)来训练一个 SVM 模型。暂时地,我们将使用线性核并将参数C
设置为一个非常大的数(稍后我们将深入讨论它们的含义):
In [5]: from sklearn.svm import SVC # "Support vector classifier"
model = SVC(kernel='linear', C=1E10)
model.fit(X, y)
Out[5]: SVC(C=10000000000.0, kernel='linear')
为了更好地可视化这里发生的情况,让我们创建一个快速便利函数,它将为我们绘制 SVM 决策边界(图 43-4)。
In [6]: def plot_svc_decision_function(model, ax=None, plot_support=True):
"""Plot the decision function for a 2D SVC"""
if ax is None:
ax = plt.gca()
xlim = ax.get_xlim()
ylim = ax.get_ylim()
# create grid to evaluate model
x = np.linspace(xlim[0], xlim[1], 30)
y = np.linspace(ylim[0], ylim[1], 30)
Y, X = np.meshgrid(y, x)
xy = np.vstack([X.ravel(), Y.ravel()]).T
P = model.decision_function(xy).reshape(X.shape)
# plot decision boundary and margins
ax.contour(X, Y, P, colors='k',
levels=[-1, 0, 1], alpha=0.5,
linestyles=['--', '-', '--'])
# plot support vectors
if plot_support:
ax.scatter(model.support_vectors_[:, 0],
model.support_vectors_[:, 1],
s=300, linewidth=1, edgecolors='black',
facecolors='none');
ax.set_xlim(xlim)
ax.set_ylim(ylim)
In [7]: plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn')
plot_svc_decision_function(model);
图 43-4. 一个拟合到数据的支持向量机分类器,显示了边界(虚线)和支持向量(圆点)
这是最大化两组点之间间隔的分隔线。请注意,一些训练点恰好接触边界:它们在图 43-5 中被圈出来。这些点是此拟合的关键元素;它们被称为支持向量,并赋予了算法其名称。在 Scikit-Learn 中,这些点的标识存储在分类器的support_vectors_
属性中:
In [8]: model.support_vectors_
Out[8]: array([[0.44359863, 3.11530945],
[2.33812285, 3.43116792],
[2.06156753, 1.96918596]])
此分类器成功的关键在于对拟合来说,只有支持向量的位置是重要的;远离边界但在正确一侧的点不会修改拟合。从技术上讲,这是因为这些点不会对用于拟合模型的损失函数产生贡献,因此它们的位置和数量并不重要,只要它们不跨越边界。
例如,如果我们绘制从这个数据集的前 60 个点和前 120 个点学习到的模型(图 43-5),我们可以看到这一点。
In [9]: def plot_svm(N=10, ax=None):
X, y = make_blobs(n_samples=200, centers=2,
random_state=0, cluster_std=0.60)
X = X[:N]
y = y[:N]
model = SVC(kernel='linear', C=1E10)
model.fit(X, y)
ax = ax or plt.gca()
ax.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn')
ax.set_xlim(-1, 4)
ax.set_ylim(-1, 6)
plot_svc_decision_function(model, ax)
fig, ax = plt.subplots(1, 2, figsize=(16, 6))
fig.subplots_adjust(left=0.0625, right=0.95, wspace=0.1)
for axi, N in zip(ax, [60, 120]):
plot_svm(N, axi)
axi.set_title('N = {0}'.format(N))
图 43-5. 新训练点对 SVM 模型的影响
在左侧面板中,我们看到了 60 个训练点的模型和支持向量。在右侧面板中,我们增加了训练点的数量,但模型没有改变:左侧面板中的三个支持向量与右侧面板中的支持向量相同。这种对远点行为的确切不敏感性是 SVM 模型的一种优势之一。
如果您正在实时运行此笔记本,您可以使用 IPython 的交互式小部件来交互地查看 SVM 模型的此功能:
In [10]: from ipywidgets import interact, fixed
interact(plot_svm, N=(10, 200), ax=fixed(None));
Out[10]: interactive(children=(IntSlider(value=10, description='N', max=200, min=10),
> Output()), _dom_classes=('widget-...
超越线性边界:核支持向量机
当 SVM 与核结合时,它可以变得非常强大。我们之前在第四十二章中已经看到了核的一个版本,即基函数回归。在那里,我们将数据投影到由多项式和高斯基函数定义的更高维空间中,从而能够使用线性分类器拟合非线性关系。
在 SVM 模型中,我们可以使用相同思想的一个版本。为了证明核函数的必要性,让我们看一些不是线性可分的数据(参见图 43-6)。
In [11]: from sklearn.datasets import make_circles
X, y = make_circles(100, factor=.1, noise=.1)
clf = SVC(kernel='linear').fit(X, y)
plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn')
plot_svc_decision_function(clf, plot_support=False);
图 43-6. 线性分类器对非线性边界的性能较差。
很明显,没有任何线性判别能永远分离这些数据。但我们可以从第四十二章的基函数回归中吸取教训,并思考如何将数据投影到更高的维度,以便线性分隔器足够。例如,我们可以使用的一个简单投影是在中间聚集上计算一个径向基函数(RBF):
In [12]: r = np.exp(-(X ** 2).sum(1))
我们可以使用三维图来可视化这个额外的数据维度,如图 43-7 所示。
In [13]: from mpl_toolkits import mplot3d
ax = plt.subplot(projection='3d')
ax.scatter3D(X[:, 0], X[:, 1], r, c=y, s=50, cmap='autumn')
ax.view_init(elev=20, azim=30)
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('r');
图 43-7. 为数据添加的第三个维度允许线性分离
我们可以看到,通过这个额外的维度,数据变得简单线性可分,通过在 r=0.7 处绘制一个分离平面。
在这种情况下,我们不得不选择并仔细调整我们的投影:如果我们没有将我们的径向基函数放在正确的位置,我们就不会看到如此清晰、线性可分的结果。一般来说,需要做出这样的选择是一个问题:我们希望以某种方式自动找到最佳的基函数来使用。
实现这一目标的一种策略是计算数据集中每个点处的基函数,并让 SVM 算法筛选结果。这种类型的基函数转换被称为核变换,因为它是基于每对点之间的相似关系(或核)。
这种策略的一个潜在问题是——将 点投影到 维空间中可能会变得非常计算密集,当 变大时。然而,由于一个称为 核技巧 的巧妙小程序,对核变换数据的拟合可以隐式完成——也就是说,根本不需要构建核投影的完整 -维表示。这个核技巧内置在 SVM 中,是该方法如此强大的原因之一。
在 Scikit-Learn 中,我们可以通过将我们的线性核改为 RBF 核,使用 kernel
模型超参数来应用核化的 SVM:
In [14]: clf = SVC(kernel='rbf', C=1E6)
clf.fit(X, y)
Out[14]: SVC(C=1000000.0)
让我们使用之前定义的函数来可视化拟合并标识支持向量(参见图 43-8)。
In [15]: plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn')
plot_svc_decision_function(clf)
plt.scatter(clf.support_vectors_[:, 0], clf.support_vectors_[:, 1],
s=300, lw=1, facecolors='none');
图 43-8. 核 SVM 对数据的拟合
使用这种核化支持向量机,我们学习到了一个适合的非线性决策边界。这种核变换策略在机器学习中经常被使用,将快速的线性方法转换为快速的非线性方法,特别适用于可以使用核技巧的模型。
调整 SVM:软化间隔
到目前为止,我们的讨论集中在非常干净的数据集上,其中存在完美的决策边界。但是如果您的数据有一定的重叠呢?例如,您可能有这样的数据(见图 43-9)。
In [16]: X, y = make_blobs(n_samples=100, centers=2,
random_state=0, cluster_std=1.2)
plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn');
图 43-9. 具有一定重叠级别的数据
为了处理这种情况,SVM 实现中有一个“软化”间隔的修正因子:即,如果允许更好的拟合,某些点可以进入间隔。间隔的硬度由调整参数控制,通常称为C
。对于很大的C
,间隔是硬的,点不能位于其中。对于较小的C
,间隔较软,并且可以包含一些点。
图 43-10 中显示的图表展示了通过软化间隔来改变C
如何影响最终拟合的视觉效果:
In [17]: X, y = make_blobs(n_samples=100, centers=2,
random_state=0, cluster_std=0.8)
fig, ax = plt.subplots(1, 2, figsize=(16, 6))
fig.subplots_adjust(left=0.0625, right=0.95, wspace=0.1)
for axi, C in zip(ax, [10.0, 0.1]):
model = SVC(kernel='linear', C=C).fit(X, y)
axi.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='autumn')
plot_svc_decision_function(model, axi)
axi.scatter(model.support_vectors_[:, 0],
model.support_vectors_[:, 1],
s=300, lw=1, facecolors='none');
axi.set_title('C = {0:.1f}'.format(C), size=14)
图 43-10. C
参数对支持向量拟合的影响
C
的最佳值将取决于您的数据集,您应该使用交叉验证或类似的程序来调整此参数(参考第三十九章)。
示例:人脸识别
作为支持向量机在实际中的应用示例,让我们来看一下人脸识别问题。我们将使用“野外标记人脸”数据集,该数据集包含数千张各种公众人物的合并照片。Scikit-Learn 内置了该数据集的获取器:
In [18]: from sklearn.datasets import fetch_lfw_people
faces = fetch_lfw_people(min_faces_per_person=60)
print(faces.target_names)
print(faces.images.shape)
Out[18]: ['Ariel Sharon' 'Colin Powell' 'Donald Rumsfeld' 'George W Bush'
'Gerhard Schroeder' 'Hugo Chavez' 'Junichiro Koizumi' 'Tony Blair']
(1348, 62, 47)
让我们绘制几张这些人脸,看看我们正在处理的内容(见图 43-11)。
In [19]: fig, ax = plt.subplots(3, 5, figsize=(8, 6))
for i, axi in enumerate(ax.flat):
axi.imshow(faces.images[i], cmap='bone')
axi.set(xticks=[], yticks=[],
xlabel=faces.target_names[faces.target[i]])
图 43-11. 来自野外标记人脸数据集的示例
每个图像包含 62 × 47,约 3,000 个像素。我们可以简单地使用每个像素值作为特征,但通常使用某种预处理器来提取更有意义的特征更为有效;在这里,我们将使用主成分分析(见第四十五章)提取 150 个基本组分,以供支持向量机分类器使用。我们可以通过将预处理器和分类器打包到单个管道中来实现这一点:
In [20]: from sklearn.svm import SVC
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
pca = PCA(n_components=150, whiten=True,
svd_solver='randomized', random_state=42)
svc = SVC(kernel='rbf', class_weight='balanced')
model = make_pipeline(pca, svc)
为了测试我们分类器的输出,我们将数据分割为训练集和测试集:
In [21]: from sklearn.model_selection import train_test_split
Xtrain, Xtest, ytrain, ytest = train_test_split(faces.data, faces.target,
random_state=42)
最后,我们可以使用网格搜索交叉验证来探索参数的组合。在这里,我们将调整C
(控制边界硬度)和gamma
(控制径向基函数核的大小),并确定最佳模型:
In [22]: from sklearn.model_selection import GridSearchCV
param_grid = {'svc__C': [1, 5, 10, 50],
'svc__gamma': [0.0001, 0.0005, 0.001, 0.005]}
grid = GridSearchCV(model, param_grid)
%time grid.fit(Xtrain, ytrain)
print(grid.best_params_)
Out[22]: CPU times: user 1min 19s, sys: 8.56 s, total: 1min 27s
Wall time: 36.2 s
{'svc__C': 10, 'svc__gamma': 0.001}
最优值集中在我们网格的中间;如果它们在边缘,我们将扩展网格以确保找到真正的最优值。
现在有了这个经过交叉验证的模型,我们可以预测测试数据的标签,这些数据模型尚未见过:
In [23]: model = grid.best_estimator_
yfit = model.predict(Xtest)
让我们看一些测试图像及其预测值(见图 43-12)。
In [24]: fig, ax = plt.subplots(4, 6)
for i, axi in enumerate(ax.flat):
axi.imshow(Xtest[i].reshape(62, 47), cmap='bone')
axi.set(xticks=[], yticks=[])
axi.set_ylabel(faces.target_names[yfit[i]].split()[-1],
color='black' if yfit[i] == ytest[i] else 'red')
fig.suptitle('Predicted Names; Incorrect Labels in Red', size=14);
图 43-12. 我们模型预测的标签
在这个小样本中,我们的最优估计器只误标了一个面孔(底部行的布什面孔被误标为布莱尔)。我们可以通过分类报告更好地了解我们估计器的性能,报告会逐标签列出恢复统计信息:
In [25]: from sklearn.metrics import classification_report
print(classification_report(ytest, yfit,
target_names=faces.target_names))
Out[25]: precision recall f1-score support
Ariel Sharon 0.65 0.73 0.69 15
Colin Powell 0.80 0.87 0.83 68
Donald Rumsfeld 0.74 0.84 0.79 31
George W Bush 0.92 0.83 0.88 126
Gerhard Schroeder 0.86 0.83 0.84 23
Hugo Chavez 0.93 0.70 0.80 20
Junichiro Koizumi 0.92 1.00 0.96 12
Tony Blair 0.85 0.95 0.90 42
accuracy 0.85 337
macro avg 0.83 0.84 0.84 337
weighted avg 0.86 0.85 0.85 337
我们还可以显示这些类别之间的混淆矩阵(见图 43-13)。
In [26]: from sklearn.metrics import confusion_matrix
import seaborn as sns
mat = confusion_matrix(ytest, yfit)
sns.heatmap(mat.T, square=True, annot=True, fmt='d',
cbar=False, cmap='Blues',
xticklabels=faces.target_names,
yticklabels=faces.target_names)
plt.xlabel('true label')
plt.ylabel('predicted label');
图 43-13. 面部数据的混淆矩阵
这帮助我们了解哪些标签可能会被估计器混淆。
对于一个现实世界的人脸识别任务,在这种任务中照片并未预先裁剪成漂亮的网格,面部分类方案唯一的区别在于特征选择:您需要使用更复杂的算法来找到面部,并提取与像素化无关的特征。对于这种应用,一个好的选择是利用OpenCV,它包括对一般图像和特别是人脸的先前实现的最新特征提取工具。
摘要
这是支持向量机背后原理的简明直观介绍。这些模型由于以下几个原因而是一种强大的分类方法:
-
它们依赖于相对较少的支持向量,因此紧凑且占用极少的内存空间。
-
一旦模型训练完成,预测阶段非常快速。
-
因为它们只受到靠近边界的点的影响,所以它们在处理高维数据时表现良好——即使是比样本更多维度的数据,这对其他算法来说是个挑战。
-
它们与核方法的集成使它们非常灵活,能够适应许多类型的数据。
然而,支持向量机(SVMs)也有几个缺点:
-
样本数量的缩放为最坏情况下是,或者对于高效实现是。对于大量的训练样本,这种计算成本可能是限制性的。
-
结果强烈依赖于合适的软化参数
C
的选择。必须通过交叉验证仔细选择,随着数据集增大,这可能是昂贵的。 -
结果没有直接的概率解释。可以通过内部交叉验证来估计(参见
SVC
的probability
参数),但这额外的估计是昂贵的。
考虑到这些特性,我通常只有在其他更简单、更快速、不需要过多调整的方法被证明不足以满足我的需求时,才会转向支持向量机(SVM)。尽管如此,如果你有足够的 CPU 周期来进行数据训练和交叉验证 SVM,这种方法可以带来出色的结果。
第四十四章:深入探讨:决策树和随机森林
之前,我们深入研究了一个简单的生成分类器(朴素贝叶斯;见 第四十一章)和一个强大的判别分类器(支持向量机;见 第四十三章)。在这里,我们将看看另一种强大的算法:一个称为 随机森林 的非参数算法。随机森林是一种 集成 方法的示例,意味着它依赖于聚合一组更简单的估算器的结果。这样的集成方法的一个令人惊讶的结果是,总和可以大于各部分之和:也就是说,多个估算器之间的多数投票的预测准确度最终可能会比任何进行投票的单个估算器的准确度更高!我们将在以下部分看到这方面的例子。
我们从标准导入开始:
In [1]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
推动随机森林的动机:决策树
随机森林是建立在决策树上的集成学习器的一个例子。因此,我们将首先讨论决策树本身。
决策树是极其直观的分类或标记对象的方式:你只需提出一系列旨在对分类进行精准定位的问题。例如,如果你想构建一个用于对徒步时遇到的动物进行分类的决策树,你可以构建如 图 44-1 所示的决策树。
图 44-1. 二叉决策树的示例^(1)
二元分割使其极其高效:在构造良好的树时,每个问题将使选项数量减少约一半,非常快速地将选项缩小,即使在大量类别中也是如此。当然,关键在于决定每一步要问什么问题。在决策树的机器学习实现中,问题通常采用数据中的轴对齐分割形式:即,树中的每个节点都使用一个特征内的截止值将数据分为两组。现在让我们看一个示例。
创建决策树
考虑以下二维数据,它具有四个类标签之一(参见 图 44-2)。
In [2]: from sklearn.datasets import make_blobs
X, y = make_blobs(n_samples=300, centers=4,
random_state=0, cluster_std=1.0)
plt.scatter(X[:, 0], X[:, 1], c=y, s=50, cmap='rainbow');
图 44-2. 决策树分类器的数据
基于这些数据构建的简单决策树将根据某些定量标准迭代地沿着一个或另一个轴将数据分割,并在每个级别根据其中的点的多数投票确定新区域的标签。图 44-3 展示了此数据的决策树分类器的前四个级别的可视化。
图 44-3. 决策树如何分割数据的可视化^(2)
注意,第一个分割后,上层每个点保持不变,因此无需进一步细分此分支。除了包含同一颜色的节点外,在每个级别每个区域再次沿着两个特征之一进行分割。
在 Scikit-Learn 中,可以使用DecisionTreeClassifier
估计器来拟合决策树到我们的数据:
In [3]: from sklearn.tree import DecisionTreeClassifier
tree = DecisionTreeClassifier().fit(X, y)
让我们编写一个实用函数来帮助我们可视化分类器的输出:
In [4]: def visualize_classifier(model, X, y, ax=None, cmap='rainbow'):
ax = ax or plt.gca()
# Plot the training points
ax.scatter(X[:, 0], X[:, 1], c=y, s=30, cmap=cmap,
clim=(y.min(), y.max()), zorder=3)
ax.axis('tight')
ax.axis('off')
xlim = ax.get_xlim()
ylim = ax.get_ylim()
# fit the estimator
model.fit(X, y)
xx, yy = np.meshgrid(np.linspace(*xlim, num=200),
np.linspace(*ylim, num=200))
Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
# Create a color plot with the results
n_classes = len(np.unique(y))
contours = ax.contourf(xx, yy, Z, alpha=0.3,
levels=np.arange(n_classes + 1) - 0.5,
cmap=cmap, zorder=1)
ax.set(xlim=xlim, ylim=ylim)
现在我们可以看一下决策树分类的样子(参见图 44-4)。
In [5]: visualize_classifier(DecisionTreeClassifier(), X, y)
图 44-4. 决策树分类的可视化
如果您正在实时运行此笔记本,您可以使用在线附录中包含的辅助脚本来打开决策树构建过程的交互式可视化:
In [6]: # helpers_05_08 is found in the online appendix
import helpers_05_08
helpers_05_08.plot_tree_interactive(X, y);
Out[6]: interactive(children=(Dropdown(description='depth', index=1, options=(1, 5),
> value=5), Output()), _dom_classes...
注意,随着深度的增加,我们往往会得到非常奇怪形状的分类区域;例如,在深度为五时,在黄色和蓝色区域之间有一个高而瘦的紫色区域。显然,这不是真实的内在数据分布的结果,而更多地是数据的特定采样或噪声特性的结果。也就是说,即使在仅深度为五的情况下,这棵决策树明显地过拟合了我们的数据。
决策树和过拟合
这种过拟合事实上是决策树的一个普遍特性:很容易使树的深度过深,从而适应特定数据的细节,而不是它们抽取自的分布的总体特性。另一种看待这种过拟合的方法是查看在不同数据子集上训练的模型——例如,在图 44-5 中,我们训练了两棵不同的树,每棵树都使用了原始数据的一半。
图 44-5. 两棵随机决策树的示例^(3)
显然,在某些地方,两棵树产生一致的结果(例如,在四个角落),而在其他地方,两棵树给出非常不同的分类结果(例如,在任意两个簇之间的区域)。关键观察是,这种不一致往往发生在分类不确定的地方,因此通过使用这两棵树的信息,我们可能会得到更好的结果!
如果您正在实时运行此笔记本,以下函数将允许您交互地显示在数据的随机子集上训练的树的拟合情况:
In [7]: # helpers_05_08 is found in the online appendix
import helpers_05_08
helpers_05_08.randomized_tree_interactive(X, y)
Out[7]: interactive(children=(Dropdown(description='random_state', options=(0, 100),
> value=0), Output()), _dom_classes...
正如利用两棵树的信息可以改进我们的结果一样,我们可能期望利用许多树的信息进一步改进我们的结果。
集成估计器:随机森林
这种多个过拟合估计器组合以减少过拟合效应的概念,是支持一种称为bagging的集成方法的基础。Bagging 利用一个并行估计器的集合(可能是一个抓袋),每个估计器都会对数据过拟合,并对结果进行平均以找到更好的分类。随机化决策树的集成称为随机森林。
这种袋装分类可以通过 Scikit-Learn 的BaggingClassifier
元估计器手动完成,如下所示(见图 44-6)。
In [8]: from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import BaggingClassifier
tree = DecisionTreeClassifier()
bag = BaggingClassifier(tree, n_estimators=100, max_samples=0.8,
random_state=1)
bag.fit(X, y)
visualize_classifier(bag, X, y)
在本例中,我们通过对训练点的随机 80%子集拟合每个估计器来随机化数据。在实践中,通过在如何选择分割时注入一些随机性来更有效地随机化决策树:这样每次都会使所有数据对拟合有贡献,但拟合结果仍具有所需的随机性。例如,在确定要分割哪个特征时,随机树可能从顶部几个特征中选择。您可以在Scikit-Learn 文档和其中的参考文献中阅读有关这些随机化策略的更多技术细节。
图 44-6. 随机决策树集成的决策边界
在 Scikit-Learn 中,这样一个优化的随机决策树集成是通过RandomForestClassifier
估计器实现的,它自动处理所有随机化。你只需选择一些估计器,它将非常快速地(如果需要的话是并行的)拟合树的集成(见图 44-7)。
In [9]: from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(n_estimators=100, random_state=0)
visualize_classifier(model, X, y);
图 44-7. 随机森林的决策边界,这是一组优化的决策树集成
我们看到通过对一百个随机扰动模型进行平均,最终得到一个与我们关于参数空间如何分割的直觉更接近的整体模型。
随机森林回归
在前一节中,我们考虑了随机森林在分类的上下文中。随机森林也可以在回归的情况下工作(即使用连续变量而不是分类变量)。用于此目的的估计器是RandomForestRegressor
,其语法与我们之前看到的非常相似。
考虑以下数据,这些数据来自快速和慢速振荡的组合(见图 44-8)。
In [10]: rng = np.random.RandomState(42)
x = 10 * rng.rand(200)
def model(x, sigma=0.3):
fast_oscillation = np.sin(5 * x)
slow_oscillation = np.sin(0.5 * x)
noise = sigma * rng.randn(len(x))
return slow_oscillation + fast_oscillation + noise
y = model(x)
plt.errorbar(x, y, 0.3, fmt='o');
图 44-8. 随机森林回归的数据
使用随机森林回归器,我们可以找到最佳拟合曲线(见图 44-9)。
In [11]: from sklearn.ensemble import RandomForestRegressor
forest = RandomForestRegressor(200)
forest.fit(x[:, None], y)
xfit = np.linspace(0, 10, 1000)
yfit = forest.predict(xfit[:, None])
ytrue = model(xfit, sigma=0)
plt.errorbar(x, y, 0.3, fmt='o', alpha=0.5)
plt.plot(xfit, yfit, '-r');
plt.plot(xfit, ytrue, '-k', alpha=0.5);
图 44-9. 随机森林模型拟合数据
这里显示了真实模型的平滑灰色曲线,而随机森林模型则通过锯齿状红色曲线展示。非参数随机森林模型足够灵活,能够拟合多期数据,而无需指定多期模型!
示例:用于分类数字的随机森林
在第三十八章中,我们通过一个使用 Scikit-Learn 提供的数字数据集的示例来工作。让我们再次使用它来看看随机森林分类器在这种情况下的应用:
In [12]: from sklearn.datasets import load_digits
digits = load_digits()
digits.keys()
Out[12]: dict_keys(['data', 'target', 'frame', 'feature_names', 'target_names',
> 'images', 'DESCR'])
为了提醒我们正在查看的内容,我们将可视化前几个数据点(参见图 44-10)。
In [13]: # set up the figure
fig = plt.figure(figsize=(6, 6)) # figure size in inches
fig.subplots_adjust(left=0, right=1, bottom=0, top=1,
hspace=0.05, wspace=0.05)
# plot the digits: each image is 8x8 pixels
for i in range(64):
ax = fig.add_subplot(8, 8, i + 1, xticks=[], yticks=[])
ax.imshow(digits.images[i], cmap=plt.cm.binary, interpolation='nearest')
# label the image with the target value
ax.text(0, 7, str(digits.target[i]))
图 44-10. 数字数据的表示
我们可以使用随机森林对数字进行分类,如下所示:
In [14]: from sklearn.model_selection import train_test_split
Xtrain, Xtest, ytrain, ytest = train_test_split(digits.data, digits.target,
random_state=0)
model = RandomForestClassifier(n_estimators=1000)
model.fit(Xtrain, ytrain)
ypred = model.predict(Xtest)
让我们看看这个分类器的分类报告:
In [15]: from sklearn import metrics
print(metrics.classification_report(ypred, ytest))
Out[15]: precision recall f1-score support
0 1.00 0.97 0.99 38
1 0.98 0.98 0.98 43
2 0.95 1.00 0.98 42
3 0.98 0.96 0.97 46
4 0.97 1.00 0.99 37
5 0.98 0.96 0.97 49
6 1.00 1.00 1.00 52
7 1.00 0.96 0.98 50
8 0.94 0.98 0.96 46
9 0.98 0.98 0.98 47
accuracy 0.98 450
macro avg 0.98 0.98 0.98 450
weighted avg 0.98 0.98 0.98 450
并且为了更直观,绘制混淆矩阵(参见图 44-11)。
In [16]: from sklearn.metrics import confusion_matrix
import seaborn as sns
mat = confusion_matrix(ytest, ypred)
sns.heatmap(mat.T, square=True, annot=True, fmt='d',
cbar=False, cmap='Blues')
plt.xlabel('true label')
plt.ylabel('predicted label');
我们发现,一个简单的未调整的随机森林能够对数字数据进行相当准确的分类。
图 44-11. 使用随机森林进行数字分类的混淆矩阵
概要
本章简要介绍了集成估计器的概念,特别是随机森林,它是随机化决策树的集成。随机森林是一种功能强大的方法,具有多个优点:
-
由于底层决策树的简单性,训练和预测都非常快。此外,由于每棵树都是独立实体,因此这两个任务可以直接并行化。
-
多棵树允许进行概率分类:估算器的多数投票给出了概率的估计(在 Scikit-Learn 中通过
predict_proba
方法访问)。 -
非参数模型非常灵活,因此在其他估算器欠拟合的任务上表现良好。
随机森林的一个主要缺点是结果不易解释:也就是说,如果你想对分类模型的含义得出结论,随机森林可能不是最佳选择。
^(1) 生成此图的代码可在在线附录中找到。
^(2) 生成此图的代码可在在线附录中找到。
^(3) 生成此图的代码可在在线附录中找到。
第四十五章:深入解析:主成分分析
到目前为止,我们一直在深入研究监督学习估计器:那些根据标记的训练数据预测标签的估计器。在这里,我们开始研究几个无监督估计器,这些估计器可以突出数据的一些有趣方面,而不需要参考任何已知的标签。
在本章中,我们将探讨或许是最广泛使用的无监督算法之一,即主成分分析(PCA)。PCA 本质上是一种降维算法,但它也可以作为可视化工具、噪声过滤器、特征提取和工程等工具使用。在简短讨论了 PCA 算法的概念后,我们将探索其进一步应用的几个例子。
我们从标准导入开始:
In [1]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
引入主成分分析
主成分分析是一种快速灵活的无监督数据降维方法,我们在第 38 章中简要介绍过。通过查看一个二维数据集,最容易理解其行为。考虑这 200 个点(见图 45-1)。
In [2]: rng = np.random.RandomState(1)
X = np.dot(rng.rand(2, 2), rng.randn(2, 200)).T
plt.scatter(X[:, 0], X[:, 1])
plt.axis('equal');
图 45-1. 主成分分析演示数据
从眼睛的角度来看,很明显x和y变量之间存在近乎线性的关系。这让人想起我们在第 42 章中探索的线性回归数据,但这里的问题设置略有不同:与尝试从x值预测y值不同,无监督学习问题试图学习x和y值之间的关系。
在主成分分析中,通过找到数据中的一组主轴来量化这种关系,并使用这些轴来描述数据集。使用 Scikit-Learn 的PCA
估计器,我们可以按如下方式计算:
In [3]: from sklearn.decomposition import PCA
pca = PCA(n_components=2)
pca.fit(X)
Out[3]: PCA(n_components=2)
拟合从数据中学习到一些量,最重要的是分量和解释方差:
In [4]: print(pca.components_)
Out[4]: [[-0.94446029 -0.32862557]
[-0.32862557 0.94446029]]
In [5]: print(pca.explained_variance_)
Out[5]: [0.7625315 0.0184779]
要理解这些数字的含义,让我们将它们视为向量在输入数据上的可视化,使用分量定义向量的方向和解释方差定义向量的平方长度(见图 45-2)。
In [6]: def draw_vector(v0, v1, ax=None):
ax = ax or plt.gca()
arrowprops=dict(arrowstyle='->', linewidth=2,
shrinkA=0, shrinkB=0)
ax.annotate('', v1, v0, arrowprops=arrowprops)
# plot data
plt.scatter(X[:, 0], X[:, 1], alpha=0.2)
for length, vector in zip(pca.explained_variance_, pca.components_):
v = vector * 3 * np.sqrt(length)
draw_vector(pca.mean_, pca.mean_ + v)
plt.axis('equal');
图 45-2. 数据中主轴的可视化
这些向量代表了数据的主轴,每个向量的长度表示描述数据分布中该轴的“重要性”的指标——更精确地说,它是数据在投影到该轴上时的方差的度量。将每个数据点投影到主轴上得到了数据的主成分。
如果我们将这些主成分与原始数据一起绘制,我们会看到在图 45-3 中显示的图形。
图 45-3. 数据中转换后的主轴^(1)
将数据轴转换为主轴的这种变换是仿射变换,这意味着它由平移、旋转和均匀缩放组成。
虽然这种寻找主成分的算法可能看起来只是一种数学上的好奇,但它事实证明在机器学习和数据探索领域有着非常广泛的应用。
PCA 作为降维
使用 PCA 进行降维涉及将一个或多个最小主成分置零,结果是数据的低维投影,保留了最大的数据方差。
这里是使用 PCA 作为降维转换的示例:
In [7]: pca = PCA(n_components=1)
pca.fit(X)
X_pca = pca.transform(X)
print("original shape: ", X.shape)
print("transformed shape:", X_pca.shape)
Out[7]: original shape: (200, 2)
transformed shape: (200, 1)
转换后的数据已经降维到了单一维度。为了理解这种降维的效果,我们可以对这个降维后的数据执行逆变换,并将其与原始数据一起绘制(参见图 45-4)。
In [8]: X_new = pca.inverse_transform(X_pca)
plt.scatter(X[:, 0], X[:, 1], alpha=0.2)
plt.scatter(X_new[:, 0], X_new[:, 1], alpha=0.8)
plt.axis('equal');
图 45-4. PCA 作为降维的可视化
浅色点是原始数据,而深色点是投影版本。这清楚地展示了 PCA 降维的含义:移除沿着最不重要的主轴或轴的信息,只留下数据方差最大的分量。被削减的方差比例(与在前述图中形成的线周围的点的散布成比例)大致是这种降维中丢失的“信息”量的度量。
这个降维后的数据集在某些意义上是“足够好”的,能够编码点之间最重要的关系:尽管将数据特征的数量减少了 50%,但数据点之间的整体关系大部分得到了保留。
PCA 用于可视化:手写数字
在只有两个维度时,降维的实用性可能并不完全明显,但当我们查看高维数据时,它变得更加清晰。为了看到这一点,让我们快速看一下我们在第四十四章中处理的数字数据集应用 PCA 的情况。
我们将从加载数据开始:
In [9]: from sklearn.datasets import load_digits
digits = load_digits()
digits.data.shape
Out[9]: (1797, 64)
请回忆,数字数据集由 8×8 像素的图像组成,这意味着它们是 64 维的。为了对这些点之间的关系有一些直观的理解,我们可以使用 PCA 将它们投影到一个更易处理的维度,比如说两个维度:
In [10]: pca = PCA(2) # project from 64 to 2 dimensions
projected = pca.fit_transform(digits.data)
print(digits.data.shape)
print(projected.shape)
Out[10]: (1797, 64)
(1797, 2)
现在我们可以绘制每个点的前两个主成分,以了解数据,如在图 45-5 中所见。
In [11]: plt.scatter(projected[:, 0], projected[:, 1],
c=digits.target, edgecolor='none', alpha=0.5,
cmap=plt.cm.get_cmap('rainbow', 10))
plt.xlabel('component 1')
plt.ylabel('component 2')
plt.colorbar();
图 45-5. 应用于手写数字数据的 PCA
回想一下这些组件的含义:完整的数据是一个 64 维点云,而这些点是每个数据点沿着最大方差方向的投影。基本上,我们在 64 维空间中找到了允许我们在两个维度上看到数据布局的最佳拉伸和旋转,而且这是无监督的方式完成的,即没有参考标签。
组件的含义是什么?
在这里,我们可以再进一步,开始问减少的维度意味着什么。这种意义可以用基向量的组合来理解。例如,训练集中的每个图像由一组 64 个像素值定义,我们将其称为向量:
我们可以用像素基础来考虑这一点。也就是说,为了构建图像,我们将向量的每个元素乘以它描述的像素,然后将结果相加以构建图像:
我们可以想象一种降低数据维度的方法是将除了少数基向量之外的所有值归零。例如,如果我们仅使用前八个像素,我们得到数据的八维投影(见图 45-6)。然而,这并不太反映整个图像:我们几乎丢弃了 90%的像素!
图 45-6. 通过丢弃像素实现的天真降维^(2)
上排面板显示单独的像素,下排显示这些像素对图像构建的累积贡献。仅使用八个像素基础组件,我们只能构建 64 像素图像的一小部分。如果我们继续这个序列并使用所有 64 个像素,我们将恢复原始图像。
但像素级表示并不是唯一的基础选择。我们还可以使用其他基函数,每个基函数都包含来自每个像素的一些预定义贡献,并编写如下内容:
PCA 可以被看作是选择最优基函数的过程,使得仅添加前几个基函数就足以适当地重构数据集中的大部分元素。主成分作为我们数据的低维表示,实际上只是乘以这一系列中每个元素的系数。图 45-7 展示了使用平均值加上前八个 PCA 基函数重建相同数字的类似描述。
图 45-7. 通过丢弃最不重要的主成分实现的更复杂的降维(与图 45-6 比较)^(3)
与像素基础不同,PCA 基础允许我们仅通过平均值加上八个组件来恢复输入图像的显著特征!每个像素在每个组件中的量是我们二维示例中向量方向的必然结果。这就是 PCA 提供数据低维表示的方式:它发现一组比输入数据的原生像素基础更有效的基础函数。
选择组件的数量
在实际应用中使用 PCA 的一个重要部分是估计需要多少个组件来描述数据。这可以通过查看组件数量作为累积解释方差比的函数来确定(参见图 45-8)。
In [12]: pca = PCA().fit(digits.data)
plt.plot(np.cumsum(pca.explained_variance_ratio_))
plt.xlabel('number of components')
plt.ylabel('cumulative explained variance');
此曲线量化了在前个组件中包含的总 64 维方差的比例。例如,我们看到对于数字数据,前 10 个组件包含大约 75%的方差,而您需要约 50 个组件来描述接近 100%的方差。
图 45-8. 累积解释方差,用于衡量 PCA 保留数据内容的效果
这告诉我们,我们的二维投影丢失了大量信息(由解释方差度量),我们需要大约 20 个组件来保留 90%的方差。查看高维数据集的此图可以帮助您了解其特征中存在的冗余水平。
PCA 作为噪声过滤器
PCA 还可以用作噪声数据的过滤方法。其思想是:任何方差远大于噪声影响的成分应该相对不受噪声影响。因此,如果您仅使用主成分的最大子集重建数据,则应优先保留信号并丢弃噪声。
让我们看看数字数据的情况。首先,我们将绘制几个无噪声输入样本(图 45-9)。
In [13]: def plot_digits(data):
fig, axes = plt.subplots(4, 10, figsize=(10, 4),
subplot_kw={'xticks':[], 'yticks':[]},
gridspec_kw=dict(hspace=0.1, wspace=0.1))
for i, ax in enumerate(axes.flat):
ax.imshow(data[i].reshape(8, 8),
cmap='binary', interpolation='nearest',
clim=(0, 16))
plot_digits(digits.data)
图 45-9. 无噪声的数字
现在让我们添加一些随机噪声以创建一个带噪声的数据集,并重新绘制它(图 45-10)。
In [14]: rng = np.random.default_rng(42)
rng.normal(10, 2)
Out[14]: 10.609434159508863
In [15]: rng = np.random.default_rng(42)
noisy = rng.normal(digits.data, 4)
plot_digits(noisy)
图 45-10. 添加了高斯随机噪声的数字
可视化使得随机噪声的存在变得明显。让我们在嘈杂数据上训练一个 PCA 模型,并要求投影保留 50%的方差:
In [16]: pca = PCA(0.50).fit(noisy)
pca.n_components_
Out[16]: 12
这里 50%的方差相当于 12 个主成分,而原始的 64 个特征。现在我们计算这些成分,然后使用变换的逆来重构经过滤波的数字;图 45-11 展示了结果。
In [17]: components = pca.transform(noisy)
filtered = pca.inverse_transform(components)
plot_digits(filtered)
图 45-11. 使用 PCA 进行“去噪”处理的数字
这种信号保留/噪声过滤特性使得 PCA 成为非常有用的特征选择例程——例如,不是在非常高维度的数据上训练分类器,而是在较低维度的主成分表示上训练分类器,这将自动过滤输入中的随机噪声。
示例:特征脸
我们之前探讨了使用 PCA 技术作为支持向量机的特征选择器进行人脸识别的示例(见第四十三章)。现在让我们回顾一下,并深入探讨这背后的更多内容。回想一下,我们使用的是由 Scikit-Learn 提供的 Labeled Faces in the Wild(LFW)数据集:
In [18]: from sklearn.datasets import fetch_lfw_people
faces = fetch_lfw_people(min_faces_per_person=60)
print(faces.target_names)
print(faces.images.shape)
Out[18]: ['Ariel Sharon' 'Colin Powell' 'Donald Rumsfeld' 'George W Bush'
'Gerhard Schroeder' 'Hugo Chavez' 'Junichiro Koizumi' 'Tony Blair']
(1348, 62, 47)
让我们看看涵盖此数据集的主轴。由于这是一个大数据集,我们将在PCA
估计器中使用"random"
特征求解器:它使用随机方法来更快地近似前个主成分,而不是标准方法,以牺牲一些准确性。这种权衡在高维数据(这里接近 3,000 维)中非常有用。我们将看一看前 150 个成分:
In [19]: pca = PCA(150, svd_solver='randomized', random_state=42)
pca.fit(faces.data)
Out[19]: PCA(n_components=150, random_state=42, svd_solver='randomized')
在这种情况下,可以通过可视化与前几个主成分相关联的图像来进行探索(这些成分在技术上称为特征向量,因此这些类型的图像通常被称为特征脸;正如您在图 45-12 中所见,它们听起来就像它们看起来那样可怕):
In [20]: fig, axes = plt.subplots(3, 8, figsize=(9, 4),
subplot_kw={'xticks':[], 'yticks':[]},
gridspec_kw=dict(hspace=0.1, wspace=0.1))
for i, ax in enumerate(axes.flat):
ax.imshow(pca.components_[i].reshape(62, 47), cmap='bone')
图 45-12. 从 LFW 数据集学习的特征脸的可视化
结果非常有趣,并且为我们提供了关于图像变化的见解:例如,前几个特征脸(左上角)似乎与脸部的光照角度有关,而后来的主成分似乎在挑选出特定的特征,如眼睛、鼻子和嘴唇。让我们看一看这些成分的累计方差,以查看投影保留了多少数据信息(见图 45-13)。
In [21]: plt.plot(np.cumsum(pca.explained_variance_ratio_))
plt.xlabel('number of components')
plt.ylabel('cumulative explained variance');
图 45-13. LFW 数据的累计解释方差
我们选择的 150 个组件占了超过 90%的方差。这使我们相信,使用这 150 个组件,我们将恢复数据的大部分基本特征。为了更具体化,我们可以将输入图像与从这些 150 个组件重建的图像进行比较(参见图 45-14)。
In [22]: # Compute the components and projected faces
pca = pca.fit(faces.data)
components = pca.transform(faces.data)
projected = pca.inverse_transform(components)
In [23]: # Plot the results
fig, ax = plt.subplots(2, 10, figsize=(10, 2.5),
subplot_kw={'xticks':[], 'yticks':[]},
gridspec_kw=dict(hspace=0.1, wspace=0.1))
for i in range(10):
ax[0, i].imshow(faces.data[i].reshape(62, 47), cmap='binary_r')
ax[1, i].imshow(projected[i].reshape(62, 47), cmap='binary_r')
ax[0, 0].set_ylabel('full-dim\ninput')
ax[1, 0].set_ylabel('150-dim\nreconstruction');
图 45-14. LFW 数据的 150 维 PCA 重建
这里的顶部行显示了输入图像,而底部行显示了仅从约 3000 个初始特征中的 150 个进行图像重建。这种可视化清楚地说明了 PCA 特征选择在第四十三章中为何如此成功:虽然它将数据的维度减少了近 20 倍,但投影图像包含足够的信息,使我们可以通过肉眼识别每个图像中的个体。这意味着我们的分类算法只需要在 150 维数据上进行训练,而不是 3000 维数据,根据我们选择的特定算法,这可能会导致更高效的分类。
摘要
在本章中,我们探讨了主成分分析在降维、高维数据可视化、噪声过滤和高维数据特征选择中的应用。由于其多功能性和可解释性,PCA 已被证明在各种背景和学科中都非常有效。对于任何高维数据集,我倾向于从 PCA 开始,以便可视化数据点之间的关系(就像我们在数字数据中所做的那样),理解数据中的主要方差(就像我们在特征脸中所做的那样),并理解内在的维度(通过绘制解释方差比)。当然,PCA 并非对每个高维数据集都有用,但它提供了一条直观和高效的路径,以洞察高维数据。
PCA 的主要弱点是它往往受到数据中异常值的影响。因此,已经开发了几种鲁棒性较强的 PCA 变体,其中许多变体通过迭代地丢弃初始组件描述不佳的数据点来作用。Scikit-Learn 在sklearn.decomposition
子模块中包括了许多有趣的 PCA 变体;一个例子是SparsePCA
,它引入了一个正则化项(参见第四十二章),用于强制组件的稀疏性。
在接下来的章节中,我们将研究其他建立在 PCA 思想基础上的无监督学习方法。
^(1) 可在在线附录中找到生成此图的代码。
^(2) 可在在线附录中找到生成此图的代码。
^(3) 生成此图的代码可以在在线附录中找到。
第四十六章:深入探讨:流形学习
在上一章中,我们看到 PCA 可以用于降维,减少数据集的特征数,同时保持点之间的基本关系。虽然 PCA 灵活、快速且易于解释,但当数据中存在非线性关系时,它的表现并不理想,我们很快将看到一些例子。
为了解决这一不足,我们可以转向流形学习算法——一类无监督估计器,旨在将数据集描述为嵌入在高维空间中的低维流形。当你想到流形时,我建议想象一张纸:这是一个二维物体,存在于我们熟悉的三维世界中。
在流形学的术语中,你可以将这张纸片看作是嵌入在三维空间中的二维流形。在三维空间中旋转、重新定向或拉伸这张纸片并不会改变其平面几何特性:这些操作类似于线性嵌入。如果你将纸张弯曲、卷曲或揉皱,它仍然是一个二维流形,但是嵌入到三维空间的方式不再是线性的。流形学算法旨在学习纸张的基本二维特性,即使它被扭曲以填充三维空间。
在这里,我们将研究多种流形方法,深入探讨其中的一些技术:多维尺度法(MDS)、局部线性嵌入(LLE)和等距映射(Isomap)。
我们从标准导入开始:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
流形学习:“HELLO”
为了更清晰地阐明这些概念,让我们首先生成一些二维数据,以便用来定义一个流形。这里是一个能创建“HELLO”形状数据的函数:
In [2]: def make_hello(N=1000, rseed=42):
# Make a plot with "HELLO" text; save as PNG
fig, ax = plt.subplots(figsize=(4, 1))
fig.subplots_adjust(left=0, right=1, bottom=0, top=1)
ax.axis('off')
ax.text(0.5, 0.4, 'HELLO', va='center', ha='center',
weight='bold', size=85)
fig.savefig('hello.png')
plt.close(fig)
# Open this PNG and draw random points from it
from matplotlib.image import imread
data = imread('hello.png')[::-1, :, 0].T
rng = np.random.RandomState(rseed)
X = rng.rand(4 * N, 2)
i, j = (X * data.shape).astype(int).T
mask = (data[i, j] < 1)
X = X[mask]
X[:, 0] *= (data.shape[0] / data.shape[1])
X = X[:N]
return X[np.argsort(X[:, 0])]
让我们调用这个函数,并可视化生成的数据(参见图 46-1)。
In [3]: X = make_hello(1000)
colorize = dict(c=X[:, 0], cmap=plt.cm.get_cmap('rainbow', 5))
plt.scatter(X[:, 0], X[:, 1], **colorize)
plt.axis('equal');
输出是二维的,由“HELLO”形状的点组成。这种数据形式将帮助我们直观地了解这些算法的作用。
图 46-1. 用于流形学习的数据
多维尺度法
查看这样的数据,我们可以看到数据集中特定的x和y值并不是数据最基本的描述:我们可以缩放、收缩或旋转数据,而“HELLO”仍然是显而易见的。例如,如果我们使用旋转矩阵旋转数据,x和y值会改变,但数据基本上仍然是相同的(参见图 46-2)。
In [4]: def rotate(X, angle):
theta = np.deg2rad(angle)
R = [[np.cos(theta), np.sin(theta)],
[-np.sin(theta), np.cos(theta)]]
return np.dot(X, R)
X2 = rotate(X, 20) + 5
plt.scatter(X2[:, 0], X2[:, 1], **colorize)
plt.axis('equal');
图 46-2. 旋转的数据集
这证实了x和y值不一定是数据关系中的基本要素。在这种情况下,基本的是数据集中每个点之间的距离。表示这一点的常见方法是使用距离矩阵:对于个点,我们构造一个数组,使得条目包含点和点之间的距离。让我们使用 Scikit-Learn 的高效pairwise_distances
函数来为我们的原始数据做到这一点:
In [5]: from sklearn.metrics import pairwise_distances
D = pairwise_distances(X)
D.shape
Out[5]: (1000, 1000)
正如承诺的那样,对于我们的N=1,000 个点,我们获得了一个 1,000 × 1,000 的矩阵,可以像这样进行可视化(参见图 46-3)。
In [6]: plt.imshow(D, zorder=2, cmap='viridis', interpolation='nearest')
plt.colorbar();
图 46-3. 点之间的成对距离可视化
如果我们类似地为我们旋转和平移的数据构建一个距离矩阵,我们会看到它是相同的:
In [7]: D2 = pairwise_distances(X2)
np.allclose(D, D2)
Out[7]: True
这个距离矩阵给出了一个与旋转和平移无关的数据表示,但在图 46-3 中的矩阵可视化并不完全直观。在那里显示的表示中,我们失去了数据中有趣结构的任何可见迹象:“HELLO”。
此外,从(x, y)坐标计算距离矩阵很简单,但将距离转换回x和y坐标却相当困难。这正是多维缩放算法的目标:给定点之间的距离矩阵,它恢复数据的维坐标表示。让我们看看它如何处理我们的距离矩阵,使用precomputed
表示我们正在传递一个距离矩阵(见图 46-4)。
In [8]: from sklearn.manifold import MDS
model = MDS(n_components=2, dissimilarity='precomputed', random_state=1701)
out = model.fit_transform(D)
plt.scatter(out[:, 0], out[:, 1], **colorize)
plt.axis('equal');
图 46-4. 从成对距离计算得到的 MDS 嵌入
MDS 算法使用描述数据点之间关系的距离矩阵之一,仅恢复了我们数据的可能的二维坐标表示。
MDS 作为流形学习
当我们考虑到距离矩阵可以从任何维度的数据中计算时,这种用处变得更加明显。例如,我们不仅可以简单地将数据在二维平面上旋转,还可以使用以下函数将其投影到三维(本质上是早期使用的旋转矩阵的三维推广)。
In [9]: def random_projection(X, dimension=3, rseed=42):
assert dimension >= X.shape[1]
rng = np.random.RandomState(rseed)
C = rng.randn(dimension, dimension)
e, V = np.linalg.eigh(np.dot(C, C.T))
return np.dot(X, V[:X.shape[1]])
X3 = random_projection(X, 3)
X3.shape
Out[9]: (1000, 3)
让我们可视化这些点,看看我们在处理什么(见图 46-5)。
In [10]: from mpl_toolkits import mplot3d
ax = plt.axes(projection='3d')
ax.scatter3D(X3[:, 0], X3[:, 1], X3[:, 2],
**colorize);
图 46-5. 数据线性嵌入到三维空间中
现在,我们可以要求MDS
估计器输入这三维数据,计算距离矩阵,然后确定这个距离矩阵的最优二维嵌入。结果恢复了原始数据的表示,如图 46-6 所示。
In [11]: model = MDS(n_components=2, random_state=1701)
out3 = model.fit_transform(X3)
plt.scatter(out3[:, 0], out3[:, 1], **colorize)
plt.axis('equal');
这基本上是流形学习估计器的目标:在给定高维嵌入数据的情况下,寻找保持数据内部某些关系的低维表示。在 MDS 的情况下,保持的量是每对点之间的距离。
图 46-6. 三维数据的 MDS 嵌入恢复了输入,经过了旋转和反射。
非线性嵌入:MDS 失败的地方
到目前为止,我们讨论的是线性嵌入,这本质上是将数据旋转、平移和缩放到更高维空间中。当嵌入是非线性的时候,即超出这简单操作集合时,MDS 失效了。考虑下面的嵌入,它将输入扭曲成了三维空间中的“S”形状:
In [12]: def make_hello_s_curve(X):
t = (X[:, 0] - 2) * 0.75 * np.pi
x = np.sin(t)
y = X[:, 1]
z = np.sign(t) * (np.cos(t) - 1)
return np.vstack((x, y, z)).T
XS = make_hello_s_curve(X)
这同样是三维数据,但正如我们在图 46-7 中所见,嵌入更加复杂。
In [13]: from mpl_toolkits import mplot3d
ax = plt.axes(projection='3d')
ax.scatter3D(XS[:, 0], XS[:, 1], XS[:, 2],
**colorize);
图 46-7. 数据非线性嵌入到三维空间中
数据点之间的基本关系仍然存在,但这次数据以非线性的方式进行了转换:它被包裹成了一个“S”形状。
如果我们尝试在这些数据上使用简单的 MDS 算法,它无法“展开”这个非线性嵌入,我们会丢失嵌入流形中基本的关系(见图 46-8)。
In [14]: from sklearn.manifold import MDS
model = MDS(n_components=2, random_state=2)
outS = model.fit_transform(XS)
plt.scatter(outS[:, 0], outS[:, 1], **colorize)
plt.axis('equal');
图 46-8. 应用于非线性数据的 MDS 算法;它未能恢复底层结构
最佳的二维线性嵌入并没有展开 S 形曲线,而是丢弃了原始的 y 轴。
非线性流形:局部线性嵌入
在这里我们该怎么继续?退一步来看,我们可以看到问题的根源在于,MDS 试图在构建嵌入时保持远距离点之间的距离。但如果我们改变算法,使其仅保持附近点之间的距离会怎样?结果的嵌入会更接近我们想要的。
从视觉上看,我们可以将其想象成如图 46-9 所示。
这里每条淡淡的线表示一个应该在嵌入中保留的距离。左侧是 MDS 使用的模型的表示:它试图保持数据集中每对点之间的距离。右侧是一种名为局部线性嵌入的流形学习算法使用的模型的表示:它不是保留所有距离,而是试图仅保留相邻点之间的距离(在这种情况下,每个点的最近 100 个邻居)。
考虑左侧面板,我们可以看到为什么 MDS 失败了:没有办法展开这些数据同时充分保持两点之间绘制的每条线的长度。另一方面,对于右侧面板,情况看起来更加乐观。我们可以想象以一种方式展开数据,以保持线的长度大致相同。这正是 LLE 通过全局优化反映这种逻辑的成本函数所做的。
图 46-9. MDS 和 LLE 之间点之间联系的表示^(1)
LLE 有许多变体;在这里,我们将使用改进的 LLE算法来恢复嵌入的二维流形。总的来说,改进的 LLE 比算法的其他变体更能够恢复具有很少扭曲的明确定义流形(参见图 46-10)。
In [15]: from sklearn.manifold import LocallyLinearEmbedding
model = LocallyLinearEmbedding(
n_neighbors=100, n_components=2,
method='modified', eigen_solver='dense')
out = model.fit_transform(XS)
fig, ax = plt.subplots()
ax.scatter(out[:, 0], out[:, 1], **colorize)
ax.set_ylim(0.15, -0.15);
与我们原始流形相比,结果仍然有些扭曲,但捕捉到了数据中的基本关系!
图 46-10. 局部线性嵌入可以从非线性嵌入的输入中恢复底层数据
对流形方法的一些思考
尽管这些示例可能很引人注目,但在实践中,流形学习技术往往很难处理,因此很少被用于除了高维数据的简单定性可视化之外的任何其他用途。
以下是流形学习的一些特定挑战,这些挑战都与 PCA 相比非常不利:
-
流形学习中,没有处理缺失数据的良好框架。相比之下,在 PCA 中有直接的迭代方法来处理缺失数据。
-
在流形学习中,数据中的噪声存在可以“短路”流形并显著改变嵌入的情况。相比之下,PCA 自然地从最重要的组件中过滤噪声。
-
流形嵌入结果通常高度依赖于选择的邻居数,并且通常没有一种可靠的定量方法来选择最佳邻居数。相比之下,PCA 不涉及这样的选择。
-
在流形学习中,确定全局最佳输出维度的难度很大。相比之下,PCA 可以根据解释的方差来确定输出维度的数量。
-
在流形学习中,嵌入维度的含义并不总是清晰的。在 PCA 中,主成分有一个非常明确的含义。
-
在流形学习中,流形方法的计算开销按 或 的方式扩展。对于 PCA,存在一些随机化方法通常要快得多(尽管请参考megaman package以获取更多可扩展的流形学习实现)。
总结一下,流形学习方法相对于 PCA 唯一明显的优势是它们能够保留数据中的非线性关系;因此,我倾向于仅在首先使用 PCA 探索数据后才使用流形方法探索数据。
Scikit-Learn 实现了除了 LLE 和 Isomap 之外的几种常见流形学习变体(我们在前几章中使用过 Isomap,将在下一节中查看):Scikit-Learn 文档对它们进行了很好的讨论和比较。根据我的经验,我会提出以下建议:
-
对于像我们之前看到的 S 曲线这样的玩具问题,LLE 及其变体(特别是修改的 LLE)表现非常好。这在
sklearn.manifold.LocallyLinearEmbedding
中实现。 -
对于来自现实世界来源的高维数据,LLE 通常会产生较差的结果,而 Isomap 似乎通常会导致更有意义的嵌入。这在
sklearn.manifold.Isomap
中实现。 -
对于高度聚类的数据,t-分布随机近邻嵌入(t-SNE)似乎效果非常好,尽管与其他方法相比速度较慢。这在
sklearn.manifold.TSNE
中实现。
如果你对这些方法的运行感兴趣,我建议你在这一节的数据上分别运行每种方法。
例如:Faces 上的 Isomap
流形学习经常用于理解高维数据点之间的关系。高维数据的一个常见案例是图像:例如,每个包含 1,000 个像素的图像集可以被视为一个 1,000 维空间中的点集,其中每个像素的亮度定义了该维度上的坐标。
为了说明,让我们在一些来自 Labeled Faces in the Wild 数据集的数据上应用 Isomap,我们之前在第四十三章和第四十五章中看到过。运行此命令将下载数据集并将其缓存到您的主目录供以后使用:
In [16]: from sklearn.datasets import fetch_lfw_people
faces = fetch_lfw_people(min_faces_per_person=30)
faces.data.shape
Out[16]: (2370, 2914)
我们有 2,370 张图像,每张图像有 2,914 个像素。换句话说,这些图像可以被视为 2,914 维空间中的数据点!
让我们展示几张这些图像,以便提醒我们正在处理的内容(参见图 46-11)。
In [17]: fig, ax = plt.subplots(4, 8, subplot_kw=dict(xticks=[], yticks=[]))
for i, axi in enumerate(ax.flat):
axi.imshow(faces.images[i], cmap='gray')
图 46-11. 输入人脸示例
当我们在第四十五章遇到这些数据时,我们的目标基本上是压缩:使用组件从较低维度表示重建输入。
PCA 足够灵活,我们也可以在此上下文中使用它,我们想要绘制 2914 维数据的低维嵌入,以学习图像之间的基本关系。让我们再次查看解释的方差比率,这将为我们提供所需的线性特征数量的概念(参见图 46-12)。
In [18]: from sklearn.decomposition import PCA
model = PCA(100, svd_solver='randomized').fit(faces.data)
plt.plot(np.cumsum(model.explained_variance_ratio_))
plt.xlabel('n components')
plt.ylabel('cumulative variance');
图 46-12. 来自 PCA 投影的累积方差
对于这些数据,我们看到需要近 100 个组件才能保留 90%的方差。这告诉我们,数据在本质上是非常高维的——不能仅用少量组件线性描述。
当情况如此时,非线性流形嵌入如 LLE 和 Isomap 可能会有所帮助。我们可以使用与之前相同的模式在这些人脸上计算 Isomap 嵌入:
In [19]: from sklearn.manifold import Isomap
model = Isomap(n_components=2)
proj = model.fit_transform(faces.data)
proj.shape
Out[19]: (2370, 2)
输出是所有输入图像的二维投影。为了更好地了解投影告诉我们的内容,让我们定义一个函数,该函数将在投影位置输出图像缩略图:
In [20]: from matplotlib import offsetbox
def plot_components(data, model, images=None, ax=None,
thumb_frac=0.05, cmap='gray'):
ax = ax or plt.gca()
proj = model.fit_transform(data)
ax.plot(proj[:, 0], proj[:, 1], '.k')
if images is not None:
min_dist_2 = (thumb_frac * max(proj.max(0) - proj.min(0))) ** 2
shown_images = np.array([2 * proj.max(0)])
for i in range(data.shape[0]):
dist = np.sum((proj[i] - shown_images) ** 2, 1)
if np.min(dist) < min_dist_2:
# don't show points that are too close
continue
shown_images = np.vstack([shown_images, proj[i]])
imagebox = offsetbox.AnnotationBbox(
offsetbox.OffsetImage(images[i], cmap=cmap),
proj[i])
ax.add_artist(imagebox)
现在调用此函数,我们将在图 46-13 中看到结果。
In [21]: fig, ax = plt.subplots(figsize=(10, 10))
plot_components(faces.data,
model=Isomap(n_components=2),
images=faces.images[:, ::2, ::2])
图 46-13. LFW 数据的 Isomap 嵌入
结果很有趣。前两个 Isomap 维度似乎描述了全局图像特征:图像的整体亮度从左到右,以及脸部的一般方向从底部到顶部。这为我们提供了一些数据中一些基本特征的良好视觉指示。
从这里,我们可以继续对这些数据进行分类(也许使用流形特征作为分类算法的输入),就像我们在第四十三章中所做的那样。
示例:可视化数字中的结构
作为使用流形学习进行可视化的另一个示例,让我们看一下 MNIST 手写数字数据集。这类似于我们在第四十四章中看到的数字数据集,但每个图像的像素更多。可以使用 Scikit-Learn 工具从http://openml.org下载它:
In [22]: from sklearn.datasets import fetch_openml
mnist = fetch_openml('mnist_784')
mnist.data.shape
Out[22]: (70000, 784)
数据集由 70,000 个图像组成,每个图像有 784 个像素(即,图像为 28 × 28)。和以前一样,我们可以查看前几个图像(参见图 46-14)。
In [23]: mnist_data = np.asarray(mnist.data)
mnist_target = np.asarray(mnist.target, dtype=int)
fig, ax = plt.subplots(6, 8, subplot_kw=dict(xticks=[], yticks=[]))
for i, axi in enumerate(ax.flat):
axi.imshow(mnist_data[1250 * i].reshape(28, 28), cmap='gray_r')
图 46-14. MNIST 数字示例
这让我们了解到数据集中手写风格的多样性。
让我们在数据集中进行流形学习投影。为了加快速度,我们只使用了 1/30 的数据,大约是 ~2,000 个点(由于流形学习的比例尺度相对较差,我发现几千个样本是一个相对快速探索的良好起点,然后再转向完整的计算)。图 46-15 展示了结果。
In [24]: # Use only 1/30 of the data: full dataset takes a long time!
data = mnist_data[::30]
target = mnist_target[::30]
model = Isomap(n_components=2)
proj = model.fit_transform(data)
plt.scatter(proj[:, 0], proj[:, 1], c=target,
cmap=plt.cm.get_cmap('jet', 10))
plt.colorbar(ticks=range(10))
plt.clim(-0.5, 9.5);
图 46-15. MNIST 数字数据的 Isomap 嵌入
结果散点图展示了数据点之间的一些关系,但有点拥挤。我们可以通过逐个查看单个数字来获得更多见解(参见图 46-16)。
In [25]: # Choose 1/4 of the "1" digits to project
data = mnist_data[mnist_target == 1][::4]
fig, ax = plt.subplots(figsize=(10, 10))
model = Isomap(n_neighbors=5, n_components=2, eigen_solver='dense')
plot_components(data, model, images=data.reshape((-1, 28, 28)),
ax=ax, thumb_frac=0.05, cmap='gray_r')
图 46-16. 仅包含 MNIST 数据集中数字 1 的 Isomap 嵌入
结果让你了解到数据集中数字 1 可以呈现的各种形式。数据沿着投影空间中的一条宽曲线分布,这条曲线似乎跟数字的方向有关。通过观察图上方的部分,你会发现一些带帽子和/或底座的数字 1,尽管它们在数据集中非常稀疏。投影让我们能够识别出存在数据问题的离群值:例如,一些邻近数字的部分可能已经混入提取的图像中。
现在,这本身对于分类数字可能并不有用,但它确实帮助我们了解数据,并可能给我们关于如何继续进行的想法——比如我们可能希望在构建分类管道之前对数据进行预处理。
^(1) 在在线附录中可以找到产生此图的代码。
第四十七章:深入理解 k-Means 聚类
在之前的章节中,我们探索了用于降维的无监督机器学习模型。现在我们将转向另一类无监督机器学习模型:聚类算法。聚类算法试图从数据的属性中学习出最优的分割或离散标记的群组点。
Scikit-Learn 和其他地方提供了许多聚类算法,但可能最容易理解的算法是称为 k-means 聚类 的算法,它在 sklearn.cluster.KMeans
中实现。
我们从标准导入开始:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
介绍 k-Means
k-means 算法在未标记的多维数据集中搜索预定数量的簇。它使用简单的概念来定义最优的聚类:
-
簇中心 是属于该簇的所有点的算术平均值。
-
每个点更接近它自己的簇中心,而不是其他簇中心。
这两个假设是 k-means 模型的基础。我们很快将深入了解算法如何达到这个解决方案,但现在让我们看一看一个简单数据集,并查看 k-means 的结果。
首先,让我们生成一个包含四个不同斑点的二维数据集。为了突出这是一个无监督算法,我们将在可视化中省略标签(见图 47-1)。
In [2]: from sklearn.datasets import make_blobs
X, y_true = make_blobs(n_samples=300, centers=4,
cluster_std=0.60, random_state=0)
plt.scatter(X[:, 0], X[:, 1], s=50);
图 47-1:用于演示聚类的数据
肉眼看来,相对容易选出这四个簇。k-means 算法自动执行此操作,并在 Scikit-Learn 中使用典型的估计器 API:
In [3]: from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=4)
kmeans.fit(X)
y_kmeans = kmeans.predict(X)
让我们通过按照这些标签对数据进行着色来可视化结果(图 47-2)。我们还将绘制由 k-means 估计器确定的簇中心:
In [4]: plt.scatter(X[:, 0], X[:, 1], c=y_kmeans, s=50, cmap='viridis')
centers = kmeans.cluster_centers_
plt.scatter(centers[:, 0], centers[:, 1], c='black', s=200);
令人高兴的是,k-means 算法(至少在这个简单案例中)将点分配到簇中的方式与我们通过肉眼观察的方式非常相似。但你可能会想知道这个算法是如何如此快速地找到这些簇的:毕竟,簇分配的可能组合数随数据点数量呈指数增长——全面搜索将非常、非常昂贵。对我们来说幸运的是,这样的全面搜索并不是必需的:相反,k-means 的典型方法涉及一种直观的迭代方法,称为期望最大化。
图 47-2:带有颜色指示簇的 k-means 簇中心
期望最大化
期望最大化(E–M)是数据科学中多种情境中的一个强大算法。k-means 是该算法的一个特别简单且易于理解的应用;我们将在这里简要介绍它。简而言之,在这里期望最大化方法包括以下步骤:
-
猜测一些聚类中心。
-
直到收敛重复:
-
E 步:将点分配给最近的聚类中心。
-
M 步:将聚类中心设置为其分配点的平均值。
-
这里的 E 步 或 期望步骤 之所以这样命名,是因为它涉及更新我们对每个点属于哪个聚类的期望。M 步 或 最大化步骤 之所以这样命名,是因为它涉及最大化某些定义聚类中心位置的适应函数——在本例中,通过简单地取每个聚类中数据的平均值来实现该最大化。
关于这一算法的文献非常丰富,但可以总结如下:在典型情况下,每次 E 步和 M 步的重复都会导致对聚类特征的更好估计。
我们可以将算法可视化如图 Figure 47-3 所示。对于此处显示的特定初始化,聚类在仅三次迭代中收敛。(有关此图的交互版本,请参阅在线 附录 中的代码。)
图 47-3. k-means 的 E-M 算法可视化^(1)
k-means 算法简单到我们可以用几行代码来编写它。以下是一个非常基本的实现(参见图 Figure 47-4)。
In [5]: from sklearn.metrics import pairwise_distances_argmin
def find_clusters(X, n_clusters, rseed=2):
# 1\. Randomly choose clusters
rng = np.random.RandomState(rseed)
i = rng.permutation(X.shape[0])[:n_clusters]
centers = X[i]
while True:
# 2a. Assign labels based on closest center
labels = pairwise_distances_argmin(X, centers)
# 2b. Find new centers from means of points
new_centers = np.array([X[labels == i].mean(0)
for i in range(n_clusters)])
# 2c. Check for convergence
if np.all(centers == new_centers):
break
centers = new_centers
return centers, labels
centers, labels = find_clusters(X, 4)
plt.scatter(X[:, 0], X[:, 1], c=labels,
s=50, cmap='viridis');
图 47-4. 使用 k-means 标记的数据
大多数经过良好测试的实现在底层会做更多事情,但上述函数传达了期望-最大化方法的主旨。在使用期望-最大化算法时,有几个需要注意的事项:
可能无法达到全局最优结果
首先,尽管 E-M 过程保证在每个步骤中改善结果,但不能保证它会导致 全局 最佳解。例如,如果在我们的简单过程中使用不同的随机种子,特定的起始猜测会导致糟糕的结果(参见图 Figure 47-5)。
In [6]: centers, labels = find_clusters(X, 4, rseed=0)
plt.scatter(X[:, 0], X[:, 1], c=labels,
s=50, cmap='viridis');
图 47-5. k-means 算法收敛不良的示例
在这里,E-M 方法已经收敛,但未收敛到全局最优配置。因此,通常会对算法使用多个起始猜测进行多次运行,默认情况下 Scikit-Learn 就是如此(该数字由 n_init
参数设置,默认为 10)。
必须事先选择聚类数
k-means 的另一个常见挑战是您必须告诉它您期望的聚类数:它无法从数据中学习到聚类数。例如,如果我们要求算法识别六个聚类,它将愉快地继续并找到最佳的六个聚类,如图 Figure 40-1 所示:
In [7]: labels = KMeans(6, random_state=0).fit_predict(X)
plt.scatter(X[:, 0], X[:, 1], c=labels,
s=50, cmap='viridis');
图 47-6. 簇数量选择不当的示例
结果是否有意义是一个很难明确回答的问题;一个相当直观的方法是使用轮廓分析,但我们这里不再进一步讨论。
或者,您可以使用更复杂的聚类算法,该算法对于每个簇的适应度有更好的定量衡量(例如,高斯混合模型;参见第四十八章),或者可以选择合适的簇数量(例如,DBSCAN、均值漂移或亲和力传播,这些都在sklearn.cluster
子模块中提供)。
k-means 仅限于线性簇边界
k-means 的基本模型假设(点会更靠近自己的簇中心而不是其他簇)意味着如果簇具有复杂的几何结构,则该算法通常会失效。
特别地,k-means 簇之间的边界始终是线性的,这意味着对于更复杂的边界,它将失败。 考虑以下数据,以及典型k-means 方法找到的簇标签(见图 47-7)。
In [8]: from sklearn.datasets import make_moons
X, y = make_moons(200, noise=.05, random_state=0)
In [9]: labels = KMeans(2, random_state=0).fit_predict(X)
plt.scatter(X[:, 0], X[:, 1], c=labels,
s=50, cmap='viridis');
图 47-7. k-means 在非线性边界下的失败
这种情况让人想起了第四十三章中的讨论,在那里我们使用核变换将数据投影到更高的维度,从而可能实现线性分离。 我们可以想象使用相同的技巧来允许k-means 发现非线性边界。
这种基于核的k-means 的一个版本在 Scikit-Learn 中通过SpectralClustering
估计器实现。 它使用最近邻图来计算数据的更高维表示,然后使用k-means 算法分配标签(参见图 47-8)。
In [10]: from sklearn.cluster import SpectralClustering
model = SpectralClustering(n_clusters=2,
affinity='nearest_neighbors',
assign_labels='kmeans')
labels = model.fit_predict(X)
plt.scatter(X[:, 0], X[:, 1], c=labels,
s=50, cmap='viridis');
图 47-8. SpectralClustering 学习的非线性边界
我们看到,通过这种核变换方法,基于核的k-means 能够找到更复杂的簇之间的非线性边界。
对于大量样本,k-means 可能会运行缓慢。
因为k-means 的每次迭代都必须访问数据集中的每个点,所以随着样本数量的增长,该算法可能相对缓慢。 您可能会想知道是否可以放宽每次迭代使用所有数据的要求;例如,您可能只使用数据的子集来更新每个步骤的簇中心。 这就是批量式k-means 算法背后的思想,其中一种形式在sklearn.cluster.MiniBatchKMeans
中实现。 其接口与标准的KMeans
相同;我们将在继续讨论时看到其使用示例。
例子
虽然我们要注意算法的这些限制,但我们可以在各种情况下利用k-均值来获益。现在我们来看几个例子。
示例 1:数字上的k-均值
首先,让我们看看在我们在第四十四章 和第四十五章 中看到的相同简单数字数据上应用k-均值。在这里,我们将尝试使用k-均值来尝试识别类似的数字,而不使用原始标签信息;这可能类似于从一个没有任何先验标签信息的新数据集中提取含义的第一步。
我们将从加载数据集开始,然后找到聚类。回想一下,数字数据集包含 1,797 个样本,每个样本有 64 个特征,其中每个特征是 8 × 8 图像中一个像素的亮度。
In [11]: from sklearn.datasets import load_digits
digits = load_digits()
digits.data.shape
Out[11]: (1797, 64)
我们可以像之前一样执行聚类:
In [12]: kmeans = KMeans(n_clusters=10, random_state=0)
clusters = kmeans.fit_predict(digits.data)
kmeans.cluster_centers_.shape
Out[12]: (10, 64)
结果是 64 维空间中的 10 个聚类。请注意,聚类中心本身是 64 维点,可以解释为聚类内“典型”的数字。让我们看看这些聚类中心是什么样子的(见 图 47-9)。
In [13]: fig, ax = plt.subplots(2, 5, figsize=(8, 3))
centers = kmeans.cluster_centers_.reshape(10, 8, 8)
for axi, center in zip(ax.flat, centers):
axi.set(xticks=[], yticks=[])
axi.imshow(center, interpolation='nearest', cmap=plt.cm.binary)
图 47-9. k-均值学习到的聚类中心
我们看到,即使没有标签的情况下,KMeans
也能够找到其聚类中心可识别的数字,也许除了“1”和“8”之外。
因为k-均值对聚类的身份一无所知,0–9 标签可能会被排列。我们可以通过将每个学习到的聚类标签与聚类中找到的真实标签匹配来解决这个问题:
In [14]: from scipy.stats import mode
labels = np.zeros_like(clusters)
for i in range(10):
mask = (clusters == i)
labels[mask] = mode(digits.target[mask])[0]
现在我们可以检查我们的无监督聚类在找到数据中相似数字方面的准确性:
In [15]: from sklearn.metrics import accuracy_score
accuracy_score(digits.target, labels)
Out[15]: 0.7935447968836951
仅仅使用简单的k-均值算法,我们就为 80%的输入数字找到了正确的分组!让我们来查看这个混淆矩阵,它在 图 47-10 中可视化。
In [16]: from sklearn.metrics import confusion_matrix
import seaborn as sns
mat = confusion_matrix(digits.target, labels)
sns.heatmap(mat.T, square=True, annot=True, fmt='d',
cbar=False, cmap='Blues',
xticklabels=digits.target_names,
yticklabels=digits.target_names)
plt.xlabel('true label')
plt.ylabel('predicted label');
图 47-10. k-均值分类器的混淆矩阵
正如我们之前可视化的聚类中心所示,混淆的主要点在于数字“8”和“1”。但这仍然表明,使用k-均值,我们基本上可以建立一个数字分类器,无需参考任何已知标签!
仅仅是为了好玩,让我们尝试推动这个进展更远。我们可以在执行k-均值之前使用 t-分布随机邻居嵌入算法(在第四十六章中提到)对数据进行预处理。t-SNE 是一种非线性嵌入算法,特别擅长保留簇内的点。我们来看看它的表现:
In [17]: from sklearn.manifold import TSNE
# Project the data: this step will take several seconds
tsne = TSNE(n_components=2, init='random',
learning_rate='auto',random_state=0)
digits_proj = tsne.fit_transform(digits.data)
# Compute the clusters
kmeans = KMeans(n_clusters=10, random_state=0)
clusters = kmeans.fit_predict(digits_proj)
# Permute the labels
labels = np.zeros_like(clusters)
for i in range(10):
mask = (clusters == i)
labels[mask] = mode(digits.target[mask])[0]
# Compute the accuracy
accuracy_score(digits.target, labels)
Out[17]: 0.9415692821368948
这是一种不使用标签的 94% 分类准确率。这展示了无监督学习在谨慎使用时的强大能力:它可以从数据集中提取信息,这可能难以手工或肉眼提取。
示例 2:颜色压缩的k-均值
聚类的一个有趣应用是图像内的颜色压缩(此示例改编自 Scikit-Learn 的“使用 K-Means 进行颜色量化”)。例如,想象一下你有一幅包含数百万种颜色的图像。在大多数图像中,许多颜色将未被使用,并且图像中的许多像素将具有相似或甚至相同的颜色。
例如,考虑图像显示在图 47-11 中,这是来自 Scikit-Learn datasets
模块的(为了使其工作,您必须安装PIL
Python 包):
In [18]: # Note: this requires the PIL package to be installed
from sklearn.datasets import load_sample_image
china = load_sample_image("china.jpg")
ax = plt.axes(xticks=[], yticks=[])
ax.imshow(china);
图 47-11. 输入图像
图像本身存储在一个大小为(height, width, RGB)
的三维数组中,包含从 0 到 255 的整数表示的红/蓝/绿分量:
In [19]: china.shape
Out[19]: (427, 640, 3)
我们可以将这组像素视为三维色彩空间中的一组点云。我们将重新调整数据为[n_samples, n_features]
的形状,并重新缩放颜色,使其介于 0 到 1 之间:
In [20]: data = china / 255.0 # use 0...1 scale
data = data.reshape(-1, 3)
data.shape
Out[20]: (273280, 3)
我们可以使用 10000 个像素的子集在此色彩空间中可视化这些像素(见图 47-12)。
In [21]: def plot_pixels(data, title, colors=None, N=10000):
if colors is None:
colors = data
# choose a random subset
rng = np.random.default_rng(0)
i = rng.permutation(data.shape[0])[:N]
colors = colors[i]
R, G, B = data[i].T
fig, ax = plt.subplots(1, 2, figsize=(16, 6))
ax[0].scatter(R, G, color=colors, marker='.')
ax[0].set(xlabel='Red', ylabel='Green', xlim=(0, 1), ylim=(0, 1))
ax[1].scatter(R, B, color=colors, marker='.')
ax[1].set(xlabel='Red', ylabel='Blue', xlim=(0, 1), ylim=(0, 1))
fig.suptitle(title, size=20);
In [22]: plot_pixels(data, title='Input color space: 16 million possible colors')
图 47-12. 在 RGB 色彩空间中的像素分布^(3)
现在让我们将这 1600 万种颜色减少到只有 16 种颜色,使用像素空间的* k -means 聚类。由于我们正在处理一个非常大的数据集,我们将使用小批量 k -means,它在数据子集上计算结果(显示在图 47-13 中)比标准 k *-means 算法更快:
In [23]: from sklearn.cluster import MiniBatchKMeans
kmeans = MiniBatchKMeans(16)
kmeans.fit(data)
new_colors = kmeans.cluster_centers_[kmeans.predict(data)]
plot_pixels(data, colors=new_colors,
title="Reduced color space: 16 colors")
图 47-13. RGB 色彩空间中的 16 个聚类^(4)
结果是原始像素的重新着色,其中每个像素分配到最接近的聚类中心的颜色。将这些新颜色在图像空间而不是像素空间中绘制,显示了这种效果(见图 47-14)。
In [24]: china_recolored = new_colors.reshape(china.shape)
fig, ax = plt.subplots(1, 2, figsize=(16, 6),
subplot_kw=dict(xticks=[], yticks=[]))
fig.subplots_adjust(wspace=0.05)
ax[0].imshow(china)
ax[0].set_title('Original Image', size=16)
ax[1].imshow(china_recolored)
ax[1].set_title('16-color Image', size=16);
图 47-14. 全彩图像(左)和 16 色图像(右)的比较
右侧面板中确实丢失了一些细节,但整体图像仍然很容易识别。在存储原始数据所需的字节方面,右侧的图像实现了约 100 万的压缩比!现在,这种方法不会与像 JPEG 这样的专用图像压缩方案匹敌,但这个例子展示了通过* k *-means 等无监督方法进行创新思维的威力。
^(1) 生成此图的代码可以在在线附录中找到。
^(2) 欲查看彩色版本以及后续图像,请参阅本书的在线版本。
^(3) 这幅图的全尺寸版本可以在GitHub上找到。
^(4) 这幅图的全尺寸版本可以在GitHub上找到。
第四十八章:深入:高斯混合模型
在前一章中探讨的 k-means 聚类模型简单且相对易于理解,但其简单性导致在实际应用中存在实际挑战。特别是,k-means 的非概率性质以及其使用简单的距离从聚类中心分配聚类成员导致在许多实际情况下性能不佳。在本章中,我们将介绍高斯混合模型,它可以被视为对 k-means 背后思想的扩展,同时也可以是一种超越简单聚类的强大工具。
我们从标准导入开始:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
激励高斯混合模型:k-means 的弱点
让我们来看一些 k-means 的弱点,并思考如何改进聚类模型。正如我们在前一章中看到的,对于简单而明确分离的数据,k-means 能够找到合适的聚类结果。
例如,如果我们有简单的数据块,k-means 算法可以快速地标记这些聚类,其结果与我们可能通过眼睛观察到的相似(参见 图 48-1)。
In [2]: # Generate some data
from sklearn.datasets import make_blobs
X, y_true = make_blobs(n_samples=400, centers=4,
cluster_std=0.60, random_state=0)
X = X[:, ::-1] # flip axes for better plotting
In [3]: # Plot the data with k-means labels
from sklearn.cluster import KMeans
kmeans = KMeans(4, random_state=0)
labels = kmeans.fit(X).predict(X)
plt.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis');
图 48-1:简单数据的 k-means 标签
从直觉上讲,我们可能期望某些点的聚类分配比其他点更加确定:例如,在两个中间聚类之间似乎存在非常轻微的重叠,因此我们可能对它们之间的点的聚类分配没有完全的信心。不幸的是,k-means 模型没有内在的概率或聚类分配不确定性的衡量方法(尽管可能可以使用自举方法来估计此不确定性)。为此,我们必须考虑模型的泛化。
关于 k-means 模型的一种思考方式是,它在每个聚类的中心放置一个圆(或在更高维度中,一个超球体),其半径由聚类中最远的点定义。这个半径作为训练集内聚类分配的硬截止:任何在这个圆外的点都不被视为聚类的成员。我们可以用以下函数可视化这个聚类模型(参见 图 48-2)。
In [4]: from sklearn.cluster import KMeans
from scipy.spatial.distance import cdist
def plot_kmeans(kmeans, X, n_clusters=4, rseed=0, ax=None):
labels = kmeans.fit_predict(X)
# plot the input data
ax = ax or plt.gca()
ax.axis('equal')
ax.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis', zorder=2)
# plot the representation of the KMeans model
centers = kmeans.cluster_centers_
radii = [cdist(X[labels == i], [center]).max()
for i, center in enumerate(centers)]
for c, r in zip(centers, radii):
ax.add_patch(plt.Circle(c, r, ec='black', fc='lightgray',
lw=3, alpha=0.5, zorder=1))
In [5]: kmeans = KMeans(n_clusters=4, random_state=0)
plot_kmeans(kmeans, X)
图 48-2:k-means 模型暗示的圆形聚类
对于 k-means 的一个重要观察是,这些聚类模型必须是圆形的:k-means 没有内建的方法来处理椭圆形或椭圆形聚类。因此,例如,如果我们取同样的数据并对其进行转换,聚类分配最终变得混乱,正如你可以在 图 48-3 中看到的。
In [6]: rng = np.random.RandomState(13)
X_stretched = np.dot(X, rng.randn(2, 2))
kmeans = KMeans(n_clusters=4, random_state=0)
plot_kmeans(kmeans, X_stretched)
图 48-3:k-means 对于非圆形聚类的性能不佳
凭眼观察,我们认识到这些转换后的聚类不是圆形的,因此圆形聚类会拟合效果差。然而,k-means 不足以解决这个问题,并试图强行将数据拟合为四个圆形聚类。这导致了聚类分配的混合,其中结果的圆形重叠:尤其是在图的右下角可见。可以想象通过使用 PCA 预处理数据来处理这种情况(参见第四十五章),但实际上不能保证这样的全局操作会使各个群体圆形化。
k-means 的这两个缺点——在聚类形状上的灵活性不足和缺乏概率聚类分配——意味着对于许多数据集(特别是低维数据集),其性能可能不如人们所期望的那样好。
您可以通过泛化k-means 模型来解决这些弱点:例如,可以通过比较每个点到所有聚类中心的距离来测量聚类分配的不确定性,而不是仅关注最近的距离。您还可以想象允许聚类边界为椭圆而不是圆形,以适应非圆形聚类。事实证明,这些是不同类型聚类模型——高斯混合模型的两个基本组成部分。
泛化 E-M:高斯混合模型
高斯混合模型(GMM)试图找到最适合模拟任何输入数据集的多维高斯概率分布混合物。在最简单的情况下,GMM 可以像k-means 一样用于查找聚类(参见图 48-4)。
In [7]: from sklearn.mixture import GaussianMixture
gmm = GaussianMixture(n_components=4).fit(X)
labels = gmm.predict(X)
plt.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis');
图 48-4。数据的高斯混合模型标签
但是因为 GMM 在幕后包含一个概率模型,因此可以找到概率聚类分配——在 Scikit-Learn 中,这是通过predict_proba
方法完成的。这将返回一个大小为[n_samples, n_clusters]
的矩阵,用于测量任何点属于给定聚类的概率:
In [8]: probs = gmm.predict_proba(X)
print(probs[:5].round(3))
Out[8]: [[0. 0.531 0.469 0. ]
[0. 0. 0. 1. ]
[0. 0. 0. 1. ]
[0. 1. 0. 0. ]
[0. 0. 0. 1. ]]
我们可以通过将每个点的大小与其预测的确定性成比例来可视化这种不确定性;查看图 48-5,我们可以看到恰好在聚类边界上的点反映了聚类分配的不确定性:
In [9]: size = 50 * probs.max(1) ** 2 # square emphasizes differences
plt.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis', s=size);
图 48-5。GMM 概率标签:点的大小显示概率大小
在幕后,高斯混合模型与k-means 非常相似:它使用期望最大化方法,大致如下:
-
选择位置和形状的初始猜测。
-
直到收敛为止重复:
-
E 步骤:对于每个点,找到编码每个聚类成员概率的权重。
-
M 步:对于每个聚类,根据所有数据点更新其位置、归一化和形状,利用权重。
-
其结果是,每个聚类不再关联于硬边界的球体,而是与平滑的高斯模型相关联。就像k-means 期望最大化方法一样,这种算法有时会错过全局最优解,因此在实践中使用多个随机初始化。
让我们创建一个函数,通过根据 GMM 输出绘制椭圆来帮助我们可视化 GMM 聚类的位置和形状:
In [10]: from matplotlib.patches import Ellipse
def draw_ellipse(position, covariance, ax=None, **kwargs):
"""Draw an ellipse with a given position and covariance"""
ax = ax or plt.gca()
# Convert covariance to principal axes
if covariance.shape == (2, 2):
U, s, Vt = np.linalg.svd(covariance)
angle = np.degrees(np.arctan2(U[1, 0], U[0, 0]))
width, height = 2 * np.sqrt(s)
else:
angle = 0
width, height = 2 * np.sqrt(covariance)
# Draw the ellipse
for nsig in range(1, 4):
ax.add_patch(Ellipse(position, nsig * width, nsig * height,
angle, **kwargs))
def plot_gmm(gmm, X, label=True, ax=None):
ax = ax or plt.gca()
labels = gmm.fit(X).predict(X)
if label:
ax.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis',
zorder=2)
else:
ax.scatter(X[:, 0], X[:, 1], s=40, zorder=2)
ax.axis('equal')
w_factor = 0.2 / gmm.weights_.max()
for pos, covar, w in zip(gmm.means_, gmm.covariances_, gmm.weights_):
draw_ellipse(pos, covar, alpha=w * w_factor)
有了这些基础,我们可以看看四分量 GMM 对我们的初始数据给出了什么结果(参见图 48-6)。
In [11]: gmm = GaussianMixture(n_components=4, random_state=42)
plot_gmm(gmm, X)
图 48-6. 存在圆形聚类的四分量 GMM
同样地,我们可以使用 GMM 方法拟合我们的伸展数据集;允许完全协方差模型将适合甚至是非常椭圆形、拉伸的聚类,正如我们在图 48-7 中所看到的。
In [12]: gmm = GaussianMixture(n_components=4, covariance_type='full',
random_state=42)
plot_gmm(gmm, X_stretched)
图 48-7. 存在非圆形聚类的四分量 GMM
这清楚地表明,GMM 解决了之前在k-means 中遇到的两个主要实际问题。
选择协方差类型
如果您查看前面拟合的细节,您会发现在每个拟合中设置了covariance_type
选项。该超参数控制每个聚类形状的自由度;对于任何给定的问题,仔细设置这一点至关重要。默认值是covariance_type="diag"
,这意味着可以独立设置每个维度上的聚类大小,生成的椭圆受限于与轴对齐。covariance_type="spherical"
是一个稍微简单且更快的模型,它限制了聚类形状,使得所有维度相等。结果聚类将具有与k-means 类似的特征,尽管它并非完全等价。一个更复杂和计算开销更大的模型(特别是在维度增长时)是使用covariance_type="full"
,它允许将每个聚类建模为带有任意方向的椭圆。图 48-8 表示了这三种选择对单个聚类的影响。
图 48-8. GMM 协方差类型可视化^(1)
高斯混合模型作为密度估计
尽管 GMM 通常被归类为聚类算法,但从根本上讲,它是一种用于密度估计的算法。也就是说,对某些数据进行 GMM 拟合的结果在技术上不是聚类模型,而是描述数据分布的生成概率模型。
以 Scikit-Learn 的make_moons
函数生成的数据为例,介绍在第四十七章中。
In [13]: from sklearn.datasets import make_moons
Xmoon, ymoon = make_moons(200, noise=.05, random_state=0)
plt.scatter(Xmoon[:, 0], Xmoon[:, 1]);
图 48-9. GMM 应用于具有非线性边界的聚类
如果我们尝试用一个两组分的 GMM 作为聚类模型来拟合它,结果并不特别有用(见图 48-10)。
In [14]: gmm2 = GaussianMixture(n_components=2, covariance_type='full',
random_state=0)
plot_gmm(gmm2, Xmoon)
图 48-10. 对非线性聚类拟合的两组分 GMM
但是,如果我们使用更多组分并忽略聚类标签,我们会发现拟合结果更接近输入数据(见图 48-11)。
In [15]: gmm16 = GaussianMixture(n_components=16, covariance_type='full',
random_state=0)
plot_gmm(gmm16, Xmoon, label=False)
图 48-11. 使用多个 GMM 组件来建模点分布
这里的 16 个高斯分量的混合并不是为了找到数据的分离聚类,而是为了对输入数据的整体分布进行建模。这是一个生成模型,意味着 GMM 给了我们一个生成新随机数据的方法,其分布类似于我们的原始输入数据。例如,这里有 400 个新点从这个 16 组分的 GMM 拟合到我们的原始数据中绘制出来(见图 48-12)。
In [16]: Xnew, ynew = gmm16.sample(400)
plt.scatter(Xnew[:, 0], Xnew[:, 1]);
图 48-12. 从 16 组分 GMM 中绘制的新数据
GMM 作为一种灵活的方法,方便地对数据的任意多维分布进行建模。
GMM 作为生成模型的事实给了我们一种自然的方法来确定给定数据集的最优组件数。生成模型本质上是数据集的概率分布,因此我们可以简单地在模型下评估数据的似然性,使用交叉验证来避免过度拟合。另一种校正过度拟合的方法是使用一些分析标准来调整模型的似然性,例如阿卡奇信息准则(AIC)或贝叶斯信息准则(BIC)。Scikit-Learn 的GaussianMixture
估计器实际上包含内置方法来计算这两者,因此使用这种方法非常容易。
让我们看看我们的 moons 数据集的 GMM 组件数对应的 AIC 和 BIC(见图 48-13)。
In [17]: n_components = np.arange(1, 21)
models = [GaussianMixture(n, covariance_type='full',
random_state=0).fit(Xmoon)
for n in n_components]
plt.plot(n_components, [m.bic(Xmoon) for m in models], label='BIC')
plt.plot(n_components, [m.aic(Xmoon) for m in models], label='AIC')
plt.legend(loc='best')
plt.xlabel('n_components');
图 48-13. AIC 和 BIC 的可视化,用于选择 GMM 组件数
最优的聚类数是能够最小化 AIC 或 BIC 的值,具体取决于我们希望使用哪种近似方法。AIC 告诉我们,我们之前选择的 16 组分可能太多了:选择大约 8-12 组分可能更合适。对于这类问题,BIC 通常推荐一个更简单的模型。
注意重要的一点:组件数量的选择衡量的是 GMM 作为密度估计器的工作效果,而不是作为聚类算法的工作效果。我鼓励您主要将 GMM 视为密度估计器,并仅在简单数据集内合适时用它进行聚类。
示例:使用 GMM 生成新数据
我们刚刚看到了使用 GMM 作为生成模型的简单示例,以便从定义为输入数据分布的模型中创建新的样本。在这里,我们将继续这个想法,并从之前使用过的标准数字语料库中生成新的手写数字。
首先,让我们使用 Scikit-Learn 的数据工具加载数字数据:
In [18]: from sklearn.datasets import load_digits
digits = load_digits()
digits.data.shape
Out[18]: (1797, 64)
接下来,让我们绘制前 50 个样本,以确切回顾我们正在查看的内容(参见图 48-14)。
In [19]: def plot_digits(data):
fig, ax = plt.subplots(5, 10, figsize=(8, 4),
subplot_kw=dict(xticks=[], yticks=[]))
fig.subplots_adjust(hspace=0.05, wspace=0.05)
for i, axi in enumerate(ax.flat):
im = axi.imshow(data[i].reshape(8, 8), cmap='binary')
im.set_clim(0, 16)
plot_digits(digits.data)
图 48-14. 手写数字输入
我们有将近 1,800 个 64 维度的数字样本,我们可以在其上构建一个混合高斯模型(GMM)以生成更多数字。在这么高维度的空间中,GMM 可能会有收敛困难,因此我们将从数据中开始使用一个可逆的降维算法。这里我们将使用简单的 PCA,要求它在投影数据中保留 99%的方差:
In [20]: from sklearn.decomposition import PCA
pca = PCA(0.99, whiten=True)
data = pca.fit_transform(digits.data)
data.shape
Out[20]: (1797, 41)
结果是 41 个维度,几乎没有信息损失的减少了近 1/3。鉴于这个投影数据,让我们使用 AIC 来确定我们应该使用多少个 GMM 组件(参见图 48-15)。
In [21]: n_components = np.arange(50, 210, 10)
models = [GaussianMixture(n, covariance_type='full', random_state=0)
for n in n_components]
aics = [model.fit(data).aic(data) for model in models]
plt.plot(n_components, aics);
图 48-15. 选择适当的 GMM 组件数量的 AIC 曲线
看起来大约使用 140 个组件可以最小化 AIC;我们将使用这个模型。让我们快速将其拟合到数据上并确认它已经收敛:
In [22]: gmm = GaussianMixture(140, covariance_type='full', random_state=0)
gmm.fit(data)
print(gmm.converged_)
Out[22]: True
现在我们可以在这个 41 维度的投影空间内绘制 100 个新点的样本,使用 GMM 作为生成模型:
In [23]: data_new, label_new = gmm.sample(100)
data_new.shape
Out[23]: (100, 41)
最后,我们可以使用 PCA 对象的逆变换来构造新的数字(参见图 48-16)。
In [24]: digits_new = pca.inverse_transform(data_new)
plot_digits(digits_new)
图 48-16. 从 GMM 估计器的基础模型中随机绘制的“新”数字
大多数结果看起来像数据集中合理的数字!
考虑我们在这里所做的:鉴于手写数字的抽样,我们已经模拟了该数据的分布,以便我们可以从数据中生成全新的样本:这些是“手写数字”,它们不会单独出现在原始数据集中,而是捕捉了混合模型建模的输入数据的一般特征。这样的手写数字的生成模型在贝叶斯生成分类器的组成部分中可以非常有用,这一点我们将在下一章看到。
^(1) 生成此图的代码可以在在线附录中找到。
第四十九章:深入探讨:核密度估计
在第四十八章中,我们讨论了高斯混合模型,这是一种聚类估计器和密度估计器之间的混合类型。回想一下,密度估计器是一种算法,它接受一个维数据集,并生成数据抽取自其中的维概率分布的估计。GMM 算法通过将密度表示为高斯分布的加权和来实现这一点。核密度估计(KDE)在某种意义上是将高斯混合思想推向其逻辑极限的算法:它使用每个点一个高斯分量的混合,从而得到一个基本上是非参数的密度估计器。在本章中,我们将探讨 KDE 的动机和用途。
我们从标准导入开始:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
激发核密度估计:直方图
如前所述,密度估计器是一种算法,旨在模拟生成数据集的概率分布。对于一维数据,你可能已经熟悉一个简单的密度估计器:直方图。直方图将数据分成离散的箱子,计算落入每个箱子的点的数量,然后直观地可视化结果。
例如,让我们创建一些从两个正态分布中绘制的数据:
In [2]: def make_data(N, f=0.3, rseed=1):
rand = np.random.RandomState(rseed)
x = rand.randn(N)
x[int(f * N):] += 5
return x
x = make_data(1000)
我们之前看到,可以通过指定直方图的density
参数来创建标准的基于计数的直方图。通过这种方式得到的是归一化的直方图,其中箱子的高度不反映计数,而是反映概率密度(参见图 49-1)。
In [3]: hist = plt.hist(x, bins=30, density=True)
图 49-1. 从正态分布组合中绘制的数据
对于等距分箱,这种归一化仅仅改变了 y 轴的比例,使得相对高度基本上与计数直方图中的相同。选择这种归一化是为了使直方图下面积等于 1,我们可以通过直方图函数的输出来确认:
In [4]: density, bins, patches = hist
widths = bins[1:] - bins[:-1]
(density * widths).sum()
Out[4]: 1.0
使用直方图作为密度估计器的一个问题是,箱子的大小和位置的选择可能导致具有不同特征的表现。例如,如果我们查看只有 20 个点的此数据的一个版本,如何绘制箱子的选择可以导致对数据的完全不同解释!考虑到这个例子,在图 49-2 中可视化。
In [5]: x = make_data(20)
bins = np.linspace(-5, 10, 10)
In [6]: fig, ax = plt.subplots(1, 2, figsize=(12, 4),
sharex=True, sharey=True,
subplot_kw={'xlim':(-4, 9),
'ylim':(-0.02, 0.3)})
fig.subplots_adjust(wspace=0.05)
for i, offset in enumerate([0.0, 0.6]):
ax[i].hist(x, bins=bins + offset, density=True)
ax[i].plot(x, np.full_like(x, -0.01), '|k',
markeredgewidth=1)
图 49-2. 直方图的问题:箱子的位置会影响解释
左侧,直方图清楚地显示这是一个双峰分布。右侧,我们看到一个长尾单峰分布。如果不看前面的代码,你可能不会猜到这两个直方图是由同一组数据构建的。有了这个认识,我们如何相信直方图所传达的直觉呢?我们如何改进这一点呢?
总结一下,我们可以把直方图看作是一堆块,我们在数据集中的每个点上都堆叠一个块。让我们直接查看这一点(见图 49-3)。
In [7]: fig, ax = plt.subplots()
bins = np.arange(-3, 8)
ax.plot(x, np.full_like(x, -0.1), '|k',
markeredgewidth=1)
for count, edge in zip(*np.histogram(x, bins)):
for i in range(count):
ax.add_patch(plt.Rectangle(
(edge, i), 1, 1, ec='black', alpha=0.5))
ax.set_xlim(-4, 8)
ax.set_ylim(-0.2, 8)
Out[7]: (-0.2, 8.0)
图 49-3. 堆叠的块直方图
我们两次分箱的问题源于这样一个事实:块堆叠的高度经常反映不出附近点的实际密度,而是由于分箱与数据点对齐的巧合。这种点与它们块之间的不对齐可能是导致这里糟糕直方图结果的一个潜在原因。但是,如果我们不是将块与分箱对齐,而是将块与它们所代表的点对齐会怎样呢?如果我们这样做,块就不会对齐,但我们可以在每个 x 轴位置上添加它们的贡献来找到结果。让我们试试这个方法(见图 49-4)。
In [8]: x_d = np.linspace(-4, 8, 2000)
density = sum((abs(xi - x_d) < 0.5) for xi in x)
plt.fill_between(x_d, density, alpha=0.5)
plt.plot(x, np.full_like(x, -0.1), '|k', markeredgewidth=1)
plt.axis([-4, 8, -0.2, 8]);
图 49-4. 一个“直方图”,其中每个块都以各个个体点为中心;这是一个核密度估计的示例
结果看起来有些杂乱,但它比标准直方图更能鲜明地反映实际数据特征。尽管如此,其粗糙的边缘既不美观,也不能反映数据的任何真实特性。为了平滑它们,我们可以决定在每个位置用一个平滑函数来取代这些块,比如一个高斯函数。让我们在每个点上使用一个标准正态曲线代替一个块(见图 49-5)。
In [9]: from scipy.stats import norm
x_d = np.linspace(-4, 8, 1000)
density = sum(norm(xi).pdf(x_d) for xi in x)
plt.fill_between(x_d, density, alpha=0.5)
plt.plot(x, np.full_like(x, -0.1), '|k', markeredgewidth=1)
plt.axis([-4, 8, -0.2, 5]);
图 49-5. 一个使用高斯核的核密度估计
这个平滑处理后的图,每个输入点处贡献一个高斯分布,更准确地反映了数据分布的形状,并且具有更低的方差(即对不同采样差异的响应更小)。
在过去的两个图中,我们所得到的就是一维核密度估计:我们在每个点的位置放置一个“核”—在前者中是一个方形或者顶帽形的核,在后者中是一个高斯核,并用它们的总和作为密度的估计。有了这个直觉,我们现在将更详细地探讨核密度估计。
实践中的核密度估计
核密度估计的自由参数包括 核函数,它指定放置在每个点处的分布的形状,以及 核带宽,它控制每个点处核的大小。实际上,可以使用许多核函数进行核密度估计:特别是,Scikit-Learn 的 KDE 实现支持六种核函数,你可以在“密度估计”部分的文档中了解更多信息。
虽然 Python 中有几个实现 KDE 的版本(特别是在 SciPy 和 statsmodels
包中),但我更倾向于使用 Scikit-Learn 的版本,因为它高效且灵活。它是在 sklearn.neighbors.KernelDensity
估计器中实现的,可以使用六种核函数和几十种距离度量来处理多维 KDE。由于 KDE 可能计算量较大,Scikit-Learn 的估计器在底层使用基于树的算法,并可以通过 atol
(绝对容差)和 rtol
(相对容差)参数在计算时间和准确性之间进行权衡。核带宽可以使用 Scikit-Learn 的标准交叉验证工具来确定,这很快我们会看到。
让我们首先展示一个简单的示例,使用 Scikit-Learn 的 KernelDensity
估计器复制先前的图(参见 Figure 49-6)。
In [10]: from sklearn.neighbors import KernelDensity
# instantiate and fit the KDE model
kde = KernelDensity(bandwidth=1.0, kernel='gaussian')
kde.fit(x[:, None])
# score_samples returns the log of the probability density
logprob = kde.score_samples(x_d[:, None])
plt.fill_between(x_d, np.exp(logprob), alpha=0.5)
plt.plot(x, np.full_like(x, -0.01), '|k', markeredgewidth=1)
plt.ylim(-0.02, 0.22);
图 49-6. 使用 Scikit-Learn 计算的核密度估计
此处的结果已归一化,使得曲线下面积等于 1。
通过交叉验证选择带宽
KDE 过程产生的最终估计对带宽的选择非常敏感,带宽是控制密度估计中偏差-方差权衡的旋钮。带宽太窄会导致高方差估计(即过拟合),其中单个点的存在或缺失会产生较大差异。带宽太宽会导致高偏差估计(即欠拟合),数据结构被广核模糊化。
在统计学中,有很长的历史可以快速估计基于数据的最佳带宽,基于对数据的严格假设:例如,如果你查看 SciPy 和 statsmodels
包中的 KDE 实现,你会看到基于这些规则的实现。
在机器学习环境中,我们看到这种超参数调整通常通过经验交叉验证方法完成。考虑到这一点,Scikit-Learn 的 KernelDensity
估计器设计成可以直接在包的标准网格搜索工具中使用。在这里,我们将使用 GridSearchCV
来优化前述数据集的带宽。由于我们正在查看一个如此小的数据集,我们将使用留一法交叉验证,以最小化每个交叉验证试验的训练集大小减少:
In [11]: from sklearn.model_selection import GridSearchCV
from sklearn.model_selection import LeaveOneOut
bandwidths = 10 ** np.linspace(-1, 1, 100)
grid = GridSearchCV(KernelDensity(kernel='gaussian'),
{'bandwidth': bandwidths},
cv=LeaveOneOut())
grid.fit(x[:, None]);
现在我们可以找到使得分数最大化的带宽选择(在这种情况下,默认为对数似然):
In [12]: grid.best_params_
Out[12]: {'bandwidth': 1.1233240329780276}
最优带宽与我们在之前示例图中使用的非常接近,那里的带宽是 1.0(即scipy.stats.norm
的默认宽度)。
示例:不那么朴素的贝叶斯
此示例探讨了带 KDE 的贝叶斯生成分类,并演示了如何使用 Scikit-Learn 架构创建自定义估计器。
在第四十一章中,我们探讨了朴素贝叶斯分类,其中我们为每个类别创建了一个简单的生成模型,并使用这些模型构建了一个快速分类器。对于高斯朴素贝叶斯,生成模型是一个简单的轴对齐高斯分布。使用 KDE 等密度估计算法,我们可以去除“朴素”元素,并使用更复杂的生成模型为每个类别执行相同的分类。它仍然是贝叶斯分类,但不再是朴素的。
生成分类的一般方法如下:
-
根据标签将训练数据进行拆分。
-
对每个集合,拟合一个 KDE 以获得数据的生成模型。这允许你对于任意观测值和标签,计算出一个似然概率。
-
根据训练集中每个类别的示例数量,计算类先验。
-
对于未知点,每个类别的后验概率为。最大化这个后验概率的类别是分配给该点的标签。
算法很简单直观易懂;更难的部分是将其嵌入 Scikit-Learn 框架中,以便利用网格搜索和交叉验证架构。
这是在 Scikit-Learn 框架中实现算法的代码;我们将在代码块后面逐步分析它:
In [13]: from sklearn.base import BaseEstimator, ClassifierMixin
class KDEClassifier(BaseEstimator, ClassifierMixin):
"""Bayesian generative classification based on KDE
Parameters
----------
bandwidth : float
the kernel bandwidth within each class
kernel : str
the kernel name, passed to KernelDensity
"""
def __init__(self, bandwidth=1.0, kernel='gaussian'):
self.bandwidth = bandwidth
self.kernel = kernel
def fit(self, X, y):
self.classes_ = np.sort(np.unique(y))
training_sets = [X[y == yi] for yi in self.classes_]
self.models_ = [KernelDensity(bandwidth=self.bandwidth,
kernel=self.kernel).fit(Xi)
for Xi in training_sets]
self.logpriors_ = [np.log(Xi.shape[0] / X.shape[0])
for Xi in training_sets]
return self
def predict_proba(self, X):
logprobs = np.array([model.score_samples(X)
for model in self.models_]).T
result = np.exp(logprobs + self.logpriors_)
return result / result.sum(axis=1, keepdims=True)
def predict(self, X):
return self.classes_[np.argmax(self.predict_proba(X), 1)]
自定义估计器的解剖学。
让我们逐步分析这段代码,并讨论其关键特性:
from sklearn.base import BaseEstimator, ClassifierMixin
class KDEClassifier(BaseEstimator, ClassifierMixin):
"""Bayesian generative classification based on KDE
Parameters
----------
bandwidth : float
the kernel bandwidth within each class
kernel : str
the kernel name, passed to KernelDensity
"""
Scikit-Learn 中的每个评估器都是一个类,最方便的是这个类也应该从 BaseEstimator
类以及适当的 mixin 继承,提供标准功能。例如,在这里,BaseEstimator
包含了克隆/复制评估器以供交叉验证过程使用的必要逻辑,而 ClassifierMixin
定义了这些例程使用的默认 score
方法。我们还提供了一个文档字符串,这将被 IPython 的帮助功能捕获(参见 第一章)。
接下来是类的初始化方法:
def __init__(self, bandwidth=1.0, kernel='gaussian'):
self.bandwidth = bandwidth
self.kernel = kernel
当使用 KDEClassifier
实例化对象时,执行的实际代码是这样的。在 Scikit-Learn 中,初始化不包含任何操作,只是将传递的值按名称分配给 self
。这是由于 BaseEstimator
中包含的逻辑,用于克隆和修改评估器,以供交叉验证、网格搜索和其他功能使用。同样,所有传递给 __init__
的参数都应该是明确的:即应避免使用 *args
或 **kwargs
,因为它们在交叉验证过程中无法正确处理。
下面是 fit
方法,我们在这里处理训练数据:
def fit(self, X, y):
self.classes_ = np.sort(np.unique(y))
training_sets = [X[y == yi] for yi in self.classes_]
self.models_ = [KernelDensity(bandwidth=self.bandwidth,
kernel=self.kernel).fit(Xi)
for Xi in training_sets]
self.logpriors_ = [np.log(Xi.shape[0] / X.shape[0])
for Xi in training_sets]
return self
在这里,我们找到训练数据中的唯一类别,为每个类别训练一个 KernelDensity
模型,并基于输入样本的数量计算类别先验概率。最后,fit
应该始终返回 self
,以便我们可以链接命令。例如:
label = model.fit(X, y).predict(X)
注意,每次 fit
持久结果都带有下划线结尾(例如 self.logpriors_
)。这是 Scikit-Learn 中的一种约定,因此您可以快速扫描评估器的成员(使用 IPython 的 tab 自动完成),并查看哪些成员是适合训练数据的。
最后,我们有预测新数据标签的逻辑:
def predict_proba(self, X):
logprobs = np.vstack([model.score_samples(X)
for model in self.models_]).T
result = np.exp(logprobs + self.logpriors_)
return result / result.sum(axis=1, keepdims=True)
def predict(self, X):
return self.classes_[np.argmax(self.predict_proba(X), 1)]
因为这是一个概率分类器,我们首先实现 predict_proba
,它返回一个形状为 [n_samples, n_classes]
的类别概率数组。数组中的条目 [i, j]
是计算得到的样本 i
是类别 j
的后验概率,通过将似然性乘以类先验并进行归一化计算得到。
predict
方法使用这些概率,简单地返回具有最大概率的类别。
使用我们的自定义评估器
让我们尝试将这个自定义评估器应用于我们之前见过的问题:手写数字的分类。在这里,我们将加载数字并使用 GridSearchCV
元评估器计算一系列候选带宽的交叉验证分数(参考 第三十九章):
In [14]: from sklearn.datasets import load_digits
from sklearn.model_selection import GridSearchCV
digits = load_digits()
grid = GridSearchCV(KDEClassifier(),
{'bandwidth': np.logspace(0, 2, 100)})
grid.fit(digits.data, digits.target);
接下来,我们可以绘制交叉验证分数作为带宽的函数(参见 图 49-7)。
In [15]: fig, ax = plt.subplots()
ax.semilogx(np.array(grid.cv_results_['param_bandwidth']),
grid.cv_results_['mean_test_score'])
ax.set(title='KDE Model Performance', ylim=(0, 1),
xlabel='bandwidth', ylabel='accuracy')
print(f'best param: {grid.best_params_}')
print(f'accuracy = {grid.best_score_}')
Out[15]: best param: {'bandwidth': 6.135907273413174}
accuracy = 0.9677298050139276
图 49-7. 基于 KDE 的贝叶斯分类器的验证曲线
这表明我们的 KDE 分类器达到了超过 96%的交叉验证准确率,而朴素贝叶斯分类器的准确率约为 80%。
In [16]: from sklearn.naive_bayes import GaussianNB
from sklearn.model_selection import cross_val_score
cross_val_score(GaussianNB(), digits.data, digits.target).mean()
Out[16]: 0.8069281956050759
这样一个生成分类器的一个好处是结果的可解释性:对于每个未知样本,我们不仅获得一个概率分类,而且获得了与其比较的点分布的完整模型!如果需要,这为特定分类的原因提供了一个直观的窗口,而像 SVM 和随机森林这样的算法往往会掩盖这些原因。
如果您希望进一步进行,这里有一些可以改进我们的 KDE 分类器模型的想法:
-
您可以允许每个类别中的带宽独立变化。
-
不应该基于它们的预测分数来优化这些带宽,而是应该基于每个类别中生成模型下训练数据的可能性(即使用
KernelDensity
本身的分数而不是全局预测准确度)。
最后,如果你想要一些练习来构建自己的估计器,可以尝试使用高斯混合模型而不是 KDE 来构建类似的贝叶斯分类器。
第五十章:应用:一个人脸检测流水线
本书的这一部分探讨了许多机器学习的中心概念和算法。但是从这些概念到真实世界的应用可能是一个挑战。真实世界的数据集通常是嘈杂和异构的;它们可能具有缺失的特征,并且数据可能以难以映射到干净的[n_samples, n_features]
矩阵的形式存在。在应用这里讨论的任何方法之前,您必须首先从您的数据中提取这些特征:没有适用于所有领域的公式,因此这是您作为数据科学家必须运用自己的直觉和专业知识的地方。
机器学习的一个有趣而引人注目的应用是图像,我们已经看到了一些例子,其中像素级特征用于分类。再次强调,现实世界的数据很少是如此统一的,简单的像素将不合适:这导致了大量关于图像数据特征提取方法的文献(参见第四十章)。
在本章中,我们将介绍一种特征提取技术:方向梯度直方图(HOG),它将图像像素转换为对广泛信息的敏感向量表示,而不受照明等混淆因素的影响。我们将使用这些特征来开发一个简单的人脸检测流水线,利用本书这部分中已经介绍过的机器学习算法和概念。
我们从标准导入开始:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
import numpy as np
HOG 特征
HOG 是一个简单直接的特征提取过程,最初用于图像中行人的识别。它包括以下步骤:
-
可选择地对图像进行预归一化。这导致特征对照明变化的依赖性较小。
-
将图像与两个对水平和垂直亮度梯度敏感的滤波器卷积。这些捕捉边缘、轮廓和纹理信息。
-
将图像细分为预定大小的单元格,并计算每个单元格内梯度方向的直方图。
-
通过与相邻单元格块比较来归一化每个单元格中的直方图。这进一步抑制了整个图像中照明效果的影响。
-
从每个单元格中的信息构建一个一维特征向量。
Scikit-Image 项目中内置了一个快速的 HOG 特征提取器,我们可以相对快速地尝试并可视化每个单元格内的定向梯度(见图 50-1)。
In [2]: from skimage import data, color, feature
import skimage.data
image = color.rgb2gray(data.chelsea())
hog_vec, hog_vis = feature.hog(image, visualize=True)
fig, ax = plt.subplots(1, 2, figsize=(12, 6),
subplot_kw=dict(xticks=[], yticks=[]))
ax[0].imshow(image, cmap='gray')
ax[0].set_title('input image')
ax[1].imshow(hog_vis)
ax[1].set_title('visualization of HOG features');
图 50-1。从图像计算的 HOG 特征的可视化
HOG 在行动:一个简单的人脸检测器
利用这些 HOG 特征,我们可以使用任何 Scikit-Learn 评估器构建一个简单的面部检测算法;在这里,我们将使用线性支持向量机(如果需要恢复记忆,请参阅 第四十三章)。具体步骤如下:
-
获得一组人脸缩略图,作为“正”训练样本。
-
获得一组非面部图像缩略图,作为“负”训练样本。
-
从这些训练样本中提取 HOG 特征。
-
在这些样本上训练线性 SVM 分类器。
-
为“未知”图像,通过图像上的滑动窗口,使用模型评估该窗口是否包含面部。
-
如果检测重叠,将它们合并成一个单一窗口。
让我们按照这些步骤进行并尝试一下。
1. 获得一组正训练样本
我们将从中找出一些显示各种面部的正训练样本。我们有一个易于使用的数据集——Wild 中的带标签面部数据集,可以通过 Scikit-Learn 下载:
In [3]: from sklearn.datasets import fetch_lfw_people
faces = fetch_lfw_people()
positive_patches = faces.images
positive_patches.shape
Out[3]: (13233, 62, 47)
这为我们提供了一些用于训练的 13,000 张面部图像样本。
2. 获得一组负训练样本
接下来,我们需要一组大小相似的缩略图,其中没有面部。获得这些的一种方法是从任何输入图像语料库中提取它们的缩略图,以多种比例。在这里,我们将使用一些随着 Scikit-Image 提供的图像以及 Scikit-Learn 的 PatchExtractor
:
In [4]: data.camera().shape
Out[4]: (512, 512)
In [5]: from skimage import data, transform
imgs_to_use = ['camera', 'text', 'coins', 'moon',
'page', 'clock', 'immunohistochemistry',
'chelsea', 'coffee', 'hubble_deep_field']
raw_images = (getattr(data, name)() for name in imgs_to_use)
images = [color.rgb2gray(image) if image.ndim == 3 else image
for image in raw_images]
In [6]: from sklearn.feature_extraction.image import PatchExtractor
def extract_patches(img, N, scale=1.0, patch_size=positive_patches[0].shape):
extracted_patch_size = tuple((scale * np.array(patch_size)).astype(int))
extractor = PatchExtractor(patch_size=extracted_patch_size,
max_patches=N, random_state=0)
patches = extractor.transform(img[np.newaxis])
if scale != 1:
patches = np.array([transform.resize(patch, patch_size)
for patch in patches])
return patches
negative_patches = np.vstack([extract_patches(im, 1000, scale)
for im in images for scale in [0.5, 1.0, 2.0]])
negative_patches.shape
Out[6]: (30000, 62, 47)
现在我们有 30,000 个合适的图像补丁,不包含面部。让我们可视化其中的一些,以了解它们的外观(见 图 50-2)。
In [7]: fig, ax = plt.subplots(6, 10)
for i, axi in enumerate(ax.flat):
axi.imshow(negative_patches[500 * i], cmap='gray')
axi.axis('off')
我们希望这些样本足以覆盖算法可能见到的“非面部”空间。
图 50-2. 不包含面部的负图像补丁
3. 合并集合并提取 HOG 特征
现在我们有了这些正样本和负样本,我们可以将它们合并并计算 HOG 特征。这一步骤需要一些时间,因为它涉及到每个图像的非平凡计算:
In [8]: from itertools import chain
X_train = np.array([feature.hog(im)
for im in chain(positive_patches,
negative_patches)])
y_train = np.zeros(X_train.shape[0])
y_train[:positive_patches.shape[0]] = 1
In [9]: X_train.shape
Out[9]: (43233, 1215)
我们剩下 43,000 个训练样本,具有 1,215 个维度,现在我们已经将数据处理成了可以输入到 Scikit-Learn 的形式!
4. 训练支持向量机
接下来,我们将利用这里探索的工具创建缩略图补丁的分类器。对于这种高维二元分类任务,线性支持向量机是一个很好的选择。我们将使用 Scikit-Learn 的 LinearSVC
,因为与 SVC
相比,它通常对大量样本具有更好的扩展性。
不过首先,让我们使用简单的高斯朴素贝叶斯估算器得到一个快速的基准:
In [10]: from sklearn.naive_bayes import GaussianNB
from sklearn.model_selection import cross_val_score
cross_val_score(GaussianNB(), X_train, y_train)
Out[10]: array([0.94795883, 0.97143518, 0.97224471, 0.97501735, 0.97374508])
我们看到在我们的训练数据上,即使是简单的朴素贝叶斯算法也可以达到 95% 以上的准确率。让我们尝试支持向量机,并对几种 C
参数进行网格搜索:
In [11]: from sklearn.svm import LinearSVC
from sklearn.model_selection import GridSearchCV
grid = GridSearchCV(LinearSVC(), {'C': [1.0, 2.0, 4.0, 8.0]})
grid.fit(X_train, y_train)
grid.best_score_
Out[11]: 0.9885272620319941
In [12]: grid.best_params_
Out[12]: {'C': 1.0}
这将使我们的准确率提高到接近 99%。让我们选择最佳的估计器,并在完整数据集上重新训练它:
In [13]: model = grid.best_estimator_
model.fit(X_train, y_train)
Out[13]: LinearSVC()
5. 在新图像中查找面部
现在我们已经建立了这个模型,让我们拿一幅新的图像来看看模型的表现。我们将简单地使用宇航员图像中的一部分,如 图 50-3 所示,运行一个滑动窗口,并评估每个补丁:
In [14]: test_image = skimage.data.astronaut()
test_image = skimage.color.rgb2gray(test_image)
test_image = skimage.transform.rescale(test_image, 0.5)
test_image = test_image[:160, 40:180]
plt.imshow(test_image, cmap='gray')
plt.axis('off');
图 50-3. 一幅我们将尝试定位面部的图像。
接下来,让我们创建一个窗口,迭代这幅图像的补丁,并为每个补丁计算 HOG 特征:
In [15]: def sliding_window(img, patch_size=positive_patches[0].shape,
istep=2, jstep=2, scale=1.0):
Ni, Nj = (int(scale * s) for s in patch_size)
for i in range(0, img.shape[0] - Ni, istep):
for j in range(0, img.shape[1] - Ni, jstep):
patch = img[i:i + Ni, j:j + Nj]
if scale != 1:
patch = transform.resize(patch, patch_size)
yield (i, j), patch
indices, patches = zip(*sliding_window(test_image))
patches_hog = np.array([feature.hog(patch) for patch in patches])
patches_hog.shape
Out[15]: (1911, 1215)
最后,我们可以取这些具有 HOG 特征的补丁,并使用我们的模型评估每个补丁是否包含面部:
In [16]: labels = model.predict(patches_hog)
labels.sum()
Out[16]: 48.0
我们看到在将近 2,000 个补丁中,我们发现了 48 个检测结果。让我们利用这些补丁的信息来显示它们在我们的测试图像中的位置,将它们绘制成矩形(参见 图 50-4)。
In [17]: fig, ax = plt.subplots()
ax.imshow(test_image, cmap='gray')
ax.axis('off')
Ni, Nj = positive_patches[0].shape
indices = np.array(indices)
for i, j in indices[labels == 1]:
ax.add_patch(plt.Rectangle((j, i), Nj, Ni, edgecolor='red',
alpha=0.3, lw=2, facecolor='none'))
图 50-4. 被确定包含面部的窗口。
所有检测到的补丁都重叠并找到了图像中的面部!对于几行 Python 代码来说效果不错。
注意事项和改进
如果你深入研究前面的代码和示例,你会发现在我们宣称拥有一个可以投入生产的人脸检测器之前,我们还有一些工作要做。我们做的工作存在几个问题,也可以做出几个改进。特别是:
我们的训练集,特别是负面特征,不太完整。
中心问题在于训练集中有许多像面部的纹理,而我们当前的模型非常容易产生假阳性。如果你尝试在完整的宇航员图像上运行算法,就会发现这一点:当前的模型在图像的其他区域导致了许多误检测。
我们可以通过向负训练集添加更多类型的图像来解决这个问题,这可能会带来一些改善。另一种选择是使用更有针对性的方法,如硬负样本挖掘,我们采用一组分类器尚未见过的新图像,找到所有表示假阳性的补丁,并明确将它们作为负实例添加到训练集中,然后重新训练分类器。
我们当前的流水线仅在一个尺度上进行搜索。
按照当前的写法,我们的算法会漏掉不是大约 62 × 47 像素的面部。可以通过使用各种大小的滑动窗口,并在将每个补丁输入模型之前使用skimage.transform.resize
来直接解决这个问题。事实上,这里使用的sliding_window
工具已经考虑到了这一点。
我们应该结合重叠的检测补丁。
对于一个可投入生产的流水线,我们更希望不要有 30 个相同人脸的检测结果,而是将重叠的检测组减少到一个单独的检测结果。这可以通过无监督的聚类方法(均值漂移聚类是一个很好的选择),或者通过类似于非最大抑制这样的程序化方法来实现,这是机器视觉中常见的一种算法。
流水线应该被简化
一旦解决了上述问题,创建一个更简化的流水线来输入训练图像并预测滑动窗口输出也将是一个不错的选择。这就是 Python 作为数据科学工具的优势所在:通过一点工作,我们可以将我们的原型代码打包成一个设计良好的面向对象的 API,让用户能够轻松地使用它。我将把这留给读者作为一个“练习题”。
更近期的进展:深度学习
最后,我应该补充说,在机器学习环境中,HOG 和其他程序化特征提取方法并不总是被使用。相反,许多现代目标检测流水线使用深度神经网络的变体(通常称为深度学习):一个思考神经网络的方式是将其视为从数据中确定最佳特征提取策略的估计器,而不是依赖用户的直觉。
虽然近年来该领域取得了巨大的成果,但深度学习在概念上与前几章中探讨的机器学习模型并没有太大的不同。主要进步在于利用现代计算硬件(通常是大型强大机器集群)在更大的训练数据集上训练更加灵活的模型。但尽管规模不同,最终目标仍然是非常相似的:从数据中构建模型。
如果你对更深入的了解感兴趣,以下部分的参考文献清单应该是一个很好的起点!
更多机器学习资源
本书的这一部分快速介绍了 Python 中的机器学习,主要使用了 Scikit-Learn 库中的工具。尽管这些章节很长,但仍然太短,无法涵盖许多有趣和重要的算法、方法和讨论。在这里,我想为那些有兴趣的人提供一些关于在 Python 中学习更多关于机器学习的资源:
Scikit-Learn 网站拥有令人印象深刻的文档和示例,涵盖了这里讨论的一些模型,以及更多内容。如果你想要简要了解最重要和经常使用的机器学习算法,这是一个很好的开始。
SciPy、PyCon 和 PyData 教程视频
Scikit-Learn和其他机器学习主题是许多以 Python 为重点的会议系列(特别是 PyCon、SciPy 和 PyData 会议)教程轨道中的常青之选。这些会议大多免费在线发布其主题演讲、讨论和教程的视频,你可以通过合适的网络搜索轻松找到(例如,“PyCon 2022 视频”)。
《Python 机器学习导论》,作者 Andreas C. Müller 和 Sarah Guido(O’Reilly)
本书涵盖了这些章节讨论的许多机器学习基础知识,但特别是在涵盖 Scikit-Learn 更高级特性方面更具相关性,包括额外的估算器、模型验证方法和管道化。
《使用 PyTorch 和 Scikit-Learn 的机器学习》,作者 Sebastian Raschka(Packt)
Sebastian Raschka 的最新书籍从这些章节覆盖的基础主题开始,但深入探讨,并展示了这些概念如何应用于更复杂和计算密集的深度学习和强化学习模型,使用知名的PyTorch 库。