Python-数据科学手册第二版-全-

Python 数据科学手册第二版(全)

原文:zh.annas-archive.org/md5/051facaf2908ae8198253e3a14b09ec1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

什么是数据科学?

这是一本关于使用 Python 进行数据科学的书籍,这立即引发了一个问题:什么是数据科学?这是一个令人惊讶地难以定义的问题,特别是考虑到这个术语已经如此普遍。激烈的批评者们曾将其视为一个多余的标签(毕竟,哪门科学不涉及数据?)或者一个简单的流行语,只存在于简历中或吸引过度热衷的技术招聘者的眼球。

在我看来,这些批评忽略了一些重要的东西。尽管数据科学带有被夸大的光环,但它或许是我们在工业和学术界越来越重要的许多应用中拥有的跨学科技能集合的最佳标签。跨学科的部分至关重要:在我看来,关于数据科学最好的现有定义可以通过德鲁·康威于 2010 年 9 月在他的博客上首次发布的数据科学维恩图来说明(图 P-1)。

虽然一些交集标签有些玩味,但这张图表捕捉到了人们说“数据科学”时我认为的本质:它本质上是一门跨学科的学科。数据科学包括三个明确且有重叠的领域:统计学家的技能,他们知道如何对数据集进行建模和总结(这些数据集越来越大);计算机科学家的技能,能够设计和使用算法来高效存储、处理和可视化这些数据;以及领域专业知识,我们可以称之为在一个学科中的“经典”训练,既需要制定正确的问题,又需要将其答案放入背景中。

在这种情况下,我鼓励您把数据科学看作不是要学习的新知识领域,而是可以应用于您当前专业领域的一组新技能。无论您是在报告选举结果、预测股票回报、优化在线广告点击、识别显微镜照片中的微生物、寻找新的天文物体类别,还是在任何其他领域处理数据,本书的目标是赋予您提出和回答有关所选主题领域的新问题的能力。

数据科学 VD

图 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 实际上创建了一些名为 InOut 的 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) ** 2cos(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

请注意,所有这些只是通过键入命令而不是点击图标和菜单来执行熟悉操作(导航目录结构、创建目录、移动文件等)的一种紧凑方式。仅仅几个命令(pwdlscdmkdircp)就可以完成许多最常见的文件操作,但当您超越这些基础操作时,shell 方法真正显示其强大之处。

IPython 中的 Shell 命令

任何标准的 shell 命令都可以通过在其前面加上!字符直接在 IPython 中使用。例如,lspwdecho命令可以如下运行:

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 列表,但具有额外的功能,比如grepfields方法以及允许您以方便的方式搜索、过滤和显示结果的snp属性。有关这些信息,您可以使用 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 接受一个参数,即模式,有三种可能性:PlainContextVerbose。默认是 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 模式?随着代码变得复杂,这种回溯可能会变得非常长。根据情况,有时 PlainContext 模式的简洁性更容易处理。

调试:当仅仅阅读跟踪不足以解决问题时

用于交互式调试的标准 Python 工具是 pdb,即 Python 调试器。此调试器允许用户逐行步进代码,以查看可能导致更复杂错误的原因。其 IPython 增强版本是 ipdb,即 IPython 调试器。

启动和使用这两个调试器有许多方法;我们在此处不会全面涵盖它们。请参考这两个实用工具的在线文档以了解更多信息。

在 IPython 中,也许最方便的调试接口是 %debug 魔术命令。如果在异常发生后调用它,它将自动在异常点打开一个交互式调试提示符。ipdb 提示符允许您查看堆栈的当前状态,探索可用变量,甚至运行 Python 命令!

让我们查看最近的异常,然后执行一些基本任务。我们将打印 ab 的值,然后输入 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_profilermemory_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 网站

IPython 网站提供了文档、示例、教程以及各种其他资源的链接。

nbviewer 网站

此网站显示任何可在互联网上找到的 Jupyter 笔记本的静态渲染。首页展示了一些示例笔记本,您可以浏览看看其他人是如何使用 IPython 的!

Jupyter 笔记本的精选集

由 nbviewer 提供支持的这个不断增长的笔记本列表展示了您可以用 IPython 进行的数值分析的深度和广度。它涵盖了从简短的示例和教程到完整的课程和笔记本格式的书籍!

视频教程

在互联网上搜索,您会发现许多关于 IPython 的视频教程。我特别推荐从 PyCon、SciPy 和 PyData 大会上获取教程,由 IPython 和 Jupyter 的两位主要创作者和维护者 Fernando Perez 和 Brian Granger 提供。

书籍

Python 数据分析(O’Reilly)

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 = 10000x不只是一个“原始”整数。它实际上是一个指向复合 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 所示。

cint vs pyint

图 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 中有所说明。

array vs list

图 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 相同;通常是 int64int32
intc 与 C 语言 int 相同(通常是 int32int64
intp 用于索引的整数(与 C 语言的 ssize_t 相同;通常是 int32int64
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=0stop=<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值为负时。在这种情况下,startstop的默认值会被交换。这成为反转数组的便捷方式:

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]]

让我们从中提取一个2 × 2子数组:

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 放在一个3 × 3网格中,你可以这样做:

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.concatenatenp.vstacknp.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.splitnp.hsplitnp.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.hsplitnp.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.lognp.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.sumnp.prodnp.cumsumnp.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.atufunc.reduceat方法同样也是非常有用的,我们将在第十章中探讨它们。

我们还将遇到 ufunc 能够在不同形状和大小的数组之间执行操作的能力,这一组操作被称为广播。这个主题非常重要,我们将专门为其设立一整章(参见第八章)。

Ufuncs:了解更多

更多有关通用函数的信息(包括可用函数的完整列表)可以在NumPySciPy文档网站上找到。

请回忆,您还可以通过在 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 内置了minmax函数,用于找到任意给定数组的最小值和最大值:

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)

对于minmaxsum以及其他几个 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');

output 39 0

图 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]])

就像之前我们将一个值拉伸或广播到另一个形状相匹配的数组一样,这里我们拉伸了ab,使它们匹配一个公共形状,结果是一个二维数组!这些示例的几何形状在图 8-1 中可视化。

浅色框表示广播的值。这种关于广播的思考方式可能会引发关于其内存使用效率的疑问,但不用担心:NumPy 广播实际上不会在内存中复制广播的值。尽管如此,这种思维模型在我们思考广播时仍然很有用。

02.05 broadcasting

图 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 的形状来使 aM 兼容。但这不是广播规则的工作方式!这种灵活性在某些情况下可能很有用,但它会导致潜在的歧义。如果你想要右侧填充,你可以通过显式地重新塑造数组来实现(我们将在第五章介绍 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 章),我们将其存储在一个10 × 3数组中:

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])

机器精度内,均值现在为零。

绘制二维函数

广播经常派上用场的一个地方是基于二维函数显示图像。如果我们想定义一个函数z = f ( x , y ),可以使用广播来计算整个网格上的函数:

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();

output 52 0

图 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);

output 6 0

图 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 被解释为 0True 被解释为 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.anynp.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.allnp.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 中有内置的 sumanyall 函数。它们与 NumPy 版本的语法不同,特别是在多维数组上使用时可能会失败或产生意外的结果。确保在这些示例中使用 np.sumnp.anynp.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 与操作符 &/|

一个常见的混淆点是关键词 andor 与操作符 &| 之间的区别。什么情况下会使用其中一个而不是另一个?

区别在于:andor 在整个对象上操作,而 &| 在对象内的元素上操作。

当你使用andor时,相当于要求 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 = True0 = 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()

类似地,当对给定数组评估布尔表达式时,应该使用|&而不是orand

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()

因此,请记住:andor对整个对象执行单个布尔评估,而&|对对象的内容(各个位或字节)执行多个布尔评估。对于布尔 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 索引的一个常见用途是从矩阵中选择行的子集。例如,我们可能有一个表示N × D维度的矩阵,如从二维正态分布中抽取的以下点:

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]);

output 31 0

图 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);

output 36 0

图 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');

output 52 0

图 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 个点集开始。按照标准约定,我们将这些点排列在一个10 × 2数组中:

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);

output 30 0

图 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:这是因为每个点的最近邻居是它自己,这是我们预期的结果。

在这里使用完全排序,实际上做了比需要的更多的工作。如果我们只是对最近的k个邻居感兴趣,我们只需对每一行进行分区,使得最小的k + 1个平方距离首先出现,剩余的距离填充数组的其余位置。我们可以使用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')

output 44 0

图 11-2. 每个点的最近邻居的可视化

每个图中的点都有线连接到其两个最近的邻居。乍一看,一些点有超过两条线连接可能会显得奇怪:这是因为如果点 A 是点 B 的两个最近邻之一,这并不一定意味着点 B 是点 A 的两个最近邻之一。

尽管这种方法的广播和行排序可能比编写循环不那么直观,但事实证明这是一种非常高效的在 Python 中处理这些数据的方法。您可能会尝试通过手动循环遍历数据并逐个排序每组邻居来执行相同类型的操作,但这几乎肯定会导致比我们使用的向量化版本更慢的算法。这种方法的美妙之处在于它以一种对输入数据大小不可知的方式编写:我们可以轻松地在任意维度中计算 100 个或 1,000,000 个点之间的邻居,代码看起来都一样。

最后,我要注意的是,在进行非常大的最近邻搜索时,有基于树的和/或近似算法可以扩展为𝒪 [ N log N ]或更好,而不是粗暴算法的𝒪 [ N 2 ]。这种算法的一个例子是 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 组件由一个 3 × 3 浮点矩阵组成:

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 和一个 3 × 3 矩阵。为什么您会使用这个而不是简单的多维数组,或者可能是 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,特别是其 SeriesDataFrame 对象,基于 NumPy 数组结构,并提供了有效的访问这些“数据清洗”任务的方法,这些任务占据了数据科学家大部分时间。

在本书的这一部分中,我们将重点介绍如何有效地使用 SeriesDataFrame 和相关结构的机制。我们将在适当的情况下使用从真实数据集中提取的示例,但这些示例并不一定是重点。

注意

在您的系统上安装 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 数据结构:SeriesDataFrameIndex

我们将从标准的 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 将一系列值与显式的索引序列结合在一起,我们可以使用 valuesindex 属性来访问。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] 将返回第一行。对于 DataFramedata['col0'] 将返回第一列。因此,最好将 DataFrame 视为广义的字典,而不是广义的数组,尽管两种视角都是有用的。我们将在 第十四章 探讨更灵活的 DataFrame 索引方式。

构造 DataFrame 对象

Pandas DataFrame 可以以多种方式构建。这里我们将探讨几个例子。

从单个 Series 对象

DataFrameSeries 对象的集合,一个单列的 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 对象

正如你所见,SeriesDataFrame 对象都包含了一个明确的索引,让你可以引用和修改数据。这个 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 SeriesDataFrame对象中的值。如果你使用过 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 代码的一个指导原则是“明确优于隐式”。lociloc的显式特性使它们在保持代码清晰和可读性方面非常有帮助;特别是在整数索引的情况下,始终一致地使用它们可以防止由于混合索引/切片约定而导致的微妙错误。

数据框选择

回想一下,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 再次使用了前面提到的lociloc索引器。使用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 的 SeriesDataFrame 对象上使用。让我们先定义一个简单的 SeriesDataFrame 来演示这一点:

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:索引对齐

对于两个 SeriesDataFrame 对象的二元操作,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,但允许可选地显式指定AB中可能缺失元素的填充值:

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 之间的操作

当对DataFrameSeries进行操作时,索引和列的对齐方式类似地保持,并且结果类似于二维数组和一维 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 有两种存储和操作空值的“模式”:

  • 默认模式是使用基于哨兵值的缺失数据方案,哨兵值为NaNNone,具体取决于数据类型。

  • 或者,您可以选择使用 Pandas 提供的可空数据类型(dtypes)(稍后在本章讨论),这将导致创建一个伴随的掩码数组来跟踪缺失的条目。然后,这些缺失的条目将被呈现给用户作为特殊的pd.NA值。

无论哪种情况,Pandas API 提供的数据操作和操作将以可预测的方式处理和传播这些缺失的条目。但是为了对为什么会做出这些选择有一些直觉,让我们快速探讨一下NoneNaNNA中固有的权衡。像往常一样,我们将从导入 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的算术运算,像summin这样的聚合通常会导致错误:

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

NaNNone都有它们的用途,而且 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 无变化 Nonenp.nan
integer 转换为float64 np.nan
boolean 转换为object Nonenp.nan

请记住,在 Pandas 中,字符串数据始终以object类型存储。

Pandas 可空数据类型

在早期版本的 Pandas 中,NaNNone作为哨兵值是唯一可用的缺失数据表示。这引入的主要困难是隐式类型转换:例如,无法表示真正的整数数组带有缺失数据。

为了解决这个问题,Pandas 后来添加了可空数据类型,它们通过名称的大写区分于常规数据类型(例如,pd.Int32np.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 将 NoneNaNNA 视为基本可以互换,用于指示缺失或空值。为了促进这一约定,Pandas 提供了几种方法来检测、删除和替换 Pandas 数据结构中的空值。它们包括:

isnull

生成一个指示缺失值的布尔掩码

notnull

isnull 的反操作

dropna

返回数据的过滤版本

fillna

返回填充或插补了缺失值的数据副本

我们将以对这些程序的简要探索和演示来结束本章。

检测空值

Pandas 数据结构有两个有用的方法来检测空数据:isnullnotnull。任何一个都将返回数据的布尔掩码。例如:

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

正如在 第十四章 中提到的那样,布尔掩码可以直接用作 SeriesDataFrame 的索引:

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=1axis='columns' 将删除包含空值的所有列:

In [21]: df.dropna(axis='columns')
Out[21]:    2
         0  2
         1  5
         2  6

但是这样会丢掉一些好数据;您可能更感兴趣的是删除具有所有 NA 值或大多数 NA 值的行或列。这可以通过 howthresh 参数进行指定,这些参数允许对允许通过的空值数量进行精细控制。

默认值为 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 SeriesDataFrame 对象中的一维和二维数据。通常,超出这些维度存储更高维度的数据是有用的——也就是说,数据由超过一个或两个键索引。早期的 Pandas 版本提供了 PanelPanel4D 对象,可以视为二维 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中的二维数据一样,我们也可以用它来操作SeriesDataFrame中的三维或更高维度的数据。多重索引中的每个额外级别代表了数据的一个额外维度;利用这个特性使我们在能够表示的数据类型上有了更大的灵活性。具体来说,我们可能希望为每个州在每年的人口(例如 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 创建方法

构建一个多重索引的SeriesDataFrame最直接的方法是简单地将两个或更多索引数组列表传递给构造函数。例如:

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)],
                    )

