Pandas-秘籍第三版-全-

Pandas 秘籍第三版(全)

原文:annas-archive.org/md5/dbf45b033e25cfae0fd6c82aa3a4578a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

pandas 是一个用于创建和处理结构化数据的 Python 库。我说的“结构化”是什么意思?我指的是像你在电子表格或数据库中看到的那样,按行和列组织的数据。数据科学家、分析师、程序员、工程师以及其他人都在利用它来塑造他们的数据。

pandas 仅限于“小数据”(可以容纳在单台机器内存中的数据)。然而,pandas 的语法和操作已经被其他项目采纳或启发:例如 PySpark、Dask 和 cuDF 等。这些项目的目标不同,但其中一些可以扩展到大数据。因此,了解 pandas 的工作原理是有价值的,因为它的功能正在成为与结构化数据交互的事实标准 API。

我是 Will Ayd,自 2018 年以来一直是 pandas 库的核心维护者。在此期间,我有幸为其他许多开源项目做出贡献并进行合作,这些项目属于同一生态系统,包括但不限于 Arrow、NumPy 和 Cython。

我还以咨询为生,使用我所贡献的相同生态系统。通过使用最好的开源工具,我帮助客户制定数据战略、实施流程和模式,并培训员工,以保持在不断变化的分析领域中领先。我坚信开源工具所提供的自由,并已向许多公司证明了这一价值。

如果贵公司有兴趣优化您的数据战略,请随时联系我(will_ayd@innobi.io)。

本书适合谁

本书包含了大量配方,范围从非常简单到高级。所有配方都力求以清晰、简洁和现代化的 pandas 惯用代码编写。“如何运作”部分包含了对每个步骤细节的极其详细描述。通常,在“更多内容…”部分,您会看到看似全新的配方。本书充满了大量 pandas 代码。

尽管不是严格要求,但建议用户按时间顺序阅读本书。书中的配方以一种结构化的方式呈现,首先通过非常小且有针对性的示例引入概念和特性,但从此基础上不断构建,逐渐过渡到更复杂的应用。

由于本书涉及的复杂性范围广泛,它对初学者和日常用户都很有用。根据我的经验,即使是那些经常使用 pandas 的人,也无法掌握它,除非他们接触到惯用的 pandas 代码。这在一定程度上是由于 pandas 提供的广度。几乎总是有多种方式完成相同的操作,这可能让用户得到他们想要的结果,但效率非常低。经常可以看到两组 pandas 解决方案在同一个问题上性能差距达到一个数量级或更多。

本书的唯一真正前提是具备基础的 Python 知识。假设读者已经熟悉 Python 中所有常见的内建数据容器,例如列表、集合、字典和元组。

本书内容涵盖

第一章pandas 基础,介绍了 pandas 中的主要对象,即 Series、DataFrame 和 Index

第二章选择与赋值,展示了如何筛选你已加载到任何 pandas 数据结构中的数据。

第三章数据类型,探讨了 pandas 背后的类型系统。这是一个快速发展的领域,并将继续发展,因此了解类型及其区别是非常宝贵的信息。

第四章pandas I/O 系统,展示了为什么 pandas 长期以来一直是读取和写入各种存储格式的流行工具。

第五章算法及其应用,介绍了使用 pandas 数据结构进行计算的基础。

第六章可视化,展示了如何直接使用 pandas 进行绘图,以及与 seaborn 库的良好集成。

第七章重塑 DataFrame,讨论了如何通过 pandas pd.DataFrame 强大地转换和总结数据的多种方式。

第八章分组,展示了如何对包含在 pd.DataFrame 中的数据子集进行分段和总结。

第九章时间数据类型与算法,向用户介绍了支持 pandas 在时间序列分析中应用的日期/时间类型,并强调了如何使用这些类型处理真实数据。

第十章常见用法/性能提示,讲解了用户在使用 pandas 时常遇到的常见陷阱,并展示了符合惯用法的解决方案。

第十一章pandas 生态系统,讨论了与 pandas 集成、扩展和/或互补的其他开源库。

如何充分利用这本书

有几件事情可以帮助你充分利用这本书。首先,最重要的一点是,你应该下载所有代码,这些代码存储在 Jupyter Notebook 中。在阅读每个食谱时,运行笔记本中的每一步代码。在运行代码时,确保自己进行探索。其次,打开 pandas 的官方文档页面(pandas.pydata.org/pandas-docs/stable/)作为浏览器标签之一。pandas 文档是一个出色的资源,包含了超过 1000 页的资料,文档中有大多数 pandas 操作的示例,而且这些示例通常会直接从 另见 部分提供链接。虽然文档涵盖了大多数操作的基础内容,但它用的是琐碎的示例和虚假的数据,这些内容不能反映你在分析现实世界数据集时可能遇到的情况。

这本书所需的知识

pandas 是 Python 编程语言的第三方包,截至本书打印时,正从 2.x 版本过渡到 3.x 版本。本书中的示例应至少适用于 pandas 2.0 版本以及 Python 3.9 及以上版本。

本书中的代码将使用 pandas、NumPy 和 PyArrow 库。Jupyter Notebook 文件也是一种流行的可视化和检查代码的方式。所有这些库应该可以通过 pip 或你选择的包管理器安装。对于 pip 用户,你可以运行:

`python -m pip install pandas numpy pyarrow notebook` 

下载示例代码文件

你可以从你的账户在 www.packt.com 下载本书的示例代码文件。如果你从其他地方购买了本书,你可以访问 www.packtpub.com/support/errata 并注册,让文件直接通过电子邮件发送给你。

你可以按照以下步骤下载代码文件:

  1. www.packt.com 上登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载

  4. 搜索框中输入书名,并按照屏幕上的指示操作。

本书的代码包也托管在 GitHub 上,网址为 github.com/WillAyd/Pandas-Cookbook-Third-Edition。如果代码有更新,它会在现有的 GitHub 仓库中进行更新。

运行 Jupyter notebook

本书推荐的学习方法是保持 Jupyter notebook 运行,这样你可以在阅读配方时运行代码。边读边做可以让你在电脑上探索,从而比仅仅阅读书本更深入理解内容。

安装 Jupyter notebook 后,打开命令提示符(在 Windows 上输入 cmd,或者在 Mac 或 Linux 上打开终端),然后输入:

`jupyter notebook` 

从你的主目录运行此命令并不是必须的。你可以从任何位置运行它,浏览器中的内容将会反映该位置。尽管我们现在已经启动了 Jupyter Notebook 程序,但实际上我们还没有启动任何单独的笔记本来开始进行 Python 开发。要做到这一点,你可以点击页面右侧的新建按钮,这将下拉显示所有可供你使用的内核列表。如果你是从全新安装开始,那么你将只能使用一个内核(Python 3)。选择 Python 3 内核后,浏览器中将打开一个新标签页,在那里你可以开始编写 Python 代码。

当然,你可以打开以前创建的笔记本,而不是开始一个新的笔记本。为此,你可以通过 Jupyter Notebook 浏览器主页提供的文件系统进行导航,选择你想要打开的笔记本。所有 Jupyter Notebook 文件的扩展名都是 .ipynb

或者,您可以使用云服务提供商的笔记本环境。谷歌和微软都提供免费的预装有 pandas 的笔记本环境。

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此下载:packt.link/gbp/9781836205876

约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码字、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter/X 账户。例如:“您可能需要安装 xlwtopenpyxl 来分别写入 XLS 或 XLSX 文件。”

代码块设置如下:

`import pandas as pd import numpy as np movies = pd.read_csv("data/movie.csv") movies` 

粗体:表示一个重要的词,或屏幕上显示的词。例如:“从管理面板中选择系统信息。”

斜体:表示在写作上下文中具有额外重要性的术语

重要提示

显示如下。

提示

显示如下。

每个食谱的假设

假设每个食谱开始时,都会将 pandas、NumPy、PyArrow 和 Matplotlib 导入到命名空间中:

`import numpy as np import pyarrow as pa import pandas as pd` 

数据集描述

本书中使用了大约二十多个数据集。在完成食谱步骤时,了解每个数据集的背景信息非常有帮助。每个数据集的详细描述可以在 dataset_descriptions Jupyter Notebook 文件中找到,该文件位于 github.com/WillAyd/Pandas-Cookbook-Third-Edition。每个数据集将列出各列、每列的相关信息,并提供有关数据来源的备注。

章节

在本书中,您将找到几个经常出现的标题。

为了提供清晰的食谱完成指导,我们可能会使用以下某些或所有章节:

如何做

本节包含完成食谱所需的步骤。

它是如何工作的

本章节通常包含对前一章节内容的详细解释。

还有更多内容…

本节包含关于食谱的附加信息,旨在让您更了解该食谱。

联系我们

我们始终欢迎读者的反馈。

常规反馈:如果您对本书的任何方面有疑问,请在邮件主题中注明书名,并通过 customercare@packtpub.com 给我们发送邮件。

勘误:虽然我们已尽最大努力确保内容的准确性,但仍可能会发生错误。如果您在本书中发现错误,我们将非常感谢您向我们报告。请访问,www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表格链接,并填写相关详情。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,请提供给我们位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料链接。

如果您有兴趣成为作者:如果您在某个专业领域有专长,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

留下评论!

感谢您从 Packt Publishing 购买这本书,希望您喜欢!您的反馈是宝贵的,有助于我们改进和成长。阅读完毕后,请花点时间留下亚马逊评论;只需一分钟,但对像您这样的读者有很大的帮助。

packt.link/NzOWQ

扫描下面的二维码,获取您选择的免费电子书。

下载这本书的免费 PDF 副本

感谢您购买这本书!

您喜欢随时随地阅读,但无法随身携带印刷书籍吗?

您购买的电子书与您选择的设备不兼容吗?

别担心,现在每本 Packt 书籍您都可以免费获得该书的无 DRM PDF 版本。

随时随地,在任何地方,使用任何设备阅读。直接从您喜爱的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

好处不止于此,您还可以独家获得折扣、新闻简报和每天在您的收件箱中获得优质免费内容。

按照以下简单步骤获取这些好处:

  1. 扫描二维码或访问下面的链接:

packt.link/free-ebook/9781836205876

  1. 提交您的购买凭证。

  2. 就这样!我们将把您的免费 PDF 和其他好处直接发送到您的电子邮件。

第一章:pandas 基础

pandas 库对于处理结构化数据非常有用。什么是结构化数据?存储在表格中的数据,如 CSV 文件、Excel 电子表格或数据库表格,都是结构化的。非结构化数据则包括自由格式的文本、图像、声音或视频。如果你处理的是结构化数据,pandas 对你会非常有帮助。

pd.Series 是一维数据集合。如果你来自 Excel,可以把它当作一列。主要的区别是,像数据库中的一列一样,pd.Series 中的所有值必须具有相同的数据类型。

pd.DataFrame 是一个二维对象。就像 Excel 表格或数据库表格可以被看作是列的集合,pd.DataFrame 可以被看作是 pd.Series 对象的集合。每个 pd.Series 具有相同的数据类型,但 pd.DataFrame 可以是异构的,允许存储具有不同数据类型的各种 pd.Series 对象。

pd.Index 与其他工具没有直接的类比。Excel 可能提供最接近的功能,通过工作表左侧的自动编号行,但这些数字通常仅用于显示。正如你将在本书的过程中发现的那样,pd.Index 可以用于选择值、连接表格等更多用途。

本章的教程将展示如何手动构建 pd.Seriespd.DataFrame 对象,定制与之关联的 pd.Index 对象,并展示你在分析过程中可能需要检查的 pd.Seriespd.DataFrame 的常见属性。

本章我们将讨论以下教程:

  • 导入 pandas

  • Series

  • DataFrame

  • 索引

  • Series 属性

  • DataFrame 属性

导入 pandas

大多数 pandas 库的用户会使用导入别名,这样他们就可以用 pd 来引用它。一般来说,在本书中,我们不会显示 pandas 和 NumPy 的导入语句,但它们通常是这样的:

`import pandas as pd import numpy as np` 

虽然在 2.x 版本的 pandas 中它是一个可选的依赖项,但本书中的许多示例也会使用 PyArrow 库,我们假设它是以如下方式导入的:

`import pyarrow as pa` 

Series

pandas 中的基本构建块是 pd.Series,它是一个与 pd.Index 配对的一维数据数组。索引标签可以作为一种简单的方式来查找 pd.Series 中的值,类似于 Python 字典使用键/值对的方式(我们将在第二章选择与赋值中详细展开这一点,以及更多关于 pd.Index 的功能)。

接下来的部分展示了几种直接创建 pd.Series 的方式。

如何做到

构建 pd.Series 的最简单方法是提供一个值的序列,比如一个整数列表:

`pd.Series([0, 1, 2])` 
`0    0 1    1 2    2 dtype: int64` 

元组 是另一种序列类型,使其可以作为 pd.Series 构造函数的有效参数:

`pd.Series((12.34, 56.78, 91.01))` 
`0    12.34 1    56.78 2    91.01 dtype: float64` 

在生成示例数据时,你可能会经常使用 Python 的 range 函数:

`pd.Series(range(0, 7, 2))` 
`0    0 1    2 2    4 3    6 dtype: int64` 

在到目前为止的所有示例中,pandas 会尝试根据其参数推断出适当的数据类型。然而,有时你对数据的类型和大小的了解超过了它所能推断的内容。通过dtype=参数明确地提供这些信息,可以帮助节省内存或确保与其他类型系统(如 SQL 数据库)正确集成。

为了说明这一点,让我们使用一个简单的range参数来填充一个pd.Series,以生成一个整数序列。当我们之前这么做时,推断的数据类型是 64 位整数,但我们作为开发人员可能知道,永远不会期望在这个pd.Series中存储更大的值,使用 8 位存储就足够了(如果你不知道 8 位和 64 位整数的区别,这个话题会在第三章数据类型中讨论)。将dtype="int8"传递给pd.Series构造函数将告诉 pandas 我们希望使用较小的数据类型:

`pd.Series(range(3), dtype="int8")` 
`0    0 1    1 2    2 dtype: int8` 

pd.Series还可以附加一个名称,可以通过name=参数指定(如果未指定,默认名称为None):

`pd.Series(["apple", "banana", "orange"], name="fruit")` 
`0     apple 1     banana 2     orange Name: fruit, dtype: object` 

DataFrame

虽然pd.Series是构建块,但pd.DataFrame是 pandas 用户首先想到的主要对象。pd.DataFrame是 pandas 中最常用的主要对象,当人们想到 pandas 时,通常会想到使用pd.DataFrame

在大多数分析工作流中,你将从其他来源导入数据,但现在,我们将向你展示如何直接构造一个pd.DataFrame(输入/输出将在第四章pandas I/O 系统中讨论)。

如何实现

pd.DataFrame的最基本构造是使用二维序列,例如一个列表的列表:

`pd.DataFrame([     [0, 1, 2],     [3, 4, 5],     [6, 7, 8], ])` 
 `0   1   2 0   0   1   2 1   3   4   5 2   6   7   8` 

使用列表的列表时,pandas 会自动为你编号行和列标签。通常,pandas 的用户至少会为列提供标签,因为这使得从pd.DataFrame中进行索引和选择变得更加直观(有关索引和选择的介绍,请参见第二章选择与赋值)。在从列表的列表构造pd.DataFrame时,你可以为构造函数提供一个columns=参数,以标记你的列:

`pd.DataFrame([     [1, 2],     [4, 8], ], columns=["col_a", "col_b"])` 
 `col_a    col_b 0    1          2 1    4          8` 

除了使用列表的列表,你还可以提供一个字典。字典的键将作为列标签,字典的值将表示放置在pd.DataFrame中该列的值:

`pd.DataFrame({     "first_name": ["Jane", "John"],     "last_name": ["Doe", "Smith"], })` 
 `first_name      last_name 0           Jane            Doe 1           John            Smith` 

在上面的示例中,我们的字典值是字符串的列表,但pd.DataFrame并不严格要求使用列表。任何序列都可以使用,包括pd.Series

`ser1 = pd.Series(range(3), dtype="int8", name="int8_col") ser2 = pd.Series(range(3), dtype="int16", name="int16_col") pd.DataFrame({ser1.name: ser1, ser2.name: ser2})` 
 `int8_col         int16_col 0            0                0 1            1                1 2            2                2` 

索引

当构造之前部分中的pd.Seriespd.DataFrame对象时,你可能注意到这些对象左侧的值从 0 开始,每增加一行数据就递增 1。负责这些值的对象是pd.Index,如下图所示:

图 1.1:默认的pd.Index,以红色突出显示

pd.DataFrame的情况下,不仅对象左侧有pd.Index(通常称为行索引或仅称为索引),上方也有(通常称为列索引):

计算机截图

图 1.2:一个带有行和列索引的pd.DataFrame

除非明确提供,否则 pandas 会为你创建一个自动编号的pd.Index(从技术上讲,这是pd.RangeIndex,它是pd.Index类的子类)。然而,很少将pd.RangeIndex用于列,因为引用名为CityDate的列比引用第n^(th)位置的列更具表达性。pd.RangeIndex更常见于行索引中,尽管你可能仍然希望在此使用自定义标签。关于默认的pd.RangeIndex和自定义pd.Index值的更高级选择操作将在第二章《选择与赋值》中进行讲解,帮助你理解不同的用例,但现在我们先来看一下如何在构建pd.Seriespd.DataFrame时覆盖行和列pd.Index对象的构造。

如何操作

在构建pd.Series时,最简单的改变行索引的方法是向index=参数提供一个标签序列。在这个例子中,使用dogcathuman作为标签,而不是默认的从 0 到 2 的pd.RangeIndex

`pd.Series([4, 4, 2], index=["dog", "cat", "human"])` 
`dog          4 cat          4 human        2 dtype: int64` 

如果你想要更精细的控制,可能需要在将pd.Index作为参数传递给index=之前先构建pd.Index。在以下示例中,pd.Index被命名为animal,而pd.Series本身被命名为num_legs,为数据提供了更多上下文:

`index = pd.Index(["dog", "cat", "human"], name="animal") pd.Series([4, 4, 2], name="num_legs", index=index)` 
`animal dog          4 cat          4 human        2 Name: num_legs, dtype: int64` 

pd.DataFrame使用pd.Index来表示两个维度。与pd.Series构造函数类似,index=参数可以用来指定行标签,但现在你还可以使用columns=参数来控制列标签:

`pd.DataFrame([     [24, 180],     [42, 166], ], columns=["age", "height_cm"], index=["Jack", "Jill"])` 
 `age    height_cm Jack     24     180 Jill     42     166` 

序列属性

一旦你拥有了pd.Series,你可能想检查一些属性。最基本的属性可以告诉你数据的类型和大小,这通常是你在从数据源读取数据时首先检查的内容。

如何操作

让我们从创建一个带有名称的pd.Series开始,同时创建一个自定义的pd.Index,该pd.Index本身也有名称。虽然这些元素并非全部必需,但它们将帮助我们更清楚地理解通过这个示例访问的属性实际上展示了什么:

`index = pd.Index(["dog", "cat", "human"], name="animal") ser = pd.Series([4, 4, 2], name="num_legs", index=index) ser` 
`animal dog      4 cat      4 human    2 Name: num_legs, dtype: int64` 

用户通常想了解的第一个数据特性是pd.Series的类型。可以通过pd.Series.dtype属性来检查:

`ser.dtype` 
`dtype('int64')` 

可以通过pd.Series.name属性检查名称。我们在此例中创建的数据使用了name="num_legs"参数,这就是你在访问该属性时看到的内容(如果没有提供名称,则返回None):

`ser.name` 
`num_legs` 

相关的pd.Index可以通过pd.Series.index访问:

`ser.index` 
`Index(['dog', 'cat', 'human'], dtype='object', name='animal')` 

关联的pd.Index的名称可以通过pd.Series.index.name访问:

`ser.index.name` 
`animal` 

形状可以通过pd.Series.shape访问。对于一维的pd.Series,形状返回为一个单一元组,其中第一个元素表示行数:

`ser.shape` 
`3` 

大小(元素个数)可以通过pd.Series.size访问:

`ser.size` 
`3` 

Python 内建函数len可以显示你数据的长度(行数):

`len(ser)` 
`3` 

DataFrame 属性

pd.DataFramepd.Series共享许多属性,但也有一些细微差别。通常,pandas 会尽量在pd.Seriespd.DataFrame之间共享尽可能多的属性,但pd.DataFrame的二维特性使得某些内容更自然地以复数形式表达(例如,.dtype属性变为.dtypes),并为我们提供了一些额外的属性进行检查(例如,.columns对于pd.DataFrame存在,但pd.Series则没有)。

如何操作

就像我们在前一部分所做的那样,我们将构造一个带有自定义pd.Indexpd.DataFrame,同时在列中使用自定义标签。这在检查各种属性时将更有帮助:

`index = pd.Index(["Jack", "Jill"], name="person") df = pd.DataFrame([     [24, 180, "red"],     [42, 166, "blue"], ], columns=["age", "height_cm", "favorite_color"], index=index) df` 
 `age    height_cm    favorite_color person Jack       24     180          red Jill       42     166          blue` 

每一列的类型可以通过pd.DataFrame.dtypes属性进行检查。此属性返回一个pd.Series,其中每一行显示对应于pd.DataFrame每列的数据类型:

`df.dtypes` 
`age                int64 height_cm          int64 favorite_color     object dtype: object` 

行索引可以通过pd.DataFrame.index访问:

`df.index` 
`Index(['Jack', 'Jill'], dtype='object', name='person')` 

列索引可以通过pd.DataFrame.columns访问:

`df.columns` 
`Index(['age', 'height_cm', 'favorite_color'], dtype='object')` 

形状可以通过pd.DataFrame.shape访问。对于二维的pd.DataFrame,形状返回为一个二元组,第一个元素表示行数,第二个元素表示列数:

`df.shape` 
`2     3` 

大小(元素个数)可以通过pd.DataFrame.size访问:

`df.size` 
`6` 

Python 内建函数len可以显示你数据的长度(行数):

`len(df)` 
`2` 

加入我们在 Discord 的社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

第二章:选择和赋值

在上一章中,我们学习了如何创建 pd.Seriespd.DataFrame,并且还了解了它们与 pd.Index 的关系。在掌握了 构造函数 的基础后,我们现在转向选择和赋值这一关键过程。选择,也称为 索引,被认为是 getter;即它用于从 pandas 对象中检索值。相比之下,赋值是 setter,用于更新值。

本章的内容从教你如何从 pd.Seriespd.DataFrame 对象中检索值开始,逐步增加复杂性。我们最终将介绍 pd.MultiIndex,它可以用于层次选择数据,最后我们会介绍赋值运算符。pandas API 在选择和赋值方面非常注重复用相同的方法,这使得你可以以非常表达的方式与数据交互。

到本章结束时,你将熟练掌握如何高效地从 pandas 对象中检索数据并更新其中的值。本章我们将涵盖以下内容:

  • 从 Series 中的基本选择

  • 从 DataFrame 中的基本选择

  • 从 Series 中的按位置选择

  • DataFrame 的位置选择

  • 基于标签的选择从 Series 中选择

  • 从 DataFrame 中基于标签的选择

  • 混合基于位置和基于标签的选择

  • DataFrame.filter

  • 按数据类型选择

  • 通过布尔数组进行选择/过滤

  • 使用多级索引选择 – 单级别

  • 使用多级索引选择 – 多级别

  • 使用多级索引选择 – DataFrame

  • 使用 .loc 和 .iloc 进行项赋值

  • DataFrame 列赋值

从 Series 中的基本选择

pd.Series 中选择涉及通过其位置或标签访问元素。这类似于通过索引访问列表中的元素或通过键访问字典中的元素。pd.Series 对象的多功能性使得数据检索直观且简单,是数据操作的基础工具。

pd.Series 被认为是 Python 中的 容器,就像内建的 listtupledict 对象。因此,对于简单的选择操作,用户首先使用的是 Python 索引运算符,即 [] 语法。

如何做到这一点

为了介绍选择的基础知识,我们从一个非常简单的 pd.Series 开始:

`ser = pd.Series(list("abc") * 3) ser` 
`0    a 1    b 2    c 3    a 4    b 5    c 6    a 7    b 8    c dtype: object` 

在 Python 中,你已经发现 [] 运算符可以用来从 容器 中选择元素;即,some_dictionary[0] 会返回与键为 0 相关联的值。对于 pd.Series,基本选择的行为类似:

`ser[3]` 
`a` 

使用表达式ser[3]时,pandas 会尝试在pd.Series的索引中找到标签 3,并且假设只有一个匹配项,它会返回与该标签相关联的值。

如果你不想从pd.Series中选择相关值,而是希望返回一个pd.Series,这样可以保持标签 3 与数据元素“a”关联。在 pandas 中,你可以通过提供包含单个元素的列表参数来实现这一点:

`ser[[3]]` 
`3    a dtype: object` 

在扩展列表参数的用法时,如果列表包含多个元素,你可以从pd.Series中选择多个值:

`ser[[0, 2]]` 
`0    a 2    c dtype: object` 

假设你使用默认的索引,你可以使用类似于 Python 列表切片的切片参数。例如,要获取一个pd.Series中位置 3 之前的元素(但不包括位置 3),可以使用:

`ser[:3]` 
`0    a 1    b 2    c dtype: object` 

负数切片索引对 pandas 来说不是问题。以下代码将选择pd.Series的最后四个元素:

`ser[-4:]` 
`5    c 6    a 7    b 8    c dtype: object` 

你甚至可以提供带有startstop参数的切片。以下代码将检索pd.Series中从位置 2 开始,并直到(但不包括)位置 6 的所有元素:

`ser[2:6]` 
`2    c 3    a 4    b 5    c dtype: object` 

这个关于切片的最终示例使用了startstopstep参数,从位置 1 开始,抓取每隔一个元素,直到遇到位置 8:

`ser[1:8:3]` 
`1    b 4    b 7    b dtype: object` 

当提供你自己的pd.Index值时,选择仍然有效。让我们创建一个带有字符串索引标签的小型pd.Series来说明:

`ser = pd.Series(range(3), index=["Jack", "Jill", "Jayne"]) ser` 
`Jack     0 Jill     1 Jayne    2 dtype: int64` 

通过ser["Jill"]选择时,将扫描索引查找字符串Jill并返回相应的元素:

`ser["Jill"]` 
`1` 

再次提供单元素列表参数将确保你收到一个pd.Series作为返回值,而不是单一值:

`ser[["Jill"]]` 
`Jill    1 dtype: int64` 

还有更多…

使用[]运算符时,一个常见的陷阱是错误地认为使用整数参数的选择方式与从 Python 列表中选择的方式相同。这在使用默认的pd.Index时才成立,默认的pd.Index是自动编号的,从 0 开始(这在技术上称为pd.RangeIndex)。

在不使用pd.RangeIndex时,必须特别注意行为。为了说明这一点,让我们从一个小型pd.Series开始,它仍然使用整数作为pd.Index,但没有使用从 0 开始的自动递增序列:

`ser = pd.Series(list("abc"), index=[2, 42, 21]) ser` 
`2     a 42    b 21    c dtype: object` 

需要注意的是,整数参数是按标签选择,而不是按位置选择;也就是说,以下代码将返回与标签 2 关联的值,而不是位置 2 的值:

`ser[2]` 
`a` 

尽管整数参数按标签匹配,而不是按位置匹配,但切片仍然按位置工作。以下示例在遇到数字 2 时不会停止,而是返回前两个元素:

`ser[:2]` 
`2     a 42    b dtype: object` 

用户还应该了解在处理非唯一的pd.Index时的选择行为。让我们创建一个小型pd.Series,其中数字 1 在行索引中出现两次:

`ser = pd.Series(["apple", "banana", "orange"], index=[0, 1, 1]) ser` 
`0     apple 1    banana 1    orange dtype: object` 

对于这个pd.Series,尝试选择数字 1 将不会返回单一值,而是返回另一个pd.Series

`ser[1]` 
`1    banana 1    orange dtype: object` 

由于使用默认的pd.RangeIndex时,像ser[1]这样的选择可以被认为是通过位置或标签进行互换的,但实际上,在使用其他pd.Index类型时是通过标签进行选择的,这可能是用户程序中细微 bug 的来源。许多用户可能认为他们在选择第n个元素,但当数据发生变化时,这个假设会被打破。为了消除通过整数参数选择标签位置的歧义,强烈建议利用本章稍后介绍的.loc.iloc方法。

从 DataFrame 中进行基本选择

使用[]运算符与pd.DataFrame时,简单的选择通常涉及从列索引中选择数据,而不是从行索引中选择数据。这个区别对于有效的数据操作和分析至关重要。pd.DataFrame中的列可以通过它们的标签进行访问,这使得处理来自更大pd.DataFrame结构中的pd.Series的命名数据变得容易。

理解这种选择行为的基本差异是充分利用 pandas 中pd.DataFrame功能的关键。通过利用[]运算符,你可以高效地访问和操作特定的列数据,为更高级的操作和分析奠定基础。

如何实现

让我们从创建一个简单的 3x3pd.DataFrame开始。pd.DataFrame的值并不重要,但我们故意提供自己的列标签,而不是让 pandas 为我们创建自动编号的列索引:

`df = pd.DataFrame(np.arange(9).reshape(3, -1), columns=["a", "b", "c"]) df` 
 `a     b     c 0    0     1     2 1    3     4     5 2    6     7     8` 

要选择单个列,使用带有标量参数的[]运算符:

`df["a"]` 
`0    0 1    3 2    6 Name: a, dtype: int64` 

要选择单个列,但仍然返回pd.DataFrame而不是pd.Series,请传递一个单元素列表:

`df[["a"]]` 
 `a 0    0 1    3 2    6` 

可以使用列表选择多个列:

`df[["a", "b"]]` 
 `a     b 0    0     1 1    3     4 2    6     7` 

在所有这些示例中,[]的参数是从列中选择的,但是提供切片参数会表现出不同的行为,实际上会从行中进行选择。请注意,以下示例选择了所有列和前两行数据,而不是反过来:

`df[:2]` 
 `a     b     c 0    0     1     2 1    3     4     5` 

还有更多…

当使用列表参数时,[]运算符使你可以灵活指定输出中列的顺序。这允许你根据需要定制pd.DataFrame。输出中列的顺序将完全匹配作为输入提供的标签顺序。例如:

`df[["a", "b"]]` 
 `a     b 0    0     1 1    3     4 2    6     7` 

将列表中的元素顺序交换作为[]运算符的参数时,将会交换结果pd.DataFrame中列的顺序:

`df[["b", "a"]]` 
 `b     a 0    1     0 1    4     3 2    7     6` 

这个功能在需要为展示目的重新排序列时特别有用,或者在准备导出到 CSV 或 Excel 格式时需要特定列顺序时(有关 pandas I/O 系统的更多信息,请参见第四章pandas I/O 系统)。

基于位置的系列选择

如同在DataFrame 的基本选择一节中讨论的那样,使用[]作为选择机制并没有明确表达意图,有时甚至可能让人感到困惑。ser[42]选择的是与数字 42 匹配的标签,而不是pd.Series的第 42 行,这是新用户常见的错误,而当你开始尝试使用[]操作符从pd.DataFrame中选择两个维度时,这种模糊性可能会变得更加复杂。

为了明确表明你是在按位置选择而不是按标签选择,你应该使用pd.Series.iloc

如何操作

让我们创建一个pd.Series,其索引使用的是整数标签,并且这些标签不唯一:

`ser = pd.Series(["apple", "banana", "orange"], index=[0, 1, 1]) ser` 
`0     apple 1    banana 1    orange dtype: object` 

要选择一个标量,你可以使用带有整数参数的pd.Series.iloc

`ser.iloc[1]` 
`banana` 

按照我们之前看到的模式,将整数参数转换为包含单一元素的列表将返回一个pd.Series,而不是标量:

`ser.iloc[[1]]` 
`1    banana dtype: object` 

在列表参数中使用多个整数将按位置选择pd.Series的多个元素:

`ser.iloc[[0, 2]]` 
`0     apple 1    orange dtype: object` 

切片是表达你想选择的元素范围的自然方式,并且它们与pd.Series.iloc的参数非常匹配:

`ser.iloc[:2]` 
`0     apple 1    banana dtype: object` 

基于位置的 DataFrame 选择

pd.Series类似,整数、整数列表和切片对象都是DataFrame.iloc的有效参数。然而,对于pd.DataFrame,需要两个参数。第一个参数负责选择,第二个参数负责选择

在大多数使用场景中,用户在获取行时会选择基于位置的选择,而在获取列时会选择基于标签的选择。我们将在基于标签的 DataFrame 选择一节中讲解后者,并且在混合位置选择和标签选择一节中展示如何将两者结合使用。然而,当你的行索引使用默认的pd.RangeIndex并且列的顺序很重要时,本节中展示的技巧将非常有价值。

如何操作

让我们创建一个包含五行四列的pd.DataFrame

`df = pd.DataFrame(np.arange(20).reshape(5, -1), columns=list("abcd")) df` 
 `a     b     c     d 0    0     1     2     3 1    4     5     6     7 2    8     9     10    11 3    12    13    14    15 4    16    17    18    19` 

将两个整数参数传递给pd.DataFrame.iloc将返回该行和列位置的标量:

`df.iloc[2, 2]` 
`10` 

在某些情况下,你可能不想选择特定轴上的单个值,而是希望选择该轴上的所有内容。一个空切片对象:可以让你做到这一点;例如,如果你想选择pd.DataFrame第一列的所有数据行,你可以使用:

`df.iloc[:, 0]` 
`0     0 1     4 2     8 3    12 4    16 Name: a, dtype: int64` 

翻转pd.DataFrame.iloc的参数顺序会改变行为。下面的代码不是选择第一列的所有行,而是选择所有列并且只选择第一行的数据:

`df.iloc[0, :]` 
`a    0 b    1 c    2 d    3 Name: 0, dtype: int64` 

因为前述示例仅返回数据的一个维度,它们隐含地尝试将pd.DataFrame的返回值“挤压”为pd.Series。根据本章中我们已经多次看到的模式,您可以通过为轴传递单元素列表参数来防止隐式降维,而不是空切片。例如,要选择第一列的所有行但仍然返回pd.DataFrame,您可以选择:

`df.iloc[:, [0]]` 
 `a 0    0 1    4 2    8 3    12 4    16` 

反转这些参数会给我们返回一个pd.DataFrame中的第一行和所有列:

`df.iloc[[0], :]` 
 `a    b    c    d 0    0    1    2    3` 

列表可以用来从行和列中选择多个元素。如果我们想要pd.DataFrame的第一行和第二行与最后一列和倒数第二列配对,您可以选择一个表达式如下:

`df.iloc[[0, 1], [-1, -2]]` 
 `d    c 0    3    2 1    7    6` 

还有更多……

空切片是.iloc的有效参数。ser.iloc[:]df.iloc[:, :]都将返回每个轴上的所有内容,从本质上来说,给您一个对象的副本。

从 Series 中基于标签的选择

在 pandas 中,pd.Series.loc用于根据标签而不是位置进行选择。当您考虑您的pd.Seriespd.Index包含查找值时,此方法特别有用,这些值类似于 Python 字典中的键,而不是给出数据在pd.Series中的顺序或位置的重要性。

如何做到这一点

让我们创建一个pd.Series,其中我们使用整数标签作为行索引,这些标签也是非唯一的:

`ser = pd.Series(["apple", "banana", "orange"], index=[0, 1, 1]) ser` 
`0     apple 1    banana 1    orange dtype: object` 

pd.Series.loc将选择所有具有标签 1 的行:

`ser.loc[1]` 
`1    banana 1    orange dtype: object` 

当然,在 pandas 中,您并不局限于整数标签。让我们看看使用由字符串值组成的pd.Index的情况:

`ser = pd.Series([2, 2, 4], index=["dog", "cat", "human"], name="num_legs") ser` 
`dog      2 cat      2 human    4 Name: num_legs, dtype: int64` 

pd.Series.loc可以选择所有具有"dog"标签的行:

`ser.loc["dog"]` 
`2` 

要选择所有具有"dog""cat"标签的行:

`ser.loc[["dog", "cat"]]` 
`dog    2 cat    2 Name: num_legs, dtype: int64` 

最后,要选择直到包括标签"cat"的所有行:

`ser.loc[:"cat"]` 
`dog    2 cat    2 Name: num_legs, dtype: int64` 

还有更多……

使用pd.Series.loc进行基于标签的选择提供了强大的功能,用于访问和操作pd.Series中的数据。虽然这种方法可能看起来很简单,但它提供了重要的细微差别和行为,对于有效的数据处理是很重要的。

对于所有经验水平的 pandas 用户来说,一个非常常见的错误是忽视pd.Series.loc切片行为与标准 Python 和pd.Series.iloc情况下切片行为之间的差异。

要逐步进行这一点,让我们创建一个小 Python 列表和一个具有相同数据的pd.Series

`values = ["Jack", "Jill", "Jayne"] ser = pd.Series(values) ser` 
`0     Jack 1     Jill 2    Jayne dtype: object` 

正如您已经看到的那样,与 Python 语言内置的列表和其他容器一样,切片返回值直到提供的位置,但不包括:

`values[:2]` 
`Jack    Jill` 

使用pd.Series.iloc进行切片与此行为相匹配,返回一个与 Python 列表具有相同长度和元素的pd.Series

`ser.iloc[:2]` 
`0    Jack 1    Jill dtype: object` 

但是,使用pd.Series.loc进行切片实际上产生了不同的结果:

`ser.loc[:2]` 
`0     Jack 1     Jill 2    Jayne dtype: object` 

这里发生了什么?为了更好理解这一点,需要记住 pd.Series.loc 是通过标签进行匹配,而非位置。pandas 库会对每个 pd.Series 和其对应的 pd.Index 元素进行类似循环的操作,直到它在索引中找到值为 2 的元素。然而,pandas 无法保证 pd.Index 中只有一个值为 2 的元素,因此它必须继续搜索直到找到其他的东西。如果你尝试对一个重复索引标签为 2 的 pd.Series 进行相同的选择,你将看到这一点的实际表现:

`repeats_2 = pd.Series(range(5), index=[0, 1, 2, 2, 0]) repeats_2.loc[:2]` 
`0    0 1    1 2    2 2    3 dtype: int64` 

如果你预期行索引包含整数,可能会觉得这有些狡猾,但pd.Series.loc的主要用例是用于处理 pd.Index,其中位置/顺序不重要(对于此情况,可以使用 pd.Series.iloc)。以字符串标签作为一个更实际的例子,pd.Series.loc 的切片行为变得更加自然。以下代码可以基本理解为在请求 pandas 遍历 pd.Series,直到行索引中找到标签 "xxx",并继续直到找到另一个标签:

`ser = pd.Series(range(4), index=["zzz", "xxx", "xxx", "yyy"]) ser.loc[:"xxx"]` 
`zzz    0 xxx    1 xxx    2 dtype: int64` 

在某些情况下,当你尝试使用 pd.Series.loc 切片,但索引标签没有确定的顺序时,pandas 将会抛出错误:

`ser = pd.Series(range(4), index=["zzz", "xxx", "yyy", "xxx"]) ser.loc[:"xxx"]` 
`KeyError: "Cannot get right slice bound for non-unique label: 'xxx'"` 

基于标签的 DataFrame 选择

正如我们在基于位置的 DataFrame 选择部分讨论过的,pd.DataFrame 最常见的用例是,在引用列时使用基于标签的选择,而在引用行时使用基于位置的选择。然而,这并不是绝对要求,pandas 允许你从行和列中使用基于标签的选择。

与其他数据分析工具相比,从 pd.DataFrame 的行中通过标签进行选择是 pandas 独有的优势。对于熟悉 SQL 的用户,SQL 并没有提供真正相当的功能;在 SELECT 子句中选择列非常容易,但只能通过 WHERE 子句过滤行。对于擅长使用 Microsoft Excel 的用户,你可以通过数据透视表创建具有行标签和列标签的二维结构,但你在该透视表内选择或引用数据的能力是有限的。

目前,我们将介绍如何为非常小的 pd.DataFrame 对象进行选择,以便熟悉语法。在第八章数据框重塑中,我们将探索如何创建有意义的 pd.DataFrame 对象,其中行和列标签是重要的。结合本节介绍的知识,你将会意识到这种选择方式是 pandas 独有的,并且它如何帮助你以其他工具无法表达的方式探索数据。

如何进行选择

让我们创建一个 pd.DataFrame,其行列索引均由字符串组成:

`df = pd.DataFrame([     [24, 180, "blue"],     [42, 166, "brown"],     [22, 160, "green"], ], columns=["age", "height_cm", "eye_color"], index=["Jack", "Jill", "Jayne"]) df` 
 `age    height_cm    eye_color Jack    24     180          blue Jill    42     166          brown Jayne   22     160          green` 

pd.DataFrame.loc 可以通过行和列标签进行选择:

`df.loc["Jayne", "eye_color"]` 
`green` 

要选择来自标签为 "age" 列的所有行:

`df.loc[:, "age"]` 
`Jack     24 Jill     42 Jayne    22 Name: age, dtype: int64` 

要从标签为 "Jack" 的行中选择所有列:

`df.loc["Jack", :]` 
`age            24 height_cm     180 eye_color    blue Name: Jack, dtype: object` 

要从标签为 "age" 的列中选择所有行,并保持 pd.DataFrame 的形状:

`df.loc[:, ["age"]]` 
 `age Jack     24 Jill     42 Jayne    22` 

要从标签为 "Jack" 的行中选择所有列,并保持 pd.DataFrame 的形状:

`df.loc[["Jack"], :]` 
 `age   height_cm    eye_color Jack    24    180          blue` 

使用标签列表选择行和列:

`df.loc[["Jack", "Jill"], ["age", "eye_color"]]` 
 `age   eye_color Jack    24    blue Jill    42    brown` 

混合基于位置和标签的选择

由于 pd.DataFrame.iloc 用于基于位置的选择,而 pd.DataFrame.loc 用于基于标签的选择,当用户尝试在一个维度上按标签选择,另一个维度上按位置选择时,必须额外采取一步措施。如前所述,大多数构造的 pd.DataFrame 对象会非常重视用于列的标签,而对这些列的顺序关注较少。行的情况正好相反,因此能够有效地混合和匹配这两种风格是非常有价值的。

如何操作

让我们从一个 pd.DataFrame 开始,该数据框的行使用默认的自动编号 pd.RangeIndex,但列使用自定义的字符串标签:

`df = pd.DataFrame([     [24, 180, "blue"],     [42, 166, "brown"],     [22, 160, "green"], ], columns=["age", "height_cm", "eye_color"]) df` 
 `age   height_cm    eye_color 0    24    180          blue 1    42    166          brown 2    22    160          green` 

pd.Index.get_indexer 方法可以帮助我们将标签或标签列表转换为它们在 pd.Index 中对应的位置:

`col_idxer = df.columns.get_indexer(["age", "eye_color"]) col_idxer` 
`array([0, 2])` 

这随后可以作为参数传递给 .iloc,确保你在行和列上都使用基于位置的选择:

`df.iloc[[0, 1], col_idxer]` 
 `age    eye_color 0    24     blue 1    42     brown` 

还有更多……

你可以不使用 pd.Index.get_indexer,将这个表达式拆分成几个步骤,其中一个步骤进行基于索引的选择,另一个步骤进行基于标签的选择。如果你这样做,你最终会得到与上面相同的结果:

`df[["age", "eye_color"]].iloc[[0, 1]]` 
 `age    eye_color 0    24     blue 1    42     brown` 

有强有力的理由认为,这比使用 pd.Index.get_indexer 更具表达力,所有 pandas 用户的开发者都会同意这一点。那么,为什么还要使用 pd.Index.get_indexer 呢?

尽管这些在表面上看起来一样,但 pandas 计算结果的方式却有很大不同。为各种方法添加一些计时基准应该能突出这一点。尽管准确的数字会因你的机器而异,但可以比较本节中描述的惯用方法的计时输出:

`import timeit def get_indexer_approach():   col_idxer = df.columns.get_indexer(["age", "eye_color"])   df.iloc[[0, 1], col_idxer] timeit.timeit(get_indexer_approach, number=10_000)` 
`1.8184850879988517` 

使用分步方法先按标签选择,再按位置选择:

`two_step_approach = lambda: df[["age", "eye_color"]].iloc[[0, 1]] timeit.timeit(two_step_approach, number=10_000` 
`2.027099569000711` 

pd.Index.get_indexer 方法速度更快,并且应该能更好地扩展到更大的数据集。之所以如此,是因为 pandas 采用的是 贪婪 计算方式,或者更具体地说,它会按你说的来做。表达式 df[["age", "eye_color"]].iloc[[0, 1]] 首先运行 df[["age", "eye_color"]],这会创建一个中间的 pd.DataFrame,然后 .iloc[[0, 1]] 被应用于这个数据框。相比之下,表达式 df.iloc[[0, 1], col_idxer] 一次性执行了标签和位置的选择,避免了创建任何中间的 pd.DataFrame

与 pandas 采取的急切执行方法相对的方式通常被称为延迟执行。如果你以前使用过 SQL,后者就是一个很好的例子;你通常不会精确指示 SQL 引擎应该采取什么步骤来产生期望的结果。相反,你声明你希望结果是什么样的,然后将优化和执行查询的任务交给 SQL 数据库。

pandas 是否会支持延迟评估和优化?我认为会,因其有助于 pandas 扩展到更大的数据集,并减轻最终用户编写优化查询的负担。然而,这种功能目前还不存在,因此作为库的用户,你仍然需要了解你编写的代码是否会高效或低效地处理。

在决定是否值得尝试将基于位置/标签的选择合并为一步操作时,也值得考虑你数据分析的上下文,或者它们是否可以作为单独的步骤。在我们这个简单的示例中,df.iloc[[0, 1], col_idxer]df[["age", "eye_color"]].iloc[[0, 1]]之间的运行时差异在整体上可能不值得关注,但如果你处理的是更大的数据集,并且受到性能瓶颈的限制,前一种方法可能是救命稻草。

DataFrame.filter

pd.DataFrame.filter是一个专门的方法,允许你从pd.DataFrame的行或列中进行选择。

如何操作

让我们创建一个pd.DataFrame,其中行和列的索引都是由字符串组成的:

`df = pd.DataFrame([     [24, 180, "blue"],     [42, 166, "brown"],     [22, 160, "green"], ], columns=[     "age",     "height_cm",     "eye_color" ], index=["Jack", "Jill", "Jayne"]) df` 
 `age   height_cm   eye_color Jack    24    180         blue Jill    42    166         brown Jayne   22    160         green` 

默认情况下,pd.DataFrame.filter会选择与标签参数匹配的列,类似于pd.DataFrame[]

`df.filter(["age", "eye_color"])` 
 `age   eye_color Jack   24    blue Jill   42    brown Jayne  22    green` 

然而,pd.DataFrame.filter也接受一个axis=参数,它允许你改变所选择的轴。若要选择行而不是列,传递axis=0

`df.filter(["Jack", "Jill"], axis=0)` 
 `age   height_cm   eye_color Jack   24    180         blue Jill   42    166         brown` 

你不局限于与标签进行精确字符串匹配。如果你想选择包含某个字符串的标签,可以使用like=参数。此示例将选择任何包含下划线的列:

`df.filter(like="_")` 
 `height_cm   eye_color Jack   180         blue Jill   166         brown Jayne  160         green` 

如果简单的字符串包含检查不够,你也可以使用正则表达式通过regex=参数匹配索引标签。以下示例将选择任何以"Ja"开头,但不以"e"结尾的行标签:

`df.filter(regex=r"^Ja.*(?<!e)$", axis=0)` 
 `age   height_cm   eye_color Jack   24    180         blue` 

按数据类型选择

到目前为止,在这本食谱中,我们已经看过数据类型,但我们并没有深入讨论它们到底是什么。我们还没有完全深入探讨;pandas 的类型系统将在第三章数据类型中进行深入讨论。不过,目前你应该意识到,列类型提供了元数据,pd.DataFrame.select_dtypes可以用它来进行选择。

如何操作

让我们从一个包含整数、浮点数和字符串列的pd.DataFrame开始:

`df = pd.DataFrame([     [0, 1.0, "2"],     [4, 8.0, "16"], ], columns=["int_col", "float_col", "string_col"]) df` 
 `int_col   float_col   string_col 0   0         1.0         2 1   4         8.0         16` 

使用pd.DataFrame.select_dtypes仅选择整数列:

`df.select_dtypes("int")` 
 `int_col 0   0 1   4` 

如果你传递一个列表参数,多个类型可以被选择:

`df.select_dtypes(include=["int", "float"])` 
 `int_col   float_col 0   0         1.0 1   4         8.0` 

默认行为是包括你作为参数传递的数据类型。要排除它们,请使用 exclude= 参数:

`df.select_dtypes(exclude=["int", "float"])` 
 `string_col 0   2 1   16` 

通过布尔数组进行选择/过滤

使用布尔列表/数组(也称为 遮罩)是选择子集行的常见方法。

如何做到这一点

让我们创建一个 True=/=False 值的遮罩,并与一个简单的 pd.Series 配对:

`mask = [True, False, True] ser = pd.Series(range(3)) ser` 
`0    0 1    1 2    2 dtype: int64` 

将遮罩作为参数传递给 pd.Series[] 将返回每一行,其中相应的遮罩条目为 True

`ser[mask]` 
`0    0 2    2 dtype: int64` 

pd.Series.loc 在这种情况下将与 pd.Series[] 的行为完全一致:

`ser.loc[mask]` 
`0    0 2    2 dtype: int64` 

有趣的是,尽管 pd.DataFrame[] 通常在提供列表参数时尝试从列中选择,但在使用布尔值序列时,它的行为有所不同。使用我们已经创建的遮罩,df[mask] 实际上会沿行匹配,而不是列:

`df = pd.DataFrame(np.arange(6).reshape(3, -1)) df[mask]` 
 `0   1 0   0   1 2   4   5` 

如果你需要同时遮蔽行和列,pd.DataFrame.loc 将接受两个遮罩参数:

`col_mask = [True, False] df.loc[mask, col_mask]` 
 `0 0   0 2   4` 

还有更多内容……

通常,你会使用 OR、AND 或 INVERT 运算符的组合来操作你的遮罩。为了演示这些操作,我们从一个稍微复杂的 pd.DataFrame 开始:

`df = pd.DataFrame([     [24, 180, "blue"],     [42, 166, "brown"],     [22, 160, "green"], ], columns=["age", "height_cm", "eye_color"], index=["Jack", "Jill", "Jayne"]) df` 
 `age   height_cm   eye_color Jack   24    180         blue Jill   42    166         brown Jayne  22    160         green` 

如果我们的目标是只筛选出有蓝眼睛或绿眼睛的用户,我们可以先识别哪些用户有蓝眼睛:

`blue_eyes = df["eye_color"] == "blue" blue_eyes` 
`Jack      True Jill     False Jayne    False Name: eye_color, dtype: bool` 

然后,我们找出哪些人有绿色眼睛:

`green_eyes = df["eye_color"] == "green" green_eyes` 
`Jack     False Jill     False Jayne     True Name: eye_color, dtype: bool` 

并将这些结合在一起,使用 OR 运算符 | 形成一个布尔 遮罩

`mask = blue_eyes | green_eyes mask` 
`Jack      True Jill     False Jayne     True Name: eye_color, dtype: bool` 

在将该遮罩作为索引器传递给我们的 pd.DataFrame 之前:

`df[mask]` 
 `age   height_cm   eye_color Jack   24    180         blue Jayne  22    160         green` 

与使用 OR 运算符 | 不同,你通常会使用 AND 运算符 &。例如,让我们为年龄小于 40 的记录创建一个筛选器:

`age_lt_40 = df["age"] < 40 age_lt_40` 
`Jack      True Jill     False Jayne     True Name: age, dtype: bool` 

还要高度大于 170:

`height_gt_170 = df["height_cm"] > 170 height_gt_170` 
`Jack      True Jill     False Jayne    False Name: height_cm, dtype: bool` 

这些可以通过 AND 运算符组合在一起,只选择满足两个条件的记录:

`df[age_lt_40 & height_gt_170]` 
 `age   height_cm   eye_color Jack   24    180         blue` 

INVERT 运算符可以视为 NOT 运算符;也就是说,在遮罩的上下文中,它将使任何 True 值变为 False,任何 False 值变为 True。继续我们上面的例子,如果我们想找到那些没有满足年龄低于 40 且身高超过 170 的条件的记录,我们只需使用 ~ 反转遮罩:

`df[~(age_lt_40 & height_gt_170)]` 
 `age   height_cm   eye_color Jill   42    166         brown Jayne  22    160         green` 

使用 MultiIndex 进行选择 – 单一层级

pd.MultiIndexpd.Index 的一个子类,支持层级标签。根据询问的人不同,这可能是 pandas 最好的特性之一,或者是最差的特性之一。看完这本手册后,我希望你把它视为最好的特性之一。

pd.MultiIndex 的大部分贬低来自于这样一个事实:使用它选择时的语法很容易变得模糊,尤其是在使用 pd.DataFrame[] 时。以下示例仅使用 pd.DataFrame.loc 方法,避免使用 pd.DataFrame[] 以减少混淆。

如何做到这一点

pd.MultiIndex.from_tuples 可以用来从元组列表构建 pd.MultiIndex。在以下示例中,我们创建一个具有两个层级的 pd.MultiIndexfirst_namelast_name,依次排列。我们将其与一个非常简单的 pd.Series 配对:

`index = pd.MultiIndex.from_tuples([     ("John", "Smith"),     ("John", "Doe"),     ("Jane", "Doe"),     ("Stephen", "Smith"), ], names=["first_name", "last_name"]) ser = pd.Series(range(4), index=index) ser` 
`first_name  last_name John        Smith        0             Doe          1 Jane        Doe          2 Stephen     Smith        3 dtype: int64` 

使用pd.Series.locpd.MultiIndex以及标量参数将匹配pd.MultiIndex的第一个级别。输出将不包含这个第一个级别的结果:

`ser.loc["John"]` 
`last_name Smith    0 Doe      1 dtype: int64` 

上面示例中删除pd.MultiIndex第一个级别的行为也被称为部分切片。这个概念类似于我们在前几节看到的.loc.iloc中的维度压缩,唯一的不同是,pandas 在这里试图减少pd.MultiIndex中的级别数量,而不是减少维度

为了防止发生这种隐式的级别减少,我们可以再次提供一个包含单一元素的列表参数:

`ser.loc[["John"]]` 
`first_name  last_name John        Smith        0             Doe          1 dtype: int64` 

使用 MultiIndex 选择 – 多个级别

如果你只能从pd.MultiIndex的第一个级别进行选择,那么事情就不那么有趣了。幸运的是,pd.DataFrame.loc通过巧妙地使用元组参数可以扩展到不仅仅是第一个级别。

如何执行

让我们重新创建前面一节中的pd.Series

`index = pd.MultiIndex.from_tuples([     ("John", "Smith"),     ("John", "Doe"),     ("Jane", "Doe"),     ("Stephen", "Smith"), ], names=["first_name", "last_name"]) ser = pd.Series(range(4), index=index) ser` 
`first_name  last_name John        Smith        0             Doe          1 Jane        Doe          2 Stephen     Smith        3 dtype: int64` 

要选择所有第一个索引级别使用标签"Jane"且第二个索引级别使用标签"Doe"的记录,请传递以下元组:

`ser.loc[("Jane", "Doe")]` 
`2` 

要选择所有第一个索引级别使用标签"Jane"且第二个索引级别使用标签"Doe"的记录,同时保持pd.MultiIndex的形状,请将一个单一元素的列表放入元组中:

`ser.loc[(["Jane"], "Doe")]` 
`first_name  last_name Jane        Doe          2 dtype: int64` 

要选择所有第一个索引级别使用标签"John"且第二个索引级别使用标签"Smith",或者第一个索引级别是"Jane"且第二个索引级别是"Doe"的记录:

`ser.loc[[("John", "Smith"), ("Jane", "Doe")]]` 
`first_name  last_name John        Smith        0 Jane        Doe          2 dtype: int64` 

要选择所有第二个索引级别为"Doe"的记录,请将一个空切片作为元组的第一个元素。注意,这会删除第二个索引级别,并从剩下的第一个索引级别重建结果,形成一个简单的pd.Index

`ser.loc[(slice(None), "Doe")]` 
`first_name John    1 Jane    2 dtype: int64` 

要选择所有第二个索引级别为"Doe"的记录,同时保持pd.MultiIndex的形状,请将一个单元素列表作为第二个元组元素:

`ser.loc[(slice(None), ["Doe"])]` 
`first_name  last_name John        Doe          1 Jane        Doe          2 dtype: int64` 

在这一点上,你可能会问,slice(None)到底是什么意思?这个相当隐晦的表达式实际上创建了一个没有起始停止步长值的切片对象,这在用更简单的 Python 列表来说明时会更容易理解——注意,这里的行为:

`alist = list("abc") alist[:]` 
`['a', 'b', 'c']` 

结果与使用slice(None)时完全相同:

`alist[slice(None)]` 
`['a', 'b', 'c']` 

pd.MultiIndex期待一个元组参数却没有得到时,这个问题通常是由元组中的切片引起的,类似于 Python 中(:,)的语法错误。更明确的写法(slice(None),)可以修复这个问题。

还有更多…

如果你觉得slice(None)语法太繁琐,pandas 提供了一个方便的对象叫做pd.IndexSlice,它像元组一样工作,但允许你使用更自然的:符号进行切片。

`ser.loc[(slice(None), ["Doe"])]` 
`first_name  last_name John        Doe          1 Jane        Doe          2 dtype: int64` 

这样可以变成:

`ixsl = pd.IndexSlice ser.loc[ixsl[:, ["Doe"]]]` 
`first_name  last_name John        Doe          1 Jane        Doe          2 dtype: int64` 

使用 MultiIndex 选择 – 一个 DataFrame

pd.MultiIndex可以同时作为行索引和列索引使用,并且通过pd.DataFrame.loc的选择方式在两者中都可以工作。

如何执行

让我们创建一个既在行也在列使用pd.MultiIndexpd.DataFrame

`row_index = pd.MultiIndex.from_tuples([     ("John", "Smith"),     ("John", "Doe"),     ("Jane", "Doe"),     ("Stephen", "Smith"), ], names=["first_name", "last_name"]) col_index = pd.MultiIndex.from_tuples([     ("music", "favorite"),     ("music", "last_seen_live"),     ("art", "favorite"), ], names=["art_type", "category"]) df = pd.DataFrame([    ["Swift", "Swift", "Matisse"],    ["Mozart", "T. Swift", "Van Gogh"],    ["Beatles", "Wonder", "Warhol"],    ["Jackson", "Dylan", "Picasso"], ], index=row_index, columns=col_index) df` 
 `art_type              music           art              category   favorite   last_seen_live  favorite first_name   last_name John         Smith      Swift      Swift           Matisse              Doe        Mozart     T. Swift        Van Gogh Jane         Doe        Beatles    Wonder          Warhol Stephen      Smith      Jackson    Dylan           Picasso` 

要选择所有第二级为 "Smith" 的行以及所有第二级为 "favorite" 的列,你需要传递两个元组,其中每个元组的第二个元素是所需的标签:

`row_idxer = (slice(None), "Smith") col_idxer = (slice(None), "favorite") df.loc[row_idxer, col_idxer]` 
 `art_type   music      art              category   favorite   favorite first_name   last_name John         Smith      Swift      Matisse Stephen      Smith      Jackson    Picasso` 

pd.DataFrame.loc 总是需要两个参数——第一个指定如何对行进行索引,第二个指定如何对列进行索引。当你有一个 pd.DataFrame,其行列都使用 pd.MultiIndex 时,你可能会发现,将索引器分开定义为变量在风格上更为清晰。上面的代码也可以写成:

`df.loc[(slice(None), "Smith"), (slice(None), "favorite")]` 
 `art_type   music      art              category   favorite   favorite first_name   last_name John         Smith      Swift      Matisse Stephen      Smith      Jackson    Picasso` 

尽管你可以说这更难以解读。正如古老的说法所说,美在于观者的眼中。

使用 .loc 和 .iloc 进行项目赋值

pandas 库针对读取、探索和评估数据进行了优化。试图 修改 或改变数据的操作要低效得多。

然而,当你必须修改数据时,可以使用 .loc.iloc 来实现。

如何做

让我们从一个非常小的 pd.Series 开始:

`ser = pd.Series(range(3), index=list("abc"))` 

pd.Series.loc 在你想通过匹配索引的标签来赋值时非常有用。例如,如果我们想在行索引包含 "b" 的位置存储值 42,我们可以写成:

`ser.loc["b"] = 42 ser` 
`a     0 b    42 c     2 dtype: int64` 

pd.Series.iloc 用于在你想按位置赋值时。例如,为了将值 -42 赋给我们 pd.Series 中的第二个元素,我们可以写成:

`ser.iloc[2] = -42 ser` 
`a     0 b    42 c   -42 dtype: int64` 

还有更多内容…

通过 pandas 修改数据的成本在很大程度上取决于两个因素:

  • pandas pd.Series 支持的数组类型(第三章数据类型,将在后续章节中详细讲解数据类型)

  • 有多少个对象引用了 pd.Series

对这些因素的深入探讨远超本书的范围。对于上面的第一个要点,我的普遍建议是,数组类型越简单,你就越有可能在不复制数组内容的情况下修改它,对于大型数据集来说,复制可能会非常昂贵。

对于第二个要点,在 pandas 2.x 系列中涉及了大量的 写时复制 (CoW) 工作。CoW 是 pandas 3.0 中的默认行为,它试图使得在修改数据时,哪些内容被复制,哪些内容没有被复制变得更加可预测。对于高级用户,我强烈建议阅读 pandas CoW 文档。

DataFrame 列赋值

在 pandas 中,对 数据 的赋值操作可能相对昂贵,但为 pd.DataFrame 分配列是常见操作。

如何做

让我们创建一个非常简单的 pd.DataFrame

`df = pd.DataFrame({"col1": [1, 2, 3]}) df` 
 `col1 0   1 1   2 2   3` 

新列可以使用 pd.DataFrame[] 操作符进行赋值。最简单的赋值类型可以是一个标量值,并将其 广播pd.DataFrame 的每一行:

`df["new_column1"] = 42 df` 
 `col1   new_column1 0   1      42 1   2      42 2   3      42` 

你还可以赋值一个 pd.Series 或序列,只要元素的数量与 pd.DataFrame 中的行数匹配:

`df["new_column2"] = list("abc") df` 
 `col1   new_column1   new_column2 0   1      42            a 1   2      42            b 2   3      42            c` 
`df["new_column3"] = pd.Series(["dog", "cat", "human"]) df` 
 `col1   new_column1   new_column2   new_column3 0   1      42            a             dog 1   2      42            b             cat 2   3      42            c             human` 

如果新序列的行数与现有 pd.DataFrame 的行数不匹配,赋值将失败:

`df["should_fail"] = ["too few", "rows"]` 
`ValueError: Length of values (2) does not match length of index (3)` 

对于具有pd.MultiIndex列的pd.DataFrame,也可以进行赋值操作。让我们来看一个这样的pd.DataFrame

`row_index = pd.MultiIndex.from_tuples([     ("John", "Smith"),     ("John", "Doe"),     ("Jane", "Doe"),     ("Stephen", "Smith"), ], names=["first_name", "last_name"]) col_index = pd.MultiIndex.from_tuples([     ("music", "favorite"),     ("music", "last_seen_live"),     ("art", "favorite"), ], names=["art_type", "category"]) df = pd.DataFrame([    ["Swift", "Swift", "Matisse"],    ["Mozart", "T. Swift", "Van Gogh"],    ["Beatles", "Wonder", "Warhol"],    ["Jackson", "Dylan", "Picasso"], ], index=row_index, columns=col_index) df` 
 `art_type   music            art              category   favorite   last_seen_live   favorite first_name   last_name John         Smith      Swift      Swift            Matisse              Doe        Mozart     T. Swift         Van Gogh Jane         Doe        Beatles    Wonder           Warhol Stephen      Smith      Jackson    Dylan            Picasso` 

要在"art"层次下为看到的博物馆数量赋值,可以将元组作为参数传递给pd.DataFrame.loc

`df.loc[:, ("art", "museuems_seen")] = [1, 2, 4, 8] df` 
 `art_type    music            art              category    favorite   last_seen_live   favorite   museuems_seen first_name   last_name John         Smith       Swift       Swift           Matisse    1              Doe         Mozart      T. Swift        Van Gogh   2 Jane         Doe         Beatles     Wonder          Warhol     4 Stephen      Smith       Jackson     Dylan           Picasso    8` 

使用pd.DataFrame进行赋值时遵循与使用pd.DataFrame[]pd.DataFrame.loc[]选择值时相同的模式。主要的区别在于,在选择时,你会在表达式的右侧使用pd.DataFrame[]pd.DataFrame.loc[],而在赋值时,它们出现在左侧。

还有更多…

pd.DataFrame.assign方法可用于在赋值时允许方法链。我们从一个简单的pd.DataFrame开始,来展示这种方法的用法:

`df = pd.DataFrame([[0, 1], [2, 4]], columns=list("ab")) df` 
 `a   b 0   0   1 1   2   4` 

方法链指的是 pandas 能够将多个算法连续应用于 pandas 数据结构的能力(算法及其应用方式将在第五章算法及其应用中详细讨论)。所以,要将我们的pd.DataFrame进行加倍并为每个元素加上 42,我们可以做类似如下的操作:

`(     df     .mul(2)     .add(42) )` 
 `a    b 0   42   44 1   46   50` 

但是,如果我们想在这个链条中添加一个新列会发生什么呢?不幸的是,使用标准赋值运算符时,你需要打破这个链条,通常需要为新变量赋值:

`df2 = (     df     .mul(2)     .add(42) ) df2["assigned_c"] = df2["b"] - 3 df2` 
 `a   b   assigned_c 0   42  44  41 1   46  50  47` 

但是通过pd.DataFrame.assign,你可以继续链式操作。只需将所需的列标签作为关键字传递给pd.DataFrame.assign,其参数是你希望在新的pd.DataFrame中看到的值:

`(     df     .mul(2)     .add(42)     .assign(chained_c=lambda df: df["b"] - 3) )` 
 `a    b    chained_c 0   42   44   41 1   46   50   47` 

在这种情况下,你只能使用符合 Python 语法要求的标签作为参数名,而不幸的是,这在pd.MultiIndex中无法使用。有些用户认为方法链使调试变得更困难,而另一些人则认为像这样的方法链使代码更容易阅读。归根结底,没有对错之分,我现在能给出的最佳建议是使用你最舒服的形式。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

第三章:数据类型

pd.Series的数据类型允许你指定可以或不可以存储的元素类型。数据类型对于确保数据质量以及在代码中启用高性能算法至关重要。如果你有数据库工作背景,你很可能已经熟悉数据类型及其好处;你将在 pandas 中找到像TEXTINTEGERDOUBLE PRECISION这样的类型,就像在数据库中一样,尽管它们的名称不同。

然而,与数据库不同,pandas 提供了多种实现方式,来处理TEXTINTEGERDOUBLE PRECISION类型。不幸的是,这意味着作为最终用户,你至少应该了解不同数据类型的实现方式,以便为你的应用选择最佳的选项。

关于 pandas 中类型的简短历史可以帮助解释这一可用性上的怪癖。最初,pandas 是建立在 NumPy 类型系统之上的。这种方法在一段时间内是有效的,但存在重大缺陷。首先,pandas 构建的 NumPy 类型不支持缺失值,因此 pandas 创造了一种“弗兰肯斯坦的怪物”方法来支持这些值。由于 NumPy 专注于数值计算,它也没有提供一流的字符串数据类型,导致 pandas 中的字符串处理非常差。

从 pandas 版本 0.23 开始,pandas 努力超越了 NumPy 类型系统,该版本引入了直接内置在 pandas 中的新数据类型,这些类型虽然仍然使用 NumPy 实现,但实际上能够处理缺失值。在版本 1.0 中,pandas 实现了自己的字符串数据类型。当时,这些类型被称为numpy_nullable数据类型,但随着时间的推移,它们被称为 pandas 扩展类型。

在这一切发生的同时,pandas 的原始创建者 Wes McKinney 正致力于 Apache Arrow 项目。完全解释 Arrow 项目超出了本书的范围,但它帮助的一个主要方面是定义一组可以在不同工具和编程语言之间使用的标准化数据类型。这些数据类型也受到数据库的启发;如果使用数据库已经是你分析旅程的一部分,那么 Arrow 类型对你来说可能非常熟悉。从版本 2.0 开始,pandas 允许你使用 Arrow 作为数据类型。

尽管支持 pandas 扩展类型和 Arrow 数据类型,但 pandas 的默认类型从未改变,在大多数情况下仍然使用 NumPy。作者认为这是非常遗憾的;本章将介绍一种较为主观的观点,如何最好地管理类型的领域,通常的指导原则如下:

  • 在可用时,使用 pandas 扩展类型

  • 当 pandas 扩展类型不足时,使用 Arrow 数据类型

  • 使用基于 NumPy 的数据类型

这条指南可能会引发争议,并且在极端情况下可能会受到质疑,但对于刚接触 pandas 的人来说,我认为这种优先级设定为用户提供了最佳的可用性与性能平衡,无需深入了解 pandas 背后如何工作。

本章的总体结构将首先介绍 pandas 扩展系统的常规使用方法,然后再深入探讨 Arrow 类型系统以应对更复杂的使用案例。在我们介绍这些类型时,还将突出展示可以通过访问器解锁的特殊行为。最后,我们将讨论历史上的 NumPy 支持的数据类型,并深入探讨它们的一些致命缺陷,我希望这能说服你为什么应当限制使用这些类型。

本章将涵盖以下几个实例:

  • 整数类型

  • 浮点类型

  • 布尔类型

  • 字符串类型

  • 缺失值处理

  • 分类类型

  • 时间类型 – 日期时间

  • 时间类型 – 时间差

  • 时间类型 PyArrow

  • PyArrow 列表类型

  • PyArrow 十进制类型

  • NumPy 类型系统、对象类型及其陷阱

整数类型

整数类型是最基本的类型类别。类似于 Python 中的int类型或数据库中的INTEGER数据类型,这些类型仅能表示整数。尽管有这一限制,整数在各种应用中非常有用,包括但不限于算术运算、索引、计数和枚举。

整数类型经过高度优化,性能得到了极大的提升,从 pandas 一直追踪到你电脑上的硬件。pandas 提供的整数类型比 Python 标准库中的int类型要快得多,正确使用整数类型通常是实现高性能、可扩展报告的关键。

如何实现

任何有效的整数序列都可以作为参数传递给pd.Series构造函数。搭配dtype=pd.Int64Dtype()参数,你将得到一个 64 位整数数据类型:

`pd.Series(range(3), dtype=pd.Int64Dtype())` 
`0    0 1    1 2    2 dtype: Int64` 

当存储和计算资源不成问题时,用户通常会选择 64 位整数,但在我们的示例中,我们也可以选择一个更小的数据类型:

`pd.Series(range(3), dtype=pd.Int8Dtype())` 
`0    0 1    1 2    2 dtype: Int8` 

关于缺失值,pandas 使用pd.NA作为指示符,类似于数据库使用NULL

`pd.Series([1, pd.NA, 2], dtype=pd.Int64Dtype())` 
`0       1 1    <NA> 2       2 dtype: Int64` 

为了方便,pd.Series构造函数会自动将 Python 中的None值转换为pd.NA

`pd.Series([1, None, 2], dtype=pd.Int64Dtype())` 
`0       1 1    <NA> 2       2 dtype: Int64` 

还有更多……

对于科学计算的新手用户来说,重要的是要知道,与 Python 的int类型不同,后者没有理论上的大小限制,pandas 中的整数有上下限。这些限制由整数的宽度符号决定。

在大多数计算环境中,用户拥有的整数宽度为 8、16、32 和 64。符号性可以是有符号(即,数字可以是正数或负数)或无符号(即,数字不得为负)。每种整数类型的限制总结在下表中:

类型 下限 上限
8 位宽度,有符号 -128 127
8 位宽度,无符号 0 255
16 位宽度,有符号 -32769 32767
16 位宽度,无符号 0 65535
32 位宽度,有符号 -2147483648 2147483647
32 位宽度,无符号 0 4294967295
64 位宽度,有符号 -(2**63) 2**63-1
64 位宽度,无符号 0 2**64-1

表 3.1:按符号性和宽度的整数极限

这些类型的权衡是容量与内存使用之间的平衡——64 位整数类型需要的内存是 8 位整数类型的 8 倍。是否会成为问题完全取决于你的数据集的大小以及你执行分析的系统。

在 pandas 扩展类型系统中,每种类型的dtype=参数遵循pd.IntXXDtype()形式的有符号整数和pd.UIntXXDtype()形式的无符号整数,其中XX表示位宽:

`pd.Series(range(555, 558), dtype=pd.Int16Dtype())` 
`0    555 1    556 2    557 dtype: Int16` 
`pd.Series(range(3), dtype=pd.UInt8Dtype())` 
`0    0 1    1 2    2 dtype: UInt8` 

浮点类型

浮点类型允许你表示实数,而不仅仅是整数。这使得你可以在计算中处理一个连续的、理论上无限的值集。浮点计算几乎出现在每一个科学计算、宏观金融分析、机器学习算法等中,这一点并不令人惊讶。

然而,单词理论上的重点是故意强调的,并且对于理解非常重要。浮点类型仍然有边界,真实的限制是由你的计算机硬件所强加的。本质上,能够表示任何数字的概念是一种错觉。浮点类型容易失去精度并引入舍入误差,尤其是在处理极端值时。因此,当你需要绝对精度时,浮点类型并不适用(对于这种情况,你可以参考本章后面介绍的 PyArrow 十进制类型)。

尽管存在这些限制,但实际上你很少需要绝对精度,因此浮点类型是最常用的数据类型,通常用于表示分数。

如何操作

要构建浮点数据,请使用dtype=pd.Float64Dtype()

`pd.Series([3.14, .333333333, -123.456], dtype=pd.Float64Dtype())` 
`0        3.14 1    0.333333 2    -123.456 dtype: Float64` 

就像我们在整数类型中看到的那样,缺失值指示符是pd.NA。Python 对象None会被隐式地转换为此,以便于使用:

`pd.Series([3.14, None, pd.NA], dtype=pd.Float64Dtype())` 
`0    3.14 1    <NA> 2    <NA> dtype: Float64` 

还有更多内容…

由于其设计的性质,浮点值是不精确的,且浮点值的算术运算比整数运算要慢。深入探讨浮点算术超出了本书的范围,但有兴趣的人可以在 Python 文档中找到更多信息。

Python 有一个内建的 float 类型,这个名字有些误导,因为它实际上是一个 IEEE 754 double。该标准和其他像 C/C++ 这样的语言有独立的 floatdouble 类型,前者占用 32 位,后者占用 64 位。为了澄清这些位宽的差异,同时保持与 Python 术语的一致性,pandas 提供了 pd.Float64Dtype()(有些人认为它是 double)和 pd.Float32Dtype()(有些人认为它是 float)。

通常,除非你的系统资源有限,否则建议用户使用 64 位浮动点类型。32 位浮动点类型丢失精度的概率远高于对应的 64 位类型。事实上,32 位浮动点数仅提供 6 到 9 位小数的精度,因此,尽管我们可以很清楚地看到数字并不相同,下面的表达式仍然可能返回 True 作为相等比较的结果:

`ser1 = pd.Series([1_000_000.123], dtype=pd.Float32Dtype()) ser2 = pd.Series([1_000_000.124], dtype=pd.Float32Dtype()) ser1.eq(ser2)` 
`0    True dtype: boolean` 

使用 64 位浮动点数时,你至少能获得 15 到 17 位小数的精度,因此四舍五入误差发生的数值范围要远大于 32 位浮动点数。

布尔类型

布尔类型表示一个值,值只能是 TrueFalse。布尔数据类型用于简单地回答是/否式的问题,也广泛用于机器学习算法中,将分类值转换为计算机可以更容易处理的 1 和 0(分别代表 TrueFalse)(参见《第五章,算法及其应用》中关于 One-hot 编码与 pd.get_dummies 的部分)。

如何实现

对于布尔类型,适当的 dtype= 参数是 pd.BooleanDtype

`pd.Series([True, False, True], dtype=pd.BooleanDtype())` 
`0     True 1    False 2     True dtype: boolean` 

pandas 库会自动为你处理值到布尔值的隐式转换。通常,FalseTrue 分别用 0 和 1 来代替:

`pd.Series([1, 0, 1], dtype=pd.BooleanDtype())` 
`0     True 1    False 2     True dtype: boolean` 

再次强调,pd.NA 是标准的缺失值指示符,尽管 pandas 会自动将 None 转换为缺失值:

`pd.Series([1, pd.NA, None], dtype=pd.BooleanDtype())` 
`0    True 1    <NA> 2    <NA> dtype: boolean` 

字符串类型

字符串数据类型是表示文本数据的合适选择。除非你在纯粹的科学领域工作,否则字符串类型的值很可能会广泛存在于你使用的数据中。

在本教程中,我们将重点介绍 pandas 在处理字符串数据时提供的一些附加功能,特别是通过 pd.Series.str 访问器。这个访问器有助于改变大小写、提取子字符串、匹配模式等等。

作为一个技术注解,在我们进入具体内容之前,从 pandas 3.0 开始,字符串类型将会在幕后进行重大改造,启用一种更符合类型的实现,速度更快,内存需求也比 pandas 2.x 系列要低得多。为了在 3.0 及更高版本中实现这一点,强烈建议用户在安装 pandas 时同时安装 PyArrow。对于那些想了解 pandas 3.0 中字符串处理的权威参考,可以查看 PDEP-14 专门针对字符串数据类型的文档。

如何做到这一点

字符串数据应该使用 dtype=pd.StringDtype() 构造:

`pd.Series(["foo", "bar", "baz"], dtype=pd.StringDtype())` 
`0    foo 1    bar 2    baz dtype: string` 

你可能已经发现,pd.NA 是用于表示缺失值的标识符,但 pandas 会自动将 None 转换为 pd.NA

`pd.Series(["foo", pd.NA, None], dtype=pd.StringDtype())` 
`0     foo 1    <NA> 2    <NA> dtype: string` 

在处理包含字符串数据的 pd.Series 时,pandas 会创建一个称为字符串 访问器 的工具,帮助你解锁适用于字符串的新方法。字符串访问器通过 pd.Series.str 使用,帮助你做诸如通过 pd.Series.str.len 获取每个字符串的长度等操作:

`ser = pd.Series(["xx", "YyY", "zZzZ"], dtype=pd.StringDtype()) ser.str.len()` 
`0    2 1    3 2    4 dtype: Int64` 

它也可以用于强制将所有内容转换为特定的格式,例如大写:

`ser.str.upper()` 
`0      XX 1     YYY 2    ZZZZ dtype: string` 

它也可以用于强制将所有内容转换为小写:

`ser.str.lower()` 
`0      xx 1     yyy 2    zzzz dtype: string` 

甚至可以是“标题大小写”(即只有第一个字母大写,其他字母小写):

`ser.str.title()` 
`0      Xx 1     Yyy 2    Zzzz dtype: string` 

pd.Series.str.contains 可用于检查字符串是否包含特定内容:

`ser = pd.Series(["foo", "bar", "baz"], dtype=pd.StringDtype()) ser.str.contains("o")` 
`0     True 1    False 2    False dtype: boolean` 

它还具有使用 regex=True 测试正则表达式的灵活性,类似于标准库中的 re.searchcase=False 参数还会将匹配操作转换为不区分大小写的比较:

`ser.str.contains(r"^ba[rz]$", case=False, regex=True)` 
`0    False 1     True 2     True dtype: boolean` 

缺失值处理

在我们继续讨论更多数据类型之前,我们必须回过头来谈谈 pandas 如何处理缺失值。到目前为止,事情都很简单(我们只看到了 pd.NA),但随着我们探索更多类型,会发现 pandas 处理缺失值的方式并不一致,这主要源于该库的开发历史。虽然能够挥动魔杖让任何不一致消失会很棒,但实际上,它们一直存在,并将在未来几年继续出现在生产代码库中。对这种发展过程有一个高层次的理解将帮助你编写更好的 pandas 代码,并希望能将那些不了解的人引导到我们在本书中提倡的习惯用法中。

如何做到这一点

pandas 库最初是建立在 NumPy 之上的,而 NumPy 的默认数据类型不支持缺失值。因此,pandas 必须从零开始构建自己的缺失值处理解决方案,并且,无论好坏,它决定使用 np.nan 哨兵值(代表“非数字”)作为其处理缺失值的工具。

np.nan 本身是 IEEE 754 标准中的“非数字”哨兵值的实现,这一规范仅与浮点运算有关。对于整数数据来说并不存在“非数字”这一概念,这也是为什么 pandas 会隐式地将像这样的 pd.Series 转换为:

`ser = pd.Series(range(3)) ser` 
`0    0 1    1 2    2 dtype: int64` 

在分配缺失值后,将数据转换为浮点数据类型:

`ser.iloc[1] = None ser` 
`0    0.0 1    NaN 2    2.0 dtype: float64` 

正如我们在 浮动点类型 配方中讨论的那样,浮动点值的计算速度比整型值要慢。虽然整数可以使用 8 位和 16 位宽度表示,但浮动点类型至少需要 32 位。即使你使用的是 32 位宽度的整数,使用 32 位浮动点值可能会因为精度损失而不可行,而使用 64 位整数时,转换可能只能牺牲精度。一般来说,从整型到浮动点类型的转换,必须牺牲一定的性能、内存使用和/或精度,因此这类转换并不是理想的选择。

当然,pandas 不仅提供了整型和浮动点类型,因此其他类型也必须附加自定义的缺失值解决方案。默认的布尔类型会被转换为 object 类型,这一问题将在本章后面的一个配方中讨论。对于日期时间类型,我们很快会讨论,pandas 必须创建一个完全不同的 pd.NaT 哨兵,因为 np.nan 在技术上并不适用于该数据类型。实质上,pandas 中的每个数据类型都有可能有自己的指示符和隐式类型转换规则,这对于初学者和有经验的 pandas 开发者来说都很难解释清楚。

pandas 库通过引入 pandas 扩展类型 在 0.24 版本中尝试解决这些问题,正如我们迄今为止所看到的示例,它们在缺失值出现时仅使用 pd.NA 并且没有进行隐式类型转换,做得相当出色。然而,pandas 扩展类型 被作为可选类型引入,而不是默认类型,因此 pandas 为处理缺失值所开发的自定义解决方案在代码中仍然占主导地位。遗憾的是,由于这些不一致性从未得到纠正,用户必须理解他们所选择的数据类型以及不同数据类型如何处理缺失值。

尽管存在这些不一致之处,幸运的是 pandas 提供了一个 pd.isna 函数,可以告诉你数组中的某个元素是否缺失。它适用于默认数据类型:

`pd.isna(pd.Series([1, np.nan, 2]))` 
`0    False 1     True 2    False dtype: bool` 

它与 pandas 扩展类型 同样有效:

`pd.isna(pd.Series([1, pd.NA, 2], dtype=pd.Int64Dtype()))` 
`0    False 1     True 2    False dtype: bool` 

还有更多内容…

用户应当注意,与 np.nanpd.NA 进行比较时,它们的行为是不同的。例如,np.nan == np.nan 返回 False,而 pd.NA == pd.NA 返回 pd.NA。前者的比较遵循 IEEE 757 标准,而 pd.NA 哨兵遵循 Kleene 逻辑。

pd.NA 的工作方式允许在 pandas 中进行更具表现力的掩码/选择。例如,如果你想创建一个也包含缺失值的布尔掩码并用它来选择值,pd.BooleanDtype 使你能够做到这一点,并且自然地只会选择掩码为 True 的记录:

`ser = pd.Series(range(3), dtype=pd.Int64Dtype()) mask = pd.Series([True, pd.NA, False], dtype=pd.BooleanDtype()) ser[mask]` 
`0    0 dtype: Int64` 

如果没有布尔扩展类型,相应的操作将引发错误:

`mask = pd.Series([True, None, False]) ser[mask]` 
`ValueError: Cannot mask with non-boolean array containing NA / NaN values` 

因此,在不使用pd.BooleanDtype的代码中,你可能会看到许多方法调用,将“缺失”值替换为False,然后使用pd.Series.astype尝试在填充后将其转换回布尔数据类型:

`mask = pd.Series([True, None, False]) mask = mask.fillna(False).astype(bool) ser[mask]` 
``/tmp/ipykernel_45649/2987852505.py:2: FutureWarning: Downcasting object dtype arrays on .fillna, .ffill, .bfill is deprecated and will change in a future version. Call result.infer_objects(copy=False) instead. To opt-in to the future behavior, set `pd.set_option('future.no_silent_downcasting', True)`  mask = mask.fillna(False).astype(bool) 0    0 dtype: Int64`` 

这种方法不必要地复杂且低效。使用pd.BooleanDtype能更简洁地表达你的操作意图,让你更少担心 pandas 的细微差别。

分类类型

分类数据类型的主要目的是定义你的pd.Series可以包含的可接受域值集合。第四章中的CSV - 读取大文件的策略食谱将向你展示一个例子,其中这可能导致显著的内存节省,但通常这里的使用案例是让 pandas 将诸如foobarbaz等字符串值转换为代码012,这些代码可以更高效地存储。

如何做到

到目前为止,我们总是选择pd.XXDtype()作为dtype=参数,这在分类数据类型的情况下仍然可能有效,但不幸的是,它没有一致地处理缺失值(详见还有更多……,深入探讨这个问题)。因此,我们必须选择两种替代方法中的一种,使用pd.NA缺失值指示符来创建pd.CategoricalDtype

无论哪种方法,你都需要从一个使用pd.StringDtype的数据pd.Series开始:

`values = ["foo", "bar", "baz"] values_ser = pd.Series(values, dtype=pd.StringDtype())` 

从那里,你可以使用pd.DataFrame.astype将其转换为分类类型:

`ser = values_ser.astype(pd.CategoricalDtype()) ser` 
`0    foo 1    bar 2    baz dtype: category Categories (3, string): [bar, baz, foo]` 

或者,如果你需要对分类类型的行为有更多控制,你可以从你的pd.Series值构造pd.CategoricalDtype,并随后将其用作dtype=参数:

`cat = pd.CategoricalDtype(values_ser) ser = pd.Series(values, dtype=cat) ser` 
`0    foo 1    bar 2    baz dtype: category Categories (3, string): [foo, bar, baz]` 

两种方法最终都能达到相同的目的,尽管第二种方法在构造pd.CategoricalDtype时牺牲了一些冗长性,以换取对其行为的更细致控制,正如你将在本食谱的其余部分看到的那样。

无论你采用何种方法,都应该注意,在构造分类类型pd.Series时所使用的值定义了可以使用的可接受域值的集合。鉴于我们用["foo", "bar", "baz"]创建了我们的分类类型,随后使用这些值进行赋值并没有问题:

`ser.iloc[2] = "foo" ser` 
`0    foo 1    bar 2    foo dtype: category Categories (3, string): [foo, bar, baz]` 

然而,赋值超出该域范围会引发错误:

`ser.iloc[2] = "qux"` 
`TypeError: Cannot setitem on a Categorical with a new category (qux), set the categories first` 

当显式构造pd.CategoricalDtype时,可以通过ordered=参数为值分配非字典序的顺序。这在处理顺序数据时非常宝贵,因为这些数据的值并不是按你想要的方式由计算机算法自然排序的。

作为一个实际例子,我们来考虑一下服装尺码的使用案例。自然,小号服装比中号服装小,中号服装比大号服装小,依此类推。通过按顺序构造pd.CategoricalDtype并使用ordered=True,pandas 使得比较尺码变得非常自然:

`shirt_sizes = pd.Series(["S", "M", "L", "XL"], dtype=pd.StringDtype()) cat = pd.CategoricalDtype(shirt_sizes, ordered=True) ser = pd.Series(["XL", "L", "S", "L", "S", "M"], dtype=cat) ser < "L"` 
`0    False 1    False 2     True 3    False 4     True 5     True dtype: bool` 

那么,pandas 是如何做到这么简单高效的呢?pandas 库暴露了一个类别访问器pd.Series.cat,它可以帮助你更深入地理解这一点。为了进一步探索,让我们首先创建一个pd.Series类别数据,其中某一类别被多次使用:

`accepted_values = pd.Series(["foo", "bar"], dtype=pd.StringDtype()) cat = pd.CategoricalDtype(accepted_values) ser = pd.Series(["foo", "bar", "foo"], dtype=cat) ser` 
`0    foo 1    bar 2    foo dtype: category Categories (2, string): [foo, bar]` 

如果你检查pd.Series.cat.codes,你会看到一个大小相同的pd.Series,但值foo被替换为数字0,值bar被替换为数字1

`ser.cat.codes` 
`0    0 1    1 2    0 dtype: int8` 

另外,pd.Series.cat.categories将包含每个类别的值,按顺序排列:

`ser.cat.categories` 
`Index(['foo', 'bar'], dtype='string')` 

除去一些内部细节,你可以将 pandas 视为创建了一个形式为{0: "foo", 1: "bar"}的字典。虽然它内部存储着一个值为[0, 1, 0]pd.Series,但当需要显示或以任何方式访问这些值时,这些值会像字典中的键一样被用来访问最终用户想要使用的真实值。因此,你会经常看到类别数据类型被描述为字典类型(例如,Apache Arrow 就使用了“字典”这个术语)。

那么,为什么要费心呢?将标签编码成非常小的整数查找值的过程,可能会对内存使用产生显著影响。请注意与普通字符串类型之间的内存使用差异:

`pd.Series(["foo", "bar", "baz"] * 100, dtype=pd.StringDtype()).memory_usage()` 
`2528` 

与等效的类别类型相比,如下所示:

`pd.Series(["foo", "bar", "baz"] * 100, dtype=cat).memory_usage()` 
`552` 

你的数字可能和.memory_usage()的输出完全不一致,但至少你应该会看到,在使用类别数据类型时,内存使用量有明显的减少。

还有更多…

如果直接使用dtype=pd.CategoricalDtype()有效,为什么用户不想使用它呢?不幸的是,pandas API 中存在一个较大的空白,导致缺失值无法在类别类型之间传播,这可能会意外地引入我们在缺失值处理方法中警告过的np.nan缺失值指示符。这可能会导致非常令人惊讶的行为,即使你认为自己已经正确使用了pd.NA哨兵值:

`pd.Series(["foo", "bar", pd.NA], dtype=pd.CategoricalDtype())` 
`0    foo 1    bar 2    NaN dtype: category Categories (2, object): ['bar', 'foo']` 

请注意,在前面的示例中,我们尝试提供pd.NA仍然返回了np.nan?从dtype=pd.StringDtype()构造的pd.Series显式构建pd.CategoricalDtype帮助我们避免了这种令人惊讶的行为:

`values = pd.Series(["foo", "bar"], dtype=pd.StringDtype()) cat = pd.CategoricalDtype(values) pd.Series(["foo", "bar", pd.NA], dtype=cat)` 
`0     foo 1     bar 2    <NA> dtype: category Categories (2, string): [foo, bar]` 

如果你发现这种行为令人困惑或麻烦,相信你并不孤单。隧道尽头的曙光可能是 PDEP-16,它旨在让pd.NA仅作为缺失值指示符使用。这意味着你可以直接使用pd.CategoricalDtype()构造函数,并遵循直到此时为止所看到的所有相同模式。

不幸的是,这本书是在 pandas 3.0 发布的时候发布的,而且在 PDEP-16 被正式接受之前,因此很难预测这些 API 中的不一致何时会消失。如果你是在本书出版几年后阅读的,请务必查看 PDEP-16 的状态,因为它可能会改变构造分类数据的正确方式(以及其他数据类型)。

时间类型 – 日期时间

“时间”一词通常包含那些涉及日期和时间的数据类型,既包括绝对时间,也包括衡量两点之间持续时间的情况。时间类型是基于时间序列分析的关键支持,它对趋势检测和预测模型至关重要。事实上,pandas 最初是在一家资本管理公司开发的,随后才开源。pandas 内置的许多时间序列处理功能,受到金融和经济行业实际报告需求的影响。

尽管分类类型部分开始展示了 pandas 类型系统 API 中的一些不一致,时间类型更是将这些问题推向了一个新的层次。合理的预期是,pd.DatetimeDtype()应该作为构造函数存在,但不幸的是,至少在写作时并非如此。此外,正如缺失值处理一节所提到的,时间类型是在 pandas 类型扩展系统之前实现的,使用了不同的缺失值指示符pd.NaT(即,“不是一个时间”)。

尽管存在这些问题,pandas 仍然提供了令人惊讶的高级功能来处理时间数据。第九章时间数据类型与算法,将深入探讨这些数据类型的应用;现在,我们只提供一个快速概述。

如何操作

与许多数据库系统提供单独的DATEDATETIMETIMESTAMP数据类型不同,pandas 只有一种“日期时间”类型,可以通过dtype=参数的"datetime64[<unit>]"形式进行构造。

在 pandas 的历史大部分时间里,ns是唯一被接受的<unit>值,因此我们暂时从它开始(但请查看还有更多……,了解不同值的详细解释):

`ser = pd.Series([     "2024-01-01 00:00:00",     "2024-01-02 00:00:01",     "2024-01-03 00:00:02" ], dtype="datetime64[ns]") ser` 
`0   2024-01-01 00:00:00 1   2024-01-02 00:00:01 2   2024-01-03 00:00:02 dtype: datetime64[ns]` 

你也可以使用不包含时间组件的字符串参数构造一个pd.Series数据类型:

`ser = pd.Series([     "2024-01-01",     "2024-01-02",     "2024-01-03" ], dtype="datetime64[ns]") ser` 
`0   2024-01-01 1   2024-01-02 2   2024-01-03 dtype: datetime64[ns]` 

上述构造的输出略显误导;虽然时间戳没有显示,pandas 仍然将这些值内部存储为日期时间,而不是日期。这可能是一个问题,因为没有办法阻止后续的时间戳被存储在那个pd.Series中:

`ser.iloc[1] = "2024-01-04 00:00:42" ser` 
`0   2024-01-01 00:00:00 1   2024-01-04 00:00:42 2   2024-01-03 00:00:00 dtype: datetime64[ns]` 

如果保留日期很重要,请务必稍后阅读本章中的时间 PyArrow 类型一节。

就像我们在字符串类型中看到的那样,包含日期时间数据的pd.Series会有一个访问器,它解锁了处理日期和时间的灵活功能。在这种情况下,访问器是pd.Series.dt

我们可以使用这个访问器来确定pd.Series中每个元素的年份:

`ser.dt.year` 
`0    2024 1    2024 2    2024 dtype: int32` 

pd.Series.dt.month将返回月份:

`ser.dt.month` 
`0    1 1    1 2    1 dtype: int32` 

pd.Series.dt.day提取日期所在的月日:

`ser.dt.day` 
`0    1 1    4 2    3 dtype: int32` 

还有一个pd.Series.dt.day_of_week函数,它会告诉你一个日期是星期几。星期一为0,依此类推,直到6,表示星期日:

`ser.dt.day_of_week` 
`0    0 1    3 2    2 dtype: int32` 

如果你曾经处理过时间戳(尤其是在全球化组织中),你可能会问这些值代表的是哪个时间。2024-01-03 00:00:00 在纽约市发生的时间并不会与 2024-01-03 00:00:00 在伦敦或上海同时发生。那么,我们如何获得真正的时间表示呢?

我们之前看到的时间戳被视为无时区感知的(即,它们并未清楚地表示地球上任何一个时刻)。相比之下,你可以通过在dtype=参数中指定时区,使你的时间戳变为有时区感知

奇怪的是,pandas 确实有一个pd.DatetimeTZDtype(),因此我们可以结合tz=参数来指定假定事件发生的时区。例如,要使你的时间戳表示为 UTC,你可以执行以下操作:

`pd.Series([     "2024-01-01 00:00:01",     "2024-01-02 00:00:01",     "2024-01-03 00:00:01" ], dtype=pd.DatetimeTZDtype(tz="UTC"))` 
`0   2024-01-01 00:00:01+00:00 1   2024-01-02 00:00:01+00:00 2   2024-01-03 00:00:01+00:00 dtype: datetime64[ns, UTC]` 

字符串 UTC 表示的是互联网号码分配局IANA)的时区标识符。你可以使用任何这些标识符作为tz=参数,如America/New_York

`pd.Series([     "2024-01-01 00:00:01",     "2024-01-02 00:00:01",     "2024-01-03 00:00:01" ], dtype=pd.DatetimeTZDtype(tz="America/New_York"))` 
`0   2024-01-01 00:00:01-05:00 1   2024-01-02 00:00:01-05:00 2   2024-01-03 00:00:01-05:00 dtype: datetime64[ns, America/New_York]` 

如果你不想使用时区标识符,也可以选择指定一个 UTC 偏移量:

`pd.Series([     "2024-01-01 00:00:01",     "2024-01-02 00:00:01",     "2024-01-03 00:00:01" ], dtype=pd.DatetimeTZDtype(tz="-05:00"))` 
`0   2024-01-01 00:00:01-05:00 1   2024-01-02 00:00:01-05:00 2   2024-01-03 00:00:01-05:00 dtype: datetime64[ns, UTC-05:00]` 

我们在本节中介绍的pd.Series.dt访问器也具有一些非常适用于时区操作的功能。例如,如果你正在处理的数据技术上没有时区信息,但你知道这些时间实际上代表的是美国东部时间,pd.Series.dt.tz_localize可以帮助你表达这一点:

`ser_no_tz = pd.Series([     "2024-01-01 00:00:00",     "2024-01-01 00:01:10",     "2024-01-01 00:02:42" ], dtype="datetime64[ns]") ser_et = ser_no_tz.dt.tz_localize("America/New_York") ser_et` 
`0   2024-01-01 00:00:00-05:00 1   2024-01-01 00:01:10-05:00 2   2024-01-01 00:02:42-05:00 dtype: datetime64[ns, America/New_York]` 

你还可以使用pd.Series.dt.tz_convert将时间转换为其他时区:

`ser_pt = ser_et.dt.tz_convert("America/Los_Angeles") ser_pt` 
`0   2023-12-31 21:00:00-08:00 1   2023-12-31 21:01:10-08:00 2   2023-12-31 21:02:42-08:00 dtype: datetime64[ns, America/Los_Angeles]` 

你甚至可以使用pd.Series.dt.normalize将所有的日期时间数据设定为所在时区的午夜。如果你根本不关心日期时间的时间部分,只想将其视为日期,这会很有用,尽管 pandas 并没有提供一等的DATE类型:

`ser_pt.dt.normalize()` 
`0   2023-12-31 00:00:00-08:00 1   2023-12-31 00:00:00-08:00 2   2023-12-31 00:00:00-08:00 dtype: datetime64[ns, America/Los_Angeles]` 

尽管我们迄今为止提到的 pandas 在处理日期时间数据时有许多很棒的功能,但我们也应该看看其中一些并不那么出色的方面。在缺失值处理部分,我们讨论了如何使用np.nan作为 pandas 中的缺失值指示符,尽管更现代的数据类型使用pd.NA。对于日期时间数据类型,还有一个额外的缺失值指示符pd.NaT

`ser = pd.Series([     "2024-01-01",     None,     "2024-01-03" ], dtype="datetime64[ns]") ser` 
`0   2024-01-01 1          NaT 2   2024-01-03 dtype: datetime64[ns]` 

再次强调,这个差异源于时间类型在 pandas 引入扩展类型之前就已经存在,而推动统一缺失值指示符的进展尚未完全实现。幸运的是,像pd.isna这样的函数仍然能够正确识别pd.NaT作为缺失值:

`pd.isna(ser)` 
`0    False 1     True 2    False dtype: bool` 

还有更多内容……

历史上的ns精度限制了 pandas 中的时间戳范围,从稍早于 1677-09-21 开始,到稍晚于 2264-04-11 结束。尝试分配超出这些边界之外的日期时间值将引发OutOfBoundsDatetime异常:

`pd.Series([     "1500-01-01 00:00:01",     "2500-01-01 00:00:01", ], dtype="datetime64[ns]")` 
`OutOfBoundsDatetime: Out of bounds nanosecond timestamp: 1500-01-01 00:00:01, at position 0` 

从 pandas 的 3.0 系列开始,您可以指定低精度,如smsus,以扩展您的时间范围超出这些窗口:

`pd.Series([     "1500-01-01 00:00:01",     "2500-01-01 00:00:01", ], dtype="datetime64[us]")` 
`0   1500-01-01 00:00:01 1   2500-01-01 00:00:01 dtype: datetime64[us]` 

时间类型 – timedelta

Timedelta 非常有用,用于测量两个时间点之间的持续时间。这可以用于测量诸如“平均来看,事件 X 和事件 Y 之间经过了多少时间”,这对于监控和预测组织内某些过程或系统的周转时间非常有帮助。此外,timedelta 可以用于操作您的日期时间,轻松实现“添加 X 天”或“减去 Y 秒”,而无需深入了解日期时间对象在内部存储的细节。

如何操作

到目前为止,我们已经介绍了每种数据类型通过直接构建它。然而,手工构造 timedelta pd.Series的用例非常罕见。更常见的情况是,您会遇到这种类型作为从一个日期时间减去另一个日期时间的表达式的结果:

`ser = pd.Series([     "2024-01-01",     "2024-01-02",     "2024-01-03" ], dtype="datetime64[ns]") ser - pd.Timestamp("2023-12-31 12:00:00")` 
`0   0 days 12:00:00 1   1 days 12:00:00 2   2 days 12:00:00 dtype: timedelta64[ns]` 

在 pandas 中,还有pd.Timedelta标量,可以在表达式中用来添加或减去一个持续时间到日期时间。例如,以下代码展示了如何在pd.Series中的每个日期时间上添加 3 天:

`ser + pd.Timedelta("3 days")` 
`0   2024-01-04 1   2024-01-05 2   2024-01-06 dtype: datetime64[ns]` 

更多信息…

虽然不是一个常见的模式,如果您曾经需要手动构建一个 timedelta 对象的pd.Series,您可以使用dtype="timedelta[ns]"来实现:

`pd.Series([     "-1 days",     "6 hours",     "42 minutes",     "12 seconds",     "8 milliseconds",     "4 microseconds",     "300 nanoseconds", ], dtype="timedelta64[ns]")` 
`0           -1 days +00:00:00 1             0 days 06:00:00 2             0 days 00:42:00 3             0 days 00:00:12 4      0 days 00:00:00.008000 5      0 days 00:00:00.000004 6   0 days 00:00:00.000000300 dtype: timedelta64[ns]` 

如果我们尝试创建一个以月为单位的 timedelta 呢?我们来看看:

`pd.Series([     "1 months", ], dtype="timedelta64[ns]")` 
`ValueError: invalid unit abbreviation: months` 

pandas 不允许这样做的原因是,timedelta 表示一个一致可测量的持续时间。尽管微秒中始终有 1,000 纳秒,毫秒中始终有 1,000 微秒,秒中始终有 1,000 毫秒,等等,但一个月中的天数并不一致,从 28 到 31 不等。说两个事件相隔一个月并不能满足 timedelta 测量时间段的严格要求。

如果您需要按照日历而不是有限的持续时间来移动日期,您仍然可以使用我们在第九章 时间数据类型和算法中将介绍的pd.DateOffset对象。虽然这本章节中没有相关的数据类型介绍,但该对象本身可以作为 timedelta 类型的一个很好的补充或增强,用于那些不严格将时间视为有限持续时间的分析。

PyArrow 的时间类型

到目前为止,我们已经回顾了内置到 pandas 中的许多“一流”数据类型,同时也突出了困扰它们的一些粗糙边缘和不一致性。尽管存在这些问题,内置到 pandas 中的这些类型可以在数据旅程中为您提供很长一段路。

但仍然有一些情况下,pandas 类型不适用,常见的情况是与数据库的互操作性。大多数数据库有独立的DATEDATETIME类型,因此,pandas 只提供DATETIME类型可能让熟悉 SQL 的用户感到失望。

幸运的是,Apache Arrow 项目定义了一个真正的DATE类型。从 2.0 版本开始,pandas 用户可以开始利用通过 PyArrow 库暴露的 Arrow 类型。

如何实现

要在 pandas 中直接构造 PyArrow 类型,你总是需要提供dtype=参数,格式为pd.ArrowDtype(XXX),并用适当的 PyArrow 类型替换XXX。PyArrow 中的 DATE 类型是pa.date32()

`ser = pd.Series([     "2024-01-01",     "2024-01-02",     "2024-01-03", ], dtype=pd.ArrowDtype(pa.date32())) ser` 
`0    2024-01-01 1    2024-01-02 2    2024-01-03 dtype: date32[day][pyarrow]` 

pa.date32()类型可以表示更广泛的日期范围,而不需要切换精度:

`ser = pd.Series([     "9999-12-29",     "9999-12-30",     "9999-12-31", ], dtype=pd.ArrowDtype(pa.date32())) ser` 
`0    9999-12-29 1    9999-12-30 2    9999-12-31 dtype: date32[day][pyarrow]` 

PyArrow 库提供了一个时间戳类型;然而,它的功能几乎与您已经看到的 datetime 类型完全相同,因此我建议使用 pandas 内置的 datetime 类型。

PyArrow 列表类型

如果你遇到的每一项数据都恰好适合并整齐地放在pd.DataFrame的单个位置,生活将会变得如此简单,但不可避免地,你会遇到那些情况,数据并不总是这样。让我们先假设尝试分析一家公司中工作的员工:

`df = pd.DataFrame({     "name": ["Alice", "Bob", "Janice", "Jim", "Michael"],     "years_exp": [10, 2, 4, 8, 6], }) df` 
 `name      years_exp 0    Alice     10 1    Bob       2 2    Janice    4 3    Jim       8 4    Michael   6` 

这种类型的数据相对容易处理——你可以轻松地计算出每个员工的工作经验年数总和或平均值。但如果我们还想知道,Bob 和 Michael 向 Alice 汇报,而 Janice 向 Jim 汇报怎么办?

我们对世界的美好视角突然崩塌——我们怎么可能在pd.DataFrame中表达这个呢?如果你来自 Microsoft Excel 或 SQL 背景,你可能会想你需要创建一个单独的pd.DataFrame来存储直接汇报信息。但在 pandas 中,我们可以通过使用 PyArrow 的pa.list_()数据类型,更自然地表达这一点。

如何实现

在处理pa.list_()类型时,您必须参数化它,指定它将包含的元素的数据类型。在我们的例子中,我们希望列表包含类似BobJanice这样的值,因此我们将使用pa.string()类型对pa.list_()类型进行参数化:

`ser = pd.Series([     ["Bob", "Michael"],     None,     None,     ["Janice"],     None, ], dtype=pd.ArrowDtype(pa.list_(pa.string()))) df["direct_reports"] = ser df` 
 `name      years_exp    direct_reports 0    Alice     10           ['Bob' 'Michael'] 1    Bob       2            <NA> 2    Janice    4            <NA> 3    Jim       8            ['Janice'] 4    Michael   6            <NA>` 

还有更多……

在处理具有 PyArrow 列表类型的pd.Series时,你可以通过使用.list访问器解锁更多pd.Series的功能。例如,要查看列表中包含多少项,可以调用ser.list.len()

`ser.list.len()` 
`0       2 1    <NA> 2    <NA> 3       1 4    <NA> dtype: int32[pyarrow]` 

你可以使用.list[]语法访问列表中给定位置的项:

`ser.list[0]` 
`0       Bob 1      <NA> 2      <NA> 3    Janice 4      <NA> dtype: string[pyarrow]` 

还有一个.list.flatten访问器,它可以帮助你识别所有向某人汇报的员工:

`ser.list.flatten()` 
`0        Bob 1    Michael 2     Janice dtype: string[pyarrow]` 

PyArrow 十进制类型

当我们在本章早些时候查看 浮点数类型 示例时,我们提到的一个重要内容是浮点数类型是 不精确的。大多数计算机软件的用户可能一生都不会知道这个事实,在许多情况下,精度的缺失可能是为了获得浮点数类型所提供的性能而可接受的折衷。然而,在某些领域,精确的计算是至关重要的。

举一个简单的例子,假设一个电影推荐系统使用浮点算术计算某部电影的评分为 4.3334(满分 5 星),而实际应为 4.33337。即使这个四舍五入误差重复了一百万次,可能也不会对文明产生很大负面影响。相反,一个每天处理数十亿交易的金融系统会认为这种四舍五入误差是无法接受的。随着时间的推移,这个误差会积累成一个相当大的数值。

十进制数据类型是解决这些问题的方案。通过放弃一些浮点计算带来的性能,十进制值允许你实现更精确的计算。

如何实现

pa.decimal128() 数据类型需要两个参数,这两个参数定义了你希望表示的数字的 精度小数位。精度决定了可以安全存储多少位小数,而小数位则表示这些小数位中有多少位出现在小数点后。

例如,当 精度 为 5 和 小数位 为 2 时,你可以准确表示 -999.99 到 999.99 之间的数字,而精度为 5 且小数位为 0 时,表示的范围是 -99999 到 99999。实际上,你选择的精度通常会更高。

下面是如何在 pd.Series 中表示这一点的示例:

`pd.Series([     "123456789.123456789",     "-987654321.987654321",     "99999999.9999999999", ], dtype=pd.ArrowDtype(pa.decimal128(19, 10)))` 
`0     123456789.1234567890 1    -987654321.9876543210 2      99999999.9999999999 dtype: decimal128(19, 10)[pyarrow]` 

特别需要注意的是,我们将数据提供为字符串。如果我们一开始尝试提供浮点数数据,我们会立即看到精度丢失:

`pd.Series([     123456789.123456789,     -987654321.987654321,     99999999.9999999999, ], dtype=pd.ArrowDtype(pa.decimal128(19, 10)))` 
`0     123456789.1234567910 1    -987654321.9876543283 2     100000000.0000000000 dtype: decimal128(19, 10)[pyarrow]` 

这发生是因为 Python 默认使用浮点数存储实数,因此当语言运行时尝试解释你提供的数字时,四舍五入误差就会出现。根据你的平台,你甚至可能会发现 99999999.9999999999 == 100000000.0 返回 True。对于人类读者来说,这显然不是真的,但计算机存储的限制使得语言无法辨别这一点。

Python 解决这个问题的方法是使用 decimal 模块,确保不会发生四舍五入误差:

`import decimal decimal.Decimal("99999999.9999999999") == decimal.Decimal("100000000.0")` 
`False` 

同时,你仍然可以进行正确的算术运算,如下所示:

`decimal.Decimal("99999999.9999999999") + decimal.Decimal("100000000.0")` 
`Decimal('199999999.9999999999')` 

decimal.Decimal 对象在构建 PyArrow 十进制类型时也是有效的参数:

`pd.Series([     decimal.Decimal("123456789.123456789"),     decimal.Decimal("-987654321.987654321"),     decimal.Decimal("99999999.9999999999"), ], dtype=pd.ArrowDtype(pa.decimal128(19, 10)))` 
`0     123456789.1234567890 1    -987654321.9876543210 2      99999999.9999999999 dtype: decimal128(19, 10)[pyarrow]` 

还有更多内容……

pa.decimal128 数据类型最多支持 38 位有效数字。如果你需要更高的精度,Arrow 生态系统还提供了 pa.decimal256 数据类型:

`ser = pd.Series([     "123456789123456789123456789123456789.123456789" ], dtype=pd.ArrowDtype(pa.decimal256(76, 10))) ser` 
`0    123456789123456789123456789123456789.1234567890 dtype: decimal256(76, 10)[pyarrow]` 

只需注意,这将消耗是 pa.decimal128 数据类型两倍的内存,且可能会有更慢的计算时间。

NumPy 类型系统、对象类型和陷阱

如本章介绍所提到的,至少在 2.x 和 3.x 系列中,pandas 仍然默认使用对一般数据分析并不理想的数据类型。然而,你无疑会在同伴的代码或在线代码片段中遇到它们,因此理解它们的工作原理、潜在的陷阱以及如何避免它们,将在未来几年中变得非常重要。

如何做到这一点

让我们看一下从整数序列构造 pd.Series 的默认方式:

`pd.Series([0, 1, 2])` 
`0    0 1    1 2    2 dtype: int64` 

从这个参数开始,pandas 给我们返回了一个 pd.Series,它的 dtypeint64。这看起来很正常,那到底有什么问题呢?好吧,让我们看看引入缺失值时会发生什么:

`pd.Series([0, None, 2])` 
`0    0.0 1    NaN 2    2.0 dtype: float64` 

嗯?我们提供了整数数据,但现在却得到了浮点类型。指定 dtype= 参数肯定能帮我们解决这个问题吧:

`pd.Series([0, None, 2], dtype=int)` 
`TypeError: int() argument must be a string, a bytes-like object or a number, not 'NoneType'` 

无论你多么努力,你就是不能将缺失值与 pandas 默认返回的 NumPy 整数数据类型混合。解决这个模式的常见方法是,在使用 pd.Series.astype 转回实际整数数据类型之前,先用另一个值(如 0)填充缺失值:

`ser = pd.Series([0, None, 2]) ser.fillna(0).astype(int)` 
`0    0 1    0 2    2 dtype: int64` 

这解决了让我们得到正确整数类型的问题,但它必须改变数据才能做到这一点。是否在意这个变化是一个依赖于上下文的问题;有些用户如果只是想对这一列进行求和,可能会接受将缺失值当作 0 处理,但同样的用户可能不满意这种数据所产生的新计数平均值

请注意此fillna方法与本章开始时介绍的 pandas 扩展类型之间的区别:

`pd.Series([0, None, 2]).fillna(0).astype(int).mean()` 
`0.6666666666666666` 
`pd.Series([0, None, 2], dtype=pd.Int64Dtype()).mean()` 
`1.0` 

不仅得到了不同的结果,而且我们不使用 dtype=pd.Int64Dtype() 的方法需要更长时间来计算:

`import timeit func = lambda: pd.Series([0, None, 2]).fillna(0).astype(int).mean() timeit.timeit(func, number=10_000)` 
`0.9819313539992436` 
`func = lambda: pd.Series([0, None, 2], dtype=pd.Int64Dtype()).mean() timeit.timeit(func, number=10_000)` 
`0.6182142379984725` 

当你考虑到你必须经历的步骤,只为了获得整数而非浮点数时,这也许并不令人惊讶。

当你查看 pandas 中历史上的布尔数据类型时,事情变得更加怪异。让我们再次从看似合理的基本案例开始:

`pd.Series([True, False])` 
`0     True 1    False dtype: bool` 

让我们通过引入缺失值来打乱一些事情:

`pd.Series([True, False, None])` 
`0     True 1    False 2     None dtype: object` 

这是我们第一次看到 object 数据类型。撇开一些技术细节,你应该相信 object 数据类型是 pandas 中最差的数据类型之一。基本上,object 数据类型几乎可以存储任何内容;它完全禁止类型系统对你的数据进行任何强制要求。即使我们只是想存储 TrueFalse 的值,其中一些可能是缺失的,实际上任何有效的值都可以与这些值一起存储:

`pd.Series([True, False, None, "one of these things", ["is not like"], ["the other"]])` 
`0                   True 1                  False 2                   None 3    one of these things 4          [is not like] 5            [the other] dtype: object` 

所有这些混乱都可以通过使用 pd.BooleanDtype 来避免:

`pd.Series([True, False, None], dtype=pd.BooleanDtype())` 
`0     True 1    False 2     <NA> dtype: boolean` 

另一个相当不幸的事实是,默认的 pandas 实现(至少在 2.x 系列中)将 object 数据类型用于字符串:

`pd.Series(["foo", "bar", "baz"])` 
`0    foo 1    bar 2    baz dtype: object` 

再次强调,这里并没有严格强制要求我们必须拥有字符串数据:

`ser = pd.Series(["foo", "bar", "baz"]) ser.iloc[2] = 42 ser` 
`0    foo 1    bar 2     42 dtype: object` 

使用pd.StringDtype()时,这种类型的赋值将会引发错误:

`ser = pd.Series(["foo", "bar", "baz"], dtype=pd.StringDtype()) ser.iloc[2] = 42` 
`TypeError: Cannot set non-string value '42' into a StringArray.` 

还有更多……

在本章中,我们详细讨论了object数据类型缺乏类型强制的问题。另一方面,在某些使用场景中,拥有这种灵活性可能是有帮助的,尤其是在与 Python 对象交互时,在这种情况下,您无法事先对数据做出断言:

`alist = [42, "foo", ["sub", "list"], {"key": "value"}] ser = pd.Series(alist) ser` 
`0                  42 1                 foo 2         [sub, list] 3    {'key': 'value'} dtype: object` 

如果您曾经使用过类似 Microsoft Excel 的工具,您可能会觉得将任何值以几乎任何格式放入任何地方的想法并不新奇。另一方面,如果您的经验更多来自于使用 SQL 数据库,您可能会觉得将任何数据加载进来是一个陌生的概念。

在数据处理领域,主要有两种方法:提取、转换、加载ETL)和提取、加载、转换ELT)。ETL 要求您在将数据加载到数据分析工具之前先进行转换,这意味着所有清理工作必须提前在其他工具中完成。

ELT 方法允许您先加载数据,稍后再处理清理工作;object数据类型使您能够在 pandas 中使用 ELT 方法,如果您选择这样做的话。

话虽如此,我通常建议您将object数据类型严格用作staging数据类型,然后再将其转换为更具体的类型。通过避免使用object数据类型,您将获得更高的性能,更好地理解您的数据,并能够编写更简洁的代码。

在本章的最后一点,当您直接使用pd.Series构造函数并指定dtype=参数时,控制数据类型是相当容易的。虽然pd.DataFrame也有dtype=参数,但它不允许您为每一列指定类型,这意味着您通常会在创建pd.DataFrame时使用历史的 NumPy 数据类型:

`df = pd.DataFrame([     ["foo", 1, 123.45],     ["bar", 2, 333.33],     ["baz", 3, 999.99], ], columns=list("abc")) df` 
 `a     b   c 0   foo   1   123.45 1   bar   2   333.33 2   baz   3   999.99` 

检查pd.DataFrame.dtypes将帮助我们确认这一点:

`df.dtypes` 
`a     object b      int64 c    float64 dtype: object` 

为了让我们开始使用更理想的 pandas 扩展类型,我们可以显式使用pd.DataFrame.astype方法:

`df.astype({     "a": pd.StringDtype(),     "b": pd.Int64Dtype(),     "c": pd.Float64Dtype(), }).dtypes` 
`a    string[python] b             Int64 c           Float64 dtype: object` 

或者,我们可以使用pd.DataFrame.convert_dtypes方法并设置dtype_backend="numpy_nullable"

`df.convert_dtypes(dtype_backend="numpy_nullable").dtypes` 
`a    string[python] b             Int64 c           Float64 dtype: object` 

numpy_nullable这个术语在 pandas 的历史上有些不准确,但正如我们在介绍中提到的,它是后来被称为 pandas 扩展类型系统的原始名称。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

留下评论!

感谢您从 Packt 出版购买本书——我们希望您喜欢它!您的反馈非常宝贵,能够帮助我们改进和成长。读完本书后,请花一点时间在亚马逊上留下评论;这只需要一分钟,但对像您这样的读者来说,意义重大。

扫描下面的二维码,领取您选择的免费电子书。

packt.link/NzOWQ

第四章:pandas I/O 系统

到目前为止,我们一直在用数据创建pd.Seriespd.DataFrame对象,内联处理数据。虽然这样做有助于建立理论基础,但在生产代码中,很少有用户会这样做。相反,用户会使用 pandas 的 I/O 函数来从各种格式读取/写入数据。

I/O,指的是输入/输出,通常指从常见的数据格式(如 CSV、Microsoft Excel、JSON 等)中读取和写入数据的过程。当然,数据存储并不是只有一种格式,许多选项在性能、存储大小、第三方集成、可访问性和/或普及性之间进行权衡。有些格式假设数据是结构化且严格定义的(SQL 可能是最极端的例子),而其他格式则可以用于表示半结构化数据,这些数据不局限于二维结构(JSON 就是一个很好的例子)。

pandas 能够与多种数据格式进行交互,这也是它的最大优势之一,使得 pandas 成为数据分析工具中的瑞士军刀。无论是与 SQL 数据库、Microsoft Excel 文件集、HTML 网页,还是通过 JSON 传输数据的 REST API 端点交互,pandas 都能够胜任帮助你构建数据的统一视图。因此,pandas 被认为是 ETL 领域中的一个流行工具。

在本章中,我们将介绍以下操作方法:

  • CSV – 基本的读写操作

  • CSV – 读取大型文件的策略

  • Microsoft Excel – 基本的读写操作

  • Microsoft Excel – 在非默认位置查找表格

  • Microsoft Excel – 层次化数据

  • 使用 SQLAlchemy 的 SQL

  • 使用 ADBC 的 SQL

  • Apache Parquet

  • JSON

  • HTML

  • Pickle

  • 第三方 I/O 库

CSV – 基本的读写操作

CSV,代表逗号分隔值,是最常见的数据交换格式之一。虽然没有正式的标准来定义什么是 CSV 文件,但大多数开发者和用户通常认为它是一个纯文本文件,其中文件中的每一行表示一条数据记录,每条记录的字段之间有分隔符,用于表示一条记录的结束和下一条记录的开始。最常用的分隔符是逗号(因此叫做逗号分隔值),但这并不是硬性要求;有时我们也会看到使用管道符(|)、波浪符(~)或反引号(`)作为分隔符的 CSV 文件。如果期望分隔符字符出现在某条记录内,通常会对单个记录(或所有记录)加上引号,以确保正确解析。

例如,假设一个 CSV 文件使用管道分隔符,其内容如下:

`column1|column2 a|b|c` 

第一行将只读取两列数据,而第二行将包含三列数据。假设我们希望记录 ["a|b", "c"] 出现在第二行,就需要进行适当的引号处理:

`column1|column2 "a|b"|c` 

上述规则相对简单,可以轻松地写入 CSV 文件,但反过来这也使得读取 CSV 文件变得更加困难。CSV 格式没有提供元数据(例如,什么分隔符、引号规则等),也没有提供关于数据类型的任何信息(例如,哪些数据应位于 X 列)。这使得 CSV 读取器必须自己搞清楚这些内容,从而增加了性能开销,并且很容易导致数据误解。作为一种基于文本的格式,与像 Apache Parquet 这样的二进制格式相比,CSV 也是一种低效的数据存储方式。通过压缩 CSV 文件(以牺牲读/写性能为代价),可以在一定程度上弥补这些问题,但通常来说,CSV 在 CPU 效率、内存使用和无损性方面是最差的格式之一。

尽管存在这些缺点,CSV 格式已经存在很长时间,并且不会很快消失,因此了解如何使用 pandas 读取和写入此类文件是很有帮助的。

如何做到这一点

让我们从一个简单的 pd.DataFrame 开始。基于我们在第三章中学到的数据类型,我们知道 pandas 默认使用的数据类型并不理想,因此我们将使用 pd.DataFrame.convert_dtypes 方法,并使用 dtype_backend="numpy_nullable" 参数来构建这个以及以后所有的 pd.DataFrame 对象。

`df = pd.DataFrame([     ["Paul", "McCartney", 1942],     ["John", "Lennon", 1940],     ["Richard", "Starkey", 1940],     ["George", "Harrison", 1943], ], columns=["first", "last", "birth"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `first   last       birth 0       Paul    McCartney  1942 1       John    Lennon     1940 2       Richard Starkey    1940 3       George  Harrison   1943` 

要将这个 pd.DataFrame 写入到 CSV 文件中,我们可以使用 pd.DataFrame.to_csv 方法。通常,您提供的第一个参数是文件名,但在这个例子中,我们将使用 io.StringIO 对象来代替。io.StringIO 对象类似于一个文件,但不会将任何内容保存到磁盘上。相反,它完全在内存中管理文件内容,无需清理,也不会在文件系统中留下任何东西:

`import io buf = io.StringIO() df.to_csv(buf) print(buf.getvalue())` 
`,first,last,birth 0,Paul,McCartney,1942 1,John,Lennon,1940 2,Richard,Starkey,1940 3,George,Harrison,1943` 

现在我们有了一个包含 CSV 数据的“文件”,我们可以使用 pd.read_csv 函数将这些数据读回来。然而,默认情况下,pandas 中的 I/O 函数将使用与 pd.DataFrame 构造函数相同的默认数据类型。幸运的是,我们仍然可以使用 dtype_backend="numpy_nullable" 参数与 I/O 读取函数一起使用,从而避免这个问题:

`buf.seek(0) pd.read_csv(buf, dtype_backend="numpy_nullable")` 
 `Unnamed: 0   first    last       birth 0    0            Paul     McCartney  1942 1    1            John     Lennon     1940 2    2            Richard  Starkey    1940 3    3            George   Harrison   1943` 

有趣的是,pd.read_csv 的结果并不完全与我们最初的 pd.DataFrame 匹配,因为它包含了一个新增的 Unnamed: 0 列。当你调用 pd.DataFrame.to_csv 时,它会将行索引和列一起写入到 CSV 文件中。CSV 格式不允许你存储任何额外的元数据来指示哪些列应映射到行索引,哪些列应表示 pd.DataFrame 中的列,因此 pd.read_csv 假设所有内容都是列。

您可以通过让 pd.read_csv 知道 CSV 文件中的第一列数据应该形成行索引,并使用 index_col=0 参数来纠正这种情况:

`buf.seek(0) pd.read_csv(buf, dtype_backend="numpy_nullable", index_col=0)` 
 `first    last       birth 0     Paul     McCartney  1942 1     John     Lennon     1940 2     Richard  Starkey    1940 3     George   Harrison   1943` 

或者,您可以通过 pd.DataFrame.to_csvindex=False 参数避免一开始就写入索引:

`buf = io.StringIO() df.to_csv(buf, index=False) print(buf.getvalue())` 
`first,last,birth Paul,McCartney,1942 John,Lennon,1940 Richard,Starkey,1940 George,Harrison,1943` 

还有更多……

正如本节开头提到的,CSV 文件使用引号来防止字段中出现的分隔符与其预期用途(即指示新记录的开始)混淆。幸运的是,pandas 默认以一种相当理智的方式处理这一点,我们可以通过一些新示例数据来看到这一点:

`df = pd.DataFrame([     ["McCartney, Paul", 1942],     ["Lennon, John", 1940],     ["Starkey, Richard", 1940],     ["Harrison, George", 1943], ], columns=["name", "birth"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `name               birth 0    McCartney, Paul    1942 1    Lennon, John       1940 2    Starkey, Richard   1940 3    Harrison, George   1943` 

现在我们只有一个包含逗号的name列,可以看到 pandas 会对该字段加上引号,表示逗号的使用是数据的一部分,而不是新记录的开始:

`buf = io.StringIO() df.to_csv(buf, index=False) print(buf.getvalue())` 
`name,birth "McCartney, Paul",1942 "Lennon, John",1940 "Starkey, Richard",1940 "Harrison, George",1943` 

我们也可以选择使用不同的分隔符,这可以通过 sep= 参数进行切换:

`buf = io.StringIO() df.to_csv(buf, index=False, sep="|") print(buf.getvalue())` 
`name|birth McCartney, Paul|1942 Lennon, John|1940 Starkey, Richard|1940 Harrison, George|1943` 

我们还提到,尽管 CSV 文件本质上是纯文本格式,您也可以通过压缩它们来节省存储空间。最简单的方法是提供带有常见压缩文件扩展名的文件名参数,例如通过 df.to_csv("data.csv.zip")。如果需要更明确的控制,您可以使用 compression= 参数。

为了看到这一点的实际效果,让我们使用一个更大的 pd.DataFrame

`df = pd.DataFrame({     "col1": ["a"] * 1_000,     "col2": ["b"] * 1_000,     "col3": ["c"] * 1_000, }) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.head()` 
 `col1     col2    col3 0      a        b       c 1      a        b       c 2      a        b       c 3      a        b       c 4      a        b       c` 

请注意将文件写出为纯文本 CSV 文件时所使用的字节数:

`buf = io.StringIO() df.to_csv(buf, index=False) len(buf.getvalue())` 
`6015` 

使用 compression="gzip",我们可以生成一个占用存储空间更少的文件:

`buf = io.BytesIO() df.to_csv(buf, index=False, compression="gzip") len(buf.getvalue())` 
`69` 

这里的权衡是,虽然压缩文件需要更少的磁盘存储空间,但它们需要更多的 CPU 工作来压缩或解压缩文件内容。

CSV – 读取大文件的策略

处理大型 CSV 文件可能具有挑战性,尤其是在它们耗尽计算机内存时。在许多现实世界的数据分析场景中,您可能会遇到无法通过单次读取操作处理的数据集。这可能导致性能瓶颈和 MemoryError 异常,使分析变得困难。不过,不必担心!有很多方法可以提高处理文件的效率。

在这个例子中,我们将展示如何使用 pandas 查看 CSV 文件的部分内容,以了解正在推断的数据类型。通过这个理解,我们可以指示 pd.read_csv 使用更高效的数据类型,从而大大提高内存使用效率。

如何操作

在这个例子中,我们将查看钻石数据集。这个数据集对于现代计算机来说并不算特别大,但让我们假设这个文件比实际要大得多,或者假设我们的机器内存有限,以至于正常的 read_csv 调用会引发 MemoryError

首先,我们将查看数据集中的前 1,000 行,通过 nrows=1_000 来了解文件中的内容:

`df = pd.read_csv("data/diamonds.csv", dtype_backend="numpy_nullable", nrows=1_000) df` 
 `carat  cut      color  clarity  depth  table  price  x      y      z 0  0.23   Ideal    E      SI2      61.5   55.0   326    3.95   3.98   2.43 1  0.21   Premium  E      SI1      59.8   61.0   326    3.89   3.84   2.31 2  0.23   Good     E      VS1      56.9   65.0   327    4.05   4.07   2.31 3  0.29   Premium  I      VS2      62.4   58.0   334    4.2    4.23   2.63 4  0.31   Good     J      SI2      63.3   58.0   335    4.34   4.35   2.75 …  …      …        …      …        …      …      …      …      …      … 995  0.54   Ideal    D    VVS2    61.4    52.0   2897   5.3    5.34   3.26 996  0.72   Ideal    E    SI1     62.5    55.0   2897   5.69   5.74   3.57 997  0.72   Good     F    VS1     59.4    61.0   2897   5.82   5.89   3.48 998  0.74   Premium  D    VS2     61.8    58.0   2897   5.81   5.77   3.58 999  1.12   Premium  J    SI2     60.6    59.0   2898   6.68   6.61   4.03 1000 rows × 10 columns` 

pd.DataFrame.info 方法应该能让我们了解这个子集使用了多少内存:

`df.info()` 
`<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 10 columns): #   Column   Non-Null Count  Dtype  ---  ------   --------------  -----  0   carat    1000 non-null   Float64 1   cut      1000 non-null   string 2   color    1000 non-null   string 3   clarity  1000 non-null   string 4   depth    1000 non-null   Float64 5   table    1000 non-null   Float64 6   price    1000 non-null   Int64  7   x        1000 non-null   Float64 8   y        1000 non-null   Float64 9   z        1000 non-null   Float64 dtypes: Float64(6), Int64(1), string(3) memory usage: 85.1 KB` 

您看到的具体内存使用量可能取决于您使用的 pandas 版本和操作系统,但假设我们使用的 pd.DataFrame 大约需要 85 KB 的内存。如果我们有 10 亿行数据,而不是只有 1,000 行,那仅仅存储这个 pd.DataFrame 就需要 85 GB 的内存。

那么我们如何解决这个问题呢?首先,值得更仔细地查看已经推断出的数据类型。price列可能是一个立即引起我们注意的列;它被推断为pd.Int64Dtype(),但我们很可能不需要 64 位来存储这些信息。关于汇总统计的更多细节将在第五章中讨论,算法及其应用,但现在,让我们先看看pd.Series.describe,看看 pandas 可以为我们提供关于这个列的信息:

`df["price"].describe()` 
`count       1000.0 mean       2476.54 std      839.57562 min          326.0 25%         2777.0 50%         2818.0 75%         2856.0 max         2898.0 Name: price, dtype: Float64` 

最小值为 326,最大值为 2,898。这些值可以安全地适配pd.Int16Dtype(),与pd.Int64Dtype()相比,这将节省大量内存。

让我们还来看看一些浮点类型,从指数开始:

`df["carat"].describe()` 
`count      1000.0 mean      0.68928 std      0.195291 min           0.2 25%           0.7 50%          0.71 75%          0.79 max          1.27 Name: carat, dtype: Float64` 

这些值的范围从 0.2 到 1.27,除非我们预计要进行许多小数点计算,否则 32 位浮点数据类型提供的 6 到 9 位小数精度应该足够使用。

对于这个配方,我们假设 32 位浮动类型可以用于所有其他浮动类型。告诉pd.read_csv我们希望使用更小的数据类型的一个方法是使用dtype=参数,并通过字典将列名映射到所需的数据类型。由于我们的dtype=参数将覆盖所有列,因此我们也可以省略dtype_backend="numpy_nullable",因为它是多余的:

`df2 = pd.read_csv(     "data/diamonds.csv",     nrows=1_000,     dtype={         "carat": pd.Float32Dtype(),         "cut": pd.StringDtype(),         "color": pd.StringDtype(),         "clarity": pd.StringDtype(),         "depth": pd.Float32Dtype(),         "table": pd.Float32Dtype(),         "price": pd.Int16Dtype(),         "x": pd.Float32Dtype(),         "y": pd.Float32Dtype(),         "z": pd.Float32Dtype(),     } ) df2.info()` 
`<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 10 columns): #   Column   Non-Null Count  Dtype  ---  ------   --------------  -----  0   carat    1000 non-null   Float32 1   cut      1000 non-null   string 2   color    1000 non-null   string 3   clarity  1000 non-null   string 4   depth    1000 non-null   Float32 5   table    1000 non-null   Float32 6   price    1000 non-null   Int16  7   x        1000 non-null   Float32 8   y        1000 non-null   Float32 9   z        1000 non-null   Float32 dtypes: Float32(6), Int16(1), string(3) memory usage: 55.8 KB` 

仅仅这些步骤可能就会将内存使用量减少到大约 55KB,相较于最初的 85KB,已经是一个不错的减少!为了更安全起见,我们可以使用pd.DataFrame.describe()方法获取汇总统计信息,并确保这两个pd.DataFrame对象相似。如果这两个pd.DataFrame对象的数字相同,那是一个很好的迹象,表明我们的转换没有实质性地改变数据:

`df.describe()` 
 `carat    depth    table     price      x         y         z count   1000.0   1000.0	  1000.0    1000.0     1000.0    1000.0    1000.0 mean    0.68928  61.7228  57.7347   2476.54    5.60594   5.59918   3.45753 std     0.195291 1.758879 2.467946  839.57562  0.625173  0.611974  0.389819 min     0.2      53.0     52.0      326.0      3.79      3.75      2.27 25%     0.7      60.9     56.0      2777.0     5.64      5.63      3.45 50%     0.71     61.8     57.0      2818.0     5.77      5.76      3.55 75%     0.79     62.6     59.0      2856.0     5.92      5.91      3.64 max     1.27     69.5     70.0      2898.0     7.12      7.05      4.33` 
`df2.describe()` 
 `carat    depth     table      price      x        y        z count  1000.0   1000.0    1000.0     1000.0     1000.0   1000.0   1000.0 mean   0.68928  61.722801 57.734699  2476.54    5.60594  5.59918  3.45753 std    0.195291 1.758879  2.467946   839.57562  0.625173 0.611974 0.389819 min    0.2      53.0      52.0       326.0      3.79     3.75     2.27 25%    0.7      60.900002 56.0       2777.0     5.64     5.63     3.45 50%    0.71     61.799999 57.0       2818.0     5.77     5.76     3.55 75%    0.79     62.599998 59.0       2856.0     5.92     5.91     3.64 max    1.27     69.5      70.0       2898.0     7.12     7.05     4.33` 

到目前为止,一切看起来不错,但我们仍然可以做得更好。首先,似乎cut列有相对较少的唯一值:

`df2["cut"].unique()` 
`<StringArray> ['Ideal', 'Premium', 'Good', 'Very Good', 'Fair'] Length: 5, dtype: string` 

color列也可以说同样的事情:

`df2["color"].unique()` 
`<StringArray> ['E', 'I', 'J', 'H', 'F', 'G', 'D'] Length: 7, dtype: string` 

以及clarity列:

`df2["clarity"].unique()` 
`<StringArray> ['SI2', 'SI1', 'VS1', 'VS2', 'VVS2', 'VVS1', 'I1', 'IF'] Length: 8, dtype: string` 

从 1,000 行抽样数据来看,cut列有 5 个不同的值,color列有 7 个不同的值,clarity列有 8 个不同的值。我们认为这些列具有低基数,即与行数相比,独特值的数量非常少。

这使得这些列非常适合使用分类类型。然而,我建议不要将pd.CategoricalDtype()作为dtype=的参数,因为默认情况下,它使用np.nan作为缺失值指示符(如果你需要回顾这个警告,可以重新查看在第三章中提到的分类类型配方)。相反,最佳的做法是首先将列读取为pd.StringDtype(),然后在适当的列上使用pd.DataFrame.astype

`df3 = pd.read_csv(     "data/diamonds.csv",     nrows=1_000,     dtype={         "carat": pd.Float32Dtype(),         "cut": pd.StringDtype(),         "color": pd.StringDtype(),         "clarity": pd.StringDtype(),         "depth": pd.Float32Dtype(),         "table": pd.Float32Dtype(),         "price": pd.Int16Dtype(),         "x": pd.Float32Dtype(),         "y": pd.Float32Dtype(),         "z": pd.Float32Dtype(),     } ) cat_cols = ["cut", "color", "clarity"] df3[cat_cols] = df3[cat_cols].astype(pd.CategoricalDtype()) df3.info()` 
`<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 10 columns): #   Column   Non-Null Count  Dtype   ---  ------   --------------  -----   0   carat    1000 non-null   Float32 1   cut      1000 non-null   category 2   color    1000 non-null   category 3   clarity  1000 non-null   category 4   depth    1000 non-null   Float32 5   table    1000 non-null   Float32 6   price    1000 non-null   Int16   7   x        1000 non-null   Float32 8   y        1000 non-null   Float32 9   z        1000 non-null   Float32 dtypes: Float32(6), Int16(1), category(3) memory usage: 36.2 KB` 

为了进一步节省内存,我们可能会决定不读取 CSV 文件中的某些列。如果希望 pandas 跳过这些数据以节省更多内存,可以使用usecols=参数:

`dtypes = {  # does not include x, y, or z     "carat": pd.Float32Dtype(),     "cut": pd.StringDtype(),     "color": pd.StringDtype(),     "clarity": pd.StringDtype(),     "depth": pd.Float32Dtype(),     "table": pd.Float32Dtype(),     "price": pd.Int16Dtype(), } df4 = pd.read_csv(     "data/diamonds.csv",     nrows=1_000,     dtype=dtypes,     usecols=dtypes.keys(), ) cat_cols = ["cut", "color", "clarity"] df4[cat_cols] = df4[cat_cols].astype(pd.CategoricalDtype()) df4.info()` 
`<class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 7 columns): #   Column   Non-Null Count  Dtype   ---  ------   --------------  -----   0   carat    1000 non-null   Float32 1   cut      1000 non-null   category 2   color    1000 non-null   category 3   clarity  1000 non-null   category 4   depth    1000 non-null   Float32 5   table    1000 non-null   Float32 6   price    1000 non-null   Int16   dtypes: Float32(3), Int16(1), category(3) memory usage: 21.5 KB` 

如果前面的步骤不足以创建足够小的pd.DataFrame,你可能还是有机会的。如果你可以一次处理一部分数据,而不需要将所有数据都加载到内存中,你可以使用chunksize=参数来控制从文件中读取数据块的大小:

`dtypes = {  # does not include x, y, or z     "carat": pd.Float32Dtype(),     "cut": pd.StringDtype(),     "color": pd.StringDtype(),     "clarity": pd.StringDtype(),     "depth": pd.Float32Dtype(),     "table": pd.Float32Dtype(),     "price": pd.Int16Dtype(), } df_iter = pd.read_csv(     "data/diamonds.csv",     nrows=1_000,     dtype=dtypes,     usecols=dtypes.keys(),     chunksize=200 ) for df in df_iter:     cat_cols = ["cut", "color", "clarity"]     df[cat_cols] = df[cat_cols].astype(pd.CategoricalDtype())     print(f"processed chunk of shape {df.shape}")` 
`processed chunk of shape (200, 7) processed chunk of shape (200, 7) processed chunk of shape (200, 7) processed chunk of shape (200, 7) processed chunk of shape (200, 7)` 

还有更多...

这里介绍的usecols参数也可以接受一个可调用对象,当它在每个遇到的列标签上求值时,如果该列应被读取,则返回True,如果应跳过,则返回False。如果我们只想读取caratcutcolorclarity列,可能会像这样:

`def startswith_c(column_name: str) -> bool:     return column_name.startswith("c") pd.read_csv(     "data/diamonds.csv",     dtype_backend="numpy_nullable",     usecols=startswith_c, )` 
 `carat   cut       color  clarity 0      0.23    Ideal     E      SI2 1      0.21    Premium   E      SI1 2      0.23    Good      E      VS1 3      0.29    Premium   I      VS2 4      0.31    Good      J      SI2 …      …       …         …      … 53935  0.72    Ideal     D      SI1 53936  0.72    Good      D      SI1 53937  0.7     Very Good D      SI1 53938  0.86    Premium   H      SI2 53939  0.75    Ideal     D      SI2 53940 rows × 4 columns` 

Microsoft Excel – 基本的读写操作

Microsoft Excel 是一个极其流行的数据分析工具,因其易用性和普及性。Microsoft Excel 提供了一个相当强大的工具包,帮助清洗、转换、存储和可视化数据,而无需了解编程语言。许多成功的分析师可能会认为它是他们永远需要的唯一工具。尽管如此,Microsoft Excel 在性能和可扩展性上确实存在困难,作为存储介质时,它甚至可能在意想不到的方式上改变你的数据。

如果你以前使用过 Microsoft Excel,现在开始学习 pandas,你会发现 pandas 是一个互补工具。使用 pandas 时,你将放弃 Microsoft Excel 的点选操作便捷性,但你将轻松解锁性能,超越 Microsoft Excel 的限制。

在我们进入这个方法之前,值得注意的是,Microsoft Excel 支持并不是 pandas 的一部分,因此你需要安装第三方包来使这些方法生效。虽然这不是唯一的选择,但鼓励用户安装openpyxl,因为它非常适合读取和写入各种 Microsoft Excel 格式。如果你还没有安装openpyxl,可以通过以下命令进行安装:

`python -m pip install openpyxl` 

如何做到这一点

让我们再次从一个简单的pd.DataFrame开始:

`df = pd.DataFrame([     ["Paul", "McCartney", 1942],     ["John", "Lennon", 1940],     ["Richard", "Starkey", 1940],     ["George", "Harrison", 1943], ], columns=["first", "last", "birth"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `first     last       birth 0    Paul      McCartney  1942 1    John      Lennon     1940 2    Richard   Starkey    1940 3    George    Harrison   1943` 

你可以使用pd.DataFrame.to_excel方法将其写入文件,通常第一个参数是文件名,例如myfile.xlsx,但在这里,我们将再次使用io.BytesIO,它像文件一样工作,但将二进制数据存储在内存中,而不是磁盘上:

`import io buf = io.BytesIO() df.to_excel(buf)` 

对于读取操作,使用pd.read_excel函数。我们将继续使用dtype_backend="numpy_nullable",以防止 pandas 执行默认的类型推断:

`buf.seek(0) pd.read_excel(buf, dtype_backend="numpy_nullable")` 
 `Unnamed: 0   first   last       birth 0    0            Paul    McCartney  1942 1    1            John    Lennon     1940 2    2            Richard Starkey    1940 3    3            George  Harrison   1943` 

许多函数参数与 CSV 共享。为了去除上面提到的Unnamed: 0列,我们可以指定index_col=参数:

`buf.seek(0) pd.read_excel(buf, dtype_backend="numpy_nullable", index_col=0)` 
 `first    last       birth 0    Paul     McCartney  1942 1    John     Lennon     1940 2    Richard  Starkey    1940 3    George   Harrison   1943` 

或者选择根本不写索引:

`buf = io.BytesIO() df.to_excel(buf, index=False) buf.seek(0) pd.read_excel(buf, dtype_backend="numpy_nullable")` 
 `first     last       birth 0    Paul      McCartney  1942 1    John      Lennon     1940 2    Richard   Starkey    1940 3    George    Harrison   1943` 

数据类型可以通过dtype=参数进行控制:

`buf.seek(0) dtypes = {     "first": pd.StringDtype(),     "last": pd.StringDtype(),     "birth": pd.Int16Dtype(), } df = pd.read_excel(buf, dtype=dtypes) df.dtypes` 
`first    string[python] last     string[python] birth             Int16 dtype: object` 

Microsoft Excel – 在非默认位置查找表格

在上一个教程中,Microsoft Excel – 基本读写,我们使用了 Microsoft Excel 的 I/O 函数,而没有考虑数据在工作表中的位置。默认情况下,pandas 会从/写入数据的第一个工作表的第一个单元格开始读取,但通常会收到数据位于文档其他位置的 Microsoft Excel 文件。

对于这个示例,我们有一个 Microsoft Excel 工作簿,其中第一个选项卡Sheet1用作封面页:

计算机的屏幕截图  自动生成描述

图 4.1:Sheet1 中不包含有用数据的工作簿

第二个选项卡是我们有用的信息所在:

计算机的屏幕截图

图 4.2:另一个工作表包含相关数据的工作簿

如何操作

为了仍然能够读取这些数据,您可以使用pd.read_excelsheet_name=skiprows=usecols=参数的组合:

`pd.read_excel(     "data/beatles.xlsx",     dtype_backend="numpy_nullable",     sheet_name="the_data",     skiprows=4,     usecols="C:E", )` 
 `first     last       birth 0    Paul      McCartney  1942 1    John      Lennon     1940 2    Richard   Starkey    1940 3    George    Harrison   1943` 

通过传递sheet_name="the_data"pd.read_excel函数能够准确定位 Microsoft Excel 文件中要开始查找数据的特定工作表。或者,我们也可以使用sheet_name=1按选项卡位置搜索。在找到正确的工作表后,pandas 查看skiprows=参数,并知道要忽略工作表上的第 1-4 行。然后查看usecols=参数,仅选择 C 到 E 列。

还有更多…

我们可以提供我们想要的标签,而不是usecols="C:E"

`pd.read_excel(     "data/beatles.xlsx",     dtype_backend="numpy_nullable",     sheet_name="the_data",     skiprows=4,     usecols=["first", "last", "birth"], )` 
 `first    last      birth 0    Paul     McCartney 1942 1    John     Lennon    1940 2    Richard  Starkey   1940 3    George   Harrison  1943` 

将这样的参数传递给usecols=是在处理 CSV 格式时选择文件中特定列的要求。然而,pandas 在读取 Microsoft Excel 文件时提供了特殊行为,允许像"C:E""C,D,E"这样的字符串引用列。

微软 Excel – 分层数据

数据分析的主要任务之一是将非常详细的信息汇总为易于消化的摘要。大多数公司的高管不想要翻阅成千上万的订单,他们只想知道,“过去 X 个季度我的销售情况如何?”

使用 Microsoft Excel,用户通常会在类似图 4.3所示的视图中总结这些信息,该视图代表了行上的区域/子区域层次结构和列上的年份/季度

电子表格的屏幕截图

图 4.3:具有分层数据的工作簿 – 按区域和季度销售

尽管这个总结似乎并不太离谱,但许多分析工具很难正确呈现这种类型的信息。以传统的 SQL 数据库为例,没有直接的方法来在表中表示这种Year/Quarter层次结构 - 您唯一的选择是将所有层次结构字段连接在一起,并生成像2024/Q12024/Q22025/Q12025/Q2这样的列。虽然这样可以轻松选择任何单独的列,但您失去了轻松选择诸如“所有 2024 年销售额”之类的内容而不需要额外努力的能力。

幸运的是,pandas 可以比 SQL 数据库更理智地处理这个问题,直接支持行和列索引中的这种层次关系。如果您回忆起第二章选择和赋值,我们介绍了pd.MultiIndex;能够保持这些关系使用户能够高效地从任何层次的层次结构中进行选择。

如何做到这一点

仔细检查图 4.3,您会看到第 1 行和第 2 行包含标签YearQuarter,这些标签可以形成我们想要在pd.DataFrame的列中形成的pd.MultiIndex的级别。Microsoft Excel 使用每行基于 1 的编号,因此行[1, 2]转换为 Python 实际上是[0, 1];我们将使用这个作为我们的header=参数,以确立我们希望前两行形成我们的列pd.MultiIndex

将我们的注意力转向 Microsoft Excel 中的 A 列和 B 列,我们现在可以看到标签RegionSub-Region,这将帮助我们在行中塑造pd.MultiIndex。回到CSV - 基本读取/写入部分,我们介绍了index_col=参数,它可以告诉 pandas 实际上应该使用哪些列数据来生成行索引。Microsoft Excel 文件中的 A 列和 B 列代表第一列和第二列,因此我们可以再次使用[0, 1]来告诉 pandas 我们的意图:

`df = pd.read_excel(     "data/hierarchical.xlsx",     dtype_backend="numpy_nullable",     index_col=[0, 1],     header=[0, 1], ) df` 
 `Year    2024            2025         Quarter Q1      Q2      Q1      Q2 Region  Sub-Region America East    1       2       4       8         West    16      32      64      128         South   256     512     1024    4096 Europe  West    8192    16384   32768   65536         East    131072  262144  524288  1048576` 

大功告成!我们成功读取了数据并保持了行和列的层次结构,这使我们可以使用所有原生 pandas 功能从这些数据中进行选择,甚至回答诸如“每个东部子区域的 Q2 业绩在年度上看起来如何?”这样的问题。

`df.loc[(slice(None), "East"), (slice(None), "Q2")]` 
 `Year    2024    2025         Quarter Q2      Q2 Region  Sub-Region America East    2       8 Europe  East    262144  1048576` 

使用 SQLAlchemy 的 SQL

pandas 库提供了与 SQL 数据库交互的强大功能,使您可以直接在关系数据库中存储的数据上进行数据分析。

当然,存在着无数的数据库(而且还会有更多!),每个数据库都有自己的特点、认证方案、方言和怪癖。为了与它们交互,pandas 依赖于另一个伟大的 Python 库 SQLAlchemy,它在核心上充当 Python 和数据库世界之间的桥梁。理论上,pandas 可以与 SQLAlchemy 可以连接的任何数据库一起使用。

要开始,您应该首先将 SQLAlchemy 安装到您的环境中:

`python -m pip install sqlalchemy` 

SQLAlchemy 支持所有主要的数据库,如 MySQL、PostgreSQL、MS SQL Server 等,但设置和正确配置这些数据库本身需要不少努力,这部分内容超出了本书的范围。为了尽可能简化,我们将专注于使用 SQLite 作为我们的数据库,因为它不需要任何设置,且可以完全在计算机内存中运行。一旦你熟悉了 SQLite 的使用,你只需更改连接的凭据来指向目标数据库;否则,所有功能保持不变。

操作步骤

我们需要做的第一件事是创建一个 SQLAlchemy 引擎,使用sa.create_engine。这个函数的参数是一个 URL,取决于你试图连接的数据库(更多信息请参见 SQLAlchemy 文档)。在这些示例中,我们将使用内存中的 SQLite:

`import sqlalchemy as sa engine = sa.create_engine("sqlite:///:memory:")` 

使用pd.DataFrame.to_sql方法,你可以将一个现有的pd.DataFrame写入数据库表中。第一个参数是你想创建的表的名称,第二个参数是引擎/可连接对象:

`df = pd.DataFrame([     ["dog", 4],     ["cat", 4], ], columns=["animal", "num_legs"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.to_sql("table_name", engine, index=False)` 
`2` 

pd.read_sql函数可以用于反向操作,从数据库表中读取数据:

`pd.read_sql("table_name", engine, dtype_backend="numpy_nullable")` 
 `animal    num_legs 0    dog       4 1    cat       4` 

另外,如果你想要的不是仅仅复制表,而是某个不同的内容,你可以将 SQL 查询传递给pd.read_sql

`pd.read_sql(     "SELECT SUM(num_legs) AS total_legs FROM table_name",     engine,     dtype_backend="numpy_nullable" )` 
 `total_legs 0     8` 

当数据库中已经存在表时,再次向同一表写入数据将会引发错误。你可以传递if_exists="replace"来覆盖此行为并替换表:

`df = pd.DataFrame([     ["dog", 4],     ["cat", 4],     ["human", 2], ], columns=["animal", "num_legs"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.to_sql("table_name", engine, index=False, if_exists="replace")` 
`3` 

你还可以使用if_exists="append"将数据添加到表中:

`new_data = pd.DataFrame([["centipede", 100]], columns=["animal", "num_legs"]) new_data.to_sql("table_name", engine, index=False, if_exists="append") pd.read_sql("table_name", engine, dtype_backend="numpy_nullable")` 
 `animal     num_legs 0    dog        4 1    cat        4 2    human      2 3    centipede  100` 

绝大部分繁重的工作都由 SQLAlchemy 引擎在幕后完成,该引擎使用dialect+driver://username:password@host:port/database形式的 URL 构建。并非所有的字段都是必需的——该字符串会根据你使用的数据库及其配置有所不同。

在我们的具体示例中,sa.create_engine("sqlite:///:memory:")在我们计算机的内存空间中创建并连接到一个 SQLite 数据库。这个特性是 SQLite 特有的;我们也可以传递一个文件路径,比如sa.create_engine("sqlite:///tmp/adatabase.sql"),而不是使用:memory:

欲了解更多关于 SQLAlchemy URL 的信息,以及如何搭配其他数据库使用驱动程序,请参考 SQLAlchemy 的后端特定 URL 文档。

使用 ADBC 的 SQL

尽管使用 SQLAlchemy 连接数据库是一种可行的选择,并且多年来一直帮助着 pandas 的用户,但来自 Apache Arrow 项目的一项新技术已经出现,它能进一步扩展 SQL 交互。这项新技术被称为Arrow 数据库连接,简称ADBC。从 2.2 版本开始,pandas 增加了对使用 ADBC 驱动程序与数据库交互的支持。

使用 ADBC 在与 SQL 数据库交互时,比上述基于 SQLAlchemy 的方法能提供更好的性能和类型安全。权衡之下,SQLAlchemy 支持更多的数据库,因此根据你的数据库,SQLAlchemy 可能是唯一的选择。ADBC 会记录其驱动实现状态;我建议在回退到 SQLAlchemy 之前,首先查看该记录,以确保你所使用的数据库有一个稳定的驱动实现。

和上一节一样,我们将使用 SQLite 作为数据库,因为它易于设置和配置。请确保为 SQLite 安装适当的 ADBC Python 包:

`python -m pip install adbc-driver-sqlite` 

如何实现

首先,我们从我们的 SQLite ADBC 驱动中导入 dbapi 对象,并创建一些示例数据:

`from adbc_driver_sqlite import dbapi df = pd.DataFrame([     ["dog", 4],     ["cat", 4],     ["human", 2], ], columns=["animal", "num_legs"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `animal   num_legs 0     dog      4 1     cat      4 2     human    2` 

术语 dbapi 来自 Python 数据库 API 规范(PEP-249),该规范标准化了 Python 模块和库如何与数据库交互。调用 .connect 方法并提供凭据是 Python 中打开数据库连接的标准化方式。我们将再次通过 dbapi.connect("file::memory:") 使用内存中的 SQLite 应用程序。

通过在 Python 中使用 with ... as: 语法来使用上下文管理器,我们可以连接到一个数据库,并将其赋值给一个变量,这样 Python 就会在块执行完毕后自动清理连接。在块内连接处于打开状态时,我们可以使用 pd.DataFrame.to_sql / pd.read_sql 分别向数据库写入和从数据库读取数据:

`with dbapi.connect("file::memory:") as conn:     df.to_sql("table_name", conn, index=False, if_exists="replace")     df = pd.read_sql(         "SELECT * FROM table_name",         conn,         dtype_backend="numpy_nullable",     ) df` 
 `animal    num_legs 0    dog       4 1    cat       4 2    human     2` 

对于较小的数据集,你可能看不出太大差别,但在较大的数据集上,ADBC 的性能提升会非常明显。让我们比较一下使用 SQLAlchemy 写入一个 10,000 行、10 列的 pd.DataFrame 所需的时间:

`import timeit import sqlalchemy as sa np.random.seed(42) df = pd.DataFrame(     np.random.randn(10_000, 10),     columns=list("abcdefghij") ) with sa.create_engine("sqlite:///:memory:").connect() as conn:     func = lambda: df.to_sql("test_table", conn, if_exists="replace")     print(timeit.timeit(func, number=100))` 
`4.898935955003253` 

使用 ADBC 的等效代码:

`from adbc_driver_sqlite import dbapi with dbapi.connect("file::memory:") as conn:     func = lambda: df.to_sql("test_table", conn, if_exists="replace")     print(timeit.timeit(func, number=100))` 
`0.7935214300014195` 

你的结果会有所不同,具体取决于你的数据和数据库,但通常情况下,ADBC 应该会表现得更快。

还有更多…

为了理解 ADBC 的作用以及它为何重要,首先值得简单回顾一下数据库标准的历史以及它们如何发展。在 1990 年代,开放数据库连接ODBC)和 Java 数据库连接JDBC)标准被引入,这帮助标准化了不同客户端如何与各种数据库进行通信。在这些标准引入之前,如果你开发了一个需要与两个或更多不同数据库交互的应用程序,那么你的应用程序就必须使用每个数据库能理解的语言来与其交互。

假设这个应用程序只想获取每个数据库中可用表格的列表。PostgreSQL 数据库将这些信息存储在名为 pg_catalog.pg_tables 的表中,而 SQLite 数据库则将其存储在一个 sqlite_schema 表中,条件是 type='table'。这个应用程序需要根据这些特定的信息来开发,并且每当数据库更改了存储这些信息的方式,或者当应用程序想要支持新数据库时,都需要重新发布。

使用像 ODBC 这样的标准时,应用程序只需要与驱动程序进行通信,告知驱动程序它需要系统中的所有表格。这将数据库交互的责任从应用程序转移到驱动程序,给应用程序提供了一个抽象层。随着新数据库或新版本的发布,应用程序本身不再需要改变;它只需与新的 ODBC/JDBC 驱动程序配合工作,继续正常运作。事实上,SQLAlchemy 就像这个理论中的应用程序;它通过 ODBC/JDBC 驱动程序与数据库交互,而不是试图独立管理无尽的数据库交互。

尽管这些标准在许多用途上非常出色,但值得注意的是,1990 年代的数据库与今天的数据库差异很大。许多这些标准试图解决的问题是针对当时盛行的行式数据库的。列式数据库是在十多年后才出现的,并且它们已经主导了数据分析领域。不幸的是,在没有列式数据传输标准的情况下,许多数据库不得不重新设计,使其兼容 ODBC/JDBC。这使得它们能够与今天存在的无数数据库客户端工具兼容,但也需要在性能和效率上做出一定的妥协。

ADBC 是解决这个问题的列式规范。pandas 库以及许多类似的产品在设计上明确地(或至少非常接近)采用了列式设计。当与列式数据库(如 BigQuery、Redshift 或 Snowflake)交互时,拥有一个列式驱动程序来交换信息可以带来数量级更好的性能。即使你没有与列式数据库交互,ADBC 驱动程序也经过精细优化,专为与 Apache Arrow 配合的分析工作,因此它 仍然 比 SQLAlchemy 使用的任何 ODBC/JDBC 驱动程序都要好。

对于想要了解更多 ADBC 的用户,我建议观看我在 PyData NYC 2023 上的演讲,标题为《使用 pandas 和 Apache Arrow 加速 SQL》,可以在 YouTube 上观看 (youtu.be/XhnfybpWOgA?si=RBrM7UUvpNFyct0L)。

Apache Parquet

pd.DataFrame 的通用存储格式而言,Apache Parquet 是最佳选择。Apache Parquet 允许:

  • 元数据存储 —— 这使得格式能够追踪数据类型等特性

  • 分区 – 不是所有数据都需要在一个文件中

  • 查询支持 – Parquet 文件可以在磁盘上进行查询,因此你不必将所有数据都加载到内存中

  • 并行化 – 读取数据可以并行化以提高吞吐量

  • 紧凑性 – 数据被压缩并以高效的方式存储

除非你在处理遗留系统,否则 Apache Parquet 格式应该取代在工作流中使用 CSV 文件的方式,从本地持久化数据、与其他团队成员共享,到跨系统交换数据。

如何操作

读取/写入 Apache Parquet 的 API 与我们到目前为止看到的所有 pandas API 一致;读取使用 pd.read_parquet,写入使用 pd.DataFrame.to_parquet 方法。

让我们从一些示例数据和 io.BytesIO 对象开始:

`import io buf = io.BytesIO() df = pd.DataFrame([     ["Paul", "McCartney", 1942],     ["John", "Lennon", 1940],     ["Richard", "Starkey", 1940],     ["George", "Harrison", 1943], ], columns=["first", "last", "birth"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `first    last       birth 0     Paul     McCartney  1942 1     John     Lennon     1940 2     Richard  Starkey    1940 3     George   Harrison   1943` 

这是如何写入文件句柄的方式:

`df.to_parquet(buf, index=False)` 

下面是如何从文件句柄中读取数据。注意我们故意没有提供 dtype_backend="numpy_nullable"

`buf.seek(0) pd.read_parquet(buf)` 
 `first    last       birth 0     Paul     McCartney  1942 1     John     Lennon     1940 2     Richard  Starkey    1940 3     George   Harrison   1943` 

为什么我们在 pd.read_parquet 中不需要 dtype_backend= 参数?与像 CSV 这样的格式只存储数据不同,Apache Parquet 格式同时存储数据和元数据。在元数据中,Apache Parquet 能够追踪正在使用的数据类型,所以你写入的数据类型应该和你读取的数据类型完全一致。

你可以通过将 birth 列的数据类型更改为不同类型来进行测试:

`df["birth"] = df["birth"].astype(pd.UInt16Dtype()) df.dtypes` 
`first    string[python] last     string[python] birth            UInt16 dtype: object` 

通过 Apache Parquet 格式进行回环操作会返回你最初使用的相同数据类型:

`buf = io.BytesIO() df.to_parquet(buf, index=False) buf.seek(0) pd.read_parquet(buf).dtypes` 
`first    string[python] last     string[python] birth            UInt16 dtype: object` 

当然,如果你想更加防守型,在这里使用 dtype_backend="numpy_nullable" 也没有坏处。我们一开始故意没有使用它,是为了展示 Apache Parquet 格式的强大,但如果你从其他来源和开发者那里接收文件,他们没有使用我们在第三章《数据类型》中推荐的类型系统,那么确保使用 pandas 提供的最佳类型可能会对你有帮助:

`suboptimal_df = pd.DataFrame([     [0, "foo"],     [1, "bar"],     [2, "baz"], ], columns=["int_col", "str_col"]) buf = io.BytesIO() suboptimal_df.to_parquet(buf, index=False) buf.seek(0) pd.read_parquet(buf, dtype_backend="numpy_nullable").dtypes` 
`int_col             Int64 str_col    string[python] dtype: object` 

Apache Parquet 格式的另一个伟大特点是它支持 分区,这打破了所有数据必须位于一个文件中的要求。通过能够将数据分割到不同的目录和文件中,分区使得用户可以轻松地组织内容,同时也使程序能够更高效地优化可能需要或不需要读取的文件来解决分析查询。

有许多方法可以对数据进行分区,每种方法都有实际的空间/时间权衡。为了演示,我们假设使用 基于时间的分区,即为不同的时间段生成单独的文件。考虑到这一点,让我们使用以下数据布局,其中我们为每个年份创建不同的目录,并在每个年份内,为每个销售季度创建单独的文件:

`Partitions  2022/    q1_sales.parquet    q2_sales.parquet  2023/    q1_sales.parquet    q2_sales.parquet` 

本书附带的每个示例 Apache Parquet 文件都已经使用我们在第三章(数据类型)中推荐的 pandas 扩展类型创建,因此我们进行的 pd.read_parquet 调用故意不包括 dtype_backend="numpy_nullable" 参数。在任何文件中,你都会看到我们存储了关于 year(年份)、quarter(季度)、region(地区)和总的 sales(销售额)等信息:

`pd.read_parquet(     "data/partitions/2022/q1_sales.parquet", )` 
 `year   quarter   region   sales 0     2022   Q1        America  1 1     2022   Q1        Europe   2` 

如果我们想查看所有数据的汇总,最直接的方法是遍历每个文件并累积结果。然而,使用 Apache Parquet 格式,pandas 可以本地有效地处理这个问题。与其将单个文件名传递给 pd.read_parquet,不如传递目录路径:

`pd.read_parquet("data/partitions/")` 
 `year    quarter  region    sales 0    2022    Q1       America   1 1    2022    Q1       Europe    2 2    2022    Q2       America   4 3    2022    Q2       Europe    8 4    2023    Q1       America   16 5    2023    Q1       Europe    32 6    2023    Q2       America   64 7    2023    Q2       Europe    128` 

由于我们的示例数据非常小,我们没有问题先将所有数据读取到 pd.DataFrame 中,然后从那里进行操作。然而,在生产环境中,你可能会遇到存储量达到吉字节或太字节的 Apache Parquet 文件。试图将所有数据读取到 pd.DataFrame 中可能会导致 MemoryError 错误。

幸运的是,Apache Parquet 格式使你能够在读取文件时动态过滤记录。在 pandas 中,你可以通过传递 filters= 参数来启用此功能,方法是使用 pd.read_parquet。该参数应该是一个列表,其中每个列表元素是一个包含三个元素的元组:

  • 列名

  • 逻辑运算符

例如,如果我们只想读取 region 列值为 Europe 的数据,可以这样写:

`pd.read_parquet(     "data/partitions/",     filters=[("region", "==", "Europe")], )` 
 `year    quarter   region   sales 0    2022    Q1        Europe   2 1    2022    Q2        Europe   8 2    2023    Q1        Europe   32 3    2023    Q2        Europe   128` 

JSON

JavaScript 对象表示法JSON)是一种常用于通过互联网传输数据的格式。JSON 规范可以在 www.json.org 上找到。尽管名称中有 JavaScript,但它不需要 JavaScript 来读取或创建。

Python 标准库附带了 json 库,它可以将 Python 对象序列化为 JSON,或者从 JSON 反序列化回 Python 对象:

`import json beatles = {     "first": ["Paul", "John", "Richard", "George",],     "last": ["McCartney", "Lennon", "Starkey", "Harrison",],     "birth": [1942, 1940, 1940, 1943], } serialized = json.dumps(beatles) print(f"serialized values are: {serialized}") deserialized = json.loads(serialized) print(f"deserialized values are: {deserialized}")` 
`serialized values are: {"first": ["Paul", "John", "Richard", "George"], "last": ["McCartney", "Lennon", "Starkey", "Harrison"], "birth": [1942, 1940, 1940, 1943]} deserialized values are: {'first': ['Paul', 'John', 'Richard', 'George'], 'last': ['McCartney', 'Lennon', 'Starkey', 'Harrison'], 'birth': [1942, 1940, 1940, 1943]}` 

然而,标准库并不知道如何处理 pandas 对象,因此 pandas 提供了自己的 I/O 函数,专门用于处理 JSON。

如何实现

在最简单的形式下,pd.read_json 可以用于读取 JSON 数据:

`import io data = io.StringIO(serialized) pd.read_json(data, dtype_backend="numpy_nullable")` 
 `first    last       birth 0    Paul     McCartney  1942 1    John     Lennon     1940 2    Richard  Starkey    1940 3    George   Harrison   1943` 

pd.DataFrame.to_json 方法可以用于写入:

`df = pd.DataFrame(beatles) print(df.to_json())` 
`{"first":{"0":"Paul","1":"John","2":"Richard","3":"George"},"last":{"0":"McCartney","1":"Lennon","2":"Starkey","3":"Harrison"},"birth":{"0":1942,"1":1940,"2":1940,"3":1943}}` 

然而,在实际应用中,存在无数种将表格数据表示为 JSON 的方式。有些用户可能希望看到 pd.DataFrame 中的每一行作为 JSON 数组,而另一些用户可能希望看到每一列作为数组。还有一些用户可能希望查看行索引、列索引和数据作为独立的 JSON 对象列出,而其他用户可能根本不关心是否看到行或列标签。

对于这些用例以及更多用例,pandas 允许你传递一个参数给 orient=,其值决定了要读取或写入的 JSON 布局:

  • columns(默认值):生成 JSON 对象,其中键是列标签,值是另一个对象,该对象将行标签映射到数据点。

  • recordspd.DataFrame 的每一行都表示为一个 JSON 数组,其中包含将列名映射到数据点的对象。

  • split:映射到 {"columns": […], "index": […], "data": […]}。列/索引值是标签的数组,数据包含数组的数组。

  • index:与列类似,不同之处在于行和列标签作为键的使用被反转。

  • values:将 pd.DataFrame 的数据映射到数组的数组中。行/列标签被丢弃。

  • table:遵循 JSON 表格模式。

JSON 是一种有损的数据交换格式,因此上述每种 orient 都是在损失、冗长性和最终用户需求之间的权衡。orient="table" 会最少损失数据,但会产生最大负载,而 orient="values" 完全位于该范围的另一端。

为了突出每种 orient 之间的差异,让我们从一个相当简单的 pd.DataFrame 开始:

`df = pd.DataFrame(beatles, index=["row 0", "row 1", "row 2", "row 3"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `first    last       birth row 0  Paul     McCartney  1942 row 1  John     Lennon     1940 row 2  Richard  Starkey    1940 row 3  George   Harrison   1943` 

传递 orient="columns" 会生成使用以下模式的数据:{"column":{"row": value, "row": value, ...}, ...}

`serialized = df.to_json(orient="columns") print(f'Length of orient="columns": {len(serialized)}') serialized[:100]` 
`Length of orient="columns": 221 {"first":{"row 0":"Paul","row 1":"John","row 2":"Richard","row 3":"George"},"last":{"row 0":"McCartn` 

这是一种相当冗长的存储数据方式,因为它会为每一列重复行索引标签。优点是,pandas 可以相对较好地从这种 orient 中重建正确的 pd.DataFrame

`pd.read_json(     io.StringIO(serialized),     orient="columns",     dtype_backend="numpy_nullable" )` 
 `first    last       birth row 0  Paul     McCartney  1942 row 1  John     Lennon     1940 row 2  Richard  Starkey    1940 row 3  George   Harrison   1943` 

使用 orient="records" 时,你会得到每一行 pd.DataFrame 的表示,不带行索引标签,形成一个模式 [{"col": value, "col": value, ...}, ...]

`serialized = df.to_json(orient="records") print(f'Length of orient="records": {len(serialized)}') serialized[:100]` 
`Length of orient="records": 196 [{"first":"Paul","last":"McCartney","birth":1942},{"first":"John","last":"Lennon","birth":1940},{"fi` 

尽管这种表示方式比 orient="columns" 更紧凑,但它不存储任何行标签,因此在重建时,你将得到一个带有新生成 pd.RangeIndexpd.DataFrame

`pd.read_json(     io.StringIO(serialized),     orient="orient",     dtype_backend="numpy_nullable" )` 
 `first    last       birth 0     Paul     McCartney  1942 1     John     Lennon     1940 2     Richard  Starkey    1940 3     George   Harrison   1943` 

使用 orient="split" 时,行索引标签、列索引标签和数据会分别存储:

`serialized = df.to_json(orient="split") print(f'Length of orient="split": {len(serialized)}') serialized[:100]` 
`Length of orient="split": 190 {"columns":["first","last","birth"],"index":["row 0","row 1","row 2","row 3"],"data":[["Paul","McCar` 

这种格式使用的字符比 orient="columns" 少,而且你仍然可以相对较好地重建一个 pd.DataFrame,因为它与你使用构造函数(如 pd.DataFrame(data, index=index, columns=columns))构建 pd.DataFrame 的方式相似:

`pd.read_json(     io.StringIO(serialized),     orient="split",     dtype_backend="numpy_nullable", )` 
 `first    last       birth row 0  Paul     McCartney  1942 row 1  John     Lennon     1940 row 2  Richard  Starkey    1940 row 3  George   Harrison   1943` 

虽然这是一个很好的格式,用于双向转换 pd.DataFrame,但与其他格式相比,在“野外”遇到这种 JSON 格式的可能性要低得多。

orient="index"orient="columns" 非常相似,但它反转了行和列标签的角色:

`serialized = df.to_json(orient="index") print(f'Length of orient="index": {len(serialized)}') serialized[:100]` 
`Length of orient="index": 228 {"row 0":{"first":"Paul","last":"McCartney","birth":1942},"row 1":{"first":"John","last":"Lennon","b` 

再次强调,你可以合理地重建你的 pd.DataFrame

`pd.read_json(     io.StringIO(serialized),     orient="index",     dtype_backend="numpy_nullable", )` 
 `first    last       birth row 0  Paul     McCartney  1942 row 1  John     Lennon     1940 row 2  Richard  Starkey    1940 row 3  George   Harrison   1943` 

一般来说,orient="index" 会比 orient="columns" 占用更多空间,因为大多数 pd.DataFrame 对象使用的列标签比索引标签更冗长。我建议仅在列标签不那么冗长,或者有其他系统强制要求特定格式的情况下使用此格式。

对于最简洁的表示方式,你可以选择 orient="values"。使用此 orient,既不保存行标签,也不保存列标签:

`serialized = df.to_json(orient="values") print(f'Length of orient="values": {len(serialized)}') serialized[:100]` 
`Length of orient="values": 104 [["Paul","McCartney",1942],["John","Lennon",1940],["Richard","Starkey",1940],["George","Harrison",19` 

当然,由于它们没有在 JSON 数据中表示,当使用 orient="values" 读取时,你将无法保留行/列标签:

`pd.read_json(     io.StringIO(serialized),     orient="values",     dtype_backend="numpy_nullable", )` 
 `0        1          2 0    Paul     McCartney  1942 1    John     Lennon     1940 2    Richard  Starkey    1940 3    George   Harrison   1943` 

最后,我们有了 orient="table"。这是所有输出中最冗长的一种,但它是唯一一个有实际标准支持的输出,标准叫做 JSON 表格模式:

`serialized = df.to_json(orient="table") print(f'Length of orient="table": {len(serialized)}') serialized[:100]` 
`Length of orient="table": 524 {"schema":{"fields":[{"name":"index","type":"string"},{"name":"first","type":"any","extDtype":"strin` 

表格模式更冗长,因为它存储了关于数据序列化的元数据,类似于我们在 Apache Parquet 格式中看到的内容(尽管功能不如 Apache Parquet)。对于所有其他的 orient= 参数,pandas 必须在读取时推断数据的类型,但 JSON 表格格式会为你保留这些信息。因此,假设你一开始就使用了 pandas 扩展类型,你甚至不需要 dtype_backend="numpy_nullable" 参数:

`df["birth"] = df["birth"].astype(pd.UInt16Dtype()) serialized = df.to_json(orient="table") pd.read_json(     io.StringIO(serialized),     orient="table", ).dtypes` 
`first    string[python] last     string[python] birth            UInt16 dtype: object` 

还有更多……

当尝试读取 JSON 时,你可能会发现以上格式仍然无法充分表达你想要实现的目标。幸运的是,仍然有 pd.json_normalize,它可以作为一个功能强大的函数,将你的 JSON 数据转换为表格格式。

想象一下,正在处理来自一个理论上的带有分页的 REST API 的以下 JSON 数据:

`data = {     "records": [{         "name": "human",         "characteristics": {             "num_leg": 2,             "num_eyes": 2         }     }, {         "name": "dog",         "characteristics": {             "num_leg": 4,             "num_eyes": 2         }     }, {         "name": "horseshoe crab",         "characteristics": {             "num_leg": 10,             "num_eyes": 10         }     }],     "type": "animal",     "pagination": {         "next": "23978sdlkusdf97234u2io",         "has_more": 1     } }` 

虽然 "pagination" 键对于导航 API 很有用,但对于我们来说报告价值不大,而且它可能会导致 JSON 序列化器出现问题。我们真正关心的是与 "records" 键相关的数组。你可以指示 pd.json_normalize 只查看这些数据,使用 record_path= 参数。请注意,pd.json_normalize 不是一个真正的 I/O 函数,因为它处理的是 Python 对象而不是文件句柄,因此没有 dtype_backend= 参数;相反,我们将链接一个 pd.DataFrame.convert_dtypes 调用,以获得所需的 pandas 扩展类型:

`pd.json_normalize(     data,     record_path="records" ).convert_dtypes(dtype_backend="numpy_nullable")` 
 `name            characteristics.num_leg  characteristics.num_eyes 0   human           2                        2 1   dog             4                        2 2   horseshoe crab  10                       10` 

通过提供 record_path= 参数,我们能够忽略不需要的 "pagination" 键,但不幸的是,我们现在有了一个副作用,就是丢失了包含每条记录重要元数据的 "type" 键。为了保留这些信息,你可以使用 meta= 参数:

`pd.json_normalize(     data,     record_path="records",     meta="type" ).convert_dtypes(dtype_backend="numpy_nullable")` 
 `name    characteristics.num_leg  characteristics.num_eyes  type 0   human   2                        2                         animal 1   dog     4                        2                         animal 2   horseshoe     crab    10                       10                        animal` 

HTML

你可以使用 pandas 从网站读取 HTML 表格。这使得获取像 Wikipedia 上的表格变得容易。

在这个教程中,我们将从 Wikipedia 上关于 The Beatles Discography 的页面抓取表格 (en.wikipedia.org/wiki/The_Beatles_discography)。特别是,我们想要抓取 2024 年 Wikipedia 上图片中展示的表格:

一张图表的截图

图 4.4:The Beatles Discography 的 Wikipedia 页面

在尝试读取 HTML 之前,用户需要安装一个第三方库。在本节的示例中,我们将使用 lxml

`python -m pip install lxml` 

如何做到这一点

pd.read_html 允许你从网站读取表格:

`url = "https://en.wikipedia.org/wiki/The_Beatles_discography" dfs = pd.read_html(url, dtype_backend="numpy_nullable") len(dfs)` 
`60` 

与我们到目前为止看到的其他 I/O 方法不同,pd.read_html不会返回一个pd.DataFrame,而是返回一个pd.DataFrame对象的列表。让我们看看第一个列表元素是什么样子的:

`dfs[0]` 
 `The Beatles discography   The Beatles discography.1 0   The Beatles in 1965       The Beatles in 1965 1   Studio albums             12 (UK), 17 (US) 2   Live albums               5 3   Compilation albums        51 4   Video albums              22 5   Music videos              53 6   EPs                       36 7   Singles                   63 8   Mash-ups                  2 9   Box sets                  17` 

上述表格是工作室专辑、现场专辑、合辑专辑等的统计摘要。这不是我们想要的表格。我们可以循环遍历pd.read_html创建的每个表格,或者我们可以给它一个提示,以找到特定的表格。

获取我们想要的表格的一种方法是利用pd.read_htmlattrs=参数。此参数接受一个将 HTML 属性映射到值的字典。因为 HTML 中的id属性应该在文档中是唯一的,尝试使用attrs={"id": ...}来查找表格通常是一个安全的方法。让我们看看我们能否在这里做到这一点。

使用你的网络浏览器检查网页的 HTML(如果你不知道如何做到这一点,请在网上搜索诸如Firefox inspectorSafari Web InspectorGoogle Chrome DevTools之类的术语;不幸的是,术语并不标准化)。寻找任何id字段、唯一字符串或帮助我们识别所需表格的表格元素属性。

这是原始 HTML 的一部分:

`<table class="wikipedia plainrowheaders" style="text-align:center;">   <caption>List of studio albums, with selected chart positions and certification   </caption>   <tbody>     <tr>       <th rowspan="2" scope="col" style="width:20em;">Title</th>       <th rowspan="2" scope="col" style="width:20em;">Album details<sup id="cite_ref-8" class="reference"><a href="#cite_note-8">[A]</a></sup></th>       ...     </tr>   </tbody>` 

不幸的是,我们正在寻找的表格没有id属性。我们可以尝试使用上述 HTML 片段中看到的classstyle属性,但这些可能不会是唯一的。

我们可以尝试的另一个参数是match=,它可以是一个字符串或正则表达式,用来匹配表格内容。在上述 HTML 的<caption>标签中,你会看到文本"List of studio albums";让我们将其作为一个参数试一试。为了提高可读性,我们只需查看每张专辑及其在英国、澳大利亚和加拿大的表现:

`url = "https://en.wikipedia.org/wiki/The_Beatles_discography" dfs = pd.read_html(     url,     match=r"List of studio albums",     dtype_backend="numpy_nullable", ) print(f"Number of tables returned was: {len(dfs)}") dfs[0].filter(regex=r"Title|UK|AUS|CAN").head()` 
 `Title                  Peak chart positions                       Title       UK [8][9]   AUS [10]    CAN [11] 0          Please Please Me               1          —          — 1       With the Beatles[B]               1          —          — 2        A Hard Day's Night               1          1          — 3          Beatles for Sale               1          1          — 4                     Help!               1          1          —` 

虽然我们现在能够找到表格,但列名不太理想。如果你仔细观察维基百科的表格,你会注意到它在Peak chart positions文本和下面国家名称之间部分创建了一个层次结构,这些内容会被 pandas 转换为pd.MultiIndex。为了使我们的表格更易读,我们可以传递header=1来忽略生成的pd.MultiIndex的第一个级别:

`url = "https://en.wikipedia.org/wiki/The_Beatles_discography" dfs = pd.read_html(     url,     match="List of studio albums",     header=1,     dtype_backend="numpy_nullable", ) dfs[0].filter(regex=r"Title|UK|AUS|CAN").head()` 
 `Title      UK [8][9]   AUS [10]   CAN [11] 0          Please Please Me              1         —           — 1       With the Beatles[B]              1         —           — 2        A Hard Day's Night              1         1           — 3          Beatles for Sale              1         1           — 4                     Help!              1         1           —` 

当我们更仔细地查看数据时,我们可以看到维基百科使用来表示缺失值。如果我们将其作为参数传递给pd.read_htmlna_values=参数,我们将看到=—=值被转换为缺失值:

`url = "https://en.wikipedia.org/wiki/The_Beatles_discography" dfs = pd.read_html(     url,     match="List of studio albums",     header=1,     na_values=["—"],     dtype_backend="numpy_nullable", ) dfs[0].filter(regex=r"Title|UK|AUS|CAN").head()` 
 `Title                 UK [8][9]   AUS [10]   CAN [11] 0       Please Please Me              1       <NA>       <NA> 1       With the Beatles[B]           1       <NA>       <NA> 2       A Hard Day's Night            1          1       <NA> 3       Beatles for Sale              1          1       <NA> 4       Help!                         1          1       <NA>` 

Pickle

Pickle 格式是 Python 的内置序列化格式。Pickle 文件通常以.pkl扩展名结尾。

与之前遇到的其他格式不同,pickle 格式不应该用于在机器间传输数据。它的主要使用场景是将包含 Python 对象的 pandas 对象保存在本地机器上,并在以后重新加载。如果你不确定是否应该使用这个格式,我建议你首先尝试 Apache Parquet 格式,它涵盖了更广泛的使用场景。

不要从不可信来源加载 pickle 文件。我通常只建议将 pickle 用于自己的分析;不要分享数据,也不要指望从别人那里收到 pickle 格式的数据。

如何操作

为了强调 pickle 格式应该仅在 pandas 对象包含 Python 对象时使用,假设我们决定将 Beatles 数据存储为一个包含 namedtuple 类型的 pd.Series。人们可能会质疑为什么要这么做,因为它更适合表示为 pd.DataFrame……但不论如何,这样做是有效的:

`from collections import namedtuple Member = namedtuple("Member", ["first", "last", "birth"]) ser = pd.Series([     Member("Paul", "McCartney", 1942),     Member("John", "Lennon", 1940),     Member("Richard", "Starkey", 1940),     Member("George", "Harrison", 1943), ]) ser` 
`0     (Paul, McCartney, 1942) 1        (John, Lennon, 1940) 2    (Richard, Starkey, 1940) 3    (George, Harrison, 1943) dtype: object` 

我们在本章讨论的其他 I/O 方法都无法准确表示namedtuple,因为它是纯 Python 构造。然而,pd.Series.to_pickle却能够顺利写出这个内容:

`import io buf = io.BytesIO() ser.to_pickle(buf)` 

当你调用 pd.read_pickle 时,你将获得你开始时所使用的确切表示:

`buf.seek(0) ser = pd.read_pickle(buf) ser` 
`0     (Paul, McCartney, 1942) 1        (John, Lennon, 1940) 2    (Richard, Starkey, 1940) 3    (George, Harrison, 1943) dtype: object` 

你可以通过检查单个元素进一步验证这一点:

`ser.iloc[0]` 
`Member(first='Paul', last='McCartney', birth=1942)` 

再次强调,Apache Parquet 格式应优先于 pickle,只有在 pd.Seriespd.DataFrame 中包含 Python 特定对象且需要回传时,才应作为最后的选择使用。务必不要从不可信来源加载 pickle 文件;除非你自己创建了该 pickle 文件,否则强烈建议不要尝试处理它。

第三方 I/O 库

虽然 pandas 支持大量格式,但它无法涵盖所有重要格式。为此,第三方库应运而生,填补了这个空白。

以下是一些你可能感兴趣的库——它们的工作原理超出了本书的范围,但它们通常遵循的模式是:具有读取函数返回pd.DataFrame对象,并且写入方法接受pd.DataFrame参数:

  • pandas-gbq 让你与 Google BigQuery 交换数据

  • AWS SDK for pandas 可以与 Redshift 以及更广泛的 AWS 生态系统一起使用

  • Snowflake Connector for Python 帮助与 Snowflake 数据库交换数据

  • pantab 让你将 pd.DataFrame 对象在 Tableau 的 Hyper 数据库格式中进出(注意:我也是 pantab 的作者)

加入我们社区的 Discord

加入我们社区的 Discord 讨论区,与作者和其他读者互动:

packt.link/pandas

第五章:算法及其应用

在本书中,我们已经看过了多种创建 pandas 数据结构、选择/赋值数据以及将这些结构存储为常见格式的方法。这些功能单独来看已经足以使 pandas 在数据交换领域成为一个强大的工具,但我们仍然只是触及了 pandas 所能提供的一小部分。

数据分析和计算的核心组成部分是算法的应用,它描述了计算机处理数据时应采取的步骤序列。在简单的形式下,常见的数据算法基于基本的算术运算(例如,“对这一列求和”),但它们也可以扩展到你可能需要的任何步骤序列,以进行自定义计算。

正如你将在本章中看到的,pandas 提供了许多常见的数据算法,但同时也为你提供了一个强大的框架,通过它你可以构建和应用自己的算法。pandas 提供的这些算法通常比你在 Python 中手动编写的任何算法都要快,随着你在数据处理的旅程中不断进步,你会发现这些算法的巧妙应用可以涵盖大量的数据处理需求。

在本章中,我们将涵盖以下几种方法:

  • 基本的pd.Series算术运算

  • 基本的pd.DataFrame算术运算

  • 聚合

  • 转换

  • 映射

  • 应用

  • 摘要统计

  • 分箱算法

  • 使用pd.get_dummies进行独热编码

  • 使用.pipe进行链式操作

  • 从前 100 部电影中选择预算最低的电影

  • 计算尾部止损订单价格

  • 寻找棒球运动员最擅长…

  • 理解每个团队中得分最高的位置

基本的 pd.Series 算术运算

探索 pandas 算法的最佳起点是使用pd.Series,因为它是 pandas 库提供的最基本的数据结构。基本的算术运算包括加法、减法、乘法和除法,正如你将在本节中看到的,pandas 提供了两种执行这些操作的方式。第一种方法允许 pandas 使用 Python 语言内置的+-*/运算符,这对于初次接触该库的新用户来说是一种直观的学习工具。然而,为了涵盖 Python 语言未涵盖的数据分析特定功能,并支持稍后在本章中将介绍的使用.pipe 进行链式操作方法,pandas 还提供了pd.Series.addpd.Series.subpd.Series.mulpd.Series.div方法,分别对应着这些运算符。

pandas 库极力保持其 API 在所有数据结构中的一致性,因此你将会看到本节中的知识可以轻松地转移到pd.DataFrame结构中,唯一的区别是pd.Series是一维的,而pd.DataFrame是二维的。

如何做到这一点

让我们从 Python 的range表达式创建一个简单的pd.Series

`ser = pd.Series(range(3), dtype=pd.Int64Dtype()) ser` 
`0    0 1    1 2    2 dtype: Int64` 

为了确立术语,让我们简单地考虑一个像 a + b 这样的表达式。在这种表达式中,我们使用了一个 二元操作符+)。术语 二元 是指你需要将两个东西加在一起才能使这个表达式有意义,也就是说,像 a + 这样的表达式是不合逻辑的。这两个“东西”在技术上被视为 操作数;因此,在 a + b 中,我们有一个左操作数 a 和一个右操作数 b

当其中一个操作数是 pd.Series 时,pandas 中最基本的算法表达式会包含另一个操作数是 标量,也就是说,只有一个值。当发生这种情况时,标量值会被 广播pd.Series 的每个元素上,从而应用该算法。

例如,如果我们想将数字 42 加到 pd.Series 中的每一个元素,我们可以简单地这样表达:

`ser + 42` 
`0    42 1    43 2    44 dtype: Int64` 

pandas 库能够以 向量化 方式处理加法表达式(即数字 42 会一次性应用到所有值上,而无需用户在 Python 中使用 for 循环)。

减法可以自然地用 - 操作符来表示:

`ser - 42` 
`0    -42 1    -41 2    -40 dtype: Int64` 

类似地,乘法可以通过 * 操作符来表示:

`ser * 2` 
`0    0 1    2 2    4 dtype: Int64` 

到现在为止,你可能已经猜到,除法是用 / 操作符来表示的:

`ser / 2` 
`0    0.0 1    0.5 2    1.0 dtype: Float64` 

两个操作数都是 pd.Series 也是完全有效的:

`ser2 = pd.Series(range(10, 13), dtype=pd.Int64Dtype()) ser + ser2` 
`0    10 1    12 2    14 dtype: Int64` 

正如本节介绍中所提到的,虽然内置的 Python 操作符在大多数情况下是常用且可行的,pandas 仍然提供了专门的方法,如 pd.Series.addpd.Series.subpd.Series.mulpd.Series.div

`ser1 = pd.Series([1., 2., 3.], dtype=pd.Float64Dtype()) ser2 = pd.Series([4., pd.NA, 6.], dtype=pd.Float64Dtype()) ser1.add(ser2)` 
`0     5.0 1    <NA> 2     9.0 dtype: Float64` 

pd.Series.add 相较于内置操作符的优势在于,它接受一个可选的 fill_value= 参数来处理缺失数据:

`ser1.add(ser2, fill_value=0.)` 
`0    5.0 1    2.0 2    9.0 dtype: Float64` 

本章稍后你还将接触到使用 .pipe 进行链式操作,这与 pandas 方法链式操作最为自然,而不是与内置的 Python 操作符链式操作。

还有更多内容……

当表达式中的两个操作数都是 pd.Series 对象时,重要的是要注意,pandas 会对齐行标签。这种对齐行为被视为一种特性,但对新手来说可能会令人惊讶。

为了了解为什么这很重要,我们先从两个具有相同行索引的 pd.Series 对象开始。当我们尝试将它们相加时,结果并不令人意外:

`ser1 = pd.Series(range(3), dtype=pd.Int64Dtype()) ser2 = pd.Series(range(3), dtype=pd.Int64Dtype()) ser1 + ser2` 
`0    0 1    2 2    4 dtype: Int64` 

那么当行索引值不相同时,会发生什么呢?一个简单的例子是将两个 pd.Series 对象相加,其中一个 pd.Series 使用的行索引是另一个的子集。你可以通过以下代码中的 ser3 来看到这一点,它只有两个值,并且使用默认的 pd.RangeIndex,值为 [0, 1]。当与 ser1 相加时,我们仍然得到一个包含三个元素的 pd.Series,但只有当两个 pd.Series 对象的行索引标签能够对齐时,值才会被相加:

`ser3 = pd.Series([2, 4], dtype=pd.Int64Dtype()) ser1 + ser3` 
`0       2 1       5 2    <NA> dtype: Int64` 

现在让我们看看当两个相同长度的pd.Series对象相加时会发生什么,但它们的行索引值不同:

`ser4 = pd.Series([2, 4, 8], index=[1, 2, 3], dtype=pd.Int64Dtype()) ser1 + ser4` 
`0    <NA> 1       3 2       6 3    <NA> dtype: Int64` 

对于一个更极端的例子,让我们考虑一个情况,其中一个pd.Series的行索引值是非唯一的:

`ser5 = pd.Series([2, 4, 8], index=[0, 1, 1], dtype=pd.Int64Dtype()) ser1 + ser5` 
`0       2 1       5 1       9 2    <NA> dtype: Int64` 

如果你有 SQL 的背景,pandas 在这里的行为类似于数据库中的FULL OUTER JOIN。每个行索引的标签都会被包含在输出中,pandas 会将可以在两个pd.Series对象中看到的标签进行匹配。这可以在像 PostgreSQL 这样的数据库中直接复制:

`WITH ser1 AS (   SELECT * FROM (     VALUES       (0, 0),       (1, 1),       (2, 2)    ) AS t(index, val1) ), ser5 AS (   SELECT * FROM (     VALUES       (0, 2),       (1, 4),       (1, 8)    ) AS t(index, val2) ) SELECT * FROM ser1 FULL OUTER JOIN ser5 USING(index);` 

如果你直接在 PostgreSQL 中运行这段代码,你将得到以下结果:

`index | val1 | val2 ------+------+------     0 |    0 |    2     1 |    1 |    8     1 |    1 |    4     2 |    2 | (4 rows)` 

忽略顺序差异,你可以看到数据库返回了从[0, 1, 2][0, 1, 1]的组合中得到的所有唯一index值,以及任何相关的val1val2值。尽管ser1只有一个index值为1,但这个值在ser5index列中出现了两次。因此,FULL OUTER JOIN显示了来自ser5的两个val2值(48),同时重复了源自ser1val1值(1)。

如果你接着在数据库中将val1val2相加,你将得到一个结果,该结果与ser1 + ser5的输出相匹配,唯一的区别是数据库可能会选择不同的输出顺序:

`WITH ser1 AS (   SELECT * FROM (     VALUES       (0, 0),       (1, 1),       (2, 2)    ) AS t(index, val1) ), ser5 AS (   SELECT * FROM (     VALUES       (0, 2),       (1, 4),       (1, 8)    ) AS t(index, val2) ) SELECT index, val1 + val2 AS value FROM ser1 FULL OUTER JOIN ser5 USING(index);` 
`index | value ------+-------     0 |     2     1 |     9     1 |     5     2 | (4 rows)` 

基本的pd.DataFrame算术运算

在介绍了基本的pd.Series算术运算后,你会发现,相应的pd.DataFrame算术运算几乎是完全相同的,唯一的区别是我们的算法现在在二维数据上工作,而不仅仅是单维数据。这样,pandas API 使得无论数据的形状如何,都能轻松地解释数据,而且无需用户编写循环来与数据交互。这大大减少了开发人员的工作量,帮助你编写更快的代码——对开发人员来说是双赢。

它是如何工作的

让我们使用随机数创建一个小的 3x3pd.DataFrame

`np.random.seed(42) df = pd.DataFrame(     np.random.randn(3, 3),     columns=["col1", "col2", "col3"],     index=["row1", "row2", "row3"], ).convert_dtypes(dtype_backend="numpy_nullable") df` 
 `col1         col2         col3 row1    0.496714    -0.138264     0.647689 row2    1.52303     -0.234153    -0.234137 row3    1.579213     0.767435    -0.469474` 

就像pd.Series一样,pd.DataFrame也支持带有标量参数的内置二进制运算符。这里是一个简化的加法操作:

`df + 1` 
 `col1        col2        col3 row1    1.496714    0.861736    1.647689 row2    2.52303     0.765847    0.765863 row3    2.579213    1.767435    0.530526` 

下面是一个简化的乘法操作:

`df * 2` 
 `col1        col2          col3 row1    0.993428    -0.276529     1.295377 row2    3.04606     -0.468307    -0.468274 row3    3.158426     1.534869    -0.938949` 

你还可以对pd.Series执行算术运算。默认情况下,pd.Series中的每一行标签都会被查找并与pd.DataFrame的列进行对齐。为了说明这一点,让我们创建一个小的pd.Series,它的索引标签与df的列标签匹配:

`ser = pd.Series(     [20, 10, 0],     index=["col1", "col2", "col3"],     dtype=pd.Int64Dtype(), ) ser` 
`col1    20 col2    10 col3     0 dtype: Int64` 

如果你尝试将其添加到我们的pd.DataFrame中,它将取pd.Series中的col1值并将其添加到pd.DataFramecol1列的每个元素,针对每个索引条目重复执行:

`df + ser` 
 `col1         col2        col3 row1    20.496714    9.861736    0.647689 row2    21.52303     9.765847   -0.234137 row3    21.579213    10.767435  -0.469474` 

pd.Series的行标签与pd.DataFrame的列标签不匹配的情况下,你可能会遇到缺失数据:

`ser = pd.Series(     [20, 10, 0, 42],     index=["col1", "col2", "col3", "new_column"],     dtype=pd.Int64Dtype(), ) ser + df` 
 `col1        col2        col3        new_column row1    20.496714   9.861736    0.647689    NaN row2    21.52303    9.765847    -0.234137   NaN row3    21.579213   10.767435   -0.469474   NaN` 

如果你希望控制pd.Seriespd.DataFrame的对齐方式,可以使用像pd.DataFrame.addpd.DataFrame.subpd.DataFrame.mulpd.DataFrame.div等方法的axis=参数。

让我们通过创建一个新的pd.Series来查看这个过程,使用的行标签与我们pd.DataFrame的行标签更好地对齐:

`ser = pd.Series(     [20, 10, 0, 42],     index=["row1", "row2", "row3", "row4"],     dtype=pd.Int64Dtype(), ) ser` 
`row1    20 row2    10 row3     0 row4    42 dtype: Int64` 

指定df.add(ser, axis=0)将会匹配pd.Seriespd.DataFrame中的行标签:

`df.add(ser, axis=0)` 
 `col1        col2        col3 row1    20.496714   19.861736   20.647689 row2    11.52303    9.765847    9.765863 row3    1.579213    0.767435   -0.469474 row4    <NA>        <NA>        <NA>` 

你还可以将两个pd.DataFrame作为加法、减法、乘法和除法的操作数。以下是如何将两个pd.DataFrame对象相乘:

`df * df` 
 `col1       col2        col3 row1    0.246725   0.019117    0.4195 row2    2.31962    0.054828    0.05482 row3    2.493913   0.588956    0.220406` 

当然,在执行此操作时,你仍然需要注意索引对齐规则——项目总是按标签对齐,而不是按位置对齐!

让我们创建一个新的 3x3 pd.DataFrame,具有不同的行和列标签,以展示这一点:

`np.random.seed(42) df2 = pd.DataFrame(np.random.randn(3, 3)) df2 = df2.convert_dtypes(dtype_backend="numpy_nullable") df2` 
 `0            1             2 0    0.496714     -0.138264     0.647689 1    1.52303      -0.234153    -0.234137 2    1.579213      0.767435    -0.469474` 

尝试将其添加到我们之前的pd.DataFrame中,将生成一个行索引,标签为["row1", "row2", "row3", 0, 1, 2],列索引,标签为["col1", "col2", "col3", 0, 1, 2]。因为无法对齐标签,所有数据都会返回缺失值:

`df + df2` 
 `col1    col2    col3    0       1       2 row1    <NA>    <NA>    <NA>    <NA>    <NA>    <NA> row2    <NA>    <NA>    <NA>    <NA>    <NA>    <NA> row3    <NA>    <NA>    <NA>    <NA>    <NA>    <NA> 0       <NA>    <NA>    <NA>    <NA>    <NA>    <NA> 1       <NA>    <NA>    <NA>    <NA>    <NA>    <NA> 2       <NA>    <NA>    <NA>    <NA>    <NA>    <NA>` 

聚合

聚合(也称为归约)帮助你将多个值从一个值的序列中减少为单个值。即使这个技术术语对你来说比较新,你无疑在数据过程中已经遇到过许多聚合。诸如记录的计数总和或销售额、平均价格等,都是非常常见的聚合。

在本食谱中,我们将探索 pandas 内置的许多聚合方法,同时形成对这些聚合如何应用的理解。在你的数据旅程中,大多数分析都涉及将大型数据集进行聚合,转化为你的观众可以理解的结果。大多数公司高层并不感兴趣接收一大堆事务数据,他们只关心这些事务中数值的总和、最小值、最大值、平均值等。因此,有效地使用和应用聚合方法是将复杂的数据转换管道转化为他人可以使用和采取行动的简单输出的关键组成部分。

如何操作

许多基础聚合作为方法直接实现于pd.Series对象,这使得计算常见的输出(如countsummax等)变得非常简单。

为了开始这个食谱,我们再次从一个包含随机数的pd.Series开始:

`np.random.seed(42) ser = pd.Series(np.random.rand(10_000), dtype=pd.Float64Dtype())` 

pandas 库提供了许多常用的聚合方法,如pd.Series.countpd.Series.meanpd.Series.stdpd.Series.minpd.Series.maxpd.Series.sum

`print(f"Count is: {ser.count()}") print(f"Mean value is: {ser.mean()}") print(f"Standard deviation is: {ser.std()}") print(f"Minimum value is: {ser.min()}") print(f"Maximum value is: {ser.max()}") print(f"Summation is: {ser.sum()}")` 
`Count is: 10000 Mean value is: 0.49415955768429964 Standard deviation is: 0.2876301265269928 Minimum value is: 1.1634755366141114e-05 Maximum value is: 0.9997176732861306 Summation is: 4941.595576842997` 

与直接调用这些方法不同,调用这些聚合方法的一个更通用的方式是使用pd.Series.agg,并将你想执行的聚合名称作为字符串传递:

`print(f"Count is: {ser.agg('count')}") print(f"Mean value is: {ser.agg('mean')}") print(f"Standard deviation is: {ser.agg('std')}") print(f"Minimum value is: {ser.agg('min')}") print(f"Maximum value is: {ser.agg('max')}") print(f"Summation is: {ser.agg('sum')}")` 
`Count is: 10000 Mean value is: 0.49415955768429964 Standard deviation is: 0.2876301265269928 Minimum value is: 1.1634755366141114e-05 Maximum value is: 0.9997176732861306 Summation is: 4941.595576842997` 

使用pd.Series.agg的一个优点是它可以为你执行多个聚合操作。例如,如果你想要在一步中计算一个字段的最小值和最大值,你可以通过将一个列表传递给pd.Series.agg来实现:

`ser.agg(["min", "max"])` 
`min    0.000012 max    0.999718 dtype: float64` 

聚合pd.Series是直接的,因为只有一个维度需要聚合。对于pd.DataFrame来说,有两个可能的维度需要聚合,因此作为库的最终用户,你需要考虑更多因素。

为了演示这一点,让我们创建一个包含随机数的pd.DataFrame

`np.random.seed(42) df = pd.DataFrame(     np.random.randn(10_000, 6),     columns=list("abcdef"), ).convert_dtypes(dtype_backend="numpy_nullable") df` 
 `a          b         c         d          e          f 0    0.496714  -0.138264  0.647689  1.523030  -0.234153  -0.234137 1    1.579213   0.767435 -0.469474  0.542560  -0.463418  -0.465730 2    0.241962  -1.913280 -1.724918 -0.562288  -1.012831   0.314247 3   -0.908024  -1.412304  1.465649 -0.225776   0.067528  -1.424748 4   -0.544383   0.110923 -1.150994  0.375698  -0.600639  -0.291694 …     …         …         …         …         …         … 9995  1.951254  0.324704  1.937021 -0.125083  0.589664   0.869128 9996  0.624062 -0.317340 -1.636983  2.390878 -0.597118   2.670553 9997 -0.470192  1.511932  0.718306  0.764051 -0.495094  -0.273401 9998 -0.259206  0.274769 -0.084735 -0.406717 -0.815527  -0.716988 9999  0.533743 -0.701856 -1.099044  0.141010 -2.181973  -0.006398 10000 rows × 6 columns` 

默认情况下,使用像pd.DataFrame.sum这样的内置方法进行聚合时,会沿着列进行操作,也就是说,每一列都会单独进行聚合。然后,pandas 会将每一列的聚合结果显示为pd.Series中的一项:

`df.sum()` 
`a    -21.365908 b     -7.963987 c    152.032992 d   -180.727498 e     29.399311 f     25.042078 dtype: Float64` 

如果你想要对每一行的数据进行聚合,可以指定axis=1参数,值得注意的是,pandas 在axis=0操作上进行了更多优化,因此这可能比聚合列要显著慢。尽管如此,这是 pandas 的一个独特功能,当性能不是主要关注点时,它还是非常有用的:

`df.sum(axis=1)` 
`0       2.060878 1       1.490586 2      -4.657107 3      -2.437675 4      -2.101088          ...    9995     5.54669 9996     3.134053 9997     1.755601 9998    -2.008404 9999    -3.314518 Length: 10000, dtype: Float64` 

就像pd.Series一样,pd.DataFrame也有一个.agg方法,可以用于一次性应用多个聚合操作:

`df.agg(["min", "max"])` 
 `a         b         c         d         e         f min  -4.295391 -3.436062 -3.922400 -4.465604 -3.836656 -4.157734 max   3.602415  3.745379  3.727833  4.479084  3.691625  3.942331` 

还有更多…

如何做到这一点部分的例子中,我们将像minmax这样的函数作为字符串传递给.agg。对于简单的函数来说,这很好用,但对于更复杂的情况,你也可以传入可调用的参数。每个可调用对象应该接受一个pd.Series作为参数,并将其归约为标量:

`def mean_and_add_42(ser: pd.Series):     return ser.mean() + 42 def mean_and_sub_42(ser: pd.Series):     return ser.mean() - 42 np.random.seed(42) ser = pd.Series(np.random.rand(10_000), dtype=pd.Float64Dtype()) ser.agg([mean_and_add_42, mean_and_sub_42])` 
`mean_and_add_42    42.49416 mean_and_sub_42   -41.50584 dtype: float64` 

变换

聚合不同,变换不会将一组值压缩为单一值,而是保持调用对象的形状。这个特定的例子可能看起来很平凡,因为它来自于前一节的聚合内容,但变换和聚合最终会成为非常互补的工具,用于像“群体的百分比总和”之类的计算,这些将在后续的手册中展示。

如何做到这一点

让我们创建一个小的pd.Series

`ser = pd.Series([-1, 0, 1], dtype=pd.Int64Dtype())` 

就像我们之前在pd.Series.agg中看到的那样,pd.Series.transform也可以接受一个要应用的函数列表。然而,pd.Series.agg期望这些函数返回一个单一值,而pd.Series.transform期望这些函数返回一个具有相同索引和形状的pd.Series

`def adds_one(ser: pd.Series) -> pd.Series:     return ser + 1 ser.transform(["abs", adds_one])` 
 `abs    adds_one 0    1      0 1    0      1 2    1      2` 

就像pd.DataFrame.agg默认会聚合每一列一样,pd.DataFrame.transform默认会变换每一列。让我们创建一个小的pd.DataFrame来看看这个过程:

`df = pd.DataFrame(     np.arange(-5, 4, 1).reshape(3, -1) ).convert_dtypes(dtype_backend="numpy_nullable") df` 
 `0    1    2 0   -5   -4   -3 1   -2   -1    0 2    1    2    3` 

抛开实现细节,像df.transform("abs")这样的调用将对每一列单独应用绝对值函数,然后将结果拼接回一个pd.DataFrame

`df.transform("abs")` 
 `0    1    2 0    5    4    3 1    2    1    0 2    1    2    3` 

如果你将多个变换函数传递给pd.DataFrame.transform,你将得到一个pd.MultiIndex

`def add_42(ser: pd.Series):     return ser + 42 df.transform(["abs", add_42])` 
 `0       1       2     abs  add_42  abs  add_42  abs  add_42 0   5      37    4      38    3      39 1   2      40    1      41    0      42 2   1      43    2      44    3      45` 

还有更多…

正如本食谱介绍中提到的,转换和聚合可以与GroupBy概念自然地结合使用,这将在第八章中介绍,分组方法。特别是,我们的分组基础食谱将有助于比较和对比聚合与转换,并强调如何使用转换来简洁而富有表现力地计算“分组百分比”。

映射

到目前为止,我们看到的.agg.transform方法一次性作用于整个值序列。通常在 pandas 中,这是一个好事;它允许 pandas 执行向量化操作,速度快且计算高效。

但是,有时,作为最终用户,你可能决定愿意牺牲性能以换取定制或更细粒度的控制。这时,.map方法可以派上用场;.map帮助你将函数逐一应用到 pandas 对象的每个元素。

如何做到

假设我们有一个包含数字和数字列表混合的数据pd.Series

`ser = pd.Series([123.45, [100, 113], 142.0, [110, 113, 119]]) ser` 
`0             123.45 1         [100, 113] 2              142.0 3    [110, 113, 119] dtype: object` 

.agg.transform在这里不适用,因为我们没有统一的数据类型——我们实际上需要检查每个元素,决定如何处理它。

对于我们的分析,假设当我们遇到一个数字时,我们愿意直接返回该值。如果我们遇到一个值的列表,我们希望计算该列表中的所有值的平均值并返回它。实现这个功能的函数如下所示:

`def custom_average(value):     if isinstance(value, list):         return sum(value) / len(value)     return value` 

然后我们可以使用pd.Series.map将其应用到pd.Series的每个元素:

`ser.map(custom_average)` 
`0    123.45 1    106.50 2    142.00 3    114.00 dtype: float64` 

如果我们有一个包含这种数据类型的pd.DataFrame,那么pd.DataFrame.map也能够很好地应用这个函数:

`df = pd.DataFrame([     [2., [1, 2], 3.],     [[4, 5], 5, 7.],     [1, 4, [1, 1, 5.5]], ]) df` 
 `0         1              2 0       2.0    [1, 2]            3.0 1    [4, 5]         5            7.0 2         1         4    [1, 1, 5.5]` 
`df.map(custom_average)` 
 `0      1     2 0    2.0    1.5   3.0 1    4.5    5.0   7.0 2    1.0    4.0   2.5` 

还有更多…

在上述示例中,你也可以使用pd.Series.transform,而不是使用pd.Series.map

`ser.transform(custom_average)` 
`0    123.45 1    106.50 2    142.00 3    114.00 dtype: float64` 

然而,你不会得到与pd.DataFrame.transform相同的结果:

`df.transform(custom_average)` 
 `0        1             2 0       2.0   [1, 2]           3.0 1    [4, 5]        5           7.0 2         1        4   [1, 1, 5.5]` 

为什么会这样呢?记住,.map会明确地对每个元素应用一个函数,无论你是操作pd.Series还是pd.DataFramepd.Series.transform也很乐意对它包含的每个元素应用一个函数,但pd.DataFrame.transform本质上是遍历每一列,并将该列作为参数传递给可调用的函数。

因为我们的函数是这样实现的:

`def custom_average(value):     if isinstance(value, list):         return sum(value) / len(value)     return value` 

当传入一个pd.Series时,isinstance(value, list)检查会失败,结果你只是返回了pd.Series本身。如果我们稍微调整一下我们的函数:

`def custom_average(value):     if isinstance(value, (pd.Series, pd.DataFrame)):         raise TypeError("Received a pandas object - expected a single value!")     if isinstance(value, list):         return sum(value) / len(value)     return value` 

那么pd.DataFrame.transform的行为就更清晰了:

`df.transform(custom_average)` 
`TypeError: Received a pandas object - expected a single value!` 

虽然可能存在概念上的重叠,但通常,在代码中,你应该把.map看作是逐元素操作,而.agg.transform则尽可能一次性处理更大范围的数据序列。

应用

apply是一个常用的方法,我甚至认为它被过度使用。到目前为止,我们看到的.agg.transform.map方法都有相对明确的语义(.agg用于汇总,.transform保持形状不变,.map逐元素应用函数),但是当你使用.apply时,它几乎可以模拟所有这些功能。一开始,这种灵活性可能看起来很不错,但由于.apply让 pandas 来“做正确的事情”,通常你最好选择最明确的方法,以避免意外结果。

即使如此,你仍然会在实际代码中看到很多代码(尤其是那些没有阅读这本书的用户写的代码);因此,理解它的功能和局限性是非常有价值的。

如何操作

调用pd.Series.apply会使.apply.map一样工作(即,函数应用于pd.Series的每个单独元素)。

让我们来看一个稍微有点牵强的函数,它会打印出每个元素:

`def debug_apply(value):     print(f"Apply was called with value:\n{value}")` 

通过.apply来传递:

`ser = pd.Series(range(3), dtype=pd.Int64Dtype()) ser.apply(debug_apply)` 
`Apply was called with value: 0 Apply was called with value: 1 Apply was called with value: 2 0    None 1    None 2    None dtype: object` 

会得到与pd.Series.map完全相同的行为:

`ser.map(debug_apply)` 
`Apply was called with value: 0 Apply was called with value: 1 Apply was called with value: 2 0    None 1    None 2    None dtype: object` 

pd.Series.apply的工作方式类似于 Python 循环,对每个元素调用函数。因为我们的函数没有返回任何值,所以我们得到的pd.Series是一个索引相同的None值数组。

pd.Series.apply是逐元素应用的,pd.DataFrame.apply则按列工作,将每一列视为一个pd.Series。让我们用一个形状为(3, 2)pd.DataFrame来看看它的实际应用:

`df = pd.DataFrame(     np.arange(6).reshape(3, -1),     columns=list("ab"), ).convert_dtypes(dtype_backend="numpy_nullable") df` 
 `a     b 0     0     1 1     2     3 2     4     5` 
`df.apply(debug_apply)` 
`Apply was called with value: 0    0 1    2 2    4 Name: a, dtype: Int64 Apply was called with value: 0    1 1    3 2    5 Name: b, dtype: Int64 a    None b    None dtype: object` 

如上所示,在给定的两列数据中,函数仅被调用了两次,但在包含三行的pd.Series中,它被应用了三次。

除了pd.DataFrame.apply实际应用函数的次数外,返回值的形状也可能有所不同,可能与.agg.transform的功能相似。我们之前的例子更接近.agg,因为它返回了一个单一的None值,但如果我们返回我们打印的元素,那么行为更像是.transform

`def debug_apply_and_return(value):     print(value)     return value df.apply(debug_apply_and_return)` 
`0    0 1    2 2    4 Name: a, dtype: Int64 0    1 1    3 2    5 Name: b, dtype: Int64       a    b 0     0    1 1     2    3 2     4    5` 

如果你觉得这很困惑,你不是一个人。相信 pandas 在使用.apply时“做正确的事情”可能是一个有风险的选择;我强烈建议用户在使用.apply之前,先尝试使用.agg.transform.map,直到这些方法无法满足需求为止。

汇总统计

汇总统计提供了一种快速了解数据基本属性和分布的方式。在这一部分,我们介绍了两个强大的 pandas 方法:pd.Series.value_countspd.Series.describe,它们可以作为探索的有用起点。

如何操作

pd.Series.value_counts方法为每个不同的数据点附加了频率计数,使得查看每个值出现的频率变得简单。这对离散数据特别有用:

`ser = pd.Series(["a", "b", "c", "a", "c", "a"], dtype=pd.StringDtype()) ser.value_counts()` 
`a    3 c    2 b    1 Name: count, dtype: Int64` 

对于连续数据,pd.Series.describe是将一堆计算打包成一个方法调用。通过调用这个方法,你可以轻松查看数据的计数、均值、最小值、最大值,以及数据的高层次分布:

`ser = pd.Series([0, 42, 84], dtype=pd.Int64Dtype()) ser.describe()` 
`count     3.0 mean     42.0 std      42.0 min       0.0 25%      21.0 50%      42.0 75%      63.0 max      84.0 dtype: Float64` 

默认情况下,我们会看到通过 25%、50%、75% 和最大值(或 100%)四分位数来概述我们的分布。如果您的数据分析集中在分布的某一特定部分,您可以通过提供 percentiles= 参数来控制此方法返回的内容:

`ser.describe(percentiles=[.10, .44, .67])` 
`count      3.0 mean      42.0 std       42.0 min        0.0 10%        8.4 44%      36.96 50%       42.0 67%      56.28 max       84.0 dtype: Float64` 

分箱算法

分箱(Binning)是将连续变量分成离散区间的过程。这对于将可能是无限的数值转化为有限的“区间”以进行分析是非常有用的。

如何实现

假设我们已经从一个系统的用户那里收集了调查数据。调查中的一个问题询问用户的年龄,产生的数据如下所示:

`df = pd.DataFrame([     ["Jane", 34],     ["John", 18],     ["Jamie", 22],     ["Jessica", 36],     ["Jackie", 33],     ["Steve", 40],     ["Sam", 30],     ["Stephanie", 66],     ["Sarah", 55],     ["Aaron", 22],     ["Erin", 28],     ["Elsa", 37], ], columns=["name", "age"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.head()` 
 `name       age 0       Jane       34 1       John       18 2       Jamie      22 3       Jessica    36 4       Jackie     33` 

我们不打算将每个年龄当作一个独立的数值,而是使用 pd.cut 将每条记录分到一个年龄组。作为第一次尝试,我们将 pd.Series 和我们想要生成的区间数量作为参数传递:

`pd.cut(df["age"], 4)` 
`0       (30.0, 42.0] 1     (17.952, 30.0] 2     (17.952, 30.0] 3       (30.0, 42.0] 4       (30.0, 42.0] 5       (30.0, 42.0] 6     (17.952, 30.0] 7       (54.0, 66.0] 8       (54.0, 66.0] 9     (17.952, 30.0] 10    (17.952, 30.0] 11      (30.0, 42.0] Name: age, dtype: category Categories (4, interval[float64, right]): [(17.952, 30.0] < (30.0, 42.0] < (42.0, 54.0] < (54.0, 66.0]]` 

这会生成一个具有 4 个不同区间的 pd.CategoricalDtype —— (17.952, 30.0](30.0, 42.0](42.0, 54.0](54.0, 66.0]。除了第一个区间从 17.952 开始有一些意外的小数位外,这些区间都覆盖了 12 年的等距范围,得出这个范围的原因是最大值(66)减去最小值(18)得到的年龄差为 48 年,然后将其平分成 4 个区间,每个区间的跨度为 12 年。

我们在第一个区间看到的年龄17.952可能对于 pandas 内部使用的任何算法在确定区间时有意义,但对于我们来说并不重要,因为我们知道我们处理的是整数。幸运的是,可以通过 precision= 关键字参数来控制,去除任何小数部分:

`pd.cut(df["age"], 4, precision=0)` 
`0     (30.0, 42.0] 1     (18.0, 30.0] 2     (18.0, 30.0] 3     (30.0, 42.0] 4     (30.0, 42.0] 5     (30.0, 42.0] 6     (18.0, 30.0] 7     (54.0, 66.0] 8     (54.0, 66.0] 9     (18.0, 30.0] 10    (18.0, 30.0] 11    (30.0, 42.0] Name: age, dtype: category Categories (4, interval[float64, right]): [(18.0, 30.0] < (30.0, 42.0] < (42.0, 54.0] < (54.0, 66.0]]` 

pd.cut 并不限于生成像上面那样大小相等的区间。如果我们想要将每个人按 10 年一组进行分箱,可以将这些范围作为第二个参数提供:

`pd.cut(df["age"], [10, 20, 30, 40, 50, 60, 70])` 
`0     (30, 40] 1     (10, 20] 2     (20, 30] 3     (30, 40] 4     (30, 40] 5     (30, 40] 6     (20, 30] 7     (60, 70] 8     (50, 60] 9     (20, 30] 10    (20, 30] 11    (30, 40] Name: age, dtype: category Categories (6, interval[int64, right]): [(10, 20] < (20, 30] < (30, 40] < (40, 50] < (50, 60] < (60, 70]]` 

然而,这种方式有点过于严格,因为它没有考虑到 70 岁以上的用户。为了解决这个问题,我们可以将最后一个区间的边界从 70 改为 999,并将其视为“其他”区间:

`pd.cut(df["age"], [10, 20, 30, 40, 50, 60, 999])` 
`0      (30, 40] 1      (10, 20] 2      (20, 30] 3      (30, 40] 4      (30, 40] 5      (30, 40] 6      (20, 30] 7     (60, 999] 8      (50, 60] 9      (20, 30] 10     (20, 30] 11     (30, 40] Name: age, dtype: category Categories (6, interval[int64, right]): [(10, 20] < (20, 30] < (30, 40] < (40, 50] < (50, 60] < (60, 999]]` 

反过来,这生成了标签 (60, 999),从显示角度来看,这并不令人满意。如果我们对默认生成的标签不满意,可以通过 labels= 参数控制它们的输出:

`pd.cut(     df["age"],     [10, 20, 30, 40, 50, 60, 999],     labels=["10-20", "20-30", "30-40", "40-50", "50-60", "60+"], )` 
`0     30-40 1     10-20 2     20-30 3     30-40 4     30-40 5     30-40 6     20-30 7       60+ 8     50-60 9     20-30 10    20-30 11    30-40 Name: age, dtype: category Categories (6, object): ['10-20' < '20-30' < '30-40' < '40-50' < '50-60' < '60+']` 

然而,上面的标签并不完全正确。请注意,我们提供了 30-4040-50,但是如果某人恰好是 40 岁呢?他们会被放入哪个区间?

幸运的是,我们可以通过数据中 Steve 的记录看到这一点,他恰好符合这个标准。如果查看他所在的默认区间,它显示为 (30, 40]

`df.assign(age_bin=lambda x: pd.cut(x["age"], [10, 20, 30, 40, 50, 60, 999]))` 
 `name        age     age_bin 0       Jane        34      (30, 40] 1       John        18      (10, 20] 2       Jamie       22      (20, 30] 3       Jessica     36      (30, 40] 4       Jackie      33      (30, 40] 5       Steve       40      (30, 40] 6       Sam         30      (20, 30] 7       Stephanie   66     (60, 999] 8       Sarah       55      (50, 60] 9       Aaron       22      (20, 30] 10      Erin        28      (20, 30] 11      Elsa        37      (30, 40]` 

默认情况下,分箱是右闭的,这意味着每个区间可以被认为是包括特定值。如果我们想要的是不包括特定值的行为,可以通过 right 参数来控制:

`df.assign(     age_bin=lambda x: pd.cut(x["age"], [10, 20, 30, 40, 50, 60, 999], right=False) )` 
 `name      age   age_bin 0       Jane      34    [30, 40) 1       John      18    [10, 20) 2       Jamie     22    [20, 30) 3       Jessica   36    [30, 40) 4       Jackie    33    [30, 40) 5       Steve     40    [40, 50) 6       Sam       30    [30, 40) 7       Stephanie 66    [60, 999) 8       Sarah     55    [50, 60) 9       Aaron     22    [20, 30) 10      Erin      28    [20, 30) 11      Elsa      37    [30, 40)` 

这将 Steve 的区间从 (30, 40] 改为 [40, 50)。在默认的字符串表示中,方括号表示该边界是包含某个特定值的,而圆括号则是不包含的。

使用 pd.get_dummies 进行独热编码

在数据分析和机器学习应用中,将类别型数据转换为 0/1 值的情况并不罕见,因为后者能更容易地被数值算法解释。这一过程通常称为 独热编码,输出通常被称为 虚拟指示符

如何实现

让我们从一个包含离散颜色集的小 pd.Series 开始:

`ser = pd.Series([     "green",     "brown",     "blue",     "amber",     "hazel",     "amber",     "green",     "blue",     "green", ], name="eye_colors", dtype=pd.StringDtype()) ser` 
`0    green 1    brown 2     blue 3    amber 4    hazel 5    amber 6    green 7     blue 8    green Name: eye_colors, dtype: string` 

将其作为参数传递给 pd.get_dummies 将创建一个具有布尔列的 pd.DataFrame,每个颜色对应一列。每一行会有一个 True 的列,将其映射回原始值;该行中的其他所有列将是 False

`pd.get_dummies(ser)` 
 `amber   blue    brown   green   hazel 0       False   False   False   True    False 1       False   False   True    False   False 2       False   True    False   False   False 3       True    False   False   False   False 4       False   False   False   False   True 5       True    False   False   False   False 6       False   False   False   True    False 7       False   True    False   False   False 8       False   False   False   True    False` 

如果我们不满意默认的列名,可以通过添加前缀来修改它们。在数据建模中,一个常见的约定是将布尔列的前缀加上 is_

`pd.get_dummies(ser, prefix="is")` 
 `is_amber  is_blue  is_brown  is_green  is_hazel 0       False     False    False     True      False 1       False     False    True      False     False 2       False     True     False     False     False 3       True      False    False     False     False 4       False     False    False     False     True 5       True      False    False     False     False 6       False     False    False     True      False 7       False     True     False     False     False 8       False     False    False     True      False` 

使用 .pipe 链式调用

在编写 pandas 代码时,开发者通常遵循两种主要的风格形式。第一种方法是大量使用变量,无论是创建新的变量,像这样:

`df = pd.DataFrame(...) df1 = do_something(df) df2 = do_another_thing(df1) df3 = do_yet_another_thing(df2)` 

或者只是反复重新赋值给同一个变量:

`df = pd.DataFrame(...) df = do_something(df) df = do_another_thing(df) df = do_yet_another_thing(df)` 

另一种方法是将代码表达为 管道,每个步骤接受并返回一个 pd.DataFrame

`(     pd.DataFrame(...)     .pipe(do_something)     .pipe(do_another_thing)     .pipe(do_yet_another_thing) )` 

使用基于变量的方法,你必须在程序中创建多个变量,或者在每次重新赋值时改变 pd.DataFrame 的状态。相比之下,管道方法不会创建任何新变量,也不会改变 pd.DataFrame 的状态。

虽然管道方法从理论上讲可以更好地由查询优化器处理,但截至写作时,pandas 并没有提供此类功能,且很难猜测未来可能会是什么样子。因此,选择这两种方法几乎对性能没有影响;这真的是风格上的问题。

我鼓励你熟悉这两种方法。你可能会发现有时将代码表达为管道更容易;而其他时候,可能会觉得那样做很繁琐。没有强制要求使用其中任何一种方法,因此你可以在代码中自由混合和匹配这两种风格。

如何实现

让我们从一个非常基础的 pd.DataFrame 开始。列及其内容暂时不重要:

`df = pd.DataFrame({     "col1": pd.Series([1, 2, 3], dtype=pd.Int64Dtype()),     "col2": pd.Series(["a", "b", "c"], dtype=pd.StringDtype()), }) df` 
 `col1   col2 0     1      a 1     2      b 2     3      c` 

现在让我们创建一些示例函数,这些函数将改变列的内容。这些函数应该接受并返回一个 pd.DataFrame,你可以从代码注释中看到这一点:

`def change_col1(df: pd.DataFrame) -> pd.DataFrame:     return df.assign(col1=pd.Series([4, 5, 6], dtype=pd.Int64Dtype())) def change_col2(df: pd.DataFrame) -> pd.DataFrame:     return df.assign(col2=pd.Series(["X", "Y", "Z"], dtype=pd.StringDtype()))` 

正如在本教程开头提到的,应用这些函数的最常见方法之一是将它们列为程序中的单独步骤,并将每个步骤的结果分配给一个新变量:

`df2 = change_col1(df) df3 = change_col2(df2) df3` 
 `col1   col2 0    4      X  1    5      Y 2    6      Z` 

如果我们希望完全避免使用中间变量,我们也可以尝试将函数调用嵌套在一起:

`change_col2(change_col1(df))` 
 `col1   col2 0     4      X 1     5      Y 2     6      Z` 

然而,这并不会使代码更加易读,特别是考虑到change_col1change_col2之前执行。

通过将这个过程表达为管道,我们可以避免使用变量,并更容易表达应用的操作顺序。为了实现这一点,我们将使用pd.DataFrame.pipe方法:

`df.pipe(change_col1).pipe(change_col2)` 
 `col1    col2 0       4       X 1       5       Y 2       6       Z` 

如你所见,我们得到了与之前相同的结果,但没有使用变量,而且以一种可以说更易读的方式呈现。

如果你想在管道中应用的某些函数需要接受更多参数,pd.DataFrame.pipe可以将它们转发给你。例如,让我们看看如果我们向change_col2函数添加一个新的str_case参数会发生什么:

`from typing import Literal def change_col2(         df: pd.DataFrame,         str_case: Literal["upper", "lower"] ) -> pd.DataFrame:     if str_case == "upper":         values = ["X", "Y", "Z"]     else:         values = ["x", "y", "z"]     return df.assign(col2=pd.Series(values, dtype=pd.StringDtype()))` 

正如你在pd.DataFrame.pipe中看到的,你可以简单地将参数作为位置参数或关键字参数传递,就像你直接调用change_col2函数一样:

`df.pipe(change_col2, str_case="lower")` 
 `col1    col2 0      1       x 1      2       y 2      3       z` 

重申一下我们在本食谱介绍中提到的,这些风格之间几乎没有功能差异。我鼓励你同时学习这两种风格,因为你不可避免地会看到代码同时以这两种方式编写。为了你自身的开发,你甚至可能会发现混合使用这两种方法是最有效的。

从前 100 部电影中选择最低预算的电影

现在我们已经从理论层面覆盖了许多核心的 pandas 算法,我们可以开始看看一些“真实世界”的数据集,并探讨常见的探索方法。

Top N 分析是一种常见技术,通过该技术,你可以根据数据在单一变量上的表现来筛选数据。大多数分析工具都能帮助你筛选数据,以回答类似销售额最高的前 10 个客户是哪些? 或者 库存最少的 10 个产品是哪些? 这样的问题。当这些方法链式调用时,你甚至可以形成引人注目的新闻标题,比如在前 100 所大学中,这 5 所学费最低,或在前 50 个宜居城市中,这 10 个最实惠

由于这些类型的分析非常常见,pandas 提供了内置功能来帮助你轻松执行这些分析。在本食谱中,我们将查看pd.DataFrame.nlargestpd.DataFrame.nsmallest,并看看如何将它们结合使用,以回答类似从前 100 部电影中,哪些电影预算最低?的问题。

如何做

我们从读取电影数据集并选择movie_titleimdb_scorebudgetgross列开始:

`df = pd.read_csv(     "data/movie.csv",     usecols=["movie_title", "imdb_score", "budget", "gross"],     dtype_backend="numpy_nullable", ) df.head()` 
 `gross          movie_title                          budget        imdb_score 0      760505847.0    Avatar                              237000000.0    7.9 1      309404152.0    Pirates of the Caribbean: At World's End  300000000.0    7.1 2      200074175.0    Spectre                             245000000.0    6.8 3      448130642.0    The Dark Knight Rises              250000000.0    8.5 4      <NA>           Star Wars: Episode VII - The Force Awakens  <NA>      7.1` 

pd.DataFrame.nlargest方法可以用来选择按imdb_score排序的前 100 部电影:

`df.nlargest(100, "imdb_score").head()` 
 `gross        movie_title              budget      imdb_score 2725    <NA>         Towering Inferno         <NA>        9.5 1920    28341469.0   The Shawshank Redemption  25000000.0  9.3 3402    134821952.0  The Godfather            6000000.0   9.2 2779    447093.0     Dekalog                  <NA>        9.1 4312    <NA>         Kickboxer: Vengeance     17000000.0  9.1` 

现在我们已经选择了前 100 部电影,我们可以链式调用pd.DataFrame.nsmallest,从中返回五部预算最低的电影:

`df.nlargest(100, "imdb_score").nsmallest(5, "budget")` 
 `gross       movie_title              budget       imdb_score 4804    <NA>        Butterfly Girl           180000.0     8.7 4801    925402.0    Children of Heaven       180000.0     8.5 4706    <NA>        12 Angry Men             350000.0     8.9 4550    7098492.0   A Separation             500000.0     8.4 4636    133778.0    The Other Dream Team     500000.0     8.4` 

还有更多…

可以将列名列表作为columns=参数传递给pd.DataFrame.nlargestpd.DataFrame.nsmallest方法。这在遇到第一列中有重复值共享第 n 名排名时才有用,以此来打破平局。

为了看到这一点的重要性,让我们尝试按imdb_score选择前 10 部电影:

`df.nlargest(10, "imdb_score")` 
 `gross   movie_title     budget  imdb_score 2725    <NA>    Towering Inferno        <NA>     9.5 1920    28341469.0      The Shawshank Redemption        25000000.0     9.3 3402    134821952.0     The Godfather   6000000.0       9.2 2779    447093.0        Dekalog    <NA>    9.1 4312    <NA>    Kickboxer: Vengeance    17000000.0      9.1 66      533316061.0     The Dark Knight 185000000.0     9.0 2791    57300000.0      The Godfather: Part II     13000000.0      9.0 3415    <NA>    Fargo   <NA>    9.0 335     377019252.0     The Lord of the Rings: The Return of the King   94000000.0     8.9 1857    96067179.0      Schindler's List        22000000.0      8.9` 

正如你所看到的,前 10 名中的最低imdb_score8.9。然而,实际上有超过 10 部电影的评分为8.9或更高:

`df[df["imdb_score"] >= 8.9]` 
 `gross   movie_title     budget  imdb_score 66      533316061.0     The Dark Knight 185000000.0      9.0 335     377019252.0     The Lord of the Rings: The Return of the King   94000000.0     8.9 1857    96067179.0      Schindler's List        22000000.0      8.9 1920    28341469.0      The Shawshank Redemption       25000000.0      9.3 2725    <NA>    Towering Inferno        <NA>    9.5 2779    447093.0        Dekalog <NA>    9.1 2791    57300000.0      The Godfather: Part II      13000000.0    9.0 3295    107930000.0     Pulp Fiction    8000000.0       8.9 3402    134821952.0     The Godfather   6000000.0       9.2 3415    <NA>    Fargo   <NA>    9.0 4312    <NA>    Kickboxer: Vengeance    17000000.0      9.1 4397    6100000.0       The Good, the Bad and the Ugly    1200000.0      8.9 4706    <NA>    12 Angry Men     350000.0      8.9` 

那些出现在前 10 名的电影,恰好是 pandas 遇到的前两部评分为该分数的电影。但是,你可以使用gross列作为平局的决胜者:

`df.nlargest(10, ["imdb_score", "gross"])` 
 `gross   movie_title     budget  imdb_score 2725    <NA>    Towering Inferno        <NA>     9.5 1920    28341469.0      The Shawshank Redemption        25000000.0      9.3 3402    134821952.0     The Godfather   6000000.0       9.2 2779    447093.0        Dekalog    <NA>    9.1 4312    <NA>    Kickboxer: Vengeance    17000000.0      9.1 66      533316061.0     The Dark Knight    185000000.0     9.0 2791    57300000.0      The Godfather: Part II    13000000.0        9.0 3415    <NA>    Fargo   <NA>    9.0 335     377019252.0     The Lord of the Rings: The Return of the King   94000000.0      8.9 3295    107930000.0     Pulp Fiction    8000000.0       8.9` 

因为《低俗小说》票房更高,所以你可以看到它取代了《辛德勒的名单》成为我们前 10 名分析中的一部分。

计算追踪止损单价格

有许多股票交易策略。许多投资者采用的一种基本交易类型是止损单。止损单是投资者在市场价格达到某个点时执行的买入或卖出股票的订单。止损单有助于防止巨大的亏损并保护收益。

在典型的止损单中,价格在整个订单生命周期内不会变化。例如,如果你以每股$100 的价格购买了一只股票,你可能希望在每股$90 设置止损单,以限制你的最大损失为 10%。

一种更高级的策略是不断调整止损单的售价,以跟踪股票价值的变化,如果股票上涨。这被称为追踪止损单。具体来说,如果一只价格为$100 的股票上涨到$120,那么一个低于当前市场价 10%的追踪止损单会将售价调整为$108。

追踪止损单从不下调,总是与购买时的最高价值挂钩。如果股票从$120 跌到$110,止损单仍然会保持在$108。只有当价格超过$120 时,止损单才会上调。

这段代码通过pd.Series.cummax方法,根据任何股票的初始购买价格来确定追踪止损单价格,并展示了如何使用pd.Series.cummin来处理短仓头寸。我们还将看到如何使用pd.Series.idxmax方法来识别止损单被触发的那一天。

操作步骤

为了开始,我们将以 Nvidia(NVDA)股票为例,假设在 2020 年第一交易日进行购买:

`df = pd.read_csv(     "data/NVDA.csv",     usecols=["Date", "Close"],     parse_dates=["Date"],     index_col=["Date"],     dtype_backend="numpy_nullable", ) df.head()` 
`ValueError: not all elements from date_cols are numpy arrays` 

在 pandas 2.2 版本中,存在一个 bug,导致前面的代码块无法运行,反而抛出ValueError错误。如果遇到这个问题,可以选择不使用dtype_backend参数运行pd.read_csv,然后改为调用pd.DataFrame.convert_dtypes

`df = pd.read_csv(     "data/NVDA.csv",     usecols=["Date", "Close"],     parse_dates=["Date"],     index_col=["Date"], ).convert_dtypes(dtype_backend="numpy_nullable") df.head()` 
 `Close Date 2020-01-02     59.977501 2020-01-03     59.017502 2020-01-06     59.264999 2020-01-07     59.982498 2020-01-08     60.095001` 

更多信息请参见 pandas 的 bug 问题#57930github.com/pandas-dev/pandas/issues/57930)。

无论您采取了哪条路径,请注意,pd.read_csv返回一个pd.DataFrame,但对于本分析,我们只需要一个pd.Series。为了进行转换,您可以调用pd.DataFrame.squeeze,如果可能的话,它将把对象从二维减少到一维:

`ser = df.squeeze() ser.head()` 
`Date 2020-01-02    59.977501 2020-01-03    59.017502 2020-01-06    59.264999 2020-01-07    59.982498 2020-01-08    60.095001 Name: Close, dtype: float64` 

这样,我们可以使用pd.Series.cummax方法来跟踪到目前为止观察到的最高收盘价:

`ser_cummax = ser.cummax() ser_cummax.head()` 
`Date 2020-01-02    59.977501 2020-01-03    59.977501 2020-01-06    59.977501 2020-01-07    59.982498 2020-01-08    60.095001 Name: Close, dtype: float64` 

为了创建一个将下行风险限制在 10%的止损订单,我们可以链式操作,将其乘以0.9

`ser.cummax().mul(0.9).head()` 
`Date 2020-01-02    53.979751 2020-01-03    53.979751 2020-01-06    53.979751 2020-01-07    53.984248 2020-01-08    54.085501 Name: Close, dtype: float64` 

pd.Series.cummax方法通过保留截至当前值为止遇到的最大值来工作。将该系列乘以 0.9,或者使用您希望的任何保护系数,即可创建止损订单。在这个特定的例子中,NVDA 的价值增加,因此其止损也随之上升。

另一方面,假设我们对 NVDA 股票在这段时间内持悲观看法,并且我们想要做空该股票。然而,我们仍然希望设置一个止损订单,以限制下跌幅度不超过 10%。

为此,我们只需将pd.Series.cummax的使用替换为pd.Series.cummin,并将0.9改为1.1即可:

`ser.cummin().mul(1.1).head()` 
`Date 2020-01-02    65.975251 2020-01-03    64.919252 2020-01-06    64.919252 2020-01-07    64.919252 2020-01-08    64.919252 Name: Close, dtype: float64` 

还有更多内容…

通过计算我们的止损订单,我们可以轻松地确定在哪些日子我们会跌破累计最大值,超过我们的设定阈值。

`stop_prices = ser.cummax().mul(0.9) ser[ser <= stop_prices]` 
`Date 2020-02-24     68.320000 2020-02-25     65.512497 2020-02-26     66.912498 2020-02-27     63.150002 2020-02-28     67.517502                  ...     2023-10-27    405.000000 2023-10-30    411.609985 2023-10-31    407.799988 2023-11-01    423.250000 2023-11-02    435.059998 Name: Close, Length: 495, dtype: float64` 

如果我们只关心找出我们第一次跌破累计最大值的那一天,我们可以使用pd.Series.idxmax方法。该方法通过首先计算pd.Series中的最大值,然后返回出现该最大值的第一行索引:

`(ser <= stop_prices).idxmax()` 
`Timestamp('2020-02-24 00:00:00')` 

表达式ser <= stop_prices会返回一个布尔类型的pd.Series,其中包含True/False值,每个True记录表示股票价格在我们已经计算出的止损价格或以下。pd.Series.idxmax会将True视为该pd.Series中的最大值;因此,返回第一次遇到True的索引标签,它告诉我们应该触发止损订单的第一天。

这个示例让我们初步了解了 pandas 在证券交易中的应用价值。

寻找最擅长的棒球选手...

美国棒球运动长期以来一直是激烈分析研究的对象,数据收集可以追溯到 20 世纪初。对于美国职业棒球大联盟的球队,先进的数据分析帮助回答诸如我应该为 X 球员支付多少薪水?以及在当前局势下,我应该在比赛中做什么?这样的问题。对于球迷来说,同样的数据也可以作为无尽辩论的素材,讨论谁是历史上最伟大的球员

对于这个示例,我们将使用从retrosheet.org收集的数据。根据 Retrosheet 的许可要求,您应注意以下法律免责声明:

此处使用的信息是免费获取的,并由 Retrosheet 版权所有。有兴趣的各方可以联系 Retrosheet,网址是www.retrosheet.org

从原始形式中,这些数据被汇总以显示 2020 年至 2023 年间职业球员的常见棒球统计指标,包括打数(ab)、安打(h)、得分(r)和全垒打(hr)。

如何做到

让我们从读取我们的汇总数据和将id列(表示唯一球员)设置为索引开始:

`df = pd.read_parquet(     "data/mlb_batting_summaries.parquet", ).set_index("id") df` 
 `ab      r       h       hr id  abadf001        0       0       0       0 abboa001        0       0       0       0 abboc001        3       0       1       0 abrac001        847     116     208     20 abrea001        0       0       0       0 …               …       …       …       … zimmk001        0       0       0       0 zimmr001        255     27      62      14 zubet001        1       0       0       0 zunig001        0       0       0       0 zunim001        572     82      111     41 2183 rows × 4 columns` 

在棒球中,一个球员很少能在所有统计类别中占据主导地位。通常情况下,一位具有许多全垒打(hr)的球员更具威力,能够将球打得更远,但可能比一个更专注于收集大量安打(h)的球员频率较低。有了 pandas,我们幸运地不必深入研究每个指标;只需简单调用pd.DataFrame.idxmax就可以查看每一列,找到最大值,并返回与该最大值关联的行索引值:

`df.idxmax()` 
`ab    semim001 r     freef001 h     freef001 hr    judga001 dtype: string` 

正如您所看到的,球员semim001(Marcus Semien)在上场次数方面表现最佳,freef001(Freddie Freeman)在得分和安打方面表现最佳,而judga001(Aaron Judge)在这段时间内敲出了最多的全垒打。

如果您想深入了解这些优秀球员在所有类别中的表现,可以使用pd.DataFrame.idxmax的输出,随后对这些值调用pd.Series.unique作为整体pd.DataFrame的掩码:

`best_players = df.idxmax().unique() mask = df.index.isin(best_players) df[mask]` 
 `ab      r       h       hr id freef001  1849    368     590     81 judga001  1487    301     433    138 semim001  1979    338     521    100` 

这还不止于此…

要为这些数据提供良好的视觉增强效果,您可以使用pd.DataFrame.style.highlight_max来非常具体地显示这些球员在哪个类别表现最佳:

`df[mask].style.highlight_max()` 

图 5.1:Jupyter Notebook 输出的 DataFrame,突出显示每列的最大值

了解哪个位置在每支球队中得分最高

在棒球中,每支球队可以有 9 名“打击阵容”球员,1 代表第一个击球手,9 代表最后一个。在比赛过程中,球队按顺序循环轮换击球手,第一位击球手在最后一位击球手击球后重新开始。

通常,球队会把一些最佳击球手放在“阵容的前面”(即较低的位置),以最大化他们的得分机会。然而,这并不总是意味着第一位击球手总是第一个得分的人。

在这个示例中,我们将查看从 2000 年到 2023 年的所有大联盟棒球队,并找出在每个赛季中为球队得分最多的位置。

如何做到

就像我们在寻找棒球运动员最擅长…的示例中所做的那样,我们将使用从retrosheet.org获取的数据。对于这个特定的数据集,我们将把yearteam列设置为行索引,剩余的列用于显示击球顺序中的位置:

`df = pd.read_parquet(     "data/runs_scored_by_team.parquet", ).set_index(["year", "team"]) df` 
 `1    2    3    …    7    8    9 year  team 2000  ANA  124  107  100  …   77   76   54       ARI  110  106  109  …   72   68   40       ATL  113  125  124  …   77   74   39       BAL  106  106   92  …   83   78   74       BOS   99  107   99  …   75   66   62 …     …    …    …    …    …   …    …    … 2023  SLN  105   91   85  …   70   55   74       TBA  121  120   93  …   78   95   98       TEX  126  115   91  …   80   87   81       TOR   91   97   85  …   64   70   79       WAS  110   90   87  …   63   67   64 720 rows × 9 columns` 

使用pd.DataFrame.idxmax,我们可以查看每年和每个队伍中哪个位置得分最高。然而,在这个数据集中,我们希望pd.DataFrame.idxmax识别的索引标签实际上是在列中,而不是行中。幸运的是,pandas 仍然可以通过axis=1参数轻松计算这个:

`df.idxmax(axis=1)` 
`year  team 2000  ANA     1       ARI     1       ATL     2       BAL     1       BOS     4             ... 2023  SLN     1       TBA     1       TEX     1       TOR     2       WAS     1 Length: 720, dtype: object` 

从那里,我们可以使用pd.Series.value_counts来了解在队伍中,某一位置代表得分最多的次数。我们还将使用normalize=True参数,它将为我们提供频率而不是总数:

`df.idxmax(axis=1).value_counts(normalize=True)` 
`1    0.480556 2    0.208333 3    0.202778 4    0.088889 5    0.018056 6    0.001389 Name: proportion, dtype: float64` 

不出所料,得分最多的首位打者通常会占据得分最多的位置,约 48%的队伍如此。

还有更多……

我们可能想深入探索并回答这个问题:对于首位打者得分最多的队伍,谁得分第二多

为了计算这个,我们可以创建一个掩码来筛选出首位打者得分最多的队伍,然后从数据集中删除该列,然后重复相同的pd.DataFrame.idxmax方法来识别下一个位置:

`mask = df.idxmax(axis=1).eq("1") df[mask].drop(columns=["1"]).idxmax(axis=1).value_counts(normalize=True)` 
`2    0.497110 3    0.280347 4    0.164740 5    0.043353 6    0.014451 Name: proportion, dtype: float64` 

正如你所看到的,如果一个队伍的首位打者并没有得分最多,第二位打者几乎 50%的情况下会成为得分最多的人。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

第六章:可视化

可视化是探索性数据分析、演示和应用中的关键组成部分。在进行探索性数据分析时,你通常是单独工作或在小组中,需要快速创建图表来帮助你更好地理解数据。可视化可以帮助你识别异常值和缺失数据,或者激发其他有趣的问题,进而进行进一步分析和更多可视化。这种类型的可视化通常不是为了最终用户而设计的,它仅仅是为了帮助你更好地理解当前的数据。图表不必是完美的。

在为报告或应用程序准备可视化时,必须采用不同的方法。你应该关注细节。而且,通常你需要将所有可能的可视化方式缩小到少数几个最能代表数据的方式。好的数据可视化能够让观众享受提取信息的过程。就像让观众沉浸其中的电影一样,好的可视化会包含大量能够激发兴趣的信息。

默认情况下,pandas 提供了 pd.Series.plotpd.DataFrame.plot 方法,帮助你快速生成图表。这些方法会调度到一个绘图后端,默认是 Matplotlib (matplotlib.org/)。

我们将在本章稍后讨论不同的后端,但现在,让我们先安装 Matplotlib 和 PyQt5,Matplotlib 用它们来绘制图表:

`python -m pip install matplotlib pyqt5` 

本章中的所有代码示例假设前面已经导入以下内容:

`import matplotlib.pyplot as plt plt.ion()` 

上述命令启用了 Matplotlib 的交互模式,每次执行绘图命令时,它会自动创建和更新图表。如果出于某种原因你运行了绘图命令但图表没有出现,你可能处于非交互模式(你可以通过 matplotlib.pyplot.isinteractive() 来检查),此时你需要显式调用 matplotlib.pyplot.show() 来显示图表。

本章我们将讨论以下几个案例:

  • 从聚合数据中创建图表

  • 绘制非聚合数据的分布

  • 使用 Matplotlib 进行进一步的图表定制

  • 探索散点图

  • 探索分类数据

  • 探索连续数据

  • 使用 seaborn 绘制高级图表

从聚合数据中创建图表

pandas 库使得可视化 pd.Seriespd.DataFrame 对象中的数据变得容易,分别使用 pd.Series.plotpd.DataFrame.plot 方法。在本案例中,我们将从相对基础的折线图、条形图、面积图和饼图开始,同时了解 pandas 提供的高级定制选项。虽然这些图表类型较为简单,但有效地使用它们对于探索数据、识别趋势以及与非技术人员分享你的研究结果都非常有帮助。

需要注意的是,这些图表类型期望你的数据已经被聚合,我们在本教程中的示例数据也反映了这一点。如果你正在处理的数据尚未聚合,你将需要使用在第七章《重塑数据框》与第八章《分组》所涉及的技术,或者使用本章后续“使用 Seaborn 进行高级绘图”教程中展示的技术。

如何操作

让我们创建一个简单的pd.Series,显示 7 天内的书籍销售数据。我们故意使用类似Day n的行索引标签,这将为我们创建的不同图表类型提供良好的视觉提示:

`ser = pd.Series(     (x ** 2 for x in range(7)),     name="book_sales",     index=(f"Day {x + 1}" for x in range(7)),     dtype=pd.Int64Dtype(), ) ser` 
`Day 1     0 Day 2     1 Day 3     4 Day 4     9 Day 5    16 Day 6    25 Day 7    36 Name: book_sales, dtype: Int64` 

如果调用pd.Series.plot而不传递任何参数,将会生成一张折线图,x轴上的标签来自行索引,而Y轴上的数值对应pd.Series中的数据:

`ser.plot()` 

折线图将我们的数据视为完全连续的,产生的可视化效果似乎展示了每一天之间的数值,尽管我们的数据中并没有这些值。对于我们的pd.Series,更好的可视化是条形图,它将每一天离散地展示,我们只需将kind="bar"参数传递给pd.Series.plot方法即可获得:

`ser.plot(kind="bar")` 

再次提醒,行索引标签出现在X轴上,数值出现在Y轴上。这有助于你从左到右阅读可视化,但在某些情况下,你可能会发现从上到下阅读数值更容易。在 pandas 中,这种可视化被认为是横向条形图,可以通过使用kind="barh"参数来渲染:

`ser.plot(kind="barh")` 

一张带有蓝色条形的图表

kind="area"参数会生成一个区域图,它像折线图一样,但填充了线下的区域:

`ser.plot(kind="area")` 

一张带数字的蓝色折线图

最后但同样重要的是,我们有饼图。与之前介绍的所有可视化不同,饼图没有 x 轴和 y 轴。相反,每个来自行索引的标签代表饼图中的一个不同切片,其大小由我们pd.Series中相关的数值决定:

`ser.plot(kind="pie")` 

带有数字和天数的饼图

在使用pd.DataFrame时,生成图表的 API 保持一致,尽管你可能需要提供更多的关键字参数来获得期望的可视化效果。

为了看到这一点,让我们扩展数据,展示book_salesbook_returns

`df = pd.DataFrame({     "book_sales": (x ** 2 for x in range(7)),     "book_returns": [3, 2, 1, 0, 1, 2, 3], }, index=(f"Day {x + 1}" for x in range(7))) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `book_sales   book_returns Day 1   0            3 Day 2   1            2 Day 3   4            1 Day 4   9            0 Day 5   16           1 Day 6   25           2 Day 7   36           3` 

就像我们在pd.Series.plot中看到的那样,默认调用pd.DataFrame.plot会给我们一张折线图,每一列由自己的线表示:

`df.plot()` 

再次强调,要将其转为条形图,你只需向绘图方法传递kind="bar"参数:

`df.plot(kind="bar")` 

默认情况下,pandas 会将每一列作为单独的条形图呈现。如果您想将这些列堆叠在一起,请传递stacked=True

`df.plot(kind="bar", stacked=True)` 

使用水平条形图时也可以看到相同的行为。默认情况下,列不会堆叠:

`df.plot(kind="barh")` 

但是传递stacked=True会将条形图堆叠在一起:

`df.plot(kind="barh", stacked=True)` 

当使用pd.DataFrame绘制面积图时,默认行为是将列堆叠在一起:

`df.plot(kind="area")` 

要取消堆叠,传递stacked=False并添加alpha=参数以引入透明度。此参数的值应在 0 和 1 之间,值越接近 0,图表的透明度越高:

`df.plot(kind="area", stacked=False, alpha=0.5)` 

还有更多……

本食谱中的示例使用了最少的参数来生成可视化图形。然而,绘图方法接受更多的参数,以控制标题、标签、颜色等内容。

如果您想为可视化添加标题,只需将其作为title=参数传递:

`ser.plot(     kind="bar",     title="Book Sales by Day", )` 

color=参数可用于更改图表中线条、条形和标记的颜色。颜色可以通过 RGB 十六进制代码(例如,#00008B表示深蓝色)或使用 Matplotlib 命名颜色(如seagreen)来表示(matplotlib.org/stable/gallery/color/named_colors.html):

`ser.plot(     kind="bar",     title="Book Sales by Day",     color="seagreen", )` 

当使用pd.DataFrame时,您可以将字典传递给pd.DataFrame.plot,以控制哪些列使用哪些颜色:

`df.plot(     kind="bar",     title="Book Metrics",     color={         "book_sales": "slateblue",         "book_returns": "#7D5260",     } )` 

grid=参数控制是否显示网格线:

`ser.plot(     kind="bar",     title="Book Sales by Day",     color="teal",     grid=False, )` 

您可以使用xlabel=ylabel=参数来控制您的x轴和y轴的标签:

`ser.plot(     kind="bar",     title="Book Sales by Day",     color="darkgoldenrod",     grid=False,     xlabel="Day Number",     ylabel="Book Sales", )` 

当使用pd.DataFrame时,pandas 默认将每一列的数据放在同一张图表上。然而,您可以通过subplots=True轻松生成独立的图表:

`df.plot(     kind="bar",     title="Book Performance",     grid=False,     subplots=True, )` 

对于独立的图表,图例变得多余。要关闭图例,只需传递legend=False

`df.plot(     kind="bar",     title="Book Performance",     grid=False,     subplots=True,     legend=False, )` 

在使用子图时,值得注意的是,默认情况下,x轴的标签是共享的,但y轴的数值范围可能不同。如果您希望y轴也共享,只需在方法调用中添加sharey=True

`df.plot(     kind="bar",     title="Book Performance",     grid=False,     subplots=True,     legend=False,     sharey=True, )` 

当使用pd.DataFrame.plot时,y=参数可以控制哪些列需要可视化,这在您不希望所有列都显示时非常有用:

`df.plot(     kind="barh",     y=["book_returns"],     title="Book Returns",     legend=False,     grid=False,     color="seagreen", )` 

如你所见,pandas 提供了丰富的选项来控制显示内容和方式。尽管 pandas 会尽最大努力确定如何在可视化中布置这些元素,但它不一定总能做到完美。在本章后面,使用 Matplotlib 进一步定制图表的示例将向你展示如何更精细地控制你的可视化布局。

绘制非聚合数据的分布

可视化在识别数据中的模式和趋势时非常有帮助。你的数据是正态分布的吗?是否偏左?是否偏右?是多峰分布吗?虽然你可能能自己得出这些问题的答案,但可视化能够轻松地突出这些模式,从而深入洞察你的数据。

在这个示例中,我们将看到 pandas 如何轻松地帮助我们可视化数据的分布。直方图是绘制分布的非常流行的选择,因此我们将从它们开始,然后展示更强大的核密度估计KDE)图。

如何实现

我们先用 10,000 个随机记录创建一个pd.Series,这些数据已知遵循正态分布。NumPy 可以方便地生成这些数据:

`np.random.seed(42) ser = pd.Series(     np.random.default_rng().normal(size=10_000),     dtype=pd.Float64Dtype(), ) ser` 
`0       0.049174 1      -1.577584 2      -0.597247 3        -0.0198 4       0.938997          ...    9995   -0.141285 9996    1.363863 9997   -0.738816 9998   -0.373873 9999   -0.070183 Length: 10000, dtype: Float64` 

可以使用直方图来绘制这些数据,方法是使用kind="hist"参数:

`ser.plot(kind="hist")` 

带数字的蓝色图形

直方图并不是尝试绘制每一个单独的点,而是将我们的值放入一个自动生成的数量的“箱子”中。每个箱子的范围绘制在可视化的 X 轴上,而每个箱子内的出现次数显示在直方图的 Y 轴上。

由于我们已经创建了可视化的数据,并且知道它是一个正态分布的数字集合,因此前面的直方图也显示了这一点。不过,我们可以通过提供bins=参数给pd.Series.plot来选择不同的箱数,这会显著影响可视化效果及其解释方式。

举例来说,如果我们传递 bins=2,我们将得到极少的箱子,以至于我们的正态分布不再明显:

`ser.plot(kind="hist", bins=2)` 

另一方面,传递 bins=100 可以清楚地看到,我们通常有一个正态分布:

`ser.plot(kind="hist", bins=100)` 

带数字的蓝色图形,描述自动生成

当使用 pd.DataFrame 绘制直方图时,同样的问题也会出现。为说明这一点,让我们创建一个包含两列的 pd.DataFrame,其中一列是正态分布的,另一列则使用三角分布:

`np.random.seed(42) df = pd.DataFrame({     "normal": np.random.default_rng().normal(size=10_000),     "triangular": np.random.default_rng().triangular(-2, 0, 2, size=10_000), }) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.head()` 
 `normal     triangular 0  -0.265525   -0.577042 1   0.327898   -0.391538 2  -1.356997   -0.110605 3   0.004558    0.71449 4   1.03956     0.676207` 

pd.DataFrame.plot 的基本绘图调用将生成如下图表:

`df.plot(kind="hist")` 

不幸的是,一个分布的箱子与另一个分布的箱子重叠了。你可以通过引入一些透明度来解决这个问题:

`df.plot(kind="hist", alpha=0.5)` 

或生成子图:

`df.plot(kind="hist", subplots=True)` 

初看这些分布似乎差不多,但使用更多的分箱后就会发现它们并不相同:

`df.plot(kind="hist", alpha=0.5, bins=100)` 

虽然直方图很常用,但分箱选择对数据解释的影响确实是个不幸之处;你不希望因为选择了“错误”的分箱数量而改变对数据的解释!

幸运的是,你可以使用一个类似但更强大的可视化方式,它不需要你选择任何类型的分箱策略,这就是 核密度估计(或 KDE)图。要使用此图,你需要安装 SciPy:

`python -m pip install scipy` 

安装 SciPy 后,你可以简单地将 kind="kde" 传递给 pd.Series.plot

`ser.plot(kind="kde")` 

A blue line graph with numbers  Description automatically generated

对于我们的 pd.DataFrame,KDE 图清晰地表明我们有两个不同的分布:

`df.plot(kind="kde")` 

使用 Matplotlib 进一步自定义图表

对于非常简单的图表,默认布局可能足够用,但你不可避免地会遇到需要进一步调整生成的可视化的情况。为了超越 pandas 的开箱即用功能,了解一些 Matplotlib 的术语是很有帮助的。在 Matplotlib 中,figure 是指绘图区域,而 axessubplot 是指你可以绘制的区域。请小心不要将 axes(用于绘制数据的区域)与 axis(指的是 XY 轴)混淆。

如何操作

让我们从我们的图书销售数据的 pd.Series 开始,尝试在同一张图表上三种不同方式绘制它——一次作为线图,一次作为柱状图,最后一次作为饼图。为了设置绘图区域,我们将调用 plt.subplots(nrows=1, ncols=3),基本上告诉 matplotlib 我们希望绘制区域有多少行和列。这将返回一个包含图形本身和一个 Axes 对象序列的二元组,我们可以在这些对象上进行绘制。我们将其解包为 figaxes 两个变量。

因为我们要求的是一行三列,返回的 axes 序列长度将为三。我们可以将 pandas 绘图时使用的单独 Axes 对象传递给 pd.DataFrame.plotax= 参数。我们第一次尝试绘制这些图表的效果应该如下所示,结果是,嗯,简直丑陋:

`ser = pd.Series(     (x ** 2 for x in range(7)),     name="book_sales",     index=(f"Day {x + 1}" for x in range(7)),     dtype=pd.Int64Dtype(), ) fig, axes = plt.subplots(nrows=1, ncols=3) ser.plot(ax=axes[0]) ser.plot(kind="bar", ax=axes[1]) ser.plot(kind="pie", ax=axes[2])` 

因为我们没有告诉它其他设置,Matplotlib 给了我们三个大小相等的 axes 对象来绘制。然而,这使得上面的线形图和柱状图非常高而窄,最终我们在饼图上下产生了大量的空白区域。

为了更精细地控制这一点,我们可以使用 Matplotlib 的 GridSpec 来创建一个 2x2 的网格。这样,我们可以将柱状图和线形图并排放置在第一行,然后让饼图占据整个第二行:

`from matplotlib.gridspec import GridSpec   fig = plt.figure() gs = GridSpec(2, 2, figure=fig) ax0 = fig.add_subplot(gs[0, 0]) ax1 = fig.add_subplot(gs[0, 1]) ax2 = fig.add_subplot(gs[1, :]) ser.plot(ax=ax0) ser.plot(kind="bar", ax=ax1) ser.plot(kind="pie", ax=ax2)` 

A graph and pie chart  Description automatically generated

这样看起来好一些,但现在我们依然有一个问题:饼图的标签与条形图的X轴标签重叠。幸运的是,我们仍然可以单独修改每个坐标轴对象来旋转标签、移除标签、修改标题等。

`from matplotlib.gridspec import GridSpec fig = plt.figure() fig.suptitle("Book Sales Visualized in Different Ways") gs = GridSpec(2, 2, figure=fig, hspace=.5) ax0 = fig.add_subplot(gs[0, 0]) ax1 = fig.add_subplot(gs[0, 1]) ax2 = fig.add_subplot(gs[1, :]) ax0 = ser.plot(ax=ax0) ax0.set_title("Line chart") ax1 = ser.plot(kind="bar", ax=ax1) ax1.set_title("Bar chart") ax1.set_xticklabels(ax1.get_xticklabels(), rotation=45) # Remove labels from chart and show in custom legend instead ax2 = ser.plot(kind="pie", ax=ax2, labels=None) ax2.legend(     ser.index,     bbox_to_anchor=(1, -0.2, 0.5, 1),  # put legend to right of chart     prop={"size": 6}, # set font size for legend ) ax2.set_title("Pie Chart") ax2.set_ylabel(None)  # remove book_sales label` 

A graph and pie chart  Description automatically generated

使用 Matplotlib 绘制图表的定制化程度是没有限制的,遗憾的是,在本书中我们无法触及这一话题的表面。如果你对可视化非常感兴趣,我强烈建议你阅读 Matplotlib 的文档或找到一本专门的书籍来深入了解。然而,许多仅仅想查看自己数据的用户可能会觉得过多的定制化处理是一个负担。对于这些用户(包括我自己),幸运的是,还有像 seaborn 这样的高级绘图库,可以用最小的额外努力制作出更美观的图表。本章后面关于使用 seaborn 绘制高级图表的章节将让你了解这个库有多么有用。

探索散点图

散点图是你可以创建的最强大的可视化类型之一。在一个非常紧凑的区域内,散点图可以帮助你可视化两个变量之间的关系,衡量单个数据点的规模,甚至看到这些关系和规模如何在不同类别中变化。能够有效地在散点图中可视化数据,代表着分析能力的一大飞跃,相较于我们到目前为止看到的一些更常见的可视化方式。

在本章节中,我们将探索如何仅在一个散点图上同时衡量所有这些内容。

如何实现

散点图从定义上讲,衡量至少两个变量之间的关系。因此,散点图只能通过pd.DataFrame创建。pd.Series简单来说没有足够的变量。

话虽如此,让我们创建一个示例pd.DataFrame,其中包含四列不同的数据。三列是连续变量,第四列是颜色,我们最终将用它来对不同的数据点进行分类:

`df = pd.DataFrame({     "var_a": [1, 2, 3, 4, 5],     "var_b": [1, 2, 4, 8, 16],     "var_c": [500, 200, 600, 100, 400],     "var_d": ["blue", "orange", "gray", "blue", "gray"], }) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `var_a  var_b  var_c  var_d 0   1      1      500    blue 1   2      2      200    orange 2   3      4      600    gray 3   4      8      100    blue 4   5      16     400    gray` 

除了kind="scatter"之外,我们还需要明确控制绘制在X轴上的内容,绘制在Y轴上的内容,每个数据点的大小,以及每个数据点应该呈现的颜色。这些都可以通过x=, y=, s=, 和 c= 参数来控制:

`df.plot(     kind="scatter",     x="var_a",     y="var_b",     s="var_c",     c="var_d", )` 

像这样的简单散点图并不特别有趣,但现在我们已经掌握了基础知识,让我们试用一个更具现实感的数据集。美国能源部发布了年度报告(www.fueleconomy.gov/feg/download.shtml),总结了对在美国销售的车辆进行的详细燃油经济性测试的结果。这本书包括了一个涵盖 1985 年至 2025 年模型年份的本地副本。

目前,我们只读取一些对我们有兴趣的列,即 city08(城市油耗,英里/加仑)、highway08(高速公路油耗,英里/加仑)、VClass(紧凑型车、SUV 等)、fuelCost08(年度燃油成本)和每辆车的模型 year(有关此数据集包含的所有术语的完整定义,请参阅 www.fueleconomy.gov):

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     usecols=["city08", "highway08", "VClass", "fuelCost08", "year"], ) df.head()` 
 `city08    fuelCost08   highway08    VClass           year 0      19         2,450          25    Two Seaters      1985 1       9         4,700          14    Two Seaters      1985 2      23         1,900          33    Subcompact Cars  1985 3      10         4,700          12    Vans             1985 4      17         3,400          23    Compact Cars     1993` 

该数据集包括许多不同的车辆类别,因此为了让我们的分析更集中,暂时我们只关注 2015 年及之后的不同车型。卡车、SUV 和厢式车可以留到另一个分析中:

`car_classes = (     "Subcompact Cars",     "Compact Cars",     "Midsize Cars",     "Large Cars",     "Two Seaters", ) mask = (df["year"] >= 2015) & df["VClass"].isin(car_classes) df = df[mask] df.head()` 
 `city08   fuelCost08   highway08    VClass             year 27058      16        3,400          23    Subcompact Cars    2015 27059      20        2,250          28    Compact Cars       2015 27060      26        1,700          37    Midsize Cars       2015 27061      28        1,600          39    Midsize Cars       2015 27062      25        1,800          35    Midsize Cars       2015` 

散点图可以帮助我们回答这样一个问题:城市油耗和高速公路油耗之间的关系是什么? 通过将这些列分别绘制在 X 轴和 Y 轴上:

`df.plot(     kind="scatter",     x="city08",     y="highway08", )` 

也许不令人惊讶的是,存在一个强烈的线性趋势。车辆在城市道路上获得的油耗越好,它在高速公路上的油耗也越好。

当然,我们仍然看到值的分布相当大;许多车辆集中在 10–35 MPG 范围内,但有些超过 100。为了进一步深入,我们可以为每个车辆类别分配颜色,并将其添加到可视化中。

这有很多方法可以实现,但通常最好的方法之一是确保你想要用作颜色值的变量是一个分类数据类型:

`classes_ser = pd.Series(car_classes, dtype=pd.StringDtype()) cat = pd.CategoricalDtype(classes_ser) df["VClass"] = df["VClass"].astype(cat) df.head()` 
 `city08  fuelCost08  highway08  VClass           year 27058  16      3,400       23         Subcompact Cars  2015 27059  20      2,250       28         Compact Cars     2015 27060  26      1,700       37         Midsize Cars     2015 27061  28      1,600       39         Midsize Cars     2015 27062  25      1,800       35         Midsize Cars     2015` 

解决了这些问题后,你可以将分类列传递给 pd.DataFrame.plotc= 参数:

`df.plot(     kind="scatter",     x="city08",     y="highway08",     c="VClass", )` 

不同大小和颜色的图形,描述自动生成,信心中等

添加一个 colormap= 参数可能有助于在视觉上区分数据点。有关该参数可接受的值列表,请参阅 Matplotlib 文档(matplotlib.org/stable/users/explain/colors/colormaps.html):

`df.plot(     kind="scatter",     x="city08",     y="highway08",     c="VClass",     colormap="Dark2", )` 

从这些图表中,我们可以推测一些事情。虽然“二座车”不多,但当它们出现时,往往在城市和高速公路的油耗表现都较差。“中型车”似乎主导了 40–60 MPG 范围,但当你查看那些在城市或高速公路上都能达到 100 MPG 或更好的车辆时,“大型车”和“中型车”似乎都相对较好。

到目前为止,我们已经使用了X轴、Y轴和散点图的颜色来深入分析数据,但我们可以更进一步,按燃油成本对每个数据点进行缩放,传递fuelCost08作为 s=参数:

`df.plot(     kind="scatter",     x="city08",     y="highway08",     c="VClass",     colormap="Dark2",     s="fuelCost08", )` 

自动生成的图表,描述了一行彩色圆圈,具有中等信心

这里每个气泡的大小可能太大,不太实用。我们的燃油经济性列中的值范围是几千,这造成了一个过于庞大的图表区域,难以有效使用。只需对这些值进行缩放,就能快速得到一个更合理的可视化;在这里,我选择将其除以 25,并使用alpha=参数引入一些透明度,得到一个更令人满意的图表:

`df.assign(     scaled_fuel_cost=lambda x: x["fuelCost08"] / 25, ).plot(     kind="scatter",     x="city08",     y="highway08",     c="VClass",     colormap="Dark2",     s="scaled_fuel_cost",     alpha=0.4, )` 

更大圆圈出现接近原点的趋势表明,通常情况下,油耗较差的车辆有更高的年燃油成本。你可能会在这个散点图中找到一些点,其中相对较高的油耗仍然比其他具有相似范围的车辆有更高的燃油成本,这可能是因为不同的燃料类型要求。

还有更多内容…

散点图的一个很好的补充是散点矩阵,它生成你pd.DataFrame中所有连续列数据之间的成对关系。让我们看看使用我们的车辆数据会是什么样子:

`from pandas.plotting import scatter_matrix scatter_matrix(df)` 

自动生成的图表描述,具有中等信心

这张图表包含了很多信息,所以让我们先消化第一列的可视化。如果你看图表的底部,标签为city08,这意味着city08是该列每个图表中的Y轴。

第一列第一行的可视化展示了city08Y轴与city08X轴上的组合。这不是一个将同一列与自己进行散点图绘制的散点图,而是散点矩阵,展示了在这个可视化中city08值的分布。正如你所看到的,大多数车辆的城市油耗低于 50 MPG。

如果你看一下第二行第一列中下方的可视化,你会看到燃油成本与城市油耗之间的关系。这表明,随着选择城市油耗更高的汽车,你每年在燃油上的支出会呈指数性减少。

第一列第三行的可视化展示了highway08Y轴上的数据,这与我们在整个教程中展示的视觉效果相同。再次强调,城市与高速公路的里程之间存在线性关系。

第一列最后一行的可视化展示了年份在Y轴上的数据。从中可以看出,2023 年和 2024 年型号的车辆更多,且实现了 75 MPG 及以上的城市油耗。

探索分类数据

形容词类别应用于那些广义上用于分类和帮助导航数据的数据,但这些值在聚合时几乎没有任何实际意义。例如,如果你正在处理一个包含眼睛颜色字段的数据集,值为棕色绿色榛色蓝色等,你可以使用这个字段来导航数据集,回答类似眼睛颜色为 X 的行,平均瞳孔直径是多少?的问题。然而,你不会问诸如眼睛颜色的总和是多少?的问题,因为像"榛色" + "蓝色"这样的公式在这种情况下没有意义。

相比之下,形容词连续通常应用于你需要聚合的数据。比如问题是什么是平均瞳孔直径?,那么瞳孔直径这一列就会被认为是连续的。了解它聚合后的结果(例如最小值、最大值、平均值、标准差等)是有意义的,而且它可以表示理论上无穷多的值。

有时,判断数据是类别数据还是连续数据可能会有些模糊。以一个人的年龄为例,如果你测量的是被试者的平均年龄,那么这一列就是连续的,但如果问题是20 到 30 岁之间的用户有多少人?,那么相同的数据就变成了类别数据。最终,是否将年龄这样的数据视为连续数据或类别数据,将取决于你在分析中的使用方式。

在本实例中,我们将生成有助于快速识别类别数据分布的可视化图表。我们的下一个实例,探索连续数据,将为你提供一些处理连续数据的思路。

如何操作

回到散点图的实例,我们介绍了由美国能源部分发的vehicles数据集。这个数据集包含了多种类别和连续数据,因此我们再次从将其加载到pd.DataFrame开始:

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable", ) df.head()` 
`/tmp/ipykernel_834707/1427318601.py:1: DtypeWarning: Columns (72,74,75,77) have mixed types. Specify dtype option on import or set low_memory=False.  df = pd.read_csv(     barrels08   bar-relsA08   charge120   …   phevCity   phevHwy   phevComb 0   14.167143   0.0           0.0         …   0          0         0 1   27.046364   0.0           0.0         …   0          0         0 2   11.018889   0.0           0.0         …   0          0         0 3   27.046364   0.0           0.0         …   0          0         0 4   15.658421   0.0           0.0         …   0          0         0 5 rows × 84 columns` 

你可能注意到我们收到了一个警告,Columns (72,74,75,77) have mixed types。在开始进行可视化之前,我们快速看一下这些列:

`df.iloc[:, [72, 74, 75, 77]]` 
 `rangeA  mfrCode c240Dscr  c240bDscr 0       <NA>    <NA>    <NA>      <NA> 1       <NA>    <NA>    <NA>      <NA> 2       <NA>    <NA>    <NA>      <NA> 3       <NA>    <NA>    <NA>      <NA> 4       <NA>    <NA>    <NA>      <NA> …       …       …       …         … 47,518  <NA>    <NA>    <NA>      <NA> 47,519  <NA>    <NA>    <NA>      <NA> 47,520  <NA>    <NA>    <NA>      <NA> 47,521  <NA>    <NA>    <NA>      <NA> 47,522  <NA>    <NA>    <NA>      <NA> 47,523 rows × 4 columns` 

虽然我们可以看到列名,但我们的pd.DataFrame预览没有显示任何实际值,因此为了进一步检查,我们可以对每一列使用pd.Series.value_counts

这是我们在rangeA列中看到的内容:

`df["rangeA"].value_counts()` 
`rangeA 290            74 270            58 280            56 310            41 277            38               .. 240/290/290     1 395             1 258             1 256             1 230/350         1 Name: count, Length: 264, dtype: int64` 

这里的值……很有意思。在我们还不清楚具体数据含义的情况下,rangeA这一列名和大部分值暗示将其视为连续数据是有价值的。通过这样做,我们可以回答类似车辆的平均 rangeA 是多少?的问题,但我们看到的240/290/290230/350等值会阻止我们这样做。目前,我们将把这些数据当作字符串来处理。

回到pd.read_csv发出的警告,pandas 在读取 CSV 文件时会尝试推断数据类型。如果文件开头的数据显示一种类型,但在文件后面看到另一种类型,pandas 会故意发出这个警告,以提醒你数据中可能存在的问题。对于这一列,我们可以结合使用pd.Series.str.isnumericpd.Series.idxmax,快速确定 CSV 文件中首次出现非整数值的行:

`df["rangeA"].str.isnumeric().idxmax()` 
`7116` 

如果你检查pd.read_csv警告的其他列,你不会看到整型数据和字符串数据混合的情况,但你会发现文件开头的大部分数据都缺失,这使得 pandas 很难推断出数据类型:

`df.iloc[:, [74, 75, 77]].pipe(pd.isna).idxmin()` 
`mfrCode      23147 c240Dscr     25661 c240bDscr    25661 dtype: int64` 

当然,最好的解决方案本应是避免使用 CSV 文件,转而使用一种可以保持类型元数据的数据存储格式,比如 Apache Parquet。然而,我们无法控制这些数据是如何生成的,因此目前我们能做的最好的办法是明确告诉pd.read_csv将所有这些列当作字符串处理,并抑制任何警告:

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     dtype={         "rangeA": pd.StringDtype(),         "mfrCode": pd.StringDtype(),         "c240Dscr": pd.StringDtype(),         "c240bDscr": pd.StringDtype()     } ) df.head()` 
 `barrels08  bar-relsA08  charge120  …  phevCity  phevHwy  phevComb 0   14.167143  0.0          0.0        …  0         0        0 1   27.046364  0.0          0.0        …  0         0        0 2   11.018889  0.0          0.0        …  0         0        0 3   27.046364  0.0          0.0        …  0         0        0 4   15.658421  0.0          0.0        …  0         0        0 5 rows × 84 columns` 

现在我们已经清理了数据,让我们尝试识别哪些列是类别性质的。由于我们对这个数据集一无所知,我们可以做出一个方向上正确的假设,即所有通过pd.read_csv读取为字符串的列都是类别型的:

`df.select_dtypes(include=["string"]).columns` 
`Index(['drive', 'eng_dscr', 'fuelType', 'fuelType1', 'make', 'model',       'mpgData', 'trany', 'VClass', 'baseModel', 'guzzler', 'trans_dscr',       'tCharger', 'sCharger', 'atvType', 'fuelType2', 'rangeA', 'evMotor',       'mfrCode', 'c240Dscr', 'c240bDscr', 'createdOn', 'modifiedOn',       'startStop'],      dtype='object')` 

我们可以遍历所有这些列,并调用pd.Series.value_counts来理解每列包含什么,但更有效的探索数据方式是先通过pd.Series.nunique来了解每列中有多少个唯一值,并按从低到高排序。较低的数值表示低基数(即与pd.DataFrame的值计数相比,唯一值的数量较少)。而具有较高数值的字段则反向被认为是高基数

`df.select_dtypes(include=["string"]).nunique().sort_values()` 
`sCharger         1 tCharger         1 startStop        2 mpgData          2 guzzler          3 fuelType2        4 c240Dscr         5 c240bDscr        7 drive            7 fuelType1        7 atvType          9 fuelType        15 VClass          34 trany           40 trans_dscr      52 mfrCode         56 make           144 rangeA         245 modifiedOn     298 evMotor        400 createdOn      455 eng_dscr       608 baseModel     1451 model         5064 dtype: int64` 

为了便于可视化,我们将选择具有最低基数的九列。这并不是一个绝对的规则来决定哪些内容应该可视化,最终这个决定取决于你自己。对于我们这个特定的数据集,基数最低的九列最多有七个唯一值,这些值可以合理地绘制在条形图的X轴上,以帮助可视化值的分布。

基于我们在本章的Matplotlib 进一步绘图自定义一节中学到的内容,我们可以使用plt.subplots创建一个简单的 3x3 网格,并在该网格中将每个可视化图表绘制到相应的位置:

`low_card = df.select_dtypes(include=["string"]).nunique().sort_values().iloc[:9].index fig, axes = plt.subplots(nrows=3, ncols=3) for index, column in enumerate(low_card):     row = index % 3     col = index // 3     ax = axes[row][col]     df[column].value_counts().plot(kind="bar", ax=ax) plt.tight_layout()` 
`/tmp/ipykernel_834707/4000549653.py:10: UserWarning: Tight layout not applied. tight_layout cannot make axes height small enough to accommodate all axes decorations.  plt.tight_layout()` 

那张图表… 很难阅读。许多X轴标签超出了图表区域,由于它们的长度。修复这个问题的一种方法是使用pd.Index.str[]pd.Index.set_axis将更短的标签分配给我们的行索引值,以使用这些值创建一个新的pd.Index。我们还可以使用 Matplotlib 来旋转和调整X轴标签的大小:

`low_card = df.select_dtypes(include=["string"]).nunique().sort_values().iloc[:9].index fig, axes = plt.subplots(nrows=3, ncols=3) for index, column in enumerate(low_card):     row = index % 3     col = index // 3     ax = axes[row][col]     counts = df[column].value_counts()     counts.set_axis(counts.index.str[:8]).plot(kind="bar", ax=ax)     ax.set_xticklabels(ax.get_xticklabels(), rotation=45, fontsize=6) plt.tight_layout()` 

通过这个可视化,我们可以更容易地从高层次理解我们的数据集。mpgData列中的N出现频率明显高于Y。对于guzzler列,我们看到G值大约是T值的两倍。对于c240Dscr列,我们可以看到绝大多数条目都是standard,尽管总体上,我们的整个数据集中只有略多于 100 行分配了这个值,因此我们可能会决定没有足够的测量数据可靠地使用它。

探索连续数据

探索分类数据的示例中,我们提供了分类连续数据的定义,同时仅探索了前者。我们在那个示例中使用的同一个vehicles数据集既包含这两种类型的数据(大多数数据集都是如此),所以我们将重复使用同一个数据集,但是将焦点转移到本示例中的连续数据。

在阅读本示例之前,建议您先熟悉非聚合数据分布绘图示例中展示的技术。实际的绘图调用将是相同的,但本示例将它们应用于更“真实”的数据集,而不是人为创建的数据。

如何做

让我们首先加载vehicles数据集:

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     dtype={         "rangeA": pd.StringDtype(),         "mfrCode": pd.StringDtype(),         "c240Dscr": pd.StringDtype(),         "c240bDscr": pd.StringDtype()     } ) df.head()` 
 `barrels08  bar-relsA08  charge120  …  phevCity  phevHwy  phevComb 0  14.167143  0.0          0.0        …  0         0        0 1  27.046364  0.0          0.0        …  0         0        0 2  11.018889  0.0          0.0        …  0         0        0 3  27.046364  0.0          0.0        …  0         0        0 4  15.658421  0.0          0.0        …  0         0        0 5 rows × 84 columns` 

在前面的示例中,我们使用了带有include=参数的pd.DataFrame.select_dtypes来保留只包含字符串列的内容,这些列被用作分类数据的代理。通过将相同的参数传递给exclude=,我们可以得到对连续列的合理概述:

`df.select_dtypes(exclude=["string"]).columns` 
`Index(['barrels08', 'barrelsA08', 'charge120', 'charge240', 'city08',       'city08U', 'cityA08', 'cityA08U', 'cityCD', 'cityE', 'cityUF', 'co2',       'co2A', 'co2TailpipeAGpm', 'co2TailpipeGpm', 'comb08', 'comb08U',       'combA08', 'combA08U', 'combE', 'combinedCD', 'combinedUF', 'cylinders',       'displ', 'engId', 'feScore', 'fuelCost08', 'fuelCostA08', 'ghgScore',       'ghgScoreA', 'highway08', 'highway08U', 'highwayA08', 'highwayA08U',       'highwayCD', 'highwayE', 'highwayUF', 'hlv', 'hpv', 'id', 'lv2', 'lv4',       'phevBlended', 'pv2', 'pv4', 'range', 'rangeCity', 'rangeCityA',       'rangeHwy', 'rangeHwyA', 'UCity', 'UCityA', 'UHighway', 'UHighwayA',       'year', 'youSaveSpend', 'charge240b', 'phevCity', 'phevHwy',       'phevComb'],      dtype='object')` 

对于连续数据,使用pd.Series.nunique并不那么合理,因为值可以取理论上无限多的值。相反,为了确定良好的绘图候选列,我们可能只想了解哪些列具有足够数量的非缺失数据,可以使用pd.isna

`df.select_dtypes(     exclude=["string"] ).pipe(pd.isna).sum().sort_values(ascending=False).head()` 
`cylinders      801 displ          799 barrels08        0 pv4              0 highwayA08U      0 dtype: int64` 

一般来说,我们的大多数连续数据是完整的,但是让我们看看cylinders,看看缺失值是什么:

`df.loc[df["cylinders"].isna(), ["make", "model"]].value_counts()` 
`make      model                           Fiat      500e                               8 smart     fortwo electric drive coupe        7 Toyota    RAV4 EV                            7 Nissan    Leaf                               7 Ford      Focus Electric                     7                                            .. Polestar  2 Single Motor (19 Inch Wheels)    1 Ford      Mustang Mach-E RWD LFP             1 Polestar  2 Dual Motor Performance Pack      1           2 Dual Motor Perf Pack             1 Acura     ZDX AWD                            1 Name: count, Length: 450, dtype: int64` 

这些似乎是电动车辆,因此我们可以合理地选择用0来填充这些缺失值:

`df["cylinders"] = df["cylinders"].fillna(0)` 

我们在displ列中看到了相同的模式:

`df.loc[df["displ"].isna(), ["make", "model"]].value_counts()` 
`make     model                              Fiat     500e                                  8 smart    fortwo electric drive coupe           7 Toyota   RAV4 EV                               7 Nissan   Leaf                                  7 Ford     Focus Electric                        7                                              .. Porsche  Taycan 4S Performance Battery Plus    1          Taycan GTS ST                         1 Fisker   Ocean Extreme One                     1 Fiat     500e All Season                       1 Acura    ZDX AWD                               1 Name: count, Length: 449, dtype: int64` 

是否应该用0来填充这些数据还有待讨论。在cylinder的情况下,用0填充缺失值是有道理的,因为我们的数据实际上是分类的(即cylinder值只能有那么多种,而且不能简单地聚合这些值)。如果你有一辆车有 2 个气缸,另一辆车有 3 个,那么说“平均气缸数为 2.5”是没有意义的。

然而,对于像 displacement 这样的列,测量“平均排量”可能更有意义。在这种情况下,向平均数提供许多 0 值将使其向下偏斜,而缺失值将被忽略。与 cylinders 相比,还有许多更多的唯一值:

`df["displ"].nunique()` 
`66` 

最终,在这个字段中填补缺失值是一个判断调用;对于我们的分析,我们将把它们保留为空白。

现在我们已经验证了数据集中的缺失值,并对我们的完整性感到满意,是时候开始更详细地探索单个字段了。在探索连续数据时,直方图通常是用户首先选择的可视化方式。让我们看看我们的 city08 列是什么样子:

`df["city08"].plot(kind="hist")` 

图形看起来非常倾斜,因此我们将增加直方图中的箱数,以查看倾斜是否隐藏了行为(因为倾斜使箱子变宽):

`df["city08"].plot(kind="hist", bins=30)` 

一个带有数字和蓝色柱的图形 自动以中等置信度生成的描述

正如我们在绘制非聚合数据的分布配方中讨论的那样,如果安装了 SciPy,您可以放弃寻找最佳箱数。使用 SciPy,KDE 图将为您提供更好的分布视图。

知道了这一点,并且从Matplotlib 进一步的图形定制配方中得到启发,我们可以使用 plt.subplots 来一次可视化多个变量的 KDE 图,比如城市和高速公路里程:

`fig, axes = plt.subplots(nrows=2, ncols=1) axes[0].set_xlim(0, 40) axes[1].set_xlim(0, 40) axes[0].set_ylabel("city") axes[1].set_ylabel("highway") df["city08"].plot(kind="kde", ax=axes[0]) df["highway08"].plot(kind="kde", ax=axes[1])` 

一个正态分布图的图形 自动以中等置信度生成的描述

如您所见,城市里程倾向于略微向左倾斜,分布的峰值大约在 16 或 17 英里每加仑。高速公路里程的峰值更接近于 23 或 24,与理想的正态分布相比,在 17 或 18 英里每加仑处出现更多值。

使用 seaborn 进行高级绘图

seaborn 库是一个流行的 Python 库,用于创建可视化。它本身不进行任何绘制,而是将繁重的工作推迟到 Matplotlib。然而,对于使用 pd.DataFrame 的用户,seaborn 提供了美观的可视化效果和一个抽象了许多工作的 API,这些工作在直接使用 Matplotlib 时必须手动完成。

而不是使用 pd.Series.plotpd.DataFrame.plot,我们将使用 seaborn 自己的 API。本节中的所有示例假设以下代码导入 seaborn 并使用其默认主题:

`import seaborn as sns sns.set_theme() sns.set_style("white")` 

如何做到这一点

让我们创建一个小的 pd.DataFrame,展示两个 GitHub 项目随时间收到的星数:

`df = pd.DataFrame([     ["Q1-2024", "project_a", 1],     ["Q1-2024", "project_b", 1],     ["Q2-2024", "project_a", 2],     ["Q2-2024", "project_b", 2],     ["Q3-2024", "project_a", 4],     ["Q3-2024", "project_b", 3],     ["Q4-2024", "project_a", 8],     ["Q4-2024", "project_b", 4],     ["Q5-2025", "project_a", 16],     ["Q5-2025", "project_b", 5], ], columns=["quarter", "project", "github_stars"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `quarter   project     github_stars 0   Q1-2024   project_a   1 1   Q1-2024   project_b   1 2   Q2-2024   project_a   2 3   Q2-2024   project_b   2 4   Q3-2024   project_a   4 5   Q3-2024   project_b   3 6   Q4-2024   project_a   8 7   Q4-2024   project_b   4 8   Q5-2025   project_a   16 9   Q5-2025   project_b   5` 

这些简单的数据适合用作柱状图,我们可以使用 sns.barplot 来生成。注意,使用 seaborn 的 API 时,调用签名的不同——在 seaborn 中,你需要将 pd.DataFrame 作为参数传递,并明确选择 xyhue 参数。你还会注意到 seaborn 主题使用了与 Matplotlib 不同的配色方案,你可能会觉得这种配色更具视觉吸引力:

`sns.barplot(df, x="quarter", y="github_stars", hue="project")` 

sns.lineplot 可以用来生成与此相同的折线图:

`sns.lineplot(df, x="quarter", y="github_stars", hue="project")` 

一张带有折线的图表,描述自动生成

使用 seaborn 时需要注意的一点是,你需要以长格式而不是宽格式提供数据。为了说明这一点,请仔细观察我们刚刚绘制的原始 pd.DataFrame

`df` 
 `quarter   project     github_stars 0   Q1-2024   project_a   1 1   Q1-2024   project_b   1 2   Q2-2024   project_a   2 3   Q2-2024   project_b   2 4   Q3-2024   project_a   4 5   Q3-2024   project_b   3 6   Q4-2024   project_a   8 7   Q4-2024   project_b   4 8   Q5-2025   project_a   16 9   Q5-2025   project_b   5` 

如果我们想用 pandas 绘制等效的折线图和柱状图,我们必须在调用pd.DataFrame.plot之前以不同的方式构建数据:

`df = pd.DataFrame({     "project_a": [1, 2, 4, 8, 16],     "project_b": [1, 2, 3, 4, 5], }, index=["Q1-2024", "Q2-2024", "Q3-2024", "Q4-2024", "Q5-2024"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `project_a   project_b Q1-2024   1           1 Q2-2024   2           2 Q3-2024   4           3 Q4-2024   8           4 Q5-2024   16          5` 

虽然 seaborn 提供的默认样式有助于制作漂亮的图表,但该库还可以帮助你构建更强大的可视化,而这些在直接使用 pandas 时是没有等效功能的。

为了看到这些类型的图表如何运作,让我们再次使用我们在第五章《算法及其应用》中探讨过的 movie 数据集:

`df = pd.read_csv(     "data/movie.csv",     usecols=["movie_title", "title_year", "imdb_score", "content_rating"],     dtype_backend="numpy_nullable", ) df.head()` 
 `movie_title  content_rating  title_year  imdb_score 0   Avatar       PG-13           2009.0      7.9 1   Pirates of the Caribbean: At World's End   PG-13   2007.0   7.1 2   Spectre      PG-13           2015.0      6.8 3   The Dark Knight Rises   PG-13   2012.0   8.5 4   Star Wars: Episode VII – The Force Awakens   <NA>   <NA>   7.1` 

在我们深入分析这个数据集之前,我们需要进行一些数据清理。首先,看起来 title_year 被读取为浮动点数值。使用整数值会更为合适,因此我们将重新读取数据,并明确传递 dtype= 参数:

`df = pd.read_csv(     "data/movie.csv",     usecols=["movie_title", "title_year", "imdb_score", "content_rating"],     dtype_backend="numpy_nullable",     dtype={"title_year": pd.Int16Dtype()}, ) df.head()` 
 `movie_title   content_rating   title_year   imdb_score 0   Avatar        PG-13            2009         7.9 1   Pirates of the Caribbean: At World's End    PG-13   2007   7.1 2   Spectre       PG-13            2015         6.8 3   The Dark Knight Rises   PG-13  2012         8.5 4   Star Wars: Episode VII - The Force Awakens   <NA>   <NA>   7.1` 

既然这些问题已经解决了,让我们看看我们数据集中最老的电影是什么时候上映的:

`df["title_year"].min()` 
`1916` 

然后将其与最后一部电影进行比较:

`df["title_year"].max()` 
`2016` 

当我们开始考虑如何可视化这些数据时,我们可能并不总是关心电影确切的上映年份。相反,我们可以通过使用我们在第五章《算法及其应用》中讲解过的 pd.cut 函数,将每部电影归入一个 decade(十年)类别,并提供一个区间,该区间会涵盖数据集中第一部和最后一部电影上映的年份:

`df = df.assign(     title_decade=lambda x: pd.cut(x["title_year"],                                   bins=range(1910, 2021, 10))) df.head()` 
 `movie_title   content_rating   title_year   imdb_score   title_decade 0   Avatar        PG-13            2009         7.9          (2000.0, 2010.0] 1   Pirates of the Caribbean: At World's End   PG-13   2007   7.1   (2000.0, 2010.0] 2   Spectre       PG-13            2015         6.8          (2010.0, 2020.0] 3   The Dark Knight Rises   PG-13  2012         8.5          (2010.0, 2020.0] 4   Star Wars: Epi-sode VII - The Force Awakens   <NA>   <NA>   7.1   NaN` 

如果我们想了解电影评分在各个十年间的分布变化,箱线图将是可视化这些趋势的一个很好的起点。Seaborn 提供了一个 sns.boxplot 方法,可以轻松绘制此图:

`sns.boxplot(     data=df,     x="imdb_score",     y="title_decade", )` 

如果你观察箱线图中的中位数电影评分(即每个箱体中间的黑色竖线),你会发现电影评分整体上有下降的趋势。与此同时,从每个箱体延伸出来的线条(代表最低和最高四分位数的评分)似乎随着时间的推移有了更宽的分布,这可能表明每个十年的最差电影在变得越来越糟,而最好的电影则可能在变得越来越好,至少自 1980 年代以来如此。

虽然箱线图提供了一个不错的高层次视图,显示了每十年的数据分布,但 seaborn 还提供了其他可能更具洞察力的图表。一个例子是小提琴图,它本质上是一个核密度估计图(在非聚合数据的分布绘图一节中已介绍),并叠加在箱线图之上。Seaborn 通过 sns.violinplot 函数来实现这一点:

`sns.violinplot(     data=df,     x="imdb_score",     y="title_decade", )` 

一张数字图表,自动生成的描述,信心较中等

许多十年显示出单峰分布,但如果仔细观察 1950 年代,你会注意到核密度估计图有两个峰值(一个在大约 7 分左右,另一个峰值稍微高于 8 分)。1960 年代也展示了类似的现象,尽管 7 分左右的峰值略不明显。对于这两个十年,小提琴图的覆盖层表明,相对较高的评分量分布在 25 和 75 百分位之间,而其他十年则更多地回归到中位数附近。

然而,小提琴图仍然使得难以辨别每十年有多少条评分数据。虽然每个十年的分布当然很重要,但数量可能会讲述另一个故事。也许旧时代的电影评分较高是因为生存偏差,只有被认为好的电影才会在这些十年里被评价,或者也许是因为现代的十年更注重质量而非数量。

无论根本原因是什么,seaborn 至少可以通过使用群体图帮助我们直观地确认每个十年的数量分布,这种图表将小提琴图的核密度估计部分按数量纵向缩放:

`sns.swarmplot(     data=df,     x="imdb_score",     y="title_decade",     size=.25, )` 

一张包含数字和线条的图表,自动生成的描述

正如你在图表中看到的,大多数评论的数量集中在 1990 年代及之后,尤其是 2000-2010 年间的评论(请记住,我们的数据集仅包含到 2016 年的电影)。1990 年之前的十年,评论的数量相对较少,甚至在图表上几乎不可见。

使用群体图,你可以更进一步地分析数据,通过向可视化中添加更多维度。目前,我们已经发现电影评分随着时间的推移呈现下降趋势,无论是由于评分的生存偏差,还是注重数量而非质量。那么,如果我们想了解不同类型的电影呢?PG-13 电影的表现是否优于 R 级电影?

通过控制每个点在散点图上的颜色,你可以为可视化添加额外的维度。为了更好地展示这一点,让我们缩小范围,仅查看一些年份的数据,因为我们当前的图表已经很难阅读了。我们也可以只关注有评分的电影,因为没有评分的条目或电视节目并不是我们需要深入分析的内容。作为最终的数据清洗步骤,我们将把title_year列转换为分类数据类型,这样绘图库就能知道像 2013 年、2014 年、2015 年等年份应该作为离散值处理,而不是作为 2013 到 2015 年的连续范围:

`ratings_of_interest = {"G", "PG", "PG-13", "R"} mask = (     (df["title_year"] >= 2013)     & (df["title_year"] <= 2015)     & (df["content_rating"].isin(ratings_of_interest)) ) data = df[mask].assign(     title_year=lambda x: x["title_year"].astype(pd.CategoricalDtype()) ) data.head()` 
 `movie_title   content_rating   title_year   imdb_score   title_decade 2   Spectre       PG-13            2015         6.8          (2010, 2020] 8   Avengers: Age of Ultron   PG-13   2015      7.5          (2010, 2020] 14  The Lone Ranger   PG-13        2013         6.5          (2010, 2020] 15  Man of Steel  PG-13            2013         7.2          (2010, 2020] 20  The Hobbit: The Battle of the Five Ar-mies   PG-13   2014   7.5   (2010, 2020]` 

在数据清洗完成后,我们可以继续将content_rating添加到我们的图表中,并通过hue=参数让 seaborn 为每个数据点分配一个独特的颜色:

`sns.swarmplot(     data=data,     x="imdb_score",     y="title_year",     hue="content_rating", )` 

自动生成的数据分析图,描述具有中等置信度

添加颜色为我们的图表增添了另一个信息维度,尽管另一种方法是为每个content_rating使用一个单独的图表,这样可能会让图表更加易读。

为了实现这一点,我们将使用sns.catplot函数并传入一些额外的参数。需要注意的第一个参数是kind=,通过这个参数,我们将告诉 seaborn 绘制“散点图”给我们。col=参数决定了用于生成单独图表的字段,而col_wrap=参数则告诉我们在一行中可以放几个图表,假设我们的图表是以网格布局方式排列的:

`sns.catplot(     kind="swarm",     data=data,     x="imdb_score",     y="title_year",     col="content_rating",     col_wrap=2, )` 

自动生成的一群蓝点,描述

该可视化图表似乎表明,2013 年是一个适合电影的年份,至少相对于 2014 年和 2015 年。在 PG-13 内容评级下,看起来 2013 年有相对更多的电影评分在 7 到 8 之间,而其他年份的评分则没有这么集中的趋势。对于 R 级电影来说,2013 年大多数电影的评分都在 5 分以上,而随着年份的推移,更多的电影评分低于这一线。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

留下你的评论!

感谢你从 Packt 出版社购买这本书——我们希望你喜欢它!你的反馈对于我们至关重要,它帮助我们改进和成长。阅读完成后,请花一点时间在亚马逊上留下评论;这只需一分钟,但对像你这样的读者来说意义重大。

扫描下面的二维码,获取你选择的免费电子书。

packt.link/NzOWQ

第七章:重塑 DataFrame

处理数据很困难。很少,甚至从未,有人能够仅仅收集数据就能直接获得洞见。通常,必须投入大量时间和精力进行数据清洗、转换和重塑,以便将数据转化为可用、可消化和/或可理解的格式。

您的源数据是多个 CSV 文件的集合吗?每个文件代表一天的数据?通过正确使用pd.concat,您可以轻松地将这些文件合并为一个。

您使用的关系型数据库作为数据源是否以规范化形式存储数据,而目标列式数据库更倾向于将所有数据存储在一个表中?pd.merge可以帮助您将数据合并在一起。

如果您的老板要求您从数百万行数据中提取并生成一份任何业务人员都能理解的简明报告,该怎么办?pd.pivot_table是完成此任务的正确工具,它能够快速、轻松地汇总您的数据。

最终,您需要重塑数据的原因来自不同的地方。无论是系统还是人们的需求,pandas 都可以帮助您按需操作数据。

在本章中,我们将逐步介绍 pandas 提供的函数和方法,帮助您重塑数据。掌握正确的知识和一些创造力后,使用 pandas 重塑数据可以成为您分析过程中的最有趣和最具回报的部分之一。

本章将介绍以下内容:

  • 连接pd.DataFrame对象

  • 使用pd.merge合并 DataFrame

  • 使用pd.DataFrame.join连接 DataFrame

  • 使用pd.DataFrame.stackpd.DataFrame.unstack进行重塑

  • 使用pd.DataFrame.melt进行重塑

  • 使用pd.wide_to_long进行重塑

  • 使用pd.DataFrame.pivotpd.pivot_table进行重塑

  • 使用pd.DataFrame.explode进行重塑

  • 使用pd.DataFrame.T进行转置

连接 pd.DataFrame 对象

在 pandas 中,连接一词指的是将两个或更多的pd.DataFrame对象以某种方式堆叠起来的过程。最常见的,pandas 用户通常会进行我们认为的垂直连接,即将pd.DataFrame对象堆叠在彼此之上:

图 7.1:两个 pd.DataFrame 对象的垂直连接

然而,pandas 还具有灵活性,可以将您的pd.DataFrame对象并排堆叠,这个过程称为水平连接:

自动生成的表格图示

图 7.2:两个 pd.DataFrame 对象的垂直连接

这些图表可能会帮助您很好地理解连接的概念,但也有一些潜在问题需要考虑。如果我们尝试进行垂直连接,但各个对象的列标签不相同,应该怎么办?相反,如果我们尝试进行水平连接,而不是所有的行标签都相同,又该如何处理?

无论您想要沿着哪个方向进行连接,也无论您的标签是否对齐,pandas 中的连接完全受pd.concat函数的控制。本文将介绍pd.concat的基础知识,同时向您展示在处理不像标记的pd.DataFrame对象时如何控制其行为。

如何做到

假设我们已经收集了关于不同公司在两个季度内股票表现的数据。为了最好地展示如何进行连接操作,我们故意使这两个pd.DataFrame对象涵盖不同的时间段,显示不同的公司,甚至包含不同的列:

`df_q1 = pd.DataFrame([     ["AAPL", 100., 50., 75.],     ["MSFT", 80., 42., 62.],     ["AMZN", 60., 100., 120.], ], columns=["ticker", "shares", "low", "high"]) df_q1 = df_q1.convert_dtypes(dtype_backend="numpy_nullable") df_q1` 
 `ticker   shares   low   high 0   AAPL     100      50    75 1   MSFT     80       42    62 2   AMZN     60       100   120` 
`df_q2 = pd.DataFrame([     ["AAPL", 80., 70., 80., 77.],     ["MSFT", 90., 50., 60., 55.],     ["IBM", 100., 60., 70., 64.],     ["GE", 42., 30., 50., 44.], ], columns=["ticker", "shares", "low", "high", "close"]) df_q2 = df_q2.convert_dtypes(dtype_backend="numpy_nullable") df_q2` 
 `ticker   shares   low   high   close 0   AAPL     80       70    80     77 1   MSFT     90       50    60     55 2   IBM      100      60    70     64 3   GE       42       30    50     44` 

pd.concat的最基本调用将接受这两个pd.DataFrame对象的列表。默认情况下,这将垂直堆叠对象,即第一个pd.DataFrame简单地堆叠在第二个上面。

尽管我们的pd.DataFrame对象中大多数列是重叠的,但df_q1没有close列,而df_q2有。为了让连接仍然生效,pandas 将在pd.concat的结果中包括close列,并为来自df_q1的行分配缺失值:

`pd.concat([df_q1, df_q2])` 
 `ticker   shares   low   high   close 0   AAPL     100      50    75     <NA> 1   MSFT     80       42    62     <NA> 2   AMZN     60       100   120    <NA> 0   AAPL     80       70    80     77 1   MSFT     90       50    60     55 2   IBM      100      60    70     64 3   GE       42       30    50     44` 

您还应该注意 pandas 在结果中给出的行索引。实际上,pandas 获取了df_q1的索引值,范围从 0 到 2,然后获取了df_q2的索引值,范围从 0 到 3。在创建新的行索引时,pandas 简单地保留了这些值,并在结果中垂直堆叠它们。如果您不喜欢这种行为,可以向pd.concat传递ignore_index=True

`pd.concat([df_q1, df_q2], ignore_index=True)` 
 `ticker   shares   low   high   close 0   AAPL     100      50    75     <NA> 1   MSFT     80       42    62     <NA> 2   AMZN     60       100   120    <NA> 3   AAPL     80       70    80     77 4   MSFT     90       50    60     55 5   IBM      100      60    70     64 6   GE       42       30    50     44` 

另一个潜在的问题是我们不能再看到我们的记录最初来自哪个pd.DataFrame了。为了保留这些信息,我们可以通过keys=参数传递自定义标签,以表示数据的来源:

`pd.concat([df_q1, df_q2], keys=["q1", "q2"])` 
 `ticker   shares   low   high   close q1   0   AAPL     100      50    75     <NA>      1   MSFT     80       42    62     <NA>      2   AMZN     60       100   120    <NA> q2   0   AAPL     80       70    80     77      1   MSFT     90       50    60     55      2   IBM      100      60    70     64      3   GE       42       30    50     44` 

pd.concat还允许您控制连接的方向。与默认的垂直堆叠行为不同,我们可以传递axis=1来水平堆叠:

`pd.concat([df_q1, df_q2], keys=["q1", "q2"], axis=1)` 
 `q1                      …   q2     ticker   shares   low   …   low   high   close 0   AAPL     100      50    …   70    80     77 1   MSFT     80       42    …   50    60     55 2   AMZN     60       100   …   60    70     64 3   <NA>     <NA>     <NA>  …   30    50     44 4 rows × 9 columns` 

虽然这样做使我们得到了一个没有错误的结果,但仔细检查结果后发现了一些问题。数据的前两行分别涵盖了AAPLMSFT,所以在这里没有什么好担心的。然而,数据的第三行显示AMZN作为 Q1 的股票代码,而IBM作为 Q2 的股票代码 - 这是怎么回事?

pandas 的问题在于它根据索引的值进行对齐,而不是像ticker这样的其他列,这可能是我们感兴趣的。如果我们希望pd.concat根据ticker进行对齐,在连接之前,我们可以将这两个pd.DataFrame对象的ticker设置为行索引:

`pd.concat([     df_q1.set_index("ticker"),     df_q2.set_index("ticker"), ], keys=["q1", "q2"], axis=1)` 
 `q1                   …   q2         shares  low   high   …   low   high   close ticker AAPL    100     50    75     …   70    80     77 MSFT    80      42    62     …   50    60     55 AMZN    60      100   120    …   <NA>  <NA>   <NA> IBM     <NA>    <NA>  <NA>   …   60    70     64 GE      <NA>    <NA>  <NA>   …   30    50     44 5 rows × 7 columns` 

我们可能想要控制的最后一个对齐行为是如何处理至少在一个对象中出现但不是所有对象中都出现的标签。默认情况下,pd.concat 执行“外连接”操作,这将取所有的索引值(在我们的例子中是 ticker 符号),并将它们显示在输出中,适用时使用缺失值指示符。相对地,传递 join="inner" 作为参数,只会显示在所有被连接对象中都出现的索引标签:

`pd.concat([     df_q1.set_index("ticker"),     df_q2.set_index("ticker"), ], keys=["q1", "q2"], axis=1, join="inner")` 
 `q1                    …   q2         shares   low   high   …   low   high   close ticker AAPL    100      50    75     …   70    80     77 MSFT    80       42    62     …   50    60     55 2 rows × 7 columns` 

还有更多内容…

pd.concat 是一个开销较大的操作,绝对不应该在 Python 循环中调用。如果你在循环中创建了一堆 pd.DataFrame 对象,并且最终希望将它们连接在一起,最好先将它们存储在一个序列中,等到序列完全填充后再调用一次 pd.concat

我们可以使用 IPython 的 %%time 魔法函数来分析不同方法之间的性能差异。让我们从在循环中使用 pd.concat 的反模式开始:

`%%time concatenated_dfs = df_q1 for i in range(1000):     concatenated_dfs = pd.concat([concatenated_dfs, df_q1]) print(f"Final pd.DataFrame shape is {concatenated_dfs.shape}")` 
`Final pd.DataFrame shape is (3003, 4) CPU times: user 267 ms, sys: 0 ns, total: 267 ms Wall time: 287 ms` 

这段代码将产生等效的结果,但遵循在循环中追加到 Python 列表的做法,并且仅在最后调用一次 pd.concat

`%%time df = df_q1 accumulated = [df_q1] for i in range(1000):     accumulated.append(df_q1) concatenated_dfs = pd.concat(accumulated) print(f"Final pd.DataFrame shape is {concatenated_dfs.shape}")` 
`Final pd.DataFrame shape is (3003, 4) CPU times: user 28.4 ms, sys: 0 ns, total: 28.4 ms Wall time: 31 ms` 

使用 pd.merge 合并 DataFrame

数据重塑中的另一个常见任务称为合并,在某些情况下也叫连接,后者术语在数据库术语中使用得较多。与连接操作将对象上下堆叠或并排放置不同,合并通过查找两个实体之间的共同键(或一组键)来工作,并使用这个键将其他列合并在一起:

图示:数字描述自动生成

图 7.3:合并两个 pd.DataFrame 对象

在 pandas 中,最常用的合并方法是 pd.merge,其功能将在本食谱中详细介绍。另一个可行的(但不太常用的)方法是 pd.DataFrame.join,尽管在讨论它之前,先了解 pd.merge 是有帮助的(我们将在下一个食谱中介绍 pd.DataFrame.join)。

如何操作

接着我们继续使用在连接 pd.DataFrame 对象示例中创建的股票 pd.DataFrame 对象:

`df_q1 = pd.DataFrame([     ["AAPL", 100., 50., 75.],     ["MSFT", 80., 42., 62.],     ["AMZN", 60., 100., 120.], ], columns=["ticker", "shares", "low", "high"]) df_q1 = df_q1.convert_dtypes(dtype_backend="numpy_nullable") df_q1` 
 `ticker   shares   low   high 0   AAPL     100      50    75 1   MSFT     80       42    62 2   AMZN     60       100   120` 
`df_q2 = pd.DataFrame([     ["AAPL", 80., 70., 80., 77.],     ["MSFT", 90., 50., 60., 55.],     ["IBM", 100., 60., 70., 64.],     ["GE", 42., 30., 50., 44.], ], columns=["ticker", "shares", "low", "high", "close"]) df_q2 = df_q2.convert_dtypes(dtype_backend="numpy_nullable") df_q2` 
 `ticker   shares   low   high   close 0   AAPL     80       70    80     77 1   MSFT     90       50    60     55 2   IBM      100      60    70     64 3   GE       42       30    50     44` 

在该示例中,我们看到你可以通过结合使用 pd.concatpd.DataFrame.set_index 来通过 ticker 列合并这两个 pd.DataFrame 对象:

`pd.concat([     df_q1.set_index("ticker"),     df_q2.set_index("ticker"), ], keys=["q1", "q2"], axis=1)` 
 `q1                    …   q2          shares   low   high   …   low   high   close ticker AAPL     100      50    75     …   70    80     77 MSFT     80       42    62     …   50    60     55 AMZN     60       100   120    …   <NA>  <NA>   <NA> IBM      <NA>     <NA>  <NA>   …   60    70     64 GE       <NA>     <NA>  <NA>   …   30    50     44 5 rows × 7 columns` 

使用 pd.merge,你可以通过传递 on= 参数更简洁地表达这一点,明确表示你希望 pandas 使用哪一列(或哪几列)进行对齐:

`pd.merge(df_q1, df_q2, on=["ticker"])` 
 `ticker   shares_x   low_x   …   low_y   high_y   close 0   AAPL     100        50      …   70      80       77 1   MSFT     80         42      …   50      60       55 2 rows × 8 columns` 

如你所见,结果并不完全相同,但我们可以通过切换合并行为来更接近原来的结果。默认情况下,pd.merge 执行内连接;如果我们想要一个更类似于 pd.concat 示例的结果,可以传递 how="outer"

`pd.merge(df_q1, df_q2, on=["ticker"], how="outer")` 
 `ticker   shares_x   low_x   …   low_y   high_y   close 0   AAPL     100        50      …   70      80       77 1   AMZN     60         100     …   <NA>    <NA>     <NA> 2   GE       <NA>       <NA>    …   30      50       44 3   IBM      <NA>       <NA>    …   60      70       64 4   MSFT     80         42      …   50      60       55 5 rows × 8 columns` 

虽然pd.concat只允许执行内连接外连接,但pd.merge还支持左连接,它保留第一个pd.DataFrame中的所有数据,并根据关键字段匹配将第二个pd.DataFrame中的数据合并进来:

`pd.merge(df_q1, df_q2, on=["ticker"], how="left")` 
 `ticker   shares_x   low_x   …   low_y   high_y   close 0   AAPL     100        50      …   70      80       77 1   MSFT     80         42      …   50      60       55 2   AMZN     60         100     …   <NA>    <NA>     <NA> 3 rows × 8 columns` 

how="right"则反转了这一点,确保第二个pd.DataFrame中的每一行都出现在输出中:

`pd.merge(df_q1, df_q2, on=["ticker"], how="right")` 
 `ticker   shares_x   low_x   …   low_y   high_y   close 0   AAPL     100        50      …   70      80       77 1   MSFT     80         42      …   50      60       55 2   IBM      <NA>       <NA>    …   60      70       64 3   GE       <NA>       <NA>    …   30      50       44 4 rows × 8 columns` 

使用how="outer"时的一个额外功能是可以提供一个indicator=参数,这将告诉你结果pd.DataFrame中的每一行来自哪里:

`pd.merge(df_q1, df_q2, on=["ticker"], how="outer", indicator=True)` 
 `ticker   shares_x   low_x   …   high_y   close   _merge 0   AAPL     100        50      …   80       77      both 1   AMZN     60         100     …   <NA>     <NA>    left_only 2   GE       <NA>       <NA>    …   50       44      right_only 3   IBM      <NA>       <NA>    …   70       64      right_only 4   MSFT     80         42      …   60       55      both 5 rows × 9 columns` 

“both”的值表示用于执行合并的键在两个pd.DataFrame对象中都找到了,这在AAPLMSFT的股票代码中是适用的。left_only的值意味着该键仅出现在左侧pd.DataFrame中,正如AMZN的情况。right_only则突出显示仅出现在右侧pd.DataFrame中的键,例如GEIBM

我们的pd.concat输出和pd.merge的区别之一是,前者在列中生成了pd.MultiIndex,从而有效地防止了两个pd.DataFrame对象中出现的列标签冲突。相比之下,pd.merge会为在两个pd.DataFrame对象中都出现的列添加后缀,以进行区分。来自左侧pd.DataFrame的列会附加_x后缀,而_y后缀则表示该列来自右侧pd.DataFrame

若想更好地控制这个后缀,可以将元组作为参数传递给suffixes=。在我们的示例数据中,这个参数可以方便地区分 Q1 和 Q2 的数据:

`pd.merge(     df_q1,     df_q2,     on=["ticker"],     how="outer",     suffixes=("_q1", "_q2"), )` 
 `ticker   shares_q1   low_q1   …   low_q2   high_q2   close 0   AAPL     100         50       …   70       80        77 1   AMZN     60          100      …   <NA>     <NA>      <NA> 2   GE       <NA>        <NA>     …   30       50        44 3   IBM      <NA>        <NA>     …   60       70        64 4   MSFT     80          42       …   50       60        55 5 rows × 8 columns` 

但是,你应该知道,后缀只会在列名同时出现在两个pd.DataFrame对象中时才会应用。如果某个列只出现在其中一个对象中,则不会应用后缀:

`pd.merge(     df_q1[["ticker"]].assign(only_in_left=42),     df_q2[["ticker"]].assign(only_in_right=555),     on=["ticker"],     how="outer",     suffixes=("_q1", "_q2"), )` 
 `ticker   only_in_left   only_in_right 0   AAPL     42.0           555.0 1   AMZN     42.0           NaN 2   GE       NaN            555.0 3   IBM      NaN            555.0 4   MSFT     42.0           555.0` 

如果我们的键列在两个pd.DataFrame对象中有不同的名称,那会是个问题吗?当然不会!不过不必只听我说——让我们把其中一个pd.DataFrame对象中的ticker列重命名为SYMBOL试试看:

`df_q2 = df_q2.rename(columns={"ticker": "SYMBOL"}) df_q2` 
 `SYMBOL   shares   low   high   close 0   AAPL     80       70    80     77 1   MSFT     90       50    60     55 2   IBM      100      60    70     64 3   GE       42       30    50     44` 

使用pd.merge时,唯一改变的是你现在需要将两个不同的参数传递给left_on=right_on=,而不再是将一个参数传递给on=

`pd.merge(     df_q1,     df_q2,     left_on=["ticker"],     right_on=["SYMBOL"],     how="outer",     suffixes=("_q1", "_q2"), )` 
 `ticker   shares_q1   low_q1   …   low_q2   high_q2   close 0   AAPL     100         50       …   70       80        77 1   AMZN     60          100      …   <NA>     <NA>      <NA> 2   <NA>     <NA>        <NA>     …   30       50        44 3   <NA>     <NA>        <NA>     …   60       70        64 4   MSFT     80          42       …   50       60        55 5 rows × 9 columns` 

为了完成这个示例,让我们考虑一个案例,其中有多个列应作为我们的合并键。我们可以通过创建一个pd.DataFrame来列出股票代码、季度和最低价来开始:

`lows = pd.DataFrame([     ["AAPL", "Q1", 50.],     ["MSFT", "Q1", 42.],     ["AMZN", "Q1", 100.],     ["AAPL", "Q2", 70.],     ["MSFT", "Q2", 50.],     ["IBM", "Q2", 60.],     ["GE", "Q2", 30.], ], columns=["ticker", "quarter", "low"]) lows = lows.convert_dtypes(dtype_backend="numpy_nullable") lows` 
 `ticker   quarter   low 0   AAPL     Q1        50 1   MSFT     Q1        42 2   AMZN     Q1        100 3   AAPL     Q2        70 4   MSFT     Q2        50 5   IBM      Q2        60 6   GE       Q2        30` 

第二个pd.DataFrame也会包含股票代码和季度(尽管名称不同),但会显示最高值而不是最低值:

`highs = pd.DataFrame([     ["AAPL", "Q1", 75.],     ["MSFT", "Q1", 62.],     ["AMZN", "Q1", 120.],     ["AAPL", "Q2", 80.],     ["MSFT", "Q2", 60.],     ["IBM", "Q2", 70.],     ["GE", "Q2", 50.], ], columns=["SYMBOL", "QTR", "high"]) highs = highs.convert_dtypes(dtype_backend="numpy_nullable") highs` 
 `SYMBOL   QTR   high 0   AAPL     Q1    75 1   MSFT     Q1    62 2   AMZN     Q1    120 3   AAPL     Q2    80 4   MSFT     Q2    60 5   IBM      Q2    70 6   GE       Q2    50` 

在这些pd.DataFrame对象的布局下,我们的关键字段现在变成了股票代码和季度的组合。通过将适当的标签作为参数传递给left_on=right_on=,pandas 仍然可以执行这个合并:

`pd.merge(     lows,     highs,     left_on=["ticker", "quarter"],     right_on=["SYMBOL", "QTR"], )` 
 `ticker   quarter   low   SYMBOL   QTR   high 0   AAPL     Q1        50    AAPL     Q1    75 1   MSFT     Q1        42    MSFT     Q1    62 2   AMZN     Q1        100   AMZN     Q1    120 3   AAPL     Q2        70    AAPL     Q2    80 4   MSFT     Q2        50    MSFT     Q2    60 5   IBM      Q2        60    IBM      Q2    70 6   GE       Q2        30    GE       Q2    50` 

还有更多内容……

在尝试合并数据时,另一个需要考虑的因素是两个pd.DataFrame对象中键的唯一性。对这一点理解不清或理解错误,可能会导致在应用程序中出现难以察觉的错误。幸运的是,pd.merge可以帮助我们提前发现这些问题。

为了说明我们在谈论唯一性时的意思,突出它可能引发的问题,并展示如何通过 pandas 解决这些问题,我们首先从一个小的pd.DataFrame开始,展示假设的销售数据,按销售人员随时间变化:

`sales = pd.DataFrame([     ["Jan", "John", 10],     ["Feb", "John", 20],     ["Mar", "John", 30], ], columns=["month", "salesperson", "sales"]) sales = sales.convert_dtypes(dtype_backend="numpy_nullable") sales` 
 `month   salesperson   sales 0   Jan     John          10 1   Feb     John          20 2   Mar     John          30` 

让我们再创建一个单独的pd.DataFrame,将每个销售人员映射到一个特定的地区:

`regions = pd.DataFrame([     ["John", "Northeast"],     ["Jane", "Southwest"], ], columns=["salesperson", "region"]) regions = regions.convert_dtypes(dtype_backend="numpy_nullable") regions` 
 `salesperson   region 0   John          Northeast 1   Jane          Southwest` 

如果你曾在一家小公司或小部门工作过,你可能见过以这种方式构建的数据源。在那个环境中,员工们都知道John是谁,因此他们对这种数据布局方式感到满意。

在销售数据中,John出现了多次,但在地区数据中,John只出现了一次。因此,使用salesperson作为合并键时,销售与地区之间的关系是多对一(n-to-1)。反之,地区与销售之间的关系是单对多(1-to-n)。

在这些类型的关系中,合并不会引入任何意外的行为。对这两个对象进行pd.merge将简单地显示销售数据的多行,并与相应的地区信息并列显示:

`pd.merge(sales, regions, on=["salesperson"])` 
 `month   salesperson   sales   region 0   Jan     John          10      Northeast 1   Feb     John          20      Northeast 2   Mar     John          30      Northeast` 

如果我们在合并后尝试对销售额进行求和,我们仍然会得到正确的60

`pd.merge(sales, regions, on=["salesperson"])["sales"].sum()` 
`60` 

随着公司或部门的扩展,另一个John被雇佣是不可避免的。为了解决这个问题,我们的regionspd.DataFrame被更新,增加了一个新的last_name列,并为John Newhire添加了一条新记录:

`regions_orig = regions regions = pd.DataFrame([     ["John", "Smith", "Northeast"],     ["Jane", "Doe", "Southwest"],     ["John", "Newhire", "Southeast"], ], columns=["salesperson", "last_name", "region"]) regions = regions.convert_dtypes(dtype_backend="numpy_nullable") regions` 
 `salesperson   last_name   region 0   John          Smith       Northeast 1   Jane          Doe         Southwest 2   John          Newhire     Southeast` 

突然,我们之前执行的相同的pd.merge产生了不同的结果:

`pd.merge(sales, regions, on=["salesperson"])` 
 `month   salesperson   sales   last_name   region 0   Jan     John          10      Smith       Northeast 1   Jan     John          10      Newhire     Southeast 2   Feb     John          20      Smith       Northeast 3   Feb     John          20      Newhire     Southeast 4   Mar     John          30      Smith       Northeast 5   Mar     John          30      Newhire     Southeast` 

这是一个明确的编程错误。如果你尝试从合并后的pd.DataFrame中对sales列进行求和,你最终会将实际销售的数量加倍。总之,我们只卖出了 60 个单位,但通过引入John Newhire到我们的regionspd.DataFrame中,突然改变了两个pd.DataFrame对象之间的关系,变成了多对多(或n-to-n),这使得我们的数据被重复,从而导致了错误的销售数字:

`pd.merge(sales, regions, on=["salesperson"])["sales"].sum()` 
`120` 

为了用 pandas 提前捕捉到这些意外情况,你可以在pd.merge中提供validate=参数,这样可以明确合并键在两个对象之间的预期关系。如果使用我们原始的pd.DataFrame对象,many_to_one的验证是可以的:

`pd.merge(sales, regions_orig, on=["salesperson"], validate="many_to_one")` 
 `month   salesperson   sales   region 0   Jan     John          10      Northeast 1   Feb     John          20      Northeast 2   Mar     John          30      Northeast` 

然而,当John Newhire进入我们的合并时,同样的验证会抛出一个错误:

`pd.merge(sales, regions, on=["salesperson"], validate="many_to_one")` 
`MergeError: Merge keys are not unique in right dataset; not a many-to-one merge` 

在这个简单的例子中,如果我们一开始就以不同的方式建模数据,就可以避免这个问题,方法是使用由多个列组成的自然键来建模销售 pd.DataFrame,或在两个 pd.DataFrame 对象中都使用替代键。因为这些例子数据量很小,我们也可以通过目测发现结构上存在的问题。

在实际应用中,检测类似的问题并不那么简单。你可能需要合并成千上万甚至数百万行数据,即使大量行受到关系问题的影响,也可能很容易被忽视。手动检测此类问题就像是在大海捞针,因此我强烈建议使用数据验证功能,以避免意外情况发生。

虽然失败并非理想的结果,但在这种情况下,你已经大声失败,并且可以轻松识别你的建模假设出现问题的地方。如果没有这些检查,用户将默默看到不正确的数据,这往往是更糟糕的结果。

使用 pd.DataFrame.join 合并 DataFrame

虽然 pd.merge 是合并两个不同 pd.DataFrame 对象的最常用方法,但功能上类似但使用较少的 pd.DataFrame.join 方法是另一个可行的选择。从风格上讲,pd.DataFrame.join 可以被视为当你想要在现有的 pd.DataFrame 中添加更多列时的快捷方式;而相比之下,pd.merge 默认将两个 pd.DataFrame 对象视为具有相等重要性的对象。

如何实现

为了强调 pd.DataFrame.join 是增强现有 pd.DataFrame 的一种快捷方式,假设我们有一个销售表格,其中行索引对应于销售人员,但使用的是替代键而不是自然键:

`sales = pd.DataFrame(     [[1000], [2000], [4000]],     columns=["sales"],     index=pd.Index([42, 555, 9000], name="salesperson_id") ) sales = sales.convert_dtypes(dtype_backend="numpy_nullable") sales` 
 `sales salesperson_id 42      1000 555     2000 9000    4000` 

那么,我们还可以考虑一个专门的 pd.DataFrame,它存储了某些(但不是全部)销售人员的元数据:

`salesperson = pd.DataFrame([     ["John", "Smith"],     ["Jane", "Doe"], ], columns=["first_name", "last_name"], index=pd.Index(     [555, 42], name="salesperson_id" )) salesperson = salesperson.convert_dtypes(dtype_backend="numpy_nullable") salesperson` 
 `first_name   last_name salesperson_id 555     John         Smith 42      Jane         Doe` 

由于我们想要用来连接这两个 pd.DataFrame 对象的数据位于行索引中,因此在调用 pd.merge 时,你需要写出 left_index=Trueright_index=True。同时请注意,因为我们在销售 pd.DataFrame 中有 salesperson_id9000 的记录,但在 salesperson 中没有对应的条目,所以你需要使用 how="left" 来确保合并时记录不会丢失:

`pd.merge(sales, salesperson, left_index=True, right_index=True, how="left")` 
 `sales   first_name   last_name salesperson_id 42      1000    Jane         Doe 555     2000    John         Smith 9000    4000    <NA>         <NA>` 

那个相对较长的 pd.merge 调用描述了 pd.DataFrame.join 的默认行为,因此你可能会发现直接使用后者更为简便:

`sales.join(salesperson)` 
 `sales   first_name   last_name salesperson_id 42      1000    Jane         Doe 555     2000    John         Smith 9000    4000    <NA>         <NA>` 

尽管 pd.DataFrame.join 默认进行左连接,你也可以通过传递 how= 参数选择不同的行为:

`sales.join(salesperson, how="inner")` 
 `sales   first_name   last_name salesperson_id 42      1000    Jane         Doe 555     2000    John         Smith` 

最终,没有强制要求必须使用 pd.DataFrame.join 而非 pd.merge。前者只是一个快捷方式,并且是一种风格上的指示,表示调用的 pd.DataFrame(此处是 sales)在与另一个 pd.DataFrame(如 salesperson)合并时不应该丢失任何记录。

使用 pd.DataFrame.stackpd.DataFrame.unstack 重塑数据

在我们深入探讨堆叠拆分这两个术语之前,让我们退后一步,比较两张数据表。你注意到它们之间有什么不同吗:

a b c
x 1 2 3
y 4 5 6

表格 7.1:宽格式的表格

与:

x a 1
x b 2
x c 3
y a 4
y b 5
y c 6

表格 7.2:长格式的表格

当然,从视觉上看,表格的形状不同,但它们所包含的数据是相同的。前一个表格通常被称为宽格式表格,因为它将数据分散存储在不同的列中。相比之下,第二个表格(许多人会说它是存储在长格式中)则使用新行来表示不同的数据项。

哪种格式更好?答案是视情况而定——也就是说,这取决于你的受众和/或你所交互的系统。你公司的一位高管可能更喜欢查看以宽格式存储的数据,因为这样一目了然。柱状数据库则更倾向于长格式,因为它在处理数百万甚至数十亿行数据时,能比处理相同数量的列更加优化。

既然没有一种存储数据的统一方式,你可能需要在这两种格式之间来回转换数据,这就引出了堆叠拆分这两个术语。

堆叠指的是将列压入行中的过程,本质上是帮助将宽格式转换为长格式:

图 7.4:将 pd.DataFrame 从宽格式堆叠到长格式

拆分则是相反的过程,将存储在长格式中的数据转换为宽格式:

一张数字和数字的图示,描述自动生成,信心中等

图 7.5:将 pd.DataFrame 从长格式拆分到宽格式

在本节中,我们将引导你正确使用 pd.DataFrame.stackpd.DataFrame.unstack 方法,这些方法可以用于数据格式转换。

如何实现

让我们从以下 pd.DataFrame 开始,它总结了不同州种植的水果数量:

`df = pd.DataFrame([     [12, 10, 40],     [9, 7, 12],     [0, 14, 190] ], columns=pd.Index(["Apple", "Orange", "Banana"], name="fruit"), index=pd.Index(     ["Texas", "Arizona", "Florida"], name="state")) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
`fruit   Apple   Orange  Banana state Texas   12      10      40 Arizona 9       7       12 Florida 0       14      190` 

在数据建模术语中,我们会将此视为一个“宽”表格。每一行代表一个州,并且每种作物的不同数量存储在各自的列中。

如果我们想将表格转换为“长”格式,我们基本上希望将每个 statefruit 的组合作为一行展示。pd.DataFrame.stack 将帮助我们实现这一目标,它通过将水果从列索引中移除,形成一个新的 pd.MultiIndex 在行中,其中包含状态和水果信息:

`df.stack()` 
`state     fruit Texas     Apple      12          Orange      10          Banana      40 Arizona   Apple       9          Orange       7          Banana      12 Florida   Apple       0          Orange      14          Banana     190 dtype: Int64` 

在调用 pd.DataFrame.stack 后,许多用户会接着调用 pd.Series.reset_index 方法,并使用 name= 参数。这将把由 pd.DataFrame.stack 创建的带有 pd.MultiIndexpd.Series 转换回具有有意义列名的 pd.DataFrame

`df.stack().reset_index(name="number_grown")` 
 `state     fruit    number_grown 0   Texas     Apple    12 1   Texas     Orange   10 2   Texas     Banana   40 3   Arizona   Apple    9 4   Arizona   Orange   7 5   Arizona   Banana   12 6   Florida   Apple    0 7   Florida   Orange   14 8   Florida   Banana   190` 

这种数据存储的长格式被许多数据库偏好用于存储,并且是传递给像 Seaborn 这样的库时pd.DataFrame的预期格式,我们在第六章 可视化中的Seaborn 简介食谱中曾展示过。

然而,有时你可能想反向操作,将你的长格式pd.DataFrame转换为宽格式。这在需要在紧凑区域中总结数据时尤其有用;同时利用两个维度进行显示,比让观众滚动查看大量数据行更为有效。

为了看到这一效果,让我们从我们刚才进行的pd.DataFrame.stack调用中创建一个新的pd.Series

`stacked = df.stack() stacked` 
`state    fruit Texas    Apple      12         Orange      10         Banana      40 Arizona  Apple       9         Orange       7         Banana      12 Florida  Apple       0         Orange      14         Banana     190 dtype: Int64` 

要反向操作,将某个索引层级从行移到列,只需要调用pd.Series.unstack

`stacked.unstack()` 
`fruit   Apple   Orange   Banana state Texas   12      10       40 Arizona 9       7        12 Florida 0       14       190` 

默认情况下,调用pd.Series.unstack会移动行索引中最内层的层级,在我们的例子中是fruit。然而,我们可以传递level=0,使其移动最外层的第一个层级,而不是最内层的层级,这样可以将状态汇总到列中:

`stacked.unstack(level=0)` 
`state   Texas   Arizona   Florida fruit Apple   12      9         0 Orange  10      7         14 Banana  40      12        190` 

因为我们的pd.MultiIndex层级有名称,我们也可以通过名称而不是位置来引用我们想要移动的层级:

`stacked.unstack(level="state")` 
`state   Texas   Arizona   Florida fruit Apple   12      9         0 Orange  10      7         14 Banana  40      12        190` 

使用pd.DataFrame.melt进行数据重塑

使用 pd.DataFrame.stack 和 pd.DataFrame.unstack 进行重塑食谱中,我们发现,你可以通过在调用pd.DataFrame.stack之前设置合适的行和列索引,将宽格式的pd.DataFrame转换为长格式。pd.DataFrame.melt函数也能将你的pd.DataFrame从宽格式转换为长格式,但无需在中间步骤设置行和列索引值,同时还能对宽到长的转换中是否包含其他列进行更多控制。

如何操作

让我们再次总结不同水果在不同州的种植情况。然而,与使用 pd.DataFrame.stack 和 pd.DataFrame.unstack 进行重塑食谱不同,我们不会将行索引设置为州值,而是将其视为pd.DataFrame中的另一列:

`df = pd.DataFrame([     ["Texas", 12, 10, 40],     ["Arizona", 9, 7, 12],     ["Florida", 0, 14, 190] ], columns=["state", "apple", "orange", "banana"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `state    apple    orange    banana 0   Texas    12       10        40 1   Arizona  9        7         12 2   Florida  0        14        190` 

要通过pd.DataFrame.stack转换为长格式,我们需要将几个调用链起来,最终得到一个没有pd.MultiIndexpd.DataFrame

`df.set_index("state").stack().reset_index()` 
 `state     level_1   0 0   Texas     apple     12 1   Texas     orange    10 2   Texas     banana    40 3   Arizona   apple     9 4   Arizona   orange    7 5   Arizona   banana    12 6   Florida   apple     0 7   Florida   orange    14 8   Florida   banana    190` 

列名level_1在我们的pd.DataFrame.stack操作中默认创建,因为我们开始时的列索引没有名称。我们还看到,对于长格式中新引入的值,会自动生成一个0的列名,所以我们仍然需要链式调用重命名操作来获得一个更具可读性的pd.DataFrame

`df.set_index("state").stack().reset_index().rename(columns={     "level_1": "fruit",     0: "number_grown", })` 
 `state    fruit    number_grown 0   Texas    apple    12 1   Texas    orange   10 2   Texas    banana   40 3   Arizona  apple    9 4   Arizona  orange   7 5   Arizona  banana   12 6   Florida  apple    0 7   Florida  orange   14 8   Florida  banana   190` 

pd.DataFrame.melt通过提供一个id_vars=参数,直接让我们接近我们想要的pd.DataFrame,这个参数对应于你在使用pd.DataFrame.stack时会用到的行索引:

`df.melt(id_vars=["state"])` 
 `state     variable  value 0   Texas     apple     12 1   Arizona   apple     9 2   Florida   apple     0 3   Texas     orange    10 4   Arizona   orange    7 5   Florida   orange    14 6   Texas     banana    40 7   Arizona   banana    12 8   Florida   banana    190` 

使用pd.DataFrame.melt时,我们从变量(这里是不同的水果)创建的新列被命名为variable,值列的默认名称为value。我们可以通过使用var_name=value_name=参数来覆盖这些默认值:

`df.melt(     id_vars=["state"],     var_name="fruit",     value_name="number_grown", )` 
 `state     fruit   number_grown 0   Texas     apple   12 1   Arizona   apple   9 2   Florida   apple   0 3   Texas     orange  10 4   Arizona   orange  7 5   Florida   orange  14 6   Texas     banana  40 7   Arizona   banana  12 8   Florida   banana  190` 

作为额外的好处,pd.DataFrame.melt为你提供了一种简单的方法来控制在宽转长的转换中包含哪些列。例如,如果我们不想在新创建的长表中包含banana的值,我们可以只将appleorange的其他列作为参数传递给value_vars=

`df.melt(     id_vars=["state"],     var_name="fruit",     value_name="number_grown",     value_vars=["apple", "orange"], )` 
 `state     fruit     number_grown 0   Texas     apple     12 1   Arizona   apple     9 2   Florida   apple     0 3   Texas     orange    10 4   Arizona   orange    7 5   Florida   orange    14` 

使用 pd.wide_to_long 重塑数据

到目前为止,我们已经遇到了两种非常可行的方法,将数据从宽格式转换为长格式,无论是通过使用pd.DataFrame.stack方法(我们在使用 pd.DataFrame.stack 和 pd.DataFrame.unstack 重塑数据食谱中介绍的),还是通过使用pd.DataFrame.melt(我们在使用 pd.DataFrame.melt 重塑数据食谱中看到的)。

如果这些还不够,pandas 提供了pd.wide_to_long函数,如果你的列遵循特定的命名模式,它可以帮助完成这种转换,正如我们在本食谱中所看到的。

如何实现

假设我们有以下pd.DataFrame,其中有一个id变量为widget,以及四列代表一个商业季度的销售额。每一列的销售额以"quarter_"开头:

`df = pd.DataFrame([     ["Widget 1", 1, 2, 4, 8],     ["Widget 2", 16, 32, 64, 128], ], columns=["widget", "quarter_1", "quarter_2", "quarter_3", "quarter_4"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `widget     quarter_1   quarter_2   quarter_3   quarter_4 0   Widget 1   1           2           4           8 1   Widget 2   16          32          64          128` 

回到我们pd.DataFrame.stack的例子,我们可以使用以下方法将其从宽格式转换为长格式:

`df.set_index("widget").stack().reset_index().rename(columns={     "level_1": "quarter",     0: "quantity", })` 
 `widget     quarter     quantity 0   Widget 1   quarter_1   1 1   Widget 1   quarter_2   2 2   Widget 1   quarter_3   4 3   Widget 1   quarter_4   8 4   Widget 2   quarter_1   16 5   Widget 2   quarter_2   32 6   Widget 2   quarter_3   64 7   Widget 2   quarter_4   128` 

对于一个更简洁的解决方案,我们可以使用pd.DataFrame.melt

`df.melt(     id_vars=["widget"],     var_name="quarter",     value_name="quantity", )` 
 `widget     quarter     quantity 0   Widget 1   quarter_1   1 1   Widget 2   quarter_1   16 2   Widget 1   quarter_2   2 3   Widget 2   quarter_2   32 4   Widget 1   quarter_3   4 5   Widget 2   quarter_3   64 6   Widget 1   quarter_4   8 7   Widget 2   quarter_4   128` 

但是pd.wide_to_long提供了一个特性,是这两种方法都没有直接处理的——即从正在转换为变量的列标签中创建一个新变量。到目前为止,我们看到新的quarter值为quarter_1quarter_2quarter_3quarter_4,但pd.wide_to_long可以从新创建的变量中提取该字符串,更简单地只留下数字1234

`pd.wide_to_long(     df,     i=["widget"],     stubnames="quarter_",     j="quarter" ).reset_index().rename(columns={"quarter_": "quantity"})` 
 `widget      quarter   quantity 0   Widget 1    1         1 1   Widget 2    1         16 2   Widget 1    2         2 3   Widget 2    2         32 4   Widget 1    3         4 5   Widget 2    3         64 6   Widget 1    4         8 7   Widget 2    4         128` 

使用 pd.DataFrame.pivot 和 pd.pivot_table 重塑数据

到目前为止,在本章中,我们已经看到pd.DataFrame.stackpd.DataFrame.meltpd.wide_to_long都可以帮助你将pd.DataFrame从宽格式转换为长格式。另一方面,我们只看到pd.Series.unstack帮助我们从长格式转换为宽格式,但该方法有一个缺点,需要我们在使用之前为其分配一个合适的行索引。使用pd.DataFrame.pivot,你可以跳过任何中间步骤,直接从长格式转换为宽格式。

除了pd.DataFrame.pivot之外,pandas 还提供了pd.pivot_table函数,它不仅可以将数据从长格式转换为宽格式,还允许你在重塑的同时进行聚合。

图 7.6:使用 pd.pivot_table 进行求和聚合重塑

有效使用pd.pivot_table可以让你使用紧凑简洁的语法执行非常复杂的计算。

如何实现

在前面的一些示例中,我们从宽格式数据开始,之后将其重塑为长格式。在这个示例中,我们将从一开始就使用长格式数据。我们还会添加一个新列number_eaten,以展示在 pandas 中透视时的聚合功能:

`df = pd.DataFrame([     ["Texas", "apple", 12, 8],     ["Arizona", "apple", 9, 10],     ["Florida", "apple", 0, 6],     ["Texas", "orange", 10, 4],     ["Arizona", "orange", 7, 2],     ["Florida", "orange", 14, 3],     ["Texas", "banana", 40, 28],     ["Arizona", "banana", 12, 17],     ["Florida", "banana", 190, 42], ], columns=["state", "fruit", "number_grown", "number_eaten"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `state     fruit     number_grown     number_eaten 0   Texas     apple     12               8 1   Arizona   apple     9                10 2   Florida   apple     0                6 3   Texas     orange    10               4 4   Arizona   orange    7                2 5   Florida   orange    14               3 6   Texas     banana    40               28 7   Arizona   banana    12               17 8   Florida   banana    190              42` 

正如我们在使用 pd.DataFrame.stack 和 pd.DataFrame.unstack 重塑数据一节中学到的那样,如果我们希望将数据从长格式转换为宽格式,可以通过巧妙使用pd.DataFrame.set_index配合pd.DataFrame.unstack来实现:

`df.set_index(["state", "fruit"]).unstack()` 
 `number_grown                    number_eaten fruit   apple   banana  orange  apple   banana  orange state Arizona 9       12      7       10      17      2 Florida 0       190     14      6       42      3 Texas   12      40      10      8       28      4` 

pd.DataFrame.pivot让我们通过一次方法调用来解决这个问题。这个方法的基本用法需要index=columns=参数,用来指定哪些列应该出现在行和列的索引中:

`df.pivot(index=["state"], columns=["fruit"])` 
 `number_grown                    number_eaten fruit   apple   banana  orange  apple   banana  orange state Arizona 9       12      7       10      17      2 Florida 0       190     14      6       42      3 Texas   12      40      10      8       28      4` 

pd.DataFrame.pivot会将任何未指定为index=columns=参数的列,尝试转换为结果pd.DataFrame中的值。然而,如果你不希望所有剩余的列都成为透视后pd.DataFrame的一部分,你可以使用values=参数指定需要保留的列。例如,如果我们只关心透视number_grown列,而忽略number_eaten列,可以写成这样:

`df.pivot(       index=["state"],       columns=["fruit"],       values=["number_grown"],   )` 
 `number_grown fruit   apple   banana   orange state Arizona 9       12       7 Florida 0       190      14 Texas   12      40       10` 

如果你只想保留一个值,那么在列中生成的pd.MultiIndex可能显得多余。幸运的是,通过简单调用pd.DataFrame.droplevel,你可以删除它,在这个函数中你需要指明axis=,以指定你希望删除哪个级别(对于列,指定1),以及你希望删除的索引级别(这里0代表第一级):

`wide_df = df.pivot(     index=["state"],     columns=["fruit"],     values=["number_grown"], ).droplevel(level=0, axis=1) wide_df` 
`fruit   apple   banana   orange state Arizona 9       12       7 Florida 0       190      14 Texas   12      40       10` 

虽然pd.DataFrame.pivot对于重塑数据很有用,但它仅适用于那些用于形成行和列的值没有重复的情况。为了看到这个限制,我们来看一个稍微修改过的pd.DataFrame,展示不同水果在不同州和年份的消费或种植情况:

`df = pd.DataFrame([     ["Texas", "apple", 2023, 10, 6],     ["Texas", "apple", 2024, 2, 8],     ["Arizona", "apple", 2023, 3, 7],     ["Arizona", "apple", 2024, 6, 3],     ["Texas", "orange", 2023, 5, 2],     ["Texas", "orange", 2024, 5, 2],     ["Arizona", "orange", 2023, 7, 2], ], columns=["state", "fruit", "year", "number_grown", "number_eaten"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `state      fruit    year    number_grown   number_eaten 0   Texas      apple    2023    10             6 1   Texas      apple    2024    2              8 2   Arizona    apple    2023    3              7 3   Arizona    apple    2024    6              3 4   Texas      orange   2023    5              2 5   Texas      orange   2024    5              2 6   Arizona    orange   2023    7              2` 

如果我们将statefruityear放入行或列中,我们仍然能够在这个pd.DataFrame上使用pd.DataFrame.pivot

`df.pivot(     index=["state", "year"],     columns=["fruit"],     values=["number_grown", "number_eaten"] )` 
 `number_grown            number_eaten         fruit   apple   orange  apple   orange state   year Arizona 2023    3       7       7       2         2024    6       NaN     3       NaN Texas   2023    10      5       6       2         2024    2       5       8       2` 

那如果我们不想在输出中看到year呢?只需从pd.DataFrame.pivot的参数中移除它就会抛出异常:

`df.pivot(     index=["state"],     columns=["fruit"],     values=["number_grown", "number_eaten"] )` 
`ValueError: Index contains duplicate entries, cannot reshape` 

对于pd.pivot_table,缺少year列完全不成问题:

`pd.pivot_table(     df,     index=["state"],     columns=["fruit"],     values=["number_grown", "number_eaten"] )` 
 `number_eaten            number_grown fruit   apple   orange  apple   orange state Arizona 5.0     2.0     4.5     7.0 Texas   7.0     2.0     6.0     5.0` 

这之所以有效,是因为pd.pivot_table在重塑数据时会对值进行聚合,并转换为宽格式。以亚利桑那州的苹果为例,输入数据显示在 2023 年种植了三颗苹果,到了 2024 年数量翻倍达到了六颗。在我们调用pd.pivot_table时,这显示为4.5。默认情况下,pd.pivot_table会在重塑过程中取你提供的值的平均值。

当然,你可以控制使用的聚合函数。在这种特定情况下,我们可能更关心知道每个州总共种了多少水果,而不是按年份计算平均数。通过将不同的聚合函数作为参数传递给 aggfunc=,你可以轻松获得总和:

`pd.pivot_table(     df,     index=["state"],     columns=["fruit"],     values=["number_grown", "number_eaten"],     aggfunc="sum" )` 
 `number_eaten            number_grown fruit   apple   orange  apple   orange state Arizona 10      2       9       7 Texas   14      4       12      10` 

对于更高级的使用场景,你甚至可以向 aggfunc= 提供一个值的字典,其中字典的每个键/值对分别指定要应用的列和聚合类型:

`pd.pivot_table(     df,     index=["state"],     columns=["fruit"],     values=["number_grown", "number_eaten"],     aggfunc={         "number_eaten": ["min", "max"],         "number_grown": ["sum", "mean"],     }, )` 
 `number_eaten            …       number_grown         max             min     …       mean    sum fruit   apple   orange  apple   …       orange  apple   orange state Arizona 7       2       3       …       7.0     9       7 Texas   8       2       6       …       5.0     12      10 2 rows × 8 columns` 

使用 pd.DataFrame.explode 进行数据重塑

如果每一条数据都能完美地作为标量适应一个二维的 pd.DataFrame,那该多好啊。然而,生活并非如此简单。特别是当处理像 JSON 这样的半结构化数据源时,pd.DataFrame 中的单个项包含非标量序列(如列表和元组)并不罕见。

你可能觉得将数据保持在这种状态下是可以接受的,但有时,将数据规范化并可能将列中的序列提取为单独的元素是有价值的。

图 7.7:使用 pd.DataFrame.explode 将列表元素提取到单独的行

为此,pd.DataFrame.explode 是完成此任务的正确工具。它可能不是你每天都使用的函数,但当你最终需要使用它时,你会很高兴知道它。试图在 pandas 之外复制相同的功能可能容易出错且性能较差!

如何实现

由于我们在本食谱的介绍中提到过 JSON 是一个很好的半结构化数据源,让我们假设需要与一个 HR 系统的 REST API 交互。HR 系统应该告诉我们公司中每个人是谁,以及谁(如果有的话)向他们报告。

员工之间的层级关系很容易用像 JSON 这样的半结构化格式表示,因此 REST API 可能会返回类似如下的内容:

`[     {         "employee_id": 1,         "first_name": "John",         "last_name": "Smith",         "direct_reports": [2, 3]     },     {         "employee_id": 2,         "first_name": "Jane",         "last_name": "Doe",         "direct_reports": []     },     {         "employee_id": 3,         "first_name": "Joe",         "last_name": "Schmoe",         "direct_reports": []     } ]` 

pandas 库还允许我们将这些数据加载到 pd.DataFrame 中,尽管 direct_reports 列包含的是列表:

`df = pd.DataFrame(     [         {             "employee_id": 1,             "first_name": "John",             "last_name": "Smith",             "direct_reports": [2, 3]         },         {             "employee_id": 2,             "first_name": "Jane",             "last_name": "Doe",             "direct_reports": []         },         {             "employee_id": 3,             "first_name": "Joe",             "last_name": "Schmoe",             "direct_reports": []         }     ] ) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `employee_id   first_name   last_name   direct_reports 0   1             John         Smith       [2, 3] 1   2             Jane         Doe         [] 2   3             Joe          Schmoe      []` 

使用 pd.DataFrame.explode,你可以将 direct_reports 拆分成 pd.DataFrame 中的单独行:

`df.explode("direct_reports").convert_dtypes(dtype_backend="numpy_nullable")` 
 `employee_id   first_name   last_name   direct_reports 0   1             John         Smith       2 0   1             John         Smith       3 1   2             Jane         Doe         <NA> 2   3             Joe          Schmoe      <NA>` 

基于我们在 使用 pd.merge 合并数据框 食谱中学到的合并/连接数据的知识,我们可以非常轻松地将爆炸后的信息与直接汇报人员的名字合并,从而生成一个关于谁在公司工作以及谁(如果有的话)向他们汇报的简易总结:

`exploded = df.explode("direct_reports").convert_dtypes(     dtype_backend="numpy_nullable" ) pd.merge(     exploded,     df.drop(columns=["direct_reports"]),     how="left",     left_on=["direct_reports"],     right_on=["employee_id"],     suffixes=("", "_direct_report"), )` 
 `employee_id  first_name  last_name  …  employee_id_direct_report  first_name_direct_report  last_name_direct_report 0   1        John     Smith    …  2       Jane           Doe 1   1        John     Smith    …  3       Joe            Schmoe 2   2        Jane     Doe      …  <NA>    <NA>           <NA> 3   3        Joe      Schmoe   …  <NA>    <NA>           <NA> 4 rows × 7 columns` 

还有更多内容…

虽然我们在第三章的类型回顾中没有介绍它,数据类型,但 PyArrow 确实提供了一种结构体数据类型,当它在 pd.Series 中使用时,会暴露出 pd.Series.struct.explode 方法:

`dtype = pd.ArrowDtype(pa.struct([     ("int_col", pa.int64()),     ("str_col", pa.string()),     ("float_col", pa.float64()), ])) ser = pd.Series([     {"int_col": 42, "str_col": "Hello, ", "float_col": 3.14159},     {"int_col": 555, "str_col": "world!", "float_col": 3.14159}, ], dtype=dtype) ser` 
`0    {'int_col': 42, 'str_col': 'Hello, ', 'float_c... 1    {'int_col': 555, 'str_col': 'world!', 'float_c... dtype: struct<int_col: int64, str_col: string, float_col: double>[pyarrow]` 

pd.DataFrame.explode 不同,后者会生成新的数据行,pd.Series.struct.explode 会根据其结构成员生成新的数据列:

`ser.struct.explode()` 
 `int_col   str_col   float_col 0   42        Hello,    3.14159 1   555       world!    3.14159` 

如果你处理的是类似 JSON 的半结构化数据源,这特别有用。如果你能将来自这样的数据源的嵌套数据适配到 PyArrow 提供的类型化结构中,那么 pd.Series.struct.explode 可以在尝试展开数据时为你节省大量麻烦。

使用 pd.DataFrame.T 进行转置

本章的最后一个实例,让我们来探索 pandas 中一个较为简单的重塑功能。转置是指将你的 pd.DataFrame 反转,使行变成列,列变成行的过程:

图 7.8:转置一个 pd.DataFrame

在这个实例中,我们将看到如何使用 pd.DataFrame.T 方法进行转置,同时讨论这可能会如何有用。

如何操作

pandas 中的转置非常简单。只需要获取任何 pd.DataFrame

`df = pd.DataFrame([     [1, 2, 3],     [4, 5, 6], ], columns=list("xyz"), index=list("ab")) df` 
 `x   y   z a   1   2   3 b   4   5   6` 

你只需要访问 pd.DataFrame.T 属性,就能看到你的行变成列,列变成行:

`df.T` 
 `a   b x   1   4 y   2   5 z   3   6` 

转置的原因无穷无尽,从单纯地觉得在给定格式下看起来更好,到更容易通过行索引标签选择而不是列索引标签选择的情况。

然而,转置的主要用例之一是,在应用函数之前,将你的 pd.DataFrame 转换为最佳格式。正如我们在第五章《算法与如何应用它们》中所学到的,pandas 能够对每一列进行聚合:

`df.sum()` 
`x    5 y    7 z    9 dtype: int64` 

以及对每一行使用axis=1参数:

`df.sum(axis=1)` 
`a     6 b    15 dtype: int64` 

不幸的是,使用 axis=1 参数可能会显著降低应用程序的性能。如果你发现自己在代码中散布了大量的 axis=1 调用, chances are 你最好先进行数据转置,再使用默认的 axis=0 来应用函数。

为了看出差异,让我们看一个相当宽的 pd.DataFrame

`np.random.seed(42) df = pd.DataFrame(     np.random.randint(10, size=(2, 10_000)),     index=list("ab"), ) df` 
 `0   1   2   …   9997   9998   9999 a   6   3   7   …   2      9      4 b   2   4   2   …   1      5      5 2 rows × 10,000 columns` 

最终,无论是求行和:

`df.sum(axis=1)` 
`a    44972 b    45097 dtype: int64` 

或者先转置,再使用默认的列求和:

`df.T.sum()` 
`a    44972 b    45097 dtype: int64` 

然而,如果你反复使用 axis=1 作为参数,你会发现,先进行转置可以节省大量时间。

为了衡量这一点,我们可以使用 IPython 来检查执行 100 次求和操作所需的时间:

`import timeit def baseline_sum():    for _ in range(100):       df.sum(axis=1) timeit.timeit(baseline_sum, number=100)` 
`4.366703154002607` 

相比之下,先进行转置然后求和会更快:

`def transposed_sum():    transposed = df.T    for _ in range(100):       transposed.sum() timeit.timeit(transposed_sum, number=100)` 
`0.7069798299999093` 

总体而言,使用 pd.DataFrame.T 来避免后续使用 axis=1 调用是非常推荐的做法。

加入我们的社区,加入 Discord 讨论

加入我们社区的 Discord 空间,与作者和其他读者讨论:

packt.link/pandas

第八章:分组操作

数据分析中最基本的任务之一是将数据分成独立的组,然后对每个组执行计算。这种方法已经存在了很长时间,但最近被称为split-apply-combine

split-apply-combine范式的apply步骤中,了解我们是在进行归约(也称为聚合)还是转换是非常有帮助的。前者会将组中的值归约为一个值,而后者则试图保持组的形状不变。

为了说明这一点,以下是归约操作的 split-apply-combine 示例:

图 8.1:归约的 split-apply-combine 范式

下面是转换的相同范式:

图 8.2:转换的 split-apply-combine 范式

在 pandas 中,pd.DataFrame.groupby方法负责将数据进行分组,应用你选择的函数,并将结果合并回最终结果。

本章将介绍以下内容:

  • 分组基础

  • 分组并计算多个列

  • 分组应用

  • 窗口操作

  • 按年份选择评分最高的电影

  • 比较不同年份的棒球最佳击球手

分组基础

熟练掌握 pandas 的分组机制是每个数据分析师的重要技能。使用 pandas,你可以轻松地总结数据,发现不同组之间的模式,并进行组与组之间的比较。从理论上讲,能够在分组后应用的算法数目是无穷无尽的,这为分析师提供了极大的灵活性来探索数据。

在这个初步示例中,我们将从一个非常简单的求和操作开始,针对不同的组进行计算,数据集故意很小。虽然这个示例过于简化,但理解分组操作如何工作是非常重要的,这对于将来的实际应用非常有帮助。

如何实现

为了熟悉分组操作的代码实现,接下来我们将创建一些示例数据,匹配我们在图 8.1图 8.2中的起始点:

`df = pd.DataFrame([     ["group_a", 0],     ["group_a", 2],     ["group_b", 1],     ["group_b", 3],     ["group_b", 5], ], columns=["group", "value"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `group      value 0    group_a    0 1    group_a    2 2    group_b    1 3    group_b    3 4    group_b    5` 

我们的pd.DataFrame有两个不同的组:group_agroup_b。如你所见,group_a的行与value数据的02关联,而group_b的行与value数据的135关联。因此,对每个group的值进行求和,结果应该分别是29

为了使用 pandas 表达这一点,你将使用pd.DataFrame.groupby方法,该方法接受作为参数的分组名称。在我们的例子中,这是group列。技术上,它返回一个pd.core.groupby.DataFrameGroupBy对象,暴露出一个用于求和的pd.core.groupby.DataFrameGroupBy.sum方法:

`df.groupby("group").sum()` 
`group      value group_a    2 group_b    9` 

如果你觉得方法名 pd.core.groupby.DataFrameGroupBy.sum 太冗长,不用担心;它确实冗长,但你永远不需要手动写出它。我们在这里为了完整性会使用它的技术名称,但作为终端用户,你始终会按照你所看到的形式使用:

`df.groupby(<GROUP_OR_GROUPS>)` 

这就是你用来获取 pd.core.groupby.DataFrameGroupBy 对象的方式。

默认情况下,pd.core.groupby.DataFrameGroupBy.sum 被视为聚合,因此每个组在应用阶段会被归约为一行,就像我们在图 8.1 中看到的那样。

我们本可以不直接调用 pd.core.groupby.DataFrameGroupBy.sum,而是使用 pd.core.groupby.DataFrameGroupBy.agg 方法,并传递 "sum" 作为参数:

`df.groupby("group").agg("sum")` 
`group    value group_a  2 group_b  9` 

pd.core.groupby.DataFrameGroupBy.agg 的明确性在与 pd.core.groupby.DataFrameGroupBy.transform 方法对比时显得非常有用,后者将执行转换(再次见图 8.2),而不是归约

`df.groupby("group").transform("sum")` 
 `value 0       2 1       2 2       9 3       9 4       9` 

pd.core.groupby.DataFrameGroupBy.transform 保证返回一个具有相同索引的对象给调用者,这使得它非常适合进行诸如% of group之类的计算:

`df[["value"]].div(df.groupby("group").transform("sum"))` 
 `value 0    0.000000 1    1.000000 2    0.111111 3    0.333333 4    0.555556` 

在应用归约算法时,pd.DataFrame.groupby 会取出组的唯一值,并利用它们来形成一个新的行 pd.Index(或者在多个分组的情况下是 pd.MultiIndex)。如果你不希望分组标签创建新的索引,而是将它们保留为列,你可以传递 as_index=False

`df.groupby("group", as_index=False).sum()` 
 `group    value 0  group_a      2 1  group_b      9` 

你还应该注意,在执行分组操作时,任何非分组列的名称不会改变。例如,即使我们从一个包含名为 value 的列的 pd.DataFrame 开始:

`df` 
 `group    value 0  group_a      0 1  group_a      2 2  group_b      1 3  group_b      3 4  group_b      5` 

事实上,我们随后按 group 列分组并对 value 列求和,这并不会改变结果中的列名;它仍然叫做 value

`df.groupby("group").sum()` 
`group      value group_a    2 group_b    9` 

如果你对组应用其他算法,比如 min,这可能会让人困惑或产生歧义:

`df.groupby("group").min()` 
`group      value group_a    0 group_b    1` 

我们的列仍然叫做 value,即使在某个实例中,我们是在计算value 的总和,而在另一个实例中,我们是在计算value 的最小值

幸运的是,有一种方法可以通过使用 pd.NamedAgg 类来控制这一点。当调用 pd.core.groupby.DataFrameGroupBy.agg 时,你可以提供关键字参数,其中每个参数键决定了所需的列名,而参数值是 pd.NamedAgg,它决定了聚合操作以及它应用的原始列。

例如,如果我们想对 value 列应用 sum 聚合,并且将结果显示为 sum_of_value,我们可以写出以下代码:

`df.groupby("group").agg(sum_of_value=pd.NamedAgg(column="value", aggfunc="sum"))` 
`group           sum_of_value group_a         2 group_b         9` 

还有更多…

尽管这篇教程主要关注求和,但 pandas 提供了许多其他内置的归约算法,可以应用于 pd.core.groupby.DataFrameGroupBy 对象,例如以下几种:

any all sum prod
idxmin idxmax min max
mean median var std
sem skew first last

表 8.1:常用的 GroupBy 减少算法

同样,您可以使用一些内置的转换函数:

cumprod cumsum cummin
cummax rank

表 8.2:常用的 GroupBy 转换算法

功能上,直接调用这些函数作为pd.core.groupby.DataFrameGroupBy的方法与将它们作为参数提供给pd.core.groupby.DataFrameGroupBy.aggpd.core.groupby.DataFrameGroupBy.transform没有区别。你将通过以下方式获得相同的性能和结果:

`df.groupby("group").max()` 
`group      value group_a    2 group_b    5` 

上述代码片段将得到与以下代码相同的结果:

`df.groupby("group").agg("max")` 
`group      value group_a    2 group_b    5` 

你可以说,后者的方法更明确,特别是考虑到max可以作为转换函数使用,就像它作为聚合函数一样:

`df.groupby("group").transform("max")` 
 `value 0       2 1       2 2       5 3       5 4       5` 

在实践中,这两种风格都很常见,因此你应该熟悉不同的方法。

对多个列进行分组和计算

现在我们掌握了基本概念,接下来让我们看一个包含更多数据列的pd.DataFrame。通常情况下,你的pd.DataFrame对象将包含许多列,且每列可能有不同的数据类型,因此了解如何通过pd.core.groupby.DataFrameGroupBy来选择并处理它们非常重要。

如何实现

让我们创建一个pd.DataFrame,展示一个假设的widget在不同regionmonth值下的销售退货数据:

`df = pd.DataFrame([     ["North", "Widget A", "Jan", 10, 2],     ["North", "Widget B", "Jan", 4, 0],     ["South", "Widget A", "Jan", 8, 3],     ["South", "Widget B", "Jan", 12, 8],     ["North", "Widget A", "Feb", 3, 0],     ["North", "Widget B", "Feb", 7, 0],     ["South", "Widget A", "Feb", 11, 2],     ["South", "Widget B", "Feb", 13, 4], ], columns=["region", "widget", "month", "sales", "returns"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `region     widget     month   sales     returns 0    North      Widget A   Jan     10        2 1    North      Widget B   Jan      4        0 2    South      Widget A   Jan      8        3 3    South      Widget B   Jan     12        8 4    North      Widget A   Feb      3        0 5    North      Widget B   Feb      7        0 6    South      Widget A   Feb     11        2 7    South      Widget B   Feb     13        4` 

要计算每个widget销售退货总额,你的第一次尝试可能会是这样的:

`df.groupby("widget").sum()` 
`widget    region                month         sales  returns Widget A  NorthSouthNorthSouth  JanJanFebFeb     32        7 Widget B  NorthSouthNorthSouth  JanJanFebFeb     36       12` 

尽管salesreturns看起来很好,但regionmonth列也被汇总了,使用的是 Python 在处理字符串时的相同求和逻辑:

`"North" + "South" + "North" + "South"` 
`NorthSouthNorthSouth` 

不幸的是,这种默认行为通常是不可取的。我个人认为很少会希望字符串以这种方式连接,而且在处理大型pd.DataFrame对象时,执行此操作的成本可能非常高。

避免这个问题的一种方法是更加明确地选择你希望聚合的列,可以在df.groupby("widget")调用后进行选择:

`df.groupby("widget")[["sales", "returns"]].agg("sum")` 
`widget        sales    returns Widget A      32        7 Widget B      36       12` 

或者,你可以使用我们在Group by basics配方中介绍的pd.NamedAgg类。虽然它更加冗长,但使用pd.NamedAgg可以让你重命名你希望在输出中看到的列(例如,sales可以改为sales_total):

`df.groupby("widget").agg(     sales_total=pd.NamedAgg(column="sales", aggfunc="sum"),     returns_total=pd.NamedAgg(column="returns", aggfunc="sum"), )` 
`widget            sales_total     returns_total Widget A          32               7 Widget B          36              12` 

pd.core.groupby.DataFrameGroupBy的另一个值得注意的特性是其能够处理多个group参数。通过提供一个列表,你可以扩展分组,涵盖widgetregion

`df.groupby(["widget", "region"]).agg(     sales_total=pd.NamedAgg("sales", "sum"),     returns_total=pd.NamedAgg("returns", "sum"), )` 
`widget      region          sales_total     returns_total Widget A    North           13               2             South           19               5 Widget B    North           11               0             South           25              12` 

使用pd.core.groupby.DataFrameGroupBy.agg时,没有对可以应用多少个函数的限制。例如,如果你想查看每个widgetregion中的销售退货summinmean,你可以简单地写出如下代码:

`df.groupby(["widget", "region"]).agg(     sales_total=pd.NamedAgg("sales", "sum"),     returns_total=pd.NamedAgg("returns", "sum"),     sales_min=pd.NamedAgg("sales", "min"),     returns_min=pd.NamedAgg("returns", "min"), )` 
 `sales_total   returns_total   sales_min   returns_min widget     region  Widget A   North            13                2           3             0            South            19                5           8             2 Widget B   North            11                0           4             0            South            25               12          12             4` 

还有更多……

虽然内置的分组聚合和转换函数在默认情况下非常有用,但有时你可能需要使用自己的自定义函数。当你发现某个算法在本地分析中“足够好”时,这尤其有用,尽管这个算法可能很难推广到所有使用场景。

pandas 中一个常见的请求函数是 mode,但是在分组操作中并没有开箱即用的提供,尽管有 pd.Series.mode 方法。使用 pd.Series.mode 时,返回的类型始终是 pd.Series,无论是否只有一个值出现频率最高:

`pd.Series([0, 1, 1]).mode()` 
`0    1 dtype: int64` 

即使有两个或更多元素出现频率相同,这一切仍然成立:

`pd.Series([0, 1, 1, 2, 2]).mode()` 
`0    1 1    2 dtype: int64` 

鉴于pd.Series.mode已存在,为什么 pandas 在进行分组时没有提供类似的功能?从 pandas 开发者的角度来看,原因很简单;没有一种单一的方式来解释分组操作应该返回什么。

让我们通过以下示例更详细地思考这个问题,其中 group_a 包含两个出现频率相同的值(42 和 555),而 group_b 只包含值 0:

`df = pd.DataFrame([     ["group_a", 42],     ["group_a", 555],     ["group_a", 42],     ["group_a", 555],     ["group_b", 0], ], columns=["group", "value"]) df` 
 `group    value 0 group_a     42 1 group_a    555 2 group_a     42 3 group_a    555 4 group_b      0` 

我们需要回答的问题是,对于 group_a,mode 应该返回什么? 一种可能的解决方案是返回一个列表(或任何 Python 序列),其中包含 42 和 555。然而,这种方法的缺点是,返回的 dtype 会是 object,这种类型的陷阱我们在 第三章数据类型 中已经讨论过。

`pd.Series([[42, 555], 0], index=pd.Index(["group_a", "group_b"], name="group"))` 
`group group_a    [42, 555] group_b            0 dtype: object` 

第二种期望是,pandas 只是选择一个值。当然,这就引出了一个问题,pandas 应该如何做出这个决定——对于 group_a,值 42 或 555 哪个更合适,如何在一般情况下做出判断呢?

另一个期望是,在聚合后的结果行索引中,group_a 标签会出现两次。然而,没有其他的分组聚合是这样工作的,所以我们会引入新的并可能是意外的行为,通过简化为此:

`pd.Series(     [42, 555, 0],     index=pd.Index(["group_a", "group_a", "group_b"], name="group") )` 
`group group_a     42 group_a    555 group_b      0 dtype: int64` 

pandas 并没有试图解决所有这些期望并将其编码为 API 的一部分,而是完全交给你来决定如何实现 mode 函数,只要你遵循聚合操作每个分组返回单一值的预期。这排除了我们刚才概述的第三种期望,至少在本章后续谈论 Group by apply 时才会重新讨论。

为此,如果我们想使用自定义的众数函数,它们可能最终看起来像这样:

`def scalar_or_list_mode(ser: pd.Series):     result = ser.mode()     if len(result) > 1:         return result.tolist()     elif len(result) == 1:         return result.iloc[0]     return pd.NA def scalar_or_bust_mode(ser: pd.Series):     result = ser.mode()     if len(result) == 0:         return pd.NA     return result.iloc[0]` 

由于这些都是聚合操作,我们可以在 pd.core.groupby.DataFrameGroupBy.agg 操作的上下文中使用它们:

`df.groupby("group").agg(     scalar_or_list=pd.NamedAgg(column="value", aggfunc=scalar_or_list_mode),     scalar_or_bust=pd.NamedAgg(column="value", aggfunc=scalar_or_bust_mode), )` 
 `scalar_or_list    scalar_or_bust group                                 group_a   [42, 555]              42 group_b          0                0` 

Group by apply

在我们讨论算法以及如何应用它们的 第五章算法及其应用 中,我们接触到了 Apply 函数,它既强大又令人恐惧。一个与 group by 等效的函数是 pd.core.groupby.DataFrameGroupBy.apply,并且有着相同的注意事项。通常,这个函数被过度使用,您应该选择 pd.core.groupby.DataFrameGroupBy.aggpd.core.groupby.DataFrameGroupBy.transform。然而,对于那些您既不想要 聚合 也不想要 转换,但又希望得到介于两者之间的功能的情况,使用 apply 是唯一的选择。

通常情况下,pd.core.groupby.DataFrameGroupBy.apply 应该仅在不得已时使用。它有时会产生模糊的行为,并且在 pandas 的不同版本之间容易出现破裂。

如何做

在前一个食谱的 还有更多… 部分中,我们提到过从以下的 pd.DataFrame 开始是不可能的:

`df = pd.DataFrame([     ["group_a", 42],     ["group_a", 555],     ["group_a", 42],     ["group_a", 555],     ["group_b", 0], ], columns=["group", "value"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `group    value 0 group_a     42 1 group_a    555 2 group_a     42 3 group_a    555 4 group_b      0` 

并使用自定义的 mode 算法,提供给 pd.core.groupby.DataFrameGroupBy.agg 以生成以下输出:

`pd.Series(     [42, 555, 0],     index=pd.Index(["group_a", "group_a", "group_b"], name="group"),     dtype=pd.Int64Dtype(), )` 
`group group_a     42 group_a    555 group_b      0 dtype: Int64` 

这样做的原因很简单;聚合期望你将每个分组标签减少到一个单一值。输出中重复 group_a 标签两次对于聚合来说是不可接受的。同样,转换期望你生成的结果与调用的 pd.DataFrame 具有相同的行索引,而这并不是我们想要的结果。

pd.core.groupby.DataFrameGroupBy.apply 是一个介于两者之间的方法,可以让我们更接近所期望的结果,正如接下来的代码所示。作为一个技术性的旁注,include_groups=False 参数被传递以抑制关于 pandas 2.2 版本行为的弃用警告。在后续版本中,您可能不需要这个参数:

`def mode_for_apply(df: pd.DataFrame):     return df["value"].mode() df.groupby("group").apply(mode_for_apply, include_groups=False)` 
`group       group_a  0     42          1    555 group_b  0      0 Name: value, dtype: Int64` 

需要注意的是,我们将 mode_for_apply 函数的参数注解为 pd.DataFrame。在聚合和转换中,用户定义的函数每次只会接收一个 pd.Series 类型的数据,但使用 apply 时,您将获得整个 pd.DataFrame。如果想更详细地了解发生了什么,可以在用户定义的函数中添加 print 语句:

`def mode_for_apply(df: pd.DataFrame):     print(f"\nThe data passed to apply is:\n{df}")     return df["value"].mode() df.groupby("group").apply(mode_for_apply, include_groups=False)` 
`The data passed to apply is:   value 0     42 1    555 2     42 3    555 The data passed to apply is:   value 4      0 group      group_a  0     42         1    555 group_b  0      0 Name: value, dtype: Int64` 

本质上,pd.core.groupby.DataFrameGroupBy.apply 将数据传递给用户定义的函数,传递的数据是一个 pd.DataFrame,并排除了用于分组的列。从那里,它会查看用户定义的函数的返回类型,并尝试推断出最合适的输出形状。在这个特定的例子中,由于我们的 mode_for_apply 函数返回的是一个 pd.Seriespd.core.groupby.DataFrameGroupBy.apply 已经确定最佳的输出形状应该是一个 pd.MultiIndex,其中索引的第一层是组值,第二层包含由 mode_for_apply 函数返回的 pd.Series 的行索引。

pd.core.groupby.DataFrameGroupBy.apply 被过度使用的地方在于,当它检测到所应用的函数可以减少为单个值时,它会改变形状,看起来像是一个聚合操作:

`def sum_values(df: pd.DataFrame):     return df["value"].sum() df.groupby("group").apply(sum_values, include_groups=False)` 
`group group_a    1194 group_b       0 dtype: int64` 

然而,以这种方式使用它是一个陷阱。即使它能够推断出一些输出的合理形状,确定这些形状的规则是实现细节,这会导致性能损失,或者在不同版本的 pandas 中可能导致代码破裂。如果你知道你的函数将减少为单个值,始终选择使用 pd.core.groupby.DataFrameGroupBy.agg 来代替 pd.core.groupby.DataFrameGroupBy.apply,后者只应在极端用例中使用。

窗口操作

窗口操作允许你在一个滑动的分区(或“窗口”)内计算值。通常,这些操作用于计算“滚动的 90 天平均值”等,但它们足够灵活,可以扩展到你选择的任何算法。

虽然从技术上讲这不是一个分组操作,但窗口操作在这里被包含进来,因为它们共享类似的 API 并且可以与“数据组”一起工作。与分组操作的唯一不同之处在于,窗口操作并不是通过唯一值集来形成分组,而是通过遍历 pandas 对象中的每个值,查看特定数量的前后(有时是后续)值来创建其组。

如何实现

为了理解窗口操作如何工作,让我们从一个简单的 pd.Series 开始,其中每个元素是 2 的递增幂:

`ser = pd.Series([0, 1, 2, 4, 8, 16], dtype=pd.Int64Dtype()) ser` 
`0     0 1     1 2     2 3     4 4     8 5    16 dtype: Int64` 

你将遇到的第一种窗口操作是“滚动窗口”,通过 pd.Series.rolling 方法访问。当调用此方法时,你需要告诉 pandas 你希望的窗口大小 n。pandas 会从每个元素开始,向后查看 n-1 个记录来形成“窗口”:

`ser.rolling(2).sum()` 
`0     NaN 1     1.0 2     3.0 3     6.0 4    12.0 5    24.0 dtype: float64` 

你可能注意到,我们开始时使用了 pd.Int64Dtype(),但在滚动窗口操作后,最终得到了 float64 类型。不幸的是,pandas 窗口操作至少在 2.2 版本中与 pandas 扩展系统的兼容性不好(参见问题 #50449),因此目前,我们需要将结果转换回正确的数据类型:

`ser.rolling(2).sum().astype(pd.Int64Dtype())` 
`0    <NA> 1       1 2       3 3       6 4      12 5      24 dtype: Int64` 

那么,这里发生了什么?本质上,你可以将滚动窗口操作看作是遍历 pd.Series 的值。在此过程中,它向后查看,试图收集足够的值以满足所需的窗口大小,我们指定的窗口大小是 2。

在每个窗口中收集两个元素后,pandas 会应用指定的聚合函数(在我们的例子中是求和)。每个窗口中的聚合结果将用于将结果拼接回去:

图 8.3:滚动窗口与求和聚合

对于我们的第一个记录,由于无法形成包含两个元素的窗口,pandas 会返回缺失值。如果你希望滚动计算即使窗口大小无法满足也能尽可能地求和,你可以向min_periods=传递一个参数,指定每个窗口中进行聚合所需的最小元素数量:

`ser.rolling(2, min_periods=1).sum().astype(pd.Int64Dtype())` 
`0     0 1     1 2     3 3     6 4    12 5    24 dtype: Int64` 

默认情况下,滚动窗口操作会向后查找以满足你的窗口大小要求。你也可以将它们“居中”,让 pandas 同时向前和向后查找。

这种效果在使用奇数窗口大小时更为明显。当我们将窗口大小扩展为3时,注意到的区别如下:

`ser.rolling(3).sum().astype(pd.Int64Dtype())` 
`0    <NA> 1    <NA> 2       3 3       7 4      14 5      28 dtype: Int64` 

与使用center=True参数的相同调用进行比较:

`ser.rolling(3, center=True).sum().astype(pd.Int64Dtype())` 
`0    <NA> 1       3 2       7 3      14 4      28 5    <NA> dtype: Int64` 

与查看当前值及前两个值不同,使用center=True告诉 pandas 在窗口中包含当前值、前一个值和后一个值。

另一种窗口函数是“扩展窗口”,它会查看所有先前遇到的值。其语法非常简单;只需将调用pd.Series.rolling替换为pd.Series.expanding,然后跟随你想要的聚合函数。扩展求和类似于你之前看到的pd.Series.cumsum方法,因此为了演示,我们选择一个不同的聚合函数,比如mean

`ser.expanding().mean().astype(pd.Float64Dtype())` 
`0         0.0 1         0.5 2         1.0 3        1.75 4         3.0 5    5.166667 dtype: Float64` 

以可视化方式表示,扩展窗口计算如下(为了简洁,未显示所有pd.Series元素):

图 8.4:扩展窗口与均值聚合

还有更多…

第九章时间数据类型与算法中,我们将更深入地探讨 pandas 在处理时间数据时提供的一些非常有用的功能。在我们深入探讨之前,值得注意的是,分组和滚动/扩展窗口函数与此类数据非常自然地配合使用,让你能够简洁地执行诸如“X 天移动平均”、“年初至今 X”、“季度至今 X”等计算。

为了看看这如何运作,我们再来看一下在第五章算法及如何应用它们中,我们最初使用的 Nvidia 股票表现数据集,该数据集作为计算追踪止损价格食谱的一部分:

`df = pd.read_csv(     "data/NVDA.csv",     usecols=["Date", "Close"],     parse_dates=["Date"],     dtype_backend="numpy_nullable", ).set_index("Date") df` 
 `Date        Close 2020-01-02    59.977501 2020-01-03    59.017502 2020-01-06    59.264999 2020-01-07    59.982498 2020-01-08    60.095001 …             … 2023-12-22   488.299988 2023-12-26   492.790009 2023-12-27   494.170013 2023-12-28   495.220001 2023-12-29   495.220001 1006 rows × 1 columns` 

使用滚动窗口函数,我们可以轻松地计算 30 天、60 天和 90 天的移动平均。随后调用pd.DataFrame.plot也让这种可视化变得简单:

`import matplotlib.pyplot as plt plt.ion() df.assign(     ma30=df["Close"].rolling(30).mean().astype(pd.Float64Dtype()),     ma60=df["Close"].rolling(60).mean().astype(pd.Float64Dtype()),     ma90=df["Close"].rolling(90).mean().astype(pd.Float64Dtype()), ).plot()` 

对于“年初至今”和“季度至今”计算,我们可以使用分组与扩展窗口函数的组合。对于“年初至今”的最小值、最大值和均值,我们可以首先形成一个分组对象,将数据分成按年划分的桶,然后可以调用.expanding()

`df.groupby(pd.Grouper(freq="YS")).expanding().agg(     ["min", "max", "mean"] )` 
 `Close                         min        max        mean Date       Date 2020-01-01  2020-01-02  59.977501  59.977501  59.977501             2020-01-03  59.017502  59.977501  59.497501             2020-01-06  59.017502  59.977501  59.420001             2020-01-07  59.017502  59.982498  59.560625             2020-01-08  59.017502  60.095001  59.667500 …           …          …          …          … 2023-01-01  2023-12-22  142.649994  504.089996  363.600610             2023-12-26  142.649994  504.089996  364.123644             2023-12-27  142.649994  504.089996  364.648024             2023-12-28  142.649994  504.089996  365.172410             2023-12-29  142.649994  504.089996  365.692600 1006 rows × 3 columns` 

pd.Grouper(freq="YS")将我们的行索引(包含日期时间)按年份的开始进行分组。分组后,调用.expanding()执行最小值/最大值聚合,只看每年开始时的值。这个效果再次通过可视化更容易看出:

`df.groupby(pd.Grouper(freq="YS")).expanding().agg(     ["min", "max", "mean"] ).droplevel(axis=1, level=0).reset_index(level=0, drop=True).plot()` 

为了获得更详细的视图,你可以通过将freq=参数从YS改为QS,计算每个季度的扩展最小/最大收盘价格:

`df.groupby(pd.Grouper(freq="QS")).expanding().agg(     ["min", "max", "mean"] ).reset_index(level=0, drop=True).plot()` 

使用MS freq=参数可以将时间精度降低到月份级别:

`df.groupby(pd.Grouper(freq="MS")).expanding().agg(     ["min", "max", "mean"] ).reset_index(level=0, drop=True).plot()` 

按年份选择评分最高的电影

数据分析中最基本和常见的操作之一是选择某个列在组内具有最大值的行。应用到我们的电影数据集,这可能意味着找出每年评分最高的电影或按内容评级找出最高票房的电影。为了完成这些任务,我们需要对组以及用于排名每个组成员的列进行排序,然后提取每个组中的最高成员。

在这个示例中,我们将使用pd.DataFrame.sort_valuespd.DataFrame.drop_duplicates的组合,找出每年评分最高的电影。

如何操作

开始时,读取电影数据集并将其精简为我们关心的三列:movie_titletitle_yearimdb_score

`df = pd.read_csv(     "data/movie.csv",     usecols=["movie_title", "title_year", "imdb_score"],     dtype_backend="numpy_nullable", ) df` 
 `movie_title                                  title_year  imdb_score 0  Avatar                                        2009.0        7.9 1  Pirates of the Caribbean: At World's End      2007.0        7.1 2  Spectre                                       2015.0        6.8 3  The Dark Knight Rises                         2012.0        8.5 4  Star Wars: Episode VII - The Force Awakens     <NA>        7.1 …                                                 …          … 4911  Signed Sealed Delivered                    2013.0        7.7 4912  The Following                               <NA>        7.5 4913  A Plague So Pleasant                       2013.0        6.3 4914  Shanghai Calling                           2012.0        6.3 4915  My Date with Drew                          2004.0        6.6 4916 rows × 3 columns` 

如你所见,title_year列被解释为浮动小数点值,但年份应始终是整数。我们可以通过直接为列分配正确的数据类型来纠正这一点:

`df["title_year"] = df["title_year"].astype(pd.Int16Dtype()) df.head(3)` 
 `movie_title                                title_year  imdb_score 0   Avatar                                     2009        7.9 1   Pirates of the Caribbean: At World's End   2007        7.1 2   Spectre                                    2015        6.8` 

另外,我们也可以在pd.read_csv中通过dtype=参数传递所需的数据类型:

`df = pd.read_csv(     "data/movie.csv",     usecols=["movie_title", "title_year", "imdb_score"],     dtype={"title_year": pd.Int16Dtype()},     dtype_backend="numpy_nullable", ) df.head(3)` 
 `movie_title                                 title_year  imdb_score 0   Avatar                                      2009         7.9 1   Pirates of the Caribbean: At World's End    2007         7.1 2   Spectre                                     2015         6.8` 

通过数据清洗工作完成后,我们现在可以专注于回答“每年评分最高的电影是什么?”这个问题。我们可以通过几种方式来计算,但让我们从最常见的方法开始。

当你在 pandas 中执行分组操作时,原始pd.DataFrame中行的顺序会被保留,行会根据不同的组进行分配。知道这一点后,很多用户会通过首先按title_yearimdb_score对数据集进行排序来回答这个问题。排序后,你可以按title_year列进行分组,仅选择movie_title列,并链式调用pd.DataFrameGroupBy.last来选择每个组的最后一个值:

`df.sort_values(["title_year", "imdb_score"]).groupby(     "title_year" )[["movie_title"]].agg(top_rated_movie=pd.NamedAgg("movie_title", "last"))` 
`title_year                                    top_rated_movie 1916         Intolerance: Love's Struggle Throughout the Ages 1920                           Over the Hill to the Poorhouse 1925                                           The Big Parade 1927                                               Metropolis 1929                                            Pandora's Box …                                                           … 2012                                         Django Unchained 2013                  Batman: The Dark Knight Returns, Part 2 2014                                           Butterfly Girl 2015                                          Running Forever 2016                                     Kickboxer: Vengeance 91 rows × 1 columns` 

如果使用pd.DataFrameGroupBy.idxmax,它会选择每年评分最高的电影的行索引值,这是一种更简洁的方法。这要求你事先将索引设置为movie_title

`df.set_index("movie_title").groupby("title_year").agg(     top_rated_movie=pd.NamedAgg("imdb_score", "idxmax") )` 
`title_year                                   top_rated_movie 1916        Intolerance: Love's Struggle Throughout the Ages 1920                          Over the Hill to the Poorhouse 1925                                          The Big Parade 1927                                              Metropolis 1929                                           Pandora's Box …                                                          … 2012                                   The Dark Knight Rises 2013                 Batman: The Dark Knight Returns, Part 2 2014                                  Queen of the Mountains 2015                                         Running Forever 2016                                    Kickboxer: Vengeance 91 rows × 1 columns` 

我们的结果大致相同,尽管我们可以看到在 2012 年和 2014 年,两个方法在选择评分最高的电影时存在不同。仔细查看这些电影标题可以揭示出根本原因:

`df[df["movie_title"].isin({     "Django Unchained",     "The Dark Knight Rises",     "Butterfly Girl",     "Queen of the Mountains", })]` 
 `movie_title 			title_year 	imdb_score 3 			The Dark Knight Rises 	2012 		8.5 293 			Django Unchained 		2012 		8.5 4369 		Queen of the Mountains 	2014 		8.7 4804 		Butterfly Girl			2014 		8.7` 

在发生平局的情况下,每种方法都有自己选择值的方式。没有哪种方法本身是对错的,但如果你希望对这一点进行更精细的控制,你将不得不使用按组应用

假设我们想要汇总这些值,以便在没有平局的情况下返回一个字符串,而在发生平局时返回一组字符串。为此,您应该定义一个接受pd.DataFrame的函数。这个pd.DataFrame将包含与每个唯一分组列相关的值,在我们的例子中,这个分组列是title_year

在函数体内,您可以找出最高的电影评分,找到所有具有该评分的电影,并返回一个单一的电影标题(当没有平局时)或一组电影(在发生平局时):

`def top_rated(df: pd.DataFrame):     top_rating = df["imdb_score"].max()     top_rated = df[df["imdb_score"] == top_rating]["movie_title"].unique()     if len(top_rated) == 1:         return top_rated[0]     else:         return top_rated df.groupby("title_year").apply(     top_rated, include_groups=False ).to_frame().rename(columns={0: "top_rated_movie(s)"})` 
`title_year                                   top_rated_movie(s) 1916           Intolerance: Love's Struggle Throughout the Ages 1920                             Over the Hill to the Poorhouse 1925                                             The Big Parade 1927                                                 Metropolis 1929                                              Pandora's Box …                                                             … 2012                  [The Dark Knight Rises, Django Unchained] 2013                    Batman: The Dark Knight Returns, Part 2 2014                   [Queen of the Mountains, Butterfly Girl] 2015                                            Running Forever 2016                                       Kickboxer: Vengeance 91 rows × 1 columns` 

比较棒球历史上各年最佳击球手

第五章,算法及其应用* 中的寻找棒球 最擅长的球员…食谱中,我们处理了一个已经汇总了 2020 至 2023 年球员表现的数据集。然而,基于球员在多个年份之间的表现进行比较相当困难。即使是逐年比较,某一年看似精英的统计数据,其他年份可能也仅被认为是“非常好”。统计数据跨年份的变化原因可以进行辩论,但可能归结为战略、设备、天气以及纯粹的统计运气等多种因素的组合。

对于这个食谱,我们将使用一个更精细的数据集,该数据集细化到游戏层级。从那里,我们将把数据汇总到年度总结,然后计算一个常见的棒球统计数据——打击率

对于不熟悉的人,打击率是通过将球员的击球次数(即他们击打棒球并成功上垒的次数)作为总打席次数(即他们上场打击的次数,不包括保送)的百分比来计算的。

那么,什么样的打击率才算好呢?正如你所见,答案是一个不断变化的目标,甚至在过去的二十年里也发生了变化。在 2000 年代初,打击率在.260-.270 之间(即每 26%-27%的打击中能击中一次)被认为是职业选手的中等水平。近年来,这个数字已经下降到了.240-.250 的范围。

因此,为了尝试将每年最佳击球手进行比较,我们不能仅仅看打击率。在一个联盟整体打击率为.240 的年份,打击率为.325 的球员可能比在联盟整体打击率为.260 的年份,打击率为.330 的球员更具震撼力。

如何做

再次强调,我们将使用从retrosheet.org收集的数据,并附上以下法律声明:

这里使用的信息是从 Retrosheet 免费获得的,并且受版权保护。感兴趣的各方可以通过 www.retrosheet.org 联系 Retrosheet。

在这个示例中,我们将使用 2000-2023 年每场常规赛的“比赛记录”摘要:

`df = pd.read_parquet("data/mlb_batting_lines.parquet") df` 
 `year     game       starttime   …   cs  gidp  int 0   2015  ANA201504100   7:12PM   …    0    0    0 1   2015  ANA201504100   7:12PM   …    0    0    0 2   2015  ANA201504100   7:12PM   …    0    0    0 3   2015  ANA201504100   7:12PM   …    0    0    0 4   2015  ANA201504100   7:12PM   …    0    0    0 …     …          …          …   …    …    …    … 1630995 2013  WAS201309222   7:06PM   …    0    0    0 1630996 2013  WAS201309222   7:06PM   …    0    0    0 1630997 2013  WAS201309222   7:06PM   …    0    0    0 1630998 2013  WAS201309222   7:06PM   …    0    0    0 1630999 2013  WAS201309222   7:06PM   …    0    0    0 1631000 rows × 26 columns` 

比赛记录总结了每个球员在比赛中的表现。因此,我们可以专注于 2015 年 4 月 10 日在巴尔的摩进行的某场比赛,并查看击球手的表现:

`bal = df[df["game"] == "BAL201504100"] bal.head()` 
 `year      game       starttime   …  cs  gidp  int 2383  2015  BAL201504100   3:11PM   …   0    0    0 2384  2015  BAL201504100   3:11PM   …   0    0    0 2385  2015  BAL201504100   3:11PM   …   0    0    0 2386  2015  BAL201504100   3:11PM   …   0    0    0 2387  2015  BAL201504100   3:11PM   …   0    0    0 5 rows × 26 columns` 

在那场比赛中,我们看到了总共 75 次打击(ab)、29 次安打(h)和两支本垒打(hr):

`bal[["ab", "h", "hr"]].sum()` 
`ab    75 h     29 hr     2 dtype: Int64` 

通过对比赛记录的基本理解,我们可以将焦点转向计算每个球员每年产生的打击率。每个球员在我们的数据集中都有一个 id 列的标注,而由于我们想查看整个赛季的打击率,因此我们可以使用 yearid 的组合作为 pd.DataFrame.groupby 的参数。然后,我们可以对打击次数(ab)和安打次数(h)列进行求和:

`df.groupby(["year", "id"]).agg(     total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),     total_h=pd.NamedAgg(column="h", aggfunc="sum"), )` 
`year  id        total_ab  total_h 2000  abboj002     215       59       abbok002     157       34       abbop001       5        2       abreb001     576      182       acevj001       1        0 …     …           …        … 2023  zavas001     175       30       zerpa001       0        0       zimmb002       0        0       zunig001       0        0       zunim001     124       22 31508 rows × 2 columns` 

为了将这些总数转化为打击率,我们可以使用 pd.DataFrame.assign 链接一个除法操作。之后,调用 pd.DataFrame.drop 将让我们专注于打击率,删除我们不再需要的 total_abtotal_h 列:

`(     df.groupby(["year", "id"]).agg(         total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),         total_h=pd.NamedAgg(column="h", aggfunc="sum"))     .assign(avg=lambda x: x["total_h"] / x["total_ab"])     .drop(columns=["total_ab", "total_h"]) )` 
`year  id        avg 2000  abboj002  0.274419       abbok002  0.216561       abbop001  0.400000       abreb001  0.315972       acevj001  0.000000 …     …         … 2023  zavas001  0.171429       zerpa001  NaN       zimmb002  NaN       zunig001  NaN       zunim001  0.177419 31508 rows × 1 columns` 

在我们继续之前,我们必须考虑在计算平均值时可能出现的一些数据质量问题。在一整个棒球赛季中,球队可能会使用一些只在非常特殊情况下出现的球员,导致其打席次数很低。在某些情况下,击球手甚至可能在整个赛季中没有记录一个“打击机会”,所以使用它作为除数时,可能会导致除以 0,从而产生 NaN。在一些击球手打击次数不为零但仍然相对较少的情况下,小样本量可能会严重扭曲他们的打击率。

美国职棒大联盟有严格的规定,确定一个击球手需要多少次打击机会才能在某一年内资格入选记录。我们不必完全遵循这个规则,也不需要在我们的数据集中计算打击机会,但我们可以通过设置至少 400 次打击机会的要求来作为替代:

`(     df.groupby(["year", "id"]).agg(         total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),         total_h=pd.NamedAgg(column="h", aggfunc="sum"))     .loc[lambda df: df["total_ab"] > 400]     .assign(avg=lambda x: x["total_h"] / x["total_ab"])     .drop(columns=["total_ab", "total_h"]) )` 
`year  id        avg 2000  abreb001  0.315972       alfoe001  0.323529       alicl001  0.294444       alomr001  0.309836       aloum001  0.354626 …     …         … 2023  walkc002  0.257732       walkj003  0.276190       wittb002  0.276131       yelic001  0.278182       yoshm002  0.288641 4147 rows × 1 columns` 

我们可以进一步总结,通过查找每个赛季的平均值和最大值 batting_average,甚至可以使用 pd.core.groupby.DataFrameGroupBy.idxmax 来识别出打击率最高的球员:

`averages = (     df.groupby(["year", "id"]).agg(         total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),         total_h=pd.NamedAgg(column="h", aggfunc="sum"))     .loc[lambda df: df["total_ab"] > 400]     .assign(avg=lambda x: x["total_h"] / x["total_ab"])     .drop(columns=["total_ab", "total_h"]) ) averages.groupby("year").agg(     league_mean_avg=pd.NamedAgg(column="avg", aggfunc="mean"),     league_max_avg=pd.NamedAgg(column="avg", aggfunc="max"),     batting_champion=pd.NamedAgg(column="avg", aggfunc="idxmax"), )` 
`year  league_mean_avg  league_max_avg  batting_champion 2000  0.284512         0.372414         (2000, heltt001) 2001  0.277945         0.350101         (2001, walkl001) 2002  0.275713         0.369727         (2002, bondb001) 2003  0.279268         0.358714         (2003, pujoa001) 2004  0.281307         0.372159         (2004, suzui001) 2005  0.277350         0.335017         (2005, lee-d002) 2006  0.283609         0.347409         (2006, mauej001) 2007  0.281354         0.363025         (2007, ordom001) 2008  0.277991         0.364465         (2008, jonec004) 2009  0.278010         0.365201         (2009, mauej001) 2010  0.271227         0.359073         (2010, hamij003) 2011  0.269997         0.344406         (2011, cabrm001) 2012  0.269419         0.346405         (2012, cabrm002) 2013  0.268789         0.347748         (2013, cabrm001) 2014  0.267409         0.340909         (2014, altuj001) 2015  0.268417         0.337995         (2015, cabrm001) 2016  0.270181         0.347826         (2016, lemad001) 2017  0.268651         0.345763         (2017, altuj001) 2018  0.261824         0.346154         (2018, bettm001) 2019  0.269233         0.335341         (2019, andet001) 2021  0.262239         0.327731         (2021, turnt001) 2022  0.255169         0.326454         (2022, mcnej002) 2023  0.261457         0.353659         (2023, arral001)` 

正如我们所看到的,平均击球率每年都有波动,而这些数字在 2000 年左右较高。在 2005 年,平均击球率为 0.277,最佳击球手(lee-d002,或德雷克·李)击出了 0.335 的成绩。2019 年最佳击球手(andet001,或蒂姆·安德森)同样打出了 0.335 的平均击球率,但整个联盟的平均击球率约为 0.269。因此,有充分的理由认为,蒂姆·安德森 2019 赛季的表现比德雷克·李 2005 赛季的表现更为出色,至少从击球率的角度来看。

虽然计算均值很有用,但它并没有完整地展示一个赛季内发生的所有情况。我们可能更希望了解每个赛季击球率的整体分布,这就需要可视化图表来呈现。我们在用 seaborn 按十年绘制电影评分的食谱中发现的小提琴图,可以帮助我们更详细地理解这一点。

首先,让我们设置好 seaborn 的导入,并尽快让 Matplotlib 绘制图表:

`import matplotlib.pyplot as plt import seaborn as sns plt.ion()` 

接下来,我们需要为 seaborn 做一些调整。seaborn 不支持pd.MultiIndex,因此我们将使用pd.DataFrame.reset_index将索引值移到列中。此外,seaborn 可能会误将离散的年份值(如 2000、2001、2002 等)解释为一个连续的范围,我们可以通过将该列转化为类别数据类型来解决这个问题。

我们希望构建的pd.CategoricalDtype是有序的,这样 pandas 才能确保 2000 年之后是 2001 年,2001 年之后是 2002 年,依此类推:

`sns_df = averages.reset_index() years = sns_df["year"].unique() cat = pd.CategoricalDtype(sorted(years), ordered=True) sns_df["year"] = sns_df["year"].astype(cat) sns_df` 
 `year      id        avg 0   2000  abreb001  0.315972 1   2000  alfoe001  0.323529 2   2000  alicl001  0.294444 3   2000  alomr001  0.309836 4   2000  aloum001  0.354626 …     …      …         … 4142 2023  walkc002  0.257732 4143 2023  walkj003  0.276190 4144 2023  wittb002  0.276131 4145 2023  yelic001  0.278182 4146 2023  yoshm002  0.288641 4147 rows × 3 columns` 

23 年的数据绘制在一张图上可能会占据大量空间,所以我们先来看 2000-2009 年这段时间的数据:

`mask = (sns_df["year"] >= 2000) & (sns_df["year"] < 2010) fig, ax = plt.subplots() sns.violinplot(     data=sns_df[mask],     ax=ax,     x="avg",     y="year",     order=sns_df.loc[mask, "year"].unique(), ) ax.set_xlim(0.15, 0.4) plt.show()` 

我们故意调用了plt.subplots()并使用ax.set_xlim(0.15, 0.4),以确保在绘制其余年份时 x 轴不会发生变化:

`mask = sns_df["year"] >= 2010 fig, ax = plt.subplots() sns.violinplot(     data=sns_df[mask],     ax=ax,     x="avg",     y="year",     order=sns_df.loc[mask, "year"].unique(), ) ax.set_xlim(0.15, 0.4) plt.show()` 

虽然某些年份的数据表现出偏斜(例如 2014 年向右偏斜,2018 年向左偏斜),但我们通常可以将这些数据的分布视为接近正态分布。因此,为了更好地比较不同年份的最佳表现,我们可以使用一种技术,即在每个赛季内标准化数据。我们不再用绝对的击球率(如 0.250)来思考,而是考虑击球手的表现偏离赛季常态的程度。

更具体地,我们可以使用 Z-score 标准化,数学表示如下:

这里,![](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/pd-cb-3e/img/B31091_08_002.png)是均值,![](https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/pd-cb-3e/img/B31091_08_003.png)是标准差。

在 pandas 中计算这一点相当简单;我们需要做的就是定义一个自定义的normalize函数,并将其作为参数传递给pd.core.groupby.DataFrameGroupBy.transform,以为每一组年和球员的组合分配标准化的击球率。在随后的 group by 操作中使用它,可以帮助我们更好地比较不同年份间的最佳表现:

`def normalize(ser: pd.Series) -> pd.Series:     return (ser - ser.mean()) / ser.std() (     averages.assign(         normalized_avg=averages.groupby("year").transform(normalize)     )     .groupby("year").agg(         league_mean_avg=pd.NamedAgg(column="avg", aggfunc="mean"),         league_max_avg=pd.NamedAgg(column="avg", aggfunc="max"),         batting_champion=pd.NamedAgg(column="avg", aggfunc="idxmax"),         max_normalized_avg=pd.NamedAgg(column="normalized_avg", aggfunc="max"),     )     .sort_values(by="max_normalized_avg", ascending=False) ).head()` 
`year  league_mean_avg  league_max_avg  batting_champion      max_normalized_avg 2023  0.261457         0.353659        (2023, arral001)                3.714121 2004  0.281307         0.372159        (2004, suzui001)                3.699129 2002  0.275713         0.369727        (2002, bondb001)                3.553521 2010  0.271227         0.359073        (2010, hamij003)                3.379203 2008  0.277991         0.364465        (2008, jonec004)                3.320429` 

根据这一分析,路易斯·阿雷兹(Luis Arráez)在 2023 赛季的击球率表现是自 2000 年以来最为出色的。他当年创下的league_max_avg可能看起来是我们前五名中的最低值,但 2023 年的league_mean_avg也正是如此。

正如这个示例所示,合理使用 pandas 的 Group By 功能可以帮助你更公平地评估不同组别中的记录。我们的示例使用了一个赛季内的职业棒球运动员,但同样的方法也可以扩展到评估不同年龄组的用户、不同产品线的产品、不同领域的股票等。简而言之,通过 Group By 探索你的数据的可能性是无穷无尽的!

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

第九章:时间数据类型和算法

正确处理时间数据(即日期和时间)可能看起来很直接,但深入了解后,你会发现它比预想的复杂得多。以下是我想到的一些问题:

  • 一些用户按年计算时间;其他用户按纳秒计算

  • 一些用户忽略时区问题;而其他人需要协调全球的事件

  • 并非每个国家都有多个时区,即使它们足够大有时区(例如:中国)

  • 并非每个国家都实行夏令时;即使实行,国家之间也无法达成统一的时间

  • 在实行夏令时的国家,并非每个地区都会参与(例如,美国的亚利桑那州)

  • 不同的操作系统和版本对时间的处理方式不同(另见2038 年问题

这些问题其实只是冰山一角,尽管存在众多潜在的数据质量问题,时间数据在监控、趋势检测和预测方面是无价的。幸运的是,pandas 使得你不需要成为日期和时间的专家就能从数据中提取洞察。通过使用 pandas 提供的功能和抽象,你可以轻松清洗和插补时间数据,从而减少对日期和时间“问题”的关注,更多地关注数据所能提供的洞察。

虽然我们在第三章《数据类型》中的时间类型 - datetime部分介绍了一些 pandas 提供的时间类型,本章将首先关注 pandas 提供的增强这些类型功能的内容。除此之外,我们将讨论如何清洗和插补你的时间数据,最后以实际应用为重点结束本章。

本章将介绍以下几个内容:

  • 时区处理

  • 日期偏移

  • 日期时间选择

  • 重采样

  • 聚合每周的犯罪和交通事故数据

  • 按类别计算犯罪的年同比变化

  • 准确测量传感器收集的具有缺失值的事件

时区处理

迄今为止,我遇到的关于时间数据的最常见错误,源于对时区的误解。在我居住的美国东海岸,我见过很多用户尝试从数据库中读取他们认为是 2024-01-01 的日期,然而讽刺的是,他们分析出来的日期却是 2023-12-31。尽管这个偏差仅仅是一天,但这种不对齐的影响可能会极大地扭曲将日期按周、月、季或年分组的汇总数据。

对于那些之前遇到过类似问题的人,你可能已经意识到,你所通信的源系统可能确实给你提供了一个 2024-01-01 00:00:00 的时间戳,假设它是午夜 UTC 时间。某个环节中,住在美国东海岸的分析师可能将该时间戳转换成了他们的本地时间,这个时间可能会因为夏令时而比 UTC 快四小时,或者因为标准时间而比 UTC 快五小时。结果,时间戳在 EDT/EST 时区分别被显示为 2023-12-31 20:00:00 或 2023-12-31 19:00:00,而用户可能无意中尝试将其转换为一个日期。

为了避免在处理时间数据时出现这些问题,理解你何时正在处理时区感知日期时间(即那些与时区相关的日期时间,如 UTC 或America/New_York),以及时区无关对象(没有附带时区信息的对象)是至关重要的。在本章中,我们将展示如何创建和识别这两种类型的日期时间,并深入探讨 pandas 提供的工具,帮助你在不同的时区之间进行转换,以及从时区感知转换为时区无关。

如何操作

第三章数据类型中,我们学习了如何创建带有日期时间数据的pd.Series。让我们更详细地看看这个例子:

`ser = pd.Series([     "2024-01-01 00:00:00",     "2024-01-02 00:00:01",     "2024-01-03 00:00:02" ], dtype="datetime64[ns]") ser` 
`0   2024-01-01 00:00:00 1   2024-01-02 00:00:01 2   2024-01-03 00:00:02 dtype: datetime64[ns]` 

这些时间戳表示的是发生在 2024 年 1 月 1 日至 1 月 3 日之间午夜时分或接近午夜时分的事件。然而,这些日期时间无法告诉我们的是这些事件发生的地点;纽约市的午夜时间与迪拜的午夜时间是不同的,因此很难确定这些事件发生的确切时间。没有额外的元数据,这些日期时间是时区无关的

要通过编程确认你的日期时间是时区无关的,你可以使用pd.Series.dt.tz,它将返回None

`ser.dt.tz is None` 
`True` 

使用pd.Series.dt.tz_localize方法,我们可以为这些日期时间分配一个互联网号码分配局(IANA)时区标识符,使它们变得时区感知。例如,要指定这些事件发生在美国东海岸,我们可以写:

`ny_ser = ser.dt.tz_localize("America/New_York") ny_ser` 
`0   2024-01-01 00:00:00-05:00 1   2024-01-02 00:00:01-05:00 2   2024-01-03 00:00:02-05:00 dtype: datetime64[ns, America/New_York]` 

如果你尝试在这个pd.Series上使用pd.Series.dt.tz,它将报告你正在使用America/New_York时区:

`ny_ser.dt.tz` 
`<DstTzInfo 'America/New_York' LMT-1 day, 19:04:00 STD>` 

现在我们的pd.Series已经具备时区感知能力,其中包含的日期时间可以映射到世界上任何地方的某个时间点。通过使用pd.Series.dt.tz_convert,你可以轻松地将这些事件转换为另一个时区的时间:

`la_ser = ny_ser.dt.tz_convert("America/Los_Angeles") la_ser` 
`0   2023-12-31 21:00:00-08:00 1   2024-01-01 21:00:01-08:00 2   2024-01-02 21:00:02-08:00 dtype: datetime64[ns, America/Los_Angeles]` 

实际操作中,通常最好将日期时间与时区绑定,这样可以减少在不同日期或不同时间点被误解的风险。然而,并非所有系统和数据库都能够保留这些信息,这可能迫使你在进行互操作时去除时区信息。如果遇到这种需求,你可以通过将None作为参数传递给pd.Series.dt.tz_localize来实现:

`la_ser.dt.tz_localize(None)` 
`0   2023-12-31 21:00:00 1   2024-01-01 21:00:01 2   2024-01-02 21:00:02 dtype: datetime64[ns]` 

如果你被迫从日期时间中去除时区信息,我强烈建议将时区作为字符串存储在pd.DataFrame和数据库的另一个列中:

`df = la_ser.to_frame().assign(    datetime=la_ser.dt.tz_localize(None),    timezone=str(la_ser.dt.tz), ).drop(columns=[0]) df` 
 `datetime              timezone 0   2023-12-31 21:00:00   America/Los_Angeles 1   2024-01-01 21:00:01   America/Los_Angeles 2   2024-01-02 21:00:02   America/Los_Angeles` 

在这种数据往返操作时,你可以通过将timezone列中的值应用到datetime列的数据来重建原始的pd.Series。为了增加安全性,下面的代码示例结合了pd.Series.drop_duplicatespd.Series.squeeze,从timezone列中提取出America/Los_Angeles的单一值,然后传递给pd.Series.dt.tz_localize

`tz = df["timezone"].drop_duplicates().squeeze() df["datetime"].dt.tz_localize(tz)` 
`0   2023-12-31 21:00:00-08:00 1   2024-01-01 21:00:01-08:00 2   2024-01-02 21:00:02-08:00 Name: datetime, dtype: datetime64[ns, America/Los_Angeles]` 

日期偏移

时间类型 – Timedelta的章节中(见第三章数据类型),我们介绍了pd.Timedelta类型,并提到它如何用于将日期时间按有限的时间跨度进行偏移,例如 10 秒或 5 天。然而,pd.Timedelta不能用于偏移日期或日期时间,比如说一个月,因为一个月的长度并不总是相同。在公历中,月份的天数通常在 28 到 31 天之间。2 月通常有 28 天,但对于每个能被 4 整除的年份,它会扩展到 29 天,除非该年能被 100 整除但不能被 400 整除。

如果总是思考这些问题会显得非常繁琐。幸运的是,pandas 处理了所有这些繁琐的细节,只需使用pd.DateOffset对象,你就可以根据日历来移动日期,我们将在本节中进一步探讨。

如何操作

为了构建对这个功能的基础理解,让我们从一个非常简单的pd.Series开始,包含 2024 年初几天的日期:

`ser = pd.Series([     "2024-01-01",     "2024-01-02",     "2024-01-03", ], dtype="datetime64[ns]") ser` 
`0   2024-01-01 1   2024-01-02 2   2024-01-03 dtype: datetime64[ns]` 

将这些日期偏移一个月通常意味着保持同样的日期,只是把日期从 1 月移到 2 月。使用pd.DateOffset,你可以传入一个months=的参数,来指定你希望偏移的月份数;例如,我们可以看一下传入1作为参数的效果:

`ser + pd.DateOffset(months=1)` 
`0   2024-02-01 1   2024-02-02 2   2024-02-03 dtype: datetime64[ns]` 

将日期偏移两个月意味着将这些日期从 1 月移到 3 月。我们不需要关心 1 月有 31 天,而 2 月 2024 年有 29 天;pd.DateOffset会为我们处理这些差异:

`ser + pd.DateOffset(months=2)` 
`0   2024-03-01 1   2024-03-02 2   2024-03-03 dtype: datetime64[ns]` 

对于不存在的日期(例如,试图将 1 月 30 日移到 2 月 30 日),pd.DateOffset会尝试匹配目标月份中最近的有效日期:

`pd.Series([     "2024-01-29",     "2024-01-30",     "2024-01-31", ], dtype="datetime64[ns]") + pd.DateOffset(months=1)` 
`0   2024-02-29 1   2024-02-29 2   2024-02-29 dtype: datetime64[ns]` 

你还可以通过向months=传递负数的参数,倒退日期到前一个月:

`ser + pd.DateOffset(months=-1)` 
`0   2023-12-01 1   2023-12-02 2   2023-12-03 dtype: datetime64[ns]` 

pd.DateOffset 足够灵活,可以同时接受多个关键字参数。例如,如果你想将日期偏移一个月、两天、三小时、四分钟和五秒钟,你可以在一个表达式中完成:

`ser + pd.DateOffset(months=1, days=2, hours=3, minutes=4, seconds=5)` 
`0   2024-02-03 03:04:05 1   2024-02-04 03:04:05 2   2024-02-05 03:04:05 dtype: datetime64[ns]` 

除了 pd.DateOffset 类,pandas 还提供了通过 pd.offsets 模块中的不同类,将日期移动到某一时期的开始或结束的功能。例如,如果你想将日期移动到月末,可以使用 pd.offsets.MonthEnd

`ser + pd.offsets.MonthEnd()` 
`0   2024-01-31 1   2024-01-31 2   2024-01-31 dtype: datetime64[ns]` 

pd.offsets.MonthBegin 将日期移动到下个月的开始:

`ser + pd.offsets.MonthBegin()` 
`0   2024-02-01 1   2024-02-01 2   2024-02-01 dtype: datetime64[ns]` 

pd.offsets.SemiMonthBeginpd.offsets.SemiMonthEndpd.offsets.QuarterBeginpd.offsets.QuarterEndpd.offsets.YearBeginpd.offsets.YearEnd 都提供类似的功能,可以将日期移动到不同时间段的开始或结束。

还有更多……

默认情况下,pd.DateOffset 是基于公历工作的,但它的不同子类可以提供更多自定义功能。

最常用的子类之一是 pd.offsets.BusinessDay,默认情况下,它仅将周一到周五的标准“工作日”计入日期偏移。为了看看它是如何工作的,让我们考虑 ser 中每个日期对应的星期几:

`ser.dt.day_name()` 
`0       Monday 1      Tuesday 2    Wednesday dtype: object` 

现在,让我们看看在给日期添加了三个工作日后会发生什么:

`bd_ser = ser + pd.offsets.BusinessDay(n=3) bd_ser` 
`0   2024-01-04 1   2024-01-05 2   2024-01-08 dtype: datetime64[ns]` 

我们可以使用相同的 pd.Series.dt.day_name 方法来检查这些日期新的星期几:

`bd_ser.dt.day_name()` 
`0    Thursday 1      Friday 2      Monday dtype: object` 

在添加了三个工作日之后,我们从周一和周二开始的日期,分别落在了同一周的周四和周五。我们从周三开始的日期被推到了下周一,因为周六和周日都不算作工作日。

如果你的业务在周一到周五的工作日与常规工作日不同,你可以使用 pd.offsets.CustomBusinessDay 来设定你自己的偏移规则。weekmask= 参数将决定哪些星期几被视为工作日:

`ser + pd.offsets.CustomBusinessDay(     n=3,     weekmask="Mon Tue Wed Thu", )` 
`0   2024-01-04 1   2024-01-08 2   2024-01-09 dtype: datetime64[ns]` 

你甚至可以添加 holidays= 参数来考虑你的业务可能关闭的日子:

`ser + pd.offsets.CustomBusinessDay(     n=3,     weekmask="Mon Tue Wed Thu",     holidays=["2024-01-04"], )` 
`0   2024-01-08 1   2024-01-09 2   2024-01-10 dtype: datetime64[ns]` 

对于公历,我们已经看过 pd.offsets.MonthEndpd.offsets.MonthBegin 类,分别帮助你将日期移动到一个月的开始或结束。类似的类也可以用于在尝试将日期移动到工作月的开始或结束时:

`ser + pd.offsets.BusinessMonthEnd()` 
`0   2024-01-31 1   2024-01-31 2   2024-01-31 dtype: datetime64[ns]` 

日期时间选择

第二章选择和赋值中,我们讨论了 pandas 提供的多种强大方法,帮助你通过与相关行 pd.Index 的交互,从 pd.Seriespd.DataFrame 中选择数据。如果你创建了一个包含日期时间数据的 pd.Index,它将作为一种名为 pd.DatetimeIndex 的特殊子类进行表示。这个子类重写了 pd.Index.loc 方法的一些功能,给你提供了更灵活的选择选项,专门针对时间数据。

如何操作

pd.date_range 是一个方便的函数,帮助你快速生成 pd.DatetimeIndex。使用此函数的一种方式是通过 start= 参数指定起始日期,使用 freq= 参数指定步长频率,并通过 periods= 参数指定所需的 pd.DatetimeIndex 长度。

例如,要生成一个从 2023 年 12 月 27 日开始、总共提供 5 天且每条记录之间间隔 10 天的 pd.DatetimeIndex,你可以写:

`pd.date_range(start="2023-12-27", freq="10D", periods=5)` 
`DatetimeIndex(['2023-12-27', '2024-01-06', '2024-01-16', '2024-01-26',               '2024-02-05'],              dtype='datetime64[ns]', freq='10D')` 

"2W" 的频率字符串将生成间隔为两周的日期。如果 start= 参数是一个星期天,日期将从该日期开始;否则,下一个星期天将作为序列的起点:

`pd.date_range(start="2023-12-27", freq="2W", periods=5)` 
`DatetimeIndex(['2023-12-31', '2024-01-14', '2024-01-28', '2024-02-11',               '2024-02-25'],              dtype='datetime64[ns]', freq='2W-SUN')` 

你甚至可以通过添加像 "-WED" 这样的后缀来控制用于锚定日期的星期几,这将生成每周三的日期,而不是每周日:

`pd.date_range(start="2023-12-27", freq="2W-WED", periods=5)` 
`DatetimeIndex(['2023-12-27', '2024-01-10', '2024-01-24', '2024-02-07',               '2024-02-21'],              dtype='datetime64[ns]', freq='2W-WED')` 

"WOM-3THU"freq= 参数将为你生成每个月的第三个星期四:

`pd.date_range(start="2023-12-27", freq="WOM-3THU", periods=5)` 
`DatetimeIndex(['2024-01-18', '2024-02-15', '2024-03-21', '2024-04-18',               '2024-05-16'],              dtype='datetime64[ns]', freq='WOM-3THU')` 

每月的第一天和第十五天可以通过 "SMS" 参数生成:

`pd.date_range(start="2023-12-27", freq="SMS", periods=5)` 
`DatetimeIndex(['2024-01-01', '2024-01-15', '2024-02-01', '2024-02-15',               '2024-03-01'],              dtype='datetime64[ns]', freq='SMS-15')` 

如你所见,有无数的频率字符串可以用来描述 pandas 所称的 日期偏移。欲获取更完整的列表,务必参考 pandas 文档:pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#dateoffset-objects

pd.DatetimeIndex 的每个元素实际上是一个 pd.Timestamp。当从 pd.Seriespd.DataFrame 中进行选择时,用户可能最初会倾向于写出如下的代码,以选择像 2024-01-18 这样的日期及其之前的所有记录:

`index = pd.date_range(start="2023-12-27", freq="10D", periods=20) ser = pd.Series(range(20), index=index) ser.loc[:pd.Timestamp("2024-01-18")]` 
`2023-12-27    0 2024-01-06    1 2024-01-16    2 Freq: 10D, dtype: int64` 

类似地,用户可能会倾向于写出如下代码来选择一个日期范围:

`ser.loc[pd.Timestamp("2024-01-06"):pd.Timestamp("2024-01-18")]` 
`2024-01-06    1 2024-01-16    2 Freq: 10D, dtype: int64` 

然而,从 pd.DatetimeIndex 中进行选择的方法较为冗长。为了方便,pandas 允许你传入字符串来表示所需的日期,而不是使用 pd.Timestamp 实例:

`ser.loc["2024-01-06":"2024-01-18"]` 
`2024-01-06    1 2024-01-16    2 Freq: 10D, dtype: int64` 

你也不需要指定完整的日期(YYYY-MM-DD 格式)。例如,如果你想选择所有发生在 2024 年 2 月的日期,你只需将字符串 2024-02 传递给 pd.Series.loc 调用:

`ser.loc["2024-02"]` 
`2024-02-05    4 2024-02-15    5 2024-02-25    6 Freq: 10D, dtype: int64` 

切片操作足够智能,能够识别这种模式,便于选择二月和三月中的所有记录:

`ser.loc["2024-02":"2024-03"]` 
`2024-02-05    4 2024-02-15    5 2024-02-25    6 2024-03-06    7 2024-03-16    8 2024-03-26    9 Freq: 10D, dtype: int64` 

你可以将这种抽象化再进一步,选择整个年份:

`ser.loc["2024"].head()` 
`2024-01-06    1 2024-01-16    2 2024-01-26    3 2024-02-05    4 2024-02-15    5 Freq: 10D, dtype: int64` 

还有更多……

你还可以通过提供 tz= 参数,将 pd.DatetimeIndex 与时区相关联:

`index = pd.date_range(start="2023-12-27", freq="12h", periods=6, tz="US/Eastern") ser = pd.Series(range(6), index=index) ser` 
`2023-12-27 00:00:00-05:00    0 2023-12-27 12:00:00-05:00    1 2023-12-28 00:00:00-05:00    2 2023-12-28 12:00:00-05:00    3 2023-12-29 00:00:00-05:00    4 2023-12-29 12:00:00-05:00    5 Freq: 12h, dtype: int64` 

当使用字符串从带有时区感知的 pd.DatetimeIndex 中进行选择时,需注意 pandas 会隐式地将你的字符串参数转换为 pd.DatetimeIndex 的时区。例如,下面的代码将只从我们的数据中选择一个元素:

`ser.loc[:"2023-12-27 11:59:59"]` 
`2023-12-27 00:00:00-05:00    0 Freq: 12h, dtype: int64` 

而下面的代码将正确地选择两个元素:

`ser.loc[:"2023-12-27 12:00:00"]` 
`2023-12-27 00:00:00-05:00    0 2023-12-27 12:00:00-05:00    1 Freq: 12h, dtype: int64` 

尽管我们的日期与 UTC 相差五小时,且字符串中没有指明期望的时区,这两种方法仍然有效。这样,pandas 使得从pd.DatetimeIndex中进行选择变得非常简单,无论它是时区感知的还是时区非感知的。

重采样

第八章分组操作中,我们深入探讨了 pandas 提供的分组功能。通过分组,你可以根据数据集中唯一值的组合来拆分数据,应用算法到这些拆分上,并将结果重新合并。

重采样与分组操作非常相似,唯一的区别发生在拆分阶段。与根据唯一值组合生成分组不同,重采样允许你将日期时间数据按增量进行分组,例如每 5 秒每 10 分钟

如何实现

让我们再次使用在日期时间选择示例中介绍过的pd.date_range函数,不过这次我们将生成一个以秒为频率的pd.DatetimeIndex,而不是以天为频率:

`index = pd.date_range(start="2024-01-01", periods=10, freq="s") ser = pd.Series(range(10), index=index, dtype=pd.Int64Dtype()) ser` 
`2024-01-01 00:00:00    0 2024-01-01 00:00:01    1 2024-01-01 00:00:02    2 2024-01-01 00:00:03    3 2024-01-01 00:00:04    4 2024-01-01 00:00:05    5 2024-01-01 00:00:06    6 2024-01-01 00:00:07    7 2024-01-01 00:00:08    8 2024-01-01 00:00:09    9 Freq: s, dtype: Int64` 

如果每秒查看数据被认为过于细致,可以使用pd.Series.resample对数据进行降采样,以获得不同的增量,例如每 3 秒。重采样还需要使用聚合函数来指示在每个增量内所有记录的处理方式;为了简便起见,我们可以从求和开始:

`ser.resample("3s").sum()` 
`2024-01-01 00:00:00     3 2024-01-01 00:00:03    12 2024-01-01 00:00:06    21 2024-01-01 00:00:09     9 Freq: 3s, dtype: Int64` 

在这个特定的例子中,resample会使用[00:00:00-00:00:03)[00:00:03-00:00:06)[00:00:06-00:00:09)[00:00:09-00:00:12)这些区间来创建桶。对于每一个区间,左方括号表示该区间在左侧是闭合的(即包括这些值)。相反,右侧的圆括号表示该区间是开放的,不包括该值。

从技术角度来说,所有这些通过"3s"频率重采样创建的区间默认是“左闭合”的,但可以通过closed=参数改变这种行为,从而生成如(23:59:57-00:00:00](00:00:00-00:00:03](00:00:03-00:00:06](00:00:06-00:00:09]这样的区间:

`ser.resample("3s", closed="right").sum()` 
`2023-12-31 23:59:57     0 2024-01-01 00:00:00     6 2024-01-01 00:00:03    15 2024-01-01 00:00:06    24 Freq: 3s, dtype: Int64` 

对于"3s"频率,区间的左值会作为结果行索引中的值。这个行为也可以通过使用label=参数来改变:

`ser.resample("3s", closed="right", label="right").sum()` 
`2024-01-01 00:00:00     0 2024-01-01 00:00:03     6 2024-01-01 00:00:06    15 2024-01-01 00:00:09    24 Freq: 3s, dtype: Int64` 

最后一个需要注意的陷阱是,closed=label=参数的默认值依赖于你选择的频率。我们选择的"3s"频率创建的是左闭合区间,并在行索引中使用左侧区间值。然而,如果我们选择了一个面向周期结束的频率,比如MEYE(月末和年末),pandas 将会生成右闭合区间,并使用右侧标签:

`ser.resample("ME").sum()` 
`2024-01-31    45 Freq: ME, dtype: Int64` 

既然我们在讨论下采样,让我们来看一种不同的频率,比如天数("D")。在这个级别,pd.Series.resample可以方便地将每日事件聚合到每周时间段中。为了看看这怎么运作,我们只需要查看 2024 年的前 10 天:

`index = pd.date_range(start="2024-01-01", freq="D", periods=10) ser = pd.Series(range(10), index=index, dtype=pd.Int64Dtype()) ser` 
`2024-01-01    0 2024-01-02    1 2024-01-03    2 2024-01-04    3 2024-01-05    4 2024-01-06    5 2024-01-07    6 2024-01-08    7 2024-01-09    8 2024-01-10    9 Freq: D, dtype: Int64` 

在不查找每个日期对应星期几的情况下,我们可以使用pd.DatetimeIndex.dt.day_name()来帮助我们定位:

`ser.index.day_name()` 
`Index(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',       'Sunday', 'Monday', 'Tuesday', 'Wednesday'],      dtype='object')` 

默认情况下,重采样为每周的时间段将会创建以周日为结束的周期:

`ser.resample("W").sum()` 
`2024-01-07    21 2024-01-14    24 Freq: W-SUN, dtype: Int64` 

然而,你可以自由选择一周中的任何一天作为周期的结束日。在美国,认为周六是周末的结束日,实际上比周日更常见:

`ser.resample("W-SAT").sum()` 
`2024-01-06    15 2024-01-13    30 Freq: W-SAT, dtype: Int64` 

当然,你也可以选择一周中的任何一天:

`ser.resample("W-WED").sum()` 
`2024-01-03     3 2024-01-10    42 Freq: W-WED, dtype: Int64` 

现在我们已经讲解了下采样(即从更精细的频率转为更粗略的频率)的主题,接下来我们来看看相反的方向——上采样过程。我们的数据展示了每天发生的事件,但如果我们想创建一个每 12 小时记录事件的时间序列,该怎么办呢?

幸运的是,实现这一目标的 API 并不会有太大区别。你仍然可以使用pd.Series.resample开始,但接下来需要链式调用pandas.core.resample.Resampler.asfreq

`ser.resample("12h").asfreq().iloc[:5]` 
`2024-01-01 00:00:00       0 2024-01-01 12:00:00    <NA> 2024-01-02 00:00:00       1 2024-01-02 12:00:00    <NA> 2024-01-03 00:00:00       2 Freq: 12h, dtype: Int64` 

上采样过程中生成的时间间隔,如果没有对应的活动,会被分配为缺失值。如果不做处理,像这样的上采样可能没有太大价值。然而,pandas 提供了几种填充这些缺失数据的方法。

处理缺失数据的第一种方法可能是向前填充或向后填充值,这样缺失值就会分别被前一个或后一个记录替代。

向前填充会生成[0, 0, 1, 1, 2, 2, ...]的值:

`ser.resample("12h").asfreq().ffill().iloc[:6]` 
`2024-01-01 00:00:00    0 2024-01-01 12:00:00    0 2024-01-02 00:00:00    1 2024-01-02 12:00:00    1 2024-01-03 00:00:00    2 2024-01-03 12:00:00    2 Freq: 12h, dtype: Int64` 

而向后填充则会得到[0, 1, 1, 2, 2, 3, ...]

`ser.resample("12h").asfreq().bfill().iloc[:6]` 
`2024-01-01 00:00:00    0 2024-01-01 12:00:00    1 2024-01-02 00:00:00    1 2024-01-02 12:00:00    2 2024-01-03 00:00:00    2 2024-01-03 12:00:00    3 Freq: 12h, dtype: Int64` 

一个更强健的解决方案是使用插值,其中可以利用缺失值前后的值来通过数学方式推测缺失的值。默认的插值方法是线性插值,本质上是取缺失值前后的平均值:

`ser.resample("12h").asfreq().interpolate().iloc[:6]` 
`2024-01-01 00:00:00    0.0 2024-01-01 12:00:00    0.5 2024-01-02 00:00:00    1.0 2024-01-02 12:00:00    1.5 2024-01-03 00:00:00    2.0 2024-01-03 12:00:00    2.5 Freq: 12h, dtype: Float64` 

还有更多内容……

在本节的介绍中,我们提到过,重采样类似于分组(Group By)。实际上,你可以通过pd.DataFrame.groupbypd.Grouper参数重写一个重采样操作。

让我们再次看一个包含 10 条记录的pd.Series,这些记录每秒发生一次:

`index = pd.date_range(start="2024-01-01", periods=10, freq="s") ser = pd.Series(range(10), index=index, dtype=pd.Int64Dtype()) ser` 
`2024-01-01 00:00:00    0 2024-01-01 00:00:01    1 2024-01-01 00:00:02    2 2024-01-01 00:00:03    3 2024-01-01 00:00:04    4 2024-01-01 00:00:05    5 2024-01-01 00:00:06    6 2024-01-01 00:00:07    7 2024-01-01 00:00:08    8 2024-01-01 00:00:09    9 Freq: s, dtype: Int64` 

三秒增量的重采样结果如下所示:

`ser.resample("3s").sum()` 
`2024-01-01 00:00:00     3 2024-01-01 00:00:03    12 2024-01-01 00:00:06    21 2024-01-01 00:00:09     9 Freq: 3s, dtype: Int64` 

通过将 "3s" 传递给pd.Grouperfreq=参数,重写后的代码可以得到相同的结果:

`ser.groupby(pd.Grouper(freq="3s")).sum()` 
`2024-01-01 00:00:00     3 2024-01-01 00:00:03    12 2024-01-01 00:00:06    21 2024-01-01 00:00:09     9 Freq: 3s, dtype: Int64` 

并不要求你必须使用pd.DataFrame.resample,实际上,当你还需要按非日期时间值进行分组时,你会发现pd.Grouper方法效果更好。我们将在本章稍后的按类别计算犯罪的年同比变化示例中看到这一点。

聚合每周的犯罪和交通事故数据

到目前为止,我们已经对 pandas 在处理时间数据方面的功能进行了基本的了解。从小型样本数据集开始,使我们能够轻松地检查操作的输出,但现在我们已经进入了可以开始关注如何应用于“真实世界”数据集的阶段。

丹佛市的犯罪数据集庞大,共有超过 46 万行,每行都标注了犯罪报告的日期时间。正如你将看到的,在这个示例中,我们可以使用 pandas 轻松地重新采样这些事件,并提出类似在某一周内报告了多少起犯罪的问题。

如何实现

首先,让我们读取犯罪数据集,并将索引设置为REPORTED_DATE。该数据集是使用 pandas 扩展类型保存的,因此无需指定dtype_backend=参数:

`df = pd.read_parquet(     "data/crime.parquet", ).set_index("REPORTED_DATE") df.head()` 
`REPORTED_DATE           OFFENSE_TYPE_ID               OFFENSE_CATEGORY_ID      2014-06-29 02:01:00     traffic-accident-dui-duid     traffic-accident         2014-06-29 01:54:00     vehicular-eluding-no-chase    all-other-crimes         2014-06-29 02:00:00     disturbing-the-peace          public-disorder          2014-06-29 02:18:00     curfew                        public-disorder          2014-06-29 04:17:00     aggravated-assault            aggravated-assault       GEO_LON           NEIGHBORHOOD_ID             IS_CRIME         IS_TRAFFIC   -105.000149       cbd                         0                1 -105.020719       ath-mar-park                1                0 -105.001552       sunny-side                  1                0 -105.018557       college-view-south-platte   1                0 5 rows × 7 columns` 

为了计算每周的犯罪数量,我们需要按周形成一个分组,我们知道可以通过pd.DataFrame.resample来实现。链式调用.size方法将计算每周的犯罪数:

`df.resample("W").size()` 
`REPORTED_DATE 2012-01-08     877 2012-01-15    1071 2012-01-22     991 2012-01-29     988 2012-02-05     888              ... 2017-09-03    1956 2017-09-10    1733 2017-09-17    1976 2017-09-24    1839 2017-10-01    1059 Freq: W-SUN, Length: 300, dtype: int64` 

现在我们得到了每周犯罪数量的pd.Series,新索引每次递增一周。有几个默认发生的事情非常重要,需要理解。星期日被选为每周的最后一天,并且也是在生成的pd.Series中用于标记每个元素的日期。例如,第一个索引值 2012 年 1 月 8 日是一个星期日。那一周(截至 1 月 8 日)共发生了 877 起犯罪。而 1 月 9 日星期一至 1 月 15 日星期日的一周,记录了 1,071 起犯罪。让我们进行一些合理性检查,确保我们的重新采样做到了这一点:

`len(df.sort_index().loc[:'2012-01-08'])` 
`877` 
`len(df.sort_index().loc['2012-01-09':'2012-01-15'])` 
`1071` 

为了全面了解趋势,从重新采样的数据中创建一个图表会很有帮助:

`import matplotlib.pyplot as plt plt.ion() df.resample("W").size().plot(title="All Denver Crimes")` 

丹佛市的犯罪数据集将所有犯罪和交通事故放在一个表中,并通过二进制列IS_CRIMEIS_TRAFFIC进行区分。通过pd.DataFrame.resample,我们可以仅选择这两列,并对其进行特定时期的汇总。对于季度汇总,你可以写成:

`df.resample("QS")[["IS_CRIME", "IS_TRAFFIC"]].sum().head()` 
 `IS_CRIME  IS_TRAFFIC REPORTED_DATE 2012-01-01    7882      4726 2012-04-01    9641      5255 2012-07-01    10566     5003 2012-10-01    9197      4802 2013-01-01    8730      4442` 

再次来说,使用折线图来了解趋势可能会更加有帮助:

`df.resample("QS")[["IS_CRIME", "IS_TRAFFIC"]].sum().plot(   color=["black", "lightgrey"],   title="Denver Crime and Traffic Accidents" )` 

按类别计算犯罪的年变化

用户经常想知道这种变化年复一年有多少?或者…季度与季度之间的变化是多少?。尽管这些问题经常被提及,但编写算法来回答这些问题可能相当复杂且耗时。幸运的是,pandas 为你提供了许多现成的功能,简化了很多工作。

为了让事情更复杂一些,在这个示例中,我们将提出按类别变化有多少的问题?将按类别纳入计算将使我们无法直接使用pd.DataFrame.resample,但正如你将看到的,pandas 仍然可以非常轻松地帮助你回答这类详细的问题。

如何实现

让我们加载犯罪数据集,但这次我们不会将REPORTED_DATE作为索引:

`df = pd.read_parquet(     "data/crime.parquet", ) df.head()` 
 `OFFENSE_TYPE_ID  OFFENSE_CATEGORY_ID  REPORTED_DATE  …  NEIGHBORHOOD_ID  IS_CRIME   IS_TRAFFIC 0   traffic-accident-dui-duid   traffic-accident   2014-06-29 02:01:00   …   cbd   0   1 1   vehicular-eluding-no-chase   all-other-crimes   2014-06-29 01:54:00   …   east-colfax   1   0 2   disturbing-the-peace   public-disorder   2014-06-29 02:00:00   …   athmar-park   1   0 3   curfew   public-disorder   2014-06-29 02:18:00   …   sunny-side   1   0 4   aggravated-assault   aggravated-assault   2014-06-29 04:17:00   …   college-view-south-platte   1   0 5 rows × 8 columns` 

到现在为止,你应该已经对数据重塑感到足够熟悉,可以回答类似于某一年发生了多少起犯罪?这样的问题了。但如果我们想进一步分析,了解每个OFFENSE_CATEGORY_ID内的变化情况该怎么做呢?

由于pd.DataFrame.resample仅适用于pd.DatetimeIndex,因此无法帮助我们按OFFENSE_CATEGORY_IDREPORTED_DATE进行分组。不过,pd.DataFrame.groupbypd.Grouper参数的组合可以帮助我们实现这一点:

`df.groupby([     "OFFENSE_CATEGORY_ID",     pd.Grouper(key="REPORTED_DATE", freq="YS"), ], observed=True).agg(     total_crime=pd.NamedAgg(column="IS_CRIME", aggfunc="sum"), )` 
 `total_crime OFFENSE_CATEGORY_ID    REPORTED_DATE aggravated-assault     2012-01-01           1707                        2013-01-01           1631                        2014-01-01           1788                        2015-01-01           2007                        2016-01-01           2139 …                               …              … white-collar-crime     2013-01-01            771                        2014-01-01           1043                        2015-01-01           1319                        2016-01-01           1232                        2017-01-01           1058 90 rows × 1 columns` 

顺便提一下,observed=True参数可以抑制在 pandas 2.x 版本中使用分类数据类型进行分组时的警告;未来的读者可能不需要指定此参数,因为它将成为默认设置。

为了加入“同比”部分,我们可以尝试使用pd.Series.pct_change方法,它将每条记录表示为直接前一条记录的百分比:

`df.groupby([     "OFFENSE_CATEGORY_ID",     pd.Grouper(key="REPORTED_DATE", freq="YS"), ], observed=True).agg(     total_crime=pd.NamedAgg(column="IS_CRIME", aggfunc="sum"), ).assign(     yoy_change=lambda x: x["total_crime"].pct_change().astype(pd.Float64Dtype()) ).head(10)` 
 `total_crime     yoy_change OFFENSE_CATEGORY_ID      REPORTED_DATE aggravated-assault          2012-01-01           1707           <NA>                             2013-01-01           1631      -0.044523                             2014-01-01           1788        0.09626                             2015-01-01           2007       0.122483                             2016-01-01           2139        0.06577                             2017-01-01           1689      -0.210379 all-other-crimes            2012-01-01           1999       0.183541                             2013-01-01           9377       3.690845                             2014-01-01          15507       0.653727                             2015-01-01          15729       0.014316` 

不幸的是,这并没有给我们准确的结果。如果你仔细观察所有其他犯罪类别的第一个yoy_change值,它显示为 0.183541。然而,这个值是通过将 1999 除以 1689 得到的,1689 来自加重攻击犯罪类别。默认情况下,pd.Series.pct_change并没有做任何智能的操作——它只是将当前行与前一行相除。

幸运的是,有一种方法可以解决这个问题,再次使用分组操作。因为我们的OFFENSE_CATEGORY_ID是第一个索引级别,所以我们可以用第二个分组操作,设置level=0并在此基础上调用.pct_change方法。这样可以避免我们错误地将all-other-crimesaggravated-assault进行比较:

`yoy_crime = df.groupby([     "OFFENSE_CATEGORY_ID",     pd.Grouper(key="REPORTED_DATE", freq="YS"), ], observed=True).agg(     total_crime=pd.NamedAgg(column="IS_CRIME", aggfunc="sum"), ).assign(     yoy_change=lambda x: x.groupby(         level=0, observed=True     ).pct_change().astype(pd.Float64Dtype()) ) yoy_crime.head(10)` 
 `total_crime     yoy_change OFFENSE_CATEGORY_ID     REPORTED_DATE aggravated-assault         2012-01-01           1707           <NA>                            2013-01-01           1631      -0.044523                            2014-01-01           1788        0.09626                            2015-01-01           2007       0.122483                            2016-01-01           2139        0.06577                            2017-01-01           1689      -0.210379 all-other-crimes           2012-01-01           1999           <NA>                            2013-01-01           9377       3.690845                            2014-01-01          15507       0.653727                            2015-01-01          15729       0.014316` 

为了获得更直观的展示,我们可能希望将所有不同分组的总犯罪数和年同比变化并排绘制,基于我们在第六章数据可视化中学到的知识进行构建。

为了简洁并节省视觉空间,我们只会绘制几种犯罪类型:

`crimes = tuple(("aggravated-assault", "arson", "auto-theft")) fig, axes = plt.subplots(nrows=len(crimes), ncols=2, sharex=True) for idx, crime in enumerate(crimes):     crime_df = yoy_crime.loc[crime]     ax0 = axes[idx][0]     ax1 = axes[idx][1]     crime_df.plot(kind="bar", y="total_crime", ax=ax0, legend=False)     crime_df.plot(kind="bar", y="yoy_change", ax=ax1, legend=False)     xlabels = [x.year for x in crime_df.index]     ax0.set_xticklabels(xlabels)     ax0.set_title(f"{crime} total")     ax1.set_xticklabels(xlabels)     ax1.set_title(f"{crime} YoY")     ax0.set_xlabel("")     ax1.set_xlabel("") plt.tight_layout()` 

准确衡量带有缺失值的传感器收集事件

缺失数据可能对数据分析产生巨大影响,但有时并不容易判断缺失数据的发生时间和程度。在详细且大量的交易数据中,数据集是否完整可能并不明显。必须格外注意衡量和恰当地填补缺失的交易数据;否则,对这样的数据集进行任何聚合可能会展示出不完整甚至完全错误的情况。

对于这个案例,我们将使用芝加哥数据门户提供的智能绿色基础设施监测传感器 - 历史数据数据集。该数据集包含了测量芝加哥市不同环境因素的传感器集合,如水流量和温度。理论上,传感器应该持续运行并反馈数值,但实际上,它们易于发生间歇性故障,导致数据丢失。

如何操作

虽然芝加哥数据门户提供了覆盖 2017 年和 2018 年的 CSV 格式源数据,但本书中我们将使用一个精心整理的 Parquet 文件,它仅覆盖了 2017 年 6 月到 2017 年 10 月的几个月数据。仅此数据就有近 500 万行记录,我们可以通过简单的pd.read_parquet调用加载它:

`df = pd.read_parquet(     "data/sgi_monitoring.parquet",     dtype_backend="numpy_nullable", ) df.head()` 
 `Measurement Title   Measurement Description   Measurement Type   …   Latitude   Longitude   Location 0   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 1   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 2   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 3   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 4   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 5 rows × 16 columns` 

Measurement Time列应包含每个事件发生时的日期时间数据,但经过仔细检查后,你会发现 pandas 并未将其识别为日期时间类型:

`df["Measurement Time"].head()` 
`0    07/26/2017 07:00:00 AM 1    06/23/2017 07:00:00 AM 2    06/04/2017 07:00:00 AM 3    09/19/2017 07:00:00 AM 4    06/07/2017 07:00:00 AM Name: Measurement Time, dtype: string` 

因此,探索数据的第一步将是使用pd.to_datetime将其转换为实际的日期时间类型。虽然从数据本身不容易看出,但芝加哥数据门户文档指出,这些值是芝加哥时区的本地时间,我们可以使用pd.Series.dt.tz_localize来进行时区设置:

`df["Measurement Time"] = pd.to_datetime(     df["Measurement Time"] ).dt.tz_localize("US/Central") df["Measurement Time"]` 
`0         2017-07-26 07:00:00-05:00 1         2017-06-23 07:00:00-05:00 2         2017-06-04 07:00:00-05:00 3         2017-09-19 07:00:00-05:00 4         2017-06-07 07:00:00-05:00                     ...            4889976   2017-08-26 20:11:55-05:00 4889977   2017-08-26 20:10:54-05:00 4889978   2017-08-26 20:09:53-05:00 4889979   2017-08-26 20:08:52-05:00 4889980   2017-08-26 20:07:50-05:00 Name: Measurement Time, Length: 4889981, dtype: datetime64[ns, US/Central]` 

如前所述,该数据集收集了来自传感器的反馈,这些传感器测量了不同的环境因素,如水流量和温度。检查Measurement TypeUnits列应能让我们更好地理解我们正在查看的数据:

`df[["Measurement Type", "Units"]].value_counts()` 
`Measurement Type         Units                            Temperature              degrees Celsius                     721697 DifferentialPressure     pascals                             721671 WindSpeed                meters per second                   721665 Temperature              millivolts                          612313 SoilMoisture             millivolts                          612312 RelativeHumidity         percent                             389424 CumulativePrecipitation  count                               389415 WindDirection            degrees from north                  389413 SoilMoisture             Percent Volumetric Water Content    208391 CumulativePrecipitation  inches                              122762 TimeWindowBoundary       universal coordinated time             918 Name: count, dtype: int64` 

由于不同的传感器对于不同类型的数据会产生不同的测量结果,我们必须小心,避免一次比较多个传感器。为了这次分析,我们将只专注于TM1 Temp Sensor,它仅使用毫伏作为单位来测量温度。此外,我们将只关注一个Data Stream ID,在芝加哥数据门户中文档中描述为:

一个用于标识测量类型和位置的标识符。所有具有相同值的记录应该是可以比较的。

`df[df["Measurement Description"] == "TM1 Temp Sensor"]["Data Stream ID"].value_counts()` 
`Data Stream ID 33305    211584 39197    207193 39176    193536 Name: count, dtype: Int64` 

对于此次分析,我们将只查看Data Stream ID 39176。过滤后,我们还将设置Measurement Time为行索引并进行排序:

`mask = (     (df["Measurement Description"] == "TM1 Temp Sensor")     & (df["Data Stream ID"] == 39176) ) df = df[mask].set_index("Measurement Time").sort_index() df[["Measurement Type", "Units"]].value_counts()` 
`Measurement Type  Units      Temperature       millivolts    193536 Name: count, dtype: int64` 

Measurement Value列包含传感器的实际毫伏数值。我们可以通过将数据重采样到每日级别,并对该列进行均值聚合,来尝试更高层次地理解我们的数据:

`df.resample("D")["Measurement Value"].mean().plot()` 

几乎立刻,我们就能看到数据中的一些问题。最显著的是,7 月底和 10 月中旬有两个间隙,几乎可以确定这些记录是由于传感器停机而未收集的。

让我们尝试缩小日期范围,以便更清楚地看到数据集中缺失的日期:

`df.loc["2017-07-24":"2017-08-01"].resample("D")["Measurement Value"].mean()` 
`Measurement Time 2017-07-24 00:00:00-05:00    3295.908956 2017-07-25 00:00:00-05:00    3296.152968 2017-07-26 00:00:00-05:00    3296.460156 2017-07-27 00:00:00-05:00    3296.697269 2017-07-28 00:00:00-05:00    3296.328725 2017-07-29 00:00:00-05:00    3295.882705 2017-07-30 00:00:00-05:00    3295.800989 2017-07-31 00:00:00-05:00           <NA> 2017-08-01 00:00:00-05:00    3296.126888 Freq: D, Name: Measurement Value, dtype: Float64` 

如你所见,我们在 2017 年 7 月 31 日完全没有收集到任何数据。为了解决这个问题,我们可以简单地调用pd.Series.interpolate,它将使用前后值的平均数填补缺失的日期:

`df.resample("D")["Measurement Value"].mean().interpolate().plot()` 

完成了!现在,我们的数据收集没有任何间隙,呈现出一个视觉上令人愉悦、完整的图表。

还有更多…

你如何处理缺失数据也取决于你使用的聚合函数。在这个例子中,平均数是一个相对宽容的函数;缺失的交易可以通过它们不显著改变生成的平均数来掩盖。

然而,如果我们要测量每天的读数总和,仍然需要做一些额外的工作。首先,让我们看看这些读数的每日重采样总和是怎样的:

`df.resample("D")["Measurement Value"].sum().plot()` 

情况比想要计算平均值时更加严峻。我们仍然看到 7 月和 10 月末有巨大的跌幅,这显然是数据缺失造成的。然而,当我们深入研究之前看到的 7 月末数据时,求和将揭示一些关于数据的更有趣的内容:

`df.loc["2017-07-30 15:45:00":"2017-08-01"].head()` 
 `Measurement Title   Measurement Description   Measurement Type    …   Latitude   Longitude   Location   Measurement Time 2017-07-30 15:48:44-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 2017-07-30 15:49:45-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 2017-07-30 15:50:46-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 2017-08-01 15:21:33-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 2017-08-01 15:22:34-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 5 rows × 15 columns` 

不仅仅是 7 月 31 日那天我们发生了停机。我们之前做的平均聚合掩盖了一个事实,即传感器在 7 月 30 日 15:50:46 之后出现故障,并且直到 8 月 1 日 15:21:33 才恢复在线——几乎停机了整整两天。

另一个有趣的事情是尝试测量我们的数据应该以什么频率被填充。初步查看我们的数据时,似乎每分钟都应该提供一个数据点,但如果你尝试测量每小时收集到多少事件,你会看到另一个结果:

`df.resample("h").size().plot()` 

很多小时间隔看起来收集了接近 60 个事件,尽管令人惊讶的是,只有 1 小时实际上收集了满满的 60 个事件。

`df.resample("h").size().loc[lambda x: x >= 60]` 
`Measurement Time 2017-07-05 15:00:00-05:00    60 Freq: h, dtype: int64` 

为了解决这个问题,我们再次尝试按分钟重采样数据,并在结果缺失的地方进行插值:

`df.resample("min")["Measurement Value"].sum().interpolate()` 
`Measurement Time 2017-06-01 00:00:00-05:00    3295.0 2017-06-01 00:01:00-05:00    3295.0 2017-06-01 00:02:00-05:00    3295.0 2017-06-01 00:03:00-05:00    3295.0 2017-06-01 00:04:00-05:00    3295.0                              ...   2017-10-30 23:55:00-05:00    3293.0 2017-10-30 23:56:00-05:00    3293.0 2017-10-30 23:57:00-05:00       0.0 2017-10-30 23:58:00-05:00    3293.0 2017-10-30 23:59:00-05:00    3293.0 Freq: min, Name: Measurement Value, Length: 218880, dtype: Float64` 

用户需要注意的是,缺失值的总和存在一个小的注意事项。默认情况下,pandas 会将所有缺失值求和为0,而不是缺失值。在我们按分钟重采样的情况下,2017 年 10 月 30 日 23:57:00 的数据点没有值可供求和,因此 pandas 返回了0,而不是缺失值指示符。

我们需要一个缺失值指示符来使重采样工作顺利进行。幸运的是,我们仍然可以通过给sum方法提供min_count=参数并设置为1(或更大值)来实现这一点,实际上是设定了必须看到多少个非缺失值才能得到一个非缺失的结果:

`interpolated = df.resample("min")["Measurement Value"].sum(min_count=1).interpolate() interpolated` 
`Measurement Time 2017-06-01 00:00:00-05:00    3295.0 2017-06-01 00:01:00-05:00    3295.0 2017-06-01 00:02:00-05:00    3295.0 2017-06-01 00:03:00-05:00    3295.0 2017-06-01 00:04:00-05:00    3295.0                              ...   2017-10-30 23:55:00-05:00    3293.0 2017-10-30 23:56:00-05:00    3293.0 2017-10-30 23:57:00-05:00    3293.0 2017-10-30 23:58:00-05:00    3293.0 2017-10-30 23:59:00-05:00    3293.0 Freq: min, Name: Measurement Value, Length: 218880, dtype: Float64` 

如你所见,2017 年 10 月 30 日 23:57:00 的值现在显示为3293,这是通过取前后的值进行插值得到的。

完成这些之后,让我们确认我们每小时总是收集到 60 个事件:

`interpolated.resample("h").size().plot()` 

这个检查看起来很好,现在,我们可以尝试再次将数据降采样到日级别,看看整体求和趋势是什么样的:

`interpolated.resample("D").sum().plot()` 

这张图表与我们最初的图表大不相同。我们不仅去除了极端异常值对图表 y 轴的影响,还可以看到数值下限的普遍上升。在我们最初的图表中,总计的毫伏值下限通常在每天 350 万到 400 万之间,但现在,我们的下限大约在 474 万左右。

实际上,通过关注和处理我们时间序列数据中的缺失值,我们能够从数据集中获得许多不同的见解。在相对少量的代码行中,pandas 帮助我们清晰简洁地将数据处理到比开始时更好的状态。

加入我们在 Discord 的社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

留下您的评价!

感谢您从 Packt Publishing 购买本书——我们希望您喜欢它!您的反馈对我们非常宝贵,能够帮助我们改进和成长。在阅读完本书后,请抽空在 Amazon 上留下评价;这只需一分钟,但对像您这样的读者来说意义重大。

扫描下面的二维码,获得你选择的免费电子书。

packt.link/NzOWQ

第十章:一般使用和性能优化建议

到目前为止,我们已经覆盖了 pandas 库的相当大一部分,同时通过示例应用来强化良好的使用习惯。掌握了这些知识后,你现在已经做好准备,踏入实际工作,并将所学的内容应用到数据分析问题中。

本章将提供一些你在独立工作时应牢记的小窍门和建议。本章介绍的食谱是我在多种经验水平的 pandas 用户中经常看到的常见错误。尽管这些做法出发点良好,但不当使用 pandas 构造会浪费很多性能。当数据集较小时,这可能不是大问题,但数据通常是增长的,而不是缩小。使用正确的惯用法并避免低效代码带来的维护负担,可以为你的组织节省大量时间和金钱。

本章将涵盖以下食谱:

  • 避免 dtype=object

  • 注意数据大小

  • 使用矢量化函数代替循环

  • 避免修改数据

  • 使用字典编码低基数数据

  • 测试驱动开发功能

避免使用 dtype=object

在 pandas 中使用 dtype=object 来存储字符串是最容易出错且效率低下的做法之一。不幸的是,在很长一段时间里,dtype=object 是处理字符串数据的唯一方法;直到 1.0 版本发布之前,这个问题才“得到解决”。

我故意将“解决”放在引号中,因为尽管 pandas 1.0 确实引入了 pd.StringDtype(),但直到 3.0 版本发布之前,许多构造和 I/O 方法默认并未使用它。实际上,除非你明确告诉 pandas 否则,在 2.x 版本中,你所有的字符串数据都会使用 dtype=object。值得一提的是,1.0 引入的 pd.StringDtype() 帮助确保你存储字符串,但直到 3.0 版本发布之前,它并未进行性能优化。

如果你使用的是 pandas 3.0 版本及更高版本,可能仍然会遇到一些旧代码,如 ser = ser.astype(object)。通常情况下,这类调用应该被替换为 ser = ser.astype(pd.StringDtype()),除非你确实需要在 pd.Series 中存储 Python 对象。不幸的是,我们无法真正了解原始意图,所以作为开发者,你应该意识到使用 dtype=object 的潜在问题,并学会判断是否可以用 pd.StringDtype() 适当地替代它。

如何实现

我们在第三章《数据类型》中已经讨论过使用 dtype=object 的一些问题,但在此重申并扩展这些问题是值得的。

为了做一个简单的比较,我们来创建两个 pd.Series 对象,它们的数据相同,一个使用 object 数据类型,另一个使用 pd.StringDtype

`ser_obj = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=object) ser_str = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=pd.StringDtype())` 

尝试为 ser_str 分配一个非字符串值将会失败:

`ser_str.iloc[0] = False` 
`TypeError: Cannot set non-string value 'False' into a StringArray.` 

相比之下,pd.Series 类型的对象会很乐意接受我们的 Boolean 值:

`ser_obj.iloc[0] = False` 

反过来,这只会让你更难发现数据中的问题。当我们尝试分配非字符串数据时,使用pd.StringDtype时,失败的地方是非常明显的。而在使用对象数据类型时,直到你在代码的后面尝试做一些字符串操作(比如大小写转换)时,才可能发现问题:

`ser_obj.str.capitalize().head()` 
`0    NaN 1    Bar 2    Baz 3    Foo 4    Bar dtype: object` 

pandas 并没有抛出错误,而是决定将我们在第一行的False条目设置为缺失值。这样的默认行为可能并不是你想要的,但使用对象数据类型时,你对数据质量的控制会大大减弱。

如果你使用的是 pandas 3.0 及更高版本,当安装了 PyArrow 时,你还会发现pd.StringDtype变得显著更快。让我们重新创建我们的pd.Series对象来测量这一点:

`ser_obj = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=object) ser_str = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=pd.StringDtype())` 

为了快速比较执行时间,让我们使用标准库中的timeit模块:

`import timeit timeit.timeit(ser_obj.str.upper, number=1000)` 
`2.2286621460007154` 

将该运行时间与使用正确的pd.StringDtype的情况进行比较:

`timeit.timeit(ser_str.str.upper, number=1000)` 
`2.7227514309997787` 

不幸的是,3.0 版本之前的用户无法看到任何性能差异,但单单是数据验证的改进就足以让你远离dtype=object

那么,避免使用dtype=object的最简单方法是什么?如果你有幸使用 pandas 3.0 及以上版本,你会发现这个数据类型不再那么常见,因为这是该库的自然发展。即便如此,对于仍然使用 pandas 2.x 系列的用户,我建议在 I/O 方法中使用dtype_backend="numpy_nullable"参数:

`import io data = io.StringIO("int_col,string_col\n0,foo\n1,bar\n2,baz") data.seek(0) pd.read_csv(data, dtype_backend="numpy_nullable").dtypes` 
`int_col                Int64 string_col    string[python] dtype: object` 

如果你手动构建一个pd.DataFrame,可以使用pd.DataFrame.convert_dtypes方法,并配合使用相同的dtype_backend="numpy_nullable"参数:

`df = pd.DataFrame([     [0, "foo"],     [1, "bar"],     [2, "baz"], ], columns=["int_col", "string_col"]) df.convert_dtypes(dtype_backend="numpy_nullable").dtypes` 
`int_col                Int64 string_col    string[python] dtype: object` 

请注意,numpy_nullable这个术语有些误导。这个参数本来应该被命名为pandas_nullable,甚至直接命名为pandasnullable也更合适,但当它首次引入时,它与 NumPy 系统密切相关。随着时间的推移,numpy_nullable这个术语保留下来了,但类型已经不再依赖于 NumPy。在本书出版后,可能会有更合适的值来实现相同的行为,基本上是为了找到支持缺失值的 pandas 最佳数据类型。

虽然dtype=object最常被误用来处理字符串,但它在处理日期时间时也暴露了一些问题。我常常看到新用户写出这样的代码,试图创建一个他们认为是日期的pd.Series

`import datetime ser = pd.Series([     datetime.date(2024, 1, 1),     datetime.date(2024, 1, 2),     datetime.date(2024, 1, 3), ]) ser` 
`0    2024-01-01 1    2024-01-02 2    2024-01-03 dtype: object` 

虽然这是一种逻辑上可行的方法,但问题在于 pandas 并没有一个真正的日期类型。相反,这些数据会使用 Python 标准库中的datetime.date类型存储在一个object数据类型的数组中。Python 对象的这种不幸用法掩盖了你正在处理日期的事实,因此,尝试使用pd.Series.dt访问器时会抛出错误:

`ser.dt.year` 
`AttributeError: Can only use .dt accessor with datetimelike values` 

第三章数据类型中,我们简要讨论了 PyArrow 的date32类型,它会是解决这个问题的一个更原生的方案:

`import datetime ser = pd.Series([     datetime.date(2024, 1, 1),     datetime.date(2024, 1, 2),     datetime.date(2024, 1, 3), ], dtype=pd.ArrowDtype(pa.date32())) ser` 
`0    2024-01-01 1    2024-01-02 2    2024-01-03 dtype: date32[day][pyarrow]` 

这样就解锁了pd.Series.dt属性,可以使用了:

`ser.dt.year` 
`0    2024 1    2024 2    2024 dtype: int64[pyarrow]` 

我觉得这个细节有些遗憾,希望未来的 pandas 版本能够抽象化这些问题,但无论如何,它们在当前版本中存在,并且可能会存在一段时间。

尽管我已经指出了dtype=object的一些缺点,但在处理凌乱数据时,它仍然有其用处。有时,你可能对数据一无所知,需要先检查它,才能做出进一步的决策。对象数据类型为你提供了加载几乎任何数据的灵活性,并且可以应用相同的 pandas 算法。虽然这些算法可能效率不高,但它们仍然为你提供了一种一致的方式来与数据交互和探索数据,最终为你争取了时间,帮助你找出如何最好地清理数据并将其存储为更合适的格式。因此,我认为dtype=object最好作为一个暂存区——我不建议将类型保存在其中,但它为你争取时间,以便对数据类型做出断言,可能是一个资产。

留意数据大小

随着数据集的增长,你可能会发现必须选择更合适的数据类型,以确保pd.DataFrame仍然能适应内存。

第三章数据类型中,我们讨论了不同的整数类型,以及它们在内存使用和容量之间的权衡。当处理像 CSV 和 Excel 文件这样的无类型数据源时,pandas 会倾向于使用过多的内存,而不是选择错误的容量。这种保守的做法可能导致系统内存的低效使用,因此,了解如何优化内存使用可能是加载文件和收到OutOfMemory错误之间的关键差异。

如何做

为了说明选择合适的数据类型的影响,我们从一个相对较大的pd.DataFrame开始,这个 DataFrame 由 Python 整数组成:

`df = pd.DataFrame({     "a": [0] * 100_000,     "b": [2 ** 8] * 100_000,     "c": [2 ** 16] * 100_000,     "d": [2 ** 32] * 100_000, }) df = df.convert_dtypes(dtype_backend="numpy_nullable") df.head()` 
 `a    b       c          d 0   0  256  65536  4294967296 1   0  256  65536  4294967296 2   0  256  65536  4294967296 3   0  256  65536  4294967296 4   0  256  65536  4294967296` 

对于整数类型,确定每个pd.Series需要多少内存是一个相对简单的过程。对于pd.Int64Dtype,每条记录是一个 64 位整数,需要 8 个字节的内存。每条记录旁边,pd.Series还会关联一个字节,值为 0 或 1,用来表示记录是否缺失。因此,每条记录总共需要 9 个字节,对于每个pd.Series中的 100,000 条记录,我们的内存使用量应该为 900,000 字节。pd.DataFrame.memory_usage确认这个计算是正确的:

`df.memory_usage()` 
`Index       128 a        900000 b        900000 c        900000 d        900000 dtype: int64` 

如果你知道应该使用什么类型,可以通过.astype显式地为pd.DataFrame的列选择更合适的大小:

`df.assign(     a=lambda x: x["a"].astype(pd.Int8Dtype()),     b=lambda x: x["b"].astype(pd.Int16Dtype()),     c=lambda x: x["c"].astype(pd.Int32Dtype()), ).memory_usage()` 
`Index       128 a        200000 b        300000 c        500000 d        900000 dtype: int64` 

作为一种便捷方式,pandas 可以通过调用 pd.to_numeric 来推断更合适的大小。传递 downcast="signed" 参数将确保我们继续使用带符号整数,并且我们将继续传递 dtype_backend="numpy_nullable" 来确保我们获得适当的缺失值支持:

`df.select_dtypes("number").assign(     **{x: pd.to_numeric(          y, downcast="signed", dtype_backend="numpy_nullable"     ) for x, y in df.items()} ).memory_usage()` 
`Index       128 a        200000 b        300000 c        500000 d        900000 dtype: int64` 

使用向量化函数代替循环

Python 作为一门语言,以其强大的循环能力而著称。无论你是在处理列表还是字典,循环遍历 Python 对象通常是一个相对容易完成的任务,并且能够编写出非常简洁、清晰的代码。

尽管 pandas 是一个 Python 库,但这些相同的循环结构反而成为编写符合 Python 编程习惯且高效代码的障碍。与循环相比,pandas 提供了向量化计算,即对 pd.Series 中的所有元素进行计算,而无需显式地循环。

如何实现

让我们从一个简单的 pd.Series 开始,这个 pd.Series 是由一个范围构造的:

`ser = pd.Series(range(100_000), dtype=pd.Int64Dtype())` 

我们可以使用内置的 pd.Series.sum 方法轻松计算求和:

`ser.sum()` 
`4999950000` 

遍历 pd.Series 并积累自己的结果会得到相同的数字:

`result = 0 for x in ser:     result += x result` 
`4999950000` 

然而,两个代码示例完全不同。使用 pd.Series.sum 时,pandas 在低级语言(如 C)中执行元素求和,避免了与 Python 运行时的任何交互。在 pandas 中,我们称之为向量化函数。

相反,for 循环由 Python 运行时处理,正如你可能知道的那样,Python 比 C 慢得多。

为了提供一些具体的数字,我们可以使用 Python 的 timeit 模块进行简单的时间基准测试。我们先从 pd.Series.sum 开始:

`timeit.timeit(ser.sum, number=1000)` 
`0.04479526499926578` 

我们可以将其与 Python 循环进行比较:

`def loop_sum():     result = 0     for x in ser:         result += x timeit.timeit(loop_sum, number=1000)` 
`5.392715779991704` 

使用循环会导致巨大的性能下降!

通常情况下,你应该使用 pandas 内置的向量化函数来满足大多数分析需求。对于更复杂的应用,使用 .agg.transform.map.apply 方法,这些方法已经在第五章,算法及其应用中讲解过。你应该能避免在 99.99%的分析中使用 for 循环;如果你发现自己更频繁地使用它们,可能需要重新考虑你的设计,通常可以通过仔细重读第五章,算法及其应用来解决。

这个规则的唯一例外情况是当处理 pd.GroupBy 对象时,使用 for 循环可能更合适,因为它可以像字典一样高效地进行迭代:

`df = pd.DataFrame({     "column": ["a", "a", "b", "a", "b"],     "value": [0, 1, 2, 4, 8], }) df = df.convert_dtypes(dtype_backend="numpy_nullable") for label, group in df.groupby("column"):     print(f"The group for label {label} is:\n{group}\n")` 
`The group for label a is:  column  value 0      a      0 1      a      1 3      a      4 The group for label b is:  column  value 2      b      2 4      b      8` 

避免修改数据

尽管 pandas 允许你修改数据,但修改的成本会根据数据类型有所不同。在某些情况下,这可能代价高昂,因此你应该尽可能地减少任何必须执行的修改操作。

如何实现

在考虑数据变更时,应该尽量在加载数据到 pandas 结构之前进行变更。我们可以通过比较加载到 pd.Series 后修改记录所需的时间,轻松地说明性能差异:

`def mutate_after():     data = ["foo", "bar", "baz"]     ser = pd.Series(data, dtype=pd.StringDtype())     ser.iloc[1] = "BAR" timeit.timeit(mutate_after, number=1000)` 
`0.041951814011554234` 

如果变异事先执行,所需的时间:

`def mutate_before():     data = ["foo", "bar", "baz"]     data[1] = "BAR"     ser = pd.Series(data, dtype=pd.StringDtype()) timeit.timeit(mutate_before, number=1000)` 
`0.019495725005981512` 

还有更多...

你可能会陷入一个技术性的深坑,试图解读在不同版本的 pandas 中变异不同数据类型的影响。然而,从 pandas 3.0 开始,行为变得更加一致,这是由于引入了按需写入(Copy-on-Write),这一点是 PDEP-07 提案的一部分。简单来说,每次你尝试变异pd.Seriespd.DataFrame时,都会得到原始数据的一个副本。

尽管这种行为现在更容易预测,但也意味着变异可能非常昂贵,特别是如果你尝试变异一个大的pd.Seriespd.DataFrame

字典编码低基数数据

第三章数据类型中,我们讨论了分类数据类型,它可以通过将字符串(或任何数据类型)替换为更小的整数代码来减少内存使用。虽然第三章数据类型提供了一个很好的技术深度讲解,但考虑到在处理低基数数据时,这可以带来显著的节省,因此在这里再强调一次作为最佳实践是值得的。低基数数据是指唯一值与总记录数的比率相对较低的数据。

如何做

为了进一步强调内存节省的观点,假设我们创建一个低基数pd.Series。我们的pd.Series将有 300,000 行数据,但只有三个唯一值"foo""bar""baz"

`values = ["foo", "bar", "baz"] ser = pd.Series(values * 100_000, dtype=pd.StringDtype()) ser.memory_usage()` 
`2400128` 

仅仅将其更改为分类数据类型,就能大幅提升内存性能:

`cat = pd.CategoricalDtype(values) ser = pd.Series(values * 100_000, dtype=cat) ser.memory_usage()` 
`300260` 

测试驱动开发特点

测试驱动开发(简称TDD)是一种流行的软件开发实践,旨在提高代码质量和可维护性。总体上,TDD 从开发者编写测试开始,测试描述了对变更的预期功能。测试从失败状态开始,开发者在测试最终通过时,可以确信他们的实现是正确的。

测试通常是代码评审者在考虑代码变更时首先查看的内容(在贡献 pandas 时,测试是必须的!)。在接受了一个有测试的变更后,后续的任何代码变更都会重新运行该测试,确保你的代码库随着时间的推移继续按预期工作。通常,正确构造的测试可以帮助你的代码库扩展,同时在开发新特性时减轻回归的风险。

pandas 库提供了工具,使得你可以通过pd.testing模块为你的pd.Seriespd.DataFrame对象编写测试,我们将在本教程中进行回顾。

它是如何工作的

Python 标准库提供了unittest模块,用于声明和自动执行测试。创建测试时,通常会创建一个继承自unittest.TestCase的类,并在该类中创建方法来对程序行为进行断言。

在下面的代码示例中,MyTests.test_42 方法将调用 unittest.TestCase.assertEqual,并传入两个参数,21 * 242。由于这两个参数在逻辑上是相等的,测试执行将通过:

`import unittest class MyTests(unittest.TestCase):     def test_42(self):         self.assertEqual(21 * 2, 42) def suite():     suite = unittest.TestSuite()     suite.addTest(MyTests("test_42"))     return suite runner = unittest.TextTestRunner() runner.run(suite())` 
`. ---------------------------------------------------------------------- Ran 1 test in 0.001s OK <unittest.runner.TextTestResult run=1 errors=0 failures=0>` 

现在让我们尝试使用 pandas 遵循相同的执行框架,不过这次我们不是比较 21 * 242,而是尝试比较两个 pd.Series 对象:

`def some_cool_numbers():     return pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype()) class MyTests(unittest.TestCase):     def test_cool_numbers(self):         result = some_cool_numbers()         expected = pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())         self.assertEqual(result, expected) def suite():     suite = unittest.TestSuite()     suite.addTest(MyTests("test_cool_numbers"))     return suite runner = unittest.TextTestRunner() runner.run(suite())` 
`E ====================================================================== ERROR: test_cool_numbers (__main__.MyTests) ---------------------------------------------------------------------- Traceback (most recent call last):  File "/tmp/ipykernel_79586/2361126771.py", line 9, in test_cool_numbers    self.assertEqual(result, expected)  File "/usr/lib/python3.9/unittest/case.py", line 837, in assertEqual    assertion_func(first, second, msg=msg)  File "/usr/lib/python3.9/unittest/case.py", line 827, in _baseAssertEqual    if not first == second:  File "/home/willayd/clones/Pandas-Cookbook-Third-Edition/lib/python3.9/site-packages/pandas/core/generic.py", line 1577, in __nonzero__    raise ValueError( ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all(). ---------------------------------------------------------------------- Ran 1 test in 0.004s FAILED (errors=1) <unittest.runner.TextTestResult run=1 errors=1 failures=0>` 

哦……这真是让人吃惊!

这里的根本问题是调用 self.assertEqual(result, expected) 执行表达式 result == expected。如果该表达式的结果是 True,测试将通过;返回 False 的表达式将使测试失败。

然而,pandas 重载了 pd.Series 的等于运算符,因此它不会返回 TrueFalse,而是返回另一个进行逐元素比较的 pd.Series

`result = some_cool_numbers() expected = pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype()) result == expected` 
`0    True 1    True 2    <NA> dtype: boolean` 

由于测试框架不知道如何处理这个问题,你需要使用 pd.testing 命名空间中的自定义函数。对于 pd.Series 的比较,pd.testing.assert_series_equal 是最合适的工具:

`import pandas.testing as tm def some_cool_numbers():     return pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype()) class MyTests(unittest.TestCase):     def test_cool_numbers(self):         result = some_cool_numbers()         expected = pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())         tm.assert_series_equal(result, expected) def suite():     suite = unittest.TestSuite()     suite.addTest(MyTests("test_cool_numbers"))     return suite runner = unittest.TextTestRunner() runner.run(suite())` 
`. ---------------------------------------------------------------------- Ran 1 test in 0.001s   OK <unittest.runner.TextTestResult run=1 errors=0 failures=0>` 

为了完整性,让我们触发一个故意的失败并查看输出:

`def some_cool_numbers():     return pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype()) class MyTests(unittest.TestCase):     def test_cool_numbers(self):         result = some_cool_numbers()         expected = pd.Series([42, 555, pd.NA], dtype=pd.Int32Dtype())         tm.assert_series_equal(result, expected) def suite():     suite = unittest.TestSuite()     suite.addTest(MyTests("test_cool_numbers"))     return suite runner = unittest.TextTestRunner() runner.run(suite())` 
`F ====================================================================== FAIL: test_cool_numbers (__main__.MyTests) ---------------------------------------------------------------------- Traceback (most recent call last):  File "/tmp/ipykernel_79586/2197259517.py", line 9, in test_cool_numbers    tm.assert_series_equal(result, expected)  File "/home/willayd/clones/Pandas-Cookbook-Third-Edition/lib/python3.9/site-packages/pandas/_testing/asserters.py", line 975, in assert_series_equal    assert_attr_equal("dtype", left, right, obj=f"Attributes of {obj}")  File "/home/willayd/clones/Pandas-Cookbook-Third-Edition/lib/python3.9/site-packages/pandas/_testing/asserters.py", line 421, in assert_attr_equal    raise_assert_detail(obj, msg, left_attr, right_attr)  File "/home/willayd/clones/Pandas-Cookbook-Third-Edition/lib/python3.9/site-packages/pandas/_testing/asserters.py", line 614, in raise_assert_detail    raise AssertionError(msg) AssertionError: Attributes of Series are different Attribute "dtype" are different [left]:  Int64 [right]: Int32 ---------------------------------------------------------------------- Ran 1 test in 0.003s FAILED (failures=1) <unittest.runner.TextTestResult run=1 errors=0 failures=1>` 

在测试失败的追踪信息中,pandas 告诉我们,比较对象的数据类型不同。调用 some_cool_numbers 返回一个带有 pd.Int64Dtypepd.Series,而我们的期望是 pd.Int32Dtype

虽然这些示例集中在使用 pd.testing.assert_series_equal,但对于 pd.DataFrame,等效的方法是 pd.testing.assert_frame_equal。这两个函数知道如何处理可能不同的行索引、列索引、值和缺失值语义,并且如果期望不符合,它们会向测试运行器报告有用的错误信息。

还有更多……

这个示例使用了 unittest 模块,因为它是 Python 语言自带的。然而,许多大型 Python 项目,特别是在科学 Python 领域,使用 pytest 库来编写和执行单元测试。

unittest 不同,pytest 放弃了基于类的测试结构(包括 setUptearDown 方法),转而采用基于测试夹具的方法。关于这两种不同测试范式的比较,可以在 pytest 文档中找到。

pytest 库还提供了一套丰富的插件。有些插件可能旨在改善与第三方库的集成(比如 pytest-djangopytest-sqlalchemy),而其他插件则可能专注于扩展测试套件,利用系统的所有资源(例如 pytest-xdist)。介于两者之间,还有无数的插件使用场景,因此我强烈建议你了解 pytest 及其插件生态系统,以便测试你的 Python 代码库。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者一起讨论:

packt.link/pandas

第十一章:pandas 生态系统

虽然 pandas 库提供了大量令人印象深刻的功能,但它的流行很大程度上得益于大量与之互补工作的第三方库。我们不能在本章中覆盖所有这些库,也无法深入探讨每个单独库的工作原理。然而,仅仅知道这些工具的存在并理解它们提供的功能,就能为未来的学习提供很大的启发。

虽然 pandas 是一个令人惊叹的工具,但它也有自己的缺陷,我们在本书中已经尽力突出了这些缺陷;pandas 不可能解决所有分析问题。我强烈建议你熟悉本章中提到的工具,并在寻找新工具和专用工具时,参考 pandas 生态系统的文档(pandas.pydata.org/about/)。

本章的技术说明是,随着库的新版本发布,这些代码块可能会中断或行为发生变化。虽然我们在本书中尽力编写“未来可用”的 pandas 代码,但随着我们讨论第三方依赖库(及其依赖项),要保证这一点变得更加困难。如果你在运行本章代码时遇到问题,请确保参考与本书代码示例一起提供的 requirements.txt 文件。该文件将包含已知与本章兼容的依赖项和版本的列表。

本章将涵盖以下内容:

  • 基础库

  • 探索性数据分析

  • 数据验证

  • 可视化

  • 数据科学

  • 数据库

  • 其他 DataFrame 库

基础库

和许多开源库一样,pandas 在其他基础库的基础上构建了功能,使得它们可以处理低层次的细节,而 pandas 则提供了更友好的功能。如果你希望深入研究比在 pandas 中学到的更技术性的细节,那么这些库就是你需要关注的重点。

NumPy

NumPy 自称为Python 科学计算的基础包,它是 pandas 最初构建之上的库。实际上,NumPy 是一个n维度的库,因此你不仅仅局限于像 pd.DataFrame(pandas 实际上曾经提供过 3 维和 4 维的面板结构,但现在这些已不再使用)那样的二维数据。

在本书中,我们向你展示了如何从 NumPy 对象构建 pandas 对象,如以下 pd.DataFrame 构造函数所示:

`arr = np.arange(1, 10).reshape(3, -1) df = pd.DataFrame(arr) df` 
 `0    1    2 0    1    2    3 1    4    5    6 2    7    8    9` 

然而,你也可以通过使用 pd.DataFrame.to_numpy 方法,从 pd.DataFrame 对象创建 NumPy 数组:

`df.to_numpy()` 
`array([[1, 2, 3],       [4, 5, 6],       [7, 8, 9]])` 

许多 NumPy 函数接受 pd.DataFrame 作为参数,甚至仍然会返回一个 pd.DataFrame

`np.log(df)` 
 `0           1           2 0    0.000000    0.693147    1.098612 1    1.386294    1.609438    1.791759 2    1.945910    2.079442    2.197225` 

需要记住的主要一点是,NumPy 与 pandas 的互操作性会在你需要处理非浮动类型的缺失值时下降,或者更一般地,当你尝试使用既非整数也非浮动点类型的数据时。

这一点的具体规则过于复杂,无法在本书中列出,但通常,我建议除非是浮动点或整数数据,否则不要调用 pd.Series.to_numpypd.DataFrame.to_numpy

PyArrow

pandas 的另一个主要依赖库是 Apache Arrow,Apache Arrow 自称是一个 跨语言的内存分析开发平台。这个项目由 pandas 的创建者 Wes McKinney 启动,并在他具有影响力的博客文章《Apache Arrow 与 我讨厌 pandas 的 10 件事》中宣布 (wesmckinney.com/blog/apache-arrow-pandas-internals/)。Apache Arrow 项目为一维数据结构定义了内存布局,允许不同的语言、程序和库共享相同的数据。除了定义这些结构外,Apache Arrow 项目还提供了一整套工具,供库实现 Apache Arrow 的规范。

本书中在特定场景下使用了 Apache Arrow 的 Python 实现 PyArrow。虽然 pandas 没有提供将 pd.DataFrame 转换为 PyArrow 的方法,但 PyArrow 库提供了一个 pa.Table.from_pandas 方法,专门用于此目的:

`tbl = pa.Table.from_pandas(df) tbl` 
`pyarrow.Table 0: int64 1: int64 2: int64 ---- 0: [[1,4,7]] 1: [[2,5,8]] 2: [[3,6,9]]` 

PyArrow 同样提供了一个 pa.Table.to_pandas 方法,可以将 pa.Table 转换为 pd.DataFrame

`tbl.to_pandas()` 
 `0    1    2 0    1    2    3 1    4    5    6 2    7    8    9` 

通常,PyArrow 被认为是比 pandas 更低级的库。它主要旨在为其他库的开发者提供服务,而非为寻求 DataFrame 库的一般用户提供服务,因此,除非你正在编写库,否则你可能不需要经常将 pd.DataFrame 转换为 PyArrow。然而,随着 Apache Arrow 生态系统的发展,pandas 和 PyArrow 可以互操作这一事实为 pandas 与许多其他分析库和数据库的集成提供了无限的可能性。

探索性数据分析

很多时候,你会遇到一个对数据几乎不了解的数据集。在本书中,我们展示了手动筛选数据的方法,但也有一些工具可以帮助自动化这些潜在的繁琐任务,帮助你更短时间内掌握数据。

YData Profiling

YData Profiling 自称是“领先的数据概况分析包,能够自动化并标准化生成详细报告,包含统计信息和可视化”。虽然我们在可视化章节中已经学会了如何手动探索数据,但这个包可以作为一个快速启动工具,自动生成许多有用的报告和特性。

为了将其与我们在那些章节中做的工作进行对比,我们再来看一看车辆数据集。现在,我们只挑选一个小子集的列,以保持我们的 YData Profiling 尽量简洁;对于大型数据集,性能往往会下降:

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     usecols=[         "id",         "engId",         "make",         "model",         "cylinders",         "city08",         "highway08",         "year",         "trany",     ] ) df.head()` 
 `city08   cylinders   engId   …   model               trany           year 0   19       4           9011    …   Spider Veloce 2000  Manual 5-spd    1985 1   9        12          22020   …   Testarossa          Manual 5-spd    1985 2   23       4           2100    …   Charger             Manual 5-spd    1985 3   10       8           2850    …   B150/B250 Wagon 2WD Automatic 3-spd 1985 4   17       4           66031   …   Legacy AWD Turbo    Manual 5-spd    1993 5 rows × 9 columns` 

YData Profiling 使你能够轻松创建一个概况报告,该报告包含许多常见的可视化内容,并有助于描述你在pd.DataFrame中工作的各列。

本书是使用ydata_profiling版本 4.9.0 编写的。要创建概况报告,只需运行:

`from ydata_profiling import ProfileReport profile = ProfileReport(df, title="Vehicles Profile Report")` 

如果在 Jupyter notebook 中运行代码,你可以直接在 notebook 中看到输出,方法是调用:

`profile.to_widgets()` 

如果你没有使用 Jupyter,你还可以将该概况导出为本地 HTML 文件,然后从那里打开:

`profile.to_file("vehicles_profile.html")` 

在查看概况时,你首先会看到一个高层次的概述部分,其中列出了缺失数据的单元格数、重复行数等:

一张计算机屏幕截图 说明自动生成

图 11.1:YData Profiling 提供的概述

每一列来自pd.DataFrame的数据都会被详细列出。如果某列包含连续值,YData Profiling 会为你创建一个直方图:

一张图表截图 说明自动生成

图 11.2:YData Profiling 生成的直方图

对于分类变量,该工具将生成一个词云可视化图:

一张计算机屏幕截图 说明自动生成

图 11.3:YData Profiling 生成的词云

为了理解你的连续变量是否存在相关性,概况报告中包含了一个简洁的热图,根据每一对变量之间的关系着色:

一张颜色图表截图 说明自动生成

图 11.4:YData Profiling 生成的热图

尽管你可能仍然需要深入分析数据集,超出该库所提供的功能,但它是一个很好的起点,并可以帮助自动生成那些本来可能是繁琐的图表。

数据验证

计算中的“垃圾进,垃圾出”原则指出,无论你的代码多么出色,如果从质量差的数据开始,分析结果也会是质量差的。数据从业者经常面临诸如意外缺失数据、重复值和建模实体之间断裂关系等问题。

幸运的是,有一些工具可以帮助你自动化输入到模型和从模型输出的数据,这样可以确保你所执行工作的可信度。在这篇文章中,我们将介绍 Great Expectations。

Great Expectations

本书是使用 Great Expectations 版本 1.0.2 编写的。为了开始,让我们再次看看我们的车辆数据集:

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     dtype={         "rangeA": pd.StringDtype(),         "mfrCode": pd.StringDtype(),         "c240Dscr": pd.StringDtype(),         "c240bDscr": pd.StringDtype()     } ) df.head()` 
 `barrels08  barrelsA08  charge120  …  phevCity  phevHwy  phevComb 0  14.167143  0.0         0.0        …  0         0        0 1  27.046364  0.0         0.0        …  0         0        0 2  11.018889  0.0         0.0        …  0         0        0 3  27.046364  0.0         0.0        …  0         0        0 4  15.658421  0.0         0.0        …  0         0        0 5 rows × 84 columns` 

使用 Great Expectations 的方式有几种,不是所有的方式都能在本手册中记录。为了展示一个自包含的例子,我们将在内存中设置并处理所有的期望。

为了实现这一点,我们将导入great_expectations库,并为我们的测试创建一个context

`import great_expectations as gx context = gx.get_context()` 

在此上下文中,你可以创建数据源和数据资产。对于像 SQL 这样的非 DataFrame 源,数据源通常会包含连接凭证,而对于存在内存中的pd.DataFrame,则无需做太多工作。数据资产是用于存储结果的分组机制。这里我们只创建了一个数据资产,但在实际应用中,你可能会决定创建多个资产来存储和组织 Great Expectations 输出的验证结果:

`datasource = context.data_sources.add_pandas(name="pandas_datasource") data_asset = datasource.add_dataframe_asset(name="vehicles")` 

从这里,你可以在 Great Expectations 中创建一个批次定义。对于非 DataFrame 源,批次定义会告诉库如何从源中获取数据。对于 pandas,批次定义将简单地从关联的pd.DataFrame中检索所有数据:

`batch_definition_name = "dataframe_definition" batch_definition = data_asset.add_batch_definition_whole_dataframe(     batch_definition_name ) batch = batch_definition.get_batch(batch_parameters={     "dataframe": df })` 

到此时,你可以开始对数据进行断言。例如,你可以使用 Great Expectations 来确保某一列不包含任何空值:

`city_exp = gx.expectations.ExpectColumnValuesToNotBeNull(     column="city08" ) result = batch.validate(city_exp) print(result)` 
`{   "success": true,   "expectation_config": {     "type": "expect_column_values_to_not_be_null",     "kwargs": {       "batch_id": "pandas_datasource-vehicles",       "column": "city08"     },     "meta": {}   },   "result": {     "element_count": 48130,     "unexpected_count": 0,     "unexpected_percent": 0.0,     "partial_unexpected_list": [],     "partial_unexpected_counts": [],     "partial_unexpected_index_list": []   },   "meta": {},   "exception_info": {     "raised_exception": false,     "exception_traceback": null,     "exception_message": null   } }` 

同样应用于cylinders列的期望将不会成功:

`cylinders_exp = gx.expectations.ExpectColumnValuesToNotBeNull(     column="cylinders" ) result = batch.validate(cylinders_exp) print(result)` 
`{   "success": false,   "expectation_config": {     "type": "expect_column_values_to_not_be_null",     "kwargs": {       "batch_id": "pandas_datasource-vehicles",       "column": "cylinders"     },     "meta": {}   },   "result": {     "element_count": 48130,     "unexpected_count": 965,     "unexpected_percent": 2.0049864949096197,     "partial_unexpected_list": [       null,       null,       ...       null,       null     ],     "partial_unexpected_counts": [       {         "value": null,         "count": 20       }     ],     "partial_unexpected_index_list": [       7138,       7139,       8143,       ...       23022,       23023,       23024     ]   },   "meta": {},   "exception_info": {     "raised_exception": false,     "exception_traceback": null,     "exception_message": null   } }` 

为了简洁起见,我们只展示了如何设置关于空值的期望,但你可以在greatexpectations.io/expectations/中找到完整的期望库,供你用于其他断言。Great Expectations 还与其他工具(如 Spark、PostgreSQL 等)兼容,因此你可以在数据转换管道中的多个点应用你的期望。

可视化

在第6 章可视化中,我们详细讨论了使用 matplotlib 进行可视化,并且还讨论了使用 Seaborn 进行高级图表的绘制。这些工具非常适合生成静态图表,但当你想要加入一些交互性时,你需要选择其他的库。

对于这个例子,我们将加载来自车辆数据集的数据,这是我们在第6 章散点图例子中使用过的:

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     dtype={         "rangeA": pd.StringDtype(),         "mfrCode": pd.StringDtype(),         "c240Dscr": pd.StringDtype(),         "c240bDscr": pd.StringDtype()     } ) df.head()` 
 `barrels08  barrelsA08  charge120  …  phevCity  phevHwy  phevComb 0  14.167143  0.0         0.0        …  0         0        0 1  27.046364  0.0         0.0        …  0         0        0 2  11.018889  0.0         0.0        …  0         0        0 3  27.046364  0.0         0.0        …  0         0        0 4  15.658421  0.0         0.0        …  0         0        0 5 rows × 84 columns` 

Plotly

让我们先来看看 Plotly,它可以用来创建具有高交互性的可视化图表,因此在 Jupyter 笔记本中非常受欢迎。使用它时,只需将plotly作为backend=参数传递给pd.DataFrame.plot。我们还将添加一个hover_data=参数,Plotly 可以用它为每个数据点添加标签:

`df.plot(     kind="scatter",     x="city08",     y="highway08",     backend="plotly",     hover_data={"make": True, "model": True, "year": True}, )` 

如果你在 Jupyter 笔记本或 HTML 页面中查看此内容,你将看到你可以将鼠标悬停在任何数据点上,查看更多细节:

图 11.5: 使用 Plotly 悬停在数据点上

你甚至可以选择图表的某个区域,以便放大数据点:

图 11.6: 使用 Plotly 进行缩放

如您所见,Plotly 与您在本书中看到的相同的 pandas API 配合使用,非常简单。如果您希望图表具有互动性,这是一个非常适合使用的工具。

PyGWalker

到目前为止,您看到的所有绘图代码都是声明式的;即,您告诉 pandas 您想要一个条形图、折线图、散点图等,然后 pandas 为您生成相应的图表。然而,许多用户可能更喜欢一种“自由形式”的工具进行探索,在这种工具中,他们可以直接拖放元素,即时制作图表。

如果这正是您所追求的,那么您将希望查看 PyGWalker 库。通过一个非常简洁的 API,您可以在 Jupyter 笔记本中生成一个互动工具,您可以拖放不同的元素来生成各种图表:

`import pygwalker as pyg pyg.walk(df)` 

图 11.7:Jupyter 笔记本中的 PyGWalker

数据科学

虽然 pandas 提供了一些内建的统计算法,但它无法涵盖所有数据科学领域使用的统计和机器学习算法。幸运的是,许多专注于数据科学的库与 pandas 有很好的集成,让您能够在不同库之间无缝地移动数据。

scikit-learn

scikit-learn 是一个流行的机器学习库,能够帮助进行监督学习和无监督学习。scikit-learn 库提供了一个令人印象深刻的算法库,用于分类、预测和聚类任务,同时还提供了数据预处理和清洗工具。

我们无法涵盖所有这些功能,但为了展示一些内容,我们再次加载车辆数据集:

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     dtype={         "rangeA": pd.StringDtype(),         "mfrCode": pd.StringDtype(),         "c240Dscr": pd.StringDtype(),         "c240bDscr": pd.StringDtype()     } ) df.head()` 
 `barrels08  barrelsA08  charge120  …  phevCity  phevHwy  phevComb 0  14.167143  0.0         0.0        …  0         0        0 1  27.046364  0.0         0.0        …  0         0        0 2  11.018889  0.0         0.0        …  0         0        0 3  27.046364  0.0         0.0        …  0         0        0 4  15.658421  0.0         0.0        …  0         0        0 5 rows × 84 columns` 

现在假设我们想创建一个算法来预测车辆的综合行驶里程,从数据中的其他属性推断出来。由于里程是一个连续变量,我们可以选择线性回归模型来进行预测。

我们将使用的线性回归模型希望使用的特征也应该是数值型的。虽然我们可以通过某些方法将部分非数值数据人工转换为数值(例如,使用《第五章,算法及其应用》中提到的One-hot 编码与 pd.get_dummies技术),但我们现在暂时忽略任何非数值列。线性回归模型也无法处理缺失数据。我们从《第六章,探索连续数据》探索连续数据食谱中知道,该数据集有两个缺失数据的连续变量。虽然我们可以尝试插值来填充这些值,但在这个例子中,我们还是采用简单的方法,直接删除这些数据:

`num_df = df.select_dtypes(include=["number"]) num_df = num_df.drop(columns=["cylinders", "displ"])` 

scikit-learn 模型需要知道我们想用来进行预测的 特征(通常标记为 X)和我们试图预测的目标变量(通常标记为 y)。将数据拆分为训练集和测试集也是一个好习惯,我们可以使用 train_test_split 函数来完成:

`from sklearn.model_selection import train_test_split target_col = "comb08" X = num_df.drop(columns=[target_col]) y = num_df[target_col] X_train, X_test, y_train, y_test = train_test_split(X, y)` 

使用我们这样的数据格式,我们可以继续训练线性回归模型,然后将其应用于测试数据,以生成预测结果:

`from sklearn import linear_model   regr = linear_model.LinearRegression() regr.fit(X_train, y_train) y_pred = regr.predict(X_test)` 

现在我们已经从测试数据集得到了预测结果,我们可以将其与实际的测试数据对比。这是衡量我们训练模型准确性的好方法。

管理模型准确性的方式有很多种,但现在我们可以选择一个常用且相对简单的 mean_squared_error,这是 scikit-learn 也提供的一个便捷函数:

`from sklearn.metrics import mean_squared_error mean_squared_error(y_test, y_pred)` 
`0.11414180317382835` 

如果你有兴趣了解更多,我强烈推荐你阅读 scikit-learn 网站上的文档和示例,或者阅读像《Machine Learning with PyTorch and Scikit-Learn: Develop machine learning and deep learning models with Python》这样的书籍 (www.packtpub.com/en-us/product/machine-learning-with-pytorch-and-scikit-learn-9781801819312)。

XGBoost

现在让我们转向另一个出色的机器学习库 XGBoost,它使用梯度提升算法实现了多种算法。XGBoost 性能极其出色,扩展性强,在机器学习竞赛中表现优异,且与存储在 pd.DataFrame 中的数据兼容。如果你已经熟悉 scikit-learn,那么它使用的 API 会让你感到非常熟悉。

XGBoost 可用于分类和回归。由于我们刚刚使用 scikit-learn 进行了回归分析,接下来让我们通过一个分类示例来尝试预测车辆的品牌,这个预测是基于数据集中的数值特征。

我们正在使用的车辆数据集包含 144 种不同的品牌。为了进行分析,我们将选择一小部分消费者品牌:

`brands = {"Dodge", "Toyota", "Volvo", "BMW", "Buick", "Audi", "Volkswagen", "Subaru"} df2 = df[df["make"].isin(brands)] df2 = df2.drop(columns=["cylinders", "displ"])` 

接下来,我们将把数据分为特征(X)和目标变量(y)。为了适应机器学习算法,我们还需要将目标变量转换为类别数据类型,以便算法能够预测像 012 等值,而不是像 "Dodge""Toyota""Volvo" 这样的字符串:

`X = df2.select_dtypes(include=["number"]) y = df2["make"].astype(pd.CategoricalDtype())` 

在此基础上,我们可以再次使用 scikit-learn 中的 train_test_split 函数来创建训练数据和测试数据。请注意,我们使用了 pd.Series.cat.codes 来获取分配给类别数据类型的数字值,而不是字符串:

`X_train, X_test, y_train, y_test = train_test_split(X, y.cat.codes)` 

最后,我们可以从 XGBoost 中导入 XGBClassifier,将其在我们的数据上进行训练,并应用于测试特征以生成预测结果:

`from xgboost import XGBClassifier bst = XGBClassifier() bst.fit(X_train, y_train) preds = bst.predict(X_test)` 

现在我们得到了预测结果,可以验证它们中有多少与测试数据中包含的目标变量相匹配:

`accuracy = (preds == y_test).sum() / len(y_test) print(f"Model prediction accuracy is: {accuracy:.2%}")` 
`Model prediction accuracy is: 97.07%` 

再次强调,我们只是简单地触及了像 XGBoost 这样的库所能做的皮毛。你可以通过许多不同的方法来调整模型,以提高准确性、避免过拟合/欠拟合、优化不同的结果等等。对于想要深入了解这个优秀库的用户,我建议查看 XGBoost 的文档或像 Hands-On Gradient Boosting with XGBoost and scikit-learn 这样的书籍。

数据库

数据库知识是任何数据从业人员工具包中的重要工具。虽然 pandas 是一个适用于单机内存计算的优秀工具,但数据库提供了一套非常互补的分析工具,可以帮助存储和分发分析过程。

第四章pandas I/O 系统 中,我们介绍了如何在 pandas 和理论上任何数据库之间转移数据。然而,一个相对较新的数据库叫做 DuckDB,值得额外关注,因为它可以让你更加无缝地将 DataFrame 和数据库的世界连接在一起。

DuckDB

DuckDB 是一个轻量级数据库系统,提供与 Apache Arrow 的零复制集成,这项技术也支持与 pandas 高效的数据共享和使用。它非常轻量,并且与大多数数据库系统不同,可以轻松嵌入到其他工具或流程中。最重要的是,DuckDB 针对分析型工作负载进行了优化。

DuckDB 使你可以轻松地使用 SQL 查询 pd.DataFrame 中的数据。让我们通过加载车辆数据集来实际操作一下:

`df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     dtype={         "rangeA": pd.StringDtype(),         "mfrCode": pd.StringDtype(),         "c240Dscr": pd.StringDtype(),         "c240bDscr": pd.StringDtype()     } ) df.head()` 
 `barrels08  barrelsA08  charge120  …  phevCity  phevHwy  phevComb 0  14.167143  0.0         0.0        …  0         0        0 1  27.046364  0.0         0.0        …  0         0        0 2  11.018889  0.0         0.0        …  0         0        0 3  27.046364  0.0         0.0        …  0         0        0 4  15.658421  0.0         0.0        …  0         0        0 5 rows × 84 columns` 

通过将 CREATE TABLE 语句传递给 duckdb.sql,你可以将数据从 pd.DataFrame 加载到表格中:

`import duckdb duckdb.sql("CREATE TABLE vehicles AS SELECT * FROM df")` 

一旦表格创建完成,你就可以通过 SQL 查询它:

`duckdb.sql("SELECT COUNT(*) FROM vehicles WHERE make = 'Honda'")` 
`┌──────────────┐ │ count_star() │ │    int64     │ ├──────────────┤ │         1197 │ └──────────────┘` 

如果你想将结果转换回 pd.DataFrame,你可以使用 .df 方法:

`duckdb.sql(     "SELECT make, model, year, id, city08 FROM vehicles where make = 'Honda' LIMIT 5" ).df()` 
 `make    model          year   id      city08 0   Honda   Accord Wagon   1993   10145   18 1   Honda   Accord Wagon   1993   10146   20 2   Honda   Civic Del Sol  1994   10631   25 3   Honda   Civic Del Sol  1994   10632   30 4   Honda   Civic Del Sol  1994   10633   23` 

要深入了解 DuckDB,我强烈建议查看 DuckDB 的文档,并且为了更好地理解它在数据库领域的定位,阅读 Why DuckDB 文章(duckdb.org/why_duckdb)。通常,DuckDB 的重点是单用户分析,但如果你对共享的云端数据仓库感兴趣,也可以看看 MotherDuck(motherduck.com/)。

其他 DataFrame 库

pandas 开发不久后,它成为了 Python 领域事实上的 DataFrame 库。从那时起,许多新的 DataFrame 库在该领域得以开发,它们都旨在解决 pandas 的一些不足之处,同时引入自己独特的设计决策。

Ibis

Ibis 是另一个由 pandas 创始人 Wes McKinney 创建的出色分析工具。从高层次来看,Ibis 是一个 DataFrame 的“前端”,通过一个通用的 API,你可以查询多个“后端”。

为了帮助理解这一点,值得与 pandas 的设计方法进行对比。在 pandas 中,进行分组和求和的 API 或“前端”看起来是这样的:

`df.groupby("column").agg(result="sum")` 
pd.DataFrame.groupby). Behind the scenes, pandas dictates how the pd.DataFrame is stored (in memory using pandas’ own representation) and even dictates how the summation should be performed against that in-memory representation.

在 Ibis 中,类似的表达式看起来是这样的:

`df.group_by("column").agg(result=df.sum())` 

尽管暴露给用户的 API 可能没有太大不同,但 Ibis 和 pandas 之间的相似性就此为止。Ibis 不规定你查询的数据应该如何存储;它可以存储在 BigQuery、DuckDB、MySQL、PostgreSQL 等数据库中,甚至可以存储在像 pandas 这样的其他 DataFrame 库中。除了存储之外,Ibis 也不规定如何执行求和操作;它将这一过程交给执行引擎。许多 SQL 数据库有自己的执行引擎,但也有一些可能会依赖于像 Apache DataFusion (datafusion.apache.org/) 这样的第三方库。

要通过 Ibis 使用 pd.DataFrame,你需要使用 ibis.memtable 函数将其包装起来:

`import ibis df = pd.read_csv(     "data/vehicles.csv.zip",     dtype_backend="numpy_nullable",     usecols=["id", "year", "make", "model", "city08"], ) t = ibis.memtable(df)` 

完成这些后,你可以像使用 pandas 一样,使用 Ibis API 查询数据:

`t.filter(t.make == "Honda").select("make", "model", "year", "city08")` 
`r0 := InMemoryTable   data:     PandasDataFrameProxy:              city08     id        make                model  year       0          19      1  Alfa Romeo   Spider Veloce 2000  1985       1           9     10     Ferrari           Testarossa  1985       2          23    100       Dodge              Charger  1985       3          10   1000       Dodge  B150/B250 Wagon 2WD  1985       4          17  10000      Subaru     Legacy AWD Turbo  1993       ...       ...    ...         ...                  ...   ...       48125      19   9995      Subaru               Legacy  1993       48126      20   9996      Subaru               Legacy  1993       48127      18   9997      Subaru           Legacy AWD  1993       48128      18   9998      Subaru           Legacy AWD  1993       48129      16   9999      Subaru     Legacy AWD Turbo  1993         [48130 rows x 5 columns]  r1 := Filter[r0]   r0.make == 'Honda'  Project[r1]   make:   r1.make   model:  r1.model   year:   r1.year   city08: r1.city08` 

值得注意的是,前面的代码实际上并没有返回结果。与 pandas 不同,pandas 会“急切地”执行你给它的所有操作,而 Ibis 会收集你想要的所有表达式,并且直到明确要求时才执行。这种做法通常被称为 推迟执行惰性执行

推迟执行的优势在于 Ibis 可以找到优化你请求执行的查询的方法。我们的查询要求 Ibis 查找所有“make”为 Honda 的行,并选择几个列,但对于底层数据库来说,先选择列再执行过滤可能会更快。这一过程对最终用户是透明的;用户只需要告诉 Ibis 他们需要什么,Ibis 会处理如何获取这些数据。

要将其转化为 pd.DataFrame,可以链式调用 .to_pandas

`t.filter(t.make == "Honda").select("make", "model", "year", "city08").to_pandas().head()` 
 `make    model          year   city08 0    Honda   Accord Wagon   1993   18 1    Honda   Accord Wagon   1993   20 2    Honda   Civic Del Sol  1994   25 3    Honda   Civic Del Sol  1994   30 4    Honda   Civic Del Sol  1994   23` 

然而,你并不一定要返回 pd.DataFrame。如果你想要一个 PyArrow 表格,完全可以选择 .to_pyarrow

`t.filter(t.make == "Honda").select("make", "model", "year", "city08").to_pyarrow()` 
`pyarrow.Table make: string model: string year: int64 city08: int64 ---- make: [["Honda","Honda","Honda","Honda","Honda",...,"Honda","Honda","Honda","Honda","Honda"]] model: [["Accord Wagon","Accord Wagon","Civic Del Sol","Civic Del Sol","Civic Del Sol",...,"Prelude","Prelude","Prelude","Accord","Accord"]] year: [[1993,1993,1994,1994,1994,...,1993,1993,1993,1993,1993]] city08: [[18,20,25,30,23,...,21,19,19,19,21]]` 

有关 Ibis 的更多信息,请务必查看 Ibis 文档。甚至有一个专门面向来自 pandas 用户的 Ibis 教程。

Dask

另一个与 pandas 紧密相关的流行库是 Dask。Dask 是一个框架,它提供了与 pd.DataFrame 类似的 API,但将其使用扩展到并行计算和超出系统可用内存的数据集。

如果我们想将车辆数据集转换为 Dask DataFrame,可以使用 dask.dataframe.from_pandas 函数,并设置 npartitions= 参数来控制如何划分数据集:

`import dask.dataframe as dd ddf = dd.from_pandas(df, npartitions=10)` 
``/home/willayd/clones/Pandas-Cookbook-Third-Edition/lib/python3.9/site-packages/dask/dataframe/__init__.py:42: FutureWarning: Dask dataframe query planning is disabled because dask-expr is not installed. You can install it with `pip install dask[dataframe]` or `conda install dask`. This will raise in a future version.  warnings.warn(msg, FutureWarning)`` 

通过将 DataFrame 划分为不同的分区,Dask 允许你对每个分区并行执行计算,这对性能和可扩展性有极大的帮助。

就像 Ibis 一样,Dask 也懒惰地执行计算。如果你想强制执行计算,你需要调用 .compute 方法:

`ddf.size.compute()` 
`3991932` 

要从 Dask DataFrame 转回 pandas,只需调用 ddf.compute

`ddf.compute().head()` 
 `city08    id      make          model                 year 0    19        1       Alfa Romeo    Spider Veloce 2000    1985 1    9         10      Ferrari       Testarossa            1985 2    23        100     Dodge         Charger               1985 3    10        1000    Dodge         B150/B250 Wagon 2WD   1985 4    17        10000   Subaru        Legacy AWD Turbo      1993` 

Polars

Polars 是 DataFrame 领域的新秀,并在非常短的时间内开发出了令人印象深刻的功能,并拥有了一群忠实的追随者。Polars 库是 Apache Arrow 原生的,因此它拥有比 pandas 当前提供的更清晰的类型系统和一致的缺失值处理(关于 pandas 类型系统及其所有缺陷的历史,请务必阅读 第三章数据类型)。

除了更简洁、更清晰的类型系统外,Polars 还能够扩展到大于内存的数据集,它甚至提供了一个懒执行引擎,并配备了查询优化器,使得编写高效、可扩展的代码变得更加容易。

对于从 pandas 到 Polars 的简单转换,你可以使用 polars.from_pandas

`import polars as pl pl_df = pl.from_pandas(df) pl_df.head()` 
`shape: (5, 84) barrels08  barrelsA08  charge120  charge240  ...  phevCity  phevHwy  phevComb f64        f64         f64        f64        ...  i64       i64      i64 14.167143  0.0         0.0        0.0        ...    0         0        0 27.046364  0.0         0.0        0.0        ...    0         0        0 11.018889  0.0         0.0        0.0        ...    0         0        0 27.046364  0.0         0.0        0.0        ...    0         0        0 15.658421  0.0         0.0        0.0        ...    0         0        0` 

对于懒执行,你可能会想试试 pl.LazyFrame,它可以直接将 pd.DataFrame 作为参数:

`lz_df = pl.LazyFrame(df)` 

就像我们在 Ibis 中看到的那样,Polars 的懒执行引擎可以优化执行筛选和选择操作的最佳路径。要执行计划,你需要将 pl.LazyFrame.collect 链接起来:

`lz_df.filter(     pl.col("make") == "Honda" ).select(["make", "model", "year", "city08"]).collect().head()` 
`shape: (5, 4) make    model   year    city08 str     str     i64     i64 "Honda" "Accord Wagon"  1993    18 "Honda" "Accord Wagon"  1993    20 "Honda" "Civic Del Sol" 1994    25 "Honda" "Civic Del Sol" 1994    30 "Honda" "Civic Del Sol" 1994    23` 

如果你想从 Polars 转回 pandas,pl.DataFramepl.LazyFrame 都提供了 .to_pandas 方法:

`lz_df.filter(     pl.col("make") == "Honda" ).select(["make", "model", "year", "city08"]).collect().to_pandas().head()` 
 `make   model          year  city08 0     Honda  Accord Wagon   1993  18 1     Honda  Accord Wagon   1993  20 2     Honda  Civic Del Sol  1994  25 3     Honda  Civic Del Sol  1994  30 4     Honda  Civic Del Sol  1994  23` 

若想更详细地了解 Polars 及其所有优秀的功能,建议查看 Polars Cookbook (www.packtpub.com/en-us/product/polars-cookbook-9781805121152)。

cuDF

如果你拥有 Nvidia 设备并且已经安装了 CUDA 工具包,你可能会对 cuDF 感兴趣。理论上,cuDF 是 pandas 的“即插即用”替代品;只要你有合适的硬件和工具,就可以将 pandas 表达式运行在你的 GPU 上,只需在 pandas 之前导入 cuDF:

`import cudf.pandas cudf.pandas.install() import pandas as pd` 

鉴于现代 GPU 相较于 CPU 的强大,这个库能够为用户提供显著的性能提升,而无需改变代码的编写方式。对于拥有合适硬件的用户来说,这种即插即用的性能提升可能是无价的。

加入我们的社区 Discord

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

留下评价!

感谢你购买这本由 Packt Publishing 出版的书籍——我们希望你喜欢它!你的反馈非常宝贵,能够帮助我们改进和成长。请花一点时间在 Amazon 上留下评论,这只需要一分钟,但对像你这样的读者来说意义重大。

扫描下方的二维码,领取你选择的免费电子书。

packt.link/NzOWQ

packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,及行业领先的工具,帮助你规划个人发展并推进职业生涯。欲了解更多信息,请访问我们的网站。

为什么订阅?

  • 通过来自 4000 多位行业专家的实用电子书和视频,减少学习时间,增加编程时间

  • 通过专为你打造的技能计划,提升你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制、粘贴、打印和收藏内容

www.packt.com上,你还可以阅读一系列免费的技术文章,注册各种免费的电子邮件通讯,并获得 Packt 图书和电子书的独家折扣和优惠。

你可能会喜欢的其他书籍

如果你喜欢这本书,你可能会对 Packt 出版的这些其他书籍感兴趣:

机器学习与 PyTorch 和 Scikit-Learn

塞巴斯蒂安·拉什卡(Sebastian Raschka)

刘宇熙(Hayden Liu)

瓦希德·米尔贾利利(Vahid Mirjalili)

ISBN:978-1-80181-931-2

  • 探索机器学习的数据框架、模型和技术

  • 使用 scikit-learn 进行机器学习,使用 PyTorch 进行深度学习

  • 在图像、文本等数据上训练机器学习分类器

  • 构建并训练神经网络、变换器和提升算法

  • 发现评估和调优模型的最佳实践

  • 使用回归分析预测连续目标结果

  • 通过情感分析深入挖掘文本和社交媒体数据

深度学习与 TensorFlow 和 Keras(第三版)

阿米塔·卡普尔(Amita Kapoor)

安东尼奥·古利(Antonio Gulli)

苏吉特·帕尔(Sujit Pal)

ISBN:978-1-80323-291-1

  • 学习如何使用流行的 GNNs 与 TensorFlow 进行图形挖掘任务

  • 探索变换器的世界,从预训练到微调再到评估

  • 将自监督学习应用于自然语言处理、计算机视觉和音频信号处理

  • 使用 TensorFlow 概率结合概率模型和深度学习模型

  • 在云端训练你的模型,并在真实环境中应用 TF

  • 使用 TensorFlow 2.x 和 Keras API 构建机器学习和深度学习系统

算法交易的机器学习(第二版)

斯特凡·詹森(Stefan Jansen)

ISBN:978-1-83921-771-5

  • 利用市场、基本面和替代性文本与图像数据

  • 使用统计学、Alphalens 和 SHAP 值研究和评估 alpha 因子

  • 实现机器学习技术解决投资和交易问题

  • 基于 Zipline 和 Backtrader 回测并评估机器学习的交易策略

  • 使用 pandas、NumPy 和 pyfolio 优化投资组合的风险和表现分析

  • 基于协整为美国股票和 ETF 创建配对交易策略

  • 使用 AlgoSeek 的高质量交易和报价数据训练梯度提升模型,以预测日内收益

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并立即申请。我们与成千上万的开发者和技术专业人士合作,帮助他们与全球技术社区分享他们的见解。你可以提交一个通用申请,申请我们正在招募的具体热门话题,或者提交你自己的想法。

加入我们在 Discord 上的社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

posted @ 2025-10-23 15:13  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报