面向科学家的-Python-工具指南-全-

面向科学家的 Python 工具指南(全)

原文:zh.annas-archive.org/md5/89b6b137b84ef92e7564baffcbec099d

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

image

本书面向那些希望在工作中使用 Python 编程语言的科学家和初学者。它讲解了 Python 的基础,并展示了访问 Python 科学库宇宙的最简单、最流行的方法,介绍了记录工作的首选方式,以及如何保持各种项目的独立性和安全性。

作为一种成熟、开源且易于学习的语言,Python 拥有庞大的用户基础和一个热情的社区,愿意帮助您发展技能。这个用户基础为科学事业(如数据科学、机器学习、语言处理、机器人技术、计算机视觉等)贡献了丰富的工具和支持库(预编译程序集合)。因此,Python 已成为学术界和工业界最重要的科学计算语言之一。

然而,流行度带来了代价。Python 生态系统正变成一片难以穿透的丛林。事实上,本书源自与企业界科学同事的对话。他们刚接触 Python,感到沮丧、压力山大,并且深受分析瘫痪的困扰。在每一个转折点,他们都觉得必须做出关键而困难的决策,比如选择哪个库来绘制图表,选择哪个文本编辑器来编写程序。他们没有时间或兴趣去学习多个工具,因此他们希望选择对未来影响最小的选项。

本书旨在解决这些问题。其目标是帮助您尽快且无痛地开始科学计算。可以把它看作是通过 Python 的发行版、工具和库的密林开路的砍刀(图 1)。

Image

图 1:在 Python 丛林中开路

为了实现这一目标,我将帮助您做出一些决策。由于每个人的需求都是独特的,这些决策不一定是完美的,但它们应该代表明智的、不会后悔的选择,为您在积累更多经验后自定义设置打下基础。

首先,您将使用免费的Anaconda 发行版的 Python。作为最受欢迎的 Python 发行平台,它在全球拥有超过 3000 万用户。由 Anaconda 公司提供(www.anaconda.com/),它是 Python 数据科学的首选平台。Anaconda 将使安装 Python、设置计算环境,并保持其有序和及时更新变得简单。

请注意,本书是为那些编写个人使用或团队使用脚本的科学家而设计的。它不针对专业的软件开发人员或从事企业软件开发的工程师。此外,本书仅涉及免费和开源软件。您的工作场所可能使用的专有或商业库可能会取代这里列出的库。

最后,这本书不会教你如何科学、如何进行数据分析,或者你的工作内容。它不会教你如何使用操作系统,也不会提供每个重要科学库的详细使用说明。每个领域都需要大量的专门书籍,你可以在书店或在线找到这些资源。相反,本书将介绍一些适用于广泛科学领域的基本工具和库,帮助你安装它们,并帮助你开始使用它们。希望它能减少你在为科学设置和使用 Python 时的压力。

为什么选择 Python?

因为你正在阅读这本书,你可能已经决定使用 Python 了。然而,如果你还在考虑中,我们来看看一些选择 Python 进行科学编程的理由。否则,欢迎直接跳到下一节“阅读本书指南”,请参考第 xxvii 页。

Python 的设计理念强调简洁性、可读性和灵活性。这些优先事项使得它在研究和科学工作中的各个阶段都很有用,包括一般计算、实验设计、设备接口构建、连接和控制多个硬件/软件工具、重型数字运算以及数据分析和可视化。让我们来看看 Python 的一些关键特点,以及它们为何是科学领域的重要卖点:

免费和开源: Python 是开源的,这意味着原始源代码是自由提供的,任何人都可以重新分发和修改它。它由一支志愿者团队不断开发,并由非营利的 Python 软件基金会管理(www.python.org/)。开源软件的一个强大优点是它被强化过;也就是说,它被大量活跃的用户群体修复了漏洞和其他问题。此外,这些用户通常会发布并分享他们的代码,以便整个社区可以获得最新的技术。不过,开源软件也有缺点,它可能更容易受到恶意用户的攻击,使用起来不如商业软件友好,而且文档和支持可能不如商业替代品完善。

高级语言: Python 是一种高级编程语言。这意味着计算系统中的重要部分,如内存管理,已经自动化并对用户隐藏。因此,Python 的语法非常易于人类阅读,使其容易学习和使用。

解释型: Python 是一种解释型语言,这意味着它会立即执行指令——类似于在电子表格中应用计算——无需编译代码。这为你提供即时反馈,使 Python 高度互动,并帮助你在错误发生时立即捕捉到。然而,与像 Java 和 C++这样的编译型语言相比,它确实会使语言的执行速度变慢。

平台中立: Python 可以在 Windows、macOS 和 Linux/Unix 上运行,并且有适用于 Android 和 iOS 的应用程序。

广泛的支持和共享学习: 数百万开发者为 Python 提供了强大的支持系统。得益于这个庞大的社区,所有主要的 Python 产品都包括在线文档,你可以通过免费和付费的在线支持网站和教程轻松找到帮助和指导。同样,近年来与 Python 相关的印刷和电子书数量激增,涵盖了从初学者到高级用户的广泛主题。

Python 的帮助性用户群体非常重要,因为编程的关键不在于记住所有命令,而在于 理解你想做什么。你会在在线搜索引擎上花费和在 Python 上花费同样多的时间,知道如何构造一个 任务特定 的问题(例如“如何在 OpenCV 中将文本添加到图像上?”)将成为一项必备技能(图 2)。

Image

图 2:程序员的秘密生活

更受欢迎的支持网站之一是 Stack Overflow (stackoverflow.com/)。在许多情况下,你会发现你的问题已经得到了回答。如果没有,记得先参加导览 (stackoverflow.com/tour/),并访问 提问 部分 (stackoverflow.com/help/asking/),了解发布问题的正确方式。

你还可以找到专门为特定学科使用 Python 的网站。例如,天文学家实用 Python (python4astronomers.github.io/) 是一个对天文学家有用的网站,而 Analytics Vidhya (www.analyticsvidhya.com/) 则是为数据科学家设计的。

附带电池: Python 的座右铭是“附带电池”,意味着它提供了所有实现完全可用性所需的部分。除了一个包含有用工具的大型 标准库,Python 还可以轻松地从各种第三方库中进行升级。这些库是由某一领域的专家编写并测试的 Python 程序,你可以将它们应用到自己的工作中。一些例子包括用于处理图像和视频数据的 OpenCV,机器学习项目中的 TensorFlow,以及用于生成图表和图形的 Matplotlib。这些库将大大减少你编写代码的量,从而进行实验、分析和可视化数据、设计仿真,并完成项目。

可扩展性: Python 可以轻松处理科学和工程中常用的大型数据集。你主要的限制因素将是计算机的处理速度和内存。相比之下,Microsoft Excel 电子表格在处理仅数万条数据时就会出现速度和稳定性问题。随着电子表格数量的增加,复杂的 Excel 项目变得非常脆弱,导致错误难以识别、查找和修复。

Python 支持过程式编程和面向对象编程,可以帮助你编写清晰、逻辑的代码,适用于小型和大型项目。Python 还会在错误发生时立即捕捉到这些错误。

灵活性: Python 能够处理多种数据格式,并且能够运行科学实验和数据采集所需的仪器和传感器。作为一种“胶水”语言,它容易与 C、C++和 FORTRAN 等低级语言进行集成,并且对于连接多个脚本或系统(包括数据库和 Web 服务)也非常有用。大量的第三方库使得 Python 能够扩展到许多任务。

本书导航

本书设计面向真正的初学者,也适合那些熟悉 Python,但对 Anaconda 或一些常用的科学库不熟悉的人。它的目标是提供“一站式购物”,帮助你掌握足够的知识,以便开始使用数据并编写自己的程序。

真正的初学者如果希望快速入门 Python,应该首先阅读图 3 框中所示的章节,然后返回第一部分,完成第五章和第六章。

Image

图 3:学习 Python 的快速通道

更有经验的用户可能希望跳过某些部分(例如,省略 Python 入门)。考虑到这一点,这里有一本书内容的简短概述。

第一部分:设置你的科学编码环境

第一部分提供了如何安装、启动和使用 Anaconda 的说明,并介绍了如何使用 conda 包管理器,这是一个在 Windows、macOS 和 Linux 上运行的开源包和环境管理系统。此外,你将了解 Shell、解释器、文本编辑器、笔记本和集成开发环境(IDE)的世界,包括何时以及为什么需要它们。第一部分包括以下章节:

第一章,安装和启动 Anaconda: 如何在 Windows、macOS 和 Linux 上安装 Anaconda,然后带你浏览 Anaconda Navigator 图形用户界面(GUI)和替代的基于终端的命令提示符。

第二章,使用 Conda 环境保持组织性: 介绍了虚拟环境的概念,它们让你能够隔离项目并使用不同版本的 Python 及其科学库。你将设置你的第一个conda 环境,这是一个包含特定版本 Python 的目录,你将安装一组特定的 conda 包。这将帮助你保持项目的有序,并防止不同版本的 Python 和/或各种库之间的冲突。

第三章,在 Jupyter Qt 控制台中进行简单脚本编写: 介绍了Jupyter (IPython) Qt 控制台,这是一个轻量级界面,适用于交互式编码、快速概念测试和数据探索。

第四章,使用 Spyder 进行严肃的脚本编写: 介绍了Spyder,Anaconda 中包含的科学 Python 开发环境。Spyder 是由科学家、工程师和数据分析师设计的,提供了一个全面开发工具的高级编辑、分析、调试和性能分析功能,以及一个科学应用程序的数据探索、交互式执行、深度检查和可视化能力。如果你是 Python 的完全新手,可以跳到第二部分,在这里你将使用该工具和 Qt 控制台学习 Python 基础知识。

第五章,Jupyter Notebook:计算研究的交互式日志: 介绍了Jupyter (IPython) Notebook,一个基于网络的交互式计算平台,结合了实时代码、方程式、描述性文本、交互式可视化和其他类型的媒体。在 Jupyter 中编写的程序可以在本地进行广泛的文档化,并转化为可发布的文章、交互式仪表板和演示质量的幻灯片。

第六章,JupyterLab:你的科学中心: 介绍了JupyterLab,一个基于 Web 的 Jupyter 笔记本、代码和数据的交互式开发环境。JupyterLab 灵活的界面可以配置以支持数据科学、科学计算和机器学习中的各种工作流。事实上,如果你是数据科学家,你可能会在这里度过你大部分的科学计算“时光”。

第二部分:Python 入门

第二部分是对 Python 编程语言的简要介绍。如果你已经熟悉基础内容,可以跳过这一部分,仅在需要时作为参考。第二部分包括以下章节:

第七章,整数、浮点数和字符串: 介绍了 Python 的一些基本数据类型、运算符和错误信息。

第八章,变量: 介绍了变量和变量命名约定。

第九章,容器数据类型: 介绍了 Python 的元组、列表、集合和字典数据类型。

第十章,流程控制: 介绍了流程控制语句、行结构以及处理异常(错误)的方法。

第十一章,函数与模块: 介绍了重要的概念,如抽象和封装,用于使程序更易于阅读和维护。

第十二章,文件与文件夹: 介绍了用于处理文件、文件夹和目录路径的模块和函数。

第十三章,面向对象编程: 介绍了面向对象编程(OOP)的基础知识,OOP 有助于使程序更容易维护和更新。

第十四章,文档化你的工作: 介绍了代码文档化的最佳实践。

第三部分:Anaconda 生态系统

第三部分介绍了 Anaconda Python 生态系统,并包括对许多重要科学和可视化库的高层次总结,如 NumPy、pandas 和 Matplotlib,以及如何在众多选项中进行选择。第三部分包括以下章节:

第十五章,科学库: 概述了按功能分组的核心科学库,如数据分析、机器学习、语言处理、计算机视觉、深度学习等。提供了在多个竞争库中进行选择的指南,并讨论了处理非常大数据集的方法和库。

第十六章,信息可视化、科学可视化与仪表盘库: 概述了用于绘制统计数据和三维数据以及生成仪表盘的最重要库。提供了在多个竞争库中进行选择的指南。

第十七章,GeoVis 库: 概述了用于绘制地理空间数据的最重要库。提供了在多个竞争库中进行选择的指南。

第四部分:基础库

第四部分将介绍如何使用 NumPy、Matplotlib 和 pandas——Python 科学库的“三大主力”。这些库是非常重要且广泛使用的,许多其他库都是基于它们构建的。第四部分包括以下章节:

第十八章,NumPy:数值 Python: 介绍了NumPy,这是 Python 中用于数学计算的模块。许多有用的科学库,如 pandas 和 Matplotlib,都是基于 NumPy 构建的。本节将介绍一些关键概念和基础功能。

第十九章,解密 Matplotlib: 介绍了 Matplotlib 的基础知识,Matplotlib 是 Python 中绘图的祖宗,包括一些更为混乱的方面。

第二十章,pandas,seaborn 和 scikit-learn: 介绍了 pandas,这是一个为数据加载、处理和分析而设计的 Python 库。它提供了用于处理数值表格和时间序列的各种数据结构和操作,并包括数据可视化功能。本章围绕一个机器学习分类问题展开,问题中还涉及 seaborn(用于简化 Matplotlib 绘图)和 scikit-learn(用于构建预测模型)。

第二十一章,使用 Python 和 Pandas 管理日期和时间: 介绍了在原生 Python 和 pandas 中处理日期和时间的方法。

附录

附录提供了全书“测试你的知识”挑战的答案。

更新与勘误

本书可能会有多个印刷版本,您可以在 www.nostarch.com/python-tools-scientists 上查看任何更新或修正。如果您发现任何拼写错误或其他问题,请通过 errata@nostarch.com 报告给我们。请确保提供书名和受影响的页码(电子书读者应注明章节和小节)。

由于 Python、Anaconda 和科学库不断发展,我会在适当的地方提供它们的官方网站链接,以便您随时获得关于这些产品的最新信息。

留下评论

如果您觉得本书有帮助,请花时间留下在线评论,即使只是简单的星级评分。您的公正意见将帮助其他用户在越来越拥挤的 Python 编程书籍市场中做出选择。

第一章:安装和启动 Anaconda**

image

Anaconda,全球最受欢迎的数据科学平台,提供了大量常用科学库的访问权限。本章将引导你完成在 Windows、macOS 和 Linux 上安装 Anaconda 的过程。为了验证安装,你将启动 Navigator,这是 Anaconda 的图形用户界面(GUI),并快速浏览其功能。

关于 Anaconda

除了其他功能外,Anaconda 包括帮助你编写代码并处理数据集的工具;Python 语言本身;称为的预编写程序集合;Navigator GUI;以及Nucleus,一个社区学习和共享资源。许多这些内容,如图 1-1 所示,由其他组织创建和维护,并通过 Anaconda 分发。

Image

图 1-1:Anaconda 的关键组件

如果你是编程新手,可能对包(packages)这个概念不太熟悉。包是模块的集合,模块是执行某些任务的单一程序,其他程序可以使用这些任务。例如,一个模块可能加载图像并将其从彩色转换为灰度。另一个模块可能会调整或裁剪图像。这些图像处理模块中的几个可能被组合在一起形成一个包,而多个包则形成一个(图 1-2)。例如,OpenCV 计算机视觉库包括执行简单图像处理的包,还有处理流视频的包,以及执行机器学习任务(如人脸检测)的包。

Image

图 1-2:模块、包和库的定义

不幸的是,术语模块经常被交替使用,以至于它们几乎可以指同一事物。更糟糕的是,还可以指一个分发单元,能够与社区共享,且可以包含一个库、一个可执行文件或两者。因此,你不必太纠结于这些定义。

许多随 Anaconda 一起发布的科学包需要许多依赖项(其他支持包的特定版本)才能运行。它们可能还需要特定版本的 Python。为了防止不同的 Python 安装和其他包互相干扰并导致故障,并保持它们的最新状态,Anaconda 使用名为conda的二进制包和环境管理器。你可以使用 conda 从 Anaconda 公共仓库安装成千上万的包。此外,还有成千上万的包来自社区频道,例如 conda-forge。这些包是 Anaconda 自动安装的几百个包之外的额外内容。

Conda 会确保每个库的所有必要依赖项都已安装,节省你大量的麻烦。如果缺少某个依赖项,它也会提醒你。最后,为了防止各种包发生冲突,conda 让你创建conda 环境,这些环境是你科学项目的安全、隔离的实验室。conda 环境中的包不会与其他位置的包产生干扰,且当你分享一个环境时,你可以确保所有必要的包都已包含。你将在第二章中学习如何创建 conda 环境。

当你下载 Anaconda 时,你将可以访问Anaconda.org,这是一个包管理系统,使你能够轻松地查找、访问、存储和共享公共笔记本、环境、数据库以及 conda 和 Python 包索引(PyPI)中的包。你可以使用它在云端共享你的工作,或搜索并下载流行的 Python 包和笔记本。你还可以使用 conda-build 构建新的 conda 包,然后将它们上传到云端与他人共享(或者随时从任何地方访问它们)。

Anaconda 由 Anaconda 公司开发和维护。除了我们将使用的免费 Anaconda 发行版(以前称为Anaconda 个人版),该公司还提供商业版本。你可以在docs.anaconda.com/anacondaorg/找到所有版本的官方文档。Anaconda 也是 R 编程语言的一个发行版,conda 为 Ruby、Lua、Scala、Java、JavaScript、C/C++、FORTRAN 等语言提供包、依赖项和环境管理。然而,在本书中,我们将专注于其在 Python 中的使用。

你需要大约 5GB 的可用硬盘空间来安装 Anaconda。否则,你需要安装 Miniconda,这是一个最小化安装,要求大约 400MB,并且附带 Python,但不包括其他预安装的库。安装 Anaconda 之前,也不需要卸载任何现有的 Python 安装或包。

如果你遇到问题,请参阅故障排除指南docs.anaconda.com/anaconda/user-guide/troubleshooting/和常见问题解答docs.anaconda.com/anaconda/user-guide/faq/。如果你遇到指令上的差异,请按照安装向导中的指示操作。

在 Windows 上安装 Anaconda

你可以在 docs.anaconda.com/anaconda/install/windows/ 找到官方的 Windows 安装说明。第一步是下载 Anaconda 安装程序。你可能需要选择 32 位或 64 位安装程序。除非你的电脑非常老旧,否则你应该选择 64 位版本。如果不确定,可以通过进入 设置系统关于 来验证你的系统类型。

点击安装程序时,会将一个 .exe 文件下载到你的 下载 文件夹中(这可能需要几分钟时间)。此时,你可以选择使用 SHA-256 校验和 来检查安装程序的完整性,校验和是一种数学算法,用于检查文件是否损坏。将新生成的校验和与提前生成的校验和进行比较,可以检测在数据传输过程中是否出现错误。如果选择运行校验和,请参阅 docs.anaconda.com/anaconda/install/hashes/ 中的说明。

要开始安装,请右键单击下载的 .exe 文件,并从弹出窗口中选择 以管理员身份运行 选项。以管理员身份,你将拥有在系统中任何位置安装 Anaconda 的权限。安装程序会请求你允许其对电脑进行更改。点击 。安装向导现在应该出现。点击 下一步,然后同意许可协议。

下一窗口会要求你选择安装类型。选择推荐的 仅限我 选项,然后点击 下一步。接下来,系统会要求你选择安装位置。安装程序会建议在 *C:* 驱动器下你的用户名文件夹中创建一个文件夹。请注意,此路径应仅包含 7 位 ASCII 字符(数字、字母和某些符号),并且不能包含空格。记下这个默认位置,然后点击 下一步

在“高级安装选项”窗口中,将 Anaconda 设置为默认 Python,并且不要将其添加到 PATH 中。这是推荐的做法。这意味着你需要通过开始菜单打开 Anaconda Navigator 或 Anaconda 命令提示符。如果勾选了环境变量复选框“将 Anaconda3 添加到我的 PATH”,你就可以在命令提示符中使用 Anaconda;然而,这可能会带来后续问题。另外,你也可以稍后将 Anaconda 添加到 PATH 中。点击 安装 以继续。当安装完成后,点击 下一步

安装窗口关闭后,你可能会看到安装 PyCharm 或 DataSpell IDE 的选项。如果出现此情况,忽略它并点击 下一步。我们将使用 Anaconda 中预安装的 Spyder IDE。

安装现在应该完成了。在最后一个窗口中,如果你希望稍后查看教程,勾选相应框,然后点击完成。此时,可能会弹出一个窗口,欢迎你使用 Anaconda,并邀请你注册 Anaconda Nucleus。你还应该在开始菜单中看到一个Anaconda3文件夹(图 1-3)。该文件夹应该包含一些项目,如 Navigator 和提示符,它们是输入文本命令的终端。你也可能看到启动 Jupyter 和 Spyder 的图标。

要验证 Anaconda 是否正确加载,点击 Windows 开始按钮,导航到 Anaconda3 应用程序,然后从下拉菜单中启动 Anaconda Navigator。你也可以在 Anaconda Prompt 终端中输入 anaconda-navigator。这个窗口不总是会自动弹出,因此请确保检查屏幕底部的任务栏。

要查看有关你 Anaconda 发行版和 Python 版本的详细信息,请在 Anaconda Prompt 中输入 conda info。

Image

图 1-3:Windows 开始菜单中的 Anaconda3 程序文件夹

在 macOS 上安装 Anaconda

你可以通过图形化设置向导或命令行在 macOS 上安装 Anaconda 个人版。你可以在docs.anaconda.com/anaconda/install/mac-os/找到这两种方法的安装说明。通过滚动到下载页面底部选择适合你操作系统版本的安装程序。下载完成后,你可以选择使用 SHA-256 校验和算法验证数据的完整性(请参见“在 Windows 上安装 Anaconda”部分,位于第 9 页)。然后,双击下载的文件并点击继续开始安装过程。

安装过程中,你将依次看到强制性的介绍、读取说明和许可证界面。在“读取说明”界面的“重要信息”框中,会提供具体说明,若你想偏离任何推荐的默认选择,可以参考这些说明。当你完成这些步骤后,点击安装按钮,将 Anaconda 安装到你的~/opt目录中。虽然这是推荐的位置,但你也可以选择点击“更改安装位置”按钮来更改安装目录。

在下一个界面,选择仅为我安装,然后点击继续。此时,你可能会看到安装 PyCharm 或 DataSpell IDE 的选项。我们将使用 Anaconda 自带的 Spyder IDE,所以跳过此步骤,点击继续。此时,你应该看到一个显示成功安装的界面。我强烈建议你花时间查看快速入门指南和教程。

要结束安装过程,点击关闭

为了验证安装,点击Launchpad然后选择Anaconda Navigator。另外,你可以使用 CMD-SPACE 打开 Spotlight 搜索,然后输入Navigator打开程序。你还可以通过访问 Mac 终端并输入conda info来查看安装的 Anaconda 发行版和 Python 版本的详细信息。

在 Linux 上安装 Anaconda

由于 Linux 有许多不同的发行版,我强烈建议你访问官方 Anaconda 安装说明,地址为docs.anaconda.com/anaconda/install/linux/。如果你在 IBM PowerPC 或 Power ISA 计算机上运行 Linux,请参见docs.anaconda.com/anaconda/install/linux-power8/。这些网站将帮助你安装在特定 Linux 发行版上使用 GUI 包所需的依赖项。本节中的说明适用于 x86 架构。

Linux 没有图形化安装选项,因此你需要使用命令行来完成大部分过程。首先,滚动到下载页面底部并点击适用于你系统的安装程序。当下载完成后,你可以选择使用 SHA-256 校验和算法验证数据的完整性(参见第 9 页的“在 Windows 上安装 Anaconda”部分)。打开终端并输入以下命令:

sha256sum /path/filename

然后,输入以下命令开始安装:

bash ~/Downloads/Anaconda4-202x.xx-Linux-x86_64.sh

请注意上述.sh文件名中的日期。这应该设置为你下载的文件名。如果你没有将安装程序下载到Downloads目录,请将~/Downloads/替换为正确的路径。

在安装程序提示时,点击Enter查看许可证条款,然后点击Yes同意。接下来,安装程序会提示你点击Enter以接受默认安装位置(推荐)或指定其他安装目录。如果你接受默认位置,安装程序将显示以下内容:

PREFIX=/home/<user>/anaconda<2 or 3>

安装程序将继续进行,可能需要几分钟才能完成。当安装程序询问“你希望安装程序通过运行conda init初始化 Anaconda3 吗?”时,推荐的回答是“yes”。如果由于某种原因你决定选择“no”,请参阅安装网站上的说明和常见问题解答。

当安装程序完成时,你将看到一条消息,感谢你安装 Anaconda。忽略有关安装 PyCharm 或 DataSpell IDE 的链接,因为我们将使用预安装的 Spyder IDE。

为了使安装生效,你需要关闭并重新打开终端窗口,或者输入命令 source ~/.bashrc。要控制每个 Shell 会话是否默认激活 base 环境,请运行conda config --set auto_activate_base True。如果不希望默认激活 base 环境,请将其设置为False。通常,你会希望使用 base 环境作为默认环境。

要验证安装,请打开终端并输入 conda list。如果 Anaconda 正常工作,这将显示所有已安装包及其版本号的列表。你也可以输入 anaconda-navigator 来打开 Navigator。

了解 Anaconda Navigator

Anaconda Navigator 是一个桌面 GUI。它提供了一种友好的点击式替代方式,不需要打开命令提示符或终端,并通过键入命令来操作 Anaconda。你可以使用 Navigator 启动应用程序、在 Anaconda.org 或本地 Anaconda 仓库中搜索包、管理 conda 环境、渠道和包,并访问大量的培训材料。它可以在 Windows、macOS 和 Linux 上运行。

启动 Navigator

在 Windows 上,安装程序会为 Navigator 创建一个开始菜单快捷方式。对于通过 .sh 安装程序安装的 Linux 或 macOS(如我们之前所做的),打开终端并输入 anaconda-navigator。如果你在 macOS 上使用了 GUI (.pkg) 安装程序,可以点击 Launchpad 中的 Navigator 图标。

主页面

Navigator 启动时会显示一个类似于 图 1-4 所示的窗口。你的视图中的应用图标(如 Jupyter Notebook 和 Spyder)可能会有所不同。

Image

图 1-4:Anaconda Navigator 主页面

你看到的初始窗口是主页面 ➊。除了主页面外,还列出了 Environments、Learning 和 Community 等其他页面。当你启动 Navigator 时,你会进入 base(根)环境 ➋。环境就像是文件夹或目录,用于隔离和管理包。base 环境是 Anaconda 安装的文件夹,例如在 Windows 上是 *C:\Users<your_username>\anaconda3*。

可滚动的主屏幕上填充了多个应用程序的方块图标,如 Datalore、Spyder、命令提示符等 ➌。每个图标包含一个应用程序的 logo、应用程序的名称、应用程序的描述,以及根据当前状态显示的 Launch ➍ 或 Install ➎ 按钮。每个图标右上角的“齿轮”图标还允许你安装该应用程序,并更新、删除或安装特定版本。Anaconda 的优点在于,当它安装一个应用程序时,它会自动找到并安装该应用程序运行所需的所有依赖(其他包),并在弹出窗口中显示这些依赖的列表。

如果你通过 Anaconda Prompt 命令行界面安装了一个包或工具,Navigator 主页面可能不会反映这一更改。为了确保该页面始终保持最新,你可以点击右上角的刷新按钮 ➏。

在主页选项卡的左下角,您可能会看到一个 Anaconda Nucleus 的链接 ➐。您可以在此加入,或者使用右上角的按钮 ➑ 登录现有账户。请注意,这个按钮可能会显示为“登录”或“连接”。只有在您需要访问 Anaconda Nucleus 以通过云共享项目,或者访问像Anaconda.org 这样的存储库时,才需要登录。

环境选项卡

现在,让我们来看一下“环境”选项卡(图 1-5)。要打开它,请点击主页下方的Environments链接 ➊。在这里,您可以管理 conda 环境,并安装或卸载来自 Anaconda、conda forge 及其他站点的库。我们将在第二章中详细讨论这一点。

图片

图 1-5:Anaconda Navigator 环境选项卡

此时,您应该只看到基础(root)环境 ➋。其他显示的环境,如“Levy”、“golden_spiral”和“penguins”,是我以前使用屏幕底部的“创建”按钮 ➌ 创建的环境。请注意,还有其他按钮用于克隆、导入和删除环境。更新版本可能会显示一个额外的按钮,用于将环境备份到云端。

每次只能激活一个环境。点击一个环境链接会停用当前环境(例如“base”),并激活您所点击的环境(例如“penguins”)。屏幕更新可能需要几秒钟。屏幕的右半部分将显示该环境中安装的包列表,以及描述和版本号。还请注意,您可以使用主页选项卡上的下拉菜单中的应用程序来切换环境。

如果您点击已安装的下拉菜单,您将看到“未安装”、“可更新”、“已选择”和“所有” ➍ 的选项。在屏幕底部,您将看到当前安装和可用的包数量 ➎。对于基础环境,Anaconda 预安装的包可能会随着时间的推移略有变化,因此您看到的数字可能会有所不同。

注意

您还可以通过访问 docs.anaconda.com/anaconda/packages/pkg-docs/ 查看哪些包是 Anaconda 预装的。 您需要知道您的操作系统和 Python 版本。

当您选择未安装时,您将看到 Anaconda 中可用但当前未安装在所选环境中的包列表。要查看来自其他来源(例如 conda-forge)的包,只需点击Channels按钮 ➏,然后选择或添加一个新频道(图 1-6)。频道只是 conda 查找包时所采用的路径。与包操作相关的其他选项包括更新已启用频道的包列表(更新索引)和搜索包。

图片

图 1-6:频道下拉菜单让您添加、更新和删除频道。

要从活动环境中移除一个包,请点击该包旁边的复选框(图 1-7)。这将打开一个菜单,提供诸如标记包进行移除或安装特定版本号的选项,后者将打开另一个菜单。

Image

图 1-7:标记一个包进行操作

我们将在下一章详细讨论如何管理包。您还可以访问 Anaconda 文档了解更多相关内容 (docs.anaconda.com/anaconda/navigator/tutorials/manage-packages/).

学习选项卡

在“学习”选项卡(图 1-8)中,您可以发现更多关于 Navigator、Anaconda 平台和开放数据科学的内容。要打开它,请点击主页下方的Learning链接 ➊。

Image

图 1-8:Anaconda Navigator 学习选项卡

点击“文档”、“培训”、“网络研讨会”或“视频”按钮 ➋ 以查看相关的图块项目 ➌。您可以一次性打开所有类别。要关闭一个高亮显示的类别,只需再次点击它即可。点击一个图块项目按钮将在浏览器窗口中打开它 ➍。按钮选项包括“阅读”、“查看”和“探索”。

社区选项卡

在“社区”选项卡(图 1-9)中,您可以了解更多关于事件、免费支持论坛以及与 Navigator 相关的社交网络内容。要打开它,请点击主页下方的Community链接 ➊。

Image

图 1-9:Anaconda Navigator 社区选项卡

点击“事件”、“论坛”或“社交”按钮 ➋ 可以更改显示的图块。根据图块的类型,您可以点击“了解更多” ➌、“探索” ➍ 或“参与” ➎。点击一个图块按钮将在浏览器窗口中打开该内容。

文件菜单

Navigator 屏幕左上角的文件菜单包括让您设置偏好的选项(图 1-10)以及退出程序的选项。macOS 用户将在偏好设置菜单中看到额外的选项,包括“服务”,用于链接到计算机的系统偏好设置菜单;“隐藏 Anaconda-Navigator”,用于隐藏 Navigator 窗口;“隐藏其他”,用于隐藏除 Navigator 外的所有窗口;以及“显示所有”,用于显示所有窗口。有关偏好设置菜单选项的详细说明,请参见 docs.anaconda.com/anaconda/navigator/overview/

退出选项将关闭 Navigator,并释放 Anaconda 使用的内存资源。

这完成了对 Anaconda Navigator 的概述。您可以在官方文档中找到更多信息,网址为 docs.anaconda.com/anaconda/navigator/。在下一章中,我们将使用 Navigator 和命令行界面来设置 conda 环境,从而保持您的项目独立、安全和有序。

Image

图 1-10:Windows 上的 Anaconda Navigator 文件 ▸ 首选项菜单

总结

安装了 Anaconda 后,你现在可以轻松访问 Python 及其数千个有用的包。你还成为了 Anaconda 社区的一部分,享有存储选项、丰富的学习机会,以及上传和分享自己构建的包的能力。最后,你已经熟悉了 Navigator 界面,能够以点选的方式方便地运行 Anaconda。

第二章:使用 Conda 环境保持组织

image

每个 Python 项目应该有自己的 conda 环境。Conda 环境让你可以使用任何你想要的包版本,包括 Python,而不会面临兼容性冲突的风险。你可以根据项目需求来组织你的包,而不是把不必要的包堆积在基础目录中。你还可以与他人共享你的环境,使他们能够完美地重现你的项目。

在前一章中介绍的 Anaconda Navigator 提供了一个简单的点选界面来管理环境和包。为了更高的控制,conda 让你可以通过文本命令在 Anaconda Prompt(Windows)或终端(macOS 或 Linux)中执行类似的任务。

在本章中,我们将使用 Navigator 和 conda 来创建 conda 环境、安装包、管理包、删除环境等。在开始之前,让我们更详细地了解为什么 conda 环境如此有用。

理解 Conda 环境

你可以把 conda 环境看作是独立的 Python 安装。conda 环境管理器,在 图 2-1 中由货船表示,将每个环境视为一个安全的运输容器。每个“容器”都可以有自己的 Python 版本和你为特定项目需要运行的任何其他包的版本。这些容器不过是你计算机目录树中的专用目录。

Image

图 2-1:conda 环境和包管理器的概念图

如 图 2-1 所示,你可以在计算机上加载不同版本的 Python 和相同库的不同版本。如果它们在不同的环境中,它们将被隔离,不会互相冲突。这一点很重要,因为你可能会继承只在某些旧版本的包上运行的遗留项目。

conda 包管理器,在 图 2-1 中由起重机表示,用于查找并安装包到你的环境中。可以把每个包想象成一个单独的项目,就像你应该早就回收掉的那箱 《国家地理》 杂志一样,打包在一个运输容器里。

包管理器确保你拥有最新的稳定版本,或者是你指定的版本的包。它还会查找并加载主包运行所需的所有 依赖项,并确保依赖项的版本匹配。依赖项只是提供支持功能的另一个 Python 包。例如,Matplotlib(用于绘图)和 pandas(用于数据分析)都建立在 NumPy(数值 Python)之上,且没有它们无法运行。因此,最好在可能的情况下,同时安装所有项目所需的包,以避免依赖冲突。

如果你担心在每个 conda 环境中安装软件包会浪费空间,完全不用担心。不会创建任何副本。Conda 会将软件包下载到软件包缓存中,每个环境都链接到该缓存中的相应软件包。

默认情况下,这个软件包缓存位于 Anaconda 发行版的 pkgs 目录中。要找到它,可以打开 Anaconda 提示符或终端(参见 第一章 的说明),并输入 conda info。根据你的操作系统,你应该能在 C:\Users<username>\anaconda3\pkgs(Windows)、~/opt/anaconda3(macOS)或 /home//anaconda3/pkgs(Linux)找到软件包缓存。

当然,这里的 指的是你的个人用户名。macOS 显示的位置是图形化安装路径。如果你使用命令行安装 Anaconda,你可以在 /Users//anaconda3 找到它。无论如何,conda info 命令都会显示其位置。

注意

默认情况下,每个用户都有自己的软件包缓存,并且不会与其他人共享。如果你想设置一个共享的软件包缓存来节省磁盘空间并减少安装时间,可以查看 docs.anaconda.com/anaconda/user-guide/tasks/shared-pkg-cache/ 中的说明。

你也可以使用conda info命令(或conda info --envs)来查看你的 conda 环境存储的位置。例如,在 Windows 中,默认位置是 C:\Users<username>\anaconda3\envs

基础环境在安装 Anaconda 时默认创建,并包含 Python 安装和 conda 的核心系统库及依赖项。一般建议是,避免在基础环境中安装额外的软件包。如果你需要为新项目安装额外的软件包,最好首先创建一个新的 conda 环境。

CONDA 和 PIP

有时你会遇到无法通过 conda 安装的软件包。在这种情况下,你需要使用 Python 的 包管理系统(pip) 来安装。Conda 和 pip 的工作原理类似,但有两个不同之处。首先,pip 只适用于 Python,而 conda 支持多种语言。其次,pip 从 Python 包索引pypi.org/)即 PyPI 安装软件包,而 conda 则从 Anaconda 仓库repo.anaconda.com/)和 Anaconda.organaconda.org/)安装软件包。你也可以在激活的 conda 环境中使用 pip 从 PyPI 安装软件包。为了方便起见,conda 会在你创建的每个新环境中自动安装一份 pip。

不幸的是,当 conda 和 pip 一起使用创建环境时,可能会出现问题,特别是在多次连续使用这些工具时,会形成一种很难复现的状态。大多数问题源于这样一个事实:conda 像其他包管理器一样,对于它没有安装的包控制能力有限。在一起使用 conda 和 pip 时,以下是一般的指导原则:

  • 在安装通过 conda 可用的包之后,再安装仅通过 pip 需要的包。

  • 不要在根环境中运行 pip。

  • 如果需要更改,可以从头开始重新创建 conda 环境。

  • 将 conda 和 pip 的需求存储在环境(文本)文件中。

有关此问题的更多细节,请参见 www.anaconda.com/blog/using-pip-in-a-conda-environment/。关于 pip 的更多信息,请参见 packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment/。我们将在本章稍后讨论如何创建需求文本文件。

使用 Navigator 管理 Conda 环境

设置你的第一个 conda 环境非常简单。在接下来的部分中,我们将使用 Anaconda Navigator 图形用户界面来管理 conda 环境。稍后在本章中,我们将使用 Anaconda Prompt(或终端)来做相同的操作。Anaconda Prompt 和 Navigator 在第一章中已有介绍。

启动 Navigator

在 Windows 中,打开开始菜单并点击 Anaconda Navigator 桌面应用程序;在 macOS 中,打开 Launchpad,然后点击 Anaconda-Navigator 图标;在 Linux 中,打开终端窗口并输入 anaconda-navigator。

当 Navigator 启动时,它会自动检查是否有新版本。如果你看到一个更新应用程序的消息框,询问是否要更新 Navigator,请点击。有关 Navigator 界面的复习,请参见第一章。

创建新环境

在 Navigator 中,选择环境标签,然后点击创建按钮。这将打开创建新环境对话框(见图 2-2)。因为这是你的第一个环境,所以命名为my_first_env

Image

图 2-2:Navigator 创建新环境对话框

请注意图 2-2 中的位置说明。默认情况下,conda 环境存储在 Anaconda 安装目录中的envs文件夹内。因此,在使用 Navigator 时,必须为每个环境指定一个唯一的名称。也可以通过命令行界面在其他位置创建环境。我们将在“指定环境位置”一节中进一步探讨这一选项,详见第 37 页。

第一个安装的包是 Python。默认情况下,这与你下载并安装 Anaconda 时使用的 Python 版本相同。如果你想安装不同版本的 Python,可以使用下拉菜单进行选择。

点击创建。大约一分钟后,你应该能在“环境”标签页上看到新环境。现在你应该有两个环境,base (root)my_first_env。名称右侧的箭头表示my_first_env现在是活动环境(图 2-3)。活动意味着这是你当前工作的环境,任何你加载的包都会被放入这个文件夹中。点击列表中的名称会激活该名称,并停用其他环境。

Image

图 2-3:在 Navigator 环境标签页上创建的活动环境(my_first_env)

在“环境”标签页上,还列出了my_first_env中安装的包及其版本号(图 2-4)。在窗口底部,你可以看到已经安装了 12 个包。这些都是与 Python 相关的包。随着时间的推移,包的数量可能会变化,因此你可能看到不同的数字。

Image

图 2-4:在 Navigator 环境标签页上最初安装的包列表

恭喜,你刚刚创建了第一个 conda 环境!你可以立即开始使用 Python。但是,如果你需要额外的包,比如 pandas 和 NumPy,你必须在此环境中安装它们。那么,让我们开始吧。

管理包

创建环境后,你可以使用“环境”标签页查看已安装的包,检查可用的包,查找并安装特定的包,以及更新和删除包。

查找和安装包

要查找已安装的包,点击你想要搜索的环境名称以激活它(见图 2-3)。如果右侧窗格中的已安装包列表很长且你不想滚动,可以在“搜索包”框中输入包的名称。这会减少显示的包的数量,直到只剩下你想要的包。

要查找未安装的包,点击右窗格上方的下拉菜单并选择未安装(见图 2-5)。

Image

图 2-5:在 Navigator 环境标签页上可用但未安装的包列表

如图 2-5 左下角所示,创建新环境后,当前有 8,601 个包可以自动使用(这个数字可能随时间变化,因此你看到的数字可能不同)。要查看更多包,你可以使用“环境”标签页上的“频道”按钮添加频道。

点击Channels以打开对话框 (图 2-6)。然后,输入conda-forge以访问 conda-forge 社区频道。该频道由成千上万的贡献者组成,提供各种软件的包(更多信息,请参见conda-forge.org/docs/user/introduction.html)。

图片

图 2-6:通过 Channels 对话框添加 conda-forge

按 ENTER 键,然后点击更新频道按钮以添加 conda-forge (图 2-7)。

图片

图 2-7:使用 Channels 对话框更新频道

Environments 标签页右侧的面板现在应该会刷新,显示你有成千上万个可用包。你可以通过点击对话框中的相应垃圾桶来移除频道(请参见图 2-7)。

注意

如果你想要的包在 Anaconda 中不可用,可以尝试通过 Python 包索引(PyPI.org/)使用 pip 进行安装,conda 默认在 conda 环境中安装了 pip(有关更多信息,请参见第 24 页的“Conda 和 PIP”侧边栏)。

记住,我们想要添加 NumPy 和 pandas。由于 NumPy 是 pandas 的依赖,因此它已包含在 pandas 的依赖列表中。因此,你只需要安装 pandas。在右侧面板顶部的搜索框中输入pandas (图 2-8)。然后,点击 pandas 包旁边的复选框,并点击右下角的应用按钮。要同时安装多个包,在点击应用之前,请先勾选每个相应的复选框。

图片

图 2-8:在 Environments 标签页中查找并安装 pandas 包

打开一个新的对话框,并在片刻后显示 pandas 依赖的包列表 (图 2-9)。如你所见,NumPy 就在其中。点击应用按钮以完成 pandas 的安装。

如果你切换到已安装列表,已安装包的数量将增加,列表中将包括 pandas 和 NumPy。请注意,你可能需要清除“搜索包”框才能查看完整列表。

图片

图 2-9:包含依赖包的安装包列表

你可能会注意到一些主要的库在“未安装”列表中似乎有重复。例如,你可以在“matplotlib”和“matplotlib-base”之间选择 (图 2-10)。“-base”选项通常是轻量级版本,适用于当像 Matplotlib 这样的包作为其他包的依赖时。因此,它可能无法完全功能化;因此,在安装像 Matplotlib 或 NumPy 这样的包时,你不应安装此“-base”版本。这样,你可以确保一切正常工作,没有意外。

图片

图 2-10:在未安装的包列表中,有两个可选的 matplotlib 库。

更新和删除包

随着时间的推移,已安装包的新版可能会发布。要检查这些版本,请在“环境”选项卡的右侧窗格顶部选择可更新筛选器(见图 2-11)。你看到的列表可能与所示列表略有不同。

Image

图 2-11:环境选项卡的右侧窗格,显示可用更新的已安装包

在这个例子中,Python 已过时,因此我们将其更新到当前版本。如果你的版本已经是最新的,尝试更新可更新列表中的另一个包。

首先,点击 Python 旁边的复选框,然后从弹出菜单中选择标记为更新(见图 2-12)。

Image

图 2-12:点击包旁边的复选框以打开用于更新和删除包的菜单。

点击右下角的应用按钮。这将打开“更新包”窗口,显示哪些包将被修改,哪些将被安装(见图 2-13)。

Image

图 2-13:用于更新 Python 的更新包窗口

点击应用以继续。几分钟后,Python 将从可更新列表中消失。将筛选器改为已安装,你将看到 Python 的版本已更改。基础(root)环境中的 Python 版本没有变化,因为你所做的所有更改仅针对活动的 conda 环境,即my_first_env

在没有充分理由的情况下更新包应谨慎,因为其他包可能依赖于旧版本。如果你这样破坏了环境,世界并不终结;你可以使用环境文件恢复它,我们将在本章稍后讨论。

如果你想安装特定版本的包,请点击已安装包名称旁边的复选框,并使用图 2-12 中的菜单。点击标记为特定版本安装,然后从弹出窗口中选择版本号。点击应用以启动安装。

如果可更新包的列表很长,而你不想点击每个复选框,你可以使用命令行来提高效率。在“环境”选项卡中,点击活动环境名称旁边的三角箭头(见图 2-3)。然后,选择打开终端并输入以下命令:

conda update --all

将显示一个待更新包的列表,并询问你是否继续。稍后在本章中,我们将讨论命令行界面时,会更详细地介绍这个命令。我们还会讨论如何锁定或冻结包,以便它不会被更新。

要从环境中移除一个软件包,点击其复选框,选择 标记为移除选项(见 图 2-12),然后点击 应用。这将移除该软件包,包括它的依赖项。这一点很重要。如果您从 my_first_env 中移除 pandas,那么 NumPy 也会被一并移除!为了避免这种情况,您需要在安装 pandas 之前明确安装 NumPy。

复制环境

Environments 窗格底部的克隆和导入按钮(图 2-14)分别允许您创建环境的精确副本,和从规格文件创建新环境。要克隆环境,您首先需要通过点击环境名称来激活该环境。在使用导入时,系统会提示您为新环境命名,并指向规格文件。我们将在“复制和共享环境”一节中更详细地讨论如何从文件创建新环境,详见 第 44 页。

图片

图 2-14:Environments 选项卡中间窗格底部的按钮帮助您管理 conda 环境。

备份环境

在新版的 Anaconda Navigator 中,您可能会看到在导入按钮旁边有一个备份按钮。这个按钮允许您将环境备份到云端,并可以将其恢复。您可以用它来保存工作进度,保持灾难恢复的副本,或实现从一台机器到另一台机器的迁移。您需要拥有 Anaconda Nucleus 账户。详情请访问 www.anaconda.com/blog/keeping-your-conda-environments-safe-and-secure-with-your-anaconda-nucleus-account/

移除环境

要删除一个 conda 环境,首先点击要删除的环境名称,然后点击 Environments 选项卡底部的 移除 按钮(带垃圾桶图标)(图 2-14)。接着会弹出一个窗口,显示该环境的位置并请求确认。

在删除环境之前,最好先创建一个环境文件,这样在需要时可以恢复该环境。我们将在后面的章节中介绍如何操作。

同时请注意,环境是文件夹,您在该文件夹中存储的任何数据都会在删除环境时被删除。您应该将数据保存在一个或多个独立的文件夹中。

使用命令行界面操作 Conda 环境

您还可以在 命令行界面CLI,发音为 Clie)中使用 conda 环境。高级用户可能更喜欢这种基于文本的界面提供的控制功能,而不是 Navigator 提供的点选功能。

启动命令行界面

在 Windows 中,使用开始菜单启动 Anaconda Prompt;在 macOS 或 Linux 中,打开终端窗口。在命令行界面(CLI)中,conda 命令是管理环境和各种软件包安装的主要接口。和 Navigator 一样,你可以使用它来完成以下任务:

  • 查询和搜索 Anaconda 软件包索引及当前的 Anaconda 安装

  • 创建和管理 conda 环境

  • 安装并更新现有 conda 环境中的软件包

与 Navigator 一样,你应从创建一个新的 conda 环境或激活一个现有的环境开始。表 2-1 列出了处理环境时一些更有用的单行 conda 命令。这些命令使你能够重现并扩展 Navigator 的功能。你需要将所有大写字母的单词替换为具体的名称。例如,对于 ENVNAME,你应替换为你的环境实际名称,如 my_first_env。你还可以将许多以双破折号(--)开头的命令选项缩写为一个破折号加上选项的第一个字母。换句话说,你可以使用 -n 替代 --name,使用 -e 替代 --envs。我们将在接下来的部分中更详细地讨论这些命令。

表 2-1: 用于处理环境的有用 conda 命令

命令 描述
conda help 显示 conda 命令位置参数的解释
conda info 验证安装、版本号、目录位置
conda update --name base conda 更新 conda 到当前版本
conda create --name ENVNAME python 创建新环境并安装 Python
conda create --name ENVNAME python=3.x 使用特定 Python 版本创建新环境
conda create --prefix path\ENVNAME 在指定位置创建新环境
conda activate ENVNAME 激活指定的环境
conda activate path\to\environment-dir 激活指定位置的环境
conda deactivate 停用当前环境
conda list 列出当前环境中的所有软件包和版本
conda list --name ENVNAME 列出指定环境中的所有软件包及其版本
conda list --revisions 列出当前环境的版本
conda install -n ENVNAME --``revision REVNUM 恢复环境到先前的版本
conda remove --name ENVNAME --all 删除已停用的环境
conda create --clone ENVNAME --name NEWENV 创建环境的精确副本
conda env export --name ENVNAME > envname.yml 导出环境到可读的 YAML 文件
conda env create --file ENVNAME.yml 从 YAML 文件创建环境
conda list --explicit > pkgs.txt 导出具有精确版本的软件包列表(适用于单一操作系统)
conda create --name NEWENV --file pkgs.txt 基于精确的软件包版本创建环境

有关完整的命令列表,请参见 conda 备忘单

注意

本章假设你已按照第一章中的说明安装了 Anaconda。这样可以确保 Anaconda 正确添加到你的 PATH 中,PATH 是一个环境变量,指定了可执行程序所在的目录集。这对于在 macOS 和 Linux 上使用终端中的 conda 命令非常重要。

创建新环境

让我们创建一个新的 conda 环境,命名为 my_second_env,因为我们已经使用 Navigator 创建了 my_first_env。在 Anaconda 提示符窗口或终端中,输入以下命令:

conda create --name my_second_env python

这将创建一个带有当前版本 Python 的新环境。当系统提示你是否继续时,输入 y(并在本章中继续执行此操作)。

注意

你可以通过在命令末尾添加 --yes 或 -y 标志来禁用验证提示。如果你在自动化过程中使用,这很有帮助,但在日常工作中应避免使用,以减少出错的可能性。

如果你想安装某个特定版本的 Python,比如 3.9,可以使用以下命令(但现在不要运行):

conda create --name my_second_env python=3.9

这个命令有些微妙。因为我们在指定 Python 版本时使用了单个等号(=),结果是 Python 3.9 树中的最新版本(例如 Python 3.9.4)。要获得精确的 Python 3.9,必须在指定版本号时使用双等号(==)。

在创建环境时安装多个软件包,可以在 Python 安装后列出它们(现在也不要执行此操作):

conda create --name my_second_env python numpy pandas

要激活新环境,请输入以下命令:

conda activate my_second_env

接下来,让我们检查环境是否已创建并处于活动状态:

conda env list

这将生成图 2-15 中显示的列表。星号(*)标记了活动环境。你还可以看到我们在上一节中使用 Navigator 创建的 my_first_env,以及我之前创建的其他环境,其中一些将在本书后面使用。

为了让你始终清楚哪个环境处于活动状态,命令提示符现在会显示环境的名称(如图 2-15 中的第一行)。

要查看当前环境中已安装的软件包列表,请输入 conda list。这将返回软件包名称、版本、构建和频道信息。要查看非活动环境的内容,例如my_first_env,请使用 conda list -n my_first_env。记住,-n 只是 --name 的简写。

Image

图 2-15:Anaconda 提示符窗口中 conda env list 命令的输出

指定环境的位置

你创建的 conda 环境默认存储在 Anaconda 安装目录下的envs文件夹中。例如,在我的 Windows 电脑上,我们刚才创建的环境存储在C:\Users\hanna\anaconda3\envs\my_second_env(是我妻子 Hannah 设置的电脑,因此她被列为用户)。

然而,确实可以将环境存储在其他地方。这样,你可以将 conda 环境放入项目文件夹,并始终命名为类似conda_env的名称(参见图 2-16)。

Image

图 2-16:将 conda 环境存储在默认位置之外的示例目录树

要在默认的envs文件夹之外创建 conda 环境,请将--name-n)标志替换为--prefix-p):

conda create -p D:\Documents_on_D\anywhere_you_want\a_project\conda_env

要激活环境,请运行以下命令:

conda activate D:\Documents_on_D\anywhere_you_want\a_project\conda_env

将 conda 环境放在项目目录中有几个好处。首先,你可以立即判断一个项目是否使用了隔离的环境。其次,它使得你的项目自包含,而不是将环境、数据和像 Jupyter 笔记本这样的文件存储在不同、不相关的地方。第三,你可以为所有环境使用相同的名称,例如conda_env,使其对任何人来说都一目了然。

像默认位置的环境一样,你的新环境会在使用conda env listconda info -e命令时显示,尽管它没有官方名称,比如my_first_envbase(参见图 2-17 中的*)。

Image

图 2-17:显示在 D:\驱动器上活动环境的conda info -e命令输出

不足为奇的是,在创建 conda 环境时指定非默认安装路径会有一些缺点。例如,conda 将无法通过--name标志找到你的环境。例如,要列出位于默认位置的my_first_env的内容,你可以简单地输入以下命令:

conda list -n my_first_env

对于其他位置的环境,你必须使用--prefix标志并指定完整路径:

conda list -p D:\Documents_on_D\anywhere_you_want\a_project\conda_env

另一个问题是,你的命令提示符现在会以活动环境的绝对路径作为前缀,而不是环境的名称。这可能会导致一些冗长且难以管理的提示符,就像图 2-17 中第一行所看到的那样。

你可以通过修改.condarc文件中的env_prompt设置,强制 conda 始终使用环境名称作为提示符。这是conda 配置文件,一个可选的运行时配置文件,允许高级用户配置 conda 的各个方面,例如搜索包的频道。你可以在文档中阅读关于它的内容,链接为conda.io/projects/conda/en/latest/user-guide/configuration/index.html

如果你想修改(或创建)一个 .condarc 文件,以缩短你在 shell 提示符中的长前缀,请使用以下命令:

conda config --set env_prompt ′({name})′

现在,你在提示符中将只看到环境名称,而不管该环境存储在哪里。如果你使用通用的 conda_env 名称,这样显示并不会非常有帮助,反而可能会让你感到困惑,甚至在错误的环境中工作。因此,你可能想继续使用长前缀格式,或者将每个环境名称与项目名称拼接,比如 conda_env_penguinsconda_env_covid

管理包

在你创建一个环境后,可以使用 conda 来检查所有可用的包,找到特定的包并安装,更新和删除包。如在“启动 Navigator”部分中所述,应该同时安装项目所需的所有包,以确保没有依赖冲突。

表 2-2 列出了在处理包时一些有用的 conda 命令。该表主要展示了在活动环境中使用的命令,因为这被认为是最佳实践。你需要用具体的名称替换所有大写字母的词汇。

表 2-2: 用于处理包的有用 conda 命令

命令 描述
conda search PKGNAME 在当前配置的通道中搜索一个包
conda search PKGNAME=3.9 在配置的通道中搜索特定版本
conda search PKGNAME --info 获取包的详细信息,包括其依赖关系
conda install PKGNAME 在当前环境中安装当前版本的包
conda install PKGNAME=3.4.2 在当前环境中安装指定版本的包
conda install PKG1 PKG2 PKG3 在当前环境中安装多个包
conda install -c CHANNELNAME PKGNAME=3.4.2 从指定通道安装指定版本的包
conda uninstall PKGNAME 从当前环境中移除一个包
conda update PKGNAME 更新当前环境中指定的包
conda update --all 更新当前环境中所有可以更新的包
conda list 列出当前环境中的所有包
conda list anaconda 显示已安装的 Anaconda 发行版的版本号
conda clean --all 删除未使用的缓存文件,包括未使用的包
conda config --show 检查 conda 配置文件
PKGNAME --version 显示已安装包的版本号

有关完整的命令列表,请参阅“conda 备忘单”,链接如下:docs.conda.io/projects/conda/en/4.6.0/_downloads/52a95608c49671267e40c689e0bc00ca/conda-cheatsheet.pdf

安装包

使用 conda 安装包的推荐方式是在一个激活的环境内。或者,你也可以通过使用--name--prefix标志并指定目录路径,从环境外安装包。此方法不建议使用。因为不仅麻烦,而且你还可能会把包安装到错误的环境中。

为了演示如何使用 conda 查找和安装包,让我们将两个包(Matplotlib 用于绘图,pillow 用于处理图像)添加到 my_second_env 环境中。首先,激活环境:

conda activate my_second_env

在安装时,最好指定每个包的版本。这将帮助你明确记录环境中的内容,以防将来需要重建或共享你的项目。由于我们不需要使用旧版的 Matplotlib 或 pillow,让我们搜索包以查看其当前的版本号:

conda search matplotlib

这将返回一个包含所有可用版本的长列表,在下面的示例中为简洁起见进行了截断。最右侧的列表示频道信息。当然,版本号会随着时间变化,因此你会看到不同的列表:

--snip--
matplotlib                     3.3.4  py39haa95532_0  pkgs/main
matplotlib                     3.3.4  py39hcbf5309_0  conda-forge
matplotlib                     3.4.1  py37h03978a9_0  conda-forge
matplotlib                     3.4.1  py38haa244fe_0  conda-forge
matplotlib                     3.4.1  py39hcbf5309_0  conda-forge
matplotlib                     3.4.2  py37h03978a9_0  conda-forge
matplotlib                     3.4.2  py38haa244fe_0  conda-forge
matplotlib                     3.4.2  py39hcbf5309_0  conda-forge

pkgs/main频道是 conda 的defaults频道中的最高优先级频道,默认设置为 Anaconda Repository。在这个例子中,注意到默认频道中有 Matplotlib 3.3.4,而 conda-forge 频道则有 Matplotlib 3.4.2。

在 conda-forge 上的包可能比默认频道上的包更新,并且你可以在 conda-forge 上找到默认频道没有的包。然而,使用默认频道,你可以确保可用的包已经过兼容性检查,因此它是最“安全”的选择。

如果你没有指定频道,Anaconda 会自动使用你.condarc文件中频道配置列表顶部的频道。要查看你的频道列表,请输入:

conda config --show channels

这将产生以下输出:

channels:
  - conda-forge
  - defaults

按照这个示例配置,Anaconda 会首先在 conda-forge 频道中查找包。

如果你正在寻找的包位于最高优先级的频道,它会被安装,即使在列表中的下一个频道有更新的版本。在这种情况下,如果你在没有指定版本或频道的情况下安装 Matplotlib,你将获得最新的版本,因为 conda-forge 拥有最高优先级。

当我对 pillow 包进行相同操作时,我发现两个频道使用的是相同的版本(8.2.0),因此频道不重要。现在,让我们在my_second_env环境中同时安装这两个包,指定最新版本(使用此处显示的版本号,或者根据需要更新为当前版本):

conda install matplotlib=3.4.2 pillow=8.2.0

现在让我们验证一下安装:

conda list

你应该能够看到正确的包版本以及 conda-forge 源频道。然而,默认频道将在“Channel”列中显示为空白。

如果您希望 conda 安装 任何 列出的渠道中的最新版本软件包,您可以使用以下命令关闭渠道优先级顺序:

conda config --set channel_priority: false

您可以通过使用 --channel 标志和渠道名称来强制 conda 使用特定渠道,如下所示(对于默认渠道):

conda install -c defaults matplotlib=3.3.4

为了获得该渠道上最现代的版本,您可以省略版本号,尽管这不建议这样做。

要更改配置文件中渠道列表的成员资格和顺序,您可以使用 --remove--append--prepend 等标志。通常,您希望将默认渠道放在顶部,因此首先将其删除然后再添加回来:

conda config --remove channels defaults
conda config --prepend channels defaults

注意

您可以通过在 anaconda.org/ 注册并上传自己的 conda 包,来添加自己的渠道。

如果您通过 Anaconda 找不到所需的软件包,请尝试 Python 包索引 (pypi.org/)。有关此资源的更多信息,请参见 第 24 页上的“Conda 和 PIP”侧边栏。当您使用 pip 安装软件包并使用 conda list 命令时,该软件包的渠道标识将是“pypi”。

最后,如果您希望在每个创建的环境中都安装基础软件包或一组软件包,可以编辑配置文件以自动添加它们。例如,要始终默认安装 Python 的最新版本,请运行以下命令:

conda config --add create_default_packages python

现在每次创建新的 conda 环境时,Python 默认会被包含在内。如果您做大量数据科学工作,您可能还希望添加 NumPy、pandas 和绘图库。您可以通过输入以下命令来查看默认软件包列表:

conda config --show

要从默认软件包列表中删除软件包,请使用 --remove 标志替代 --add。您还可以在命令提示符下通过 --no-default-packages 标志覆盖此选项。

要了解更多编辑配置文件的选项,请输入 conda config --help。有关安装软件包和管理渠道的更多信息,请访问 docs.conda.io/ 并分别搜索“使用 conda 安装”和“管理渠道”。

更新和删除软件包

随着时间的推移,可能会有安装的软件包的新版本可用。以下命令将帮助您保持环境的最新状态。

首先,请确保 conda 是最新的,运行以下命令(在任何位置):

conda update -n base conda

要检查在活动环境中是否有特定软件包(例如 pip)的更新,请输入:

conda update pip

如果有更新,您将看到新的软件包信息,如其版本、构建、内存要求和渠道,并会提示您接受或拒绝更新。

要将活动环境中的所有软件包更新为当前版本,请输入:

conda update –-all

要更新非活动环境,请输入以下命令,其中 ENV_NAME 是环境的名称。

conda update -n ENV_NAME --all

即使update命令尝试将所有内容更新到最新版本,它也可能无法将所有软件包升级到最新版本。如果环境中存在冲突的约束条件,Anaconda 可能会使用较旧版本的某些软件包来满足依赖关系约束。

拥有强大功能就意味着更大的责任。小心更新 Anaconda 软件包,因为这个元包的升级发布频率低于其他软件包。因此,你可能在更新时不经意地降级了某些软件包。而且,绝不要尝试在基础(root)环境中管理一组精确的软件包。这是特定 conda 环境的任务。

有关这些主题的更多信息,请参见* www.anaconda.com/blog/keeping-anaconda-date/,* docs.anaconda.com/anaconda/install/update-version/ www.anaconda.com/blog/whats-in-a-name-clarifying-the-anaconda-metapackage/*。

注意

通过创建一个例外列表并将其保存为名为 pinned.txt 的文件在环境的 conda-meta 目录中,可以防止某些软件包更新。你可以在docs.conda.io/了解更多关于“防止软件包更新(锁定)”的信息。

要从活动环境中删除一个软件包,比如 Matplotlib,请输入:

conda remove matplotlib

要同时删除多个软件包,可以将它们一个接一个列出。现在就为my_second_env执行此操作:

conda remove matplotlib pillow

要从非活动环境中删除相同的软件包,请使用--name-n)标志提供环境的名称:

conda remove -n ENV_NAME matplotlib

请记住,以这种方式处理非活动环境是不推荐的,因为这样更容易出错。无论是使用 Navigator 还是 conda,失去对所工作环境的追踪都极其容易,从而导致各种混乱。

要验证更新和删除软件包的结果,请在活动环境中使用 conda list 命令。

复制和共享环境

你可以通过克隆环境或使用列出其内容的特殊文件来精确复制一个环境,这样就可以轻松地与他人共享环境、存档或恢复已删除的版本。

克隆环境

复制环境的最简单方法是使用--clone标志。例如,要创建一个名为my_third_envmy_second_env的精确副本,可以使用以下命令:

conda create --name my_third_env --clone my_second_env

要验证结果,请输入:

conda env list
使用环境文件

你还可以通过记录其内容来复制环境。环境文件是一个文本文件,列出了环境中安装的所有软件包和版本,包括使用 pip 安装的那些软件包。这有助于你恢复环境并与他人共享。

环境文件是以YAML (.yml)格式编写的,这是一种用于数据存储的可读数据序列化格式。YAML 最初意味着“Yet Another Markup Language”,但现在代表“YAML Ain’t Markup Language”,强调它不仅仅是一个文档标记工具。

要生成环境文件,您必须先激活环境,然后导出环境。以下是为my_second_env创建文件的方法:

conda activate my_second_env
conda env export > environment.yml

您可以将文件命名为任何有效的文件名,例如my_second_env.yml,但请小心,因为如果已经存在同名文件,它将被覆盖。

默认情况下,文件会写入用户目录。对于我的 Windows 配置,这个目录是C:\Users\hanna。以下是文件内容(特定版本和日期已被替换为 x,因为这些值是时间依赖的,您的输出可能会有所不同):

name: my_second_env
channels:
  - conda-forge
  - defaults
dependencies:
  - ca-certificates=202x.xx.x=h5b45459_0
  - certifi=202x.xx.x=py39hcbf5309_1
  - openssl=1.1.1k=h8ffe710_0
  - pip=21.1.x=pyhd8ed1ab_0
  - python=3.x.x=h7840368_0_cpython
  - python_abi=3.x=1_cp39
  - setuptools=49.x.x=py39hcbf5309_3
  - sqlite=3.xx.x=h8ffe710_0
  - tzdata=202x=he74cb21_0
  - vc=14.c=hb210afc_4
  - vs20xx _runtime=14.28.29325=h5e1d092_4
  - wheel=0.xx.x=pyhd3deb0d_0
  - wincertstore=0.x=py39hcbf5309_1006
prefix: C:\Users\hanna\anaconda3\envs\my_second_env

现在,您可以将此文件通过电子邮件发送给同事,他们可以完美地重现您的环境。如果他们使用不同的操作系统,您可以使用--from-history标志生成一个跨平台都能使用的文件:

conda env export --from-history > environment.yml

新的环境文件如下所示:

name: my_second_env
channels:
  - conda-forge
  - defaults
dependencies:
  - python
prefix: C:\Users\hanna\anaconda3\envs\my_second_env

在这种情况下,环境文件仅包含您明确要求的包,如 Python,而不包括其依赖项。解决依赖问题可能会引入一些在不同平台间不兼容的包,因此它们未被包含在内。

记得我曾说过,安装包时最好指定版本号,即使您想安装最新版本吗?现在看看最后一个环境文件中的-python没有版本号。当您使用历史标志时,环境文件包括您要求的内容。如果不指定版本,您告诉 conda 安装当前版本的 Python。如果某人在 Python 新版本发布后使用您的文件,不仅他们无法重现您的环境(假设您没有更新它),而且他们也不知道他们没有重现环境!

在拥有environment.yml文件后,您可以使用它来重新创建一个环境。例如,一位同事可以通过输入以下命令来复制my_second_env

conda env create -n my_second_env -f \directory\path\to\environment.yml

您还可以通过提供环境名称(此处用 ENV_NAME 表示)将包添加到文件中的另一个环境:

conda env update -n ENV_NAME -f \directory\path\to\environment.yml

欲了解更多关于环境文件的信息,包括如何手动生成它们,请访问conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html#sharing-an-environment/

使用规范文件

如果您的环境中不包括使用 pip 安装的包,您也可以使用规范文件在相同操作系统上重现 conda 环境。要创建规范文件,激活一个环境,例如my_second_env,然后输入以下命令:

conda list --explicit > exp_spec_list.txt

这将产生以下输出,出于简洁性考虑已被截断:

# This file may be used to create an environment using:
# $ conda create --name <env> --file <this file>
# platform: win-64
@EXPLICIT
https://conda.anaconda.org/conda-forge/win-64/ca-certificates-202x.xx.x-h5b45459_0.tar.bz2
https://conda.anaconda.org/conda-forge/noarch/tzdata-202xx-he74cb21_0.tar.bz2
--snip--

要使用这个文本文件重新创建 my_second_env,运行以下命令:

conda create -n my_second_env -f \directory\path\to\exp_spec_list.txt

请注意,--explicit 标志确保文件中标注了目标平台,在这种情况下,第三行会显示 # platform: win-64

恢复环境

因为 conda 会保留对环境所做的所有更改的历史记录,你可以随时回滚到以前的版本。要查看可用版本的列表,首先激活环境,然后输入以下命令:

conda list --revisions

在修订列表中,包名前的加号( + )表示该包已被添加,减号( )表示该包已被卸载,包名前没有符号表示该包已被更新。

要将环境恢复到之前的版本,例如版本 3,使用以下命令:

conda install --revision 3

或者,输入以下命令:

conda install --rev 3

如果你恢复到较旧的修订版本,该修订将获得自己的编号,因此你仍然可以恢复到更早的版本。例如,如果修订列表显示有八个修订版本,且你恢复到修订版 6,当你重新生成修订列表时,你会看到九个修订版本。修订版 9 将与修订版 6 完全相同。

删除环境

要删除一个 conda 环境,首先必须通过运行以下命令将其停用:

conda deactivate

然后,要删除停用的环境,运行此命令,替换 ENVNAME 为环境的名称:

conda remove -n ENVNAME --all

或者,你可以运行以下命令:

conda env remove -n ENVNAME --all

为了验证删除操作,运行以下命令:

conda env list

你还可以使用 info 命令来验证这一点:

conda info -e

被删除的环境应该不会出现在环境列表中。

请记住,对于位于 Anaconda envs 文件夹之外的环境,你需要包括目录路径:

conda remove -p PATH\ENVNAME --all

清理包缓存

随着时间推移,随着你创建和删除环境,以及安装和卸载包,你的 anaconda3 文件夹将占用越来越多的磁盘空间。你可以通过清理包缓存来恢复部分空间。如《理解 Conda 环境》一节中所述(第 22 页),这个文件夹保存了你所有已安装的包。

要清理包缓存,可以在任何环境中运行 conda clean 命令。为了预览它将标记为删除的文件,可以执行一次干运行:

conda clean --all --dry-run

提交时,使用以下命令:

conda clean --all

这将删除索引缓存、未使用的缓存包(不再与任何环境关联的包)、tar 包(将多个文件合并并压缩的文件)以及 pkgs 目录下的锁文件。Windows 用户在运行此命令后,建议重启计算机。

要了解更多运行 conda clean 时的选项,请参阅 docs.conda.io/projects/conda/en/latest/commands/clean.html

总结

每个 Python 项目都应该有自己的 conda 环境,以保持工作有序、隔离、更新、可重现并且可共享。尽管 Anaconda Navigator 提供了便捷的环境图形化操作,你还是应该学习一些命令行接口命令,以便获得完全的控制。

第三章:在 JUPYTER QT CONSOLE 中的简单脚本编写**

image

Jupyter Qt 控制台 是一款轻量级应用程序,它将终端的简洁性与仅在图形用户界面(GUI)中可能实现的功能(如查看内联图形)相结合。它旨在快速测试想法、探索数据集并处理教程,而不是长时间的交互式使用。

当我说 Qt 控制台是“轻量级”的意思是,它的内存占用小,不会给 CPU 带来负担。同样,它也不会通过众多的控制项和选项让用户感到困惑。其界面简洁而稀疏(见 图 3-1),类似于 Python 自带的交互式 Shell,但做了许多改进。这些改进包括行号、多标签页支持、丰富媒体输出(如图像、视频、音频和交互式元素)的支持、跨会话的命令历史记录检索、带语法高亮的多行编辑、会话导出等。

Image

图 3-1:具有两个标签(内核 0 和内核 1)以及内联图形的 Jupyter Qt 控制台

接下来的章节将提供 Qt 控制台的广泛介绍。有关更深入的研究,你可以访问官方文档 qtconsole.readthedocs.io/

安装 seaborn

如果你想重现本章中显示的图表,你需要安装 seaborn 数据可视化库。使用上一章中创建的 my_first_env conda 环境。

打开 Anaconda Prompt(Windows 系统)或终端(macOS 和 Linux 系统),然后输入以下命令:

conda activate my_first_env

conda install seaborn

你还需要了解多行编辑,你可以在本章稍后的“多行编辑”部分找到相关内容。

使用 Navigator 安装并启动 Jupyter Qt 控制台

使用 Anaconda Navigator 安装 Jupyter Qt 控制台有两种方法。如果你在第一种方法中遇到问题,可以尝试第二种方法。

最简单的方法是使用首页标签上的 Qt Console 磁贴。首先通过在首页标签顶部附近的 Applications on 下拉菜单中选择其名称来激活环境(见 图 3-2)。在这个例子中,我们使用的是上一章中创建的 my_first_env。接下来,点击 Qt Console 应用程序磁贴上的 Install 按钮。你可能需要滚动首页标签以找到该磁贴。

Image

图 3-2:Anaconda Navigator 首页标签,显示了活动环境(my_first_env)和 Qt Console 磁贴

注意

忽略名为 jupyter console 的包。此版本的控制台纯粹基于终端,不涉及用于图形的 Qt。

几秒钟后,Install 按钮应该会变成 Launch 按钮。点击它以启动控制台。请注意,尽管首页标签上的磁贴显示的是 IPython(IP[y])图标,但控制台窗口的名称是 Qt Console。

如果由于某种原因你没有在首页标签看到安装图块,请点击环境标签,将视图切换到未安装,在搜索包框中搜索qtconsole,然后点击列表中qtconsole旁边的按钮(图 3-3)。

图片

图 3-3:通过“环境”标签安装 Jupyter Qt 控制台

接下来,点击屏幕底部的应用按钮,然后在弹出窗口中再次点击应用。你现在应该在首页标签上看到 Qt 控制台图块(图 3-3)。如果没有,尝试点击首页标签右上角的刷新按钮。

通过在每个环境中安装 Qt 控制台,你将能够导入并使用该环境中的其他包。

使用 CLI 安装和启动 Jupyter Qt 控制台

若要使用 CLI 在新环境中安装 Jupyter Qt 控制台,而不是使用 Anaconda Navigator,首先打开 Anaconda 提示符(在 Windows 中)或终端(在 macOS 和 Linux 中),并激活 conda 环境。我们将在上一章创建的my_first_env环境中进行操作:

conda activate my_first_env

接下来,使用 conda 安装控制台:

conda install qtconsole

注意在待安装包列表中有PyQt。这个库使得在同一窗口中使用图形成为可能,也解释了 Jupyter Qt 控制台中的“Qt”含义。安装过程中,如果提示,输入 Y 完成安装。

启动程序时,输入:

jupyter qtconsole

如果控制台没有自动出现,请检查你的任务栏。之后,如果你想更新应用程序,可以输入以下命令:

conda update qtconsole

注意

如果你同时打开了 Navigator 和 Anaconda 提示符(或终端),并且在它们之间切换工作,使用 conda 安装或删除包后,你需要点击 Navigator 首页上的“刷新”按钮。这将更新安装和启动按钮的状态。

Qt 控制台控制

Qt 控制台是交互式的,这意味着它像电子计算器一样工作。你输入的任何指令都会立即执行。实际上,你可以把控制台当作计算器使用:

In [1]: 5 * 2 + (10 / 2)
Out[1]: 15.0

注意控制台如何标记输入与输出,并包括行号。尽管在黑白书籍中你看不见,Qt 控制台也使用不同的颜色来区分关键字、注释、错误信息等。这个被称为语法高亮的颜色编码帮助你将代码进行视觉分类。

你还可以选择浅色或深色背景。事实上,现在正是调整屏幕配置选项的好时机,看看你最喜欢哪种设置。

选择语法风格

在 Jupyter Qt 控制台窗口顶部,点击 视图语法样式。你将看到大约 36 种样式类型,包括流行的 emacsvimvs 样式。选择其中一个,然后输入你在 图 3-4 中看到的代码,这样你就能看到主题的颜色选择。本书默认使用 default 语法样式,除非另有说明。

要比较样式,使用 文件新标签页与新内核 打开新标签页。然后使用 窗口重命名当前标签 为当前显示的样式命名(例如,图 3-4 中的“Monokai”)。你可以将代码从一个标签复制到下一个,以查看高亮变化。

Image

图 3-4:Monokai 语法样式

如果你从命令行启动 Jupyter Qt 控制台,你可以同时指定一个样式。例如,要选择 Monokai,输入以下内容:

jupyter qtconsole –-style monokai

当然,不需要指定 default 样式。

甚至可以配置控制台并设置你自己的样式(请参阅 qtconsole.readthedocs.io/_/downloads/en/stable/pdf/ 中的“颜色和高亮”以及“字体”部分)。

使用键盘快捷键

Jupyter Qt 控制台支持键盘快捷键,或称 键绑定,包括熟悉的 CTRL-C 和 CTRL-V,用于复制和粘贴(分别见表 3-1)。你可以通过点击 帮助显示 QtConsole 帮助 来查看键绑定列表。退出帮助时,使用 ESC 键。

表 3-1: Jupyter Qt 控制台中常用的键绑定

键绑定 描述
CTRL-C 将高亮文本复制到剪贴板,无需提示
CTRL-SHIFT-C 将高亮文本复制到剪贴板,并显示提示
CTRL-V 从剪贴板粘贴文本
CTRL-Z 撤销
CTRL-SHIFT-Z 重做
CTRL-S 保存为 HTML/XHTML
CTRL-L 清除终端
CTRL-A 跳转到行首
CTRL-E 跳转到行尾
CTRL-U 从光标位置删除到行首
CTRL-K 从光标位置删除到行尾
CTRL-P 上一行(类似上箭头)
CTRL-N 下一行(类似下箭头)
CTRL-F 前进(类似右箭头)
CTRL-B 返回(类似左箭头)
CTRL-D 删除下一个字符,或者当输入为空时退出
ALT-D 删除下一个单词
ALT-BACKSPACE 删除上一个单词
CTRL-. 强制重启内核
CTRL-+ 增大字体大小
CTRL-hyphen 减小字体大小
CTRL-T 打开带有新内核的新标签页
CTRL-SHIFT-P 打印
F11 切换全屏模式
CTRL-R 重命名当前标签
ALT-R 重命名窗口

在更实用的快捷键中,向上和向下箭头键尤其有用。它们允许你循环查看已经输入的行,方便再次使用。

使用标签和内核

Jupyter Qt 控制台支持多个标签页,可以从文件菜单打开。你必须选择一个内核选项,这是执行代码的活动“计算引擎”。有三个选项:

使用新内核的新标签页 打开一个带有新 IPython 内核的新标签页。

使用相同内核的新标签页 创建一个父内核的子内核,该内核会加载在特定标签页上。在父标签页上初始化的对象将在两个标签页中都可以访问。

使用现有内核的新标签页 打开一个新标签页,并让你选择 IPython 以外的内核。

打印与保存

如果你是那种喜欢把程序打印出来并用红笔编辑的“老派”人,你会喜欢文件打印命令,它会生成代码的硬拷贝,正如它在控制台中显示的那样。

你可以通过文件保存为 HTML/XHTML 将 Qt 控制台会话保存为 HTML 或 XHTML 文件。如果你有任何内嵌的图形或图片,可以选择将它们写入外部 PNG 文件。PNG 图像可以保存在外部文件夹中,或者内嵌保存,以创建一个更大但更便携的文件。在 Windows 中,外部文件夹名为ipython_files,它存储在 HTML 文件所在的位置下。

使用 XHTML 选项时,图形将作为 SVG 文件内嵌显示。要将内嵌图形的格式从默认的 PNG 格式切换到 SVG 格式,请参见“保存与打印”部分,网址为 qtconsole.readthedocs.io/

虽然 Qt 控制台主要用于交互式工作,但你也可以将已保存的 HTML/XHTML 文件或外部文本编辑器中的代码复制到控制台中再次运行。然而,你需要去除任何输出行,并且你将失去行号格式(比较图 3-5 与图 3-1)。

图片

图 3-5:从 HTML 文件复制并在新的 Qt 控制台会话中再次运行的代码

你也可以使用%load魔法命令,将任何脚本(如文本文件或现有的 Python 文件)粘贴到 Qt 控制台作为下一次输入。然后,你可以编辑它或按原样执行。

注意

魔法命令是 IPython 对普通 Python 代码的增强功能,它简化了诸如加载文件之类的常见任务。在 Qt 控制台中使用的行魔法命令,以百分号符号(%)为前缀。

要查看%load命令如何工作,请为你的平台打开文本编辑器并输入以下内容:

print()
print(″This is just a test.″)

print()函数是一个内建的 Python 例程(迷你程序),用于将输出打印到屏幕。我们将在第十一章中更详细地讨论函数。

将此文件保存为 test.pytest.txt。在 Qt 控制台中,键入%load,然后输入你的文件路径,像我在图 3-6 中的示例一样。按回车键加载文件,再按一次回车键执行代码。

图片

图 3-6:使用%load魔法命令从文本文件加载并执行代码

%load 命令也可以从其他来源加载代码,如 URL。

常用的部分魔法命令列在 表 3-2 中。你可以在 ipython.readthedocs.io/en/stable/interactive/magics.html 了解更多相关内容。

正如你从这个例子中看到的,你不需要一个复杂的工具来编写 Python 程序;一个简单的记事本应用就足够了。但是你可以做得更好。专门用于编程的文本编辑器,如 Emacs、Vim、IDLE、Notepad++、Sublime Text 等,具有内置的功能,能让你更高效地编程。我们将在下一章介绍 Spyder 的文本编辑器。

表 3-2: 常用的行魔法命令

命令 描述
%cd 更改当前工作目录
%cls (或 %clear) 清除屏幕
%conda 在当前内核中运行 conda 包管理器
%load 加载代码到当前前端
%lsmagic 列出当前可用的魔法函数(按 ESC 键退出)
%matplotlib qt 在交互式 Qt 窗口中显示 Matplotlib 图表,而不是内嵌显示
%pprint 切换美观打印开关
%precision 设置浮点数精度以便美观打印
%pwd 返回当前工作目录路径
%quickref 显示魔法函数的参考资料(按 ESC 键退出)
%reset 从会话内存中移除所有变量
%timeit 计时 Python 语句或表达式的执行时间
%MAGIC? 在魔法命令后加上“?”可以显示其文档字符串

多行编辑

多行编辑 是一个有用的功能,终端中没有,但 Qt 控制台支持。它让你可以通过使用 CTRL-ENTER 替代 ENTER 来输入多行,而无需立即执行。

注意

本书使用 Windows 操作系统的习惯。macOS 用户在使用快捷键时应将 COMMAND 键替换为 CONTROL 键,将 OPTION 键替换为 ALT 键。

如果你仔细看 图 3-1 中的代码,你会注意到第 4 行看起来有些奇怪。第二行没有编号,而是以三个点作为前缀:

In [4]: chart = sns.FacetGrid(tips, row=′day′, ...
   ...: chart.map(sns.kdeplot, ′tip′);

由于我在输入第 4 行后按下了 CTRL-ENTER,这一行并未执行。因此,我能够在绘制图表之前完全定义它。如果我逐行输入并执行这些代码,我将会得到 图 3-7 中显示的不可接受的结果。

图片

图 3-7:逐行执行导致图表绘制时没有数据

此外,在多行块中的任何位置,你都可以通过使用 SHIFT-ENTER 强制执行当前的代码块(无需跳到最后一行)。

多行编辑是 Jupyter Qt 控制台与其他基本解释器的一个显著区别。对于短小的代码片段,这个功能非常方便,但随着程序变得更长,你会更倾向于使用真正的文本编辑器,它提供更高效且持久的编辑体验。

总结

Jupyter Qt 控制台是一个轻量级应用程序,适用于编写代码片段、快速探索数据集、测试想法以及完成编码教程。对于编写大型持久程序,你需要使用其他编码工具,如 Jupyter Notebook、JupyterLab 或 Spyder。

第四章:用 Spyder 进行严肃编程

image

科学 Python 开发 IDE(Spyder) 是一个开源的交互式开发环境,旨在由科学家为科学家设计。它将多个专业化工具,如文本编辑器、调试器、分析器、代码检查器和控制台,集成到一个全面的软件开发工具中。

Spyder 是为重度工作设计的,因此它比上一章中提到的 Jupyter Qt 控制台有更大的系统占用和更复杂的界面(图 4-1)。但这并不意味着你不能用 Spyder 来做小任务。它包含一个控制台,用于执行临时代码,还有一个文本编辑器,用于编写持久且易于编辑的各种大小的脚本。本书的第二部分使用了 Spyder,作为 Python 编程的入门教程,帮助你在需要学习 Python 或刷新某些概念时提供支持。

Image

图 4-1:科学 Python 开发 IDE(Spyder)

一般来说,如果你打算编写复杂的程序或开发应用程序,你会选择使用 Spyder 或类似的 IDE。

使用 Anaconda Navigator 安装和启动 Spyder

Spyder 已经预安装在你的 base 环境中。要在不同的环境中使用 Anaconda Navigator 安装 Spyder,首先通过选择首页标签顶部附近的 Applications on 下拉菜单中的环境名称来激活该环境(图 4-2)。在此示例中,我们使用的是在第二章中创建的 my_first_env。接下来,点击 Spyder 应用图标上的 Install 按钮来安装它。你可能需要向下滚动首页标签以找到该图标。

几分钟后,安装按钮应该会变成启动按钮。点击它来启动 Spyder。记住,如果你想安装 Spyder 的特定版本,可以点击右上角的齿轮图标,查看可用的版本号列表。

有关安装 Spyder 的更多信息,请参见安装指南 docs.spyder-ide.org/current/installation.html

Image

图 4-2:Anaconda Navigator 首页标签,显示活动环境(my_first_env)和 Spyder 瓷砖

使用 CLI 安装和启动 Spyder

Spyder 已经预安装在你的 base 环境中。要在新环境中使用 conda 安装它,首先打开 Anaconda 提示符(在 Windows 中)或终端(在 macOS 和 Linux 中),然后激活 conda 环境。我们通过输入以下命令为 my_first_env 环境执行此操作:

conda activate my_first_env

接下来,使用 conda 安装 Spyder:

conda install spyder

要安装特定版本,如 5.0.3,输入:

conda install spyder=5.0.3

要通过命令行启动 Spyder,输入:

spyder

有关安装 Spyder 的更多信息,请参见安装指南 docs.spyder-ide.org/current/installation.html

通过开始菜单启动 Spyder

在大多数平台上,官方文档建议通过 Anaconda Navigator 启动 Spyder。然而,在 Windows 中,推荐的启动方法是通过开始菜单启动 Spyder(见图 4-3)。

图片

图 4-3:Spyder 安装在 Windows 的开始菜单中的 Anaconda3 文件夹下

在那里,你应该能看到列出所有 Spyder 安装和它们所加载的环境,这些都在Anaconda3文件夹下。

配置 Spyder 界面

图 4-4 显示了带有标记的 Spyder 界面,展示了主要的面板和工具栏。请注意,我已将其外观从“出厂设置”视图更改,以便于本次讲解并使其在黑白书籍中更易于查看。不要被所有控制项和面板吓到。Spyder 可以根据你需要的复杂度来调整,既可以很简单,也可以很复杂。

为了让你更容易跟随操作,让我们将你的屏幕配置为更接近图 4-4 中所示的样子。首先,在偏好设置窗口中设置语法高亮主题,Windows 和 Linux 系统上通过点击顶部工具栏的工具偏好设置;macOS 系统上通过点击Python/Spyder偏好设置;或者点击屏幕顶部主工具栏的扳手图标(见图 4-4)。

找到语法高亮主题菜单,选择Spyder选项,然后点击确定。这会将背景设置为白色(如果你眼睛敏感,可以使用Spyder Dark)。请注意,你有许多代码高亮颜色的选择,就像在上一章中使用 Jupyter Qt 控制台时一样。

图片

图 4-4:标记关键组件的 Spyder 界面

现在,让我们将文件资源管理器面板移到屏幕的左侧。在界面顶部的工具栏中,点击视图解锁面板和工具栏。这使你可以像移动桌面窗口一样拖动它们。在右上角的面板中,找到灰色标签的文件,点击它。右上角的面板现在应该显示文件资源管理器窗口。抓住它的顶部,将其拖动到界面的左侧,如图 4-4 所示。你可以抓住面板的边缘来调整其大小。

在顶部工具栏中,点击运行运行分析器,然后点击运行代码分析。这些选项会自动作为标签显示在右上角的面板中,如图 4-4 所示。使用分析器,你可以测量代码的运行时间,而代码分析会检查样式违规和潜在的错误。

要保存这个或任何布局,在顶部工具栏中选择视图窗口布局保存当前布局,并为布局起一个唯一的名字。这样,当你启动 Spyder 时,这个布局就会成为默认布局。要选择另一个布局,请在视图窗口布局中查找。

正如你所看到的,Spyder 是高度可配置的。你可以通过拖动面板将其从 Spyder 中弹出。你可以分别使用 视图面板视图工具栏 来开启或关闭面板和工具栏。随着时间的推移,你的界面将逐步发展,变得独一无二。只要记得保存你的窗口布局!

在接下来的章节中,我们将探讨如何在不同环境中使用 Spyder,如何设置 Spyder 项目,以及如何使用 Spyder 的面板和工具栏。其他有用的参考资料包括 Spyder 的主页 (www.spyder-ide.org/)、文档 (docs.spyder-ide.org/current/index.html)、以及常见问题 (docs.spyder-ide.org/5/faq.html).

在环境和包中使用 Spyder

Spyder 是一个像其他包一样的工具,必须安装在 某个 conda 环境中。这意味着,如果你尝试导入并使用不在与 Spyder 相同环境中的包,就会遇到错误。为了处理这个问题,我们将看看简单但资源消耗大的 天真方法 和轻量但更复杂的 模块化方法

天真方法

使用 Spyder 与环境结合的最简单方法是将 Spyder 直接安装到每个 conda 环境中并从那里运行,就像我们在之前的安装示例中所做的那样。这种方法适用于所有版本的 Spyder,并且在 IDE 安装后应该无需额外配置。不幸的是,这种方法会导致需要管理多个安装,并且不像其他替代方案那样灵活或可配置。

例如,假设你在一月份启动了一个新项目并在该环境中安装了当前版本的 Spyder。六个月后的七月,你开始了另一个项目并将 Spyder 加载到该项目的新环境中。这个版本的 Spyder 可能比你在一月份安装的版本更新。此时,你的 pkgs 文件夹中有两个单独的 Spyder 安装,占用了空间。如果你不需要保留旧版本,一个选择是运行 conda update spyder 来更新你所有环境中的 Spyder 版本,然后运行 conda clean -all 来删除未链接到任何环境的版本。

如果你不打算频繁使用 Spyder,或者你不会同时处理大量项目,或者你的系统资源并不严重受限,那么你可能会发现天真方法是一个合适的解决方案。它确实符合 科学优先,编程其次 的思维方式。否则,请查看下一节中的模块化方法。

模块化方法

另一种使用现有环境的方法是将 Spyder 安装在一个位置,然后更改其默认的 Python 解释器。解释器是每个 conda 环境文件夹中存在的python.exe文件。根据你的系统,你可能会看到它被称为python.exepythonw.exepythonpythonw

采用模块化的方法,你只需安装一次 Spyder 并将其放入一个专用环境(我们称之为spyder_env)。这样,你可以单独更新 Spyder,避免与其他包产生冲突。你可以执行 Spyder 的最小安装,或者执行完整安装,后者包含所有 Spyder 的可选依赖项以确保其完全功能。

让我们使用命令行创建专用环境并执行完整安装,添加像 NumPy、pandas 等包:

conda create -n spyder_env spyder numpy scipy pandas matplotlib sympy cython

从现在开始,你将从这个专用环境启动 Spyder。

要允许spyder_env中的 Spyder 包导入并使用另一个环境中的包,你必须将轻量级的spyder_kernels包安装到另一个环境中,可以使用 Navigator 或 conda。举个例子,我们并没有在第二章中创建的my_second_env中安装 Spyder。要在该环境中使用 Spyder,请激活该环境并像这样执行安装:

conda activate my_second_env
conda install spyder_kernels

现在,你可以将运行在spyder_env中的 Spyder 应用程序指向my_second_env中的解释器,这样它就可以找到并使用安装在my_second_env中的包。

要在 Spyder 中更改 Python 解释器,请点击状态栏中当前环境的名称(见图 4-4),然后选择在首选项中更改默认环境(你也可以使用主工具栏中的“扳手”图标)。在首选项对话框中,选择Python 解释器,然后点击使用以下 Python 解释器旁边的单选按钮(见图 4-5)。从下拉列表中选择环境,或者使用文本框(或文本框右侧的选择文件图标)提供你希望使用的 Python 解释器路径。

Image

图 4-5:使用首选项对话框更改 Python 解释器

点击确定更改解释器,然后点击顶部工具栏中的控制台重启内核,使更改生效。状态栏上的环境名称应从spyder_env更改为my_second_env(见图 4-6)。现在,Spyder 可以从选定的环境中找到并导入包,无论 Spyder 包的位置在哪里。

Image

图 4-6:Spyder 状态栏显示 Python 解释器的源环境名称

注意,如果你将解释器更改为没有安装 Spyder 或 spyder-kernels 包的环境,当你尝试重新启动控制台时,将会在控制台中看到错误信息。同样,如果你尝试启动新控制台,你将看到图 4-7 中显示的提示信息。

Image

图 4-7:在没有 spyder-kernels 包的环境中启动新控制台会产生一个有用的错误信息。

如你所想,使用多个环境的模块化方法可能会变得繁琐,你可能会失去对正在使用哪个环境的跟踪。Spyder 的一些功能,如变量资源管理器,可能不会正确处理某些数据类型。如果你需要在特定项目中锁定 Spyder 的某个版本,你最终可能不得不运行多个 Spyder 安装,这样其他项目才能使用最新版本。

有关模块化方法的更多详细信息,请参阅 Spyder 开发团队关于在 Spyder 中处理环境和包的指南,网址为github.com/spyder-ide/spyder/wiki/Working-with-packages-and-environments-in-Spyder/

使用项目文件和文件夹

Spyder 允许你创建特殊的项目文件来存储你的所有工作。这些文件帮助你保持组织性,并且可以稍后重新加载项目,顺畅地继续工作。项目通过 Spyder 顶部工具栏中的项目菜单进行管理(打开、关闭、创建等)。

在新目录中创建项目

要创建一个新的项目文件作为新目录,请在顶部工具栏中点击项目新建项目。这将打开图 4-8 中所示的创建新项目对话框。为新项目命名为my_spyder_proj,选择一个磁盘位置,然后点击创建

Image

图 4-8:创建新项目对话框

这将创建如图 4-9 所示的目录结构。除了显示的文件夹,Spyder 还将创建八个文件,以帮助管理你的项目。

Image

图 4-9:创建新项目后 Spyder 的初始目录结构

为了保持项目的有序性,你可以向my_spyder_proj中添加其他文件夹。理想情况下,这些文件夹应该使用标准化的名称,简洁明了,这样你就可以轻松地在项目之间工作,并与他人共享它们。让我们现在以此为例。如果你已经有自己的系统,随时可以使用它。

在 Spyder 的文件资源管理器窗格中,右键点击my_spyder_proj,然后从弹出菜单中选择新建文件夹。在现有的.spyproject文件夹下添加如下所示的文件夹,如图 4-10 所示。

Image

图 4-10:文件资源管理器窗格中显示的新项目文件夹

在显示的命名格式中,code 用于存放你的 Python 代码;data 用于存放数据文件,如 Excel 表格、.csv 文件、图片等;documents 用于存放文本文件,如报告;output 用于存放你的代码生成的图表和表格;misc 用于存放其他所有内容。

为了使你的项目真正独立,我建议将你的 conda 环境和 Python 包列表包含在项目文件夹中。为此,如下一节所述,在现有目录中创建 Spyder 项目。

在现有目录中创建项目

有时,你可能希望在一个 现有的 目录中创建你的 Spyder 项目。例如,当你想将 conda 环境包括在项目中时,这样这个重要的文件夹就可以与其他项目文件一起打包,方便你轻松地共享或归档项目。

当存储在 Spyder 项目中时,环境文件夹应该命名为类似 envconda_env 的名称。如果你有多个项目,想要为环境文件夹添加一个项目名称——如果项目名较长,可以缩写——例如 env_PROJ_NAME。这样,你在从 Windows 开始 菜单启动时,就可以识别正确的 Spyder 安装。请记住,在默认的 pkgs 文件夹之外创建环境有一些小的缺点,因此在提交之前,建议你查看 第 37 页的“指定环境位置”。

为了将 conda 环境文件夹包含在你的 Spyder 项目中,我们将通过命令行同时创建项目和环境文件夹。我们将项目文件夹命名为 spyder_proj_w_env,并使用 conda 同时创建这两个文件夹。在这个示例中,我将其放置在 Windows 中的 *C:\Users\hanna* 文件夹下,但你可以将其放在任何位置。

注意

后续的说明遵循了“使用 Spyder 与环境和包”的简单方法,正如本章前面所述。如果你使用的是 模块化方法,你只需在项目的 conda 环境中安装 spyder-kernels 包。之后,从 Spyder 的专用环境启动它,再将其 Python 解释器切换到你的项目 conda 环境。

首先,如果 Spyder 当前正在运行,请使用 文件退出 在顶部工具栏退出它。接下来,打开 Anaconda 提示符(在 Windows 中)或终端(在 macOS 和 Linux 中),并输入以下内容:

conda create -p C:\Users\hanna\spyder_proj_w_env\conda_env python=3.9 spyder=5.0.3

记住,-p--prefix 的简写,它允许你指定目录路径。我们还同时安装了 Python 和 Spyder,指定了推荐的版本号。这代表了 Spyder 的最小安装。若要安装 Spyder 所有可选的依赖包以实现完全功能,你可以在前面的命令中将这些包名添加到 Spyder 后面(为简洁起见,我省略了版本号):

numpy scipy pandas matplotlib sympy cython

现在,激活新的环境并通过输入以下两行启动 Spyder,替换为你的环境路径:

conda activate C:\Users\hanna\spyder_proj_w_env\conda_env
spyder

此时,你可以通过选择 Spyder 顶部工具栏中的项目新建项目来创建一个新项目。这一次,选择现有目录,将项目名称留空,并将位置设置为新项目文件夹的路径,spyder_proj_w_env,如图 4-11 所示。

Image

图 4-11:使用现有目录创建新的 Spyder 项目

现在,你可以像我们在上一节中所做的那样,添加代码、数据等额外的文件夹。在这一点上,你将拥有一个独立的项目(图 4-12)。

Image

图 4-12:带有嵌入式 conda 环境(conda_env 文件夹)的新 Spyder 项目

同样,你可以使用任何你喜欢的文件组织系统,但我强烈建议不要把所有东西直接丢进项目文件夹。这样会造成混乱,尤其是在处理大型项目时。

使用项目面板

在处理项目文件夹时,你有几个选择。图 4-10 是从 Spyder 的文件资源管理器面板中截取的。如果你希望在使用 Spyder 时只看到你的项目文件夹,可以通过点击顶部工具栏的视图面板项目来打开项目面板。要关闭文件资源管理器面板,可以使用面板右上角的“汉堡”图标,或者通过顶部工具栏的视图面板并取消选择该面板。

注意

你也可以在 Spyder 中通过操作系统的文件资源管理器查看你的项目文件夹。在项目面板或文件资源管理器面板中,右键点击项目文件夹,然后选择 在文件夹中显示

帮助面板

Spyder 的帮助面板无论是对初学者还是经验丰富的程序员都非常有用。要激活它,请点击图 4-4 中右上角面板底部的帮助标签。

当你第一次启动 Spyder 时,你会在帮助面板中看到一条消息,提示你阅读一篇简短的介绍性教程(图 4-13)。我强烈推荐你阅读,但如果你想等一下,也可以稍后通过顶部工具栏的帮助菜单来查看它。

Image

图 4-13:初始启动时的帮助面板

除了介绍性教程外,工具栏的帮助菜单还提供了更长的 Spyder 教程,它会显示在帮助面板中(图 4-14)。你还可以观看视频,访问 Spyder 和 IPython 文档,查看键盘快捷键摘要等等。

Image

图 4-14:帮助菜单和在帮助面板中显示的 Spyder 教程

如果你在编码时打开了帮助窗格,它可以查找、呈现并显示任何带有文档字符串(描述性文本摘要)的对象的文档,包括模块、类、函数和方法。这让你可以直接在 Spyder 中访问文档,而无需中断工作流去查看其他地方。

帮助窗格顶部的源菜单让你可以在编辑器和 IPython 控制台之间进行选择(见图 4-15)。手动点击一个对象,比如图 4-15 中的print()函数,然后按 CTRL-I(在 macOS 上是 CMD-I)会显示该项的信息。你也可以通过在窗格顶部的对象文本框中手动输入对象名称(比如“print”)来获取帮助。

Image

图 4-15:在编辑器中使用 CTRL-I(Windows)调用的 print() 函数的帮助输出

要启用编辑器和控制台的自动帮助,首先点击主工具栏上的扳手图标(见图 4-4),然后选择帮助,并在自动连接下点击编辑器和 IPython 控制台的单选按钮。之后,可以通过帮助窗格顶部的“锁”图标打开和关闭它。当启用时,只需在函数或方法名称后输入左括号字符((),即可显示其关联的帮助文档。

你也可以通过将鼠标悬停在编辑器中的对象上来访问对象的摘要帮助。点击悬停弹窗将会在帮助窗格中打开完整的文档。确保“源”菜单设置为“编辑器”。

最后,帮助窗格右上角的“汉堡”图标可以切换显示模式下的功能,比如富文本或纯文本、停靠和取消停靠窗格、关闭窗口等等。

IPython 控制台

IPython 控制台位于图 4-4 中的右下窗格,代表与 Python 的直接连接,允许你交互式地运行代码。我们在第三章中已经回顾了大部分功能,因此这里不再赘述。

使用 Spyder,你可以打开多个控制台、重启内核、清除命名空间、查看历史日志、取消停靠窗口以及执行类似的任务。你可以通过点击 IPython 控制台窗格顶部的命名标签、使用窗格右上角的“汉堡”图标,或者点击顶部工具栏中的控制台来选择这些选项。你还可以与增强版 Spyder 调试器和变量资源管理器进行完整的图形用户界面集成,我们将在后续章节中探讨。

使用控制台进行输出和绘图

当你使用 Spyder 的文本编辑器时,任何基于文本的输出都会显示在控制台中。同样,任何基于 Matplotlib 的图形将会显示在控制台中,如你在第三章中看到的,或者显示在 Spyder 的 Plots 窗格中。Pltots 窗格是默认位置,但你也可以通过打开Plts窗格,点击右上角的“汉堡”图标,然后取消选择静默内联绘图,强制图形显示控制台内。你也可以通过选择顶部工具栏中的工具首选项IPython 控制台图形,然后从图形后端菜单中选择来控制图形的显示方式(图 4-16)。

Image

图 4-16:IPython 控制台图形对话框

在控制台显示图形是一个不错的选择,如果你想保存交互式会话的记录。然而,如果你需要与图形进行交互,例如缩放、配置子图、操作文件并使用不同的格式保存图形,你就需要在新窗口中打开图形。你可以通过在程序顶部导入模块后添加魔法命令%matplotlib qt来实现这一点。

注意

某些类型的图形无法在 Spyder 中显示,而是会在浏览器或外部本地窗口中打开。这些图形包括基于 Web 的图形和 Turtle 和 TKinter 窗口。

在控制台中使用内核

Python 内核是执行代码的计算引擎。你可以在控制台中使用几种操作内核的方式,包括启动新的内核和中断正在运行的内核。这些功能可以通过顶部工具栏中的控制台菜单、命名的控制台标签,或 IPython 控制台窗格中的“汉堡”图标访问。

你还可以通过控制台菜单连接到外部本地和远程内核(包括由 Jupyter Notebook 或 Qt 控制台管理的内核)。要了解更多信息,请访问 docs.spyder-ide.org/5/panes/ipythonconsole.html

清除命名空间

Python 内核会跟踪你在编写代码时使用的对象,例如变量和函数。这个在控制台中定义的对象集合被称为命名空间。为了防止命名空间变得凌乱,Spyder 允许你在任何时候清除命名空间。

让我们来看一个示例。图 4-17 中的左窗格是文本编辑器,右窗格是控制台。你可以在两者中编写代码。在编辑器中,我设置了 x = 5,然后按 F5 运行程序。因为我没有包括 print() 函数,所以看似没有任何反应,但实际上,Python 已经将值 5 分配给了变量 x

Image

图 4-17:文本编辑器(左)和控制台(右)共享相同的 x 值

现在我决定停下来在控制台中测试一个编程想法。我想使用 x 的值为 10,但我忘记输入这个值。相反,我直接将 x 乘以 10,得到了 50 的输出 (图 4-17)。通常来说,这会引发错误,因为我还没有命名 x,但是由于我之前在编辑器中做了这件事(我认为那是一个独立的程序),x 已经在命名空间中了。从我的角度来看,这个结果是出乎意料的。

在一个小片段中调试很简单,但想象一下你正在处理更长、更复杂的程序。x 变量的一次出现可能会隐藏在 200 行代码中。即便是小程序,一个常见的错误是运行程序、删除了某个重要的部分,然后在程序正确运行时没有注意到这个错误,因为被删除的对象已经驻留在内存中。

这些持久对象很容易被忘记,它们可能来自多个来源,包括先前执行的代码、控制台中的交互式工作或便捷的库导入(Spyder 可能会自动进行一些便捷导入)。要在不重新启动内核的情况下删除这些对象并清空命名空间,你可以点击顶部工具栏中的删除所有变量,或在控制台面板中的控制台标签下进行操作。你也可以通过在控制台中输入以下命令删除所有变量:

%reset

如果你想查看会话中全局命名空间中定义的对象,可以使用:

dir()

注意,即使删除了所有变量,仍会有十几个内建对象存在。命名空间永远不会完全为空。

通常来说,每当你完成编写一个程序时,应该首先通过删除所有变量或启动一个新内核来检查程序是否能够独立运行。

历史面板

历史面板 (图 4-4) 包含了你在控制台中运行的所有命令和代码的时间戳记录。你可以使用这个日志回顾你的操作步骤并重现你的工作。然而,它不会显示输出或消息,如果你在编辑器面板中运行程序,它只会显示文件被运行,而不会显示执行了哪些命令。不管你打开了多少个控制台,历史面板只有一个。所有来自不同控制台的命令将按执行顺序列出,而不会标明是来自哪个控制台。

你可以从历史面板中复制命令,并将它们粘贴到控制台和编辑器中。目前,历史面板最多只能显示 1,000 行历史记录,并且没有办法清除历史。命令列表存储在用户主文件夹中的 history.py 文件中,位置在 .spyder-py3 目录下(例如,Windows 系统中为 C:/Users/<username>,macOS 系统中为 /Users/,在 GNU/Linux 系统中为 /home/)。

特殊控制台

除了 IPython 控制台,Spyder 还支持几种特殊控制台,你可以从顶部工具栏的“控制台”或通过使用 IPython 控制台面板上的“汉堡”图标启动。例如,Cython 控制台让你使用 Cython(Python 语言的超集)来加速代码,并能直接从 Python 调用 C 函数。SymPy 控制台则支持创建和显示符号数学表达式。你还可以通过 首选项IPython 控制台高级设置使用符号数学 来激活符号数学功能,前提是你已安装 SymPy 包。更多内容,请点击顶部工具栏上的 帮助Spyder 教程

编辑器面板

文本编辑器(图 4-4)是 Spyder 的核心。与控制台基本上是为一次性、交互式脚本编写而设计的“草稿本”,几乎没有持久化功能不同,Spyder 的编辑器面板让你能够创建可以保存和稍后运行(或编辑)的程序。你可以将它看作是一个带有代码友好功能的文字处理器,如语法高亮、实时代码和风格分析、按需补全、常用键盘快捷键、水平和垂直拆分等。

使用编辑器编写程序

要测试编辑器,可以使用命令行或导航器激活你在本章“在现有目录中创建项目”部分中创建的spyder_proj_w_env环境。要在 IDE 中尝试绘图,请使用导航器或命令行在活动环境中安装 NumPy 和 Matplotlib 包。在命令行中,这看起来像以下内容:

conda install matplotlib numpy -y

-y--yes 的简写)只是确认在执行时的安装命令,这样你就不需要在过程中手动确认。为了方便起见,我这样展示,但手动确认安装和删除总是更安全。这也给你提供了另一个机会,确认正确的环境已被激活,并且 conda 不需要降级现有包,避免由于某些依赖问题。

接下来,从相同的环境启动 Spyder,可以通过 Windows 开始菜单(确保选择带有正确环境名称的图标)、Anaconda Navigator 或命令行启动。现在你可以使用编辑器编写你的第一个程序了。

为了评估 Spyder 的绘图功能,让我们使用 Matplotlib 画廊中的“Stem Plot”示例(matplotlib.org/stable/gallery/index.html)。通过点击顶部工具栏中的 文件新建文件 或使用 CTRL-N 来启动一个新文件。你将看到一个新的“未命名”标签出现在编辑器面板中。

删除编辑器窗格中的模板文本,并输入以下代码。与控制台不同,按 ENTER 键添加新行是可以的。在脚本模式下,您的代码稍后会通过特殊命令执行。如果您是初学者,不必担心代码的细节;目前,专注于编辑器窗格的操作方式:

″″″Stem plot example from matplotlib gallery″″″

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0.1, 2 * np.pi, 41) y = np.exp(np.sin(x))

plt.stem(x, y)

print(″\nThis is a stem plot.″)

虽然现在已经可以运行这段代码,但首先让我们使用顶部工具栏中的 文件另存为 来保存它。将文件命名为 stem_plot.py,并将其保存在 Spyder 项目的 code 文件夹中。

注意

文本编辑器支持许多键盘快捷键。要查看快捷键列表,请点击顶部工具栏中的 帮助快捷键概述。要查找特定快捷键,请点击 工具首选项键盘快捷键,同样在顶部工具栏中。

要执行代码,您有几个选择。您可以使用运行工具栏左侧的“播放”箭头(参见图 4-4),点击编辑器窗格内并按 CTRL-ENTER,或在编辑器窗格内按 F5(或 FN-F5,具体取决于您的键盘)。

如果这是您第一次在 Spyder 中运行程序,系统会要求您选择一个运行配置(参见图 4-18)。选择默认选项 在当前控制台中执行。我们将在本章后面的小节中讨论这意味着什么,具体是“设置运行配置”部分。

Image

图 4-18:运行设置对话框

现在,您应该能看到图 4-19 中显示的结果。

Image

图 4-19:执行 stem_plot.py 程序后的 Spyder 界面

在图 4-19 中需要注意的一点是,文本输出“这是一个茎图”出现在控制台窗口中。这比在控制台中编程要干净得多,因为在控制台中,代码及其输出(包括错误信息)会一起显示在同一个窗口中。

在编辑器中运行程序后,程序就会被“识别”到控制台。这意味着它会记住像命名变量和定义的函数等内容。要查看示例,请在 IPython 控制台中输入以下内容,然后按 ENTER 键:

plt.stem(x, y)

这应该会重新生成图 4-19 中的茎图。

这种行为在开发和调试复杂程序时非常有用,尤其是在探索大型数据集时,您不希望加载超过一次。它也可能导致意外结果,正如我们在本章“清空命名空间”部分所讨论的那样。

现在,让我们看看另一种显示图形的方式。在编辑器窗格中,在导入语句下方添加 %matplotlib qt 魔法命令,如下所示:

″″″Stem plot example from matplotlib gallery″″″

import matplotlib.pyplot as plt
import numpy as np
%matplotlib qt
--snip--

将文件保存为stem_plot.py,然后使用 CTRL-ENTER 运行程序。在这种情况下,图形会显示在外部窗口中(如果没有弹出窗口,请检查任务栏是否有图标)。这个 Qt 窗口有一个工具栏,比内联模式下提供的选项更多(参见图 4-20)。

Image

图 4-20:在外部 Qt 窗口中显示的 Stem 图

现在是重新访问全局命名空间的好时机。关闭 Qt 窗口。现在,从文件中删除 %matplotlib qt 魔法命令,并按 CTRL-S 保存。无论你是从编辑器重新运行文件,还是像之前一样在控制台使用 plt.stem(x, y),Qt 窗口都会再次弹出。即使你将其从文件中删除,旧的命令仍然会在内存中持续存在。

要恢复内联绘图,请重启内核或运行魔法命令 %matplotlib inline,无论是在控制台还是通过编辑器。

定义代码单元格

在前面的示例中,你将完整的程序输入到编辑器窗格中然后运行。你也可以一次运行一行,或运行一个连贯的代码块,称为“代码单元格”。

查看示例时,创建一个新文件,不过这次使用文件工具栏左侧的矩形“新建文件”图标(图 4-4)。将文件命名为 temperature_converter.py 并保存在 code 文件夹中。

在此示例中,假设你总是需要将温度测量值从一个刻度转换到另一个刻度,并且你希望将几个转换公式放在一个文件中以方便使用。你不想每次都运行整个程序,因为你通常只执行一次转换,比如从华氏度到摄氏度,或从摄氏度到开尔文度。在这种情况下,代码单元格是一个便捷的解决方案。

将以下代码输入到新文件中并保存。使用 #%% 将代码分割成独立的单元格:

#%% Convert Temperature: Fahrenheit to Celsius
degrees_f = 0
degrees_c = (degrees_f - 32) * 5 / 9
print(f″{degrees_f} F = {degrees_c} C″)

#%% Convert Temperature: Celsius to Fahrenheit
degrees_c = -40
degrees_f = (degrees_c * 9 / 5) + 32
print(f″{degrees_c} C = {degrees_f} F″)

#%% Convert Temperature Celsius to Kelvin
degrees_c = 0
degrees_k = degrees_c + 273.15
print(f″{degrees_c} C = {degrees_k} K″)

在分隔符右侧添加描述不仅记录了该单元格的功能,还在大纲窗格中为该单元格命名。要激活此窗格,请前往顶部工具栏,点击 查看窗格大纲。图 4-21 显示了编辑器和大纲窗格的组合。

Image

图 4-21:温度转换器程序(temperature_converter.py)的编辑器窗格(左)和大纲窗格(右)

在图 4-21 中的编辑器窗格中,注意到水平线如何将脚本划分为以 #%% 分隔的单元格。在大纲窗格中,单元格的描述按顺序从上到下出现。如果点击某个描述,编辑器窗格中对应的单元格将被高亮并激活。你也可以通过在文本编辑器中点击单元格来高亮显示该单元格。

要查看运行单元格的选项,将鼠标光标悬停在运行工具栏上的图标上(图 4-4)。这还会显示快捷键。要运行仅将摄氏度转换为华氏度的中间单元格,请点击该单元格,然后按 CTRL-ENTER 或点击工具栏上的 运行当前单元格 图标。你也可以使用图标或 F9 键运行选定的代码或单行代码。

能够运行选定的单元或单行代码在设计和调试程序时非常有用。它还很方便,比如说,改变绘图参数并评估结果,而不需要重新加载所有输入数据。你也可以用它更新程序的一部分,而不重新运行所有代码,但请记住,控制台只会“记住”最后一次执行的内容。如果这变得令人困惑,你可能需要通过重启内核或清除所有变量来刷新控制台。

设置运行配置

当你第一次在编辑器中运行程序时,无论是通过工具栏的 RunRun 还是按 F5 键,都会弹出一个对话框,询问你选择执行文件的方法(参见图 4-18)。你会有三个选择:

  • 在当前控制台中执行(默认)

  • 在专用控制台中执行

  • 在外部系统控制台中执行

接下来我们将详细介绍前两种,但对于初学者的建议是使用默认选项在当前控制台中执行,然后验证已完成的代码是否可以独立执行。这需要通过删除所有变量或在检查程序之前重启内核来清理命名空间。

不用担心被锁定在某个决定中。你可以随时通过从顶部工具栏选择 RunConfiguration per file 来更改运行配置。

在当前控制台中执行

当在当前控制台中执行文件时,文件运行完毕后,你可以继续与控制台交互。这使得你可以检查并与执行过程中创建的任何对象进行交互。这是增量编码、测试和调试时非常有用的功能。如同在“使用编辑器编写程序”中所示,它让你可以从控制台调用命令和函数,而无需再次执行文件。

然而,这也有代价。对象可能在执行代码之前就已经在全局命名空间中持久存在(参见第 76 页的“清理命名空间”)。确保代码不依赖于命名空间中现有但临时存在的对象的一种方法是,如下所述,在新控制台中执行文件。

在专用控制台中执行

选择在专用控制台中执行选项意味着每次在编辑器中执行代码时,都会打开一个新的 IPython 控制台。选择此选项,可以确保没有持久化的全局对象污染命名空间,例如未定义的函数、无名变量或未导入的包。这是一个安全的选项,但在与代码交互时提供的灵活性稍差。它还可能生成很多控制台标签需要管理。因此,如果你已经意识到命名空间问题,在当前控制台中执行选项是更优选。

自动完成文本

为了节省你的敲击次数,文本编辑器和控制台都支持使用 TAB 键进行 自动完成。例如,在编辑器中输入以下长变量名:

supercalifragilisticexpialidocious = 'wonderful'

现在,慢慢开始再次输入它,看看会发生什么。

当你开始输入一个对象的名称时,比如命令、函数、变量、类等,编辑器会显示以这些字母开头的对象列表(图 4-22)。在控制台中,你必须按 TAB 键来显示列表。

Image

图 4-22:使用自动完成功能

如果名称是唯一的,或者位于列表的顶部,你可以直接按 TAB 或 ENTER 键,Spyder 会自动填充剩余的部分。如果有多个选择,你可以继续输入,直到只剩下你想要的名称,然后按 TAB 或 ENTER;也可以使用箭头键选择正确的名称并按 TAB 或 ENTER;或者用鼠标双击正确的名称。你可以通过进入 工具首选项自动完成与 Linting 来改变看到建议完成列表所需的字符数。

自动完成是一个很棒的功能,因为它支持编写易于阅读的“Pythonic”代码。通过自动完成,你可以使用高度描述性的变量和函数名,例如 photoshpere_temperature_in_celsiusstep_2_apply_Gaussian_blur(),而不会产生重复性劳损。

代码分析窗格

Python 有一些写代码的准则,社区成员应遵循这些准则。目标是编写 Pythonic 代码,其他人可以轻松理解和使用。我们将在第二部分中进一步讨论这些准则。现在,只需要知道,linter 是一种工具,可以审查你的代码并提供反馈,指出可能违反准则的地方。Spyder 在其代码分析窗格中使用了业内最好的 Pylint linter。

代码分析将帮助你通过检测风格问题、不良实践和潜在的 bug 来改进代码。在你通过 linter 运行程序之前,你不应该认为程序已经完成——或者准备好发布到在线帮助网站上。

让我们来看看这个功能是如何工作的。从顶部工具栏选择 项目打开项目,打开你之前在“在现有目录中创建项目”一节中创建的 spyder_proj_w_env 项目。然后,使用顶部工具栏的 文件打开 打开 stem_plot.py 文件。我们之前在“使用编辑器编写程序”一节中创建了这个文件。接下来,通过点击编辑器窗格并按 F8,或通过使用顶部工具栏的 运行代码分析 来打开代码分析窗格。你应该看到图 4-23 中显示的结果。

Image

图 4-23:编辑器窗格(左)和代码分析窗格(右)用于 stem_plot.py 程序

在右侧代码分析窗格的顶部,你可以看到代码获得了 8.33 分(满分 10 分)的高评价。唯一的违规之处是程序末尾存在多余的换行符(空行)。

你可以通过点击 工具首选项,然后从 常规Linting代码风格和格式文档字符串风格标签中进行选择,来定制代码分析。这里有很多选项,包括忽略某些错误和警告、改变格式化代码所用的工具、选择用于检查文档字符串的约定、下划线标记错误和警告等。

你还可以通过向代码中添加特定的注释来抑制消息。例如,一个期望是大多数全局空间中的变量应表示常量并应使用全大写字母命名。在短小的程序中,你可能选择通过在文件顶部插入以下注释来忽略这一点:

# pylint: disable=invalid-name

要找到正确的消息名称,如“trailing-newlines”,请检查代码分析窗格中的结果(见图 4-23)。

要了解更多代码分析的内容,请参阅 Spyder 文档中的“深入窗格”部分(docs.spyder-ide.org/)。要了解更多 Python 风格指南,请参阅 pep8.org/

变量资源管理器窗格

变量资源管理器窗格允许你查看并编辑在文本编辑器中执行程序时生成的变量,或在 IPython 控制台中直接输入的变量。这些是当前 IPython 控制台会话的命名空间内容,你可以通过各种基于 GUI 的编辑器来使用变量资源管理器检查、添加、删除和编辑它们的值。

让我们来试试。首先,在顶部工具栏中点击 控制台重启内核,以启动一个新的 IPython 控制台会话。这将删除任何可能存在于内存中的旧变量。现在,在右上角窗格中,点击 变量资源管理器 标签,或者在顶部工具栏中点击 查看窗格变量资源管理器

在 IPython 控制台中,输入以下内容:

In [1]: import numpy as np

In [2]: an_array = np.random.randn(10, 5)

In [3]: a_list = ['talc', 'gypsum', 'calcite']

In [4]: a_dictionary = {'gold': 'Au', 'silver': 'Ag'}

In [5]: a_sum = 1 + 2 + 3

In [6]: a_float = 10 / 3

In [7]: a_string = ″latchstring″

每次按下 ENTER 键时,变量资源管理器窗格应该会更新,直到它显示像图 4-24 那样。

Image

图 4-24:变量资源管理器窗格

窗格显示变量的名称;它的类型,例如整数、字符串、字典等;它的大小;以及它的值。在变量资源管理器中右键单击对象时,会显示出进一步绘图和分析这些对象的选项。该窗格支持编辑列表、字符串、字典、NumPy 数组、pandas DataFrame、pandas Series、Pillow 图像等,允许你通过单击绘制和可视化它们。例如,虽然 10 行 5 列的 NumPy 数组太大,无法在值列中显示,但如果你双击它,会出现一个对象查看器窗口,允许你查看数组并操作其内容(见图 4-25)。

Image

图 4-25:an_array 对象的对象查看器显示

同样,在变量浏览器面板中双击列表对象所在的行,会启动对象查看器(图 4-26)。你可以通过右键点击对象查看器中的一行来执行一些操作,比如插入一行或添加新项,如“fluorite”。

如果你在当前会话中再次使用 a_list 变量,它将包含新项“fluorite”。你还可以使用变量浏览器的工具栏将当前会话的数据保存为 .spydata 文件,稍后可以加载该文件来恢复所有存储的变量。但是,请注意,在对象查看器中更改对象的值并不会改变你的代码。如果你重新运行生成 a_list 变量的代码,无论是从文件还是控制台,它都不会包含“fluorite”。

图片

图 4-26:对象查看器显示一个列表对象

你可以通过点击面板右上角的“汉堡”图标来筛选变量浏览器中的项目。如果某个项目可以绘图,你可以通过右键点击该对象生成一个符合其数据类型的图表。例如,右键点击 an_array 对象,然后选择 显示图像。这将生成该数组的热力图(图 4-27)。

图片

图 4-27:an_array 对象的热力图

Variable Explorer(变量浏览器)有一个限制,它无法显示在函数内定义的“局部”变量(我们将在第十一章中讨论函数)。如果你使用以下代码定义了一个函数,你将无法在面板中看到 var1var2 变量。

In [1]: def a_function():
   ...:      var1 = 42
   ...:      var2 = ″spam″
   ...:

变量浏览器让你跟踪程序的全局变量。它通过允许在友好的 GUI 格式中检查和编辑变量,帮助你开发和调试程序。欲了解更多可用选项,请参见 Spyder 文档中的 docs.spyder-ide.org/5/panes/variableexplorer.html

性能分析器面板

Profiler(性能分析器)通过测量文件中每个函数或方法的运行时间和调用次数,帮助你优化代码。你可以利用它识别瓶颈,并在进行更改后定量地衡量性能的提升。

让我们看一个例子,了解它是如何工作的。在顶部工具栏中,点击文件新建文件,打开编辑器中的新文件。将该文件保存在 code 文件夹中,位于你的 spyder_proj_w_env 项目中(或者你想保存的其他地方),文件名为 hoot.py。现在,输入以下代码:

def search_list(my_iterable):
    if 'hoot' in my_iterable:
        print(″Hooty hoot!″)

def search_set(my_iterable):
    if 'hoot' in my_iterable:
        print(″Hooty hoot!″)

my_list = [i for i in range(1000)]
my_list[998] = 'hoot'

my_set = set(my_list)

search_list(my_list)
search_set(my_set)

在这个例子中,我们定义了两个函数,search_listsearch_set,它们除了名称之外是完全相同的。我们将使用分析器来证明,在 Python 的集合中搜索一个项目比在 Python 的列表中搜索要快得多,因此我们需要区分这两个函数(我们将在第九章中更详细地讨论集合和列表)。

接下来,我们创建了一个包含 0 到 999 的数字的列表(Python 从 0 开始计数,而不是从 1 开始),并将倒数第二个项(索引 998)替换为“hoot”。然后我们从这个列表中创建了一个集合,命名为my_set。现在,我们调用每个函数,并根据需要传递列表或集合(传递意味着我们在函数的括号中输入列表或集合的名称)。当每个函数到达列表或集合中的“hoot”项时,它会立即在控制台中打印“Hooty hoot!”。

通过点击编辑器面板中的文件并按下 F5 键来运行文件。你应该能在 IPython 控制台中看到Hooty hoot!显示两次。

要查看每个函数运行所需的时间,点击顶部工具栏中的运行运行分析器。这将启动分析器面板并显示运行统计信息(见图 4-28)。

Image

图 4-28:分析器面板

总时间列显示指定项目及其调用的每个函数所花费的时间(缩进显示在其下)。本地时间列仅计算在特定可调用对象的作用域内所花费的时间。根据本地时间,列表对象的运行时间为 14.8 微秒,而集合则只有 400 纳秒。由于这两个函数除了输入不同之外是相同的,我们可以推测,哈希集合比列表在进行成员查找时是更好的数据类型。

请注意,你可以使用分析器面板顶部的文本框选择文件,并通过文本框右侧的绿色“播放”箭头来运行它们(见图 4-28)。其他选项包括显示程序输出、保存分析数据、加载分析数据以进行比较和清除比较。

要了解更多关于分析器的内容,包括衡量代码内存使用情况的选项,请参见docs.spyder-ide.org/5/panes/profiler.html

调试器面板

调试是检测和消除代码中的错误(“bug”)的过程,这些错误可能导致程序崩溃、返回错误结果或表现出其他意外行为。Python 会自动生成错误信息,帮助你确定代码中的哪个部分出现了问题。

对于更复杂的方法,Spyder 集成了 Python 标准库中的增强版ipdb调试器。通过调试工具,你可以逐行查看代码,检查问题。

深入探讨调试器的细节超出了本书的范围,你可能会写很多代码而不需要用到它。不过,如果你感兴趣,你可以在docs.spyder-ide.org/5/panes/debugging.html上获得一个很好的概述,还有许多使用真实编码示例的在线教程和视频。

总结

Spyder 足够强大,适合全职开发者,因此这里有很多内容我们没有涵盖。但尽管它功能强大,对于初学者来说也很容易上手使用,如果你只是想快速编写短小的脚本,它的编辑器和 IPython 控制台非常适合。虽然你大部分的科学编程可能会在接下来讲解的 Jupyter Notebook 中进行,但有许多编码任务 Spyder 更为适用,而你一定会很高兴将其加入到你的工具库中。

如果你是 Python 新手,并且想立即开始学习这门语言,你可以跳到第二部分,即《Python 入门》。完成后,别忘了回到第一部分,查看关于 Jupyter Notebook 和 JupyterLab 的章节(第五章和第六章)。

第五章:5

JUPYTER NOTEBOOK:计算研究的交互式日志**

image

经典的 Jupyter Notebook 是全球最流行的数据科学工具。作为一个可以保存的基于 Web 的应用程序,Notebook 让你能够捕捉整个计算过程,从加载和探索数据到开发和执行代码,甚至记录和展示结果。难怪 Notebook 已成为基于代码的研究的默认环境。

用 Anaconda 的定制服务总监 James Bednar 的话来说,notebooks 讲述故事。它们旨在捕捉并传达一个基于代码的叙事,这个叙事有线性的流程,并由小而易于理解的步骤组成。它们可以包括简明且准确地解释发生了什么的文档。这帮助科学家、研究人员、开发者和学生生成可重复的基于代码的研究。

像个人科学日志一样,Jupyter notebook 可以作为一次计算会话的完整记录。为了让你的工作更易于理解和重复,你可以将输入和输出与叙述文本、数学公式、图片、链接等交织在一起。你还可以直接分享你的 notebooks,或者将它们转换为互动幻灯片或仪表板。

在这一章中,我们将深入探讨经典版本的 Jupyter Notebook。下一章中,我们将介绍 JupyterLab 中的新版本,它是 Project Jupyter 的下一代界面。除了菜单的轻微重排,新版本的功能和经典 Notebook 一样,并且使用相同的文件格式。事实上,这两个版本可以在同一台计算机上并行运行,JupyterLab 甚至配有一个按钮用于启动经典版本。

注意

在接下来的页面中,Jupyter Notebook 或 Notebook(大写“N”)指的是应用程序,而 Jupyter notebook 或 notebook(小写“n”)指的是应用程序生成的实际 notebook 文件。这些文件的扩展名为.ipynb,代表“IPython notebook”。

为了补充这一章,你可以在jupyter-notebook-beginner-guide.readthedocs.io/en/latest/找到快速入门指南,并在jupyter-notebook.readthedocs.io/en/stable/notebook.html找到完整的文档。

安装 Jupyter Notebook

Jupyter Notebook 是一个开源包,预装在 Anaconda 的base环境中。然而,在base环境中工作并不是一个好主意,因为那样会变得混乱。为了保持项目包的有序、安全和可共享,它们需要放在专门的 conda 环境中。

要在 conda 环境中使用 Jupyter Notebook,你有两种主要选择。你可以在每个 conda 环境中直接安装 Jupyter Notebook,或者将每个环境链接到base环境中的 Notebook 安装。为了模仿我们在第四章中使用 Spyder 的做法,我们将第一种选择称为幼稚的做法,第二种选择称为模块化的做法。虽然模块化的做法通常更为推荐,但如果一个项目需要锁定特定版本的 Notebook,你将需要使用幼稚的做法。

幼稚的做法

幼稚的做法是直接在每个 conda 环境中安装 Jupyter Notebook。然后,Notebook 可以导入并使用同一环境中安装的任何包。

这是最简单的方法,但随着时间的推移,随着pkgs文件夹中不同版本的 Notebook 逐渐堆积,它可能会变得资源密集。你可能还会发现很难保持所有安装版本的更新,并且可能无法在 Notebook 中查看或切换到其他环境。

通过 Anaconda Navigator 安装和启动 Jupyter Notebook

要使用 Anaconda Navigator 在新环境中安装 Jupyter Notebook,首先通过 Windows 中的开始菜单、macOS 中的 Launchpad,或者在 Linux 中通过终端输入 anaconda-navigator 启动 Navigator。然后,在主页标签页顶部附近的下拉菜单中的“应用程序”(图 5-1)中,选择该环境的名称以激活它。在这个示例中,我们使用的是第二章中创建的my_first_env环境。如果你在第二章中跳过了这一步,参见“创建新环境”部分,详见第 36 页。

图片

图 5-1:Anaconda Navigator 主页标签,显示活动环境(my_first_env)和 Notebook 图块

接下来,找到Jupyter Notebook应用程序的图块,然后点击安装按钮。你可能需要向下滚动主页标签页才能找到该图块。这将安装来自顶部频道的最新版本的 Notebook,频道列表位于主页标签页顶部附近。如果你想安装特定版本的 Jupyter Notebook,可以点击 Notebook 图块右上角的“齿轮”图标,查看可用版本号的列表(见图 5-1)。

几秒钟后,安装按钮应该会变成启动按钮。此按钮会在你的计算机上启动一个本地 Web 服务器,显示 Jupyter 仪表盘。因为它是本地运行的,所以你不需要连接到互联网。不过,你需要保持 Navigator 打开,因为它正在运行本地服务器来支持 Notebook 的功能,让你能在浏览器中与之互动。

通过 CLI 安装和启动 Jupyter Notebook

要在新环境中使用 conda 安装 Jupyter Notebook,首先打开 Anaconda Prompt(Windows 中)或终端(macOS 和 Linux 中),并激活 conda 环境。让我们为第二章中创建的my_second_env执行此操作。

如果你在第二章中跳过了这一步,使用以下命令创建环境:

conda create --name my_second_env

现在,通过输入以下命令来激活环境:

conda activate my_second_env

接下来,使用 conda 安装 Notebook:

conda install notebook

要安装特定版本(例如 6.4.1),可以使用以下命令:

conda install notebook=6.4.1

要启动 Notebook,输入:

jupyter notebook

这会在你的计算机上启动一个本地 Web 服务器,显示 Jupyter 仪表板。由于它是本地运行的,因此你不需要活动的互联网连接。不过,你需要保持提示窗口或终端打开,因为它在本地运行 Notebook 服务器,允许你与浏览器进行交互。

注意

有名为 notebook 和 jupyter 的 conda 包。notebook 包是经典的 Jupyter Notebook 应用程序,而更大的 jupyter 包则捆绑了 Jupyter Notebook、Qt 控制台和 IPython 内核。

模块化方法

模块化方法将每个 conda 环境链接回安装 Anaconda 时在base环境中加载的 Notebook 包。这种方法资源高效,便于更新 Notebook 包,并能让你在相同的 Notebook 中查看和选择不同的环境。

你可以使用模块化方法,无论是通过 Navigator 还是 CLI。为了简化,让我们使用 CLI。在 Anaconda Prompt(Windows 中)或终端(macOS 或 Linux 中)中输入以下命令来创建一个名为my_jupe_env的新环境:

conda create --name my_jupe_env

当提示时输入 y 以接受安装。接下来,激活新环境:

conda activate my_jupe_env

要将此环境与base环境中的 Jupyter Notebook 安装链接,使用以下命令:

conda install ipykernel

因为我们使用了ipykernel包,所以在环境中不需要显式安装 Python。然而,如果你需要在项目中使用特定版本的 Python,你需要在环境中安装它。

现在,停用my_jupe_env,这会将你返回到base环境,然后安装nb_conda_kernels包(你只需要做一次):

conda deactivate
conda install nb_conda_kernels

nb_conda_kernels 包使得在环境中的 Jupyter 实例能够自动识别任何其他安装了 ipykernel 包的环境。正是base环境中的 nb_conda_kernels 和其他 conda 环境中的 ipykernel 的组合,才允许你使用单一的 Jupyter Notebook 安装。

要从base环境启动 Notebook,你需要输入以下命令:

jupyter notebook

这会在你的计算机上启动一个本地 Web 服务器,显示 Jupyter 仪表板。由于它是本地运行的,因此你不需要活动的互联网连接。不过,你需要保持提示窗口或终端打开,因为它在本地运行 Notebook 服务器,允许你与浏览器进行交互。

你的第一个 Jupyter Notebook

首先,让我们通过一个例子来进行操作。在这个例子中,我们将使用一个笔记本来总结著名的黄石公园老忠实间歇泉的喷发周期。我们将加载一些数据,进行准备,绘制图表,然后添加一张装饰性图片。

如果你在前面的章节中启动了 Notebook,浏览器将打开一个像 图 5-2 中那样的仪表板页面。现在使用页面右上角的 Quit 按钮关闭它,然后关闭浏览器标签。如果 Navigator 已经打开,通过选择 文件退出 来关闭它。

图片

图 5-2:当你启动 Jupyter Notebook 时,Jupyter 仪表板会出现在你的浏览器中。

接下来,我们将使用上一节中描述的模块化方法,所以如果你还没有安装 nb_conda_kernels 包,请确保在 base 环境中安装它。要通过 CLI 执行此操作,请打开 Anaconda Prompt(Windows)或终端(macOS 或 Linux),然后激活 base 环境:

conda activate base

然后输入以下内容:

conda install nb_conda_kernels

notebook 包随 Anaconda 一起提供,因此它已经包含在 base 环境中。

创建专用项目文件夹

Jupyter 笔记本会保存在你启动应用程序的文件夹中。这意味着笔记本通常会积累在你的主目录或用户目录中。此外,Anaconda 使用专用文件夹来跟踪你安装的包和 conda 环境(参见 第二章)。尽管 Anaconda 设计时就考虑到与这种结构的兼容性,并帮助你进行导航,但并非每个人都希望项目文件散落在目录树的各个位置。正如我们在前一章中讨论的,将所有项目文件保存在一个文件夹中有许多好处。

对于这个项目,让我们将 conda 环境和 Jupyter 笔记本存储在一个名为 my_nb_proj 的文件夹中,my_nb_proj 是“我的笔记本项目”的缩写。我将在 Windows 的用户目录中创建这个文件夹,建议你也在你的系统中使用类似的位置。虽然你可以通过 Anaconda Navigator 来操作,但命令行更简洁,所以我们接下来将使用命令行。

要为项目创建目录,打开 Anaconda Prompt(在 Windows 中)或终端(在 macOS 或 Linux 中),然后输入以下内容(使用你自己的目录路径):

mkdir C:\Users\hanna\my_nb_proj
mkdir C:\Users\hanna\my_nb_proj\notebooks
mkdir C:\Users\hanna\my_nb_proj\data

这将创建一个包含 notebooksdata 子目录的 my_nb_proj 目录。接下来,在项目目录中创建一个名为 my_nb_proj_env 的 conda 环境,激活它,并安装一些库(根据需要替换你自己的路径):

conda create --prefix C:\Users\hanna\my_nb_proj\my_nb_proj_env
conda activate C:\Users\hanna\my_nb_proj\my_nb_proj_env
conda install ipykernel pandas seaborn

如前所述,ipykernel 包让你可以在 base 环境中使用 Jupyter Notebook 应用程序。pandas 包是 Python 的主要数据分析库,seaborn 是一个包含一些有用数据集的绘图库。(我们将在本书后续章节中更详细地介绍这些库。)

在这一点上,你的项目目录结构应该类似于图 5-3。当然,实际项目中,你可能会包含其他文件夹,用于存储特定类型的数据、非笔记本脚本、杂项文件等。

Image

图 5-3:我的my_nb_proj目录结构

Jupyter Notebook 喜欢保存到其当前目录。第一次保存文件时,如果你从该文件夹启动 Notebook,操作最为简便。之后,你可以从任何位置启动 Notebook,并且仍然可以访问该文件。要在新的notebooks文件夹中启动 Notebook,首先激活base环境(其中安装了 Jupyter Notebook),然后使用cd命令切换目录:

conda activate base
cd C:\Users\hanna\my_nb_proj\notebooks

由于该文件夹已经在我的用户目录中,我也可以使用相对路径:

cd my_nb_proj\notebooks

要启动 Notebook,输入:

jupyter notebook

你现在应该能在浏览器中看到 Jupyter 仪表板。

浏览笔记本仪表板和用户界面

Jupyter Notebook 仪表板,也叫做首页,会打开一个直观的文件浏览器标签页(见图 5-4)。该标签页展示了从启动 Notebook 的目录中打开的笔记本文档和其他文件,这个目录被称为当前目录。当你点击文件或文件夹时,会看到标准的选项,如复制、重命名、删除等。仪表板还帮助你创建新的笔记本、退出应用程序,并管理当前运行的 Jupyter 进程和用于并行处理的集群。

因为我们是从空的notebooks文件夹启动的 Notebook,所以没有文件或文件夹可见。让我们通过创建一个新笔记本来解决这个问题。首先,点击文件标签页右上角的新建按钮,打开下拉菜单,如图 5-4 所示。

Image

图 5-4:从 Jupyter 仪表板新菜单中选择内核

菜单为你提供了在你创建的各种 conda 环境中选择内核的选项,包括那些不在默认envs文件夹中的内核。它能够做到这一点,因为安装了 nb_conda_kernels 包在base环境中,并且每个环境中都安装了 ipykernel 包。在列表的底部,你还有其他选项,包括创建新文本文件、文件夹或终端。

要在你的my_nb_proj环境中激活内核,从列表中选择Python [conda env:my_nb_proj_env]。这将打开笔记本的用户界面(UI)。笔记本 UI 是你交互式构建笔记本文档的地方。其主要组件包括菜单栏、工具栏和单元格(见图 5-5)。我鼓励你通过点击菜单中的帮助用户界面导览来快速体验这些组件。

Image

图 5-5:笔记本用户界面

在菜单栏的右侧,你会看到活动内核和 conda 环境(Python[conda env:my_nb_proj_env])。如果这不是你预期的名称,那么你正在使用来自其他环境的包,这些包可能不包含你需要的包或其正确的版本。

Jupyter Notebook 的模块化特性是其成功的关键。它由块组成,称为单元格,可以包含代码或“文本”(例如标题、项目符号列表、图片和超链接)。代码单元可以独立运行,也可以一起运行,每个单元都有自己的输出区域。这使你能够将计算问题分解为多个部分,并将相关的想法组织到不同的单元格中。当你让一个(或多个)单元格正常工作时,你就可以继续。这对于交互式探索非常方便,尤其适用于你只需要每个会话运行一次的长时间运行过程。

命名笔记本

让我们通过主动创建一个笔记本来学习 UI 组件和工作流程。首先,通过点击位于菜单栏上方的Untitled,在文本框中输入geyser,然后点击重命名来为你的新笔记本命名(见图 5-6)。

Image

图 5-6:重命名笔记本

此时,你应该可以在项目的notebooks文件夹中看到一个新的文件和文件夹。geyser.ipynb文件是笔记本文档。这只是一个以.ipynb扩展名保存的纯文本 JSON 文件。.ipynb_checkpoints文件夹包含geyser-checkpoint.ipynb文件,它可以让你将笔记本恢复到之前的版本。

你还会在仪表盘中看到笔记本文件出现(见图 5-7)。如果点击文件名旁边的框,你将启动一个菜单栏,提供操作文件的选项,例如移动、重命名和删除它(“垃圾桶”图标)。

Image

图 5-7:激活文件操作菜单的 Jupyter 仪表盘

要在以后打开 geyser 笔记本,只需在仪表盘中点击文件名。书籍图标会变绿,表示笔记本正在运行,你可以使用文件名上方的下拉菜单来筛选所有正在运行的笔记本。请注意,你无法访问仪表盘中目录树根目录(上层)的外部(更高层)笔记本文件。根目录是你启动 Notebook 时所在的目录。

使用 Markdown 单元添加文本

现在,让我们为笔记本提供一个描述性标题,让大家知道它的内容,并引用 geysers 数据的来源。点击第一个单元格,左侧标记为In [ ]:。接着,在工具栏上,将单元格类型从代码更改为Markdown,如图 5-8 所示。

Image

图 5-8:使用工具栏更改单元类型

Markdown (daringfireball.net/projects/markdown/), 是 HTML 标记语言的超集,让你可以在笔记本中添加解释性文本。你可以以多种方式格式化这些文本,包括文字大小、粗体、斜体和删除线。你可以更改颜色,使用样式表,创建列表,添加超链接。你甚至可以将图片和视频拖放到 Markdown 单元格中。

一些常用的 Markdown 样式列在 表 5-1 中。将自己的文本插入到全大写的词语中。要查看更多样式,请访问 jupyter-notebook.readthedocs.io/

表 5-1: 常见的 Markdown 样式

样式语法 描述
# YOUR TEXT 标题大小;#(最大)→ #####(最小)
**YOUR TEXT** 将文本设置为 粗体
*YOUR TEXT* 将文本设置为 斜体
~~YOUR TEXT~~ 给文本添加删除线
- YOUR TEXT 创建项目符号列表(也支持 + 和 *)
<span style=``″``color:red``″``>YOUR TEXT</span> 将文本更改为指定颜色(在 JupyterLab 中)
Text 插入指向网站 URL 的超链接
![title](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/py-tl-sci/img/FILENAME)``![title](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/py-tl-sci/img/URL) 使用文件名或 URL 地址插入图片。你也可以将图片拖放到单元格中。

注意

样式菜单中的标题选项已被弃用,不再有效。笔记本将引导你使用 Markdown 选项来创建标题。

要为你的笔记本创建标题,请点击单元格并输入以下内容:

## Old Faithful geyser eruption dataset
### (Weisberg (2005) in *Applied Linear Regression*)

请确保在井号后面加一个空格。你的单元格应类似于 图 5-9。

Image

图 5-9:使用 Markdown 单元格创建标题

要执行单元格,请点击工具栏上的 运行 按钮,或者在键盘上按 SHIFT-ENTER。你的单元格应类似于 图 5-10。注意,“应用线性回归”是斜体的。要返回并再次编辑单元格,只需双击它。

Image

图 5-10:格式化的标题

注意

SHIFT-ENTER 执行单元并将光标移到下一个单元,如果必要会创建一个新单元。CTRL-ENTER 执行当前单元但不移到下一个单元。

通过代码单元添加代码和制作图表

笔记本支持浏览器内的代码编辑,并且包含了像 Spyder 一样的功能,例如自动语法高亮、缩进和标签补全/自省。换句话说,你可以在浏览器中执行代码,并在笔记本中的专用输出单元中看到计算结果,包括图表和图像。

要开始编写代码,请点击新的代码单元并输入以下内容:

%matplotlib inline
import pandas as pd
import seaborn as sns df = sns.load_dataset('geyser')  # Times are in minutes.
display(df.head())
df = df.rename(columns={'kind': 'eruption_cycle'})

这次,使用 CTRL-ENTER 来运行单元格。你可能已经注意到,这与 Jupyter Qt 控制台相反,在该控制台中,你按 ENTER 执行代码,而使用 CTRL-ENTER 可以输入多行但不执行。

若要以不同方式添加另一个单元格,请从菜单栏点击 插入插入下方单元格。点击新单元格,输入并运行以下代码以生成“小提琴图”:

sns.violinplot(x=df.eruption_cycle, y=df.duration, inner=None);

行末的分号防止 Notebook 显示关于图形对象的文本信息。现在你的 Notebook 应该看起来像 图 5-11。

Image

图 5-11:带有内联图形的 geyser.ipynb Notebook

有件重要的事情刚刚发生。在第一个单元格中,你导入了包,加载了 seaborn 的“geysers”数据集作为 pandas DataFrame,查看了 DataFrame 的前五行(df.head()),然后将其中一列的名称更改为更有意义的内容。在第二个单元格中,你绘制了 DataFrame。

关键在于,你将数据加载和准备这些(可能)耗时的步骤隔离在了各自的单元格中。如果你在第一个单元格中导入了包并加载了数据集(标记为 In [1]:),那么你就可以在后续单元格中“玩”数据。每次执行时没有理由等待数据加载。你在 Spyder 中看到了类似的单元格方法(请参见 第 81 页的“定义代码单元格”)。

另一个值得注意的地方是,你使用了一个魔法命令使 Matplotlib 图形嵌入在 Notebook 中(你不需要显式导入 Matplotlib 库,因为它是 seaborn 的依赖项)。你还可以通过使用 %matplotlib notebook 为图表添加简单的交互性,尽管这可能会减慢渲染速度。魔法命令最早是在 第三章中引入的。要查看所有魔法命令的列表,包括单元格魔法命令,可以在单元格中运行 %lsmagic。单元格魔法命令以两个百分号(%%)开头。

图表本身显示了老忠实喷泉有一个短周期和一个长周期的喷发。你等待的时间越长,喷发持续的时间就越长,因此作为游客,你的耐心会得到回报。

与输出单元格的工作

默认情况下,Notebook 只显示代码单元格中最后一条命令的输出。根据不同情况,你可以通过使用 print()display() 函数来绕过这个限制。在前一节中,我们使用了 display() 来显示 DataFrame 的前几行(df.head())。你也可以将多个命令用逗号隔开写在同一行。或者,你可以在 Notebook 开始时导入 IPython InteractiveShell,并将其交互性选项设置为“all”:

from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity = ″all″

除了 allInteractiveShell 还有其他选项,包括 nonelastlast_exprlast_expr_or_assign(其中“expr”代表“表达式”,“assign”代表“赋值”)。

要对输出单元进行更多控制,请使用单元格菜单(图 5-12)。“当前输出”和“所有输出”选项允许你隐藏输出、清除输出或为单个单元或整个笔记本切换滚动。

Image

图 5-12:切换滚动选项会向输出单元添加滚动条

滚动选项在显示超大对象时非常有用,超出了输出单元的显示范围。图 5-12 显示了间歇泉笔记本中的完整 DataFrame(使用 display(df)),并且开启滚动条后,你可以查看 DataFrame 的最后几行。

使用 Markdown 单元格添加图片

为了完成笔记本,我们来添加 Jim Peaco 提供的老忠实喷泉的鸟瞰图,这张图片可以从国家公园管理局的图像库中找到。插入一个新单元并将其类型更改为 Markdown。输入并运行以下代码,引用图片的网络地址:

![title](https://www.nps.gov/npgallery/GetAsset/393757C9-1DD8
-B71B-0BEF06BE19C76D4D/proxy/hires)

假设你有一个活跃的互联网连接,这会生成 图 5-13 中的输出。

Image

图 5-13:包含国家公园管理局老忠实喷泉图像的笔记本下半部分

如果你想控制图像的大小,可以使用以下命令:

<img src=https://www.nps.gov/npgallery/GetAsset/393757C9-1DD8-B71B
-0BEF06BE19C76D4D/proxy/hires width=″250″>

如果你不想担心链接失效、不稳定的互联网连接或跟踪外部文件,你可以通过菜单栏中的 编辑插入图片,粘贴剪贴板中的内容,或者直接将图片拖放到 Markdown 单元格中,来嵌入图像。嵌入的图像使得笔记本更具可携带性,但也有增加文件大小和使代码修订变得不太友好的缺点。

Markdown 单元格和笔记本通常使得在格式化文档中包含代码、方程式和图形变得非常容易。事实上,许多在线文章,如 MediumTowards Data Science 上的文章,都是使用 Jupyter Notebook 创建的。

保存笔记本

笔记本会在设定的时间段后自动保存,通常是 120 秒。你可以通过在单元格中运行 %autosave n 魔法命令来覆盖此设置,其中 n 是秒数,n=0 会禁用自动保存。这仅适用于单个笔记本,并且仅对当前会话有效。每次打开笔记本时,你都需要运行包含魔法命令的单元格才能生效。有关如何全局更改所有笔记本的自动保存设置的说明,请在线搜索 autosavetime Jupyter 扩展(我们将在本章后面讨论如何使用扩展)。

要在任何时候手动保存笔记本,可以使用工具栏上的 保存 图标、快捷键 CTRL-S 或菜单栏中的 文件保存并创建检查点

每次手动保存笔记本时,你都会在一个名为.ipynb_checkpoints的文件夹中创建一个检查点文件,该文件夹位于初始.ipynb文件所在的文件夹中。你可以通过从菜单中点击文件恢复到检查点,然后点击最后一次检查点的日期戳来重置笔记本到检查点版本。

检查点很重要,因为自动保存仅更新.ipynb文件。这让你可以在不手动保存的情况下安全地工作一段时间。如果你发现自己走入了死胡同或犯了错误,你总是可以使用检查点文件恢复到早期的版本。

关闭笔记本

要正确关闭笔记本,从菜单栏选择文件关闭并停止。接下来,在仪表板中按退出按钮,然后关闭窗口。

如果你登录到另一个服务器,而不是本地工作,你需要通过点击笔记本右上角的注销按钮,或者在 Jupyter 仪表板的右上角注销。

获取帮助

帮助菜单虽然非常直观,但足够实用,值得一提。除了笔记本界面游览和文档外,它还提供了许多有用库的文档链接,如 Python、NumPy、pandas、Matplotlib 等 (图 5-14)。

图片

图 5-14:帮助菜单

键盘快捷键

你还可以调出命令模式和编辑模式的键盘快捷键列表。你可能已经注意到,单元格边框最初是蓝色的,点击单元格后会切换为绿色。蓝色单元格表示你处于命令模式;绿色单元格表示你处于编辑模式。

在命令模式下,整个笔记本会被选中。在编辑模式下,焦点集中在单个单元格上。尽管有所重叠,但命令模式的键盘快捷键 (表 5-2) 帮助你操作单元格,而编辑模式的快捷键 (表 5-3) 帮助你处理单元格中的文本

表 5-2: 选择的命令模式键盘快捷键

快捷键 描述
H 显示所有键盘快捷键
ENTER 进入单元格编辑模式
SHIFT-ENTER 运行单元格并选择下方单元格
CTRL-ENTER 运行选定的单元格
F 查找并替换
Y 将单元格模式更改为代码
M 将单元格模式更改为 Markdown
1 到 6 将单元格更改为标题模式(1 = 最大,6 = 最小)
UP 选择上方单元格
DOWN 选择下方单元格
A 在上方插入单元格
B 在下方插入单元格
X 剪切选定的单元格
C 复制选定的单元格
V 将单元格粘贴到下方
SHIFT-V 将单元格粘贴到上方
D, D 删除选定的单元格
Z 撤销单元格删除
SHIFT-M 合并选定单元格,或如果只选择了一个单元格,则与下方单元格合并
S(或 CTRL-S) 保存并创建检查点
L 切换行号
O 切换选定单元格的输出
I, I 中断内核
SPACE 向下滚动笔记本
SHIFT-SPACE 向上滚动笔记本

表 5-3: 选定的编辑模式快捷键

快捷键 描述
CTRL-M (或 ESC) 进入命令模式
UP 向上移动光标
DOWN 向下移动光标
CTRL-UP 跳转到单元格开始
CTRL-DOWN 跳转到单元格结束
CTRL-LEFT 向左移动一个单词
CTRL-RIGHT 向右移动一个单词
CTRL-] 增缩
CTRL- 缩进
CTRL-/ 切换注释
CTRL-D 删除整行
CTRL-A 全选
CTRL-Z 撤销
CTRL-Y 重做
CTRL-BACKSPACE 删除前一个单词
CTRL-DELETE 删除后一个单词
SHIFT-ENTER 运行单元格并选择下方单元格
CTRL-ENTER 运行选中的单元格
CTRL-SHIFT-hyphen 在光标处拆分单元格
INSERT 切换覆盖标志
CTRL-S 保存并创建检查点

要查看完整的快捷键列表,请点击帮助键盘快捷键,或者在命令模式下按键盘上的 H 键。如果这些快捷键还不够用,你可以通过笔记本应用程序本身自定义命令模式快捷键,使用编辑键盘快捷键项。对话框会引导你添加自定义快捷键。之后,来自笔记本的快捷键设置将保存到你的配置文件中。

命令面板

你可以将光标悬停在工具栏上的项目上(见[图 5-5),以查看它们的功能。除非是命令面板图标,它像一个键盘,否则它们都很直观。

在 Jupyter Notebook 和 JupyterLab 中,所有用户操作都通过一个集中式的命令系统进行处理。这些操作包括菜单栏、上下文菜单、键盘快捷键等。为了方便使用,命令面板提供了一种通过键盘搜索和运行这些命令的方式(见图 5-15)。

Image

图 5-15:命令面板的一部分

你也可以在命令模式下按 P 打开命令面板,在编辑模式下按 CTRL-SHIFT-P 打开命令面板。要退出命令面板,请按 ESC 键。

使用笔记本扩展

你可以通过使用用 JavaScript 编写的扩展来扩展笔记本环境的功能。这些模块,称为nbextensions,基本上是附加组件或插件,执行诸如自动完成代码、隐藏代码单元、拼写检查 Markdown 单元、创建目录、打开“草稿”单元进行独立实验等任务。你还可以编写自己的自定义扩展。要查看完整的扩展列表,请访问jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions.html

注意

经典笔记本扩展在 JupyterLab 版本中无法使用,因为 JupyterLab 有自己的一套扩展。你可以在下一章阅读相关内容。

安装扩展

jupyter_contrib_nbextensions 包是一个社区贡献的 nbextensions 集合。要在浏览器中本地加载这些扩展,你需要在 base 环境中安装它(如果你使用的是模块化方法),或在你的项目环境中安装它(如果使用的是简单方法)。例如,要使用命令行在 base 环境中安装,首先使用以下命令激活环境:

conda activate base

然后,输入以下内容:

conda install -c conda-forge jupyter_contrib_nbextensions

最后,将 JavaScript 和 CSS 文件安装到 Notebook 可以找到的位置:

jupyter contrib nbextension install --user

CSS(即 层叠样式表)描述了笔记本中 HTML 元素的显示方式。--user 标志将文件安装到用户的 Jupyter 目录中。或者,使用 --system 标志将安装到系统范围的 Jupyter 目录中。

确认安装后,重新启动笔记本服务器。

启用扩展

现在你应该可以在 Jupyter 首页看到一个 Nbextensions 标签,列出了可选择的 nbextensions,如 图 5-16 所示。

Image

图 5-16:Jupyter 仪表板上的新 Nbextensions 标签(显示为截断形式)

点击扩展名会启动其 README 文件。例如,如果点击 Tree Filter nbextension 并向下滚动,你将看到它的功能描述以及如何使用的演示(图 5-17)。

Image

图 5-17:点击 nbextension 名称将启动其描述性 README 文件。

点击扩展旁边的复选框将启用该扩展。你还可以通过命令行启用和禁用 nbextensions(其中 <extension_name> 表示扩展的名称):

jupyter nbextension enable <extension_name>

和:

jupyter nbextension disable <extension_name>

要了解更多关于 jupyter_contrib_nbextension 包的信息,请访问 jupyter-contrib-nbextensions.readthedocs.io/。要查找最新的扩展,可以在线搜索“有用的 Jupyter Notebook 扩展”。

使用小部件

Widgets(即“窗口小工具”)是互动对象,如滑块、单选按钮、下拉菜单、复选框等。小部件让你为笔记本构建一个图形用户界面(GUI),使得探索数据、设置模拟、接受用户输入等任务变得更加容易。

在本节中,我们将使用 ipywidgets 扩展来创建小部件。一些示例显示在 图 5-18 中。要查看完整的小部件列表及其可配置的参数,请访问文档 ipywidgets.readthedocs.io/en/stable/examples/Widget%20List.html

Image

图 5-18:在 ipywidgets 中可用的众多小部件类型之一

我们不会在此覆盖所有类型的小部件,但会介绍足够的内容,让你有信心自行探索。

安装 ipywidgets

ipywidgets 扩展可以像任何包一样安装。以下的指令将使用 CLI,但你也可以通过 Navigator GUI 轻松地复制这些步骤。

如果你使用的是简单的方法,即 Notebook 包安装在你的 conda 环境中,打开 Anaconda Prompt(Windows)或终端(macOS 和 Linux),激活目标环境,然后输入以下命令:

conda install -c conda-forge ipywidgets

如果你使用的是模块化方法,其中 Jupyter Notebook 和 IPython 内核安装在不同的环境中,你还需要在包含 Jupyter Notebook 服务器的环境中安装widgetsnbextension包。widgetsnbextension 包配置经典的 Jupyter Notebook 以显示和使用小部件。现在让我们在basemy_nb_proj_env环境中执行此操作(你需要将my_nb_proj_env的路径替换为你的路径):

conda install -n base -c conda-forge widgetsnbextension
conda install -p C:\Users\hanna\my_nb_proj\my_nb_proj_env -c conda-forge ipywidgets

安装了 ipywidget 包后,你可以轻松地手动创建小部件,或者通过使用interactinteractive类来创建。

使用 Interact 创建小部件

ipywidgets.interact类帮助你生成小部件,用于探索和与数据交互。让我们在一个新的 Notebook 中试试。

打开 Anaconda Prompt(在 Windows 中)或终端(在 macOS 或 Linux 中)。你应该处于base环境中(如果不是,请输入 conda activate base)。由于我们将保存一个新的 Notebook,请在启动 Jupyter Notebook 之前,导航到my_nb_proj\notebooks目录:

cd C:\Users\hanna\my_nb_proj\notebooks
jupyter notebook

从 Jupyter 仪表板中,选择新建Python [conda env:my_nb_proj_env]。当浏览器中出现未命名的 Notebook 时,将其重命名为widgets并保存。

在第一个单元格中,输入并运行以下代码:

   import numpy as np
   import matplotlib.pyplot as plt
➊ from ipywidgets import interact

   x = np.linspace(0, 6)

   def sine_wave(w=1.0):
       plt.plot(x, np.sin(w * x))
       plt.show()

➋ interact(sine_wave);

通过这段代码,你导入了 NumPy、Matplotlib 和 ipywidgets 中的 interact 类 ➊。然后,你使用 NumPy 的linspace()方法返回指定区间(0-6)内均匀分布的数字数组,并将其分配给x变量(我们在第十八章中详细介绍 NumPy)。接下来,你定义了一个简短的函数,它将x值乘以x的正弦值,再乘以一个名为w的标量,并绘制结果。最后,你调用interact()并传入sine_wave()函数 ➋。这会生成在图 5-19 中显示的滑块小部件。滑动控制按钮会重新定义w的值,并自动调用sine_wave()函数以更新图表。

图像

图 5-19:更改滑块会交互式地更新正弦波图。

请注意,你不需要指定滑块小部件。Ipywidgets 自动检测到我们传递给sine_wave()函数的是一个浮动值(w=1.0),因此知道使用浮动滑块。如果我们传递的是整数,它将使用整数滑块。

你还可以将 interact() 用作 Python 的 装饰器。我们在第十三章中讲解了装饰器;这些是用于增强其他函数行为的函数。要将 interact() 作为装饰器使用,请在你的 widgets 笔记本底部插入一个新单元格并运行以下代码:

➊ @interact(w=1.0)
   def sine_wave(w):
       plt.plot(x, np.sin(w * x))
       plt.show()

你必须运行前面的单元格才能使其生效,因为我们没有重新导入库或重新赋值 x。当你运行当前单元格时,装饰器 ➊ 会为你调用正弦波函数。

在这些例子中,interact() 会在你移动滑块时尝试更新图表,有时会导致显示延迟。为了防止 interact() 立即更新,你可以从 ipywidgets 导入 interact_manual() 方法,并用它来调用 sine_wave() 函数。在这种情况下,图表不会更新,直到你停止移动滑块并按下 运行交互 按钮(图 5-20)。

图片

图 5-20:interact_manual() 方法生成一个用于手动运行 interact 的按钮

如你所见,interact 根据输入的类型来确定生成的小部件类型。如果你传递一个布尔值,如 x=True,它会生成一个复选框。如果传递一个字符串,如 x='Hello, World!',它会生成一个文本框。传递一个列表或字典会生成一个下拉菜单。例如,在你的 widgets 笔记本底部插入一个新单元格并运行以下代码:

def languages(descriptor):
    return descriptor

options = {′The King′: ′Python′, ′Not bad′: ′Julia′, ′Up and Coming′: ′Go′}
interact(languages, descriptor=options);

你应该能得到图 5-21 所示的输出。

图片

图 5-21:一个 interact 生成的下拉菜单

interact 类抽象化了许多决策,因此很容易使用。若要获得更多控制,你可以尝试使用 interactive 类或手动生成小部件。

使用交互式小部件创建

ipywidgets.interactive 类让你能够访问与小部件绑定的信息,如它的关键字参数和结果。与 interact() 不同,你需要显式地使用 display() 方法将小部件显示在屏幕上。

让我们看一个例子。在你的 widgets 笔记本底部插入一个新单元格并输入以下代码:

from ipywidgets import interactive

def my_function(x):
    return x

widget = interactive(my_function, x=5)
display(widget)

运行单元格并将滑块移到 8 的值。现在,插入一个单元格并运行以下代码:

print(widget.result)

输出应该是 8。这使你可以在后续代码中使用小部件的结果,而不仅仅是查看结果。

手动创建小部件

interactinteractive 类使得创建小部件几乎是自动化的。但如果你想对过程有更多控制权,可以通过指定所需的小部件来“手动”创建它们。你可以定义布局和样式,命名小部件,将它们连接在一起,获取事件等。

让我们做一个例子。首先在你的 widgets.ipynb 笔记本底部插入一个新单元格,然后运行以下代码:

import ipywidgets as widgets

slider = widgets.IntSlider(value=0,
                           min=0, 
                           max=20, 
                           step=2, 
                           description='A Slider',
                           orientation='horizontal')
display(slider)

这会生成整数滑块条,如图 5-22 所示。

图片

图 5-22:一个命名的整数滑动条

通过直接构建小部件,你可以指定额外的参数,比如显示名称(description)和方向(horizontalvertical)。要查看所有可用的参数,可以将 display(slider.keys) 添加到当前单元格,或者在新单元格中运行 slider.keys。你可以在前面提到的文档链接中找到示例用例。

单独的滑动条本身用处不大,但像 interactive 类一样,你可以访问滑动条的值,在这种情况下,通过 .value 属性。在新单元格中,运行以下代码:

print(f″Slider value = {slider.value}″)

这应该产生以下输出:

Slider value = 0

处理事件

用户与小部件交互时会创建一个 事件。例如,当你按下按钮小部件时,会发生一个 点击 事件。处理事件时,你告诉程序如何处理结果。这通常涉及编写一个“事件处理”函数。

为了捕获输出并确保它被显示,你必须将其发送到一个 Output 小部件,或者将你希望显示的信息放入 HTML 小部件。让我们来看一个 Output 的例子。

在你的小部件笔记本的底部插入一个新单元格,然后运行以下代码:

   import ipywidgets as widgets
➊ from IPython.display import clear_output

   button1 = widgets.Button(description='Python')
   button2 = widgets.Button(description='Go')
   button3 = widgets.Button(description='Rust')
➋ output = widgets.Output()

   print(″Pick your favorite language:″)
   display(button1, button2, button3, output)

➌ def event_handler(button):
       with output:
           clear_output()
           print(″Your favorite language is {}″.format(button.description))

➍ button1.on_click(event_handler)
   button2.on_click(event_handler)
   button3.on_click(event_handler)

这将产生如 图 5-23 所示的输出。点击按钮将打印出按钮的描述(名称)。

Image

图 5-23:处理按钮点击事件

每次点击按钮时,输出会保留在输出单元格中,所以从 IPython 导入 clear_output() 方法 ➊。这个方法让你每次点击按钮时都能重新开始。接下来,创建三个按钮小部件和一个输出小部件来显示结果 ➋。

为了处理按钮点击事件,定义一个名为 event_handler() 的函数,接受一个按钮对象作为参数 ➌。使用输出小部件,首先清除显示,以移除任何先前按钮点击的输出,然后打印被点击按钮的名称。最后,对于每个按钮,使用 Button 小部件的 on_click() 方法并传递事件处理函数 ➍。这将把函数绑定到按钮点击事件。

自定义小部件

ipywidgets 提供的小部件开箱即用非常吸引人,但如果你愿意,可以对其进行修改。小部件的 layout 属性让你可以控制小部件的大小、边框、对齐方式和位置。你还可以将小部件按网格方式排列。

在上一节中的事件处理代码示例中,在 button1 变量赋值之前添加以下行,并按照这里所示的方式更改 button1 的代码:

layout = widgets.Layout(width='300px', height='50px', border='solid 2px')
button1 = widgets.Button(description='Python', layout=layout)

运行单元格,你应该会看到 图 5-24 中所示的输出。正如你可能猜到的,px 代表 像素

Image

图 5-24:Python 按钮(button1)的新布局

方便的是,许多小部件允许你使用预定义样式。在前面的例子中,将 button1 的赋值更改为以下内容,然后运行单元格:

button1 = widgets.Button(description='Python', button_style='danger')

Python 按钮应该变成红色。其他预定义的按钮样式选择包括 primary(蓝色)、success(绿色)、info(青色)和 warning(橙色)。

如果你需要更多控制,style 属性可以显示与布局无关的小部件样式属性。这个属性的属性是小部件特定的;你可以通过使用 keys 属性列出它们。例如,在前面的例子中,对于 button1,你可以运行 button1.style.keys

假设你希望 Python 按钮变成粉红色,而这种颜色在预定义样式中不可用。在这种情况下,你首先需要将 button1 恢复到原始状态,然后使用 style 属性设置它的背景色:

button1 = widgets.Button(description='Python')
button1.style.button_color = 'pink'

这些示例仅仅是你可以做的一个小小的尝试。要查看更多选项,请访问文档 ipywidgets.readthedocs.io/en/latest/examples/Widget%20Styling.html

在其他格式中嵌入小部件

笔记本菜单栏提供了一个小部件选项,用于将交互式小部件嵌入到静态网页、Sphinx 文档(熟悉的“Read the Docs”网页)以及 HTML 转换后的笔记本中,使用 nbviewer 网络应用程序。以下是菜单项:

保存笔记本小部件状态 将当前小部件状态保存为元数据,允许笔记本文件在渲染的小部件下呈现。

清除笔记本小部件状态 删除已保存的状态(你需要重新启动内核)。

下载小部件状态 触发下载包含当前使用的小部件模型序列化状态的 JSON 文件。

嵌入小部件 提供一个对话框,包含嵌入当前小部件的 HTML 页面。为了支持自定义小部件,它使用 RequireJS 嵌入器。

要了解更多关于嵌入的信息,请访问 ipywidgets.readthedocs.io/en/latest/embedding.html#

共享笔记本

科学工作很少是孤立进行的。你需要一种方式来共享你的笔记本。在某些情况下,你可能希望共享一个可执行版本;例如,供同事运行和修改笔记本(想想程序员)。在其他情况下,你可能想要共享一个已执行的笔记本的静态副本,其中包含所有生成的图表和输出(想想非程序员)。后一组人可能包括那些不想安装笔记本、处理其数据或包依赖关系,或等待长时间运行的笔记本完成的利益相关者。

笔记本以 JSON 格式保存,需要渲染才能读取。在接下来的章节中,我们将讨论一些下载和共享笔记本的方法。

检查并运行带有内核菜单的笔记本

笔记本的问题在于,单元格可能会被乱序运行、被删除,并且没有保证正确的执行顺序能够轻松重复。如我们在前面关于 Jupyter Qt 控制台和 Spyder 的章节中看到的那样,驻留内存中的导入和变量赋值可能会导致混淆和意外后果。

因此,在分享你的工作之前,强烈建议你点击 内核 菜单中的 重启并运行全部(图 5-25)。如果出现错误,修复第一个错误,重复该命令,然后继续处理下一个错误。

Image

图 5-25:笔记本内核菜单

另一个有用的菜单项是“中断”。对于长时间运行的笔记本,当你忘记更改参数或识别到代码或输入中的错误时,这非常方便。

下载笔记本

笔记本会自动保存为交互式 .ipynb 文件。你可以直接通过电子邮件将它们发送给使用笔记本的同事。或者,文件另存为 命令让你可以将笔记本保存为多种不同格式(图 5-26)。这些格式中有些,例如通过 LaTeX 导出的 PDF,需要安装特定的包(如果未安装,也不必担心,你会收到一个错误消息,告知你需要什么)。其中较为重要的格式有 HTML 和 Python。

Image

图 5-26:笔记本文件菜单

Python 选项会将你的笔记本保存为一个带有 .py 扩展名的文本文件,保存在你的下载文件夹中。然后你可以将该文件作为 Python 脚本在控制台或 IDE(如 Spyder)中运行。Markdown 单元格、单元格编号及其他非代码材料会通过 # 符号被注释掉。

例如,如果你使用 Python(.py)下载上一节中的间歇泉笔记本,你将得到以下脚本:

#!/usr/bin/env python
# coding: utf-8

#  ## Old Faithful geyser eruption dataset   
#  ### (Weisberg (2005) in *Applied Linear Regression*)

# In[1]:
get_ipython().run_line_magic('matplotlib', 'inline')
import pandas as pd
import seaborn as sns

df = sns.load_dataset('geyser')  # Times are in minutes.
display(df.head())
df = df.rename(columns={'kind': 'eruption_cycle'})

# In[2]:
sns.violinplot(x=df.eruption_cycle, y=df.duration, inner=None);

# ![title](https://www.nps.gov/npgallery/GetAsset/393757C9-1DD8-B71B-0BEF06BE19C76D4D/proxy/
hires)

如果你在 Spyder 中打开这个 geyser.py 文件并运行它,你将看到表格形式的 DataFrame 输出和小提琴图,但看不到标题或老忠实喷泉的图像。

你还可以使用命令行通过 nbconvert 工具导出你的笔记本。这个工具已经驱动了 另存为 菜单选项,但在 CLI 中使用时(通过 jupyter nbconvert 调用),你可以方便地通过单个命令将一批笔记本文件转换为另一种格式。想了解更多,访问 nbconvert.readthedocs.io/

在你以合适的格式下载了笔记本之后,你仍然需要分享它。电子邮件是一种选择,但对于协作工作,你需要包含你所使用的任何外部数据文件。如果你的笔记本使用了第三方包,你还需要分享一个环境或需求文件(请参阅 第二章),这样与您共享的人就可以设置相同的环境。在接下来的章节中,我们将介绍一些通过第三方网站分享笔记本的便捷方式。

通过 GitHub 和 Gist 分享笔记本

部署笔记本的一种简单灵活的方式是将其放入一个代码仓库。这些网站存储源代码档案,提供版本控制以跟踪变更,并且既有公共组件也有私人组件。虽然有很多免费的主机可以选择,但最受欢迎的是GitHub

GitHub 公司是微软的子公司,提供基于Git 程序的互联网托管服务,用于软件开发和版本控制。Git 让你能够将你想要共享的笔记本存储在你计算机上的文件夹中,可以将其视为本地仓库。为了使这个文件夹充当仓库,Git 还会在一个名为.git的隐藏文件夹中存储快照(特定时间点版本状态的记录)和元数据。这使得它能够追踪文件内容和变更。你还可以包含支持数据文件和文件夹。

GitHub 网站让你能够在线托管这些 Git 仓库的克隆,以便共享、进行协作工作以及提供安全备份。你可以包含一个README.md 文件来描述仓库中的内容。其他用户可以下载你的笔记本进行运行和编辑。通过 Git 的版本控制系统,他们可以上传他们的更改,从而确保你的原始作品不会被覆盖。

要查看示例仓库,请访问这个链接:github.com/rlvaugh/Impractical_Python_Projects/。确保向下滚动以查看 README 文件。

你可以通过命令行运行 Git,但如果你是新手,或者只是想共享笔记本,我推荐使用GitHub Desktop 图形用户界面。Desktop 网站 (docs.github.com/en/desktop/ )会指导你完成设置免费账户和创建第一个仓库的步骤。此外,快速在线搜索还会提供许多优秀的 GitHub 使用教程。

另外,如果你需要一个快速、简单且轻量的共享笔记本的选项,你可以使用GitHub Gist。Gist 基本上是一个共享文本的工具,由于笔记本是以 JSON 格式保存的,因此也符合条件。Gist 是一个简单的解决方案,适用于你不需要大规模仓库,但仍然能享受 Git 版本控制系统的情况下。实际上,gist 就是一个 Git 仓库,具备完整的提交历史、差异(diff)、分叉和克隆选项等功能。

当你创建一个 gist 时,你可以选择添加多个文件,但会有一定的限制。例如,若要添加 Excel 表格,你需要将其保存为逗号分隔值(CSV)文件。同样,你不能添加图片文件,也不能添加目录。因此,如果你的项目数据量较大,你可能需要使用 GitHub Desktop 或通过命令行使用 Git 创建一个完整的 GitHub 仓库。

鉴于我们的geyser.ipynb笔记本很简单且独立,让我们将其添加到 Gist 中。首先,访问网站* gist.github.com/*。如果你已经有 GitHub 账户,点击 Gist 横幅右侧的登录按钮(图 5-27)。否则,点击注册以创建一个免费账户。

Image

图 5-27:GitHub Gist 启动横幅

登录后,点击横幅上的加号(+)(图 5-28)来创建一个 Gist。

Image

图 5-28:登录后的 GitHub Gist 横幅

在下一个窗口中,你将看到一个大的空白区域,用于添加文本(图 5-29)。你还会被提示输入带扩展名的文件名。请输入geyser.ipynb,然后在Gist 描述框中输入老忠实喷发笔记本

Image

图 5-29:Gist 创建页面

现在是有趣的部分。在你操作系统的文件资源管理器中,导航到我们之前构建的geyser.ipynb文件,并将其拖放到 Gist 创建页面的空白文本框区域中。你应该能看到你笔记本的 JSON 文本文件(图 5-30)。

Image

图 5-30:拖放笔记本到 Gist 中的结果

完成后,点击右下角绿色按钮上的箭头,查看保存选项。你可以创建一个私人(秘密)或公共的 Gist(图 5-31)。使用秘密选项时,只有知道你的 URL 的人才能看到内容。让我们保密,所以点击创建秘密 Gist

Image

图 5-31:创建 Gist 的选项

注意

如果有人猜测或意外发现了秘密 Gist 的 URL,他们将能够查看它。为了更好的安全性,你需要使用 GitHub Desktop 或 Git 创建一个私人仓库(参见“使用 GitHub Desktop 创建你的第一个仓库” docs.github.com/)。

几秒钟后,你应该能看到你的笔记本已完全渲染为静态 HTML 文件。输出内容,如小提琴图,仅会在笔记本在保存之前已经执行过时显示。

如果你向下滚动,你将看到一个添加评论的框。如果你向上滚动,你会看到一些操作图标,例如删除或编辑文件,正如图 5-32 所示。如果你点击编辑,笔记本将恢复为 JSON 格式(图 5-30)。虽然可以编辑这些文本并更改笔记本,但我怀疑你会想这么做。我们来看看其他选项。

Image

图 5-32:与 Gist 一起工作的选项

通过点击 下载 ZIP 按钮,用户可以将你的 Gist 下载为一个文件夹到他们的计算机中,在那里他们可以编辑和运行该笔记本。嵌入下拉菜单提供了将 Gist 嵌入到网站(例如博客文章)、复制共享链接或克隆 Gist 的选项。嵌入选项适用于任何支持 JavaScript 的文本字段。在 下载 ZIP 按钮的左侧,有一个图标用于将 Gist 保存到你的计算机,并在 GitHub Desktop 中使用它。

注意

如果你分享笔记本的主要目的是与他人协作修改笔记本内容,那么在将其添加到仓库之前,应该清除笔记本中的输出。这将使得跟踪代码的变化变得更加容易。欲了解更多信息,请访问 mg.readthedocs.io/git-jupyter.html

要查看 Gist 的完整文档,请访问 docs.github.com/en/github/writing-on-github/editing-and-sharing-content-with-gists/creating-gists/。此外,还有一个名为 gist-it 的笔记本扩展,用于创建 Gist (jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/gist_it/readme.html)。

将笔记本放入仓库后,你将有更多分发它们的选项。让我们来看看一些最受欢迎的选项。

通过 Jupyter Notebook Viewer 分享笔记本

Jupyter nbviewer,或称 Notebook Viewer,是一个免费的在线渲染 GitHub 托管笔记本的服务。当 GitHub 渲染引擎遇到困难时,例如处理大型笔记本、使用移动设备或使用某些基于 JavaScript 的库时,这个服务特别有用。同行和利益相关者可以使用 nbviewer 查看输入和输出,但要执行代码,他们必须将笔记本下载到本地 Jupyter 安装中。

要使用 nbviewer,你只需启动网站 (nbviewer.jupyter.org/),并将笔记本的 URL 粘贴到文本框中(图 5-33)。这将把笔记本渲染为一个静态的 HTML 网页,并为你提供一个稳定的链接,可以与他人分享。只要 GitHub 仓库中的笔记本位置没有改变,这个链接将保持有效。

Image

图 5-33:nbviewer Web 应用程序

该应用程序还支持浏览笔记本集合,并渲染其他格式的笔记本,如幻灯片和脚本。要分享多个笔记本,首先将它们都放入同一个仓库中。然后,将 nbviewer 指向该仓库的地址,它将自动为用户创建一个可导航的索引。

你可以使用我们在上一节中制作的 gist 来测试 nbviewer。只需使用嵌入菜单或剪贴板图标复制链接(参见 图 5-32),然后将其粘贴到 nbviewer 中。

通过 Binder 分享笔记本

Binder (mybinder.org) 是一个为使用 公开 仓库(例如 GitHub 上的仓库)而设计的免费网站。Binder 通过构建仓库的 Docker 镜像,让你运行存储在这些静态仓库中的笔记本。Docker 镜像是一个包含文件系统和参数的组合(参见 www.docker.com/)。当你通过 URL 分享你的笔记本时,Binder 会提供你的代码以及运行它所需的所有软件。用户无需下载或安装任何东西。

Binder 的基础环境是精简的。如果你的项目使用了任何第三方包,例如 Matplotlib 或 NumPy,你的 GitHub 仓库应包含 environment.ymlrequirements.txt 文件。这些文件列出了你的项目所需的包(参见 第 44 页的“复制和共享环境”部分)。Binder 会读取该文件,并将所有包包括在 Docker 镜像中。如果你向 GitHub 提交新的更改,Binder 会更新该镜像。

镜像构建完成后,你可以使用 Binder URL 分享你的笔记本。Binder 使用免费的 JupyterHub (jupyter.org/hub) 服务器来托管你的仓库。JupyterHub 是一个开源服务,允许机构在大量用户之间共享笔记本。通过你提供的公共 IP 地址,用户可以在一个实时的 JupyterHub 实例中与代码和环境进行交互。

图 5-34 显示了 Binder 启动屏幕。我强烈推荐点击图中顶部可见的 Python 链接,查看“从零开始使用 Binder”的初学者教程。附加的指导(未显示)包含在主页底部。

图片

图 5-34:Binder 在线表单用于分享互动笔记本

用户可以执行你的笔记本,因此你需要提供任何数据依赖项。如果这些数据文件占用的内存为 10MB 或更少,最简单的解决方案是直接将它们添加到你的 GitHub 仓库中。记住,这必须是一个公开仓库,以便 Binder 访问它,因此你不应包含任何敏感信息。你还需要记住,Binder 仅在构建 Docker 镜像时下载数据,而不是在点击 Binder 链接时。只有当仓库有新的提交时,镜像才会被重建。

对于大小在 10MB 到几百兆字节之间的数据,你需要向你的仓库添加一个名为 postBuild 的文件。这个文件是一个 shell 脚本,它会作为 Docker 镜像构建的一部分执行,并且只会在新镜像构建时执行一次。要了解更多信息,请参见文档中的 mybinder.readthedocs.io/en/latest/using/config_files.html#postbuild-run-code-after-installing-the-environment/

将大文件放入 GitHub 仓库或直接包含在 Binder 构建的镜像中并不实际。你最好使用一个特定于数据格式的库,在使用数据时流式传输它。或者,你可以在代码中按需下载它。出于安全原因,外发流量仅限于 HTTP 或 GitHub 连接,因此你不能使用 FTP 网站通过 Binder 获取数据。

如果用户通过 Binder 更改了你的笔记本,他们将无法保存或推送更改到 GitHub 仓库。要保存更改,他们需要通过点击 文件下载为笔记本 (.ipynb) 将笔记本下载到他们的计算机。

由于数据限制、保存问题和缺乏版本控制,Binder 最适合用来查看和运行笔记本。要协作 开发 笔记本,最好使用 Git 和 GitHub。

其他共享选项

共享笔记本的其他选项包括但不限于 Jovian (jovian.ai/docs/), Google Colaboratory (colab.research.google.com/notebooks/intro.ipynb/), 和 Microsoft Azure Notebooks (notebooks.azure.com/)。这些选项通常比我们之前讨论的选项需要更多的设置,并且可能与 GitHub 兼容性较差。所有这些都要求你拥有一个帐户,而且 Jovian 需要本地安装。Google 和 Microsoft 的选项中,笔记本界面看起来会稍有不同。

Colab 让用户能够协作并运行利用 Google 云资源的代码。这包括使用免费的 GPU,将文档保存到 Google Drive,以及直接在浏览器中运行 TensorFlow 机器学习库。实际上,Google 有一个名为 “Seedbank” 的示例深度学习笔记本仓库,你可以一键打开并运行。

Jovian 允许在单元格级别进行注释和讨论,以促进协作。Azure 帮助你从笔记本创建交互式演示并轻松分享,尽管无论如何,做到这一点都很简单。

最后,如果你希望完全控制谁可以访问你的笔记本以及如何使用它们,你可以设置自己的 JupyterHub 多用户 Hub。这让你可以为学生班级、企业数据科学工作组、科研项目等提供笔记本服务器。

使用 JupyterHub 时,你需要在某个地方运行一个可供用户通过网络访问的 Unix 服务器(通常是 Linux)。这可能需要配置一个公共服务器,最好由 IT 团队来完成,以确保安全问题得到妥善解决。了解更多信息,请访问jupyterhub.readthedocs.io/en/latest/jupyter-server.readthedocs.io/en/latest/operators/public-server.html

注意

如果你只需要远程访问你的个人机器,可以按照jupyter-notebook.readthedocs.io/en/stable/public_server.html上的说明设置一个单用户的公共服务器。

信任笔记本

如果你在自己的计算机上本地运行笔记本,那么笔记本的安全性取决于你的计算机。但如果你是远程访问笔记本、共享笔记本或为多个用户创建服务器,则黑客利用笔记本的潜力会增加。

问题在于笔记本包含在可以执行代码的上下文中存在的输出(通过 JavaScript)。理想情况下,代码不应仅仅因为用户打开了笔记本就执行,尤其是那些他们没有编写的代码。但一旦用户决定执行笔记本中的代码,无论其执行什么内容,都应视为受信任的。

为了解决这个问题,Jupyter 开发者实现了旨在防止未经明确用户输入执行不受信任代码的安全模型。为了确保笔记本是“可信的”,每次执行和保存时,都会通过笔记本内容的摘要和一个秘密密钥计算签名。该签名被存储在一个数据库中,只有当前用户可以写入。默认情况下,该数据库位于以下位置:

  • Windows 中的%APPDATA%/jupyter/nbsignatures.db

  • macOS 中的~/Library/Jupyter/nbsignatures.db

  • Linux 中的~/.local/share/jupyter/nbsignatures.db

每个签名代表一系列输出,这些输出是用户执行代码后产生的。如前所述,在交互式会话中生成并保存的任何输出都被视为可信的。

当用户打开一个笔记本时,服务器会计算其签名。如果它在用户的数据库中找到该签名,任何 HTML 和 JavaScript 输出将被信任。否则,它是不受信任的。

在合作编辑笔记本时,其他用户将拥有不同的密钥,因此当笔记本共享给他们时,它将处于不受信任的状态。针对这种情况,有三种推荐的方法进行管理:

  • 打开后重新运行笔记本(并非总是可行,你应该信任发送者)。

  • 通过文件信任的笔记本明确信任笔记本(参见图 5-26)或在 CLI 中运行jupyter trust /path/to/notebook.ipynb。这些方法加载笔记本,计算新的签名,并将该签名添加到用户的数据库中。

  • 分享一个“笔记本签名数据库”,并使用一个专门针对该项目的配置。

有关最后一种方法的详细说明,以及有关笔记本和服务器安全的更多信息,请参见文档

将笔记本转化为幻灯片

完成项目后,您可以通过将笔记本转化为幻灯片直接展示结果。这与 Microsoft PowerPoint 的工作方式类似,唯一的区别是,您可以实时运行代码,带来动态和沉浸式的体验。我们将通过一个示例来演示模块化的方法,在这种方法中,您从base环境运行 Jupyter Notebook。

安装 RISE 扩展

要在幻灯片中启用交互式编程,您需要安装Reveal.js – Jupyter/IPython 幻灯片扩展(RISE)。首先,关闭所有正在运行的 Jupyter 笔记本。接下来,打开 Anaconda Prompt(Windows 系统)或终端(macOS 或 Linux 系统),并在base环境中运行以下命令:

conda install -c conda-forge rise

现在,笔记本可以找到这个扩展并将其显示在仪表盘页面的 nbextensions 标签上。确保在安装笔记本的 conda 环境中安装 RISE。

创建幻灯片

让我们创建一个新的笔记本,用来演示幻灯片功能。由于我们要保存一个新文件,因此我们将在之前创建的notebooks文件夹中启动笔记本。

激活base环境(即 Jupyter Notebook 已安装的环境)。接下来,使用cd命令和您的个人路径打开notebooks目录,然后启动笔记本:

conda activate base
cd C:\Users\hanna\my_nb_proj\notebooks
jupyter notebook

当笔记本仪表盘在浏览器中打开时,点击文件标签页右上角的新建按钮,打开下拉菜单(见图 5-4)。为了激活my_nb_proj环境中的内核,从列表中选择Python [conda env:my_nb_proj_env]。请记住,这样可以从base环境启动笔记本,然后在另一个环境中工作。

当空白笔记本出现时,点击窗口顶部的无标题,将新笔记本重命名为幻灯片并保存。您还应该在工具栏的最右侧看到新的 RISE 图标(见图 5-35)。

Image

图 5-35:工具栏末端的 RISE 图标

从顶部菜单点击视图单元格工具栏幻灯片。您的笔记本中的第一个空单元格现在应该包含一个用于选择幻灯片类型的下拉菜单,如图 5-36 所示。

Image

图 5-36:幻灯片模式下的空笔记本单元格。注意右侧用于选择幻灯片类型的菜单。

这个菜单提供了六个选项,详见表 5-4。最常用的是“幻灯片”、“跳过”和“备注”。

表 5-4: 幻灯片类型菜单

幻灯片类型 描述
幻灯片 开始新的一张幻灯片。在展示时,使用左右箭头键切换幻灯片。
子幻灯片 创建带有过渡动画的子页面。使用上下箭头键切换。
片段 创建一个隐藏的幻灯片部分,可以通过空格键过渡显示。
跳过 表示该幻灯片应被跳过,不显示。适用于隐藏不生成展示中可视化效果的代码。
备注 表示该幻灯片为演讲者备注。
- 表示当前单元格应与前一个单元格的行为一致。

现在,让我们做一个关于对数螺旋的简短幻灯片,这是自然界中常见的形状(图 5-37)。

图片

图 5-37:自然界中对数螺旋的一些例子

从制作标题幻灯片开始。在第一个单元格中,将幻灯片类型菜单设置为幻灯片。然后,使用顶部工具栏将单元格类型更改为Markdown并输入以下内容:

# Spira mirabilis:  The Miraculous Spiral

在键盘上按 CTRL-ENTER 退出 Markdown 模式。

在标题下方插入一个新单元格,继续将其类型设置为幻灯片Markdown,然后输入以下内容:

### - Why does a hurricane look like a galaxy? Or the chambers in a nautilus
shell resemble the swirls in a pinecone?  

### - Growth in nature is a geometric progression, and spirals that increase
geometrically are *logarithmic*. 

### - Logarithmic spirals can be plotted using Python with the polar equation:  
## $r = ae^{b\theta}$  
*Where:
r = radius   
a is the scaling factor (size of spiral)   
b is the growth factor that controls the ″openness″  
$\theta$ controls length of spiral*

按 CTRL-ENTER 执行代码。

插入一个新单元格,并将其类型设置为幻灯片代码。接下来,输入以下代码,应用极坐标方程并生成互动滑块。这些滑块让你评估 a、b 和θ参数的影响。暂时不用担心所有的细节,我们将在书中的后面部分介绍 NumPy 和 Matplotlib 库。

import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact

def log_spiral(a=1, b=0.2, t=4):
    theta_radians = np.arange(0, t * np.pi, 0.1)
    radii = [a * np.exp(b * rad) for rad in theta_radians]
    plt.polar(theta_radians, radii, 'o', c='black')

interact(log_spiral);

接下来,在上一个单元格下方插入一个新单元格,并将其类型设置为备注Markdown。此单元格可以在你向观众描述动态图时提示参数的含义。输入以下内容,然后按 CTRL-ENTER 执行:

#### a: (scaling factor) controls size
#### b: (growth factor) controls openness
#### t: (theta) controls length

表示“幻灯片备注”的单元格必须紧接在与之关联的单元格之后。

使用一个新的单元格来结束展示,将其类型设置为幻灯片Markdown。输入以下内容,然后按 CTRL-ENTER 执行:

# The End

要启动幻灯片,首先保存笔记本,然后点击顶部单元格,再点击RISE按钮(图 5-35)。操作幻灯片时,使用表 5-5 中的键盘快捷键。有关所有快捷键的完整列表,包括操作虚拟黑板的快捷键,请点击每张幻灯片左下角可见的?图标。

表 5-5: 选定的 RISE 键盘快捷键

快捷键 结果
ALT-R 进入或退出 RISE(幻灯片模式)
SPACE 向前切换到下一张幻灯片
SHIFT-SPACE 返回上一张幻灯片
SHIFT-ENTER 评估并选择下一个可见单元格
HOME/END 跳转到开始/结束
T 打开演讲者备注窗口

使用空格键导航到代码单元。如果你还没有执行这个单元,现在可以按 CTRL-ENTER 或 SHIFT-ENTER 执行。你可能需要手动调整窗口以使图表正确显示。慢慢移动滑块,观察参数如何影响图表。这是 PowerPoint 做不到的!

使用演讲者备注

Slideshow 附带一个演讲者备注窗口,可以帮助你进行演示。它显示当前幻灯片、接下来的幻灯片、演讲者备注以及当前和经过的时间(图 5-38)。你可以在投影幻灯片的同时将此窗口打开在你的笔记本屏幕上。要进入此模式,演示时按下 T 键。

Image

图 5-38:RISE 演讲者备注窗口

对于远程会议,Binder(在第 129 页的“通过 Binder 分享笔记本”中讨论)允许你在浏览器中免费托管实时幻灯片会议,用户无需安装 Python 或 Jupyter 即可查看。对于非实时查看,幻灯片可以导出为单个 HTML 文件。

幻灯片的内容远不止我在这里能涉及的。Jupyter 项目的文档没有太多关于幻灯片模式的内容,但你可以通过在线搜索“Jupyter Notebook 幻灯片”找到大量详细的教程和技巧。

总结

Jupyter Notebook 之所以广受欢迎,是有充分理由的;它既实用、又简单、还充满乐趣!通过允许你将所有的分析和评论存储在一个地方,笔记本让记录工作、分享、展示以及快速恢复进度变得非常简单。

尽管如此,不要对笔记本过于迷恋,因为它们并不完美。它们的单元组织方式容易污染全局命名空间,且不利于编写可重用的函数和类,也让源代码管理和单元测试变得困难。这些正是我们在第四章中学习 Spyder 的一些原因,也是接下来我们将要了解 JupyterLab 的原因。掌握了 Notebook、Spyder 和 JupyterLab,你将始终能够根据任务需要选择最佳工具。

第六章:JUPYTERLAB:你的科学中心**

image

JupyterLab是 Project Jupyter 的基于网页的界面。它将你所需要的所有科学计算组件整合到一个互动式协作环境中。其灵活的布局使你能够高效地执行完整的计算工作流程,从加载数据到生成最终报告。它的架构也是可以修改的,这意味着它具有扩展性,并且对开发者开放。

如果你从本书的开头就开始阅读,你已经接触过多个 JupyterLab 组件,如控制台(第三章),文本编辑器(第四章),以及 Jupyter Notebook(第五章)。所以我们这里不会再回顾这些内容;我们将重点介绍 JupyterLab 带来的新界面和其他一些功能。

注意

JupyterLab 正在积极开发中,并且新功能会定期添加。为了确保你了解 JupyterLab 的最新状态,请务必查看完整文档:jupyterlab.readthedocs.io/

何时使用 JupyterLab 而非 Notebook?

JupyterLab 的开发者根据 2015 年用户体验调查的结果创建了新的界面,该调查表明 Jupyter Notebook 需要更多的灵活性和集成。用户希望不仅能够轻松访问笔记本,还能访问文本编辑器、终端、数据查看器、调试器、Markdown 编辑器、CSV 编辑器、文件浏览器、IPython 集群管理器等。

JupyterLab 不仅提供这些工具,还允许你在它们之间共享内核。在一个浏览器窗口中,你可以在一个标签页上使用笔记本,在另一个标签页上编辑相关的数据文件,在终端中检查资源或进程,在控制台中测试概念,轻松地在文件管理器中查找和打开文件,改变显示语言,等等。像 Notebook 一样,它是免费的并且是开源的。

虽然 Jupyter Notebook 非常适合数据探索、增量软件开发和文档编写,但 JupyterLab 通过提供许多传统 IDE 中常见的功能,允许进行更为严肃的软件开发。但是如果你喜欢 Notebook,不必担心;JupyterLab 基本上是一个位于现有 Jupyter 架构之上的新前端。它使用与经典 Jupyter Notebook 相同的服务器和文件格式,因此与你现有的笔记本完全兼容。事实上,你可以在同一台计算机上同时运行经典的 Notebook 应用程序和 JupyterLab。

安装 JupyterLab

正如我们在第二章中讨论的那样,为您的每个项目最好有一个独立的 conda 环境。要在这些环境中使用 JupyterLab,您有两个主要选项:可以直接在每个 conda 环境中安装 JupyterLab,或者将每个环境链接到base环境中的 JupyterLab 安装。我们将第一个选项称为朴素方法,第二个称为模块化方法。尽管通常推荐使用模块化方法,但如果一个项目需要锁定特定版本的 JupyterLab,您将需要使用朴素方法。

朴素方法

在朴素方法中,您直接在 conda 环境中安装 JupyterLab。然后,您可以导入并使用在同一环境中安装的任何软件包。这是最简单的方法,但随着时间的推移,它可能会变得资源密集,因为您的pkgs文件夹会被不同版本的 JupyterLab 填满。

使用 Anaconda Navigator 安装和启动 JupyterLab

要使用 Anaconda Navigator 在新环境中安装 JupyterLab,首先通过 Windows 中的开始菜单、macOS 中的 Launchpad,或者在 Linux 中通过终端输入anaconda-navigator来启动 Navigator。然后,通过选择主页标签页顶部的Applications on下拉菜单中的环境名称来激活该环境(见图 6-1)。在本示例中,我们使用的是base环境。

图片

图 6-1:Anaconda Navigator 主页标签页,显示活动环境(base 或 root)和 JupyterLab 图块

接下来,找到 JupyterLab 应用图块并点击安装按钮。您可能需要向下滚动主页标签页才能找到该图块。如果找不到该图块,请按照下一节所述使用 CLI 安装 JupyterLab。

注意

如果您看到启动按钮而不是安装按钮,则说明 JupyterLab 现在已与 Anaconda 一起预装。

安装按钮将安装来自您的频道列表顶部的最新版本的 JupyterLab,频道列表位于主页标签页的顶部附近。如果您想安装特定版本,请点击 JupyterLab 图块右上角的“齿轮”图标(见图 6-1),以查看可用版本号的列表。

如果您需要绝对最新版本的 JupyterLab,请确保 conda-forge 频道位于您的频道列表顶部。默认频道中的软件包可能稍微旧一些,但作为补偿,它们通过了最严格的兼容性测试。有关使用频道的更多信息,请参见第二章。

几分钟后,安装按钮应变为启动按钮。此按钮将在您的计算机上启动一个本地 Web 服务器,显示 JupyterLab 界面。由于它是在本地运行的,您不需要活跃的互联网连接;然而,您需要保持 Navigator 处于运行状态。

使用 CLI 安装和启动 JupyterLab

要在新环境中使用 conda 安装 JupyterLab,首先打开 Anaconda Prompt(Windows)或终端(macOS 和 Linux),并激活 conda 环境。我们为在第二章中创建的my_second_env做这个操作。如果你跳过了第二章中的这一步,按照以下步骤创建该环境:

conda create --name my_second_env

现在,激活该环境:

conda activate my_second_env

接下来,使用 conda 安装 JupyterLab:

conda install -c conda-forge jupyterlab

要安装特定版本,比如 3.1.4,你可以使用以下命令:

conda install -c conda-forge jupyterlab=3.1.4

要通过命令行启动 JupyterLab,输入以下命令:

jupyter lab

这会在你的计算机上启动一个本地 Web 服务器,显示 JupyterLab 界面。由于它是本地运行的,你不需要活跃的互联网连接。然而,你需要保持打开提示窗口或终端,因为它运行着本地服务器,允许你与网页浏览器进行交互。

模块化方法

使用模块化方法,你将每个 conda 环境链接回你的base环境中的 JupyterLab 包。该方法资源效率高,并且使你可以轻松保持包的最新状态,并从同一个 JupyterLab 实例中选择不同的环境。

你可以使用模块化方法,无论是通过 Navigator 还是 CLI。为了简单起见,我们使用 CLI。打开 Anaconda Prompt(Windows)或终端(macOS 或 Linux),并输入以下命令以创建一个名为my_lab_env的新环境:

conda create --name my_lab_env

当提示时输入 y 以接受安装。接下来,激活新的环境:

conda activate my_lab_env

要将此环境与base环境中的 JupyterLab 安装进行链接,输入以下内容:

conda install ipykernel

多亏了 ipykernel,我们无需显式地在环境中安装 Python。但是,如果你确实需要在项目中使用特定版本的 Python,你需要显式地将其安装到环境中。

现在,停用 my_lab_env,这将让你返回到base环境:

conda deactivate

如果 JupyterLab 已经安装在base环境中,你可以跳过下一步。否则,使用以下命令安装 JupyterLab:

conda install -c conda-forge jupyterlab

要安装特定版本,比如 3.1.4,你可以使用以下命令:

conda install -c conda-forge jupyterlab=3.1.4

接下来,安装 nb_conda_kernels 包到base环境。你只需要做一次,如果你已经完成了第五章,它应该已经安装(你可以通过激活环境后运行 conda list nb_conda_kernels 来检查):

conda install nb_conda_kernels

nb_conda_kernels 包使得环境中的 Jupyter 实例能够自动识别任何安装了 ipykernel 包的其他环境。正是这种在base环境中的 nb_conda_kernels 和其他 conda 环境中的 ipykernel 的组合,使你能够使用单一安装的 JupyterLab。

要从活动的base环境启动 JupyterLab,输入以下内容:

jupyter lab

这将在你的计算机上启动一个本地 web 服务器,显示 JupyterLab 界面。由于它是本地运行的,你不需要活跃的互联网连接。然而,你需要保持 Prompt 窗口或终端打开,因为它正在运行一个本地服务器,允许你与 web 浏览器进行交互。

构建一个 3D 天文模拟

现在是时候开始使用 JupyterLab 了!在这个例子中,我们将使用 JupyterLab 构建一个 3D 模拟,模拟一个天文奇观:球状星团。球状星团是围绕大多数螺旋星系(例如我们的银河系)旋转的球形星星集合。它们是星系中最古老的特征之一,可能包含数百万颗紧密集中的星星。

为了避免混淆,让我们从头开始。如果你在前面的章节中启动了 JupyterLab,打开它的浏览器页面,然后通过点击 文件关闭 来关闭它。如果 Navigator 打开了,选择 文件退出 来关闭它。

以后我们将采用模块化方法,因此请确保按照前一部分的描述在你的 base 环境中安装 JupyterLab 和 nb_conda_kernels 包。

使用专门的项目文件夹

Anaconda 使用专门的文件夹来跟踪你已安装的包和 conda 环境(见 第二章)。虽然 Anaconda 设计上能够顺利与这种结构配合并帮助你浏览它,但并不是每个人都希望将项目文件散布在目录树中。正如我们在 第四章 中讨论的,保持所有项目文件和文件夹都在一个主文件夹内有多种好处。

让我们通过一个例子来进行操作,在这个例子中,我们将 conda 环境和 Jupyter 笔记本存储在一个名为 my_jlab_proj 的文件夹中,简称“我的 JupyterLab 项目”。我将这个目录创建在 Windows 中的用户目录 (*C:\Users\hanna*),我建议你在你的系统中使用类似的位置。

注意

JupyterLab 文件浏览器的根目录(即层级结构中的最高目录)是你启动 JupyterLab 时所在的目录。通常这是存放 anaconda3 文件夹的主目录。因此,你将无法在 JupyterLab 中访问此目录结构之上的文件或文件夹。

虽然你可以使用 Anaconda Navigator 创建目录和环境,但命令行更加简洁,因此我们以后将使用命令行。要为项目创建目录,打开 Anaconda Prompt(在 Windows 中)或终端(在 macOS 或 Linux 中),然后输入以下内容(使用你自己的目录路径,直到 \my_jlab_proj):

mkdir C:\Users\hanna\my_jlab_proj
mkdir C:\Users\hanna\my_jlab_proj\notebooks
mkdir C:\Users\hanna\my_jlab_proj\data

这会创建一个名为 my_jlab_proj 的目录,里面有笔记本和数据子目录。接下来,在项目目录下创建一个名为 my_jlab_proj_env 的 conda 环境,激活它,并安装一些库(需要时替换为你自己的路径):

conda create --prefix C:\Users\hanna\my_jlab_proj\my_jlab_proj_env
conda activate C:\Users\hanna\my_jlab_proj\my_jlab_proj_env
conda install ipykernel matplotlib

如前所述,ipykernel 包允许你在基本环境中使用单个安装的 JupyterLab 应用程序。Matplotlib 包是 Python 的主要绘图库,其中包括 NumPy(数值 Python)包作为依赖项。我们将在本书的后续章节中详细探讨这些库。

此时,你的项目目录结构应该像图 6-2 所示。当然,对于一个真实的项目,你可能会包含额外的文件夹,用于特定类型的数据、非笔记本脚本、杂项和其他内容。

Image

图 6-2:my_jlab_proj 的目录结构

要启动 JupyterLab,请首先返回到基本环境:

conda deactivate

然后,输入以下内容:

jupyter lab

JupyterLab 接口

当你启动 JupyterLab 时,浏览器会新建一个标签页,左侧是文件管理器,主工作区是一个启动器标签页(图 6-3)。如果你在菜单栏顶部看不到启动器窗格,请选择 文件新建启动器

在图 6-3 中的默认视图仅作为一个起点。事实上,JupyterLab 的构建模块非常灵活和可定制,因此没有标准视图的概念,尽管有一些共同的特性。

JupyterLab 会话驻留在一个工作空间中,其中包含 JupyterLab 的状态;即当前打开的文件、应用程序区域和选项卡的布局等。工作空间包括一个主工作区启动器窗格,其中包含文档和活动的选项卡;一个菜单栏;以及一个可折叠的左侧边栏。左侧边栏包含文件浏览器和打开选项卡列表及运行的内核和终端的图标,目录表和扩展管理器。

Image

图 6-3:带有主要组件标签的 JupyterLab 工作空间

在启动器窗格中,你会看到笔记本和控制台的部分。其中包含你各种 conda 环境中的内核磁贴(你的视图将与图 6-3 不同,因为我自己设置了一些环境)。你还会看到一个其他部分,可以从中打开终端、文本文件、Markdown 文件、Python 文件或上下文帮助页面。

菜单栏

JupyterLab 的顶部菜单栏(图 6-3)提供了顶级菜单,显示可用操作及其键盘快捷键。这些菜单是特定于主工作区中活动选项卡的;不可用的操作将显示但呈灰色(半透明)。为了方便起见,某些操作在左侧边栏中也有重复显示。以下是默认菜单:

文件 与文件和文件夹相关的操作,包括关闭和退出登录

编辑 与编辑文档和处理笔记本单元格相关的操作

视图 改变 JupyterLab 的外观并打开命令面板

运行 在笔记本和控制台中运行代码的操作

内核 管理内核的操作

选项卡 处理选项卡的操作,以及打开的选项卡列表

设置 主题、语言、键盘映射、字体大小等的设置

帮助 JupyterLab 帮助链接,并启动经典 Jupyter Notebook

JupyterLab 扩展也可以在菜单栏中创建新的顶级菜单。这些菜单将特定于该扩展。

左侧边栏

左侧边栏提供了访问常用选项卡的功能,如文件浏览器、打开的选项卡列表、正在运行的终端和内核、目录生成器以及第三方扩展管理器,如图 6-4 所示。

图片

图 6-4: 启动文件浏览器的左侧边栏

当你关闭一个笔记本、代码控制台或终端时,服务器上运行的底层内核或终端仍然会继续运行。这使你能够执行长时间运行的操作,并在稍后返回。运行面板(图 6-5)让你可以重新打开与特定终端或内核相关联的文档。你还可以关闭任何打开的内核或终端。

图片

图 6-5: 运行中的终端和内核面板

现在内建于 JupyterLab 的目录扩展使得查看和浏览文档结构变得更加容易。当你打开笔记本、Markdown、LaTeX 或 Python 文件时,左侧边栏会自动生成一个目录。每个列出的部分都可以点击链接到文档中的实际部分。你可以为标题编号、折叠章节并浏览文件。

工具使用你在 Markdown 单元格中设置的标题来生成目录。启用“自动编号”选项(当你打开文件时可见),它将遍历笔记本并为由标题指定的部分和子部分编号。这使得你可以移动大部分章节,而无需逐一修改文档中的编号。

扩展管理器帮助你管理已安装的任何第三方扩展。我们稍后将详细介绍扩展。

左侧边栏是可折叠的。只需点击活动面板的图标,或者从菜单栏选择 视图显示左侧边栏,即可切换其显示状态。

创建新笔记本

让我们在notebooks文件夹中创建一个新的 Jupyter 笔记本,用于存放我们的星团代码和输出。在文件管理器视图中,也就是左侧边栏,导航到文件夹并打开它。然后,在启动器面板中的笔记本部分,找到并点击标记为 Python[conda env:my_jlab_proj_env] 的方块(如果方块标签被截断,将光标悬停在方块上查看完整名称)。这将打开一个新的未命名笔记本,使用指定环境中的内核(图 6-6)。

图片

图 6-6: JupyterLab 工作区中的新未命名笔记本

请注意,笔记本的标签上方有一个彩色的顶部边框(默认是蓝色)。工作区只允许一个当前活动,这让你知道哪个标签是激活的。

如果你阅读了第五章,你可能会认出笔记本界面,尽管相对于经典笔记本有一些变化。单元格顶部的图标和菜单选项(位于 Untitled.ipynb 标签下方的工具栏)更简化、精炼,并且与运行在界面顶部的功能更全的菜单栏共享功能。花点时间将鼠标悬停在工具栏图标上,然后点击主菜单项,如文件、编辑和运行,查看可用的选项。这些功能应该你在第五章中已经熟悉。

命名笔记本

现在,让我们重命名笔记本。你可以通过几种方式来完成此操作。你可以从主菜单中选择文件重命名笔记本。另外,你也可以通过右键点击Untitled.ipynb标签页或在文件浏览器中右键点击文件名,然后选择重命名(图 6-7)。

Image

图 6-7:文件浏览器的上下文菜单,用于操作文件

注意

JupyterLab 配备了许多方便的上下文菜单。几乎所有可点击的区域,包括笔记本单元格下方的空白区域,都有可用的菜单。

使用图 6-7 中显示的上下文菜单将笔记本命名为globular.ipynb。笔记本标签页的名称也应该发生变化。

使用 Markdown 单元格

要创建一个描述性的标题,请点击第一个单元格并使用笔记本顶部的工具栏(图 6-8)将单元格类型从代码更改为Markdown

Image

图 6-8:笔记本工具栏

现在,输入以下内容并按 CTRL-ENTER 运行单元格:

## Simulate a Globular Star Cluster with Matplotlib

有关 Markdown 的更多信息,请参见第 102 页中的“使用 Markdown 单元格添加文本”。

添加代码并绘制图形

你可以轻松地在单个单元格中运行模拟器代码,但为了叙述方便,让我们将其分散到多个单元格中。像这样创建模块化程序有其优势。例如,你可以在第一个单元格中隔离导入和数据加载,这样你就不需要在修改后续单元格时每次都重新运行它们。

首先通过使用笔记本工具栏中的“+”添加一个新单元格(图 6-8)。新单元格默认是代码单元格,因此你可以开始编码。第一步是导入构建模拟所需的库:

%matplotlib inline
import numpy as np
from matplotlib import pyplot as plt
plt.style.use('dark_background')

该代码从一个魔术命令开始,使 Matplotlib 在 inline 中绘图。这意味着它将在笔记本内部的输出单元格中绘制。接下来的两行导入 NumPy 和 Matplotlib。最后一行选择 Matplotlib 的黑暗主题来绘制图形,这样我们的白色星星将在黑色背景下显示。按下 SHIFT-ENTER 运行该单元格,这将运行该单元格并在下方添加一个新的代码单元格,或者点击工具栏中的三角形“播放”图标( ▸ )(参见 图 6-8)。

现在定义一个通用函数,创建一个球形体积中的 x、y、z 坐标列表。在新单元格中,输入以下内容:

def spherical_coords(num_pts, radius):
    ″″″Return list of uniformly distributed points in a sphere.″″″
 ➊ position_list = []
    for _ in range(num_pts):
     ➋ coords = np.random.normal(0, 1, 3)     
        coords *= radius   
        position_list.append(list(coords))
    return position_list

该函数将点数 (num_pts) 和球体半径 (radius) 作为参数。这决定了星团的大小及其包含的星星数量。然后,创建一个空列表 ➊ 以保存坐标,并循环处理点数,每次从具有均值 0 和标准差 1 的正态分布中绘制三个随机值 ➋。这三个值将表示星星的 x、y、z 坐标。将坐标乘以半径会拉伸或缩小星团的大小。在每次循环结束时,将坐标附加到列表中,并通过返回列表结束函数。

按下 SHIFT-ENTER 运行该单元格,以在笔记本底部添加一个新单元格。

现在,创建一个球状星团并将其绘制出来。在新单元格中,输入以下代码:

   rim_radius = 1
   num_rim_stars = 3000
   rim_stars = spherical_coords(num_rim_stars, rim_radius)
➊ core_stars = spherical_coords(int(num_rim_stars/4), rim_radius/2.5)

➋ fig, ax = plt.subplots(1, 1, subplot_kw={'projection':'3d'})
   ax.axis('off')
➌ ax.scatter(*zip(*core_stars), s=0.5, c='white')
   ax.scatter(*zip(*rim_stars), s=0.1, c='white')
➍ ax.set_xlim(-(rim_radius * 4), (rim_radius * 4))
   ax.set_ylim(-(rim_radius * 4), (rim_radius * 4))
   ax.set_zlim(-(rim_radius * 3), (rim_radius * 3))
   ax.set_aspect('auto')

“边缘”变量表示整个星团的半径和星星数量。通过调用函数生成坐标。然后,再次调用该函数生成位于星团中心密集核心区域的星星的坐标 ➊。注意,您可以通过将它们除以缩放因子并确保星星数量变量保持整数来更改传递给函数的输入参数。您可以使用这些缩放器来改变核心区域的外观。

现在是绘制星星的时候了。暂时不必担心 Matplotlib 的复杂语法;我们稍后会在本书中详细讨论这一点。基本上,绘图被称为 Axes(简称 ax)位于 Figurefig)对象中,这些对象作为容器 ➋。要创建一个单独的三维 ax 对象,您可以调用 plt.subplots() 方法,并将投影类型设置为 3d。然后,关闭绘图的 x、y 和 z 轴;我们希望我们的星团漂浮在宇宙的黑暗中。

要发布星点,请两次调用 scatter() 方法:一次用于边缘星星,一次用于核心 ➌。这使您可以为两个区域指定不同的点大小。scatter() 方法期望 x、y、z 点,但数据当前是一个列表的列表,每个点的坐标在其自己的列表中:

[[-1.3416146295620397, 0.24853387721205472, -1.3228171973149565],
 [-0.23230429303889005, 0.04705622148151854, 0.7578767084376479]...]

为了提取这些坐标,我们将使用 Python 内置的zip()函数,并结合其splat*)操作符来解包多个变量。最后通过设置坐标轴的限制,使它们的纵横比相等,并且足够大以容纳星团➍。通过将限制与rim_radius变量相关联,而不是指定绝对大小,如果你改变半径值,图表会自动调整。

按 CTRL-ENTER 运行单元格并生成图表,而无需添加新单元格。你的完成笔记本应该如下所示:图 6-9。

Image

图 6-9:完成的球状星团笔记本

要保存工作,点击工具栏上的软盘图标,或者使用 CTRL-S。

添加一个控制台

到目前为止,我们所做的都可以在经典的 Jupyter Notebook 应用程序中完成。现在,让我们看看 JupyterLab 能带来什么,尤其是能够使用多个标签页连接到相同内核的功能。

在处理代码时,特别是继承自队友的代码,你可能需要检查数据类型、列表内容、函数返回值等。通常,调查这些副作用会让你的笔记本变得杂乱。但是,JupyterLab 允许你打开多个标签页,并将这些标签页与正在运行的内核连接。这使得你可以在笔记本之外进行探索性工作,同时仍然处于工作空间内。

要打开一个连接到当前内核的控制台,在任何单元格中右键点击,然后在打开的上下文菜单中选择为笔记本新建控制台。一个控制台应该会出现在笔记本下方,如图 6-10 所示。

Image

图 6-10:一个新控制台链接到 globular.ipynb 笔记本

要查看rim_stars列表中坐标的格式,将光标放在控制台底部的空框中,输入以下内容,然后按 SHIFT-ENTER 运行:

print(rim_stars[:3])

这将显示列表的前三行:

[[0.9223767036280706, -1.0746823426729988, 0.30774451034833233],
[0.25440816717656933, 0.21302429871155004, 0.7991568645529153], 
[-0.922974317327836, 0.49065537767349343, 0.5170958730770349]]

你可以看到你正在处理一个列表的列表,每个嵌套列表包含三个浮动值,分别表示 x、y 和 z 坐标。因为笔记本和控制台共享相同的内核,一旦你运行笔记本,任何导入、变量赋值、函数定义等都会驻留在内存中并可以被控制台访问。你甚至可以将单元格[3]的内容复制到控制台中,调整参数,并在那里绘制结果,而不会影响你的笔记本。

为了保持控制台整洁,打开其上下文菜单并选择清除控制台单元格

显示图像文件

如果你想将输出与球状星团的照片进行比较,以帮助你调整输入变量以获得真实感的模拟效果怎么办?你可以将图像添加到 Markdown 单元格中,但你可能需要向下滚动才能看到它,并且稍后必须记得删除它。为了避免这种麻烦,你可以在一个单独的 JupyterLab 窗口中显示该图像。

首先,访问 Wikimedia Commons 网站 (commons.wikimedia.org/),并搜索 “The Great Globular Cluster in Hercules – M13”。将图片保存或下载到你的 my_jlab_proj\data 文件夹。我使用了分辨率为 640 像素的版本,下载地址是 upload.wikimedia.org/wikipedia/commons/thumb/6/6f/The_Great_Globular_Cluster_in_Hercules_-_M13.jpg/640px-The_Great_Globular_Cluster_in_Hercules_-_M13.jpg

回到 JupyterLab,导航到文件浏览器中的图片并通过右键点击文件名选择打开,或者双击文件名来打开它。接下来,将新的图片面板和控制台拖动并堆叠到屏幕的右侧,以生成图 6-11 所示的布局。

Image

图 6-11:我们的最终工作区,包含文件浏览器(左)、笔记本(中)、控制台(右上)和 .jpg 图片(右下)

一个类似这样的工作区,包含文件浏览器、笔记本和控制台,是初学者的理想设置。

探索仿真

你可以通过在外部窗口中打开仿真,改变背景色,添加网格线等方式来改变仿真的外观。要以 3D 模式探索仿真,可以将单元格 [1] 中的第一行更改为:

%matplotlib qt

然后,从主菜单中选择 运行运行所有单元。这将打开一个外部 Qt 窗口,允许你旋转该星团并从各个角度查看它。如果窗口没有自动弹出,请检查任务栏。

如果你想看到图形的 3D 网格,最好使用负像素图像。首先,使用 CTRL / 或 CMD / 快捷键找到并注释掉以下两行:

# plt.style.use('dark_background')
# ax.axis('off')

接下来,将恒星的颜色改为黑色:

ax.scatter(*zip(*core_stars), s=0.5, c='black')
ax.scatter(*zip(*rim_stars), s=0.1, c='black')

将笔记本保存为 globular_black.ipynb 并运行所有单元。你可能需要重启内核来清除黑色背景的图形样式。如果需要,从菜单栏选择 内核重启内核并运行所有单元。你应该会看到图 6-12 所示的图形。

Image

图 6-12:带有网格线的“黑色”星团仿真

注意

你可以使用 jupyterlab-matplotlib 扩展在笔记本输出单元中与图形进行交互。我们将在本章稍后讨论 JupyterLab 扩展。

打开多个笔记本

JupyterLab 的一个优点是它允许你同时处理多个笔记本项目。假设你想编辑在第五章中制作的 geyser.ipynb 文件。在 JupyterLab 中,你可以通过文件管理器找到该笔记本并双击它来打开一个新的标签页(图 6-13)。

Image

图 6-13:在同一浏览器窗口中打开的两个笔记本

您现在在同一个浏览器窗口中打开了两个笔记本,它们使用不同的内核,如每个笔记本右上角所示。

保存工作区

工作区中的文档,如 Jupyter 笔记本和文本文件,可以使用标准命令进行保存,如 CTRL-S、文件保存笔记本等。此外,您的工作区的 布局(即您打开的标签页、它们的排列以及内容)可以作为 .jupyterlab-workspace 文件保存。

如果您打算多次使用当前布局,或者打算拥有多个依赖项目的布局,您需要为每个工作区提供一个独特的名称。要将此布局文件存储在项目文件夹中,请转到 JupyterLab 文件浏览器,确保您在 my_jlab_proj 文件夹中。接下来,使用新建文件夹图标(一个带有“+”的文件夹)创建一个名为 workspaces 的文件夹(图 6-14)。然后打开此文件夹。

Image

图 6-14:添加工作区文件夹

为了保存当前的 JupyterLab 状态,请在菜单栏中选择 文件另存为当前工作区。弹出窗口会要求您输入名称,格式如下:

my_jlab_proj/new-workspace.jupyterlab-workspace

new-workspace 文本改为 globular 然后点击 保存

my_jlab_proj/globular.jupyterlab-workspace

要恢复到已保存的工作区,只需打开 .jupyterlab-workspace 文件。

清空工作区

要清空工作区的内容,请使用 reset URL 参数。这里的示例展示了其一般格式:

http(s)://<server:port>/<lab-location>/lab/workspaces/<workspace-name>?reset

例如,要重置我们的球形工作区,在浏览器的地址栏中使用reset,如下所示:

http://localhost:8888/lab/workspaces/globular?reset

这将配置您的工作区,类似于 图 6-3 中所示的内容。如果您已按照上一节中的描述保存了之前的布局,仍然可以恢复该布局。

欲了解更多关于管理工作区的信息,请访问 jupyterlab.readthedocs.io/en/stable/user/urls.html

关闭工作区

与 Jupyter Notebook 一样,仅仅关闭浏览器标签页并不会停止 JupyterLab。要完全关闭它,请在菜单栏上选择 文件关闭。如果您登录的是另一服务器而不是本地工作,您可以使用 文件登出 来退出。

注意

请注意,一些服务提供商(如大学)可能有特定的服务器登出程序。未遵循这些程序可能会浪费分配的时间资源,并导致意外的使用费用。

利用 JupyterLab 界面

希望之前的示例让您对 JupyterLab 界面有所了解。在接下来的章节中,我们将更详细地看一下它的一些组件和控件。很多这些都是不言而喻的,因此我们将重点关注最有用和不太直观的部分。

正如你在球状星团练习中看到的,工作区允许你通过自定义布局将工具组合在一起。它还为 Jupyter 笔记本带来了一些不错的功能,包括通过拖拽单元格重新排列笔记本、在笔记本之间拖拽单元格以复制内容,以及创建多个相同笔记本的同步视图。

创建同步视图

让我们来看一下关于同步视图的最后一个案例。你可能经常需要同时查看一个长笔记本的顶部和底部,或者向下滚动查看交互式输出。为了管理这一需求,JupyterLab 允许你多次打开同一个笔记本。

要了解这个是如何工作的,在球状星团会话中,点击globular笔记本,然后选择文件为笔记本创建新视图。接下来,调整布局,使两个笔记本并排显示。然后,缩小浏览器窗口,使其无法显示完整的笔记本及其输出,模拟一个长笔记本。在左侧笔记本中,向上滚动查看代码。在右侧笔记本中,向下滚动查看图表,如图 6-15 所示。现在,重新运行第一个笔记本中的单元格。右侧的图表应该会更新。

Image

图 6-15:不同标签页中相同笔记本的同步视图

或者,你也可以将输出单元格移动到一个新窗格中。只需在包含球状星团模拟的输出单元格中打开上下文菜单,然后选择为输出创建新视图(图 6-16)。然后,你可以将它拖动到工作区中的任何位置。

如果你使用滑块或其他小部件来交互式地更改参数并更新可视化,这些也会包含在新的视图中。这使你可以在工作区内创建伪仪表板。

在笔记本之间复制单元格

要在笔记本之间拖拽并复制单元格,使用文件新建笔记本打开一个新笔记本。将新笔记本拖到globular笔记本旁边。在globular笔记本中,点击一个单元格编号(例如[1]:),并将其拖动到新的未命名笔记本中。你应该看到类似图 6-17 所示的结果。

Image

图 6-16:带有输出单元格的球状星团笔记本,输出单元格位于一个单独的窗格中

Image

图 6-17:将左侧笔记本中的第一个单元格拖入右侧笔记本的结果

通过使用单文档模式保持专注

经典的 Jupyter 笔记本有一个优点,那就是你可以专注于一个任务,而不必让应用程序“干扰”你。JupyterLab 的开发者注意到这一点,并加入了一个设置,允许你专注于单个文档或活动,而无需关闭主工作区中的所有其他标签。

要启用此设置,通过点击标签激活该标签,然后从菜单栏中选择 视图简单界面,或使用 JupyterLab 窗口左下角的 简单 切换开关。工作区将仅显示活动标签。如果你在 球形 工作区上开启和关闭此设置,可能会发现一个缺点。当你返回常规视图时,你 可能 会丢失你偏好的标签排列(比较 图 6-18 和 6-11)。

Image

图 6-18:关闭简单界面模式后,球形会话的工作区标签排列

如果视图发生变化,你可以通过手动操作或使用已保存的 .jupyterlab-workspace 文件来恢复原始布局。由于这有点繁琐,当你打算长时间停留在单个文档或活动中时,你可能只想使用简单界面选项。

使用文本编辑器

JupyterLab 包括一个文本编辑器,你可以用它来编写 Python 脚本。在我们的 球形 会话中没有涉及这个内容,所以这里我们通过一个简单的例子来讲解,使用勾股定理。这个著名的 a² + b² = c² 公式用于求直角三角形的斜边。

如果你已经关闭了 JupyterLab,从你的 base 环境启动它。打开 Anaconda 提示符(Windows)或终端(macOS 或 Linux),然后输入以下命令:

jupyter lab

这应该会打开默认布局,如 图 6-3 所示。

如果你已经启动了 JupyterLab,可以通过编辑 URL 将其结尾改为 /lab 来返回默认工作区。例如:

http://localhost:8888/lab

如果由于某种原因你的工作区看起来与 图 6-3 中的不同,可以通过添加 ?reset URL 参数来重置它,如下所示:

http://localhost:8888/lab?reset

现在,从启动器面板中,启动一个新的文本文件或 Python 文件。一个新的标签页应该会为未命名文件打开。点击文件并输入以下内容:

def pythagoras(a, b):
    return (a**2 + b**2)**0.5

for i in range(9):
     a = i
     b = i + 1
     print(f″a = {a}, b = {b}, c = {pythagoras(a, b)}″)

从菜单栏中,选择 文件另存为(如果你选择了 Python 文件选项,则选择 文件另存 Python 文件为),并将文件命名为 pythagoras.py。当你点击 保存 时,文件应该会出现在文件浏览器中。如果你回到并再次点击文件菜单,你会注意到保存选项现在变成了 保存 Python 文件另存 Python 文件为,即使你最初选择的是文本文件。JupyterLab 现在已经识别这是一个 Python 文件。

在运行脚本之前,你需要保存它们。你可以通过查看标签来判断文件是否已保存。未保存的文件会在文件名旁边显示一个黑点,而已保存的文件则会显示一个 X(图 6-19)。

Image

图 6-19:未保存的文本文件标签上有•,而保存的文本文件标签上有 X

尽管 JupyterLab 文本编辑器不像 Spyder 的编辑器那样强大(第四章),但它比像记事本这样的简单编辑器更为精致。如果你在菜单栏上点击 设置,你会看到多个子菜单,可以用来更改其外观和行为,如 图 6-20 所示。

Image

图 6-20:设置菜单

有一个键盘映射可以让你使用与 Sublime Text、vim 和 emacs 编辑器相同的快捷键。还有多种编辑器主题选择,能够更改字体大小,设置制表符缩进级别,并自动关闭括号。在高级设置编辑器中,你可以更改编辑器的配置文件。键盘快捷键也可以使用,具体取决于你选择的键盘映射。你可以在线搜索各个映射的键位绑定列表。

默认情况下,编辑器使用 纯文本 语法高亮样式,但你可以通过选择菜单栏上的 视图文本编辑器语法高亮(或 文本编辑器主题)从完整的列表中选择其他样式。往后,我将使用默认的 Jupyter 主题、键盘映射和语法高亮样式。有关高亮的更多信息,请参见 第三章。

回到我们的脚本。你有几种方法可以运行你在编辑器中编写的代码。在接下来的部分,我们将介绍通过终端和笔记本运行代码的选项。

在终端中运行脚本

要在终端模拟器中运行已保存的 pythagoras.py 文件,在菜单栏上选择 文件新建终端。接着,点击终端面板并输入以下内容:

python pythagoras.py

按下 ENTER,脚本应该会运行(图 6-21)。

Image

图 6-21:在终端面板中运行 Python 文件

根据你的计算机设置,你可能需要使用 python3 替代 python

python3 pythagoras.py

如果你编辑了 Python 文件并想在终端中重新运行它,请记住,你可以使用箭头键选择之前的命令,从而节省敲击键盘的时间。

在笔记本中运行脚本

要在笔记本中运行已保存的 pythagoras.py 文件,在菜单栏上选择 文件新建笔记本。如果系统提示选择内核,请接受 Python3 或从下拉菜单中选择 my_jlab_proj_env 中的内核。接着,点击笔记本单元格并输入以下内容:

%run pythagoras.py

按下 CTRL-ENTER,你应该会在笔记本中看到输出结果(图 6-22)。

Image

图 6-22:在笔记本中运行 Python 文件

请注意,你无需保存或重命名笔记本就可以使用它来运行脚本。

同时编写和记录代码

JupyterLab 允许你在一个工作空间内编写文档代码、检查文档中的代码是否能运行,并预览结果。让我们来看一个例子。

在文件浏览器中,导航到你的用户目录。通过菜单栏中的文件新建文本文件打开一个新的文本编辑器。将其重命名为 doc.md.md 文件是使用 Markdown 语言的纯文本格式文件,就像 Notebook 的 Markdown 单元),然后输入以下内容:

## Example of Previewing Code Documentation in JupyterLab.

现在,在编辑器窗格中,打开上下文菜单,然后选择显示 Markdown 预览

返回编辑器,输入以下内容:

### Let's run some code in a console.

import matplotlib.pyplot as plt

plt.plot([0, 1, 2, 3], [0, 1, 2, 3])
plt.savefig('doc_test.png')

在编辑器窗格中,打开上下文菜单,然后选择为编辑器创建控制台。如果系统提示选择内核,选择一个已安装 Matplotlib 的内核,例如 Python [conda env:my_jlab_proj_env]。现在,将前面的代码(包括导入语句)复制到控制台中,然后使用 SHIFT-ENTER 执行。

接下来,在编辑器中输入以下代码,以便在 Markdown 预览中显示图表:

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/py-tl-sci/img/doc_test.png)

你的布局应该类似于 图 6-23。

你还可以使用为编辑器创建控制台选项在文本编辑器中运行代码。打开控制台后,突出显示编辑器中的代码,然后从菜单中选择运行运行代码

JupyterLab 的多样化布局和可共享内核支持高效的工作流程,从而提高生产力。如果你在编写代码时总是不断切换标签和滚动窗格,你可能没有充分利用 JupyterLab 的全部功能。

Image

图 6-23:使用编辑器、控制台和 Markdown 窗格预览代码

使用 JupyterLab 扩展

JupyterLab 扩展是即插即用的插件,用于“扩展” JupyterLab 的功能。每个扩展可以包含一个或多个插件(可扩展性的基本单元)。扩展可以由任何人创建,包括你自己。引用文档中的话,“[JupyterLab] 本身就是一系列扩展的集合,它们没有比任何自定义扩展更强大或更具特权。”

一小部分流行的 JupyterLab 扩展列在 表 6-1 中。一些以前流行的扩展,如目录和调试器扩展,现在已经内置到 JupyterLab 中。还有一些用于处理绘图和仪表盘库(如 Plotly、Bokeh 和 Dash)的扩展。我们将在 第十六章 中讨论这些库。

JupyterLab 扩展包含在浏览器中运行的 JavaScript。扩展有两种类型:源代码预构建。激活源代码扩展需要安装 Node.js 并重建 JupyterLab。而预构建的扩展(如以 Python 包发布的扩展)不需要重建 JupyterLab。扩展还可以包括必要的服务器端组件,供扩展功能使用。

表 6-1: 有用的 JupyterLab 扩展

扩展 描述 网站
nbdime 用于比较和合并 Jupyter 笔记本的工具 nbdime.readthedocs.io/en/latest/
jupyterlab-git 使用 Git 进行版本控制 github.com/jupyterlab/jupyterlab-git/
JupyterLab GitHub 从仓库访问笔记本 www.npmjs.com/package/@jupyterlab/github/
Jupyter-ML 工作区 专为机器学习设计的 IDE github.com/ml-tooling/ml-workspace/
JupyterLab 系统监控 监控内存和 CPU 使用情况 github.com/jtpio/jupyterlab-system-monitor/
jupyterlab_html 查看渲染后的 HTML 文件 github.com/mflevine/jupyterlab_html
jupyterlab matplotlib 交互式内联 Matplotlib github.com/matplotlib/ipympl/
JupyterLab LaTeX 实时编辑 LaTeX 文档 github.com/jupyterlab/jupyterlab-latex/
JupyterLab 代码格式化工具 使用 Black 或 Autopep8 等格式化工具来强制执行风格指南 github.com/ryantam626/jupyterlab_code_formatter/
jupyterlab-spellchecker Markdown 单元和文本文件的拼写检查工具 github.com/ocordes/jupyterlab_spellchecker/
jupyterlab-google-drive 通过 Google Drive 提供云存储 github.com/jupyterlab/jupyterlab-google-drive

注意

我们在第五章中回顾的经典 Jupyter Notebook 扩展与 JupyterLab 不兼容。尽管许多有用的扩展已被移植到 JupyterLab,但其他一些扩展仍在更新中。如果你想要的扩展不可用,请耐心等待,并定期检查扩展管理器以查看是否有更新。扩展的官方网站也可能会发布有关更新的新闻。

使用扩展管理器安装和管理扩展

你可以在左侧边栏使用扩展管理器(参见图 6-3)来安装和管理作为单个 JavaScript 包分发的扩展,这些包位于 npm,即 node 包管理器 上(* www.npmjs.com/*)。扩展管理器默认是关闭的,但你可以通过点击 启用 按钮来打开它(参见图 6-24)。

Image

图 6-24:从左侧边栏启用扩展管理器

安装扩展允许它们在服务器、内核和浏览器上执行任意代码。由于第三方扩展未经审查,可能会引入安全风险或包含恶意代码,因此需要明确启用此操作。

扩展管理器面板分为三个部分:一个搜索栏、已安装扩展的列表,以及一个“发现”部分,显示 NPM 注册表中的所有 JupyterLab 扩展。结果会根据注册表的排序规则列出(参见 docs.npmjs.com/searching-for-and-choosing-packages-to-download#package-search-rank-criteria/)。其中一个例外是由 Jupyter 组织发布的扩展。这些扩展在名称旁边会有一个小 Jupyter 图标,并且始终出现在搜索结果列表的顶部(图 6-25)。

图片

图 6-25:由 Jupyter 组织发布的扩展已清晰标记,并出现在搜索结果的顶部。

要查找可用的扩展,你可以向下滚动列表或使用扩展管理器的搜索框。要了解更多关于某个扩展的信息,点击其名称。这将会在新浏览器窗口中打开扩展的官方网站(通常是 GitHub)。

你可以使用管理器的 安装 按钮来安装扩展。对于源代码扩展,你需要 Node.js。要在你的 base 环境中 从默认频道 安装它,打开 Anaconda Prompt(Windows)或终端(macOS 或 Linux),并输入以下命令:

conda install nodejs

要从 conda-forge 渠道安装,输入以下命令:

conda install -c conda-forge nodejs

现在你已经准备好安装扩展了。

因为大多数扩展都是 源代码 扩展,当你点击管理器的 安装 按钮时,搜索栏下方会出现一个下拉菜单,指示扩展已经下载,但需要重建才能完成安装。你应该点击 重建,但如果由于某些原因忽略此操作,下次你刷新浏览器、切换工作区或启动 JupyterLab 时,系统会显示一个 构建 按钮。点击该按钮后,你将被要求“重新加载而不保存”或“保存并重新加载”。

如果你想同时管理多个扩展,你可以忽略重建通知,直到完成所有所需的更改。然后,点击 重建 按钮开始在后台进行重建。当重建完成后,将会弹出一个对话框,提示需要重新加载页面以加载最新的构建到浏览器中。此时,扩展会出现在管理器的已安装部分,在那里你将可以选择卸载或禁用它(图 6-26)。禁用扩展会阻止它被激活,但无需重建应用程序。

注意

避免安装你不信任的扩展,并留意任何试图伪装成受信任扩展的扩展。由 Jupyter 组织发布的扩展将会在扩展名称的右侧显示一个小 Jupyter 图标。

在安装过程中,JupyterLab 将检查软件包元数据,查找任何配套软件包,如笔记本服务器扩展或内核软件包。如果 JupyterLab 找到配套软件包的说明,它将弹出一个信息对话框,提醒你这些软件包的存在。是否考虑这些配套软件包,取决于你自己。

要了解更多有关扩展管理器的信息,请访问文档 jupyterlab.readthedocs.io/en/stable/user/extensions.html

Image

图 6-26:用户安装的扩展可以在扩展管理器中卸载或禁用

使用 CLI 安装和管理扩展

除了扩展管理器之外,还有其他安装扩展的方法。不过,安装源扩展仍然需要你安装 Node.js 并重建 JupyterLab,而且你需要注意相同的安全问题(请参见前一节的详细信息)。

在管理器中点击扩展名称将带你到该扩展的网站。在这里,你可能会找到使用 CLI 安装的说明。例如,要安装 jupyterlab-git 扩展,该扩展让你能够使用 Git 进行版本控制,打开 Anaconda Prompt(Windows)或终端(macOS 或 Linux),并输入以下命令:

conda install -c conda-forge jupyterlab-git

要卸载扩展,请使用以下命令:

conda remove jupyterlab-git

同样在 CLI 中,你可以使用 jupyter labextension 命令从 NPM 安装或卸载源扩展,列出所有已安装的扩展,或禁用某个扩展。

要安装扩展,请使用这种格式,其中 表示扩展的名称:

jupyter labextension install <extension-name>

要安装多个扩展,请输入以下命令:

jupyter labextension install <extension-name> <another-extension-name>

要安装特定版本的扩展,请使用以下命令:

jupyter labextension install <extension-name>@1.2.3

要卸载扩展,请使用以下命令:

jupyter labextension uninstall <extension-name> <another-extension-name>

如果你在多个阶段安装/卸载多个扩展,可能希望通过在安装/卸载步骤中包含--no-build标志来推迟重建 JupyterLab。当你准备好重建时,可以运行以下命令:

jupyter lab build

你可以使用以下命令列出扩展:

jupyter labextension list

注意

jupyter labextension 命令使用扩展的 JavaScript 包名称,这可能与用于分发扩展的 conda 包名称不同。

要禁用扩展而不重建 JupyterLab,请使用以下命令:

jupyter labextension disable <extension-name>

禁用扩展会保持代码加载,但会阻止插件运行。

你可以使用以下命令启用禁用的扩展:

jupyter labextension enable <extension-name>

安装的扩展默认是启用的,除非有明确的配置禁用了它们。

要获取 jupyter labextension 命令的帮助,请输入:

jupyter labextension --help

要了解更多关于此命令的信息,请访问文档 jupyterlab.readthedocs.io/en/stable/user/extensions.html

为 JupyterLab 安装 ipywidgets

在 第五章中,我们使用了 ipywidgets 扩展在经典的 Jupyter Notebook 中使用小部件。大多数情况下,安装 ipywidgets 会自动配置 JupyterLab 使用小部件,因为它依赖于 jupyterlab_widgets 包,该包配置 JupyterLab 以显示和使用小部件。

如果你使用的是模块化方法,即 JupyterLab 和 IPython 内核安装在不同的环境中,则安装 ipywidgets 需要两个步骤:

  1. 在包含 JupyterLab 的环境中安装 jupyterlab_widgets 包。

  2. 在每个将使用 ipywidgets 的内核环境中安装 ipywidgets。

例如,如果你在 base 环境中安装了 JupyterLab,并且在之前创建的 my_jlab_proj_env 环境中安装了内核,可以使用以下命令,替换为你自己的 my_jlab_proj_env 文件夹路径:

conda activate base
conda install -c conda-forge jupyterlab_widgets
conda activate C:\Users\hanna\my_jlab_proj\my_jlab_proj_env
conda install -c conda-forge ipywidgets

创建自定义扩展

JupyterLab 扩展是一个包含一个或多个 JupyterLab 插件的包。你可以编写自己的插件,并将它们打包成一个 JupyterLab 扩展。关于这方面的详细信息超出了本书的范围,但你可以在扩展开发者指南中找到所需的内容,网址为 jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html

共享

当我们谈论 JupyterLab 中的共享时,主要是指共享笔记本。因为我们已经在 第 122 页的“共享笔记本”部分讨论过这个话题,所以这里不再赘述。不过,作为补充,你可以在 jupyterlab.readthedocs.io/en/stable/user/jupyterhub.html 上找到更多关于在 JupyterHub 上使用 JupyterLab 的信息。如果你想进行实时协作,可以参阅 jupyterlab.readthedocs.io/en/stable/user/rtc.html

总结

JupyterLab 在 Jupyter Notebook 的基础上,通过提供一个类似 IDE 的环境来开发代码、探索数据集和进行实验。凭借其可扩展的环境,JupyterLab 使我们更接近真正的 文学化编程,即将逻辑的阐述与普通人类语言结合在一起。

尽管 JupyterLab 已经可以使用,但它仍在开发中,因此你需要查阅官方文档,以获取最新的功能、变化和已弃用的内容。除了核心程序的工作,第三方扩展的开发将继续进行。像 nbdev (nbdev.fast.ai/) 和调试器 (jupyterlab.readthedocs.io/en/stable/user/debugger.html) 等新工具正在将 JupyterLab 打造成一个完整的集成开发环境(IDE)。

2021 年底的一个发展是发布了跨平台的独立版JupyterLab App(* github.com/jupyterlab/jupyterlab-desktop/*)。有了这个应用程序,JupyterLab 不再“存在”于网页浏览器中,而是作为一个独立的桌面应用程序存在。为了方便使用,它捆绑了一个 Python 环境,并附带了多个常用的库,准备在科学计算和数据科学工作流中使用。这些库包括 pandas、NumPy、Matplotlib、SciPy 等。然而,目前的一个缺点是该应用程序仅提供pip安装,而不支持 conda 安装。这意味着,与网页版本相比,一些库的安装并不像那么方便。

这就是本书的第一部分内容。到第四章,对于 Python 新手,读者被指示完成第二部分,这部分是 Python 入门教程。如果你已经完成了这部分,或者不需要的话,可以直接进入第三部分,这一部分概述了通过 Anaconda 提供的重要科学计算和可视化包,包括如何根据需要选择最适合的工具的技巧。

第七章:整数、浮点数和字符串

image

在本章中,你将学习表达式和语句的区别,了解如何给变量赋值,并熟悉 Python 中最常见的数据类型:整数、浮点数和字符串。在这个过程中,你可能会惊讶于使用简单的数学运算就能完成很多编程任务。

在完成本章及后续章节的学习时,我建议你运行代码示例,而不仅仅是阅读它们。输入命令会帮助你记住它们,并减少你对编程的任何恐惧。我将使用 Spyder 中的控制台和文本编辑器来展示本教程中的示例。我建议你也这么做,这样你可以跟着一起操作。如果你需要复习这些工具,请参见第三章了解 Jupyter Qt 控制台,和第四章了解 Spyder IDE。

数学表达式

在计算机科学中,表达式是计算得出单一值的指令。最常见的表达式是数学表达式,比如 1 + 2,它的计算结果为 3。使用 Python,你可以将方程式嵌入到你的程序中(甚至可以将交互式控制台当作计算器使用)。为此,你需要熟悉数学运算符。

数学运算符

用于表示某种操作或过程的符号叫做运算符。这些运算符执行某些功能或以某种方式操作值。常见的运算符有加号(+)和减号(-),分别用于加法和减法。表 7-1 列出了 Python 中一些可用的数学运算符。大多数运算符你应该都很熟悉,剩下的一些我们将在接下来的部分详细讲解。

表 7-1: 数学运算符

运算符 描述 示例 结果
+ 加法 5 + 3 8
- 减法 5 - 3 2
* 乘法 5 * 3 15
/ 除法 5/3 1.6666666666666667
// 除法(向下取整或整数除法) 5 // 3 1
% 取模(余数) 5 % 3 2
** 幂运算 5**3 125

除法运算符(/)表示真正的除法,而向下取整除法(//)返回一个整数,忽略任何小数部分。注意,向下取整除法不会向上取整。如果结果是1.99999,你仍然会得到1作为答案。

如果你只想得到除法运算的分数或余数,可以使用取模运算符(%)。余数可能看起来像是一个奇怪的东西,但它其实非常有用。例如,你可以用它来识别偶数和奇数。在控制台输入以下代码:

In [1]: 4 % 2
Out[1]: 0

注意

执行代码的命令将取决于你使用的工具。对于 Jupyter Qt 控制台,按下回车键(如果在缩进代码内,按 SHIFT+ENTER)。

在前面的例子中,使用取余运算符将 4 除以 2 得到 0,这意味着操作结果没有余数,因此 4 是偶数。取余的其他用途包括让程序每隔 n 次执行某些操作,或将秒转换为小时、分钟和秒。

幂运算符,或指数运算符,也有一个不太直观的特性。你不仅可以将数字提高到某个指数,还可以通过在 ** 运算符后使用小数值来计算根。例如,要计算 9 的平方根,可以输入以下内容:

In [2]: 9**0.5
Out[2]: 3.0

要计算 27 的立方根,请输入:

In [3]: 27**(1/3)
Out[3]: 3.0

赋值运算符

将 Python 作为手持计算器使用有点像对蚂蚁进行空袭。为了让程序真正有用,你需要以可重用的方式存储表达式的输出。这就是赋值语句、赋值运算符和变量的作用。

表达式计算出单一值,而语句执行某些操作。例如,赋值语句会创建一个新的变量。变量只是指向存储在内存中的数据的引用。在赋值语句中,等号(=)是一个赋值运算符,它将一个值或表达式赋给一个变量(图 7-1)。一个简单的例子是 my_name = 'Lee'

Image

图 7-1:赋值语句的基础

在赋值语句中,等号左边的项是变量的名称。这充当访问内存中信息的标签。等号右边的项是变量的值。这些值不必是数字。文本数据、项目列表,甚至图像和音乐都可以作为变量存储。

现在你了解了赋值语句,让我们通过将结果赋给变量,来使数学表达式更加持久和有目的。因为这是一个非常常见的编程任务,Python 会通过提供特殊的增强赋值运算符来帮助你,接下来我们将讨论这些运算符。

增强赋值运算符

为了方便,你可以将数学运算符结合起来形成增强赋值运算符,这样你可以同时执行两个操作。以下是没有增强运算符的示例:

In [4]: x = 5

In [5]: x = x + 5

In [6]: x
Out[6]: 10

请注意,你可以将一个变量加到它自身上,并且在控制台中输入变量名将显示其值。在文本编辑器中,你需要使用 print(x) 来将值显示到屏幕上。

使用增强赋值运算符(+=),你可以将 5 加到 x 上,而不必重复写 x

In [7]: x += 5

In [8]: x

Out[8]: 15

要创建增强赋值运算符,只需在等号(=)前添加数学运算符(表 7-1)。例如,要将 x 乘以 2,可以输入以下内容:

In [9]: x *= 2

In [10]: x
Out[10]: 30

请注意,由于你将每个表达式的结果赋值给变量 x,每个表达式都可以在前一个的基础上构建。

优先级

Python 中的数学表达式使用熟悉的优先级规则(表 7-2)。被括号包围的表达式总是首先执行,同一优先级的操作从左到右计算。

表 7-2: 数学运算优先级

级别 运算符 描述
1(最高) () 括号
2 ** 幂运算
3 -n, +n 负数和正数参数
4 *, /, //, % 乘法、除法、整除、取余
5 +, - 加法和减法

这是优先级作用的一个示例。你可以在心里跟着做,看看是否和 Python 的结果一致:

In [11]: 10**2 + (6 - 2) / 2 * 3
Out[11]: 106.0

优先级影响你在表达式中如何使用空格。例如,下面的表达式虽然会执行,但你可能会觉得它比前一个版本更难读:

In [12]: 10 ** 2 + (6-2)/2*3
Out[12]: 106.0

你可以在 PEP8 中找到提高表达式可读性的指南(* pep8.org/ *)。虽然有一些规定的规则——比如从不使用多个空格、始终确保数学运算符两边的空格数量相同、赋值(=)和增强赋值运算符(如+=)两侧留一个空格——但你大部分时间可以自由发挥。如果视力不好,你可能会倾向于使用比推荐更多的空格。

math 模块

Python 标准库包括一个math模块,它提供了对底层 C 库函数的访问。函数就像迷你程序,执行某些任务。它们将这些程序的细节隐藏起来,让你能够写出更简洁的代码。

要使用一个函数,只需输入函数名后跟括号。你在括号中输入的值或变量将作为输入传递给函数。我们将在第十一章中更详细地讨论函数,包括如何编写你自己的自定义版本。

相关功能的组通常被集中到模块中。math模块让你高效地进行常见和有用的数学计算,包括处理阶乘、二次方程以及三角函数、指数函数和双曲函数。它还包括常数,如πe。可用功能的一个子集列在表 7-3 中。

要使用math模块,首先需要通过import语句将其导入。可以把这看作是从图书馆借书。因为有成千上万的可用模块,你不希望它们都默认加载。这就像把图书馆的所有书架上的书都一次性搬到你的桌子上一样。相反,你只拿取你需要的书籍。导入模块遵循这个原则,以便节省计算机的内存。

表 7-3: Python 数学模块函数子集

函数 描述
ceil(x) 返回大于或等于x的最小整数
fabs(x) 返回 x 的绝对值,作为浮点数
factorial(x) 返回 x 的阶乘值
floor(x) 返回小于或等于 x 的最大整数
frexp(x) 返回 x 的尾数和指数,作为一对 (m, e)
isnan(x) 如果 x 是 NaN(不是一个数字),则返回 True
exp(x) 返回 e**x
log(x[, b]) 返回 xb 为底的对数(默认以 e 为底)
log2(x) 返回 x 的以 2 为底的对数
log10(x) 返回 x 的以 10 为底的对数
pow(x, y) 返回 xy 次方
sqrt(x) 返回 x 的平方根
acos(x) 返回 x 的反余弦值
asin(x) 返回 x 的反正弦值
atan(x) 返回 x 的反正切值
atan2(y, x) 返回 y / x 的反正切值
cos(x) 返回 x 的余弦值
hypot(x, y) 返回欧几里得范数,sqrt(x**2 + y**2)
sin(x) 返回 x 的正弦值
tan(x) 返回 x 的正切值
degrees(x) x 从弧度转换为度数
radians(x) x 从度数转换为弧度

让我们使用 math 模块来计算 45 度的余弦值:

In [13]: import math

In [14]: x = math.radians(45)

In [15]: math.cos(x)
Out[15]: 0.7071067811865476

首先,导入 math 模块,将 45 转换为弧度(Python 中所有三角函数使用弧度),并将结果赋给变量 x。注意,你输入模块名称后跟一个点(.),然后是 radians() 函数,括号中是你想要转换的角度。以这种方式使用点号被称为 点符号法。它告诉 Python 使用 math 模块的 radians() 函数。你可以把它理解为一个表示所有权的撇号:“mathradians() 函数。”

最后,调用 cos() 函数并传入 x。你也可以将这个值赋给一个变量,如下所示:

In [16]: cos_x = math.cos(x)

In [17]: cos_x
Out[17]: 0.7071067811865476

接下来,让我们使用 math 来访问 π 并计算一个直径为 100 单位的圆的周长:

In [18]: 100 * math.pi
Out[18]: 314.1592653589793

math 模块处理基本数学运算很好,但对于更高级的功能,例如微积分,你可能需要使用外部库,如 SymPy,我们将在后续章节中详细讲解。与此同时,想要了解更多关于 math 的内容,查看完整的函数和常量列表,以及详细的文档,可以访问 docs.python.org/3/library/math.html

注意

回顾你最近学习的信息有助于保持记忆。花几分钟完成这个简短的测验。你可以在 附录 中找到答案和建议。

测试你的知识

1.  对错:语句是计算指令,会计算出一个单一的值。

2.  表达式 12%4 的结果是:

a.  3

b.  48

c.  0

d.  12.4

3.  优先级最高的数学运算符是:

a.  乘方(**)

b.  整除 (//)

c.  括号 (())

d.  负数与正数参数 (-n, +n)

  1. 编写一行代码,首先计算 42 的平方根,然后将结果提升到 4 次方。

错误信息

一旦你开始编写代码,你就会犯错误。一个问题是,计算机比人类更加字面化。你和我在处理上下文意义、语法甚至拼写时可以非常灵活,但在计算机中,看到的就是得到的(图 7-2)。

Image

图 7-2:计算机将一切都理解为字面意思。

你不能像处理人类语言的语法规则那样去弯曲 Python 的语法规则。当你在 Python 中尝试执行非法操作时,比如除以零,它会停止执行并显示一条错误信息,这个过程叫做抛出异常

让我们来看一个人类能处理但 Python 不能处理的例子:

In [16]: 25 / 'five'
Traceback (most recent call last):

File ″C:\Users\hanna\AppData\Local\Temp/ipykernel_8852/1797604750.py″, line 1, in <module>
25 / 'five'

TypeError: unsupported operand type(s) for /: 'int' and 'str'

Python 显示了一条错误信息,指出 TypeError,因为你试图将一个整数(int)除以一个字符串(str)。虽然你我可以很容易地猜出正确答案,但 Python 甚至不会尝试,因为你混合了数据类型(稍后会讲到这些)。对于 Python 来说,这就像将 25 除以“Steve”一样荒谬。

现在,让我们尝试除以零:

In [20]: x = 42 / 0
Traceback (most recent call last):

File ″C:\Users\hanna\AppData\Local\Temp/ipykernel_22688/3599633117.py″, line 1, in <module>
42 / 0

ZeroDivisionError: division by zero

这会引发名为 ZeroDivisionError 的错误,并再次提供一个记录,称为回溯,描述了解释器在你的代码中遇到问题的位置。在这个案例中,回溯包含了导致异常的赋值语句和遇到的错误类型。对于某些错误,它还会提供一个指针(^),指向该行中发生异常的位置。

注意

在许多情况下,实际上是回溯中引用的前一行导致了问题。所以请永远记得往上看!

了解解释器遇到的错误类型将帮助你在犯错时调试代码。表 7-4 列出了你可能遇到的一些常见错误类型(你可以在 docs.python.org/3/library/exceptions.html 上找到更多)。如果你现在不理解它们,也不用担心。到本书的最后,它们应该会变得更加清晰。

表 7-4: 常见 Python 错误类型

错误类型 抛出时…
SyntaxError 遇到语法错误。
IndexError 尝试访问一个无效索引的项。
ModuleNotFoundError 找不到模块或包。
KeyError 找不到字典键。
ImportError 加载模块或包时发生问题。
StopIteration next() 函数超出了迭代器的项目。
TypeError 操作或函数应用于不适当类型的数据。
ValueError 函数的参数类型不合适。
NameError 找不到对象(变量、函数等)。
RecursionError 超过了最大递归深度(长时间运行的循环被终止)。
ZeroDivisionError 除法操作中的分母为零。
MemoryError 操作耗尽了内存。
KeyboardInterrupt 用户在执行过程中按下中断键(例如 CTRL-C)。

错误没什么大不了的。回溯信息的最后一行包含错误类型和简短的解释(如 NameError: name 'load' is not defined)。如果你将这一行复制并粘贴到搜索引擎中,你会找到很多更易理解的友好解释,比回溯报告和官方文档中过于技术化的解释更容易懂。

接下来,我们将探讨处理某些异常的方法,以便在程序遇到异常时能够继续运行,而不是崩溃。如果提供的异常不足以处理特定情况,还可以为特定程序编写自定义异常。

数据类型

就像错误有类型一样,Python 中的每个值都会自动分配到一个特定的数据类型。这使得 Python 能够区分字母(如“abc”)和数字(如“123”)。

同样的原则也适用于人类。我们不会尝试将字母相乘(除非我们在做代数)。我们也不会用数字给孩子起名字(除非我们是埃隆·马斯克)。在没有意识思考的情况下,我们的大脑能够识别不同类型的数据,并在对这些数据进行分类后,知道如何使用它们。

在计算机科学中,数据类型是一种分类,规定了对象可以存储哪些值(换句话说,哪些输入是可接受的),以及它们如何使用(可以进行哪些操作,例如将文本转换为小写)。许多编程语言使用静态类型,要求你明确声明每个创建的变量的数据类型,而 Python 使用动态类型,变量可以是任何数据类型,甚至在执行过程中改变类型。这使得 Python 成为一种更友好的语言,尽管这也有代价。使用静态类型的语言更擅长捕捉错误,因为它们可以在程序运行之前检查数据是否被正确使用。

注意

Python 允许使用类型提示进行可选的静态类型检查。我们在这里不做详细介绍,但你可以在www.python.org/dev/peps/pep-0484/了解更多。

让我们首先来看一下 Python 中你将使用的一些内置数据类型(表 7-5)。由于数字和文本几乎出现在每个计算机程序中,我们在这里重点介绍三种数据类型:字符串整数浮点数(浮动类型);其他数据类型将在后续章节中介绍。这三种数据类型在表 7-5 中以粗体突出显示。

表 7-5: 一些常见的数据类型

类别 数据类型 示例
数值类型 整数 -1, 0, 1, 4000
数值类型 浮动类型 -1.5, 0.0, 0.33, 4000.001
数值类型 复数 a = 4 + 3j
文本类型 字符串 'a', "b", "Hello, world"
序列类型 元组 (2, 5, 'Pluto', 4.56)
序列类型 列表 [2, 5, 'Pluto', 4.56]
序列类型 范围 range(0, 10, 1)
集合类型 集合 {2, 5, 'Pluto', 4.56}
集合类型 冻集合 frozenset({2, 5, 'Pluto', 4.56})
映射类型 字典 {'key': 'value'}
布尔类型 布尔值 True, False

除了表 7-5 中列出的二进制类型,还包括 字节字节数组内存视图。有关所有这些内建类型的更多信息,请访问 docs.python.org/3/library/stdtypes.html

访问数据类型

你可以使用内建的 type() 函数来查询数据类型。将一个值或变量放入括号中,如以下代码所示:

In [21]: type(0.5)
Out[21]: float

In [22]: type(0)
Out[22]: int

你还可以使用 isinstance() 函数来检查一个变量是否是某种数据类型的实例。例如,检查整数 42 是否是整数或字符串,可以将 42 放入括号中,并指定你要检查的数据类型,如下所示:

In [23]: x = 42

In [24]: isinstance(x, int)
Out[24]: True

In [25]: isinstance(x, str)
Out[25]: False

就像人类大脑一样,Python 可以根据上下文识别数据类型。没有小数点的数字被视为整数。带有小数点的数字是浮动数,即使小数点后面没有数值(比如 5.)。字符串通过将字符括在引号中来识别(如“Hello”或‘123’)。

整数

整数 类型表示整数,如 0、42 和 5,280。整数的长度仅受系统最大可用内存的限制。

Python 通过小数点的缺失来识别整数:

In [26]: whole_number = 42

In [27]: type(whole_number)
Out[27]: int

在处理大数字时,你可以使用下划线 (_) 来分隔千位数,例如 15_000_000 代表 15000000。Python 不需要这个分隔符来理解这些数值,但它能让你更容易阅读。它减少了输入错误,避免了你数一堆零的麻烦:

In [28]: 30_000_000 * 2
Out[28]: 60000000

本章后续内容我们还会探讨如何使输出更易于阅读。

浮动数

浮动数浮点数 包含小数点。它们包括 0.0、0.42 和 3.14159。浮动数具有 15 到 17 位的精度。由于 CPU 在二进制数字系统中存储数字的方式,可能会出现小的舍入误差,这意味着浮动数不总是完全准确的。例如,注意以下加法结果会出现额外的 0.00000000000000004:

In [29]: 0.1 + 0.1 + 0.1
Out[29]: 0.30000000000000004

如果你需要更精确的精度来进行科学计算,可以使用内建的 decimal 模块 (docs.python.org/3/library/decimal.html)。关于浮点数精度的更多信息,请参见 docs.python.org/3/tutorial/floatingpoint.html

转换浮动数和整数

使用整数进行操作有时返回整数,有时返回浮点数。在控制台中尝试以下操作:

In [30]: x = 42 * 2

In [31]: x
Out[31]: 84

In [32]: type(x)
Out[32]: int

In [33]: y = 42 / 2

In [34]: y
Out[34]: 21.0

In [35]: type(y)
Out[35]: float

尽管大多数整数之间的操作总是返回整数,但除法可能不会(例如,42 / 5)。因为将整数除以整数可能会得到浮点数,所以 Python 会自动将商转换为浮点数,即使结果仍然是整数。

从一种数据类型转换到另一种数据类型的过程称为 类型转换。这可以是 隐式的,如前面的示例,或者是 显式的,你使用预定义的函数进行转换。显式类型转换通常用于用户输入,以确保输入值的类型适合后续操作。

在 Python 中,你可以通过几种方式将整数转换为浮点数。一个方法是将它们组合在同一个数学运算中。注意,将浮点数加到整数上会将该整数转换为浮点数:

In [36]: x = 5

In [37]: type(x)
Out[37]: int

In [38]: x += 0.0

In [39]: type(x)
Out[39]: float

你也可以使用显式类型转换与内置的 float() 函数:

In [40]: x = float(5)

如果 x 是整数,下面的操作也会生效:

In [41]: x = float(x)

要将浮点数转换为整数,使用内置的 int() 函数:

In [42]: y = 5.8

In [43]: y = int(y)

In [44]: y
Out[44]: 5

请注意,int() 仅仅是去掉小数部分,保留小数点左边的整数部分。如果你想处理任何小数余数,你需要使用四舍五入。

四舍五入

要将浮点数四舍五入到最接近的整数,而不仅仅是去掉小数部分,你需要使用内置的 round() 函数。在以下示例中,我们使用 round() 将浮点数 5.89 四舍五入到最接近的整数 6:

In [45]: y = 5.89

In [46]: y = round(5.89)

In [47]: y
Out[47]: 6 In [48]: type(y)
Out[48]: int

round() 函数默认四舍五入到没有小数位,并返回一个整数。要指定四舍五入的小数位数,可以在待四舍五入的值后面加上数字。在以下示例中,我们将 y 变量的值四舍五入到一位小数:

In [49]: y = 5.89

In [50]: y = round(y, 1)

In [51]: y
Out[51]: 5.9

因为你保留了小数点后的值,y 仍然是一个浮点数。

在交互式控制台中工作时,你也可以直接对数字进行四舍五入,而无需使用变量:

In [52]: round(5.678, 2)
Out[52]: 5.68

如果浮点值恰好在两个整数之间,四舍五入函数会将奇数向上舍入,偶数向下舍入,如下所示:

In [53]: round(5.5)
Out[53]: 6

In [54]: round(4.5)
Out[54]: 4

从之前的示例中可以看出,在处理数字时,你应该时刻注意数据类型。整数在代码处理中可以自动转换为浮点数,反之亦然。例如,进行任何使用浮点数的操作(如 5 *= 1.0),或是产生浮点数的操作(如 5 /= 3),都会得到浮点数。

测试你的知识

  1. 写一个表达式,抛出一个 SyntaxError

  2. 你会期望表达式 round(``'``Alice``'``) 出现什么错误?

a. 一个 TypeError

b. 一个 ValueError

c. 一个 NameError

d. 一个 SyntaxError

  1. π 四舍五入到五位小数。

  2. 使用 Python 来确定这个对象的类型:(1, 2, 3)

  3. 真假:有时在浮点数中出现的微小不准确性是 Python 特有的问题。

字符串

字符串,也叫做字符串文字,是我们所说的文本值。你可以通过引号来识别它们。编程中最著名的字符串值“Hello, World!”通常是你学会打印的第一个内容。

字符串应该用引号括起来,表示字符串的开始和结束。在控制台中,输入:

In [55]: a_string = "Hello, World!"

In [56]: print(a_string)
Hello, World!

In [57]: type(a_string)
Out[57]: str 

In [58]: type('1234')
Out[58]: str

In [59]: """Multiline strings can be encased in triple quotes \
    ...: so you don't have to type the marks over and over \
 ...: like a chump."""
Out[59]: "Multiline strings can be encased in triple quotes so you don't have
to type the marks over and over like a chump."

通常,你应该使用单引号括起来字符串,但如果你需要在字符串中包含一个单引号,比如作为省略号,你可以使用双引号,如In [55]行所示。在In [58]行中,注意如果数字被引号括起来,它们会被视为字符串。你不能直接将这些数字用于数学表达式,除非将其转换为数字类型,如整数或浮点数。

三引号(""")让你能够跨多行书写字符串。虽然计算机不在乎代码行的长度,但人类在乎。为了可读性,PEP 8 推荐最大行长度为 79 个字符。如果你想写一个非常长的字符串,比如用于代码中的文档说明,你可以在字符串的开始和结束处使用三引号,如 In [59] 行所示。

为了遵守行长规范,你可以使用行续字符(\)在三引号之间换行。但请注意,如果你使用的是单引号或双引号的字符串,你需要将其放置在引号外部,如下面所示:

In [60]: 'Hello, ' \
    ...: 'World!'
Out[60]: 'Hello, World!'

三引号还允许你在程序中添加简单的图形,例如显示井字游戏中棋盘位置的网格:

"""

0 | 1 | 2
---------
3 | 4 | 5
---------
6 | 7 | 8

"""

最后,你可以使用str()函数将其他数据类型转换为字符串。以下示例将整数转换为字符串:

In [61]: x = 42

In [62]: type(x)
Out[62]: int

In [63]: x = str(x)

In [64]: type(x)
Out[64]: str

注意

在幕后,字符串是 Unicode 字符序列,Unicode 是一种国际编码标准,其中每个字母、数字或符号都有一个唯一的数值。Unicode 确保世界各地的计算机都能看到 A 作为 A,☺ 作为一个笑脸。

转义序列

转义序列 是一种特殊字符,它让你将原本无法插入字符串中的文本插入进去。在前一节中,我们通过先用三引号括起字符串,成功地在其中包含了单引号的省略号。使用反斜杠(\)转义字符,放在引号内,我们可以仅使用单引号:

In [65]: print('I don\'t have a banana.')
I don't have a banana.

请注意,反斜杠在最终的字符串中不会出现。如果你想打印反斜杠字符,你需要用另一个反斜杠对其进行转义:

In [66]: print("I don't have an apple\\banana.")
I don't have an apple\banana.

表 7-6 列出了几个有用的转义序列及其结果。

表 7-6: 有用的 Python 转义序列

转义序列 结果
\``' 单引号('
\``″ 双引号("
\\ 反斜杠(\
\a ASCII 响铃(例如在 Windows 10 中使用 print(``'``\a``'``)
\n ASCII 换行符(换行)
\r ASCII 回车符
\t ASCII 制表符

要查看完整的转义序列列表,请访问文档:docs.python.org/3/reference/lexical_analysis.html

原始字符串

原始字符串不识别转义序列。当你需要处理大量反斜杠时,它们非常有用,比如 Windows 路径名。在普通字符串中,你必须用\\转义反斜杠,这可能会变得很麻烦:

In [67]: print('C:\\Users\\hanna\\anaconda3\\envs')
C:\Users\hanna\anaconda3\envs

使用原始字符串,你看到的就是你得到的。要使用原始字符串,只需在字符串前加一个r前缀,在第一个引号之前:

In [68]: print(r'C:\Users\hanna\anaconda3\envs')
C:\Users\hanna\anaconda3\envs
运算符重载

Python 可以根据运算符与数字或字符串的配合使用,来应用上下文。一个在不同数据类型上执行不同操作的运算符被称为运算符重载。这听起来可能很糟糕,但其实并非如此。要查看示例,请输入以下代码:

In [69]: 'Hello, ' + 'world!'
Out[69]: 'Hello, world!'

当与字符串一起使用时,+加法运算符变成了字符串连接运算符。还要注意,空格是合法字符,所以我在Hello的单引号前加了一个空格。或者,也可以将空格加在world前面,或者完全分开,如下所示:

In [70]: 'Hello,' + ' ' + 'world!'
Out[70]: 'Hello, world!'

同样,当一个字符串与整数相乘时,*乘法运算符变成了字符串复制运算符:

In [71]: 'Ha' * 7
Out[71]: 'HaHaHaHaHaHaHa'

这对于脚本中的绘图很有用,例如生成代码中的分隔线:

In [72]: '-' * 20
Out[72]: '--------------------'

当然,你不能轻松地在不同的数据类型之间混合使用它们。例如,你不能将数字和字符串相加,或者将两个单词相乘。

字符串格式化

在许多情况下,你可能想创建一个包含其他字符串的字符串。例如,你可能想在print()函数中引用一个变量。格式化字符串,也叫做f-strings,可以轻松实现这一点。你只需在字符串前加上f并将变量名放入花括号中,如下所示:

In [73]: solute = 'salt'

In [74]: solvent = 'water'

In [75]: print(f'{solute} dissolves in {solvent}')
salt dissolves in water

注意

如果你在控制台工作,可以省略print()函数,直接应用 f-string(例如:f'{solute} dissolves in {solvent}')。

在 f-string 中,花括号中的表达式会在运行时进行求值:

In [76]: print(f"The circumference of a 10-inch circle is {10 * 3.14159}")
The circumference of a 10-inch circle is 31.4159

你还可以使用 f-strings 来指定文本的对齐方式,从而创建表格输出。在下面的示例中,保留了 25 个空格,<将这些空格左对齐,^将文本居中对齐,>则将文本右对齐:

In [77]: print(f'{"output1" : <25}') 
    ...: print(f'{"output2" : ²⁵}') 
    ...: print(f'{"output3" : >25}') 
output1 
           output2 
                       output3

你可以使用 f-strings 来格式化数字值。要为一个长数字添加逗号,请使用以下格式:

In [78]: long_number = 93000000

In [79]: print(f'{long_number:,}')
93,000,000

要使用指数表示法,使用e修饰符:

In [80]: speed_of_light = 299792458

In [81]: print(f'{speed_of_light:e}')
2.997925e+08

要将数字格式化为特定的精度点,使用f修饰符。例如,要将欧拉数e打印为三位小数,输入:

In [82]: e = 2.718281828459045

In [83]: print(f'{e:.3f}')
2.718

要将一个数字转换为百分比,使用%修饰符。包含一个数字来指定保留的小数位数:

In [84]: num = 0.456

In [85]: print(f'{num:.2%}')
45.60%

如你所见,f-strings 使代码非常易读,只要你的变量名有意义。

测试你的知识

10.  如果x = '``30_000_000``',那么x的数据类型是什么?

  1. 以下哪项是运行代码 f'{3.14159:.2f}' 在 Jupyter Qt 控制台中的结果?

a. '3.14'

b. '314,159'

c. '3.141590e+00'

d. '314.15%'

  1. 在文本编辑器中绘制一只猫头鹰并将其打印到屏幕上。每行代码不应超过 79 个字符。

  2. 编写一个程序,将 1,824 秒转换为分钟和秒数,然后打印结果。

字符串切片

字符串中的每个字符都有一个独特的索引,用来定位它在字符串中的位置。可以把这个索引当作字符的地址。Python 从 0 开始计数,因此,字符串中第一个字符的索引是0,而不是1

在控制台中,输入以下内容以检索字符串 'PYTHON' 的第一个和最后一个字符:

In [86]: x = 'PYTHON'

In [87]: x[0]
Out[87]: 'P'

In [88]: x[5]
Out[88]: 'N'

使用索引时,输入变量名(如x)和你想要的索引,索引放在方括号([])中。请注意,尽管PYTHON有六个字符,最后一个索引是5,这也是因为 Python 从0开始计数。

如果你请求一个超出字符串末尾的索引,你将得到一个(非常常见的)index out of range 错误:

In [89]: x[6]
Traceback (most recent call last):

File "<ipython-input-89-04aa5bc9ecce>", line 1, in <module>
x[6]

IndexError: string index out of range

你还可以使用索引来切片字符串(以及许多其他数据类型)。切片让你把一个字符串切割成更小的部分。例如,你可能会提取前三个字符,最后两个字符,中间的一个字符,等等。

要切片一个字符串,输入包含你感兴趣字符的端点。例如,要获取PYTHON中的前三个字符,输入以下内容:

In [90]: x[0:3]
Out[90]: 'PYT'

请注意,你获取的是索引012上的字符,但不是索引3上的字符。切片时,Python 会获取(但不包括)结束索引的所有内容。

由于起始和结束索引使用得很频繁,Python 提供了一种简写技巧,你可以省略这些索引。重新运行上面的代码,省略0

In [91]: x[:3]
Out[91]: 'PYT'

要返回整个字符串,你只需要使用冒号:

In [92]: x[:]
Out[92]: 'PYTHON'

你还可以通过指定步长来更大步幅地切割字符串。默认的步长是1。如果你想从开始位置并每隔一个字符取一个字符,可以再加上一个冒号,后跟步长2

In [93]: x[::2]
Out[93]: 'PTO'

需要额外的冒号是因为我们正在使用语法x[start:end:step]。当没有提供值时,Python 默认采用起始和结束索引,方便使用。

你可以选择正向或反向切片字符串。要反向切片,使用索引。例如,如果你只想获取字符串的末尾部分,可以在切片时使用负索引。要分别获取最后一个字符和最后三个字符,输入:

In [94]: x[-1]
Out[94]: 'N'

In [95]: x[-3:]
Out[95]: 'HON'

请注意,反向的“第一个”索引是-1,而不是你可能预期的0

要打印反向的字符串,可以使用-1逐个字符倒退:

In [96]: x[::-1]
Out[96]: 'NOHTYP'
成员运算符

innot in运算符告诉你一个字符或子字符串是否存在于字符串中。例如:

In [97]: 'e' in 'scientist'
Out[97]: True

In [98]: 'engineer' not in 'I am a scientist'
Out[98]: True

这种功能在条件语句中很有用,我们稍后会更详细地讲解。例如,如果你想知道'Waldo'是否是变量x中字符串的一部分,可以输入以下内容:

In [99]: x = "Here's Waldo!"

In [100]: if 'Waldo' in x:
     ...:      print("I found Waldo!")
I found Waldo!
字符串方法

数据类型的一个优点是,它们带有一些方法(一种函数),可以帮助你操作这些数据类型。方法表示数据类型可以执行的操作。例如,虽然in运算符告诉你某个字符或子字符串是否存在,但它并不会告诉你该字符或子字符串出现了多少次。如果你想计算出现的次数,可以使用count()方法。

要计算小写字母“i”在字符串“I am a scientist”中出现的次数,可以输入该字符串(或表示该字符串的变量),后跟一个点(.)和count()方法,并在括号内提供你要查找的字符或子字符串:

In [101]: 'I am a scientist'.count('i')
Out[101]: 2

Python 提供了一个包含许多字符串方法的列表(参见 docs.python.org/3/library/stdtypes.html#string-methods/)。一些常用的方法列在表 7-7 中。你应将粗体文本替换为你特定的字符串或子字符串。斜体文本是可选的。例如,startend的索引选项默认分别为字符串的起始和结束索引,如果选择忽略它们。

表 7-7: 常用字符串方法

方法 描述
str.capitalize() 将第一个字符大写,其余字符小写
str.count(sub, start, end) 计算字符或子字符串出现的次数
str.endswith(suffix, start, end) 如果字符串以指定后缀结束,则返回 True
str.find(sub, start, end) 返回子字符串在切片中的最低索引
str.isalnum() 如果字符串中的所有字符都是字母数字字符,则返回 True
str.isalpha() 如果字符串中的所有字符都是字母,则返回 True
str.isdigit() 如果字符串中的所有字符都是数字,则返回 True
str.islower() 如果字符串中的所有字符都是小写字母,则返回 True
str.isupper() 如果字符串中的所有字符都是大写字母,则返回 True
str.lower() 将所有字符转换为小写
str.replace(old, new, count) 用新子字符串替换旧子字符串
str.split(sep=None, maxsplit=-1) 使用“sep”字符作为分隔符,返回单词列表
str.startswith(prefix, start, end) 如果字符串以指定前缀开始,则返回 True
str.strip(chars) 移除前后的字符;如果未指定字符,则移除空白字符
str.title() 将每个单词的第一个字符大写
str.upper() 将所有字符转换为大写

由于字符串是不可变的(无法更改的),这些方法返回的是字符串的副本,而不是修改原始对象。你可以在控制台中输入以下内容:

In [102]: x = 'string'

In [103]: print(x.upper())
STRING

In [104]: x
Out[104]: 'string'

在这个示例中,你将一个小写字符串('``string``')赋值给了变量x。然后,你对x调用了upper()字符串方法并打印出来。Python 能够看到并使用大写字符串,但当你稍后使用x变量时,它仍然保持原来的小写形式。

要使x始终引用大写字符串,你需要将其重新赋值为自身,如下所示:

In [105]: x = x.upper()

In [106]: x
Out[106]: 'STRING'

为了演示字符串不可变性,我们尝试通过索引将'STRING'中的I改为A

In [107]: x[3] = 'A'
Traceback (most recent call last):

File "<ipython-input-106-124534701dc6>", line 1, in <module>
x[3] = 'A'

TypeError: 'str' object does not support item assignment

这引发了一个TypeError,因为字符串数据类型是不可变的。

为了(某种程度上)绕过不可变性,使用replace()方法,这需要创建一个新变量:

In [108]: old_string = "I'm the old string."

In [109]: new_string = old_string.replace('old', 'new')

In [110]: new_string
Out[110]: "I'm the new string."

split()方法将一个字符串拆分并返回一个列表数据类型(在第九章中介绍)。例如:

In [111]: caesar_said = 'Tee-hee, Brutus.'

In [112]: words = caesar_said.split()

In [113]: print(words)
Out[113]: ['Tee-hee,', 'Brutus.']

如果你仔细查看结果,你会发现标点符号被和单词一起归类。

默认情况下,split()将空格作为分隔符来拆分字符串。你还可以指定分隔符,例如连字符或如下例中的逗号以及后续的空格:

In [114]: words = caesar_said.split(sep=', ')

In [115]: words
Out[115]: ['Tee-hee', 'Brutus.']

注意,与Out[113]行相比,逗号不再附加在Tee-hee上,但句点仍然附加在Brutus项上。

要去除标点符号,导入string模块,它包含一个punctuation字符串,并使用内置的translate()函数将其去除:

In [116]: from string import punctuation

In [117]: print(punctuation)
!"#$%&'()*+,-./:;<=>?@[\]^_'{|}~

In [118]: no_punc = caesar_said.translate(str.maketrans('', '', punctuation))

In [119]: no_punc
Out[119]: 'Teehee Brutus'

maketrans()方法接受三个参数;前两个是空字符串(''),第三个是要删除的标点符号列表。这告诉函数将所有标点符号替换为None

现在,你可以按空格分割字符串,并仅获得单词列表:

In [120]: no_punc.split()
Out[120]: ['Teehee', 'Brutus']

注意,Out[120]行中的逗号是列表的一部分,用于分隔列表中的项,如TeeheeBrutus。它不算作字符串的一部分。还要注意,Teehee中缺少了连字符。这是因为punctuation字符串中包含了连字符。

在 Python 中有很多方法可以去除字符串中的不需要的字符。如果处理大块文本,建议使用正则表达式regex)或自然语言处理库。这些工具专为处理文本而设计,我们将在第十五章中探讨其中的一些。

测试你的知识

  1. 运行代码'``latchstring``'``[2:8]的结果是哪个?

a. '``atchstr``'

b. '``tchstr``'

c. '``gnirts``'

d. '``atchst``'

  1. 要获取字符串值的子集,应该使用:

a. 负索引

b. 字符串迭代

c. 增强运算符

d. 字符串切片

  1. 运行代码'``latchstring``'``[12]的结果是:

a. 一个SyntaxError

b. 一个StopIteration错误

c. 一个IndexError

d. 一个ValueError

  1. 使用之前的caesar_said示例,使用translate()函数删除所有标点符号,除了连字符。

  2. 将字符串 '``impractical python projects``' 转换为“标题”格式。

总结

在这一章,你学到了表达式是评估为单一值的指令,就像数学公式一样。语句表示需要执行的某个动作,但不评估为值。运算符是用于表示某种动作或过程的符号,执行函数或以某种方式操作值。

你还学到了变量是存储在内存中的数据的标签。变量有名称。在 Python 中,每个值都会自动分配一个数据类型,你了解了三种最基本的数据类型:整数浮点数字符串

在下一章,你将学习更多关于变量的内容,变量是让你连接和操作数据的对象。

第八章:变量

image

变量是初学编程者需要理解的最重要概念之一,因此本章详细探讨了这些特性。从技术角度讲,变量是一个用来存储值的保留内存位置。它是指向内存中对象的引用或指针,但它本身不是对象。变量让你能够访问和操作每个对象的相关元数据(属性)和功能(方法)。

在上一章,你学习了如何赋值给变量。在这一章,你将进一步了解赋值语句,学习如何清晰地命名变量,使用内建函数获取用户输入,并练习比较一个变量和另一个变量。

变量具有标识

Python 将数据和操作这些数据的函数封装到被称为对象的命名实体中。作为语言的基本构建块,Python 中的一切都是对象,每个对象都有一个标识(内存地址)、类型。数字 42 是一个对象,句子“Hello, world!”也是一个对象。对象的标识和类型永远不会改变,但它的值有时是可以改变的。

变量可以被看作是对象的标签。就像你可以有多个名字和昵称(图 8-1),Python 中的一个对象也可以通过多个变量进行引用。

Image

图 8-1:我们可以有多个名字;Python 中的对象也是如此。

当你使用赋值语句,比如 x = 5,变量 x 会被初始化为引用等号右侧的对象。它还会被赋予一个作为标识的整数。这个数字对于所有现有对象都是唯一的。你可以通过 Python 内建的 id() 函数查看这个数字。注意,你在电脑上看到的 ID 号码可能与你看到的不同。

In [1]: x = 5

In [2]: id(x)
Out[2]: 140718638636928

你可以通过赋予一个新值来覆盖一个变量:

In [3]: x = 15

In [4]: id(x)
Out[4]: 140718638637248

重新赋值后的 x 现在有了新的标识。在第一个例子中,变量 x 是对一个值为 5 的整数对象的引用。当你用新值 15 覆盖 x 时,旧对象继续存在,但如果没有其他变量引用它,它的引用计数将归零。此时,它将进入垃圾回收过程,Python 会定期回收不再使用的内存块。在一些其他语言中,你必须手动指定并释放内存分配。而 Python 能够自动管理内存并“清理自己的垃圾”,这使得它成为一个非常友好的语言!

因为变量只是引用,所以多个变量可以指向同一个对象:

In [5]: x = 42

In [6]: id(x)
Out[6]: 140718638638112

In [7]: y = x

In [8]: y
Out[8]: 42

In [9]: id(y)
Out[9]: 140718638638112

通过在 In [7] 行将 x 赋值给 y,两个变量现在都引用同一个对象,这从它们具有相同标识可以看出。这种内存高效的行为叫做别名

如果你覆盖了x,它的身份会改变,但y仍然指向“旧”对象:

In [10]: x = 50

In [11]: id(x)
Out[11]: 140718638638368

In [12]: y
Out[12]: 42

In [13]: id(y)
Out[13]: 140718638638112

结果是,旧对象的引用计数大于一,因此它不会在垃圾回收时被删除,并且会保留供你在表达式、函数等中使用。

赋值变量

给变量赋值在 Python 中被称为绑定。除了直接使用“x = y”方法赋值外,你还可以使用表达式、操作符重载、函数等进行赋值。基本上,既可以赋值给变量值,也可以赋值给返回值的东西。

使用表达式

表达式的结果可以赋值给一个变量:

In [14]: x = 6 * 7

In [15]: x
Out[15]: 42

操作符重载

同样,你可以在赋值变量时使用操作符重载。正如前一章所讨论的,操作符重载是指操作符能够以不同的方式与不同的数据类型一起工作。经典的例子是使用+号既用于加法运算用于字符串连接。

操作符重载直接作用于字符串或其他变量。这里,我们与字符串一起使用它:

In [16]: name = 'Hari ' + 'Seldon'

In [17]: print(name)
Hari Seldon

这里是使用变量的示例:

In [18]: first_name = 'Hari'

In [19]: surname = 'Seldon'

In [20]: full_name = first_name + ' ' + surname

In [21]: print(full_name)
Hari Seldon

In [20]这一行,注意我在名称之间添加了空格。没有它,输出将会是HariSeldon

这是另一个操作符重载的例子:

In [22]: repeat_name = (full_name + ' ') * 5

In [23]: print(repeat_name)
Hari Seldon Hari Seldon Hari Seldon Hari Seldon Hari Seldon

注意你可以使用操作符的优先级来控制顺序。要查看效果,可以再次运行In [22]去掉括号并打印结果。

使用函数

尽管我们尚未讲解它们,你可以在赋值语句中使用函数。在这里,我们将使用内建的count()字符串方法与赋值语句结合:

In [24]: number_of_y_in_python = 'Python'.count('y')

In [25]: number_of_y_in_python
Out[25]: 1

在这种情况下,count()方法返回了值1,然后将其存储在变量中。

链式赋值与内存驻留

你可以通过链式赋值同时将相同的值赋给多个变量:

In [26]: answer_to_life = answer_to_the_universe = answer_to_everything = 42

有趣的是,Python 并不会为这些变量创建新的对象;它们都共享相同的身份:

In [27]: id(answer_to_life)
Out[27]: 140718641390624

In [28]: id(answer_to_the_universe)
Out[28]: 140718641390624

In [29]: id(answer_to_everything)
Out[29]: 140718641390624

为了提高处理速度,Python 在启动时会创建一个小型的内存地址缓存。它会为一些小整数值(-5256)使用这些缓存中的地址。将引用用于等价对象副本的编程实践被称为内存驻留。不在缓存中的较大值将会得到新的地址。例如:

In [30]: big_var1 = 5**9

In [31]: big_var2 = 5**9

In [32]: id(big_var1)
Out[32]: 2642973757040

In [33]: id(big_var2)
Out[33]: 2642973756016

一些字符串也会被 Python 自动驻留,以优化性能。当 Python 代码被编译时,诸如变量名、函数名和类名这样的标识符会被驻留。其他字符串也可能被驻留。Python 会基于代码逐行地自动决定是否驻留。

字符串驻留的一个优点是,当你比较两个变量时,Python 可以比较内存地址。这比逐个字符比较字符串要快。

通常,Python 默认的字符串驻留机制已经足够,但你不应依赖它。如果你需要确保字符串被驻留,可以导入系统模块(import sys),并使用 sys.intern() 方法,将字符串放在括号内(请参阅 docs.python.org/3/library/sys.html)。

使用 f-Strings

你可以在变量中使用 f-strings(请参阅第 192 页的“字符串格式化”部分)。只需在赋值前加上 f,然后是一个单引号或双引号。将你想要使用的变量放入大括号中,然后添加结束引号。以下是一个示例:

In [34]: first_component = 'hydrogen'

In [35]: second_component = 'sulfide'

In [36]: compound = f'{first_component} {second_component}'

In [37]: print(compound)
hydrogen sulfide

你甚至可以在赋值语句中格式化字符串。例如:

In [38]: compound = f'{first_component.title()} {second_component.title()}'

In [39]: print(compound)
Hydrogen Sulfide

在这种情况下,我们在大括号中的每个变量上调用了 title() 字符串方法。此方法将单词的第一个字母大写,并将剩余字母转换为小写。

命名变量

程序被读取的频率远高于编写的频率。你的代码应该尽可能易于阅读,不仅是为了其他用户,也为了你自己。很多时候,我们会回到几个月前编写的程序,根本不记得它是如何工作的。

有句常见的话说“代码应该是自文档化的。”这意味着读者应该能够理解你的代码,而不依赖解释性注释。为了让代码“自文档化”,你需要特别注意变量命名。这包括确保你的名字合法,并尽可能地使它们逻辑清晰、简洁。

命名变量时有三条主要规则:

  • 变量只能包含字母、数字或下划线 (_)

  • 第一个字符不能是数字

  • 变量名不能是保留关键字

保留关键字

Python 为其自身保留了一组关键字(表 8-1)。你不能将这些作为变量名、函数名或其他标识符。

表 8-1: Python 的保留关键字

关键字 描述
and 逻辑运算符
as 用于给导入的模块或工具起别名
assert 用于调试
async 用于定义异步函数
await 指定在异步函数中将控制权交回事件循环的点
break 跳出循环
class 用于定义面向对象编程中的类
continue 继续到下一次循环迭代
def 定义一个函数
del 删除一个对象
elif Else-if 条件语句
else 条件语句
except 处理异常的指令
False 布尔值
finally 用于一个代码块,无论是否有异常都会执行
for 创建一个 for 循环
from 导入模块的特定部分
global 声明一个全局变量
if 条件语句
import 加载模块
in 检查某个值是否存在
is 测试两个变量是否相等
lambda 动态创建匿名函数
None 空值
nonlocal 声明一个非本地变量
not 逻辑运算符
or 逻辑运算符
pass 什么都不做的语句
raise 抛出一个异常
return 退出函数并返回一个或多个值
True 布尔值
try 创建 try/except 语句
while 创建一个 while 循环
with 简化异常处理;加载文件后自动关闭
yield 挂起一个生成器函数并返回一个值

你不需要记住所有的关键字;如果你尝试将其中一个作为变量名,Python 会抛出 SyntaxError 错误。如果你试图将值 5 赋给 pass 关键字,结果会发生如下情况:

In [40]: pass = 5
File "<ipython-input-40-85539e45a032>", line 1
pass = 5
^
SyntaxError: invalid syntax

你还可以通过 Python 查看关键字列表。只需运行 import keyword 然后执行 keyword.kwlist

除了关键字外,你还应该避免使用 Python 内置函数的名称作为变量名,比如 print()id()。然而,这并不会阻止你这么做。例如:

In [41]: print = 5

In [42]: print
Out[42]: 5

你会后悔这么做,因为 print 现在指向整数 5。如果你尝试使用 print() 函数,Python 会抛出错误:

In [43]: print("Hello, World!")
Traceback (most recent call last):

File "<ipython-input-43-2223c92d0779>", line 1, in <module>
print(print)

TypeError: 'int' object is not callable

为了解决这个问题,你需要使用 del(print) 删除 print 变量。这样会恢复 print() 函数并使其重新生效。

许多这些函数名称是你可能会想使用的,比如 minmaxsortedlistsetslicesum。我们将在第十一章中查看这些内置函数及其用途。与此同时,你只需要在命名变量时保持警觉。Spyder 中的控制台和文本编辑器会用独特的颜色高亮这些特殊名称。我无法在黑白书籍中展示这一点,但你可以通过在控制台中输入以下代码来亲自查看:

In [44]: spam = 42

In [45]: list

spam 变量应该与 list 变量不同颜色,因为 list 是一个内置函数的名称。如果在你的控制台中,list 是紫色的,避免为变量使用紫色名称。

注意

如果你坚持使用保留关键字或内置函数名称,你可以通过在变量名后加下划线来避免冲突,例如 sum_、max_ 或 class_。更好的做法是,在下划线后添加描述符,例如 max_pressure。大家都能受益!

变量区分大小写

Python 是一种区分大小写的编程语言。你不仅需要正确拼写变量名以访问它们,还必须保持大写和小写字母的正确排列。例如:

In [46]: declination = 80

In [47]: print(declination)
80

In [48]: print(Declination)
Traceback (most recent call last):

File "<ipython-input-48-d1839757958b>", line 1, in <module>
print(Declination)

NameError: name 'Declination' is not defined

Python 不识别 Declination 变量,因为第一个字母大写,结果抛出了 NameError 错误。

命名变量的最佳实践

以下是一些确保你的变量名符合 Python 风格的建议。你还可以在 Python 的 PEP8 风格指南中找到关于命名约定的章节,地址是 pep8.org/#naming-conventions/

你应该使用下划线 (_) 来分隔变量名中的单词。例如:

In [49]: the_answer_to_life_the_universe_and_everything = 42

在大多数情况下,你应该使用小写字母,并将大写字母保留给特殊对象,例如常量。常量是指在程序执行过程中不应该改变的值,你可以通过使用全大写来告知其他人某个变量表示的是常量。例如,赋值光速常量时,你可以使用如下命名:

In [50]: SPEED_OF_LIGHT = 299_792_458

Python 中的常量有一个上下文意义,但这一点并不会被 Python 解释器强制执行。使用全大写字母来命名常量只是为了提醒其他程序员你的意图。否则,常量仍然可以像其他变量一样被覆盖。

变量名应该是合乎逻辑的并且具描述性,但尽量使用尽可能少的字符来实现这一目标。长变量名不仅难以输入,还可能导致代码行换行,从而使代码变得难以阅读。

这是一个过于清晰的命名示例:

In [51]: distance_from_earth_to_the_sun_in_kilometers = 149_597_870

你可以通过几种方式缩短这个名字,例如:

In [52]: earth_sun_distance_km = 149_597_870

或者:

In [53]: earth_to_sun_km = 149_597_870

你也可以在不可执行的注释中包含重要信息,如单位。这些注释有助于你保持变量名的控制。我们将在第十四章讨论注释,但这里有一个内联注释的例子:

In [54]: SPEED_OF_LIGHT = 299_792_458  # Meters per second in a vacuum.

Python 会忽略 # 符号之后的所有文本,但阅读你代码的程序员可以从注释中获取比变量名所传达的信息更多的内容。

命名变量是一个优化练习。你会惊讶于自己能够多频繁地回到程序中并改进变量名。然而,你应该避免过度优化,因为很容易走向极端。如果你习惯使用英制单位,mps 可能对你来说意味着“每秒英里数”,但对大多数人来说,它意味着“每秒米数”。

同样,避免使用以数字后缀结尾的变量名,如 step1step2。这些名字没有意义,而且如果你增加或删除一个步骤,你需要重构整个程序。最好使用描述性更强的名字,像是 denoised_imagekalman_filtered。此外,永远不要在变量名中使用 final。这会惹怒神灵,你肯定在final之后还需要一个新的变量。

你还应该避免使用字符“l”(小写字母 L)、“O”(大写字母 O)或“I”(大写字母 I)作为单字符变量名。这些字符容易与数字 1 和 0 混淆。事实上,你应该完全避免使用单字符变量名,除非该字母是常见且广为人知的,例如使用 xy 作为笛卡尔坐标值。在进行简单的教程练习时,像我们这里使用的那样使用简短的名字也是可以接受的。

管理动态类型问题

在上一章,我们讨论了 Python 是一种动态类型语言,这意味着 Python 可以根据上下文分配数据类型,并且变量没有固定类型。这可能导致代码复杂且难以调试,因为一个名为 x 的变量在程序中的不同位置可能代表整数、字符串甚至函数。

管理这个问题的一种方法是在变量更改数据类型时更改变量名。对比以下代码:

In [55]: x = '42'

In [56]: x = int(x)

In [57]: x = float(x)
In [58]: type(x)

Out[58]: float

使用以下代码:

In [59]: x_string = '42'

In [60]: x_integer = int(x_string)

In [61]: x_float = float(x_integer)

In [62]: type(x_float)
Out[62]: float

在这两个例子中,x 变量最开始是字符串类型,最终变成了浮点数。然而,第二个例子中的小心且周到的命名实践帮助你跟踪程序的运行,即使程序有很多分支和循环,赋值语句之间相隔很远。

注意

通过 Spyder 可以使用的 Linters,比如 Pylint (pylint.pycqa.org/),会在你将变量重新赋值为不同类型时提醒你。

没有必要重新使用变量名,因为每次赋值都会创建一个新对象。你不需要像前面的例子那样明确包含数据类型在变量名中。重要的是,当你更改数据类型时,应该更改变量名。

处理无关紧要的变量

作为占位符的变量通常使用单个小写字母命名,通常是“i”。这里是一个使用 for 循环的例子(我们在第十章中讨论过):

In [63]: for i in 'Python':
    ...:      print(i)
P
y
t
h
o
n

记得在执行 print(i) 代码后按 SHIFT-ENTER 以在 Qt 控制台中执行代码。

虽然从技术上讲,这个策略没有错,但检查你代码是否符合 PEP8 标准的 linters(比如我们在第四章中讨论过)会把 i 标记为“未使用的变量”。虽然你可以忽略这个,但它会让人感到烦躁。

为了避免违反编码标准,你可以使用下划线作为占位符变量名,例如以下示例:

In [64]: for _ in 'Python':
    ...:      print(_)
P
y
t
h
o
n

这将让 linters 安静并保持愉快。

测试你的知识

  1. 哪些变量名是有效的?

a.  _steve

b.  br549

c.  light-speed

d.  O579

  1. 推荐使用哪种命名风格来命名常量?

a.  GravConstant

b.  GRAV_CONSTANT

c.  GRAV_constant

d.  grav_constant

  1. 什么时候应该使用单个下划线作为变量名?

a.  当你想使用保留关键字时

b.  当你想保持变量的私密性时

c.  当你需要一个占位符用于迭代时

d.  当你想不出一个好名字时

  1. 当你改变一个变量的数据类型时,你应该 ___________ 。

  2. 创建一个新变量并删除它。

获取用户输入

到目前为止,我们一直在将值赋给变量。在很多情况下,你可能希望直接从用户那里获取输入;例如,用于单位转换程序。由于这种做法非常常见,Python 提供了内建的

input() 函数。它的工作原理如下:

In [65]: first_name = input('Enter your first name: ')

Enter your first name: Robert

In [66]: first_name
Out[66]: 'Robert'

input() 函数接收一个问题,称为 提示,并将其呈现给用户。这个提示应该尽可能清晰,以便用户知道该输入什么(以及什么格式)。然后,函数会暂停程序,直到用户输入一个值。由于函数期望输入的是字符串,所以你不需要为输入加上引号。

input() 函数返回一个 字符串,这意味着数字可能需要根据程序对输入的处理方式转换为整数或浮动数。为了进行转换,可以在赋值语句中调用 int()float() 函数。例如,为了确保 age 变量是一个整数,可以输入以下内容:

In [67]: age = int(input('Enter your age in years: '))

Enter your age in years: 42

In [68]: type(age)
Out[68]: int

你可能还希望输入保持一致性。由于 Python 区分大小写,通常会将输入转换为小写字母,以避免后续出现问题。在控制台中输入以下内容:

In [69]: name = input('Enter your full name: ').lower()

Enter your full name: Chesterfield Walkingstick

In [70]: print(name)
chesterfield walkingstick

在这个例子中,我们调用了 lower() 字符串方法来自动将输入转换为小写。

注意

当你的程序与实际用户互动时,如果有什么可能出错,它就会出错。没有任何东西能阻止用户将年龄输入为“forty-two”而不是“42”。幸运的是,Python 提供了像 while 循环、try 语句和条件语句等功能,允许你检查输入错误,并可以直接修复问题或要求用户重新输入符合期望格式的值。我们将在后续章节中探讨这些内容。

input() 函数是一种比较原始的获取用户输入的方式。在本书的后面,我们将探讨一些更复杂的方法,例如使用带菜单、单选按钮、文本框等的图形用户界面(GUI)。

使用比较运算符

Python 提供了 比较 运算符,也叫做 关系 运算符(参见 表 8-2),它允许你比较变量并确定它们之间的关系。每个运算符都会返回 TrueFalse

表 8-2: Python 关系运算符

运算符 描述 示例
== 如果值相等,则条件为 True (a == a) 为 True
!= 如果值不相等,则条件为 True (a != b) 为 True
<> 如果值不相等,则条件为 True (a <> b) 为 True
> 如果左侧大于右侧,则为 True (2 > 6) 为 False
< 如果左侧小于右侧,则为 True (2 < 6) 为 True
>= 如果左侧大于或等于右侧,则为 True (2 >= 6) 为 False
<= 如果左侧小于或等于右侧,则为 True (2 <= 6) 为 True
is 对象身份 (a is a) 为 True
is not 否定对象身份 (a is not b) 为 True

运算符从左到右进行评估。例如,要检查 10 是否大于 2:

In [71]: 10 > 2
Out[71]: True

比较运算符评估为 布尔 数据类型,具有两个值:TrueFalse。这两个值总是大写的,与字符串不同,它们不需要引号。你可以像检查任何其他值一样检查它们的类型:

In [72]: type(False)
Out[72]: bool

因为计算机以二进制方式工作,True 代表 1(或 1.0),而 False 代表 0(或 0.0)。你可以在控制台中测试这一点,如下所示:

In [73]: a = True
In [74]: int(a)
Out[74]: 1

In [75]: b = False

In [76]: float(b)
Out[76]: 0.0

正如你从这个例子中看到的,你可以将布尔值存储在变量中,使用它们进行表达式运算,并将它们转换为整数和浮点数而不会引发异常。

前两个运算符都用于评估相等性,适用于任何数据类型:

In [77]: 42 == 42
Out[77]: True

In [78]: 'Steve' != 'Steve'
Out[78]: False

其他运算符,如 ><=,仅与浮点数值和整数一起使用。为了方便,你可以将这些运算符链式连接在一行中,例如 2 < x < 5,这表示 x 大于 2 且小于 5

你可以使用这些关系来控制程序的行为。这里有一个使用条件语句的示例,我们将在第十章中研究。此示例比较了三种样本的 pH 值,以确定哪个是最酸的:

In [79]: sample1_pH = 1.6

In [80]: sample2_pH = 6.0

In [81]: sample3_pH = 7.8

In [82]: if sample1_pH <= sample2_pH:
   ...:       print('Sample 1 is more acidic.')
   ...:  else:
   ...:       print('Sample 2 is more acidic.')
Sample 1 is more acidic.

在前面的示例中,关系运算符决定了打印哪个语句。你还可以将多个变量的比较链式连接在一起,如下所示:

In [83]: sample1_pH < sample2_pH < sample3_pH
Out[83]: True

isis not 运算符检查两个对象是否具有相同的身份(换句话说,是否指向内存中的同一个对象)。这与等于运算符(==)和不等于运算符(!=)不同,后者检查的是相等性。我们来看一个例子:

In [84]: x = 1_000_000

In [85]: y = 1_000_000

In [86]: x == y
Out[86]: True

In [87]: x is y
Out[87]: False

因为我们为 xy 分配了一个大数字,它们的值相等,但不是同一个对象。然而,如果我们使用较小的值,它们将是同一个对象,这是由于 Python 使用了启动内存缓存,正如在《链式赋值与驻留机制》一节中第 205 页讨论的那样。例如:

In [88]: a = 256

In [89]: b = 256

In [90]: a is b
Out[90]: True

测试你的知识

6.  如果 x = 257y = 257x is y 代码的结果是什么?

a.  True

b.  False

7.  编写一个代码片段,提示用户输入他们的名字,然后打印出反向的名字。

8.  编写一个赋值语句,生成 NameError

9.  编写一个赋值语句,生成 TypeError

10.  在控制台中,'hydrogen sulfide'.title() 的输出是什么?

a.  'Hydrogen Sulfide'

b.  'HYDROGEN SULFIDE'

c.  AttributeError: 'str' 对象没有 'title' 属性

d.  NameError: name 'title' is not defined

总结

在这一章中,你更深入地了解了变量的工作原理以及如何使用它们。你学习了更多关于变量赋值的知识,以及一些命名规则和建议,如何比较变量并获取用户输入。

因为变量只是内存中对象的标签,它们有时会表现得出乎意料,尤其是在处理可变对象时。在下一章中,我们将探讨容器数据类型,如列表、集合和字典,这一点会变得更加明显。

第九章:容器数据类型

image

根据梅里亚姆-韦伯斯特词典,数据是复数形式。如果你在处理数据,那么你实际上是在处理集合,例如班级中的学生姓名或星系中恒星的光度。你需要某个地方来存储所有这些集合。这就是像元组、列表、集合和字典等容器数据类型派上用场的地方。它们每种都有特定的用途和功能。它们将帮助你将老鼠、基因、土壤样本和温度测量等内容保持有序和受控。

在本章中,我们将探讨表 9-1 中列出的内建数据结构,并介绍它们的一些主要特性。记住,可变性指的是对象创建后是否可以被修改(变异)。不可变对象必须被复制到新对象中,才能改变其值、追加或移除内容。

表 9-1: 容器数据类型

类别 数据类型 可变性 特点 示例
序列类型 元组 不可变 快速、高效且不可更改 (2, 5, '``Pluto``'``, 4.56)
序列类型 列表 可变 灵活,拥有许多内建函数 [2, 5, '``Pluto``'``, 4.56]
集合类型 集合 可变 无重复元素,快速查找 {2, 5, '``Pluto``'``, 4.56}
集合类型 冻结集合 不可变 无重复元素,快速查找 frozenset({2, 5, '``Pluto``'``, 4.56})
映射类型 字典 可变 将唯一的键映射到值 {``'``key``'``: '``value``'``}

让我们从最简单的元组开始。

元组

元组(发音为TOO-pul)是一个固定长度、可迭代、不可变、有序的值序列。这些值通常被称为元素。以下是一个元组的例子,其中每个名字代表一个项:

('K. L. Putney', 'M. B. Clark', 'S. B. Vaughan')

注意

可迭代对象是可以循环遍历的项集合,例如字符串、元组、列表和集合。序列是一个按位置排序的项集合,作为单一存储单元。

元组与字符串非常相似,但字符串只能包含字符,而元组是异质的,可以包含任何类型的值,包括不同类型的混合体。你甚至可以创建元组的元组。

元组的使用频率低于列表数据类型,而且对它们有效的方法较少。但正如古话所说,“马有马路,车有车道。”在某些情况下,元组比其他容器类型更为适用。

例如,由于元组是不可变的,它们非常适合存储像密码这样的对象。当你使用元组时,其他程序员会理解你不希望这些值发生变化。由于元组的不可变性,它们比列表更加节省内存,元组的操作也较小,因此在处理大量元素时,元组的速度稍微更快。

创建元组

元组由一系列用逗号分隔的值组成,这些值被括号包围。但 Python 很智能,就像它能根据上下文识别不同的数据类型(如浮动数和整数)一样,你在创建元组时也可以打破规则,省略括号:

In [1]: tup = 1, 2, 3

In [2]: tup
Out[2]: (1, 2, 3)

然而,大多数时候,你会希望包含括号,这样既能提高代码的可读性,又能在使用更复杂的代码(例如嵌套元组,即元组中存储元组)时保持一致:

In [3]: nested = (1, 2, 3), ('Alice', 'Bob')

In [4]: nested
Out[4]: ((1, 2, 3), ('Alice', 'Bob'))

由于元组可以包含单个值,因此使用逗号分隔值比使用括号更为重要。为了了解原因,在控制台中输入以下内容并检查对象的类型:

In [5]: what_am_I = (1)
In [6]: type(what_am_I)
Out[6]: int

在这种情况下,Python 认为你刚输入了一个带括号的整数!现在,添加一个结尾逗号:

In [7]: what_am_I = (1,)

In [8]: type(what_am_I)
Out[8]: tuple

需要记住的是,单项元组需要一个结尾的逗号。

将其他类型转换为元组

你还可以通过使用内置的tuple()函数将其他数据类型转换为元组。在控制台中输入以下内容:

In [9]: x = tuple('Hello, World!')

In [10]: x
Out[10]: ('H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd', '!')

这个代码片段将字符串Hello, World!转换为元组。请注意,字符串中的每个字符现在都是元组中的一个单独元素。

注意

由于“tuple”是一个函数名称,你不应将其用作变量名。

你也可以使用tuple()将一个列表(由方括号[]表示)转换为元组,如下所示:

In [11]: planet_list = ['Venus', 'Earth', 'Mars']

In [12]: planet_tup = tuple(planet_list)

In [13]: planet_tup
Out[13]: ('Venus', 'Earth', 'Mars')

在这种情况下,字符串被保留为单独的单词,因为每个字符串都是列表中的一个单独项。

使用元组

元组可以像字符串一样被索引和切片(参见第七章)。由于元组是不可变的,无法修改,因此它们没有许多内置方法。然而,在使用元组时,你可以利用 Python 标准库中的通用内置函数以增加其实用性。这些函数和方法的一些示例列在表 9-2 中。你应当将斜体中的虚拟变量和数值替换为实际的变量和数值名称。

表 9-2: 有用的内置函数和元组方法

函数 描述 示例
tuple() 将序列转换为元组 tuple(seq_name)
len() 返回序列的长度 len(tuple_name)
min() 返回具有最小值的序列项 min(tuple_name)
max() 返回具有最大值的序列项 max(tuple_name)
方法 描述 示例
count() 返回指定值的出现次数 tuple_name.count(value)
index() 返回指定值的位置 tuple_name.index(value)

注意,元组方法是在元组名称后使用点表示法调用的,例如 tuple_name.count(value)。你在前一节中已经看过tuple()的用法,所以让我们先看看len()

获取元组的长度

创建元组后,它的长度是固定的。你可以使用内置的len()函数来找到这个长度。让我们使用之前创建的元组来看看它是如何工作的:

In [14]: len(tup)
Out[14]: 3

In [15]: len(nested)
Out[15]: 2

第一个结果3是直观的,因为tup中有三个项目。但嵌套元组的长度只有2,尽管明显有五个项目存在。

为了理解发生了什么,让我们使用索引来查看每个元组中的第一个项目:

In [16]: tup[0]
Out[16]: 1

In [17]: nested[0]
Out[17]: (1, 2, 3)

括号内的值被视为元组中整体序列中的单个项目。如果你需要访问一个项目内部的值,可以再添加一个索引。例如,要查看每个嵌套项目中的第一个元素,输入以下内容:

In [18]: nested[0][0]
Out[18]: 1

In [19]: nested[1][0]
Out[19]: 'Alice' 

In [20]: nested[1][0][0]
Out[20]: 'A'

如图 9-1 所示。由于第二个嵌套元组中的项目是字符串('``Alice``''``Bob``'),而这些字符串又由元素(字母)组成,你需要索引到三层才能访问nested[1]中的所有元素。第一个索引获取嵌套元组,第二个索引获取嵌套元组中的字符串,第三个索引获取字符串中的字符。

Image

图 9-1:索引嵌套元组的示例

理解这种行为很重要,万一你想迭代一个元组,逐一获取它的值时(我们还没有讲解循环,所以请耐心等待)。对于非嵌套元组,这是直接的:

In [21]: for i in tup:
    ...:      print(i * 10)
10
20
30

对于嵌套元组,你需要遍历每个嵌套元组中的元素,以便访问所有项目:

In [22]: for item in nested:
    ...:      for element in item:
    ...:           print(element * 5)
5
10
15
AliceAliceAliceAliceAlice
BobBobBobBobBob

“项目”和“元素”这两个术语没有特殊含义。你完全可以把它们称为“i”和“j”或者“Fred”和“George”。

获取元组的最小值和最大值

min()max()函数分别返回元组中的最小值和最大值。以下是一个示例:

In [23]: min(tup)
Out[23]: 1

In [24]: max(tup)
Out[24]: 3

这很简单,但如果我们尝试在嵌套元组上进行操作呢?让我们看看会发生什么:

In [25]: min(nested)
Traceback (most recent call last):

File ″C:\Users\hanna\AppData\Local\Temp/ipykernel_25576/1378168620.py″, line 1, in <module>
min(nested)

TypeError: '<' not supported between instances of 'str' and 'int'

Python 抛出一个TypeError错误,因为它不知道如何区分最小字符串和最小整数。然而,你可以在字符串元组中找到最小值。看看这个:

In [26]: test = ('c', 'bob', 'z')

In [27]: min(test)
Out[27]: 'bob'

In [28]: test = ('a', 'A')

In [29]: min(test)
Out[29]: 'A'

min()max()函数使用 ASCII 排序顺序来排序字符串。在 ASCII 表中(见* www.asciitable.com/*),特殊字符(如标点符号)排在字母之前,大写字母排在小写字母之前。

许多其他内置函数可以处理多种数据类型。例如,len()函数适用于字符串、元组、集合、列表和字典。在第七章中学过的成员操作符也适用于多种类型。这里是一个示例:

In [30]: elements = 'carbon', 'calcium', 'oxygen'

In [31]: 'carbon' in elements
Out[31]: True
拆解元组

你可以使用一种叫做解包的过程将元组中的值一次性赋给多个变量。假设你编写了一个返回笛卡尔坐标(x,y)对的函数,并希望在程序的后续部分使用单独的 x 和 y 值。以下是你可以获取这些值的方法:

In [32]: coordinates = (45, 160)

In [33]: x, y = coordinates In [34]: x
Out[34]: 45

In [35]: y
Out[35]: 160

要使用嵌套元组,可以使用相同的括号结构来赋值变量。让我们重新审视我们的 nested 元组:

In [36]: nested = (1, 2, 3), ('Alice', 'Bob'), 549

In [37]: (a, b, c), (d, e), f = nested

In [38]: a
Out[38]: 1

In [39]: e
Out[39]: 'Bob'

你不需要获取所有的元素。假设你只想获取嵌套元组中的前三个数字。如果直接尝试获取,它将引发一个异常:

In [40]: (a, b, c) = nested
Traceback (most recent call last):

File ″C:\Users\hanna\AppData\Local\Temp/ipykernel_25576/3799313898.py″, line 1, in <module>
(a, b, c) = nested

ValueError: not enough values to unpack (expected 3, got 2)

Python 期望你解包元组中的每个项目。为了绕过这个问题,你可以使用 splat(或 star)运算符(*)结合无关的变量符号(_)来解包元组。Splat 允许任意数量的项,所以在这种情况下,你告诉 Python “获取其余部分”并将它们赋值给 _

In [41]: (a, b, c), *_ = nested

In [42]: a
Out[42]: 1

In [43]: b
Out[43]: 2

In [44]: c
Out[44]: 3

In [45]: _
Out[45]: [('Alice', 'Bob'), 549]

你不需要使用 _ 中的值。这些值将在 Python 执行常规垃圾回收时被清除出内存。

元组的运算符重载

你可以像操作字符串一样在元组上使用运算符重载。例如,将两个元组相加会产生一个新元组,包含两个元组的所有值:

In [46]: tup1 = 1, 2, 3

In [47]: tup2 = 4, 5, 6

In [48]: tup3 = tup1 + tup2

In [49]: tup3
Out[49]: (1, 2, 3, 4, 5, 6)

使用乘法运算符与整数结合时,会将元组的多个副本与它们所包含的对象引用的副本连接在一起:

In [50]: tup1 * 3
Out[50]: (1, 2, 3, 1, 2, 3, 1, 2, 3)
意外的元组行为

元组不可变的规则存在漏洞。例如,如果元组包含一个可变数据类型,你可以在元组内更改该项。让我们通过使用一个可变列表来尝试(列表用方括号括起来;我们将在下一部分进一步了解列表):

In [51]: tup_with_list = (1, 2, ['Alice', 'Bob'], 3)

In [52]: tup_with_list
Out[52]: (1, 2, ['Alice', 'Bob'], 3)

In [53]: tup_with_list[2][1] = 'Steve'

In [54]: tup_with_list
Out[54]: (1, 2, ['Alice', 'Steve'], 3)

在这个例子中,我们能够将列表中的第二项([2][1])从 Bob 更改为 Steve,即使元组是不可变的。这属于你可以做的事情,但不应该做的事情!

打印元组

在元组上运行 print() 可能会令人沮丧,因为默认显示包含了逗号和引号:

In [55]: names = 'Harry', 'Ron', 'Hermione'

In [56]: print(names)
('Harry', 'Ron', 'Hermione')

为了解决这个问题,你可以使用 join() 字符串方法来只打印元组中的字符串:

In [57]: print(' '.join(names))
Harry Ron Hermione

在这个例子中,我们使用空格(' ')将元组中的每个项连接起来。你也可以使用其他字符来连接项,例如换行符转义序列(\n):

In [58]: print('\n'.join(names))
Harry
Ron
Hermione

join() 方法仅适用于由字符串组成的序列。要处理混合数据类型,请包含内建函数 map()

In [59]: tup = 'Steve', 5, 'a', 5

In [60]: print(' '.join(map(str, tup)))
Steve 5 a 5

你还可以使用 splat 运算符(*)高效且美观地打印元组:

In [61]: print(*tup, sep='\n')
Steve
5
a
5

Splat 将元组作为输入并将其展开为函数调用中的位置参数。最后一个参数是用于打印时项之间的分隔符。默认分隔符是空格(sep=' ')。

测试你的知识

  1. 对于 tup = (1, 2, 3),如果运行这段代码 tup[3],会发生什么?

  2. 如果 test = ('a', '!'),运行 min(test) 的结果是什么?

  3. 使用索引来提取tup = (``'``Rust``'``, '``R``'``, '``Go``'``, '``Julia``'``), (``'``Python``'``)中的字母“y”。

  4. 以下哪些是元组的特征?

a. 固定长度

b. 有序的值

c. 内容不可更改

d. 仅包含整数、浮点数和字符串

  1. 创建一个“实地考察”元组,包含这五项:顶篷帽、岩石锤、手持放大镜、登山靴和太阳镜。然后,编写代码从元组中移除顶篷帽(因为我不穿那个!)。

列表

列表是一个可变长度、可迭代、有序的值序列。它们看起来像元组,只是它们被方括号而不是圆括号包围:

['K. L. Putney', 'M. B. Clark', 'S. B. Vaughan']

因为列表是可变的,你可以随意更改它们的值。你可以添加项目、更改项目和删除项目。否则,列表和元组类似。它们可以保存多种数据类型,包括不同类型的混合。你可以对它们进行索引、切片、连接、嵌套、使用内置函数等等。列表是 Python 中的真正“工作马”,你将经常使用它们。

注意

列表是可以作为值处理的对象。也就是说,它们可以存储在变量中并传递给函数。如果你听到“列表值”这个术语,请注意它指的是整个列表,而不是列表中的某个值。

创建列表

创建一个列表,将值或逗号分隔的值放入方括号([])中:

In [62]: dna_bases = ['adenosine', 'guanine', 'cytosine', 'thymidine']

In [63]: dna_bases
Out[63]: ['adenosine', 'guanine', 'cytosine', 'thymidine']

因为列表是可变的,你可以从一个空列表开始。例如,你可以设置一个空列表,在程序稍后保存用户输入。操作方法如下:

In [64]: empty_list = []

你还可以使用内置的list()函数将其他数据类型(如元组和字符串)转换为列表:

In [65]: my_tuple = 1, 2, 3

In [66]: my_tuple
Out[66]: (1, 2, 3)

In [67]: my_list = list(my_tuple)

In [68]: my_list
Out[68]: [1, 2, 3]

注意

因为“list”是一个函数的名称,请勿将其用作变量名。

与列表的操作

因为列表是可变的,你可以对它们做更多操作,而不仅限于元组,并且它们提供了更多的内置功能。表 9-3 总结了列表方法。你需要将斜体部分的名称替换为你自己的名称。此外,你还可以使用表 9-2 中的len()min()max()内置函数来处理列表。

表 9-3: 内置列表方法

方法 描述 示例
append() 将单个项添加到列表末尾 list_name.append(item)
extend() 将可迭代项添加到列表末尾 list_name.extend(iterable)
insert() 在给定索引(i)之前插入项 list_name.insert(i, item)
remove() 移除列表中第一个与项值相等的项 list_name.remove(item)
pop() 移除并返回给定索引处的项 list_name.pop(index)
clear() 移除列表中的所有项 list_name.clear()
index() 返回第一个值等于项的索引 list_name.index(item)
count() 返回某个项在列表中出现的次数 list_name.count(item)
sort() 原地排序列表项 list_name.sort()
reverse() 原地反转列表项 list_name.reverse()
copy() 返回列表的浅拷贝 list_name.copy()

列表方法的工作方式不同于你在第七章中学到的字符串方法。字符串方法执行任务并返回一个新的字符串,而列表方法通常会修改列表并返回None。例如,要排序一个列表,你应该使用list_name.sort(),而不是list_name = list_name.sort()

注意

所有打印元组的方法也适用于列表,因此请参见第 228 页的“打印元组”。

向列表添加项

append项允许你将一个项添加到列表的末尾。

In [69]: patroni = ['stag', 'otter', 'dog']

In [70]: patroni.append('doe')

In [71]: patroni
Out[71]: ['stag', 'otter', 'dog', 'doe']

要将多个项添加到列表末尾,项需要以可迭代的形式存在。让我们尝试将一只鹭鸟和一只野兔添加到patroni列表中:

In [72]: patroni.extend('heron', 'hare')
Traceback (most recent call last):

File ″C:\Users\hanna\AppData\Local\Temp/ipykernel_24452/4246633803.py″, line 1, in <module>
patroni.extend('heron', 'hare')

TypeError: extend() takes exactly one argument (2 given)

你会得到一个TypeError,因为extend()方法期望一个参数(括号中的内容),而不是两个。现在,尝试传递一个元组的名称,看看效果:

In [73]: extra_patroni = 'heron', 'hare'

In [74]: patroni.extend(extra_patroni)

In [75]: patroni
Out[75]: ['cat', 'stag', 'otter', 'dog', 'doe', 'heron', 'hare']

成功了!无论是循环遍历值并将其中一些添加到列表中,还是添加从函数返回的值,append()extend()都很有用。

插入值到列表中

如果你需要将一个项插入到列表的特定位置,而不仅仅是在末尾,可以使用insert()方法,并传递(即在括号中添加)你想放置该项之前的索引,然后是该项,两者之间用逗号分隔。例如,要将一个项添加到patroni列表的开头,可以使用索引0

In [76]: patroni.insert(0, 'cat')

In [77]: patroni
Out[77]: ['cat', 'stag', otter, dog, 'doe']

insert()方法会为每个项调整索引,以容纳新的项。然而,这在计算上是昂贵的,应尽量避免使用。

从列表中删除项

如果你想从列表的任何位置删除一个项,可以使用pop()方法。让我们删除cat的守护神。因为pop()不仅会删除该项,还会返回它,我们可以以某种方式使用它,比如将其赋值给一个变量,虽然这是可选的。让我们来看一下:

In [78]: Umbridge_patronus = patroni.pop(0)

In [79]: Umbridge_patronus
Out[79]: 'cat'
In [80]: patroni
Out[80]: ['stag', 'otter', 'dog', 'doe', 'heron', 'hare']

我们现在有了一个新的变量,保存字符串cat,而patroni列表中不再包含该项。

如果你不指定索引,pop()会删除列表中的最后一个项。

删除项的另一种方法是使用del运算符,它是“删除”的缩写。只需传递索引给它:

In [81]: names = ['Harry', 'Ron', 'Hermione', 'Ginny']

In [82]: del names[1]

In [83]: names
Out[83]: ['Harry', 'Hermione', 'Ginny']

del运算符也允许切片操作:

In [84]: del names[:2]

In [85]: names
Out[85]: ['Ginny']

你还可以通过在remove()方法中指定项来删除该项:

In [86]: my_list = ['a', 'b', 'c', 'a', 'b', 'c']

In [87]: my_list.remove('a')

In [88]: my_list
Out[88]: ['b', 'c', 'a', 'b', 'c']

注意,只有第一个出现的'a'会被移除。而且,如果指定的项在列表中不存在,Python 会引发ValueError

更改列表中项的值

你可以通过使用索引来更改列表中项的值。让我们将hare的守护神更改为wolf

In [89]: patroni[5] = 'wolf'

In [90]: patroni
Out[90]: ['stag', 'otter', 'dog', 'doe', 'heron', 'wolf']

因为hare位于列表的末尾,我们也可以使用内置的len()函数来找到列表的末尾,并使用其返回值:

In [91]: patroni[len(patroni) - 1] = 'wolf'

你需要从列表的长度中减去 1,因为 Python 中的迭代和索引是从 0 开始的,所以最后一个索引总是比列表的长度小 1。

在列表中查找项目的索引

类似于remove()方法,index()方法将返回列表中指定项第一次出现的零基索引。如果该项不存在,它还会引发ValueError。让我们获取patroni列表中dog的索引:

In [92]: patroni

Out[92]: ['stag', 'otter', 'dog', 'doe', 'heron', 'wolf']

In [93]: patroni.index('dog')
Out[93]: 2

你还可以在列表上使用切片表示法,限制搜索范围到某个子序列。只需在项名后添加可选的开始和结束参数。然而,返回的索引仍然是相对于整个序列的开始来计算的:

In [94]: patroni.index('dog', 2, 5)
Out[94]: 2

在这个例子中,index()方法查看了索引25之间的项目(dog 直到 wolf)。

count()方法返回某项在列表中出现的次数:

In [95]: my_list.count('b')
Out[95]: 2
列表中的值排序

sort()方法原地对列表进行排序,可以是按字母顺序或数字顺序。例如:

In [96]: letters = ['c', 'a', 'c', 'b', 'd']

In [97]: letters.sort()

In [98]: letters
Out[98]: ['a', 'b', 'c', 'c', 'd']

然而,计算机非常字面化,事情可能不会按计划进行。注意如果你尝试按字母顺序对一个包含不同大小写字母的列表进行排序时会发生什么:

In [99]: letters_mixed_case = ['C', 'a', 'c', 'B', 'd']

In [100]: letters_mixed_case.sort()

In [101]: letters_mixed_case
Out[101]: ['B', 'C', 'a', 'c', 'd']

Python 的默认行为是将大写字母排在小写字母前。因此,这个混合大小写的例子虽然符合 Python 的标准,但可能并不是你预期或想要的。为了强制 Python 将字母一视同仁,你可以使用可选的key参数,在排序前将所有字符串转换为小写:

In [102]: letters_mixed_case.sort(key=str.lower)

In [103]: letters_mixed_case
Out[103]: ['a', 'B', 'C', 'c', 'd']

sort()方法还带有第二个可选参数,用于反转列表中项的顺序:

In [104]: letters_mixed_case.sort(reverse=True)

In [105]: letters_mixed_case
Out[105]: ['d', 'c', 'a', 'C', 'B']

你可以为sort()传递一个排序键,告诉它你希望按哪个参数进行排序。在这个例子中,我们根据字符串的长度使用len排序键进行排序:

In [106]: my_list = ['longest', 'long', 'longer']

In [107]: my_list.sort(key=len)

In [108]: my_list
Out[108]: ['long', 'longer', 'longest']

你甚至可以编写并传递一个自定义函数给sort(),以进行更复杂的排序。要了解更多内容,请访问排序教程 docs.python.org/3/howto/sorting.html

复制的奇怪案例

复制列表的行为可能暴露了 Python 语言中最大的“陷阱”。给自己倒杯咖啡,因为这可能是你今天学到的最重要的事情。

还记得变量名是指向对象的引用,而不是对象本身吗?同样,当你通过赋值语句复制一个对象时,你只是复制了对该对象的引用。当这种行为与可变对象结合时,可能会导致混乱。

让我们将一个列表赋值给另一个列表,看起来是件简单的事:

In [109]: my_patroni = ['cat', 'hare', 'doe']

In [110]: your_patroni = my_patroni

In [111]: your_patroni
Out[111]: ['cat', 'hare', 'doe']

你可能认为my_patroniyour_patroni是包含相同值的独立列表,但它们并不是。每个名称都指向内存中的同一个对象。你可以通过检查每个对象的标识来确认这一点:

In [112]: id(my_patroni), id(your_patroni)
Out[112]: (2181240760640, 2181240760640)

它们是同一个对象。所以,如果你修改一个,另一个也会被修改:

In [113]: my_patroni[0] = 'stag'

In [114]: my_patroni
Out[114]: ['stag', 'hare', 'doe']

In [115]: your_patroni
Out[115]: ['stag', 'hare', 'doe']

修改my_patroni中的第一个项同时改变了your_patroni中的相同项。这种行为可能会让你整夜调试找 bug。

要正确地复制一个可变对象,比如列表,使用copy()方法:

In [116]: my_patroni = ['cat', 'hare', 'doe']

In [117]: your_patroni = my_patroni.copy()

In [118]: your_patroni
Out[118]: ['cat', 'hare', 'doe']

另外,你也可以使用切片表示法从头到尾复制整个列表:

In [119]: your_patroni = my_patroni[:]

无论哪种方法,每个列表对象都有一个独立的身份:

In [120]: id(my_patroni), id(your_patroni)
Out[120]: (2181240443968, 2181240620288)

这很好,但我们还没有完成。切片和copy()方法会进行拷贝。这意味着,如果一个列表包含嵌套列表,copy()方法只会复制指向内部嵌套列表的引用。让我们来看一个例子:

In [121]: my_patroni = [['cat', 'hare'], ['doe', 'stag']]

In [122]: your_patroni = my_patroni.copy()

In [123]: id(my_patroni), id(your_patroni)
Out[123]: (2181240513024, 2181240710976)

如预期的那样,这两个列表有不同的身份,意味着它们是不同的对象。现在让我们检查第一个嵌套列表(索引为0)的身份:

In [124]: id(my_patroni[0]), id(your_patroni[0])
Out[124]: (2181240520640, 2181240520640)

这个内部列表在两个列表中是同一个对象。为了证明这一点,改变这个列表中的第一个项为wolf。记住,第一个索引引用的是第一个嵌套列表,而第二个索引引用的是这个列表中的第一个项。

In [125]: my_patroni[0][0] = 'wolf'

In [126]: my_patroni
Out[126]: [['wolf', 'hare'], ['doe', 'stag']]

In [127]: your_patroni
Out[127]: [['wolf', 'hare'], ['doe', 'stag']]

再次,修改一个列表中的项会同时修改另一个列表中的相同项。注意,这种行为仅限于嵌套列表。如果你向my_patroni列表添加一个新项,它不会影响到your_patroni

In [128]: my_patroni.append('Manx cat')

In [129]: my_patroni
Out[129]: [['wolf', 'hare'], ['doe', 'stag'], 'Manx cat']

In [130]: your_patroni
Out[130]: [['wolf', 'hare'], ['doe', 'stag']]

为了避免这种行为,你应该导入内建的copy模块并使用其deepcopy()方法:

In [131]: import copy

In [132]: their_patroni = copy.deepcopy(your_patroni)

In [133]: their_patroni
Out[133]: [['wolf', 'hare'], ['doe', 'stag']]

In [134]: id(your_patroni[0]), id(their_patroni[0])
Out[134]: (2181240520640, 2181240818368)

现在,嵌套列表已经是独立的对象,你已成功创建了原始列表的真正副本。不再有“量子纠缠”或“远程作用”的问题。

为了略微降低代码运行速度,deepcopy()会确保你复制所有内部对象的引用。这包括列表中每一层级的所有可变对象,从而避免可能需要花费大量时间来发现和修复的 bug。

检查成员关系

你可以使用innot_in关键字检查一个项是否出现在列表中。这些关键字同样适用于其他容器数据类型,如下所示:

In [135]: my_patroni = ['cat', 'hare', 'doe']

In [136]: 'hare' in my_patroni Out[137]: True

In [138]: 'wolf' in my_patroni
Out[138]: False

然而,对于大列表,不建议这样做。检查列表中的成员关系是计算开销很大的操作,因此很慢。Python 必须检查列表中的每一个值才能执行此操作,而在其他集合数据类型,如集合和字典中,它可以使用非常快速的哈希表来显著提高性能。为了这个目的,将列表转换为集合非常简单,稍后我们会在本章中进行介绍。

测试你的知识

  1. 创建一个名为patroni的空列表,然后一次性向其中添加老虎、鲨鱼和鼬鼠。

  2. 删除之前patroni列表中的所有项。

  3. patroni列表中添加“鼬鼠”的错误方法是什么?

a. patroni.append(``'``shrew``'``)

b. patroni += ['shrew']`

c. patroni = patroni + 'shrew'

patroni = patroni + ['shrew']

  1. 为什么不应该使用patroni += 'shrew'

a. 你将抛出TypeError: can only concatenate list (not ″str″) to list

b. 增量运算符只适用于字符串和数学表达式

c. “鼩鼱”中的每个字母都会变成列表中的一个单独项

d. 没有人会用鼩鼱作为守护神

J.K. Rowling 的个人守护神是:

a. 曼岛猫

b. 杰克·拉塞尔梗

c. 鹤

d. 蜂鸟

集合

集合是一个可变的、无序的、可迭代的包含唯一元素(不允许重复)的集合。集合的设计方式类似于数学中的集合,因此支持并集、交集和差集等操作。集合看起来像元组和列表,只不过它们被大括号包围:

{'K. L. Putney', 'M. B. Clark', 'S. B. Vaughan'}

集合基于一种称为哈希表的数据结构,这使得向集合中添加和查找元素非常快速(我们在《调试器面板》一节中讨论过这种数据结构,见第 90 页)。哈希表的深度讨论超出了本书的范围,但基本上,哈希是一种将键或字符序列转换为更短、固定长度的值的过程,这些值更容易查找,并且这些值被存储在哈希表中以便快速查找。

除了在成员测试上比元组和列表明显更快外,集合还可以通过将元组或列表转换为集合来有效去除其中的重复值。另一方面,集合在迭代时稍微慢一些,占用的内存也更多。而且,由于集合是无序的,你不能像访问元组和列表那样使用索引来访问元素。

创建集合

集合由一系列以逗号分隔的值组成,这些值被大括号({})包围:

In [139]: a_set = {1, 2, 3}

In [140]: a_set
Out[140]: {1, 2, 3}

你还可以使用内置的 set() 函数来复制集合:

In [141]: new_set = set(a_set)

In [142]: new_set
Out[142]: {1, 2, 3}

set() 函数也可以将其他数据类型转换为集合:

In [143]: a_string = ('Hello, World!')

In [144]: a_set = set(a_string)

In [145]: a_set
Out[145]: {' ', '!', ',', 'H', 'W', 'd', 'e', 'l', 'o', 'r'}

这段代码将字符串 a_string 转换为了集合。注意,字符串中的每个字符现在是集合中的一个单独元素,重复项已被删除,元素是无序的。

顺便提一下,要创建一个空集合,你必须使用 set(),而不是 {},因为后者会创建一个空字典,这是我们将在下一节中介绍的数据结构。

注意

因为“set”是一个函数的名称,你永远不应将其用作变量名。

集合中的元素必须是可哈希的,这意味着它们必须是不可变的。因为 Python 对集合中的每个元素进行哈希并存储哈希值,如果你就地修改一个元素,它会被重新哈希,新的哈希值会存储在哈希表中的不同位置。这可能会导致冲突和丢失元素,从而使集合无法正常工作。

整数、浮动数和字符串是不可变的,就像由不可变项组成的元组一样。你可以使用 hash() 函数检查 Python 中的对象是否是可哈希的:

In [146]: hash('astrolabe')
Out[146]: -4570350835965251752

因为字符串 '``astrolabe``' 是不可变的,所以它被分配了一个哈希值。但是,如果你尝试对一个可变列表执行相同的操作,你将抛出一个 TypeError 错误。

要在集合中使用可变序列,首先必须将其转换为元组。在这个例子中,我们在将列表赋值给集合时将其转换为元组:

In [147]: my_set = {tuple(['a', 'list'])}

In [148]: my_set
Out[148]: {('a', 'list')}

这仅在元组中不包含可变项(如列表)时有效:

In [149]: a_tuple = (1, 2, 3, ['Hello, World!'])

In [150]: my_set = set(a_tuple)
Traceback (most recent call last):

File ″C:\Users\hanna\AppData\Local\Temp/ipykernel_3856/1713377465.py″, line 1, in <module>
my_set = set(a_tuple)

TypeError: unhashable type: 'list'

如果你的元组中有列表,你需要将它们也转换为元组。

因为集合使用哈希表,所以它们比元组和列表占用更多的内存。输入以下代码到控制台以查看差异:

In [151]: import sys  # For system module.

In [152]: a_list = list(range(10_000))

In [153]: a_tuple = tuple(a_list)

In [154]: a_set = set(a_list)

In [155]: sys.getsizeof(a_list)
Out[155]: 87616

In [156]: sys.getsizeof(a_tuple)
Out[156]: 80040

In [157]: sys.getsizeof(a_set)
Out[157]: 524504

一个包含 10,000 个元素的集合大约占用比列表多六倍的内存,比相同大小的元组多 6.5 倍的内存。

与集合的操作

表 9-4 列出了一些常用的集合操作方法。你应该用自己的变量名替换文中显示的斜体变量名。除了这些方法,你还可以使用 Python 的许多内置函数,例如min()max()len(),与集合一起使用。

表 9-4: 有用的内置集合方法

方法 操作符语法 描述
set1.add(item) 向集合中添加项
set1.clear() 重置为空集合
set1.copy() 返回集合的浅拷贝
set1.difference(set2) set1 - set2 返回不共享的项
set1.difference_update(set2) set1 -= set2 将 set1 设置为不在 set2 中的项
set1.discard(item) 从集合中移除指定项
set1.intersection(set2) set1 & set2 返回两个集合中的所有项
set1.intersection_update(set2) set1 &= set2 将 set1 设置为交集项
set1.isdisjoint(set2) 如果没有共享项,返回 True
set1.issubset(set2) set1 <= set2 如果 set2 包含 set1,返回 True
set1.issuperset(set2) set1 >= set2 如果 set1 包含 set2,返回 True
set_name.pop() 从集合中移除任意元素
set_name.remove(item) 从集合中移除一个元素
set1.symmetric_difference(set2) set1 ^ set2 返回不共享的 set1 和 set2 项
set1.symmetric_difference_update(set2) set1 ^= set2 将 set1 设置为不共享的项
set1.union(set2) set1 &#124; set2 返回两个集合中的所有唯一项
set1.update(set2) set1 |= set2 将 set1 设置为 set1 和 set2 的唯一项

操作符语法列表示了你可以使用的某些方法的简写语法。显然,这些语法比完整的方法名更难阅读。

集合非常适合用于存储无法包含重复项且需要与其他数据集进行比较的数据集。假设你正在研究两个农场池塘的动物群。为了方便起见,我们会简化数据集,但可以想象它是一个更为广泛的列表(并使用适当的分类学命名):

In [158]: pond1 = {'catfish', 'bullfrog', 'snail', 'planaria', 'turtle'}

In [159]: pond2 = {'bullfrog', 'crayfish', 'snail', 'leech', 'planaria'}

接下来,向pond1添加一个 gar:

In [160]: pond1.add('gar')

In [161]: pond1
Out[161]: {'bullfrog', 'catfish', 'gar', 'planaria', 'turtle', 'snail'}

如果你尝试向pond1添加另一个 gar,所有操作看起来似乎正常,但集合中依然只会有一个gar项,因为不允许重复项。

查找两个集合之间的差异

假设你已经完成并且每个集合中的动物太多以至于无法直观比较。没问题,你可以使用difference()方法查看每个池塘独有的动物。图 9-2 使用 Venn 图演示了这种方法的返回内容。

Image

图 9-2:difference()集合方法

将这个方法应用于我们的池塘集合产生以下结果:

In [162]: pond1_unique_animals = pond1.difference(pond2)

In [163]: pond1_unique_animals
Out[163]: {'catfish', 'gar', 'turtle'}

In [164]: pond2_unique_animals = pond2.difference(pond1)

In [165]: pond2_unique_animals
Out[165]: {'crayfish', 'leech'}

因为没有不带有乌龟的池塘,这些结果表明你需要再去pond2做一次野外考察。

查找两个集合中的重复项

要查看哪些动物同时存在于两个池塘,使用intersection()方法,详见图 9-3。

Image

图 9-3:intersection()集合方法

在我们的池塘数据上使用这种方法得到以下结果:

In [166]: pond_common_animals = pond1.intersection(pond2)

In [167]: pond_common_animals
Out[167]: {'bullfrog', 'planaria', 'snail'}

你只需对pond1这样做,因为你会得到相同的结果,即pond2.intersection(pond1)

合并集合

现在假设你对一个大湖进行了采样,并发现更多种类的动物比小池塘中的种类还多:

In [168]: lake1 = {′bream′, ′planaria′, ′mussel′, ′catfish′, ′gar′, ′snail′, ′crayfish′,
′turtle′, ′bullfrog′, ′cottonmouth′, ′leech′, ′alligator′}

你想知道湖泊环境是否像一个大池塘,包括相同的动物以及一些额外的动物。为了确定这一点,首先必须使用union()方法将池塘动物合并为一个集合,如图 9-4 所示。

Image

图 9-4:union()集合方法

In [170]: pond_animals = pond1.union(pond2)

In [171]: print(pond_animals)
{'planaria', 'catfish', 'gar', 'snail', 'crayfish', 'turtle', 'bullfrog', 'leech'}

注意

可以使用以下语法一次性合并多个集合:set1.union(set2, set3, set4...)

确定一个集合是否是另一个集合的超集

如果lake1集合包含了pond_animals集合中的所有动物,则被视为超集。图 9-5 展示了超集和子集的工作原理。

Image

图 9-5:issuperset()和 issubset()集合方法

如果在 lake 集合上运行issuperset(),它将返回True,表示所有池塘动物都存在于湖中:

In [172]: lake1.issuperset(pond_animals)
Out[172]: True

创建 Frozensets

内置的frozenset()函数接受一个可迭代对象作为输入,并使其不可变。生成的“frozenset”是一个集合,其中的元素无法添加、移除或更改。

Frozensets 主要用作字典键(必须是不可变的)或其他集合中的元素,因为集合不能被插入到集合中。Frozensets 比集合更加“安全”,因为后续代码中不会意外更改 frozensets 中的元素。

要创建一个 frozenset,传递一个可迭代对象,比如另一个集合:

In [173]: a_set = {1, 2, 3}

In [174]: a_frozenset = frozenset(a_set)

In [175]: a_frozenset
Out[175]: frozenset({1, 2, 3})

你可以在 frozensets 上使用与集合相同的函数和方法,只要它们不会改变 frozenset。数学集合操作如交集、差集和并集都适用于 frozensets。

测试你的知识

11.  为什么不应该使用count()来计算一个集合?

12.  要获取集合 1 和集合 2 中所有元素但不在两者中都存在的元素,你会使用以下哪种方法?

a.  set1.difference(set2)

b.  set1.intersection(set2)

c.  set1.symmetric_difference(set2)

d.  set1.issubset(set2)

13.  正确还是错误:创建空集的最佳方法是使用set()函数。

14.  哪种方法不适用于不可变集合?

a.  frozenset1.union(frozenset2)

b.  len(frozenset1)

c.  frozenset1.pop()

d.  frozenset1.copy()

字典

简称字典,dict数据类型是一个有序的、可迭代的、可变的值集合,由而不是数字索引。键可以是几乎任何数据类型,并映射到一个或多个值。字典被认为是存储和访问数据的 Python 结构中最重要的。

字典看起来与元组、列表和集合不同,因为它有键-值对。但与集合类似,它用花括号括起来:

{'a_key': 'a_value', 'another_key': 'another_value'}

键具有与集合中元素相同的属性:它们必须是唯一的和不可变的,因为它们被哈希了。事实上,集合只是没有相应值的字典键的集合。像集合一样,字典由于使用了哈希过程而占用更多内存。

字典键类似于语言字典中的单词,而值表示该单词的定义。键可以是多个对象(如单词对),只要这些多个对象是不可变的(可以使用元组但不能使用列表)。

另一方面,值可以是可变对象。就像语言字典中的单词可以有多个定义一样,可以有多个值映射到单个键上是可以的。

因为字典关联或映射一件事物到另一件事物,当集合中的项目被标记时,它们倾向于被用作简单的数据库。你可以把它们用作具有键-值关系的简单数据库,比如学生的姓名和学生的 ID。

创建字典

字典由用花括号({})括起来的逗号分隔的键-值对组成。键-值对之间用冒号分隔,如下所示:

{'hello': 'hola', 'goodbye': 'adios'}

冒号的使用区分了字典和使用花括号的集合。

现在让我们创建一个将一些字母映射到它们对应的莫尔斯电码符号的字典:

In [176]: morse = {'e': '.', 'h': '....', 'l': '.-..', 'o': '---', 's': '...'}

In [177]: morse
Out[177]: {'e': '.', 'h': '....', 'l': '.-..', 'o': '---', 's': '...'}

正如你所见,字典保留了键-值对的插入顺序。这主要影响查看字典时的可读性,输入数据时应考虑这一点。

现在,使用这个morse字典,你可以遍历一个单词并将其转换为莫尔斯电码:

In [178]: for letter in 'hello':
    ...:      print(morse[letter])
....
.
.-..
.-..
---

请注意,字典键区分大小写,因此以下代码将引发KeyError错误:

In [179]: morse['S']
Traceback (most recent call last):

File "C:\Users\hanna\AppData\Local\Temp/ipykernel_6456/1793354668.py", line 1, in <module>
morse['S']

KeyError: 'S'

如果你正在做像在字典中循环字母这样的事情,你可以通过将字母转换为小写作为键索引的一部分来避免这个错误,如下所示:

In [180]: for letter in 'SOS':
    ...:       print(morse[letter.lower()])
...
---
...

你也可以使用内置的dict()函数来创建字典。这里的优势在于,你可以为键使用关键字参数,并避免键入太多单引号:

In [181]: frank_sez = dict(bread='good', fire='bad')

In [182]: frank_sez
Out[182]: {'bread': 'good', 'fire': 'bad'}

注意

因为“dict”是一个函数名,你永远不应该将它用作变量名。

将两个序列合并成一个字典

你可以将两个序列(如元组或列表)配对成一个字典。当然,序列中的元素数量应该相同,并且它们应该按适当的顺序排列,这样第一个列表中的索引 5 就会和第二个列表中索引 5 对应。以下是一个将英语单词翻译为西班牙语单词的示例。内置的zip()函数逐项配对这两个列表,映射相同的索引:

In [183]: english = ['then', 'but', 'cold']

In [184]: spanish = ['entonces', 'pero', ['frio', 'fria']]

In [185]: translation = {}

In [186]: for key, value in zip(english, spanish):
    ...: translation[key] = value

In [187]: translation
Out[187]: 
{'then': 'entonces',
'but': 'pero',
'cold': ['frio', 'fria']}

由于西班牙语中“冷”这个词既可以是男性形式也可以是女性形式,因此这两种形式存储在一个嵌套列表中(你也可以使用元组来提高内存效率)。你可以使用标准的列表索引来访问列表中的项:

In [188]: translation['cold'][0]
Out[188]: 'frio'

当然,你可能希望在嵌套列表中始终将男性形式或女性形式排在前面,这样你总是可以使用相同的索引来访问它。或者,你可以嵌套一个字典来指定男性(m)和女性(f):

In [189]: english = ['then', 'but', 'cold']

In [190]: spanish = ['entonces', 'pero', {'m': 'frio', 'f': 'fria'}]

In [191]: translation = {}

In [192]: for key, value in zip(english, spanish):
    ...:      translation[key] = value

In [193]: translation['cold']['f']
Out[193]: 'fria'

In [194]: translation['cold']['m']
Out[194]: 'frio'

通过使用字典,你不需要将翻译映射到任意的索引,而且你的代码更具可读性且不容易出错。如果你想让代码更简洁,可以使用以下方式构建翻译字典:translation = dict(zip(english, spanish))

创建空字典和值

要创建一个空字典,使用大括号:

In [195]: empty_dict = {}

值也可以为空。这对于设置占位符键很有用,稍后你可以为其分配值,比如当你加载一个新的列表或用户提供某些输入时。以下是一个示例:

In [196]: empty_dict['color'] = None

In [197]: empty_dict['weight'] = ''

In [198]: empty_dict
Out[198]: {'color': None, 'weight': ''}

操作字典

表 9-5 总结了一些字典方法。你需要将斜体中的名称替换为你自己的名称。此外,你还可以使用许多内置函数,如len()min()max()与字典一起使用。

表 9-5: 内置字典方法

方法 描述 示例
clear() 移除字典中的所有元素 dict_name.clear()
copy() 返回字典的副本 dict_name.copy()
fromkeys() 返回一个具有指定键和值的字典 dict_name = dict.fromkeys(key_tuple, value)
get() 返回指定键的值 dict_name.get(key)
items() 返回所有键值对的元组 dict_name.items()
keys() 返回字典键的列表 dict_name.keys()
pop() 移除指定键的元素 dict_name.pop(key)
popitem() 移除最后插入的键值对 dict_name.popitem()
setdefault() 如果键不存在,则插入指定的键和值;如果键存在,则返回其值 dict_name.setdefault(key, value)
update() 使用指定的键值对更新字典 dict_name.update({key: value})
values() 返回字典中的值列表 dict_name.values()

注意

虽然您可以更改映射到键的值,但没有字典方法可以让您向现有键添加值。要做到这一点,您需要导入并使用 collections 第三方模块。我们将在第十一章中查看 collections 和其他有用的模块。

获取字典内容

keys()values()items() 方法以列表形式的数据类型 dict_keysdict_valuesdict_items() 返回字典的内容。您可以对这些结构进行迭代(循环),但它们不像真正的列表那样工作。以下是它们的工作方式:

In [199]: chems = dict(HCl='acid', NaOH='base', HNO3='acid')

In [200]: chems.keys()
Out[200]: dict_keys(['HCl', 'NaOH', 'HNO3'])

In [201]: chems.values()
Out[201]: dict_values(['acid', 'base', 'acid'])

In [202]: chems.items()
Out[202]: dict_items([('HCl', 'acid'), ('NaOH', 'base'), ('HNO3', 'acid')])

如果要将此输出用作列表,可以使用 list() 函数进行转换:

In [203]: chems_keys = list(chems.keys())
In [204]: chems_keys
Out[204]: ['HCl', 'NaOH', 'HNO3']

当与 items() 一起使用时,这将返回一个键值对元组列表:

In [205]: list(chems.items())
Out[205]: [('HCl', 'acid'), ('NaOH', 'base'), ('HNO3', 'acid')]
获取字典键的值

正如您所见,如果只需字典中键的值,可以像在列表中使用索引一样使用键,如下所示:

In [206]: chems['HCl']
Out[206]: 'acid'

这很好用,直到您要求一个不存在的键时,Python 将引发 KeyError。为了避免这种情况,请使用 get() 方法,它允许您为不存在的键提供默认值:

In [207]: chems.get('KOH', 'unknown')
Out[207]: 'unknown'

传递给 get() 方法的第二个参数('unknown')是默认返回值。现在,当您请求一个不存在的键(如氢氧化钾)时,该方法将返回 'unknown'

您还可以使用 in 关键字来检查键是否存在:

In [208]: 'NaOH' in chems
Out[208]: True
向字典添加键值对

要向字典添加键值对,您可以使用索引方法(见第 In [196]: 行)或使用 update() 方法:

In [209]: chems.update({'KOH': 'base'})

In [210]: chems
Out[210]: {'HCl': 'acid', 'NaOH': 'base', 'HNO3': 'acid', 'KOH': 'base'}

要添加多个键值对,请使用逗号分隔它们:

In [211]: chems.update({'KOH': 'base', 'Ca(OH)2': 'base'})
合并字典

您还可以使用 update() 将一个字典添加到另一个字典中,但更简洁的方法是使用 ** 运算符。让我们将三个字典链接在一起,形成第四个字典:

In [212]: d1 = dict(Harry='good', Draco='bad')

In [213]: d2 = dict(Hermione='good', Tom='bad')

In [214]: d3 = dict(Ron='good', Dolores='bad')

In [215]: d4 = {**d1, **d2, **d3}

In [216]: d4
Out[216]: 
{'Harry': 'good',
'Draco': 'bad',
'Hermione': 'good',
'Tom': 'bad',
'Ron': 'good',
'Dolores': 'bad'}
从字典中删除键值对

要移除一个键值对,请将键传递给 pop() 方法:

In [217]: chems.pop('Ca(OH)2')
Out[217]: 'base'

In [218]: chems
Out[218]: {'HCl': 'acid', 'NaOH': 'base', 'HNO3': 'acid', 'KOH': 'base'}

注意,此方法会返回该值,因此在弹出时,如果需要,可以将其分配给变量:

In [219]: val = chems.pop('KOH')

In [220]: val
Out[220]: 'base'

您还可以使用 del 关键字来删除元素;例如,del chems['KHO']

为键创建默认值

setdefault() 方法允许您检查键是否存在,如果不存在,则为键设置一个值。否则,它将返回键的值。以下是一个示例:

In [221]: solar_system = {'Sol': 0, 'Mercury': 1, 'Venus': 2, 'Earth': 3}

In [222]: solar_system.setdefault('Mars', 4)
Out[222]: 4 In [223]: solar_system
Out[223]: {'Sol': 0, 'Mercury': 1, 'Venus': 2, 'Earth': 3, 'Mars': 4}

因为键 'Mars' 不存在,该方法添加了它及其在太阳系中的顺序 4。但是,如果您尝试更改现有键,如 'Earth',该方法将只返回其值并不做任何更改:

In [224]: solar_system.setdefault('Earth', 42)
Out[224]: 3

In [225]: solar_system
Out[225]: {'Sol': 0, 'Mercury': 1, 'Venus': 2, 'Earth': 3, 'Mars': 4}

假设你想要统计 2021 年与冠状病毒相关的文章中提到的 Pfizer、Moderna 和 Johnson & Johnson 的次数。你打算将这些计数存储在一个字典中。fromkeys()方法将帮助你创建这个字典,通过填充相同初始值的键。默认值是None,但在这种情况下,使用0。你需要以元组的形式传递键,然后再传入一个值:

In [226]: companies = ('Pfizer', 'Moderna', 'Johnson & Johnson')

In [227]: company_counts = dict.fromkeys(companies, 0)

In [228]: company_counts
Out[228]: {'Pfizer': 0, 'Moderna': 0, 'Johnson & Johnson': 0}
执行反向查找

字典经过优化,能够高效地查找给定键的值。然而,有时你可能希望查找与某个值对应的所有键(比如查找与某个电话号码关联的姓名)。Python 没有内置功能进行“反向查找”,所以你需要定义一个函数来执行这个任务。让我们来看一下:

In [229]: def lookup_keys(d, v):
     ...:      keys = []
     ...:      for k in d:
     ...:           if d[k] == v:
     ...:                keys.append(k)
     ...:      return keys

我们还没有讲解函数,所以让我来解释一下。我们使用def关键字定义了一个名为lookup_keys的函数,它有两个参数:d(字典)和v(值)。按下 ENTER 时,Python 会自动缩进四个空格,这表示你正在函数内部工作。由于同一个值可能与多个键关联,我们创建了一个名为keys的空列表来保存这些值。接下来,我们遍历字典中的键,如果键的值与指定的值(v)匹配,我们就把它加入到列表中。循环结束后,我们使用return关键字返回该列表,结束函数的执行,并使得该列表对程序的其他部分可用。

让我们用上一节中的solar_system字典来测试这个函数。将字典的名称和3(第三颗行星)作为参数传入:

In [230]: lookup_keys(solar_system, 3)
Out[230]: ['Earth']

正如你所料,反向查找的速度比正向查找慢。

打印字典

如果使用print()函数打印字典,你将看到构建字典时使用的所有大括号、引号和逗号。为了避免这种情况,你可以使用“漂亮打印”技术。

如果你在网上搜索“漂亮打印 Python 字典”,你会发现很多方法,比如pprint(),它们能生成比内建的print()函数更易读的输出。我们这里来看其中的一种方法——json.dumps()

json.dumps()方法将 Python 对象转换为JSON字符串。这样,它就将字典格式化为吸引人的JSON格式。该方法接受三个用于漂亮打印的参数:字典名称,一个布尔值(TrueFalse)表示是否排序键,以及缩进的空格数。

在以下示例中,我们导入了json,创建了一个字典(d),然后使用print()函数打印它,接着用json.dumps()进行比较:

In [231]: import json

In [232]: letter_order = dict(z=26, c=3, a=1, b=2, g=7, t=20)

In [233]: print(letter_order)
{'z': 26, 'c': 3, 'a': 1, 'b': 2, 'g': 7, 't': 20}

In [234]: print(json.dumps(letter_order, sort_keys=False, indent=4))
{
     "z": 26,
     "c": 3,
     "a": 1,
     "b": 2,
     "g": 7,
     "t": 20
}

JSON输出比print()返回的水平布局更易于理解。然而,它只适用于JSON支持的数据类型,这意味着嵌套的集合和函数将无法处理。

虽然我们有些提前了,但值得注意的是,你可以使用for循环、内置的sorted()函数和print()来遍历、排序和打印字典。你甚至可以使用 f-strings 来“美化”输出,像这样:

In [235]: for k in sorted(letter_order):
     ...:      print(f'{k}: {letter_order[k]}')
a: 1
b: 2
c: 3
g: 7
t: 20
z: 26

你可以通过在print命令中的第一个单引号后添加空格或制表符(\t),使输出格式类似于json.dumps()方法。试试看。

测试你的知识

  1. 如果你想初始化一个所有值为空的字典,应该使用什么方法?

a. setdefault()方法

b. fromkeys()方法

c. update()方法

d. 内置的zip()函数

  1. 关于字典,哪一项陈述是错误的?

a. 成员查找非常快速。

b. 字典被优化以查找给定值的键或键值。

c. 你可以使用pprint()模块来美化字典的打印输出。

d. 字典比列表更占用内存。

  1. 创建一个笑话字典,将开场白与笑点相对应。以下是几个示例:“你听说过绑架案吗?他睡了三小时。” “我组建了一个名为‘999 兆字节’的乐队。” “你永远也不会得到一个工作机会。” “我听说你不得不射杀你的狗。它生气了吗?” “它对此并不太开心!”

  2. contacts[′Nix′, ′Goaty′] = ′goatynix@gmail.com′中,方括号内保存的是什么数据类型?

a. 字符串

b. 列表

c. 元组

d. 集合

总结

在本章中,你学习了四种用于处理集合数据的 Python 数据类型。元组是一个不可变的序列类型,按整数索引顺序存储对象的集合。列表是一个可变的序列类型,按整数索引顺序存储对象的集合。集合是一个可变的集合类型,存储无序的唯一对象集合。字典是一个可变的映射类型,存储有序的唯一对象(键)集合,并将这些键映射到相关的对象(值)。

这些内置数据结构各自有其用途和独特的行为。元组在内存上非常高效,是存放“安全”对象的好地方,因为它们在创建后无法更改。列表则是可变的,灵活多用,适用于许多任务,尽管成员查找较慢。集合可以高效地从数据集中删除重复项,提供非常快速的成员查找,并且允许执行数学集合操作,如并集和交集。字典也提供快速的成员查找,并且让你轻松设置关联数据库以存储标签数据。如果你不确定某种数据类型的行为,建议在将其纳入代码前,先在交互式控制台中测试一下。

还有很多东西需要学习。理解式,我们将在下一章讨论,提供了用于创建列表、集合和字典的简便方法。可以导入的模块,如collectionsitertools,提供了处理容器数据类型的有用工具。我们将在第十一章中查看这些内容。然后,在第十二章中,我们将学习如何将外部数据集加载到列表、集合等中,而不是一项一项地手动输入。目前,是时候学习流程控制了。

第十章:流程控制**

image

到目前为止,我们一直在关注程序的组件,例如表达式、变量和数据类型。我们将这些组件组合成简单的可执行指令,但这些指令大多是线性的;换句话说,它们按编写的顺序依次执行。更复杂的程序会包括分支指令,这些指令可以跳过整个代码块,跳回到开头,或在多个选项中做出选择。为了处理这些情况,你需要一种控制代码流程的方法。

执行流程 指的是程序中语句执行的顺序。执行从代码的顶部开始,首先执行第一条语句,之后按照顺序依次执行剩余的语句。但这个顺序不一定是从上到下的。事实上,大多数程序的流程像繁忙交叉路口的车辆一样会改变方向。

流程控制语句 赋予 Python 做出决定的能力,以决定接下来执行哪些指令。你可以将这些语句看作流程图中的菱形,表示需要做出决策才能继续前进(见 图 10-1)。

Image

图 10-1:菱形表示流程图中的决策。

这个流程图评估 number 变量是否大于或等于 3。根据评估结果,代码会选择一条路径或另一条路径,这一过程称为 分支

在本章中,我们将讨论 ifelseelifwhileforbreakcontinue 流程控制语句和子句。我们还将探讨如何监控流程的执行并处理可能发生的任何异常。

if 语句

if 语句是一种条件语句或关系语句。所有控制语句,包括 if 语句,都以冒号(:)结束,并紧随其后的是一个缩进的代码块。只有当 if 语句的条件为 True 时,这个缩进的代码块才会执行。否则,该代码块会被跳过。

例如,这段代码检查 42 是否小于 2。只有当条件为 True 时,它才会打印消息:

In [1]: if 42 < 2:
    ...:      print("That's crazy!")
In [2]:

所有的 if 语句必须表达一个 条件,即一个结果为真或假的表达式。这个例子使用了比较运算符(<)来表达条件。另一个选项是使用布尔值(在 第八章 中讨论)。

如果你运行这段代码,你应该会注意到没有任何变化。这是因为该语句的结果是 False。没关系,但在大多数情况下,你会希望显式地处理 False 的结果,至少为了明确表示没有遗漏的代码。

你可以通过添加 else 子句来实现这一点,它会在 if 语句未执行时执行。图 10-1 中的菱形表示一个 if-else 语句,其工作原理如下:

In [3]: number = 2

In [4]: if number <= 3:
   ...:      print(number)
   ...: else:
   ...:      print('Out of range.')
2

else子句表示图 10-1 中的False分支。如果if语句中的条件未满足,它将打印字符串′``Out of range.``′

处理代码块

紧接在if语句和else子句之后的代码行会进行缩进。缩进代码是为了告诉 Python 解释器,这一组语句属于特定的代码块。这些代码块作为一个单元执行,直到缩进级别减少回零或回到与包含块相同的级别时结束。

大多数编程语言使用特定的语法来构造代码,例如使用大括号({})来标记代码块,使用分号(;)来结束语句行。Python 使用空白字符,因为这样在视觉上更容易理解,如图 10-2 所示,展示了前面提到的if语句。

Image

图 10-2:示例代码块

第一行末尾的冒号告诉 Python 接下来会有一个新的代码块。此代码块中的每一行都必须进行相同的缩进。在该图中,代码块 1 是当if语句条件为True时执行的代码。

以下的else子句返回到先前的缩进级别。else后面的冒号表示另一个代码块的开始(即if语句条件为False时执行的代码块,或称为代码块 2),这个代码块也必须进行缩进。

我们目前处理的是单级缩进,但代码块中也可以包含更深层次的缩进,或者称为嵌套代码块。在下面的示例中,每个In [7]之后的输入行表示一个新的代码块:

In [5]: genus = ′rattus′

In [6]: species = 'norvegicus'

In [7]: if genus == 'rattus':
    ...:      if species == 'norvegicus':
    ...:           print('The common brown rat.')
The common brown rat.

如果你在缩进代码时犯了错误,不用担心,Python 会提示你。根据错误发生的位置(例如,在函数外部或内部),Python 会抛出SyntaxErrorIndentationError

注意

根据 PEP8 风格指南 (pep8.org/#indentation/),每个缩进级别应使用四个空格,并且推荐使用空格而非制表符。默认情况下,Spyder 编辑器将制表符转换为四个空格,因此可以减少反复操作带来的肌肉劳损。你可以在工具 首选项 编辑器 源代码 缩进字符中找到此选项。

使用 else 和 elif 子句

if语句有一个可选的子句,叫做elif(即“else-if”的缩写),当if语句的条件为False时,它会测试另一个条件。elif子句允许你检查多个表达式是否为True,并在其中一个条件为True时执行一个代码块。然后,你可以使用else子句作为最终的“捕捉”语句,当没有任何前面的条件满足时执行。

让我们使用elifelse来比较一个变量,该变量表示核反应堆的核心温度(单位:摄氏度),并对几种可能的响应进行比较:

In [8]: core = 300

In [9]: if core < 200:
    ...:      print("Core is shut down")
    ...: elif 200 <= core < 300:
    ...:      print("Core is below optimum")
    ...: elif core == 300:
    ...:      print("Core is at optimum")
    ...: elif 300 < core < 1800:
    ...:      print("Core is above optimum") ...: else:
    ...:      print("Meltdown! Run for your life!")
Core is at optimum

代码首先为core变量分配一个 300^°的最佳工作温度。接着,if语句测试温度是否低于 200^°。如果是,核心应当关闭,因此会打印出相关的消息。然后,一系列elif语句检查其他结果,比如核心温度恰好为 300^°,并打印出相应的响应。最后,如果所有前述条件的评估结果为Falseelse语句会执行。这将捕捉到core值大于或等于1800的情况。使用else语句结束if语句块,确保至少有一个语句被执行,你不会得到空的响应。

使用else语句时,你需要特别小心,确保你的代码能够正确处理所有可能的值。例如,以下代码会打印出过热警告,尽管核心温度仅为 200^°。试着找找哪里出了问题:

In [10]: core = 200

In [11]: if core < 200:
    ...:      print("Core is shutdown")
    ...: elif 200 < core < 300:
    ...:      print("Core is below optimum")
    ...: elif core == 300:
    ...:      print("Core is at optimum")
    ...: elif 300 < core < 1800:
    ...:      print("Core is above optimum")
    ...: else:
    ...:      print("Meltdown! Run your life!")
Meltdown! Run your life!

由于这段代码没有明确处理core值恰好为200的情况,因此它被评估为else语句中的条件,导致错误的消息和许多不必要的激动。

同时,要确保只有一个条件评估为True。使用elif的一个优点是,如果某个条件评估为True,程序会立即执行对应的代码块并退出语句。这是高效的,但如果多个elif条件评估为True,只有与第一个True条件相关联的代码块会执行。

下面是一个示例,展示了一段使用elif来递增多个计数变量的代码,且这些条件之间有重叠:

In [12]: dogs = ('poodle', 'bulldog', 'husky')

In [13]: cats = ('persian', 'siamese', 'burmese')

In [14]: popular_breeds = ('poodle', 'persian', 'siamese')

In [15]: dog_count = 0 In [16]: cat_count = 0

In [17]: popular_breeds_count = 0

In [18]: animal = 'poodle'

In [19]: if animal in dogs:
    ...:      dog_count += 1
    ...: elif animal in cats:
    ...:      cat_count += 1
    ...: elif animal in popular_breeds:
    ...:      popular_breeds_count += 1

In [20]: dog_count
Out[20]: 1

In [21]: cat_count
Out[21]: 0

In [22]: popular_breeds_count
Out[22]: 0

这段代码首先分配了狗品种、猫品种以及流行狗品种的元组。接着,它为每个类别分配了计数变量,之后将“poodle”品种赋值给animal变量。

接下来,一系列条件语句评估animal变量。如果它在dogs元组中,dog_count变量加 1。否则,如果它在cats元组中,cat_count变量加 1,之后,如果它仅在popular_breeds元组中,popular_breeds_count会加 1。

当你运行代码并检查计数时,它们是错误的。尽管“poodle”同时出现在dogspopular_breeds元组中,只有dog_count变量被更新。因为第一个elif语句评估为Trueif语句立即终止,导致流行品种的评估从未执行。

使用三元表达式

为了方便,Python 允许将if-else语句块合并为一个称为三元表达式的单一表达式,其语法如下:

true expression if condition else false expression

这是一个示例:

In [23]: core = 1801

In [24]: 'Run for your lives!' if core >= 1800 else 'So far so good!'
Out[24]: 'Run for your lives!'

三元表达式让你可以写出简洁的代码,但代价是可读性降低。它们应该仅在条件和表达式简单直白的情况下使用。

使用布尔运算符

为了帮助你进行比较,Python 提供了 andornot 运算符。这三个运算符比较布尔值并计算出一个布尔值。

布尔运算符的可能结果可以通过真值表展示,我们在表 10-1 中展示了该表。

表 10-1: and/or 运算符的真值表

表达式 求值
True and True True
True and False False
False and True False
False and False False
True or True True
True or False True
False or True True
False or False False

如你所见,and 运算符只有在两个布尔值都为 True 时才会求值为 Trueor 运算符只要任意一个布尔值为 True,就会求值为 True。例如,如果你早餐吃了玉米片葡萄干麦片,那么你可以说自己吃了“麦片”,但如果你吃了培根鸡蛋,你才能说自己吃了“培根和鸡蛋”。

not 运算符仅作用于一个表达式或布尔值,并计算出相反的布尔值。例如:

In [25]: not False
Out[25]: True

使用 andornot,你可以构建更复杂的比较来控制代码的执行流。尝试在控制台中执行几个例子:

In [26]: 'a' == 'a' and 10 > 2
Out[26]: True

In [27]: (10 > 2) and (42 > 2) and ('a' == 'b')
Out[27]: False

In [28]: (10 < 2) or ('a' != 'b')
Out[28]: True

Python 会从左到右依次计算每个表达式,直到得到一个单一的布尔值。然后它会将这些布尔值计算为一个最终值,可能是 TrueFalse。运算顺序如下:

数学运算符 → 比较运算符 → not 运算符 → and 运算符 → or 运算符

使用布尔运算符,你可以在 if 语句中比较多个变量。以下是一个例子,你可以通过动物的腿数和发出的声音来区分动物:

In [29]: legs = 4

In [30]: sound = 'bark'

In [31]: if legs == 4 and sound == 'bark':
    ...:      print('a dog')
    ...: elif legs == 4 and sound == 'meow':
    ...:      print('a cat')
a dog

在前面的示例中,if 语句要执行,两个条件必须都是 True。在以下示例中,只需要其中一个条件为 True

In [32]: today = 'Sunday'

In [33]: if today in ('Saturday', 'Sunday'):
    ...:      print('Enjoy your weekend!')
Enjoy your weekend!

如果 todaySaturdaySunday,那么你就在周末,并且会调用 print() 函数。

注意,在使用 if 语句语法时容易出错。以下代码看起来是合乎逻辑的,但无论 today 变量的值如何,结果都会求值为 True

In [34]: today = 'Saturday'

In [35]: if today == 'Saturday' or 'Sunday':
    ...:      print('Enjoy your weekend!')
Enjoy your weekend!

循环

循环允许在关键字下缩进的某些步骤反复执行。直到满足某个条件,重复才会停止,这使得循环类似于 if 语句,但它们可以执行多次。

Python 使用 whilefor 关键字来实现循环。这些分别对应于条件控制循环和集合控制循环。

while 关键字加上一个条件构成了一个 while 语句。这些语句用于反复执行一段代码,直到给定的条件求值为 False。此时,程序中紧接着循环的下一行代码会被执行。以下是语法:

while some condition is True:
   do something

for 关键字用于重复执行一段固定次数的代码或对一组项进行迭代。以下是基本语法:

for something in something:
  do something

for 循环耗尽所有项目时,它的底层条件变为 False,循环结束并将控制权返回到 for 循环块下的第一行代码。

while 语句

while 语句会测试一个条件,并不断执行代码块,直到条件变为 False(图 10-3),或者你显式地使用 break 语句结束循环(后面会详细讲解)。事实上,while 循环可能会永远运行下去。

Image

图 10-3:通用 while 循环的流程图

while 循环用于在达到目标之前执行某个操作。例如,你可以模拟一群鹿的种群增长,直到种群达到目标值,这时模拟循环可以停止并记录相关细节,如达到目标所花费的时间或成年鹿的平均体重。

一个更简单的例子是测试密码。在下面的代码中,我们给用户设定了一个输入正确密码的次数限制。

In [36]: password = ''

In [37]: count = 0 In [38]: ➊ while password != 'Python':
    ...:        password = input("Enter your password: ")
    ...:        count += 1
    ...:    ➋ if count > 3:
    ...:           print("No more tries.")
    ...:           break

在这个例子中,我们首先创建一个空的 password 变量,并将 count 变量设置为 0。然后我们使用 while 关键字开始一个循环 ➊。如果 password 不等于“Python”,缩进的 while 子句将提示用户输入密码。接着它将 count 变量增加 1,并使用 if 语句检查是否超过了允许的次数 ➋。如果这个条件为 True,用户将被告知已超出允许的尝试次数,并且 break 关键字将结束循环。如果 count 小于或等于 3,循环将继续提示用户输入密码。如果用户输入正确密码,循环将在没有任何提示的情况下结束。

图 10-4 用流程图记录了这个循环。注意 whileif 条件都被标记为菱形。这是因为它们代表了决策点。

Image

图 10-4:密码 while 循环的流程图

每次执行循环都称为一次 迭代while 循环可以 无限次 迭代,因为循环的次数在开始时没有明确指定。while 循环可以执行一百万次,或者在第一次迭代后结束,这取决于其条件是否满足。

注意

如果你的程序进入了无限循环,可以使用 CTRL-C 停止程序并退出。

测试你的知识

  1. 每个新的代码块应该缩进 _____ 个空格。

  2. 判断对错:or 操作符仅在 两个 布尔值都为 True 时,才会使表达式的结果为 True

  3. 编写一个永不结束的 while 循环,然后使用 CTRL-C 停止它。

  4. 哪些值可以用来表示 False

a. 0

b. 0.0

c. F

d. 上述所有

5.  形成猪拉丁语的方法是:将一个以辅音开头的英语单词将辅音移到末尾,并在其后添加“ay”。如果单词以元音开头,则直接在末尾添加“way”。使用 Spyder 文本编辑器编写一个程序,接受一个单词作为输入,利用索引和切片返回其对应的猪拉丁语形式。保持程序运行,直到用户决定退出。

for 语句

for 语句让你能够执行固定次数的循环。这个次数通常通过内建的 range() 函数来指定。这个内存高效的函数返回一个从 0(默认值)开始,直到指定结束点之前的等距数字序列。以下是 range() 函数的语法:

range(start, stop, step).

startstep 参数是可选的。如果省略,start 参数默认值为 0step 默认值为 1

这是一个示例,演示了如何使用 for 循环和 range() 打印五个数字:

In [39]: for i in range(5):
    ...:      print(i)
0
1
2
3
4

请注意,5 没有包含在输出中。这是因为该函数读取直到停止值,但不包括它。for 语句下面的代码块是缩进的,就像在 while 循环中一样。

这是一个示例,展示如何使用 range() 中的所有三个参数(startstopstep),以打印从 1 到 6 之间的每个其他数字:

In [40]: for i in range(1, 6, 2):
    ...:      print(i)
1
3
5

请注意,在之前的代码中,i 是按照惯例在遍历数字范围时使用的变量名。任何合法的变量名(如 numnumber)都可以使用。如果你使用 linter 工具检查代码,建议使用下划线(_);否则,其他变量名有时会触发“未使用的变量”警告。

你可以将 range()len() 函数结合使用,来获取任意大小序列的结束点。以下是一个示例:

In [41]: my_list = ['a', 'b', 'c', 'd', 'e']

In [42]: for i in range(len(my_list)):
    ...:      print(my_list[i])
a
b
c
d
e

这段代码首先将字母列表赋值给 my_list 变量。在随后的 if 语句中,通过将 range() 函数与 len() 函数结合使用,设置了循环的迭代次数。每次迭代时,当前的 i 值被用作列表的索引,并打印出相应的字母。

尽管它能工作,但这段代码并不算非常 Pythonic。幸运的是,你可以直接在 for 语句中使用可迭代对象。记住,可迭代对象是那些可以一次返回一个元素的对象,包括像 rangelisttuplestringset 等序列类型。因此,你不需要跟踪可迭代对象的大小或使用一个递增的索引值。以下是使用这种格式的之前代码片段:

In [43]: for item in my_list:
    ...:      print(item)
a
b
c
d
e

请注意代码几乎像英语一样可读。没有比这更“Pythonic”的写法了!

注意

在遍历列表时,绝对不要添加或删除列表中的项。如果你想在循环过程中更改列表,应该将更改追加到一个新的列表中。

你甚至可以遍历一个字符串并打印其字符,而无需使用中间变量来保存字符串:

In [44]: for letter in "Python":
    ...:      print(letter)
P
y
t
h
o
n

如果你在循环过程中需要获取项的索引,最佳的解决方案是使用内置的enumerate()函数。该函数为可迭代对象中的每个项添加计数器,并返回一个enumerate对象,允许你在循环过程中追踪迭代次数。默认情况下,第一个索引值从 0 开始,但你可以通过指定起始索引来覆盖这一行为。下面的示例生成一个编号的项列表(从 1 开始),数据类型为list

In [45]: equipment_list = ['binoculars', 'rock hammer', 'hand lens']

In [46]: for index, item in enumerate(equipment_list, start=1):
    ...:      print(index, item)
1 binoculars
2 rock hammer
3 hand lens

enumerate()的其他用法包括从列表中选择每隔n项,循环n项后结束循环,以及在绘图时使用索引作为线条权重或符号大小。

循环控制语句

循环控制语句用于循环内部,改变正常的执行顺序。之前,你使用了break语句来中断并结束while循环。Python 还使用continuepass语句来控制循环。

break 语句

break关键字允许你随时退出循环的代码块。一种常见的用法是将while设置为Truewhile True:),然后在满足某个条件时“手动”退出循环。由于True始终为真,无法通过条件停止循环,因此必须使用break强制退出。

在嵌套循环中,break只终止其所在的代码块及其中的任何内部代码块。外部循环将继续执行。例如,下面的示例包含了两个嵌套循环:

In [47]: mythbusters = ['Kari', 'Grant', 'Tory']

In [48]: MYTHBUSTERS = ['Adam', 'Jamie']

In [49]: for star in mythbusters:
    ...:      for big_star in MYTHBUSTERS:
    ...:           if big_star == 'Adam':
    ...:                break
    ...:      print(star, big_star)
Kari Adam
Grant Adam
Tory Adam

即使break语句中断了内部的for循环并且阻止了“Jamie”的打印,外部循环仍然会继续执行直到完成。

continue 语句

continue关键字会立即将控制权返回到循环的起始位置,跳过其下方的任何循环语句。例如,下面的示例使用while循环来验证用户的用户名和密码。将代码输入到 Jupyter Qt 控制台中,然后按 SHIFT-ENTER 运行:

In [50]: while True:
    ...:      name = input('Enter your username: ')
    ...:      if name != 'Alice':
    ...:        ➊ continue
    ...:   ➋ pwd = input('Enter your password: ')
    ...:      if pwd == 'Star_Lord':
    ...:        ➌ break
    ...:      else:
    ...:           print('That password is incorrect')

首先,当然是获取正确的用户名。如果第一个if语句的条件为True(用户名不是“Alice”),则continue语句会中断循环并重新开始序列 ➊。如果用户名通过测试,continue语句会被跳过,接下来会提示用户输入密码 ➋。如果输入正确,break语句会结束循环 ➌。否则,用户会收到错误提示,循环重新开始。

pass 语句

你可以使用pass关键字来构建“空”循环,或者不执行任何操作的代码块。这些关键字作为占位符,预留将来填写的代码,或者标记你故意省略某些内容的位置。例如,在这个片段中,我们选择不打印Python中的h

In [51]: word = 'Python'
In [52]: count = 0
In [53]: while count < len(word):
    ...:      if count < 3:
    ...:           print(word[count])
    ...:   ➊ elif count == 3:
    ...:           pass
    ...:      else:
    ...:           print(word[count])
    ...:      count += 1
P
y
t
o
n

这段代码将“Python”赋值给了word变量,并将 0 赋值给了count变量。当count值小于word的长度时,循环会打印出word中索引为当前count值的每个字符。但是,当count等于3时,它使用pass关键字跳过当前循环。

你本可以通过只打印count < 3count > 3来完成相同的任务,但从回顾的角度看,这可能被视为一个索引错误(请参阅前面第In [10]-[11]行的核心温度示例)。通过包括对h的引用并使用pass ➊,可以明确看出你知道自己在做什么。你基本上是说,“我知道在这个索引处有一个字母,但我故意不打印它。”

用推导式替代循环

在 Python 中,推导式是一种通过现有序列构建新序列(如列表、集合或字典)的方法。例如,你可能想要创建一个新列表,其中包含另一个列表中数字的对数。作为标准for循环的替代,推导式更快、更优雅、也更简洁。

推导式的一个缺点是,你不能在循环中嵌入print()函数来帮助你跟踪发生了什么。当使用复杂的表达式时,它们也可能变得难以阅读,但对于简单的表达式来说,它们无可替代。Python 支持列表、字典、集合的推导式,还有我们将在下一章讨论的:生成器。

列表推导式

要使用列表推导式创建一个新列表,将推导式表达式放在方括号中。如果你想用条件从现有的可迭代对象中选择项目,可以使用以下语法:

new_list = [item for item in iterable if item satisfies condition]

如果你想在将这些项添加到新列表之前修改它们,或生成新的项,可以使用以下语法:

new_list = [expression for item in iterable if condition]

例如,这里有一个示例,它接受一个字符串,遍历字符串, 将每个字母大写,并将大写字母追加到一个新列表中:

In [54]: word = 'Python'

In [55]: letters = [letter.upper() for letter in word]

In [56]: letters
Out[56]: ['P', 'Y', 'T', 'H', 'O', 'N']

在这里,我们将一个字符串赋值给了word变量,然后使用列表推导式[letter.upper() for letter in word]创建了letters列表。注意语法略有不同,循环变量(letter)出现在定义之前。

以下示例提取了“Python”中的大写字母“P”。注意你可以在推导式中使用if语句:

In [57]: cap = [letter.upper() for letter in word if letter.isupper()]

In [58]: cap
Out[58]: ['P']

In [57]中的单行列表推导式等价于使用标准for循环的以下代码:

In [59]: cap = []

In [60]: for letter in word:
    ...:      if letter.isupper():
    ...:           cap.append(letter)

在这种情况下,列表推导式帮你节省了三行代码。

你也可以通过嵌套的for循环创建列表推导式,如下所示:

In [61]: first = ['Python is']

In [62]: last = ['fun', 'easy', 'neat'] In [63]: print([f + ' ' + l for f in first for l in last])
['Python is fun', 'Python is easy', 'Python is neat']

你可以在 docs.python.org/3/tutorial/datastructures.html 中看到更多的列表推导式示例。

字典推导式

字典推导式类似于列表推导式,不同之处在于它使用键值对(k, v)代替了项,使用大括号包裹,并返回一个字典而不是列表。

你可以在现有的字典上使用字典推导式,在这种情况下,你要么基于某些条件从现有字典中选择键值对(k, v),要么基于条件对键和值应用表达式。一般语法如下:

new_dict = {k:v for (k, v) in dictionary if k, v satisfy condition}

或者:

new_dict = {k-expr:v-expr for (k, v) in dictionary if condition}

当在另一种可迭代对象(如列表)上使用字典推导式时,你可以选择并/或改变现有的项,以便将其作为新字典中的键值对。语法有多种变化,但大致如下所示:

new_dict = {item:item-expr for item in iterable if condition}

注意,表达式可以改变用于键、值,或者两者的项。

这是一个例子,我们从列表中提取偶数,并将其映射到它们的平方:

In [64]: inputs = [1, 2, 3, 4, 5, 6]

In [65]: new_dict = {item:item**2 for item in inputs if item % 2 == 0}

In [66]: new_dict
Out[66]: {2: 4, 4: 16, 6: 36}

在这里,我们将两个元组(mineralhardness变量)压缩在一起,创建摩氏著名的矿物硬度标度的一部分:

In [67]: mineral = 'talc', 'gypsum', 'calcite'

In [68]: hardness = 1, 2, 3

In [69]: mohs = {m: h for (m, h) in zip(mineral, hardness)}

In [70]: mohs
Out[70]: {'talc': 1, 'gypsum': 2, 'calcite': 3}

注意,你也可以通过调用内置的dict()函数来生成mohs字典,如下所示:

In [71]: mohs = dict(zip(mineral, hardness))
集合推导式

集合推导式 用大括号括起来,并返回未排序的集合。你可以使用以下语法从可迭代对象中 选择 已有项:

new_set = {item for item in iterable if item satisfies condition}

或者,你可以通过在将项添加到新集合之前应用基于条件的表达式,来改变项或派生新项:

new_set = {expression for item in iterable if condition}

这是一个例子,它返回字符串中的所有唯一字符,考虑到集合不允许重复值(这与set()函数相同):

In [72]: pond_animals = ['turtle', 'duck', 'frog', 'turtle', 'snail', 'duck']

In [73]: unique_animals = {animal for animal in pond_animals}

In [74]: unique_animals
Out[74]: {'duck', 'frog', 'snail', 'turtle'}

这里我们使用一个表达式来计算单词的长度:

In [75]: unique_word_lengths = {len(word) for word in pond_animals}

In [76]: unique_word_lengths
Out[76]: {4, 5, 6}

处理异常

当 Python 在执行过程中遇到错误时,会发生 异常。这会导致它“抛出异常”,并产生一个代表错误的 Python 对象。如果不立即处理,异常会导致程序终止并显示错误消息。

幸运的是,异常可以通过流控制“捕获”并进行处理。这为你提供了修复问题、重新尝试、提供更有帮助的错误信息或抑制错误的机会。

使用 try 和 except

Python 提供了一个try语句和一个except子句来帮助你处理异常。try语句允许你隔离可能包含错误并导致程序崩溃的代码。如果代码中包含错误,except子句将通过提供代码来处理它,这些代码只有在异常被抛出时才会执行。

最简单的异常处理仅仅是防止你的程序毫无预警地崩溃。它会呈现一个警告并(希望)给出有帮助的消息。以下是一个处理用户输入错误的例子:

In [77]: try:
    ...:      age = int(input("Enter your age in years: "))
    ...: except:
    ...:      print("Please start again and enter a whole number.")

Enter your age in years: Harry
Please start again and enter a whole number.

在这个例子中,age的值被转换为整数,因为我们希望age是一个完整的年份数字。但是用户输入了字母(Harry),这引发了一个ValueError异常,因为字母不能转换为整数。然而,你不会看到这个错误信息,因为我们使用try语句捕获了异常,try语句的作用正如其名,它会单独尝试这段代码,给我们机会在程序崩溃之前采取措施。在这里,我们向用户打印了一条信息,以便程序能够相对优雅地结束。

在大多数情况下,你希望指定要处理的异常类型,而不是像我们在前一个代码片段中那样捕获所有内置异常。你可以在第 183 页的表 7-4 中找到异常类型的列表。你也可以在控制台中创建你需要的错误,并从结果的Traceback消息中读取异常名称。

让我们改写前面的代码片段来捕获ValueError。只需在except后面放上适当的异常名称,如下所示:

In [78]: try:
    ...:     age = int(input("Enter your age in years: "))
    ...: except ValueError:
    ...:     print("Please start again and enter a whole number.")

你还可以通过将多个异常类型放在except后面,作为一个元组(带有括号)来捕获多个异常类型:

In [79]: except (ValueError, TypeError):

如果你希望对每种错误类型使用不同的消息或采取不同的措施,只需在try语句中使用多个堆叠的except语句,格式如下:

try:
   something...
except ValueError:
   do something... except TypeError:
   do something else...

你还可以将 Python 的错误消息集成到你自定义的版本中。异常有参数,它们是 Python 描述发生了什么的官方消息。你可以通过在异常类型后面指定一个变量,并用as关键字来使用这个参数。以下是一个例子:

In [80]: try:
     ...:     age = int(input("Enter your age in years: "))
     ...: except ValueError as e:
     ...:      print(e)
     ...:      print("Please start again and enter a whole number.")

Enter your age in years: Steve
invalid literal for int() with base 10: "Steve"
Please start again and enter a whole number.

现在,你既能享受 Python 精确但技术性的解释,又能得到非程序员更友好的指示。

最后,你可以在所有except语句的末尾添加一个else子句。如果在try块中没有引发异常,你可以做一些操作,例如确认操作成功:

In [81]: try:
    ...:     age = int(input("Enter your age in years: "))
    ...: except ValueError as e:
    ...:     print(f'\n{e}')
    ...:     print("Please start again and enter a whole number.")
    ...: else:
    ...:     print(f"You entered an age of {age} years")

Enter your age in years: 42
You entered an age of 42 years

使用 raise 关键字强制抛出异常

Python 的raise关键字允许你在发生某个条件时强制抛出指定的异常。你可以使用它来抛出内置的错误类型或你自己的自定义错误。它对于验证输入尤其有用,例如强制使用最大值,或者在处理正数时处理负数输入。

要查看如何创建你自己的自定义错误,请在 Spyder 的文本编辑器中输入以下内容并保存为任何你喜欢的文件名:

word = input("Enter Harry's last name: ")
if word.lower()!= 'potter':
    raise Exception('I was looking for Potter!')

在这里,你接受用户输入的名字,将其转换为小写,并与“potter”进行比较。如果名字不匹配,你使用raise关键字并调用Exception类,传递一个自定义消息。

现在,运行文件并在提示时输入“Houdini”。你应该在控制台看到类似的输出(为简洁起见,这里截断了一部分):

Enter Harry's last name: Houdini
Traceback (most recent call last):

--snip--

  File "C:/Users/hanna/file_play/junk.py", line 1, in <module>
    raise Exception('I was looking for Potter!')

Exception: I was looking for Potter!

要强制 Python 抛出一个内置异常,替换你之前使用的Exception类,使用内置异常类的名称(参见表 7-4)。在这个例子中,我们抛出 Python 内置的TypeError异常:

number = 'Steve'
if isinstance(number, int):
    pass
else:
    raise TypeError("Only integers are accepted.")

你可以在docs.python.org/3/tutorial/errors.html上阅读更多关于raise的内容。

忽略错误

如果你想在循环时忽略错误该怎么办?例如,假设你使用 Python 的None关键字来定义数据集中缺失或空的样本值(data):

In [82]: data = [24, 42, 5, 26, None, 101]

你不希望从数据集中剔除这个占位符,因为它包含了有价值的信息。它能告诉你数据集是不完整的,以及缺失数据的位置。但是,如果你尝试遍历数据并对其进行某些操作,比如将每个值除以 2,None值会抛出TypeError并导致程序崩溃:

In [83]: for sample in data:
    ...:     print(f'{sample / 2}’)
12.0
21.0
2.5
13.0
Traceback (most recent call last):

File "C:\Users\hanna\AppData\Local\Temp/ipykernel_5140/163932511.py", line 2, in <module> print(f’{sample / 2}’)

TypeError: unsupported operand type(s) for /: 'NoneType' and 'int'

我们可以结合多个流程控制元素来处理这些缺失数据。在这里,我们在for循环内使用了try语句和except子句:

In [84]: for sample in data:
    ...:     try:
    ...:         x = sample / 2
    ...:         print(x)
    ...:     except TypeError:
    ...:         print("missing data")
12.0
21.0
2.5
13.0
missing data
50.5

现在,循环已完成执行并标记了缺失数据的位置。

然而,如果你想完全跳过缺失数据呢?例如,你想将输出传递给其他数学运算,而“缺失数据”值会干扰这些运算?在这种情况下,使用continue语句,如下所示:

In [85]: for sample in data:
    ...:     try:
    ...:         x = sample / 2
    ...:         print(x)
    ...:      except TypeError:
    ...:         continue
12.0
21.0
2.5
13.0
50.5

现在,循环会将缺失值当作不存在的情况处理,因为它遇到该值时会继续循环。记住,continue会立即将控制权返回到循环的起始位置。

通过日志追踪执行

为了控制程序的流程,你需要知道程序在关键位置返回了什么。跟踪这个信息的一种方式是使用print()函数。这可以让你打印输出、变量的数据类型或其他关于某个重要步骤的有用信息。

print()函数对于小型程序来说效果很好,但如果你只是用它来进行代码的质量控制,它可能会带来一些问题。为了简化代码和输出,你可能需要稍后删除所有包含print()的行,或者将它们注释掉(在行首加上#),以避免它们被执行。

对于大型程序,更好的选择是使用logging模块。这个模块是 Python 标准库的一部分,可以提供自定义报告,记录程序在你选择的任何位置所做的事情。五个日志级别可以让你按重要性对消息进行分类。它们在表 10-3 中列出。

表 10-3: Python 的日志级别

级别 功能 描述
DEBUG logging.debug() 诊断问题的详细信息
INFO logging.info() 确认一切按预期正常工作
WARNING logging.warning() 工作代码中的意外事件或潜在的未来问题
ERROR logging.error() 错误阻止代码按预期功能运行
CRITICAL logging.critical() 一个可能会导致程序停止的严重错误

需要记录日志的大型程序在控制台中编写较为困难,因此请将以下示例输入到 Spyder 的文本编辑器中,并保存为类似 logging.py 的文件名。此代码使用 logging 检查一个元音计数程序是否正确运行:

   import logging
➊ logging.basicConfig(level=logging.DEBUG,
                       format='%(asctime)s %(levelname)s - %(message)s')
   word = 'scarecrow'
   VOWELS = 'aeiouy'
   num_vowels = 0

   for letter in word:
       if letter in VOWELS:
           num_vowels += 1
    ➋ logging.debug('letter & vowel count = %s-%s', letter, num_vowels)

保存文件并按 F5(或点击 运行 按钮)执行代码。你应该看到以下通用输出:

In [86]: runfile(′C:/Users/hanna/.spyder-py3/temp.py′, wdir=′C:/Users/hanna/.spyder-py3′)
202x-09-27 14:37:30,578 DEBUG - letter & vowel count = s-0
202x-09-27 14:37:30,580 DEBUG - letter & vowel count = c-0
202x-09-27 14:37:30,581 DEBUG - letter & vowel count = a-1
202x-09-27 14:37:30,581 DEBUG - letter & vowel count = r-1
202x-09-27 14:37:30,582 DEBUG - letter & vowel count = e-2
202x-09-27 14:37:30,582 DEBUG - letter & vowel count = c-2
202x-09-27 14:37:30,582 DEBUG - letter & vowel count = r-2
202x-09-27 14:37:30,583 DEBUG - letter & vowel count = o-3
202x-09-27 14:37:30,583 DEBUG - letter & vowel count = w-3

我们来看看你做了什么。在导入模块后,你使用 basicConfig() 方法设置并格式化了你想看到的调试信息 ➊。DEBUG 级别是最低的级别,用于诊断细节。添加时间戳(%(asctime)s)在这里不是必需的,但在调试长时间运行的程序时,它可能变得非常重要。

在设置好要计数的单词、元音常量和计数变量后,你启动了一个 for 循环,遍历单词中的字母并将每个字母与 VOWELS 的内容进行比较。如果字母匹配,则将 num_vowels 计数器加 1

对于每个评估的字母,你使用 logging.debug() 输入自定义文本消息,并显示当前计数 ➋。logging 输出会显示在控制台中。你可以看到时间戳、日志级别以及累积的元音计数,还可以看到哪些字母更改了计数。在这种情况下,只有元音字母会更改计数,所以程序看起来运行正常。

你可以将日志消息重定向到一个永久的文本文件中,而不是在屏幕上显示。只需在 logging.basicConfig() 函数中使用 filename 关键字,示例如下:

logging.basicConfig(filename='vowel_counter_log.txt',
                    level=logging.DEBUG,
                    format='%(asctime)s %(levelname)s - %(message)s')

如代码所示,这段代码会将日志文件保存在与你的 Python 文件相同的文件夹中。若要将其保存在其他地方,你需要指定路径。

print() 函数和 logging 都可能减慢程序的运行。然而,禁用 logging 消息更为简便。通过使用 logging.disable() 函数,你可以用一行代码关闭某一级别的所有消息,示例如下:

import logging
logging.disable(logging.CRITICAL)

logging.disable() 放在程序顶部、导入语句下方,可以轻松找到它并通过注释掉哈希标记来切换消息的开启和关闭,示例如下:

import logging
#logging.disable(logging.CRITICAL)

logging.disable() 方法将压制所有指定级别及更低级别的消息。由于 CRITICAL 是最高级别,因此你可以使用它来禁用所有级别的消息。这比找到并删除(或注释掉)多个 print() 调用要容易得多。

欲了解更多有关 logging 模块的详细信息,请查看文档 docs.python.org/3/library/logging.html。若要获取基本教程,请访问 docs.python.org/3/howto/logging.html

测试你的知识

编写一个代码片段,要求用户输入用户名和密码。如果用户名不正确,继续提示直到输入正确。如果只有密码不正确,继续提示输入正确的密码,但不再重复请求用户名。

for 循环只是 while 循环的简洁版本。写一个 while 循环,模拟 for 循环的行为,并打印出“Python”五次。

使用列表推导式生成 1 到 10 之间的所有偶数

使用 for 循环和 range() 函数打印一个类似 NASA 风格的倒计时,从 10 到 0。

一个隐藏的秘密信息位于以下单词的中心:“age”,“moody”,“knock”,“adder”,“project”,“stoop”,“blubber”。使用 for 循环找到并打印出这个信息。

使用文本编辑器编写一个“猜数字”游戏,程序随机选择一个介于 1 到 100 之间的整数(使用 random.randint()),并告诉玩家他们的猜测是太高还是太低,直到猜对为止。告知玩家他们获胜,并告诉他们猜了多少次。

使用文本编辑器编写一个“幸运饼干”程序,给用户提供三个选项:退出、打开幸运饼干或打开不幸饼干。列出正面的幸运语和幽默的“不幸”语句,并使用 random 模块的 choice() 函数从每个列表中随机选择。将结果打印到屏幕上。

总结

编程的魔力在于程序在执行过程中能够做出决策。这些决策通过评估为 TrueFalse 的条件来实现。通过使用比较和布尔运算符与条件语句(如 ifelifelse),你可以控制代码执行的内容和时机。

缩进(空白字符)用于将代码分隔成功能相似的片段,称为 代码块。缩进级别告诉 Python 何时开始和结束代码块。这有助于你控制程序执行的流程。

while 循环会使代码重复执行,直到满足某个条件。而 for 循环则会执行指定次数,或者直到它耗尽容器数据类型中的项,例如列表。两种类型的循环都可以通过 break 语句手动中断,或者使用 continue 语句强制跳回开始。

for 循环可以通过推导式简化为一行代码。你可以在列表、集合和字典中使用推导式。对于简单的表达式,推导式不仅比 for 循环更加简洁,而且速度更快。

因为错误可能会影响代码的流程,Python 提供了带有 except 子句的 try 语句,帮助你通过抑制错误、修复错误、创建自定义错误信息或让用户重试来处理错误。为了帮助你找到和调试错误以及其他问题,Python 提供了 logging 模块。与 print() 函数相比,logging 是一种更复杂且易于管理的方式,适用于监控大型程序的执行流程。

控制流程的另一种方式是编写函数。我们将在下一章中学习这些重要的“迷你程序”。

第十一章:函数和模块

image

函数是一个可重用的指令集,用于执行特定任务。当函数完成任务后,执行流程会返回到更大代码结构中的正确位置。模块是程序,通常由多个函数组成,用来执行某一任务或一组相关任务。你可以在代码中定义函数,但必须导入模块才能在 Python 程序中使用它们。

函数和模块都通过抽象化过程来简化代码。抽象化是将某个过程的细节移动到一个看似更简单的对象中,远离主要的执行流程。稍后,你可以通过一行代码调用该对象的名称来完成任务。

最好的函数和模块名称应该简短且富有描述性。它们可以让你快速浏览程序的主要部分,了解程序的执行流程,就像是在阅读摘要一样。一个好的类比是本书的目录。尽管实际章节中隐藏了大量的细节,但标题和小节标题让你大致了解每一章的内容。

在前几章中,你导入了像mathos这样的模块,并使用了内置函数如print()input()。它们的代码被抽象化到了你从未看到的程度。你只需调用一个函数,然后就会发生某些事情。然而,也有时候,预先构建的解决方案要么不可用,要么不足以满足需求,这时你需要自己创建一个函数。

通过编写自己的函数来重用代码单元,你可以创建更加可读、组织更好、冗余更少的程序。在本章中,你将编写自定义函数和模块,并熟悉一些额外的内置函数和第三方模块,它们能让你的编程生活更轻松。

定义函数

要在 Python 中编写一个函数,你需要使用def关键字定义它,后面跟上函数名、括号和冒号。与往常一样,冒号后面的代码必须缩进,缩进的行表示可执行的代码。以下是在 Spyder IDE 的 IPython 控制台中的示例:

In [1]: def warning():
   ...:     print("WARNING: Converting units from metric to Imperial!")

注意

在控制台中,你可以通过按两次 ENTER 键或使用 SHIFT-ENTER 键来完成一个函数。在编辑器中,当代码块回到与def关键字相同的缩进级别时,函数的代码块就结束了。

你现在已经将警告信息封装在warning()函数中。要再次使用该信息,只需通过输入函数名和括号来调用函数即可。这可以避免你一遍又一遍地输入完整的消息:

In [2]: warning()
WARNING: Converting units from metric to Imperial!

在函数名中,括号(),有时被称为调用操作符,让 Python 知道一个对象可以被调用,这是“执行此命令”的一种高级说法。

和 Python 中的其他一切一样,函数也是对象。它们属于function数据类型:

In [3]: type(warning)
Out[3]: function

你可以将函数赋值给变量,在其他函数中使用它们,在其他函数中定义它们,将它们作为返回值从其他函数返回,甚至将它们存储在数据结构中(例如,作为列表中的项)。

根据 Python 的 PEP 8 风格指南 (pep8.org/),你应该用两行空白行将顶层函数(即缩进级别为 0 的函数)包围。在函数内部,你应该适量使用空白行来表示逻辑部分。

使用参数和实参

你可以向函数提交或传递输入,执行某些操作后,再输出或返回结果。为此,你可以在括号内使用参数和实参,并用逗号分隔它们。

参数 是函数定义时使用的特殊变量,当调用函数时,它们接收一个值。它们指的是作为输入提供的数据片段,而不是数据本身。例如,以下代码定义了一个函数,该函数通过传入质量和加速度参数,使用著名的方程 F=MA 来计算力的值:

In [4]: def calc_force(mass, acceleration):
   ...:      return mass * acceleration

实参 是在调用函数时传入的实际数据值。例如,你可以使用以下参数调用 calc_force() 函数:

In [5]: calc_force(15000, 9.78033)
Out[5]: 146704.94999999998

图 11-1 识别了 calc_force() 函数定义中的参数以及调用时传入的实参。

图片

图 11-1:函数定义使用参数,函数调用使用实参

calc_force() 这样返回值的函数被称为 有返回值的函数。执行某个操作但不返回值的函数被称为 无返回值的函数。前一节中的 warning() 函数就是一个无返回值的函数示例。

对于有返回值的函数,return 语句会使得执行退出函数并在调用该函数的代码点后立即恢复执行,这个位置被称为函数的 返回地址。在 return 关键字之后同一行列出的值会传回调用函数的代码。例如,对于 calc_force() 函数,这将是 Out[5] 行的值。

return 关键字总是结束一个函数,防止函数内的任何后续代码继续执行。

注意

从技术上讲,所有函数都需要返回一个值。空函数通过自动返回 Python 的空值 None 来满足这个要求,None 属于 NoneType 数据类型。

位置参数与关键字参数

函数的参数可以分为两种类型:位置参数关键字参数。位置参数必须按照函数定义中参数的顺序输入。正如 图 11-1 所示,calc_force() 函数使用位置参数,第一个传入的参数对应质量,第二个对应加速度。

关键字(或命名)参数包括一个关键字和等号,后面跟着提交的值。它们用于增加清晰度并明确函数的意图。下面是如何使用关键字调用calc_force()函数:

In [6]: calc_force(mass=15000, acceleration=9.78)
Out[6]: 146700.0

注意

根据 Python 风格指南,关键字参数中的等号两侧不应有空格。

关键字参数的另一个优点是你不需要记住定义参数时的顺序。在这里,我们按相反的顺序输入参数:

In [7]: calc_force(acceleration=9.78, mass=15000)
Out[7]: 146700.0

你可以在调用函数时同时输入位置参数和关键字参数。然而,一旦使用了关键字参数,就不能在同一个函数调用中再使用位置参数。因此,这段代码是有效的:

In [8]: calc_force(15000, acceleration=9.78)
Out[8]: 146700.0

但是这段代码失败了:

In [9]: calc_force(mass=15000, 9.78)

File "C:\Users\hanna\AppData\Local\Temp/ipykernel_3212/3649549750.py", line 1
calc_force(mass=15000, 9.78)
^
SyntaxError: positional argument follows keyword argument

你可以通过在定义函数时将星号(*)作为第一个参数来强制使用关键字参数:

In [10]: def calc_force(*, mass, acceleration):
    ...:      return(mass * acceleration)

现在,如果你尝试使用位置参数,Python 将抛出异常并告知你不接受位置参数:

In [11]: calc_force(15000, 9.78)
Traceback (most recent call last):

File "C:\Users\hanna\AppData\Local\Temp/ipykernel_3212/2133932729.py", line 1, in <module>
calc_force(15000, 9.78)

TypeError: calc_force() takes 0 positional arguments but 2 were given

使用默认值

你可以为一个或多个参数指定默认值。这让你在参数通常使用特定值时简化函数调用。如果用户不确定要输入什么,它还可以引导用户选择一个可接受的值。

默认参数应放在所有非默认参数之后。下面是一个示例,当用户按下 ENTER 键而没有回应提示问题时,函数会使用默认值:

In [12]: def default_input(prompt, default=None):
    ...:   ➊ prompt = f'{prompt} [{default}]:'
    ...:      response = input(prompt)
    ...:   ➋ if not response and default:
    ...:           return default
    ...:      else:
    ...:           return response

这个函数接受一个提示和一个默认值作为参数。调用函数时会指定提示和默认值,程序会在方括号中显示默认值➊。response变量存储用户的输入。如果用户什么都不输入且存在默认值,则返回默认值➋。否则,返回用户的响应。

让我们使用这个函数来获取用户的出生国家。对于目前在美国的用户,我们将默认值设置为“USA”,这样他们只需按下 ENTER 键,而不需要输入名称。注意这个默认值如何让你控制响应,尤其是当有多个可能的选项时(比如“America”、“United States”、“United States of America”、“US”等等):

In [13]: birth_country = default_input('Enter your country of birth:', 'USA')
Enter your country of birth: [USA]:

In [14]: birth_country
Out[14]: 'USA'

用户可以通过输入一个响应来覆盖默认值:

In [15]: birth_country = default_input('Enter your country of birth:', 'USA')

Enter your country of birth: [USA]: Scotland

In [16]: birth_country
Out[16]: 'Scotland'

在大多数情况下,你会希望避免在 Python 中使用像字典、集合或列表这样的可变对象作为默认参数值。这是因为默认的可变对象仅在函数定义时初始化一次,而不是每次函数被调用时初始化。这可能会导致意想不到的输出。下面是一个例子:

In [17]: def dog_breeds(new, current=['bulldog', 'dachshund']):
    ...:     current.append(new)
    ...:     return current

In [18]: my_dogs = dog_breeds('pomeranian')

In [19]: my_dogs
Out[19]: ['bulldog', 'dachshund', 'pomeranian']

In [20]: your_dogs = dog_breeds('poodle')

In [21]: his_dogs = dog_breeds('mutt')

In [22]: his_dogs
Out[22]: ['bulldog', 'dachshund', 'pomeranian', 'poodle', 'mutt']

这里的天真期望是,每个调用dog_breeds()函数的人都会从一只斗牛犬和腊肠犬开始,然后将他们的狗品种添加到这个列表中。但是,由于current列表是在函数定义时创建的(在第In [17]行),每次调用该函数时都会向这个相同的列表添加项目。

返回值

当函数返回一个值时,你可以通过赋值语句将结果存储到一个变量中。例如,下面的代码将从运行calc_force()函数返回的值存储到名为force的变量中:

In [23]: force = calc_force(15000, 9.78033)

In [24]: force
Out[24]: 146704.94999999998

你甚至可以返回多个值,用逗号分隔。你需要一个变量来存储每个值,如这个例子所示,函数接受一个数字作为参数,并返回该数字的平方和立方:

In [25]: def square_and_cube(a_number):
    ...:      return a_number**2, a_number**3

In [26]: squared, cubed = square_and_cube(2)

In [27]: squared, cubed
Out[27]: (4, 8)

最后,函数可以包含多个return语句。每个语句在特定条件下执行,一旦某个语句执行完毕,函数就结束。试试在控制台中执行这个:

In [28]: def goldilocks(a_number):
    ...:      num = int(a_number)
    ...:      if num > 42:
    ...:           return "too high"
    ...:      elif num < 42:
    ...:           return "too low"
    ...:      else:
    ...:           return "just right!"

In [29]: goldilocks(43)
Out[29]: 'too high'

In [30]: goldilocks(41)
Out[30]: 'too low'

In [31]: goldilocks(42)
Out[31]: 'just right!'

在这个例子中,goldilocks()函数接受一个数字作为参数,将其转换为整数,然后与42进行比较。三种可能的结果(大于、小于或等于)各自有一个return语句。

命名函数

命名函数的规则与命名变量的规则相同(参见第 206 页的“命名变量”)。你可以使用字母、下划线和数字,只要首字符不是数字。所有字符应为小写,并且应使用下划线分隔单词。你应避免使用保留字和内置函数的名称。

因为函数执行一个动作,所以好的命名策略是包含描述该动作的动词和名词。一些例子是reset_password()register_image()plot_light_curve()

关于命名和定义函数的更多信息,请访问文档中的docs.python.org/3/tutorial/controlflow.html#defining-functions/

内置函数

Python 提供了多个内置函数,让你的编码生活更加轻松。你已经使用过许多这些函数,包括print()len()type()list()input()round()等。

表 11-1 列出了更常用的内置函数。要查看完整列表以及每个函数的详细描述,请访问docs.python.org/3/library/functions.html

表 11-1: 常用的内置函数

函数 描述
abs() 返回数字的绝对值。
all() 如果可迭代对象的所有元素都为真,或者可迭代对象为空,返回True
any() 如果可迭代对象的任一元素为真,或者可迭代对象为空,返回True
chr() 返回表示输入的 Unicode 代码点的字符串(chr(97)返回'a')。
dict() 创建一个新的字典对象
dir() 不带参数时,返回当前作用域中的名称。如果传入一个对象作为参数,返回该对象的属性和方法列表。
enumerate() 为可迭代对象中的每个项添加一个计数器,并返回一个枚举对象。
filter() 返回一个迭代器,包含那些函数返回True的可迭代元素。
float() 返回由数字或字符串构建的浮动数值。
frozenset() 返回一个frozenset对象。
hash() 返回对象的哈希值(如果有的话)。
help() 调用内置帮助系统(适用于交互式使用)。
hex() 将整数转换为小写十六进制字符串,并以“0x”开头。
id() 返回一个对象的标识。
input() 获取用户输入并将其作为字符串返回。
int() 返回由数字或字符串构建的整数。
isinstance() 如果指定的对象是指定类型的实例,则返回True,否则返回False
len() 返回序列或集合中的元素数量(例如字符串、列表或集合)。
list() 创建一个新的list对象。
max() 返回可迭代对象中的最大项,或两个或更多参数中的最大值。
min() 返回可迭代对象中的最小项,或两个或更多参数中的最小值。
next() 从迭代器中获取下一个元素。
open() 打开一个文件并返回相应的file对象。
ord() 返回字符的 Unicode 码点(ord('a') 返回 97)。
pow() 返回指定幂次的数值。
print() 将指定的消息打印到屏幕或其他标准输出设备。
range() 生成一个不可变的数字序列,用于指定的起始和停止整数。
repr() 返回包含对象可打印表示形式的字符串。
reversed() 返回一个反向迭代器。
round() 返回四舍五入到 n 位小数的数值。
set() 创建一个新的set对象。
sorted() 从可迭代对象的元素中返回一个新的排序列表(正序或倒序)。
str() 返回对象的字符串版本。
sum() 返回可迭代对象中所有项的和。
tuple() 创建一个新的tuple对象。
type() 返回一个对象的类型。
zip() 并行遍历多个可迭代对象,生成每个元素的元组。

最好在自己编写代码之前,先检查是否有内置函数可以完成特定任务。

测试你的知识

  1. 当你调用一个需要输入的函数时,你传递给它的是:

a. 参数

b. 对象

c. 参数

d. def 关键字

  1. 理想情况下,一个函数名称应当包含以下两者:

a. 名词和下划线

b. 动词和下划线

c. 动词和名词

d. 数字和下划线

  1. 一个不返回值的函数称为:

a. 有效的函数

b. 无返回值的函数

c. 警告函数

d. 模块

  1. 编写一个函数,接受用户的名字并返回去除元音字母后的名字。你需要创建一个元音字母的字符串,遍历名字中的每个字母,并将每个字母与元音字符串中的内容进行比较。

5.  编写一个仅使用关键字参数的函数来计算动量(质量 * 速度)。

函数与执行流程

类似于条件语句和循环,函数可以导致代码分支或跳转。在下面的示例中,我们定义了两个函数,并在第二个函数内部调用第一个函数:

In [32]: def success():
    ...:      print("You found the number 3!")   

In [33]: def find_3():
    ...:      for i in range(6):
    ...:           if i == 3:
    ...:                success()

In [34]: find_3()
You found the number 3!

当你调用find_3()函数时,执行流程会进入该函数。但该函数并不是返回一个值并将控制权交回主程序,而是调用了另一个函数,理论上该函数可以调用代码中更高位置定义的其他函数。

这两个函数的定义不需要按顺序进行,也可以通过其他代码进行分隔,只要对这些函数的调用发生在它们定义之后。在 Spyder 主菜单中,点击控制台重启内核,然后输入以下代码,这次将find()定义放在success()之前,中间有其他代码:

In [35]: def find_3():
    ...:     for i in range(6):
    ...:         if i == 3:
    ...:             success()

In [36]: print("Here's some other code...")
Here's some other code...

In [37]: print("Here's some more code...")
Here's some more code...

In [38]: def success():
    ...:      print("You found the number 3!")

In [39]: find_3()
You found the number 3!

如你所见,定义这两个函数的顺序并不重要;重要的是你在它们定义之后才调用了find_3()

使用命名空间和作用域

命名空间是名称的集合。在幕后,Python 使用命名空间将名称映射到内存中的相应对象。这使得 Python 能够跟踪当前正在使用的所有名称,并防止冲突,即两个不同的对象共享相同的名称。

不同的、独立的命名空间,称为作用域,可以在单个程序中同时存在。当你在控制台或文本编辑器中开始编写程序时,你位于全局作用域中,所有的对象名称共享同一个命名空间。每次你定义一个函数时,你进入该函数的局部作用域,所有在函数中使用的名称都会共享一个新的命名空间,这个命名空间对全局作用域和其他函数的局部作用域都是隐藏的。因此,在一个函数中使用与另一个函数或全局作用域中的主程序相同的对象名称是可能的(见图 11-2)。

Image

图 11-2:这个程序有一个全局作用域(灰色)和两个函数中的独立局部作用域(白色)。

让我们来看一下作用域行为的实际应用。请在控制台中输入以下内容:

In [40]: x = 42

In [41]: print(x)
42

In [42]: def local_scope():
    ...:     x = 5
    ...:     print(x)

In [43]: local_scope()
5

In [44]: print(x)
42

在前面的代码片段中,你使用了相同的变量名(x)两次,而没有问题。这是因为第一个x在全局作用域中,而第二个x安全地存放在函数的局部作用域中。按照当前的写法,全局作用域无法访问局部作用域中的x。因此,当你在In [44]行打印x时,你得到的是全局作用域中的值,尽管x在函数中看似已被重新赋值为5。函数终止后,Python 会“忘记”所有局部变量,因此不会发生名称冲突。

使用作用域来划分代码也有助于调试。因为函数只能通过它们传递的参数和返回的值与程序的其他部分进行交互,所以更容易追踪错误值的来源。

使用全局变量

在全局作用域中赋值的任何变量对全局和局部作用域都是可见的。为了表明你在访问全局变量而不是将其作为参数传递给函数,并使其完全在函数中可用,你必须使用global 语句将其指定为函数中的全局变量,如下所示:

In [45]: x = 42

In [46]: def local_scope():
    ...:      global x
    ...:      x = 5
    ...:      print(x)

In [47]: local_scope()
5

In [48]: print(x)
5

通过在local_scope()函数的定义中添加global x这一行,你为该函数提供了访问全局作用域中x变量的权限。现在,当你在函数中更改x的值时,这一变化将在全局作用域中反映出来,打印x时返回的是5,而不是之前的42

注意

全局空间中的变量如果是可变对象,可以在函数内进行修改,而无需使用 global 语句。

由于在函数的局部作用域中可以使用全局变量,因此应避免在局部变量和全局变量中使用相同的名称。同样,应该避免在局部作用域中使用相同的变量名。虽然不可能在全局范围或其他函数之间共享局部变量,但这可能会造成混淆。即使它们从不交互,通常也不建议为两个不同的东西使用相同的名称。

通常不建议使用全局变量,尤其是在大型和复杂的程序中。试想,如果你有数百行代码和几十个函数,其中一个函数由于错误或逻辑失败将全局变量的值更改为错误的值。为了找到并修复这个问题,你必须遍历整个程序,而不是专注于单个函数或函数调用。

注意

“不要使用全局变量”规则的例外是全局常量。将常量值赋值到程序顶部的全局作用域是可以的。因为常量的值不应该变化,所以它们不应增加代码的复杂性。

使用 main()函数

除了短小简单的程序外,通常将程序的主要代码封装到一个名为main()的函数中。这段代码通过执行表达式和语句、调用函数来运行程序的其余部分。将其从全局作用域中移除使得查找和管理更为方便。

你可以在程序中的任何位置定义main()函数,但通常它位于程序的开始或结束。如果你的代码和函数名称非常易读,将main()放在程序的开始部分可以很好地概括程序的功能。

以下是一个使用main()函数计算一些统计数据的程序。在 Spyder 文本编辑器中输入以下内容,并将其保存为main_function_example.py

from random import uniform

def main():
    data = generate_data()
    print(f"data = {data}")
    calc_mean(data)
    calc_max_value(data)
    calc_min_value(data)

def generate_data():
    samples = []
    for _ in range(10):
     ➊ sample = round(uniform(0.0, 50.0), 1)
        samples.append(sample)
    return samples

def calc_mean(data):
    print(f"\nMean = {round(sum(data) / len(data), 1)}")

def calc_max_value(data):
    print(f"Max = {max(data)}")

def calc_min_value(data):
    print(f"Min = {min(data)}")

main()

在这段代码中,我们首先从random模块导入uniform()方法,以便生成随机浮动值作为数据(在实际应用中,你会加载或输入一些数据到程序中)。接下来,我们定义了main()函数。就这个案例而言,函数的作用仅仅是调用其他函数。注意它的结构就像是对程序功能的总结。

下一个函数generate_data()返回一个包含 10 个随机浮动值的列表,这些值四舍五入到一个小数位,取自均匀分布。要使用uniform方法,需要传入你想要的范围的起始和结束值,在这个例子中是 0.0 和 50.0➊。接下来的三个函数将接受这个列表作为输入(一个参数),分别返回均值、最大值和最小值。

到此为止,你只定义了函数。如果你希望程序做一些事情,执行之前需要调用main()函数。

对于这种简单的代码,你可以不使用main()函数,而是将其内容移到全局作用域中,被调用函数的定义下方。但是,随着代码变得更长、更复杂,main()函数将帮助你保持代码清晰和有序,并让你容易找到并回顾程序的功能。

高级函数主题

到目前为止,你已经掌握了足够的函数知识,能够解决大部分(如果不是全部)你将遇到的编程问题。然而,总有更多的内容等待学习。本节将简要介绍递归、函数设计、lambda 函数和生成器。递归是一个特别具有挑战性的主题,如果你觉得它有趣或有用,我建议你自己深入阅读。

递归

递归是一种强大的编程技巧,其中一个函数会调用自身。虽然递归可以通过更高效的forwhile循环来实现,但这些循环有时会变得复杂和凌乱。

对于复杂的问题,递归函数可以提供一种更简单、更易读的方式来构建代码。你会经常看到递归被用来解决阶乘问题、查找斐波那契数列中的数字,或者计算贷款的复利利息,使用额外的数据,例如定期付款。

这里是一个简单的递归函数示例,名为beer()。注意elifelse语句中都包含了对beer()函数的调用。

In [49]: def beer(bottles):
    ...:   ➊ if bottles <= 0:
    ...:           print("No more bottles of beer on the wall!")
    ...:      elif bottles == 1:
    ...:           print(f"{bottles} bottle of beer on the wall!")
    ...:           beer(bottles - 1)
    ...:      else:
    ...:           print(f"{bottles} bottles of beer on the wall!")
    ...:           beer(bottles - 1)

In [50]: beer(3)
3 bottles of beer on the wall!
2 bottles of beer on the wall!
1 bottle of beer on the wall!
No more bottles of beer on the wall!

这个函数的灵感来源于著名的《99 瓶啤酒》歌曲。它接受一个数字——代表啤酒瓶数——作为参数,然后更新剩余瓶数并再次调用自身,直到瓶数达到零。中间的elif语句仅用于在剩下最后一瓶时修正语法。

if语句➊ 包括对beer()的递归调用,因为这是基本条件,或基例,用于函数的结束。当满足条件时,基本条件会终止函数。

你需要一个基本情况,因为递归就像while循环一样,可能会一直持续下去。要查看一个示例,可以在控制台输入以下内容:

In [51]: def keep_on_keeping_on():
    ...:      print("Somebody stop me!")
    ...:      keep_on_keeping_on()

In [52]: keep_on_keeping_on()

这个示例将引发以下异常:

RecursionError: maximum recursion depth exceeded while calling a Python object

因为keep_on_keeping_on()函数不断调用自身,造成了无限递归,导致了栈溢出。这个错误发生在你尝试向一个内存块写入超出其容量的数据时。如果包含一个可达的基本情况,可以避免这种情况发生,但如果它允许过多的递归调用,则无法阻止。

为了防止无限递归,Python 解释器限制了递归深度;也就是说,限制了对一个函数的递归调用次数,默认为一个值。要查看这个值,可以在控制台中使用系统模块(sys),如下所示:

In [53]: import sys

In [54]: print(sys.getrecursionlimit())
3000

虽然你可以通过传递一个整数给sys.setrecursionlimit()函数来增加递归限制,但需要小心这样做,因为最高可能的限制取决于平台,而过高的限制仍然可能导致崩溃。更好的选择是重写你的代码,避免使用递归。

注意

实际的递归限制通常比sys.getrecursionlimit()返回的值略小。在我的机器上,尽管限制设置为 3,000,但在进行了 2,967 次调用后仍会引发 RecursionError。

设计函数

在编写函数时,有一种观点认为一个函数“应该只做一件事,且只做这件事”。虽然保持函数简短和简单是一个好的准则,但在许多情况下,较长、更复杂的函数反而是更好的选择。

较长的函数可以将相关任务合并到一个函数中,同时减少代码的总行数。因此,向函数添加一些局部复杂性可以减少程序的全局复杂性。

尽管如此,在编写函数时,保持“只做一件事”的准则仍然是一个好主意。这里有一个简单的示例,涉及嵌入式print()函数:

In [55]: def area_of_square(side_length):
    ...:      area = side_length**2
    ...:      print(f"Area is {area}")
    ...:      return area

In [56]: area_of_square(50)
Area is 2500
Out[56]: 2500

如果这个函数作为程序中的一个中间步骤使用——也就是说,如果你只是计算一个面积并将其传递给另一个函数——你真的希望它将答案打印到屏幕上吗?不必要的打印会增加程序的运行时间,并可能将不需要的信息堆积在屏幕上。

另一方面,假设你想获取用户的名字,将其转换为小写,然后按字母顺序排列字母,以便在字典中找到这个名字的字谜。为了遵守“只做一件事”的准则,把这些任务拆分成多个函数是很傻的做法。

在他的书《Beyond the Basic Stuff with Python》(No Starch Press, 2021)中,作者 Al Sweigart 推荐函数应尽可能简短,但又不能过短。它们的代码行数不应超过 200 行,理想情况下应少于 30 行。

Lambda 函数

还记得如何通过推导式将for循环缩减为一行代码吗?好吧,lambda 函数让你可以用类似的方式处理函数。

lambda 函数 是一个一次性使用的、没有名称的函数,由一个语句组成。它们有时被称为匿名函数,因为它们是通过 lambda 关键字定义的,而不是使用自己的名字。其语法如下:

lambda parameter_1, parameter_2: expression

紧随 lambda 之后的单词和字符被视为参数。表达式出现在冒号后,返回值是自动的,无需使用 return 关键字。下面是一个示例,它将两个数字相乘:

In [57]: multiply = lambda a, b: a * b

In [58]: multiply(6, 7)
Out[58]: 42

Lambda 函数的一个优点是,你可以即时创建它们,而无需变量赋值。只需将函数放在括号中,并在末尾添加参数(也用括号括起来):

In [59]: (lambda a, b: a * b)(6, 7)
Out[59]: 42

Lambda 函数通常与内置的 filter() 函数一起使用,用于从序列中选择特定的元素。lambda 函数定义了过滤条件,filter() 函数随后将这些条件应用到序列中。下面是一个示例,我们从一个列表中返回所有小于 10 的数字:

In [60]: numbers = [5, 42, 26, 55, 12, 0, 99]

In [61]: filtered = filter(lambda x: x < 10, numbers)

In [62]: print(list(filtered))
[5, 0]

请注意,在打印 filtered 对象之前,你需要将其转换为其他数据类型,如列表或元组。

Lambda 函数在数据分析中非常有用,尤其是当你需要将一个函数作为参数传递给数据转换函数时。它们还可以省去你编写完整函数定义的麻烦,同时保持代码的可读性。

生成器

生成器 是一种用于控制循环迭代行为的特殊例程。它允许你一次生成一个值,而不是一次性生成整个序列。与此相比,普通函数必须在返回结果之前先在内存中创建整个序列,无论序列的大小如何。

生成器使用评估,这意味着它们只有在被调用时才计算一个项目的值,而不需要先将所有内容加载到内存中。因此,生成器对象比其他可迭代对象(如列表)占用更少的内存。

当处理足够大的序列(可能占用你系统内存的大部分或全部)时,生成器非常有用。它们也是仅需使用一次序列时的好选择。

最常见的生成器是内置的 range() 函数,你之前可能已经使用过。使用 range() 时,无论你设置的上限是十还是万亿,对系统内存都没有影响,因为每个数字都是按需生成的,生成后立即丢弃。

生成器函数的定义方式与普通函数相同,不同的是它们使用 yield 语句代替 return 语句。下面是一个示例,它生成序列中每个数字的立方:

In [63]: def cubes(my_range):
    ...:      for i in range(1, my_range + 1):
    ...:           yield i**3 
In [64]:

return 语句会结束退出一个函数,yield 语句则挂起函数的执行,并将一个值返回给调用者。稍后,函数可以从上次挂起的位置继续执行。当生成器到达末尾时,它变为空,无法再次调用。

如果您尝试调用生成器函数并像调用常规函数那样传递参数,您可能会对结果感到惊讶:

In [65]: cubes(5)
Out[65]: <generator object cubes at 0x0000017FE06834A0>

这里的问题是函数返回了一种称为生成器对象的迭代器类型。除非您通过在for循环中使用它或调用内置的next()函数等方式请求元素,否则此对象不会开始执行其代码。

下面是一个创建生成器对象(cube_gen)并使用next()获取其下一个值的示例。在幕后,生成器在每次调用next()函数后暂停,并在再次调用函数时恢复。这将持续进行,直到生成器对象耗尽并引发StopIteration异常:

In [66]: cube_gen = cubes(5)

In [67]: next(cube_gen)
Out[67]: 1

In [68]: next(cube_gen)
Out[68]: 8

In [69]: next(cube_gen)
Out[69]: 27

In [70]: next(cube_gen)
Out[70]: 64

In [71]: next(cube_gen)
Out[71]: 125

In [72]: next(cube_gen)
Traceback (most recent call last): File "C:\Users\hanna\AppData\Local\Temp/ipykernel_23936/2492540236.py", line 1, in <module>
next(cube_gen)

StopIteration

此时,生成器对象为空,无法再次使用。如果您尝试使用for循环迭代它,将得不到任何内容:

In [73]: for i in cube_gen:
    ...:      print(i)

In [74]:

您必须重新创建生成器才能再次使用它:

In [75]: cube_gen = cubes(5)

In [76]: for i in cube_gen:
    ...:      print(i)
1
8
27
64
125

如果您的生成器使用简单表达式,可以使用生成器表达式更简洁地定义它。生成器表达式看起来很像列表推导式,但是您需要将包含for循环的表达式用括号括起来:

In [77]: my_gen = (i for i in range(5))

In [78]: my_gen
Out[78]: <generator object <genexpr> at 0x000001C0DC3280B0>

由于其效率,生成器表达式经常用于替代函数中的列表推导式,例如minmaxsum

In [79]: sum(x**2 for x in range(500))
Out[79]: 41541750

最后,您可以使用类型转换将生成器转换为列表或元组。在本例中,我们将生成器表达式包装在内置的list()函数中,将结果转换为列表:

In [80]: my_list = list(range(5))

In [81]: my_list
Out[81]: [0, 1, 2, 3, 4]

在处理非常大的序列以生成较小序列并且具有足够小的内存占用以存储在列表中时,您可能会执行此操作。

并且在这里,我们使用tuple()内置函数将结果转换为元组:

In [82]: my_tuple = tuple(i**2 for i in my_list)

In [83]: my_tuple
Out[83]: (0, 1, 4, 9, 16)

当您需要从较大的输入序列中高效生成相对较小的元组时,您可能会再次执行此操作。

测试您的知识

6.  生成器函数始终包含哪个关键字?

a.  return

b.  main

c.  yield

d.  range

7.  将“使用main()函数”中的generate_data()函数重写为列表推导式,而不是使用for循环,详见第 295 页。

8.  编写一个 lambda 表达式,打印出此列表中的 5 的倍数:[3, 10, 16, 25, 88, 75]

9.  正确还是错误:在代码末尾定义main()函数的目的是为了使其能够访问任何前面的函数。

10.  要运行一个 lambda 函数而不将其分配给变量,您必须将其包含在:

a.  花括号

b.  方括号

c.  括号

d.  您根本不需要对其进行封闭

模块

模块是文件——通常是用 Python 编写——它们包含一组相关的函数。模块可以嵌入到 Python 程序中,用于执行常见任务和专业任务。例如,Python 的 标准库 包括 os 模块,它提供与操作系统相关的广泛功能。它还包括更专业的 math 模块,提供基本的数学函数。

像函数一样,模块让你隐藏你不希望看到的复杂代码。事实上,许多模块和内置函数甚至不是用 Python 编写的。例如,标准库中常见的len()函数是用 C 语言实现的。以下是它的一些源代码:

static PyObject *
builtin_len(PyObject *module, PyObject *obj)
/*[clinic end generated code: output=fa7a270d314dfb6c input=bc55598da9e9c9b5]*/
{
    Py_ssize_t res;

    res = PyObject_Size(obj);
    if (res < 0) {
        assert(PyErr_Occurred());
        return NULL;
    }
    return PyLong_FromSsize_t(res);
}

想象一下,每次你想获取一个列表或字符串的长度时,都需要在你的程序中包含这样的代码!

通过封装,模块将复杂的代码简化为单行函数调用。这反过来有助于你编写更简洁、易读的代码。模块本身也让你将代码拆分为更容易访问和维护的功能组。

模块节省了你的时间、精力,甚至金钱,因为大多数第三方模块都是开源的。最棒的是,模块让你能够利用领域专家经过实践检验的成果。例如,OpenCV 计算机视觉模块让你即使对这个领域了解甚少,也能识别面部、追踪物体、处理图像等等。如果没有第三方版本,你还可以编写自己的模块。

导入模块

除了一些标准库中的模块,你需要在使用之前先导入模块。按照约定,你应该将这些导入放在 Python 程序的顶部,并在最后一个导入之后插入一个空行。因此,你可以将导入看作是执行流程的“源头”。

将模块导入到顶部可以轻松查看哪些模块正在使用。考虑到很多时候用户需要在运行程序之前安装这些模块,他们不想在你的代码中进行“模块寻宝”。

让我们通过使用random模块来看看导入过程,它允许你处理伪随机数。导入这个模块最简单的方法是使用import关键字,后面跟上模块名称:

In [84]: import random

现在,为了使用random模块中的函数,你需要使用点表示法,输入模块名称,后跟一个句点,再跟上函数名称。以下是一个例子,使用choice()函数从列表中的项中随机选择:

In [85]: planets = ['Mars', 'Venus', 'Jupiter']

In [86]: planet = random.choice(planets)

In [87]: planet
Out[87]: 'Venus'

你可以使用逗号分隔的值一次导入多个函数,像这样:

In [88]: from random import choice, randint, shuffle

为了节省每次输入random的麻烦,并使你的代码行更简洁,你可以仅使用from关键字导入choice,如在import语句中所示:

In [89]: from random import choice

In [90]: planet = choice(planets)

In [91]: planet
Out[91]: 'Mars'

这种方式更简洁,但可读性稍差,因为你可能会忘记choice()来自哪里(尽管你可以随时向上滚动到顶部查看)。

另一种减少输入的方法是为模块名使用别名:

In [92]: import random as ran

In [93]: planet = ran.choice(planets)

In [94]: planet
Out[94]: 'Jupiter'

一般来说,我会避免这样做,除非是那些别名被广泛使用的模块,比如用于 seaborn 绘图库的sns和用于 pandas 数据分析库的pd等。

同样,永远不要使用*通配符来导入模块中的所有函数,如下所示:

In [95]: from random import *

这基本上表示“导入random模块中所有可用的函数”。你可能会在文献或其他人的代码中遇到这种写法,但这被认为是一个不好的做法。它将模块中的所有函数和类导入到你的命名空间中。结果,模块中的名称可能会与你定义的函数或其他库的函数发生冲突。虽然冲突很少发生,但养成尽量保持命名空间整洁的好习惯是很有必要的,因此应该避免使用import *

最后,当导入多个模块时,最佳实践是将每个模块导入写在 单独的行 上。这种方式更具可读性,并且让你能够按 Python 标准库 → 第三方模块 → 用户自定义模块的优先顺序来组织模块。每一组模块之间应该用空行隔开,最后一个import语句后也应该跟一个空行。

如果你担心多个导入的模块可能使用相同的函数名称,可以按模块名称导入这些模块,或者为名称使用简短的别名,并通过点符号调用它们。这样,模块名就会清晰地与函数名关联,避免了混淆和冲突。

注意

Python 库是包的集合,而包是模块的集合。因此,这三者的导入方式是相同的:使用由 import 关键字和要导入的库、包或模块的名称组成的导入语句。

检查模块

你可以使用内置的dir()函数来查看模块中可用的函数。我们来看一下用于生成随机数的random模块。输出很长,因此我在这里做了截断:

In [96]: import random

In [97]: dir(random)
Out[97]: 
['BPF',
--snip--
'betavariate',
'choice',
'choices',
'expovariate',
'gammavariate',
'gauss',
'getrandbits',
'getstate',
'lognormvariate',
'normalvariate',
'paretovariate',
'randint',
'random',
'randrange',
'sample',
'seed',
'setstate',
'shuffle',
'triangular', 'uniform',
'vonmisesvariate',
'weibullvariate']

要查看每个函数的源代码,你可以使用来自inspect模块的getsource()方法。我们来看一下random模块中的choice()函数,它用于从序列中随机选择一个元素。请注意,这些模块是开源的,可能会更新和修订,因此你的输出可能与我展示的不同:

In [98]: import inspect

In [99]: print(inspect.getsource(random.choice))
def choice(self, seq):
     """Choose a random element from a non-empty sequence."""
     try:
          i = self._randbelow(len(seq))
     except ValueError:
          raise IndexError('Cannot choose from an empty sequence') from None
     return seq[i]

你可以看到,choice()只是一个像你之前定义的函数。模块并没有什么神奇之处。

如果你只想查看模块的文档,可以使用getdoc()方法:

In [100]: print(inspect.getdoc(random.choice))
Choose a random element from a non-empty sequence.

如前所述,Python 标准库中的内置函数是用 C 编写的,因此无法通过 inspect 访问。要查看它们的源代码,你需要从 www.python.org/downloads/source/ 下载。

除了检查模块的功能外,检查源代码还可以帮助你学习如何编写自己的自定义函数,扩展或修改现有模块的功能。

编写你自己的模块

一个 Python(.py)文件可以作为一个模块。导入后,它会成为一个特殊的module对象,可以通过点符号调用其函数。

假设你正在处理一个需要反复求解二次方程并计算球体体积的项目。由于这些方程并不是math模块的一部分,你需要自己实现它们。为了避免在每个需要执行这些任务的程序中都定义函数,你可以一次性在一个名为mymath的可重用模块中定义这些函数,并在需要的地方导入该模块。文件名即为模块名。

接下来,我们需要确定模块的保存位置。当导入模块时,Python 解释器首先会查找一个名称匹配的内建模块。如果没有找到内建模块,它会在由sys模块的内建sys.path变量指定的目录列表中查找相应的文件。根据文档说明,该路径会从以下位置初始化:

  • 包含输入脚本的目录(或者如果没有指定文件,则为当前目录)。

  • PYTHONPATH(一个目录名称的列表,其语法与 shell 变量 PATH 相同)。

  • 安装相关的默认值(按照惯例,包括一个由site模块管理的 site-packages 目录)。

之后,我们将使用第一个选项,将你的自定义模块存储在项目目录中。这对初学者和非开发者(如科学家和工程师)来说是最简单、最直接的方法。然而,这样模块只会对从项目目录中运行的脚本可用。要在其他项目中使用该模块,你需要将文件复制到那些目录中,或者使用前面列表中的其他选项。最简单的方法是将路径添加到PATH变量中,像这样:

In [101]: import sys

In [102]: sys.path.append(r'/path/to/my_module')

mymath模块将包含解决二次方程和计算球体体积的函数。我将把它保存在我们在第 70 页的“在现有目录中创建项目”中创建的spyder_proj_w_env项目中。如果你不想使用这个项目,随时可以按照第四章中的说明创建自己的项目文件夹。

首先,通过点击 Spyder 顶部工具栏中的ProjectsOpen Projectspyder_proj_w_env*,打开项目。你将需要查看 Spyder 的文件浏览器、文本编辑器和 IPython 控制台,如图 4-4 所示。

现在,在文本编辑器中输入以下代码:

import math

def quad(a, b, c):
    x1 = (-b - (b**2 - 4 * a * c)**0.5) / (2 * a)
    x2 = (-b + (b**2 - 4 * a * c)**0.5) / (2 * a)
    return x1, x2

def sphere_vol(r):
    vol = (4 / 3) * math.pi * r**3
    return round(vol, 2)

quad()函数接受二次方程的标准 a、b 和 c 系数作为参数,然后计算并返回方程的两个解。sphere_vol()函数接受一个半径作为参数,并返回该半径的球体体积,保留两位小数。

注意

mymath 模块导入了内置的 math 模块。这是可以的,但要小心编写和导入相互依赖的多个模块。这样会导致循环依赖,结果会变得混乱,并可能引发 ImportError 错误。

现在,通过点击顶部工具栏中的文件另存为,将程序保存为mythmath.py文件到代码文件夹中。你也可以将其保存到项目文件夹级别(图 11-3),并仍然能够从代码文件夹中的脚本访问它。个人而言,我不喜欢将项目文件夹弄得杂乱无章,因此决定将其保存在代码中。

Image

图 11-3:mymath.py 模块可以保存在代码文件夹或主项目文件夹中。

如果你对当前 Python 解释器正在工作的文件夹有疑问,可以导入操作系统模块(os)并使用其getcwd()函数来返回当前工作目录。以下是控制台中的示例:

In [103]: import os

In [104]: os.getcwd()
Out[104]: 'C:\\Users\\hanna\\spyder_proj_w_env\\code'

因为当前目录是代码文件夹,你无需指定路径来导入或以其他方式访问该文件夹中的其他文件。

现在,让我们在控制台中测试该模块:

In [105]: import mymath

In [106]: mymath.quad(2, 5, -3)
Out[106]: (-3.0, 0.5) In [107]: mymath.sphere_vol(100)
Out[107]: 4188790.2

如果你想将quad()函数的结果赋值给一个变量,请记住,二次方程有两个解,因此你需要在赋值语句中使用两个变量:

In [108]: soln1, soln2 = mymath.quad(2, 5, -3)

In [109]: soln1, soln2 
Out[109]: (-3.0, 0.5)

就是这么简单!现在,任何位于代码文件夹中的程序都可以导入并使用mymath模块,就像它们使用内置模块一样。

注意

如果你尝试导入已经被导入的模块,什么也不会发生。所以,如果你修改了一个模块并想重新导入它,最好的做法是重启内核,然后再次导入该模块。事实上,任何时候 Python 表现异常时,你都应该考虑重启内核。正如你的 IT 支持人员常说的,“你试过重启吗?”

模块命名

在命名模块时,最佳实践是使用小写字母并用下划线分隔单词。名称最好只使用一个词,因为带下划线的名称容易与变量名混淆。你还应该避免使用点(.)和问号(?)等特殊符号。这些符号可能会导致问题,因为 Python 查找模块时的方式。例如,像my.module.py这样的文件名会告诉 Python,应该在名为my的文件夹中找到module.py文件。

编写独立运行的模块

你在“编写自己的模块”中编写的mymath.py程序第 307 页仅定义了两个函数。作为模块它运行良好,但单独运行时不太实用,因为没有调用这些函数。所以,让我们将mymath.py转变为一个可以独立运行 同时 作为模块工作的程序。

在 Spyder 中,打开mymath.py文件,在文本编辑器中使用 文件另存为 从顶部工具栏创建它的副本。将新文件命名为mymath2.py

现在,添加➊和➋处的代码块来定义并调用main()函数:

   import math

➊ def main():
       a = 2
       b = 5 c = -3
       r = 100
       soln1, soln2 = quad(a, b, c)
       vol = sphere_vol(r)
       print(f'solution1 = {soln1}')
       print(f'solution2 = {soln2}')
       print(f'sphere volume = {vol}')

   def quad(a, b, c):
       x1 = (–b - (b**–2 - 4 * a * c)**0.5) / (2 * a)
       x2 = (-b + (b**–2 - 4 * a * c)**0.5) / (2 * a)
       return x1, x2

   def sphere_vol(r):
       vol = (4 / 3) * math.pi * r**3
       return round(vol, 2)

➋ if __name__ == '__main__':
       main()

在➊处,你定义一个main()函数来运行程序,分配变量作为模块函数的参数,调用这两个函数,并打印结果。

为了让 Python 判断程序是以独立模式运行还是作为导入的模块运行,你需要使用特殊的内置变量__name__➋。如果直接运行程序,__name__将设置为__main__,然后调用main()函数。如果程序是被导入的,__name__将设置为模块的文件名,main()不会被调用,程序不会执行,直到你调用它的某个函数,如quad()sphere_vol()

保存程序并使用 F5 或运行工具栏上的“播放”图标运行它。你应该在控制台看到以下输出:

In [110]: runfile('C:/Users/hanna/spyder_proj_w_env/code/mymath2.py', wdir='C:/Users/hanna/
spyder_proj_w_env/code')
solution1 = -3.0
solution2 = 0.5
sphere volume = 4188790.2

该程序运行时就像你仅仅将main()作为最后一行代码调用一样。

内置模块

Python 配备了多个内置模块。涵盖所有这些模块超出了本书的范围,但表 11-2 列出了其中一些常用的模块,并简要描述了每个模块。你已经使用过其中几个,包括mathrandomlogginginspect。我们将在后续章节中查看其他一些模块。

表 11-2: 常用内置 Python 模块

模块 描述
os 操作系统任务,如目录和文件的创建、删除,识别当前目录等。
sys 系统操作和运行时环境任务,如退出程序、获取路径、命令行使用等。
shutil 用于高级文件操作的 Shell 工具,如复制、移动、删除目录树等。
inspect 用于获取有关活动对象的信息,如模块、类、方法、函数、回溯、帧对象和代码对象。
logging 一个灵活的事件日志系统,用于监控程序的执行流程。
math 基本数学运算和常量。
random 实现用于各种分布的伪随机数生成器。
statistics 用于计算数学统计的函数,如均值、几何均值、中位数、众数、协方差等。
collections 提供专门的容器数据类型,作为 Python 通用内置容器(如字典、列表、集合、元组)的替代品。常用工具包括 namedtuple()、deque、defaultdict 和 Counter。
itertools 创建用于高效循环的迭代器。包括快速函数用于压缩、计算笛卡尔积、生成排列和组合、循环等。
datetime 提供获取和操作日期和时间的工具。
re 用于处理正则表达式的工具,正则表达式定义了一组匹配字符串。用于搜索和解析文本数据。
http 集成了多个用于处理超文本传输协议的模块。
json 用于处理 JSON 格式数据的方法。
threading 用于创建、控制和管理线程(程序指令的最小序列),允许程序的不同部分并发运行,从而提高速度和简化操作。
multiprocessing 允许在给定机器上高效使用多个处理器。

了解内置模块是个好主意,这样你就不会重新发明轮子,也不会重复开发已经存在的模块。你可以在 docs.python.org/3/tutorial/modules.html 查找官方文档。但不必记住所有模块或它们的内容。通过在线搜索某个特定任务,通常会返回有关模块的信息,以及实现该任务的实际代码示例。

测试你的知识

11.  编写一个计算重力的函数,使用公式 F = (G * mass1 * mass2) / radius²,其中 G 是万有引力常数(6.67 × 10-11 N-m²/kg²)。将 G 视为 全局 常量。

12.  导入 math 模块并列出它包含的所有函数。

13.  导入模块中 所有 可用函数的首选方法是使用:

a.  from module import *

b.  import 模块

c.  import module as *

d.  from module import func1, func2, func3...

14.  当你导入一个模块时,Python 首先会搜索:

a.  在当前工作目录中有一个名为该模块的文件。

b.  在 PYTHONPATH 中有一个名为该模块的文件。

在 site-packages 目录中有一个名为该模块的文件。

d.  具有该名称的内置模块

15.  编写一个接受全局作用域变量作为参数的函数。然后,将该函数重写为使用相同的变量作为全局变量。

总结

函数是可调用的代码集合,它让你将程序组织成模块化、逻辑清晰的组。如果你发现自己重复编写代码,应该停下来写一个函数。

递归意味着“返回运行”,递归函数会反复调用自身。递归函数用于解决可以分解成相同类型的较小问题的复杂问题,且使用循环实现起来会非常困难。

Lambda 函数是一次性使用的、没有名称的函数,由单个语句组成。对于简单的任务,它们可以节省你定义完整命名函数的精力。

生成器是一个返回可以单次迭代的对象的函数。生成器不会一次性计算所有的值,而是等待被请求,然后逐个生成值。因此,生成器具有低内存占用,非常适用于只需使用一次的大数据集。

模块是一个包含相关函数集合的 Python 文件。模块必须被导入到其他 Python 文件中才能使用。模块使你能够利用他人的专长和努力,同时保持代码简洁清晰。你还可以为自己的项目编写自定义模块。

第十二章:文件和文件夹

image

文件让你能够以持久化和可共享的方式存储数据。没有它们,几乎不可能完成任何实际工作。Python 提供了许多模块和方法来处理文件、文件夹和目录路径。这些方法可以让你读取和写入文本文件;在退出程序后保存复杂数据;创建、移动和删除文件夹;以及执行其他系统级任务。

在本章中,我们将使用内置的操作系统模块(os)、路径库模块(pathlib)和 shell 工具模块(shutil)来处理文件、文件夹和目录路径。然后,我们将使用内置函数打开、读取、写入和关闭文本文件,并使用内置的 pickleshelvejson 模块来保存和存储更复杂的数据类型,如 Python 列表和字典。最后,我们将学习在打开文件时如何处理异常。

创建一个新的 Spyder 项目

让我们创建一个新的 Spyder 项目来用于本章。如果你需要复习 Spyder 项目的使用,请参阅第 68 页的“使用项目文件和文件夹”部分(第 68 页)。

首先,启动 Spyder(可以从开始菜单或 Anaconda Navigator 中启动),然后在顶部工具栏中点击 项目新建项目。在打开的创建新项目对话框中(图 12-1),确保“位置”框中包含你的主目录,将项目名称设置为 file_play,然后点击 创建 按钮。

Image

图 12-1:Spyder 创建新项目对话框

现在你应该能在 Spyder 的文件资源管理器面板中看到这个新文件夹。

处理目录路径

在你开始处理文件和文件夹(也称为 目录)之前,你需要知道如何找到它们以及将它们保存在哪里。为此,你需要一个地址,也就是 目录路径

目录路径是用来唯一标识目录结构中某个位置的字符字符串。路径以根目录开始,在 Windows 中用一个字母(如 C:*)表示,在基于 Unix 的系统中用正斜杠(/)表示。在 Windows 中,其他驱动器会被分配一个不同于 C 的字母,macOS 中的驱动器位于 /volume 下,而 Unix 中的驱动器位于 /mnt 下(表示“挂载”)。

路径名在不同的操作系统中表现不同。Windows 使用反斜杠(*)分隔文件夹,而 macOS 和 Unix 系统则使用正斜杠(/)。在 Unix 系统中,文件夹和文件名对大小写敏感。

操作系统之间的这些差异可能会导致问题,特别是当你尝试编写可以在任何系统上运行的代码时。如果你在 Windows 中编写程序并使用反斜杠表示路径,其他平台将无法识别这些路径。幸运的是,Python 提供了标准库模块,如 ospathlib,来帮助你解决这个问题。

操作系统模块

操作系统(os)模块被描述为“系统相关事项的杂物抽屉”。表 12-1 总结了该模块中一些最常用的方法。要查看完整的方法列表及其使用详情,请访问文档 docs.python.org/3/library/os.html

表 12-1: 有用的 os 模块方法

方法 描述
os.getcwd() 返回当前工作目录 (cwd) 的位置
os.chdir() 将 cwd 更改为指定路径
os.getsize() 返回文件的大小(以字节为单位)
os.listdir() 返回指定目录内的文件和文件夹列表(默认为 cwd)
os.mkdir() 根据指定路径创建新目录
os.makedirs() 根据指定路径创建多个嵌套目录
os.rename() 重命名指定的文件或目录
os.rmdir() 删除一个空目录
os.walk() 生成目录树中的文件名
os.path.join() 连接路径组件并返回包含拼接路径的字符串
os.path.split() 将路径名拆分为头部和尾部(尾部=最后一个路径组件)
os.path.abspath() 返回指定路径的规范化绝对版本
os.path.normpath() 根据当前系统修正路径分隔符
os.path.isdir() 检查指定路径是否对应现有目录
os.path.isfile() 检查指定路径是否对应现有文件
os.path.isabs() 检查指定路径是否为绝对路径
os.path.exists() 检查指定路径是否存在

这些 os 方法中的几个对于发现你之前未知道的路径非常有帮助。例如,要确定你当前工作的目录名称(即当前工作目录,或 cwd),可以导入 os 模块并在控制台中输入以下内容:

In [1]: import os

In [2]: os.getcwd()
Out[2]: 'C:\\Users\\hanna\\file_play'

在这个例子中,你使用了 os.getcwd() 方法来获取当前工作目录的路径(你的路径会有所不同)。这是一个 Windows 示例,因此使用反斜杠来分隔目录名称,而且因为这是一个字符串,反斜杠必须通过另一个反斜杠进行转义(有关转义序列的复习,请参见 第 190 页)。os.getcwd() 方法会为你插入这些反斜杠,但如果你尝试在其他操作系统中使用这个路径,可能会遇到问题。

当前工作目录在进程启动时会分配给该进程(即程序的运行实例)。对于 Python 程序,当前工作目录始终是包含该程序的文件夹。

你可以使用 os.chdir() 从当前工作目录切换到另一个目录,示例如下:

In [3]: os.chdir('C:\\Users\\hanna')

In [4]: os.getcwd()
Out[4]: 'C:\\Users\\hanna'

如你所见,这个新目录变成了当前工作目录。

如果你在 Windows 上工作并且不想输入双反斜杠,可以在路径名称前输入r,将其转换为原始字符串:

In [5]: os.chdir(r'C:\Users\hanna')

In [6]: os.getcwd()
Out[6]: 'C:\\Users\\hanna'

为了使你的程序兼容所有操作系统,请使用os.path.join()方法,将文件夹名称和文件名作为独立的字符串传入,而不加分隔符字符。os.path方法会根据你使用的系统返回正确的分隔符。这使得文件和文件夹名的操作与平台无关。下面是一个示例:

In [13]: path = '/Users/'

In [14]: path2 = os.path.join(path, 'hanna', 'file_play')

In [15]: path2
Out[15]: '/Users/hanna\\file_play'

In [16]: os.chdir(path2)

In [17]: os.getcwd()
Out[17]: 'C:\\Users\\hanna\\file_play'

在这个片段中,你将一个路径名作为字符串赋值给path变量。注意,你可以在 Windows 中安全地使用正斜杠。接下来,你使用os.path.join()方法创建了一个新的路径变量(path2)。即使在Out[15]行中的输出看起来有点乱,os.path.join()方法会根据你使用的操作系统自动修正分隔符(见In[16] - Out[17]行)。

你也可以将一个使用错误分隔符的现有路径通过os.normpath()转换为你正在使用的系统路径。下面是一个示例,其中 Unix 的正斜杠被转换为 Windows 的反斜杠:

In [18]: path = 'C//Users//hanna'

In [19]: os.path.normpath(path)
Out[19]: 'C\\Users\\hanna'

绝对路径与相对路径

从驱动器到当前文件或文件夹的完整目录路径被称为绝对路径。你可以使用被称为相对路径的快捷方式,使得目录操作更加简便。

相对路径是从当前工作目录的视角进行解析的。而绝对路径是以正斜杠或驱动器标签开始的,相对路径则不是。在以下代码片段中,你可以在不输入绝对路径的情况下切换目录,因为 Python 知道当前工作目录中的文件夹:

In [20]: import os

In [21]: os.getcwd()
Out[21]: 'C:\\Users\\hanna'

In [22]: os.chdir('file_play')

In [23]: os.getcwd()
Out[23]: 'C:\\Users\\hanna\\file_play'

在幕后,相对路径被连接到指向当前工作目录的路径,以形成完整的绝对路径,该路径显示在Out[23]行中。

在 Windows、macOS 和 Linux 中,你可以通过使用点号(.)和点点(..)来识别文件夹,减少输入。例如,在 Windows 中,.\表示当前工作目录,..\表示包含当前工作目录的父目录。你也可以使用点号获取当前工作目录的绝对路径:

In [24]: os.path.abspath('.')
Out[24]: 'C:\\Users\\hanna\\file_play'

如果你需要访问的文件、文件夹或用户自定义模块存储在与你的代码相同的文件夹中,你可以直接在代码中引用该项目的名称,而无需路径或“点”快捷方式。以下是一个示例,我们在file_play文件夹内创建多个嵌套文件夹。因为file_play是当前工作目录,这些文件夹将位于其中,所以不需要包含文件路径:

In [25]: os.makedirs(r'test1/test2/test3')

在这个示例中,os.makedirs()方法使用原始字符串创建了三个嵌套文件夹(test1test2test3)。现在你应该能在 Spyder 项目的文件资源管理器窗格中看到这三个文件夹(图 12-2)。

图片

图 12-2:Spyder 项目中的三个新文件夹

pathlib 模块

os模块被广泛使用,你应该熟悉它的各种方法和语法。但它将路径视为字符串,这可能会变得繁琐,并且需要你使用标准库中其他模块的功能(仅仅为了收集和移动文件,跨目录操作就需要三个模块)。

另一种选择是使用更小且更集中的pathlib模块。该模块将路径视为对象而非字符串,并将所需的路径功能集中在一个地方。它与操作系统无关,使得它在编写跨平台程序时非常有用。

该模块的PathPurePath类不仅帮助你处理目录路径,它们还复制了os模块中的一些有用方法,用于以下任务:

  • 获取当前工作目录:Path.cwd()

  • 创建目录:Path.mkdir()

  • 重命名目录:Path.rename()

  • 删除目录:Path.rmdir()

注意

pathlib 中的 Path 类分为纯路径和具体路径。PurePath 对象像字符串一样作用,提供路径处理操作,如编辑路径、连接路径、查找父路径等,但它们不会访问文件系统。具体路径继承自 PurePath,提供了纯路径操作和新的方法,用于对路径对象进行系统调用。具体路径让你可以访问文件系统,进行目录搜索、删除目录、写入文件等操作。

表 12-2 总结了pathlib模块中一些较为有用的方法。欲了解完整列表,请访问文档 docs.python.org/3/library/pathlib.html。该文档还包括了os方法与其对应的PathPurePath方法的完整映射。

表 12-2: 用于路径操作的有用PathPurePath方法

方法 描述
Path.cwd() 返回当前工作目录的路径对象
Path.exists() 返回布尔值,指示路径是否指向一个现有的文件或文件夹
Path.home() 返回表示用户主目录的路径对象
PurePath.is_absolute() 返回布尔值,指示路径是否为绝对路径
Path.is_dir() 如果给定路径指向一个目录(或符号链接),返回True
Path.iterdir() 生成给定目录的内容
PurePath.joinpath() 将给定路径与其他参数逐个连接
Path.mkdir() 在给定路径创建一个新目录
Path.readlink() 返回给定符号链接的路径
Path.resolve() 将路径转换为绝对路径,解析任何符号链接;返回新路径
Path.rmdir() 删除空目录
Path.unlink() 删除一个文件或符号链接

下面是如何使用Path创建路径变量。首先从模块中导入类,如下所示:

In [26]: from pathlib import Path

In [27]: a_path = Path('folder1', 'folder2', 'file1.txt')

In [28]: a_path
Out[28]: WindowsPath('folder1/folder2/file1.txt')

请注意,Path返回了一个WindowsPath对象。如果你使用的是 macOS 或 Linux,你应该看到一个PosixPath对象。还要注意,尽管WindowsPath对象显示的是正斜杠,但实际上它在后台使用的是正确的 Windows 反斜杠:

In [29]: print(a_path)
folder1\folder2\file1.txt

Path 包含可以让你的代码更具可读性且更方便编写的方法。假设你想将一个路径追加到你的主目录。你可以使用home()方法来获取路径,而无需输入完整的路径:

In [30]: home = Path.home()

In [31]: another_path = Path(home, 'folder1', 'folder2', 'file1.txt')

In [32]: print(another_path)
C:\Users\hanna\folder1\folder2\file1.txt

或者,你可以将所有操作放在一行中,并使用正斜杠而不是逗号来分隔路径组件:

In [33]: another_path = Path.home() / 'folder1' / 'folder2' / 'file1.txt'

In [34]: another_path
Out[34]: WindowsPath('C:/Users/hanna/folder1/folder2/file1.txt')

如果你使用的是 Windows,不必担心那些正斜杠。如前所示,路径对象会根据使用的系统自动识别并返回正确的格式。

每个Path对象都包含用于处理文件和文件夹的有用属性。这些属性可以让你获取像路径的stem、文件的name或扩展名(suffix)等信息。例如,parent属性返回给定文件路径的最直接的父级。在以下示例中,我们获取到another_path变量中文本文件的路径直到该文件:

In [35]: print(another_path.parent)
C:\Users\hanna\folder1\folder2

你可以多次访问此属性,以沿着给定文件的祖先树向上遍历,像这样:

In [36]: print(another_path.parent.parent.parent)
C:\Users\hanna

如前所述,pathlib提供了对基本文件系统操作的访问,如移动、重命名和删除文件和文件夹。这些方法不会在执行前提醒你或等待确认,所以在使用它们时你需要非常小心。否则,你可能会轻易删除或覆盖你想保留的数据。

Shell 工具模块

内建的 shell 工具模块(shutil)提供了用于处理文件和文件夹的高级函数,如复制、移动和删除。表 12-3 总结了一些最常用的方法。有关所有可用方法的列表及其详细使用说明,请访问文档:docs.python.org/3/library/shutil.html

表 12-3: 有用的shutil模块方法

方法 描述
copy() 复制文件(如果包括路径,则会复制到新目录)
copy2() copy()相同,但会保留源文件的所有元数据
copytree() 递归地将源目录下的整个目录树复制到新的目标目录,并返回目标目录路径
disk_usage() 返回文件系统的磁盘使用统计信息,作为一个命名元组,包含 total、used 和 free 属性,单位为字节
move() 将文件或目录移动到另一个位置并返回目标位置
rmtree() 删除整个目录树(非常危险)
make_archive() 创建一个归档文件(zip 或 tar),并返回其名称

这是一个示例,我通过使用点(.)表示绝对路径来获取系统的当前磁盘使用情况:

In [37]: import shutil

In [38]: gb = 10**9

In [39]: total, used, free = shutil.disk_usage('.')

In [40]: print(f"Total memory (GB): {total / gb:.2f}")
Total memory (GB): 238.06

In [41]: print(f"Used memory (GB): {used / gb:.2f}")
Used memory (GB): 146.85

In [42]: print(f"Free memory (GB): {free / gb:.2f}")
Free memory (GB): 91.22

在下一个示例中,我们将 test2 文件夹移动到 file_play 文件夹下的新位置。为此,我们将当前路径(当前工作目录用点文件夹表示)和目标路径传递给 move() 方法(注意路径已为 Windows 配置):

In [43]: shutil.move('.\\test1\\test2', '.\\')
Out[43]: '.\\test2'

你应该在 Spyder 的文件资源管理器中看到此更新(比较 图 12-2 和 图 12-3)。子文件夹会随着父文件夹移动,所以 test3 文件夹会保持在 test2 文件夹下。

Image

图 12-3:test2 文件夹已移至 file_play 文件夹下

注意

使用 shutil 方法时一定要小心;没有警告信息,可能会导致意外行为。rmtree() 方法尤其危险,因为它会永久删除文件夹及其内容。你可能会清除系统的大部分内容,丢失与 Python 项目无关的重要文档,甚至可能会损坏电脑!

现在你已经对使用 Python 操作文件和文件夹有了一些了解,是时候开始写入和读取文件了。我们将从简单的文本文件开始,然后再转向更复杂的数据结构。

测试你的知识

  1. '.' 文件夹表示:

a.  当前工作目录

b.  当前工作目录的父目录

c.  绝对路径

d.  当前工作目录的子目录

  1. 你应该特别小心使用哪种方法?

a.  shutil.move()

b.  shutil.copytree()

c.  Path.resolve()

d.  shutil.rmtree()

  1. 对还是错:相对目录路径是相对于根目录的。

  2. 你可以使用 os.path.join() 方法来:

a.  返回目录路径作为对象而不是字符串

b.  返回目录路径作为列表而不是字符串

c.  返回适合你操作系统的路径分隔符

d.  为你的操作系统修正现有的路径分隔符

  1. pathlib 模块将路径视为 ___________ 。

处理文本文件

纯文本 文件由可读字符组成,这些字符使用一些标准(如 ASCII)进行编码,除了空格、制表符和换行符外没有其他格式信息。纯文本文件的示例有文本文件 (.txt)、Python 文件 (.py) 和逗号分隔值文件 (.csv)。纯文本文件是跨平台的。你可以使用 Windows 的记事本和 macOS 的 TextEdit 应用程序打开并阅读它。

Python 的标准库包括用于读取和写入文本文件的内置函数。pathlib 模块也包含处理文本文件的方法。在接下来的部分中,我们将首先使用内置函数,然后再看 pathlib 的替代方法。

读取文本文件

使用 Python,你可以通过多种方式从文本文件中读取字符串。例如,你可以读取单个字符、完整的行、整个文件等等。为了演示,打开你的系统文本编辑器并输入以下内容。确保在前两行后按回车键:

This is the first line.
This is the second line.
This is the third line.

将文件保存在file_play文件夹中,命名为lines.txt

注意

你可以在 Spyder 文件资源管理器中双击文本文件以编辑和查看其内容。你也可以通过顶部工具栏的“文件 ▸ 新建文件”来生成文本文件。使用“另存为”命令选择 .txt 扩展名。

现在,在控制台中输入以下内容以打开、读取和关闭文件:

In [44]: f = open('lines.txt', 'r')

In [45]: f
Out[45]: <_io.TextIOWrapper name='lines.txt' mode='r' encoding='cp1252'>

在第一行,我们使用内置的 open() 函数打开文件,并将其内容赋给 f 变量(代表“文件”)。open() 函数接受两个参数,第一个是文本文件的名称。由于该文件位于当前工作目录中,因此无需包含路径。对于不在当前工作目录中的文件,你需要传递绝对路径或相对路径。

第二个参数是访问模式,它设置了在打开文件时可以执行的操作类型,例如读取、写入、追加等等。'r'告诉 Python 你想要以只读模式打开文件。这可以保护文件不被修改。尽管只读模式是默认模式,但明确地包括'r'参数可以让你的意图更加清晰。表 12-4 列出了 Python 中一些常见的文件访问模式。

表 12-4: 选定的文本文件访问模式

模式 描述
'r' 从文本文件读取。如果文件不存在,抛出异常。
'w' 写入文本文件。创建一个新文件,否则覆盖现有文件。
'x' 写入文本文件,但如果文件已经存在,则返回错误。
'a' 追加到文本文件。如果文件不存在,创建一个新文件。
'r+' 允许读写模式。
'b' 为二进制文件(如 'rb')添加模式。

open() 函数返回了一个类型为 _io.TextIOWrapperFile 对象。这是一种类似于列表或元组的对象类型。

现在,让我们看一下读取文件的一些文件对象方法(表 12-5)。这些方法通过点号表示法调用文件对象。

表 12-5: 选定的文件对象方法和属性

方法 描述
close() 关闭文件。
closed 如果文件已关闭,则返回 True
read() 从文件中读取指定数量的字符并返回一个字符串。
readline() 从文件中读取指定数量的字符并返回一个字符串。默认情况下,从当前位置读取直到行末的所有字符。
readlines() 读取文件中的所有行并将它们作为列表项返回。
seek() 将文件指针的位置更改为文件中的特定位置。
tell() 返回文件读/写指针在文件中的当前位置。
write() 将指定的字符串写入文件。
writelines() 将指定列表中的字符串写入文件。

最重要的方法之一是close()。在终止进程之前关闭文件是一种良好的实践。如果不关闭文件,可能会耗尽文件描述符(在计算机操作系统中唯一标识打开文件的数字),在 Windows 中锁定文件以防止进一步访问,导致文件损坏,或者在写入文件时丢失数据。

要关闭文件,可以使用点符号调用close()方法:

In [46]: f.close()

你只能在文件对象打开时进行操作。文件对象关闭后,不能再对其进行操作。

现在,让我们来看一下获取文件内容的方法。在下面的控制台代码片段中,再次打开文件并使用read()方法读取第一个字符。该方法返回一个string数据类型(记住,在控制台中你可以使用上下箭头键来检索之前的命令):

In [47]: f = open('lines.txt', 'r')

In [48]: f.read(1)
Out[48]: 'T'

In [49]: f.read(10)
Out[49]: 'his is the'

read()方法传入1会返回文件中的第一个字符。但传入10却没有返回文件中的前 10 个字符。这是因为read()会记住它上次读取的位置。要查找文件中的当前位置,可以使用tell()方法:

In [50]: f.tell()
Out[50]: 11

要手动改变文件中指针的位置,可以将一个数字传递给seek()方法,如下所示:

In [51]: f.seek(12)
Out[51]: 12

In [52]: f.read(1)
Out[52]: 'f'

In [53]: f.close()

要从头开始,必须关闭并重新打开文件,或者使用seek()方法返回到文件的开头。

如果你没有指定要读取的字符数,Python 会返回整个文件。这对于小文件来说没问题,但对于非常大的文件可能会成为问题。为了演示如何读取整个文件,请重新打开文件并调用没有参数的read()方法:

In [54]: f = open('lines.txt', 'r')

In [55]: f.read()
Out[55]: 'This is the first line.\nThis is the second line.\nThis is the third
line.'

In [56]: f.close()

注意,文件对象包含换行符转义序列(\n)。这让它知道如何正确地打印每一行:

In [57]: f = open('lines.txt', 'r')

In [58]: print(f.read()) This is the first line.
This is the second line.
This is the third line.

In[59]: f.close()

你可以使用readline()方法一次读取一行,如下所示:

In [60]: f = open('lines.txt', 'r')

In [61]: print(f.readline())
This is the first line.

In [62]: print(f.readline())
This is the second line.

In [63]: print(f.readline())
This is the third line.

In [64]: f.close()

在这种情况下,“行”由换行符转义序列(\n)定义。与read()函数类似,readline()会记住它上次读取的位置,因此要从头开始,必须关闭并重新打开文件。

使用readline()时要小心。不要假设你传递给它的值代表一行;它实际上代表的是一个字符,就像read()方法一样。事实上,你可以重复In[48]-In[49]行的结果:

In [65]: f = open('lines.txt', 'r')

In [66]: f.readline(1)
Out[66]: 'T'

In [67]: f.readline(10)
Out[67]: 'his is the'

In [68]: f.close()

要一次性读取整个文件,可以使用readlines()方法。与之前的方法不同,它返回一个包含文件中每一行的列表,而不是字符串。文件中的每一行会成为列表中的一个单独项。以下是一个示例:

In [69]: f = open('lines.txt', 'r')

In [70]: lines = f.readlines()

In [71]: lines
Out[71]: 
['This is the first line.\n', 'This is the second line.\n',
'This is the third line.']

In [72]: f.close()

因为输出是一个列表,你可以获取它的长度,遍历它,等等,就像对待任何其他列表一样:

In [73]: len(lines)
Out[73]: 3

In [74]: for line in lines:
    ...:     print(line)
This is the first line.

This is the second line.

This is the third line.

在前述方法中,行尾(EOL)标记被保留。这些是字符编码规范(如 ASCII)使用的控制字符,用来表示一行文本的结束。如果你不希望保留这些标记,可以使用列表推导式将其去除:

In [75]: lines = [line.rstrip() for line in open('lines.txt', 'r')]

In [76]: lines
Out[76]: 
['This is the first line.',
'This is the second line.',
'This is the third line.']

将之前的输出与Out[71]中的输出进行比较。换行符(\n)已经消失。rstrip()字符串方法会移除字符串右侧指定的尾随字符。如果没有指定字符,它会移除行尾的换行符或空白字符。

使用 with 语句关闭文件

由于关闭文件非常重要(且容易被忽视),Python 提供了with语句,它在嵌套代码块执行完毕后会自动关闭文件。在这个示例中,我们使用with语句和open()函数加载文本文件,然后使用read()方法获取文件的完整内容,并将其赋值给lines变量:

In [77]: with open('lines.txt') as f:
    ...:     lines = f.read()
    ...:     print(lines)
This is the first line.
This is the second line.
This is the third line.

每当可能时,尝试在打开文件时使用with语句,以确保文件能够正确关闭。要检查文件是否关闭,可以使用它的closed属性,返回TrueFalse

In [78]: f = open('lines.txt', 'r')

In [79]: f.closed
Out[79]: False

In [80]: f.close()

In [81]: f.closed
Out[82]: True

写入文本文件

你可以使用write()writelines()文件对象方法将字符串写入文本文件(参见表 12-5)。让我们通过一首由我创作的俳句诗来试试这个方法。

要写入文件,首先必须使用写入('w')文件访问模式打开它(参见表 12-4)。在控制台中输入以下内容:

In [83]: f = open('haiku.txt', 'w')

在写入模式下调用open()打开文件时,如果指定的文件不存在,它将创建一个新文件;如果文件已经存在,它会完全覆盖该文件,删除其中的内容。在这种情况下,我们只需要输入文件名,因为我们是写入当前工作目录。如果要写入其他地方,你需要使用chdir()方法更改目录,或在文件名中包含目录路径。

现在我们有了一个文件对象,可以向其写入字符串,使用换行符来指定回车位置:

In [84]: f.write('Faraway cloudbanks\n')
Out[84]: 19

In [85]: f.write('That I let myself pretend\n')
Out[85]: 26

In [86]: f.write('Are distant mountains')
Out[86]: 21

In [87]: f.close()

输出表示每个字符串中的字符数,包括换行符。关闭文件可以释放系统资源,防止不小心向文件写入更多数据。

让我们通过使用read()方法来检查是否成功:

In [88]: with open('haiku.txt', 'r') as f:
    ...:     print(f.read()) Faraway cloudbanks
That I let myself pretend
Are distant mountains

记住,当你使用with语句打开文件时,它会自动关闭。

一行一行地输入非常繁琐。writelines()方法允许你写入一个字符串列表到文件,就像readlines()方法提供了将文本文件读取为列表的能力。以下示例创建了一个新的俳句列表,覆盖了现有的haiku.txt文件,将该列表写入文件,然后读取该文件:

In [89]: poem = ['In city fields\n',
    ...:         'Contemplating cherry trees\n',
    ...:         'Strangers are like friends\n']

In [90]: with open('haiku.txt', 'w') as f:
    ...:     f.writelines(poem)

In [91]: with open('haiku.txt', 'r') as f:
    ...:     print(f.read())
In city fields
Contemplating cherry trees
Strangers are like friends

哎呀,我们忘记给俳句注明出处了——是大诗人一茶。没关系,使用追加('a')文件访问模式,您可以将字符串添加到现有的文本文件中,而不会覆盖原有内容:

In [92]: with open('haiku.txt', 'a') as f:
    ...:     f.write('                        --Issa')

In [93]: with open('haiku.txt', 'r') as f:
    ...:     print(f.read())
In city fields
Contemplating cherry trees
Strangers are like friends
                        --Issa

您还可以使用 writelines() 来动态生成新的文件内容,如下所示:

In [94]: with open('a_random_thought.txt', 'w') as f:
    ...:     f.writelines(line for line in poem if line.startswith('C'))

In [95]: with open('a_random_thought.txt', 'r') as f:
    ...:     print(f.read())
Contemplating cherry trees

在此示例中,我们过滤了 poem 列表,仅将以 C 开头的行写入新文件。

使用 pathlib 读取和写入文本文件

pathlib 模块的 Path 类还提供了处理文件和文件夹的方法(见 表 12-6)。这些方法结合了像 open() 这样的内置函数,可以让简单的读写操作变得更方便(假设您喜欢使用路径对象)。

表 12-6: 一些用于处理文件和文件夹的有用 Path 方法

方法 描述
Path.glob() 根据给定模式(如 *.py)返回所有匹配的文件
Path.is_file() 如果给定路径指向常规文件(或符号链接),则返回 True
Path.open() 根据文件名或路径+文件名打开文件
Path.read_bytes() 返回给定文件的内容作为字节对象
Path.read_text() 返回给定文件的内容作为字符串并关闭文件
Path.rename() 重命名文件或目录并返回新路径
Path.replace() 无条件重命名文件或目录并返回新路径
Path.touch() 在给定路径创建一个文件
Path.write_text() 以文本模式打开指定文件,写入内容,然后关闭文件

Path.read_text() 方法在后台调用 open(),并将文件的内容作为字符串返回。它还会像 with 语句一样自动关闭文件。以下是使用章节中早些时候提到的 lines.txt 文件的控制台示例:

In [96]: from pathlib import Path

In [97]: p = Path('lines.txt')

In [98]: p.read_text()
Out[98]: 'This is the first line.\nThis is the second line.\nThis is the third
line.'

请注意,您必须首先创建一个路径对象(p)。对于不熟悉 pathlib 的用户来说,相较于上一节回顾的传统文件打开方法,这可能会让人感到困惑。

现在,让我们在 test1 文件夹中创建一个文件并使用 Path 向其中写入内容。在控制台中输入以下内容:

In [99]: path = Path(Path.cwd() / 'test1' / 'another_haiku.txt')

In [100]: lines2 = 'Desolate moors fray\nBlack cloudbank, broken, scatters\nIn the pines, the
graves' In [101]: path.write_text(lines2)
Out[101]: 78

In [102]: print(path.read_text())
Desolate moors fray
Black cloudbank, broken, scatters
In the pines, the graves

Path.write_text() 方法接受一个字符串作为参数。与 open() 类似,它会覆盖具有相同名称的现有文件。与 open() 不同,它不允许使用追加模式。然而,它会自动关闭文件。

您可以在 docs.python.org/3/library/pathlib.html 上阅读更多关于 pathlib 的内容。

测试你的知识

6.  哪些语句或方法会关闭文本文件?

a.  with 语句

b.  Path.read_text() 方法

c.  Path.write_text() 方法

d.  close() 方法

e.  上述所有方法

7.  将上一节中创建的 another_haiku.txt 文件重命名为 haiku_2.txt。使用 ospathlib 模块。

8.  从第 15 个字符开始打印 haiku.txt 文件。

9.  用于向现有文本文件添加文本的文件访问模式是什么?

a.  w

b.  r

c.  a

d.  b

10.  真或假:os.writelines()方法将列表写入文件;Path.write_text()方法将字符串写入文件。

处理复杂数据

文本文件方便且流行,但这并不是唯一的选择。我们迄今为止审查的各种文件写入方法仅接受字符串或字符串列表作为输入。但 Python 包含许多不同的数据类型,例如字典,在您的日常工作中会用到,因此您需要一种方法来保存这些数据。

要保存这些其他数据类型,您需要使用数据序列化。此过程将结构化数据(例如 Python 字典)转换为可存储和共享的格式。当从存储中读取时,此格式保留了重建对象所需的信息。这个过程称为反序列化

在本节中,我们将查看像picklejson这样的模块,这些模块用于序列化和反序列化数据。pickle模块是 Python 的本地序列化模块。它将对象转换为一系列有序的字节(0 和 1),称为字节流。通过 pickling 和 unpickling,我们可以轻松地将数据从一个服务器或系统传输到另一个,并将其存储在文件或数据库中。

json模块将 Python 对象转换为一种称为JavaScript 对象表示法(简称JSON)的序列化表示形式,并根据需要进行反序列化。我们在第九章中用json来美化打印字典。它适用于几乎所有的编程语言。

这两个模块各有其优势和劣势(见表 12-7)。Pickling 适用于大多数 Python 对象和数据类型,而 JSON 仅限于某些对象和数据类型。

表 12-7: Pickle vs. JSON 序列化比较

特性 Pickle JSON
存储格式 字节流 人类可读的字符串对象
Python 对象 所有对象 仅限于某些对象
Python 数据类型 几乎所有数据类型 仅限于列表、字典、空值、布尔值、数字、字符串、数组和 JSON 对象
兼容性 仅限 Python 语言无关
速度 相对较慢 相对较快
安全性 存在安全问题 安全可靠

注意

Pickling 不如使用 JSON 安全。您应该非常小心地从未知来源 unpickle 数据,因为它可能包含恶意数据。Pickling 也适用于相对短期的数据存储,因为模块的修订可能不总是向后兼容的。

Pickling 数据

Pickle 意味着保存某物。pickle模块(docs.python.org/3/library/pickle.html)将 Python 数据对象 pickle 成二进制文件。与文本文件不同,人类无法读取二进制文件。

序列化就像将字符串写入文件,只不过你写的是序列化对象。访问模式是相同的,只是增加了一个'b'表示“二进制”(表 12-8)。

表 12-8: 选择的二进制文件访问模式

模式 描述
'rb' 从二进制文件中读取。
'wb' 写入二进制文件。根据需要创建或覆盖文件。
'ab' 追加到二进制文件。根据需要创建或修改文件。

让我们来序列化一些列表。在控制台中输入以下内容:

In [103]: import pickle

In [104]: dragon_prefix = ['Hungarian', 'Chinese', 'Peruvian']

In [105]: dragon_suffix = ['Horntail', 'Fireball', 'Vipertooth']

In [106]: f = open('dragons.dat', 'wb')

In [107]: pickle.dump(dragon_prefix, f)

In [108]: pickle.dump(dragon_suffix, f)

In [109]: f.close()

在导入pickle模块并创建了两个龙的列表后,我们打开了一个名为dragons.dat的新二进制文件。接着,我们使用pickle.dump()函数将这两个列表存储到该文件中,传递给它列表的名称和文件对象的名称作为参数。最后,我们关闭了文件(你应该能在file_play文件夹中看到它)。

pickle.dump()函数将每个列表作为单独的对象写入文件。为了检索这些对象,我们再次以二进制模式打开文件,并调用pickle.load()函数,如下所示:

In [110]: f = open('dragons.dat', 'rb')

In [111]: dragon_prefix = pickle.load(f)

In [112]: dragon_suffix = pickle.load(f)

In [113]: print(dragon_prefix)
['Hungarian', 'Chinese', 'Peruvian']

In [114]: print(dragon_suffix)
['Horntail', 'Fireball', 'Vipertooth']

In [115]: f.close()

pickle.load()函数接受文件对象作为参数,并返回(或反序列化)第一个序列化对象,将其赋值给变量dragon_prefix。下一次调用pickle.load()将返回下一个序列化对象。这里需要注意的一点是,你不需要知道列表的原始名称(如dragon_prefix)就可以提取数据。你可以将它们命名为“poodledoodle”和“snickerdoodle”,你依然会以相同的顺序检索到相同的列表。

但是,如果你想按其他顺序检索序列化的对象,例如只检索龙的后缀怎么办?为此,你需要使用shelve模块,它将序列化进一步扩展。

存储序列化数据

数据库是一个用于存储数据的特殊文件。大多数数据库类似于 Python 字典,因为它们将键映射到值。然而,与字典不同的是,数据库在程序结束后依然会保持数据。

Python 配有dbm模块,用于创建和更新数据库文件。然而,这个模块有一个限制,它的键和值必须是字符串或字节。pickle模块通过将多种数据类型转换为适合在数据库中使用的字符串,帮助克服了这个限制。

由于需要在数据库中存储非字符串对象的需求非常常见,这一功能已被整合到一个名为shelve的模块中,帮助你在文件中存储和访问被序列化的对象。它建立在pickle的基础上,实现了一个序列化字典,其中对象通过一个与之关联的键(由字符串组成)进行序列化。这些键让你能够加载已存储的数据文件,并随机访问由序列化对象组成的值。

shelve模块生成一个架子,它是一个持久化的、类似字典的对象。尽管可以直接序列化字典,但使用shelve模块在内存使用上更高效。

注意

因为这个过程涉及到 pickle 模块,加载一个架子可能会执行意外的代码,所以从不可信来源加载架子是非常不安全的。

让我们来看一下如何使用上一节的龙数据来实现数据存储:

In [116]: import shelve

In [117]: s = shelve.open('dragon_shelf', 'c')

In [118]: type(s)
Out[118]: shelve.DbfilenameShelf

在导入模块后,我们使用了 shelve.open() 方法,在当前工作目录中创建了一个名为 dragon_shelf 的新架子,并将其赋值给变量 s,然后获取了 s 的数据类型。为了创建这个架子,我们使用了 'c' 访问模式。其他 shelve 访问模式可以在 表 12-9 中查看。

表 12-9: Shelve 访问模式

模式 描述
'c' 打开一个架子,支持读写操作,必要时创建该架子
'n' 创建一个新的、空的架子,支持读写操作,如果需要可以覆盖
'r' 打开一个现有的架子,仅支持读取操作
'w' 打开一个现有的架子,支持读写操作

现在,让我们通过键值组合将龙的数据添加到架子上。这会在后台对数据进行序列化。虽然我们在这里创建了列表,但我们也可以像在前面的 pickle.dump() 示例中那样,直接使用赋值给列表的变量名。

In [119]: s['prefix'] = ['Hungarian', 'Chinese', 'Peruvian']

In [120]: s['suffix'] = ['Horntail', 'Fireball', 'Vipertooth']

In [121]: s.close()

关闭架子会 同步 数据,确保任何在内存缓存或缓冲区中的数据被写入磁盘。然后,它通过清除缓存来释放系统资源。

这里有两点需要注意:shelve 会自动在文件名后添加 .dat 扩展名,并且会创建额外的支持文件(在 图 12-4 中以灰色高亮显示)。这些额外的文件是操作系统特定的。例如,在 macOS 上,你可能只会看到一个名为 dragon_shelf.db 的文件。

Image

图 12-4:Windows 中与 dragon_shelf 相关的文件

注意

在 Spyder 的文件资源管理器中,二进制文件图标会显示 “01”。文本文件图标则使用两条直线。

现在,让我们重新打开架子并检索一些数据:

In [122]: s = shelve.open('dragon_shelf', 'r')

In [123]: type(s['prefix'])
Out[123]: list

In [124]: print(f"Dragon suffixes: {s['suffix']}")
Dragon suffixes: ['Horntail', 'Fireball', 'Vipertooth']

In [125]: s.close()

在以只读模式打开 dragon_shelf 文件后,你可以看到 prefix 键对应的是一个 list 对象。你还可以先打印出 suffix 列表,尽管它是第二个加载到架子上的列表。将这个与前一节中的 pickle.load() 方法进行比较,后者按顺序返回被序列化的对象。

使用 with 语句关闭架子

储存大量数据可能会占用很多内存,因此在完成操作后,关闭架子是非常重要的。由于这很容易被忽视,Python 允许你在打开架子时使用 with 语句,以便在某些操作后自动关闭文件。下面是一个示例:

In [126]: with shelve.open('dragon_shelf', 'r') as s:
     ...:     print(type(s['prefix']))
<class 'list'>

由于 with 语句在代码块执行后关闭了架子,后续对 s 的操作将会抛出 ValueError 错误:

ValueError: invalid operation on closed shelf
使用 Shelve 方法

架子对象支持字典支持的大多数方法和操作(表 12-10)。这是设计使然,旨在简化从基于字典的脚本过渡到需要持久存储的脚本。

如果您忘记了架子中的键名称,或者使用的是您未创建的架子,您可以使用keys()方法来检索这些名称。请注意,您需要使用list()函数将输出转换为列表:

In [127]: with shelve.open('dragon_shelf', 'r') as s:
     ...:     print(list(s.keys()))
['prefix', 'suffix']

表 12-10: Shelve 模块方法

方法 描述
close() 同步并关闭架子对象
get() 返回与键相关的架子值
items() 返回架子的键值对元组
keys() 返回架子的键列表
pop() 删除指定的架子键并返回关联的架子值
sync() 如果架子(shelf)在开启时设置了True的写回(writeback),则将所有缓存中的条目写回
update() 从另一个字典或可迭代对象更新架子
values() 返回架子的所有值列表

其他一些方法会返回一个可迭代对象,您可以循环遍历。以下是一个使用items()方法的示例,该方法返回键值对元组:

In [128]: with shelve.open('dragon_shelf', 'r') as s:
     ...:     print(s.items())
ItemsView(<shelve.DbfilenameShelf object at 0x000001D3956BAF70>)

打印输出时显示的是一个对象名称,而不是您可能预期的键值对。要获取键值元组,请循环遍历输出,如下所示:

In [129]: with shelve.open('dragon_shelf', 'r') as s:
     ...:     for item in s.items():
     ...:         print(item)
('prefix', ['Hungarian', 'Chinese', 'Peruvian'])
('suffix', ['Horntail', 'Fireball', 'Vipertooth'])

您可以在docs.python.org/3/library/shelve.html上阅读更多关于shelve及其方法的内容。

使用 JSON 存储数据

使用json模块(docs.python.org/3/library/json.html),您可以将数据存储为一个单一的人类可读的字符串。以下是一个以 JSON 格式存储的 Python 字典示例:

′{″key1″: ″value1″, ″key2″: ″value2″, ″key3″: ″value3″}′

它看起来就像一个常规的 Python 字典,唯一的不同是:它被单引号包围,这使得整个内容成为一个字符串。

pickleshelve相比,json模块提供了一种更快速且更安全的方式来存储和检索复杂的 Python 数据类型。然而,它支持的数据类型比pickle少,因为它仅限于字典、列表、空值、布尔值、数字(整数和浮点数)、字符串和 JSON 对象。

JSON 还可以帮助您访问全球范围内的信息。作为一种轻量级的数据交换格式,既便于人类阅读,又便于机器解析,许多网站的应用程序接口(API)使用 JSON 格式传递数据。

以 JSON 格式保存数据

要查看json如何工作,首先创建一个包含三艘著名宇宙飞船乘员容量的 Python 字典,并以 JSON 格式保存。请在控制台输入以下内容:

In [130]: import json

In [131]: crew = dict(Mercury=1, Gemini=2, Apollo=3)

In [132]: crew
Out[132]: {'Mercury': 1, 'Gemini': 2, 'Apollo': 3}

In [133]: capsules_data = json.dumps(crew)

In [134]: capsules_data
Out[134]: '{"Mercury": 1, "Gemini": 2, "Apollo": 3}'

In [135]: with open('capsules_data.json', 'w') as f:
    ...:      f.write(capsules_data)

json.dumps() 方法将字典转换为 JSON 字符串。你可以像之前一样使用 open() 函数以写入模式将 JSON 字符串写入持久化文件。新的 capsules_data.json 文件应该会出现在 Spyder 文件资源管理器窗格中(图 12-5)。

图片

图 12-5:文件资源管理器窗格中的 capsules_data.json 文件

请注意 Spyder 如何使用特殊图标表示文件。由于它是人类可读的,你可以像打开文本文件一样打开它并阅读其内容。

以 JSON 格式加载数据

现在,让我们打开、加载并使用 JSON 文件。我们将继续在控制台中工作,但以下示例也可以很容易地在文本编辑器或 Jupyter Notebook 中编写的已保存程序中完成:

In [136]: with open('capsules_data.json', 'r') as f:
     ...:     crew = json.load(f)

In [137]: print(f"The Mercury capsule had {crew['Mercury']} seat.")
The Mercury capsule had 1 seat.

In [138]: print(f"The Apollo capsule had {crew['Apollo']} seats.")
The Apollo capsule had 3 seats.
以 JSON 格式保存元组

在 JSON 格式中没有元组的概念。如果你将元组保存为 JSON 格式,你将得到一个列表。在控制台中,输入以下内容来查看示例:

In [139]: import json

In [140]: t = (1, 2, 3)

In [141]: type(t)
Out[141]: tuple

In [142]: t_json = json.dumps(t)

In [143]: t_json
Out[143]: '[1, 2, 3]'

In [144]: t2 = json.loads(t_json)

In [145]: t2
Out[145]: [1, 2, 3]

In [146]: type(t2)
Out[146]: list

在简单的情况下,你可以通过将输出转换回元组来处理:

In [147]: t2 = tuple(t2)

In [148]: t2
Out[148]: (1, 2, 3)

In [149]: type(t2)
Out[149]: tuple

对于更复杂的情况,你可能需要在线搜索如何在 JSON 中使用元组。

打开文件时捕获异常

读取和写入文件属于用户交互范畴,正如我们在第十章中看到的,用户参与时很多事情可能会出错。处理文件时,这些错误包括尝试打开不存在的文件或路径、没有适当权限尝试打开文件或文件夹、尝试打开文件夹而不是文件等等。

这些问题无法在你的代码中修复,但你可以捕获这些异常并提供一些有用的建议,而不是让程序崩溃并在屏幕上乱七八糟地输出错误信息。

大多数常见的文件加载错误属于操作系统异常类 OSError。这些错误包括表 12-11 中显示的错误。

表 12-11: 与文件加载相关的常见错误

类别 子类
BlockingIOError
ChildProcessError
ConnectionError BrokenPipeError
ConnectionError ConnectionAbortedError
ConnectionError ConnectionRefusedError
ConnectionError ConnectionResetError
FileExistsError
FileNotFoundError
InterruptedError
IsADirectoryError
NotADirectoryError
PermissionError
ProcessLookupError
TimeOutError

这是一个示例,我们使用 OSError 捕获一个不存在的文件(fluffybunnyfeet.lol)引发的异常。有关使用 tryexcept 的复习,请参见第十章。

In [150]: try:
     ...:     with open('fluffybunnyfeet.lol', 'r') as f:
     ...:         data = f.read() ...: except OSError as e:
     ...:     print(e)
     ...: else:
     ...:     print('File successfully loaded.')
     ...: finally:
     ...:     print('File load process complete.')
[Errno 2] No such file or directory: 'fluffybunnyfeet.lol'
File load process complete.

except 子句打印了一条有用的信息,告知用户文件不存在(这是一个 FileNotFoundError)。finally 子句则通知用户文件加载过程已经结束。请注意,finally 块无论结果如何都会执行,而 else 代码块只会在操作成功时执行。

下面是一个成功使用我们之前创建的haiku.txt文件的示例:

In [151]: try:
     ...:     with open('haiku.txt', 'r') as f:
     ...:         data = f.read()
     ...: except OSError as e:
     ...:     print(e)
     ...: else:
     ...:     print('File successfully loaded.')
     ...: finally:
     ...:     print('File load process complete.')
File successfully loaded.
File load process complete.

欲了解更多关于 Python 内置异常的内容,请访问文档:docs.python.org/3/library/exceptions.html

其他存储解决方案

如果你的数据足够复杂,可能需要更强大的存储解决方案。可扩展标记语言(XML) 旨在存储和传输小到中等规模的数据,并广泛用于共享结构化信息。YAML 是另一种人类可读的数据序列化语言,常用于配置文件和数据存储或传输的应用中。与 XML 相比,它具有更简洁的语法。SQLite 是一种轻量级数据库,提供零配置的关系型数据库管理系统。层次数据格式(HDF5) 用于存储大量科学阵列数据。涉及这些存储系统超出了本书的范围,但你可以在线找到关于每种方法的大量信息。

测试你的知识

11.  对错判断:shelve 模块帮助你在文件中存储和访问被序列化的对象。

12.  在本章讨论的保存和加载复杂数据的方法中,最安全的是:

a.  序列化

b.  同步

c.  JSON 格式

d.  存储

13.  重写本章“使用 JSON 存储数据”部分的船员容量程序,使其根据船员数量自动打印舱体名称和语法正确的“座位”(seatseats)。

14.  使用控制台调查 JSON 如何处理引号。使用列表 ["don't", "do"]['don\'t', 'do']

15.  打开和关闭文件的 Python 内置异常属于哪个异常类?

a.  IOError

b.  FileNotFoundError

c.  PermissionError

d.  OSError

16.  使用 Python 模块将lines.txt文件的副本移动到test1文件夹中,然后将其归档为 ZIP 文件。

总结

文件使你能够以持久和可共享的方式保存你的工作,包括你在程序中分配的变量。要使用文件,你需要基本了解计算机文件系统的工作原理,如何操作目录路径,以及如何打开、读取和写入文件。

绝对目录路径是指完整的目录路径,从根目录开始(例如 Windows 上的C:*)。相对目录是相对于当前工作目录定义的。您可以使用快捷方式,例如“.”表示绝对目录,“.*”表示当前工作目录,以便更轻松地处理目录。

Python 的内置ospathlibshutil模块包含用于处理文件和文件夹的有用高级方法。但是,这些方法执行时没有警告,因此在移动、重命名或删除数据时需要小心。

Python 还有其他内置工具,用于处理人类可读的文本文件。要读取文件,首先必须使用open()函数将其作为文件对象打开。然后可以在此对象上调用read()readlines()等方法。要写入文件,必须以写入模式打开它,然后调用write()writelines()等方法。要向现有文件添加数据而不覆盖其内容,必须以追加模式打开它。

使用完文件后,应始终关闭文件以释放系统资源并防止文件意外被覆盖。您可以使用close()方法手动关闭文件,或者通过使用with语句自动关闭文件。

更复杂的数据,如 Python 字典和列表,可以使用pickle模块保存为二进制格式,或者使用json模块保存为人类可读的字符串。shelve模块帮助您将 pickled 对象存储和访问在shelf文件中,这是一个持久化的类似字典的对象,为每个 pickled 对象分配唯一的key名称。使用 JSON 格式比 pickling 更快速和安全,但并非所有 Python 对象和数据类型都可以存储在 JSON 中。

尽管理解 Python 文件和文件夹管理的基本工具很重要,但如果你的工作涉及大量存储在磁盘上的数据,你可能需要阅读关于Python 数据分析库的内容,也称为pandas。这个库包含高级工具,可以将数据从磁盘导入 Python 数据结构,然后再导出。许多文件格式都被支持,包括 Excel,CSV,TXT,SQL,HTML,JSON,Pickle 和 HDF5。我们将在第十五章和第二十章中讨论 pandas。

第十三章:面向对象编程(OOP

image

迄今为止,在本教程中,你一直在使用基于执行动作和评估逻辑的过程式编程技术编写代码。你已经学会了如何使用函数和模块组织代码,并使用内置数据类型来组织数据。在本章中,你将学习如何使用面向对象编程来定义你自己的类型,以组织代码和数据。

面向对象编程(OOP)是一种语言模型,它允许你将相关数据与作用于这些数据的功能捆绑在一起。数据由属性(类似于变量)组成,通过方法(类似于函数)进行操作。这些“捆绑”形成了名为的自定义数据类型。类帮助你将程序分成处理不同信息块的不同部分,而不是让所有信息混杂成一个无结构的混乱。

类允许你创建具有特定属性和行为的独立对象。通过使用类模板,你可以高效地“制作”多个对象,就像一套蓝图让你建造多个相同型号的汽车。每辆车一开始具有相同的属性,如颜色和里程,并且拥有相同的方法,如加速和刹车,但它们离开工厂后,情况可能会有所不同。一些汽车可能会重新喷漆,另一些可能会失去车轮对齐并向左偏移,它们的里程也会有所不同,等等。

在本章中,你将学习如何定义创建对象的类,编写对象的属性和方法,然后实例化这些对象。你还将编写继承其他类属性和方法的类,并使用数据类减少代码冗余。这个主题的介绍应当帮助你理解 OOP 的基本知识,并让你意识到作为程序员,你如何从中受益。

何时使用 OOP

面向对象编程在编写大型复杂程序时更容易理解,因为它帮助你将代码结构化为更易于理解的小部分。它还减少了代码重复,使得代码更易于维护、更新和重用。因此,大多数商业软件现在都是采用面向对象编程构建的。

因为 Python 是一种面向对象编程语言,你已经在使用其他人定义的对象和方法。但与 Java 等语言不同,Python 并不强制你为自己的程序使用 OOP。它提供了使用其他方法(如过程式编程或函数式编程)来封装和分离抽象层的方式。

拥有这种选择很重要。如果你在小型程序中实现 OOP,大部分程序可能会显得过于复杂。引用计算机科学家 Joe Armstrong 的话来说,“面向对象语言的问题在于它们携带了所有这些隐式的环境。你想要的是一根香蕉,但你得到的是一只拿着香蕉和整个丛林的猩猩!”

作为科学家或工程师,你可以在没有 OOP 的情况下完成很多工作,但这并不意味着你应该忽视它。OOP 使得同时模拟许多对象变得简单,比如一群鸟或一簇星系。当需要在计算机内存中长时间保存和操作某些事物时,例如 GUI 按钮或窗口,它也变得非常重要。而且,由于你会遇到的大多数科学软件包都是基于 OOP 构建的,因此你需要对这一范式有一定的了解。

创建一个新的 Spyder 项目

让我们创建一个新的 Spyder 项目,以便在本章中使用。如果你需要复习 Spyder 项目,请参见第 68 页中的“使用项目文件和文件夹”。

从你的base(根)环境启动 Spyder(无论是从开始菜单还是从 Anaconda Navigator)。在开始窗口中,它可能显示为 Spyder(anaconda3)。如果需要复习 conda 环境,请参见第二章。

接下来,在顶部工具栏中,点击ProjectsNew Project。在打开的创建新项目对话框中(见图 13-1),确保位置框包含你的主目录,将项目名称设置为oop,然后点击Create按钮。

图片

图 13-1:Spyder 创建新项目对话框

你现在应该能在 Spyder 的文件资源管理器面板中看到这个新文件夹。

为了方便起见,我们将使用位于anaconda3文件夹中的默认 conda 环境文件夹来存放第三方库。如果你希望使用与此项目绑定的环境文件夹,请参见第 37 页中的“指定环境位置”的说明。

定义护卫舰类

演示面向对象编程(OOP)比单纯讨论它要容易得多,因此我们来构建一些可能在(非常)简单的战争游戏模拟器中使用的船只对象。每种独特类型的船只都需要自己的类来生成该船只类型的多个版本。我们可以独立追踪和操作这些对象。通过 OOP,船只类、船只对象以及作用于这些对象的方法之间的关系将变得清晰、合逻辑且紧凑。

让我们从定义一个类开始,构建最常见类型的战舰,称为“护卫舰”(见图 13-2)。护卫舰设计要快速、机动性强且多功能,用于护航和保护大型舰船免受空中、表面和水下的威胁。

图片

图 13-2:巴西塔曼达雷级护卫舰

要构建护卫舰,你需要一个虚拟船坞,因此,在你的oop项目中,打开 Spyder 的文本编辑器并创建一个名为ships.py的新文件。将其保存在你喜欢的位置。

为了作为护卫舰的蓝图,使用class语句定义一个Frigate类。在class关键字之后,输入类的名称并加上括号:

class Frigate(object):
 ➊ designation = 'USS'

 ➋ def __init__(self, name):
        self.name = name
        self.crew = 200     
        self.length_ft = 450
        self.tonnage = 5_000
        self.fuel_gals = 500_000
        self.guns = 2
     ➌ self.ammo = self.guns * 300
        self.heading = 0
        self.max_speed = 24
        self.speed = 0

根据 PEP 8 风格指南,类名应该以大写字母开头。如果类名由多个单词组成,使用CapWords约定,即每个新单词首字母大写,且单词之间不加空格(也叫做CamelCase)。

Frigate类使用一个参数,object。这个object参数表示 Python 中所有类型的基类。因为object是默认参数,所以在定义类时可以省略显式声明它。

接下来,将字符串USS(代表“United States Ship”,即“美国战舰”)赋值给一个名为designation ➊的属性。这是船只的名称前缀。你也可以使用 HMS(“Her Majesty’s Ship”,即“英皇舰”)、INS(“Indian Naval Ship”,即“印度海军舰”)或任何你喜欢的名称。在 Python 中,属性是与对象相关联的任何变量。就像在函数中一样,类为属性创建一个新的局部命名空间。

类也是对象,因此它们也可以拥有自己的属性。类属性是所有从该类创建的对象共享的属性,表现得有点像全局变量。在这个例子中,你构建的所有护卫舰都会有“USS”前缀,例如“USS Saratoga”。类属性非常高效,因为它们允许你将共享属性存储在一个位置。

接下来,你定义一个初始化方法 ➋,用于设置对象的初始属性值。方法实际上是定义在类中的函数。__init__()方法是一个特殊的内置方法,Python 在创建新对象时会自动调用它。在这个例子中,它接受两个参数,self和对象的namename就是你为船只命名的名字。

注意

__init__()方法是一个双下划线(dunder)方法,意味着它的名字前后都有双下划线。它也叫做魔法方法或特殊方法,允许你创建像 Python 原生数据结构(例如列表、元组和集合)一样行为的类。它们也是运算符重载和其他函数行为自定义的魔法。当你调用内置的 len()函数时,例如,背后实际上是调用了一个__len__方法。

每个类方法的第一个参数,包括__init__(),总是对当前实例的引用,按约定称为self。(一个新对象被称为类的实例,而设置实例的初始值和行为的过程叫做实例化。)

你可以将self看作是你给对象命名的占位符。如果你创建一个船只对象并命名为Intrepidself就变成了Intrepidself.speed属性将变成“Intrepid 的速度”的引用。如果你实例化另一个船只对象并命名为Indefatigable,该对象的self就变成了Indefatigable。这样,Intrepid对象的speed属性的作用域就与Indefatigable对象的作用域相互独立。

现在是时候列出一些护卫舰的属性了。你需要为每艘船指定一个名称,以便区分不同的船只。你还需要指定一些关键的操作和战斗特性值,如燃料、航向和速度。由于这些属性与类的实例相关,因此被称为实例属性,并在__init__()方法内部分配(例如代码self.name = name)。

这些属性中的一些,比如大炮的数量和船只的长度,代表了每艘船共有的、不会随时间变化的值。然而,最好不要将它们设置为属性,因为它们可能会发生变化。例如,一艘特定的船只可能会装备额外的实验性炮台,或者它的直升机停机坪可能会延伸至船尾。其他属性,如航向和速度,代表了预期会变化的占位符。通常,你应该将属性设置为良好的默认值,比如将油箱加满。

注意

虽然可以使用方法在之后分配新的属性,但最好在__init__方法中初始化所有属性。这样,所有可用的属性都会方便地列出,且易于查找。

花点时间查看初始化方法中的属性列表。为了简洁起见,我省略了一些在真实模拟中可能需要的属性,比如船只当前的位置、当前的“健康”状况,以及反向速度的最大限制。你可能还想要为建造和操作船只设置“成本”属性,这样可以帮助你保持预算。

还要注意,在分配属性时,你可以像使用变量一样使用表达式。例如,我们假设船只每门大炮携带 300 发弹药 ➌。

现在,让我们定义一些方法来驾驶船只并开火。

定义实例方法

实例方法用于访问或修改对象的状态。它们必须具有self参数,以引用当前对象。

现在,让我们定义一个helm()方法,用于设置船只的航向和速度,并将速度限制在最大值。请在文本编辑器中输入以下内容(def语句应相对于类定义缩进四个空格):

    def helm(self, heading, speed):
     ➊ self.heading = heading
        self.speed = speed
        if self.speed > self.max_speed:
         ➋ self.speed = self.max_speed
        print(f"\n{self.name} heading = {self.heading} degrees")
        print(f"{self.name} speed = {self.speed} knots")

除了self参数外,你还需要传递一个航向(介于 0 到 359 度之间)和一个速度(单位为节)。

方法定义中的代码使用传递给它的参数更新对象的现有属性。要访问和更改属性,可以使用点符号。你之前已经使用过这种语法来调用像osrandom模块中的方法。要覆盖在__init__()方法中最初分配的航向和速度属性,只需将它们设置为传递给方法的值 ➊。

此时,你需要验证用户输入。确保速度值不超过船只的最大速度。将self.speed属性与self.max_speed属性进行比较。如果大于最大速度,将其设置为self.max_speed ➋。通过打印船的航向和速度来完成方法。

现在,让我们定义一个名为fire_guns()的方法,用于一次性发射所有大炮。除了self之外,你不需要传递任何参数给此方法:

    def fire_guns(self):
      ➌ if self.ammo >= self.guns:
            print("\nBOOM!")
          ➍ self.ammo -= self.guns
            print(f"\n{self.name} ammo remaining = {self.ammo} shells")
        else:
            print("\nInsufficient ammunition!")

首先,检查是否还有弹药 ➌。如果有,打印“BOOM!”然后,将self.ammo属性减去枪支数量(self.guns) ➍,并显示剩余的子弹数。否则,打印出弹药耗尽的消息。

这里需要注意的一点是,我们定义的方法没有返回任何内容。相反,它们直接在原地更改属性值。这种行为非常类似于在函数内更改全局变量(参见第十二章)。然而,这些方法之所以可以接受,是因为这些属性存在于类的范围内,而不是全局命名空间中。由于这些变化仅限于类的局部命名空间,所以跟踪和调试问题比使用全局变量要容易得多。

实例化对象并调用实例方法

我们已经定义了一个Frigate类以及一些用于操作Frigate对象的方法。现在,让我们实例化一艘船并开始使用它。将以下代码添加到ships.py文件中,并保存:

garcia = Frigate('garcia')
print(f"\n----------{Frigate.designation} {garcia.name.upper()}----------")
print(f"\nCrew complement = {garcia.crew}")
garcia.fire_guns()
garcia.fire_guns()
garcia.helm(heading=180, speed=30)

这段代码首先实例化了一个名为“Garcia”的新Frigate对象,并将其赋值给garcia变量。然后,它打印出船的名称,并调用内置的upper()方法以大写字母显示。

请注意,这段代码通过使用类名访问Frigate.designation类属性来打印“USS”标识。你可能注意到,在这里直接输入“USS”会更容易,而不是访问designation属性。我们使用这个属性是为了演示类属性的工作方式,同时也想强调类属性的一个常见问题:在许多情况下,你可以找到一个同样好的替代方案来避免使用它们。

接下来,代码打印船员配备情况,使用点符号表示法访问crew属性。最后,它发射大炮两次,然后改变船只的方向和速度。

如果运行文件,你应该会看到以下输出:

----------USS GARCIA----------

Crew complement = 200

BOOM!

garcia ammo remaining = 598 shells

BOOM! garcia ammo remaining = 596 shells

garcia heading = 180 degrees
garcia speed = 24 knots

使用我们的Frigate类模板,我们可以创建任意数量的船只。让我们再创建一艘名为“Boone”的船。在文本编辑器中输入以下内容并保存:

boone = Frigate('Boone')
print(f"\n----------{Frigate.designation} {boone.name.upper()}----------")
boone.fire_guns()
boone.fire_guns()
boone.helm(heading=270, speed=-1)

现在,运行它以查看此输出:

----------USS BOONE----------

BOOM!

Boone ammo remaining = 598 shells

BOOM!

Boone ammo remaining = 596 shells

Boone heading = 270 degrees
Boone speed = -1 knots

你现在有两艘船,它们使用相似的代码,但速度和航向不同。使用Frigate类和面向对象编程,你可以轻松创建和跟踪数百艘具有不同属性的船只。

测试你的知识

1.  面向对象编程(OOP)通过以下方式使代码更易于阅读、维护和更新:

a.  消除对函数的需求

b.  减少代码重复

c.  使用方法而非函数

d.  给猩猩提供香蕉

2.  由类创建的对象的名称是什么:

a.  子类

b.  属性

c.  实例

d.  方法

3.  判断题:方法是定义在类中的函数,并通过点符号调用。

4.  在 OOP 中,你可以将self参数理解为:

a.  调用的类名的占位符

b.  调用方法时的占位符

c.  创建对象时的占位符

d.  以上所有

5.  编写一个Parrot类,包含名称、颜色和年龄属性,并具有尖叫和“模仿”(重复)输入的方法。

使用继承定义导弹护卫舰类

今天,战舰上的炮已经大多被导弹系统取代(图 13-3)。我们可以通过简单地使用继承技术重新改造现有的Frigate类,轻松构建新的导弹护卫舰。

Image

图 13-3:护卫舰 HMS Iron Duke,发射她的 Harpoon 反舰导弹系统

面向对象编程(OOP)中的一个关键概念是继承,它允许你基于现有的父类或祖先类定义一个新的子类。(从技术上讲,原始类称为基类超类,新类称为派生类子类。)新子类继承了现有超类的所有属性和方法。这使得通过添加特定于子类的新属性和方法,复制和扩展现有的基类变得容易。

让我们创建一个新的导弹护卫舰类,名为GMFrigate,它继承并修改我们当前的Frigate类。在你的ships.py程序的底部输入以下内容:

class GMFrigate(Frigate):
 ➊ designation = Frigate.designation

    def __init__(self, name):
     ➋ Frigate.__init__(Frigate, name)
     ➌ self.missiles = 100 
        self.ammo = self.guns * 100

    def fire_missile(self):
        if self.missiles > 0:
            print("\nSSSSSSSSSSSttttt!")
            self.missiles -= 1
            print(f"\n{self.name} missiles remaining = {self.missiles}")
        else:
            print("\nMissiles depleted")
        self.missiles -= 1

要创建一个子类,传递class语句父类或超类的名称,在本例中是Frigate。记住,当你第一次定义Frigate时,你传递了object。这意味着Frigateobject类继承,object类是所有 Python 对象的根类。object类提供了所有派生类可能需要的常见方法的默认实现。现在通过传递Frigate而不是object,你将获得object下的属性和方法以及你添加到Frigate类中的新属性和方法。

导弹护卫舰将与护卫舰使用相同的“USS”命名方式,因此将一个designation类属性分配给相同的Frigate类属性,并使用点符号进行引用 ➊。你可以跳过这一步,只在需要时使用Frigate.designation属性,但通过显式重新分配类属性,你可以增加代码的清晰度。

接下来,我们为 GMFrigate 类定义 __init__() 初始化方法,该方法与 Frigate 类一样,包含 selfname 参数。在其下方,我们调用 Frigate 类的初始化方法 ➋,并传入 Frigate 而不是 self,以及一个 name 参数。传入 Frigate 类可以访问 Frigate.__init__() 方法中的所有属性,因此你无需重复编写代码,例如关于船员、吨位、火炮等内容。

如果你没有为子类定义 __init__() 方法,它将使用父类的 __init__() 方法。如果你想重写父类中的某些属性值,或添加新的属性,你需要为子类包含一个 __init__() 方法,正如我们在这个示例中所做的那样。

我们的原始驱逐舰类不支持导弹,因此添加一个新的 self.missile 属性 ➌。将导弹数量设为 100。由于这些导弹占用空间,你就没有足够的空间来存放其他弹药,因此通过将 self.ammo 属性设为火炮数量的 100 倍,而不是我们之前使用的 300 倍,来 覆盖 该属性。注意,这不会影响直接从原始 Frigate 类实例化的舰船的弹药数量;它们将使用父类的弹药设置。

你的舰船需要一种发射导弹的方式,因此定义一个新的方法 fire_missile(),其行为与之前定义的 fire_guns() 类似,但一次只发射一枚导弹。

实例化一个新的导弹驱逐舰对象

现在,你可以实例化一个新的导弹驱逐舰。我们将它命名为 “Ticonderoga”:

ticonderoga = GMFrigate('Ticonderoga')
print(f"\n------{ticonderoga.designation} {ticonderoga.name.upper()}------")
for _ in range(3):
    ticonderoga.fire_guns()
ticonderoga.fire_missile()
ticonderoga.helm(95, 22)

这段代码会生成以下输出:

------USS TICONDEROGA------

BOOM!

Ticonderoga ammo remaining = 198 shells

BOOM!

Ticonderoga ammo remaining = 196 shells

BOOM!

Ticonderoga ammo remaining = 194 shells

SSSSSSSSSSSttttt!

Ticonderoga missiles remaining = 99

Ticonderoga heading = 95 degrees
Ticonderoga speed = 22 knots

通过让你的新类继承 Frigate 类的属性和方法,你能够遵循软件开发中的 DRY(“不要重复自己”)原则,旨在减少软件模式的重复。不过,你需要小心不要对 Frigate 类做出任何更改,除非你希望这些更改也体现在 GMFrigate 类中。

注意

Python 允许使用多重继承,即子类可以从多个父类继承。这是通过在类定义中传递以逗号分隔的父类名称来实现的。如果父类中的方法名称没有重叠,使用多重继承是直接的。当方法名称重叠时,Python 会使用一种叫做方法解析顺序(MRO)的过程来解决问题。这可能会比较棘手,因此在大多数情况下,建议使用单一继承、不继承,或者确保所有父类的属性和方法名称不同的情况。

使用 super() 函数进行继承

super() 内置函数消除了在调用基类方法时显式调用基类名称的需要。它适用于单继承和多继承。例如,在 GMFrigate 类定义中,你在 GMFrigate 类的 __init__() 方法中调用了 Frigate 类的 __init__() 方法,如下所示:

    def __init__(self, name):
        Frigate.__init__(Frigate, name)

这让 GMFrigate 类从 Frigate 类继承。或者,你可以使用 super() 函数,它返回一个代理对象,允许访问基类的方法:

    def __init__(self, name):
        super().__init__(name)

在这种情况下,super() 消除了显式调用 Frigate 类的需要。在使用单一继承时,super() 只是一个更高级的方式来引用基类类型。它使代码更易于维护。例如,如果你在代码中到处使用 super() 并且想更改基类的名称(比如从 Frigate 改为 Type26Frigate),你只需要在定义基类时更改一次名称。

super() 的另一个用途是访问在新类中被重写的继承方法。让我们看一个例子,我们定义一个 Destroyer 类,该类包括了较小型的护卫舰(另一种类型的军舰)上的炮火,外加一些更大的炮火。在文本编辑器中启动一个新的 super_destroyer.py 文件,然后输入以下内容:

   class Corvette:
       def fire_guns(self):
           print('boom!')

➊ class Destroyer(Corvette):
       def fire_guns(self):
        ➋ super().fire_guns()
           print('BOOM!')

首先,我们定义一个 Corvette 类,并为其添加一个触发炮火的方法。因为这些炮火相对较小,所以它们发出的声音是小写的“boom”。接下来,我们定义一个从 Corvette 类继承的 Destroyer 类 ➊。它有自己的 fire_guns() 方法,会为其大炮打印“BOOM!”。

要触发驱逐舰上的小型炮火,请使用 super() 函数 ➋。因为“super”指的是基类,它调用了 Corvette 类的 fire_guns() 方法。

现在,让我们实例化一个护卫舰和驱逐舰对象,并触发它们的炮火:

print('-----A Corvette-----')
corvette = Corvette()
corvette.fire_guns()

print('\n-----A Destroyer-----')
destroyer = Destroyer()
destroyer.fire_guns()

这是输出结果。注意到“boom”的两种版本都被驱逐舰对象打印出来:

-----A Corvette-----
boom!

-----A Destroyer-----
boom!
BOOM!

注意

使用 super() 是有些争议的。一方面,它使代码更易于维护;另一方面,它使代码变得不够显式,这违反了 Python 的禅宗格言“显式优于隐式。”

对象中的对象:定义舰队类

回到我们的战争模拟,让我们创建一个 Fleet 类来操作我们已经实例化的所有舰船对象。没错:使用面向对象编程(OOP),对象可以控制其他对象。

在编辑器中,将以下代码添加到 ships.py 文件的底部:

class Fleet():

    def __init__(self, name, list_of_ships):
        self.fleet_name = name
        self.ships = list_of_ships
        self.fleet_heading = 0  
        self.fleet_max_speed = 0  
        self.fleet_speed = 0

这个类的初始化方法看起来和 Frigate 类的初始化方法很像,只不过现在它有一个参数用于传递舰船列表。这个列表数据类型的项是之前实例化的舰船对象,比如 garciaboone

现在为这个类定义一些方法:

    def find_fleet_max_speed(self):
     ➊ max_speeds = [ship.max_speed for ship in self.ships]
        print(f'\nMaximum ship speeds = {max_speeds} knots')
     ➋ self.fleet_max_speed = min(max_speeds)
        print(f'Fleet maximum speed = {self.fleet_max_speed} knots')

    def fleet_helm(self, heading, speed):
        self.fleet_heading = heading
        self.fleet_speed = speed
     ➌ if self.fleet_speed > self.fleet_max_speed:
            self.fleet_speed = self.fleet_max_speed
        print(f"\n{self.fleet_name} heading = {self.fleet_heading} degrees")
        print(f"{self.fleet_name} speed = {self.fleet_speed} knots") 
        for ship in self.ships:
            ship.heading = self.fleet_heading
            ship.speed = self.fleet_speed

一个舰队的速度不能超过最慢的船,因此需要定义一个方法来设置舰队的最大速度,就像我们之前为单个舰船所做的那样。第一步是使用列表推导,遍历self.ships列表中的船只,并将它们的最大速度(在ship.max_speed属性中找到)追加到一个名为max_speeds的新列表中 ➊。

当列表完成后,打印它,并通过调用内置的min()函数找到最慢舰船的最大速度 ➋,并将self.fleet_max_speed属性设置为该最大速度。最后,打印舰队的最大速度属性。

接下来,定义一个方法来设置舰队的航向和速度。这个方法与我们为单个舰船设置这些值时使用的技术类似。和之前一样,我们会将速度限制在最大速度范围内,以防用户输入无效的速度 ➌。然后,我们打印信息,并遍历self.ships列表中的每艘船,设置它们的航向和速度。

让我们通过实例化一个包含之前创建的 Garcia、Boone 和 Ticonderoga 舰船对象的“第七舰队”来测试Fleet类。输入以下内容,然后保存并运行程序:

ships = [garcia, boone, ticonderoga]
seventh = Fleet("Seventh", ships) 
print(f"\nShips in {seventh.fleet_name} fleet:") 
for ship in seventh.ships:
    print(f"\t{ship.name.capitalize()}") 

seventh.find_fleet_max_speed()
seventh.fleet_helm(42, 28)
print(f"\ngarcia helm = {garcia.heading, garcia.speed}") print(f"boone helm = {boone.heading, boone.speed}")
print(f"ticonderoga helm = {ticonderoga.heading, ticonderoga.speed}")

这将产生以下输出:

Ships in Seventh fleet:
     Garcia
     Boone
     Ticonderoga

Maximum ship speeds = [24, 24, 24] knots
Fleet maximum speed = 24 knots

Seventh heading = 42 degrees
Seventh speed = 24 knots

garcia helm = (42, 24)
boone helm = (42, 24)
ticonderoga helm = (42, 24)

由于所有的船都是护卫舰,它们的最大速度没有区别。但如果你有驱逐舰、航母等,你会在max_speeds列表中看到不同的值。

使用Fleet类及其fleet_helm()方法,你可以同时为舰船分配相同的航向和速度。如果你想要覆盖这些设置,也可以通过调用单个船只的self.helm()方法来实现,例如:

garcia.helm(heading=50, speed=24)
print(f"\ngarcia helm = {garcia.heading, garcia.speed}")
print(f"boone helm = {boone.heading, boone.speed}")
print(f"ticonderoga helm = {ticonderoga.heading, ticonderoga.speed}")

现在,Garcia 的航向与舰队中其他船只的航向不同:

garcia heading = 50 degrees
garcia speed = 24 knots

garcia helm = (50, 24)
boone helm = (42, 24)
ticonderoga helm = (42, 24)

通过数据类减少代码冗余

Python 3.7 中引入的内置dataclass模块提供了一种方便的方式来使类变得更简洁。虽然数据类主要设计用于存储数据的类,但它们像普通类一样工作,并可以包括与数据交互的方法。一些使用场景包括银行账户类、科学文章内容类和员工信息类。

数据类已经实现了基本的“模板”功能。你可以直接实例化、打印和比较数据类实例,许多在类中常做的事情,比如根据传入的参数实例化属性,都可以简化为几条基本指令。

注意

代码检查器通常会抱怨如果在类中使用超过七个实例属性。这似乎与数据类的目的相矛盾,因为数据类的目的是存储数据。此外,在科学领域,这一限制可能很难遵守,因为通常需要许多属性。尽管可以忽略代码检查器的建议,但你仍然应该尽量限制每个类的实例属性数量,以减少复杂性。你可以尝试将一些作为类属性处理,其他的移动到父类中,合并一些为一个单独的属性,等等。

使用装饰器

数据类是使用一种有用且强大的 Python 工具——装饰器来实现的。装饰器是一个设计用来包裹(封装)另一个函数或类的函数,以改变或增强被包裹对象的行为。它使你能够修改行为,而无需永久改变对象。装饰器还可以让你在多个函数上运行相同的过程时避免代码重复,例如检查内存使用情况、添加日志记录或测试性能。

装饰器基础

为了了解装饰器是如何工作的,让我们定义一个函数来计算一个数字的平方。然后,我们将定义一个装饰器函数来对该结果进行平方。请在控制台输入以下内容:

In [1]: def square_it(x):
   ...:     return x**2

In [2]: def square_it_again(func):
   ...:     def wrapper(*args, **kwargs):
   ...:         result = (func(*args, **kwargs))**2 
   ...:         return result
   ...:     return wrapper

第一个函数 square_it()接受一个数字,由x表示,并返回它的平方。第二个函数 square_it_again()将作为第一个函数的装饰器,稍微复杂一些。

装饰器函数有一个func参数,表示一个函数。因为函数是对象,你可以将一个函数作为参数传递给另一个函数,甚至在一个函数内定义一个函数。当我们调用这个装饰器函数时,我们会将 square_it()函数作为参数传递给它。

接下来,我们定义一个内部函数,叫做wrapper()。因为 square_it()需要一个参数,所以我们需要设置内部函数来处理参数,使用特殊的位置参数和关键字参数*args**kwargs

wrapper()函数内,我们调用传递给装饰器的函数(func),对其输出进行平方,将结果赋值给result变量,并返回result。最后,我们返回wrapper()函数。

要使用square_it_again()装饰器,调用它,将你想要装饰的函数(square_it())传递给它,并将结果赋值给一个变量(square),该变量同样代表一个函数:

In [3]: square = square_it_again(square_it)

In [4]: type(square)
Out[4]: function

现在你可以调用这个新函数,并传入一个合适的参数:

In [5]: print(square(3))
81

在这个例子中,我们手动调用了装饰器函数。这展示了装饰器是如何工作的,但它有点冗长和复杂。在下一节中,我们将查看一种更方便的使用装饰器的方法。

装饰器语法糖

在计算机科学中,语法糖是简洁明了的语法,简化了语言,使其对人类使用更加“甜美”。装饰器的语法糖是@符号,它必须紧跟装饰器函数的名称。接下来的行必须是被包装函数或类的定义语句,如下所示:

@decorator_func_name
def new_func():
     do something

在这种情况下,decorator_func_name表示装饰器函数,new_func()是被包装的函数。类定义可以替代def语句。

为了查看其工作原理,让我们重新创建一个数字平方的示例。使用箭头键调出控制台中之前定义的square_it_again()函数。因为我们必须在定义要包装的函数之前调用装饰器,所以我们必须将代码顺序与之前的示例相反地编写。

现在,添加装饰器并在下一行定义.square_it()函数。请注意,在使用@符号时,装饰器函数的名称后不带括号:

In [7]: @square_it_again
   ...: def square_it(x):
   ...:     return x**2

要使用装饰后的函数,只需调用它并传入一个数字:

In [8]: square_it(3)
Out[8]: 81

请注意,使用@装饰器时,我们不需要像在In [3]行中那样使用square函数。

如果装饰器让你感到有点头晕,不用担心。如果你能输入@dataclass,那么你就可以使用数据类了。这个装饰器修改了常规的 Python 类,使得你可以使用更简洁的语法来定义它们。

定义船只类

为了看到数据类的优势,让我们定义一个常规类,然后使用数据类重复这个练习。我们的目标是创建通用的船只对象,供我们在模拟网格上进行跟踪。对于每个船只,我们需要提供一个名称、一个分类(如“护卫舰”)、一个注册国家和一个位置。

将船只定义为常规类

要定义一个名为Ship的常规类,在文本编辑器中输入以下内容,然后将其保存为ship_tracker.py

class Ship:
 ➊ def __init__(self, name, classification, registry, location):
        self.name = name
        self.classification = classification
        self.registry = registry
        self.location = location
    ➋  self.obj_type = 'ship'
        self.obj_color = 'black'

这段代码看起来与我们之前定义的Frigate类非常相似。不过,这次__init__()方法包括更多的参数➊。所有这些数据在实例化基于此类的对象时都需要作为参数传递。

注意,我们被迫通过重复每个参数名称(如classification)来复制代码:一次作为参数,另两次用于分配实例属性。你需要传递给方法的数据越多,这种冗余就越大。

除了传递给初始化方法的参数外,Ship类还包括两个“固定”属性,分别表示对象类型➋和颜色。这些属性使用等号赋值,就像常规类一样。因为这些属性对于给定的对象始终相同,所以无需将其作为参数传递。

现在,让我们实例化一个新的船只对象。输入以下内容,保存文件并运行:

garcia = Ship('Garcia', 'frigate', 'USA', (20, 15))
print(garcia)

这创建了一个名为garcia的美国护卫舰,位于网格位置(20, 15)。但是,当你打印对象时,输出并不太有帮助:

<__main__.Ship object at 0x0000021F5FF501F0>

这里的问题是,打印对象信息需要你定义额外的双下划线方法,如__str____repr__,它们返回对象的字符串表示,供信息性和调试使用。另一个有用的方法是__eq__,它允许你比较类的实例。Python 中有很多特殊方法,这里列出了一些基本的例子,详见表 13-1。

表 13-1: 基本特殊方法

特殊方法 描述
__init__(self) 在从类初始化一个对象时被调用。
__del__(self) 被调用以销毁一个对象。
__repr__(self) 返回一个可打印的字符串,用于调试时显示对象信息。
__str__(self) 返回一个字符串,用于漂亮地打印有关对象的有用信息。如果未实现,则使用__repr__
__eq__(self, other) 执行两个对象的相等比较(==)。

为每个你编写的类定义这些方法可能会变得繁琐,这就是数据类派上用场的地方。数据类自动处理有关属性和双下划线方法的冗余问题。

将 Ship 定义为数据类

现在,让我们再次将Ship类定义为数据类。将此操作放在名为ship_tracker_dc.py(即“船只跟踪数据类”)的新文件中:

   from math import dist
   from dataclasses import dataclass

   @dataclass
➊ class Ship:
    ➋ name: str
       classification: str
       registry: str
       location: tuple
    ➌ obj_type = 'ship'
       obj_color = 'black'

首先导入mathdataclass模块。我们将使用math中的dist方法来计算船只之间的距离,并使用dataclass来装饰我们的Ship类。要使用dist,你需要 Python 3.8 或更高版本。

接下来,使用@符号将dataclass前缀化,使其成为装饰器。在下一行定义Ship类,以便让装饰器知道它正在包装这个类➊。

通常,下一步是定义带有self和其他参数的__init__()方法,但数据类不需要这个。初始化是在幕后处理的,这样就不需要这段代码了。不过,你仍然需要列出属性,但与之前相比冗余大大减少。

对于每个必须作为参数传递的属性,输入属性名称,后跟冒号,再跟一个类型提示 ➋。类型提示或类型注解告诉阅读代码的人预期的数据类型。静态分析工具可以使用类型提示检查代码中的错误。类型提示在 PEP 484 中引入(www.python.org/dev/peps/pep-0484/)。

带有类型提示的类变量称为字段@dataclass装饰器检查类以查找字段。如果没有类型提示,该属性将不会成为数据类中的字段。在这个例子中,Ship类中的所有字段都使用字符串数据类型(str),除了location,它使用tuple(表示一对 x、y 坐标)。有关常见数据类型的提醒,请参见表 7-5 在第 184 页。

注意

你可以使用类型注解的默认值。例如,location: tuple = (0, 0) 将把新创建的 Ship 对象放置在坐标 x = 0, y = 0 的位置,如果在创建对象时未指定位置。然而,当你使用默认参数时,所有后续的参数必须也有默认值。

因为我们在创建新对象时不需要传递obj_typeobj_color属性作为参数,所以我们用等号定义它们而不是冒号,并且不使用类型提示➌。通过像普通类一样赋值,每个Ship对象默认会被指定为“船只”,并具有一致的颜色属性用于绘图。

数据类(Dataclasses)可以像常规类一样具有方法。让我们定义一个方法来计算两艘船之间的欧几里得距离。def语句应该相对于类定义缩进四个空格:

    def distance_to(self, other):
        distance = round(dist(self.location, other.location), 2)
        return str(distance) + ' ' + 'km'

distance_to()方法接受当前船只对象和另一个船只对象作为参数。然后,它使用内置的dist方法来计算它们之间的距离。该方法返回两点(x 和 y)之间的欧几里得距离,其中 x 和 y 是该点的坐标。距离以字符串形式返回,因此我们可以包含公里数的引用。

现在,在全局作用域中,不进行缩进,创建三个船只对象,并传递以下信息:

garcia = Ship('Garcia', 'frigate', 'USA', (20, 15))
ticonderoga = Ship('Ticonderoga', 'destroyer', 'USA', (5, 10))
kobayashi = Ship('Kobayashi', 'maru', 'Federation', (10, 22))

一旦你开始输入Ship()类的参数,Spyder 文本编辑器中应该会弹出一个窗口,提示你输入正确的内容(图 13-4)。

图片

图 13-4:Spyder 文本编辑器弹出窗口,显示 Ship 类中参数的名称和数据类型。

因为你创建的类是 Python 中的合法数据类型,它们的行为与内置数据类型类似。因此,Spyder 编辑器会使用类型提示来指导你创建船只对象。在下一章中,我们将讨论如何正确地记录类,以便在图 13-4 中替换掉“No documentation available”消息,变成类似“用于在网格上跟踪船只的对象”这样的一行类描述。

还值得注意的是,你不需要为参数使用正确的数据类型。因为 Python 是一个动态类型语言(见第七章的第 184 页),你可以将整数作为classification参数传递,程序仍然会运行。下面是一个包含错误参数的示例,错误部分以灰色突出显示(请不要将其添加到代码中):

test = Ship('Test', 42, 'HMS', (15, 15))

注意

尽管 Python 解释器会忽略类型提示,你可以使用第三方静态类型检查工具,如 Mypy(mypy.readthedocs.io/),在程序运行之前分析代码并检查错误。

@dataclass 装饰器是一个代码生成器,它会自动添加方法。这包括 __repr__ 方法。这意味着,当你调用 print(garcia) 时,你现在会看到有用的信息:

print(garcia)
Ship(name='Garcia', classification='frigate', registry='USA', location=(20, 15))

现在,让我们检查数据是否存在,并且方法是否正常工作。添加以下代码行并重新运行脚本:

ships = [garcia, ticonderoga, kobayashi]
for ship in ships:
    print(f"The {ship.classification} {ship.name} is visible.")
    print(f"{ship.name} is a {ship.registry} {ship.obj_type}.")
    print(f"The {ship.name} is currently at grid position {ship.location}\n")

print(f"Garcia is {garcia.distance_to(kobayashi)} from the Kobayashi")

通过将船只对象放入一个列表中,我们可以遍历该列表,使用点符号访问属性,并打印结果:

The frigate Garcia is visible.
Garcia is a USA ship.
The Garcia is currently at grid position (20, 15)

The destroyer Ticonderoga is visible.
Ticonderoga is a USA ship.
The Ticonderoga is currently at grid position (5, 10)

The maru Kobayashi is visible.
Kobayashi is a Federation ship.
The Kobayashi is currently at grid position (10, 22)

Garcia is 12.21 km from the Kobayashi

Ship 数据类让你实例化一个船只对象,并在类型注解的字段中存储诸如船只名称和位置等数据。通过减少冗余并自动生成所需的类方法,例如 __init__()__repr__()@dataclass 装饰器使得你可以编写更易于阅读和编写的代码。

注意

@classmethod 和 @staticmethod 装饰器让你可以在类的命名空间内定义与类的特定实例无关的方法。它们不常用,通常可以用普通函数替代。然而,你应该知道它们的存在,因为它们在 OOP 教程中经常提到,在某些情况下也很有用。

使用 Ship 数据类绘图

为了更好地理解如何使用面向对象编程(OOP),让我们进一步推进这个项目,并在网格上绘制我们的船只对象。绘制船只时,我们将使用 Matplotlib 绘图库。(我们将在本书后面更详细地讨论 Matplotlib。)要在你的基础环境中安装该库,打开 Anaconda Prompt(在 Windows 中)或终端(在 macOS 或 Linux 中),并输入以下内容:

conda activate base
conda install matplotlib

如果提示输入y,请按提示操作,如果你已经安装了 Matplotlib,不用担心,因为 Anaconda 会根据需要更新该软件包。

注意

如果你在使用 Spyder,并且不确定当前激活的是哪个 conda 环境,可以在控制台输入 conda info。这样会显示当前环境及其路径。

在文本编辑器中,将你的 ship_tracker_dc.py 文件保存或复制到一个新文件 ship_display.py,并按如下方式编辑:

   from math import dist
   from dataclasses import dataclass
➊ import matplotlib.pyplot as plt

   @dataclass
   class Ship:
       name: str
       classification: str
       registry: str
       location: tuple
       obj_type = 'ship'
       obj_color = 'black'

       def distance_to(self, other):
           distance = round(dist(self.location, other.location), 2)
           return str(distance) + ' ' + 'km'

   garcia = Ship('Garcia', 'frigate', 'USA', (20, 15))
   ticonderoga = Ship('Ticonderoga', 'destroyer', 'USA', (5, 10))
   kobayashi = Ship('Kobayashi', 'maru', 'Federation', (10, 22))

➋ VISIBLE_SHIPS = [garcia, ticonderoga, kobayashi]

➌ def plot_ship_dist(ship1, ship2):
       sep = ship1.distance_to(ship2)  
       for ship in VISIBLE_SHIPS:
        ➍ plt.scatter(ship.location[0], ship.location[1],
                       marker='d',
                       color=ship.obj_color)
           plt.text(ship.location[0], ship.location[1], ship.name)
    ➎ plt.plot([ship1.location[0], ship2.location[0]],
                  [ship1.location[1], ship2.location[1]],
                  color='gray', 
                  linestyle="--")
       plt.text((ship2.location[0]), (ship2.location[1] - 2), sep, c='gray')
       plt.xlim(0, 30)
       plt.ylim([0, 30])
       plt.show()

➏ plot_ship_dist(kobayashi, garcia)

首先添加一行代码以导入 Matplotlib ➊。在实例化三个船只对象后,替换从 ➋ 开始的其余代码。这行代码将三个船只对象的列表赋值给变量 VISIBLE_SHIPS,它表示你在仿真网格上可以看到的船只。我们将其视为常量,因此使用大写格式。

接下来,定义一个函数来计算两个船只(ship1ship2)之间的距离,并绘制所有可见的船只 ➌。对两个船只调用 Ship 类的 distance_to() 方法,将结果赋值给名为 sep(表示分离)的变量,然后遍历 VISIBLE_LIST,在散点图中绘制每个船只 ➍。为此,Matplotlib 需要船只的 x 和 y 位置、标记样式('d' 表示菱形形状)以及颜色(ship.obj_color 属性)。注意你可以在每个参数后面的逗号处输入换行,以便使输入更加易读“堆叠”。

现在,使用 Matplotlib 的plt.plot()方法在用于距离测量的船只之间绘制虚线 ➎。该方法接受每艘船的 x–y 坐标、颜色和线条样式。接着,使用plt.text()方法向图表中添加文本,传入一个位置、sep变量和颜色作为参数。

完成函数后,设置图表的 x 和 y 坐标限制,然后调用plt.show()方法来显示图表。在全局作用域中,调用该函数并传入kobayashigarcia船只对象 ➏。

保存并运行文件。你应该能看到图 13-5 中显示的图表。

图片

图 13-5:ship_display.py 程序的输出

将数据和方法封装到类中可以生成紧凑、直观的对象,方便你批量操作。得益于面向对象编程(OOP),我们可以轻松地在网格上生成和追踪成千上万的船只对象。

通过字段和后初始化处理来识别敌友

有时,你可能需要初始化一个依赖于另一个属性值的属性。由于另一个属性必须已经存在,因此你需要在__init__函数外初始化第二个属性。幸运的是,Python 提供了内置的__post_init__函数,专门用于此目的。

让我们通过一个基于战争游戏模拟的例子来看看。由于联盟关系可能随时间变化,某一国家注册的船只可能从盟友转变为敌人。尽管registry属性是固定的,但它的归属关系是不确定的,你可能想在初始化后评估它的敌友状态。

为了创建一个能够满足这个需求的Ship数据类版本,请在文本编辑器中输入以下内容,并将其保存为ship_allegiance_post_init.py

from dataclasses import dataclass, field

@dataclass
class Ship:
    name: str
    classification: str
    registry: str
    location: tuple
    obj_type = 'ship'
    obj_color = 'black'
 ➊ friendly: bool = field(init=False)

 ➋ def __post_init__(self):
        unfriendlies = ('IKS')
        self.friendly = self.registry not in unfriendlies

在这种情况下,我们首先从dataclasses模块导入dataclassfieldfield函数帮助你更改数据类属性的各种属性,例如为它们提供默认值。

接下来,我们初始化Ship类,就像在ship_tracker_dc.py程序中那样,不同的是我们添加了一个新的属性friendly,其数据类型为布尔类型,默认值为False ➊。请注意,我们通过调用field函数并使用关键字参数init来设置这个默认值。

现在,我们定义__post_init__()方法,并以self作为参数 ➋。接着,我们将一组敌对的注册名称赋给一个名为unfriendlies的变量。最后,通过检查当前对象的self.registry属性是否出现在unfriendlies元组中,我们为self.friendly属性赋值为TrueFalse

让我们通过创建两艘船来测试它,一艘是友好的,另一艘是敌对的。请注意,你并没有为Ship类传递friendly属性的参数;这是因为它使用默认值,最终由__post_init__()方法决定:

homer = Ship('Homer', 'tug', 'USA', (20, 9))
bortas = Ship('Bortas', 'D5', 'IKS', (15, 25))

print(homer)
print(bortas)

这会产生以下结果:

Ship(name='Homer', classification='tug', registry='USA', location=(20, 9), friendly=True)
Ship(name='Bortas', classification='D5', registry='IKS', location=(15, 25), friendly=False)

你可能已经注意到,不需要显式调用__post_init__()方法。这是因为dataclass生成的__init__()代码会在类中定义该方法时自动调用它。

注意

继承在数据类中与常规类中的工作方式大体相同。需要注意的一点是,数据类通过一种方式组合属性,这种方式会阻止在子类中使用具有默认值的父类属性,尤其是当子类中包含没有默认值的属性时。因此,你应该避免在作为基类使用的类上设置字段默认值。

使用 slots 优化数据类

如果你使用数据类存储大量数据,或者预计从一个类实例化成千上万个对象,你应该考虑使用类变量__slots__。这个特殊属性通过减少内存消耗和访问属性的时间来优化类的性能。

一个常规类将实例属性存储在一个名为__dict__的内部管理字典中。__slots__变量通过使用在 C 编程语言中实现的高效数组相关数据结构来存储它们。

下面是一个示例,使用了一个名为Ship的标准数据类,接着是一个使用__slots__ShipSlots数据类。将此代码输入文本编辑器,并将其保存为ship_slots.py

from dataclasses import dataclass

@dataclass
class Ship:
    name: str
    classification: str
    registry: str
    location: tuple

@dataclass
class ShipSlots:
 ➊ __slots__ = 'name', 'classification', 'registry', 'location'

    name: str
    classification: str
    registry: str
    location: tuple

这两个类定义之间的唯一区别是将一个属性名元组分配给__slots__变量 ➊。这个变量允许你明确声明期望对象具有哪些实例属性。现在,代替拥有一个允许在对象创建后添加属性的动态字典(__dict__),你有了一个静态结构,它为使用__slots__的每个对象节省了一个字典的开销。由于通常建议一次性初始化对象的所有属性,因此无法使用__slots__动态添加属性并不一定是坏事。

然而,使用__slots__与多重继承结合时可能会出现问题。同样,当通过类属性为实例变量提供默认值时,你也应该避免使用它。你可以在官方文档中的* docs.python.org/3/reference/datamodel.html#slots/* 和这个Stack Overflow回答中找到更多注意事项,链接为* stackoverflow.com/questions/472000/usage-of-slots/*。

创建类模块

在前一章中,我们使用模块来抽象代码。一个包含一个或多个类声明的程序也可以作为模块使用,让你在当前代码中使用这些类,而无需重新定义它们。

让我们通过一个例子来逐步讲解,使用你在上一节中制作的 ship_slots.py 程序。在控制台中,首先导入 ship_slots.py 程序:

In [9]: import ship_slots as slots

现在你可以像在控制台中定义它们一样使用它的类:

In [10]: garcia = slots.Ship('Garcia', 'frigate', 'USS', (10, 20))

In 11]: garcia
Out[11]: Ship(name='Garcia', classification='frigate', registry='USS', location=(10, 20))

创建一个类模块对于构建完整的战争游戏模拟将非常有用。你可以将各种船只、舰队和显示类转化为模块化的类库,然后在构建个别模拟时导入这些模块。这将使你专注于当前模拟的代码,而不会遇到类声明的“杂乱”。

如果你忘记了类名或每个类所需的参数,只需开始实例化一个新对象。Spyder 将弹出一个窗口提示你这些值(见 图 13-6 和 13-7)。

Image

图 13-6:Spyder 弹出窗口列出 ship_slots 模块中的类

Image

图 13-7:Spyder 弹出窗口列出 Ship 类使用的参数和数据类型

如果你使用常规类而非 dataclass,这些提示会少一些详细信息。在下一章中,你将学习如何文档化类,并且 图 13-7 中的文档注释将非常有用。

测试你的知识

  1. super() 内置函数消除了以下需求:

a. 为每个实例属性指定适当的数据类型

b. 每个类实例共有的类属性

c. 显式调用基类名称来调用方法

d. 一个初始化(__init__())方法

  1. dataclass 设计用于当你有:

a. 许多需要大量参数的方法

b. 拥有大量属性但方法较少的类

c. 一个需要大量参数的初始化方法

d. a. 和 b.

e. b. 和 c.

  1. 装饰器的“语法糖”符号是:

a. #

b. @

c. **

d. //

  1. 对错:类型提示指定了你应该使用的属性数据类型。

  2. 后初始化处理用于:

a. 替换类定义中的 __init__() 方法

b. 在 __init__() 方法外初始化属性

c. 为大数据集优化内存使用

d. 在创建大量对象时优化处理速度

  1. 类变量 __slots__ 通过以下方式减少内存占用:

a. 替换通常用于存储实例属性的 __dict__ 字典

b. 在幕后实现 C

c. 使用动态字典与静态数据结构

d. a. 和 b.

e. b. 和 c.

  1. 编辑 ship_display.py 程序,使其在网格中移动 Garcia,同时持续更新到 Kobayashi 的距离(提示:你需要一个 for 循环)。

总结

面向对象编程 帮助你组织代码,同时减少冗余。 让你将相关的数据和操作这些数据的函数结合成新的自定义数据类型。

面向对象编程中的函数称为方法。当你使用class语句定义一个时,你将相关的元素组合在一起,使数据与方法之间的关系变得清晰,并确保合适的方法与适当的数据一起使用。因此,当你有多种类型的数据、与每种数据相关的多个函数,以及一个日益复杂的代码库时,你会想考虑使用类。

class是创建对象(也称为实例)的模板或工厂。你通过使用函数符号调用类的名称来创建对象。与常规函数一样,这种做法引入了一个新的局部名称空间,所有在class语句中分配的名称都会生成类的对象属性,这些属性是所有类实例共享的。属性用于存储数据,每个对象的属性可能随着时间的推移而改变,以反映对象状态的变化。

类可以继承其他类的属性和方法,从而实现代码重用。在这种情况下,新的类是子类或子类,而已有的类是父类或基类。继承的属性和方法可以在子类中被重写,以修改或增强继承的行为。通过super()函数,你可以调用基类中的原始方法,即使这些方法在子类中已经被修改。由于 Python 允许类从多个父类继承,这可能导致复杂的代码,难以理解。

装饰器是修改另一个函数行为的函数,但不会永久改变被修改的函数。它们还帮助你避免重复代码。@dataclass装饰器装饰class语句,使其更加简洁。尽管数据类是为主要存储数据的类设计的,它们仍然可以作为常规类使用。然而,缺点是,与常规类相比,使用数据类时多重继承可能会变得更加困难。

__slots__类变量优化了内存使用和属性访问速度。然而,它也有一些限制,例如,初始化后无法动态创建属性,并且在使用多重继承时,复杂度会增加。

你可以将相关的class语句组合在一起并保存为 Python 文件。这些类库随后可以作为模块导入到其他程序中。像 Spyder 这样的集成开发环境(IDE)将提示用户正确的类名、参数、方法和文档,从而无需查看所有的类定义代码。

面向对象编程(OOP)远比我们在这里讨论的内容要广泛;毕竟它就是整个丛林。如果你认为你的项目可以从 OOP 中受益,并且希望进一步探索这个主题,你可以查看官方的 Python 类教程,地址为 docs.python.org/3/tutorial/classes.html,官方的数据类文档,地址为 docs.python.org/3/library/dataclasses.html,以及 PEP 557 数据类增强提案,地址为 www.python.org/dev/peps/pep-0557/

第十四章:记录你的工作

image

Python 因其代码的可读性而著名,但这种可读性只能带你走到一定程度。为了与他人协作,并提醒自己为什么做出这样的选择,你需要依靠自然语言来传达信息,使你的意思尽可能明确,或者解释程序的目的。Python 通过注释和文档字符串(docstrings)实现这一点。

注释是计算机程序中的不可执行注解。文档字符串(docstring)是多行字符串,不赋值给任何变量,用来为 Python 模块、类、方法和函数添加文档。注释和文档字符串一起构成了代码文档。

良好的文档能够清晰表达你的意图,并为未来的用户(包括你自己)节省时间和精力。没有理由逆向工程代码的部分内容或浪费时间去理解没有上下文的晦涩参数或数字。

适当的文档也可能包括编程过程中学到的经验教训,并能标记出潜在问题,例如跨操作系统工作时遇到的问题。这些能够帮助你传递宝贵的知识,避免其他人在独立发现并处理这些问题时浪费时间。

由于控制台中生成的代码通常是临时的,因此你只需要为持久程序记录文档,例如在文本编辑器或 Jupyter Notebook 中生成的程序。这些文件会保存到磁盘上并重复使用,有时会在几个月后再次使用,因此记录任何不明显的意图和假设是非常重要的。

注释

注释是你在代码中添加的说明,用来提醒你正在做什么,解释新代码块的目的,标记待办事项,或者暂时“关闭”你不希望执行的代码。它们在别人需要理解和修改你的工作时尤其有用。

注释以井号(#)符号开始,告诉 Python 忽略(不执行)同一行中剩余的代码。下面是一个例子:

# Step 1: Crop image to 50x50 pixels.

在控制台和文本编辑器中,注释的颜色通常与常规代码不同。如果你使用的是“Spyder”语法高亮主题(详见第 64 页的“配置 Spyder 界面”),注释会显示为灰色,文档字符串会显示为绿色。

注释可以出现在单行中,跨越多行,或者嵌入在代码行中。后者称为内联注释。

和变量名一样,注释应该尽量简洁,通常需要多次迭代才能做到最好。如果注释太长或太多,它们会分散注意力,用户可能会忽视它们。如果注释过于简短且含糊不清,它们的目的就会失效。如果缺少注释,用户可能会浪费时间去解读代码。而那个用户可能就是你!

当然,你总是要避免粗鲁的评论:

# Added this to fix Steve's stupid mistake.

这样的注释会让人反感,破坏团队合作,并且让你看起来不专业。

另一个注释错误是违反了 DRY(不要重复自己)的准则,详细说明了已经易于理解和明确的代码。下面是一个冗余的注释例子,它没有增加任何价值,反而产生了视觉噪音:

force = mass * acceleration  # multiply mass variable by acceleration variable.

以下注释显得过于显而易见,并且使代码显得杂乱无章,而并未提供额外的价值,因为代码本身已经很容易理解:

# As Step 1, enter the mass of the object.
mass = 549
# As Step 2, enter the acceleration of the object.
acceleration = 42
# As Step 3, calculate Force.
force = mass * acceleration

紧随其后的难懂内联注释可能原本是作为临时提醒,但编码者忘记将其删除,结果现在它带来了困惑而非清晰:

acceleration = 42  # Intermediate for now.

类似地,与代码相矛盾的注释比没有注释更糟糕。因此,应该保持注释的更新并处理任何代码变化。实际上,这很难做到,因此一个好的理由是将注释数量限制在严格必要的范围内。

你可以在 Python 风格指南(PEP8) 中找到官方的 Python 注释指南。大部分内容将在接下来的章节中总结。

单行注释

注释通常占据单独一行,并总结随后的代码,如下所示:

# Use Cartesian product to generate permutations with repetition.
for perm in product([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], repeat=len(combo)):

因为用户可能不熟悉内建的 itertools 模块中的 product 函数,所以注释帮助他们省去了查找的麻烦。

编写单行注释时,应在井号后插入一个空格,并使用完整的句子并加上句号。如果注释包含多个句子,每个句号后应跟两个空格。注释应以大写字母开头,除非第一个单词是以小写字母开头的标识符。

此外,所有注释都应该与它们所描述的代码保持相同的缩进级别。例如,由于物质无法达到或超过光速(C),下面的注释解释了为何将 velocity 变量重新赋值为光速减去 0.000001:

if velocity >= C:
    # Don't let the ship reach light speed.
    velocity = C - 0.000001

由于被引用的变量赋值发生在 if 语句块内部,所以注释缩进了四个空格。

多行注释

跨越多行的注释被称为多行注释或块注释。Python 没有正式的多行注释语法。处理它们的一种方式是将其视为一系列以井号开头的单行注释,如下所示:

# This is a really long-winded comment that probably should be 
# shortened or left off or broken up and inserted before various
# bits of code or in a docstring somewhere.

这种方法的缺点是可读性较差。另一种替代方法是使用三引号的多行字符串。之所以有效,是因为 Python 会忽略未赋值给变量的字符串。这种方法也更具可读性:

"""
This is a really long-winded comment that probably should be 
shortened or left off or broken up and inserted before various
bits of code or in a docstring somewhere.
"""

你也可以将三引号放置在与注释相同的行上,如下所示:

"""This is a really long-winded comment that probably should be 
shortened or left off or broken up and inserted before various
bits of code or in a docstring somewhere."""

如果块注释包含多个段落,请使用空行分隔这些段落。

块注释会打破代码的连续性,应仅在特殊情况下使用。这些情况包括记录重要的经验教训、添加许可证和版权信息,以及插入临时提醒,如 TODO 列表、FIXME 标记和警告。

内联注释

内联注释出现在语句的末尾。一个常见的用途是指定测量单位,如下所示:

C = 299_792_458  # Speed of light in a vacuum in meters per second.

通过在注释中指定值的单位,而不是将单位包含在变量名中,我们能够使用更简洁的变量名。

内联注释应至少与代码隔开两个空格,并且#后面应该跟一个空格。如果注释无法与代码放在同一行,应使用单行或多行注释将其放在语句上方。

内联注释会分散注意力,应谨慎使用。它们绝不应该陈述显而易见的内容,而是应该增加清晰度。例如,一些函数和方法带有非直观的参数值,比如内置的turtle模块的screen()方法,它设置一个绘图窗口。通常,你传递给它你想要的窗口大小,以像素为单位,例如width=800, height=900,但如果想要使用整个屏幕,你只需要传递1。内联注释可以使这一点更加明确:

screen.setup(width=1.0, height=1.0)  # For fullscreen view.

内联注释也可以为变量赋值提供上下文:

apogee = 25_500  # Highest point in the orbit.

另外,内联注释还可以提供格式化提示:

url = https://www.python.org/  # Cut and paste from website address.

在这里,注释为用户定义函数的一个参数提供了清晰的解释:

trajectory = rocket(dx=25, dy=-100)  # Negative y moves down the screen.

你可能会忍不住使用比实际需要更多的内联注释。在大多数情况下,通过使用清晰的对象名称,可以避免或最小化注释的使用。

注释掉代码

因为 Python 会忽略注释,你可以使用#符号来阻止部分代码的执行。这有助于你通过开启或关闭部分代码来测试和调试代码。

例如,你可能希望程序打印出大量信息,但在开发过程中,这些打印输出可能会拖慢代码的执行速度,并且遮蔽你希望看到的其他输出。在编写代码时,你可以通过将这些行注释掉来临时停止它们的输出,如下所示:

# print(key_used)
# print(ciphertext)
# print(plaintext)
# print('Program complete.')

为了方便,你可以通过快捷键高亮并注释掉代码块。在 Spyder 中,你可以通过点击顶部工具栏的文件编辑查看适合你系统的快捷键。例如,在 Windows 中,你可以使用 CTRL-1 切换代码的启用和禁用。若要注释掉包含解释性注释的代码块,请使用 CTRL-4 来注释代码块,使用 CTRL-1 恢复代码块。

测试你的知识

1.  内联注释前应该留多少空格?

a.  1

b.  2

c.  0

d.  内联注释应该使用三引号

2.  对还是错:多行注释如果使用三引号会更易读。

3.  在 Python 中,井号(#)表示以下哪项:

a.  一个注释

b.  一个数字

c.  一行不可执行的代码

d.  以磅为单位的重量

4.  一个不错的替代方法是:

a. 多行文档字符串

b. 单行注释

c. 良好的命名规范

d. 明智地使用常量

文档字符串

文档字符串是一个三引号字符串字面量,出现在模块、函数、类或方法定义的第一行。由于这种位置和三引号的使用,各种帮助工具可以发现并显示文档字符串。

文档字符串通常由一行摘要和更详细的描述组成:

"""
A one-line summary.

More info such as:
 function summaries
 method summaries
 attribute summaries
 exceptions raised
 and so on
"""

由于摘要行可以被自动索引工具使用,因此它应当只占用一行,并且与文档字符串的其余部分用空行分隔。摘要行可以与开头的引号在同一行,也可以放在下一行。除非整个文档字符串可以在一行内显示,否则应将结束引号放在单独的一行。文档字符串的缩进应与第一行的引号对齐。

当文档字符串设置正确时,你可以通过特殊的 __doc__ 属性访问它们。要查看我们在第十二章中使用的 pickle 模块的示例,请在控制台中输入以下内容:

In [1]: import pickle

In [2]: print(pickle.__doc__)

这将显示模块的文档字符串:

Create portable serialized representations of Python objects.

See module copyreg for a mechanism for registering custom picklers.
See module pickletools source for extensive comments.

Classes:

    Pickler
    Unpickler

Functions:

    dump(object, file)
    dumps(object) -> string
    load(file) -> object
    loads(string) -> object

Misc variables:

    __version__
    format_version
    compatible_formats

你也可以通过在 Spyder 的帮助面板中输入 pickle 来看到这一点(图 14-1)。

图片

图 14-1:在 Spyder 帮助面板中显示的 pickle 模块文档字符串

对于简单的函数或方法,文档字符串可以完全由一行摘要组成。即使这个摘要不跨越多行,你仍然应该使用三引号,示例如下:

"""Accept number as n and return cube of n."""

这几乎是文档字符串可以最简洁的形式,但对于简单的函数或你为自己定义的函数来说是足够的。然而,如果你打算处理企业级别的代码或为开源项目做贡献,你会想要遵循 PEP 257 中的文档字符串规范(www.python.org/dev/peps/pep-0257/)。有些情况可能相当复杂,文档字符串可能会长达几个屏幕。

在接下来的章节中,我们将探讨适用于独立工作或小组密切合作的科学家和工程师的文档字符串规范。在这些情况下,用户使用代码的频率通常高于修改代码,因此简单的文档字符串应该能够满足他们的需求。

文档化模块

模块的文档字符串应当放置在模块的顶部,位于任何导入语句之上。第一行应描述模块的目的。文档字符串的其余部分通常应列出模块导出的类、异常、函数以及其他对象,每个对象附带一行简短的总结。如果这些总结提供的信息不如对象自身文档字符串中的摘要详细,也是可以的。

下面是 pickle 模块的文档字符串在实际代码中的样子:

"""Create portable serialized representations of Python objects.

See module copyreg for a mechanism for registering custom picklers.
See module pickletools source for extensive comments.

Classes:

    Pickler
    Unpickler

Functions:

    dump(object, file)
    dumps(object) -> string
    load(file) -> object
    loads(string) -> object

Misc variables:

    __version__
    format_version
    compatible_formats
"""

随着模块变得越来越大和复杂,它们的文档字符串可能变得相当技术性。这使得初学者和非开发者在编写和阅读时都会感到困难。对于为自己或直接团队使用编写的程序,更简单的总结可能更为合适。以下是我们在第十一章中编写的mymath.py模块的友好文档字符串(加粗):

"""
Functions to solve the quadratic equation and get the volume of a sphere.

Functions:
quad(a, b, c) -> soln1, soln2
sphere_vol(radius) -> volume rounded to 2 decimal places
"""
import math

def quad(a, b, c):
    x1 = (-b - (b**2 - 4 * a * c)**0.5) / (2 * a)
    x2 = (-b + (b**2 - 4 * a * c)**0.5) / (2 * a)
    return x1, x2

def sphere_vol(r):
    vol = (4 / 3) * math.pi * r**3
    return round(vol, 2)

你可以通过__doc__来获取这个文档:

In [3]: import my_math

In [4]: print(my_math.__doc__)

Functions to solve the quadratic equation and get the volume of a sphere.

Functions:
quad(a, b, c) -> soln1, soln2
sphere_vol(radius) -> volume rounded to 2 decimal places

同样,内置的help()函数可以检索这个文档字符串,提供更多信息,包括文件的位置:

In [5]: help(my_math)
Help on module my_math:

NAME
my_math - Functions to solve the quadratic equation and get the volume of a sphere.

DESCRIPTION
    Functions:
    quad(a, b, c) -> soln1, soln2
    sphere_vol(radius) -> volume rounded to 2 decimal places FUNCTIONS
    quad(a, b, c)
    sphere_vol(r)

FILE
    C:\Users\hanna\spyder_proj_w_env\code\my_math.py

这个文档字符串为用户提供了my_math模块的良好概述。别担心函数的描述稍显简略。正如你将在后面的章节中看到的那样,函数有自己的文档字符串,你可以在其中详细描述函数的目的、参数、输出等内容。

类的文档

类的文档字符串应遵循与模块级文档字符串相同的模式。它应该总结类的行为,并列出公共方法和实例变量。任何子类、构造函数和方法应有自己的文档字符串。所有记录类的文档字符串后应插入一个空行。

这是一个Starship类的文档字符串示例:

class Starship:
    """
    A class to represent a starship.

    Attributes
    ----------
    name : str
        name of the ship
    torpedoes : int
        number of photon torpedoes
    phasers: int
        number of phaser banks
    crew: int
        number of crew members

    Methods
    -------
    info():
        Print the ship's attributes.

    fire_all():
        Return the sum of the weapon attributes as an integer.
    """

这个文档字符串很简单,但没关系,因为文档字符串的主要用途之一是在使用类时提供动态提示(见图 13-4)。因此,你应该尽量简洁地呈现信息。

在这种情况下,我们用一行总结开始文档字符串,随后列出属性。这个列出包括属性名称、数据类型和简要描述。接下来,我们列出类的方法,并为每个方法提供一行总结。

函数和方法的文档

函数或方法的文档字符串应总结其行为,并记录其参数、返回值、副作用以及引发的任何异常和调用时的限制(如果适用)。应标明可选参数和关键字参数。

通常,如果你的函数或方法不接受任何参数并返回一个单一的值,一行总结就足够作为文档。这个总结应该使用命令式语气;换句话说,应该使用“Return”而不是“Returns”:

def warning():
    """Print structural integrity warning message."""
    print("She canna take it Capt'n! She's gonna blow!")

这是一个更长的文档字符串,描述了一个接受两个单词的函数,如果这些单词是字谜(由相同字母按不同顺序组成)则返回True,否则返回False。它提供了关于函数的参数和返回值的信息:

def is_anagram(word1, word2):
    """
    Check if two strings are anagrams and return a Boolean.

    Arguments:
        word1: a string
        word2: a string

    Returns:
        Boolean
    """
    return sorted(word1.lower()) == sorted(word2.lower())

print(is_anagram('forest', 'softer'))

这是此代码的输出。由于“softer”是“forest”的字谜(由相同字母按不同顺序组成),所以比较结果为True

In [6]: runfile('C:/Users/hanna/oop/junk.py', wdir='C:/Users/hanna/oop')
True

如果一个函数的参数有默认值,应该提到这些默认值。这里是使用tax_rate参数的一个示例:

def calc_taxes(taxable_income, tax_rate=0.24):
    """ Calculate Federal taxes based on taxable income and rate.

    Args:
        taxable_income: int
                           Income after qualified deductions.
        tax_rate: float 
                    Federal tax rate as decimal value. 
                    Defaults to 24% tax bracket.

    Returns: int
               Federal taxes owed. 
"""

使用 doctest 保持文档字符串更新

更新程序时,容易忘记编辑相关的文档字符串。使用内置的doctest模块,你可以在文档字符串中嵌入使用示例,以检查代码和文档之间是否存在差异。

doctest模块会搜索看起来像是交互式 Python 会话的文本片段,然后执行这些会话以验证它们是否按预期工作。我们来看一个简单的函数,它接受星舰的跃迁因子值,并调整它使其落在可接受的操作范围内。粗体部分的代码代表嵌入的测试用例:

def warp(factor):
    """Return input warp factor adjusted to allowable values.

    Args:
        factor: int
                  warp factor

    Returns: int
               warp factor adjusted to operating limits

    Raise: ValueError
            factor value must be float or integer

 >>> warp(5)
 5
 >>> warp(3.5)
 3
 >>> warp(12)
 10
 >>> warp(-4) 
 0
 >>> warp(0)
 0
 >>> warp('ten')
 Traceback (most recent call last):
 …
 ValueError: factor must be a number """  
    if isinstance(factor, (int, float)):
        speed = int(factor)
        if speed < 0:
            speed = 0
        elif speed > 10:
            speed = 10   
        return speed
    else:
        raise ValueError("factor must be a number")

if __name__ == "__main__":
    import doctest
    doctest.testmod()

测试用例检查可接受和不可接受的值。不可接受的值是那些会导致比较语句失败的值,例如12-4

你可以通过几种方式运行doctest。一种方式是通过按 F5 在文本编辑器中运行脚本。另一种方式是打开控制台,导入doctest模块和你的自定义模块(不带.py扩展名),然后调用testmod()方法,如下所示:

In [7]: import doctest
In [8]: import set_warp

In [9]: doctest.testmod(set_warp)
Out[9]: TestResults(failed=0, attempted=6)

因为没有任何测试失败,你得到了一个简要的测试结果总结。如果你返回到文档字符串并将warp(-4)的预期结果从0编辑为4,然后重新运行该方法(记得先保存脚本),你会看到如下输出:

In [10]: doctest.testmod(set_warp)
**********************************************************************
File "C:\Users/hanna/file_play\set_warp.py", line 21, in set_warp.warp
Failed example:
warp(-4) 
Expected:
4
Got:
0
**********************************************************************
1 items had failures:
1 of 6 in set_warp.warp
***Test Failed*** 1 failures.
Out[10]: TestResults(failed=1, attempted=6)

要打印出doctest模块尝试的详细日志、它的预期结果以及实际找到的结果,可以将verbose=True传递给testmod()。以下是没有失败的测试的结果:

In [11]: doctest.testmod(set_warp, verbose=True) 
Trying:
    warp(5)
Expecting:
    5
ok
Trying:
    warp(3.5)
Expecting:
    3
ok
Trying:
    warp(12)
Expecting:
    10
ok
Trying:
    warp(-4) 
Expecting:
    0
ok
Trying:
    warp(0)
Expecting:
    0
ok
Trying:
    warp('ten')  
Expecting:
    Traceback (most recent call last):
        ...
    ValueError: factor must be a number
ok
1 items had no tests:
    set_warp
1 items passed all tests:
   6 tests in set_warp.warp
6 tests in 2 items.
6 passed and 0 failed.
Test passed.

你还可以通过 Anaconda Prompt 或终端运行doctest。只需导航到包含 Python 文件的目录,并使用-v开关(用于详细模式)运行以下命令:

python <your_filename.py> -v

如果只需要简要的总结,可以省略-v开关。

除了检查模块的文档字符串是否是最新的,你还可以使用doctest验证测试文件或测试对象中的交互式示例是否按预期工作。这被称为回归测试,它确保以前开发和测试的软件在更改后仍然正常运行。

你还可以使用doctest为包编写教程文档,充分使用输入输出示例进行说明。要了解更多信息,请访问docs.python.org/3/library/doctest.html

在 Spyder 代码分析面板中检查文档字符串

你可以使用 Spyder IDE 检查文档字符串是否符合已建立的规范。结果将在代码分析面板中显示,该面板在第四章的第 85 页中介绍过,并且也会在文本编辑器中显示。

设置 Spyder 偏好设置

要设置 Spyder 以检查文档字符串,点击顶部工具栏中的工具偏好设置。在偏好设置窗口中,点击自动补全和代码检查,然后选择文档字符串样式标签。你应该能看到图 14-2 所示的窗口。确保勾选启用文档字符串样式检查复选框。

Image

图 14-2:Spyder 的文档字符串样式窗口

选择用于检查文档字符串的约定下拉菜单提供了三种选择:PEP 257、NumPy 和自定义。如前所述,PEP 257 是 Python 官方的文档字符串指南,因此我们将在这里使用它。

除了 PEP 257,一些科学界的成员使用 NumPy 文档字符串标准(numpydoc.readthedocs.io/en/latest/install.html)。你可以在sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html看到该样式的示例)。

你还可以选择根据* www.pydocstyle.org/en/stable/error_codes.html*中找到的代码来显示或忽略某些错误。

注意

除了 PEP 257 和 NumPy 之外,还有其他文档字符串格式可供遵循。Google 有自己的一种格式和优秀的样式指南(google.github.io/styleguide/pyguide.html)。你可以在sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html看到该样式的示例。此外,reStructuredText是一种流行的格式,主要与名为Sphinx的工具配合使用。Sphinx 使用文档字符串生成 Python 项目的文档,格式包括 HTML 和 PDF。如果你曾经阅读过 Python 模块的文档(readthedocs.org/),你就见识过 Sphinx 的实际应用。

运行分析

要查看在实践中使用 Spyder 检查文档字符串的效果,我们来写一些缺少文档字符串的代码。打开文本编辑器,输入以下内容,并将其保存为test_docs.py(你可以在上一章的oop Spyder 项目中进行,或在其他地方进行):

class Volcano():
    'A volcano object'
    def __init__(self, name, classification, active):
        """sfsds"""
        self.name = name
        self.classification = classification
        self.active = active

    def erupt(self):
        'lsjljl'
        if self.classification == 'stratovolcano' and self.active is True:
            print("\nRUMBLE!\n")

    def pyroclastic_cloud(self):
        if self.classification == 'stratovolcano' and self.active is True:
            print("\nWHOOSH!\n")

mountain = Volcano('Krakatoa', 'stratovolcano', True)
mountain.erupt()
mountain.pyroclastic_cloud()

希望你已经注意到这里的几个文档错误,如果没有,不要灰心,Spyder 会为你找到并标记这些错误。首先,在顶部工具栏中点击Source。这将会弹出图 14-3 中显示的菜单。

Image

图 14-3:Spyder 顶部工具栏中的 Source 菜单

确保选中显示文档字符串样式警告复选框,然后在菜单底部点击运行代码分析选项(或按 F8 快捷键)。代码分析面板应该会出现(图 14-4)。

Image

图 14-4:带有与文档字符串相关的消息的代码分析面板

点击Convention标题旁边的右箭头(>)符号,展开代码和文档字符串的样式信息。在test_docs.py示例中,我们缺少两个推荐的文档字符串:一个是整个程序的文档字符串,称为模块文档字符串,另一个是pyroclastic_cloud方法的文档字符串。

代码分析窗格中的信息来自代码静态检查工具,它在处理文档字符串时缺乏精细度。要查看特定的文档错误,将光标悬停在文本编辑器中行号左侧的橙色三角形上(图 14-5)。你将看到多个错误代码及其描述。

Image

图 14-5:第 1 行的代码分析信息

关于缺失模块文档字符串的消息会重复出现,但其错误代码与代码分析窗格中的不同。然后,对于 Volcano 类定义,系统会标记缺少空行、引号使用不当,以及文档字符串末尾需要加句号的问题。

如果你将光标悬停在第 3 行的三角形上,指向__init__()方法,你会看到一个显示类似错误的窗口(图 14-6)。

Image

图 14-6:第 3 行的代码分析信息

请注意,工具检查的是摘要描述的存在,但不会评估描述的内容。例如 """sfsds""" 这样的无意义摘要仍然能通过测试。

Spyder 的代码分析工具是确保你的代码及其文档符合 Python 社区标准的好方法。

测试你的知识

  1. 以下哪些选项帮助你访问文档字符串?

a.  __doc__ 特殊属性

b.  help()

c.  Spyder 帮助窗格

d.  以上所有选项

  1. 导入内置的 itertools 模块,并查看其 product() 方法的帮助信息。

  2. 哪些 Python 增强提案(PEP)提供了关于代码文档化的指导?

a.  PEP 248

b.  PEP 8

c.  PEP 549

d.  PEP 257

  1. 以下哪些选项使文档字符串能够被自动帮助工具访问?

a.  使用三重引号

b.  输入和输出数据类型的描述

c.  它紧跟在 def 语句后面

d.  以空格结尾的三重引号

  1. Spyder 的代码分析工具可以检查文档字符串是否符合以下要求:

a.  PEP 8 风格指南

b.  PEP 257 和 Google 风格指南

c.  PEP 8 和 Google 风格指南

d.  PEP 257 和 NumPy 风格指南

  1. 为第十三章的ships.py 程序中定义的 Frigate 类编写文档字符串。

总结

良好的文档化能最大化代码的可用性及其在未来的可维护性。在 Python 社区,编写良好的代码意味着编写了良好的文档。注释和文档字符串允许你将人类语言添加到程序中,以填补关于程序目的、意义和可用性的解释空白。

注释表示不可执行的注释内容,用于标注代码或暂时屏蔽某些行,以防它们运行。你应该谨慎使用注释,解释你的意图、记录重要的编程经验、提供警告、包含法律信息(如许可证和版权数据)、指定单位等。大多数注释占据单行,或嵌入到代码行中,并以#符号开头。多行注释可以使用三引号来提高可读性。

文档字符串是特殊的三引号字符串,出现在模块的顶部或classdef语句后面。它们为用户提供代码的概述,说明代码的功能以及如何使用,你可以通过自动帮助工具访问它们。你应该在每个模块、类、方法和函数中使用文档字符串,并且随着代码的变化,保持它们的更新。

各种工具可以帮助你检查文档字符串是否最新且格式正确。通过内置的doctest模块,你可以在文档字符串中嵌入可测试的案例。这些案例可以帮助你检查代码更新是否改变了预期的行为,并且为新用户提供示例用法。Spyder IDE 包含一个工具,用于检查文档字符串是否符合 PEP 257 和 NumPy 的规范。检查结果会显示在代码分析面板中,并在文本编辑器的边缘与问题代码行相邻显示。

第十五章:THE SCIENTIFIC LIBRARIES**

image

在本章中,我们将概述 Python 中用于数学、数据分析、机器学习、深度学习、计算机视觉、语言处理、网页抓取和并行处理的核心库的高级总结(见表 15-1)。我们还将探讨一些选择竞争产品的指南。在接下来的章节中,我们将深入研究这些库的功能,并将其应用于实际应用中。

表 15-1 将这些库组织为子类别,列出它们的网站,并提供简短的描述。由于这些是流行且在许多情况下成熟的库,你应该能轻松找到每个库的更多指导,既可以在线查阅,也可以在书店找到相关书籍。

表 15-1: Python 的科学计算库

任务 描述 网站
Math and data analysis NumPy 数组的数值计算工具 numpy.org/
SciPy Library 友好且高效的数值例程 www.scipy.org/
SymPy 符号数学/计算机代数工具 www.sympy.org/
Pandas 数据操作、分析和可视化工具 pandas.pydata.org/
Machine and deep learning Scikit-learn 通用机器学习工具包 scikit-learn.org/
TensorFlow 深度学习神经网络的符号数学库 www.tensorflow.org/
Keras 更友好的 TensorFlow 封装 keras.io/
PyTorch 快速高效的人工神经网络 pytorch.org/
Image processing OpenCV 实时计算机视觉库 opencv.org/
Scikit-image 科学图像处理和分析工具 scikit-image.org/
Pillow 基本图像处理工具 python-pillow.org/
Language processing NLTK 符号和统计语言处理库 www.nltk.org/
spaCy 快速的生产级语言处理库 spacy.io/
Helper libraries requests HTTP 请求的网页抓取工具 pypi.org/project/requests/
BeautifulSoup 从 HTML 和 XML 文件中提取文本的工具 www.crummy.com/software/
re 正则表达式库 docs.python.org/3/library/re.html
Dask 用于 Python 的并行计算库 dask.org/
Spark 针对大数据的“更重”替代 Dask spark.apache.org/

SciPy 堆栈

SciPy 开源库栈预先安装在 Anaconda 中,并包含 NumPy、SciPy 库、Matplotlib、IPython、SymPy 和 pandas(见图 15-1)。这些被称为“Python 数值计算和可视化的基石”,并且是使用最广泛的科学库之一。

Image

图 15-1:SciPy 生态系统的核心组件(图片来源:SciPy.org)

在接下来的章节中,我们将对这些库进行高层次的介绍。然后,在后续章节中,我们将深入探讨 NumPy、Matplotlib 和 pandas。

NumPy

NumPy 是 Numerical Python(数值 Python)的缩写,是 Python 专门用于执行数值计算的库。它支持创建大型的多维数组和矩阵,并提供了大量用于操作这些数组的高级数学函数。NumPy 被认为是 Python 科学计算的基础包,但我也会称其为 基础性 因为许多其他重要库,如 pandas、Matplotlib、SymPy 和 OpenCV,都是建立在它之上的。

NumPy 包含了大多数涉及数值数据的科学应用所需的数据结构、算法和“粘合剂”。NumPy 中的操作比 Python 自带标准库中的同类功能更快、更高效。掌握 NumPy 知识对使用大多数(如果不是全部的话)科学 Python 包至关重要,因此我们将在第十八章中对其进行更详细的探讨。

SciPy

科学库 SciPy 旨在解决数学、科学和工程领域的问题,涵盖了科学计算中许多标准问题领域。它是建立在 NumPy 之上并对其进行了扩展,提供了许多用户友好且高效的数值例程,如数值积分、插值、优化、线性代数、统计学、快速傅里叶变换、信号与图像处理以及求解微分方程等功能。它扩展了 NumPy 提供的线性代数例程和矩阵分解功能,并提供了对许多物理常数和转换因子的访问。

SymPy

SymPy 是一个用于符号数学的开源库。它的目标是成为一个功能齐全的 计算机代数系统(CAS)

虽然大多数计算机代数系统发明了自己的语言,但 SymPy 是用 Python 编写和执行的。这使得熟悉 Python 的人可以更轻松地使用它。它还允许你将其作为库使用。因此,除了在交互式环境中使用 SymPy 之外,你还可以将它导入到你自己的 Python 应用程序中,进行自动化或扩展。

SymPy 让你能够以符号的方式进行各种计算。它可以简化表达式;计算导数、积分和极限;解方程;处理矩阵等等。它包括用于绘图、打印(包括数学公式或 LaTeX 的漂亮打印输出)、代码生成、物理学、统计学、组合数学、数论、几何学、逻辑学等方面的软件包。

以一种简单的方式理解 SymPy,可以考虑使用 Python 的基本math库计算无理数√8:

In [1]: import math

In [2]: math.sqrt(8)
Out[2]: 2.8284271247461903

结果是一个截断的数字答案,因为√8 不能用有限的数字表示。使用 SymPy 时,对于非完美平方的数字,默认不对其进行计算;因此,符号结果默认会进行符号化简化(例如 2 × √2):

In [2]: import sympy
   ...: sympy.pprint(sympy.sqrt(8))
Out[2]: 2⋅√2

如前所述,SymPy 包含许多有用的方法,例如解方程。例如,解x² - 2 = 0。

In [3]: import sympy

In [4]: x = sympy.symbols('x')

In [5]: sympy.pprint(sympy.solve(x**2 - 2, x))
[-√2, √2]

SymPy 方便地提供了自己的绘图模块:

In [6]: from sympy import symbols, cos
   ...: from sympy.plotting import plot3d

In [7]: x, y = symbols('x y')

In [8]: plot3d(cos(x * 2) * cos(y * 4) - (y / 4), (x, -1, 1), (y, -1, 1))

Image

要查看更多 SymPy 的功能,请访问docs.sympy.org/latest/tutorial/index.html

你可能会问,既然已经有 NumPy 和 SciPy 库,为什么还要使用 SymPy?简短的回答是,SymPy 用于处理代数和进行理论数学或物理学计算;而 NumPy 和 SciPy 用于对实际数据进行分析。

pandas

Python 数据分析库是最流行的开源数据科学库。简称pandas,它包含了旨在促进数据提取、清洗、分析和可视化的数据结构和操作工具。它采用了 NumPy 的许多重要部分,并与其他库如 SciPy、statsmodels、scikit-learn 和 Matplotlib 兼容。

此外,pandas 对于处理表格数据和常见数据源,如 SQL 关系型数据库和 Excel 电子表格,非常有用。它尤其适用于处理时间索引数据,并包含基于 Python 核心可视化库 Matplotlib 的绘图功能,方便你可视化数据。

pandas 中最常见的数据结构是DataFrame,它是一种类似于电子表格的表格格式,包含列、行和数据。你可以从多种类型的输入构建 DataFrame,在以下示例中,通过使用 Jupyter Notebook 从列表列表构建:

import pandas as pd
data = [['Carbon', 'C', 6], ['Nitrogen', 'N', 7], ['Oxygen', 'O', 8]]
df = pd.DataFrame(data, columns=['Element', 'Symbol', 'Atomic #'])
df
元素 符号 原子序数
0 C 6
1 N 7
2 O 8

使用 DataFrame 时,你可以在 Python 中拥有类似 Excel 电子表格或 SQL 表格的功能。然而,DataFrame 通常更快、更易用且更强大,因为它们是 Python 和 NumPy 生态系统的核心组成部分。

pandas 库是科学家们最重要的库之一,正如古谚所说,他们花费 80%的时间寻找和准备数据,另外 20%的时间抱怨它!因此,掌握 pandas 是至关重要的,您将在 第二十章 中获得良好的起点,该章节涵盖了一些基础知识。

注意

其他库开始挑战 pandas,保持其简单性的同时解决了一些效率问题,如无法通过多核处理、GPU 处理或集群计算来扩展项目。Modin 提供了完整的 pandas 替代方案,让您可以更多地优化使用 pandas。Vaex (vaex.io/) 使用高效的惰性评估和巧妙的内存映射,帮助您在普通硬件上探索和可视化大型数据集。Dask (dask.org/) 实现了许多与 pandas 相同的方法,并提供比 Modin 或 Vaex 更多的功能。Dask 使用更复杂,但可以帮助您处理大型数据集,并利用计算集群提高处理速度。

通用机器学习库:scikit-learn

数据分析的一部分是构建和验证预测模型,这些模型利用已知结果预测未来结果或解释过去行为。这属于机器学习范畴,本身又是人工智能的一个类别(图 15-2)。机器学习涉及数据集中模式识别的方法,使得机器学习算法能够通过经验自动改进。这些算法根据训练数据构建监督模型,以及无监督模型,在后者中,模型可以自行“发现”模式。这些算法可以利用这些模型做出决策,而无需显式编程。

开源的 scikit-learn 库建立在 NumPy、SciPy 和 Matplotlib 的基础之上。被认为是 Python 程序员的首选通用机器学习工具包,scikit-learn 对使 Python 成为高效的数据科学工具至关重要。在 Anaconda 上预安装的 scikit-learn 也很易于使用,使其成为机器学习的良好起点。

图片

图 15-2:一些人工智能分支

如 图 15-3 所示,scikit-learn 库包括用于预测数据分析的软件包,包括分类(支持向量机、随机森林、最近邻等)、回归、聚类(k-means、谱聚类等)、降维(主成分分析、矩阵分解、特征选择等)、预处理(特征提取和归一化)以及模型选择(度量、交叉验证和网格搜索)。既涵盖了监督方法也涵盖了无监督方法。您可以在 第二十章 中了解 scikit-learn 的工作原理。

图片

图 15-3:使用 scikit-learn 进行回归、分类和聚类分析的示例(由scikit-learn.org/提供)

注意

作为 scikit-learn 的补充,有一个名为 statsmodels 的库(www.statsmodels.org/),其中包含经典统计和经济学算法。而 scikit-learn 更关注预测,statsmodels 则更关注统计推断、p 值和不确定性估计。

深度学习框架

深度学习是机器学习的一个分支,超越了包含在 scikit-learn 中的方法。与通过结构化训练集修改固定模型的参数不同,深度学习网络能够从未经处理或未标记的数据中进行无监督学习。因此,深度学习系统模仿人脑在处理数据和生成决策模式时的非线性工作方式。

这些系统中最知名的被称为人工神经网络(ANNs)。这些网络通常需要进行非常复杂的数学运算,涉及百万到数十亿个参数,仅有得益于为视频游戏开发的图形处理单元(GPUs)的速度和效率才有可能实现。示例应用包括自动驾驶汽车和谷歌翻译。

专为深度学习设计的 Python 库被称为深度学习框架。这些接口抽象了底层算法的细节,允许您使用预构建和优化的组件快速轻松地定义模型。在众多可用的框架中,TensorFlow、Keras 和 PyTorch 三者占据主导地位。

尽管这三个系统仍在不断发展,它们已经拥有了良好的文档、训练集、教程和支持,您可以信赖它们提供强大的深度学习解决方案。

TensorFlow

Python 的最古老、最流行的深度学习框架是一个名为TensorFlow的开源库。由谷歌创建以支持其大规模应用,TensorFlow 是一个用于多个机器学习任务的端到端平台。由于其庞大的用户群体、良好的文档和在所有主要操作系统上的运行能力,TensorFlow 在工业和学术界广受欢迎,跨越各种领域。您可以在网上找到许多文章来帮助您实现复杂问题的解决方案。您还可以在线完成认证程序。

TensorFlow 非常强大,能够通过在数百个多 GPU 服务器上分布计算高效处理大型数据集。它提供了许多功能,包括一个名为TensorBoard的工具,帮助您创建易于理解的漂亮可视化,并从中获得有用的分析。

Keras

虽然 TensorFlow 有丰富的文档并附带操作指南,但它仍然被认为是最具挑战性的深度学习框架之一,界面复杂且学习曲线陡峭。幸运的是,谷歌工程师 François Challet 编写了另一个库Keras,它作为 TensorFlow 的接口。虽然它现在是 TensorFlow 核心应用编程接口(API)的一部分,但你也可以独立使用 Keras。

与 TensorFlow 类似,Keras 是开源的并且可以在所有平台上运行。不同于 TensorFlow,Keras 是用 Python 编写的,使其更易于使用。Keras 旨在快速原型开发和小数据集上的快速实验,其轻量级、简洁的界面采用极简主义设计,使得构建可以与 TensorFlow 配合工作的神经网络变得容易。而且,因为 Keras 可以作为封装层,你在需要使用 Keras 简洁接口中不包含的功能时,可以随时“降级”到 TensorFlow。

Keras 可以在 CPU 和 GPU 上无缝运行。它主要用于分类、语音识别、文本生成、摘要和标签。通过简化操作并使模型易于理解,Keras 成为初学者非常好的深度学习工具。

PyTorch

PyTorch,由 Facebook 的人工智能研究实验室开发,是 TensorFlow 的直接竞争对手。PyTorch 可以在所有平台上运行,并且最近集成了Caffe,这是一种在伯克利开发的流行深度学习框架,主要面向图像处理领域。PyTorch 在 Anaconda 中预安装。

PyTorch 正在成为学术研究的首选框架,尽管它在 Facebook、Microsoft 和 Wells Fargo 等行业巨头中仍被广泛使用。它在原型设计方面表现出色,适合那些更多处于非生产实现阶段的项目。它的优势包括灵活性、调试能力和较短的训练时间。

与 TensorFlow 不同,PyTorch 被描述为对 Python 更加“本地化”,使得开发和实现机器学习模型变得更为容易。其语法和应用非常符合 Python 风格,并且与 NumPy 等重要库无缝集成。尽管 Keras 在易用性方面似乎占据优势,尤其对于深度学习新手而言,PyTorch 在速度、灵活性和内存优化方面更胜一筹。

PyTorch 的另一个优势是调试。Keras 通过封装到各种函数中隐藏了构建神经网络的许多细节。这意味着你只需几行代码就能构建一个人工神经网络。而在 PyTorch 中,你需要在代码中明确指定更多细节;因此,找出错误变得更加简单。同时,修改权重、偏置和网络层并重新运行模型也变得更加容易。

总的来说,PyTorch 被认为比 TensorFlow 更容易使用,但比 Keras 更难使用。TensorFlow 的可视化功能也受到更高的评价。

选择深度学习框架

根据马克·吐温的说法,“所有的概括都是错误的,包括这条。” 许多个人和项目相关的问题可能会影响你选择哪种深度学习框架。不过,如果你足够搜索互联网上的资源,你还是可以找到一些关于深度学习框架选择的通用指南:

  • 如果你是深度学习的初学者,考虑选择 Keras,其次是 PyTorch。

  • 如果你是新手并且是研究社区的一部分,考虑使用 PyTorch。

  • 如果你是经验丰富的研究人员,你可能会更喜欢 PyTorch。

  • 想要一个快速即插即用框架的开发者会更喜欢 Keras。

  • 如果你有经验并且希望从事工业界工作,考虑使用 TensorFlow。

  • 如果你处理的是大规模数据集,并且需要速度和性能,选择 PyTorch 或 TensorFlow。

  • 如果调试是个问题,使用 PyTorch,因为可以使用标准的 Python 调试器(尽管使用 Keras 时,由于界面简单,调试的需求很少)。

  • 如果你需要多个后端支持,选择 Keras 或 TensorFlow。

  • Keras 和 TensorFlow 提供更多的部署选项,并简化了模型导出到 Web 的过程;而在 PyTorch 中,你必须使用 Flask 或 Django 作为后端服务器。

  • 对于快速原型设计,使用 Keras,其次是 PyTorch。

  • 如果可视化是优先考虑的,选择 Keras 或 TensorFlow。

  • 如果你已经在使用 Keras 或 TensorFlow,使用 Keras 进行深度神经网络设计,使用 TensorFlow 进行机器学习应用。

计算机视觉库

计算机视觉是人工智能的一个分支,专注于训练计算机像人类视觉一样看和处理数字图像和视频。其目标是让计算机通过图像获取世界状态的高层次理解,并返回适当的输出。例如,自动驾驶汽车应该检测到你已经偏离车道,并警告你或自动将车轮转回。这需要在图像中检测、跟踪和分类特征。除了自动驾驶汽车,常见的应用还包括面部检测和识别、皮肤癌诊断、事件检测以及相机自动对焦。

有很多专门用于计算机视觉和图像处理的 Python 库,但有三个库,OpenCV、scikit-image 和 Pillow,基本上可以满足你大多数的需求。让我们在接下来的章节中快速了解一下这三个库。

OpenCV

OpenCV,即开源计算机视觉,是全球最流行的开源计算机视觉库。它的主要关注点是实时应用程序,例如在流媒体视频中识别面孔,但它可以做从简单图像编辑到机器学习应用的所有事情。OpenCV 是用 C++编写的,以提高速度,但它有一个 Python 封装器,支持 Windows、Linux、Android 和 macOS 平台。

OpenCV 具有模块化结构,包括数千个优化的算法,涵盖了简单的图像处理、视频分析、2D 特征框架、物体检测、物体跟踪、相机标定、3D 重建等多个领域。OpenCV 将图像转换为高效的 NumPy 数组,并且由于其采用优化的 C/C++编写,能够利用快速的多核处理。

OpenCV 已经存在了超过 20 年,拥有一个庞大且支持的用户群体。许多大型公司如 Google、Yahoo、Microsoft、Intel、IBM、Sony 和 Honda 都在积极使用 OpenCV。由于其成熟和受欢迎,你可以找到许多书籍和在线教程来帮助你使用这个库。

scikit-image

开源的 scikit-image 库是 SciPy 的图像处理工具箱。其使命是成为 Python 中科学图像分析的权威库。它预装在 Anaconda 中。

scikit-image 库包含许多行业、研究和教育中常用的算法和工具。它是用 Python 编写的,像 OpenCV 一样,通过转换原始图像来使用 NumPy 数组作为图像对象。尽管它缺乏一些 OpenCV 中用于实时图像处理的复杂算法,但它仍然拥有许多对科学家有用的算法,包括特征和斑点检测。它还包含一些 OpenCV 没有的算法实现。

该库使用起来相当简便,并且有丰富的文档和大量示例与使用案例。所有代码都经过同行评审,质量很高。它为许多机器学习模型提供了一致的接口,使得学习新模型相对容易。它还提供了许多可选项——并且具有合理的默认设置——用于调优模型以获得最佳性能。你可以在 scikit-image.org/docs/stable/auto_examples/ 找到示例库。

PIL/Pillow

PillowPython 图像库(PIL)的“友好”分支,它是 Python 中最古老的核心图像处理库之一。Pillow 可以在所有主要操作系统上运行,并且预装在 Anaconda 中,主要用于基本的图像处理。

如果你不需要 OpenCV 或 scikit-image 的功能,Pillow 因其轻量级和易用性,在 Web 项目中被广泛用于图像转换。它支持多种图像文件格式,并提供预定义的图像增强滤镜,包括锐化、模糊、轮廓、平滑、边缘检测、调整大小、像素操作等。它尤其适合用于自动处理大量图像。

选择图像处理库

下面是选择图像处理库的一些建议。这里只考虑开源库。

  • 如果你的工作或研究涉及 实时 计算机视觉应用,那么你需要学习 OpenCV。

  • 如果你的数据集包含静态图像和流媒体视频的混合,你应该同时考虑使用 OpenCV 和 scikit-image。后者的一些方法和工具可以补充 OpenCV。关于这两者如何协同工作的简短示例,请访问 Adrian Rosebrock 的教程,了解如何检测低对比度图像 (www.pyimagesearch.com/2021/01/25/detecting-low-contrast-images-with-opencv-scikit-image-and-python/).

  • 如果你主要处理静态图像,scikit-image 或 Pillow 应该足够,并且可以避免 OpenCV 带来的“开销”。在这两者之间,如果你经常处理图像并进行较为复杂的分析和操作,scikit-image 会更为合适。

  • 对于基本的图像处理,比如加载图像、裁剪图像或简单的过滤,Pillow 应该足够使用。同样,你也可以直接在 NumPy 和 SciPy 的ndimage模块中实现许多简单的操作。

自然语言处理库

自然语言处理(NLP)是语言学和人工智能的一个分支,旨在赋予计算机从书面和口头语言中提取意义的能力。一些常见的 NLP 应用包括语音识别;语音转文本;机器翻译;聊天机器人;垃圾邮件检测;词语分割(称为标记化);情感分析;光学字符识别(OCR),将手写或印刷文本的图像转换为数字文本;当然,还有亚马逊的 Alexa。

更受欢迎的 NLP 库包括 NLTK、spaCy、Gensim、Pattern 和 TextBlob。NLTK 和 spaCy 是通用的 NLP 库,接下来的章节将详细讨论这两个库。其他库如 Gensim 则更为专业,专注于子领域如语义分析(检测单词的意义)、主题建模(根据单词统计确定文档的含义)和文本挖掘。

NLTK

自然语言工具包,简称NLTK,是 Python 中最古老、最强大、最受欢迎的 NLP 库之一。NLTK 是开源的,可以在 Windows、macOS 和 Linux 上使用。它于 2001 年作为宾夕法尼亚大学计算语言学课程的一部分创建,并在几十位贡献者的帮助下不断发展壮大。NLTK 已在 Anaconda 中预装。

因为 NLTK 是为学术研究群体设计的,它功能强大,但在快速生产环境中的使用可能稍显缓慢。它也被认为有些难以学习,尽管这一点通过开发者编写的免费且有用的在线教科书《Python 自然语言处理》(Natural Language Processing with Python)(www.nltk.org/book/)在一定程度上得到了缓解。

NLTK 的一个优势是它包含了大量的语料库(文本集)和预训练模型。因此,它可以被认为是学术界自然语言处理(NLP)领域的事实标准库。

spaCy

spaCy库比 NLTK 年轻,且设计上能够很好地与像 scikit-learn、TensorFlow、PyTorch 等机器学习框架以及其他 NLP 库(如 Gensim)协同工作。它被宣传为“工业级”,意味着它是可扩展的、经过优化的,并且在生产应用中非常快速。像 NLTK 一样,它有很好的文档,并且预装了有用的语言模型。它的支持社区虽然不如 NLTK 庞大,但正在快速增长,未来可能会超越 NLTK 的受欢迎程度。

选择 NLP 库

尽管 NLP 领域有数十种库,但你只需掌握其中几个,就能在该领域达到熟练水平。以下是选择 NLP 库的一些指导原则:

  • 如果你从事学术研究或其他研究工作,你可能会想花时间学习 NLTK。

  • spaCy 库在将 NLP 与机器学习模型结合使用时会非常有用。

  • 如果你需要高效优化的性能,可以考虑 spaCy。

  • 如果你计划做的是抓取网站并分析结果,可以考虑Pattern (github.com/clips/pattern/),它是一个具有基本 NLP 功能的专用网页挖掘工具。

  • 如果你是初学者或计划在工作中轻度使用 NLP,可以考虑TextBlob (textblob.readthedocs.io/en/dev/)。TextBlob 是一个用户友好的前端接口,封装了 NLTK 和 Pattern 库,提供高层次、易于使用的界面。它适合学习和快速原型设计,随着你经验的积累,你可以添加功能来优化你的原型。

  • 如果你对主题建模和统计语义学(分析和评分文档的相似性)感兴趣,可以考虑Gensim (radimrehurek.com/gensim/)。Gensim 通过将文档流式传输到其分析引擎,并对其进行增量式无监督学习,能够处理非常大的文件。它的内存优化和快速处理速度是通过使用 NumPy 库实现的。Gensim 是一个专用工具,不适用于通用 NLP 任务。

  • 如果你想同时处理多种语言的 NLP,可以考虑Polyglot (polyglot.readthedocs.io/en/latest/index.html)。

辅助库

辅助库帮助你使用本章讨论的科学库。这里讨论的那些库可以帮助你快速下载数据、准备数据并进行分析。

Requests

数据整理(或 数据清理)指的是将数据从“原始”形式转化为更适合分析的格式的过程。这包括检查、修正、重新映射等过程。你可以通过之前讨论的 pandas 库做很多这方面的工作,但首先你需要获取数据。

由于大量的人类知识都可以在线获取,你很可能需要一种方法从全球信息网上提取数据。请注意,我并不是在说简单地从在线数据库下载一个 Excel 表格,这很容易,或者手动复制并粘贴网页上的文本。我指的是自动提取和处理内容的过程,这个过程称为 网页抓取。让我们来看两个开源库来帮助实现这一点,requests 和 Beautiful Soup,以及第三个库 re,它帮助你清理和修正数据。

流行且可靠的 requests 库旨在使 超文本传输协议 (HTTP) 请求变得更加简单和用户友好。HTTP 是全球信息网(World Wide Web)数据通信的基础,其中的超文本文档包含指向其他资源的超链接,用户可以通过点击鼠标或在网页浏览器中点击屏幕轻松访问这些资源。requests 库在 Anaconda 中是预安装的。

让我们来看一个例子,在 Jupyter Notebook 中使用爬虫从网站上抓取马丁·路德·金博士的《我有一个梦想》演讲(* www.analytictech.com/mb021/mlk.htm *):

import requests

url = 'http://www.analytictech.com/mb021/mlk.htm'
page = requests.get(url)

在导入 requests 库后,您需要将 url 地址作为字符串提供。您可以从希望提取文本的网站复制并粘贴这个地址。requests 库抽象化了在 Python 中发起 HTTP 请求的复杂性。get() 方法获取 url 并将输出赋值给 page 变量,该变量引用了网页返回的 Response 对象。此对象的文本属性包含网页内容,包括演讲稿,以可读文本字符串的形式呈现。

此时,数据是以 超文本标记语言 (HTML) 的形式存在的,这是创建网页的标准格式:

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
<html> <head>
<meta http-equiv="Content-Type"
content="text/html; charset=iso-8859-1">
<meta name="GENERATOR" content="Microsoft FrontPage 4.0">
<title>Martin Luther King Jr.'s 1962 Speech</title>
</head>
--snip--
<p>I am happy to join with you today in what will go down in
history as the greatest demonstration for freedom in the history
of our nation. </p>
--snip--

正如你所看到的,HTML 中有很多 标签,如 <head><p>,它们告诉浏览器如何格式化网页。在开始标签和结束标签之间的文本被称为 元素。例如,“马丁·路德·金博士 1962 年的演讲”是一个标题元素,位于开始标签 <title> 和结束标签 </title> 之间。段落使用 <p></p> 标签格式化。

因为这些标签不是原始文本的一部分,所以在进行进一步分析(如自然语言处理)之前应该将它们移除。要移除这些标签,你需要使用 Beautiful Soup 库。

Beautiful Soup

Beautiful Soup 是一个开源 Python 库,用于从 HTML 和 XML 文件中提取可读数据。它在 Anaconda 中是预安装的。

让我们在上一节中通过请求返回的 HTML 文件上使用 Beautiful Soup(简写为 bs4):

import bs4

soup = bs4.BeautifulSoup(page.text, 'html.parser')
p_elems = [element.text for element in soup.find_all('p')]
speech = ' '.join(p_elems)
print(speech)

在导入 bs4 后,我们调用bs4.BeautifulSoup()方法,并将包含 HTML 的字符串传递给它。soup变量现在引用一个BeautifulSoup对象,这意味着你可以使用find_all()方法来定位 HTML 文档中被段落标签(<p>)包裹的文本。这将生成一个列表,你可以通过在空格(' ')上连接段落元素,将它变成一个连续的文本字符串。以下是(截断的)打印结果:

I am happy to join with you today in what will go down in
history as the greatest demonstration for freedom in the history
of our nation.  Five score years ago a great American in whose
symbolic shadow we stand today signed the Emancipation Proclamation.
This momentous decree came as a great beckoning light of hope to
--snip--

现在你拥有了可以轻松阅读并用 Python 的多种语言处理工具进行分析的文本。

正则表达式

无论你从哪里获取原始数据,它可能都包含拼写错误、格式问题、缺失的值以及其他阻碍你立即使用的数据问题。你需要以某种方式处理它,例如重新格式化、替换或删除某些部分,而且你希望能够 批量 处理。幸运的是,正则表达式 为你提供了多种工具来解析原始文本并执行这些任务。

正则表达式(或 有理 表达式),通常简写为 regex,是一个字符序列,用来指定一个 搜索模式。如果你曾经在文本编辑器中使用过“查找”或“查找和替换”功能,那么你可能对这些模式很熟悉。通过模式匹配,正则表达式帮助你从不需要的文本中提取出你想要的内容。

正则表达式可以做一些繁琐但重要的事情,这些事情通常是你会委派给助理或技术人员的。例如,它可以扫描文本,寻找与你研究领域相关的信息。如果你是一名研究地震的地震学家,你可以编写程序扫描新闻信息,获取有关这些事件的报告,抓取数据、格式化并存储到数据库中。

Python 有一个内建模块叫做 re,你可以用它来处理正则表达式。我们来看一个例子,在这个例子中,你正在搜索文本中的 10 位电话号码。在这个数据库中,人们以多种方式输入电话号码,例如带区号的括号、使用破折号、使用空格等等,但你希望使用没有空格的 10 个连续数字。以下是 re 和 Python 如何帮助你提取和格式化这些数字的方式:

import re

data = 'My phone number: (601)437-4455, also my number: (601) 437-4455, \
          again my number: 601-437-4455, still my number: 601.437.4455'

nums = re.findall(r'[\(]?[1-9][0-9\ \.\-\(\)]{10,}[0-9]', data)

print(nums)

在导入 re 并输入数据后,你会分配一个名为nums的变量,并调用re.findall()方法。这种复杂的语法看起来像是某种代码,双关语有意而为之,就像任何代码一样,你必须知道关键所在。不展开细节,你基本上是在告诉findall()方法以下内容:

  • 匹配的文本字符串可能以(符号或数字一到九[\(]?[1-9]开始。

  • 其中可以有数字、空格、句点、破折号或括号[0-9\ \.\-\(\)]

  • 匹配的字符串必须至少包含 10 个字符{10,}

  • 最终,它必须以零到九之间的数字[0-9]结束。

这个第一次尝试会找到所有输入的数字:

['(601)437-4455', '(601) 437-4455', '601-437-4455', '601.437.4455']

接下来,你需要使用 re.sub() 方法删除非数字字符,该方法会用你提供的字符替换目标字符。^ 告诉方法查找除了零到九的数字以外的所有内容,并将它们替换为空,表示为 ''

nums_nospace = re.sub('[⁰-9]', '', str(nums))
print(nums_nospace)

这会生成一个连续的数字字符串:

6014374455601437445560143744556014374455

你现在可以使用列表推导式遍历这个字符串,并提取你所需的 10 位数字分组:

phone_list = [nums_nospace[x:x+10] for x in range(0, len(nums_nospace), 10)]
print(phone_list)

这将生成你所需格式的数字列表(以字符串形式表示):

['6014374455', '6014374455', '6014374455', '6014374455']

这个简单的例子展示了正则表达式的强大功能,也暴露了它语法的复杂性。事实上,正则表达式可能是 Python 中最“不 Pythonic”的东西。幸运的是,由于几乎每个人都在与其语法作斗争,因此有很多工具、教程、书籍和备忘单可供帮助你使用它。

你可以在 docs.python.org/3/howto/regex.htmlrealpython.com/regex-python/ 上找到很好的“如何使用”教程。Al Sweigart 的《用 Python 自动化无聊的工作》第二版(No Starch Press,2019)第七章提供了正则表达式模式匹配的概述,而 Jeffrey Friedl 的《正则表达式精通》(O'Reilly,2006)则深入讲解了它们。你还可以在许多网站上找到带有示例的备忘单,包括 learnbyexample.github.io/python-regex-cheatsheet/。其他网站,如 regexr.com/www.regexpal.com/ 允许你玩转正则表达式,以学习它们是如何工作的。

如果你需要处理大量文本,正则表达式将显著减少你需要编写的代码量,从而节省时间并减少挫败感。只需稍加努力,你就能完全掌握你的数据,解决问题,并自动化一些你可能从未意识到可以自动化的任务。

Dask

Dask 是一个用 Python 编写的开源并行计算库。它被开发用来将 Python 生态系统库(如 pandas、NumPy、scikit-learn、Matplotlib、Jupyter Notebook 等)从单台计算机扩展到多核机器和分布式集群。Dask 在 Anaconda 中是预安装的。

为了理解 Dask 所提供的好处,先让我们简单了解一些术语。线程是可以被调度器独立管理的最小程序指令序列。并行处理指的是将计算任务的不同部分——线程——分配到两个或多个处理器上,以加速程序执行。

在过去,计算机的中央处理单元(CPU)只有一个微处理器,或者说是 核心,它一次执行一步代码,就像一支军队单列行进。如今,计算机至少配备双核 CPU,包含两个完整的微处理器,它们共享通往内存和外设的单一路径。高端工作站甚至可以有八个或更多核心。因此,理论上,你的程序不再需要单列行进;它们可以并肩运行。也就是说,如果存在独立的线程,它们可以同时运行,从而节省大量时间。

但是 Python 在并行计算方面有其局限性。即使现在计算机有多个 CPU,Python 仍然使用 全局解释器锁(GIL) 来提高单线程的性能,鼓励一次只执行一个线程。这限制了多个 CPU 核心的使用,无法提高计算速度。

使用 Dask,你可以用 Python 在多核机器上本地进行并行计算,或者跨上千台机器远程计算。Dask 还非常高效地执行这些任务,同时进行内存管理。为了保持较低的内存占用,它将大数据集存储在磁盘上,并复制数据块进行处理。它还会尽可能快地丢弃中间值。因此,Dask 允许在笔记本电脑上操作大于 100GB 的数据集,在工作站上操作大于 1TB 的数据集。

Dask 由两部分组成:分布式数据结构,提供类似于 pandas DataFrame 和 NumPy 数组的 API,以及任务图和调度器(图 15-4)。它实现了许多与 pandas 相同的方法,这意味着在许多情况下它可以完全替代 pandas。Dask 还提供 NumPy 和 scikit-learn 的替代品,并具备扩展 任何 Python 代码的能力。

图片

图 15-4:Dask 集合生成由调度器执行的图(图片来源:dask.org/)。

Dask 会给你的项目增加额外的复杂性,因此你应该主要在处理巨大数据集并需要使用集群计算时使用它。Dask 的文档非常优秀,并且网上有很多教程可以帮助你使用这个库。

注意

你可能听说过 Apache Spark,这是一个比 Dask 更成熟、更“重”的替代方案,已经成为大数据企业界的主流工具,并且深受信赖。它是一个一体化项目,拥有自己的生态系统,主要使用 Scala 编写,并支持部分 Python。你可以在 docs.dask.org/en/latest/spark.html 找到这两个库的对比。一般来说,如果你已经在使用 Python 及其相关库,如 NumPy 和 pandas,你可能会更倾向于使用 Dask。

总结

Python 通过其易于使用的核心语言和构建在其上的众多库,支持科学工作。这些包不仅免费,而且由于庞大且活跃的用户社区,它们也非常稳健、可靠且文档齐全。

你了解了一些最重要且最受欢迎的科学库,包括用于数值和数组计算的 NumPy 和 SciPy;用于数据分析的 pandas;用于机器学习的 scikit-learn;用于神经网络的 Tensorflow、Keras 和 PyTorch;用于计算机视觉的 OpenCV;以及用于语言处理的 NLTK。在第十六章和第十七章中,我们介绍了处理地理数据和创建可视化的库。在第十八章、第十九章、第二十章和第二十一章中,我们更深入地探讨了 NumPy、Matplotlib、pandas 和 scikit-learn。

第十六章:信息可视化、科学可视化与仪表板库**

image

数据可视化是科学的一个重要组成部分。人类天生是视觉生物,以图形方式查看数据比阅读字符串或数字列表更高效、更直观。有效的图表帮助你清理、准备和探索数据。你可以利用它们揭示异常值和虚假样本,识别模式,并比较数据集。也许最重要的是,它们帮助你与他人清晰地沟通,并以一种易于理解的方式传达你的想法。难怪图形被称为“沟通的巅峰”。

数据可视化是一个非常广泛的类别,涵盖了从用于数据探索和报告的简单图表,到实时操作的复杂交互式 Web 应用程序。使用 Python,你可以轻松地涵盖这一范围。事实上,当涉及到创建图形时,Python 因其丰富的资源而感到困扰。拥有超过 40 个不同的绘图库,几乎每个人都能找到适合自己的。但这也是问题的一部分。

穿越 Python 的绘图 API 令人精疲力尽。用户可能会被所有选择所淹没,这些选择涵盖了广泛的功能,既有独特的也有重叠的。结果,他们通常更多地关注学习 API,而不是他们真正的工作:探索数据。事实上,本书的灵感来自于与其他科学家讨论这一问题,他们正是因为这个问题感到沮丧。

Python 的绘图库的另一个问题是,绝大多数都要求你编写代码来创建即使是最简单的可视化。与 Tableau 或 Excel 等软件相比,这些软件通过少量鼠标点击就能创建合理且美观的图表,且对用户的认知负担很小。

幸运的是,许多用户有类似的需求,通过稍微的前瞻性思考,你可以避免走上次优路径。通常,这涉及选择一个涵盖最常见任务的高层工具,简洁且方便,通常通过在现有工具上提供更简单的 API 来实现。

在接下来的章节中,我们将广泛地了解一些 Python 中最受欢迎和最有用的绘图和仪表板库。然后,我们将回顾一些逻辑性问题,帮助你找到最适合你需求的绘图库或库。

注意

本章中的绘图示例旨在展示代码的复杂性和生成的图表类型。你不需要运行代码片段,因为许多讨论的库并未预先安装在 Anaconda 中。但如果你确实想自己测试它们,可以在产品网页上找到安装说明。我建议你将它们全部安装在一个专用的 conda 环境中(请参见第二章),而不是将它们放在基础环境中。

信息可视化和科学可视化库

我们可以将可视化分为三大类:InfoVisSciVisGeoVis (见图 16-1)。InfoVis(信息可视化)指的是 2D 或简单的 3D 静态或交互式数据表示,常见的例子包括统计图表,如饼图和柱状图。SciVis(科学可视化)指的是物理数据的图形表示,这些可视化旨在通过新颖和非常规的手段提供数据洞察。例如磁共振成像(MRI)和湍流流体流动的模拟。GeoVis(地理可视化)指的是通过静态和交互式可视化分析地理位置数据。常见的例子包括卫星图像和地图创建。

Image

图 16-1:三种可视化类别及示例

表 16-1 列出了 Python 中一些重要的 InfoVis 和 SciVis 绘图库。接下来,我们将详细介绍其中的一些,然后再转向仪表盘库。最后,在第十七章中,我们将对 GeoVis 库进行类似的探讨。

表 16-1: Python 主要的 InfoVis 和 SciVis 库

类型 描述 网址
InfoVis Matplotlib 适用于出版质量的 2D 和简单 3D 图表 matplotlib.org/
seaborn Matplotlib 封装库,简化且美化绘图 seaborn.pydata.org/
pandas Matplotlib 封装库,简化 DataFrame 图表绘制 pandas.pydata.org/
Altair 简单易用的 2D 图形,适用于小型数据集 altair-viz.github.io/
ggplot 使用 pandas 绘制简单的“图形语法”图表 yhat.github.io/ggpy/
Bokeh 大型或流数据集的网页交互工具 bokeh.org/
Chartify 基于 Bokeh 的图表封装,简化图表绘制 github.com/spotify/chartify/
Plotly 动态交互图形,适用于网页应用 plotly.com/python/
HoloViews 可被多种库使用的可视化数据结构 holoviews.org/
hvPlot 基于 HoloViews/Bokeh 的简易交互绘图库 hvplot.holoviz.org/
Datashader 用于将巨大数据集栅格化的工具,便于可视化 datashader.org/
SciVis VTK 3D 计算机图形的可视化工具包 vtk.org/
Mayavi 具有交互性的 3D 科学可视化工具 docs.enthought.com/mayavi/
ParaView 具有交互性的 3D 科学可视化工具 www.paraview.org/

注意

如果你对我们是如何陷入这场混乱的好奇,可以花几分钟看看 James Bednar 的博客文章“Python 数据可视化 2018:为什么有这么多库?”(www.anaconda.com/blog/python-data-visualization-2018-why-so-many-libraries/)。你还应该查看他的电子书《Python 数据可视化》和 PyViz 网站 (pyviz.org/),这些资源旨在帮助用户为他们的需求选择最佳的开源 Python 数据可视化工具,包括链接、概述、比较、示例和详尽的工具列表。

Matplotlib

Matplotlib 库是一个开源的、综合性的 Python 可视化库,旨在创建手稿级别的静态、动画和交互式可视化。主要是 2D 图表,例如条形图、饼图、散点图等,尽管也有一些 3D 绘图的可能性(图 16-2)。Matplotlib 已经有近 20 年的历史,最初是为了为早期版本的 Python 提供一个类似 MATLAB 的界面。MATLAB 是一种专有的科学编程语言,虽然曾经很流行,但现在已被 Python 取而代之。

Image

图 16-2:Matplotlib 绘图类型的小样本(图片来源:matplotlib.org/

Matplotlib 的重点是创建用于出版物的静态图像和用于数据探索与分析的交互式图形。这些交互式图形使用像 Qt 这样的 GUI 工具包,而不是 Web 应用程序。该库已与 Anaconda 一起预安装。

Matplotlib 是 Python 可视化的王者、祖宗和“大咖”。它是一个庞大且详尽的库,许多替代产品都建立在它之上,就像其他库也建立在 NumPy 之上(包括 Matplotlib)。同样,像 pandas 这样的库的内部可视化工具也依赖于 Matplotlib 方法。

Matplotlib 的座右铭是“让简单的事变得简单,让复杂的事变得可能”。它支持所有操作系统,并处理所有常见的图像格式。它功能广泛,几乎可以构建你能想象的任何类型的图表,而且由于 Matplotlib 和 IPython 社区的合作,它与其他流行的科学库如 pandas、NumPy 和 scikit-learn 具有高度兼容性。

Matplotlib 是一个强大但低级的绘图引擎。这意味着你有很多灵活性和选项,可以通过逐步组装组件来精确控制图表。但这种自由也带来了复杂性。当创建复杂的图表时,你的代码可能会变得丑陋、密集且冗长。

Matplotlib 的 API 的难用性在一定程度上被其流行度和成熟度所弥补。简单的在线搜索就能找到几乎任何你想绘制的图形的示例代码。它最宝贵的资源无疑是 Matplotlib 的图库matplotlib.org/gallery/index.html/),这是一个包含各种图形绘制代码“食谱”的“烹饪书”。

Matplotlib 的其他问题包括其图形的外观和“可探索性”。尽管 Matplotlib 图形具有交互功能,如缩放、平移、保存和显示光标的位置(图 16-3),但与更现代的库相比,它们显得有些过时。

Image

图 16-3:Matplotlib 图形在外部 Qt 窗口(左)与 Jupyter 笔记本内联显示(右)的对比

默认情况下,Matplotlib 的交互功能是设计为在外部窗口中工作,而不是在与你的代码位于同一屏幕上的内联模式下。你可以在 Jupyter Notebook 和 JupyterLab 中强制使用内联交互,但结果可能会出现故障。例如,保存按钮可能会直接打开一个空白网页,而不是下载图形。其他库还提供了更智能的光标悬停功能,可以显示有关已显示数据的自定义信息。

作为 Matplotlib 主导地位和实用性的证明,许多外部包扩展或建立在 Matplotlib 的功能之上(参见matplotlib.org/3.2.1/thirdpartypackages/)。其中两个包,mpldatacursormplcursors,允许你只用几行代码向图形添加一些交互式数据光标功能。

同样,也有一些依赖 Matplotlib 底层的附加可视化工具包。其中最重要的之一是seaborn,它旨在简化绘图,并生成比 Matplotlib 默认值更具吸引力的图形。seaborn 和 pandas 都是 Matplotlib 的封装库,使你可以使用更少的代码访问 Matplotlib 的部分方法。

seaborn

seaborn库是一个免费的开源可视化库,建立在 Matplotlib 之上。它提供了一个更高层次的(即更易用的)接口,用于绘制吸引人且信息丰富的统计图形,如条形图、散点图、直方图等。它还内置了密度估计器、置信区间和回归函数的功能。不出所料,它与 pandas 和 NumPy 中的数据结构有很好的集成。seaborn 已在 Anaconda 中预安装。

seaborn 的目标之一是通过使用面向数据集的绘图函数,使可视化成为探索和理解数据的核心部分。它使得默认的图表更加美观,并支持构建复杂的可视化。通过使用高级的多图网格和不同的颜色调色板,它帮助揭示数据模式(访问 seaborn.pydata.org/examples/index.html 以查看更多示例)。

Seaborn 设计上与 pandas 中的流行 DataFrame 对象兼容,你可以轻松地将列名分配给图表的坐标轴。它也被认为比 Matplotlib 更适合制作多维度的图表。

在下面的示例中,最后一行代码生成了一个吸引人的散点图,包括一个带有 95%置信区间的线性回归线、边际直方图和分布图:

import seaborn as sns
tips = sns.load_dataset('tips')
sns.jointplot(data=tips, x='total_bill', y='tip', kind='reg');

Image

seaborn 的最佳特性之一是pairplot。这种内置的图表类型让你可以在一张图中探索整个数据集中的成对关系,提供查看直方图、分层核密度估计、散点图等选项。以下是使用 Palmer 群岛数据集创建的 pairplot 示例,用于识别企鹅物种。数据被加载为 pandas DataFrame(有关 pandas 库的概述,请参见第十五章)。

import seaborn as sns
penguins = sns.load_dataset('penguins')
sns.pairplot(data=penguins, hue='species', markers=['o', 'X', 's']);

Image

另一个内置的图表类型,stripplot,是一种散点图,其中一个变量是分类变量。它非常适合比较不同企鹅物种的喙长:

sns.set_theme(style='whitegrid')
strip = sns.stripplot(x='bill_length_mm', y='species', data=penguins);

Image

与 Matplotlib 不同,seaborn 允许你在绘图操作过程中操作数据。例如,你可以通过在barplot()方法中调用内置的长度函数(len)来计算企鹅数据集中的体重样本数量:

bar = sns.barplot(data=penguins, x='species', y='body_mass_g', estimator=len)
bar.set(xlabel='Penguin Species', ylabel='Number of Samples');

Image

让我们看看使用 seaborn 定制图表有多么简单。表 16-2 列出了 COVID-19 大流行初期,受影响最严重的前 10 个国家(按病例数排序)。致死率列列出了每 100 例确诊病例的死亡人数。每 10 万人死亡数列根据国家的总人口计算死亡人数。

表 16-2: COVID-19 统计数据

国家 地区 病例数 死亡人数 每 10 万人死亡数 致死率
美国 北美 31,197,873 562,066 171.80 0.018
印度 亚洲 13,527,717 170,179 12.58 0.013
巴西 拉丁美洲 13,482,023 353,137 168.59 0.026
法国 欧洲 5,119,585 98,909 147.65 0.019
俄罗斯 亚洲 4,589,209 101,282 70.10 0.022
英国 欧洲 4,384,610 127,331 191.51 0.029
土耳其 中东 3,849,011 33,939 41.23 0.009
意大利 欧洲 3,769,814 114,254 189.06 0.030
西班牙 欧洲 3,347,512 76,328 163.36 0.023
德国 欧洲 3,012,158 78,500 94.66 0.026
来源: coronavirus.jhu.edu/data/mortality

让我们将表 16-2 保存为逗号分隔值(.csv)文件,并与 seaborn 一起使用,查看死亡人数、每 10 万人死亡率和致死率之间的关系:

import pandas as pd
import seaborn as sns

sns.set_style('whitegrid')
df = pd.read_csv('johns_hopkins_covid_stats_apr_2021.csv')
scatter = sns.scatterplot(data=df, 
                          x='Deaths', 
                          y='Deaths/100K Popl', 
                          hue='Country', 
                          style='Country',
                          size='Fatality Rate', 
                          sizes=(50, 200))
scatter.legend(loc='center right', bbox_to_anchor=(1.4, 0.5), ncol=1);

Image

在导入 pandas 和 seaborn 后,你设置图表的样式,使其背景为白色并带有网格线。然后以 .csv 格式加载数据,生成名为 df 的 pandas DataFrame。创建散点图(scatter)只需要一个命令。标记的颜色(hue)和形状(style)基于国家,标记的大小反映了致死率,大小范围为 50 到 200。最后,你创建一个图例并调用图表。请注意,通过使用表 16-2 中的 DataFrame 列名,代码易于阅读和理解。

尽管 seaborn 是基于 Matplotlib 的抽象层,但它仍然提供访问底层 Matplotlib 对象的方式,因此你仍然可以精确控制你的图表。当然,你需要对 Matplotlib 有一定了解,才能以这种方式调整 seaborn 的默认设置。

相比 Matplotlib,seaborn 绘制的图表被认为更具吸引力,因此更适合用于出版物和演示文稿。如果你只需要用更简单的代码和更好的默认设置制作静态图像,那么 seaborn 是一个不错的选择。

注意

即使你选择使用 Matplotlib 而不是 seaborn 封装器,你仍然可以导入 seaborn 并使用其主题来改善图表的视觉效果。例如,参见 www.python-graph-gallery.com/106-seaborn-style-on-matplotlib-plotseaborn.pydata.org/generated/seaborn.set_theme.html?highlight=themes

pandas 绘图 API

在上一章中讨论的 pandas 库有自己的绘图 API,Pandas.plot() (pandas.pydata.org/pandas-docs/stable/user_guide/visualization.html)。这个 API 已经成为创建 2D 图表的事实标准,因为它可以使用 Matplotlib 和许多其他库作为绘图后端。这使得通过 pandas 学习一套绘图命令后,你可以使用各种库来制作静态或交互式图表。

在 pandas 中绘图可以说是使用 Python 创建可视化的最简单方法。它特别适合于快速的“临时”图表,用于数据探索。我们来看看:

import pandas as pd

female_ht_vs_wt = {'height': [137, 152, 168, 183, 198, 213],
                  'weight': [31.2, 45.2, 58.8, 72.3, 85.5, 108.3]}
df = pd.DataFrame(female_ht_vs_wt)
df.plot(kind='scatter', x='weight', y='height')
df.plot.bar('weight');

Image

在导入 pandas 并创建一个关于女性身高与体重关系的 Python 字典后,我们将字典转换为 pandas DataFrame。然后,代码的最后两行可以立即生成两个图形!还有比这更简单的吗?

这些图形非常简洁,缺乏任何交互功能,但不用担心,pandas 与其他绘图库配合得很好。只需一点点努力,你就可以切换到其他绘图库以获得额外的功能。通过将 pandas 的绘图后端更改为 HoloViews,我们将很快讨论这个库,你就能生成一个交互式图形,让你缩放、平移、保存,并在点上悬停光标查看其值。下面是代码和结果的示例:

import pandas as pd

pd.options.plotting.backend = 'holoviews'
female_ht_vs_wt = {'height': [137, 152, 168, 183, 196, 213],
                  'weight': [31.2, 45.2, 58.8, 72.3, 84.5, 108.3]}
df = pd.DataFrame(female_ht_vs_wt)
df.plot(kind='scatter', x='weight', y='height')

Image

请注意,尽管更改了绘图库,但你并没有需要修改原始绘图代码中的任何一行。要查看一些其他的Pandas .plot() API 替代品,请访问 pyviz.org/high-level/index.html#pandas-plot-api/

Altair

Altair 是一个开源的统计可视化库,专为 Python 设计,与 pandas DataFrame 紧密对接。它受到那些希望快速可视化小型数据集的用户的喜爱。

Altair 自动处理了许多绘图细节,让你专注于你想要做的事情,而不是“如何做”的按钮操作部分。就像前一部分中提到的女性身高与体重的例子,你只需将数据列与编码通道(例如 x 轴和 y 轴)关联,即可制作图形。但这种易用性也带来了一些缺点。与 Matplotlib 绘制的图形相比,它的自定义程度较低,而且不支持 3D 绘图功能。

另一方面,所有 Altair 图形都可以交互式操作,这意味着你可以缩放、平移、突出显示图形区域,使用选定的数据更新关联图表,启用工具提示,让你将光标悬停在点上以查看详细信息,等等。Altair 可视化需要 JavaScript 前端来显示图表,因此应与 Jupyter notebooks 或支持笔记本功能的集成开发环境(IDE)一起使用。

与 Matplotlib 和其他命令式绘图库不同,Altair 本质上是声明式的,它生成一个 JSON 格式的图形对象,图形可以从该对象中重新构建。JSON,全称为 JavaScript 对象表示法,是一种用于存储和传输数据对象的文件和数据交换格式,采用人类可读的文本格式。由此,Altair 生成的图形并非由像素构成,而是由数据和可视化规格构成的图形。

由于声明式绘图对象存储了数据和相关元数据,因此在绘制图表命令期间操纵数据或将其与其他数据一起可视化变得非常容易。它还可能导致非常大的可视化文件大小,或将整个数据集存储在你的 Jupyter notebook 中。尽管有一些解决方法可以帮助你管理内存和性能问题,但库的文档建议绘制的数据行数不要超过 5000 行(见 altair-viz.github.io/user_guide/faq.html#altair-faq-large-notebook/)。

使用 JSON 的另一个缺点是,如果与不受信任的服务或浏览器一起使用,它可能会被攻击。这会使托管的网页应用程序容易受到各种攻击。

Bokeh

Bokeh 是一个开源可视化库,支持从非常大或流式数据集创建交互式的、适合网页展示的图表。Bokeh(发音为“BO-kay”)使用 Python 定义的图表,并自动通过 HTML 和 JavaScript(这两种是用于交互式网页的主流编程语言)在网页浏览器中渲染它们。它是维护良好且支持较好的库之一,并且已经预装在 Anaconda 中。

Bokeh 可以输出 JSON 对象、HTML 文档或交互式网页应用程序。它有一个三层接口,从简单快速到非常详细,逐步增加对图表的控制。然而,与 Matplotlib 不同,Bokeh 没有为一些常见图表类型(如饼图、甜甜圈图或直方图)提供高级方法。这需要额外的工作,并且需要使用如 NumPy 等额外的库。对 3D 绘图的支持也有限。因此,从实际角度来看,Bokeh 的原生 API 主要用于将图表发布为网页应用或基于 HTML/JavaScript 的报告,或者当你需要生成高度交互的图表或仪表板时使用。

Bokeh 在 Jupyter notebooks 中表现良好,并允许你使用主题,你可以提前规定绘图的外观,比如字体大小、坐标轴刻度、图例等等。图表还配备了一个工具栏(图 16-4),用于交互操作,包括缩放、平移和保存。

Image

图 16-4:Bokeh 图表工具栏(由 bokeh.org/ 提供)

最后,如果你将数据保存在 pandas 中,你可以使用一个名为 Pandas-Bokeh 的库(github.com/PatrikHlobil/Pandas-Bokeh/),该库直接消费 pandas 数据对象,并使用 Bokeh 渲染它们。这比单独使用 Bokeh 提供了一个更高层次、更易用的接口。基于 Bokeh 构建的其他高级 API 包括用于绘图的 HoloViews、hvPlot 和 Chartify,以及用于创建仪表板的 Panel。我们将在本章稍后部分介绍这些内容。

Plotly

Plotly 是一个开源的基于网页的工具包,用于制作互动式的、出版质量的图形。它与 Bokeh 类似,都是通过 Python 生成所需的 JavaScript 来构建交互式图表。像 Bokeh 和 Matplotlib 一样,Plotly 是一个核心 Python 库,许多更高级的库都基于它。

Plotly 图表存储在 JSON 数据格式中。这使得它们可以便捷地通过其他编程语言(如 R、Julia、MATLAB 等)的脚本进行读取和使用。其基于网页的可视化效果可以在 Jupyter notebook 中展示,保存为独立的 HTML 文件,或嵌入到网页应用程序中。由于 Plotly 使用 JSON,它也面临与 Altair 类似的内存和安全问题(请参见 第 429 页的“Altair”部分)。

与 Matplotlib 和 seaborn 不同,Plotly 专注于在 Python 中创建动态的、交互式的图形,以便嵌入到网页应用中。你可以创建基础图表,也可以创建更独特的等高线图、树状图和 3D 图表(参见 图 16-5)。

Image

图 16-5:使用 Plotly Express 制作的 3D 散点图

图 16-6 展示了一个 3D 网格的示例。你甚至可以在图例和标题中显示 LaTeX 方程式。

Image

图 16-6:在 Plotly/Dash 中绘制的 3D 网格凉鞋

Plotly 还支持滑块、过滤器、鼠标悬停和鼠标点击事件。只需几行代码,你就可以创建引人注目的互动图表,这不仅能节省你在数据集探索中的时间,还能轻松修改并导出。这一工具包还支持多个数据源的复杂可视化,而不像 Tableau 等产品那样每个图表只能接受一个数据表作为输入。

Plotly 使用 JavaScript 编写,并驱动 Dash (dash.plotly.com/introduction),一个开源 Python 框架,用于构建网页分析应用程序(称为仪表盘)。Dash 是基于 Plotly.js 的,它大大简化了在 Python 中构建高度自定义的仪表盘。此类应用程序可以在网页浏览器中渲染,并可以部署到服务器并通过 URL 分享。Dash 是跨平台的,并且支持移动端。我们将在“仪表盘”一章的 第 445 页中进一步讨论 Dash。

Plotly 还提供了一个高层次、更加直观的 API,叫做 Plotly Express (plotly.com/python/plotly-express/),它提供了简化语法,可以一次性创建整个图表。它有超过 30 个函数用于创建不同类型的图形,每个函数都经过精心设计,力求保持一致性并尽可能易于学习,让你能够轻松地在散点图、条形图、旭日图等图形之间切换,适用于数据探索过程中的各个阶段。因此,Plotly Express 是使用 Plotly 创建常见图形的推荐起点。

Plotly Express 图表非常容易进行样式设置,以实现非常有用的功能。假设你想查看二十年期间的每月降水总量,并查看 8 月和 10 月与其他月份的比较。使用 Plotly Express,你可以轻松地突出显示这些月份的线条,使其更加显眼。通过交互式工具栏,你可以切换尖峰线和悬停功能,以查询数值(图 16-7)。

图片

图 16-7:一个包含突出显示的线条、尖峰线条和悬停框的 Plotly Express 折线图

Plotly Express 的另一个有用功能是图例是“动态的”。点击图例中的一个类别一次,你会暂时从图表中移除它。点击两次,所有其他线条将消失,只留下该类别独立显示。这是在图 16-8 中针对 8 月(Aug)类别完成的。你甚至可以通过动画展示图表,查看随着时间的变化情况。这是理清复杂“意大利面条”图表的好方法!

图片

图 16-8:双击图例类别将通过移除其他数据来隔离该类别。

让我们重新审视捕捉病毒传播第一年死亡统计数据的 COVID-19 数据集。你将希望将以下代码和结果与第 427 页中的 seaborn 示例进行比较。

import pandas as pd
import plotly.express as px

df = pd.read_csv('johns_hopkins_covid_stats_apr_2021.csv')
fig = px.scatter(data_frame=df, 
                 x='Deaths', 
                 y='Deaths/100K Popl', 
                 color='Country', 
                 size='Fatality Rate', 
                 text='Country')
fig.update_layout(showlegend=False)
fig.show()

图片

与之前的 seaborn 代码类似,这段代码非常易读且易于理解。还要注意,Plotly Express 有一个特定的参数data_frame,它明确告诉你,这个库是为处理 pandas 而设计的。

这里的一个不错的功能是,你可以轻松地在标记上方显示国家名称,让你使用一致的标记形状来进行大小比较。你无法像在 seaborn 中那样自动获得“大小”图例,但 Plotly Express 通过自动允许鼠标悬停事件来弥补这一点,正如英国数据中的图表所示。

Plotly Express 的另一个有用功能是面板图,它让你可以按地理区域查看之前的散点图:

--snip--
fig = px.scatter(data_frame=df, 
                 x='Deaths', 
                 y='Deaths/100K Popl', 
                 color='Country', 
                 size='Fatality Rate', 
                 text='Country',
              ➊ facet_col='Region')
fig.update_layout(showlegend=False)
fig.show()

图片

我们通过向px.scatter()方法添加一个参数➊来实现这一点。

Plotly Express 主要用于探索性数据分析。你的数据必须采用非常特定的格式(它针对的是 pandas DataFrame),你对图表的自定义能力有限,而且你可能会在将可视化结果放入演示文稿时遇到困难。为了能够完成你可能想做的所有操作,你可能需要偶尔切换到完整的 Plotly API,或者将 Plotly Express 与其他库(如 Matplotlib 或 seaborn)结合使用。

还有一个独立的第三方包装库围绕 Plotly,名为 cufflinks (github.com/santosjorge/cufflinks/),它提供了 Plotly 和 pandas 之间的绑定。这个库帮助你使用 Pandas.plot() 接口从 pandas DataFrame 创建图表,但输出结果是 Plotly 图形。

Plotly 和 Plotly Express 都能够直接从 pandas DataFrame 构建 web 图表。你在 Jupyter notebooks 中创建的图表可以直接复制粘贴到 Dash 应用中,快速实现仪表盘。你可以在 plotly.com/python/scientific-charts/ 查看一些使用 Plotly 构建的科学图表示例。

HoloViews

HoloViews 是一个开源库(注意,我没有说是 绘图库),旨在通过抽象掉绘图过程来简化可视化。HoloViews 通过提供一组声明式绘图对象,方便你交互式地可视化数据,这些对象存储你的数据并附带相关元数据。其目标是支持科学研究的整个生命周期,从最初的探索到发布、再到工作复现以及新的扩展。

HoloViews 允许你将各种容器类型组合成数据结构,以便可视化地探索数据。一些示例容器类型包括 Layout,用于将元素并排显示为独立的子图;Overlay,用于将元素叠加显示;以及 DynamicMap,用于动态图表,能够自动更新并响应用户交互。要体验 DynamicMap 容器,可以查看 holoviews.org/user_guide/Streaming_Data.htmlholoviews.org/user_guide/Responding_to_Events.html 来查看动画示例。

HoloViews 使用适当的绘图库,如 Matplotlib、Plotly 或 Bokeh,作为后端生成最终的图表。这让你能够专注于数据,而不是浪费时间编写绘图代码。作为一个绘图“中介”,HoloViews 与 seaborn 和 pandas 等库集成良好,特别适合用来可视化大数据集——多达数十亿条——使用如 DaskDatashader 等库(例如 holoviz.org/tutorial/Plotting.html)。

Python 绘图未来的一个愿景是使用一组库来简化在 web 浏览器中处理小型和大型数据集的过程(见 图 16-9)。这将包括进行探索性分析、制作简单的基于小部件的工具,或者构建功能齐全的仪表盘。

图片

图 16-9:HoloViz 维护的库(由 holoviz.org 提供)

在这一协同努力下,HoloViews 和 GeoViews 提供了一个统一简洁的高级 API,支持像 Matplotlib、Bokeh、Datashader、Cartopy 和 Plotly 这样的库。Panel 提供了统一的仪表盘方法,而 Datashader 允许绘制非常大的数据集。Param 支持声明用于在笔记本上下文中或外部使用的小部件的用户相关参数。这种安排使你能够轻松地在后端之间切换,而无需学习每个新绘图库的命令。

认识到典型的图形是由许多视觉表现组成的对象,HoloViews 使得组合元素变得非常简单,支持两种最常见的方式:将多个表现合并成一个图形,或在同一坐标轴上叠加视觉元素。在制作多图形图表时,HoloViews 通过自动链接各个图形中的坐标轴和选择项来提供帮助。它还非常适用于创建动态更新的图表,尤其是使用滑块的图表。借助 Bokeh 后端,你可以结合各种小部件以及缩放和漫游工具来帮助数据探索。

让我们来看一个 Jupyter Notebook 示例,这个示例改编自 HoloViews 展示页面 (holoviews.org/gallery/index.html),它使用了 HoloViews 和 Panel 来生成图表。数据方面,我们将再次使用 Palmer Archipelago 数据集,该数据集量化了三种企鹅物种的形态学差异。得益于 Panel,你将能够使用下拉菜单在单个图表中切换和装饰显示的数据。

   import seaborn as sns  # For access to penguins dataset.
   import holoviews as hv
   import panel as pn, panel.widgets as pnw
   hv.extension('bokeh')

➊ hv.opts.defaults(hv.opts.Points(height=400, width=500, 
                                   legend_position='right',
                                   show_grid=True))

   penguins = sns.load_dataset('penguins')
   columns = penguins.columns
   discrete = [x for x in columns if penguins[x].dtype == object]
   continuous = [x for x in columns if x not in discrete]
➋ x = pnw.Select(name='X-Axis', value='bill_length_mm', options=continuous)
   y = pnw.Select(name='Y-Axis', value='bill_depth_mm', options=continuous)
   size = pnw.Select(name='Size', value='None', options=['None'] + continuous)
   color = pnw.Select(name='Color', value='None', 
                      options=['None'] + ['species'] + ['island'] + ['sex'])
   @pn.depends(x.param.value, y.param.value, 
               color.param.value, size.param.value) 

➌ def create_figure(x, y, color, size):
       opts = dict(cmap='Category10', line_color='black')
       if color != 'None':
           opts['color'] = color 
       if size != 'None':
           opts['size'] = hv.dim(size).norm() * 20
       return hv.Points(penguins, [x, y], label="{} vs {}".
                        format(x.title(), y.title())).opts(**opts)

   widgets = pn.WidgetBox(x, y, color, size, width=200)
   pn.Row(widgets, create_figure).servable('Cross-selector')

在导入 seaborn(用于数据)、HoloViews 和 Panel 后,你告诉 HoloViews 使用哪个绘图库。Bokeh 是默认选项,但你可以通过将代码行更改为 hv.extension('matplotlib')hv.extension('plotly') 来轻松更改为 Matplotlib 或 Plotly。通常情况下,改变后端无需更改代码的其余部分。

下一行 ➊ 是可选的,但它展示了 HoloViews 的一个不错功能:设置自定义默认值,以决定你希望图表的外观。在这种情况下,你设置了图形的大小、图例的位置以及所有散点图使用的背景网格。

接下来,你加载企鹅数据集,该数据集方便地随 seaborn 库一起以 pandas DataFrame 格式提供。为了给用户提供菜单选项,遍历 penguins DataFrame 中的列,并将内容分别赋值给名为 discretecontinuous 的列表。discrete 列表包含对象,如物种名称、岛屿名称或企鹅的性别。continuous 列表则用于数值数据,如喙长和喙深。

从 ➋ 开始,您必须指定 Panel 小部件将为 x 轴和 y 轴以及标记的大小和颜色显示哪些选项,包括最初显示的默认选项。之后,您定义一个函数来创建图形 ➌ 并返回一个 HoloViews Points 元素。最后两行代码使用菜单小部件创建图形。

该程序的输出如图 16-10 所示。注意图表左侧的下拉菜单和右侧的交互式工具栏。因为我们将 sizecolor 的默认值设置为 'None',所以所有数据点看起来都相同。

现在,您可以使用菜单小部件按物种为数据点上色(见图 16-11),这会在图表的右下角生成一个图例。将大小选项设置为体重允许您将第三个度量标准定性地融入到二维散点图中。现在您可以看到,Gentoo 物种明显比另外两个物种大。

图片

图 16-10:三种不同企鹅物种的喙深度与喙长度关系

图片

图 16-11:按物种着色并根据体重调整大小的喙深度与喙长度关系

在图 16-12 中,我们使用了下拉菜单更改了数据和大小参数。正如您所看到的,这是一个非常好的方式,可以在不生成大量图表的情况下,交互式地探索并熟悉数据集。

图片

图 16-12:按物种着色并根据鳍长调整大小的喙长度与体重关系

这里的一个关键点是,代码引用了 DataFrame 来创建一个 HoloViews Points 元素。这个对象基本上是 DataFrame,并且知道哪些数据映射到 x 轴和 y 轴。这使得 DataFrame 可以被绘制。但与其他库中的图表对象不同,hv.Points 元素会保留原始数据。这使得它在后续的处理管道中仍然可用(要查看动态演示,请访问 HoloViews Showcase: holoviews.org/Tutorials/Showcase.html)。

就像 Plotly 有 Plotly Express 一样,HoloViz 库也有 hvPlot,这是一个建立在 HoloViews 基础上的更简单的绘图替代方案。这个完全交互式的高级 API 补充了基于 Matplotlib 构建的库(如 pandas 和 GeoPandas)提供的主要是静态的图表,这些库需要额外的支持库来进行基于 Web 的交互式绘图。它专为 PyData 生态系统设计,并与其核心数据容器兼容,这些容器允许用户处理各种数据类型(见图 16-13)。

图片

图 16-13:hvPlot 库为 HoloViews 提供了一个高级绘图 API

hvPlot 库的交互式 Bokeh 基础 API 支持平移、缩放、悬停和可点击/可选择的图例。在以下示例中,hvPlot 与 pandas 配合使用,生成一个交互式图表:

import hvplot.pandas
from bokeh.sampledata.degrees import data as degrees

degrees.hvplot.line(x='Year', y=['Art and Performance', 
                                 'Business', 'Biology', 
                                 'Education', 'Computer Science'], 
                    value_label='% of Degrees Earned by Women', 
                    legend='top')

图片

这就像在 pandas 中绘图一样简单,但请注意图表右侧的工具栏,其中有平移、缩放、保存和悬停图标。悬停时,你可以使用光标查询图形的详细信息,如计算机科学变量的弹出窗口所示。这些选项在使用原生 pandas 绘图时是不可用的。

若想了解更多关于这些库的信息,请查看 HoloViz (holoviz.org/),这是一个旨在让 Python 中的基于浏览器的数据可视化更加易用、易学并更强大的协调性努力。

Datashader

Datashader 是一个开源库,旨在可视化非常大的数据集。Datashader 并不是将整个数据集从 Python 服务器传输到浏览器进行渲染,而是将其栅格化(像素化)成一个更小的热图或图像,然后再传输进行渲染。与 Matplotlib 等流行库在处理仅 10 万个点时可能遇到的性能问题不同,Datashader 可以处理数亿甚至数十亿个数据点。例如,图 16-14 绘制了 3 亿个数据点。

图片

图 16-14:Datashader 创建的 3 亿数据点的图表,数据来源于 2010 年人口普查(感谢 Datashader 提供)

Datashader 使得在标准硬件(如笔记本电脑)上处理非常大的数据集成为可能。尽管计算密集型步骤是用 Python 编写的,但它们通过名为 Numba (numba.pydata.org/) 的工具透明地编译为机器代码,并通过 Dask 分布到多个处理器上。

Datashader 文档强调了该工具在绘图预处理阶段的功能。这意味着 Datashader 通常与其他绘图库一起使用,处理与大数据集相关的繁重工作。因此,尽管它更注重性能和效率,而非直接生成基础统计图表,但它可以与其他工具配合,帮助你绘制大数据集——比如在散点图中——通过处理常见的过度绘制问题,解决分布点密度被掩盖的问题(如图 16-15 所示)。

图片

图 16-15:Datashader(右侧)很好地处理了过度绘制的点(感谢 holoviews.org/ 提供)。

在另一个示例中,假设你使用 Bokeh 将数据直接传输到浏览器,这样即便没有 Python 进程在后台运行,用户也能与数据进行交互。如果数据集包含数百万或数十亿个样本,你将面临 Web 浏览器的限制。但使用 Datashader,你可以将这个庞大的数据集预渲染为固定大小的栅格图像,从而捕捉数据的分布。然后,Bokeh 的交互式图表可以在缩放和平移时动态重新渲染这些图像,使得在 Web 浏览器中处理庞大数据集变得更加容易(图 16-16)。

Image

图 16-16:使用 HoloViews + Bokeh 生成基于 Datashader 的交互式图表(图像来源:datashader.org/)

你可以在 examples.pyviz.org/ 的“选区划分”(gerrymandering)示例中看到 Datashader 实际应用的精彩实例。Datashader 与 HoloViews 和多个绘图库协同工作,生成了一张展示休斯顿人口的地图,按种族进行颜色编码,将绘图转化为精美的艺术作品,呈现出一种如水彩画般的效果,只有在彩色显示时才能真正感受到其美妙。

查看一个很好的示例,展示了如何将 Datashader 与统计图表结合使用,参考 holoviews.org/user_guide/Large_Data.html。Datashader 的共同创始人 Peter Wang 在 www.youtube.com/watch?v=fB3cUrwxMVY/ 上提供了一个易于理解的库概述视频。

在所有这些示例中,请注意你将失去 Datashader 一些交互性。你仍然可以进行缩放和平移,但鼠标悬停事件等将不再有效,除非有特殊支持,因为浏览器并不会将所有数据点加载以供检查。作为回报,你可以在不让计算机崩溃的情况下可视化数百万个数据点。

Mayavi 和 ParaView

一种常见的科学实践是可视化点云数据,比如你可能在激光雷达(LIDAR)扫描中遇到的数据。像 Matplotlib 这样的通用工作库能够在一定程度上执行这个任务,但当你尝试交互式地可视化点云和其他 3D 图表时,性能会迅速下降。例如,如果你尝试与大量样本进行交互,Matplotlib 可能会变得很慢,甚至崩溃你的计算机。即使 3D 表现成功渲染,它们看起来也不会很好,可能很难理解你所看到的内容。

Datashader 可以提供帮助,但对于需要大量图形处理的 3D 和 4D 可视化(例如用于物理过程的可视化),你需要一个专门的库,如 Mayavi(发音为 MA-ya-vee),它能够处理物理位置的规则和不规则网格数据。这使得 Mayavi 与 Datashader 有所不同,因为后者更多地专注于在任意空间中的信息可视化,而不一定是三维的物理世界。

Mayavi2 是一个开源的通用跨平台工具,用于 3D 科学数据可视化。它从一开始就考虑到了脚本编写和可扩展性。你可以将 Mayavi2 导入 Python 脚本中,像使用 Matplotlib 一样将其用作一个简单的绘图库。它还提供了一个应用程序(图 16-17),可以单独使用。

Mayavi2 是用 Python 编写的,使用强大的可视化工具包(VTK)库,并通过 Tkinter 提供图形用户界面(GUI)。它是跨平台的,可以在任何同时支持 Python 和 VTK 的系统上运行(几乎所有 Unix、macOS 或 Windows 系统)。在一定程度上,你可以在 Jupyter 笔记本中使用 Mayavi。想要查看一些 Mayavi2 图表示例,请访问* docs.enthought.com/mayavi/mayavi/auto/examples.html*。

Image

图 16-17:Mayavi2 应用程序用于 3D 可视化。注意右下角的 Python 控制台。

Mayavi2 的替代方案是ParaView(图 2-18)。虽然设计用于 3D,但它也能处理 2D,具有高度的交互性,并且提供 Python 脚本接口。

Image

图 16-18:ParaView 应用程序用于 3D 可视化。注意左下角的 Python 控制台。

ParaView 由桑迪亚国家实验室开发,而 Mayavi 是 Enthought 公司推出的产品,其 Canopy 发行版是 Anaconda 的直接竞争对手。

仪表盘

仪表盘是一种易于阅读的交互式图形用户界面,通常实时展示。仪表盘通常显示在一个链接到数据库的网页上,这样展示的信息就能不断更新。科学领域的仪表盘示例包括天气站、地震监测和航天器跟踪(图 16-19)。

Image

图 16-19:NASA 航天器跟踪仪表盘(图片来源:www.nasa.gov)

仪表盘确实可以大大提高数据的可用性和交互性,尤其是对于非技术用户。只要有互联网连接,它们还可以让数据随时随地都能访问。这在与外部合作方协作或向分散的利益相关者提供结果时尤其重要。

仪表盘需要执行多个任务,如分析和可视化数据、监听并接受用户请求、以及通过 Web 服务器返回网页。你可以将不同的库组合起来处理这些任务,或者你可以直接使用专门的仪表盘库。

Python 支持五个主要库进行高级 Web 仪表盘开发:Dash、Streamlit、Voilà、Panel 和 Bokeh(表 16-4)。这些库允许你使用纯 Python 创建仪表盘,因此你不必学习像 JavaScript 和 HTML 这样的底层启用语言。我们之前已经看过 Bokeh,因此这里我们将重点介绍其他四个。

表 16-4: Python 最重要的仪表盘库

描述 网址
Plotly Dash 高级生产级/企业仪表盘 plotly.com/dash/
Streamlit 快速、简便的 Web 应用,支持多种绘图库 streamlit.io/
Voilà 将 Jupyter Notebook 呈现为独立的 Web 应用 voila.readthedocs.io/
Panel 使用几乎任何库的交互式 Web 应用 panel.holoviz.org/
Bokeh 适用于大规模或流式数据集的 Web 交互性 bokeh.org/

在我们快速了解这四个工具之前,请注意,实际上在其他库中也可以实现仪表盘的某些功能。图形绘制的老牌库 Matplotlib 支持多种 GUI 工具包接口,如 Qt,可以生成本地应用程序,作为基于 Web 的仪表盘的替代方案。而多个库利用 JavaScript 来帮助构建仪表盘,Bowtie(* bowtie-py.readthedocs.io/ )则让你使用纯 Python 来构建仪表盘。你可以在 Jupyter Notebook 中使用ipywidgets*来构建仪表盘,但你需要使用一个单独的可部署服务器,如 Voilà,来共享它。

如需更多了解,PyViz 提供了一个仪表盘页面,包括博客文章、比较文章的链接以及替代或辅助工具的列表。你可以在* pyviz.org/dashboarding/ *找到它。

注意

我们之前看过的 Bokeh,包含了一个小部件和应用库,以及一个用于图表和仪表盘的服务器。它还支持大规模数据集的实时流式传输。然而,如果你打算使用 Bokeh 开发复杂的数据可视化,你需要一些 JavaScript 的知识。Panel 是建立在 Bokeh 上的,就像 seaborn 是建立在 Matplotlib 上的一样,它提供了一个更高级的工具包,使得构建仪表盘变得更容易。它还支持除了 Bokeh 之外的多个绘图库。

Dash

Dash 是一个开源 Python 框架,由 Plotly 开发,作为部署 web 分析应用程序的完整解决方案。Dash 建立在 Plotly.js、React.js 和 Flask(一个用于从零开始构建 web 应用的低级框架)之上。Dash 应用程序在 web 浏览器中渲染,部署到服务器并通过 URL 共享。这使得 Dash 平台独立,并且支持移动设备。在 2020 年,Plotly 发布了 JupyterDash (github.com/plotly/jupyter-dash/),一个新的库,旨在从 Jupyter 环境中构建 Dash 应用。

使用 Dash,你可以仅用几个小时就构建一个响应式的定制界面。顺便提一下,响应式 意味着网页可以在各种设备和屏幕尺寸上良好渲染。Dash 使用简单的模式来抽象化大部分仪表板构建过程,例如生成所需的 JavaScript、React 组件、HTML 和服务器 API。实际上,你基本上可以将 Plotly 图表直接从 Jupyter notebook 复制并粘贴到 Dash 应用中。

在仪表板的外观方面,Dash 提供了一个吸引人的开箱即用默认样式表,但也允许你轻松添加第三方样式。Dash-bootstrap-components (dash-bootstrap-components.opensource.faculty.ai/)是一个开源库,它使得构建具有复杂响应式布局、一致风格的应用变得更容易。你还可以使用来自 Bootswatch 主题的任何主题 (www.bootstrapcdn.com/bootswatch/)。这些节省时间的附加工具可以让你以最小的努力构建专业外观的仪表板。

由于其相对成熟、用户社区的扩展以及大型企业组织的采用,Dash 现在拥有大量专业模块库、多个仓库以及出色的文档和教程,帮助构建定制化仪表板。而大多数科学家可能只会制作简单的单页仪表板,Dash 也能够构建多页、可扩展、高性能的仪表板,并能够将组织风格指南融入最终的布局中。这是 Dash 与像 Streamlit 和 Voilà 这样更简便工具的区别所在。

另一方面,Dash 主要是为 Plotly 设计的,尽管也可以使用其他第三方绘图库(参见 github.com/plotly/dash-alternative-viz-demo/)。Dash 还要求你使用 HTML 和层叠样式表(CSS)语法,这不是 Python 用户通常愿意做的事情。这导致了更简单工具的出现,例如 Streamlit,接下来我们将介绍它。

Streamlit

Streamlit 是一个相对较新的开源库,用于快速构建吸引人的仪表板 web 应用程序。作为一个一体化工具,它解决了 web 服务和数据分析的问题。

Streamlit 的简单 API 让你可以专注于数据分析和可视化,而不是前端和后端技术问题。共享和部署都非常快速且简单,而且学习曲线可以说是 Python 中所有仪表板工具中最短的。因此,Streamlit 的受欢迎程度迅速上升,并且不断有新功能被添加。

与 Dash 主要针对生产和企业环境不同,Streamlit 设计用于快速原型开发。它让你用更少的代码做更多的事情,而且与 Dash 主要与 Plotly 配合工作不同,Streamlit 允许你轻松地混合和匹配来自多个库的图表,包括 Plotly、Altair、Bokeh、seaborn 和 Matplotlib。这使你可以根据具体的绘图任务选择最合适的工具,并允许团队成员使用他们偏好的绘图库。

对于现有的 Python 脚本,Streamlit 可以说是将其快速且轻松转化为交互式仪表板的最佳方法。然而,它不支持 Jupyter Notebook,且在将代码迁移到 Streamlit 时,你可能会遇到一些障碍。另一方面,它与诸如 scikit-learn、TensorFlow/Keras、NumPy、OpenCV、PyTorch、pandas 等主流库高度兼容。如果你对 Streamlit 的默认设计感到满意,并且不需要做太多定制,它是一个快速启动仪表板的绝佳选择。

Voilà

Voilà 是一个开源库,可以让你快速将 Jupyter 笔记本转化为独立的交互式仪表板,并与他人共享。作为建立在 Jupyter 上的薄层,它代表了一个非常具体的用例,而不是一个完整的仪表板解决方案。

Voilà 允许与项目相关的非技术人员使用你的 Jupyter 笔记本,而无需了解 Python 或 Jupyter,也无需在他们的电脑上安装这些工具。如果你已经有了包含所需所有交互性的笔记本,它是将工作转化为仪表板的最短路径。

Voilà 主要用于渲染。一个常见的方法是使用像 bqplot、Plotly 或 ipywidgets 这样的 Python 库,向 Jupyter notebook 中添加交互性(小部件),这些都受到 Voilà 的支持。(我们在第五章中介绍了 ipywidgets)。你可能需要格式化笔记本,以便隐藏和抑制未使用的代码和 Markdown。

Voilà 运行笔记本中的代码,收集输出并将其转换为 HTML。默认情况下,笔记本的代码单元会被隐藏。输出按其在笔记本中出现的顺序垂直显示(图 16-20),但是你可以使用 小部件布局模板 来改变单元输出的位置,例如将它们拖动到水平布局中。然后,页面被保存为一个 Web 应用,其中页面上的小部件可以访问底层 Jupyter 内核。

此时,仪表板仅在你的计算机上。为了让其他人可以访问,你需要通过使用公共云计算平台(如 Binder、Heroku、Amazon Web Services (AWS)、Google Cloud Platform (GCP)、IBM Cloud 或 Microsoft Azure)将仪表板部署到云端。

Binder 是一个免费的开源网络应用程序,用于管理数字存储库,它是部署 Voilà 应用程序最容易接入的方式之一。使用场景包括研讨会、科学工作流和团队之间的简化共享。Heroku (www.heroku.com/) 对于技术不熟悉和预算有限的人也是一个不错的选择。它管理支持的硬件和服务器基础设施,让你可以专注于完善你的应用程序。缺点是,由于网络性能较低,应用程序可能运行较慢。你可以在 voila.readthedocs.io/en/stable/deploy.html 查看更多部署选项。

Voilà 生成的仪表板与 Streamlit 类似,并且使用起来更简单,前提是你已经准备好了 Jupyter 笔记本。Jupyter 爱好者还会欣赏 Voilà 共享 Jupyter 的小部件库,而 Streamlit 则要求你学习自己的一套自定义小部件。你可以在 voila-gallery.org/ 查看一些示例仪表板。

Image

图 16-20:仪表板元素保持 Jupyter Notebook 的排列方式(图片来自 voila-gallery.org)。

Panel

Panel 是一个开源的 Python 库,允许你通过将用户定义的小部件连接到图表、图像、表格或文本,创建自定义的互动式网页应用和仪表板。由 Anaconda 创建和支持,Panel 是 HoloViz 统一绘图库的一部分(见 图 16-9),并使用 Bokeh 服务器。

Panel 帮助支持你的整个工作流,使你无需仅仅依赖一种方式来使用数据和分析,且不需要为了让代码适应不同的使用方式而重写代码。你可以无缝地从数据探索、创建可复现的步骤,到在笔记本中讲述故事,再到为目标观众创建仪表板,甚至从仪表板中创建笔记本。

Panel 根据 Python 语法自动创建前端,而无需编写 HTML 或创建 CSS 样式表。它比 Dash 或 Streamlit 更好地与 Jupyter Notebook 集成。如果你已经在使用 Jupyter Notebook,而 Voilà 又不够灵活,那么它无疑是下一个选择。

与 Streamlit 类似,Panel 可以与多个库的可视化配合使用,包括 Bokeh、Matplotlib、HoloViews 等,使其能够立即查看,无论是单独显示还是与控制它们的交互式小部件结合显示。由于与 HoloViz 家族(包括 GeoViews)集成,Panel 在处理地理空间数据方面特别出色。

Panel 对象是响应式的,会立即更新以反映其状态的变化。这使得组合可视化对象并将它们链接成简单的单次应用程序来执行特定的探索任务变得容易。然后,你可以在更复杂的组合中重用相同的对象来构建更具雄心的应用程序。你还可以在多个页面之间共享信息,以便构建功能全面的多页应用程序。要查看一些示例仪表盘以及 Panel 如何与多个绘图库一起工作,请访问 panel.holoviz.org/gallery/index.html

选择绘图库

即使是 Python 中最简单的绘图库,也需要一定的时间和精力来学习,因此你不可能实际学习所有的库。但有这么多的绘图库可供选择,你如何从中选择?

一个简化的回答是,这取决于你想做什么。但事情并不仅仅如此。你需要超越眼前的需求。你明年会做什么?你的队友和客户使用什么?你如何为长期发展做好准备,减少需要学习的库的数量?

以下部分旨在帮助你选择最适合你的库,或库的组合。它们包括我们迄今讨论过的库,并涵盖以下标准:

数据集大小 你需要绘制的数据点数量

图表类型 你计划制作的图表类型,从统计图表到复杂的 3D 可视化

格式 你计划展示数据的方式,例如静态图表、Jupyter 笔记本、交互式仪表盘等。

多功能性 一个库的能力范围,例如易用性、制作复杂图表的能力以及仪表盘支持

成熟度 库的年龄

对于前四个标准,我们将关注原生的、开箱即用的功能。尽管通过使用其他库(例如,启用交互性)来扩展给定库的功能总是可能的,但这里的假设是,普通用户希望避免这类复杂性。

请记住,我们这里只讨论最流行的绘图库的一个子集。如果你有高度专业化的需求,你需要进行在线搜索,找到最合适的工具。

数据集大小

选择绘图库的最重要的起点考虑因素是你计划使用的数据集的大小。在当今的大数据时代,你无法承受在可视化过程中出现性能不佳或内存问题。尽管有一些方法可以减少和操作大数据集,使其表现得像较小的数据集,但通常你希望尽可能避免这样做。

图 16-21 展示了不同库可以绘制的实际数据规模的大致范围。这些范围更偏向于相对而非绝对,因为最大限制可能取决于你所做的绘图类型、使用的硬件、浏览器性能、是否在 Jupyter notebook 中工作等因素。

Image

图 16-21: InfoVis 和 SciVis 库与数据集大小(以样本数计)对比

我们讨论的大多数 InfoVis 库能够绘制十万到百万个数据点之间的数据。Bokeh 支持基于 Canvas 和 WebGL 的绘图,默认的 Canvas 绘图限制可能在数十万之内。但是,如果使用 WebGL JavaScript API(get.webgl.org/来绘制 Bokeh 图形,假设它支持特定类型的绘图,那么其限制应与 Matplotlib 和 Plotly 相似。

更大的数据集需要使用 Datashader,它将绘图渲染为图像。SciVis 库 Mayavi 和 ParaView 可以使用编译过的数据库和原生 GUI 应用程序处理数十亿个样本。由于 HoloViews 可以使用 Matplotlib、Bokeh 或 Plotly 作为其绘图后端,并且支持 Datashader,因此理论上它能够覆盖 图 16-21 所展示的整个范围。

绘图类型

了解你计划制作的绘图类型,以及它们的互动性程度,有助于你选择最适合你需求的工具。图 16-22 展示了绘图库的能力,左侧为简单的统计图,右侧为复杂的 3D 可视化图。

Image

图 16-22: InfoVis 和 SciVis 库与绘图类型对比

所有的 InfoVis 库都能处理统计绘图。即使是 SciVis 工具 Mayavi 和 ParaView 在某种程度上也具备这个能力,尽管它们并非最佳选择。同样,虽然几个 InfoVis 库可以生成 3D 散点图(图 16-5)和网格(图 16-2 和 16-6),但对于大规模和复杂的 3D 图(如 图 16-17 和 16-18),你仍然需要 Mayavi 或 ParaView 来进行高性能的可视化。在三大绘图库中,只有 Bokeh 没有内置的 3D 功能,尽管可以通过安装其他库进行扩展。

格式

了解你如何展示可视化将帮助你选择一个库,同时保持尽可能简单。除了如 Mayavi、ParaView 和仪表盘工具等特殊产品外,大多数库都可以用来生成静态图形和图像,供打印或报告使用。不过,你需要确认是否能够输出平滑的 SVG 格式,尽管大多数库都支持这一选项。图 16-23 展示了更复杂的选项,范围从 Jupyter 笔记本到可以在浏览器中查看的高度互动的 Web 应用程序。

图片

图 16-23:InfoVis 和 SciVis 库与发布格式

仪表盘库的展示方式是将最简单、最不灵活的库放到左侧,而将功能更强大、可定制性更高的库放到右侧。例如,Voilà 仅与 Jupyter Notebook 配合使用,而 Dash 可以生成企业级的可视化图表。Bokeh 通过 WebSockets 操作,这是一种用于维持客户端和服务器之间持久连接的库,允许你进行多个双向交互时保持连接。

多功能性

有时是自然演变,有时是设计使然,绘图库会成长为一种“家族”形式(图 16-24)。例如,Plotly 家族包含 Plotly Express(用于快速简便的绘图)和 Dash(用于仪表盘)。类似地,HoloViews 包含 hvPlot 和 Panel,而 pandas 和 seaborn 则让使用 Matplotlib 绘图变得尽可能简单。拥有真正多功能的家族,你可以通过简单的语法快速生成图形,深入核心库添加复杂的元素,并无缝地将结果作为 Web 仪表盘分享。

尽管在一定程度上可以混合使用这些库,但需要学习多个库的语法并不太吸引人。Plotly 和 HoloViews 都为你提供了完整的内置功能,但这并不意味着你只能选择这两个选项。Matplotlib 系列可以“集成”一个仪表盘库,如 Streamlit、Panel 或 Voilà,而 Chartify、Pandas-Bokeh 和 hvPlot 则可以作为 Bokeh 的“简易”选项。

图片

图 16-24:InfoVis 和 SciVis 库的多功能性

成熟度

图 16-25 展示了各绘图库的相对使用年限。一个库存在的时间越长,它就越有可能是可靠的、文档齐全的,并且有一个成熟的用户基础,这些用户会提供有用的教程、示例图库和扩展功能。随着时间的推移,用户遇到 bug,学习使用模式,并分享他们的经验。因此,你可以在像 Stack Overflow(* stackoverflow.com/*)这样的帮助网站上找到大多数问题的答案。

Paraview、Matplotlib 和 pandas 已经存在了很长时间,而像 Voilà 和 Panel 这样的库则相对较新。请记住,成熟度是一个有一定可扩展性的标准。非常受欢迎的库会迅速成熟。一个很好的例子就是较新的仪表板库 Dash 和 Streamlit,它们拥有快速增长的用户群体,不断添加新特性并完善文档。

图片

图 16-25:InfoVis 和 SciVis 库的相对年龄

做出最终选择

尽管最好的绘图库可能依赖于你的使用场景以及你的背景和技能水平,但没有人愿意在每个新项目中都跳来跳去地更换工具。尽管如此,很可能你不能仅凭一个可视化库就应付所有需求,尤其是当你需要做多种工作时,包括可视化复杂的 3D 模拟。

如果你打算大量使用 Python,你应该寻找像 Matplotlib、Plotly 或 HoloViz 家族这样的库,覆盖 图 16-21 到 图 16-25 中的尽可能多的内容。这些库可能更难学习,但从长远来看,它是值得的。

学习 Matplotlib 的理由一直都很强大,主要由于它的成熟度、多功能性、与生态系统的良好集成,以及许多其他库是基于它构建的。作为默认的绘图工具,它是一个安全的选择,但如果你更倾向于一个更简单的库,情况也并非完全没有希望。如前所述,图 16-21 到 图 16-24 假设你正在使用所发布库的原生功能。它们进一步假设你希望像缩放和平移这样的功能能够开箱即用。但实际上,许多其他库也存在,并且只需要稍加努力,就可以扩展它们的原生功能。之前你已经看到,借助一行额外的代码,HoloViews 就能为 pandas 绘图 API 生成的静态图形增加交互性。

使用 Anaconda,你可以轻松地安装绘图库并在 Jupyter Notebook 中进行试验。你应该利用在线教程,花时间做一些实验。如果你发现你更喜欢一个相对简单的库,或者一个在这里没有讨论的库,寻找可以添加任何缺失功能的库。你也许能拼凑出一个完美符合你需求的 Frankenstein 产品。

最后的评论:HoloViz 的概念很有趣。它的目标是为 Python 提供一个统一、一致且面向未来的绘图解决方案。特别是如果你有一个长远的职业生涯,这个方案值得认真考虑。

注释

在选择绘图库后,你仍然需要选择一种绘图类型来与数据配合使用。一个很好的起点是From Data to Viz 网站。在这里,你可以找到一个决策树,帮助你根据数据集的格式确定最合适的图表类型。你还会找到一个“注意事项”页面,帮助你理解并避免一些最常见的数据展示错误。

总结

在这一章中,我们回顾了 InfoVis 库,它们用于二维或简单三维的静态或交互式数据表示,以及更复杂的 SciVis 库,用于物理数据的图形表示。由于 InfoVis 库涉及常见的显示方式,如条形图和散点图,因此有许多库可以选择。

最受欢迎的 InfoVis 库是 Matplotlib。由于它的成熟性和灵活性,其他绘图库,如 seaborn,基于 Matplotlib 进行了“封装”,使其更易于使用,并提供了额外的主题和样式。像 Bokeh、Plotly 和 Holoviews 这样的新型绘图库,提供了 Matplotlib 的大部分功能,但还专注于 Web 应用程序和交互式仪表板的构建。其他工具,如 Datashader,则解决了高效绘制大量数据的需求。

选择一个常用的绘图库是个人的决定,受你需要完成的任务和你愿意投入的精力的影响。因为大多数用户希望尽量学习尽可能少的库,最佳的解决方案是选择一个绘图库“家族”,它能提供广泛的图表类型、格式、数据集大小等支持。这需要权衡与一个成熟(但可能不太连贯)的解决方案相比,后者提供大量支持,而新颖的、文档较少的库则试图提供一个无缝的、整体的方法,能够经得起时间的考验。

第十七章:地理空间库

image

地理空间数据是指包含地理位置参考的任何数据,例如纬度和经度、街道地址和邮政编码。这对许多科学领域都非常重要,包括地质学、地理学、气象学、气候学、生物学、考古学、人类学、海洋学、经济学和社会学。因此,有很多 Python 库专门用于处理地理空间数据。

地理空间数据包括矢量栅格数据(图 17-1)。在矢量数据中,空间元素(如多边形、线条和点)通过 x 和 y 坐标表示。举例来说,包括道路中心线、国家边界和星巴克位置。栅格数据由一组行列组成,每个单元格都有一些相关信息(类似于像素)。例如,航拍照片和卫星图像就是栅格数据的例子。这些数据类型可以作为图层应用于地图,让你根据任务需求仅显示所需的内容,比如仅使用基于矢量的街道地图进行导航。你还可以使用矢量数据来计算距离和面积。

Image

图 17-1:通过矢量和栅格数据的组合表示世界

地理信息系统(GIS)全球定位系统(GPS)和遥感是用于获取、处理和存储地理空间数据的技术示例。Python 的灵活性使其在将数据从文件或数据库转换为可用数据方面非常出色。大约在 2008 年,主要的 GIS 平台,如 ArcGIS 和 QGIS,开始采用 Python 进行脚本编写、工具制作和分析。因此,Python 现在成为执行地理空间分析的主流编程语言。正如统计可视化一样,有大量的 Python 库专为帮助你可视化地理空间数据而设计。

地理空间库

地理空间库的目的是跟踪和使用空间对象类型(如点和多边形)、空间参考系统(用于将地球的曲面投影到平面上)、地理和几何格式(用于精确或快速测量距离和面积)、常见的 GIS 数据格式(用于输入/输出)、空间索引(加速处理)以及地图装饰器(如国界和海岸线)。大多数库都允许你创建动画,可以通过将帧转换为 MP4 或直接作为实时动画。

表 17-1 列出了一些重要且流行的地理空间库,以及一些特殊的库。接下来的章节中,我们将从高层次上简要了解其中的几个库。

表 17-1: Python 的重要地理空间库

库名 描述 网站
GeoPandas GIS 库结合了“带几何的 pandas” geopandas.org/
Cartopy 用于与 Matplotlib 一起进行投影感知绘图的工具 scitools.org.uk/cartopy/
geoplot Cartopy 扩展(“seaborn for geospatial”) residentmario.github.io/geoplot/
Plotly 易用的交互式地图 plotly.com/python/maps/
folium 低资源消耗的易用交互式地图 python-visualization.github.io/folium/
ipyleaflet 基于 ipywidgets 的 Jupyter-LeafletJS 桥接 github.com/jupyter-widgets/ipyleaflet/
GeoViews 使用 HoloViews 和 Cartopy 进行地理绘图。 geoviews.org/
KeplerGL 在 Jupyter 中可视化大数据集的工具 docs.kepler.gl/docs/keplergl-jupyter/
pydeck 优化的适用于 Jupyter 的大规模交互工具 pydeck.gl/
PyGMT 用于通用地图工具的 Python 封装 www.pygmt.org/
Bokeh 包括 Google 地图在内的响应式绘图 docs.bokeh.org/
EarthPy 用于处理空间数据的辅助函数 earthpy.readthedocs.io/
gmplot 类似 Matplotlib 的接口,用于在 Google 地图上绘图 github.com/gmplot/gmplot/
MovingPandas 用于跟踪和分析运动数据的工具 anitagraser.github.io/movingpandas/
cuSpatial 用于常见空间操作的 GPU 加速工具 github.com/rapidsai/cuspatial/

注意

本章中的绘图示例旨在展示代码的复杂性和所生成的图形类型。你不需要运行这些代码片段,因为大多数讨论的库并未预先安装在 Anaconda 中。如果你确实想自己测试它们,可以在每个部分引用的产品网页上找到安装说明。我建议你在专门的 conda 环境中安装它们(请参见第二章),而不是将它们安装到基础环境中。

GeoPandas

GeoPandas 是 Python 中解析地理空间数据的最流行的开源库。正如你从名字中可以猜到的,它扩展了 pandas 使用的数据类型(参见第 403 页中的“pandas”),使得处理地理空间矢量数据类似于处理表格数据。它还使得在 Python 中进行的操作,可以避免使用专用的空间数据库(如 PostGIS)。

在 GeoPandas 中,GeoDataFrame看起来像 pandas 中的表格 DataFrame,但它有一个特殊的“geometry”列来存储位置数据(图 17-2)。

Image

图 17-2:几何列(框住的部分)区分了 GeoDataFrame 和 DataFrame。

该几何列将几何对象的类型(表 17-2)和绘制该对象所需的坐标(以经纬度表示)捆绑在一起。

表 17-2: GeoPandas 中使用的几何体

几何类型 描述
Point 一个点
MultiPoint 一组点
LineString 一段线段
MultiLineString 一系列连接的线段
LinearRing 一个封闭的线段集合(零面积多边形)
Polygon 由一系列点定义的闭合形状
MultiPolygon 一组多边形

GeoPandas 不仅使用 pandas,还使用其他几个重要的开源库,提供一个简单而方便的框架来处理地理空间数据。它依赖于Shapelypypi.org/project/Shapely/)来处理平面几何形状(如街道中心线或国家边界多边形)、Fiona(pypi.org/project/Fiona/)来读取和写入地理数据文件格式、pyproj(pypi.org/project/pyproj/)来处理投影、Matplotlib 来绘图,以及 descartes(pypi.org/project/descartes/)来将 Shapely 几何对象与 Matplotlib 进行集成。

结果,你可以通过几行代码从 GeoSeries 或 GeoDataFrame 绘制地图:

import geopandas as gpd

world = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'))
world.plot();

Image

在这个 Jupyter Notebook 示例中,world变量表示一个由 GeoPandas 内部全球数据集生成的 GeoDataFrame。当然,这个简单的图表可以进一步自定义。你可以传递给 Matplotlib 的样式选项,特别是针对线条的样式,将与plot()方法一起使用。

下面是一个人口按国家划分的分级颜色地图示例——其中各区域根据数据值进行着色:

import geopandas as gpd

world = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'))
world = world[(world.name != 'Antarctica')]  # Omit Antarctica.
world.plot(column='pop_est', 
           legend=True,
           legend_kwds={'label': "Population by Country in Billions",
                        'orientation': "horizontal"});

Image

通过将世界数据加载为 GeoDataFrame,可以轻松地过滤数据并重新绘制。在之前的图表中,我们移除了南极洲,因为它没有常住人口。现在,让我们通过修改一行代码来查看所有人口超过 3 亿的国家:

world = world[(world.pop_est > 300000000) & (world.name != 'Antarctica')]

重新运行代码块后,仅显示中国、印度和美国:

Image

历史上,你只能使用 GeoPandas 绘制静态地图。现在,多亏了 Contextily (github.com/geopandas/contextily) 提供的基础地图和 IPYMPL (github.com/matplotlib/ipympl) 提供的 Jupyter 中交互式的 Matplotlib 图表,现在可以使用 GeoPandas 创建交互式地图。同样,基于 HoloViews 构建的 hvPlot(见 第十六章)使用基于 Bokeh 的交互式绘图 API,为 pandas 和 GeoPandas 输出添加了缩放、平移、查询、滑块和可点击图例等功能(见 图 17-3)。

Image

图 17-3:带工具栏和滑块小部件的交互式 hvPlot(感谢 holoviz.org 提供)

安装并导入 Contextily 库后,GeoPandas 可以支持基于瓦片的地图,以及前面展示的基于轮廓的地理地图。瓦片地图(或 Web 地图瓦片)是一种通过无缝拼接数十个通过互联网单独请求的图像或矢量数据文件,在浏览器中显示的地图。Google Maps 中的街道和地形图层就是基于瓦片的地图的典型例子。Contextily 提供了轻松访问流行瓦片源的功能,如 OpenStreetMap 和 Stamen,允许你添加类似 Google Maps 背景的地图(见 图 17-4)。

Image

图 17-4:东京一部分的瓦片地图

与 pandas 类似,GeoPandas 在单核上运行,但它还支持空间索引,这是一种可以显著提升查询大型地理空间数据集性能的技术。GeoPandas 可以生成空间索引,在某些情况下自动生成,在其他情况下则需要手动生成,你可以通过调用 GeoDataFrame 上的 sindex 属性来实现。此外,一个新的库,geofeather (pypi.org/project/geofeather/),可以显著加快读取和写入标准空间文件格式(如 shapefile)的速度。

如果你不打算执行复杂的数据转换或处理百万级记录,GeoPandas 是一个非常好的通用工具。使用这个工具进行绘图需要了解一些晦涩的 Matplotlib 语法,且需要插件来增加交互性。GeoPandas 最适合处理矢量数据,但你也可以使用 rasterio (rasterio.readthedocs.io/en/latest/ ) 执行有限的栅格处理。幸运的是,许多其他地理空间库与 GeoPandas 配合得很好,因此你可以选择在 GeoPandas 中组织数据,并使用其他工具进行绘图。

Cartopy

Cartopy 是一个开源库,用于制作地图和进行地理空间分析。它专为科学家设计,并由一个活跃的开发社区维护。Cartopy 是 Python 标准绘图库 Matplotlib 的扩展,利用了包括 NumPy、Shapely 和 PROJ.4 在内的其他库。

Cartopy 引以为傲的是其“投影感知”功能。也就是说,它能够处理大量的投影(图 17-5),并在这些投影之间转换点、线、向量、多边形和图像。它还与 GeoPandas 配合得很好,使你能够在使用栅格数据时,比仅使用 GeoPandas 更轻松地创建符合地图学准确性的地图。如果你使用 Matplotlib 进行基本绘图,Cartopy 使你能够轻松将技能扩展到制图学领域。

Image

图 17-5:Cartopy 中可用的一些地图投影

与许多其他地理空间库一样,你可以仅用几行代码制作一个基础地图:

import cartopy.crs as ccrs
import matplotlib.pyplot as plt

ax = plt.axes(projection=ccrs.Robinson())
ax.coastlines()
plt.show()

Image

根据你的设置,Cartopy 可以快速绘制最多一百万个点,但随着数据集变大,性能会明显下降。你可以在其画廊页面查看更多 Cartopy 绘图示例 (scitools.org.uk/cartopy/docs/latest/gallery/index.html),以及支持的地图投影列表 (scitools.org.uk/cartopy/docs/v0.19/crs/projections.html)。

Geoplot

Geoplot 是一个相对较新的高级开源地理空间绘图库。作为 Cartopy 和 Matplotlib 的扩展,它自称为“地理空间领域的 seaborn”,这意味着它在底层库的基础上简化了绘图过程,使得地图制作变得更容易。

Geoplot 旨在与 GeoPandas 输入良好配合,提供了一系列易于使用的地理空间可视化工具(大概涵盖了你将需要的 90% 的功能)。由于 geoplot 是基于 Cartopy 构建的,它能够充分利用 Cartopy 广泛的地图投影列表。

geoplot 的一大特色是 卡托图,这是一种将多边形(如省份或州)在地图上呈现的主题图,其中地理大小根据选定的变量(如人口、国内生产总值或肥胖率)进行扭曲变换。在以下来自 geoplot 的绘图参考页面的示例中,你使用 geopandas 和 geoplot 的一个原生数据集 contiguous_usa,轻松生成了按州划分的美国人口卡托图:

import geopandas as gpd
import geoplot as gplt
import geoplot.crs as gcrs

contiguous_usa = gpd.read_file(gplt.datasets.get_path('contiguous_usa'))
gplt.cartogram(contiguous_usa, scale='population', 
               projection=gcrs.AlbersEqualArea(), 
               color='black');

Image

在这个卡托图中,加利福尼亚州是美国人口最多的州,按照其真实大小展示。其余州的大小根据各自人口的相对规模进行了缩小。

Geoplot 还允许你制作桑基图。这是一种流动图,其中线条和箭头的宽度与可视化的移动量成比例,例如城市街道上的交通流动(图 17-6)。最著名的桑基图描绘了拿破仑著名的俄罗斯战役及其从莫斯科撤退的过程。

Image

图 17-6:华盛顿特区街道的桑基图,按日均交通量排序(感谢 geoplot 提供)

像 GeoPandas 一样,geoplot 只制作静态地图。然而,通过一些额外的工作,比如将你的图形写入 HTML 并使用 mplleaflet 库,你可以实现交互功能,如缩放和平移。

如果你愿意放弃较多的设计控制,geoplot 让你可以轻松制作地图。要超越基本功能并制作高度自定义的地图,你需要熟悉 Matplotlib。尽管核心文档还不错,但由于 geoplot 尚处于不成熟阶段,你可能很难找到符合你特定需求的教程或示例。此外,geoplot 目前处于“维护”状态,没有计划推出新功能。

Plotly

在第十六章中介绍的 Plotly 和 Plotly Express,具有广泛的地理空间数据可视化功能。它们提供了许多制图选项,且 Plotly Express API 使用简便。你可以用一行代码制作动画的 choropleth 地图,并通过 Dash 部署到网页上。

Plotly 地图对于快速探索数据、识别异常值和发现趋势非常有用。你可以使用 GeoPandas 的便利,或者如果你的数据框中有经纬度列,直接从 pandas DataFrame 中绘制。以下的 Jupyter Notebook 示例,使用 Plotly Express,将一个关于全球火山的 Plotly 数据集转化为一个高度互动的图形,只需几行代码。

➊ import pandas as pd
   import plotly.express as px

   f = "https://raw.githubusercontent.com/plotly/datasets/master/volcano_db.csv"
   df = pd.read_csv(f, encoding="iso-8859-1")
➋ fig = px.scatter_geo(data_frame=df,
                        lat='Latitude',
                        lon='Longitude',
                        hover_name='Type',
                        hover_data={'Type':False,
                                    'Country':True, 
                                    'Volcano Name':True},
                        symbol='Type',
                        color='Type',
                        projection='orthographic')
   fig.show()

Image

大部分代码由导入库 ➊ 和加载数据构成,之后才是实际的绘图步骤 ➋。在这个图表中,你可以使用光标抓取并旋转地图,就像它是一个真实的三维地球仪一样。你可以将光标悬停在火山标记上,弹出一个窗口显示火山类型以及其他信息,如其位置、国家和名称。你还可以选择让标记可以点击,只有在故意按下鼠标按钮时,弹出窗口才会出现。

如果你查看这个图的右上角,你会看到一个工具栏,它可以让你截屏、平移、缩放等。这些工具非常有帮助,特别是当你需要解决密集的数据点时,例如冰岛的众多火山(见图 17-7)。

Image

图 17-7:Plotly Express 根据缩放级别重新发布数据,适应合适的比例。

你还可以使用 Plotly 制作 3D 表面图,其自动工具栏允许围绕多个轴进行旋转。下面是一个单个火山的示例:

import pandas as pd
import plotly.graph_objects as go

df = pd.read_csv(
"https://raw.githubusercontent.com/plotly/datasets/master/volcano.csv")
fig = go.Figure(data=[go.Surface(z=df.values)])
fig.update_layout(title='Volcano', 
                  autosize=False,
                  width=600, height=600,
                  margin=dict(l=65, r=50, b=65, t=90))
fig.show()

Image

像大多数其他地理空间库一样,Plotly 和 Plotly Express 支持基于瓦片的地图(见图 17-4),用于添加街道、地形、影像等内容。与 GeoPandas 不同,你可以直接访问这些功能,而无需使用像 Contextily 这样的额外库。

如果你想快速构建交互式图表,可以通过将光标悬停在某个区域来查询地图,或者将用户输入的小部件(如滑块)放置在与地图同一屏幕上,Plotly 和 Plotly Express 是不错的选择。通过 Plotly 的 Dash 库(请参阅 第 446 页中的“Dash”),你可以将你的工作无缝地转换为一个仪表盘。

folium

开源的 folium 库让你能够使用 Leaflet.JS 可视化地图,Leaflet.JS 是一个强大的 JavaScript 库,用于在大多数移动和桌面平台上构建交互式网页地图应用。Folium 首次发布于 2013 年,极受欢迎,因此你可以在网上找到大量的资料,帮助你学习如何使用它并根据自己的需求进行定制。

使用 folium,你可以从 OpenStreetMap、Mapbox 和 Stamen 等地图服务中选择多个 图块集。图块集是由光栅或矢量数据组成的集合,这些数据被划分成均匀的正方形网格图块,最多有 22 个预设的缩放级别。它们让你轻松制作出美观的 Leaflet 地图:

import folium

map = folium.Map(location=[29.7, -95.2147])
map

Image

这个 Jupyter Notebook 示例默认使用 OpenStreetMap 图块。地图中心的位置坐标是经纬度(这可能会让你感到困惑,因为许多库使用的是现代的经度-纬度顺序)。你可以使用 LatLong.net (www.latlong.net/geo-tools) 等工具查找地址的坐标,或者通过在线搜索地理特征的坐标来获取。也可以通过光标查询 folium 地图来获取这些信息。此地图还具有可缩放性;当你放大时,地图会显示越来越详细的信息,直到你耗尽可用的图块缩放级别。

folium 的另一个优点是它支持 标记。你可能见过这些泪滴形的图标,用于在 Google 地图上标识搜索位置。Folium 提供了几个预定义的标记,也允许你通过使用图片或访问免费图标库来创建自定义标记。你还可以在标记上添加弹出窗口。我们来看一个例子:

import folium

map = folium.Map(location=[37.15, -111.1], tiles='stamen terrain') 
folium.Marker(location=[37.1, -111.17],
              popup="Water Sample #2",
              icon=folium.Icon(color="black")).add_to(map)
map

Image

这段代码使用了“Stamen Terrain”图块,显示了犹他州鲍威尔湖周围的区域。标记表示水质样本的位置,点击标记会显示样本编号。

现在让我们回顾一下在 第 467 页中使用的火山数据集。如果你运行代码,可以从像 Free onlinewebfonts.com (onlinewebfonts.com/fonts) 或 Iconfinder (iconfinder.com/) 这样的网站下载火山图标。

import pandas as pd
import folium
from folium import plugins

f = "https://raw.githubusercontent.com/plotly/datasets/master/volcano_db.csv"
df = pd.read_csv(f, encoding="iso-8859-1")
map = folium.Map(tile='Stamen Terrain', control_scale=True)
for index, row in df.iterrows():
    volcano_icon = folium.features.CustomIcon('volcano_icon.png', 
                                              icon_size=(25, 25))
    folium.Marker(location=(row['Latitude'], row['Longitude']),
                  popup=row['Type'],
                  icon=volcano_icon, tooltip=(row['Type'], 
                           row['Country'], 
                           row['Volcano Name'])
                 ).add_to(map)
mini_map = folium.plugins.MiniMap(toggle_display=True)
map.add_child(mini_map)
map

该脚本生成了另一个世界火山位置地图。图 17-8 是该地图缩放至冰岛,类似于图 17-7。请注意自定义的火山图标、地形背景、悬停窗口、右下角的索引图以及左下角的比例尺。所有这些都只需几行代码。

Image

图 17-8:使用 folium 绘制的冰岛火山

由于 folium 嵌入了大量信息,文件大小可能会变得非常大。例如,前面的代码生成了一个 138MB 的笔记本文件。

将 folium 与流行的 GeoPandas 库结合使用是可视化地理参考数据的绝佳方法。假设您正在研究法国巴黎周围的城市“热岛”效应。您已记录下成千上万的数据点,记录了城市东部的温度,并且正在使用 GeoPandas 来处理这些数据。通过 folium 的热图,数据点将根据地图的缩放级别进行聚合或分离(图 17-9)。您还可以添加时间序列,使您能够查看全天、整个月份、全年等的温度变化。而且,借助 folium 的MarkerCluster插件,您可以将这种技术应用于单独的标记。只是不要尝试添加图例;folium 仅对分级图有图例支持。

Image

图 17-9:温度数据的热图,缩小(左)与放大(右)

folium 库旨在简化操作、提高性能和可用性。通过将 Python 库(如 GeoPandas)中的数据分析能力与 LeafletJS 的映射功能相结合,folium 使您能够生成具有多层数据表示的地图。包含有用的背景图像(例如街道地图和地形图)非常简单,而且有许多插件可用来扩展 folium 的功能(请参见python-visualization.github.io/folium/plugins.html#folium-plugins/)。

ipyleaflet

ipyleaflet开源互动小部件库基于 ipywidgets(github.com/jupyter-widgets/ipywidgets/)。与 folium 类似,ipyleaflet 封装了 Leaflet.JS,为 Jupyter Notebook 和 JupyterLab 带来映射功能。尽管 folium 被认为更易于使用,但 ipyleaflet 被认为更具可定制性,且提供了更多的交互性选择。

ipyleaflet 中的一切,例如瓦片地图和标记,都是互动式的,您可以从 Python 或笔记本界面动态更新属性。而且,因为 ipyleaflet 是建立在 ipywidgets 之上的,您可以编写程序,使用小部件捕捉用户输入。

假设您正在编制地面撞击陨石坑的统计数据。在这个示例中,您使用测量控制小部件和鼠标交互式地找出乍得共和国的阿罗昂加陨石坑的半径和面积:

from ipyleaflet import Map, MeasureControl, basemaps

m = Map(basemap=basemaps.OpenTopoMap,center=(19.0933, 19.2431), zoom=11)
measure = MeasureControl(position='bottomleft', 
                         active_color = 'black',
                         primary_length_unit = 'kilometers')
m.add_control(measure)
measure.completed_color = 'red'
m

Image

点击地图上的方形 (Image) 图标可以激活测量距离和面积工具。然后,你可以点击两个位置以获取它们之间的线性测量,或者绘制一个多边形来获得一个区域,正如前面的示例所示。你甚至可以自定义单位。

另一个有趣的控制选项是SplitMap,它让你可以在同一位置比较不同的图层集。假设你正在研究欧洲的夜景,并且你很好奇哪座城市造成了一个明亮的光斑。只需几行代码,你就可以生成一个双层显示来回答这个问题:

from ipyleaflet import Map, basemaps, basemap_to_tiles, SplitMapControl

m = Map(center=(42.6824, 365.581), zoom=5)
left_layer = basemap_to_tiles(basemaps.Esri.WorldStreetMap)
right_layer = basemap_to_tiles(basemaps.NASAGIBS.ViirsEarthAtNight2012)
control = SplitMapControl(left_layer=left_layer, right_layer=right_layer)
m.add_control(control)
m

Image

上述代码生成了一个“分割”地图,左侧显示城市和街道,右侧显示夜间卫星图。你可以抓住屏幕中央的圆形“|||”标记并拖动它到每一侧,以牺牲另一侧的地图来扩展一侧的显示(图 17-10)。这让你可以在夜间地图下窥视城市和道路,而无需通过将地图合并或调整上方地图的透明度来增加一个地图的混乱。你还可以缩放查看更小的城市。

Image

图 17-10:SplitMap 边界被拖动到右侧

放大镜 是一个特别有趣的功能,允许你在不改变地图整体缩放级别的情况下查看详细信息。当它被激活时,你只需将光标移到地图上的一个圆圈内,即可查看该圆圈内的放大视图(图 17-11)。它适用于任何缩放级别,并且与所有可用的底图兼容。

Image

图 17-11:ipyleaflet 中的放大镜选项

许多此类功能,包括标记聚类等,也可以在 folium 中使用,尽管你可能需要使用一个插件(python-visualization.github.io/folium/plugins.html#folium-plugins/来复制在 ipyleaflet 中可以做到的功能。然而,这些功能重叠并不包括将用户交互(如选择)返回到 Python 中进行进一步处理的方式,因为 folium 仅提供从 Python 到 JavaScript 地图的单向路径。

注意

与 ipyleaflet 类似,Jupyter-gmaps (github.com/pbugnion/gmaps/) 也是建立在 Jupyter 交互式小部件框架之上,但它连接的是 Jupyter 和 Google Maps,而非 Leaflet.JS。

GeoViews: HoloViz 方法

HoloViz 维护的库,如 第十六章(见 图 16-9)中讨论的,提供了一个统一的解决方案,用于处理地理空间数据。这包括仪表板和其他类型的交互式可视化。在这系列开源库中,HoloViews 为地理空间数据提供了大量支持,包括执行基础地球科学工作的能力。

对于更高级的工作,尤其是涉及地图投影的工作,HoloViz 包括一个专门的地理空间库,称为 GeoViews。GeoViews 基于 HoloViews,并且基于 Cartopy 库的地理绘图类型,可以使用 Matplotlib 或 Bokeh 作为绘图后端。

GeoViews 使你能够处理大型、多维的地理数据集,快速可视化子集或组合,并可以访问底层的原始数据。它设计为与 Iris 和 xarray 库一起使用,并且可以接受多种数据格式,包括 NumPy 数组、pandas DataFrame 和 GeoPandas GeoDataFrame。在这些情况下,数据被封装在 HoloViews 或 GeoViews 对象中,提供即时交互式可视化(见第 436 页的 “HoloViews”)。地理投影使用广泛的 Cartopy 坐标参考系统。

与其他地理空间库类似,GeoViews 让你能够访问各种有用的数据库、多边形集合(如国家边界)以及街道和地形的瓦片地图。只需几行代码,就能生成图表,如官方网页上的 Jupyter Notebook 示例所示:

import geoviews as gv
import geoviews.feature as gf
from cartopy import crs

gv.extension('bokeh')
(gf.ocean + gf.land + gf.ocean * gf.land * gf.coastline * gf.borders).opts(
'Feature', projection=crs.Geostationary(), global_extent=True, height=325).cols(3)

Image

GeoViews 支持 GeoPandas 数据结构,使得绘制 shapefile 和分级图变得轻松。以下是使用 GeoPandas 数据集绘制人口分级图的示例:

import geopandas as gpd
import geoviews as gv
from cartopy import crs

gv.extension('bokeh')
gv.Polygons(gpd.read_file(gpd.datasets.get_path('naturalearth_lowres')),
            vdims=['pop_est', ('name', 'Country')]).opts(width=600,
            projection=crs.Robinson())

Image

最后,这是带有新变化的火山示例。因为 GeoViews 是 HoloViz 的一部分,你可以选择使用 hvPlot 绘制图表,我个人觉得它更容易使用(就像 Plotly Express 相较于 Plotly)。

   import pandas as pd
   import holoviews as hv
   import hvplot.pandas

   f = "https://raw.githubusercontent.com/plotly/datasets/master/volcano_db.csv"
   df = pd.read_csv(f, encoding="iso-8859-1")

   # Reassign the dataframe with only 3 volcano types:
➊ df = df[(df['Type'] == 'Cone') | 
           (df['Type'] == 'Stratovolcano') | 
           (df['Type'] == 'Shield volcano')] 
➋ marker = hv.dim('Type').categorize({'Cone': 'triangle', 
                                       'Shield volcano': 'circle', 
                                       'Stratovolcano': 'square'}) 
   size = hv.dim('Type').categorize({'Cone': 6,
                                     'Shield volcano': 5,
                                     'Stratovolcano': 4})
   df.hvplot.points('Longitude', 'Latitude', 
                    color='Type',
                    marker=marker,
                    size=size,
                    hover_cols=['Volcano Name'],
                    coastline=True)

Image

在这种情况下,除了盾形火山、层状火山和锥形火山之外,所有其他类型的火山都从 DataFrame ➊ 中被移除。然后,地图被定制化,以独特的形状 ➋、大小和颜色绘制这些火山类型。尽管这里没有展示,你也可以选择分配默认的形状和大小。

注意右侧工具栏,包含平移、缩放、保存等图标,以及可自定义的悬停窗口。不幸的是,没有像 Plotly Express 那样能够在正射投影中旋转地球的工具,因为 hvPlot 仅使用 Bokeh,而不是 Plotly 作为绘图后端。

GeoViews 的一个主要卖点是,它是一个全面、前瞻性的解决方案的一部分,旨在满足你所有绘图和制图的需求。另一方面,与其他库相比,其文档支持相对有限。

KeplerGL

KeplerGL JupyterLab 扩展 是一个先进的开源地理空间库,建立在 Mapbox GL (www.mapbox.com/) 和 deck.gl (deck.gl/)之上。后者是一个 WebGL(GPU)驱动的框架,用于使用分层方法可视化大规模数据集。它拥有大量层类型,支持位图、图标、点云、网格、等高线、地形等多种数据展示(详见 deck.gl/docs/api-reference/layers/)。

Uber 开发了 KeplerGL (kepler.gl/) 作为一个基于 web 的工具,旨在让不同经验和技能水平的用户更容易创建有意义的数据可视化。它专为处理大型地理空间数据集设计,特别是与流动性相关的数据集。它包括令人印象深刻的功能,包括一个图形用户界面(图 17-12),你可以通过拖放数据集、使用内置的时间序列动画、进行 3D 可视化、处理数百万个数据点、动态执行空间聚合、以及通过调整颜色、大小、过滤等方式定制地图。

Image

图 17-12:KeplerGL 界面用于在 JupyterLab 中定制地图(图片来自 KeplerGL)。

在 Jupyter 中运行 KeplerGL GUI 时,你可以完全避免使用 Python。你可以将数据文件拖放到浏览器中,用不同的地图图层进行可视化,进行过滤和聚合探索,最后将最终的可视化结果导出为静态地图或动画视频。该网站会引导你完成制图工作流程 (docs.kepler.gl/docs/user-guides/b-kepler-gl-workflow/),并展示如何使用 GUI 的友好菜单(图 17-13)。

Image

图 17-13:KeplerGL 界面菜单,用于选择地图图层类型(图片来自 KeplerGL)

KeplerGL 提供了一组 Mapbox 背景地图,包括用于显示陆地、水域、道路、建筑物轮廓、3D 建筑物和标签的地图。你需要在 Mapbox 注册,免费计划每月提供 50,000 次地图下载,足以应付大多数小型应用程序。你还被限制使用 CSV、GeoJSON、pandas DataFrame 或 GeoPandas GeoDataFrame 格式的数据,这意味着无法进行实时数据流处理。

设置和使用 KeplerGL 比其他地理空间库稍微复杂一些。它可以在 JupyterLab 中使用,并且(目前)必须通过 Python 的标准包管理器(pip)进行安装,而不是通过 conda 或 conda-forge 安装。

pydeck

pydeck 图形库是一组针对 Jupyter Notebook 环境优化的 Python 绑定,用于使用 deck.gl 创建空间可视化。如前所述,deck.gl 是一个 WebGL 驱动的框架,用于使用分层方法可视化大规模数据集。

pydeck 库为你提供了在 Python 中访问完整的 deck.gl 图层目录的权限。你可以创建美丽的 deck.gl 地图(图 17-14),无需编写大量 JavaScript,还可以将这些地图嵌入到 Jupyter Notebook 中,或者导出为独立的 HTML 文件。该库的设计目的是与流行的 JavaScript 基础地图提供商(尤其是 Mapbox)协同工作,但其他地图瓦片解决方案,如 OpenStreetMap,可能具有不同的兼容性级别。

Image

图 17-14:英国的个人伤害道路事故 (pydeck.gl/gallery/hexagon_layer.html)

Pydeck 支持大规模更新,例如在 2D 和 3D 中对成千上万的数据点进行颜色更改或数据修改。像 ipyleaflet 一样,它支持双向通信,即可以将可视化中选择的数据传回 Jupyter Notebook 内核。例如,你可以将从政府来源加载到地图上的几何数据传回 pandas DataFrame。

让我们再次访问我们的火山数据库。以下代码片段输入到 Jupyter Notebook 中,会将数据加载为 pandas DataFrame,并生成一个聚焦在非洲之角的全球地图:

   import pandas as pd
   import pydeck as pdk

   f = "https://raw.githubusercontent.com/plotly/datasets/master/volcano_db.csv"
   df = pd.read_csv(f, encoding="iso-8859-1")
➊ layer = pdk.Layer('ScatterplotLayer', 
                     df, 
                     get_position=['Longitude', 'Latitude'], 
                     auto_highlight=True,
                     get_radius=10_000, 
                     radius_min_pixels=1,
                     radius_max_pixels=10_000,  
                     get_fill_color='[255, 255, 255]',
                     pickable=True)
➋ view_state = pdk.ViewState(longitude=42.59, latitude=11.82, 
                              zoom=5, min_zoom=1, max_zoom=8, 
                              pitch=0, bearing=0)
   r = pdk.Deck(layers=[layer], initial_view_state=view_state)
➌ r.to_html("scatterplot_layer.html")

Image

导入库并将 CSV 文件读取为 DataFrame 后,你调用 pydeck 的 Layer 方法并选择 ScatterplotLayer ➊。在此过程中,你还将点的半径设置为 10 公里,颜色设置为白色,并使其“可选”,这样你就可以将光标悬停在每个点上,查看与 DataFrame 中数据相关的内容(如“达马·阿里”火山地图所示)。

接下来,你需要设置 view_state,它告诉 pydeck 地图的中心位置、缩放比例,以及 pitchbearing ➋。后两个参数允许你生成一个倾斜的视角,就像 图 17-14 中的那样。最后,你告诉 pydeck 如何渲染地图并将其保存为 HTML 文件 ➌。

如果你玩这个示例几分钟,某些问题会变得显而易见。要为每种类型的火山分配一个独特的颜色,你需要使用以下代码在 DataFrame 中创建一个新列:

color_lookup = pdk.data_utils.assign_random_colors(df['Type'])
df['color'] = df.apply(lambda row: color_lookup.get(row['Type']), axis=1)

同样,如果你需要一个图例,你需要使用像 Matplotlib 这样的外部库来创建一个(搜索 matplotlib.pyplot.colorbar),然后将其渲染到你的 pydeck 可视化旁边。与 Plotly Express 和 hvPlot 示例相比,这两个任务要么非常直观,要么完全自动化。

这些问题部分是由于 pydeck 的不成熟,可能在你读到这篇文章时已经得到解决。然而,目前的结论是,pydeck 最适合用于处理大数据集的数据分析场景——而这正是它的强项所在。

使用 pydeck,您可以使用 Python 访问Google Earth Engine(* earthengine.google.com/ *),这是一个用于处理卫星图像和其他地球观测数据的云计算平台。Earth Engine 托管了一个多拍字节的地理空间数据集和卫星图像目录,包括超过 40 年的历史地球图像。它每天摄取图像,存储在公共数据档案中,并将其免费提供给学术、非营利、商业和政府用户进行全球规模的数据挖掘。

除了提供访问大量地理空间数据的仓库,Earth Engine 还提供了分析大数据集所需的计算能力、API 和其他工具。根据网站介绍,这些工具提供了行星级的分析能力,使科学家、研究人员和开发人员能够检测变化、绘制趋势并量化地球表面差异。

pydeck-earthengine-layer包装器(* github.com/UnfoldedInc/earthengine-layers/tree/master/py/ )通过使用 deck.gl 层将 pydeck 与 Google Earth Engine 连接,支持 Earth Engine API( earthengine-layers.com/ )。这使得通过 Python 可视化庞大的地理空间数据集成为可能。该 pydeck 包装器于 2020 年发布,可以通过 conda-forge 轻松安装。使用它时,您需要使用启用 Earth Engine 的 Google 帐户进行身份验证(您可以在 earthengine.google.com/new_signup/ *注册)。

而 Earth Engine 可视化通常是基于栅格的,pydeck 则赋予您混合栅格和矢量图形的能力,开辟了新的可视化机会。您可以添加交互性功能,例如基于悬停的工具提示,还可以将 Earth Engine 数据解释为地形高度,以在 3D 中显示它们。您甚至可以使用 Earth Engine 平台(* earthengine.google.com/platform/ *)上传和操作您自己的数据集。

为了帮助您入门,Earth Engine 提供了许多预打包的数据集(* developers.google.com/earth-engine/datasets/ )和示例案例研究( earthengine.google.com/case_studies/ *)。通过 pydeck 和 Earth Engine,您可以监控降水和洪水、植被变化、森林火灾和森林砍伐、城市扩展等,而无需将成千上万的卫星图像下载到您的计算机中。

如果你预计会经常处理“行星级”数据集,pydeck 是一个很好的解决方案。它也比 KeplerGL 更容易安装,因为你可以使用 conda-forge。尽管它无法与 Plotly Express 或 hvPlot 在较小数据集上的快速简单绘图相竞争,但随着产品的成熟,这一差距应该会逐渐缩小。

Bokeh

Bokeh,已在第十六章介绍,是 Python 主要的绘图库之一。与 Matplotlib 和 Plotly 库类似,它也具有自己的地理空间能力 (docs.bokeh.org/en/latest/docs/user_guide/geo.html).

Bokeh 可以接收来自多个来源的地理空间数据,包括 GeoPandas 和 GeoJSON。它还可以使用 Web Mercator 投影的 XYZ 瓦片服务。通过 gmap() 方法,你可以在 Google 地图上绘制符号,但你必须提供一个 Google API 密钥,任何使用 Bokeh 与 Google 地图的操作都必须符合 Google 的服务条款。

尽管 Bokeh 允许你重现其他库中可用的地理空间功能,例如人口图、热图、地图瓦片等,但你可能会发现这个过程比较困难。一个常见的用户抱怨是文档和学习资源有限。初学者也可能会在“中级”API 上遇到困难,这个 API 并不算难,但也不算容易。使用像 hvPlot 这样高层 API 可以在一定程度上缓解这个问题,hvPlot 使用 Bokeh 作为其绘图后端。

选择 GeoVis 库

到这时,你可能已经得出结论,选择任何一种 Python 可视化库就像是在买新车。你永远无法在一个地方得到所有你想要的功能,而每一个非常有用的功能背后总有一个相应的限制,迫使你做出妥协。

然而,仍然有希望。得益于像 Contextily、IPYMPL、hvPlot 等“桥接”库,地理空间绘图库之间的界限正变得越来越模糊。此外,大多数库都可以与 GeoPandas 配合使用,GeoPandas 是 Python 用于解析地理空间数据的主要工具,像 Datashader 这样的库也可以帮助绘制大型数据集。

然而,仍然存在一些重要的差异,可以帮助你决定使用哪个库或哪些库。正如上一章所述,成熟度可以作为区分绘图库的重要因素。图 17-15 展示了各种 GeoVis 库到 2022 年的年龄。这个图与图 16-26 使用相同的比例尺,如果你比较这两个图,你会发现即便是最老的 GeoVis 库,其年龄也不到最老的 InfoVis 和 SciVis 库的一半。

Image

图 17-15:GeoVis 库的相对年龄

尽管如此,成熟且广泛使用的库(如 GeoPandas、folium 和 Plotly)有大量讨论材料,你可以找到如何使用它们的丰富资料。它们经过了更多的实战考验,你不太可能是第一个遇到令人沮丧的 bug 或无法解决的限制问题的人。与此同时,一些较年轻的库也有“老骨头”。例如,geoplot 是基于 Cartopy 构建的,GeoViews 则基于 HoloViews 和 Cartopy 构建,后者的用户数量是 GeoViews 本身的 10 倍。一个库是否成熟且广泛使用,在某种程度上取决于它所构建的基础库。

为了进一步区分各个库,让我们关注它们的优势。本书假设大多数科学家希望尽可能抽象化编程,只学习一个 API。为此,图 17-16 中的阴影区域标示了库的开箱即用的显著特点,这些特点基于开发者的声明、在线教程与评论以及我个人的经验等多种因素的综合评估。阴影越深,代表该特点越突出,相关的评估因素已注明。没有阴影并不一定意味着某个库缺乏该特性,而是说明:1)它相较于竞争库中的特性较为次要,或 2)实现该特性需要额外使用其他库。

图片

图 17-16:重要 Python 地理空间库的优势(带有评估标注的阴影区域)

举个例子,Cartopy 的一个主要卖点是其强大的投影系统,它不仅能够提供高度准确的地图绘制,还能执行复杂的参考系统数据转换。这并不意味着其他库会把纽约市绘制在大西洋中央,它只是意味着在处理投影时,其他库不如 Cartopy 及基于 Cartopy 的库。因此,如果这个功能对你非常重要,Cartopy、geoplot 和 GeoViews 应该是你关注的对象。

folium 和 ipyleaflet 库提供了大量易于访问的地图图块。GeoPandas 通过 Contextily 库提供对这些图块的访问。虽然这不是一个很高的门槛,但它确实违背了科学优先,编程其次的原则。

如果你预计进行大量遥感工作,pydeck 库提供了一个轻松连接到 Google Earth Engine 的方式,Google Earth Engine 拥有 PB 级的卫星影像数据。

在易用性方面,Plotly Express 和 folium 无可比拟。它们代表了绘图的“甜蜜点”,做得很多事情都很好,只要你不使用庞大的数据集。要理解这一点,可以尝试使用其他库和相同的代码量,重现图 17-7 中的 Plotly Express 地图,如第 467 页所示。

如果你已经是 seaborn 和 Matplotlib 用户,你应该会发现 GeoPandas、Cartopy 和 geoplot 有一定的直观性。GeoViews 的文档较为有限,但你可以使用同为 HoloViz 系列的一部分的 hvPlot 作为一种易于使用的“Plotly Express-like”绘图选项(参见第 476 页中的“GeoViews:HoloViz 方法”)。

GeoViews 在某种程度上似乎满足了所有需求。它是一个全功能的库,从头到尾应有尽有,作为统一的 HoloViz 系列的一部分,可能为你未来的工作提供良好的支持。GeoViews 首次发布于 2016 年,已有时间积累其受欢迎程度,并希望能提供更完善的文档支持。

在数据大小方面,大多数库在绘制数十万点时没有问题,但许多库在处理更大数据集时开始变得吃力。这在一定程度上可以通过 Datashader 缓解。虽然 Datashader 本身不是一个地理空间库,但它是处理大型地理空间数据集的科学家的必备库。它将可视化过程分解为多个步骤并行运行,从而快速为大数据集创建显示。同样,pydeck 帮助你管理像 Google Earth Engine 这样的网站提供的大型数据集。

最后,尽管 GeoPandas 没有满足许多需求,但这并不意味着你不会使用它。它仍然是处理地理空间数据的最流行方法。只是有更好的方式来绘制和探索结果。

总结

地理空间数据包括带有地理位置参考的矢量数据和/或栅格数据。在本章中,我们回顾了用于绘制此类数据的主要 Python 库。

最受欢迎的开源 Python 库用于解析地理空间数据的是 GeoPandas,它还内置了基于 Matplotlib 的绘图功能。由于许多其他包与 GeoPandas 配合使用,你可能会发现自己在准备数据时使用这个库,而使用其他工具绘制结果。

正如在第十六章讨论的 InfoVis 库一样,选择一个地理空间绘图库在很大程度上取决于你需要绘制的内容——无论是现在还是将来——以及你愿意投入多少精力。为了帮助你做出选择,图 17-16 提供了主要地理空间库的即用型区分特性总结。需要记住的是,始终有可能通过“桥接”库将缺失的功能填补起来,从而将一组自定义的包组合起来。

第十八章:NUMPY: 数值计算 Python**

image

NumPyNumerical Python 的缩写,是 Python 数值计算的基础库。它扩展了 Python 的数学功能,成为许多科学和数学包的基础。因此,你需要了解 NumPy,才能有效使用 Python 的科学库,如 Matplotlib(用于绘图)和 pandas(用于数据分析)。

NumPy 是开源的,并且与 Anaconda 一起预安装。它增强了 Python 标准库中内置工具的功能,这些工具对于许多数据分析计算来说可能过于简单。使用 NumPy,你可以执行快速操作,包括数学、逻辑、形状操作、排序、选择、I/O、离散傅里叶变换、基本线性代数、基本统计运算、随机模拟等。

NumPy 的核心是 array 数据结构,基本上是一个值的网格。通过使用预编译的 C 代码、多维数组和操作数组的函数,NumPy 加速了较慢算法的运行,并以高效的方式执行高级数学计算。NumPy 还使得处理具有数百万到数十亿样本的大型均匀数据集变得更加容易。

如果你不理解数组,就无法理解 NumPy,因此在本章中,我们将首先关注这些特性,然后再查看库的一些基本功能。有关进一步学习,请访问官方站点 (numpy.org/),该站点包含“快速入门”和更详细的教程和指南。

介绍数组

在计算机科学中,数组是一种数据结构,包含一组具有相同大小和数据类型(在 NumPy 中称为dytpes)的元素(值或变量)。数组可以通过非负整数的元组、布尔值、另一个数组或整数进行索引。

这是一个二维整数数组的示例,由两行三列组成的网格。由于数组使用方括号,因此它们看起来与 Python 列表非常相似:

array([[0, 1, 2],
       [3, 4, 5]])

要从这个数组中选择一个元素,可以使用标准的索引和切片技术。例如,要选择元素 2,你需要先索引行,然后是列,使用 [0][2](记住:Python 从 0 开始计数,而不是从 1)。

你可能有几个理由想要使用数组。通过索引访问单个元素非常高效,使得无论数组大小如何,运行时间都是常数。实际上,数组让你能够对整块数据执行复杂的计算,而无需逐个循环并访问每个元素。因此,基于 NumPy 的算法运行速度比原生 Python 中的算法快几个数量级。

除了更快之外,数组还将数据存储在连续的内存块中,与 Python 内置的非连续块列表等序列相比,内存占用显著较小。例如,列表基本上是指向(可能是)非连续块中存储的异构 Python 对象的指针数组,使其比 NumPy 数组非常不紧凑。因此,数组通常是存储数据的首选数据结构,可靠且高效。例如,流行的 OpenCV 计算机视觉库将数字图像操作和存储为 NumPy 数组。

使用维度和形状描述数组

理解数组需要了解它们的布局。数组中的维度是从数组中选择元素所需的索引数量。你可以将维度看作是数组的

数组中的维度数量,也称为其,可用于描述数组。图 18-1 是一维、二维和三维数组的图形示例。

图像

图 18-1:一维、二维和三维数组的图形表示

数组的形状是一个整数元组,表示沿每个维度的数组大小,从第一个维度(轴 0)开始。示例形状元组如下所示,每个数组下方显示在图 18-1 中。

一维数组,也称为向量,具有单个轴。这是数组的最简单形式,是 Python 的list数据类型的 NumPy 等效形式。以下是图 18-1 中 1D 数组在 Python 中的示例外观:

array([5, 4, 9])

具有多个维度的数组基本上是数组内的数组。具有行和列的数组称为 2D 数组。图 18-1 中的 2D 数组具有形状元组(2, 3),因为其第一个轴(0)的长度为 2,第二个轴(1)的长度为 3。

2D 数组用于表示矩阵。您可能还记得数学课上的矩阵,这些是元素(例如数字或代数表达式)的矩形网格,排列在行和列中,并用方括号括起来。矩阵以一种优雅而紧凑的方式存储数据,尽管包含许多元素,但每个矩阵被视为一个单位。

以下是 Python 中图 18-1 中 2D 数组的示例:

array([[4.1, 2.0, 6.7],
       [0.3, 9.4, 2.2]])

具有三个或更多维度的数组称为张量。如前所述,数组可以具有任意数量的维度。以下是图 18-1 中 3D 数组的示例:

array([[[1, 0, 1, 1],
        [0, 1, 1, 1],
        [1, 1, 0, 1]],

       [[0, 0, 0, 0],
        [0, 0, 0, 0],
        [1, 1, 0, 1]]])

张量在二维显示中可能难以可视化,但 Python 会尽量帮助你。注意空行如何将两个堆叠的矩阵分开,这些矩阵组成了 3D 网格。你还可以通过计算输出中方括号的数量来确定数组的秩。连续三个方括号意味着你正在处理一个 3D 数组。

创建数组

NumPy 通过其ndarray类来处理数组,也称为array别名。ndarray这个名称是N 维的缩写,因为这个类可以处理任意数量的维度。NumPy 的ndarray在创建时具有固定大小,无法像 Python 列表或元组那样增长。更改ndarray的大小会创建一个新数组并删除原始数组。

注意

你应该知道 numpy.array 不同于 Python 标准库中的 array.array。后者只是一个一维数组,并且与 NumPy 数组相比功能有限。

NumPy 提供了多个内置函数来创建ndarray。这些函数让你能够直接创建数组,或将现有的序列数据类型(如元组和列表)转换为数组。表 18-1 列出了其中一些常见的创建函数。我们将在接下来的章节中详细介绍其中一些。你可以在numpy.org/doc/stable/reference/routines.array-creation.html找到完整的创建函数列表。

表 18-1: 数组创建函数

函数 描述
array 通过推断或指定dtype将输入序列转换(复制)为ndarray
asarray 类似于array,但选项较少,默认情况下不创建副本
arange 类似于内置的range()函数,但返回的是ndarray而不是列表
linspace 在指定区间内返回均匀间隔的数字
ones 生成一个所有值为 1 的ndarray,具有给定的形状和dtype
ones_like 生成与输入数组形状和dtype相同的全 1ndarray
zeros 生成一个所有值为 0 的ndarray,具有给定的形状和dtype
zeros_like 生成与输入数组形状和dtype相同的zeros ndarray
empty 为一个给定形状的新未填充的ndarray分配内存
empty_like 基于输入数组为一个新的未填充的ndarray分配内存
full 生成一个具有给定形状和dtypendarray,所有值都设置为填充值
full_like 采用输入数组,并生成一个与输入数组形状和dtype相同的填充数组
eye 返回一个二维方阵,对角线为 1,其它位置为 0
identity 类似于eye,但没有指定对角线索引的选项

因为数组必须包含相同类型的数据,所以数组需要知道传递给它的 dtype 类型。你可以选择让函数推断出最合适的 dtype(尽管你会想检查一下结果),或者显式地提供 dtype 作为额外的参数。

一些常用的 dtype 类型列在 表 18-2 中。代码列列出了你可以传递给函数的简写参数,例如 dtype= 'i8 ',以替代 dtype= 'int64 '。要查看完整的支持数据类型列表,请访问 numpy.org/doc/stable/user/basics.types.html

表 18-2: 常见的 NumPy 数据类型

类型 代码 描述
bool ? 布尔类型(True 和 False)。
object O 任意 Python 对象类型。
string_ Sn 固定长度的 ASCII 字符串类型,每个字符占用 1 字节。n 参数表示最长字符串的长度,例如 'S15'
unicode_ Un 固定长度的 Unicode 类型,字节数与平台相关。n 参数表示最长的长度,例如 'U12'
int8, uint8 i1, u1 有符号和无符号 8 位(1 字节)整数类型。
int16, uint16 i2, u2 有符号和无符号 16 位整数类型。
int32, uint32 i4, u4 有符号和无符号 32 位整数类型。
int64, uint64 i8, u8 有符号和无符号 64 位整数类型。
float32 f4f 单精度浮动点类型。
float64 f8d 双精度浮动点类型,与 Python 的浮点数兼容。
float128 f16g 扩展精度浮动点类型。
complex64 c8 由两个 32 位浮点数组成的复数类型。
complex128 c16 由两个 64 位浮点数组成的复数类型。
complex256 c32 由两个 128 位浮点数组成的复数类型。

对于字符串和 Unicode 数据类型,dtype 参数必须包括最长字符串或 Unicode 对象的长度。例如,如果数据集中最长的字符串有 12 个字符,指定的 dtype 应为 'S12'。这是必要的,因为所有的 ndarray 元素应该具有相同的大小。无法创建变长字符串,因此必须确保分配足够的内存来容纳数据集中每个可能的字符串。在使用 现有输入 时,例如将字符串列表转换为数组时,NumPy 可以为你进行这个计算。

因为 dtype 占用的内存量是自动分配的(或者可以手动输入),所以 NumPy 知道在创建 ndarray 时应该分配多少内存。表 18-2 中的选项让你对数据在内存中的存储方式有充分的控制,但不要因此感到害怕。大多数情况下,你只需要知道你使用的数据的基本类型,例如浮点数或整数。

NUMPY 如何分配内存

NumPy 的聪明之处在于它如何分配内存。下图显示了一个 3x4 的二维数组,其中包含从 0 到 11 的数字,这些信息通过图底部的“Python 视图”示意图表示。你已经熟悉了像 dtype、维度和数据这样的参数,接下来我们重点关注内存分配和步幅。

Image

ndarray 的值作为计算机 RAM 中一个连续的内存块存储,如图中的内存块示意图所示。这种方式非常高效,因为处理器更喜欢将内存中的数据按块存储,而不是随机散布。如果你将数据存储在像列表这样的 Python 数据类型中,内存中会保存到对象的指针,这会产生“开销”,从而降低处理速度。

为了帮助 NumPy 解释内存中的字节,dtype 对象存储了关于数组布局的附加信息,例如数据的大小(以字节为单位)和数据的字节顺序。因为我们在示例中使用了 int32 类型的 dtype,所以每个数字占用 4 字节的内存(32 位/每字节 8 位)。

Ndarray 拥有一个属性,strides,它是一个元组,表示在遍历数组时,在每个维度上步进所需的字节数。这个元组告诉 NumPy 如何将连续的内存块转换为图中所示的 Python 视图数组。

在图中,内存块由 48 字节组成(12 个整数,每个占 4 字节),一个接一个地存储。数组步幅指示了在内存中跳过多少字节才能移动到某一轴上的下一个位置。例如,我们需要跳过 4 字节(1 个整数)才能到达下一列,但要跳过 16 字节(4 个整数)才能到达下一行的同一位置。因此,数组的步幅为 (16, 4)。

使用 array() 函数

创建数组的最简单方法是将一个序列(例如列表)传递给 NumPy 的 array() 函数,之后它会被转换为 ndarray。我们现在来做一个例子,创建一个一维数组。首先,我们通过别名 np 导入 NumPy(这是约定俗成的做法,可以减少调用 NumPy 函数时的输入量):

In [1]: import numpy as np

In [2]: arr1d = np.array([1, 2, 3, 4]) 

In [3]: type(arr1d)
Out[3]: numpy.ndarray

In [4]: print(arr1d)
[1 2 3 4]

你也可以通过传递一个变量给 array() 函数来创建 ndarray,比如这样:

In [5]: my_sequence = [1, 2, 3, 4]

In [6]: arr1d = np.array(my_sequence)

In [7]: arr1d
Out[7]: array([1, 2, 3, 4])

要创建多维数组,可以将 array() 函数传递一个嵌套序列,其中每个嵌套序列的长度相同。下面是一个例子,使用一个包含三个嵌套列表的列表来创建一个二维数组:

In [8]: arr2d = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])

In [9]: print(arr2d)
[[0 1 2]
 [3 4 5]
 [6 7 8]]

每个嵌套列表变成了二维数组中的一行。要从元组构建相同的数组,你需要将 In [8] 行中的所有方括号 [] 替换为圆括号 ()

注意

当你打印一个数组时,NumPy 会按照以下布局显示它:最后一个轴从左到右打印,倒数第二个从上到下打印,其他轴也从上到下打印,每个切片之间用空行分隔。所以,1D 数组打印为行,2D 数组为矩阵,3D 数组为矩阵列表。

现在,让我们查看一些二维数组的属性,比如它的形状:

In [10]: arr2d.shape
Out[10]: (3, 3)

它的维度数量

In [11]: arr2d.ndim
Out[11]: 2

以及它的步长:

In [12]: arr2d.strides
Out[12]: (12, 4)

虽然数组中的项必须是相同的数据类型,但这并不意味着你不能将这些项作为混合序列类型(如元组和列表)传递给 array() 函数:

In [13]: mixed_input = np.array([[0, 1, 2], (3, 4, 5), [6, 7, 8]])

In [14]: mixed_input
Out[14]: 
array([[0, 1, 2],
       [3, 4, 5],
       [6, 7, 8]])

之所以有效,是因为 NumPy 读取的是序列中元素的数据类型,而不是序列本身的数据类型。

然而,如果你尝试传递不同长度的嵌套列表,你可能不会这么幸运:

In [15]: arr2d = np.array([[0, 1, 2], [3, 4, 5], [6, 7]])

C:\Users\hanna\AppData\Local\Temp/ipykernel_19556/570173853.py:1:
VisibleDeprecationWarning: Creating an ndarray from ragged nested sequences
(which is a list-or-tuple of lists-or-tuples-or ndarrays with different
lengths or shapes) is deprecated. If you meant to do this, you must specify
'dtype=object' when creating the ndarray.
    arr2d = np.array([[0, 1, 2], [3, 4, 5], [6, 7]])

你可以通过将 dtype 更改为 object 来避免此警告,如下所示:

In [16]: arr2d = np.array([[0, 1, 2], [3, 4, 5], [6, 7]], dtype='object')

In [17]: print(arr2d)
[list([0, 1, 2]) list([3, 4, 5]) list([6, 7])]

注意,你现在得到的是一个包含列表对象的一维数组,而不是你想要的二维整数数组。就像数学矩阵一样,数组如果要用于数学计算,它们需要具有 相同的行和列数(虽然在这方面有一定的灵活性,但我们会把它留到“广播”一节讲解)。

现在,让我们看看具有多于两个维度的数组。array() 函数将序列的序列转换为二维数组;序列的序列的序列转换为三维数组;依此类推。所以,要创建一个 3D 数组,你需要传递多个嵌套的序列。下面是一个使用嵌套列表的示例:

In [18]: arr3d = np.array([[[0, 0, 0],
    ...:                   [1, 1, 1]],
    ...:                  [[2, 2, 2],
    ...:                   [3, 3, 3]]])

In [19]: arr3d
Out[19]: 
array([[[0, 0, 0],
        [1, 1, 1]],

       [[2, 2, 2],
        [3, 3, 3]]])

在这个示例中,我们传递了一个包含两个嵌套列表的列表,每个嵌套列表中又包含两个嵌套列表。注意输出的数组中间有一个空行,这在视觉上分隔了函数创建的两个堆叠的二维数组。

在创建高维数组时,跟踪所有的括号可能既繁琐又对视力有害。幸运的是,NumPy 提供了其他创建数组的方法,这些方法比 array() 函数更方便。我们将在接下来的部分中探讨这些方法。

使用 arange() 函数

为了创建包含数字序列的数组,NumPy 提供了 arange() 函数,它的功能类似于 Python 内置的 range() 函数,只不过它返回的是数组,而不是不可变的数字序列。

arange() 函数接受与 range() 相似的参数。这里,我们创建一个从 0 到 9 的一维数组:

In [20]: arr1d = np.arange(10)

In [21]: arr1d
Out[21]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

我们还可以添加起始、结束和步长参数来创建一个包含 0 到 10 之间偶数的数组:

In [22]: arr1d_step = np.arange(0, 10, 2)

In [23]: arr1d_step
Out[23]: array([0, 2, 4, 6, 8])

接下来,我们从 5 开始序列,并在 9 停止:

In [24]: arr1d_start_5 = np.arange(5, 10)

In [25]: arr1d_start_5
Out[25]: array([5, 6, 7, 8, 9])

range() 总是产生整数序列,arange() 则允许你指定数组中数字的数据类型。这里,我们使用双精度浮点数:

In [26]: arr1d_float = np.arange(10, dtype='float64')

In [27]: arr1d_float.dtype
Out[27]: dtype('float64')

有趣的是,arange() 允许为步长参数传入浮动数值:

In [28]: arr1d_float_step = np.arange(0, 3, 0.3)

In [29]: arr1d_float_step
Out[29]: array([0\. , 0.3, 0.6, 0.9, 1.2, 1.5, 1.8, 2.1, 2.4, 2.7])

注意

arange()与浮点数参数一起使用时,通常无法预测获得的元素数量,因为浮点数的精度有限。因此,最好使用 NumPy 的linspace()函数,该函数接收所需元素数量作为参数,而不是步长参数。我们稍后将详细讨论linspace()

使用arange()reshape()函数,你可以通过一行代码创建多维数组——并生成大量数据。arange()函数创建一个一维数组,reshape()将这个线性数组按照形状参数划分为不同的部分。以下是使用 3D 形状元组(2, 2, 4)的示例:

In [30]: arr3d = np.arange(16).reshape(2, 2, 4)
In [31]: print(arr3d)
[[[ 0  1  2  3]
  [ 4  5  6  7]]

 [[ 8  9 10 11]
  [12 13 14 15]]]

因为数组需要对称,形状元组的乘积必须等于数组的大小。在这种情况下,(8, 2, 1)(4, 2, 2)是有效的,但(2, 3, 4)会报错,因为结果数组包含 24 个元素,而你指定的是 16 个元素(np.arange(16)):

In [32]: arr3d = np.arange(16).reshape(2, 3, 4)
Traceback (most recent call last):

File "C:\Users\hanna\AppData\Local\Temp/ipykernel_19556/3404575613.py", line 1, in <module>
arr3d = np.arange(16).reshape(2, 3, 4)

ValueError: cannot reshape array of size 16 into shape (2,3,4)
使用 linspace()函数

NumPy 的linspace()函数创建一个在定义区间内均匀间隔的ndarray。它基本上是带有num(样本数量)参数的arange()函数,而不是step参数。num参数决定数组中将包含多少个元素,函数会计算它们之间的数值,使间隔相等。

假设你需要一个大小为 6 的数组,值在 0 和 20 之间。你只需传递一个起始值、终止值和num值,如下所示,并为清晰起见使用关键字参数:

In [33]: np.linspace(start=0, stop=20, num=6)
Out[33]: array([ 0., 4., 8., 12., 16., 20.])

这生成了一个包含六个浮点值的一维数组,所有值均匀分布。请注意,终止值(20)包含在数组中。

你可以通过将布尔参数endpoint设置为False,强制函数不包括终点:

In [34]: np.linspace(0, 20, 6, endpoint=False)
Out[34]: 
array([ 0\. , 3.33333333, 6.66666667, 10\. , 13.33333333, 16.66666667])

如果你想获取值之间间隔的大小,可以将布尔参数retstep设置为True。这样会返回步长值:

In [35]: arr1d, step = np.linspace(0, 20, 6, retstep=True)

In [36]: step
Out[36]: 4.0

默认情况下,linspace()函数返回dtypefloat64。你可以通过传递dtype参数来覆盖这一点:

In [37]: np.linspace(0, 20, 6, dtype='int64')
Out[37]: array([ 0, 4, 8, 12, 16, 20], dtype=int64)

然而,在更改数据类型时需要小心,因为由于四舍五入,结果可能不再是线性空间。

arange()一样,你可以实时地重塑数组。在这里,我们使用相同的linspace()参数生成一个二维数组:

In [38]: np.linspace(0, 20, 6).reshape(2, 3)
Out[38]: 
array([[ 0.,  4.,  8.],
       [12., 16., 20.]])

注意

可以创建间隔不均匀的序列。例如,np.logspace()函数创建一个对数空间,其中的数字在对数尺度上均匀分布。

linspace() 函数让你控制数组中元素的数量,这是使用 arange() 时可能遇到的挑战。均匀间隔的数字数组在处理连续变量的数学函数时非常有用。同样,当你需要均匀地采样一个对象,比如波形时,线性空间也非常有用。要查看 linspace() 的一些有用示例,请访问 realpython.com/np-linspace-numpy/

meshgrid() 函数根据给定的两个一维数组创建一个矩形网格。生成的索引矩阵在每个单元格中保存二维空间中每个点的 x 和 y 坐标。虽然 meshgrid() 在绘图和插值二维数组时非常有用,但 mgrid() 函数调用 meshgrid() 来生成一个密集的“网格”,支持多个维度。

创建预填充数组

为了方便,NumPy 允许你使用预填充的 1、0、随机值或自定义值来创建 ndarray。你甚至可以创建一个没有预定义值的空数组。这些数组通常用于需要一个用于存储计算结果的结构,机器学习训练应用,创建图像掩膜,执行线性代数等情况。

要创建一个填充为零的数组,只需传递 zero() 函数一个形状元组,如下所示:

In [39]: np.zeros((3, 3))
Out[39]: 
array([[0., 0., 0.],
       [0., 0., 0.],
       [0., 0., 0.]])

要创建一个填充为 1 的数组,使用 ones() 函数重复此过程:

In [40]: np.ones((3, 3))
Out[40]: 
array([[1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.]])

np.eye() 函数创建一个数组,其中所有元素的值都为零,除了 k 维度的对角线元素,其值为一:

In [41]: np.eye(N=3, M=3, k=0)
Out[41]: 
array([[1., 0., 0.],
       [0., 1., 0.],
       [0., 0., 1.]])

In [42]: np.eye(N=3, M=3, k=1)
Out[42]: 
array([[0., 1., 0.],
       [0., 0., 1.],
       [0., 0., 0.]])

默认情况下,这些函数返回 float64 值,但你可以通过 dtype 参数覆盖这一点,比如 dtype=int

要用自定义值和数据类型填充数组,请使用 full() 函数,语法如下:

In [43]: np.full((3, 3), fill_value=5, dtype='int64')
Out[43]: 
array([[5, 5, 5],
       [5, 5, 5],
       [5, 5, 5]], dtype=int64)

empty() 函数返回一个新的 ndarray,其形状为给定的形状,并填充了未初始化(任意)数据,数据类型为给定的数据类型:

In [44]: np.empty((2, 3, 2))
Out[44]: 
array([[[2.20687562e-312, 2.05833592e-312],
        [5.73116149e-322, 0.00000000e+000],
        [2.35541533e-312, 2.07955588e-312]],

       [[2.05833592e-312, 2.44029516e-312],
        [2.35541533e-312, 2.33419537e-312],
        [0.00000000e+000, 0.00000000e+000]]])

根据文档,empty() 不会将数组的值设置为零,因此可能比 zeros() 函数稍快。但它要求用户手动设置数组中的所有值,因此应谨慎使用。

最后,你可以使用 NumPy 生成伪随机数数组。对于 0 到 1 之间的浮动值,只需传递一个形状元组给 random()

In [45]: np.random.random((3,3))
Out[45]: 
array([[0.16666842, 0.54555604, 0.08931106],
       [0.14603673, 0.84008062, 0.67797898],
       [0.17353608, 0.34648653, 0.97878551]])

此外,你可以生成随机整数,从“标准正态”分布中采样值,打乱现有数组的内容等。我们将在本章稍后介绍这些选项,你也可以在 numpy.org/doc/stable/reference/random/generator.html 找到官方文档。

访问数组属性

作为对象,ndarray 具有可通过点符号访问的属性。我们已经查看了一些这些属性,你可以在 表 18-3 中找到更多。

表 18-3: 重要的 ndarray 属性

属性 描述
ndim 数组的轴(维度)数量
shape 一个整数元组,表示数组在每个维度的大小
size 数组中元素的总数
itemsize 数组中每个元素的字节大小
dtype 描述数组元素数据类型的对象
strides 在遍历数组时每个维度的步长(字节元组)

例如,要获取 arr1d 对象的形状,可以输入以下内容:

In [46]: arr1d = np.arange(0, 4)

In [47]: arr1d.shape
Out[47]: (4,)

作为一个一维数组,只有一个轴,因此只有一个索引。注意索引后的逗号,它告诉 Python 这是一个元组数据类型,而不仅仅是括号中的整数。

数组的大小是它包含的元素总数。这与通过 shape 返回的元素的乘积相同。要获取数组的大小,输入以下内容:

In [48]: arr1d.size
Out[48]: 4

要获取数组的 dtype,请输入:

In [49]: arr1d.dtype
Out[49]: dtype('int32')

请注意,即使你使用的是 64 位机器,数字的默认 dtype 可能是 32 位,例如 int32float32。为了确保使用 64 位数字,你可以在创建数组时指定 dtype,如下所示(对于 int64):

In [50]: test = np.array([5, 4, 9], dtype='int64')

In [51]: test.dtype
Out[51]: dtype('int64')

要获取数组的步长,使用点符号访问strides属性:

In [52]: arr1d.strides
Out[52]: (4,)

在数组中使用字符串时,dtype 需要包括最长字符串的长度。NumPy 通常可以自动推断出来,如下所示:

In [53]: arr1d_str = np.array(['wheat', 'soybeans', 'corn'])

In [54]: arr1d_str.dtype
Out[54]: dtype('<U8')

注意,unicode(Udtype 包括数字 8,这是 soybeans(最长的字符串项)的长度。

要查看每个项目所占的位数和数据类型,可以调用dtypename属性,如下所示:

In [55]: arr1d_str.dtype.name
Out[55]: 'str256'

在这种情况下,数组中的每个项目都是占用 256 位(8 个字符 x 32 位)的 string。这与 itemsize 属性不同,后者只显示单个字符的大小,以字节为单位

In [56]: arr1d_str.itemsize
Out[56]: 32

测试你的知识

  1. 什么不是数组的特征?

a.  使得计算快速且内存占用小

b.  完全由单一数据类型的元素组成

c.  最多支持四个维度

d.  提供了比循环更高效的替代方案

  1. 二维数组也称为:

a.  线性数组

b.  张量

c.  秩

d.  矩阵

  1. 步长元组告诉 NumPy:

a.  数组中不同数据类型的数量

b.  遍历数组时,在每个维度中步进的字节数

c.  采样数组时的步长

d.  数组的字节大小

  1. 你已经得到一个包含各种大小数字图像的数据集,并被要求从每张图像中均匀地抽取 100 个像素强度样本。你会使用哪个 NumPy 函数来选择样本位置?

a.  arange()

b.  empty()

c.  empty_like()

d.  full()

e.  linspace()

  1. 写一个表达式生成一个 100×100 的零矩阵。

数组的索引和切片

ndarray 中的元素可以通过索引和切片访问。这让你能够提取元素的值,并且可以使用赋值语句修改值。数组索引使用方括号 [],就像 Python 列表一样。

索引和切片 1D 数组

一维数组是从零开始索引的,所以第一个索引始终是 0。对于反向索引和切片,第一个值是 -1。图 18-2 描述了数组中五个元素的索引。

Image

图 18-2:1D ndarray 的索引

如果你熟悉列表索引,那么索引 1D 数组不会有任何问题。我们来看一些示例,其中使用正索引和负索引选择元素:

In [57]: arr1d = np.array([15, 16, 17, 18, 19, 20])

In [58]: arr1d[0]
Out[58]: 15

In [59]: arr1d[-6]
Out[59]: 15

In [60]: arr1d[-1]
Out[60]: 20

要访问数组中的每隔一个元素,可以加入步长值 2:

In [61]: arr1d[::2]
Out[61]: array([15, 17, 19])

要一次访问多个元素,请使用一个由逗号分隔的索引数组,如下所示:

In [62]: arr1d[[0, 2, 4]]
Out[62]: array([15, 17, 19])

选择了这些元素后,你可以给它们赋新值,并改变底层数组中的值,像这样:

In [63]: arr1d[[0, 2, 4]] = 0

In [64]: arr1d
Out[64]: array([ 0, 16, 0, 18, 0, 20])

你还可以通过数组切片给一组数组元素赋新值。在下一个示例中,我们使用切片将前三个元素的值改为 100:

In [65]: arr1d[:3] = 100

In [66]: arr1d
Out[66]: array([100, 100, 100, 18, 0, 20])

在前面的示例中,100 的值传播到了整个切片。这一过程被称为 广播。因为数组切片是源数组的视图,而不是副本,所以对视图的任何修改都会改变原数组。这在处理非常大的数组时是有利的,因为它避免了 NumPy 动态创建内存密集型副本。

请注意,即使将数组切片赋值给一个变量,这种赋值行为仍然存在:

In [67]: arr1d = np.array([0, 1, 2, 3, 4])

In [68]: a_slice = arr1d[3:]

In [69]: a_slice
Out[69]: array([3, 4])

In [70]: a_slice[0] = 666

In [71]: arr1d
Out[71]: array([ 0, 1, 2, 666, 4])

In [72]: a_slice[:] = 42

In [73]: arr1d
Out[73]: array([ 0, 1, 2, 42, 42])

因为切片本身是一个数组,它有自己的一组索引,这些索引与源数组的索引不同。因此,a_slice[:] 对应于 arr2d[3:]

要创建一个真正的副本而不是视图,请调用 copy() 方法,如下所示:

In [74]: a_slice = arr1d[1:3].copy()

In [75]: a_slice[:] = 55

In [76]: a_slice
Out[76]: array([55, 55])

In [77]: arr1d
Out[77]: array([ 0, 1, 2, 42, 42])

现在,a_slice 数组与 arr1d 独立,改变它的元素不会影响源数组。

另外,你可以先对切片调用 array 函数,然后修改结果:

In [78]: a_slice = np.array(arr1d[:])

In [79]: a_slice[:] = 55

In [80]: arr1d
Out[80]: array([0, 1, 2, 42, 42])

改变 a_slice 数组对 arr1d 没有影响,因为这些数组表示的是独立的对象。

索引和切片 2D 数组

二维数组使用一对值进行索引。这些值对类似于笛卡尔坐标系,除了行索引(轴 0 的值)在前,列索引(轴 1 的值)在后,如图 18-3 所示。再次使用方括号。

Image

图 18-3:2D ndarray 的索引

让我们创建图 18-3 中的 2D 数组,进一步研究这个问题:

In [81]: arr2d = np.arange(1, 10).reshape(3, 3)

In [82]: arr2d
Out[82]: 
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])

在 2D 数组中,索引对中的每个值引用的是 1D 数组(整行或整列),而不是单个元素。例如,指定整数索引 1 输出的是由 2D 数组的第二行组成的 1D 数组:

In [83]: arr2d[1]
Out[83]: array([4, 5, 6])

对 2D 数组进行切片也可以沿 1D 数组进行。在这里,我们沿着行切片,取最后两行:

In [84]: arr2d[1:3]
Out[84]: 
array([[4, 5, 6],
       [7, 8, 9]])

这产生了一个形状为 (2, 3) 的 2D 数组。

要获取 2D 数组中的整列,可以使用以下语法:

In [85]: arr2d[:, 1]
Out[85]: array([2, 5, 8])

冒号(:)告诉 NumPy 获取所有行;1 则选择仅第 1 列,这样你就只剩下来自 arr2d 中心列的 1D 数组。

你也可以使用以下语法提取一列,尽管在这种情况下,与其输出包含列值的 1D 数组,不如生成形状为 (3, 1) 的 2D 数组:

In [86]: arr2d[:, 1:2]
Out[86]: 
array([[2],
       [5],
       [8]])

In [87]: arr2d[:, 1:2].shape
Out[87]: (3, 1)

一般来说,如果你使用整数索引和切片的混合方式对 2D 数组进行切片,你会得到一个 1D 数组。如果你沿着两个轴进行切片,你会得到另一个 2D 数组。参考 图 18-4,它展示了使用各种表达式从 2D 数组中采样的结果。

与 1D 数组一样,2D 切片是数组的视图,你可以使用这些视图来修改源数组中的值。在这个示例中,我们选择了 图 18-3 中数组的中间列,并将其所有元素更改为 42

In [88]: a2_slice = arr2d[:, 1]

In [89]: a2_slice
Out[89]: array([2, 5, 8])

In [90]: a2_slice[:] = 42

In [91]: arr2d
Out[91]: 
array([[ 1, 42, 3],
       [ 4, 42, 6],
       [ 7, 42, 9]])

Image

图 18-4:2D ndarray 的示例切片

要从 2D 数组中选择单个元素,指定一对整数作为元素的索引。例如,要获取第二行和第二列交点处的元素,可以输入以下内容:

In [92]: arr2d[1, 1]
Out[92]: 42

请注意,这种语法是更传统的嵌套列表语法的简化版本,在传统语法中,每个索引都被括号包围:

In [93]: arr2d[1][1]
Out[93]: 42
索引和切片高维数组

对于具有多于两个维度的数组,索引和切片的关键是将它们视为 一系列堆叠的低维数组。我们将这些堆叠的数组称为 plans。与 2D 数组一样,索引 3D 数组的顺序由它们的形状元组决定。

让我们先来看一个形状为 (2, 3, 4) 的 3D 数组。你可以将形状元组中的第一个值看作是该 3D 数组中的 2D 数组的数量。接下来的两个数字被视为这些 2D 数组的形状元组,分别表示其行数和列数。这里有一个示例:

In [94]: arr3d = np.arange(24).reshape(2, 3, 4)

In [94]: arr3d
Out[94]: 
array([[[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]],

       [[12, 13, 14, 15],
        [16, 17, 18, 19],
        [20, 21, 22, 23]]])

查看输出时,你应该会看到两个形状为 (3, 4) 的 2D 数组,一个叠放在另一个上面。这些数组通过输出中的空格以及第二个 2D 数组周围的一组新的方括号来区分。

因为数组包含两个矩阵,形状元组中的 3D 维度是 2。这个数字排在前面,因此你可以将形状元组看作记录了计划、行和列的数量。

为了看看这如何工作,我们可以使用索引来检索数组中的值 20。我们可以使用数组的形状元组 (plans, rows, columns) 来指导我们:

In [95]: arr3d[1, 2, 0]
Out[95]: 20

首先,我们必须选择第二个 2D 数组,它的索引是 1,因为 Python 从 0 开始计数。接下来,我们使用 2 选择了第三行。最后,我们使用 0 选择了第一列。关键是按顺序处理形状元组。数组的维度会告诉你需要多少个索引(三维数组需要三个,四维数组需要四个,以此类推)。

切片也遵循形状元组的顺序。例如,要查看 arr3d 数组的下方 2D 数组,您需要输入 1 作为平面索引,然后使用冒号简写符号选择所有行和列:

In [96]: arr3d[1, :, :]
Out[96]: 
array([[12, 13, 14, 15],
       [16, 17, 18, 19],
       [20, 21, 22, 23]])

供参考,图 18-5 展示了通过 3D 数组的一些示例切片,以及相应的形状。

Image

图 18-5:通过 3D ndarray 的一些示例切片

像往常一样,改变切片中元素的值将会改变源数组,除非该切片是一个副本:

In [97]: arr3d[0, :, :] = 0

In [98]: arr3d
Out[98]: 
array([[[ 0,  0,  0,  0],
        [ 0,  0,  0,  0],
        [ 0,  0,  0,  0]],

       [[12, 13, 14, 15],
        [16, 17, 18, 19],
        [20, 21, 22, 23]]])

在我们继续之前,先练习一下对多于三维的数组进行索引和切片。例如,看看以下 4D 数组:

In [99]: arr4d = np.arange(24).reshape(2, 2, 2, 3)

In [100]: arr4d
Out[100]: 
array([[[[ 0,  1,  2],
         [ 3,  4,  5]],

        [[ 6,  7,  8],
         [ 9, 10, 11]]],

       [[[12, 13, 14],
         [15, 16, 17]],

        [[18, 19, 20],
         [21, 22, 23]]]])

请注意,数组以四个方括号开始,并且使用两个空行将两个堆叠的 3D 数组分开。由于我们处理的是一个 4D 数组,要选择 20 元素,您需要输入四个索引:

In [101]: arr4d[1, 1, 0, 2]
Out[101]: 20

这里,从左到右,您将 4D 数组索引到 3D 数组;3D 数组索引到 2D 数组;2D 数组索引到 1D 数组;最后将 1D 数组索引到单个元素。这在图 18-6 中可能更加明显,它演示了这些操作的顺序。

Image

图 18-6:将 4D 数组索引到单个元素 [1, 1, 0, 2]

这种排序风格适用于任意数量的维度。

最后,对于 4D 数组,其中第四维表示时间,考虑将数组水平排列而不是垂直排列会很有帮助(图 18-7)。

Image

图 18-7:每个 4D 切片可以代表同一个 3D 数组,在不同的时间进行采样。

在这种情况下,每个单一的 4D 切片将代表同一数据集(3D 数组),但是在不同的时间点进行测量的。因此,要查看第一组测量结果,您可以输入 arr4d[0, :, :],而查看最后一组测量结果时,可以输入 arr4d[-1, :, :]

布尔索引

除了使用数值索引和切片,您还可以通过条件和布尔运算符在数组中选择元素。这使您能够在不知道元素位置的情况下提取数组中的元素。例如,您可能在一个垃圾填埋场周围有数百个监测井,您想找到所有检测到有毒污染物甲苯超过某一阈值的井。使用布尔索引,您不仅可以识别这些井,还可以基于输出创建一个新的数组。

为了说明,以下条件会在数组中搜索任何大于或等于四的整数元素:

In [102]: arr1d = np.array([1, 2, 3, 4, 5])

In [103]: print(arr1d >= 4)
[False False False True True]

如你所见,Python 会返回一个布尔值数组,其中条件满足的地方会有 True 值。请注意,这种语法适用于任何维度的 ndarray

NumPy 还可以在后台使用布尔值,允许你基于条件对数组进行切片:

In [104]: a_slice = arr1d[arr1d >= 4]

In [105]: a_slice
Out[105]: array([4, 5])

比较两个数组也会生成一个布尔数组。在这个例子中,我们标记所有在 arr_2 中大于 arr_1 中对应元素的值为 True

In [106]: arr_1 = np.random.randn(3, 4)

In [107]: arr_2 = np.random.randn(3, 4)

In [108]: arr_2 > arr_1
Out[108]: 
array([[ True,  True, False,  True],
       [ True, False,  True, False],
       [False,  True,  True, True]])

布尔索引的常见用法是将灰度图像分割为前景和背景部分,这个过程叫做 阈值处理。这会生成一个基于截断值的二值图像。以下是一个示例,其中我们创建一个二维图像数组,然后对大于 4 的值进行阈值处理:

In [109]: img = np.array([
 [12, 13, 14,  4, 16,  1, 11, 10,  9],
 [11, 14, 12,  3, 15,  1, 10, 12, 11],
 [10, 12, 12,  1, 14,  3, 10, 12, 12], [ 9, 11, 16,  0,  4,  2,  3, 12, 10],
 [12, 11, 16, 14, 10,  2, 16, 12, 13],
 [10, 15, 16, 14, 14,  4, 16, 15, 12],
 [13, 17, 14, 10, 14,  1, 14, 15, 10]])

In [110]: img_thresh = (img > 4).astype(int)

记住,True 等价于 1False 等价于 0。这使得我们可以通过附加 astype() 函数并传入整数数据类型来将布尔数组转换为数值数组。

阈值处理后,新的数组中 0 值的区域应该形成数字 4:

In [111]: print(img_thresh)
[[1 1 1 0 1 0 1 1 1]
 [1 1 1 0 1 0 1 1 1]
 [1 1 1 0 1 0 1 1 1]
 [1 1 1 0 0 0 0 1 1]
 [1 1 1 1 1 0 1 1 1]
 [1 1 1 1 1 0 1 1 1]
 [1 1 1 1 1 0 1 1 1]]

要根据布尔数组赋值,你需要基于条件索引源数组,然后赋予一个值。在这里,我们将 0 赋值给所有值小于 5 的数组元素:

In [112]: img[img < 5] = 0

In [113]: img
Out[113]: 
array([[12, 13, 14,  0, 16,  0, 11, 10,  9],
       [11, 14, 12,  0, 15,  0, 10, 12, 11],
       [10, 12, 12,  0, 14,  0, 10, 12, 12],
       [ 9, 11, 16,  0,  0,  0,  0, 12, 10],
       [12, 11, 16, 14, 10,  0, 16, 12, 13],
       [10, 15, 16, 14, 14,  0, 16, 15, 12],
       [13, 17, 14, 10, 14,  0, 14, 15, 10]])

同样,你也可以使用索引改变布尔数组中的整行、整列和整块。例如,img[0] = 0 会将 img 数组的第一行所有元素都改为 0

在数组中使用布尔值涉及一些怪癖。通过布尔索引提取数组中的元素会默认创建数据的副本,这意味着不需要使用 copy() 函数。布尔数组的另一个特殊之处是,在编写比较语句时,你必须将 andor 关键字分别替换为 &| 符号。

测试你的知识

6.  创建一个大小为 30、形状为 (5, 6) 的二维 ndarray。然后,切片数组以提取灰色高亮的值:

Image

7.  对第 6 题中的数组进行重采样,提取灰色高亮的元素:

Image

8.  切片一个 ndarray 会得到:

a.  一个新的数组对象

b.  源数组的副本

c.  源数组的视图

d.  一个 Python list 对象

9.  使用标量索引和另一个切片组合切片二维数组会得到:

a.  一个二维数组

b.  一个一维数组

d.  一个单一元素(0D 数组)

e.  以上都不是

10.  这个数组的秩是多少?

array([[[[ 0,  1,  2,  3],

[ 4,  5,  6,  7]],

[[ 8,  9, 10, 11],

[12, 13, 14, 15]]],

[[[16, 17, 18, 19],

[20, 21, 22, 23]],

[[24, 25, 26, 27],

[28, 29, 30, 31]]]])

操作数组

NumPy 提供了处理现有数组的工具。常见的操作包括重塑数组、交换轴以及合并和拆分数组。这些操作在旋转、放大、平移图像以及拟合机器学习模型时非常有用。

改变形状与转置

NumPy 提供了更改数组形状、转置数组(交换列和行)以及交换轴的函数。你已经在使用其中的一个函数——reshape()

使用reshape()时要注意的一点是,像所有 NumPy 赋值一样,它创建的是数组的视图而不是副本。在以下示例中,重塑arr1d数组只会对数组进行临时更改:

In [114]: arr1d = np.array([1, 2, 3, 4])

In [115]: arr1d.reshape(2, 2)
Out[115]: 
array([[1, 2],
       [3, 4]])

In [116]: arr1d
Out[116]: array([1, 2, 3, 4])

这种行为在你希望暂时更改数组的形状以便进行计算时非常有用,而不需要复制任何数据。

同样,将数组赋值给一个新变量只是创建了对源数组的另一个引用。在以下示例中,尽管将重塑后的arr1d数组赋值给一个名为arr2d的新变量,但更改arr2d中的值也会更改arr1d中相应的值:

In [117]: arr2d = arr1d.reshape(2, 2)

In [118]: arr2d
Out[118]: 
array([[1, 2],
       [3, 4]]) 

In [119]: arr2d[0] = 42

In [120]: arr2d
Out[120]: 
array([[42, 42],
       [ 3,  4]])

In [121]: arr1d
Out[121]: array([42, 42, 3, 4])

显然,这种行为可能会让你陷入困境。如前所述,如果你想从现有数组创建一个独立的ndarray对象,使用copy()函数。

要修改数组本身而不是仅创建视图,使用shape()函数并传递一个形状元组:

In [122]: arr1d.shape = (2, 2)

In [123]: arr1d
Out[123]: 
array([[42, 42],
        [3, 4]])

将这段代码与In [114] – Out [116]进行比较。在这里,源数组被永久更改。

展平数组

有时候,即使你的数据是高维的,你也希望使用 1D 数组作为某些过程的输入。例如,标准的绘图程序通常期望使用简单的数据结构,如列表或单一的平面数组。同样,图像数据通常会在输入神经网络的输入层之前转换为 1D 数组。

从高维数组转到 1D 数组称为展平ravel()函数可以在创建数组视图的同时完成这一操作。以下是一个示例:

In [124]: arr2d = np.arange(8).reshape(2, 4)

In [125]: arr2d
Out[125]: 
array([[0, 1, 2, 3],
       [4, 5, 6, 7]])

In [126]: arr1d = arr2d.ravel()

In [127]: arr1d
Out[127]: array([0, 1, 2, 3, 4, 5, 6, 7])

在展平数组时创建数组副本,可以使用ndarray对象的flatten()方法。由于该方法生成的是副本而不是视图,因此比ravel()稍慢。以下是语法:

In [128]: arr2d.flatten()
Out[128]: array([0, 1, 2, 3, 4, 5, 6, 7])

你也可以通过使用shape()函数并传递数组中元素的数量来原地展平原始数组:

In [129]: arr2d.shape = (8)

In [130]: arr2d
Out[130]: array([0, 1, 2, 3, 4, 5, 6, 7])

请记住,你可以通过调用数组的size属性并使用点符号获取数组的大小。

交换数组的列和行

在分析数据时,最好从多个角度进行检查。图 18-8 显示了三个德州城市的月平均气温数据。如何展示数据——是按月份展示,还是按地点展示——取决于你想回答的问题以及你在报告中可以用于展示数据的空间。

Image

图 18-8:显示德克萨斯州三个城市的平均月度温度(^°F),按月和城市展示

就像 Microsoft Excel 可以轻松地反转列和行一样,NumPy 提供了便捷的transpose()函数来执行此操作:

In [131]: arr2d = np.arange(8).reshape(2, 4)

In [132]: arr2d
Out[132]: 
array([[0, 1, 2, 3],
       [4, 5, 6, 7]])

In [133]: arr2d.transpose()
Out[133]: 
array([[0, 4],
       [1, 5],
       [2, 6],
       [3, 7]])

这仍然是原始数组的视图。要创建一个新数组,你可以添加copy()函数,如下所示:

In [134]: arr2d_transposed = arr2d.transpose().copy()

对于更高维度的数组,你可以将transpose()传递一个轴编号的元组,按照你希望的顺序进行转置。让我们将一个 3D 数组转置,使轴的顺序变为第三轴优先,第一轴第二,第二轴保持不变:

In [135]: arr3d = np.arange(12).reshape(2, 2, 3)

In [136]: arr3d
Out[136]: 
array([[[0,  1,  2],
        [3,  4,  5]],

       [[6,  7,  8],
        [9, 10, 11]]])

In [137]: arr3d.transpose((2, 1, 0))
Out[137]: 
array([[[0,  6],
        [3,  9]],

       [[1,  7],
        [4, 10]],

       [[2,  8],
        [5, 11]]])

交换轴的另一种方法是swapaxes()。它接受一对轴并重新排列数组,返回数组的视图。这里是一个例子:

In [138]: arr3d
Out[138]: 
array([[[0,  1,  2],
        [3,  4,  5]],

       [[6,  7,  8],
        [9, 10, 11]]])

In [139]: arr3d.swapaxes(0, 1)
Out[139]: 
array([[[0,  1,  2],
        [6,  7,  8]],

       [[3,  4,  5],
        [9, 10, 11]]])

连接数组

NumPy 提供了几个函数,让你可以将多个现有数组合并或堆叠成一个新数组。让我们首先创建两个 2D 数组,第一个由零组成,第二个由一组成:

In [140]: zeros = np.zeros((3, 3))

In [141]: ones = np.ones((3, 3))

现在,让我们使用vstack()函数将两个数组按垂直方向堆叠。这将把第二个数组作为新行沿轴 0 添加到第一个数组中:

In [142]: np.vstack((zeros, ones))
Out[142]: 
array([[0., 0., 0.],
       [0., 0., 0.],
       [0., 0., 0.],
       [1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.]])

hstack()函数将第二个数组作为新列添加到第一个数组上:

In [143]: np.hstack((zeros, ones))
Out[143]: 
array([[0., 0., 0., 1., 1., 1.],
       [0., 0., 0., 1., 1., 1.],
       [0., 0., 0., 1., 1., 1.]])

row_stack()column_stack()函数将 1D 数组堆叠成新的 2D 数组。例如:

In [144]: x = np.array([1, 2, 3])

In [145]: y = np.array([4, 5, 6])

In [146]: z = np.array([7, 8, 9])

In [147]: np.row_stack((x, y, z))
Out[147]: 
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])

In [148]: np.column_stack((x, y, z))
Out[148]: 
array([[1, 4, 7],
       [2, 5, 8],
       [3, 6, 9]])

你还可以使用深度堆叠函数(dstack((x, y, z)))在轴 2 上进行列堆叠。此函数类似于hstack(),不同之处在于它首先将 1D 数组转换为 2D 列向量。

分割数组

NumPy 还允许你对数组进行除法或分割。与连接一样,你可以垂直或水平地进行分割。

下面是一个使用vsplit()函数的例子。首先,让我们创建一个数组:

In [149]: source = np.arange(24).reshape((4, 6))

In [150]: source
Out[150]: 
array([[ 0,  1,  2,  3,  4,  5],
       [ 6,  7,  8,  9, 10, 11],
       [12, 13, 14, 15, 16, 17],
       [18, 19, 20, 21, 22, 23]])

要将source数组垂直(沿轴 0)分割为两部分,传递vsplit()函数数组和2作为参数:

In [151]: split1, split2 = np.vsplit(source, 2)

In [152]: split1
Out[152]: 
array([[ 0, 1, 2, 3,  4,  5],
       [ 6, 7, 8, 9, 10, 11]])

In [153]: split2
Out[153]: 
array([[12, 13, 14, 15, 16, 17],
       [18, 19, 20, 21, 22, 23]])

要将source数组水平(沿轴 1)分割为两部分,传递hsplit()数组和2作为参数:

In [154]: split1, split2 = np.hsplit(source, 2)

In [155]: split1
Out[155]: 
array([[ 0,  1,  2],
       [ 6,  7,  8],
       [12, 13, 14],
       [18, 19, 20]])

In [156]: split2
Out[156]: 
array([[ 3,  4,  5],
       [ 9, 10, 11],
       [15, 16, 17],
       [21, 22, 23]])

在之前的示例中,数组分割必须是均等划分。使用split()函数,你可以沿一个轴将数组分割成多个数组。你将原始数组和要分割的部分的索引传递给该函数,并且可以选择传递一个轴编号(默认为轴 0)。例如,要将source数组分割成包含两列、三列和一列的三个数组,你将输入如下:

In [157]: a, b, c = np.split(source, [2, 5], axis=1)

In [158]: a
Out[158]: 
array([[ 0,  1],
       [ 6,  7],
       [12, 13],
       [18, 19]])

In [159]: b
Out[159]: 
array([[ 2,  3,  4],
       [ 8,  9, 10],
       [14, 15, 16],
       [20, 21, 22]])

In [160]: c
Out[160]: 
array([[ 5],
       [11],
       [17],
       [23]])

索引[2, 5]告诉 NumPy 在轴 1 上哪里分割数组。要在行上重复此操作,只需将axis参数改为0

使用数组进行数学运算

现在你已经知道如何创建和操作数组,是时候将它们应用于它们的主要目的:数学运算了。NumPy 使用两种内部实现来高效地对数组进行数学运算:矢量化广播。矢量化支持在相同大小的数组之间进行运算,而广播则将这种行为扩展到形状不同的数组。

矢量化

ndarray 的最强大特性之一,矢量化 让你无需显式的 for 循环就能对数据执行批量操作。这意味着你可以一次性对整个数组应用操作,而不需要选择其中的每一个元素。

对于相同大小的数组,算术运算会逐元素应用,如 图 18-9 所示。

图片

图 18-9:涉及相同大小数组的数学运算会在对应元素上执行。

因为循环背后是用 C 语言实现的,所以矢量化可以加速处理。我们来看看一个例子,比较 Python 中的循环和 NumPy 中的矢量化。

从创建两个包含 100,000 个随机选择的整数(范围是 0 到 500)的数据集开始:

In [161]: data_a = np.random.randint(500, size=100_000)

In [162]: data_b = np.random.randint(500, size=100_000)

现在,创建一个空列表,然后遍历这两个数据集,如果 data_a 中的每一项也出现在 data_b 中,就将它添加到列表中:

In [163]: shared_list = []

In [164]: for item in data_a:
     ...:     if item in data_b:
     ...:         shared_list.append(item)

请注意,这也可以通过列表推导式写成 shared_list = [item for item in data_a if item in data_b]

根据你的硬件配置,可能需要等待五秒钟或更长时间,才能完成这个循环。

这是列表中的前三个值(你的可能不同,因为这些是随机生成的):

In [165]: shared_list[:3]
Out[165]: [326, 159, 155]

让我们使用 NumPy 的 isin() 函数重复这个练习。这个优化函数会将目标数组中的每个元素与另一个数组进行比较,并返回一个布尔值。我们可以将其与索引结合使用,返回值为 True 的元素:

In [166]: data_a[np.isin(data_a, data_b)]
Out[166]: array([326, 159, 155, ..., 136, 416, 307])

与之前标准的 Python 循环相比,这个计算几乎是瞬间完成的。

矢量化还允许更简洁和可读的代码,看起来像数学表达式。例如,要将两个数组相乘,你可以省略嵌套循环,直接写成 arr1 * arr2,如下所示:

In [167]: arr1 = np.array([[1, 1, 1], [2, 2, 2]])

In [168]: arr1
Out[168]: 
array([[1, 1, 1],
       [2, 2, 2]])

In [169]: arr2 = np.array([[3, 3, 3], [4, 4, 4]])

In [170]: arr2
Out[170]: 
array([[3, 3, 3],
       [4, 4, 4]])

In [171]: arr1 * arr2
Out[171]: 
array([[3, 3, 3],
       [8, 8, 8]])

这种行为适用于所有基本的算术运算,例如加法、减法、乘法和除法。

广播

广播 技术允许对不同形状的数组执行运算。为了理解它的工作原理,考虑 图 18-10,其中一个包含四个元素的 1D 数组与一个包含单个元素的 1D 数组相乘。

图片

图 18-10:当将 1D ndarray 与标量相乘时的广播示例

如你所见,较小的数组会扩展到较大的数组,直到它们具有兼容的形状。形状为 (1,) 的数组会变成形状为 (4,) 的数组,其中单一的值会被重复,以便可以进行逐元素乘法操作。这种行为同样适用于标量与数组之间的运算。

要使广播生效,两个数组的维度必须兼容。当两个维度相等或其中一个维度为 1 时,它们是兼容的。NumPy 通过比较数组形状元组来确定这种兼容性,从右到左进行比较。例如,要检查不同的 24 元素 3D 数组是否可以广播,NumPy 将比较它们的形状元组,如 图 18-11 所示。

Image

图 18-11:检查 3D 数组的维度是否兼容(灰色阴影值)

从尾部维度 ➊ 开始,NumPy 确定这两对数组是兼容的,因为至少有一个维度等于 1。对于下一个比较 ➋,这仍然成立,但在最后的比较 ➌ 中,底部的那一对数组失败了,因为 6 和 3 不相等。因此,我们无法在这两个数组之间执行任何数学操作。

相比之下,在 图 18-12 中,一个 2D 数组和一个 1D 数组是兼容的,因此 1D 数组可以广播到缺失的行中。

Image

图 18-12:将 2D 数组与 1D 数组相加时广播的示例

这允许按元素进行加法运算。广播可以沿着行、列或平面进行,具体取决于需要。有关广播的更多信息,包括实际示例,请访问 numpy.org/doc/stable/user/basics.broadcasting.html

矩阵点积

在 NumPy 中,数组之间的基本乘法是按元素执行的。换句话说,一个数组中的每个元素都与第二个数组中对应的元素相乘。这也包括 2D 数组的乘法,通常称为矩阵乘法。

然而,你可能还记得数学课上,正确的矩阵乘法涉及对行和列进行操作,而不是按元素进行。这就是 矩阵点积,其中第一个矩阵的水平方向与第二个矩阵的垂直方向相乘,结果然后相加,如 图 18-13 中的灰色阴影值所示。这个过程不仅不是 按元素 执行的,而且是不可交换的,因为 arr1 * arr2 不等于 arr2 * arr1

Image

图 18-13:矩阵点积

要以这种方式相乘两个矩阵,NumPy 提供了 dot() 函数。以下是使用 图 18-13 中的矩阵的示例:

In [172]: arr1 = np.array([[0, 1], [2, 3]])

In [173]: arr2 = np.array([[4, 5], [6, 7]])

In [174]: np.dot(arr1, arr2)
Out[174]: 
array([[ 6,  7],
       [26, 31]])

你也可以使用替代语法 arr1.dot(arr2) 来计算点积。

除了点积外,NumPy 还提供了其他进行线性代数运算的函数。要查看完整列表,请访问 numpy.org/doc/stable/reference/routines.linalg.html

递增和递减数组

你可以使用增强运算符,如 +=,在不创建新数组的情况下更改数组中的值。以下是使用一维数组的一些示例:

In [175]: arr1d = np.array([0, 1, 2, 3])

In [176]: arr1d += 10

In [177]: arr1d
Out[177]: array([10, 11, 12, 13])

In [178]: arr1d -= 10

In [179]: arr1d
Out[179]: array([0, 1, 2, 3])

In [180]: arr1d *= 2

In [181]: arr1d
Out[181]: array([0, 2, 4, 6])

在这些情况下,标量值会应用到数组中的每个元素。

使用 NumPy 函数

与 Python 的标准 math 模块类似,NumPy 也提供了一套自己的数学函数。这些函数包括通用函数和聚合函数。通用函数,也称为 ufunc,按元素逐个处理,并生成一个与输入大小相同的新数组。聚合函数作用于整个数组,生成一个单一值,例如数组中元素的总和。

通用函数

执行简单逐元素转换的通用函数,例如取对数或平方一个元素,称为 一元 ufunc。使用它们时,调用该函数并传入一个 ndarray,如下所示:

In [182]: arr1d = np.array([10, 20, 30, 40])

In [183]: np.log10(arr1d)
Out[183]: array([1\. , 1.30103 , 1.47712125, 1.60205999])

In [184]: np.square(arr1d)
Out[184]: array([ 100, 400, 900, 1600], dtype=int32)

一些常用的一元 ufunc 已列在 表 18-4 中。你可以在 numpy.org/doc/stable/reference/ufuncs.html#ufuncs/ 上找到完整的列表。

表 18-4: 有用的 NumPy 一元通用函数

函数 描述
abs 计算每个元素的绝对值
fabs 计算每个元素的绝对值并返回浮动类型
all 测试沿指定轴的所有数组元素是否均为 True
any 测试沿指定轴的任意数组元素是否为 True
ceil 计算每个元素大于或等于它的最小整数
floor 计算每个元素小于或等于它的最大整数
clip 将数组中的值限制在指定的最小值和最大值范围内
round 将数组中的值四舍五入到指定的小数位数
exp 计算每个元素的指数(ex)
log, log10, log2 计算每个元素的自然对数、以 10 为底的对数或以 2 为底的对数
rint 将元素四舍五入到最接近的整数,同时保持数据类型
sign 计算每个元素的符号(正数=1,零=0,负数=-1)
sqrt 计算每个元素的平方根
square 计算每个元素的平方
modf 返回数组的整数部分和小数部分,作为新数组
isnan 返回布尔数组,指示 NaN(非数字)值
degrees 将表示弧度的元素转换为角度
radians 将表示角度的元素转换为弧度
cos, sin, tan 计算每个元素的余弦、正弦或正切
cosh, sinh, tanh 计算每个元素的双曲余弦、双曲正弦或双曲正切
arccos, arcsin, arctan 计算每个元素的反三角函数
arccosh, arcsinh, arctanh 计算每个元素的反双曲三角函数
sort arr.sort() 就地排序;np.sort() 返回排序后的副本

接受两个数组作为输入并返回单一数组的通用函数称为二元 ufunc。以下二元函数用于找到两个数组中的最大值和最小值,并将结果返回为新数组:

In [185]: a = np.array([1, 2, 500])

In [186]: b = np.array([0, 2, -1])

In [187]: np.maximum(a, b)
Out[187]: array([ 1, 2, 500])

In [188]: np.minimum(a, b)
Out[188]: array([ 0, 2, -1])

其他一些二元函数列在表 18-5 中。

表 18-5: 有用的 NumPy 二元通用函数

函数 描述
add 按元素相加
subtract 按元素从第一个数组中减去第二个数组
multiply 按元素相乘
divide 按元素相除
floor_divide 对数组进行除法运算并截断余数
power 将第一个数组中的元素提升到第二个数组中的幂
maximum, fmax 按元素返回最大值,fmax忽略 NaN 值
minimum, fmin 按元素返回最小值,fmax忽略 NaN 值
mod 按元素返回模值
copysign 将第二个数组中的符号复制到第一个数组的值上
greater 返回元素逐一大于的布尔数组
greater_equal 返回元素逐一大于或等于的布尔数组
less 返回元素逐一小于的布尔数组
less_equal 返回元素逐一小于或等于的布尔数组
equal 返回元素逐一相等的布尔数组
not_equal 返回元素逐一负相等的布尔数组

欲了解更多关于通用函数的信息,请访问:numpy.org/doc/stable/user/basics.ufuncs.html

统计方法

NumPy 还提供了一些方法,用于计算整个数组或沿某一轴的数据的统计值。将数组中的元素减少为单一值的操作称为聚合归约

让我们通过一个包含随机生成整数的二维数组来尝试一下这些方法:

In [189]: arr = np.random.randint(100, size=(3, 5))

In [190]: arr
Out[190]: 
array([[85, 77,  0, 10, 24],
       [16, 39, 94, 11, 21],
       [71, 54,  8, 73, 98]])

要计算这个数组中所有元素的均值,可以使用点符号在数组上调用mean()

In [191]: arr.mean()
Out[191]: 45.4

你还可以将数组传递给mean()函数,像这样:

In [192]: np.mean(arr)
Out[192]: 45.4

可选的axis参数允许你指定计算统计值的轴。例如,指定轴 1 意味着计算将在列方向进行,结果是一个一维数组,元素个数等于数组中的行数:

In [193]: arr.mean(axis=1)
Out[193]: array([39.2, 36.2, 60.8])

指定轴 0 告诉函数沿行方向进行计算。在下面的示例中,这将生成一个一维数组,包含五个元素,等于列的数量:

In [194]: arr.sum(axis=0)
Out[194]: array([172, 170, 102, 94, 143])

这些函数也可以在没有axis关键字的情况下调用:

In [195]: arr.mean(1)
Out[195]: array([39.2, 36.2, 60.8])

表 18-6 列出了一些有用的数组统计方法。你可以使用整个数组,也可以指定轴。

表 18-6: 有用的 NumPy 统计方法

函数 描述
argmin 最小值元素的索引
argmax 最大值元素的索引
count_nonzero 计算数组中非零值的数量
cumprod 从索引 1 开始的元素累积积
cumsum 从索引 0 开始的元素累积和
mean 元素的算术平均值
min 元素的最小值
max 元素的最大值
std 元素的标准差
sum 元素的和
var 元素的方差

请注意,NumPy 还提供了 apply_along_axis() 聚合函数,它允许你传递统计函数、轴和数组作为参数。以下是使用前述数组的一个示例:

In [196]: np.apply_along_axis(np.mean, axis=1, arr=arr)
Out[196]: array([37.4, 31\. , 74.4])

你还可以定义自己的函数并将它们传递给 apply_along_axis()

In [197]: def cube(x):
     ...:      return x**3

In [198]: np.apply_along_axis(cube, axis=1, arr=arr)
Out[198]: 
array([[614125, 456533,      0,   1000,  13824],
       [  4096,  59319, 830584,   1331,   9261],
       [357911, 157464,    512, 389017, 941192]], dtype=int32)

请注意,在这些示例中,你能够在不显式遍历每个元素的情况下操作数组。这再次展示了 NumPy 的一个强大优势。

生成伪随机数

NumPy 提供了用于从不同类型的概率分布创建数组的函数。这些函数对于生成随机数据来测试机器学习模型、创建具有已知形状或分布的数据分布、为蒙特卡洛模拟随机抽取数据等任务非常有用。它们的运行速度通常比 Python 内建的 random 模块中的类似函数快至少一个数量级。

表 18-7 列出了你可以在 np.random 中找到的一些函数。完整列表请访问 numpy.org/doc/stable/reference/random/index.html

表 18-7: 有用的 NumPy 伪随机函数

函数 描述
beta 从 Beta 分布中绘制样本
binomial 从二项分布中绘制样本
chisquare 从卡方分布中绘制样本
gamma 从 Gamma 分布中绘制样本
normal 从正态(高斯)分布中绘制随机样本
permutation 返回一个排列的范围或序列的随机排列
power 从幂函数分布中绘制样本
rand 创建一个给定形状的数组,填充来自(0, 1)区间的均匀分布随机样本
randint 返回从低(包含)到高(不包含)的随机整数
randn 从“标准正态”分布中返回一个样本(或多个样本)
random 返回位于半开区间(0.0, 1.0)中的随机浮点数
seed 更改随机数生成器的种子
shuffle 原地随机排列序列
uniform 从半开区间(low, high)中绘制均匀分布的样本

读取和写入数组数据

NumPy 可以以二进制和文本格式从磁盘加载和保存数据。支持的文本格式有 .txt.csv。通常,你会使用基于 NumPy 的 pandas 库来处理文本或表格数据。我们将在第二十章中讨论 pandas。

对于以二进制格式存储和检索数据,NumPy 提供了 save()load() 函数。要将数组保存到磁盘,只需将文件名和数组作为参数传递,如下所示:

In [199]: arr = np.arange(8).reshape(2, 4)

In [200]: arr
Out[200]: 
array([[0, 1, 2, 3],
       [4, 5, 6, 7]])

In [201]: np.save('my_array', arr)

这将生成二进制文件 my_array.npy.npy 扩展名会自动添加)。

要重新加载此文件,请输入以下命令:

In [202]: np.load('my_array.npy')
Out[202]: 
array([[0, 1, 2, 3],
       [4, 5, 6, 7]])

np.savez() 函数允许你将多个数组保存到一个未压缩的 .npz 文件中。提供关键字参数可以将它们按相应的名称存储在输出文件中:

In [203]: arr1 = np.arange(5)

In [204]: arr2 = np.arange(4)

In [205]: np.savez('arr_arch.npz', a=arr1, b=arr2)

In [206]: archive = np.load('arr_arch.npz')

In [207]: archive['a']
Out[207]: array([0, 1, 2, 3, 4])

如果数组作为 位置 参数(没有关键字)指定,它们的名称默认会是 arr_0arr_1 等。

要在归档时压缩数据,使用 savez_compressed() 函数:

In [208]: np.savez_compressed('arr_arch_compressed.npz', a=arr1, b=arr2)

如果你确实想要读取文本文件,NumPy 提供了 genfromtxt()(从文本生成)函数。例如,要加载一个 .csv 文件,你需要将文件路径、分隔值的字符(如逗号),以及数据列是否有标题等信息传递给该函数。

In [209]: arr = np.genfromtxt('my_data.csv', delimiter=',', names=True)

这将生成一个 结构化 数组,该数组包含记录而不是单独的元素。我们没有讨论结构化数组,因为它们是低级工具,我们将使用 pandas 来执行加载 .csv 文件等操作。不过,你可以在 numpy.org/doc/stable/user/basics.rec.html 阅读更多关于结构化数组的信息。

测试你的知识

11.  为什么在输出数组的前两个元素中会有这么多空白([ 0, 2, -10000])?

12.  你会使用哪个函数将高维数组压缩为一维数组?

a.  meshgrid()

b.  vsplit()

c.  ravel()

d.  thresh()

13.  对于数组 [[0, 1, 2], [3, 4, 5], [6, 7, 8]],切片 arr2d[:2, 2] 会产生什么结果?

a.  array([1])

b.  array([2, 5])

c.  array([6, 7])

d.  array([3, 4, 5])

14.  在 NumPy 中,数组的乘法是如何执行的:

a.  按行列排列

b.  按行列排列

c.  按元素逐个执行

d.  按行排列然后按列排列

15.  哪个数组可以与形状为 (4, 3, 6, 1) 的数组进行广播?

a.  (4, 6, 6, 1)

b.  (1, 6, 3, 1)

c.  (4, 1, 6, 6)

d.  (6, 3, 1, 6)

总结

在处理统一数据集时,NumPy 的 ndarray 是比 Python 列表等竞争数据结构更快、更高效的替代方案。可以在不使用 for 循环的情况下执行复杂计算,并且 ndarray 占用的内存比其他 Python 数据类型少得多。

本章涉及了许多 NumPy 的基础知识,但仍有更多内容需要学习。为了扩展你对 NumPy 的了解,我推荐 NumPy 的“Beyond the Basics”页面 (numpy.org/doc/stable/user/c-info.beyond-basics.html) 和 Wes McKinney 的《Python 数据分析:Pandas、NumPy 与 IPython 数据清洗》,第二版(O'Reilly,2018)。

在你开始应用 NumPy 之前,建议先阅读接下来的两章,内容涉及 Matplotlib 和 pandas。这些库是基于 NumPy 构建的,并提供了更高级的封装,用于执行数据分析和绘图。

第十九章:解密 MATPLOTLIB

image

即使在 Python 中有大量的绘图包可供选择,Matplotlib 依然脱颖而出。它于 2003 年推出,旨在为科学和工程提供类似 MATLAB 的绘图接口,现在它已经主导了 Python 中的绘图工作。它催生了许多可视化扩展包,如 seaborn,并为像 pandas 这样的流行分析工具提供了底层绘图功能。掌握 Matplotlib 后,你可以快速生成简单的图表,也能制作复杂的精致图表,同时控制显示的每一个细节。

Matplotlib 库已随 Anaconda 预安装。得益于其成熟、流行和开源的特点,它拥有一个庞大的支持社区,随时为你提供建议和代码示例。最好的资源是著名的 Matplotlib 画廊(matplotlib.org/stable/gallery/index.html),它包含了制作几乎所有你能想象到的图表的代码“食谱”。

就像任何强大的软件一样,Matplotlib 有时被一位作者形容为“语法上乏味”。最简单的图表很容易制作,但难度很快上升。尽管像 Matplotlib 画廊这样的资源提供了有用的代码示例,但如果你需要一些与提供的示例稍有不同的内容,可能会让你一头雾水。事实上,许多人通过复制和粘贴他人的代码,然后在边缘进行修改,直到得到他们想要的效果。正如一位用户曾告诉我:“无论我用多少次 Matplotlib,它总是让我感觉像是第一次使用!”

幸运的是,通过花时间学习一些 Matplotlib 的关键要素,你可以大大减轻这种困扰。因此,在本章中,我们将研究 Matplotlib 图表的基础,包括它的两个绘图接口和制作多面板、动画以及自定义图表的方法。有了这些知识,你可能会发现 Matplotlib 是一个值得掌握的工具,而不是一个需要回避或勉强使用的工具。

然而,如果你不打算成为绘图高手,可以查看下一章中更简单的 seaborn 包装器。如果 seaborn 对你来说过于复杂,还有一个更简单——虽然灵活性较低——的 pandas 绘图选项。

图表的结构

理解 Matplotlib 的第一步是掌握其图表中使用的有时令人困惑的术语。为此,让我们解析一个图表及其组成部分。

Matplotlib 中的图表保存在一个 Figure 对象中(见 图 19-1 左侧)。这是一个空白画布,代表所有图表元素的最顶层容器。除了提供绘制图表的画布外,Figure 对象还控制图表的大小、长宽比、多个图表在同一画布上的间距,以及将图表输出为图像的功能。

Image

图 19-1:Matplotlib 图表中的 Figure、Axes 和 Axis 组件

图表本身——也就是你我认为的图形——由 Axes 类表示(图 19-1,中央)。这个类包括大多数图形元素,如线条、多边形、标记(点)、文本、标题等,以及作用它们的方法。它还设置坐标系统。一个 Figure 可以包含多个 Axes 对象,但每个 Axes 对象只能属于一个 Figure

Axes 对象不应与表示图表中 x 轴或 y 轴上数值的 Axis 元素混淆(图 19-1,右)。这包括刻度线、标签和坐标范围。所有这些元素都包含在 Axes 类中。

图 19-1 中的每个组件都存在于一个层级结构中(图 19-2)。最底层包括图 19-1 中的元素,如每个坐标轴、坐标轴刻度线和标签,以及曲线(Line2D)。最高层是 Figure 对象,它作为所有下层元素的容器。

Image

图 19-2:在图 19-1 中,图表组件的层级结构

因为一个 Figure 对象可以包含多个 Axes 对象,你可以在图 19-2 中让多个 Axes 对象指向同一个 Figure。一个常见的例子是子图,其中一个 Figure 画布包含两个或更多不同的图表并排显示。

pyplot 和面向对象的方法

Matplotlib 有两种主要的绘图接口。使用第一种接口,称为pyplot 方法,你依赖 Matplotlib 的内部 pyplot 模块自动创建和管理 FigureAxes 对象,然后使用 pyplot 方法进行绘图。这种方法主要用于处理单个图表,减少了你需要了解和编写的代码量。它是一个类似 MATLAB 的 API,非常适合快速交互式工作。

使用第二种方法,即面向对象风格,你显式地创建 FigureAxes 对象,然后在这些对象上调用方法。这种方法让你对定制图表和在大型程序中跟踪多个图表有最好的控制权。如果你首先创建一个 Axes 对象,理解与其他库的交互也会更容易。

在接下来的章节中,我们将讨论这两种方法。然而,根据 Matplotlib 文档,为了保持一致性,你应该选择一种方法并坚持使用。他们建议使用面向对象风格,特别是对于复杂的图表以及那些作为更大项目一部分需要重用的方法和脚本。

其实可以说,初学者觉得 Matplotlib 难以掌握的一个原因是他们在现有代码中看到这些方法的混合,例如在像 Stack Overflow 这样的问答网站上。由于这种情况是不可避免的,我建议你通读这两种方法的描述,这样你就可以做出明智的选择,并且在遇到遗留代码或教程时,能够了解另一种方法。

使用 pyplot 方法

为了使用 pyplot 方法生成一个简单的图表,让我们使用 Jupyter Qt 控制台。在基础环境中打开 Anaconda 提示符(在 Windows 中)或终端(在 macOS 或 Linux 中)来启动控制台。

首先,运行以下命令(如果你的提示符中包括“base”,可以忽略这个命令):

conda activate base

接下来,输入以下内容:

jupyter qtconsole

现在,将 Matplotlib 的 pyplot 模块导入到控制台中。为了方便起见,并且根据惯例,你应该使用别名 plt

In [1]: import matplotlib.pyplot as plt

默认情况下,控制台中的绘图会显示为内联(即在控制台内)。为了启用图表交互功能,比如缩放和平移,可以使用魔法命令%matplotlib qt。之后的图表将在外部 Qt 窗口中呈现,并附带一个工具栏。要恢复内联绘图,请使用魔法命令%matplotlib inline

注意

在 Jupyter Notebook 中,你还可以使用 %matplotlib notebook 来启用单元格内交互。这可能会导致绘图时出现一些延迟,因为渲染是在服务器端完成的。

现在,导入 NumPy 并使用它生成一个简单的 1D 数组用于绘图:

In [2]: import numpy as np

In [3]: data = np.arange(5, 10) In [4]: data
Out[4]: array([5, 6, 7, 8, 9])

要绘制数据,将其传递给命名恰当的 plot() 方法:

In [5]: plt.plot(data);

行尾的分号抑制了Figure对象名称的显示,而这个名称并不需要。你现在应该能在控制台看到图 19-3。

Image

图 19-3:一个简单的自动生成的线性图

有两点需要注意:在代码中我们没有显式地引用 FigureAxes 对象,因为 pyplot 在幕后处理了这些问题。我们也没有指定要在图中显示哪些元素,包括沿 x 轴和 y 轴显示的刻度和数值。相反,Matplotlib 根据你的数据智能地选择了你需要的图表类型并进行了注解。

按照这种方式,plot() 方法绘制折线图,scatter() 绘制散点图,bar() 绘制条形图,hist() 绘制直方图,pie() 绘制饼图,等等。我们将在接下来的章节中深入了解这些内容,你也可以访问 matplotlib.org/stable/plot_types/index

这些方法的自动化特性在您想快速探索数据集时非常有用,但生成的图形通常过于简单,不适合用于演示或报告。一个问题是,像plt.plot()这样的默认方法假设您希望每个坐标轴的大小与输入数据的范围相匹配(例如,当数据仅限于 5 到 8 之间时,x 轴范围从 5 到 8,而不是从 0 到 10)。它还假设您不需要图例、标题或坐标轴标签,并且希望线条和标记绘制为蓝色。实际情况并非总是如此,因此 pyplot 提供了许多方法来为图表添加标题、坐标轴标签、网格等内容。接下来,我们将查看这些方法。

使用 pyplot 方法创建和操作图形

尽管被认为比面向对象的风格更简单,pyplot 仍然可以生成一些非常复杂的图形。为了演示这一点,让我们使用一些 pyplot 方法创建一个比 图 19-3 中展示的图形更复杂的图形。

悬链线是链条在两端悬挂时所呈现的形状。这是一种在自然和建筑中常见的形状,例如风压下的方形帆和密苏里州圣路易斯的著名拱门。您可以通过在控制台窗口中输入以下代码来生成悬链线,其中 cosh(x) 表示 x 值的双曲余弦:

In [6]: import numpy as np

In [7]: x = np.arange(-5, 5, 0.1)

In [8]: y = np.cosh(x)

现在,让我们使用宽度为 3 的黑色线条绘制悬链线,并添加标题、坐标轴标签、坐标轴值的限制以及背景网格。在前六行代码之后,务必按下 CTRL-ENTER,以防止图形过早生成。最后一行之后,您可以按下 ENTER。

In [9]: plt.title('A Catenary')
   ...: plt.xlabel('Horizontal Distance')
   ...: plt.ylabel('Height')
   ...: plt.xlim(-8, 8)
   ...: plt.ylim(0, 60)
   ...: plt.grid()
   ...: plt.plot(x, y, lw=3, color='k');

对于线条颜色,'k' 是“黑色”的单字符简写符号。您可以在 matplotlib.org/stable/tutorials/colors/colors.html 查看更多颜色选择,并在 matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html 上查看 plot() 的更多参数。您的输出应该如下所示:图 19-4。

在 Matplotlib 中,渲染在图形画布上的元素,例如标题、图例或线条,称为 Artist 对象。标准的图形对象,如矩形、圆形和文本,称为 原始 Artist。保存这些原始对象的容器,如 FigureAxesAxis 对象,称为 容器 Artist

Image

图 19-4:悬链线的线图

一些常用的 pyplot 方法用于制作图形和操作 Artists,列在 表 19-1 和 19-2 中。要查看完整列表,请访问 matplotlib.org/stable/api/pyplot_summary.html。点击此在线列表中的方法名称将跳转到有关方法参数和示例应用的详细信息。要了解有关 Artists 的更多内容,请访问 matplotlib.org/stable/tutorials/intermediate/artists.html

表 19-1: 创建图形的常用 pyplot 方法

方法 描述 示例
bar 制作条形图 plt.bar(x, height, width=0.8)
barh 制作水平条形图 plt.barh(x, height)
contour 绘制等高线图 plt.contour(X, Y, Z)
contourf 绘制填充的等高线图 plt.contourf(X, Y, Z, cmap='Greys')
hist 制作二维直方图 plt.hist(x, bins)
pie 显示饼图 plt.pie(x=[8, 80, 9], labels=['A', 'B', 'C'])
plot 绘制数据线/标记 plt.plot(x, y, 'r+') # 红色交叉点
Polar 制作极坐标图 plt.polar(theta, r, 'bo') # 蓝色点
Scatter 绘制散点图 plt.scatter(x, y, marker='o')
stem 绘制到 y 坐标的垂直线 plt.stem(x, y)

表 19-2: 操作图形的常用 pyplot 方法

方法 描述 示例
annotate Axes 添加文本和箭头 plt.annotate('text', (x, y))
axis 设置坐标轴属性(最小值,最大值) plt.axis([xmin, xmax, ymin, ymax])
axhline 添加一条水平线 plt.axhline(y_loc, lw=5)
axvline 添加一条垂直线 plt.axvline(x_loc, lw=3, c='red')
close 关闭图形 plt.close()
draw 如果交互模式关闭,则更新 plt.draw()
figure 创建或激活一个图形 plt.figure(figsize=(4.0, 6.0))
grid 添加网格线 plt.grid()
imshow 将数据显示为图像 pic = plt.imread('img.png')``plt.imshow(pic, cmap='gray'))
legend 在坐标轴上放置图例 plt.plot(data, label='Data')``plt.legend()
loglog 对每个轴使用对数刻度 plt.loglog()
minorticks_off 移除坐标轴的次刻度 plt.minorticks_off()
minorticks_on 显示坐标轴上的次刻度 plt.minorticks_on()
savefig 保存为 .jpg, .png, .pdf 等格式 plt.savefig('filename.jpg')
semilogx 对 x 轴使用对数刻度 plt.semilogx()
semiology 对 y 轴使用对数刻度 plt.semilogy()
set_cmap 设置颜色映射 plt.set_cmap('Greens')
show 在终端或交互模式关闭时显示图形 plt.show()
subplot 在图形上创建子图 plt.subplot(nrows, ncols, index)
text Axes中添加文本 plt.text(x, y, 'text')
tight_layout 调整子图间的填充 plt.tight_layout(pad=3)
title Axes添加标题 plt.title('text')
xkcd 打开 xkcd 草图风格* plt.xkcd()
xlabel 设置 x 轴标签 plt.xlabel('text')
xlim 设置 x 轴限制 plt.xlim(xmin, xmax)
xticks 设置刻度信息 plt.xticks([0, 2], rotation=30)
ylabel 设置 y 轴标签 plt.ylabel('text')
ylim 设置 y 轴限制 plt.ylim(ymin, ymax)
yticks 设置刻度信息 plt.yticks([0, 2], rotation=30)
*为了最佳效果,应安装 Humor Sans 字体。

请注意,表格中的代码示例代表简单的情况。大多数方法接受多个参数,使你可以精细调整图表的属性,如字体样式和大小、线宽和颜色、旋转角度、爆炸视图等(参见matplotlib.org/stable/api/pyplot_summary.html)。

与子图的工作

到目前为止,我们一直在处理单个图形,但有时你可能希望将两个图表并排比较,或者将多个图表打包成一个汇总显示。在这些情况下,Matplotlib 提供了subplot()方法。

要了解这个是如何工作的,我们首先生成两条不同的正弦波数据:

In [10]: time = np.arange(-12.5, 12.5, 0.1)

In [11]: amplitude = np.sin(time)

In [12]: amplitude_halved = np.sin(time) / 2

比较这些波形的一种方法是将它们绘制在同一个Axes中,如下所示:

In [13]: plt.plot(time, amplitude, label='sine1')
    ...: plt.plot(time, amplitude_halved, lw=3, ls='--', label='sine2')
    ...: plt.legend();

这会产生图 19-5 中的输出。默认情况下,曲线在 Qt 控制台中使用不同的颜色绘制,但因为这是一本黑白书籍,我们使用了不同的线宽(lw)和线条样式(ls)来区分amplitude_halved数据和amplitude数据。plt.plot()中的label参数也允许使用图例。有关标记和线条样式的字符列表,请访问matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html

Image

图 19-5:两条正弦波绘制在同一个 Axes 对象中

如果你要比较的曲线多于几条,单个图表可能会变得混乱,难以阅读。在这种情况下,你可能希望使用由subplot()方法创建的独立堆叠图表。图 19-6 描述了此方法的语法,其中四个子图(Axes)被放置在同一个Figure容器中。

Image

图 19-6:理解 subplot()方法

子图将按网格排列,传递给subplot()方法的前两个参数指定该网格的尺寸。第一个参数表示网格的行数,第二个参数表示列数,第三个参数是活动子图的索引(在图中以灰色高亮显示)。

活动子图是你在调用像 plot()scatter() 这样的函数时当前正在绘制的子图。与 Python 中的大多数情况不同,索引从 1 开始,而不是 0。

Matplotlib 使用一个名为 当前图形 的概念来追踪当前正在绘制的 Axes。例如,当你调用 plt.plot() 时,pyplot 会创建一个新的“当前图形” Axes 来绘制。这就是为什么在控制台工作时你必须按 CTRL-ENTER。一旦按下 ENTER,图形就完成了,一个新的“当前图形”会被排队。当你处理多个子图时,index 参数告诉 pyplot 哪个子图代表“当前图形”。

注意

为了方便起见,你不需要在 subplot() 参数中使用逗号。例如,plt.subplot(223) 和 plt.subplot(2, 2, 3) 效果相同,尽管前者的可读性较差。

现在,让我们将正弦波绘制为两个独立的堆叠图。过程是调用 subplot() 方法并修改其活动子图参数,以更改当前子图。对于每个当前子图,plot() 方法会将该子图特定的数据绘制出来,如下所示:

In [14]: plt.subplot(2, 1, 1)
    ...: plt.plot(time, amplitude, label='sine1')
    ...: plt.legend(loc='upper right')
    ...: 
    ...: plt.subplot(2, 1, 2)
    ...: plt.ylim(-1, 1)
    ...: plt.plot(time, amplitude_halved, label='sine2')
    ...: plt.legend(loc='best');

请注意,如果你没有设置第二个图的 y 限制,pyplot 会自动缩放图形,使两个子图看起来相同。因为我们使用 ylim() 方法手动设置了缩放,所以可以清楚地看到第二个正弦波的振幅是第一个的一半(图 19-7)。

Image

图 19-7:正弦波显示在两个水平子图中

这些图形看起来有点拥挤。让我们通过调用 tight_layout() 方法并传递一个 pad 值来为它们腾出一些空间。pad 值越大,子图之间的间隔就越大,但也有一个最大限制。还可以使用其他参数来微调显示;例如,使用 h_padw_pad 来调整相邻子图之间边缘的高度和宽度。

使用方向键调出前一个代码,并添加 tight_layout() 方法,如下所示:

In [15]: plt.subplot(2, 1, 1)
    ...: plt.plot(time, amplitude, label='sine1')
    ...: plt.legend(loc='upper right')
    ...: 
    ...: plt.subplot(2, 1, 2)
    ...: plt.ylim(-1, 1)
    ...: plt.plot(time, amplitude_halved, label='sine2')
    ...: plt.legend(loc='best')
    ...: 
    ...: plt.tight_layout(pad=2)

这将生成图形 图 19-8。现在可以清楚地看到哪个 x 轴对应哪个子图。

Image

图 19-8:调用 tight_layout() 后的结果

你刚刚看到 subplot() 方法如何让你将一个图形细分为不同的绘图区域,并将绘图命令集中在单个子图上。为了帮助你管理更复杂的图形,Matplotlib 提供了 GridSpec 类,我们接下来会了解它。

测试你的知识

  1. Axes 对象表示:

a. 绘图的 x、y 和 z 轴

b. 绘图的单独元素,例如标题和图例

c. 单个图形元素的容器

d. 一个空白画布

  1. 对还是错:对于复杂的图形,以及那些打算作为更大项目的一部分进行重用的方法和脚本,Matplotlib 文档建议你使用面向对象的风格。

  2. 哪段代码生成一个宽 4 列高 3 行的子图网格,并激活第二个子图?

a. plt.subplot(3, 4, 1)

b. plt.subplots(3, 4, 2)

c. plt.subplot(4, 3, 2)

d. plt.subplot(342)

  1. %matplotlib qt魔法命令用于:

a. 在控制台中启用图形显示

b. 允许在 Jupyter Notebook 中进行交互式图形操作

c. 打开一个具有交互控件的外部窗口

d. 在使用外部窗口后恢复内联图形

  1. 使用以下数据创建一个火箭高度的 Python 字典:Atlas: 57, Falcon9: 70, Saturn V: 111, Starship: 120。绘制一个柱状图,y 轴标注为米单位,并将柱宽设置为 0.3。

使用 GridSpec 构建多面板显示

matplotlib.gridspec模块包含一个GridSpec类,允许你将一个Figure划分为多个子区域。这有助于你创建具有不同宽度和高度的子图。最终的多面板显示对于在演示和报告中总结信息非常有用。

构建火星多面板显示

让我们通过一个例子来操作:假设你正在研究火星上的一个古老湖床。你想总结一些关于赤铁矿、菱铁矿和黄铁矿的发现,这三种铁矿物与水环境有关。你已经草拟了一个汇编图的布局(图 19-9),现在你想用 Matplotlib 来创建它。

Image

图 19-9:火星研究汇总图的草图(由表 19-2 中的 xkdc()方法生成)

注意

如果你想保存代码,你可以在 Spyder 文本编辑器或 Jupyter Notebook 中创建这个项目,而不是在控制台中。你已经在第四章、第五章和第六章的这些应用程序中使用过 Matplotlib。如果你在 Jupyter Notebook 中工作,所有定义图形的代码应该包含在同一个单元格内。

首先,如果你还没有这样做,导入 NumPy 和 Matplotlib:

In [16]: import numpy as np

In [17]: import matplotlib.pyplot as plt

现在,调用GridSpec来创建一个 3×3 的网格,并将结果对象赋值给名为gs的变量,代表网格规范。在控制台中,输入此语句后,使用 CTRL-ENTER,我们将开始定义子图:

In [18]: gs = plt.GridSpec(3, 3)

上述代码创建了一个包含三行三列的网格。要将子图放置在此网格中,你需要索引gs对象。与subplot()方法不同,索引从 0 开始,而不是从 1 开始。

图 19-10 展示了子图位置及其网格索引。要放置左上角的子图(用于展示直方图,如图 19-9 所示),使用 gs[0, :2]。这表示第一行 [0] 和第一、第二列 [:2],因此该子图跨越第一行的前两列。同样,gs[:2, 2] 跨越第三列的前两行,而 gs[2, 1] 将子图放置在第三行的中间。

Image

图 19-10:带有网格规格索引标注的图 19-9 草图子图

在构建总结图中的子图之前,您需要使用图 19-10 中的索引指定它的网格位置。现在我们为直方图做这个操作。由于我们没有真实的火星数据,因此我们将使用从正态分布中抽取的虚拟数据集(使用 NumPy 的 random.normal() 方法):

   ...: 
   ...: plt.subplot(gs[0,:2])
   ...: plt.title('Goethite Distribution Location 1')
   ...: plt.hist(np.random.normal(0.22, 0.02, size=500), bins=5)
   ...:

np.random.normal() 方法的参数包括均值、标准差和从正态分布中抽取的样本数。plt.hist() 方法使用这个输出,以及直方图的分箱数。

这将生成图 19-11 中的图表,尽管直到整个绘图完成之前,你是看不到这个图的。由于直方图数据是随机生成的,因此你的视图可能与此略有不同。

Image

图 19-11:直方图子图

接下来,我们将在直方图下方构建等高线图。请注意,我们可以按照任何顺序构建子图,但遵循逻辑顺序可以让后续修改代码时更容易。像往常一样,首先通过 plt.subplot() 定位子图在网格上的位置:

   ...: plt.subplot(gs[1, :2])
   ...: plt.title('Goethite Concentration Location 1')
   ...: plt.text(1.3, 1.6, ➊ 'o--Sample A')
   ...: x, y = np.arange(0, 3, 0.1), np.arange(0, 3, 0.1)
   ...: X, Y = ➋ np.meshgrid(x, y)
   ...: Z = np.absolute(np.cos(X * 2 + Y) * 2 + np.sin(Y + 3))
   ...: plt.contourf(X, Y, Z, cmap='Greys')
   ...: plt.colorbar() 
   ...:

为了演示如何在图形上放置文本,添加一个标注,标明样本 A ➊ 的位置。使用的 text() 方法至少需要 x, y 坐标和文本字符串。字符串中的圆圈和线条部分(o--)表示指向样本位置的指针。text() 方法还有许多其他参数,包括 fontsizecolorrotation 等。

接下来,使用 NumPy 生成一些虚拟坐标和 网格网格 ➋。meshgrid() 方法基于给定的两个一维数组创建一个矩形网格,这些数组表示笛卡尔坐标或矩阵索引。通过这个网格,我们可以使用一个方程生成相应的 Z 值。调用 pyplotcontourf() 方法并传入坐标和灰度色图,可以生成填充的等高线图。最后,展示色标。

这段代码将生成类似于图 19-12 所示的地图。如果你想让它更炫酷,可以使用箭头艺术家指向样本位置(请参见 matplotlib.org/stable/tutorials/text/annotations.html#annotating-with-arrow/)。

Image

图 19-12:等高线图子图

接下来,我们将生成图 19-9 右上角的散点图。这将绘制位置 1 的赤铁矿浓度与菱铁矿浓度的关系。首先分配网格位置,然后添加标题以及 x 轴和 y 轴的标签。

   ...: plt.subplot(gs[:2, 2])
   ...: plt.title('Loc1 Goe-Hem Ratio')
   ...: plt.xlabel('Hematite mg')
   ...: plt.ylabel('Goethite mg')
   ...: plt.scatter(np.random.normal(3, 1, 30), np.random.uniform(1, 30, 30))
   ...:

要生成散点图,你需要将plt.scatter()方法传递一个 x、y 值的序列。在本例中,我们将使用 NumPy 的正态分布和均匀分布方法随机生成这些值。对于正态分布方法,参数是均值、标准差和抽样数量。对于均匀分布方法,参数分别表示低值和高值以及抽样数量。

这最终会生成图 19-13 中的子图。同样,由于数据是随机生成的,每个散点图都会看起来不同。

注意

对于包含几千个数据点以上的数据集,将标记类型传递给 plt.plot()比使用 plt.scatter()更高效。原因是 plt.plot()将点渲染为克隆,而 plt.scatter()则单独渲染每个点,以便调整标记的大小以反映数据值或区分数据集。

图片

图 19-13:散点图子图

现在,让我们构建三个饼图,记录样本 A、B 和 C 中赤铁矿、菱铁矿和黄铁矿的百分比。我们将这些饼图沿汇总图的底部排列。

每个饼图将使用相同的扇区标签(表示图表中的类别),因此这些标签应在一开始就分配,以避免重复代码。此外,我们将使用plt.pie()方法的explode参数来分开饼图的扇区。为了指定扇区之间的间隙大小,我们将使用一个名为explode的列表,该列表通过稍微将黄铁矿扇区从其余部分拉出,来突出显示它:

    ...: labels = 'Goethite', 'Hematite', 'Jarosite'
    ...: explode = [0.1, 0.1, 0.2]
    ...:

要制作饼图,需要将plt.pie()方法传递labels(表示图表中的类别)、sizes(表示每个类别的百分比)和explode列表:

   ...: plt.subplot(gs[2, 0])
   ...: plt.title('Sample A')
   ...: sizes = [35, 55, 10]
   ...: plt.pie(sizes, labels=labels, explode=explode)
   ...: 
   ...: plt.subplot(gs[2, 1])
   ...: plt.title('Sample B')
   ...: sizes = [35, 45, 20]
   ...: plt.pie(sizes, labels=labels, explode=explode)
   ...: 
   ...: plt.subplot(gs[2, 2])
   ...: plt.title('Sample C')
   ...: sizes = [35, 35, 30]
   ...: plt.pie(sizes, labels=labels, explode=explode)
   ...:

通过调用tight_layout()方法来完成图形,以在子图之间添加一些间距。完成此最后一行后,如果你在控制台中,按 ENTER 或 SHIFT-ENTER 生成最终的多面板图形,你可以在图 19-14 中查看它。

    ...: plt.tight_layout();

图片

图 19-14:最终的多面板汇总图

由于GridSpec,汇总显示包含了跨越多行和多列的子图。

更改子图的宽度和高度

在某些范围内,你可以设置GridSpec生成的行和列的宽度和高度。你可以通过width_ratiosheight_ratios参数来实现,这些参数接受一个数字列表。只有这些数字之间的比例才重要。例如,要为我们的 3×3 网格设置每列的宽度比例,[1, 2, 4][2, 4, 8]是等效的。

为了演示,输入以下代码来修改我们的火星多面板显示:

In [19]: widths = [2, 3, 2]

In [20]: heights = [2, 10, 3]

widths列表处理列宽,从索引 0 开始。heights列表则重复这一过程,用于行高。

现在,调出上一节的代码(如果在控制台中,使用箭头键),并编辑plt.GridSpec的调用,如下所示:

In [21]: gs = plt.GridSpec(3, 3, width_ratios=widths, height_ratios=heights)

重新运行代码,你应该能在图 19-15 中看到图形。注意像是较短的直方图和较高的等高线图等变化。

图片

图 19-15:具有新行和列宽度及高度的多面板显示

要了解更多关于GridSpec的信息并查看一些示例用法,请访问matplotlib.org/stable/api/_as_gen/matplotlib.gridspec.GridSpec.html。有关pyplot方法的教程,请参阅matplotlib.org/stable/tutorials/introductory/pyplot.html

使用面向对象风格

面向对象的绘图风格通常需要比之前描述的pyplot方法更多的代码,但它能让你最大限度地利用 Matplotlib。通过显式创建FigureAxes对象,你将能更轻松地控制图形,更好地理解与其他库的交互,创建具有多个 x 轴和 y 轴的图形,等等。

注意

如果你熟悉面向对象编程,你会更欣赏面向对象风格。这个编程范式在第十三章中有详细介绍。

为了熟悉面向对象风格,我们重新创建图 19-3 中的简单图形。如果你正在使用 Qt 控制台,现在请重新启动它。

不管使用哪种绘图方法,Matplotlib 的import语句保持不变:

In [22]: import matplotlib.pyplot as plt

In [23]: import numpy as np

现在,使用 NumPy 重新生成数据集:

In [24]: data = np.arange(5, 10)

要开始使用面向对象的风格,输入以下内容并在控制台中按 CTRL-ENTER:

In [25]: fig, ax = plt.subplots()

一旦在程序中看到这行代码,你就知道你正在处理面向对象风格。plt.subplots()方法创建一个Figure实例和一组子图(一个Axes对象的 NumPy 数组)。如果没有指定子图的数量,默认返回一个子图。因为返回了两个对象,你需要将结果解包到两个变量中,按照约定命名为figax。记住,在pyplot方法中,这两个实体是在幕后创建的。

为了显示图形,添加以下行并按 ENTER:

    ...: ax.plot(data);

这将生成与图 19-3 中相同的图形,正如图 19-16 所示。

图片

图 19-16:使用面向对象风格生成的简单折线图

因为你将图形分配给了fig变量,所以你可以通过简单地在控制台中输入fig来重新生成它:

In [26]: fig

面向对象的绘图风格其实并不神秘。关键在于将pyplot创建的FigureAxes对象赋值给变量。你将不再获得pyplot的自动化特性,但作为回报,你将打开一扇定制图表的对象属性和方法的大门。

使用面向对象风格创建和操作图表

为了更好地理解面向对象风格,让我们用它重新创建《使用 pyplot 方法创建和操作图表》中的悬链线示例,见第 542 页。为了展示该风格的一些增强功能,我们将强制 y 轴穿过图表的中心。

如果你正在使用 Qt 控制台,现在请重新启动内核。然后,导入 NumPy 和 Matplotlib,并重新生成悬链线数据:

In [27]: import numpy as np

In [28]: import matplotlib.pyplot as plt

In [29]: x = np.arange(-5, 5, 0.1)

In [30]: y = np.cosh(x)

要创建单个图表,输入以下内容,然后按 CTRL-ENTER(在控制台中):

In [31]: fig, ax = plt.subplots()

接下来,调用AXES对象的set()方法,并传递关键字参数来设置标题、坐标轴标签和坐标轴范围。这个便捷方法可以让你一次性设置多个属性,而无需为每个属性单独调用特定的方法。你可以使用一行长代码,或者在每个逗号后按 ENTER 键,生成一个更具可读性的垂直堆叠,如下所示:

    ...: ax.set(title='A Catenary', 
    ...:        xlabel='Horizontal Distance', 
    ...:        ylabel='Height',
    ...:        xlim=(-8, 8.1),
    ...:        ylim=(0, 60))

现在让我们将 y 轴移动到图表的中心,而不是沿着一侧。在 Matplotlib 中,脊柱是连接坐标轴刻度线并标记包含绘制数据区域边界的线。这些脊柱默认位于图表的四周,刻度和标签位于左侧和底部边缘(见图 19-16)。但是,脊柱也可以放置在任意位置。通过面向对象风格,我们可以使用Spine子类的set_position()方法来实现这一点。

以下代码首先将左侧(y 轴)移动到 x 轴上的 0 值。然后,设置线条宽度为2,使得该坐标轴能够稍微从背景网格中突出,背景网格稍后将被使用:

 ...: ax.spines.left.set_position('zero')
 ...: ax.spines.left.set_linewidth(2)

以下行通过将颜色设置为无,关闭绘图的右边界:

 ...: ax.spines.right.set_color('none')

接下来的三行分别重复此过程,用于底部坐标轴和顶部坐标轴:

 ...: ax.spines.bottom.set_position('zero')
 ...: ax.spines.bottom.set_linewidth(2)
 ...: ax.spines.top.set_color('none')

为了完成绘图,添加背景网格并调用绘图方法,传入 x 和 y 数据,将线条宽度设置为3,颜色设置为黑色('k'):

    ...: ax.grid()
    ...: ax.plot(x, y, lw=3, color='k');

这将生成图 19-17 中的图表。

Image

图 19-17:使用面向对象风格构建的悬链线折线图

如果你省略与坐标轴脊柱相关的代码,你可以使用与pyplot方法相同的代码量,重新生成图 19-4 中的图表。因此,面向对象风格的冗长与其能够提供更多功能密切相关,人们通常会利用这一点。

使用 pyplot 方法的对象导向风格有等效的方法。不幸的是,方法名称往往不同。例如,pyplot 中的 title() 在面向对象风格中变为 set_title()xticks() 变为 set_xticks()。这也是为什么最好选择一种风格并坚持使用它的原因之一。

一些常用的面向对象绘图方法列在表 19-3 中。你还可以在 matplotlib.org/stable/plot_types/index.html 和本章中 Matplotlib 画廊的第 538 页找到更多方法,如绘制箱形图、小提琴图等。

表 19-3: 用于创建图形的有用面向对象方法

方法 描述 示例
bar 绘制条形图 ax.bar(x, height)
barh 绘制水平条形图 ax.barh(x, height)
contour 绘制等高线图 ax.contour(X, Y, Z)
contourf 绘制填充的等高线图 ax.contourf(X, Y, Z, cmap='Greys')
hist 绘制二维直方图 ax.hist(x, bins)
pie 显示饼图 ax.pie(x=[8, 80, 9], labels=['A', 'B', 'C'])
plot 绘制数据为线条/标记 ax.plot(x, y, 'r+') # 红色叉号
polar 绘制极坐标图 fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})``ax.plot(theta, r, 'bo') # 蓝色圆点
scatter 绘制散点图 ax.scatter(x, y, marker='o')
stem 绘制垂直线到 y 坐标 ax.stem(x, y)

处理 FigureAxes 对象的常见方法列在表 19-4 和表 19-5 中。在很多情况下,这些方法的作用类似于表 19-2 中的 pyplot 方法,尽管方法名称可能不同。

表 19-4: 用于处理 Figure 对象的有用面向对象方法

方法 描述 示例
add_subplot 添加或获取 Axes ax = fig.add_subplot(2, 2, 1)
close() 关闭一个图形 plt.close(fig2)
colorbar Axes 添加颜色条 fig.colorbar(image, ax=ax)
constrained_layout 自动调整子图布局 fig, ax = plt.subplots(constrained_layout=True)
gca 获取当前图形中的当前 Axes 实例 fig.gca()
savefig 保存为 .jpg.png.pdf 等格式 fig.savefig('filename.jpg')
set_size_inches 设置 Figure 的大小(以英寸为单位) fig.set_size_inches(6, 4)
set_dpi 设置 Figure 的每英寸点数 fig.set_dpi(200) # 默认是 100.
show 显示图形,适用于终端运行或当交互模式关闭时 plt.show()
subplots 创建带有 AxesFigure fig, ax = plt.subplots(2, 2)
suptitle Figure 添加超级标题 fig.suptitle('text')
tight_layout 自动调整子图适应 fig.tight_layout()

表 19-5: 操作 Axes 对象的有用面向对象方法

方法 描述 示例
annotate Axes 添加文本和箭头 ax.annotate('text', xy=(5, 2))
axis 获取或设置轴的属性 ax.axis([xmin, xmax, ymin, ymax])
axhline 添加水平线 ax.axhline(y_loc, lw=5)
axvline 添加垂直线 ax.axvline(x_loc, lw=3, c='red')
grid 添加网格线 ax.grid()
imshow 以图像形式显示数据 pic = plt.imread('img.png')``ax.imshow(pic, cmap='gray'))
legend Axes 上放置图例 ax.plot(data, label='Data')``ax.legend()
loglog 在每个轴上使用对数缩放 ax.loglog()
minorticks_on 显示轴的次刻度 ax.yaxis.get_ticklocs(minor=True)``ax.minorticks_on()
minorticks_off 移除轴的次刻度 plt.minorticks_off()
semilogx 对 x 轴使用对数缩放 ax.semilogx()
semiology 对 y 轴使用对数缩放 ax.semilogy()
set 一次设置多个属性 ax.set(title, ylabel, xlim, alpha)
set_title() 设置 Axes 标题 ax.set_title('text', loc='center')
set_xticks() 设置 x 轴刻度标记 xticks = np.arange(0, 100, 10) ax.set_xticks(xticks)
set_yticks() 设置 y 轴刻度标记 yticks = np.arange(0, 100, 10) ax.set_yticks(yticks)
set_xticklabels 在调用 set_xticks() 后设置 x 轴标签 labels = [a', 'b', 'c', 'd']``ax.set_xticklabels(labels)
set_yticklabels 在调用 set_yticks() 后设置 y 轴标签 ax.set_yticklabels([1, 2, 3, 4])
tick_params 更改刻度、标签和网格 ax.tick_params(labelcolor= 'red')
twinx 创建一个新的 y 轴,和 x 轴共享 ax.twinx()
twiny 创建一个新的 x 轴,和 y 轴共享 ax.twiny()
set_xlabel() 设置 x 轴标签 ax.set_xlabel('text', loc='left')
set_ylabel() 设置 y 轴标签 ax.set_ylabel('text', loc='top')
set_xlim() 设置 x 轴的范围 ax.set_xlim(-5, 5)
set_ylim() 设置 y 轴的范围 ax.set_ylim(0, 10)
set_xscale() 设置 x 轴的尺度 ax.set_xscale('log')
set_yscale() 设置 y 轴的尺度 ax.set_yscale('linear')
text Axes 添加文本 ax.text(x, y, 'text')
xaxis.grid() 添加 x 轴网格线 ax.xaxis.grid(True, which='major')
yaxis.grid() 添加 y 轴网格线 ax.yaxis.grid(True, which='minor')

如在 pyplot 部分中提到的,这些表格中的代码示例代表的是简单的案例。大多数方法接受多个参数,使您能够微调图表的属性,例如字体样式和大小、线宽和颜色、旋转角度、爆炸视图等。要了解更多内容,请访问 Matplotlib 文档:matplotlib.org/

与子图的操作

pyplot方法类似,面向对象风格支持使用子图(参见第 545 页的“与子图一起工作”)。虽然有多种方法可以将子图分配给FigureAxes对象,但plt.subplots()方法非常方便,返回一个 NumPy 数组,允许你使用标准索引或使用诸如axs[0, 0]ax1之类的唯一名称选择子图。另一个好处是,你可以在绘制任何数据之前预览子图的几何形状。

注意

面向对象方法创建子图的函数是 subplots,而 pyplot 方法使用 subplot。

调用没有参数的plt.subplots()会生成一个空的单一图表(图 19-18)。从技术上讲,这会生成一个 1×1 的AxesSubplot对象。

In [32]: fig, ax = plt.subplots()

Image

图 19-18:使用面向对象风格的 subplots()方法生成的空图表

生成多个子图类似于plt.subplot()方法,只是没有用于活动子图的索引参数。第一个参数表示行数;第二个参数表示列数。通常,多个Axes被赋予复数形式的名称axs,而不是axes,以避免与单个Axes实例混淆。

传递两个参数给plt.subplots()方法可以控制子图的数量及其几何形状。以下代码生成了图 19-19 中所示的 2×2 子图网格,并将两个AxesSubplot对象的列表存储在axs变量中:

In [33]: fig, axs = plt.subplots(2, 2)
    ...: axs
Out[33]: 
array([[<AxesSubplot:>, <AxesSubplot:>],
         [<AxesSubplot:>, <AxesSubplot:>]], dtype=object)

Image

图 19-19:2×2 排列中的四个子图

要激活一个子图,你可以使用它的索引。在这个例子中,我们绘制第一行第二个子图,生成图 19-20:

In [34]: fig, axs = plt.subplots(2, 2)
    ...: axs[0, 1].plot([1, 2, 3]);

Image

图 19-20:使用子图索引[0, 1]进行绘图

或者,你可以通过使用元组拆包来单独命名和存储多个Axes的子图。每一行的子图需要放在自己的元组中。然后,你可以使用名称选择子图,而不是使用不易读的索引。以下代码重现了图 19-20:

In [35]: fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2)
    ...: ax2.plot([1, 2, 3]);

最后,subplots()方法还接受其他关键词参数,包括图形关键词,允许你进行一些操作,比如在图表间共享坐标轴、调整图形的大小和布局等(图 19-21):

In [36]: fig, axs = plt.subplots(ncols=2, 
    ...:                         nrows=2, 
    ...:                         sharex=True,
    ...:                         sharey=True,
    ...:                         figsize=(6, 4),
    ...:                         tight_layout=True)

欲了解更多关于这些关键词的信息,请参阅该方法的文档,地址为 matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.subplots.html

Image

图 19-21:共享 x 轴和 y 轴的 2×2 子图网格

使用 GridSpec 构建多面板显示

matplotlib.gridspec模块(参见“使用 GridSpec 构建多面板显示”部分,见第 549 页)也适用于面向对象的风格。现在让我们使用它来重现图 19-14 中的火星多面板显示。这将让你直接对比pyplot方法和面向对象方法的差异。

重建火星多面板显示

为了重新开始,可以在已打开的控制台中重启内核(KernelRestart Current Kernel)或退出并重新打开控制台。如果是重启,请使用 CTRL-L 清空窗口。在 Jupyter Notebook 中,使用KernelRestart & Clear Output进行重启。

现在,导入 NumPy 和 Matplotlib,并使用GridSpec设置一个 3×3 的网格。在控制台中,在In [39]这一行后按 CTRL-ENTER,以防止生成图表(在 Notebook 中,使用 ENTER 键):

In [37]: import numpy as np

In [38]: import matplotlib.pyplot as plt

In [39]: fig = plt.figure()
    ...: gs = fig.add_gridspec(3, 3)
    ...:

接下来,构建直方图子图。命名为ax1并使用图 19-10 中的网格索引来定位它:

    ...: ax1 = fig.add_subplot(gs[0, :2])
    ...: ax1.set_title(' Goethite Distribution Location 1')
    ...: ax1.hist(np.random.normal(0.22, 0.02, size=500), bins=5)

在控制台中继续使用 CTRL-ENTER,构建等高线图,方法如下:

    ...: ax2 = fig.add_subplot(gs[1, :2])
    ...: ax2.set_title('Goethite Concentration Location 1')
    ...: ax2.annotate('o--Sample A', xy=(1.3, 1.6))
    ...: x, y = np.arange(0, 3, 0.1), np.arange(0, 3, 0.1)
    ...: X, Y = np.meshgrid(x, y)
    ...: Z = np.absolute(np.cos(X * 2 + Y) * 2 + np.sin(Y + 3))
    ...: contour_map = ax2.contourf(X, Y, Z, cmap='Greys')
    ...: fig.colorbar(contour_map)
    ...:

接下来,我们将构建显示区域右上角的散点图:

    ...: ax3 = fig.add_subplot(gs[:2, 2])
    ...: ax3.set_title('Loc1 Goe-Hem Ratio')
    ...: ax3.set_xlabel('Hematite mg')
    ...: ax3.set_ylabel('Goethite mg')
    ...: ax3.scatter(np.random.normal(3, 1, 30), 
    ...:                np.random.uniform(1, 30, 30))
    ...:

添加饼状图楔形标签和楔形之间间隙的代码。这减少了代码重复,因为这些变量对于所有图表都是相同的:

     ...: labels = 'Goethite', 'Hematite', 'Jarosite'
    ...: explode = [0.1, 0.1, 0.2]
    ...:

完成所有图表后,调用Figure对象的tight_layout()方法,以防止图表彼此重叠。在控制台中按 ENTER 或 SHIFT-ENTER 生成显示效果,在 Jupyter Notebook 中按 CTRL-ENTER:

    ...: ax4 = fig.add_subplot(gs[2, 0])
    ...: ax4.set_title('Sample A')
    ...: sizes = [35, 55, 10]
    ...: ax4.pie(sizes, labels=labels, explode=explode)
    ...: 
    ...: ax5 = fig.add_subplot(gs[2, 1])
    ...: ax5.set_title('Sample B')
    ...: sizes = [35, 45, 20]
    ...: ax5.pie(sizes, labels=labels, explode=explode)
    ...: 
    ...: ax6 = fig.add_subplot(gs[2, 2])
    ...: ax6.set_title('Sample C')
    ...: sizes = [35, 35, 30]
    ...: ax6.pie(sizes, labels=labels, explode=explode)
    ...: 
    ...: fig.tight_layout();

请注意,pyplot方法与对象导向方法的主要区别在于一些方法名称的变化,例如title()被替换为set_title(),以及使用了子图名称。最终的显示效果应该与图 19-14 一致,唯一的不同可能是在随机生成的数据上有所变化。

要更改子图的宽度和高度,请参阅“更改子图的宽度和高度”部分,见第 554 页。这个任务在两种绘图方法中是一样的。

matplotlib.gridspec模块为多面板显示中的子图布局提供了很大的控制能力。然而,正如 Python 中的常见做法一样,完成同一任务有多种方法,接下来我们将介绍其中一种替代方法。

GridSpec 的高级替代方法

Matplotlib 库提供了一些高级替代方法来替代使用GridSpec。例如,使用subplot_mosaic()方法,你可以通过逻辑名称(如upper_leftright)来布局网格。然后你可以使用这些名称来索引axs对象,如下所示:

In [40]: fig, axs = plt.subplot_mosaic([['left', 'upper right'],
    ...:                               ['left', 'lower right']],
    ...:                               figsize=(4.5, 3.5),
    ...: tight_layout=True)
    ...: axs['upper right'].set_title('upper right');

这将生成图 19-22 中的显示效果。子图会按照In [40]中分配的顺序排列。

Image

图 19-22:使用 plt.subplot_mosaic()方法创建的子图

要了解更多关于subplot_mosaic()和其他多面板选项的信息,请参阅“处理多个图形和坐标轴”部分,访问matplotlib.org/stable/tutorials/introductory/usage.html#sphx-glr-tutorials-introductory-usage-py/,以及“在图形中排列多个坐标轴”部分,访问matplotlib.org/stable/tutorials/intermediate/arranging_axes.html

插入图

插入图——即在一个图内插入另一个图——对于展示包围图的某部分的细节、同一数据的不同处理方式、数据的地理位置等非常有用。插图类似于子图,但它采用了不同的构建技术。

要创建插图,首先创建一个Figure对象,然后使用add_axes()方法将Axes添加到其中。请在控制台或笔记本中输入以下代码;如果你已经在当前会话中执行了导入操作,可以忽略导入部分:

In [41]: import numpy as np

In [42]: import matplotlib.pyplot as plt

In [43]: %matplotlib inline

In [44]: x = np.arange(0, 25)

In [45]: y = x**3

现在,设置FigureAxes对象。在此例中,ax2表示插入图:

In [46]: fig = plt.figure()    
    ...: ax1 = fig.add_axes([0, 0, 1.0, 1.0]) 
    ...: ax2 = fig.add_axes([0.1, 0.6, 0.4, 0.3])
    ...:

传递给add_axes()方法的看似复杂的列表代表了Axesrect参数。它定义了矩形Axes对象的尺寸。该值的范围从 0 到 1,分别表示矩形的左、下、宽度和高度。

现在创建主图和插图:

    ...: # Main plot
    ...: ax1.plot(x, y, 'k*-')
    ...: ax1.set_xlabel('x')
    ...: ax1.set_ylabel('y')
    ...: 
    ...: # Inset plot
    ...: ax2.plot(x, np.sin(y), 'r*-')
    ...: ax2.set_xlabel('x')
    ...: ax2.set_ylabel('y')
    ...: ax2.set_title('Sine of Y')

你应该得到类似图 19-23 所示的图表。

图片

图 19-23:带有插入图的图表

3D 绘图

尽管 Matplotlib 主要用于 2D 绘图,但它也包括一个Axes3D类,支持 3D 散点图、直方图、表面图、等高线图等。以下是一个示例:

In [47]: import numpy as np
    ...: import matplotlib.pyplot as plt 
    ...: 
    ...: z = np.arange(0, 200, 1)
    ...: x = z * np.cos(25 * z)
    ...: y = z * np.sin(25 * z)
    ...: 
 ➊ ...: ax = plt.figure().add_subplot(projection='3d') 
    ...:
    ...: ax.plot(x, y, z, 'black');

关键是创建Axes对象时传递projection='3d'关键字➊。如果你觉得下面这种两行语法更容易理解,也可以使用:

    ...: fig = plt.figure()
    ...: ax = plt.axes(projection ='3d')

两者将产生如图 19-24 所示的图表。

图片

图 19-24:一个 3D 线图

要了解更多关于 3D 绘图的信息,请访问matplotlib.org/stable/tutorials/toolkits/mplot3d.html

动画图

科学家通常研究动态现象,如洋流和驯鹿迁徙。无论是基于实际观察还是模拟行为,能够在图表中可视化运动(这一过程叫做动画)可以带来深入的洞察,帮助更好地理解这些现象。动画还能够增强演示效果,帮助观众更好地理解你所传达的内容。

正如你所期待的,Matplotlib 提供了多种方式来动画化图表。对于简单的动画,你可以通过在循环中手动更新和绘制变量。为了方便并处理更复杂的动画,你可以使用matplotlib.animation模块 (matplotlib.org/stable/api/animation_api.html).

animation模块包含FuncAnimation类,它通过重复调用一个函数来动画化一个可视化。ArtistAnimation类通过使用固定的一组Artist对象(例如预计算的图像列表)来制作动画。通常,FuncAnimation更易于使用且效率更高。我们在这里不讨论ArtistAnimation

使用 for 循环动画化图表

也许最简单的动画化图表的方法是使用for循环。让我们尝试使用《处理子图》中的正弦波示例,第 545 页。在控制台中输入以下代码,在第一行后使用 CTRL-ENTER,在最后一行后使用 ENTER:

In [48]: import numpy as np
    ...: import matplotlib.pyplot as plt
 ➊ ...: import time 
 ...: %matplotlib qt
    ...: 
    ...: t = np.arange(-12.5, 12.5, 0.1)
    ...: amplitude = np.sin(t)
    ...: 
    ...: fig, ax = plt.subplots()
 ➋ ...: line, = ax.plot(t, amplitude)
    ...: for i in range(30):
    ...:     updated_amp = np.sin(t + i)
 ➌ ...:    line.set_ydata(updated_amp) 
    ...:     fig.canvas.draw()
    ...:     fig.canvas.flush_events()
    ...:     time.sleep(0.1)

开始时,像往常一样导入 NumPy 和 Matplotlib,但这次需要加入标准库的time模块 ➊。time.sleep()方法将帮助我们稍后控制动画的速度。

我们将在外部 Qt 窗口中展示动画,所以调用%matplotlib qt魔法命令。如果你在 Jupyter Notebook 中工作,你可以使用%matplotlib notebook命令在 notebook 中显示动画。

接下来,重新生成之前的时间(t)和amplitude数据,然后分配figax变量。要使用for循环动画化图表,你需要在每次循环迭代之前更新显示的数据。因为我们绘制的是一条线,所以需要为图表分配一个line变量 ➋。注意line后的逗号,这表示这是一个元组解包过程。

开始一个运行 30 次的for循环。在每次循环中,通过将循环次数(i)添加到 y 数据中,使用方程np.sin(t + i)将时间序列向前移动一秒。将结果赋值给updated_amp变量。在绘图之前,调用line对象的set_ydata()方法并传入updated_amp变量 ➌。

要更新已经更改但没有自动重绘的Figure对象,调用canvas.draw()。接着使用canvas_flush_events()方法,清除当前图表,以便下一次迭代可以从空白屏幕开始。

最后,调用time.sleep()方法并传入0.1。这是暂停程序执行的秒数。可以随意调整这个数字,看看它对动画的影响;数字越大,动画进行得越慢。

要运行动画,在控制台中按下 ENTER;在 Jupyter Notebook 中按下 CTRL-ENTER。要返回内联绘图,记得使用%matplotlib inline魔法命令。

你也可以通过使用pyplot方法来实现这种实时动画。以下是一个示例,我们通过不断更新一个散点图,添加一个通过二次方程计算出的新点。你可以在 Qt 控制台或 Spyder 文本编辑器中运行它:

In [49]: import numpy as np
    ...: import matplotlib.pyplot as plt
    ...: %matplotlib qt
    ...: 
    ...: x = 0 ...: for i in range(30):
    ...:     x = x + 1
    ...:     y = x**2
    ...:     plt.scatter(x, y)
    ...:     plt.title("Quadratic Function")
    ...:     plt.xlabel("x")
    ...:     plt.ylabel("x-squared")
    ...:     plt.pause(0.1)

注意使用plt.pause()代替time.sleep()plt.pause()方法以秒为单位接受一个参数,并在此时间间隔内运行 GUI 事件循环。在暂停之前,活动图形会被更新并显示,暂停期间(如果有)会运行 GUI 事件循环。

当动画运行时,x 轴和 y 轴会自动调整,以适应扩展的绘图范围。当动画完成时,你应该会看到一个如图 19-25 所示的图形。

Image

图 19-25:完成的 pyplot 动画

对于复杂的动画,Matplotlib 文档建议使用matplotlib.animation模块,而不是使用for循环。我们将在接下来介绍这种技术。

使用 FuncAnimation 类进行动画绘图

FuncAnimation类通过重复调用一个函数来制作动画。它提供了一种比上一部分使用的for循环方法更正式、更灵活的方式。

让我们使用面向对象的方式在同一个图中为两条线做动画。在控制台或 Jupyter Notebook 中输入以下内容(如果你使用 Notebook,将%matplotlib qt魔法命令替换为%matplotlib notebook):

In [50]: import numpy as np
    ...: import matplotlib.pyplot as plt
    ...: from matplotlib.animation import ➊ FuncAnimation
    ...: %matplotlib qt
    ...: 
    ...: x = np.arange(-6, 6, 0.02)
    ...: y = np.sin(2 * x) / x
 ➋ ...: scaler = np.arange(1, 10, 0.1) 
    ...:  
    ...: fig, ax = plt.subplots()
    ...: line1 = ax.plot(x, y, color='k', lw=2) ➌[0]
    ...: line2 = ax.plot(x, y, color='r', ls='--')[0]
    ...: 
    ...: def animate(frame):
    ...:     line1.set_ydata(y / frame)
    ...:     line2.set_ydata(y / frame * -0.2)
    ...: 
    ...: animated = FuncAnimation(fig, animate, frames=scaler, interval=20)

matplotlib.animation模块导入FuncAnimation ➊。接下来,使用 NumPy 生成一些数据以供绘图。scaler数组将使你能够修改xy数据,从而在动画运行时打印出不同的内容 ➋。

设置figax对象,然后为每条线绘制图形,为第一条线设置黑色,为第二条线设置红色。同时,将第一条线的线宽设置为 2,第二条线的线型设置为虚线。

对于两条线,在绘图代码的末尾添加一个零索引[0] ➌。plot命令返回一系列线条对象,而我们只希望获取序列中的第一个项。这是替代先前部分中用于动画化正弦波的元组解包方法(line, = ax.plot(t, amplitude))的一种方式。

现在是定义一个函数的时候了,这个函数将更新数据以创建动画的每一帧。我们将这个函数命名为animate,并给它一个名为frame的参数。这个参数的值将是scalar数组,该数组通过FuncAnimation()类中的frames参数传递。

对每条线使用set_ydata()方法,并传入由scaler数组除以的y数据。对于第二条线,将scaler乘以一个负的标量,以便line2看起来与line1不同。

要完成代码,调用FuncAnimation()并将Figure对象(即它将绘制的fig)传递给它,传入用户定义的函数(animate),以及framesinterval参数。frames参数代表传递给用户定义函数的每一帧数据的来源。它可以是一个可迭代对象、一个整数、一个生成器函数或Noneinterval参数设置每帧之间的延迟时间,单位是毫秒。增大这个数字将使动画变慢。

注意

你可以直接将标量 NumPy 数组赋值给 frames 参数,像这样:FuncAnimation(fig, animate, frames=np.arange(1, 10, 0.1), interval=20)。尽管这去除了对标量变量的需求,但代码的可读性可能变差。

通过在控制台按 ENTER 键或在 Jupyter Notebook 中按 CTRL-ENTER 来运行代码。你应该会看到两个动画折线图,如图 19-26 所示。要停止动画,请点击图表窗口右上角的关闭按钮。否则,在调用FuncAnimation()时,将repeat=False设置为在动画播放一次后停止。

Image

图 19-26:功能动画的屏幕截图

FuncAnimation()中的一个可选参数是fargsfargs功能参数的缩写,当你的用户定义的函数需要多个参数时,可以使用这个参数。第一个参数总是保留给FuncAnimation()中的frames参数,但你可以将后续的参数(即在frames之后的参数)作为有序的元组传递,例如:

    ...: ani = FuncAnimation(fig, func, frames=param1,fargs=(param2, param3))

最后,要将动画保存为.gif文件,请使用save()方法,并传入可选的每秒帧数(fps)和每英寸点数(dpi)参数,如下所示:

    ...: animated.save('animation.gif', fps=20, dpi=150)

其他支持的文件格式包括.avi.mp4.mov,其他保存选项包括to_html5_video()to_jshtml()方法。要了解更多关于FuncAnimation方法和参数的信息,请访问matplotlib.org/stable/api/_as_gen/matplotlib.animation.FuncAnimation.html

样式设置

到目前为止,你已经通过传递新的值来改变图表的默认设置,如线宽或标记颜色。但是如果你想为多个图表同时设置这些值,使得所有线条的颜色都是黑色呢?或者如果你想按顺序循环显示一组预定义的颜色呢?

好吧,一种做法是使用RcParams类的实例在运行时设置参数。该类的名称代表运行时配置参数,你可以在笔记本、脚本或控制台中通过pyplot方式或面向对象的风格来运行它。它将设置保存在matplotlib.rcParams变量中,这是一个类似字典的对象。

有一长串可配置的参数,你可以通过多种方式查看。要查看有效的参数列表,请访问 matplotlib.org/stable/api/matplotlib_configuration_api.html?highlight=rcparams/。要查看有关这些参数的更多详细信息,运行 import matplotlib as mpl,然后运行 print(mpl.matplotlib_fname())。这将显示你计算机上 matplotlibrc 文件的路径,你可以打开并查看该文件。

更改运行时配置参数

让我们来看一个 pyplot 示例,在这个示例中,我们标准化了图形的大小,使用黑色绘制所有线条,并循环使用两种不同的线条样式。这意味着绘制的第一条线将始终采用某种一致的样式,而第二条线将采用另一种一致的样式。在控制台中输入以下内容:

In [51]: import numpy as np
    ...: import matplotlib.pyplot as plt
    ...: import matplotlib as mpl
    ...: from cycler import cycler
    ...: %matplotlib inline
    ...:

在这里需要注意的是,我们将 Matplotlib 导入为 mpl。以这种方式导入 Matplotlib 可以让我们访问比仅使用 pyplot 模块更多的功能。我们还导入了 cyclerCycler 类将让我们在制作多数据图时指定想要循环使用的颜色和其他样式属性。你可以在 matplotlib.org/stable/tutorials/intermediate/color_cycle.html 中阅读相关内容。

要访问 rcParams 中的一个属性,可以像访问字典键一样处理它。你可以通过输入 mpl.rcParams.keys() 或访问上一节中列出的来源来查找有效的参数名称。在接下来的三行中,我们设置了图形大小、线条颜色和线条样式:

    ...: mpl.rcParams['figure.figsize'] = (5, 4)
    ...: mpl.rcParams['lines.color'] = 'black'
    ...: mpl.rcParams['axes.prop_cycle'] = cycler('linestyle', ['-', ':'])
    ...:

注意

你也可以通过 pyplot 设置参数,使用类似 plt.rcParams['lines.color'] = 'black' 的语法。

要循环使用线条样式,可以使用 axes.prop_cycle 键,然后将 cycler 工厂函数传递给参数('linestyle')和样式列表(实线和虚线)。这些默认设置已经为你当前会话中的所有图形重置。

最后,生成一些数据并绘制它:

    ...: x = np.arange(0, 15, 0.1)
    ...: y = np.sin(x)
    ...: 
    ...: plt.plot(x, y)
    ...: plt.plot(x + 1, y - 2);

通常,这段代码会生成一个有两条实线的图形,一条蓝色,另一条橙色。然而,现在,你会看到两条黑色的线,通过不同的线条样式区分(图 19-27)。

Image

图 19-27:使用全局图形大小、线条颜色和线条样式参数构建的图形

请注意,如果你在之前的图形中绘制 条线,那么第三条线将回到使用实线样式,你将会看到一条虚线和两条实线。如果你想要三种不同的样式,你需要将额外的样式添加到循环器中。

为了方便,Matplotlib 提供了通过关键字参数同时修改多个设置的函数。以下是一个例子,使用之前的绘图数据,我们首先重置 Matplotlib 的“工厂默认设置”:

In [52]: mpl.rcParams.update(mpl.rcParamsDefault)
    ...:

现在,让我们使用 rc() 便捷函数将默认的线宽更改为 5,并将线条样式设置为点划线:

In [53]: mpl.rc('lines', lw=5, ls='-.')
    ...: plt.plot(x, y);

这将生成图表,如 图 19-28 所示。

Image

图 19-28:使用便捷函数设置的新绘图参数

如果你只想对特定代码块使用某个样式,样式包提供了一个上下文管理器,限制你的更改仅作用于特定范围。有关详细信息,请参阅“临时样式”部分,链接为 matplotlib.org/stable/tutorials/introductory/customizing.html

创建并使用样式文件

你可以将 Matplotlib 默认样式的更改保存到文件中。这让你可以为报告或演示文稿标准化图表,并在项目团队中共享这些自定义设置。它还通过让你预设某些图表参数并将其封装在外部文件中,减少了代码冗余和复杂性。

让我们创建一个简单的样式文件,设置图表的一些标准,例如图形大小和分辨率、是否使用背景网格,以及标题、坐标轴标签和刻度标签的字体类型和大小。在 Spyder 文本编辑器或任何文本编辑器中,输入以下内容:

# scientific_style.mplstyle

figure.figsize:    4, 3  # width & height in inches
figure.dpi:        200   # dots per inch
axes.grid:         True
font.family:       Times New Roman
axes.titlesize:    24
axes.labelsize:    20
xtick.labelsize:   16
ytick.labelsize:   16

注意

有关创建样式文件的指导,请使用你计算机上的 matplotlibrc 文件,如前所述。你还可以在 matplotlib.org/stable/tutorials/introductory/customizing.html 找到一个副本。

为了让 Matplotlib 容易找到这个文件,你需要将其保存在特定的位置。首先,通过在控制台输入以下命令来找到 matplotlibrc 文件的位置:

In [54]: import matplotlib as mpl

In [55]: mpl.matplotlib_fname()
Out[55]: 'C:\\Users\\hanna\\anaconda3\\lib\\site-packages\\matplotlib\\mpl-
data\\matplotlibrc'

这会显示你到 mpl-data 文件夹的路径,该文件夹包含 matplotlibrc 文件和一个名为 stylelib 的文件夹等。将你的样式文件保存到 stylelib 文件夹中,命名为 scientific_style.mplstyle(替换 .txt 扩展名)。

注意

如果 Matplotlib 以后无法找到该文件,你可能需要重新启动内核。在控制台中,点击 内核重新启动当前内核。在 Jupyter Notebook 中,点击 内核重新启动

现在,让我们使用这个文件创建一个标准化的图表。在导入 pyplot 后,使用其 style.use() 方法加载样式文件,不带 文件扩展名:

In [56]: import matplotlib.pyplot as plt

In [57]: plt.style.use('scientific_style')

接下来,使用面向对象的风格生成一个空白图形。你应该会看到类似于 图 19-29 的图表。

In [58]: fig, ax = plt.subplots()
    ...: ax.set_title('Standardized Title')
    ...: ax.set_xlabel('Standardized X-labels')
    ...: ax.set_ylabel('Standardized Y-labels');

Image

图 19-29:通过样式文件生成的空白标准化图表

当你保存样式文件时,可能已经注意到 stylelib 文件夹中充满了现有的 mplstyle 文件。这些文件创建了许多不同的图表格式,你可以查看它们,获取如何编写自己样式文件的线索。在下一节中,我们将使用其中一个文件来覆盖 Matplotlib 的一些默认值。

应用样式表

除了让你自定义自己的绘图外,Matplotlib 还提供了可以通过style.use()导入的预定义样式表。样式表与matplotlibrc文件看起来相同,但其中只能设置与实际绘图样式相关的rcParams。这使得样式表在不同的机器之间具有可移植性,因为不必担心未安装的依赖项。只有少数rcParams不能被重置,你可以查看这些参数的列表,访问 matplotlib.org/stable/api/style_api.html#matplotlib.style.use/

你可以在 matplotlib.org/stable/gallery/style_sheets/style_sheets_reference.html 上看到可用样式表的示例。这些示例以缩略图条的形式展示,如图 19-30 所示。有些样式表模仿了流行的绘图库,如 seaborn 和 ggplot。

Image

图 19-30:灰度样式表示例

注意

一个需要注意的重要样式表是seaborn-colorblind样式表。该样式表使用“色盲安全”颜色,专为 5% 到 10% 的色盲人群设计。

让我们尝试使用与 Matplotlib 一起提供的灰度样式表绘制一个散点图。首先,在控制台或 Jupyter Notebook 中导入 NumPy 和 Matplotlib,然后调用灰度文件:

In [59]: import numpy as np
    ...: import matplotlib.pyplot as plt
    ...: 
    ...: plt.style.use('grayscale')
    ...:

现在,生成一些虚拟数据以制作两个不同的点云:

    ...: x = np.arange(0, 20, 0.1)
    ...: noise = np.random.uniform(0, 10, len(x))
    ...: y = x + (noise * x**2)
    ...: y2 = x + (noise * x**3)
    ...:

完成后,使用pyplot方法设置并执行绘图。为两个坐标轴使用对数刻度。

    ...: plt.title('Grayscale Style Scatterplot')
    ...: plt.xlabel('Log X')
    ...: plt.ylabel('Log Y')
    ...: plt.loglog()
 ➊ ...: plt.scatter(x, y2, alpha=0.4, label='X Cubed') 
    ...: plt.scatter(x, y, marker='+', label='X Squared')
    ...: plt.legend(loc=(1.01, 0.7));

你应该能看到类似于图 19-31 中的图形。由于使用了随机生成的数据,点的位置可能会有所不同。

Image

图 19-31:使用灰度样式表制作的散点图

注意调用plt.scatter()时使用alpha关键字➊。alpha属性控制透明度,让你调节线条或标记的透明度。值为1表示完全不透明。

让一个数据集稍微透明有助于解决重叠问题,其中一个数据集的标记会覆盖在其他数据集的标记上,从而遮挡了重叠的标记。半透明标记在叠加时也会变得更暗,这可以帮助你可视化数据密度(例如,图图 19-31 中的黑色圆圈)。

注意

要控制标记的绘图顺序,在调用 plt.scatter() 时使用 zorder 参数(例如 zorder=2)。具有较高 zorder 值的艺术元素会覆盖具有较低值的元素。

回到我们的样式表:如果你打开grayscale.mplstyle,你会发现它看起来很像我们在“创建和使用样式文件”一节中制作的scientific_style.mplstyle文件,位于第 576 页。因此,如果现有的样式表不完全适合你的需求,你可以随时复制该文件,进行编辑,然后将其保存为新的样式表!

测试你的知识

  1. 真还是假:操作脊柱的能力是pyplot方法的一个优点。

  2. 在图 19-14 的显示中添加总结标题“火星赤铁矿、赤铁矿和针铁矿分布”。使用你喜欢的任何绘图方法。

  3. 使用以下代码生成三个数据集以供绘图:np.random.normal(0, 1, 50).cumsum()。在一行中生成三个子图,并使用for循环将每个子图填充不同的数据集。为每个子图添加独特的标题,并使用黑色交叉标记绘制数据。

  4. 使用np.random.rand(4, 4)生成一个随机化的二维 NumPy 数组。然后,使用heat = ax.imshow(data)绘制热图。使用for循环和range 30 来使热图动画化。

  5. 使用公式velocity = 9.81 * time计算自由落体物体的速度。让物体自由下落 15 秒,并且每秒钟在一个单一的图表中显示其位置和速度,使用不同的 y 轴来表示每个数据。

总结

本章的目标是介绍强大的 Matplotlib 绘图库,并(希望)解决其中一些令人沮丧的方面。一个主要的困惑来源是,制作图形有两种主要接口;为了保持一致性,你应该选择一个并坚持使用它。

pyplot方法适用于隐式的、“当前激活的”FigureAxes对象,其中Figure是一个空白画布,而Axes包含图形元素,如线条、图例、标题等。为了简化绘图,pyplot在幕后创建了这些对象。

pyplot方法在互动式使用 Matplotlib 时以及在小型脚本中效果很好,但在构建更大的应用程序时,更推荐使用面向对象的风格。明确地将FigureAxes对象赋值给变量将帮助你跟踪多个图表,并确保生成它们的代码尽可能清晰。你还将对某些图形元素有更多的控制。

对于比pyplot方法更简单、更自动化的绘图,你可以使用 seaborn 包,它是 Matplotlib 的一个包装器。此外,pandas 数据分析包也为 Matplotlib 提供了封装,使绘图更加简便,但功能相对简单。第十六章中概述了 seaborn 和 pandas 的绘图,我们将在下一章再次讨论它们。

若要进一步学习并掌握高级的 Matplotlib 功能,可以查看官方网页上的教程和用户指南 (matplotlib.org/) 以及 Real Python 网站上的相关内容 (realpython.com/python-matplotlib-guide/)。你还可以在 matplotlib.org/cheatsheets/ 找到有用的备忘单。

第二十章:PANDAS、SEABORN 和 SCIKIT-LEARN**

image

一种常见的科学实践是评估数据并利用它来生成预测模型。在本章中,我们将使用 Python 的三个最受欢迎的开源库来解决一个动物分类问题。通过这种基于项目的实践方法,将展示这些库的功能和协同效应,并演示使用 Python 做科学研究的具体过程。

对于数据加载、分析和处理,我们将使用 pandas 包(pandas.pydata.org/)。pandas 基于 NumPy 和 Matplotlib 构建,虽然底层使用基于数组的计算,但它的语法更简单,使得编写代码和绘图更加快速、容易且更少出错。与原生 Python 不同,pandas 能够智能地读取表格文本文件数据,识别列、行、标题等内容。与 NumPy 不同,pandas 能处理异质数据类型,如文本和数字的混合数据。

我们还将使用 seaborn 库(seaborn.pydata.org/),它封装了 Matplotlib,用于生成更具吸引力且更易于理解的可视化图表。它在高度可定制但冗长的 Matplotlib 语法和简单的 pandas 之间提供了一个良好的折衷。更好的是,seaborn 与 pandas 紧密集成,能够无缝地进行绘图和有效的数据探索。

最后,scikit-learn 库(scikit-learn.org/)是 Python 的主要通用机器学习工具包。它提供了分类、回归、聚类、降维、预处理和模型选择的算法。

在接下来的章节中,你将应用这些库来解决一个实际问题,并观察它们如何协同工作。但由于这些库的规模和范围庞大,我们无法深入研究它们。每个库都有专门的书籍,Wes McKinney 的 《Python 数据分析》 第二版中关于 pandas 的概述就足足有 270 页,尽管它不是全面的。

如果你想了解完整的内容,我在本章末的“总结”部分列出了一些附加资源。你还可以在之前提到的官方网站上找到有用的教程和示例。

介绍 pandas Series 和 DataFrame

pandas 库包含用于处理常见数据源(如 Excel 表格和 SQL 关系数据库)的数据结构。它的两个主要数据结构是 Series 和 DataFrame。其他库(如 seaborn)则设计用于与这些数据结构良好集成,并提供额外的功能,使得 pandas 成为任何数据科学项目的良好基础。

Series 数据结构

Series 是一种一维的带标签数组,可以容纳任何类型的数据,例如整数、浮点数、字符串等。由于 pandas 基于 NumPy,因此序列对象本质上是两个关联的数组。一个数组包含数据点的值,可以是任何 NumPy 数据类型;另一个数组包含每个数据点的标签,称为 索引(表 20-1)。

表 20-1: 一个 Series 对象

索引
0 42
1 549
2 ' Steve '
3 -66.6

与 Python 列表项的索引不同,序列中的索引不需要是整数。在 表 20-2 中,索引是元素的名称,而值是它们的原子序数。

表 20-2: 一个具有有意义索引的 Series 对象

索引
14
11
18
27

一个序列的作用类似于 Python 字典,其中索引代表键。因此,它可以在许多场景中作为字典的替代品。另一个有用的特性是,在进行算术操作时,即使标签的顺序不同,不同的序列也会根据索引标签对齐。

与列表或 NumPy 数组类似,你可以通过指定索引来切片一个序列或选择单独的元素。你可以以多种方式操作序列,例如过滤它、对其进行数学运算,或将其与其他序列合并。要查看序列对象的众多属性和方法,请访问 pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.html

DataFrame 数据结构

DataFrame 是一种由两维结构组成的更复杂的数据结构。它是一个使用表格结构组织的对象集合,类似于电子表格,包含列、行和数据(表 20-3)。你可以将其视为一个有序的列集合,配有两个索引数组。每一列代表一个 pandas 序列。

表 20-3: 一个 DataFrame 对象

索引
---
0
1
2
3

第一个索引用于行,类似于序列中的索引数组。第二个索引用于跟踪标签序列,每个标签表示一个列标题。DataFrame 也类似于字典;列名是键,每列中的数据序列是值。像序列一样,DataFrame 拥有许多属性和方法。有关更多信息,请参见 pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html

通过将索引对象和标签集成到结构中,你可以轻松地操作数据框(DataFrames)。我们将在处理分类问题时查看一些这样的功能。你也可以通过访问“10 分钟快速了解 pandas”教程来掌握基础知识,网址为 pandas.pydata.org/docs/user_guide/10min.html

Palmer Penguins 项目

Palmer Penguins 数据集包含了来自南极洲帕尔默群岛三个岛屿的 342 个企鹅观察数据(图 20-1)。该数据集通过Palmer Station Antarctica LTER(* pallter.marine.rutgers.edu/ *)提供。

Image

图 20-1:Dream、Torgersen 和 Biscoe 岛,帕尔默群岛,南极洲的位置

研究中采样了三种不同的企鹅物种。按体型从大到小排列,它们是信天翁企鹅、帽带企鹅和阿德利企鹅(图 20-2)。

Image

图 20-2:由查尔斯·约瑟夫·赫尔曼德尔(Charles Joseph Hullmandel)绘制的 Palmer Penguins 数据集中的三种企鹅物种(图片来自 Wikimedia Commons)

本项目的目标是生成一个模型,通过翅膀长度、体重等形态特征的组合来预测企鹅物种。在机器学习中,这被认为是一个 分类 问题。我们将使用 pandas 加载、探索、验证和清理数据,使用 seaborn 绘制数据,使用 scikit-learn 构建预测模型。

项目大纲

像这样的数据科学项目遵循一系列逻辑步骤,如下所示:

  1. 定义问题(最重要的一步)。

  2. 收集原始数据并设置项目。

  3. 处理数据(通过清洗、合并、填充、简化等方式)。

  4. 探索数据。

  5. 进行深入分析并开发模型和算法。

  6. 应用模型并展示项目结果。

Jupyter Notebook 非常适合此过程,因为它可以按顺序处理所有步骤,且基本上是自文档化的。它还可以转化为幻灯片用于展示(如第五章中讨论的)。

项目设置

对于本项目,我们将在专用项目文件夹中使用 Jupyter Notebook。我们将使用最简单的方式安装 Notebook 和科学绘图库,即直接在项目文件夹中的 conda 环境中安装(参见第五章)。通常,当你希望使用 特定的持久的 版本库或应用程序时,会使用这种简单的方法。我们在这里使用它进行实践,因为之前我们一直专注于模块化方法,在这种方法中,Notebook 安装在 base 环境中。

首先,在你的用户目录下创建一个名为 penguins 的文件夹。虽然你可以通过 Anaconda Navigator 执行此操作,但命令行更加简洁,因此我们接下来将使用命令行。

要为项目创建目录,请打开 Anaconda 提示符(在 Windows 中)或终端(在 macOS 或 Linux 中),然后输入以下命令(请使用你自己的目录路径):

mkdir C:\Users\hanna\penguins
mkdir C:\Users\hanna\penguins\notebooks

这将创建一个名为 penguins 的目录,并在其中创建一个 notebooks 子目录。接下来,在项目目录下创建一个名为 penguins_env 的 conda 环境,激活它,并安装我们将要使用的库(根据需要替换自己的路径):

conda create --prefix C:\Users\hanna\penguins\penguins_env
conda activate C:\Users\hanna\penguins\penguins_env
conda install python notebook pandas seaborn 
conda install -c conda-forge scikit-learn

现在,你已经为项目创建了一个 conda 环境,其中包含 notebook、pandas、python、scikit-learn 和 seaborn 包。请记得在 第二章 中提到,这个环境是隔离的,不能“看到”系统中其他的包,例如 base 环境中的包。

此时,你的 penguins_env 应该已经激活,项目目录结构应该如 图 20-3 所示。我们将直接从 seaborn 加载数据集,因此无需创建 data 文件夹。

Image

图 20-3:penguins 项目的目录结构

要为项目创建一个 notebook,首先使用 Anaconda 提示符或终端导航到 notebooks 文件夹:

cd C:\Users\hanna\penguins\notebooks

要启动 Notebook,输入以下命令:

jupyter notebook

现在,你应该在浏览器中看到 Jupyter 仪表板。点击 New 按钮,选择 Python[conda env:penguins_env] 来创建一个新的 notebook。一个新的 notebook 应该会在浏览器中出现。点击 Untitled,将其命名为 penguins_project,然后点击 Save 按钮。你准备好了!

注意

如果你以后想使用 Anaconda Navigator 打开 notebook,请启动 Navigator,使用 Environments 标签激活 penguins_env,然后点击 Jupyter Notebook 磁贴上的 Launch 按钮。这将打开仪表板,你可以导航到 notebook 文件夹并启动 penguins_project.ipynb。如果你想在 JupyterLab 中使用 Notebook,请参阅 第六章 中的 JupyterLab 安装和启动 Notebook 的说明。

导入包和设置显示

在第一个 notebook 单元格中,导入 Matplotlib、seaborn 和 pandas。输入以下代码并使用 SHIFT-ENTER 执行,它会自动跳转到一个新的空白单元格:

import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

# Enable multiple outputs per cell:
%config InteractiveShell.ast_node_interactivity = 'all'

# Set plotting styles
sns.set_style('whitegrid')
sns.set_palette(['black', 'red', 'grey'])

注意

通常,我们会在这里执行所有导入操作,但为了叙述的方便,我们将在稍后导入 scikit-learn 组件,这样我们可以在应用它们之前讨论这些组件。

默认情况下,Notebook 每个单元格只显示一个输出。使用 %config 魔法命令可以覆盖此设置,使我们能够在一个单元格中看到多个输出,例如数据表和条形图。

seaborn 默认的颜色调色板无疑是美丽的(见 seaborn.pydata.org/tutorial/function_overview.html),但在黑白书籍中,它的魅力略有失色。作为折中,我们将使用 whitegrid 样式表,并将调色板重置为黑色、红色和灰色,每种颜色代表数据集中的三种企鹅物种。

加载数据集

seaborn 附带了一些练习数据集,这些数据集在安装时会自动下载。这些数据集都是逗号分隔值(.csv)文件,存储在 github.com/mwaskom/seaborn-data/ 仓库中。如果你需要获取数据集名称,可以通过在笔记本或控制台中运行 sns.get_dataset_names() 来检索(当然,前提是先导入 seaborn)。

作为数据分析工具,pandas 可以读取和写入存储在多种介质中的数据,如文件和数据库(见表 20-4)。示例语法为 df = pd.read_excel('filename.xlsx')df.to_excel('filename.xlsx'),其中 df 代表 DataFrame。欲了解更多选项,请访问 pandas.pydata.org/docs/reference/io.html

除了表 20-4 中的方法外,read_table()方法可以读取表格数据,例如文本 (.txt) 文件,其中值由空格或制表符分隔。Python 通常能自动检测分隔符,但你也可以将分隔符作为参数传入,例如,sep='\t'表示制表符。

表 20-4: 有用的 pandas I/O 方法

输入(读取器) 输出(写入器)
read_csv() to_csv()
read_excel() to_excel()
read_hdf() to_hdf()
read_sql() to_sql()
read_json() to_json()
read_html() to_html()
read_stata() to_stata()
read_clipboard() to_clipboard()
read_pickle() to_pickle()

除了加载外部数据源,你还可以从多种不同类型的输入中创建 DataFrame。这些包括 2D ndarray、列表的列表或元组、字典或系列的列表、现有的 DataFrame 等。

尽管有这么多选择,我们将使用 seaborn 的load_dataset()方法来加载企鹅数据集。这个专用方法从 seaborn 仓库中读取一个 CSV 格式的数据集,并返回一个 pandas DataFrame 对象。在新的单元格中输入以下内容并按 SHIFT-ENTER:

# Load penguins dataset:
df = sns.load_dataset('penguins')

注意

在本笔记本中,我使用了简单的注释,例如 # 加载企鹅数据集,作为单元格标题。为了制作合适的标题,你可以在每个代码单元格之前添加一个 Markdown 单元格,如第五章中所述。

在前面的代码中,我们将 DataFrame 分配给名为 df 的变量。这是方便的,因为这个名称反映了数据类型。然而,你也可以使用其他名称,比如 penguins_df

显示 DataFrame 并重命名列

加载数据后,首先你需要查看数据。对于像企鹅这样的较大数据集,pandas 默认会显示 DataFrame 的部分顶部和部分底部。要查看示例,输入以下内容,然后按 CTRL-ENTER 执行单元格而不离开:

# View dataframe head and tail:
df

要在可滚动的输出单元格中查看整个 DataFrame,将此命令放在单元格顶部并重新运行:pd.set_option('display.max_rows', None)

调用 DataFrame 会显示所有列及前五行和后五行数据(图 20-4)。如果可能,列名应该简洁且具有描述性。然而,这并非总是可行的,因此我们来练习更改列标题。

Image

图 20-4:DataFrame 头部和尾部显示

在同一个单元格中,添加以下代码以将 sex 列标题重命名为 genderinplace 参数告诉 pandas 更改当前的 DataFrame,而不是返回一个副本。按 CTRL-SHIFT 执行代码并跳转到新单元格。

# Rename sex column to 'gender' and verify change:
df.rename(columns={'sex': 'gender'}, inplace=True)
df.head()

head() 方法显示 DataFrame 中的前五行,如图 20-5 所示。要查看更多行,只需将你想查看的行数作为参数传递给它。

Image

图 20-5:更改 sex 列标题为 gender 后的 DataFrame 头部

在图 20-4 中,输出底部包含了行数和列数。你可能会立即注意到一个问题:有 344 行数据,但之前我提到数据集有 342 个观察值。这一差异可能是由两个常见的数据集问题引起的:重复或缺失值。

检查重复数据

数据行意外重复并不罕见。这可能发生在数据集初次创建时,之后的编辑中,或在数据传输和转换过程中。在开始分析之前,你应该删除这些冗余数据,因为它占用了内存,降低了处理速度,并由于重复值的过度权重而扭曲统计数据。

幸运的是,pandas 提供了 duplicated() 方法来查找重复的行。在新单元格中输入以下内容,然后按 CTRL-ENTER 执行:

# Check for duplicate rows:
duplicate_rows = df[df.duplicated(keep=False)]
print(f'Number of duplicate rows = {len(duplicate_rows)}')

你应该会得到以下输出,因为没有重复行:

Number of duplicate rows = 0

如果数据集中存在重复项,我们可以使用 drop_duplicates() 方法将其移除,方法如下:

df.drop_duplicates(inplace=True)

你也可以查看特定列中的重复值。在当前单元格底部输入以下内容,并通过按 SHIFT-ENTER 执行:

# Check for duplicates across specified columns:
df[df.duplicated(['bill_length_mm', 'bill_depth_mm', 'flipper_length_mm', 'body_mass_g'])]

请注意,内部的方括号定义了一个包含列名的 Python 列表,而外部的方括号表示“选择括号”,用于从 pandas DataFrame 中选择数据。我们指定了七列中的四列,产生了图 20-6 中的输出。

Image

图 20-6:四列浮动数据类型中具有重复值的行

正如你马上就会看到的,第 339 行是第 3 行的重复(针对四个指定的列)。但是,尽管这里有重复值,它们并不是我们需要当作重复数据来处理的类型。相反,它们表示缺失值,我们将在下一节讨论这个问题。

处理缺失值

图 20-6 中的重复值由非数字NaN)表示。这是一个特殊的浮点值,所有使用 IEEE 标准浮点表示法的系统都能识别它。为了计算速度和便捷性,它作为 NumPy 和 pandas 的默认缺失值标记。NaN和 Python 的内建None值基本上是可以互换的。默认情况下,这些空值不会参与计算。

查找缺失值

缺失数据值会减少统计效能,并且在估算参数和进行预测时可能引入偏差。要查找企鹅数据框中的缺失值,在一个新单元格中输入以下内容,然后按 SHIFT-RETURN:

# Find null values
df.isnull().sum()
df[df.isnull().any(axis=1)]

第一个方法将缺失值求和并以表格形式显示结果(图 20-7)。企鹅数据集缺失了 11 个性别标注和总计 8 个形态测量值。

Image

图 20-7:df.isnull().sum()的输出

第二次调用会索引 DataFrame,其中任何列的值缺失(而不是所有列)。记住,pandas 是基于 NumPy 构建的,因此轴 1 指的是列,轴 0 指的是行。你应该得到图 20-8 中所示的结果。

Image

图 20-8:所有包含缺失数据的 DataFrame 行

填充和删除缺失值

在你尝试从数据集进行分析或构建模型之前,必须处理缺失值。虽然忽略这个问题是一个选择,但最好是填补缺失值或完全删除它们。处理方法列出了在表 20-5 中。

表 20-5: 处理缺失数据的有用方法

方法 描述
dropna 根据参数,移除包含缺失数据的行或列,依据是任何值或所有值是否为空。
fillna 用常数值或插值方法填充缺失值。参数包括ffillbfill方法。
ffill “向前填充”通过将最后一个有效观察值向前传播。
bfill “向后填充”通过用下一个行或列中的值来替代缺失值,如所指定的那样。
isnull 返回布尔值,指示缺失/NA 值。
notnull 否定isnull

使用fillna()填补缺失值的选项包括用数据集中的均值、中位数或最频繁值来替换它们,从而避免整体统计数据的偏差。例如,要使用列的均值,你可以使用以下语法(不要将其添加到你的项目代码中):

df['col1'] = df['col1'].fillna(df['col1'].mean())

注意

pandas 库试图模仿 R 编程语言,而 fillna()方法中的 na 代表 R 中用于表示缺失数据的 NA(不可用)标记。

填充缺失数据对于数据集较小且需要考虑每一个观测值的情况非常重要。如果在众多列中只有一列缺少值,你可能不希望“丢弃”行中其他所有有用的数据。

由于我们拥有一个强大的数据集,且无法轻易插补和替换缺失的性别数据,因此我们将删除包含缺失数据的行。在新单元格中输入以下内容,然后按 SHIFT-ENTER:

# Drop Null Values
df = df.dropna(how='any')
df.isnull().sum()

使用赋值语句调用dropna()会导致当前的 DataFrame(df)被覆盖。这使得 DataFrame 可以随着时间变化,但需要注意的是,要撤销更改并恢复 DataFrame 的先前状态,你需要运行当前单元格上方的所有单元格。将how参数设置为any传递给dropna()意味着任何包含至少一个缺失值的行都会被删除。

要检查结果,重新运行isnull()方法。你应该会得到图 20-9 中的输出。

Image

图 20-9:删除空值后空值的总结

DataFrame 中不再包含缺失值。

重新索引

重新索引是指使数据符合特定轴上给定标签集的过程。在标签位置上没有数据时,缺失值标记会自动插入。

当我们在上一部分删除了带有空值的行时,我们也删除了它们相应的索引。要查看结果,请在新单元格中运行以下代码并按 SHIFT-ENTER:

# Check index values after dropping rows.
df.head()

如图 20-10 所示,DataFrame 的索引(最左侧的列)中存在一个空隙,这是因为包含空值的第 3 行被删除了。

Image

图 20-10:删除行导致缺失的 DataFrame 索引。

若要恢复索引,请运行以下代码并使用 SHIFT-ENTER 执行该单元格。

# After dropping nulls, reindex:
df.reset_index(drop=True, inplace=True)
df.head()

reset_index()方法中,drop=True表示旧的索引不会作为新列保留在 DataFrame 中,因为不需要保留该信息。inplace=True参数表示该方法会直接修改当前的 DataFrame,而不是返回一个副本。作为替代,你也可以简单地重新分配 DataFrame,如下所示:

df = df.reset_index(drop=True).

调用head()方法显示索引现在按顺序排列(图 20-11)。

Image

图 20-11:重新索引后的 DataFrame 头部

Pandas 包含了其他几个重新索引的函数,如 reindex()reindex_like()。你可以在 pandas.pydata.org/pandas-docs/stable/reference/frame.html 找到这些函数和其他 DataFrame 函数。关于缺失值的更多内容,请参见 pandas.pydata.org/docs/user_guide/missing_data.html

探索数据集

到这一步,你已经通过检查重复项、移除缺失值和重新索引 DataFrame 来清理了数据。当然,仍然可能存在问题,比如错误的值(例如,企鹅的体重为一百万克)。捕捉并纠正这些问题需要对数据集进行探索,pandas 和 seaborn 提供了多种方法来帮助你完成这一过程。这些方法还将帮助你理解数据集,从而为解决项目目标制定计划。

描述 DataFrame

让我们通过表格和图表的结合来探索 DataFrame。首先,我们将查看使用的数据类型和总体统计信息。在一个新的单元格中,输入以下内容并按下 SHIFT-ENTER:

# Display datatypes and data statistics:
df.dtypes
df.describe(include='all')

这将生成如图 20-12 所示的输出。

Image

图 20-12:dtypes() 和 describe() 方法的输出

describe() 方法返回 DataFrame 的快速统计概览。传入 all 会生成 所有 列的统计摘要。如果省略 include 参数,则只会显示 数值 列的摘要。

表中存在的 NaN 值表示 不适用 值,而不是缺失值。例如,你不能对一个像 species 这样的类别特征计算均值,因此结果显示为 NaN

统计表不会告诉你数据集中的每个值是否有效,但它有助于框定事情的好坏。如果最小值、最大值和均值看起来合理,那么数据集可能是可靠的。

使用 countplot 计数观察值

数据表虽然有用,但可能密集且难以解读。例如,数据是偏向雄性还是雌性企鹅?这些信息是存在的,但你需要努力去提取它。

在这些情况下,创建数据的可视化是有益的。seaborn 库提供了许多用于数据探索的统计图表类型(表 20-6)。你可以在 seaborn 画廊中查看这些示例 (seaborn.pydata.org/examples/index.html),并且可以在 seaborn.pydata.org/tutorial.html 找到绘图教程。

表 20-6:有用的 seaborn 绘图方法

方法 描述
barplot() 通过条形图呈现类别数据,条形的高度或长度与所代表的值成比例。
boxplot() 通过四分位数表示数值数据的局部性、离散程度和偏度分组的图形表示。
countplot() 使用条形图显示每个类别数据分箱中的观察计数的可视化。
histplot() 用于将连续数据按类别形式分箱并显示的条形图序列。
jointgrid() 用于绘制包含边际单变量图的双变量图的网格。
jointplot() 用于绘制两个变量的jointgrid()图形的jointgrid()封装函数,使用标准的双变量和单变量图形。
lineplot() 在数轴上显示数据的图形,其中标记位于响应值之上,表示出现次数。
pairgrid() 用于绘制数据集中成对关系的子图网格。
pairplot() 更易于使用的pairgrid()封装函数。
relplot() 用于通过散点图和折线图可视化统计关系的函数。
scatterplot() 使用笛卡尔坐标显示两个变量值的图形。可以通过标记编码(颜色/大小/形状)加入其他变量。
stripplot() 一个类别变量的散点图。
swarmplot() 一个没有重叠点的 stripplot。
violinplot() 结合了箱线图和核密度估计,展示跨越一个或多个类别变量水平的定量数据分布。

让我们看一个示例,其中我们绘制企鹅的数量和性别。在新单元格中输入以下内容,然后按 SHIFT-ENTER:

# Plot species and gender counts:
sns.countplot(data=df, x='species', hue='gender')
plt.xticks(rotation=45)
plt.legend(loc='best');

这将在图 20-13 中生成输出结果。

通过可视化数据,我们可以立刻看到鹤顶企鹅物种的样本略显不足,而且性别之间的分布几乎是平衡的。

Image

图 20-13:企鹅物种和性别数量的条形图

那么企鹅的岛屿分布是怎样的呢?它们是一个大家庭,还是有些更喜欢某个岛屿呢?为了检查这一点,在新单元格中输入以下代码,然后按 SHIFT-ENTER:

# Count and plot penguin species per island:
sns.countplot(data=df, x='island', hue='species')
plt.legend(loc='best');

这段代码统计了每个岛屿上的企鹅数量,将结果以条形图呈现,并根据物种为条形着色(参见图 20-14)。

Image

图 20-14:每个岛屿上采样的企鹅数量条形图,按物种着色

基于图 20-14,我们可以看到,阿德利企鹅生活在所有岛屿上,但鹤顶企鹅仅分布在梦岛,而金图企鹅仅分布在比斯科岛(岛屿位置请参见图 20-1)。所以,如果你从托尔格森岛获得测量数据,你就知道那是阿德利企鹅。而对于其他两个岛屿,由于你只能在这两个岛屿上选择两种物种,维度空间就被简化了。

注意

这里的假设是每个岛屿都已被充分采样。我们假设如果某个岛屿的数据集中没有某种企鹅物种,说明该物种不在该岛屿上栖息。你应该在实际研究中验证这一假设,因为缺乏证据并不等于证据缺失。

另一种按岛屿统计每种物种的方法是结合使用 pandas 的 get_dummies() 方法和 groupby() 方法。第一个方法将分类变量转换为 虚拟变量,这些是用于表示分类数据的数值变量。第二个方法用于对大量数据进行分组并对这些组执行操作。

在这种情况下,我们希望按岛屿 汇总 企鹅物种,因此我们将方法链式调用,并将按 island 列分组的 species 列传递给它,接着使用 sum() 方法。在新的单元格中输入以下代码,然后按 SHIFT-ENTER:

# Count penguins per species per island
count_df = (pd.get_dummies(data=df, columns=['species']).groupby(
    'island', as_index=False).sum())
print(count_df.columns)
count_df[['island', 'species_Adelie', 'species_Gentoo', 'species_Chinstrap']]

调用 print() 可以让你看到新“虚拟”列的名称(以粗体显示):

Index(['island', 'bill_length_mm', 'bill_depth_mm', 'flipper_length_mm',
      'body_mass_g', 'species_Adelie', 'species_Chinstrap', 'species_
      Gentoo'],
      dtype='object')

代码的最后一行显示了 count_df 数据框中的新列(图 20-15)。

图片

图 20-15:按岛屿和企鹅物种汇总列的 count_df 数据框

检查表格数据的一个优势是,低值和高值同样显而易见。而在条形图中,低值可能会被误认为是 0,因为条形的高度较短。

你可以在 pandas.pydata.org/pandas-docs/stable/reference/api/pandas.get_dummies.htmlpandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.groupby.html 上阅读更多关于 get_dummies()groupby() 方法的信息。

通过 pairplot 获取整体图像

由于可视化对于理解数据非常有效,seaborn 提供了 pairplot() 方法,用于绘制数据集中变量之间的成对关系。此方法创建一个坐标轴网格,其中每个变量在单独的一行共享 y 轴,在单独的一列共享 x 轴。这样可以帮助你快速发现数据中的模式。

要创建 pairplot,在新的单元格中输入以下代码,然后按 SHIFT-ENTER:

sns.pairplot(df, hue='species', markers=['o', '*', 'd']);

这里的参数是数据框的名称、用于为图形着色的列和标记类型。你可以在 matplotlib.org/stable/api/markers_api.html 上找到标记类型的列表。

由于数据集只有四列数值型数据,pairplot(图 20-16)非常直观且易于理解。

图片

图 20-16:企鹅数据集的 pairplot

pairplot 使得查看数据分布和关系变得容易。例如,散点图中点聚集成不同的组是重要的,因为它们表明像主成分分析(PCA)和 k 最近邻等分类策略应该能够区分不同物种。具有线性关系的散点图,如鳍长与体重的关系,表明回归技术在已知其中一个特征时可以预测另一个特征。

深入挖掘散点图的细节

尽管包含了大量信息,即使是 pairplot 也不能讲述完整的故事。例如,性别在决定每个物种的体重和喙长中起什么作用?要探究这个问题,你需要更详细的图表。在一个新单元格中输入以下内容,然后按 SHIFT-ENTER:

# Investigate bill length vs. body mass by species by gender:
sns.scatterplot(data=df, 
                x='body_mass_g', 
                y='bill_length_mm', 
                hue='species', 
                style='species', 
                size='gender')

plt.legend(bbox_to_anchor=(1.3, 1.0));

scatterplot() 的调用中,huestylesize 参数分别控制标记的颜色、形状和大小。前两个基于 species,后者基于 gender;因此,表示雌性企鹅的数据点在同一物种中与雄性企鹅的大小不同。调用 legend() 并使用 bbox_to_anchor 参数可以防止图例覆盖图表,遮挡一些数据。你应该会在 图 20-17 中看到结果。

Image

图 20-17:鳍长与体重的散点图,按物种着色,按性别调整大小

这个图表显示,每个物种的雌性通常比雄性更小,喙较短,体重较轻。喙长和体重之间的相关性对于阿德利企鹅和金图企鹅物种而言似乎更强,无论性别如何。

你可以通过 seaborn.pydata.org/generated/seaborn.scatterplot.html 学习更多关于散点图的内容。

使用箱线图和条形图调查类别散点图

我们可以通过使用不同类型的图表,如 箱线图条形图,进一步探索性别关系。要创建一个箱线图,在新单元格中输入以下代码,然后按 SHIFT-ENTER:

# Plot body mass by species by gender:
box = sns.boxplot(x="body_mass_g", 
                  y="gender", 
                  orient='h', 
                  hue='species',
                  data=df)

这会生成图 图 20-18。

Image

图 20-18:按物种和性别划分的企鹅体重箱线图

箱线图提供了关于数据对称性、分组和偏斜度的洞察。每个箱体表示数据分布的第一到第三四分位数,箱内的垂直线表示中位数值。 “胡须”延伸至显示其余的分布,排除被视为“异常值”的点,这些异常值以菱形表示。

根据图 20-18 中的箱型图,阿德利企鹅和帽带企鹅的体型相似,都比金图企鹅小,而且所有物种的雌性企鹅通常都比雄性小。然而,性别之间存在重叠,这意味着单靠体重不能明显区分雄性和雌性。

seaborn 条形图会显示实际数据点,而不是像箱型图那样对数据进行总结。让我们来看看两种物种和性别的喙长测量值。在一个新的单元格中,输入以下代码,然后按 SHIFT-ENTER:

# Plot bill length by species by gender:
strip = sns.stripplot(data=df,
                      x='bill_length_mm', 
                      y='gender', 
                      hue='species', 
                      dodge=True)

dodge 参数会将每个物种的点进行偏移,从而减少重叠,使图表更易阅读(见图 20-19)。

图片

图 20-19:按物种和性别分组的企鹅喙长条形图

根据图表,我们可以看到,阿德利企鹅的喙比其他两种企鹅明显短。性别差异较小,尽管所有物种的雌性企鹅的喙通常较短,平均而言。

使用 jointplot 组合视图

企鹅的另一个可能的特征是喙的垂直厚度,称为其 深度。你可以在图 20-2 中看到,金图企鹅的喙窄且尖,而其他两种物种的喙则更圆胖。尽管有许多方法可以通过图形进行比较,但我们将尝试使用 核密度估计(KDE) 来绘制一个联合图。

KDE 图是一种可视化数据集观察分布的方法,类似于直方图。但与直方图通过计数离散区间内的观察值来逼近数据的概率密度不同,KDE 图通过使用高斯核平滑观察值,从而产生连续的密度估计。这使得在绘制多个分布时,图表更简洁、更易于理解。joinplot() 方法允许你使用双变量和单变量的 KDE 图来绘制两个变量。

在一个新的单元格中,输入以下内容,然后按 SHIFT-ENTER:

# Plot bill depth vs. bill length by species:
sns.jointplot(data=df, 
              x="bill_length_mm", 
              y="bill_depth_mm", 
              kind="kde",
              hue="species", 
              alpha=0.75);

这将生成图 20-20 中的图表。

图片

图 20-20:按物种分组的喙深与喙长的联合图

从联合图中沿边缘的高斯曲线可以看出,阿德利企鹅因其较短的喙长而有所区别,而金图企鹅则因其较浅的喙深而有所区别。

你可以通过多种方式自定义 jointplot。要查看一些示例,请查看文档中的 seaborn.pydata.org/generated/seaborn.jointplot.html

使用 radviz 可视化多个维度

pandas 库自带基于 Matplotlib 的绘图功能。这包括用于以二维格式绘制多维数据集的 radviz()(径向可视化)方法(见图 20-21)。

图片

图 20-21:汽车数据集的一个示例 Radviz 图

在径向可视化中,DataFrame 中的维度,如企鹅的体重或喙长,会均匀地分布在圆周上。这些数值型列中的数据会被归一化到 0 到 1 之间,以确保所有维度的权重相等。然后,这些数据会投影到圆形的二维空间中,就像假想的弹簧将它们固定在圆周上的列标签处。一个点被绘制在“弹簧”力作用的合力为零的地方。

径向可视化本质上是直观的。具有相似维度值的点会聚集在圆心附近,维度值相似且位于圆上相对位置的点也会聚集在一起。维度值较大的点会被“拉”向圆的这一侧。企鹅数据集只有四个维度,但 radviz() 方法可以处理更多维度。

要制作径向可视化,请在新单元格中输入以下内容,然后按 SHIFT-ENTER:

   # Make radial visualization:
➊ sns.set_theme(context='talk')
   plt.figure(figsize=(7, 7))
➋ pd.plotting.radviz(df.drop(['island', 'gender'], axis=1), 
                      class_column='species',
                      color=['black', 'red', 'grey'], 
                      marker='+', 
                      alpha=0.7)
   plt.legend(loc=(1.01, 0.7));

为了使 Radviz 图看起来更好,可以使用 set_theme() 方法重置默认的 seaborn 绘图参数,并将上下文设置为 'talk' ➊。context 参数控制图表元素的缩放,比如标签大小和线条粗细。基本上下文是 notebook,其他上下文有 papertalkposter,这些都是通过不同的缩放值调整后的 notebook 参数。使用 talk 参数可以确保图表标签易于阅读。为了更好地提高可读性,手动将图形尺寸设置为 7" × 7"。

接下来,调用 pandas 的 plotting.radviz() 方法 ➋。此方法只接受一个类别列,称为 class_column,在此例中为 species。其他 DataFrame 列假定为数值型,因此我们需要删除不包含数值数据的 islandgender 列。你可以通过创建 DataFrame 的副本来实现这一点,但因为我们只需要这个修改后的 DataFrame 来进行绘图,所以我们将临时删除这些列,使用 drop() 方法,同时将 DataFrame 传递给 radviz() 方法。

drop() 方法接受两个参数:作为列表的列名和轴编号,其中 0 = 行,1 = 列。除非你传递 inplace=True 参数,否则 DataFrame 只会在当前操作中发生变化。

因为我们没有使用 seaborn 绘图,所以需要提醒 pandas 使用我们选择的颜色方案,并且更改标记样式和透明度,以便更容易看到重叠的点。将图例移到一旁也有帮助。注意我们是如何将 seaborn (sns) 和 Matplotlib 的 pyplot (plt) 与 pandas 的 plotting 结合使用的。这是因为 seaborn 和 pandas 都是建立在 Matplotlib 之上的。

你应该能看到 图 20-22 中显示的图表。

Image

图 20-22:企鹅数据集的 Radviz 图

在图中,Gentoo 数据点形成一个明显的簇,倾向于体重和鳍肢长度。体型相似的 Chinstrap 和 Adélie 企鹅主要通过喙长区分,这使得 Chinstrap 的点位于中心的右侧。

radviz 图是探索数据的另一种方式,并且在数据维度更多时变得更有用。要了解更多关于 pandas 实现的内容,请访问pandas.pydata.org/docs/reference/api/pandas.plotting.radviz.html

这里值得注意的是,我们更改了绘图样式。我将继续使用这种新样式,但如果你想返回到以前的'whitegrid'样式,需要在新单元格中输入以下代码,然后再绘制更多图形:

# Restore theme and palette:
sns.set_theme(context='notebook') 
sns.set_style("whitegrid")
sns.set_palette(['black', 'red', 'grey'])
使用 corr()量化相关性

pandas 的DataFrame类提供了一个corr()方法,通过计算列之间的逐对相关性(排除 NA/null 值)来量化数据相关性。当你计划使用回归技术进行预测时,这非常有用。

在一个新单元格中,输入以下内容并按 SHIFT-ENTER:

correlations = df.corr()
sns.heatmap(correlations, center=1, annot=True);

第一行调用corr()方法,并将结果赋值给 correlations 变量。下一行将结果绘制为 seaborn 热图(图 20-23)。center参数是可选的,它告诉该方法绘制分歧数据时,色彩映射的中心值。如果值为1,最佳相关性将以黑色显示。annot参数启用每个彩色方块内的绘图注释。

Image

图 20-23:相关性热图

热图确认并量化了我们在 pairplot 中注意到的相关性(图 20-16)。鳍肢长度和体重的相关性最强,其次是鳍肢长度和喙长。

要了解更多关于corr()方法和 seaborn 热图的信息,请访问pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.corr.htmlseaborn.pydata.org/generated/seaborn.heatmap.html

测试你的知识

1.  以下哪些是 pandas 系列或数据框相比 NumPy 数组的优势?

a.  使用异构数据类型的能力

b.  能够使用数字标签作为索引

c.  加载 Python 字典的能力

d.  处理表格数据的易用性

2.  对错:在重命名数据框中的列后需要重新索引。

3.  将以下字典转换为数据框,并将最后一列重命名为“whales”:

animals = {'canines': ['husky', 'poodle', 'bulldog'],

'felines': ['Siamese', 'Persian', 'Maine Coon'],

'cetaceans': ['humpback', 'sperm', 'right']}

4.  显示上一题中animals数据框的第一行。

5.  翻转animals数据框中的行和列(提示:查阅 pandas 的transpose()方法)。

使用 k-最近邻算法预测企鹅物种

本项目的目标是开发一个基于 Palmer Archipelago 数据集对企鹅进行分类的模型。我们的数据探索显示,四个形态特征(喙长和喙深、鳍长和体重)在多个图表中形成了不同但重叠的聚类。这意味着一个机器学习分类算法应该能够处理这个问题。

最好从简单的开始,如果你稍微做些研究,你会发现 k-最近邻k-NN)是机器学习中最基础、最适合初学者的分类算法之一。它使用距离度量直观地找到一个新未知数据点的 k 个最近邻,然后利用这些邻居做出预测。

在图 20-24 中,两个分类类(A 和 B)的数值数据点在散点图中绘制。一个新的未标记数据点(⋆)位于两个聚类之间。为了对这个新点进行分类,算法的 k 参数设置为 7。由于大多数最接近的点属于 B 类,因此新数据点将被分配到 B 类。由于这是一个“投票”算法,k 应始终设置为奇数,以避免出现平局。

Image

图 20-24:k-NN 算法选择七个最接近的新数据点邻居的示例

除了直观且易于解释外,k-最近邻算法运行速度快,适用于小型数据集,能有效抵抗噪声,并且可以调优以提高准确性。它还具有多功能性,因为可以同时应用于分类和回归问题。

然而,该算法需要一个密集的数据集,以确保数据点之间不会相距太远。数据维度越多(例如鳍长和体重),所需的数据量就越大,以确保k-最近邻算法能够正常工作。

此外,k-最近邻算法像其他机器学习算法一样,需要自己的数据准备流程。由于该算法只能处理数值数据,因此通常需要将分类值转换为整数,并将数值数据归一化到 0 和 1 之间。归一化是必要的,因为较大数值的维度会扭曲距离计算。

将分类数据转换为数值数据

如前所述,k-最近邻算法使用 数值 数据。为了利用重要的非数值数据,例如来源岛屿和性别,你需要将这些值转换为数字。

首先对island列执行此操作,使用我们之前在计算每个岛屿企鹅数量时用过的 pandas get_dummies()方法。接下来,我们将手动对性别重复这个练习,这样你可以练习 DataFrame 索引。在一个新单元格中输入以下内容,然后按 SHIFT-ENTER:

   # Prepare for k-NN.
   # Add numerical columns for island and gender labels:
   knn_df = pd.get_dummies(data=df, columns=['island'])

➊ knn_df['male'] = 0
   knn_df.loc[knn_df['gender'] == 'Male', 'male'] = 1

   knn_df['female'] = 0
   knn_df.loc[knn_df['gender'] == 'Female', 'female'] = 1

➋ knn_df.iloc[:300:30]

要使用 get_dummies(),将数据框和你想转换的列标签传递给它。将结果赋值给一个名为 knn_df 的新数据框。该方法将创建三个新列——每个岛屿一个——其值为 0 或 1,具体取决于 island 列中的值(可能是 BiscoeDreamTorgersen)。

接下来,为了演示,我们将使用不同的方法将 gender 列转换为新的 malefemale 列。我们将为每个类别创建一个新列,并用零填充它。然后,使用条件语句,我们将查找目标类别所在的行,并将列值更改为一。例如,雄性企鹅的列将在 gender 列包含雄性标记的行中显示 1;对于所有其他行,该列将显示 0

首先,创建一个名为 male 的新列。为该列分配一个值 0 ➊。接下来,使用 pandas 的 loc 索引运算符来选择 knn_df 数据框的一个 子集。因为 pandas 可以使用基于标签和基于整数的索引来处理行和列,所以它有两个索引运算符。loc 运算符用于严格基于标签的索引,而 iloc 运算符用于处理标签严格为整数的情况。在我们的例子中,列使用标签(例如“species”),行使用整数。

当前操作将把 gender 列中的 Male 值转换为 male 列中的 1 值。所以,选择 gender 列(knn_df['gender']),然后使用条件语句覆盖我们在前一行中设置的 0 值。你在这里的意思是,“获取 gender 列,如果它的值是 Male,就将 1 放入 male 列中。”

对女性列重复这段代码,然后通过使用 iloc 运算符对数据框中的行进行采样来检查结果 ➋。这就像索引一个列表,你从头开始,到达索引 300,并使用步长 30 来选择每第 30 行。

你应该在图 20-24 中看到输出。注意数据框右侧的五个新列。现在,类别化的岛屿和性别列可以被 k-NN 算法使用,这样你就可以利用所有可用数据。

Image

图 20-25:一个新的 knn_df 数据框示例,其中包含岛屿和性别的新数值列

通过将性别和岛屿数据转换为数字,我们为形态学数据补充了两个新维度。

如果你的目标是预测在海上采集的企鹅物种,你可能需要删除与岛屿相关的列,因为你无法确定企鹅的原始地点。

设置训练和测试数据

k-NN 分类器是一种监督学习算法。这意味着你通过提供一组已知的例子,即“训练”数据集,向它展示答案应该是什么样子。然而,你不能使用所有可用的数据作为训练集,因为这样你就没有客观的方式来测试结果。因此,你需要随机地划分出一个较小的子集,用来测试模型的准确性。

作为一种懒惰学习算法,k-NN 没有实际的训练阶段,在训练阶段它不会“学习”一个判别函数来应用于新数据。相反,它加载或记住数据,并在预测阶段使用数据进行计算。

为了方便,scikit-learn 将train_test_split()方法提供为sklearn.model_selection模块的一部分。在新的单元格中输入以下代码,然后按 SHIFT-ENTER:

   # Break out numerical and target data and split off train and test sets:
   from sklearn.model_selection import train_test_split

➊ X = knn_df.select_dtypes(include='number')  # Use numerical columns.
   y = knn_df['species']  # The prediction target.

   # Split out training and testing datasets:
➋ X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                       test_size=0.25,
                                                       random_state=300)

导入模块后,在新的knn_df数据框上调用 pandas 的select_dtypes()方法 ➊。此方法返回一个数据框的子集,包含或排除根据数据类型筛选的列。我们需要数值列用于k-NN 算法,因此将include设置为'number',并将结果赋值给名为X的变量。

接下来,将species列赋值给名为y的变量。这代表你要预测的分类标签。请注意,大写的“X”和小写的y格式遵循 scikit-learn 文档中的惯例。

使用train_test_split()方法分割训练和测试数据 ➋。你需要为Xy的训练和测试解包四个变量。因为我们传递给方法的是数据框,它将返回数据框。

这里的一个关键参数是test_size,它表示完整数据集的比例。默认情况下,这个值是 0.25,也就是 25%。因此,对于我们的企鹅数据集,这代表 83 个样本(332 × 0.25)。

为了避免偏倚,train_test_split()方法会在划分数据前随机打乱数据。为了在多次函数调用中生成可复现的结果,你可以向random_state参数传递一个整数。按照当前的写法,这段代码让你产生一组可重复的训练和测试数据。要生成新的随机数据集,你需要更改random_state的值或完全不使用它。

尽管在这里我们不需要它来获得良好的结果,但train_test_split()方法带有一个stratify参数,它确保数据划分时保持每个目标类样本的比例与原始数据集中的比例一致。因此,如果原始数据集采样了 25%的 A 类和 75%的 B 类,训练集和测试集将反映这一比例。这有助于避免抽样偏差,即样本不能代表真实的总体。

要了解更多关于 train_test_split() 方法的信息,请访问 scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html

标准化数据

训练集和测试集中的每一列数值数据应该被标准化到 0 和 1 之间。这可以防止具有大数值的列对 k-NN 距离度量造成偏差。

由于列转换(例如标准化)是机器学习中的常见操作,scikit-learn 提供了两个模块,composepreprocessing,来简化这一任务。在一个新的单元格中输入以下内容,然后按 SHIFT-ENTER:

   # Normalize numerical columns to 0-1:
   from sklearn.compose import make_column_transformer
   from sklearn.preprocessing import MinMaxScaler

➊ column_transformer = make_column_transformer((MinMaxScaler(),
                                                 ['bill_depth_mm',
                                                  'bill_length_mm',
                                                  'flipper_length_mm',
                                                  'body_mass_g']), 
                                                remainder='passthrough')
   X_train = column_transformer.fit_transform(X_train) ➋ X_train = pd.DataFrame(data=X_train, 
                          columns=column_transformer.get_feature_names_out())
   X_train.head()

   X_test = column_transformer.fit_transform(X_test)
   X_test = pd.DataFrame(data=X_test, 
                         columns=column_transformer.get_feature_names_out())
   X_test.head()

首先导入 make_column_transformer() 方法和 MinMaxScaler() 方法。第一个方法让我们转换列数据;第二个方法指定如何进行转换。

为了标准化数据,需要将其缩放,使得最小值和最大值介于 0 和 1 之间。将 make_column_transformer() 方法与 MinMaxScaler() 方法一起传入 ➊。接下来,传入你希望转换的列;在此情况下,是那些尚未缩放到 0 和 1 的数值列。默认情况下,转换器会 丢弃 你在前一个参数中未指定的列。为了避免这种情况,将 remainder 参数设置为 passthrough

现在你需要通过调用 fit_transform() 方法并传入你在上一节创建的 X_train 变量来应用转换器。此方法会转换数据并将结果合并,返回一个数组。要将此数组转换回 DataFrame,请调用 pandas 的 DataFrame 类并传入 X_train 数组 ➋。列转换器不仅会转换列,还会重命名它们,因此对于 columns 参数,请在 column_transformer 对象上调用 get_feature_names_out() 方法。

调用 X_train.head() 查看结果,然后为测试集重复此代码。两个 DataFrame 的表头显示在 图 20-26 中。在两个 DataFrame 中,列应该具有新名称,所有列应包含数值数据,且值应介于 0.0 和 1.0 之间。

Image

图 20-26:标准化后的 X_train 和 X_test DataFrame 的表头(横向截断显示)

要了解更多关于 scikit-learn 列转换器的信息,请访问 scikit-learn.org/stable/modules/generated/sklearn.compose.make_column_transformer.html

现在你有了只有数字的 DataFrame,可以用于训练和测试。x_testy_test DataFrame 让你将这些数字 DataFrame 与物种标签关联起来。这个数据集只有七个维度,是一个 低维 数据集。一个 高维 数据集,在机器学习中很常见,可能包含超过 100,000 个特征!

运行 k-NN 并检查预测的准确性

到此为止,数据已经准备好用于 k-NN 分类器了。是时候给那只企鹅命名了!

在一个新的单元格中,输入以下代码,然后按下 SHIFT-ENTER 键:

# Run k-NN and check accuracy of prediction:
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score

knn = KNeighborsClassifier(n_neighbors=5, p=2)
knn.fit(X_train, y_train)
predictions = knn.predict(X_test)

accuracy = accuracy_score(y_test, predictions)
print(f"Model accuracy = {accuracy}")

要运行 k-NN,你需要从 scikit-learn 的 neighbors 模块中导入 KNeighborsClassifier 类(scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html)。要检查结果,请从 metrics 模块中导入 accuracy_score() 方法。

调用 KNeighborsClassifier,并传入 k 值为 5p 值为 2p 值告诉分类器使用欧几里得(即直线)距离度量。通常,低维数据集且异常值较少时,这种方式是合适的。

接下来,调用分类器的 fit() 方法来训练模型,然后在 X_test 数据集上运行 predict() 方法。这将使用你 从训练中保留 的测量数据来预测物种。

最后,调用 accuracy_score() 方法,并传入 y_testpredictions 变量。此方法将比较这两个数据集,并将准确度指标存储在 accuracy 变量中,随后你可以打印出来(图 20-27)。

图片

图 20-27:k-NN 模型的准确度

一开始,模型准确地匹配了 X_test 数据集中的约 99% 的样本。但不要太激动。这仅仅是匹配了 penguins 数据集中的一个 随机选择 子集中的样本。如果你通过将 train_test_split() 方法的 random_state 参数从 300 改为 500 来生成一个新的测试集,并重新运行这些单元格,你会得到 0.9642857142857143 的准确度。虽然这对于一个真实世界的数据集来说已经算是最好的结果,但我们可以利用这个差异来探讨如何在其他项目中处理更大程度的错配。

使用交叉验证优化 k 值

监督式机器学习的目标是使模型能够超越我们在训练样本中看到的内容,从而能够可靠地分类新的数据。k-NN 分类器使用多个 超参数,如 kpweights 来控制学习过程。此外,train_test_split() 方法中的 test_size 和其他参数也会对模型结果产生重大影响。你可以将这些参数视为可以调节的旋钮,来“调整”或“定制”模型的拟合程度。

然而,必须小心不要将参数调整得 精细。过拟合 是一个常见问题,它可能潜藏在一个表面上看似准确的模型背后(图 20-28)。

图片

图 20-28:原始训练集(左)和与新、未匹配的训练集叠加(右)的模型拟合示例

在图 20-28 中,左侧的图表展示了对随机化训练数据集的三种拟合(过拟合、欠拟合和最佳拟合)。任何落在这些线右侧的点将被归类为属于 B 类。

如果左侧图表代表我们将来所有的数据,过拟合的模型将是最准确的。但当我们选择并发布一个新的训练集(图 20-28 右侧)时,看看会发生什么。一些点保持不变,其他点发生变化,过拟合的模型不再与数据匹配。另一方面,欠拟合的模型既无法拟合训练数据,也无法足够地推广到新数据。

通常,k 值越小,模型与数据的拟合越“紧密”,也越容易出现过拟合;k 值越大,模型越可能出现欠拟合。

在图 20-28 中,更加泛化的“最佳拟合”模型很好地匹配了这两个数据集。为了实现这个泛化模型,我们需要找到重要超参数的最佳值。但在多维空间中工作时,这不是直观的。它需要通过迭代调查,在多次改变参数后,记录和评分结果。

在接下来的代码中,我们将使用 交叉验证(简称 cv)来研究一系列 k 值。这是一种模型验证技术,用于评估统计分析结果如何推广到新的、未知的数据集。它还帮助发现诸如过拟合等问题。

交叉验证通过重新抽样训练集的不同部分来创建测试集。为了确保整个训练集都得到评估,它会多次重复这种抽样。随着迭代的进行,它会改变一个超参数的值,比如 k,并根据模型准确性对结果进行评分。当找到最佳参数时,你将其输入到 k-NN 分类器中,并对与交叉验证过程分离的测试数据集进行最终评估(图 20-29)。

Image

图 20-29:使用交叉验证构建预测模型(修改自scikit-learn.org)

要在我们的企鹅数据集模型上使用交叉验证,在新单元格中输入以下内容,然后按下 SHIFT-ENTER:

   # Run cross-validation on k:
   import numpy as np
   from sklearn.model_selection import cross_validate

   cv_metrics = {'train_score_ave': [],
                 'cv_score_ave': []}

   num_neighbors = {'k': np.arange(1, 25)} for k in num_neighbors['k']:
    ➊ knn = KNeighborsClassifier(n_neighbors=k, p=2)
       scores = cross_validate(knn, X_train, y_train, return_train_score=True)
       cv_metrics['cv_score_ave'].append(np.mean(scores['test_score']))
       cv_metrics['train_score_ave'].append(np.mean(scores['train_score']))

➋ cv_metrics_df = pd.DataFrame(cv_metrics)
   cv_metrics_df.insert(loc=0, column='k', value=num_neighbors['k'])
   cv_metrics_df.head(10)

   best_k = cv_metrics_df.loc[cv_metrics_df['cv_score_ave'].idxmax()]
   print(f"Best k for current training and testing set: {int(best_k['k'])}")

我们将使用 NumPy 来创建一系列 k 值,以评估并平均每次交叉验证迭代的结果。cross_validate() 方法位于 scikit-learn 的 model_selection 模块中。

Python 字典非常适合存储诸如测试结果之类的数据,并且可以轻松转换为 pandas DataFrame。在导入之后,创建一个名为 cv_metrics 的字典,键为每次迭代的平均训练集和交叉验证分数。这些字典键的初始值是空列表。

接下来,创建一个num_neighbors字典,其中包含一个键值对:k和从 1 到 25 的 1Dndarray。这些代表您将要测试的k值范围。

遍历num_neighbors字典,并将当前的k值传递给KNeighborsClassifier ➊。然后,调用cross_validate()方法,将其传递给knn模型和训练数据,并将return_train_score参数设置为True。在每次循环结束时,将分数结果附加到cv_metrics字典中的相应键中。使用 NumPy 的mean()方法对整个过程中每个数据点的分数进行平均。

在循环之外,将cv_metrics字典转换为 DataFrame ➋,然后将num_neighbors字典作为新列添加到 DataFrame 的最左侧。通过在 DataFrame 上调用insert()方法,并传递第一列位置(loc=0)、列名和值(通过将num_neighbors字典传递给k键获得)。最后调用head(10)以显示前 10 行。

与其在 DataFrame 中滚动查找具有最佳分数的k值,不如使用idxmax()方法让 pandas 来找到它,该方法返回请求轴上第一次出现的最大值的索引。默认情况下,这是轴 0(行)。当打印结果时,您应该在图 20-30 中看到输出。

比较交叉验证和训练分数可以深入了解模型的过拟合和欠拟合情况。然而,对于高维数据集来说,这个过程计算开销很大,并且不需要训练分数来选择最佳参数。

图像

图 20-30:cv_metrics DataFrame 中的前 10 行和最佳k值的 cv 选择

要绘制 cv 结果,在新单元格中输入以下内容,然后按 SHIFT-ENTER:

# Plot cross-validation results:
sns.set_palette(['black', 'red', 'grey']) 
df_melt = cv_metrics_df.melt('k', var_name='cols',  value_name='vals')
sns.lineplot(x='k', y="vals", hue='cols', data=df_melt);

第一行重置我们一直使用的 seaborn 颜色调色板,以保持一致的外观。下一行准备 DataFrame 进行绘图。在 seaborn 中,将多列绘制到同一 y 轴上需要调用 pandas 的melt()方法。此方法返回一个新的 DataFrame,将其重塑为长表格,每列一个行。有关宽格式和长格式数据的详细信息,请参阅seaborn.pydata.org/tutorial/data_structure.html

使用新的df_melt DataFrame,您可以调用 seaborn 的lineplot()方法来获得图 20-31 中的绘图。顶部曲线代表平均训练分数。

图像

图 20-31:使用k值进行平均训练分数和交叉验证分数的比较

如果你计划广泛使用 pandas,考虑学习它的绘图语法。例如,你可以用一行代码重新创建图 20-31:cv_metrics_df.plot.line(x='k')。这些图表的自定义能力不如 seaborn 或 Matplotlib,但对于数据探索来说已经足够。要了解更多信息,请访问 pandas.pydata.org/docs/getting_started/intro_tutorials/04_plotting.html

欠拟合的模型将具有较低的训练和测试准确性,而过拟合的模型将具有较高的训练准确性,但测试准确性较低。在图 20-31 的左侧,当 k = 1 时,训练集是完全准确的,因为训练数据点仅与其自身进行比较。然而,交叉验证结果却不那么准确。这表明存在轻微的过拟合,我们可以预期在 k 值较低时会出现这种情况。

在图的右侧,当 k 大于 20 时,两个曲线的准确性都会下降。模型试图适应过多的数据点,导致出现欠拟合。

k = 4 时,交叉验证得分达到了最高的平均准确性,两个曲线开始汇聚并平行运行。在这种情况下,5 到 10 之间的 k 值只会增加计算负担,而不会提高模型准确性。

如果你更改 train_test_split() 方法中的 random_statetest_size 参数并重新运行代码,你会看到最佳 k 值的变化。这是因为模型开始时的准确性非常高,因此微小的随机效应可以产生较大的相对影响,而几乎没有绝对影响。

使用 GridSearchCV 优化多个超参数

运行交叉验证可能需要一些时间,因此 scikit-learn 提供了一个名为 GridSearchCV 的便捷类。它接受一个参数名称和值的字典,对其进行交叉验证,并报告结果。你可以在 scikit-learn.org/stable/modules/generated/sklearn.model_selection.GridSearchCV.html 上找到文档。

在一个新的单元格中,输入以下内容并按 SHIFT-ENTER:

from sklearn.model_selection import GridSearchCV

params = {'n_neighbors': np.arange(1, 20),
          'weights': ['uniform', 'distance'], 
          'p': [1, 2]}

grid = GridSearchCV(estimator=knn, 
                    param_grid=params, 
                    scoring='accuracy', 
                    verbose=1)
grid_results = grid.fit(X_train, y_train)
print(f"Best parameter values: {grid_results.best_params_}")

一个名为 params 的字典包含超参数范围。在此示例中,kn_neighbors)范围是一个 NumPy 数组,weightsp 参数使用列表。

GridSearchCV 类需要知道你正在使用的 DataFrame(knn)、参数字典的名称(params)、评分依据(accuracy)以及你希望它报告的详细程度。通过传递 verbose=1,我们抑制了大部分无关的输出。

在拟合模型后,你可以打印 best_params_ 属性查看结果(图 20-32)。

Image

图 20-32:运行 GridSearchCV 的结果

接下来,在一个新单元格中,将网格搜索识别的最佳参数传递给 KNeighborsClassifier,拟合模型,对测试数据集进行预测,并评估准确性。这对应于图 Figure 20-29 中的“重新训练的模型”和“最终评估”步骤。

knn = KNeighborsClassifier(n_neighbors=4, p=2, weights='uniform')
knn.fit(X_train, y_train)
predictions=knn.predict(X_test) 

accuracy = accuracy_score(y_test, predictions)
print(f"Model accuracy = {accuracy}")

你应该得到如图 Figure 20-33 所示的输出。

Image

图 20-33:应用优化超参数后的模型准确性

你可能会注意到,这个分数比我们最初使用 k=5 时的分数差(见 Figure 20-27)。这是因为这次在 单一 训练-测试数据集上的初步运行相当于一次幸运的骰子投掷。当前使用 k=4 构建的模型已经在 多个 数据集上进行了测试,并且在重复使用时应该会得到与 k=5 相同的平均准确度(见 Figure 20-31),但具有更好的计算效率。

在这些思路下,我们仅使用了企鹅数据集的 75%来训练模型。我们怎么知道 80%的数据比例不会得到更好的结果呢?为了找出答案,你可以使用一个循环,运行 train_test_split() 方法的 test_sizerandom_state 参数的多个组合,并对每个组合进行建模。

需要测试许多参数和数据集组合,并且内存使用量较大,这使得 k-NN 算法不适用于非常大的数据集。否则,它有许多优点,包括易于使用、易于理解、训练速度快、多功能且不依赖于底层数据分布的假设。

测试你的知识

6.  要概览数据集的整体情况,可以调用:

a.  seaborn relplot() 方法

b.  pandas radviz() 方法

c.  seaborn pairplot() 方法

d.  seaborn jointplot() 方法

7.  k-NN 算法适用于:

a.  高维数据集中的分类

b.  计算机内存稀缺的项目

c.  在嘈杂、低维数据集中的分类

d.  以上所有

8.  使用非常低的 k 值与 k-NN 算法一起使用可能导致:

a.  过长的运行时间

b.  一个欠拟合的模型

c.  一个过拟合的模型

d.  一个通用模型

9.  在机器学习中,超参数是:

a.  一个由算法自动选择的参数

b.  设置在算法顶层的参数

c.  一个可调节的参数,用于控制学习过程

d.  一个过于兴奋的参数

10.  交叉验证用于:

a.  检查模型在独立测试集上的准确性

b.  从超参数输入范围中找到最优的超参数

c.  检查数据集是否有重复样本

d.  获取关于模型欠拟合和过拟合的洞察

总结

Palmer 企鹅项目提供了一个很好的概述,展示了 pandas、seaborn 和 scikit-learn 的工作原理,它们是如何协同工作的,以及你可以使用它们完成什么任务。然而,在这一点上,你仅仅是略微接触到了这些包的庞大功能。为了扩展你的知识,我推荐参考本章介绍中的官方库文档以及以下书籍:

Python 数据分析:使用 Pandas、NumPy 和 IPython 进行数据整理,第二版,Wes McKinney 著(O’Reilly Media,2018 年),是由 pandas 库的创建者编写的不可或缺的指南。

Python 数据科学手册:处理数据的必备工具,Jake VanderPlas 著(O’Reilly Media,2016 年),是一本详尽的参考书,涵盖了重要的 Python 数据科学工具,包括 pandas。

动手实践机器学习:Scikit-Learn、Keras 与 TensorFlow 的概念、工具与技术,第二版,Aurélien Géron 著(O’Reilly Media,2019 年),为机器学习初学者提供了实用的指导。

尽管企鹅项目覆盖了很多内容,但它并没有涉及科学家使用的最重要的结构化数据形式之一:时间序列数据。在接下来的最终章节中,我们将探讨如何在程序和图表中加入日期和时间的方法。

第二十一章:使用 Python 和 Pandas 管理日期和时间

image

在数学中,时间序列 是按时间顺序索引的一系列数据点。它们是科学数据集中常见的组件,其中观察是在一段时间内进行的。

尽管你我都将“11/11/1918”识别为日期,但计算机将此值视为字符串。为了智能地处理日历日期以及小时、分钟、秒等,Python 和 pandas 将它们视为特殊对象。这些对象“知道”公历日历、六十进制(基础 60)时间系统、时区、夏令时、闰年等机制。

原生 Python 支持通过其 datetime 模块的时间序列,而 pandas 则是面向使用日期数组,例如 DataFrame 中的索引或列。除了其用于处理固定频率和不规则时间序列的内置工具和算法外,pandas 还使用 datetime 模块。固定频率 时间序列中的观察是以固定间隔(例如每天一次)记录的。否则,时间序列被认为是不规则的性质。

我们将在这里看一看 Python 和 pandas 的方法,目标是介绍与时间序列工作的基础,并使您熟悉这一主题。更详细的信息,您可以访问 docs.python.org/3/library/datetime.html 获取 Python 的 datetime 模块和 pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html 获取 pandas 工具。

Python datetime 模块

Python 的内置 datetime 模块包括 datetime 和组合的 datetime 类型。通过将时间信息视为特定数据类型,Python 知道如何正确高效地操作它。这包括处理时区、夏令时 (DST)、闰年和不同的国际格式化方法。

在这个简要介绍中,我们将看一看如何用 时间戳 标记时间序列数据,表示特定的时间点;时间 间隔,由开始和结束时间戳划分;以及 固定周期,如一年。您还可以追踪经过的时间,例如相对于实验开始的时间。我们还将讨论如何将 datetime 对象转换为字符串,然后再转换回来。

获取当前日期和时间

datetime.now() 方法基于您计算机的时钟返回当前日期和时间。在安装了 NumPy、pandas 和 Matplotlib 的环境中,启动 Jupyter Qt 控制台并输入以下内容(您将看到一个不同的日期,由于明显的原因):

In [1]: from datetime import date, time, datetime

In [2]: now=datetime.now()

In [3]: now
Out[3]: datetime.datetime(2022, 10, 27, 17, 51, 26, 382489)

In [4]: type(now)
Out[4]: datetime.datetime

now() 方法以 ISO 8601 格式(年-月-日)返回日期。ISO 8601 是数字日期的全球标准格式。

now 变量表示 datetime 数据类型。用于存储日期和时间信息的其他类型见 表 20-1。

表 21-1: Python datetime 模块中的数据类型

数据类型 描述
date 公历日期,格式为年、月、日
datetime 组合的 datetime 类型
time 二十四小时制(军用)时间,包含小时、分钟、秒和微秒
timedelta 两个 datetime 对象之间的差值,单位为天数、秒数和微秒数
tzinfo 时区信息

要访问 now 对象中的 datetime 数据,或任何其他时间戳,使用它的 datetime 属性,使用点符号调用:

In [5]: now.day
Out[5]: 27

In [6]: now.hour
Out[6]: 17

In [7]: now.minute
Out[7]: 51

In [8]: now.microsecond
Out[8]: 382489

要提取 datetime 对象,请使用相同名称调用 datetime 方法:

In [9]: now.date()
Out[9]: datetime.date(2022, 10, 27)

In [10]: now.time()
Out[10]: datetime.time(17, 51, 26, 382489)

分配时间戳并计算时间差

要将时间戳分配给变量,将 datetime() 与年-月-日-小时-分钟-秒-微秒格式的日期和时间传递:

In [11]: ts = datetime(1976, 7, 4, 0, 0, 1, 1)

要将其作为字符串查看,将变量传递给 Python 内置的 str() 函数:

In [12]: str(ts)
Out[12]: '1976-07-04 00:00:01.000001'

如果你不关心时间数据,只需将 datetime() 与日期一起传递:

In [13]: ts = datetime(1976, 7, 4)

In [14]: str(ts)
Out[14]: '1976-07-04 00:00:00'

timedelta 对象表示一个 持续时间,即两个日期或时间之间的差异。减去两个 datetime 对象得到的是经过的时间。为了演示,我们来计算 Python 创建者 Guido van Rossum 在 2022 年 10 月 28 日的年龄:

In [15]: delta = datetime(2022, 10, 28) - datetime(1956, 1, 31)

In [16]: delta
Out[16]: datetime.timedelta(days=24377)

In [17]: age = delta.days / 365.2425

In [18]: int(age)
Out[18]: 66

如果你同时包含日期和时间信息,timedelta 对象将显示天数、秒数和微秒数,这些是唯一存储的内部单位:

In [19]: dt1 = datetime(2022, 10, 28, 10, 36, 59, 3)

In [20]: dt2 = datetime(1956, 1, 31, 0, 0, 0, 0)

In [21]: delta = dt1 - dt2

In [22]: delta
Out[22]: datetime.timedelta(days=24377, seconds=38219, microseconds=3)

timedelta 对象支持加法、减法、乘法、除法、取模等算术运算。要查看支持的完整操作列表,请访问 pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Timedelta.html

格式化日期和时间

如你所见,datetime 输出并不十分友好。使用 str() 函数将其转换为字符串可以有所帮助,但你还可以通过使用 datetimestrftime() 方法做得更多。

strftime() 方法使用 C 语言(ISO C89)兼容的规范或 指令,这些指令以 % 符号开头。常用的一些指令列在 表 21-2 中。

表 21-2: 选择的 Datetime 格式规范

指令 描述 示例
%a 缩写的星期几名称 Sun, So, Mon, Mo, Sat, Sa
%A 星期几的全名 Sunday, Sonntag
%d 两位数的星期几 01, 02, ..., 05
%b 月份的缩写 Jan, Feb, Dec, Dez
%B 月份的全名 February, Februar
%m 两位数的月份 01, 02, ..., 31
%y 两位数的年份 00, 01, ..., 99
%Y 带世纪的年份,十进制数 0001, ..., 2022, ..., 9999
%H 二十四小时制的小时 01, 02, ..., 23
%I 十二小时制的小时数 01, 02, . . ., 12
%p 上午或下午 AM, am, PM, pm
%M 两位数的分钟数 01, 02, . . ., 59
%S 两位数的秒数(60, 61 用于闰秒) 01, 02, . . ., 59
%f 微秒的十进制表示(零填充六位数字) 000000, 000001, . . ., 999999
%w 星期几的整数,0 代表星期日 0, 1, . . ., 6
%W 一年中的星期数(星期一 = 一周的第一天;在第一个星期一之前的天数为第 0 周) 00, 01, . . . 53
%U 一年中的星期数(星期日 = 一周的第一天;在第一个星期日之前的天数为第 0 周) 00, 01, . . . 53
%Z 时区名称(空为天真对象) (空),UTC,GMT
%c 适合地区的日期和时间表示 Wed Mar 30 09:14:12 2022
%x 适合地区的日期表示 07/31/1984, 31.07.1984
%X 适合地区的时间表示 13:30:15
%F %Y-%m-%d格式的快捷方式 2022-03-30
%D %m/%d/%y格式的快捷方式 03/30/22
粗体 = 地区特定的日期格式

在控制台中,输入以下内容以查看一些示例格式:

In [23]: now = datetime.now()

In [24]: now.strftime('%m/%d/%y %H:%M')
Out[24]: '10/30/22 09:14' In [25]: now.strftime('%x')
Out[25]: '10/30/22'

In [26]: now.strftime('%A, %B %d, %Y')
Out[26]: 'Sunday, October 30, 2022'

In [27]: now.strftime('%c')
Out[27]: 'Sun Oct 30 09:14:00 2022'

你可以在 pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Period.strftime.html 找到更多格式化指令。

将字符串转换为日期和时间

有时,你可能需要从文件导入日期和时间信息,而不是自己创建。如果输入数据是字符串格式,你需要将字符串转换为日期。这基本上是我们在上一节中执行的操作的反向操作,你可以使用dateutil.parser.parse()方法或datetime.strptime()方法来实现这一点。第三方dateutil日期工具包扩展了datetime模块,并随着 pandas 一起自动安装。

对于常见的datetime格式,使用parse()方法会更方便。在控制台中,输入以下内容:

In [28]: from dateutil.parser import parse

parse()方法可以处理大多数日期表示。如果你输入的日期是月份在前,像美国那样,它会在datetime对象中遵循这种惯例:

In [29]: parse('Oct 31, 2022, 11:59 PM')
Out[29]: datetime.datetime(2022, 10, 31, 23, 59)

对于日期在月份之前的地区,将dayfirst参数设置为True

In [30]: parse('2/10/2022', dayfirst=True)
Out[30]: datetime.datetime(2022, 10, 2, 0, 0)

让我们来看一个现实世界的例子。假设你已经按日期记录了在自动相机中捕获到的动物类型。你已将数据加载为列表,并希望将字符串格式的日期替换为datetime对象:

In [31]: data = ['2022/10/31', 'bobcat',
 '2022/11/1', 'fox', 
                  '2022/11/2', ['bobcat', 'opposum']]

In [32]: data_dt = data.copy() In [33]: for i, date in enumerate(data): 
     ...:     if i % 2 == 0:
     ...:         data_dt[i] = parse(date)

In [34]: data_dt
Out[34]: 
[datetime.datetime(2022, 10, 31, 0, 0),
'bobcat',
datetime.datetime(2022, 11, 1, 0, 0),
'fox',
datetime.datetime(2022, 11, 2, 0, 0),
['bobcat', 'opposum']]

在这个例子中,我们对初始数据列表进行了复制(date_dt),然后使用内置的enumerate()函数遍历data列表,以获取列表中的项及其索引。如果索引是偶数,对应的是日期的位置,我们就解析data_dt列表中该位置的日期。现在,我们得到了原始数据以及一个日期为datetime对象的版本。

尽管parse()对于常见的已知日期格式非常有用,但它无法处理每种情况。对于边缘情况,你需要使用datetime模块的strptime()方法,并传递正确的格式规范。例如,假设你的实验助理使用下划线来分隔日期组件输入了一堆日期:

In [35]: date = '2022_10_31'

parse()方法无法识别此格式并将引发错误(ParserError: Unknown string format: 2022_10_31)。要处理此非标准格式,请使用strptime()并从表 21-2 中使用指令,并确保在相对位置放置下划线:

In [36]: datetime.strptime(date, '%Y_%m_%d')
Out[36]: datetime.datetime(2022, 10, 31, 0, 0)

parse()类似,你可以使用strptime()将一系列日期转换。以下是使用列表推导而不是for循环的示例:

In [37]: dates = ('8/11/84', '9/11/84', '10/11/84')

In [38]: dates_dt = [datetime.strptime(date, '%m/%d/%y') for date in dates]

In [39]: dates_dt
Out[39]: 
[datetime.datetime(1984, 8, 11, 0, 0),
datetime.datetime(1984, 9, 11, 0, 0),
datetime.datetime(1984, 10, 11, 0, 0)]

若要了解更多关于dateutil包的信息,请访问pypi.org/project/python-dateutil

使用 datetime 对象绘图

绘制日期可能会因为日期标签过长而变得混乱。此外,标准绘图默认值在显示刻度时不考虑适当的时间间隔。要查看使用 Matplotlib 的示例,请在控制台中输入以下内容:

In [40]: import datetime as dt
   ...:  import numpy as np
   ...:  import matplotlib.pyplot as plt

In [41]: dates = [dt.date(2022, 1, 31),
   ...:           dt.date(2022, 2, 28),
   ...:           dt.date(2022, 3, 31),
   ...:           dt.date(2022, 4, 30)]

In [42]: obs = [5, 12, 25, 42]

In [43]: plt.plot(dates, obs);

这将在图 21-1 中生成难以阅读的结果。

图片

图 21-1:x 轴上重叠的日期标签

处理绘图日期时,必须通过导入matplotlib.dates模块告知 Matplotlib 处理datetime对象。这个专用模块建立在datetime和第三方dateutil模块之上。在其复杂的绘图能力中,它帮助你使用定位器方法定义时间尺度,这些方法能够找到并理解你使用的日期类型,例如月份和年份。

让我们使用matplotlib.dates重新构建上一个图。记得在输入第In [48]行代码时使用 CTRL-ENTER 以防止过早执行:

In [44]: import matplotlib.dates as mdates

In [45]: months = mdates.MonthLocator()

In [46]: days = mdates.DayLocator() In [47]: date_fmt = mdates.DateFormatter('%Y-%m')

In [48]: fig, ax = plt.subplots()
    ...: plt.plot(dates, obs)
    ...: ax.xaxis.set_major_locator(months)
    ...: ax.xaxis.set_major_formatter(date_fmt)
    ...: ax.xaxis.set_minor_locator(days)

这将在图 21-2 中生成结果。

图片

图 21-2:日期格式正确的绘图

现在日期标签可读,并且如果你的视力足够好,你将能够数出每个月每天的正确刻度数。定位器函数也适用于其他单位,如小时、分钟、秒和工作日。要了解更多信息,请访问matplotlib.org/stable/api/dates_api.html模块文档。

创建朴素对象与知情对象

Python 的datetime对象可以根据是否包含时区信息分为朴素知情。朴素对象不包含时区信息,无法相对于其他datetime对象定位自身。具有元数据(如时区和夏令时信息)的知情对象表示特定且明确的时间点,可以相对于其他知情对象定位。

为了生成带时区信息的对象,datetimetime对象具有一个可选的时区属性tzinfo,用于捕捉协调世界时(UTC)的时差、时区名称以及是否处于夏令时(DST)。

UTC 是格林威治标准时间(GMT)的继任者,是世界用来调节时钟和时间的主要时间标准。精度通常为毫秒,但使用卫星信号时可以达到亚微秒级精度。UTC 不随季节变化,也不受夏令时(DST)的影响。通过使用 UTC,你可以自信地分享你的工作,避免了复杂的时区和类似的调整。

尽管tzinfo属性可以包含详细的、特定于国家的时区信息,但datetime模块的timezone类只能表示与 UTC 有固定时差的简单时区,如 UTC 本身或北美的 EST 和 EDT 时区。

要访问更详细的时区信息,你可以使用第三方库pytzpypi.org/project/pytz/),该库被 pandas 封装。要创建带时区信息的时间戳,导入pytz并将datetime方法的时区名称传递给pytz库。你可以使用common_timezones属性查找这些名称,下面是列出的最后 10 个时区名称:

In [49]: import pytz

In [50]: pytz.common_timezones[-10:]
Out[50]: 
['Pacific/Wake',
'Pacific/Wallis',
'US/Alaska',
'US/Arizona',
'US/Central',
'US/Eastern',
'US/Hawaii',
'US/Mountain',
'US/Pacific',
'UTC']

首先,让我们创建一个带时区信息的 UTC 时间戳:

In [51]: aware = datetime(2022, 11, 2, 21, 15, 19, 426910, pytz.UTC)

In [52]: aware
Out[52]: datetime.datetime(2022, 11, 2, 21, 15, 19, 426910, tzinfo=<UTC>)

请注意,带时区信息的时间戳包含时区元数据(tzinfo=<UTC>)。

要将现有的无时区信息的时间戳转换为带时区信息的时间戳,可以在pytz时区上调用localize()方法,并将datetime对象传递给该方法,如下所示:

In [53]: unaware = datetime(2022, 11, 3, 0, 0, 0)

In [54]: aware = pytz.timezone('Europe/London').localize(unaware)

In [55]: aware
Out[55]: datetime.datetime(2022, 11, 3, 0, 0, tzinfo=<DstTzInfo 'Europe/
London' GMT0:00:00 STD>)

要从一个时区转换到另一个时区,可以使用astimezone()方法:

In [56]: here = datetime(2022, 11, 3, 14, 51, 3,
 tzinfo=pytz.timezone('US/Central'))

In [57]: there = here.astimezone(pytz.timezone('Europe/London')) In [58]: there
Out[58]: datetime.datetime(2022, 11, 3, 20, 42, 3, tzinfo=<DstTzInfo 'Europe/
London' GMT0:00:00 STD>)

pytz库在转换时会考虑本地区域的特殊情况,比如夏令时(DST)。

注意

因为许多日期时间方法将天真的日期时间对象视为本地时间,所以最好使用带时区信息的日期时间来表示 UTC 时间。因此,推荐的创建当前 UTC 时间对象的方式是调用datetime.now(timezone.utc)

测试你的知识

  1. 哪个日期是以全球标准的数字日期格式书写的?

a. 23-2-2021

b. 2021 年 2 月 23 日

c. 2021 年 2 月 23 日

d. 23/2/21

  1. 哪些方法可以将日期的字符串表示转换为datetime对象?

a. strftime()

b. str()

c. strptime()

d. parse()

  1. 哪个指令产生格式'03/30/2022 21:09'

a. '%m/%d/%y %H:%M'

b. '%M/%D/%Y %H:%m'

c. '%m/%d/%Y %H:%M'

d. '%m/%d/%y %H:%M'

  1. 什么是全球时间标准?

a. pytz

b. 美国东部时间(US/Eastern)

c. UTC

d. 格林威治标准时间(GMT)

  1. 哪种方法可以将天真的datetime对象转换到新时区?

a. mdates()

b. parse()

c. timedelta()

d. localize()

使用 pandas 进行时间序列和日期功能

正如您可能预料的那样,pandas 在处理时间序列方面具有广泛的功能。该功能基于 NumPy 的 datetime64timedelta64 数据类型,具有纳秒级分辨率。此外,许多其他 Python 库的功能已被合并,并且开发了新的功能。使用 pandas,您可以加载时间序列;将数据转换为适当的 datetime 格式;生成日期时间范围;索引、合并和重新采样固定频率和不规则频率的数据;等等。

pandas 库使用四个与时间相关的通用概念。它们分别是日期时间、时间差、时间跨度和日期偏移量(表 21-3)。除了日期偏移量,每个时间概念都有一个标量类,用于单一观测值,以及一个相关的数组类,用作索引结构。

表 21-3: pandas 中的时间相关概念

概念 标量类 数组类 数据类型 创建方法
日期时间 Timestamp DatetimeIndex datetime64[ns]``datetime64[ns, tz] to_datetimedate_range
时间差 Timedelta TimedeltaIndex timedelta64[ns] to_timedeltatimedelta_range
时间跨度 Period PeriodIndex period[freq] Periodperiod_range
日期偏移量 DateOffset Dateoffset

日期时间表示具有时区支持的特定日期和时间。它类似于 Python 标准库中的 datetime.datetime时间差是一个绝对的时间持续时间,类似于标准库中的 datetime.timedelta时间跨度是由某个时间点及其相关的频率(如每日、每月等)定义的一个周期。日期偏移量表示一个相对的时间持续时间,遵循日历运算规则。

在接下来的章节中,我们将探讨这些不同的概念以及创建它们的方法。欲了解更多详情,您可以访问官方文档,地址为 pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html

解析时间序列信息

要创建一个表示特定事件时间的时间戳,使用 Timestamp 类:

In [59]: import pandas as pd
In [60]: ts = pd.Timestamp('2021, 2, 23 00:00:00')

In [61]: ts
Out[61]: Timestamp('2021-02-23 00:00:00')

同样,要创建 DatetimeIndex 对象,使用 DatetimeIndex 类:

In [62]: dti = pd.DatetimeIndex(['2022-03-31 14:39:00', 
                                 '2022-04-01 00:00:00'])

In [63]: dti
Out[63]: DatetimeIndex(['2022-03-31 14:39:00', '2022-04-01 00:00:00'],
dtype='datetime64[ns]', freq=None)

对于现有数据,pandas 的 to_datetime() 方法将标量、类似数组、类似字典以及 pandas 的 Series 或 DataFrame 对象转换为 pandas datetime64[ns] 对象。这使得您可以轻松地从各种来源和格式中解析时间序列信息。

要查看我所说的内容,请在控制台中输入以下命令:

In [64]: import numpy as np
   ...:  from datetime import datetime
   ...:  import pandas as pd

In [65]: dti = pd.to_datetime(["2/23/2021", 
   ...:                        np.datetime64("2021-02-23"),
   ...:                        datetime(2022, 2, 23)])

In [66]: dti
Out[66]: DatetimeIndex(['2021-02-23', '2021-02-23', '2022-02-23'],
dtype='datetime64[ns]', freq=None)

在这个例子中,我们向 to_datetime() 方法传递了三种不同格式的日期列表。这包括一个字符串、一个 NumPy datetime64 对象和一个 Python datetime 对象。该方法返回一个 pandas DatetimeIndex 对象,它将日期统一存储为 ISO 8601 格式(年-月-日)的 datetime64[ns] 对象。

该方法可以处理时间以及日期:

In [67]: dates = ['2022-3-31 14:39:00',
   ...:          '2022-4-1 00:00:00',
   ...:          '2022-4-2 00:00:20',
   ...:          '']

In [68]: dti = pd.to_datetime(dates)

In [69]: dti
Out[69]: 
DatetimeIndex(['2022-03-31 14:39:00', '2022-04-01 00:00:00',
'2022-04-02 00:00:20', 'NaT'],
dtype='datetime64[ns]', freq=None)

在这个例子中,我们传递了一个包含日期和时间的列表,所有日期和时间都被正确转换了。注意,我们在列表末尾包含了一个空项('')。to_datetime() 方法将此条目转换为 NaT(Not a Time)值,这相当于你在前一章学到的 pandas NaN(Not a Number)值的时间戳等价物。

to_datetime() 方法也适用于 pandas 数据框。让我们看一个示例,在这个示例中,你记录了(在 Microsoft Excel 中)一个拍摄动物图像的野外摄像机的日期和时间。你已将电子表格导出为 .csv 文件,现在想要使用 pandas 加载并解析它。

要创建 .csv 文件,在诸如记事本或 TextEdit 之类的文本编辑器中,输入以下内容,然后将其保存为 camera_1.csv

Date,Obs
3/30/22 11:43 PM,deer
3/31/22 1:05 AM,fox
4/1/22 2:54 AM,cougar

回到控制台,输入以下内容以将文件读取为 DataFrame(替换为你的 .csv 文件路径):

In [70]: csv_df = pd.read_csv('C:/Users/hanna/camera_1.csv')

In [71]: csv_df
Out[71]: 
              Date    Obs
0 3/30/22 11:43 PM   deer
1  3/31/22 1:05 AM    fox
2   4/1/22 2:54 AM cougar

要将 Date 列转换为 ISO 8601 格式,请输入以下内容:

In [72]: csv_df['Date'] = pd.to_datetime(csv_df['Date'])

In [73]: csv_df
Out[73]: 
                 Date    Obs
0 2022-03-30 23:43:00   deer
1 2022-03-31 01:05:00    fox
2 2022-04-01 02:54:00 cougar

这些日期时间记录在美国东部时区,但没有包含该信息。为了使日期时间具有意识,首先进行以下导入:

In [74]: import pytz

接下来,将一个变量分配给 pytz tzfile 对象,然后将变量传递给 localize() 方法:

In [75]: my_tz = pytz.timezone('US/Eastern')
In [76]: csv_df['Date'] = csv_df['Date'].dt.tz_localize(my_tz)

你可以在一行中完成所有操作,但使用 my_tz 变量可以使代码更易读且不太可能换行。要检查结果,请打印 Date 列:

In [77]: print(csv_df['Date'])
0 2022-03-30 23:43:00-04:00
1 2022-03-31 01:05:00-04:00
2 2022-04-01 02:54:00-04:00
Name: Date, dtype: datetime64[ns, US/Eastern]

即使在 UTC 中工作是个好主意,保留有意义的时间数据也很重要。例如,你可能希望研究这些动物何时在当地时间活动,因此你会希望保留记录在美国东部的时间。在这种情况下,你将希望基于 Date 列创建一个新的“UTC-aware”列,以便同时兼顾两者。因为 Date 列现在意识到其时区,所以你必须使用 tz_convert() 而不是 tz_localize()

In [78]: csv_df['Date_UTC'] = csv_df['Date'].dt.tz_convert(pytz.utc)

打印列以验证转换是否成功:

In [79]: print(csv_df[['Date', 'Date_UTC']])
                       Date                  Date_UTC
0 2022-03-30 23:43:00-04:00 2022-03-31 03:43:00+00:00
1 2022-03-31 01:05:00-04:00 2022-03-31 05:05:00+00:00
2 2022-04-01 02:54:00-04:00 2022-04-01 06:54:00+00:00

注意

要从日期时间中删除时区信息以使其变为 naive,可以像这样将 tz_convert() 方法设置为 None:csv_df['Date'] = csv_df['Date'].dt.tz_convert(None).

最后,如果你查看 csv_df DataFrame 的前面输出,你会看到索引值从 0 到 2。这是默认的,但你完全可以使用 datetime 值作为索引。事实上,当进行绘图等操作时,datetime 索引非常有用。因此,让我们将 Date_UTC 列作为 DataFrame 的索引。在控制台中,输入以下内容来完成这一步:

In [80]: csv_df = csv_df.set_index('Date_UTC')
Out[80]: 
                                         Date Obs
Date_UTC 
2022-03-31 03:43:00+00:00 2022-03-31 03:43:00 deer
2022-03-31 05:05:00+00:00 2022-03-31 05:05:00 fox
2022-04-01 06:54:00+00:00 2022-04-01 06:54:00 cougar

要了解更多关于to_datetime()方法的信息,请访问pandas.pydata.org/pandas-docs/stable/reference/api/pandas.to_datetime.html。你可以在pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.dt.tz_localize.htmlpandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.dt.tz_convert.html中找到dt.tz_localize()dt.tz_convert()的文档。

创建日期范围

固定频率的时间序列在科学中经常出现,应用领域广泛,如信号处理中的波形采样、心理学中的目标行为观察、经济学中的股市变动记录以及交通工程中的交通流量记录。毫不奇怪,pandas 提供了许多标准化的频率和生成它们、重采样它们及推断它们的工具。

pandas 的date_range()方法返回一个具有固定频率的DatetimeIndex对象。要生成由天组成的索引,传入一个开始日期和结束日期,如下所示:

In [81]: day_index = pd.date_range(start='2/23/21', end='3/1/21')

In [82]: day_index
Out[82]: 
DatetimeIndex(['2021-02-23', '2021-02-24', '2021-02-25', '2021-02-26',
'2021-02-27', '2021-02-28', '2021-03-01'],
dtype='datetime64[ns]', freq='D')

你还可以传入开始日期或结束日期,以及要生成的周期数量(例如,天数)。在以下示例中,我们从某个观察点的时间戳开始,并请求六个周期:

In [83]: day_index = pd.date_range(start='2/23/21 12:59:59', periods=6)

In [84]: day_index
Out[84]: 
DatetimeIndex(['2021-02-23 12:59:59', '2021-02-24 12:59:59',
'2021-02-25 12:59:59', '2021-02-26 12:59:59',
'2021-02-27 12:59:59', '2021-02-28 12:59:59'],
dtype='datetime64[ns]', freq='D')

请注意,这六个日期时间表示的是从 12:59:59 开始的日期。通常,你希望日期从午夜开始,因此,pandas 提供了一个方便的normalize参数来进行此调整:

In [85]: day_index_normal = pd.date_range(start='2/23/21 12:59:59',
 periods=6, 
 normalize=True)

In [86]: day_index_normal
Out[86]: 
DatetimeIndex(['2021-02-23', '2021-02-24', '2021-02-25', '2021-02-26',
'2021-02-27', '2021-02-28'],
dtype='datetime64[ns]', freq='D')

在它们被标准化为天后,输出的datetime64对象将不再包含时间部分。

默认情况下,date_range()方法假设你想要的是按天的频率。然而,也可以使用其他频率,许多频率是为商业应用设计的(例如,商业月末、商业年末等)。

表 21-4 列出了与科学更相关的一些时间序列频率。完整的列表,包括金融频率,请参见“DateOffset 对象”在pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html

表 21-4: 有用的时间序列频率

Freq string Offset type Description
N Nano 按纳秒
U Micro 按微秒
L or ms Milli 按毫秒
S Second 按秒
T or min Minute 按分钟
H Hour 按小时
D Day 按日历天
W-MON, W-TUE, . . . Week 每周,且可选地固定在某一星期几
MS MonthBegin 按月的第一天
M MonthEnd 按月的最后一天
Q Quarter 按季度
AS-JAN, AS-FEB, . . . YearBegin 每年,锚定在给定月份的第一个日历日
A-JAN, A-FEB, . . . YearEnd 每年,锚定在给定月份的最后一个日历日

要指定偏移类型,请将来自 表 21-4 的频率字符串别名作为freq参数传递。您还可以使用tz参数指定时区。以下是如何创建与 UTC 参考的每小时频率:

In [87]: hour_index = pd.date_range(start='2/23/21', 
 periods=6, 
 freq='H', 
 tz='UTC')

In [88]: hour_index
Out[88]: 
DatetimeIndex(['2021-02-23 00:00:00+00:00', '2021-02-23 01:00:00+00:00',
'2021-02-23 02:00:00+00:00', '2021-02-23 03:00:00+00:00',
'2021-02-23 04:00:00+00:00', '2021-02-23 05:00:00+00:00'],
dtype='datetime64[ns, UTC]', freq='H')

对于现有时间序列,您可以通过使用freq属性检索其频率,如下所示:

In [89]: hour_index.freq
Out[89]: <Hour>

表 21-4 中显示的频率代表基础频率。将整数2放在freq参数中的H前面,即可制作此新频率,例如双小时。

In [90]: bi_hour_index = pd.date_range(start='2/23/21', periods=6, freq='2H')

In [91]: bi_hour_index
Out[91]: 
DatetimeIndex(['2021-02-23 00:00:00', '2021-02-23 02:00:00',
'2021-02-23 04:00:00', '2021-02-23 06:00:00',
'2021-02-23 08:00:00', '2021-02-23 10:00:00'],
dtype='datetime64[ns]', freq='2H')

您还可以通过传递像'2H30min'这样的频率字符串来组合偏移量。

In [92]: pd.date_range(start='2/23/21', periods=6, freq='2H30min')
Out[92]:
DatetimeIndex(['2021-02-23 00:00:00', '2021-02-23 02:30:00',
'2021-02-23 05:00:00', '2021-02-23 07:30:00',
'2021-02-23 10:00:00', '2021-02-23 12:30:00'],
dtype='datetime64[ns]', freq='150T')

创建周期

时间戳将数据与时间点关联起来。然而,有时数据在某个时间跨度内保持恒定,例如一个月,您希望将数据与该间隔关联起来。

在 pandas 中,诸如日、月、年等的常规时间间隔由Period对象表示。使用period_range()方法,Period对象可以收集到一个序列中以形成PeriodIndex。您可以使用freq关键字和来自 表 21-4 的频率别名指定周期的时间跨度。

假设您想要跟踪 2022 年 9 月的每日观察。首先,使用period_range()方法创建一个频率为天的时间跨度:

In [93]: p_index = pd.period_range(start='2022-9-1', 
 end='2022-9-30', 
 freq='D')

接下来,创建一个 pandas 系列,并使用 NumPy 的random.randn()方法即时生成一些虚假数据。请注意,数据点的数量必须等于索引中的天数。

In [94]: ts = pd.Series(np.random.randn(30), index=p_index)

In [95]: ts
Out[95]: 
2022-09-01 0.412853
2022-09-02 0.350678
2022-09-03 0.086216
--snip--
2022-09-28 1.944123
2022-09-29 0.311337
2022-09-30 0.906780
Freq: D, dtype: float64

您现在拥有一个按日组织的 2022 年 9 月时间序列。

要将周期按其自身的频率移动,只需添加或减去一个整数。这是使用年度时间跨度的示例:

In [96]: year_index = pd.period_range(2001, 2006, freq='A-DEC')

In [97]: year_index
Out[97]: PeriodIndex(['2001', '2002', '2003', '2004', '2005', '2006'],
dtype='period[A-DEC]')

In [98]: year_index + 10
Out[98]: PeriodIndex(['2011', '2012', '2013', '2014', '2015', '2016'],
dtype='period[A-DEC]')

使用'A-DEC'的频率意味着每年表示 1 月 1 日到 12 月 31 日。通过添加10来将周期向上移动 10 年。您只能在具有相同频率的Period对象之间以这种方式执行算术运算。

这里是创建月度周期的示例:

In [99]: month_index = pd.period_range('2022-01-01', '2022-12-31', freq='M')

In [100]: month_index
Out[100]: 
PeriodIndex(['2022-01', '2022-02', '2022-03', '2022-04', '2022-05', '2022-06',
'2022-07', '2022-08', '2022-09', '2022-10', '2022-11', '2022-12'],
dtype='period[M]')

使用asfreq()方法,您可以将现有期间转换为另一个频率。这是一个示例,我们将month_index变量的期间转换为每个月第一小时锚定的小时:

In [101]: hour_index = month_index.asfreq('H', how='start')

In [102]: hour_index
Out[102]: 
PeriodIndex(['2022-01-01 00:00', '2022-02-01 00:00', '2022-03-01 00:00',
'2022-04-01 00:00', '2022-05-01 00:00', '2022-06-01 00:00',
'2022-07-01 00:00', '2022-08-01 00:00', '2022-09-01 00:00',
'2022-10-01 00:00', '2022-11-01 00:00', '2022-12-01 00:00'],
dtype='period[H]')

要了解有关 pandas Period 类和asfreq()方法的更多信息,请访问 pandas.pydata.org/docs/reference/api/pandas.Period.htmlpandas.pydata.org/docs/reference/api/pandas.Period.asfreq.html

创建时间间隔

timedelta_range()方法创建TimedeltaIndex对象。它的行为类似于date_range()period_range()

In [103]: pd.timedelta_range(start='1 day', periods = 5)
Out[103]: TimedeltaIndex(['1 days', '2 days', '3 days', '4 days', '5 days'],
dtype='timedelta64[ns]', freq='D')

在电视剧《迷失》中,某个角色每隔 108 分钟必须输入代码并按下按钮,以避免某种未知的灾难。使用timedelta_range()方法和频率参数,他可以围绕这一要求安排自己的日程。假设他最后一次按下按钮是在午夜,他将无法获得多少不被打扰的睡眠:

In [104]: pd.timedelta_range(start="1 day", end="2 day", freq="108min")
Out[104]: 
TimedeltaIndex(['1 days 00:00:00', '1 days 01:48:00', '1 days 03:36:00',
'1 days 05:24:00', '1 days 07:12:00', '1 days 09:00:00',
'1 days 10:48:00', '1 days 12:36:00', '1 days 14:24:00',
'1 days 16:12:00', '1 days 18:00:00', '1 days 19:48:00',
'1 days 21:36:00', '1 days 23:24:00'],
dtype='timedelta64[ns]', freq='108T')

使用偏移量平移日期

除了处理频率外,你还可以导入偏移量并使用它们来偏移TimestampDatetimeIndex对象。以下是一个示例,我们导入Day类并用它来偏移一个著名的日期:

In [105]: from pandas.tseries.offsets import Day

In [106]: apollo_11_moon_landing = pd.to_datetime('1969, 7, 20')

In [107]: apollo_11_splashdown = apollo_11_moon_landing + 4 * Day()
In [108]: print(f"{apollo_11_splashdown.month}/{apollo_11_splashdown.day}")
7/24

你还可以导入DateOffset类,然后将时间跨度作为参数传递:

In [109]: from pandas.tseries.offsets import DateOffset

In [110]: ts = pd.Timestamp('2021-02-23 09:10:11')

In [111]: ts + DateOffset(months=4)
Out[111]: Timestamp('2021-06-23 09:10:11')

DateOffset对象的一个优点是它们遵循夏令时(DST)转换。你只需要从pandas.tseries.offsets导入适当的类。下面是一个示例,演示如何在美国中部时区的春季夏令时转换中,将时间偏移一小时:

In [112]: from pandas.tseries.offsets import Hour

In [113]: pre_dst_date = pd.Timestamp('2022-03-13 1:00:00', tz='US/Central')

In [114]: pre_dst_date
Out[114]: Timestamp('2022-03-13 01:00:00-0600', tz='US/Central')

In [115]: post_dst_date = pre_dst_date + Hour()

In [116]: post_dst_date
Out[116]: Timestamp('2022-03-13 03:00:00-0500', tz='US/Central')

请注意,最终的日期时间(03:00:00)比起始日期时间(01:00:00)晚了两小时,即使你只偏移了一个小时。这是因为跨越了夏令时转换。

在这一点上,即使两个时间序列位于不同的时区,你也可以将它们结合起来。结果将是 UTC 时间,因为 pandas 会自动跟踪每个时间序列的等效 UTC 时间戳。

要查看可用的偏移量列表,请访问 pandas.pydata.org/pandas-docs/stable/reference/api/pandas.tseries.offsets.DateOffset.html

时间序列的索引与切片

当你处理时间序列数据时,通常将时间组件作为系列或数据框的索引,以便可以根据时间元素进行操作。在这里,我们创建了一个其索引表示时间序列、数据为从 0 到 9 的整数的系列:

In [117]: ts = pd.Series(range(10), index=pd.date_range('2022', 
 freq='D', 
 periods=10))
In [118]: ts
Out[118]: 
2022-01-01 0
2022-01-02 1
2022-01-03 2
2022-01-04 3
2022-01-05 4
2022-01-06 5
2022-01-07 6
2022-01-08 7
2022-01-09 8
2022-01-10 9
Freq: D, dtype: int64

即使索引现在是日期,你仍然可以像使用整数索引那样切片和操作系列。例如,要选择每隔一行的数据,可以输入以下内容:

In [119]: ts[::2]
Out[119]: 
2022-01-01 0
2022-01-03 2
2022-01-05 4
2022-01-07 6
2022-01-09 8
Freq: 2D, dtype: int64

要选择与 1 月 5 日相关的数据,请使用该日期索引系列:

In [120]: ts['2022-01-05']
Out[120]: 4

方便的是,你不需要以输入日期时的相同格式输入日期。任何可以解析为日期的字符串都可以使用:

In [121]: ts['1/5/2022']
Out[121]: 4

In [122]: ts['January 5, 2022']
Out[122]: 4

重复的日期将产生一个切片,显示与该日期相关的所有值。同样,你也可以使用以下语法查看按相同日期索引的所有行:dataframe.loc['datetime_index']

此外,如果你有一个包含多个年份的时间序列,可以根据年份进行索引,并检索包含该年份的所有索引和数据。这对于其他时间跨度(如月份)也适用。

切片的操作方式相同。你可以使用时间序列中未明确包含的时间戳,如2021-12-31

In [123]: ts['2021-12-31':'2022-1-2']
Out[123]: 
2022-01-01 0
2022-01-02 1
Freq: D, dtype: int64

在这种情况下,我们从 2021 年 12 月 31 日开始索引,该日期在时间序列中的日期之前。

注意

记住,pandas 是基于 NumPy 的,因此切片创建的是视图而非副本。对视图执行的任何操作都会改变源 series 或 DataFrame。

如果你希望日期时间组件作为数据而不是索引,在创建 series 时省略索引参数:

In [124]: pd.Series(pd.date_range('2022', freq='D', periods=3))
Out[124]: 
0 2022-01-01
1 2022-01-02
2 2022-01-03
dtype: datetime64[ns]

结果是一个带有整数索引的 pandas 系列,并且日期被当作数据处理。

重采样时间序列

将时间序列的频率转换为不同频率的过程称为重采样。这可能涉及降采样,即将数据聚合到较低的频率,可能是为了减少内存需求或查看数据趋势;上采样,即将频率提高,可能是为了在两个分辨率不同的数据集之间进行数学操作;或者简单的重采样,即保持相同的频率,但改变基准点,比如从年初(AS-JAN)到年末(A-JAN)。

在 pandas 中,重采样是通过在 pandas 对象上调用resample()方法来完成的,使用点符号表示法。它的一些常用参数列在表格 21-5 中。要查看完整的列表,请访问pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.resample.html。series 和dataframe对象使用相同的参数。

表 21-5: pandas resample()方法的有用参数

参数 描述
freq DateOffsetTimedelta对象,或字符串,表示重采样频率(如'D''Q''10min')。
axis 要进行重采样的轴(0'index'1'columns')。默认为0
closed 在降采样时,指示哪个区间的结束是包含的,可以是'right''left'。默认值会根据freq类型而变化。
label 在降采样时,选择使用哪个区间边缘来标记结果,可以是'right''left'。默认值会根据freq类型而变化。
convention 仅对PeriodIndex有效,控制在将频率从低频转换为高频时是否使用freq的开始或结束。默认为'start'
kind 传入'timestamp'将结果索引转换为DateTimeIndex,传入'period'将其转换为PeriodIndex。默认情况下,保留输入表示法。
on 对于 DataFrame,指定要用于重采样的列,而不是index。该列必须是类似 datetime 的类型。
上采样

上采样是指将数据重采样到更短的时间跨度,例如从日数据转换为小时数据。这会创建包含NaN值的桶,这些值必须填充;例如,使用前向填充和后向填充方法ffill()bfill()。这个两步过程可以通过链式调用 resample 方法和填充方法来完成。

为了说明这一点,让我们创建一个包含年度值的玩具数据集,并将其扩展为季度值。这可能在某些情况下是必要的,例如,生产目标每年增加,但进展必须以季度生产来跟踪。在控制台中输入以下内容:

In [125]: import pandas as pd

In [126]: dti = pd.period_range('2021-02-23', freq='Y', periods=3)

In [127]: df = pd.DataFrame({'value': [10, 20, 30]}, index=dti)

In [128]: df.resample('Q').ffill()

在导入 pandas 之后,创建一个名为dti的年度PeriodIndex。接着,创建数据框,并传入一个字典,其值以列表形式提供。然后,将index参数设置为dti对象。调用resample()方法并传入Q,表示季度,然后调用ffill()方法,链式调用在末尾。

该代码的结果在图 21-3 中进行了分解,图中从左到右依次展示了原始数据框、重采样结果和填充结果。原始的年度值以粗体显示。

图片

图 21-3:使用 resample()方法将年度范围的数据框重采样为季度范围,之后跟随 ffill()方法

resample()方法构建新的季度索引,并用NaN值填充新行。调用ffill()方法会填充这些空行,填充方向是“前向”的。这里的意思是,“每年第一季度(Q1)的值将用于该年内所有季度的值。”

后向填充执行相反的操作,假设每年新年(Q1)开始时的值应适用于前一年中的季度,不包括前一个第一季度:

In [129]: df.resample('Q').bfill()

此代码的执行结果在图 21-4 中进行了描述。再次强调,原始的年度值以粗体显示。

图片

图 21-4:使用 resample()方法将年度范围的数据框重采样为季度范围,之后跟随 bfill()方法

在这种情况下,与第一季度相关的值被“回填”到前面三个季度。然而,你需要小心,因为可能会出现“剩余”的NaN值。这些值可以在图 21-4 中右侧数据框的值列末尾看到。最后三个值没有变化,因为没有可用的2024Q1数据来设置该值。

为了填充缺失的数据,假设每个季度的值都以 10 为增量增长,然后使用链式调用fillna()方法重新运行代码。传入40来填充剩余的空缺:

In [130]: df.resample('Q').bfill().fillna(40)
Out[130]: 
       value
2021Q1  10.0
2021Q2  20.0
2021Q3  20.0
2021Q4  20.0
2022Q1  20.0
2022Q2  30.0
2022Q3  30.0
2022Q4  30.0
2023Q1  30.0
2023Q2  40.0
2023Q3  40.0
2023Q4  40.0

bfill()ffill()都是fillna()方法的同义词。你可以在pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.fillna.html中查看更多关于它的信息。

降采样

下采样是指将高频数据重采样到低频数据,例如将分钟数据转为小时数据。由于需要将多个样本合并为一个样本,resample() 方法通常与用于 聚合 数据的方法链式使用(参见 表 21-6)。

表 21-6: pandas 中有用的聚合方法

方法 描述
count() 返回非空值的数量
max() 返回最大值
mean() 返回值的算术平均数
median() 返回值的中位数
min() 返回最小值
std() 返回值的标准差
sum() 返回值的总和
var() 返回值的方差

为了练习下采样,我们将使用 The Atlantic 的 “COVID Tracking Project” 提供的真实数据集。该数据集包括从 2020 年 3 月 3 日到 2021 年 3 月 7 日的 COVID-19 统计数据。

为了缩小数据集的规模,我们将只下载德克萨斯州的数据。请访问 covidtracking.com/data/download/,向下滚动,然后点击 Texas 的链接。为了方便起见,我建议将此文件移动到启动 Jupyter Qt 控制台的同一文件夹中;这样加载数据时就不需要提供文件路径。

首先,将数据加载为 pandas DataFrame。输入文件包含许多我们不需要的列,因此我们只选择 datedeathIncrease 两列。后者是当天与 COVID 相关的死亡人数。

In [131]: df = pd.read_csv("texas-history.csv", 
    ...:                  usecols=['date','deathIncrease'])

In [132]: df.head()
Out[132]: 
        date deathIncrease
0 2021-03-07            84
1 2021-03-06           233
2 2021-03-05           256
3 2021-03-04           315
4 2021-03-03           297

通过在 DataFrame 上调用 head() 方法,保持关注数据的变化是很有帮助的,默认情况下该方法返回前五行数据。在这里,我们看到日期是按 降序 排列的,但我们通常使用并绘制按 升序 排列的日期数据。因此,我们调用 pandas 的 sort_values() 方法,传入列名,并将 ascending 参数设置为 True

In [133]: df = df.sort_values('date', ascending=True)

In [134]: df.head()
Out[134]: 
          date deathIncrease
369 2020-03-03             0
368 2020-03-04             0
367 2020-03-05             0
366 2020-03-06             0
365 2020-03-07             0

接下来,日期看起来像日期,但它们真的是日期吗?通过检查 DataFrame 的 dtypes 属性,可以确认这一点:

In [135]: df.dtypes
Out[135]: 
date object
deathIncrease int64
dtype: object

结果显示它们并不是日期。这一点很重要,因为 resample() 方法仅适用于具有 datetime 类型索引的对象,如 DatetimeIndexPeriodIndexTimedeltaIndex。我们需要将它们的类型更改并将其设置为 DataFrame 的索引,替换当前的整数值。我们还将删除 date 列,因为我们不再需要它。

In [136]: df = df.set_index(pd.DatetimeIndex(df['date'])).drop('date',
    ...:                                                       axis=1)
In [137]: df.head()
Out[137]: 
            deathIncrease
date 
2020-03-03              0
2020-03-04              0
2020-03-05              0
2020-03-06              0
2020-03-07              0

到目前为止,我们已经整理了数据,使得我们的 DataFrame 使用了一个按升序排列日期的 DatetimeIndex。接下来,使用 pandas 绘图工具快速绘制图形,来查看数据的表现,这对于数据探索既快速又方便:

In [138]: df.plot();

这将返回图 图 21-5 中展示的图形。

图片

图 21-5:2020 年 3 月 3 日至 2021 年 3 月 7 日期间德克萨斯州与 COVID-19 相关的每日死亡人数

图 21-5 中一个非常突出的方面是 2020 年 8 月初的峰值。因为这显然是一个最大值,你可以使用max()idxmax()方法分别轻松获取该值及其日期索引:

In [139]: print(df.max(), df.idxmax())
deathIncrease 675
dtype: int64 deathIncrease 2020-07-27
dtype: datetime64[ns]

这很可能是一个异常值,特别是考虑到 CDC 在该日期仅记录了 239 例死亡,这与相邻的数据更为一致(请参见* covid.cdc.gov/covid-data-tracker/#trends_dailydeaths/*)。接下来,我们将使用 CDC 的数据。要更改 DataFrame,应用.loc索引器,并传递日期(索引)和列名,如下所示:

In [140]: df.loc['2020-7-27', 'deathIncrease'] = 239

In [141]: df.plot();

峰值现在已经消失,图形看起来更合理,如图 21-6 所示。

Image

图 21-6:删除异常峰值后的德克萨斯州 COVID-19 相关每日死亡人数

另一个显著的现象是曲线的“锯齿状”特征,这由死亡人数的周期性振荡所造成。这些振荡的频率很高,怀疑疾病是以这种方式进展的。

为了调查这个异常,创建一个新的 DataFrame,其中包括一列表示星期几:

In [142]: df_weekdays = df.copy()

In [143]: df_weekdays['weekdays'] = df.index.day_name()

现在,使用 pandas 的iloc[]索引打印出多周的数据:

In [144]: print(df_weekdays.iloc[90:115])
           deathIncrease    weekdays
date 
2020-06-01             6      Monday
2020-06-02            20     Tuesday
2020-06-03            36   Wednesday
2020-06-04            33    Thursday
2020-06-05            21      Friday
2020-06-06            31    Saturday
2020-06-07            11      Sunday
2020-06-08             0      Monday
2020-06-09            23     Tuesday
2020-06-10            32   Wednesday
2020-06-11            35    Thursday
2020-06-12            19      Friday
2020-06-13            18    Saturday
2020-06-14            19      Sunday
2020-06-15             7      Monday
2020-06-16            46     Tuesday
2020-06-17            33   Wednesday
2020-06-18            43    Thursday
2020-06-19            35      Friday
2020-06-20            25    Saturday
2020-06-21            17      Sunday
2020-06-22            10      Monday
2020-06-23            28     Tuesday
2020-06-24            29   Wednesday
2020-06-25            47    Thursday

正如我在灰色部分所强调的,最低的死亡人数通常发生在星期一,星期天的死亡人数也似乎被压缩了。这表明周末存在报告问题,存在一天的时间滞后。你可以在* www.ncbi.nlm.nih.gov/pmc/articles/PMC7363007/* 上了解更多关于这一报告现象的内容。

注意

如果你在大流行期间活跃在社交媒体上,可能注意到有人质疑基于像图 21-5 这样的图表的 COVID 数据的真实性。这是一个很好的例子,说明如何通过简单的数据科学应用,快速解决谜题并有效平息阴谋论。

由于这些振荡是每周发生的,从每日到每周的降采样应该能够合并低和高的报告,并平滑曲线。输入以下内容来测试这一假设:

In [145]: df.resample('W').sum().plot();

这会产生图 21-7 中的图形,振荡的高频部分已消失。

Image

图 21-7:2020 年 3 月 3 日至 2021 年 3 月 7 日德克萨斯州 COVID-19 相关每周死亡人数

现在,让我们将数据降采样到每月周期:

In [146]: df.resample('m').sum().plot();

这会产生图 21-8 中的更平滑图形。

Image

图 21-8:2020 年 3 月 3 日至 2021 年 3 月 7 日德克萨斯州与 COVID-19 相关的每月死亡人数

请注意,你也可以降采样到自定义周期,例如每四天的'4D'

重采样时更改开始日期

到目前为止,我们在聚合间隔时一直使用的是默认起始日期,但这可能导致不希望出现的结果。以下是一个例子:

In [147]: raw_dict = {'2022-02-23 09:00:00': 100,
     ...:             '2022-02-23 10:00:00': 200,
     ...:             '2022-02-23 11:00:00': 100,
     ...:             '2022-02-23 12:00:00': 300}

In [148]: ts = pd.Series(raw_dict)

In [149]: ts.index = pd.to_datetime(ts.index)

In [150]: ts.resample('2H').sum()
Out[150]: 
2022-02-23 08:00:00 100
2022-02-23 10:00:00 300
2022-02-23 12:00:00 300
Freq: 2H, dtype: int64

尽管第一个数据点在早上 9 点被记录,重新采样后的总和却早上 8 点开始。这是因为聚合间隔的默认值是 0,导致两小时('2H')频率的时间戳为 00:00:00,... 08:00:0010:00:00 等,因此跳过了 09:00:00

要强制输出范围从 09:00:00 开始,传递方法的 origin 参数 'start'。现在它应该使用时间序列的实际开始时间:

In [151]: ts.resample('2H', origin='start').sum()
Out[151]: 
2022-02-23 09:00:00 300
2022-02-23 11:00:00 400
Freq: 2H, dtype: int64

聚合从早上 9 点开始,正如所期望的那样。

使用插值重新采样不规则时间序列

科学观察通常是 irregular 的。毕竟,角马不会按照固定的时间表出现在水坑旁。幸运的是,无论时间序列的频率是 irregular 还是 fixed,重新采样的工作方式都是一样的。

与上采样类似,正则化时间序列会生成带有空值的新时间戳。之前,我们使用反向填充和前向填充来填补这些空白值。在下一个示例中,我们将使用 pandas 的interpolate()方法。

让我们先生成一个不规则间隔的日期时间列表,分辨率为秒:

In [152]: raw = ['2021-02-23 09:46:48',
     ...:        '2021-02-23 09:46:51', ...:        '2021-02-23 09:46:53',
     ...:        '2021-02-23 09:46:55',
     ...:        '2021-02-23 09:47:00']

接下来,在一行代码中创建一个 pandas 序列对象,其中索引是转换为 DatetimeIndexdatetime 字符串:

In [153]: ts = pd.Series(np.arange(5), index=pd.to_datetime(raw))

In [154]: ts
Out[154]: 
2021-02-23 09:46:48 0
2021-02-23 09:46:51 1
2021-02-23 09:46:53 2
2021-02-23 09:46:55 3
2021-02-23 09:47:00 4
dtype: int32

现在,以相同的分辨率('s')重新采样此时间序列,并使用 'linear' 作为 method 参数调用 interpolate()

In [155]: ts_regular = ts.resample('s').interpolate(method='linear')

In [156]: ts_regular
Out[156]: 
2021-02-23 09:46:48 0.000000
2021-02-23 09:46:49 0.333333
2021-02-23 09:46:50 0.666667
2021-02-23 09:46:51 1.000000
2021-02-23 09:46:52 1.500000
2021-02-23 09:46:53 2.000000
2021-02-23 09:46:54 2.500000
2021-02-23 09:46:55 3.000000
2021-02-23 09:46:56 3.200000
2021-02-23 09:46:57 3.400000
2021-02-23 09:46:58 3.600000
2021-02-23 09:46:59 3.800000
2021-02-23 09:47:00 4.000000
Freq: S, dtype: float64

现在,你已经为每秒生成了时间戳,并且在原始数据点之间插值了新值。method 参数还提供了其他选项,包括 nearestpadzerospline 等。你可以在 pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.interpolate.html 中阅读相关内容。

重新采样与分析不规则时间序列:一个二进制示例

让我们来看一个处理不规则时间序列的实际例子。假设你已经将一个传感器安装在制冷机的压缩机上,用来查看压缩机在一天内的开(1)和关(0)状态。

要构建玩具数据集,请在控制台中输入以下内容:

In [157]: import pandas as pd

In [158]: raw_dict = {'2021-2-23, 06:00:00': 0,
    ...:              '2021-2-23, 08:05:09': 1,
    ...:              '2021-2-23, 08:49:13': 0,
    ...:              '2021-2-23, 11:23:21': 1,
    ...:              '2021-2-23, 11:28:14': 0}

In [159]: ts = pd.Series(raw_dict)

In [160]: ts.index = pd.to_datetime(ts.index)

In [161]: ts.plot();

这产生了图 21-9 中的图形。注意,它并未反映数据的二进制(01)特性。

图片

图 21-9:压缩机开关数据的不规则时间序列图

在其原始的不规则形式中,数据难以可视化和处理。例如,如果你尝试检查上午 11 点压缩机的状态,你会遇到错误:

In [162]: ts['2021-02-23 11:00:00']
KeyError --snip--

问题在于,系列索引不能即时插值。我们需要先将数据重新采样到一个“工作分辨率”,在这个例子中是秒:

In [163]: ts_secs = ts.resample('S').ffill() In [164]: ts_secs
Out[164]:
2021-02-23 06:00:00 0
2021-02-23 06:00:01 0
2021-02-23 06:00:02 0
2021-02-23 06:00:03 0
2021-02-23 06:00:04 0
..
2021-02-23 11:28:10 1
2021-02-23 11:28:11 1
2021-02-23 11:28:12 1
2021-02-23 11:28:13 1
2021-02-23 11:28:14 0
Freq: S, Length: 19695, dtype: int64

In [165]: ts_secs.plot();

图片

图 21-10:重新采样为每秒频率的时间序列图

现在,图表反映了数据的二进制“开关”特性,你可以提取出上午 11 点的状态:

In [166]: ts_secs['2021-2-23 11:00:00']
Out[166]: 0

要确定压缩机在该时间段内的开关时间,请对序列调用value_counts()方法:

In [167]: ts_secs.value_counts()
Out[167]: 
0 16758
1 2937
dtype: int64

要确定压缩机运行的时间占一天的比例,只需将value_counts()输出中的索引1的值除以一天的秒数:

In [168]: num_secs_per_day = 60 * 60 * 24

In [169]: print(f"On = {ts_secs.value_counts()[1] / num_secs_per_day}")
On = 0.033993055555555554

压缩机只运行了当天的三分之一。这真是很好的隔热效果!

滑动窗口函数

pandas 库提供了用于转换时间序列的函数,这些函数使用滑动窗口或具有指数衰减权重的方法。这些函数平滑原始数据点,使得长期趋势更加明显。

移动平均是常用的时间序列技术,用于平滑噪声和空隙,并揭示潜在的数据趋势。广为人知的例子是用于分析股市数据的 50 天和 200 天移动平均线。

为了计算移动平均,使用指定长度的“窗口”来对 DataFrame 列中的行进行平均。窗口从最早的日期开始,并按时间单位逐步滑动到列的下方,然后重复这个过程。以下是一个三天移动平均的示例,平均值用粗体显示:

                   value
date 
2020-06-01             6     |
2020-06-02            20   | |
2020-06-03            36 | | |--> (6 + 20 + 36)/3 = 20.67
2020-06-04            33 | |--> (20 + 36 + 33)/3 = 29.67
2020-06-05            21 |--> (36 + 33 + 21)/3 = 30.0

为了制作我们 COVID 数据的“月度”30 天移动平均(来自“下采样”部分,见第 650 页),首先将其重新导入为一个名为df_roll的新 DataFrame,并替换异常值。(如果你仍然有数据在内存中,可以用df_roll = df.copy()替代接下来的五行):

In [170]: df_roll = pd.read_csv("texas-history.csv",
    ...: usecols = ['date','deathIncrease'])

In [171]: df_roll = df_roll.sort_values('date', ascending=True)

In [172]: df_roll = df_roll.set_index(pd.DatetimeIndex(df_roll['date'])) In [173]: df_roll = df_roll.drop('date', axis=1)

In [174]: df_roll.loc['2020-7-27', 'deathIncrease'] = 239

接下来,为该 DataFrame 创建一个30_day_ma列,并通过调用rolling()方法对deathIncrease列进行计算,传递30,然后加上mean()方法。最后调用plot()来完成:

In [175]: df_roll['30_day_ma'] = df_roll.deathIncrease.rolling(30).mean()

In [176]: df_roll.plot();

如图 21-11 所示,移动平均曲线比每月重采样所产生的曲线(见图 21-8)更加平滑,但保留了一些周期性的波动。

Image

图 21-11:德克萨斯州与 COVID 相关的死亡人数及 30 天移动平均曲线

默认情况下,平均值会显示在窗口的末尾,这使得平均曲线相对于日常数据看起来有所偏移。要将其显示在窗口的中心,请将True传递给rolling()方法的center参数:

In [177]: df_roll['30_day_ma'] = df_roll.deathIncrease.rolling(30,
 center=True).mean()

In [178]: df_roll.plot();

现在,平均曲线和原始数据中的峰值和谷值更好地对齐,如图 21-12 所示。

Image

图 21-12:德克萨斯州与 COVID 相关的死亡人数及 30 天移动平均曲线,显示在窗口间隔的中心

你可以在表 21-6 中使用rolling()调用其他聚合方法。在这里,我们对同一 30 天滑动窗口调用标准差方法,并将新列与其他列一起显示(见图 21-13):

In [179]: df_roll['30_std'] = df_roll.deathIncrease.rolling(30,
 center=True).std()
In [180]: df_roll.plot();

Image

图 21-13:德克萨斯州 COVID 相关每日死亡的 30 天滑动窗口标准差和移动平均

除了具有固定窗口大小的滚动平均外,pandas 还提供了使用扩展窗口(expanding())、二进制移动窗口(corr())、指数加权函数(ewm())和用户定义的移动窗口函数(apply())的方法。你可以在 pandas.pydata.org/pandas-docs/stable/reference/frame.html 了解关于 DataFrame 的相关信息,在 pandas.pydata.org/pandas-docs/stable/reference/series.html 了解关于 Series 的相关信息。

测试你的知识

6.  与 pandas Timestamp 类相关的索引结构是什么?

a.  datetime64

b.  datetime64[ns]

c.  TimedeltaIndex

d.  DatetimeIndex

7.  将 '2021-2-23 00:00:00' 转换为 pandas Timestamp

8.  将上面的时间戳本地化到欧洲/华沙时区。

9.  为 2021 年 5 月 1 日的每个小时创建一个 PeriodIndex。

10.  以下哪项是降采样的示例?

a.  从分钟到秒

b.  从分钟到小时

c.  从年到周

d.  从天到月

总结

时间序列表示以时间为参考的数据索引。Python 和 pandas 都提供了特殊的“时间感知”数据类型和工具。这些工具让你可以轻松处理如六十进制运算、时区转换、夏令时、闰年、日期时间绘图等问题。通过操作时间序列,你可以深入理解数据,解决其他无法预测的问题。

好的,这就是科学家用 Python 的工具。这本书有一个简单的目标:让你作为一名科学家开始使用 Python。

如果你已经读完了这本书,你就学会了如何通过 Anaconda 配置你的计算机进行科学计算,如何使用 conda 环境和专用项目文件夹来组织项目,如何熟悉 Jupyter Qt 控制台、Spyder、Jupyter Notebook 和 JupyterLab 等编码工具。如果你是 Python 新手,你现在已经掌握了语言的基础知识。你了解了许多重要的科学和可视化包,并且应该对如何在它们之间做出选择有所了解。最后,你已经在像 NumPy、Matplotlib、pandas、seaborn 和 scikit-learn 等关键库上获得了一些实际操作经验。

接下来,提升你编程知识和技能的最佳方法是做项目,无论是赚钱还是娱乐。项目让你将庞大的 Python 世界拆解成可管理的小块,迫使你集中精力完成一组特定的任务,并提高你的信心。它们会引发你从未想过的问题,寻找答案的过程将有助于你进一步提升自己的教育水平。继续前进!

附录:“测试你的知识”挑战的答案

image

第七章

1.  False

2.  c

3.  c

In [22]: (420.5)4

Out[22]: 1764.0000000000002

In [1]: 42 **= 2

File "C:\Users\hanna\Local\Temp/ipykernel_24188/3589881457.py", line 1

42 **= 2

^

SyntaxError: 无法赋值给字面量

6.  a

In [1]: import math

In [2]: round(math.pi, 5)

Out[2]: 3.14159

In [1]: type((1, 2, 3))

Out[1]: tuple

9.  False

10.  字符串

11.  a

print (

"""

-------

|  >   <  |

|   V   |

\     /


Hooty Hoot!

""")

In [35]: secs = 1824

In [36]: minutes, seconds = ((int(secs / 60)), (int(secs % 60)))

In [37]: print(f'{secs}秒 = {minutes}分钟, {seconds}秒')

1824 秒 = 30 分钟, 24 秒

14.  b(据说是英语中最长的连续辅音字符串)

15.  d

16.  c

In [1]: from string import punctuation

In [2]: punc = punctuation.replace('-','')

In [3]: caesar_said = '呵呵,布鲁图斯。'

In [4]: hyph_only = caesar_said.translate(str.maketrans('', '',punc))

In [5]: hyph_only

Out[5]: '呵呵 布鲁图斯'

In [1]: '不切实际的 python 项目'.title()

Out[1]: '不切实际的 Python 项目'

第八章

1.  a, b, d

2.  b

3.  c

4.  改变它的名字

In [1]: x = 42

In [2]: del x

6.  False

In [1]: print(input("请输入你的名字: ")[::-1])

Enter your first name: Lee

eeL

In [1]: john == 'Harry'

Traceback (most recent call last):

File "", line 1, in

john == 'Harry'

NameError: 名字'john'未定义

In [1]: a = '爱丽丝'

In [2]: b = 42

In [3]: c = a / b

Traceback (most recent call last):

File "C:\Users\hanna\AppData\Local\Temp/ipykernel_24188/2258966649.py",

line 1, in

c = a / b

TypeError: 不支持的操作数类型:'int'和'str'

10.  a

第九章

1.  IndexError: 元组索引超出范围

2.  '!'

In [1]: tup = ('Rust', 'R', 'Go', 'Julia'), ('Python')

In [2]: tup[1][1]

Out[2]: 'y'

4.  a, b, c

In [1]: field_trip = '草帽',

...:            '岩锤',

...:            '放大镜',

...:            '登山靴',

...:            '太阳镜' In [2]: field_trip

Out[2]: ('草帽', '岩锤', '放大镜', '登山靴',

'太阳镜')

In [3]: field_trip = field_trip[1:]

In [4]: field_trip

Out[4]: ('岩锤', '放大镜', '登山靴', '太阳镜')

In [1]: patroni = []

In [2]: patroni.extend(['老虎', '鲨鱼', '黄鼠狼'])

In [3]: patroni

Out[3]: ['老虎', '鲨鱼', '黄鼠狼']

In [1]: patroni.clear()

In [2]: patroni

Out[2]: []

8.  c

9.  c

10.  c

11.  集合去除数据集中的重复值;每个唯一值只会出现一次。

12.  c

13.  True

14.  c

15.  a

16.  b

In [1]: jokes = {"你听说过绑架的事吗?":

...:          "他睡了 4 个小时!",

...:          "你把你的狗打了吗?他生气了吗?":

...:          "他对这件事并不太高兴!"}

In [2]: jokes["你听说过绑架的事吗?"]

Out[2]: '他睡了 4 个小时!'

18.  c

第十章

1.  四个空格

2.  False

In [1]: while True:

...:     print('帮帮我!!!!')

4.  a, b

print('英语到猪拉丁翻译器')

VOWELS = 'aeiouy'

while True:

word = input("输入一个单词,或者输入 '0' 停止:")

if word == '0':

break

if word[0] in VOWELS:

pig_latin = word + 'way'

else:

pig_latin = word[1:] + word[0] + 'ay'

print(f'\n{pig_latin}')

In [1]: while True:

...:      name = input('请输入你的用户名:')

...:      if name != 'Alice':

...:           continue

...:      while True:

...:           pwd = input('请输入你的密码:')

...:           如果 pwd == 'Star Lord':

...:                break

...:           else:

...:                print('密码错误')

...:      break

In [1]: count = 0

In [2]: while count < 5:

...:      print('Python')

...:      count += 1

Python

Python

Python

Python

Python

In [1]: print([i for i in range(1, 10) if i % 2 == 0])

[2, 4, 6, 8]

In [1]: for i in range(10, -1, -1):

...:      print(i)

...:

10

9

8

7

6

5

4

3

2

1

0

In [1]: words = ['年龄', '情绪化', '敲门', '毒蛇', '项目', '台阶',

'胡言乱语'

In [2]: for word in words:

...:      middle = int(len(word) / 2)

...:      print(word[middle])

g

o

o

d

j

o

b

import random

answer = random.randint(1, 100)

guess = int(input('猜一个 1 到 100 之间的数字:'))

attempts = 1

while guess != answer:

if guess > answer:

print('你猜得太高了。')

else:

print('你猜得太低了。')

guess = int(input('再猜一次:'))

attempts += 1

print('\n 你猜对了!')

print(f'你只用了 {attempts} 次尝试。')

import random

fortunes = ['天哪,人们喜欢你!',

'今天你将学习一项新的编码技能。',

'你是一个快速学习者!',

'你的智慧使你优于他人。']

misfortunes = ['你的眼睛像池塘。污水池。',

'你的耳朵像花朵。菜花。',

'你的呼吸能杀死一千只骆驼。',

'跑进巷子大喊“鱼!”']

print("""

0 - 退出

1 - 一张幸运饼干

2 - 一张不幸饼干

""")

while True:

choice = input('从菜单中选择一个数字:')

if choice.isdigit():

choice = int(choice)

if choice == 0:

print('感谢你参与游戏!')

break

if choice == 1:

print(random.choice(fortunes))

elif choice == 2:

print(random.choice(misfortunes))

else:

print('从菜单选项中选择。')

第十一章

1.  c

2.  c

3.  b

In [1]: def vowel_voider():

...:      name = input("请输入你的姓氏:")

...:      new_name = ''

...:      vowels = 'aeiouy'

...:      for char in name:

...:           if char not in vowels:

...:                new_name += char

...:           else:

...:               continue

...:      return new_name

In [1]: def calc_momentum(*, mass, velocity):

...:      return mass * velocity

In [2]: calc_momentum(mass=10, velocity=50)

Out[2]: 500

6.  c

In [1]: from random import uniform

In [2]: samples = [round(uniform(0, 50), 1) for x in range(10)]

In [3]: samples

Out[3]: [42.7, 37.8, 30.2, 35.0, 0.4, 35.1, 22.4, 9.8, 23.4, 30.0]

In [1]: nums = [3, 10, 16, 25, 88, 75]

在 [2]:filtered = filter(lambda x: x % 5 == 0, nums)

在 [3]:print(list(filtered))

[10, 25, 75]

9.  假。调用main()可以授予其访问权限。

10.  c

在 [1]:G = 0.0000000000667

在 [2]:def calc_force_gravity(mass1, mass2, radius):

...:      global G

...:      f = (G * mass1 * mass2) / radius**2

...:      return f

注意

你也可以使用 G = 6.67e-11。

在 [1]:import math

在 [2]:dir(math)

13.  b

14.  d

在 [1]:x = 25

在 [2]:def use_x(x):

...:      print(x**2)

在 [3]:use_x(x)

625

在 [4]:def use_x():

...:      global x

...:      print(x**2)

在 [5]:use_x()

625

第十二章

1.  c

2.  d

3.  假

4.  c

5.  对象

6.  e

在 [1]:from pathlib import Path

在 [2]:p = Path('.\test1\another_haiku.txt')

在 [3]:p.rename('.\test1\haiku_2.txt')

输出[3]:WindowsPath('test1/haiku_2.txt')

8.  记住,Python 从 0 开始计数:

在 [93]:with open('haiku.txt') as f:

...:      f.seek(14)

...:      print(f.read())

思考樱花树

陌生人像朋友

--一茶

9.  c

10.  真

11.  真

12.  c

在 [1]:import json

在 [2]:crew = dict(Mercury=1, Gemini=2, Apollo=3)

在 [3]:capsules_data = json.dumps(crew)

在 [4]:with open('capsules_data.json', 'w') as f:

...:      f.write(capsules_data)

在 [5]:with open('capsules_data.json', 'r') as f:

...:      crew = json.load(f)

在 [6]:for key in crew:

...:      如果 crew[key] == 1:

...:           seat = 'seat'

...:      否则:

...:           seat = 'seats'

...:      print(f"该{key}舱有{crew[key]}个{seat}。")

水星舱有 1 个座位。

双子座舱有 2 个座位。

阿波罗舱有 3 个座位。

在 [1]:test = ["don't", "do"]

在 [2]:test_json = json.dumps(test)

在 [3]:test_json

输出[3]:'["don't", "do"]'

在 [4]:test = ['don't', 'do']

在 [5]:test

输出[5]:["don't", 'do']

在 [6]:test_json = json.dumps(test)

在 [7]:test_json

输出[7]:'["don't", "do"]'

15.  d

假设当前工作目录是file_play

在 [1]:import shutil

在 [2]:shutil.move('lines.txt', 'test1')

输出[2]:'test1\lines.txt'

在 [3]:shutil.make_archive('.\test1\lines.txt', 'zip')

输出[3]:'.\test1\lines.txt.zip'

第十三章

1.  b

2.  c

3.  真

4.  c

class Parrot():

def init(self, name, color, age):

self.name = name

self.color = color

self.age = age

def squawk(self):

print("\n 刺耳的叫声!\n")

def parroting(self):

phrase = input("请输入波莉重复的话:")

print(f"\n 刺耳的叫声!{phrase}刺耳的叫声!")

polly = Parrot('波莉', '绿色', 80)

polly.squawk()

polly.parroting()

输出:

刺耳的叫声!

请输入波莉重复的话:波莉想要一个饼干!

刺耳的叫声!波莉想要一个饼干!刺耳的叫声!

6.  c

7.  e

8.  b

9.  真

10.  b

11.  d

12.  ship_display.py 程序中的新代码已用灰色高亮显示:

from math import dist

from dataclasses import dataclass

import matplotlib.pyplot as plt

@dataclass

class Ship:

'''用于追踪在网格上定位船只的对象。'''

name: str

classification: str

registry: str

location: tuple

obj_type = 'ship'

obj_color = '黑色'

def distance_to(self, other):

distance = round(dist(self.location, other.location), 2)

return str(distance) + ' ' + 'km'

garcia = Ship('Garcia', 'frigate', 'USA', (20, 15))

ticonderoga = Ship('Ticonderoga', 'destroyer', 'USA', (5, 10))

kobayashi = Ship('Kobayashi', 'maru', 'Federation', (10, 22))

VISIBLE_SHIPS = [garcia, ticonderoga, kobayashi]

def plot_ship_dist(ship1, ship2):

sep = ship1.distance_to(ship2)

对于 VISIBLE_SHIPS 中的每一艘船:

plt.scatter(ship.location[0], ship.location[1],

marker='d',

color=ship.obj_color)

plt.text(ship.location[0], ship.location[1], ship.name)

plt.plot([ship1.location[0], ship2.location[0]],

[ship1.location[1], ship2.location[1]],

color='gray',

linestyle="--")

plt.text((ship2.location[0]), (ship2.location[1] - 2), sep, c='gray')

plt.xlim(0, 30)

plt.ylim([0, 30])

plt.show()

对于 i 在 range(30) 中:

garcia.location = (20, i)

plot_ship_dist(kobayashi, garcia)i)

在 Spyder 中运行脚本之前,请进入图表窗格并选择静音内联绘图(图 A-1)。这样会强制图表在图表窗格中显示,而不是在控制台内联显示。

Image

图 A-1:在 Spyder 中从图表窗格选择静音内联绘图

要关闭所有图表,请点击图表窗格工具栏上的大X图标。

作为挑战,看看你能否让 Garcia 在屏幕上斜向移动。

第十四章

  1. b

  2. True

  3. a, c

  4. c

  5. d

在 [1]: import itertools

在 [2]: help(itertools.product)

  1. b, d

  2. a, c

  3. d

class Frigate():

"""一款用于战争游戏模拟的护卫舰类战舰。

属性

name (str): 没有指定/登记的船只名称。

crew (int): 机组成员数量。

length_ft(int): 船只的长度,单位为英尺。

tonnage (int): 船只的重量,单位为短吨(美国)。

fuel_gals(int): 油箱容量,单位为美制加仑。

guns (int): 大炮数量。

ammo (int): 可用的弹药数量。 heading (int): 船头所指的航向。

max_speed (float): 船只的最大速度,以节为单位。

speed (float): 船只当前的速度,单位为节。

定义的方法:

init(self, name)

构造船只对象所需的所有属性。

参数

name (str):

没有指定/登记的船只名称。

helm(self, heading, speed)

设置并显示船只的当前航向和速度。

参数

heading (int):

船头指向的航向。

speed (float):

当前船速,以节为单位。

返回

None

fire_guns(self)

打印 "BOOM!" 并减少并打印剩余弹药。

参数

None

返回

None

"""

第十八章

  1. c (数组可以容纳任何维度的数值)

  2. d

  3. b

  4. e

在 [1]: import numpy as np

在 [2]: np.zeros((10, 10))

在 [1]: import numpy as np

在 [2]: arr2d = np.arange(30).reshape(5, 6)

在 [3]: arr2d[::2]

在 [4]: arr2d[1::2, 1::2]

在 [5]: # 也可以:

在 [6]: arr2d[1:5:2, 1:6:2]

  1. c

  2. b

  3. 4

  4. 因为每个元素的字节大小由最大元素设定(–10000)

  5. c

  6. b

  7. c

  8. c

第十九章

  1. c

  2. True

  3. d

  4. c

  5. 注意:该解决方案使用了pyplot方法。

In [1]: rockets = {'Atlas': 57, 'Falcon9': 70, 'SaturnV': 111, 'Starship':

120}

In [2]: plt.ylabel('高度 (米)')

...: plt.bar(rockets.keys(), rockets.values(), width=0.3);

图片

  1. False

  2. 使用suptitle()方法,如下所示,适用于pyplot方法:

plt.suptitle('火星高岭土、赤铁矿和黄铁矿分布')

和面向对象风格如下:

fig.suptitle('火星高岭土、赤铁矿和黄铁矿分布')

  1. 该解决方案使用面向对象风格:

In [64]: # 创建虚拟数据集:

...: x = np.random.normal(0, 1, 50).cumsum()

...: y = np.random.normal(0, 1, 50).cumsum()

...: z = np.random.normal(0, 1, 50).cumsum()

...:

...: # 创建数据集和标题的列表:

...: data = [x, y, z]

...: titles = ['数据 X', '数据 Y', '数据 Z']

...:

...: # 创建子图:

...: fig, axs = plt.subplots(1, 3)

...: fig.tight_layout()

...:

...: # 遍历子图并使用黑色交叉点绘制数据:

...: for i, ax in enumerate(axs):

...:     ax.set_title(titles[i])

...:     ax.plot(data[i], 'k+')

图片

注意

你的图形可能会有所不同,因为数据是随机生成的。

  1. 该解决方案使用面向对象风格:

import numpy as np

import matplotlib.pyplot as plt

%matplotlib notebook

fig, ax = plt.subplots()

for _ in range(30): data = np.random.rand(4, 4)

heat = ax.imshow(data)

fig.canvas.draw()

fig.canvas.flush_events()

图片

注意

你的图形可能会有所不同,因为数据是随机生成的。

  1. 该解决方案使用面向对象风格:

import matplotlib.pyplot as plt

%matplotlib inline

def calc_data(t, pos, vel, dt):

"""返回在真空中自由下落物体的时间、位置和速度。"""

time = []  # 秒

position = []  # 米

velocity = []  # 米/秒

for _ in range(15):  # 自由落体时间(秒)。

pos = pos + vel * dt

vel = vel + -9.81 * dt  # 9.81 米/秒² 为地球重力。

t += dt

position.append(pos)

velocity.append(abs(vel))  # 转换为绝对值。

time.append(t)

return time, position, velocity

time, position, velocity = calc_data(t=0, pos=0, vel=0, dt=1)

设置绘图:

fig, ax1 = plt.subplots()

ax2 = ax1.twinx()  # 与 ax 共享 x 轴。

ax1.set_xlabel('时间 (秒)')

ax1.set_ylabel('距离 (米)', color='green')

ax2.set_ylabel('速度 (米/秒)', color='red') ax2.invert_yaxis()  # 使较大的数字显示在底部。

绘制数据:

ax1.plot(time, position, 'go', label='位置')

ax1.legend()

ax2.plot(time, velocity, 'red', label='速度')

ax2.legend(loc='lower left');

图片

第二十章

  1. a, b, d

  2. False

import pandas as pd

animals = {'canines': ['哈士奇', '贵宾犬', '斗牛犬'],

'felines': ['暹罗', '波斯', '缅因猫'],

'cetaceans': ['座头鲸', '抹香鲸', '弓头鲸']}

df = pd.DataFrame(animals)

df.rename(columns={'cetaceans': '鲸类'}, inplace=True)

df.head()

犬科动物 猫科动物 鲸类
0 哈士奇 暹罗 弓头鲸
1 贵宾犬 波斯猫 精子鲸
2 英国斗牛犬 缅因猫 right

df.head(1)

犬科动物 猫科动物 鲸类
0 哈士奇 暹罗 弓头鲸

df_t = df.T

df_t

0 1 2
犬科动物 哈士奇 贵宾犬 英国斗牛犬
猫科动物 暹罗 波斯猫 缅因猫
鲸类 弓头鲸 精子鲸 right
  1. b 和 c

  2. c

  3. c

  4. c

  5. b 和 d

第二十一章

  1. b

  2. c 和 d

  3. c

  4. c

  5. d

  6. d

在 [1]: import pandas as pd

在 [2]: date = '2021-2-23 00:00:00'

在 [3]: dt = pd.to_datetime(date)

在 [4]: dt

输出[4]: Timestamp('2021-02-23 00:00:00')

在 [1]: import pandas as pd

在 [2]: dt_warsaw = dt.tz_localize('Europe/Warsaw')

在 [3]: dt_warsaw

输出[3]: Timestamp('2021-02-23 00:00:00+0100', tz='Europe/Warsaw')

在 [1]: import pandas as pd

在 [2]: hours = pd.period_range(start='2021-5-1',

...: periods=24,

...: freq='H')

在 [3]: hours

输出[3]:

PeriodIndex(['2021-05-01 00:00', '2021-05-01 01:00', '2021-05-01 02:00', '2021-05-01 03:00', '2021-05-01 04:00', '2021-05-01 05:00',

'2021-05-01 06:00', '2021-05-01 07:00', '2021-05-01 08:00',

'2021-05-01 09:00', '2021-05-01 10:00', '2021-05-01 11:00',

'2021-05-01 12:00', '2021-05-01 13:00', '2021-05-01 14:00',

'2021-05-01 15:00', '2021-05-01 16:00', '2021-05-01 17:00',

'2021-05-01 18:00', '2021-05-01 19:00', '2021-05-01 20:00',

'2021-05-01 21:00', '2021-05-01 22:00', '2021-05-01 23:00'],

dtype='period[H]')

  1. b 和 d

第一部分:设置你的科学编码环境**

在第一部分中,你将创建一个科学编码环境,为未来多年的工作打下基础。你将首先安装Anaconda,这是一个适用于 Windows、macOS 和 Linux 的 Python 发行版,提供了本书中将使用的科学库。然后,你将学习使用 conda 包和环境管理器来保持项目的组织和更新。之后,你将熟悉流行的编码工具 Jupyter Qt 控制台、Spyder、Jupyter Notebook 和 JupyterLab。

这些编码工具帮助你编写代码、运行代码并查看输出,已总结在表 I-1 中。如果你不确定表中任何术语的含义,请参见“术语”侧边栏。

表 I-1: 编码工具摘要

Image

Jupyter Qt 控制台让你在名为 IPython 解释器的窗口内执行命令,并立即显示结果。你可以使用此控制台与数据交互和可视化。它也非常适合学习 Python。

著名的 Jupyter Notebook 是一个 Web 应用程序,允许你创建和共享包含实时代码、方程式、可视化以及叙述文本的文档。它是数据科学中广泛使用的工具,可以让你做从探索和清洗数据到生成精美的交互式报告、演示和仪表盘的一切。通过基于云的JupyterHub,你可以为多个用户提供 Jupyter 笔记本,例如一班学生或一个科学研究小组。

Spyder 和 JupyterLab 是集成开发环境(IDEs)。IDE 是为程序员提供一套软件开发工具的应用程序。例如,IDE 可能包括调试软件和测量代码或代码部分运行时间的工具。IDE 旨在与特定的应用平台一起使用,消除开发生命周期中的障碍。它们通常用于比控制台或笔记本中通常做的更复杂的编程。JupyterLab是 Anaconda 的 Jupyter 项目的下一代用户界面,结合了经典的 Jupyter Notebook 和提供类似 IDE 体验的用户界面。它将来有一天会取代 Jupyter Notebook。

这些编码工具是Interactive Python (IPython)的产品,IPython 是用于交互式计算的命令行外壳。(命令行外壳将操作系统的服务暴露给程序或用户。)IPython 仍在不断发展,2015 年该项目分裂,语言无关的部分(如笔记本格式、Qt 控制台、Web 应用程序、消息协议等)被移至 Jupyter 项目。

Jupyter这个名字来源于 Julia、Python 和 R 语言,尽管该项目支持超过 40 种语言。拆分后,一些术语发生了变化。最显著的是,IPython Notebook 变成了 Jupyter Notebook。IPython 产品的功能也存在一些重叠。这可能会导致混淆,尤其是考虑到许多在线文章和教程中引用了旧的术语。如果你对 IPython 和 Jupyter Notebook 的历史感兴趣,可以查看 datacamp 博客文章“IPython 还是 Jupyter?”*,www.datacamp.com/community/blog/ipython-jupyter/

术语

以下是我们将在第一部分中使用的一些重要术语。

调试

这是一个多步骤的过程,用于查找、隔离和解决阻止程序正常运行的问题,这些问题被称为bug。调试通常使用一个名为调试器的程序来进行。调试器在受控条件下以逐步模式运行问题程序,以跟踪其操作。这通常包括在特定点运行或暂停程序,跳过某些部分,显示内存内容,显示导致程序崩溃的错误位置,等等。

可扩展

可扩展性是软件工程和系统设计中的一个原则,它表明一个工具是否提供了未来发展的空间。例如,JupyterLab 被设计为一个可扩展的环境。JupyterLab 的扩展是提供新的交互功能的附加组件。例如,JupyterLab LaTeX是一个允许你实时编辑 LaTeX 文档的扩展,JupyterLab Plotly是一个渲染 Plotly 图表的扩展,JupyterLab 系统监视器让你监控自己的资源使用情况,比如内存和 CPU 时间。你甚至可以为自己的项目编写自定义插件。

集成开发环境(IDE)

IDE 是一种编程工具,它将其他专用工具集成到一个单一的编程环境中。这些专用工具包括文本编辑器、调试器、代码自动完成功能、错误高亮功能、文件管理器、项目管理器、性能分析器、部署工具、编译器等等。通过将常见的软件开发工具整合到一个应用程序中,IDE 提高了程序员的生产力,并且使得管理有许多互相关联脚本的大型项目变得更加容易。缺点是,IDE 可能会比较沉重,意味着它们可能占用大量系统资源。对于初学者和只需要编写相对简单脚本的人来说,IDE 也可能显得有些过于复杂。

自省

确定对象类型并在运行时检查其属性的能力。在 Python 中,对象是一种具有属性和方法的代码特性;你将在第十三章中进一步了解这些内容。代码自省动态检查这些对象并提供关于它们的信息。当自省功能可用时,将鼠标悬停在代码中的对象上会弹出一个窗口,列出对象的类型以及有关如何使用它的有用提示。

内核

操作系统核心中的计算引擎。它始终驻留在内存中,这意味着操作系统不能将其交换到存储设备上。内核管理磁盘、任务和内存,并作为应用程序与硬件层面数据处理之间的桥梁。

性能分析

一种分析方法,用于衡量程序或程序组件在运行时所需的时间或内存。性能分析信息可以优化代码并提高其性能。集成开发环境(IDE),如 Spyder,内置了性能分析工具。

Qt

发音为cute,这是一个小工具(“Windows 小工具”)工具包,用于创建图形用户界面和跨平台应用程序,这些应用程序可以在 Windows、macOS、Linux 和 Android 上运行。

终端

在现代用法中,终端指的是终端仿真器,而非实际的硬件,如显示器和键盘。仿真器提供一个基于文本的界面,用于输入命令,也可以被称为命令行界面(CLI)命令提示符控制台shell。所有主要操作系统都配备了某种类型的终端。Windows 包括用于运行磁盘操作系统(DOS)命令的命令提示符可执行文件cmd.exe,并可以连接到其他服务器。macOS 附带了恰如其名的终端,你可以使用它在操作系统中运行 Unix 命令,或使用 Zsh 或 Z shell 访问其他计算机。Unix 通常包括一个名为xterm的程序,可以运行Bash或其他 Unix shell。

终端并不是很用户友好,但它们可以访问某些信息和软件,这些内容有时只在中央计算机上可用,例如文件传输协议(FTP)服务器。在终端中操作成千上万的文件和文件夹比在图形窗口中更容易。你可以在计算机上自动化和加速工作流,从而节省时间并减少烦恼。此外,你还可以通过终端运行 Python 程序,以及许多 Anaconda 操作(作为使用 Anaconda Navigator GUI 的替代方法)。最棒的是,知道如何使用终端将大大给你的同事们留下深刻印象。

在完成第四章的内容后,你可以继续学习第一部分,"Python 入门",以了解 Python 编程的基础。如果你已经对 Python 有一定了解,可以直接完成第一部分,然后跳到第三部分,"Anaconda 生态系统",深入学习科学计算中常用的关键包。

第二部分**

PYTHON 入门

如果你以前从未使用过 Python 编程语言,这本入门书将帮助你快速上手。你将学习语言的基础知识,以及一些解决实际问题的有用提示和技巧。如果你已经了解一些 Python,可以将这本入门书作为参考材料,在需要时帮助你回忆起相关内容。

当你在学校学习人类语言时,你会从字母表和名词、动词、副词等词类开始。接下来,你可能会学习如何使用这些构建块来分析句子,将它们串联起来形成连贯的思想。

学习编程语言的方式与学习人类语言非常相似。就像人类语言通过语法规则将词语连接成可理解的表达,Python 也通过语法规则将对象连接成可执行的程序。但这不是一个线性过程。就像一个学说话的幼儿,很多事情会同时发生。

从一开始,你将获得大量“嵌套”的知识。你无法理解变量是什么,直到你理解对象是什么;而你无法理解对象,直到你理解值,或者无法理解值,直到你理解数据类型。因此,如果你浏览初学者编程书籍的目录,你会发现它们呈现信息的方式并不一致。

在接下来的章节中,我将尝试按照逻辑顺序推进语言的学习,使得每一步都在前一步的基础上构建。然而,有时候我们可能需要先运行某些函数再进行定义,或者在完全展开一个概念之前就提到它。没关系,人类是通过实践来学习的,我们擅长利用上下文和推断填补知识空白。

当然,这个简短的介绍无法详细涵盖 Python 的所有特性,但它应该为你提供一个良好的基础,帮助你开始自己的编程之旅。如果你想要更深入地了解 Python,我建议阅读 Python Crash Course, 2nd edition: A Hands-On, Project-Based Introduction to Programming(No Starch Press, 2019),作者是 Eric Matthes。或者,如果你想要一个更技术性、更深入的介绍,可以尝试 Learning Python(第五版,O’Reilly Media, 2013),作者是 Mark Lutz。为了拓展你在初学者书籍之外的知识,我建议阅读 Beyond the Basic Stuff with Python: Best Practices for Writing Clean Code(No Starch Press, 2021),作者是 Al Sweigart。

若要查找在线教程、编程训练营、视频等资源,请访问 wiki.python.org/moin/BeginnersGuide/Programmers/。该 Wiki 页面包括面向非程序员的部分 (wiki.python.org/moin/BeginnersGuide/NonProgrammers/),同时也为不同编程经验的学习者提供了资源,帮助你找到更多的学习资料。我还发现 Real Python 网站 (realpython.com/),是一个很好的 Python 教程和信息来源。它提供了免费的内容和付费内容。

要成为一名真正的 Python 爱好者,你需要了解 Python 的禅意 (www.python.org/dev/peps/pep-0020/),这是一组影响 Python 语言设计的 19 条指导原则。根据这些原则,“做某件事应该有一个——并且最好只有一个——明显的方法。” 本着提供单一明显的“正确方式”并围绕这些实践建立共识的精神,Python 社区发布了被称为 Python 增强提案PEP 的编码规范。

最重要的 PEP 是 PEP 8 (www.python.org/dev/peps/pep-0008/),它是关于 Python 代码风格的一套标准。它包括命名规范、关于空行、制表符和空格使用的规则、最大行长度、注释格式等。其目标是提高代码的可读性,并在广泛的 Python 程序中保持一致性。另一个有用的风格指南是 PEP 257 (www.python.org/dev/peps/pep-0257/),它涵盖了代码文档的编写。在接下来的章节中,我们将详细介绍这两个指南。

最后,如果书籍和在线搜索无法满足你的需求,下一步就是寻求他人的帮助。如果没有同事或同学能够帮忙,你可以在网上寻求帮助,既可以付费也可以在像 Stack Overflow 这样的免费论坛上找到答案 (stackoverflow.com/)。但要警告你的是:这些网站的成员不容忍愚蠢的提问。在发帖前,一定要阅读他们的“如何提问?”页面。你可以在 stackoverflow.com/help/how-to-ask/ 找到 Stack Overflow 的相关建议和指导。

第三部分**

Anaconda 生态系统

Python 的科学生态系统是强大的,这意味着它试图以多种方式满足相同的需求。如本书引言中所述,这可能导致用户在大量包和工具中迷失方向。通过 conda 和 conda-forge 提供的数千个 Python 包,用户很容易感到不知所措。

幸运的是,只有少数几个包被认为是科学工作中必不可少的。这些库构成了用于科学研究的基本 Python 生态系统,并在图 III-1 中显示,此外还包括一些重叠、附加和竞争的库(例如,PyCharm 和 Spyder 是通过 Anaconda 提供的类似编程工具)。

这个生态系统的核心是 Python。在图 III-1 中,Anaconda 在外缘环绕着 Python 以及其他库和工具,帮助你高效地使用它们。在 Python 和 Anaconda 之间有几个环形层,旨在传达某些库是建立在其他库之上的。

外层的两个环包含了帮助你编写代码、运行代码并查看输出的工具。这些工具包括(如今已经熟悉的)Jupyter Qt 控制台Jupyter NotebookSpyderJupyterLab

最内层的三个环包含了通过 Anaconda 提供的一些众多科学和绘图库。在第三部分中,我们将快速了解这些库的功能、使用它们的原因,以及如何在重叠或竞争的版本中做出选择。在第四部分中,我们将更深入地探讨一些更重要的库,尽管并非全面。

图片

图 III-1:用于科学研究的基础 Python 生态系统(改编自 indranilsinharoy.com/,2013)

第四部分:必备库

在所有对科学至关重要的 Python 库中,NumPy、Matplotlib 和 pandas 无疑是最为核心的。这三大库组成了一个三联画,每一块都建立在前一块的基础上。它们共同构成了 Python 中大多数科学与数据分析工作的巨大画布。

为什么这些库如此重要?首先,它们是成熟的、可靠的,并且被广泛应用于许多学科。作为成熟的库,它们拥有庞大的支持网络,包括在线论坛、教程、示例用例,以及许多专门介绍每个库的优秀纸质书籍和电子书。此外,它们为其他重要的库奠定了基础。在大多数科学和工程工作中,熟悉 NumPy、Matplotlib 和 pandas 是熟练使用 Python 的必备条件。

在接下来的三章中,我们将依次介绍这些库。由于每个库都可以单独成册,我们将重点介绍这些库的目的、对新用户来说难以理解或令人沮丧的组件,以及开始将这些库应用到自己项目中所需的基本功能。

posted @ 2025-11-30 19:37  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报