在创建SeriesDataFrame时,可以将任何这些对象作为index参数传递,或者将其传递给现有SeriesDataFramereindex方法。

多重索引级别名称

有时候给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

正如单索引情况一样,我们还可以使用在第十四章介绍的locilocix索引器。例如:

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

这些索引器提供了底层二维数据的类似数组的视图,但每个lociloc中的单个索引可以传递多个索引的元组。例如:

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

如您所见,在多重索引的SeriesDataFrame中与数据交互的方式有很多,并且与本书中的许多工具一样,熟悉它们的最佳方法是尝试它们!

重新排列多重索引

处理多重索引数据的关键之一是知道如何有效地转换数据。有许多操作将保留数据集中的所有信息,但为各种计算目的重新排列数据。我们在stackunstack方法中看到了一个简短的示例,但在控制数据在层次索引和列之间重新排列方面,还有许多其他方法,我们将在这里探讨它们。

排序和未排序的索引

我之前简要提到过一个警告,但我应该在这里更加强调。如果索引未排序,则许多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 提供了许多方便的例程来执行这种类型的排序,例如DataFramesort_indexsortlevel方法。我们在这里将使用最简单的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,其中包含stateyear列,这些列保存了以前在索引中的信息。为了清晰起见,我们可以选择指定数据的名称作为列的表示方式:

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。这可以通过DataFrameset_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

一些最有趣的数据研究来自于结合不同的数据源。这些操作可以涉及从两个不同数据集的非常简单的连接到更复杂的数据库风格的联接和合并,正确处理数据集之间的任何重叠。SeriesDataFrame是专为这类操作而构建的,Pandas 包含使这种数据处理快速和简单的函数和方法。

在这里,我们将使用pd.concat函数查看SeriesDataFrame的简单连接;稍后我们将深入探讨 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 数组的连接

SeriesDataFrame对象的连接行为与 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可用于简单连接SeriesDataFrame对象,就像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.concatenatepd.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 方法

因为直接数组连接是如此常见,SeriesDataFrame 对象具有一个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 列表的 appendextend 方法不同,Pandas 中的 append 方法不会修改原始对象;相反,它会创建一个包含组合数据的新对象。它也不是一种非常有效的方法,因为它涉及到新索引的创建 以及 数据缓冲区。因此,如果你计划进行多个 append 操作,通常最好建立一个 DataFrame 对象的列表,并一次性将它们全部传递给 concat 函数。

在下一章中,我们将介绍一种更强大的方法来组合来自多个来源的数据:pd.merge 中实现的数据库风格的合并/连接。有关 concatappend 和相关功能的更多信息,请参阅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函数和SeriesDataFrame对象的相关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,它合并了两个输入的信息。请注意,每列中的条目顺序不一定保持一致:在这种情况下,df1df2中的employee列顺序不同,pd.merge函数能够正确处理这一点。此外,请记住,一般情况下合并会丢弃索引,除非是通过索引进行合并(参见left_indexright_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_onright_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_indexright_onleft_onright_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中得到完整的州名。我们要基于popstate/region列和abbrevsabbreviation列进行合并。我们将使用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

我们看到迄今为止最稀疏的州是阿拉斯加,平均每平方英里略高于一名居民。

当尝试使用真实数据源回答问题时,这种数据合并是一项常见任务。希望这个例子给您提供了一些想法,展示了如何结合我们涵盖的工具来从数据中获取洞察!

第二十章:聚合和分组

许多数据分析任务的基本组成部分是高效的汇总:计算summeanmedianminmax等聚合,其中单个数字总结了可能有很多数据集的各个方面。在本章中,我们将探索 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 项目总数
firstlast 第一个和最后一个项目
meanmedian 平均值和中位数
minmax 最小和最大
stdvar 标准差和方差
mad 平均绝对偏差
prod 所有项目的乘积
sum 所有项目的和

这些都是DataFrameSeries对象的方法。

然而,要深入了解数据,简单的聚合通常是不够的。数据汇总的下一级是groupby操作,它允许您快速高效地在数据子集上计算聚合。

groupby:分割、应用、组合

简单的聚合可以让你了解数据集的特征,但通常我们更希望在某些标签或索引上进行条件聚合:这在所谓的groupby操作中实现。这个名字“group by”来自 SQL 数据库语言中的一个命令,但也许更具启发性的是,我们可以根据 Rstats 名人哈德利·维克姆首次提出的术语来思考它:分割、应用、组合

分割、应用、组合

这个分割-应用-组合操作的典型示例,其中“应用”是一个求和聚合,如图 20-1 所示。

图 20-1 展示了groupby操作的完成情况:

  • 分割步骤涉及根据指定键的值拆分和分组DataFrame

  • 应用步骤涉及在各个组内计算某个函数,通常是一个聚合、转换或筛选。

  • 合并步骤将这些操作的结果合并到输出数组中。

03.08 split apply combine

图 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

最基本的分割-应用-组合操作可以使用DataFramegroupby方法来计算,传递所需键列的名称:

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提供的可能是最重要的操作是aggregatefiltertransformapply。我们将在下一节更详细地讨论每一个,但在此之前,让我们看一些可以与基本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对象支持直接在组上进行迭代,返回每个组作为SeriesDataFrame

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对象具有aggregatefiltertransformapply方法,可以在组合并分组数据之前有效地实现多种有用的操作。

出于以下各小节的目的,我们将使用这个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

聚合

现在你已经熟悉了使用summedian等方法的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 对象(例如DataFrameSeries)或一个标量;合并步骤的行为将根据返回的输出类型进行调整。

例如,这里是一个通过第一列的总和来归一化的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_valuedropna与缺失数据有关,非常直观;我不会在这里展示它们的示例。

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');

output 33 0

图 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

output 44 0

图 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);

output 50 0

图 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 SeriesIndex对象的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

能够简洁地应用正则表达式于 SeriesDataFrame 条目之上,为数据的分析和清理开辟了许多可能性。

杂项方法

最后,Table 22-2 列出了使其他便捷操作得以实现的杂项方法。

Table 22-2. 其他 Pandas 字符串方法

方法 描述
get 对每个元素进行索引
slice 对每个元素进行切片
slice_replace 用传递的值替换每个元素中的片段
cat 连接字符串
repeat 重复值
normalize 返回字符串的 Unicode 形式
pad 在字符串的左侧、右侧或两侧添加空格
wrap 将长字符串分割成长度小于给定宽度的行
join Series 中每个元素的字符串用指定分隔符连接起来
get_dummies 提取作为 DataFrame 的虚拟变量

向量化项访问和切片

特别是 getslice 操作,使得可以从每个数组中进行向量化元素访问。例如,我们可以使用 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 返回的数组的元素。例如,结合 splitstr 索引,可以提取每个条目的姓氏:

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 &amp; Brownie Layer Cake with Whipped Cream &amp;
          > 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']

然后,我们可以构建一个由TrueFalse值组成的布尔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

现在,举个例子,假设我们想找到使用欧芹、辣椒粉和龙蒿的菜谱。我们可以使用DataFramequery方法快速计算这一点,有关详细信息,请参阅 第二十四章:

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,它包含了处理时间序列数据中最令人头疼的元素:时区。

datetimedateutil的威力在于它们的灵活性和简单的语法:你可以使用这些对象及其内置方法轻松地执行几乎任何你感兴趣的操作。它们的局限性在于当你希望处理大量的日期和时间时:正如 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 位,所以可编码时间的范围是基本单位的2 64倍。换句话说,datetime64时间分辨率最大时间跨度之间存在权衡。

例如,如果您想要 1 纳秒的时间分辨率,您只有足够的信息来编码2 64纳秒范围内的时间,或者不到 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对象,它结合了datetimedateutil的易用性以及numpy.datetime64的高效存储和向量化接口。从这些Timestamp对象中,Pandas 可以构建一个DatetimeIndex,用于索引SeriesDataFrame中的数据。

例如,我们可以使用 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

这些日期/时间对象中最基础的是TimestampDatetime​In⁠dex对象。虽然可以直接调用这些类对象,但更常见的是使用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')

要创建PeriodTimedelta值的常规序列,可以使用类似的pd.period_rangepd.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();

output 68 0

图 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');

output 70 0

图 23-2. 标准普尔 500 指数收盘价的重新采样

注意区别:在每个点上,resample报告的是前一年的平均值,而asfreq报告的是年末的值

对于上采样,resampleasfreq基本上是等效的,尽管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"]);

output 73 0

图 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');

output 76 0

图 23-4. 一年后的投资回报率

最糟糕的一年回报率约为 2019 年 3 月,随后的一年发生了与冠状病毒相关的市场崩盘。正如你所预料的,最佳的一年回报率出现在 2020 年 3 月,对于那些有足够远见或运气购买低位的人来说。

滚动窗口

计算滚动统计数据是 Pandas 实现的第三种时间序列特定操作。这可以通过SeriesDataFrame对象的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)

output 80 0

图 23-5. S&P500 指数的滚动统计数据

groupby操作一样,aggregateapply方法可以用于自定义滚动计算。

示例:可视化西雅图自行车计数

作为处理时间序列数据的更深入的例子,让我们来看看西雅图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');

output 92 0

图 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 年初。

output 94 0

图 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');

output 96 0

图 23-8. 每周自行车计数的滚动均值

结果的不平滑是由于窗口的硬截断造成的。我们可以使用窗口函数来获得更平滑的滚动均值,例如,使用高斯窗口,如图 23-9 所示。以下代码指定了窗口的宽度(这里是 50 天)和高斯窗口的宽度(这里是 10 天):

In [39]: daily.rolling(50, center=True,
                       win_type='gaussian').sum(std=10).plot(style=['-', ':', '--']);

output 98 0

图 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=['-', ':', '--']);

output 100 0

图 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=['-', ':', '--']);

output 102 0

图 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=['-', ':', '--']);

output 106 0

图 23-12. 每小时平均自行车计数(按工作日和周末划分)

结果显示了工作日的双峰通勤模式和周末的单峰休闲模式。深入挖掘这些数据并详细分析天气、温度、年份和其他因素对人们通勤模式的影响可能会很有趣;有关详细讨论,请参阅我的博客文章《西雅图真的看到了自行车使用率的上升吗?》,该文章使用了这些数据的子集。我们还将在第 42 章中探讨这些数据集的建模背景。

第二十四章:高性能 Pandas:eval 和 query

正如我们在之前的章节中已经看到的,PyData 栈的强大建立在 NumPy 和 Pandas 将基本操作推送到低级编译代码中的能力上,通过直观的高级语法:例如 NumPy 中的向量化/广播操作,以及 Pandas 中的分组类型操作。虽然这些抽象对许多常见用例是高效和有效的,但它们经常依赖于临时中间对象的创建,这可能会导致计算时间和内存使用的不必要开销。

为了解决这个问题,Pandas 包括一些方法,允许您直接访问 C 速度操作,而无需昂贵地分配中间数组:evalquery,这些方法依赖于 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

换句话说,每个中间步骤都显式地分配在内存中。如果 xy 数组非常大,这可能导致显著的内存和计算开销。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 evalquery 工具在概念上类似,并且本质上是 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

此外,它还支持在布尔表达式中使用字面量andor

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

性能:何时使用这些函数

在考虑是否使用evalquery时,有两个考虑因素:计算时间内存使用。内存使用是最可预测的方面。正如前面提到的,涉及 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的大小与您可用的系统内存(通常为几个千兆字节)相比显著,则使用evalquery表达式是个好主意。您可以使用以下命令检查数组的大约大小(以字节为单位):

In [28]: df.values.nbytes
Out[28]: 32000

就性能而言,即使您没有使用完系统内存,eval可能会更快。问题在于您的临时对象与系统的 L1 或 L2 CPU 缓存大小(通常为几兆字节)相比如何;如果它们要大得多,那么eval可以避免在不同内存缓存之间移动值时可能出现的某些潜在缓慢。实际上,我发现传统方法与eval/query方法之间的计算时间差异通常不显著——如果有什么的话,对于较小的数组来说,传统方法更快!eval/query的好处主要在于节省内存,以及它们有时提供的更清晰的语法。

我们在这里已经涵盖了关于evalquery的大部分细节;有关更多信息,请参阅 Pandas 文档。特别是,可以为运行这些查询指定不同的解析器和引擎;有关详细信息,请参阅文档中的“提升性能”部分

更多资源

在本书的这一部分中,我们已经涵盖了有效使用 Pandas 进行数据分析的许多基础知识。但我们的讨论还有很多内容未涉及。要了解更多关于 Pandas 的信息,我推荐以下资源:

Pandas 在线文档

这是完整文档的首选来源。虽然文档中的示例通常基于小型生成的数据集,但选项的描述是全面的,并且通常非常有助于理解各种函数的使用。

Python for Data Analysis

由 Pandas 的原始创建者 Wes McKinney 撰写,这本书包含了比我们在本章中有空间讨论的 Pandas 包更多的细节。特别是,McKinney 深入探讨了用于时间序列的工具,这些工具是他作为金融顾问的核心内容。这本书还包含许多将 Pandas 应用于从实际数据集中获得洞察的有趣例子。

