Python-数据分析-PYDA-第三版-全-
Python 数据分析(PYDA)第三版(全)
译者:飞龙
Python 数据分析第三版
译者:飞龙
关于开放版本
第 3 版的《Python 数据分析》现在作为“开放获取”HTML 版本在此网站wesmckinney.com/book上提供,除了通常的印刷和电子书格式。该版本最初于 2022 年 8 月出版,将在未来几个月和年份内定期修正勘误。如果您发现任何勘误,请在此处报告。
一般来说,本网站的内容不得复制或复制。代码示例采用 MIT 许可证,可在GitHub或Gitee上找到,以及支持数据集。
如果您发现本书的在线版本有用,请考虑订购纸质版本或无 DRM 的电子书(PDF 和 EPUB 格式)以支持作者。
本书的网络版本是使用Quarto 出版系统创建的。
第 3 版的新内容是什么?
本书已更新到 pandas 2.0.0 和 Python 3.10。第 2 版和第 3 版之间的变化主要集中在将内容与自 2017 年以来 pandas 的变化保持最新。
更新历史
本网站将定期更新,以提供新的早期发布内容,并在出版后修复勘误。
-
2023 年 4 月 12 日:更新到 pandas 2.0.0 并修复一些代码示例。
-
2022 年 10 月 19 日:修复表格链接并添加eBooks.com 链接。
-
2022 年 9 月 20 日:最终出版后的网站更新,包括修复了一些小错误。
-
2022 年 7 月 22 日:在今年夏天后期出版之前,将副本编辑和其他改进纳入“QC1”生产阶段。
-
2022 年 5 月 18 日:更新开放获取版本的所有章节。包括来自技术审查反馈(谢谢!)的编辑,第三版的致谢以及其他准备工作,使本书准备好在 2022 年晚些时候印刷。
-
2022 年 2 月 13 日:更新开放获取版本,包括第 7 至第十章。
-
2022 年 1 月 23 日:首次开放获取版本,包括第 1 至第六章。
正文
前言
原文:
wesmckinney.com/book/preface译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
本书的第一版于 2012 年出版,当时 Python 的开源数据分析库,尤其是 pandas,非常新且快速发展。到了 2016 年和 2017 年写第二版时,我不仅需要将书更新为 Python 3.6(第一版使用 Python 2.7),还需要更新 pandas 在过去五年中发生的许多变化。现在是 2022 年,Python 语言的变化较少(我们现在使用 Python 3.10,3.11 将于 2022 年底发布),但 pandas 仍在不断发展。
在这第三版中,我的目标是将内容与当前版本的 Python、NumPy、pandas 和其他项目保持同步,同时对于讨论近几年出现的较新的 Python 项目保持相对保守。由于这本书已成为许多大学课程和职业人士的重要资源,我将尽量避免讨论可能在一两年内过时的主题。这样,纸质副本在 2023 年、2024 年甚至更久以后也不会太难理解。
第三版的一个新特性是托管在我的网站上的开放访问在线版本,网址为wesmckinney.com/book,可作为印刷版和数字版的所有者的资源和便利。我打算保持那里的内容相对及时更新,因此如果您拥有纸质书并遇到某些问题,请在那里查看最新的内容更改。
本书中使用的约定
本书中使用以下排版约定:
斜体
指示新术语、URL、电子邮件地址、文件名和文件扩展名。
等宽
用于程序清单,以及段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。
等宽粗体
显示用户应按照字面意思键入的命令或其他文本。
<等宽斜体>
显示应替换为用户提供的值或由上下文确定的值的文本。
提示:
此元素表示提示或建议。
注意:
此元素表示一般说明。
警告:
此元素表示警告或注意事项。
使用代码示例
您可以在本书的 GitHub 存储库中找到每章的数据文件和相关材料,网址为github.com/wesm/pydata-book,该存储库在 Gitee 上有镜像(供无法访问 GitHub 的用户使用),网址为gitee.com/wesmckinn/pydata-book。
这本书旨在帮助您完成工作。一般来说,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用本书中几个代码块的程序不需要许可。销售或分发 O'Reilly 图书中的示例需要许可。通过引用本书回答问题并引用示例代码不需要许可。将本书中大量示例代码合并到产品文档中需要许可。
我们感谢,但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Python for Data Analysis by Wes McKinney(O'Reilly)。版权所有 2022 年 Wes McKinney,978-1-098-10403-0。”
如果您觉得您对代码示例的使用超出了合理使用范围或上述许可,请随时通过 permissions@oreilly.com 与我们联系。
致谢
这项工作是多年来与世界各地许多人进行富有成果的讨论和合作的成果。我想感谢其中的一些人。
追悼:约翰·D·亨特(1968-2012)
我们亲爱的朋友和同事约翰·D·亨特在 2012 年 8 月 28 日与结肠癌搏斗后去世。这发生在我完成本书第一版最终手稿后不久。
约翰在 Python 科学和数据社区的影响和遗产难以估量。除了在 21 世纪初开发 matplotlib(当时 Python 并不那么流行)之外,他还帮助塑造了一代关键的开源开发者文化,这些开发者已经成为我们现在经常视为理所当然的 Python 生态系统的支柱。
我很幸运在 2010 年 1 月早期与约翰建立了联系,就在发布 pandas 0.1 后不久。他的启发和指导帮助我在最黑暗的时刻推动前进,实现了我对 pandas 和 Python 作为一流数据分析语言的愿景。
John 与 Fernando Pérez 和 Brian Granger 非常亲近,他们是 IPython、Jupyter 和 Python 社区中许多其他倡议的先驱。我们曾希望一起合作写一本书,但最终我成为了拥有最多空闲时间的人。我相信他会为我们在过去九年中所取得的成就感到自豪,无论是作为个人还是作为一个社区。
致谢第三版(2022 年)
自从我开始写这本书的第一版以来已经有十多年了,自从我最初作为 Python 程序员开始我的旅程以来已经有 15 年了。那时发生了很多变化!Python 已经从一个相对小众的数据分析语言发展成为最受欢迎、最广泛使用的语言,支持着数据科学、机器学习和人工智能工作的多数(如果不是大多数!)。
自 2013 年以来,我并没有积极参与 pandas 开源项目,但其全球开发者社区仍在蓬勃发展,成为以社区为中心的开源软件开发模式的典范。许多处理表格数据的“下一代”Python 项目直接模仿 pandas 的用户界面,因此该项目已经对 Python 数据科学生态系统未来的发展轨迹产生了持久的影响。
希望这本书能继续为想要学习如何在 Python 中处理数据的学生和个人提供宝贵的资源。
我特别感谢 O'Reilly 允许我在我的网站wesmckinney.com/book上发布这本书的“开放获取”版本,希望它能触达更多人,并帮助扩大数据分析领域的机会。J.J. Allaire 在帮助我将这本书从 Docbook XML“移植”到Quarto时是一个救星,Quarto 是一个出色的新科学技术出版系统,适用于印刷和网络。
特别感谢我的技术审阅者 Paul Barry、Jean-Christophe Leyder、Abdullah Karasan 和 William Jamir,他们的详细反馈极大地提高了内容的可读性、清晰度和可理解性。
致谢第二版(2017 年)
距离我在 2012 年 7 月完成这本书第一版手稿已经快五年了。很多事情发生了变化。Python 社区已经大幅增长,围绕它的开源软件生态系统也蓬勃发展。
如果不是 pandas 核心开发者们不懈的努力,这本书的新版将不会存在,他们已经将这个项目及其用户社区发展成为 Python 数据科学生态系统的支柱之一。这些人包括但不限于 Tom Augspurger、Joris van den Bossche、Chris Bartak、Phillip Cloud、gfyoung、Andy Hayden、Masaaki Horikoshi、Stephan Hoyer、Adam Klein、Wouter Overmeire、Jeff Reback、Chang She、Skipper Seabold、Jeff Tratner 和 y-p。
在撰写这本第二版时,我要感谢 O'Reilly 的工作人员在写作过程中耐心地帮助我。其中包括 Marie Beaugureau、Ben Lorica 和 Colleen Toporek。我再次有幸得到 Tom Augspurger、Paul Barry、Hugh Brown、Jonathan Coe 和 Andreas Müller 等杰出的技术审阅者的帮助。谢谢。
这本书的第一版已经被翻译成许多外语,包括中文、法语、德语、日语、韩语和俄语。翻译所有这些内容并让更广泛的受众获得是一项巨大且常常被忽视的工作。感谢您帮助更多世界上的人学习如何编程和使用数据分析工具。
在过去几年里,我很幸运地得到了 Cloudera 和 Two Sigma Investments 对我持续的开源开发工作的支持。随着开源软件项目相对于用户群体规模而言资源更加稀缺,企业为关键开源项目的开发提供支持变得越来越重要。这是正确的做法。
致谢第一版(2012)
如果没有许多人的支持,我很难写出这本书。
在 O'Reilly 的工作人员中,我非常感激我的编辑 Meghan Blanchette 和 Julie Steele,他们在整个过程中指导我。Mike Loukides 也在提案阶段与我合作,帮助使这本书成为现实。
我得到了许多人的技术审查。特别是 Martin Blais 和 Hugh Brown 在改进书中的示例、清晰度和组织方面提供了极大帮助。James Long,Drew Conway,Fernando Pérez,Brian Granger,Thomas Kluyver,Adam Klein,Josh Klein,Chang She 和 Stéfan van der Walt 分别审查了一个或多个章节,从许多不同的角度提供了有针对性的反馈。
我从数据社区的朋友和同事那里得到了许多出色的示例和数据集的创意,其中包括:Mike Dewar,Jeff Hammerbacher,James Johndrow,Kristian Lum,Adam Klein,Hilary Mason,Chang She 和 Ashley Williams。
当然,我要感谢许多开源科学 Python 社区的领导者,他们为我的开发工作奠定了基础,并在我写这本书时给予了鼓励:IPython 核心团队(Fernando Pérez,Brian Granger,Min Ragan-Kelly,Thomas Kluyver 等),John Hunter,Skipper Seabold,Travis Oliphant,Peter Wang,Eric Jones,Robert Kern,Josef Perktold,Francesc Alted,Chris Fonnesbeck 等等。还有许多其他人,无法一一列举。还有一些人在这个过程中提供了大量的支持、想法和鼓励:Drew Conway,Sean Taylor,Giuseppe Paleologo,Jared Lander,David Epstein,John Krowas,Joshua Bloom,Den Pilsworth,John Myles-White 等等。
我还要感谢一些在我成长过程中的人。首先是我的前 AQR 同事,多年来一直在我的 pandas 工作中支持我:Alex Reyfman,Michael Wong,Tim Sargen,Oktay Kurbanov,Matthew Tschantz,Roni Israelov,Michael Katz,Ari Levine,Chris Uga,Prasad Ramanan,Ted Square 和 Hoon Kim。最后,我的学术导师 Haynes Miller(MIT)和 Mike West(Duke)。
2014 年,我得到了 Phillip Cloud 和 Joris van den Bossche 的重要帮助,更新了书中的代码示例,并修复了由于 pandas 变化而导致的一些不准确之处。
在个人方面,Casey 在写作过程中提供了宝贵的日常支持,容忍我在本已过度忙碌的日程表上拼凑出最终草稿时的起起伏伏。最后,我的父母 Bill 和 Kim 教导我始终追随梦想,永不妥协。
一、初步
原文:
wesmckinney.com/book/preliminaries译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
这本书关注的是在 Python 中操纵、处理、清理和处理数据的基本原理。我的目标是为 Python 编程语言及其面向数据的库生态系统和工具提供指南,使您能够成为一名有效的数据分析师。虽然书名中有“数据分析”一词,但重点特别放在 Python 编程、库和工具上,而不是数据分析方法论。这是您进行数据分析所需的 Python 编程。
在我 2012 年首次出版这本书之后不久,人们开始使用“数据科学”这个术语作为从简单的描述性统计到更高级的统计分析和机器学习等各种内容的总称。自那时以来,用于进行数据分析(或数据科学)的 Python 开源生态系统也显著扩展。现在有许多其他专门关注这些更高级方法的书籍。我希望这本书能够作为足够的准备,使您能够转向更具领域特定性的资源。
注意:
有些人可能将本书的大部分内容描述为“数据操纵”,而不是“数据分析”。我们还使用整理或整理这些术语来指代数据操纵。*### 什么样的数据?
当我说“数据”时,我确切指的是什么?主要关注的是结构化数据,这是一个故意模糊的术语,包括许多不同形式的常见数据,例如:
-
表格或类似电子表格的数据,其中每列可能是不同类型(字符串、数字、日期或其他)。这包括通常存储在关系数据库或制表符或逗号分隔文本文件中的各种数据。
-
多维数组(矩阵)。
-
由关键列相互关联的多个数据表(对 SQL 用户来说可能是主键或外键)。
-
均匀或不均匀间隔的时间序列。
这绝不是一个完整的列表。即使可能并不总是明显,大部分数据集都可以转换为更适合分析和建模的结构化形式。如果不行,可能可以从数据集中提取特征到结构化形式。例如,一组新闻文章可以处理成一个词频表,然后用于执行情感分析。
像 Microsoft Excel 这样的电子表格程序的大多数用户,可能是世界上最广泛使用的数据分析工具,对这些数据类型并不陌生。*## 1.2 为什么选择 Python 进行数据分析?
对许多人来说,Python 编程语言具有很强的吸引力。自 1991 年首次亮相以来,Python 已成为最受欢迎的解释性编程语言之一,与 Perl、Ruby 等一起。自 2005 年左右以来,Python 和 Ruby 特别受欢迎,用于构建网站,使用它们众多的 Web 框架,如 Rails(Ruby)和 Django(Python)。这些语言通常被称为“脚本”语言,因为它们可以用于快速编写小程序或脚本来自动化其他任务。我不喜欢“脚本语言”这个术语,因为它带有一种暗示,即它们不能用于构建严肃的软件。出于各种历史和文化原因,在解释性语言中,Python 已经发展成一个庞大而活跃的科学计算和数据分析社区。在过去的 20 年里,Python 已经从一个前沿或“自担风险”的科学计算语言发展成为学术界和工业界数据科学、机器学习和通用软件开发中最重要的语言之一。
对于数据分析、交互式计算和数据可视化,Python 不可避免地会与其他广泛使用的开源和商业编程语言和工具进行比较,如 R、MATLAB、SAS、Stata 等。近年来,Python 改进的开源库(如 pandas 和 scikit-learn)使其成为数据分析任务的热门选择。结合 Python 在通用软件工程方面的整体实力,它是构建数据应用程序的主要语言的绝佳选择。
Python 作为胶水
Python 在科学计算中的成功部分在于轻松集成 C、C++和 FORTRAN 代码。大多数现代计算环境共享一组类似的传统 FORTRAN 和 C 库,用于进行线性代数、优化、积分、快速傅里叶变换等算法。许多公司和国家实验室使用 Python 将几十年的传统软件粘合在一起的故事也是如此。
许多程序由小部分代码组成,其中大部分时间都花在其中,大量“胶水代码”很少运行。在许多情况下,胶水代码的执行时间微不足道;最有价值的努力是在优化计算瓶颈上,有时通过将代码移动到像 C 这样的低级语言来实现。
解决“双语言”问题
在许多组织中,通常使用更专门的计算语言如 SAS 或 R 进行研究、原型设计和测试新想法,然后将这些想法移植为更大的生产系统的一部分,比如 Java、C#或 C++。人们越来越发现 Python 不仅适合用于研究和原型设计,也适合用于构建生产系统。当一个开发环境足够时,为什么要维护两个呢?我相信越来越多的公司会选择这条道路,因为让研究人员和软件工程师使用相同的编程工具集通常会带来重大的组织效益。
在过去的十年里,一些解决“双语言”问题的新方法出现了,比如 Julia 编程语言。在许多情况下,充分利用 Python 将需要使用低级语言如 C 或 C++编程,并创建 Python 绑定到该代码。也就是说,像 Numba 这样的“即时”(JIT)编译器技术提供了一种在 Python 编程环境中实现出色性能的方法,而无需离开 Python 编程环境。
为什么不用 Python?
虽然 Python 是构建许多种分析应用程序和通用系统的优秀环境,但也有一些用途不太适合 Python。
由于 Python 是一种解释性编程语言,通常大多数 Python 代码运行速度会比像 Java 或 C++这样的编译语言编写的代码慢得多。由于程序员时间通常比CPU 时间更有价值,许多人愿意做出这种权衡。然而,在具有非常低延迟或对资源利用要求苛刻的应用程序中(例如高频交易系统),花费时间以低级语言(但也低生产力)如 C++编程,以实现可能的最大性能,可能是值得的。
Python 可能是一个具有挑战性的语言,用于构建高度并发、多线程的应用程序,特别是具有许多 CPU 绑定线程的应用程序。造成这种情况的原因是它具有所谓的全局解释器锁(GIL),这是一种机制,防止解释器一次执行多个 Python 指令。GIL 存在的技术原因超出了本书的范围。虽然在许多大数据处理应用中,可能需要一组计算机集群来在合理的时间内处理数据集,但仍然存在一些情况,其中单进程、多线程系统是可取的。
这并不是说 Python 不能执行真正的多线程、并行代码。使用本地多线程(在 C 或 C++中)的 Python C 扩展可以在不受 GIL 影响的情况下并行运行代码,只要它们不需要经常与 Python 对象交互。
1.3 必要的 Python 库
对于那些对 Python 数据生态系统和本书中使用的库不太熟悉的人,我将简要介绍其中一些。
NumPy
NumPy,简称 Numerical Python,长期以来一直是 Python 中数值计算的基石。它提供了大多数涉及 Python 中数值数据的科学应用所需的数据结构、算法和库粘合剂。NumPy 包含,除其他内容外:
-
快速高效的多维数组对象ndarray
-
执行数组元素计算或数组之间的数学运算的函数
-
用于读取和写入基于数组的数据集到磁盘的工具
-
线性代数运算、傅里叶变换和随机数生成
-
成熟的 C API,用于使 Python 扩展和本地 C 或 C++代码能够访问 NumPy 的数据结构和计算功能
除了 NumPy 为 Python 增加的快速数组处理功能外,它在数据分析中的主要用途之一是作为数据容器,在算法和库之间传递数据。对于数值数据,NumPy 数组比其他内置 Python 数据结构更有效地存储和操作数据。此外,使用低级语言(如 C 或 FORTRAN)编写的库可以在 NumPy 数组中存储的数据上操作,而无需将数据复制到其他内存表示中。因此,许多 Python 的数值计算工具要么将 NumPy 数组作为主要数据结构,要么针对与 NumPy 的互操作性。
pandas
pandas提供了高级数据结构和函数,旨在使处理结构化或表格数据变得直观和灵活。自 2010 年出现以来,它已经帮助 Python 成为一个强大和高效的数据分析环境。本书中将使用的 pandas 中的主要对象是 DataFrame,这是一个表格化的、以列为导向的数据结构,具有行和列标签,以及 Series,这是一个一维带标签的数组对象。
pandas 将 NumPy 的数组计算思想与电子表格和关系数据库(如 SQL)中发现的数据操作能力相结合。它提供了方便的索引功能,使您能够重新塑造、切片、执行聚合操作和选择数据子集。由于数据操作、准备和清理在数据分析中是如此重要,pandas 是本书的主要关注点之一。
作为背景,我在 2008 年初在 AQR Capital Management 期间开始构建 pandas,这是一家量化投资管理公司。当时,我有一套明确的要求,任何单一工具都无法很好地满足:
-
具有带有标签轴的数据结构,支持自动或显式数据对齐——这可以防止由于数据不对齐和来自不同来源的不同索引数据而导致的常见错误
-
集成的时间序列功能
-
相同的数据结构处理时间序列数据和非时间序列数据
-
保留元数据的算术操作和减少
-
灵活处理缺失数据
-
在流行数据库(例如基于 SQL 的数据库)中找到的合并和其他关系操作
我希望能够在一个地方完成所有这些事情,最好是在一种适合通用软件开发的语言中。Python 是这方面的一个很好的候选语言,但当时并不存在一个集成了这些功能的数据结构和工具集。由于最初构建是为了解决金融和业务分析问题,pandas 具有特别深入的时间序列功能和适用于处理由业务流程生成的时间索引数据的工具。
我在 2011 年和 2012 年的大部分时间里与我以前的 AQR 同事 Adam Klein 和 Chang She 一起扩展了 pandas 的功能。2013 年,我停止了日常项目开发的参与,pandas 自那时起已成为一个完全由社区拥有和维护的项目,全球范围内有超过两千名独特贡献者。
对于使用 R 语言进行统计计算的用户,DataFrame 这个名字将是熟悉的,因为该对象是根据类似的 R data.frame对象命名的。与 Python 不同,数据框内置于 R 编程语言及其标准库中。因此,pandas 中许多功能通常要么是 R 核心实现的一部分,要么是由附加包提供的。
pandas 这个名字本身来源于panel data,这是一个描述多维结构化数据集的计量经济学术语,也是对Python 数据分析这个短语的一种变换。
matplotlib
matplotlib是用于生成图表和其他二维数据可视化的最流行的 Python 库。最初由 John D. Hunter 创建,现在由一个庞大的开发团队维护。它专为创建适合出版的图表而设计。虽然 Python 程序员可以使用其他可视化库,但 matplotlib 仍然被广泛使用,并且与生态系统的其他部分相当好地集成。我认为它是默认可视化工具的一个安全选择。
IPython 和 Jupyter
IPython 项目始于 2001 年,是 Fernando Pérez 的一个副业项目,旨在打造更好的交互式 Python 解释器。在随后的 20 年里,它已成为现代 Python 数据堆栈中最重要的工具之一。虽然它本身不提供任何计算或数据分析工具,但 IPython 旨在用于交互式计算和软件开发工作。它鼓励执行-探索工作流程,而不是许多其他编程语言的典型编辑-编译-运行工作流程。它还提供了对操作系统的 shell 和文件系统的集成访问;这在许多情况下减少了在终端窗口和 Python 会话之间切换的需求。由于许多数据分析编码涉及探索、试错和迭代,IPython 可以帮助您更快地完成工作。
2014 年,Fernando 和 IPython 团队宣布了Jupyter 项目,这是一个更广泛的倡议,旨在设计与语言无关的交互式计算工具。IPython 网络笔记本变成了 Jupyter 笔记本,现在支持超过 40 种编程语言。IPython 系统现在可以作为使用 Python 与 Jupyter 的内核(编程语言模式)。
IPython 本身已成为更广泛的 Jupyter 开源项目的组成部分,为交互式和探索性计算提供了一个高效的环境。它最古老和最简单的“模式”是作为一个增强的 Python shell,旨在加速 Python 代码的编写、测试和调试。您还可以通过 Jupyter 笔记本使用 IPython 系统。
Jupyter 笔记本系统还允许您在 Markdown 和 HTML 中编写内容,为您提供了一种创建包含代码和文本的丰富文档的方式。
我个人经常在我的 Python 工作中使用 IPython 和 Jupyter,无论是运行、调试还是测试代码。
在GitHub 上的附带书籍材料中,您将找到包含每章代码示例的 Jupyter 笔记本。如果您无法访问 GitHub,您可以尝试Gitee 上的镜像。
SciPy
SciPy是一个解决科学计算中一些基础问题的包集合。以下是它在各个模块中包含的一些工具:
scipy.integrate
数值积分例程和微分方程求解器
scipy.linalg
线性代数例程和矩阵分解,扩展到numpy.linalg提供的范围之外
scipy.optimize
函数优化器(最小化器)和根查找算法
scipy.signal
信号处理工具
scipy.sparse
稀疏矩阵和稀疏线性系统求解器
scipy.special
SPECFUN 的包装器,一个实现许多常见数学函数(如gamma函数)的 FORTRAN 库
scipy.stats
标准连续和离散概率分布(密度函数、采样器、连续分布函数)、各种统计检验和更多描述性统计
NumPy 和 SciPy 共同构成了许多传统科学计算应用的相当完整和成熟的计算基础。
scikit-learn
自 2007 年项目开始以来,scikit-learn已成为 Python 程序员的首选通用机器学习工具包。截至撰写本文时,超过两千名不同的个人为该项目贡献了代码。它包括用于以下模型的子模块:
-
分类:SVM、最近邻、随机森林、逻辑回归等
-
回归:Lasso、岭回归等
-
聚类:k-means、谱聚类等
-
降维:PCA、特征选择、矩阵分解等
-
模型选择:网格搜索、交叉验证、度量
-
预处理:特征提取、归一化
除了 pandas、statsmodels 和 IPython 之外,scikit-learn 对于使 Python 成为一种高效的数据科学编程语言至关重要。虽然我无法在本书中包含对 scikit-learn 的全面指南,但我将简要介绍一些其模型以及如何将其与本书中提供的其他工具一起使用。
statsmodels
statsmodels是一个统计分析包,由斯坦福大学统计学教授 Jonathan Taylor 的工作启发而来,他实现了 R 编程语言中流行的一些回归分析模型。Skipper Seabold 和 Josef Perktold 于 2010 年正式创建了新的 statsmodels 项目,自那时以来,该项目已经发展成为一群积极参与的用户和贡献者。Nathaniel Smith 开发了 Patsy 项目,该项目提供了一个受 R 公式系统启发的用于 statsmodels 的公式或模型规范框架。
与 scikit-learn 相比,statsmodels 包含用于经典(主要是频率主义)统计和计量经济学的算法。这包括诸如:
-
回归模型:线性回归、广义线性模型、鲁棒线性模型、线性混合效应模型等
-
方差分析(ANOVA)
-
时间序列分析:AR、ARMA、ARIMA、VAR 和其他模型
-
非参数方法:核密度估计、核回归
-
统计模型结果的可视化
statsmodels 更专注于统计推断,为参数提供不确定性估计和p-值。相比之下,scikit-learn 更注重预测。
与 scikit-learn 一样,我将简要介绍 statsmodels 以及如何与 NumPy 和 pandas 一起使用它。
其他包
在 2022 年,有许多其他 Python 库可能会在关于数据科学的书中讨论。这包括一些较新的项目,如 TensorFlow 或 PyTorch,这些项目已经成为机器学习或人工智能工作中流行的工具。现在有其他更专注于这些项目的书籍,我建议使用本书来建立通用 Python 数据处理的基础。然后,您应该准备好转向更高级的资源,这些资源可能假定一定水平的专业知识。
1.4 安装和设置
由于每个人都在不同的应用中使用 Python,因此设置 Python 并获取必要的附加包没有单一的解决方案。许多读者可能没有完整的 Python 开发环境,适合跟随本书,因此我将在每个操作系统上提供详细的设置说明。我将使用 Miniconda,这是 conda 软件包管理器的最小安装,以及conda-forge,这是一个基于 conda 的社区维护的软件分发。本书始终使用 Python 3.10,但如果您是在未来阅读,欢迎安装更新版本的 Python。
如果由于某种原因,这些说明在您阅读时已过时,您可以查看我的书籍网站,我将努力保持最新安装说明的更新。
Windows 上的 Miniconda
要在 Windows 上开始,请从https://conda.io下载最新 Python 版本(目前为 3.9)的 Miniconda 安装程序。我建议按照 conda 网站上提供的 Windows 安装说明进行安装,这些说明可能在本书出版时和您阅读时之间发生了变化。大多数人会想要 64 位版本,但如果这在您的 Windows 机器上无法运行,您可以安装 32 位版本。
当提示是否仅为自己安装还是为系统上的所有用户安装时,请选择最适合您的选项。仅为自己安装将足以跟随本书。它还会询问您是否要将 Miniconda 添加到系统 PATH 环境变量中。如果选择此选项(我通常会这样做),则此 Miniconda 安装可能会覆盖您已安装的其他 Python 版本。如果不这样做,那么您将需要使用安装的 Window 开始菜单快捷方式才能使用此 Miniconda。此开始菜单条目可能称为“Anaconda3 (64 位)”。
我假设您还没有将 Miniconda 添加到系统路径中。要验证配置是否正确,请在“开始”菜单下的“Anaconda3 (64 位)”中打开“Anaconda Prompt (Miniconda3)”条目。然后尝试通过输入python来启动 Python 解释器。您应该会看到类似以下的消息:
(base) C:\Users\Wes>python
Python 3.9 [MSC v.1916 64 bit (AMD64)] :: Anaconda, Inc. on win32
Type "help", "copyright", "credits" or "license" for more information.
>>>
要退出 Python shell,请输入exit()并按 Enter 键。
GNU/Linux
Linux 的详细信息会根据您的 Linux 发行版类型有所不同,但在这里我提供了 Debian、Ubuntu、CentOS 和 Fedora 等发行版的详细信息。设置与 macOS 类似,唯一的区别是 Miniconda 的安装方式。大多数读者会想要下载默认的 64 位安装程序文件,这是针对 x86 架构的(但未来可能会有更多用户使用基于 aarch64 的 Linux 机器)。安装程序是一个必须在终端中执行的 shell 脚本。然后您将会得到一个类似Miniconda3-latest-Linux-x86_64.sh的文件。要安装它,请使用bash执行此脚本:
$ bash Miniconda3-latest-Linux-x86_64.sh
注意
一些 Linux 发行版在其软件包管理器中具有所有所需的 Python 软件包(在某些情况下是过时版本),可以使用类似 apt 的工具进行安装。这里描述的设置使用 Miniconda,因为它在各种发行版中都很容易重现,并且更简单地升级软件包到最新版本。
您可以选择将 Miniconda 文件放在哪里。我建议将文件安装在您的主目录中的默认位置;例如,/home/$USER/miniconda(自然包括您的用户名)。
安装程序会询问您是否希望修改您的 shell 脚本以自动激活 Miniconda。我建议这样做(选择“是”)以方便起见。
安装完成后,启动一个新的终端进程并验证您是否已经安装了新的 Miniconda:
(base) $ python
Python 3.9 | (main) [GCC 10.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
要退出 Python shell,请输入exit()并按 Enter 键或按 Ctrl-D。*### macOS 上的 Miniconda
下载 macOS Miniconda 安装程序,应该命名为Miniconda3-latest-MacOSX-arm64.sh,适用于 2020 年以后发布的基于 Apple Silicon 的 macOS 计算机,或者Miniconda3-latest-MacOSX-x86_64.sh,适用于 2020 年之前发布的基于 Intel 的 Mac。在 macOS 中打开终端应用程序,并通过使用bash执行安装程序(很可能在您的Downloads目录中)来安装:
$ bash $HOME/Downloads/Miniconda3-latest-MacOSX-arm64.sh
当安装程序运行时,默认情况下会自动在默认 shell 环境和默认 shell 配置文件中配置 Miniconda。这可能位于/Users/$USER/.zshrc。我建议让它这样做;如果您不想让安装程序修改默认的 shell 环境,您需要查阅 Miniconda 文档以便继续。
要验证一切是否正常工作,请尝试在系统 shell 中启动 Python(打开终端应用程序以获取命令提示符):
$ python
Python 3.9 (main) [Clang 12.0.1 ] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
要退出 shell,请按 Ctrl-D 或输入exit()并按 Enter 键。
安装必要的软件包
现在我们已经在您的系统上设置了 Miniconda,是时候安装本书中将要使用的主要软件包了。第一步是通过在 shell 中运行以下命令将 conda-forge 配置为您的默认软件包渠道:
(base) $ conda config --add channels conda-forge
(base) $ conda config --set channel_priority strict
现在,我们将使用 Python 3.10 使用conda create命令创建一个新的 conda“环境”:
(base) $ conda create -y -n pydata-book python=3.10
安装完成后,请使用conda activate激活环境:
(base) $ conda activate pydata-book
(pydata-book) $
注意
每次打开新终端时,都需要使用conda activate来激活您的环境。您可以随时通过在终端中运行conda info来查看有关活动 conda 环境的信息。
现在,我们将使用conda install安装整本书中使用的基本软件包(以及它们的依赖项):
(pydata-book) $ conda install -y pandas jupyter matplotlib
我们还将使用其他软件包,但这些软件包可以在需要时稍后安装。有两种安装软件包的方法:使用conda install和pip install。在使用 Miniconda 时,应始终优先使用conda install,但某些软件包无法通过 conda 获得,因此如果conda install $package_name失败,请尝试pip install $package_name。
注意
如果您想安装本书其余部分使用的所有软件包,现在可以通过运行:
conda install lxml beautifulsoup4 html5lib openpyxl \
requests sqlalchemy seaborn scipy statsmodels \
patsy scikit-learn pyarrow pytables numba
在 Windows 上,将^替换为 Linux 和 macOS 上使用的行继续符\。
您可以使用conda update命令更新软件包:
conda update package_name
pip 还支持使用--upgrade标志进行升级:
pip install --upgrade package_name
您将有机会在整本书中尝试这些命令。
注意
虽然您可以使用 conda 和 pip 来安装软件包,但应避免使用 pip 更新最初使用 conda 安装的软件包(反之亦然),因为这样做可能会导致环境问题。我建议尽可能使用 conda,并仅在无法使用conda install安装软件包时才回退到 pip。
集成开发环境和文本编辑器
当被问及我的标准开发环境时,我几乎总是说“IPython 加上文本编辑器”。我通常会在 IPython 或 Jupyter 笔记本中编写程序,并逐步测试和调试每个部分。交互式地玩弄数据并直观验证特定数据操作是否正确也是很有用的。像 pandas 和 NumPy 这样的库旨在在 shell 中使用时提高生产力。
然而,在构建软件时,一些用户可能更喜欢使用功能更丰富的集成开发环境(IDE),而不是像 Emacs 或 Vim 这样的编辑器,后者在开箱即用时提供了更简洁的环境。以下是一些您可以探索的内容:
-
PyDev(免费),基于 Eclipse 平台构建的 IDE
-
来自 JetBrains 的 PyCharm(面向商业用户的订阅制,对于开源开发者免费)
-
Visual Studio 的 Python 工具(适用于 Windows 用户)
-
Spyder(免费),目前与 Anaconda 捆绑的 IDE
-
Komodo IDE(商业版)
由于 Python 的流行,大多数文本编辑器,如 VS Code 和 Sublime Text 2,都具有出色的 Python 支持。
1.5 社区和会议
除了通过互联网搜索外,各种科学和数据相关的 Python 邮件列表通常对问题有帮助并且响应迅速。一些可以参考的包括:
-
pydata:一个 Google Group 列表,用于与 Python 数据分析和 pandas 相关的问题
-
pystatsmodels:用于 statsmodels 或与 pandas 相关的问题
-
scikit-learn 邮件列表(scikit-learn@python.org)和 Python 中的机器学习,一般
-
numpy-discussion:用于与 NumPy 相关的问题
-
scipy-user:用于一般 SciPy 或科学 Python 问题
我故意没有发布这些 URL,以防它们发生变化。它们可以通过互联网搜索轻松找到。
每年举办许多全球各地的 Python 程序员会议。如果您想与其他分享您兴趣的 Python 程序员联系,我鼓励您尽可能参加其中一个。许多会议为那些无法支付入场费或旅行费的人提供财政支持。以下是一些可以考虑的会议:
-
PyCon 和 EuroPython:分别是在北美和欧洲举办的两个主要的一般 Python 会议
-
SciPy 和 EuroSciPy:分别是在北美和欧洲举办的面向科学计算的会议
-
PyData:面向数据科学和数据分析用例的全球系列区域会议
-
国际和地区 PyCon 会议(请参阅
pycon.org获取完整列表)
1.6 浏览本书
如果您以前从未在 Python 中编程过,您可能需要花一些时间阅读 第二章:Python 语言基础、IPython 和 Jupyter Notebooks 和 第三章:内置数据结构、函数和文件,我在这里放置了有关 Python 语言特性、IPython shell 和 Jupyter notebooks 的简明教程。这些内容是本书其余部分的先决知识。如果您已经有 Python 经验,您可以选择略读或跳过这些章节。
接下来,我简要介绍了 NumPy 的关键特性,将更高级的 NumPy 使用留给 附录 A:高级 NumPy。然后,我介绍了 pandas,并将本书的其余部分专注于应用 pandas、NumPy 和 matplotlib 进行数据分析主题(用于可视化)。我以递增的方式组织了材料,尽管在章节之间偶尔会有一些轻微的交叉,有些概念可能尚未介绍。
尽管读者可能对他们的工作有许多不同的最终目标,但通常所需的任务大致可以分为许多不同的广泛组别:
与外部世界互动
使用各种文件格式和数据存储进行读写
准备
清理、整理、合并、规范化、重塑、切片和切块以及转换数据以进行分析
转换
对数据集组应用数学和统计操作以派生新数据集(例如,通过组变量对大表进行聚合)
建模和计算
将您的数据连接到统计模型、机器学习算法或其他计算工具
演示
创建交互式或静态图形可视化或文本摘要
代码示例
本书中的大多数代码示例都显示了输入和输出,就像在 IPython shell 或 Jupyter notebooks 中执行时一样:
In [5]: CODE EXAMPLE
Out[5]: OUTPUT
当您看到像这样的代码示例时,意图是让您在编码环境中的 In 区块中键入示例代码,并通过按 Enter 键(或在 Jupyter 中按 Shift-Enter)执行它。您应该看到类似于 Out 区块中显示的输出。
我已更改了 NumPy 和 pandas 的默认控制台输出设置,以提高本书的可读性和简洁性。例如,您可能会看到在数字数据中打印更多位数的精度。要完全匹配书中显示的输出,您可以在运行代码示例之前执行以下 Python 代码:
import numpy as np
import pandas as pd
pd.options.display.max_columns = 20
pd.options.display.max_rows = 20
pd.options.display.max_colwidth = 80
np.set_printoptions(precision=4, suppress=True)
示例数据
每一章的示例数据集都托管在 GitHub 仓库 中(如果无法访问 GitHub,则可以在 Gitee 上的镜像)。您可以通过使用 Git 版本控制系统在命令行上下载这些数据,或者通过从网站下载仓库的 zip 文件来获取数据。如果遇到问题,请转到 书籍网站 获取有关获取书籍材料的最新说明。
如果您下载包含示例数据集的 zip 文件,则必须完全提取 zip 文件的内容到一个目录,并在终端中导航到该目录,然后才能继续运行本书的代码示例:
$ pwd
/home/wesm/book-materials
$ ls
appa.ipynb ch05.ipynb ch09.ipynb ch13.ipynb README.md
ch02.ipynb ch06.ipynb ch10.ipynb COPYING requirements.txt
ch03.ipynb ch07.ipynb ch11.ipynb datasets
ch04.ipynb ch08.ipynb ch12.ipynb examples
我已尽一切努力确保 GitHub 仓库包含重现示例所需的一切,但可能会出现一些错误或遗漏。如果有的话,请发送邮件至:book@wesmckinney.com。报告书中错误的最佳方式是在 O'Reilly 网站上的勘误页面上。
导入约定
Python 社区已经采用了许多常用模块的命名约定:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
import statsmodels as sm
这意味着当你看到np.arange时,这是对 NumPy 中arange函数的引用。这样做是因为在 Python 软件开发中,从像 NumPy 这样的大型包中导入所有内容(from numpy import *)被认为是不良实践。
二、Python 语言基础,IPython 和 Jupyter 笔记本
原文:
wesmckinney.com/book/python-basics译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
当我在 2011 年和 2012 年编写本书的第一版时,关于在 Python 中进行数据分析的学习资源较少。这在一定程度上是一个鸡生蛋的问题;许多我们现在认为理所当然的库,如 pandas、scikit-learn 和 statsmodels,在那时相对不成熟。现在是 2022 年,现在有越来越多关于数据科学、数据分析和机器学习的文献,补充了关于面向计算科学家、物理学家和其他研究领域专业人士的通用科学计算的先前作品。还有关于学习 Python 编程语言本身并成为有效软件工程师的优秀书籍。
由于本书旨在作为在 Python 中处理数据的入门文本,我认为从数据操作的角度对 Python 的内置数据结构和库的一些最重要特性进行自包含概述是有价值的。因此,我将在本章和第三章:内置数据结构、函数和文件中提供大致足够的信息,以便您能够跟随本书的其余部分。
本书的很大一部分关注于基于表格的分析和数据准备工具,用于处理足够小以适合个人计算机的数据集。要使用这些工具,有时您必须对混乱的数据进行一些整理,将其整理成更整洁的表格(或结构化)形式。幸运的是,Python 是做这些事情的理想语言。您对 Python 语言及其内置数据类型的熟练程度越高,准备新数据集进行分析就会变得更容易。
本书中的一些工具最好在实时的 IPython 或 Jupyter 会话中进行探索。一旦您学会如何启动 IPython 和 Jupyter,我建议您跟着示例进行实验和尝试不同的东西。与任何基于键盘的类似控制台的环境一样,熟悉常用命令也是学习曲线的一部分。
注意:本章未涵盖一些入门级 Python 概念,如类和面向对象编程,这些概念在您进入 Python 数据分析领域时可能会有用。
为了加深您对 Python 语言的了解,我建议您将本章与官方 Python 教程以及可能是许多优秀的通用 Python 编程书籍结合起来阅读。一些建议让您开始包括:
-
《Python Cookbook》,第三版,作者 David Beazley 和 Brian K. Jones(O'Reilly)
-
《流畅的 Python》,作者 Luciano Ramalho(O'Reilly)
-
《Effective Python》,第二版,作者 Brett Slatkin(Addison-Wesley)## 2.1 Python 解释器
Python 是一种解释性语言。Python 解释器通过逐条执行程序来运行程序。标准的交互式 Python 解释器可以通过命令行使用python命令调用:
$ python
Python 3.10.4 | packaged by conda-forge | (main, Mar 24 2022, 17:38:57)
[GCC 10.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = 5
>>> print(a)
5
您看到的>>>是您将输入代码表达式的提示。要退出 Python 解释器,您可以输入exit()或按 Ctrl-D(仅适用于 Linux 和 macOS)。
运行 Python 程序就像调用python并将.py文件作为第一个参数一样简单。假设我们已经创建了包含以下内容的hello_world.py文件:
print("Hello world")
您可以通过执行以下命令来运行它(hello_world.py文件必须在您当前的工作终端目录中):
$ python hello_world.py
Hello world
虽然一些 Python 程序员以这种方式执行他们的所有 Python 代码,但进行数据分析或科学计算的人使用 IPython,这是一个增强的 Python 解释器,或者使用 Jupyter 笔记本,这是最初在 IPython 项目中创建的基于 Web 的代码笔记本。我在本章中介绍了如何使用 IPython 和 Jupyter,并在附录 A:高级 NumPy 中深入探讨了 IPython 功能。当您使用%run命令时,IPython 会在同一进程中执行指定文件中的代码,使您能够在完成时交互地探索结果:
$ ipython
Python 3.10.4 | packaged by conda-forge | (main, Mar 24 2022, 17:38:57)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.31.1 -- An enhanced Interactive Python. Type '?' for help.
In [1]: %run hello_world.py
Hello world
In [2]:
默认的 IPython 提示采用带编号的In [2]:样式,与标准的>>>提示相比。
2.2 IPython 基础知识
在本节中,我将带您快速了解 IPython shell 和 Jupyter 笔记本,并介绍一些基本概念。
运行 IPython Shell
您可以像启动常规 Python 解释器一样在命令行上启动 IPython shell,只是使用ipython命令:
$ ipython
Python 3.10.4 | packaged by conda-forge | (main, Mar 24 2022, 17:38:57)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.31.1 -- An enhanced Interactive Python. Type '?' for help.
In [1]: a = 5
In [2]: a
Out[2]: 5
您可以通过键入 Python 语句并按回车键(或 Enter 键)来执行任意 Python 语句。当您只输入一个变量时,IPython 会呈现对象的字符串表示:
In [5]: import numpy as np
In [6]: data = [np.random.standard_normal() for i in range(7)]
In [7]: data
Out[7]:
[-0.20470765948471295,
0.47894333805754824,
-0.5194387150567381,
-0.55573030434749,
1.9657805725027142,
1.3934058329729904,
0.09290787674371767]
前两行是 Python 代码语句;第二个语句创建了一个名为data的变量,指向一个新创建的列表。最后一行在控制台中打印了data的值。
许多种类的 Python 对象都被格式化为更易读或漂亮打印,这与使用print进行正常打印不同。如果您在标准 Python 解释器中打印上述data变量,它将不太易读:
>>> import numpy as np
>>> data = [np.random.standard_normal() for i in range(7)]
>>> print(data)
>>> data
[-0.5767699931966723, -0.1010317773535111, -1.7841005313329152,
-1.524392126408841, 0.22191374220117385, -1.9835710588082562,
-1.6081963964963528]
IPython 还提供了执行任意代码块(通过一种略微夸张的复制粘贴方法)和整个 Python 脚本的功能。您还可以使用 Jupyter 笔记本来处理更大的代码块,我们很快就会看到。
在终端中运行 Jupyter Notebook
Jupyter 项目的一个主要组件是笔记本,一种用于代码、文本(包括 Markdown)、数据可视化和其他输出的交互式文档。Jupyter 笔记本与内核交互,这些内核是针对不同编程语言的 Jupyter 交互式计算协议的实现。Python Jupyter 内核使用 IPython 系统作为其基础行为。
要启动 Jupyter,请在终端中运行命令jupyter notebook:
$ jupyter notebook
[I 15:20:52.739 NotebookApp] Serving notebooks from local directory:
/home/wesm/code/pydata-book
[I 15:20:52.739 NotebookApp] 0 active kernels
[I 15:20:52.739 NotebookApp] The Jupyter Notebook is running at:
http://localhost:8888/?token=0a77b52fefe52ab83e3c35dff8de121e4bb443a63f2d...
[I 15:20:52.740 NotebookApp] Use Control-C to stop this server and shut down
all kernels (twice to skip confirmation).
Created new window in existing browser session.
To access the notebook, open this file in a browser:
file:///home/wesm/.local/share/jupyter/runtime/nbserver-185259-open.html
Or copy and paste one of these URLs:
http://localhost:8888/?token=0a77b52fefe52ab83e3c35dff8de121e4...
or http://127.0.0.1:8888/?token=0a77b52fefe52ab83e3c35dff8de121e4...
在许多平台上,Jupyter 会自动在默认的 Web 浏览器中打开(除非您使用--no-browser启动)。否则,您可以导航到启动笔记本时打印的 HTTP 地址,例如http://localhost:8888/?token=0a77b52fefe52ab83e3c35dff8de121e4bb443a63f2d3055。在 Google Chrome 中查看图 2.1。
注意
许多人将 Jupyter 用作本地计算环境,但它也可以部署在服务器上并远程访问。我不会在这里涵盖这些细节,但如果这与您的需求相关,我鼓励您在互联网上探索这个主题。

图 2.1:Jupyter 笔记本首页
要创建一个新笔记本,点击“New”按钮并选择“Python 3”选项。您应该看到类似于图 2.2 的内容。如果这是您第一次尝试,请尝试点击空的代码“单元格”并输入一行 Python 代码。然后按 Shift-Enter 执行它。

图 2.2:Jupyter 新笔记本视图
当您保存笔记本(请参见笔记本文件菜单下的“保存和检查点”)时,它将创建一个扩展名为.ipynb的文件。这是一种自包含的文件格式,包含当前笔记本中的所有内容(包括任何已评估的代码输出)。其他 Jupyter 用户可以加载和编辑这些文件。
要重命名打开的笔记本,请单击页面顶部的笔记本标题,然后键入新标题,完成后按 Enter。
要加载现有笔记本,请将文件放在启动笔记本进程的同一目录中(或其中的子文件夹),然后从登录页面点击名称。您可以尝试使用 GitHub 上我的wesm/pydata-book存储库中的笔记本。请参见图 2.3。
当您想要关闭笔记本时,请单击文件菜单,然后选择“关闭并停止”。如果您只是关闭浏览器选项卡,则与笔记本相关联的 Python 进程将继续在后台运行。
虽然 Jupyter 笔记本可能感觉与 IPython shell 有所不同,但本章中的几乎所有命令和工具都可以在任何环境中使用。

图 2.3:现有笔记本的 Jupyter 示例视图
Tab Completion
从表面上看,IPython shell 看起来像标准终端 Python 解释器的外观不同版本(使用python调用)。与标准 Python shell 相比,IPython shell 的一个主要改进是制表完成,在许多 IDE 或其他交互式计算分析环境中都可以找到。在 shell 中输入表达式时,按 Tab 键将搜索命名空间以查找与您迄今为止键入的字符匹配的任何变量(对象、函数等),并在方便的下拉菜单中显示结果:
In [1]: an_apple = 27
In [2]: an_example = 42
In [3]: an<Tab>
an_apple an_example any
在此示例中,请注意 IPython 显示了我定义的两个变量以及内置函数any。此外,在键入句点后,您还可以完成任何对象的方法和属性:
In [3]: b = [1, 2, 3]
In [4]: b.<Tab>
append() count() insert() reverse()
clear() extend() pop() sort()
copy() index() remove()
模块也是如此:
In [1]: import datetime
In [2]: datetime.<Tab>
date MAXYEAR timedelta
datetime MINYEAR timezone
datetime_CAPI time tzinfo
注意
请注意,默认情况下,IPython 隐藏以下划线开头的方法和属性,例如魔术方法和内部“私有”方法和属性,以避免显示混乱(并使初学者感到困惑!)。这些也可以通过制表完成,但您必须首先键入下划线才能看到它们。如果您希望始终在制表完成中看到此类方法,请更改 IPython 配置中的此设置。请参阅IPython 文档以了解如何执行此操作。
制表完成在许多上下文中起作用,不仅限于搜索交互式命名空间并完成对象或模块属性。在键入任何看起来像文件路径的内容(即使在 Python 字符串中),按 Tab 键将完成与您键入的内容匹配的计算机文件系统上的任何内容。
结合%run命令(请参见附录 B.2.1:%run命令),此功能可以为您节省许多按键。
制表完成还可以节省函数关键字参数(包括=符号!)的完成时间。请参见图 2.4。

图 2.4:在 Jupyter 笔记本中自动完成函数关键字
我们稍后将更仔细地查看函数。
内省
在变量前或后使用问号(?)将显示有关对象的一些常规信息:
In [1]: b = [1, 2, 3]
In [2]: b?
Type: list
String form: [1, 2, 3]
Length: 3
Docstring:
Built-in mutable sequence.
If no argument is given, the constructor creates a new empty list.
The argument must be an iterable if specified.
In [3]: print?
Docstring:
print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)
Prints the values to a stream, or to sys.stdout by default.
Optional keyword arguments:
file: a file-like object (stream); defaults to the current sys.stdout.
sep: string inserted between values, default a space.
end: string appended after the last value, default a newline.
flush: whether to forcibly flush the stream.
Type: builtin_function_or_method
这被称为对象内省。如果对象是函数或实例方法,则如果定义了文档字符串,它也将显示出来。假设我们编写了以下函数(您可以在 IPython 或 Jupyter 中重现):
def add_numbers(a, b):
"""
Add two numbers together
Returns
-------
the_sum : type of arguments
"""
return a + b
然后使用?显示文档字符串:
In [6]: add_numbers?
Signature: add_numbers(a, b)
Docstring:
Add two numbers together
Returns
-------
the_sum : type of arguments
File: <ipython-input-9-6a548a216e27>
Type: function
?还有一个最终的用途,就是在 IPython 命名空间中进行搜索,类似于标准的 Unix 或 Windows 命令行。与通配符(*)结合的一系列字符将显示所有与通配符表达式匹配的名称。例如,我们可以获取包含load的顶级 NumPy 命名空间中的所有函数列表:
In [9]: import numpy as np
In [10]: np.*load*?
np.__loader__
np.load
np.loads
np.loadtxt
2.3 Python 语言基础
在本节中,我将为您概述基本的 Python 编程概念和语言机制。在下一章中,我将更详细地介绍 Python 数据结构、函数和其他内置工具。
语言语义
Python 语言设计的一个重要特点是其对可读性、简单性和明确性的强调。有些人甚至将其比作“可执行的伪代码”。
缩进,而不是大括号
Python 使用空格(制表符或空格)来结构化代码,而不是像 R、C++、Java 和 Perl 等许多其他语言那样使用大括号。考虑一个排序算法中的for循环:
for x in array:
if x < pivot:
less.append(x)
else:
greater.append(x)
冒号表示缩进代码块的开始,之后所有代码都必须缩进相同的量,直到块的结束。
无论你喜欢还是讨厌,对于 Python 程序员来说,有意义的空白是一个事实。虽然一开始可能会感到陌生,但希望你能逐渐习惯它。
注意
我强烈建议使用四个空格作为默认缩进,并用四个空格替换制表符。许多文本编辑器都有一个设置,可以自动将制表符替换为空格(请这样做!)。IPython 和 Jupyter 笔记本会在冒号后的新行自动插入四个空格,并用四个空格替换制表符。
正如你现在所看到的,Python 语句也不需要以分号结尾。但是,分号可以用来在单行上分隔多个语句:
a = 5; b = 6; c = 7
在一行上放置多个语句通常在 Python 中是不鼓励的,因为这可能会使代码变得不太可读。
一切都是对象
Python 语言的一个重要特点是其对象模型的一致性。每个数字、字符串、数据结构、函数、类、模块等都存在于 Python 解释器中的自己的“盒子”中,这被称为Python 对象。每个对象都有一个关联的类型(例如整数、字符串或函数)和内部数据。实际上,这使得语言非常灵活,因为即使函数也可以像任何其他对象一样对待。
注释
由井号(井号)#引导的任何文本都会被 Python 解释器忽略。这通常用于向代码添加注释。有时您可能还想排除某些代码块而不删除它们。一种解决方案是注释掉代码:
results = []
for line in file_handle:
# keep the empty lines for now
# if len(line) == 0:
# continue
results.append(line.replace("foo", "bar"))
注释也可以出现在执行代码的行之后。虽然一些程序员更喜欢将注释放在特定代码行之前的行中,但有时这样做也是有用的:
print("Reached this line") # Simple status report
函数和对象方法调用
使用括号调用函数并传递零个或多个参数,可选地将返回的值赋给一个变量:
result = f(x, y, z)
g()
Python 中几乎每个对象都有附加的函数,称为方法,这些函数可以访问对象的内部内容。您可以使用以下语法调用它们:
obj.some_method(x, y, z)
函数可以接受位置和关键字参数:
result = f(a, b, c, d=5, e="foo")
我们稍后会更详细地看一下这个。
变量和参数传递
在 Python 中赋值变量(或名称)时,您正在创建对等号右侧显示的对象的引用。在实际操作中,考虑一个整数列表:
In [8]: a = [1, 2, 3]
假设我们将a赋给一个新变量b:
In [9]: b = a
In [10]: b
Out[10]: [1, 2, 3]
在一些语言中,对b的赋值将导致数据[1, 2, 3]被复制。在 Python 中,a和b实际上现在指向同一个对象,即原始列表[1, 2, 3](请参见图 2.5 的模拟)。您可以通过向a附加一个元素,然后检查b来证明这一点:
In [11]: a.append(4)
In [12]: b
Out[12]: [1, 2, 3, 4]

图 2.5:同一对象的两个引用
了解 Python 中引用的语义以及何时、如何以及为什么数据被复制,在处理 Python 中的大型数据集时尤为重要。
注意
赋值也被称为绑定,因为我们正在将一个名称绑定到一个对象。已经分配的变量名称有时可能被称为绑定变量。
当您将对象作为参数传递给函数时,将创建新的本地变量引用原始对象,而不进行任何复制。如果在函数内部将一个新对象绑定到一个变量,那么它不会覆盖函数外部(“父范围”)具有相同名称的变量。因此,可以更改可变参数的内部。假设我们有以下函数:
In [13]: def append_element(some_list, element):
....: some_list.append(element)
然后我们有:
In [14]: data = [1, 2, 3]
In [15]: append_element(data, 4)
In [16]: data
Out[16]: [1, 2, 3, 4]
动态引用,强类型
Python 中的变量没有与之关联的固有类型;通过赋值,变量可以引用不同类型的对象。以下情况没有问题:
In [17]: a = 5
In [18]: type(a)
Out[18]: int
In [19]: a = "foo"
In [20]: type(a)
Out[20]: str
变量是特定命名空间内对象的名称;类型信息存储在对象本身中。一些观察者可能匆忙得出结论,认为 Python 不是一种“类型化语言”。这是不正确的;考虑这个例子:
In [21]: "5" + 5
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-21-7fe5aa79f268> in <module>
----> 1 "5" + 5
TypeError: can only concatenate str (not "int") to str
在某些语言中,字符串'5'可能会被隐式转换(或转换)为整数,从而得到 10。在其他语言中,整数5可能会被转换为字符串,从而得到连接的字符串'55'。在 Python 中,不允许这种隐式转换。在这方面,我们说 Python 是一种强类型语言,这意味着每个对象都有一个特定的类型(或类),隐式转换只会在某些允许的情况下发生,例如:
In [22]: a = 4.5
In [23]: b = 2
# String formatting, to be visited later
In [24]: print(f"a is {type(a)}, b is {type(b)}")
a is <class 'float'>, b is <class 'int'>
In [25]: a / b
Out[25]: 2.25
在这里,即使b是一个整数,它也会被隐式转换为浮点数进行除法运算。
了解对象的类型很重要,能够编写能够处理许多不同类型输入的函数也很有用。您可以使用isinstance函数检查对象是否是特定类型的实例:
In [26]: a = 5
In [27]: isinstance(a, int)
Out[27]: True
如果要检查对象的类型是否在元组中存在,isinstance可以接受一个类型元组:
In [28]: a = 5; b = 4.5
In [29]: isinstance(a, (int, float))
Out[29]: True
In [30]: isinstance(b, (int, float))
Out[30]: True
属性和方法
Python 中的对象通常具有属性(存储在对象“内部”的其他 Python 对象)和方法(与对象关联的函数,可以访问对象的内部数据)。它们都可以通过语法<obj.attribute_name>访问:
In [1]: a = "foo"
In [2]: a.<Press Tab>
capitalize() index() isspace() removesuffix() startswith()
casefold() isprintable() istitle() replace() strip()
center() isalnum() isupper() rfind() swapcase()
count() isalpha() join() rindex() title()
encode() isascii() ljust() rjust() translate()
endswith() isdecimal() lower() rpartition()
expandtabs() isdigit() lstrip() rsplit()
find() isidentifier() maketrans() rstrip()
format() islower() partition() split()
format_map() isnumeric() removeprefix() splitlines()
属性和方法也可以通过getattr函数按名称访问:
In [32]: getattr(a, "split")
Out[32]: <function str.split(sep=None, maxsplit=-1)>
虽然我们在本书中不会广泛使用getattr函数和相关函数hasattr和setattr,但它们可以非常有效地用于编写通用的可重用代码。
鸭子类型
通常,您可能不关心对象的类型,而只关心它是否具有某些方法或行为。这有时被称为鸭子类型,源自谚语“如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子。”例如,如果对象实现了迭代器协议,则可以验证该对象是否可迭代。对于许多对象,这意味着它具有一个__iter__“魔术方法”,尽管检查的另一种更好的方法是尝试使用iter函数:
In [33]: def isiterable(obj):
....: try:
....: iter(obj)
....: return True
....: except TypeError: # not iterable
....: return False
对于字符串以及大多数 Python 集合类型,此函数将返回True:
In [34]: isiterable("a string")
Out[34]: True
In [35]: isiterable([1, 2, 3])
Out[35]: True
In [36]: isiterable(5)
Out[36]: False
导入
在 Python 中,模块只是一个包含 Python 代码的扩展名为.py的文件。假设我们有以下模块:
# some_module.py
PI = 3.14159
def f(x):
return x + 2
def g(a, b):
return a + b
如果我们想要从同一目录中的另一个文件中访问some_module.py中定义的变量和函数,我们可以这样做:
import some_module
result = some_module.f(5)
pi = some_module.PI
或者:
from some_module import g, PI
result = g(5, PI)
通过使用as关键字,您可以为导入指定不同的变量名称:
import some_module as sm
from some_module import PI as pi, g as gf
r1 = sm.f(pi)
r2 = gf(6, pi)
二进制运算符和比较
大多数二进制数学运算和比较使用其他编程语言中常用的数学语法:
In [37]: 5 - 7
Out[37]: -2
In [38]: 12 + 21.5
Out[38]: 33.5
In [39]: 5 <= 2
Out[39]: False
查看表 2.1 以获取所有可用的二进制运算符。
表 2.1:二进制运算符
| 操作 | 描述 |
|---|---|
a + b |
将a和b相加 |
a - b |
从a中减去b |
a * b |
将a乘以b |
a / b |
将a除以b |
a // b |
通过b进行地板除法,去除任何小数余数 |
a ** b |
将a提升到b次方 |
a & b |
如果a和b都为True,则为True;对于整数,取位AND |
| `a | b` |
a ^ b |
对于布尔值,如果a或b为True,但不是两者都为True;对于整数,取位异或 |
a == b |
如果a等于b,则为True |
a != b |
如果a不等于b,则为True |
a < b,a <= b |
如果a小于(小于或等于)b,则为True |
a > b, a >= b |
如果a大于(大于或等于)b,则为True |
a is b |
如果a和b引用相同的 Python 对象,则为True |
a is not b |
如果a和b引用不同的 Python 对象,则为True |
要检查两个变量是否引用同一对象,请使用is关键字。使用is not来检查两个对象是否不相同:
In [40]: a = [1, 2, 3]
In [41]: b = a
In [42]: c = list(a)
In [43]: a is b
Out[43]: True
In [44]: a is not c
Out[44]: True
由于list函数始终创建一个新的 Python 列表(即一个副本),我们可以确保c与a不同。与==运算符不同,使用is不同,因为在这种情况下我们有:
In [45]: a == c
Out[45]: True
is和is not的常见用法是检查变量是否为None,因为None只有一个实例:
In [46]: a = None
In [47]: a is None
Out[47]: True
可变和不可变对象
Python 中的许多对象,如列表、字典、NumPy 数组和大多数用户定义的类型(类),都是可变的。这意味着它们包含的对象或值可以被修改:
In [48]: a_list = ["foo", 2, [4, 5]]
In [49]: a_list[2] = (3, 4)
In [50]: a_list
Out[50]: ['foo', 2, (3, 4)]
其他,如字符串和元组,是不可变的,这意味着它们的内部数据不能被更改:
In [51]: a_tuple = (3, 5, (4, 5))
In [52]: a_tuple[1] = "four"
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-52-cd2a018a7529> in <module>
----> 1 a_tuple[1] = "four"
TypeError: 'tuple' object does not support item assignment
请记住,仅因为您可以改变对象并不意味着您总是应该这样做。这些操作被称为副作用。例如,在编写函数时,任何副作用都应明确地在函数的文档或注释中向用户传达。如果可能的话,我建议尽量避免副作用并偏爱不可变性,即使可能涉及可变对象。
标量类型
Python 具有一小组内置类型,用于处理数字数据、字符串、布尔(True或False)值以及日期和时间。这些“单值”类型有时被称为标量类型,我们在本书中将它们称为标量。请参阅表 2.2 以获取主要标量类型的列表。日期和时间处理将单独讨论,因为这些由标准库中的datetime模块提供。
表 2.2:标准 Python 标量类型
| 类型 | 描述 |
|---|---|
None |
Python 的“null”值(只存在一个None对象的实例) |
str |
字符串类型;保存 Unicode 字符串 |
bytes |
原始二进制数据 |
float |
双精度浮点数(请注意没有单独的double类型) |
bool |
布尔值True或False |
int |
任意精度整数 |
数字类型
数字的主要 Python 类型是int和float。int可以存储任意大的数字:
In [53]: ival = 17239871
In [54]: ival ** 6
Out[54]: 26254519291092456596965462913230729701102721
浮点数用 Python 的float类型表示。在底层,每个都是双精度值。它们也可以用科学计数法表示:
In [55]: fval = 7.243
In [56]: fval2 = 6.78e-5
整数除法如果结果不是整数,将始终产生一个浮点数:
In [57]: 3 / 2
Out[57]: 1.5
要获得 C 风格的整数除法(如果结果不是整数,则丢弃小数部分),请使用地板除法运算符//:
In [58]: 3 // 2
Out[58]: 1
字符串
许多人使用 Python 是因为其内置的字符串处理功能。您可以使用单引号'或双引号"(通常更喜欢双引号)编写字符串字面值:
a = 'one way of writing a string'
b = "another way"
Python 字符串类型是str。
对于带有换行符的多行字符串,可以使用三引号,即'''或""":
c = """
This is a longer string that
spans multiple lines
"""
这个字符串c实际上包含四行文本可能会让您感到惊讶;在"""之后和lines之后的换行符包含在字符串中。我们可以使用c上的count方法来计算换行符的数量:
In [60]: c.count("\n")
Out[60]: 3
Python 字符串是不可变的;您不能修改一个字符串:
In [61]: a = "this is a string"
In [62]: a[10] = "f"
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-62-3b2d95f10db4> in <module>
----> 1 a[10] = "f"
TypeError: 'str' object does not support item assignment
要解释此错误消息,请从下往上阅读。我们尝试用字母"f"替换位置 10 处的字符(“项”),但对于字符串对象来说,这是不允许的。如果我们需要修改一个字符串,我们必须使用一个创建新字符串的函数或方法,比如字符串replace方法:
In [63]: b = a.replace("string", "longer string")
In [64]: b
Out[64]: 'this is a longer string'
此操作后,变量a保持不变:
In [65]: a
Out[65]: 'this is a string'
许多 Python 对象可以使用str函数转换为字符串:
In [66]: a = 5.6
In [67]: s = str(a)
In [68]: print(s)
5.6
字符串是 Unicode 字符序列,因此可以像其他序列(如列表和元组)一样对待:
In [69]: s = "python"
In [70]: list(s)
Out[70]: ['p', 'y', 't', 'h', 'o', 'n']
In [71]: s[:3]
Out[71]: 'pyt'
s[:3]语法称为切片,对于许多种类的 Python 序列都有实现。稍后将更详细地解释这一点,因为它在本书中被广泛使用。
反斜杠字符\是一个转义字符,意味着它用于指定特殊字符,如换行符\n或 Unicode 字符。要编写带有反斜杠的字符串字面值,您需要对其进行转义:
In [72]: s = "12\\34"
In [73]: print(s)
12\34
如果您有一个带有许多反斜杠且没有特殊字符的字符串,您可能会觉得有点烦人。幸运的是,您可以在字符串的前导引号前加上r,这意味着应该按原样解释字符:
In [74]: s = r"this\has\no\special\characters"
In [75]: s
Out[75]: 'this\\has\\no\\special\\characters'
r代表原始。
将两个字符串相加会将它们连接在一起并生成一个新字符串:
In [76]: a = "this is the first half "
In [77]: b = "and this is the second half"
In [78]: a + b
Out[78]: 'this is the first half and this is the second half'
字符串模板或格式化是另一个重要主题。随着 Python 3 的出现,进行此操作的方式数量已经扩展,这里我将简要描述其中一个主要接口的机制。字符串对象具有一个format方法,可用于将格式化参数替换为字符串中,生成一个新字符串:
In [79]: template = "{0:.2f} {1:s} are worth US${2:d}"
在这个字符串中:
-
{0:.2f}表示将第一个参数格式化为带有两位小数的浮点数。 -
{1:s}表示将第二个参数格式化为字符串。 -
{2:d}表示将第三个参数格式化为精确整数。
要为这些格式参数替换参数,我们将一系列参数传递给format方法:
In [80]: template.format(88.46, "Argentine Pesos", 1)
Out[80]: '88.46 Argentine Pesos are worth US$1'
Python 3.6 引入了一个名为f-strings(即格式化字符串字面值)的新功能,可以使创建格式化字符串更加方便。要创建 f-string,只需在字符串字面值之前立即写入字符f。在字符串中,用大括号括起 Python 表达式,以将表达式的值替换为格式化字符串中的值:
In [81]: amount = 10
In [82]: rate = 88.46
In [83]: currency = "Pesos"
In [84]: result = f"{amount} {currency} is worth US${amount / rate}"
格式说明符可以在每个表达式后添加,使用与上面字符串模板相同的语法:
In [85]: f"{amount} {currency} is worth US${amount / rate:.2f}"
Out[85]: '10 Pesos is worth US$0.11'
字符串格式化是一个深入的主题;有多种方法和大量选项和调整可用于控制结果字符串中的值的格式。要了解更多,请参阅官方 Python 文档。
字节和 Unicode
在现代 Python(即 Python 3.0 及更高版本)中,Unicode 已成为一流的字符串类型,以实现更一致地处理 ASCII 和非 ASCII 文本。在旧版本的 Python 中,字符串都是字节,没有任何明确的 Unicode 编码。您可以假设您知道字符编码来转换为 Unicode。这里是一个带有非 ASCII 字符的示例 Unicode 字符串:
In [86]: val = "español"
In [87]: val
Out[87]: 'español'
我们可以使用encode方法将此 Unicode 字符串转换为其 UTF-8 字节表示:
In [88]: val_utf8 = val.encode("utf-8")
In [89]: val_utf8
Out[89]: b'espa\xc3\xb1ol'
In [90]: type(val_utf8)
Out[90]: bytes
假设您知道bytes对象的 Unicode 编码,您可以使用decode方法返回:
In [91]: val_utf8.decode("utf-8")
Out[91]: 'español'
现在最好使用 UTF-8 进行任何编码,但出于历史原因,您可能会遇到各种不同编码的数据:
In [92]: val.encode("latin1")
Out[92]: b'espa\xf1ol'
In [93]: val.encode("utf-16")
Out[93]: b'\xff\xfee\x00s\x00p\x00a\x00\xf1\x00o\x00l\x00'
In [94]: val.encode("utf-16le")
Out[94]: b'e\x00s\x00p\x00a\x00\xf1\x00o\x00l\x00'
在处理文件时,最常见的是遇到bytes对象,其中不希望将所有数据隐式解码为 Unicode 字符串。
布尔值
Python 中的两个布尔值分别写为True和False。比较和其他条件表达式的结果要么为True,要么为False。布尔值可以使用and和or关键字组合:
In [95]: True and True
Out[95]: True
In [96]: False or True
Out[96]: True
当转换为数字时,False变为0,True变为1:
In [97]: int(False)
Out[97]: 0
In [98]: int(True)
Out[98]: 1
关键字not可以将布尔值从True翻转为False,反之亦然:
In [99]: a = True
In [100]: b = False
In [101]: not a
Out[101]: False
In [102]: not b
Out[102]: True
类型转换
str、bool、int和float类型也是可以用来将值转换为这些类型的函数:
In [103]: s = "3.14159"
In [104]: fval = float(s)
In [105]: type(fval)
Out[105]: float
In [106]: int(fval)
Out[106]: 3
In [107]: bool(fval)
Out[107]: True
In [108]: bool(0)
Out[108]: False
请注意,大多数非零值在转换为bool时会变为True。
None
None是 Python 的空值类型:
In [109]: a = None
In [110]: a is None
Out[110]: True
In [111]: b = 5
In [112]: b is not None
Out[112]: True
None也是函数参数的常见默认值:
def add_and_maybe_multiply(a, b, c=None):
result = a + b
if c is not None:
result = result * c
return result
日期和时间
内置的 Python datetime模块提供了datetime、date和time类型。datetime类型结合了date和time中存储的信息,是最常用的类型:
In [113]: from datetime import datetime, date, time
In [114]: dt = datetime(2011, 10, 29, 20, 30, 21)
In [115]: dt.day
Out[115]: 29
In [116]: dt.minute
Out[116]: 30
给定一个datetime实例,您可以通过在具有相同名称的datetime上调用方法来提取等效的date和time对象:
In [117]: dt.date()
Out[117]: datetime.date(2011, 10, 29)
In [118]: dt.time()
Out[118]: datetime.time(20, 30, 21)
strftime方法将datetime格式化为字符串:
In [119]: dt.strftime("%Y-%m-%d %H:%M")
Out[119]: '2011-10-29 20:30'
字符串可以使用strptime函数转换(解析)为datetime对象:
In [120]: datetime.strptime("20091031", "%Y%m%d")
Out[120]: datetime.datetime(2009, 10, 31, 0, 0)
查看表 11.2 以获取完整的格式规范列表。
当您聚合或以其他方式对时间序列数据进行分组时,偶尔会有必要替换一系列datetime的时间字段,例如,将minute和second字段替换为零:
In [121]: dt_hour = dt.replace(minute=0, second=0)
In [122]: dt_hour
Out[122]: datetime.datetime(2011, 10, 29, 20, 0)
由于datetime.datetime是不可变类型,这些方法总是会产生新对象。因此,在前面的例子中,dt不会被replace修改:
In [123]: dt
Out[123]: datetime.datetime(2011, 10, 29, 20, 30, 21)
两个datetime对象的差产生一个datetime.timedelta类型:
In [124]: dt2 = datetime(2011, 11, 15, 22, 30)
In [125]: delta = dt2 - dt
In [126]: delta
Out[126]: datetime.timedelta(days=17, seconds=7179)
In [127]: type(delta)
Out[127]: datetime.timedelta
输出timedelta(17, 7179)表示timedelta编码了 17 天和 7179 秒的偏移量。
将timedelta添加到datetime会产生一个新的偏移datetime:
In [128]: dt
Out[128]: datetime.datetime(2011, 10, 29, 20, 30, 21)
In [129]: dt + delta
Out[129]: datetime.datetime(2011, 11, 15, 22, 30)
控制流
Python 有几个内置关键字用于条件逻辑、循环和其他标准控制流概念,这些概念在其他编程语言中也可以找到。
if、elif 和 else
if语句是最为人熟知的控制流语句类型之一。它检查一个条件,如果为True,则评估后面的代码块:
x = -5
if x < 0:
print("It's negative")
if语句后面可以选择跟随一个或多个elif代码块和一个全捕获的else代码块,如果所有条件都为False:
if x < 0:
print("It's negative")
elif x == 0:
print("Equal to zero")
elif 0 < x < 5:
print("Positive but smaller than 5")
else:
print("Positive and larger than or equal to 5")
如果任何条件为True,则不会继续执行任何elif或else代码块。使用and或or的复合条件,条件从左到右进行评估并会短路:
In [130]: a = 5; b = 7
In [131]: c = 8; d = 4
In [132]: if a < b or c > d:
.....: print("Made it")
Made it
在这个例子中,比较c > d永远不会被评估,因为第一个比较是True。
也可以链接比较:
In [133]: 4 > 3 > 2 > 1
Out[133]: True
for 循环
for循环用于遍历集合(如列表或元组)或迭代器。for循环的标准语法是:
for value in collection:
# do something with value
您可以使用continue关键字将for循环推进到下一个迭代,跳过代码块的其余部分。考虑这段代码,它对列表中的整数求和并跳过None值:
sequence = [1, 2, None, 4, None, 5]
total = 0
for value in sequence:
if value is None:
continue
total += value
可以使用break关键字完全退出for循环。这段代码将列表元素求和,直到达到 5 为止:
sequence = [1, 2, 0, 4, 6, 5, 2, 1]
total_until_5 = 0
for value in sequence:
if value == 5:
break
total_until_5 += value
break关键字仅终止最内层的for循环;任何外部的for循环将继续运行:
In [134]: for i in range(4):
.....: for j in range(4):
.....: if j > i:
.....: break
.....: print((i, j))
.....:
(0, 0)
(1, 0)
(1, 1)
(2, 0)
(2, 1)
(2, 2)
(3, 0)
(3, 1)
(3, 2)
(3, 3)
正如我们将在更详细地看到的,如果集合或迭代器中的元素是序列(例如元组或列表),它们可以方便地在for循环语句中解包为变量:
for a, b, c in iterator:
# do something
while 循环
while循环指定一个条件和一个要执行的代码块,直到条件评估为False或循环被显式地使用break结束为止:
x = 256
total = 0
while x > 0:
if total > 500:
break
total += x
x = x // 2
pass
pass是 Python 中的“空操作”(或“什么也不做”)语句。它可以在不需要执行任何操作的代码块中使用(或作为尚未实现的代码的占位符);它仅仅是因为 Python 使用空格来分隔代码块:
if x < 0:
print("negative!")
elif x == 0:
# TODO: put something smart here
pass
else:
print("positive!")
范围
range函数生成一系列均匀间隔的整数:
In [135]: range(10)
Out[135]: range(0, 10)
In [136]: list(range(10))
Out[136]: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
可以给定起始点、终点和步长(可以是负数):
In [137]: list(range(0, 20, 2))
Out[137]: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
In [138]: list(range(5, 0, -1))
Out[138]: [5, 4, 3, 2, 1]
正如您所看到的,range 生成的整数是直到但不包括终点的。range 的一个常见用途是通过索引迭代序列:
In [139]: seq = [1, 2, 3, 4]
In [140]: for i in range(len(seq)):
.....: print(f"element {i}: {seq[i]}")
element 0: 1
element 1: 2
element 2: 3
element 3: 4
虽然您可以使用list等函数将range生成的所有整数存储在其他数据结构中,但通常默认的迭代器形式会是您想要的。这段代码将从 0 到 99,999 之间是 3 或 5 的倍数的所有数字相加:
In [141]: total = 0
In [142]: for i in range(100_000):
.....: # % is the modulo operator
.....: if i % 3 == 0 or i % 5 == 0:
.....: total += i
In [143]: print(total)
2333316668
虽然生成的范围可以任意大,但在任何给定时间内的内存使用可能非常小。
2.4 结论
本章简要介绍了一些基本的 Python 语言概念以及 IPython 和 Jupyter 编程环境。在下一章中,我将讨论许多内置数据类型、函数和输入输出工具,这些内容将在本书的其余部分中持续使用。
三、内置数据结构、函数和文件
原文:
wesmckinney.com/book/python-builtin译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
本章讨论了内置到 Python 语言中的功能,这些功能将在整本书中被广泛使用。虽然像 pandas 和 NumPy 这样的附加库为更大的数据集添加了高级计算功能,但它们旨在与 Python 的内置数据操作工具一起使用。
我们将从 Python 的主要数据结构开始:元组、列表、字典和集合。然后,我们将讨论如何创建自己可重用的 Python 函数。最后,我们将看看 Python 文件对象的机制以及如何与本地硬盘交互。
3.1 数据结构和序列
Python 的数据结构简单而强大。掌握它们的使用是成为熟练的 Python 程序员的关键部分。我们从元组、列表和字典开始,它们是一些最常用的序列类型。
元组
元组是 Python 对象的固定长度、不可变序列,一旦分配,就无法更改。创建元组的最简单方法是使用括号括起的逗号分隔的值序列:
In [2]: tup = (4, 5, 6)
In [3]: tup
Out[3]: (4, 5, 6)
在许多情况下,括号可以省略,所以这里我们也可以这样写:
In [4]: tup = 4, 5, 6
In [5]: tup
Out[5]: (4, 5, 6)
您可以通过调用tuple将任何序列或迭代器转换为元组:
In [6]: tuple([4, 0, 2])
Out[6]: (4, 0, 2)
In [7]: tup = tuple('string')
In [8]: tup
Out[8]: ('s', 't', 'r', 'i', 'n', 'g')
元素可以使用方括号[]访问,就像大多数其他序列类型一样。与 C、C++、Java 和许多其他语言一样,在 Python 中,序列是从 0 开始索引的:
In [9]: tup[0]
Out[9]: 's'
当您在更复杂的表达式中定义元组时,通常需要将值括在括号中,就像在创建元组的示例中一样:
In [10]: nested_tup = (4, 5, 6), (7, 8)
In [11]: nested_tup
Out[11]: ((4, 5, 6), (7, 8))
In [12]: nested_tup[0]
Out[12]: (4, 5, 6)
In [13]: nested_tup[1]
Out[13]: (7, 8)
虽然存储在元组中的对象本身可能是可变的,但一旦创建了元组,就无法修改存储在每个槽中的对象:
In [14]: tup = tuple(['foo', [1, 2], True])
In [15]: tup[2] = False
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-15-b89d0c4ae599> in <module>
----> 1 tup[2] = False
TypeError: 'tuple' object does not support item assignment
如果元组中的对象是可变的,比如列表,您可以就地修改它:
In [16]: tup[1].append(3)
In [17]: tup
Out[17]: ('foo', [1, 2, 3], True)
您可以使用+运算符连接元组以生成更长的元组:
In [18]: (4, None, 'foo') + (6, 0) + ('bar',)
Out[18]: (4, None, 'foo', 6, 0, 'bar')
将元组乘以一个整数,与列表一样,会产生该元组的多个副本的效果:
In [19]: ('foo', 'bar') * 4
Out[19]: ('foo', 'bar', 'foo', 'bar', 'foo', 'bar', 'foo', 'bar')
请注意,对象本身并没有被复制,只有对它们的引用。
解包元组
如果您尝试对类似元组的变量表达式进行赋值,Python 将尝试在等号右侧解包值:
In [20]: tup = (4, 5, 6)
In [21]: a, b, c = tup
In [22]: b
Out[22]: 5
即使包含嵌套元组的序列也可以解包:
In [23]: tup = 4, 5, (6, 7)
In [24]: a, b, (c, d) = tup
In [25]: d
Out[25]: 7
使用这个功能,您可以轻松交换变量名,这在许多语言中可能看起来像:
tmp = a
a = b
b = tmp
但是,在 Python 中,交换可以这样做:
In [26]: a, b = 1, 2
In [27]: a
Out[27]: 1
In [28]: b
Out[28]: 2
In [29]: b, a = a, b
In [30]: a
Out[30]: 2
In [31]: b
Out[31]: 1
变量解包的常见用途是迭代元组或列表的序列:
In [32]: seq = [(1, 2, 3), (4, 5, 6), (7, 8, 9)]
In [33]: for a, b, c in seq:
....: print(f'a={a}, b={b}, c={c}')
a=1, b=2, c=3
a=4, b=5, c=6
a=7, b=8, c=9
另一个常见用途是从函数返回多个值。我稍后会更详细地介绍这个问题。
有一些情况下,您可能希望从元组的开头“摘取”一些元素。有一种特殊的语法可以做到这一点,*rest,这也用于函数签名中捕获任意长的位置参数:
In [34]: values = 1, 2, 3, 4, 5
In [35]: a, b, *rest = values
In [36]: a
Out[36]: 1
In [37]: b
Out[37]: 2
In [38]: rest
Out[38]: [3, 4, 5]
这个rest位有时是您想要丢弃的内容;rest名称没有特殊之处。作为一种惯例,许多 Python 程序员会使用下划线(_)表示不需要的变量:
In [39]: a, b, *_ = values
元组方法
由于元组的大小和内容不能被修改,因此实例方法非常少。一个特别有用的方法(也适用于列表)是count,它计算值的出现次数:
In [40]: a = (1, 2, 2, 2, 3, 4, 2)
In [41]: a.count(2)
Out[41]: 4
列表
与元组相反,列表是可变长度的,其内容可以就地修改。列表是可变的。您可以使用方括号[]定义它们,也可以使用list类型函数:
In [42]: a_list = [2, 3, 7, None]
In [43]: tup = ("foo", "bar", "baz")
In [44]: b_list = list(tup)
In [45]: b_list
Out[45]: ['foo', 'bar', 'baz']
In [46]: b_list[1] = "peekaboo"
In [47]: b_list
Out[47]: ['foo', 'peekaboo', 'baz']
列表和元组在语义上是相似的(尽管元组不能被修改),并且可以在许多函数中互换使用。
list内置函数在数据处理中经常用作实例化迭代器或生成器表达式的方法:
In [48]: gen = range(10)
In [49]: gen
Out[49]: range(0, 10)
In [50]: list(gen)
Out[50]: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
添加和删除元素
元素可以使用append方法附加到列表的末尾:
In [51]: b_list.append("dwarf")
In [52]: b_list
Out[52]: ['foo', 'peekaboo', 'baz', 'dwarf']
使用insert可以在列表中的特定位置插入元素:
In [53]: b_list.insert(1, "red")
In [54]: b_list
Out[54]: ['foo', 'red', 'peekaboo', 'baz', 'dwarf']
插入索引必须在列表的长度之间,包括 0 和长度。
警告:
与append相比,insert的计算成本较高,因为必须在内部移动后续元素的引用以为新元素腾出空间。如果需要在序列的开头和结尾插入元素,您可能希望探索collections.deque,这是一个双端队列,专为此目的进行了优化,并且包含在 Python 标准库中。
insert的反向操作是pop,它会删除并返回特定索引处的元素:
In [55]: b_list.pop(2)
Out[55]: 'peekaboo'
In [56]: b_list
Out[56]: ['foo', 'red', 'baz', 'dwarf']
可以使用remove按值删除元素,它会定位第一个这样的值并将其从列表中删除:
In [57]: b_list.append("foo")
In [58]: b_list
Out[58]: ['foo', 'red', 'baz', 'dwarf', 'foo']
In [59]: b_list.remove("foo")
In [60]: b_list
Out[60]: ['red', 'baz', 'dwarf', 'foo']
如果不关心性能,通过使用append和remove,可以使用 Python 列表作为类似集合的数据结构(尽管 Python 有实际的集合对象,稍后讨论)。
使用in关键字检查列表是否包含一个值:
In [61]: "dwarf" in b_list
Out[61]: True
关键字not可以用来否定in:
In [62]: "dwarf" not in b_list
Out[62]: False
检查列表是否包含一个值比使用字典和集合慢得多(即将介绍),因为 Python 会在线性扫描列表的值,而可以在常量时间内检查其他值(基于哈希表)。
连接和组合列表
与元组类似,使用+将两个列表相加会将它们连接起来:
In [63]: [4, None, "foo"] + [7, 8, (2, 3)]
Out[63]: [4, None, 'foo', 7, 8, (2, 3)]
如果已经定义了一个列表,可以使用extend方法将多个元素附加到其中:
In [64]: x = [4, None, "foo"]
In [65]: x.extend([7, 8, (2, 3)])
In [66]: x
Out[66]: [4, None, 'foo', 7, 8, (2, 3)]
请注意,通过加法进行列表连接是一种相对昂贵的操作,因为必须创建一个新列表并复制对象。通常最好使用extend将元素附加到现有列表中,特别是如果您正在构建一个大列表。因此:
everything = []
for chunk in list_of_lists:
everything.extend(chunk)
比连接替代方案更快:
everything = []
for chunk in list_of_lists:
everything = everything + chunk
排序
您可以通过调用其sort函数就地对列表进行排序(而不创建新对象):
In [67]: a = [7, 2, 5, 1, 3]
In [68]: a.sort()
In [69]: a
Out[69]: [1, 2, 3, 5, 7]
sort有一些选项,偶尔会派上用场。其中之一是能够传递一个次要排序键——即生成用于对对象进行排序的值的函数。例如,我们可以按字符串的长度对字符串集合进行排序:
In [70]: b = ["saw", "small", "He", "foxes", "six"]
In [71]: b.sort(key=len)
In [72]: b
Out[72]: ['He', 'saw', 'six', 'small', 'foxes']
很快,我们将看一下sorted函数,它可以生成一份排序后的一般序列的副本。
切片
您可以使用切片表示法选择大多数序列类型的部分,其基本形式是将start:stop传递给索引运算符[]:
In [73]: seq = [7, 2, 3, 7, 5, 6, 0, 1]
In [74]: seq[1:5]
Out[74]: [2, 3, 7, 5]
切片也可以用序列赋值:
In [75]: seq[3:5] = [6, 3]
In [76]: seq
Out[76]: [7, 2, 3, 6, 3, 6, 0, 1]
虽然start索引处的元素被包括在内,但stop索引不包括在内,因此结果中的元素数量为stop - start。
start或stop可以省略,此时它们分别默认为序列的开头和序列的结尾:
In [77]: seq[:5]
Out[77]: [7, 2, 3, 6, 3]
In [78]: seq[3:]
Out[78]: [6, 3, 6, 0, 1]
负索引相对于末尾切片序列:
In [79]: seq[-4:]
Out[79]: [3, 6, 0, 1]
In [80]: seq[-6:-2]
Out[80]: [3, 6, 3, 6]
切片语义需要一点时间来适应,特别是如果你是从 R 或 MATLAB 过来的。参见图 3.1 以了解使用正整数和负整数进行切片的有用示例。在图中,索引显示在“箱边缘”,以帮助显示使用正整数或负整数索引开始和停止的切片选择。

图 3.1:Python 切片约定的示例
第二个冒号后也可以使用step,比如,每隔一个元素取一个:
In [81]: seq[::2]
Out[81]: [7, 3, 3, 0]
这种方法的一个巧妙用法是传递-1,这样可以有效地反转列表或元组:
In [82]: seq[::-1]
Out[82]: [1, 0, 6, 3, 6, 3, 2, 7]
字典
字典或dict可能是 Python 中最重要的内置数据结构。在其他编程语言中,字典有时被称为哈希映射或关联数组。字典存储一组键-值对,其中键和值是 Python 对象。每个键都与一个值关联,以便可以方便地检索、插入、修改或删除给定特定键的值。创建字典的一种方法是使用大括号{}和冒号来分隔键和值:
In [83]: empty_dict = {}
In [84]: d1 = {"a": "some value", "b": [1, 2, 3, 4]}
In [85]: d1
Out[85]: {'a': 'some value', 'b': [1, 2, 3, 4]}
可以使用与访问列表或元组元素相同的语法来访问、插入或设置元素:
In [86]: d1[7] = "an integer"
In [87]: d1
Out[87]: {'a': 'some value', 'b': [1, 2, 3, 4], 7: 'an integer'}
In [88]: d1["b"]
Out[88]: [1, 2, 3, 4]
你可以使用与检查列表或元组是否包含值相同的语法来检查字典是否包含键:
In [89]: "b" in d1
Out[89]: True
可以使用del关键字或pop方法(同时返回值并删除键)来删除值:
In [90]: d1[5] = "some value"
In [91]: d1
Out[91]:
{'a': 'some value',
'b': [1, 2, 3, 4],
7: 'an integer',
5: 'some value'}
In [92]: d1["dummy"] = "another value"
In [93]: d1
Out[93]:
{'a': 'some value',
'b': [1, 2, 3, 4],
7: 'an integer',
5: 'some value',
'dummy': 'another value'}
In [94]: del d1[5]
In [95]: d1
Out[95]:
{'a': 'some value',
'b': [1, 2, 3, 4],
7: 'an integer',
'dummy': 'another value'}
In [96]: ret = d1.pop("dummy")
In [97]: ret
Out[97]: 'another value'
In [98]: d1
Out[98]: {'a': 'some value', 'b': [1, 2, 3, 4], 7: 'an integer'}
keys和values方法分别为你提供字典的键和值的迭代器。键的顺序取决于它们插入的顺序,这些函数以相同的顺序输出键和值:
In [99]: list(d1.keys())
Out[99]: ['a', 'b', 7]
In [100]: list(d1.values())
Out[100]: ['some value', [1, 2, 3, 4], 'an integer']
如果需要同时迭代键和值,可以使用items方法以 2 元组的形式迭代键和值:
In [101]: list(d1.items())
Out[101]: [('a', 'some value'), ('b', [1, 2, 3, 4]), (7, 'an integer')]
可以使用update方法将一个字典合并到另一个字典中:
In [102]: d1.update({"b": "foo", "c": 12})
In [103]: d1
Out[103]: {'a': 'some value', 'b': 'foo', 7: 'an integer', 'c': 12}
update方法会直接更改字典,因此传递给update的数据中的任何现有键都将丢弃其旧值。
从序列创建字典
通常会偶尔出现两个你想要逐个元素配对的序列。作为第一步,你可能会编写这样的代码:
mapping = {}
for key, value in zip(key_list, value_list):
mapping[key] = value
由于字典本质上是 2 元组的集合,dict函数接受一个 2 元组的列表:
In [104]: tuples = zip(range(5), reversed(range(5)))
In [105]: tuples
Out[105]: <zip at 0x17d604d00>
In [106]: mapping = dict(tuples)
In [107]: mapping
Out[107]: {0: 4, 1: 3, 2: 2, 3: 1, 4: 0}
稍后我们将讨论字典推导,这是构建字典的另一种方法。
默认值
通常会有类似以下逻辑:
if key in some_dict:
value = some_dict[key]
else:
value = default_value
因此,字典方法get和pop可以接受要返回的默认值,因此上述if-else块可以简单地写为:
value = some_dict.get(key, default_value)
get默认情况下会返回None,如果键不存在,而pop会引发异常。在设置值时,可能字典中的值是另一种集合,比如列表。例如,你可以想象将单词列表按照它们的首字母分类为列表的字典:
In [108]: words = ["apple", "bat", "bar", "atom", "book"]
In [109]: by_letter = {}
In [110]: for word in words:
.....: letter = word[0]
.....: if letter not in by_letter:
.....: by_letter[letter] = [word]
.....: else:
.....: by_letter[letter].append(word)
.....:
In [111]: by_letter
Out[111]: {'a': ['apple', 'atom'], 'b': ['bat', 'bar', 'book']}
setdefault字典方法可用于简化此工作流程。前面的for循环可以重写为:
In [112]: by_letter = {}
In [113]: for word in words:
.....: letter = word[0]
.....: by_letter.setdefault(letter, []).append(word)
.....:
In [114]: by_letter
Out[114]: {'a': ['apple', 'atom'], 'b': ['bat', 'bar', 'book']}
内置的collections模块有一个有用的类defaultdict,使这更加容易。要创建一个,你需要传递一个类型或函数,用于为字典中的每个槽生成默认值:
In [115]: from collections import defaultdict
In [116]: by_letter = defaultdict(list)
In [117]: for word in words:
.....: by_letter[word[0]].append(word)
有效的字典键类型
虽然字典的值可以是任何 Python 对象,但键通常必须是不可变对象,如标量类型(int、float、string)或元组(元组中的所有对象也必须是不可变的)。这里的技术术语是可哈希性。你可以使用hash函数检查对象是否可哈希(可以用作字典中的键):
In [118]: hash("string")
Out[118]: 4022908869268713487
In [119]: hash((1, 2, (2, 3)))
Out[119]: -9209053662355515447
In [120]: hash((1, 2, [2, 3])) # fails because lists are mutable
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-120-473c35a62c0b> in <module>
----> 1 hash((1, 2, [2, 3])) # fails because lists are mutable
TypeError: unhashable type: 'list'
通常情况下,使用hash函数时看到的哈希值将取决于你使用的 Python 版本。
要将列表用作键,一种选择是将其转换为元组,只要其元素也可以被散列:
In [121]: d = {}
In [122]: d[tuple([1, 2, 3])] = 5
In [123]: d
Out[123]: {(1, 2, 3): 5}
集合
集合是一个无序的唯一元素集合。可以通过set函数或使用花括号的集合字面值来创建集合:
In [124]: set([2, 2, 2, 1, 3, 3])
Out[124]: {1, 2, 3}
In [125]: {2, 2, 2, 1, 3, 3}
Out[125]: {1, 2, 3}
集合支持数学集合操作,如并集、交集、差集和对称差集。考虑这两个示例集合:
In [126]: a = {1, 2, 3, 4, 5}
In [127]: b = {3, 4, 5, 6, 7, 8}
这两个集合的并集是两个集合中出现的不同元素的集合。可以使用union方法或|二进制运算符来计算:
In [128]: a.union(b)
Out[128]: {1, 2, 3, 4, 5, 6, 7, 8}
In [129]: a | b
Out[129]: {1, 2, 3, 4, 5, 6, 7, 8}
交集包含两个集合中都出现的元素。可以使用&运算符或intersection方法:
In [130]: a.intersection(b)
Out[130]: {3, 4, 5}
In [131]: a & b
Out[131]: {3, 4, 5}
请参见表 3.1 以获取常用集合方法的列表。
表 3.1:Python 集合操作
| 函数 | 替代语法 | 描述 |
|---|---|---|
a.add(x) |
N/A | 将元素x添加到集合a中 |
a.clear() |
N/A | 将集合a重置为空状态,丢弃所有元素 |
a.remove(x) |
N/A | 从集合a中删除元素x |
a.pop() |
N/A | 从集合a中删除一个任意元素,如果集合为空则引发KeyError |
a.union(b) |
a | b |
a和b中所有唯一的元素 |
a.update(b) |
a |= b |
将a的内容设置为a和b中元素的并集 |
a.intersection(b) |
a & b |
a和b中都存在的所有元素 |
a.intersection_update(b) |
a &= b |
将a的内容设置为a和b中元素的交集 |
a.difference(b) |
a - b |
a中不在b中的元素 |
a.difference_update(b) |
a -= b |
将a设置为a中不在b中的元素 |
a.symmetric_difference(b) |
a ^ b |
a或b中的所有元素,但不是两者都有的 |
a.symmetric_difference_update(b) |
a ^= b |
将a设置为a或b中的元素,但不是两者都有的 |
a.issubset(b) |
<= |
如果a的元素都包含在b中,则为True |
a.issuperset(b) |
>= |
如果b的元素都包含在a中,则为True |
a.isdisjoint(b) |
N/A | 如果a和b没有共同元素,则为True |
注意
如果将不是集合的输入传递给union和intersection等方法,Python 将在执行操作之前将输入转换为集合。在使用二进制运算符时,两个对象必须已经是集合。
所有逻辑集合操作都有原地对应物,这使您可以用结果替换操作左侧集合的内容。对于非常大的集合,这可能更有效率:*
In [132]: c = a.copy()
In [133]: c |= b
In [134]: c
Out[134]: {1, 2, 3, 4, 5, 6, 7, 8}
In [135]: d = a.copy()
In [136]: d &= b
In [137]: d
Out[137]: {3, 4, 5}
与字典键类似,集合元素通常必须是不可变的,并且它们必须是可散列的(这意味着对值调用hash不会引发异常)。为了将类似列表的元素(或其他可变序列)存储在集合中,可以将它们转换为元组:
In [138]: my_data = [1, 2, 3, 4]
In [139]: my_set = {tuple(my_data)}
In [140]: my_set
Out[140]: {(1, 2, 3, 4)}
您还可以检查一个集合是否是另一个集合的子集(包含在内)或超集(包含所有元素):
In [141]: a_set = {1, 2, 3, 4, 5}
In [142]: {1, 2, 3}.issubset(a_set)
Out[142]: True
In [143]: a_set.issuperset({1, 2, 3})
Out[143]: True
只有当集合的内容相等时,集合才相等:
In [144]: {1, 2, 3} == {3, 2, 1}
Out[144]: True
内置序列函数
Python 有一些有用的序列函数,您应该熟悉并在任何机会使用。
enumerate
在迭代序列时,通常希望跟踪当前项目的索引。自己动手的方法如下:
index = 0
for value in collection:
# do something with value
index += 1
由于这种情况很常见,Python 有一个内置函数enumerate,它返回一个(i, value)元组序列:
for index, value in enumerate(collection):
# do something with value
sorted
sorted函数从任何序列的元素返回一个新的排序列表:
In [145]: sorted([7, 1, 2, 6, 0, 3, 2])
Out[145]: [0, 1, 2, 2, 3, 6, 7]
In [146]: sorted("horse race")
Out[146]: [' ', 'a', 'c', 'e', 'e', 'h', 'o', 'r', 'r', 's']
sorted函数接受与列表的sort方法相同的参数。
zip
zip将多个列表、元组或其他序列的元素“配对”起来,以创建一个元组列表:
In [147]: seq1 = ["foo", "bar", "baz"]
In [148]: seq2 = ["one", "two", "three"]
In [149]: zipped = zip(seq1, seq2)
In [150]: list(zipped)
Out[150]: [('foo', 'one'), ('bar', 'two'), ('baz', 'three')]
zip可以接受任意数量的序列,并且它生成的元素数量由最短的序列决定:
In [151]: seq3 = [False, True]
In [152]: list(zip(seq1, seq2, seq3))
Out[152]: [('foo', 'one', False), ('bar', 'two', True)]
zip的一个常见用法是同时迭代多个序列,可能还与enumerate结合使用:
In [153]: for index, (a, b) in enumerate(zip(seq1, seq2)):
.....: print(f"{index}: {a}, {b}")
.....:
0: foo, one
1: bar, two
2: baz, three
反转
reversed以相反的顺序迭代序列的元素:
In [154]: list(reversed(range(10)))
Out[154]: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
请记住,reversed是一个生成器(稍后将更详细讨论),因此它不会创建反转的序列,直到实现(例如,使用list或for循环)。
列表、集合和字典推导
列表推导是 Python 语言中一个方便且广泛使用的特性。它们允许您通过过滤集合的元素,将通过过滤的元素转换为一个简洁的表达式来简洁地形成一个新列表。它们的基本形式如下:
[expr for value in collection if condition]
这等同于以下的for循环:
result = []
for value in collection:
if condition:
result.append(expr)
过滤条件可以被省略,只留下表达式。例如,给定一个字符串列表,我们可以过滤出长度为2或更少的字符串,并将它们转换为大写:
In [155]: strings = ["a", "as", "bat", "car", "dove", "python"]
In [156]: [x.upper() for x in strings if len(x) > 2]
Out[156]: ['BAT', 'CAR', 'DOVE', 'PYTHON']
集合和字典推导是一个自然的扩展,以一种类似的方式产生集合和字典,而不是列表。
字典推导看起来像这样:
dict_comp = {key-expr: value-expr for value in collection
if condition}
集合推导看起来与等效的列表推导相同,只是用花括号代替方括号:
set_comp = {expr for value in collection if condition}
与列表推导类似,集合和字典推导大多是便利性的,但它们同样可以使代码更易于编写和阅读。考虑之前的字符串列表。假设我们想要一个集合,其中只包含集合中包含的字符串的长度;我们可以很容易地使用集合推导来计算:
In [157]: unique_lengths = {len(x) for x in strings}
In [158]: unique_lengths
Out[158]: {1, 2, 3, 4, 6}
我们也可以更加功能化地使用map函数,稍后介绍:
In [159]: set(map(len, strings))
Out[159]: {1, 2, 3, 4, 6}
作为一个简单的字典推导示例,我们可以创建一个查找这些字符串在列表中位置的查找映射:
In [160]: loc_mapping = {value: index for index, value in enumerate(strings)}
In [161]: loc_mapping
Out[161]: {'a': 0, 'as': 1, 'bat': 2, 'car': 3, 'dove': 4, 'python': 5}
嵌套列表推导
假设我们有一个包含一些英文和西班牙名字的列表列表:
In [162]: all_data = [["John", "Emily", "Michael", "Mary", "Steven"],
.....: ["Maria", "Juan", "Javier", "Natalia", "Pilar"]]
假设我们想要获得一个包含所有包含两个或更多个a的名称的单个列表。我们可以通过一个简单的for循环来实现:
In [163]: names_of_interest = []
In [164]: for names in all_data:
.....: enough_as = [name for name in names if name.count("a") >= 2]
.....: names_of_interest.extend(enough_as)
.....:
In [165]: names_of_interest
Out[165]: ['Maria', 'Natalia']
实际上,您可以将整个操作封装在一个单独的嵌套列表推导中,看起来像:
In [166]: result = [name for names in all_data for name in names
.....: if name.count("a") >= 2]
In [167]: result
Out[167]: ['Maria', 'Natalia']
起初,嵌套列表推导可能有点难以理解。列表推导的for部分按照嵌套的顺序排列,任何过滤条件都放在最后。这里是另一个示例,我们将整数元组的列表“展平”为一个简单的整数列表:
In [168]: some_tuples = [(1, 2, 3), (4, 5, 6), (7, 8, 9)]
In [169]: flattened = [x for tup in some_tuples for x in tup]
In [170]: flattened
Out[170]: [1, 2, 3, 4, 5, 6, 7, 8, 9]
请记住,如果您写一个嵌套的for循环而不是列表推导,for表达式的顺序将是相同的:
flattened = []
for tup in some_tuples:
for x in tup:
flattened.append(x)
您可以有任意多层的嵌套,尽管如果您有超过两三层的嵌套,您可能应该开始质疑这是否在代码可读性方面是有意义的。重要的是要区分刚刚显示的语法与列表推导内部的列表推导,后者也是完全有效的:
In [172]: [[x for x in tup] for tup in some_tuples]
Out[172]: [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
这将产生一个列表的列表,而不是所有内部元素的扁平化列表。
3.2 函数
函数是 Python 中代码组织和重用的主要和最重要的方法。作为一个经验法则,如果您预计需要重复相同或非常相似的代码超过一次,编写可重用的函数可能是值得的。函数还可以通过给一组 Python 语句命名来使您的代码更易读。
函数使用def关键字声明。函数包含一个代码块,可选使用return关键字:
In [173]: def my_function(x, y):
.....: return x + y
当到达带有return的行时,return后的值或表达式将发送到调用函数的上下文,例如:
In [174]: my_function(1, 2)
Out[174]: 3
In [175]: result = my_function(1, 2)
In [176]: result
Out[176]: 3
有多个return语句是没有问题的。如果 Python 在函数结尾处没有遇到return语句,将自动返回None。例如:
In [177]: def function_without_return(x):
.....: print(x)
In [178]: result = function_without_return("hello!")
hello!
In [179]: print(result)
None
每个函数可以有 位置 参数和 关键字 参数。关键字参数最常用于指定默认值或可选参数。在这里,我们将定义一个带有默认值 1.5 的可选 z 参数的函数:
def my_function2(x, y, z=1.5):
if z > 1:
return z * (x + y)
else:
return z / (x + y)
虽然关键字参数是可选的,但在调用函数时必须指定所有位置参数。
您可以向 z 参数传递值,可以使用关键字也可以不使用关键字,但建议使用关键字:
In [181]: my_function2(5, 6, z=0.7)
Out[181]: 0.06363636363636363
In [182]: my_function2(3.14, 7, 3.5)
Out[182]: 35.49
In [183]: my_function2(10, 20)
Out[183]: 45.0
对函数参数的主要限制是关键字参数 必须 跟在位置参数(如果有的话)后面。您可以以任何顺序指定关键字参数。这使您不必记住函数参数的指定顺序。您只需要记住它们的名称。
命名空间、作用域和本地函数
函数可以访问函数内部创建的变量以及函数外部在更高(甚至 全局)作用域中的变量。在 Python 中描述变量作用域的另一种更具描述性的名称是 命名空间。在函数内部分配的任何变量默认分配给本地命名空间。本地命名空间在函数调用时创建,并立即由函数的参数填充。函数完成后,本地命名空间将被销毁(有一些例外情况超出了本章的范围)。考虑以下函数:
def func():
a = []
for i in range(5):
a.append(i)
当调用 func() 时,将创建空列表 a,附加五个元素,然后在函数退出时销毁 a。假设我们改为这样声明 a:
In [184]: a = []
In [185]: def func():
.....: for i in range(5):
.....: a.append(i)
每次调用 func 都会修改列表 a:
In [186]: func()
In [187]: a
Out[187]: [0, 1, 2, 3, 4]
In [188]: func()
In [189]: a
Out[189]: [0, 1, 2, 3, 4, 0, 1, 2, 3, 4]
在函数范围之外分配变量是可能的,但这些变量必须使用 global 或 nonlocal 关键字显式声明:
In [190]: a = None
In [191]: def bind_a_variable():
.....: global a
.....: a = []
.....: bind_a_variable()
.....:
In [192]: print(a)
[]
nonlocal 允许函数修改在非全局高级作用域中定义的变量。由于它的使用有些神秘(我在这本书中从未使用过它),我建议您查阅 Python 文档以了解更多信息。
注意
我通常不鼓励使用 global 关键字。通常,全局变量用于在系统中存储某种状态。如果您发现自己使用了很多全局变量,这可能表明需要使用面向对象编程(使用类)
返回多个值
当我在 Java 和 C++ 中编程后第一次在 Python 中编程时,我最喜欢的功能之一是能够以简单的语法从函数中返回多个值。这里有一个例子:
def f():
a = 5
b = 6
c = 7
return a, b, c
a, b, c = f()
在数据分析和其他科学应用中,您可能经常这样做。这里发生的是函数实际上只返回一个对象,一个元组,然后将其解包为结果变量。在前面的例子中,我们可以这样做:
return_value = f()
在这种情况下,return_value 将是一个包含三个返回变量的 3 元组。与之前返回多个值的一个潜在有吸引力的替代方法可能是返回一个字典:
def f():
a = 5
b = 6
c = 7
return {"a" : a, "b" : b, "c" : c}
这种替代技术可以根据您尝试做什么而有用。
函数是对象
由于 Python 函数是对象,许多构造可以很容易地表达,而在其他语言中很难做到。假设我们正在进行一些数据清理,并需要对以下字符串列表应用一系列转换:
In [193]: states = [" Alabama ", "Georgia!", "Georgia", "georgia", "FlOrIda",
.....: "south carolina##", "West virginia?"]
任何曾经处理过用户提交的调查数据的人都会看到这样混乱的结果。需要做很多事情才能使这个字符串列表统一并准备好进行分析:去除空格、删除标点符号,并标准化适当的大写。其中一种方法是使用内置的字符串方法以及 re 标准库模块进行正则表达式:
import re
def clean_strings(strings):
result = []
for value in strings:
value = value.strip()
value = re.sub("[!#?]", "", value)
value = value.title()
result.append(value)
return result
结果如下:
In [195]: clean_strings(states)
Out[195]:
['Alabama',
'Georgia',
'Georgia',
'Georgia',
'Florida',
'South Carolina',
'West Virginia']
您可能会发现有用的另一种方法是制作一个要应用于特定字符串集的操作列表:
def remove_punctuation(value):
return re.sub("[!#?]", "", value)
clean_ops = [str.strip, remove_punctuation, str.title]
def clean_strings(strings, ops):
result = []
for value in strings:
for func in ops:
value = func(value)
result.append(value)
return result
然后我们有以下内容:
In [197]: clean_strings(states, clean_ops)
Out[197]:
['Alabama',
'Georgia',
'Georgia',
'Georgia',
'Florida',
'South Carolina',
'West Virginia']
像这样的更函数式模式使您能够轻松修改字符串在非常高级别上的转换方式。clean_strings函数现在也更具可重用性和通用性。
您可以将函数用作其他函数的参数,比如内置的map函数,它将一个函数应用于某种序列:
In [198]: for x in map(remove_punctuation, states):
.....: print(x)
Alabama
Georgia
Georgia
georgia
FlOrIda
south carolina
West virginia
map可以作为替代方案用于列表推导而不需要任何过滤器。
匿名(Lambda)函数
Python 支持所谓的匿名或lambda函数,这是一种编写由单个语句组成的函数的方式,其结果是返回值。它们使用lambda关键字定义,该关键字除了“我们正在声明一个匿名函数”之外没有其他含义:
In [199]: def short_function(x):
.....: return x * 2
In [200]: equiv_anon = lambda x: x * 2
我通常在本书的其余部分中将这些称为 lambda 函数。它们在数据分析中特别方便,因为正如您将看到的,有许多情况下,数据转换函数将接受函数作为参数。与编写完整函数声明或甚至将 lambda 函数分配给本地变量相比,传递 lambda 函数通常更少输入(更清晰)。考虑这个例子:
In [201]: def apply_to_list(some_list, f):
.....: return [f(x) for x in some_list]
In [202]: ints = [4, 0, 1, 5, 6]
In [203]: apply_to_list(ints, lambda x: x * 2)
Out[203]: [8, 0, 2, 10, 12]
您也可以写成[x * 2 for x in ints],但在这里我们能够简洁地将自定义运算符传递给apply_to_list函数。
举个例子,假设你想按每个字符串中不同字母的数量对字符串集合进行排序:
In [204]: strings = ["foo", "card", "bar", "aaaa", "abab"]
在这里,我们可以将一个 lambda 函数传递给列表的sort方法:
In [205]: strings.sort(key=lambda x: len(set(x)))
In [206]: strings
Out[206]: ['aaaa', 'foo', 'abab', 'bar', 'card']
生成器
Python 中的许多对象支持迭代,例如列表中的对象或文件中的行。这是通过迭代器协议实现的,这是一种使对象可迭代的通用方法。例如,对字典进行迭代会产生字典键:
In [207]: some_dict = {"a": 1, "b": 2, "c": 3}
In [208]: for key in some_dict:
.....: print(key)
a
b
c
当您写for key in some_dict时,Python 解释器首先尝试从some_dict创建一个迭代器:
In [209]: dict_iterator = iter(some_dict)
In [210]: dict_iterator
Out[210]: <dict_keyiterator at 0x17d60e020>
迭代器是任何对象,在上下文中像for循环中使用时,将向 Python 解释器产生对象。大多数期望列表或类似列表的对象的方法也将接受任何可迭代对象。这包括内置方法如min、max和sum,以及类构造函数如list和tuple:
In [211]: list(dict_iterator)
Out[211]: ['a', 'b', 'c']
生成器是一种方便的方式,类似于编写普通函数,来构造一个新的可迭代对象。普通函数一次执行并返回一个结果,而生成器可以通过暂停和恢复执行每次使用生成器时返回多个值的序列。要创建一个生成器,请在函数中使用yield关键字而不是return:
def squares(n=10):
print(f"Generating squares from 1 to {n ** 2}")
for i in range(1, n + 1):
yield i ** 2
当您实际调用生成器时,不会立即执行任何代码:
In [213]: gen = squares()
In [214]: gen
Out[214]: <generator object squares at 0x17d5fea40>
直到您请求生成器的元素时,它才开始执行其代码:
In [215]: for x in gen:
.....: print(x, end=" ")
Generating squares from 1 to 100
1 4 9 16 25 36 49 64 81 100
注意
由于生成器一次产生一个元素的输出,而不是一次产生整个列表,这可以帮助您的程序使用更少的内存。
生成器表达式
另一种生成器的方法是使用生成器表达式。这是列表、字典和集合推导的生成器类比。要创建一个,将否则是列表推导的内容括在括号中而不是方括号中:
In [216]: gen = (x ** 2 for x in range(100))
In [217]: gen
Out[217]: <generator object <genexpr> at 0x17d5feff0>
这等同于以下更冗长的生成器:
def _make_gen():
for x in range(100):
yield x ** 2
gen = _make_gen()
生成器表达式可以在某些情况下用作函数参数,而不是列表推导:
In [218]: sum(x ** 2 for x in range(100))
Out[218]: 328350
In [219]: dict((i, i ** 2) for i in range(5))
Out[219]: {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
根据推导表达式产生的元素数量,生成器版本有时可以更有意义地更快。
itertools 模块
标准库itertools模块具有许多常见数据算法的生成器集合。例如,groupby接受任何序列和一个函数,通过函数的返回值对序列中的连续元素进行分组。这里是一个例子:
In [220]: import itertools
In [221]: def first_letter(x):
.....: return x[0]
In [222]: names = ["Alan", "Adam", "Wes", "Will", "Albert", "Steven"]
In [223]: for letter, names in itertools.groupby(names, first_letter):
.....: print(letter, list(names)) # names is a generator
A ['Alan', 'Adam']
W ['Wes', 'Will']
A ['Albert']
S ['Steven']
查看表 3.2 以获取我经常发现有用的其他一些itertools函数列表。您可能想查看官方 Python 文档以获取有关这个有用的内置实用程序模块的更多信息。
表 3.2:一些有用的itertools函数
| 函数 | 描述 |
|---|---|
chain(*iterables) |
通过将迭代器链接在一起生成序列。一旦第一个迭代器的元素用尽,将返回下一个迭代器的元素,依此类推。 |
combinations(iterable, k) |
生成可迭代对象中所有可能的k元素元组的序列,忽略顺序且不重复(另请参阅伴随函数combinations_with_replacement)。 |
permutations(iterable, k) |
生成可迭代对象中所有可能的k元素元组的序列,保持顺序。 |
groupby(iterable[, keyfunc]) |
为每个唯一键生成(key, sub-iterator)。 |
| product(*iterables, repeat=1) | 生成输入可迭代对象的笛卡尔积作为元组,类似于嵌套的for循环。 |
错误和异常处理
处理 Python 错误或异常的优雅是构建健壮程序的重要部分。在数据分析应用中,许多函数只对特定类型的输入有效。例如,Python 的float函数能够将字符串转换为浮点数,但在不当输入时会引发ValueError异常:
In [224]: float("1.2345")
Out[224]: 1.2345
In [225]: float("something")
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-225-5ccfe07933f4> in <module>
----> 1 float("something")
ValueError: could not convert string to float: 'something'
假设我们想要一个版本的float,它能够优雅地失败,返回输入参数。我们可以通过编写一个函数,在其中将对float的调用封装在try/except块中来实现这一点(在 IPython 中执行此代码):
def attempt_float(x):
try:
return float(x)
except:
return x
块中的except部分的代码只有在float(x)引发异常时才会执行:
In [227]: attempt_float("1.2345")
Out[227]: 1.2345
In [228]: attempt_float("something")
Out[228]: 'something'
您可能会注意到float可能引发除ValueError之外的异常:
In [229]: float((1, 2))
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-229-82f777b0e564> in <module>
----> 1 float((1, 2))
TypeError: float() argument must be a string or a real number, not 'tuple'
您可能只想抑制ValueError,因为TypeError(输入不是字符串或数值)可能表明程序中存在合法错误。要做到这一点,请在except后面写上异常类型:
def attempt_float(x):
try:
return float(x)
except ValueError:
return x
然后我们有:
In [231]: attempt_float((1, 2))
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-231-8b0026e9e6b7> in <module>
----> 1 attempt_float((1, 2))
<ipython-input-230-6209ddecd2b5> in attempt_float(x)
1 def attempt_float(x):
2 try:
----> 3 return float(x)
4 except ValueError:
5 return x
TypeError: float() argument must be a string or a real number, not 'tuple'
您可以通过编写异常类型的元组来捕获多个异常类型(括号是必需的):
def attempt_float(x):
try:
return float(x)
except (TypeError, ValueError):
return x
在某些情况下,您可能不想抑制异常,但您希望无论try块中的代码是否成功,都执行一些代码。要做到这一点,请使用finally:
f = open(path, mode="w")
try:
write_to_file(f)
finally:
f.close()
在这里,文件对象f将始终被关闭。同样,您可以使用else来执行仅在try:块成功时执行的代码:
f = open(path, mode="w")
try:
write_to_file(f)
except:
print("Failed")
else:
print("Succeeded")
finally:
f.close()
在 IPython 中的异常
如果在%run脚本或执行任何语句时引发异常,默认情况下 IPython 将打印完整的调用堆栈跟踪(traceback),并在堆栈中的每个位置周围显示几行上下文:
In [10]: %run examples/ipython_bug.py
---------------------------------------------------------------------------
AssertionError Traceback (most recent call last)
/home/wesm/code/pydata-book/examples/ipython_bug.py in <module>()
13 throws_an_exception()
14
---> 15 calling_things()
/home/wesm/code/pydata-book/examples/ipython_bug.py in calling_things()
11 def calling_things():
12 works_fine()
---> 13 throws_an_exception()
14
15 calling_things()
/home/wesm/code/pydata-book/examples/ipython_bug.py in throws_an_exception()
7 a = 5
8 b = 6
----> 9 assert(a + b == 10)
10
11 def calling_things():
AssertionError:
仅仅通过附加上下文本身就是与标准 Python 解释器相比的一个巨大优势(标准 Python 解释器不提供任何额外上下文)。您可以使用%xmode魔术命令来控制显示的上下文量,从Plain(与标准 Python 解释器相同)到Verbose(内联函数参数值等)。正如您将在附录 B:更多关于 IPython 系统中看到的,您可以在错误发生后进行交互式事后调试,进入堆栈(使用%debug或%pdb魔术)。
本书的大部分内容使用高级工具如pandas.read_csv从磁盘读取数据文件到 Python 数据结构中。然而,了解如何在 Python 中处理文件的基础知识是很重要的。幸运的是,这相对简单,这也是 Python 在文本和文件处理方面如此受欢迎的原因。
要打开一个文件进行读取或写入,请使用内置的open函数,使用相对或绝对文件路径以及可选的文件编码:
In [233]: path = "examples/segismundo.txt"
In [234]: f = open(path, encoding="utf-8")
在这里,我传递 encoding="utf-8" 作为最佳实践,因为默认的 Unicode 编码读取文件在不同平台上有所不同。
默认情况下,文件以只读模式 "r" 打开。然后我们可以像处理列表一样处理文件对象 f 并迭代文件行:
for line in f:
print(line)
行从文件中出来时保留了行尾(EOL)标记,因此您经常会看到代码以获取文件中无行尾的行列表,如下所示:
In [235]: lines = [x.rstrip() for x in open(path, encoding="utf-8")]
In [236]: lines
Out[236]:
['Sueña el rico en su riqueza,',
'que más cuidados le ofrece;',
'',
'sueña el pobre que padece',
'su miseria y su pobreza;',
'',
'sueña el que a medrar empieza,',
'sueña el que afana y pretende,',
'sueña el que agravia y ofende,',
'',
'y en el mundo, en conclusión,',
'todos sueñan lo que son,',
'aunque ninguno lo entiende.',
'']
当使用 open 创建文件对象时,建议在完成后关闭文件。关闭文件会将其资源释放回操作系统:
In [237]: f.close()
使得清理打开文件更容易的一种方法是使用 with 语句:
In [238]: with open(path, encoding="utf-8") as f:
.....: lines = [x.rstrip() for x in f]
当退出 with 块时,这将自动关闭文件 f。确保关闭文件在许多小程序或脚本中不会导致问题,但在需要与大量文件交互的程序中可能会出现问题。
如果我们输入 f = open(path, "w"),examples/segismundo.txt 将会创建一个新文件(小心!),覆盖原来的任何文件。还有 "x" 文件模式,它创建一个可写文件,但如果文件路径已经存在则失败。查看 Table 3.3 获取所有有效的文件读写模式列表。
Table 3.3: Python 文件模式
| 模式 | 描述 |
|---|---|
r |
只读模式 |
w |
只写模式;创建一个新文件(擦除同名文件的数据) |
x |
只写模式;创建一个新文件,但如果文件路径已经存在则失败 |
a |
追加到现有文件(如果文件不存在则创建文件) |
r+ |
读取和写入 |
b |
用于二进制文件的附加模式(即 "rb" 或 "wb") |
t |
文件的文本模式(自动将字节解码为 Unicode);如果未指定,则为默认模式 |
对于可读文件,一些最常用的方法是 read、seek 和 tell。read 从文件返回一定数量的字符。什么构成一个“字符”取决于文件编码,或者如果文件以二进制模式打开,则是原始字节:
In [239]: f1 = open(path)
In [240]: f1.read(10)
Out[240]: 'Sueña el r'
In [241]: f2 = open(path, mode="rb") # Binary mode
In [242]: f2.read(10)
Out[242]: b'Sue\xc3\xb1a el '
read 方法通过读取的字节数推进文件对象位置。tell 给出当前位置:
In [243]: f1.tell()
Out[243]: 11
In [244]: f2.tell()
Out[244]: 10
即使我们从以文本模式打开的文件 f1 中读取了 10 个字符,位置也是 11,因为使用默认编码解码 10 个字符需要这么多字节。您可以在 sys 模块中检查默认编码:
In [245]: import sys
In [246]: sys.getdefaultencoding()
Out[246]: 'utf-8'
为了在各个平台上获得一致的行为,最好在打开文件时传递一个编码(例如 encoding="utf-8",这是广泛使用的)。
seek 将文件位置更改为文件中指定的字节:
In [247]: f1.seek(3)
Out[247]: 3
In [248]: f1.read(1)
Out[248]: 'ñ'
In [249]: f1.tell()
Out[249]: 5
最后,我们记得关闭文件:
In [250]: f1.close()
In [251]: f2.close()
要将文本写入文件,可以使用文件的 write 或 writelines 方法。例如,我们可以创建一个没有空行的 examples/segismundo.txt 版本如下:
In [252]: path
Out[252]: 'examples/segismundo.txt'
In [253]: with open("tmp.txt", mode="w") as handle:
.....: handle.writelines(x for x in open(path) if len(x) > 1)
In [254]: with open("tmp.txt") as f:
.....: lines = f.readlines()
In [255]: lines
Out[255]:
['Sueña el rico en su riqueza,\n',
'que más cuidados le ofrece;\n',
'sueña el pobre que padece\n',
'su miseria y su pobreza;\n',
'sueña el que a medrar empieza,\n',
'sueña el que afana y pretende,\n',
'sueña el que agravia y ofende,\n',
'y en el mundo, en conclusión,\n',
'todos sueñan lo que son,\n',
'aunque ninguno lo entiende.\n']
查看 Table 3.4 获取许多最常用的文件方法。
Table 3.4: 重要的 Python 文件方法或属性
| 方法/属性 | 描述 |
|---|---|
read([size]) |
根据文件模式返回文件数据作为字节或字符串,可选的 size 参数指示要读取的字节数或字符串字符数 |
readable() |
如果文件支持 read 操作则返回 True |
readlines([size]) |
返回文件中行的列表,带有可选的 size 参数 |
write(string) |
将传递的字符串写入文件 |
writable() |
如果文件支持 write 操作则返回 True |
writelines(strings) |
将传递的字符串序列写入文件 |
close() |
关闭文件对象 |
flush() |
刷新内部 I/O 缓冲区到磁盘 |
seek(pos) |
移动到指定的文件位置(整数) |
seekable() |
如果文件对象支持寻找并且随机访问则返回 True(某些类似文件的对象不支持) |
tell() |
返回当前文件位置作为整数 |
closed |
如果文件已关闭则为True |
encoding |
用于将文件中的字节解释为 Unicode 的编码(通常为 UTF-8) |
字节和 Unicode 与文件
Python 文件的默认行为(无论是可读还是可写)是文本模式,这意味着您打算使用 Python 字符串(即 Unicode)。这与二进制模式相反,您可以通过在文件模式后附加b来获得。重新访问上一节中包含 UTF-8 编码的非 ASCII 字符的文件,我们有:
In [258]: with open(path) as f:
.....: chars = f.read(10)
In [259]: chars
Out[259]: 'Sueña el r'
In [260]: len(chars)
Out[260]: 10
UTF-8 是一种可变长度的 Unicode 编码,因此当我从文件请求一些字符时,Python 会读取足够的字节(可能少至 10 个或多至 40 个字节)来解码相应数量的字符。如果我以"rb"模式打开文件,read请求确切数量的字节:
In [261]: with open(path, mode="rb") as f:
.....: data = f.read(10)
In [262]: data
Out[262]: b'Sue\xc3\xb1a el '
根据文本编码,您可能可以自己将字节解码为str对象,但前提是每个编码的 Unicode 字符都是完整形式的:
In [263]: data.decode("utf-8")
Out[263]: 'Sueña el '
In [264]: data[:4].decode("utf-8")
---------------------------------------------------------------------------
UnicodeDecodeError Traceback (most recent call last)
<ipython-input-264-846a5c2fed34> in <module>
----> 1 data[:4].decode("utf-8")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc3 in position 3: unexpecte
d end of data
文本模式,结合open的encoding选项,提供了一种方便的方法来将一个 Unicode 编码转换为另一个:
In [265]: sink_path = "sink.txt"
In [266]: with open(path) as source:
.....: with open(sink_path, "x", encoding="iso-8859-1") as sink:
.....: sink.write(source.read())
In [267]: with open(sink_path, encoding="iso-8859-1") as f:
.....: print(f.read(10))
Sueña el r
在除了二进制模式之外的任何模式下打开文件时要小心使用seek。如果文件位置落在定义 Unicode 字符的字节中间,那么后续的读取将导致错误:
In [269]: f = open(path, encoding='utf-8')
In [270]: f.read(5)
Out[270]: 'Sueña'
In [271]: f.seek(4)
Out[271]: 4
In [272]: f.read(1)
---------------------------------------------------------------------------
UnicodeDecodeError Traceback (most recent call last)
<ipython-input-272-5a354f952aa4> in <module>
----> 1 f.read(1)
~/miniforge-x86/envs/book-env/lib/python3.10/codecs.py in decode(self, input, fin
al)
320 # decode input (taking the buffer into account)
321 data = self.buffer + input
--> 322 (result, consumed) = self._buffer_decode(data, self.errors, final
)
323 # keep undecoded input until the next call
324 self.buffer = data[consumed:]
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb1 in position 0: invalid s
tart byte
In [273]: f.close()
如果您经常在非 ASCII 文本数据上进行数据分析,掌握 Python 的 Unicode 功能将会很有价值。查看Python 的在线文档获取更多信息。
3.4 结论
随着 Python 环境和语言的一些基础知识现在掌握,是时候继续学习 Python 中的 NumPy 和面向数组的计算了。
四、NumPy 基础知识:数组和向量化计算
原文:
wesmckinney.com/book/numpy-basics译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
NumPy,即 Numerical Python,是 Python 中最重要的数值计算基础包之一。许多提供科学功能的计算包使用 NumPy 的数组对象作为数据交换的标准接口之一。我涵盖的关于 NumPy 的许多知识也适用于 pandas。
以下是您将在 NumPy 中找到的一些内容:
-
ndarray,一种高效的多维数组,提供快速的基于数组的算术运算和灵活的广播功能
-
用于在整个数据数组上快速操作的数学函数,而无需编写循环
-
用于读取/写入数组数据到磁盘和处理内存映射文件的工具
-
线性代数、随机数生成和傅里叶变换功能
-
用于将 NumPy 与用 C、C++或 FORTRAN 编写的库连接的 C API
由于 NumPy 提供了全面且有文档的 C API,因此将数据传递给用低级语言编写的外部库,以及让外部库将数据作为 NumPy 数组返回给 Python 是很简单的。这个特性使 Python 成为封装传统 C、C++或 FORTRAN 代码库并为其提供动态和可访问接口的首选语言。
虽然 NumPy 本身并不提供建模或科学功能,但了解 NumPy 数组和面向数组的计算将帮助您更有效地使用具有数组计算语义的工具,如 pandas。由于 NumPy 是一个庞大的主题,我将在以后更深入地涵盖许多高级 NumPy 功能,比如广播(参见附录 A:高级 NumPy)。这些高级功能中的许多并不需要遵循本书的其余部分,但在您深入研究 Python 科学计算时可能会有所帮助。
对于大多数数据分析应用程序,我将关注的主要功能领域是:
-
用于数据整理和清洗、子集和过滤、转换以及任何其他类型计算的快速基于数组的操作
-
常见的数组算法,如排序、唯一值和集合操作
-
高效的描述统计和聚合/汇总数据
-
数据对齐和关系数据操作,用于合并和连接异构数据集
-
将条件逻辑表达为数组表达式,而不是使用
if-elif-else分支循环 -
分组数据操作(聚合、转换和函数应用)
虽然 NumPy 为一般数值数据处理提供了计算基础,但许多读者将希望使用 pandas 作为大多数统计或分析的基础,尤其是在表格数据上。此外,pandas 还提供了一些更具领域特定功能,如时间序列操作,这在 NumPy 中不存在。
注意
Python 中的面向数组计算可以追溯到 1995 年,当时 Jim Hugunin 创建了 Numeric 库。在接下来的 10 年里,许多科学编程社区开始在 Python 中进行数组编程,但在 2000 年代初,库生态系统变得分散。2005 年,Travis Oliphant 能够从当时的 Numeric 和 Numarray 项目中打造出 NumPy 项目,将社区团结在一个单一的数组计算框架周围。
NumPy 在 Python 中进行数值计算如此重要的原因之一是因为它专为大型数据数组的效率而设计。这有几个原因:*
-
NumPy 在内部以连续的内存块存储数据,独立于其他内置 Python 对象。NumPy 的用 C 语言编写的算法库可以在这个内存上操作,而无需进行任何类型检查或其他开销。NumPy 数组也比内置 Python 序列使用更少的内存。
-
NumPy 操作在整个数组上执行复杂计算,无需 Python
for循环,对于大型序列来说,这可能会很慢。NumPy 比常规 Python 代码更快,因为它的基于 C 的算法避免了常规解释 Python 代码的开销。
为了让您了解性能差异,考虑一个包含一百万个整数的 NumPy 数组,以及等效的 Python 列表:
In [7]: import numpy as np
In [8]: my_arr = np.arange(1_000_000)
In [9]: my_list = list(range(1_000_000))
现在让我们将每个序列乘以 2:
In [10]: %timeit my_arr2 = my_arr * 2
309 us +- 7.48 us per loop (mean +- std. dev. of 7 runs, 1000 loops each)
In [11]: %timeit my_list2 = [x * 2 for x in my_list]
46.4 ms +- 526 us per loop (mean +- std. dev. of 7 runs, 10 loops each)
基于 NumPy 的算法通常比纯 Python 对应算法快 10 到 100 倍(或更多),并且使用的内存明显更少。
4.1 NumPy ndarray:多维数组对象
NumPy 的一个关键特性是其 N 维数组对象,或者 ndarray,它是 Python 中大型数据集的快速、灵活的容器。数组使您能够使用类似标量元素之间等效操作的语法在整个数据块上执行数学运算。
为了让您了解 NumPy 如何使用类似标量值的语法在内置 Python 对象上进行批量计算,我首先导入 NumPy 并创建一个小数组:
In [12]: import numpy as np
In [13]: data = np.array([[1.5, -0.1, 3], [0, -3, 6.5]])
In [14]: data
Out[14]:
array([[ 1.5, -0.1, 3. ],
[ 0. , -3. , 6.5]])
然后我用data编写数学运算:
In [15]: data * 10
Out[15]:
array([[ 15., -1., 30.],
[ 0., -30., 65.]])
In [16]: data + data
Out[16]:
array([[ 3. , -0.2, 6. ],
[ 0. , -6. , 13. ]])
在第一个示例中,所有元素都乘以了 10。在第二个示例中,数组中每个“单元格”中的相应值已经相加。
注意
在本章和整本书中,我使用标准的 NumPy 约定,始终使用import numpy as np。您可以在代码中使用from numpy import *来避免编写np.,但我建议不要养成这种习惯。numpy命名空间很大,包含许多函数,它们的名称与内置 Python 函数(如min和max)冲突。遵循这些标准约定几乎总是一个好主意。
ndarray 是一个用于同质数据的通用多维容器;也就是说,所有元素必须是相同类型。每个数组都有一个shape,一个指示每个维度大小的元组,以及一个dtype,描述数组的数据类型的对象:
In [17]: data.shape
Out[17]: (2, 3)
In [18]: data.dtype
Out[18]: dtype('float64')
本章将介绍使用 NumPy 数组的基础知识,这应该足以跟随本书的其余部分。虽然对于许多数据分析应用程序来说,深入了解 NumPy 并不是必需的,但精通面向数组的编程和思维是成为科学 Python 大师的关键步骤。
注意
在书中文本中,每当您看到“array”,“NumPy array”或“ndarray”时,在大多数情况下它们都指的是 ndarray 对象。
创建 ndarrays
创建数组的最简单方法是使用array函数。它接受任何类似序列的对象(包括其他数组)并生成包含传递数据的新 NumPy 数组。例如,列表是一个很好的转换候选:
In [19]: data1 = [6, 7.5, 8, 0, 1]
In [20]: arr1 = np.array(data1)
In [21]: arr1
Out[21]: array([6. , 7.5, 8. , 0. , 1. ])
嵌套序列,比如等长列表的列表,将被转换为多维数组:
In [22]: data2 = [[1, 2, 3, 4], [5, 6, 7, 8]]
In [23]: arr2 = np.array(data2)
In [24]: arr2
Out[24]:
array([[1, 2, 3, 4],
[5, 6, 7, 8]])
由于data2是一个列表的列表,NumPy 数组arr2具有两个维度,形状从数据中推断出。我们可以通过检查ndim和shape属性来确认这一点:
In [25]: arr2.ndim
Out[25]: 2
In [26]: arr2.shape
Out[26]: (2, 4)
除非明确指定(在 ndarrays 的数据类型中讨论),numpy.array会尝试推断创建的数组的良好数据类型。数据类型存储在特殊的dtype元数据对象中;例如,在前两个示例中我们有:
In [27]: arr1.dtype
Out[27]: dtype('float64')
In [28]: arr2.dtype
Out[28]: dtype('int64')
除了numpy.array之外,还有许多其他用于创建新数组的函数。例如,numpy.zeros和numpy.ones分别创建长度或形状为 0 或 1 的数组。numpy.empty创建一个数组,而不将其值初始化为任何特定值。要使用这些方法创建更高维度的数组,请传递一个形状的元组:
In [29]: np.zeros(10)
Out[29]: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
In [30]: np.zeros((3, 6))
Out[30]:
array([[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.]])
In [31]: np.empty((2, 3, 2))
Out[31]:
array([[[0., 0.],
[0., 0.],
[0., 0.]],
[[0., 0.],
[0., 0.],
[0., 0.]]])
注意
不能假设numpy.empty会返回一个全为零的数组。该函数返回未初始化的内存,因此可能包含非零的“垃圾”值。只有在打算用数据填充新数组时才应使用此函数。
numpy.arange是内置 Python range函数的数组版本:
In [32]: np.arange(15)
Out[32]: array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14])
请参见表 4.1 中的一些标准数组创建函数的简要列表。由于 NumPy 专注于数值计算,如果未指定数据类型,数据类型在许多情况下将是float64(浮点数)。
表 4.1:一些重要的 NumPy 数组创建函数
| 函数 | 描述 |
|---|---|
array |
将输入数据(列表、元组、数组或其他序列类型)转换为 ndarray,可以通过推断数据类型或显式指定数据类型来完成;默认情况下会复制输入数据 |
asarray |
将输入转换为 ndarray,如果输入已经是 ndarray,则不复制 |
arange |
类似于内置的range,但返回一个 ndarray 而不是列表 |
ones, ones_like |
生成所有值为 1 的数组,具有给定的形状和数据类型;ones_like接受另一个数组,并生成相同形状和数据类型的ones数组 |
zeros, zeros_like |
类似于ones和ones_like,但生成的是全为 0 的数组 |
empty, empty_like |
通过分配新内存创建新数组,但不像ones和zeros那样填充任何值 |
full, full_like |
生成具有给定形状和数据类型的数组,所有值都设置为指定的“填充值”;full_like接受另一个数组,并生成相同形状和数据类型的填充数组 |
| eye, identity | 创建一个 N×N 的方阵单位矩阵(对角线上为 1,其他地方为 0) |
ndarrays 的数据类型
数据类型或dtype是一个特殊对象,包含 ndarray 需要将内存块解释为特定类型数据的信息(或元数据,关于数据的数据):
In [33]: arr1 = np.array([1, 2, 3], dtype=np.float64)
In [34]: arr2 = np.array([1, 2, 3], dtype=np.int32)
In [35]: arr1.dtype
Out[35]: dtype('float64')
In [36]: arr2.dtype
Out[36]: dtype('int32')
数据类型是 NumPy 灵活性的来源,用于与来自其他系统的数据进行交互。在大多数情况下,它们直接映射到底层磁盘或内存表示,这使得可以将数据的二进制流读写到磁盘,并连接到用低级语言(如 C 或 FORTRAN)编写的代码。数值数据类型的命名方式相同:类型名称,如float或int,后跟表示每个元素的位数的数字。标准的双精度浮点值(Python 中float对象底层使用的)占用 8 字节或 64 位。因此,在 NumPy 中,此类型称为float64。请参见表 4.2 以获取 NumPy 支持的数据类型的完整列表。
注意
不要担心记住 NumPy 数据类型,特别是如果您是新用户。通常只需要关心您正在处理的数据的一般类型,无论是浮点数、复数、整数、布尔值、字符串还是一般的 Python 对象。当您需要更多控制数据在内存和磁盘上的存储方式,特别是对于大型数据集时,知道您可以控制存储类型是很好的。
表 4.2:NumPy 数据类型
| 类型 | 类型代码 | 描述 |
|---|---|---|
int8, uint8 |
i1, u1 |
有符号和无符号 8 位(1 字节)整数类型 |
int16, uint16 |
i2, u2 |
有符号和无符号 16 位整数类型 |
int32, uint32 |
i4, u4 |
有符号和无符号 32 位整数类型 |
int64, uint64 |
i8, u8 |
有符号和无符号 64 位整数类型 |
float16 |
f2 |
半精度浮点数 |
float32 |
f4 或 f |
标准单精度浮点数;与 C 浮点兼容 |
float64 |
f8 或 d |
标准双精度浮点数;与 C 双精度和 Python float对象兼容 |
float128 |
f16 或 g |
扩展精度浮点数 |
complex64,complex128,complex256 |
c8, c16, c32 |
分别由两个 32、64 或 128 个浮点数表示的复数 |
bool |
? | 存储True和False值的布尔类型 |
object |
O | Python 对象类型;值可以是任何 Python 对象 |
string_ |
S | 固定长度 ASCII 字符串类型(每个字符 1 字节);例如,要创建长度为 10 的字符串数据类型,请使用'S10' |
unicode_ |
U | 固定长度 Unicode 类型(字节数平台特定);与string_(例如,'U10')具有相同的规范语义 |
注意
有有符号和无符号整数类型,许多读者可能不熟悉这个术语。有符号整数可以表示正整数和负整数,而无符号整数只能表示非零整数。例如,int8(有符号 8 位整数)可以表示从-128 到 127(包括)的整数,而uint8(无符号 8 位整数)可以表示 0 到 255。
您可以使用 ndarray 的astype方法显式地将数组从一种数据类型转换为另一种数据类型:
In [37]: arr = np.array([1, 2, 3, 4, 5])
In [38]: arr.dtype
Out[38]: dtype('int64')
In [39]: float_arr = arr.astype(np.float64)
In [40]: float_arr
Out[40]: array([1., 2., 3., 4., 5.])
In [41]: float_arr.dtype
Out[41]: dtype('float64')
在这个例子中,整数被转换为浮点数。如果我将一些浮点数转换为整数数据类型,小数部分将被截断:
In [42]: arr = np.array([3.7, -1.2, -2.6, 0.5, 12.9, 10.1])
In [43]: arr
Out[43]: array([ 3.7, -1.2, -2.6, 0.5, 12.9, 10.1])
In [44]: arr.astype(np.int32)
Out[44]: array([ 3, -1, -2, 0, 12, 10], dtype=int32)
如果您有一个表示数字的字符串数组,可以使用astype将它们转换为数值形式:
In [45]: numeric_strings = np.array(["1.25", "-9.6", "42"], dtype=np.string_)
In [46]: numeric_strings.astype(float)
Out[46]: array([ 1.25, -9.6 , 42. ])
注意
在使用numpy.string_类型时要小心,因为 NumPy 中的字符串数据是固定大小的,可能会在没有警告的情况下截断输入。pandas 对非数值数据具有更直观的开箱即用行为。
如果由于某种原因(例如无法将字符串转换为float64)而转换失败,将引发ValueError。以前,我有点懒,写了float而不是np.float64;NumPy 将 Python 类型别名为其自己的等效数据类型。
您还可以使用另一个数组的dtype属性:
In [47]: int_array = np.arange(10)
In [48]: calibers = np.array([.22, .270, .357, .380, .44, .50], dtype=np.float64)
In [49]: int_array.astype(calibers.dtype)
Out[49]: array([0., 1., 2., 3., 4., 5., 6., 7., 8., 9.])
有简写类型代码字符串,您也可以使用它们来引用dtype:
In [50]: zeros_uint32 = np.zeros(8, dtype="u4")
In [51]: zeros_uint32
Out[51]: array([0, 0, 0, 0, 0, 0, 0, 0], dtype=uint32)
注意
调用astype 总是会创建一个新数组(数据的副本),即使新数据类型与旧数据类型相同。
NumPy 数组的算术运算
数组很重要,因为它们使您能够在不编写任何for循环的情况下对数据执行批量操作。NumPy 用户称之为向量化。任何等大小数组之间的算术运算都会逐元素应用该操作:
In [52]: arr = np.array([[1., 2., 3.], [4., 5., 6.]])
In [53]: arr
Out[53]:
array([[1., 2., 3.],
[4., 5., 6.]])
In [54]: arr * arr
Out[54]:
array([[ 1., 4., 9.],
[16., 25., 36.]])
In [55]: arr - arr
Out[55]:
array([[0., 0., 0.],
[0., 0., 0.]])
标量的算术运算会将标量参数传播到数组中的每个元素:
In [56]: 1 / arr
Out[56]:
array([[1. , 0.5 , 0.3333],
[0.25 , 0.2 , 0.1667]])
In [57]: arr ** 2
Out[57]:
array([[ 1., 4., 9.],
[16., 25., 36.]])
相同大小的数组之间的比较会产生布尔数组:
In [58]: arr2 = np.array([[0., 4., 1.], [7., 2., 12.]])
In [59]: arr2
Out[59]:
array([[ 0., 4., 1.],
[ 7., 2., 12.]])
In [60]: arr2 > arr
Out[60]:
array([[False, True, False],
[ True, False, True]])
在不同大小的数组之间进行操作被称为广播,将在附录 A:高级 NumPy 中更详细地讨论。对广播的深入理解对本书的大部分内容并不是必要的。
基本索引和切片
NumPy 数组索引是一个深入的话题,因为有许多种方式可以选择数据的子集或单个元素。一维数组很简单;从表面上看,它们的行为类似于 Python 列表:
In [61]: arr = np.arange(10)
In [62]: arr
Out[62]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
In [63]: arr[5]
Out[63]: 5
In [64]: arr[5:8]
Out[64]: array([5, 6, 7])
In [65]: arr[5:8] = 12
In [66]: arr
Out[66]: array([ 0, 1, 2, 3, 4, 12, 12, 12, 8, 9])
正如您所看到的,如果您将标量值分配给一个切片,如arr[5:8] = 12,该值将传播(或者广播)到整个选择。
注意
与 Python 内置列表的一个重要区别是,数组切片是原始数组的视图。这意味着数据没有被复制,对视图的任何修改都将反映在源数组中。
为了举例说明,我首先创建arr的一个切片:
In [67]: arr_slice = arr[5:8]
In [68]: arr_slice
Out[68]: array([12, 12, 12])
现在,当我在arr_slice中更改值时,这些变化会反映在原始数组arr中:
In [69]: arr_slice[1] = 12345
In [70]: arr
Out[70]:
array([ 0, 1, 2, 3, 4, 12, 12345, 12, 8,
9])
“裸”切片[:]将分配给数组中的所有值:
In [71]: arr_slice[:] = 64
In [72]: arr
Out[72]: array([ 0, 1, 2, 3, 4, 64, 64, 64, 8, 9])
如果您是 NumPy 的新手,您可能会对此感到惊讶,特别是如果您已经使用过其他更积极复制数据的数组编程语言。由于 NumPy 被设计为能够处理非常大的数组,如果 NumPy 坚持始终复制数据,您可能会遇到性能和内存问题。
注意
如果您想要一个 ndarray 切片的副本而不是视图,您需要显式复制数组,例如arr[5:8].copy()。正如您将看到的,pandas 也是这样工作的。
对于更高维度的数组,您有更多的选择。在二维数组中,每个索引处的元素不再是标量,而是一维数组:
In [73]: arr2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
In [74]: arr2d[2]
Out[74]: array([7, 8, 9])
因此,可以递归访问单个元素。但这有点太麻烦了,所以您可以传递一个逗号分隔的索引列表来选择单个元素。因此,这些是等价的:
In [75]: arr2d[0][2]
Out[75]: 3
In [76]: arr2d[0, 2]
Out[76]: 3
请参见图 4.1 以了解如何在二维数组上进行索引的说明。我发现将轴 0 视为数组的“行”而将轴 1 视为“列”是有帮助的。

图 4.1:索引 NumPy 数组中的元素
在多维数组中,如果省略后面的索引,返回的对象将是一个较低维度的 ndarray,由沿着更高维度的所有数据组成。因此,在 2×2×3 数组arr3d中:
In [77]: arr3d = np.array([[[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]])
In [78]: arr3d
Out[78]:
array([[[ 1, 2, 3],
[ 4, 5, 6]],
[[ 7, 8, 9],
[10, 11, 12]]])
arr3d[0]是一个 2×3 数组:
In [79]: arr3d[0]
Out[79]:
array([[1, 2, 3],
[4, 5, 6]])
标量值和数组都可以分配给arr3d[0]:
In [80]: old_values = arr3d[0].copy()
In [81]: arr3d[0] = 42
In [82]: arr3d
Out[82]:
array([[[42, 42, 42],
[42, 42, 42]],
[[ 7, 8, 9],
[10, 11, 12]]])
In [83]: arr3d[0] = old_values
In [84]: arr3d
Out[84]:
array([[[ 1, 2, 3],
[ 4, 5, 6]],
[[ 7, 8, 9],
[10, 11, 12]]])
类似地,arr3d[1, 0]会给您所有索引以(1, 0)开头的值,形成一个一维数组:
In [85]: arr3d[1, 0]
Out[85]: array([7, 8, 9])
这个表达式与我们分两步索引的方式相同:
In [86]: x = arr3d[1]
In [87]: x
Out[87]:
array([[ 7, 8, 9],
[10, 11, 12]])
In [88]: x[0]
Out[88]: array([7, 8, 9])
请注意,在所有这些选择数组的子部分的情况下,返回的数组都是视图。
注意
这种用于 NumPy 数组的多维索引语法不适用于常规的 Python 对象,例如列表的列表。
使用切片进行索引
像 Python 列表这样的一维对象一样,ndarrays 可以使用熟悉的语法进行切片:
In [89]: arr
Out[89]: array([ 0, 1, 2, 3, 4, 64, 64, 64, 8, 9])
In [90]: arr[1:6]
Out[90]: array([ 1, 2, 3, 4, 64])
考虑之前的二维数组arr2d。对该数组进行切片有点不同:
In [91]: arr2d
Out[91]:
array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
In [92]: arr2d[:2]
Out[92]:
array([[1, 2, 3],
[4, 5, 6]])
正如您所看到的,它已经沿着轴 0 切片,即第一个轴。因此,切片选择沿着一个轴的一系列元素。阅读表达式arr2d[:2]为“选择arr2d的前两行”可能会有所帮助。
您可以像传递多个索引一样传递多个切片:
In [93]: arr2d[:2, 1:]
Out[93]:
array([[2, 3],
[5, 6]])
像这样切片时,您总是获得相同维数的数组视图。通过混合整数索引和切片,您可以获得较低维度的切片。
例如,我可以选择第二行,但只选择前两列,如下所示:
In [94]: lower_dim_slice = arr2d[1, :2]
在这里,虽然arr2d是二维的,lower_dim_slice是一维的,其形状是一个带有一个轴大小的元组:
In [95]: lower_dim_slice.shape
Out[95]: (2,)
同样,我可以选择第三列,但只选择前两行,如下所示:
In [96]: arr2d[:2, 2]
Out[96]: array([3, 6])
请参见图 4.2 进行说明。请注意,单独的冒号表示取整个轴,因此您可以通过以下方式仅切片更高维度的轴:
In [97]: arr2d[:, :1]
Out[97]:
array([[1],
[4],
[7]])
当然,对切片表达式的分配会分配给整个选择:
In [98]: arr2d[:2, 1:] = 0
In [99]: arr2d
Out[99]:
array([[1, 0, 0],
[4, 0, 0],
[7, 8, 9]])

图 4.2:二维数组切片
布尔索引
让我们考虑一个例子,其中我们有一些数据在一个数组中,并且有一个包含重复名称的数组:
In [100]: names = np.array(["Bob", "Joe", "Will", "Bob", "Will", "Joe", "Joe"])
In [101]: data = np.array([[4, 7], [0, 2], [-5, 6], [0, 0], [1, 2],
.....: [-12, -4], [3, 4]])
In [102]: names
Out[102]: array(['Bob', 'Joe', 'Will', 'Bob', 'Will', 'Joe', 'Joe'], dtype='<U4')
In [103]: data
Out[103]:
array([[ 4, 7],
[ 0, 2],
[ -5, 6],
[ 0, 0],
[ 1, 2],
[-12, -4],
[ 3, 4]])
假设每个名称对应于data数组中的一行,并且我们想要选择所有与相应名称"Bob"相对应的行。与算术运算一样,与数组进行比较(如==)也是矢量化的。因此,将names与字符串"Bob"进行比较会产生一个布尔数组:
In [104]: names == "Bob"
Out[104]: array([ True, False, False, True, False, False, False])
此布尔数组可以在索引数组时传递:
In [105]: data[names == "Bob"]
Out[105]:
array([[4, 7],
[0, 0]])
布尔数组的长度必须与其索引的数组轴的长度相同。甚至可以将布尔数组与切片或整数(或整数序列)混合使用(稍后将详细介绍)。
在这些示例中,我从names == "Bob"的行中选择,并且也索引列:
In [106]: data[names == "Bob", 1:]
Out[106]:
array([[7],
[0]])
In [107]: data[names == "Bob", 1]
Out[107]: array([7, 0])
要选择除了"Bob"之外的所有内容,可以使用!=或使用~否定条件:
In [108]: names != "Bob"
Out[108]: array([False, True, True, False, True, True, True])
In [109]: ~(names == "Bob")
Out[109]: array([False, True, True, False, True, True, True])
In [110]: data[~(names == "Bob")]
Out[110]:
array([[ 0, 2],
[ -5, 6],
[ 1, 2],
[-12, -4],
[ 3, 4]])
当您想要反转由变量引用的布尔数组时,~运算符可能很有用:
In [111]: cond = names == "Bob"
In [112]: data[~cond]
Out[112]:
array([[ 0, 2],
[ -5, 6],
[ 1, 2],
[-12, -4],
[ 3, 4]])
使用布尔运算符如&(和)和|(或)选择三个名称中的两个来组合多个布尔条件:
In [113]: mask = (names == "Bob") | (names == "Will")
In [114]: mask
Out[114]: array([ True, False, True, True, True, False, False])
In [115]: data[mask]
Out[115]:
array([[ 4, 7],
[-5, 6],
[ 0, 0],
[ 1, 2]])
通过布尔索引从数组中选择数据并将结果分配给新变量始终会创建数据的副本,即使返回的数组未更改。
注意
Python 关键字and和or不能与布尔数组一起使用。请改用&(和)和|(或)。
使用布尔数组设置值的工作方式是将右侧的值或值替换到布尔数组的值为True的位置。要将data中的所有负值设置为 0,我们只需要执行:
In [116]: data[data < 0] = 0
In [117]: data
Out[117]:
array([[4, 7],
[0, 2],
[0, 6],
[0, 0],
[1, 2],
[0, 0],
[3, 4]])
您还可以使用一维布尔数组设置整行或整列:
In [118]: data[names != "Joe"] = 7
In [119]: data
Out[119]:
array([[7, 7],
[0, 2],
[7, 7],
[7, 7],
[7, 7],
[0, 0],
[3, 4]])
正如我们将在后面看到的,对二维数据进行这些类型的操作很方便使用 pandas。
花式索引
花式索引是 NumPy 采用的术语,用于描述使用整数数组进行索引。假设我们有一个 8×4 数组:
In [120]: arr = np.zeros((8, 4))
In [121]: for i in range(8):
.....: arr[i] = i
In [122]: arr
Out[122]:
array([[0., 0., 0., 0.],
[1., 1., 1., 1.],
[2., 2., 2., 2.],
[3., 3., 3., 3.],
[4., 4., 4., 4.],
[5., 5., 5., 5.],
[6., 6., 6., 6.],
[7., 7., 7., 7.]])
要按特定顺序选择行的子集,只需传递一个指定所需顺序的整数列表或 ndarray:
In [123]: arr[[4, 3, 0, 6]]
Out[123]:
array([[4., 4., 4., 4.],
[3., 3., 3., 3.],
[0., 0., 0., 0.],
[6., 6., 6., 6.]])
希望这段代码符合您的期望!使用负索引可从末尾选择行:
In [124]: arr[[-3, -5, -7]]
Out[124]:
array([[5., 5., 5., 5.],
[3., 3., 3., 3.],
[1., 1., 1., 1.]])
传递多个索引数组会产生略有不同的结果;它选择与每个索引元组对应的一维数组元素:
In [125]: arr = np.arange(32).reshape((8, 4))
In [126]: arr
Out[126]:
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11],
[12, 13, 14, 15],
[16, 17, 18, 19],
[20, 21, 22, 23],
[24, 25, 26, 27],
[28, 29, 30, 31]])
In [127]: arr[[1, 5, 7, 2], [0, 3, 1, 2]]
Out[127]: array([ 4, 23, 29, 10])
要了解有关reshape方法的更多信息,请查看附录 A:高级 NumPy。
这里选择了元素(1, 0), (5, 3), (7, 1)和(2, 2)。使用与轴数量相同的整数数组进行花式索引的结果始终是一维的。
在这种情况下,花式索引的行为与一些用户可能期望的有些不同(包括我自己),即通过选择矩阵的行和列的子集形成的矩形区域。以下是获得该区域的一种方法:
In [128]: arr[[1, 5, 7, 2]][:, [0, 3, 1, 2]]
Out[128]:
array([[ 4, 7, 5, 6],
[20, 23, 21, 22],
[28, 31, 29, 30],
[ 8, 11, 9, 10]])
请记住,花式索引与切片不同,当将结果分配给新变量时,总是将数据复制到新数组中。如果使用花式索引分配值,则将修改索引的值:
In [129]: arr[[1, 5, 7, 2], [0, 3, 1, 2]]
Out[129]: array([ 4, 23, 29, 10])
In [130]: arr[[1, 5, 7, 2], [0, 3, 1, 2]] = 0
In [131]: arr
Out[131]:
array([[ 0, 1, 2, 3],
[ 0, 5, 6, 7],
[ 8, 9, 0, 11],
[12, 13, 14, 15],
[16, 17, 18, 19],
[20, 21, 22, 0],
[24, 25, 26, 27],
[28, 0, 30, 31]])
转置数组和交换轴
转置是一种特殊的重塑形式,类似地返回基础数据的视图,而不复制任何内容。数组具有transpose方法和特殊的T属性:
In [132]: arr = np.arange(15).reshape((3, 5))
In [133]: arr
Out[133]:
array([[ 0, 1, 2, 3, 4],
[ 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14]])
In [134]: arr.T
Out[134]:
array([[ 0, 5, 10],
[ 1, 6, 11],
[ 2, 7, 12],
[ 3, 8, 13],
[ 4, 9, 14]])
在进行矩阵计算时,您可能经常这样做-例如,使用numpy.dot计算内部矩阵乘积时:
In [135]: arr = np.array([[0, 1, 0], [1, 2, -2], [6, 3, 2], [-1, 0, -1], [1, 0, 1
]])
In [136]: arr
Out[136]:
array([[ 0, 1, 0],
[ 1, 2, -2],
[ 6, 3, 2],
[-1, 0, -1],
[ 1, 0, 1]])
In [137]: np.dot(arr.T, arr)
Out[137]:
array([[39, 20, 12],
[20, 14, 2],
[12, 2, 10]])
@中缀运算符是进行矩阵乘法的另一种方式:
In [138]: arr.T @ arr
Out[138]:
array([[39, 20, 12],
[20, 14, 2],
[12, 2, 10]])
使用.T进行简单的转置是交换轴的特例。ndarray 具有swapaxes方法,该方法接受一对轴编号,并切换指定的轴以重新排列数据:
In [139]: arr
Out[139]:
array([[ 0, 1, 0],
[ 1, 2, -2],
[ 6, 3, 2],
[-1, 0, -1],
[ 1, 0, 1]])
In [140]: arr.swapaxes(0, 1)
Out[140]:
array([[ 0, 1, 6, -1, 1],
[ 1, 2, 3, 0, 0],
[ 0, -2, 2, -1, 1]])
类似地,swapaxes返回数据的视图而不进行复制。
4.2 伪随机数生成
numpy.random 模块通过函数有效地生成许多种概率分布的样本值的整个数组来补充内置的 Python random 模块。例如,您可以使用 numpy.random.standard_normal 从标准正态分布中获取一个 4 × 4 的样本数组:
In [141]: samples = np.random.standard_normal(size=(4, 4))
In [142]: samples
Out[142]:
array([[-0.2047, 0.4789, -0.5194, -0.5557],
[ 1.9658, 1.3934, 0.0929, 0.2817],
[ 0.769 , 1.2464, 1.0072, -1.2962],
[ 0.275 , 0.2289, 1.3529, 0.8864]])
相比之下,Python 的内置 random 模块一次只抽取一个值。从这个基准测试中可以看出,对于生成非常大的样本,numpy.random 的速度要快一个数量级以上:
In [143]: from random import normalvariate
In [144]: N = 1_000_000
In [145]: %timeit samples = [normalvariate(0, 1) for _ in range(N)]
490 ms +- 2.23 ms per loop (mean +- std. dev. of 7 runs, 1 loop each)
In [146]: %timeit np.random.standard_normal(N)
32.6 ms +- 271 us per loop (mean +- std. dev. of 7 runs, 10 loops each)
这些随机数并不是真正的随机(而是伪随机),而是由可配置的随机数生成器生成的,该生成器确定确定性地创建哪些值。像 numpy.random.standard_normal 这样的函数使用 numpy.random 模块的默认随机数生成器,但是您的代码可以配置为使用显式生成器:
In [147]: rng = np.random.default_rng(seed=12345)
In [148]: data = rng.standard_normal((2, 3))
seed 参数决定生成器的初始状态,每次使用 rng 对象生成数据时状态都会改变。生成器对象 rng 也与可能使用 numpy.random 模块的其他代码隔离开来:
In [149]: type(rng)
Out[149]: numpy.random._generator.Generator
查看 表 4.3 以获取类似 rng 这样的随机生成器对象上可用的部分方法列表。我将使用上面创建的 rng 对象在本章的其余部分生成随机数据。
表 4.3:NumPy 随机数生成器方法
| 方法 | 描述 |
|---|---|
permutation |
返回一个序列的随机排列,或返回一个排列的范围 |
shuffle |
在原地随机排列一个序列 |
uniform |
从均匀分布中抽取样本 |
integers |
从给定的低到高范围中抽取随机整数 |
standard_normal |
从均值为 0,标准差为 1 的正态分布中抽取样本 |
binomial |
从二项分布中抽取样本 |
normal |
从正态(高斯)分布中抽取样本 |
beta |
从 beta 分布中抽取样本 |
chisquare |
从卡方分布中抽取样本 |
gamma |
从 gamma 分布中抽取样本 |
uniform |
从均匀 [0, 1) 分布中抽取样本 |
4.3 通用函数:快速逐元素数组函数
通用函数,或者 ufunc,是在 ndarrays 中对数据执行逐元素操作的函数。您可以将它们看作是快速矢量化的简单函数的包装器,这些函数接受一个或多个标量值并产生一个或多个标量结果。
许多 ufuncs 都是简单的逐元素转换,比如 numpy.sqrt 或 numpy.exp:
In [150]: arr = np.arange(10)
In [151]: arr
Out[151]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
In [152]: np.sqrt(arr)
Out[152]:
array([0. , 1. , 1.4142, 1.7321, 2. , 2.2361, 2.4495, 2.6458,
2.8284, 3. ])
In [153]: np.exp(arr)
Out[153]:
array([ 1. , 2.7183, 7.3891, 20.0855, 54.5982, 148.4132,
403.4288, 1096.6332, 2980.958 , 8103.0839])
这些被称为一元 ufuncs。其他一些,比如 numpy.add 或 numpy.maximum,接受两个数组(因此是二元 ufuncs)并返回一个单一数组作为结果:
In [154]: x = rng.standard_normal(8)
In [155]: y = rng.standard_normal(8)
In [156]: x
Out[156]:
array([-1.3678, 0.6489, 0.3611, -1.9529, 2.3474, 0.9685, -0.7594,
0.9022])
In [157]: y
Out[157]:
array([-0.467 , -0.0607, 0.7888, -1.2567, 0.5759, 1.399 , 1.3223,
-0.2997])
In [158]: np.maximum(x, y)
Out[158]:
array([-0.467 , 0.6489, 0.7888, -1.2567, 2.3474, 1.399 , 1.3223,
0.9022])
在这个例子中,numpy.maximum 计算了 x 和 y 中元素的逐元素最大值。
虽然不常见,ufunc 可以返回多个数组。numpy.modf 就是一个例子:它是内置 Python math.modf 的矢量化版本,返回浮点数组的小数部分和整数部分:
In [159]: arr = rng.standard_normal(7) * 5
In [160]: arr
Out[160]: array([ 4.5146, -8.1079, -0.7909, 2.2474, -6.718 , -0.4084, 8.6237])
In [161]: remainder, whole_part = np.modf(arr)
In [162]: remainder
Out[162]: array([ 0.5146, -0.1079, -0.7909, 0.2474, -0.718 , -0.4084, 0.6237])
In [163]: whole_part
Out[163]: array([ 4., -8., -0., 2., -6., -0., 8.])
Ufuncs 接受一个可选的 out 参数,允许它们将结果分配到现有数组中,而不是创建一个新数组:
In [164]: arr
Out[164]: array([ 4.5146, -8.1079, -0.7909, 2.2474, -6.718 , -0.4084, 8.6237])
In [165]: out = np.zeros_like(arr)
In [166]: np.add(arr, 1)
Out[166]: array([ 5.5146, -7.1079, 0.2091, 3.2474, -5.718 , 0.5916, 9.6237])
In [167]: np.add(arr, 1, out=out)
Out[167]: array([ 5.5146, -7.1079, 0.2091, 3.2474, -5.718 , 0.5916, 9.6237])
In [168]: out
Out[168]: array([ 5.5146, -7.1079, 0.2091, 3.2474, -5.718 , 0.5916, 9.6237])
查看 表 4.4 和 表 4.5 以获取 NumPy 的一些 ufuncs 列表。新的 ufuncs 仍在不断添加到 NumPy 中,因此查阅在线 NumPy 文档是获取全面列表并保持最新的最佳方式。
表 4.4:一些一元通用函数
| 函数 | 描述 |
|---|---|
abs, fabs |
计算整数、浮点数或复数值的绝对值元素 |
sqrt |
计算每个元素的平方根(等同于 arr ** 0.5) |
square |
计算每个元素的平方(等同于 arr ** 2) |
exp |
计算每个元素的 e^x 指数 |
log, log10, log2, log1p |
自然对数(基数e),以 10 为底的对数,以 2 为底的对数,以及 log(1 + x) |
sign |
计算每个元素的符号:1(正数),0(零),或-1(负数) |
ceil |
计算每个元素的上限(即大于或等于该数字的最小整数) |
floor |
计算每个元素的下限(即小于或等于每个元素的最大整数) |
rint |
将元素四舍五入到最近的整数,保留dtype |
modf |
将数组的分数部分和整数部分作为单独的数组返回 |
isnan |
返回布尔数组,指示每个值是否为NaN(不是一个数字) |
isfinite, isinf |
返回布尔数组,指示每个元素是否有限(非inf,非NaN)或无限 |
cos, cosh, sin, sinh, tan, tanh |
常规和双曲三角函数 |
arccos, arccosh, arcsin, arcsinh, arctan, arctanh |
反三角函数 |
logical_not |
逐个元素计算not x的真值(等同于~arr) |
表 4.5:一些二元通用函数
| 函数 | 描述 |
|---|---|
add |
将数组中对应的元素相加 |
subtract |
从第一个数组中减去第二个数组中的元素 |
multiply |
乘以数组元素 |
divide, floor_divide |
除法或地板除法(截断余数) |
power |
将第一个数组中的元素提升到第二个数组中指示的幂 |
maximum, fmax |
逐个元素的最大值;fmax忽略NaN |
minimum, fmin |
逐个元素的最小值;fmin忽略NaN |
mod |
逐个元素的模数(除法的余数) |
copysign |
将第二个参数中的值的符号复制到第一个参数中的值 |
greater, greater_equal, less, less_equal, equal, not_equal |
执行逐个元素的比较,产生布尔数组(等同于中缀运算符>, >=, <, <=, ==, !=) |
logical_and |
计算逻辑与(&)的逐个元素真值 |
logical_or |
计算逻辑或(` |
logical_xor |
计算逻辑异或(^)的逐个元素真值 |
4.4 数组导向编程与数组
使用 NumPy 数组使您能够将许多种类的数据处理任务表达为简洁的数组表达式,否则可能需要编写循环。用数组表达式替换显式循环的这种做法被一些人称为向量化。一般来说,向量化的数组操作通常比它们纯 Python 等效的要快得多,在任何类型的数值计算中影响最大。稍后,在附录 A:高级 NumPy 中,我将解释广播,这是一种用于向量化计算的强大方法。
举个简单的例子,假设我们希望在一组常规值的网格上评估函数sqrt(x² + y²)。numpy.meshgrid函数接受两个一维数组,并产生两个对应于两个数组中所有(x, y)对的二维矩阵:
In [169]: points = np.arange(-5, 5, 0.01) # 100 equally spaced points
In [170]: xs, ys = np.meshgrid(points, points)
In [171]: ys
Out[171]:
array([[-5. , -5. , -5. , ..., -5. , -5. , -5. ],
[-4.99, -4.99, -4.99, ..., -4.99, -4.99, -4.99],
[-4.98, -4.98, -4.98, ..., -4.98, -4.98, -4.98],
...,
[ 4.97, 4.97, 4.97, ..., 4.97, 4.97, 4.97],
[ 4.98, 4.98, 4.98, ..., 4.98, 4.98, 4.98],
[ 4.99, 4.99, 4.99, ..., 4.99, 4.99, 4.99]])
现在,评估函数只是写出您将用两个点写出的相同表达式的问题:
In [172]: z = np.sqrt(xs ** 2 + ys ** 2)
In [173]: z
Out[173]:
array([[7.0711, 7.064 , 7.0569, ..., 7.0499, 7.0569, 7.064 ],
[7.064 , 7.0569, 7.0499, ..., 7.0428, 7.0499, 7.0569],
[7.0569, 7.0499, 7.0428, ..., 7.0357, 7.0428, 7.0499],
...,
[7.0499, 7.0428, 7.0357, ..., 7.0286, 7.0357, 7.0428],
[7.0569, 7.0499, 7.0428, ..., 7.0357, 7.0428, 7.0499],
[7.064 , 7.0569, 7.0499, ..., 7.0428, 7.0499, 7.0569]])
作为第九章:绘图和可视化的预览,我使用 matplotlib 来创建这个二维数组的可视化:
In [174]: import matplotlib.pyplot as plt
In [175]: plt.imshow(z, cmap=plt.cm.gray, extent=[-5, 5, -5, 5])
Out[175]: <matplotlib.image.AxesImage at 0x17f04b040>
In [176]: plt.colorbar()
Out[176]: <matplotlib.colorbar.Colorbar at 0x1810661a0>
In [177]: plt.title("Image plot of $\sqrt{x² + y²}$ for a grid of values")
Out[177]: Text(0.5, 1.0, 'Image plot of $\\sqrt{x² + y²}$ for a grid of values'
)
在在网格上评估函数的绘图中,我使用了 matplotlib 函数imshow来从函数值的二维数组创建图像图。

图 4.3:在网格上评估函数的绘图
如果您在 IPython 中工作,可以通过执行plt.close("all")关闭所有打开的绘图窗口:
In [179]: plt.close("all")
注意
术语矢量化用于描述其他计算机科学概念,但在本书中,我使用它来描述对整个数据数组进行操作,而不是逐个值使用 Python 的for循环。
将条件逻辑表达为数组操作
numpy.where函数是三元表达式x if condition else y的矢量化版本。假设我们有一个布尔数组和两个值数组:
In [180]: xarr = np.array([1.1, 1.2, 1.3, 1.4, 1.5])
In [181]: yarr = np.array([2.1, 2.2, 2.3, 2.4, 2.5])
In [182]: cond = np.array([True, False, True, True, False])
假设我们想要从cond中对应的值为True时从xarr中取一个值,否则从yarr中取一个值。一个做到这一点的列表推导可能如下所示:
In [183]: result = [(x if c else y)
.....: for x, y, c in zip(xarr, yarr, cond)]
In [184]: result
Out[184]: [1.1, 2.2, 1.3, 1.4, 2.5]
这有多个问题。首先,对于大数组来说速度不会很快(因为所有工作都是在解释的 Python 代码中完成的)。其次,它不适用于多维数组。使用numpy.where可以通过单个函数调用来实现这一点:
In [185]: result = np.where(cond, xarr, yarr)
In [186]: result
Out[186]: array([1.1, 2.2, 1.3, 1.4, 2.5])
numpy.where的第二个和第三个参数不需要是数组;它们中的一个或两个可以是标量。在数据分析中,where的典型用法是根据另一个数组生成一个新的值数组。假设你有一个随机生成数据的矩阵,并且你想用 2 替换所有正值和用-2 替换所有负值。这可以通过numpy.where来实现:
In [187]: arr = rng.standard_normal((4, 4))
In [188]: arr
Out[188]:
array([[ 2.6182, 0.7774, 0.8286, -0.959 ],
[-1.2094, -1.4123, 0.5415, 0.7519],
[-0.6588, -1.2287, 0.2576, 0.3129],
[-0.1308, 1.27 , -0.093 , -0.0662]])
In [189]: arr > 0
Out[189]:
array([[ True, True, True, False],
[False, False, True, True],
[False, False, True, True],
[False, True, False, False]])
In [190]: np.where(arr > 0, 2, -2)
Out[190]:
array([[ 2, 2, 2, -2],
[-2, -2, 2, 2],
[-2, -2, 2, 2],
[-2, 2, -2, -2]])
在使用numpy.where时,可以将标量和数组组合在一起。例如,我可以用常数 2 替换arr中的所有正值,如下所示:
In [191]: np.where(arr > 0, 2, arr) # set only positive values to 2
Out[191]:
array([[ 2. , 2. , 2. , -0.959 ],
[-1.2094, -1.4123, 2. , 2. ],
[-0.6588, -1.2287, 2. , 2. ],
[-0.1308, 2. , -0.093 , -0.0662]])
数学和统计方法
一组数学函数,用于计算整个数组或沿轴的数据的统计信息,作为数组类的方法可访问。您可以通过调用数组实例方法或使用顶级 NumPy 函数来使用聚合(有时称为缩减)如sum、mean和std(标准差)。当您使用 NumPy 函数,如numpy.sum时,您必须将要聚合的数组作为第一个参数传递。
这里我生成一些正态分布的随机数据并计算一些聚合统计数据:
In [192]: arr = rng.standard_normal((5, 4))
In [193]: arr
Out[193]:
array([[-1.1082, 0.136 , 1.3471, 0.0611],
[ 0.0709, 0.4337, 0.2775, 0.5303],
[ 0.5367, 0.6184, -0.795 , 0.3 ],
[-1.6027, 0.2668, -1.2616, -0.0713],
[ 0.474 , -0.4149, 0.0977, -1.6404]])
In [194]: arr.mean()
Out[194]: -0.08719744457434529
In [195]: np.mean(arr)
Out[195]: -0.08719744457434529
In [196]: arr.sum()
Out[196]: -1.743948891486906
像mean和sum这样的函数接受一个可选的axis参数,该参数在给定轴上计算统计量,结果是一个维数少一的数组:
In [197]: arr.mean(axis=1)
Out[197]: array([ 0.109 , 0.3281, 0.165 , -0.6672, -0.3709])
In [198]: arr.sum(axis=0)
Out[198]: array([-1.6292, 1.0399, -0.3344, -0.8203])
这里,arr.mean(axis=1)表示“计算沿着列的平均值”,而arr.sum(axis=0)表示“计算沿着行的总和”。
像cumsum和cumprod这样的其他方法不进行聚合,而是产生中间结果的数组:
In [199]: arr = np.array([0, 1, 2, 3, 4, 5, 6, 7])
In [200]: arr.cumsum()
Out[200]: array([ 0, 1, 3, 6, 10, 15, 21, 28])
在多维数组中,像cumsum这样的累积函数返回一个相同大小的数组,但是根据每个较低维度切片沿着指定轴计算部分累积:
In [201]: arr = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
In [202]: arr
Out[202]:
array([[0, 1, 2],
[3, 4, 5],
[6, 7, 8]])
表达式arr.cumsum(axis=0)计算沿着行的累积和,而arr.cumsum(axis=1)计算沿着列的和:
In [203]: arr.cumsum(axis=0)
Out[203]:
array([[ 0, 1, 2],
[ 3, 5, 7],
[ 9, 12, 15]])
In [204]: arr.cumsum(axis=1)
Out[204]:
array([[ 0, 1, 3],
[ 3, 7, 12],
[ 6, 13, 21]])
查看表 4.6 以获取完整列表。我们将在后面的章节中看到这些方法的许多示例。
表 4.6:基本数组统计方法
| 方法 | 描述 |
|---|---|
sum |
数组或沿轴的所有元素的总和;长度为零的数组的总和为 0 |
mean |
算术平均值;对于长度为零的数组无效(返回NaN) |
std, var |
分别是标准差和方差 |
min, max |
最小值和最大值 |
argmin, argmax |
分别是最小和最大元素的索引 |
cumsum |
从 0 开始的元素的累积和 |
cumprod |
从 1 开始的元素的累积乘积 |
布尔数组的方法
在前面的方法中,布尔值被强制转换为 1(True)和 0(False)。因此,sum经常被用作计算布尔数组中True值的计数的手段:
In [205]: arr = rng.standard_normal(100)
In [206]: (arr > 0).sum() # Number of positive values
Out[206]: 48
In [207]: (arr <= 0).sum() # Number of non-positive values
Out[207]: 52
这里表达式(arr > 0).sum()中的括号是必要的,以便能够在arr > 0的临时结果上调用sum()。
另外两个方法,any和all,特别适用于布尔数组。any测试数组中是否有一个或多个值为True,而all检查是否每个值都为True:
In [208]: bools = np.array([False, False, True, False])
In [209]: bools.any()
Out[209]: True
In [210]: bools.all()
Out[210]: False
这些方法也适用于非布尔数组,其中非零元素被视为True。
排序
与 Python 内置的列表类型类似,NumPy 数组可以使用sort方法原地排序:
In [211]: arr = rng.standard_normal(6)
In [212]: arr
Out[212]: array([ 0.0773, -0.6839, -0.7208, 1.1206, -0.0548, -0.0824])
In [213]: arr.sort()
In [214]: arr
Out[214]: array([-0.7208, -0.6839, -0.0824, -0.0548, 0.0773, 1.1206])
您可以通过将轴编号传递给sort方法,在多维数组中对每个一维部分的值沿着轴进行原地排序。在这个例子数据中:
In [215]: arr = rng.standard_normal((5, 3))
In [216]: arr
Out[216]:
array([[ 0.936 , 1.2385, 1.2728],
[ 0.4059, -0.0503, 0.2893],
[ 0.1793, 1.3975, 0.292 ],
[ 0.6384, -0.0279, 1.3711],
[-2.0528, 0.3805, 0.7554]])
arr.sort(axis=0)对每列内的值进行排序,而arr.sort(axis=1)对每行进行排序:
In [217]: arr.sort(axis=0)
In [218]: arr
Out[218]:
array([[-2.0528, -0.0503, 0.2893],
[ 0.1793, -0.0279, 0.292 ],
[ 0.4059, 0.3805, 0.7554],
[ 0.6384, 1.2385, 1.2728],
[ 0.936 , 1.3975, 1.3711]])
In [219]: arr.sort(axis=1)
In [220]: arr
Out[220]:
array([[-2.0528, -0.0503, 0.2893],
[-0.0279, 0.1793, 0.292 ],
[ 0.3805, 0.4059, 0.7554],
[ 0.6384, 1.2385, 1.2728],
[ 0.936 , 1.3711, 1.3975]])
顶层方法numpy.sort返回一个数组的排序副本(类似于 Python 内置函数sorted),而不是在原地修改数组。例如:
In [221]: arr2 = np.array([5, -10, 7, 1, 0, -3])
In [222]: sorted_arr2 = np.sort(arr2)
In [223]: sorted_arr2
Out[223]: array([-10, -3, 0, 1, 5, 7])
有关使用 NumPy 的排序方法的更多详细信息,以及更高级的技术,如间接排序,请参见附录 A:高级 NumPy。还可以在 pandas 中找到与排序相关的其他数据操作(例如,按一个或多个列对数据表进行排序)。
唯一值和其他集合逻辑
NumPy 具有一些用于一维 ndarrays 的基本集合操作。一个常用的操作是numpy.unique,它返回数组中排序的唯一值:
In [224]: names = np.array(["Bob", "Will", "Joe", "Bob", "Will", "Joe", "Joe"])
In [225]: np.unique(names)
Out[225]: array(['Bob', 'Joe', 'Will'], dtype='<U4')
In [226]: ints = np.array([3, 3, 3, 2, 2, 1, 1, 4, 4])
In [227]: np.unique(ints)
Out[227]: array([1, 2, 3, 4])
将numpy.unique与纯 Python 替代方案进行对比:
In [228]: sorted(set(names))
Out[228]: ['Bob', 'Joe', 'Will']
在许多情况下,NumPy 版本更快,并返回一个 NumPy 数组而不是 Python 列表。
另一个函数numpy.in1d测试一个数组中的值在另一个数组中的成员资格,返回一个布尔数组:
In [229]: values = np.array([6, 0, 0, 3, 2, 5, 6])
In [230]: np.in1d(values, [2, 3, 6])
Out[230]: array([ True, False, False, True, True, False, True])
请参见表 4.7 以获取 NumPy 中数组集合操作的列表。
表 4.7:数组集合操作
| 方法 | 描述 |
|---|---|
unique(x) |
计算x中排序的唯一元素 |
intersect1d(x, y) |
计算x和y中排序的公共元素 |
union1d(x, y) |
计算元素的排序并集 |
in1d(x, y) |
计算一个布尔数组,指示x的每个元素是否包含在y中 |
setdiff1d(x, y) |
差集,x中不在y中的元素 |
setxor1d(x, y) | 对称差集;在任一数组中但不在两个数组中的元素 |
4.5 使用数组进行文件输入和输出
NumPy 能够以一些文本或二进制格式将数据保存到磁盘并从磁盘加载数据。在本节中,我只讨论 NumPy 内置的二进制格式,因为大多数用户更倾向于使用 pandas 和其他工具来加载文本或表格数据(详见第六章:数据加载、存储和文件格式)。
numpy.save和numpy.load是在磁盘上高效保存和加载数组数据的两个主要函数。默认情况下,数组以未压缩的原始二进制格式保存,文件扩展名为.npy:
In [231]: arr = np.arange(10)
In [232]: np.save("some_array", arr)
如果文件路径尚未以.npy结尾,则会添加扩展名。然后可以使用numpy.load加载磁盘上的数组:
In [233]: np.load("some_array.npy")
Out[233]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
您可以使用numpy.savez并将数组作为关键字参数传递来保存多个数组到未压缩的存档中:
In [234]: np.savez("array_archive.npz", a=arr, b=arr)
当加载一个.npz文件时,您会得到一个类似字典的对象,它会延迟加载各个数组:
In [235]: arch = np.load("array_archive.npz")
In [236]: arch["b"]
Out[236]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
如果您的数据压缩效果很好,您可能希望使用numpy.savez_compressed:
In [237]: np.savez_compressed("arrays_compressed.npz", a=arr, b=arr)
4.6 线性代数
线性代数运算,如矩阵乘法、分解、行列式和其他方阵数学,是许多数组库的重要组成部分。两个二维数组使用*进行元素级乘积,而矩阵乘法需要使用dot函数或@中缀运算符。dot既是一个数组方法,也是numpy命名空间中用于执行矩阵乘法的函数:
In [241]: x = np.array([[1., 2., 3.], [4., 5., 6.]])
In [242]: y = np.array([[6., 23.], [-1, 7], [8, 9]])
In [243]: x
Out[243]:
array([[1., 2., 3.],
[4., 5., 6.]])
In [244]: y
Out[244]:
array([[ 6., 23.],
[-1., 7.],
[ 8., 9.]])
In [245]: x.dot(y)
Out[245]:
array([[ 28., 64.],
[ 67., 181.]])
x.dot(y)等同于np.dot(x, y):
In [246]: np.dot(x, y)
Out[246]:
array([[ 28., 64.],
[ 67., 181.]])
两个二维数组与适当大小的一维数组之间的矩阵乘积会得到一个一维数组:
In [247]: x @ np.ones(3)
Out[247]: array([ 6., 15.])
numpy.linalg具有一套标准的矩阵分解和逆矩阵、行列式等功能:
In [248]: from numpy.linalg import inv, qr
In [249]: X = rng.standard_normal((5, 5))
In [250]: mat = X.T @ X
In [251]: inv(mat)
Out[251]:
array([[ 3.4993, 2.8444, 3.5956, -16.5538, 4.4733],
[ 2.8444, 2.5667, 2.9002, -13.5774, 3.7678],
[ 3.5956, 2.9002, 4.4823, -18.3453, 4.7066],
[-16.5538, -13.5774, -18.3453, 84.0102, -22.0484],
[ 4.4733, 3.7678, 4.7066, -22.0484, 6.0525]])
In [252]: mat @ inv(mat)
Out[252]:
array([[ 1., 0., 0., 0., 0.],
[ 0., 1., 0., 0., 0.],
[ 0., 0., 1., -0., 0.],
[ 0., 0., 0., 1., 0.],
[-0., 0., 0., 0., 1.]])
表达式X.T.dot(X)计算X与其转置X.T的点积。
请参见表 4.8 以获取一些最常用的线性代数函数的列表。
表 4.8:常用的numpy.linalg函数
| 函数 | 描述 |
|---|---|
diag |
返回方阵的对角线(或非对角线)元素作为 1D 数组,或将 1D 数组转换为具有非对角线零的方阵 |
dot |
矩阵乘法 |
trace |
计算对角线元素的和 |
det |
计算矩阵行列式 |
eig |
计算方阵的特征值和特征向量 |
inv |
计算方阵的逆 |
pinv |
计算矩阵的 Moore-Penrose 伪逆 |
qr |
计算 QR 分解 |
svd |
计算奇异值分解(SVD) |
solve |
解线性方程组 Ax = b,其中 A 是方阵 |
lstsq |
计算Ax = b的最小二乘解 |
4.7 示例:随机漫步
随机漫步的模拟提供了利用数组操作的说明性应用。让我们首先考虑一个简单的从 0 开始的随机漫步,步长为 1 和-1,发生概率相等。
这是一个使用内置的random模块实现一次包含 1,000 步的随机漫步的纯 Python 方法:
#! blockstart
import random
position = 0
walk = [position]
nsteps = 1000
for _ in range(nsteps):
step = 1 if random.randint(0, 1) else -1
position += step
walk.append(position)
#! blockend
查看图 4.4 以查看这些随机漫步中前 100 个值的示例图:
In [255]: plt.plot(walk[:100])

图 4.4:一个简单的随机漫步
你可能会观察到walk是随机步数的累积和,可以被评估为一个数组表达式。因此,我使用numpy.random模块一次绘制 1,000 次硬币翻转,将这些设置为 1 和-1,并计算累积和:
In [256]: nsteps = 1000
In [257]: rng = np.random.default_rng(seed=12345) # fresh random generator
In [258]: draws = rng.integers(0, 2, size=nsteps)
In [259]: steps = np.where(draws == 0, 1, -1)
In [260]: walk = steps.cumsum()
从中我们可以开始提取统计数据,比如沿着漫步轨迹的最小值和最大值:
In [261]: walk.min()
Out[261]: -8
In [262]: walk.max()
Out[262]: 50
一个更复杂的统计量是第一次穿越时间,即随机漫步达到特定值的步数。在这里,我们可能想知道随机漫步离原点 0 至少 10 步的时间。np.abs(walk) >= 10给出一个布尔数组,指示漫步已经达到或超过 10,但我们想要第一个 10 或-10 的索引。事实证明,我们可以使用argmax来计算这个,它返回布尔数组中最大值的第一个索引(True是最大值):
In [263]: (np.abs(walk) >= 10).argmax()
Out[263]: 155
请注意,在这里使用argmax并不总是高效的,因为它总是对数组进行完整扫描。在这种特殊情况下,一旦观察到True,我们就知道它是最大值。
一次模拟多个随机漫步
如果你的目标是模拟许多随机漫步,比如说五千次,你可以通过对前面的代码进行微小修改来生成所有的随机漫步。如果传递一个 2 元组,numpy.random函数将生成一个二维数组的抽样,我们可以为每一行计算累积和,以一次性计算所有五千次随机漫步:
In [264]: nwalks = 5000
In [265]: nsteps = 1000
In [266]: draws = rng.integers(0, 2, size=(nwalks, nsteps)) # 0 or 1
In [267]: steps = np.where(draws > 0, 1, -1)
In [268]: walks = steps.cumsum(axis=1)
In [269]: walks
Out[269]:
array([[ 1, 2, 3, ..., 22, 23, 22],
[ 1, 0, -1, ..., -50, -49, -48],
[ 1, 2, 3, ..., 50, 49, 48],
...,
[ -1, -2, -1, ..., -10, -9, -10],
[ -1, -2, -3, ..., 8, 9, 8],
[ -1, 0, 1, ..., -4, -3, -2]])
现在,我们可以计算所有漫步中获得的最大值和最小值:
In [270]: walks.max()
Out[270]: 114
In [271]: walks.min()
Out[271]: -120
在这些漫步中,让我们计算到达 30 或-30 的最小穿越时间。这有点棘手,因为并非所有的 5000 次都达到 30。我们可以使用any方法来检查:
In [272]: hits30 = (np.abs(walks) >= 30).any(axis=1)
In [273]: hits30
Out[273]: array([False, True, True, ..., True, False, True])
In [274]: hits30.sum() # Number that hit 30 or -30
Out[274]: 3395
我们可以使用这个布尔数组来选择实际穿越绝对值 30 水平的walks的行,并在轴 1 上调用argmax来获取穿越时间:
In [275]: crossing_times = (np.abs(walks[hits30]) >= 30).argmax(axis=1)
In [276]: crossing_times
Out[276]: array([201, 491, 283, ..., 219, 259, 541])
最后,我们计算平均最小穿越时间:
In [277]: crossing_times.mean()
Out[277]: 500.5699558173785
随意尝试使用与等大小硬币翻转不同的步骤分布。你只需要使用不同的随机生成器方法,比如standard_normal来生成具有一定均值和标准差的正态分布步数:
In [278]: draws = 0.25 * rng.standard_normal((nwalks, nsteps))
注意
请记住,这种矢量化方法需要创建一个具有nwalks * nsteps元素的数组,这可能会在大型模拟中使用大量内存。如果内存更受限制,则需要采用不同的方法。
4.8 结论
尽管本书的大部分内容将集中在使用 pandas 构建数据整理技能上,我们将继续以类似的基于数组的风格工作。在附录 A:高级 NumPy 中,我们将深入探讨 NumPy 的特性,帮助您进一步发展数组计算技能。
五、使用 pandas 入门
原文:
wesmckinney.com/book/pandas-basics译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
pandas 将是本书剩余部分中的一个主要工具。它包含了专为在 Python 中快速方便地进行数据清洗和分析而设计的数据结构和数据操作工具。pandas 经常与数值计算工具(如 NumPy 和 SciPy)、分析库(如 statsmodels 和 scikit-learn)以及数据可视化库(如 matplotlib)一起使用。pandas 采用了 NumPy 的很多习惯用法,特别是基于数组的计算和对数据处理的偏好,而不使用for循环。
虽然 pandas 采用了许多来自 NumPy 的编码习惯,但最大的区别在于 pandas 是为处理表格或异构数据而设计的。相比之下,NumPy 更适合处理同质类型的数值数组数据。
自 2010 年成为开源项目以来,pandas 已经发展成一个相当庞大的库,适用于广泛的实际用例。开发者社区已经发展到超过 2500 名不同的贡献者,他们在解决日常数据问题时一直在帮助构建这个项目。充满活力的 pandas 开发者和用户社区是其成功的关键部分。
注意
很多人不知道我自 2013 年以来并没有积极参与日常 pandas 的开发;从那时起,它一直是一个完全由社区管理的项目。请务必向核心开发人员和所有贡献者传达感谢他们的辛勤工作!
在本书的剩余部分中,我使用以下的 NumPy 和 pandas 的导入约定:
In [1]: import numpy as np
In [2]: import pandas as pd
因此,每当在代码中看到pd.时,它指的是 pandas。您可能也会发现将 Series 和 DataFrame 导入到本地命名空间中更容易,因为它们经常被使用:
In [3]: from pandas import Series, DataFrame
5.1 pandas 数据结构简介
要开始使用 pandas,您需要熟悉其两个主要数据结构:Series和DataFrame。虽然它们并非适用于每个问题的通用解决方案,但它们为各种数据任务提供了坚实的基础。
Series
Series 是一个一维数组样对象,包含一系列值(与 NumPy 类型相似的类型)和一个关联的数据标签数组,称为索引。最简单的 Series 是仅由数据数组形成的:
In [14]: obj = pd.Series([4, 7, -5, 3])
In [15]: obj
Out[15]:
0 4
1 7
2 -5
3 3
dtype: int64
Series 的交互式显示的字符串表示在左侧显示索引,右侧显示值。由于我们没有为数据指定索引,因此会创建一个默认索引,由整数0到N-1(其中N是数据的长度)组成。您可以通过其array和index属性分别获取 Series 的数组表示和索引对象:
In [16]: obj.array
Out[16]:
<PandasArray>
[4, 7, -5, 3]
Length: 4, dtype: int64
In [17]: obj.index
Out[17]: RangeIndex(start=0, stop=4, step=1)
.array属性的结果是一个PandasArray,通常包装了一个 NumPy 数组,但也可以包含特殊的扩展数组类型,这将在 Ch 7.3:扩展数据类型中更详细讨论。
通常,您会希望创建一个带有标识每个数据点的索引的 Series:
In [18]: obj2 = pd.Series([4, 7, -5, 3], index=["d", "b", "a", "c"])
In [19]: obj2
Out[19]:
d 4
b 7
a -5
c 3
dtype: int64
In [20]: obj2.index
Out[20]: Index(['d', 'b', 'a', 'c'], dtype='object')
与 NumPy 数组相比,当选择单个值或一组值时,可以在索引中使用标签:
In [21]: obj2["a"]
Out[21]: -5
In [22]: obj2["d"] = 6
In [23]: obj2[["c", "a", "d"]]
Out[23]:
c 3
a -5
d 6
dtype: int64
这里["c", "a", "d"]被解释为索引列表,即使它包含字符串而不是整数。
使用 NumPy 函数或类似 NumPy 的操作,例如使用布尔数组进行过滤、标量乘法或应用数学函数,将保留索引值链接:
In [24]: obj2[obj2 > 0]
Out[24]:
d 6
b 7
c 3
dtype: int64
In [25]: obj2 * 2
Out[25]:
d 12
b 14
a -10
c 6
dtype: int64
In [26]: import numpy as np
In [27]: np.exp(obj2)
Out[27]:
d 403.428793
b 1096.633158
a 0.006738
c 20.085537
dtype: float64
将 Series 视为固定长度的有序字典的另一种方式,因为它是索引值到数据值的映射。它可以在许多上下文中使用,您可能会使用字典:
In [28]: "b" in obj2
Out[28]: True
In [29]: "e" in obj2
Out[29]: False
如果您的数据包含在 Python 字典中,可以通过传递字典来创建一个 Series:
In [30]: sdata = {"Ohio": 35000, "Texas": 71000, "Oregon": 16000, "Utah": 5000}
In [31]: obj3 = pd.Series(sdata)
In [32]: obj3
Out[32]:
Ohio 35000
Texas 71000
Oregon 16000
Utah 5000
dtype: int64
Series 可以使用其to_dict方法转换回字典:
In [33]: obj3.to_dict()
Out[33]: {'Ohio': 35000, 'Texas': 71000, 'Oregon': 16000, 'Utah': 5000}
当您只传递一个字典时,生成的 Series 中的索引将遵循字典的keys方法的键的顺序,这取决于键插入顺序。您可以通过传递一个索引,其中包含字典键的顺序,以便它们出现在生成的 Series 中的顺序来覆盖这一点:
In [34]: states = ["California", "Ohio", "Oregon", "Texas"]
In [35]: obj4 = pd.Series(sdata, index=states)
In [36]: obj4
Out[36]:
California NaN
Ohio 35000.0
Oregon 16000.0
Texas 71000.0
dtype: float64
在这里,sdata中找到的三个值被放置在适当的位置,但由于没有找到"California"的值,它显示为NaN(不是一个数字),在 pandas 中被视为标记缺失或NA值。由于states中没有包含"Utah",因此它被排除在结果对象之外。
我将使用术语“missing”、“NA”或“null”来交替引用缺失数据。应该使用 pandas 中的isna和notna函数来检测缺失数据:
In [37]: pd.isna(obj4)
Out[37]:
California True
Ohio False
Oregon False
Texas False
dtype: bool
In [38]: pd.notna(obj4)
Out[38]:
California False
Ohio True
Oregon True
Texas True
dtype: bool
Series 还具有这些作为实例方法:
In [39]: obj4.isna()
Out[39]:
California True
Ohio False
Oregon False
Texas False
dtype: bool
我将在第七章:数据清洗和准备中更详细地讨论处理缺失数据的工作。
对于许多应用程序来说,Series 的一个有用特性是它在算术运算中自动按索引标签对齐:
In [40]: obj3
Out[40]:
Ohio 35000
Texas 71000
Oregon 16000
Utah 5000
dtype: int64
In [41]: obj4
Out[41]:
California NaN
Ohio 35000.0
Oregon 16000.0
Texas 71000.0
dtype: float64
In [42]: obj3 + obj4
Out[42]:
California NaN
Ohio 70000.0
Oregon 32000.0
Texas 142000.0
Utah NaN
dtype: float64
数据对齐功能将在后面更详细地讨论。如果您有数据库经验,可以将其视为类似于连接操作。
Series 对象本身和其索引都有一个name属性,它与 pandas 功能的其他区域集成:
In [43]: obj4.name = "population"
In [44]: obj4.index.name = "state"
In [45]: obj4
Out[45]:
state
California NaN
Ohio 35000.0
Oregon 16000.0
Texas 71000.0
Name: population, dtype: float64
Series 的索引可以通过赋值来直接更改:
In [46]: obj
Out[46]:
0 4
1 7
2 -5
3 3
dtype: int64
In [47]: obj.index = ["Bob", "Steve", "Jeff", "Ryan"]
In [48]: obj
Out[48]:
Bob 4
Steve 7
Jeff -5
Ryan 3
dtype: int64
DataFrame
DataFrame 表示数据的矩形表,并包含一个有序的、命名的列集合,每个列可以是不同的值类型(数值、字符串、布尔值等)。DataFrame 既有行索引又有列索引;它可以被视为共享相同索引的一系列 Series 的字典。
注意
虽然 DataFrame 在物理上是二维的,但您可以使用它来以分层索引的方式表示更高维度的数据,这是我们将在第八章:数据整理:连接、合并和重塑中讨论的一个主题,并且是 pandas 中一些更高级数据处理功能的一个组成部分。
有许多构建 DataFrame 的方法,尽管其中最常见的一种是从等长列表或 NumPy 数组的字典中构建:
data = {"state": ["Ohio", "Ohio", "Ohio", "Nevada", "Nevada", "Nevada"],
"year": [2000, 2001, 2002, 2001, 2002, 2003],
"pop": [1.5, 1.7, 3.6, 2.4, 2.9, 3.2]}
frame = pd.DataFrame(data)
生成的 DataFrame 将自动分配其索引,与 Series 一样,并且列根据data中键的顺序放置(取决于字典中的插入顺序):
In [50]: frame
Out[50]:
state year pop
0 Ohio 2000 1.5
1 Ohio 2001 1.7
2 Ohio 2002 3.6
3 Nevada 2001 2.4
4 Nevada 2002 2.9
5 Nevada 2003 3.2
注意
如果您正在使用 Jupyter 笔记本,pandas DataFrame 对象将显示为更适合浏览器的 HTML 表格。请参见图 5.1 作为示例。

图 5.1:Jupyter 中 pandas DataFrame 对象的外观
对于大型 DataFrame,head方法仅选择前五行:
In [51]: frame.head()
Out[51]:
state year pop
0 Ohio 2000 1.5
1 Ohio 2001 1.7
2 Ohio 2002 3.6
3 Nevada 2001 2.4
4 Nevada 2002 2.9
类似地,tail返回最后五行:
In [52]: frame.tail()
Out[52]:
state year pop
1 Ohio 2001 1.7
2 Ohio 2002 3.6
3 Nevada 2001 2.4
4 Nevada 2002 2.9
5 Nevada 2003 3.2
如果指定一系列列,DataFrame 的列将按照该顺序排列:
In [53]: pd.DataFrame(data, columns=["year", "state", "pop"])
Out[53]:
year state pop
0 2000 Ohio 1.5
1 2001 Ohio 1.7
2 2002 Ohio 3.6
3 2001 Nevada 2.4
4 2002 Nevada 2.9
5 2003 Nevada 3.2
如果传递一个字典中不包含的列,它将以缺失值的形式出现在结果中:
In [54]: frame2 = pd.DataFrame(data, columns=["year", "state", "pop", "debt"])
In [55]: frame2
Out[55]:
year state pop debt
0 2000 Ohio 1.5 NaN
1 2001 Ohio 1.7 NaN
2 2002 Ohio 3.6 NaN
3 2001 Nevada 2.4 NaN
4 2002 Nevada 2.9 NaN
5 2003 Nevada 3.2 NaN
In [56]: frame2.columns
Out[56]: Index(['year', 'state', 'pop', 'debt'], dtype='object')
DataFrame 中的列可以通过类似字典的表示法或使用点属性表示法检索为 Series:
In [57]: frame2["state"]
Out[57]:
0 Ohio
1 Ohio
2 Ohio
3 Nevada
4 Nevada
5 Nevada
Name: state, dtype: object
In [58]: frame2.year
Out[58]:
0 2000
1 2001
2 2002
3 2001
4 2002
5 2003
Name: year, dtype: int64
注意
提供类似属性访问(例如,frame2.year)和 IPython 中列名称的制表符补全作为便利。
frame2[column]适用于任何列名,但只有当列名是有效的 Python 变量名且不与 DataFrame 中的任何方法名冲突时,frame2.column才适用。例如,如果列名包含空格或下划线以外的其他符号,则无法使用点属性方法访问。
请注意,返回的 Series 具有与 DataFrame 相同的索引,并且它们的name属性已经适当设置。
行也可以通过特殊的iloc和loc属性按位置或名称检索(稍后在使用 loc 和 iloc 在 DataFrame 上进行选择中详细介绍):
In [59]: frame2.loc[1]
Out[59]:
year 2001
state Ohio
pop 1.7
debt NaN
Name: 1, dtype: object
In [60]: frame2.iloc[2]
Out[60]:
year 2002
state Ohio
pop 3.6
debt NaN
Name: 2, dtype: object
列可以通过赋值进行修改。例如,可以为空的debt列分配一个标量值或一个值数组:
In [61]: frame2["debt"] = 16.5
In [62]: frame2
Out[62]:
year state pop debt
0 2000 Ohio 1.5 16.5
1 2001 Ohio 1.7 16.5
2 2002 Ohio 3.6 16.5
3 2001 Nevada 2.4 16.5
4 2002 Nevada 2.9 16.5
5 2003 Nevada 3.2 16.5
In [63]: frame2["debt"] = np.arange(6.)
In [64]: frame2
Out[64]:
year state pop debt
0 2000 Ohio 1.5 0.0
1 2001 Ohio 1.7 1.0
2 2002 Ohio 3.6 2.0
3 2001 Nevada 2.4 3.0
4 2002 Nevada 2.9 4.0
5 2003 Nevada 3.2 5.0
当将列表或数组分配给列时,值的长度必须与 DataFrame 的长度相匹配。如果分配一个 Series,其标签将被重新对齐到 DataFrame 的索引,插入任何不存在的索引值的缺失值:
In [65]: val = pd.Series([-1.2, -1.5, -1.7], index=[2, 4, 5])
In [66]: frame2["debt"] = val
In [67]: frame2
Out[67]:
year state pop debt
0 2000 Ohio 1.5 NaN
1 2001 Ohio 1.7 NaN
2 2002 Ohio 3.6 -1.2
3 2001 Nevada 2.4 NaN
4 2002 Nevada 2.9 -1.5
5 2003 Nevada 3.2 -1.7
分配一个不存在的列将创建一个新列。
del关键字将像字典一样删除列。例如,首先添加一个新列,其中布尔值等于"Ohio"的state列:
In [68]: frame2["eastern"] = frame2["state"] == "Ohio"
In [69]: frame2
Out[69]:
year state pop debt eastern
0 2000 Ohio 1.5 NaN True
1 2001 Ohio 1.7 NaN True
2 2002 Ohio 3.6 -1.2 True
3 2001 Nevada 2.4 NaN False
4 2002 Nevada 2.9 -1.5 False
5 2003 Nevada 3.2 -1.7 False
警告:
不能使用frame2.eastern点属性表示法创建新列。
然后可以使用del方法删除此列:
In [70]: del frame2["eastern"]
In [71]: frame2.columns
Out[71]: Index(['year', 'state', 'pop', 'debt'], dtype='object')
注意
从 DataFrame 索引返回的列是基础数据的视图,而不是副本。因此,对 Series 的任何原地修改都将反映在 DataFrame 中。可以使用 Series 的copy方法显式复制列。
另一种常见的数据形式是嵌套字典的字典:
In [72]: populations = {"Ohio": {2000: 1.5, 2001: 1.7, 2002: 3.6},
....: "Nevada": {2001: 2.4, 2002: 2.9}}
如果将嵌套字典传递给 DataFrame,pandas 将解释外部字典键为列,内部键为行索引:
In [73]: frame3 = pd.DataFrame(populations)
In [74]: frame3
Out[74]:
Ohio Nevada
2000 1.5 NaN
2001 1.7 2.4
2002 3.6 2.9
您可以使用类似于 NumPy 数组的语法转置 DataFrame(交换行和列):
In [75]: frame3.T
Out[75]:
2000 2001 2002
Ohio 1.5 1.7 3.6
Nevada NaN 2.4 2.9
警告:
请注意,如果列的数据类型不全都相同,则转置会丢弃列数据类型,因此转置然后再次转置可能会丢失先前的类型信息。在这种情况下,列变成了纯 Python 对象的数组。
内部字典中的键被组合以形成结果中的索引。如果指定了显式索引,则这种情况不成立:
In [76]: pd.DataFrame(populations, index=[2001, 2002, 2003])
Out[76]:
Ohio Nevada
2001 1.7 2.4
2002 3.6 2.9
2003 NaN NaN
Series 的字典以类似的方式处理:
In [77]: pdata = {"Ohio": frame3["Ohio"][:-1],
....: "Nevada": frame3["Nevada"][:2]}
In [78]: pd.DataFrame(pdata)
Out[78]:
Ohio Nevada
2000 1.5 NaN
2001 1.7 2.4
有关可以传递给 DataFrame 构造函数的许多内容,请参见表 5.1。
表 5.1:DataFrame 构造函数的可能数据输入
| 类型 | 注释 |
|---|---|
| 2D ndarray | 一组数据的矩阵,传递可选的行和列标签 |
| 数组、列表或元组的字典 | 每个序列都变成了 DataFrame 中的一列;所有序列必须具有相同的长度 |
| NumPy 结构化/记录数组 | 被视为“数组的字典”情况 |
| Series 的字典 | 每个值都变成了一列;如果没有传递显式索引,则每个 Series 的索引被合并在一起以形成结果的行索引 |
| 字典的字典 | 每个内部字典都变成了一列;键被合并以形成行索引,就像“Series 的字典”情况一样 |
| 字典或 Series 的列表 | 每个项目都变成了 DataFrame 中的一行;字典键或 Series 索引的并集成为 DataFrame 的列标签 |
| 列表或元组的列表 | 被视为“2D ndarray”情况 |
| 另一个 DataFrame | 除非传递了不同的索引,否则将使用 DataFrame 的索引 |
| NumPy MaskedArray | 与“2D ndarray”情况类似,只是在 DataFrame 结果中缺少掩码值 |
如果 DataFrame 的index和columns有设置它们的name属性,这些也会被显示出来:
In [79]: frame3.index.name = "year"
In [80]: frame3.columns.name = "state"
In [81]: frame3
Out[81]:
state Ohio Nevada
year
2000 1.5 NaN
2001 1.7 2.4
2002 3.6 2.9
与 Series 不同,DataFrame 没有name属性。DataFrame 的to_numpy方法将 DataFrame 中包含的数据作为二维 ndarray 返回:
In [82]: frame3.to_numpy()
Out[82]:
array([[1.5, nan],
[1.7, 2.4],
[3.6, 2.9]])
如果 DataFrame 的列是不同的数据类型,则返回的数组的数据类型将被选择以容纳所有列:
In [83]: frame2.to_numpy()
Out[83]:
array([[2000, 'Ohio', 1.5, nan],
[2001, 'Ohio', 1.7, nan],
[2002, 'Ohio', 3.6, -1.2],
[2001, 'Nevada', 2.4, nan],
[2002, 'Nevada', 2.9, -1.5],
[2003, 'Nevada', 3.2, -1.7]], dtype=object)
索引对象
pandas 的 Index 对象负责保存轴标签(包括 DataFrame 的列名)和其他元数据(如轴名称)。在构建 Series 或 DataFrame 时使用的任何数组或其他标签序列都会在内部转换为 Index:
In [84]: obj = pd.Series(np.arange(3), index=["a", "b", "c"])
In [85]: index = obj.index
In [86]: index
Out[86]: Index(['a', 'b', 'c'], dtype='object')
In [87]: index[1:]
Out[87]: Index(['b', 'c'], dtype='object')
Index 对象是不可变的,因此用户无法修改它们:
index[1] = "d" # TypeError
不可变性使得在数据结构之间共享 Index 对象更加安全:
In [88]: labels = pd.Index(np.arange(3))
In [89]: labels
Out[89]: Index([0, 1, 2], dtype='int64')
In [90]: obj2 = pd.Series([1.5, -2.5, 0], index=labels)
In [91]: obj2
Out[91]:
0 1.5
1 -2.5
2 0.0
dtype: float64
In [92]: obj2.index is labels
Out[92]: True
注意
一些用户可能不经常利用 Index 提供的功能,但由于一些操作会产生包含索引数据的结果,因此了解它们的工作原理是很重要的。
除了类似数组,Index 还表现得像一个固定大小的集合:
In [93]: frame3
Out[93]:
state Ohio Nevada
year
2000 1.5 NaN
2001 1.7 2.4
2002 3.6 2.9
In [94]: frame3.columns
Out[94]: Index(['Ohio', 'Nevada'], dtype='object', name='state')
In [95]: "Ohio" in frame3.columns
Out[95]: True
In [96]: 2003 in frame3.index
Out[96]: False
与 Python 集合不同,pandas 的 Index 可以包含重复标签:
In [97]: pd.Index(["foo", "foo", "bar", "bar"])
Out[97]: Index(['foo', 'foo', 'bar', 'bar'], dtype='object')
具有重复标签的选择将选择该标签的所有出现。
每个 Index 都有一些用于集合逻辑的方法和属性,可以回答关于其包含的数据的其他常见问题。一些有用的方法总结在 Table 5.2 中。
Table 5.2: 一些索引方法和属性
| 方法/属性 | 描述 |
|---|---|
append() |
与其他 Index 对象连接,生成一个新的 Index |
difference() |
计算索引的差集 |
intersection() |
计算集合交集 |
union() |
计算集合并 |
isin() |
计算布尔数组,指示每个值是否包含在传递的集合中 |
delete() |
通过删除索引i处的元素来计算新的索引 |
drop() |
通过删除传递的值来计算新的索引 |
insert() |
通过在索引i处插入元素来计算新的索引 |
is_monotonic |
如果每个元素大于或等于前一个元素则返回True |
is_unique |
如果索引没有重复值则返回True |
| unique() | 计算索引中唯一值的数组 |
5.2 基本功能
本节将带领您了解与 Series 或 DataFrame 中包含的数据进行交互的基本机制。在接下来的章节中,我们将更深入地探讨使用 pandas 进行数据分析和操作的主题。本书不旨在作为 pandas 库的详尽文档,而是专注于让您熟悉常用功能,将不太常见的(即更神秘的)内容留给您通过阅读在线 pandas 文档来学习。
重新索引
pandas 对象上的一个重要方法是reindex,它意味着创建一个新对象,其值重新排列以与新索引对齐。考虑一个例子:
In [98]: obj = pd.Series([4.5, 7.2, -5.3, 3.6], index=["d", "b", "a", "c"])
In [99]: obj
Out[99]:
d 4.5
b 7.2
a -5.3
c 3.6
dtype: float64
在这个 Series 上调用reindex会根据新索引重新排列数据,如果某些索引值之前不存在,则会引入缺失值:
In [100]: obj2 = obj.reindex(["a", "b", "c", "d", "e"])
In [101]: obj2
Out[101]:
a -5.3
b 7.2
c 3.6
d 4.5
e NaN
dtype: float64
对于有序数据如时间序列,当重新索引时可能需要进行一些插值或值填充。method选项允许我们使用ffill这样的方法来实现,它可以向前填充值:
In [102]: obj3 = pd.Series(["blue", "purple", "yellow"], index=[0, 2, 4])
In [103]: obj3
Out[103]:
0 blue
2 purple
4 yellow
dtype: object
In [104]: obj3.reindex(np.arange(6), method="ffill")
Out[104]:
0 blue
1 blue
2 purple
3 purple
4 yellow
5 yellow
dtype: object
对于 DataFrame,reindex可以改变(行)索引、列或两者。当只传递一个序列时,它会重新索引结果中的行:
In [105]: frame = pd.DataFrame(np.arange(9).reshape((3, 3)),
.....: index=["a", "c", "d"],
.....: columns=["Ohio", "Texas", "California"])
In [106]: frame
Out[106]:
Ohio Texas California
a 0 1 2
c 3 4 5
d 6 7 8
In [107]: frame2 = frame.reindex(index=["a", "b", "c", "d"])
In [108]: frame2
Out[108]:
Ohio Texas California
a 0.0 1.0 2.0
b NaN NaN NaN
c 3.0 4.0 5.0
d 6.0 7.0 8.0
可以使用columns关键字重新索引列:
In [109]: states = ["Texas", "Utah", "California"]
In [110]: frame.reindex(columns=states)
Out[110]:
Texas Utah California
a 1 NaN 2
c 4 NaN 5
d 7 NaN 8
因为"Ohio"不在states中,所以该列的数据被从结果中删除。
重新索引特定轴的另一种方法是将新的轴标签作为位置参数传递,然后使用axis关键字指定要重新索引的轴:
In [111]: frame.reindex(states, axis="columns")
Out[111]:
Texas Utah California
a 1 NaN 2
c 4 NaN 5
d 7 NaN 8
查看 Table 5.3 以了解有关reindex参数的更多信息。
表 5.3:reindex函数参数
| 参数 | 描述 |
|---|---|
labels |
用作索引的新序列。可以是 Index 实例或任何其他类似序列的 Python 数据结构。Index 将被完全使用,不会进行任何复制。 |
index |
使用传递的序列作为新的索引标签。 |
columns |
使用传递的序列作为新的列标签。 |
axis |
要重新索引的轴,无论是"index"(行)还是"columns"。默认为"index"。您也可以使用reindex(index=new_labels)或reindex(columns=new_labels)。 |
method |
插值(填充)方法;"ffill"向前填充,而"bfill"向后填充。 |
fill_value |
重新索引时引入缺失数据时要使用的替代值。当您希望缺失标签在结果中具有空值时,请使用fill_value="missing"(默认行为)。 |
limit |
在向前填充或向后填充时,要填充的最大大小间隙(元素数量)。 |
tolerance |
在向前填充或向后填充时,要填充的最大大小间隙(绝对数值距离)。 |
level |
在 MultiIndex 级别上匹配简单索引;否则选择子集。 |
copy |
如果为True,即使新索引等效于旧索引,也始终复制基础数据;如果为False,当索引等效时不复制数据。 |
正如我们稍后将在使用 loc 和 iloc 在 DataFrame 上进行选择中探讨的,您也可以通过使用loc运算符重新索引,许多用户更喜欢始终以这种方式进行操作。这仅在所有新索引标签已存在于 DataFrame 中时才有效(而reindex将为新标签插入缺失数据):
In [112]: frame.loc[["a", "d", "c"], ["California", "Texas"]]
Out[112]:
California Texas
a 2 1
d 8 7
c 5 4
从轴中删除条目
如果您已经有一个不包含这些条目的索引数组或列表,那么从轴中删除一个或多个条目就很简单,因为您可以使用reindex方法或基于.loc的索引。由于这可能需要一些数据处理和集合逻辑,drop方法将返回一个新对象,其中包含从轴中删除的指定值或值:
In [113]: obj = pd.Series(np.arange(5.), index=["a", "b", "c", "d", "e"])
In [114]: obj
Out[114]:
a 0.0
b 1.0
c 2.0
d 3.0
e 4.0
dtype: float64
In [115]: new_obj = obj.drop("c")
In [116]: new_obj
Out[116]:
a 0.0
b 1.0
d 3.0
e 4.0
dtype: float64
In [117]: obj.drop(["d", "c"])
Out[117]:
a 0.0
b 1.0
e 4.0
dtype: float64
使用 DataFrame,可以从任一轴删除索引值。为了说明这一点,我们首先创建一个示例 DataFrame:
In [118]: data = pd.DataFrame(np.arange(16).reshape((4, 4)),
.....: index=["Ohio", "Colorado", "Utah", "New York"],
.....: columns=["one", "two", "three", "four"])
In [119]: data
Out[119]:
one two three four
Ohio 0 1 2 3
Colorado 4 5 6 7
Utah 8 9 10 11
New York 12 13 14 15
使用一系列标签调用drop将从行标签(轴 0)中删除值:
In [120]: data.drop(index=["Colorado", "Ohio"])
Out[120]:
one two three four
Utah 8 9 10 11
New York 12 13 14 15
要从列中删除标签,而不是使用columns关键字:
In [121]: data.drop(columns=["two"])
Out[121]:
one three four
Ohio 0 2 3
Colorado 4 6 7
Utah 8 10 11
New York 12 14 15
您还可以通过传递axis=1(类似于 NumPy)或axis="columns"来从列中删除值:
In [122]: data.drop("two", axis=1)
Out[122]:
one three four
Ohio 0 2 3
Colorado 4 6 7
Utah 8 10 11
New York 12 14 15
In [123]: data.drop(["two", "four"], axis="columns")
Out[123]:
one three
Ohio 0 2
Colorado 4 6
Utah 8 10
New York 12 14
索引、选择和过滤
Series 索引(obj[...])的工作方式类似于 NumPy 数组索引,只是您可以使用 Series 的索引值而不仅仅是整数。以下是一些示例:
In [124]: obj = pd.Series(np.arange(4.), index=["a", "b", "c", "d"])
In [125]: obj
Out[125]:
a 0.0
b 1.0
c 2.0
d 3.0
dtype: float64
In [126]: obj["b"]
Out[126]: 1.0
In [127]: obj[1]
Out[127]: 1.0
In [128]: obj[2:4]
Out[128]:
c 2.0
d 3.0
dtype: float64
In [129]: obj[["b", "a", "d"]]
Out[129]:
b 1.0
a 0.0
d 3.0
dtype: float64
In [130]: obj[[1, 3]]
Out[130]:
b 1.0
d 3.0
dtype: float64
In [131]: obj[obj < 2]
Out[131]:
a 0.0
b 1.0
dtype: float64
虽然您可以通过标签这种方式选择数据,但选择索引值的首选方式是使用特殊的loc运算符:
In [132]: obj.loc[["b", "a", "d"]]
Out[132]:
b 1.0
a 0.0
d 3.0
dtype: float64
更喜欢loc的原因是因为在使用[]进行索引时,对整数的处理方式不同。如果索引包含整数,常规的[]索引将将整数视为标签,因此行为取决于索引的数据类型。例如:
In [133]: obj1 = pd.Series([1, 2, 3], index=[2, 0, 1])
In [134]: obj2 = pd.Series([1, 2, 3], index=["a", "b", "c"])
In [135]: obj1
Out[135]:
2 1
0 2
1 3
dtype: int64
In [136]: obj2
Out[136]:
a 1
b 2
c 3
dtype: int64
In [137]: obj1[[0, 1, 2]]
Out[137]:
0 2
1 3
2 1
dtype: int64
In [138]: obj2[[0, 1, 2]]
Out[138]:
a 1
b 2
c 3
dtype: int64
在使用loc时,当索引不包含整数时,表达式obj.loc[[0, 1, 2]]将失败:
In [134]: obj2.loc[[0, 1]]
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
/tmp/ipykernel_804589/4185657903.py in <module>
----> 1 obj2.loc[[0, 1]]
^ LONG EXCEPTION ABBREVIATED ^
KeyError: "None of [Int64Index([0, 1], dtype="int64")] are in the [index]"
由于loc运算符仅使用标签进行索引,因此还有一个iloc运算符,它仅使用整数进行索引,以便在索引包含整数或不包含整数时始终保持一致:
In [139]: obj1.iloc[[0, 1, 2]]
Out[139]:
2 1
0 2
1 3
dtype: int64
In [140]: obj2.iloc[[0, 1, 2]]
Out[140]:
a 1
b 2
c 3
dtype: int64
注意
您也可以使用标签进行切片,但与正常的 Python 切片不同,终点是包含的:
In [141]: obj2.loc["b":"c"]
Out[141]:
b 2
c 3
dtype: int64
使用这些方法分配值会修改 Series 的相应部分:
In [142]: obj2.loc["b":"c"] = 5
In [143]: obj2
Out[143]:
a 1
b 5
c 5
dtype: int64
注意
尝试调用loc或iloc等函数而不是使用方括号“索引”可能是新手的常见错误。方括号表示用于启用切片操作并允许在 DataFrame 对象上的多个轴上进行索引。
在 DataFrame 中进行索引会检索一个或多个列,可以使用单个值或序列:
In [144]: data = pd.DataFrame(np.arange(16).reshape((4, 4)),
.....: index=["Ohio", "Colorado", "Utah", "New York"],
.....: columns=["one", "two", "three", "four"])
In [145]: data
Out[145]:
one two three four
Ohio 0 1 2 3
Colorado 4 5 6 7
Utah 8 9 10 11
New York 12 13 14 15
In [146]: data["two"]
Out[146]:
Ohio 1
Colorado 5
Utah 9
New York 13
Name: two, dtype: int64
In [147]: data[["three", "one"]]
Out[147]:
three one
Ohio 2 0
Colorado 6 4
Utah 10 8
New York 14 12
这种索引有一些特殊情况。第一个是使用布尔数组进行切片或选择数据:
In [148]: data[:2]
Out[148]:
one two three four
Ohio 0 1 2 3
Colorado 4 5 6 7
In [149]: data[data["three"] > 5]
Out[149]:
one two three four
Colorado 4 5 6 7
Utah 8 9 10 11
New York 12 13 14 15
行选择语法data[:2]是作为一种便利提供的。将单个元素或列表传递给[]运算符将选择列。
另一个用例是使用布尔 DataFrame 进行索引,比如通过标量比较生成的 DataFrame。考虑一个通过与标量值比较生成的全布尔值的 DataFrame:
In [150]: data < 5
Out[150]:
one two three four
Ohio True True True True
Colorado True False False False
Utah False False False False
New York False False False False
我们可以使用这个 DataFrame 将值为True的位置赋值为 0,就像这样:
In [151]: data[data < 5] = 0
In [152]: data
Out[152]:
one two three four
Ohio 0 0 0 0
Colorado 0 5 6 7
Utah 8 9 10 11
New York 12 13 14 15
使用 loc 和 iloc 在 DataFrame 上进行选择
与 Series 一样,DataFrame 具有专门的属性loc和iloc,用于基于标签和基于整数的索引。由于 DataFrame 是二维的,您可以使用类似 NumPy 的符号使用轴标签(loc)或整数(iloc)选择行和列的子集。
作为第一个示例,让我们通过标签选择单行:
In [153]: data
Out[153]:
one two three four
Ohio 0 0 0 0
Colorado 0 5 6 7
Utah 8 9 10 11
New York 12 13 14 15
In [154]: data.loc["Colorado"]
Out[154]:
one 0
two 5
three 6
four 7
Name: Colorado, dtype: int64
选择单行的结果是一个带有包含 DataFrame 列标签的索引的 Series。要选择多个行,创建一个新的 DataFrame,传递一个标签序列:
In [155]: data.loc[["Colorado", "New York"]]
Out[155]:
one two three four
Colorado 0 5 6 7
New York 12 13 14 15
您可以通过用逗号分隔选择在loc中同时选择行和列:
In [156]: data.loc["Colorado", ["two", "three"]]
Out[156]:
two 5
three 6
Name: Colorado, dtype: int64
然后我们将使用iloc执行一些类似的整数选择:
In [157]: data.iloc[2]
Out[157]:
one 8
two 9
three 10
four 11
Name: Utah, dtype: int64
In [158]: data.iloc[[2, 1]]
Out[158]:
one two three four
Utah 8 9 10 11
Colorado 0 5 6 7
In [159]: data.iloc[2, [3, 0, 1]]
Out[159]:
four 11
one 8
two 9
Name: Utah, dtype: int64
In [160]: data.iloc[[1, 2], [3, 0, 1]]
Out[160]:
four one two
Colorado 7 0 5
Utah 11 8 9
这两个索引函数都可以处理切片,除了单个标签或标签列表:
In [161]: data.loc[:"Utah", "two"]
Out[161]:
Ohio 0
Colorado 5
Utah 9
Name: two, dtype: int64
In [162]: data.iloc[:, :3][data.three > 5]
Out[162]:
one two three
Colorado 0 5 6
Utah 8 9 10
New York 12 13 14
布尔数组可以与loc一起使用,但不能与iloc一起使用:
In [163]: data.loc[data.three >= 2]
Out[163]:
one two three four
Colorado 0 5 6 7
Utah 8 9 10 11
New York 12 13 14 15
有许多方法可以选择和重新排列 pandas 对象中包含的数据。对于 DataFrame,表 5.4 提供了许多这些方法的简要总结。正如您将在后面看到的,还有许多其他选项可用于处理分层索引。
表 5.4:DataFrame 的索引选项
| 类型 | 注释 |
|---|---|
df[column] |
从 DataFrame 中选择单个列或列序列;特殊情况便利:布尔数组(过滤行)、切片(切片行)或布尔 DataFrame(根据某些条件设置值) |
df.loc[rows] |
通过标签从 DataFrame 中选择单行或行子集 |
df.loc[:, cols] |
通过标签选择单个列或列子集 |
df.loc[rows, cols] |
通过标签选择行和列 |
df.iloc[rows] |
通过整数位置从 DataFrame 中选择单行或行子集 |
df.iloc[:, cols] |
通过整数位置选择单个列或列子集 |
df.iloc[rows, cols] |
通过整数位置选择行和列 |
df.at[row, col] |
通过行和列标签选择单个标量值 |
df.iat[row, col] |
通过行和列位置(整数)选择单个标量值 |
reindex方法 |
通过标签选择行或列 |
整数索引的陷阱
使用整数索引的 pandas 对象可能会成为新用户的绊脚石,因为它们与内置的 Python 数据结构(如列表和元组)的工作方式不同。例如,您可能不会期望以下代码生成错误:
In [164]: ser = pd.Series(np.arange(3.))
In [165]: ser
Out[165]:
0 0.0
1 1.0
2 2.0
dtype: float64
In [166]: ser[-1]
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
~/miniforge-x86/envs/book-env/lib/python3.10/site-packages/pandas/core/indexes/ra
nge.py in get_loc(self, key)
344 try:
--> 345 return self._range.index(new_key)
346 except ValueError as err:
ValueError: -1 is not in range
The above exception was the direct cause of the following exception:
KeyError Traceback (most recent call last)
<ipython-input-166-44969a759c20> in <module>
----> 1 ser[-1]
~/miniforge-x86/envs/book-env/lib/python3.10/site-packages/pandas/core/series.py
in __getitem__(self, key)
1010
1011 elif key_is_scalar:
-> 1012 return self._get_value(key)
1013
1014 if is_hashable(key):
~/miniforge-x86/envs/book-env/lib/python3.10/site-packages/pandas/core/series.py
in _get_value(self, label, takeable)
1119
1120 # Similar to Index.get_value, but we do not fall back to position
al
-> 1121 loc = self.index.get_loc(label)
1122
1123 if is_integer(loc):
~/miniforge-x86/envs/book-env/lib/python3.10/site-packages/pandas/core/indexes/ra
nge.py in get_loc(self, key)
345 return self._range.index(new_key)
346 except ValueError as err:
--> 347 raise KeyError(key) from err
348 self._check_indexing_error(key)
349 raise KeyError(key)
KeyError: -1
在这种情况下,pandas 可能会“回退”到整数索引,但是在不引入对用户代码中微妙错误的情况下,通常很难做到这一点。在这里,我们有一个包含0、1和2的索引,但 pandas 不想猜测用户想要什么(基于标签的索引还是基于位置的):
In [167]: ser
Out[167]:
0 0.0
1 1.0
2 2.0
dtype: float64
另一方面,对于非整数索引,没有这种歧义:
In [168]: ser2 = pd.Series(np.arange(3.), index=["a", "b", "c"])
In [169]: ser2[-1]
Out[169]: 2.0
如果您有包含整数的轴索引,数据选择将始终是基于标签的。正如我上面所说的,如果您使用loc(用于标签)或iloc(用于整数),您将得到确切想要的结果:
In [170]: ser.iloc[-1]
Out[170]: 2.0
另一方面,使用整数进行切片始终是基于整数的:
In [171]: ser[:2]
Out[171]:
0 0.0
1 1.0
dtype: float64
由于这些陷阱,最好始终优先使用loc和iloc进行索引,以避免歧义。
链式索引的陷阱
在前一节中,我们看了如何使用loc和iloc在 DataFrame 上进行灵活的选择。这些索引属性也可以用于就地修改 DataFrame 对象,但这样做需要一些小心。
例如,在上面的 DataFrame 示例中,我们可以按标签或整数位置分配到列或行:
In [172]: data.loc[:, "one"] = 1
In [173]: data
Out[173]:
one two three four
Ohio 1 0 0 0
Colorado 1 5 6 7
Utah 1 9 10 11
New York 1 13 14 15
In [174]: data.iloc[2] = 5
In [175]: data
Out[175]:
one two three four
Ohio 1 0 0 0
Colorado 1 5 6 7
Utah 5 5 5 5
New York 1 13 14 15
In [176]: data.loc[data["four"] > 5] = 3
In [177]: data
Out[177]:
one two three four
Ohio 1 0 0 0
Colorado 3 3 3 3
Utah 5 5 5 5
New York 3 3 3 3
对于新的 pandas 用户来说,一个常见的坑是在赋值时链接选择,就像这样:
In [177]: data.loc[data.three == 5]["three"] = 6
<ipython-input-11-0ed1cf2155d5>:1: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
根据数据内容的不同,这可能会打印一个特殊的SettingWithCopyWarning,它警告您正在尝试修改一个临时值(data.loc[data.three == 5]的非空结果),而不是原始 DataFramedata,这可能是您的本意。在这里,data没有被修改:
In [179]: data
Out[179]:
one two three four
Ohio 1 0 0 0
Colorado 3 3 3 3
Utah 5 5 5 5
New York 3 3 3 3
在这些情况下,修复的方法是重写链接赋值,使用单个loc操作:
In [180]: data.loc[data.three == 5, "three"] = 6
In [181]: data
Out[181]:
one two three four
Ohio 1 0 0 0
Colorado 3 3 3 3
Utah 5 5 6 5
New York 3 3 3 3
一个很好的经验法则是在进行赋值时避免链接索引。还有其他情况下,pandas 会生成SettingWithCopyWarning,这与链接索引有关。我建议您查阅在线 pandas 文档中的这个主题。
算术和数据对齐
pandas 可以使处理具有不同索引的对象变得更简单。例如,当您添加对象时,如果任何索引对不相同,结果中的相应索引将是索引对的并集。让我们看一个例子:
In [182]: s1 = pd.Series([7.3, -2.5, 3.4, 1.5], index=["a", "c", "d", "e"])
In [183]: s2 = pd.Series([-2.1, 3.6, -1.5, 4, 3.1],
.....: index=["a", "c", "e", "f", "g"])
In [184]: s1
Out[184]:
a 7.3
c -2.5
d 3.4
e 1.5
dtype: float64
In [185]: s2
Out[185]:
a -2.1
c 3.6
e -1.5
f 4.0
g 3.1
dtype: float64
将它们相加得到:
In [186]: s1 + s2
Out[186]:
a 5.2
c 1.1
d NaN
e 0.0
f NaN
g NaN
dtype: float64
内部数据对齐会在不重叠的标签位置引入缺失值。缺失值将在进一步的算术计算中传播。
对于 DataFrame,对齐是在行和列上执行的:
In [187]: df1 = pd.DataFrame(np.arange(9.).reshape((3, 3)), columns=list("bcd"),
.....: index=["Ohio", "Texas", "Colorado"])
In [188]: df2 = pd.DataFrame(np.arange(12.).reshape((4, 3)), columns=list("bde"),
.....: index=["Utah", "Ohio", "Texas", "Oregon"])
In [189]: df1
Out[189]:
b c d
Ohio 0.0 1.0 2.0
Texas 3.0 4.0 5.0
Colorado 6.0 7.0 8.0
In [190]: df2
Out[190]:
b d e
Utah 0.0 1.0 2.0
Ohio 3.0 4.0 5.0
Texas 6.0 7.0 8.0
Oregon 9.0 10.0 11.0
将它们相加返回一个 DataFrame,其索引和列是每个 DataFrame 中的索引的并集:
In [191]: df1 + df2
Out[191]:
b c d e
Colorado NaN NaN NaN NaN
Ohio 3.0 NaN 6.0 NaN
Oregon NaN NaN NaN NaN
Texas 9.0 NaN 12.0 NaN
Utah NaN NaN NaN NaN
由于 DataFrame 对象中都没有找到"c"和"e"列,它们在结果中显示为缺失。对于标签不共同的行也是如此。
如果添加没有共同列或行标签的 DataFrame 对象,结果将包含所有空值:
In [192]: df1 = pd.DataFrame({"A": [1, 2]})
In [193]: df2 = pd.DataFrame({"B": [3, 4]})
In [194]: df1
Out[194]:
A
0 1
1 2
In [195]: df2
Out[195]:
B
0 3
1 4
In [196]: df1 + df2
Out[196]:
A B
0 NaN NaN
1 NaN NaN
带有填充值的算术方法
在不同索引对象之间的算术操作中,当一个对象中找到一个轴标签而另一个对象中没有时,您可能希望填充一个特殊值,比如 0。以下是一个示例,我们通过将np.nan赋值给它来将特定值设置为 NA(null):
In [197]: df1 = pd.DataFrame(np.arange(12.).reshape((3, 4)),
.....: columns=list("abcd"))
In [198]: df2 = pd.DataFrame(np.arange(20.).reshape((4, 5)),
.....: columns=list("abcde"))
In [199]: df2.loc[1, "b"] = np.nan
In [200]: df1
Out[200]:
a b c d
0 0.0 1.0 2.0 3.0
1 4.0 5.0 6.0 7.0
2 8.0 9.0 10.0 11.0
In [201]: df2
Out[201]:
a b c d e
0 0.0 1.0 2.0 3.0 4.0
1 5.0 NaN 7.0 8.0 9.0
2 10.0 11.0 12.0 13.0 14.0
3 15.0 16.0 17.0 18.0 19.0
将它们相加会导致不重叠位置的缺失值:
In [202]: df1 + df2
Out[202]:
a b c d e
0 0.0 2.0 4.0 6.0 NaN
1 9.0 NaN 13.0 15.0 NaN
2 18.0 20.0 22.0 24.0 NaN
3 NaN NaN NaN NaN NaN
在df1上使用add方法,我传递df2和一个参数给fill_value,它会用传递的值替换操作中的任何缺失值:
In [203]: df1.add(df2, fill_value=0)
Out[203]:
a b c d e
0 0.0 2.0 4.0 6.0 4.0
1 9.0 5.0 13.0 15.0 9.0
2 18.0 20.0 22.0 24.0 14.0
3 15.0 16.0 17.0 18.0 19.0
请参阅表 5.5 以获取有关算术的 Series 和 DataFrame 方法的列表。每个方法都有一个对应的方法,以字母r开头,参数顺序相反。因此,以下两个语句是等价的:
In [204]: 1 / df1
Out[204]:
a b c d
0 inf 1.000000 0.500000 0.333333
1 0.250 0.200000 0.166667 0.142857
2 0.125 0.111111 0.100000 0.090909
In [205]: df1.rdiv(1)
Out[205]:
a b c d
0 inf 1.000000 0.500000 0.333333
1 0.250 0.200000 0.166667 0.142857
2 0.125 0.111111 0.100000 0.090909
相关地,在重新索引 Series 或 DataFrame 时,您还可以指定不同的填充值:
In [206]: df1.reindex(columns=df2.columns, fill_value=0)
Out[206]:
a b c d e
0 0.0 1.0 2.0 3.0 0
1 4.0 5.0 6.0 7.0 0
2 8.0 9.0 10.0 11.0 0
表 5.5:灵活的算术方法
| 方法 | 描述 |
|---|---|
add, radd |
加法方法(+) |
sub, rsub |
减法方法(-) |
div, rdiv |
除法方法(/) |
floordiv, rfloordiv |
地板除法方法(//) |
mul, rmul |
乘法方法(*) |
pow, rpow |
指数方法(**) |
DataFrame 和 Series 之间的操作
与不同维度的 NumPy 数组一样,DataFrame 和 Series 之间的算术也是定义的。首先,作为一个激励性的例子,考虑一个二维数组和其一行之间的差异:
In [207]: arr = np.arange(12.).reshape((3, 4))
In [208]: arr
Out[208]:
array([[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.]])
In [209]: arr[0]
Out[209]: array([0., 1., 2., 3.])
In [210]: arr - arr[0]
Out[210]:
array([[0., 0., 0., 0.],
[4., 4., 4., 4.],
[8., 8., 8., 8.]])
当我们从arr中减去arr[0]时,减法将针对每一行执行一次。这被称为广播,并且在附录 A:高级 NumPy 中更详细地解释了它与一般 NumPy 数组的关系。DataFrame 和 Series 之间的操作类似:
In [211]: frame = pd.DataFrame(np.arange(12.).reshape((4, 3)),
.....: columns=list("bde"),
.....: index=["Utah", "Ohio", "Texas", "Oregon"])
In [212]: series = frame.iloc[0]
In [213]: frame
Out[213]:
b d e
Utah 0.0 1.0 2.0
Ohio 3.0 4.0 5.0
Texas 6.0 7.0 8.0
Oregon 9.0 10.0 11.0
In [214]: series
Out[214]:
b 0.0
d 1.0
e 2.0
Name: Utah, dtype: float64
默认情况下,DataFrame 和 Series 之间的算术会将 Series 的索引与 DataFrame 的列匹配,向下广播行:
In [215]: frame - series
Out[215]:
b d e
Utah 0.0 0.0 0.0
Ohio 3.0 3.0 3.0
Texas 6.0 6.0 6.0
Oregon 9.0 9.0 9.0
如果索引值既不在 DataFrame 的列中,也不在 Series 的索引中找到,那么对象将被重新索引以形成并集:
In [216]: series2 = pd.Series(np.arange(3), index=["b", "e", "f"])
In [217]: series2
Out[217]:
b 0
e 1
f 2
dtype: int64
In [218]: frame + series2
Out[218]:
b d e f
Utah 0.0 NaN 3.0 NaN
Ohio 3.0 NaN 6.0 NaN
Texas 6.0 NaN 9.0 NaN
Oregon 9.0 NaN 12.0 NaN
如果您希望在列上进行广播,匹配行,您必须使用其中一个算术方法并指定匹配索引。例如:
In [219]: series3 = frame["d"]
In [220]: frame
Out[220]:
b d e
Utah 0.0 1.0 2.0
Ohio 3.0 4.0 5.0
Texas 6.0 7.0 8.0
Oregon 9.0 10.0 11.0
In [221]: series3
Out[221]:
Utah 1.0
Ohio 4.0
Texas 7.0
Oregon 10.0
Name: d, dtype: float64
In [222]: frame.sub(series3, axis="index")
Out[222]:
b d e
Utah -1.0 0.0 1.0
Ohio -1.0 0.0 1.0
Texas -1.0 0.0 1.0
Oregon -1.0 0.0 1.0
您传递的轴是要匹配的轴。在这种情况下,我们的意思是匹配 DataFrame 的行索引(axis="index")并在列之间广播。
函数应用和映射
NumPy ufuncs(逐元素数组方法)也适用于 pandas 对象:
In [223]: frame = pd.DataFrame(np.random.standard_normal((4, 3)),
.....: columns=list("bde"),
.....: index=["Utah", "Ohio", "Texas", "Oregon"])
In [224]: frame
Out[224]:
b d e
Utah -0.204708 0.478943 -0.519439
Ohio -0.555730 1.965781 1.393406
Texas 0.092908 0.281746 0.769023
Oregon 1.246435 1.007189 -1.296221
In [225]: np.abs(frame)
Out[225]:
b d e
Utah 0.204708 0.478943 0.519439
Ohio 0.555730 1.965781 1.393406
Texas 0.092908 0.281746 0.769023
Oregon 1.246435 1.007189 1.296221
另一个频繁的操作是将一个一维数组上的函数应用于每列或每行。DataFrame 的apply方法正是这样做的:
In [226]: def f1(x):
.....: return x.max() - x.min()
In [227]: frame.apply(f1)
Out[227]:
b 1.802165
d 1.684034
e 2.689627
dtype: float64
这里的函数f计算 Series 的最大值和最小值之间的差异,对frame中的每列调用一次。结果是一个具有frame列作为其索引的 Series。
如果将axis="columns"传递给apply,则该函数将每行调用一次。将其视为"跨列应用"是一种有用的方式:
In [228]: frame.apply(f1, axis="columns")
Out[228]:
Utah 0.998382
Ohio 2.521511
Texas 0.676115
Oregon 2.542656
dtype: float64
许多最常见的数组统计(如sum和mean)都是 DataFrame 方法,因此不需要使用apply。
传递给apply的函数不必返回标量值;它也可以返回具有多个值的 Series:
In [229]: def f2(x):
.....: return pd.Series([x.min(), x.max()], index=["min", "max"])
In [230]: frame.apply(f2)
Out[230]:
b d e
min -0.555730 0.281746 -1.296221
max 1.246435 1.965781 1.393406
也可以使用逐元素 Python 函数。假设您想要从frame中的每个浮点值计算格式化字符串。您可以使用applymap来实现:
In [231]: def my_format(x):
.....: return f"{x:.2f}"
In [232]: frame.applymap(my_format)
Out[232]:
b d e
Utah -0.20 0.48 -0.52
Ohio -0.56 1.97 1.39
Texas 0.09 0.28 0.77
Oregon 1.25 1.01 -1.30
applymap的命名原因是 Series 有一个map方法,用于应用逐元素函数:
In [233]: frame["e"].map(my_format)
Out[233]:
Utah -0.52
Ohio 1.39
Texas 0.77
Oregon -1.30
Name: e, dtype: object
排序和排名
按某个标准对数据集进行排序是另一个重要的内置操作。要按行或列标签的字典顺序排序,请使用sort_index方法,该方法返回一个新的排序对象:
In [234]: obj = pd.Series(np.arange(4), index=["d", "a", "b", "c"])
In [235]: obj
Out[235]:
d 0
a 1
b 2
c 3
dtype: int64
In [236]: obj.sort_index()
Out[236]:
a 1
b 2
c 3
d 0
dtype: int64
对于 DataFrame,您可以在任一轴上按索引排序:
In [237]: frame = pd.DataFrame(np.arange(8).reshape((2, 4)),
.....: index=["three", "one"],
.....: columns=["d", "a", "b", "c"])
In [238]: frame
Out[238]:
d a b c
three 0 1 2 3
one 4 5 6 7
In [239]: frame.sort_index()
Out[239]:
d a b c
one 4 5 6 7
three 0 1 2 3
In [240]: frame.sort_index(axis="columns")
Out[240]:
a b c d
three 1 2 3 0
one 5 6 7 4
默认情况下,数据按升序排序,但也可以按降序排序:
In [241]: frame.sort_index(axis="columns", ascending=False)
Out[241]:
d c b a
three 0 3 2 1
one 4 7 6 5
要按值对 Series 进行排序,请使用其sort_values方法:
In [242]: obj = pd.Series([4, 7, -3, 2])
In [243]: obj.sort_values()
Out[243]:
2 -3
3 2
0 4
1 7
dtype: int64
默认情况下,任何缺失值都按顺序排在 Series 的末尾:
In [244]: obj = pd.Series([4, np.nan, 7, np.nan, -3, 2])
In [245]: obj.sort_values()
Out[245]:
4 -3.0
5 2.0
0 4.0
2 7.0
1 NaN
3 NaN
dtype: float64
缺失值也可以通过使用na_position选项将其排序到开头:
In [246]: obj.sort_values(na_position="first")
Out[246]:
1 NaN
3 NaN
4 -3.0
5 2.0
0 4.0
2 7.0
dtype: float64
在对 DataFrame 进行排序时,可以使用一个或多个列中的数据作为排序键。为此,请将一个或多个列名传递给sort_values:
In [247]: frame = pd.DataFrame({"b": [4, 7, -3, 2], "a": [0, 1, 0, 1]})
In [248]: frame
Out[248]:
b a
0 4 0
1 7 1
2 -3 0
3 2 1
In [249]: frame.sort_values("b")
Out[249]:
b a
2 -3 0
3 2 1
0 4 0
1 7 1
要按多个列排序,请传递一个名称列表:
In [250]: frame.sort_values(["a", "b"])
Out[250]:
b a
2 -3 0
0 4 0
3 2 1
1 7 1
排名从数组中的最低值开始,为数组中的每个有效数据点分配从 1 到数据点数量的等级。Series 和 DataFrame 的rank方法是要查看的地方;默认情况下,rank通过为每个组分配平均等级来打破平局:
In [251]: obj = pd.Series([7, -5, 7, 4, 2, 0, 4])
In [252]: obj.rank()
Out[252]:
0 6.5
1 1.0
2 6.5
3 4.5
4 3.0
5 2.0
6 4.5
dtype: float64
排名也可以根据它们在数据中观察到的顺序进行分配:
In [253]: obj.rank(method="first")
Out[253]:
0 6.0
1 1.0
2 7.0
3 4.0
4 3.0
5 2.0
6 5.0
dtype: float64
在这里,与使用条目 0 和 2 的平均等级 6.5 不同,它们分别设置为 6 和 7,因为标签 0 在数据中位于标签 2 之前。
您也可以按降序排名:
In [254]: obj.rank(ascending=False)
Out[254]:
0 1.5
1 7.0
2 1.5
3 3.5
4 5.0
5 6.0
6 3.5
dtype: float64
请参阅表 5.6 以获取可用的平局破解方法列表。
DataFrame 可以在行或列上计算排名:
In [255]: frame = pd.DataFrame({"b": [4.3, 7, -3, 2], "a": [0, 1, 0, 1],
.....: "c": [-2, 5, 8, -2.5]})
In [256]: frame
Out[256]:
b a c
0 4.3 0 -2.0
1 7.0 1 5.0
2 -3.0 0 8.0
3 2.0 1 -2.5
In [257]: frame.rank(axis="columns")
Out[257]:
b a c
0 3.0 2.0 1.0
1 3.0 1.0 2.0
2 1.0 2.0 3.0
3 3.0 2.0 1.0
表 5.6:排名的平局破解方法
| 方法 | 描述 |
|---|---|
"average" |
默认:为相等组中的每个条目分配平均等级 |
"min" |
使用整个组的最小等级 |
"max" |
使用整个组的最大等级 |
"first" |
按数据中值出现的顺序分配等级 |
"dense" |
类似于method="min",但等级总是在组之间增加 1,而不是在组中相等元素的数量之间增加 |
具有重复标签的轴索引
到目前为止,我们看过的几乎所有示例都具有唯一的轴标签(索引值)。虽然许多 pandas 函数(如reindex)要求标签是唯一的,但这并非强制要求。让我们考虑一个具有重复索引的小 Series:
In [258]: obj = pd.Series(np.arange(5), index=["a", "a", "b", "b", "c"])
In [259]: obj
Out[259]:
a 0
a 1
b 2
b 3
c 4
dtype: int64
索引的is_unique属性可以告诉您其标签是否唯一:
In [260]: obj.index.is_unique
Out[260]: False
数据选择是与重复不同的主要行为之一。索引具有多个条目的标签返回一个 Series,而单个条目返回一个标量值:
In [261]: obj["a"]
Out[261]:
a 0
a 1
dtype: int64
In [262]: obj["c"]
Out[262]: 4
这可能会使您的代码变得更加复杂,因为根据标签是否重复,索引的输出类型可能会有所不同。
相同的逻辑也适用于 DataFrame 中的行(或列)索引:
In [263]: df = pd.DataFrame(np.random.standard_normal((5, 3)),
.....: index=["a", "a", "b", "b", "c"])
In [264]: df
Out[264]:
0 1 2
a 0.274992 0.228913 1.352917
a 0.886429 -2.001637 -0.371843
b 1.669025 -0.438570 -0.539741
b 0.476985 3.248944 -1.021228
c -0.577087 0.124121 0.302614
In [265]: df.loc["b"]
Out[265]:
0 1 2
b 1.669025 -0.438570 -0.539741
b 0.476985 3.248944 -1.021228
In [266]: df.loc["c"]
Out[266]:
0 -0.577087
1 0.124121
2 0.302614
Name: c, dtype: float64
5.3 总结和计算描述性统计
pandas 对象配备了一组常见的数学和统计方法。其中大多数属于减少或摘要统计的类别,这些方法从 Series 中提取单个值(如总和或均值),或者从 DataFrame 的行或列中提取一系列值。与 NumPy 数组上找到的类似方法相比,它们内置了对缺失数据的处理。考虑一个小的 DataFrame:
In [267]: df = pd.DataFrame([[1.4, np.nan], [7.1, -4.5],
.....: [np.nan, np.nan], [0.75, -1.3]],
.....: index=["a", "b", "c", "d"],
.....: columns=["one", "two"])
In [268]: df
Out[268]:
one two
a 1.40 NaN
b 7.10 -4.5
c NaN NaN
d 0.75 -1.3
调用 DataFrame 的sum方法会返回一个包含列和的 Series:
In [269]: df.sum()
Out[269]:
one 9.25
two -5.80
dtype: float64
传递axis="columns"或axis=1会跨列求和:
In [270]: df.sum(axis="columns")
Out[270]:
a 1.40
b 2.60
c 0.00
d -0.55
dtype: float64
当整行或整列包含所有 NA 值时,总和为 0,而如果任何值不是 NA,则结果为 NA。可以使用skipna选项禁用此功能,在这种情况下,行或列中的任何 NA 值都会使相应的结果为 NA:
In [271]: df.sum(axis="index", skipna=False)
Out[271]:
one NaN
two NaN
dtype: float64
In [272]: df.sum(axis="columns", skipna=False)
Out[272]:
a NaN
b 2.60
c NaN
d -0.55
dtype: float64
一些聚合,如mean,需要至少一个非 NA 值才能产生一个值结果,因此我们有:
In [273]: df.mean(axis="columns")
Out[273]:
a 1.400
b 1.300
c NaN
d -0.275
dtype: float64
请参见表 5.7 以获取每种减少方法的常见选项列表。
表 5.7:减少方法的选项
| 方法 | 描述 |
|---|---|
axis |
要减少的轴;DataFrame 的行为“index”,列为“columns” |
skipna |
排除缺失值;默认为True |
level |
如果轴是分层索引(MultiIndex),则按级别减少 |
一些方法,如idxmin和idxmax,返回间接统计信息,如达到最小值或最大值的索引值:
In [274]: df.idxmax()
Out[274]:
one b
two d
dtype: object
其他方法是累积:
In [275]: df.cumsum()
Out[275]:
one two
a 1.40 NaN
b 8.50 -4.5
c NaN NaN
d 9.25 -5.8
一些方法既不是减少也不是累积。describe就是一个例子,一次生成多个摘要统计信息:
In [276]: df.describe()
Out[276]:
one two
count 3.000000 2.000000
mean 3.083333 -2.900000
std 3.493685 2.262742
min 0.750000 -4.500000
25% 1.075000 -3.700000
50% 1.400000 -2.900000
75% 4.250000 -2.100000
max 7.100000 -1.300000
对于非数字数据,describe会生成替代的摘要统计信息:
In [277]: obj = pd.Series(["a", "a", "b", "c"] * 4)
In [278]: obj.describe()
Out[278]:
count 16
unique 3
top a
freq 8
dtype: object
请参见表 5.8 以获取摘要统计和相关方法的完整列表。
表 5.8:描述性和摘要统计
| 方法 | 描述 |
|---|---|
count |
非 NA 值的数量 |
describe |
计算一组摘要统计信息 |
min, max |
计算最小值和最大值 |
argmin, argmax |
计算获得最小值或最大值的索引位置(整数),分别;在 DataFrame 对象上不可用 |
idxmin, idxmax |
计算获得最小值或最大值的索引标签 |
quantile |
计算从 0 到 1 范围的样本分位数(默认值:0.5) |
sum |
值的总和 |
mean |
值的均值 |
median |
值的算术中位数(50%分位数) |
mad |
与均值的平均绝对偏差 |
prod |
所有值的乘积 |
var |
值的样本方差 |
std |
值的样本标准差 |
skew |
值的样本偏度(第三时刻) |
kurt |
值的样本峰度(第四时刻) |
cumsum |
值的累积和 |
cummin, cummax |
值的累积最小值或最大值,分别 |
cumprod |
值的累积乘积 |
diff |
计算第一个算术差异(对时间序列有用) |
pct_change |
计算百分比变化 |
相关性和协方差
一些摘要统计信息,如相关性和协方差,是从一对参数计算得出的。让我们考虑一些股票价格和成交量的 DataFrame,最初从 Yahoo! Finance 获取,并在本书的附带数据集中以二进制 Python pickle 文件的形式提供:
In [279]: price = pd.read_pickle("examples/yahoo_price.pkl")
In [280]: volume = pd.read_pickle("examples/yahoo_volume.pkl")
现在我计算价格的百分比变化,这是一个时间序列操作,将在第十一章:时间序列中进一步探讨:
In [281]: returns = price.pct_change()
In [282]: returns.tail()
Out[282]:
AAPL GOOG IBM MSFT
Date
2016-10-17 -0.000680 0.001837 0.002072 -0.003483
2016-10-18 -0.000681 0.019616 -0.026168 0.007690
2016-10-19 -0.002979 0.007846 0.003583 -0.002255
2016-10-20 -0.000512 -0.005652 0.001719 -0.004867
2016-10-21 -0.003930 0.003011 -0.012474 0.042096
Series 的corr方法计算两个 Series 中重叠的、非 NA、按索引对齐的值的相关性。相关地,cov计算协方差:
In [283]: returns["MSFT"].corr(returns["IBM"])
Out[283]: 0.49976361144151166
In [284]: returns["MSFT"].cov(returns["IBM"])
Out[284]: 8.870655479703549e-05
另一方面,DataFrame 的corr和cov方法分别返回完整的相关性或协方差矩阵作为 DataFrame:
In [285]: returns.corr()
Out[285]:
AAPL GOOG IBM MSFT
AAPL 1.000000 0.407919 0.386817 0.389695
GOOG 0.407919 1.000000 0.405099 0.465919
IBM 0.386817 0.405099 1.000000 0.499764
MSFT 0.389695 0.465919 0.499764 1.000000
In [286]: returns.cov()
Out[286]:
AAPL GOOG IBM MSFT
AAPL 0.000277 0.000107 0.000078 0.000095
GOOG 0.000107 0.000251 0.000078 0.000108
IBM 0.000078 0.000078 0.000146 0.000089
MSFT 0.000095 0.000108 0.000089 0.000215
使用 DataFrame 的corrwith方法,您可以计算 DataFrame 的列或行与另一个 Series 或 DataFrame 之间的成对相关性。传递一个 Series 会返回一个 Series,其中计算了每列的相关值:
In [287]: returns.corrwith(returns["IBM"])
Out[287]:
AAPL 0.386817
GOOG 0.405099
IBM 1.000000
MSFT 0.499764
dtype: float64
传递一个 DataFrame 会计算匹配列名的相关性。在这里,我计算了百分比变化与成交量的相关性:
In [288]: returns.corrwith(volume)
Out[288]:
AAPL -0.075565
GOOG -0.007067
IBM -0.204849
MSFT -0.092950
dtype: float64
传递axis="columns"会逐行执行操作。在所有情况下,在计算相关性之前,数据点都会按标签对齐。
唯一值、值计数和成员资格
另一类相关方法提取一维 Series 中包含的值的信息。为了说明这些方法,考虑以下示例:
In [289]: obj = pd.Series(["c", "a", "d", "a", "a", "b", "b", "c", "c"])
第一个函数是unique,它为您提供 Series 中唯一值的数组:
In [290]: uniques = obj.unique()
In [291]: uniques
Out[291]: array(['c', 'a', 'd', 'b'], dtype=object)
唯一的值不一定按它们首次出现的顺序返回,也不按排序顺序返回,但如果需要的话可以在之后排序(uniques.sort())。相关地,value_counts计算包含值频率的 Series:
In [292]: obj.value_counts()
Out[292]:
c 3
a 3
b 2
d 1
Name: count, dtype: int64
Series 按值降序排序以方便起见。value_counts也作为顶级 pandas 方法可用,可与 NumPy 数组或其他 Python 序列一起使用:
In [293]: pd.value_counts(obj.to_numpy(), sort=False)
Out[293]:
c 3
a 3
d 1
b 2
Name: count, dtype: int64
isin执行矢量化的成员检查,并且在将数据集过滤到 Series 或 DataFrame 中的值子集时可能很有用:
In [294]: obj
Out[294]:
0 c
1 a
2 d
3 a
4 a
5 b
6 b
7 c
8 c
dtype: object
In [295]: mask = obj.isin(["b", "c"])
In [296]: mask
Out[296]:
0 True
1 False
2 False
3 False
4 False
5 True
6 True
7 True
8 True
dtype: bool
In [297]: obj[mask]
Out[297]:
0 c
5 b
6 b
7 c
8 c
dtype: object
与isin相关的是Index.get_indexer方法,它从可能不同的值的数组中为另一个不同值的数组提供索引数组:
In [298]: to_match = pd.Series(["c", "a", "b", "b", "c", "a"])
In [299]: unique_vals = pd.Series(["c", "b", "a"])
In [300]: indices = pd.Index(unique_vals).get_indexer(to_match)
In [301]: indices
Out[301]: array([0, 2, 1, 1, 0, 2])
有关这些方法的参考,请参见表 5.9。
表 5.9:唯一值、值计数和成员资格方法
| 方法 | 描述 |
|---|---|
isin |
计算一个布尔数组,指示每个 Series 或 DataFrame 值是否包含在传递的值序列中 |
get_indexer |
为数组中的每个值计算整数索引,以便将其对齐到另一个不同值的数组;有助于数据对齐和连接类型操作 |
unique |
计算 Series 中唯一值的数组,按观察顺序返回 |
value_counts |
返回一个 Series,其唯一值作为索引,频率作为值,按降序计数排序 |
在某些情况下,您可能希望在 DataFrame 中的多个相关列上计算直方图。以下是一个示例:
In [302]: data = pd.DataFrame({"Qu1": [1, 3, 4, 3, 4],
.....: "Qu2": [2, 3, 1, 2, 3],
.....: "Qu3": [1, 5, 2, 4, 4]})
In [303]: data
Out[303]:
Qu1 Qu2 Qu3
0 1 2 1
1 3 3 5
2 4 1 2
3 3 2 4
4 4 3 4
我们可以计算单列的值计数,如下所示:
In [304]: data["Qu1"].value_counts().sort_index()
Out[304]:
Qu1
1 1
3 2
4 2
Name: count, dtype: int64
要为所有列计算此值,请将pandas.value_counts传递给 DataFrame 的apply方法:
In [305]: result = data.apply(pd.value_counts).fillna(0)
In [306]: result
Out[306]:
Qu1 Qu2 Qu3
1 1.0 1.0 1.0
2 0.0 2.0 1.0
3 2.0 2.0 0.0
4 2.0 0.0 2.0
5 0.0 0.0 1.0
在这里,结果中的行标签是所有列中出现的不同值。这些值是每列中这些值的相应计数。
还有一个DataFrame.value_counts方法,但它计算考虑 DataFrame 的每一行作为元组的计数,以确定每个不同行的出现次数:
In [307]: data = pd.DataFrame({"a": [1, 1, 1, 2, 2], "b": [0, 0, 1, 0, 0]})
In [308]: data
Out[308]:
a b
0 1 0
1 1 0
2 1 1
3 2 0
4 2 0
In [309]: data.value_counts()
Out[309]:
a b
1 0 2
2 0 2
1 1 1
Name: count, dtype: int64
在这种情况下,结果具有一个表示不同行的索引作为层次索引,这是我们将在第八章:数据整理:连接、合并和重塑中更详细地探讨的一个主题。
5.4 结论
在下一章中,我们将讨论使用 pandas 读取(或加载)和写入数据集的工具。之后,我们将深入探讨使用 pandas 进行数据清洗、整理、分析和可视化的工具。
六、数据加载、存储和文件格式
原文:
wesmckinney.com/book/accessing-data译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
读取数据并使其可访问(通常称为数据加载)是使用本书中大多数工具的必要第一步。术语解析有时也用于描述加载文本数据并将其解释为表格和不同数据类型。我将专注于使用 pandas 进行数据输入和输出,尽管其他库中有许多工具可帮助读取和写入各种格式的数据。
输入和输出通常分为几个主要类别:读取文本文件和其他更高效的磁盘格式、从数据库加载数据以及与网络源(如 Web API)交互。
6.1 以文本格式读取和写入数据
pandas 提供了许多函数,用于将表格数据读取为 DataFrame 对象。表 6.1 总结了其中一些;pandas.read_csv是本书中最常用的之一。我们将在二进制数据格式中稍后查看二进制数据格式。
表 6.1:pandas 中的文本和二进制数据加载函数
| 函数 | 描述 |
|---|---|
read_csv |
从文件、URL 或类似文件的对象中加载分隔数据;使用逗号作为默认分隔符 |
read_fwf |
以固定宽度列格式读取数据(即没有分隔符) |
read_clipboard |
读取剪贴板中的数据的read_csv变体;用于将网页上的表格转换的有用工具 |
read_excel |
从 Excel XLS 或 XLSX 文件中读取表格数据 |
read_hdf |
读取 pandas 写入的 HDF5 文件 |
read_html |
读取给定 HTML 文档中找到的所有表格 |
read_json |
从 JSON(JavaScript 对象表示)字符串表示、文件、URL 或类似文件的对象中读取数据 |
read_feather |
读取 Feather 二进制文件格式 |
read_orc |
读取 Apache ORC 二进制文件格式 |
read_parquet |
读取 Apache Parquet 二进制文件格式 |
read_pickle |
使用 Python pickle 格式读取由 pandas 存储的对象 |
read_sas |
读取存储在 SAS 系统的自定义存储格式之一中的 SAS 数据集 |
read_spss |
读取由 SPSS 创建的数据文件 |
read_sql |
读取 SQL 查询的结果(使用 SQLAlchemy) |
read_sql_table |
读取整个 SQL 表(使用 SQLAlchemy);等同于使用选择该表中的所有内容的查询使用read_sql |
read_stata |
从 Stata 文件格式中读取数据集 |
read_xml |
从 XML 文件中读取数据表 |
我将概述这些函数的机制,这些函数旨在将文本数据转换为 DataFrame。这些函数的可选参数可能属于几个类别:
索引
可以将一个或多个列视为返回的 DataFrame,并确定是否从文件、您提供的参数或根本不获取列名。
类型推断和数据转换
包括用户定义的值转换和自定义缺失值标记列表。
日期和时间解析
包括一种组合能力,包括将分布在多个列中的日期和时间信息组合成结果中的单个列。
迭代
支持迭代处理非常大文件的块。
不干净的数据问题
包括跳过行或页脚、注释或其他像数字数据以逗号分隔的小事物。
由于现实世界中的数据可能会很混乱,一些数据加载函数(特别是pandas.read_csv)随着时间的推移积累了很长的可选参数列表。对于不同参数的数量感到不知所措是正常的(pandas.read_csv大约有 50 个)。在线 pandas 文档有许多关于每个参数如何工作的示例,因此如果您在阅读特定文件时感到困惑,可能会有足够相似的示例帮助您找到正确的参数。
其中一些函数执行类型推断,因为列数据类型不是数据格式的一部分。这意味着您不一定需要指定哪些列是数字、整数、布尔值或字符串。其他数据格式,如 HDF5、ORC 和 Parquet,将数据类型信息嵌入到格式中。
处理日期和其他自定义类型可能需要额外的努力。
让我们从一个小的逗号分隔值(CSV)文本文件开始:
In [10]: !cat examples/ex1.csv
a,b,c,d,message
1,2,3,4,hello
5,6,7,8,world
9,10,11,12,foo
注意
这里我使用了 Unix 的cat shell 命令将文件的原始内容打印到屏幕上。如果您使用 Windows,可以在 Windows 终端(或命令行)中使用type代替cat来实现相同的效果。
由于这是逗号分隔的,我们可以使用pandas.read_csv将其读入 DataFrame:
In [11]: df = pd.read_csv("examples/ex1.csv")
In [12]: df
Out[12]:
a b c d message
0 1 2 3 4 hello
1 5 6 7 8 world
2 9 10 11 12 foo
文件不总是有标题行。考虑这个文件:
In [13]: !cat examples/ex2.csv
1,2,3,4,hello
5,6,7,8,world
9,10,11,12,foo
要读取此文件,您有几个选项。您可以允许 pandas 分配默认列名,或者您可以自己指定名称:
In [14]: pd.read_csv("examples/ex2.csv", header=None)
Out[14]:
0 1 2 3 4
0 1 2 3 4 hello
1 5 6 7 8 world
2 9 10 11 12 foo
In [15]: pd.read_csv("examples/ex2.csv", names=["a", "b", "c", "d", "message"])
Out[15]:
a b c d message
0 1 2 3 4 hello
1 5 6 7 8 world
2 9 10 11 12 foo
假设您希望message列成为返回的 DataFrame 的索引。您可以使用index_col参数指示您希望在索引 4 处或使用名称"message":
In [16]: names = ["a", "b", "c", "d", "message"]
In [17]: pd.read_csv("examples/ex2.csv", names=names, index_col="message")
Out[17]:
a b c d
message
hello 1 2 3 4
world 5 6 7 8
foo 9 10 11 12
如果要从多个列创建分层索引(在 Ch 8.1:分层索引中讨论),请传递列编号或名称的列表:
In [18]: !cat examples/csv_mindex.csv
key1,key2,value1,value2
one,a,1,2
one,b,3,4
one,c,5,6
one,d,7,8
two,a,9,10
two,b,11,12
two,c,13,14
two,d,15,16
In [19]: parsed = pd.read_csv("examples/csv_mindex.csv",
....: index_col=["key1", "key2"])
In [20]: parsed
Out[20]:
value1 value2
key1 key2
one a 1 2
b 3 4
c 5 6
d 7 8
two a 9 10
b 11 12
c 13 14
d 15 16
在某些情况下,表格可能没有固定的分隔符,而是使用空格或其他模式来分隔字段。考虑一个看起来像这样的文本文件:
In [21]: !cat examples/ex3.txt
A B C
aaa -0.264438 -1.026059 -0.619500
bbb 0.927272 0.302904 -0.032399
ccc -0.264273 -0.386314 -0.217601
ddd -0.871858 -0.348382 1.100491
虽然您可以手动进行一些数据处理,但这里的字段是由可变数量的空格分隔的。在这些情况下,您可以将正则表达式作为pandas.read_csv的分隔符传递。这可以通过正则表达式\s+表示,因此我们有:
In [22]: result = pd.read_csv("examples/ex3.txt", sep="\s+")
In [23]: result
Out[23]:
A B C
aaa -0.264438 -1.026059 -0.619500
bbb 0.927272 0.302904 -0.032399
ccc -0.264273 -0.386314 -0.217601
ddd -0.871858 -0.348382 1.100491
由于列名比数据行数少一个,pandas.read_csv推断在这种特殊情况下第一列应该是 DataFrame 的索引。
文件解析函数有许多额外的参数,可帮助您处理发生的各种异常文件格式(请参见表 6.2 中的部分列表)。例如,您可以使用skiprows跳过文件的第一、第三和第四行:
In [24]: !cat examples/ex4.csv
# hey!
a,b,c,d,message
# just wanted to make things more difficult for you
# who reads CSV files with computers, anyway?
1,2,3,4,hello
5,6,7,8,world
9,10,11,12,foo
In [25]: pd.read_csv("examples/ex4.csv", skiprows=[0, 2, 3])
Out[25]:
a b c d message
0 1 2 3 4 hello
1 5 6 7 8 world
2 9 10 11 12 foo
处理缺失值是文件读取过程中重要且经常微妙的部分。缺失数据通常要么不存在(空字符串),要么由某个标记(占位符)值标记。默认情况下,pandas 使用一组常见的标记,例如NA和NULL:
In [26]: !cat examples/ex5.csv
something,a,b,c,d,message
one,1,2,3,4,NA
two,5,6,,8,world
three,9,10,11,12,foo
In [27]: result = pd.read_csv("examples/ex5.csv")
In [28]: result
Out[28]:
something a b c d message
0 one 1 2 3.0 4 NaN
1 two 5 6 NaN 8 world
2 three 9 10 11.0 12 foo
请记住,pandas 将缺失值输出为NaN,因此在result中有两个空值或缺失值:
In [29]: pd.isna(result)
Out[29]:
something a b c d message
0 False False False False False True
1 False False False True False False
2 False False False False False False
na_values选项接受一个字符串序列,用于添加到默认识别为缺失的字符串列表中:
In [30]: result = pd.read_csv("examples/ex5.csv", na_values=["NULL"])
In [31]: result
Out[31]:
something a b c d message
0 one 1 2 3.0 4 NaN
1 two 5 6 NaN 8 world
2 three 9 10 11.0 12 foo
pandas.read_csv有许多默认的 NA 值表示列表,但这些默认值可以通过keep_default_na选项禁用:
In [32]: result2 = pd.read_csv("examples/ex5.csv", keep_default_na=False)
In [33]: result2
Out[33]:
something a b c d message
0 one 1 2 3 4 NA
1 two 5 6 8 world
2 three 9 10 11 12 foo
In [34]: result2.isna()
Out[34]:
something a b c d message
0 False False False False False False
1 False False False False False False
2 False False False False False False
In [35]: result3 = pd.read_csv("examples/ex5.csv", keep_default_na=False,
....: na_values=["NA"])
In [36]: result3
Out[36]:
something a b c d message
0 one 1 2 3 4 NaN
1 two 5 6 8 world
2 three 9 10 11 12 foo
In [37]: result3.isna()
Out[37]:
something a b c d message
0 False False False False False True
1 False False False False False False
2 False False False False False False
可以在字典中为每列指定不同的 NA 标记:
In [38]: sentinels = {"message": ["foo", "NA"], "something": ["two"]}
In [39]: pd.read_csv("examples/ex5.csv", na_values=sentinels,
....: keep_default_na=False)
Out[39]:
something a b c d message
0 one 1 2 3 4 NaN
1 NaN 5 6 8 world
2 three 9 10 11 12 NaN
表 6.2 列出了pandas.read_csv中一些经常使用的选项。
表 6.2:一些pandas.read_csv函数参数
| 参数 | 描述 |
|---|---|
path |
指示文件系统位置、URL 或类似文件的字符串。 |
sep或delimiter |
用于在每行中拆分字段的字符序列或正则表达式。 |
header |
用作列名的行号;默认为 0(第一行),但如果没有标题行,则应为None。 |
index_col |
用作结果中行索引的列号或名称;可以是单个名称/编号或用于分层索引的列表。 |
names |
结果的列名列表。 |
skiprows |
要忽略的文件开头的行数或要跳过的行号列表(从 0 开始)。 |
na_values |
要替换为 NA 的值序列。除非传递keep_default_na=False,否则它们将添加到默认列表中。 |
keep_default_na |
是否使用默认的 NA 值列表(默认为True)。 |
comment |
用于将注释从行末分隔出来的字符。 |
parse_dates |
尝试解析数据为datetime;默认为False。如果为True,将尝试解析所有列。否则,可以指定要解析的列号或名称的列表。如果列表的元素是元组或列表,则将多个列组合在一起并解析为日期(例如,如果日期/时间跨越两列)。 |
keep_date_col |
如果连接列以解析日期,则保留连接的列;默认为False。 |
converters |
包含列号或名称映射到函数的字典(例如,{"foo": f}将对"foo"列中的所有值应用函数f)。 |
dayfirst |
在解析可能模糊的日期时,将其视为国际格式(例如,7/6/2012 -> 2012 年 6 月 7 日);默认为False。 |
date_parser |
用于解析日期的函数。 |
nrows |
从文件开头读取的行数(不包括标题)。 |
iterator |
返回一个用于逐步读取文件的TextFileReader对象。此对象也可以与with语句一起使用。 |
chunksize |
用于迭代的文件块的大小。 |
skip_footer |
要忽略的文件末尾行数。 |
verbose |
打印各种解析信息,如文件转换各阶段所花费的时间和内存使用信息。 |
encoding |
文本编码(例如,UTF-8 编码文本的"utf-8")。如果为None,默认为"utf-8"。 |
squeeze |
如果解析的数据只包含一列,则返回一个 Series。 |
thousands |
千位分隔符(例如,","或".");默认为None。 |
decimal |
数字中的小数分隔符(例如,"."或",");默认为"."。 |
engine |
要使用的 CSV 解析和转换引擎;可以是"c"、"python"或"pyarrow"之一。默认为"c",尽管较新的"pyarrow"引擎可以更快地解析一些文件。"python"引擎速度较慢,但支持其他引擎不支持的一些功能。 |
分块读取文本文件
在处理非常大的文件或找出正确的参数集以正确处理大文件时,您可能只想读取文件的一小部分或迭代文件的较小块。
在查看大文件之前,我们将 pandas 显示设置更加紧凑:
In [40]: pd.options.display.max_rows = 10
现在我们有:
In [41]: result = pd.read_csv("examples/ex6.csv")
In [42]: result
Out[42]:
one two three four key
0 0.467976 -0.038649 -0.295344 -1.824726 L
1 -0.358893 1.404453 0.704965 -0.200638 B
2 -0.501840 0.659254 -0.421691 -0.057688 G
3 0.204886 1.074134 1.388361 -0.982404 R
4 0.354628 -0.133116 0.283763 -0.837063 Q
... ... ... ... ... ..
9995 2.311896 -0.417070 -1.409599 -0.515821 L
9996 -0.479893 -0.650419 0.745152 -0.646038 E
9997 0.523331 0.787112 0.486066 1.093156 K
9998 -0.362559 0.598894 -1.843201 0.887292 G
9999 -0.096376 -1.012999 -0.657431 -0.573315 0
[10000 rows x 5 columns]
省略号...表示已省略数据框中间的行。
如果您只想读取少量行(避免读取整个文件),请使用nrows指定:
In [43]: pd.read_csv("examples/ex6.csv", nrows=5)
Out[43]:
one two three four key
0 0.467976 -0.038649 -0.295344 -1.824726 L
1 -0.358893 1.404453 0.704965 -0.200638 B
2 -0.501840 0.659254 -0.421691 -0.057688 G
3 0.204886 1.074134 1.388361 -0.982404 R
4 0.354628 -0.133116 0.283763 -0.837063 Q
要分块读取文件,指定一个作为行数的chunksize:
In [44]: chunker = pd.read_csv("examples/ex6.csv", chunksize=1000)
In [45]: type(chunker)
Out[45]: pandas.io.parsers.readers.TextFileReader
由pandas.read_csv返回的TextFileReader对象允许您根据chunksize迭代文件的部分。例如,我们可以迭代ex6.csv,聚合"key"列中的值计数,如下所示:
chunker = pd.read_csv("examples/ex6.csv", chunksize=1000)
tot = pd.Series([], dtype='int64')
for piece in chunker:
tot = tot.add(piece["key"].value_counts(), fill_value=0)
tot = tot.sort_values(ascending=False)
然后我们有:
In [47]: tot[:10]
Out[47]:
key
E 368.0
X 364.0
L 346.0
O 343.0
Q 340.0
M 338.0
J 337.0
F 335.0
K 334.0
H 330.0
dtype: float64
TextFileReader还配备有一个get_chunk方法,使您能够以任意大小读取文件的片段。
将数据写入文本格式
数据也可以导出为分隔格式。让我们考虑之前读取的一个 CSV 文件:
In [48]: data = pd.read_csv("examples/ex5.csv")
In [49]: data
Out[49]:
something a b c d message
0 one 1 2 3.0 4 NaN
1 two 5 6 NaN 8 world
2 three 9 10 11.0 12 foo
使用 DataFrame 的 to_csv 方法,我们可以将数据写入逗号分隔的文件:
In [50]: data.to_csv("examples/out.csv")
In [51]: !cat examples/out.csv
,something,a,b,c,d,message
0,one,1,2,3.0,4,
1,two,5,6,,8,world
2,three,9,10,11.0,12,foo
当然也可以使用其他分隔符(写入到 sys.stdout 以便将文本结果打印到控制台而不是文件):
In [52]: import sys
In [53]: data.to_csv(sys.stdout, sep="|")
|something|a|b|c|d|message
0|one|1|2|3.0|4|
1|two|5|6||8|world
2|three|9|10|11.0|12|foo
缺失值在输出中显示为空字符串。您可能希望用其他标记值来表示它们:
In [54]: data.to_csv(sys.stdout, na_rep="NULL")
,something,a,b,c,d,message
0,one,1,2,3.0,4,NULL
1,two,5,6,NULL,8,world
2,three,9,10,11.0,12,foo
如果未指定其他选项,则将同时写入行标签和列标签。这两者都可以禁用:
In [55]: data.to_csv(sys.stdout, index=False, header=False)
one,1,2,3.0,4,
two,5,6,,8,world
three,9,10,11.0,12,foo
您还可以仅写入列的子集,并按您选择的顺序进行写入:
In [56]: data.to_csv(sys.stdout, index=False, columns=["a", "b", "c"])
a,b,c
1,2,3.0
5,6,
9,10,11.0
处理其他分隔格式
使用函数如 pandas.read_csv 可以从磁盘加载大多数形式的表格数据。然而,在某些情况下,可能需要一些手动处理。接收到一个或多个格式错误的行可能会导致 pandas.read_csv 出错。为了说明基本工具,考虑一个小的 CSV 文件:
In [57]: !cat examples/ex7.csv
"a","b","c"
"1","2","3"
"1","2","3"
对于任何具有单字符分隔符的文件,您可以使用 Python 的内置 csv 模块。要使用它,将任何打开的文件或类似文件的对象传递给 csv.reader:
In [58]: import csv
In [59]: f = open("examples/ex7.csv")
In [60]: reader = csv.reader(f)
像处理文件一样迭代读取器会产生去除任何引号字符的值列表:
In [61]: for line in reader:
....: print(line)
['a', 'b', 'c']
['1', '2', '3']
['1', '2', '3']
In [62]: f.close()
然后,您需要进行必要的整理以将数据放入所需的形式。让我们一步一步来。首先,我们将文件读取为行列表:
In [63]: with open("examples/ex7.csv") as f:
....: lines = list(csv.reader(f))
然后我们将行分割为标题行和数据行:
In [64]: header, values = lines[0], lines[1:]
然后我们可以使用字典推导和表达式 zip(*values) 创建数据列的字典(请注意,这将在大文件上使用大量内存),将行转置为列:
In [65]: data_dict = {h: v for h, v in zip(header, zip(*values))}
In [66]: data_dict
Out[66]: {'a': ('1', '1'), 'b': ('2', '2'), 'c': ('3', '3')}
CSV 文件有许多不同的风格。要定义一个具有不同分隔符、字符串引用约定或行终止符的新格式,我们可以定义一个简单的 csv.Dialect 的子类:
class my_dialect(csv.Dialect):
lineterminator = "\n"
delimiter = ";"
quotechar = '"'
quoting = csv.QUOTE_MINIMAL
reader = csv.reader(f, dialect=my_dialect)
我们还可以将单独的 CSV 方言参数作为关键字传递给 csv.reader,而无需定义子类:
reader = csv.reader(f, delimiter="|")
可能的选项(csv.Dialect 的属性)及其作用可以在 表 6.3 中找到。
表 6.3: CSV dialect 选项
| 参数 | 描述 |
|---|---|
delimiter |
用于分隔字段的单字符字符串;默认为 ","。 |
lineterminator |
用于写入的行终止符;默认为 "\r\n"。读取器会忽略这个并识别跨平台的行终止符。 |
quotechar |
用于具有特殊字符(如分隔符)的字段的引用字符;默认为 '"'。 |
quoting |
引用约定。选项包括 csv.QUOTE_ALL(引用所有字段)、csv.QUOTE_MINIMAL(只有包含特殊字符如分隔符的字段)、csv.QUOTE_NONNUMERIC 和 csv.QUOTE_NONE(不引用)。详细信息请参阅 Python 的文档。默认为 QUOTE_MINIMAL。 |
skipinitialspace |
忽略每个分隔符后的空格;默认为 False。 |
doublequote |
如何处理字段内的引用字符;如果为 True,则会加倍(请查看在线文档以获取完整的详细信息和行为)。 |
escapechar |
如果 quoting 设置为 csv.QUOTE_NONE,用于转义分隔符的字符串;默认情况下禁用。 |
注意
对于具有更复杂或固定多字符分隔符的文件,您将无法使用 csv 模块。在这些情况下,您将需要使用字符串的 split 方法或正则表达式方法 re.split 进行行分割和其他清理。幸运的是,如果传递必要的选项,pandas.read_csv 能够几乎做任何您需要的事情,因此您很少需要手动解析文件。
要 手动 写入分隔文件,可以使用 csv.writer。它接受一个打开的可写文件对象以及与 csv.reader 相同的方言和格式选项:
with open("mydata.csv", "w") as f:
writer = csv.writer(f, dialect=my_dialect)
writer.writerow(("one", "two", "three"))
writer.writerow(("1", "2", "3"))
writer.writerow(("4", "5", "6"))
writer.writerow(("7", "8", "9"))
JSON 数据
JSON(JavaScript 对象表示法的缩写)已经成为在 Web 浏览器和其他应用程序之间通过 HTTP 请求发送数据的标准格式之一。它是比 CSV 等表格文本形式更自由的数据格式。这里是一个例子:
obj = """
{"name": "Wes",
"cities_lived": ["Akron", "Nashville", "New York", "San Francisco"],
"pet": null,
"siblings": [{"name": "Scott", "age": 34, "hobbies": ["guitars", "soccer"]},
{"name": "Katie", "age": 42, "hobbies": ["diving", "art"]}]
}
"""
JSON 几乎是有效的 Python 代码,只是其空值null和一些其他细微差别(例如不允许在列表末尾使用逗号)。基本类型是对象(字典)、数组(列表)、字符串、数字、布尔值和空值。对象中的所有键都必须是字符串。有几个 Python 库可用于读取和写入 JSON 数据。我将在这里使用json,因为它内置在 Python 标准库中。要将 JSON 字符串转换为 Python 形式,请使用json.loads:
In [68]: import json
In [69]: result = json.loads(obj)
In [70]: result
Out[70]:
{'name': 'Wes',
'cities_lived': ['Akron', 'Nashville', 'New York', 'San Francisco'],
'pet': None,
'siblings': [{'name': 'Scott',
'age': 34,
'hobbies': ['guitars', 'soccer']},
{'name': 'Katie', 'age': 42, 'hobbies': ['diving', 'art']}]}
json.dumps,另一方面,将 Python 对象转换回 JSON:
In [71]: asjson = json.dumps(result)
In [72]: asjson
Out[72]: '{"name": "Wes", "cities_lived": ["Akron", "Nashville", "New York", "San
Francisco"], "pet": null, "siblings": [{"name": "Scott", "age": 34, "hobbies": [
"guitars", "soccer"]}, {"name": "Katie", "age": 42, "hobbies": ["diving", "art"]}
]}'
如何将 JSON 对象或对象列表转换为 DataFrame 或其他数据结构以进行分析将取决于您。方便的是,您可以将字典列表(先前是 JSON 对象)传递给 DataFrame 构造函数并选择数据字段的子集:
In [73]: siblings = pd.DataFrame(result["siblings"], columns=["name", "age"])
In [74]: siblings
Out[74]:
name age
0 Scott 34
1 Katie 42
pandas.read_json可以自动将特定排列的 JSON 数据集转换为 Series 或 DataFrame。例如:
In [75]: !cat examples/example.json
[{"a": 1, "b": 2, "c": 3},
{"a": 4, "b": 5, "c": 6},
{"a": 7, "b": 8, "c": 9}]
pandas.read_json的默认选项假定 JSON 数组中的每个对象是表中的一行:
In [76]: data = pd.read_json("examples/example.json")
In [77]: data
Out[77]:
a b c
0 1 2 3
1 4 5 6
2 7 8 9
有关阅读和操作 JSON 数据的扩展示例(包括嵌套记录),请参见第十三章:数据分析示例中的美国农业部食品数据库示例。
如果您需要将数据从 pandas 导出为 JSON,一种方法是在 Series 和 DataFrame 上使用to_json方法:
In [78]: data.to_json(sys.stdout)
{"a":{"0":1,"1":4,"2":7},"b":{"0":2,"1":5,"2":8},"c":{"0":3,"1":6,"2":9}}
In [79]: data.to_json(sys.stdout, orient="records")
[{"a":1,"b":2,"c":3},{"a":4,"b":5,"c":6},{"a":7,"b":8,"c":9}]
XML 和 HTML:网络抓取
Python 有许多用于读取和写入 HTML 和 XML 格式数据的库。示例包括 lxml、Beautiful Soup 和 html5lib。虽然 lxml 通常在一般情况下更快,但其他库可以更好地处理格式不正确的 HTML 或 XML 文件。
pandas 有一个内置函数pandas.read_html,它使用所有这些库自动将 HTML 文件中的表格解析为 DataFrame 对象。为了展示这是如何工作的,我下载了一个 HTML 文件(在 pandas 文档中使用)从美国联邦存款保险公司显示银行倒闭。¹首先,您必须安装一些read_html使用的附加库:
conda install lxml beautifulsoup4 html5lib
如果您没有使用 conda,pip install lxml也应该可以工作。
pandas.read_html函数有许多选项,但默认情况下它会搜索并尝试解析包含在<table>标签中的所有表格数据。结果是一个 DataFrame 对象的列表:
In [80]: tables = pd.read_html("examples/fdic_failed_bank_list.html")
In [81]: len(tables)
Out[81]: 1
In [82]: failures = tables[0]
In [83]: failures.head()
Out[83]:
Bank Name City ST CERT
0 Allied Bank Mulberry AR 91 \
1 The Woodbury Banking Company Woodbury GA 11297
2 First CornerStone Bank King of Prussia PA 35312
3 Trust Company Bank Memphis TN 9956
4 North Milwaukee State Bank Milwaukee WI 20364
Acquiring Institution Closing Date Updated Date
0 Today's Bank September 23, 2016 November 17, 2016
1 United Bank August 19, 2016 November 17, 2016
2 First-Citizens Bank & Trust Company May 6, 2016 September 6, 2016
3 The Bank of Fayette County April 29, 2016 September 6, 2016
4 First-Citizens Bank & Trust Company March 11, 2016 June 16, 2016
由于failures有许多列,pandas 会插入一个换行符\。
正如您将在后面的章节中了解到的那样,从这里我们可以继续进行一些数据清理和分析,比如计算每年的银行倒闭次数:
In [84]: close_timestamps = pd.to_datetime(failures["Closing Date"])
In [85]: close_timestamps.dt.year.value_counts()
Out[85]:
Closing Date
2010 157
2009 140
2011 92
2012 51
2008 25
...
2004 4
2001 4
2007 3
2003 3
2000 2
Name: count, Length: 15, dtype: int64
使用lxml.objectify解析 XML
XML 是另一种常见的结构化数据格式,支持具有元数据的分层嵌套数据。您当前正在阅读的书实际上是从一系列大型 XML 文档创建的。
之前,我展示了pandas.read_html函数,它在底层使用 lxml 或 Beautiful Soup 来解析 HTML 中的数据。XML 和 HTML 在结构上相似,但 XML 更通用。在这里,我将展示如何使用 lxml 来解析更一般的 XML 格式中的数据的示例。
多年来,纽约大都会交通管理局(MTA)以 XML 格式发布了许多关于其公交车和火车服务的数据系列。在这里,我们将查看性能数据,这些数据包含在一组 XML 文件中。每个火车或公交车服务都有一个不同的文件(例如Performance_MNR.xml用于 Metro-North Railroad),其中包含作为一系列 XML 记录的月度数据,看起来像这样:
<INDICATOR>
<INDICATOR_SEQ>373889</INDICATOR_SEQ>
<PARENT_SEQ></PARENT_SEQ>
<AGENCY_NAME>Metro-North Railroad</AGENCY_NAME>
<INDICATOR_NAME>Escalator Availability</INDICATOR_NAME>
<DESCRIPTION>Percent of the time that escalators are operational
systemwide. The availability rate is based on physical observations performed
the morning of regular business days only. This is a new indicator the agency
began reporting in 2009.</DESCRIPTION>
<PERIOD_YEAR>2011</PERIOD_YEAR>
<PERIOD_MONTH>12</PERIOD_MONTH>
<CATEGORY>Service Indicators</CATEGORY>
<FREQUENCY>M</FREQUENCY>
<DESIRED_CHANGE>U</DESIRED_CHANGE>
<INDICATOR_UNIT>%</INDICATOR_UNIT>
<DECIMAL_PLACES>1</DECIMAL_PLACES>
<YTD_TARGET>97.00</YTD_TARGET>
<YTD_ACTUAL></YTD_ACTUAL>
<MONTHLY_TARGET>97.00</MONTHLY_TARGET>
<MONTHLY_ACTUAL></MONTHLY_ACTUAL>
</INDICATOR>
使用lxml.objectify,我们解析文件并获取 XML 文件的根节点的引用:
In [86]: from lxml import objectify
In [87]: path = "datasets/mta_perf/Performance_MNR.xml"
In [88]: with open(path) as f:
....: parsed = objectify.parse(f)
In [89]: root = parsed.getroot()
root.INDICATOR返回一个生成器,产生每个<INDICATOR> XML 元素。对于每条记录,我们可以通过运行以下代码填充一个标签名称(如YTD_ACTUAL)到数据值(排除一些标签)的字典:
data = []
skip_fields = ["PARENT_SEQ", "INDICATOR_SEQ",
"DESIRED_CHANGE", "DECIMAL_PLACES"]
for elt in root.INDICATOR:
el_data = {}
for child in elt.getchildren():
if child.tag in skip_fields:
continue
el_data[child.tag] = child.pyval
data.append(el_data)
最后,将这个字典列表转换为 DataFrame:
In [91]: perf = pd.DataFrame(data)
In [92]: perf.head()
Out[92]:
AGENCY_NAME INDICATOR_NAME
0 Metro-North Railroad On-Time Performance (West of Hudson) \
1 Metro-North Railroad On-Time Performance (West of Hudson)
2 Metro-North Railroad On-Time Performance (West of Hudson)
3 Metro-North Railroad On-Time Performance (West of Hudson)
4 Metro-North Railroad On-Time Performance (West of Hudson)
DESCRIPTION
0 Percent of commuter trains that arrive at their destinations within 5 m... \
1 Percent of commuter trains that arrive at their destinations within 5 m...
2 Percent of commuter trains that arrive at their destinations within 5 m...
3 Percent of commuter trains that arrive at their destinations within 5 m...
4 Percent of commuter trains that arrive at their destinations within 5 m...
PERIOD_YEAR PERIOD_MONTH CATEGORY FREQUENCY INDICATOR_UNIT
0 2008 1 Service Indicators M % \
1 2008 2 Service Indicators M %
2 2008 3 Service Indicators M %
3 2008 4 Service Indicators M %
4 2008 5 Service Indicators M %
YTD_TARGET YTD_ACTUAL MONTHLY_TARGET MONTHLY_ACTUAL
0 95.0 96.9 95.0 96.9
1 95.0 96.0 95.0 95.0
2 95.0 96.3 95.0 96.9
3 95.0 96.8 95.0 98.3
4 95.0 96.6 95.0 95.8
pandas 的pandas.read_xml函数将此过程转换为一行表达式:
In [93]: perf2 = pd.read_xml(path)
In [94]: perf2.head()
Out[94]:
INDICATOR_SEQ PARENT_SEQ AGENCY_NAME
0 28445 NaN Metro-North Railroad \
1 28445 NaN Metro-North Railroad
2 28445 NaN Metro-North Railroad
3 28445 NaN Metro-North Railroad
4 28445 NaN Metro-North Railroad
INDICATOR_NAME
0 On-Time Performance (West of Hudson) \
1 On-Time Performance (West of Hudson)
2 On-Time Performance (West of Hudson)
3 On-Time Performance (West of Hudson)
4 On-Time Performance (West of Hudson)
DESCRIPTION
0 Percent of commuter trains that arrive at their destinations within 5 m... \
1 Percent of commuter trains that arrive at their destinations within 5 m...
2 Percent of commuter trains that arrive at their destinations within 5 m...
3 Percent of commuter trains that arrive at their destinations within 5 m...
4 Percent of commuter trains that arrive at their destinations within 5 m...
PERIOD_YEAR PERIOD_MONTH CATEGORY FREQUENCY DESIRED_CHANGE
0 2008 1 Service Indicators M U \
1 2008 2 Service Indicators M U
2 2008 3 Service Indicators M U
3 2008 4 Service Indicators M U
4 2008 5 Service Indicators M U
INDICATOR_UNIT DECIMAL_PLACES YTD_TARGET YTD_ACTUAL MONTHLY_TARGET
0 % 1 95.00 96.90 95.00 \
1 % 1 95.00 96.00 95.00
2 % 1 95.00 96.30 95.00
3 % 1 95.00 96.80 95.00
4 % 1 95.00 96.60 95.00
MONTHLY_ACTUAL
0 96.90
1 95.00
2 96.90
3 98.30
4 95.80
对于更复杂的 XML 文档,请参考pandas.read_xml的文档字符串,其中描述了如何进行选择和过滤以提取感兴趣的特定表格。
6.2 二进制数据格式
以二进制格式存储(或序列化)数据的一种简单方法是使用 Python 的内置pickle模块。所有 pandas 对象都有一个to_pickle方法,它以 pickle 格式将数据写入磁盘:
In [95]: frame = pd.read_csv("examples/ex1.csv")
In [96]: frame
Out[96]:
a b c d message
0 1 2 3 4 hello
1 5 6 7 8 world
2 9 10 11 12 foo
In [97]: frame.to_pickle("examples/frame_pickle")
Pickle 文件通常只能在 Python 中读取。您可以直接使用内置的pickle读取存储在文件中的任何“pickled”对象,或者更方便地使用pandas.read_pickle:
In [98]: pd.read_pickle("examples/frame_pickle")
Out[98]:
a b c d message
0 1 2 3 4 hello
1 5 6 7 8 world
2 9 10 11 12 foo
注意
pickle仅建议作为短期存储格式。问题在于很难保证格式随时间稳定;今天使用 pickle 的对象可能无法在以后的库版本中解除 pickle。pandas 在可能的情况下尽力保持向后兼容性,但在将来的某个时候可能需要“破坏”pickle 格式。
pandas 内置支持其他几种开源二进制数据格式,例如 HDF5、ORC 和 Apache Parquet。例如,如果安装pyarrow包(conda install pyarrow),则可以使用pandas.read_parquet读取 Parquet 文件:
In [100]: fec = pd.read_parquet('datasets/fec/fec.parquet')
我将在 HDF5 格式使用中给出一些 HDF5 示例。我鼓励您探索不同的文件格式,看看它们的速度和对您的分析工作的适用性。
读取 Microsoft Excel 文件
pandas 还支持使用pandas.ExcelFile类或pandas.read_excel函数读取存储在 Excel 2003(及更高版本)文件中的表格数据。在内部,这些工具使用附加包xlrd和openpyxl来分别读取旧式 XLS 和新式 XLSX 文件。这些必须使用 pip 或 conda 单独安装,而不是从 pandas 安装:
conda install openpyxl xlrd
要使用pandas.ExcelFile,请通过传递路径到xls或xlsx文件来创建一个实例:
In [101]: xlsx = pd.ExcelFile("examples/ex1.xlsx")
此对象可以显示文件中可用工作表名称的列表:
In [102]: xlsx.sheet_names
Out[102]: ['Sheet1']
可以使用parse将工作表中存储的数据读入 DataFrame:
In [103]: xlsx.parse(sheet_name="Sheet1")
Out[103]:
Unnamed: 0 a b c d message
0 0 1 2 3 4 hello
1 1 5 6 7 8 world
2 2 9 10 11 12 foo
此 Excel 表具有索引列,因此我们可以使用index_col参数指示:
In [104]: xlsx.parse(sheet_name="Sheet1", index_col=0)
Out[104]:
a b c d message
0 1 2 3 4 hello
1 5 6 7 8 world
2 9 10 11 12 foo
如果要在一个文件中读取多个工作表,则创建pandas.ExcelFile会更快,但您也可以简单地将文件名传递给pandas.read_excel:
In [105]: frame = pd.read_excel("examples/ex1.xlsx", sheet_name="Sheet1")
In [106]: frame
Out[106]:
Unnamed: 0 a b c d message
0 0 1 2 3 4 hello
1 1 5 6 7 8 world
2 2 9 10 11 12 foo
要将 pandas 数据写入 Excel 格式,必须首先创建一个ExcelWriter,然后使用 pandas 对象的to_excel方法将数据写入其中:
In [107]: writer = pd.ExcelWriter("examples/ex2.xlsx")
In [108]: frame.to_excel(writer, "Sheet1")
In [109]: writer.close()
您还可以将文件路径传递给to_excel,避免使用ExcelWriter:
In [110]: frame.to_excel("examples/ex2.xlsx")
使用 HDF5 格式
HDF5 是一种受尊敬的文件格式,用于存储大量科学数组数据。它作为一个 C 库可用,并且在许多其他语言中都有接口,包括 Java、Julia、MATLAB 和 Python。HDF5 中的“HDF”代表分层数据格式。每个 HDF5 文件可以存储多个数据集和支持的元数据。与更简单的格式相比,HDF5 支持各种压缩模式的即时压缩,使具有重复模式的数据能够更有效地存储。HDF5 可以是处理不适合内存的数据集的良好选择,因为您可以有效地读取和写入更大数组的小部分。
要开始使用 HDF5 和 pandas,您必须首先通过使用 conda 安装tables包来安装 PyTables:
conda install pytables
注意
请注意,PyTables 包在 PyPI 中称为“tables”,因此如果您使用 pip 安装,您将需要运行pip install tables。
虽然可以直接使用 PyTables 或 h5py 库访问 HDF5 文件,但 pandas 提供了一个简化存储 Series 和 DataFrame 对象的高级接口。HDFStore类的工作方式类似于字典,并处理底层细节:
In [113]: frame = pd.DataFrame({"a": np.random.standard_normal(100)})
In [114]: store = pd.HDFStore("examples/mydata.h5")
In [115]: store["obj1"] = frame
In [116]: store["obj1_col"] = frame["a"]
In [117]: store
Out[117]:
<class 'pandas.io.pytables.HDFStore'>
File path: examples/mydata.h5
然后可以使用相同类似字典的 API 检索 HDF5 文件中包含的对象:
In [118]: store["obj1"]
Out[118]:
a
0 -0.204708
1 0.478943
2 -0.519439
3 -0.555730
4 1.965781
.. ...
95 0.795253
96 0.118110
97 -0.748532
98 0.584970
99 0.152677
[100 rows x 1 columns]
HDFStore支持两种存储模式,"fixed"和"table"(默认为"fixed")。后者通常较慢,但支持使用特殊语法进行查询操作:
In [119]: store.put("obj2", frame, format="table")
In [120]: store.select("obj2", where=["index >= 10 and index <= 15"])
Out[120]:
a
10 1.007189
11 -1.296221
12 0.274992
13 0.228913
14 1.352917
15 0.886429
In [121]: store.close()
put是store["obj2"] = frame方法的显式版本,但允许我们设置其他选项,如存储格式。
pandas.read_hdf函数为您提供了这些工具的快捷方式:
In [122]: frame.to_hdf("examples/mydata.h5", "obj3", format="table")
In [123]: pd.read_hdf("examples/mydata.h5", "obj3", where=["index < 5"])
Out[123]:
a
0 -0.204708
1 0.478943
2 -0.519439
3 -0.555730
4 1.965781
如果您愿意,可以删除您创建的 HDF5 文件,方法如下:
In [124]: import os
In [125]: os.remove("examples/mydata.h5")
注意
如果您正在处理存储在远程服务器上的数据,如 Amazon S3 或 HDFS,使用设计用于分布式存储的不同二进制格式(如Apache Parquet)可能更合适。
如果您在本地处理大量数据,我建议您探索 PyTables 和 h5py,看看它们如何满足您的需求。由于许多数据分析问题受 I/O 限制(而不是 CPU 限制),使用 HDF5 等工具可以大大加速您的应用程序。
注意
HDF5 不是数据库。它最适合于一次写入,多次读取的数据集。虽然数据可以随时添加到文件中,但如果多个写入者同时这样做,文件可能会损坏。
6.3 与 Web API 交互
许多网站都有提供数据源的公共 API,可以通过 JSON 或其他格式提供数据。有许多方法可以从 Python 访问这些 API;我推荐的一种方法是requests包,可以使用 pip 或 conda 进行安装:
conda install requests
要在 GitHub 上找到 pandas 的最近 30 个问题,我们可以使用附加的requests库进行GET HTTP 请求:
In [126]: import requests
In [127]: url = "https://api.github.com/repos/pandas-dev/pandas/issues"
In [128]: resp = requests.get(url)
In [129]: resp.raise_for_status()
In [130]: resp
Out[130]: <Response [200]>
在使用requests.get后,始终调用raise_for_status以检查 HTTP 错误是一个好习惯。
响应对象的json方法将返回一个包含解析后的 JSON 数据的 Python 对象,作为字典或列表(取决于返回的 JSON 是什么):
In [131]: data = resp.json()
In [132]: data[0]["title"]
Out[132]: 'BUG: DataFrame.pivot mutates empty index.name attribute with typing._L
iteralGenericAlias'
由于检索到的结果基于实时数据,当您运行此代码时,您看到的结果几乎肯定会有所不同。
data中的每个元素都是一个包含 GitHub 问题页面上找到的所有数据的字典(评论除外)。我们可以直接将data传递给pandas.DataFrame并提取感兴趣的字段:
In [133]: issues = pd.DataFrame(data, columns=["number", "title",
.....: "labels", "state"])
In [134]: issues
Out[134]:
number
0 52629 \
1 52628
2 52626
3 52625
4 52624
.. ...
25 52579
26 52577
27 52576
28 52571
29 52570
title
0 BUG: DataFrame.pivot mutates empty index.name attribute with typing._Li... \
1 DEPR: unused keywords in DTI/TDI construtors
2 ENH: Infer best datetime format from a random sample
3 BUG: ArrowExtensionArray logical_op not working in all directions
4 ENH: pandas.core.groupby.SeriesGroupBy.apply allow raw argument
.. ...
25 BUG: Axial inconsistency of pandas.diff
26 BUG: describe not respecting ArrowDtype in include/exclude
27 BUG: describe does not distinguish between Int64 and int64
28 BUG: `pandas.DataFrame.replace` silently fails to replace category type...
29 BUG: DataFrame.describe include/exclude do not work for arrow datatypes
labels
0 [{'id': 76811, 'node_id': 'MDU6TGFiZWw3NjgxMQ==', 'url': 'https://api.g... \
1 []
2 []
3 [{'id': 76811, 'node_id': 'MDU6TGFiZWw3NjgxMQ==', 'url': 'https://api.g...
4 [{'id': 76812, 'node_id': 'MDU6TGFiZWw3NjgxMg==', 'url': 'https://api.g...
.. ...
25 [{'id': 76811, 'node_id': 'MDU6TGFiZWw3NjgxMQ==', 'url': 'https://api.g...
26 [{'id': 3303158446, 'node_id': 'MDU6TGFiZWwzMzAzMTU4NDQ2', 'url': 'http...
27 [{'id': 76811, 'node_id': 'MDU6TGFiZWw3NjgxMQ==', 'url': 'https://api.g...
28 [{'id': 76811, 'node_id': 'MDU6TGFiZWw3NjgxMQ==', 'url': 'https://api.g...
29 [{'id': 76811, 'node_id': 'MDU6TGFiZWw3NjgxMQ==', 'url': 'https://api.g...
state
0 open
1 open
2 open
3 open
4 open
.. ...
25 open
26 open
27 open
28 open
29 open
[30 rows x 4 columns]
通过一些努力,您可以创建一些更高级的接口,用于常见的 Web API,返回 DataFrame 对象以便进行更方便的分析。
6.4 与数据库交互
在商业环境中,许多数据可能不存储在文本或 Excel 文件中。基于 SQL 的关系数据库(如 SQL Server、PostgreSQL 和 MySQL)被广泛使用,许多替代数据库也变得非常流行。数据库的选择通常取决于应用程序的性能、数据完整性和可扩展性需求。
pandas 有一些函数可以简化将 SQL 查询结果加载到 DataFrame 中。例如,我将使用 Python 内置的sqlite3驱动程序创建一个 SQLite3 数据库:
In [135]: import sqlite3
In [136]: query = """
.....: CREATE TABLE test
.....: (a VARCHAR(20), b VARCHAR(20),
.....: c REAL, d INTEGER
.....: );"""
In [137]: con = sqlite3.connect("mydata.sqlite")
In [138]: con.execute(query)
Out[138]: <sqlite3.Cursor at 0x188e40ac0>
In [139]: con.commit()
然后,插入一些数据行:
In [140]: data = [("Atlanta", "Georgia", 1.25, 6),
.....: ("Tallahassee", "Florida", 2.6, 3),
.....: ("Sacramento", "California", 1.7, 5)]
In [141]: stmt = "INSERT INTO test VALUES(?, ?, ?, ?)"
In [142]: con.executemany(stmt, data)
Out[142]: <sqlite3.Cursor at 0x188ed02c0>
In [143]: con.commit()
大多数 Python SQL 驱动程序在从表中选择数据时返回一个元组列表:
In [144]: cursor = con.execute("SELECT * FROM test")
In [145]: rows = cursor.fetchall()
In [146]: rows
Out[146]:
[('Atlanta', 'Georgia', 1.25, 6),
('Tallahassee', 'Florida', 2.6, 3),
('Sacramento', 'California', 1.7, 5)]
您可以将元组列表传递给 DataFrame 构造函数,但还需要列名,这些列名包含在游标的description属性中。请注意,对于 SQLite3,游标的description仅提供列名(其他字段,这些字段是 Python 的数据库 API 规范的一部分,为None),但对于其他一些数据库驱动程序,提供了更多的列信息:
In [147]: cursor.description
Out[147]:
(('a', None, None, None, None, None, None),
('b', None, None, None, None, None, None),
('c', None, None, None, None, None, None),
('d', None, None, None, None, None, None))
In [148]: pd.DataFrame(rows, columns=[x[0] for x in cursor.description])
Out[148]:
a b c d
0 Atlanta Georgia 1.25 6
1 Tallahassee Florida 2.60 3
2 Sacramento California 1.70 5
这是一种相当复杂的操作,您不希望每次查询数据库时都重复。SQLAlchemy 项目是一个流行的 Python SQL 工具包,它抽象了 SQL 数据库之间的许多常见差异。pandas 有一个read_sql函数,可以让您轻松地从通用的 SQLAlchemy 连接中读取数据。您可以像这样使用 conda 安装 SQLAlchemy:
conda install sqlalchemy
现在,我们将使用 SQLAlchemy 连接到相同的 SQLite 数据库,并从之前创建的表中读取数据:
In [149]: import sqlalchemy as sqla
In [150]: db = sqla.create_engine("sqlite:///mydata.sqlite")
In [151]: pd.read_sql("SELECT * FROM test", db)
Out[151]:
a b c d
0 Atlanta Georgia 1.25 6
1 Tallahassee Florida 2.60 3
2 Sacramento California 1.70 5
6.5 结论
获取数据通常是数据分析过程中的第一步。在本章中,我们已经介绍了一些有用的工具,这些工具应该可以帮助您入门。在接下来的章节中,我们将深入探讨数据整理、数据可视化、时间序列分析等主题。
七、数据清理和准备
原文:
wesmckinney.com/book/data-cleaning译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
在进行数据分析和建模过程中,大量时间花费在数据准备上:加载、清理、转换和重新排列。这些任务通常被报告为占据分析师 80%或更多的时间。有时,文件或数据库中存储数据的方式并不适合特定任务。许多研究人员选择使用通用编程语言(如 Python、Perl、R 或 Java)或 Unix 文本处理工具(如 sed 或 awk)对数据进行自发处理,从一种形式转换为另一种形式。幸运的是,pandas 与内置的 Python 语言功能一起,为您提供了一套高级、灵活和快速的工具,使您能够将数据转换为正确的形式。
如果您发现在本书或 pandas 库中找不到的数据操作类型,请随时在 Python 邮件列表或 pandas GitHub 网站上分享您的用例。事实上,pandas 的设计和实现很大程度上是由真实应用程序的需求驱动的。
在本章中,我讨论了有关缺失数据、重复数据、字符串操作和其他一些分析数据转换的工具。在下一章中,我将专注于以各种方式组合和重新排列数据集。
7.1 处理缺失数据
缺失数据在许多数据分析应用中很常见。pandas 的目标之一是尽可能地使处理缺失数据变得轻松。例如,默认情况下,pandas 对象上的所有描述性统计都排除缺失数据。
pandas 对象中表示缺失数据的方式有些不完美,但对于大多数真实世界的用途来说是足够的。对于float64数据类型,pandas 使用浮点值NaN(Not a Number)表示缺失数据。
我们称之为标记值:当存在时,表示缺失(或空)值:
In [14]: float_data = pd.Series([1.2, -3.5, np.nan, 0])
In [15]: float_data
Out[15]:
0 1.2
1 -3.5
2 NaN
3 0.0
dtype: float64
isna方法为我们提供一个布尔 Series,其中值为空时为True:
In [16]: float_data.isna()
Out[16]:
0 False
1 False
2 True
3 False
dtype: bool
在 pandas 中,我们采用了 R 编程语言中使用的惯例,将缺失数据称为 NA,代表不可用。在统计应用中,NA 数据可能是不存在的数据,也可能是存在但未被观察到的数据(例如通过数据收集问题)。在清理数据进行分析时,通常重要的是对缺失数据本身进行分析,以识别数据收集问题或由缺失数据引起的数据潜在偏差。
内置的 Python None值也被视为 NA:
In [17]: string_data = pd.Series(["aardvark", np.nan, None, "avocado"])
In [18]: string_data
Out[18]:
0 aardvark
1 NaN
2 None
3 avocado
dtype: object
In [19]: string_data.isna()
Out[19]:
0 False
1 True
2 True
3 False
dtype: bool
In [20]: float_data = pd.Series([1, 2, None], dtype='float64')
In [21]: float_data
Out[21]:
0 1.0
1 2.0
2 NaN
dtype: float64
In [22]: float_data.isna()
Out[22]:
0 False
1 False
2 True
dtype: bool
pandas 项目已经尝试使处理缺失数据在不同数据类型之间保持一致。像pandas.isna这样的函数抽象了许多烦人的细节。请参阅表 7.1 以获取与处理缺失数据相关的一些函数列表。
表 7.1:NA 处理对象方法
| 方法 | 描述 |
|---|---|
dropna |
根据每个标签的值是否具有缺失数据来过滤轴标签,对于可以容忍多少缺失数据有不同的阈值。 |
fillna |
使用某个值或插值方法(如 "ffill" 或 "bfill")填充缺失数据。 |
isna |
返回指示哪些值缺失/NA 的布尔值。 |
notna |
isna 的否定,对于非 NA 值返回 True,对于 NA 值返回 False。 |
过滤缺失数据
有几种过滤缺失数据的方法。虽然您始终可以选择使用 pandas.isna 和布尔索引手动执行,但 dropna 可能会有所帮助。对于 Series,它返回仅具有非空数据和索引值的 Series:
In [23]: data = pd.Series([1, np.nan, 3.5, np.nan, 7])
In [24]: data.dropna()
Out[24]:
0 1.0
2 3.5
4 7.0
dtype: float64
这与执行以下操作相同:
In [25]: data[data.notna()]
Out[25]:
0 1.0
2 3.5
4 7.0
dtype: float64
对于 DataFrame 对象,有不同的方法可以删除缺失数据。您可能希望删除所有 NA 的行或列,或者仅删除包含任何 NA 的行或列。dropna 默认情况下会删除包含缺失值的任何行:
In [26]: data = pd.DataFrame([[1., 6.5, 3.], [1., np.nan, np.nan],
....: [np.nan, np.nan, np.nan], [np.nan, 6.5, 3.]])
In [27]: data
Out[27]:
0 1 2
0 1.0 6.5 3.0
1 1.0 NaN NaN
2 NaN NaN NaN
3 NaN 6.5 3.0
In [28]: data.dropna()
Out[28]:
0 1 2
0 1.0 6.5 3.0
传递 how="all" 将仅删除所有 NA 的行:
In [29]: data.dropna(how="all")
Out[29]:
0 1 2
0 1.0 6.5 3.0
1 1.0 NaN NaN
3 NaN 6.5 3.0
请记住,这些函数默认情况下返回新对象,不会修改原始对象的内容。
要以相同方式删除列,请传递 axis="columns":
In [30]: data[4] = np.nan
In [31]: data
Out[31]:
0 1 2 4
0 1.0 6.5 3.0 NaN
1 1.0 NaN NaN NaN
2 NaN NaN NaN NaN
3 NaN 6.5 3.0 NaN
In [32]: data.dropna(axis="columns", how="all")
Out[32]:
0 1 2
0 1.0 6.5 3.0
1 1.0 NaN NaN
2 NaN NaN NaN
3 NaN 6.5 3.0
假设您只想保留包含至多一定数量缺失观察的行。您可以使用 thresh 参数指示这一点:
In [33]: df = pd.DataFrame(np.random.standard_normal((7, 3)))
In [34]: df.iloc[:4, 1] = np.nan
In [35]: df.iloc[:2, 2] = np.nan
In [36]: df
Out[36]:
0 1 2
0 -0.204708 NaN NaN
1 -0.555730 NaN NaN
2 0.092908 NaN 0.769023
3 1.246435 NaN -1.296221
4 0.274992 0.228913 1.352917
5 0.886429 -2.001637 -0.371843
6 1.669025 -0.438570 -0.539741
In [37]: df.dropna()
Out[37]:
0 1 2
4 0.274992 0.228913 1.352917
5 0.886429 -2.001637 -0.371843
6 1.669025 -0.438570 -0.539741
In [38]: df.dropna(thresh=2)
Out[38]:
0 1 2
2 0.092908 NaN 0.769023
3 1.246435 NaN -1.296221
4 0.274992 0.228913 1.352917
5 0.886429 -2.001637 -0.371843
6 1.669025 -0.438570 -0.539741
填充缺失数据
与过滤缺失数据(并可能连同其他数据一起丢弃)不同,您可能希望以任意方式填补任意数量的“空洞”。对于大多数情况,fillna 方法是要使用的主要函数。通过使用常量调用 fillna 可以用该值替换缺失值:
In [39]: df.fillna(0)
Out[39]:
0 1 2
0 -0.204708 0.000000 0.000000
1 -0.555730 0.000000 0.000000
2 0.092908 0.000000 0.769023
3 1.246435 0.000000 -1.296221
4 0.274992 0.228913 1.352917
5 0.886429 -2.001637 -0.371843
6 1.669025 -0.438570 -0.539741
通过字典调用 fillna,您可以为每列使用不同的填充值:
In [40]: df.fillna({1: 0.5, 2: 0})
Out[40]:
0 1 2
0 -0.204708 0.500000 0.000000
1 -0.555730 0.500000 0.000000
2 0.092908 0.500000 0.769023
3 1.246435 0.500000 -1.296221
4 0.274992 0.228913 1.352917
5 0.886429 -2.001637 -0.371843
6 1.669025 -0.438570 -0.539741
可用于重新索引的相同插值方法(请参见 表 5.3)也可用于 fillna:
In [41]: df = pd.DataFrame(np.random.standard_normal((6, 3)))
In [42]: df.iloc[2:, 1] = np.nan
In [43]: df.iloc[4:, 2] = np.nan
In [44]: df
Out[44]:
0 1 2
0 0.476985 3.248944 -1.021228
1 -0.577087 0.124121 0.302614
2 0.523772 NaN 1.343810
3 -0.713544 NaN -2.370232
4 -1.860761 NaN NaN
5 -1.265934 NaN NaN
In [45]: df.fillna(method="ffill")
Out[45]:
0 1 2
0 0.476985 3.248944 -1.021228
1 -0.577087 0.124121 0.302614
2 0.523772 0.124121 1.343810
3 -0.713544 0.124121 -2.370232
4 -1.860761 0.124121 -2.370232
5 -1.265934 0.124121 -2.370232
In [46]: df.fillna(method="ffill", limit=2)
Out[46]:
0 1 2
0 0.476985 3.248944 -1.021228
1 -0.577087 0.124121 0.302614
2 0.523772 0.124121 1.343810
3 -0.713544 0.124121 -2.370232
4 -1.860761 NaN -2.370232
5 -1.265934 NaN -2.370232
使用 fillna,您可以做很多其他事情,比如使用中位数或平均统计数据进行简单的数据填充:
In [47]: data = pd.Series([1., np.nan, 3.5, np.nan, 7])
In [48]: data.fillna(data.mean())
Out[48]:
0 1.000000
1 3.833333
2 3.500000
3 3.833333
4 7.000000
dtype: float64
请参见 表 7.2 了解 fillna 函数参数的参考。
表 7.2:fillna 函数参数
| 参数 | 描述 |
|---|---|
value |
用于填充缺失值的标量值或类似字典的对象 |
method |
插值方法:可以是 "bfill"(向后填充)或 "ffill"(向前填充)之一;默认为 None |
axis |
填充的轴("index" 或 "columns");默认为 axis="index" |
limit |
对于向前和向后填充,最大连续填充周期数 |
7.2 数据转换
到目前为止,在本章中,我们一直关注处理缺失数据。过滤、清理和其他转换是另一类重要操作。
删除重复项
DataFrame 中可能会出现重复行,原因有很多。这里是一个例子:
In [49]: data = pd.DataFrame({"k1": ["one", "two"] * 3 + ["two"],
....: "k2": [1, 1, 2, 3, 3, 4, 4]})
In [50]: data
Out[50]:
k1 k2
0 one 1
1 two 1
2 one 2
3 two 3
4 one 3
5 two 4
6 two 4
DataFrame 方法 duplicated 返回一个布尔 Series,指示每行是否为重复行(其列值与较早行中的值完全相等):
In [51]: data.duplicated()
Out[51]:
0 False
1 False
2 False
3 False
4 False
5 False
6 True
dtype: bool
相关地,drop_duplicates 返回一个 DataFrame,其中过滤掉 duplicated 数组为 False 的行:
In [52]: data.drop_duplicates()
Out[52]:
k1 k2
0 one 1
1 two 1
2 one 2
3 two 3
4 one 3
5 two 4
默认情况下,这两种方法都考虑所有列;或者,您可以指定任何子集来检测重复项。假设我们有一个额外的值列,并且只想基于 "k1" 列过滤重复项:
In [53]: data["v1"] = range(7)
In [54]: data
Out[54]:
k1 k2 v1
0 one 1 0
1 two 1 1
2 one 2 2
3 two 3 3
4 one 3 4
5 two 4 5
6 two 4 6
In [55]: data.drop_duplicates(subset=["k1"])
Out[55]:
k1 k2 v1
0 one 1 0
1 two 1 1
duplicated 和 drop_duplicates 默认保留第一个观察到的值组合。传递 keep="last" 将返回最后一个:
In [56]: data.drop_duplicates(["k1", "k2"], keep="last")
Out[56]:
k1 k2 v1
0 one 1 0
1 two 1 1
2 one 2 2
3 two 3 3
4 one 3 4
6 two 4 6
使用函数或映射转换数据
对于许多数据集,您可能希望根据数组、Series 或 DataFrame 中的值执行一些基于值的转换。考虑收集的关于各种肉类的假设数据:
In [57]: data = pd.DataFrame({"food": ["bacon", "pulled pork", "bacon",
....: "pastrami", "corned beef", "bacon",
....: "pastrami", "honey ham", "nova lox"],
....: "ounces": [4, 3, 12, 6, 7.5, 8, 3, 5, 6]})
In [58]: data
Out[58]:
food ounces
0 bacon 4.0
1 pulled pork 3.0
2 bacon 12.0
3 pastrami 6.0
4 corned beef 7.5
5 bacon 8.0
6 pastrami 3.0
7 honey ham 5.0
8 nova lox 6.0
假设您想要添加一个指示每种食物来自哪种动物的列。让我们写下每种不同肉类到动物种类的映射:
meat_to_animal = {
"bacon": "pig",
"pulled pork": "pig",
"pastrami": "cow",
"corned beef": "cow",
"honey ham": "pig",
"nova lox": "salmon"
}
Series 上的 map 方法(也在 Ch 5.2.5: 函数应用和映射 中讨论)接受一个包含映射的函数或类似字典的对象,用于对值进行转换:
In [60]: data["animal"] = data["food"].map(meat_to_animal)
In [61]: data
Out[61]:
food ounces animal
0 bacon 4.0 pig
1 pulled pork 3.0 pig
2 bacon 12.0 pig
3 pastrami 6.0 cow
4 corned beef 7.5 cow
5 bacon 8.0 pig
6 pastrami 3.0 cow
7 honey ham 5.0 pig
8 nova lox 6.0 salmon
我们也可以传递一个执行所有工作的函数:
In [62]: def get_animal(x):
....: return meat_to_animal[x]
In [63]: data["food"].map(get_animal)
Out[63]:
0 pig
1 pig
2 pig
3 cow
4 cow
5 pig
6 cow
7 pig
8 salmon
Name: food, dtype: object
使用 map 是执行逐元素转换和其他数据清理相关操作的便捷方式。
替换值
使用 fillna 方法填充缺失数据是更一般的值替换的特殊情况。正如您已经看到的,map 可以用于修改对象中的一部分值,但 replace 提供了一种更简单、更灵活的方法。让我们考虑这个 Series:
In [64]: data = pd.Series([1., -999., 2., -999., -1000., 3.])
In [65]: data
Out[65]:
0 1.0
1 -999.0
2 2.0
3 -999.0
4 -1000.0
5 3.0
dtype: float64
-999 值可能是缺失数据的标记值。要用 pandas 理解的 NA 值替换这些值,可以使用 replace,生成一个新的 Series:
In [66]: data.replace(-999, np.nan)
Out[66]:
0 1.0
1 NaN
2 2.0
3 NaN
4 -1000.0
5 3.0
dtype: float64
如果您想一次替换多个值,可以传递一个列表,然后是替代值:
In [67]: data.replace([-999, -1000], np.nan)
Out[67]:
0 1.0
1 NaN
2 2.0
3 NaN
4 NaN
5 3.0
dtype: float64
要为每个值使用不同的替代值,传递一个替代列表:
In [68]: data.replace([-999, -1000], [np.nan, 0])
Out[68]:
0 1.0
1 NaN
2 2.0
3 NaN
4 0.0
5 3.0
dtype: float64
传递的参数也可以是一个字典:
In [69]: data.replace({-999: np.nan, -1000: 0})
Out[69]:
0 1.0
1 NaN
2 2.0
3 NaN
4 0.0
5 3.0
dtype: float64
注意
data.replace 方法与 data.str.replace 是不同的,后者执行逐元素的字符串替换。我们将在本章后面的 Series 中查看这些字符串方法。
重命名轴索引
与 Series 中的值类似,轴标签也可以通过函数或某种形式的映射进行类似转换,以生成新的、不同标记的对象。您还可以在原地修改轴,而不创建新的数据结构。这是一个简单的例子:
In [70]: data = pd.DataFrame(np.arange(12).reshape((3, 4)),
....: index=["Ohio", "Colorado", "New York"],
....: columns=["one", "two", "three", "four"])
与 Series 一样,轴索引具有 map 方法:
In [71]: def transform(x):
....: return x[:4].upper()
In [72]: data.index.map(transform)
Out[72]: Index(['OHIO', 'COLO', 'NEW '], dtype='object')
您可以分配给 index 属性,直接修改 DataFrame:
In [73]: data.index = data.index.map(transform)
In [74]: data
Out[74]:
one two three four
OHIO 0 1 2 3
COLO 4 5 6 7
NEW 8 9 10 11
如果要创建一个转换后的数据集副本而不修改原始数据集,一个有用的方法是 rename:
In [75]: data.rename(index=str.title, columns=str.upper)
Out[75]:
ONE TWO THREE FOUR
Ohio 0 1 2 3
Colo 4 5 6 7
New 8 9 10 11
值得注意的是,rename 可以与类似字典的对象一起使用,为轴标签的子集提供新值:
In [76]: data.rename(index={"OHIO": "INDIANA"},
....: columns={"three": "peekaboo"})
Out[76]:
one two peekaboo four
INDIANA 0 1 2 3
COLO 4 5 6 7
NEW 8 9 10 11
rename 可以避免手动复制 DataFrame 并为其 index 和 columns 属性分配新值的繁琐工作。
离散化和分箱
连续数据通常被离散化或以其他方式分成“箱子”进行分析。假设您有一组人的研究数据,并且想要将它们分成离散的年龄段:
In [77]: ages = [20, 22, 25, 27, 21, 23, 37, 31, 61, 45, 41, 32]
让我们将这些分成 18 至 25 岁、26 至 35 岁、36 至 60 岁,最后是 61 岁及以上的箱子。为此,您必须使用 pandas.cut:
In [78]: bins = [18, 25, 35, 60, 100]
In [79]: age_categories = pd.cut(ages, bins)
In [80]: age_categories
Out[80]:
[(18, 25], (18, 25], (18, 25], (25, 35], (18, 25], ..., (25, 35], (60, 100], (35,
60], (35, 60], (25, 35]]
Length: 12
Categories (4, interval[int64, right]): [(18, 25] < (25, 35] < (35, 60] < (60, 10
0]]
pandas 返回的对象是一个特殊的分类对象。您看到的输出描述了 pandas.cut 计算的箱。每个箱由一个特殊的(对于 pandas 是唯一的)区间值类型标识,其中包含每个箱的下限和上限:
In [81]: age_categories.codes
Out[81]: array([0, 0, 0, 1, 0, 0, 2, 1, 3, 2, 2, 1], dtype=int8)
In [82]: age_categories.categories
Out[82]: IntervalIndex([(18, 25], (25, 35], (35, 60], (60, 100]], dtype='interval
[int64, right]')
In [83]: age_categories.categories[0]
Out[83]: Interval(18, 25, closed='right')
In [84]: pd.value_counts(age_categories)
Out[84]:
(18, 25] 5
(25, 35] 3
(35, 60] 3
(60, 100] 1
Name: count, dtype: int64
请注意,pd.value_counts(categories) 是 pandas.cut 结果的箱计数。
在区间的字符串表示中,括号表示一侧是 开放的(排除的),而方括号表示一侧是 闭合的(包含的)。您可以通过传递 right=False 来更改哪一侧是闭合的:
In [85]: pd.cut(ages, bins, right=False)
Out[85]:
[[18, 25), [18, 25), [25, 35), [25, 35), [18, 25), ..., [25, 35), [60, 100), [35,
60), [35, 60), [25, 35)]
Length: 12
Categories (4, interval[int64, left]): [[18, 25) < [25, 35) < [35, 60) < [60, 100
)]
通过将列表或数组传递给 labels 选项,可以覆盖默认的基于区间的箱标签:
In [86]: group_names = ["Youth", "YoungAdult", "MiddleAged", "Senior"]
In [87]: pd.cut(ages, bins, labels=group_names)
Out[87]:
['Youth', 'Youth', 'Youth', 'YoungAdult', 'Youth', ..., 'YoungAdult', 'Senior', '
MiddleAged', 'MiddleAged', 'YoungAdult']
Length: 12
Categories (4, object): ['Youth' < 'YoungAdult' < 'MiddleAged' < 'Senior']
如果将整数数量的箱传递给 pandas.cut 而不是显式的箱边界,它将基于数据中的最小值和最大值计算等长的箱。考虑一下一些均匀分布的数据被分成四等份的情况:
In [88]: data = np.random.uniform(size=20)
In [89]: pd.cut(data, 4, precision=2)
Out[89]:
[(0.34, 0.55], (0.34, 0.55], (0.76, 0.97], (0.76, 0.97], (0.34, 0.55], ..., (0.34
, 0.55], (0.34, 0.55], (0.55, 0.76], (0.34, 0.55], (0.12, 0.34]]
Length: 20
Categories (4, interval[float64, right]): [(0.12, 0.34] < (0.34, 0.55] < (0.55, 0
.76] <
(0.76, 0.97]]
precision=2 选项将小数精度限制为两位数。
一个与之密切相关的函数 pandas.qcut,根据样本分位数对数据进行分箱。根据数据的分布,使用 pandas.cut 通常不会导致每个箱具有相同数量的数据点。由于 pandas.qcut 使用样本分位数,因此您将获得大致相同大小的箱:
In [90]: data = np.random.standard_normal(1000)
In [91]: quartiles = pd.qcut(data, 4, precision=2)
In [92]: quartiles
Out[92]:
[(-0.026, 0.62], (0.62, 3.93], (-0.68, -0.026], (0.62, 3.93], (-0.026, 0.62], ...
, (-0.68, -0.026], (-0.68, -0.026], (-2.96, -0.68], (0.62, 3.93], (-0.68, -0.026]
]
Length: 1000
Categories (4, interval[float64, right]): [(-2.96, -0.68] < (-0.68, -0.026] < (-0
.026, 0.62] <
(0.62, 3.93]]
In [93]: pd.value_counts(quartiles)
Out[93]:
(-2.96, -0.68] 250
(-0.68, -0.026] 250
(-0.026, 0.62] 250
(0.62, 3.93] 250
Name: count, dtype: int64
类似于 pandas.cut,您可以传递自己的分位数(介于 0 和 1 之间的数字):
In [94]: pd.qcut(data, [0, 0.1, 0.5, 0.9, 1.]).value_counts()
Out[94]:
(-2.9499999999999997, -1.187] 100
(-1.187, -0.0265] 400
(-0.0265, 1.286] 400
(1.286, 3.928] 100
Name: count, dtype: int64
我们将在本章后面的聚合和分组操作讨论中再次回到pandas.cut和pandas.qcut,因为这些离散化函数对于分位数和分组分析特别有用。
检测和过滤异常值
过滤或转换异常值主要是应用数组操作的问题。考虑一个包含一些正态分布数据的 DataFrame:
In [95]: data = pd.DataFrame(np.random.standard_normal((1000, 4)))
In [96]: data.describe()
Out[96]:
0 1 2 3
count 1000.000000 1000.000000 1000.000000 1000.000000
mean 0.049091 0.026112 -0.002544 -0.051827
std 0.996947 1.007458 0.995232 0.998311
min -3.645860 -3.184377 -3.745356 -3.428254
25% -0.599807 -0.612162 -0.687373 -0.747478
50% 0.047101 -0.013609 -0.022158 -0.088274
75% 0.756646 0.695298 0.699046 0.623331
max 2.653656 3.525865 2.735527 3.366626
假设您想要查找绝对值超过 3 的某一列中的值:
In [97]: col = data[2]
In [98]: col[col.abs() > 3]
Out[98]:
41 -3.399312
136 -3.745356
Name: 2, dtype: float64
要选择所有值超过 3 或-3 的行,您可以在布尔 DataFrame 上使用any方法:
In [99]: data[(data.abs() > 3).any(axis="columns")]
Out[99]:
0 1 2 3
41 0.457246 -0.025907 -3.399312 -0.974657
60 1.951312 3.260383 0.963301 1.201206
136 0.508391 -0.196713 -3.745356 -1.520113
235 -0.242459 -3.056990 1.918403 -0.578828
258 0.682841 0.326045 0.425384 -3.428254
322 1.179227 -3.184377 1.369891 -1.074833
544 -3.548824 1.553205 -2.186301 1.277104
635 -0.578093 0.193299 1.397822 3.366626
782 -0.207434 3.525865 0.283070 0.544635
803 -3.645860 0.255475 -0.549574 -1.907459
在data.abs() > 3周围的括号是必要的,以便在比较操作的结果上调用any方法。
可以根据这些标准设置值。以下是将值限制在区间-3 到 3 之外的代码:
In [100]: data[data.abs() > 3] = np.sign(data) * 3
In [101]: data.describe()
Out[101]:
0 1 2 3
count 1000.000000 1000.000000 1000.000000 1000.000000
mean 0.050286 0.025567 -0.001399 -0.051765
std 0.992920 1.004214 0.991414 0.995761
min -3.000000 -3.000000 -3.000000 -3.000000
25% -0.599807 -0.612162 -0.687373 -0.747478
50% 0.047101 -0.013609 -0.022158 -0.088274
75% 0.756646 0.695298 0.699046 0.623331
max 2.653656 3.000000 2.735527 3.000000
np.sign(data)语句根据data中的值是正数还是负数产生 1 和-1 值:
In [102]: np.sign(data).head()
Out[102]:
0 1 2 3
0 -1.0 1.0 -1.0 1.0
1 1.0 -1.0 1.0 -1.0
2 1.0 1.0 1.0 -1.0
3 -1.0 -1.0 1.0 -1.0
4 -1.0 1.0 -1.0 -1.0
排列和随机抽样
通过使用numpy.random.permutation函数,可以对 Series 或 DataFrame 中的行进行排列(随机重新排序)。调用permutation并传入您想要排列的轴的长度会产生一个整数数组,指示新的排序:
In [103]: df = pd.DataFrame(np.arange(5 * 7).reshape((5, 7)))
In [104]: df
Out[104]:
0 1 2 3 4 5 6
0 0 1 2 3 4 5 6
1 7 8 9 10 11 12 13
2 14 15 16 17 18 19 20
3 21 22 23 24 25 26 27
4 28 29 30 31 32 33 34
In [105]: sampler = np.random.permutation(5)
In [106]: sampler
Out[106]: array([3, 1, 4, 2, 0])
然后可以将该数组用于基于iloc的索引或等效的take函数:
In [107]: df.take(sampler)
Out[107]:
0 1 2 3 4 5 6
3 21 22 23 24 25 26 27
1 7 8 9 10 11 12 13
4 28 29 30 31 32 33 34
2 14 15 16 17 18 19 20
0 0 1 2 3 4 5 6
In [108]: df.iloc[sampler]
Out[108]:
0 1 2 3 4 5 6
3 21 22 23 24 25 26 27
1 7 8 9 10 11 12 13
4 28 29 30 31 32 33 34
2 14 15 16 17 18 19 20
0 0 1 2 3 4 5 6
通过使用axis="columns"调用take,我们还可以选择列的排列:
In [109]: column_sampler = np.random.permutation(7)
In [110]: column_sampler
Out[110]: array([4, 6, 3, 2, 1, 0, 5])
In [111]: df.take(column_sampler, axis="columns")
Out[111]:
4 6 3 2 1 0 5
0 4 6 3 2 1 0 5
1 11 13 10 9 8 7 12
2 18 20 17 16 15 14 19
3 25 27 24 23 22 21 26
4 32 34 31 30 29 28 33
要选择一个不带替换的随机子集(同一行不能出现两次),可以在 Series 和 DataFrame 上使用sample方法:
In [112]: df.sample(n=3)
Out[112]:
0 1 2 3 4 5 6
2 14 15 16 17 18 19 20
4 28 29 30 31 32 33 34
0 0 1 2 3 4 5 6
要生成一个带有替换的样本(允许重复选择),请将replace=True传递给sample:
In [113]: choices = pd.Series([5, 7, -1, 6, 4])
In [114]: choices.sample(n=10, replace=True)
Out[114]:
2 -1
0 5
3 6
1 7
4 4
0 5
4 4
0 5
4 4
4 4
dtype: int64
计算指示/虚拟变量
另一种用于统计建模或机器学习应用的转换类型是将分类变量转换为虚拟或指示矩阵。如果 DataFrame 中的一列有k个不同的值,您将得到一个包含所有 1 和 0 的k列的矩阵或 DataFrame。pandas 有一个pandas.get_dummies函数可以做到这一点,尽管您也可以自己设计一个。让我们考虑一个示例 DataFrame:
In [115]: df = pd.DataFrame({"key": ["b", "b", "a", "c", "a", "b"],
.....: "data1": range(6)})
In [116]: df
Out[116]:
key data1
0 b 0
1 b 1
2 a 2
3 c 3
4 a 4
5 b 5
In [117]: pd.get_dummies(df["key"], dtype=float)
Out[117]:
a b c
0 0.0 1.0 0.0
1 0.0 1.0 0.0
2 1.0 0.0 0.0
3 0.0 0.0 1.0
4 1.0 0.0 0.0
5 0.0 1.0 0.0
在这里,我传递了dtype=float以将输出类型从布尔值(pandas 较新版本中的默认值)更改为浮点数。
在某些情况下,您可能希望在指示 DataFrame 的列中添加前缀,然后将其与其他数据合并。pandas.get_dummies有一个用于执行此操作的前缀参数:
In [118]: dummies = pd.get_dummies(df["key"], prefix="key", dtype=float)
In [119]: df_with_dummy = df[["data1"]].join(dummies)
In [120]: df_with_dummy
Out[120]:
data1 key_a key_b key_c
0 0 0.0 1.0 0.0
1 1 0.0 1.0 0.0
2 2 1.0 0.0 0.0
3 3 0.0 0.0 1.0
4 4 1.0 0.0 0.0
5 5 0.0 1.0 0.0
DataFrame.join方法将在下一章中详细解释。
如果 DataFrame 中的一行属于多个类别,则我们必须使用不同的方法来创建虚拟变量。让我们看一下 MovieLens 1M 数据集,该数据集在 Ch 13:数据分析示例中有更详细的研究:
In [121]: mnames = ["movie_id", "title", "genres"]
In [122]: movies = pd.read_table("datasets/movielens/movies.dat", sep="::",
.....: header=None, names=mnames, engine="python")
In [123]: movies[:10]
Out[123]:
movie_id title genres
0 1 Toy Story (1995) Animation|Children's|Comedy
1 2 Jumanji (1995) Adventure|Children's|Fantasy
2 3 Grumpier Old Men (1995) Comedy|Romance
3 4 Waiting to Exhale (1995) Comedy|Drama
4 5 Father of the Bride Part II (1995) Comedy
5 6 Heat (1995) Action|Crime|Thriller
6 7 Sabrina (1995) Comedy|Romance
7 8 Tom and Huck (1995) Adventure|Children's
8 9 Sudden Death (1995) Action
9 10 GoldenEye (1995) Action|Adventure|Thriller
pandas 实现了一个特殊的 Series 方法str.get_dummies(以str.开头的方法将在字符串操作中更详细地讨论),处理了将多个组成员身份编码为分隔字符串的情况:
In [124]: dummies = movies["genres"].str.get_dummies("|")
In [125]: dummies.iloc[:10, :6]
Out[125]:
Action Adventure Animation Children's Comedy Crime
0 0 0 1 1 1 0
1 0 1 0 1 0 0
2 0 0 0 0 1 0
3 0 0 0 0 1 0
4 0 0 0 0 1 0
5 1 0 0 0 0 1
6 0 0 0 0 1 0
7 0 1 0 1 0 0
8 1 0 0 0 0 0
9 1 1 0 0 0 0
然后,与之前一样,您可以将此与movies组合,同时在dummies DataFrame 的列名中添加"Genre_",使用add_prefix方法:
In [126]: movies_windic = movies.join(dummies.add_prefix("Genre_"))
In [127]: movies_windic.iloc[0]
Out[127]:
movie_id 1
title Toy Story (1995)
genres Animation|Children's|Comedy
Genre_Action 0
Genre_Adventure 0
Genre_Animation 1
Genre_Children's 1
Genre_Comedy 1
Genre_Crime 0
Genre_Documentary 0
Genre_Drama 0
Genre_Fantasy 0
Genre_Film-Noir 0
Genre_Horror 0
Genre_Musical 0
Genre_Mystery 0
Genre_Romance 0
Genre_Sci-Fi 0
Genre_Thriller 0
Genre_War 0
Genre_Western 0
Name: 0, dtype: object
注意
对于更大的数据,使用这种构建具有多个成员身份的指示变量的方法并不特别快速。最好编写一个直接写入 NumPy 数组的低级函数,然后将结果包装在 DataFrame 中。
在统计应用中的一个有用的技巧是将pandas.get_dummies与像pandas.cut这样的离散化函数结合使用:*
In [128]: np.random.seed(12345) # to make the example repeatable
In [129]: values = np.random.uniform(size=10)
In [130]: values
Out[130]:
array([0.9296, 0.3164, 0.1839, 0.2046, 0.5677, 0.5955, 0.9645, 0.6532,
0.7489, 0.6536])
In [131]: bins = [0, 0.2, 0.4, 0.6, 0.8, 1]
In [132]: pd.get_dummies(pd.cut(values, bins))
Out[132]:
(0.0, 0.2] (0.2, 0.4] (0.4, 0.6] (0.6, 0.8] (0.8, 1.0]
0 False False False False True
1 False True False False False
2 True False False False False
3 False True False False False
4 False False True False False
5 False False True False False
6 False False False False True
7 False False False True False
8 False False False True False
9 False False False True False
我们稍后将再次查看pandas.get_dummies,在为建模创建虚拟变量中。
7.3 扩展数据类型
注意
这是一个较新且更高级的主题,许多 pandas 用户不需要了解太多,但我在这里完整地介绍它,因为在接下来的章节中我将引用和使用扩展数据类型。
pandas 最初是建立在 NumPy 的基础上的,NumPy 是一个主要用于处理数值数据的数组计算库。许多 pandas 概念,如缺失数据,是使用 NumPy 中可用的内容实现的,同时尽量在使用 NumPy 和 pandas 的库之间最大程度地保持兼容性。
基于 NumPy 的构建存在许多缺点,例如:
-
对于一些数值数据类型,如整数和布尔值,缺失数据处理是不完整的。因此,当这些数据中引入缺失数据时,pandas 会将数据类型转换为
float64,并使用np.nan表示空值。这导致许多 pandas 算法中出现了微妙的问题。 -
具有大量字符串数据的数据集在计算上是昂贵的,并且使用了大量内存。
-
一些数据类型,如时间间隔、时间增量和带时区的时间戳,如果不使用计算昂贵的 Python 对象数组,将无法有效支持。
最近,pandas 开发了一个扩展类型系统,允许添加新的数据类型,即使它们在 NumPy 中没有原生支持。这些新数据类型可以被视为与来自 NumPy 数组的数据同等重要。
让我们看一个例子,我们创建一个带有缺失值的整数 Series:
In [133]: s = pd.Series([1, 2, 3, None])
In [134]: s
Out[134]:
0 1.0
1 2.0
2 3.0
3 NaN
dtype: float64
In [135]: s.dtype
Out[135]: dtype('float64')
主要出于向后兼容的原因,Series 使用了使用float64数据类型和np.nan表示缺失值的传统行为。我们可以使用pandas.Int64Dtype来创建这个 Series:
In [136]: s = pd.Series([1, 2, 3, None], dtype=pd.Int64Dtype())
In [137]: s
Out[137]:
0 1
1 2
2 3
3 <NA>
dtype: Int64
In [138]: s.isna()
Out[138]:
0 False
1 False
2 False
3 True
dtype: bool
In [139]: s.dtype
Out[139]: Int64Dtype()
输出<NA>表示扩展类型数组中的值缺失。这使用了特殊的pandas.NA标记值:
In [140]: s[3]
Out[140]: <NA>
In [141]: s[3] is pd.NA
Out[141]: True
我们也可以使用缩写"Int64"来指定类型,而不是pd.Int64Dtype()。大写是必需的,否则它将是一个基于 NumPy 的非扩展类型:
In [142]: s = pd.Series([1, 2, 3, None], dtype="Int64")
pandas 还有一种专门用于字符串数据的扩展类型,不使用 NumPy 对象数组(需要安装 pyarrow 库):
In [143]: s = pd.Series(['one', 'two', None, 'three'], dtype=pd.StringDtype())
In [144]: s
Out[144]:
0 one
1 two
2 <NA>
3 three
dtype: string
这些字符串数组通常使用更少的内存,并且在对大型数据集进行操作时通常更高效。
另一个重要的扩展类型是Categorical,我们将在 Categorical Data 中更详细地讨论。截至本文撰写时,可用的扩展类型的相对完整列表在表 7.3 中。
扩展类型可以传递给 Series 的astype方法,允许您在数据清理过程中轻松转换:
In [145]: df = pd.DataFrame({"A": [1, 2, None, 4],
.....: "B": ["one", "two", "three", None],
.....: "C": [False, None, False, True]})
In [146]: df
Out[146]:
A B C
0 1.0 one False
1 2.0 two None
2 NaN three False
3 4.0 None True
In [147]: df["A"] = df["A"].astype("Int64")
In [148]: df["B"] = df["B"].astype("string")
In [149]: df["C"] = df["C"].astype("boolean")
In [150]: df
Out[150]:
A B C
0 1 one False
1 2 two <NA>
2 <NA> three False
3 4 <NA> True
表 7.3:pandas 扩展数据类型
| 扩展类型 | 描述 |
|---|---|
BooleanDtype |
可空布尔数据,传递字符串时使用"boolean" |
CategoricalDtype |
分类数据类型,传递字符串时使用"category" |
DatetimeTZDtype |
带时区的日期时间 |
Float32Dtype |
32 位可空浮点数,传递字符串时使用"Float32" |
Float64Dtype |
64 位可空浮点数,传递字符串时使用"Float64" |
Int8Dtype |
8 位可空有符号整数,传递字符串时使用"Int8" |
Int16Dtype |
16 位可空有符号整数,传递字符串时使用"Int16" |
Int32Dtype |
32 位可空有符号整数,传递字符串时使用"Int32" |
Int64Dtype |
64 位可空有符号整数,传递字符串时使用"Int64" |
UInt8Dtype |
8 位可空无符号整数,传递字符串时使用"UInt8" |
UInt16Dtype |
16 位可空无符号整数,传递字符串时使用"UInt16" |
UInt32Dtype |
32 位可空无符号整数,传递字符串时使用"UInt32" |
| UInt64Dtype | 64 位可空无符号整数,在传递为字符串时使用"UInt64" |
7.4 字符串操作
Python 长期以来一直是一种流行的原始数据处理语言,部分原因是它易于用于字符串和文本处理。大多数文本操作都可以通过字符串对象的内置方法简化。对于更复杂的模式匹配和文本操作,可能需要使用正则表达式。pandas 通过使您能够简洁地在整个数据数组上应用字符串和正则表达式,另外处理了缺失数据的烦恼。
Python 内置字符串对象方法
在许多字符串处理和脚本应用程序中,内置字符串方法已经足够。例如,逗号分隔的字符串可以使用split分割成多个部分:
In [151]: val = "a,b, guido"
In [152]: val.split(",")
Out[152]: ['a', 'b', ' guido']
split通常与strip结合使用以修剪空格(包括换行符):
In [153]: pieces = [x.strip() for x in val.split(",")]
In [154]: pieces
Out[154]: ['a', 'b', 'guido']
这些子字符串可以使用加法和双冒号分隔符连接在一起:
In [155]: first, second, third = pieces
In [156]: first + "::" + second + "::" + third
Out[156]: 'a::b::guido'
但这并不是一种实用的通用方法。更快速和更符合 Python 风格的方法是将列表或元组传递给字符串"::"上的join方法:
In [157]: "::".join(pieces)
Out[157]: 'a::b::guido'
其他方法涉及定位子字符串。使用 Python 的in关键字是检测子字符串的最佳方法,尽管也可以使用index和find:
In [158]: "guido" in val
Out[158]: True
In [159]: val.index(",")
Out[159]: 1
In [160]: val.find(":")
Out[160]: -1
请注意,find和index之间的区别在于,如果未找到字符串,index会引发异常(而不是返回-1):
In [161]: val.index(":")
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-161-bea4c4c30248> in <module>
----> 1 val.index(":")
ValueError: substring not found
相关地,count返回特定子字符串的出现次数:
In [162]: val.count(",")
Out[162]: 2
replace将一个模式的出现替换为另一个。通常也用于通过传递空字符串来删除模式:
In [163]: val.replace(",", "::")
Out[163]: 'a::b:: guido'
In [164]: val.replace(",", "")
Out[164]: 'ab guido'
请参阅表 7.4 以获取 Python 的一些字符串方法列表。
正则表达式也可以与许多这些操作一起使用,您将看到。
表 7.4:Python 内置字符串方法
| 方法 | 描述 |
|---|---|
count |
返回字符串中子字符串的非重叠出现次数 |
endswith |
如果字符串以后缀结尾,则返回True |
startswith |
如果字符串以前缀开头,则返回True |
join |
用作分隔符将字符串用于连接其他字符串序列 |
index |
如果在字符串中找到传递的子字符串,则返回第一个出现的起始索引;否则,如果未找到,则引发ValueError |
find |
返回字符串中第一个出现的子字符串的第一个字符的位置;类似于index,但如果未找到则返回-1 |
rfind |
返回字符串中最后出现的子字符串的第一个字符的位置;如果未找到则返回-1 |
replace |
用另一个字符串替换字符串的出现 |
strip, rstrip, lstrip |
修剪空格,包括右侧、左侧或两侧的换行符 |
split |
使用传递的分隔符将字符串拆分为子字符串列表 |
lower |
将字母字符转换为小写 |
upper |
将字母字符转换为大写 |
casefold |
将字符转换为小写,并将任何区域特定的可变字符组合转换为一个通用的可比较形式 |
ljust, rjust |
分别左对齐或右对齐;用空格(或其他填充字符)填充字符串的对侧,以返回具有最小宽度的字符串 |
正则表达式
正则表达式提供了一种灵活的方式来在文本中搜索或匹配(通常更复杂的)字符串模式。单个表达式,通常称为regex,是根据正则表达式语言形成的字符串。Python 的内置re模块负责将正则表达式应用于字符串;我将在这里给出一些示例。
注意
编写正则表达式的艺术可能是一个单独的章节,因此超出了本书的范围。互联网和其他书籍上有许多优秀的教程和参考资料。
re 模块的函数分为三类:模式匹配、替换和拆分。当然,这些都是相关的;正则表达式描述了要在文本中定位的模式,然后可以用于许多目的。让我们看一个简单的例子:假设我们想要使用可变数量的空白字符(制表符、空格和换行符)来拆分字符串。
描述一个或多个空白字符的正则表达式是 \s+:
In [165]: import re
In [166]: text = "foo bar\t baz \tqux"
In [167]: re.split(r"\s+", text)
Out[167]: ['foo', 'bar', 'baz', 'qux']
当您调用 re.split(r"\s+", text) 时,正则表达式首先被 编译,然后在传递的文本上调用其 split 方法。您可以使用 re.compile 自己编译正则表达式,形成一个可重用的正则表达式对象:
In [168]: regex = re.compile(r"\s+")
In [169]: regex.split(text)
Out[169]: ['foo', 'bar', 'baz', 'qux']
如果您想要获取与正则表达式匹配的所有模式的列表,可以使用 findall 方法:
In [170]: regex.findall(text)
Out[170]: [' ', '\t ', ' \t']
注意
为了避免在正则表达式中使用 \ 进行不必要的转义,请使用 原始 字符串字面量,如 r"C:\x",而不是等效的 "C:\\x"。
如果您打算将相同的表达式应用于许多字符串,强烈建议使用 re.compile 创建一个正则表达式对象;这样可以节省 CPU 周期。
match 和 search 与 findall 密切相关。虽然 findall 返回字符串中的所有匹配项,但 search 只返回第一个匹配项。更严格地说,match 仅 在字符串开头匹配。作为一个不太琐碎的例子,让我们考虑一个文本块和一个能够识别大多数电子邮件地址的正则表达式:
text = """Dave dave@google.com
Steve steve@gmail.com
Rob rob@gmail.com
Ryan ryan@yahoo.com"""
pattern = r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}"
# re.IGNORECASE makes the regex case insensitive
regex = re.compile(pattern, flags=re.IGNORECASE)
在文本上使用 findall 会产生一个电子邮件地址列表:
In [172]: regex.findall(text)
Out[172]:
['dave@google.com',
'steve@gmail.com',
'rob@gmail.com',
'ryan@yahoo.com']
search 为文本中的第一个电子邮件地址返回一个特殊的匹配对象。对于前面的正则表达式,匹配对象只能告诉我们模式在字符串中的起始和结束位置:
In [173]: m = regex.search(text)
In [174]: m
Out[174]: <re.Match object; span=(5, 20), match='dave@google.com'>
In [175]: text[m.start():m.end()]
Out[175]: 'dave@google.com'
regex.match 返回 None,因为它只会匹配如果模式出现在字符串的开头时:
In [176]: print(regex.match(text))
None
相关地,sub 将返回一个新字符串,其中模式的出现被新字符串替换:
In [177]: print(regex.sub("REDACTED", text))
Dave REDACTED
Steve REDACTED
Rob REDACTED
Ryan REDACTED
假设您想要查找电子邮件地址,并同时将每个地址分成三个组件:用户名、域名和域后缀。为此,请在模式的部分周围加上括号以进行分段:
In [178]: pattern = r"([A-Z0-9._%+-]+)@([A-Z0-9.-]+)\.([A-Z]{2,4})"
In [179]: regex = re.compile(pattern, flags=re.IGNORECASE)
由此修改后的正则表达式生成的匹配对象将使用其 groups 方法返回模式组件的元组:
In [180]: m = regex.match("wesm@bright.net")
In [181]: m.groups()
Out[181]: ('wesm', 'bright', 'net')
当模式有组时,findall 返回一个元组列表:
In [182]: regex.findall(text)
Out[182]:
[('dave', 'google', 'com'),
('steve', 'gmail', 'com'),
('rob', 'gmail', 'com'),
('ryan', 'yahoo', 'com')]
sub 还可以使用特殊符号如 \1 和 \2 访问每个匹配中的组。符号 \1 对应于第一个匹配组,\2 对应于第二个,依此类推:
In [183]: print(regex.sub(r"Username: \1, Domain: \2, Suffix: \3", text))
Dave Username: dave, Domain: google, Suffix: com
Steve Username: steve, Domain: gmail, Suffix: com
Rob Username: rob, Domain: gmail, Suffix: com
Ryan Username: ryan, Domain: yahoo, Suffix: com
Python 中的正则表达式还有很多内容,其中大部分超出了本书的范围。表 7.5 提供了一个简要总结。
表 7.5:正则表达式方法
| 方法 | 描述 |
|---|---|
findall |
返回字符串中所有非重叠匹配模式的列表 |
finditer |
类似于 findall,但返回一个迭代器 |
match |
在字符串开头匹配模式,并可选择将模式组件分段;如果模式匹配,则返回一个匹配对象,否则返回 None |
search |
扫描字符串以查找与模式匹配的内容,如果匹配,则返回一个匹配对象;与 match 不同,匹配可以出现在字符串的任何位置,而不仅仅是在开头 |
split |
在每次模式出现时将字符串分割成片段 |
| sub, subn | 用替换表达式替换字符串中所有 (sub) 或前 n 次出现 (subn) 的模式;使用符号 \1, \2, ... 来引用替换字符串中的匹配组元素 |
pandas 中的字符串函数
清理混乱的数据集以进行分析通常需要大量的字符串操作。为了使事情更加复杂,包含字符串的列有时会有缺失数据:
In [184]: data = {"Dave": "dave@google.com", "Steve": "steve@gmail.com",
.....: "Rob": "rob@gmail.com", "Wes": np.nan}
In [185]: data = pd.Series(data)
In [186]: data
Out[186]:
Dave dave@google.com
Steve steve@gmail.com
Rob rob@gmail.com
Wes NaN
dtype: object
In [187]: data.isna()
Out[187]:
Dave False
Steve False
Rob False
Wes True
dtype: bool
可以将字符串和正则表达式方法应用于每个值(传递 lambda 或其他函数)使用 data.map,但它将在 NA(空值)上失败。为了应对这一情况,Series 具有面向数组的字符串操作方法,可以跳过并传播 NA 值。这些方法通过 Series 的 str 属性访问;例如,我们可以使用 str.contains 检查每个电子邮件地址中是否包含 "gmail":
In [188]: data.str.contains("gmail")
Out[188]:
Dave False
Steve True
Rob True
Wes NaN
dtype: object
请注意,此操作的结果具有 object 类型。pandas 具有提供对字符串、整数和布尔数据进行专门处理的扩展类型,这些类型在处理缺失数据时一直存在一些问题:
In [189]: data_as_string_ext = data.astype('string')
In [190]: data_as_string_ext
Out[190]:
Dave dave@google.com
Steve steve@gmail.com
Rob rob@gmail.com
Wes <NA>
dtype: string
In [191]: data_as_string_ext.str.contains("gmail")
Out[191]:
Dave False
Steve True
Rob True
Wes <NA>
dtype: boolean
更详细地讨论了扩展类型,请参阅扩展数据类型。
也可以使用正则表达式,以及任何 re 选项,如 IGNORECASE:
In [192]: pattern = r"([A-Z0-9._%+-]+)@([A-Z0-9.-]+)\.([A-Z]{2,4})"
In [193]: data.str.findall(pattern, flags=re.IGNORECASE)
Out[193]:
Dave [(dave, google, com)]
Steve [(steve, gmail, com)]
Rob [(rob, gmail, com)]
Wes NaN
dtype: object
有几种进行矢量化元素检索的方法。可以使用 str.get 或索引到 str 属性:
In [194]: matches = data.str.findall(pattern, flags=re.IGNORECASE).str[0]
In [195]: matches
Out[195]:
Dave (dave, google, com)
Steve (steve, gmail, com)
Rob (rob, gmail, com)
Wes NaN
dtype: object
In [196]: matches.str.get(1)
Out[196]:
Dave google
Steve gmail
Rob gmail
Wes NaN
dtype: object
您也可以使用以下语法对字符串进行切片:
In [197]: data.str[:5]
Out[197]:
Dave dave@
Steve steve
Rob rob@g
Wes NaN
dtype: object
str.extract 方法将返回正则表达式的捕获组作为 DataFrame:
In [198]: data.str.extract(pattern, flags=re.IGNORECASE)
Out[198]:
0 1 2
Dave dave google com
Steve steve gmail com
Rob rob gmail com
Wes NaN NaN NaN
查看更多 pandas 字符串方法,请参阅表 7.6。
表 7.6: Series 字符串方法的部分列表
| 方法 | 描述 |
|---|---|
cat |
逐元素连接字符串,可选分隔符 |
contains |
如果每个字符串包含模式/正则表达式,则返回布尔数组 |
count |
计算模式的出现次数 |
extract |
使用具有组的正则表达式从字符串 Series 中提取一个或多个字符串;结果将是一个每组一列的 DataFrame |
endswith |
对每个元素等同于 x.endswith(pattern) |
startswith |
对每个元素等同于 x.startswith(pattern) |
findall |
计算每个字符串的模式/正则表达式的所有出现的列表 |
get |
索引到每个元素(检索第 i 个元素) |
isalnum |
等同于内置的 str.alnum |
isalpha |
等同于内置的 str.isalpha |
isdecimal |
等同于内置的 str.isdecimal |
isdigit |
等同于内置的 str.isdigit |
islower |
等同于内置的 str.islower |
isnumeric |
等同于内置的 str.isnumeric |
isupper |
等同于内置的 str.isupper |
join |
使用传递的分隔符连接 Series 中每个元素的字符串 |
len |
计算每个字符串的长度 |
lower, upper |
转换大小写;对每个元素等同于 x.lower() 或 x.upper() |
match |
对每个元素使用传递的正则表达式的 re.match,返回是否匹配的 True 或 False |
pad |
在字符串的左侧、右侧或两侧添加空格 |
center |
等同于 pad(side="both") |
repeat |
复制值(例如,s.str.repeat(3) 相当于对每个字符串执行 x * 3) |
replace |
用其他字符串替换模式/正则表达式的出现 |
slice |
对 Series 中的每个字符串进行切片 |
split |
按分隔符或正则表达式拆分字符串 |
strip |
从两侧修剪空白,包括换行符 |
rstrip |
修剪右侧的空白 |
| lstrip | 修剪左侧的空白 |
7.5 分类数据
本节介绍了 pandas 的 Categorical 类型。我将展示如何通过使用它在某些 pandas 操作中实现更好的性能和内存使用。我还介绍了一些工具,这些工具可能有助于在统计和机器学习应用中使用分类数据。
背景和动机
通常,表中的一列可能包含较小一组不同值的重复实例。我们已经看到了像 unique 和 value_counts 这样的函数,它们使我们能够从数组中提取不同的值并分别计算它们的频率:
In [199]: values = pd.Series(['apple', 'orange', 'apple',
.....: 'apple'] * 2)
In [200]: values
Out[200]:
0 apple
1 orange
2 apple
3 apple
4 apple
5 orange
6 apple
7 apple
dtype: object
In [201]: pd.unique(values)
Out[201]: array(['apple', 'orange'], dtype=object)
In [202]: pd.value_counts(values)
Out[202]:
apple 6
orange 2
Name: count, dtype: int64
许多数据系统(用于数据仓库、统计计算或其他用途)已经开发了专门的方法来表示具有重复值的数据,以实现更高效的存储和计算。在数据仓库中,最佳实践是使用所谓的维度表,其中包含不同的值,并将主要观察结果存储为引用维度表的整数键:
In [203]: values = pd.Series([0, 1, 0, 0] * 2)
In [204]: dim = pd.Series(['apple', 'orange'])
In [205]: values
Out[205]:
0 0
1 1
2 0
3 0
4 0
5 1
6 0
7 0
dtype: int64
In [206]: dim
Out[206]:
0 apple
1 orange
dtype: object
我们可以使用take方法恢复原始的字符串 Series:
In [207]: dim.take(values)
Out[207]:
0 apple
1 orange
0 apple
0 apple
0 apple
1 orange
0 apple
0 apple
dtype: object
这种整数表示被称为分类或字典编码表示。不同值的数组可以称为数据的类别、字典或级别。在本书中,我们将使用术语分类和类别。引用类别的整数值称为类别代码或简称代码。
在进行分析时,分类表示可以显著提高性能。您还可以在保持代码不变的情况下对类别执行转换。一些可以以相对较低的成本进行的示例转换包括:
-
重命名类别
-
追加一个新类别而不改变现有类别的顺序或位置
pandas 中的分类扩展类型
pandas 具有专门的Categorical扩展类型,用于保存使用基于整数的分类表示或编码的数据。这是一种流行的数据压缩技术,适用于具有许多相似值出现的数据,并且可以提供更快的性能和更低的内存使用,特别是对于字符串数据。
让我们考虑之前的示例 Series:
In [208]: fruits = ['apple', 'orange', 'apple', 'apple'] * 2
In [209]: N = len(fruits)
In [210]: rng = np.random.default_rng(seed=12345)
In [211]: df = pd.DataFrame({'fruit': fruits,
.....: 'basket_id': np.arange(N),
.....: 'count': rng.integers(3, 15, size=N),
.....: 'weight': rng.uniform(0, 4, size=N)},
.....: columns=['basket_id', 'fruit', 'count', 'weight'])
In [212]: df
Out[212]:
basket_id fruit count weight
0 0 apple 11 1.564438
1 1 orange 5 1.331256
2 2 apple 12 2.393235
3 3 apple 6 0.746937
4 4 apple 5 2.691024
5 5 orange 12 3.767211
6 6 apple 10 0.992983
7 7 apple 11 3.795525
这里,df['fruit']是 Python 字符串对象的数组。我们可以通过调用以下方式将其转换为分类:
In [213]: fruit_cat = df['fruit'].astype('category')
In [214]: fruit_cat
Out[214]:
0 apple
1 orange
2 apple
3 apple
4 apple
5 orange
6 apple
7 apple
Name: fruit, dtype: category
Categories (2, object): ['apple', 'orange']
现在,fruit_cat的值是pandas.Categorical的一个实例,您可以通过.array属性访问:
In [215]: c = fruit_cat.array
In [216]: type(c)
Out[216]: pandas.core.arrays.categorical.Categorical
Categorical对象具有categories和codes属性:
In [217]: c.categories
Out[217]: Index(['apple', 'orange'], dtype='object')
In [218]: c.codes
Out[218]: array([0, 1, 0, 0, 0, 1, 0, 0], dtype=int8)
可以使用cat访问器更轻松地访问这些,这将在 Categorical Methods 中很快解释。
获取代码和类别之间的映射的一个有用技巧是:
In [219]: dict(enumerate(c.categories))
Out[219]: {0: 'apple', 1: 'orange'}
您可以通过分配转换后的结果将 DataFrame 列转换为分类:
In [220]: df['fruit'] = df['fruit'].astype('category')
In [221]: df["fruit"]
Out[221]:
0 apple
1 orange
2 apple
3 apple
4 apple
5 orange
6 apple
7 apple
Name: fruit, dtype: category
Categories (2, object): ['apple', 'orange']
您还可以直接从其他类型的 Python 序列创建pandas.Categorical:
In [222]: my_categories = pd.Categorical(['foo', 'bar', 'baz', 'foo', 'bar'])
In [223]: my_categories
Out[223]:
['foo', 'bar', 'baz', 'foo', 'bar']
Categories (3, object): ['bar', 'baz', 'foo']
如果您从另一个来源获得了分类编码数据,可以使用替代的from_codes构造函数:
In [224]: categories = ['foo', 'bar', 'baz']
In [225]: codes = [0, 1, 2, 0, 0, 1]
In [226]: my_cats_2 = pd.Categorical.from_codes(codes, categories)
In [227]: my_cats_2
Out[227]:
['foo', 'bar', 'baz', 'foo', 'foo', 'bar']
Categories (3, object): ['foo', 'bar', 'baz']
除非明确指定,分类转换假定类别没有特定的排序。因此,categories数组的顺序可能会根据输入数据的顺序而有所不同。在使用from_codes或任何其他构造函数时,您可以指示类别具有有意义的排序:
In [228]: ordered_cat = pd.Categorical.from_codes(codes, categories,
.....: ordered=True)
In [229]: ordered_cat
Out[229]:
['foo', 'bar', 'baz', 'foo', 'foo', 'bar']
Categories (3, object): ['foo' < 'bar' < 'baz']
输出[foo < bar < baz]表示'foo'在排序中位于'bar'之前,依此类推。无序的分类实例可以通过as_ordered变为有序:
In [230]: my_cats_2.as_ordered()
Out[230]:
['foo', 'bar', 'baz', 'foo', 'foo', 'bar']
Categories (3, object): ['foo' < 'bar' < 'baz']
最后一点,分类数据不一定是字符串,尽管我只展示了字符串示例。分类数组可以由任何不可变的值类型组成。
使用 Categoricals 进行计算
与非编码版本(如字符串数组)相比,在 pandas 中使用Categorical通常表现相同。在处理分类数据时,pandas 的某些部分,如groupby函数,表现更好。还有一些函数可以利用ordered标志。
让我们考虑一些随机数值数据,并使用pandas.qcut分箱函数。这将返回pandas.Categorical;我们在本书的早期使用了pandas.cut,但忽略了分类的工作原理的细节:
In [231]: rng = np.random.default_rng(seed=12345)
In [232]: draws = rng.standard_normal(1000)
In [233]: draws[:5]
Out[233]: array([-1.4238, 1.2637, -0.8707, -0.2592, -0.0753])
让我们计算一下这些数据的四分位数分箱,并提取一些统计数据:
In [234]: bins = pd.qcut(draws, 4)
In [235]: bins
Out[235]:
[(-3.121, -0.675], (0.687, 3.211], (-3.121, -0.675], (-0.675, 0.0134], (-0.675, 0
.0134], ..., (0.0134, 0.687], (0.0134, 0.687], (-0.675, 0.0134], (0.0134, 0.687],
(-0.675, 0.0134]]
Length: 1000
Categories (4, interval[float64, right]): [(-3.121, -0.675] < (-0.675, 0.0134] <
(0.0134, 0.687] <
(0.687, 3.211]]
尽管有用,确切的样本四分位数可能不如四分位数名称有用于生成报告。我们可以通过qcut的labels参数实现这一点:
In [236]: bins = pd.qcut(draws, 4, labels=['Q1', 'Q2', 'Q3', 'Q4'])
In [237]: bins
Out[237]:
['Q1', 'Q4', 'Q1', 'Q2', 'Q2', ..., 'Q3', 'Q3', 'Q2', 'Q3', 'Q2']
Length: 1000
Categories (4, object): ['Q1' < 'Q2' < 'Q3' < 'Q4']
In [238]: bins.codes[:10]
Out[238]: array([0, 3, 0, 1, 1, 0, 0, 2, 2, 0], dtype=int8)
标记的bins分类不包含数据中的箱边信息,因此我们可以使用groupby来提取一些摘要统计信息:
In [239]: bins = pd.Series(bins, name='quartile')
In [240]: results = (pd.Series(draws)
.....: .groupby(bins)
.....: .agg(['count', 'min', 'max'])
.....: .reset_index())
In [241]: results
Out[241]:
quartile count min max
0 Q1 250 -3.119609 -0.678494
1 Q2 250 -0.673305 0.008009
2 Q3 250 0.018753 0.686183
3 Q4 250 0.688282 3.211418
结果中的'quartile'列保留了来自bins的原始分类信息,包括排序:
In [242]: results['quartile']
Out[242]:
0 Q1
1 Q2
2 Q3
3 Q4
Name: quartile, dtype: category
Categories (4, object): ['Q1' < 'Q2' < 'Q3' < 'Q4']
分类数据的更好性能
在本节开头,我说过分类类型可以提高性能和内存使用,所以让我们看一些例子。考虑一些具有 1000 万个元素和少量不同类别的 Series:
In [243]: N = 10_000_000
In [244]: labels = pd.Series(['foo', 'bar', 'baz', 'qux'] * (N // 4))
现在我们将labels转换为分类:
In [245]: categories = labels.astype('category')
现在我们注意到labels使用的内存比categories要多得多:
In [246]: labels.memory_usage(deep=True)
Out[246]: 600000128
In [247]: categories.memory_usage(deep=True)
Out[247]: 10000540
当然,转换为类别并不是免费的,但这是一次性的成本:
In [248]: %time _ = labels.astype('category')
CPU times: user 279 ms, sys: 6.06 ms, total: 285 ms
Wall time: 285 ms
由于底层算法使用基于整数的代码数组而不是字符串数组,因此使用分类的 GroupBy 操作可以显着提高性能。这里我们比较了使用 GroupBy 机制的value_counts()的性能:
In [249]: %timeit labels.value_counts()
331 ms +- 5.39 ms per loop (mean +- std. dev. of 7 runs, 1 loop each)
In [250]: %timeit categories.value_counts()
15.6 ms +- 152 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
分类方法
包含分类数据的 Series 具有几个类似于Series.str专门的字符串方法的特殊方法。这也提供了方便访问类别和代码。考虑 Series:
In [251]: s = pd.Series(['a', 'b', 'c', 'd'] * 2)
In [252]: cat_s = s.astype('category')
In [253]: cat_s
Out[253]:
0 a
1 b
2 c
3 d
4 a
5 b
6 c
7 d
dtype: category
Categories (4, object): ['a', 'b', 'c', 'd']
特殊的访问器属性cat提供了对分类方法的访问:
In [254]: cat_s.cat.codes
Out[254]:
0 0
1 1
2 2
3 3
4 0
5 1
6 2
7 3
dtype: int8
In [255]: cat_s.cat.categories
Out[255]: Index(['a', 'b', 'c', 'd'], dtype='object')
假设我们知道此数据的实际类别集扩展到数据中观察到的四个值之外。我们可以使用set_categories方法来更改它们:
In [256]: actual_categories = ['a', 'b', 'c', 'd', 'e']
In [257]: cat_s2 = cat_s.cat.set_categories(actual_categories)
In [258]: cat_s2
Out[258]:
0 a
1 b
2 c
3 d
4 a
5 b
6 c
7 d
dtype: category
Categories (5, object): ['a', 'b', 'c', 'd', 'e']
虽然数据看起来没有改变,但使用它们的操作将反映新的类别。例如,如果存在,value_counts会尊重类别:
In [259]: cat_s.value_counts()
Out[259]:
a 2
b 2
c 2
d 2
Name: count, dtype: int64
In [260]: cat_s2.value_counts()
Out[260]:
a 2
b 2
c 2
d 2
e 0
Name: count, dtype: int64
在大型数据集中,分类通常被用作一种方便的工具,用于节省内存和提高性能。在过滤大型 DataFrame 或 Series 之后,许多类别可能不会出现在数据中。为了帮助解决这个问题,我们可以使用remove_unused_categories方法来修剪未观察到的类别:
In [261]: cat_s3 = cat_s[cat_s.isin(['a', 'b'])]
In [262]: cat_s3
Out[262]:
0 a
1 b
4 a
5 b
dtype: category
Categories (4, object): ['a', 'b', 'c', 'd']
In [263]: cat_s3.cat.remove_unused_categories()
Out[263]:
0 a
1 b
4 a
5 b
dtype: category
Categories (2, object): ['a', 'b']
请参见表 7.7 列出的可用分类方法。
表 7.7:pandas 中 Series 的分类方法
| 方法 | 描述 |
|---|---|
add_categories |
在现有类别的末尾追加新的(未使用的)类别 |
as_ordered |
使类别有序 |
as_unordered |
使类别无序 |
remove_categories |
删除类别,将任何删除的值设置为 null |
remove_unused_categories |
删除数据中不存在的任何类别值 |
rename_categories |
用指定的新类别名称集替换类别;不能改变类别数量 |
reorder_categories |
表现类似于rename_categories,但也可以改变结果为有序类别 |
set_categories |
用指定的新类别集替换类别;可以添加或删除类别 |
为建模创建虚拟变量
当您使用统计或机器学习工具时,通常会将分类数据转换为虚拟变量,也称为独热编码。这涉及创建一个 DataFrame,其中每个不同的类别都有一列;这些列包含给定类别的出现为 1,否则为 0。
考虑前面的例子:
In [264]: cat_s = pd.Series(['a', 'b', 'c', 'd'] * 2, dtype='category')
如本章前面提到的,pandas.get_dummies函数将这个一维分类数据转换为包含虚拟变量的 DataFrame:
In [265]: pd.get_dummies(cat_s, dtype=float)
Out[265]:
a b c d
0 1.0 0.0 0.0 0.0
1 0.0 1.0 0.0 0.0
2 0.0 0.0 1.0 0.0
3 0.0 0.0 0.0 1.0
4 1.0 0.0 0.0 0.0
5 0.0 1.0 0.0 0.0
6 0.0 0.0 1.0 0.0
7 0.0 0.0 0.0 1.0
7.6 结论
有效的数据准备可以通过使您花更多时间分析数据而不是准备分析数据来显着提高生产率。本章中我们探讨了许多工具,但这里的覆盖范围并不全面。在下一章中,我们将探讨 pandas 的连接和分组功能。
八、数据整理:连接、合并和重塑
原文:
wesmckinney.com/book/data-wrangling译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
在许多应用程序中,数据可能分布在许多文件或数据库中,或者以不便于分析的形式排列。本章重点介绍帮助组合、连接和重新排列数据的工具。
首先,我介绍了 pandas 中层次索引的概念,这在某些操作中被广泛使用。然后我深入研究了特定的数据操作。您可以在第十三章:数据分析示例中看到这些工具的各种应用用法。
8.1 层次索引
层次索引是 pandas 的一个重要特性,它使您能够在轴上具有多个(两个或更多)索引级别。另一种思考方式是,它为您提供了一种以较低维度形式处理较高维度数据的方法。让我们从一个简单的示例开始:创建一个 Series,其索引为列表的列表(或数组):
In [11]: data = pd.Series(np.random.uniform(size=9),
....: index=[["a", "a", "a", "b", "b", "c", "c", "d", "d"],
....: [1, 2, 3, 1, 3, 1, 2, 2, 3]])
In [12]: data
Out[12]:
a 1 0.929616
2 0.316376
3 0.183919
b 1 0.204560
3 0.567725
c 1 0.595545
2 0.964515
d 2 0.653177
3 0.748907
dtype: float64
您看到的是一个带有MultiIndex作为索引的 Series 的美化视图。索引显示中的“间隙”表示“使用直接上面的标签”:
In [13]: data.index
Out[13]:
MultiIndex([('a', 1),
('a', 2),
('a', 3),
('b', 1),
('b', 3),
('c', 1),
('c', 2),
('d', 2),
('d', 3)],
)
对于具有层次索引的对象,可以进行所谓的部分索引,使您能够简洁地选择数据的子集:
In [14]: data["b"]
Out[14]:
1 0.204560
3 0.567725
dtype: float64
In [15]: data["b":"c"]
Out[15]:
b 1 0.204560
3 0.567725
c 1 0.595545
2 0.964515
dtype: float64
In [16]: data.loc[["b", "d"]]
Out[16]:
b 1 0.204560
3 0.567725
d 2 0.653177
3 0.748907
dtype: float64
甚至可以从“内部”级别进行选择。在这里,我从第二个索引级别选择所有具有值2的值:
In [17]: data.loc[:, 2]
Out[17]:
a 0.316376
c 0.964515
d 0.653177
dtype: float64
层次索引在重塑数据和基于组的操作(如形成数据透视表)中发挥着重要作用。例如,您可以使用其unstack方法将这些数据重新排列为 DataFrame:
In [18]: data.unstack()
Out[18]:
1 2 3
a 0.929616 0.316376 0.183919
b 0.204560 NaN 0.567725
c 0.595545 0.964515 NaN
d NaN 0.653177 0.748907
unstack的逆操作是stack:
In [19]: data.unstack().stack()
Out[19]:
a 1 0.929616
2 0.316376
3 0.183919
b 1 0.204560
3 0.567725
c 1 0.595545
2 0.964515
d 2 0.653177
3 0.748907
dtype: float64
stack和unstack将在重塑和透视中更详细地探讨。
对于 DataFrame,任一轴都可以具有分层索引:
In [20]: frame = pd.DataFrame(np.arange(12).reshape((4, 3)),
....: index=[["a", "a", "b", "b"], [1, 2, 1, 2]],
....: columns=[["Ohio", "Ohio", "Colorado"],
....: ["Green", "Red", "Green"]])
In [21]: frame
Out[21]:
Ohio Colorado
Green Red Green
a 1 0 1 2
2 3 4 5
b 1 6 7 8
2 9 10 11
层次级别可以有名称(作为字符串或任何 Python 对象)。如果有的话,这些名称将显示在控制台输出中:
In [22]: frame.index.names = ["key1", "key2"]
In [23]: frame.columns.names = ["state", "color"]
In [24]: frame
Out[24]:
state Ohio Colorado
color Green Red Green
key1 key2
a 1 0 1 2
2 3 4 5
b 1 6 7 8
2 9 10 11
这些名称取代了仅用于单级索引的name属性。
注意
请注意,索引名称"state"和"color"不是行标签(frame.index值)的一部分。
您可以通过访问其nlevels属性来查看索引具有多少级别:
In [25]: frame.index.nlevels
Out[25]: 2
通过部分列索引,您也可以类似地选择列组:
In [26]: frame["Ohio"]
Out[26]:
color Green Red
key1 key2
a 1 0 1
2 3 4
b 1 6 7
2 9 10
MultiIndex可以单独创建,然后重复使用;具有级别名称的前述 DataFrame 中的列也可以这样创建:
pd.MultiIndex.from_arrays([["Ohio", "Ohio", "Colorado"],
["Green", "Red", "Green"]],
names=["state", "color"])
重新排序和排序级别
有时您可能需要重新排列轴上级别的顺序或按特定级别的值对数据进行排序。swaplevel方法接受两个级别编号或名称,并返回一个级别互换的新对象(但数据本身不变):
In [27]: frame.swaplevel("key1", "key2")
Out[27]:
state Ohio Colorado
color Green Red Green
key2 key1
1 a 0 1 2
2 a 3 4 5
1 b 6 7 8
2 b 9 10 11
sort_index默认按所有索引级别词典顺序对数据进行排序,但您可以选择通过传递level参数仅使用单个级别或一组级别进行排序。例如:
In [28]: frame.sort_index(level=1)
Out[28]:
state Ohio Colorado
color Green Red Green
key1 key2
a 1 0 1 2
b 1 6 7 8
a 2 3 4 5
b 2 9 10 11
In [29]: frame.swaplevel(0, 1).sort_index(level=0)
Out[29]:
state Ohio Colorado
color Green Red Green
key2 key1
1 a 0 1 2
b 6 7 8
2 a 3 4 5
b 9 10 11
注意
如果索引按字典顺序排序,从最外层级别开始,那么在具有分层索引的对象上进行数据选择性能要好得多——也就是说,调用sort_index(level=0)或sort_index()的结果。
按级别汇总统计
DataFrame 和 Series 上的许多描述性和汇总统计信息具有level选项,您可以在特定轴上指定要按级别聚合的级别。考虑上面的 DataFrame;我们可以按行或列的级别进行聚合,如下所示:
In [30]: frame.groupby(level="key2").sum()
Out[30]:
state Ohio Colorado
color Green Red Green
key2
1 6 8 10
2 12 14 16
In [31]: frame.groupby(level="color", axis="columns").sum()
Out[31]:
color Green Red
key1 key2
a 1 2 1
2 8 4
b 1 14 7
2 20 10
我们将在第十章:数据聚合和分组操作中更详细地讨论groupby。
使用 DataFrame 的列进行索引
希望使用一个或多个 DataFrame 列作为行索引并不罕见;或者,您可能希望将行索引移入 DataFrame 的列中。这是一个示例 DataFrame:
In [32]: frame = pd.DataFrame({"a": range(7), "b": range(7, 0, -1),
....: "c": ["one", "one", "one", "two", "two",
....: "two", "two"],
....: "d": [0, 1, 2, 0, 1, 2, 3]})
In [33]: frame
Out[33]:
a b c d
0 0 7 one 0
1 1 6 one 1
2 2 5 one 2
3 3 4 two 0
4 4 3 two 1
5 5 2 two 2
6 6 1 two 3
DataFrame 的set_index函数将使用一个或多个列作为索引创建一个新的 DataFrame:
In [34]: frame2 = frame.set_index(["c", "d"])
In [35]: frame2
Out[35]:
a b
c d
one 0 0 7
1 1 6
2 2 5
two 0 3 4
1 4 3
2 5 2
3 6 1
默认情况下,列会从 DataFrame 中移除,但您可以通过向set_index传递drop=False来保留它们:
In [36]: frame.set_index(["c", "d"], drop=False)
Out[36]:
a b c d
c d
one 0 0 7 one 0
1 1 6 one 1
2 2 5 one 2
two 0 3 4 two 0
1 4 3 two 1
2 5 2 two 2
3 6 1 two 3
另一方面,reset_index的作用与set_index相反;层次化索引级别被移动到列中:
In [37]: frame2.reset_index()
Out[37]:
c d a b
0 one 0 0 7
1 one 1 1 6
2 one 2 2 5
3 two 0 3 4
4 two 1 4 3
5 two 2 5 2
6 two 3 6 1
8.2 合并和组合数据集
pandas 对象中包含的数据可以以多种方式组合:
pandas.merge
基于一个或多个键连接 DataFrame 中的行。这将为使用 SQL 或其他关系数据库的用户提供熟悉的操作,因为它实现了数据库join操作。
pandas.concat
沿轴连接或“堆叠”对象。
combine_first
将重叠数据拼接在一起,用另一个对象中的值填充另一个对象中的缺失值。
我将逐个讨论这些并给出一些示例。它们将在本书的其余部分的示例中使用。
数据库风格的 DataFrame 连接
合并或连接操作通过使用一个或多个键链接行来合并数据集。这些操作在关系数据库(例如基于 SQL 的数据库)中尤为重要。pandas 中的pandas.merge函数是使用这些算法在您的数据上的主要入口点。
让我们从一个简单的例子开始:
In [38]: df1 = pd.DataFrame({"key": ["b", "b", "a", "c", "a", "a", "b"],
....: "data1": pd.Series(range(7), dtype="Int64")})
In [39]: df2 = pd.DataFrame({"key": ["a", "b", "d"],
....: "data2": pd.Series(range(3), dtype="Int64")})
In [40]: df1
Out[40]:
key data1
0 b 0
1 b 1
2 a 2
3 c 3
4 a 4
5 a 5
6 b 6
In [41]: df2
Out[41]:
key data2
0 a 0
1 b 1
2 d 2
在这里,我使用 pandas 的Int64扩展类型来表示可空整数,详细讨论请参见第 7.3 章:扩展数据类型。
这是一个多对一连接的示例;df1中的数据有多行标记为a和b,而df2中的每个值在key列中只有一行。使用这些对象调用pandas.merge,我们得到:
In [42]: pd.merge(df1, df2)
Out[42]:
key data1 data2
0 b 0 1
1 b 1 1
2 b 6 1
3 a 2 0
4 a 4 0
5 a 5 0
请注意,我没有指定要连接的列。如果没有指定该信息,pandas.merge将使用重叠的列名作为键。不过,最好明确指定:
In [43]: pd.merge(df1, df2, on="key")
Out[43]:
key data1 data2
0 b 0 1
1 b 1 1
2 b 6 1
3 a 2 0
4 a 4 0
5 a 5 0
一般来说,在pandas.merge操作中,列的输出顺序是不确定的。
如果每个对象中的列名不同,您可以分别指定它们:
In [44]: df3 = pd.DataFrame({"lkey": ["b", "b", "a", "c", "a", "a", "b"],
....: "data1": pd.Series(range(7), dtype="Int64")})
In [45]: df4 = pd.DataFrame({"rkey": ["a", "b", "d"],
....: "data2": pd.Series(range(3), dtype="Int64")})
In [46]: pd.merge(df3, df4, left_on="lkey", right_on="rkey")
Out[46]:
lkey data1 rkey data2
0 b 0 b 1
1 b 1 b 1
2 b 6 b 1
3 a 2 a 0
4 a 4 a 0
5 a 5 a 0
您可能会注意到结果中缺少"c"和"d"值及其相关数据。默认情况下,pandas.merge执行的是"inner"连接;结果中的键是交集,或者是在两个表中都找到的公共集合。其他可能的选项是"left"、"right"和"outer"。外连接取键的并集,结合了应用左连接和右连接的效果:
In [47]: pd.merge(df1, df2, how="outer")
Out[47]:
key data1 data2
0 b 0 1
1 b 1 1
2 b 6 1
3 a 2 0
4 a 4 0
5 a 5 0
6 c 3 <NA>
7 d <NA> 2
In [48]: pd.merge(df3, df4, left_on="lkey", right_on="rkey", how="outer")
Out[48]:
lkey data1 rkey data2
0 b 0 b 1
1 b 1 b 1
2 b 6 b 1
3 a 2 a 0
4 a 4 a 0
5 a 5 a 0
6 c 3 NaN <NA>
7 NaN <NA> d 2
在外连接中,左侧或右侧 DataFrame 对象中与另一个 DataFrame 中的键不匹配的行将在另一个 DataFrame 的列中出现 NA 值。
请参阅表 8.1 以获取how选项的摘要。
表 8.1:使用how参数的不同连接类型
| 选项 | 行为 |
|---|---|
how="inner" |
仅使用在两个表中观察到的键组合 |
how="left" |
使用在左表中找到的所有键组合 |
how="right" |
使用在右表中找到的所有键组合 |
how="outer" |
使用两个表中观察到的所有键组合 |
多对多 合并形成匹配键的笛卡尔积。以下是一个示例:
In [49]: df1 = pd.DataFrame({"key": ["b", "b", "a", "c", "a", "b"],
....: "data1": pd.Series(range(6), dtype="Int64")})
In [50]: df2 = pd.DataFrame({"key": ["a", "b", "a", "b", "d"],
....: "data2": pd.Series(range(5), dtype="Int64")})
In [51]: df1
Out[51]:
key data1
0 b 0
1 b 1
2 a 2
3 c 3
4 a 4
5 b 5
In [52]: df2
Out[52]:
key data2
0 a 0
1 b 1
2 a 2
3 b 3
4 d 4
In [53]: pd.merge(df1, df2, on="key", how="left")
Out[53]:
key data1 data2
0 b 0 1
1 b 0 3
2 b 1 1
3 b 1 3
4 a 2 0
5 a 2 2
6 c 3 <NA>
7 a 4 0
8 a 4 2
9 b 5 1
10 b 5 3
由于左侧 DataFrame 中有三行"b",右侧 DataFrame 中有两行"b",因此结果中有六行"b"。传递给how关键字参数的连接方法仅影响结果中出现的不同键值:
In [54]: pd.merge(df1, df2, how="inner")
Out[54]:
key data1 data2
0 b 0 1
1 b 0 3
2 b 1 1
3 b 1 3
4 b 5 1
5 b 5 3
6 a 2 0
7 a 2 2
8 a 4 0
9 a 4 2
要使用多个键进行合并,请传递列名列表:
In [55]: left = pd.DataFrame({"key1": ["foo", "foo", "bar"],
....: "key2": ["one", "two", "one"],
....: "lval": pd.Series([1, 2, 3], dtype='Int64')})
In [56]: right = pd.DataFrame({"key1": ["foo", "foo", "bar", "bar"],
....: "key2": ["one", "one", "one", "two"],
....: "rval": pd.Series([4, 5, 6, 7], dtype='Int64')})
In [57]: pd.merge(left, right, on=["key1", "key2"], how="outer")
Out[57]:
key1 key2 lval rval
0 foo one 1 4
1 foo one 1 5
2 foo two 2 <NA>
3 bar one 3 6
4 bar two <NA> 7
要确定根据合并方法的选择将出现在结果中的哪些键组合,请将多个键视为形成元组数组,用作单个连接键。
注意
当您在列上进行列连接时,传递的 DataFrame 对象的索引会被丢弃。如果需要保留索引值,可以使用reset_index将索引附加到列中。
合并操作中要考虑的最后一个问题是处理重叠列名的方式。例如:
In [58]: pd.merge(left, right, on="key1")
Out[58]:
key1 key2_x lval key2_y rval
0 foo one 1 one 4
1 foo one 1 one 5
2 foo two 2 one 4
3 foo two 2 one 5
4 bar one 3 one 6
5 bar one 3 two 7
虽然您可以手动处理重叠(请参阅 Ch 7.2.4:重命名轴索引部分以重命名轴标签),pandas.merge具有一个suffixes选项,用于指定要附加到左侧和右侧 DataFrame 对象中重叠名称的字符串:
In [59]: pd.merge(left, right, on="key1", suffixes=("_left", "_right"))
Out[59]:
key1 key2_left lval key2_right rval
0 foo one 1 one 4
1 foo one 1 one 5
2 foo two 2 one 4
3 foo two 2 one 5
4 bar one 3 one 6
5 bar one 3 two 7
请参阅 pandas.merge 中的表 8.2,了解有关参数的参考。下一节将介绍使用 DataFrame 的行索引进行连接。
表 8.2:pandas.merge函数参数
| 参数 | 描述 |
|---|---|
left |
要在左侧合并的 DataFrame。 |
right |
要在右侧合并的 DataFrame。 |
how |
要应用的连接类型:"inner"、"outer"、"left"或"right"之一;默认为"inner"。 |
on |
要连接的列名。必须在两个 DataFrame 对象中找到。如果未指定并且没有给出其他连接键,则将使用left和right中的列名的交集作为连接键。 |
left_on |
用作连接键的left DataFrame 中的列。可以是单个列名或列名列表。 |
right_on |
与right DataFrame 的left_on类似。 |
left_index |
使用left中的行索引作为其连接键(或键,如果是MultiIndex)。 |
right_index |
与left_index类似。 |
sort |
按连接键按字典顺序对合并数据进行排序;默认为False。 |
suffixes |
字符串元组值,用于在重叠的列名后追加(默认为("_x", "_y"),例如,如果两个 DataFrame 对象中都有"data",则在结果中会显示为"data_x"和"data_y"。 |
copy |
如果为False,则在某些特殊情况下避免将数据复制到结果数据结构中;默认情况下始终复制。 |
validate |
验证合并是否是指定类型,一对一、一对多或多对多。有关选项的完整详细信息,请参阅文档字符串。 |
| indicator | 添加一个特殊列_merge,指示每行的来源;值将根据每行中连接数据的来源为"left_only"、"right_only"或"both"。
在索引上合并
在某些情况下,DataFrame 中的合并键会在其索引(行标签)中找到。在这种情况下,您可以传递left_index=True或right_index=True(或两者都传递)来指示索引应该用作合并键:
In [60]: left1 = pd.DataFrame({"key": ["a", "b", "a", "a", "b", "c"],
....: "value": pd.Series(range(6), dtype="Int64")})
In [61]: right1 = pd.DataFrame({"group_val": [3.5, 7]}, index=["a", "b"])
In [62]: left1
Out[62]:
key value
0 a 0
1 b 1
2 a 2
3 a 3
4 b 4
5 c 5
In [63]: right1
Out[63]:
group_val
a 3.5
b 7.0
In [64]: pd.merge(left1, right1, left_on="key", right_index=True)
Out[64]:
key value group_val
0 a 0 3.5
2 a 2 3.5
3 a 3 3.5
1 b 1 7.0
4 b 4 7.0
注意
如果您仔细观察这里,您会发现left1的索引值已被保留,而在上面的其他示例中,输入 DataFrame 对象的索引已被丢弃。由于right1的索引是唯一的,这种“一对多”合并(使用默认的how="inner"方法)可以保留与输出中的行对应的left1的索引值。
由于默认合并方法是交集连接键,您可以使用外连接来形成它们的并集:
In [65]: pd.merge(left1, right1, left_on="key", right_index=True, how="outer")
Out[65]:
key value group_val
0 a 0 3.5
2 a 2 3.5
3 a 3 3.5
1 b 1 7.0
4 b 4 7.0
5 c 5 NaN
对于具有分层索引的数据,情况会更加复杂,因为在索引上进行连接等效于多键合并:
In [66]: lefth = pd.DataFrame({"key1": ["Ohio", "Ohio", "Ohio",
....: "Nevada", "Nevada"],
....: "key2": [2000, 2001, 2002, 2001, 2002],
....: "data": pd.Series(range(5), dtype="Int64")})
In [67]: righth_index = pd.MultiIndex.from_arrays(
....: [
....: ["Nevada", "Nevada", "Ohio", "Ohio", "Ohio", "Ohio"],
....: [2001, 2000, 2000, 2000, 2001, 2002]
....: ]
....: )
In [68]: righth = pd.DataFrame({"event1": pd.Series([0, 2, 4, 6, 8, 10], dtype="I
nt64",
....: index=righth_index),
....: "event2": pd.Series([1, 3, 5, 7, 9, 11], dtype="I
nt64",
....: index=righth_index)})
In [69]: lefth
Out[69]:
key1 key2 data
0 Ohio 2000 0
1 Ohio 2001 1
2 Ohio 2002 2
3 Nevada 2001 3
4 Nevada 2002 4
In [70]: righth
Out[70]:
event1 event2
Nevada 2001 0 1
2000 2 3
Ohio 2000 4 5
2000 6 7
2001 8 9
2002 10 11
在这种情况下,您必须指示要合并的多个列作为列表(注意使用how="outer"处理重复索引值):
In [71]: pd.merge(lefth, righth, left_on=["key1", "key2"], right_index=True)
Out[71]:
key1 key2 data event1 event2
0 Ohio 2000 0 4 5
0 Ohio 2000 0 6 7
1 Ohio 2001 1 8 9
2 Ohio 2002 2 10 11
3 Nevada 2001 3 0 1
In [72]: pd.merge(lefth, righth, left_on=["key1", "key2"],
....: right_index=True, how="outer")
Out[72]:
key1 key2 data event1 event2
0 Ohio 2000 0 4 5
0 Ohio 2000 0 6 7
1 Ohio 2001 1 8 9
2 Ohio 2002 2 10 11
3 Nevada 2001 3 0 1
4 Nevada 2002 4 <NA> <NA>
4 Nevada 2000 <NA> 2 3
使用合并的两侧的索引也是可能的:
In [73]: left2 = pd.DataFrame([[1., 2.], [3., 4.], [5., 6.]],
....: index=["a", "c", "e"],
....: columns=["Ohio", "Nevada"]).astype("Int64")
In [74]: right2 = pd.DataFrame([[7., 8.], [9., 10.], [11., 12.], [13, 14]],
....: index=["b", "c", "d", "e"],
....: columns=["Missouri", "Alabama"]).astype("Int64")
In [75]: left2
Out[75]:
Ohio Nevada
a 1 2
c 3 4
e 5 6
In [76]: right2
Out[76]:
Missouri Alabama
b 7 8
c 9 10
d 11 12
e 13 14
In [77]: pd.merge(left2, right2, how="outer", left_index=True, right_index=True)
Out[77]:
Ohio Nevada Missouri Alabama
a 1 2 <NA> <NA>
b <NA> <NA> 7 8
c 3 4 9 10
d <NA> <NA> 11 12
e 5 6 13 14
DataFrame 有一个join实例方法,可以简化按索引合并。它还可以用于合并许多具有相同或类似索引但列不重叠的 DataFrame 对象。在前面的例子中,我们可以这样写:
In [78]: left2.join(right2, how="outer")
Out[78]:
Ohio Nevada Missouri Alabama
a 1 2 <NA> <NA>
b <NA> <NA> 7 8
c 3 4 9 10
d <NA> <NA> 11 12
e 5 6 13 14
与pandas.merge相比,DataFrame 的join方法默认在连接键上执行左连接。它还支持将传递的 DataFrame 的索引与调用 DataFrame 的某一列进行连接:
In [79]: left1.join(right1, on="key")
Out[79]:
key value group_val
0 a 0 3.5
1 b 1 7.0
2 a 2 3.5
3 a 3 3.5
4 b 4 7.0
5 c 5 NaN
您可以将此方法视为将数据“合并”到调用其join方法的对象中。
最后,对于简单的索引对索引合并,您可以将 DataFrame 的列表传递给join,作为使用下一节中描述的更一般的pandas.concat函数的替代方法:
In [80]: another = pd.DataFrame([[7., 8.], [9., 10.], [11., 12.], [16., 17.]],
....: index=["a", "c", "e", "f"],
....: columns=["New York", "Oregon"])
In [81]: another
Out[81]:
New York Oregon
a 7.0 8.0
c 9.0 10.0
e 11.0 12.0
f 16.0 17.0
In [82]: left2.join([right2, another])
Out[82]:
Ohio Nevada Missouri Alabama New York Oregon
a 1 2 <NA> <NA> 7.0 8.0
c 3 4 9 10 9.0 10.0
e 5 6 13 14 11.0 12.0
In [83]: left2.join([right2, another], how="outer")
Out[83]:
Ohio Nevada Missouri Alabama New York Oregon
a 1 2 <NA> <NA> 7.0 8.0
c 3 4 9 10 9.0 10.0
e 5 6 13 14 11.0 12.0
b <NA> <NA> 7 8 NaN NaN
d <NA> <NA> 11 12 NaN NaN
f <NA> <NA> <NA> <NA> 16.0 17.0
沿轴连接
另一种数据组合操作被称为连接或堆叠。NumPy 的concatenate函数可以使用 NumPy 数组来执行此操作:
In [84]: arr = np.arange(12).reshape((3, 4))
In [85]: arr
Out[85]:
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
In [86]: np.concatenate([arr, arr], axis=1)
Out[86]:
array([[ 0, 1, 2, 3, 0, 1, 2, 3],
[ 4, 5, 6, 7, 4, 5, 6, 7],
[ 8, 9, 10, 11, 8, 9, 10, 11]])
在 pandas 对象(如 Series 和 DataFrame)的上下文中,具有标记轴使您能够进一步推广数组连接。特别是,您有许多额外的考虑:
-
如果对象在其他轴上的索引不同,我们应该合并这些轴中的不同元素还是仅使用共同的值?
-
连接的数据块在结果对象中需要被识别吗?
-
“连接轴”中包含需要保留的数据吗?在许多情况下,DataFrame 中的默认整数标签在连接时最好被丢弃。
pandas 中的concat函数提供了一种一致的方法来解决这些问题。我将给出一些示例来说明它是如何工作的。假设我们有三个没有索引重叠的 Series:
In [87]: s1 = pd.Series([0, 1], index=["a", "b"], dtype="Int64")
In [88]: s2 = pd.Series([2, 3, 4], index=["c", "d", "e"], dtype="Int64")
In [89]: s3 = pd.Series([5, 6], index=["f", "g"], dtype="Int64")
使用这些对象的列表调用pandas.concat会将值和索引粘合在一起:
In [90]: s1
Out[90]:
a 0
b 1
dtype: Int64
In [91]: s2
Out[91]:
c 2
d 3
e 4
dtype: Int64
In [92]: s3
Out[92]:
f 5
g 6
dtype: Int64
In [93]: pd.concat([s1, s2, s3])
Out[93]:
a 0
b 1
c 2
d 3
e 4
f 5
g 6
dtype: Int64
默认情况下,pandas.concat沿着axis="index"工作,产生另一个 Series。如果传递axis="columns",结果将是一个 DataFrame:
In [94]: pd.concat([s1, s2, s3], axis="columns")
Out[94]:
0 1 2
a 0 <NA> <NA>
b 1 <NA> <NA>
c <NA> 2 <NA>
d <NA> 3 <NA>
e <NA> 4 <NA>
f <NA> <NA> 5
g <NA> <NA> 6
在这种情况下,另一个轴上没有重叠,您可以看到这是索引的并集("outer"连接)。您可以通过传递join="inner"来取交集:
In [95]: s4 = pd.concat([s1, s3])
In [96]: s4
Out[96]:
a 0
b 1
f 5
g 6
dtype: Int64
In [97]: pd.concat([s1, s4], axis="columns")
Out[97]:
0 1
a 0 0
b 1 1
f <NA> 5
g <NA> 6
In [98]: pd.concat([s1, s4], axis="columns", join="inner")
Out[98]:
0 1
a 0 0
b 1 1
在这个最后的例子中,"f"和"g"标签消失了,因为使用了join="inner"选项。
一个潜在的问题是结果中无法识别连接的片段。假设您希望在连接轴上创建一个分层索引。为此,请使用keys参数:
In [99]: result = pd.concat([s1, s1, s3], keys=["one", "two", "three"])
In [100]: result
Out[100]:
one a 0
b 1
two a 0
b 1
three f 5
g 6
dtype: Int64
In [101]: result.unstack()
Out[101]:
a b f g
one 0 1 <NA> <NA>
two 0 1 <NA> <NA>
three <NA> <NA> 5 6
在沿axis="columns"组合 Series 的情况下,keys变成了 DataFrame 的列标题:
In [102]: pd.concat([s1, s2, s3], axis="columns", keys=["one", "two", "three"])
Out[102]:
one two three
a 0 <NA> <NA>
b 1 <NA> <NA>
c <NA> 2 <NA>
d <NA> 3 <NA>
e <NA> 4 <NA>
f <NA> <NA> 5
g <NA> <NA> 6
相同的逻辑也适用于 DataFrame 对象:
In [103]: df1 = pd.DataFrame(np.arange(6).reshape(3, 2), index=["a", "b", "c"],
.....: columns=["one", "two"])
In [104]: df2 = pd.DataFrame(5 + np.arange(4).reshape(2, 2), index=["a", "c"],
.....: columns=["three", "four"])
In [105]: df1
Out[105]:
one two
a 0 1
b 2 3
c 4 5
In [106]: df2
Out[106]:
three four
a 5 6
c 7 8
In [107]: pd.concat([df1, df2], axis="columns", keys=["level1", "level2"])
Out[107]:
level1 level2
one two three four
a 0 1 5.0 6.0
b 2 3 NaN NaN
c 4 5 7.0 8.0
在这里,keys参数用于创建一个分层索引,其中第一级可以用于标识每个连接的 DataFrame 对象。
如果您传递的是对象字典而不是列表,那么字典的键将用于keys选项:
In [108]: pd.concat({"level1": df1, "level2": df2}, axis="columns")
Out[108]:
level1 level2
one two three four
a 0 1 5.0 6.0
b 2 3 NaN NaN
c 4 5 7.0 8.0
有一些额外的参数控制如何创建分层索引(参见表 8.3)。例如,我们可以使用names参数为创建的轴级别命名:
In [109]: pd.concat([df1, df2], axis="columns", keys=["level1", "level2"],
.....: names=["upper", "lower"])
Out[109]:
upper level1 level2
lower one two three four
a 0 1 5.0 6.0
b 2 3 NaN NaN
c 4 5 7.0 8.0
最后一个考虑因素涉及行索引不包含任何相关数据的 DataFrame:
In [110]: df1 = pd.DataFrame(np.random.standard_normal((3, 4)),
.....: columns=["a", "b", "c", "d"])
In [111]: df2 = pd.DataFrame(np.random.standard_normal((2, 3)),
.....: columns=["b", "d", "a"])
In [112]: df1
Out[112]:
a b c d
0 1.248804 0.774191 -0.319657 -0.624964
1 1.078814 0.544647 0.855588 1.343268
2 -0.267175 1.793095 -0.652929 -1.886837
In [113]: df2
Out[113]:
b d a
0 1.059626 0.644448 -0.007799
1 -0.449204 2.448963 0.667226
在这种情况下,您可以传递ignore_index=True,这将丢弃每个 DataFrame 的索引并仅连接列中的数据,分配一个新的默认索引:
In [114]: pd.concat([df1, df2], ignore_index=True)
Out[114]:
a b c d
0 1.248804 0.774191 -0.319657 -0.624964
1 1.078814 0.544647 0.855588 1.343268
2 -0.267175 1.793095 -0.652929 -1.886837
3 -0.007799 1.059626 NaN 0.644448
4 0.667226 -0.449204 NaN 2.448963
表 8.3 描述了pandas.concat函数的参数。
表 8.3:pandas.concat函数参数
| 参数 | 描述 |
|---|---|
objs |
要连接的 pandas 对象的列表或字典;这是唯一必需的参数 |
axis |
要沿着连接的轴;默认为沿着行连接(axis="index") |
join |
要么是"inner"要么是"outer"(默认为"outer");是否沿着其他轴相交(inner)或联合(outer)索引 |
keys |
与要连接的对象关联的值,形成沿着连接轴的分层索引;可以是任意值的列表或数组,元组的数组,或数组的列表(如果在levels中传递了多级数组) |
levels |
用作分层索引级别的特定索引,如果传递了键 |
names |
如果传递了keys和/或levels,则为创建的分层级别命名 |
verify_integrity |
检查连接对象中的新轴是否存在重复项,如果存在则引发异常;默认情况下(False)允许重复项 |
ignore_index |
不保留沿着连接axis的索引,而是生成一个新的range(total_length)索引 |
组合具有重叠部分的数据
还有另一种数据组合情况,既不能表示为合并操作也不能表示为连接操作。您可能有两个具有完全或部分重叠索引的数据集。作为一个激励性的例子,考虑 NumPy 的where函数,它执行数组导向的 if-else 表达式的等效操作:
In [115]: a = pd.Series([np.nan, 2.5, 0.0, 3.5, 4.5, np.nan],
.....: index=["f", "e", "d", "c", "b", "a"])
In [116]: b = pd.Series([0., np.nan, 2., np.nan, np.nan, 5.],
.....: index=["a", "b", "c", "d", "e", "f"])
In [117]: a
Out[117]:
f NaN
e 2.5
d 0.0
c 3.5
b 4.5
a NaN
dtype: float64
In [118]: b
Out[118]:
a 0.0
b NaN
c 2.0
d NaN
e NaN
f 5.0
dtype: float64
In [119]: np.where(pd.isna(a), b, a)
Out[119]: array([0. , 2.5, 0. , 3.5, 4.5, 5. ])
在这里,每当a中的值为空时,将选择b中的值,否则将选择a中的非空值。使用numpy.where不会检查索引标签是否对齐(甚至不需要对象具有相同的长度),因此如果要按索引对齐值,请使用 Seriescombine_first方法:
In [120]: a.combine_first(b)
Out[120]:
a 0.0
b 4.5
c 3.5
d 0.0
e 2.5
f 5.0
dtype: float64
对于 DataFrame,combine_first按列执行相同的操作,因此您可以将其视为使用传递的对象中的数据“修补”调用对象中的缺失数据:
In [121]: df1 = pd.DataFrame({"a": [1., np.nan, 5., np.nan],
.....: "b": [np.nan, 2., np.nan, 6.],
.....: "c": range(2, 18, 4)})
In [122]: df2 = pd.DataFrame({"a": [5., 4., np.nan, 3., 7.],
.....: "b": [np.nan, 3., 4., 6., 8.]})
In [123]: df1
Out[123]:
a b c
0 1.0 NaN 2
1 NaN 2.0 6
2 5.0 NaN 10
3 NaN 6.0 14
In [124]: df2
Out[124]:
a b
0 5.0 NaN
1 4.0 3.0
2 NaN 4.0
3 3.0 6.0
4 7.0 8.0
In [125]: df1.combine_first(df2)
Out[125]:
a b c
0 1.0 NaN 2.0
1 4.0 2.0 6.0
2 5.0 4.0 10.0
3 3.0 6.0 14.0
4 7.0 8.0 NaN
使用 DataFrame 对象的combine_first的输出将具有所有列名称的并集。
8.3 重塑和旋转
有许多用于重新排列表格数据的基本操作。这些操作被称为重塑或旋转操作。
使用分层索引进行重塑
分层索引提供了在 DataFrame 中重新排列数据的一致方法。有两个主要操作:
stack
这将从数据中的列旋转或旋转到行。
unstack
这将从行旋转到列。
我将通过一系列示例来说明这些操作。考虑一个具有字符串数组作为行和列索引的小 DataFrame:
In [126]: data = pd.DataFrame(np.arange(6).reshape((2, 3)),
.....: index=pd.Index(["Ohio", "Colorado"], name="state"),
.....: columns=pd.Index(["one", "two", "three"],
.....: name="number"))
In [127]: data
Out[127]:
number one two three
state
Ohio 0 1 2
Colorado 3 4 5
在这些数据上使用stack方法将列旋转为行,生成一个 Series:
In [128]: result = data.stack()
In [129]: result
Out[129]:
state number
Ohio one 0
two 1
three 2
Colorado one 3
two 4
three 5
dtype: int64
从具有分层索引的 Series 中,您可以使用unstack将数据重新排列回 DataFrame:
In [130]: result.unstack()
Out[130]:
number one two three
state
Ohio 0 1 2
Colorado 3 4 5
默认情况下,最内层级别被取消堆叠(与stack相同)。您可以通过传递级别编号或名称来取消堆叠不同的级别:
In [131]: result.unstack(level=0)
Out[131]:
state Ohio Colorado
number
one 0 3
two 1 4
three 2 5
In [132]: result.unstack(level="state")
Out[132]:
state Ohio Colorado
number
one 0 3
two 1 4
three 2 5
如果在每个子组中未找到级别中的所有值,则取消堆叠可能会引入缺失数据:
In [133]: s1 = pd.Series([0, 1, 2, 3], index=["a", "b", "c", "d"], dtype="Int64")
In [134]: s2 = pd.Series([4, 5, 6], index=["c", "d", "e"], dtype="Int64")
In [135]: data2 = pd.concat([s1, s2], keys=["one", "two"])
In [136]: data2
Out[136]:
one a 0
b 1
c 2
d 3
two c 4
d 5
e 6
dtype: Int64
堆叠默认会过滤掉缺失数据,因此该操作更容易反转:
In [137]: data2.unstack()
Out[137]:
a b c d e
one 0 1 2 3 <NA>
two <NA> <NA> 4 5 6
In [138]: data2.unstack().stack()
Out[138]:
one a 0
b 1
c 2
d 3
two c 4
d 5
e 6
dtype: Int64
In [139]: data2.unstack().stack(dropna=False)
Out[139]:
one a 0
b 1
c 2
d 3
e <NA>
two a <NA>
b <NA>
c 4
d 5
e 6
dtype: Int64
当您在 DataFrame 中取消堆叠时,取消堆叠的级别将成为结果中的最低级别:
In [140]: df = pd.DataFrame({"left": result, "right": result + 5},
.....: columns=pd.Index(["left", "right"], name="side"))
In [141]: df
Out[141]:
side left right
state number
Ohio one 0 5
two 1 6
three 2 7
Colorado one 3 8
two 4 9
three 5 10
In [142]: df.unstack(level="state")
Out[142]:
side left right
state Ohio Colorado Ohio Colorado
number
one 0 3 5 8
two 1 4 6 9
three 2 5 7 10
与unstack一样,调用stack时,我们可以指定要堆叠的轴的名称:
In [143]: df.unstack(level="state").stack(level="side")
Out[143]:
state Colorado Ohio
number side
one left 3 0
right 8 5
two left 4 1
right 9 6
three left 5 2
right 10 7
将“长”格式旋转为“宽”格式
在数据库和 CSV 文件中存储多个时间序列的常见方法有时被称为长或堆叠格式。在此格式中,单个值由表中的一行表示,而不是每行多个值。
让我们加载一些示例数据,并进行少量时间序列整理和其他数据清理:
In [144]: data = pd.read_csv("examples/macrodata.csv")
In [145]: data = data.loc[:, ["year", "quarter", "realgdp", "infl", "unemp"]]
In [146]: data.head()
Out[146]:
year quarter realgdp infl unemp
0 1959 1 2710.349 0.00 5.8
1 1959 2 2778.801 2.34 5.1
2 1959 3 2775.488 2.74 5.3
3 1959 4 2785.204 0.27 5.6
4 1960 1 2847.699 2.31 5.2
首先,我使用pandas.PeriodIndex(表示时间间隔而不是时间点),在 Ch 11: Time Series 中更详细地讨论,将year和quarter列组合起来,将索引设置为每个季度末的datetime值:
In [147]: periods = pd.PeriodIndex(year=data.pop("year"),
.....: quarter=data.pop("quarter"),
.....: name="date")
In [148]: periods
Out[148]:
PeriodIndex(['1959Q1', '1959Q2', '1959Q3', '1959Q4', '1960Q1', '1960Q2',
'1960Q3', '1960Q4', '1961Q1', '1961Q2',
...
'2007Q2', '2007Q3', '2007Q4', '2008Q1', '2008Q2', '2008Q3',
'2008Q4', '2009Q1', '2009Q2', '2009Q3'],
dtype='period[Q-DEC]', name='date', length=203)
In [149]: data.index = periods.to_timestamp("D")
In [150]: data.head()
Out[150]:
realgdp infl unemp
date
1959-01-01 2710.349 0.00 5.8
1959-04-01 2778.801 2.34 5.1
1959-07-01 2775.488 2.74 5.3
1959-10-01 2785.204 0.27 5.6
1960-01-01 2847.699 2.31 5.2
在这里,我在 DataFrame 上使用了pop方法,该方法返回一个列,同时从 DataFrame 中删除它。
然后,我选择一部分列,并给columns索引命名为"item":
In [151]: data = data.reindex(columns=["realgdp", "infl", "unemp"])
In [152]: data.columns.name = "item"
In [153]: data.head()
Out[153]:
item realgdp infl unemp
date
1959-01-01 2710.349 0.00 5.8
1959-04-01 2778.801 2.34 5.1
1959-07-01 2775.488 2.74 5.3
1959-10-01 2785.204 0.27 5.6
1960-01-01 2847.699 2.31 5.2
最后,我使用stack重新塑造,使用reset_index将新的索引级别转换为列,最后给包含数据值的列命名为"value":
In [154]: long_data = (data.stack()
.....: .reset_index()
.....: .rename(columns={0: "value"}))
现在,ldata看起来像这样:
In [155]: long_data[:10]
Out[155]:
date item value
0 1959-01-01 realgdp 2710.349
1 1959-01-01 infl 0.000
2 1959-01-01 unemp 5.800
3 1959-04-01 realgdp 2778.801
4 1959-04-01 infl 2.340
5 1959-04-01 unemp 5.100
6 1959-07-01 realgdp 2775.488
7 1959-07-01 infl 2.740
8 1959-07-01 unemp 5.300
9 1959-10-01 realgdp 2785.204
在这种所谓的长格式中,每个时间序列的每一行在表中代表一个单独的观察。
数据经常以这种方式存储在关系型 SQL 数据库中,因为固定的模式(列名和数据类型)允许item列中的不同值的数量随着数据添加到表中而改变。在前面的例子中,date和item通常会成为主键(在关系数据库术语中),提供关系完整性和更容易的连接。在某些情况下,以这种格式处理数据可能更加困难;您可能更喜欢拥有一个 DataFrame,其中包含一个以date列中的时间戳为索引的每个不同item值的列。DataFrame 的pivot方法正好执行这种转换:
In [156]: pivoted = long_data.pivot(index="date", columns="item",
.....: values="value")
In [157]: pivoted.head()
Out[157]:
item infl realgdp unemp
date
1959-01-01 0.00 2710.349 5.8
1959-04-01 2.34 2778.801 5.1
1959-07-01 2.74 2775.488 5.3
1959-10-01 0.27 2785.204 5.6
1960-01-01 2.31 2847.699 5.2
传递的前两个值分别是要使用的列,作为行和列索引,最后是一个可选的值列,用于填充 DataFrame。假设您有两个值列,希望同时重塑:
In [159]: long_data["value2"] = np.random.standard_normal(len(long_data))
In [160]: long_data[:10]
Out[160]:
date item value value2
0 1959-01-01 realgdp 2710.349 0.802926
1 1959-01-01 infl 0.000 0.575721
2 1959-01-01 unemp 5.800 1.381918
3 1959-04-01 realgdp 2778.801 0.000992
4 1959-04-01 infl 2.340 -0.143492
5 1959-04-01 unemp 5.100 -0.206282
6 1959-07-01 realgdp 2775.488 -0.222392
7 1959-07-01 infl 2.740 -1.682403
8 1959-07-01 unemp 5.300 1.811659
9 1959-10-01 realgdp 2785.204 -0.351305
通过省略最后一个参数,您可以获得一个具有分层列的 DataFrame:
In [161]: pivoted = long_data.pivot(index="date", columns="item")
In [162]: pivoted.head()
Out[162]:
value value2
item infl realgdp unemp infl realgdp unemp
date
1959-01-01 0.00 2710.349 5.8 0.575721 0.802926 1.381918
1959-04-01 2.34 2778.801 5.1 -0.143492 0.000992 -0.206282
1959-07-01 2.74 2775.488 5.3 -1.682403 -0.222392 1.811659
1959-10-01 0.27 2785.204 5.6 0.128317 -0.351305 -1.313554
1960-01-01 2.31 2847.699 5.2 -0.615939 0.498327 0.174072
In [163]: pivoted["value"].head()
Out[163]:
item infl realgdp unemp
date
1959-01-01 0.00 2710.349 5.8
1959-04-01 2.34 2778.801 5.1
1959-07-01 2.74 2775.488 5.3
1959-10-01 0.27 2785.204 5.6
1960-01-01 2.31 2847.699 5.2
请注意,pivot等同于使用set_index创建一个分层索引,然后调用unstack:
In [164]: unstacked = long_data.set_index(["date", "item"]).unstack(level="item")
In [165]: unstacked.head()
Out[165]:
value value2
item infl realgdp unemp infl realgdp unemp
date
1959-01-01 0.00 2710.349 5.8 0.575721 0.802926 1.381918
1959-04-01 2.34 2778.801 5.1 -0.143492 0.000992 -0.206282
1959-07-01 2.74 2775.488 5.3 -1.682403 -0.222392 1.811659
1959-10-01 0.27 2785.204 5.6 0.128317 -0.351305 -1.313554
1960-01-01 2.31 2847.699 5.2 -0.615939 0.498327 0.174072
从“宽”格式到“长”格式的旋转
DataFrame 的pivot的逆操作是pandas.melt。与在新的 DataFrame 中将一个列转换为多个不同,它将多个列合并为一个,生成一个比输入更长的 DataFrame。让我们看一个例子:
In [167]: df = pd.DataFrame({"key": ["foo", "bar", "baz"],
.....: "A": [1, 2, 3],
.....: "B": [4, 5, 6],
.....: "C": [7, 8, 9]})
In [168]: df
Out[168]:
key A B C
0 foo 1 4 7
1 bar 2 5 8
2 baz 3 6 9
"key"列可以是一个组指示器,其他列是数据值。在使用pandas.melt时,我们必须指示哪些列(如果有的话)是组指示器。让我们在这里只使用"key"作为唯一的组指示器:
In [169]: melted = pd.melt(df, id_vars="key")
In [170]: melted
Out[170]:
key variable value
0 foo A 1
1 bar A 2
2 baz A 3
3 foo B 4
4 bar B 5
5 baz B 6
6 foo C 7
7 bar C 8
8 baz C 9
使用pivot,我们可以重新塑造回原始布局:
In [171]: reshaped = melted.pivot(index="key", columns="variable",
.....: values="value")
In [172]: reshaped
Out[172]:
variable A B C
key
bar 2 5 8
baz 3 6 9
foo 1 4 7
由于pivot的结果从用作行标签的列创建索引,我们可能希望使用reset_index将数据移回到列中:
In [173]: reshaped.reset_index()
Out[173]:
variable key A B C
0 bar 2 5 8
1 baz 3 6 9
2 foo 1 4 7
您还可以指定要用作“值”列的列的子集:
In [174]: pd.melt(df, id_vars="key", value_vars=["A", "B"])
Out[174]:
key variable value
0 foo A 1
1 bar A 2
2 baz A 3
3 foo B 4
4 bar B 5
5 baz B 6
pandas.melt也可以在没有任何组标识符的情况下使用:
In [175]: pd.melt(df, value_vars=["A", "B", "C"])
Out[175]:
variable value
0 A 1
1 A 2
2 A 3
3 B 4
4 B 5
5 B 6
6 C 7
7 C 8
8 C 9
In [176]: pd.melt(df, value_vars=["key", "A", "B"])
Out[176]:
variable value
0 key foo
1 key bar
2 key baz
3 A 1
4 A 2
5 A 3
6 B 4
7 B 5
8 B 6
8.4 结论
现在您已经掌握了一些关于 pandas 的基础知识,用于数据导入、清理和重新组织,我们准备继续使用 matplotlib 进行数据可视化。当我们讨论更高级的分析时,我们将回到书中的其他领域来探索 pandas 的更多功能。
九、绘图和可视化
原文:
wesmckinney.com/book/plotting-and-visualization译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
制作信息丰富的可视化(有时称为*图)是数据分析中最重要的任务之一。它可能是探索过程的一部分,例如,帮助识别异常值或所需的数据转换,或者作为生成模型想法的一种方式。对于其他人,构建用于网络的交互式可视化可能是最终目标。Python 有许多附加库用于制作静态或动态可视化,但我主要关注matplotlib和构建在其之上的库。
matplotlib 是一个桌面绘图包,旨在创建适合出版的图形和图表。该项目由 John Hunter 于 2002 年发起,旨在在 Python 中实现类似 MATLAB 的绘图界面。matplotlib 和 IPython 社区合作简化了从 IPython shell(现在是 Jupyter 笔记本)进行交互式绘图。matplotlib 支持所有操作系统上的各种 GUI 后端,并且可以将可视化导出为所有常见的矢量和光栅图形格式(PDF、SVG、JPG、PNG、BMP、GIF 等)。除了一些图表外,本书中几乎所有的图形都是使用 matplotlib 生成的。
随着时间的推移,matplotlib 衍生出了许多用于数据可视化的附加工具包,这些工具包使用 matplotlib 进行底层绘图。其中之一是seaborn,我们将在本章后面探讨。
在本章中跟随代码示例的最简单方法是在 Jupyter 笔记本中输出图形。要设置这个,可以在 Jupyter 笔记本中执行以下语句:
%matplotlib inline
注意
自 2012 年第一版以来,已经创建了许多新的数据可视化库,其中一些(如 Bokeh 和 Altair)利用现代网络技术创建交互式可视化,与 Jupyter 笔记本很好地集成。与在本书中使用多个可视化工具不同,我决定坚持使用 matplotlib 来教授基础知识,特别是因为 pandas 与 matplotlib 有很好的集成。您可以根据本章的原则学习如何使用其他可视化库。
9.1 简要的 matplotlib API 入门
使用 matplotlib 时,我们使用以下导入约定:
In [13]: import matplotlib.pyplot as plt
在 Jupyter 中运行%matplotlib notebook(或在 IPython 中运行%matplotlib),我们可以尝试创建一个简单的图。如果一切设置正确,应该会出现一个类似 Simple line plot 的线图:
In [14]: data = np.arange(10)
In [15]: data
Out[15]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
In [16]: plt.plot(data)

图 9.1:简单线图
虽然像 seaborn 和 pandas 内置绘图函数将处理许多制作图形的琐碎细节,但如果您希望自定义超出提供的函数选项之外的内容,您需要了解一些关于 matplotlib API 的知识。
注意
本书中没有足够的空间来全面介绍 matplotlib 的功能广度和深度。它应该足以教会您如何上手。matplotlib 图库和文档是学习高级功能的最佳资源。
图和子图
matplotlib 中的绘图位于 Figure 对象中。您可以使用 plt.figure 创建一个新的图:
In [17]: fig = plt.figure()
在 IPython 中,如果您首先运行 %matplotlib 来设置 matplotlib 集成,将会出现一个空白绘图窗口,但在 Jupyter 中,直到我们使用更多命令之前,什么都不会显示。
plt.figure 有许多选项;特别是,如果保存到磁盘,figsize 将保证图的特定大小和纵横比。
您不能在空白图中制作绘图。您必须使用 add_subplot 创建一个或多个 subplots:
In [18]: ax1 = fig.add_subplot(2, 2, 1)
这意味着图应该是 2 × 2(因此总共最多四个绘图),我们选择了四个子图中的第一个(从 1 编号)。如果您创建下两个子图,您将得到一个看起来像 一个空的 matplotlib 图,带有三个子图 的可视化:
In [19]: ax2 = fig.add_subplot(2, 2, 2)
In [20]: ax3 = fig.add_subplot(2, 2, 3)

图 9.2:一个空的 matplotlib 图,带有三个子图
提示:
使用 Jupyter 笔记本的一个细微之处是,每次评估单元格后绘图都会重置,因此您必须将所有绘图命令放在一个单独的笔记本单元格中。
在这里,我们在同一个单元格中运行所有这些命令:
fig = plt.figure()
ax1 = fig.add_subplot(2, 2, 1)
ax2 = fig.add_subplot(2, 2, 2)
ax3 = fig.add_subplot(2, 2, 3)
这些绘图轴对象有各种方法,可以创建不同类型的绘图,最好使用轴方法而不是像 plt.plot 这样的顶级绘图函数。例如,我们可以使用 plot 方法制作一条线图(参见单个绘图后的数据可视化):
In [21]: ax3.plot(np.random.standard_normal(50).cumsum(), color="black",
....: linestyle="dashed")

图 9.3:单个绘图后的数据可视化
当您运行此命令时,您可能会注意到类似 <matplotlib.lines.Line2D at ...> 的输出。matplotlib 返回引用刚刚添加的绘图子组件的对象。大多数情况下,您可以安全地忽略此输出,或者您可以在行末加上分号以抑制输出。
附加选项指示 matplotlib 绘制一条黑色虚线。这里由 fig.add_subplot 返回的对象是 AxesSubplot 对象,您可以通过调用每个实例方法直接在其他空子图上绘制(参见添加额外绘图后的数据可视化):
In [22]: ax1.hist(np.random.standard_normal(100), bins=20, color="black", alpha=0
.3);
In [23]: ax2.scatter(np.arange(30), np.arange(30) + 3 * np.random.standard_normal
(30));

图 9.4:添加额外绘图后的数据可视化
alpha=0.3 样式选项设置了叠加绘图的透明度。
您可以在 matplotlib 文档 中找到绘图类型的全面目录。
为了更方便地创建子图网格,matplotlib 包括一个 plt.subplots 方法,它创建一个新图并返回一个包含创建的子图对象的 NumPy 数组:
In [25]: fig, axes = plt.subplots(2, 3)
In [26]: axes
Out[26]:
array([[<Axes: >, <Axes: >, <Axes: >],
[<Axes: >, <Axes: >, <Axes: >]], dtype=object)
然后,axes 数组可以像二维数组一样索引;例如,axes[0, 1] 指的是顶部行中心的子图。您还可以使用 sharex 和 sharey 指示子图应具有相同的 x 或 y 轴。当您在相同比例上比较数据时,这可能很有用;否则,matplotlib 会独立自动缩放绘图限制。有关此方法的更多信息,请参见 表 9.1。
表 9.1:matplotlib.pyplot.subplots 选项
| 参数 | 描述 |
|---|---|
nrows |
子图的行数 |
ncols |
子图的列数 |
sharex |
所有子图应使用相同的 x 轴刻度(调整 xlim 将影响所有子图) |
sharey |
所有子图应使用相同的 y 轴刻度(调整 ylim 将影响所有子图) |
subplot_kw |
传递给 add_subplot 调用的关键字字典,用于创建每个子图 |
**fig_kw |
创建图时使用subplots的附加关键字,例如plt.subplots(2, 2, figsize=(8, 6)) |
调整子图周围的间距
默认情况下,matplotlib 在子图周围留有一定量的填充和子图之间的间距。这些间距都是相对于绘图的高度和宽度指定的,因此如果您通过编程或使用 GUI 窗口手动调整绘图大小,绘图将动态调整自身。您可以使用Figure对象上的subplots_adjust方法更改间距:
subplots_adjust(left=None, bottom=None, right=None, top=None,
wspace=None, hspace=None)
wspace和hspace控制子图之间使用的百分比图宽度和图高度的间距。这里是一个您可以在 Jupyter 中执行的小例子,我将间距缩小到零(参见没有子图间距的数据可视化):
fig, axes = plt.subplots(2, 2, sharex=True, sharey=True)
for i in range(2):
for j in range(2):
axes[i, j].hist(np.random.standard_normal(500), bins=50,
color="black", alpha=0.5)
fig.subplots_adjust(wspace=0, hspace=0)

图 9.5:没有子图间距的数据可视化
您可能会注意到轴标签重叠。matplotlib 不会检查标签是否重叠,因此在这种情况下,您需要通过指定显式刻度位置和刻度标签自行修复标签(我们将在后面的部分刻度、标签和图例中看到如何做到这一点)。
颜色、标记和线型
matplotlib 的线plot函数接受 x 和 y 坐标数组以及可选的颜色样式选项。例如,要用绿色虚线绘制x与y,您可以执行:
ax.plot(x, y, linestyle="--", color="green")
提供了许多常用颜色的颜色名称,但您可以通过指定其十六进制代码(例如,"#CECECE")来使用光谱上的任何颜色。您可以查看plt.plot的文档字符串以查看一些支持的线型。在线文档中提供了更全面的参考资料。
线图还可以具有标记来突出实际数据点。由于 matplotlib 的plot函数创建连续线图,插值点之间的插值,有时可能不清楚点位于何处。标记可以作为附加样式选项提供(参见带有标记的线图):
In [31]: ax = fig.add_subplot()
In [32]: ax.plot(np.random.standard_normal(30).cumsum(), color="black",
....: linestyle="dashed", marker="o");

图 9.6:带有标记的线图
对于线图,您会注意到默认情况下后续点是线性插值的。这可以通过drawstyle选项进行更改(参见带有不同 drawstyle 选项的线图):
In [34]: fig = plt.figure()
In [35]: ax = fig.add_subplot()
In [36]: data = np.random.standard_normal(30).cumsum()
In [37]: ax.plot(data, color="black", linestyle="dashed", label="Default");
In [38]: ax.plot(data, color="black", linestyle="dashed",
....: drawstyle="steps-post", label="steps-post");
In [39]: ax.legend()

图 9.7:带有不同 drawstyle 选项的线图
在这里,由于我们将label参数传递给plot,我们能够使用ax.legend创建一个图例,以标识每条线。我在刻度、标签和图例中更多地讨论图例。
注意
无论您在绘制数据时是否传递了label选项,都必须调用ax.legend来创建图例。
刻度、标签和图例
大多数类型的绘图装饰都可以通过 matplotlib 轴对象上的方法访问。这包括xlim、xticks和xticklabels等方法。它们分别控制绘图范围、刻度位置和刻度标签。它们可以以两种方式使用:
-
不带参数调用返回当前参数值(例如,
ax.xlim()返回当前 x 轴绘图范围) -
带参数调用设置参数值(例如,
ax.xlim([0, 10])将 x 轴范围设置为 0 到 10)
所有这些方法都作用于活动或最近创建的AxesSubplot。每个对应于 subplot 对象本身的两种方法;在xlim的情况下,这些方法是ax.get_xlim和ax.set_xlim。
设置标题、轴标签、刻度和刻度标签
为了说明如何自定义坐标轴,我将创建一个简单的图和一个随机漫步的绘图(参见用于说明 xticks 的简单绘图(带有默认标签)):
In [40]: fig, ax = plt.subplots()
In [41]: ax.plot(np.random.standard_normal(1000).cumsum());

图 9.8:用于说明 xticks 的简单图表(带有默认标签)
要更改 x 轴刻度,最简单的方法是使用set_xticks和set_xticklabels。前者指示 matplotlib 在数据范围内放置刻度的位置;默认情况下,这些位置也将是标签。但是我们可以使用set_xticklabels设置任何其他值作为标签:
In [42]: ticks = ax.set_xticks([0, 250, 500, 750, 1000])
In [43]: labels = ax.set_xticklabels(["one", "two", "three", "four", "five"],
....: rotation=30, fontsize=8)
rotation选项将 x 轴刻度标签设置为 30 度旋转。最后,set_xlabel为 x 轴命名,set_title为子图标题(请参见用于说明自定义 xticks 的简单图表以查看生成的图):
In [44]: ax.set_xlabel("Stages")
Out[44]: Text(0.5, 6.666666666666652, 'Stages')
In [45]: ax.set_title("My first matplotlib plot")

图 9.9:用于说明自定义 xticks 的简单图表
修改 y 轴的过程与此示例中的x替换为y相同。axes 类有一个set方法,允许批量设置绘图属性。从前面的示例中,我们也可以这样写:
ax.set(title="My first matplotlib plot", xlabel="Stages")
添加图例
图例是识别图表元素的另一个关键元素。有几种方法可以添加图例。最简单的方法是在添加每个图表元素时传递label参数:
In [46]: fig, ax = plt.subplots()
In [47]: ax.plot(np.random.randn(1000).cumsum(), color="black", label="one");
In [48]: ax.plot(np.random.randn(1000).cumsum(), color="black", linestyle="dashed
",
....: label="two");
In [49]: ax.plot(np.random.randn(1000).cumsum(), color="black", linestyle="dotted
",
....: label="three");
一旦您完成了这一步,您可以调用ax.legend()来自动创建图例。生成的图表在带有三条线和图例的简单图表中:
In [50]: ax.legend()

图 9.10:带有三条线和图例的简单图表
legend方法有几个其他选项可用于位置loc参数。有关更多信息,请参阅文档字符串(使用ax.legend?)。
loc图例选项告诉 matplotlib 在哪里放置图例。默认值是"best",它会尝试选择一个最不起眼的位置。要从图例中排除一个或多个元素,请不传递标签或传递label="_nolegend_"。
注释和在子图上绘制
除了标准的绘图类型,您可能希望绘制自己的绘图注释,这可能包括文本、箭头或其他形状。您可以使用text、arrow和annotate函数添加注释和文本。text在给定坐标(x, y)处绘制文本,可选的自定义样式:
ax.text(x, y, "Hello world!",
family="monospace", fontsize=10)
注释可以绘制文本和箭头,并适当排列。例如,让我们绘制自 2007 年以来的标准普尔 500 指数收盘价(从 Yahoo! Finance 获取),并用 2008-2009 年金融危机的一些重要日期进行注释。您可以在 Jupyter 笔记本中的单个单元格中运行此代码示例。查看 2008-2009 年金融危机中的重要日期以查看结果:
from datetime import datetime
fig, ax = plt.subplots()
data = pd.read_csv("examples/spx.csv", index_col=0, parse_dates=True)
spx = data["SPX"]
spx.plot(ax=ax, color="black")
crisis_data = [
(datetime(2007, 10, 11), "Peak of bull market"),
(datetime(2008, 3, 12), "Bear Stearns Fails"),
(datetime(2008, 9, 15), "Lehman Bankruptcy")
]
for date, label in crisis_data:
ax.annotate(label, xy=(date, spx.asof(date) + 75),
xytext=(date, spx.asof(date) + 225),
arrowprops=dict(facecolor="black", headwidth=4, width=2,
headlength=4),
horizontalalignment="left", verticalalignment="top")
# Zoom in on 2007-2010
ax.set_xlim(["1/1/2007", "1/1/2011"])
ax.set_ylim([600, 1800])
ax.set_title("Important dates in the 2008–2009 financial crisis")

图 9.11:2008-2009 年金融危机中的重要日期
在这个图表中有几个重要的要点需要强调。ax.annotate方法可以在指定的 x 和 y 坐标处绘制标签。我们使用set_xlim和set_ylim方法手动设置绘图的起始和结束边界,而不是使用 matplotlib 的默认值。最后,ax.set_title为绘图添加了一个主标题。
请查看在线 matplotlib 画廊,了解更多注释示例以供学习。
绘制形状需要更多的注意。matplotlib 有代表许多常见形状的对象,称为patches。其中一些,如Rectangle和Circle,可以在matplotlib.pyplot中找到,但完整的集合位于matplotlib.patches中。
要向图表添加形状,您需要创建补丁对象,并通过将补丁传递给ax.add_patch将其添加到子图ax中(请参见由三个不同补丁组成的数据可视化):
fig, ax = plt.subplots()
rect = plt.Rectangle((0.2, 0.75), 0.4, 0.15, color="black", alpha=0.3)
circ = plt.Circle((0.7, 0.2), 0.15, color="blue", alpha=0.3)
pgon = plt.Polygon([[0.15, 0.15], [0.35, 0.4], [0.2, 0.6]],
color="green", alpha=0.5)
ax.add_patch(rect)
ax.add_patch(circ)
ax.add_patch(pgon)

图 9.12:由三个不同补丁组成的数据可视化
如果您查看许多熟悉的绘图类型的实现,您会发现它们是由补丁组装而成的。
保存图表到文件
您可以使用图形对象的savefig实例方法将活动图形保存到文件。例如,要保存图形的 SVG 版本,您只需输入:
fig.savefig("figpath.svg")
文件类型是从文件扩展名中推断的。因此,如果您使用.pdf,您将得到一个 PDF。我经常用于发布图形的一个重要选项是dpi,它控制每英寸的分辨率。要获得相同的图形作为 400 DPI 的 PNG,您可以执行:
fig.savefig("figpath.png", dpi=400)
有关savefig的一些其他选项,请参见表 9.2。要获取全面的列表,请参考 IPython 或 Jupyter 中的文档字符串。
表 9.2:一些fig.savefig选项
| 参数 | 描述 |
|---|---|
fname |
包含文件路径或 Python 文件对象的字符串。图形格式从文件扩展名中推断(例如,.pdf表示 PDF,.png表示 PNG)。 |
dpi |
每英寸点数的图形分辨率;在 IPython 中默认为 100,在 Jupyter 中默认为 72,但可以进行配置。 |
facecolor, edgecolor |
子图外部的图形背景颜色;默认为"w"(白色)。 |
format |
要使用的显式文件格式("png"、"pdf"、"svg"、"ps"、"eps"等)。 |
matplotlib 配置
matplotlib 预先配置了色彩方案和默认设置,主要用于准备出版图。幸运的是,几乎所有默认行为都可以通过全局参数进行自定义,这些参数控制图形大小、子图间距、颜色、字体大小、网格样式等。从 Python 编程方式修改配置的一种方法是使用rc方法;例如,要将全局默认图形大小设置为 10×10,可以输入:
plt.rc("figure", figsize=(10, 10))
所有当前的配置设置都可以在plt.rcParams字典中找到,并且可以通过调用plt.rcdefaults()函数将其恢复为默认值。
rc的第一个参数是您希望自定义的组件,例如"figure"、"axes"、"xtick"、"ytick"、"grid"、"legend"或其他许多选项。之后可以跟随一系列关键字参数,指示新的参数。在程序中写下选项的便捷方式是作为一个字典:
plt.rc("font", family="monospace", weight="bold", size=8)
要进行更广泛的自定义并查看所有选项列表,matplotlib 附带了一个配置文件matplotlibrc,位于matplotlib/mpl-data目录中。如果您自定义此文件并将其放在名为.matplotlibrc的主目录中,每次使用 matplotlib 时都会加载它。
正如我们将在下一节中看到的,seaborn 包具有几个内置的绘图主题或样式,这些主题或样式在内部使用 matplotlib 的配置系统。
9.2 使用 pandas 和 seaborn 绘图
matplotlib 可以是一个相当低级的工具。您可以从其基本组件中组装图表:数据显示(即绘图类型:线条、柱状图、箱线图、散点图、等高线图等)、图例、标题、刻度标签和其他注释。
在 pandas 中,我们可能有多列数据,以及行和列标签。pandas 本身具有内置方法,简化了从 DataFrame 和 Series 对象创建可视化的过程。另一个库是seaborn,这是一个建立在 matplotlib 之上的高级统计图形库。seaborn 简化了创建许多常见可视化类型的过程。
线图
Series 和 DataFrame 具有plot属性,用于创建一些基本的绘图类型。默认情况下,plot()生成线图(参见简单 Series 绘图):
In [61]: s = pd.Series(np.random.standard_normal(10).cumsum(), index=np.arange(0,
100, 10))
In [62]: s.plot()

图 9.13:简单 Series 绘图
Series 对象的索引被传递给 matplotlib 以在 x 轴上绘制,尽管您可以通过传递 use_index=False 来禁用此功能。x 轴刻度和限制可以通过 xticks 和 xlim 选项进行调整,y 轴分别通过 yticks 和 ylim 进行调整。请参见 表 9.3 以获取 plot 选项的部分列表。我将在本节中评论其中一些,并留下其余的供您探索。
表 9.3:Series.plot 方法参数
| 参数 | 描述 |
|---|---|
label |
图例标签 |
ax |
要绘制的 matplotlib 子图对象;如果未传递任何内容,则使用活动的 matplotlib 子图 |
style |
样式字符串,如 "ko--",传递给 matplotlib |
alpha |
图形填充不透明度(从 0 到 1) |
kind |
可以是 "area", "bar", "barh", "density", "hist", "kde", "line", 或 "pie";默认为 "line" |
figsize |
要创建的图形对象的大小 |
logx |
在 x 轴上进行对数缩放,传递 True;传递 "sym" 以进行允许负值的对称对数缩放 |
logy |
在 y 轴上进行对数缩放,传递 True;传递 "sym" 以进行允许负值的对称对数缩放 |
title |
用于图的标题 |
use_index |
使用对象索引作为刻度标签 |
rot |
刻度标签的旋转(0 到 360) |
xticks |
用于 x 轴刻度的值 |
yticks |
用于 y 轴刻度的值 |
xlim |
x 轴限制(例如,[0, 10]) |
ylim |
y 轴限制 |
grid |
显示坐标轴网格(默认关闭) |
大多数 pandas 的绘图方法都接受一个可选的 ax 参数,可以是一个 matplotlib 子图对象。这样可以在网格布局中更灵活地放置子图。
DataFrame 的 plot 方法将其每列作为不同的线绘制在同一个子图上,自动创建图例(请参见 简单的 DataFrame 绘图):
In [63]: df = pd.DataFrame(np.random.standard_normal((10, 4)).cumsum(0),
....: columns=["A", "B", "C", "D"],
....: index=np.arange(0, 100, 10))
In [64]: plt.style.use('grayscale')
In [65]: df.plot()

图 9.14:简单的 DataFrame 绘图
注意
这里我使用了 plt.style.use('grayscale') 来切换到更适合黑白出版的颜色方案,因为一些读者可能无法看到完整的彩色图。
plot 属性包含不同绘图类型的方法“家族”。例如,df.plot() 等同于 df.plot.line()。我们将在接下来探索其中一些方法。
注意
plot 的其他关键字参数会传递给相应的 matplotlib 绘图函数,因此您可以通过学习更多关于 matplotlib API 的知识来进一步自定义这些图。
DataFrame 有许多选项,允许对列的处理方式进行一定的灵活性,例如,是否将它们全部绘制在同一个子图上,还是创建单独的子图。更多信息请参见 表 9.4。
表 9.4:DataFrame 特定的绘图参数
| 参数 | 描述 |
|---|---|
subplots |
在单独的子图中绘制每个 DataFrame 列 |
layouts |
2 元组(行数,列数),提供子图的布局 |
sharex |
如果 subplots=True,共享相同的 x 轴,链接刻度和限制 |
sharey |
如果 subplots=True,共享相同的 y 轴 |
legend |
添加子图图例(默认为 True) |
sort_columns |
按字母顺序绘制列;默认使用现有列顺序 |
注意
有关时间序列绘图,请参见 第十一章:时间序列。
条形图
plot.bar() 和 plot.barh() 分别绘制垂直和水平条形图。在这种情况下,Series 或 DataFrame 的索引将用作 x(bar)或 y(barh)刻度(请参见 水平和垂直条形图):
In [66]: fig, axes = plt.subplots(2, 1)
In [67]: data = pd.Series(np.random.uniform(size=16), index=list("abcdefghijklmno
p"))
In [68]: data.plot.bar(ax=axes[0], color="black", alpha=0.7)
Out[68]: <Axes: >
In [69]: data.plot.barh(ax=axes[1], color="black", alpha=0.7)

图 9.15:水平和垂直条形图
使用 DataFrame,条形图将每行中的值分组在条形图中,侧边显示,每个值一个条形图。请参见 DataFrame 条形图:
In [71]: df = pd.DataFrame(np.random.uniform(size=(6, 4)),
....: index=["one", "two", "three", "four", "five", "six"],
....: columns=pd.Index(["A", "B", "C", "D"], name="Genus"))
In [72]: df
Out[72]:
Genus A B C D
one 0.370670 0.602792 0.229159 0.486744
two 0.420082 0.571653 0.049024 0.880592
three 0.814568 0.277160 0.880316 0.431326
four 0.374020 0.899420 0.460304 0.100843
five 0.433270 0.125107 0.494675 0.961825
six 0.601648 0.478576 0.205690 0.560547
In [73]: df.plot.bar()

图 9.16:DataFrame 条形图
请注意,DataFrame 列上的“种属”名称用于标题图例。
我们通过传递stacked=True从 DataFrame 创建堆叠条形图,导致每行中的值水平堆叠在一起(参见 DataFrame 堆叠条形图):
In [75]: df.plot.barh(stacked=True, alpha=0.5)

图 9.17:DataFrame 堆叠条形图
注意
一个有用的条形图的制作方法是使用value_counts来可视化 Series 的值频率:s.value_counts().plot.bar()。
让我们看一个关于餐厅小费的示例数据集。假设我们想要制作一个堆叠条形图,显示每天每个派对规模的数据点的百分比。我使用read_csv加载数据,并通过日期和派对规模进行交叉制表。pandas.crosstab函数是从两个 DataFrame 列计算简单频率表的便捷方法:
In [77]: tips = pd.read_csv("examples/tips.csv")
In [78]: tips.head()
Out[78]:
total_bill tip smoker day time size
0 16.99 1.01 No Sun Dinner 2
1 10.34 1.66 No Sun Dinner 3
2 21.01 3.50 No Sun Dinner 3
3 23.68 3.31 No Sun Dinner 2
4 24.59 3.61 No Sun Dinner 4
In [79]: party_counts = pd.crosstab(tips["day"], tips["size"])
In [80]: party_counts = party_counts.reindex(index=["Thur", "Fri", "Sat", "Sun"])
In [81]: party_counts
Out[81]:
size 1 2 3 4 5 6
day
Thur 1 48 4 5 1 3
Fri 1 16 1 1 0 0
Sat 2 53 18 13 1 0
Sun 0 39 15 18 3 1
由于没有很多一人和六人的派对,我在这里删除它们:
In [82]: party_counts = party_counts.loc[:, 2:5]
然后,对每一行进行归一化,使总和为 1,并绘制图表(参见每天各尺寸派对的比例):
# Normalize to sum to 1
In [83]: party_pcts = party_counts.div(party_counts.sum(axis="columns"),
....: axis="index")
In [84]: party_pcts
Out[84]:
size 2 3 4 5
day
Thur 0.827586 0.068966 0.086207 0.017241
Fri 0.888889 0.055556 0.055556 0.000000
Sat 0.623529 0.211765 0.152941 0.011765
Sun 0.520000 0.200000 0.240000 0.040000
In [85]: party_pcts.plot.bar(stacked=True)

图 9.18:每天各尺寸派对的比例
因此,您可以看到在这个数据集中,派对规模似乎在周末增加。
对于需要在制作图表之前进行聚合或总结的数据,使用seaborn包可以使事情变得更简单(使用conda install seaborn进行安装)。现在让我们用 seaborn 查看小费百分比按天的情况(查看带误差条的每日小费百分比以查看结果图):
In [87]: import seaborn as sns
In [88]: tips["tip_pct"] = tips["tip"] / (tips["total_bill"] - tips["tip"])
In [89]: tips.head()
Out[89]:
total_bill tip smoker day time size tip_pct
0 16.99 1.01 No Sun Dinner 2 0.063204
1 10.34 1.66 No Sun Dinner 3 0.191244
2 21.01 3.50 No Sun Dinner 3 0.199886
3 23.68 3.31 No Sun Dinner 2 0.162494
4 24.59 3.61 No Sun Dinner 4 0.172069
In [90]: sns.barplot(x="tip_pct", y="day", data=tips, orient="h")

图 9.19:每日小费百分比带误差条
seaborn 中的绘图函数接受一个data参数,它可以是一个 pandas DataFrame。其他参数是指列名。因为在day的每个值中有多个观察值,所以条形图是tip_pct的平均值。在条形图上画的黑线代表 95%的置信区间(可以通过可选参数进行配置)。
seaborn.barplot有一个hue选项,可以使我们按照额外的分类值进行拆分(参见每日和时间的小费百分比):
In [92]: sns.barplot(x="tip_pct", y="day", hue="time", data=tips, orient="h")

图 9.20:每日和时间的小费百分比
请注意,seaborn 自动更改了图表的美学特征:默认颜色调色板、图表背景和网格线颜色。您可以使用seaborn.set_style在不同的图表外观之间切换:
In [94]: sns.set_style("whitegrid")
在为黑白打印媒介制作图表时,您可能会发现设置灰度调色板很有用,如下所示:
sns.set_palette("Greys_r")
直方图和密度图
直方图是一种显示值频率的离散化条形图。数据点被分成离散的、均匀间隔的箱子,并绘制每个箱子中的数据点数。使用之前的小费数据,我们可以使用 Series 的plot.hist方法制作总账单的小费百分比的直方图(参见小费百分比的直方图):
In [96]: tips["tip_pct"].plot.hist(bins=50)

图 9.21:小费百分比的直方图
一个相关的图表类型是密度图,它是通过计算可能生成观察数据的连续概率分布的估计而形成的。通常的做法是将这个分布近似为“核”混合——即,像正态分布这样的简单分布。因此,密度图也被称为核密度估计(KDE)图。使用plot.density可以使用传统的正态混合估计制作密度图(参见小费百分比的密度图):
In [98]: tips["tip_pct"].plot.density()

图 9.22:小费百分比的密度图
这种情节需要 SciPy,所以如果您还没有安装它,可以暂停一下然后安装:
conda install scipy
通过其histplot方法,seaborn 使直方图和密度图更加容易,可以同时绘制直方图和连续密度估计。例如,考虑一个由两个不同标准正态分布的抽样组成的双峰分布(请参见 Normalized histogram of normal mixture):
In [100]: comp1 = np.random.standard_normal(200)
In [101]: comp2 = 10 + 2 * np.random.standard_normal(200)
In [102]: values = pd.Series(np.concatenate([comp1, comp2]))
In [103]: sns.histplot(values, bins=100, color="black")

图 9.23:正态混合的归一化直方图
散点图或点图
点图或散点图可以是检查两个一维数据系列之间关系的有用方法。例如,这里我们从 statsmodels 项目加载macrodata数据集,选择几个变量,然后计算对数差异:
In [104]: macro = pd.read_csv("examples/macrodata.csv")
In [105]: data = macro[["cpi", "m1", "tbilrate", "unemp"]]
In [106]: trans_data = np.log(data).diff().dropna()
In [107]: trans_data.tail()
Out[107]:
cpi m1 tbilrate unemp
198 -0.007904 0.045361 -0.396881 0.105361
199 -0.021979 0.066753 -2.277267 0.139762
200 0.002340 0.010286 0.606136 0.160343
201 0.008419 0.037461 -0.200671 0.127339
202 0.008894 0.012202 -0.405465 0.042560
然后我们可以使用 seaborn 的regplot方法,它可以制作散点图并拟合线性回归线(参见 A seaborn regression/scatter plot):
In [109]: ax = sns.regplot(x="m1", y="unemp", data=trans_data)
In [110]: ax.set_title("Changes in log(m1) versus log(unemp)")

图 9.24:一个 seaborn 回归/散点图
在探索性数据分析中,查看一组变量之间的所有散点图是有帮助的;这被称为pairs图或scatter plot matrix。从头开始制作这样的图需要一些工作,因此 seaborn 有一个方便的pairplot函数,支持将每个变量的直方图或密度估计放在对角线上(请参见 Pair plot matrix of statsmodels macro data 以查看生成的图):
In [111]: sns.pairplot(trans_data, diag_kind="kde", plot_kws={"alpha": 0.2})

图 9.25:statsmodels 宏数据的 pairs 图矩阵
您可能会注意到plot_kws参数。这使我们能够将配置选项传递给对角线元素上的各个绘图调用。查看seaborn.pairplot文档字符串以获取更详细的配置选项。
Facet Grids 和分类数据
那么对于具有额外分组维度的数据集呢?一种可视化具有许多分类变量的数据的方法是使用facet grid,这是一个二维布局的图,其中数据根据某个变量的不同值在每个轴上分割到各个图中。seaborn 有一个有用的内置函数catplot,简化了根据分类变量拆分的许多种 facet 图的制作(请参见 Tipping percentage by day/time/smoker 以查看生成的图):
In [112]: sns.catplot(x="day", y="tip_pct", hue="time", col="smoker",
.....: kind="bar", data=tips[tips.tip_pct < 1])

图 9.26:按天/时间/吸烟者的小费百分比
与在 facet 内通过不同的条形颜色对“时间”进行分组不同,我们还可以通过为每个time值添加一行来扩展 facet grid(请参见 Tipping percentage by day split by time/smoker):
In [113]: sns.catplot(x="day", y="tip_pct", row="time",
.....: col="smoker",
.....: kind="bar", data=tips[tips.tip_pct < 1])

图 9.27:按天分割的小费百分比按时间/吸烟者
catplot支持其他可能有用的绘图类型,具体取决于您要显示的内容。例如,箱线图(显示中位数、四分位数和异常值)可以是一种有效的可视化类型(请参见 Box plot of tipping percentage by day):
In [114]: sns.catplot(x="tip_pct", y="day", kind="box",
.....: data=tips[tips.tip_pct < 0.5])

图 9.28:按天的小费百分比箱线图
您可以使用更通用的seaborn.FacetGrid类创建自己的 facet grid 图。有关更多信息,请参阅seaborn 文档。
9.3 其他 Python 可视化工具
与开源软件一样,Python 中有许多用于创建图形的选项(太多了无法列出)。自 2010 年以来,许多开发工作都集中在为在网页上发布的交互式图形创建工具上。使用诸如Altair、Bokeh和Plotly等工具,现在可以在 Python 中指定动态、交互式图形,用于与 Web 浏览器一起使用。
对于为印刷品或网络创建静态图形,我建议使用 matplotlib 以及构建在 matplotlib 基础上的库,如 pandas 和 seaborn,以满足您的需求。对于其他数据可视化需求,学习如何使用其他可用工具可能会有所帮助。我鼓励您探索这个生态系统,因为它将继续发展和创新。
数据可视化方面的一本优秀书籍是 Claus O. Wilke 的《数据可视化基础》(O'Reilly),可以在印刷版或 Claus 的网站clauswilke.com/dataviz上找到。
9.4 结论
本章的目标是通过使用 pandas、matplotlib 和 seaborn 进行一些基本数据可视化,让您初步了解。如果在您的工作中视觉传达数据分析结果很重要,我鼓励您寻找资源,了解更多关于有效数据可视化的知识。这是一个活跃的研究领域,您可以通过在线和印刷的许多优秀学习资源进行实践。
在下一章中,我们将关注使用 pandas 进行数据聚合和分组操作。
十、数据聚合和组操作
原文:
wesmckinney.com/book/data-aggregation译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
对数据集进行分类并对每个组应用函数,无论是聚合还是转换,都可能是数据分析工作流程的关键组成部分。加载、合并和准备数据集后,您可能需要计算组统计信息或可能需要为报告或可视化目的计算数据透视表。pandas 提供了一个多功能的groupby接口,使您能够以自然的方式切片、切块和总结数据集。
关系数据库和 SQL(结构化查询语言)的流行原因之一是数据可以很容易地进行连接、过滤、转换和聚合。然而,像 SQL 这样的查询语言对可以执行的组操作类型施加了一定的限制。正如您将看到的,借助 Python 和 pandas 的表达力,我们可以通过将它们表达为自定义 Python 函数来执行相当复杂的组操作,这些函数操作与每个组相关联的数据。在本章中,您将学习如何:
-
使用一个或多个键(以函数、数组或 DataFrame 列名的形式)将 pandas 对象分成片段
-
计算组摘要统计信息,如计数、均值或标准差,或用户定义的函数
-
应用组内转换或其他操作,如归一化、线性回归、排名或子集选择
-
计算数据透视表和交叉制表
-
执行分位数分析和其他统计组分析
注意
对时间序列数据进行基于时间的聚合,是groupby的一个特殊用例,在本书中被称为重新采样,将在第十一章:时间序列中单独处理。*与其他章节一样,我们首先导入 NumPy 和 pandas:
In [12]: import numpy as np
In [13]: import pandas as pd
10.1 如何思考组操作
Hadley Wickham,R 编程语言许多流行包的作者,为描述组操作创造了术语split-apply-combine。在过程的第一阶段中,包含在 pandas 对象中的数据,无论是 Series、DataFrame 还是其他形式,都根据您提供的一个或多个键被分割成组。分割是在对象的特定轴上执行的。例如,DataFrame 可以根据其行(axis="index")或列(axis="columns")进行分组。完成此操作后,将应用一个函数到每个组,生成一个新值。最后,所有这些函数应用的结果将合并成一个结果对象。结果对象的形式通常取决于对数据的操作。请参见图 10.1 以查看简单组聚合的模拟。
每个分组键可以采用多种形式,键不必是相同类型的:
-
一个与被分组的轴长度相同的值列表或数组
-
DataFrame 中表示列名的值
-
一个字典或 Series,给出了被分组的轴上的值与组名之间的对应关系
-
要在轴索引或索引中的个别标签上调用的函数

图 10.1:组聚合的示例
请注意,后三种方法是用于生成用于拆分对象的值数组的快捷方式。如果这一切看起来很抽象,不要担心。在本章中,我将给出所有这些方法的许多示例。为了开始,这里是一个作为 DataFrame 的小表格数据集:
In [14]: df = pd.DataFrame({"key1" : ["a", "a", None, "b", "b", "a", None],
....: "key2" : pd.Series([1, 2, 1, 2, 1, None, 1],
....: dtype="Int64"),
....: "data1" : np.random.standard_normal(7),
....: "data2" : np.random.standard_normal(7)})
In [15]: df
Out[15]:
key1 key2 data1 data2
0 a 1 -0.204708 0.281746
1 a 2 0.478943 0.769023
2 None 1 -0.519439 1.246435
3 b 2 -0.555730 1.007189
4 b 1 1.965781 -1.296221
5 a <NA> 1.393406 0.274992
6 None 1 0.092908 0.228913
假设你想使用 key1 标签计算 data1 列的均值。有多种方法可以做到这一点。一种方法是访问 data1 并使用 key1 列(一个 Series)调用 groupby:
In [16]: grouped = df["data1"].groupby(df["key1"])
In [17]: grouped
Out[17]: <pandas.core.groupby.generic.SeriesGroupBy object at 0x17b7913f0>
这个 grouped 变量现在是一个特殊的 "GroupBy" 对象。除了一些关于组键 df["key1"] 的中间数据之外,它实际上还没有计算任何东西。这个对象的想法是它包含了对每个组应用某些操作所需的所有信息。例如,要计算组均值,我们可以调用 GroupBy 的 mean 方法:
In [18]: grouped.mean()
Out[18]:
key1
a 0.555881
b 0.705025
Name: data1, dtype: float64
稍后在 数据聚合 中,我将更详细地解释当你调用 .mean() 时会发生什么。这里重要的是,数据(一个 Series)已经通过在组键上拆分数据进行聚合,产生了一个新的 Series,现在由 key1 列中的唯一值进行索引。结果索引的名称是 "key1",因为 DataFrame 列 df["key1"] 是这样的。
如果我们传递了多个数组作为列表,将会得到不同的结果:
In [19]: means = df["data1"].groupby([df["key1"], df["key2"]]).mean()
In [20]: means
Out[20]:
key1 key2
a 1 -0.204708
2 0.478943
b 1 1.965781
2 -0.555730
Name: data1, dtype: float64
在这里,我们使用两个键对数据进行分组,结果 Series 现在具有由观察到的唯一键对组成的分层索引:
In [21]: means.unstack()
Out[21]:
key2 1 2
key1
a -0.204708 0.478943
b 1.965781 -0.555730
在这个例子中,组键都是 Series,尽管它们可以是任何正确长度的数组:
In [22]: states = np.array(["OH", "CA", "CA", "OH", "OH", "CA", "OH"])
In [23]: years = [2005, 2005, 2006, 2005, 2006, 2005, 2006]
In [24]: df["data1"].groupby([states, years]).mean()
Out[24]:
CA 2005 0.936175
2006 -0.519439
OH 2005 -0.380219
2006 1.029344
Name: data1, dtype: float64
通常,分组信息在与你要处理的数据相同的 DataFrame 中找到。在这种情况下,你可以将列名(无论是字符串、数字还是其他 Python 对象)作为组键传递:
In [25]: df.groupby("key1").mean()
Out[25]:
key2 data1 data2
key1
a 1.5 0.555881 0.441920
b 1.5 0.705025 -0.144516
In [26]: df.groupby("key2").mean(numeric_only=True)
Out[26]:
data1 data2
key2
1 0.333636 0.115218
2 -0.038393 0.888106
In [27]: df.groupby(["key1", "key2"]).mean()
Out[27]:
data1 data2
key1 key2
a 1 -0.204708 0.281746
2 0.478943 0.769023
b 1 1.965781 -1.296221
2 -0.555730 1.007189
你可能会注意到,在第二种情况下,有必要传递 numeric_only=True,因为 key1 列不是数值列,因此不能使用 mean() 进行聚合。
无论使用 groupby 的目的是什么,一个通常有用的 GroupBy 方法是 size,它返回一个包含组大小的 Series:
In [28]: df.groupby(["key1", "key2"]).size()
Out[28]:
key1 key2
a 1 1
2 1
b 1 1
2 1
dtype: int64
请注意,默认情况下,组键中的任何缺失值都会被排除在结果之外。通过将 dropna=False 传递给 groupby 可以禁用此行为:
In [29]: df.groupby("key1", dropna=False).size()
Out[29]:
key1
a 3
b 2
NaN 2
dtype: int64
In [30]: df.groupby(["key1", "key2"], dropna=False).size()
Out[30]:
key1 key2
a 1 1
2 1
<NA> 1
b 1 1
2 1
NaN 1 2
dtype: int64
一种类似于 size 的组函数是 count,它计算每个组中的非空值的数量:
In [31]: df.groupby("key1").count()
Out[31]:
key2 data1 data2
key1
a 2 3 3
b 2 2 2
遍历组
groupby 返回的对象支持迭代,生成一个包含组名和数据块的 2 元组序列。考虑以下内容:
In [32]: for name, group in df.groupby("key1"):
....: print(name)
....: print(group)
....:
a
key1 key2 data1 data2
0 a 1 -0.204708 0.281746
1 a 2 0.478943 0.769023
5 a <NA> 1.393406 0.274992
b
key1 key2 data1 data2
3 b 2 -0.555730 1.007189
4 b 1 1.965781 -1.296221
在多个键的情况下,元组中的第一个元素将是一个键值的元组:
In [33]: for (k1, k2), group in df.groupby(["key1", "key2"]):
....: print((k1, k2))
....: print(group)
....:
('a', 1)
key1 key2 data1 data2
0 a 1 -0.204708 0.281746
('a', 2)
key1 key2 data1 data2
1 a 2 0.478943 0.769023
('b', 1)
key1 key2 data1 data2
4 b 1 1.965781 -1.296221
('b', 2)
key1 key2 data1 data2
3 b 2 -0.55573 1.007189
当然,你可以选择对数据块做任何你想做的事情。一个你可能会发现有用的方法是将数据块计算为一个字典:
In [34]: pieces = {name: group for name, group in df.groupby("key1")}
In [35]: pieces["b"]
Out[35]:
key1 key2 data1 data2
3 b 2 -0.555730 1.007189
4 b 1 1.965781 -1.296221
默认情况下,groupby 在 axis="index" 上进行分组,但你可以在任何其他轴上进行分组。例如,我们可以按照我们的示例 df 的列是否以 "key" 或 "data" 开头进行分组:
In [36]: grouped = df.groupby({"key1": "key", "key2": "key",
....: "data1": "data", "data2": "data"}, axis="columns")
我们可以这样打印出组:
In [37]: for group_key, group_values in grouped:
....: print(group_key)
....: print(group_values)
....:
data
data1 data2
0 -0.204708 0.281746
1 0.478943 0.769023
2 -0.519439 1.246435
3 -0.555730 1.007189
4 1.965781 -1.296221
5 1.393406 0.274992
6 0.092908 0.228913
key
key1 key2
0 a 1
1 a 2
2 None 1
3 b 2
4 b 1
5 a <NA>
6 None 1
选择列或列的子集
从 DataFrame 创建的 GroupBy 对象进行索引,使用列名或列名数组会对聚合进行列子集操作。这意味着:
df.groupby("key1")["data1"]
df.groupby("key1")[["data2"]]
是方便的:
df["data1"].groupby(df["key1"])
df[["data2"]].groupby(df["key1"])
特别是对于大型数据集,可能只需要聚合几列。例如,在前面的数据集中,仅计算 data2 列的均值并将结果作为 DataFrame 获取,我们可以这样写:
In [38]: df.groupby(["key1", "key2"])[["data2"]].mean()
Out[38]:
data2
key1 key2
a 1 0.281746
2 0.769023
b 1 -1.296221
2 1.007189
通过这种索引操作返回的对象是一个分组的 DataFrame(如果传递了列表或数组),或者是一个分组的 Series(如果只传递了一个列名作为标量):
In [39]: s_grouped = df.groupby(["key1", "key2"])["data2"]
In [40]: s_grouped
Out[40]: <pandas.core.groupby.generic.SeriesGroupBy object at 0x17b8356c0>
In [41]: s_grouped.mean()
Out[41]:
key1 key2
a 1 0.281746
2 0.769023
b 1 -1.296221
2 1.007189
Name: data2, dtype: float64
使用字典和 Series 进行分组
分组信息可能以其他形式存在,而不仅仅是数组。让我们考虑另一个示例 DataFrame:
In [42]: people = pd.DataFrame(np.random.standard_normal((5, 5)),
....: columns=["a", "b", "c", "d", "e"],
....: index=["Joe", "Steve", "Wanda", "Jill", "Trey"])
In [43]: people.iloc[2:3, [1, 2]] = np.nan # Add a few NA values
In [44]: people
Out[44]:
a b c d e
Joe 1.352917 0.886429 -2.001637 -0.371843 1.669025
Steve -0.438570 -0.539741 0.476985 3.248944 -1.021228
Wanda -0.577087 NaN NaN 0.523772 0.000940
Jill 1.343810 -0.713544 -0.831154 -2.370232 -1.860761
Trey -0.860757 0.560145 -1.265934 0.119827 -1.063512
现在,假设我有列的分组对应关系,并且想要按组对列求和:
In [45]: mapping = {"a": "red", "b": "red", "c": "blue",
....: "d": "blue", "e": "red", "f" : "orange"}
现在,您可以从这个字典构造一个数组传递给groupby,但我们可以直接传递字典(我包含了键"f"来突出显示未使用的分组键是可以的):
In [46]: by_column = people.groupby(mapping, axis="columns")
In [47]: by_column.sum()
Out[47]:
blue red
Joe -2.373480 3.908371
Steve 3.725929 -1.999539
Wanda 0.523772 -0.576147
Jill -3.201385 -1.230495
Trey -1.146107 -1.364125
相同的功能也适用于 Series,它可以被视为一个固定大小的映射:
In [48]: map_series = pd.Series(mapping)
In [49]: map_series
Out[49]:
a red
b red
c blue
d blue
e red
f orange
dtype: object
In [50]: people.groupby(map_series, axis="columns").count()
Out[50]:
blue red
Joe 2 3
Steve 2 3
Wanda 1 2
Jill 2 3
Trey 2 3
使用函数分组
使用 Python 函数比使用字典或 Series 定义分组映射更通用。作为分组键传递的任何函数将针对每个索引值(或者如果使用axis="columns"则是每个列值)调用一次,返回值将用作分组名称。更具体地,考虑前一节中的示例 DataFrame,其中人们的名字作为索引值。假设您想按名称长度分组。虽然您可以计算一个字符串长度的数组,但更简单的方法是只传递len函数:
In [51]: people.groupby(len).sum()
Out[51]:
a b c d e
3 1.352917 0.886429 -2.001637 -0.371843 1.669025
4 0.483052 -0.153399 -2.097088 -2.250405 -2.924273
5 -1.015657 -0.539741 0.476985 3.772716 -1.020287
将函数与数组、字典或 Series 混合在一起不是问题,因为所有内容在内部都会转换为数组:
In [52]: key_list = ["one", "one", "one", "two", "two"]
In [53]: people.groupby([len, key_list]).min()
Out[53]:
a b c d e
3 one 1.352917 0.886429 -2.001637 -0.371843 1.669025
4 two -0.860757 -0.713544 -1.265934 -2.370232 -1.860761
5 one -0.577087 -0.539741 0.476985 0.523772 -1.021228
按索引级别分组
对于具有层次索引的数据集,最后一个便利之处是能够使用轴索引的一个级别进行聚合。让我们看一个例子:
In [54]: columns = pd.MultiIndex.from_arrays([["US", "US", "US", "JP", "JP"],
....: [1, 3, 5, 1, 3]],
....: names=["cty", "tenor"])
In [55]: hier_df = pd.DataFrame(np.random.standard_normal((4, 5)), columns=column
s)
In [56]: hier_df
Out[56]:
cty US JP
tenor 1 3 5 1 3
0 0.332883 -2.359419 -0.199543 -1.541996 -0.970736
1 -1.307030 0.286350 0.377984 -0.753887 0.331286
2 1.349742 0.069877 0.246674 -0.011862 1.004812
3 1.327195 -0.919262 -1.549106 0.022185 0.758363
要按级别分组,请使用level关键字传递级别编号或名称:
In [57]: hier_df.groupby(level="cty", axis="columns").count()
Out[57]:
cty JP US
0 2 3
1 2 3
2 2 3
3 2 3
10.2 数据聚合
聚合指的是从数组中产生标量值的任何数据转换。前面的示例中使用了其中几个,包括mean、count、min和sum。当您在 GroupBy 对象上调用mean()时,您可能会想知道发生了什么。许多常见的聚合,如表 10.1 中找到的那些,都有优化的实现。但是,您不仅限于这组方法。
表 10.1:优化的groupby方法
| 函数名称 | 描述 |
|---|---|
any, all |
如果任何(一个或多个值)或所有非 NA 值为“真值”则返回True |
count |
非 NA 值的数量 |
cummin, cummax |
非 NA 值的累积最小值和最大值 |
cumsum |
非 NA 值的累积和 |
cumprod |
非 NA 值的累积乘积 |
first, last |
首个和最后一个非 NA 值 |
mean |
非 NA 值的均值 |
median |
非 NA 值的算术中位数 |
min, max |
非 NA 值的最小值和最大值 |
nth |
检索在排序顺序中出现在位置n的值 |
ohlc |
为类似时间序列的数据计算四个“开盘-最高-最低-收盘”统计数据 |
prod |
非 NA 值的乘积 |
quantile |
计算样本分位数 |
rank |
非 NA 值的序数排名,类似于调用Series.rank |
size |
计算组大小,将结果返回为 Series |
sum |
非 NA 值的总和 |
std, var |
样本标准差和方差 |
您可以使用自己设计的聚合,并额外调用任何也在被分组对象上定义的方法。例如,nsmallest Series 方法从数据中选择请求的最小数量的值。虽然nsmallest没有明确为 GroupBy 实现,但我们仍然可以使用它与非优化的实现。在内部,GroupBy 将 Series 切片,为每个片段调用piece.nsmallest(n),然后将这些结果组装成结果对象:
In [58]: df
Out[58]:
key1 key2 data1 data2
0 a 1 -0.204708 0.281746
1 a 2 0.478943 0.769023
2 None 1 -0.519439 1.246435
3 b 2 -0.555730 1.007189
4 b 1 1.965781 -1.296221
5 a <NA> 1.393406 0.274992
6 None 1 0.092908 0.228913
In [59]: grouped = df.groupby("key1")
In [60]: grouped["data1"].nsmallest(2)
Out[60]:
key1
a 0 -0.204708
1 0.478943
b 3 -0.555730
4 1.965781
Name: data1, dtype: float64
要使用自己的聚合函数,只需将任何聚合数组的函数传递给aggregate方法或其简短别名agg:
In [61]: def peak_to_peak(arr):
....: return arr.max() - arr.min()
In [62]: grouped.agg(peak_to_peak)
Out[62]:
key2 data1 data2
key1
a 1 1.598113 0.494031
b 1 2.521511 2.303410
您可能会注意到一些方法,比如describe,即使严格来说它们不是聚合也可以工作:
In [63]: grouped.describe()
Out[63]:
key2 data1 ...
count mean std min 25% 50% 75% max count mean ...
key1 ...
a 2.0 1.5 0.707107 1.0 1.25 1.5 1.75 2.0 3.0 0.555881 ... \
b 2.0 1.5 0.707107 1.0 1.25 1.5 1.75 2.0 2.0 0.705025 ...
data2
75% max count mean std min 25%
key1
a 0.936175 1.393406 3.0 0.441920 0.283299 0.274992 0.278369 \
b 1.335403 1.965781 2.0 -0.144516 1.628757 -1.296221 -0.720368
50% 75% max
key1
a 0.281746 0.525384 0.769023
b -0.144516 0.431337 1.007189
[2 rows x 24 columns]
我将在应用:通用的分割-应用-合并中更详细地解释这里发生了什么。
注意
自定义聚合函数通常比在 Table 10.1 中找到的优化函数慢得多。这是因为在构建中间组数据块时存在一些额外开销(函数调用,数据重新排列)*### 按列和多函数应用
让我们回到上一章中使用的小费数据集。在使用pandas.read_csv加载后,我们添加一个小费百分比列:
In [64]: tips = pd.read_csv("examples/tips.csv")
In [65]: tips.head()
Out[65]:
total_bill tip smoker day time size
0 16.99 1.01 No Sun Dinner 2
1 10.34 1.66 No Sun Dinner 3
2 21.01 3.50 No Sun Dinner 3
3 23.68 3.31 No Sun Dinner 2
4 24.59 3.61 No Sun Dinner 4
现在我将添加一个tip_pct列,其中包含总账单的小费百分比:
In [66]: tips["tip_pct"] = tips["tip"] / tips["total_bill"]
In [67]: tips.head()
Out[67]:
total_bill tip smoker day time size tip_pct
0 16.99 1.01 No Sun Dinner 2 0.059447
1 10.34 1.66 No Sun Dinner 3 0.160542
2 21.01 3.50 No Sun Dinner 3 0.166587
3 23.68 3.31 No Sun Dinner 2 0.139780
4 24.59 3.61 No Sun Dinner 4 0.146808
正如您已经看到的,聚合 Series 或 DataFrame 的所有列是使用aggregate(或agg)与所需函数或调用mean或std方法的问题。但是,您可能希望根据列使用不同的函数进行聚合,或者一次使用多个函数。幸运的是,这是可能的,我将通过一些示例来说明。首先,我将按day和smoker对tips进行分组:
In [68]: grouped = tips.groupby(["day", "smoker"])
请注意,对于像 Table 10.1 中的描述性统计数据,您可以将函数的名称作为字符串传递:
In [69]: grouped_pct = grouped["tip_pct"]
In [70]: grouped_pct.agg("mean")
Out[70]:
day smoker
Fri No 0.151650
Yes 0.174783
Sat No 0.158048
Yes 0.147906
Sun No 0.160113
Yes 0.187250
Thur No 0.160298
Yes 0.163863
Name: tip_pct, dtype: float64
如果您传递的是函数或函数名称的列表,您将获得一个列名从函数中获取的 DataFrame:
In [71]: grouped_pct.agg(["mean", "std", peak_to_peak])
Out[71]:
mean std peak_to_peak
day smoker
Fri No 0.151650 0.028123 0.067349
Yes 0.174783 0.051293 0.159925
Sat No 0.158048 0.039767 0.235193
Yes 0.147906 0.061375 0.290095
Sun No 0.160113 0.042347 0.193226
Yes 0.187250 0.154134 0.644685
Thur No 0.160298 0.038774 0.193350
Yes 0.163863 0.039389 0.151240
在这里,我们将一系列聚合函数传递给agg,以独立评估数据组。
您不需要接受 GroupBy 为列提供的名称;特别是,lambda函数的名称为"<lambda>",这使得它们难以识别(您可以通过查看函数的__name__属性来自行查看)。因此,如果您传递一个(name, function)元组的列表,每个元组的第一个元素将被用作 DataFrame 列名(您可以将 2 元组的列表视为有序映射):
In [72]: grouped_pct.agg([("average", "mean"), ("stdev", np.std)])
Out[72]:
average stdev
day smoker
Fri No 0.151650 0.028123
Yes 0.174783 0.051293
Sat No 0.158048 0.039767
Yes 0.147906 0.061375
Sun No 0.160113 0.042347
Yes 0.187250 0.154134
Thur No 0.160298 0.038774
Yes 0.163863 0.039389
使用 DataFrame,您有更多的选项,因为您可以指定要应用于所有列或不同列的不同函数的函数列表。首先,假设我们想要计算tip_pct和total_bill列的相同三个统计数据:
In [73]: functions = ["count", "mean", "max"]
In [74]: result = grouped[["tip_pct", "total_bill"]].agg(functions)
In [75]: result
Out[75]:
tip_pct total_bill
count mean max count mean max
day smoker
Fri No 4 0.151650 0.187735 4 18.420000 22.75
Yes 15 0.174783 0.263480 15 16.813333 40.17
Sat No 45 0.158048 0.291990 45 19.661778 48.33
Yes 42 0.147906 0.325733 42 21.276667 50.81
Sun No 57 0.160113 0.252672 57 20.506667 48.17
Yes 19 0.187250 0.710345 19 24.120000 45.35
Thur No 45 0.160298 0.266312 45 17.113111 41.19
Yes 17 0.163863 0.241255 17 19.190588 43.11
如您所见,生成的 DataFrame 具有分层列,与分别聚合每列并使用列名作为keys参数使用concat粘合结果时获得的结果相同:
In [76]: result["tip_pct"]
Out[76]:
count mean max
day smoker
Fri No 4 0.151650 0.187735
Yes 15 0.174783 0.263480
Sat No 45 0.158048 0.291990
Yes 42 0.147906 0.325733
Sun No 57 0.160113 0.252672
Yes 19 0.187250 0.710345
Thur No 45 0.160298 0.266312
Yes 17 0.163863 0.241255
与以前一样,可以传递具有自定义名称的元组列表:
In [77]: ftuples = [("Average", "mean"), ("Variance", np.var)]
In [78]: grouped[["tip_pct", "total_bill"]].agg(ftuples)
Out[78]:
tip_pct total_bill
Average Variance Average Variance
day smoker
Fri No 0.151650 0.000791 18.420000 25.596333
Yes 0.174783 0.002631 16.813333 82.562438
Sat No 0.158048 0.001581 19.661778 79.908965
Yes 0.147906 0.003767 21.276667 101.387535
Sun No 0.160113 0.001793 20.506667 66.099980
Yes 0.187250 0.023757 24.120000 109.046044
Thur No 0.160298 0.001503 17.113111 59.625081
Yes 0.163863 0.001551 19.190588 69.808518
现在,假设您想要对一个或多个列应用可能不同的函数。为此,请将包含列名到迄今为止列出的任何函数规范的映射的字典传递给agg:
In [79]: grouped.agg({"tip" : np.max, "size" : "sum"})
Out[79]:
tip size
day smoker
Fri No 3.50 9
Yes 4.73 31
Sat No 9.00 115
Yes 10.00 104
Sun No 6.00 167
Yes 6.50 49
Thur No 6.70 112
Yes 5.00 40
In [80]: grouped.agg({"tip_pct" : ["min", "max", "mean", "std"],
....: "size" : "sum"})
Out[80]:
tip_pct size
min max mean std sum
day smoker
Fri No 0.120385 0.187735 0.151650 0.028123 9
Yes 0.103555 0.263480 0.174783 0.051293 31
Sat No 0.056797 0.291990 0.158048 0.039767 115
Yes 0.035638 0.325733 0.147906 0.061375 104
Sun No 0.059447 0.252672 0.160113 0.042347 167
Yes 0.065660 0.710345 0.187250 0.154134 49
Thur No 0.072961 0.266312 0.160298 0.038774 112
Yes 0.090014 0.241255 0.163863 0.039389 40
只有在至少对一列应用多个函数时,DataFrame 才会具有分层列。
返回不带行索引的聚合数据
到目前为止的所有示例中,聚合数据都带有一个索引,可能是分层的,由唯一的组键组合组成。由于这并不总是理想的,您可以通过在大多数情况下将as_index=False传递给groupby来禁用此行为:
In [81]: grouped = tips.groupby(["day", "smoker"], as_index=False)
In [82]: grouped.mean(numeric_only=True)
Out[82]:
day smoker total_bill tip size tip_pct
0 Fri No 18.420000 2.812500 2.250000 0.151650
1 Fri Yes 16.813333 2.714000 2.066667 0.174783
2 Sat No 19.661778 3.102889 2.555556 0.158048
3 Sat Yes 21.276667 2.875476 2.476190 0.147906
4 Sun No 20.506667 3.167895 2.929825 0.160113
5 Sun Yes 24.120000 3.516842 2.578947 0.187250
6 Thur No 17.113111 2.673778 2.488889 0.160298
7 Thur Yes 19.190588 3.030000 2.352941 0.163863
当然,通过在结果上调用reset_index,总是可以以这种格式获得结果。使用as_index=False参数可以避免一些不必要的计算。*## 10.3 应用:通用的分割-应用-合并
最通用的 GroupBy 方法是apply,这是本节的主题。apply将被操作的对象分割成片段,对每个片段调用传递的函数,然后尝试连接这些片段。
回到以前的小费数据集,假设您想要按组选择前五个tip_pct值。首先,编写一个函数,该函数选择特定列中最大值的行:
In [83]: def top(df, n=5, column="tip_pct"):
....: return df.sort_values(column, ascending=False)[:n]
In [84]: top(tips, n=6)
Out[84]:
total_bill tip smoker day time size tip_pct
172 7.25 5.15 Yes Sun Dinner 2 0.710345
178 9.60 4.00 Yes Sun Dinner 2 0.416667
67 3.07 1.00 Yes Sat Dinner 1 0.325733
232 11.61 3.39 No Sat Dinner 2 0.291990
183 23.17 6.50 Yes Sun Dinner 4 0.280535
109 14.31 4.00 Yes Sat Dinner 2 0.279525
现在,如果我们按smoker分组,并使用此函数调用apply,我们将得到以下结果:
In [85]: tips.groupby("smoker").apply(top)
Out[85]:
total_bill tip smoker day time size tip_pct
smoker
No 232 11.61 3.39 No Sat Dinner 2 0.291990
149 7.51 2.00 No Thur Lunch 2 0.266312
51 10.29 2.60 No Sun Dinner 2 0.252672
185 20.69 5.00 No Sun Dinner 5 0.241663
88 24.71 5.85 No Thur Lunch 2 0.236746
Yes 172 7.25 5.15 Yes Sun Dinner 2 0.710345
178 9.60 4.00 Yes Sun Dinner 2 0.416667
67 3.07 1.00 Yes Sat Dinner 1 0.325733
183 23.17 6.50 Yes Sun Dinner 4 0.280535
109 14.31 4.00 Yes Sat Dinner 2 0.279525
这里发生了什么?首先,根据smoker的值将tips DataFrame 分成组。然后在每个组上调用top函数,并使用pandas.concat将每个函数调用的结果粘合在一起,用组名标记各个部分。因此,结果具有一个具有内部级别的分层索引,该级别包含原始 DataFrame 的索引值。
如果您将一个接受其他参数或关键字的函数传递给apply,则可以在函数之后传递这些参数:
In [86]: tips.groupby(["smoker", "day"]).apply(top, n=1, column="total_bill")
Out[86]:
total_bill tip smoker day time size tip_pct
smoker day
No Fri 94 22.75 3.25 No Fri Dinner 2 0.142857
Sat 212 48.33 9.00 No Sat Dinner 4 0.186220
Sun 156 48.17 5.00 No Sun Dinner 6 0.103799
Thur 142 41.19 5.00 No Thur Lunch 5 0.121389
Yes Fri 95 40.17 4.73 Yes Fri Dinner 4 0.117750
Sat 170 50.81 10.00 Yes Sat Dinner 3 0.196812
Sun 182 45.35 3.50 Yes Sun Dinner 3 0.077178
Thur 197 43.11 5.00 Yes Thur Lunch 4 0.115982
除了这些基本的使用机制外,要充分利用apply可能需要一些创造力。传递的函数内部发生的事情取决于你;它必须返回一个 pandas 对象或一个标量值。本章的其余部分主要将包含示例,向您展示如何使用groupby解决各种问题。
例如,你可能还记得我之前在 GroupBy 对象上调用describe:
In [87]: result = tips.groupby("smoker")["tip_pct"].describe()
In [88]: result
Out[88]:
count mean std min 25% 50% 75%
smoker
No 151.0 0.159328 0.039910 0.056797 0.136906 0.155625 0.185014 \
Yes 93.0 0.163196 0.085119 0.035638 0.106771 0.153846 0.195059
max
smoker
No 0.291990
Yes 0.710345
In [89]: result.unstack("smoker")
Out[89]:
smoker
count No 151.000000
Yes 93.000000
mean No 0.159328
Yes 0.163196
std No 0.039910
Yes 0.085119
min No 0.056797
Yes 0.035638
25% No 0.136906
Yes 0.106771
50% No 0.155625
Yes 0.153846
75% No 0.185014
Yes 0.195059
max No 0.291990
Yes 0.710345
dtype: float64
在 GroupBy 中,当你调用像describe这样的方法时,实际上只是一个快捷方式:
def f(group):
return group.describe()
grouped.apply(f)
抑制组键
在前面的示例中,您可以看到生成的对象具有从组键形成的分层索引,以及原始对象的每个部分的索引。您可以通过将group_keys=False传递给groupby来禁用这一点:
In [90]: tips.groupby("smoker", group_keys=False).apply(top)
Out[90]:
total_bill tip smoker day time size tip_pct
232 11.61 3.39 No Sat Dinner 2 0.291990
149 7.51 2.00 No Thur Lunch 2 0.266312
51 10.29 2.60 No Sun Dinner 2 0.252672
185 20.69 5.00 No Sun Dinner 5 0.241663
88 24.71 5.85 No Thur Lunch 2 0.236746
172 7.25 5.15 Yes Sun Dinner 2 0.710345
178 9.60 4.00 Yes Sun Dinner 2 0.416667
67 3.07 1.00 Yes Sat Dinner 1 0.325733
183 23.17 6.50 Yes Sun Dinner 4 0.280535
109 14.31 4.00 Yes Sat Dinner 2 0.279525
分位数和桶分析
正如你可能从第八章:数据整理:连接、合并和重塑中记得的那样,pandas 有一些工具,特别是pandas.cut和pandas.qcut,可以将数据切分成您选择的桶或样本分位数。将这些函数与groupby结合起来,可以方便地对数据集进行桶或分位数分析。考虑一个简单的随机数据集和使用pandas.cut进行等长度桶分类:
In [91]: frame = pd.DataFrame({"data1": np.random.standard_normal(1000),
....: "data2": np.random.standard_normal(1000)})
In [92]: frame.head()
Out[92]:
data1 data2
0 -0.660524 -0.612905
1 0.862580 0.316447
2 -0.010032 0.838295
3 0.050009 -1.034423
4 0.670216 0.434304
In [93]: quartiles = pd.cut(frame["data1"], 4)
In [94]: quartiles.head(10)
Out[94]:
0 (-1.23, 0.489]
1 (0.489, 2.208]
2 (-1.23, 0.489]
3 (-1.23, 0.489]
4 (0.489, 2.208]
5 (0.489, 2.208]
6 (-1.23, 0.489]
7 (-1.23, 0.489]
8 (-2.956, -1.23]
9 (-1.23, 0.489]
Name: data1, dtype: category
Categories (4, interval[float64, right]): [(-2.956, -1.23] < (-1.23, 0.489] < (0.
489, 2.208] <
(2.208, 3.928]]
cut返回的Categorical对象可以直接传递给groupby。因此,我们可以计算四分位数的一组组统计信息,如下所示:
In [95]: def get_stats(group):
....: return pd.DataFrame(
....: {"min": group.min(), "max": group.max(),
....: "count": group.count(), "mean": group.mean()}
....: )
In [96]: grouped = frame.groupby(quartiles)
In [97]: grouped.apply(get_stats)
Out[97]:
min max count mean
data1
(-2.956, -1.23] data1 -2.949343 -1.230179 94 -1.658818
data2 -3.399312 1.670835 94 -0.033333
(-1.23, 0.489] data1 -1.228918 0.488675 598 -0.329524
data2 -2.989741 3.260383 598 -0.002622
(0.489, 2.208] data1 0.489965 2.200997 298 1.065727
data2 -3.745356 2.954439 298 0.078249
(2.208, 3.928] data1 2.212303 3.927528 10 2.644253
data2 -1.929776 1.765640 10 0.024750
请记住,同样的结果可以更简单地计算为:
In [98]: grouped.agg(["min", "max", "count", "mean"])
Out[98]:
data1 data2
min max count mean min max count
data1
(-2.956, -1.23] -2.949343 -1.230179 94 -1.658818 -3.399312 1.670835 94 \
(-1.23, 0.489] -1.228918 0.488675 598 -0.329524 -2.989741 3.260383 598
(0.489, 2.208] 0.489965 2.200997 298 1.065727 -3.745356 2.954439 298
(2.208, 3.928] 2.212303 3.927528 10 2.644253 -1.929776 1.765640 10
mean
data1
(-2.956, -1.23] -0.033333
(-1.23, 0.489] -0.002622
(0.489, 2.208] 0.078249
(2.208, 3.928] 0.024750
这些是等长度的桶;要基于样本分位数计算等大小的桶,使用pandas.qcut。我们可以将4作为桶的数量计算样本四分位数,并传递labels=False以仅获取四分位数索引而不是间隔:
In [99]: quartiles_samp = pd.qcut(frame["data1"], 4, labels=False)
In [100]: quartiles_samp.head()
Out[100]:
0 1
1 3
2 2
3 2
4 3
Name: data1, dtype: int64
In [101]: grouped = frame.groupby(quartiles_samp)
In [102]: grouped.apply(get_stats)
Out[102]:
min max count mean
data1
0 data1 -2.949343 -0.685484 250 -1.212173
data2 -3.399312 2.628441 250 -0.027045
1 data1 -0.683066 -0.030280 250 -0.368334
data2 -2.630247 3.260383 250 -0.027845
2 data1 -0.027734 0.618965 250 0.295812
data2 -3.056990 2.458842 250 0.014450
3 data1 0.623587 3.927528 250 1.248875
data2 -3.745356 2.954439 250 0.115899
示例:使用组特定值填充缺失值
在清理缺失数据时,有些情况下您将使用dropna删除数据观察值,但在其他情况下,您可能希望使用固定值或从数据中派生的某个值填充空(NA)值。fillna是正确的工具;例如,这里我用均值填充了空值:
In [103]: s = pd.Series(np.random.standard_normal(6))
In [104]: s[::2] = np.nan
In [105]: s
Out[105]:
0 NaN
1 0.227290
2 NaN
3 -2.153545
4 NaN
5 -0.375842
dtype: float64
In [106]: s.fillna(s.mean())
Out[106]:
0 -0.767366
1 0.227290
2 -0.767366
3 -2.153545
4 -0.767366
5 -0.375842
dtype: float64
假设您需要填充值根据组而变化。一种方法是对数据进行分组,并使用调用fillna的函数在每个数据块上使用apply。这里是一些关于美国各州的样本数据,分为东部和西部地区:
In [107]: states = ["Ohio", "New York", "Vermont", "Florida",
.....: "Oregon", "Nevada", "California", "Idaho"]
In [108]: group_key = ["East", "East", "East", "East",
.....: "West", "West", "West", "West"]
In [109]: data = pd.Series(np.random.standard_normal(8), index=states)
In [110]: data
Out[110]:
Ohio 0.329939
New York 0.981994
Vermont 1.105913
Florida -1.613716
Oregon 1.561587
Nevada 0.406510
California 0.359244
Idaho -0.614436
dtype: float64
让我们将数据中的一些值设置为缺失:
In [111]: data[["Vermont", "Nevada", "Idaho"]] = np.nan
In [112]: data
Out[112]:
Ohio 0.329939
New York 0.981994
Vermont NaN
Florida -1.613716
Oregon 1.561587
Nevada NaN
California 0.359244
Idaho NaN
dtype: float64
In [113]: data.groupby(group_key).size()
Out[113]:
East 4
West 4
dtype: int64
In [114]: data.groupby(group_key).count()
Out[114]:
East 3
West 2
dtype: int64
In [115]: data.groupby(group_key).mean()
Out[115]:
East -0.100594
West 0.960416
dtype: float64
我们可以使用组均值填充 NA 值,如下所示:
In [116]: def fill_mean(group):
.....: return group.fillna(group.mean())
In [117]: data.groupby(group_key).apply(fill_mean)
Out[117]:
East Ohio 0.329939
New York 0.981994
Vermont -0.100594
Florida -1.613716
West Oregon 1.561587
Nevada 0.960416
California 0.359244
Idaho 0.960416
dtype: float64
在另一种情况下,您可能在代码中预定义了根据组变化的填充值。由于组内部设置了name属性,我们可以使用它:
In [118]: fill_values = {"East": 0.5, "West": -1}
In [119]: def fill_func(group):
.....: return group.fillna(fill_values[group.name])
In [120]: data.groupby(group_key).apply(fill_func)
Out[120]:
East Ohio 0.329939
New York 0.981994
Vermont 0.500000
Florida -1.613716
West Oregon 1.561587
Nevada -1.000000
California 0.359244
Idaho -1.000000
dtype: float64
示例:随机抽样和排列
假设您想要从大型数据集中随机抽取(有或没有替换)用于蒙特卡洛模拟或其他应用。有许多执行“抽取”的方法;在这里,我们使用 Series 的sample方法。
为了演示,这里有一种构建一副英式扑克牌的方法:
suits = ["H", "S", "C", "D"] # Hearts, Spades, Clubs, Diamonds
card_val = (list(range(1, 11)) + [10] * 3) * 4
base_names = ["A"] + list(range(2, 11)) + ["J", "K", "Q"]
cards = []
for suit in suits:
cards.extend(str(num) + suit for num in base_names)
deck = pd.Series(card_val, index=cards)
现在我们有一个长度为 52 的 Series,其索引包含牌名,值是在二十一点和其他游戏中使用的值(为了简单起见,我让 ace "A"为 1):
In [122]: deck.head(13)
Out[122]:
AH 1
2H 2
3H 3
4H 4
5H 5
6H 6
7H 7
8H 8
9H 9
10H 10
JH 10
KH 10
QH 10
dtype: int64
现在,根据我之前说的,从牌组中抽取五张牌可以写成:
In [123]: def draw(deck, n=5):
.....: return deck.sample(n)
In [124]: draw(deck)
Out[124]:
4D 4
QH 10
8S 8
7D 7
9C 9
dtype: int64
假设你想要从每种花色中抽取两张随机牌。因为花色是每张牌名称的最后一个字符,我们可以根据这个进行分组,并使用apply:
In [125]: def get_suit(card):
.....: # last letter is suit
.....: return card[-1]
In [126]: deck.groupby(get_suit).apply(draw, n=2)
Out[126]:
C 6C 6
KC 10
D 7D 7
3D 3
H 7H 7
9H 9
S 2S 2
QS 10
dtype: int64
或者,我们可以传递group_keys=False以删除外部套索索引,只留下所选的卡:
In [127]: deck.groupby(get_suit, group_keys=False).apply(draw, n=2)
Out[127]:
AC 1
3C 3
5D 5
4D 4
10H 10
7H 7
QS 10
7S 7
dtype: int64
示例:组加权平均和相关性
在groupby的分割-应用-组合范式下,DataFrame 或两个 Series 中的列之间的操作,例如组加权平均,是可能的。例如,考虑包含组键、值和一些权重的数据集:
In [128]: df = pd.DataFrame({"category": ["a", "a", "a", "a",
.....: "b", "b", "b", "b"],
.....: "data": np.random.standard_normal(8),
.....: "weights": np.random.uniform(size=8)})
In [129]: df
Out[129]:
category data weights
0 a -1.691656 0.955905
1 a 0.511622 0.012745
2 a -0.401675 0.137009
3 a 0.968578 0.763037
4 b -1.818215 0.492472
5 b 0.279963 0.832908
6 b -0.200819 0.658331
7 b -0.217221 0.612009
按category加权平均值将是:
In [130]: grouped = df.groupby("category")
In [131]: def get_wavg(group):
.....: return np.average(group["data"], weights=group["weights"])
In [132]: grouped.apply(get_wavg)
Out[132]:
category
a -0.495807
b -0.357273
dtype: float64
另一个例子是,考虑一个最初从 Yahoo! Finance 获取的金融数据集,其中包含一些股票的日终价格和标准普尔 500 指数(SPX符号):
In [133]: close_px = pd.read_csv("examples/stock_px.csv", parse_dates=True,
.....: index_col=0)
In [134]: close_px.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 2214 entries, 2003-01-02 to 2011-10-14
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 AAPL 2214 non-null float64
1 MSFT 2214 non-null float64
2 XOM 2214 non-null float64
3 SPX 2214 non-null float64
dtypes: float64(4)
memory usage: 86.5 KB
In [135]: close_px.tail(4)
Out[135]:
AAPL MSFT XOM SPX
2011-10-11 400.29 27.00 76.27 1195.54
2011-10-12 402.19 26.96 77.16 1207.25
2011-10-13 408.43 27.18 76.37 1203.66
2011-10-14 422.00 27.27 78.11 1224.58
这里的 DataFrame info()方法是获取 DataFrame 内容概述的便捷方式。
一个感兴趣的任务可能是计算一个由每日收益(从百分比变化计算)与SPX的年度相关性组成的 DataFrame。作为一种方法,我们首先创建一个函数,计算每列与"SPX"列的成对相关性:
In [136]: def spx_corr(group):
.....: return group.corrwith(group["SPX"])
接下来,我们使用pct_change计算close_px的百分比变化:
In [137]: rets = close_px.pct_change().dropna()
最后,我们按年将这些百分比变化分组,可以使用一个一行函数从每个行标签中提取datetime标签的year属性:
In [138]: def get_year(x):
.....: return x.year
In [139]: by_year = rets.groupby(get_year)
In [140]: by_year.apply(spx_corr)
Out[140]:
AAPL MSFT XOM SPX
2003 0.541124 0.745174 0.661265 1.0
2004 0.374283 0.588531 0.557742 1.0
2005 0.467540 0.562374 0.631010 1.0
2006 0.428267 0.406126 0.518514 1.0
2007 0.508118 0.658770 0.786264 1.0
2008 0.681434 0.804626 0.828303 1.0
2009 0.707103 0.654902 0.797921 1.0
2010 0.710105 0.730118 0.839057 1.0
2011 0.691931 0.800996 0.859975 1.0
您还可以计算列间的相关性。这里我们计算苹果和微软之间的年度相关性:
In [141]: def corr_aapl_msft(group):
.....: return group["AAPL"].corr(group["MSFT"])
In [142]: by_year.apply(corr_aapl_msft)
Out[142]:
2003 0.480868
2004 0.259024
2005 0.300093
2006 0.161735
2007 0.417738
2008 0.611901
2009 0.432738
2010 0.571946
2011 0.581987
dtype: float64
示例:组内线性回归
与前面的示例相同,您可以使用groupby执行更复杂的组内统计分析,只要函数返回一个 pandas 对象或标量值。例如,我可以定义以下regress函数(使用statsmodels计量经济学库),它在每个数据块上执行普通最小二乘(OLS)回归:
import statsmodels.api as sm
def regress(data, yvar=None, xvars=None):
Y = data[yvar]
X = data[xvars]
X["intercept"] = 1.
result = sm.OLS(Y, X).fit()
return result.params
如果您尚未安装statsmodels,可以使用 conda 安装它:
conda install statsmodels
现在,要在AAPL对SPX回报的年度线性回归中执行:
In [144]: by_year.apply(regress, yvar="AAPL", xvars=["SPX"])
Out[144]:
SPX intercept
2003 1.195406 0.000710
2004 1.363463 0.004201
2005 1.766415 0.003246
2006 1.645496 0.000080
2007 1.198761 0.003438
2008 0.968016 -0.001110
2009 0.879103 0.002954
2010 1.052608 0.001261
2011 0.806605 0.001514
10.4 组转换和“展开”的 GroupBys
在 Apply: General split-apply-combine 中,我们看了一下在分组操作中执行转换的apply方法。还有另一个内置方法叫做transform,它类似于apply,但对您可以使用的函数种类施加了更多的约束:
-
它可以生成一个标量值广播到组的形状。
-
它可以生成与输入组相同形状的对象。
-
它不能改变其输入。
让我们考虑一个简单的例子以说明:
In [145]: df = pd.DataFrame({'key': ['a', 'b', 'c'] * 4,
.....: 'value': np.arange(12.)})
In [146]: df
Out[146]:
key value
0 a 0.0
1 b 1.0
2 c 2.0
3 a 3.0
4 b 4.0
5 c 5.0
6 a 6.0
7 b 7.0
8 c 8.0
9 a 9.0
10 b 10.0
11 c 11.0
这里是按键的组平均值:
In [147]: g = df.groupby('key')['value']
In [148]: g.mean()
Out[148]:
key
a 4.5
b 5.5
c 6.5
Name: value, dtype: float64
假设我们想要生成一个与df['value']相同形状的 Series,但值被按'key'分组后的平均值替换。我们可以传递一个计算单个组平均值的函数给transform:
In [149]: def get_mean(group):
.....: return group.mean()
In [150]: g.transform(get_mean)
Out[150]:
0 4.5
1 5.5
2 6.5
3 4.5
4 5.5
5 6.5
6 4.5
7 5.5
8 6.5
9 4.5
10 5.5
11 6.5
Name: value, dtype: float64
对于内置的聚合函数,我们可以像 GroupBy agg方法一样传递一个字符串别名:
In [151]: g.transform('mean')
Out[151]:
0 4.5
1 5.5
2 6.5
3 4.5
4 5.5
5 6.5
6 4.5
7 5.5
8 6.5
9 4.5
10 5.5
11 6.5
Name: value, dtype: float64
与apply一样,transform适用于返回 Series 的函数,但结果必须与输入的大小相同。例如,我们可以使用一个辅助函数将每个组乘以 2:
In [152]: def times_two(group):
.....: return group * 2
In [153]: g.transform(times_two)
Out[153]:
0 0.0
1 2.0
2 4.0
3 6.0
4 8.0
5 10.0
6 12.0
7 14.0
8 16.0
9 18.0
10 20.0
11 22.0
Name: value, dtype: float64
作为一个更复杂的例子,我们可以计算每个组按降序排名:
In [154]: def get_ranks(group):
.....: return group.rank(ascending=False)
In [155]: g.transform(get_ranks)
Out[155]:
0 4.0
1 4.0
2 4.0
3 3.0
4 3.0
5 3.0
6 2.0
7 2.0
8 2.0
9 1.0
10 1.0
11 1.0
Name: value, dtype: float64
考虑一个由简单聚合组成的组转换函数:
In [156]: def normalize(x):
.....: return (x - x.mean()) / x.std()
在这种情况下,我们可以使用transform或apply获得等效的结果:
In [157]: g.transform(normalize)
Out[157]:
0 -1.161895
1 -1.161895
2 -1.161895
3 -0.387298
4 -0.387298
5 -0.387298
6 0.387298
7 0.387298
8 0.387298
9 1.161895
10 1.161895
11 1.161895
Name: value, dtype: float64
In [158]: g.apply(normalize)
Out[158]:
key
a 0 -1.161895
3 -0.387298
6 0.387298
9 1.161895
b 1 -1.161895
4 -0.387298
7 0.387298
10 1.161895
c 2 -1.161895
5 -0.387298
8 0.387298
11 1.161895
Name: value, dtype: float64
内置的聚合函数如'mean'或'sum'通常比一般的apply函数快得多。当与transform一起使用时,这些函数也有一个“快速路径”。这使我们能够执行所谓的展开组操作:
In [159]: g.transform('mean')
Out[159]:
0 4.5
1 5.5
2 6.5
3 4.5
4 5.5
5 6.5
6 4.5
7 5.5
8 6.5
9 4.5
10 5.5
11 6.5
Name: value, dtype: float64
In [160]: normalized = (df['value'] - g.transform('mean')) / g.transform('std')
In [161]: normalized
Out[161]:
0 -1.161895
1 -1.161895
2 -1.161895
3 -0.387298
4 -0.387298
5 -0.387298
6 0.387298
7 0.387298
8 0.387298
9 1.161895
10 1.161895
11 1.161895
Name: value, dtype: float64
在这里,我们在多个 GroupBy 操作的输出之间进行算术运算,而不是编写一个函数并将其传递给groupby(...).apply。这就是所谓的“展开”。
尽管展开的组操作可能涉及多个组聚合,但矢量化操作的整体效益通常超过了这一点。
10.5 透视表和交叉制表
透视表是一种经常在电子表格程序和其他数据分析软件中找到的数据汇总工具。它通过一个或多个键对数据表进行聚合,将数据排列在一个矩形中,其中一些组键沿行排列,另一些沿列排列。在 Python 中,通过本章描述的groupby功能以及利用分层索引进行重塑操作,可以实现使用 pandas 的透视表。DataFrame 还有一个pivot_table方法,还有一个顶级的pandas.pivot_table函数。除了提供一个方便的groupby接口外,pivot_table还可以添加部分总计,也称为边际。
返回到小费数据集,假设您想要计算按day和smoker排列的组平均值的表格(默认的pivot_table聚合类型):
In [162]: tips.head()
Out[162]:
total_bill tip smoker day time size tip_pct
0 16.99 1.01 No Sun Dinner 2 0.059447
1 10.34 1.66 No Sun Dinner 3 0.160542
2 21.01 3.50 No Sun Dinner 3 0.166587
3 23.68 3.31 No Sun Dinner 2 0.139780
4 24.59 3.61 No Sun Dinner 4 0.146808
In [163]: tips.pivot_table(index=["day", "smoker"],
.....: values=["size", "tip", "tip_pct", "total_bill"])
Out[163]:
size tip tip_pct total_bill
day smoker
Fri No 2.250000 2.812500 0.151650 18.420000
Yes 2.066667 2.714000 0.174783 16.813333
Sat No 2.555556 3.102889 0.158048 19.661778
Yes 2.476190 2.875476 0.147906 21.276667
Sun No 2.929825 3.167895 0.160113 20.506667
Yes 2.578947 3.516842 0.187250 24.120000
Thur No 2.488889 2.673778 0.160298 17.113111
Yes 2.352941 3.030000 0.163863 19.190588
这可以直接使用groupby生成,使用tips.groupby(["day", "smoker"]).mean()。现在,假设我们只想计算tip_pct和size的平均值,并另外按time分组。我将smoker放在表格列中,time和day放在行中:
In [164]: tips.pivot_table(index=["time", "day"], columns="smoker",
.....: values=["tip_pct", "size"])
Out[164]:
size tip_pct
smoker No Yes No Yes
time day
Dinner Fri 2.000000 2.222222 0.139622 0.165347
Sat 2.555556 2.476190 0.158048 0.147906
Sun 2.929825 2.578947 0.160113 0.187250
Thur 2.000000 NaN 0.159744 NaN
Lunch Fri 3.000000 1.833333 0.187735 0.188937
Thur 2.500000 2.352941 0.160311 0.163863
我们可以通过传递margins=True来增加此表,以包括部分总计。这将添加All行和列标签,相应的值是单个层次内所有数据的组统计信息:
In [165]: tips.pivot_table(index=["time", "day"], columns="smoker",
.....: values=["tip_pct", "size"], margins=True)
Out[165]:
size tip_pct
smoker No Yes All No Yes All
time day
Dinner Fri 2.000000 2.222222 2.166667 0.139622 0.165347 0.158916
Sat 2.555556 2.476190 2.517241 0.158048 0.147906 0.153152
Sun 2.929825 2.578947 2.842105 0.160113 0.187250 0.166897
Thur 2.000000 NaN 2.000000 0.159744 NaN 0.159744
Lunch Fri 3.000000 1.833333 2.000000 0.187735 0.188937 0.188765
Thur 2.500000 2.352941 2.459016 0.160311 0.163863 0.161301
All 2.668874 2.408602 2.569672 0.159328 0.163196 0.160803
这里,All值是没有考虑吸烟者与非吸烟者(All列)或行中的两个级别分组的平均值(All行)。
要使用除mean之外的聚合函数,请将其传递给aggfunc关键字参数。例如,"count"或len将为您提供组大小的交叉制表(计数或频率)(尽管"count"将在数据组内排除空值的计数,而len不会):
In [166]: tips.pivot_table(index=["time", "smoker"], columns="day",
.....: values="tip_pct", aggfunc=len, margins=True)
Out[166]:
day Fri Sat Sun Thur All
time smoker
Dinner No 3.0 45.0 57.0 1.0 106
Yes 9.0 42.0 19.0 NaN 70
Lunch No 1.0 NaN NaN 44.0 45
Yes 6.0 NaN NaN 17.0 23
All 19.0 87.0 76.0 62.0 244
如果某些组合为空(或其他 NA),您可能希望传递一个fill_value:
In [167]: tips.pivot_table(index=["time", "size", "smoker"], columns="day",
.....: values="tip_pct", fill_value=0)
Out[167]:
day Fri Sat Sun Thur
time size smoker
Dinner 1 No 0.000000 0.137931 0.000000 0.000000
Yes 0.000000 0.325733 0.000000 0.000000
2 No 0.139622 0.162705 0.168859 0.159744
Yes 0.171297 0.148668 0.207893 0.000000
3 No 0.000000 0.154661 0.152663 0.000000
... ... ... ... ...
Lunch 3 Yes 0.000000 0.000000 0.000000 0.204952
4 No 0.000000 0.000000 0.000000 0.138919
Yes 0.000000 0.000000 0.000000 0.155410
5 No 0.000000 0.000000 0.000000 0.121389
6 No 0.000000 0.000000 0.000000 0.173706
[21 rows x 4 columns]
请参阅表 10.2 以获取pivot_table选项的摘要。
表 10.2:pivot_table选项
| 参数 | 描述 |
|---|---|
values |
要聚合的列名;默认情况下,聚合所有数值列 |
index |
要在生成的透视表的行上分组的列名或其他组键 |
columns |
要在生成的透视表的列上分组的列名或其他组键 |
aggfunc |
聚合函数或函数列表(默认为"mean");可以是在groupby上下文中有效的任何函数 |
fill_value |
替换结果表中的缺失值 |
dropna |
如果为True,则不包括所有条目都为NA的列 |
margins |
添加行/列小计和总计(默认为False) |
margins_name |
在传递margins=True时用于边缘行/列标签的名称;默认为"All" |
observed |
使用分类组键,如果为True,则仅显示键中的观察类别值,而不是所有类别 |
交叉制表:交叉制表
交叉制表(或简称为交叉制表)是计算组频率的透视表的一种特殊情况。这里是一个例子:
In [168]: from io import StringIO
In [169]: data = """Sample Nationality Handedness
.....: 1 USA Right-handed
.....: 2 Japan Left-handed
.....: 3 USA Right-handed
.....: 4 Japan Right-handed
.....: 5 Japan Left-handed
.....: 6 Japan Right-handed
.....: 7 USA Right-handed
.....: 8 USA Left-handed
.....: 9 Japan Right-handed
.....: 10 USA Right-handed"""
.....:
In [170]: data = pd.read_table(StringIO(data), sep="\s+")
In [171]: data
Out[171]:
Sample Nationality Handedness
0 1 USA Right-handed
1 2 Japan Left-handed
2 3 USA Right-handed
3 4 Japan Right-handed
4 5 Japan Left-handed
5 6 Japan Right-handed
6 7 USA Right-handed
7 8 USA Left-handed
8 9 Japan Right-handed
9 10 USA Right-handed
作为一些调查分析的一部分,我们可能希望按国籍和惯用手总结这些数据。您可以使用pivot_table来做到这一点,但pandas.crosstab函数可能更方便:
In [172]: pd.crosstab(data["Nationality"], data["Handedness"], margins=True)
Out[172]:
Handedness Left-handed Right-handed All
Nationality
Japan 2 3 5
USA 1 4 5
All 3 7 10
crosstab的前两个参数可以是数组、Series 或数组列表。就像在小费数据中一样:
In [173]: pd.crosstab([tips["time"], tips["day"]], tips["smoker"], margins=True)
Out[173]:
smoker No Yes All
time day
Dinner Fri 3 9 12
Sat 45 42 87
Sun 57 19 76
Thur 1 0 1
Lunch Fri 1 6 7
Thur 44 17 61
All 151 93 244
10.6 结论
掌握 pandas 的数据分组工具可以帮助数据清洗和建模或统计分析工作。在 Ch 13:数据分析示例中,我们将查看几个更多实际数据上使用groupby的示例用例。
在下一章中,我们将把注意力转向时间序列数据。
十一、时间序列
原文:
wesmckinney.com/book/time-series译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
时间序列数据是许多不同领域中的结构化数据的重要形式,如金融、经济、生态学、神经科学和物理学。任何在许多时间点重复记录的东西都构成一个时间序列。许多时间序列是固定频率的,也就是说,数据点按照某种规则定期发生,例如每 15 秒、每 5 分钟或每月一次。时间序列也可以是不规则的,没有固定的时间单位或单位之间的偏移。如何标记和引用时间序列数据取决于应用程序,您可能有以下之一:
时间戳
特定的时间点。
固定周期
例如 2017 年 1 月的整个月,或 2020 年的整年。
时间间隔
由开始和结束时间戳指示。周期可以被视为间隔的特殊情况。
实验或经过的时间
每个时间戳都是相对于特定开始时间的时间度量(例如,自放入烤箱以来每秒烘烤的饼干的直径),从 0 开始。
在本章中,我主要关注前三类时间序列,尽管许多技术也可以应用于实验时间序列,其中索引可能是整数或浮点数,表示从实验开始经过的时间。最简单的时间序列是由时间戳索引的。
提示:
pandas 还支持基于时间差的索引,这是一种表示实验或经过时间的有用方式。我们在本书中没有探讨时间差索引,但您可以在pandas 文档中了解更多。
pandas 提供了许多内置的时间序列工具和算法。您可以高效地处理大型时间序列,对不规则和固定频率的时间序列进行切片、聚合和重采样。其中一些工具对金融和经济应用很有用,但您当然也可以用它们来分析服务器日志数据。
与其他章节一样,我们首先导入 NumPy 和 pandas:
In [12]: import numpy as np
In [13]: import pandas as pd
11.1 日期和时间数据类型和工具
Python 标准库包括用于日期和时间数据以及与日历相关的功能的数据类型。datetime、time和calendar模块是主要的起点。datetime.datetime类型,或简称datetime,被广泛使用:
In [14]: from datetime import datetime
In [15]: now = datetime.now()
In [16]: now
Out[16]: datetime.datetime(2023, 4, 12, 13, 9, 16, 484533)
In [17]: now.year, now.month, now.day
Out[17]: (2023, 4, 12)
datetime 存储日期和时间,精确到微秒。datetime.timedelta,或简称timedelta,表示两个datetime对象之间的时间差:
In [18]: delta = datetime(2011, 1, 7) - datetime(2008, 6, 24, 8, 15)
In [19]: delta
Out[19]: datetime.timedelta(days=926, seconds=56700)
In [20]: delta.days
Out[20]: 926
In [21]: delta.seconds
Out[21]: 56700
您可以将timedelta或其倍数添加(或减去)到datetime对象中,以产生一个新的偏移对象:
In [22]: from datetime import timedelta
In [23]: start = datetime(2011, 1, 7)
In [24]: start + timedelta(12)
Out[24]: datetime.datetime(2011, 1, 19, 0, 0)
In [25]: start - 2 * timedelta(12)
Out[25]: datetime.datetime(2010, 12, 14, 0, 0)
表 11.1 总结了datetime模块中的数据类型。虽然本章主要关注 pandas 中的数据类型和高级时间序列操作,但您可能会在 Python 的许多其他地方遇到基于datetime的类型。
表 11.1:datetime模块中的类型
| 类型 | 描述 |
|---|---|
date |
使用公历存储日期(年,月,日) |
time |
以小时,分钟,秒和微秒存储一天中的时间 |
datetime |
存储日期和时间 |
timedelta |
两个datetime值之间的差异(以天,秒和微秒计) |
tzinfo |
存储时区信息的基本类型 |
在字符串和日期时间之间转换
您可以使用str或strftime方法对datetime对象和 pandas 的Timestamp对象进行格式化为字符串,传递格式规范:
In [26]: stamp = datetime(2011, 1, 3)
In [27]: str(stamp)
Out[27]: '2011-01-03 00:00:00'
In [28]: stamp.strftime("%Y-%m-%d")
Out[28]: '2011-01-03'
请参阅表 11.2 以获取完整的格式代码列表。
表 11.2:datetime格式规范(ISO C89 兼容)
| 类型 | 描述 |
|---|---|
%Y |
四位数年份 |
%y |
两位数年份 |
%m |
两位数月份[01, 12] |
%d |
两位数日期[01, 31] |
%H |
小时(24 小时制)[00, 23] |
%I |
小时(12 小时制)[01, 12] |
%M |
两位数分钟[00, 59] |
%S |
秒[00, 61](秒 60, 61 表示闰秒) |
%f |
微秒作为整数,零填充(从 000000 到 999999) |
%j |
一年中的日期作为零填充的整数(从 001 到 336) |
%w |
星期几作为整数[0(星期日),6] |
%u |
从 1 开始的星期几整数,其中 1 是星期一。 |
%U |
一年中的周数[00, 53]; 星期日被认为是一周的第一天,年初第一个星期日之前的日子被称为“第 0 周” |
%W |
一年中的周数[00, 53]; 星期一被认为是一周的第一天,年初第一个星期一之前的日子被称为“第 0 周” |
%z |
UTC 时区偏移为+HHMM或-HHMM; 如果时区是 naive,则为空 |
%Z |
时区名称作为字符串,如果没有时区则为空字符串 |
%F |
%Y-%m-%d的快捷方式(例如,2012-4-18) |
%D |
%m/%d/%y的快捷方式(例如,04/18/12) |
您可以使用许多相同的格式代码使用datetime.strptime将字符串转换为日期(但是一些代码,如%F,不能使用):
In [29]: value = "2011-01-03"
In [30]: datetime.strptime(value, "%Y-%m-%d")
Out[30]: datetime.datetime(2011, 1, 3, 0, 0)
In [31]: datestrs = ["7/6/2011", "8/6/2011"]
In [32]: [datetime.strptime(x, "%m/%d/%Y") for x in datestrs]
Out[32]:
[datetime.datetime(2011, 7, 6, 0, 0),
datetime.datetime(2011, 8, 6, 0, 0)]
datetime.strptime 是一种解析具有已知格式的日期的方法。
pandas 通常面向处理日期数组,无论是作为轴索引还是数据框中的列。pandas.to_datetime方法解析许多不同类型的日期表示。标准日期格式如 ISO 8601 可以快速解析:
In [33]: datestrs = ["2011-07-06 12:00:00", "2011-08-06 00:00:00"]
In [34]: pd.to_datetime(datestrs)
Out[34]: DatetimeIndex(['2011-07-06 12:00:00', '2011-08-06 00:00:00'], dtype='dat
etime64[ns]', freq=None)
它还处理应被视为缺失的值(None,空字符串等):
In [35]: idx = pd.to_datetime(datestrs + [None])
In [36]: idx
Out[36]: DatetimeIndex(['2011-07-06 12:00:00', '2011-08-06 00:00:00', 'NaT'], dty
pe='datetime64[ns]', freq=None)
In [37]: idx[2]
Out[37]: NaT
In [38]: pd.isna(idx)
Out[38]: array([False, False, True])
NaT(不是时间)是 pandas 中的时间戳数据的空值。
注意
dateutil.parser是一个有用但不完美的工具。值得注意的是,它会将一些字符串识别为日期,而您可能希望它不会;例如,"42"将被解析为年份2042与今天的日历日期相对应。
datetime对象还具有许多针对其他国家或语言系统的特定于区域的格式选项。例如,德国或法国系统上的缩写月份名称与英语系统上的不同。请参阅表 11.3 以获取列表。
表 11.3:特定于区域的日期格式化
| 类型 | 描述 |
|---|---|
%a |
缩写的星期几名称 |
%A |
完整的星期几名称 |
%b |
缩写的月份名称 |
%B |
完整的月份名称 |
%c |
完整的日期和时间(例如,‘周二 2012 年 5 月 1 日 下午 04:20:57’) |
%p |
AM 或 PM 的本地等效 |
%x |
本地适用的格式化日期(例如,在美国,2012 年 5 月 1 日为‘05/01/2012’) |
| %X | 本地适用的时间(例如,‘下午 04:24:12’) |
11.2 时间序列基础知识
pandas 中的一种基本类型的时间序列对象是由时间戳索引的 Series,通常在 pandas 之外表示为 Python 字符串或datetime对象:
In [39]: dates = [datetime(2011, 1, 2), datetime(2011, 1, 5),
....: datetime(2011, 1, 7), datetime(2011, 1, 8),
....: datetime(2011, 1, 10), datetime(2011, 1, 12)]
In [40]: ts = pd.Series(np.random.standard_normal(6), index=dates)
In [41]: ts
Out[41]:
2011-01-02 -0.204708
2011-01-05 0.478943
2011-01-07 -0.519439
2011-01-08 -0.555730
2011-01-10 1.965781
2011-01-12 1.393406
dtype: float64
在幕后,这些datetime对象已被放入DatetimeIndex中:
In [42]: ts.index
Out[42]:
DatetimeIndex(['2011-01-02', '2011-01-05', '2011-01-07', '2011-01-08',
'2011-01-10', '2011-01-12'],
dtype='datetime64[ns]', freq=None)
与其他 Series 一样,不同索引的时间序列之间的算术运算会自动对齐日期:
In [43]: ts + ts[::2]
Out[43]:
2011-01-02 -0.409415
2011-01-05 NaN
2011-01-07 -1.038877
2011-01-08 NaN
2011-01-10 3.931561
2011-01-12 NaN
dtype: float64
请记住,ts[::2]选择ts中的每个第二个元素。
pandas 使用 NumPy 的datetime64数据类型以纳秒分辨率存储时间戳:
In [44]: ts.index.dtype
Out[44]: dtype('<M8[ns]')
来自DatetimeIndex的标量值是 pandas 的Timestamp对象:
In [45]: stamp = ts.index[0]
In [46]: stamp
Out[46]: Timestamp('2011-01-02 00:00:00')
pandas.Timestamp可以替代大多数您将使用datetime对象的地方。然而,反之则不成立,因为pandas.Timestamp可以存储纳秒精度数据,而datetime仅存储微秒精度。此外,pandas.Timestamp可以存储频率信息(如果有的话),并且了解如何执行时区转换和其他类型的操作。稍后在时区处理中会更详细地介绍这两个方面。
索引、选择、子集
当您根据标签索引和选择数据时,时间序列的行为与任何其他 Series 相同:
In [47]: stamp = ts.index[2]
In [48]: ts[stamp]
Out[48]: -0.5194387150567381
为了方便起见,您还可以传递一个可解释为日期的字符串:
In [49]: ts["2011-01-10"]
Out[49]: 1.9657805725027142
对于更长的时间序列,可以传递一年或仅一年和一个月以轻松选择数据的片段(pandas.date_range在生成日期范围中有更详细的讨论):
In [50]: longer_ts = pd.Series(np.random.standard_normal(1000),
....: index=pd.date_range("2000-01-01", periods=1000))
In [51]: longer_ts
Out[51]:
2000-01-01 0.092908
2000-01-02 0.281746
2000-01-03 0.769023
2000-01-04 1.246435
2000-01-05 1.007189
...
2002-09-22 0.930944
2002-09-23 -0.811676
2002-09-24 -1.830156
2002-09-25 -0.138730
2002-09-26 0.334088
Freq: D, Length: 1000, dtype: float64
In [52]: longer_ts["2001"]
Out[52]:
2001-01-01 1.599534
2001-01-02 0.474071
2001-01-03 0.151326
2001-01-04 -0.542173
2001-01-05 -0.475496
...
2001-12-27 0.057874
2001-12-28 -0.433739
2001-12-29 0.092698
2001-12-30 -1.397820
2001-12-31 1.457823
Freq: D, Length: 365, dtype: float64
在这里,字符串"2001"被解释为一年,并选择了那个时间段。如果指定月份,也可以这样做:
In [53]: longer_ts["2001-05"]
Out[53]:
2001-05-01 -0.622547
2001-05-02 0.936289
2001-05-03 0.750018
2001-05-04 -0.056715
2001-05-05 2.300675
...
2001-05-27 0.235477
2001-05-28 0.111835
2001-05-29 -1.251504
2001-05-30 -2.949343
2001-05-31 0.634634
Freq: D, Length: 31, dtype: float64
使用datetime对象进行切片也是有效的:
In [54]: ts[datetime(2011, 1, 7):]
Out[54]:
2011-01-07 -0.519439
2011-01-08 -0.555730
2011-01-10 1.965781
2011-01-12 1.393406
dtype: float64
In [55]: ts[datetime(2011, 1, 7):datetime(2011, 1, 10)]
Out[55]:
2011-01-07 -0.519439
2011-01-08 -0.555730
2011-01-10 1.965781
dtype: float64
因为大多数时间序列数据是按时间顺序排列的,所以可以使用不包含在时间序列中的时间戳进行切片以执行范围查询:
In [56]: ts
Out[56]:
2011-01-02 -0.204708
2011-01-05 0.478943
2011-01-07 -0.519439
2011-01-08 -0.555730
2011-01-10 1.965781
2011-01-12 1.393406
dtype: float64
In [57]: ts["2011-01-06":"2011-01-11"]
Out[57]:
2011-01-07 -0.519439
2011-01-08 -0.555730
2011-01-10 1.965781
dtype: float64
与以前一样,您可以传递字符串日期、datetime或时间戳。请记住,以这种方式切片会在源时间序列上产生视图,就像在 NumPy 数组上切片一样。这意味着不会复制任何数据,并且对切片的修改将反映在原始数据中。
有一个等效的实例方法,truncate,它在两个日期之间切片一个 Series:
In [58]: ts.truncate(after="2011-01-09")
Out[58]:
2011-01-02 -0.204708
2011-01-05 0.478943
2011-01-07 -0.519439
2011-01-08 -0.555730
dtype: float64
对于 DataFrame 来说,所有这些都是正确的,可以对其行进行索引:
In [59]: dates = pd.date_range("2000-01-01", periods=100, freq="W-WED")
In [60]: long_df = pd.DataFrame(np.random.standard_normal((100, 4)),
....: index=dates,
....: columns=["Colorado", "Texas",
....: "New York", "Ohio"])
In [61]: long_df.loc["2001-05"]
Out[61]:
Colorado Texas New York Ohio
2001-05-02 -0.006045 0.490094 -0.277186 -0.707213
2001-05-09 -0.560107 2.735527 0.927335 1.513906
2001-05-16 0.538600 1.273768 0.667876 -0.969206
2001-05-23 1.676091 -0.817649 0.050188 1.951312
2001-05-30 3.260383 0.963301 1.201206 -1.852001
具有重复索引的时间序列
在某些应用程序中,可能会有多个数据观测值落在特定的时间戳上。这里是一个例子:
In [62]: dates = pd.DatetimeIndex(["2000-01-01", "2000-01-02", "2000-01-02",
....: "2000-01-02", "2000-01-03"])
In [63]: dup_ts = pd.Series(np.arange(5), index=dates)
In [64]: dup_ts
Out[64]:
2000-01-01 0
2000-01-02 1
2000-01-02 2
2000-01-02 3
2000-01-03 4
dtype: int64
我们可以通过检查其is_unique属性来确定索引不是唯一的:
In [65]: dup_ts.index.is_unique
Out[65]: False
现在,对这个时间序列进行索引将产生标量值或切片,具体取决于时间戳是否重复:
In [66]: dup_ts["2000-01-03"] # not duplicated
Out[66]: 4
In [67]: dup_ts["2000-01-02"] # duplicated
Out[67]:
2000-01-02 1
2000-01-02 2
2000-01-02 3
dtype: int64
假设您想要聚合具有非唯一时间戳的数据。一种方法是使用groupby并传递level=0(唯一的级别):
In [68]: grouped = dup_ts.groupby(level=0)
In [69]: grouped.mean()
Out[69]:
2000-01-01 0.0
2000-01-02 2.0
2000-01-03 4.0
dtype: float64
In [70]: grouped.count()
Out[70]:
2000-01-01 1
2000-01-02 3
2000-01-03 1
dtype: int64
11.3 日期范围、频率和移位
在 pandas 中,通常假定通用时间序列是不规则的;也就是说,它们没有固定的频率。对于许多应用程序来说,这是足够的。然而,通常希望相对于固定频率(如每日、每月或每 15 分钟)进行工作,即使这意味着在时间序列中引入缺失值。幸运的是,pandas 具有一整套标准时间序列频率和重新采样工具(稍后在重新采样和频率转换中更详细地讨论),可以推断频率并生成固定频率的日期范围。例如,您可以通过调用resample将示例时间序列转换为固定的每日频率:
In [71]: ts
Out[71]:
2011-01-02 -0.204708
2011-01-05 0.478943
2011-01-07 -0.519439
2011-01-08 -0.555730
2011-01-10 1.965781
2011-01-12 1.393406
dtype: float64
In [72]: resampler = ts.resample("D")
In [73]: resampler
Out[73]: <pandas.core.resample.DatetimeIndexResampler object at 0x17b0e7bb0>
字符串"D"被解释为每日频率。
在频率之间的转换或重新采样是一个足够大的主题,后面会有自己的部分(重新采样和频率转换)。在这里,我将向您展示如何使用基本频率及其倍数。
生成日期范围
虽然我之前没有解释过,但pandas.date_range负责根据特定频率生成具有指定长度的DatetimeIndex:
In [74]: index = pd.date_range("2012-04-01", "2012-06-01")
In [75]: index
Out[75]:
DatetimeIndex(['2012-04-01', '2012-04-02', '2012-04-03', '2012-04-04',
'2012-04-05', '2012-04-06', '2012-04-07', '2012-04-08',
'2012-04-09', '2012-04-10', '2012-04-11', '2012-04-12',
'2012-04-13', '2012-04-14', '2012-04-15', '2012-04-16',
'2012-04-17', '2012-04-18', '2012-04-19', '2012-04-20',
'2012-04-21', '2012-04-22', '2012-04-23', '2012-04-24',
'2012-04-25', '2012-04-26', '2012-04-27', '2012-04-28',
'2012-04-29', '2012-04-30', '2012-05-01', '2012-05-02',
'2012-05-03', '2012-05-04', '2012-05-05', '2012-05-06',
'2012-05-07', '2012-05-08', '2012-05-09', '2012-05-10',
'2012-05-11', '2012-05-12', '2012-05-13', '2012-05-14',
'2012-05-15', '2012-05-16', '2012-05-17', '2012-05-18',
'2012-05-19', '2012-05-20', '2012-05-21', '2012-05-22',
'2012-05-23', '2012-05-24', '2012-05-25', '2012-05-26',
'2012-05-27', '2012-05-28', '2012-05-29', '2012-05-30',
'2012-05-31', '2012-06-01'],
dtype='datetime64[ns]', freq='D')
默认情况下,pandas.date_range生成每日时间戳。如果只传递开始或结束日期,必须传递一个周期数来生成:
In [76]: pd.date_range(start="2012-04-01", periods=20)
Out[76]:
DatetimeIndex(['2012-04-01', '2012-04-02', '2012-04-03', '2012-04-04',
'2012-04-05', '2012-04-06', '2012-04-07', '2012-04-08',
'2012-04-09', '2012-04-10', '2012-04-11', '2012-04-12',
'2012-04-13', '2012-04-14', '2012-04-15', '2012-04-16',
'2012-04-17', '2012-04-18', '2012-04-19', '2012-04-20'],
dtype='datetime64[ns]', freq='D')
In [77]: pd.date_range(end="2012-06-01", periods=20)
Out[77]:
DatetimeIndex(['2012-05-13', '2012-05-14', '2012-05-15', '2012-05-16',
'2012-05-17', '2012-05-18', '2012-05-19', '2012-05-20',
'2012-05-21', '2012-05-22', '2012-05-23', '2012-05-24',
'2012-05-25', '2012-05-26', '2012-05-27', '2012-05-28',
'2012-05-29', '2012-05-30', '2012-05-31', '2012-06-01'],
dtype='datetime64[ns]', freq='D')
开始和结束日期为生成的日期索引定义了严格的边界。例如,如果您想要一个包含每个月最后一个工作日的日期索引,您将传递 "BM" 频率(月底的工作日;请参阅 Table 11.4 中更完整的频率列表),只有落在日期区间内或日期区间内的日期将被包括:
In [78]: pd.date_range("2000-01-01", "2000-12-01", freq="BM")
Out[78]:
DatetimeIndex(['2000-01-31', '2000-02-29', '2000-03-31', '2000-04-28',
'2000-05-31', '2000-06-30', '2000-07-31', '2000-08-31',
'2000-09-29', '2000-10-31', '2000-11-30'],
dtype='datetime64[ns]', freq='BM')
Table 11.4: 基础时间序列频率(不全面)
| 别名 | 偏移类型 | 描述 |
|---|---|---|
D |
Day |
日历日 |
B |
BusinessDay |
工作日 |
H |
Hour |
每小时 |
T 或 min |
Minute |
每分钟一次 |
S |
Second |
每秒一次 |
L 或 ms |
Milli |
毫秒(1 秒的 1/1,000) |
U |
Micro |
微秒(1 秒的 1/1,000,000) |
M |
MonthEnd |
月份的最后一个日历日 |
BM |
BusinessMonthEnd |
月份的最后一个工作日(工作日) |
MS |
MonthBegin |
月份的第一个日历日 |
BMS |
BusinessMonthBegin |
月份的第一个工作日 |
W-MON, W-TUE, ... |
Week |
每周在给定星期的某一天(MON、TUE、WED、THU、FRI、SAT 或 SUN) |
WOM-1MON, WOM-2MON, ... |
WeekOfMonth |
在月份的第一、第二、第三或第四周生成每周日期(例如,每月的第三个星期五为 WOM-3FRI) |
Q-JAN, Q-FEB, ... |
QuarterEnd |
季度日期锚定在每个月的最后一个日历日,年终在指定月份(JAN、FEB、MAR、APR、MAY、JUN、JUL、AUG、SEP、OCT、NOV 或 DEC) |
BQ-JAN, BQ-FEB, ... |
BusinessQuarterEnd |
季度日期锚定在每个月的最后一个工作日,年终在指定月份 |
QS-JAN, QS-FEB, ... |
QuarterBegin |
季度日期锚定在每个月的第一个日历日,年终在指定月份 |
BQS-JAN, BQS-FEB, ... |
BusinessQuarterBegin |
季度日期锚定在每个月的第一个工作日,年终在指定月份 |
A-JAN, A-FEB, ... |
YearEnd |
年度日期锚定在给定月份的最后一个日历日(JAN、FEB、MAR、APR、MAY、JUN、JUL、AUG、SEP、OCT、NOV 或 DEC) |
BA-JAN, BA-FEB, ... |
BusinessYearEnd |
年度日期锚定在给定月份的最后一个工作日 |
AS-JAN, AS-FEB, ... |
YearBegin |
年度日期锚定在给定月份的第一天 |
BAS-JAN, BAS-FEB, ... |
BusinessYearBegin |
年度日期锚定在给定月份的第一个工作日 |
pandas.date_range 默认保留开始或结束时间戳的时间(如果有):
In [79]: pd.date_range("2012-05-02 12:56:31", periods=5)
Out[79]:
DatetimeIndex(['2012-05-02 12:56:31', '2012-05-03 12:56:31',
'2012-05-04 12:56:31', '2012-05-05 12:56:31',
'2012-05-06 12:56:31'],
dtype='datetime64[ns]', freq='D')
有时您会有带有时间信息的开始或结束日期,但希望生成一组时间戳,规范化 为午夜作为约定。为此,有一个 normalize 选项:
In [80]: pd.date_range("2012-05-02 12:56:31", periods=5, normalize=True)
Out[80]:
DatetimeIndex(['2012-05-02', '2012-05-03', '2012-05-04', '2012-05-05',
'2012-05-06'],
dtype='datetime64[ns]', freq='D')
频率和日期偏移
在 pandas 中,频率由 基础频率 和一个乘数组成。基础频率通常用字符串别名表示,如 "M" 表示每月或 "H" 表示每小时。对于每个基础频率,都有一个称为 日期偏移 的对象。例如,小时频率可以用 Hour 类表示:
In [81]: from pandas.tseries.offsets import Hour, Minute
In [82]: hour = Hour()
In [83]: hour
Out[83]: <Hour>
您可以通过传递一个整数来定义偏移的倍数:
In [84]: four_hours = Hour(4)
In [85]: four_hours
Out[85]: <4 * Hours>
在大多数应用程序中,您通常不需要显式创建这些对象之一;而是使用类似 "H" 或 "4H" 的字符串别名。在基础频率前放置一个整数会创建一个倍数:
In [86]: pd.date_range("2000-01-01", "2000-01-03 23:59", freq="4H")
Out[86]:
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 04:00:00',
'2000-01-01 08:00:00', '2000-01-01 12:00:00',
'2000-01-01 16:00:00', '2000-01-01 20:00:00',
'2000-01-02 00:00:00', '2000-01-02 04:00:00',
'2000-01-02 08:00:00', '2000-01-02 12:00:00',
'2000-01-02 16:00:00', '2000-01-02 20:00:00',
'2000-01-03 00:00:00', '2000-01-03 04:00:00',
'2000-01-03 08:00:00', '2000-01-03 12:00:00',
'2000-01-03 16:00:00', '2000-01-03 20:00:00'],
dtype='datetime64[ns]', freq='4H')
许多偏移可以通过加法组合:
In [87]: Hour(2) + Minute(30)
Out[87]: <150 * Minutes>
同样,您可以传递频率字符串,如 "1h30min",这将有效地解析为相同的表达式:
In [88]: pd.date_range("2000-01-01", periods=10, freq="1h30min")
Out[88]:
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 01:30:00',
'2000-01-01 03:00:00', '2000-01-01 04:30:00',
'2000-01-01 06:00:00', '2000-01-01 07:30:00',
'2000-01-01 09:00:00', '2000-01-01 10:30:00',
'2000-01-01 12:00:00', '2000-01-01 13:30:00'],
dtype='datetime64[ns]', freq='90T')
一些频率描述的是时间点,这些时间点不是均匀间隔的。例如,"M"(日历月底)和 "BM"(月底的最后一个工作日/工作日)取决于一个月的天数,以及在后一种情况下,月份是否在周末结束。我们将这些称为 锚定 偏移。
请参考 Table 11.4 以获取 pandas 中可用的频率代码和日期偏移类的列表。
注意
用户可以定义自己的自定义频率类,以提供 pandas 中不可用的日期逻辑,但这些完整的细节超出了本书的范围。
月份周日期
一个有用的频率类是“月份周”,从WOM开始。这使您可以获得每个月的第三个星期五这样的日期:
In [89]: monthly_dates = pd.date_range("2012-01-01", "2012-09-01", freq="WOM-3FRI
")
In [90]: list(monthly_dates)
Out[90]:
[Timestamp('2012-01-20 00:00:00'),
Timestamp('2012-02-17 00:00:00'),
Timestamp('2012-03-16 00:00:00'),
Timestamp('2012-04-20 00:00:00'),
Timestamp('2012-05-18 00:00:00'),
Timestamp('2012-06-15 00:00:00'),
Timestamp('2012-07-20 00:00:00'),
Timestamp('2012-08-17 00:00:00')]
移动(领先和滞后)数据
移动指的是通过时间向后和向前移动数据。Series 和 DataFrame 都有一个shift方法,用于进行简单的向前或向后移位,保持索引不变:
In [91]: ts = pd.Series(np.random.standard_normal(4),
....: index=pd.date_range("2000-01-01", periods=4, freq="M"))
In [92]: ts
Out[92]:
2000-01-31 -0.066748
2000-02-29 0.838639
2000-03-31 -0.117388
2000-04-30 -0.517795
Freq: M, dtype: float64
In [93]: ts.shift(2)
Out[93]:
2000-01-31 NaN
2000-02-29 NaN
2000-03-31 -0.066748
2000-04-30 0.838639
Freq: M, dtype: float64
In [94]: ts.shift(-2)
Out[94]:
2000-01-31 -0.117388
2000-02-29 -0.517795
2000-03-31 NaN
2000-04-30 NaN
Freq: M, dtype: float64
当我们这样移动时,缺失数据会在时间序列的开始或结束引入。
shift的一个常见用法是计算时间序列或多个时间序列的连续百分比变化作为 DataFrame 列。这表示为:
ts / ts.shift(1) - 1
因为无时区移位会保持索引不变,所以会丢失一些数据。因此,如果知道频率,可以将其传递给shift以推进时间戳,而不仅仅是数据:
In [95]: ts.shift(2, freq="M")
Out[95]:
2000-03-31 -0.066748
2000-04-30 0.838639
2000-05-31 -0.117388
2000-06-30 -0.517795
Freq: M, dtype: float64
也可以传递其他频率,这样可以在如何领先和滞后数据方面提供一些灵活性:
In [96]: ts.shift(3, freq="D")
Out[96]:
2000-02-03 -0.066748
2000-03-03 0.838639
2000-04-03 -0.117388
2000-05-03 -0.517795
dtype: float64
In [97]: ts.shift(1, freq="90T")
Out[97]:
2000-01-31 01:30:00 -0.066748
2000-02-29 01:30:00 0.838639
2000-03-31 01:30:00 -0.117388
2000-04-30 01:30:00 -0.517795
dtype: float64
这里的T代表分钟。请注意,这里的freq参数表示要应用于时间戳的偏移量,但它不会改变数据的基础频率(如果有的话)。
使用偏移移动日期
pandas 日期偏移也可以与datetime或Timestamp对象一起使用:
In [98]: from pandas.tseries.offsets import Day, MonthEnd
In [99]: now = datetime(2011, 11, 17)
In [100]: now + 3 * Day()
Out[100]: Timestamp('2011-11-20 00:00:00')
如果添加像MonthEnd这样的锚定偏移,第一个增量将根据频率规则“向前滚动”日期到下一个日期:
In [101]: now + MonthEnd()
Out[101]: Timestamp('2011-11-30 00:00:00')
In [102]: now + MonthEnd(2)
Out[102]: Timestamp('2011-12-31 00:00:00')
锚定偏移可以通过简单使用它们的rollforward和rollback方法明确地“滚动”日期向前或向后:
In [103]: offset = MonthEnd()
In [104]: offset.rollforward(now)
Out[104]: Timestamp('2011-11-30 00:00:00')
In [105]: offset.rollback(now)
Out[105]: Timestamp('2011-10-31 00:00:00')
日期偏移的一个创造性用法是将这些方法与groupby一起使用:
In [106]: ts = pd.Series(np.random.standard_normal(20),
.....: index=pd.date_range("2000-01-15", periods=20, freq="4D")
)
In [107]: ts
Out[107]:
2000-01-15 -0.116696
2000-01-19 2.389645
2000-01-23 -0.932454
2000-01-27 -0.229331
2000-01-31 -1.140330
2000-02-04 0.439920
2000-02-08 -0.823758
2000-02-12 -0.520930
2000-02-16 0.350282
2000-02-20 0.204395
2000-02-24 0.133445
2000-02-28 0.327905
2000-03-03 0.072153
2000-03-07 0.131678
2000-03-11 -1.297459
2000-03-15 0.997747
2000-03-19 0.870955
2000-03-23 -0.991253
2000-03-27 0.151699
2000-03-31 1.266151
Freq: 4D, dtype: float64
In [108]: ts.groupby(MonthEnd().rollforward).mean()
Out[108]:
2000-01-31 -0.005833
2000-02-29 0.015894
2000-03-31 0.150209
dtype: float64
当然,更简单更快的方法是使用resample(我们将在重新采样和频率转换中更深入地讨论这个问题):
In [109]: ts.resample("M").mean()
Out[109]:
2000-01-31 -0.005833
2000-02-29 0.015894
2000-03-31 0.150209
Freq: M, dtype: float64
11.4 时区处理
与时区一起工作可能是时间序列操作中最不愉快的部分之一。因此,许多时间序列用户选择在协调世界时或UTC中处理时间序列,这是地理独立的国际标准。时区表示为与 UTC 的偏移;例如,纽约在夏令时(DST)期间比 UTC 晚四个小时,在其他时间比 UTC 晚五个小时。
在 Python 中,时区信息来自第三方pytz库(可通过 pip 或 conda 安装),该库公开了Olson 数据库,这是世界时区信息的编译。这对于历史数据尤为重要,因为夏令时转换日期(甚至 UTC 偏移)已根据地区法律多次更改。在美国,自 1900 年以来,夏令时转换时间已经多次更改!
有关pytz库的详细信息,您需要查看该库的文档。就本书而言,pandas 封装了pytz的功能,因此您可以忽略其 API 以外的时区名称。由于 pandas 对pytz有硬性依赖,因此不需要单独安装它。时区名称可以在交互式和文档中找到:
In [110]: import pytz
In [111]: pytz.common_timezones[-5:]
Out[111]: ['US/Eastern', 'US/Hawaii', 'US/Mountain', 'US/Pacific', 'UTC']
要从pytz中获取时区对象,请使用pytz.timezone:
In [112]: tz = pytz.timezone("America/New_York")
In [113]: tz
Out[113]: <DstTzInfo 'America/New_York' LMT-1 day, 19:04:00 STD>
pandas 中的方法将接受时区名称或这些对象。
时区本地化和转换
默认情况下,pandas 中的时间序列是时区无关的。例如,考虑以下时间序列:
In [114]: dates = pd.date_range("2012-03-09 09:30", periods=6)
In [115]: ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)
In [116]: ts
Out[116]:
2012-03-09 09:30:00 -0.202469
2012-03-10 09:30:00 0.050718
2012-03-11 09:30:00 0.639869
2012-03-12 09:30:00 0.597594
2012-03-13 09:30:00 -0.797246
2012-03-14 09:30:00 0.472879
Freq: D, dtype: float64
索引的tz字段为None:
In [117]: print(ts.index.tz)
None
可以生成带有时区设置的日期范围:
In [118]: pd.date_range("2012-03-09 09:30", periods=10, tz="UTC")
Out[118]:
DatetimeIndex(['2012-03-09 09:30:00+00:00', '2012-03-10 09:30:00+00:00',
'2012-03-11 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
'2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00',
'2012-03-15 09:30:00+00:00', '2012-03-16 09:30:00+00:00',
'2012-03-17 09:30:00+00:00', '2012-03-18 09:30:00+00:00'],
dtype='datetime64[ns, UTC]', freq='D')
从无时区转换为本地化(重新解释为在特定时区中观察到)由tz_localize方法处理:
In [119]: ts
Out[119]:
2012-03-09 09:30:00 -0.202469
2012-03-10 09:30:00 0.050718
2012-03-11 09:30:00 0.639869
2012-03-12 09:30:00 0.597594
2012-03-13 09:30:00 -0.797246
2012-03-14 09:30:00 0.472879
Freq: D, dtype: float64
In [120]: ts_utc = ts.tz_localize("UTC")
In [121]: ts_utc
Out[121]:
2012-03-09 09:30:00+00:00 -0.202469
2012-03-10 09:30:00+00:00 0.050718
2012-03-11 09:30:00+00:00 0.639869
2012-03-12 09:30:00+00:00 0.597594
2012-03-13 09:30:00+00:00 -0.797246
2012-03-14 09:30:00+00:00 0.472879
Freq: D, dtype: float64
In [122]: ts_utc.index
Out[122]:
DatetimeIndex(['2012-03-09 09:30:00+00:00', '2012-03-10 09:30:00+00:00',
'2012-03-11 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
'2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00'],
dtype='datetime64[ns, UTC]', freq='D')
一旦时间序列被本地化到特定的时区,它可以使用tz_convert转换为另一个时区:
In [123]: ts_utc.tz_convert("America/New_York")
Out[123]:
2012-03-09 04:30:00-05:00 -0.202469
2012-03-10 04:30:00-05:00 0.050718
2012-03-11 05:30:00-04:00 0.639869
2012-03-12 05:30:00-04:00 0.597594
2012-03-13 05:30:00-04:00 -0.797246
2012-03-14 05:30:00-04:00 0.472879
Freq: D, dtype: float64
在前述时间序列的情况下,该时间序列跨越了America/New_York时区的夏令时转换,我们可以将其本地化为美国东部时间,然后转换为 UTC 或柏林时间:
In [124]: ts_eastern = ts.tz_localize("America/New_York")
In [125]: ts_eastern.tz_convert("UTC")
Out[125]:
2012-03-09 14:30:00+00:00 -0.202469
2012-03-10 14:30:00+00:00 0.050718
2012-03-11 13:30:00+00:00 0.639869
2012-03-12 13:30:00+00:00 0.597594
2012-03-13 13:30:00+00:00 -0.797246
2012-03-14 13:30:00+00:00 0.472879
dtype: float64
In [126]: ts_eastern.tz_convert("Europe/Berlin")
Out[126]:
2012-03-09 15:30:00+01:00 -0.202469
2012-03-10 15:30:00+01:00 0.050718
2012-03-11 14:30:00+01:00 0.639869
2012-03-12 14:30:00+01:00 0.597594
2012-03-13 14:30:00+01:00 -0.797246
2012-03-14 14:30:00+01:00 0.472879
dtype: float64
tz_localize和tz_convert也是DatetimeIndex的实例方法:
In [127]: ts.index.tz_localize("Asia/Shanghai")
Out[127]:
DatetimeIndex(['2012-03-09 09:30:00+08:00', '2012-03-10 09:30:00+08:00',
'2012-03-11 09:30:00+08:00', '2012-03-12 09:30:00+08:00',
'2012-03-13 09:30:00+08:00', '2012-03-14 09:30:00+08:00'],
dtype='datetime64[ns, Asia/Shanghai]', freq=None)
注意
本地化无时区时间戳还会检查夏令时转换周围的模糊或不存在的时间。
与时区感知时间戳对象的操作
类似于时间序列和日期范围,个别Timestamp对象也可以从无时区转换为时区感知,并从一个时区转换为另一个时区:
In [128]: stamp = pd.Timestamp("2011-03-12 04:00")
In [129]: stamp_utc = stamp.tz_localize("utc")
In [130]: stamp_utc.tz_convert("America/New_York")
Out[130]: Timestamp('2011-03-11 23:00:00-0500', tz='America/New_York')
创建Timestamp时也可以传递时区:
In [131]: stamp_moscow = pd.Timestamp("2011-03-12 04:00", tz="Europe/Moscow")
In [132]: stamp_moscow
Out[132]: Timestamp('2011-03-12 04:00:00+0300', tz='Europe/Moscow')
时区感知的Timestamp对象在内部以自 Unix 纪元(1970 年 1 月 1 日)以来的纳秒为单位存储 UTC 时间戳值,因此更改时区不会改变内部 UTC 值:
In [133]: stamp_utc.value
Out[133]: 1299902400000000000
In [134]: stamp_utc.tz_convert("America/New_York").value
Out[134]: 1299902400000000000
在使用 pandas 的DateOffset对象执行时间算术时,pandas 会尽可能尊重夏令时转换。这里我们构造了发生在夏令时转换之前的时间戳(向前和向后)。首先,在转换为夏令时前 30 分钟:
In [135]: stamp = pd.Timestamp("2012-03-11 01:30", tz="US/Eastern")
In [136]: stamp
Out[136]: Timestamp('2012-03-11 01:30:00-0500', tz='US/Eastern')
In [137]: stamp + Hour()
Out[137]: Timestamp('2012-03-11 03:30:00-0400', tz='US/Eastern')
然后,在夏令时转换前 90 分钟:
In [138]: stamp = pd.Timestamp("2012-11-04 00:30", tz="US/Eastern")
In [139]: stamp
Out[139]: Timestamp('2012-11-04 00:30:00-0400', tz='US/Eastern')
In [140]: stamp + 2 * Hour()
Out[140]: Timestamp('2012-11-04 01:30:00-0500', tz='US/Eastern')
不同时区之间的操作
如果将具有不同时区的两个时间序列组合,结果将是 UTC。由于时间戳在 UTC 下存储,这是一个简单的操作,不需要转换:
In [141]: dates = pd.date_range("2012-03-07 09:30", periods=10, freq="B")
In [142]: ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)
In [143]: ts
Out[143]:
2012-03-07 09:30:00 0.522356
2012-03-08 09:30:00 -0.546348
2012-03-09 09:30:00 -0.733537
2012-03-12 09:30:00 1.302736
2012-03-13 09:30:00 0.022199
2012-03-14 09:30:00 0.364287
2012-03-15 09:30:00 -0.922839
2012-03-16 09:30:00 0.312656
2012-03-19 09:30:00 -1.128497
2012-03-20 09:30:00 -0.333488
Freq: B, dtype: float64
In [144]: ts1 = ts[:7].tz_localize("Europe/London")
In [145]: ts2 = ts1[2:].tz_convert("Europe/Moscow")
In [146]: result = ts1 + ts2
In [147]: result.index
Out[147]:
DatetimeIndex(['2012-03-07 09:30:00+00:00', '2012-03-08 09:30:00+00:00',
'2012-03-09 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
'2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00',
'2012-03-15 09:30:00+00:00'],
dtype='datetime64[ns, UTC]', freq=None)
不支持在时区无关和时区感知数据之间进行操作,会引发异常。*## 11.5 周期和周期算术
Periods代表时间跨度,如天、月、季度或年。pandas.Period类表示这种数据类型,需要一个字符串或整数和一个来自 Table 11.4 的支持频率:
In [148]: p = pd.Period("2011", freq="A-DEC")
In [149]: p
Out[149]: Period('2011', 'A-DEC')
在这种情况下,Period对象表示从 2011 年 1 月 1 日到 2011 年 12 月 31 日的完整时间跨度。方便的是,从周期中添加和减去整数会改变它们的频率:
In [150]: p + 5
Out[150]: Period('2016', 'A-DEC')
In [151]: p - 2
Out[151]: Period('2009', 'A-DEC')
如果两个周期具有相同的频率,则它们之间的差异是单位之间的数量作为日期偏移量:
In [152]: pd.Period("2014", freq="A-DEC") - p
Out[152]: <3 * YearEnds: month=12>
可以使用period_range函数构建周期的常规范围:
In [153]: periods = pd.period_range("2000-01-01", "2000-06-30", freq="M")
In [154]: periods
Out[154]: PeriodIndex(['2000-01', '2000-02', '2000-03', '2000-04', '2000-05', '20
00-06'], dtype='period[M]')
PeriodIndex类存储一系列周期,并可以作为任何 pandas 数据结构中的轴索引:
In [155]: pd.Series(np.random.standard_normal(6), index=periods)
Out[155]:
2000-01 -0.514551
2000-02 -0.559782
2000-03 -0.783408
2000-04 -1.797685
2000-05 -0.172670
2000-06 0.680215
Freq: M, dtype: float64
如果您有一个字符串数组,也可以使用PeriodIndex类,其中所有值都是周期:
In [156]: values = ["2001Q3", "2002Q2", "2003Q1"]
In [157]: index = pd.PeriodIndex(values, freq="Q-DEC")
In [158]: index
Out[158]: PeriodIndex(['2001Q3', '2002Q2', '2003Q1'], dtype='period[Q-DEC]')
周期频率转换
周期和PeriodIndex对象可以使用它们的asfreq方法转换为另一个频率。例如,假设我们有一个年度周期,想要将其转换为每月周期,可以在年初或年末进行。可以这样做:
In [159]: p = pd.Period("2011", freq="A-DEC")
In [160]: p
Out[160]: Period('2011', 'A-DEC')
In [161]: p.asfreq("M", how="start")
Out[161]: Period('2011-01', 'M')
In [162]: p.asfreq("M", how="end")
Out[162]: Period('2011-12', 'M')
In [163]: p.asfreq("M")
Out[163]: Period('2011-12', 'M')
您可以将Period("2011", "A-DEC")看作是指向一段时间的光标,由月度周期细分。参见 Figure 11.1 以了解这一点。对于以 12 月以外的月份结束的财政年度,相应的月度子周期是不同的:
In [164]: p = pd.Period("2011", freq="A-JUN")
In [165]: p
Out[165]: Period('2011', 'A-JUN')
In [166]: p.asfreq("M", how="start")
Out[166]: Period('2010-07', 'M')
In [167]: p.asfreq("M", how="end")
Out[167]: Period('2011-06', 'M')

图 11.1:周期频率转换示例
当您从高频率转换为低频率时,pandas 会确定子周期,取决于超级周期“属于”哪里。例如,在A-JUN频率中,月份Aug-2011实际上是2012周期的一部分:
In [168]: p = pd.Period("Aug-2011", "M")
In [169]: p.asfreq("A-JUN")
Out[169]: Period('2012', 'A-JUN')
整个PeriodIndex对象或时间序列也可以使用相同的语义进行类似转换:
In [170]: periods = pd.period_range("2006", "2009", freq="A-DEC")
In [171]: ts = pd.Series(np.random.standard_normal(len(periods)), index=periods)
In [172]: ts
Out[172]:
2006 1.607578
2007 0.200381
2008 -0.834068
2009 -0.302988
Freq: A-DEC, dtype: float64
In [173]: ts.asfreq("M", how="start")
Out[173]:
2006-01 1.607578
2007-01 0.200381
2008-01 -0.834068
2009-01 -0.302988
Freq: M, dtype: float64
在这里,年度周期被替换为对应于每个年度周期中第一个月的月度周期。如果我们希望每年的最后一个工作日,可以使用"B"频率并指示我们想要周期的结束:
In [174]: ts.asfreq("B", how="end")
Out[174]:
2006-12-29 1.607578
2007-12-31 0.200381
2008-12-31 -0.834068
2009-12-31 -0.302988
Freq: B, dtype: float64
季度周期频率
季度数据在会计、金融和其他领域中很常见。许多季度数据是相对于财年结束报告的,通常是一年中的 12 个月的最后一个日历日或工作日。因此,期间 2012Q4 根据财年结束日期的不同具有不同的含义。pandas 支持所有 12 种可能的季度频率,从 Q-JAN 到 Q-DEC:
In [175]: p = pd.Period("2012Q4", freq="Q-JAN")
In [176]: p
Out[176]: Period('2012Q4', 'Q-JAN')
在财年结束于一月的情况下,2012Q4 从 2011 年 11 月到 2012 年 1 月,您可以通过转换为每日频率来检查:
In [177]: p.asfreq("D", how="start")
Out[177]: Period('2011-11-01', 'D')
In [178]: p.asfreq("D", how="end")
Out[178]: Period('2012-01-31', 'D')
参见 Figure 11.2 进行说明。

Figure 11.2: 不同的季度频率约定
因此,可以进行方便的期间算术;例如,要获取季度倒数第二个工作日下午 4 点的时间戳,可以执行以下操作:
In [179]: p4pm = (p.asfreq("B", how="end") - 1).asfreq("T", how="start") + 16 * 6
0
In [180]: p4pm
Out[180]: Period('2012-01-30 16:00', 'T')
In [181]: p4pm.to_timestamp()
Out[181]: Timestamp('2012-01-30 16:00:00')
to_timestamp 方法默认返回期间开始的 Timestamp。
您可以使用 pandas.period_range 生成季度范围。算术也是相同的:
In [182]: periods = pd.period_range("2011Q3", "2012Q4", freq="Q-JAN")
In [183]: ts = pd.Series(np.arange(len(periods)), index=periods)
In [184]: ts
Out[184]:
2011Q3 0
2011Q4 1
2012Q1 2
2012Q2 3
2012Q3 4
2012Q4 5
Freq: Q-JAN, dtype: int64
In [185]: new_periods = (periods.asfreq("B", "end") - 1).asfreq("H", "start") + 1
6
In [186]: ts.index = new_periods.to_timestamp()
In [187]: ts
Out[187]:
2010-10-28 16:00:00 0
2011-01-28 16:00:00 1
2011-04-28 16:00:00 2
2011-07-28 16:00:00 3
2011-10-28 16:00:00 4
2012-01-30 16:00:00 5
dtype: int64
将时间戳转换为期间(以及相反)
通过 to_period 方法,以时间戳索引的 Series 和 DataFrame 对象可以转换为期间:
In [188]: dates = pd.date_range("2000-01-01", periods=3, freq="M")
In [189]: ts = pd.Series(np.random.standard_normal(3), index=dates)
In [190]: ts
Out[190]:
2000-01-31 1.663261
2000-02-29 -0.996206
2000-03-31 1.521760
Freq: M, dtype: float64
In [191]: pts = ts.to_period()
In [192]: pts
Out[192]:
2000-01 1.663261
2000-02 -0.996206
2000-03 1.521760
Freq: M, dtype: float64
由于期间指的是不重叠的时间跨度,因此给定频率的时间戳只能属于一个期间。虽然新的 PeriodIndex 的频率默认情况下是根据时间戳推断的,但您可以指定任何支持的频率(大多数列在 Table 11.4 中列出的频率都受支持)。在结果中有重复期间也没有问题:
In [193]: dates = pd.date_range("2000-01-29", periods=6)
In [194]: ts2 = pd.Series(np.random.standard_normal(6), index=dates)
In [195]: ts2
Out[195]:
2000-01-29 0.244175
2000-01-30 0.423331
2000-01-31 -0.654040
2000-02-01 2.089154
2000-02-02 -0.060220
2000-02-03 -0.167933
Freq: D, dtype: float64
In [196]: ts2.to_period("M")
Out[196]:
2000-01 0.244175
2000-01 0.423331
2000-01 -0.654040
2000-02 2.089154
2000-02 -0.060220
2000-02 -0.167933
Freq: M, dtype: float64
要转换回时间戳,请使用 to_timestamp 方法,该方法返回一个 DatetimeIndex:
In [197]: pts = ts2.to_period()
In [198]: pts
Out[198]:
2000-01-29 0.244175
2000-01-30 0.423331
2000-01-31 -0.654040
2000-02-01 2.089154
2000-02-02 -0.060220
2000-02-03 -0.167933
Freq: D, dtype: float64
In [199]: pts.to_timestamp(how="end")
Out[199]:
2000-01-29 23:59:59.999999999 0.244175
2000-01-30 23:59:59.999999999 0.423331
2000-01-31 23:59:59.999999999 -0.654040
2000-02-01 23:59:59.999999999 2.089154
2000-02-02 23:59:59.999999999 -0.060220
2000-02-03 23:59:59.999999999 -0.167933
Freq: D, dtype: float64
从数组创建 PeriodIndex
固定频率数据集有时会存储在跨多列的时间跨度信息中。例如,在这个宏观经济数据集中,年份和季度在不同的列中:
In [200]: data = pd.read_csv("examples/macrodata.csv")
In [201]: data.head(5)
Out[201]:
year quarter realgdp realcons realinv realgovt realdpi cpi
0 1959 1 2710.349 1707.4 286.898 470.045 1886.9 28.98 \
1 1959 2 2778.801 1733.7 310.859 481.301 1919.7 29.15
2 1959 3 2775.488 1751.8 289.226 491.260 1916.4 29.35
3 1959 4 2785.204 1753.7 299.356 484.052 1931.3 29.37
4 1960 1 2847.699 1770.5 331.722 462.199 1955.5 29.54
m1 tbilrate unemp pop infl realint
0 139.7 2.82 5.8 177.146 0.00 0.00
1 141.7 3.08 5.1 177.830 2.34 0.74
2 140.5 3.82 5.3 178.657 2.74 1.09
3 140.0 4.33 5.6 179.386 0.27 4.06
4 139.6 3.50 5.2 180.007 2.31 1.19
In [202]: data["year"]
Out[202]:
0 1959
1 1959
2 1959
3 1959
4 1960
...
198 2008
199 2008
200 2009
201 2009
202 2009
Name: year, Length: 203, dtype: int64
In [203]: data["quarter"]
Out[203]:
0 1
1 2
2 3
3 4
4 1
..
198 3
199 4
200 1
201 2
202 3
Name: quarter, Length: 203, dtype: int64
通过将这些数组传递给 PeriodIndex 并指定频率,可以将它们组合成 DataFrame 的索引:
In [204]: index = pd.PeriodIndex(year=data["year"], quarter=data["quarter"],
.....: freq="Q-DEC")
In [205]: index
Out[205]:
PeriodIndex(['1959Q1', '1959Q2', '1959Q3', '1959Q4', '1960Q1', '1960Q2',
'1960Q3', '1960Q4', '1961Q1', '1961Q2',
...
'2007Q2', '2007Q3', '2007Q4', '2008Q1', '2008Q2', '2008Q3',
'2008Q4', '2009Q1', '2009Q2', '2009Q3'],
dtype='period[Q-DEC]', length=203)
In [206]: data.index = index
In [207]: data["infl"]
Out[207]:
1959Q1 0.00
1959Q2 2.34
1959Q3 2.74
1959Q4 0.27
1960Q1 2.31
...
2008Q3 -3.16
2008Q4 -8.79
2009Q1 0.94
2009Q2 3.37
2009Q3 3.56
Freq: Q-DEC, Name: infl, Length: 203, dtype: float64
11.6 重新采样和频率转换
重新采样 指的是将时间序列从一种频率转换为另一种频率的过程。将高频数据聚合到低频称为下采样,而将低频转换为高频称为上采样。并非所有重新采样都属于这两类;例如,将 W-WED(每周三)转换为 W-FRI 既不是上采样也不是下采样。
pandas 对象配备有一个 resample 方法,这是所有频率转换的工作函数。resample 具有类似于 groupby 的 API;您调用 resample 来对数据进行分组,然后调用聚合函数:
In [208]: dates = pd.date_range("2000-01-01", periods=100)
In [209]: ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)
In [210]: ts
Out[210]:
2000-01-01 0.631634
2000-01-02 -1.594313
2000-01-03 -1.519937
2000-01-04 1.108752
2000-01-05 1.255853
...
2000-04-05 -0.423776
2000-04-06 0.789740
2000-04-07 0.937568
2000-04-08 -2.253294
2000-04-09 -1.772919
Freq: D, Length: 100, dtype: float64
In [211]: ts.resample("M").mean()
Out[211]:
2000-01-31 -0.165893
2000-02-29 0.078606
2000-03-31 0.223811
2000-04-30 -0.063643
Freq: M, dtype: float64
In [212]: ts.resample("M", kind="period").mean()
Out[212]:
2000-01 -0.165893
2000-02 0.078606
2000-03 0.223811
2000-04 -0.063643
Freq: M, dtype: float64
resample 是一个灵活的方法,可用于处理大型时间序列。以下部分的示例说明了其语义和用法。Table 11.5 总结了一些选项。
Table 11.5: resample 方法参数
| 参数 | 描述 |
|---|---|
rule |
字符串、DateOffset 或时间增量,指示所需的重新采样频率(例如,’M'、’5min' 或 Second(15)) |
axis |
要重新采样的轴;默认 axis=0 |
fill_method |
在上采样时如何插值,例如 "ffill" 或 "bfill";默认情况下不进行插值 |
closed |
在下采样时,每个间隔的哪一端是闭合的(包含的),"right" 或 "left" |
label |
在下采样时,如何标记聚合结果,使用 "right" 或 "left" 边界(例如,9:30 到 9:35 五分钟间隔可以标记为 9:30 或 9:35) |
limit |
在向前或向后填充时,要填充的最大周期数 |
kind |
聚合到期间("period")或时间戳("timestamp");默认为时间序列具有的索引类型 |
convention |
在重新采样周期时,用于将低频周期转换为高频的约定("start"或"end");默认为"start" |
origin |
用于确定重新采样箱边缘的“基准”时间戳;也可以是"epoch"、"start"、"start_day"、"end"或"end_day"之一;有关完整详细信息,请参阅resample文档字符串 |
offset |
添加到原点的偏移时间间隔;默认为None |
下采样
下采样是将数据聚合到常规、较低的频率。您正在聚合的数据不需要经常固定;所需频率定义了用于将时间序列切片成块以进行聚合的箱边缘。例如,要转换为每月,"M"或"BM",您需要将数据切割成一个月的间隔。每个间隔被称为半开放;数据点只能属于一个间隔,间隔的并集必须构成整个时间范围。在使用resample对数据进行下采样时,有几件事需要考虑:
-
每个间隔的哪一侧是关闭的
-
如何为每个聚合的箱子打标签,可以是间隔的开始或结束
为了说明,让我们看一些一分钟频率的数据:
In [213]: dates = pd.date_range("2000-01-01", periods=12, freq="T")
In [214]: ts = pd.Series(np.arange(len(dates)), index=dates)
In [215]: ts
Out[215]:
2000-01-01 00:00:00 0
2000-01-01 00:01:00 1
2000-01-01 00:02:00 2
2000-01-01 00:03:00 3
2000-01-01 00:04:00 4
2000-01-01 00:05:00 5
2000-01-01 00:06:00 6
2000-01-01 00:07:00 7
2000-01-01 00:08:00 8
2000-01-01 00:09:00 9
2000-01-01 00:10:00 10
2000-01-01 00:11:00 11
Freq: T, dtype: int64
假设您想要通过将每组的总和来将这些数据聚合成五分钟的块或条:
In [216]: ts.resample("5min").sum()
Out[216]:
2000-01-01 00:00:00 10
2000-01-01 00:05:00 35
2000-01-01 00:10:00 21
Freq: 5T, dtype: int64
您传递的频率定义了以五分钟为增量的箱边缘。对于这个频率,默认情况下左箱边缘是包含的,因此00:00值包含在00:00到00:05间隔中,而00:05值不包含在该间隔中。¹
In [217]: ts.resample("5min", closed="right").sum()
Out[217]:
1999-12-31 23:55:00 0
2000-01-01 00:00:00 15
2000-01-01 00:05:00 40
2000-01-01 00:10:00 11
Freq: 5T, dtype: int64
生成的时间序列由每个箱子左侧的时间戳标记。通过传递label="right",您可以使用右侧箱子边缘对它们进行标记:
In [218]: ts.resample("5min", closed="right", label="right").sum()
Out[218]:
2000-01-01 00:00:00 0
2000-01-01 00:05:00 15
2000-01-01 00:10:00 40
2000-01-01 00:15:00 11
Freq: 5T, dtype: int64
请参见图 11.3,以了解将分钟频率数据重新采样为五分钟频率的示例。

图 11.3:五分钟重新采样示例,显示了闭合、标签约定
最后,您可能希望将结果索引向前移动一定量,例如从右边减去一秒,以便更清楚地了解时间戳所指的间隔。要执行此操作,请向结果索引添加一个偏移量:
In [219]: from pandas.tseries.frequencies import to_offset
In [220]: result = ts.resample("5min", closed="right", label="right").sum()
In [221]: result.index = result.index + to_offset("-1s")
In [222]: result
Out[222]:
1999-12-31 23:59:59 0
2000-01-01 00:04:59 15
2000-01-01 00:09:59 40
2000-01-01 00:14:59 11
Freq: 5T, dtype: int64
开盘-最高-最低-收盘(OHLC)重新采样
在金融领域,聚合时间序列的一种流行方式是为每个桶计算四个值:第一个(开盘)、最后一个(收盘)、最大值(最高)和最小值(最低)。通过使用ohlc聚合函数,您将获得一个包含这四个聚合值的列的 DataFrame,这四个值可以在单个函数调用中高效计算:
In [223]: ts = pd.Series(np.random.permutation(np.arange(len(dates))), index=date
s)
In [224]: ts.resample("5min").ohlc()
Out[224]:
open high low close
2000-01-01 00:00:00 8 8 1 5
2000-01-01 00:05:00 6 11 2 2
2000-01-01 00:10:00 0 7 0 7
上采样和插值
上采样是将数据从较低频率转换为较高频率,不需要聚合。让我们考虑一个包含一些周数据的 DataFrame:
In [225]: frame = pd.DataFrame(np.random.standard_normal((2, 4)),
.....: index=pd.date_range("2000-01-01", periods=2,
.....: freq="W-WED"),
.....: columns=["Colorado", "Texas", "New York", "Ohio"])
In [226]: frame
Out[226]:
Colorado Texas New York Ohio
2000-01-05 -0.896431 0.927238 0.482284 -0.867130
2000-01-12 0.493841 -0.155434 1.397286 1.507055
当您使用聚合函数处理这些数据时,每组只有一个值,缺失值会导致间隙。我们使用asfreq方法将其转换为更高的频率,而不进行任何聚合:
In [227]: df_daily = frame.resample("D").asfreq()
In [228]: df_daily
Out[228]:
Colorado Texas New York Ohio
2000-01-05 -0.896431 0.927238 0.482284 -0.867130
2000-01-06 NaN NaN NaN NaN
2000-01-07 NaN NaN NaN NaN
2000-01-08 NaN NaN NaN NaN
2000-01-09 NaN NaN NaN NaN
2000-01-10 NaN NaN NaN NaN
2000-01-11 NaN NaN NaN NaN
2000-01-12 0.493841 -0.155434 1.397286 1.507055
假设您希望在非星期三填充每周值。与fillna和reindex方法中可用的填充或插值方法相同,对于重新采样也是可用的:
In [229]: frame.resample("D").ffill()
Out[229]:
Colorado Texas New York Ohio
2000-01-05 -0.896431 0.927238 0.482284 -0.867130
2000-01-06 -0.896431 0.927238 0.482284 -0.867130
2000-01-07 -0.896431 0.927238 0.482284 -0.867130
2000-01-08 -0.896431 0.927238 0.482284 -0.867130
2000-01-09 -0.896431 0.927238 0.482284 -0.867130
2000-01-10 -0.896431 0.927238 0.482284 -0.867130
2000-01-11 -0.896431 0.927238 0.482284 -0.867130
2000-01-12 0.493841 -0.155434 1.397286 1.507055
您也可以选择仅填充一定数量的周期,以限制使用观察值的范围:
In [230]: frame.resample("D").ffill(limit=2)
Out[230]:
Colorado Texas New York Ohio
2000-01-05 -0.896431 0.927238 0.482284 -0.867130
2000-01-06 -0.896431 0.927238 0.482284 -0.867130
2000-01-07 -0.896431 0.927238 0.482284 -0.867130
2000-01-08 NaN NaN NaN NaN
2000-01-09 NaN NaN NaN NaN
2000-01-10 NaN NaN NaN NaN
2000-01-11 NaN NaN NaN NaN
2000-01-12 0.493841 -0.155434 1.397286 1.507055
值得注意的是,新的日期索引不一定与旧的完全重合:
In [231]: frame.resample("W-THU").ffill()
Out[231]:
Colorado Texas New York Ohio
2000-01-06 -0.896431 0.927238 0.482284 -0.867130
2000-01-13 0.493841 -0.155434 1.397286 1.507055
使用周期重新采样
按周期索引的数据重新采样类似于时间戳:
In [232]: frame = pd.DataFrame(np.random.standard_normal((24, 4)),
.....: index=pd.period_range("1-2000", "12-2001",
.....: freq="M"),
.....: columns=["Colorado", "Texas", "New York", "Ohio"])
In [233]: frame.head()
Out[233]:
Colorado Texas New York Ohio
2000-01 -1.179442 0.443171 1.395676 -0.529658
2000-02 0.787358 0.248845 0.743239 1.267746
2000-03 1.302395 -0.272154 -0.051532 -0.467740
2000-04 -1.040816 0.426419 0.312945 -1.115689
2000-05 1.234297 -1.893094 -1.661605 -0.005477
In [234]: annual_frame = frame.resample("A-DEC").mean()
In [235]: annual_frame
Out[235]:
Colorado Texas New York Ohio
2000 0.487329 0.104466 0.020495 -0.273945
2001 0.203125 0.162429 0.056146 -0.103794
上采样更加微妙,因为在重新采样之前,您必须决定将值放在新频率的时间跨度的哪一端。convention参数默认为"start",但也可以是"end":
# Q-DEC: Quarterly, year ending in December
In [236]: annual_frame.resample("Q-DEC").ffill()
Out[236]:
Colorado Texas New York Ohio
2000Q1 0.487329 0.104466 0.020495 -0.273945
2000Q2 0.487329 0.104466 0.020495 -0.273945
2000Q3 0.487329 0.104466 0.020495 -0.273945
2000Q4 0.487329 0.104466 0.020495 -0.273945
2001Q1 0.203125 0.162429 0.056146 -0.103794
2001Q2 0.203125 0.162429 0.056146 -0.103794
2001Q3 0.203125 0.162429 0.056146 -0.103794
2001Q4 0.203125 0.162429 0.056146 -0.103794
In [237]: annual_frame.resample("Q-DEC", convention="end").asfreq()
Out[237]:
Colorado Texas New York Ohio
2000Q4 0.487329 0.104466 0.020495 -0.273945
2001Q1 NaN NaN NaN NaN
2001Q2 NaN NaN NaN NaN
2001Q3 NaN NaN NaN NaN
2001Q4 0.203125 0.162429 0.056146 -0.103794
由于周期指的是时间跨度,因此有关上采样和下采样的规则更为严格:
-
在下采样中,目标频率必须是源频率的子周期。
-
在上采样中,目标频率必须是源频率的超周期。
如果这些规则不满足,将会引发异常。这主要影响季度、年度和每周频率;例如,由Q-MAR定义的时间跨度只与A-MAR、A-JUN、A-SEP和A-DEC对齐:
In [238]: annual_frame.resample("Q-MAR").ffill()
Out[238]:
Colorado Texas New York Ohio
2000Q4 0.487329 0.104466 0.020495 -0.273945
2001Q1 0.487329 0.104466 0.020495 -0.273945
2001Q2 0.487329 0.104466 0.020495 -0.273945
2001Q3 0.487329 0.104466 0.020495 -0.273945
2001Q4 0.203125 0.162429 0.056146 -0.103794
2002Q1 0.203125 0.162429 0.056146 -0.103794
2002Q2 0.203125 0.162429 0.056146 -0.103794
2002Q3 0.203125 0.162429 0.056146 -0.103794
分组时间重采样
对于时间序列数据,resample方法在时间间隔化的基础上是一个组操作。这里是一个小例子表:
In [239]: N = 15
In [240]: times = pd.date_range("2017-05-20 00:00", freq="1min", periods=N)
In [241]: df = pd.DataFrame({"time": times,
.....: "value": np.arange(N)})
In [242]: df
Out[242]:
time value
0 2017-05-20 00:00:00 0
1 2017-05-20 00:01:00 1
2 2017-05-20 00:02:00 2
3 2017-05-20 00:03:00 3
4 2017-05-20 00:04:00 4
5 2017-05-20 00:05:00 5
6 2017-05-20 00:06:00 6
7 2017-05-20 00:07:00 7
8 2017-05-20 00:08:00 8
9 2017-05-20 00:09:00 9
10 2017-05-20 00:10:00 10
11 2017-05-20 00:11:00 11
12 2017-05-20 00:12:00 12
13 2017-05-20 00:13:00 13
14 2017-05-20 00:14:00 14
在这里,我们可以按"time"索引,然后重采样:
In [243]: df.set_index("time").resample("5min").count()
Out[243]:
value
time
2017-05-20 00:00:00 5
2017-05-20 00:05:00 5
2017-05-20 00:10:00 5
假设一个 DataFrame 包含多个时间序列,由额外的分组键列标记:
In [244]: df2 = pd.DataFrame({"time": times.repeat(3),
.....: "key": np.tile(["a", "b", "c"], N),
.....: "value": np.arange(N * 3.)})
In [245]: df2.head(7)
Out[245]:
time key value
0 2017-05-20 00:00:00 a 0.0
1 2017-05-20 00:00:00 b 1.0
2 2017-05-20 00:00:00 c 2.0
3 2017-05-20 00:01:00 a 3.0
4 2017-05-20 00:01:00 b 4.0
5 2017-05-20 00:01:00 c 5.0
6 2017-05-20 00:02:00 a 6.0
为了对每个"key"值执行相同的重采样,我们引入pandas.Grouper对象:
In [246]: time_key = pd.Grouper(freq="5min")
然后我们可以设置时间索引,按"key"和time_key分组,并进行聚合:
In [247]: resampled = (df2.set_index("time")
.....: .groupby(["key", time_key])
.....: .sum())
In [248]: resampled
Out[248]:
value
key time
a 2017-05-20 00:00:00 30.0
2017-05-20 00:05:00 105.0
2017-05-20 00:10:00 180.0
b 2017-05-20 00:00:00 35.0
2017-05-20 00:05:00 110.0
2017-05-20 00:10:00 185.0
c 2017-05-20 00:00:00 40.0
2017-05-20 00:05:00 115.0
2017-05-20 00:10:00 190.0
In [249]: resampled.reset_index()
Out[249]:
key time value
0 a 2017-05-20 00:00:00 30.0
1 a 2017-05-20 00:05:00 105.0
2 a 2017-05-20 00:10:00 180.0
3 b 2017-05-20 00:00:00 35.0
4 b 2017-05-20 00:05:00 110.0
5 b 2017-05-20 00:10:00 185.0
6 c 2017-05-20 00:00:00 40.0
7 c 2017-05-20 00:05:00 115.0
8 c 2017-05-20 00:10:00 190.0
使用pandas.Grouper的一个限制是时间必须是 Series 或 DataFrame 的索引。
11.7 移动窗口函数
用于时间序列操作的一类重要的数组转换是在滑动窗口上评估统计数据和其他函数,或者使用指数衰减权重。这对于平滑嘈杂或有缺失数据的数据很有用。我将这些称为移动窗口函数,尽管它们包括没有固定长度窗口的函数,比如指数加权移动平均。与其他统计函数一样,这些函数也会自动排除缺失数据。
在深入研究之前,我们可以加载一些时间序列数据并将其重采样为工作日频率:
In [250]: close_px_all = pd.read_csv("examples/stock_px.csv",
.....: parse_dates=True, index_col=0)
In [251]: close_px = close_px_all[["AAPL", "MSFT", "XOM"]]
In [252]: close_px = close_px.resample("B").ffill()
我现在介绍rolling运算符,它的行为类似于resample和groupby。它可以与一个window(表示为一定数量的周期)一起在 Series 或 DataFrame 上调用(请参见 Apple 价格与 250 日移动平均创建的图):
In [253]: close_px["AAPL"].plot()
Out[253]: <Axes: >
In [254]: close_px["AAPL"].rolling(250).mean().plot()

图 11.4:苹果价格与 250 日移动平均值
表达式rolling(250)在行为上类似于groupby,但不是分组,而是创建一个对象,使得可以在 250 天滑动窗口上进行分组。因此,这里是苹果股价的 250 日移动窗口平均值。
默认情况下,滚动函数要求窗口中的所有值都不是 NA。这种行为可以更改以考虑缺失数据,特别是在时间序列开始时将少于window周期的数据(请参见苹果 250 日每日回报标准差):
In [255]: plt.figure()
Out[255]: <Figure size 1000x600 with 0 Axes>
In [256]: std250 = close_px["AAPL"].pct_change().rolling(250, min_periods=10).std
()
In [257]: std250[5:12]
Out[257]:
2003-01-09 NaN
2003-01-10 NaN
2003-01-13 NaN
2003-01-14 NaN
2003-01-15 NaN
2003-01-16 0.009628
2003-01-17 0.013818
Freq: B, Name: AAPL, dtype: float64
In [258]: std250.plot()

图 11.5:苹果 250 日每日回报标准差
要计算扩展窗口均值,请使用expanding运算符,而不是rolling。扩展均值从与滚动窗口相同的时间窗口开始,并增加窗口的大小,直到包含整个系列。std250时间序列上的扩展窗口均值如下所示:
In [259]: expanding_mean = std250.expanding().mean()
在 DataFrame 上调用移动窗口函数会将转换应用于每一列(请参见股价 60 日移动平均(对数 y 轴)):
In [261]: plt.style.use('grayscale')
In [262]: close_px.rolling(60).mean().plot(logy=True)

图 11.6:股价 60 日移动平均(对数 y 轴)
rolling函数还接受一个字符串,指示固定大小的时间偏移rolling()在移动窗口函数中,而不是一组周期。使用这种表示法对于不规则的时间序列很有用。这些是您可以传递给resample的相同字符串。例如,我们可以这样计算 20 天的滚动均值:
In [263]: close_px.rolling("20D").mean()
Out[263]:
AAPL MSFT XOM
2003-01-02 7.400000 21.110000 29.220000
2003-01-03 7.425000 21.125000 29.230000
2003-01-06 7.433333 21.256667 29.473333
2003-01-07 7.432500 21.425000 29.342500
2003-01-08 7.402000 21.402000 29.240000
... ... ... ...
2011-10-10 389.351429 25.602143 72.527857
2011-10-11 388.505000 25.674286 72.835000
2011-10-12 388.531429 25.810000 73.400714
2011-10-13 388.826429 25.961429 73.905000
2011-10-14 391.038000 26.048667 74.185333
[2292 rows x 3 columns]
指数加权函数
使用固定窗口大小和等权观测值的替代方法是指定一个恒定的衰减因子,以赋予更多权重给最近的观测值。有几种指定衰减因子的方法。一种流行的方法是使用跨度,使结果与窗口大小等于跨度的简单移动窗口函数可比较。
由于指数加权统计对最近的观察结果赋予更大的权重,与等权重版本相比,它更快地“适应”变化。
pandas 有ewm运算符(代表指数加权移动),与rolling和expanding配合使用。以下是一个示例,比较了苹果公司股价的 30 天移动平均值与指数加权(EW)移动平均值(span=60)(请参阅简单移动平均与指数加权):
In [265]: aapl_px = close_px["AAPL"]["2006":"2007"]
In [266]: ma30 = aapl_px.rolling(30, min_periods=20).mean()
In [267]: ewma30 = aapl_px.ewm(span=30).mean()
In [268]: aapl_px.plot(style="k-", label="Price")
Out[268]: <Axes: >
In [269]: ma30.plot(style="k--", label="Simple Moving Avg")
Out[269]: <Axes: >
In [270]: ewma30.plot(style="k-", label="EW MA")
Out[270]: <Axes: >
In [271]: plt.legend()

图 11.7:简单移动平均与指数加权
二进制移动窗口函数
一些统计运算符,如相关性和协方差,需要在两个时间序列上操作。例如,金融分析师通常对股票与标普 500 等基准指数的相关性感兴趣。为了查看这一点,我们首先计算所有感兴趣时间序列的百分比变化:
In [273]: spx_px = close_px_all["SPX"]
In [274]: spx_rets = spx_px.pct_change()
In [275]: returns = close_px.pct_change()
在我们调用rolling之后,corr聚合函数可以计算与spx_rets的滚动相关性(请参阅苹果公司六个月回报与标普 500 的相关性以查看结果图):
In [276]: corr = returns["AAPL"].rolling(125, min_periods=100).corr(spx_rets)
In [277]: corr.plot()

图 11.8:苹果公司六个月回报与标普 500 的相关性
假设您想要计算 S&P 500 指数与多只股票的滚动相关性。您可以像我们上面为苹果公司所做的那样编写一个循环来计算每只股票的相关性,但如果每只股票是单个 DataFrame 中的一列,我们可以通过在 DataFrame 上调用rolling并传递spx_rets Series 来一次性计算所有滚动相关性。
请参阅与标普 500 的六个月回报相关性以查看结果图:
In [279]: corr = returns.rolling(125, min_periods=100).corr(spx_rets)
In [280]: corr.plot()

图 11.9:与标普 500 的六个月回报相关性
用户定义的移动窗口函数
rolling和相关方法上的apply方法提供了一种方法,可以在移动窗口上应用自己创建的数组函数。唯一的要求是函数从数组的每个部分产生一个单一值(一个减少)。例如,虽然我们可以使用rolling(...).quantile(q)计算样本分位数,但我们可能对特定值在样本中的百分位数感兴趣。scipy.stats.percentileofscore函数正是这样做的(请参阅 2%苹果公司回报在一年窗口内的百分位数以查看结果图):
In [282]: from scipy.stats import percentileofscore
In [283]: def score_at_2percent(x):
.....: return percentileofscore(x, 0.02)
In [284]: result = returns["AAPL"].rolling(250).apply(score_at_2percent)
In [285]: result.plot()

图 11.10:2%苹果公司回报在一年窗口内的百分位数
如果您尚未安装 SciPy,可以使用 conda 或 pip 进行安装:
conda install scipy
11.8 结论
时间序列数据需要不同类型的分析和数据转换工具,与我们在之前章节中探讨过的其他类型数据不同。
在接下来的章节中,我们将展示如何开始使用建模库,如 statsmodels 和 scikit-learn。
- 对于
closed和label的默认值选择可能对一些用户来说有点奇怪。默认值为closed="left",除了一组特定的值("M"、"A"、"Q"、"BM"、"BQ"和"W")默认为closed="right"。选择默认值是为了使结果更直观,但值得知道默认值并不总是一个或另一个。
十二、Python 建模库介绍
原文:
wesmckinney.com/book/modeling译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
在本书中,我专注于为在 Python 中进行数据分析提供编程基础。由于数据分析师和科学家经常报告花费大量时间进行数据整理和准备,因此本书的结构反映了掌握这些技术的重要性。
您用于开发模型的库将取决于应用程序。许多统计问题可以通过简单的技术解决,如普通最小二乘回归,而其他问题可能需要更高级的机器学习方法。幸运的是,Python 已经成为实现分析方法的首选语言之一,因此在完成本书后,您可以探索许多工具。
在本章中,我将回顾一些 pandas 的特性,这些特性在您在 pandas 中进行数据整理和模型拟合和评分之间来回切换时可能会有所帮助。然后,我将简要介绍两个流行的建模工具包,statsmodels和scikit-learn。由于这两个项目都足够庞大,值得有自己的专门书籍,因此我没有尝试全面介绍,而是建议您查阅这两个项目的在线文档,以及一些其他基于 Python 的数据科学、统计学和机器学习书籍。
12.1 pandas 与模型代码之间的接口
模型开发的常见工作流程是使用 pandas 进行数据加载和清理,然后切换到建模库来构建模型本身。模型开发过程中的一个重要部分被称为特征工程,在机器学习中。这可以描述从原始数据集中提取信息的任何数据转换或分析,这些信息在建模环境中可能有用。我们在本书中探讨的数据聚合和 GroupBy 工具经常在特征工程环境中使用。
虽然“好”的特征工程的细节超出了本书的范围,但我将展示一些方法,使在 pandas 中进行数据操作和建模之间的切换尽可能轻松。
pandas 与其他分析库之间的接触点通常是 NumPy 数组。要将 DataFrame 转换为 NumPy 数组,请使用to_numpy方法:
In [12]: data = pd.DataFrame({
....: 'x0': [1, 2, 3, 4, 5],
....: 'x1': [0.01, -0.01, 0.25, -4.1, 0.],
....: 'y': [-1.5, 0., 3.6, 1.3, -2.]})
In [13]: data
Out[13]:
x0 x1 y
0 1 0.01 -1.5
1 2 -0.01 0.0
2 3 0.25 3.6
3 4 -4.10 1.3
4 5 0.00 -2.0
In [14]: data.columns
Out[14]: Index(['x0', 'x1', 'y'], dtype='object')
In [15]: data.to_numpy()
Out[15]:
array([[ 1. , 0.01, -1.5 ],
[ 2. , -0.01, 0. ],
[ 3. , 0.25, 3.6 ],
[ 4. , -4.1 , 1.3 ],
[ 5. , 0. , -2. ]])
回到 DataFrame,正如您可能从前几章中记得的那样,您可以传递一个二维的 ndarray,其中包含可选的列名:
In [16]: df2 = pd.DataFrame(data.to_numpy(), columns=['one', 'two', 'three'])
In [17]: df2
Out[17]:
one two three
0 1.0 0.01 -1.5
1 2.0 -0.01 0.0
2 3.0 0.25 3.6
3 4.0 -4.10 1.3
4 5.0 0.00 -2.0
to_numpy方法旨在在数据是同质的情况下使用,例如所有的数值类型。如果您有异构数据,结果将是一个 Python 对象的 ndarray:
In [18]: df3 = data.copy()
In [19]: df3['strings'] = ['a', 'b', 'c', 'd', 'e']
In [20]: df3
Out[20]:
x0 x1 y strings
0 1 0.01 -1.5 a
1 2 -0.01 0.0 b
2 3 0.25 3.6 c
3 4 -4.10 1.3 d
4 5 0.00 -2.0 e
In [21]: df3.to_numpy()
Out[21]:
array([[1, 0.01, -1.5, 'a'],
[2, -0.01, 0.0, 'b'],
[3, 0.25, 3.6, 'c'],
[4, -4.1, 1.3, 'd'],
[5, 0.0, -2.0, 'e']], dtype=object)
对于某些模型,您可能希望仅使用部分列。我建议使用loc索引和to_numpy:
In [22]: model_cols = ['x0', 'x1']
In [23]: data.loc[:, model_cols].to_numpy()
Out[23]:
array([[ 1. , 0.01],
[ 2. , -0.01],
[ 3. , 0.25],
[ 4. , -4.1 ],
[ 5. , 0. ]])
一些库原生支持 pandas,并自动完成一些工作:从 DataFrame 转换为 NumPy,并将模型参数名称附加到输出表或 Series 的列上。在其他情况下,您将不得不手动执行这种“元数据管理”。
在 Ch 7.5:分类数据中,我们看过 pandas 的Categorical类型和pandas.get_dummies函数。假设我们的示例数据集中有一个非数字列:
In [24]: data['category'] = pd.Categorical(['a', 'b', 'a', 'a', 'b'],
....: categories=['a', 'b'])
In [25]: data
Out[25]:
x0 x1 y category
0 1 0.01 -1.5 a
1 2 -0.01 0.0 b
2 3 0.25 3.6 a
3 4 -4.10 1.3 a
4 5 0.00 -2.0 b
如果我们想用虚拟变量替换'category'列,我们创建虚拟变量,删除'category'列,然后将结果连接:
In [26]: dummies = pd.get_dummies(data.category, prefix='category',
....: dtype=float)
In [27]: data_with_dummies = data.drop('category', axis=1).join(dummies)
In [28]: data_with_dummies
Out[28]:
x0 x1 y category_a category_b
0 1 0.01 -1.5 1.0 0.0
1 2 -0.01 0.0 0.0 1.0
2 3 0.25 3.6 1.0 0.0
3 4 -4.10 1.3 1.0 0.0
4 5 0.00 -2.0 0.0 1.0
使用虚拟变量拟合某些统计模型时存在一些微妙之处。当您拥有不仅仅是简单数字列时,使用 Patsy(下一节的主题)可能更简单且更不容易出错。
12.2 使用 Patsy 创建模型描述
Patsy是一个用于描述统计模型(尤其是线性模型)的 Python 库,它使用基于字符串的“公式语法”,受到 R 和 S 统计编程语言使用的公式语法的启发(但并非完全相同)。在安装 statsmodels 时会自动安装它:
conda install statsmodels
Patsy 在为 statsmodels 指定线性模型方面得到很好的支持,因此我将重点介绍一些主要功能,以帮助您快速上手。Patsy 的公式是一种特殊的字符串语法,看起来像:
y ~ x0 + x1
语法a + b并不意味着将a加到b,而是这些是为模型创建的设计矩阵中的项。patsy.dmatrices函数接受一个公式字符串以及一个数据集(可以是 DataFrame 或数组字典),并为线性模型生成设计矩阵:
In [29]: data = pd.DataFrame({
....: 'x0': [1, 2, 3, 4, 5],
....: 'x1': [0.01, -0.01, 0.25, -4.1, 0.],
....: 'y': [-1.5, 0., 3.6, 1.3, -2.]})
In [30]: data
Out[30]:
x0 x1 y
0 1 0.01 -1.5
1 2 -0.01 0.0
2 3 0.25 3.6
3 4 -4.10 1.3
4 5 0.00 -2.0
In [31]: import patsy
In [32]: y, X = patsy.dmatrices('y ~ x0 + x1', data)
现在我们有:
In [33]: y
Out[33]:
DesignMatrix with shape (5, 1)
y
-1.5
0.0
3.6
1.3
-2.0
Terms:
'y' (column 0)
In [34]: X
Out[34]:
DesignMatrix with shape (5, 3)
Intercept x0 x1
1 1 0.01
1 2 -0.01
1 3 0.25
1 4 -4.10
1 5 0.00
Terms:
'Intercept' (column 0)
'x0' (column 1)
'x1' (column 2)
这些 Patsy DesignMatrix实例是带有附加元数据的 NumPy ndarrays:
In [35]: np.asarray(y)
Out[35]:
array([[-1.5],
[ 0. ],
[ 3.6],
[ 1.3],
[-2. ]])
In [36]: np.asarray(X)
Out[36]:
array([[ 1. , 1. , 0.01],
[ 1. , 2. , -0.01],
[ 1. , 3. , 0.25],
[ 1. , 4. , -4.1 ],
[ 1. , 5. , 0. ]])
您可能会想知道Intercept项是从哪里来的。这是线性模型(如普通最小二乘回归)的一个约定。您可以通过在模型中添加+ 0项来抑制截距:
In [37]: patsy.dmatrices('y ~ x0 + x1 + 0', data)[1]
Out[37]:
DesignMatrix with shape (5, 2)
x0 x1
1 0.01
2 -0.01
3 0.25
4 -4.10
5 0.00
Terms:
'x0' (column 0)
'x1' (column 1)
Patsy 对象可以直接传递到像numpy.linalg.lstsq这样的算法中,该算法执行普通最小二乘回归:
In [38]: coef, resid, _, _ = np.linalg.lstsq(X, y, rcond=None)
模型元数据保留在design_info属性中,因此您可以重新附加模型列名称到拟合系数以获得一个 Series,例如:
In [39]: coef
Out[39]:
array([[ 0.3129],
[-0.0791],
[-0.2655]])
In [40]: coef = pd.Series(coef.squeeze(), index=X.design_info.column_names)
In [41]: coef
Out[41]:
Intercept 0.312910
x0 -0.079106
x1 -0.265464
dtype: float64
Patsy 公式中的数据转换
您可以将 Python 代码混合到您的 Patsy 公式中;在评估公式时,库将尝试在封闭范围中找到您使用的函数:
In [42]: y, X = patsy.dmatrices('y ~ x0 + np.log(np.abs(x1) + 1)', data)
In [43]: X
Out[43]:
DesignMatrix with shape (5, 3)
Intercept x0 np.log(np.abs(x1) + 1)
1 1 0.00995
1 2 0.00995
1 3 0.22314
1 4 1.62924
1 5 0.00000
Terms:
'Intercept' (column 0)
'x0' (column 1)
'np.log(np.abs(x1) + 1)' (column 2)
一些常用的变量转换包括标准化(均值为 0,方差为 1)和中心化(减去均值)。Patsy 具有内置函数用于此目的:
In [44]: y, X = patsy.dmatrices('y ~ standardize(x0) + center(x1)', data)
In [45]: X
Out[45]:
DesignMatrix with shape (5, 3)
Intercept standardize(x0) center(x1)
1 -1.41421 0.78
1 -0.70711 0.76
1 0.00000 1.02
1 0.70711 -3.33
1 1.41421 0.77
Terms:
'Intercept' (column 0)
'standardize(x0)' (column 1)
'center(x1)' (column 2)
作为建模过程的一部分,您可以在一个数据集上拟合模型,然后基于另一个数据集评估模型。这可能是一个保留部分或稍后观察到的新数据。当应用诸如中心化和标准化之类的转换时,您在使用模型基于新数据形成预测时应当小心。这些被称为有状态转换,因为在转换新数据时必须使用原始数据集的统计数据,如均值或标准差。
patsy.build_design_matrices函数可以使用原始样本内数据的保存信息对新的样本外数据应用转换:
In [46]: new_data = pd.DataFrame({
....: 'x0': [6, 7, 8, 9],
....: 'x1': [3.1, -0.5, 0, 2.3],
....: 'y': [1, 2, 3, 4]})
In [47]: new_X = patsy.build_design_matrices([X.design_info], new_data)
In [48]: new_X
Out[48]:
[DesignMatrix with shape (4, 3)
Intercept standardize(x0) center(x1)
1 2.12132 3.87
1 2.82843 0.27
1 3.53553 0.77
1 4.24264 3.07
Terms:
'Intercept' (column 0)
'standardize(x0)' (column 1)
'center(x1)' (column 2)]
因为 Patsy 公式中加号(+)并不表示加法,所以当您想按名称从数据集中添加列时,您必须将它们包装在特殊的I函数中:
In [49]: y, X = patsy.dmatrices('y ~ I(x0 + x1)', data)
In [50]: X
Out[50]:
DesignMatrix with shape (5, 2)
Intercept I(x0 + x1)
1 1.01
1 1.99
1 3.25
1 -0.10
1 5.00
Terms:
'Intercept' (column 0)
'I(x0 + x1)' (column 1)
Patsy 在patsy.builtins模块中还有几个内置转换。请查看在线文档以获取更多信息。
分类数据有一类特殊的转换,接下来我会解释。
分类数据和 Patsy
非数字数据可以以多种不同的方式转换为模型设计矩阵。本书不涉及这个主题的完整处理,最好是在统计课程中学习。
当您在 Patsy 公式中使用非数字术语时,默认情况下它们会被转换为虚拟变量。如果有一个截距,将会有一个级别被排除以避免共线性:
In [51]: data = pd.DataFrame({
....: 'key1': ['a', 'a', 'b', 'b', 'a', 'b', 'a', 'b'],
....: 'key2': [0, 1, 0, 1, 0, 1, 0, 0],
....: 'v1': [1, 2, 3, 4, 5, 6, 7, 8],
....: 'v2': [-1, 0, 2.5, -0.5, 4.0, -1.2, 0.2, -1.7]
....: })
In [52]: y, X = patsy.dmatrices('v2 ~ key1', data)
In [53]: X
Out[53]:
DesignMatrix with shape (8, 2)
Intercept key1[T.b]
1 0
1 0
1 1
1 1
1 0
1 1
1 0
1 1
Terms:
'Intercept' (column 0)
'key1' (column 1)
如果从模型中省略截距,那么每个类别值的列将包含在模型设计矩阵中:
In [54]: y, X = patsy.dmatrices('v2 ~ key1 + 0', data)
In [55]: X
Out[55]:
DesignMatrix with shape (8, 2)
key1[a] key1[b]
1 0
1 0
0 1
0 1
1 0
0 1
1 0
0 1
Terms:
'key1' (columns 0:2)
数值列可以使用C函数解释为分类列:
In [56]: y, X = patsy.dmatrices('v2 ~ C(key2)', data)
In [57]: X
Out[57]:
DesignMatrix with shape (8, 2)
Intercept C(key2)[T.1]
1 0
1 1
1 0
1 1
1 0
1 1
1 0
1 0
Terms:
'Intercept' (column 0)
'C(key2)' (column 1)
当您在模型中使用多个分类项时,情况可能会更加复杂,因为您可以包括形式为key1:key2的交互项,例如在方差分析(ANOVA)模型中使用:
In [58]: data['key2'] = data['key2'].map({0: 'zero', 1: 'one'})
In [59]: data
Out[59]:
key1 key2 v1 v2
0 a zero 1 -1.0
1 a one 2 0.0
2 b zero 3 2.5
3 b one 4 -0.5
4 a zero 5 4.0
5 b one 6 -1.2
6 a zero 7 0.2
7 b zero 8 -1.7
In [60]: y, X = patsy.dmatrices('v2 ~ key1 + key2', data)
In [61]: X
Out[61]:
DesignMatrix with shape (8, 3)
Intercept key1[T.b] key2[T.zero]
1 0 1
1 0 0
1 1 1
1 1 0
1 0 1
1 1 0
1 0 1
1 1 1
Terms:
'Intercept' (column 0)
'key1' (column 1)
'key2' (column 2)
In [62]: y, X = patsy.dmatrices('v2 ~ key1 + key2 + key1:key2', data)
In [63]: X
Out[63]:
DesignMatrix with shape (8, 4)
Intercept key1[T.b] key2[T.zero] key1[T.b]:key2[T.zero]
1 0 1 0
1 0 0 0
1 1 1 1
1 1 0 0
1 0 1 0
1 1 0 0
1 0 1 0
1 1 1 1
Terms:
'Intercept' (column 0)
'key1' (column 1)
'key2' (column 2)
'key1:key2' (column 3)
Patsy 提供了其他转换分类数据的方法,包括具有特定顺序的项的转换。有关更多信息,请参阅在线文档。
12.3 statsmodels 简介
statsmodels是一个用于拟合许多种统计模型、执行统计检验以及数据探索和可视化的 Python 库。statsmodels 包含更多“经典”的频率统计方法,而贝叶斯方法和机器学习模型则在其他库中找到。
在 statsmodels 中找到的一些模型类型包括:
-
线性模型、广义线性模型和鲁棒线性模型
-
线性混合效应模型
-
方差分析(ANOVA)方法
-
时间序列过程和状态空间模型
-
广义矩估计法
在接下来的几页中,我们将使用 statsmodels 中的一些基本工具,并探索如何使用 Patsy 公式和 pandas DataFrame 对象的建模接口。如果您之前在 Patsy 讨论中没有安装 statsmodels,现在可以使用以下命令进行安装:
conda install statsmodels
估计线性模型
statsmodels 中有几种线性回归模型,从更基本的(例如普通最小二乘法)到更复杂的(例如迭代重新加权最小二乘法)。
statsmodels 中的线性模型有两种不同的主要接口:基于数组和基于公式。可以通过以下 API 模块导入来访问这些接口:
import statsmodels.api as sm
import statsmodels.formula.api as smf
为了展示如何使用这些方法,我们从一些随机数据生成一个线性模型。在 Jupyter 中运行以下代码:
# To make the example reproducible
rng = np.random.default_rng(seed=12345)
def dnorm(mean, variance, size=1):
if isinstance(size, int):
size = size,
return mean + np.sqrt(variance) * rng.standard_normal(*size)
N = 100
X = np.c_[dnorm(0, 0.4, size=N),
dnorm(0, 0.6, size=N),
dnorm(0, 0.2, size=N)]
eps = dnorm(0, 0.1, size=N)
beta = [0.1, 0.3, 0.5]
y = np.dot(X, beta) + eps
在这里,我写下了具有已知参数beta的“真实”模型。在这种情况下,dnorm是一个用于生成具有特定均值和方差的正态分布数据的辅助函数。现在我们有:
In [66]: X[:5]
Out[66]:
array([[-0.9005, -0.1894, -1.0279],
[ 0.7993, -1.546 , -0.3274],
[-0.5507, -0.1203, 0.3294],
[-0.1639, 0.824 , 0.2083],
[-0.0477, -0.2131, -0.0482]])
In [67]: y[:5]
Out[67]: array([-0.5995, -0.5885, 0.1856, -0.0075, -0.0154])
通常使用截距项拟合线性模型,就像我们之前在 Patsy 中看到的那样。sm.add_constant函数可以向现有矩阵添加一个截距列:
In [68]: X_model = sm.add_constant(X)
In [69]: X_model[:5]
Out[69]:
array([[ 1. , -0.9005, -0.1894, -1.0279],
[ 1. , 0.7993, -1.546 , -0.3274],
[ 1. , -0.5507, -0.1203, 0.3294],
[ 1. , -0.1639, 0.824 , 0.2083],
[ 1. , -0.0477, -0.2131, -0.0482]])
sm.OLS类可以拟合普通最小二乘线性回归:
In [70]: model = sm.OLS(y, X)
模型的fit方法返回一个包含估计模型参数和其他诊断信息的回归结果对象:
In [71]: results = model.fit()
In [72]: results.params
Out[72]: array([0.0668, 0.268 , 0.4505])
results上的summary方法可以打印出模型的诊断输出:
In [73]: print(results.summary())
OLS Regression Results
=================================================================================
======
Dep. Variable: y R-squared (uncentered):
0.469
Model: OLS Adj. R-squared (uncentered):
0.452
Method: Least Squares F-statistic:
28.51
Date: Wed, 12 Apr 2023 Prob (F-statistic): 2.
66e-13
Time: 13:09:20 Log-Likelihood: -
25.611
No. Observations: 100 AIC:
57.22
Df Residuals: 97 BIC:
65.04
Df Model: 3
Covariance Type: nonrobust
==============================================================================
coef std err t P>|t| [0.025 0.975]
------------------------------------------------------------------------------
x1 0.0668 0.054 1.243 0.217 -0.040 0.174
x2 0.2680 0.042 6.313 0.000 0.184 0.352
x3 0.4505 0.068 6.605 0.000 0.315 0.586
==============================================================================
Omnibus: 0.435 Durbin-Watson: 1.869
Prob(Omnibus): 0.805 Jarque-Bera (JB): 0.301
Skew: 0.134 Prob(JB): 0.860
Kurtosis: 2.995 Cond. No. 1.64
==============================================================================
Notes:
[1] R² is computed without centering (uncentered) since the model does not contai
n a constant.
[2] Standard Errors assume that the covariance matrix of the errors is correctly
specified.
这里的参数名称已经被赋予了通用名称x1, x2等。假设所有模型参数都在一个 DataFrame 中:
In [74]: data = pd.DataFrame(X, columns=['col0', 'col1', 'col2'])
In [75]: data['y'] = y
In [76]: data[:5]
Out[76]:
col0 col1 col2 y
0 -0.900506 -0.189430 -1.027870 -0.599527
1 0.799252 -1.545984 -0.327397 -0.588454
2 -0.550655 -0.120254 0.329359 0.185634
3 -0.163916 0.824040 0.208275 -0.007477
4 -0.047651 -0.213147 -0.048244 -0.015374
现在我们可以使用 statsmodels 的公式 API 和 Patsy 公式字符串:
In [77]: results = smf.ols('y ~ col0 + col1 + col2', data=data).fit()
In [78]: results.params
Out[78]:
Intercept -0.020799
col0 0.065813
col1 0.268970
col2 0.449419
dtype: float64
In [79]: results.tvalues
Out[79]:
Intercept -0.652501
col0 1.219768
col1 6.312369
col2 6.567428
dtype: float64
注意 statsmodels 如何将结果返回为带有 DataFrame 列名称附加的 Series。在使用公式和 pandas 对象时,我们也不需要使用add_constant。
给定新的样本外数据,可以根据估计的模型参数计算预测值:
In [80]: results.predict(data[:5])
Out[80]:
0 -0.592959
1 -0.531160
2 0.058636
3 0.283658
4 -0.102947
dtype: float64
在 statsmodels 中有许多用于分析、诊断和可视化线性模型结果的附加工具,您可以探索。除了普通最小二乘法之外,还有其他类型的线性模型。
估计时间序列过程
statsmodels 中的另一类模型是用于时间序列分析的模型。其中包括自回归过程、卡尔曼滤波和其他状态空间模型以及多变量自回归模型。
让我们模拟一些具有自回归结构和噪声的时间序列数据。在 Jupyter 中运行以下代码:
init_x = 4
values = [init_x, init_x]
N = 1000
b0 = 0.8
b1 = -0.4
noise = dnorm(0, 0.1, N)
for i in range(N):
new_x = values[-1] * b0 + values[-2] * b1 + noise[i]
values.append(new_x)
这个数据具有 AR(2)结构(两个滞后),参数为0.8和-0.4。当拟合 AR 模型时,您可能不知道要包括的滞后项的数量,因此可以使用一些更大数量的滞后项来拟合模型:
In [82]: from statsmodels.tsa.ar_model import AutoReg
In [83]: MAXLAGS = 5
In [84]: model = AutoReg(values, MAXLAGS)
In [85]: results = model.fit()
结果中的估计参数首先是截距,接下来是前两个滞后的估计值:
In [86]: results.params
Out[86]: array([ 0.0235, 0.8097, -0.4287, -0.0334, 0.0427, -0.0567])
这些模型的更深层细节以及如何解释它们的结果超出了我在本书中可以涵盖的范围,但在 statsmodels 文档中还有很多内容等待探索。
12.4 scikit-learn 简介
scikit-learn是最广泛使用和信任的通用 Python 机器学习工具包之一。它包含广泛的标准监督和无监督机器学习方法,具有模型选择和评估工具,数据转换,数据加载和模型持久性。这些模型可用于分类,聚类,预测和其他常见任务。您可以像这样从 conda 安装 scikit-learn:
conda install scikit-learn
有很多在线和印刷资源可供学习机器学习以及如何应用类似 scikit-learn 的库来解决实际问题。在本节中,我将简要介绍 scikit-learn API 风格。
scikit-learn 中的 pandas 集成在近年来显著改善,当您阅读本文时,它可能已经进一步改进。我鼓励您查看最新的项目文档。
作为本章的示例,我使用了一份来自 Kaggle 竞赛的经典数据集,关于 1912 年泰坦尼克号上乘客生存率。我们使用 pandas 加载训练和测试数据集:
In [87]: train = pd.read_csv('datasets/titanic/train.csv')
In [88]: test = pd.read_csv('datasets/titanic/test.csv')
In [89]: train.head(4)
Out[89]:
PassengerId Survived Pclass
0 1 0 3 \
1 2 1 1
2 3 1 3
3 4 1 1
Name Sex Age SibSp
0 Braund, Mr. Owen Harris male 22.0 1 \
1 Cumings, Mrs. John Bradley (Florence Briggs Thayer) female 38.0 1
2 Heikkinen, Miss. Laina female 26.0 0
3 Futrelle, Mrs. Jacques Heath (Lily May Peel) female 35.0 1
Parch Ticket Fare Cabin Embarked
0 0 A/5 21171 7.2500 NaN S
1 0 PC 17599 71.2833 C85 C
2 0 STON/O2\. 3101282 7.9250 NaN S
3 0 113803 53.1000 C123 S
像 statsmodels 和 scikit-learn 这样的库通常无法处理缺失数据,因此我们查看列,看看是否有包含缺失数据的列:
In [90]: train.isna().sum()
Out[90]:
PassengerId 0
Survived 0
Pclass 0
Name 0
Sex 0
Age 177
SibSp 0
Parch 0
Ticket 0
Fare 0
Cabin 687
Embarked 2
dtype: int64
In [91]: test.isna().sum()
Out[91]:
PassengerId 0
Pclass 0
Name 0
Sex 0
Age 86
SibSp 0
Parch 0
Ticket 0
Fare 1
Cabin 327
Embarked 0
dtype: int64
在统计学和机器学习的示例中,一个典型的任务是根据数据中的特征预测乘客是否会生存。模型在训练数据集上拟合,然后在外样本测试数据集上进行评估。
我想使用Age作为预测变量,但它有缺失数据。有很多方法可以进行缺失数据插补,但我将使用训练数据集的中位数来填充两个表中的空值:
In [92]: impute_value = train['Age'].median()
In [93]: train['Age'] = train['Age'].fillna(impute_value)
In [94]: test['Age'] = test['Age'].fillna(impute_value)
现在我们需要指定我们的模型。我添加一个名为IsFemale的列,作为'Sex'列的编码版本:
In [95]: train['IsFemale'] = (train['Sex'] == 'female').astype(int)
In [96]: test['IsFemale'] = (test['Sex'] == 'female').astype(int)
然后我们决定一些模型变量并创建 NumPy 数组:
In [97]: predictors = ['Pclass', 'IsFemale', 'Age']
In [98]: X_train = train[predictors].to_numpy()
In [99]: X_test = test[predictors].to_numpy()
In [100]: y_train = train['Survived'].to_numpy()
In [101]: X_train[:5]
Out[101]:
array([[ 3., 0., 22.],
[ 1., 1., 38.],
[ 3., 1., 26.],
[ 1., 1., 35.],
[ 3., 0., 35.]])
In [102]: y_train[:5]
Out[102]: array([0, 1, 1, 1, 0])
我不断言这是一个好模型或这些特征是否被正确设计。我们使用 scikit-learn 中的LogisticRegression模型并创建一个模型实例:
In [103]: from sklearn.linear_model import LogisticRegression
In [104]: model = LogisticRegression()
我们可以使用模型的fit方法将此模型拟合到训练数据中:
In [105]: model.fit(X_train, y_train)
Out[105]: LogisticRegression()
现在,我们可以使用model.predict为测试数据集进行预测:
In [106]: y_predict = model.predict(X_test)
In [107]: y_predict[:10]
Out[107]: array([0, 0, 0, 0, 1, 0, 1, 0, 1, 0])
如果您有测试数据集的真实值,可以计算准确率百分比或其他错误度量:
(y_true == y_predict).mean()
在实践中,模型训练通常存在许多额外的复杂层。许多模型具有可以调整的参数,并且有一些技术,如交叉验证可用于参数调整,以避免过度拟合训练数据。这通常可以提供更好的预测性能或对新数据的鲁棒性。
交叉验证通过拆分训练数据来模拟外样本预测。根据像均方误差这样的模型准确度得分,您可以对模型参数执行网格搜索。一些模型,如逻辑回归,具有内置交叉验证的估计器类。例如,LogisticRegressionCV类可以与一个参数一起使用,该参数指示在模型正则化参数C上执行多精细的网格搜索:
In [108]: from sklearn.linear_model import LogisticRegressionCV
In [109]: model_cv = LogisticRegressionCV(Cs=10)
In [110]: model_cv.fit(X_train, y_train)
Out[110]: LogisticRegressionCV()
手动进行交叉验证,可以使用cross_val_score辅助函数,该函数处理数据拆分过程。例如,要对我们的模型进行四个不重叠的训练数据拆分进行交叉验证,我们可以这样做:
In [111]: from sklearn.model_selection import cross_val_score
In [112]: model = LogisticRegression(C=10)
In [113]: scores = cross_val_score(model, X_train, y_train, cv=4)
In [114]: scores
Out[114]: array([0.7758, 0.7982, 0.7758, 0.7883])
默认的评分指标取决于模型,但可以选择一个明确的评分函数。交叉验证模型训练时间较长,但通常可以获得更好的模型性能。
12.5 结论
虽然我只是浅尝了一些 Python 建模库的表面,但有越来越多的框架适用于各种统计和机器学习,要么是用 Python 实现的,要么有 Python 用户界面。
这本书专注于数据整理,但还有许多其他专门用于建模和数据科学工具的书籍。一些优秀的书籍包括:
-
《Python 机器学习入门》作者 Andreas Müller 和 Sarah Guido(O'Reilly)
-
《Python 数据科学手册》作者 Jake VanderPlas(O'Reilly)
-
《从零开始的数据科学:Python 基础》作者 Joel Grus(O'Reilly)
-
《Python 机器学习》作者 Sebastian Raschka 和 Vahid Mirjalili(Packt Publishing)
-
《使用 Scikit-Learn、Keras 和 TensorFlow 进行实践机器学习》作者 Aurélien Géron(O'Reilly)
尽管书籍可以是学习的宝贵资源,但当底层的开源软件发生变化时,它们有时会变得过时。熟悉各种统计或机器学习框架的文档是一个好主意,以便了解最新功能和 API。
十三、数据分析示例
原文:
wesmckinney.com/book/data-analysis-examples译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
现在我们已经到达本书的最后一章,我们将查看一些真实世界的数据集。对于每个数据集,我们将使用本书中介绍的技术从原始数据中提取含义。演示的技术可以应用于各种其他数据集。本章包含一系列杂例数据集,您可以使用这些数据集练习本书中的工具。
示例数据集可在本书附带的GitHub 存储库中找到。如果无法访问 GitHub,还可以从Gitee 上的存储库镜像获取它们。
13.1 Bitly Data from 1.USA.gov
2011 年,URL 缩短服务Bitly与美国政府网站USA.gov合作,提供从缩短链接以.gov或.mil结尾的用户收集的匿名数据的源。2011 年,可下载的文本文件提供了实时数据以及每小时的快照。本文撰写时(2022 年),该服务已关闭,但我们保留了一份数据文件用于本书的示例。
在每个文件的每一行中,每小时快照包含一种称为 JSON 的常见网络数据形式,JSON 代表 JavaScript 对象表示法。例如,如果我们只读取文件的第一行,可能会看到类似于这样的内容:
In [5]: path = "datasets/bitly_usagov/example.txt"
In [6]: with open(path) as f:
...: print(f.readline())
...:
{ "a": "Mozilla\\/5.0 (Windows NT 6.1; WOW64) AppleWebKit\\/535.11
(KHTML, like Gecko) Chrome\\/17.0.963.78 Safari\\/535.11", "c": "US", "nk": 1,
"tz": "America\\/New_York", "gr": "MA", "g": "A6qOVH", "h": "wfLQtf", "l":
"orofrog", "al": "en-US,en;q=0.8", "hh": "1.usa.gov", "r":
"http:\\/\\/www.facebook.com\\/l\\/7AQEFzjSi\\/1.usa.gov\\/wfLQtf", "u":
"http:\\/\\/www.ncbi.nlm.nih.gov\\/pubmed\\/22415991", "t": 1331923247, "hc":
1331822918, "cy": "Danvers", "ll": [ 42.576698, -70.954903 ] }
Python 有内置和第三方库,用于将 JSON 字符串转换为 Python 字典。在这里,我们将使用json模块及其在我们下载的示例文件中的每一行上调用的loads函数:
import json
with open(path) as f:
records = [json.loads(line) for line in f]
结果对象records现在是一个 Python 字典列表:
In [18]: records[0]
Out[18]:
{'a': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.11 (KHTML, like Gecko)
Chrome/17.0.963.78 Safari/535.11',
'al': 'en-US,en;q=0.8',
'c': 'US',
'cy': 'Danvers',
'g': 'A6qOVH',
'gr': 'MA',
'h': 'wfLQtf',
'hc': 1331822918,
'hh': '1.usa.gov',
'l': 'orofrog',
'll': [42.576698, -70.954903],
'nk': 1,
'r': 'http://www.facebook.com/l/7AQEFzjSi/1.usa.gov/wfLQtf',
't': 1331923247,
'tz': 'America/New_York',
'u': 'http://www.ncbi.nlm.nih.gov/pubmed/22415991'}
使用纯 Python 计算时区
假设我们有兴趣找出数据集中最常出现的时区(tz字段)。我们可以通过多种方式来实现这一点。首先,让我们再次使用列表推导式提取时区列表:
In [15]: time_zones = [rec["tz"] for rec in records]
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
<ipython-input-15-abdeba901c13> in <module>
----> 1 time_zones = [rec["tz"] for rec in records]
<ipython-input-15-abdeba901c13> in <listcomp>(.0)
----> 1 time_zones = [rec["tz"] for rec in records]
KeyError: 'tz'
糟糕!原来并非所有记录都有时区字段。我们可以通过在列表推导式末尾添加检查if "tz" in rec来处理这个问题:
In [16]: time_zones = [rec["tz"] for rec in records if "tz" in rec]
In [17]: time_zones[:10]
Out[17]:
['America/New_York',
'America/Denver',
'America/New_York',
'America/Sao_Paulo',
'America/New_York',
'America/New_York',
'Europe/Warsaw',
'',
'',
'']
仅查看前 10 个时区,我们会发现其中一些是未知的(空字符串)。您也可以将这些过滤掉,但我暂时保留它们。接下来,为了按时区生成计数,我将展示两种方法:一种更困难的方法(仅使用 Python 标准库)和一种更简单的方法(使用 pandas)。计数的一种方法是使用字典来存储计数,同时我们遍历时区:
def get_counts(sequence):
counts = {}
for x in sequence:
if x in counts:
counts[x] += 1
else:
counts[x] = 1
return counts
使用 Python 标准库中更高级的工具,您可以更简洁地编写相同的内容:
from collections import defaultdict
def get_counts2(sequence):
counts = defaultdict(int) # values will initialize to 0
for x in sequence:
counts[x] += 1
return counts
我将这个逻辑放在一个函数中,以使其更具可重用性。要在时区上使用它,只需传递time_zones列表:
In [20]: counts = get_counts(time_zones)
In [21]: counts["America/New_York"]
Out[21]: 1251
In [22]: len(time_zones)
Out[22]: 3440
如果我们想要前 10 个时区及其计数,我们可以通过(count, timezone)创建一个元组列表,并对其进行排序:
def top_counts(count_dict, n=10):
value_key_pairs = [(count, tz) for tz, count in count_dict.items()]
value_key_pairs.sort()
return value_key_pairs[-n:]
我们有:
In [24]: top_counts(counts)
Out[24]:
[(33, 'America/Sao_Paulo'),
(35, 'Europe/Madrid'),
(36, 'Pacific/Honolulu'),
(37, 'Asia/Tokyo'),
(74, 'Europe/London'),
(191, 'America/Denver'),
(382, 'America/Los_Angeles'),
(400, 'America/Chicago'),
(521, ''),
(1251, 'America/New_York')]
如果您搜索 Python 标准库,可能会找到collections.Counter类,这将使这个任务变得更简单:
In [25]: from collections import Counter
In [26]: counts = Counter(time_zones)
In [27]: counts.most_common(10)
Out[27]:
[('America/New_York', 1251),
('', 521),
('America/Chicago', 400),
('America/Los_Angeles', 382),
('America/Denver', 191),
('Europe/London', 74),
('Asia/Tokyo', 37),
('Pacific/Honolulu', 36),
('Europe/Madrid', 35),
('America/Sao_Paulo', 33)]
使用 pandas 计算时区
您可以通过将记录列表传递给pandas.DataFrame来从原始记录集创建一个 DataFrame:
In [28]: frame = pd.DataFrame(records)
我们可以查看有关这个新 DataFrame 的一些基本信息,比如列名、推断的列类型或缺失值的数量,使用frame.info():
In [29]: frame.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3560 entries, 0 to 3559
Data columns (total 18 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 a 3440 non-null object
1 c 2919 non-null object
2 nk 3440 non-null float64
3 tz 3440 non-null object
4 gr 2919 non-null object
5 g 3440 non-null object
6 h 3440 non-null object
7 l 3440 non-null object
8 al 3094 non-null object
9 hh 3440 non-null object
10 r 3440 non-null object
11 u 3440 non-null object
12 t 3440 non-null float64
13 hc 3440 non-null float64
14 cy 2919 non-null object
15 ll 2919 non-null object
16 _heartbeat_ 120 non-null float64
17 kw 93 non-null object
dtypes: float64(4), object(14)
memory usage: 500.8+ KB
In [30]: frame["tz"].head()
Out[30]:
0 America/New_York
1 America/Denver
2 America/New_York
3 America/Sao_Paulo
4 America/New_York
Name: tz, dtype: object
frame的输出显示为摘要视图,适用于大型 DataFrame 对象。然后我们可以使用 Series 的value_counts方法:
In [31]: tz_counts = frame["tz"].value_counts()
In [32]: tz_counts.head()
Out[32]:
tz
America/New_York 1251
521
America/Chicago 400
America/Los_Angeles 382
America/Denver 191
Name: count, dtype: int64
我们可以使用 matplotlib 可视化这些数据。我们可以通过为记录中的未知或缺失时区数据填充替代值来使图表更加美观。我们使用fillna方法替换缺失值,并使用布尔数组索引来处理空字符串:
In [33]: clean_tz = frame["tz"].fillna("Missing")
In [34]: clean_tz[clean_tz == ""] = "Unknown"
In [35]: tz_counts = clean_tz.value_counts()
In [36]: tz_counts.head()
Out[36]:
tz
America/New_York 1251
Unknown 521
America/Chicago 400
America/Los_Angeles 382
America/Denver 191
Name: count, dtype: int64
此时,我们可以使用seaborn 包制作一个水平条形图(参见 1.usa.gov 示例数据中的顶级时区以查看结果可视化):
In [38]: import seaborn as sns
In [39]: subset = tz_counts.head()
In [40]: sns.barplot(y=subset.index, x=subset.to_numpy())

图 13.1:1.usa.gov 示例数据中的顶级时区
a字段包含有关用于执行 URL 缩短的浏览器、设备或应用程序的信息:
In [41]: frame["a"][1]
Out[41]: 'GoogleMaps/RochesterNY'
In [42]: frame["a"][50]
Out[42]: 'Mozilla/5.0 (Windows NT 5.1; rv:10.0.2) Gecko/20100101 Firefox/10.0.2'
In [43]: frame["a"][51][:50] # long line
Out[43]: 'Mozilla/5.0 (Linux; U; Android 2.2.2; en-us; LG-P9'
解析这些“代理”字符串中的所有有趣信息可能看起来是一项艰巨的任务。一种可能的策略是将字符串中的第一个标记(大致对应于浏览器功能)拆分出来,并对用户行为进行另一个摘要:
In [44]: results = pd.Series([x.split()[0] for x in frame["a"].dropna()])
In [45]: results.head(5)
Out[45]:
0 Mozilla/5.0
1 GoogleMaps/RochesterNY
2 Mozilla/4.0
3 Mozilla/5.0
4 Mozilla/5.0
dtype: object
In [46]: results.value_counts().head(8)
Out[46]:
Mozilla/5.0 2594
Mozilla/4.0 601
GoogleMaps/RochesterNY 121
Opera/9.80 34
TEST_INTERNET_AGENT 24
GoogleProducer 21
Mozilla/6.0 5
BlackBerry8520/5.0.0.681 4
Name: count, dtype: int64
现在,假设您想将顶级时区分解为 Windows 和非 Windows 用户。为简化起见,假设如果代理字符串中包含"Windows"字符串,则用户使用的是 Windows。由于一些代理缺失,我们将排除这些数据:
In [47]: cframe = frame[frame["a"].notna()].copy()
然后,我们想计算每行是否为 Windows 的值:
In [48]: cframe["os"] = np.where(cframe["a"].str.contains("Windows"),
....: "Windows", "Not Windows")
In [49]: cframe["os"].head(5)
Out[49]:
0 Windows
1 Not Windows
2 Windows
3 Not Windows
4 Windows
Name: os, dtype: object
然后,您可以按其时区列和这个新的操作系统列表对数据进行分组:
In [50]: by_tz_os = cframe.groupby(["tz", "os"])
类似于value_counts函数,可以使用size计算组计数。然后将结果重塑为表格,使用unstack:
In [51]: agg_counts = by_tz_os.size().unstack().fillna(0)
In [52]: agg_counts.head()
Out[52]:
os Not Windows Windows
tz
245.0 276.0
Africa/Cairo 0.0 3.0
Africa/Casablanca 0.0 1.0
Africa/Ceuta 0.0 2.0
Africa/Johannesburg 0.0 1.0
最后,让我们选择顶级的整体时区。为此,我从agg_counts中的行计数构建一个间接索引数组。在使用agg_counts.sum("columns")计算行计数后,我可以调用argsort()来获得一个可以用于升序排序的索引数组:
In [53]: indexer = agg_counts.sum("columns").argsort()
In [54]: indexer.values[:10]
Out[54]: array([24, 20, 21, 92, 87, 53, 54, 57, 26, 55])
我使用take按顺序选择行,然后切掉最后 10 行(最大值):
In [55]: count_subset = agg_counts.take(indexer[-10:])
In [56]: count_subset
Out[56]:
os Not Windows Windows
tz
America/Sao_Paulo 13.0 20.0
Europe/Madrid 16.0 19.0
Pacific/Honolulu 0.0 36.0
Asia/Tokyo 2.0 35.0
Europe/London 43.0 31.0
America/Denver 132.0 59.0
America/Los_Angeles 130.0 252.0
America/Chicago 115.0 285.0
245.0 276.0
America/New_York 339.0 912.0
pandas 有一个方便的方法叫做nlargest,可以做同样的事情:
In [57]: agg_counts.sum(axis="columns").nlargest(10)
Out[57]:
tz
America/New_York 1251.0
521.0
America/Chicago 400.0
America/Los_Angeles 382.0
America/Denver 191.0
Europe/London 74.0
Asia/Tokyo 37.0
Pacific/Honolulu 36.0
Europe/Madrid 35.0
America/Sao_Paulo 33.0
dtype: float64
然后,可以绘制一个分组条形图,比较 Windows 和非 Windows 用户的数量,使用 seaborn 的barplot函数(参见按 Windows 和非 Windows 用户的顶级时区)。我首先调用count_subset.stack()并重置索引以重新排列数据,以便更好地与 seaborn 兼容:
In [59]: count_subset = count_subset.stack()
In [60]: count_subset.name = "total"
In [61]: count_subset = count_subset.reset_index()
In [62]: count_subset.head(10)
Out[62]:
tz os total
0 America/Sao_Paulo Not Windows 13.0
1 America/Sao_Paulo Windows 20.0
2 Europe/Madrid Not Windows 16.0
3 Europe/Madrid Windows 19.0
4 Pacific/Honolulu Not Windows 0.0
5 Pacific/Honolulu Windows 36.0
6 Asia/Tokyo Not Windows 2.0
7 Asia/Tokyo Windows 35.0
8 Europe/London Not Windows 43.0
9 Europe/London Windows 31.0
In [63]: sns.barplot(x="total", y="tz", hue="os", data=count_subset)

图 13.2:按 Windows 和非 Windows 用户的顶级时区
在较小的组中,很难看出 Windows 用户的相对百分比,因此让我们将组百分比归一化为 1:
def norm_total(group):
group["normed_total"] = group["total"] / group["total"].sum()
return group
results = count_subset.groupby("tz").apply(norm_total)
然后在出现频率最高的时区中 Windows 和非 Windows 用户的百分比中绘制这个图:
In [66]: sns.barplot(x="normed_total", y="tz", hue="os", data=results)

图 13.3:出现频率最高的时区中 Windows 和非 Windows 用户的百分比
我们可以通过使用transform方法和groupby更有效地计算归一化和:
In [67]: g = count_subset.groupby("tz")
In [68]: results2 = count_subset["total"] / g["total"].transform("sum")
13.2 MovieLens 1M 数据集
GroupLens Research提供了从 1990 年代末到 2000 年代初从 MovieLens 用户收集的多个电影评分数据集。数据提供了电影评分、电影元数据(类型和年份)以及关于用户的人口统计数据(年龄、邮政编码、性别认同和职业)。这些数据通常在基于机器学习算法的推荐系统的开发中很有兴趣。虽然我们在本书中没有详细探讨机器学习技术,但我将向您展示如何将这些数据集切分成您需要的确切形式。
MovieLens 1M 数据集包含从六千名用户对四千部电影收集的一百万个评分。它分布在三个表中:评分、用户信息和电影信息。我们可以使用pandas.read_table将每个表加载到一个 pandas DataFrame 对象中。在 Jupyter 单元格中运行以下代码:
unames = ["user_id", "gender", "age", "occupation", "zip"]
users = pd.read_table("datasets/movielens/users.dat", sep="::",
header=None, names=unames, engine="python")
rnames = ["user_id", "movie_id", "rating", "timestamp"]
ratings = pd.read_table("datasets/movielens/ratings.dat", sep="::",
header=None, names=rnames, engine="python")
mnames = ["movie_id", "title", "genres"]
movies = pd.read_table("datasets/movielens/movies.dat", sep="::",
header=None, names=mnames, engine="python")
您可以通过查看每个 DataFrame 来验证一切是否成功:
In [70]: users.head(5)
Out[70]:
user_id gender age occupation zip
0 1 F 1 10 48067
1 2 M 56 16 70072
2 3 M 25 15 55117
3 4 M 45 7 02460
4 5 M 25 20 55455
In [71]: ratings.head(5)
Out[71]:
user_id movie_id rating timestamp
0 1 1193 5 978300760
1 1 661 3 978302109
2 1 914 3 978301968
3 1 3408 4 978300275
4 1 2355 5 978824291
In [72]: movies.head(5)
Out[72]:
movie_id title genres
0 1 Toy Story (1995) Animation|Children's|Comedy
1 2 Jumanji (1995) Adventure|Children's|Fantasy
2 3 Grumpier Old Men (1995) Comedy|Romance
3 4 Waiting to Exhale (1995) Comedy|Drama
4 5 Father of the Bride Part II (1995) Comedy
In [73]: ratings
Out[73]:
user_id movie_id rating timestamp
0 1 1193 5 978300760
1 1 661 3 978302109
2 1 914 3 978301968
3 1 3408 4 978300275
4 1 2355 5 978824291
... ... ... ... ...
1000204 6040 1091 1 956716541
1000205 6040 1094 5 956704887
1000206 6040 562 5 956704746
1000207 6040 1096 4 956715648
1000208 6040 1097 4 956715569
[1000209 rows x 4 columns]
请注意,年龄和职业被编码为整数,表示数据集的README文件中描述的组。分析分布在三个表中的数据并不是一项简单的任务;例如,假设您想要按性别身份和年龄计算特定电影的平均评分。正如您将看到的,将所有数据合并到一个单一表中更方便。使用 pandas 的merge函数,我们首先将ratings与users合并,然后将该结果与movies数据合并。pandas 根据重叠的名称推断要用作合并(或join)键的列:
In [74]: data = pd.merge(pd.merge(ratings, users), movies)
In [75]: data
Out[75]:
user_id movie_id rating timestamp gender age occupation zip
0 1 1193 5 978300760 F 1 10 48067 \
1 2 1193 5 978298413 M 56 16 70072
2 12 1193 4 978220179 M 25 12 32793
3 15 1193 4 978199279 M 25 7 22903
4 17 1193 5 978158471 M 50 1 95350
... ... ... ... ... ... ... ... ...
1000204 5949 2198 5 958846401 M 18 17 47901
1000205 5675 2703 3 976029116 M 35 14 30030
1000206 5780 2845 1 958153068 M 18 17 92886
1000207 5851 3607 5 957756608 F 18 20 55410
1000208 5938 2909 4 957273353 M 25 1 35401
title genres
0 One Flew Over the Cuckoo's Nest (1975) Drama
1 One Flew Over the Cuckoo's Nest (1975) Drama
2 One Flew Over the Cuckoo's Nest (1975) Drama
3 One Flew Over the Cuckoo's Nest (1975) Drama
4 One Flew Over the Cuckoo's Nest (1975) Drama
... ... ...
1000204 Modulations (1998) Documentary
1000205 Broken Vessels (1998) Drama
1000206 White Boys (1999) Drama
1000207 One Little Indian (1973) Comedy|Drama|Western
1000208 Five Wives, Three Secretaries and Me (1998) Documentary
[1000209 rows x 10 columns]
In [76]: data.iloc[0]
Out[76]:
user_id 1
movie_id 1193
rating 5
timestamp 978300760
gender F
age 1
occupation 10
zip 48067
title One Flew Over the Cuckoo's Nest (1975)
genres Drama
Name: 0, dtype: object
为了获得按性别分组的每部电影的平均评分,我们可以使用pivot_table方法:
In [77]: mean_ratings = data.pivot_table("rating", index="title",
....: columns="gender", aggfunc="mean")
In [78]: mean_ratings.head(5)
Out[78]:
gender F M
title
$1,000,000 Duck (1971) 3.375000 2.761905
'Night Mother (1986) 3.388889 3.352941
'Til There Was You (1997) 2.675676 2.733333
'burbs, The (1989) 2.793478 2.962085
...And Justice for All (1979) 3.828571 3.689024
这产生了另一个包含平均评分的 DataFrame,其中电影标题作为行标签(“索引”),性别作为列标签。我首先筛选出至少收到 250 个评分的电影(一个任意的数字);为此,我按标题对数据进行分组,并使用size()来获取每个标题的组大小的 Series:
In [79]: ratings_by_title = data.groupby("title").size()
In [80]: ratings_by_title.head()
Out[80]:
title
$1,000,000 Duck (1971) 37
'Night Mother (1986) 70
'Til There Was You (1997) 52
'burbs, The (1989) 303
...And Justice for All (1979) 199
dtype: int64
In [81]: active_titles = ratings_by_title.index[ratings_by_title >= 250]
In [82]: active_titles
Out[82]:
Index([''burbs, The (1989)', '10 Things I Hate About You (1999)',
'101 Dalmatians (1961)', '101 Dalmatians (1996)', '12 Angry Men (1957)',
'13th Warrior, The (1999)', '2 Days in the Valley (1996)',
'20,000 Leagues Under the Sea (1954)', '2001: A Space Odyssey (1968)',
'2010 (1984)',
...
'X-Men (2000)', 'Year of Living Dangerously (1982)',
'Yellow Submarine (1968)', 'You've Got Mail (1998)',
'Young Frankenstein (1974)', 'Young Guns (1988)',
'Young Guns II (1990)', 'Young Sherlock Holmes (1985)',
'Zero Effect (1998)', 'eXistenZ (1999)'],
dtype='object', name='title', length=1216)
然后,可以使用至少收到 250 个评分的标题的索引来从mean_ratings中选择行,使用.loc:
In [83]: mean_ratings = mean_ratings.loc[active_titles]
In [84]: mean_ratings
Out[84]:
gender F M
title
'burbs, The (1989) 2.793478 2.962085
10 Things I Hate About You (1999) 3.646552 3.311966
101 Dalmatians (1961) 3.791444 3.500000
101 Dalmatians (1996) 3.240000 2.911215
12 Angry Men (1957) 4.184397 4.328421
... ... ...
Young Guns (1988) 3.371795 3.425620
Young Guns II (1990) 2.934783 2.904025
Young Sherlock Holmes (1985) 3.514706 3.363344
Zero Effect (1998) 3.864407 3.723140
eXistenZ (1999) 3.098592 3.289086
[1216 rows x 2 columns]
要查看女性观众最喜欢的电影,我们可以按降序排序F列:
In [86]: top_female_ratings = mean_ratings.sort_values("F", ascending=False)
In [87]: top_female_ratings.head()
Out[87]:
gender F M
title
Close Shave, A (1995) 4.644444 4.473795
Wrong Trousers, The (1993) 4.588235 4.478261
Sunset Blvd. (a.k.a. Sunset Boulevard) (1950) 4.572650 4.464589
Wallace & Gromit: The Best of Aardman Animation (1996) 4.563107 4.385075
Schindler's List (1993) 4.562602 4.491415
测量评分分歧
假设您想要找到在男性和女性观众之间最具分歧的电影。一种方法是向mean_ratings添加一个包含平均值差异的列,然后按照该列进行排序:
In [88]: mean_ratings["diff"] = mean_ratings["M"] - mean_ratings["F"]
按照"diff"排序,可以得到评分差异最大的电影,以便看到哪些电影更受女性喜欢:
In [89]: sorted_by_diff = mean_ratings.sort_values("diff")
In [90]: sorted_by_diff.head()
Out[90]:
gender F M diff
title
Dirty Dancing (1987) 3.790378 2.959596 -0.830782
Jumpin' Jack Flash (1986) 3.254717 2.578358 -0.676359
Grease (1978) 3.975265 3.367041 -0.608224
Little Women (1994) 3.870588 3.321739 -0.548849
Steel Magnolias (1989) 3.901734 3.365957 -0.535777
颠倒行的顺序并再次切片前 10 行,我们得到了男性喜欢但女性评分不高的电影:
In [91]: sorted_by_diff[::-1].head()
Out[91]:
gender F M diff
title
Good, The Bad and The Ugly, The (1966) 3.494949 4.221300 0.726351
Kentucky Fried Movie, The (1977) 2.878788 3.555147 0.676359
Dumb & Dumber (1994) 2.697987 3.336595 0.638608
Longest Day, The (1962) 3.411765 4.031447 0.619682
Cable Guy, The (1996) 2.250000 2.863787 0.613787
假设您想要找到在观众中引起最大分歧的电影,而不考虑性别认同。分歧可以通过评分的方差或标准差来衡量。为了得到这个结果,我们首先按标题计算评分的标准差,然后筛选出活跃的标题:
In [92]: rating_std_by_title = data.groupby("title")["rating"].std()
In [93]: rating_std_by_title = rating_std_by_title.loc[active_titles]
In [94]: rating_std_by_title.head()
Out[94]:
title
'burbs, The (1989) 1.107760
10 Things I Hate About You (1999) 0.989815
101 Dalmatians (1961) 0.982103
101 Dalmatians (1996) 1.098717
12 Angry Men (1957) 0.812731
Name: rating, dtype: float64
然后,我们按降序排序并选择前 10 行,这大致是评分最具分歧的 10 部电影:
In [95]: rating_std_by_title.sort_values(ascending=False)[:10]
Out[95]:
title
Dumb & Dumber (1994) 1.321333
Blair Witch Project, The (1999) 1.316368
Natural Born Killers (1994) 1.307198
Tank Girl (1995) 1.277695
Rocky Horror Picture Show, The (1975) 1.260177
Eyes Wide Shut (1999) 1.259624
Evita (1996) 1.253631
Billy Madison (1995) 1.249970
Fear and Loathing in Las Vegas (1998) 1.246408
Bicentennial Man (1999) 1.245533
Name: rating, dtype: float64
您可能已经注意到电影类型是以管道分隔(|)的字符串给出的,因为一部电影可以属于多种类型。为了帮助我们按类型对评分数据进行分组,我们可以在 DataFrame 上使用explode方法。让我们看看这是如何工作的。首先,我们可以使用 Series 上的str.split方法将类型字符串拆分为类型列表:
In [96]: movies["genres"].head()
Out[96]:
0 Animation|Children's|Comedy
1 Adventure|Children's|Fantasy
2 Comedy|Romance
3 Comedy|Drama
4 Comedy
Name: genres, dtype: object
In [97]: movies["genres"].head().str.split("|")
Out[97]:
0 [Animation, Children's, Comedy]
1 [Adventure, Children's, Fantasy]
2 [Comedy, Romance]
3 [Comedy, Drama]
4 [Comedy]
Name: genres, dtype: object
In [98]: movies["genre"] = movies.pop("genres").str.split("|")
In [99]: movies.head()
Out[99]:
movie_id title
0 1 Toy Story (1995) \
1 2 Jumanji (1995)
2 3 Grumpier Old Men (1995)
3 4 Waiting to Exhale (1995)
4 5 Father of the Bride Part II (1995)
genre
0 [Animation, Children's, Comedy]
1 [Adventure, Children's, Fantasy]
2 [Comedy, Romance]
3 [Comedy, Drama]
4 [Comedy]
现在,调用movies.explode("genre")会生成一个新的 DataFrame,其中每个电影类型列表中的“内部”元素都有一行。例如,如果一部电影被分类为喜剧和浪漫片,那么结果中将有两行,一行只有“喜剧”,另一行只有“浪漫片”:
In [100]: movies_exploded = movies.explode("genre")
In [101]: movies_exploded[:10]
Out[101]:
movie_id title genre
0 1 Toy Story (1995) Animation
0 1 Toy Story (1995) Children's
0 1 Toy Story (1995) Comedy
1 2 Jumanji (1995) Adventure
1 2 Jumanji (1995) Children's
1 2 Jumanji (1995) Fantasy
2 3 Grumpier Old Men (1995) Comedy
2 3 Grumpier Old Men (1995) Romance
3 4 Waiting to Exhale (1995) Comedy
3 4 Waiting to Exhale (1995) Drama
现在,我们可以将所有三个表合并在一起,并按类型分组:
In [102]: ratings_with_genre = pd.merge(pd.merge(movies_exploded, ratings), users
)
In [103]: ratings_with_genre.iloc[0]
Out[103]:
movie_id 1
title Toy Story (1995)
genre Animation
user_id 1
rating 5
timestamp 978824268
gender F
age 1
occupation 10
zip 48067
Name: 0, dtype: object
In [104]: genre_ratings = (ratings_with_genre.groupby(["genre", "age"])
.....: ["rating"].mean()
.....: .unstack("age"))
In [105]: genre_ratings[:10]
Out[105]:
age 1 18 25 35 45 50
genre
Action 3.506385 3.447097 3.453358 3.538107 3.528543 3.611333 \
Adventure 3.449975 3.408525 3.443163 3.515291 3.528963 3.628163
Animation 3.476113 3.624014 3.701228 3.740545 3.734856 3.780020
Children's 3.241642 3.294257 3.426873 3.518423 3.527593 3.556555
Comedy 3.497491 3.460417 3.490385 3.561984 3.591789 3.646868
Crime 3.710170 3.668054 3.680321 3.733736 3.750661 3.810688
Documentary 3.730769 3.865865 3.946690 3.953747 3.966521 3.908108
Drama 3.794735 3.721930 3.726428 3.782512 3.784356 3.878415
Fantasy 3.317647 3.353778 3.452484 3.482301 3.532468 3.581570
Film-Noir 4.145455 3.997368 4.058725 4.064910 4.105376 4.175401
age 56
genre
Action 3.610709
Adventure 3.649064
Animation 3.756233
Children's 3.621822
Comedy 3.650949
Crime 3.832549
Documentary 3.961538
Drama 3.933465
Fantasy 3.532700
Film-Noir 4.125932
13.3 美国婴儿姓名 1880-2010
美国社会保障管理局(SSA)提供了从 1880 年到现在的婴儿名字频率数据。Hadley Wickham,几个流行 R 包的作者,在 R 中说明数据操作时使用了这个数据集。
我们需要进行一些数据整理来加载这个数据集,但一旦我们这样做了,我们将得到一个看起来像这样的 DataFrame:
In [4]: names.head(10)
Out[4]:
name sex births year
0 Mary F 7065 1880
1 Anna F 2604 1880
2 Emma F 2003 1880
3 Elizabeth F 1939 1880
4 Minnie F 1746 1880
5 Margaret F 1578 1880
6 Ida F 1472 1880
7 Alice F 1414 1880
8 Bertha F 1320 1880
9 Sarah F 1288 1880
有许多事情你可能想要对数据集做:
-
可视化随着时间推移给定名字(您自己的名字或其他名字)的婴儿比例
-
确定一个名字的相对排名
-
确定每年最受欢迎的名字或受欢迎程度增长或下降最多的名字
-
分析名字的趋势:元音、辅音、长度、整体多样性、拼写变化、首尾字母
-
分析趋势的外部来源:圣经名字、名人、人口统计学
使用本书中的工具,许多这类分析都可以实现,所以我会带你走一些。
截至目前,美国社会保障管理局提供了数据文件,每年一个文件,其中包含每个性别/名字组合的总出生数。您可以下载这些文件的原始存档。
如果您在阅读此页面时发现已移动,很可能可以通过互联网搜索再次找到。下载“国家数据”文件names.zip并解压缩后,您将获得一个包含一系列文件如yob1880.txt的目录。我使用 Unix 的head命令查看其中一个文件的前 10 行(在 Windows 上,您可以使用more命令或在文本编辑器中打开):
In [106]: !head -n 10 datasets/babynames/yob1880.txt
Mary,F,7065
Anna,F,2604
Emma,F,2003
Elizabeth,F,1939
Minnie,F,1746
Margaret,F,1578
Ida,F,1472
Alice,F,1414
Bertha,F,1320
Sarah,F,1288
由于这已经是逗号分隔形式,可以使用pandas.read_csv将其加载到 DataFrame 中:
In [107]: names1880 = pd.read_csv("datasets/babynames/yob1880.txt",
.....: names=["name", "sex", "births"])
In [108]: names1880
Out[108]:
name sex births
0 Mary F 7065
1 Anna F 2604
2 Emma F 2003
3 Elizabeth F 1939
4 Minnie F 1746
... ... .. ...
1995 Woodie M 5
1996 Worthy M 5
1997 Wright M 5
1998 York M 5
1999 Zachariah M 5
[2000 rows x 3 columns]
这些文件只包含每年至少有五次出现的名字,所以为了简单起见,我们可以使用按性别的出生列的总和作为该年出生的总数:
In [109]: names1880.groupby("sex")["births"].sum()
Out[109]:
sex
F 90993
M 110493
Name: births, dtype: int64
由于数据集按年份分成文件,首先要做的事情之一是将所有数据组装到一个单独的 DataFrame 中,并进一步添加一个year字段。您可以使用pandas.concat来做到这一点。在 Jupyter 单元格中运行以下内容:
pieces = []
for year in range(1880, 2011):
path = f"datasets/babynames/yob{year}.txt"
frame = pd.read_csv(path, names=["name", "sex", "births"])
# Add a column for the year
frame["year"] = year
pieces.append(frame)
# Concatenate everything into a single DataFrame
names = pd.concat(pieces, ignore_index=True)
这里有几件事情需要注意。首先,记住concat默认按行组合 DataFrame 对象。其次,您必须传递ignore_index=True,因为我们不关心从pandas.read_csv返回的原始行号。因此,现在我们有一个包含所有年份的所有名字数据的单个 DataFrame:
In [111]: names
Out[111]:
name sex births year
0 Mary F 7065 1880
1 Anna F 2604 1880
2 Emma F 2003 1880
3 Elizabeth F 1939 1880
4 Minnie F 1746 1880
... ... .. ... ...
1690779 Zymaire M 5 2010
1690780 Zyonne M 5 2010
1690781 Zyquarius M 5 2010
1690782 Zyran M 5 2010
1690783 Zzyzx M 5 2010
[1690784 rows x 4 columns]
有了这些数据,我们可以开始使用groupby或pivot_table在年份和性别水平上对数据进行聚合(参见按性别和年份统计的总出生数):
In [112]: total_births = names.pivot_table("births", index="year",
.....: columns="sex", aggfunc=sum)
In [113]: total_births.tail()
Out[113]:
sex F M
year
2006 1896468 2050234
2007 1916888 2069242
2008 1883645 2032310
2009 1827643 1973359
2010 1759010 1898382
In [114]: total_births.plot(title="Total births by sex and year")

图 13.4:按性别和年份统计的总出生数
接下来,让我们插入一个名为prop的列,该列显示每个名字相对于总出生数的比例。prop值为0.02表示每 100 个婴儿中有 2 个被赋予特定的名字。因此,我们按年份和性别对数据进行分组,然后向每个组添加新列:
def add_prop(group):
group["prop"] = group["births"] / group["births"].sum()
return group
names = names.groupby(["year", "sex"], group_keys=False).apply(add_prop)
现在得到的完整数据集现在具有以下列:
In [116]: names
Out[116]:
name sex births year prop
0 Mary F 7065 1880 0.077643
1 Anna F 2604 1880 0.028618
2 Emma F 2003 1880 0.022013
3 Elizabeth F 1939 1880 0.021309
4 Minnie F 1746 1880 0.019188
... ... .. ... ... ...
1690779 Zymaire M 5 2010 0.000003
1690780 Zyonne M 5 2010 0.000003
1690781 Zyquarius M 5 2010 0.000003
1690782 Zyran M 5 2010 0.000003
1690783 Zzyzx M 5 2010 0.000003
[1690784 rows x 5 columns]
在执行这样的组操作时,通常很有价值进行一些合理性检查,比如验证所有组中prop列的总和是否为 1:
In [117]: names.groupby(["year", "sex"])["prop"].sum()
Out[117]:
year sex
1880 F 1.0
M 1.0
1881 F 1.0
M 1.0
1882 F 1.0
...
2008 M 1.0
2009 F 1.0
M 1.0
2010 F 1.0
M 1.0
Name: prop, Length: 262, dtype: float64
现在这样做了,我将提取数据的一个子集以便进一步分析:每个性别/年份组合的前 1000 个名字。这是另一个组操作:
In [118]: def get_top1000(group):
.....: return group.sort_values("births", ascending=False)[:1000]
In [119]: grouped = names.groupby(["year", "sex"])
In [120]: top1000 = grouped.apply(get_top1000)
In [121]: top1000.head()
Out[121]:
name sex births year prop
year sex
1880 F 0 Mary F 7065 1880 0.077643
1 Anna F 2604 1880 0.028618
2 Emma F 2003 1880 0.022013
3 Elizabeth F 1939 1880 0.021309
4 Minnie F 1746 1880 0.019188
我们可以删除组索引,因为我们不需要它进行分析:
In [122]: top1000 = top1000.reset_index(drop=True)
现在得到的数据集要小得多:
In [123]: top1000.head()
Out[123]:
name sex births year prop
0 Mary F 7065 1880 0.077643
1 Anna F 2604 1880 0.028618
2 Emma F 2003 1880 0.022013
3 Elizabeth F 1939 1880 0.021309
4 Minnie F 1746 1880 0.019188
我们将在接下来的数据调查中使用这个前一千个数据集。
分析命名趋势
有了完整的数据集和前一千个数据集,我们可以开始分析各种有趣的命名趋势。首先,我们可以将前一千个名字分为男孩和女孩部分:
In [124]: boys = top1000[top1000["sex"] == "M"]
In [125]: girls = top1000[top1000["sex"] == "F"]
简单的时间序列,比如每年约翰或玛丽的数量,可以绘制,但需要一些操作才能更有用。让我们形成一个按年份和姓名总数的数据透视表:
In [126]: total_births = top1000.pivot_table("births", index="year",
.....: columns="name",
.....: aggfunc=sum)
现在,可以使用 DataFrame 的plot方法为一些名字绘制图表(一些男孩和女孩名字随时间变化显示了结果):
In [127]: total_births.info()
<class 'pandas.core.frame.DataFrame'>
Index: 131 entries, 1880 to 2010
Columns: 6868 entries, Aaden to Zuri
dtypes: float64(6868)
memory usage: 6.9 MB
In [128]: subset = total_births[["John", "Harry", "Mary", "Marilyn"]]
In [129]: subset.plot(subplots=True, figsize=(12, 10),
.....: title="Number of births per year")

图 13.5:一些男孩和女孩名字随时间变化
看到这个,你可能会得出结论,这些名字已经不再受到美国人口的青睐。但事实实际上比这更复杂,将在下一节中探讨。
衡量命名多样性的增加
减少图表的原因之一是越来越少的父母选择常见的名字给他们的孩子。这个假设可以在数据中进行探索和确认。一个度量是由前 1000 个最受欢迎的名字代表的出生比例,我按年份和性别进行汇总和绘制(性别在前一千个名字中所代表的出生比例显示了结果图):
In [131]: table = top1000.pivot_table("prop", index="year",
.....: columns="sex", aggfunc=sum)
In [132]: table.plot(title="Sum of table1000.prop by year and sex",
.....: yticks=np.linspace(0, 1.2, 13))

图 13.6:性别在前一千个名字中所代表的出生比例
您可以看到,确实存在着越来越多的名字多样性(前一千名中总比例减少)。另一个有趣的指标是在出生的前 50%中按照从高到低的流行度顺序取的不同名字的数量。这个数字更难计算。让我们只考虑 2010 年的男孩名字:
In [133]: df = boys[boys["year"] == 2010]
In [134]: df
Out[134]:
name sex births year prop
260877 Jacob M 21875 2010 0.011523
260878 Ethan M 17866 2010 0.009411
260879 Michael M 17133 2010 0.009025
260880 Jayden M 17030 2010 0.008971
260881 William M 16870 2010 0.008887
... ... .. ... ... ...
261872 Camilo M 194 2010 0.000102
261873 Destin M 194 2010 0.000102
261874 Jaquan M 194 2010 0.000102
261875 Jaydan M 194 2010 0.000102
261876 Maxton M 193 2010 0.000102
[1000 rows x 5 columns]
在对prop进行降序排序后,我们想知道最受欢迎的名字中有多少个名字达到了 50%。您可以编写一个for循环来执行此操作,但使用矢量化的 NumPy 方法更具计算效率。对prop进行累积求和cumsum,然后调用searchsorted方法返回0.5需要插入的累积和位置,以保持其按顺序排序:
In [135]: prop_cumsum = df["prop"].sort_values(ascending=False).cumsum()
In [136]: prop_cumsum[:10]
Out[136]:
260877 0.011523
260878 0.020934
260879 0.029959
260880 0.038930
260881 0.047817
260882 0.056579
260883 0.065155
260884 0.073414
260885 0.081528
260886 0.089621
Name: prop, dtype: float64
In [137]: prop_cumsum.searchsorted(0.5)
Out[137]: 116
由于数组是从零开始索引的,将此结果加 1 将得到 117 的结果。相比之下,在 1900 年,这个数字要小得多:
In [138]: df = boys[boys.year == 1900]
In [139]: in1900 = df.sort_values("prop", ascending=False).prop.cumsum()
In [140]: in1900.searchsorted(0.5) + 1
Out[140]: 25
现在,您可以将此操作应用于每个年份/性别组合,对这些字段进行groupby,并apply一个返回每个组计数的函数:
def get_quantile_count(group, q=0.5):
group = group.sort_values("prop", ascending=False)
return group.prop.cumsum().searchsorted(q) + 1
diversity = top1000.groupby(["year", "sex"]).apply(get_quantile_count)
diversity = diversity.unstack()
这个结果 DataFrame diversity 现在有两个时间序列,一个用于每个性别,按年份索引。这可以像以前一样进行检查和绘制(参见按年份绘制的多样性指标):
In [143]: diversity.head()
Out[143]:
sex F M
year
1880 38 14
1881 38 14
1882 38 15
1883 39 15
1884 39 16
In [144]: diversity.plot(title="Number of popular names in top 50%")

图 13.7:按年份绘制的多样性指标
正如你所看到的,女孩名字一直比男孩名字更多样化,而且随着时间的推移,它们变得更加多样化。关于到底是什么推动了这种多样性的进一步分析,比如替代拼写的增加,留给读者自行探讨。
“最后一个字母”革命
在 2007 年,婴儿姓名研究员劳拉·瓦滕伯格指出,过去 100 年来,以最后一个字母结尾的男孩名字的分布发生了显著变化。为了看到这一点,我们首先按年份、性别和最后一个字母聚合完整数据集中的所有出生情况:
def get_last_letter(x):
return x[-1]
last_letters = names["name"].map(get_last_letter)
last_letters.name = "last_letter"
table = names.pivot_table("births", index=last_letters,
columns=["sex", "year"], aggfunc=sum)
然后我们选择三个代表性年份跨越历史,并打印前几行:
In [146]: subtable = table.reindex(columns=[1910, 1960, 2010], level="year")
In [147]: subtable.head()
Out[147]:
sex F M
year 1910 1960 2010 1910 1960 2010
last_letter
a 108376.0 691247.0 670605.0 977.0 5204.0 28438.0
b NaN 694.0 450.0 411.0 3912.0 38859.0
c 5.0 49.0 946.0 482.0 15476.0 23125.0
d 6750.0 3729.0 2607.0 22111.0 262112.0 44398.0
e 133569.0 435013.0 313833.0 28655.0 178823.0 129012.0
接下来,通过总出生数对表进行标准化,计算一个包含每个性别以每个字母结尾的总出生比例的新表:
In [148]: subtable.sum()
Out[148]:
sex year
F 1910 396416.0
1960 2022062.0
2010 1759010.0
M 1910 194198.0
1960 2132588.0
2010 1898382.0
dtype: float64
In [149]: letter_prop = subtable / subtable.sum()
In [150]: letter_prop
Out[150]:
sex F M
year 1910 1960 2010 1910 1960 2010
last_letter
a 0.273390 0.341853 0.381240 0.005031 0.002440 0.014980
b NaN 0.000343 0.000256 0.002116 0.001834 0.020470
c 0.000013 0.000024 0.000538 0.002482 0.007257 0.012181
d 0.017028 0.001844 0.001482 0.113858 0.122908 0.023387
e 0.336941 0.215133 0.178415 0.147556 0.083853 0.067959
... ... ... ... ... ... ...
v NaN 0.000060 0.000117 0.000113 0.000037 0.001434
w 0.000020 0.000031 0.001182 0.006329 0.007711 0.016148
x 0.000015 0.000037 0.000727 0.003965 0.001851 0.008614
y 0.110972 0.152569 0.116828 0.077349 0.160987 0.058168
z 0.002439 0.000659 0.000704 0.000170 0.000184 0.001831
[26 rows x 6 columns]
现在有了字母比例,我们可以按年份将每个性别分解为条形图(参见以每个字母结尾的男孩和女孩名字的比例):
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 1, figsize=(10, 8))
letter_prop["M"].plot(kind="bar", rot=0, ax=axes[0], title="Male")
letter_prop["F"].plot(kind="bar", rot=0, ax=axes[1], title="Female",
legend=False)

图 13.8:以每个字母结尾的男孩和女孩名字的比例
正如您所看到的,自 20 世纪 60 年代以来,以n结尾的男孩名字经历了显著增长。回到之前创建的完整表格,再次按年份和性别进行标准化,并选择男孩名字的一部分字母,最后转置使每一列成为一个时间序列:
In [153]: letter_prop = table / table.sum()
In [154]: dny_ts = letter_prop.loc[["d", "n", "y"], "M"].T
In [155]: dny_ts.head()
Out[155]:
last_letter d n y
year
1880 0.083055 0.153213 0.075760
1881 0.083247 0.153214 0.077451
1882 0.085340 0.149560 0.077537
1883 0.084066 0.151646 0.079144
1884 0.086120 0.149915 0.080405
有了这个时间序列的 DataFrame,我可以再次使用其plot方法制作时间趋势图(请参见随时间变化以 d/n/y 结尾的男孩出生比例):
In [158]: dny_ts.plot()

图 13.9:随时间变化以 d/n/y 结尾的男孩出生比例
男孩名字变成女孩名字(反之亦然)
另一个有趣的趋势是查看在样本早期更受一性别欢迎,但随着时间推移已成为另一性别的首选名字的名字。一个例子是 Lesley 或 Leslie 这个名字。回到top1000 DataFrame,我计算出数据集中以"Lesl"开头的名字列表:
In [159]: all_names = pd.Series(top1000["name"].unique())
In [160]: lesley_like = all_names[all_names.str.contains("Lesl")]
In [161]: lesley_like
Out[161]:
632 Leslie
2294 Lesley
4262 Leslee
4728 Lesli
6103 Lesly
dtype: object
然后,我们可以筛选出那些名字,按名字分组对出生进行求和,以查看相对频率:
In [162]: filtered = top1000[top1000["name"].isin(lesley_like)]
In [163]: filtered.groupby("name")["births"].sum()
Out[163]:
name
Leslee 1082
Lesley 35022
Lesli 929
Leslie 370429
Lesly 10067
Name: births, dtype: int64
接下来,让我们按性别和年份进行聚合,并在年份内进行归一化:
In [164]: table = filtered.pivot_table("births", index="year",
.....: columns="sex", aggfunc="sum")
In [165]: table = table.div(table.sum(axis="columns"), axis="index")
In [166]: table.tail()
Out[166]:
sex F M
year
2006 1.0 NaN
2007 1.0 NaN
2008 1.0 NaN
2009 1.0 NaN
2010 1.0 NaN
最后,现在可以制作按性别随时间变化的分布图(请参见随时间变化男/女 Lesley 样式名字的比例):
In [168]: table.plot(style={"M": "k-", "F": "k--"})

图 13.10:随时间变化男/女 Lesley 样式名字的比例
13.4 USDA 食品数据库
美国农业部(USDA)提供了一个食品营养信息数据库。程序员 Ashley Williams 以 JSON 格式创建了这个数据库的一个版本。记录看起来像这样:
{
"id": 21441,
"description": "KENTUCKY FRIED CHICKEN, Fried Chicken, EXTRA CRISPY,
Wing, meat and skin with breading",
"tags": ["KFC"],
"manufacturer": "Kentucky Fried Chicken",
"group": "Fast Foods",
"portions": [
{
"amount": 1,
"unit": "wing, with skin",
"grams": 68.0
},
...
],
"nutrients": [
{
"value": 20.8,
"units": "g",
"description": "Protein",
"group": "Composition"
},
...
]
}
每种食物都有一些标识属性,还有两个营养素和分量大小的列表。这种形式的数据不太适合分析,因此我们需要做一些工作,将数据整理成更好的形式。
您可以使用您选择的任何 JSON 库将此文件加载到 Python 中。我将使用内置的 Python json模块:
In [169]: import json
In [170]: db = json.load(open("datasets/usda_food/database.json"))
In [171]: len(db)
Out[171]: 6636
db中的每个条目都是一个包含单个食物所有数据的字典。"nutrients"字段是一个字典列表,每个营养素一个:
In [172]: db[0].keys()
Out[172]: dict_keys(['id', 'description', 'tags', 'manufacturer', 'group', 'porti
ons', 'nutrients'])
In [173]: db[0]["nutrients"][0]
Out[173]:
{'value': 25.18,
'units': 'g',
'description': 'Protein',
'group': 'Composition'}
In [174]: nutrients = pd.DataFrame(db[0]["nutrients"])
In [175]: nutrients.head(7)
Out[175]:
value units description group
0 25.18 g Protein Composition
1 29.20 g Total lipid (fat) Composition
2 3.06 g Carbohydrate, by difference Composition
3 3.28 g Ash Other
4 376.00 kcal Energy Energy
5 39.28 g Water Composition
6 1573.00 kJ Energy Energy
将字典列表转换为 DataFrame 时,我们可以指定要提取的字段列表。我们将提取食物名称、组、ID 和制造商:
In [176]: info_keys = ["description", "group", "id", "manufacturer"]
In [177]: info = pd.DataFrame(db, columns=info_keys)
In [178]: info.head()
Out[178]:
description group id
0 Cheese, caraway Dairy and Egg Products 1008 \
1 Cheese, cheddar Dairy and Egg Products 1009
2 Cheese, edam Dairy and Egg Products 1018
3 Cheese, feta Dairy and Egg Products 1019
4 Cheese, mozzarella, part skim milk Dairy and Egg Products 1028
manufacturer
0
1
2
3
4
In [179]: info.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 6636 entries, 0 to 6635
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 description 6636 non-null object
1 group 6636 non-null object
2 id 6636 non-null int64
3 manufacturer 5195 non-null object
dtypes: int64(1), object(3)
memory usage: 207.5+ KB
从info.info()的输出中,我们可以看到manufacturer列中有缺失数据。
您可以使用value_counts查看食物组的分布:
In [180]: pd.value_counts(info["group"])[:10]
Out[180]:
group
Vegetables and Vegetable Products 812
Beef Products 618
Baked Products 496
Breakfast Cereals 403
Legumes and Legume Products 365
Fast Foods 365
Lamb, Veal, and Game Products 345
Sweets 341
Fruits and Fruit Juices 328
Pork Products 328
Name: count, dtype: int64
现在,要对所有营养数据进行一些分析,最简单的方法是将每种食物的营养成分组装成一个单独的大表格。为此,我们需要采取几个步骤。首先,我将把每个食物营养列表转换为一个 DataFrame,添加一个食物id的列,并将 DataFrame 附加到列表中。然后,可以使用concat将它们连接起来。在 Jupyter 单元格中运行以下代码:
nutrients = []
for rec in db:
fnuts = pd.DataFrame(rec["nutrients"])
fnuts["id"] = rec["id"]
nutrients.append(fnuts)
nutrients = pd.concat(nutrients, ignore_index=True)
如果一切顺利,nutrients应该是这样的:
In [182]: nutrients
Out[182]:
value units description group id
0 25.180 g Protein Composition 1008
1 29.200 g Total lipid (fat) Composition 1008
2 3.060 g Carbohydrate, by difference Composition 1008
3 3.280 g Ash Other 1008
4 376.000 kcal Energy Energy 1008
... ... ... ... ... ...
389350 0.000 mcg Vitamin B-12, added Vitamins 43546
389351 0.000 mg Cholesterol Other 43546
389352 0.072 g Fatty acids, total saturated Other 43546
389353 0.028 g Fatty acids, total monounsaturated Other 43546
389354 0.041 g Fatty acids, total polyunsaturated Other 43546
[389355 rows x 5 columns]
我注意到这个 DataFrame 中有重复项,所以删除它们会更容易:
In [183]: nutrients.duplicated().sum() # number of duplicates
Out[183]: 14179
In [184]: nutrients = nutrients.drop_duplicates()
由于 DataFrame 对象中都有"group"和"description",我们可以重命名以便更清晰:
In [185]: col_mapping = {"description" : "food",
.....: "group" : "fgroup"}
In [186]: info = info.rename(columns=col_mapping, copy=False)
In [187]: info.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 6636 entries, 0 to 6635
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 food 6636 non-null object
1 fgroup 6636 non-null object
2 id 6636 non-null int64
3 manufacturer 5195 non-null object
dtypes: int64(1), object(3)
memory usage: 207.5+ KB
In [188]: col_mapping = {"description" : "nutrient",
.....: "group" : "nutgroup"}
In [189]: nutrients = nutrients.rename(columns=col_mapping, copy=False)
In [190]: nutrients
Out[190]:
value units nutrient nutgroup id
0 25.180 g Protein Composition 1008
1 29.200 g Total lipid (fat) Composition 1008
2 3.060 g Carbohydrate, by difference Composition 1008
3 3.280 g Ash Other 1008
4 376.000 kcal Energy Energy 1008
... ... ... ... ... ...
389350 0.000 mcg Vitamin B-12, added Vitamins 43546
389351 0.000 mg Cholesterol Other 43546
389352 0.072 g Fatty acids, total saturated Other 43546
389353 0.028 g Fatty acids, total monounsaturated Other 43546
389354 0.041 g Fatty acids, total polyunsaturated Other 43546
[375176 rows x 5 columns]
完成所有这些后,我们准备将info与nutrients合并:
In [191]: ndata = pd.merge(nutrients, info, on="id")
In [192]: ndata.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 375176 entries, 0 to 375175
Data columns (total 8 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 value 375176 non-null float64
1 units 375176 non-null object
2 nutrient 375176 non-null object
3 nutgroup 375176 non-null object
4 id 375176 non-null int64
5 food 375176 non-null object
6 fgroup 375176 non-null object
7 manufacturer 293054 non-null object
dtypes: float64(1), int64(1), object(6)
memory usage: 22.9+ MB
In [193]: ndata.iloc[30000]
Out[193]:
value 0.04
units g
nutrient Glycine
nutgroup Amino Acids
id 6158
food Soup, tomato bisque, canned, condensed
fgroup Soups, Sauces, and Gravies
manufacturer
Name: 30000, dtype: object
现在我们可以制作按食物组和营养类型中位数值的图表(请参见各食物组的锌中位数值):
In [195]: result = ndata.groupby(["nutrient", "fgroup"])["value"].quantile(0.5)
In [196]: result["Zinc, Zn"].sort_values().plot(kind="barh")

图 13.11:各食物组的锌中位数值
使用idxmax或argmax Series 方法,您可以找到每种营养素中最密集的食物。在 Jupyter 单元格中运行以下内容:
by_nutrient = ndata.groupby(["nutgroup", "nutrient"])
def get_maximum(x):
return x.loc[x.value.idxmax()]
max_foods = by_nutrient.apply(get_maximum)[["value", "food"]]
# make the food a little smaller
max_foods["food"] = max_foods["food"].str[:50]
生成的 DataFrame 太大,无法在书中显示;这里只有"Amino Acids"营养组:
In [198]: max_foods.loc["Amino Acids"]["food"]
Out[198]:
nutrient
Alanine Gelatins, dry powder, unsweetened
Arginine Seeds, sesame flour, low-fat
Aspartic acid Soy protein isolate
Cystine Seeds, cottonseed flour, low fat (glandless)
Glutamic acid Soy protein isolate
Glycine Gelatins, dry powder, unsweetened
Histidine Whale, beluga, meat, dried (Alaska Native)
Hydroxyproline KENTUCKY FRIED CHICKEN, Fried Chicken, ORIGINAL RE
Isoleucine Soy protein isolate, PROTEIN TECHNOLOGIES INTERNAT
Leucine Soy protein isolate, PROTEIN TECHNOLOGIES INTERNAT
Lysine Seal, bearded (Oogruk), meat, dried (Alaska Native
Methionine Fish, cod, Atlantic, dried and salted
Phenylalanine Soy protein isolate, PROTEIN TECHNOLOGIES INTERNAT
Proline Gelatins, dry powder, unsweetened
Serine Soy protein isolate, PROTEIN TECHNOLOGIES INTERNAT
Threonine Soy protein isolate, PROTEIN TECHNOLOGIES INTERNAT
Tryptophan Sea lion, Steller, meat with fat (Alaska Native)
Tyrosine Soy protein isolate, PROTEIN TECHNOLOGIES INTERNAT
Valine Soy protein isolate, PROTEIN TECHNOLOGIES INTERNAT
Name: food, dtype: object
13.5 2012 年联邦选举委员会数据库
美国联邦选举委员会(FEC)发布了有关政治竞选捐款的数据。这包括捐助者姓名、职业和雇主、地址以及捐款金额。2012 年美国总统选举的捐款数据作为一个 150 兆字节的 CSV 文件P00000001-ALL.csv可用(请参阅本书的数据存储库),可以使用pandas.read_csv加载:
In [199]: fec = pd.read_csv("datasets/fec/P00000001-ALL.csv", low_memory=False)
In [200]: fec.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1001731 entries, 0 to 1001730
Data columns (total 16 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 cmte_id 1001731 non-null object
1 cand_id 1001731 non-null object
2 cand_nm 1001731 non-null object
3 contbr_nm 1001731 non-null object
4 contbr_city 1001712 non-null object
5 contbr_st 1001727 non-null object
6 contbr_zip 1001620 non-null object
7 contbr_employer 988002 non-null object
8 contbr_occupation 993301 non-null object
9 contb_receipt_amt 1001731 non-null float64
10 contb_receipt_dt 1001731 non-null object
11 receipt_desc 14166 non-null object
12 memo_cd 92482 non-null object
13 memo_text 97770 non-null object
14 form_tp 1001731 non-null object
15 file_num 1001731 non-null int64
dtypes: float64(1), int64(1), object(14)
memory usage: 122.3+ MB
注意:
有几个人要求我将数据集从 2012 年选举更新到 2016 年或 2020 年选举。不幸的是,联邦选举委员会提供的最新数据集变得更大更复杂,我决定在这里使用它们会分散我想要说明的分析技术。
数据框中的一个示例记录如下:
In [201]: fec.iloc[123456]
Out[201]:
cmte_id C00431445
cand_id P80003338
cand_nm Obama, Barack
contbr_nm ELLMAN, IRA
contbr_city TEMPE
contbr_st AZ
contbr_zip 852816719
contbr_employer ARIZONA STATE UNIVERSITY
contbr_occupation PROFESSOR
contb_receipt_amt 50.0
contb_receipt_dt 01-DEC-11
receipt_desc NaN
memo_cd NaN
memo_text NaN
form_tp SA17A
file_num 772372
Name: 123456, dtype: object
您可能会想到一些方法来开始切片和切块这些数据,以提取有关捐赠者和竞选捐款模式的信息统计。我将展示一些应用本书中技术的不同分析方法。
您会发现数据中没有政党隶属关系,因此添加这些信息会很有用。您可以使用unique获取所有唯一的政治候选人列表:
In [202]: unique_cands = fec["cand_nm"].unique()
In [203]: unique_cands
Out[203]:
array(['Bachmann, Michelle', 'Romney, Mitt', 'Obama, Barack',
"Roemer, Charles E. 'Buddy' III", 'Pawlenty, Timothy',
'Johnson, Gary Earl', 'Paul, Ron', 'Santorum, Rick',
'Cain, Herman', 'Gingrich, Newt', 'McCotter, Thaddeus G',
'Huntsman, Jon', 'Perry, Rick'], dtype=object)
In [204]: unique_cands[2]
Out[204]: 'Obama, Barack'
表示政党隶属关系的一种方法是使用字典:¹
parties = {"Bachmann, Michelle": "Republican",
"Cain, Herman": "Republican",
"Gingrich, Newt": "Republican",
"Huntsman, Jon": "Republican",
"Johnson, Gary Earl": "Republican",
"McCotter, Thaddeus G": "Republican",
"Obama, Barack": "Democrat",
"Paul, Ron": "Republican",
"Pawlenty, Timothy": "Republican",
"Perry, Rick": "Republican",
"Roemer, Charles E. 'Buddy' III": "Republican",
"Romney, Mitt": "Republican",
"Santorum, Rick": "Republican"}
现在,使用这个映射和 Series 对象上的map方法,您可以从候选人姓名计算一个政党数组:
In [206]: fec["cand_nm"][123456:123461]
Out[206]:
123456 Obama, Barack
123457 Obama, Barack
123458 Obama, Barack
123459 Obama, Barack
123460 Obama, Barack
Name: cand_nm, dtype: object
In [207]: fec["cand_nm"][123456:123461].map(parties)
Out[207]:
123456 Democrat
123457 Democrat
123458 Democrat
123459 Democrat
123460 Democrat
Name: cand_nm, dtype: object
# Add it as a column
In [208]: fec["party"] = fec["cand_nm"].map(parties)
In [209]: fec["party"].value_counts()
Out[209]:
party
Democrat 593746
Republican 407985
Name: count, dtype: int64
一些数据准备要点。首先,这些数据包括捐款和退款(负捐款金额):
In [210]: (fec["contb_receipt_amt"] > 0).value_counts()
Out[210]:
contb_receipt_amt
True 991475
False 10256
Name: count, dtype: int64
为简化分析,我将限制数据集为正捐款:
In [211]: fec = fec[fec["contb_receipt_amt"] > 0]
由于巴拉克·奥巴马和米特·罗姆尼是主要的两位候选人,我还将准备一个只包含对他们竞选活动的捐款的子集:
In [212]: fec_mrbo = fec[fec["cand_nm"].isin(["Obama, Barack", "Romney, Mitt"])]
按职业和雇主的捐款统计
按职业捐款是另一个经常研究的统计数据。例如,律师倾向于向民主党捐款更多,而商业高管倾向于向共和党捐款更多。您没有理由相信我;您可以在数据中自己看到。首先,可以使用value_counts计算每个职业的总捐款数:
In [213]: fec["contbr_occupation"].value_counts()[:10]
Out[213]:
contbr_occupation
RETIRED 233990
INFORMATION REQUESTED 35107
ATTORNEY 34286
HOMEMAKER 29931
PHYSICIAN 23432
INFORMATION REQUESTED PER BEST EFFORTS 21138
ENGINEER 14334
TEACHER 13990
CONSULTANT 13273
PROFESSOR 12555
Name: count, dtype: int64
通过查看职业,您会注意到许多职业都指的是相同的基本工作类型,或者有几种相同事物的变体。以下代码片段演示了一种通过从一个职业映射到另一个职业来清理其中一些职业的技术;请注意使用dict.get的“技巧”,以允许没有映射的职业“通过”:
occ_mapping = {
"INFORMATION REQUESTED PER BEST EFFORTS" : "NOT PROVIDED",
"INFORMATION REQUESTED" : "NOT PROVIDED",
"INFORMATION REQUESTED (BEST EFFORTS)" : "NOT PROVIDED",
"C.E.O.": "CEO"
}
def get_occ(x):
# If no mapping provided, return x
return occ_mapping.get(x, x)
fec["contbr_occupation"] = fec["contbr_occupation"].map(get_occ)
我也会为雇主做同样的事情:
emp_mapping = {
"INFORMATION REQUESTED PER BEST EFFORTS" : "NOT PROVIDED",
"INFORMATION REQUESTED" : "NOT PROVIDED",
"SELF" : "SELF-EMPLOYED",
"SELF EMPLOYED" : "SELF-EMPLOYED",
}
def get_emp(x):
# If no mapping provided, return x
return emp_mapping.get(x, x)
fec["contbr_employer"] = fec["contbr_employer"].map(get_emp)
现在,您可以使用pivot_table按政党和职业对数据进行聚合,然后筛选出总捐款至少为 200 万美元的子集:
In [216]: by_occupation = fec.pivot_table("contb_receipt_amt",
.....: index="contbr_occupation",
.....: columns="party", aggfunc="sum")
In [217]: over_2mm = by_occupation[by_occupation.sum(axis="columns") > 2000000]
In [218]: over_2mm
Out[218]:
party Democrat Republican
contbr_occupation
ATTORNEY 11141982.97 7477194.43
CEO 2074974.79 4211040.52
CONSULTANT 2459912.71 2544725.45
ENGINEER 951525.55 1818373.70
EXECUTIVE 1355161.05 4138850.09
HOMEMAKER 4248875.80 13634275.78
INVESTOR 884133.00 2431768.92
LAWYER 3160478.87 391224.32
MANAGER 762883.22 1444532.37
NOT PROVIDED 4866973.96 20565473.01
OWNER 1001567.36 2408286.92
PHYSICIAN 3735124.94 3594320.24
PRESIDENT 1878509.95 4720923.76
PROFESSOR 2165071.08 296702.73
REAL ESTATE 528902.09 1625902.25
RETIRED 25305116.38 23561244.49
SELF-EMPLOYED 672393.40 1640252.54
这些数据以条形图形式更容易查看("barh"表示水平条形图;请参见按职业和政党分组的总捐款):
In [220]: over_2mm.plot(kind="barh")

图 13.12:按职业分组的政党总捐款
您可能对捐赠最多的职业或向奥巴马和罗姆尼捐款最多的公司感兴趣。为此,您可以按候选人姓名分组,并使用本章早期的top方法的变体:
def get_top_amounts(group, key, n=5):
totals = group.groupby(key)["contb_receipt_amt"].sum()
return totals.nlargest(n)
然后按职业和雇主进行汇总:
In [222]: grouped = fec_mrbo.groupby("cand_nm")
In [223]: grouped.apply(get_top_amounts, "contbr_occupation", n=7)
Out[223]:
cand_nm contbr_occupation
Obama, Barack RETIRED 25305116.38
ATTORNEY 11141982.97
INFORMATION REQUESTED 4866973.96
HOMEMAKER 4248875.80
PHYSICIAN 3735124.94
LAWYER 3160478.87
CONSULTANT 2459912.71
Romney, Mitt RETIRED 11508473.59
INFORMATION REQUESTED PER BEST EFFORTS 11396894.84
HOMEMAKER 8147446.22
ATTORNEY 5364718.82
PRESIDENT 2491244.89
EXECUTIVE 2300947.03
C.E.O. 1968386.11
Name: contb_receipt_amt, dtype: float64
In [224]: grouped.apply(get_top_amounts, "contbr_employer", n=10)
Out[224]:
cand_nm contbr_employer
Obama, Barack RETIRED 22694358.85
SELF-EMPLOYED 17080985.96
NOT EMPLOYED 8586308.70
INFORMATION REQUESTED 5053480.37
HOMEMAKER 2605408.54
SELF 1076531.20
SELF EMPLOYED 469290.00
STUDENT 318831.45
VOLUNTEER 257104.00
MICROSOFT 215585.36
Romney, Mitt INFORMATION REQUESTED PER BEST EFFORTS 12059527.24
RETIRED 11506225.71
HOMEMAKER 8147196.22
SELF-EMPLOYED 7409860.98
STUDENT 496490.94
CREDIT SUISSE 281150.00
MORGAN STANLEY 267266.00
GOLDMAN SACH & CO. 238250.00
BARCLAYS CAPITAL 162750.00
H.I.G. CAPITAL 139500.00
Name: contb_receipt_amt, dtype: float64
将捐款金额分桶
分析这些数据的一个有用方法是使用cut函数将捐助金额分成不同的桶:
In [225]: bins = np.array([0, 1, 10, 100, 1000, 10000,
.....: 100_000, 1_000_000, 10_000_000])
In [226]: labels = pd.cut(fec_mrbo["contb_receipt_amt"], bins)
In [227]: labels
Out[227]:
411 (10, 100]
412 (100, 1000]
413 (100, 1000]
414 (10, 100]
415 (10, 100]
...
701381 (10, 100]
701382 (100, 1000]
701383 (1, 10]
701384 (10, 100]
701385 (100, 1000]
Name: contb_receipt_amt, Length: 694282, dtype: category
Categories (8, interval[int64, right]): [(0, 1] < (1, 10] < (10, 100] < (100, 100
0] <
(1000, 10000] < (10000, 100000] < (10000
0, 1000000] <
(1000000, 10000000]]
然后,我们可以按姓名和 bin 标签对 Obama 和 Romney 的数据进行分组,以获得按捐款大小分组的直方图:
In [228]: grouped = fec_mrbo.groupby(["cand_nm", labels])
In [229]: grouped.size().unstack(level=0)
Out[229]:
cand_nm Obama, Barack Romney, Mitt
contb_receipt_amt
(0, 1] 493 77
(1, 10] 40070 3681
(10, 100] 372280 31853
(100, 1000] 153991 43357
(1000, 10000] 22284 26186
(10000, 100000] 2 1
(100000, 1000000] 3 0
(1000000, 10000000] 4 0
这些数据显示,奥巴马收到的小额捐款数量明显多于罗姆尼。您还可以对捐款金额进行求和,并在桶内进行归一化,以可视化每个候选人每个大小的总捐款的百分比(每个捐款大小收到的候选人总捐款的百分比显示了结果图):
In [231]: bucket_sums = grouped["contb_receipt_amt"].sum().unstack(level=0)
In [232]: normed_sums = bucket_sums.div(bucket_sums.sum(axis="columns"),
.....: axis="index")
In [233]: normed_sums
Out[233]:
cand_nm Obama, Barack Romney, Mitt
contb_receipt_amt
(0, 1] 0.805182 0.194818
(1, 10] 0.918767 0.081233
(10, 100] 0.910769 0.089231
(100, 1000] 0.710176 0.289824
(1000, 10000] 0.447326 0.552674
(10000, 100000] 0.823120 0.176880
(100000, 1000000] 1.000000 0.000000
(1000000, 10000000] 1.000000 0.000000
In [234]: normed_sums[:-2].plot(kind="barh")

图 13.13:每个捐款大小收到的候选人总捐款的百分比
我排除了两个最大的桶,因为这些不是个人捐款。
这种分析可以以许多方式进行细化和改进。例如,您可以按捐赠人姓名和邮政编码对捐款进行汇总,以调整给出许多小额捐款与一笔或多笔大额捐款的捐赠者。我鼓励您自己探索数据集。
按州的捐款统计
我们可以通过候选人和州对数据进行汇总:
In [235]: grouped = fec_mrbo.groupby(["cand_nm", "contbr_st"])
In [236]: totals = grouped["contb_receipt_amt"].sum().unstack(level=0).fillna(0)
In [237]: totals = totals[totals.sum(axis="columns") > 100000]
In [238]: totals.head(10)
Out[238]:
cand_nm Obama, Barack Romney, Mitt
contbr_st
AK 281840.15 86204.24
AL 543123.48 527303.51
AR 359247.28 105556.00
AZ 1506476.98 1888436.23
CA 23824984.24 11237636.60
CO 2132429.49 1506714.12
CT 2068291.26 3499475.45
DC 4373538.80 1025137.50
DE 336669.14 82712.00
FL 7318178.58 8338458.81
如果您将每一行都除以总捐款金额,您将得到每位候选人每个州的总捐款相对百分比:
13.6 结论
在这本书第一版出版以来的 10 年里,Python 已经成为数据分析中流行和广泛使用的语言。您在这里所学习的编程技能将在未来很长一段时间内保持相关性。希望我们探讨过的编程工具和库能够为您提供帮助。
我们已经到达了这本书的结尾。我在附录中包含了一些您可能会发现有用的额外内容。
- 这做出了一个简化的假设,即 Gary Johnson 是共和党人,尽管后来成为了自由党候选人。
附录
附录 A:高级 NumPy
原文:
wesmckinney.com/book/advanced-numpy译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
在这个附录中,我将深入探讨 NumPy 库的数组计算。这将包括有关 ndarray 类型的更多内部细节以及更高级的数组操作和算法。
这个附录包含各种主题,不一定需要按顺序阅读。在各章节中,我将为许多示例生成随机数据,这些示例将使用numpy.random模块中的默认随机数生成器:
In [11]: rng = np.random.default_rng(seed=12345)
A.1 ndarray 对象内部
NumPy ndarray 提供了一种将块状同类型数据(连续或分步)解释为多维数组对象的方法。数据类型,或dtype,决定了数据被解释为浮点数、整数、布尔值或我们一直在查看的其他类型之一。
ndarray 灵活的部分之一是每个数组对象都是对数据块的步进视图。例如,您可能想知道,例如,数组视图arr[::2, ::-1]如何不复制任何数据。原因是 ndarray 不仅仅是一块内存和一个数据类型;它还具有步进信息,使数组能够以不同的步长在内存中移动。更准确地说,ndarray 内部包含以下内容:
-
一个数据指针—即 RAM 中的数据块或内存映射文件
-
描述数组中固定大小值单元的数据类型或 dtype
-
一个指示数组形状的元组
-
一个步长元组—表示在一个维度上前进一个元素所需的字节数
请参见图 A.1 以查看 ndarray 内部的简单模拟。

图 A.1:NumPy ndarray 对象
例如,一个 10×5 的数组将具有形状(10, 5):
In [12]: np.ones((10, 5)).shape
Out[12]: (10, 5)
一个典型的(C 顺序)3×4×5 的float64(8 字节)值数组具有步长(160, 40, 8)(了解步长可以是有用的,因为一般来说,特定轴上的步长越大,沿着该轴执行计算的成本就越高):
In [13]: np.ones((3, 4, 5), dtype=np.float64).strides
Out[13]: (160, 40, 8)
虽然典型的 NumPy 用户很少会对数组的步长感兴趣,但它们需要用来构建“零拷贝”数组视图。步长甚至可以是负数,这使得数组可以在内存中“向后”移动(例如,在像obj[::-1]或obj[:, ::-1]这样的切片中)。
NumPy 数据类型层次结构
您可能偶尔需要检查代码是否包含整数、浮点数、字符串或 Python 对象的数组。由于有多种浮点数类型(float16到float128),检查数据类型是否在类型列表中会非常冗长。幸运的是,数据类型有超类,如np.integer和np.floating,可以与np.issubdtype函数一起使用:
In [14]: ints = np.ones(10, dtype=np.uint16)
In [15]: floats = np.ones(10, dtype=np.float32)
In [16]: np.issubdtype(ints.dtype, np.integer)
Out[16]: True
In [17]: np.issubdtype(floats.dtype, np.floating)
Out[17]: True
您可以通过调用类型的mro方法查看特定数据类型的所有父类:
In [18]: np.float64.mro()
Out[18]:
[numpy.float64,
numpy.floating,
numpy.inexact,
numpy.number,
numpy.generic,
float,
object]
因此,我们还有:
In [19]: np.issubdtype(ints.dtype, np.number)
Out[19]: True
大多数 NumPy 用户永远不需要了解这一点,但有时会有用。请参见图 A.2 以查看数据类型层次结构和父-子类关系的图表。¹

图 A.2:NumPy 数据类型类层次结构
A.2 高级数组操作
除了花式索引、切片和布尔子集之外,还有许多处理数组的方法。虽然大部分数据分析应用程序的繁重工作由 pandas 中的高级函数处理,但您可能在某个时候需要编写一个在现有库中找不到的数据算法。
重新塑形数组
在许多情况下,您可以将一个数组从一种形状转换为另一种形状而不复制任何数据。为此,将表示新形状的元组传递给 reshape 数组实例方法。例如,假设我们有一个希望重新排列成矩阵的值的一维数组(这在图 A.3 中有说明):
In [20]: arr = np.arange(8)
In [21]: arr
Out[21]: array([0, 1, 2, 3, 4, 5, 6, 7])
In [22]: arr.reshape((4, 2))
Out[22]:
array([[0, 1],
[2, 3],
[4, 5],
[6, 7]])

图 A.3:按 C(行主要)或 FORTRAN(列主要)顺序重新塑形
多维数组也可以被重新塑形:
In [23]: arr.reshape((4, 2)).reshape((2, 4))
Out[23]:
array([[0, 1, 2, 3],
[4, 5, 6, 7]])
传递的形状维度中可以有一个为 -1,在这种情况下,该维度的值将从数据中推断出来:
In [24]: arr = np.arange(15)
In [25]: arr.reshape((5, -1))
Out[25]:
array([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11],
[12, 13, 14]])
由于数组的 shape 属性是一个元组,它也可以传递给 reshape:
In [26]: other_arr = np.ones((3, 5))
In [27]: other_arr.shape
Out[27]: (3, 5)
In [28]: arr.reshape(other_arr.shape)
Out[28]:
array([[ 0, 1, 2, 3, 4],
[ 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14]])
从一维到更高维的 reshape 的相反操作通常称为 展平 或 raveling:
In [29]: arr = np.arange(15).reshape((5, 3))
In [30]: arr
Out[30]:
array([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11],
[12, 13, 14]])
In [31]: arr.ravel()
Out[31]: array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14])
如果结果中的值在原始数组中是连续的,ravel 不会生成基础值的副本。
flatten 方法的行为类似于 ravel,只是它总是返回数据的副本:
In [32]: arr.flatten()
Out[32]: array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14])
数据可以以不同的顺序被重新塑形或展开。这对于新的 NumPy 用户来说是一个略微微妙的主题,因此是下一个子主题。
C 与 FORTRAN 顺序
NumPy 能够适应内存中数据的许多不同布局。默认情况下,NumPy 数组是按 行主要 顺序创建的。从空间上讲,这意味着如果您有一个二维数据数组,数组中每行的项都存储在相邻的内存位置上。与行主要顺序相反的是 列主要 顺序,这意味着数据中每列的值都存储在相邻的内存位置上。
出于历史原因,行和列主要顺序也被称为 C 和 FORTRAN 顺序。在 FORTRAN 77 语言中,矩阵都是列主要的。
像 reshape 和 ravel 这样的函数接受一个 order 参数,指示数组中使用数据的顺序。在大多数情况下,这通常设置为 'C' 或 'F'(还有一些不常用的选项 'A' 和 'K';请参阅 NumPy 文档,并参考图 A.3 以了解这些选项的说明):
In [33]: arr = np.arange(12).reshape((3, 4))
In [34]: arr
Out[34]:
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
In [35]: arr.ravel()
Out[35]: array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
In [36]: arr.ravel('F')
Out[36]: array([ 0, 4, 8, 1, 5, 9, 2, 6, 10, 3, 7, 11])
使用超过两个维度的数组进行重新塑形可能有点令人费解(参见图 A.3)。C 和 FORTRAN 顺序之间的关键区别在于维度的遍历方式:
C/行主要顺序
在遍历更高维度时,首先 遍历(例如,先在轴 1 上再在轴 0 上前进)。
FORTRAN/列主要顺序
在遍历更高维度时,最后 遍历(例如,先在轴 0 上再在轴 1 上前进)。
连接和分割数组
numpy.concatenate 接受一个数组序列(元组,列表等),并按顺序沿着输入轴连接它们:
In [37]: arr1 = np.array([[1, 2, 3], [4, 5, 6]])
In [38]: arr2 = np.array([[7, 8, 9], [10, 11, 12]])
In [39]: np.concatenate([arr1, arr2], axis=0)
Out[39]:
array([[ 1, 2, 3],
[ 4, 5, 6],
[ 7, 8, 9],
[10, 11, 12]])
In [40]: np.concatenate([arr1, arr2], axis=1)
Out[40]:
array([[ 1, 2, 3, 7, 8, 9],
[ 4, 5, 6, 10, 11, 12]])
有一些便利函数,如 vstack 和 hstack,用于常见类型的连接。前面的操作可以表示为:
In [41]: np.vstack((arr1, arr2))
Out[41]:
array([[ 1, 2, 3],
[ 4, 5, 6],
[ 7, 8, 9],
[10, 11, 12]])
In [42]: np.hstack((arr1, arr2))
Out[42]:
array([[ 1, 2, 3, 7, 8, 9],
[ 4, 5, 6, 10, 11, 12]])
另一方面,split 将数组沿着一个轴分割成多个数组:
In [43]: arr = rng.standard_normal((5, 2))
In [44]: arr
Out[44]:
array([[-1.4238, 1.2637],
[-0.8707, -0.2592],
[-0.0753, -0.7409],
[-1.3678, 0.6489],
[ 0.3611, -1.9529]])
In [45]: first, second, third = np.split(arr, [1, 3])
In [46]: first
Out[46]: array([[-1.4238, 1.2637]])
In [47]: second
Out[47]:
array([[-0.8707, -0.2592],
[-0.0753, -0.7409]])
In [48]: third
Out[48]:
array([[-1.3678, 0.6489],
[ 0.3611, -1.9529]])
传递给 np.split 的值 [1, 3] 指示在哪些索引处将数组分割成片段。
请参见表 A.1 以获取所有相关连接和分割函数的列表,其中一些仅作为非常通用的 concatenate 的便利。
表 A.1:数组连接函数
| 函数 | 描述 |
|---|---|
concatenate |
最通用的函数,沿一个轴连接数组集合 |
vstack, row_stack |
按行堆叠数组(沿轴 0) |
hstack |
按列堆叠数组(沿轴 1) |
column_stack |
类似于hstack,但首先将 1D 数组转换为 2D 列向量 |
dstack |
按“深度”(沿轴 2)堆叠数组 |
split |
沿特定轴在传递位置分割数组 |
hsplit/vsplit |
在轴 0 和 1 上分割的便利函数 |
堆叠助手:r_ 和 c_
NumPy 命名空间中有两个特殊对象,r_和c_,使堆叠数组更简洁:
In [49]: arr = np.arange(6)
In [50]: arr1 = arr.reshape((3, 2))
In [51]: arr2 = rng.standard_normal((3, 2))
In [52]: np.r_[arr1, arr2]
Out[52]:
array([[ 0. , 1. ],
[ 2. , 3. ],
[ 4. , 5. ],
[ 2.3474, 0.9685],
[-0.7594, 0.9022],
[-0.467 , -0.0607]])
In [53]: np.c_[np.r_[arr1, arr2], arr]
Out[53]:
array([[ 0. , 1. , 0. ],
[ 2. , 3. , 1. ],
[ 4. , 5. , 2. ],
[ 2.3474, 0.9685, 3. ],
[-0.7594, 0.9022, 4. ],
[-0.467 , -0.0607, 5. ]])
这些还可以将切片转换为数组:
In [54]: np.c_[1:6, -10:-5]
Out[54]:
array([[ 1, -10],
[ 2, -9],
[ 3, -8],
[ 4, -7],
[ 5, -6]])
查看文档字符串以了解您可以使用c_和r_做什么。
重复元素:tile 和 repeat
用于重复或复制数组以生成更大数组的两个有用工具是repeat和tile函数。repeat将数组中的每个元素重复若干次,生成一个更大的数组:
In [55]: arr = np.arange(3)
In [56]: arr
Out[56]: array([0, 1, 2])
In [57]: arr.repeat(3)
Out[57]: array([0, 0, 0, 1, 1, 1, 2, 2, 2])
注意
需要复制或重复数组的情况在 NumPy 中可能不像其他数组编程框架(如 MATLAB)中那样常见。其中一个原因是广播通常更好地满足这种需求,这是下一节的主题。
默认情况下,如果传递一个整数,每个元素将重复该次数。如果传递一个整数数组,每个元素可以重复不同次数:
In [58]: arr.repeat([2, 3, 4])
Out[58]: array([0, 0, 1, 1, 1, 2, 2, 2, 2])
多维数组可以沿特定轴重复其元素:
In [59]: arr = rng.standard_normal((2, 2))
In [60]: arr
Out[60]:
array([[ 0.7888, -1.2567],
[ 0.5759, 1.399 ]])
In [61]: arr.repeat(2, axis=0)
Out[61]:
array([[ 0.7888, -1.2567],
[ 0.7888, -1.2567],
[ 0.5759, 1.399 ],
[ 0.5759, 1.399 ]])
请注意,如果没有传递轴,数组将首先被展平,这可能不是您想要的。同样,当重复多维数组以不同次数重复给定切片时,可以传递整数数组:
In [62]: arr.repeat([2, 3], axis=0)
Out[62]:
array([[ 0.7888, -1.2567],
[ 0.7888, -1.2567],
[ 0.5759, 1.399 ],
[ 0.5759, 1.399 ],
[ 0.5759, 1.399 ]])
In [63]: arr.repeat([2, 3], axis=1)
Out[63]:
array([[ 0.7888, 0.7888, -1.2567, -1.2567, -1.2567],
[ 0.5759, 0.5759, 1.399 , 1.399 , 1.399 ]])
另一方面,tile是一个沿轴堆叠数组副本的快捷方式。在视觉上,您可以将其视为类似于“铺设瓷砖”:
In [64]: arr
Out[64]:
array([[ 0.7888, -1.2567],
[ 0.5759, 1.399 ]])
In [65]: np.tile(arr, 2)
Out[65]:
array([[ 0.7888, -1.2567, 0.7888, -1.2567],
[ 0.5759, 1.399 , 0.5759, 1.399 ]])
第二个参数是瓷砖的数量;对于标量,瓦片是按行而不是按列进行的。tile的第二个参数可以是一个元组,指示“瓦片”的布局:
In [66]: arr
Out[66]:
array([[ 0.7888, -1.2567],
[ 0.5759, 1.399 ]])
In [67]: np.tile(arr, (2, 1))
Out[67]:
array([[ 0.7888, -1.2567],
[ 0.5759, 1.399 ],
[ 0.7888, -1.2567],
[ 0.5759, 1.399 ]])
In [68]: np.tile(arr, (3, 2))
Out[68]:
array([[ 0.7888, -1.2567, 0.7888, -1.2567],
[ 0.5759, 1.399 , 0.5759, 1.399 ],
[ 0.7888, -1.2567, 0.7888, -1.2567],
[ 0.5759, 1.399 , 0.5759, 1.399 ],
[ 0.7888, -1.2567, 0.7888, -1.2567],
[ 0.5759, 1.399 , 0.5759, 1.399 ]])
花式索引等效:take 和 put
正如您可能从 Ch 4:NumPy 基础:数组和矢量化计算中记得的那样,通过使用整数数组进行花式索引来获取和设置数组的子集是一种方法:
In [69]: arr = np.arange(10) * 100
In [70]: inds = [7, 1, 2, 6]
In [71]: arr[inds]
Out[71]: array([700, 100, 200, 600])
在仅在单个轴上进行选择的特殊情况下,有一些替代的 ndarray 方法是有用的:
In [72]: arr.take(inds)
Out[72]: array([700, 100, 200, 600])
In [73]: arr.put(inds, 42)
In [74]: arr
Out[74]: array([ 0, 42, 42, 300, 400, 500, 42, 42, 800, 900])
In [75]: arr.put(inds, [40, 41, 42, 43])
In [76]: arr
Out[76]: array([ 0, 41, 42, 300, 400, 500, 43, 40, 800, 900])
要在其他轴上使用take,可以传递axis关键字:
In [77]: inds = [2, 0, 2, 1]
In [78]: arr = rng.standard_normal((2, 4))
In [79]: arr
Out[79]:
array([[ 1.3223, -0.2997, 0.9029, -1.6216],
[-0.1582, 0.4495, -1.3436, -0.0817]])
In [80]: arr.take(inds, axis=1)
Out[80]:
array([[ 0.9029, 1.3223, 0.9029, -0.2997],
[-1.3436, -0.1582, -1.3436, 0.4495]])
put不接受axis参数,而是索引到数组的展平(一维,C 顺序)版本。因此,当您需要使用索引数组在其他轴上设置元素时,最好使用基于[]的索引。
A.3 广播
广播规定了不同形状数组之间的操作方式。它可以是一个强大的功能,但即使对于有经验的用户也可能会引起混淆。广播的最简单示例是将标量值与数组组合时发生:
In [81]: arr = np.arange(5)
In [82]: arr
Out[82]: array([0, 1, 2, 3, 4])
In [83]: arr * 4
Out[83]: array([ 0, 4, 8, 12, 16])
在这里,我们说标量值 4 已经广播到乘法操作中的所有其他元素。
例如,我们可以通过减去列均值来对数组的每一列进行去均值处理。在这种情况下,只需要减去包含每列均值的数组即可:
In [84]: arr = rng.standard_normal((4, 3))
In [85]: arr.mean(0)
Out[85]: array([0.1206, 0.243 , 0.1444])
In [86]: demeaned = arr - arr.mean(0)
In [87]: demeaned
Out[87]:
array([[ 1.6042, 2.3751, 0.633 ],
[ 0.7081, -1.202 , -1.3538],
[-1.5329, 0.2985, 0.6076],
[-0.7793, -1.4717, 0.1132]])
In [88]: demeaned.mean(0)
Out[88]: array([ 0., -0., 0.])
请参见图 A.4 以了解此操作的示例。将行作为广播操作去均值需要更多的注意。幸运的是,跨任何数组维度广播潜在较低维值(例如从二维数组的每列中减去行均值)是可能的,只要遵循规则。
这将我们带到了广播规则。
两个数组在广播时兼容,如果对于每个尾部维度(即,从末尾开始),轴的长度匹配,或者长度中的任何一个为 1。然后在缺失或长度为 1 的维度上执行广播。

图 A.4:在 1D 数组的轴 0 上进行广播
即使作为一个经验丰富的 NumPy 用户,我经常发现自己在思考广播规则时不得不停下来画图。考虑最后一个示例,假设我们希望减去每行的平均值。由于arr.mean(0)的长度为 3,它在轴 0 上是兼容的进行广播,因为arr中的尾部维度为 3,因此匹配。根据规则,要在轴 1 上进行减法(即,从每行减去行均值),较小的数组必须具有形状(4, 1):
In [89]: arr
Out[89]:
array([[ 1.7247, 2.6182, 0.7774],
[ 0.8286, -0.959 , -1.2094],
[-1.4123, 0.5415, 0.7519],
[-0.6588, -1.2287, 0.2576]])
In [90]: row_means = arr.mean(1)
In [91]: row_means.shape
Out[91]: (4,)
In [92]: row_means.reshape((4, 1))
Out[92]:
array([[ 1.7068],
[-0.4466],
[-0.0396],
[-0.5433]])
In [93]: demeaned = arr - row_means.reshape((4, 1))
In [94]: demeaned.mean(1)
Out[94]: array([-0., 0., 0., 0.])
查看图 A.5 以了解此操作的示例。

图 A.5:在 2D 数组的轴 1 上进行广播
查看图 A.6 以获得另一个示例,这次是在轴 0 上将二维数组添加到三维数组中。

图 A.6:在 3D 数组的轴 0 上进行广播
在其他轴上进行广播
使用更高维度数组进行广播可能看起来更加令人费解,但实际上只是遵循规则的问题。如果不遵循规则,就会出现如下错误:
In [95]: arr - arr.mean(1)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-95-8b8ada26fac0> in <module>
----> 1 arr - arr.mean(1)
ValueError: operands could not be broadcast together with shapes (4,3) (4,)
通常希望使用低维数组在轴 0 以外的轴上执行算术运算是很常见的。根据广播规则,“广播维度”在较小数组中必须为 1。在这里显示的行减均值示例中,这意味着将行重塑为形状(4, 1)而不是(4,):
In [96]: arr - arr.mean(1).reshape((4, 1))
Out[96]:
array([[ 0.018 , 0.9114, -0.9294],
[ 1.2752, -0.5124, -0.7628],
[-1.3727, 0.5811, 0.7915],
[-0.1155, -0.6854, 0.8009]])
在三维情况下,沿着任何三个维度进行广播只是将数据重塑为兼容形状的问题。图 A.7 很好地可视化了广播到三维数组的每个轴所需的形状。

图 A.7:广播到 3D 数组上的兼容 2D 数组形状
因此,一个常见的问题是需要添加一个新的长度为 1 的新轴,专门用于广播目的。使用reshape是一种选择,但插入轴需要构造一个指示新形状的元组。这通常是一项繁琐的工作。因此,NumPy 数组提供了一种特殊的语法,通过索引插入新轴。我们使用特殊的np.newaxis属性以及“full”切片来插入新轴:
In [97]: arr = np.zeros((4, 4))
In [98]: arr_3d = arr[:, np.newaxis, :]
In [99]: arr_3d.shape
Out[99]: (4, 1, 4)
In [100]: arr_1d = rng.standard_normal(3)
In [101]: arr_1d[:, np.newaxis]
Out[101]:
array([[ 0.3129],
[-0.1308],
[ 1.27 ]])
In [102]: arr_1d[np.newaxis, :]
Out[102]: array([[ 0.3129, -0.1308, 1.27 ]])
因此,如果我们有一个三维数组并且希望减去轴 2 的均值,我们需要编写:
In [103]: arr = rng.standard_normal((3, 4, 5))
In [104]: depth_means = arr.mean(2)
In [105]: depth_means
Out[105]:
array([[ 0.0431, 0.2747, -0.1885, -0.2014],
[-0.5732, -0.5467, 0.1183, -0.6301],
[ 0.0972, 0.5954, 0.0331, -0.6002]])
In [106]: depth_means.shape
Out[106]: (3, 4)
In [107]: demeaned = arr - depth_means[:, :, np.newaxis]
In [108]: demeaned.mean(2)
Out[108]:
array([[ 0., -0., 0., -0.],
[ 0., -0., -0., -0.],
[ 0., 0., 0., 0.]])
您可能想知道是否有一种方法可以在不牺牲性能的情况下推广沿轴的减均值操作。有,但需要一些索引技巧:
def demean_axis(arr, axis=0):
means = arr.mean(axis)
# This generalizes things like [:, :, np.newaxis] to N dimensions
indexer = [slice(None)] * arr.ndim
indexer[axis] = np.newaxis
return arr - means[indexer]
通过广播设置数组值
控制算术运算的相同广播规则也适用于通过数组索引设置值。在简单情况下,我们可以做如下操作:
In [109]: arr = np.zeros((4, 3))
In [110]: arr[:] = 5
In [111]: arr
Out[111]:
array([[5., 5., 5.],
[5., 5., 5.],
[5., 5., 5.],
[5., 5., 5.]])
但是,如果我们有一个要设置到数组列中的值的一维数组,只要形状兼容,我们就可以这样做:
In [112]: col = np.array([1.28, -0.42, 0.44, 1.6])
In [113]: arr[:] = col[:, np.newaxis]
In [114]: arr
Out[114]:
array([[ 1.28, 1.28, 1.28],
[-0.42, -0.42, -0.42],
[ 0.44, 0.44, 0.44],
[ 1.6 , 1.6 , 1.6 ]])
In [115]: arr[:2] = [[-1.37], [0.509]]
In [116]: arr
Out[116]:
array([[-1.37 , -1.37 , -1.37 ],
[ 0.509, 0.509, 0.509],
[ 0.44 , 0.44 , 0.44 ],
[ 1.6 , 1.6 , 1.6 ]])
A.4 高级 ufunc 用法
虽然许多 NumPy 用户只会使用通用函数提供的快速逐元素操作,但偶尔一些附加功能可以帮助您编写更简洁的代码,而无需显式循环。
ufunc 实例方法
NumPy 的每个二进制 ufunc 都有特殊的方法来执行某些特定类型的特殊向量化操作。这些方法在表 A.2 中进行了总结,但我将给出一些具体示例来说明它们的工作原理。
reduce接受一个数组并通过执行一系列二进制操作(可选地沿轴)来聚合其值。例如,在数组中求和元素的另一种方法是使用np.add.reduce:
In [117]: arr = np.arange(10)
In [118]: np.add.reduce(arr)
Out[118]: 45
In [119]: arr.sum()
Out[119]: 45
起始值(例如,add的 0)取决于 ufunc。如果传递了轴,将沿着该轴执行减少。这使您能够以简洁的方式回答某些类型的问题。作为一个不那么平凡的例子,我们可以使用np.logical_and来检查数组的每一行中的值是否已排序:
In [120]: my_rng = np.random.default_rng(12346) # for reproducibility
In [121]: arr = my_rng.standard_normal((5, 5))
In [122]: arr
Out[122]:
array([[-0.9039, 0.1571, 0.8976, -0.7622, -0.1763],
[ 0.053 , -1.6284, -0.1775, 1.9636, 1.7813],
[-0.8797, -1.6985, -1.8189, 0.119 , -0.4441],
[ 0.7691, -0.0343, 0.3925, 0.7589, -0.0705],
[ 1.0498, 1.0297, -0.4201, 0.7863, 0.9612]])
In [123]: arr[::2].sort(1) # sort a few rows
In [124]: arr[:, :-1] < arr[:, 1:]
Out[124]:
array([[ True, True, True, True],
[False, True, True, False],
[ True, True, True, True],
[False, True, True, False],
[ True, True, True, True]])
In [125]: np.logical_and.reduce(arr[:, :-1] < arr[:, 1:], axis=1)
Out[125]: array([ True, False, True, False, True])
请注意,logical_and.reduce等同于all方法。
accumulate ufunc 方法与reduce相关,就像cumsum与sum相关一样。它生成一个与中间“累积”值大小相同的数组:
In [126]: arr = np.arange(15).reshape((3, 5))
In [127]: np.add.accumulate(arr, axis=1)
Out[127]:
array([[ 0, 1, 3, 6, 10],
[ 5, 11, 18, 26, 35],
[10, 21, 33, 46, 60]])
outer在两个数组之间执行成对的叉积:
In [128]: arr = np.arange(3).repeat([1, 2, 2])
In [129]: arr
Out[129]: array([0, 1, 1, 2, 2])
In [130]: np.multiply.outer(arr, np.arange(5))
Out[130]:
array([[0, 0, 0, 0, 0],
[0, 1, 2, 3, 4],
[0, 1, 2, 3, 4],
[0, 2, 4, 6, 8],
[0, 2, 4, 6, 8]])
outer的输出将具有输入维度的连接:
In [131]: x, y = rng.standard_normal((3, 4)), rng.standard_normal(5)
In [132]: result = np.subtract.outer(x, y)
In [133]: result.shape
Out[133]: (3, 4, 5)
最后一个方法,reduceat,执行“本地减少”,本质上是一个数组“分组”操作,其中数组的切片被聚合在一起。它接受指示如何拆分和聚合值的“bin 边缘”序列:
In [134]: arr = np.arange(10)
In [135]: np.add.reduceat(arr, [0, 5, 8])
Out[135]: array([10, 18, 17])
结果是在arr[0:5],arr[5:8]和arr[8:]上执行的减少(这里是求和)。与其他方法一样,您可以传递一个axis参数:
In [136]: arr = np.multiply.outer(np.arange(4), np.arange(5))
In [137]: arr
Out[137]:
array([[ 0, 0, 0, 0, 0],
[ 0, 1, 2, 3, 4],
[ 0, 2, 4, 6, 8],
[ 0, 3, 6, 9, 12]])
In [138]: np.add.reduceat(arr, [0, 2, 4], axis=1)
Out[138]:
array([[ 0, 0, 0],
[ 1, 5, 4],
[ 2, 10, 8],
[ 3, 15, 12]])
请参见表 A.2 以获取 ufunc 方法的部分列表。
表 A.2:ufunc 方法
| 方法 | 描述 |
|---|---|
accumulate(x) |
聚合值,保留所有部分聚合。 |
at(x, indices, b=None) |
在指定的索引处对x执行操作。参数b是需要两个数组输入的 ufunc 的第二个输入。 |
reduce(x) |
通过连续应用操作来聚合值。 |
reduceat(x, bins) |
“本地”减少或“分组”;减少数据的连续切片以生成聚合数组。 |
outer(x, y) |
将操作应用于x和y中所有元素对;生成的数组形状为x.shape + y.shape。 |
用 Python 编写新的 ufuncs
有许多创建自己的 NumPy ufuncs 的方法。最通用的方法是使用 NumPy C API,但这超出了本书的范围。在本节中,我们将看一下纯 Python ufuncs。
numpy.frompyfunc接受一个 Python 函数以及输入和输出数量的规范。例如,一个简单的逐元素相加的函数将被指定为:
In [139]: def add_elements(x, y):
.....: return x + y
In [140]: add_them = np.frompyfunc(add_elements, 2, 1)
In [141]: add_them(np.arange(8), np.arange(8))
Out[141]: array([0, 2, 4, 6, 8, 10, 12, 14], dtype=object)
使用frompyfunc创建的函数始终返回 Python 对象的数组,这可能不方便。幸运的是,还有一种替代(但功能稍逊一筹)的函数numpy.vectorize,允许您指定输出类型:
In [142]: add_them = np.vectorize(add_elements, otypes=[np.float64])
In [143]: add_them(np.arange(8), np.arange(8))
Out[143]: array([ 0., 2., 4., 6., 8., 10., 12., 14.])
这些函数提供了一种创建类似 ufunc 函数的方法,但它们非常慢,因为它们需要调用 Python 函数来计算每个元素,这比 NumPy 的基于 C 的 ufunc 循环慢得多:
In [144]: arr = rng.standard_normal(10000)
In [145]: %timeit add_them(arr, arr)
1.18 ms +- 14.8 us per loop (mean +- std. dev. of 7 runs, 1000 loops each)
In [146]: %timeit np.add(arr, arr)
2.8 us +- 64.1 ns per loop (mean +- std. dev. of 7 runs, 100000 loops each)
在本附录的后面,我们将展示如何使用Numba 库在 Python 中创建快速的 ufuncs。
A.5 结构化和记录数组
到目前为止,您可能已经注意到 ndarray 是一个同质数据容器;也就是说,它表示一个内存块,其中每个元素占据相同数量的字节,由数据类型确定。表面上,这似乎不允许您表示异构或表格数据。结构化数组是一个 ndarray,其中每个元素可以被视为表示 C 中的struct(因此称为“结构化”名称)或 SQL 表中具有多个命名字段的行:
In [147]: dtype = [('x', np.float64), ('y', np.int32)]
In [148]: sarr = np.array([(1.5, 6), (np.pi, -2)], dtype=dtype)
In [149]: sarr
Out[149]: array([(1.5 , 6), (3.1416, -2)], dtype=[('x', '<f8'), ('y', '<i4')])
有几种指定结构化数据类型的方法(请参阅在线 NumPy 文档)。一种典型的方法是作为具有(field_name, field_data_type)的元组列表。现在,数组的元素是类似元组的对象,其元素可以像字典一样访问:
In [150]: sarr[0]
Out[150]: (1.5, 6)
In [151]: sarr[0]['y']
Out[151]: 6
字段名称存储在dtype.names属性中。当您访问结构化数组上的字段时,会返回数据的跨度视图,因此不会复制任何内容:
In [152]: sarr['x']
Out[152]: array([1.5 , 3.1416])
嵌套数据类型和多维字段
在指定结构化数据类型时,您还可以传递一个形状(作为 int 或元组):
In [153]: dtype = [('x', np.int64, 3), ('y', np.int32)]
In [154]: arr = np.zeros(4, dtype=dtype)
In [155]: arr
Out[155]:
array([([0, 0, 0], 0), ([0, 0, 0], 0), ([0, 0, 0], 0), ([0, 0, 0], 0)],
dtype=[('x', '<i8', (3,)), ('y', '<i4')])
在这种情况下,x字段现在指的是每个记录的长度为 3 的数组:
In [156]: arr[0]['x']
Out[156]: array([0, 0, 0])
方便的是,访问arr['x']然后返回一个二维数组,而不是像之前的例子中那样返回一个一维数组:
In [157]: arr['x']
Out[157]:
array([[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]])
这使您能够将更复杂的嵌套结构表达为数组中的单个内存块。您还可以嵌套数据类型以创建更复杂的结构。这里是一个例子:
In [158]: dtype = [('x', [('a', 'f8'), ('b', 'f4')]), ('y', np.int32)]
In [159]: data = np.array([((1, 2), 5), ((3, 4), 6)], dtype=dtype)
In [160]: data['x']
Out[160]: array([(1., 2.), (3., 4.)], dtype=[('a', '<f8'), ('b', '<f4')])
In [161]: data['y']
Out[161]: array([5, 6], dtype=int32)
In [162]: data['x']['a']
Out[162]: array([1., 3.])
pandas DataFrame 并不以相同的方式支持这个特性,尽管它类似于分层索引。
为什么使用结构化数组?
与 pandas DataFrame 相比,NumPy 结构化数组是一个更低级别的工具。它们提供了一种将内存块解释为具有嵌套列的表格结构的方法。由于数组中的每个元素在内存中表示为固定数量的字节,结构化数组提供了一种有效的方式来将数据写入磁盘(包括内存映射)、通过网络传输数据以及其他类似用途。结构化数组中每个值的内存布局基于 C 编程语言中 struct 数据类型的二进制表示。
作为结构化数组的另一个常见用途,将数据文件写为固定长度记录字节流是在工业中的遗留系统中有时会发现的一种序列化数据的常见方式。只要文件的格式已知(每个记录的大小以及每个元素的顺序、字节大小和数据类型),数据就可以通过np.fromfile读入内存。像这样的专用用途超出了本书的范围,但值得知道这样的事情是可能的。
A.6 更多关于排序的内容
与 Python 内置列表类似,ndarray 的sort实例方法是一种原地排序,这意味着数组内容会被重新排列而不会产生新数组:
In [163]: arr = rng.standard_normal(6)
In [164]: arr.sort()
In [165]: arr
Out[165]: array([-1.1553, -0.9319, -0.5218, -0.4745, -0.1649, 0.03 ])
在原地对数组进行排序时,请记住,如果数组是对不同 ndarray 的视图,则原始数组将被修改:
In [166]: arr = rng.standard_normal((3, 5))
In [167]: arr
Out[167]:
array([[-1.1956, 0.4691, -0.3598, 1.0359, 0.2267],
[-0.7448, -0.5931, -1.055 , -0.0683, 0.458 ],
[-0.07 , 0.1462, -0.9944, 1.1436, 0.5026]])
In [168]: arr[:, 0].sort() # Sort first column values in place
In [169]: arr
Out[169]:
array([[-1.1956, 0.4691, -0.3598, 1.0359, 0.2267],
[-0.7448, -0.5931, -1.055 , -0.0683, 0.458 ],
[-0.07 , 0.1462, -0.9944, 1.1436, 0.5026]])
另一方面,numpy.sort会创建一个新的、排序后的数组副本。否则,它接受与 ndarray 的sort方法相同的参数(如kind):
In [170]: arr = rng.standard_normal(5)
In [171]: arr
Out[171]: array([ 0.8981, -1.1704, -0.2686, -0.796 , 1.4522])
In [172]: np.sort(arr)
Out[172]: array([-1.1704, -0.796 , -0.2686, 0.8981, 1.4522])
In [173]: arr
Out[173]: array([ 0.8981, -1.1704, -0.2686, -0.796 , 1.4522])
所有这些排序方法都接受一个轴参数,用于独立地对沿传递的轴的数据部分进行排序:
In [174]: arr = rng.standard_normal((3, 5))
In [175]: arr
Out[175]:
array([[-0.2535, 2.1183, 0.3634, -0.6245, 1.1279],
[ 1.6164, -0.2287, -0.6201, -0.1143, -1.2067],
[-1.0872, -2.1518, -0.6287, -1.3199, 0.083 ]])
In [176]: arr.sort(axis=1)
In [177]: arr
Out[177]:
array([[-0.6245, -0.2535, 0.3634, 1.1279, 2.1183],
[-1.2067, -0.6201, -0.2287, -0.1143, 1.6164],
[-2.1518, -1.3199, -1.0872, -0.6287, 0.083 ]])
您可能会注意到,所有排序方法都没有选项以降序排序。这在实践中是一个问题,因为数组切片会产生视图,因此不会产生副本或需要任何计算工作。许多 Python 用户熟悉“技巧”,即对于values列表,values[::-1]返回一个反向排序的列表。对于 ndarrays 也是如此:
In [178]: arr[:, ::-1]
Out[178]:
array([[ 2.1183, 1.1279, 0.3634, -0.2535, -0.6245],
[ 1.6164, -0.1143, -0.2287, -0.6201, -1.2067],
[ 0.083 , -0.6287, -1.0872, -1.3199, -2.1518]])
间接排序:argsort 和 lexsort
在数据分析中,您可能需要按一个或多个键重新排序数据集。例如,关于一些学生的数据表可能需要按姓氏排序,然后按名字排序。这是一个间接排序的例子,如果您已经阅读了与 pandas 相关的章节,您已经看到了许多更高级的例子。给定一个键或键(一个值数组或多个值数组),您希望获得一个整数索引数组(我口头上称之为索引器),告诉您如何重新排序数据以使其按排序顺序排列。这种情况下的两种方法是argsort和numpy.lexsort。例如:
In [179]: values = np.array([5, 0, 1, 3, 2])
In [180]: indexer = values.argsort()
In [181]: indexer
Out[181]: array([1, 2, 4, 3, 0])
In [182]: values[indexer]
Out[182]: array([0, 1, 2, 3, 5])
作为一个更复杂的例子,这段代码通过其第一行重新排序一个二维数组:
In [183]: arr = rng.standard_normal((3, 5))
In [184]: arr[0] = values
In [185]: arr
Out[185]:
array([[ 5. , 0. , 1. , 3. , 2. ],
[-0.7503, -2.1268, -1.391 , -0.4922, 0.4505],
[ 0.8926, -1.0479, 0.9553, 0.2936, 0.5379]])
In [186]: arr[:, arr[0].argsort()]
Out[186]:
array([[ 0. , 1. , 2. , 3. , 5. ],
[-2.1268, -1.391 , 0.4505, -0.4922, -0.7503],
[-1.0479, 0.9553, 0.5379, 0.2936, 0.8926]])
lexsort类似于argsort,但它对多个关键数组执行间接的*词典排序。假设我们想要按名字和姓氏对一些数据进行排序:
In [187]: first_name = np.array(['Bob', 'Jane', 'Steve', 'Bill', 'Barbara'])
In [188]: last_name = np.array(['Jones', 'Arnold', 'Arnold', 'Jones', 'Walters'])
In [189]: sorter = np.lexsort((first_name, last_name))
In [190]: sorter
Out[190]: array([1, 2, 3, 0, 4])
In [191]: list(zip(last_name[sorter], first_name[sorter]))
Out[191]:
[('Arnold', 'Jane'),
('Arnold', 'Steve'),
('Jones', 'Bill'),
('Jones', 'Bob'),
('Walters', 'Barbara')]
第一次使用lexsort可能会有点困惑,因为用于对数据排序的键的顺序是从传递的最后数组开始的。在这里,last_name在first_name之前使用。
替代排序算法
稳定排序算法保留相等元素的相对位置。这在相对排序有意义的间接排序中尤为重要:
In [192]: values = np.array(['2:first', '2:second', '1:first', '1:second',
.....: '1:third'])
In [193]: key = np.array([2, 2, 1, 1, 1])
In [194]: indexer = key.argsort(kind='mergesort')
In [195]: indexer
Out[195]: array([2, 3, 4, 0, 1])
In [196]: values.take(indexer)
Out[196]:
array(['1:first', '1:second', '1:third', '2:first', '2:second'],
dtype='<U8')
唯一可用的稳定排序是mergesort,它保证了O(n log n)的性能,但其性能平均而言比默认的 quicksort 方法差。请参见表 A.3 以获取可用方法及其相对性能(和性能保证)的摘要。这不是大多数用户需要考虑的事情,但知道它存在是有用的。
表 A.3:数组排序方法
| 类型 | 速度 | 稳定性 | 工作空间 | 最坏情况 |
|---|---|---|---|---|
'quicksort' |
1 | 否 | 0 | O(n²) |
'mergesort' |
2 | 是 | n / 2 |
O(n log n) |
'heapsort' |
3 | 否 | 0 | O(n log n) |
部分排序数组
排序的目标之一可以是确定数组中最大或最小的元素。NumPy 有快速方法numpy.partition和np.argpartition,用于围绕第k个最小元素对数组进行分区:
In [197]: rng = np.random.default_rng(12345)
In [198]: arr = rng.standard_normal(20)
In [199]: arr
Out[199]:
array([-1.4238, 1.2637, -0.8707, -0.2592, -0.0753, -0.7409, -1.3678,
0.6489, 0.3611, -1.9529, 2.3474, 0.9685, -0.7594, 0.9022,
-0.467 , -0.0607, 0.7888, -1.2567, 0.5759, 1.399 ])
In [200]: np.partition(arr, 3)
Out[200]:
array([-1.9529, -1.4238, -1.3678, -1.2567, -0.8707, -0.7594, -0.7409,
-0.0607, 0.3611, -0.0753, -0.2592, -0.467 , 0.5759, 0.9022,
0.9685, 0.6489, 0.7888, 1.2637, 1.399 , 2.3474])
调用partition(arr, 3)后,结果中的前三个元素是任意顺序中最小的三个值。numpy.argpartition类似于numpy.argsort,返回重新排列数据为等效顺序的索引:
In [201]: indices = np.argpartition(arr, 3)
In [202]: indices
Out[202]:
array([ 9, 0, 6, 17, 2, 12, 5, 15, 8, 4, 3, 14, 18, 13, 11, 7, 16,
1, 19, 10])
In [203]: arr.take(indices)
Out[203]:
array([-1.9529, -1.4238, -1.3678, -1.2567, -0.8707, -0.7594, -0.7409,
-0.0607, 0.3611, -0.0753, -0.2592, -0.467 , 0.5759, 0.9022,
0.9685, 0.6489, 0.7888, 1.2637, 1.399 , 2.3474])
numpy.searchsorted: 在排序数组中查找元素
searchsorted是一个数组方法,对排序数组执行二分查找,返回值需要插入以保持排序的数组中的位置:
In [204]: arr = np.array([0, 1, 7, 12, 15])
In [205]: arr.searchsorted(9)
Out[205]: 3
您还可以传递一个值数组,以获取一个索引数组:
In [206]: arr.searchsorted([0, 8, 11, 16])
Out[206]: array([0, 3, 3, 5])
您可能已经注意到searchsorted对于0元素返回了0。这是因为默认行为是返回相等值组的左侧的索引:
In [207]: arr = np.array([0, 0, 0, 1, 1, 1, 1])
In [208]: arr.searchsorted([0, 1])
Out[208]: array([0, 3])
In [209]: arr.searchsorted([0, 1], side='right')
Out[209]: array([3, 7])
作为searchsorted的另一个应用,假设我们有一个值在 0 到 10,000 之间的数组,以及一个我们想要用来对数据进行分箱的单独的“桶边缘”数组:
In [210]: data = np.floor(rng.uniform(0, 10000, size=50))
In [211]: bins = np.array([0, 100, 1000, 5000, 10000])
In [212]: data
Out[212]:
array([ 815., 1598., 3401., 4651., 2664., 8157., 1932., 1294., 916.,
5985., 8547., 6016., 9319., 7247., 8605., 9293., 5461., 9376.,
4949., 2737., 4517., 6650., 3308., 9034., 2570., 3398., 2588.,
3554., 50., 6286., 2823., 680., 6168., 1763., 3043., 4408.,
1502., 2179., 4743., 4763., 2552., 2975., 2790., 2605., 4827.,
2119., 4956., 2462., 8384., 1801.])
然后,为了确定每个数据点属于哪个区间(其中 1 表示桶[0, 100)),我们可以简单地使用searchsorted:
In [213]: labels = bins.searchsorted(data)
In [214]: labels
Out[214]:
array([2, 3, 3, 3, 3, 4, 3, 3, 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 4,
3, 4, 3, 3, 3, 3, 1, 4, 3, 2, 4, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 4, 3])
这与 pandas 的groupby结合使用,可以用于对数据进行分箱:
In [215]: pd.Series(data).groupby(labels).mean()
Out[215]:
1 50.000000
2 803.666667
3 3079.741935
4 7635.200000
dtype: float64
使用 Numba 编写快速 NumPy 函数
Numba 是一个开源项目,使用 CPU、GPU 或其他硬件为类似 NumPy 的数据创建快速函数。它使用LLVM Project将 Python 代码转换为编译后的机器代码。
为了介绍 Numba,让我们考虑一个纯 Python 函数,使用for循环计算表达式(x - y).mean():
import numpy as np
def mean_distance(x, y):
nx = len(x)
result = 0.0
count = 0
for i in range(nx):
result += x[i] - y[i]
count += 1
return result / count
这个函数很慢:
In [209]: x = rng.standard_normal(10_000_000)
In [210]: y = rng.standard_normal(10_000_000)
In [211]: %timeit mean_distance(x, y)
1 loop, best of 3: 2 s per loop
In [212]: %timeit (x - y).mean()
100 loops, best of 3: 14.7 ms per loop
NumPy 版本快了 100 多倍。我们可以使用numba.jit函数将此函数转换为编译后的 Numba 函数:
In [213]: import numba as nb
In [214]: numba_mean_distance = nb.jit(mean_distance)
我们也可以将其编写为装饰器:
@nb.jit
def numba_mean_distance(x, y):
nx = len(x)
result = 0.0
count = 0
for i in range(nx):
result += x[i] - y[i]
count += 1
return result / count
结果函数实际上比矢量化的 NumPy 版本更快:
In [215]: %timeit numba_mean_distance(x, y)
100 loops, best of 3: 10.3 ms per loop
Numba 无法编译所有纯 Python 代码,但它支持 Python 的一个重要子集,对于编写数值算法非常有用。
Numba 是一个深度库,支持不同类型的硬件、编译模式和用户扩展。它还能够编译 NumPy Python API 的一个重要子集,而无需显式的for循环。Numba 能够识别可以编译为机器代码的结构,同时替换对 CPython API 的调用,以便编译它不知道如何编译的函数。Numba 的jit函数选项nopython=True将允许的代码限制为可以在没有任何 Python C API 调用的情况下编译为 LLVM 的 Python 代码。jit(nopython=True)有一个更短的别名,numba.njit。
在前面的示例中,我们可以这样写:
from numba import float64, njit
@njit(float64(float64[:], float64[:]))
def mean_distance(x, y):
return (x - y).mean()
我鼓励您通过阅读Numba 的在线文档来了解更多。下一节将展示创建自定义 NumPy ufunc 对象的示例。
使用 Numba 创建自定义 numpy.ufunc 对象
numba.vectorize函数创建了编译后的 NumPy ufuncs,行为类似于内置的 ufuncs。让我们考虑一个numpy.add的 Python 实现:
from numba import vectorize
@vectorize
def nb_add(x, y):
return x + y
现在我们有:
In [13]: x = np.arange(10)
In [14]: nb_add(x, x)
Out[14]: array([ 0., 2., 4., 6., 8., 10., 12., 14., 16., 18.])
In [15]: nb_add.accumulate(x, 0)
Out[15]: array([ 0., 1., 3., 6., 10., 15., 21., 28., 36., 45.])
高级数组输入和输出
在 Ch 4: NumPy 基础:数组和矢量化计算中,我们熟悉了np.save和np.load用于将数组以二进制格式存储在磁盘上。还有一些其他更复杂用途的选项需要考虑。特别是,内存映射还具有额外的好处,使您能够对不适合 RAM 的数据集执行某些操作。
内存映射文件
内存映射文件是一种与磁盘上的二进制数据交互的方法,就好像它存储在内存中的数组中一样。NumPy 实现了一个类似 ndarray 的memmap对象,使得可以在不将整个数组读入内存的情况下读取和写入大文件的小段。此外,memmap具有与内存中数组相同的方法,因此可以替换许多算法中预期的 ndarray 的地方。
要创建新的内存映射,请使用函数np.memmap并传递文件路径、数据类型、形状和文件模式:
In [217]: mmap = np.memmap('mymmap', dtype='float64', mode='w+',
.....: shape=(10000, 10000))
In [218]: mmap
Out[218]:
memmap([[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
...,
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.]])
对memmap进行切片会返回磁盘上数据的视图:
In [219]: section = mmap[:5]
如果将数据分配给这些对象,它将被缓冲在内存中,这意味着如果您在不同的应用程序中读取文件,更改将不会立即反映在磁盘文件中。可以通过调用flush将任何修改同步到磁盘:
In [220]: section[:] = rng.standard_normal((5, 10000))
In [221]: mmap.flush()
In [222]: mmap
Out[222]:
memmap([[-0.9074, -1.0954, 0.0071, ..., 0.2753, -1.1641, 0.8521],
[-0.0103, -0.0646, -1.0615, ..., -1.1003, 0.2505, 0.5832],
[ 0.4583, 1.2992, 1.7137, ..., 0.8691, -0.7889, -0.2431],
...,
[ 0. , 0. , 0. , ..., 0. , 0. , 0. ],
[ 0. , 0. , 0. , ..., 0. , 0. , 0. ],
[ 0. , 0. , 0. , ..., 0. , 0. , 0. ]])
In [223]: del mmap
当内存映射超出范围并被垃圾回收时,任何更改也将刷新到磁盘。打开现有内存映射时,仍然必须指定数据类型和形状,因为文件只是一个没有任何数据类型信息、形状或步幅的二进制数据块:
In [224]: mmap = np.memmap('mymmap', dtype='float64', shape=(10000, 10000))
In [225]: mmap
Out[225]:
memmap([[-0.9074, -1.0954, 0.0071, ..., 0.2753, -1.1641, 0.8521],
[-0.0103, -0.0646, -1.0615, ..., -1.1003, 0.2505, 0.5832],
[ 0.4583, 1.2992, 1.7137, ..., 0.8691, -0.7889, -0.2431],
...,
[ 0. , 0. , 0. , ..., 0. , 0. , 0. ],
[ 0. , 0. , 0. , ..., 0. , 0. , 0. ],
[ 0. , 0. , 0. , ..., 0. , 0. , 0. ]])
内存映射也适用于结构化或嵌套数据类型,如结构化和记录数组中所述。
如果您在计算机上运行此示例,可能希望删除我们上面创建的大文件:
In [226]: %xdel mmap
In [227]: !rm mymmap
HDF5 和其他数组存储选项
PyTables 和 h5py 是两个 Python 项目,提供了与 NumPy 兼容的接口,用于以高效和可压缩的 HDF5 格式(HDF 代表分层数据格式)存储数组数据。您可以安全地将数百吉字节甚至数千吉字节的数据存储在 HDF5 格式中。要了解如何在 Python 中使用 HDF5,我建议阅读pandas 在线文档。
性能提示
将数据处理代码调整为使用 NumPy 通常会使事情变得更快,因为数组操作通常会取代否则相对极慢的纯 Python 循环。以下是一些提示,可帮助您从库中获得最佳性能:
-
将 Python 循环和条件逻辑转换为数组操作和布尔数组操作。
-
尽可能使用广播。
-
使用数组视图(切片)来避免复制数据。
-
利用 ufuncs 和 ufunc 方法。
如果仅使用 NumPy 无法获得所需的性能,请考虑在 C、FORTRAN 或 Cython 中编写代码。我经常在自己的工作中使用Cython作为一种获得类似 C 性能的方法,通常开发时间更短。
连续内存的重要性
虽然这个主题的全部范围有点超出了本书的范围,在一些应用中,数组的内存布局可以显著影响计算速度。这部分基于 CPU 的缓存层次结构的性能差异;访问连续内存块的操作(例如,对 C 顺序数组的行求和)通常是最快的,因为内存子系统将适当的内存块缓冲到低延迟的 L1 或 L2 CPU 缓存中。此外,NumPy 的 C 代码库中的某些代码路径已经针对连续情况进行了优化,可以避免通用的跨步内存访问。
说一个数组的内存布局是连续的意味着元素按照它们在数组中出现的顺序存储在内存中,关于 FORTRAN(列主序)或 C(行主序)排序。默认情况下,NumPy 数组被创建为 C 连续或者简单连续。一个列主序数组,比如一个 C 连续数组的转置,因此被称为 FORTRAN 连续。这些属性可以通过flags属性在 ndarray 上显式检查:
In [228]: arr_c = np.ones((100, 10000), order='C')
In [229]: arr_f = np.ones((100, 10000), order='F')
In [230]: arr_c.flags
Out[230]:
C_CONTIGUOUS : True
F_CONTIGUOUS : False
OWNDATA : True
WRITEABLE : True
ALIGNED : True
WRITEBACKIFCOPY : False
In [231]: arr_f.flags
Out[231]:
C_CONTIGUOUS : False
F_CONTIGUOUS : True
OWNDATA : True
WRITEABLE : True
ALIGNED : True
WRITEBACKIFCOPY : False
In [232]: arr_f.flags.f_contiguous
Out[232]: True
在这个例子中,理论上,对这些数组的行求和对于arr_c应该比arr_f更快,因为行在内存中是连续的。在这里,我使用 IPython 中的%timeit进行检查(这些结果可能在你的机器上有所不同):
In [233]: %timeit arr_c.sum(1)
199 us +- 1.18 us per loop (mean +- std. dev. of 7 runs, 1000 loops each)
In [234]: %timeit arr_f.sum(1)
371 us +- 6.77 us per loop (mean +- std. dev. of 7 runs, 1000 loops each)
当你想要从 NumPy 中挤出更多性能时,这通常是一个值得投入一些努力的地方。如果你有一个数组,它没有所需的内存顺序,你可以使用copy并传递'C'或'F':
In [235]: arr_f.copy('C').flags
Out[235]:
C_CONTIGUOUS : True
F_CONTIGUOUS : False
OWNDATA : True
WRITEABLE : True
ALIGNED : True
WRITEBACKIFCOPY : False
在构建一个数组的视图时,要记住结果不一定是连续的:
In [236]: arr_c[:50].flags.contiguous
Out[236]: True
In [237]: arr_c[:, :50].flags
Out[237]:
C_CONTIGUOUS : False
F_CONTIGUOUS : False
OWNDATA : False
WRITEABLE : True
ALIGNED : True
WRITEBACKIFCOPY : False
- 一些数据类型的名称中有下划线。这是为了避免 NumPy 特定类型和 Python 内置类型之间的变量名冲突。
附录 B:关于 IPython 系统的更多内容
原文:
wesmckinney.com/book/ipython译者:飞龙
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。
如果您发现本书的在线版本有用,请考虑订购纸质版或无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。
在第二章:Python 语言基础,IPython 和 Jupyter 笔记本中,我们讨论了使用 IPython shell 和 Jupyter 笔记本的基础知识。在本附录中,我们探索了 IPython 系统中的一些更深层次功能,可以从控制台或 Jupyter 中使用。
B.1 终端键盘快捷键
IPython 有许多用于导航提示的键盘快捷键(这些快捷键对于 Emacs 文本编辑器或 Unix bash shell 的用户来说是熟悉的),并与 shell 的命令历史交互。表 B.1 总结了一些最常用的快捷键。请参阅图 B.1 以查看其中一些示例,如光标移动。
表 B.1:标准 IPython 键盘快捷键
| 键盘快捷键 | 描述 |
|---|---|
| Ctrl-P 或向上箭头 | 在命令历史中向后搜索以当前输入文本开头的命令 |
| Ctrl-N 或向下箭头 | 在命令历史中向前搜索以当前输入文本开头的命令 |
| Ctrl-R | Readline 风格的反向历史搜索(部分匹配) |
| Ctrl-Shift-V | 从剪贴板粘贴文本 |
| Ctrl-C | 中断当前正在执行的代码 |
| Ctrl-A | 将光标移动到行首 |
| Ctrl-E | 将光标移动到行尾 |
| Ctrl-K | 从光标处删除文本直到行尾 |
| Ctrl-U | 放弃当前行上的所有文本 |
| Ctrl-F | 将光标向前移动一个字符 |
| Ctrl-B | 将光标向后移动一个字符 |
| Ctrl-L | 清屏 |

图 B.1:IPython shell 中一些键盘快捷键的示例
请注意,Jupyter 笔记本有一个完全独立的键盘快捷键集用于导航和编辑。由于这些快捷键的发展速度比 IPython 中的快捷键更快,我鼓励您探索 Jupyter 笔记本菜单中的集成帮助系统。
B.2 关于魔术命令
IPython 中的特殊命令(这些命令不是 Python 本身的一部分)被称为魔术命令。这些命令旨在简化常见任务,并使您能够轻松控制 IPython 系统的行为。魔术命令是以百分号 % 为前缀的任何命令。例如,您可以使用 %timeit 魔术函数检查任何 Python 语句(如矩阵乘法)的执行时间:
In [20]: a = np.random.standard_normal((100, 100))
In [20]: %timeit np.dot(a, a)
92.5 µs ± 3.43 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
魔术命令可以被视为在 IPython 系统中运行的命令行程序。其中许多具有额外的“命令行”选项,所有这些选项都可以使用 ? 查看(正如您可能期望的那样):
In [21]: %debug?
Docstring:
::
%debug [--breakpoint FILE:LINE] [statement [statement ...]]
Activate the interactive debugger.
This magic command support two ways of activating debugger.
One is to activate debugger before executing code. This way, you
can set a break point, to step through the code from the point.
You can use this mode by giving statements to execute and optionally
a breakpoint.
The other one is to activate debugger in post-mortem mode. You can
activate this mode simply running %debug without any argument.
If an exception has just occurred, this lets you inspect its stack
frames interactively. Note that this will always work only on the last
traceback that occurred, so you must call this quickly after an
exception that you wish to inspect has fired, because if another one
occurs, it clobbers the previous one.
If you want IPython to automatically do this on every exception, see
the %pdb magic for more details.
.. versionchanged:: 7.3
When running code, user variables are no longer expanded,
the magic line is always left unmodified.
positional arguments:
statement Code to run in debugger. You can omit this in cell
magic mode.
optional arguments:
--breakpoint <FILE:LINE>, -b <FILE:LINE>
Set break point at LINE in FILE.
魔术函数可以默认使用,无需百分号,只要没有定义与所讨论的魔术函数同名的变量。这个功能称为自动魔术,可以通过 %automagic 启用或禁用。
一些魔术函数的行为类似于 Python 函数,它们的输出可以分配给一个变量:
In [22]: %pwd
Out[22]: '/home/wesm/code/pydata-book'
In [23]: foo = %pwd
In [24]: foo
Out[24]: '/home/wesm/code/pydata-book'
由于 IPython 的文档可以从系统内部访问,我鼓励您使用%quickref或%magic来探索所有可用的特殊命令。此信息显示在控制台分页器中,因此您需要按q键退出分页器。表 B.2 突出显示了在 IPython 中进行交互式计算和 Python 开发时最关键的一些命令。
表 B.2:一些经常使用的 IPython 魔术命令
| 命令 | 描述 |
|---|---|
%quickref |
显示 IPython 快速参考卡 |
%magic |
显示所有可用魔术命令的详细文档 |
%debug |
进入最后一个异常回溯底部的交互式调试器 |
%hist |
打印命令输入(和可选输出)历史记录 |
%pdb |
在任何异常后自动进入调试器 |
%paste |
从剪贴板执行预格式化的 Python 代码 |
%cpaste |
打开一个特殊提示,用于手动粘贴要执行的 Python 代码 |
%reset |
删除交互式命名空间中定义的所有变量/名称 |
%page |
对对象进行漂亮打印并通过分页器显示 |
%run <script.py> |
在 IPython 内部运行 Python 脚本 |
%prun |
使用cProfile执行 |
%time |
报告单个语句的执行时间 |
%timeit |
多次运行语句以计算集合平均执行时间;用于计时执行时间非常短的代码 |
%who, %who_ls, %whos |
显示交互式命名空间中定义的变量,具有不同级别的信息/详细程度 |
%xdel |
删除变量并尝试清除 IPython 内部对该对象的任何引用 |
%run 命令
您可以使用%run命令在 IPython 会话的环境中运行任何文件作为 Python 程序。假设您在script.py中存储了以下简单脚本:
def f(x, y, z):
return (x + y) / z
a = 5
b = 6
c = 7.5
result = f(a, b, c)
您可以通过将文件名传递给%run来执行此操作:
In [14]: %run script.py
脚本在空命名空间中运行(没有导入或其他变量定义),因此行为应与在命令行上使用python script.py运行程序相同。然后,文件中定义的所有变量(导入、函数和全局变量)(直到引发异常(如果有))将在 IPython shell 中可访问:
In [15]: c
Out [15]: 7.5
In [16]: result
Out[16]: 1.4666666666666666
如果 Python 脚本需要命令行参数(可以在sys.argv中找到),这些参数可以在文件路径之后传递,就像在命令行上运行一样。
注意
如果要让脚本访问已在交互式 IPython 命名空间中定义的变量,请使用%run -i而不是普通的%run。
在 Jupyter 笔记本中,您还可以使用相关的%load魔术函数,它将脚本导入到代码单元格中:
In [16]: %load script.py
def f(x, y, z):
return (x + y) / z
a = 5
b = 6
c = 7.5
result = f(a, b, c)
中断运行的代码
在运行任何代码时按下 Ctrl-C,无论是通过%run运行脚本还是长时间运行的命令,都会引发KeyboardInterrupt。这将导致几乎所有 Python 程序立即停止,除非在某些不寻常的情况下。
警告:
当一段 Python 代码调用了一些编译的扩展模块时,按下 Ctrl-C 并不总是会立即停止程序执行。在这种情况下,您要么等待控制返回到 Python 解释器,要么在更严重的情况下,在您的操作系统中强制终止 Python 进程(例如在 Windows 上使用任务管理器或在 Linux 上使用kill命令)。
从剪贴板执行代码
如果您正在使用 Jupyter 笔记本,您可以将代码复制粘贴到任何代码单元格中并执行。还可以在 IPython shell 中从剪贴板运行代码。假设您在其他应用程序中有以下代码:
x = 5
y = 7
if x > 5:
x += 1
y = 8
最可靠的方法是%paste和%cpaste魔术函数(请注意,这些在 Jupyter 中不起作用,因为您可以将文本复制并粘贴到 Jupyter 代码单元格中)。%paste获取剪贴板中的文本并将其作为单个块在 shell 中执行:
In [17]: %paste
x = 5
y = 7
if x > 5:
x += 1
y = 8
## -- End pasted text --
%cpaste类似,只是它为您提供了一个特殊的提示符,用于粘贴代码:
In [18]: %cpaste
Pasting code; enter '--' alone on the line to stop or use Ctrl-D.
:x = 5
:y = 7
:if x > 5:
: x += 1
:
: y = 8
:--
使用%cpaste块,您可以在执行代码之前粘贴尽可能多的代码。您可能决定使用%cpaste在执行代码之前查看粘贴的代码。如果您意外粘贴了错误的代码,可以通过按 Ctrl-C 键来退出%cpaste提示符。
B.3 使用命令历史
IPython 维护一个小型的磁盘数据库,其中包含您执行的每个命令的文本。这有各种用途:
-
使用最少的键入搜索、完成和执行先前执行的命令
-
在会话之间保留命令历史
-
将输入/输出历史记录记录到文件中
这些功能在 shell 中比在笔记本中更有用,因为笔记本通过设计在每个代码单元格中保留输入和输出的日志。
搜索和重用命令历史记录
IPython shell 允许您搜索和执行以前的代码或其他命令。这很有用,因为您经常会发现自己重复执行相同的命令,例如%run命令或其他代码片段。假设您已运行:
In[7]: %run first/second/third/data_script.py
然后探索脚本的结果(假设它成功运行),只发现您进行了错误的计算。找出问题并修改data_script.py后,您可以开始键入%run命令的几个字母,然后按下 Ctrl-P 键组合或向上箭头键。这将搜索命令历史记录,找到与您键入的字母匹配的第一个先前命令。多次按下 Ctrl-P 或向上箭头键将继续搜索历史记录。如果您错过了要执行的命令,不要担心。您可以通过按下 Ctrl-N 或向下箭头键来向前浏览命令历史记录。几次这样做后,您可能会开始在不经思考的情况下按下这些键!
使用 Ctrl-R 会为您提供与 Unix 风格 shell 中使用的readline相同的部分增量搜索功能,例如 bash shell。在 Windows 上,IPython 通过模拟readline功能来实现。要使用此功能,请按 Ctrl-R,然后键入要搜索的输入行中包含的几个字符:
In [1]: a_command = foo(x, y, z)
(reverse-i-search)`com': a_command = foo(x, y, z)
按下 Ctrl-R 将循环遍历每行的历史记录,匹配您键入的字符。
输入和输出变量
忘记将函数调用的结果分配给变量可能非常恼人。IPython 会将输入命令和输出 Python 对象的引用存储在特殊变量中。前两个输出分别存储在_(一个下划线)和__(两个下划线)变量中:
In [18]: 'input1'
Out[18]: 'input1'
In [19]: 'input2'
Out[19]: 'input2'
In [20]: __
Out[20]: 'input1'
In [21]: 'input3'
Out[21]: 'input3'
In [22]: _
Out[22]: 'input3'
输入变量存储在名为_iX的变量中,其中X是输入行号。
对于每个输入变量,都有一个相应的输出变量_X。因此,在输入行 27 之后,将有两个新变量,_27(用于输出)和_i27(用于输入):
In [26]: foo = 'bar'
In [27]: foo
Out[27]: 'bar'
In [28]: _i27
Out[28]: u'foo'
In [29]: _27
Out[29]: 'bar'
由于输入变量是字符串,因此可以使用 Python 的eval关键字再次执行它们:
In [30]: eval(_i27)
Out[30]: 'bar'
在这里,_i27指的是In [27]中输入的代码。
几个魔术函数允许您使用输入和输出历史记录。%hist打印全部或部分输入历史记录,带或不带行号。%reset清除交互式命名空间,可选地清除输入和输出缓存。%xdel魔术函数从 IPython 机制中删除对特定对象的所有引用。有关这些魔术的更多详细信息,请参阅文档。
警告:
在处理非常大的数据集时,请记住 IPython 的输入和输出历史可能导致其中引用的对象不会被垃圾回收(释放内存),即使您使用del关键字从交互式命名空间中删除变量。在这种情况下,谨慎使用%xdel和%reset可以帮助您避免遇到内存问题。
B.4 与操作系统交互
IPython 的另一个特性是它允许您访问文件系统和操作系统 shell。这意味着,您可以像在 Windows 或 Unix(Linux,macOS)shell 中一样执行大多数标准命令行操作,而无需退出 IPython。这包括 shell 命令、更改目录以及将命令的结果存储在 Python 对象(列表或字符串)中。还有命令别名和目录标记功能。
查看表 B.3 以获取调用 shell 命令的魔术函数和语法摘要。我将在接下来的几节中简要介绍这些功能。
表 B.3:IPython 与系统相关的命令
| 命令 | 描述 |
|---|---|
!cmd |
在系统 shell 中执行cmd |
output = !cmd args |
运行cmd并将 stdout 存储在output中 |
%alias alias_name cmd |
为系统(shell)命令定义别名 |
%bookmark |
使用 IPython 的目录标记系统 |
%cd |
将系统工作目录更改为传递的目录 |
%pwd |
返回当前系统工作目录 |
%pushd |
将当前目录放入堆栈并切换到目标目录 |
%popd |
切换到堆栈顶部弹出的目录 |
%dirs |
返回包含当前目录堆栈的列表 |
%dhist |
打印访问过的目录的历史记录 |
%env |
将系统环境变量作为字典返回 |
%matplotlib |
配置 matplotlib 集成选项 |
Shell 命令和别名
在 IPython 中以感叹号!开头的行告诉 IPython 在感叹号后执行系统 shell 中的所有内容。这意味着您可以删除文件(使用rm或del,取决于您的操作系统)、更改目录或执行任何其他进程。
您可以通过将用!转义的表达式分配给变量来存储 shell 命令的控制台输出。例如,在我连接到以太网上网的基于 Linux 的机器上,我可以将我的 IP 地址作为 Python 变量获取:
In [1]: ip_info = !ifconfig wlan0 | grep "inet "
In [2]: ip_info[0].strip()
Out[2]: 'inet addr:10.0.0.11 Bcast:10.0.0.255 Mask:255.255.255.0'
返回的 Python 对象ip_info实际上是一个包含各种控制台输出版本的自定义列表类型。
在使用!时,IPython 还可以在当前环境中定义的 Python 值进行替换。要做到这一点,请在变量名前加上美元符号$:
In [3]: foo = 'test*'
In [4]: !ls $foo
test4.py test.py test.xml
%alias魔术函数可以为 shell 命令定义自定义快捷方式。例如:
In [1]: %alias ll ls -l
In [2]: ll /usr
total 332
drwxr-xr-x 2 root root 69632 2012-01-29 20:36 bin/
drwxr-xr-x 2 root root 4096 2010-08-23 12:05 games/
drwxr-xr-x 123 root root 20480 2011-12-26 18:08 include/
drwxr-xr-x 265 root root 126976 2012-01-29 20:36 lib/
drwxr-xr-x 44 root root 69632 2011-12-26 18:08 lib32/
lrwxrwxrwx 1 root root 3 2010-08-23 16:02 lib64 -> lib/
drwxr-xr-x 15 root root 4096 2011-10-13 19:03 local/
drwxr-xr-x 2 root root 12288 2012-01-12 09:32 sbin/
drwxr-xr-x 387 root root 12288 2011-11-04 22:53 share/
drwxrwsr-x 24 root src 4096 2011-07-17 18:38 src/
您可以通过使用分号将它们分隔来像在命令行上一样执行多个命令:
In [558]: %alias test_alias (cd examples; ls; cd ..)
In [559]: test_alias
macrodata.csv spx.csv tips.csv
您会注意到,IPython 在会话关闭后会“忘记”您交互定义的任何别名。要创建永久别名,您需要使用配置系统。
目录标记系统
IPython 具有目录标记系统,使您可以保存常见目录的别名,以便您可以轻松跳转。例如,假设您想要创建一个指向本书补充材料的书签:
In [6]: %bookmark py4da /home/wesm/code/pydata-book
完成此操作后,当您使用%cd魔术时,您可以使用您定义的任何书签:
In [7]: cd py4da
(bookmark:py4da) -> /home/wesm/code/pydata-book
/home/wesm/code/pydata-book
如果书签名称与当前工作目录中的目录名称冲突,您可以使用-b标志来覆盖并使用书签位置。使用%bookmark的-l选项列出所有书签:
In [8]: %bookmark -l
Current bookmarks:
py4da -> /home/wesm/code/pydata-book-source
与别名不同,书签在 IPython 会话之间自动保留。
B.5 软件开发工具
除了作为交互式计算和数据探索的舒适环境外,IPython 还可以成为一般 Python 软件开发的有用伴侣。在数据分析应用中,首先重要的是拥有正确的代码。幸运的是,IPython 已经紧密集成并增强了内置的 Python pdb调试器。其次,您希望您的代码快速。为此,IPython 具有方便的集成代码计时和性能分析工具。我将在这里详细介绍这些工具。
交互式调试器
IPython 的调试器通过制表符补全、语法高亮显示和异常跟踪中每行的上下文增强了pdb。调试代码的最佳时机之一是在发生错误后立即进行调试。在异常发生后立即输入%debug命令会调用“事后”调试器,并将您放入引发异常的堆栈帧中:
In [2]: run examples/ipython_bug.py
---------------------------------------------------------------------------
AssertionError Traceback (most recent call last)
/home/wesm/code/pydata-book/examples/ipython_bug.py in <module>()
13 throws_an_exception()
14
---> 15 calling_things()
/home/wesm/code/pydata-book/examples/ipython_bug.py in calling_things()
11 def calling_things():
12 works_fine()
---> 13 throws_an_exception()
14
15 calling_things()
/home/wesm/code/pydata-book/examples/ipython_bug.py in throws_an_exception()
7 a = 5
8 b = 6
----> 9 assert(a + b == 10)
10
11 def calling_things():
AssertionError:
In [3]: %debug
> /home/wesm/code/pydata-book/examples/ipython_bug.py(9)throws_an_exception()
8 b = 6
----> 9 assert(a + b == 10)
10
ipdb>
进入调试器后,您可以执行任意 Python 代码并探索每个堆栈帧中的所有对象和数据(这些对象和数据由解释器“保持活动”)。默认情况下,您从发生错误的最低级别开始。通过输入u(向上)和d(向下),您可以在堆栈跟踪的级别之间切换:
ipdb> u
> /home/wesm/code/pydata-book/examples/ipython_bug.py(13)calling_things()
12 works_fine()
---> 13 throws_an_exception()
14
执行%pdb命令会使 IPython 在任何异常发生后自动调用调试器,这是许多用户会发现有用的模式。
在开发代码时使用调试器也很有帮助,特别是当您需要设置断点或逐步执行函数或脚本以检查每个步骤的行为时。有几种方法可以实现这一点。第一种方法是使用带有-d标志的%run,在执行传递的脚本中的任何代码之前调用调试器。您必须立即输入s(步进)以进入脚本:
In [5]: run -d examples/ipython_bug.py
Breakpoint 1 at /home/wesm/code/pydata-book/examples/ipython_bug.py:1
NOTE: Enter 'c' at the ipdb> prompt to start your script.
> <string>(1)<module>()
ipdb> s
--Call--
> /home/wesm/code/pydata-book/examples/ipython_bug.py(1)<module>()
1---> 1 def works_fine():
2 a = 5
3 b = 6
在此之后,您可以自行决定如何处理文件。例如,在前面的异常中,我们可以在调用works_fine函数之前设置断点,并通过输入c(继续)运行脚本直到达到断点:
ipdb> b 12
ipdb> c
> /home/wesm/code/pydata-book/examples/ipython_bug.py(12)calling_things()
11 def calling_things():
2--> 12 works_fine()
13 throws_an_exception()
此时,您可以通过输入n(下一步)进入works_fine()或执行works_fine()以前进到下一行:
ipdb> n
> /home/wesm/code/pydata-book/examples/ipython_bug.py(13)calling_things()
2 12 works_fine()
---> 13 throws_an_exception()
14
然后,我们可以步入throws_an_exception并前进到发生错误的行,并查看作用域中的变量。请注意,调试器命令优先于变量名称;在这种情况下,使用!作为前缀来检查它们的内容:
ipdb> s
--Call--
> /home/wesm/code/pydata-book/examples/ipython_bug.py(6)throws_an_exception()
5
----> 6 def throws_an_exception():
7 a = 5
ipdb> n
> /home/wesm/code/pydata-book/examples/ipython_bug.py(7)throws_an_exception()
6 def throws_an_exception():
----> 7 a = 5
8 b = 6
ipdb> n
> /home/wesm/code/pydata-book/examples/ipython_bug.py(8)throws_an_exception()
7 a = 5
----> 8 b = 6
9 assert(a + b == 10)
ipdb> n
> /home/wesm/code/pydata-book/examples/ipython_bug.py(9)throws_an_exception()
8 b = 6
----> 9 assert(a + b == 10)
10
ipdb> !a
5
ipdb> !b
6
根据我的经验,熟练掌握交互式调试器需要时间和实践。请参阅表 B.4 以获取调试器命令的完整目录。如果您习惯使用 IDE,您可能会发现基于终端的调试器一开始有点严格,但随着时间的推移会有所改善。一些 Python IDE 具有出色的 GUI 调试器,因此大多数用户都可以找到适合自己的工具。
表 B.4:Python 调试器命令
| 命令 | 动作 |
|---|---|
h(elp) |
显示命令列表 |
help |
显示 |
c(ontinue) |
恢复程序执行 |
q(uit) |
在不执行任何其他代码的情况下退出调试器 |
b(reak) |
在当前文件的 |
b <path/to/file.py:number> |
在指定文件中的第 |
s(tep) |
步入函数调用 |
n(ext) |
执行当前行并前进到当前级别的下一行 |
u(p)/d(own) |
在函数调用堆栈中向上/向下移动 |
a(rgs) |
显示当前函数的参数 |
debug |
在新的(递归)调试器中调用语句 |
l(ist) |
显示当前位置和堆栈当前级别的上下文 |
w(here) |
打印当前位置的完整堆栈跟踪上下文 |
调试器的其他用法
还有几种有用的调用调试器的方法。第一种是使用特殊的set_trace函数(以pdb.set_trace命名),基本上是一个“穷人的断点”。以下是两个您可能希望将其放在某处以供您一般使用的小技巧(可能将它们添加到您的 IPython 配置文件中,就像我做的那样):
from IPython.core.debugger import Pdb
def set_trace():
Pdb(.set_trace(sys._getframe().f_back)
def debug(f, *args, **kwargs):
pdb = Pdb()
return pdb.runcall(f, *args, **kwargs)
第一个函数set_trace提供了一个方便的方法,在代码的某个地方设置断点。您可以在代码的任何部分使用set_trace,以便在需要临时停止以更仔细地检查它时使用(例如,在异常发生之前):
In [7]: run examples/ipython_bug.py
> /home/wesm/code/pydata-book/examples/ipython_bug.py(16)calling_things()
15 set_trace()
---> 16 throws_an_exception()
17
键入c(继续)将使代码正常恢复,不会造成任何伤害。
我们刚刚看过的debug函数使您可以轻松地在任意函数调用上调用交互式调试器。假设我们编写了一个类似以下内容的函数,并且希望逐步执行其逻辑:
def f(x, y, z=1):
tmp = x + y
return tmp / z
通常使用f看起来像f(1, 2, z=3)。要代替进入f,请将f作为debug的第一个参数传递,然后是要传递给f的位置参数和关键字参数:
In [6]: debug(f, 1, 2, z=3)
> <ipython-input>(2)f()
1 def f(x, y, z):
----> 2 tmp = x + y
3 return tmp / z
ipdb>
这两个技巧多年来为我节省了很多时间。
最后,调试器可以与%run一起使用。通过使用%run -d运行脚本,您将直接进入调试器,准备设置任何断点并启动脚本:
In [1]: %run -d examples/ipython_bug.py
Breakpoint 1 at /home/wesm/code/pydata-book/examples/ipython_bug.py:1
NOTE: Enter 'c' at the ipdb> prompt to start your script.
> <string>(1)<module>()
ipdb>
添加带有行号的-b会启动已经设置了断点的调试器:
In [2]: %run -d -b2 examples/ipython_bug.py
Breakpoint 1 at /home/wesm/code/pydata-book/examples/ipython_bug.py:2
NOTE: Enter 'c' at the ipdb> prompt to start your script.
> <string>(1)<module>()
ipdb> c
> /home/wesm/code/pydata-book/examples/ipython_bug.py(2)works_fine()
1 def works_fine():
1---> 2 a = 5
3 b = 6
ipdb>
计时代码:%time 和%timeit
对于规模较大或运行时间较长的数据分析应用程序,您可能希望测量各个组件或单个语句或函数调用的执行时间。您可能希望获得一个报告,其中列出了在复杂过程中占用最多时间的函数。幸运的是,IPython 使您能够在开发和测试代码时方便地获取这些信息。
手动使用内置的time模块及其函数time.clock和time.time来计时代码通常是乏味和重复的,因为您必须编写相同的无聊样板代码:
import time
start = time.time()
for i in range(iterations):
# some code to run here
elapsed_per = (time.time() - start) / iterations
由于这是一个常见操作,IPython 有两个魔术函数%time和%timeit,可以为您自动化这个过程。
%time运行一次语句,报告总执行时间。假设我们有一个大型字符串列表,并且我们想比较不同方法选择所有以特定前缀开头的字符串。这里是一个包含 600,000 个字符串和两种相同方法的列表,只选择以'foo'开头的字符串:
# a very large list of strings
In [11]: strings = ['foo', 'foobar', 'baz', 'qux',
....: 'python', 'Guido Van Rossum'] * 100000
In [12]: method1 = [x for x in strings if x.startswith('foo')]
In [13]: method2 = [x for x in strings if x[:3] == 'foo']
看起来它们在性能上应该是一样的,对吧?我们可以使用%time来确保:
In [14]: %time method1 = [x for x in strings if x.startswith('foo')]
CPU times: user 49.6 ms, sys: 676 us, total: 50.3 ms
Wall time: 50.1 ms
In [15]: %time method2 = [x for x in strings if x[:3] == 'foo']
CPU times: user 40.3 ms, sys: 603 us, total: 40.9 ms
Wall time: 40.6 ms
Wall time(“墙钟时间”的缩写)是主要关注的数字。从这些时间中,我们可以推断出存在一些性能差异,但这不是一个非常精确的测量。如果您尝试自己多次%time这些语句,您会发现结果有些变化。要获得更精确的测量结果,请使用%timeit魔术函数。给定一个任意语句,它有一个启发式方法多次运行语句以产生更准确的平均运行时间(这些结果在您的系统上可能有所不同):
In [563]: %timeit [x for x in strings if x.startswith('foo')]
10 loops, best of 3: 159 ms per loop
In [564]: %timeit [x for x in strings if x[:3] == 'foo']
10 loops, best of 3: 59.3 ms per loop
这个看似无害的例子说明了值得了解 Python 标准库、NumPy、pandas 和本书中使用的其他库的性能特征。在规模较大的数据分析应用程序中,这些毫秒将开始累积!
%timeit特别适用于分析具有非常短执行时间的语句和函数,甚至可以到微秒(百万分之一秒)或纳秒(十亿分之一秒)的级别。这些可能看起来是微不足道的时间,但当然,一个耗时 20 微秒的函数被调用 100 万次比一个耗时 5 微秒的函数多花费 15 秒。在前面的例子中,我们可以直接比较这两个字符串操作以了解它们的性能特征:
In [565]: x = 'foobar'
In [566]: y = 'foo'
In [567]: %timeit x.startswith(y)
1000000 loops, best of 3: 267 ns per loop
In [568]: %timeit x[:3] == y
10000000 loops, best of 3: 147 ns per loop
基本分析:%prun 和%run -p
代码剖析与计时代码密切相关,只是它关注于确定时间花费在哪里。主要的 Python 剖析工具是cProfile模块,它与 IPython 没有特定关联。cProfile执行程序或任意代码块,同时跟踪每个函数中花费的时间。
在命令行上常用的一种使用cProfile的方式是运行整个程序并输出每个函数的聚合时间。假设我们有一个脚本,在循环中执行一些线性代数运算(计算一系列 100×100 矩阵的最大绝对特征值):
import numpy as np
from numpy.linalg import eigvals
def run_experiment(niter=100):
K = 100
results = []
for _ in range(niter):
mat = np.random.standard_normal((K, K))
max_eigenvalue = np.abs(eigvals(mat)).max()
results.append(max_eigenvalue)
return results
some_results = run_experiment()
print('Largest one we saw: {0}'.format(np.max(some_results)))
您可以通过命令行运行以下脚本来使用cProfile:
python -m cProfile cprof_example.py
如果尝试这样做,您会发现输出按函数名称排序。这使得很难了解大部分时间花费在哪里,因此使用-s标志指定排序顺序很有用:
$ python -m cProfile -s cumulative cprof_example.py
Largest one we saw: 11.923204422
15116 function calls (14927 primitive calls) in 0.720 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.001 0.001 0.721 0.721 cprof_example.py:1(<module>)
100 0.003 0.000 0.586 0.006 linalg.py:702(eigvals)
200 0.572 0.003 0.572 0.003 {numpy.linalg.lapack_lite.dgeev}
1 0.002 0.002 0.075 0.075 __init__.py:106(<module>)
100 0.059 0.001 0.059 0.001 {method 'randn')
1 0.000 0.000 0.044 0.044 add_newdocs.py:9(<module>)
2 0.001 0.001 0.037 0.019 __init__.py:1(<module>)
2 0.003 0.002 0.030 0.015 __init__.py:2(<module>)
1 0.000 0.000 0.030 0.030 type_check.py:3(<module>)
1 0.001 0.001 0.021 0.021 __init__.py:15(<module>)
1 0.013 0.013 0.013 0.013 numeric.py:1(<module>)
1 0.000 0.000 0.009 0.009 __init__.py:6(<module>)
1 0.001 0.001 0.008 0.008 __init__.py:45(<module>)
262 0.005 0.000 0.007 0.000 function_base.py:3178(add_newdoc)
100 0.003 0.000 0.005 0.000 linalg.py:162(_assertFinite)
...
仅显示输出的前 15 行。通过扫描cumtime列向下阅读,可以最轻松地看出每个函数内部花费了多少总时间。请注意,如果一个函数调用另一个函数,时钟不会停止。cProfile记录每个函数调用的开始和结束时间,并使用这些时间来生成时间。
除了命令行用法外,cProfile还可以以编程方式用于剖析任意代码块,而无需运行新进程。IPython 具有方便的接口,可以使用%prun命令和-p选项来%run。%prun接受与cProfile相同的“命令行选项”,但会剖析一个任意的 Python 语句,而不是整个.py文件:
In [4]: %prun -l 7 -s cumulative run_experiment()
4203 function calls in 0.643 seconds
Ordered by: cumulative time
List reduced from 32 to 7 due to restriction <7>
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.643 0.643 <string>:1(<module>)
1 0.001 0.001 0.643 0.643 cprof_example.py:4(run_experiment)
100 0.003 0.000 0.583 0.006 linalg.py:702(eigvals)
200 0.569 0.003 0.569 0.003 {numpy.linalg.lapack_lite.dgeev}
100 0.058 0.001 0.058 0.001 {method 'randn'}
100 0.003 0.000 0.005 0.000 linalg.py:162(_assertFinite)
200 0.002 0.000 0.002 0.000 {method 'all' of 'numpy.ndarray'}
类似地,调用%run -p -s cumulative cprof_example.py具有与命令行方法相同的效果,只是您无需离开 IPython。
在 Jupyter 笔记本中,您可以使用%%prun魔术(两个%符号)来剖析整个代码块。这会弹出一个单独的窗口,显示剖析输出。这在获取可能快速答案的情况下很有用,比如“为什么那个代码块运行时间如此之长?”
在使用 IPython 或 Jupyter 时,还有其他可用的工具可帮助使剖析更易于理解。其中之一是SnakeViz,它使用 D3.js 生成剖析结果的交互式可视化。
逐行剖析函数
在某些情况下,您从%prun(或其他基于cProfile的剖析方法)获得的信息可能无法完全说明函数的执行时间,或者可能非常复杂,以至于按函数名称汇总的结果难以解释。对于这种情况,有一个名为line_profiler的小型库(可通过 PyPI 或其中一个软件包管理工具获取)。它包含一个 IPython 扩展,可以启用一个新的魔术函数%lprun,用于计算一个或多个函数的逐行剖析。您可以通过修改 IPython 配置(请参阅 IPython 文档或附录后面的配置部分)来启用此扩展,包括以下行:
# A list of dotted module names of IPython extensions to load.
c.InteractiveShellApp.extensions = ['line_profiler']
您还可以运行以下命令:
%load_ext line_profiler
line_profiler可以以编程方式使用(请参阅完整文档),但在 IPython 中交互使用时可能效果最好。假设您有一个名为prof_mod的模块,其中包含执行一些 NumPy 数组操作的以下代码(如果要重现此示例,请将此代码放入一个新文件prof_mod.py中):
from numpy.random import randn
def add_and_sum(x, y):
added = x + y
summed = added.sum(axis=1)
return summed
def call_function():
x = randn(1000, 1000)
y = randn(1000, 1000)
return add_and_sum(x, y)
如果我们想了解add_and_sum函数的性能,%prun给出以下结果:
In [569]: %run prof_mod
In [570]: x = randn(3000, 3000)
In [571]: y = randn(3000, 3000)
In [572]: %prun add_and_sum(x, y)
4 function calls in 0.049 seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.036 0.036 0.046 0.046 prof_mod.py:3(add_and_sum)
1 0.009 0.009 0.009 0.009 {method 'sum' of 'numpy.ndarray'}
1 0.003 0.003 0.049 0.049 <string>:1(<module>)
这并不特别启发人。启用line_profiler IPython 扩展后,将可用一个新命令%lprun。使用方式的唯一区别是我们必须指示%lprun要剖析哪个函数或函数。一般语法是:
%lprun -f func1 -f func2 statement_to_profile
在这种情况下,我们想要剖析add_and_sum,所以我们运行:
In [573]: %lprun -f add_and_sum add_and_sum(x, y)
Timer unit: 1e-06 s
File: prof_mod.py
Function: add_and_sum at line 3
Total time: 0.045936 s
Line # Hits Time Per Hit % Time Line Contents
==============================================================
3 def add_and_sum(x, y):
4 1 36510 36510.0 79.5 added = x + y
5 1 9425 9425.0 20.5 summed = added.sum(axis=1)
6 1 1 1.0 0.0 return summed
这可能更容易解释。在这种情况下,我们对我们在语句中使用的相同函数进行了分析。查看前面的模块代码,我们可以调用call_function并对其进行分析,以及add_and_sum,从而获得代码性能的完整图片:
In [574]: %lprun -f add_and_sum -f call_function call_function()
Timer unit: 1e-06 s
File: prof_mod.py
Function: add_and_sum at line 3
Total time: 0.005526 s
Line # Hits Time Per Hit % Time Line Contents
==============================================================
3 def add_and_sum(x, y):
4 1 4375 4375.0 79.2 added = x + y
5 1 1149 1149.0 20.8 summed = added.sum(axis=1)
6 1 2 2.0 0.0 return summed
File: prof_mod.py
Function: call_function at line 8
Total time: 0.121016 s
Line # Hits Time Per Hit % Time Line Contents
==============================================================
8 def call_function():
9 1 57169 57169.0 47.2 x = randn(1000, 1000)
10 1 58304 58304.0 48.2 y = randn(1000, 1000)
11 1 5543 5543.0 4.6 return add_and_sum(x, y)
作为一个一般准则,我倾向于使用%prun(cProfile)进行“宏”分析,以及%lprun(line_profiler)进行“微”分析。了解这两个工具是值得的。
注意
您必须明确指定要使用%lprun对其进行分析的函数名称的原因是“跟踪”每行的执行时间的开销很大。跟踪不感兴趣的函数可能会显著改变分析结果。
B.6 使用 IPython 进行高效代码开发的提示
以一种方便开发、调试和最终交互使用的方式编写代码可能对许多用户来说是一种范式转变。有一些程序性细节,比如代码重新加载可能需要一些调整,以及编码风格方面的考虑。
因此,实现本节中描述的大多数策略更多地是一种艺术而不是科学,需要您进行一些实验来确定一种对您有效的编写 Python 代码的方式。最终,您希望以一种方便的方式构建代码,以便进行迭代使用,并能够尽可能轻松地探索运行程序或函数的结果。我发现,专为 IPython 设计的软件比仅用作独立命令行应用程序运行的代码更容易使用。当出现问题并且您必须诊断您或其他人可能在几个月或几年前编写的代码中的错误时,这变得尤为重要。
重新加载模块依赖项
在 Python 中,当您键入import some_lib时,将执行some_lib中的代码,并将在新创建的some_lib模块命名空间中存储定义的所有变量、函数和导入。下次使用import some_lib时,您将获得对现有模块命名空间的引用。在交互式 IPython 代码开发中可能出现困难的潜在问题是,当您运行依赖于其他模块的脚本时,您可能已经进行了更改。假设我在test_script.py中有以下代码:
import some_lib
x = 5
y = [1, 2, 3, 4]
result = some_lib.get_answer(x, y)
如果您执行%run test_script.py然后修改some_lib.py,下次执行%run test_script.py时,您仍将获得some_lib.py的旧版本,因为 Python 的“一次加载”模块系统。这种行为与其他一些数据分析环境(如 MATLAB)不同,后者会自动传播代码更改。¹ 为了应对这种情况,您有几种选择。第一种方法是使用标准库中的importlib模块中的reload函数:
import some_lib
import importlib
importlib.reload(some_lib)
这尝试在每次运行test_script.py时为您提供some_lib.py的新副本(但也有一些情况下不会)。显然,如果依赖关系更深入,可能会在各个地方插入reload的用法有点棘手。对于这个问题,IPython 有一个特殊的dreload函数(不是一个魔术函数)用于对模块进行“深”(递归)重新加载。如果我运行some_lib.py然后使用dreload(some_lib),它将尝试重新加载some_lib以及其所有依赖项。不幸的是,这并不适用于所有情况,但当适用时,它比不得不重新启动 IPython 要好。
代码设计提示
这没有简单的配方,但以下是我在自己的工作中发现有效的一些高级原则。
保持相关对象和数据活动
看到一个为命令行编写的程序的结构有点像下面这样并不罕见:
from my_functions import g
def f(x, y):
return g(x + y)
def main():
x = 6
y = 7.5
result = x + y
if __name__ == '__main__':
main()
如果我们在 IPython 中运行这个程序,你能看出可能出现什么问题吗?完成后,main函数中定义的结果或对象将无法在 IPython shell 中访问。更好的方法是让main中的任何代码直接在模块的全局命名空间中执行(或者在if __name__ == '__main__':块中执行,如果你希望该模块也可以被导入)。这样,当你%run代码时,你将能够查看main中定义的所有变量。这相当于在 Jupyter 笔记本中的单元格中定义顶级变量。
扁平比嵌套更好
深度嵌套的代码让我想到洋葱的许多层。在测试或调试一个函数时,你必须剥开多少层洋葱才能到达感兴趣的代码?“扁平比嵌套更好”的想法是 Python 之禅的一部分,它也适用于为交互式使用开发代码。尽可能使函数和类解耦和模块化使它们更容易进行测试(如果你正在编写单元测试)、调试和交互使用。
克服对更长文件的恐惧
如果你来自 Java(或其他类似语言)背景,可能会被告知保持文件短小。在许多语言中,这是一个明智的建议;长长度通常是一个不好的“代码异味”,表明可能需要重构或重新组织。然而,在使用 IPython 开发代码时,处理 10 个小但相互关联的文件(每个文件不超过 100 行)通常会给你带来更多的头痛,而不是 2 或 3 个较长的文件。较少的文件意味着较少的模块需要重新加载,编辑时也减少了文件之间的跳转。我发现维护较大的模块,每个模块具有高度的内部内聚性(代码都涉及解决相同类型的问题),更加有用和符合 Python 风格。当朝着一个解决方案迭代时,当然有时将较大的文件重构为较小的文件是有意义的。
显然,我不支持将这个论点推向极端,即将所有代码放在一个庞大的文件中。为大型代码库找到一个明智和直观的模块和包结构通常需要一些工作,但在团队中正确地完成这一点尤为重要。每个模块应该在内部具有内聚性,并且应该尽可能明显地找到负责每个功能区域的函数和类。
B.7 高级 IPython 功能
充分利用 IPython 系统可能会导致你以稍微不同的方式编写代码,或者深入了解配置。
配置文件和配置
IPython 和 Jupyter 环境的外观(颜色、提示、行之间的间距等)和行为的大部分方面都可以通过一个广泛的配置系统进行配置。以下是一些可以通过配置完成的事项:
-
更改颜色方案
-
更改输入和输出提示的外观,或者在
Out之后和下一个In提示之前删除空行 -
执行一系列 Python 语句(例如,你经常使用的导入或任何其他你希望每次启动 IPython 时发生的事情)
-
启用始终开启的 IPython 扩展,比如
line_profiler中的%lprun魔术 -
启用 Jupyter 扩展
-
定义你自己的魔术或系统别名
IPython shell 的配置在特殊的ipython_config.py文件中指定,这些文件通常位于用户主目录中的.ipython/目录中。配置是基于特定的profile执行的。当您正常启动 IPython 时,默认情况下会加载default profile,存储在profile_default目录中。因此,在我的 Linux 操作系统上,我的默认 IPython 配置文件的完整路径是:
/home/wesm/.ipython/profile_default/ipython_config.py
要在您的系统上初始化此文件,请在终端中运行:
ipython profile create default
我将不会详细介绍此文件的内容。幸运的是,它有注释描述每个配置选项的用途,因此我将让读者自行调整和自定义。另一个有用的功能是可以拥有多个配置文件。假设您想要为特定应用程序或项目定制一个备用 IPython 配置。创建新配置涉及键入以下内容:
ipython profile create secret_project
完成后,编辑新创建的profile_secret_project目录中的配置文件,然后启动 IPython,如下所示:
$ ipython --profile=secret_project
Python 3.8.0 | packaged by conda-forge | (default, Nov 22 2019, 19:11:19)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.22.0 -- An enhanced Interactive Python. Type '?' for help.
IPython profile: secret_project
与以往一样,在线 IPython 文档是了解更多有关配置文件和配置的绝佳资源。
Jupyter 的配置略有不同,因为您可以将其笔记本与 Python 以外的语言一起使用。要创建类似的 Jupyter 配置文件,请运行:
jupyter notebook --generate-config
这将在您的主目录中的.jupyter/jupyter_notebook_config.py目录中写入一个默认配置文件。编辑后,您可以将其重命名为不同的文件,例如:
$ mv ~/.jupyter/jupyter_notebook_config.py ~/.jupyter/my_custom_config.py
在启动 Jupyter 时,您可以添加--config参数:
jupyter notebook --config=~/.jupyter/my_custom_config.py
B.8 结论
当您在本书中逐步学习代码示例并提高自己作为 Python 程序员的技能时,我鼓励您继续了解 IPython 和 Jupyter 生态系统。由于这些项目旨在帮助用户提高生产力,您可能会发现一些工具,使您比仅使用 Python 语言及其计算库更轻松地完成工作。
您还可以在nbviewer 网站上找到大量有趣的 Jupyter 笔记本。
- 由于模块或包可能在特定程序的许多不同位置导入,Python 在第一次导入模块时缓存模块的代码,而不是每次执行模块中的代码。否则,模块化和良好的代码组织可能会导致应用程序效率低下。


浙公网安备 33010602011771号