Effective Pandas

Pandas 开发者 Tom Augspurger 的这本简短电子书,简洁地概述了如何有效和惯用地使用 Pandas 库的全部功能。

PyVideo 上的 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 语言中的ggplotggvis以及基于 D3js 和 HTML5 canvas 的 Web 可视化工具包,常使 Matplotlib 感觉笨重和老旧。尽管如此,我认为我们不能忽视 Matplotlib 作为一个经过良好测试的跨平台图形引擎的优势。最近的 Matplotlib 版本使得设置新的全局绘图样式相对容易(参见 第三十四章),人们一直在开发新的包,利用其强大的内部机制通过更清晰、更现代的 API 驱动 Matplotlib,例如 Seaborn(在 第三十六章 讨论),ggpyHoloViews,甚至 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), '--');

output 15 0

图 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')

output 21 0

图 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));

output 27 0

图 25-3. 使用 MATLAB 风格界面的子图

重要的是要认识到这种接口是有状态的:它跟踪“当前”图形和坐标轴,所有plt命令都应用于这些对象。您可以使用plt.gcf(获取当前图形)和plt.gca(获取当前坐标轴)来获取对这些对象的引用。

虽然这种状态接口在简单绘图时快捷方便,但也容易遇到问题。例如,一旦创建了第二个面板,如何返回并向第一个面板添加内容?这在 MATLAB 风格接口中是可能的,但有点笨拙。幸运的是,有更好的方法。

面向对象接口

对于这些更复杂的情况以及当您希望对图形有更多控制时,可以使用面向对象的接口。与依赖“活跃”图形或坐标轴的概念不同,在面向对象的接口中,绘图函数是显式FigureAxes对象的方法。要使用这种绘图风格重新创建之前的图形,如在图 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));

output 30 0

图 25-4. 使用面向对象接口的子图

对于更简单的绘图,使用哪种风格主要是偏好问题,但随着绘图变得更加复杂,面向对象的方法可能变得必不可少。在接下来的章节中,我们将根据方便性在 MATLAB 风格和面向对象接口之间切换。在大多数情况下,区别仅在于将plt.plot切换为ax.plot,但在接下来的章节中遇到的一些陷阱我将会特别提出。

第二十六章:简单线图

可能所有绘图中最简单的是单个函数 y = f ( x ) 的可视化。在这里,我们将首次创建这种类型的简单绘图。如同接下来的所有章节一样,我们将从设置用于绘图的笔记本开始,并导入我们将使用的包:

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 表示一个坐标轴实例或一组坐标轴实例。

output 4 0

图 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));

output 6 0

图 26-2. 一个简单的正弦波

注意最后一行末尾的分号是有意为之:它抑制了从输出中显示绘图的文本表示。

或者,我们可以使用 PyLab 接口,让图形和坐标轴在后台自动创建(参见 第 IV 部分 讨论这两种接口);如 图 26-3 所示,结果是相同的。

In [4]: plt.plot(x, np.sin(x));

output 8 0

图 26-3. 通过面向对象接口的简单正弦波

如果我们想要创建一个包含多条线的单个图形(参见 图 26-4),我们可以简单地多次调用 plot 函数:

In [5]: plt.plot(x, np.sin(x))
        plt.plot(x, np.cos(x));

这就是在 Matplotlib 中绘制简单函数的全部内容!现在我们将深入了解如何控制坐标轴和线条的外观的更多细节。

output 10 0

图 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

output 14 0

图 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

output 16 0

图 26-6. 各种线条样式的示例

虽然对于阅读你的代码的人来说可能不太清晰,但你可以通过将 linestylecolor 代码合并为单个非关键字参数传递给 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

output 18 0

图 26-7. 使用简写语法控制颜色和样式

这些单字符颜色代码反映了 RGB(红/绿/蓝)和 CMYK(青/洋红/黄/黑)颜色系统中的标准缩写,通常用于数字彩色图形。

还有许多其他关键字参数可用于微调图表的外观;有关详细信息,请通过 IPython 的帮助工具阅读 plt.plot 函数的文档字符串(参见 第 1 章)。

调整图表:坐标轴限制

Matplotlib 在为你的图表选择默认的轴限制方面做得相当不错,但有时更精细的控制会更好。调整限制的最基本方法是使用 plt.xlimplt.ylim 函数(参见 图 26-8)。

In [9]: plt.plot(x, np.sin(x))

        plt.xlim(-1, 11)
        plt.ylim(-1.5, 1.5);

output 21 0

图 26-8. 设置坐标轴限制的示例

如果因某种原因你希望任一轴显示反向,只需反转参数的顺序(参见 图 26-9)。

In [10]: plt.plot(x, np.sin(x))

         plt.xlim(10, 0)
         plt.ylim(1.2, -1.2);

output 23 0

图 26-9. 反转 y 轴的示例

一个有用的相关方法是 plt.axis(请注意这里可能会导致 axes(带有 e)和 axis(带有 i)之间的潜在混淆),它允许更质量化地指定轴限制。例如,你可以自动收紧当前内容周围的边界,如 图 26-10 所示。

In [11]: plt.plot(x, np.sin(x))
         plt.axis('tight');

output 25 0

图 26-10. “紧凑”布局的示例

或者,您可以指定希望有一个相等的轴比率,这样 x 中的一个单位在视觉上等同于 y 中的一个单位,如 Figure 26-11 所示。

In [12]: plt.plot(x, np.sin(x))
         plt.axis('equal');

output 27 0

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)");

output 30 0

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();

output 33 0

Figure 26-13. 绘图图例示例

如您所见,plt.legend 函数跟踪线型和颜色,并将其与正确的标签匹配。有关指定和格式化绘图图例的更多信息,请参阅 plt.legend 文档字符串;此外,我们将在 第二十九章 中涵盖一些更高级的图例选项。

Matplotlib 的一些注意事项

虽然大多数 plt 函数可以直接转换为 ax 方法(plt.plotax.plotplt.legendax.legend 等),但并非所有命令都是如此。特别是用于设置限制、标签和标题的功能略有修改。为了在 MATLAB 风格函数和面向对象方法之间进行过渡,请进行以下更改:

  • plt.xlabelax.set_xlabel

  • plt.ylabelax.set_ylabel

  • plt.xlimax.set_xlim

  • plt.ylimax.set_ylim

  • plt.titleax.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');

output 36 0

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');

output 4 0

图 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);

output 6 0

图 27-2. 点数示例

进一步地,这些字符代码可以与线条和颜色代码一起使用,以绘制带有连接线的点(参见 图 27-3)。

In [4]: plt.plot(x, y, '-ok');

output 8 0

图 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);

output 10 0

图 27-4. 自定义线条和点标记

这些选项使得 plt.plot 成为 Matplotlib 中二维图的主要工具。要了解所有可用选项的详细描述,请参考 plt.plot 文档

使用 plt.scatter 创建散点图

创建散点图的第二种更强大的方法是 plt.scatter 函数,其用法与 plt.plot 函数非常相似(参见 图 27-5)。

In [6]: plt.scatter(x, y, marker='o');

output 13 0

图 27-5. 一个简单的散点图

plt.scatterplt.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

output 15 0

图 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]);

output 17 0

图 27-7. 使用点属性来编码鸢尾花数据的特征^(1)

我们可以看到,这个散点图使我们能够同时探索数据的四个不同维度:每个点的(x, y)位置对应于萼片的长度和宽度,点的大小与花瓣的宽度相关,颜色与特定种类的花相关。像这样的多颜色和多特征散点图既可以用于数据探索,也可以用于数据展示。

绘图与散点图:关于效率的一点说明

除了plt.plotplt.scatter中提供的不同特性外,为什么你可能选择使用一个而不是另一个?虽然对于少量数据来说这并不重要,但是随着数据集超过几千个点,plt.plotplt.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 的简写相同。

output 4 0

图 27-8. 一个误差条示例

除了这些基本选项外,errorbar 函数还有许多选项可以微调输出结果。使用这些附加选项,您可以轻松定制误差条绘图的美学效果。特别是在拥挤的图中,我经常发现将误差条的颜色设为比点本身更浅是有帮助的(见图 27-9)。

In [3]: plt.errorbar(x, y, yerr=dy, fmt='o', color='black',
                     ecolor='lightgray', elinewidth=3, capsize=0);

output 6 0

图 27-9. 自定义误差条

除了这些选项外,您还可以指定水平误差条、单侧误差条和许多其他变体。有关可用选项的更多信息,请参阅 plt.errorbar 的文档字符串。

连续误差

在某些情况下,希望在连续量上显示误差条。虽然 Matplotlib 没有针对这种类型应用的内置便捷例程,但可以相对轻松地结合 plt.plotplt.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)

现在我们有 xfityfitdyfit,它们对我们数据的连续拟合进行了采样。我们可以像前面的部分一样将它们传递给 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);

output 11 0

图 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

可视化三维函数

我们的第一个例子演示了使用函数 z = f ( x , y ) 绘制等高线图,这里选择了特定的 f(我们在第八章中已经见过它,当时我们将其作为数组广播的示例):

In [2]: def f(x, y):
            return np.sin(x) ** 10 + np.cos(10 + y * x) * np.cos(x)

可以使用 plt.contour 函数创建等高线图。它接受三个参数:x 值的网格,y 值的网格和 z 值的网格。xy 值表示图表上的位置,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');

output 9 0

图 28-1. 用等高线可视化三维数据

注意,当使用单一颜色时,负值用虚线表示,正值用实线表示。或者可以通过指定 cmap 参数来使用色图对线条进行颜色编码。在这里,我们还指定希望在数据范围内绘制更多线条,即 20 个等间距间隔,如图 28-2 所示。

In [5]: plt.contour(X, Y, Z, 20, cmap='RdGy');

output 11 0

图 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();

output 13 0

图 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();

output 15 0

图 28-4. 将三维数据表示为图像

使用 plt.imshow 有一些潜在的小问题:

  • 它不接受 xy 网格,因此您必须手动指定图中图像的 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();

output 18 0

图 28-5. 图像上标记的等高线

这三个函数的组合——plt.contourplt.contourfplt.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);

output 3 0

图 28-6. 简单直方图

hist 函数有许多选项可以调整计算和显示;这里有一个更加定制化的直方图示例,显示在 Figure 28-7 中。

In [3]: plt.hist(data, bins=30, density=True, alpha=0.5,
                 histtype='stepfilled', color='steelblue',
                 edgecolor='none');

output 5 0

图 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);

output 7 0

图 28-8. 叠加多个直方图^(1)

如果您只对计算而不是显示直方图(即计算给定箱中点的数量)感兴趣,可以使用 np.histogram 函数:

In [5]: counts, bin_edges = np.histogram(data, bins=5)
        print(counts)
Out[5]: [ 23 241 491 224  21]

二维直方图和分箱

就像我们通过将数轴划分为箱来创建一维直方图一样,我们也可以通过将点分配到二维箱中来创建二维直方图。我们将简要地查看几种方法来做到这一点。让我们首先定义一些数据——从多元高斯分布中抽取的 xy 数组:

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')

output 13 0

图 28-9. 使用 plt.hist2d 绘制的二维直方图

就像 plt.hist 一样,plt.hist2d 也有许多额外选项可以微调绘图和分箱,这些选项在函数的文档字符串中有详细说明。此外,就像 plt.histnp.histogram 中有对应物一样,plt.hist2dnp.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')

output 18 0

图 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")

output 21 0

图 28-11. 一个分布的核密度表示

KDE 具有一个有效的平滑长度,可以有效地在详细度和平滑度之间调节(这是普遍的偏差-方差权衡的一个例子)。选择适当的平滑长度的文献非常广泛;gaussian_kde 使用一个经验法则来尝试找到输入数据的几乎最优平滑长度。

SciPy 生态系统中还提供了其他 KDE 实现,每种实现都有其各自的优缺点;例如,可以看到 sklearn.neighbors.KernelDensitystatsmodels.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()

output 4 0

图 29-1. 默认绘图图例

但我们可能希望以许多方式自定义这样一个图例。例如,我们可以指定位置并打开边框(参见图 29-2)。

In [4]: ax.legend(loc='upper left', frameon=True)
        fig

output 6 0

图 29-2. 自定义绘图图例

我们可以使用ncol命令来指定图例中的列数,如图 29-3 所示。

In [5]: ax.legend(loc='lower center', ncol=2)
        fig

output 8 0

图 29-3. 一个两列图例

我们还可以使用圆角框(fancybox)或添加阴影,更改框架的透明度(alpha 值)或更改文本周围的填充(参见图 29-4)。

In [6]: ax.legend(frameon=True, fancybox=True, framealpha=1,
                  shadow=True, borderpad=1)
        fig

有关可用图例选项的更多信息,请参阅plt.legend的文档字符串。

output 10 0

图 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);

output 13 0

图 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属性的元素。

output 15 0

图 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');

output 19 0

图 29-7. 加利福尼亚城市的位置、地理大小和人口

图例始终引用绘图上的某个对象,因此如果我们想显示特定的形状,我们需要绘制它。在这种情况下,我们想要的对象(灰色圆圈)不在绘图上,因此我们通过绘制空列表来伪造它们。请记住,图例仅列出具有指定标签的绘图元素。

通过绘制空列表,我们创建了带标签的绘图对象,这些对象被图例捕捉,现在我们的图例告诉我们一些有用的信息。这种策略对于创建更复杂的可视化效果很有用。

多个图例

有时在设计绘图时,您可能希望向同一坐标轴添加多个图例。不幸的是,Matplotlib 并不简化这一过程:通过标准的legend接口,仅能为整个绘图创建一个图例。如果尝试使用plt.legendax.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_属性中,并在绘制绘图时将其添加到图形中。

output 22 0

图 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 上的补充材料中找到。

现在我们将讨论几个关于定制这些色条并在各种情况下有效使用它们的想法。

output 5 0

图 30-1. 一个简单的色条图例

自定义色条

可以使用cmap参数来指定色图,该参数用于创建可视化的绘图函数(参见图 30-2)。

In [4]: plt.imshow(I, cmap='Blues');

output 8 0

图 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)。

总体而言,您应该了解三种不同类型的色图:

连续色图

这些由一系列连续的颜色组成(例如,binaryviridis)。

发散色图

这些通常包含两种不同的颜色,显示与平均值的正负偏差(例如,RdBuPuOr)。

  • 定性色图

这些颜色混合没有特定的顺序(例如,rainbowjet)。

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')

output 12 0

图 30-3. jet 色彩映射及其不均匀的亮度比例

注意灰度图像中的明亮条纹。即使是全彩色,这种不均匀的亮度也意味着眼睛会被色彩范围的某些部分所吸引,这可能会强调数据集中不重要的部分。最好使用像viridis(Matplotlib 2.0 的默认色彩映射)这样的色彩映射,它专门设计成在整个范围内具有均匀的亮度变化;因此,它不仅与我们的色彩感知相配,而且在灰度打印时也能很好地转化(参见 图 30-4)。

In [7]: view_colormap('viridis')

output 14 0

图 30-4. viridis 色彩映射及其均匀的亮度比例

对于其他情况,如显示与某个均值的正负偏差,双色彩色条如RdBu(红-蓝)很有帮助。但是,正如您在 图 30-5 中所看到的,重要的是注意正/负信息在转换为灰度时会丢失!

In [8]: view_colormap('RdBu')

output 16 0

图 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)

output 19 0

图 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);

output 22 0

图 30-7. 一个离散的色图

色图的离散版本可以像任何其他色图一样使用。

例子:手写数字

这可以应用的一个例子是,让我们看一看 Scikit-Learn 中包含的手写数字数据集的一个有趣的可视化;它包括近 2000 个8 × 8缩略图,显示各种手写数字。

现在,让我们从下载手写数字数据集和用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=[])

output 25 0

图 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)

我们将使用我们的离散色图来查看结果,设置ticksclim以改善结果色条的美观度(见图 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)

output 29 0

图 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。

例如,我们可以通过将xy位置设置为 0.65(即从图的宽度和高度的 65%开始)并将xy范围设置为 0.2(即轴的大小为图宽度和高度的 20%)在另一个轴的右上角创建一个插图轴。图 31-1 显示了结果:

In [2]: ax1 = plt.axes()  # standard axes
        ax2 = plt.axes([0.65, 0.65, 0.2, 0.2])

output 4 0

图 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 处)相匹配。

output 6 0

图 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')

output 9 0

图 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_adjusthspacewspace参数,它们分别指定了图的高度和宽度上的间距,单位为子图大小的百分比(在本例中,空间为子图宽度和高度的 40%)。

output 11 0

图 31-4. 调整边距的 plt.subplot

plt.subplots:一次性创建整个网格

当创建大量子图网格时,特别是如果您希望隐藏内部图的 x 和 y 轴标签时,刚刚描述的方法很快变得繁琐。为此,plt.subplots 是更易于使用的工具(注意 subplots 末尾的 s)。该函数不是创建单个子图,而是一行内创建完整的子图网格,返回它们的 NumPy 数组。参数是行数和列数,以及可选的关键字 sharexsharey,允许您指定不同轴之间的关系。

让我们创建一个 2 × 3 的子图网格,同一行内的所有轴共享其 y 轴比例,同一列内的所有轴共享其 x 轴比例(参见 图 31-5)。

In [6]: fig, ax = plt.subplots(2, 3, sharex='col', sharey='row')

output 14 0

图 31-5. plt.subplots 中的共享 x 和 y 轴

通过指定 sharexsharey,我们自动删除了网格内部的标签,使得绘图更清晰。生成的轴实例网格返回为 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.subplotplt.subplots 更符合 Python 的传统零起始索引,而 plt.subplot 使用 MATLAB 风格的一起始索引。

output 16 0

图 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]);

output 21 0

图 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()

output 23 0

图 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);

output 6 0

图 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'));

output 8 0

图 32-2. 按日期注释的每日平均出生数^(2)

ax.text 方法需要一个 x 位置、一个 y 位置和一个字符串,然后是可选的关键字,指定文本的颜色、大小、样式、对齐方式和其他属性。这里我们使用了 ha='right'ha='center',其中 ha水平对齐 的缩写。有关可用选项的更多信息,请参阅 plt.textmpl.text.Text 的文档字符串。

转换和文本位置

在前面的示例中,我们将文本注释锚定在数据位置上。有时候,将文本锚定在轴或图的固定位置上,而与数据无关,更为可取。在 Matplotlib 中,通过修改 transform 来实现这一点。

Matplotlib 使用几种不同的坐标系统:数学上,位于 ( x , y ) = ( 1 , 1 ) 处的数据点对应于轴或图的特定位置,进而对应于屏幕上的特定像素。在数学上,这些坐标系统之间的转换相对简单,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);

output 11 0

图 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

output 13 0

图 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"));

output 16 0

图 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 的注释教程中找到。

output 18 0

图 32-6. 按天平均出生率的注释^(3)

^(1) 该图的完整版本可以在GitHub找到。

^(2) 该图的完整版本可以在GitHub找到。

^(3) 该图的完整版本可以在GitHub找到。

第三十三章:自定义刻度

Matplotlib 的默认刻度定位器和格式化程序通常在许多常见情况下是足够的,但并不是每种图表类型都是最佳选择。本章将给出几个示例,调整特定图表类型的刻度位置和格式化。

然而,在我们进入示例之前,让我们再多谈一些 Matplotlib 图表的对象层次结构。Matplotlib 的目标是让每个出现在图表上的东西都有一个 Python 对象来表示:例如,回想一下Figure是包围所有图表元素的边界框。每个 Matplotlib 对象也可以作为子对象的容器:例如,每个Figure可以包含一个或多个Axes对象,每个Axes对象又包含表示图表内容的其他对象。

刻度标记也不例外。每个轴都有属性xaxisyaxis,这些属性又包含组成轴的线条、刻度和标签的所有属性。

主要和次要刻度

在每个轴上,有一个主要刻度和一个次要刻度的概念。顾名思义,主要刻度通常较大或更显著,而次要刻度通常较小。默认情况下,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);

output 4 0

图 33-1. 对数刻度和标签的示例

在这张图表中,每个主要刻度显示一个大的刻度标记、标签和网格线,而每个次要刻度显示一个更小的刻度标记,没有标签或网格线。

这些刻度属性——即位置和标签——可以通过设置每个轴的formatterlocator对象来自定义。让我们来看看刚刚显示的图表的 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.NullLocatorplt.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())

output 10 0

图 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')

output 12 0

图 33-3. 在图像绘制中隐藏刻度

每个图像显示在自己的轴上,并且我们将刻度定位器设置为 null,因为刻度值(在这种情况下是像素数)不传达有关此特定可视化的相关信息。

减少或增加刻度的数量

默认设置的一个常见问题是较小的子图可能会有拥挤的标签。我们可以在这里显示的图网格中看到这一点(见图 33-4)。

In [7]: fig, ax = plt.subplots(4, 4, sharex=True, sharey=True)

output 15 0

图 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

output 17 0

图 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);

output 20 0

图 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

output 22 0

图 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$"被渲染为希腊字母π

output 24 0

图 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);

output 4 0

图 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');

output 6 0

图 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);

output 11 0

图 34-3. 使用 rc 设置的自定义直方图

让我们看看使用这些 rc 参数的简单线图的外观(参见图 34-4)。

In [6]: for i in range(4):
            plt.plot(np.random.rand(10))

output 13 0

图 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()

output 22 0

Figure 34-5. Matplotlib 的 default 风格

FiveThiryEight 风格

fivethirtyeight 风格模仿了流行的 FiveThirtyEight 网站 上的图形。正如您在 Figure 34-6 中看到的那样,它以鲜明的颜色、粗线条和透明的坐标轴为特征:

In [10]: with plt.style.context('fivethirtyeight'):
             hist_and_lines()

output 24 0

Figure 34-6. fivethirtyeight 风格

ggplot 风格

R 语言中的 ggplot 包是数据科学家中流行的可视化工具。Matplotlib 的 ggplot 风格模仿了该包的默认样式(见 Figure 34-7)。

In [11]: with plt.style.context('ggplot'):
             hist_and_lines()

output 26 0

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()

output 28 0

Figure 34-8. bmh 风格

Dark Background 风格

对于在演示中使用的图像,与其使用浅色背景,不如使用深色背景更为有用。dark_background 风格提供了这种选择(见 Figure 34-9)。

In [13]: with plt.style.context('dark_background'):
             hist_and_lines()

output 30 0

Figure 34-9. dark_background 风格

灰度风格

你可能会发现自己正在为不接受彩色图的印刷出版物准备图表。对此,grayscale 风格(见图 34-10)可能会很有用。

In [14]: with plt.style.context('grayscale'):
             hist_and_lines()

output 32 0

图 34-10. grayscale 风格

Seaborn 风格

Matplotlib 也有几种受 Seaborn 库启发的样式表(详见第三十六章)。我发现这些设置非常不错,通常将它们作为自己数据探索的默认设置(见图 34-11)。

In [15]: with plt.style.context('seaborn-whitegrid'):
             hist_and_lines()

output 34 0

图 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

output 5 0

Figure 35-1. 一个空的三维坐标轴

三维点和线

最基本的三维图是由一组(x, y, z)三元组创建的线条或散点图集合。类比之前讨论的常见二维图,可以使用ax.plot3Dax.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');

output 8 0

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');

output 12 0

Figure 35-3. 一个三维等高线图

有时默认的视角不是最佳的,这时我们可以使用view_init方法来设置仰角和方位角。在下面的例子中,可视化效果见图 35-4,我们将使用仰角 60 度(即相对于 x-y 平面上方 60 度)和方位角 35 度(即相对于 z 轴逆时针旋转 35 度):

In [7]: ax.view_init(60, 35)
        fig

同样地,请注意,当使用 Matplotlib 的交互式后端时,可以通过点击和拖动来实现这种类型的旋转。

output 14 0

图 35-4. 调整三维绘图的视角角度

线框和表面绘图

另外两种适用于网格数据的三维绘图类型是线框图和表面绘图。它们接受值网格并将其投影到指定的三维表面上,可以使得最终的三维形态非常易于可视化。这里有一个使用线框的例子(见图 35-5)。

In [8]: fig = plt.figure()
        ax = plt.axes(projection='3d')
        ax.plot_wireframe(X, Y, Z)
        ax.set_title('wireframe');

output 17 0

图 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');

output 19 0

图 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');

output 21 0

图 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);

output 25 0

图 35-8. 一个三维采样表面

这个点云留下了许多问题。在这种情况下帮助我们的函数是ax.plot_trisurf,它通过首先在相邻点之间找到一组三角形来创建表面(请记住这里的xyz是一维数组);结果如图 35-9 所示(见图 35-9):

In [13]: ax = plt.axes(projection='3d')
         ax.plot_trisurf(x, y, z,
                         cmap='viridis', edgecolor='none');

结果当然不像使用网格绘制时那么干净,但这种三角剖分的灵活性允许一些非常有趣的三维绘图。例如,实际上可以使用这种方法绘制一个三维莫比乌斯带,我们接下来会看到。

output 27 0

第 35-9 图。一个三角形表面绘图

示例:可视化莫比乌斯带

一个莫比乌斯带类似于一条纸条在环上粘贴成一个半扭曲的带子,结果是一个只有一个面的对象!在这里,我们将使用 Matplotlib 的三维工具可视化这样的对象。创建莫比乌斯带的关键是考虑它的参数化:它是一个二维带子,所以我们需要两个内在维度。让我们称之为 θ,它在环周围从 0 到 2 π,以及 w,它在带子宽度上从 -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) 位置。

思考一下,我们可能会意识到有两个旋转正在发生:一个是环绕其中心的位置旋转(我们称之为 θ),而另一个是带子围绕其轴线的扭曲(我们将其称为 φ)。对于一个莫比乌斯带,我们必须使带子在完整环的过程中进行半扭曲,即 Δ φ = Δ θ / 2

In [15]: phi = 0.5 * theta

现在我们利用我们对三角函数的记忆来推导三维嵌入。我们定义 r,每个点到中心的距离,并使用它来找到嵌入的 ( x , y , z ) 坐标:

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');

output 36 0

第 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)

output 5 0

图 36-1 直方图可视化分布

不仅仅是提供直方图作为可视化输出,我们还可以使用核密度估计获得分布的平滑估计(在第二十八章介绍),Seaborn 通过sns.kdeplot来实现(参见图 36-2)。

In [3]: sns.kdeplot(data=data, shade=True);

output 7 0

图 36-2 核密度估计可视化分布

如果我们将xy列传递给kdeplot,我们将得到一个二维可视化的联合密度(见图 36-3)。

In [4]: sns.kdeplot(data=data, x='x', y='y');

output 9 0

图 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);

output 14 0

图 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));

output 17 0

图 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");

output 19 0

图 36-6. 一个因子图的示例,比较给定各种离散因子的分布

联合分布

类似于我们之前看到的对角线图,我们可以使用sns.jointplot显示不同数据集之间的联合分布,以及相关的边缘分布(见图 36-7)。

In [10]: with sns.axes_style('white'):
             sns.jointplot(x="total_bill", y="tip", data=tips, kind='hex')

output 21 0

图 36-7. 一个联合分布图

联合图甚至可以进行一些自动的核密度估计和回归,如图 36-8 所示。

In [11]: sns.jointplot(x="total_bill", y="tip", data=tips, kind='reg');

output 23 0

图 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)

output 26 0

图 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')

output 28 0

图 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);我们可以通过查看DataFramedtypes属性来看到这一点:

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')

虚线显示了如果某人以完全稳定的速度跑完马拉松,其时间会在哪里。分布高于此线表明(正如您所料),大多数人在马拉松比赛过程中放慢了速度。如果您曾经参加过竞争性比赛,您会知道那些在比赛的第二半段跑得更快的人被称为“负分裂”比赛。

output 41 0

图 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="--");

output 45 0

图 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();

output 48 0

图 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');

output 50 0

图 36-14. 按性别分布的分裂比例

这里有趣的是,有很多男性比女性更接近均匀分裂!在男性和女性中间几乎呈双峰分布。让我们看看是否可以通过年龄的函数来解析正在发生的事情。

比较分布的一个好方法是使用小提琴图,显示在图 36-15 中。

In [27]: sns.violinplot(x="gender", y="split_frac", data=data,
                        palette=["lightblue", "lightpink"]);

output 52 0

图 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"]);

output 55 0

图 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=":");

output 59 0

图 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。

  • VegaVega-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)位置表示。此外,每个点有两个类别标签之一,这里由点的颜色表示。通过这些特征和标签,我们希望创建一个模型,让我们能够决定一个新点应该被标记为“蓝色”还是“红色”。

05.01 classification 1

图 37-1. 用于分类的简单数据集

对于这样的分类任务,有许多可能的模型,但我们将从一个非常简单的模型开始。我们假设这两组数据可以通过在它们之间绘制一条直线来分开,这样,线的两边的点都属于同一组。这里的模型是声明“一条直线分隔类别”的定量版本,而模型参数则是描述该线在我们的数据中位置和方向的特定数值。这些模型参数的最佳值是从数据中学习得来的(这就是机器学习中的“学习”),通常称为训练模型

图 37-2 展示了这个数据的训练模型的视觉表示。

05.01 classification 2

图 37-2. 一个简单的分类模型

现在这个模型已经被训练好了,它可以推广到新的未标记数据上。换句话说,我们可以拿到新的数据集,通过这条线进行划分,并根据这个模型为新点分配标签(参见图 37-3)。这个阶段通常被称为预测

05.01 classification 3

图 37-3. 将分类模型应用于新数据

这是机器学习分类任务的基本概念,其中“分类”表示数据具有离散的类标签。乍一看,这可能显得微不足道:很容易看到我们的数据并绘制这样的分界线来完成分类。然而,机器学习方法的好处在于它能够推广到更大的数据集和更多的维度。例如,这类似于电子邮件自动垃圾邮件检测的任务。在这种情况下,我们可能会使用以下特征和标签:

  • 特征 1, 特征 2 重要单词或短语的标准化计数(如“伟哥”,“延长保修”等)

  • 标签 “垃圾邮件”或“非垃圾邮件”

对于训练集,这些标签可能是通过对一小部分代表性电子邮件的个别检查来确定的;对于其余的电子邮件,标签将使用模型确定。对于足够训练良好且特征构造良好的分类算法(通常有数千或数百万个单词或短语),这种方法非常有效。我们将在第四十一章中看到一个基于文本的分类的示例。

我们将详细讨论的一些重要分类算法包括高斯朴素贝叶斯(见第四十一章)、支持向量机(见第四十三章)和随机森林分类(见第四十四章)。

回归:预测连续标签

与分类算法的离散标签相比,我们接下来将看一个简单的回归任务,其中标签是连续的量。

考虑图 37-4 中显示的数据,其中包含一组具有连续标签的点。

05.01 regression 1

图 37-4. 用于回归的简单数据集

就像分类示例一样,我们有二维数据:也就是说,每个数据点有两个描述特征。每个点的颜色代表该点的连续标签。

我们可以使用多种可能的回归模型来处理这类数据,但在这里我们将使用简单的线性回归模型来预测这些点。这个简单的模型假设,如果我们将标签视为第三个空间维度,我们可以将一个平面拟合到数据中。这是对将两个坐标数据拟合一条直线这一已知问题的更高级的泛化。

我们可以将这种设置视觉化,如图 37-5 所示。

05.01 regression 2

图 37-5. 回归数据的三维视图

注意,这里的 特征 1–特征 2 平面与 Figure 37-4 中的二维图是相同的;然而,在这种情况下,我们通过颜色和三维轴位置表示了标签。从这个视角看,通过这三维数据拟合平面来预测任何输入参数的预期标签似乎是合理的。回到二维投影,当我们拟合这样一个平面时,我们得到了 Figure 37-6 中显示的结果。

05.01 regression 3

Figure 37-6. 回归模型的表示

这个拟合平面为我们提供了预测新点标签所需的信息。从视觉上看,我们找到了在 Figure 37-7 中展示的结果。

05.01 regression 4

Figure 37-7. 应用回归模型到新数据上

与分类示例一样,这个任务在低维度下可能看起来微不足道。但这些方法的力量在于它们可以在具有许多特征的数据中直接应用和评估。例如,这类似于通过望远镜观测到的星系的距离任务——在这种情况下,我们可能使用以下特征和标签:

  • 特征 1特征 2 每个星系在几个波长或颜色之一上的亮度

  • 标签 星系的距离或红移

对于其中一小部分星系的距离可能通过独立的(通常更昂贵或复杂)观测来确定。然后可以使用适当的回归模型估计其余星系的距离,而无需在整个集合上使用更昂贵的观测。在天文学界,这被称为“光度红移”问题。

我们将讨论的一些重要回归算法包括线性回归(参见 Chapter 42)、支持向量机(参见 Chapter 43)和随机森林回归(参见 Chapter 44)。

聚类:推断未标记数据的标签

我们刚刚看到的分类和回归示例都是监督学习算法的例子,我们试图建立一个可以预测新数据标签的模型。无监督学习涉及描述数据而不涉及任何已知标签的模型。

无监督学习的一个常见情况是“聚类”,其中数据自动分配给一些离散的组。例如,我们可能有一些类似于 Figure 37-8 中所示的二维数据。

05.01 clustering 1

Figure 37-8. 聚类示例数据

通过目测,很明显每个点都属于一个明显的组。基于数据的内在结构,聚类模型将确定哪些点是相关的。使用非常快速和直观的k-means 算法(参见第四十七章),我们找到如图 37-9 所示的聚类。

05.01 clustering 2

图 37-9. 使用 k-means 聚类模型标记的数据

k-means 算法适配了一个模型,包括k个聚类中心;最优的中心被认为是最小化每个点到其分配中心距离的那些中心。再次强调,在二维数据中这可能看起来像是一个微不足道的练习,但随着数据变得更大更复杂,这样的聚类算法可以继续从数据集中提取有用信息。

我们将在第四十七章更深入地讨论k-means 算法。其他重要的聚类算法包括高斯混合模型(参见第四十八章)和谱聚类(参见Scikit-Learn 的聚类文档)。

降维:推断未标记数据的结构

降维是无监督算法的另一个示例,其中标签或其他信息是从数据集本身的结构中推断出来的。降维比我们之前看过的例子更加抽象,但通常它试图提取数据的一些低维表示,以某种方式保留完整数据集的相关特性。不同的降维例程以不同的方式衡量这些相关特性,正如我们将在第四十六章中看到的那样。

例如,考虑显示在图 37-10 中的数据。

05.01 dimesionality 1

图 37-10. 降维的示例数据

从视觉上看,很明显这些数据中存在一些结构:它们来自一个一维线,在二维空间内以螺旋的方式排列。从某种意义上说,你可以说这些数据“本质上”只有一维,尽管这些一维数据嵌入在二维空间中。在这种情况下,一个合适的降维模型应该对这种非线性嵌入结构敏感,并能够检测到这种较低维度的表示。

图 37-11 展示了 Isomap 算法的结果可视化,这是一种能够实现这一目标的流形学习算法。

请注意,颜色(代表提取的一维潜变量)沿螺旋线均匀变化,这表明算法确实检测到了我们肉眼看到的结构。与前面的例子一样,降维算法在高维情况下的作用变得更加明显。例如,我们可能希望可视化一个具有 100 或 1000 个特征的数据集中的重要关系。可视化 1000 维数据是一项挑战,我们可以通过使用降维技术将数据降低到 2 或 3 维来使其更易管理。

我们将讨论一些重要的降维算法,包括主成分分析(参见第四十五章)和各种流形学习算法,包括 Isomap 和局部线性嵌入(参见第四十六章)。

05.01 dimesionality 2

图 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);

output 9 0

图 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 所示。

05.02 样本特征

图 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 的步骤中,最常见的步骤如下:

  1. 通过从 Scikit-Learn 导入适当的估计器类来选择一个模型类。

  2. 通过用所需值实例化这个类来选择模型超参数。

  3. 按照本章前面概述的方式,将数据安排为特征矩阵和目标向量。

  4. 通过调用模型实例的fit方法将模型拟合到您的数据中。

  5. 将模型应用于新数据:

    • 对于监督学习,通常我们使用predict方法为未知数据预测标签。

    • 对于无监督学习,我们经常使用transformpredict方法来转换或推断数据的属性。

现在我们将逐步展示几个简单的示例,应用监督和无监督学习方法。

监督学习示例:简单线性回归

作为这一过程的示例,让我们考虑一个简单的线性回归——即,将一条直线拟合到数据(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);

output 20 0

图 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);

output 41 0

图 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 算法没有物种标签的知识!这向我们暗示,一个相对简单的分类对数据集可能是有效的,就像我们之前看到的那样。

output 53 0

图 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);

output 58 0

图 38-6. Iris 数据中的 k-means 聚类^(4)

通过按簇号拆分数据,我们可以看到 GMM 算法已经完美地恢复了底层标签:setosa 物种在簇 0 中完美分离,而 versicolorvirginica 之间仍然存在少量混合。这意味着即使没有专家告诉我们单个花的物种标签,这些花的测量也是足够明显的,以至于我们可以使用简单的聚类算法自动识别出这些不同物种群!这种算法可能进一步给领域专家提供关于他们正在观察的样本之间关系的线索。

应用:探索手写数字

为了在一个更有趣的问题上演示这些原则,让我们考虑光学字符识别问题的一部分:手写数字的识别。在实际情况中,这个问题涉及到在图像中定位和识别字符。在这里,我们将采取捷径,使用 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')

output 65 0

图 38-7. 手写数字数据;每个样本由一个 8 × 8 的像素网格表示

为了在 Scikit-Learn 中处理这些数据,我们需要一个二维的 [n_samples, n_features] 表示。我们可以通过将图像中的每个像素视为一个特征来实现这一点:即通过展开像素数组,使得我们有一个长度为 64 的数组,其中包含代表每个数字的像素值。此外,我们还需要目标数组,它给出了每个数字的预先确定标签。这两个量已经内置在 digits 数据集的 datatarget 属性中了:

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 维空间中各种数字的分离程度有了一些直观的认识。例如,零和一在参数空间中几乎没有重叠。直觉上这是有道理的:零在图像中间是空的,而一通常在图像中间有墨水。另一方面,一和四之间似乎有一个更或多或少连续的谱系:我们可以通过意识到有些人在一上画有“帽子”,这使它们看起来与四相似。

总体而言,尽管在边缘处有些混合,不同的组在参数空间中似乎被相当好地定位:这表明即使是非常简单的监督分类算法也应该在完整的高维数据集上表现适当。让我们试一试。

output 73 0

图 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”。

output 81 0

图 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 章)或其他分类方法。

output 83 0

图 38-10. 数据显示正确(绿色)和错误(红色)标签;查看这个图的彩色版本,请参阅书的在线版本

总结

在本章中,我们介绍了 Scikit-Learn 数据表示和估计器 API 的基本特性。无论使用何种类型的估计器,都保持着相同的导入/实例化/拟合/预测模式。掌握了这些信息,您可以探索 Scikit-Learn 文档,并在您的数据上尝试各种模型。

在下一章中,我们将探讨机器学习中可能最重要的主题:如何选择和验证您的模型。

^(1) 这个图的全尺寸、全彩色版本可以在GitHub上找到。

^(2) 可在在线附录中找到生成此图的代码。

^(3) 这个图的全彩色版本可以在GitHub上找到。

^(4) 这个图的全尺寸、全彩色版本可以在GitHub上找到。

第三十九章:超参数和模型验证

在上一章中,我们看到了应用监督机器学习模型的基本方法:

  1. 选择一个模型类别。

  2. 选择模型超参数。

  3. 将模型拟合到训练数据中。

  4. 使用模型来预测新数据的标签。

这两个部分——模型的选择和超参数的选择——可能是有效使用这些工具和技术的最重要部分。为了做出明智的选择,我们需要一种验证模型和超参数是否与数据相匹配的方法。虽然这听起来很简单,但要有效地做到这一点,你必须避免一些陷阱。

思考模型验证

原则上,模型验证非常简单:在选择了模型和其超参数之后,我们可以通过将其应用于一些训练数据并将预测结果与已知值进行比较来估计其有效性。

本节将首先展示一个关于模型验证的天真方法以及为什么它失败了,然后探讨使用保留集和交叉验证进行更健壮的模型评估。

错误的模型验证方法

让我们从在上一章中看到的鸢尾花数据集中采用天真的验证方法开始。我们将从加载数据开始:

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 这样。

05.03 2 fold CV

图 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 展示了五折交叉验证的可视化描述。

05.03 5 fold CV

图 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,它展示了对同一数据集的两个回归拟合。

05.03 偏差-方差

图 39-3. 高偏差和高方差的回归模型^(3)

很明显,这两个模型都不特别适合数据,但它们以不同的方式失败。

左边的模型试图通过数据找到一条直线拟合。因为在这种情况下,一条直线不能准确地分割数据,所以直线模型永远无法很好地描述这个数据集。这样的模型被称为欠拟合数据:即它没有足够的灵活性来适当地考虑数据中的所有特征。另一种说法是,该模型具有高偏差。

右边的模型试图通过数据拟合高阶多项式。在这里,模型拟合具有足够的灵活性来几乎完美地描述数据中的细微特征,但即使它非常精确地描述了训练数据,其精确形式似乎更反映了数据的特定噪声属性,而不是生成数据的任何过程的固有属性。这样的模型被称为过拟合数据:即它具有如此高的灵活性,以至于模型最终解释了随机误差以及底层数据分布。另一种说法是,该模型具有高方差。

从另一个角度来看,考虑一下如果我们使用这两个模型来预测一些新数据的y值会发生什么。在图 39-4 中的图表中,红色/浅色点表示从训练集中省略的数据。

05.03 偏差-方差 2

图 39-4. 高偏差和高方差模型中的训练和验证分数^(4)

这里的分数是R 2分数,或者确定系数,它衡量模型相对于目标值简单平均的表现。 R 2 = 1 表示完美匹配, R 2 = 0 表示模型不比简单取数据均值更好,负值则表示更差的模型。从这两个模型相关的分数中,我们可以得出一个更普遍的观察:

  • 对于高偏差模型,模型在验证集上的表现与在训练集上的表现类似。

  • 对于高方差模型,模型在验证集上的表现远远不及在训练集上的表现。

如果我们可以调整模型复杂度,我们会期望训练分数和验证分数表现如图 39-5 所示,通常称为验证曲线,我们可以看到以下特点:

  • 训练分数始终高于验证分数。一般情况下都是如此:模型对已见数据的拟合程度比对未见数据的拟合程度更好。

  • 对于非常低的模型复杂度(即高偏差模型),训练数据欠拟合,这意味着该模型对于训练数据和任何之前未见数据的预测都很差。

  • 对于非常高的模型复杂度(即高方差模型),训练数据过拟合,这意味着模型对训练数据的预测非常好,但是对于任何之前未见数据都失败了。

  • 对于某些中间值,验证曲线达到最大值。这种复杂度水平表明在偏差和方差之间有一个适当的权衡。

调整模型复杂度的方法因模型而异;在后面的章节中深入讨论各个模型时,我们将看到每个模型如何允许此类调整。

05.03 验证曲线

图 39-5. 模型复杂度、训练分数和验证分数之间关系的示意图^(5)

Scikit-Learn 中的验证曲线

让我们看一个使用交叉验证计算模型验证曲线的示例。这里我们将使用多项式回归模型,一个广义线性模型,其中多项式的次数是一个可调参数。例如,对于模型参数 ab

y = a x + b

三阶多项式对数据拟合出一个立方曲线;对于模型参数a , b , c , d

y = a x 3 + b x 2 + c x + d

我们可以将这一概念推广到任意数量的多项式特征。在 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');

在这种情况下控制模型复杂度的旋钮是多项式的阶数,它可以是任何非负整数。一个有用的问题是:哪个多项式阶数提供了偏差(欠拟合)和方差(过拟合)之间的合适权衡点?

output 33 0

图 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');

output 35 0

图 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);

output 37 0

图 39-8. Figure 39-6 中数据的交叉验证最优模型

注意,找到这个最优模型实际上并不需要我们计算训练分数,但是检查训练分数和验证分数之间的关系可以为我们提供模型性能的有用见解。

学习曲线

模型复杂度的一个重要方面是,最优模型通常取决于训练数据的大小。例如,让我们生成一个数据集,其点数是之前的五倍(见图 39-9)。

In [15]: X2, y2 = make_data(200)
         plt.scatter(X2.ravel(), y2);

output 40 0

图 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 阶模型也不会严重过拟合数据——验证和训练分数仍然非常接近。

output 42 0

图 39-10. 多项式模型拟合数据的学习曲线

因此,验证曲线的行为不仅仅取决于模型复杂度,还取决于训练点的数量。我们可以通过使用日益增大的数据子集来研究模型随训练点数量变化的行为,从而获得更深入的见解。有时,关于训练/验证分数与训练集大小的图称为学习曲线

我们期望从学习曲线得到的一般行为是:

  • 给定复杂度的模型会过拟合小数据集:这意味着训练分数会相对较高,而验证分数则相对较低。

  • 给定复杂度的模型会欠拟合大数据集:这意味着训练分数会减少,但验证分数会增加。

  • 除了偶然情况外,模型永远不会给验证集比训练集更好的分数:这意味着曲线应该会越来越接近,但永远不会交叉。

考虑到这些特征,我们期望学习曲线在质量上看起来像图 39-11 所示。

05.03 learning curve

图 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')

output 47 0

图 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);

output 56 0

图 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.OneHotEncodersklearn​.fea⁠ture_​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);

output 24 0

图 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);

output 26 0

图 40-2. 一条较差的直线拟合

但显然我们需要一个更复杂的模型来描述xy之间的关系。

对此的一种方法是转换数据,添加额外的特征列以增强模型的灵活性。例如,我们可以这样向数据中添加多项式特征:

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.]]

派生的特征矩阵有一列表示x,第二列表示x 2,第三列表示x 3。在这扩展输入上计算线性回归可以更接近我们的数据,如你在图 40-3 中所见:

In [13]: model = LinearRegression().fit(X2, y)
         yfit = model.predict(X2)
         plt.scatter(x, y)
         plt.plot(x, yfit);

output 30 0

图 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])

特征管道

使用任何上述示例,如果希望手动执行转换,尤其是希望串联多个步骤时,可能很快变得乏味。例如,我们可能希望一个处理管道看起来像这样:

  1. 使用均值填补缺失值。

  2. 将特征转换为二次项。

  3. 拟合线性回归模型。

为了简化这种类型的处理管道,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 管道实际操作的一些示例,请参阅有关朴素贝叶斯分类的以下章节,以及第四十二章和第四十三章。

第四十一章:深入:朴素贝叶斯分类

前四章概述了机器学习的概念。在第五部分的其余部分,我们将首先更详细地查看四种监督学习算法,然后是四种无监督学习算法。我们从第一个监督方法朴素贝叶斯分类开始。

朴素贝叶斯模型是一组极快速且简单的分类算法,通常适用于非常高维度的数据集。因为它们速度快、可调参数少,所以它们通常用作分类问题的快速基准线。本章将提供朴素贝叶斯分类器工作原理的直观解释,并在一些数据集上展示它们的几个例子。

贝叶斯分类

朴素贝叶斯分类器是建立在贝叶斯分类方法之上的。这些方法依赖于贝叶斯定理,该定理描述了统计量的条件概率关系。在贝叶斯分类中,我们感兴趣的是找到给定一些观察特征的标签L的概率,可以写作P ( L | features )。贝叶斯定理告诉我们如何用我们可以更直接计算的量来表达这一点:

P ( L | features ) = P( features |L)P(L) P( features )

如果我们试图在两个标签之间做出决策——让我们称它们为L 1L 2——那么做出这个决定的一种方法是计算每个标签的后验概率的比率:

P(L 1 | features ) P(L 2 | features ) = P( features |L 1 ) P( features |L 2 ) P(L 1 ) P(L 2 )

现在我们所需的只是一些模型,通过这些模型我们可以计算每个标签P ( features | L i )。这样的模型被称为生成模型,因为它指定了生成数据的假设随机过程。为每个标签指定这种生成模型是这样一个贝叶斯分类器训练的主要部分。对于这样一个训练步骤的一般版本来说,这是一个非常困难的任务,但是我们可以通过对这个模型形式做一些简化的假设来简化它。

这就是“朴素贝叶斯”中的“朴素”所在:如果我们对每个标签的生成模型作出非常朴素的假设,我们可以找到每个类别的生成模型的粗略近似,然后继续贝叶斯分类。不同类型的朴素贝叶斯分类器基于关于数据的不同朴素假设,我们将在接下来的几节中讨论其中一些。

我们从标准导入开始:

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');

output 5 0

图 41-1. 高斯朴素贝叶斯分类数据^(1)

最简单的高斯模型假设数据由没有各维度间协方差的高斯分布描述。这个模型可以通过计算每个标签内点的均值和标准差来拟合,这是我们定义这种分布所需的所有内容。这种朴素高斯假设的结果显示在图 41-2 中。

05.05 gaussian NB

图 41-2. 高斯朴素贝叶斯模型可视化^(2)

这里的椭圆代表每个标签的高斯生成模型,中心区域的概率更高。有了每个类别的生成模型,我们可以简单地计算任何数据点的似然P ( features | L 1 ),因此我们可以快速计算后验比率,并确定给定点最有可能的标签。

这个过程在 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);

output 13 0

图 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'

output 29 0

图 41-4. 多项式朴素贝叶斯文本分类器的混淆矩阵

请记住,这只是一个对字符串中每个单词(加权)频率的简单概率模型;尽管如此,结果令人印象深刻。即使是非常朴素的算法,在小心使用并在大量高维数据上训练时,也可以出奇地有效。

何时使用朴素贝叶斯

由于朴素贝叶斯分类器对数据做出如此严格的假设,它们通常不如更复杂的模型表现好。尽管如此,它们有几个优点:

  • 它们在训练和预测时都非常快速。

  • 它们提供直观的概率预测。

  • 它们通常易于解释。

  • 它们具有少量(如果有的话)可调参数。

这些优势意味着朴素贝叶斯分类器通常是作为初始基线分类的不错选择。如果它表现得合适,那么恭喜你:你已经拥有了一个非常快速、易于解释的分类器来解决你的问题。如果它表现不佳,那么你可以开始探索更复杂的模型,同时具备一些关于它们应该如何表现的基础知识。

朴素贝叶斯分类器在以下情况下表现特别好:

  • 当朴素假设实际上与数据匹配时(在实践中非常罕见)

  • 对于非常分离的类别,当模型复杂度不那么重要时

  • 对于非常高维数据,当模型复杂度不那么重要时

最后两点看似不同,但实际上是相关的:随着数据集维度的增长,任何两个点在一起的可能性大大降低(毕竟,它们必须在每个维度上都很接近才能在总体上接近)。这意味着在高维空间中,簇通常比低维空间中更为分离,平均而言。基于这个原因,像这里讨论的简单分类器往往在维度增加时表现得同样或更好:一旦你有足够的数据,即使是简单模型也可以非常强大。

^(1) 此图的全彩版本可在GitHub上找到。

^(2) 生成此图的代码可在在线附录中找到。

第四十二章:深入解析:线性回归

就像朴素贝叶斯(讨论见第四十一章)对于分类任务是一个很好的起点一样,线性回归模型对于回归任务也是一个很好的起点。这样的模型很受欢迎,因为它们可以快速拟合并且易于解释。你已经熟悉了最简单形式的线性回归模型(即将直线拟合到二维数据),但是这样的模型可以扩展到对更复杂的数据行为进行建模。

在本章中,我们将首先快速了解这个众所周知问题背后的数学知识,然后再看看线性模型如何被泛化以解决数据中更复杂的模式。

我们从标准导入开始:

In [1]: %matplotlib inline
        import matplotlib.pyplot as plt
        plt.style.use('seaborn-whitegrid')
        import numpy as np

简单线性回归

我们将从最熟悉的线性回归开始,即对数据进行直线拟合。直线拟合是一个形式为的模型:

y = a x + b

其中a通常被称为斜率,而b通常被称为截距

考虑以下数据,这些数据分布在一条斜率为 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);

output 4 0

图 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);

output 6 0

图 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估计器要比这更强大——除了简单的直线拟合外,它还可以处理形式为的多维线性模型:

y = a 0 + a 1 x 1 + a 2 x 2 +

其中有多个x值。从几何上讲,这类似于在三维空间中对点拟合平面,或者在更高维度中对点拟合超平面。

这种回归的多维性使其更难以可视化,但我们可以通过构建一些示例数据,使用 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. ]

这里的y数据是从三个随机x值的线性组合构成的,线性回归恢复了用于构建数据的系数。

通过这种方式,我们可以使用单个 LinearRegression 评估器来拟合线条、平面或超平面到我们的数据。看起来这种方法仍然限制在变量之间严格的线性关系,但事实证明我们也可以放宽这一点。

基函数回归

你可以用的一个技巧是将线性回归适应变量之间的非线性关系,方法是根据 基函数 转换数据。我们之前已经见过这样的一个版本,在第三十九章和第四十章中使用的 PolynomialRegression 流水线中。这个想法是将我们的多维线性模型:

y = a 0 + a 1 x 1 + a 2 x 2 + a 3 x 3 +

并从我们的单维输入 x 中构建 x 1 , x 2 , x 3 , 等等。也就是说,我们让 x n = f n ( x ) ,其中 f n ( ) 是将我们的数据转换的某个函数。

例如,如果 f n ( x ) = x n ,我们的模型就会变成多项式回归:

y = a 0 + a 1 x + a 2 x 2 + a 3 x 3 +

注意,这仍然是 线性模型 —— 线性指的是系数 a n 从不相乘或相除。我们所做的实质上是将我们的一维 x 值投影到更高的维度,这样线性拟合可以拟合 xy 之间更复杂的关系。

多项式基函数

这种多项式投影非常有用,以至于它被内置到 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())

通过这种转换,我们可以使用线性模型更好地拟合xy之间更复杂的关系。例如,这里是带噪声的正弦波(参见图 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);

output 19 0

图 42-3. 对非线性训练数据进行线性多项式拟合

我们的线性模型,通过使用七阶多项式基函数,可以很好地拟合这些非线性数据!

高斯基函数

当然,也可以使用其他基函数。例如,一个有用的模式是拟合一个不是多项式基函数的模型,而是高斯基函数的总和。结果可能看起来像是图 42-4。

05.06 高斯基函数

图 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);

output 24 0

图 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);

output 27 0

图 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)

output 29 0

图 42-7. 过度复杂模型中高斯基函数的系数

此图的下部面板显示了每个位置的基函数的幅度。当基函数重叠时,这是典型的过拟合行为:相邻基函数的系数会急剧增加并相互抵消。我们知道这种行为是有问题的,如果我们可以通过惩罚模型参数的大值来明确限制这样的峰值,那就太好了。这样的惩罚被称为正则化,有几种形式。

岭回归(L[2]正则化)

或许最常见的正则化形式被称为岭回归L 2 正则化(有时也称为Tikhonov 正则化)。这通过对模型系数θ n的平方和(2-范数)进行惩罚来实现。在这种情况下,模型拟合的惩罚将是:

P = α n=1 N θ n 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')

output 32 0

图 42-8. 岭(L[2])正则化应用于过度复杂的模型(与 Figure 42-7 进行比较)

参数α本质上是一个控制生成模型复杂性的旋钮。在极限α 0中,我们恢复了标准线性回归结果;在极限α 中,所有模型响应都将被抑制。岭回归的一个优点是它特别高效地计算—几乎没有比原始线性回归模型更多的计算成本。

套索回归(L[1]正则化)

另一种常见的正则化方法被称为套索回归L[1]正则化,它涉及对回归系数的绝对值(1-范数)的惩罚:

P = α n=1 N | θ n |

尽管这在概念上与岭回归非常相似,但结果可能出奇地不同。例如,由于其构造,套索回归倾向于偏爱可能的稀疏模型:也就是说,它更倾向于将许多模型系数设为零。

如果我们使用L 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')

output 35 0

图 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)

output 50 1

图 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);

output 60 0

图 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');

output 5 0

图 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);

output 7 0

图 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);

最大化这个边缘的线就是我们将选择作为最优模型的线。

output 10 0

图 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);

output 16 0

图 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))

output 20 0

图 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);

output 25 0

图 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');

output 29 0

图 43-7. 为数据添加的第三个维度允许线性分离

我们可以看到,通过这个额外的维度,数据变得简单线性可分,通过在 r=0.7 处绘制一个分离平面。

在这种情况下,我们不得不选择并仔细调整我们的投影:如果我们没有将我们的径向基函数放在正确的位置,我们就不会看到如此清晰、线性可分的结果。一般来说,需要做出这样的选择是一个问题:我们希望以某种方式自动找到最佳的基函数来使用。

实现这一目标的一种策略是计算数据集中每个点处的基函数,并让 SVM 算法筛选结果。这种类型的基函数转换被称为核变换,因为它是基于每对点之间的相似关系(或核)。

这种策略的一个潜在问题是——将 N 点投影到 N 维空间中可能会变得非常计算密集,当 N 变大时。然而,由于一个称为 核技巧 的巧妙小程序,对核变换数据的拟合可以隐式完成——也就是说,根本不需要构建核投影的完整 N -维表示。这个核技巧内置在 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');

output 33 0

图 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');

output 36 0

图 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)

output 38 0

图 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]])

output 43 0

图 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);

output 53 0

图 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');

output 57 0

图 43-13. 面部数据的混淆矩阵

这帮助我们了解哪些标签可能会被估计器混淆。

对于一个现实世界的人脸识别任务,在这种任务中照片并未预先裁剪成漂亮的网格,面部分类方案唯一的区别在于特征选择:您需要使用更复杂的算法来找到面部,并提取与像素化无关的特征。对于这种应用,一个好的选择是利用OpenCV,它包括对一般图像和特别是人脸的先前实现的最新特征提取工具。

摘要

这是支持向量机背后原理的简明直观介绍。这些模型由于以下几个原因而是一种强大的分类方法:

  • 它们依赖于相对较少的支持向量,因此紧凑且占用极少的内存空间。

  • 一旦模型训练完成,预测阶段非常快速。

  • 因为它们只受到靠近边界的点的影响,所以它们在处理高维数据时表现良好——即使是比样本更多维度的数据,这对其他算法来说是个挑战。

  • 它们与核方法的集成使它们非常灵活,能够适应许多类型的数据。

然而,支持向量机(SVMs)也有几个缺点:

  • 样本数量N的缩放为最坏情况下是𝒪 [ N 3 ],或者对于高效实现是𝒪 [ N 2 ]。对于大量的训练样本,这种计算成本可能是限制性的。

  • 结果强烈依赖于合适的软化参数C的选择。必须通过交叉验证仔细选择,随着数据集增大,这可能是昂贵的。

  • 结果没有直接的概率解释。可以通过内部交叉验证来估计(参见SVCprobability参数),但这额外的估计是昂贵的。

考虑到这些特性,我通常只有在其他更简单、更快速、不需要过多调整的方法被证明不足以满足我的需求时,才会转向支持向量机(SVM)。尽管如此,如果你有足够的 CPU 周期来进行数据训练和交叉验证 SVM,这种方法可以带来出色的结果。

第四十四章:深入探讨:决策树和随机森林

之前,我们深入研究了一个简单的生成分类器(朴素贝叶斯;见 第四十一章)和一个强大的判别分类器(支持向量机;见 第四十三章)。在这里,我们将看看另一种强大的算法:一个称为 随机森林 的非参数算法。随机森林是一种 集成 方法的示例,意味着它依赖于聚合一组更简单的估算器的结果。这样的集成方法的一个令人惊讶的结果是,总和可以大于各部分之和:也就是说,多个估算器之间的多数投票的预测准确度最终可能会比任何进行投票的单个估算器的准确度更高!我们将在以下部分看到这方面的例子。

我们从标准导入开始:

In [1]: %matplotlib inline
        import numpy as np
        import matplotlib.pyplot as plt
        plt.style.use('seaborn-whitegrid')

推动随机森林的动机:决策树

随机森林是建立在决策树上的集成学习器的一个例子。因此,我们将首先讨论决策树本身。

决策树是极其直观的分类或标记对象的方式:你只需提出一系列旨在对分类进行精准定位的问题。例如,如果你想构建一个用于对徒步时遇到的动物进行分类的决策树,你可以构建如 图 44-1 所示的决策树。

05.08 决策树

图 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');

output 8 0

图 44-2. 决策树分类器的数据

基于这些数据构建的简单决策树将根据某些定量标准迭代地沿着一个或另一个轴将数据分割,并在每个级别根据其中的点的多数投票确定新区域的标签。图 44-3 展示了此数据的决策树分类器的前四个级别的可视化。

05.08 决策树级别

图 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)

output 17 0

图 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 中,我们训练了两棵不同的树,每棵树都使用了原始数据的一半。

05.08 decision tree overfitting

图 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 文档和其中的参考文献中阅读有关这些随机化策略的更多技术细节。

output 28 0

图 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);

output 30 0

图 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');

output 33 0

图 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);

output 35 0

图 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]))

output 40 0

图 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');

我们发现,一个简单的未调整的随机森林能够对数字数据进行相当准确的分类。

output 46 0

图 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');

output 4 0

图 45-1. 主成分分析演示数据

从眼睛的角度来看,很明显xy变量之间存在近乎线性的关系。这让人想起我们在第 42 章中探索的线性回归数据,但这里的问题设置略有不同:与尝试从x值预测y值不同,无监督学习问题试图学习xy值之间的关系

在主成分分析中,通过找到数据中的一组主轴来量化这种关系,并使用这些轴来描述数据集。使用 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');

output 11 0

图 45-2. 数据中主轴的可视化

这些向量代表了数据的主轴,每个向量的长度表示描述数据分布中该轴的“重要性”的指标——更精确地说,它是数据在投影到该轴上时的方差的度量。将每个数据点投影到主轴上得到了数据的主成分。

如果我们将这些主成分与原始数据一起绘制,我们会看到在图 45-3 中显示的图形。

05.09 PCA rotation

图 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');

output 18 0

图 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();

output 25 0

图 45-5. 应用于手写数字数据的 PCA

回想一下这些组件的含义:完整的数据是一个 64 维点云,而这些点是每个数据点沿着最大方差方向的投影。基本上,我们在 64 维空间中找到了允许我们在两个维度上看到数据布局的最佳拉伸和旋转,而且这是无监督的方式完成的,即没有参考标签。

组件的含义是什么?

在这里,我们可以再进一步,开始问减少的维度意味着什么。这种意义可以用基向量的组合来理解。例如,训练集中的每个图像由一组 64 个像素值定义,我们将其称为向量x

x = [ x 1 , x 2 , x 3 x 64 ]

我们可以用像素基础来考虑这一点。也就是说,为了构建图像,我们将向量的每个元素乘以它描述的像素,然后将结果相加以构建图像:

image ( x ) = x 1 · ( pixel 1 ) + x 2 · ( pixel 2 ) + x 3 · ( pixel 3 ) x 64 · ( pixel 64 )

我们可以想象一种降低数据维度的方法是将除了少数基向量之外的所有值归零。例如,如果我们仅使用前八个像素,我们得到数据的八维投影(见图 45-6)。然而,这并不太反映整个图像:我们几乎丢弃了 90%的像素!

05.09 手写数字像素组件

图 45-6. 通过丢弃像素实现的天真降维^(2)

上排面板显示单独的像素,下排显示这些像素对图像构建的累积贡献。仅使用八个像素基础组件,我们只能构建 64 像素图像的一小部分。如果我们继续这个序列并使用所有 64 个像素,我们将恢复原始图像。

但像素级表示并不是唯一的基础选择。我们还可以使用其他基函数,每个基函数都包含来自每个像素的一些预定义贡献,并编写如下内容:

i m a g e ( x ) = mean + x 1 · ( basis 1 ) + x 2 · ( basis 2 ) + x 3 · ( basis 3 )

PCA 可以被看作是选择最优基函数的过程,使得仅添加前几个基函数就足以适当地重构数据集中的大部分元素。主成分作为我们数据的低维表示,实际上只是乘以这一系列中每个元素的系数。图 45-7 展示了使用平均值加上前八个 PCA 基函数重建相同数字的类似描述。

05.09 手写数字 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');

此曲线量化了在前N个组件中包含的总 64 维方差的比例。例如,我们看到对于数字数据,前 10 个组件包含大约 75%的方差,而您需要约 50 个组件来描述接近 100%的方差。

output 34 0

图 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)

output 37 0

图 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)

output 40 0

图 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)

output 44 0

图 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"特征求解器:它使用随机方法来更快地近似前N个主成分,而不是标准方法,以牺牲一些准确性。这种权衡在高维数据(这里接近 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')

output 51 0

图 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');

output 53 0

图 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');

output 56 0

图 45-14. LFW 数据的 150 维 PCA 重建

这里的顶部行显示了输入图像,而底部行显示了仅从约 3000 个初始特征中的 150 个进行图像重建。这种可视化清楚地说明了 PCA 特征选择在第四十三章中为何如此成功:虽然它将数据的维度减少了近 20 倍,但投影图像包含足够的信息,使我们可以通过肉眼识别每个图像中的个体。这意味着我们的分类算法只需要在 150 维数据上进行训练,而不是 3000 维数据,根据我们选择的特定算法,这可能会导致更高效的分类。

摘要

在本章中,我们探讨了主成分分析在降维、高维数据可视化、噪声过滤和高维数据特征选择中的应用。由于其多功能性和可解释性,PCA 已被证明在各种背景和学科中都非常有效。对于任何高维数据集,我倾向于从 PCA 开始,以便可视化数据点之间的关系(就像我们在数字数据中所做的那样),理解数据中的主要方差(就像我们在特征脸中所做的那样),并理解内在的维度(通过绘制解释方差比)。当然,PCA 并非对每个高维数据集都有用,但它提供了一条直观和高效的路径,以洞察高维数据。

PCA 的主要弱点是它往往受到数据中异常值的影响。因此,已经开发了几种鲁棒性较强的 PCA 变体,其中许多变体通过迭代地丢弃初始组件描述不佳的数据点来作用。Scikit-Learn 在sklearn​.decom⁠position子模块中包括了许多有趣的 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”形状的点组成。这种数据形式将帮助我们直观地了解这些算法的作用。

output 6 0

图 46-1. 用于流形学习的数据

多维尺度法

查看这样的数据,我们可以看到数据集中特定的xy值并不是数据最基本的描述:我们可以缩放、收缩或旋转数据,而“HELLO”仍然是显而易见的。例如,如果我们使用旋转矩阵旋转数据,xy值会改变,但数据基本上仍然是相同的(参见图 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');

output 9 0

图 46-2. 旋转的数据集

这证实了xy值不一定是数据关系中的基本要素。在这种情况下,基本的是数据集中每个点之间的距离。表示这一点的常见方法是使用距离矩阵:对于N个点,我们构造一个N × N数组,使得条目( i , j )包含点i和点j之间的距离。让我们使用 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();

output 13 0

图 46-3. 点之间的成对距离可视化

如果我们类似地为我们旋转和平移的数据构建一个距离矩阵,我们会看到它是相同的:

In [7]: D2 = pairwise_distances(X2)
        np.allclose(D, D2)
Out[7]: True

这个距离矩阵给出了一个与旋转和平移无关的数据表示,但在图 46-3 中的矩阵可视化并不完全直观。在那里显示的表示中,我们失去了数据中有趣结构的任何可见迹象:“HELLO”。

此外,从(x, y)坐标计算距离矩阵很简单,但将距离转换回xy坐标却相当困难。这正是多维缩放算法的目标:给定点之间的距离矩阵,它恢复数据的D维坐标表示。让我们看看它如何处理我们的距离矩阵,使用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');

output 17 0

图 46-4. 从成对距离计算得到的 MDS 嵌入

MDS 算法使用描述数据点之间关系的N × N距离矩阵之一,仅恢复了我们数据的可能的二维坐标表示。

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);

output 22 0

图 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 的情况下,保持的量是每对点之间的距离。

output 24 0

图 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);

output 29 0

图 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');

output 31 0

图 46-8. 应用于非线性数据的 MDS 算法;它未能恢复底层结构

最佳的二维线性嵌入并没有展开 S 形曲线,而是丢弃了原始的 y 轴。

非线性流形:局部线性嵌入

在这里我们该怎么继续?退一步来看,我们可以看到问题的根源在于,MDS 试图在构建嵌入时保持远距离点之间的距离。但如果我们改变算法,使其仅保持附近点之间的距离会怎样?结果的嵌入会更接近我们想要的。

从视觉上看,我们可以将其想象成如图 46-9 所示。

这里每条淡淡的线表示一个应该在嵌入中保留的距离。左侧是 MDS 使用的模型的表示:它试图保持数据集中每对点之间的距离。右侧是一种名为局部线性嵌入的流形学习算法使用的模型的表示:它不是保留所有距离,而是试图仅保留相邻点之间的距离(在这种情况下,每个点的最近 100 个邻居)。

考虑左侧面板,我们可以看到为什么 MDS 失败了:没有办法展开这些数据同时充分保持两点之间绘制的每条线的长度。另一方面,对于右侧面板,情况看起来更加乐观。我们可以想象以一种方式展开数据,以保持线的长度大致相同。这正是 LLE 通过全局优化反映这种逻辑的成本函数所做的。

05.10 LLE vs MDS

图 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);

与我们原始流形相比,结果仍然有些扭曲,但捕捉到了数据中的基本关系!

output 36 0

图 46-10. 局部线性嵌入可以从非线性嵌入的输入中恢复底层数据

对流形方法的一些思考

尽管这些示例可能很引人注目,但在实践中,流形学习技术往往很难处理,因此很少被用于除了高维数据的简单定性可视化之外的任何其他用途。

以下是流形学习的一些特定挑战,这些挑战都与 PCA 相比非常不利:

  • 流形学习中,没有处理缺失数据的良好框架。相比之下,在 PCA 中有直接的迭代方法来处理缺失数据。

  • 在流形学习中,数据中的噪声存在可以“短路”流形并显著改变嵌入的情况。相比之下,PCA 自然地从最重要的组件中过滤噪声。

  • 流形嵌入结果通常高度依赖于选择的邻居数,并且通常没有一种可靠的定量方法来选择最佳邻居数。相比之下,PCA 不涉及这样的选择。

  • 在流形学习中,确定全局最佳输出维度的难度很大。相比之下,PCA 可以根据解释的方差来确定输出维度的数量。

  • 在流形学习中,嵌入维度的含义并不总是清晰的。在 PCA 中,主成分有一个非常明确的含义。

  • 在流形学习中,流形方法的计算开销按O [ N 2 ]O [ N 3 ] 的方式扩展。对于 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')

output 43 0

图 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');

output 45 0

图 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])

output 51 0

图 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')

output 56 0

图 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);

output 58 0

图 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')

output 60 0

图 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);

output 5 0

图 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 的典型方法涉及一种直观的迭代方法,称为期望最大化。

output 9 0

图 47-2:带有颜色指示簇的 k-means 簇中心

期望最大化

期望最大化(E–M)是数据科学中多种情境中的一个强大算法。k-means 是该算法的一个特别简单且易于理解的应用;我们将在这里简要介绍它。简而言之,在这里期望最大化方法包括以下步骤:

  1. 猜测一些聚类中心。

  2. 直到收敛重复:

    1. E 步:将点分配给最近的聚类中心。

    2. M 步:将聚类中心设置为其分配点的平均值。

这里的 E 步期望步骤 之所以这样命名,是因为它涉及更新我们对每个点属于哪个聚类的期望。M 步最大化步骤 之所以这样命名,是因为它涉及最大化某些定义聚类中心位置的适应函数——在本例中,通过简单地取每个聚类中数据的平均值来实现该最大化。

关于这一算法的文献非常丰富,但可以总结如下:在典型情况下,每次 E 步和 M 步的重复都会导致对聚类特征的更好估计。

我们可以将算法可视化如图 Figure 47-3 所示。对于此处显示的特定初始化,聚类在仅三次迭代中收敛。(有关此图的交互版本,请参阅在线 附录 中的代码。)

05.11 expectation maximization

图 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');

output 15 0

图 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');

output 19 0

图 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');

output 22 0

图 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');

output 26 0

图 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');

output 28 0

图 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)

output 37 0

图 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');

output 43 0

图 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);

output 48 0

图 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')

output 55 0

图 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")

output 57 0

图 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);

output 59 0

图 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');

output 5 0

图 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)

output 8 0

图 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)

output 10 0

图 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');

output 13 0

图 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);

output 17 0

图 48-5。GMM 概率标签:点的大小显示概率大小

在幕后,高斯混合模型与k-means 非常相似:它使用期望最大化方法,大致如下:

  1. 选择位置和形状的初始猜测。

  2. 直到收敛为止重复:

    1. E 步骤:对于每个点,找到编码每个聚类成员概率的权重。

    2. 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)

output 21 0

图 48-6. 存在圆形聚类的四分量 GMM

同样地,我们可以使用 GMM 方法拟合我们的伸展数据集;允许完全协方差模型将适合甚至是非常椭圆形、拉伸的聚类,正如我们在图 48-7 中所看到的。

In [12]: gmm = GaussianMixture(n_components=4, covariance_type='full',
                               random_state=42)
         plot_gmm(gmm, X_stretched)

output 23 0

图 48-7. 存在非圆形聚类的四分量 GMM

这清楚地表明,GMM 解决了之前在k-means 中遇到的两个主要实际问题。

选择协方差类型

如果您查看前面拟合的细节,您会发现在每个拟合中设置了covariance_type选项。该超参数控制每个聚类形状的自由度;对于任何给定的问题,仔细设置这一点至关重要。默认值是covariance_type="diag",这意味着可以独立设置每个维度上的聚类大小,生成的椭圆受限于与轴对齐。covariance_type="spherical"是一个稍微简单且更快的模型,它限制了聚类形状,使得所有维度相等。结果聚类将具有与k-means 类似的特征,尽管它并非完全等价。一个更复杂和计算开销更大的模型(特别是在维度增长时)是使用covariance_type="full",它允许将每个聚类建模为带有任意方向的椭圆。图 48-8 表示了这三种选择对单个聚类的影响。

05.12 协方差类型

图 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]);

output 28 0

图 48-9. GMM 应用于具有非线性边界的聚类

如果我们尝试用一个两组分的 GMM 作为聚类模型来拟合它,结果并不特别有用(见图 48-10)。

In [14]: gmm2 = GaussianMixture(n_components=2, covariance_type='full',
                                random_state=0)
         plot_gmm(gmm2, Xmoon)

output 30 0

图 48-10. 对非线性聚类拟合的两组分 GMM

但是,如果我们使用更多组分并忽略聚类标签,我们会发现拟合结果更接近输入数据(见图 48-11)。

In [15]: gmm16 = GaussianMixture(n_components=16, covariance_type='full',
                                 random_state=0)
         plot_gmm(gmm16, Xmoon, label=False)

output 32 0

图 48-11. 使用多个 GMM 组件来建模点分布

这里的 16 个高斯分量的混合并不是为了找到数据的分离聚类,而是为了对输入数据的整体分布进行建模。这是一个生成模型,意味着 GMM 给了我们一个生成新随机数据的方法,其分布类似于我们的原始输入数据。例如,这里有 400 个新点从这个 16 组分的 GMM 拟合到我们的原始数据中绘制出来(见图 48-12)。

In [16]: Xnew, ynew = gmm16.sample(400)
         plt.scatter(Xnew[:, 0], Xnew[:, 1]);

output 34 0

图 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');

output 37 0

图 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)

output 42 0

图 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);

output 46 0

图 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)

output 52 0

图 48-16. 从 GMM 估计器的基础模型中随机绘制的“新”数字

大多数结果看起来像数据集中合理的数字!

考虑我们在这里所做的:鉴于手写数字的抽样,我们已经模拟了该数据的分布,以便我们可以从数据中生成全新的样本:这些是“手写数字”,它们不会单独出现在原始数据集中,而是捕捉了混合模型建模的输入数据的一般特征。这样的手写数字的生成模型在贝叶斯生成分类器的组成部分中可以非常有用,这一点我们将在下一章看到。

^(1) 生成此图的代码可以在在线附录中找到。

第四十九章:深入探讨:核密度估计

在第四十八章中,我们讨论了高斯混合模型,这是一种聚类估计器和密度估计器之间的混合类型。回想一下,密度估计器是一种算法,它接受一个D维数据集,并生成数据抽取自其中的D维概率分布的估计。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)

output 6 0

图 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)

output 11 0

图 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)

output 13 1

图 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]);

output 15 0

图 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]);

output 17 0

图 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);

output 20 0

图 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 等密度估计算法,我们可以去除“朴素”元素,并使用更复杂的生成模型为每个类别执行相同的分类。它仍然是贝叶斯分类,但不再是朴素的。

生成分类的一般方法如下:

  1. 根据标签将训练数据进行拆分。

  2. 对每个集合,拟合一个 KDE 以获得数据的生成模型。这允许你对于任意观测值x和标签y,计算出一个似然概率P ( x | y )

  3. 根据训练集中每个类别的示例数量,计算类先验P ( y )

  4. 对于未知点x,每个类别的后验概率为P ( y | x ) P ( x | y ) P ( y )。最大化这个后验概率的类别是分配给该点的标签。

算法很简单直观易懂;更难的部分是将其嵌入 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

output 37 1

图 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 是一个简单直接的特征提取过程,最初用于图像中行人的识别。它包括以下步骤:

  1. 可选择地对图像进行预归一化。这导致特征对照明变化的依赖性较小。

  2. 将图像与两个对水平和垂直亮度梯度敏感的滤波器卷积。这些捕捉边缘、轮廓和纹理信息。

  3. 将图像细分为预定大小的单元格,并计算每个单元格内梯度方向的直方图。

  4. 通过与相邻单元格块比较来归一化每个单元格中的直方图。这进一步抑制了整个图像中照明效果的影响。

  5. 从每个单元格中的信息构建一个一维特征向量。

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');

output 4 0

图 50-1。从图像计算的 HOG 特征的可视化

HOG 在行动:一个简单的人脸检测器

利用这些 HOG 特征,我们可以使用任何 Scikit-Learn 评估器构建一个简单的面部检测算法;在这里,我们将使用线性支持向量机(如果需要恢复记忆,请参阅 第四十三章)。具体步骤如下:

  1. 获得一组人脸缩略图,作为“正”训练样本。

  2. 获得一组非面部图像缩略图,作为“负”训练样本。

  3. 从这些训练样本中提取 HOG 特征。

  4. 在这些样本上训练线性 SVM 分类器。

  5. 为“未知”图像,通过图像上的滑动窗口,使用模型评估该窗口是否包含面部。

  6. 如果检测重叠,将它们合并成一个单一窗口。

让我们按照这些步骤进行并尝试一下。

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')

我们希望这些样本足以覆盖算法可能见到的“非面部”空间。

output 14 0

图 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');

output 28 0

图 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'))

output 34 0

图 50-4. 被确定包含面部的窗口。

所有检测到的补丁都重叠并找到了图像中的面部!对于几行 Python 代码来说效果不错。

注意事项和改进

如果你深入研究前面的代码和示例,你会发现在我们宣称拥有一个可以投入生产的人脸检测器之前,我们还有一些工作要做。我们做的工作存在几个问题,也可以做出几个改进。特别是:

我们的训练集,特别是负面特征,不太完整。

中心问题在于训练集中有许多像面部的纹理,而我们当前的模型非常容易产生假阳性。如果你尝试在完整的宇航员图像上运行算法,就会发现这一点:当前的模型在图像的其他区域导致了许多误检测。

我们可以通过向负训练集添加更多类型的图像来解决这个问题,这可能会带来一些改善。另一种选择是使用更有针对性的方法,如硬负样本挖掘,我们采用一组分类器尚未见过的新图像,找到所有表示假阳性的补丁,并明确将它们作为负实例添加到训练集中,然后重新训练分类器。

我们当前的流水线仅在一个尺度上进行搜索。

按照当前的写法,我们的算法会漏掉不是大约 62 × 47 像素的面部。可以通过使用各种大小的滑动窗口,并在将每个补丁输入模型之前使用skimage.transform.resize来直接解决这个问题。事实上,这里使用的sliding_window工具已经考虑到了这一点。

我们应该结合重叠的检测补丁。

对于一个可投入生产的流水线,我们更希望不要有 30 个相同人脸的检测结果,而是将重叠的检测组减少到一个单独的检测结果。这可以通过无监督的聚类方法(均值漂移聚类是一个很好的选择),或者通过类似于非最大抑制这样的程序化方法来实现,这是机器视觉中常见的一种算法。

流水线应该被简化

一旦解决了上述问题,创建一个更简化的流水线来输入训练图像并预测滑动窗口输出也将是一个不错的选择。这就是 Python 作为数据科学工具的优势所在:通过一点工作,我们可以将我们的原型代码打包成一个设计良好的面向对象的 API,让用户能够轻松地使用它。我将把这留给读者作为一个“练习题”。

更近期的进展:深度学习

最后,我应该补充说,在机器学习环境中,HOG 和其他程序化特征提取方法并不总是被使用。相反,许多现代目标检测流水线使用深度神经网络的变体(通常称为深度学习):一个思考神经网络的方式是将其视为从数据中确定最佳特征提取策略的估计器,而不是依赖用户的直觉。

虽然近年来该领域取得了巨大的成果,但深度学习在概念上与前几章中探讨的机器学习模型并没有太大的不同。主要进步在于利用现代计算硬件(通常是大型强大机器集群)在更大的训练数据集上训练更加灵活的模型。但尽管规模不同,最终目标仍然是非常相似的:从数据中构建模型。

如果你对更深入的了解感兴趣,以下部分的参考文献清单应该是一个很好的起点!

更多机器学习资源

本书的这一部分快速介绍了 Python 中的机器学习,主要使用了 Scikit-Learn 库中的工具。尽管这些章节很长,但仍然太短,无法涵盖许多有趣和重要的算法、方法和讨论。在这里,我想为那些有兴趣的人提供一些关于在 Python 中学习更多关于机器学习的资源:

Scikit-Learn 网站

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 库

posted @ 2024-06-17 17:11  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报