Python-数据清理和质量实践指南-全-

Python 数据清理和质量实践指南(全)

原文:zh.annas-archive.org/md5/315665282b94c575115c21732f961f1e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎!如果你拿起了这本书,你很可能是数以百万计被“数据”过程和可能性所吸引的人之一 —— 那种令人难以置信、难以捉摸的新“货币”,它正在改变我们生活、工作甚至互相连接的方式。例如,大多数人都模糊意识到,我们的电子设备和其他活动收集的数据正在用于塑造我们看到的广告内容、推荐给我们的媒体,以及我们在线搜索时首先显示的搜索结果。然而,许多人可能没有意识到的是,获取、转换和从数据中生成洞察力所需的工具和技能对他们来说是随手可得的。本书旨在帮助那些人 —— 也就是你,如果你愿意的话 —— 做到这一点。

数据不仅仅是大公司或政府数据分析者可获取和利用的东西。能够访问、理解和从数据中获取洞察力是一项宝贵的技能,无论你是数据科学家还是日托工作者。如今,有效使用数据所需的工具比以往任何时候都更容易获得。不仅可以仅使用免费软件和编程语言进行重要的数据工作,甚至不需要昂贵的计算机。例如,本书中的所有练习都是在一台不到 500 美元的 Chromebook 上设计和运行的。你甚至可以通过你所在地图书馆的互联网连接,仅仅使用免费的在线平台。

本书的目标是为数据新手提供开始探索数据世界所需的指导和信心 —— 首先是访问数据,然后评估其质量。有了这些基础,我们将进入一些基本的数据分析和呈现方法,以生成有意义的洞察力。虽然这些后续章节远非全面(数据分析和可视化本身就是强大的领域),但它们将为您提供生成准确、信息丰富的分析和可视化所需的核心技能,利用您新获取和清理过的数据。

适合阅读本书的人群

本书面向真正的初学者;您只需要基本了解如何使用计算机(例如,如何下载文件,打开程序,复制和粘贴等),持开放的思维,以及愿意尝试。我特别鼓励您在您感到对数据或编程感到 intimidated 时,或者如果您觉得自己在数学上“很差”,或者认为处理数据或学习编程对您来说太难,那么请尝试一下这本书。我已经花了近十年的时间教授数百名认为自己不“技术型”的人这本书中包含的精确技能,我从未遇到过一位真正无法完成这些材料的学生。在我的经验中,编程和处理数据最具挑战性的部分不是材料的难度,而是教学质量。^(1) 我非常感激多年来那些提出问题并帮助我找到更好地传达这些材料的方法的许多学生,也感激通过这本书与其他许多人分享我从他们那里学到的东西的机会。虽然一本书无法真正取代人类教师提供的支持,但我希望它至少能够为您提供掌握基础知识所需的工具,也许还能激发您将这些技能提升到下一个水平的灵感。

有些人对数据整理有一定经验,但已经达到了电子表格工具的极限,或者想要扩展他们可以轻松访问和操纵的数据格式范围的人也会发现本书有用,还有那些具有前端编程技能(例如 JavaScript 或 PHP)的人,他们正在寻找一种开始使用 Python 的方法。

谁不应该读这本书?

正如前文所述,本书面向初学者。因此,如果您是数据分析或可视化新手,您可能会发现一些部分有用,但本书并不适用于那些已经有 Python 或其他面向数据的编程语言(如 R)经验的人。幸运的是,O'Reilly 有许多专门讨论高级 Python 主题和库的专业书籍,比如 Wes McKinney 的 Python for Data Analysis(O'Reilly)或 Jake VanderPlas 的 Python Data Science Handbook(O'Reilly)。

这本书的预期目标

本书的内容设计为按照呈现的顺序进行,因为每章的概念和练习都是基于先前探讨的内容。然而,在整个过程中,你会发现练习以两种方式呈现:作为代码“笔记本”和作为“独立”编程文件。这样做有两个目的。首先,它允许你,读者,使用你更喜欢或更易于接受的方法;其次,它提供了一种比较这两种数据驱动 Python 代码交互方式的方法。根据我的经验,Python “笔记本”非常适合快速启动,但如果你开发了一个可靠的代码片段需要反复运行,可能会变得乏味。因为其中一种格式的代码通常不能简单地复制粘贴到另一种格式中,所以这两种格式都提供在附带的 GitHub 存储库中。数据文件也可通过 Google Drive 获取。在跟随练习的过程中,你可以使用你喜欢的格式,并且还可以选择首次查看每种格式中代码的差异。

尽管 Python 是本书中主要使用的工具,但通过智能使用一系列工具(从文本编辑器(你实际编写代码的程序)到电子表格程序),有效的数据整理和分析变得更加容易。因此,本书中偶尔会有一些练习依赖于除 Python 外的其他免费和/或开源工具。在引入这些工具时,我会提供一些为什么选择该工具的背景信息,并提供完成示例任务的足够说明。

本书中使用的约定

本书中使用以下排版约定:

斜体

指示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽

用于程序清单,以及在段落内引用程序元素,例如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应该按原样输入的命令或其他文本。

等宽斜体

显示应由用户提供的值或根据上下文确定的值替换的文本。

提示

此元素表示提示或建议。

提示

此元素表示一般提示。

警告

此元素表示警告或注意事项。

使用代码示例

附加材料(代码示例、练习等)可在https://github.com/PracticalPythonDataWranglingAndQuality下载。

如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书中的代码旨在帮助您发展技能。通常情况下,如果本书提供示例代码,则可以在您的程序和文档中使用它。除非您重复使用大量代码片段,否则无需获得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发奥莱利书籍中的示例代码则需要许可。如果引用本书并引用示例代码回答问题,则无需许可。将本书大量示例代码整合到产品文档中则需要许可。

我们感谢,但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“实用 Python 数据整理与数据质量,作者 Susan E. McGregor(奥莱利)。版权所有 2022 年 Susan E. McGregor,978-1-492-09150-9。”

如果您认为您对示例代码的使用超出了公平使用范围或上述许可,请随时联系我们:permissions@oreilly.com

奥莱利在线学习

注意

超过 40 年来,奥莱利传媒 提供技术和商业培训、知识和洞察,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台让您随时访问直播培训课程、深入学习路径、交互式编码环境,以及来自奥莱利和 200 多家其他出版商的大量文本和视频。欲了解更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • 奥莱利传媒公司

  • 1005 Gravenstein Highway North

  • 加利福尼亚州塞巴斯托波尔市 95472

  • 800-998-9938 (在美国或加拿大)

  • 707-829-0515 (国际或本地)

  • 707-829-0104 (传真)

我们为本书设有一个网页,列出勘误、示例和任何其他信息。您可以访问此网页:https://www.oreilly.com/library/view/practical-python-data/9781492091493

发送电子邮件至bookquestions@oreilly.com,以评论或提出有关本书的技术问题。

获取关于我们的书籍和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

正如我之前提到的,这本书在很大程度上要归功于多年来敢于尝试新事物并在旅途中提出真诚问题的我的许多学生。写作这本书的过程(更不用说书中的内容本身)得益于我的编辑杰夫·布莱尔(Jeff Bleiel),他的愉快、灵活和轻柔的手法在调和我的过度表达的同时为我的个人风格留出了空间,使这一过程更加愉快。我还要感谢我的审稿人乔安娜·S·高(Joanna S. Kao)、安妮·邦纳(Anne Bonner)和兰迪·奥(Randy Au)的深思熟虑和慷慨评论。

我还要感谢杰斯·哈伯曼(Jess Haberman),他给了我让这些材料成为我的机会,以及杰奎琳·卡齐尔(Jacqueline Kazil)和凯瑟琳·贾默尔(Katharine Jarmul),她们帮助我找到了机会。我还要感谢珍妮特·温格(Jeannette Wing)、克利夫·斯坦(Cliff Stein)以及哥伦比亚大学数据科学研究所的工作人员,他们对这项工作的兴趣已经帮助它产生了令人兴奋的新机会。当然,我也要感谢我的朋友和亲属,感谢他们的关注和支持,即使——也特别是——当他们完全不明白我在说什么的时候。

最后,我要感谢我的家人(包括那些太小而无法阅读本书的孩子们),即使在低落的时刻,你们的支持也使得这项工作有了价值。

^(1) 长时间以来,安装工具也是一个巨大的障碍。现在你只需要一个互联网连接!

第一章:介绍数据整理和数据质量

如今,似乎数据是解决所有问题的答案:我们利用产品和餐厅评论中的数据来决定购买和就餐地点;公司利用我们阅读、点击和观看的数据来决定生产什么内容和展示哪些广告;招聘人员使用数据来决定哪些申请者可以获得面试机会;政府利用数据决定从如何分配公路资金到您的孩子就读学校的所有事务。数据——无论是简单的数字表格还是“人工智能”系统的基础——渗透着我们的生活。数据对我们每天的经历和机会产生的广泛影响正是为何数据整理——以及将继续成为——任何有兴趣了解和影响数据驱动系统运作方式的人必备的技能。同样,评估——甚至改善——数据质量的能力对于希望使这些有时(深度)有缺陷的系统更加有效的人来说也是不可或缺的。

尽管术语数据整理数据质量对不同人意味着不同的事物,我们将从本章开始简要概述本书涉及的三个主要主题:数据整理、数据质量和 Python 编程语言。这一概述的目标是让您对我对这些主题的方法有所了解,部分原因是您可以判断本书是否适合您。之后,我们将花一些时间讨论如何访问和配置软件工具及其他资源,这些工具和资源是您需要跟随和完成本书中练习所必需的。尽管本书引用的所有资源都是免费使用的,但许多编程书籍和教程默认读者会在自己拥有的(通常是相当昂贵的)计算机上编程。然而,由于我真的相信任何有意愿的人都可以学会使用 Python 整理数据,我希望确保即使您没有自己的功能齐全的计算机,本书中的内容也可以为您提供帮助。为了帮助确保这一点,您在本书和接下来的章节中找到的所有解决方案都是在 Chromebook 上编写和测试的;它们也可以使用免费的在线工具在您自己的设备或学校或公共图书馆等地的共享计算机上运行。我希望通过展示数据整理的知识和工具不仅是可获得的,而且是激发和赋权的实践,来鼓励您探索这个令人兴奋的实践领域。

“数据整理”是什么?

数据整理是将“原始”或“找到的”数据转换为可用于生成见解和意义的过程。驱动每一个实质性数据整理工作的是一个问题:关于您想调查或了解更多的世界的某些内容。当然,如果您来到这本书是因为您对学习编程感到非常兴奋,那么数据整理可以是一个很好的开始方法,但让我现在敦促您不要试图在没有参与前几章节中的数据质量过程的情况下直接跳到编程。因为数据整理虽然可以从编程技能中受益,但它远不止于学习如何访问和操作数据;它涉及做出判断、推理和选择。正如本书将阐明的那样,大多数容易获取的数据质量并不特别好质量,因此如果不考虑数据质量就尝试进行数据整理,就像试图在没有方向盘的情况下驾驶汽车一样:您可能会到达某个地方——并且很快!——但那可能不是您想要去的地方。如果您打算花时间整理和分析数据,您应该尽量确保这至少是有可能值得努力的数据。

然而,与此同样重要的是,没有比将新技能与您真正想要做到“正确”的事情联系起来更好的学习方式,因为个人兴趣将帮助您度过不可避免的挫折。这并不意味着您选择的问题必须具有全球重要性。它可以是关于您喜爱的视频游戏、乐队或茶叶类型的问题。它可以是关于您学校、社区或社交媒体生活的问题。它可以是关于经济、政治、信仰或金钱的问题。它只需是真正关心的事情。

一旦您掌握了问题,您就可以开始数据整理过程。尽管具体步骤可能需要根据您的特定项目进行调整(或重复),但原则上,数据整理涉及以下一些或所有步骤:

  1. 定位或收集数据

  2. 审查数据

  3. “清理”,标准化,转换和/或增强数据

  4. 分析数据

  5. 可视化数据

  6. 传达数据

当然,每个步骤所需的时间和精力可能差异很大:如果您希望加快您已经在工作中进行的数据整理任务,您可能已经掌握了数据集,并基本知道其包含内容。另一方面,如果您试图回答关于您社区中城市支出的问题,那么收集数据可能是您项目中最具挑战性的部分。

此外,请注意,尽管我已经对前述列表进行了编号,但数据处理过程实际上更像是一个循环而不是线性的步骤集。往往情况是,随着你对正在处理的数据的含义和背景了解得更多,你需要重新审视早期的步骤。例如,在分析大型数据集时,你可能会发现令人惊讶的模式或值,这会导致你对在“审查”步骤中可能做出的假设产生疑问。这几乎总是意味着需要获取更多信息——要么来自原始数据源,要么是完全新的数据源——以便在继续分析或可视化之前真正理解正在发生的事情。最后,虽然我没有明确将其列入列表中,但每个步骤最好以研究和开头会更加准确。虽然我们工作中的“整理”部分主要集中在我们面前的数据集(们)上,但“质量”部分几乎完全是关于研究和背景的,并且这两者对数据整理过程的每个阶段都是至关重要的。

如果现在所有这些看起来有点令人不知所措,请不要担心!本书中的示例都是围绕真实数据集构建的,当你跟随编码和质量评估过程时,这一切将开始感觉更加自然。如果你正在进行自己的数据整理项目,并开始感到有点迷茫,只需不断提醒自己你试图回答的问题。这不仅会提醒你为什么要费心学习关于数据格式和 API 访问密钥的细枝末节,^(1) 它几乎总是会直观地引导你进入数据整理过程的下一个“步骤”——无论是可视化数据还是进行一点点更多的研究以改善其背景和质量。

什么是“数据质量”?

世界上有大量的数据和访问和收集它的方式。但并非所有数据都是平等的。理解数据质量是数据整理的重要组成部分,因为任何基于数据驱动的洞见都只能像其构建在之上的数据一样好。^(2) 因此,如果你试图用数据理解世界的某些有意义的事物,你必须首先确保你所拥有的数据能够准确反映这个世界。正如我们将在后面的章节中看到(特别是第三章和第六章),改进数据质量的工作几乎永远不像通常看起来整洁的、标签清晰的行和列数据那样简单明了。

这是因为——尽管使用了像机器学习人工智能这样的术语——计算工具能做的唯一事情就是按照给定给它们的指令使用提供的数据。即使是最复杂、最先进和最抽象的数据在其实质上也是不可逆转地人类的,因为它是关于如何测量和如何测量的人类决策的结果。此外,即使是今天最先进的计算机技术也是通过大规模的模式匹配来进行“预测”和“决策”——这些模式存在于人类“训练”它们提供的数据的特定选择中。计算机没有原创思想,也不会进行创造性的跳跃;它们在许多任务(如解释论点的“要旨”或故事情节)上基本上是不擅长的,这是人类找到直觉的地方。另一方面,计算机擅长执行重复的计算,非常快速,而不会感到无聊、疲倦或分心。换句话说,尽管计算机是人类判断和智慧的奇妙补充,但它们只能增强它们,而不能替代它们。

这意味着参与数据收集、获取和分析的人类必须确保其质量,以便我们的数据工作的输出实际上具有意义。虽然我们将在第三章中详细讨论数据质量,但我确实希望介绍两个不同(尽管同样重要)的评估数据质量的维度:(1)数据本身的完整性,以及(2)数据与特定问题或问题的“匹配”或适当性。

数据完整性

对于我们的目的,数据集的完整性是通过构成它的数据值和描述符来评估的。例如,如果我们的数据集包括随时间的测量结果,它们是按一致的间隔记录还是零散记录的?这些数值代表直接的个体读数,还是只有平均值可用?是否有数据字典提供有关数据收集、记录或解释的详细信息,例如提供相关单位?一般来说,数据完整原子化良好注释—等特性使其被认为具有更高的完整性,因为这些特点使得进行更广泛和更结论性的分析成为可能。然而,在大多数情况下,您会发现某个给定的数据集在任何数据完整性维度上都不足,这意味着您需要尽力了解其局限性并在可能的情况下加以改进。尽管这通常意味着通过找到其他可以补充、上下文化或扩展它的数据集来增强给定的数据集,但几乎总是意味着超越任何种类的“数据”并联系到专家:那些设计数据、收集数据、以前使用过数据或对您的数据应该解决的主题领域非常了解的人。

数据“匹配”

然而,即使数据集完整性很好,除非它适合你的特定目的,否则不能被视为高质量的数据。比如说,假设你有兴趣知道哪个 Citi Bike 站点在给定的 24 小时内租借和归还了最多的自行车。尽管实时的Citi Bike API包含高完整性的数据,但它对于回答哪个 Citi Bike 站点在特定日期看到最大周转量的问题并不合适。在这种情况下,使用Citi Bike“行程历史”数据来回答这个问题会更好。

当然,很少有数据拟合问题能如此简单解决;通常我们必须在能够自信地知道我们的数据集确实适合我们选择的问题或项目之前进行大量的完整性工作。然而,无法绕过这种时间投入:无论是数据完整性还是数据拟合的捷径最终都会损害数据整理工作的质量和相关性。事实上,今天计算系统造成的许多危害都与数据拟合问题有关。例如,使用描述一个现象(如收入)的数据来尝试回答有关一个潜在相关但基本上不同的现象(如教育程度)的问题,可能导致对世界发生情况的扭曲结论,有时带来毁灭性后果。在某些情况下,使用这样的代理测量是不可避免的。例如,基于患者可观察症状的初始医疗诊断可能需要提供紧急治疗,直到更为确定的测试结果可用。然而,尽管在个体层面上有时可以接受这样的代替物,但任何代理测量与真实现象之间的差距随着数据和使用它的系统规模而扩大。当这种情况发生时,我们最终得到的是对我们希望阐明的现实的极度扭曲视图。幸运的是,我们将在第三章中进一步探讨的一些方法可以防止这些类型的错误。

为什么选择 Python?

如果你正在阅读本书,很可能已经听说过 Python 编程语言,并且你甚至可能相当确定它是开始或扩展数据整理工作的正确工具。即使是这种情况,我认为简要回顾一下 Python 特别适合本书中我们将要进行的数据整理和质量工作的原因也是值得的。当然,如果你之前没有听说过 Python,那么请将其视为介绍什么使它成为当今使用中最流行和功能强大的编程语言之一。

多功能性

作为一种通用编程语言,Python 的最大优势之一是其多功能性:可以轻松地用于访问 API、从网络中抓取数据、进行统计分析以及生成有意义的可视化。虽然许多其他编程语言也能完成其中的一些功能,但很少有像 Python 一样在所有这些方面表现得如此出色。

可访问性

Python 创建者 Guido van Rossum 设计语言时的一个目标是制造 “像普通英语一样易于理解的代码”。Python 使用英语关键词,而许多其他脚本语言(如 R 和 JavaScript)使用标点符号。因此,对于英语读者来说,Python 可能比其他脚本语言更容易学习和更直观。

可读性

Python 编程语言的一个核心原则是 “可读性至关重要”。在大多数编程语言中,代码的视觉布局与其功能无关——只要“标点符号”正确,计算机就能理解。相比之下,Python 是一种“依赖空白”的语言:如果没有正确的制表符和/或空格字符缩进代码,它实际上什么也不会做,只会产生一堆错误。虽然这可能需要一些时间来适应,但它确实在 Python 程序中强制实施了一种可读性,这可以使阅读其他人的代码(或更可能是你自己的代码在一段时间后)变得不那么困难。可读性的另一个方面是注释和其他方式记录你的工作,我将在 “文档化、保存和版本管理你的工作” 中详细讨论。

社区

Python 拥有一个非常庞大且活跃的用户社区,其中许多人帮助创建和维护着各种代码“库”,极大地扩展了你可以利用自己的 Python 代码快速实现的功能。例如,Python 拥有像 NumPyPandas 这样流行且成熟的代码库,可以帮助你清理和分析数据,以及像 MatplotlibSeaborn 这样用于创建可视化的库。还有一些强大的库,如 Scikit-LearnNLTK,可以处理机器学习和自然语言处理中的繁重工作。一旦掌握了本书中将介绍的 Python 数据处理的基本要点(我们将使用许多刚提到的库),你可能会发现自己渴望探索这些库可能实现的功能,而这只需几行代码。幸运的是,撰写这些库代码的同行们通常也会写博客文章、制作视频教程,并分享代码示例,这些都可以帮助你扩展你的 Python 工作。

同样,Python 社区的规模和热情意味着很容易找到您可能遇到的常见(甚至不那么常见)问题和错误的答案 —— 通常会在线发布详细的解决方案。因此,与具有较小用户群的更专业的语言相比,调试 Python 代码可能更容易。

Python 的替代方案

尽管 Python 有很多值得推荐的地方,但您可能也在考虑其他工具来满足数据整理的需求。以下是您可能听说过的一些工具的简要概述,以及为什么我选择了 Python 而不是其他工具进行此项工作:

R

R 编程语言可能是 Python 在数据工作方面的最近竞争对手,许多团队和组织都依赖于 R,因为它集数据整理、高级统计建模和可视化功能于一身。同时,R 缺乏 Python 的一些易用性和可读性。

SQL

简单查询语言(SQL)就是那样:一种旨在“切片和切块”数据库数据的语言。虽然 SQL 可以很强大和有用,但它要求数据以特定格式存在才能发挥作用,因此在第一次“整理”数据时使用范围有限。

Scala

尽管 Scala 非常适合处理大型数据集,但与 Python 相比,它的学习曲线要陡峭得多,并且用户社区要小得多。Julia 也是如此。

Java、C/C++

虽然这些工具具有庞大的用户社区,并且非常多功能,但它们缺乏 Python 的自然语言和可读性倾向,更倾向于构建软件而不是进行数据整理和分析。

JavaScript

在基于 web 的环境中,JavaScript 是无价的,并且许多流行的可视化工具(例如 D3)都是使用 JavaScript 的变体构建的。同时,JavaScript 没有像 Python 那样的数据分析功能广泛,并且通常速度较慢。

编写和“运行” Python

要跟着本书的练习进行,您需要熟悉一些工具,这些工具将帮助您编写和运行 Python 代码;您还需要一个备份和文档化代码的系统,以防止因一次错误的按键而丢失宝贵的工作,并且,以便您在很长时间没有看到代码时,也可以轻松地提醒自己所有这些优秀代码能做什么。因为解决这些问题有多种工具集,我建议您先阅读以下部分,然后选择最适合您偏好和资源的方法(或方法组合)。从高层次来看,关键决策将是您是否希望仅“在线”工作 —— 也就是说,使用通过互联网访问的工具和服务 —— 还是您可以并且希望能够在没有互联网连接的情况下进行 Python 工作,这需要在您控制的设备上安装这些工具。

我们在不同的情境下写作时会有不同的风格和结构:你可能在写电子邮件时使用不同的风格和结构,而在发送短信时则不同;在求职信中,你可能会完全采用不同的语气。我知道,我也会根据需要完成的任务使用不同的写作工具:当我需要与同事协作编辑文档时,我会使用在线文档,但在我设备上,我更喜欢使用超简单的文本编辑器写作书籍和论文。更特定的文档格式,比如 PDF,通常用于合同和其他不希望他人轻易更改的重要文档。

就像自然人类语言一样,Python 可以用不同类型的文档来编写,每种文档支持稍微不同的编写、测试和运行代码的风格。Python 文档的主要类型是 notebookstandalone files。虽然任何一种类型的文档都可以用于数据整理、分析和可视化,但它们具有略微不同的优势和要求。由于将一种格式转换为另一种格式需要一些调整,我已经将本书中的练习提供了这两种格式。我这样做不仅是为了让你有选择你认为最容易或最有用的文档类型的灵活性,而且还让你可以比较它们并自行查看翻译过程如何影响代码。以下是这些文档类型的简要概述,以帮助你做出初步选择:

Notebooks

Python notebook 是一个交互式文档,用于运行代码块,使用浏览器窗口作为界面。在本书中,我们将使用一个称为“Jupyter”的工具来创建、编辑和执行我们的 Python notebook。[⁶] 使用 notebook 编程 Python 的一个关键优势是,它们提供了一种简单的方式来在一个地方编写、运行和记录你的 Python 代码。如果你寻求更多“点与点”编程体验,或者完全在线工作对你很重要,你可能会更喜欢 notebook。事实上,同样的 Python notebook 可以在本地设备或在线编码环境中使用,几乎不需要做任何改动,这意味着如果你 (1) 无法访问能够安装软件的设备,或者 (2) 你可以安装软件但也希望在没有机器的情况下工作,这个选项可能适合你。

Standalone files

独立 Python 文件实际上就是包含 Python 代码的任何纯文本文件。你可以使用任何基本文本编辑器创建这样的独立 Python 文件,尽管我强烈建议你使用专门用于编写代码的编辑器,比如 Atom(我将在“安装 Python、Jupyter Notebook 和代码编辑器”中介绍设置过程)。虽然你选择的编写和编辑代码的软件取决于你自己,但通常你唯一能够运行这些独立 Python 文件的地方是在已安装 Python 编程语言的物理设备(如计算机或手机)上。你(以及你的计算机)将能够通过它们的.py文件扩展名识别出独立的 Python 文件。尽管它们起初可能看起来更受限制,独立的 Python 文件也有一些优点。你不需要互联网连接来运行独立文件,它们也不需要你将数据上传到云端。虽然这两个条件在本地运行笔记本时也成立,但你在运行独立文件时也不必等待任何软件启动。一旦安装了 Python,你就可以从命令行立即运行独立的 Python 文件(稍后将详细介绍这一点)——如果你需要定期运行 Python 脚本,这尤其有用。虽然笔记本能够独立运行代码的能力使它们看起来更易接近一些,但独立 Python 文件始终运行你的代码“从头开始”,这有助于你避免如果你以不正确的顺序运行笔记本代码而产生的错误或不可预测的结果。

当然,你不必只选择其中一种;许多人发现笔记本在探索解释数据方面特别有用(感谢它们交互式和用户友好的格式),而独立文件更适合于访问、转换清理数据(因为独立文件可以更快速、更容易地在不同数据集上运行相同的代码,例如)。也许更重要的问题是你想要在线还是本地工作。如果你没有一个可以安装 Python 的设备,你需要在基于云的笔记本上工作;否则,你可以选择在你的设备上使用笔记本或独立文件(或两者兼而有之!)。正如之前所述,本书中的所有练习都可以在线或本地使用笔记本,以便你尽可能灵活地使用,也可以比较在每种情况下完成相同任务的方式!

在自己的设备上使用 Python

要理解和运行 Python 代码,您需要在设备上安装 Python。根据您的设备,可能有可下载的安装文件,或者您可能需要使用一个称为命令行的文本界面(如果您在设备上使用 Python,则必须使用它)。无论哪种方式,目标都是至少让您能够使用 Python 3.9 进行操作。^(7) 安装好 Python 后,您可以继续安装 Jupyter 笔记本和/或代码编辑器(此处的说明适用于 Atom)。如果您计划只在云端工作,您可以直接跳到“使用在线 Python”获取有关如何入门的信息。

从命令行开始

如果您计划在本地设备上使用 Python,您需要学习如何使用命令行(有时也称为终端命令提示符),这是一种通过文本方式向计算机提供指令的方法。虽然原则上您可以用命令行做任何鼠标能做的事情,但它特别适合安装代码和软件(尤其是我们将在本书中使用的 Python 库)以及备份和运行代码。虽然可能需要一点时间来适应,但命令行对于许多与编程相关的任务通常比使用鼠标更快速、更直接。也就是说,我将提供使用命令行和鼠标的说明,两者都可以使用,您可以根据具体任务选择更方便的方式。

要开始,请打开命令行(有时也称为终端)界面,并使用它来为我们的数据整理工作创建一个文件夹。如果您使用的是 Chromebook、macOS 或 Linux 设备,请搜索“终端”,然后选择名为 Terminal 的应用程序;如果是在 Windows PC 上,请搜索“powershell”,然后选择名为 Windows PowerShell 的程序。

提示

要在 Chromebook 上启用 Linux,只需进入 Chrome OS 设置(点击开始菜单中的齿轮图标,或在启动器中搜索“设置”)。在左侧菜单底部,您会看到一个名为“Linux(Beta)”的小企鹅图标。点击它并按照说明启用您的机器上的 Linux。在继续之前,您可能需要重新启动。

一旦打开了终端,现在是创建一个新文件夹的时候了!为帮助您入门,这里是一些有用的命令行术语快速词汇表:

ls

“列出”命令显示当前位置的文件和文件夹。这是一个文本版本,与查找器窗口中看到的相似。

cd foldername

“更改目录”命令将您从当前位置移动到foldername,只要您在使用ls命令时显示了foldername。这相当于在查找器窗口中双击一个文件夹。

cd ../

“改变目录”再次,但是../会将您当前的位置移动到包含的文件夹或位置。

cd ~/

“改变目录”,但~/会返回到您的“主目录”。

mkdir 文件夹名称

用名称为文件夹名称创建目录。这相当于使用鼠标选择新建→文件夹,在其图标出现后命名文件夹。

提示

在使用命令行时,您实际上无需完整输入文件或文件夹的全名;可以将其视为搜索,只需输入前几个(区分大小写的)名称字符。完成后,按 Tab 键,名称将尽可能自动完成。

例如,如果您在一个文件夹中有两个文件,一个叫做xls_parsing.py,另一个叫做xlsx_parsing.py(在您完成第四章后会有这种情况),并且您想要运行后者,您可以输入python xl然后按 Tab 键,这将使命令行自动完成为python xls。在这一点上,由于两个可能的文件名分歧,您需要输入x_,再按 Tab 键一次将完成其余的文件名,然后就可以继续进行了!

每次在设备上打开新的终端窗口时,您将处于被称为“主目录”的位置。在 macOS、Windows 和 Linux 机器上,这通常是“用户”文件夹,与您首次登录时看到的“桌面”区域不同。这一点可能会让人感到有些迷惑,因为当您在终端窗口中首次运行ls时,看到的文件和文件夹可能会很陌生。不用担心;只需通过输入以下内容将您的终端定位到常规桌面:

cd ~/Desktop

到终端并按下 Enter 或 Return 键(为了效率起见,我将在此之后仅称其为 Enter 键)。

在 Chromebook 上,Python(以及我们需要的其他程序)只能从Linux files文件夹内运行,因此您实际上无法导航到桌面,必须打开一个终端窗口。

接下来,在您的终端窗口中输入以下命令并按 Enter 键:

mkdir data_wrangling

您看到文件夹出现了吗?如果是这样,恭喜您在命令行中创建了您的第一个文件夹!如果没有,请仔细检查命令行提示符左侧的文本(在 Chromebook 上为$,在 macOS 上为%,在 Windows 上为>)。如果您没有看到其中的Desktop一词,请运行cd ~/Desktop然后再试一次。

现在您已经在命令行中练习了一点,让我们看看它如何在安装和测试 Python 时有所帮助。

安装 Python、Jupyter Notebook 和代码编辑器

为了简化事务,我们将使用一个名为 Miniconda 的软件分发管理器,它将自动安装 Python 和 Jupyter Notebook。即使您不打算在自己的编码中使用笔记本,由于它们非常受欢迎,因此能够查看和运行其他人的笔记本也很有用,并且不会占用设备太多的额外空间。除了启动您的 Python 和 Jupyter Notebook 工具外,安装 Miniconda 还将创建一个名为conda的新命令行函数,使您能够快速轻松地保持 Python 和 Jupyter Notebook 的更新状态。^(8) 您可以在附录 A 中找到有关如何进行这些更新的更多信息。

如果您计划在笔记本中进行大部分的 Python 编程工作,我仍然建议安装一个代码编辑器。即使您从未使用它们来编写一行 Python 代码,代码编辑器也是查看、编辑甚至创建自己的数据文件比大多数设备内置文本编辑软件更有效和高效的不可或缺的工具。最重要的是,代码编辑器还进行称为语法高亮的操作,这基本上是代码和数据的内置语法检查。虽然这听起来可能不起眼,但实际上,这将使您的编码和调试过程大大加快和更可靠,因为当出现问题时,您会(确切地)知道从哪里查找。这些功能的结合使得一个可靠的代码编辑器成为 Python 编程通用数据处理中最重要的工具之一。

在本书中,我将使用并参考 Atom(https://atom.io)代码编辑器,它是免费、跨平台且开源的。如果您调整设置,您将找到许多方法来定制您的编码环境以满足您的需求。在本书中提到某些字符或代码位的颜色时,它们反映了 Atom 中默认的“One Dark”主题,但请根据您的需要使用任何设置。

注意

您需要一个强大稳定的互联网连接和约 30-60 分钟的时间来完成以下设置和安装过程。我还强烈建议您将设备插入电源。

Chromebook

要在 Chromebook 上安装您的数据处理工具套件,首先您需要知道您的 Chrome OS 操作系统版本是 32 位还是 64 位。

要查找这些信息,请打开 Chrome 设置(点击开始菜单中的齿轮图标,或在启动器中搜索“设置”),然后点击左下角的“关于 Chrome OS”。在窗口顶部附近,您将看到版本号,后面跟着(32-bit)(64-bit),如图 1-1 所示。

Chrome OS 版本详细信息

图 1-1. Chrome OS 版本详细信息

在继续设置之前,请记录这些信息。

安装 Python 和 Jupyter Notebook

要开始,请下载与你的 Chrome OS 版本位数格式匹配的Linux 安装程序。然后,打开你的下载文件夹,并将安装程序文件(以.sh结尾)拖放到Linux 文件文件夹中。

接下来,打开一个终端窗口,运行ls命令,并确保你看到 Miniconda 的.sh文件。如果看到了,请运行以下命令(记住,你可以只输入文件名的开头,然后按 Tab 键,它会自动完成!):

bash _Miniconda_installation_filename_.sh

在你的终端窗口中按照出现的指示(接受许可证和conda init提示),然后关闭并重新打开你的终端窗口。接下来,你需要运行以下命令:

conda init

然后再次关闭并重新打开你的终端窗口,以便通过以下命令安装 Jupyter Notebook:

conda install jupyter

对后续提示选择是,最后关闭你的终端一次,然后你就可以了!

安装 Atom

要在你的 Chromebook 上安装 Atom,你需要从https://atom.io下载.deb包,并将其保存在(或移动到)你的Linux 文件文件夹中。

要使用终端安装软件,请打开一个终端窗口并输入:

sudo dpkg -i atom-amd64.deb

按 Enter 键。^(9) 当文本滚动完成并且命令提示符(以$结束)重新出现时,安装完成。

或者,你可以在Linux 文件文件夹中右键点击.deb文件,并从上下文菜单的顶部选择“用 Linux 安装”,然后点击“安装”和“确定”。你会在屏幕右下角看到一个进度条,并在安装完成时收到通知。

无论你选择哪种方法,安装完成后,你应该会在“Linux 应用”气泡中看到绿色的 Atom 图标。

macOS

在 macOS 上安装 Miniconda 有两种选择:你可以使用终端通过.sh文件安装,或者下载并双击.pkg安装程序进行安装。

安装 Python 和 Jupyter Notebook

要开始,请转到Miniconda 安装程序链接页面。如果想通过终端进行安装,下载以.sh结尾的 Python 3.9 “bash”文件;如果喜欢使用鼠标,下载.pkg文件(在下载过程中,操作系统可能会弹出警告通知,“此类文件可能会损害您的计算机”,请选择“保留”)。

无论你选择哪种方法,打开你的下载文件夹,并将文件拖放到你的桌面上。

如果你想通过终端尝试安装 Miniconda,请先打开一个终端窗口,并使用cd命令将其指向你的桌面:

cd ~/Desktop

接下来,运行ls命令,并确保您在结果列表中看到 Miniconda 的.sh文件。如果看到了,请运行以下命令(记住,您只需键入文件名的开头,然后按 Tab 键,它将自动补全!):

bash _Miniconda_installation_filename_.sh

按照您终端窗口中显示的说明进行操作:

  • 使用空格键逐页浏览许可协议,并在看到(END)时按回车键。

  • 键入yes再按回车键接受许可协议。

  • 按回车键确认安装位置,并键入yes再按回车键接受conda init提示。

最后,关闭您的终端窗口。

如果您更喜欢使用鼠标进行安装,只需双击.pkg文件,然后按照安装说明进行操作。

现在您已经安装了 Miniconda,您需要打开一个新的终端窗口并键入:

conda init

然后按回车键。接下来,关闭并重新打开您的终端窗口,并使用以下命令(后跟回车键)安装 Jupyter Notebook:

conda install jupyter

对后续提示选择是。

安装 Atom

要在 Mac 上安装 Atom,请访问https://atom.io,然后单击大黄色下载按钮下载安装程序。

在您的Downloads文件夹中单击atom-mac.zip文件,然后将 Atom 应用程序(其旁边将有一个绿色图标)拖入Applications文件夹(这可能会提示您输入密码)。

Windows 10+

要在 Windows 10+上安装您的数据整理工具套件,首先需要知道您的 Windows 10 操作系统版本是 32 位还是 64 位。

要找到这些信息,请打开开始菜单,然后选择齿轮图标以进入设置菜单。在弹出的窗口中,选择系统 → 关于在左侧菜单中。在名为“设备规格”的部分中,您将看到“系统类型”,其中将指定您是否拥有 32 位或 64 位系统。有关官方说明,请参阅Microsoft 相关 FAQ

在继续设置之前,请记下这些信息。

安装 Python 和 Jupyter Notebook

要开始,请访问Miniconda 安装程序链接页面,并下载适合您系统的 Python 3.9 安装程序(32 位或 64 位)。一旦.exe文件下载完成,请点击安装程序菜单,保留预选项(您可以跳过推荐的教程和最后的“Anaconda 核心”注册)。

安装完成后,在“最近添加”列表的顶部,你应该会看到两个新项目在你的开始菜单中:“Anaconda Prompt (miniconda3)” 和 “Anaconda Powershell Prompt (miniconda3)” ,如图 图 1-2 所示。虽然两者都适用于我们的目的,但我建议你在本书中使用 Powershell 作为你的“终端”界面。

Anaconda 选项在开始菜单中

图 1-2. Anaconda 选项在开始菜单中

现在你已经安装了 Miniconda,需要打开一个新的终端(Powershell)窗口并输入:

conda init

然后按回车。接下来,按照说明关闭并重新打开你的终端窗口,并使用以下命令(然后按回车)来安装 Jupyter Notebook:

conda install jupyter

回答是(通过输入 y 然后按回车键)来回应后续的提示。

安装 Atom

要在 Windows 10+机器上安装 Atom,请访问 https://atom.io 并点击大黄色的“下载”按钮下载安装程序。

点击 Atom-Setup-x64.exe 文件,^(10) 并等待安装完成;Atom 应该会自动启动。你可以回答蓝色弹出窗口的询问,关于是否注册为默认的 atom:// URI 处理程序,选择是。

测试你的设置

为了确保 Python 和 Jupyter Notebook 都按预期工作,请首先打开一个终端窗口,将其指向你在“命令行入门”中创建的 data_wrangling 文件夹,然后运行以下命令:^(11)

cd ~/Desktop/data_wrangling

然后,运行:

python --version

如果你看到类似这样的内容:

Python 3.9.4

这意味着 Python 安装成功。

接下来,通过运行以下命令来测试 Jupyter Notebook:

jupyter notebook

如果一个浏览器窗口打开^(12),看起来像图 1-3,那么你已经准备好开始了!

Jupyter Notebook 在一个空文件夹中运行

图 1-3. Jupyter Notebook 在一个空文件夹中运行

在线使用 Python

如果你想跳过在你的机器上安装 Python 和代码编辑器的麻烦(并且你计划只在有强大,稳定的互联网连接时使用 Python),通过 Google Colab 在线使用 Jupyter 笔记本是一个很好的选择。你只需要一个无限制的 Google 账户即可开始(如果你愿意,可以创建一个新的——确保你知道你的密码!)。如果这些要素都准备好了,你就可以开始与 “Hello World!” 玩耍了!

你好,世界!

现在你已经准备好使用你的数据整理工具,可以开始编写和运行你的第一个 Python 程序了。为此,我们将遵循编程传统,创建一个简单的“Hello World”程序;它的设计目的只是打印出“Hello World!”这几个词。要开始,你需要一个新的文件,可以在其中编写和保存你的代码。

使用 Atom 创建一个独立的 Python 文件

Atom 就像任何其他文本编辑程序一样工作;你可以使用鼠标启动它,甚至可以使用终端启动它。

要使用鼠标启动它,请在设备上找到程序图标:

Chromebook

在“Linux 应用”应用程序泡泡内。

Mac

应用程序或在 Mac 的启动台中。

Windows

在 Windows 的“开始”菜单或通过搜索启动。如果在 Windows 10 上第一次安装 Atom 后,在“开始”菜单或搜索中找不到 Atom,你可以在 YouTube 上找到故障排除视频

或者,你可以通过在终端中运行以下命令来打开 Atom:

atom

当你在 Chromebook 上首次打开 Atom 时,你会看到“为新钥匙环选择密码”的提示。由于我们只会使用 Atom 进行代码和数据编辑,你可以点击取消关闭此提示。在 macOS 上,你会看到一个警告,Atom 是从互联网下载的——你也可以忽略此提示。

现在你应该看到一个类似于图 1-4 中显示的屏幕。

默认情况下,当 Atom 启动时,会显示一个或多个“欢迎”选项卡;你可以通过将鼠标悬停在文本上方并在右侧显示的x关闭按钮上单击来关闭这些选项卡。这将把未命名文件移到屏幕中央(如果你愿意,你也可以通过将鼠标悬停在其右边缘直到出现<,然后单击它来折叠左侧的项目面板)。

Atom 欢迎界面

图 1-4. Atom 欢迎界面

在我们开始编写任何代码之前,让我们先保存文件,这样我们就知道在哪里找到它——在我们的data_wrangling文件夹中!在“文件”菜单中,选择“另存为…”,并将文件保存在你的data_wrangling文件夹中,文件名为HelloWorld.py

小贴士

当保存独立的 Python 文件时,确保添加.py扩展名是非常重要的。虽然你的 Python 代码没有这个扩展名也能正常工作,但是正确的扩展名将允许 Atom 执行我在“安装 Python、Jupyter Notebook 和代码编辑器”中提到的非常有用的语法高亮功能。这个功能将使你第一次就更容易写出正确的代码!

使用 Jupyter 创建新的 Python 笔记本

你可能已经注意到,当你测试 Jupyter Notebook 时,在“测试你的设置”中,你使用的界面实际上只是一个常规的浏览器窗口。信不信由你,当你运行jupyter notebook命令时,你的常规计算机实际上在设备上创建了一个微型 Web 服务器!^(13)一旦主 Jupyter 窗口启动并运行,你可以使用鼠标在你的 Web 浏览器中创建新的 Python 文件和运行其他命令!

要开始,请打开一个终端窗口并使用以下命令:

cd ~/Desktop/data_wrangling/

移动到你的桌面上的data_wrangling文件夹。接下来,运行:

jupyter notebook

你会看到在终端窗口上运行过的许多代码,并且你的计算机应该会自动打开一个浏览器窗口,显示一个空目录。在右上角的“新建”下选择“Python 3”以打开一个新的笔记本。在 Jupyter 标志旁边的左上角双击Untitled来命名你的文件为HelloWorld

警告

因为 Jupyter Notebook 实际上在你的本地计算机上运行一个 Web 服务器(是的,与常规网站运行的相同类型),所以你需要保持该终端窗口打开并运行,只要你在与笔记本交互。如果关闭该特定终端窗口,你的笔记本将“崩溃”。

幸运的是,Jupyter Notebook 每两分钟自动保存一次,因此即使发生崩溃,你可能也不会丢失太多工作。话虽如此,你可能会希望最小化你用来启动 Jupyter 的终端窗口,以免在工作时意外关闭它。

使用 Google Colab 创建一个新的 Python 笔记本

首先,登录你要用于数据整理工作的 Google 帐户,然后访问Colab 网站。你将看到类似于图 1-5 所示的叠加层。

Google Colab 登录页面(已登录)

图 1-5. Google Colab 登录页面(已登录)

在右下角选择新笔记本,然后在左上角双击以替换Untitled0.ipynbHelloWorld.ipynb。^(14)

添加代码

现在,我们将编写我们的第一段代码,用于打印出“Hello World”这几个字。无论你使用哪种类型的 Python 文件,示例 1-1 中显示的代码都是一样的。

示例 1-1. hello_world.py
# the code below should print "Hello World!"
print("Hello World!")

在独立文件中

你只需复制(或键入)示例 1-1 中的代码到你的文件中并保存即可!

在笔记本中

创建新文件时,默认情况下会有一个空的“代码单元格”(在 Jupyter Notebook 中,你会看到左侧的In [ ];在 Google Colab 中,有一个小的“播放”按钮)。复制(或键入)示例 1-1 中的代码到该单元格中。

运行代码

现在我们已经添加并保存了 Python 代码到我们的文件中,我们需要运行它。

在独立文件中

打开一个终端窗口,并使用以下命令将其移动到你的data_wrangling文件夹中:

cd ~/Desktop/data_wrangling

运行ls命令,确保你的HelloWorld.py文件在响应中列出。最后运行:

python HelloWorld.py

你应该会看到这几个字Hello World!打印在自己的一行上,然后命令提示符返回(表示程序已经运行完毕)。

在笔记本中

点击单元格左侧的“播放”按钮。你会看到在其下打印出Hello World!这几个字。

如果一切都如预期般工作——恭喜!你现在已经写下了你的第一段 Python 代码!

记录、保存和版本化你的工作

在我们真正深入讨论 Python 在第二章中之前,还有一些准备工作要做。我知道这些可能看起来很乏味,但确保你已经为正确记录工作奠定了基础将为你节省数十个小时的努力和挫折。更重要的是,仔细注释、保存和版本控制你的代码是“防弹”数据整理工作的关键部分。虽然现在可能并不那么吸引人,但很快所有这些步骤都将成为第二天性(我保证!),你会看到它们为你的数据工作增加了多少速度和效率。

文档记录

你可能已经注意到,在你的代码单元格或 Python 文件中的第一行中写的内容在“Hello World!” 没有显示在输出中;只有Hello World!被打印出来了。我们文件中的第一行是一个注释,它提供了对接下来代码行(行)将做什么的简明语言描述。几乎所有编程语言(和一些数据类型!)都提供了一种包括注释的方法,正是因为它们是为任何阅读你代码的人提供上下文和解释所必需的出色方式^(15)。

尽管许多个人程序员倾向于忽视(即:跳过)注释过程,但这可能是你可以养成的单个最有价值的编程习惯。当你查看 Python 程序时,它不仅会为你和任何你合作的人节省大量时间和精力,而且注释也是真正内化你对编程更广泛学习的最佳方式。因此,即使这本书提供的代码示例已经有注释,我强烈建议你用自己的话重新编写它们。这将有助于确保将来再次打开这些文件时,它们将包含对你第一次理解每个特定编码挑战的清晰解释。

数据整理的另一个重要文档过程是保留我称之为“数据日记”。就像个人日记一样,你的数据日记可以按照你喜欢的方式书写和组织;关键是在做事的时候记录下你正在做的事情。无论你是在网上寻找数据、给专家发邮件还是设计程序,你都需要一个地方来记录所有事情,因为你肯定会忘记。

任何数据整理项目中“日记”的第一条目应该是您试图回答的问题。虽然这可能是一项挑战,但请尝试将您的问题写成一个句子,并将其放在数据整理项目日记的顶部。为什么重要的是您的问题必须是一个句子?因为真正的数据整理过程将不可避免地使您沉迷于足够多的“兔子洞”——例如回答有关数据来源的问题,或解决某些编程问题——这很容易让您忘记最初试图完成的任务(及其原因)。但一旦您在数据日记的顶部有了这个问题,您随时可以回到这个问题来进行提醒。

您的数据日记问题对于帮助您在数据整理时决定如何分配时间也将非常宝贵。例如,您的数据集可能包含对您不熟悉的术语——您应该尝试追踪每一个单词的含义吗?是的,如果这样做有助于回答您的问题。如果不是,可能是时候转向另一个任务了。

当然,一旦您成功回答了您的问题(而且您会成功的!至少部分成功),您几乎肯定会发现您有更多问题想要回答,或者您想要在一周、一个月或一年后再次回答同样的问题。随手拿出您的数据日记作为指南将帮助您快速且更轻松地下次完成。这并不是说这不需要努力:根据我的经验,保持一个详尽的数据日记会使得项目第一次完成时间延长约 40%,但在下一次(例如使用数据集的新版本)时,完成速度至少会快两倍。拥有数据日记还是一种有价值的工作证明:如果您曾试图了解数据整理结果的过程,您的数据日记将包含您(或任何其他人)可能需要的所有信息。

然而,关于如何保持您的数据日记,真的取决于您自己。有些人喜欢进行大量的花式格式化;其他人只使用简单的纯文本文件。您甚至可能想使用真正的纸质笔记本!对您有效的任何方法都可以。当您需要与他人沟通您的数据(及整理过程)时,您的数据日记将是一份宝贵的参考,您应该按照最适合您的方式进行组织。

保存

除了通过注释和数据日记仔细记录您的工作外,您还需要确保定期保存。幸运的是,“保存”过程基本上已内置到我们的工作流程中:笔记本会定期自动保存,并且要运行我们独立文件中的代码,我们必须首先保存更改。无论您是依赖键盘快捷键(对我来说,按下 Ctrl+S 几乎成了一种紧张的习惯)还是使用鼠标驱动的菜单,您可能至少每 10 分钟就想保存一次工作。

小贴士

如果你在使用独立文件,需要熟悉的一件事是你的代码编辑器如何指示文件有未保存的更改。例如,在 Atom 中,当文件有未保存的更改时,文档标签右侧会出现一个小彩点。如果你运行的代码表现不符合你的预期,请先确认是否已保存,然后再尝试一次。

版本控制

编程——就像大多数写作一样——是一个迭代的过程。我的首选方法一直是先写一点代码,测试一下,如果有效,再写一点并再次测试。这种方法的一个目标是使得在不小心“破坏”代码的情况下更容易回退。^(16)

同时,当你不得不离开代码时,无法保证代码一定“运行”——无论是因为孩子回家了、学习休息结束,还是该睡觉了。你总想要有一个“安全”的代码副本,以备不时之需。这就是版本控制的作用所在。

开始使用 GitHub

版本控制基本上只是一个备份系统,可以在你的计算机和云端备份你的代码。在本书中,我们将使用 GitHub 进行版本控制;这是一个非常流行的网站,你可以免费备份你的代码。尽管与 GitHub 互动的方式有很多种,但我们将使用命令行,因为只需几个快速的命令,你的代码就可以安全地存储,等待下次继续工作。要开始使用,你需要在 GitHub 上创建一个账号,在计算机上安装 Git,然后将这些账号连接起来:

  1. 访问 GitHub 网站 https://github.com,并点击“Sign Up”。输入你喜欢的用户名(可能需要尝试几个才能找到一个可用的),你的电子邮箱地址,以及选择的密码(确保记下来或保存到密码管理器中——很快你会需要它!)。

  2. 登录后,点击左侧的“New”按钮。这将打开如下所示的“创建新仓库”页面,详见图 1-6。

  3. 给你的仓库起一个名字。这可以是任何你喜欢的东西,但我建议你使用描述性的名称,比如 data_wrangling_exercises

  4. 选择“Private”单选按钮,并选中旁边的“Add a README file”选项前面的复选框。

  5. 点击“Create repository”按钮。

在 GitHub.com 上创建新仓库(或“repo”)

图 1-6. 在 GitHub.com 上创建新仓库(或“repo”)

现在您将看到一个页面,显示 data_wrangling_exercises 以大号字体展示,右上方有一个小铅笔图标。点击铅笔图标,您将看到一个编辑界面,在此您可以添加文本。这是您的README文件,您可以用它来描述您的仓库。因为我们将使用此仓库(或简称“repo”)来存储此书中的练习,您可以只添加一句说明,如 Figure 1-7 所示。

滚动到页面底部,您将看到您的个人资料图标,右侧有一个可编辑区域,显示“提交更改”,在其下方是一些默认文本,显示“更新 README.md”。将该默认文本替换为您所做内容的简要描述;这就是您的“提交消息”。例如,我写道:“添加仓库内容描述”,如 Figure 1-8 所示。然后点击“提交更改”按钮。

在 GitHub.com 更新 README 文件

Figure 1-7. 在 GitHub 上更新README文件

添加提交消息到 README 文件更改

Figure 1-8. 添加提交消息到 README 文件更改

屏幕刷新后,您将看到添加到主文件下 data_wrangling_exercises 标题下的文本。在这之上,您应该能看到您的提交消息文本,以及自您点击“提交更改”以来经过的大约时间。如果您点击右侧标有“2 次提交”的文字,您将看到“提交历史”,其中显示了到目前为止(仅有两次)对该仓库进行的所有更改,如 Figure 1-9 所示。如果您想查看提交如何改变特定文件,请点击右侧的六位字符代码,您将看到所谓的差异(即“difference”)视图文件。左侧是提交前文件的版本,右侧是此提交文件的版本。

我们新仓库的简要提交历史。

Figure 1-9. 我们新仓库的简要提交历史

到此为止,您可能会想知道这与备份代码有何关系,因为我们所做的只是点击一些按钮并编辑一些文本。现在我们在 GitHub 上启动了一个“repo”,我们可以在本地计算机上创建其副本,并使用命令行对工作代码进行“提交”,并通过几个命令将其备份到此网站。

对于本地文件备份:安装和配置 Git

就像 Python 本身一样,Git 是您在计算机上安装并通过命令行运行的软件。由于版本控制是大多数编码过程中不可或缺的部分,Git 在 macOS 和 Linux 上是内置的;Windows 机器的安装说明可以在GitHub 上找到,对于 ChromeBook,您可以使用Termux 应用安装 Git。完成必要步骤后,打开终端窗口并输入:

git --version

然后按‘enter’。如果任何东西打印出来,说明您已经有了 Git!然而,您仍然需要通过运行以下命令来设置用户名和电子邮件(您可以使用任何喜欢的名称和电子邮件):

git config --global user.email *your_email@domain.com*
git config --global user.name *your_username*

现在您已经安装了 Git 并向本地 Git 账户添加了您喜欢的名称和电子邮件,需要在设备上创建身份验证密钥,以便在备份代码时,GitHub 知道这确实是来自您的(而不仅仅是来自世界另一端猜出您用户名和密码的人)。

要做到这一点,您需要创建所谓的SSH 密钥 —— 这是存储在您设备上的一长串唯一字符,GitHub 可以用它来识别您的设备。在命令行上创建这些密钥很容易:只需打开终端窗口并输入:

ssh-keygen -t rsa -b 4096 -C "*your_email@domain.com*"

当您看到“输入要保存密钥的文件”提示时,只需按 Enter 或 Return 键,以保存默认位置(这样稍后当我们想要将其添加到 GitHub 时会更容易找到)。当您看到以下提示时:

Enter passphrase (empty for no passphrase):

一定要添加一个密码!不要把它作为您的 GitHub(或任何其他)账户的密码。然而,由于您每次想要将代码备份到 GitHub 时都需要提供此密码,^(17)它需要是易于记忆的 —— 您可以尝试使用您喜欢的歌曲或诗歌第二段的前三个单词,例如。只要它至少有 8-12 个字符长,您就可以设置好了!

一旦您再次输入确认密码,您就可以将密钥复制到您的 GitHub 账户上;这将使 GitHub 能够将您账户上的密钥与设备上的密钥匹配。要做到这一点,请首先点击 GitHub 右上角的个人资料图标,从下拉菜单中选择“Settings”。然后,在左侧导航栏中,点击“SSH and GPG Keys”选项。在右上角,点击“New SSH key”按钮,如图 Figure 1-10 所示。

GitHub.com 上的 SSH 密钥页面。

图 1-10. GitHub.com 上的 SSH 密钥页面

要访问刚生成的 SSH 密钥,您需要导航到设备上的主用户文件夹(这是新终端窗口将打开的文件夹),并设置它(临时)以显示隐藏文件:

Chromebook

你的主用户文件夹实际上就是名为Linux files的文件夹。要显示隐藏文件,只需单击任何文件窗口右上角的三个堆叠点,并选择“显示隐藏文件”。

macOS

使用 Command-Shift-.键盘快捷键来显示/隐藏隐藏文件。

Windows

在任务栏上打开文件资源管理器,然后选择“查看” → “选项” → “更改文件夹和搜索选项”。在“高级设置”中的“视图”选项卡中,选择“显示隐藏文件、文件夹和驱动器”,然后点击“确定”。

找到一个文件夹(它实际上是一个文件夹!)叫做.ssh,点击进入它,然后使用基本文本编辑器(如 Atom),打开名为id_rsa.pub的文件。使用键盘选择并复制文件中的所有内容,然后将其粘贴到标有“密钥”的空文本区域中,如图 1-11 所示。

上传你的 SSH 密钥到你的 GitHub.com 账户。

图 1-11. 将你的 SSH 密钥上传到你的 GitHub.com 账户

最后,给这个密钥起一个名字,这样你就知道它与哪个设备相关联,并点击“添加新 SSH 密钥”按钮——你可能需要重新输入你的主 GitHub 密码。就这样!现在你可以继续保持隐藏文件的隐藏状态,并完成将你的 GitHub 帐户连接到你的设备和/或 Colab 帐户的过程。

提示

我建议使用键盘快捷键复制/粘贴你的 SSH 密钥,因为确切的字符串(包括空格)实际上是有影响的;如果你使用鼠标,可能会出现拖动的情况。然而,如果你粘贴你的密钥而 GitHub 报错了,有几件事情可以尝试:

  • 确保你上传的是.pub文件的内容(你实际上不想对另一个文件做任何操作)。

  • 关闭文件(不保存)然后重试。

如果你仍然遇到问题,你可以直接删除整个.ssh 文件夹并生成新的密钥——因为它们还没有添加到任何地方,所以重新开始并没有损失!

把这一切联系到一起

我们的最后一步是在我们的本地计算机上创建 GitHub 仓库的链接副本。这可以通过git clone命令轻松完成:

  1. 打开一个终端窗口,导航到你的data_wrangling文件夹。

  2. 在 GitHub 上,转到your_github_username/data_wrangling_exercises

  3. 仍在 GitHub 上,点击页面顶部的“Code”按钮。

  4. 在“使用 SSH 克隆”弹出窗口中,点击 URL 旁边的小剪贴板图标,如图 1-12 所示。

    检索回购的 SSH 位置。

    图 1-12. 检索回购的 SSH 位置
  5. 回到你的终端窗口,输入git clone,然后粘贴从剪贴板中的 URL(或者如果需要,直接输入)。它看起来可能是这样的:

    git clone git@github.com:susanemcg/data_wrangling_exercises.git
    
  6. 你可能会收到一个提示,问你是否要将目标添加到已知主机列表中。输入yes并按回车键。如果提示,输入你的 SSH 密码。

  7. 看到“完成”消息后,输入ls。现在你应该在data_wrangling文件夹中看到data_wrangling_exercises

  8. 最后,输入cd data_wrangling_exercises并按 Enter 键将终端移动到复制的仓库中。使用ls命令让终端显示README.md文件。

哇!这可能看起来很多,但请记住,您只需创建一次 SSH 密钥,并且每个仓库只需克隆一次(本书中的所有练习都可以在同一个仓库中完成)。

现在让我们通过将我们的 Python 文件添加到我们的仓库中来看看这一切是如何运作的。在 Finder 窗口中,导航到您的data_wrangling文件夹。保存并关闭您的HelloWorld.pyHelloWorld.ipynb文件,并将其拖放到data_wrangling_exercises文件夹中。回到终端,使用ls命令确认您看到您的 Python 文件。

我们的最后一步是使用add命令告知 Git 我们希望我们的 Python 文件成为备份到 GitHub 的一部分。然后我们将使用commit保存当前版本,接着使用push命令实际上传到 GitHub。

要做到这一点,我们将从终端窗口中运行git status开始。这应该生成一个提到“未跟踪文件”的消息,并显示您的 Python 文件的名称。这正是我们预期的(但运行git status是确认的好方法)。现在我们将执行之前描述的添加、提交和推送过程。请注意,add命令会在终端中产生输出消息:

  1. 在终端中运行git add your_python_filename

  2. 然后运行git commit -m "添加我的 Hello World Python 文件。" your_python_filename-m命令指示使用引号中的文本作为提交消息,相当于我们刚才在 GitHub 上输入的README更新的命令行等价物。

  3. 最后,运行git push

最后的命令是上传您的文件到 GitHub(请注意,如果您没有可用的互联网连接,这显然不起作用,但您可以随时进行提交并在再次连接互联网时运行push命令)。确认一切工作正常后,重新加载 GitHub 仓库页面,您将看到已添加您的 Python 文件和提交消息!

用于在线备份 Python 文件:将 Google Colab 连接到 GitHub

如果您正在进行所有的数据整理工作,您可以直接将 Google Colab 连接到您的 GitHub 账户。确保您已登录到您的数据整理 Google 账户,然后访问https://colab.research.google.com/github。在弹出窗口中,它将要求您登录到您的 GitHub 账户,然后“授权 Colaboratory”。一旦您这样做了,您可以从左侧的下拉菜单中选择一个 GitHub 仓库,该仓库中的任何 Jupyter 笔记本将显示在下方。

注意

查看你的 GitHub 仓库在 Google Colab 上的视图将仅显示 Jupyter 笔记本(以 .ipynb 结尾的文件)。要查看仓库中的所有文件,您需要访问 GitHub 网站。

将所有这些联系起来

如果你正在使用 Google Colab,在 GitHub 仓库中添加新文件的方法是选择 文件 → 在 GitHub 中保存副本。在自动打开和关闭几个弹出窗口后(这是 Colab 在后台登录到您的 GitHub 帐户),您将再次可以从左上角的下拉菜单中选择要将文件保存到的 GitHub 仓库。然后,您可以选择保留(或更改)笔记本名称并添加提交消息。如果您在此窗口中勾选“包括到 Colaboratory 的链接”,那么 GitHub 中的文件将包含一个小的“在 Colab 中打开”的标签,您可以点击它来自动从 GitHub 中打开笔记本到 Colab 中。这种方式没有明确备份到 GitHub 的任何笔记本将位于您的 Google Drive 中,名为Colab Notebooks的文件夹中。您也可以通过访问Colab 网站并在顶部选择 Google Drive 选项卡来找到它们。

结论

本章的目标是为您提供本书中可以期待学到的内容的概述:我所说的数据整理和数据质量的含义,以及为什么我认为 Python 编程语言是这项工作的正确工具。

此外,我们还介绍了所有设置,让您可以开始(并继续!)使用 Python 进行数据整理,通过提供设置您选择的编程环境的说明:在您自己的设备上使用“独立”Python 文件或 Jupyter 笔记本,或者使用 Google Colab 在线使用 Jupyter 笔记本。最后,我们还介绍了如何使用版本控制(无论您选择哪种设置)来备份、分享和记录您的工作。

在下一章中,我们将远离我们的“Hello World”程序,逐步学习 Python 编程语言的基础知识,甚至开始我们的第一个数据整理项目:纽约市 Citi Bike 系统的一天。

^(1) 我们将分别在第四章和第五章详细讨论这些内容。

^(2) 在计算机领域,这通常被表达为“垃圾进/垃圾出”。

^(3) 披露:许多 ProPublica 员工,包括本系列的首席记者,都是我的前同事。

^(4) “机器偏见”系列在学术界引发了广泛的辩论,一些人对 ProPublica 对偏见的定义持有异议。然而,更重要的是,这场争议催生了一个全新的学术研究领域:机器学习和智能中的公平与透明度。

^(5) 记住,即使一个错位的空格字符在 Python 中也可能会导致问题。

^(6) 这款软件还可以用于创建 R 和其他脚本语言的笔记本。

^(7) 这里的数字称为版本号,它们随着 Python 语言的变更和升级逐步增加。第一个数字(3)表示“主要”版本,第二个数字(9)表示“次要”版本。与常规的十进制不同,次要版本可以高于 9,所以未来您可能会遇到 Python 3.12。

^(8) Miniconda 是流行软件“Anaconda”的较小版本,但由于后者安装了 R 编程语言和其他一些我们不需要的项目,我们将使用 Miniconda 来节省设备空间。

^(9) 如果您使用的是 32 位 Chromebook,文件名可能会有所不同。

^(10) 如果您使用的是 32 位系统,安装程序的文件名可能会有所不同。

^(11) 除非另有说明,所有终端命令都应该在按下 Enter 或 Return 键之后执行。

^(12) 如果您看到提示询问如何“打开此文件”,我建议选择 Google Chrome。

^(13) 别担心,它不会在互联网上可见!

^(14) Jupyter Notebook 的早期版本被称为“iPython Notebook”,这就是.ipynb文件扩展名的来源。

^(15) 尤其是“未来的你”!

^(16) 这意味着我不再得到预期的输出,或者得到错误信息而没有任何输出!

^(17) 根据您的设备,您可以将此密码保存到您的“钥匙链”中。更多信息请参见Github 上的文档

第二章:Python 简介

您能读懂这段话吗?如果可以,我有个好消息告诉您:学习编程不会有任何困难。为什么这么说?因为总体而言,计算机编程语言—特别是 Python 语言—比起自然人类语言要简单得多。编程语言是由人类设计的,主要是为了让计算机阅读,因此它们的语法更简单,所涉及的“语法成分”要比自然语言少得多。所以,如果您对阅读英语感到相对舒适—这是以其庞大词汇量、不规则拼写和发音而闻名的语言—那么学习 Python 基础是完全可以做到的。

到本章末尾时,您将掌握所有必要的 Python 技能,可以开始使用我们将在第四章中介绍的常见数据格式进行基本数据处理。为了达到这一目标,我们将从一些基本的编码练习开始,涵盖以下内容:

  • Python 基础语法及其基本语法/句法要点

  • 计算机如何读取和解释您的 Python 代码

  • 如何使用他人(以及您自己!)编写的代码“配方”快速扩展您自己代码的功能

本章节中,您会找到演示每个概念的代码片段,这些代码片段也在附带的 Jupyter 笔记本和独立的 Python 文件中收集在 GitHub 上;这些可以被导入 Google Colab 中,或者下载到计算机上运行。虽然这些文件能让您看到本章的代码实际运行,但是我强烈建议您创建一个新的 Colab/Jupyter 笔记本或者独立的 Python 文件,自己动手练习编写、运行和评论这些代码(如果需要回顾如何操作,请参见“Hello World!”)。虽然您可能觉得这有些愚蠢,但实际上对于提升数据处理技能和自信心来说,没有比从零开始设置 Python 文件,然后看到自己编写的代码让计算机按您的意愿执行更有用的了,即使只是“简单地”从另一个文件中重新输入。是的,这样做可能会遇到更多问题,但这正是关键所在:良好的数据处理不仅仅是学会如何“正确”地做事情,更是学会在事情出错时如何恢复。在早期给自己留些小错误的空间(以及学会如何识别和修正它们),这才是您作为数据处理员和程序员真正进步的方式。如果您只是运行已经运行的代码,您将永远无法获得那种经验。

因为这些小错误如此重要,我在本章中包含了一些“快速前进”部分,这些部分将为您提供将我提供的代码示例推向更高级的方法,这通常涉及有意“破坏”代码。通过本章结束时,您将准备好将我们涵盖的基础知识结合到依赖于真实世界数据的完整数据整理程序中。对于每个示例,我还将包括一些明确的提醒,让您了解您希望在数据日记中包含的内容,何时需要将您的代码备份到 GitHub 等等,这样您就可以开始真正熟悉这些过程,并开始发展一种感觉,了解何时需要在自己的数据整理项目中采取这些步骤。

现在您知道我们的目标在哪里了——让我们开始吧!

编程语言的“词类”

不同的人类语言使用不同的词汇和语法结构,但它们通常共享许多基本概念。例如,让我们看看以下两个句子:

My name is Susan.          // English
Je m'appelle Susan.        // French

这两个句子基本表达了同样的意思:它们陈述了我的名字是什么。尽管每种语言使用不同的词汇和略有不同的语法结构,但它们都包括像主语、宾语、动词和修饰语等词类。它们还都遵循类似的语法和语法规则,以组织单词和思想为句子、段落等结构。

许多编程语言也分享与自然语言类似的关键结构和组织元素。为了更好地理解这一点,让我们从学习新的人类语言时常用的方式开始:探索编程语言的“词类”。

名词 ≈ 变量

在英语中,名词通常被描述为任何指代“人、地方或事物”的词。尽管这不是一个非常精确的定义,但这是一个便于说明名词可以是不同类型实体的一种方便方法。在编程语言中,变量用于保存和引用我们编写程序时使用的不同数据类型。然而,与“人、地方和事物”不同的是,在 Python 编程语言中,变量可以是五种主要的数据类型之一:

  • 数字

  • 字符串

  • 列表

  • 字典(dict)

  • 布尔

与人类语言类似,不同编程语言支持的变量类型存在许多重叠:例如,在 Python 中我们称之为列表的东西在 JavaScript 或 C 中称为数组;另一方面,JavaScript 中的对象在 Python 中正式称为映射(或字典)^(1)

在阅读数据类型列表后,你可能已经能猜到其中至少一些数据类型的样子。好消息是,与现实世界中的名词不同,Python 中的每种数据类型都可以通过其格式和标点符号可靠地识别——因此,只要你仔细看待围绕数据的符号,就不用担心会将dictslists搞混。

要感受每种数据类型独特的标点结构,请看一下示例 2-1。特别是,请确保在你的代码编辑器或笔记本中打开或复制此代码,这样你就可以看到语法高亮的效果。例如,数字应该是一个颜色,而字符串,方括号、花括号以及注释(以#开头的行)应该是另一种颜色。

示例 2-1. parts_of_speech.py
 # a number is just digits
 25

 # a string is anything surrounded by matching quotation marks
 "Hello World"

 # a list is surrounded by square brackets, with commas between items
 # note that in Python, the first item in a list is considered to be
 # in position `0`, the next in position `1`, and so on
 ["this","is",1,"list"]

 # a dict is a set of key:value pairs, separated by commas and surrounded
 # by curly braces
 {"title":"Practical Python for Data Wrangling and Data Quality",
  "format": "book",
  "author": "Susan E. McGregor"
 }

 # a boolean is a data type that has only two values, true and false.
 True

当然,这个列表远非详尽无遗;就像人类语言支持“复杂名词”(比如“理发”和“卧室”)一样,编程语言也能构建更复杂的数据类型。然而,正如你很快会看到的那样,即使只有这几种基本类型,我们也能做很多事情。

在现实世界中,我们通常也会给我们生活中许多独特的“人、地、物”取名,以便更容易地引用和交流。在编程中,我们也是如此,原因正是一样的:给变量命名让我们能以计算机能理解的方式引用和修改特定的数据。为了看看这是如何运作的,让我们尝试将一个简单的英文句子翻译成 Python 代码:

The author is Susan E. McGregor

在阅读这句话后,你将会将“Susan E. McGregor”与标签“作者”关联起来。如果有人问你谁写了这本书,你(希望的话)会记住这个并说“Susan E. McGregor”。在 Python 代码中,相应的“句子”如示例 2-2 所示。

示例 2-2. 命名 Python 变量
author = "Susan E. McGregor"

这段代码告诉计算机在内存中留出一个盒子,标记为author,然后将字符串"Susan E. McGregor"放入该盒子。在程序的后续部分,如果我们询问计算机关于author变量,它会告诉我们它包含字符串"Susan E. McGregor",如示例 2-3 所示。

示例 2-3. 打印 Python 变量的内容
# create a variable named author, set its contents to "Susan E. McGregor"
author = "Susan E. McGregor"

# confirm that the computer "remembers" what's in the `author` variable
print(author)

名字中有什么含义?

在示例 2-3 中,我选择将我的变量命名为author,但这个选择并没有什么神奇之处。原则上,你几乎可以按照任何你想要的方式命名变量——唯一的“硬性规则”是变量名不能:

  • 以数字开头

  • 包含除了下划线(_)之外的标点符号

  • 是“保留”字或“关键字”(比如 Number 或 Boolean,例如)

例如,在 示例 2-3 中,我也可以称变量为 nyc_resident 或者甚至 fuzzy_pink_bunny。最重要的是,作为程序员的你,遵循前面列出的一些限制,并且在稍后访问其内容时使用完全相同的变量名(大小写敏感!)。例如,创建一个包含 示例 2-4 代码的新 Python 文件,然后运行它查看结果。

示例 2-4. noun_examples.py
# create a variable named nyc_resident, set its contents to "Susan E. McGregor"
nyc_resident = "Susan E. McGregor"

# confirm that the computer "remembers" what's in the `nyc_resident` variable
print(nyc_resident)

# create a variable named fuzzyPinkBunny, set its contents to "Susan E. McGregor"
fuzzyPinkBunny = "Susan E. McGregor"

# confirm that the computer "remembers" what's in the `fuzzyPinkBunny` variable
print(fuzzyPinkBunny)

# but correct capitalization matters!
# the following line will produce an error
print(fuzzypinkbunny)

变量命名的最佳实践

虽然在 示例 2-4 中使用的所有示例都是合法的变量名,但并非所有都是特别的变量名。正如我们在本书中将看到的那样,写好代码——就像任何其他写作一样——不仅仅是编写“可运行”的代码;它还关乎代码对计算机和人类的有用性和可理解性。因此,我认为良好命名变量是良好编程的重要组成部分。实际上,的变量名包括:

  • 描述性的

  • 在给定文件或程序中唯一的

  • 可读性强的

因为实现前两个属性通常需要使用多个单词,程序员通常使用两种风格约定来确保其变量名也保持可读性,这两种风格都显示在 示例 2-4 中。一种方法是在单词之间添加下划线 (_)(例如,nyc_residents),或者使用“驼峰命名法”,其中每个单词的首字母(除了第一个)大写(例如,fuzzyPinkBunny)。通常情况下,你应该选择一种风格并坚持使用它,尽管即使混合使用它们,你的代码也将正常工作(并且大多数情况下会满足可读性标准)。在本书中,我们将主要使用下划线,这也被认为更符合“Python 风格”。

动词 ≈ 函数

在英语中,动词通常被描述为“动作”或“存在状态”。我们已经看到了后者的编程语言等价物:等号 (=) 和在前面示例中使用的 print() 函数。在英语中,我们使用“to be”动词的形式来描述某物什么;在 Python(以及许多其他编程语言中),变量的值等号右侧出现的内容。这也是为什么等号有时被描述为赋值操作符的原因。

在编程中,“动作动词”的等价物是函数。在 Python 和许多其他编程语言中,有内置函数,它们表示语言“知道如何执行”的任务,例如通过print()函数输出结果。虽然类似,方法是专门设计用于特定数据类型并且需要在该数据类型的变量上“调用”的特殊函数以便工作。给定数据类型的可用方法往往反映了您可能希望执行的常见任务。因此,正如大多数人能够行走、说话、吃东西、喝水和抓取物体一样,大多数编程语言都有字符串方法,可以执行像将两个字符串粘合在一起(称为连接)或将两个字符串分开等任务。但由于在数字 5 上“拆分”没有意义,数字数据类型没有split()方法。

在实践中,内置函数和方法之间有什么区别?实际上并没有太大区别,除了我们在 Python “句子”或语句中包含这些“动词”的方式不同。对于内置函数,我们可以简单地写出函数名,并通过将它们放置在圆括号之间传递它所需的任何“参数”。例如,如果你回想一下我们的示例 1-1,我们只需将字符串Hello World!传递给print()函数,就像这样:

 print("Hello World!")

然而,在split()方法的情况下,我们必须将方法附加到特定的字符串上。该字符串可以是一个文字(即一系列被引号包围的字符),或者它可以是一个其值为字符串的变量。尝试在示例 2-5 中的代码文件或笔记本中执行,并查看您会得到什么样的输出!

示例 2-5. method_madness.py
# splitting a string "literal" and then printing the result
split_world = "Hello World!".split()
print(split_world)

# assigning a string to a variable
# then printing the result of calling the `split()` method on it
world_msg = "Hello World!"
print(world_msg.split())

请注意,如果您尝试在不合适的数据类型或者没有意义的数据类型上运行split()方法,您将会收到一个错误。连续尝试这些操作(或者如果您使用笔记本,则在两个不同的单元格中),看看会发生什么:

# the following will produce an error because
# the `split()` method must be called on a string in order to work!
split("Hello World!")
# the following will produce an error because
# there is no `split()` method for numbers!
print(5.split())

与数据类型一样,方法和函数因其排版和标点而容易识别。内置函数(如print())在代码编辑器或笔记本中会显示特定的颜色。例如,在 Atom 的默认 One Dark 主题中,变量名为浅灰色,操作符如=为紫色,内置函数如print()则为青色。您还可以通过它们的相关标点来识别函数:任何您看到的文本紧接在圆括号后面(例如print()),您都在看一个函数。方法也是如此,只是它们总是在适当的数据类型或变量名称之前,并通过一个句点(.)与之分隔。

在像 Python 这样的编程语言中,仅使用运算符、方法和内置函数就可以完成相当多的工作,特别是在执行基本数学等任务时。然而,在数据处理方面,我们需要更高级的技术。正如我们可以将复杂任务(如弹奏钢琴或踢足球)视为许多较简单动作的精心组合,例如移动手指或脚一样,通过深思熟虑地组合相对简单的运算符、方法和内置函数,我们可以构建非常复杂的编程功能。这些用户定义函数是我们可以通过创建代码“配方”,从而真正增强我们代码能力的地方,这些“配方”可以一次又一次地使用。

例如,假设我们想向两个不同的人打印相同的问候语。我们可以简单地像我们一直以来所做的那样使用print()函数,如示例 2-6 所示。

示例 2-6. basic_greeting.py
# create a variable named author
author = "Susan E. McGregor"

# create another variable named editor
editor  = "Jeff Bleiel"

# use the built-in print function to output "Hello" messages to each person
print("Hello "+author)
print("Hello "+editor)

关于示例 2-6 中的代码,有几件事需要注意。首先,使用print()函数完全可以胜任;这段代码完全完成了任务。这很棒!第一次为某事编写代码(包括特定的数据处理任务),这基本上是我们的主要目标:确保其正确工作。

然而,一旦我们完成了这一点,我们可以开始考虑一些简单的方法来使我们的代码“更干净”和更有用。在先前的示例中,两个打印语句完全相同,只是使用的变量不同。每当我们在代码中看到这种重复时,这表明我们可能希望制作自己的用户定义函数,就像在示例 2-7 中一样。

示例 2-7. greet_me.py
# create a function that prints out a greeting
# to any name passed to the function

def greet_me(a_name):
    print("Hello "+a_name)

# create a variable named author
author = "Susan E. McGregor"

# create another variable named editor
editor  = "Jeff Bleiel"

# use my custom function, `greet_me` to output "Hello" messages to each person
greet_me(author)
greet_me(editor)

很神奇,对吧?在某些方面,我们几乎没有改变什么,但实际上在示例 2-7 中,有很多东西。现在我们将花几分钟时间来突出一些在这里使用的新概念,但如果一开始不完全理解,不要担心——我们将在本书中继续讨论这些想法。

我们在示例 2-7 中的主要工作是编写我们的第一个自定义函数greet_me()。我们通过使用几种不同的语法结构和排版指示符告诉计算机,我们希望它创建并记住这个函数以供将来使用。其中一些约定与我们已经见过的用于创建自定义变量的约定相匹配(例如使用描述性名称greet_me()),以及内置函数和方法的约定,例如紧接在函数名后面立即使用圆括号()

在图 2-1 中,我已经绘制了我们的greet_me()函数的代码图,以突出显示每一行的内容。

自定义函数的组成部分

图 2-1. 自定义函数的组成部分

如你所见,从图 2-1 可以看出,创建自定义函数意味着向计算机包括多个指标:

  • def关键字(缩写define)告诉计算机接下来是一个函数名。

  • 立即跟在函数名后面的圆括号强调这是一个函数,并用于包围函数的参数(如果有的话)。

  • 冒号(:)表示接下来缩进的代码行是函数的一部分。

  • 如果我们想访问作为参数传递给我们函数的变量,我们使用函数定义中圆括号之间出现的“局部”参数名称。

  • 我们可以在我们的自定义函数中使用任何类型的函数(包括内置和自定义)或方法内部。这是构建高效、灵活代码的关键策略。

当涉及到使用或“调用”我们的函数时,我们只需写函数名(greet_me()),确保在括号中放入与函数定义中出现的“配料”数量相同即可。由于我们定义了greet_me()函数仅接受一个参数“配料”(在这种情况下是a_name参数),当我们想要使用它时,必须提供恰好一个参数,否则将会出错。

使用自定义函数进行烹饪

正如你可能注意到的,我喜欢将用户定义的或“自定义”函数看作编程的“食谱”。像食谱一样,它们为计算机提供了将一个或多个原始数据“配料”转换为其他有用资源的可重复使用指令。有时只有一个参数或“配料”,就像我们的greet_me()食谱中一样;有时有多个参数,类型各异。写自定义函数没有严格的“对”或“错”—就像写烹饪食谱一样;每个人都有自己的风格。同时,当决定应该将什么放入给定函数时的策略,可以考虑我们通常如何使用(甚至可能编写!)烹饪食谱。

例如,显然可以可能编写一个称为“感恩节”的单一烹饪食谱,描述如何从头到尾制作整个假日大餐。根据你的假日风格,可能需要 2 到 72 小时“运行”,每年一次非常有用—其他时候几乎从不使用。如果你确实只想制作那个庞大食谱的一部分—也许你想在新年时享用感恩节的土豆泥—你首先必须查找感恩节的说明,识别并组合只有制作土豆泥的配料和步骤。这意味着在你开始烹饪之前要投入大量工作!

因此,虽然我们希望我们的自定义函数比计算机已经能够做的稍微复杂一些,但我们通常不希望它们真正复杂。就像“感恩节”食谱一样,制作巨大的函数(甚至程序)会限制它们在多大程度上可以被重复使用。实际上,创建简单而专注的函数会使我们的代码在长远来看更加有用和灵活——这是我们将在第八章中详细探讨的一个过程。

库:从其他编程者那里借用自定义函数

如果自定义函数是编程配方,那么就是编程食谱书:大量其他人编写的自定义函数的集合,我们可以用来转换我们的原始数据成分,而不必从零开始想出并编写我们自己的配方。正如我在第一章中提到的那样,编写有用的 Python 库的大社区是我们首先选择使用 Python 的原因之一——正如你将在本章末尾的“使用 Citi Bike 数据踏上旅途”中看到的那样,使用它们既有用又强大。

然而,在我们真正利用库之前,我们需要掌握 Python 的另外两个基本语法结构:循环条件语句

掌控:循环和条件语句

正如我们所讨论的,编写 Python 代码在许多方面与用英语写作相似。除了依赖一些基本的“词类”之外,Python 代码是从左到右编写并且基本上是从上到下阅读的。但计算机在数据整理程序中的路径更像是“选择你自己的冒险”书而不是传统的论文或文章:根据程序员——也就是你提供的命令,一些代码片段可能会根据数据或其他因素被跳过或重复执行。

在循环中

当我们用 Python 进行数据整理时,我们最常见的目标之一是对数据集中的每条记录做某些处理。例如,假设我们想要对一组数字进行求和以找到它们的总和:

# create a list that contains the number of pages in each chapter
# of a fictional print version of this book

page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]

如果你需要在没有编程的情况下对一组数字进行求和,你有几个选择:你可以使用计算机上的计算器程序,实体计算器,或者甚至(哇!)一支铅笔和纸。如果你知道如何使用电子表格程序,你可以在那里输入每个数据项并使用SUM()函数。对于较短的列表,这些解决方案可能都还好,但它们不适合扩展:当然,手工(或计算器)加起来 10 个数字可能不会花费太长时间,但加起来 100 个数字就不同了。在时间上电子表格解决方案要好一些,但它仍然需要许多外部步骤——比如将数据复制粘贴到电子表格中,并基本手动选择应该加总的行或列。通过编程解决方案,我们几乎可以避免所有这些缺点——无论我们需要加总 10 行还是 10 百万行,我们的工作量几乎没有增加,计算机实际计算的时间也只会略微延长。

当然,因为编程仍然是在编写,我们可以表达给计算机的指令有多种方式。一种方式是让计算机查看列表中的每个数字并保持运行总数,就像在示例 2-8 中所示的那样。

示例 2-8. page_count_loop.py
# fictional list of chapter page counts
page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]

# variable for tracking total page count; starting value is 0
total_pages = 0

# for every item in the list, perform some action
for a_number in page_counts:

    # in this case, add the number to our "total_pages" variable
    total_pages = total_pages + a_number

print(total_pages)

在我们探讨其他可能告诉计算机执行此任务的方式之前,让我们先分解示例 2-8。显然,我们从数字列表开始。接下来,我们创建一个变量来跟踪total_pages,我们必须显式地为其分配一个起始值0(大多数计算器程序会更或多少隐式地执行此操作)。最后,我们开始遍历我们的列表:

for a_number in page_counts:

要理解这行代码最简单的方法是像读英文句子一样大声说出来:“对于列表中的每个a_numberpage_counts执行以下操作。”实际上,事情就是这样的。对于page_counts列表中的每个项目,计算机都会按照缩进在for...in...:语句下的代码指令执行。在这种情况下,这意味着将total_pages的当前值与a_number的值相加,并将结果再次存储回total_pages中。

在某些方面,这是很直接的:我们已经非常明确地告诉计算机了page_counts(这是我们的数字列表)和total_pages的值。但a_number呢?它从哪里来的,计算机如何知道在哪里找到它?

print()语句或def...function_name():结构一样,for...in...:配置内置于 Python 语言中,这就是为什么在编码时我们不必给它那么多指示。在这种情况下,for...in...:语句要提供的两个东西是:一个类似列表的变量(在本例中是page_counts)和计算机用来引用列表中当前项目的名称(在本例中是a_number),如图 2-2 所示。

循环的结构。

图 2-2. for循环的结构

与所有变量名称一样,变量名称a_number没有任何“魔力”——我认为这是一个很好的选择,因为它具有描述性和可读性。重要的是,当我希望计算机对每个列表项执行某些操作时,我在缩进代码中使用的变量名称必须与我在顶部for...in...:语句中编写的名称相匹配。

在编程术语中,这种for...in...:结构被称为for 循环,并且每种常用的编程语言都有它。之所以称为“循环”,是因为对于提供的列表中的每个项目,计算机都会运行每一行相关的代码——在 Python 中的情况下,是for...in...:语句下的每一行缩进的代码——然后移动到下一个项目并再次“循环”回第一行缩进的代码。当我们的“循环”只有一行代码时,这有点难以看到,因此让我们添加几行代码来更好地说明正在发生的情况,如示例 2-9 所示。

示例 2-9. page_count_printout.py
# fictional list of chapter page counts
page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]

# variable for tracking total page count; starting value is 0
total_pages = 0

# for every item in the list, perform some action
for a_number in page_counts:
    print("Top of loop!")
    print("The current item is:")
    print(a_number)
    total_pages = total_pages + a_number
    print("The running total is:")
    print(total_pages)
    print("Bottom of loop!")

print(total_pages)

到目前为止,你可能会想:“这似乎是为了对一组数字求和而做很多工作。”当然,有一种更有效的方法来完成这个特定任务:Python 有一个内置的sum()函数,它将接受我们的数字列表作为参数(见示例 2-10)。

示例 2-10. 使用sum()函数
# fictional list of chapter page counts
page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]

# `print()` the result of using the `sum()` function on the list ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
print(sum(page_counts))

1

尝试自己将其添加到现有程序中!

即使我们从一开始就可以使用sum()函数,我也利用这个机会介绍了for循环,有几个原因。首先,因为这是一个很好的提醒,即使是对于简单的编程任务,也总会有不止一种方法。其次,因为for循环是数据整理(实际上是所有编程)的重要部分,for...in...:循环是我们将用于过滤、评估和重新格式化数据的关键工具之一。

一个条件…

for循环为我们提供了查看数据集中每个项目的简单方法,但数据整理还需要对数据做出决策。通常情况下,这意味着评估数据的某些方面,并根据数据的特定值执行某些操作(或什么也不做)。例如,如果我们想知道这本书有多少章节超过 30 页,有多少少于 30 页,我们需要一种方法来:

  1. 检查我们page_counts列表中特定数字是否超过 30。

  2. 如果超过 30,则将over_30计数器加 1。

  3. 否则,将 1 添加到我们的under_30计数器中。

幸运的是,Python 具有一种内置的语法结构,可以做到这种类型的评估和决策:if...else语句。让我们通过修改示例 2-9 中的for循环来看看它是如何工作的,以跟踪超过和少于 30 页的章节数量。

示例 2-11. page_count_conditional.py
# fictional list of chapter page counts
page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]

# create variables to keep track of:
# the total pages in the book
total_pages = 0

# the number of chapters with more than 30 pages,
under_30 = 0

# the number of chapters with fewer than 30 pages
over_30 = 0

# for every item in the page_counts list:
for a_number in page_counts:

    # add the current number of pages to our total_pages count
    total_pages = total_pages + a_number

    # check if the current number of pages is more than 30
    if a_number > 30:

        # if so, add 1 to our over_30 counter
        over_30 = over_30 + 1

    # otherwise...
    else:
        # add 1 to our under_30 counter
        under_30 = under_30 + 1

# print our various results
print(total_pages)
print("Number of chapters over 30 pages:")
print(over_30)
print("Number of chapters under 30 pages:")
print(under_30)

for循环一样,理解if...else条件语句中正在发生的事情最简单的方法是大声说出来(并且同样重要的是,在代码的注释中写下这个句子):“if当前页数大于 30,则将over_30计数器加一。否则(else),将under_30计数器加一。”

虽然这个直觉上是有点意义的,但我想再次放慢脚步,更详细地讲解一下正在发生的事情,因为if...else语句是我们将反复使用的另一种编程结构。

首先,让我们看一下前面示例的缩进结构:所有属于for循环的代码从左边缘缩进一个制表符;这是计算机知道该代码“在”该循环内部的方式。类似地,属于if...else语句的每个部分的代码再次缩进一个制表符。在 Python 中,这种逐步缩进的过程实际上是必需的,否则代码将无法正常工作—如果缩进不正确,Python 会抱怨。^(2) 这种机制通常称为嵌套。更直观地理解正在发生的事情的一种方法显示在图 2-3 中。

代码嵌套的文氏图

图 2-3. 代码“嵌套”

嵌套的几个含义我们将在本书的后面进行探讨,但现在的主要要点是为了使一行代码“属于”一个函数、循环或条件语句,它必须缩进到比您想要它属于的结构右边缩进一个制表符的位置。为了看到这一点的实际效果,让我们把到目前为止我们所做的一切集成起来,创建一个使用循环、条件语句和自定义定义的函数的 Python 程序,如示例 2-12 所示。

示例 2-12. page_count_custom_function.py
# fictional list of chapter page counts
page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]

# define a new `count_pages()` function that takes one ingredient/argument:
# a list of numbers
def count_pages(page_count_list): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    # create variables to keep track of:
    # the total pages in the book
    total_pages = 0

    # the number of chapters with more than 30 pages,
    under_30 = 0

    # the number of chapters with fewer than 30 pages
    over_30 = 0

    # for every item in the page_count_list:
    for a_number in page_count_list: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

        # add the current number of pages to our total_pages count
        total_pages = total_pages + a_number

        # check if the current number of pages is more than 30
        if a_number > 30:

            # if so, add 1 to our over_30 counter
            over_30 = over_30 + 1

        # otherwise...
        else:
            # add 1 to our under_30 counter
            under_30 = under_30 + 1

    # print our various results
    print(total_pages)
    print("Number of chapters over 30 pages:")
    print(over_30)
    print("Number of chapters under 30 pages:")
    print(under_30)

# call/execute this "recipe", being sure to pass in our
# actual list as an argument/ingredient
count_pages(page_counts) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

1

我们可以通过缩进一个制表符并添加函数定义行将我们现有的代码“包装”到一个新函数中(这里称为count_pages())。

2

我们必须将for循环引用的列表变量名称与函数定义中圆括号之间提供的参数名称匹配,这一点在1中已经提到。

3

函数在实际调用执行之前并不执行任何操作。在这一点上,我们需要为其提供我们希望其处理的具体参数/参数。

如果你将示例 2-12 与示例 2-11 进行比较,你会发现我实际上只是从示例 2-11 复制了代码,并做了三件事:

  1. 我添加了函数定义语句def count_pages(page_count_list):

  2. 我将所有现有代码都缩进了一个额外的制表符,以便计算机将其视为“属于”我们新的count_pages()函数。在 Atom 中,你可以通过高亮显示要移动的所有代码行并按 Tab 键来一次性完成这个操作。我还更新了for循环顶部引用的变量,使其与函数def行中圆括号内提供的参数名匹配。

  3. 我确保在最后“调用”了函数,将page_counts变量作为“成分”或参数传递给它。请注意,count_pages(page_counts)语句根本没有缩进。

希望你开始逐渐理解所有这些是如何结合在一起的。不过,在我们开始使用这些工具进行一些真实世界的数据处理之前,我们需要花些时间讨论当代码出现问题时会发生什么。

理解错误

正如我们在第一章中提到的,计算机非常擅长快速执行重复的任务(通常也很准确)。这使得我们能够编写可以很好扩展的程序:能够对包含 10 项的列表(如我们的page_counts示例)进行求和或排序的相同代码也可以相当有效地用于包含 10,000 项的列表。

然而,与此同时,计算机确实、重要且不可挽回地愚蠢。计算机无法真正推断或创新——它们只能根据人类提供给它们的指令和数据选择其代码路径。因此,写代码与给幼儿下指令非常相似:你必须非常字面和非常明确,如果发生意外,你应该期待一场发脾气。^(3)

例如,当人类在书面句子中遇到拼写或语法错误时,我们通常甚至不会注意到它:根据周围句子或段落的上下文,大部分时间我们会在不费力气的情况下推断出适当的意思。事实上,即使一个句子中几乎所有单词中的所有字母都被重新排列,我们通常也能够毫不费力地阅读出来。^(4) 相比之下,计算机只会大声抱怨并停止读取,如果你的代码中有一个逗号错位的话。

因此,编程中的错误不仅是不可避免的,而且是预期的。无论你编程多少,你写的任何代码块,只要超过几行,都会有各种类型的错误。与其担心如何避免错误,学习如何解释纠正错误会更有用。在本书中,我有时会故意制造错误(或者鼓励你这样做,如在“快进”和“快进”中),这样你就可以熟悉它们,并开始制定自己的处理过程。作为起点,我们将讨论编程中发生的三种主要类型错误:语法错误、运行时错误和逻辑错误。

语法问题

在编程中的语法错误或语法错误可能同时是你遇到的最简单和最令人沮丧的错误类型之一——部分原因是它们非常频繁发生,而计算机对此却表现得非常吵闹。我之前提到的逗号错位的例子就是语法错误的一个例子:在某种程度上,你的代码违反了编程语言的语法规则。

我将这些错误描述为“简单”的原因是它们几乎总是如此。在我的经验中,大多数语法错误——甚至可以推广到编程错误——基本上都是笔误:逗号或引号被遗忘,或者一行代码的缩进过多或不足。不幸的是,许多我合作过的新手程序员似乎特别对这些错误感到沮丧,正是因为它们简单——因为他们觉得自己犯了这些错误很愚蠢。

实际上,经验丰富的程序员一直在经历语法错误。如果你刚开始学习,你可以从语法错误中学到的主要一点是如何让它们让你步步后退。事实上,我在前面的“快进”部分中故意介绍如何故意“破坏”代码的原因之一,就是帮助说明错误是如何容易发生和被修复的。在编程时,你将学到的最重要的技能之一就是如何频繁地犯错,但不要因此而灰心。

如果你运行的代码出现语法错误,你会知道,因为(通常是多行)错误消息的最后一行会显示SyntaxError。但比起这个,更实用的是错误消息中告诉你错误出现在哪个文件哪一行的部分,在实际修复错误时更为重要。随着时间的推移,只需转到那行代码并查找问题(通常是缺少标点:逗号、括号、冒号和引号)就足以发现问题所在。尽管错误消息还会包括(可能)有问题的代码行,并在 Python 认为缺少字符的地方下方标注一个插入符(^),但这并非万无一失。例如,示例 2-13 展示了一个 Python dict 缺少一行上的逗号的情况。

示例 2-13. 引入错误
1 # although the actual error is on line 4 (missing comma)
2 # the error message points to line 5
3 book = {"title":"Practical Python for Data Wrangling and Data Quality",
4  "format": "book"
5  "author": "Susan E. McGregor"
6 }

这里是错误输出:

  File "ObjectError.py", line 5
    "author": "Susan E. McGregor"
            ^
SyntaxError: invalid syntax

如你所见,尽管示例 2-13 中的代码在第 4 行值"book"后缺少逗号,计算机却报告错误发生在第 5 行(因为计算机在那时意识到有问题)。总的来说,通常可以在计算机报告错误的行(或前一行)找到语法错误。

运行时困扰

编程中的运行时错误用于描述在“运行”或执行代码过程中出现的任何问题。与语法错误一样,运行时错误的很大一部分实质上也是拼写错误,例如错误复制的变量名称。例如,每当你看到一个包含短语some_variable is not defined的错误时,几乎可以肯定你的代码中有不匹配的变量名称(记住:大小写敏感!)。由于阅读整个“回溯”错误可能会有点复杂(它们倾向于引用 Python 编程语言的内部工作方式,而我个人认为这并不十分有用),建议直接从错误中复制变量名,然后在代码中进行大小写不敏感搜索(这是 Atom 的默认行为)。这种方法将突出显示变量名的类似(但不完全相同)拼写,加快查找不匹配的速度。

例如,在示例 2-14 中,函数定义中提供给greet_me(a_name)的参数名称与后续代码体中使用的名称并不完全匹配。

示例 2-14. 稍微不匹配的变量名称会生成运行时错误
# create a function that prints out a greeting to any name passed to the function

def greet_me(a_name):
    print("Hello "+A_name)

# create a variable named author
author = "Susan E. McGregor"

# pass my `author` variable as the "ingredient" to the `greet_me` function
greet_me(author)

因为函数定义圆括号内部出现的参数名称始终优先,运行示例 2-14 中的代码会生成以下错误:

  File "greet_me_parameter_mismatch.py", line 10, in <module>
    greet_me(author)
  File "greet_me_parameter_mismatch.py", line 4, in greet_me
    print("Hello "+A_name)
NameError: global name 'A_name' is not defined

注意,通常情况下,错误消息的最后几行提供了最有用的信息。最后一行告诉我们,我们尝试使用变量名A_name,但在此之前并未先定义它,而上一行包含了它出现的实际代码。有了这两条信息(再加上我们的搜索策略),在我们找到错误源之前可能不会花费太长时间。

另一种非常常见的运行时错误类型是在尝试对特定数据类型进行不适当操作时发生。在“快进”中,你可能尝试运行代码greet_me(14)。在这种情况下,错误输出的最后一行将包含TypeError,这意味着我们的某部分代码接收到了与预期不同的数据类型。在这个例子中,问题在于该函数期望一个字符串(可以使用+号“添加”或连接到另一个字符串),但我们提供了一个数字,即14

修复此类错误的挑战在于准确定位问题所在有些许棘手,因为涉及到变量值的赋值和实际使用位置之间的不匹配。特别是当你的程序变得更加复杂时,这两个过程可能在代码中完全不相关。例如,查看来自示例 2-14 的错误输出,你可以看到它报告了两个位置。第一个是将变量传递到函数的行,因此可用值被赋予的位置:

File "greet_me_parameter_mismatch.py", line 10, in <module>

第二个是传递给它的值被使用的行:

File "greet_me_parameter_mismatch.py", line 4, in greet_me

正如我已经提到的,从一个问题变量或值的使用位置开始进行调试工作,然后逆向查找到分配值的代码行通常是有帮助的;只是要知道,分配值的代码行可能实际上并没有出现在错误输出中。这是使这类运行时错误比普通语法错误更难跟踪的部分原因之一。

运行时错误之所以难以诊断是我推荐频繁保存和测试你的代码的一个关键原因。如果自上次测试以来你只写了几行新代码,那么识别新运行时错误的来源将更加容易,因为问题必然出现在这段新代码中。采用这种编写、运行、重复的方法,当你需要查找任何新错误的来源时,需要覆盖的范围将会少得多,并且你可能能够相对快速地修复它们。

逻辑损失

到目前为止,最棘手的编程问题绝对是逻辑错误,这个术语广泛用来描述当你的程序运行时,只是不是你打算的方式发生的情况。这些错误特别阴险,因为从计算机的角度来看,一切都很正常:你的代码没有试图执行任何它认为混乱或“错误”的操作。但是请记住,计算机很笨,所以它们会很高兴地让你的程序执行产生毫无意义、混乱甚至误导性的结果。事实上,我们已经遇到了这种情况!

如果您回顾一下示例 2-12,您会注意到我们的注释表达的意图与我们的实际代码所做的事情之间存在轻微的不一致。该示例的输出如下:

291
Number of chapters over 30 pages:
5
Number of chapters under 30 pages:
4

但我们的数据看起来是这样的:

 page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]

看出问题了吗?虽然我们确实有 5 章的页数超过 30 页,但实际上只有 3 章的页数不到30 页——有一章正好是 30 页。

现在,这可能看起来并不是一个特别重要的错误——毕竟,如果一个 30 页的章节被归类到少于 30 页的章节中,这有多重要呢?但是想象一下,如果这段代码不是用来计算章节页数,而是用来确定投票资格呢?如果这段代码只计算“超过 18 岁”的人数,将会有成千上万的人失去选举权。

要修复这个错误,在代码调整方面并不困难或复杂。我们只需要改变这一点:

 # check if the current number of pages is more than 30
 if a_number > 30:

改为:

 # check if the current number of pages is greater than or equal to 30
 if a_number >= 30:

这种类型错误的挑战在于,它完全依赖于我们作为程序员的勤奋,以确保在我们设计程序时,我们不会忽视可能导致不正确测量或结果的某些数据值或差异。因为计算机无法警告我们逻辑错误,避免它们的唯一可靠方法是一开始就仔细计划我们的程序,并仔细“审查”结果。在“使用 Citi Bike 数据上路”中,我们将看到当我们编写处理真实数据集的程序时,这个过程是什么样子的。

哎呀!现在我们已经涵盖了 Python 编程的所有基础知识(真的!),我们准备从我们关于本书的“玩具”示例中继续,使用(某种程度上)关于这本书的数据。为了让我们的脚步湿润,使用一个真实数据集进行数据整理任务,我们将转向来自纽约市自行车共享系统 Citi Bike 的信息。

使用 Citi Bike 数据上路

每个月,数百万人使用自行车共享系统在世界各地的城市和城镇中行驶。纽约市的 Citi Bike 计划于 2013 年推出了 6000 辆自行车,在 2020 年,该系统见证了其第 1 亿次骑行

Citi Bike 提供了关于其系统运营的免费可访问数据,这些数据既是实时的又是历史的。为了查看使用 Citi Bike 数据回答一个简单问题所需的步骤,我们将使用 Citi Bike 数据来回答一个简单问题:Citi Bike 不同类型的骑行者每天骑行多少次?

我们需要我们的 Python 技能来回答这个问题,因为 Citi Bike 骑行者每天骑行数以十万计——这意味着这些数据的一天对于 Microsoft Excel 或 Google Sheets 来说有太多的行了。但即使在 Chromebook 上,Python 也不会在处理这些数据量时遇到问题。

要思考如何处理这个问题,让我们重新审视数据整理的步骤,详见“什么是“数据整理”?”:

  1. 定位或收集数据

  2. 评估数据的质量

  3. “清洁”、标准化和/或转换数据

  4. 分析数据

  5. 可视化数据

  6. 传达数据

对于这个练习,我们将专注于步骤 1 至 4,尽管你会看到,我已经做了一些准备工作,这将减少我们花在某些步骤上的时间。例如,我已经找到了Citi Bike 系统的数据,并下载了2020 年 9 月的出行历史数据,确认了User Type列中出现的唯一值是CustomerSubscriber,并将整个九月的数据集削减为仅仅在 2020 年 9 月 1 日开始的骑行。虽然我们将在后续章节中详细介绍如何执行所有这些过程,但现在我想专注于如何将本章的教训应用于我们没有为自己准备的数据。^(7)

从伪代码开始

无论项目的数据整理大小如何,最好的方法之一是事先计划您的方法,并通过伪代码将该程序大纲包含在您的 Python 文件中。伪代码基本上意味着逐步编写您的程序将要做的事情,用普通英语(尽管您当然可以用其他自然语言进行伪代码,如果您更喜欢!)。除了为您提供思考程序需要完成的任务的空间而无需担心如何编写代码外,伪代码还将为您提供一个宝贵的参考,以便在需要休息一下项目并稍后回来时,确定下一步工作。尽管您无疑会遇到许多不经常完成此过程部分的专业程序员,但我可以保证这将帮助您更快地完成整理项目,并且这种习惯在任何专业的编程或数据科学环境中都受欢迎。

我更喜欢将程序大纲和伪代码放在我的 Python 文件顶部,作为一大块注释。起初,我将做三件事:

  1. 陈述我的问题。

  2. 描述如何“回答”我的问题。

  3. 用简单的语言概述我程序将采取的步骤。

这意味着我要做的第一件事就是在我的文件中写很多注释,正如你在 示例 2-15 中看到的那样。

示例 2-15. hitting_the_road_with_citibike.py
# question: How many Citi Bike rides each day are taken by
# "subscribers" versus "customers"?

# answer: Choose a single day of rides to examine.
# the dataset used for this exercise was generated from the original
# Citi Bike system data found here: https://s3.amazonaws.com/tripdata/index.html
# filename: 202009-citibike-tripdata.csv.zip

# program Outline:
# 1\. read in the data file: 202009CitibikeTripdataExample.csv
# 2\. create variables to count: subscribers, customers, and other
# 3\. for each row in the file:
#       a. If the "User Type" is "Subscriber," add 1 to "subscriber_count"
#       b. If the "User Type" is "Customer," add 1 to "customer_count"
#       c. Otherwise, add 1 to the "other" variable
# 4\. print out my results

现在程序大纲已经搞定,是时候开始我们程序的第一部分了:读取我们的数据。

提示

每次像这样开始一个新文件时,请记住,即使它保存在本地 Git 存储库文件夹内,你也需要运行 git add 来备份你所做的任何更改(记住,你不能在 add 文件之前 commit 文件)。这些步骤在 “别忘了 Git!” 中有详细说明,以防你需要恢复。虽然你可以自行决定多久 commit 一次,但对于这个练习,我建议在每个代码块之后 commit 你的代码(当然,附带描述性的提交消息!)。当你对编码和 Git commit 过程更加熟悉时,你会找到适合你的最佳代码备份频率和节奏。

加载不同的数据格式,正如我们在 第四章 中将深入了解的那样,实际上可能是数据处理中更棘手的方面之一。幸运的是,有许多 Python 库可用来帮助,我们现在将使用其中之一!我在 “库:从其他编程者那里借用自定义函数” 中简要提到了库;它们本质上是代码的食谱。对于这个项目,我们将使用 csv 库的“食谱”,它主要设计用于处理——你猜对了!——.csv 文件。文件扩展名 .csv 代表 comma-separated value,如果你以前没有见过它,不要担心。我们将在 第四章 中详细讨论文件类型(详细)。现在,有了 csv 库就意味着我们实际上不需要太多关于这种文件类型的知识来处理它,因为库的代码食谱将为我们完成大部分工作!

如果你在自己的文件中跟着做,你会想要将 示例 2-16 中的代码添加到程序大纲中。

示例 2-16. hitting_the_road_with_citibike.py(续)
# import the `csv` library ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
import csv

# open the `202009CitibikeTripdataExample.csv` file in read ("r") mode
# this file should be in the same folder as our Python script or notebook
source_file = open("202009CitibikeTripdataExample.csv","r") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# pass our `source_file` as an ingredient to the `csv` library's
# DictReader "recipe".
# store the result in a variable called `citibike_reader`
citibike_reader = csv.DictReader(source_file)

# the DictReader method has added some useful information to our data,
# like a `fieldnames` property that lets us access all the values
# in the first or "header" row
print(citibike_reader.fieldnames) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

1

csv 库包含一系列方便的代码食谱,用于处理我们的数据文件。

2

open() 是一个内置函数,它以文件名和“模式”作为参数。在这个例子中,目标文件(202009CitibikeTripdataExample.csv)应该与我们的 Python 脚本或笔记本在同一个文件夹中。对于“模式”,可以使用 "r" 表示“读取”或 "w" 表示“写入”。

3

通过打印出 citibike_reader.fieldnames 的值,我们可以看到“User Type”列的确切标签是 usertype

此时,我们实际上可以运行此脚本,我们应该看到类似以下的输出:

['tripduration', 'starttime', 'stoptime', 'start station id', 'start station
 name', 'start station latitude', 'start station longitude', 'end station id',
 'end station name', 'end station latitude', 'end station longitude', 'bikeid',
 'usertype', 'birth year', 'gender']

现在我们已经成功完成了大纲的第一步:我们已经读入了我们的数据。但我们还使用了 csv 库来转换数据,甚至生成了一些关于它的 元数据。重要的是,我们现在知道包含我们 “User Type” 信息的列的确切名称实际上是 usertype。这将有助于我们在编写 if...else 语句时的操作。为了确认一切按您的预期工作,请确保保存并运行您的代码。如果按预期工作(即,它打印出列标题的列表),现在是执行 git commit 循环的好时机:

git status
git commit -m "*Commit message here*" *filename_with_extension*
git push

请记住,如果您正在使用 Google Colab,您可以通过选择文件 → “在 GitHub 中保存副本” 并在出现的覆盖窗口中输入提交消息,直接将您的代码提交到 GitHub。

现在我们已成功完成第一步,让我们继续进行第二步,如 Example 2-17 所示。^(8)

示例 2-17. hitting_the_road_with_citibike.py(继续)
# create a variable to hold the count of each type of Citi Bike user
# assign or "initialize" each with a value of zero (0)
subscriber_count = 0
customer_count = 0
other_user_count = 0

相当简单,对吧?继续进行第三步!因为我们需要检查文件中的每一行数据,所以我们需要编写一个 for...in 循环,其中将需要在 usertype 列中测试特定值的 if...else。为了帮助跟踪代码的每一行正在做什么,我将写很多以英文解释代码的注释,如 Example 2-18 所示。

示例 2-18. hitting_the_road_with_citibike.py(继续进行)
# step 3: loop through every row of our data
for a_row in citibike_reader: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    # step 3a: if the value in the `usertype` column
    # of the current row is "Subscriber"
    if a_row["usertype"] == "Subscriber": ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

        # add 1 to `subscriber_count`
        subscriber_count = subscriber_count +1

    # step 3b: otherwise (else), if the value in the `usertype` column
    # of the current row is "Customer"
    elif a_row["usertype"] == "Customer": ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

        # add 1 to `subscriber_count`
        customer_count = customer_count + 1

    # step 3c: the `usertype` value is _neither_"Subscriber" nor "Customer",
    # so we'll add 1 to our catch-all `other_user_count` variable
    else: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)
        other_user_count = other_user_count + 1

1

我们要确保我们的 for 循环正在处理已由我们的 DictReader 转换的数据,所以我们要确保在这里引用我们的 citibike_reader 变量。

2

为了让我的 if 语句“内嵌”到我的循环中,它们必须向右缩进一个 tab

3

因为我们需要在这里使用 “else” —— 但也需要另一个 “if” 语句 —— 所以我们使用了复合关键字 elif,它是 “else if” 的简写。

4

这个最终的 else 将“捕获”任何数据行,其中 usertype 的值不是我们明确检查过的值之一(在本例中是“Subscriber”或“Customer”)。这充当了一个(非常)基本的数据质量检查:如果此数据列包含任何意外的值,other_user_count 将大于零 (0),我们需要仔细查看原始数据。

好了,在示例 2-18 中有很多内容—或者看起来像是有很多!实际上,我们只是检查了每行的usertype列的值是否为"Subscriber""Customer",如果是的话,我们将对应的count变量加一(或者增加)。如果usertype的值既不是这两者之一,我们将other_user_count变量加一。

虽然我们添加了比代码更多的行注释可能看起来有些奇怪,但实际上这是非常正常的—甚至是好的!毕竟,虽然计算机永远不会“忘记”如何读取 Python 代码,如果不在注释中解释这段代码在做什么和为什么要这样做,绝对会忘记。而这并不是一件坏事!毕竟,如果要记住所有的代码,编程效率会大大降低。通过编写详细的注释,你确保将来能轻松理解你的代码,而不必再次将 Python 翻译成英语!

在我们继续之前,请确保运行你的代码。对于我们最常见的错误类型,“无消息就是好消息”,如果你没有收到任何错误,那就进行一次git commit循环。否则,现在是暂停并排除你遇到的任何问题的好时机。一旦解决了这些问题,你会发现只剩下一个非常简单的步骤:打印!让我们采用最直接的方式并使用内置的print语句,就像示例 2-19 中显示的那样。

示例 2-19. 使用 citibike.py 出发(我们到达了!)
# step 4: print out our results, being sure to include "labels" in the process:
print("Number of subscribers:") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
print(subscriber_count)
print("Number of customers:")
print(customer_count)
print("Number of 'other' users:")
print(other_user_count)

1

请注意,这些print()语句是左对齐的,因为我们只想在for循环完成整个数据集的遍历后打印值。

当你把代码添加到示例 2-19 中后,保存并运行它。你的输出应该类似于这样:

['tripduration', 'starttime', 'stoptime', 'start station id', 'start station
 name', 'start station latitude', 'start station longitude', 'end station id',
 'end station name', 'end station latitude', 'end station longitude', 'bikeid',
 'usertype', 'birth year', 'gender']
Number of subscribers:
58961
Number of customers:
17713
Number of 'other' users:
0

如果你看到这些内容—恭喜!你成功地编写了你的第一个真实世界数据整理程序。确保进行一次git commit来备份你的优秀工作!

寻求规模

在编写这个脚本时,我们完成了许多事情:

  1. 我们成功并精确地统计了 2020 年 9 月某一天使用 Citi Bikes 的“订阅者”和“顾客”的数量。

  2. 我们确认usertype列中没有其他值(因为我们的other_user_count变量的值为 0)。

如果您之前使用电子表格或数据库程序进行数据整理,但这是您第一次在 Python 中工作,综合考虑所有事情,这个过程可能比您以前的方法花费更长时间。但正如我已经多次提到的,编码相比许多其他方法的一个关键优势是能够无缝扩展。这种扩展有两种方式。首先,它几乎可以在较大的数据集上以几乎与较小数据集相同的速度完成相同的工作。例如,在我的 Chromebook 上,hitting_the_road_with_citibike.py脚本大约在半秒内完成。如果我在整个九月份的数据上运行相同的脚本,大约需要 12 秒钟。大多数软件程序即使能够处理整个月份的数据,可能只需打开文件就需要这么长时间,更不用说对其进行任何工作了。因此,使用 Python 帮助我们扩展,因为我们可以更快速和有效地处理更大的数据集。您可以通过更改以下行来自行测试:

source_file = open("202009CitibikeTripdataExample.csv","r")

至:

source_file = open("202009-citibike-tripdata.csv","r")

如果您在独立的 Python 文件中工作,甚至可以通过在您的python命令之前添加time关键字来测量运行新文件上的脚本所需的时间:

time python _your_filename_.py

这说明了我们通过 Python 实现的第二种类型的扩展,这与在不同数据集上(具有相同结构)计算机执行相同任务所需的边际努力有关。一旦我们编写了我们的程序,Python(以及一般的编程)真正闪耀的地方就在于此。为了在 2020 年 9 月的整个月份数据上运行我的脚本,我所需要做的只是加载一个不同的数据文件。通过更改我用source_file =语句打开的目标文件名,我能够处理整个九月份的数据,而不仅仅是一天的数据。换句话说,处理成千上万个额外数据行的额外(或“边际”)努力正好等于我复制和粘贴文件名所花费的时间。基于这个例子,我可以在几分钟内(或更短时间,正如我们将在第八章中看到的那样)处理一整年的数据。这几乎是任何非编码数据整理方法都难以实现的事情。

我们在本节中构建的完整脚本是一个示例,展示了即使仅使用我们在本章中介绍的基本结构,您也可以使用 Python 进行一些非常有用和高效的数据整理。尽管在剩余的章节中有许多新的数据格式和挑战需要探索,但我希望这能让您感受到即使使用这些“基本”的 Python 工具和稍加努力和注意细节,您也能取得多大的成就。想象一下,如果您继续努力,还能实现什么!

结论

信不信由你,在本章中,我们涵盖了所有您进行数据整理所需的基本 Python 工具,以及几乎任何其他类型的 Python 编程!为了总结我们所学到的内容,我们学到了:

数据类型

这些是编程的“名词”:数字、字符串、列表、字典和布尔值。

函数

这些是编程的“动词”:运算符、内置函数和用户定义函数。

使用for...in...循环

这些让我们在列表中的每个项目上运行特定的代码块。

使用if...else条件语句

这些让我们根据数据的属性“决定”应该运行什么代码。

错误

我们探讨了编程时可能遇到的不同类型的错误,以及如何最好地处理和预防它们。

我们还练习了将这些概念结合和组合,以创建一个基本程序来处理来自纽约市自行车系统的一些样本数据。虽然我们将在未来的章节中扩展这个示例并探索其他示例,但我们下一个任务是更深入地了解如何作为数据整理工作的一部分评估数据本身。

^(1) 还有许多特定于语言的数据类型(如 Python 的元组数据类型),这些对我们的数据整理工作不是很相关,因此我们不会详细讨论这些。

^(2) 在许多其他编程语言中,花括号用于指示代码的哪些部分属于循环或条件语句。然而,正如我们在“可读性”中提到的,Python 是依赖于空白的。

^(3) 当然,与计算机不同,幼儿具有真正的独创性和学习能力。

^(4) 欲了解更多信息,请参阅 Scott Rosenberg 的 Wordyard 博客文章

^(5) 这实际上是一件好事,正如我们将在第三章中看到的那样。

^(6) 关于代码覆盖率最佳实践的信息,请参阅Google 测试博客上的“代码覆盖率最佳实践”。

^(7) 此练习的所有代码都可以在hitting_the_road_with_citibike.pyhitting_the_road_with_citibike.ipynb文件中找到。然而,我强烈建议您创建自己的新文件,并手动输入提供的代码。

^(8) 再次强调——从现在开始——将此代码添加到您的文件中,放在上一个代码块的最后一行下面。

第三章:理解数据质量

数据无处不在。它由我们的移动设备自动生成,我们的购物活动和身体活动。它由我们的电表、公共交通系统和通信基础设施捕获。它用于估计我们的健康结果、收入潜力和信用价值(1)。经济学家甚至宣称数据是“新的石油”(2),因其有可能转变人类生活的许多方面。

尽管数据可能非常丰富,但事实是好的数据却是稀缺的。所谓的“数据革命”声称,有了足够的数据,我们可以更好地理解现在,改进甚至预测未来。然而,要实现任何这些可能性,这些洞察背后的数据必须是高质量的。没有好质量的数据,我们所有的努力去整理、分析、可视化和传播数据,充其量只会让我们对世界的了解毫无进展。虽然这将是一种不幸的努力浪费,但没有意识到我们拥有质量低劣的数据的后果更为严重,因为这可能导致我们形成一个看似合理但危险扭曲的现实观。更重要的是,因为数据驱动的系统被用来大规模做决策,即使有少量不好的数据也可能造成重大危害。当然,可以用来“训练”机器学习模型的数据可能涉及数百甚至数千人的数据。但是,如果这些数据不代表模型将应用到的人群,那么系统可能造成的后果将影响到原始数据集中人数的数百甚至数千倍。由于风险如此之高,确保数据质量是数据整理的一个重要部分。但是,数据“高质量”意味着什么呢?在我看来,只有数据既适合特定目的,又具有高度的内部完整性,才能称得上是高质量的数据。

那么,每一个术语实际上意味着什么呢?这正是我们将在本章深入探讨的内容。我们将从讨论数据适合性的概念开始,这与数据在特定上下文中的适用性或用于回答特定问题相关。然后,我们将详细分析数据完整性的许多方面:影响数据适合性及我们可以负责使用的分析类型的数据集特征。最后,我们将讨论一些工具和策略,帮助您找到和处理数据,以提升其整体质量,从而增强您与其相关工作的信心和可信度。

如果你开始觉得这些内容有点乏味,让我从《什么是“数据整理”?》中再次劝告你:试图“跳过”评估数据质量的工作只会削弱你在数据整理方面的努力。充其量,你在分享工作时会遇到关于你的流程的问题,而你却无法回答。最糟糕的情况下,你可能会推广一些既错误又有害的“见解”。在这个过程中,你也会欺骗自己,因为解决数据质量问题是你扩展编程知识的最佳途径。如果你真的想在数据整理工作中表现出色,评估数据质量必须成为你实践的一部分。

评估数据的适应性

也许关于数据整理最常见的误解之一是它是一个主要定量的过程,也就是说,数据整理主要是处理数字、公式和代码。事实上,无论你处理的是什么类型的数据——从温度读数到社交媒体帖子——数据整理的核心工作都涉及做出判断:从你的数据是否准确地反映了你正在调查的现象,到如何处理缺失的数据点,以及是否有足够的数据来生成任何真正的见解。第一个概念——一个给定数据集如何准确地反映你正在调查的现象——大体上就是我所说的它的适配性,评估你的数据集对于特定目的的适配远不只是应用数学公式那么简单。其原因很简单:这个世界是一个混乱的地方,即使是最简单的关于它的数据也总是通过某种人类视角来过滤。以测量你工作空间一周内温度为例。理论上,你只需要一个温度计,把它放在空间里,每天记录一下读数就行了。完成了,对吧?

或者呢?让我们从你的设备开始说起。你用的是数字温度计还是水银温度计?你把它放在哪里?是在靠近门、窗户、还是加热或冷却源的地方?你每天都在同一时间测量吗?温度计是否曾直接暴露在阳光下?典型的湿度水平是多少?

你可能认为我在这里引入了一种人为的复杂性水平,但如果你曾经生活在共享空间(比如公寓大楼)中,你可能经历过感觉像比某个温度计所说的温度更高或更低的体验。同样,如果你曾经照顾过生病的孩子,你很可能对不同类型温度计或甚至同一温度计不同时间读数的体验感到非常熟悉。

换句话说,导致你记录的两位数或三位数温度的因素有很多,而这个数字本身并不提供任何信息。这就是为什么当你开始尝试用数据回答问题时,仅仅知道数据集中包含什么是不够的;你需要了解收集数据时使用的过程和机制。然后,根据你对数据收集方式的了解,你需要确定它是否真的可以用来有意义地回答你的具体问题。

当然,这个问题既不新鲜也不独特;这是所有科学领域在努力探索世界新信息时面临的挑战。如果每个研究人员都必须亲自进行每项研究,癌症研究几乎无法取得进展;如果没有利用他人工作的能力,科学和技术进步将停滞不前(如果不是完全偏离轨道)。因此,科学界随着时间的推移已经发展出了三个用于确定数据集是否适合回答给定问题的关键指标:有效性可靠性代表性

有效性

在其最基本的层面上,有效性描述了某物测量其应测量的程度。在我们的室温例子中,这意味着确保你选择的温度计确实能够测量空气温度,而不是其他什么东西。例如,传统的液体玻璃温度计可能很好地捕捉空气温度,而红外线温度计则倾向于捕捉其指向的任何表面的温度。因此,即使是看似基础的室温问题,你也需要理解用于收集数据读数的工具和方法,以确保它们在与你的问题相关的有效性方面的适用性。

毫不奇怪,当我们不是收集关于常见物理现象的数据时,事情会变得更加复杂。建构有效性描述了你的数据测量有效地捕捉了(通常是抽象的)建构或思想,你试图理解的内容。例如,假设你想知道你所在地区哪些学校是“最好的”。哪些数据可以帮助你回答这个问题?首先,我们必须认识到术语最好是不精确的。最好是指什么?你关心哪所学校拥有最高的毕业率?标准化考试成绩?学校分配的成绩?教师评价?学生满意度?课外活动参与度?

为了利用数据开始回答这个问题,您首先需要阐明两件事。首先,“最佳” 适合谁?您是在为自己的孩子回答这个问题吗?朋友的孩子?回答了这个问题后,您将更能够完成第二项任务,即操作化 您对“最佳”具体想法的理解。例如,如果您朋友的孩子喜欢运动,课外活动可能比学术更重要。

在数据分析中,选择测量的过程被称为操作化构建,它无可避免地需要在您尝试理解的概念或想法中选择并平衡代理。这些代理 —— 如毕业率、考试成绩、课外活动等 —— 是您可以收集 数据的事物,您选择使用它们来代表无法直接测量的抽象概念(“最佳”学校)。良好的数据至少必须在您的问题方面具有良好的结构有效性,否则您的数据处理结果将毫无意义。

数据拟合中另一种重要的有效性类型是内容有效性。这种有效性类型涉及到给定代理测量的数据完整性。在“最佳”学校的例子中,假设您已确定成绩对于确定最佳学校很重要,但您只有历史和体育课程的成绩数据可用。尽管对于许多人来说,成绩数据原则上可能具有结构有效性以确定最佳学校,但仅有两种类型课程的成绩数据是不足以满足内容有效性的要求的 —— 而对于高质量的数据,您需要两者兼顾

可靠性

在数据集内部,给定测量的可靠性描述了其准确性稳定性。这两者帮助我们评估在相同情况下两次进行相同测量是否会得到相同或非常相似的结果。回顾我们的体温例子:用口温度计测量儿童体温可能不太可靠,因为这个过程需要孩子闭口相对较长时间(根据我的经验,他们在这方面表现不佳)。相比之下,腋下测量可能更可靠 —— 因为您可以拥抱他们以保持温度计的位置,但可能不能像其他方法那样提供孩子真实的体内温度读数的准确性。这就是为什么大多数医疗建议根据使用哪种方法测量体温为儿童设置不同的发热温度阈值的原因。

对于抽象概念和现实世界的数据,确定数据测量的可靠性尤为棘手,因为真的无法再次收集数据——无论是因为成本过高、情况无法复制,还是两者兼而有之。在这些情况下,我们通常通过将一个类似群体与另一个类似群体进行比较来估计可靠性,使用之前或新收集的数据。因此,即使学校在一年内的标准化考试成绩出现显著波动表明这些分数可能不是学校质量的可靠衡量标准,这种不一致本身只是故事的一部分。毕竟,这些考试成绩可能反映了教学质量,但它们也可能反映了考试被实施的变化、评分方式或其他对学习或考试环境的干扰。为了确定标准化考试数据是否足够可靠以成为你的“最佳”学校评估的一部分,你需要查看其他年份或其他学校的比较数据,同时了解可能导致波动的更广泛情况。最终,你可能会得出结论,大多数考试成绩信息足够可靠以纳入,但应删除一些特定数据点,或者你可能会得出结论,数据过于不可靠,不能成为高质量数据流程的一部分。

代表性

数据驱动系统的关键价值主张是它们允许我们生成关于人和现象的见解——甚至预测——这些人或现象对于人类来说过于庞大或过于复杂,无法有效推理。通过整理和分析数据,逻辑是,我们可以做出比人类更快速、更公平的决策。鉴于即使是个人——更不用说公司了——如今都可以访问的强大计算工具,毫无疑问,数据驱动系统可以比人类更快地生成“决策”。然而,这些见解是否是特定人群或情况的准确描述,直接取决于所使用数据的代表性

数据集是否足够代表性取决于几个因素,其中最重要的回到了我们在“有效性”中讨论过的“为谁?”问题。如果你试图为特定小学设计新的课程表,你可能能够收集关于整个学生群体的数据。如果数据适应性的所有其他标准都已满足,那么你已经知道你的数据是具有代表性的,因为你已经直接从或关于将适用的整个群体收集了数据。

但是,如果你试图为整个城市的学校完成相同的任务呢?几乎不可能收集到每个学校每个学生的数据,这意味着当你尝试设计新的课程表时,你将仅依赖于部分学生的输入。

无论何时,当你以这种方式处理子集或样本时,确保它代表你计划应用发现的更广泛人群至关重要。虽然适当的抽样方法学超出了本书的范围,^(4) 基本思想是,为了你的见解能准确地推广到特定社群,你使用的数据样本必须按比例反映该社群的构成。这意味着在你甚至能知道你的样本是否代表性之前,你需要投入时间和资源来全面了解该社群的一些事情作为一个整体

此时你可能会想:等等,如果我们已经可以获得整个人群的信息,我们一开始就不需要样本了!这是真的——在某种程度上是真的。在许多情况下,我们可以获得一些关于整个社群的信息——只是不是我们需要的精确信息。例如,在我们的学校排课场景中,我们理想情况下会获取有关学生每天如何以及多长时间通勤的信息,以及其监护人的日程安排的一些了解。但是,我们可能已经有关于整个学校人口的信息(如果我们与学校系统合作),可能仅包括家庭地址、学校地址,也许还有交通支持类型。利用这些信息,可能还结合一些额外的行政信息,我们可以开始为特定类型学生通勤者在整个人群中所占比例创建估计,然后试图在调查结果中选择代表性样本以复制这些比例。只有在这一点上,我们才准备好进入数据整理过程的下一步。

正如你所见,确保代表性要求我们仔细考虑人群的哪些特征与我们的数据整理问题相关,并寻找足够的额外信息,以确保我们的数据集比例地代表这些特征。也许并不奇怪的是,这是许多数据驱动产品和服务在数据适应性测试中屡屡失败的原因。虽然公司和研究人员可能吹嘘他们用于在系统中开发的数据的数量,但事实是,大多数对公司和研究人员来说容易获取的数据集往往不代表例如美国或全球人口。例如,关于搜索引擎趋势、社交媒体活动、公共交通使用或智能手机拥有情况的数据,例如,都极不可能代表更广泛的人口,因为它们不可避免地受到诸如互联网访问和收入水平之类的影响。这意味着一些社区在这些数据集中过度代表,而其他一些社区(有时严重地)不足代表。其结果是系统不泛化,比如面部识别系统无法“看到”黑人面孔

面对不具代表性的数据,你会怎么做呢?至少,你需要修订(并清楚地传达)你的“为谁?”评估,以反映它实际上代表的人群;这是你的数据整理洞察力有效的唯一社区。还要记住,代表性只能确保你的数据整理努力的结果准确反映现实;这并不是对是否应该“持续”这种现实的价值判断。如果你的数据整理工作的结果将用于对系统或组织进行更改,那么你的数据整理过程的结束实际上只是开始考虑如何处理你的洞察力,特别是对复杂问题如公平性

即使你拥有整个人口的数据,这也是真实的。例如,如果你想知道哪些组织获得了某种类型的资助,那么你感兴趣的“人口”或社区只是那些资助接收者。同时,检查你的数据是否代表整体人口仍然有价值:如果一个或多个社区在你的资助接收者群体中过度或不足代表,这可能暗示着影响谁获得这笔资金的隐藏因素。

评估数据完整性

数据“适合”实质上是关于你是否拥有正确的数据来回答你的数据整理问题。另一方面,“完整性”主要是关于你拥有的数据是否能支持你需要执行的分析来回答这个问题。正如你将在本节中看到的,执行完整性评估时需要考虑数据的许多不同方面。但是,一个给定的数据集是否需要拥有所有这些特征才能具备高完整性和高质量?不一定。虽然有些是必不可少的,但其他特征的重要性取决于你具体的问题和你需要回答它的方法。而且除了少数例外,许多特征是你将作为数据整理过程的一部分增强和开发的数据特征。

换句话说,确保数据“适合”的情况是不可选的,但你的数据整理项目需要的“完整性”类型和程度会有所不同。当然,你的数据满足的要求越多,它对你和他人都越有用。

一般而言,一个高完整性的数据集将在某种程度上包括以下特征:^(5)

必要但不足的

  • 已知来源的

  • 良好注释的

重要的

  • 及时的

  • 完整的

  • 大容量的

  • 多变量的

  • 原子的

可实现的

  • 一致的

  • 清晰的

  • 维度结构化的

正如我之前提到的,然而,并非所有这些数据完整性特征同等重要。其中一些是必不可少的,而另一些几乎总是数据整理过程中某些步骤的结果,而不是其先导条件。而在你开始分析之前,希望你的数据具备尽可能多的这些特征之间,总是需要在更详细地描述你的数据和及时完成你的工作之间取得平衡。

在这个意义上,评估数据完整性可以成为优先考虑数据整理工作的一种有用方式。例如,如果一个数据集缺少“必要但不足的”特征中的任何一个,你可能需要完全放弃它。如果它在“重要的”特征中缺少一两个,可能仍然有可能通过与其他数据的合并或限制分析和声明的范围来挽救你正在处理的数据。与此同时,开发“可实现的”特征通常是你的数据整理过程中“清理”步骤的目标,而不是你第一次遇到它时就可以期待大多数现实世界数据具备的内容。

最终,你的数据集能够体现以下特征的程度,取决于你投入数据整理项目的时间,但如果缺乏其中大部分特征,你的洞察力将受限。正如将在第六章详细展示的那样,这种变异性正是数据整理和数据质量如此紧密交织的原因。

必要,但不足以

大多数情况下,我们正在处理的数据是由其他人编制的——或者是一群我们无法直接接触的人和流程。与此同时,我们需要能够支持我们的数据处理过程以及从中得出的任何见解。这意味着有几个数据特征对于成功的数据处理过程非常重要

来源已知

如“什么是“数据质量”?”中所讨论的,数据是关于衡量什么以及如何衡量的人类决策的产物。这意味着使用他人收集的数据集需要对他们进行相当程度的信任,尤其是因为很少可能独立验证每一个数据点;如果可以的话,你可能会选择自己收集数据。这就是为什么了解数据集的来源如此重要:如果你不知道谁编制了数据、他们使用的方法和/或他们收集数据的目的,你将很难判断它是否适合你的数据处理目的,或者如何正确解释它。

当然,这并不意味着你需要知道每个帮助构建给定数据集的人的生日和最喜欢的颜色。但是,你应该尽力了解足够的关于他们的专业背景、收集数据的动机(例如,它是否被法律要求)、以及他们使用的方法,这样你就能大致了解哪些测量值需要核实,哪些可能可以直接采用。理想情况下,关于数据作者的信息和关于这些过程的充分文档应该很容易获得,以便你可以迅速回答关于数据来源的所有这些问题。然而,如果他们难以找到,你可能会考虑放弃;因为你需要这些信息来评估数据适合性,你的时间可能花在寻找其他数据集上,甚至是自己收集数据。

良好注释

一个良好注释的数据集有足够的周围信息,或者元数据,使得解释成为可能。这将包括从数据收集方法的高层次解释到描述每个数据测量及其单位的“数据字典”等一切。尽管这可能看起来很简单,但并不总是有关于如何提供这些注释信息的公认标准:有时它直接出现在数据文件或数据条目本身中,有时信息可能包含在完全与检索数据位置不同的单独文件或文档中。

无论数据的结构或提供方式如何,健壮的数据注释文档都是高完整性数据的重要组成部分,因为没有这些文档,就不可能应用任何分析或从中得出任何推断。例如,试想一下,试图解释一个预算而不知道提供的数字是美元、千美元还是百万美元,显然是不可能的。或者以一种非常美国化的表达来说明数据字典等注释文档的重要性,有时可能需要起诉才能获得。

重要

尽管在数据整理中,除非有关于数据集的充分溯源和元数据信息,否则你将无法进展,但是除非你有足够、来自正确时间段且具有适当详细级别的数据,否则你仍然无法走得太远。话虽如此,以下数据特征是我们可能经常评估的,并且有时可以通过我们自己的数据整理工作来改进。因此,尽管处理起来已经拥有这些特征的数据集肯定会更容易,但即使在一些方面表现不佳的数据集,仍然值得探索。

及时

你使用的数据有多新?除非你正在研究一个历史时期,确保你的数据足够新以有意义地描述当前世界的状态是重要的——尽管“太旧”是多老将取决于你正在探索的现象以及收集和发布关于它的数据的频率。

例如,如果你对社区人口统计数据感兴趣,但你最近的数据已经几年前的,那么很可能自那时以来情况已经发生了很大变化。对于失业数据来说,超过一个月的数据将不再及时,而对于股市数据来说,只需几秒钟信息就可能被认为已经太旧,无法用于交易。除非涉及到你已经熟悉的领域,否则评估你的数据是否及时可能需要一些专家研究以及数据发布者的数据。

完整

数据集是否包含了它应该包含的所有数据值?例如,在“USDA 数据追踪”中的 USDA 苹果数据中,只有少数行包含了外观描述,而大多数行都是空白的。即使数据的部分内容如此不完整,我们仍然能够生成有用的数据洞察吗?回答这个问题首先意味着要回答另外两个问题。首先,为什么数据丢失了?其次,你是否需要这些数据测量结果来执行你的数据整理过程中所需的特定分析?

例如,你的数据可能不完整,因为个别数值缺失,或者数据报告不规律——也许数据通常每月记录,但却存在半年间隔的数据空白。数据集也可能被截断,当大数据集在电子表格程序中打开时,这是一个常见问题。无论什么原因,找出数据缺失的原因是至关重要的,以便知道如何继续进行。在“USDA 数据调查”中,如果我们主要关心不同苹果品种的定价方式,我们可以考虑忽略“外观”类别,或者我们可以再次联系市场终端,澄清外观列中的空白值是否实际上对应于他们未明确指出的某个默认值。即使是截断的数据,如果我们可用的数据涵盖了我们的目的所需的足够时间段,也可能不成问题,但了解数据的真实记录数和日期范围以进行上下文分析仍然很有用。虽然有时可以使用统计技巧使数据收集中的间隙变得不那么问题,但了解记录间隙是由于某些破坏性事件造成的可能会改变您进行分析的类型,甚至是您完全追求的问题。

换句话说,虽然拥有完整的数据始终是可取的,一旦您知道数据缺失的原因,您可能可以继续进行数据整理过程。但是,您应该始终了解并确保记录所学到的内容!

高容量

“足够”数据点有多少才够?至少,数据集需要有足够的记录来支持回答您特定问题所需的分析类型。如果您需要的是计数——例如,某一年中涉及噪音投诉的 311 呼叫数量——那么拥有“足够”数据意味着拥有那一年中所有 311 呼叫的记录。

然而,如果您的问题涉及一般或可普遍化的模式或趋势,那么“足够”的定义就不那么清晰了。例如,如果您想确定哪个 Citi Bike 站点最“繁忙”,您应该考虑多长时间段?一周?一个月?一年?应该只考虑工作日,还是所有天?正确的答案部分取决于更详细地说明您的问题。您是对通勤者或访客的体验感兴趣吗?您是在生成驱动交通规划、零售布局或服务质量的见解吗?另外,您是否真的关心哪个站点最“繁忙”,还是更关心特定周转率?如常见的情况一样,正确的答案主要取决于正确说明问题——而这通常需要非常具体。

然而,评估数据“完整性”的其中一个棘手部分是,在不太了解主题领域的情况下,很难考虑可能影响你正在调查的趋势或模式的因素。例如,尽管我们可能很容易预期 Citi Bike 的使用量可能随季节变化而变化,但公共交通服务的减少呢?票价的增加呢?这些变化可能对我们的分析有影响,但我们在刚刚开始时如何知道呢?

答案——就像往常一样——是(人类)专家。也许票价上涨暂时增加了共享单车的骑行人数,但这种增长只持续了几个月。也许自行车通勤者已经做好了应对恶劣天气的准备,即使下雪也会坚持他们的习惯。在无限的时间和数据条件下,我们可能能够自己回答这些问题;但与人类交流要快得多,信息量也要丰富得多。信不信由你,几乎每个领域都有专家存在。例如,快速搜索Google Scholar上关于“季节性共享单车骑行量”的内容,从博客文章到同行评审的研究都应有尽有。

这对数据的完整性意味着什么?现有的研究,或者更好的是与学科专家的实时交流(参见“学科专家”了解更多),将帮助你决定在分析中包含哪些因素,从而决定你选择的分析需要多少数据才能使其完整。

多变量

调整现实世界的数据不可避免地意味着遇到真实世界的复杂性,各种因素可能影响我们试图调查的现象。我们“最繁忙”的 Citi Bike 站点就是一个例子:除了季节性和交通服务外,周围的地形或站点的密度也可能起到作用。如果我们的 Citi Bike 数据仅包含关于特定站点启程和结束的骑行次数的信息,那么很难创建出超出“该站点在特定时间段内有最多自行车被取出和归还”的分析。

当数据是多变量的时候,意味着每条记录都有多个属性特征与之关联。例如,对于历史 Citi Bike 数据,我们知道在“利用 Citi Bike 数据上路”中经过整理之后,我们拥有以下所有信息:

['tripduration', 'starttime', 'stoptime', 'start station id', 'start station
 name', 'start station latitude', 'start station longitude', 'end station id',
 'end station name', 'end station latitude', 'end station longitude', 'bikeid',
 'usertype', 'birth year', 'gender']

每次记录的骑行都涉及 15 种不同的特征,其中任意数量可能能够利用起来更有意义或更细致地理解哪个 Citi Bike 站点最“繁忙”。

原子的

原子数据非常细粒度;它既精确测量,又没有汇总为摘要统计或指标。一般来说,摘要统计量如比率和平均数并不是进一步分析的好选择,因为已经丢失了大量底层数据的细节。例如,以下两组数字的算术平均数均值都是 30:20、25、30、45 和 15、20、40、45。当进行不同数据集的比较时,摘要统计量通常很有帮助,但它们对数据底层结构的洞察力太少,以支持进一步分析。

可实现性

无论数据集一开始看起来如何,事实是,真正干净、组织良好且无误的数据几乎是不可能的——部分原因是,随着时间的推移,某些事物(如金额)简单地变得不一致。因此,作为数据处理者,有些数据质量特征我们应该始终期待需要审查和改进。幸运的是,这正是 Python 的灵活性和可重用性真正发光的地方——这意味着随着我们积累编程技能,这些任务会变得更容易、更快速。

一致性

高完整性数据需要在多种不同方式上保持一致。数据集中最明显的一致性类型通常与随时间收集的数据频率有关。个体记录之间的时间间隔一致吗?是否存在间隔?数据记录之间的不规则间隔是重要的调查对象(如果不解决),部分原因是因为它们可能是数据收集过程中的干扰或不一致的第一个指标。另一个普遍出现一致数据的来源通常是每当涉及文本的数据字段时:包含名称或描述符的字段几乎不可避免地会包含相同术语的多种拼写。甚至可能看起来很容易标准化的字段也可能包含不同程度的细节。例如,“邮政编码”字段可能包含五位邮政编码条目和更精确的“Zip+4”值。

其他类型的一致性可能不那么明显,但同样重要。例如,度量单位需要在整个数据集中保持一致。虽然这可能看起来很明显,但实际上比你想象的更容易出现一致的情况。比如说,你想看一下十年间苹果的价格。当然,你的数据可能记录了所有价格的美元,但通货膨胀会不断改变一个美元的价值。虽然大多数人都知道摄氏度和华氏度的“度”大小不同,但问题并不仅限于此:英制品脉约为 568 毫升,而美制品脉约为 473 毫升。事实上,甚至拿拿破仑·波拿巴(Napoleon Bonaparte)身高矮小的描述,可能是 19 世纪法国英寸(约 2.71 厘米)和今天版本(约 2.54 厘米)之间的不一致的结果。

这类不一致性的解决方案是在进行任何比较或分析之前标准化您的数据。在大多数情况下,这只是简单算术的问题:您只需选择将所有其他单位转换为其何种解释(文档化的另一重要时刻!)。即使是货币,分析师们通常也会开始将其转换为“真实”(即通货膨胀控制的)美元,使用某一年作为基准。例如,如果我们想比较 2009 年(上次调整时)美国联邦最低工资的实际美元价值与 2021 年的差异,我们可以使用由劳工统计局(BLS)维护的通货膨胀计算器(如此类的),看到 2009 年每小时 7.25 美元的实际美元价值相当于 2021 年每小时 5.72 美元。

清晰

就像我们的 Python 代码一样,我们的数据及其标签理想上应该易于阅读和解释。现实情况是,字段(甚至数据集)描述有时可能只是需要不断与数据字典或其他资源交叉参考的晦涩代码。这也是为什么这种数据完整性几乎总是数据整理的结果,而不是其前提的一部分。

例如,美国人口调查局的美国社区调查人口和住房估计的表代码是DP05。也许有一些逻辑在其中。但对于人口普查数据的偶尔使用者来说,这显然并不明显,就像列标签DP05_0001E一样可能不会^(6)。虽然下载这个人口普查表会包含多个文件,帮助你理解文件名和列标题的含义,但要开发出清晰、高完整性的数据集很可能需要相当多的重命名、重新格式化和重新索引,特别是涉及政府生产的数据。然而,无论如何,随着进行的信息来源记录和重命名过程至关重要。

结构化维度

结构化的维度数据包含已分组或额外标记的字段,例如地理区域、年份或语言,这些特征通常为数据增强(我们将在“数据增强”中讨论)和数据分析提供快速入口,因为它们减少了我们自己必须进行的相关性和聚合工作。这些特征还可以作为数据创建者认为重要记录的指示器。

例如,我们的 Citi Bike 数据的维度包括自行车是否被分配给“订阅者”或“顾客”的账户,以及账户持有人的出生年份和性别—表明数据的设计者认为这些特征可能会提供关于 Citi Bike 行程和使用的有用见解。然而,正如我们将在第七章中看到的那样,他们并没有选择包括任何“工作日/假日”指示器—这意味着这是我们如果需要的话需要自己推导的数据维度

提高数据质量

正如前面所述,数据质量的许多方面是数据整理过程的产物,无论是调和和标准化单位、澄清不明确数据标签的含义还是寻找关于数据集代表性的背景信息。这部分说明的是,在现实世界中,确保数据质量至少部分上是多次迭代的数据整理过程的结果。虽然数据整理过程中这些阶段的术语各不相同,我通常将它们描述为数据清理和数据增强

数据清理

实际上,“数据清理”不是数据整理过程中的独立步骤,而是伴随每一个其他步骤而进行的持续活动,这是因为大多数数据在我们接触时都是干净的,而且数据集(或其部分)需要“清理”的方式通常在工作中逐步显现。从高层次来看,干净的数据可能被概括为没有错误或拼写错误,如不匹配的单位、同一单词或术语的多种拼写以及未分隔良好的字段和缺失或不可能的值。虽然这些问题中许多至少在某种程度上是容易识别的(尽管并非总是容易纠正),但更深层次的数据问题仍然可能存在。测量变化、计算错误以及其他疏忽,尤其是在系统生成的数据中,通常在进行了一定程度的分析并与具有重要专业知识和/或第一手经验的人员进行了“现实检查”后才会显现出来。

数据清洗的迭代性质说明了数据整理是一个循环过程,而不是线性的步骤序列:随着你的工作揭示了关于数据的更多信息,你对其与世界关系的理解加深,你可能发现需要重新审视之前完成的工作,并重复或调整数据的某些方面。当然,这也是为什么记录你的数据整理工作如此关键的另一个原因:你可以利用你的文档快速识别何处以及如何进行了任何更改或更新,并与现在的知识相调和。如果没有健全的文档来指导你,你可能很快就会发现自己需要从头开始。

数据增强

增强数据集是通过扩展或详细说明来实现的,通常是通过与其他数据集连接——这实际上是 21 世纪“大数据”的本质。通过使用在数据集之间共享的特征,可以将来自多个来源的数据汇集在一起,以更完整地描述发生在世界中的情况,填补空白,提供协同措施或添加帮助我们更好评估数据适应性的上下文数据。在我们的“最佳”学校示例中,这可能意味着使用学校代码将不同实体收集的几种数据,如州级标准化考试成绩和本地建筑信息,汇总到一起,如“数据解码”中提到的。通过有效的研究和数据整理的结合,数据增强可以帮助我们建立数据质量,并回答通过任何单一数据集无法解决的细微问题。

与数据清洗类似,数据增强的机会可能在数据整理过程的几乎任何时候出现。同时,我们引入的每个新数据集都将产生其自身的数据整理过程。这意味着,与数据清洗不同,数据增强没有明确的“结束状态”——总会有另一个可以添加的数据集。这也是为什么明确和简明地在数据整理问题前提上述明是如此重要的另一个原因:如果没有明确表达关于你试图调查的内容的声明,你很容易在数据整理工作中耗尽时间或其他资源。好消息是,如果你一直在保持你的数据日记,你将永远不会失去对未来项目中有用的有前途的数据集的追踪。

结论

由于到目前为止我们的实际数据操作有限,本章讨论的许多概念可能现在看起来有点抽象。别担心!接下来的章节中,我们将开始处理来自不同格式和不同来源的数据,深入了解数据质量的各种特征是如何影响我们在访问、评估、清理、分析和呈现数据时做出决策的。到本卷结束时,您将能够创建有意义、准确和引人入胜的数据分析和可视化,与世界分享您的见解!

在下一章中,我们将通过处理各种格式的数据,将其结构化,以便进行所需的清理、增强和分析。让我们开始吧!

^(1) 例如,参见埃曼纽尔·马丁内斯和劳伦·克尔施纳在The Markup上的文章“抵押贷款批准算法中隐藏的秘密偏见”。

^(2) “世界上最有价值的资源不再是石油,而是数据”,见经济学人

^(3) 例如,参见亚德里安·拉弗兰斯在大西洋上的文章“失落网络的突袭”。

^(4) 要了解简明易懂的概述,请参见阿米塔夫·巴纳吉和苏普拉卡什·乔杜里的“无泪统计”

^(5) 此列表改编自斯蒂芬·尤的优秀著作《Now You See It: Simple Visualization Techniques for Quantitative Analysis》(Analytics Press),并根据我的数据处理经验进行了调整。

^(6) 顺便提一下,它表示总人口估计。

^(7) 详细讨论大数据,请参阅丹娜·博伊德和凯特·克劳福德的“大数据的关键问题”

第四章:在 Python 中处理基于文件和基于 Feed 的数据

在第三章中,我们着重讨论了对数据质量贡献巨大的许多特征——从数据完整性、一致性和清晰度到数据适应性的可靠性、有效性和代表性。我们讨论了清理和标准化数据的必要性,以及通过与其他数据集的结合来增强数据的必要性。但在实践中,我们如何实现这些目标呢?

很显然,要评估数据集的质量,首先需要审查其内容,但这有时说起来比做起来更容易。几十年来,数据整理一直是一项高度专业化的追求,导致公司和组织创建了各种不同(有时是专有的)数字数据格式,以满足其特定需求。通常,这些格式带有自己的文件扩展名——你可能见过其中的一些:xlscsvdbfspss 都是通常与“数据”文件相关联的文件格式。^(1) 尽管它们的具体结构和细节各不相同,但所有这些格式都可以被描述为基于文件的——也就是说,它们包含(或多或少)静态文件中的历史数据,可以从数据库下载、通过同事的电子邮件发送,或者通过文件共享站点访问。最重要的是,基于文件的数据集在今天打开和一周、一个月或一年后打开时,大部分情况下会包含相同的信息。

如今,这些基于文件的格式与过去 20 年中伴随实时网络服务而出现的数据格式和接口形成鲜明对比。网络数据今天涵盖从新闻到天气监测再到社交媒体站点的一切,这些 Feed 风格的数据源具有其独特的格式和结构。像xmljsonrss 这样的扩展表示这种实时数据类型,通常需要通过专门的应用程序接口或 API 访问。与基于文件的格式不同,通过 API 访问同一网络数据位置或“端点”将始终显示出当前可用的最数据——而这些数据可能在几天、几小时甚至几秒钟内发生变化。

当然,这些并不是完美的区分。许多组织(特别是政府部门)提供可以下载的基于文件的数据,但当源数据更新时,会覆盖这些文件,并使用相同的名称创建新的文件。同时,Feed 风格的数据格式可以被下载并保存以供将来参考——但在线的源位置通常不提供访问旧版本的权限。尽管每种数据格式类别有时会有非常不寻常的用途,但在大多数情况下,您可以利用基于文件和基于 Feed 的数据格式之间的高级别区别,来帮助选择适合特定数据整理项目的最合适的数据源。

如何知道你需要基于文件还是基于源的数据?在许多情况下,你没有选择权。例如,社交媒体公司通过其 API 提供对其数据源的即时访问,但通常不提供回顾性数据。其他类型的数据——特别是那些自其他来源综合或在发布前经过大量审查的数据——更有可能以基于文件的格式提供。如果你确实可以在基于文件和基于源的格式之间进行选择,那么你的选择将取决于你的数据整理问题的性质:如果关键在于拥有最近的可用数据,那么可能基于源的格式更可取。但是,如果你关注的是趋势,那么基于文件的数据,更有可能包含随时间收集的信息,可能是你的最佳选择。尽管如此,即使两种格式都可用,也不能保证它们包含相同的字段,这可能再次为你做出决定。

在本章中,我们将通过实际示例,从几种最常见的基于文件和基于源的数据格式中整理数据,目标是使它们更容易审查、清洗、增强和分析。我们还将查看一些可能需要出于必要性而处理的更难处理的数据格式。在这些过程中,我们将大量依赖 Python 社区为这些目的开发的出色的各种库,包括用于处理从电子表格到图像等各种内容的专业库和程序。到我们完成时,你将具备处理各种数据整理项目所需的技能和示例脚本,为你的下一个数据整理项目铺平道路!

结构化数据与非结构化数据

在我们深入编写代码和整理数据之前,我想简要讨论另一个可能影响数据整理项目方向(和速度)的数据源的关键属性——处理结构化数据与非结构化数据。

大多数数据整理项目的目标是产生见解,并且通常是使用数据做出更好的决策。但决策是时间敏感的,因此我们与数据的工作也需要权衡取舍:我们可能不会等待“完美”的数据集,而是可能结合两个或三个不太完美的数据集,以便建立我们正在调查的现象的有效近似,或者我们可能寻找具有共同标识符(例如邮政编码)的数据集,即使这意味着我们需要稍后推导出真正感兴趣的特定维度结构(比如街区)。只要我们能在不牺牲太多数据质量的情况下获得这些效益,提高我们数据工作的及时性也可以增加其影响力。

使我们的数据处理更有效的最简单方法之一是寻找易于 Python 和其他计算工具访问和理解的数据格式。尽管计算机视觉、自然语言处理和机器学习的进步使计算机能够分析几乎不考虑其底层结构或格式的数据变得更加容易,但事实上,结构化机器可读的数据仍然——或许不足为奇地——是最直接的数据类型。事实上,虽然从访谈到图像再到书籍文本都可以用作数据源,但当我们讨论“数据”时,我们通常想到的更多是结构化的、数值化的数据。

结构化数据是任何已按某种方式组织和分类的数据类型,通常以记录和字段的形式存在。在基于文件的格式中,通常是行和列;在基于数据源的格式中,它们通常是(本质上是)对象列表或字典

相比之下,非结构化数据可能由不同数据类型的混合组成,包括文本、数字,甚至照片或插图。例如,杂志或小说的内容,或歌曲的波形通常被认为是非结构化数据。

如果你现在在想,“等等,小说有结构啊!章节呢?”那么恭喜你:你已经像数据处理者一样思考了。我们可以通过收集关于世界的信息并对其应用结构来创建几乎任何事物的数据。[⁴] 事实上,这就是所有数据的生成方式:我们通过文件和数据源访问的数据集都是某人关于如何收集和组织信息的决定的产物。换句话说,有多种方式可以组织信息,但所选择的结构影响了其分析方法。这就是为什么认为数据可以某种方式“客观”有点荒谬;毕竟,它是(固有主观的)人类选择的产物。

例如,尝试进行这个小实验:想象一下如何组织你的某个收藏(可以是音乐、书籍、游戏或茶叶的各种品种——你说了算)。现在问问朋友他们如何组织他们自己的这类收藏物品。你们的方式一样吗?哪种“更好”?再问问其他人,甚至第三个人。虽然你可能会发现你和朋友们用于组织音乐收藏等系统之间的相似之处,但我会非常惊讶,如果你们中有任何两个人做法完全相同的话。事实上,你可能会发现每个人都有点不同的方式,但坚信自己的方式是“最好”的。而且确实如此,对他们来说是最好的。

如果这让您想起了我们在“如何?以及为谁?”中的讨论,那并非巧合,因为您的数据整理问题和努力的结果最终将是——你猜对了!——另一个数据集,它将反映的兴趣和优先级。它也将是有结构和组织的,这使得以某些方式处理它比其他方式更容易。但这里的要点不是任何给定的方式是对还是错,而是每个选择都涉及权衡。识别和承认这些权衡是诚实和负责任地使用数据的关键部分。

因此,在使用结构化数据时的一个关键权衡是,这要求依赖他人在组织基础信息时的判断和优先考量。显然,如果这些数据是按照一个开放透明的流程,并涉及到合格的专家进行结构化的,那么这可能是一件好事——甚至是一件伟大的事情!像这样经过深思熟虑的数据结构可以让我们对一个我们可能完全不了解的主题有早期的洞察。另一方面,也存在着我们可能会继承他人的偏见或设计不良选择的可能性。

当然,非结构化数据给予了我们完全的自由,可以将信息组织成最适合我们需求的数据结构。不足为奇的是,这要求我们负责参与一个强大的数据质量过程,这可能既复杂又耗时。

我们如何事先知道特定数据集是结构化的还是非结构化的?在这种情况下,文件扩展名肯定可以帮助我们。基于提要的数据格式始终具有至少某种结构,即使它们包含“自由文本”块,如社交媒体帖子。因此,如果您看到文件扩展名.json.xml.rss.atom,这些数据至少有某种记录和字段结构,正如我们将在“基于提要的数据——网络驱动的实时更新”中探讨的那样。以.csv.tsv.txt.xls(x).ods结尾的基于文件的数据往往遵循表格类型、行和列的结构,正如我们将在下一节中看到的那样。而真正的非结构化数据,则最有可能以.doc(x).pdf的形式出现。

现在,我们对可能遇到的不同类型数据源有了很好的掌握,甚至对如何定位它们有了一些了解,让我们开始处理吧!

使用结构化数据

自数字计算的早期以来,表格一直是结构化数据的最常见方式之一。即使在今天,许多最常见且易于处理的数据格式仍然不过是表格或表格的集合。事实上,在第二章中,我们已经使用了一种非常常见的表格类型数据格式:.csv或逗号分隔值格式。

基于文件、表格类型数据——从定界到实现

一般来说,你通常会遇到的所有表格类型数据格式都是所谓的分隔文件的示例:每个数据记录都在自己的行或行上,数据值的字段或列之间的边界由特定的文本字符指示或分隔。通常,文件中使用的分隔符的指示被纳入到数据集的文件扩展名中。例如,.csv文件扩展名代表逗号分隔值,因为这些文件使用逗号字符(,)作为分隔符;.tsv文件扩展名代表制表符分隔值,因为数据列是由制表符分隔的。以下是常见与分隔数据相关的文件扩展名列表:

.csv

逗号分隔值文件是你可能会遇到的最常见的表格类型结构化数据文件之一。几乎任何处理表格数据的软件系统(例如政府或公司数据系统、电子表格程序,甚至专门的商业数据程序)都可以将数据输出为.csv,正如我们在第二章中看到的,Python 中有方便的库可以轻松处理这种数据类型。

.tsv

制表符分隔值文件已经存在很长一段时间,但是描述性的.tsv扩展名直到最近才变得普遍。虽然数据提供商通常不解释为什么选择一个分隔符而不是另一个分隔符,但是对于需要包含逗号的值的数据集,例如邮政地址,制表符分隔文件可能更常见。

.txt

具有此扩展名的结构化数据文件通常是伪装成.tsv文件的文件;旧的数据系统通常使用.txt扩展名标记制表符分隔的数据。正如您将在接下来的示例中看到的,最好在编写任何代码之前使用基本文本程序(或类似 Atom 的代码编辑器)打开和查看任何您想要处理的数据文件,因为查看文件内容是了解您正在处理的分隔符的唯一可靠方法。

.xls(x)

这是使用 Microsoft Excel 生成的电子表格的文件扩展名。因为这些文件除了公式、格式和其他简单分隔文件无法复制的特性之外,还可以包含多个“工作表”,所以它们需要更多的内存来存储相同数量的数据。它们还具有其他限制(如只能处理一定数量的行),这可能会对数据集的完整性产生影响。

.ods

开放文档电子表格文件是由许多开源软件套件(如LibreOfficeOpenOffice)生成的电子表格的默认扩展名,具有类似于.xls(x)文件的限制和功能。

在我们深入研究如何在 Python 中处理这些文件类型之前,值得花一点时间考虑一下何时我们可能想要处理表格类型数据以及何时找到它。

何时处理表格类型数据

大多数情况下,我们在源数据格式方面并没有太多选择。事实上,我们需要进行数据整理的主要原因之一是因为我们拥有的数据不完全符合我们的需求。尽管如此,了解您希望能够处理的数据格式仍然是有价值的,这样您可以利用它来指导您对初始数据的搜索。

在 “结构化数据与非结构化数据” 中,我们讨论了结构化数据的优缺点,现在我们知道,表格类型数据是最古老和最常见的机器可读数据形式之一。这一历史部分意味着多年来许多形式的源数据已经被塞进表格中,尽管它们可能并不一定适合于类似表格的表示。然而,这种格式对于回答关于时间趋势和模式的问题特别有用。例如,在我们的 Citi Bike 练习中,来自 第二章 的“顾客”与“订阅者”在一个月内骑行 Citi Bike 的次数。如果我们愿意,我们可以对每个可用的 Citi Bike 骑行月份执行相同的计算,以了解该比例随时间的任何模式。

当然,表格类型的数据通常不适合实时数据或者每个观察结果不包含相同可能值的数据。这类数据通常更适合我们在 “基于 Feed 的数据—网络驱动的实时更新” 中讨论的基于 feed 的数据格式。

在哪里找到表格类型数据

由于绝大多数机器可读数据仍然以表格类型数据格式存在,因此这是最容易定位的数据格式之一。电子表格在各个学科中很常见,并且许多政府和商业信息系统依赖于能够以此方式组织数据的软件。无论何时你从专家或组织请求数据,你可能都会得到表格类型的格式。这同样适用于几乎所有在线开放数据门户和数据共享站点。正如我们在 “智能搜索特定数据类型” 中讨论的,即使是通过搜索引擎,你也可以找到表格类型数据(和其他特定文件格式),只要你知道如何查找。

用 Python 整理表格类型数据

为了帮助说明在 Python 中处理表格类型数据有多简单,我们将逐步介绍如何从本节提到的所有文件类型中读取数据,再加上一些其他类型,只是为了确保。虽然在后面的章节中,我们将看看如何进行更多的数据清理、转换和数据质量评估,但我们目前的重点仅仅是访问每种数据文件中的数据并使用 Python 与之交互。

从 CSV 中读取数据

如果你没有在 Chapter 2 中跟随进行,这里是一个从 .csv 文件中读取数据的复习,使用了来自 Citi Bike 数据集的示例(Example 4-1)。和往常一样,在我的脚本顶部的注释中,我包含了程序正在做的描述以及任何源文件的链接。由于我们之前已经使用过这种数据格式,所以现在我们只关心打印出前几行数据以查看它们的样子。

Example 4-1. csv_parsing.py
# a simple example of reading data from a .csv file with Python
# using the "csv" library.
# the source data was sampled from the Citi Bike system data:
# https://drive.google.com/file/d/17b461NhSjf_akFWvjgNXQfqgh9iFxCu_/
# which can be found here:
# https://s3.amazonaws.com/tripdata/index.html

# import the `csv` library ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
import csv

# open the `202009CitibikeTripdataExample.csv` file in read ("r") mode
# this file should be in the same folder as our Python script or notebook
source_file = open("202009CitibikeTripdataExample.csv","r") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# pass our `source_file` as an ingredient to the `csv` library's
# DictReader "recipe".
# store the result in a variable called `citibike_reader`
citibike_reader = csv.DictReader(source_file)

# the DictReader method has added some useful information to our data,
# like a `fieldnames` property that lets us access all the values
# in the first or "header" row
print(citibike_reader.fieldnames) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

# let's just print out the first 5 rows
for i in range(0,5): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)
    print (next(citibike_reader))

1

这是我们处理表格类型数据时的得力工具库。

2

open() 是一个内置函数,接受文件名和“模式”作为参数。在这个例子中,目标文件(202009CitibikeTripdataExample.csv)应该与我们的 Python 脚本或笔记本位于同一个文件夹中。模式的值可以是 r 表示“读取”,也可以是 w 表示“写入”。

3

通过打印出 citibike_reader.fieldnames 的值,我们可以看到“User Type”列的确切标签是 usertype

4

range() 函数提供了一种执行某个代码片段特定次数的方法,从第一个参数的值开始,直到第二个参数的值之前结束。例如,下面缩进的代码将执行五次,遍历 i 的值为 01234。有关 range() 函数的更多信息,请参见 “添加迭代器:range 函数”。

运行后的输出应该是这样的:

['tripduration', 'starttime', 'StartDate', 'stoptime', 'start station id',
'start station name', 'start station latitude', 'start station longitude', 'end
station id', 'end station name', 'end station latitude', 'end station
longitude', 'bikeid', 'usertype', 'birth year', 'gender']
{'tripduration': '4225', 'starttime': '2020-09-01 00:00:01.0430', 'StartDate':
'2020-09-01', 'stoptime': '2020-09-01 01:10:26.6350', 'start station id':
'3508', 'start station name': 'St Nicholas Ave & Manhattan Ave', 'start station
latitude': '40.809725', 'start station longitude': '-73.953149', 'end station
id': '116', 'end station name': 'W 17 St & 8 Ave', 'end station latitude': '40.
74177603', 'end station longitude': '-74.00149746', 'bikeid': '44317',
'usertype': 'Customer', 'birth year': '1979', 'gender': '1'}
 ...
{'tripduration': '1193', 'starttime': '2020-09-01 00:00:12.2020', 'StartDate':
'2020-09-01', 'stoptime': '2020-09-01 00:20:05.5470', 'start station id':
'3081', 'start station name': 'Graham Ave & Grand St', 'start station
latitude': '40.711863', 'start station longitude': '-73.944024', 'end station
id': '3048', 'end station name': 'Putnam Ave & Nostrand Ave', 'end station
latitude': '40.68402', 'end station longitude': '-73.94977', 'bikeid': '26396',
'usertype': 'Customer', 'birth year': '1969', 'gender': '0'}

从 TSV 和 TXT 文件中读取数据

尽管其名称如此,但 Python 的 csv 库基本上是 Python 中处理表格类型数据的一站式解决方案,这要归功于 DictReader 函数的 delimiter 选项。除非你另有指示,否则 DictReader 会假定逗号字符(,)是它应该查找的分隔符。然而,覆盖这一假设很容易:你只需在调用函数时指定不同的字符即可。在 Example 4-2 中,我们指定了制表符 (\t),但我们也可以轻松地替换为我们喜欢的任何分隔符(或者源文件中出现的分隔符)。

Example 4-2. tsv_parsing.py
# a simple example of reading data from a .tsv file with Python, using
# the `csv` library. The source data was downloaded as a .tsv file
# from Jed Shugerman's Google Sheet on prosecutor politicians: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
# https://docs.google.com/spreadsheets/d/1E6Z-jZWbrKmit_4lG36oyQ658Ta6Mh25HCOBaz7YVrA

# import the `csv` library
import csv

# open the `ShugermanProsecutorPoliticians-SupremeCourtJustices.tsv` file
# in read ("r") mode.
# this file should be in the same folder as our Python script or notebook
tsv_source_file = open("ShugermanProsecutorPoliticians-SupremeCourtJustices.tsv","r")

# pass our `tsv_source_file` as an ingredient to the csv library's
# DictReader "recipe."
# store the result in a variable called `politicians_reader`
politicians_reader = csv.DictReader(tsv_source_file, delimiter='\t')

# the DictReader method has added some useful information to our data,
# like a `fieldnames` property that lets us access all the values
# in the first or "header" row
print(politicians_reader.fieldnames)

# we'll use the `next()` function to print just the first row of data
print (next(politicians_reader))

1

此数据集在 Jeremy Singer-Vine 的《Data Is Plural》通讯中列出(https://data-is-plural.com)。

这应该产生类似以下的输出:

['', 'Justice', 'Term Start/End', 'Party', 'State', 'Pres Appt', 'Other Offices
Held', 'Relevant Prosecutorial Background']
{'': '40', 'Justice': 'William Strong', 'Term Start/End': '1870-1880', 'Party':
'D/R', 'State': 'PA', 'Pres Appt': 'Grant', 'Other Offices Held': 'US House,
Supr Court of PA, elect comm for elec of 1876', 'Relevant Prosecutorial
Background': 'lawyer'}

尽管.tsv文件扩展名如今变得相对常见,但许多由旧数据库生成的实际上是制表符分隔的文件可能会以.txt文件扩展名的形式传送到您手中。幸运的是,正如前文中所述的那样,在我们指定正确的分隔符的情况下,这并不会影响我们如何处理文件,正如您可以在示例 4-3 中看到的那样。

示例 4-3. txt_parsing.py
# a simple example of reading data from a .tsv file with Python, using
# the `csv` library. The source data was downloaded as a .tsv file
# from Jed Shugerman's Google Sheet on prosecutor politicians:
# https://docs.google.com/spreadsheets/d/1E6Z-jZWbrKmit_4lG36oyQ658Ta6Mh25HCOBaz7YVrA
# the original .tsv file was renamed with a file extension of .txt

# import the `csv` library
import csv

# open the `ShugermanProsecutorPoliticians-SupremeCourtJustices.txt` file
# in read ("r") mode.
# this file should be in the same folder as our Python script or notebook
txt_source_file = open("ShugermanProsecutorPoliticians-SupremeCourtJustices.txt","r")

# pass our txt_source_file as an ingredient to the csv library's DictReader
# "recipe" and store the result in a variable called `politicians_reader`
# add the "delimiter" parameter and specify the tab character, "\t"
politicians_reader = csv.DictReader(txt_source_file, delimiter='\t') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# the DictReader function has added useful information to our data,
# like a label that shows us all the values in the first or "header" row
print(politicians_reader.fieldnames)

# we'll use the `next()` function to print just the first row of data
print (next(politicians_reader))

1

正如在“不要留空格!”中讨论的那样,在我们使用代码时,空白字符必须被转义。在这里,我们使用了一个tab的转义字符,即\t。另一个常见的空白字符代码是\n表示换行(或者根据您的设备是\r表示回车)。

如果一切顺利,这个脚本的输出应该与示例 4-2 中的输出完全相同。

此时您可能会问自己一个问题:“我如何知道我的文件有什么分隔符?”虽然有程序化的方法可以帮助检测这一点,但简单的答案是:看!每当您开始处理(或考虑处理)新数据集时,请首先在您的设备上最基本的文本程序中打开它(任何代码编辑器也是一个可靠的选择)。特别是如果文件很大,使用尽可能简单的程序将使您的设备能够将最大的内存和处理能力用于实际读取数据,从而减少程序挂起或设备崩溃的可能性(同时关闭其他程序和多余的浏览器标签页也会有所帮助)!

虽然我稍后会谈论一些检查真正大型文件中小部分的方法,但现在是开始练习评估数据质量的关键技能的时候——所有这些技能都需要审查您的数据并对其做出评判。因此,虽然确实有“自动化”识别数据正确分隔符等任务的方法,但在文本编辑器中用眼睛查看它通常不仅更快更直观,而且还会帮助您更加熟悉数据的其他重要方面。

实际数据整理:理解失业情况

我们将用于探索一些复杂表格类型数据格式的基础数据集是关于美国失业情况的数据。为什么选择这个数据集?因为失业以某种方式影响到我们大多数人,近几十年来,美国经历了一些特别高的失业率。美国的失业数据每月由劳工统计局(BLS)发布,尽管它们经常被一般新闻来源报道,但通常被视为对“经济”状况的某种抽象指标。这些数字真正代表的含义很少被深入讨论。

当我 2007 年首次加入华尔街日报时,为探索月度经济指标数据(包括失业率)构建交互式仪表板是我的第一个重要项目。我在这个过程中学到的更有趣的事情之一是每个月并不只计算“一个”失业率,而是几个(确切地说是六个)。通常由新闻来源报告的是所谓的“U3”失业率,这是美国劳工统计局描述的:

总失业人数,作为民用劳动力的百分比(官方失业率)。

表面上看,这似乎是对失业的一个简单定义:所有合理可能在工作的人中,有多少百分比不在工作?

然而,真实情况稍微复杂一些。什么是“就业”或被计算为“劳动力”的一部分意味着什么?查看不同的失业数据更清楚地说明了“U3”数字没有考虑到的内容。 “U6”失业率的定义如下:

总失业人数,加上所有边缘附属于劳动力的人员,再加上因经济原因而只能兼职工作的总人数,作为民用劳动力加上所有边缘附属于劳动力的人员的百分比。

当我们阅读附带的注释时,这个较长的定义开始清晰起来:^(5)

注意:边缘附属于劳动力的人员是指目前既不工作也不寻找工作,但表示他们想要工作并且有时间,并且在过去 12 个月内曾经寻找过工作。较少附属的人员是边缘附属的一个子集,他们没有目前寻找工作的原因。经济原因而只能兼职工作的人员是那些希望全职工作并且有时间但不得不接受兼职安排的人员。每年都会随着一月份的数据发布而引入更新的人口控制。

换句话说,如果你想要一份工作(并且在过去一年内曾经寻找过工作),但最近没有寻找过工作——或者你有一份兼职工作,但希望全职工作——那么在 U3 的定义中你不被正式计算为“失业”。这意味着美国人工作多份工作的经济现实(更可能是女性并且有更多孩子的人)^(6),以及可能的“零工”工作者(最近估计占美国劳动力的 30%),^(7)并不一定反映在 U3 数字中。毫不奇怪,U6 率通常比 U3 率每月高出几个百分点。

为了看到这些比率随时间的变化如何,我们可以从圣路易斯联邦储备银行的网站上下载它们,该网站提供了成千上万的经济数据集供下载,包括表格类型的.xls(x)文件以及如我们稍后将在示例 4-12 中看到的,还有提供提要型格式。

您可以从 联邦储备经济数据库 (FRED) 网站 下载这些练习的数据。它展示了自上世纪 90 年代初创建以来的当前 U6 失业率。

要在这张图表上添加 U3 率,请在右上角选择“编辑图表” → “添加线条”。在搜索字段中,输入 UNRATE 然后在搜索栏下方出现时选择“失业率”。最后,点击“添加系列”。使用右上角的 X 关闭此侧窗口,然后选择“下载”,确保选择第一个选项,即 Excel。^(8) 这将是一个 .xls 文件,我们将在最后处理它,因为尽管它仍然广泛可用,但这是一个相对过时的文件格式(自 [2007 年起被 .xlsx 取代成为 Microsoft Excel 电子表格的默认格式)。

要获取我们需要的其他文件格式,只需用电子表格程序如 Google Sheets 打开您下载的文件,选择“另存为”,然后选择 .xlsx,然后重复该过程选择 .ods。现在您应该有以下三个包含相同信息的文件:fredgraph.xlsxfredgraph.odsfredgraph.xls。^(9)

注意

如果你打开了原始的 fredgraph.xls 文件,你可能注意到它包含的不仅仅是失业数据;它还包含一些关于数据来源以及 U3 和 U6 失业率定义的头部信息。在分析这些文件中的失业率时,需要进一步将这些元数据与表格类型的数据分开。但是请记住,目前我们的目标只是将所有不同格式的文件转换为 .csv 格式。我们将在 第七章 处理涉及移除这些元数据的数据清洗过程。

XLSX、ODS 和其他所有格式

大多数情况下,最好避免直接处理保存为 .xlsx.ods 和大多数其他非文本表格类型数据格式的数据。如果您只是在探索数据集阶段,我建议您简单地使用您喜欢的电子表格程序打开它们,然后将它们保存为 .csv.tsv 文件格式,然后再在 Python 中访问它们。这不仅会使它们更容易处理,还可以让您实际查看数据文件的内容并了解其包含的内容。

.xls(x) 和类似的数据格式重新保存和审查为 .csv 或等效的基于文本的文件格式,既能减小文件大小,能让你更好地了解“真实”数据的样子。由于电子表格程序中的格式选项,有时屏幕上看到的内容与实际文件中存储的原始值有很大不同。例如,在电子表格程序中以百分比形式显示的值(例如,10%)实际上可能是小数(.1)。如果你试图基于电子表格中看到的内容而不是像 .csv 这样的基于文本的数据格式进行 Python 处理或分析,这可能会导致问题。

但是,肯定会有一些情况,你需要直接使用 Python 访问 .xls(x) 和类似的文件类型。^(10) 例如,如果有一个 .xls 数据集,你需要定期处理(比如,每个月),每次手动重新保存文件都会变得不必要地耗时。

幸运的是,我们在 “社区” 中谈到的活跃 Python 社区已经创建了可以轻松处理各种数据格式的库。为了彻底了解这些库如何与更复杂的源数据(以及数据格式)配合工作,以下代码示例读取指定的文件格式,然后创建一个包含相同数据的 .csv 文件。

然而,要使用这些库,你首先需要在设备上安装它们,方法是在终端窗口中逐个运行以下命令:^(11)

pip install openpyxl
pip install pyexcel-ods
pip install xlrd==2.0.1

在以下代码示例中,我们将使用 openpyxl 库访问(或解析.xlsx 文件,使用 pyexcel-ods 库处理 .ods 文件,并使用 xlrd 库从 .xls 文件中读取数据(有关查找和选择 Python 库的更多信息,请参见 “查找库的地方”)。

为了更好地说明这些不同文件格式的特殊性,我们将做类似于我们在 示例 4-3 中所做的事情:我们将获取作为 .xls 文件提供的示例数据,并使用电子表格程序将该源文件重新保存为其他格式,创建包含完全相同数据.xlsx.ods 文件。在此过程中,我认为你会开始感受到这些非文本格式如何使数据处理过程变得更加(我会说,是不必要地)复杂。

我们将从 \ref(示例 4-4)开始,通过一个 .xlsx 文件进行工作,使用从 FRED 下载的失业数据的一个版本。这个示例说明了处理基于文本的表格型数据文件和非文本格式之间的首个主要区别之一:由于非文本格式支持多个“工作表”,我们需要在脚本顶部包含一个 for 循环,在其中放置用于创建各自输出文件的代码(每个工作表一个文件)。

示例 4-4. xlsx_parsing.py
# an example of reading data from an .xlsx file with Python, using the "openpyxl"
# library. First, you'll need to pip install the openpyxl library:
# https://pypi.org/project/openpyxl/
# the source data can be composed and downloaded from:
# https://fred.stlouisfed.org/series/U6RATE

# specify the "chapter" you want to import from the "openpyxl" library
# in this case, "load_workbook"
from openpyxl import load_workbook

# import the `csv` library, to create our output file
import csv

# pass our filename as an ingredient to the `openpyxl` library's
# `load_workbook()` "recipe"
# store the result in a variable called `source_workbook`
source_workbook = load_workbook(filename = 'fredgraph.xlsx')

# an .xlsx workbook can have multiple sheets
# print their names here for reference
print(source_workbook.sheetnames) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# loop through the worksheets in `source_workbook`
for sheet_num, sheet_name in enumerate(source_workbook.sheetnames): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # create a variable that points to the current worksheet by
    # passing the current value of `sheet_name` to `source_workbook`
    current_sheet = source_workbook[sheet_name]

    # print `sheet_name`, just to see what it is
    print(sheet_name)

    # create an output file called "xlsx_"+sheet_name
    output_file = open("xlsx_"+sheet_name+".csv","w") ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

    # use this csv library's "writer" recipe to easily write rows of data
    # to `output_file`, instead of reading data *from* it
    output_writer = csv.writer(output_file)

    # loop through every row in our sheet
    for row in current_sheet.iter_rows(): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)

        # we'll create an empty list where we'll put the actual
        # values of the cells in each row
        row_cells = [] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/5.png)

        # for every cell (or column) in each row....
        for cell in row:

            # let's print what's in here, just to see how the code sees it
            print(cell, cell.value)

            # add the values to the end of our list with the `append()` method
            row_cells.append(cell.value)

        # write our newly (re)constructed data row to the output file
        output_writer.writerow(row_cells) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/6.png)

    # officially close the `.csv` file we just wrote all that data to
    output_file.close()

1

类似于csv库的DictReader()函数,openpyxlload_workbook()函数会向我们的源数据添加属性,例如显示工作簿中所有数据表名称的属性。

2

即使我们的示例工作簿仅包含一个工作表,未来可能会有更多。我们将使用enumerate()函数,这样我们可以访问迭代器工作表名称。这将帮助我们为每个工作表创建一个.csv文件。

3

每个source_workbook中的工作表都需要其独特命名的输出.csv文件。为了生成这些文件,我们将使用名称为"xlsx_"+sheet_name+".csv"的新文件进行“打开”,并通过将w作为“mode”参数来使其可写(直到现在,我们一直使用r模式从.csv文件中读取数据)。

4

函数iter_rows()专用于openpyxl库。在这里,它将source_workbook的行转换为可以迭代或循环的列表。

5

openpyxl库将每个数据单元格视为 Python tuple数据类型。如果我们尝试直接打印current_sheet的行,则会得到不太有用的单元格位置,而不是它们包含的数据值。为了解决这个问题,我们将在此循环内再做另一个循环,逐个遍历每行中的每个单元格,并将实际的数据值添加到row_cells中。

6

注意,此代码与示例中for cell in row代码左对齐。这意味着它在该循环之外,因此仅在给定行中的所有单元格都已附加到我们的列表之后才会运行。

此脚本还开始展示了,就像两位厨师可能有不同的准备同一道菜的方式一样,库的创建者们可能会在如何(重新)结构化每种源文件类型上做出不同选择,这对我们的代码也有相应的影响。例如,openpyxl 库的创建者选择将每个数据单元格的位置标签(例如 A6)和其包含的值存储在一个 Python tuple 中。这个设计决策导致我们需要第二个 for 循环来逐个访问每行数据,因为我们实际上必须逐个访问数据单元格,以构建成为输出 .csv 文件中的单行数据的 Python 列表。同样,如果您使用电子表格程序打开由 示例 4-4 中的脚本创建的 xlsx_FRED Graph.csv 输出文件,您会看到原始的 .xls 文件在 observation_date 列中显示的值是 YYYY-MM-DD 格式,但我们的输出文件将这些值显示为 YYYY-MM-DD HH:MM:SS 格式。这是因为 openpyxl 的创建者们决定自动将任何类似日期的数据字符串转换为 Python 的 datetime 类型。显然,这些选择没有对错之分;我们只需要在编写代码时考虑到它们,以确保不会扭曲或误解源数据。

现在,我们已经处理了数据文件的 .xlsx 版本,让我们看看当我们将其解析为 .ods 格式时会发生什么,如 示例 4-5 所示。

示例 4-5. ods_parsing.py
# an example of reading data from an .ods file with Python, using the
# "pyexcel_ods" library. First, you'll need to pip install the library:
# https://pypi.org/project/pyexcel-ods/

# specify the "chapter" of the "pyexcel_ods" library you want to import,
# in this case, `get_data`
from pyexcel_ods import get_data

# import the `csv` library, to create our output file
import csv

# pass our filename as an ingredient to the `pyexcel_ods` library's
# `get_data()` "recipe"
# store the result in a variable called `source_workbook`
source_workbook = get_data("fredgraph.ods")

# an `.ods` workbook can have multiple sheets
for sheet_name, sheet_data in source_workbook.items(): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    # print `sheet_name`, just to see what it is
    print(sheet_name)

    # create "ods_"+sheet_name+".csv" as an output file for the current sheet
    output_file = open("ods_"+sheet_name+".csv","w")

    # use this csv library's "writer" recipe to easily write rows of data
    # to `output_file`, instead of reading data *from* it
    output_writer = csv.writer(output_file)

    # now, we need to loop through every row in our sheet
    for row in sheet_data: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

        # use the `writerow` recipe to write each `row`
        # directly to our output file
        output_writer.writerow(row) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

    # officially close the `.csv` file we just wrote all that data to
    output_file.close()

1

pyexcel_ods 库将我们的源数据转换为 Python 的 OrderedDict 数据类型。然后,相关的 items() 方法允许我们访问每个工作表的名称和数据,作为一个可以循环遍历的键/值对。在这种情况下,sheet_name 是“键”,整个工作表的数据是“值”。

2

在这里,sheet_data 已经是一个列表,因此我们可以使用基本的 for 循环来遍历该列表。

3

此库将工作表中的每一行转换为一个列表,这就是为什么我们可以直接将它们传递给 writerow() 方法的原因。

对于 pyexcel_ods 库而言,我们输出的 .csv 文件的内容更接近我们通过诸如 Google Sheets 这样的电子表格程序打开原始 fredgraph.xls 文件时所看到的内容 —— 例如,observation_date 字段以简单的 YYYY-MM-DD 格式呈现。此外,库的创建者们决定将每行的值视为列表,这使得我们可以直接将每条记录写入输出文件,而无需创建任何额外的循环或列表。

最后,让我们看看当我们使用 xlrd 库直接解析原始的 .xls 文件时会发生什么,如 示例 4-6 所示。

示例 4-6. xls_parsing.py
# a simple example of reading data from a .xls file with Python
# using the "xrld" library. First, pip install the xlrd library:
# https://pypi.org/project/xlrd/2.0.1/

# import the "xlrd" library
import xlrd

# import the `csv` library, to create our output file
import csv

# pass our filename as an ingredient to the `xlrd` library's
# `open_workbook()` "recipe"
# store the result in a variable called `source_workbook`
source_workbook = xlrd.open_workbook("fredgraph.xls") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# an `.xls` workbook can have multiple sheets
for sheet_name in source_workbook.sheet_names():

    # create a variable that points to the current worksheet by
    # passing the current value of `sheet_name` to the `sheet_by_name` recipe
    current_sheet = source_workbook.sheet_by_name(sheet_name)

    # print `sheet_name`, just to see what it is
    print(sheet_name)

    # create "xls_"+sheet_name+".csv" as an output file for the current sheet
    output_file = open("xls_"+sheet_name+".csv","w")

    # use the `csv` library's "writer" recipe to easily write rows of data
    # to `output_file`, instead of reading data *from* it
    output_writer = csv.writer(output_file)

    # now, we need to loop through every row in our sheet
    for row_num, row in enumerate(current_sheet.get_rows()): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

        # each row is already a list, but we need to use the `row_value()`
        # method to access them
        # then we can use the `writerow` recipe to write them
        # directly to our output file
        output_writer.writerow(current_sheet.row_values(row_num)) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

    # officially close the `.csv` file we just wrote all that data to
    output_file.close()

1

注意,这个结构与我们在使用csv库时使用的结构类似。

2

函数get_rows()是特定于xlrd库的;它将我们当前工作表的行转换为一个可以循环遍历的列表。

3

在我们的输出文件中将会有一些关于“日期”的怪异之处。我们将看看如何在“解密 Excel 日期”中修复这些日期。

这个输出文件中我们将看到一些严重的奇怪数值记录在observation_date字段中,这反映了正如xlrd库的创建者所说的那样:

Excel 电子表格中的日期:实际上,这样的东西是不存在的。你所拥有的是浮点数和虔诚的希望。

因此,从.xls文件中获得有用的、人类可读的日期需要一些重要的清理工作,我们将在“解密 Excel 日期”中解决这个问题。

正如这些练习所希望展示的那样,通过一些聪明的库和对基本代码配置的一些调整,使用 Python 快速轻松地处理来自各种表格类型数据格式的数据是可能的。同时,我希望这些示例也说明了为什么几乎总是更倾向于使用基于文本和/或开放源代码的格式,因为它们通常需要更少的“清理”和转换以使它们进入清晰、可用的状态。

最后,固定宽度

尽管我在本节的开头没有提到它,但非常古老版本的表格类型数据之一是所谓的“固定宽度”。顾名思义,固定宽度表中的每个数据列包含特定的、预定义的字符数,而且总是那么多的字符。这意味着固定宽度文件中的有意义数据通常会被额外的字符填充,例如空格或零。

尽管在当代数据系统中非常罕见,但如果你在处理可能存在几十年历史的政府数据源,仍然可能会遇到固定宽度格式。¹⁶例如,美国国家海洋和大气管理局(National Oceanic and Atmospheric Administration ,NOAA)的起源可以追溯到 19 世纪初,通过其全球历史气候网络在网上免费提供了大量详细的最新天气信息,其中很多是以固定宽度格式发布的。例如,关于气象站的唯一标识符、位置以及它们属于哪个(些)网络的信息存储在ghcnd-stations.txt 文件中。要解释任何实际的天气数据读数(其中许多也是以固定宽度文件发布的),您需要将气象站数据与天气数据进行交叉引用。

与其他表格类型的数据文件相比,如果没有访问描述文件及其字段组织方式的元数据,使用固定宽度数据可能特别棘手。对于分隔文件,通常可以在文本编辑器中查看文件并以合理的置信水平识别所使用的分隔符。在最坏的情况下,您可以尝试使用不同的分隔符解析文件,看看哪个产生了最佳结果。对于固定宽度文件,特别是对于大文件,如果在您检查的数据样本中某个字段没有数据,很容易意外地将多个数据字段合并在一起。

幸运的是,我们正在使用作为数据源的ghcnd-stations.txt文件的元数据也包含在NOAA 网站的同一文件夹中的readme.txt文件中。

在浏览readme.txt文件时,我们发现了标题为IV. FORMAT OF "ghcnd-stations.txt"的部分,其中包含以下表格:

------------------------------
Variable   Columns   Type
------------------------------
ID            1-11   Character
LATITUDE     13-20   Real
LONGITUDE    22-30   Real
ELEVATION    32-37   Real
STATE        39-40   Character
NAME         42-71   Character
GSN FLAG     73-75   Character
HCN/CRN FLAG 77-79   Character
WMO ID       81-85   Character
------------------------------

随后详细描述了每个字段包含或表示的内容,包括单位等信息。由于这个强大的数据字典,我们现在不仅知道ghcnd-stations.txt文件的组织方式,还知道如何解释它包含的信息。正如我们将在第六章中看到的,找到(或构建)数据字典是评估或改善数据质量的重要部分。然而,目前,我们可以专注于将这个固定宽度文件转换为.csv,如示例 4-7 中所详述的那样。

示例 4-7. fixed_width_parsing.py
# an example of reading data from a fixed-width file with Python.
# the source file for this example comes from NOAA and can be accessed here:
# https://www1.ncdc.noaa.gov/pub/data/ghcn/daily/ghcnd-stations.txt
# the metadata for the file can be found here:
# https://www1.ncdc.noaa.gov/pub/data/ghcn/daily/readme.txt

# import the `csv` library, to create our output file
import csv

filename = "ghcnd-stations"

# reading from a basic text file doesn't require any special libraries
# so we'll just open the file in read format ("r") as usual
source_file = open(filename+".txt", "r")

# the built-in "readlines()" method does just what you'd think:
# it reads in a text file and converts it to a list of lines
stations_list = source_file.readlines()

# create an output file for our transformed data
output_file = open(filename+".csv","w")

# use the `csv` library's "writer" recipe to easily write rows of data
# to `output_file`, instead of reading data *from* it
output_writer = csv.writer(output_file)

# create the header list
headers = ["ID","LATITUDE","LONGITUDE","ELEVATION","STATE","NAME","GSN_FLAG",
           "HCNCRN_FLAG","WMO_ID"] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# write our headers to the output file
output_writer.writerow(headers)

# loop through each line of our file (multiple "sheets" are not possible)
for line in stations_list:

    # create an empty list, to which we'll append each set of characters that
    # makes up a given "column" of data
    new_row = []

    # ID: positions 1-11
    new_row.append(line[0:11]) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # LATITUDE: positions 13-20
    new_row.append(line[12:20])

    # LONGITUDE: positions 22-30
    new_row.append(line[21:30])

    # ELEVATION: positions 32-37
    new_row.append(line[31:37])

    # STATE: positions 39-40
    new_row.append(line[38:40])

    # NAME: positions 42-71
    new_row.append(line[41:71])

    # GSN_FLAG: positions 73-75
    new_row.append(line[72:75])

    # HCNCRN_FLAG: positions 77-79
    new_row.append(line[76:79])

    # WMO_ID: positions 81-85
    new_row.append(line[80:85])

    # now all that's left is to use the
    # `writerow` function to write new_row to our output file
    output_writer.writerow(new_row)

# officially close the `.csv` file we just wrote all that data to
output_file.close()

1

由于文件内部没有我们可以用作列标题的内容,我们必须根据 readme.txt 文件中的信息 “硬编码”它们。请注意,我已经删除了特殊字符,并在空格位置使用下划线,以便在稍后清理和分析数据时最大限度地减少麻烦。

2

Python 实际上将文本行视为字符列表,因此我们只需告诉它给出两个编号位置之间的字符。就像 range() 函数一样,包括第一个位置的字符,但第二个数字不包括在内。还要记住,Python 从零开始计算列表项(通常称为 零索引)。这意味着对于每个条目,第一个数字将比元数据所示的少一个,但右手边的数字将相同。

如果你运行 示例 4-7 中的脚本,并在电子表格程序中打开你的输出 .csv 文件,你会注意到某些列中的值格式不一致。例如,在 ELEVATION 列中,带小数点的数字左对齐,而没有小数点的数字右对齐。到底是怎么回事?

再次打开文本编辑器查看文件是有启发性的。尽管我们创建的文件 技术上 是逗号分隔的,但我们放入每个新“分隔”列的值仍然包含原始文件中存在的额外空格。因此,我们的新文件仍然看起来相当“固定宽度”。

换句话说——就像我们在 Excel “日期”案例中看到的那样——将我们的文件转换为 .csv 文件并不会“自动”在输出文件中生成合理的数据类型。确定每个字段应该具有的数据类型,并清理它们以使其表现得合适,是数据清理过程的一部分,我们将在 第七章 中讨论。

基于 Web 的数据源驱动实时更新

表格类型数据格式的结构非常适合一个已经被过滤、修订并转化为相对整理良好的数字、日期和短字符串集合的世界。然而,随着互联网的兴起,传输大量“自由”文本数据的需求也随之而来,比如新闻故事和社交媒体动态。由于这类数据内容通常包括逗号、句点和引号等影响其语义含义的字符,将其放入传统的分隔格式中将会出现问题。此外,分隔格式的水平偏向(涉及大量左右滚动)与 Web 的垂直滚动约定相矛盾。Feed-based 数据格式已经设计用来解决这些限制。

在高层次上,基于feed的数据格式主要有两种:XML 和 JSON。它们都是文本格式,允许数据提供者定义自己独特的数据结构,使其极其灵活,因此非常适用于互联网连接的网站和平台上发现的各种内容。无论它们位于在线位置还是您在本地保存了一份副本,您都可以通过它们的.xml.json文件扩展名一部分来识别它们:

.xml

可扩展标记语言涵盖了广泛的文件格式,包括.rss.atom,甚至.html。作为最通用的标记语言类型,XML 非常灵活,也许是最早用于基于网络的数据 feed 的数据格式。

.json

JSON 文件比 XML 文件稍微新一些,但目的类似。总体而言,JSON 文件比 XML 文件描述性较少(因此更短、更简洁)。这意味着它们可以编码几乎与 XML 文件相同数量的数据,同时占用更少的空间,这对移动网络的速度尤为重要。同样重要的是,JSON 文件本质上是 JavaScript 编程语言中的大型object数据类型——这是许多,如果不是大多数,网站和移动应用的基础语言。这意味着解析 JSON 格式的数据对于使用 JavaScript 的任何网站或程序来说都非常容易,尤其是与 XML 相比。幸运的是,JavaScript 的object数据类型与 Python 的dict数据类型非常相似,这也使得在 Python 中处理 JSON 非常简单。

在我们深入讨论如何在 Python 中处理这些文件类型之前,让我们回顾一下,当我们需要feed类型的数据时以及在何处找到它。

何时使用feed类型数据

从某种意义上说,feed类型的数据对于 21 世纪而言就像 20 世纪的表格类型数据一样:每天在网络上生成、存储和交换的feed类型数据的体积可能比全球所有表格类型数据的总和还要大数百万倍——这主要是因为feed类型数据是社交媒体网站、新闻应用程序等一切的动力来源。

从数据处理的角度来看,当你探索的现象是时间敏感的并且经常或者说不可预测地更新时,通常你会想要feed类型的数据。典型地,这种类型的数据是响应于人类或自然过程生成的,比如(再次)在社交媒体上发布、发布新闻故事或记录地震。

基于文件的表格类型数据和基于 Web 的提要类型数据都可以包含历史信息,但正如我们在本章开头讨论的那样,前者通常反映了某一固定时间点的数据。相比之下,后者通常以“逆时间顺序”(最新的首先)组织,首个条目是您访问数据时最近创建的数据记录,而不是预定的发布日期。

如何找到提要类型的数据

提要类型的数据几乎完全可以在网上找到,通常位于称为应用程序编程接口(API)端点的特殊网址上。我们将在第五章详细讨论使用 API 的细节,但现在你只需要知道 API 端点实际上只是数据页面:你可以使用常规的 Web 浏览器查看许多页面,但你所看到的只是数据本身。一些 API 端点甚至会根据您发送给它们的信息返回不同的数据,这正是处理提要类型数据如此灵活的部分原因:通过仅更改代码中的几个单词或值,您可以访问完全不同的数据集!

找到提供提要类型数据的 API 并不需要太多特殊的搜索策略,因为通常具有 API 的网站和服务希望您找到它们。为什么呢?简单来说,当有人编写使用 API 的代码时,它(通常)会为提供 API 的公司带来一些好处,即使这种好处只是更多的公众曝光。例如,在 Twitter 早期,许多 Web 开发人员使用 Twitter API 编写程序,这不仅使平台更加有用,节省了公司理解用户需求并构建的费用和工作量。通过最初免费提供其平台数据的 API,Twitter 促使了几家公司的诞生,这些公司最终被 Twitter 收购,尽管还有更多公司在 API 或其服务条款发生变化时被迫停业。^(17) 这突显了处理任何类型数据时可能出现的特定问题之一,尤其是由盈利公司提供的提要类型数据:数据本身及您访问它的权利随时都可能在没有警告的情况下发生变化。因此,尽管提要类型数据源确实很有价值,但它们在更多方面上也是短暂的。

使用 Python 整理提要类型数据

与表格类型的数据类似,使用 Python 整理提要类型的数据是可能的,这得益于一些有用的库以及像 JSON 这样的格式已经与 Python 编程语言中的现有数据类型相似。此外,在接下来的章节中,我们将看到 XML 和 JSON 对于我们的目的通常可以互换使用(尽管许多 API 只会提供其中一种格式的数据)。

XML:一种标记语言来统一它们

标记语言是计算机中最古老的标准化文档格式之一,旨在创建既能够轻松被人类阅读又能够被机器解析的基于文本的文档。XML 在 20 世纪 90 年代成为互联网基础设施的越来越重要的一部分,因为各种设备访问和显示基于 Web 的信息,使得内容(如文本和图像)与格式(如页面布局)的分离变得更加必要。与 HTML 文档不同——其中内容和格式完全混合——XML 文档几乎不指定其信息应如何显示。相反,它的标签和属性充当关于它包含的信息类型以及数据本身的元数据

要了解 XML 的外观,可以查看示例 4-8

示例 4-8. 一个样本 XML 文档
 <?xml version="1.0" encoding="UTF-8"?>
 <mainDoc>
    <!--This is a comment-->
    <elements>
        <element1>This is some text in the document.</element1>
        <element2>This is some other data in the document.</element2>
        <element3 someAttribute="aValue" />
    </elements>
    <someElement anAttribute="anotherValue">More content</someElement>
</mainDoc>

这里有几个要点。第一行称为文档类型(或doc-type)声明;它告诉我们,文档的其余部分应解释为 XML(而不是其他任何网络或标记语言,本章稍后将介绍其中一些)。

从以下行开始:

<mainDoc>

我们进入文档本身的内容。XML 如此灵活的部分原因在于它只包含两种真正的语法结构,这两种都包含在示例 4-8中:

标签

标签可以是成对的(如element1element2someElement或甚至mainDoc),也可以是自闭合的(如element3)。标签的名称始终用尖括号<>)括起来。对于闭合标签,在开放的尖括号后面紧跟着斜杠(/)。成对的标签或自闭合标签也被称为 XML 的元素

属性

属性只能存在于标签内部(如anAttribute)。属性是一种键/值对,其中属性名(或)紧跟着等号(=),后面是用双引号("")括起来的

XML 元素是包含在开放标签及其匹配闭合标签之间的任何内容(例如,<elements></elements>)。因此,给定的 XML 元素可能包含许多标签,每个标签也可以包含其他标签。任何标签也可以具有任意数量的属性(包括没有)。自闭合标签也被视为元素之一。

当标签出现在其他标签内部时,最近打开的标签必须首先关闭。换句话说,虽然以下是一个合法的 XML 结构:

 <outerElement>
    <!-- Notice that that the `innerElement1` is closed
 before the `innerElement2` tag is opened -->
    <innerElement1>Some content</innerElement1>
    <innerElement2>More content</innerElement2>
 </outerElement>

但这不是:

 <outerElement>
    <!-- NOPE! The `innerElement2` tag was opened
 before the `innerElement1` tag was closed -->
    <innerElement1>Some content<innerElement2>More content</innerElement1>
    </innerElement2>
 </outerElement>

先开后闭原则也被称为嵌套,类似于 图 2-3 中的“嵌套” for...in 循环。^(18) 嵌套在 XML 文档中尤为重要,因为它管理了我们用代码读取或解析 XML(和其他标记语言)文档的主要机制之一。在 XML 文档中,doc-type 声明之后的第一个元素称为元素。如果 XML 文档已经格式化,根元素将始终左对齐,而直接该元素内嵌套的任何元素将向右缩进一级,并称为元素。因此,在 示例 4-8 中,<mainDoc> 将被视为元素,<elements> 将被视为其子元素。同样,<mainDoc><elements>元素(示例 4-9)。

Example 4-9. 一个带注释的 XML 文档
 <?xml version="1.0" encoding="UTF-8"?>
 <mainDoc>
    <!--`mainDoc` is the *root* element, and `elements` is its *child*-->
    <elements>
        <!-- `elements` is the *parent* of `element1`, `element2`, and
 `element3`, which are *siblings* of one another -->
        <element1>This is text data in the document.</element1>
        <element2>This is some other data in the document.</element2>
        <element3 someAttribute="aValue" />
    </elements>
    <!-- `someElement` is also a *child* of `mainDoc`,
 and a *sibling* of `elements` -->
    <someElement anAttribute="anotherValue">More content</someElement>
</mainDoc>

鉴于这种谱系术语的趋势,您可能会想知道:如果<elements><element3>的父级,<mainDoc><elements>的父级,那么<mainDoc>是否是<element3>祖父?答案是:是的,但也不是。虽然<mainDoc>确实是<element3>的“父级”的“父级”,但在描述 XML 结构时从不使用术语“祖父”—这可能会很快变得复杂!相反,我们简单地描述这种关系正是:<mainDoc><element3>父级父级

幸运的是,与 XML 属性相关的复杂性不存在:它们只是键/值对,并且它们只能存在于 XML 标签内部,如下所示:

 <element3 someAttribute="aValue" />

请注意,等号两侧没有空格,就像元素标签的尖括号和斜杠之间没有空格一样。

就像用英语(或 Python)写作一样,何时使用标签而不是属性来表示特定信息,主要取决于个人偏好和风格。例如,示例 4-10 和 示例 4-11 中都包含了关于这本书相同的信息,但每个结构略有不同。

Example 4-10. 示例 XML 书籍数据—更多属性
<aBook>
    <bookURL url="https://www.oreilly.com/library/view/practical-python-data/
 9781492091493"/>
    <bookAbstract>
    There are awesome discoveries to be made and valuable stories to be
    told in datasets--and this book will help you uncover them.
    </bookAbstract>
    <pubDate date="2022-02-01" />
</aBook>
Example 4-11. 示例 XML 书籍数据—更多元素
<aBook>
    <bookURL>
        https://www.oreilly.com/library/view/practical-python-data/9781492091493
    </bookURL>
    <bookAbstract>
        There are awesome discoveries to be made and valuable stories to be
        told in datasets--and this book will help you uncover them.
    </bookAbstract>
    <pubDate>2022-02-01</pubDate>
</aBook>

这种灵活性意味着 XML 非常适应各种数据源和格式化偏好。同时,它可能很容易创建这样的情况:每一个新数据源都需要编写定制代码。显然,这将是一个相当低效的系统,特别是如果许多人和组织发布的数据类型非常相似的情况下。

因此,不足为奇,有许多 XML 规范 定义了格式化特定类型数据的 XML 文档的附加规则。我在这里突出了一些值得注意的例子,因为这些是您在数据整理工作中可能会遇到的格式。尽管它们具有各种格式名称和文件扩展名,但我们可以使用稍后将在示例 4-12 中详细讨论的相同方法来解析它们:

RSS

简易信息聚合(Really Simple Syndication)是一个 XML 规范,最早在 1990 年代末用于新闻信息。.atom XML 格式也广泛用于这些目的。

KML

Keyhole 标记语言(Keyhole Markup Language)是国际上公认的用于编码二维和三维地理数据的标准,并且与 Google Earth 等工具兼容。

SVG

可缩放矢量图形(Scalable Vector Graphics)是网络图形常用的格式,因其能够在不损失质量的情况下进行缩放。许多常见的图形程序可以输出.svg文件,这些文件可以用于网页和其他文档中,能够在各种屏幕尺寸和设备上显示良好。

EPUB

电子出版格式(.epub)是广泛接受的数字图书出版开放标准。

正如您从上述列表中可以看到的那样,一些常见的 XML 格式清楚地表明它们与 XML 的关系;而其他许多则没有。^(19)

现在我们对 XML 文件的工作原理有了高层次的理解,让我们看看如何使用 Python 解析一个 XML 文件。尽管 Python 有一些用于解析 XML 的内置工具,但我们将使用一个名为lxml的库,这个库特别擅长快速解析大型 XML 文件。尽管我们接下来的示例文件相当小,但请知道,即使我们的数据文件变得更大,我们基本上可以使用相同的代码。

首先,我们将使用从 FRED 网站下载的“U6”失业数据的 XML 版本,使用其 API。^(20) 在从Google Drive下载此文件的副本后,您可以使用示例 4-12 中的脚本将源 XML 转换为.csv。首先安装pip install

pip install lxml
示例 4-12. xml_parsing.py
# an example of reading data from an .xml file with Python, using the "lxml"
# library.
# first, you'll need to pip install the lxml library:
# https://pypi.org/project/lxml/
# a helpful tutorial can be found here: https://lxml.de/tutorial.html
# the data used here is an instance of
# https://api.stlouisfed.org/fred/series/observations?series_id=U6RATE& \
# api_key=YOUR_API_KEY_HERE

# specify the "chapter" of the `lxml` library you want to import,
# in this case, `etree`, which stands for "ElementTree"
from lxml import etree

# import the `csv` library, to create our output file
import csv

# choose a filename
filename = "U6_FRED_data" ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# open our data file in read format, using "rb" as the "mode"
xml_source_file = open(filename+".xml","rb") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# pass our xml_source_file as an ingredient to the `lxml` library's
# `etree.parse()` method and store the result in a variable called `xml_doc`
xml_doc = etree.parse(xml_source_file)

# start by getting the current xml document's "root" element
document_root = xml_doc.getroot() ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

# let's print it out to see what it looks like
print(etree.tostring(document_root)) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)

# confirm that `document_root` is a well-formed XML element
if etree.iselement(document_root):

    # create our output file, naming it "xml_"+filename+".csv
    output_file = open("xml_"+filename+".csv","w")

    # use the `csv` library's "writer" recipe to easily write rows of data
    # to `output_file`, instead of reading data *from* it
    output_writer = csv.writer(output_file)

    # grab the first element of our xml document (using `document_root[0]`)
    # and write its attribute keys as column headers to our output file
    output_writer.writerow(document_root[0].attrib.keys()) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/5.png)

    # now, we need to loop through every element in our XML file
      for child in document_root: ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/6.png)

        # now we'll use the `.values()` method to get each element's values
        # as a list and then use that directly with the `writerow` recipe
        output_writer.writerow(child.attrib.values())

    # officially close the `.csv` file we just wrote all that data to
    output_file.close()

1

在本例中,数据文件中没有任何内容(如工作表名称),我们可以将其作为文件名使用,因此我们将自己创建一个,并用它来加载源数据并标记输出文件。

2

我撒了谎!我们一直在使用open()函数的“模式”值假定我们希望将源文件解释为文本。但因为lxml库期望字节数据而不是文本,我们将使用rb(“读取字节”)作为“模式”。

3

有很多格式错误的 XML!为了确保看起来像好的 XML 实际上确实是好的,我们将检索当前 XML 文档的“根”元素,并确保它有效。

4

因为我们的 XML 当前存储为字节数据,所以我们需要使用etree.tostring()方法来将其视为文本。

5

多亏了lxml,我们文档中的每个 XML 元素(或“节点”)都有一个名为attrib的属性,其数据类型是 Python 字典(dict)。使用.keys()方法会返回我们 XML 元素所有属性键的列表。由于源文件中的所有元素都是相同的,我们可以使用第一个元素的键来创建输出文件的“标题行”。

6

lxml库会将 XML 元素转换为列表,因此我们可以使用简单的for...in循环遍历文档中的元素。

事实上,我们的失业数据的 XML 版本结构非常简单:它只是一个元素列表,所有我们想要访问的值都存储为属性。因此,我们能够从每个元素中提取属性值作为列表,并用一行代码直接写入到我们的.csv文件中。

当然,有许多时候我们会想从更复杂的 XML 格式中提取数据,特别是像 RSS 或 Atom 这样的格式。为了看看如何处理稍微复杂一点的东西,在示例 4-13 中,我们将解析 BBC 的科学与环境故事的 RSS 源,你可以从我的Google Drive下载副本。

示例 4-13. rss_parsing.py
# an example of reading data from an .xml file with Python, using the "lxml"
# library.
# first, you'll need to pip install the lxml library:
# https://pypi.org/project/lxml/
# the data used here is an instance of
# http://feeds.bbci.co.uk/news/science_and_environment/rss.xml

# specify the "chapter" of the `lxml` library you want to import,
# in this case, `etree`, which stands for "ElementTree"
from lxml import etree

# import the `csv` library, to create our output file
import csv

# choose a filename, for simplicity
filename = "BBC News - Science & Environment XML Feed"

# open our data file in read format, using "rb" as the "mode"
xml_source_file = open(filename+".xml","rb")

# pass our xml_source_file as an ingredient to the `lxml` library's
# `etree.parse()` method and store the result in a variable called `xml_doc`
xml_doc = etree.parse(xml_source_file)

# start by getting the current xml document's "root" element
document_root = xml_doc.getroot()

# if the document_root is a well-formed XML element
if etree.iselement(document_root):

    # create our output file, naming it "rss_"+filename+".csv"
    output_file = open("rss_"+filename+".csv","w")

    # use the `csv` library's "writer" recipe to easily write rows of data
    # to `output_file`, instead of reading data *from* it
    output_writer = csv.writer(output_file)

    # document_root[0] is the "channel" element
    main_channel = document_root[0]

    # the `find()` method returns *only* the first instance of the element name
    article_example = main_channel.find('item') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    # create an empty list in which to store our future column headers
    tag_list = []

    for child in article_example.iterdescendants(): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

        # add each tag to our would-be header list
        tag_list.append(child.tag) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

        # if the current tag has any attributes
        if child.attrib: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)

            # loop through the attribute keys in the tag
            for attribute_name in child.attrib.keys(): ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/5.png)

                # append the attribute name to our `tag_list` column headers
                tag_list.append(attribute_name)

    # write the contents of `tag_list` to our output file as column headers
    output_writer.writerow(tag_list) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/6.png)

    # now we want to grab *every* <item> element in our file
    # so we use the `findall()` method instead of `find()`
    for item in main_channel.findall('item'):

        # empty list for holding our new row's content
        new_row = []

        # now we'll use our list of tags to get the contents of each element
        for tag in tag_list:

            # if there is anything in the element with a given tag name
            if item.findtext(tag):

                # append it to our new row
                new_row.append(item.findtext(tag))

            # otherwise, make sure it's the "isPermaLink" attribute
            elif tag == "isPermaLink":

                # grab its value from the <guid> element
                # and append it to our row
                new_row.append(item.find('guid').get("isPermaLink"))

        # write the new row to our output file!
        output_writer.writerow(new_row)

    # officially close the `.csv` file we just wrote all that data to
    output_file.close()

1

和往常一样,我们需要在程序处理和视觉审核之间取得平衡。通过查看我们的数据,可以清楚地看到每篇文章的信息都存储在单独的item元素中。然而,复制单个标签和属性名称将是耗时且容易出错的,因此我们将遍历一个item元素,并列出其中所有标签(和属性),然后将其用作输出.csv文件的列标题。

2

iterdescendants() 方法是 lxml 库特有的。它仅返回 XML 元素的 后代,而 更常见的 iter() 方法则会返回 元素本身 及其子元素或“后代”。

3

使用 child.tag 将检索子元素标签名的文本。例如,对于 <pubDate>` 元素,它将返回 pubDate

4

我们的 <item> 元素中只有一个标签具有属性,但我们仍然希望将其包含在输出中。

5

keys() 方法将为我们提供属于标签的属性列表中所有键的列表。确保将其名称作为字符串获取(而不是一个单项列表)。

6

整个 article_example for 循环只是为了构建 tag_list —— 但这是值得的!

如您从 示例 4-13 中所见,借助 lxml 库,即使在 Python 中解析稍微复杂的 XML 也仍然相对简单。

尽管 XML 仍然是新闻源等一小部分文件类型的流行数据格式,但有许多功能使其不太适合处理现代网络的高容量数据源。

首先,存在尺寸的简单问题。尽管 XML 文件可以非常描述性地描述,减少了对独立数据字典的需求,但是大多数元素同时包含开标签和相应的闭标签(例如,<item></item>),这也使得 XML 有些 冗长:XML 文档中有很多文本 不是 内容。当您的文档有几十甚至几千个元素时,这并不是什么大问题,但是当您尝试处理社交网络上的数百万或数十亿篇帖子时,所有这些冗余文本确实会减慢速度。

第二,虽然将 XML 转换为其他数据格式并不 ,但这个过程也不是完全无缝的。lxml 库(以及其他一些)使得用 Python 解析 XML 变得非常简单,但是使用像 JavaScript 这样的面向网络的语言执行相同任务则是冗长而繁琐的。考虑到 JavaScript 在网络上的普及程度,一种能够与 JavaScript 无缝配合的数据格式在某个时候的开发是不奇怪的。正如我们将在下一节中看到的那样,作为一种数据格式,XML 的许多局限性都被 .json 格式的 对象 特性所解决,这在当前是互联网上最流行的 feed 类型数据格式。

JSON:Web 数据,下一代

从原理上讲,JSON 类似于 XML,因为它使用嵌套将相关信息聚合到记录和字段中。JSON 也相当易读,尽管它不支持注释,这意味着 JSON 数据源可能需要比 XML 文档更健壮的数据字典。

要开始,请看看示例 4-14 中的小 JSON 文档。

示例 4-14. 示例 JSON 文档
{
"author": "Susan E. McGregor",
"book": {
    "bookURL": "https://www.oreilly.com/library/view/practical-python-data/
 9781492091493/",
    "bookAbstract": "There are awesome discoveries to be made and valuable
 stories to be told in datasets--and this book will help you uncover
 them.",
    "pubDate": "2022-02-01"
},
"papers": [{
    "paperURL": "https://www.usenix.org/conference/usenixsecurity15/
 technical-sessions/presentation/mcgregor",
    "paperTitle": "Investigating the computer security practices and needs
 of journalists",
    "pubDate": "2015-08-12"
},
    {
    "paperURL": "https://www.aclweb.org/anthology/W18-5104.pdf",
    "paperTitle": "Predictive embeddings for hate speech detection on
 twitter",
    "pubDate": "2018-10-31"
}
    ]
}

像 XML 一样,JSON 文档的语法“规则”非常简单:在 JSON 文档中只有三种不同的数据结构,所有这些都出现在示例 4-14 中:

键/值对

从技术上讲,JSON 文档中的每个内容都是键/值对,用引号括起来位于冒号 (:) 的左侧,是出现在冒号右侧的内容。请注意,虽然键必须始终是字符串,可以是字符串(如 author)、对象(如 book)或列表(如 papers)。

对象

这些使用一对大括号 ({}) 打开和关闭。在示例 4-14 中,总共有四个对象:文档本身(由左对齐的大括号指示)、book 对象以及 papers 列表中的两个未命名对象。

列表

这些由方括号 ([]) 包围,只能包含逗号分隔的对象。

虽然 XML 和 JSON 可用于编码相同的数据,但它们在允许的内容方面存在一些显著差异。例如,JSON 文件不包含 doc-type 规范,也不能包含注释。此外,尽管 XML 列表有些隐式(任何重复元素都像列表功能),但在 JSON 中,列表必须由方括号 ([]) 指定。

最后,尽管 JSON 设计时考虑了 JavaScript,但您可能已经注意到它的结构与 Python 的 dictlist 类型非常相似。这也是使用 Python 和 JavaScript(以及其他一系列语言)解析 JSON 非常简单的原因之一。

要看到这一点有多简单,我们将在示例 4-15 中解析与我们在示例 4-12 中相同的数据,但使用 FRED API 提供的 .json 格式。您可以从这个 Google Drive 链接下载文件:https://drive.google.com/file/d/1Mpb2f5qYgHnKcU1sTxTmhOPHfzIdeBsq/view?usp=sharing

示例 4-15. json_parsing.py
# a simple example of reading data from a .json file with Python,
# using the built-in "json" library. The data used here is an instance of
# https://api.stlouisfed.org/fred/series/observations?series_id=U6RATE& \
# file_type=json&api_key=YOUR_API_KEY_HERE

# import the `json` library, since that's our source file format
import json

# import the `csv` library, to create our output file
import csv

# choose a filename
filename = "U6_FRED_data"

# open the file in read format ("r") as usual
json_source_file = open(filename+".json","r")

# pass the `json_source_file` as an ingredient to the json library's `load()`
# method and store the result in a variable called `json_data`
json_data = json.load(json_source_file)

# create our output file, naming it "json_"+filename
output_file = open("json_"+filename+".csv","w")

# use the `csv` library's "writer" recipe to easily write rows of data
# to `output_file`, instead of reading data *from* it
output_writer = csv.writer(output_file)

# grab the first element (at position "0"), and use its keys as the column headers
output_writer.writerow(list(json_data["observations"][0].keys())) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

for obj in json_data["observations"]: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # we'll create an empty list where we'll put the actual values of each object
    obj_values = []

    # for every `key` (which will become a column), in each object
    for key, value in obj.items(): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

        # let's print what's in here, just to see how the code sees it
        print(key,value)

        # add the values to our list
        obj_values.append(value)

    # now we've got the whole row, write the data to our output file
    output_writer.writerow(obj_values)

# officially close the `.csv` file we just wrote all that data to
output_file.close()

1

因为 json 库将每个对象解释为字典视图对象,我们需要告诉 Python 使用 list() 函数将其转换为常规列表。

2

在大多数情况下,找到文档中主 JSON 对象的名称(或“键”)的最简单方法就是查看它。然而,由于 JSON 数据通常在一行上呈现,我们可以通过将其粘贴到 JSONLint 中来更好地理解其结构。这让我们看到我们的目标数据是一个键为 observations 的列表。

3

由于 json 库的工作方式,如果我们试图直接写入行,我们将得到以 dict 标记的值,而不是数据值本身。因此,我们需要再做一个循环,逐个遍历每个 json 对象中的每个值,并将该值附加到我们的 obj_values 列表中。

虽然 JSON 不如 XML 那样易于阅读,但它具有其他我们已经提到的优点,比如更小的文件大小和更广泛的代码兼容性。同样,虽然 JSON 不如 XML 描述性强,JSON 数据源(通常是 API)通常有相当好的文档记录;这减少了仅仅推断给定键/值对描述的需求。然而,与所有数据处理一样,处理 JSON 格式数据的第一步是尽可能地理解其上下文。

处理非结构化数据

正如我们在 “结构化与非结构化数据” 中讨论的那样,创建数据的过程取决于向信息引入一些结构;否则,我们无法系统地分析或从中得出意义。尽管后者通常包括大段人工编写的“自由”文本,但表格类型和 feed 类型数据都相对结构化,并且最重要的是,可以被机器读取。

当我们处理非结构化数据时,相反地,我们的工作总是涉及近似值:我们无法确定我们的程序化处理努力是否会返回底层信息的准确解释。这是因为大多数非结构化数据是设计成人类感知和解释的内容的表示。正如我们在 第二章 中讨论的,虽然计算机可以比人类更快更少出错地处理大量数据,但它们仍然可能被无结构数据欺骗,这种数据永远不会愚弄人类,比如将一个 稍作修改的停止标志误认为是限速标志。自然地,这意味着当处理不能被机器读取的数据时,我们总是需要额外的验证和验证——但 Python 仍然可以帮助我们将这些数据整理成更可用的格式。

基于图像的文本:访问 PDF 中的数据

便携式文档格式(PDF)是在 1990 年代初创建的一种机制,用于保持电子文档的视觉完整性——无论是在文本编辑程序中创建还是从印刷材料中捕获。保持文档的视觉外观也意味着,与可机器读取格式(如文字处理文档)不同,难以更改或提取其内容——这是创建从合同的数字版本到正式信函的重要特性。

换句话说,最初设计时,在 PDF 中处理数据确实有些困难。然而,因为访问印刷文档中的数据是一个共同的问题,光学字符识别(OCR)的工作实际上早在 19 世纪末就已经开始。即使数字化 OCR 工具几十年来已经在软件包和在线上广泛可用,因此虽然它们远非完美,但这种文件类型中包含的数据也并非完全无法获取。

处理 PDF 中的文本的时间

通常来说,处理 PDF 文件是最后的选择(正如我们将在第五章中看到的那样,网页抓取也应如此)。一般来说,如果可以避免依赖 PDF 信息,那么就应该这样做。正如前面所述,从 PDF 中提取信息的过程通常会产生文档内容的近似值,因此准确性的校对是基于任何基于.pdf的数据处理工作流的不可推卸部分。话虽如此,有大量仅以图像或扫描文档 PDF 形式可用的信息,而 Python 是从此类文档中提取相对准确的第一版本的有效方法。

如何找到 PDF 文件

如果您确信所需数据只能在 PDF 格式中找到,那么您可以(而且应该)使用“智能搜索特定数据类型”中的提示,在线搜索中定位此文件类型。很可能,您会向个人或组织请求信息,他们将以 PDF 形式提供信息,让您自行处理如何提取所需信息的问题。因此,大多数情况下您不需要寻找 PDF 文件——很不幸,它们通常会找到您。

使用 Python 处理 PDF 文件

因为 PDF 文件可以从可机器阅读的文本(如文字处理文档)和扫描图像生成,有时可以通过程序相对少的错误提取文档的“活”文本。然而,尽管这种方法看似简单,但由于.pdf文件可以采用各种难以准确检测的编码生成,因此该方法仍然可能不可靠。因此,虽然这可能是从.pdf中提取文本的高准确性方法,但对于任何给定文件而言,它的可行性较低。

由于这个原因,我将在这里专注于使用 OCR 来识别和提取 .pdf 文件中的文本。这将需要两个步骤:

  1. 将文档页面转换为单独的图像。

  2. 对页面图像运行 OCR,提取文本,并将其写入单独的文本文件。

毫不奇怪,为了使所有这些成为可能,我们需要安装相当多的 Python 库。首先,我们将安装一些用于将我们的 .pdf 页面转换为图像的库。第一个是一个通用库,叫做 poppler,它是使我们的 Python 特定库 pdf2image 工作所必需的。我们将使用 pdf2image 来(你猜对了!)将我们的 .pdf 文件转换为一系列图像:

sudo apt install poppler-utils

然后:

pip install pdf2image

接下来,我们需要安装执行 OCR 过程的工具。第一个是一个通用库,叫做 tesseract-ocr,它使用机器学习来识别图像中的文本;第二个是依赖于 tesseract-ocr 的一个 Python 库,叫做 pytesseract

sudo apt-get install tesseract-ocr

然后:

pip install pytesseract

最后,我们需要一个 Python 的辅助库,可以执行计算机视觉,以弥合我们的页面图像和我们的 OCR 库之间的差距:

pip install opencv-python

哇!如果这看起来像是很多额外的库,要记住,我们这里实际上使用的是机器学习,这是一种让很多“人工智能”技术走向前沿的数据科学技术之一。幸运的是,特别是 Tesseract 相对健壮和包容:虽然它最初是由惠普公司在 1980 年代早期开发的专有系统,但在 2005 年开源,目前支持超过 100 种语言——所以也可以放心尝试将 示例 4-16 中的解决方案用于非英文文本!

示例 4-16. pdf_parsing.py
# a basic example of reading data from a .pdf file with Python,
# using `pdf2image` to convert it to images, and then using the
# openCV and `tesseract` libraries to extract the text

# the source data was downloaded from:
# https://files.stlouisfed.org/files/htdocs/publications/page1-econ/2020/12/01/ \
# unemployment-insurance-a-tried-and-true-safety-net_SE.pdf

# the built-in `operating system` or `os` Python library will let us create
# a new folder in which to store our converted images and output text
import os

# we'll import the `convert_from_path` "chapter" of the `pdf2image` library
from pdf2image import convert_from_path

# the built-in `glob`library offers a handy way to loop through all the files
# in a folder that have a certain file extension, for example
import glob

# `cv2` is the actual library name for `openCV`
import cv2

# and of course, we need our Python library for interfacing
# with the tesseract OCR process
import pytesseract

# we'll use the pdf name to name both our generated images and text files
pdf_name = "SafetyNet"

# our source pdf is in the same folder as our Python script
pdf_source_file = pdf_name+".pdf"

# as long as a folder with the same name as the pdf does not already exist
if os.path.isdir(pdf_name) == False:

    # create a new folder with that name
    target_folder = os.mkdir(pdf_name)

# store all the pages of the PDF in a variable
pages = convert_from_path(pdf_source_file, 300) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# loop through all the converted pages, enumerating them so that the page
# number can be used to label the resulting images
for page_num, page in enumerate(pages):

    # create unique filenames for each page image, combining the
    # folder name and the page number
    filename = os.path.join(pdf_name,"p"+str(page_num)+".png") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # save the image of the page in system
    page.save(filename, 'PNG')

# next, go through all the files in the folder that end in `.png`
for img_file in glob.glob(os.path.join(pdf_name, '*.png')): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

    # replace the slash in the image's filename with a dot
    temp_name = img_file.replace("/",".")

    # pull the unique page name (e.g. `p2`) from the `temp_name`
    text_filename = temp_name.split(".")[1] ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)

    # now! create a new, writable file, also in our target folder, that
    # has the same name as the image, but is a `.txt` file
    output_file = open(os.path.join(pdf_name,text_filename+".txt"), "w")

    # use the `cv2` library to interpret our image
    img = cv2.imread(img_file)

    # create a new variable to hold the results of using pytesseract's
    # `image_to_string()` function, which will do just that
    converted_text = pytesseract.image_to_string(img)

    # write our extracted text to our output file
    output_file.write(converted_text)

    # close the output file
    output_file.close()

1

在这里,我们将源文件的路径(在这种情况下,它只是文件名,因为它与我们的脚本位于同一文件夹中)和输出图像的期望每英寸点数(DPI)分辨率传递给 convert_from_path() 函数。虽然设置较低的 DPI 会快得多,但质量较差的图像可能会导致明显不准确的 OCR 结果。300 DPI 是标准的“打印”质量分辨率。

2

在这里,我们使用 os 库的 .join 函数将新文件保存到我们的目标文件夹中。我们还必须使用 str() 函数将页面号转换为文件名中使用的字符串。

3

请注意 *.png 可以翻译为“以 .png 结尾的任何文件”。glob() 函数创建了一个包含我们存储图像的文件夹中所有文件名的列表(在这种情况下,它的值为 pdf_name)。

4

字符串操作非常棘手!为了为我们的 OCR 文本文件生成唯一(但匹配的!)文件名,我们需要从 img_file 的值中提取一个以 SafetyNet/ 开头且以 .png 结尾的唯一页面名称。因此,我们将斜杠替换为句点,得到类似 SafetyNet.p1.png 的内容,然后如果在句点上使用 split() 这个,我们将得到一个列表:["SafetyNet", "p1", "png"]。最后,我们可以访问位置为 1 的“页面名称”。我们需要做所有这些,因为我们不能确定 glob() 会首先从图像文件夹中提取 p1.png,或者它是否会按顺序提取所有图像。

在大多数情况下,运行此脚本可以满足我们的需求:只需几十行代码,它将一个多页的 PDF 文件首先转换为图像,然后将(大部分)内容写入一系列新的文本文件中。

然而,这种全能方法也有其局限性。将 PDF 转换为图像——或者将图像转换为文本——是我们可能经常需要做的任务之一,但不总是同时进行。换句话说,长远来看,为解决这个问题编写两个独立的脚本可能更为实用,并且可以依次运行它们。事实上,通过稍加调整,我们可能可以分解前述脚本,使得我们可以将任何 PDF 转换为图像或任何图像转换为文本,而无需编写任何新代码。听起来相当巧妙,对吧?

这个重新思考和重组工作中的代码的过程被称为代码重构。在英语写作中,我们会描述这个过程为修订或编辑,而目标都是相同的:使你的工作更简单、更清晰和更有效。与文档编写一样,重构实际上是扩展数据处理工作的另一种重要方式,因为它使得代码重用变得更加直接。我们将在第八章中探讨代码重构和脚本重用的各种策略。

使用 Tabula 访问 PDF 表格

如果你查看了前面部分生成的文本文件,你可能已经注意到这些文件中有很多“额外内容”:页码和页眉、换行符以及其他“废物”。同时,也有一些关键元素缺失,比如图片和表格。

虽然我们的数据工作不会涉及到分析图像(这是一个更加专业化的领域),但在 PDF 文件中找到包含我们希望处理的数据的表格并不罕见。事实上,在我的家乡领域——新闻业,这个问题非常常见,以至于一群调查记者设计并构建了一个名为Tabula的工具,专门用来解决这个问题。

Tabula 并不是一个 Python 库——它实际上是一个独立的软件。要试用它,请下载安装程序适合您系统的版本;如果您使用的是 Chromebook 或 Linux 机器,您需要下载 .zip 文件,并按 README.txt 中的说明操作。无论您使用哪种系统,您可能需要先安装 Java 编程库,您可以通过在终端窗口中运行以下命令来完成:

sudo apt install default-jre

像我们将在后面章节讨论的一些其他开源工具(如 OpenRefine,在第二章中用于准备一些示例数据,并在第十一章中简要介绍),Tabula 在幕后完成其工作(尽管部分工作可在终端窗口中看到),您可以通过 web 浏览器与其交互。这是一种获取更传统图形界面访问权限的方式,同时仍然让您的计算机大部分资源空闲以进行大量数据工作。

结论

希望本章中的编程示例已经开始让您了解,凭借精心选择的库和在第二章中介绍的几个基本的 Python 脚本概念,您可以用相对较少的 Python 代码解决各种各样的数据处理问题。

你可能也注意到,除了我们的 PDF 文本之外,本章中所有练习的输出本质上都是一个 .csv 文件。这并非偶然。.csv 文件不仅高效且多用途,而且我们几乎需要表格类型的数据来进行几乎任何基本的统计分析或可视化。这并不是说分析非表格数据不可能;事实上,这正是许多当代计算机科学研究(如机器学习)所关注的内容。然而,由于这些系统通常复杂且不透明,它们并不适合我们在这里专注于的数据处理工作。因此,我们将把精力放在能帮助我们理解、解释和传达关于世界的新见解的分析类型上。

最后,在本章中,我们的工作集中在基于文件的数据和预先保存的基于 feed 的数据版本上,而在第五章中,我们将探讨如何使用 Python 结合 API 和网页抓取来从在线系统中处理数据,必要时甚至直接从网页本身获取数据!

^(1) 与您可能知道的其他一些格式相比,如 mp4png,它们通常分别与音乐和图像相关联。

^(2) 尽管您很快就会知道如何处理它!

^(3) 实际上,您不需要那么多运气——我们将在“使用 Python 处理 PDF 文档”一节中讨论如何做到这一点。

^(4) 在计算机科学中,“数据”和“信息”这两个术语的使用方式恰好相反: “数据”是收集的关于世界的原始事实,“信息”是组织和结构化这些数据后的有意义的最终产品。然而,近年来,随着“大数据”讨论主导了许多领域,我在此使用的解释方式变得更加普遍,因此为了清晰起见,在本书中我将坚持使用这种解释方式。

^(5) 来自美国劳工统计局

^(6) “多职工”作者是 Stéphane Auray,David L. Fuller 和 Guillaume Vandenbroucke,发布于 2018 年 12 月 21 日,https://research.stlouisfed.org/publications/economic-synopses/2018/12/21/multiple-jobholders

^(7) 参见“改进临时和替代工作安排数据的新建议”,https://blogs.bls.gov/blog/tag/contingent-workers; “灵活工作的价值:Uber 司机的证据”作者是 M. Keith Chen 等,Nber Working Paper Series No. 23296,https://nber.org/system/files/working_papers/w23296/w23296.pdf

^(8) 你也可以在FRED 网站找到相关的操作说明。

^(9) 你也可以直接从我的 Google Drive下载这些文件的副本。

^(10) 截至本文撰写时,LibreOffice 可以处理与 Microsoft Excel 相同数量的行数(2²⁰),但列数远远不及。虽然 Google Sheets 可以处理比 Excel 更多的列,但只能处理约 40,000 行。

^(11) 截至本文撰写时,所有这些库已经在 Google Colab 中可用并准备就绪。

^(12) 关于get_rows()更多信息,请参阅xlrd文档

^(13) 关于这个问题,请参阅xlrd文档

^(14) 作者是 Stephen John Machin 和 Chris Withers,《Excel 电子表格中的日期》https://xlrd.readthedocs.io/en/latest/dates.html

^(15) 如果你在文本编辑器中打开前面三个代码示例的输出文件,你会发现开源的.ods格式是最简单和最干净的。

^(16) 例如,像PennsylvaniaColorado这样的地方。

^(17) 请参阅 Vassili van der Mersch 的文章,“Twitter 与开发者关系的 10 年斗争”来自 Nordic APIs,详见Nordic APIs

^(18) 与 Python 代码不同,XML 文档在工作时需要正确缩进,但这当然会使它们更易读!

^(19) 有趣的事实:.xlsx格式中的第二个x实际上指的是 XML!

^(20) 同样地,我们将逐步介绍像这样的 API 的使用,在第五章中,但是使用这个文档让我们看到不同的数据格式如何影响我们与数据的交互。

^(21) 如果已应用样式表,例如我们在示例 4-13 中使用的 BBC feed,您可以右键点击页面并选择“查看源代码”以查看“原始”XML。

^(22) 查看更多信息,请参阅Adobe 关于 PDF 页面

^(23) George Nagy, “文档识别中的颠覆性发展,” Pattern Recognition Letters 79 (2016): 106–112, https://doi.org/10.1016/j.patrec.2015.11.024. 可查看https://ecse.rpi.edu/~nagy/PDF_chrono/2016_PRL_Disruptive_asPublished.pdf

第五章:访问基于 Web 的数据

互联网是一个不可思议的数据来源;可以说,这是数据成为我们社会、经济、政治甚至创意生活中如此主导的原因。在 第四章 中,我们专注于数据整理的过程,重点是访问和重新格式化已保存在我们设备或云端的基于文件的数据。与此同时,这些数据的大部分最初来自互联网 —— 无论是从网站下载的,如失业数据,还是从 URL 检索的,如 Citi Bike 数据。然而,现在我们已经掌握了如何使用 Python 解析和转换各种基于文件的数据格式,是时候看看首先收集这些文件涉及哪些内容了 —— 特别是当这些文件中包含的数据是实时的、基于 feed 的类型时。为此,我们将在本章的大部分时间内学习如何获取通过 API 提供的数据 —— 这些是我在 第四章 中早期提到的应用程序接口。API 是我们访问由实时或按需服务生成的数据的主要(有时是唯一)途径,例如社交媒体平台、流媒体音乐和搜索服务,以及许多其他私人和公共(例如政府生成的)数据来源。

尽管 API 的许多优点(参见 “为什么使用 API?” 进行复习)使它们成为数据收集公司提供的热门资源,但这样做也存在显著的成本和风险。对于像社交媒体平台这样以广告为驱动的企业来说,一个过于全面的外部产品或项目在数据收集方面是一种利润风险。关于个人的大量数据的即时可用性也显著增加了隐私风险。因此,通过许多 API 访问数据通常需要事先向数据收集者注册,甚至在请求数据时完成基于代码的登录或 身份验证 过程。与此同时,API 提供的数据可访问性是改善政府系统透明度(1)和私营公司责任(2)的有力工具,因此创建帐户和保护访问基于 API 的数据的任何 Python 脚本的前期工作是非常值得的。

在本章的过程中,我们将介绍如何通过 API 访问一系列基于网络的、数据供稿类型的数据集,涵盖从基本的、无需登录的资源到社交媒体平台如 Twitter 的多步骤、高度保护的 API。正如我们将在“访问在线 XML 和 JSON”中看到的那样,这个光谱的简单端涉及使用 Python 的requests库下载已经格式化为 JSON 或 XML 的网页——我们只需要 URL。在“专用 API:添加基本身份验证”中,我们将继续讨论通过Federal Reserve Economic Database (FRED) API 获取的数据访问过程。这与我们在示例 4-12 和 4-15 中看到的数据相同,但与其使用我提供的示例文件不同,您将通过编程方式下载任何最新数据每次运行脚本时

这将需要在FRED 网站上创建一个帐户,以及创建并保护您自己的基本 API“密钥”,以便检索数据。最后,在“专用 API:使用 OAuth 工作”中,我们将介绍像 Twitter 这样的社交媒体平台所需的更复杂的 API 身份验证过程。尽管需要大量的前期工作,但学习如何通过编程方式与这些 API 进行交互具有巨大的回报——在大多数情况下,您可以随时重新运行这些脚本,以获取这些服务提供的最新数据。^(3) 当然,并非我们需要的每个数据源都提供 API,因此我们将在“网页抓取:最后的数据来源”中解释如何使用 Python 的Beautiful Soup库以负责任的方式“抓取”网站上的数据。尽管在许多情况下,这些数据访问任务可以通过浏览器和鼠标完成,但您很快就会看到,使用 Python 如何帮助我们通过使过程更快、更可重复来扩展我们的数据检索工作。

访问在线 XML 和 JSON

在“使用 Python 处理数据供稿类型数据”中,我们探讨了访问和转换两种常见形式的基于网络的数据(XML 和 JSON)的过程。然而,我们没有解决的是如何将这些数据文件从互联网上下载到您的计算机上的问题。然而,借助多功能的 Python requests库,仅需几行代码即可访问和下载这些数据,而无需打开网页浏览器。

为了进行比较,让我们从“手动”下载我们在之前示例中使用的两个文件开始:示例 4-13 中的 BBC 文章的 RSS 供稿和“一个数据源,两种方式”中提到的 Citi Bike JSON 数据。

对于这两种数据源,处理过程基本相同:

  1. 访问目标 URL;在这种情况下,选择以下之一:

  2. 上下文点击(也称为“右键单击”或有时“Ctrl+单击”,具体取决于您的系统)。从出现的菜单中简单地选择“另存为”,并将文件保存到您的 Jupyter 笔记本或 Python 脚本所在的同一文件夹中。

就是这样!现在,您可以在更新的 XML 文件上运行 示例 4-13 中的脚本,或将 Citi Bike JSON 数据粘贴到 https://jsonlint.com 中,查看在正确格式化时其外观如何。请注意,尽管 BBC 页面在浏览器中看起来几乎像是一个“普通”网站,但根据其 .xml 文件扩展名,它也会下载为格式良好的 XML。

现在我们已经看到如何手动完成这一过程的部分,让我们看看在 Python 中完成同样工作需要做些什么。为了简短起见,示例 5-1 中的代码将下载并保存 两个 文件,一个接着一个。

示例 5-1. data_download.py
# a basic example of downloading data from the web with Python,
# using the requests library
#
# the source data we are downloading will come from the following URLs:
# http://feeds.bbci.co.uk/news/science_and_environment/rss.xml
# https://gbfs.citibikenyc.com/gbfs/en/station_status.json

# the `requests` library lets us write Python code that acts like
# a web browser
import requests

# our chosen XML filename
XMLfilename = "BBC_RSS.xml"

# open a new, writable file for our XML output
xml_output_file = open(XMLfilename,"w")

# use the requests library's "get" recipe to access the contents of our
# target URL and store it in our `xml_data` variable
xml_data=requests.get('http://feeds.bbci.co.uk/news/science_and_environment/rss.xml')

# the requests library's `get()` function puts contents of the web page
# in a property `text`
# we'll `write` that directly to our `xml_output_file`
xml_output_file.write(xml_data.text)

# close our xml_output_file
xml_output_file.close()

# our chosen JSON filename
JSONfilename = "citibikenyc_station_status.json"

# open a new, writable file for our JSON output
json_output_file = open(JSONfilename,"w")

# use the `requests` library's `get()` recipe to access the contents of our
# target URL and store it in our `json_data` variable
json_data = requests.get('https://gbfs.citibikenyc.com/gbfs/en/station_status.json')

# `get()` the contents of the web page and write its `text`
# directly to `json_output_file`
json_output_file.write(json_data.text)

# close our json_output_file
json_output_file.close()

很简单,对吧?除了不同的文件名外,示例 5-1 生成的 .xml.json 文件与我们手动从网页保存的文件完全相同。一旦我们设置好了这个脚本,当然,要获取最新数据只需再次运行它,新数据将覆盖早期的文件。

引入 API

直到这一点,我们大部分的数据整理工作都集中在数据源上,这些数据源的内容几乎完全由数据提供者控制。实际上,电子表格文件的内容,以及文档——甚至是刚才在 示例 5-1 中访问的包含 XML 和 JSON 的网页——可能根据我们访问它们的时间而变化,但我们并没有真正影响它们包含什么数据。

同时,我们大多数人习惯于使用互联网获取更符合我们需求的信息。当我们寻找信息时,通常第一步是在搜索引擎中输入关键词或短语,我们期望收到基于我们选择的组合的高度定制的“结果”列表。当然,我们无法控制实际存在哪些网页以供我们的搜索检索,但对于大多数人来说,这个过程如此常见和有用,以至于我们很少停下来考虑背后正在发生的事情。

尽管它们具有视觉导向的界面,但搜索引擎实际上只是 API 的一个特殊实例。它们本质上只是允许您与包含互联网上网站信息的数据库进行 接口 的网页,如它们的 URL、标题、文本、图片、视频等。当您输入搜索词并按 Enter 或 Return 键时,搜索引擎会 查询 其数据库,以获取与您搜索有关的网页内容,并更新您正在查看的网页,以在列表中显示这些结果。尽管社交媒体平台和其他在线服务提供的专门 API 需要我们以非常特定的方式进行身份验证 并且 结构化我们的搜索,但搜索引擎和更专业的 API 之间共享的功能足够多,以至于我们可以通过解构基本的 Google 搜索来学习一些有用的 API 知识。

基础 API:搜索引擎示例

尽管互联网搜索引擎可能是最直接的 API 形式,但从屏幕上看到它们的行为并不总是显而易见。例如,如果您访问 Google 并搜索“weather sebastopol”,您可能会看到类似于 图 5-1 的页面。

Sebastopol weather search results

图 5-1. 样本搜索结果

虽然搜索结果的格式可能非常熟悉,但现在让我们更仔细地查看 URL 栏中发生的事情。您看到的肯定与 图 5-1 的屏幕截图不同,但它应该至少包含部分相同的信息。具体来说,现在在 您的 URL 栏中查找以下内容:

q=weather+sebastopol

找到了吗?太好了。现在在不刷新页面的情况下,将搜索框中的文本更改为“weather san francisco”,然后按 Enter 键。再次查看 URL 中的文本,找到:

q=weather+san+francisco

最后,复制并粘贴以下内容到您的 URL 栏,然后按 Enter 键:

https://www.google.com/search?q=weather+san+francisco

注意到了吗?希望当您在 Google 的搜索栏中输入“weather san francisco”并按 Enter 键时,您看到的搜索结果与直接访问 Google 搜索 URL 并附加 q=weather+san+francisco 的情况几乎相同。这是因为 q=weather+san+francisco 是传送您实际搜索词到 Google 数据库的 查询字符串 部分;其他所有内容只是 Google 为定制或跟踪目的附加的附加信息。

虽然 Google 可以(而且将会!)向我们的搜索 URL 添加任何它想要的内容,我们也可以通过添加其他有用的键值对来扩展其功能。例如,在“智能搜索特定数据类型”中,我们探讨了通过在搜索框查询中添加filetype: .xml来搜索特定文件类型,如.xml;同样,我们可以直接在 URL 栏中通过添加相应的键值对as_filetype=xml来完成相同的操作:

https://www.google.com/search?q=weather+san+francisco&as_filetype=xml

不仅将返回正确格式的结果,还请注意它也会更新搜索框的内容!

Google 搜索引擎在这种情况下的行为几乎与我们在本章剩余部分将看到的更专业 API 相同。大多数 API 遵循我们在此搜索示例中看到的一般结构,其中终端点(在本例中为https://www.google.com/search)与一个或多个查询参数键值对(例如as_filetype=xmlq=weather+san+francisco)结合,形成查询字符串,该字符串附加在表示查询字符串开始的问号(?)之后。API 终端点和查询字符串结构的一般概述如图 5-2 所示。

塞巴斯托波尔天气搜索查询,限制为 5 个结果

图 5-2. 基本查询字符串结构

尽管这种结构非常普遍,但以下是关于基于查询字符串的 API 的一些其他有用提示:

  • 键值对(例如as_filetype=xmlnum=5,甚至q=weather+san+francisco)可以以任何顺序出现,只要它们添加在表示查询字符串开始的问号(?)之后即可。

  • 对于给定 API 而言,具体的键和值是由 API 提供者确定的,只有通过阅读 API 文档或通过实验(尽管这可能会带来自身的问题),才能识别出来。附加到查询字符串中的任何不是被识别键或有效参数值的内容都可能会被忽略。

尽管这些特征几乎适用于所有 API,但其中绝大多数在您创建登录并提供独特的专用“密钥”以及查询时,不会允许您访问任何数据,这些密钥需要首先进行身份验证(或认证)。这部分 API 流程是我们接下来将要讨论的内容。

专用 API:添加基本认证

使用大多数 API 的第一步是与 API 提供商创建某种类型的账户。尽管许多 API 允许免费使用它们,但在互联网上编译、存储、搜索和返回数据的过程仍然存在风险并且需要花钱,因此提供商希望跟踪谁在使用他们的 API,并且可以随时切断您的访问权限。^(4) 这个身份验证过程的第一部分通常包括创建一个账户并为自己和/或每个项目、程序或“应用程序”请求一个 API “密钥”。在像我们现在要进行的“基本” API 身份验证过程中,一旦您在服务提供商的网站上创建了您的 API 密钥,您只需像任何其他查询参数一样将其附加到您的数据请求中,即可成功检索数据。

举例来说,让我们开始设置以编程方式访问我们在示例 4-15 中使用的失业数据。我们将首先在 FRED 网站上创建一个账户并请求一个 API 密钥。一旦我们有了这个,我们就可以简单地将其附加到我们的查询字符串中并开始下载数据!

获取 FRED API 密钥

要在美联储经济数据库(FRED)创建账户,请访问https://fred.stlouisfed.org,并点击右上角的“我的账户”,如图 5-3 所示。

美联储经济数据库(FRED)主页

图 5-3. FRED 登录链接

按照弹出窗口中的说明操作,可以创建一个带有新用户名和密码的账户或使用您的 Google 账户登录。一旦您的注册/登录过程完成,点击“我的账户”链接将打开一个下拉菜单,其中包括一个名为“API Keys”的选项,如图 5-4 所示。

FRED 账户操作

图 5-4. FRED 账户操作

点击该链接将带您到一个页面,您可以使用“请求 API 密钥”按钮请求一个或多个 API 密钥。在接下来的页面上,您将被要求提供一个简要描述将使用 API 密钥的应用程序;这可以只是一两个句子。您还需要通过勾选提供的框同意服务条款。点击“请求 API 密钥”按钮完成整个过程。

如果您的请求成功(应该会成功),您将被带到一个显示已生成密钥的中间页面。如果您离开了那个页面,您可以随时登录并访问https://research.stlouisfed.org/useraccount/apikeys以查看您所有可用的 API 密钥。

使用您的 API 密钥请求数据

现在您有了 API 密钥,让我们探讨如何请求我们在示例 4-15 中使用的数据。首先尝试在浏览器中加载以下 URL:

https://api.stlouisfed.org/fred/series/observations?series_id=U6RATE&file_type=json

即使您已在该浏览器上登录到 FRED,您也会看到如下内容:

{"error_code":400,"error_message":"Bad Request.  Variable api_key is not set.
Read https:\/\/research.stlouisfed.org\/docs\/api\/api_key.html for more
information."}

这是一个非常描述性的错误消息:它不仅告诉您出了什么问题,还提示了如何修复它。由于您刚刚创建了一个 API 密钥,所以您只需将其作为附加参数添加到您的请求中:

https://api.stlouisfed.org/fred/series/observations?series_id=U6RATE&file_type=json&
api_key=*YOUR_API_KEY_HERE*

用您的 API 密钥替换YOUR_API_KEY_HERE。在浏览器中加载该页面将返回类似以下的内容:

{"realtime_start":"2021-02-03","realtime_end":"2021-02-03","observation_start":
"1600-01-01","observation_end":"9999-12-31","units":"lin","output_type":1,
"file_type":"json","order_by":"observation_date","sort_order":"asc","count":324,
"offset":0,"limit":100000,"observations":[{"realtime_start":"2021-02-03",
"realtime_end":"2021-02-03","date":"1994-01-01","value":"11.7"},
...
{"realtime_start":"2021-02-03","realtime_end":"2021-02-03","date":"2020-12-01",
"value":"11.7"}]}

很棒,对吧?现在您知道如何使用 API 密钥进行数据请求,是时候复习如何定制这些请求和在使用 Python 脚本时保护您的 API 密钥了。

阅读 API 文档

如前面的示例所示,一旦我们拥有 API 密钥,我们就可以随时从 FRED 数据库加载最新数据。我们所需做的只是构建我们的查询字符串并添加我们的 API 密钥。

那么我们如何知道 FRED API 将接受哪些键/值对以及它们将返回什么类型的信息呢?唯一真正可靠的方法是阅读 API 文档,它应该提供使用指南和(希望的话)API 的示例。

不幸的是,API 文档没有被广泛采用的标准,这意味着使用新的 API 几乎总是一种反复试验的过程,特别是如果文档质量不佳或提供的示例不包含您正在寻找的信息。事实上,甚至找到特定 API 的文档并不总是一件直接的事情,通常通过网络搜索是最简单的途径。

例如,从FRED 主页获取到 FRED API 文档需要点击页面中部大约一半的工具选项卡,然后在页面右下角选择开发者 API 链接,这将带您到https://fred.stlouisfed.org/docs/api/fred。相比之下,通过网络搜索“fred api documentation”将直接带您到相同的页面,如图 5-5 所示。

FRED API 文档主页

图 5-5. FRED API 文档页面

不幸的是,此页面上的链接列表实际上是一组端点——不同的基本 URL,您可以使用它们来请求更具体的信息(请记住端点是在问号 (?) 之前的所有内容,它与查询字符串分隔开)。在前面的例子中,您使用了端点 https://api.stlouisfed.org/fred/series/observations,然后配对了 series_id=U6RATEfile_type=json,当然,还有您的 API 密钥,以生成响应。

在 图 5-5 中滚动页面并点击标有“fred/series/observations”的文档链接,会将你带到 https://fred.stlouisfed.org/docs/api/fred/series_observations.html,该页面列出了该特定端点的所有有效查询键(或参数)以及这些键可以具有的有效值和一些示例查询 URL,如 图 5-6 所示。

FRED API 'observations' endpoint 文档

图 5-6. FRED API observations 端点文档

例如,你可以通过使用 limit 参数来限制返回的观测数量,或者通过添加 sort_order=desc 来反转返回结果的排序顺序。你还可以指定特定的数据格式(例如 file_type=xml 用于 XML 输出)或单位(例如 units=pc1 以查看输出作为与一年前相比的百分比变化)。

在使用 Python 时保护你的 API 密钥

正如你可能已经猜到的那样,从 FRED(或其他类似的 API)下载数据就像用你完整的查询替换 示例 5-1 中的其中一个 URL 一样简单,因为它生成的网页只是互联网上的另一个 JSON 文件。

同时,该查询包含一些特别敏感的信息:你的 API 密钥。记住,就 FRED(或任何其他 API 所有者)而言,对于在其平台上使用你的 API 密钥进行的任何活动,你都要负责。这意味着虽然你始终希望用诸如 Git 这样的工具记录、保存和版本化你的代码,但你绝不希望你的 API 密钥或其他凭据最终被其他人可以访问的文件所获取。

警告

正确保护你的 API 密钥需要一些努力,如果你是第一次使用数据、Python 或 API(或三者兼有),你可能会被诱惑跳过接下来的几节,只是把你的 API 凭据留在可能被上传到互联网的文件中。^(5) 不要这么做!虽然现在你可能会想,“谁会有兴趣看我的工作?”或“我只是随便玩玩而已—有什么区别?”但你应该知道两件事。

首先,与文档一样,如果现在没有正确处理保护您的凭据,以后要做这件事情会更加困难和耗时,部分原因是到那时您可能已经忘记了具体涉及的内容,另一部分原因是可能已经太晚。其次,虽然我们中很少有人觉得自己正在做的事情“重要”或足够“可见”,以至于其他人去查看它,但现实是,恶意行为者并不在乎他们的替罪羊是谁——如果您让它变得容易,他们可能会选择您。此外,后果可能不仅仅是使您被踢出数据平台。2021 年,SolarWinds 的前 CEO 声称,通过对公司软件的弱点,通过实习生个人 GitHub 账户上上传的一个弱密码文件,可能导致数千个高安全性系统的大规模入侵。^(6) 换句话说,即使你只是“练习”,你最好一开始就做好安全保护。

保护您的 API 凭据是一个两部分的过程:

  1. 您需要将 API 密钥或其他敏感信息与其余代码分开。我们将通过将这些凭据存储在单独的文件中,并且仅在实际运行脚本时主代码加载它们来实现这一点。

  2. 您需要一种可靠的方法来确保在使用 Git 进行代码备份时,例如,这些凭证文件绝对不会备份到任何在线位置。我们将通过在包含这些文件的任何文件名中加入credentials这个词,并使用gitignore文件来确保它们不会上传到 GitHub。

实现这两个目标的最简单方法是为包含 API 密钥或其他敏感登录相关信息的任何文件定义命名约定。在这种情况下,我们将确保任何这类文件在文件名中都包含credentials这个词。然后,我们将确保创建或更新一种名为.gitignore的特殊类型的 Git 文件,该文件存储了关于告诉 Git 哪些文件不应永远提交到我们的存储库和/或上传到 GitHub 的规则。通过为我们的“凭证”文件添加到.gitignore的规则,我们保证不会意外上传包含敏感登录信息的任何文件到 GitHub。

创建你的“凭证”文件

直到现在,我们一直将完成特定任务(如下载或转换数据文件)的所有代码放入单个 Python 文件或笔记本中。然而,出于安全性和重复使用的考虑,当我们使用 API 时,将功能代码与凭据分开更为合理。幸运的是,这个过程非常简单。

首先,创建并保存一个名为FRED_credentials.py的新的空白 Python 文件。为了简单起见,请将此文件放在计划用于从 FRED 下载数据的 Python 代码所在的同一文件夹中。

然后,简单地创建一个新变量并将其值设置为你自己的 API 密钥,如 示例 5-2 所示。

示例 5-2. 示例 FRED 凭据文件
my_api_key = "*`your_api_key_surrounded_by_double_quotes`*"

现在只需保存你的文件!

在单独的脚本中使用你的凭据

现在,你的 API 密钥作为另一个文件中的变量存在,你可以将其导入到任何你想要使用它的文件中,使用我们之前用来导入其他人创建的库的相同方法。 示例 5-3 是一个使用存储在我的 FRED_credentials.py 文件中的 API 密钥下载 FRED 的 U6 失业数据的示例脚本。

示例 5-3. FRED_API_example.py
# import the requests library
import requests

# import our API key
from FRED_credentials import my_api_key ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# specify the FRED endpoint we want to use
FRED_endpoint = "https://api.stlouisfed.org/fred/series/observations?"

# also specify the query parameters and their values
FRED_parameters = "series_id=U6RATE&file_type=json"

# construct the complete URL for our API request, adding our API key to the end
complete_data_URL = FRED_endpoint + FRED_parameters +"&api_key="+my_api_key

# open a new, writable file with our chosen filename
FRED_output_file = open("FRED_API_data.json","w")

# use the `requests` library's `get()` recipe to access the contents of our
# target URL and store it in our `FRED_data` variable
FRED_data = requests.get(complete_data_URL)

# `get()` the contents of the web page and write its `text`
# directly to `FRED_output_file`
FRED_output_file.write(FRED_data.text)

# close our FRED_output_file
FRED_output_file.close()

1

我们可以通过使用 from 关键字与我们的凭据文件的名称(注意,这里我们包括 .py 扩展名)来使我们的 API 对这个脚本可用,然后告诉它import包含我们的 API 密钥的变量。

现在,我们已成功将我们的 API 凭据与代码的主要部分分开,我们需要确保我们的凭据文件在我们git commit我们的工作和/或git push到互联网时不会意外被备份。为了简单而系统地做到这一点,我们将使用一种特殊类型的文件,称为 .gitignore

开始使用 .gitignore

正如其名称所示,.gitignore 文件让你指定某些类型的文件——惊喜,惊喜!——你希望 Git “忽略”,而不是跟踪或备份。通过创建(或修改)存储库中 .gitignore 文件中的模式匹配规则,我们可以预定义我们的存储库将跟踪或上传哪些类型的文件。虽然我们理论上可以通过从不使用 git add 来手动完成相同的操作——但使用 .gitignore 文件可以强制执行此行为^(8) 并且防止 Git 每次运行 git status 时“警告”我们有未跟踪的文件。没有 .gitignore 文件,我们将不得不确认每次提交时要忽略的文件——这会很快变得乏味,并很容易导致错误。只需一次匆忙的 git add -A 命令就可能意外开始跟踪我们的敏感凭据文件——并且将文件移出你的 Git 历史比将其加入要困难得多。更好的办法是通过一点准备来避免整个问题。

换句话说,.gitignore 文件是我们的朋友,让我们创建通用规则,防止我们意外跟踪不想要的文件,并确保 Git 只报告我们真正关心的文件的状态。

目前,我们将在与我们的FRED_credentials.py文件相同的文件夹/存储库中创建一个新的.gitignore文件,只是为了感受一下它们是如何工作的。^(9) 为此,我们将首先在 Atom 中打开一个新文件(或者您可以直接在 GitHub 存储库中添加一个新文件),并将其保存在与您的FRED_credentials.py相同的文件夹中,名称为.gitignore(确保文件名以点(.)开头——这很重要!)。

接下来,在您的文件中添加以下行:

# ignoring all credentials files
**credentials*

与 Python 一样,.gitignore文件中的注释以井号(#)符号开头,因此此文件的第一行只是描述性的。第二行的内容(**credentials*)是一种正则表达式——一种特殊的模式匹配系统,它让我们可以以一种类似于向另一个人解释的方式描述字符串(包括文件名)。^(10) 在这种情况下,表达式**credentials*转换为“此存储库中任何位置包含单词credentials的文件。”通过将此行添加到我们的.gitignore文件中,我们确保此存储库中任何文件名中包含单词credentials的文件都不会被跟踪或上传到 GitHub。

要查看您的.gitignore文件的效果,请保存文件,然后在命令行中运行:

git status

虽然您应该看到您为示例 5-3 中的代码创建的新文件,但应该看到您的FRED_credentials.py文件列为“未跟踪”。如果您想确保您打算忽略的文件实际上正在被忽略,您还可以运行:

git status --ignored

这将仅显示您的存储库中当前被忽略的文件。在其中,您可能还会看到pycache文件夹,我们也不需要备份它。

专用 API:使用 OAuth 工作

到目前为止,我们拥有一切可以使用通常被描述为“基本”身份验证流程的 API 所需的东西:我们在 API 提供商那里创建一个账户,并获得一个我们将附加到我们的数据请求中的密钥,就像我们在示例 5-3 中所做的那样。

虽然这个过程非常简单直接,但它也有一些缺点。如今,API 可以做的远不止返回数据:它们还是应用程序发布更新到社交媒体账户或向您的在线日历添加项目的方式。为了实现这一点,它们显然需要某种访问权限来访问您的账户——但当然您不想随意与应用程序和程序共享您的登录凭据。如果您只是向应用程序提供了这些信息,稍后要停止应用程序访问您的账户的唯一方法就是更改您的用户名和密码,然后您必须向所有您仍然想要使用的应用程序提供您更新后的凭据,以便它们继续工作……情况会变得混乱而复杂,很快就会变得混乱而复杂。

OAuth 认证工作流程旨在通过提供一种方式来提供 API 访问而不传递一堆用户名和密码来解决这些问题。总体上,这是通过编写所谓的 授权循环 来实现的,包括三个基本步骤:

  1. 获取和编码您的 API 密钥和“密钥”(每个都只是您从 API 提供商那里获取的字符串,就像我们在 FRED API 密钥中所做的那样)。

  2. 将这两个字符串的编码组合作为(又一个)“密钥”发送到特定的“授权”端点/URL。

  3. 从授权端点接收一个访问令牌(又一个字符串)。访问令牌是您实际发送到 API 数据端点的内容,以及您的查询信息。

虽然这听起来可能很复杂,但请放心,在实践中,即使这个听起来复杂的过程也主要是在特定顺序中传递和传回某些 URL 的字符串。是的,在这个过程中,我们需要对它们进行一些“编码”,但正如你可能猜到的那样,这部分将由一个方便的 Python 库为我们处理。

尽管需要完成授权循环并获取访问令牌,但通过 Python 与这些更专门的 API 交互的过程与我们在“专门 API:添加基本身份验证”中看到的基本相同。我们将创建一个账号,请求 API 凭据,然后创建一个文件,其中包含这些凭据,并做一些准备工作,以便我们可以在我们的主脚本中使用它们。然后我们的主脚本将导入这些凭据,并使用它们从目标平台请求数据并将其写入输出文件。

对于这个示例,我们将使用 Twitter API,但您可以对其他使用 OAuth 方法的平台(如 Facebook)使用大致相同的流程。在这里我们不会花费太多时间讨论如何结构化特定查询,因为任何给定 API 的细节都可能填满一本书!尽管如此,一旦您掌握了这个认证过程,您将拥有开始尝试各种 API 并可以开始练习访问您想要的数据的所需内容。让我们开始吧!

申请 Twitter 开发者账号

就像我们几乎每次想使用新的 API 一样,我们的第一步将是从 Twitter 请求一个 API 密钥。即使您已经有了 Twitter 账号,您也需要申请“开发者访问权限”,大约需要 15 分钟(不包括 Twitter 审核和/或批准的时间)。首先访问 Twitter 开发者 API “申请访问权限” 页面,并点击“申请开发者帐户”按钮。登录后,系统将提示您提供有关您计划如何使用 API 的更多信息。对于本次练习,您可以选择“业余爱好者”和“探索 API”,如 图 5-7 所示。

对 Twitter 开发者 API 的用例选择

图 5-7. Twitter 开发者 API 用例选择

在接下来的步骤中,您将被要求提供一个超过 200 个字符的说明,说明您计划使用 API 的目的;您可以输入类似以下内容:

使用 Twitter API 来学习如何使用 Python 进行数据整理。有兴趣尝试 OAuth 循环,并从公共 Twitter 资源和对话中获取不同类型的信息。

由于我们在这里的目标只是通过 Python 脚本和 OAuth 循环练习从 Twitter 下载数据,您可以将对四个后续问题的回答切换为“否”,如 图 5-8 所示,但如果您开始以其他方式使用 API,则需要更新这些答案。

从 Twitter 开发者 API 中选择数据的预期使用情况

图 5-8. Twitter 开发者 API 预期用途

在接下来的两个屏幕中,您将查看您之前的选择,并点击一个复选框以确认开发者协议。然后,点击“提交申请”,这将触发一个验证电子邮件。如果几分钟内未收到邮件,请务必检查您的垃圾邮件和已删除邮件。一旦找到邮件,请点击邮件中的链接以确认您的访问权限!

创建您的 Twitter “App” 和凭据

一旦 Twitter 批准了您的开发者访问权限,您可以通过登录到 Twitter 账户并访问 https://developer.twitter.com/en/portal/projects-and-apps 来创建一个新的“应用程序”。在页面中央,点击“创建项目”按钮。

这里您将通过一个迷你版的开发者访问申请过程:您需要为项目提供一个名称,指明您打算如何使用 Twitter API,用文字描述该目的,并为与该项目关联的第一个应用程序命名,如 图 5-9 至 图 5-12 所示。

Twitter 项目名称屏幕

图 5-9. Twitter 项目创建:项目名称

Twitter 项目目的屏幕

图 5-10. Twitter 项目创建:项目目的

Twitter 项目描述屏幕

图 5-11. Twitter 项目创建:项目描述

Twitter 应用程序名称

图 5-12. Twitter 项目创建:应用程序名称

一旦您添加了您的应用程序名称,您将看到一个显示您的 API 密钥、API 密钥秘钥和 Bearer 令牌的屏幕,如图 5-13 所示。^(11)

API 密钥和令牌屏幕

图 5-13. Twitter API 密钥和令牌屏幕

由于安全原因,您只能在此屏幕上查看您的 API 密钥和 API 密钥秘钥,因此我们将立即将它们放入我们的 Twitter 凭据文件中(请注意,在开发者仪表板的其他地方,它们被称为“API 密钥”和“API 密钥秘钥” - 即使是大型科技公司也可能在一致性方面出现问题!)。但不要担心!如果您意外地太快从此屏幕点击离开,错误复制了值,或者发生其他任何事情,您都可以随时返回到您的仪表板,并单击您的应用程序旁边的密钥图标,如图 5-14 所示。

带应用的 Twitter 仪表板

图 5-14. 带应用的 Twitter 仪表板

然后,只需在“Consumer Keys”下点击“重新生成”即可获得新的 API 密钥和 API 密钥秘钥,如图 5-15 所示。

现在我们知道如何访问我们应用程序的 API 密钥和 API 密钥秘钥,我们需要将它们放入一个新的“凭据”文件中,类似于我们为我们的 FRED API 密钥创建的文件。要做到这一点,请创建一个名为Twitter_credentials.py的新文件,并将其保存在您要放置用于访问 Twitter 数据的 Python 脚本的文件夹中,如示例 5-4 所示。

示例 5-4. Twitter_credentials.py
my_Twitter_key = "*`your_api_key_surrounded_by_double_quotes`*"
my_Twitter_secret = "*`your_api_key_secret_surrounded_by_double_quotes`*"

Twitter 重新生成密钥

图 5-15. Twitter 重新生成密钥
警告

确保在存储 Twitter API 密钥和 API 密钥秘钥的文件名中包含单词credentials!回想一下在“使用 .gitignore 开始”中,我们创建了一个规则,忽略任何文件名包含单词credentials的文件,以确保我们的 API 密钥永远不会因疏忽上传到 GitHub。因此,请务必仔细检查您文件名中的拼写!为了确保额外的确定性,您总是可以运行:

git status --ignored

在命令行中确认所有您的凭据文件确实被忽略。

编码您的 API 密钥和秘钥

到目前为止,我们与 FRED API 做的事情并没有不同;我们只是需要在我们的凭据文件中创建两个变量(my_Twitter_keymy_Twitter_secret),而不是一个。

现在,但是,我们需要对这些值进行一些工作,以便为身份验证过程的下一步获取它们正确的格式。虽然我不会深入研究在这些接下来的步骤中发生的“内部工作”细节,但请知道,这些编码和解码步骤对于保护 API 密钥和 API 密钥密钥的原始字符串值以便安全发送到互联网上是必要的。

因此,对于我们的Twitter_credentials.py文件,我们现在将添加几行代码,以使完成的文件看起来像 Example 5-5。

示例 5-5. Twitter_credentials.py
my_Twitter_key = "your_api_key_surrounded_by_double_quotes"
my_Twitter_secret = "your_api_key_secret_surrounded_by_double_quotes"

# import the base64 encoding library
import base64 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# first, combine the API Key and API Key Secret into a single string
# adding a colon between them
combined_key_string = my_Twitter_key+':'+my_Twitter_secret

# use the `encode()` method on that combined string,
# specifying the ASCII format (see: https://en.wikipedia.org/wiki/ASCII)
encoded_combined_key = combined_key_string.encode('ascii')

# encode the ASCII-formatted string to base64
b64_encoded_combined_key = base64.b64encode(encoded_combined_key)

# _decode_ the encoded string back to ASCII,
# so that it's ready to send over the internet
auth_ready_key = b64_encoded_combined_key.decode('ascii')

1

该库将允许我们将我们的原始 API 密钥和 API 密钥密钥转换为发送到 Twitter 授权端点的正确格式。

现在我们已经正确编码了 API 密钥和 API 密钥密钥,我们可以将justauth_ready_key导入到我们将用来指定和拉取数据的脚本中。在进行一次请求以获取我们的access token到授权端点之后,我们终于(!)准备好检索一些推文了!

从 Twitter API 请求访问令牌和数据

正如我们在 Example 5-3 中所做的那样,我们现在将创建一个 Python 文件(或笔记本),在那里我们将执行我们的 Twitter 数据加载过程的下两个步骤:

  1. 请求(和接收)从 Twitter 获取访问令牌或bearer token

  2. 将该 bearer token 包含在发送给 Twitter 的数据请求中并接收结果

请求访问令牌:get versus post

从 Twitter 请求访问或 bearer token 实际上只是向授权端点发送格式良好的请求,该端点是https://api.twitter.com/oauth2/token。但是,与将我们的auth_ready_key附加到端点 URL 不同,我们将使用称为post请求的东西(回想一下,在示例 5-1 和 5-3 中,我们使用的requests方法称为get)。

在这里,使用post请求是重要的部分,因为它在一定程度上提供了比get请求更高的安全性^(12),但主要是因为当我们要求 API 执行超出简单返回数据的操作时,post请求实际上是标准。因此,当我们使用post提交我们的auth_ready_key时,Twitter API 将处理我们的唯一密钥并返回一个唯一的 bearer token。

在 Python 中构建我们的post请求时,我们需要创建两个dict对象:一个包含请求的headers,其中包含我们的auth_ready_key和一些其他信息,另一个包含请求的data,在这种情况下将指定我们正在请求凭证。然后,我们将这些作为参数传递给requests库的post方法,而不是像在示例 5-6 中所示那样将它们粘贴在 URL 字符串的末尾。

示例 5-6. Twitter_data_download.py
# import the encoded key from our credentials file
from Twitter_credentials import auth_ready_key

# include the requests library in order to get data from the web
import requests

# specify the Twitter endpoint that we'll use to retrieve
# our access token or "bearer" token
auth_url = 'https://api.twitter.com/oauth2/token'

# add our `auth_ready_key` to a template `dict` object provided
# in the Twitter API documentation
auth_headers = {
    'Authorization': 'Basic '+auth_ready_key,
    'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
} ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# another `dict` describes what we're asking for
auth_data = {
    'grant_type': 'client_credentials'
} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# make our complete request to the authorization endpoint, and store
# the results in the `auth_resp` variable
auth_resp = requests.post(auth_url, headers=auth_headers, data=auth_data)

# pull the access token out of the json-formatted data
# that the authorization endpoint sent back to us
access_token = auth_resp.json()['access_token']

1

这个dict包含了授权端点想要返回访问令牌所需的信息。这包括我们的编码密钥及其数据格式。

2

auth_headersauth_data对象的格式是由 API 提供者定义的。

实际上非常直接了当,对吧?如果一切顺利(我们马上会确认),我们只需在这个脚本中添加几行代码,就可以使用我们的访问令牌请求关于 Twitter 上发生的实际数据。

现在我们已经有了一个访问令牌(或Bearer 令牌),我们可以继续请求一些推文。为了演示目的,我们将保持请求简单:我们将请求包含单词Python的最近推文的基本搜索请求,并要求最多返回四条推文。在示例 5-7 中,我们在示例 5-6 中开始的脚本基础上构建并发出此请求,包括我们新获取的 Bearer 令牌在头部。一旦响应返回,我们将数据写入文件。然而,由于我们在返回的 JSON 数据中除了推文文本外还有很多内容,我们也会仅仅打印出每条推文的文本,以确保我们获得了正确(有时是意外的)结果 😉

示例 5-7. Twitter_data_download.py,继续
# now that we have an access/bearer token, we're ready to request some data!

# we'll create a new dict that includes this token
search_headers = {
    'Authorization': 'Bearer ' + access_token
}

# this is the Twitter search API endpoint for version 1.1 of the API
search_url  = 'https://api.twitter.com/1.1/search/tweets.json'

# create a new dict that includes our search query parameters
search_params = {
    'q': 'Python',
    'result_type': 'recent',
    'count': 4
} ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# send our data request and store the results in `search_resp`
search_resp = requests.get(search_url, headers=search_headers, params=search_params)

# parse the response into a JSON object
Twitter_data = search_resp.json()

# open an output file where we can save the results
Twitter_output_file = open("Twitter_search_results.json", "w")

# write the returned Twitter data to our output file
Twitter_output_file.write(str(Twitter_data)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# close the output file
Twitter_output_file.close()

# loop through our results and print the text of the Twitter status
for a_Tweet in Twitter_data['statuses']: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)
    print(a_Tweet['text'] + '\n')

1

在这种情况下,我们的查询(q)是Python,我们正在寻找recent结果,并且我们希望最多返回4条推文。请记住,我们可以包含在此对象中的键和值由数据提供者定义。

2

因为 Twitter 返回的响应是一个 JSON 对象,所以在我们将其写入文件之前,我们必须使用内置的 Python str() 函数将其转换为字符串。

3

由于每个结果中包含的信息都很多,我们将打印出每条返回的推文文本,以便了解其内容。statuses是 JSON 对象中的推文列表,推文的实际文本可以通过键text访问。

根据 Twitter 用户最近对 Python 的活动程度,即使你只在几分钟内再次运行此脚本,你可能会看到不同的结果。^(13) 当然,你可以更改此搜索以包含任何你想要的查询术语;只需根据需要修改search_params变量的值。要查看所有可能的参数及其有效值,你可以查看此特定 Twitter API 端点的 API 文档

就是这样了!虽然 Twitter 提供了许多不同的 API(其他 API 允许你实际上发布到你自己的时间轴甚至别人的时间轴),但是在访问和处理数据方面,我们在这里所讲述的内容足以让你开始使用这些以及其他类似的 API 了。

API 伦理

现在你知道如何从像 Twitter 这样的服务中发出 API 请求(以及其他使用 OAuth 流程的服务),你可能会想象你可以用收集到的数据做很多有趣的事情。然而,在你开始编写几十个脚本来跟踪你喜欢的在线话题讨论之前,现在是时候进行一些实际和伦理上的思考了。

首先,几乎每个 API 都使用速率限制来限制在给定时间间隔内可以进行多少数据请求。例如,在我们在示例 5-7 中使用的特定 API 端点上,你可以在 15 分钟的时间段内最多发出 450 个请求,每个请求最多可以返回 100 条推文。如果超过这个限制,你的数据请求可能会在 Twitter 确定下一个 15 分钟窗口开始之前无法返回数据。

第二,虽然你可能没有详细阅读开发者协议(别担心,你并不孤单;^(14) ),你总是可以在在线参考副本中找到相关内容。该协议包含一些具有重要实际和伦理意义的条款。例如,Twitter 的开发者协议明确禁止“与 Twitter 外部的数据匹配”这一行为——也就是说,不得将 Twitter 数据与用户的其他信息结合,除非用户直接提供了这些信息或明确同意。协议还规定了您如何存储和展示从 API 获取的 Twitter 内容,以及一系列其他规则和限制。

无论这些服务条款是否具有法律约束力,^(15)或者在本质上是否真正符合伦理,记住最终是的责任确保你以伦理方式收集、分析、存储和共享任何数据。这意味着考虑到你可能使用的数据涉及的人的隐私和安全,以及思考聚合和分享的影响。

当然,如果伦理问题容易识别和达成一致,我们将生活在一个截然不同的世界中。因为它们不是,许多组织都设有明确定义的审查流程和监督委员会,旨在帮助探索并(如果可能的话)解决在例如研究和数据收集影响人类之前的伦理问题。对我个人来说,当试图思考伦理问题时,我仍然认为一个好的起点是"新闻记者职业道德准则"协会。虽然这份文件并没有详细涵盖每一个可能的伦理情境,但它阐述了一些核心原则,我认为所有数据用户在收集、分析和分享信息时都应该考虑。

然而,最重要的是,无论你做出什么选择,你都准备好支持它们。数据获取和整理的一大可能性是能够发现新信息并生成新的见解。就像现在完全掌握做到这一点的技能一样,使用它们的责任也完全掌握在你的手中。

网络抓取:最后的数据来源

虽然 API 是由公司和组织设计的,目的是通过互联网提供访问通常是丰富多样的数据集,但仍然有大量信息只存在于基于表单或高度格式化的网页上,而不是 API 甚至不是 CSV 或 PDF。对于这类情况,唯一的真正解决方案就是网络抓取,这是使用代码以程序化方式检索网页内容,并系统地从中提取一定量(通常是结构化的)数据的过程。

我将网络抓取称为“最后的数据来源”的原因是,这是一个技术上和伦理上都很复杂的过程。创建网络抓取的 Python 脚本几乎总是需要手动浏览杂乱的 HTML 代码,以定位你所寻找的数据,通常需要大量的试错才能成功地从中分离出你想要的信息。这是耗时的、琐碎的,而且经常令人沮丧。如果网页稍作改变,你可能需要从头开始,以使你的脚本与更新后的页面配合工作。

网络抓取在伦理上也很复杂,因为出于各种原因,许多网站所有者不希望你抓取他们的页面。编写不良的抓取程序可能会使网站不可访问,从而影响其他用户。快速连续进行大量脚本数据请求也可能会增加网站所有者的成本,因为他们需要支付自己的服务提供商在短时间内返回所有数据。因此,许多网站在其服务条款中明确禁止网络抓取。

同时,如果重要信息——尤其是关于强大组织或政府机构的信息——通过网页可获取,那么即使违反了服务条款,抓取也可能是你唯一的选择。虽然本书的范围远远超出了在这个主题上提供任何伪法律建议的范围,请记住,即使你的脚本编写得负责任,并且有一个良好的公共利益原因支持你的抓取活动,你可能会面临来自网站所有者的制裁(如“停止和放弃”信函)甚至法律诉讼。

因此,我强烈建议,在你开始撰写任何网络抓取脚本之前,通过 Sophie Chou 展示的优秀决策树仔细思考一下。该决策树见图 5-16^(16)。

Sophie Chou 为 Storybench.org 制作的网络抓取决策树,2016

图 5-16. 网络抓取决策树(Sophie Chou,Storybench.org,2016)

一旦确定了抓取是你唯一/最佳选择,就该开始工作了。

仔细地抓取 MTA

为了这个例子,我们将使用网络抓取来下载和提取纽约市地铁站提供的网页数据,时间跨度回溯到 2010 年。为了尽可能负责任地进行此操作,我们将确保我们编写的任何Python 脚本:

  1. 确定了我们是谁以及如何联系我们(在示例中,我们将包括一个电子邮件地址)。

  2. 在网页请求之间暂停,以确保我们不会压倒服务器。

此外,我们将结构化和分离脚本的各个部分,以确保我们只在绝对必要时下载特定网页。为此,我们将首先下载并保存包含个别旋转门数据文件链接的网页副本。然后,我们将编写一个单独的脚本,通过初始页面的保存版本提取我们需要的数据。这样,我们从网页中提取数据时的任何试错都会发生在我们保存的版本上,这意味着不会给 MTA 的服务器增加额外负载。最后,我们将编写第三个脚本,解析我们提取链接的文件,并下载过去四周的数据。

在我们开始编写任何代码之前,让我们先看看我们打算抓取的页面。正如您所看到的,该页面只是一个标题和一个长长的链接列表,每个链接将带您转到一个逗号分隔的.txt文件。要加载这些数据文件的最近几周,我们的第一步是下载此索引样式页面的副本(示例 5-8)。

示例 5-8. MTA_turnstiles_index.py
# include the requests library in order to get data from the web
import requests

# specify the URL of the web page we're downloading
# this one contains a linked list of all the NYC MTA turnstile data files
# going back to 2010
mta_turnstiles_index_url = "http://web.mta.info/developers/turnstile.html"

# create some header information for our web page request
headers = {
    'User-Agent': 'Mozilla/5.0 (X11; CrOS x86_64 13597.66.0) ' + \
                  'AppleWebKit/537.36 (KHTML, like Gecko) ' + \
                  'Chrome/88.0.4324.109 Safari/537.36',
    'From': 'YOUR NAME HERE - youremailaddress@emailprovider.som'
} ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# send a `get()` request for the URL, along with our informational headers
mta_web_page = requests.get(mta_turnstiles_index_url, headers=headers)

# open up a writable local file where we can save the contents of the web page
mta_turnstiles_output_file = open("MTA_turnstiles_index.html","w")

# write the `text` web page to our output file
mta_turnstiles_output_file.write(mta_web_page.text)

# close our output file!
mta_turnstiles_output_file.close()

1

由于我们在这里没有使用 API,我们希望主动向网站所有者提供有关我们是谁以及如何联系我们的信息。在这种情况下,我们描述了浏览器应如何处理我们的流量,以及我们的名称和联系信息。这些数据网站所有者将能够在他们的服务器日志中看到。

现在,您将在与您的 Python 脚本相同的文件夹中有一个名为MTA_turnstiles_index.html的文件。要查看它包含的内容,您只需双击它,它应该会在您默认的网络浏览器中打开。当然,因为我们只下载了页面上的原始代码,而没有额外的文件、图片和其他材料,它看起来可能有些奇怪,可能与图 5-17 所示的内容类似。

幸运的是,这一点根本不重要,因为我们在这里追求的是存储在页面 HTML 中的链接列表。然而,在我们担心如何通过程序获取这些数据之前,我们首先需要在页面的 HTML 代码中找到它。为此,我们将使用我们的网络浏览器的检查工具

在浏览器中查看我们的 MTA 网页的本地副本

图 5-17. 在浏览器中查看我们的 MTA 网页的本地副本

使用浏览器检查工具

在您面前浏览器中打开 MTA 转门数据页面的本地副本后,向下滚动,直到您可以看到“数据文件”标题,如图 5-17 所示。为了更精确地针对我们在这个网页上希望使用 Python 脚本获取的只有信息,我们需要尝试识别一些围绕它的 HTML 代码中的独特内容——这将使我们的脚本更容易迅速地锁定我们想要的内容。最简单的方法是通过“检查”常规浏览器界面旁边的代码来做到这一点。

要开始,请将鼠标光标置于“数据文件”文本上,然后右键单击(也称为“右键单击”或有时“Ctrl+单击”,取决于您的系统)。在弹出的菜单底部,如图 5-18 所示,选择“检查”。

我们的 MTA 网页本地副本上的上下文菜单

图 5-18. 在浏览器中查看我们的 MTA 网页的本地副本上的上下文菜单

虽然你特定浏览器的检查工具窗口的确切位置和形状会有所不同(此截图来自 Chrome 浏览器),但它的内容应该至少在某种程度上类似于图 5-19 中的图像。

突出显示“数据文件”标题的检查工具窗口。

图 5-19. 检查工具示例

无论你的浏览器窗口显示在哪里(有时它会锚定在浏览器窗口的侧面或底部,并且如图 5-19 所示,通常有多个信息窗格),我们希望在检查工具窗口中找到的主要内容是“数据文件”这几个字。如果你在窗口出现后找不到它们(或者根本就没看见过它们!),只需将鼠标移动到网页上的这些字上并右键单击,以再次打开检查工具窗口。

在这种情况下,如果你用鼠标悬停在检查工具窗口中显示的代码上:

<div class="span-84 last">

你应该能在浏览器窗口中看到网页的“数据文件”部分被突出显示。根据被突出显示的区域,似乎这段代码包含了我们感兴趣的所有链接列表,我们可以通过在检查工具窗口中向下滚动来确认这一点。在那里,我们将看到我们想要的所有数据链接(以.txt结尾)确实在这个div中(注意它们在其下缩进了吗?这是另一个嵌套工作的实例!)。现在,如果我们能确认类span-84 last只存在于网页的一个地方,我们就有一个写 Python 脚本以提取链接列表的良好起点。

Python 网页抓取解决方案:Beautiful Soup

在我们开始编写下一个 Python 脚本之前,让我们确认我们下载的页面上类span-84 last确实是唯一的。这样做的最简单方法是首先在 Atom 中打开页面(右键单击文件名而不是双击,并从“打开方式”菜单选项中选择 Atom),这将显示页面的代码。然后执行常规的“查找”命令(Ctrl+F 或 Command+F)并搜索span-84 last。事实证明,即使是span-84部分在我们的文件中也只出现了一次,所以我们可以将我们的 Python 脚本限制在查找该 HTML 标签内嵌的链接信息上。

现在我们准备编写 Python 脚本,从网页中提取链接。为此,我们将安装并使用Beautiful Soup库,这是广泛用于解析网页上通常混乱标记的库。虽然Beautiful Soup在某些功能上与我们在示例 4-12 中使用的lxml库有所重叠,但它们之间的主要区别在于Beautiful Soup可以处理甚至不太完美结构的 HTML 和 XML——这通常是我们在网页上不可避免要处理的内容。此外,Beautiful Soup允许我们根据几乎任何特征来“抓取”标记——类名、标签类型,甚至是属性值,因此它几乎成为了从网页标记“汤”中提取数据的首选库。你可以阅读关于该库的完整文档,但安装它的最简单方法与我们用于其他库的方法相同,即在命令行上使用pip

pip install beautifulsoup4

现在,我们可以使用 Python 打开我们网页的本地副本,并使用Beautiful Soup库快速抓取我们需要的所有链接,并将它们写入一个简单的.csv文件,如示例 5-9 所示。

示例 5-9. MTA_turnstiles_parsing.py
# import the Beautiful Soup recipe from the bs4 library
from bs4 import BeautifulSoup

# open the saved copy of our MTA turnstiles web page
# (original here: http://web.mta.info/developers/turnstile.html)
mta_web_page = open("MTA_turnstiles_index.html", "r")

# define the base URL for the data files
base_url = "http://web.mta.info/developers/" ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# the `BeautifulSoup` recipe takes the contents of our web page and another
# "ingredient", which tells it what kind of code it is working with
# in this case, it's HTML
soup = BeautifulSoup(mta_web_page, "html.parser")

# using the "find" recipe, we can pass a tag type and class name as
# "ingredients" to zero in on the content we want.
data_files_section = soup.find("div", class_="span-84 last") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# within that div, we can now just look for all the "anchor" (`a`) tags
all_data_links = data_files_section.find_all("a")

# need to open a file to write our extracted links to
mta_data_list = open("MTA_data_index.csv","w")

# the `find_all()` recipe returns a list of everything it matches
for a_link in all_data_links:

    # combine our base URL with the contents of each "href" (link) property,
    # and store it in `complete_link`
    complete_link = base_url+a_link["href"]

    # write this completed link to our output file, manually adding a
    # newline `\n` character to the end, so each link will be on its own row
    mta_data_list.write(complete_link+"\n")

# once we've written all the links to our file, close it!
mta_data_list.close()

1

如果我们点击网页上的一个数据链接,我们会发现实际数据文件位于 URL 的第一部分是http://web.mta.info/developers/,但每个链接只包含后半部分的 URL(格式为data/nyct/turnstile/turnstile_YYMMDD.txt)。因此,为了使我们的下载脚本具有可用的链接,我们需要指定“基础”URL。

2

由于我们使用检查工具的工作,我们可以直接定位到一个classspan-84 lastdiv来开始寻找我们需要的链接。请注意,在 Python 中class这个词有特殊含义,因此在此使用时 Beautiful Soup 会在其后附加下划线(例如,class_)。

好了!现在我们应该有一个包含我们感兴趣的所有数据链接的新文件。接下来,我们需要使用一个新的脚本读取该列表,并下载这些 URL 中的文件。但是,由于我们不希望通过过快下载文件而过载 MTA 网站,我们将使用内置的 Python time库来确保我们的请求每秒或每两秒之间有间隔。此外,我们将确保只下载我们真正需要的四个文件,而不是仅仅为了下载而下载。要了解这个第二个脚本的组织方式,请查看示例 5-10。

示例 5-10. MTA_turnstiles_data_download.py
# include the requests library in order to get data from the web
import requests

# import the `os` Python library so we can create a new folder
# in which to store our downloaded data files
import os

# import the `time` library
import time ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# open the file where we stored our list of links
mta_data_links = open("MTA_data_index.csv","r")

# create a folder name so that we can keep the data organized
folder_name = "turnstile_data"

# add our header information
headers = {
    'User-Agent': 'Mozilla/5.0 (X11; CrOS x86_64 13597.66.0) ' + \
                  'AppleWebKit/537.36 (KHTML, like Gecko) ' + \
                  'Chrome/88.0.4324.109 Safari/537.36',
    'From': 'YOUR NAME HERE - youremailaddress@emailprovider.som'
}

# the built-in `readlines()` function converts our data file to a
# list, where each line is an item
mta_links_list = mta_data_links.readlines()

# confirm there isn't already a folder with our chosen name
if os.path.isdir(folder_name) == False:

    # create a new folder with that name
    target_folder = os.mkdir(folder_name)

# only download the precise number of files we need
for i in range(0,4):

    # use the built-in `strip()` method to remove the newline (`\n`)
    # character at the end of each row/link
    data_url = (mta_links_list[i]).strip()

    # create a unique output filename based on the url
    data_filename = data_url.split("/")[-1] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # make our request for the data
    turnstile_data_file = requests.get(data_url, headers=headers)

    # open a new, writable file inside our target folder
    # using the appropriate filename
    local_data_file = open(os.path.join(folder_name,data_filename), "w")

    # save the contents of the downloaded file to that new file
    local_data_file.write(turnstile_data_file.text)

    # close the local file
    local_data_file.close()

    # `sleep()` for two seconds before moving on to the next item in the loop
    time.sleep(2)

1

这个库将允许我们在数据请求之间“暂停”我们的下载脚本,以免在太短的时间内向 MTA 服务器发送过多请求(可能会惹上麻烦)。

2

在这里,我们将链接 URL 根据斜杠分割,然后使用negative indexing从结果列表中获取最后一项,负索引从字符串末尾开始计数。这意味着位置为-1的项是最后一项,这里是.txt文件名。这是我们将用于保存数据的本地副本的文件名。

如果一切顺利,您现在应该有一个名为turnstile_data的新文件夹,其中保存了最近的四个闸机数据文件。挺不错,对吧?

结论

现在我们已经探索了多种实际获取数据并将其转换为可用格式的方法,接下来的问题是:我们应该如何处理这些数据?由于所有这些数据整理的目标是能够回答问题并生成一些关于世界的见解,所以我们现在需要从获取数据的过程转向评估、改进和分析数据的过程。为此,在下一章中,我们将对一个公共数据集进行数据质量评估,以便理解其可能性和局限性,以及我们的数据整理工作如何帮助我们充分利用它。

^(1) 就像例如美国财政部一样。

^(2) 例如,请参阅The MarkupNPR上的文章。

^(3) 这也是构建自己“应用程序”的第一步!

^(4) 虽然如果您在太短的时间内提出太多数据请求,这种情况可能发生得最有可能,但大多数 API 提供者可以在任何时候出于任何原因终止您对其 API 的访问。

^(5) 例如,当您git push您的代码时。

^(6) “黑客利用 SolarWinds 的主导地位进行大规模间谍活动” 作者 Raphael Satter、Christopher Bing 和 Joseph Menn,https://reuters.com/article/global-cyber-solarwinds/hackers-at-center-of-sprawling-spy-campaign-turned-solarwinds-dominance-against-it-idUSKBN28P2N8; “前 SolarWinds CEO 指责实习生泄露 solarwinds123 密码” 作者 Brian Fung 和 Geneva Sands,https://www.cnn.com/2021/02/26/politics/solarwinds123-password-intern.

^(7) 这就是为什么使 Python 在您的设备上运行的程序通常被称为 Python 解释器 —— 因为它将我们人类编写的代码转换为您的设备实际可以理解的字节码。

^(8) 即使我们运行,比如 git add -Agit commit -a

^(9) 一个仓库可以在不同的文件夹中有不同的 .gitignore 文件;要获取完整的详情,您可以查看 文档

^(10) 我们在 示例 4-16 中使用了 glob 库,并将在 “正则表达式:强化的字符串匹配” 中详细讨论它。

^(11) 这些密钥已被替换,将不再起作用!

^(12) 例如,post 请求的内容不会像 get 请求那样保存在浏览器历史记录中。

^(13) 请记住,每次运行脚本时,您也将覆盖输出文件,因此它只会包含最新的结果。

^(14) Aleecia M. McDonald 和 Lorrie Faith Cranor, “阅读隐私政策的成本”, I/S: 信息社会法律与政策杂志 4 (2008): 543, https://kb.osu.edu/bitstream/handle/1811/72839/ISJLP_V4N3_543.pdf, 以及 Jonathan A. Obar 和 Anne Oeldorf-Hirsch,“互联网上最大的谎言:忽视社交网络服务的隐私政策和服务条款”, 信息、传播与社会 23 号 1 (2020): 128–147, https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2757465.

^(15) Victoria D. Baranetsky,“数据新闻与法律”, Tow Center for Digital Journalism (2018) https://doi.org/10.7916/d8-15sw-fy51.

^(16) 伴随的博客文章也很棒:https://storybench.org/to-scrape-or-not-to-scrape-the-technical-and-ethical-challenges-of-collecting-data-off-the-web

第六章:评估数据质量

在过去的两章中,我们集中精力识别和访问不同位置(从电子表格到网站)的不同格式的数据。但实际上,获取我们手头(可能)有趣的数据只是个开始。下一步是进行彻底的质量评估,以了解我们所拥有的数据是否有用、可挽救或只是垃圾。

正如你可能从阅读第三章中获得的,打造高质量数据是一项复杂且耗时的工作。这个过程大致上包括研究、实验和顽强的毅力。最重要的是,致力于数据质量意味着你必须愿意投入大量的时间和精力,并且仍然愿意放弃一切重新开始,如果尽管你的最大努力,你所拥有的数据仍无法达到要求。

实际上,归根结底,这最后一个标准可能是使得与数据一起进行真正高质量、有意义的工作变得真正困难的原因。技术技能,正如我希望你已经发现的那样,需要一些努力才能掌握,但只要有足够的练习,它们仍然是可以实现的。研究技能稍微难以记录和传达,但通过本书中的例子,你将有助于发展许多与信息发现和整理相关的技能,这些技能对评估和改善数据质量至关重要。

当谈到接受这样一个事实时,即经过数十个小时的工作后,你可能需要“放弃”一个数据集,因为它的缺陷太深或太广泛,我唯一能提供的建议就是你要试着记住:学习关于世界有意义的事情是一个“长期游戏”。这就是为什么我总是建议那些有兴趣学习如何进行数据整理和分析的人,首先要找到一个对他们来说真正有趣和/或重要的关于世界的问题。要做好这项工作,你必须比“完成”更关心做对。但如果你真的在数据整理的过程中真正重视你所学到的东西,不论是因为学习了新的 Python 或数据整理策略,还是因为与新的专家接触或发现了新的信息资源,这也会有所帮助。事实上,如果你真的关心你正在探索的主题,做好数据工作的努力绝不会被浪费。只是它可能会引导你朝着一个你没有预料到的方向发展。

经常情况下,您会发现虽然一个数据集不能回答您最初的问题,但它仍然可以揭示其中某些关键方面。其他时候,您可能会发现关于您探索的主题没有数据,并且您可以利用这个事实来寻求帮助——发现不存在的数据可能会促使您和其他人彻底改变焦点。而且,由于“完美”的数据集永远不会存在,有时候您可能会选择非常小心地分享您知道有缺陷的数据,因为即使它提供的部分见解也对公众利益有重要作用。在每种情况下重要的是,您愿意对自己所做的选择负责——因为无论“原始”数据是什么或来自何处,经过清洗、增强、转换和/或分析的数据集仍然是您的

如果您感到有些不知所措——嗯,这并不完全是偶然。我们生活在一个时刻,使用强大的数字工具很容易而不考虑它们的现实世界后果,以及建造“先进”技术的人大多数情况下是在按照他们自己的利益编写算法规则的时刻。^(1)最终,数据既可以是信息的机制可以是操纵的机制,既可以解释可以利用。确保您的工作落在这些界限的哪一侧,最终取决于您自己。

但实际上,实现数据质量意味着什么呢?为了了解这一点,我们将在本章的剩余部分中评估现实世界数据,从数据完整性和数据适配性的角度来看,这些概念在第三章中已经介绍过。我们将使用的数据集是来自美国薪资保护计划(PPP)的贷款数据的单个实例,该计划包含有关在 COVID-19 大流行期间向小企业发放的数百万笔贷款的信息。正如我们在本章剩余部分中将首次亲身经历的那样,PPP 数据体现了许多“发现”数据中常见的挑战:不明确的术语和从一个数据版本到下一个数据版本的不明确变化留下了数据适配性问题的空间,这些问题需要额外的研究来解决。数据完整性问题更为直接(尽管解决起来可能不一定更快)——比如确认数据集中某个特定银行的拼写是否始终一致,或者我们的数据文件是否包含应有的值和时间范围。尽管我们将会应对大多数这些挑战,但最终我们的见解将是高度自信的,而非无可辩驳的。与所有数据工作一样,它们将是知情决策、逻辑推理和大量数据处理的累积结果。为了完成这些处理工作,我们最依赖的 Python 库是pandas,它提供了一套流行且强大的工具,用于处理表格类型的数据。让我们开始吧!

大流行和 PPP

在 2020 年春季,美国政府宣布了一个贷款计划,旨在帮助稳定因 COVID-19 大流行导致的美国经济下滑。该计划拥有近 1 万亿美元的指定资金,^(2) PPP 的目标显然是帮助小企业支付租金,并继续支付员工的工资,尽管有时会面临强制关闭和其他限制。尽管第一笔资金发放后失业率出现下降,但联邦政府的一些部分似乎决心不透明资金去向的要求。^(3)

PPP 贷款是否帮助挽救了美国的小企业?随着时间的推移,我们可能会认为这是一个足够直接的问题来回答,但我们是来亲自找出答案的。为此,当然我们会从数据开始进行系统评估,首先是对其整体质量的系统评估,在这一过程中,我们依次审查我们每个特征的 PPP 贷款数据的数据完整性和数据适配性。同样重要的是,我们将仔细记录这个过程的每一部分,以便我们有记录我们所做的事情、我们做出的选择以及背后的推理的记录。这个数据日记将是我们未来的重要资源,特别是在我们需要解释或重现我们的任何工作时。虽然您可以使用任何您喜欢的形式和格式,我喜欢保持我的格式简单,并让我的数据日记与我的代码一起,因此我将记录我的工作在一个 Markdown 文件中,我可以轻松地备份和在 GitHub 上阅读。^(4) 由于这个文件实际上是我所做的一切的一个正在进行的清单,我打算将其称为ppp_process_log.md

评估数据完整性

您是否应该通过评估数据完整性或数据适配性开始数据整理过程?毫不奇怪:两者兼顾。正如在“数据整理是什么?”中所讨论的,除非您有某种问题要解决,并且有一定的感觉某个特定数据集可以帮助您回答它——换句话说,直到您对数据整理过程的方向有了一些想法,并且您的数据“适合”这个过程。同时,要完全评估数据集的适配性通常是困难的,直到其完整性被探索过。例如,如果数据中存在间隙,是否可以通过某种方式填补?是否可以找到缺失的元数据?如果可以,那么我们可能能够解决这些完整性问题,并以更完整的信息回到适配性的评估中。如果我们然后发现可以通过增强数据来改善其适配性(我们将在第七章中详细探讨),当然,这将启动另一轮数据完整性评估。然后循环重新开始。

如果你担心这会导致数据整理的无限循环,你并非完全错误;所有好的问题都会引发其他问题,因此永远有更多东西可以学习。尽管如此,我们可以投入到数据整理工作中的时间、精力和其他资源并非无限,这就是为什么我们必须做出知情的决策并加以记录。彻底评估我们数据的质量将有助于我们做出这些决策。通过系统地检查数据的完整性和适应性,确保不仅最终获得高质量的数据,而且制定和记录决策,可以用来描述(甚至在必要时捍卫)所产生的任何见解。这并不意味着每个人都会同意你的结论。然而,它确实有助于确保你可以就此进行有意义的讨论——这才是真正推动知识进步的。

因此,让我们毫不拖延地开始我们的数据完整性评估吧!

注意

本章使用的许多数据已从互联网上删除和/或替换为其他文件——这并不罕见。尽管如此,我故意保留了本章内容的大部分原貌,因为它们反映了在尝试进行数据整理工作时面临的真实和典型挑战,尽管这些数据已发生变化。

它还说明了数据在数字世界中如何迅速且几乎无法追踪地演变和变化。这是需要记住的一点。同时,你可以在这个Google Drive 文件夹中找到本章引用的所有数据集。

是否具有已知的出处?

在撰写本文时,“最新 PPP 贷款数据”短语的第一个搜索结果是美国财政部网站上的一个页面,链接到 2020 年 8 月的数据(5)。第二个结果链接到更近期的数据,来自负责实际管理资金的小型企业管理局(SBA)(6)。

尽管这两个网站都是与 PPP 相关的政府机构的合法网站,但对我们来说,与 SBA 发布的数据主要合作更有意义。尽管如此,在我首次寻找这些数据时,它并不是我首先找到的东西。

我非常想强调这一点,因为财政部显然是我们正在寻找的数据的一个合理和声誉良好的来源,但它仍然不是最佳的选择。这就是为什么评估你的数据不仅来自何处,还要考虑你如何找到它的重要性。

是否及时?

如果我们想要利用数据来了解当前世界的状态,首先需要确定我们的数据来自何时,并确认它是目前可获得的最新数据。

如果这看起来应该很简单,请再想一想——许多网站不会自动为每篇文章注明日期,因此甚至确定某事何时上线通常是一项挑战。或者,一个标明其内容日期的网站可能会在进行任何变更(甚至是微小的变更)时更改日期;一些网站可能会定期更新“发布”日期,以试图操控搜索引擎算法,这些算法偏爱更新的内容。

换句话说,确定对于你特定的数据整理问题来说,实际上最新、最相关的数据可能需要一些挖掘。唯一确定的方法将是尝试几个不同的搜索词组,点击几组结果,并可能向专家寻求建议。在这个过程中,你很可能会找到足够的参考资料来确认最近可用的数据。

它是完整的吗?

到目前为止,我们知道 PPP 已经进行了多次数据发布。虽然我们可以相当有信心地说我们已经成功地找到了最的数据,但接下来的问题是:这是所有的数据吗?

由于我们主要关注那些获得较大贷款的企业,我们只需要担心检查一个文件:public_150k_plus.csv。^(7)但是我们如何知道这是否包括到目前为止的所有程序阶段,或者只是自 2020 年 8 月首次数据发布以来的贷款?由于我们可以访问这两组数据,^(8)我们可以采用几种策略:

  1. 查找我们“最近”数据文件中的最早日期,并确认它们是2020 年 8 月 8 日之前。

  2. 比较两个数据集的文件大小和/或行数,以确认更新的文件比旧文件更大。

  3. 比较它们包含的数据以确认较早文件中的所有记录是否已经存在于较新的文件中。

此时,你可能会想,“等等,确认最早的日期不就足够了吗?为什么我们要做其他两个?这两者似乎都更加困难。”当然,在理论上,只确认我们从早期阶段获得了数据应该就足够了。但事实上,这只是一个相当粗略的检查。显然,我们不能通过尝试自己收集更全面的数据来确认联邦政府发布的数据是否完整。另一方面(正如我们马上会看到的),政府和大型组织(包括——也许尤其是——银行)的数据收集过程远非完美。^(9)由于我们将使用这些数据来得出结论,如果我们拥有这些数据,我认为彻底地检查是值得的。

令人高兴的是,进行我们的第一个“完整性”检查非常简单:只需在文本编辑器中打开数据,我们可以看到第一个条目包含一个“DateApproved”值为05/01/2020,这表明最近的数据集确实包含从 2020 年春季发放的 PPP 贷款第一轮数据,如图 6-1 所示。

最近 PPP 贷款数据的快速文本编辑器视图。

图 6-1. 最近 PPP 贷款数据的快速文本编辑器视图

太容易了!如果我们想再进一步,我们可以随时编写一个快速脚本来查找最近数据中最早和最近的LoanStatus日期。为了避免混淆,我已将更新的文件重命名为public_150k_plus_recent.csv。目前,^(10) 根据从https://home.treasury.gov/policy-issues/cares-act/assistance-for-small-businesses/sba-paycheck-protection-program-loan-level-data链接的数据,会导致 SBA Box 账户上的不同文件夹,显示的上传日期为2020 年 8 月 14 日。我们需要下载整个文件夹,150k plus 0808.zip,但我们可以提取 CSV 并将其重命名为public_150k_plus_080820.csv

为了帮助我们进行这个过程,通常情况下,我们会寻找一个库来简化操作 —— 这次我们选择 Pandas,这是一个用于操作表格类型数据的广为人知的 Python 库。虽然我们不会在这里详细讨论pandas库(已经有一本由 Pandas 的创作者 Wes McKinney 所写的优秀的 O'Reilly 书籍,Python for Data Analysis!),但我们肯定会在今后进行数据质量检查时充分利用它的许多有用功能。

首先,我们需要通过以下命令行安装这个库:

pip install pandas

现在,让我们使用它来提取最近的 PPP 贷款数据,并查看该文件中的最早和最近日期,如示例 6-1 所示。

示例 6-1. ppp_date_range.py
# quick script for finding the earliest and latest loan dates in the PPP loan
# data

# importing the `pandas` library
import pandas as pd ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# read the recent data into a pandas DataFrame using its `read_csv()` method
ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# convert the values in the `DateApproved` column to *actual* dates
ppp_data['DateApproved'] = pd.to_datetime(ppp_data['DateApproved'],
                                          format='%m/%d/%Y') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# print out the `min()` and `max()` values in the `DateApproved` column
print(ppp_data['DateApproved'].min())
print(ppp_data['DateApproved'].max())

1

在这里使用as关键字可以为库创建一个昵称,以便我们稍后在代码中引用时使用更少的字符。

2

要找到最早和最近的日期,我们首先需要将数据集中的(字符串)数值转换为实际Date数据类型。在这里,我们将使用 Pandas 的to_datetime()函数,并提供(1)要转换的列,以及(2)日期的格式,即它们当前在我们的数据集中的外观。

正如我们从以下输出中看到的那样,这个数据集中最早的贷款是在 2020 年 4 月 3 日进行的,而最近的贷款是在 2021 年 1 月 31 日进行的:

2020-04-03 00:00:00
2021-01-31 00:00:00

请注意,虽然我们不绝对需要将format参数传递给 Pandas 的to_datetime()函数,但这样做总是一个好主意;如果我们不提供此信息,那么 Pandas 必须尝试“猜测”它所查看的日期格式,这可能会使实际处理需要很长时间。 在这里,它只能为我们节省约一秒的处理时间 - 但对于较大的数据集(或多个数据集),这些时间可能会迅速累积!

现在让我们比较最近数据的文件大小和 2020 年 8 月发布的文件的大小。 只需在一个 finder 窗口中查看public_150k_plus_recent.csvpublic_150k_plus_080820.csv文件,我们就可以看到最新数据的文件大小比先前的文件要大得多:8 月份的数据约为 124 MB,而最近的数据则为几百兆字节。 到目前为止还不错。

借鉴我们在示例 4-7 和示例 5-10 中使用的技术,让我们编写一个快速脚本来确定每个文件中有多少行数据,如示例 6-2 所示。

示例 6-2. ppp_numrows.py
# quick script to print out the number of rows in each of our PPP loan data files
# this is a pretty basic task, so no need to import extra libraries!

# open the August PPP data in "read" mode
august_data = open("public_150k_plus_080820.csv","r")

# use `readlines()` to convert the lines in the data file into a list
print("August file has "+str(len(august_data.readlines()))+" rows.") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# ditto for the recent PPP data
recent_data = open("public_150k_plus_recent.csv","r")

# once again, print the number of lines
print("Recent file has "+str(len(recent_data.readlines()))+" rows.")

1

使用readlines()方法将数据文件的行放入列表后,我们可以使用内置的len()方法来确定有多少行。 要打印结果,我们必须首先使用内置的str()函数将该数字转换为字符串,否则 Python 会对我们大喊大叫。

运行此脚本确认,2020 年 8 月的文件包含 662,516 行,而最近版本(2021 年 2 月 1 日)包含 766,500 行。

最后,让我们比较两个文件的内容,以确认较早文件中的所有内容是否出现在较新文件中。 这无疑将是一个多步骤的过程,但让我们从在文本编辑器中打开 8 月份数据文件开始,如图 6-2 所示。

2020 年 8 月 PPP 贷款数据的快速文本编辑器视图。

图 6-2. 2020 年 8 月 PPP 贷款数据的快速文本编辑器视图

立即就能看出存在一些……差异,这将使得这个特定的检查比我们预期的更加复杂。 首先,很明显,最近的文件中有更多的数据列,这意味着我们需要制定一种策略来将较早的数据集中的记录与最近的数据集进行匹配。

首先,我们需要了解两个文件之间似乎重叠的数据列。 我们将通过创建和比较两个小的样本CSV 文件来做到这一点,其中包含每个数据集的前几行数据。

接下来,我们将编写一个快速脚本,将我们的源文件中的每个文件转换为 DataFrame——这是一种特殊的 Pandas 数据类型,用于表格类型数据——然后将前几行写入一个单独的 CSV 文件,如示例 6-3 所示。

示例 6-3. ppp_data_samples.py
# quick script for creating new CSVs that each contain the first few rows of
# our larger data files

# importing the `pandas` library
import pandas as pd

# read the august data into a pandas DataFrame using its `read_csv()` method
august_ppp_data = pd.read_csv('public_150k_plus_080820.csv')

# the `head()` method returns the DataFrame's column headers
# along with the first 5 rows of data
august_sample = august_ppp_data.head()

# write those first few rows to a CSV called `august_sample.csv`
# using the pandas `to_csv()` method
august_sample.to_csv('august_sample.csv', index=False) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# read the recent data into a pandas DataFrame using its `read_csv()` method
recent_ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# the `head()` method returns the DataFrame's column headers
# along with the first 5 rows of data
recent_sample = recent_ppp_data.head()

# write those first few rows to a CSV called `recent_sample.csv`
recent_sample.to_csv('recent_sample.csv', index=False)

1

pandasto_csv() 方法封装了我们之前在普通的 Python 中所做的几个步骤:打开一个新的可写文件并将数据保存到其中。由于 Pandas 在创建每个 DataFrame 时都会添加一个索引列(实质上包含行号),因此我们需要包含第二个“成分” index=False,因为我们希望这些行号出现在我们的输出 CSV 文件中。

现在我们有了两个较小的文件样本,让我们打开它们并仅仅通过视觉比较它们的列标题及其内容。您可以在图 6-3 中看到八月数据的屏幕截图,而最近的数据则显示在图 6-4 中。

八月 PPP 贷款数据文件的前几行。

图 6-3. 八月 PPP 贷款数据文件的前几行

最近 PPP 贷款数据文件的前几行。

图 6-4. 最近 PPP 贷款数据文件的前几行

幸运的是,看起来两个文件的前几行至少包含一些相同的条目。这将使我们更容易比较它们的内容,并(希望能够)确定我们可以使用哪些列来匹配行。

让我们首先选择一个单独的条目来处理,最好是尽可能多填写的条目。例如,某个名为 SUMTER COATINGS, INC. 的东西出现在八月数据样本的第 6 行(在电子表格界面中标记)的 BusinessName 列下。在最近数据样本的第 2 行,同样的值出现在 BorrowerName 列下。在八月样本文件中,术语 Synovus Bank 出现在称为 Lender 的列中,而该术语在最近的样本文件中出现在称为 ServicingLenderName 的列中。到目前为止还算顺利——或者说是吗?

尽管这些行之间的许多细节在两个数据文件中似乎匹配,但一些看似重要的内容匹配。例如,在八月数据样本中,DateApproved 列中的值为 05/03/2020;而在最近的数据样本中,它是 05/01/2020。如果我们再看另一个看似共享的条目,我们会发现对于名为 PLEASANT PLACES, INC. 的企业/借款人(八月数据中的第 5 行和最近数据中的第 3 行),两个文件中的 CD 列(最近文件中的电子表格列 AF,八月文件中的列 P)有不同的值,显示在八月数据中为 SC-01,在最近的数据中为 SC-06。到底发生了什么?

此时,我们必须对哪些列值需要匹配以便我们将特定贷款行视为两者相同作出一些判断。要求业务名称和放贷人名称匹配似乎是一个很好的起点。因为我们知道现在已经进行了多轮贷款,所以我们可能还想要求DateApproved列中的值匹配(尽管 Synovus Bank 在两天内向 Sumter Coatings,Inc.放款似乎不太可能)。那么不匹配的国会选区呢?如果我们查看南卡罗来纳州的国会选区地图,可以清楚地看到自 2013 年以来它们的边界没有变化,^(11)尽管第 1 和第 6 选区似乎有一个共同的边界。考虑到这一点,我们可能会得出结论这里的差异只是一个错误。

正如你所看到的,我们已经发现了一些显著的数据质量问题——而我们只看了五行数据!因为显然我们不能一次解决所有这些差异,所以我们需要从汇集(我们希望)确实匹配的行开始,同时确保跟踪任何不匹配的行。为了做到这一点,我们需要连接合并这两个数据集。但因为我们已经知道会有差异,所以我们需要一种方式来跟踪这些差异。换句话说,我们希望我们连接过程创建的文件包括每一个来自两个数据集的行,无论它们是否匹配。为了做到这一点,我们需要使用所谓的外连接,我们将在示例 6-4 中使用它。

注意,因为外连接保留所有数据行,所以我们的结果数据集可能有多达个体数据集合并后的行数——在这种情况下大约有140 万行。不过别担心!Python 可以处理。但你的设备可以吗?

如果你有一台 Macintosh 或 Windows 机器,在这些示例中处理大约 500 MB 的数据应该没有问题。如果像我一样,你在使用 Chromebook 或类似设备,现在就是迁移到云端的时刻。

示例 6-4. ppp_data_join.py
# quick script for creating new CSVs that each contain the first few rows of
# our larger data files

# importing the `pandas` library
import pandas as pd

# read the august data into a pandas DataFrame using its `read_csv()` method
august_ppp_data = pd.read_csv('public_150k_plus_080820.csv')

# read the recent data into a pandas DataFrame using its `read_csv()` method
recent_ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# now that we have both files in memory, let's merge them!
merged_data = pd.merge(august_ppp_data,recent_ppp_data,how='outer',
    left_on=['BusinessName','Lender','DateApproved'],right_on=['BorrowerName',
    'ServicingLenderName','DateApproved'],indicator=True) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# `print()` the values in the "indicator" column,
# which has a default label of `_merge`
print(merged_data.value_counts('_merge')) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

1

我们在这里传递indicator=True参数,因为它将创建一个新的列,让我们知道哪些行出现在一个或两个文件中。

2

使用indicator=True会生成一个名为merge的列,每行的值显示该行在哪个数据集中匹配。正如我们将从输出中看到的那样,该值将是bothleft_onlyright_only之一。

如果一切顺利,你会得到这样的输出:

_merge
both          595866
right_only    171334
left_only      67333
dtype: int64

那么这意味着什么呢?看起来我们在企业名称、服务贷款人名称和贷款日期上努力匹配八月数据与最近数据成功匹配了 595,866 笔贷款。right_only 贷款是最近的贷款,未匹配成功的(recent_ppp_data 是我们 pd.merge() 函数的第二个参数,因此被认为是“右侧”);我们找到了其中的 171,334 笔。这似乎完全合理,因为我们可以想象自八月以来可能发放了许多新贷款。

这里令人担忧的数字是 left_only: 67,333 笔贷款在八月的数据中出现,但未与我们最近的数据集中的任何贷款行匹配。这表明我们最近的数据可能不完整,或者存在严重的质量问题。

从我们对样本数据的非常粗略的检查中,我们已经知道 DateApproved 列可能存在一些问题,所以让我们看看如果我们消除匹配日期的需求会发生什么。为此,我们将只需将 示例 6-5 中的片段添加到 示例 6-4 中,但不指定日期需要匹配。让我们看看结果如何。

示例 6-5. ppp_data_join.py(续)
# merge the data again, removing the match on `DateApproved`
merged_data_no_date = pd.merge(august_ppp_data,recent_ppp_data,how='outer',
    left_on=['BusinessName','Lender'],right_on=['BorrowerName',
    'ServicingLenderName'],indicator=True)

# `print()` the values in the "indicator" column,
# which has a default label of `_merge`
print(merged_data_no_date.value_counts('_merge'))

现在我们的输出看起来像这样:

_merge
both          671942
right_only     96656
left_only      22634
dtype: int64

换句话说,如果我们只要求企业名称和贷款人匹配,那么我们在最近的数据中“发现”了另外约 45,000 笔八月数据中的贷款。当然,我们不知道这些新“匹配”中有多少是由于数据输入错误(比如我们 05/03/202005/01/2020 的问题)导致的,有多少代表了多笔贷款。^(12) 我们只知道,从八月数据中我们还找不到 22,634 笔贷款。

那么,如果我们简单地检查一个给定的企业是否同时出现在两个数据集中呢?这似乎是最基本的比较方式:理论上,负责服务 PPP 贷款的银行或贷款人可能会在多个月内变更,或者可能因为(可能的)微小数据输入差异而导致额外的不匹配。请记住:我们目前的目标仅仅是评估我们能够多么信任最近的数据是否包含所有的八月数据。

那么让我们再添加一个最后的、非常宽松的合并,看看会发生什么。添加到 示例 6-6 中的片段,我们将仅仅根据企业名称匹配,看看结果如何。

示例 6-6. ppp_data_join.py(续)
# merge the data again, matching only on `BusinessName`/`BorrowerName`
merged_data_biz_only = pd.merge(august_ppp_data,recent_ppp_data,how='outer',
    left_on=['BusinessName'],right_on=['BorrowerName'],indicator=True)

# `print()` the values in the "indicator" column,
# which has a default label of `_merge`
print(merged_data_biz_only.value_counts('_merge'))

现在我们的输出是这样的:

_merge
both          706349
right_only     77064
left_only       7207
dtype: int64

事情看起来好了一点:在总共的 790,620(706,349 + 77,064 + 7,207)个可能贷款中,“找到”了除了 7,207 之外的所有贷款,这不到 0.1%。这相当不错;我们可能会倾向于把这些缺失数据量称为“四舍五入误差”然后继续前进。但在我们对已经占据了 99.9% 所有 PPP 贷款感到满足之前,让我们停下来考虑一下那些“少量”的缺失数据到底代表了什么。即使我们假设每一个这些“丢失”的贷款都是最小可能金额(请记住我们只看的贷款金额在 15 万美元或以上),这仍意味着我们最近的数据集中至少有 10,810,500,000 美元——超过 10 亿美元!——可能贷款未解决。考虑到我每年如何努力来计算(和支付)我的税款,我当然希望联邦政府不会简单地“丢失” 10 亿美元的纳税人的钱而不担心。但我们能为此做些什么呢?这就是我们开始对数据进行处理的部分,既令人望而生畏又充满活力:是时候与人交流了!

尽管联系主题专家始终是开始数据质量调查的好方法,在这种情况下,我们有更好的东西:关于放贷人和贷款接收者本身的信息。在文件中包含的企业名称和位置之间,我们可能会找到至少一些来自八月数据集中“丢失”的 7,207 笔贷款的联系信息,并尝试了解到底发生了什么。

在您拿起电话开始拨打人们的时候(是的,大多数情况下您应该打电话),请先想清楚您要问什么。虽然想象可能发生一些恶意行为(放贷人在隐藏资金吗?企业在误导自己吗?)很诱人,但有一句老话(而且非常灵活)可能会让你稍作思考:“绝不要归因于恶意,那些可以用无能/愚蠢/疏忽来解释的事情。”^(13) 换句话说,这些贷款在最近的数据中可能没有出现是因为某些数据输入错误,甚至是因为贷款从未到账

事实上,在打电话给全国各地几家汽车维修和理发店之后,这正是我经常听到的故事。我与多位受访者交谈后得出了类似的结论:他们申请了贷款,被告知贷款已获批,然后——资金就从未到账。虽然试图确认每一笔“丢失”的贷款是否如此并不现实,但从距离遥远的多家企业那里听到基本相同的故事,让我相当有信心这些贷款没有出现在最终数据集中,因为资金实际上从未发出。^(14)

到目前为止,我们可以相当肯定我们最近的 PPP 贷款数据实际上是“完整的”符合我们的目的。虽然对我们的数据集进行这样一个几乎十多个数据完整性措施中的其中一个测试可能感觉很多工作,但请记住,在此过程中,我们已经学到了宝贵的信息,这将使我们后续的“测试”工作更加迅速——甚至可以说是微不足道。所以,让我们转向下一个标准。

它是否有良好的注释?

经过确认,我们的数据在时间上是及时的完整的后,我们需要详细了解我们最近 PPP 贷款数据中的数据列实际包含什么信息。^15 与我们的完整性评估一样,我们可以从数据本身开始。通过查看列名和一些包含在该列中的值,我们可以开始对我们需要更多信息的内容有所了解。如果列名看起来描述性并且我们在该列中找到的数据值与我们对列名的解释相符,那么这是一个非常好的起点。

尽管我们有几个选项可以查看我们的列名及其对应的值,让我们继续利用我们之前创建的样本文件。由于屏幕宽度通常会阻止我们轻松打印大量数据列(而我们可以轻松向下滚动查看更多行),我们将首先转置我们的样本数据(即将列转换为行,将行转换为列),以便更轻松地查看列标题。^16 我们还将应用一些额外的数据类型过滤,以便更容易看到缺失的数据。我们对此的第一次尝试可以在示例 6-7 中看到。

示例 6-7. ppp_columns_review.py
# quick script for reviewing all the column names in the PPP data
# to see what we can infer about them from the data itself

# importing the `pandas` library
import pandas as pd

# read the recent data into a pandas DataFrame using its `read_csv()` method
ppp_data_sample = pd.read_csv('recent_sample.csv')

# convert all missing data entries to '<NA>' using the `convertdtypes()` method
converted_data_sample = ppp_data_sample.convert_dtypes() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# transpose the whole sample
transposed_ppp_data_sample = converted_data_sample.transpose()

# print out the results!
print(transposed_ppp_data_sample)

1

为了加快速度,Pandas 的read_csv()方法会将所有缺失的条目转换为NaN(不是一个数字),详见https://pandas.pydata.org/pandas-docs/stable/user_guide/missing_data.html。为了将这些值转换为更通用(和直观的)标签,我们将convertdtypes()方法应用于整个 DataFrame,详见https://pandas.pydata.org/pandas-docs/stable/user_guide/missing_data.html#missing-data-na-conversion

如您在图 6-5 中所见,这使我们可以将所有原始列名称显示为行,并将几个原始行作为数据列。透过这些信息,我们可以开始对我们已知和未知的内容有所了解。

多亏了它们相对描述性的名称,我们可以开始猜测在许多这些列中可能找到的内容。例如,像 DateApprovedProcessingMethodBorrowerNameBorrowerAddressBorrowerCityBorrowerCityBorrowerStateBorrowerZip 这样的列相对直观。在某些情况下,名称是描述性的,但没有给出我们所需的所有信息。例如,虽然 SBAGuarantyPercentage 告诉我们关于列内容及其单位(假设是 SBA 担保的贷款金额百分比)的信息,但 Term 列并没有告诉我们该值应解释为 24 天、周、月还是年。同样,虽然 BusinessAgeDescription 中的值本身是描述性的(例如,Existing or more than 2 years old),LoanStatus 值为 Exemption 4 并没有真正帮助我们理解贷款的具体情况。最后,像 LMIIndicator 这样的列名对专家来说可能很容易解释,但对于我们这些不熟悉贷款行话的人来说可能很难识别。

此时我们真正需要的是一个“数据字典”——有时用这个术语来指代描述(特别是)表格类型数据内容的文档。数据字典非常重要,因为虽然表格类型数据非常方便进行分析,但它本身并没有提供包括我们需要回答的“应该使用什么单位?”或“编码类别意味着什么?”等关于数据元数据——即关于数据的数据——的方法,这些问题在处理复杂数据集时经常遇到。

找到数据字典最可能的地方应该是我们最初获取数据的地方(请记住,在第四章中,我们发现 ghcnd-stations.txt 文件的描述链接在与数据位于同一文件夹的 readme.txt 文件中)。在这种情况下,这意味着回到 SBA 网站,看看我们能找到什么。

ppdw 0605

图 6-5. 最近的样本数据转置

起初,情况看起来相当有希望。在该页面的“所有数据”部分下,我们看到一个链接,承诺提供“关键数据方面”的摘要,如 图 6-6 所示。^(17)

SBA 网站上 PPP 贷款数据的登陆页面

图 6-6. SBA 网站上 PPP 贷款数据的登陆页面

点击链接 带我们到一个页面(截至目前为止),列出了 2020 年夏季的两份 PDF 文档。不幸的是,它们都似乎不包含我们需要的内容 —— 它们大部分都是有关 PPP 贷款如何处理的免责声明类型的文字,尽管它们确实确认了我们的发现,“取消”的贷款不会出现在数据库中,如 图 6-7 所示。

‘回顾工资保护计划 (PPP) 数据时需要记住的信息’ 摘录

图 6-7. 回顾 PPP 数据时需要记住的信息 摘录

现在呢?我们有几个选项。我们可以转向更一般的研究策略,以便了解更多关于 PPP 的信息,并希望自己填补一些空白。例如,阅读足够的针对潜在 PPP 申请者的网站文章将清楚地表明,Term 列的适当单位几乎肯定是周。同样,如果我们对 LMIIndicatorLMI IndicatorLMI Indicator loans 进行足够的网络搜索,最终我们将找到一个 Wikipedia 页面,该页面表明这个术语 可能 是 “Loan Mortgage Insurance Indicator” 的简称 —— 但我们几乎肯定不会确定。

换句话说,现在是再次寻找一些人类帮助的时候了。但是我们可以联系谁呢?已经查看过 PPP 数据的学者可能是一个开始的地方,但如果数据发布比较新,可能会很难找到他们。因此,就像我们在尝试确认所有那些缺失贷款从八月数据集中发生了什么时一样,我们将直接去找信息源:SBA。幸运的是,如果我们回到下载实际数据文件的网站,如 图 6-8 所示,我们会发现上传这些文件的人是 Stephen Morris。^(18)

PPP 数据下载门户

图 6-8. PPP 数据下载门户

在打电话和发送电子邮件后,我得知,在我询问的时候,SBA 还没有为 PPP 贷款数据创建数据字典,尽管莫里斯确实向我推荐了原始 PDF 和 SBA 的 7a 贷款计划的数据字典,而 PPP 贷款结构正是基于这个计划。尽管后者的文件(位于 https://data.sba.gov/dataset/7-a-504-foia)与 PPP 贷款数据列仍有显著差异,但它确实提供了一些关键元素的见解。例如,该文件包括一个名为 LoanStatus 的列描述,其可能的值似乎至少部分与我们迄今在 PPP 贷款数据中找到的内容相似:

LoanStatus        Current status of loan:
               • NOT FUNDED = Undisbursed
               • PIF = Paid In Full
               • CHGOFF = Charged Off
               • CANCLD = Canceled
               • EXEMPT = The status of loans that have been disbursed but
                   have not been canceled, paid in full, or charged off are
                   exempt from disclosure under FOIA Exemption 4

这些数据是否“充分标注”?在某种程度上是的。它的标注程度不如我们在“最后,固定宽度”中使用的 NOAA 数据那么好,但通过一些努力,我们很可能能够建立起对 PPP 贷款数据的自己的数据字典,并且对其相当有信心。

它是否高容量?

由于我们已经完成了“完整性”审查,我们基本上已经回答了我们使用的数据是否一般是高容量的问题。在超过 750,000 行数据的情况下,几乎没有情况是不足以至少进行一些有用形式的分析的。

与此同时,我们也不能确定我们将能够进行哪些类型的分析,因为我们拥有的数据行数并不重要,如果其中大多数是空的话。那么我们该如何检查呢?对于我们只期望找到少量可能值的列,比如LoanStatus,我们可以使用 Pandas 的value_counts()方法来总结其内容。对于具有非常多样化值的列(如BorrowerNameBorrowerAddress),我们将需要专门检查缺失值,然后将其与总行数进行比较,以了解可能缺失的数据量。首先,我们将总结我们预计只包含少数不同值的列,如示例 6-8 所示。我们还将统计更多种类列中的NA条目——在本例中是BorrowerAddress

示例 6-8. ppp_columns_summary.py
# quick script for reviewing all the column names in the PPP data
# to see what we can infer about them from the data itself

# importing the `pandas` library
import pandas as pd

# read the recent data sample into a pandas DataFrame
ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# print the summary of values that appear in the `LoanStatus` column
print(ppp_data.value_counts('LoanStatus'))

# print the total number of entries in the `LoanStatus` column
print(sum(ppp_data.value_counts('LoanStatus')))

# print the summary of values that appear in the `Gender` column
print(ppp_data.value_counts('Gender'))

# print the total number of entries in the `Gender` column
print(sum(ppp_data.value_counts('Gender')))

# print how many rows do not list a value for `BorrowerAddress`
print(ppp_data['BorrowerAddress'].isna().sum())

示例 6-8 的输出,如示例 6-9 所示,开始描绘了我们数据集的约 750,000 行中实际包含了哪些数据。例如,大多数贷款的状态是“第四项豁免”,我们从注释调查中知道这意味着“豁免免于 FOIA 第四项规定下的披露”。^(19) 类似地,我们可以看到超过三分之二的贷款申请者在申请时未指明其性别,还有 17 笔贷款甚至没有列出借款人的地址!

示例 6-9. 最近的数据列摘要
LoanStatus
Exemption 4            549011
Paid in Full           110120
Active Un-Disbursed    107368
dtype: int64
766499

Gender
Unanswered      563074
Male Owned      168969
Female Owned     34456
dtype: int64
766499

17

这些数据是否“高容量”?是的——虽然整体上我们丢失了相当多的数据,但在我们所拥有的行和列中似乎有相对高比例的有用信息。请记住,在某些情况下,仅有几十行数据就足以生成有关某事物的有用见解——只要它们包含真正有意义的信息。这就是为什么仅仅查看数据文件的大小或者它包含的行数来确认它是否真正“高容量”是不够的——我们实际上必须详细检查数据才能确定。

它是否一致?

另一件事,我们从先前的完整性检查中了解到的是,PPP 贷款数据的格式在 2020 年 8 月和之后的发布版本之间绝对一致:2020 年 12 月开始发布的数据比之前发布的数据更加详细,甚至大多数列名在两个文件之间也有所不同。

由于我们现在仅依赖于最近的数据文件,我们需要考虑的一种不同类型的一致性是数据集内部值的一致性。例如,我们可能希望像InitialApprovalAmountCurrentApprovalAmount这样的金额列中使用的单位是相同的。不过,最好还是检查一下,以防万一。在示例 6-10 中,我们将再次进行快速的最小/最大确认,以确保这些数字落在我们预期的范围内。

示例 6-10. ppp_min_max_loan.py
# quick script for finding the minimum and maximum loans currently approved
# in our PPP loan dataset

# importing the `pandas` library
import pandas as pd

# read the recent data into a pandas DataFrame
ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# use the pandas `min()` and `max()` methods to retrieve the
# largest and smallest values, respectively
print(ppp_data['CurrentApprovalAmount'].min())
print(ppp_data['CurrentApprovalAmount'].max())

根据我们数据文件的标题—150k_plus—我们预计我们将在批准的最低贷款金额中找到$150,000。快速搜索“PPP 最大贷款额”将我们导向 SBA 网站上的一份文件,该文件表明对于大多数类型的企业,^(20)最大贷款金额为$10 million。事实上,运行我们的脚本似乎确认了这一最小和最大值在我们的数据中得到了反映:

150000.0
10000000.0

此时,我们还希望检查最常见(也是最隐蔽的)不一致形式之一:拼写差异。每当你处理由人类输入的数据时,都会遇到拼写问题:额外的空格、打字错误和标点差异,至少是这些问题。这是一个问题,因为如果我们想要回答一个看似简单的问题,比如“X 银行的贷款来源于多少?”,我们需要在整个数据中保持该银行名称的拼写一致。但几乎可以肯定的是,起初是不会一致的。

幸运的是,因为这是一个如此常见的数据问题,已经有许多成熟的方法来处理它。在这里,我们将使用一种称为指纹技术的方法开始检查数据集中银行名称拼写的一致性。虽然在寻找拼写不同的重复项时可以按照音标等方式对这些公司名称进行分组,但我们选择指纹技术,因为它遵循一个简单、严格但有效的算法(实际上,只是一组集合),可以尽量减少我们将两个本不应该相同的名称匹配起来的风险。

具体来说,我们将使用的指纹算法执行以下操作:^(21)

  1. 删除前导和尾随空格

  2. 将所有字符更改为它们的小写表示

  3. 删除所有标点符号和控制字符

  4. 将扩展的西方字符标准化为它们的 ASCII 表示(例如“gödel” → “godel”)

  5. 将字符串分割为以空格分隔的标记

  6. 对标记进行排序并删除重复项

  7. 将标记重新连接起来

和往常一样,虽然我们可以自己编写此代码,但我们很幸运地发现 Python 社区中已有人已经完成了这项工作,并创建了pip可安装的库fingerprints

pip install fingerprints

目前,我们的主要关注点是确认是否存在拼写差异;实际上,我们将在第七章中研究如何处理这些差异。因此,我们现在只需计算数据集中所有唯一银行名称的数量,然后查看该列表中有多少个唯一的指纹。如果文件中的所有银行名称确实都是不同的,那么从理论上讲,这两个列表的长度应该相同。另一方面,如果数据集中的某些银行名称仅因为轻微的标点符号和空白差异而“唯一”,那么我们的指纹列表将会比“唯一”银行名称列表。这表明,为了确保例如我们确实能够检索与单个银行相关的所有贷款,我们需要进行一些数据转换。 (示例 6-11)。

示例 6-11. ppp_lender_names.py
# quick script for determining whether there are typos &c. in any of the PPP
# loan data's bank names

# importing the `pandas` library
import pandas as pd

# importing the `fingerprints` library, which will help us generate normalized
# labels for each of the bank names in our dataset
import fingerprints

# read the recent data into a pandas DataFrame
ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# use the pandas DataFrame `unique()` method to create a list of unique
# bank names in our data's `OriginatingLender` column
unique_names = ppp_data['OriginatingLender'].unique()

# confirm how many unique names there are
print(len(unique_names))

# create an empty list to hold the fingerprint of each of the unique names
fingerprint_list = []

# iterate through each name in the list of unique names
for name in unique_names:

    # for each name, generate its fingerprint
    # and append it to the end of the list
    fingerprint_list.append(fingerprints.generate(name))

# use the `set()` function to remove duplicates and sort `fingerprint_list`
fingerprint_set = set(fingerprint_list)

# check the length of `fingerprint_set`
print(len(fingerprint_set))

运行此脚本会产生输出:^(22)

4337
4242

两个列表之间的长度差异确实表明我们的数据集中存在一些拼写差异:原始数据中“唯一”名称的数量为 4,337 个,但这些名称的不同指纹数量仅为 4,242 个。尽管这里的差异“仅”约为 100 项,但这些差异可能会影响数千行数据,因为每家银行平均发放了数百笔贷款(750,000/4337 = 约 173)。因此,我们无法确定我们的原始数据集中有多少行包含给定的“拼写错误”名称(也无法确定这是否是详尽无遗的)。在“校正拼写不一致”一节中,我们将通过使用这些指纹来转换我们的数据,以更好地识别特定的出借人和借款人。

它是多变量吗?

就像数据集如果包含许多行,更有可能是高容量一样,如果数据集有许多列,则更有可能是多变量的。然而,与我们对容量的质量检查类似,确定我们所拥有的列是否使我们的数据真正成为多变量,还需要对它们所包含的数据进行质量检查。

例如,虽然我们的 PPP 贷款数据包含 50 个数据列,其中约有十几列基本上是扩展的地址,因为借款人的位置、发放贷款者和服务贷款者各自被分解为一个独立的列,包括街道地址、城市、州和邮政编码。虽然许多其余的列可能包含唯一的数据,我们需要了解它们包含的内容,以了解我们实际上有多少数据特征或特征可供使用。

例如,我们的数据集中有多少笔贷款报告称申请资金不仅用于工资支出?尽管数据结构(以及贷款计划)允许借款人将贷款用于其他用途(如医疗费用和租金),但这在数据中显示出了多大程度上?

就像我们评估我们的数据真正是多么高的体积一样,我们将查看更多列的内容,以确定它们似乎提供的详细信息是否确实反映在它们包含的数据量中。在这种情况下(例子 6-12),我们将查看每个PROCEED列中多少行不包含值。

例子 6-12. ppp_loan_uses.py
# quick script for determining what borrowers did (or really, did not) state
# they would use PPP loan funds for

# importing the `pandas` library
import pandas as pd

# read the recent data sample into a pandas DataFrame
ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# print how many rows do not list a value for `UTILITIES_PROCEED`
print(ppp_data['UTILITIES_PROCEED'].isna().sum())

# print how many rows do not list a value for `PAYROLL_PROCEED`
print(ppp_data['PAYROLL_PROCEED'].isna().sum())

# print how many rows do not list a value for `MORTGAGE_INTEREST_PROCEED`
print(ppp_data['MORTGAGE_INTEREST_PROCEED'].isna().sum())

# print how many rows do not list a value for `RENT_PROCEED`
print(ppp_data['RENT_PROCEED'].isna().sum())

# print how many rows do not list a value for `REFINANCE_EIDL_PROCEED`
print(ppp_data['REFINANCE_EIDL_PROCEED'].isna().sum())

# print how many rows do not list a value for `HEALTH_CARE_PROCEED`
print(ppp_data['HEALTH_CARE_PROCEED'].isna().sum())

# print how many rows do not list a value for `DEBT_INTEREST_PROCEED`
print(ppp_data['DEBT_INTEREST_PROCEED'].isna().sum())

# create a new DataFrame that contains all rows reporting *only* payroll costs
# that is, where all _other_ costs are listed as "NA"
payroll_only = ppp_data[(ppp_data['UTILITIES_PROCEED'].isna()) & (ppp_data
    ['MORTGAGE_INTEREST_PROCEED'].isna()) & (ppp_data
    ['MORTGAGE_INTEREST_PROCEED'].isna()) & (ppp_data['RENT_PROCEED'].isna()) &
    (ppp_data['REFINANCE_EIDL_PROCEED'].isna()) &  (ppp_data
    ['HEALTH_CARE_PROCEED'].isna()) & (ppp_data['DEBT_INTEREST_PROCEED'].isna())
    ]

# print the length of our "payroll costs only" DataFrame
print(len(payroll_only.index))

正如我们从例子 6-13 的输出中所看到的,绝大多数企业(除了 1,828 家)在申请时表示他们打算将资金用于支付工资支出,不到三分之一的企业报告称他们可能也会用这笔钱支付公用事业费用。另一部分提供了有关用于租金的信息。与此同时,我们最后的测试显示,超过三分之二的所有企业仅列出了工资支出作为其 PPP 资金的预期使用。

例子 6-13. 报告的 PPP 贷款资金使用
570995
1828
719946
666788
743125
708892
734456
538905

这对我们的数据“多元化”有什么意义?即使我们决定不考虑专门用于地址详细信息的额外列,或看似未充分利用的PROCEED列,这个数据集中仍然包含大量信息可供探索。看起来我们很可能能够至少开始得出有关谁获得了 PPP 贷款以及他们如何使用资金的结论。然而,我们不能假设我们的数据集的列或行具有数据内容,直到我们自己检查和确认。

是否原子?

这是另一个实例,我们先前在数据方面的工作让我们能够在数据完整性这个指标上相当快地说“是的!”。虽然我们的数据在八月版本只包含贷款金额范围,但我们知道这个数据集每一行都包含一个贷款,包括它们的确切金额。由于我们有具体的数字而不是摘要或汇总值,我们可以相当有信心地认为我们的数据足够细粒度或“原子性”,以支持后续可能的广泛数据分析。

是否清楚?

尽管这个数据集并特别注释详细,但由于大部分列标签及其含义都相当清晰,我们仍然能够通过许多数据完整性检查。例如,如果我们不确定CD代表什么,看一下一些值(例如SC-05)使得推断这代表“国会选区”相当直接。随着我们在处理公共和政府数据集方面获得更多经验(或在特定学科领域工作),越来越多的代码和行话将更快地变得清晰。

对于那些标签不太清晰的列,与 SBA 的斯蒂芬·莫里斯交换了几封电子邮件是有益的。例如,他确认了Term列的适当单位是月(而不是最初可能的周),以及PROCEED列描述了贷款资金将用于的情况,“根据‘贷款人在借款人申请表上向 SBA 提交的内容’。”

我与莫里斯的通信也说明了,尽可能地向主要信息来源专家求助是进行数据完整性检查的重要步骤。如果你还记得,开始时某些列标题的含义并不清楚,其中之一是LMIIndicator。由于我的样本数据行没有包含此列的值,我开始进行一些网络搜索,结果包括“贷款人抵押保险”(如图 6-9 所示);当时,这看起来像是对列标题的合理解释。

LMI 指标贷款的搜索结果

图 6-9. LMI 指标贷款的搜索结果

唯一的问题? 它是错误的。正如莫里斯在电子邮件中澄清的那样,“LMI 指标告诉我们借款人是否地理上位于低中等收入地区。”

这里的教训是,如果你没有官方的数据词典,你总是要对你试图从列标题中推断出多少东西持有一些谨慎态度;即使看起来清楚的那些也可能不是你所想象的意思。如果有任何疑问,请务必联系一些专家(最好是编制数据的人)来确认你的推断。

它是否具有维度结构?

如果当我们在第三章讨论它时,维度结构化数据的概念似乎有点抽象,希望现在我们面前有一个真实数据集后能更容易理解一些。维度结构化数据包括有关我们可以用来对数据进行分组的有用类别或类别的信息,以及帮助我们进行更细粒度分析的更原子特征。

就我们的 PPP 贷款数据而言,我认为一些有用的“维度”数据列包括RuralUrbanIndicatorHubzoneIndicator、新澄清的LMIIndicatorNAICSCode,甚至在某种程度上SBAOfficeCode。 像RaceEthnicityGenderVeteran这样的列在维度上也可能有用,但我们知道其中许多是“未回答”的,这限制了我们可以从中推断的内容。 另一方面,其他数据列可以帮助我们回答有关到目前为止从 PPP 中受益的企业的位置和类型的有用问题。

更甚者,像NAICSCode这样的列提供了通过了解受益企业属于哪些行业的可能性,从而有助于我们与美国劳工统计局和其他有关美国就业部门的数据集进行比较。 我们将在 “增强数据” 中更深入地探讨这个过程。

到目前为止,我们已经能够对一些数据完整性问题作出肯定的回答,而其他问题则更多是有条件的,这表明在我们进入数据分析阶段之前,我们需要进行额外的转换和评估。 然而,在这之前,我们需要转向数据适配性的关键问题:我们的数据是否展示了我们需要的有效性可靠性代表性,以便对美国小企业如何受 PPP 影响进行有意义的结论。

评估数据适配性

现在,我们已经根据几乎十多个不同的度量评估了我们数据集的完整性,是时候评估它对我们目的的适配程度了;也就是说,这些数据是否真的能够为我们提供我们所询问的问题的答案。 为此,我们将依据我们的三个主要数据适配性标准:有效性、可靠性和代表性。 现在是我们需要考虑我们原始问题并思考的时候了:PPP 是否帮助挽救了美国的小企业?

有效性

请记住,我们对有效性的工作定义是“某物品所测量的程度是否符合其预期”。 即使我们的 PPP 贷款数据是完美的,我们仍然需要以某种方式确定它是否能在某种程度上回答那个问题。 在这一点上,我们知道我们的数据集提供了这个问题至关重要的一个关键部分的答案,因为我们现在相当有信心,它准确地详细说明了哪些企业目前拥有批准的 PPP 贷款。 通过我们对其完整性的调查(特别是围绕完整性和我们对注释信息或元数据的搜索),我们也相当有信心,已经取消的贷款不会出现在数据集中——我们通过 SBA 关于 PPP 计划的公布信息以及直接联系企业已经确认了这一点。

我们还可以使用数据集的元素(具体来说是LoanStatusLoanStatusDate)来感知那些已经被批准贷款的 75 万多家企业中,哪些实际上已经收到了资金。我们可以通过首先使用value_counts()方法对LoanStatus列进行总结来进行检查,就像我们之前展示的那样,见示例 6-14。

示例 6-14. ppp_loan_status.py
# quick script for determining how many loans have been disbursed

# importing the `pandas` library
import pandas as pd

# read the recent data sample into a pandas DataFrame
ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# print a summary of values in the `LoanStatus` column
print(ppp_data['LoanStatus'].value_counts())
print(sum(ppp_data['LoanStatus'].value_counts())) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

1

请注意,由于value_counts()方法包括NA值,我也在汇总条目以确保每一行都已被记录。

此脚本的输出,如示例 6-15 所示,确认了我们数据集中目前有 766,499 笔贷款,其中超过 100,000 笔尚未实际发送给企业,而另外超过 100,000 家企业似乎已经偿还了贷款。

示例 6-15. LoanStatus 摘要
Exemption 4            549011
Paid in Full           110120
Active Un-Disbursed    107368
Name: LoanStatus, dtype: int64
766499

如果我们希望评估收到 PPP 贷款的小企业的命运,那么我们需要确保只关注那些实际上已经收到资金的企业——这意味着我们应该将我们的查询限制在LoanStatus值为“Exemption 4”或“Paid in Full”的企业上。

理论上,当企业申请 PPP 贷款时,它们要求足够的资金来维持其业务的运营,因此我们可能会倾向于假设,如果一个企业得到了 PPP 资金,它应该做得不错。但是,就像可能过于宽松的标准可能已经允许许多企业欺诈地获得 PPP 贷款一样,企业收到PPP 资金并不保证它仍然做得好。这一现实由《华尔街日报》的这篇文章所体现,讲述了一家尽管收到 PPP 贷款仍然破产的公司的故事。由于我们已经知道这家企业破产了,找到其记录在我们的数据集中可以帮助我们了解这些记录可能的情况,如示例 6-16 所示。

示例 6-16. ppp_find_waterford.py
# quick script for finding a business within our dataset by (partial) name

# importing the `pandas` library
import pandas as pd

# read the recent data sample into a pandas DataFrame
ppp_data = pd.read_csv('public_150k_plus_recent.csv')

# create a DataFrame without any missing `BorrowerName` values
ppp_data_named_borrowers = ppp_data[ppp_data['BorrowerName'].notna()] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# because precise matching can be tricky,
# we'll use the pandas `str.contains()` method
bankruptcy_example = ppp_data_named_borrowers[ \
                                ppp_data_named_borrowers['BorrowerName']
                                .str.contains('WATERFORD RECEPTIONS')] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# transposing the result so it's easier to read
print(bankruptcy_example.transpose())

1

Pandas 无法搜索具有NA值的任何列中的字符串,因此我们需要创建一个不包含目标列中任何这些值的 DataFrame,仅供审核目的(显然,我们可能希望调查没有命名借款人的贷款)。

2

虽然str.contains()将成功地匹配字符串的一部分,但它区分大小写的。这意味着借款人姓名全为大写的事实很重要!

从这个脚本的以下输出来看,情况显而易见:贷款以“Exemption 4”的状态出现,并且也许更有趣的是,具有LoanStatusDateNA。但除此之外,并没有迹象表明这家企业,呃,已经不再营业。

LoanNumber                                        7560217107
DateApproved                                      04/14/2020
SBAOfficeCode                                            353
ProcessingMethod                                         PPP
BorrowerName                       WATERFORD RECEPTIONS, LLC
BorrowerAddress                         6715 COMMERCE STREET
BorrowerCity                                     SPRINGFIELD
BorrowerState                                             VA
BorrowerZip                                            22150
LoanStatusDate                                           NaN
LoanStatus                                       Exemption 4
Term                                                      24
SBAGuarantyPercentage                                    100
InitialApprovalAmount                               413345.0
CurrentApprovalAmount                               413345.0
UndisbursedAmount                                        0.0
FranchiseName                                            NaN
ServicingLenderLocationID                             122873
ServicingLenderName                                EagleBank
ServicingLenderAddress                     7815 Woodmont Ave
ServicingLenderCity                                 BETHESDA
ServicingLenderState                                      MD
ServicingLenderZip                                     20814
RuralUrbanIndicator                                        U
HubzoneIndicator                                           N
LMIIndicator                                             NaN
BusinessAgeDescription       New Business or 2 years or less
ProjectCity                                      SPRINGFIELD
ProjectCountyName                                    FAIRFAX
ProjectState                                              VA
ProjectZip                                        22150-0001
CD                                                     VA-08
JobsReported                                            45.0
NAICSCode                                           722320.0
RaceEthnicity                                     Unanswered
UTILITIES_PROCEED                                        NaN
PAYROLL_PROCEED                                     413345.0
MORTGAGE_INTEREST_PROCEED                                NaN
RENT_PROCEED                                             NaN
REFINANCE_EIDL_PROCEED                                   NaN
HEALTH_CARE_PROCEED                                      NaN
DEBT_INTEREST_PROCEED                                    NaN
BusinessType                 Limited  Liability Company(LLC)
OriginatingLenderLocationID                           122873
OriginatingLender                                  EagleBank
OriginatingLenderCity                               BETHESDA
OriginatingLenderState                                    MD
Gender                                            Male Owned
Veteran                                          Non-Veteran
NonProfit                                                NaN

实际上,如果我们快速检查具有LoanStatusDateNA的贷款数量,通过将以下行添加到我们脚本的末尾,我们可以看到它与LoanStatusExemption 4的贷款完全匹配:

print(sum(ppp_data['LoanStatusDate'].isna()))

那么,这个 PPP 贷款数据是否衡量了它应该衡量的内容?我会说是的,但这并不是全部。正如我们从LoanStatus信息的总结中所看到的,出现在这个数据集中的并非所有企业实际上都得到了贷款;它们已经获得批准,并且仍然可能获得资金(我们知道它们的贷款没有被取消)——但其中有 107,368 家尚未拿到资金,我们无法确定它们是否最终会获得。

仅仅从这个数据集中,我们也不能说出哪些企业已经获得了资金。有些可能仍在运营,而其他一些可能已经破产。还有些可能在没有申请破产的情况下清算了。换句话说,虽然 PPP 数据在回答我们问题的某些部分上具有很强的有效性,但要回答整个问题将需要远远不止这一个数据集。

可靠性

谈到可靠性时,我们感兴趣的主要标准是准确性稳定性。换句话说,PPP 数据在多大程度上反映了谁获得了 PPP 贷款,以及随着时间的推移,获得这些贷款的人群是否可能发生变化?

多亏了我们先前的调查,我们现在知道这个数据集的稳定性远非完美。在 8 月份数据集中被批准贷款并出现的数千家企业,在当前数据集中没有出现(我们将在下一节讨论其对代表性的影响),这与 SBA 的文件(23)相符,取消的贷款不包括在内(24)。此外,目前还不清楚在更新时,先前版本的数据是否仍然可用,这使得除非我们开始下载并归档每个版本,否则很难确定发生了什么变化。

即使数据集本身的数字随时间可能也不是特别稳定。例如,我们知道有 538,905 家企业报告称他们只会将他们的 PPP 贷款用于工资支出。但正如 SBA 代表斯蒂芬·莫里斯通过电子邮件解释的那样:“这些数据在某种程度上是推测性的,因为并不要求借款人将资金用于他们在申请书上选择的用途。”换句话说,除非 PPP 贷款接收者的贷款原谅或偿还过程要求详细说明资金的使用情况(并且该信息随后在该数据集中更新),否则我们无法确定我们在各种PROCEED列中看到的数字是否准确或稳定。

代表性

PPP 贷款数据是否代表了实际获得 PPP 贷款的所有人?很可能。在公众的强烈抗议之后,许多大型和/或上市公司退还了早期的 PPP 贷款,SBA 表示他们将密切审查 200 万美元以上的贷款,并且到 7 月初,已经有近 300 亿美元的贷款被退还或取消了。在我们相对详尽的 8 月和 2 月数据集之间的比较之后,我们可以相当有信心地说,我们所拥有的数据集至少代表了迄今为止谁收到了 PPP 贷款。

与此同时,这并没有像我们可能认为的那样告诉我们太多信息。我们已经知道,在这些数据中出现的绝大多数 PPP 贷款接收者没有披露他们的性别、种族、族裔或退伍军人身份,这意味着我们实际上无法知道 PPP 贷款接收者的人口统计信息(如果有的话)是否反映了美国小企业主群体的情况。事实上,正如我们将在第九章中看到的,我们几乎不可能对 PPP 贷款接收者的人口统计信息做出结论,因为提供此类信息的申请者数量非常少。

但是代表性的问题实际上远不止于此,还涉及到(以及超出)在 8 月数据发布和更近期的数据发布之间消失的 7207 笔贷款。这些缺失的贷款反映了申请贷款并获批的企业,但用一名员工的话来说:“这些钱从来没到账。”这意味着虽然我们知道有多少企业获得了PPP 贷款,但我们无法知道有多少企业申请了。因为这些取消的贷款已经从数据中移除,我们现在面临的是我所称的“分母”问题的实例。

分母问题

几乎每个数据驱动探究领域都承认了分母问题,尽管称呼不同。有时被称为基准测试基准问题^(25),分母问题概括了在缺乏足够比较信息以便将数据置于上下文中时的困难。在大多数情况下,这是因为你真正需要的比较数据从未被收集。

在我们探索 PPP 数据的过程中,我们已经遇到了这个问题的一个版本:我们知道哪些企业获得了贷款,但我们不知道谁申请了却被拒绝了,或者为什么被拒绝了(至少在某些情况下,甚至是接受者也不知道)。对于评估 PPP 贷款流程来说,这是一个问题,因为我们不知道是否有合法的申请者被拒绝,即使一些企业获得了多轮贷款。如果我们想知道贷款的分配是否公平——甚至有效——了解谁没有被包括进来和谁包括进来一样重要。

到目前为止,我们遇到的一些分母问题可能可以通过某种类型的补充数据来回答——这就是《华尔街日报》在将 PPP 贷款数据与破产申请进行比较时所做的。在其他情况下,解决方案将是我们自己建立我们自己的存档,如果——似乎在这里是这种情况——数据提供者未提供早期数据与更新版本一起提供。

结论

那么,薪资保护计划是否帮助挽救了小企业呢?也许。一方面,很难想象这么多钱可以花掉而没有一些积极的效果。另一方面,现实世界——以及我们对其的数据——是一个复杂、相互依存的混乱。如果薪资保护计划的贷款接受者调整了其业务模式并找到了新的收入来源,我们会把它归类为“由 PPP 拯救”类别吗?同样地,如果一家未获得贷款的企业失败了,是因为它没有获得贷款,还是它本来就会失败?我们提出越来越多这样的“如果”,就越有可能发生一些事情:

  1. 我们会头痛欲裂,认为真的什么都不可能知道,并寻找一种安心的拖延方式。

  2. 在玩游戏/读互联网/向困惑的朋友和亲戚抱怨我们如何花费了所有时间来处理一个我们不确定其价值的数据集之后,我们会遇到一些能给我们提出一个稍微不同、可能更窄的问题的东西,并兴奋地回到我们的数据集,渴望看看我们是否能比上一个问题更好地回答这个新问题。

这个过程是艰难而曲折的吗?是的。这也让我想起了我们在“How? And for Whom?”中关于建构效度的讨论。最终,这实际上也是新知识是如何形成的。考虑新选项、深思熟虑、测试并(可能)以稍微更多的信息和理解重新开始整个过程的不确定、令人沮丧和易失的工作,正是真正独创见解形成的方式。这是世界上每个算法系统都在拼命尝试模拟或模仿的事情。但如果你愿意付出努力,你可以真正成功。

此时,我觉得我已经对这个数据集有了相当多的了解——足够让我知道,我可能无法用它来回答我的最初问题,但仍然可以想象它可能产生一些有趣的见解。例如,我确信最近的数据准确反映了当前批准的贷款状态,因为我确认了在更近期文件中缺失的贷款(出于某种原因)可能实际上从未实际发放。同时,尽管 SBA 宣布截至 2021 年 1 月初已经宽限超过 1000 亿美元的 PPP 贷款已经被宽限,但似乎在 6 周多之后,LoanStatus列中并没有明确的值表示已宽限的贷款。虽然 SBA 的 Stephen Morris 在 2021 年 3 月初停止回复我的电子邮件,但截至 2021 年 5 月初,数据字典似乎是可用的,^(26)尽管它和更新的数据都没有包含这些信息。

当然,在这里还有很多要学习的:关于谁获得了贷款及他们所在地的信息,他们获得了多少批准的贷款,以及谁在发放这些贷款。虽然数据还远非完美,但我可以保留过去数据集的副本以安抚自己,以便将来如果有重大变化,至少可以手边资源来察觉它。鉴于此,是时候从我们工作的评估阶段转入实际清理和转换数据的任务,我们将在第七章中处理。

^(1)即使他们并不总是意识到这一点。

^(2)详情请参阅https://en.wikipedia.org/wiki/Paycheck_Protection_Program

^(3) “谁得到了半万亿美元的 COVID 贷款?特朗普政府不会说”,https://marketplace.org/shows/make-me-smart-with-kai-and-molly/who-got-half-a-billion-in-covid-loans-the-trump-administration-wont-say

^(4) 你可以在https://github.com/adam-p/markdown-here/wiki/Markdown-Here-Cheatsheet找到一个很好的 Markdown 速查表。

^(5) 这个内容的原始链接是https://home.treasury.gov/policy-issues/cares-act/assistance-for-small-businesses/sba-paycheck-protection-program-loan-level-data,然而现在可以在https://home.treasury.gov/policy-issues/coronavirus/assistance-for-small-businesses/paycheck-protection-program找到。

^(6) 而这第二个链接是https://sba.gov/funding-programs/loans/coronavirus-relief-options/paycheck-protection-program/ppp-data

^(7) 你可以在https://drive.google.com/file/d/1EtUB0nK9aQeWWWGUOiayO9Oe-avsKvXH/view?usp=sharing下载这个文件。

^(8) 2020 年 8 月的数据可以在https://drive.google.com/file/d/11wTOapbAzcfeCQVVB-YJFIpsQVaZxJAm/view?usp=sharing找到;2021 年 2 月的数据可以在https://drive.google.com/file/d/1EtUB0nK9aQeWWWGUOiayO9Oe-avsKvXH/view?usp=sharing找到。

^(9) 例如,2008 年金融危机后,有许多银行明显没有妥善记录,因为他们重新打包和出售或“证券化”住房抵押贷款,导致一些法院拒绝他们对房主的取房权尝试

^(10) 以下链接被包含是为了保存记录,因为它们代表了本章提供的数据集的原始位置。

^(11) 有关南卡罗来纳州国会选区,请参阅维基百科的页面https://en.wikipedia.org/wiki/South_Carolina%27s_congressional_districts#Historical_and_present_district_boundaries

^(12) SBA 开始接受所谓“第二轮”PPP 贷款的申请,日期为2021 年 1 月 31 日

^(13) 尝试归因公理总是问题重重,但我更喜欢将这个表达归因为“汉隆剃刀”,见于“Jargon File”,主要因为该文件解释了许多计算机/编程行话的起源。

^(14) 当然,“为什么”它从未发送是一个有趣的问题。

^(15) 你可能已经注意到,在这章中,我没有按照第三章中列出的顺序详细讨论我们的数据完整性标准。因为 PPP 是基于现有的 7(a)贷款计划进行建模,我做出了(可疑的)假设,即这些数据将被很好地注释。当然,这些也是关于 PPP 的唯一可用数据集,所以我的选择有限(就像在处理现实世界数据时经常发生的情况一样)。

^(16) 我有时发现将数据转置为“把它放到一边”更容易理解。

^(17) 自这章节写作起,此页面已经发生了相当大的变化。值得注意的是,主数据位置现在包括一个“数据字典”——但这仅在数据首次发布几个月后才发布。

^(18) 值得注意的是,在我联系他不久后,此归因被改为SM

^(19) 有关 FOIA 请求和豁免的更多信息,请参阅“FOIA/L Requests”,以及在司法部网站上的豁免全文。

^(20) 具体来说,“薪资保护计划:如何计算第一次 PPP 贷款的最高金额及应提供的业务类型文档”,可以在https://sba.gov/sites/default/files/2021-01/PPP%20--%20How%20to%20Calculate%20Maximum%20Loan%20Amounts%20for%20First%20Draw%20PPP%20Loans%20%281.17.2021%29-508.pdf找到。

^(21) 查看https://github.com/OpenRefine/OpenRefine/wiki/Clustering-In-Depth获取更多关于聚类的信息。

^(22) 当你运行这个脚本时,你可能会看到一个关于安装 pyICU 的警告。然而,安装这个库有点复杂,并且不会改变我们此次练习的结果。不过,如果你打算广泛使用这个指纹识别过程,你可能希望投入额外的时间来设置 pyICU。你可以在这里找到关于这个过程的更多信息:https://pypi.org/project/PyICU

^(23) 可以从https://sba.gov/document/report-paycheck-protection-program-ppp-loan-data-key-aspects下载。

^(24) 再次强调,了解为何以及如何取消这些贷款可能是有益的,但我们在这里找不到这些信息——只有一些关于从哪里开始查找的线索。

^(25) 在涉及美国物业法时,“分母问题”似乎有非常具体的含义,但不用说,这并不是我在这里使用它的方式。

^(26) 你可以在https://data.sba.gov/dataset/ppp-foia/resource/aab8e9f9-36d1-42e1-b3ba-e59c79f1d7f0找到它。

第七章:清理、转换和增强数据

大多数情况下,我们最初找到、收集或获取的数据在某种方式上都不完全符合我们的需求。格式不合适,数据结构错误,或者其单位需要调整。数据本身可能包含错误、不一致或间断。它可能包含我们不理解的引用,或者暗示着尚未实现的额外可能性。无论限制是什么,在我们使用数据作为洞察力的源头的过程中,不可避免地我们将不得不以某种方式对其进行清理、转换和/或增强,以使其发挥最大的作用。

到目前为止,我们推迟了大部分这项工作,因为我们有更紧急的问题需要解决。在第四章中,我们的重点是将数据从一个棘手的文件格式中提取出来,并转换为更易访问的形式;在第六章中,我们的重点是彻底评估我们数据的质量,以便我们可以就是否值得进行增强和分析做出明智的决定。

现在,然而,是时候卷起袖子,开始对我来说是数据整理和质量工作的第二阶段:准备我们拥有的数据,以进行我们想要执行的分析。我们的数据处于我们需要的表格类型格式,并且我们已经确定它的质量足够高,可以产生一些有用的见解,即使它们不完全是我们最初想象的那些。

由于显然不可能识别和解决与清理、转换和/或增强数据相关的每一个可能的问题或技术,因此我的方法是通过处理我们已经遇到过的实际示例来进行工作,在这些任务中至少需要一个或多个。例如,我们将看看我们可能需要使用在第二章和第四章中遇到的数据集来转换日期类型信息的不同方法。我们还将探讨如何清理同时包含结构化数据元数据的数据文件中的“无用杂质”。我们甚至会探索正则表达式,这为我们提供了一种强大的方式,可以选择数据字段的某些部分或匹配特定的术语和模式,无视大小写和/或标点符号。在此过程中,我们将设法涵盖您在清理和转换大多数数据集时可能需要的工具和策略的相当范围。至少,在本章中概述的方法将为您提供一个有用的起点,如果您遇到真正棘手或独特的挑战。

选择 Citi Bike 数据的子集

在 “使用 Citi Bike 数据上路” 中,我们使用了 Citi Bike 系统数据来测试刚刚开始使用的 Python 概念,例如 for...in 循环和 if/else 条件语句。为了方便起见,我们从 2020 年 9 月系统数据文件 中摘录了一个样本数据集。

在许多情况下,我们需要对大型数据集进行分段以进行分析 —— 无论是因为我们没有时间或计算资源来一次性处理所有内容,还是因为我们只对数据集的一个子集感兴趣。如果我们只想选择特定数量的行,我们可以使用 “添加迭代器:range 函数” 中描述的 for...in 循环和 range() 函数。但我们可能也希望根据数据的值摘录数据。例如,我在选择所有 2020 年 9 月 1 日的骑行时就这样做了,但我们可能还想做一些更加微妙的事情,比如分别评估工作日 Citi Bike 骑行和周末及假期的骑行。

让我们从第一个任务开始,仅从较大数据集中摘录 2020 年 9 月 1 日的骑行。从概念上讲,这很简单:我们只想保留数据集中包含在 9 月 1 日开始的骑行的每一行。然而,如果我们简要地回顾一下数据集,就会发现即使这个任务也并不简单。

Citi Bike 骑行数据前几行的放大视图。

图 7-1. Citi Bike 骑行数据的前几行

正如您在 Figure 7-1 中所看到的,starttime 列不仅仅是一个日期,而是一种包括月、日和年以及小时、分钟和秒(精确到四位小数点)的日期/时间格式。例如,这个数据文件中的第一个条目,starttime 的值如下所示:

2020-09-01 00:00:01.0430

显然,如果我们只想分析第一天的骑行,或者仅在早晨“高峰期”通勤时间骑行,或者仅工作日骑行,我们需要一种有效地基于该列中部分信息过滤数据的方法。但我们有哪些选项可以完成这些任务呢?在接下来的几节中,我们将依次研究每个任务 —— 查找特定日期上的骑行、特定时间范围内的骑行以及特定“类型”日期上的骑行。在此过程中,我们将学习 Python 提供的一些工具来解决这些问题,以及何时以及为什么我们可能会选择其中一种方法。

简单分割

解决第一个问题——仅摘录在 2020 年 9 月 1 日开始的骑行数据——如果我们结合一些我们在之前示例中已经使用过的工具,其实是相对容易的。首先要认识到,当我们用 Python 读取基本的 CSV 文件时,大部分数据都会被视为字符串。^(2) 这意味着,即使我们人类清楚地知道2020-09-01 00:00:01.0430应该被解释为日期和时间,Python 只是将其视为一组数字和字符。

通过这种方式查看starttime字段,如何找到所有在 2020 年 9 月 1 日开始的骑行变得更加直接,因为我们数据中包含“日期”信息的部分总是通过单个空格与“时间”信息分隔开来。这意味着,如果我们能找到一种方法只查看出现在那个空格之前的内容,我们就可以轻松地建立一个if/else条件来将其与我们的目标日期字符串——在这种情况下是2020-09-01——进行比较,并利用该比较来保留我们想要的行。

尽管这看起来可能不那么迷人,但内置字符串split()将在这里发挥关键作用。在之前的练习中,当我们需要分解文件名或 URL 时,它已经扮演了一个支持角色;实际上,我们在“动词 ≈ 函数”中就曾经使用过它来说明函数和方法之间的区别!作为一个复习,这个方法允许我们指定一个字符,用来将字符串分割成几部分。该方法的输出是一个列表,其中包含字符串中以指定字符分割的“剩余”部分,按照它们出现的顺序排列,且移除了你用split()分割的字符。因此,将字符串2020-09-01 00:00:01.0430按空格分割将产生列表['2020-09-01', '00:00:01.0430']

为了看到这种方法的简单和有效,让我们修改我们的脚本,从“骑行 Citi Bike 数据”开始。在示例 7-1 中,我已经简化了一些注释,因为这些任务现在更加熟悉,但在脚本顶部概述你的脚本目标仍然是一个好主意。

示例 7-1. citibike_september1_rides.py
# objectives: filter all September 2020 Citi Bike rides, and output a new
#             file containing only the rides from 2020-09-01

# program outline:
# 1\. read in the data file: 202009-citibike-tripdata.csv
# 2\. create a new output file, and write the header row to it.
# 3\. for each row in the file, split the `starttime` value on space:
#       a. if the first item in the resulting list is '2020-09-01', write
#          the row to our output file
# 4\. close the output file

# import the "csv" library
import csv

# open our data file in "read" mode
source_file = open("202009-citibike-tripdata.csv","r")

# open our output file in "write" mode
output_file = open("2020-09-01-citibike-tripdata.csv","w")

# pass our source_file to the DictReader "recipe"
# and store the result in a variable called `citibike_reader`
citibike_reader = csv.DictReader(source_file)

# create a corresponding DictWriter and specify that the
# header should be the same as the `citibike_reader` fieldnames
output_writer = csv.DictWriter(output_file, fieldnames=citibike_reader.fieldnames)

# write the header row to the output file
output_writer.writeheader()

# use a `for...in` loop to go through our `citibike_reader` list of rows
for a_row in citibike_reader:

    # get the value in the 'starttime' column
    start_timestamp = a_row["starttime"]

    # split the value in 'starttime' on the space character
    timelist = start_timestamp.split(" ")

    # the "date" part of the string will be the first item, position 0
    the_date = timelist[0]

    # if `the_date` matches our desired date
    if the_date == "2020-09-01":

        # write that row of data to our output file
        output_writer.writerow(a_row)

# close the output file
output_file.close()

很简单,对吧?当然,你可以很容易地修改这个脚本以捕获不同的日期,甚至是多个日期。例如,你可以修改if语句,使其像这样:

 if the_date == "2020-09-01" or the_date == "2020-09-02":

当然,如果你只想查找两到三个特定日期,这种or语句完全可以胜任,但如果你需要查找更多日期,情况就会变得非常复杂(你可能还记得我们在示例 6-12 中得到的类似笨拙的条件)。为了以我们需要的精度过滤数据,而不生成非常复杂、笨拙和容易出错的代码,我们将更适合使用一个全新的工具包:正则表达式

正则表达式:超级字符串匹配

一个正则表达式(通常缩写为regex),允许您快速有效地在更大的字符串或文本块中搜索字符串模式。在大多数情况下,如果您试图解决匹配或过滤问题,并发现解决方案涉及大量andor语句,这早期迹象表明您真正需要的是一个正则表达式。

正则表达式存在于大多数编程语言中,它们简洁、强大,有时极其棘手。虽然单个正则表达式可以包含甚至非常复杂的搜索模式,设计工作如预期般工作的正则表达式可以非常耗时,通常需要大量试验和错误才能做对。由于我们的目标是让我们的数据整理工作既高效可理解,我们将在这里专注于提供通过其他方式难以实现的独特功能的短正则表达式。虽然有些任务中正则表达式是不可或缺的工具,但它们不是解决每个问题的工具,并且通常与其他技术配合使用效果最佳。

要开始,让我们使用正则表达式来解决过滤掉在典型“早晨通勤”时间内进行的乘车的问题,我们在这里估计为从上午 7 点到 9 点。任何正则表达式的处理都始于我们自己区分我们想要匹配的内容和我们不想匹配的内容。在这里,我们将从一个外部我们确定的时间范围之外的starttime条目开始:

 2020-09-01 00:00:01.0430

现在让我们看一个其中的starttime条目:

 2020-09-01 00:08:17.5150

现在,让我们首先承认,我们可以用之前看到的字符串分割方法来解决这个问题。我们可以从:字符开始分割,第二个实例中,这样做会给我们带来这个:

['2020-09-01 00', '08', '17.5150']

然后我们可以从列表中取出中间项,并使用复合条件——即,连接两个或多个测试的if语句来查看它是否与字符串'07''08''09'匹配。

这种方法当然有效,但感觉有点笨拙。它需要多个步骤和一个三部分条件语句,这很快会变得难以阅读。而正则表达式则能让我们在一个步骤中缩小到这些小时值,同时仍然相当可读。然而,在我们深入编写正则表达式本身之前,让我们快速概述一下 Python 正则表达式的词汇表。

因为正则表达式必须使用字符和字符串来描述字符和字符串的模式,Python 正则表达式“语言”使用一组元字符和特殊序列来使描述你正在搜索的模式变得更简单。在表 7-1 中,我包含了一些最有用的元素,摘自更完整的列表在 W3Schools 上

表格 7-1. 常见的正则表达式构建块

表达式 描述
[] 一组字符
“\” 表示一个特殊序列(也可以用于转义特殊字符)
. 任何字符(除换行符之外)
* 零个或多个发生次数
+ 一个或多个发生次数
{} 正好指定数量的发生次数
| 或者
() 捕获和分组
\d 返回一个匹配,其中字符串包含数字(从 0 到 9 的数字)
\D 返回一个匹配,其中字符串不包含数字
\s 返回一个匹配,其中字符串包含空白字符
\S 返回一个匹配,其中字符串不包含空白字符
\w 返回一个匹配,其中字符串包含任何单词字符(从 a 到 Z 的字符,数字从 0 到 9,以及下划线 _ 字符)
\W 返回一个匹配,其中字符串不包含任何单词字符

与写作一样,正则表达式给了我们多种捕获我们正在寻找的模式的方法。在大多数情况下,我们的目标是定义一个能匹配我们需要查找的内容的模式,同时避免意外地匹配到其他任何内容。对于我们的“高峰时间”问题,我们可以利用这样一个事实:在starttime列中的“小时”数字被冒号(:)包围,并且没有其他字符包围。这意味着我们可以在我们的正则表达式中使用这种“被冒号包围”的模式,并且可以确信不会意外地匹配到字符串的其他部分。为了看看这是否像我们希望的那样工作,让我们设置几个示例正则表达式,针对一些(真实和构造的)样本数据进行测试,如示例 7-2 中所示。

示例 7-2. regex_tests.py
# the goal of this script is to try out how a couple of regular expressions
# fare with some sample test data. ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# import the regular expression library
import re

# using the `re.compile()` method is a helpful way of keeping a reference to
# our various regular expressions
bookend_regex = re.compile("\s0[7-9]:") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# always try to be descriptive with the variable names
one_sided_regex = re.compile("0[7-9]:")

# this example should *fail*
sample1 = "2020-09-01 00:00:01.0430"

# this example should *match*
sample2 = "2020-09-01 09:04:23.7930"

# this example should *fail*
sample3 = "2020-09-01 10:07:02.0510"

# let's see what happens!
print("bookend_regex:")
print(bookend_regex.search(sample1))
print(bookend_regex.search(sample2))
print(bookend_regex.search(sample3))

print("one_sided_regex:")
print(one_sided_regex.search(sample1))
print(one_sided_regex.search(sample2))
print(one_sided_regex.search(sample3))

1

除了这样的示例文件,你还可以使用W3Schools regex demo在线测试你的 Python 正则表达式。

2

即使你在脚本中只使用它们一次,我强烈建议在文件顶部定义你的正则表达式,使用一个恰当命名的变量。这是保持追踪它们功能的最简单、最有效的方法,尤其是如果你要使用多个正则表达式的话!

当你在示例 7-2 中运行脚本时,你的输出应该看起来像这样:

bookend_regex:
None
<re.Match object; span=(10, 14), match=' 09:'>
None
one_sided_regex:
None
<re.Match object; span=(11, 14), match='09:'>
<re.Match object; span=(14, 17), match='07:'>

如你所见,“两端”正则表达式,我们指定了两个冒号,正确地匹配了(并且在所有三种情况下未匹配);另一方面,“单侧”正则表达式却错误地在sample3seconds值上找到了匹配。这正是为什么尽可能精确地定义你要查找的字符串是如此重要的原因。如果你查看之前打印出的Match object,你会看到它包含关于匹配内容(例如,match='07:')及其位置(例如,在字符串的索引位置 14 到 17)的信息。

到目前为止,这似乎相当简单。然而,当我们想要匹配的内容的结构发生变化时,情况可能会变得有些棘手。例如,如果我们希望将我们感兴趣的时间段扩展到上午 7 点到 10 点,我们的bookend_regex原本无法正常工作,因为它指定了冒号后的第一个字符必须是0。我们可以尝试将数字范围的选项添加到我们的数字范围中,例如:

plus_ten = re.compile("\s[01][0789]:")

print("plus_ten")
print(plus_ten.search("2020-09-01 18:09:11.0980"))

这产生了输出:

plus_ten
<re.Match object; span=(10, 14), match=' 18:'>

正如我们从输出中可以看到的那样,问题在于我们的数据使用的是 24 小时制,将匹配到我们不希望的一系列时间。这是因为正则表达式并不像我们想象的那样“看到”数字 —— 它们只看到字符序列。这就是为什么18会返回匹配结果 —— 我们的正则表达式允许以01开头,然后跟随0789。虽然我们显然是以数字07080910为目标编写的,但我们的代码打开了更多的可能性。

在这种情况下的解决方案是使用“或” 管道 字符(|),我们可以用它来组合两个(或其他)完全不同的正则表达式。在这种情况下,它看起来会像示例 7-3 中展示的内容。

示例 7-3. 捕获 7 到 10
seven_to_ten = re.compile("\s0[7-9]:|\s10:")

请尝试使用几个样本数据点自行验证,确认它是否符合我们的需求(而不包含其他内容)。

我不会进一步深入讨论正则表达式;正如我们在“网页抓取:最后的数据来源”中探讨的那样,没有两个正则表达式问题(或解决方案)是相同的。但我希望你能看到这些方法提供了做模式匹配的潜力,这些模式匹配仅依靠复合条件和基本字符串函数可能非常笨拙。

制作日期

将类似日期的数据视为字符串的一个吸引人之处在于,正如我们在处理各种失业数据来源格式时在第四章中所看到的,它们的解释方式在数据源和甚至 Python 库之间可能会有显著差异。然而,在某些情况和任务中,将类似日期的数据转换为实际的datetime类型非常有用。例如,如果我们想要从我们的 Citi Bike 数据中分离出工作日骑行数据,我们可以尝试通过查看日历,识别所有工作日的日期,然后创建一个巨大的字符串比较列表或编写正则表达式来匹配它们。在 2020 年 9 月的数据中,这样一个正则表达式对象可能看起来像是示例 7-4 中展示的内容。

示例 7-4. 2020 年 9 月的工作日正则表达式
september2020_weekday = re.compile("-0[123489]-|-1[0145678]-|-2[1234589]-|-30-")

唉。这当然有效,但几乎不可能阅读,并且仍然基本上是一个巨大的复合条件——即使它因为是正则表达式而被捕获的字符较少。此外,这并不是一个很好扩展的解决方案。如果我们想将分析扩展到其他月份,那意味着得重新查看日历。

幸运的是,精心构建的 Python datetime 对象具有多个内置方法,可以帮助处理这类任务。实际上,有一个简单的 weekday() 方法,根据某个日期所在星期的日期返回从 0 到 6 的数字(0 表示星期一,6 表示星期日)。这意味着,如果我们将 starttime 列的内容转换为日期格式,就像在示例 7-5 中显示的那样,我们可以使用这个方法快速识别对应于任何日期的星期几。这将帮助我们将代码应用于其他数据源——例如不同月份或年份的乘客数据——而无需进行任何操作!

示例 7-5. weekday_rides.py
# objectives: filter all September 2020 Citi Bike rides, and output a new
#             file containing only weekday rides

# program outline:
# 1\. read in the data file: 202009-citibike-tripdata.csv
# 2\. create a new output file, and write the header row to it.
# 3\. for each row in the file, make a date from the `starttime`:
#       a. if it's a weekday, write the row to our output file
# 4\. close the output file

# import the "csv" library
import csv

# import the "datetime" library
from datetime import datetime

# open our data file in "read" mode
source_file = open("202009-citibike-tripdata.csv","r")

# open our output file in "write" mode
output_file = open("202009-citibike-weekday-tripdata.csv","w")

# convert source data to a DictReader; store the result in `citibike_reader`
citibike_reader = csv.DictReader(source_file)

# create a corresponding DictWriter and specify its fieldnames
output_writer = csv.DictWriter(output_file, fieldnames=citibike_reader.fieldnames)

# actually write the header row to the output file
output_writer.writeheader()

# use a `for...in` loop to go through our `citibike_reader` list of rows
for a_row in citibike_reader:

    # convert the value in the 'starttime' column to a date object
    the_date = datetime.strptime(a_row['starttime'], '%Y-%m-%d %H:%M:%S.%f') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    # if `the_date` is a weekday
    if the_date.weekday() <= 4: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)
        # write that row of data to our output file
        output_writer.writerow(a_row)

# close the output file
output_file.close()

1

如示例 6-1 所述,提供源数据的格式将有助于我们的脚本运行更快、更可靠。

2

weekday() 方法将星期一置于位置0,因此查找包括4在内的任何内容将捕获星期一到星期五的值。

根据您的设备,您可能会注意到示例 7-5 中的脚本运行时间较长。例如,在我的(性能不是很强的)设备上,完成所需时间超过 85 秒。而在示例 7-4 中使用正则表达式完成同样的任务只需 45 秒。我还可以更轻松地调整正则表达式,以跳过官方工作日但同时也是假期的日子(如劳动节)。

那么哪种方法更好呢?像往常一样,这要看情况。对于你特定的数据整理/清理/转换过程,什么才是最有效的方法将取决于你的需求和资源。如果你要在十年的 Citi Bike 数据中寻找工作日通勤模式,你可能最好使用weekday()方法,因为你无需修改代码以处理不同的月份或年份。另一方面,如果你没有太多的月份可供使用,并且执行速度(以及绝对精确度)是你的首要关注点,你可能更喜欢正则表达式的方法。你可能也会发现正则表达式让你想抓狂,或者使用多个步骤以获得完美结果让你发疯。正如我们将在第八章中进一步探讨的那样,所有这些都可以成为特定设计选择的合理理由——只要确保选择是你的选择。

数据文件的清理

在第四章中,我们遇到了许多需要“清理”的数据集实例,否则这些数据集将显得笨拙或难以理解。例如,在我们处理老式 .xls 文件的过程中(参见示例 4-6),我们遇到了几个明显的问题。首先,电子表格中包含了表格类型的数据以及描述性的标题信息,尽管原则上很有用,但为了分析其余部分,这些信息显然需要重新安排位置。其次,.xls 格式不支持“真实”的日期,这导致我们从 .xls 转换到 .csv 的初步转换结果中,日期应有的地方出现了一堆无意义的数字。虽然一开始我选择先搁置这些问题,但现在是解决它们的时候了。

在思考第一个问题时,我想强调的是,我们绝对不希望简单地“丢弃”当前存储在 fredgraph.xls 文件顶部的信息。正如我们在第六章的工作中所明显的那样,元数据是一种宝贵的资源,我们永远不希望从主要来源中丢弃元数据。相反,在这种情况下,我更倾向于将一个文件拆分为两个文件。我们将剥离出元数据,并将其存储在一个独立但同名的文本文件中,同时解析并将表格类型的数据保存为便于分析的 .csv 格式。

在电子表格程序中查看我们的源.xls文件时,可以很容易地直观地看到元数据何时结束并且表格类型的数据从何处开始。真正的问题是:我们在脚本中如何检测这种转换?正如数据清洗经常出现的情况一样,最有效的解决方案并不总是优雅的。元数据在其包含列标题的行中结束,而表格类型的数据开始。如果我们在处理文件时查看每行的第一个值,我们可以在遇到第一个列标题时停止向元数据文件写入并开始向.csv写入。由于值observation_date是此数据集的第一个列标题,所以我们会在找到它出现在当前行开头时进行这一转换。

提示

在开始之前,仔细检查您的源文件,查看元数据在其中的位置。特别是在数据包含估计值或其他限定词的情况下,您可能会发现元数据既在表格类型数据之前,也在其之后

要了解如何从我们的单个源文件创建这两个特定用途的文件,请查看示例 7-6 中的脚本(如果您需要复习本示例中某些代码选择,请参考示例 4-6)。

示例 7-6. xls_meta_parsing.py
# converting data in an .xls file with Python to csv + metadata file
# using the "xrld" library. First, pip install the xlrd library:
# https://pypi.org/project/xlrd/2.0.1/

# import the "xlrd" library
import xlrd

# import the `csv` library, to create our output file
import csv

# pass our filename as an ingredient to the `xlrd` library's
# `open_workbook()` "recipe"
# store the result in a variable called `source_workbook`
source_workbook = xlrd.open_workbook("fredgraph.xls")

# open and name a simple metadata text file
source_workbook_metadata = open("fredgraph_metadata.txt","w") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# an `.xls` workbook can have multiple sheets
for sheet_name in source_workbook.sheet_names():

    # create a variable that points to the current worksheet by
    # passing the current value of `sheet_name` to the `sheet_by_name` recipe
    current_sheet = source_workbook.sheet_by_name(sheet_name)

    # create "xls_"+sheet_name+".csv" as an output file for the current sheet
    output_file = open("xls_"+sheet_name+".csv","w")

    # use the `csv` library's "writer" recipe to easily write rows of data
    # to `output_file`, instead of reading data *from* it
    output_writer = csv.writer(output_file)

    # create a Boolean variable to detect if we've hit our table-type data yet
    is_table_data = False ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # now, we need to loop through every row in our sheet
    for row_num, row in enumerate(current_sheet.get_rows()):

        # pulling out the value in the first column of the current row
        first_entry = current_sheet.row_values(row_num)[0]

        # if we've hit the header row of our data table
        if first_entry == 'observation_date':

            # it's time to switch our "flag" value to "True"
            is_table_data = True

        # if `is_table_data` is True
        if is_table_data:

            # write this row to the data output file
            output_writer.writerow(current_sheet.row_values(row_num))

        # otherwise, this row must be metadata
        else:

            # since we'd like our metadata file to be nicely formatted, we
            # need to loop through the individual cells of each metadata row
            for item in current_sheet.row(row_num):

                    # write the value of the cell
                    source_workbook_metadata.write(item.value)

                    # separate it from the next cell with a tab
                    source_workbook_metadata.write('\t')

            # at the end of each line of metadata, add a newline
            source_workbook_metadata.write('\n')

    # just for good measure, let's close our output files
    output_file.close()
    source_workbook_metadata.close()

1

虽然我们在这里只创建一个元数据文件,但如果需要,我们可以轻松将这一过程的这部分移至for循环内,并为每个工作表创建一个唯一的元数据文件。

2

这种布尔(True/False)变量通常被描述为标志变量。其思想是我们在循环之外设置其值,然后在发生特定事件时“翻转”其值——这样我们就不必两次循环遍历所有数据。在这里,我们将使用它来检查何时应该开始向我们的“数据”文件而不是我们的“元数据”文件写入。

在我们继续处理这个(仍然难以理解的)文件中的日期之前,我想强调在 示例 7-6 中引入的一种新技术:所谓的 标志变量 的使用。这个术语通常指的是用于跟踪是否发生了某个事件或满足了某个条件的布尔(True/False)变量,特别是在循环内部。例如,在 示例 7-6 中,我们使用 is_table_data 变量来跟踪是否已经遇到标记我们表格数据开始的数据行。由于我们 for...in 循环中的给定数据行在下一个数据行读取后基本上被“遗忘”,因此我们需要在循环之前创建这个变量。这样可以使 is_table_data 变量在我们循环的 范围 之外保持可用——这是我们将在 第八章 中更详细地看到的概念。

解密 Excel 日期

我们可以避免那些 Excel 日期不再的问题。尽管希望你不会经常遇到这种情况,但我在这里包含它是为了完整性,并且因为它展示了代码通常会如何演变——通常在我们添加甚至看似小的功能时变得更加复杂和难以阅读。例如,在 示例 7-7 中,我们需要检查变量是否包含数字,信不信由你,我们需要一个名为 numbers 的库来做到这一点。尽管这部分基本上很简单,但你很快会在 示例 7-7 中看到,如何需要转换这些日期值需要调整我们写入输出文件的表格类型数据的方法。

示例 7-7. xls_meta_and_date_parsing.py
# converting data in an .xls file with Python to csv + metadata file, with
# functional date values using the "xrld" library.
# first, pip install the xlrd library:
# https://pypi.org/project/xlrd/2.0.1/

# then, import the `xlrd` library
import xlrd

# import the csv library
import csv

# needed to test if a given value is *some* type of number
from numbers import Number

# for parsing/formatting our newly interpreted Excel dates
from datetime import datetime

# pass our filename as an ingredient to the `xlrd` library's
# `open_workbook()` "recipe"
# store the result in a variable called `source_workbook`
source_workbook = xlrd.open_workbook("fredgraph.xls")

# open and name a simple metadata text file
source_workbook_metadata = open("fredgraph_metadata.txt","w")

# an `.xls` workbook can have multiple sheets
for sheet_name in source_workbook.sheet_names():

    # create a variable that points to the current worksheet by
    # passing the current value of `sheet_name` to the `sheet_by_name` recipe
    current_sheet = source_workbook.sheet_by_name(sheet_name)

    # create "xls_"+sheet_name+".csv" as an output file for the current sheet
    output_file = open("xls_"+sheet_name+"_dates.csv","w")

    # use the `csv` library's "writer" recipe to easily write rows of data
    # to `output_file`, instead of reading data *from* it
    output_writer = csv.writer(output_file)

    # create a Boolean variable to detect if we've hit our table-type data yet
    is_table_data = False

    # now, we need to loop through every row in our sheet
    for row_num, row in enumerate(current_sheet.get_rows()):

        # pulling out the value in the first column of the current row
        first_entry = current_sheet.row_values(row_num)[0]

        # if we've hit the header row of our data table
        if first_entry == 'observation_date':

            # it's time to switch our "flag" value to "True"
            is_table_data = True

        # if `is_table_data` is True
        if is_table_data:

            # extract the table-type data values into separate variables
            the_date_num = current_sheet.row_values(row_num)[0]
            U6_value = current_sheet.row_values(row_num)[1]

            # create a new row object with each of the values
            new_row = [the_date_num, U6_value]

            # if the `the_date_num` is a number, then the current row is *not*
            # the header row. We need to transform the date.
            if isinstance(the_date_num, Number):

                # use the xlrd library's `xldate_as_datetime()` to generate
                # a Python datetime object
                the_date_num = xlrd.xldate.xldate_as_datetime(
                    the_date_num, source_workbook.datemode) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

                # overwrite the first value in the new row with
                # the reformatted date
                new_row[0] = the_date_num.strftime('%m/%d/%Y') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

            # write this new row to the data output file
            output_writer.writerow(new_row)

        # otherwise, this row must be metadata
        else:

            # since we'd like our metadata file to be nicely formatted, we
            # need to loop through the individual cells of each metadata row
            for item in current_sheet.row(row_num):

                    # write the value of the cell
                    source_workbook_metadata.write(item.value)

                    # separate it from the next cell with a tab
                    source_workbook_metadata.write('\t')

            # at the end of each line of metadata, add a newline
            source_workbook_metadata.write('\n')

    # just for good measure, let's close our output files
    output_file.close()
    source_workbook_metadata.close()

1

使用 xlrd 库的 xldate_as_datetime() 方法转换这些 .xls “日期”需要同时使用数字值 工作簿的 datemode,以便正确生成 Python datetime 对象。^(4)

2

在这里,我决定使用适当的 strftime() 格式将日期写入我的表格类型数据文件,格式为 MM/DD/YYYY,但如果你喜欢,也可以使用其他格式。

虽然xlrd库使将我们奇怪的 Excel 日期转换为可理解的内容的过程相对简单,但我认为示例 7-7 中的代码展示了如何快速增加复杂性——特别是通过额外的、嵌套的if语句——从而使最初非常简单的程序变得复杂。这就是为什么我们将花费第八章来探讨有效和高效地简化我们的代码的策略和技术的原因之一:我们希望确保它不仅能够完成我们需要的所有工作,而且还足够可读和可重用,以经受时间的考验。

从固定宽度数据生成真正的 CSV 文件

另一个我们在将固定宽度数据转换为.csv时也只取得了中等成功的实例是在示例 4-7,在那里,我们成功地将源数据转换为逗号分隔文件,但结果实际上相当不尽如人意:它保留了许多原始文件的格式化遗留问题,这可能会阻碍我们未来的数据分析工作。

幸运的是,我们遇到的“前导”和/或“尾随”空白问题非常常见,因为通常生成此类数据的数据技术已经存在了很长时间。因此,解决这个问题非常简单:解决方案存在于内置的 Python strip()函数中,正如示例 7-8 所示。

示例 7-8. fixed_width_strip_parsing.py
# an example of reading data from a fixed-width file with Python.
# the source file for this example comes from the NOAA and can be accessed here:
# https://www1.ncdc.noaa.gov/pub/data/ghcn/daily/ghcnd-stations.txt
# the metadata for the file can be found here:
# https://www1.ncdc.noaa.gov/pub/data/ghcn/daily/readme.txt

# import the `csv` library, to create our output file
import csv

filename = "ghcnd-stations"

# reading from a basic text file doesn't require any special libraries
# so we'll just open the file in read format ("r") as usual
source_file = open(filename+".txt", "r")

# the built-in "readlines()" method does just what you'd think:
# it reads in a text file and converts it to a list of lines
stations_list = source_file.readlines()

# create an output file for our transformed data
output_file = open(filename+".csv","w")

# use the `csv` library's "writer" recipe to easily write rows of data
# to `output_file`, instead of reading data *from* it
output_writer = csv.writer(output_file)

# create the header list
headers = ["ID","LATITUDE","LONGITUDE","ELEVATION","STATE","NAME","GSN_FLAG",
           "HCNCRN_FLAG","WMO_ID"]

# write our headers to the output file
output_writer.writerow(headers)

# loop through each line of our file (multiple "sheets" are not possible)
for line in stations_list:

    # create an empty list, to which we'll append each set of characters that
    # makes up a given "column" of data
    new_row = []

    # ID: positions 1-11
    new_row.append((line[0:11]).strip()) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    # LATITUDE: positions 13-20
    new_row.append((line[12:20]).strip())

    # LONGITUDE: positions 22-30
    new_row.append((line[21:30]).strip())

    # ELEVATION: positions 32-37
    new_row.append((line[31:37]).strip())

    # STATE: positions 39-40
    new_row.append((line[38:40]).strip())

    # NAME: positions 42-71
    new_row.append((line[41:71]).strip())

    # GSN_FLAG: positions 73-75
    new_row.append((line[72:75]).strip())

    # HCNCRN_FLAG: positions 77-79
    new_row.append((line[76:79]).strip())

    # WMO_ID: positions 81-85
    new_row.append((line[80:85]).strip())

    # now all that's left is to use the
    # `writerow` function to write new_row to our output file
    output_writer.writerow(new_row)

# just for good measure, let's close the `.csv` file we just created
output_file.close()

1

如果您将此代码与示例 4-7 中的代码进行比较,您会看到除了我们对每个字符串应用了strip()方法之外,它们是完全相同的。

再次看到,修改我们原始代码以解决格式化或“清理”问题并不一定很难,但结果得到的脚本也不完全优雅。用strip()方法从输出中去除空白绝对是直接的,但在此过程中我们不得不添加大量括号——这让我们的代码比我们期望的要不那么可读。

这说明了创建良好、高质量 Python 代码与更典型的写作过程如何相似。如果我们将第四章中的初步工作视为我们最终程序的“大纲”,在这里我们解决了将数据格式至少转换为我们所需的表格类型结构的高级问题,这为我们留下了空间,以后可以回来修订该工作,填充细节,使其在处理我们正在处理的特定数据集时更加细致。

同样地,在 第八章 中,我们将进一步推进这个修订过程,优化这些程序——这些程序已经做到我们需要的一切——以便它们更加简明和易于理解,就像我们对待任何一篇写作一样。这种迭代式的编程方法不仅意味着我们最终得到了更好、更有用的代码;它还帮助我们将复杂的编程问题分解成一系列较少令人畏惧的步骤,我们可以逐步解决。同样重要的是,无论我们处于过程的哪个阶段,我们都有一个可以依赖的功能程序。这种逐步的方法在我们接下来要处理的更复杂的数据清洗任务中特别有用:解决意外的拼写差异。

纠正拼写不一致

在 第六章 中,我们使用了“指纹识别”过程来帮助解决我们的支票保护计划(PPP)数据中可能存在的银行信息拼写不一致的可能性——这是任何依赖人工数据输入的数据集中常见的问题。当然,在我们编写的 示例 6-11 中,只是通过计算产生不同指纹的条目数量来估计OriginatingLender列中真正唯一条目的数量。我们发现我们的数据集包含了 4,337 个唯一的银行名称,但只有 4,242 个唯一的指纹——这表明多达 95 个银行名称实际上 相同的,但包含拼写错误,因为它们生成了相同的指纹。

因为这 95 个潜在的拼写错误可能会影响成千上万行的数据,我们需要一种方法来转换我们的数据集,以便我们可以放心地按贷方机构聚合它。与此同时,我们也不希望 过度纠正,将本不相符的条目分组在一起。

这里是一个实例,转换我们的数据非常宝贵:我们不希望冒失丢失任何原始数据(保留它对验证和抽查至关重要),但我们 需要转换它以支持我们未来的分析工作。由于我们的数据集很大,分组和过滤以满足我们的需求可能会耗费大量时间,因此我们希望通过实际 添加 新列到数据集中来保留该工作的结果。这样一来,我们既能保留我们的原始数据 能在单个文件中保留转换工作的好处。

幸运的是,我们已经有一些常用库可以使这个过程相当简单。由于我们已经知道如何使用指纹过程聚合名称(来自 示例 6-11),更棘手的部分可能是确定具有相同指纹的银行是否应当被视为不同的组织。回顾 示例 6-7 的输出(方便起见在 示例 7-9 中再现),我们看到并不是很多字段包含“originating”贷款人信息,因此,我们最有可能的选择是通过比较包含在 OriginatingLenderLocationID 中的值来决定两个共享所有相同单词的起始银行是否应当被视为不同的组织,例如,“First Bank Texas” 和 “Texas First Bank”。

示例 7-9。最近的样本数据转置
LoanNumber                                          9547507704
DateApproved                                        05/01/2020
SBAOfficeCode                                              464
ProcessingMethod                                           PPP
BorrowerName                             SUMTER COATINGS, INC.
BorrowerAddress                          2410 Highway 15 South
BorrowerCity                                            Sumter
BorrowerState                                             <NA>
BorrowerZip                                         29150-9662
LoanStatusDate                                      12/18/2020
LoanStatus                                        Paid in Full
Term                                                        24
SBAGuarantyPercentage                                      100
InitialApprovalAmount                                769358.78
CurrentApprovalAmount                                769358.78
UndisbursedAmount                                            0
FranchiseName                                             <NA>
ServicingLenderLocationID                                19248
ServicingLenderName                               Synovus Bank
ServicingLenderAddress                           1148 Broadway
ServicingLenderCity                                   COLUMBUS
ServicingLenderState                                        GA
ServicingLenderZip                                  31901-2429
RuralUrbanIndicator                                          U
HubzoneIndicator                                             N
LMIIndicator                                              <NA>
BusinessAgeDescription       Existing or more than 2 years old
ProjectCity                                             Sumter
ProjectCountyName                                       SUMTER
ProjectState                                                SC
ProjectZip                                          29150-9662
CD                                                       SC-05
JobsReported                                                62
NAICSCode                                               325510
RaceEthnicity                                       Unanswered
UTILITIES_PROCEED                                         <NA>
PAYROLL_PROCEED                                      769358.78
MORTGAGE_INTEREST_PROCEED                                 <NA>
RENT_PROCEED                                              <NA>
REFINANCE_EIDL_PROCEED                                    <NA>
HEALTH_CARE_PROCEED                                       <NA>
DEBT_INTEREST_PROCEED                                     <NA>
BusinessType                                       Corporation
OriginatingLenderLocationID                              19248
OriginatingLender                                 Synovus Bank
OriginatingLenderCity                                 COLUMBUS
OriginatingLenderState                                      GA
Gender                                              Unanswered
Veteran                                             Unanswered
NonProfit                                                 <NA>

在我们继续之前,当然,我们要确保理解 OriginatingLenderLocationID 中的数据实际上表示什么。幸运的是,当我们搜索“originating lender location id”时,第一个结果是来自 SBA 网站的另一份文档。通过这个 PDF 搜索术语“location”,我们来到了 图 7-2 所示的页面,这使我们放心,“Location ID” 的值输入不应该因为同一银行的不同分支而改变,而是表示一个给定银行的分支。

贷款人位置 ID 的信息

图 7-2。关于贷款人位置 ID 的信息

借助这些额外的信息,我们可以创建一个包含新列 OriginatingLenderFingerprint 的 PPP 贷款数据版本,其中包含 OriginatingLender 的指纹和 OriginatingLenderLocationID 的组合,如 示例 7-10 所示。稍后,我们可以利用这个值快速地按原始贷款人聚合我们的数据,同时(合理地)确信我们既不因拼写错误而未能匹配条目,也不将本应为两家独立银行的机构视为一家。

示例 7-10。ppp_add_fingerprints.py
# quick script for adding a "fingerprint" column to our loan data, which will
# help us confirm/correct for any typos or inconsistencies in, e.g., bank names

# import the csv library
import csv

# importing the `fingerprints` library
import fingerprints

# read the recent data sample into a variable
ppp_data = open('public_150k_plus_recent.csv','r')

# the DictReader function makes our source data more usable
ppp_data_reader = csv.DictReader(ppp_data)

# create an output file to write our modified dataset to
augmented_ppp_data = open('public_150k_plus_fingerprints.csv','w')

# create a "writer" so that we can output whole rows at once
augmented_data_writer = csv.writer(augmented_ppp_data)

# because we're adding a column, we need to create a new header row as well
header_row = []

# for every column header
for item in ppp_data_reader.fieldnames: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    # append the existing column header
    header_row.append(item)

    # if we're at 'OriginatingLender'
    if item == 'OriginatingLender':

        # it's time to add a new column
        header_row.append('OriginatingLenderFingerprint')

# now we can write our expanded header row to the output file
augmented_data_writer.writerow(header_row)

# iterate through every row in our data
for row in ppp_data_reader:

    # create an empty list to hold our new data row
    new_row = [] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # for each column of data in the *original* dataset
    for column_name in ppp_data_reader.fieldnames:

        # first, append this row's value for that column
        new_row.append(row[column_name])

        # when we get to the 'OriginatingLender' column, it's time
        # to add our new "fingerprint" value
        if column_name == 'OriginatingLender':

            # our fingerprint will consist of the generated fingerprint PLUS
            # the OriginatingLenderLocationID
            the_fingerprint = fingerprints.generate(row[column_name]) + \
                              " " + row['OriginatingLenderLocationID']

            # append the compound fingerprint value to our row
            new_row.append(the_fingerprint)

    # once the whole row is complete, write it to our output file
    augmented_data_writer.writerow(new_row)

# close both files
augmented_ppp_data.close()
ppp_data.close()

1

虽然这看起来可能有些多余,但这第一个循环实际上只存在于创建我们的新标题行。与往常一样,我们希望尽可能地避免引入拼写错误,因此在这种情况下,整个额外的循环是值得的(比手动打出这个列表要多了)。

2

由于我们正在添加一列数据,所以我们需要像处理标题行一样,逐项构建新的数据行。

结果文件的结构与原始文件相同,只是在OriginatingLenderOriginatingLenderCity之间添加了一个新的OriginatingLenderFingerprint列,如你可以在 Example 7-11 中看到的那样。

Example 7-11. 带指纹的 PPP 数据
LoanNumber                                           9547507704
DateApproved                                         05/01/2020
SBAOfficeCode                                               464
ProcessingMethod                                            PPP
BorrowerName                              SUMTER COATINGS, INC.
BorrowerAddress                           2410 Highway 15 South
BorrowerCity                                             Sumter
BorrowerState                                               NaN
BorrowerZip                                          29150-9662
LoanStatusDate                                       12/18/2020
LoanStatus                                         Paid in Full
Term                                                         24
SBAGuarantyPercentage                                       100
InitialApprovalAmount                                 769358.78
CurrentApprovalAmount                                 769358.78
UndisbursedAmount                                           0.0
FranchiseName                                               NaN
ServicingLenderLocationID                                 19248
ServicingLenderName                                Synovus Bank
ServicingLenderAddress                            1148 Broadway
ServicingLenderCity                                    COLUMBUS
ServicingLenderState                                         GA
ServicingLenderZip                                   31901-2429
RuralUrbanIndicator                                           U
HubzoneIndicator                                              N
LMIIndicator                                                NaN
BusinessAgeDescription        Existing or more than 2 years old
ProjectCity                                              Sumter
ProjectCountyName                                        SUMTER
ProjectState                                                 SC
ProjectZip                                           29150-9662
CD                                                        SC-05
JobsReported                                               62.0
NAICSCode                                              325510.0
RaceEthnicity                                        Unanswered
UTILITIES_PROCEED                                           NaN
PAYROLL_PROCEED                                       769358.78
MORTGAGE_INTEREST_PROCEED                                   NaN
RENT_PROCEED                                                NaN
REFINANCE_EIDL_PROCEED                                      NaN
HEALTH_CARE_PROCEED                                         NaN
DEBT_INTEREST_PROCEED                                       NaN
BusinessType                                        Corporation
OriginatingLenderLocationID                               19248
OriginatingLender                                  Synovus Bank
OriginatingLenderFingerprint                 bank synovus 19248
OriginatingLenderCity                                  COLUMBUS
OriginatingLenderState                                       GA
Gender                                               Unanswered
Veteran                                              Unanswered
NonProfit                                                   NaN

尽管这种转换将帮助我们通过特定的“发起方”借方轻松聚合数据,但我们也可以迅速地用“服务方”借方进行复制。我们甚至可以编写一个脚本,比较这两个结果指纹的值,创建一个“标志”列,指示特定贷款的服务方和发起方银行是否相同。

通向“简单”解决方案的曲折路径

我希望你觉得 Example 7-10 的练习很简单,但我想告诉你,前面的脚本并不是我尝试的第一个解决方案——甚至不是第二或第三个。事实上,事实上,在我最终意识到我的最终方法是在确保来自同一家银行的贷款被分组在一起的同时,不会意外地混淆两个不同机构之间,我可能花了大约十几个小时,思考,尝试,处理失败。

我将描述我实际通过这个过程工作的方式,因为——希望变得清楚——数据整理(以及编程一般来说)不仅仅是编码,更多的是推理和问题解决。这意味着要仔细思考面前的问题,尝试不同的解决方案,而且,也许最重要的是,即使感觉像是“扔掉”了一大堆工作,也要愿意改变方向,这些对于数据整理比能够记住两行 Python 代码更加重要^(5)。因此,为了说明这些问题解决努力的一个方面,我将(相对而言)简要地在这里为你概述我尝试的不同方法,然后确定了在“纠正拼写不一致性”中的解决方案。

起初,我从 Example 6-11 最小地调整脚本开始,创建了一个只包含那些指纹的新列,并写了一个新的 CSV,添加了这个新列。但我意识到,一些名字相似的银行很可能会共享相同的“指纹”,所以我编写了一个脚本,做了以下几件事:

  1. 创建了一个唯一指纹的列表。

  2. 对于每个唯一的指纹,创建了一个新的列表(实际上是一个pandas DataFrame),包含所有唯一的OriginatingLenderLocationID值。

  3. 如果有多个不同的OriginatingLenderLocationID值,我就更新“指纹”列以整合OriginatingLenderLocationID,就像我们最终为示例 7-10 中的所有条目所做的那样。

然而,即使创建个脚本,比这份编号概要看起来更复杂得多。第一步当然很容易 —— 我们几乎已经做过了。但当我开始在pandas中使用新文件时,我的小笔记本电脑记忆不足,所以我把工作移到了 Google Colab。这给了我更多的内存来工作(某种程度上),但现在每次我离开几分钟以上,我都必须重新验证并从我的 Google Drive 文件中重新加载数据 —— 每次都要额外花几分钟。此外,虽然我相当有信心已经正确地更新了我的 DataFrame 中的值,但尝试通过搜索我确信应该存在的新指纹来检查我的工作并不可靠:有时我会得到匹配,有时我得到一个空的 DataFrame!再加上每次运行步骤 3 大约需要 3 分钟或更多的时间,你可以想象我花了多少小时(以及多么沮丧!)才确信我的代码实际上按预期工作。

当然,一旦我成功编码(并检查)了这个多步解决方案,我意识到结果与我最初开始时并没有太大的不同。事实上,有点令人满意,因为现在我的新OriginatingLenderFingerprint列的格式不一致:有些附加了OriginatingLenderLocationID,有些则没有。但由于指纹的实际并不重要 —— 只需能够准确地用于汇总和消除歧义的意图 —— 我为什么要费事仅将位置 ID 添加到那些有多个条目的指纹?它们全部都可以附加位置 ID 吗?

当然,直到一点,我才费事查看了图 7-2 中显示的文档,确认添加位置 ID 不会破坏应该相同的指纹。^(6) 这就是我全面回到原点的方式:与其分配可能重叠的指纹,然后试图通过一种笨拙而耗时的搜索过程“清理”问题,还不如从一开始就将OriginatingLenderLocationID作为新的“指纹”列的一部分。

在花费数小时解决如何“修复”原始指纹——并在此过程中,应对我的设备限制,Google Colab 的变数,以及修改脚本后等待几分钟才能运行的乏味过程中——我不会假装没有感到失望,意识到最佳解决方案实际上只涉及对原始脚本进行小调整(虽然不是我最初开始的那个)。

但是,经过多年的数据整理,如果有一件事我学到的,那就是学会何时放手并重新开始(或回到起点)是你可以培养的最重要的技能之一。有时你必须放弃一个数据集,即使你已经花费了大量时间研究、评估和清理它。同样,有时你必须放弃一个编程方法,即使你已经花了几个小时阅读文档和尝试新方法,只是为了得到你想要的结果。因为最终的目标不是使用特定的数据集,或者使用特定的库或编码方法。而是使用数据来理解世界的某些方面。如果你能保持这个焦点,那么在需要时放手将会更容易。

你可能会发现,在你开始亲身体验之后,接受这个过程变得更容易——无论它涉及放弃一个数据集还是一个你已经花了几个小时去处理的脚本解决方案。例如,在我专注于“修复”原始的纯文本指纹之前,我真的不知道如何在pandas DataFrame 中更新数值;现在我知道了(我真的知道)。我现在也对 Google Colab 的优势和不一致性有了更多了解,并且想起了与处理多样化数据集相关的一些关键“坑”(在下一节中会详述)。

对于可能无法用来回答特定问题的数据集,也是一样的道理:只因为它们不适合当前项目,并不意味着它们对另一个项目也不适用。但是,无论你是否再次查看它们,与这些数据集一起工作将教会你许多东西:关于数据主题,关于某些数据类型的陷阱和可能性,关于该主题的专家等等。换句话说,放弃一个数据集或编码方法从来不是“浪费”:你获得的经验只会让你的下一次尝试变得更好,如果你愿意的话。

会让你陷入困境的要点!

需要记录工作的一个原因是,您写文档的对象很多时候其实只是“未来的您”,可能会在数天、数周或数月之后返回特定的数据集、脚本,甚至整个 Python 环境。在这段时间里,曾经显而易见的事情会变得令人困惑和难以理解,除非您充分记录它们,即使是常见的“经验教训”在您匆忙或专注于其他事物时也可能被忽视。我在过去几章的练习中也有这样的体验,特别是在努力检查自己的工作时。对我来说,这种经历只是又一次提醒我,当脚本出现问题时,通常是一些简单的错误 😉

以下是一些需要牢记的常见陷阱:

确认大小写

每当您检查两个字符串是否相同时,请记住大小写是有影响的!当我在处理示例 6-16 时,起初我忽视了所有企业名称(但不包括银行名称!)都是大写的事实。我曾经沮丧地花了几分钟时间,认为我的数据集中并没有包含WATERFORD RECEPTIONS的例子,直到最后我再次查看数据并意识到了我的错误。

坚持数据类型

当我按照“通往‘简单’解决方案的迂回路径”中描述的过程工作时,我再次遇到了找不到应该在数据集中的值的问题。然而,我忘记了pandas库(与csv库不同)实际上尝试为读入 DataFrame 的数据列应用数据类型。在这种情况下,OriginatingLenderLocationID变成了一个数字(而不是字符串),所以我的尝试去匹配该列的特定值失败了,因为我试图将例如数字71453与字符串"71453"进行匹配—这肯定是行不通的!

在那种情况下,我发现最简单的解决方案只是在read_csv()函数调用中添加一个参数,指定所有数据应该被读取为字符串(例如,fingerprinted_data1 = pd.read_csv('public_150k_plus_fingerprints.csv', dtype='string'))。^(7) 这也防止了数据中的一些较大金额被转换为科学计数法(例如,1.21068e+06 而不是 1210681)。

在基本的错字之后,这里描述的数据类型“陷阱”可能是您在数据整理中最常遇到的“错误”之一。所以,如果您发现自己在某些时候犯了类似的疏忽,请尽量不要过于沮丧。这实际上只是表明您的编程逻辑很好,只需修正一些格式即可。

扩展您的数据

在 示例 7-10 中添加OriginatingLenderFingerprint列是增加 PPP 贷款数据实用性和可用性的宝贵方式,但是增加数据集价值的另一种好方法是寻找其他可以用来增强它的数据集。当数据集维度结构化时,通常这样做最容易,因为它已经引用了某种广泛使用的标准。在我们的 PPP 贷款数据中,我们有一个例子,即称为NAICSCode的列,在快速的网页搜索^(8) 中确认它是:

…北美工业分类系统(North American Industry Classification System)。NAICS 系统是为联邦统计机构开发的,用于收集、分析和发布与美国经济相关的统计数据。

有了这个,我们可能可以通过为每个条目添加关于 NAICS 代码的更多信息来增强我们的数据,例如,这可能有助于我们更好地了解参与 PPP 贷款计划的行业和企业类型。虽然我们可能可以从主网站提取一个完整的 NAICS 代码列表,但是在网上搜索naics sba可以找到一些有趣的选项。具体来说,SBA 提供了一份 PDF,提供了有关 根据 NAICS 代码为企业设置的小型企业管理局规模标准的信息,可以用百万美元或员工人数来表示。除了为我们提供 NAICS 代码本身更易读的描述外,通过这些额外信息增强我们的 PPP 贷款数据可以帮助我们回答更一般的问题,即什么实际上符合“小企业”的资格。

我们处理这个的过程与以前做过的数据合并并没有太大不同,无论是我们将遵循的过程还是它引入的问题。首先,我们将寻找 SBA 尺寸标准的非 PDF 版本。在 PDF 的第一页点击“SBA 尺寸标准网页”链接将我们带到更 一般的 SBA 网站页面,在“数值要求”部分,我们找到了一个标有 “小型企业规模标准表” 的链接。滚动该页面,可以找到一个可下载的 XLSX 版本 ,这是之前 PDF 文档的第二张表格(其中包含实际的代码和描述)。从这里,我们可以将第二张表格(包含实际代码和描述)导出为 CSV 文件。现在,我们可以导入并与我们的 PPP 贷款数据进行匹配。

正如您将在示例 7-12 中看到的,每当我们集成新的数据源时,这意味着我们必须像处理“主”数据集一样评估、清理和转换它。在这种情况下,这意味着我们希望主动将 PPP 贷款数据中NAICSCode列中的任何<NA>值更新为一个标志值(我选择了字符串“None”),以防止它们与我们 SBA NAICS 代码文件中本质上是随机的<NA>值匹配。同样,一旦我们完成了合并,我们仍然希望看到我们的 PPP 贷款文件中哪些代码没有成功匹配。暂时,我们将暂时不决定如何处理它们,直到我们在分析阶段进行了更深入的挖掘,看看我们是否想要“填补”它们(例如,使用常规 NAICS 值/解释),将它们标记为 SBA 的非典型值,或两者兼而有之。

示例 7-12. ppp_adding_naics.py
# script to merge our PPP loan data with information from the SBA's NAICS
# size requirements, found here:
# https://www.sba.gov/document/support--table-size-standards

# import pandas to facilitate the merging and sorting
import pandas as pd

# read our PPP loan data into a new DataFrame
ppp_data = pd.read_csv('public_150k_plus_fingerprints.csv', dtype='string') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# read the NAICS data into a separate DataFrame
sba_naics_data = pd.read_csv('SBA-NAICS-data.csv', dtype='string')

# if there's no value in the 'NAICSCode' column, replace it with "None"
ppp_data['NAICSCode'] = ppp_data['NAICSCode'].fillna("None") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# merge the two datasets using a "left" merge
merged_data = pd.merge(ppp_data, sba_naics_data, how='left',
                      left_on=['NAICSCode'], right_on=['NAICS Codes'],
                      indicator=True)

# open a file to save our merged data to
merged_data_file = open('ppp-fingerprints-and-naics.csv', 'w')

# write the merged data to an output file as a CSV
merged_data_file.write(merged_data.to_csv())

# print out the values in the '_merge' column to see how many
# entries in our loan data don't get matched to an NAICS code
print(merged_data.value_counts('_merge'))

# create a new DataFrame that is *just* the unmatched rows
unmatched_values = merged_data[merged_data['_merge']=='left_only']

# open a file to write the unmatched values to
unmatched_values_file = open('ppp-unmatched-naics-codes.csv', 'w')

# write a new CSV file that contains all the unmatched NAICS codes in our
# PPP loan data, along with how many times it appears
unmatched_values_file.write(unmatched_values.value_counts('NAICSCode').to_csv())

1

使用dtype='string'参数强制pandas将整个数据集视为字符串;这将使后续的匹配和比较任务更加可预测。

2

如果我们不进行此替换,我们的数据将与SBA-NAICS-data.csv文件中的不可预测的NA值匹配。

正如我们在示例 7-12 中所做的那样增强数据集,可以帮助我们扩展我们可以用它来回答的问题类型,以及帮助支持更快、更全面的数据分析和解释。与此同时,每当我们引入新数据时,我们都需要完成与我们应用于“主”数据集相同的评估、清理、转换和(甚至可能)增强的生命周期。这意味着我们将始终需要在使我们的主要数据更为复杂(并可能更有用)与找到和处理我们用来增强它的“次要”数据之间取得平衡所需的时间和精力。

结论

尽管数据清理、转换和增强的可能性与数据集和分析可能性一样多样,但本章的主要目标是说明数据清理、转换和增强中的常见问题,并介绍一些解决这些问题的关键方法。

在我们继续尝试用我们的数据生成见解之前,我们将在第八章中稍作“绕道”,重点介绍一些编程最佳实践,这些最佳实践可以帮助我们确保我们的代码尽可能清晰、高效和有效。因为使用 Python 进行数据整理已经让我们能够做到其他工具无法做到的事情,优化我们的代码以实现更好的使用和重复使用,是确保我们充分利用每个程序和代码片段的另一种方式,这在大多数情况下意味着构建结构更为灵活、可组合和可读的文件,正如我们现在所看到的!

^(1) 虽然 Citi Bike 数据文件的数据格式在 2021 年初有所改变,但在此日期之前的文件仍然遵循这些示例中的格式。

^(2) 即使不是这样,我们也可以将它们转换为字符串。

^(3) 当然,除非你在团队中工作——那么你需要考虑每个人的需求。当轮到你使用他们的代码时,你会感到高兴的。

^(4) 当然,Macs 和 PCs 使用不同的“基准”日期,因为……原因

^(5) 相信我,大多数专业程序员每五分钟就会在网上查找东西。

^(6) 起初,我担心OriginatingLenderLocationID可能指的是单个银行分支机构。

^(7) 事实上,我最终在示例 7-10 的最终代码中甚至没有使用这种方法,但我在示例 7-12 中确实找到了它的用途!

^(8) 这导致我们跳转至 https://naics.com/what-is-a-naics-code-why-do-i-need-one

第八章:结构化和重构你的代码

在我们继续进行数据整理的分析和可视化工作之前,我们将简要“绕道”,讨论一些关于如何充分利用我们迄今为止所做的一切的策略。在过去的几章中,我们探讨了如何从各种数据格式和来源中访问和解析数据,如何从实际角度评估其质量,以及如何清理和增强数据以便进行最终的分析。在这个过程中,我们相对简单的程序已经发生了演变和变化,不可避免地变得更加复杂和深奥。我们的for循环现在有了一个或者(更多)嵌套的if语句,而其中一些现在嵌入了明显的“魔法”数字(比如我们在 示例 7-5 中的 the_date.weekday() <= 4)。这只是更功能化代码的代价吗?

请记住,注释我们的代码可以在很大程度上帮助我们保持脚本逻辑对潜在合作者和我们未来自己的可理解性。但事实证明,详细的文档(尽管我很喜欢)并不是提高 Python 代码清晰度的唯一方式。就像其他类型的书面文件一样,Python 支持一系列有用的机制来结构化和组织我们的代码。通过明智地利用这些机制,我们可以使将来更加简单地使用和重用我们的编程工作。

因此,在本章中,我们将讨论工具和概念,使我们能够以既可读又可重用的方式优化我们的代码。这个过程被称为重构,它展示了使用 Python 进行数据整理工作的又一种方式,即使我们在需要时可以依赖于别人的库所提供的功能,我们也可以同时创建新的编码“捷径”,这些捷径可以完全按照我们自己的偏好和需求定制。

重新审视自定义函数

当我们在第二章远古时期介绍 Python 基础知识时,我们碰到的一个概念是“定制”或用户定义函数。^(1) 在 示例 2-7 中,我们看到了一个自定义函数如何用于封装当提供特定名称时打印问候语的简单任务——当然,我们可以创建简单或复杂得多的自定义函数。然而,在我们深入探讨编写自己的自定义函数的机制之前,让我们退后一步,思考哪些设计考量最有助于我们决定何时编写自定义函数可能最有帮助。当然,就像所有写作一样,没有多少硬性规则,但接下来的一些启发法则可以帮助您决定何时以及如何重构可能是值得的。

你会多次使用它吗?

像变量一样,识别出可以从中受益于重新打包成自定义函数的代码部分的一种方法是查找任何被多次执行的特定任务。从验证输入到格式化输出(比如我们在示例 7-7 中所做的),以及其中的一切内容,如果你当前的脚本包含大量繁琐的条件语句或重复的步骤,那就表明你可能需要考虑设计一些自定义函数。同时要记住,你考虑的重复并不一定需要存在于单个脚本中,重构仍然是值得的。如果你发现你经常在你写的脚本中频繁地执行某些特定任务(例如,测试给定日期是否为工作日,就像我们在示例 7-5 中所做的那样),你可以将自定义函数放入外部脚本,并在可能需要的任何地方引用它,就像我们在第 5 章中处理凭据文件时所做的那样。

它是不是又丑又令人困惑?

在进行工作的同时记录你的工作是对当前合作者和未来自己的一种礼物^(2)。同时,彻底地注释你的代码——特别是如果你不仅包含如何而且为什么采取这种方法的解释,我仍然建议——最终可能会使代码变得有些难以管理。因此,创建真正易于理解的代码,实际上是在提供足够详细的同时也足够简洁,以便你的文档实际上被阅读到。

将相关的代码片段打包成自定义函数实际上是帮助你解决这个难题的关键方法:像变量一样,自定义函数可以(而且应该!)具有描述性的名称。仅仅通过读取函数名称,查看你的代码的人就会获得关于正在发生的事情的一些基本信息,而无需立即在其周围添加多行描述性评论。如果函数名称足够描述性,或者某个读者暂时不需要更多细节,他们可以直接继续进行。与此同时,如果他们需要,他们仍然可以找到你那些可爱的、描述性的函数文档——这些文档被整齐地放在程序的另一部分(或者完全是另一个文件)中。这意味着你的主脚本的内联注释可以保持相对简洁,而不会牺牲文档的完整性。

你只是真的讨厌默认功能吗?

好吧,也许这并不是编写自定义函数的最佳理由,但这是一个真实存在的理由。随着时间的推移,你可能会发现,在数据整理工作中,有一些你需要反复完成的任务,而现有的函数和库中总是有一些让你感到困扰的地方。也许是一个你觉得名称令人困惑的函数名,因此你总是不得不准确地记住它叫什么。或者可能是有一个你总是忘记添加的参数,这使得一切都变得更加困难(我在看你,pd.read_csv(),带上你的dtype='string'参数!)。如果你是独自工作或是在一个小团队中,编写帮助简化生活的自定义函数完全没问题,因为它们确实有用。你不需要一个宏伟的理由。如果它能让你的生活变得更轻松,那就去做吧!这就是作为程序员的力量。

当然,这也有一些限制。除非你想以更正式和深入的方式编写 Python 代码,否则你不能有效地做一些事情,比如定义一个与现有函数同名的新函数,或者让像+-这样的运算符表现得有所不同。^(3) 但是,如果你只是希望一个经常使用的现有函数工作方式略有不同,那就去做吧——只要确保你充分记录你的版本!

理解作用域

现在我们已经讨论了一些可能会使用自定义函数重构代码的理由,是时候稍微讨论一下具体的实现方式了。当你开始编写自定义函数时,可能最重要的概念就是作用域。虽然我们以前没有用过这个术语,但作用域实际上是我们自从在“名字的背后”中声明第一个变量以来一直在使用的概念。在那个例子中,我们看到我们可以:

  1. 创建并赋值给一个变量(author = "Susan E. McGregor")。

  2. 使用该变量后面引用其内容并将其值传递给一个函数(print(author))。

同时,我们知道,如果我们创建了一个仅仅包含以下一行代码的程序:

print(author)

我们会得到一个错误,因为在我们这个仅有一行的脚本的宇宙中,没有标记为author的内存盒。因此,Python 会向我们抛出一个错误,并拒绝继续执行。

当我们谈论编程中的作用域时,我们实际上是在讨论从特定代码片段的视角看当前“宇宙”的范围。每个脚本都有一个作用域,在计算机逐行读取每行代码时,从顶部到底部逐步演变,这就是导致例子 8-1 和 8-2 中脚本(非常预期的)行为的原因。

Example 8-1. 在作用域中没有author变量
# no variable called "author" exists in the same "universe"
# as this line of code; throw an error
print(author)
Example 8-2. 在作用域中的author变量
# create variable "author"
author = "Susan E. McGregor"

# variable "author" exists in the "universe" of this line of code; carry on!
print(author)

就像每当我们创建一个新变量时,计算机内存中都会创建一个新的“盒子”一样,每当我们定义一个新的自定义函数时,也会为其创建一个新的小宇宙或作用域。这意味着当我们使用自定义函数时,我们不仅在视觉上而且在逻辑上和功能上将我们的代码分隔开。这意味着我们可以像对待我们一直在本书中使用的内置 Python 方法和库函数那样对待我们自己的自定义函数:作为我们提供“配料”并返回某个值或新制作的 Python 对象的“食谱”。唯一的区别在于,使用自定义函数时,我们就是厨师!

为了在实践中理解这一切的含义,让我们重新访问示例 2-7 来自第二章,但稍作调整,如示例 8-3 所示。

示例 8-3. greet_me_revisited.py
# create a function that prints out a greeting
# to any name passed to the function
def greet_me(a_name):
    print("Variable `a_name` in `greet_me`: "+a_name)
    print("Hello "+a_name)

# create a variable named `author`
author = "Susan E. McGregor"

# create another variable named `editor`
editor  = "Jeff Bleiel"

a_name = "Python"
print("Variable `a_name` in main script: "+a_name)

# use my custom function, `greet_me` to output "Hello" messages to each person
greet_me(author)
greet_me(editor)

print("Variable `a_name` in main script again: "+a_name)

这将产生以下输出:

Variable `a_name` in main script: Python
Variable `a_name` in `greet_me`: Susan E. McGregor
Hello Susan E. McGregor
Variable `a_name` in `greet_me`: Jeff Bleiel
Hello Jeff Bleiel
Variable `a_name` in main script again: Python

因为任何自定义函数都会自动获得其自己的作用域,所以该函数只能“看到”显式传递给它的变量和值。反过来,在该函数内部的值和变量实际上是从主脚本中“隐藏”的。这一结果之一是,当我们编写自定义函数时,我们无需担心主脚本中已经使用了哪些变量名,反之亦然。因此,我们通常需要更少地定义唯一的变量名,这在我们开始编写更长和更复杂的脚本时非常有帮助。它意味着一旦我们的自定义函数按预期工作,我们可以使用甚至修改其功能细节而不需要调整周围脚本中的变量和函数。

定义函数“配料”的参数

我们已经有了相当多的经验,提供给 Python 内置方法和函数(正式称为参数)或者我们迄今为止使用的许多库提供的“配料”。然而,当我们开始编写自定义函数时,我们需要更详细地探索定义这些函数将接受的参数的过程。^(4)

首先要知道的是,与某些编程语言不同,Python 不需要(甚至实际上不允许)坚持函数的参数具有特定的数据类型。^(5) 如果有人想要将完全错误类型的数据传递给您的函数,他们完全可以这样做。因此,作为函数的作者,您需要决定是否以及如何确认(或验证)传递给您自定义函数的参数或“配料”的适当性。原则上,有三种方法可以处理这个问题:

  • 检查已传递到函数中的所有参数的数据类型,如果发现不喜欢的内容,请向程序员提出投诉。

  • 将您的代码包装在Python 的 try...except中,以便您可以捕获某些类型的错误而不会停止整个程序。您还可以使用这个来自定义向程序员传达发生了什么错误的消息。

  • 不用担心它,让函数的用户(换句话说,程序员)通过使用默认的 Python 错误消息来解决任何问题。

尽管这看起来有点随意,但在这一点上,我的主要建议实际上是选择第三种选择:不要担心它。并非因为不会发生错误(它们会发生——如果需要恢复对它们的某些了解,可以重新访问“快进”),而是因为我们在这里的主要兴趣是处理数据,而不是编写企业级 Python。与我们在第四章中编写的脚本一样,我们希望在尝试通过程序处理的内容和依赖程序员(无论他们是谁)自行调查和处理之间取得平衡。由于我们编写的程序如果出错不会使网站崩溃或破坏数据的唯一副本,因此似乎不去预料和处理可能出现的每一个错误更为合理。当然,如果您清楚地记录了您的函数——这是我们将在“使用 pydoc 记录自定义脚本和函数的详细过程”中更详细地查看的过程,那么其他程序员在首次避免错误方面将拥有一切所需。

你的选择是什么?

即使我们不试图为数千人使用编写自定义函数,我们仍然可以使它们灵活且功能齐全。其中一个最简单的方法是编写我们的函数来解决问题的最常见版本,但允许像我们在pandas^(6)和其他库中看到的那样的可选参数,这使它们在某种程度上可以适应。例如,我们可以修改我们的greet_me()函数,使其具有默认的问候语“Hello”,同时可以被程序员传入的可选值覆盖。这使我们能够编写可以在多种情境下有效使用的函数。例如,让我们看看在示例 8-4 中显示的greet_me的修改版本。

示例 8-4. greet_me_options.py
# create a function that prints out a greeting to any name
def greet_me(a_name, greeting="Hello"):
    print(greeting+" "+a_name)

# create a variable named author
author = "Susan E. McGregor"

# create another variable named editor
editor  = "Jeff Bleiel"

# use `greet_me()` to output greeting messages to each person

# say "Hello" by default
greet_me(author)
# let the programmer specify "Hi" as the greeting
greet_me(editor, greeting="Hi")

如您所见,添加可选参数实际上就是在函数定义中指定默认值;如果程序员传递了不同的值,那么在调用函数时它将简单地覆盖该默认值。

Getting Into Arguments?

在函数声明中提供默认值并不是向自定义函数添加可选参数的唯一方法。Python 还支持两种通用类型的可选参数,*args**kwargs

*args

*args 参数在想要将多个值列表传递到函数中并且给所有这些值分配名称和/或默认值会很麻烦时非常有用。作为 *args 传递的值被存储为列表,因此它们可以通过编写 for...in 循环逐个访问(例如 for arg in args)。

**kwargs

**kwargs 参数类似于 *args,不同之处在于它允许将任意数量的关键字参数传递给函数,而不为它们分配默认值,就像我们在 示例 8-4 中为 greeting 所做的那样。通过这种方式传递的值可以通过 kwargs.get() 方法访问(例如 my_var = kwargs.get("greeting"))。

如果使用 *args**kwargs 看起来像是在编写自定义函数时保持选项开放的一种便利方式,我要告诉你的是,最好始终编写解决你实际问题的自定义函数(和脚本!),而不是你认为可能在未来某处出现的问题。虽然极限灵活性的概念一开始可能看起来很吸引人,但通常会导致花费大量时间思考“某天”的问题,而不是实际解决我们面前的问题。谁有时间去做那些呢?我们有数据要处理!

返回值

到目前为止,我们对 greet_me() 函数的变化目标相当有限;我们实际上只是用它们来打印(稍微)定制的消息到控制台。相比之下,我们从外部库中使用的函数非常强大;它们可以将一个简单的 .csv 文件转换为 pandas DataFrame,或者将一个整个 .xls 文件转换为详细列表和属性的集合,几乎可以捕捉到这种多层文件类型的每个方面。尽管这种级别的 Python 编程超出了本书的范围,但我们仍然可以创建干净、超级有用的自定义函数,利用返回值的威力。

如果参数/参数是我们函数“食谱”中的“成分”,那么返回值就是最终的菜肴——被我们程序的其余部分消耗的输出。实际上,返回值只是数据片段;它们可以是字面值(如字符串“Hello”),或者它们可以是任何数据类型的变量。它们之所以有用,是因为它们让我们可以将函数需要的任何东西交给它,并获取我们需要的东西,而不必担心(至少从主程序的视角或作用域来看)如何制造这个比喻的香肠。如果我们重新构造 示例 8-3 中基本的 greet_me() 函数以使用返回值,它可能看起来像 示例 8-5

示例 8-5. make_greeting.py
# create a function that **returns** a greeting to any name passed in
def make_greeting(a_name):
    return("Hello "+a_name)

# create a variable named author
author = "Susan E. McGregor"

# create another variable named editor
editor  = "Jeff Bleiel"

# use my custom function, `greet_me()` to build and store
# the "Hello" messages to each person
author_greeting = make_greeting(author)
editor_greeting = make_greeting(editor)

# now `print()` the greetings built and returned by each function call
print(author_greeting)
print(editor_greeting)

起初,你可能会想,“这有什么帮助?” 虽然我们的主程序实际上变得更长了,但它也可以说变得更加灵活和易于理解。 因为我的make_greeting()函数返回问候语(而不是直接打印它),我可以对其做更多事情。 当然,我可以像我们在示例 8-5 中所做的那样直接打印它,但我现在也可以将其返回值存储在变量中,并稍后做其他事情。 例如,我可以添加以下行:

print(editor_greeting+", how are you?")

虽然这条新消息看起来可能不那么令人兴奋,但它允许我将一些工作分隔到函数中(在本例中,向任何姓名添加“你好”),同时为输出提供了更多灵活性(例如,向其中一个添加更多文本而不是另一个)。

攀登“堆栈”

当然,为了存储一个简单的问候语而创建一个全新的变量似乎比它值得的麻烦多了。 实际上,没有规定我们必须在将函数的输出存储在变量中之前将其传递给另一个函数——我们实际上可以“嵌套”我们的函数调用,以便第一个函数的输出直接成为下一个函数的输入。 这是一种策略,我们以前实际上已经使用过,通常是在操作字符串并将其传递给print()函数时,就像我们在示例 7-8 中将strip()函数调用添加到我们的.csv构建过程中所做的那样。 但是,我们可以对任何一组函数执行此操作,假设第一个函数返回下一个函数需要的内容。 要了解实际操作的工作原理,请查看示例 8-5 的重写,显示在示例 8-6 中,我添加了一个新函数来向问候消息附加“,你好吗?”文本。

示例 8-6. make_greeting_no_vars.py
# function that returns a greeting to any name passed in
def make_greeting(a_name):
    return("Hello "+a_name)

# function that adds a question to any greeting
def add_question(a_greeting):
    return(a_greeting+", how are you?")

# create a variable named author
author = "Susan E. McGregor"

# create another variable named editor
editor  = "Jeff Bleiel"

# print the greeting message
print(make_greeting(author))

# pass the greeting message to the question function and print the result!
print(add_question(make_greeting(editor)))

尽管代码语句print(make_greeting(author))仍然相当容易解释,但使用print(add_question(make_greeting(editor)))时情况开始变得更加复杂,希望这有助于说明函数调用嵌套的实用性是有限的。 函数调用嵌套越多,阅读代码就越困难,即使“操作顺序”逻辑保持不变:始终首先执行“最内层”函数,并且其返回值“上升”以成为下一个函数的输入。 个返回值然后上升到下一个函数,依此类推。 在传统编程术语中,这被称为函数堆栈,其中最内层函数是堆栈的“底部”,最外层函数是“顶部”。^(7) 示例 8-6 的最后一行的函数堆栈示意图如图 8-1 所示。

尽管这种函数调用嵌套是整个编程哲学的核心,但出于可读性考虑,在大多数情况下最好谨慎使用。

一个嵌套的函数调用堆栈。

图 8-1. 一个嵌套的函数调用堆栈

为了乐趣和利润进行重构

现在,我们已经探讨了一些重构我们代码的关键原则和机制,让我们看看如何利用它来提高之前章节中一些脚本的清晰度。当我们浏览以下示例时,请记住,关于什么时候以及如何重构(就像任何编辑过程一样)部分取决于个人偏好和风格。因此,在每个示例下面,我将描述我的选择背后的推理;当您开发自己的重构实践时,您可能会发现这些是有用的模型。

用于识别工作日的函数

在示例 7-5 中,我们创建了一个小脚本,旨在读取我们的Citi Bike 骑行数据,并输出一个仅包含工作日骑行的新文件。虽然在那个示例中我们所使用的方法本质上没有什么问题,但出于几个原因,我认为它是重构的一个好候选项。

首先,现有脚本依赖于一些笨拙且不太描述性的函数调用。第一个调用是将可用日期字符串转换为 Python 可以有意义地评估的实际datetime格式所必需的:

the_date = datetime.strptime(a_row['starttime'], '%Y-%m-%d %H:%M:%S.%f')

类似地,尽管内置的weekday()方法相对直观(尽管它可能更好地命名为dayofweek()),我们必须将其与“魔术数字”4进行比较,以确定the_date实际上是一个工作日:

if the_date.weekday() <= 4:

总体而言,我认为如果去除这些相对隐晦的格式和比较,代码的这些部分会更易读。

其次,检查特定的类似日期的字符串是否是周一至周五的工作日似乎是在数据整理工作中可能会频繁遇到的问题。如果我将这个任务封装到一个自定义函数中,我可以在其他脚本中轻松重复使用它。

查看我是如何重构示例 7-5 脚本的,可以参考示例 8-7。

Example 8-7. weekday_rides_refactored.py
# objective: filter all September, 2020 Citi Bike rides, and output a new
#             file containing only weekday rides

# program outline:
# 1\. read in the data file: 202009-citibike-tripdata.csv
# 2\. create a new output file, and write the header row to it.
# 3\. for each row in the file, make a date from the `starttime`:
#       a. if it's a weekday, write the row to our output file
# 4\. close the output file

# import the "csv" library
import csv

# import the "datetime" library
from datetime import datetime

def main(): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
    # open our data file in "read" mode
    source_file = open("202009-citibike-tripdata.csv","r")

    # open our output file in "write" mode
    output_file = open("202009-citibike-weekday-tripdata.csv","w")

    # pass our source_file to the DictReader "recipe"
    # and store the result in a variable called `citibike_reader`
    citibike_reader = csv.DictReader(source_file)

    # create a corresponding DictWriter; specify its fieldnames should
    # be drawn from `citibike_reader`
    output_writer = csv.DictWriter(output_file,
                                   fieldnames=citibike_reader.fieldnames)

    # actually write the header row to the output file
    output_writer.writeheader()

    # loop through our `citibike_reader` rows
    for a_row in citibike_reader:

        # if the current 'starttime' value is a weekday
        if is_weekday(a_row['starttime']): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)
            # write that row of data to our output file
            output_writer.writerow(a_row)

    # close the output file
    output_file.close()

def is_weekday(date_string, date_format='%Y-%m-%d %H:%M:%S.%f'): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # convert the value in the 'date_string' to datetime format
    the_date = datetime.strptime(date_string, date_format)

    # if `the_date` is a weekday (i.e., its integer value is 0-5)
    if the_date.weekday() <= 4:
        return(True)
    else:
        return(False)

if __name__ == "__main__": ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)
    main()

1

将我们的顶级脚本包装成名为main()的函数是 Python 的一种约定,也具有重要的功能目的。因为计算机按顺序从上到下读取 Python 代码,如果我们将此代码包装在函数中,那么当计算机在达到if is_weekday(a_row['starttime'])时还没有到达is_weekday()的定义时,会抛出错误。

2

我们可以使用is_weekday()函数来处理将我们的日期字符串转换为“真实”日期及其工作日值的微妙细节。这个描述性函数名以高层次传达了正在发生的事情,而不强迫读者深入研究具体细节。

3

计算机仍然像往常一样从顶部到底部阅读,但是当它首先遇到main()is_weekday()函数定义时,我们不要求它执行任何操作,直到脚本的最底部。

正如您所看到的,尽管示例 8-7 中的大部分代码(甚至许多注释)与示例 7-5 中的代码相同,但它们已经以一种使程序的main()部分更加简洁和可读的方式重新组织了。当然,如果程序员知道如何识别工作日的细节,那么is_weekday()函数定义中的具体内容就在那里。但是,如果他们只是简单地阅读脚本的主要部分,并且非常容易确认它正在按照概述所述的方式执行。

元数据无需混乱

在示例 7-7 中,我们构建了一个单一脚本,基于示例 4-6 和 7-6 中的代码,它有效地解释了 Microsoft Excel 日期,并将我们的源数据文件分割成元数据文本文件和结构化的.csv文件。虽然生成的脚本完成了所有我们需要的工作,但其代码变得臃肿且难以阅读,充斥着难以理解的条件语句和晦涩的日期格式化函数调用。

在这里,我们可以通过处理不同类型的行内容(分别为.csv.txt文件)的格式化来稍作整理。这需要在我们脚本的逻辑上进行一定的重新排列(和澄清)。这也展示了在将所有必要信息传递给我们自定义函数时可能会遇到的一些挑战。在示例 8-8 中概述的方法展示了我目前首选的处理这些问题的方式。

示例 8-8. xls_meta_and_date_parsing_refactored.py
# converting data in an .xls file with Python to csv + metadata file, with
# functional date values using the "xrld" library.
# first, pip install the xlrd library:
# https://pypi.org/project/xlrd/2.0.1/

# then, import the `xlrd` library
import xlrd

# import the csv library
import csv

# needed to test if a given value is *some* type of number
from numbers import Number

# for parsing/formatting our newly interpreted Excel dates
from datetime import datetime

def main():

    # use `open_workbook()` to load our data in the `source_workbook` variable
    source_workbook = xlrd.open_workbook("fredgraph.xls")

    global the_datemode ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    the_datemode = source_workbook.datemode ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

    # open and name a simple metadata text file
    source_workbook_metadata = open("fredgraph_metadata.txt","w")

    # an `.xls` workbook can have multiple sheets
    for sheet_name in source_workbook.sheet_names():

        # create a variable that points to the current worksheet
        current_sheet = source_workbook.sheet_by_name(sheet_name)

        # create "xls_"+sheet_name+".csv" as current sheet's output file
        output_file = open("xls_"+sheet_name+"_dates.csv","w")

        # use the `writer()` recipe to write `.csv`-formatted rows
        output_writer = csv.writer(output_file)

        # Boolean variable to detect if we've hit our table-type data yet
        is_table_data = False

        # now, we need to loop through every row in our sheet
        for row_num, row in enumerate(current_sheet.get_rows()):

            # pulling out the value in the first column of the current row
            first_entry = current_sheet.row_values(row_num)[0]

            # if we've hit the header row of our data table
            if first_entry == 'observation_date':

                # it's time to switch our "flag" value to "True"
                is_table_data = True

            # if `is_table_data` is True
            if is_table_data:

                # pass the requisite data to out `create_table_row()` function
                new_row = create_table_row(current_sheet, row_num) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

                # write this new row to the data output file
                output_writer.writerow(new_row)

            # otherwise, this row must be metadata
            else:

                # pass the requisite data to our `create_meta_text()` function
                metadata_line = create_meta_text(current_sheet, row_num)

                # write this new row to the metadata output file
                source_workbook_metadata.write(metadata_line)

        # just for good measure, let's close our output files
        output_file.close()
        source_workbook_metadata.close()

def create_table_row(the_sheet, the_row_num):

    # extract the table-type data values into separate variables
    the_date_num = the_sheet.row_values(the_row_num)[0]
    U6_value = the_sheet.row_values(the_row_num)[1]

    # create a new row object with each of the values
    new_row = [the_date_num, U6_value]

    # if the `the_date_num` is a number, then the current row is *not*
    # the header row. We need to transform the date.
    if isinstance(the_date_num, Number):

        # use the xlrd library's `xldate_as_datetime()` to generate
        # a Python datetime object
        the_date_num = xlrd.xldate.xldate_as_datetime(the_date_num, the_datemode)

        # create a new list containing `the_date_num` (formatted to MM/DD/YYYY
        # using the `strftime()` recipe) and the value in the second column
        new_row = [the_date_num.strftime('%m/%d/%Y'),U6_value]

    # return the fully formatted row
    return(new_row)

def create_meta_text(the_sheet, the_row_num):

    meta_line = ""

    # since we'd like our metadata file to be nicely formatted, we
    # need to loop through the individual cells of each metadata row
    for item in the_sheet.row(the_row_num):

            # write the value of the cell, followed by a tab character
            meta_line = meta_line + item.value + '\t'

    # at the end of each line of metadata, add a newline
    meta_line = meta_line+'\n'

    # return the fully formatted line
    return(meta_line)

if __name__ == "__main__":
    main()

1

尽管应该节制使用,但在这里创建一个称为the_datemode全局变量意味着它的值可以被任何函数访问(这里的global指的是它的作用域)。请注意,在 Python 中,全局变量不能在声明它们的同一行中赋值,这就是为什么这在两个单独的代码语句中完成的原因。

2

例如,如果我们没有创建一个全局变量来作为日期模式,那么我们将不得不将其作为另一个参数传递给create_table_row(),这在某种程度上感觉有些不协调。

如果您将 示例 8-8 与 示例 8-7 进行比较,您会发现它们有几个关键特征是相同的:脚本中的所有内容都已被划分为函数,并且常规的 main() 函数调用受到 if __name__ == "__main__": 条件保护。这个示例还与 示例 7-7 几乎完美重叠:包含的内容都是一样的,虽然它已被重新排列为三个函数而不是单一的线性脚本,但大部分代码几乎完全相同。

我想在这里要说明的一部分是,重构您的代码并不一定是一项巨大的工程,但其结果是无价的。作为一个程序,示例 8-8 的结构现在基本上是全部逻辑而没有具体细节——烦琐的细节由我们的新函数处理。如果我突然需要从 FRED 数据库下载并解析新的数据系列,我会很自信地将其扔给这个脚本看看会发生什么,因为如果有问题,我知道该去哪里修复它们。而不是不得不深入整个程序,可能出现新数据源的任何格式问题几乎肯定是由 create_table_row()create_meta_text() 中的代码导致的——即使出现问题,也只会出现在其中之一。这意味着为了使此脚本适应新的(类似的)数据源,我可能只需要查看(也许)十几行代码。这肯定比浏览接近 100 行要好得多!

换句话说,虽然重构数据处理脚本通常不意味着编写更多的代码,但当您希望以后使用、重复使用或调整它时,这确实可以节省阅读的时间——这也是 Python 帮助您扩展数据处理工作的另一种方式。

鉴于所有不使用全局变量的理由,为什么我最终决定在这里使用一个?首先,source_workbook.datemode 只有一个可能的值,因为每个 Excel 电子表格只有一个 datemode 属性。因此,即使一个特定的工作簿包含了 20 个不同的工作表,每个工作表包含 100 列数据,所有这些工作表的 datemode 值仍然只有一个、单一、不变的值。因此,在概念上,datemode 的值实际上是“全局”的;我们用来存储此值的变量也应该如此。并且由于 datemode 的值不会在脚本内部更新,因此从中检索到意外值的风险较小。

然而,与所有写作一样,这些选择部分取决于品味——即使我们自己的品味随时间也可能改变。起初,我喜欢创建一个函数来“构建”每个表数据行和另一个函数来“构建”每行元数据文本的对称性,但是打破这种对称性并完全避免使用全局 datemode 变量,如 Example 8-9 中所示,也有其可取之处。

Example 8-9. xls_meta_and_date_parsing_refactored_again.py
# converting data in an .xls file with Python to csv + metadata file, with
# functional date values using the "xrld" library.
# first, pip install the xlrd library:
# https://pypi.org/project/xlrd/2.0.1/

# then, import the `xlrd` library
import xlrd

# import the csv library
import csv

# needed to test if a given value is *some* type of number
from numbers import Number

# for parsing/formatting our newly interpreted Excel dates
from datetime import datetime

def main():

    # use `open_workbook()` to load our data in the `source_workbook` variable
    source_workbook = xlrd.open_workbook("fredgraph.xls")

    # open and name a simple metadata text file
    source_workbook_metadata = open("fredgraph_metadata.txt","w")

    # an `.xls` workbook can have multiple sheets
    for sheet_name in source_workbook.sheet_names():

        # create a variable that points to the current worksheet
        current_sheet = source_workbook.sheet_by_name(sheet_name)

        # create "xls_"+sheet_name+".csv" as the current sheet's output file
        output_file = open("xls_"+sheet_name+"_dates.csv","w")

        # use the `writer()` recipe to write `.csv`-formatted rows
        output_writer = csv.writer(output_file)

        # Boolean variable to detect if we've hit our table-type data yet
        is_table_data = False

        # now, we need to loop through every row in our sheet
        for row_num, row in enumerate(current_sheet.get_rows()):

            # pulling out the value in the first column of the current row
            first_entry = current_sheet.row_values(row_num)[0]

            # if we've hit the header row of our data table
            if first_entry == 'observation_date':

                # it's time to switch our "flag" value to "True"
                is_table_data = True

            # if `is_table_data` is True
            if is_table_data:

                # extract the table-type data values into separate variables
                the_date_num = current_sheet.row_values(row_num)[0]
                U6_value = current_sheet.row_values(row_num)[1]

                # if the value is a number, then the current row is *not*
                # the header row, so transform the date
                if isinstance(the_date_num, Number): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
                    the_date_num = format_excel_date(the_date_num,
                                                     source_workbook.datemode)

                # write this new row to the data output file
                output_writer.writerow([the_date_num, U6_value])

            # otherwise, this row must be metadata
            else:

                # pass the requisite data to our `create_meta_text()` function
                metadata_line = create_meta_text(current_sheet, row_num)

                # write this new row to the metadata output file
                source_workbook_metadata.write(metadata_line)

        # just for good measure, let's close our output files
        output_file.close()
        source_workbook_metadata.close()

def format_excel_date(a_date_num, the_datemode):

    # use the xlrd library's `xldate_as_datetime()` to generate
    # a Python datetime object
    a_date_num = xlrd.xldate.xldate_as_datetime(a_date_num, the_datemode)

    # create a new list containing the_date_num (formatted to MM/DD/YYYY
    # using the `strftime()` recipe) and the value in the second column
    formatted_date = a_date_num.strftime('%m/%d/%Y')

    return(formatted_date)

def create_meta_text(the_sheet, the_row_num):

    meta_line = ""

    # since we'd like our metadata file to be nicely formatted, we
    # need to loop through the individual cells of each metadata row
    for item in the_sheet.row(the_row_num):

            # write the value of the cell, followed by a tab character
            meta_line = meta_line + item.value + '\t'

    # at the end of each line of metadata, add a newline
    meta_line = meta_line+'\n'

    return(meta_line)

if __name__ == "__main__":
    main()

1

尽管我不再将并行信息传递给自定义函数以生成表格类型和元数据输出文件,但放弃对称性可以避免我创建全局变量的需求。

这些哪一个是更好的解决方案呢?与所有写作一样,这取决于您的偏好、使用案例和受众。有些团体或机构会对选择使用或避免全局变量持有“见解”,有些会认为更短的解决方案更可取,而另一些则会重视结构对称性或可重用性。虽然 Example 8-9 牺牲了一些结构对称性,但它生成的函数可能更广泛地可重用。哪一个更重要的选择,如常,由您自己来确定。

使用 pydoc 记录自定义脚本和函数

到目前为止,我们一直在对我们的代码采取彻底但自由的文档方法。从原则上讲,这种方法没有任何问题,特别是如果它确实帮助确保您首先进行文档编制。然而,随着您的 Python 脚本清单(及其潜在的受众)的扩展,有能力查看您的个人自定义函数集合而无需逐个打开和阅读每个 Python 文件是非常有用的。即使没有其他影响,打开大量文件会增加误操作引入错误到您依赖的其他许多脚本中的函数的风险,这是毁掉您一天的一种非常快速的方法。

幸运的是,通过稍微格式化,我们可以调整现有的程序描述和注释,使其适用于称为 pydoc 的命令行函数。这将使我们能够仅通过提供相关文件名就可以在命令行打印出我们的脚本和函数描述。

要看到实际效果,让我们从重构另一个脚本开始。在这种情况下,我们将修改 Example 7-8 以使其更加简洁。在此过程中,我还将更新脚本顶部的注释(并向我们的新函数添加一些注释),使它们与 pydoc 命令兼容。您可以在 Example 8-10 中看到这个效果。

Example 8-10. fixed_width_strip_parsing_refactored.py
""" NOAA data formatter ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png) Reads data from an NOAA fixed-width data file with Python and outputs
a well-formatted CSV file.

The source file for this example comes from the NOAA and can be accessed here:
https://www1.ncdc.noaa.gov/pub/data/ghcn/daily/ghcnd-stations.txt

The metadata for the file can be found here:
https://www1.ncdc.noaa.gov/pub/data/ghcn/daily/readme.txt

Available functions
-------------------
* convert_to_columns: Converts a line of text to a list

Requirements
------------
* csv module

"""
# we'll start by importing the "csv" library
import csv

def main():
    # variable to match our output filename to the input filename
    filename = "ghcnd-stations"

    # we'll just open the file in read format ("r") as usual
    source_file = open(filename+".txt", "r")

    # the "readlines()" method converts a text file to a list of lines
    stations_list = source_file.readlines()

    # as usual, we'll create an output file to write to
    output_file = open(filename+".csv","w")

    # and we'll use the `csv` library to create a "writer" that gives us handy
    # "recipe" functions for creating our new file in csv format
    output_writer = csv.writer(output_file)

    # we have to "hard code" these headers using the contents of `readme.txt`
    headers = ["ID","LATITUDE","LONGITUDE","ELEVATION","STATE","NAME",
               "GSN_FLAG","HCNCRN_FLAG","WMO_ID"]

    # create a list of `tuple`s with each column's start and end index
    column_ranges = [(1,11),(13,20),(22,30),(32,37),(39,40),(42,71),(73,75),
                     (77,79),(81,85)] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

    # write our headers to the output file
    output_writer.writerow(headers)

    # loop through each line of our file
    for line in stations_list:

        # send our data to be formatted
        new_row = convert_to_columns(line, column_ranges)

        # use the `writerow` function to write new_row to our output file
        output_writer.writerow(new_row)

    # for good measure, close our output file
    output_file.close()

def convert_to_columns(data_line, column_info, zero_index=False): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)
    """Converts a line of text to a list based on the index pairs provided

    Parameters
    ----------
    data_line : str
        The line of text to be parsed
    column_info : list of tuples
        Each tuple provides the start and end index of a data column
    zero_index: boolean, optional
        If False (default), reduces starting index position by one

    Returns
    -------
    list
        a list of data values, stripped of surrounding whitespace
    """

    new_row = []

    # function assumes that provided indices are *NOT* zero-indexed,
    # so reduce starting index values by 1
    index_offset = 1

    # if column_info IS zero-indexed, don't offset starting index values
    if zero_index:
        index_offset = 0

    # go through list of column indices
    for index_pair in column_info:

        # pull start value, modifying by `index_offset`
        start_index = index_pair[0]-index_offset

        # pull end value
        end_index = index_pair[1]

        # strip whitespace from around the data
        new_row.append((data_line[start_index:end_index]).strip())

    # return stripped data
    return new_row

if __name__ == "__main__":
    main()

1

通过在文件和convert_to_columns()函数描述的开头和结尾使用一对三个双引号("""),它们在文件中可视化显示,与文件中其他注释区分开,并且现在可以通过在终端中运行以下命令来访问pydoc:^(9)

pydoc fixed_width_strip_parsing_refactored

这将在命令行界面中显示所有文件和函数描述(使用箭头键上下滚动,或者使用空格键一次向下移动整个“页面”)。要退出文档并返回命令行,只需按下 q 键。

2

而不是编写用于从给定文本行中提取每列数据的独特代码行,我将所有列的起始/结束值放入了一个Python tuple列表中,这些值基本上是不可改变的列表。

3

将每行数据与列的起始/结束信息一起传递给convert_to_columns()函数,我们可以使用for...in循环将文本转换为列。这不仅使我们的主脚本更易读,而且使得这个函数可重复使用,适用于我们需要拆分成列的任何文本行,只要我们按正确格式传入起始/结束索引对。我甚至添加了一个名为zero_index的标志值,允许我们使用这个函数处理考虑零为第一位置的起始/结束对(默认值假定—如此数据集—第一位置是“1”)。

请注意,除了查看整个文件的文档之外,还可以使用pydoc通过运行以下命令查看单个函数(例如convert_to_columns()函数)的文档:

pydoc fixed_width_strip_parsing_refactored.convert_to_columns

并且像您为整个文件所做的那样浏览/退出其文档。

命令行参数的案例

将一个长脚本重构为一系列函数并不是使我们的数据整理代码更可重用的唯一方法。对于多步骤的数据整理过程(例如涉及下载数据的,如示例 5-8,或者转换 PDF 图像为文本的,如示例 4-16),将我们的代码分解成多个脚本是节省时间和精力的另一种方式。首先,这种方法使我们最大限度地减少了执行下载数据或实际转换 PDF 页到图像等资源密集型任务的频率。更重要的是,这些任务往往相当机械化—我们下载的数据或我们转换的 PDF 是什么并不重要;我们的脚本几乎总是在做同样的事情。因此,我们只需要一些额外的技巧,就可以将我们当前定制的数据整理脚本转换为可以一次又一次重复使用的独立代码。

例如,让我们回顾一下示例 5-8。在这个脚本中,我们主要是下载网页内容;只是在这种情况下,我们已经“硬编码”了特定的target file。同样,下载 XML 和 JSON 文件的代码在示例 5-1 中几乎相同——唯一的区别是源 URL 和本地副本的文件名。如果有一种方法可以重构这些脚本,使得整个过程更像一个函数,那么从长远来看,这可能会为我们节省大量的时间和精力。

幸运的是,这对于独立的 Python 文件来说非常容易实现,多亏了内置的argparse Python 库,它允许我们编写脚本来需要并且使用从命令行传入的参数。得益于argparse,我们无需为每个想要下载的单个网页编写新脚本,因为它允许我们直接从命令行指定目标 URL输出文件的名称,如示例 8-11 所示。

示例 8-11. webpage_saver.py
""" Web page Saver!

Downloads the contents of a web page and saves it locally

Usage
-----
python webpage_saver.py target_url filename

Parameters
----------
target_url : str
    The full URL of the web page to be downloaded
filename : str
    The desired filename of the local copy

Requirements
------------
* argparse module
* requests module

"""
# include the requests library in order to get data from the web
import requests

# include argparse library to pull arguments from the command line
import argparse

# create a new `ArgumentParser()`
parser = argparse.ArgumentParser()

# arguments will be assigned based on the order in which they were provided
parser.add_argument("target_url", help="Full URL of web page to be downloaded") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
parser.add_argument("filename", help="The desired filename of the local copy")
args = parser.parse_args()

# pull the url of the web page we're downloading from the provided arguments
target_url = args.target_url

# pull the intended output filename from the provided arguments
output_filename = args.filename

# create appropriate header information for our web page request
headers = {
    'User-Agent': 'Mozilla/5.0 (X11; CrOS x86_64 13597.66.0) ' + \
                  'AppleWebKit/537.36 (KHTML, like Gecko) ' + \
                  'Chrome/88.0.4324.109 Safari/537.36',
    'From': 'YOUR NAME HERE - youremailaddress@emailprovider.som'
}

# because we're just loading a regular web page, we send a `get` request to the
# URL, along with our informational headers
webpage = requests.get(target_url, headers=headers)

# opening up a local file to save the contents of the web page to
output_file = open(output_filename,"w")

# the web page's code is in the `text` property of the website's response
# so write that to our file
output_file.write(webpage.text)

# close our output file!
output_file.close()

1

在这里,我们正在分配参数名称,我们可以用它来从我们的脚本内部访问通过命令行传递的值。帮助文本很重要!要描述清楚但简洁。

现在我们应该有一个简单的方法来下载任何网页到我们的设备,而无需为每个独特的 URL 编写脚本。例如,如果我们运行:

python webpage_saver.py "http://web.mta.info/developers/turnstile.html" \
"MTA_turnstiles_index.html"

我们将得到与示例 5-8 完全相同的结果,但我们也可以同时运行:

python webpage_saver.py \
"https://www.citibikenyc.com/system-data/operating-reports" \
"citibike_operating_reports.html"

无需打开,更不用说修改我们的脚本,就可以获取 Citi Bike 的运营报告。方便,不是吗?

提示

使用特定于任务的脚本和命令行参数可以节省时间,但如果最终将复杂的 URL 逐字符复制到命令行界面,则不能。为了简化事情,这里有一个快速概述如何根据您的操作系统复制/粘贴到命令行:

Linux(包括 Chromebook)

高亮显示您要复制的 URL/文本,然后上下文右键单击并选择复制。在您的命令行窗口中,只需单击即可自动粘贴。

Windows/Macintosh

高亮显示您要复制的 URL/文本,然后上下文右键单击并选择复制。在您的命令行窗口中,再次上下文右键单击并选择粘贴。

脚本和笔记本之间的区别

到现在为止,你可能已经注意到,在前面的章节中,我没有描述一种将命令行参数传递到 Jupyter 笔记本中的方法,也没有多谈论如何为 Jupyter 笔记本生成和交互式地使用脚本和函数文档。这并不是因为这些事情是不可能的,而是因为 Jupyter 笔记本的设计是为了让你与 Python 交互的方式与独立脚本不同,而且这些概念对它们的适用性较少。作为一个在 Jupyter(原名 IPython)笔记本出现之前就开始使用 Python 的人,我的偏好仍然倾向于独立的脚本来处理大多数的 Python 数据整理任务。虽然笔记本(通常)非常适合于测试和调整代码片段,但一旦我确定了适合特定数据整理任务的方法,我几乎总是会回到独立的脚本上来。这主要是因为在项目进展更深时,我常常会对打开和修改脚本的过程感到不耐烦,更不用说启动一个 Web 服务器或等待网页加载(我甚至可能没有互联网连接!)。这些都是我更喜欢使用命令行参数和独立脚本来处理常见而直接的任务的原因之一。

正如我们很快将看到的那样,Jupyter 笔记本的交互性使它们在实验和尤其是分享数据分析和可视化部分时,比独立的 Python 脚本稍显优越。因此,在我们转向第 9 和 10 章节的这些主题时,你会发现更多关于 Jupyter 笔记本的引用。

结论

在这一章中,我们暂时离开了数据整理的实质性工作,重新审视了一些之前因代码难以掌控而变得杂乱的工作。通过重构的过程,我们探讨了如何将能用的代码重组成用得好的代码,因为这样的代码更易读且可重用。随着我们的数据整理项目的发展,这将帮助我们建立并利用我们自己的一套自定义函数集,以满足我们特定的数据整理需求。同样地,通过为我们的文档应用更多结构,我们使其能够从命令行直接访问并且变得有用,这样我们可以在不打开一个单独的脚本的情况下找到我们需要的脚本或函数。而且,我们也将重构的逻辑应用到了我们的脚本中,这样我们就能够定制它们的功能而无需打开它们

在接下来的章节中,我们将回到数据分析的焦点,并概述一些基本的数据分析技术。之后,在第十章中,我们将(简要地)介绍一些核心的可视化方法,这将帮助你更好地理解和展示你的数据,从而更好地与世界分享你的数据整理见解!

^(1) 在这种情况下,程序员实际上被认为是“用户”。

^(2) 实际上,良好的文档有时会挽救你的生活。

^(3) 虽然做这些事情确实可能,但远远超出了大多数数据整理活动的范围,因此也超出了本书的范围。

^(4) 在技术上,参数描述的是在函数定义中分配的变量名称,而参数则是在调用函数时传递的实际值。尽管在实践中,这些术语经常互换使用。

^(5) 所谓的“静态类型”编程语言实际上会在你的代码运行之前就抱怨你向函数或方法传递了错误的数据类型。

^(6) 又来了,dtype='string'

^(7) 这个术语也是为什么论坛被称为Stack Exchange 的原因。

^(8) 你可以在https://freecodecamp.org/news/if-name-main-python-example找到有关此约定背后推理的有用描述/演示。

^(9) 我在这里使用的实际结构/格式是从https://realpython.com/documenting-python-code/#documenting-your-python-code-base-using-docstrings的指南中衍生出的不同风格的混合。虽然在与大团队合作时使用标准方法可能很重要,但如果你是单独工作或在小团队中工作,那就找到适合你的风格吧!

第九章:数据分析简介

到目前为止,本书主要关注获取、评估、转换和增强数据的各种细节。我们探讨了如何编写代码来从互联网获取数据,从不友好的格式中提取数据,评估其完整性,并解决不一致性问题。我们甚至花了一些时间考虑如何确保我们用来做所有这些事情的工具——我们的 Python 脚本——能够优化以满足我们当前和未来的需求。

此时,我们需要重新审视这一切工作的目的。在“数据整理是什么?”一文中,我描述了数据整理的目的是将“原始”数据转化为可以生成洞察和意义的东西。但是,除非我们进行至少一定程度的分析,否则我们无法知道我们的整理工作是否足够,或者它们可能产生什么样的洞察。从这个意义上说,停留在增强/转换阶段的数据整理工作就像是摆放好你的厨房材料然后离开厨房一样。你不会花几个小时仔细准备蔬菜和测量配料,除非你打算烹饪。而数据分析就是这样:将所有精心清理和准备好的数据转化为新的洞察和知识。

如果你担心我们再次陷入抽象,不用担心——数据分析的基础知识足够简单和具体。就像我们的数据质量评估一样,它们是技术努力的一部分,加上四分之三的判断力。是的,数据分析的基础包括令人放心的、2 + 2 = 4 风格的数学,但洞察力依赖于解释这些非常简单公式的输出。这就是你需要逻辑和研究的地方——以及人类的判断和专业知识——来弥合这一差距。

在本章的过程中,我们将探讨数据分析的基础知识——特别是关于中心趋势分布的简单度量,这些帮助我们为数据赋予有意义的背景。我们还将介绍根据这些度量进行适当推断的经验法则,以及数值和视觉分析在帮助我们理解数据集内趋势和异常中的作用。在章节的末尾,我们将讨论数据分析的局限性,以及为什么从“什么”到为什么总是需要比传统的“数据”更多。当然,在此过程中,我们还将看到 Python 如何帮助我们完成所有这些任务,以及为什么它是从快速计算到必要可视化的正确工具。

背景是关键

如果我现在以$0.50 的价格向你出售一个苹果,你会买吗?为了这个例子,假设你喜欢苹果,而且你正想吃点零食。此外,这是一个漂亮的苹果:光滑、芬芳,并且手感沉重。还假设你确信我并没有企图用这个苹果伤害你。它只是一个漂亮、新鲜的苹果,售价为$0.50。你会买吗?

对大多数人来说,答案是:这取决于情况。取决于什么呢?很多事情。无论你有多么相信我(以及我那几乎完美得过分的苹果),如果站在我旁边的人也在卖每个$0.25 的好看苹果,你可能会买他的。为什么呢?显而易见,它们更便宜,即使我的非常棒的苹果可能也不会比旁边的那个好两倍。同时,如果你的表兄站在我另一边,卖每个$0.60 的美味苹果,你可能会支持他的新苹果销售初创企业,而选择买他的。

这可能看起来是一个非常复杂的果选购决策过程,但实际上我们经常做出这种选择,并且结果有时令人意外。像丹·阿里尔蒂姆·哈福德这样的经济学家已经进行了研究,说明了“免费”礼物的影响有多大,即使它会带来额外的成本,或者我们在了解周围人的收入后,我们对自己的工资满意度会降低。[¹] 大多数的优先事项和决策都依赖于价值判断,为了有效地做出这些判断,我们需要了解我们的选择有哪些。如果我可以以$0.50 买到童话般完美的苹果,我会买吗?也许吧。但如果我能在下一个拐角找到一个价格几乎相同的,那可能就不会了。但如果我赶时间,否则还得走一英里才能买到,我可能会选择买。尽管我们都明白“这取决于情况”的意思,更精确地说“这取决于背景”。

背景的重要性在于,孤立的数据点毫无意义;即使它事实上“正确”,单个数据点也无法帮助我们做出决策。一般来说,产生和获取新知识是将新信息与我们已知的信息联系起来的过程。换句话说,知识不在于数据本身,而在于它与其他事物的关系。因为我们无法详尽地探索每个决策所需的背景(包括你对表兄新苹果初创企业的关切以及支持家庭努力的重要性),我们通常不得不限制自己,仅仅审视那些我们可以(或选择)持续测量和量化的背景部分。换句话说,我们求助于数据。

我们如何从数据中得出背景?我们进行诸如调查其来源的活动,询问谁收集了数据,何时收集的,以及为什么收集的—这些答案有助于阐明数据包含了什么以及可能缺失了什么。我们还寻找方法系统地比较每个数据点与其余部分,以帮助理解它如何符合—或打破—整个数据集可能存在的任何模式。当然,这些活动不太可能给我们提供确定的“答案”,但它们会为我们提供见解和思路,我们可以与他人分享并用来激发我们对周围世界正在发生的事情的下一个问题。

同样但不同

在生成数据洞察力时,建立背景至关重要,但我们如何知道哪些背景至关重要呢?考虑到我们可以识别的无限关系类型,即使在一个相当小的数据集中,我们如何决定要关注哪些呢?例如,在示例 2-9 中的数据,它只是一个简单(虚构的)页面计数列表:

page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]

即使只有这么几个数字,我们可以想象到很多种“背景”类型:例如,我们可以按照奇偶值描述它们,或者可以被 8 整除的值。问题是,这些关系大多数并不那么有趣。我们怎么知道哪些关系才是有趣的呢?

结果表明,人类大脑对两种特定的关系特别敏感和关注:相同和不同。几乎任何类型的刺激中的趋势和异常—从在云朵或彩票结果中看到模式快速识别类似对象之间方向的差异—都会引起我们的注意。这意味着趋势异常本质上是有趣的。因此,当我们想要为我们的数据建立有意义的背景时,一个非常好的起点就是研究给定数据集中的个体记录在相互之间的相似性或差异性方面。

典型是什么?评估中心倾向

某物“平均”是什么意思?在我们日常生活中使用这个术语时,它通常是“平凡”,“预期”或“典型”的代名词。鉴于其特别引人注目的联想,因此在许多情况下,“平均”也可以是“无聊”的同义词。

然而,当涉及到分析数据时,事实证明“平均”值实际上是我们感兴趣的,因为它是比较的基础—而比较是人类非常关心的一件事。还记得有关工资的研究吗?作为人类,我们想知道影响我们的事物与其他人的“典型”相比如何。因此,即使我们永远不希望成为“平均”,总体而言,我们仍然想知道它是什么,以及我们自己的经验与之相比如何。

那意味着什么?

您可能已经熟悉计算一组数字“平均值”的过程:将它们全部加起来,然后除以数量。这种特定的集中趋势度量更准确地称为算术平均值,它的计算方式就是您记得的那样。因此,对于我们的page_counts变量,数学计算如下:

mean_pgs = (28+32+44+23+56+32+12+34+30)/9

这将给我们(大致)一个平均值:

32.333333333333336

作为“典型”章节长度的代表,这看起来相当合理:我们的许多章节的页数非常接近 30,甚至有两个章节的页数恰好是 32 页。因此,每章平均页数略高于 32 看起来是合理的。

虽然在这种情况下平均值可能已经为我们服务得很好,但有许多情况下,将其作为“典型性”的度量可能会极大地误导我们。例如,让我们再想象添加了一个非常长的章节(比如 100 页)到我们的书中。我们计算平均值的方法是一样的:

mean_pgs = (28+32+44+23+56+32+12+34+30+100)/10

但现在均值将会是:

39.1

突然间,我们的“平均”章节长度增加了近 6 页,尽管我们只添加了一个新章节,而我们一半的章节都是 28-34 页长。在这种情况下,大约 39 页的章节真的算是“典型”的吗?实际上并不是。

即使在这个小例子中,我们看到虽然在一些情况下算术平均值是一个合理的“典型性”度量,但它也受极端值的影响很大——而其中之一就足以使其作为数据集“典型性”的简写变得毫无用处。但我们还有什么其他选择呢?

拥抱中位数

在数据集中考虑“典型”值的另一种方法是弄清楚字面上的“中间”值。在数据分析中,记录系列的“中间”值被称为中位数,我们可以用比计算平均值时更少的数学操作找到它:只需排序和计数即可。例如,在我们的原始章节长度集合中,首先要将值从低到高排序:^(2)

page_counts = [28, 32, 44, 23, 56, 32, 12, 34, 30]
page_counts.sort()
print(page_counts)

这给了我们

[12, 23, 28, 30, 32, 32, 34, 44, 56]

现在,我们所要做的就是选择“中间”值——即位于列表开始和结束之间的值。由于这是一个九项列表,中间值将是第五个位置的值(左右各四项)。因此,我们的page_count数据集的中位数值是 32。

现在,让我们看看当我们添加那个额外长的章节时中位数会发生什么变化。我们排序后的数据看起来像这样:

[12, 23, 28, 30, 32, 32, 34, 44, 56, 100]

那么中位数呢?由于列表现在有一个偶数项,我们可以取两个“中间”值,将它们加在一起,然后除以二。在这种情况下,这将是位置 5 和位置 6 的值,它们都是 32。因此我们的中位数值是 (32 + 32) / 2 = 32。即使在我们添加了额外长的章节后,中位数值仍然相同!

起初你可能会想:“等等,这感觉不对。增加了一个全新的章节——一个真的很长的章节,但中位数值竟然一点都没有变化。它不应该至少有些变动吗?”

平均数和中位数之间的真正区别在于,平均数受数据集中具体数值的影响非常大——比如它们的高低——而中位数主要受到特定数值出现的频率的影响。在某种意义上,中位数更接近于“一值一票”的方法,而平均数则让最极端的数值“说得最响亮”。由于我们当前的目标是理解数据集中的“典型”数值,中位数通常会是最具代表性的选择。

不同的思考方式:识别异常值

在“同中有异”中,我注意到人类总体上对“相同”和“不同”的兴趣。在研究我们两种可能的中心趋势测量方法时,我们探讨了数据集中数值相似的方式。但是数值在哪些方面不同呢?如果我们再次查看我们原始的page_count列表,我们可能会相信,32 页的章节相对“典型”,甚至 30 页、28 页或 34 页的章节也是如此。但是 12 页的章节呢?或者 56 页的章节呢?它们显然不太典型,但我们怎么知道哪些数值足够不同,以至于真正“异常”呢?

这是我们必须开始将数学与人类判断结合起来的地方。中心趋势的度量可以被相当明确地计算出来,但确定数据集中哪些值真正异常——即哪些是异常值离群值——不能仅凭算术来确定。然而,随着数据集变得越来越大和复杂,人类有效地将其解释为数据点集合变得更加困难。^(4) 那么我们如何可能对大型数据集应用人类判断?我们需要利用我们最大和最全面的数据处理资源:人类视觉系统。^(5)

数据分析的可视化

数据工作中可视化的角色是双重的。一方面,可视化可以用来帮助我们分析和理解数据;另一方面,它可以用来传达我们从分析中获得的见解。利用数据进行后一目的——作为一种交流工具,与更广泛的观众分享有关数据的见解——是我们将在第十章深入探讨的内容。在这里,我们将集中讨论可视化如何为我们提供对所拥有数据的洞察。

为了理解可视化如何帮助我们识别数据中的极值,我们首先需要直接看数据本身。在这种情况下,我并不是指我们会打开 CSV 文件并开始逐条阅读数据记录。相反,我们将创建一种特殊类型的条形图,称为直方图,其中每个条形表示数据集中某个特定值出现的次数。例如,我们可以看到我们(扩展后的)page_count 数据的一个非常简单的直方图,如 图 9-1 所示。

一个示例直方图

图 9-1. 一个基本的直方图

对于像我们的 page_counts 示例这样的小数据集,直方图不能告诉我们太多;实际上,仅有 10 个值的数据集可能对于中心趋势(如均值中位数)或离群值的概念来说数据点太少了。即便如此,在 图 9-1 中,你可以看到一个看起来像模式的初步形态:长度相等的两章形成了一个双高峰,而其余大多数章节的唯一页数则形成了紧密聚集的单高度条形。而在右端的远处,是我们的 100 页章节,附近没有其他值。虽然根据数学,我们可能已经倾向于认为 100 页的章节是异常值或离群值,但这个直方图肯定加强了这种解释。

当然,为了真正体会可视化在数据分析中的力量,我们需要查看一个数据集,其中的值我们根本无法像我们对待页数统计列表那样“用眼睛”观察。幸运的是,我们在第六章中处理的支付保护计划(PPP)数据在这方面绝对不缺乏,因为它包含数十万条贷款记录。为了了解通过 PPP 批准的贷款金额中什么是“典型”的和什么不是,“我们将编写一个快速脚本来生成当前批准的贷款金额的直方图。然后,我们将在直方图上标注均值和中位数,以便看看每种测量中心趋势的潜力。之后,我们将回到通过可视化数据和一些数学来识别可能的离群值的问题。

为使其工作,我们将再次利用一些强大的 Python 库——具体来说是 matplotlibseaborn——它们都具有用于计算和可视化数据的功能。虽然 matplotlib 仍然是在 Python 中创建图表和图形的基础库,但我们还使用 seaborn 来支持更高级的计算和格式。由于这两者高度兼容(seaborn 实际上是构建在 matplotlib 之上的),这种组合将为我们提供所需的灵活性,既可以快速创建这里需要的基本可视化,又可以定制它们以有效呈现数据在第十章中的内容。

现在,让我们专注于我们的可视化过程的分析尺度。我们将从使用 CurrentApprovalAmount 值创建 PPP 贷款数据的基本直方图开始。我们还将添加均值和中位数线以提供更多背景信息,如示例 9-1 所示。

示例 9-1. ppp_loan_central_measures.py
# `pandas` for reading and assessing our data
import pandas as pd

# `seaborn` for its built-in themes and chart types
import seaborn as sns

# `matplotlib` for customizing visual details
import matplotlib.pyplot as plt

# read in our data
ppp_data = pd.read_csv('public_150k_plus_221.csv')

# set a basic color theme for our visualization
sns.set_theme(style="whitegrid")

# use the built-in `mean()` and `median()` methods in `pandas
mean = ppp_data['CurrentApprovalAmount'].mean() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
median = ppp_data['CurrentApprovalAmount'].median()

# create a histogram of the values in the `CurrentApprovalAmount` column
approved_loan_plot = sns.histplot(data=ppp_data, x="CurrentApprovalAmount")

# get the min and max y-values on our histogram
y_axis_range = approved_loan_plot.get_ylim() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# add the vertical lines at the correct locations
approved_loan_plot.vlines(mean, 0, y_axis_range[1], color='crimson', ls=':') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)
approved_loan_plot.vlines(median, 0, y_axis_range[1], color='green', ls='-')

# the matplotlib `show()` method actually renders the visualization
plt.show() ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)

1

在我们的 PPP 数据中,CurrentApprovalAmount 列告诉我们每笔贷款当前批准的金额(无论是否已发放)。

2

get_ylim() 方法以列表形式返回 y 轴的最低和最高值。我们主要将使用它来设置均值和中位数线的可读长度。

3

我们可以通过指定“x”位置、“y”起点、“y”终点、颜色和线型,在直方图(或其他可视化图表)上添加垂直线。请注意,“x”和“y”值的单位是相对于数据集的,而不是图表的视觉大小。

4

在 Jupyter 笔记本中,并非必须显式调用 matplotlibshow() 方法,就像 print() 语句一样,我更倾向于包含它们以确保清晰和一致性。默认情况下,图表将以“交互”模式呈现(在 Jupyter 中,您需要包含 %matplotlib notebook “魔术”命令,正如我在提供的文件中所做的那样),这使我们能够放大、平移和详细探索直方图,而无需编写更多代码。

大多数情况下,您正在查看由运行此脚本生成的图表(希望看起来像图 9-2 那样),并在想:“现在怎么办?”诚然,这种初始可视化看起来有些乏味,甚至有点令人困惑。不过不用担心!如果有什么的话,这可以作为你首次学习分析可视化与沟通可视化不同之处的案例研究。例如像这样的分析性可视化,需要更多的阅读、理解和改进工作,而不像我们希望用于一般沟通的任何可视化那样。然而,对于生成关于我们数据洞察的见解,这实际上是一个很好的起点。

不过,在我们继续进行数据分析之前,让我们花点时间来欣赏 Python 为这张图表提供的非常老派但无比有用的界面。与其说仅仅是一个静态图像,我们的 Python 脚本还同时提供了一个完整的工具栏(显示在图 9-2 中),我们可以用来与之交互:缩放、平移、修改甚至保存输出。当然,我们可以(并且最终会)使用代码自定义这张图表的内容和美观性,但事实上,我们可以有效地探索我们的数据,而无需不断修改和重新运行我们的代码,这大大节省了时间。为了了解可能的操作,请花几分钟自己玩弄控件。当您准备好继续进行数据分析时,只需点击“主页”图标将图表恢复到初始视图,并在接下来的章节中跟随进行。

PPP 贷款直方图

图 9-2. PPP 贷款直方图

我们的数据的形状是什么?理解直方图

当我们在表格类型的数据中工作时,我们倾向于从行数和列数的角度考虑其“形状”(这实际上正是 DataFrame 的pandas.shape属性返回的内容)。在这种情况下,我们感兴趣的“形状”是直方图的实际形状,它将帮助我们识别可能有趣或重要的模式或异常。在这些情况下,我们首先要查找的一些内容包括:

对称性

我们的数据在垂直方向上对称吗?也就是说,我们能否在可视化数据的某个地方画一条垂直线,使得一侧的柱形图看起来(大致上)像另一侧的镜像?

密度

我们的数据值大多集中在哪里(如果有的话)?是否有多个集群还是只有一个?

毫不奇怪,这些问题不仅仅涉及美学。数据集直方图的形状反映了通常所描述的分布。由于某些分布具有特定的属性,我们可以利用数据的分布帮助我们识别典型、不寻常以及值得进一步审查的内容。

对称性的意义

在自然界中,对称是常见的现象。植物和动物在许多方面都倾向于对称;例如,狗的脸和橡树叶都展示了所谓的对称性——我们可以描述为一侧是另一侧的“镜像”。然而,在生物群体中,某些物理特征的分布也经常表现出对称性,例如身高或翅膀长度。我们的直方图让我们可以直观地观察这种对称性,通过展示在人口中特定身高或翅膀长度的频率。一个经典的例子显示在图 9-3,显示了 20 世纪中叶由一组生物学家测量的家蝇翅膀长度。^(6)

对称的钟形曲线,如图 9-3,有时被描述为“正常”,“标准”或“高斯”分布。如果你曾经遇到过学术成绩“曲线调整”,那么你的同学们的成绩就是按照这种分布调整的:即在顶部或底部几乎没有分数,大部分集中在中间。

然而,高斯分布的力量不仅在于其漂亮的形状;更在于这种形状意味着我们可以什么。展示高斯分布的数据集可以用一种非对称分布无法做到的方式进行描述和比较,因为我们可以有意义地计算两个特定的测量值:标准偏差,它量化数据值的数值范围,其中大多数值可以找到;以及每个值的z-分数,它描述了其在标准偏差方面与均值的距离。由于高斯分布的基本对称性,即使使用不同的比例尺度,我们也可以使用标准偏差z-分数来比较两组功能上相似的数据。例如,如果学生成绩呈现高斯分布,我们可以计算和比较各个学生的 z-分数(即他们相对于他们同学的表现)。这样,即使一个教师的学生成绩平均分在 90 分以上,另一个教师的在 70 分左右,如果两组学生的成绩都真正服从高斯分布,我们仍然可以确定哪些学生在各个同学群体中表现最好或最需要帮助——这是“名义”成绩(例如 74 或 92)永远无法告诉我们的事情。

家蝇翅膀长度

图 9-3. 家蝇翅膀长度

这些特征还告诉我们如何考虑测量集中趋势和异常值。例如,在“完美”的高斯分布中,均值和中位数将具有相同的值。此外,值的 z 分数为我们提供了一个快速的方式来识别特定值是典型还是不寻常,因为我们预期具有给定 z 分数的数据值的百分比是明确定义的。混乱了吗?别担心。就像任何其他复杂的数据关系一样,如果我们将其可视化,这一切都会更加清晰。

显示正态分布,展示了在均值附近 1、2 和 3 个标准差(σ)内的值的百分比。

图 9-4. 显示正态分布,展示了在均值附近 1、2 和 3 个标准差(σ)内的值的百分比

正如您在图 9-4 中所看到的那样,^(7),如果我们数据的分布是高斯的,超过数据值的三分之二(34.1% + 34.1% = 68.2%)可以在均值的一个标准差(通常如此命名,以希腊字母 σ 表示)内找到。另外 27.2%可以在均值的一个到两个标准差之间找到,最后的 4.2%可以在均值的两到三个标准差之间找到。这意味着对于高斯分布,99.7%的所有值都可以在均值的 3 个标准差内找到

那么,有什么影响呢?嗯,要记住,在数据分析中,我们的一个基本目标是理解数据集的典型值和真正极端的值。虽然均值和中位数为数据集的“典型”值提供了一个快速的简写,但是像标准差以及我们可以从中计算的 z 分数帮助我们系统地评估哪些值可能是真正不寻常的。

不出所料,使用 Python 计算这些值非常简单。使用pandasstatistics库,我们可以快速找到数据集(σ)的标准差的值,然后使用它在我们的直方图上放置线条,显示出相关的 z 分数值。例如,我们将继续使用用于生成图 9-3 的数据的例子,如示例 9-2 所示。

示例 9-2. wing_length_with_sd.py
# `pandas` to read in our data
import pandas as pd

# `seaborn` for built-in themes and chart types
import seaborn as sns

# `matplotlib` for customizing visual details
import matplotlib.pyplot as plt

# `statistics` easily calculating statistical measures
import statistics

# read in our data
wing_data = pd.read_csv('wing_length - s057.csv')

# set a basic color theme for our visualization
sns.set_theme(style="white")

# create the histogram, allowing `seaborn` to choose default "bin" values
wing_plot = sns.histplot(data=wing_data, x="wing_length (0.1mm)", kde="True") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# calculate the standard deviation via the `statistics` `stdev()` method
sd = statistics.stdev(wing_data['wing_length (0.1mm)']) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# get the min and max y-values on our histogram
y_axis_range = wing_plot.get_ylim()

# plot the mean as a solid line
mean = wing_data['wing_length (0.1mm)'].mean()
wing_plot.vlines(mean, 0, y_axis_range[1], color='gray', ls='-')

# plot the three standard deviation boundary lines on either side of the mean
for i in range(-3,4): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

    # find the current boundary value
    z_value = mean + (i*sd)

    # don't draw a second line over the mean line
    if z_value != mean:

        # plot a dotted gray line at each boundary value
        wing_plot.vlines(z_value, 0, y_axis_range[1], color='gray', ls=':')

# show the plot!
plt.show()

1

每个“bin”是一系列实际数据值,这些值将被合并到单个直方图条中;kde参数是为我们的可视化添加平滑线的。该线条近似于我们期望的模式,如果我们的数据集有无限数据点的话。

2

我们也可以使用pandasstd()方法:wing_data['wing_length (0.1mm)'].std()

3

请记住,我们的循环将在range()提供的第二个值之前停止,因此要获取三行正数,我们将第二个值设为4。通过从一个负数开始,我们实际上平均值开始减去——这是我们想要的,因为我们希望捕获高于低于平均值的值。

当您审查示例 9-2的输出时,您可能会想到:“太好了,我们在关于虫子的数据上画了一些线。这将如何帮助我解释真实数据?”毕竟,这些家蝇翅长数据的原型高斯分布看起来与我们绘制 PPP 贷款数据时得到的输出不太相似,后者明显是对称的,而您的大多数数据可能也是如此。

那么,当我们的数据分布缺乏对称性时该怎么办呢?我们已经知道如何找到不对称分布的“中间值”,就像在示例 9-1中那样:通过计算中位数,而不是平均数。但是如何识别极端值呢?由于不对称或偏斜的分布不是对称的,因此不存在单一的“标准”偏差,我们也不能用它来计算 z 分数。然而,我们仍然可以有用地将不对称的数据集分成一种方式,这样可以让我们洞察可能异常或极端值。

像寻找中位数一样,这种分割过程其实非常简单。首先,我们找到已排序数据集的中间值——也就是中位数。现在,我们将每一半数据记录视为一个独立的数据集,并找到它们的中位数值。传统上,下半部分的中位数被标记为 Q1,而上半部分的中位数被标记为 Q3。此时,我们已将数据集分成了四部分,或四分位数,每部分包含相同数量的数据值。

这对我们有什么帮助?嗯,记住 z 分数告诉我们的一大部分是具有相似值的数据点的百分比。例如,查看图 9-4,我们可以看到 z 分数为 0.75 的数据点(正如我们预期的那样)距离均值少于一个标准偏差——我们知道整体数据集中大约 68.2%的所有数据值都会如此。通过将数据分成四分位数,我们已经开始了类似的路径。例如,在我们的数据集中,任何数值数值上小于 Q1 的值,根据定义,小于我们拥有的所有数据值的至少 75%。

不过,我们真正寻找的是识别潜在异常值的方法。比起所有数据值的 75%来说,更小或更大都算数,但这还远远不是极端的。仅仅识别我们的四分位数边界还不够。

幸运的是,我们可以使用我们的 Q1 和 Q3 值来计算数据集的下限上限。如果我们的数据分布实际上是正态的,这些边界将几乎完美地与均值加三个标准差以下和以上的值对齐。当然,我们使用它们正是因为我们的数据不是正态分布,我做这个比较是为了说明我们可以用它们来帮助识别非对称分布数据集中的极端值。

就像找到中位数一样,计算上限和下限实际上是非常简单的。我们首先找到一个称为四分位数间距(IQR)的值——这是 Q3 和 Q1 值之间的数值差异的花哨名称。然后我们将该值乘以 1.5,并从 Q1 中减去它以获得下限,将其加到 Q3 以获得上限。就是这样!

IQR(四分位数间距)= Q3 – Q1

下限 = Q1 – (1.5 × IQR)

上限 = Q3 + (1.5 × IQR)

在正态分布中,我们的上限和下限值大约是平均值的三倍标准差之上或之下,但这是否意味着超出我们上限和下限的每个值都自动成为异常值?不是。但是找到这些边界确实帮助我们缩小可能需要寻找异常值的范围。同样重要的是,这些测量帮助我们了解哪些值不是异常值,即使它们在数值上看起来与中位数或平均数提供的“典型”或“预期”值有很大差异。

举个例子,让我们回到 PPP 贷款数据。100 万美元的贷款似乎很多,即使——像我们现在这样——你只看的是起始额超过 15 万美元的贷款。但是 100 万美元的贷款真的不寻常吗?这就是我们的中心趋势和离散度测量(在这种情况下是中位数、四分位数以及下限和上限值)真正能帮助我们的地方。让我们看看我们添加了这些值后直方图的样子,如示例 9-3 所示,然后看看我们的想法。

示例 9-3. ppp_loan_central_and_dist.py
# `pandas` for reading and assessing our data
import pandas as pd

# `seaborn` for its built-in themes and chart types
import seaborn as sns

# `matplotlib` for customizing visual details
import matplotlib.pyplot as plt

# read in our data
ppp_data = pd.read_csv('public_150k_plus_221.csv')

# set a basic color theme for our visualization
sns.set_theme(style="whitegrid")

# use the built-in `mean()` and `median()` methods in `pandas
mean = ppp_data['CurrentApprovalAmount'].mean()
median = ppp_data['CurrentApprovalAmount'].median()

# Q1 is the value at the position in our dataset
# that has 25% of data readings to its left
Q1 = ppp_data['CurrentApprovalAmount'].quantile(0.25)

# Q3 is the value at the position in our dataset
# that has 75% of data readings to its left
Q3 = ppp_data['CurrentApprovalAmount'].quantile(0.75)

# IQR is the difference between the Q3 and Q1 values
IQR = Q3-Q1

# and now we calculate our lower and upper bounds
lower_bound = Q1 - (1.5*IQR)
upper_bound = Q3 + (1.5*IQR)

# use `seaborn` to plot the histogram
approved_loan_plot = sns.histplot(data=ppp_data, x="CurrentApprovalAmount")

# get the min and max y-values on our histogram
y_axis_range = approved_loan_plot.get_ylim()

# add mean line in gray
approved_loan_plot.vlines(mean, 0, y_axis_range[1], color='gray', ls='-')

# other lines in black (median solid, others dotted)
approved_loan_plot.vlines(median, 0, y_axis_range[1], color='black', ls='-')
approved_loan_plot.vlines(lower_bound, 0, y_axis_range[1], color='black', ls=':')
approved_loan_plot.vlines(Q1, 0, y_axis_range[1], color='black', ls=':')
approved_loan_plot.vlines(Q3, 0, y_axis_range[1], color='black', ls=':')
approved_loan_plot.vlines(upper_bound, 0, y_axis_range[1], color='black', ls=':')

# show the plot!
plt.show()

如您从所得图表的放大视图中可以看出(见图 9-5),实际上并没有支持声称 100 万美元贷款是不寻常的证据;这笔金额远低于我们为该数据集计算的上界。因此,即使这笔金额超过了迄今批准的所有贷款的四分之三(因为当前标记为图表 x 轴上的 1.0 1e6 的 100 万美元标记位于我们的 Q3 线右侧),但这仍然不足以使任何 100 万美元的贷款很可能值得进一步调查。至少,这可能不是我们想要开始的地方。

PPP 当前贷款金额直方图,其中中位数、四分位数和边界用黑色标出,均值用灰色标出。

图 9-5. PPP 当前贷款金额直方图的详细信息,中位数、四分位数和边界以黑色标出,平均值以灰色标出

那么我们应该在数据中的哪里寻找可能有趣的模式呢?就在我们面前,在我们已经有的图表上。因为虽然我们可以开始寻找更复杂的统计指标来计算和评估,但即使这基本的可视化也显示出数据中一些引人注目的模式。首先值得注意的是——即使只是为了让我们对我们选择的统计指标感到放心——这个数据集的平均值几乎与我们的第三四分位数处于相同的分布位置。如果我们对在这个数据集中选择中位数而不是平均值作为中心倾向度量有任何疑虑,这个事实应该让我们放心。我们还可以看到的另一件事情——在图 9-5 中展示的数据视图中,如果我们向右滚动更远,我们会看到数据中有些奇怪的小尖峰,表明某个特定的贷款金额被相对频繁地批准了。鉴于它们在周围数据模式中的显著突出,我们可能接下来应该看一看这些数值。

计算“集群”

想象一下,你正在走在拥挤的街道上,你注意到对面角落聚集了一群人。你会怎么做?在一个大多数行人只关心从一个地方到另一个地方的繁忙大街上,即使有一两个人在同一时间停在同一个地方,也足以表明有事情发生。无论“事情”最终是否是一个弹奏音乐的艺人,一个销售特别受欢迎小吃的摊贩,还是一个盒子里装满小猫咪的盒子,我们的视觉系统都被异常所吸引,这正是因为偏离趋势表明至少有些不寻常的事情正在发生。

这也是为什么数据可视化是分析数据的如此有价值的工具——我们的眼睛和大脑都被设计成能够快速感知模式,并能迅速注意到与这些模式的偏离。有时候模式形成的原因很容易猜到,有时候则不那么简单。但在任何可预测的模式中——无论是街上人群的流动,钟形的数据分布,还是平滑倾斜的曲线——任何打破这种模式的现象都值得调查。

在图 9-5 的情况下,我们可以看到一系列这种模式违背的范围。首先是图表左侧的清晰线条,这是一个很好的提醒,即我们的数据集仅包含批准的高于$150,000 的贷款金额,而不是所有已批准的贷款。如果我们忽视了这一点,数据在左侧边缘的明显和硬性截止是一个很好的提醒。

但也存在另一组模式违规:在我们的柱状图周围出现小尖峰,位于 x 轴上特定点附近,如$2 百万的数据值。这些是从哪里来的呢?虽然我们不能确定,但扫描我们的柱状图显示,类似的尖峰出现在大约$500,000 的间隔处,特别是当贷款金额增加时。在某种程度上,这些可能是“圆”数字的结果:如果您要求$1,978,562.34,为什么不将其“四舍五入”为$2 百万呢?当然,这仍然比您可能需要的多$21,437.66——对大多数人来说这是很多钱。鉴于 PPP 贷款旨在支持特定成本,确实有点奇怪,那么多贷款——根据我们的图表,近 2000 笔——竟然恰好达到了$2 百万。

那么到底发生了什么呢?这就是我们需要进行一些额外研究以有效解释我们在数据中看到的内容的地方。根据我的经验,我的第一步将是查阅 PPP 贷款的规则,看看我是否能弄清楚为什么$2 百万可能是如此受欢迎的请求金额。例如,$2 百万是基于业务的特定属性或所请求支持的最低还是最高允许金额吗?

经过一点搜索,小企业管理局(SBA)的网站似乎提供了部分答案:

对于大多数借款人,第二次提款 PPP 贷款的最高贷款金额是 2019 年或 2020 年平均月工资成本的 2.5 倍,最高为$2 百万。对于住宿和餐饮服务部门的借款人(使用 NAICS 72 确认),第二次提款 PPP 贷款的最高贷款金额为 2019 年或 2020 年平均月工资成本的 3.5 倍,最高为$2 百万。

由于$2 百万是几乎所有申请所谓的第二次提款(或第二轮)PPP 贷款的各种类型企业的上限,包括那些最初可能有资格获得更多资金的企业,因此有道理,获批的$2 百万贷款的集群如此之大。

当然,这个“答案”只会带来更多问题。 根据文件,第二轮 PPP 贷款的上限为 2 百万美元; 第一轮贷款最多可以达到 1,000 万美元。 如果有这么多企业请求第二轮贷款的上限,这表明许多企业 1)已经获得了第一轮贷款,并且 2)他们的第一轮贷款可能甚至比 2 百万美元更大,因为如果他们在第一轮中符合更高的贷款金额,那么他们必须将其调整到 2 百万美元以下。 换句话说,我们可能期望那些在第二轮贷款中请求精确 2 百万美元的企业是那些获得最大 PPP 贷款救济总额批准的企业之一。 当然,如果他们确实获得了一些最大的资金池,我们(可能还有许多其他人!)肯定想知道这件事。

2 百万美元问题

为了了解那些请求第二轮 PPP 贷款 2 百万美元的公司之间可能存在的共同特征,我们首先需要有效地在我们的数据集中隔离它们的记录。 我们该如何做呢? 嗯,我们知道我们对获得多笔贷款的公司感兴趣,这意味着他们的 BorrowerName 应该 在我们的数据中出现多次。 我们还知道在 2021 年 1 月 13 日之前没有发放第二轮贷款。 通过结合这两个观点,我们可能可以利用我们的数据整理技能来比较好地识别请求第二轮贷款 2 百万美元的公司。

为了实现这一点,我们将对我们的数据集进行一些关键的转换:

  1. 我们将为每笔贷款创建一个新列,包含 first_roundmaybe_second 标签,具体取决于它是否在 2021 年 1 月 13 日之前发放。 虽然我们不能确定所有在那之后发放的贷款都是“第二轮”,但我们可以确定在那之前发放的所有贷款都是“第一轮”。

  2. 在我们的数据集中查找重复条目。 每笔批准的贷款都会创建一个单独的记录,因此如果同一家企业获得了两笔贷款的批准,那么其信息在记录中将出现两次。

这里的逻辑是,如果我们在我们的数据中找到了给定的企业名称两次,并且这些记录具有不同的“轮次”标签,这可能表明实际上这是一家已获批两笔独立贷款的企业。

通常情况下,我们将调用一些 Python 库来完成这项工作。 我们需要像往常一样使用 pandas,但我们还将使用另一个名为 numpy 的库,它具有许多有用的数组/列表函数(pandas 在幕后实际上严重依赖于 numpy)。 我还将再次引入 seabornmatplotlib,以便我们在进行数据集演变评估时有生成可视化的选项。

尽管我们在处理这些数据时的概念是相当直观的,但在执行此分析时所涉及的整理工作却需要进行相当多的步骤,正如您在示例 9-4 中所看到的。

示例 9-4. who_got_2_loans_by_date.py
# `pandas` for data loading/transformations
import pandas as pd

# `seaborn` for visualization
import seaborn as sns

# `matplotlib` for detailed visualization support
import matplotlib.pyplot as plt

# `numpy` for manipulating arrays/lists
import numpy as np

# load our data
ppp_data = pd.read_csv('public_150k_plus_borrower_fingerprint_a.csv') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# convert the `DateApproved` column to an actual datetime data type
ppp_data['DateApproved'] = pd.to_datetime(ppp_data['DateApproved']) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# create a variable to hold the second-round start date
second_round_start =  pd.to_datetime('2021-01-13')

# treat today's date to use as the "upper" limit on possible second-round loans
todays_date = pd.to_datetime('today')

# use 1/1/2020 as a "lower" limit, since it's before the PPP launched
program_start = pd.to_datetime('2020-01-01')

# pass our boundaries and category labels to the pandas `cut()` function
loan_round = pd.cut(ppp_data.DateApproved,
                    bins=[program_start,second_round_start, todays_date],
                    labels=['first_round', 'maybe_second']) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)

# insert the new column at the position we specify
ppp_data.insert(2,'Loan Round',loan_round)

# this "pivot table" will return a Series showing the number
# of times a particular 'BorrowerNameFingerprint' appears in the dataset
loan_count = ppp_data.pivot_table(index=['BorrowerNameFingerprint'], aggfunc='size')

# convert our Series to a DataFrame and give it a name
loan_count_df = loan_count.to_frame('Loan Count') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)

# use the `describe()` method to print out summary statistics
print("Description of duplicate borrower table:")
print(loan_count_df.describe()) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/5.png)

1

此文件是通过对BorrowerName运行我们的指纹识别过程生成的,详见“找到指纹”。

2

我们想知道在 2021 年 1 月 13 日之前批准的贷款。这样做的最快方法是将我们的DateApproved字符串转换为“真实”日期,并将其与之比较。

3

pandas 的cut()函数允许我们通过对现有列应用边界和标签来创建新列。在这种情况下,我们根据是否在 2021 年 1 月 13 日之前批准来为每条记录标记。

4

为了方便起见,我们可以使用describe()方法。

5

我们预计此表中的最大值将为2,因为根据 PPP 的规定,任何企业都不得获得超过两笔贷款。

如果您运行示例 9-4 中的代码,一分钟内什么都没有发生,不要泄气。在我的 Chromebook 上,这个脚本大约需要 40 到 90 秒的时间来执行(取决于我同时运行的其他 Linux 应用程序数量)。^(10) 但是,执行完成后,您的输出将类似于以下内容:

Description of duplicate borrower table:
          Loan Count
count  694279.000000
mean        1.104022
std         0.306489
min         1.000000
25%         1.000000
50%         1.000000
75%         1.000000
max        12.000000

从这次初步尝试来看,似乎有些问题。我们的.describe()命令的输出为我们提供了一个快速获取几乎所有我们感兴趣的摘要统计信息的方法(这里标记的 Q1、中位数和 Q3 根据直方图上出现在其左侧的值的百分比进行标记,分别为 25%、50%和 75%)。这些值表明,少于 25%的所有企业获得了多于一笔贷款(否则 75%的值将大于 1),这是有道理的。但是最大值令人担忧,因为 PPP 规则似乎不允许单个企业获得超过两笔贷款,更不用说 12 笔了!让我们通过在我们在示例 9-4 中编写的内容中添加示例 9-5 中显示的代码来仔细查看。

示例 9-5. who_got_2_loans_by_date.py(续)
# start by sorting our DataFrame of loan counts from greatest to least
sorted_loan_counts = loan_count_df.sort_values(by=['Loan Count'], ascending=False)

# create a new DataFrame with *only* those that have more than two loans
more_than_two = sorted_loan_counts[sorted_loan_counts['Loan Count'] > 2]

# print one instance of each business name that appears in `more_than_two`
print("Businesses that seem to have gotten more than 2 loans:")
print(more_than_two.shape)

print("Number of businesses that appear to have gotten precisely 2 loans:")
precisely_two = sorted_loan_counts[sorted_loan_counts['Loan Count'] == 2]
print(precisely_two.shape)

现在我们得到了以下额外的输出:

Businesses that seem to have gotten more than 2 loans:
(58, 1)
Number of businesses that appear to have gotten precisely 2 loans:
(72060, 1)

这表明可能只有(相对)少数企业可能获得了超过两笔贷款,我们可以将这些情况归因于我们选择的指纹方法(BorrowerNameBorrowerCityBorrowerState的组合),以及相同城市中申请 PPP 资金的单个特许经营可能存在多个实例的可能性。无论如何,这些情况足够少,不太可能对我们的分析结果产生重大影响,因此我们暂时不会专注于追踪它们的细节。至少第二段输出表明,72060 家企业恰好获得了两笔贷款似乎到目前为止是合理的,因为这明显少于我们总数据集的 25%,因此与我们的Loan Count数据框架得到的汇总统计数据一致(因为 Q3 的值仍然为 1,这意味着少于 25%的所有企业名称在我们的数据集中出现了超过一次)。

当然,这仍然只是一个估算;如果我们有更正式的第二轮贷款计数会更好。正如第六章结尾所述,小企业管理局确实发布了官方数据字典供 PPP 贷款数据使用,尽管它并没有包含我们希望看到的所有信息,但它确实表明ProcessingMethod字段区分了第一轮(PPP)和第二轮(PPS)贷款。让我们这样来看待我们的数据,并通过在我们的文件中进一步添加示例 9-6 中的代码,与我们基于姓名匹配的估算进行比较。

示例 9-6. who_got_2_loans_by_date.py(继续)
# use `ProcessingMethod` value to identify second-round loans
pps_loans = ppp_data[ppp_data['ProcessingMethod'] == 'PPS']

# print out the `shape` of this DataFrame to see how many businesses we have
print("Number of loans labeled as second round:")
print(pps_loans.shape)

重新运行我们的脚本会产生额外的输出:

Number of loans labeled as second round:
(103949, 52)

哇!即使我们可能的指纹方法过于宽松,我们仍然未能找到超过 30 万家企业同时获得两笔贷款。我们该怎么办?

首先,请意识到这并不是一个不寻常的情况。我们处理大约 75 万条数据记录,每一条记录都是多个个体(包括借款人、贷款人,可能还包括 SBA)输入的组合。仍然存在这么多的差异并不令人惊讶(我在以下边栏中列举了一些),但并非一切都无法挽回。记住,我们最初感兴趣的是那些在第二轮贷款中恰好获得 200 万美元的企业,这可能只是获得两笔贷款的所有企业中的一小部分。我们仍然可以继续进行分析的这部分内容,以便于:(1)测试我们基于日期的第二轮贷款估算的有效性,以及(2)看看我们能从那些第二轮贷款中恰好获得 200 万美元的特定企业子集中学到什么。

此时,我们将使用 PaymentProcessingMethod 列的信息来验证我们先前使用名称匹配和基于日期的贷款轮次估计的工作。为此,我们将把我们的 Loan Count DataFrame 合并回我们的原始数据集。然后,我们将仅选择我们估计是第二轮贷款的$2M 贷款,基于它们的日期。最后,我们将比较那些使用 ProcessingMethod 值为 PPS 的已知$2M 第二轮贷款的数量。显然,这将意味着在我们的文件中添加更多代码,如 示例 9-7 所示。

示例 9-7. who_got_2_loans_by_date.py(继续更多)
# how many loans in our derived data frame were approved for precisely $2M
# during the (possibly) second-round timeframe?

# merge our `loan_count_df` back to keep track of businesses
# we labeled as having precisely two loans
ppp_data_w_lc = pd.merge(ppp_data, loan_count_df,
                         on=['BorrowerNameFingerprint'], how='left')

# now get *all* the records of business names we associated with two loans
matched_two_loans = ppp_data_w_lc[(ppp_data_w_lc['Loan Count'] == 2)]

# select those loans our `maybe_second` loans that have a value of $2M
maybe_round2_2M = matched_two_loans[(matched_two_loans[
                                    'CurrentApprovalAmount'] == 2000000.00) &
                                    (matched_two_loans[
                                    'Loan Round'] == 'maybe_second')]
print("Derived $2M second-round loans:")
print(maybe_round2_2M.shape)

# select those loans that we *know* are second round and have a value of $2M
pps_got_2M = pps_loans[pps_loans['CurrentApprovalAmount'] == 2000000.00]
print("Actual $2M second-round loans:")
print(pps_got_2M.shape)

将这段代码添加到我们的主文件中,可以再输出几行:

Derived $2M second-round loans:
(1175, 53)
Actual $2M second-round loans:
(1459, 52)

如果我们将这些结果与以前的结果进行比较,看起来我们做得更好了一些。在所有贷款中,我们似乎匹配了 103,949 个实际第二轮贷款中的 72,060 个,约占 70%。对于获得$2M 第二轮贷款批准的组织,我们找到了 1,459 个中的 1,115 个,约占 80%。

那么我们能对得到第二轮$2M 贷款的企业说些什么呢?除非我们找到那些 BorrowerNameFingerprint 在其第一轮和第二轮贷款之间不同的 284 家公司的匹配,否则我们无法百分之百自信地说出任何话。但是,我们仍然可以看看我们的 80%样本,并看看我们发现了什么。为此,我将采取以下步骤:^(13)

  1. 查找所有确实获得了$2M 第二轮贷款的企业的唯一 BorrowerNameFingerprint 值。

  2. 根据这个列表创建一个 DataFrame (biz_names_df),并用标志值 2Mil2ndRnd 填充它。

  3. 将这个 DataFrame 合并回我的数据集,并使用标志值来获取所有这些企业的贷款记录(第一轮和第二轮)。

  4. 对这些企业在两轮中获得的贷款金额进行一些基本分析,并可视化这些金额,比较官方第二轮指定(即 ProcessingMethod == 'PPS')与我们基于日期推断的类别。

当然,现在我已经在列表中写出了我的脚本应该采取的步骤(这正是你应该放在你的数据日记和/或程序大纲中的内容),现在只需在我们现有的工作下编码;为了清晰起见,我把它放在了第二个脚本文件中,完整的代码如 示例 9-8 所示。

示例 9-8. who_got_2M_with_viz.py
# `pandas` for data loading/transformations
import pandas as pd

# `seaborn` for visualization
import seaborn as sns

# `matplotlib` for detailed visualization support
import matplotlib.pyplot as plt

# `numpy` for manipulating arrays/lists
import numpy as np

# load our data
ppp_data = pd.read_csv('public_150k_plus_borrower_fingerprint_a.csv')

# convert the `DateApproved` column to an actual datetime data type
ppp_data['DateApproved'] = pd.to_datetime(ppp_data['DateApproved'])

# create a variable to hold the second-round start date
second_round_start =  pd.to_datetime('2021-01-13')

# treat today's date to use as the "upper" limit on possible second-round loans
todays_date = pd.to_datetime('today')

# use 1/1/2020 as a "lower" limit, since it's before the PPP launched
program_start = pd.to_datetime('2020-01-01')

# pass our boundaries and category labels to the pandas `cut()` function
loan_round = pd.cut(ppp_data.DateApproved,
                    bins=[program_start,second_round_start, todays_date],
                    labels=['first_round', 'maybe_second'])

# insert the new column at the position we specify
ppp_data.insert(2,'Loan Round',loan_round)

# this "pivot table" will return a Series showing the number
# of times a particular 'BorrowerNameFingerprint' appears in the dataset
loan_count = ppp_data.pivot_table(index=['BorrowerNameFingerprint'],
                                  aggfunc='size')

# convert our Series to a DataFrame and give it a name
loan_count_df = loan_count.to_frame('Loan Count')

# use the `describe()` method to print out summary statistics
print("Description of duplicate borrower table:")
print(loan_count_df.describe())

# start by sorting our DataFrame of loan counts from greatest to least
sorted_loan_counts = loan_count_df.sort_values(by=['Loan Count'],
                                               ascending=False)

# create a new DataFrame with *only* those that have more than two loans
more_than_two = sorted_loan_counts[sorted_loan_counts['Loan Count'] > 2]

# print one instance of each business name that appears in `more_than_two`
print("Businesses that seem to have gotten more than 2 loans:")
print(more_than_two.shape)

print("Number of businesses that appear to have gotten precisely 2 loans:")
precisely_two = sorted_loan_counts[sorted_loan_counts['Loan Count'] == 2]
print(precisely_two.shape)

# use `ProcessingMethod` value to identify second-round loans
pps_loans = ppp_data[ppp_data['ProcessingMethod'] == 'PPS']

# print out the `shape` of this DataFrame to see how many businesses we have
print("Number of loans labeled as second round:")
print(pps_loans.shape)

# how many loans in our derived data frame were approved for precisely $2M
# during the (possibly) second-round timeframe?

# merge our `loan_count_df` back to keep track of businesses
# we labeled as having precisely two loans
ppp_data_w_lc = pd.merge(ppp_data, loan_count_df,
                         on=['BorrowerNameFingerprint'], how='left')

# now get *all* the records of business names we associated with two loans
matched_two_loans = ppp_data_w_lc[(ppp_data_w_lc['Loan Count'] == 2)]

# select those loans our `maybe_second` loans that have a value of $2M
maybe_round2_2M = matched_two_loans[
                    (matched_two_loans['CurrentApprovalAmount'] == 2000000.00) &
                    (matched_two_loans['Loan Round'] == 'maybe_second')]
print("Derived $2M second-round loans:")
print(maybe_round2_2M.shape)

# select those loans that we *know* are second round and have a value of $2M
pps_got_2M = pps_loans[pps_loans['CurrentApprovalAmount'] == 2000000.00]
print("Actual $2M second-round loans:")
print(pps_got_2M.shape)

# isolate the fingerprints of businesses that got $2M second-round loans approved
biz_names = pd.unique(pps_got_2M['BorrowerNameFingerprint'])

# convert that list to a DataFrame
biz_names_df = pd.DataFrame(biz_names, columns=['BorrowerNameFingerprint'])

# create a new array of the same length as our biz_names_df and fill with
# a flag value
fill_column = np.full((len(biz_names),1), '2Mil2ndRnd')
biz_names_df['GotSecond'] = fill_column

# now merge this new, two-column DataFrame back onto our full_data list,
# so that we (hopefully) find their first-round loans as well
second_round_max = pd.merge(ppp_data_w_lc, biz_names_df,
                            on='BorrowerNameFingerprint')

# now all the loans that share fingerprints with the ones that got the max
# amount in the second round should have the flag value '2Mil2ndRnd' in the
# 'GotSecond' column
second_max_all_loans = second_round_max[
                                second_round_max['GotSecond'] == '2Mil2ndRnd']

# we expect this to be twice the number of businesses that received $2M
# second-round loans
print('Total # of loans approved for most orgs that got $2M for second round:')
print(second_max_all_loans.shape)

# how much money were these businesses approved to get from the PPP, total?
total_funds = second_max_all_loans['CurrentApprovalAmount'].sum()
print("Total funds approved for identified orgs that could have " + \
      "second-round max:")
print(total_funds)

# plot our date-based `Loan Round`-labeled data next to records
# separated by `ProcessingMethod`. Do we get the same results?

# set the seaborn theme
sns.set_theme(style="whitegrid")

# use `matplotlib` `subplots()` to plot charts next to each other
# use `tuples` to access the different subplots later
fig, ((row1col1, row1col2)) = plt.subplots(nrows=1, ncols=2)

# plot the histogram of our date-based analysis
date_based = sns.histplot(data=second_max_all_loans, x='CurrentApprovalAmount',
                          hue='Loan Round', ax=row1col1)

# plot the histogram of our data-based analysis
data_based = sns.histplot(data=second_max_all_loans, x='CurrentApprovalAmount',
                          hue='ProcessingMethod', ax=row1col2)

# show the plot!
plt.show()

运行这个脚本将给我们提供所有以前示例的输出,再加上几行额外的输出:

Total # of loans approved for most orgs that got $2M for second round:
(2634, 54)
Total funds approved for identified orgs that could have second-round max:
6250357574.44

起初,看起来有些不对劲,因为我们可能期望我们的总贷款数量为 2 × 1,175 = 2,350。但请记住,我们根据第二轮是否批准了恰好$2M 的贷款以及我们未能根据BorrowerNameFingerprint匹配 284 笔贷款。这意味着我们拥有所有第二轮贷款,但在这些数字中缺少 284 笔第一轮贷款。换句话说,我们预计应该有(2 × 1,175) + 284 = 2,634 — 而确实如此!好极了!总是很好当某些事物能够匹配。这意味着,尽管我们的“总”数字仍然不是 100%准确,但这个估算是对这些企业在 PPP 基金中获批的最小总贷款金额的一个相对合理的估计:约 60 亿美元。

最后,让我们看一下在图 9-6 中显示的可视化,这是由脚本生成的视图,我们可以比较我们的Loan Round分类如何与指定的PPS贷款相匹配。这是验证我们工作的一个粗略(但仍然有用)方法,结果看起来相当不错!^(14)

接受两笔 PPP 贷款的企业中获得最多批准贷款的金额,按贷款轮次计算。

图 9-6. 对于接受两笔 PPP 贷款的企业,大多数批准贷款的金额,按贷款轮次计算

有趣的是,图 9-6 还说明了另一件事情:似乎有相当数量的企业在第二轮获得$2M 贷款时违反了我们之前的假设,即第一轮贷款时这些公司获得的金额更多,当时的限额更高。像往常一样,解答一个问题时,我们又产生了另一个问题!当然,我们已经完成的工作将为我们回答它提供一个起点。在我们继续下一个问题与回答之前,我们需要谈论数据分析和解释的另一个重要组成部分:比例性

比例响应

想象一下,你和一些朋友出去吃饭。你刚吃过,所以你只点了一杯饮料,但是你的三个朋友非常饥饿,每人都点了一份完整的餐。当账单到来时,你们如何决定谁该付多少?我们大多数人会同意,最明智的做法是计算或至少估算每个人点的菜单在总账单中占的比例,然后让每个人按此比例付款,还包括对应的税和小费。

当我们分析数据时,同样的逻辑也适用。在“2 百万美元的问题”中,我们看到一些特定类型的企业通过 PPP 获得的资金,尽管 60 亿美元听起来很多,我们应该更关心这些企业如何使用这些资金,而不是它们获得的绝对美元数。由于 PPP 旨在保持人员在岗,我们可能想知道这些企业收到的资金与它们保存的工作岗位数量有多大关系,这个过程我称之为理性化数据。^(15)

幸运的是,理性化我们的数据过程非常简单:我们通过将一个数字除以另一个数字来计算两个数据值之间的比率。例如,如果我们想知道在“2 百万美元的问题”中识别的公司每份工作花费多少美元,我们可以(经过一些理智检查后)将PAYROLL_PROCEED的值除以每条记录中JobsReported的值,如在示例 9-9 中所示。

示例 9-9. dollars_per_job_2M_rnd2.py
# `pandas` for data loading/transformations
import pandas as pd

# `seaborn` for visualization
import seaborn as sns

# `matplotlib` for customizing visuals
import matplotlib.pyplot as plt

# `numpy` for manipulating arrays/lists
import numpy as np

# load our data
ppp_data = pd.read_csv('public_150k_plus_borrower_fingerprint_a.csv')

# first, sanity check our data
print(ppp_data[ppp_data['JobsReported'] <= 0]) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# drop the records with no value in `JobsReported`
ppp_data.drop(labels=[437083,765398], axis=0) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# calculate the dollars per job
dollars_per_job = ppp_data['CurrentApprovalAmount']/ppp_data['JobsReported']

# insert the new column into our original dataset
ppp_data.insert(3, 'Dollars per Job', dollars_per_job)

# use `ProcessingMethod` value to identify second-round loans
pps_loans = ppp_data[ppp_data['ProcessingMethod'] == 'PPS']

# select all second-round loans that have a value of $2M
pps_got_2M = pps_loans[pps_loans['CurrentApprovalAmount'] == 2000000.00]
print("Actual $2M second-round loans:")
print(pps_got_2M.shape)

# pull fingerprints of businesses approved for $2M second-round loans
biz_names = pd.unique(pps_got_2M['BorrowerNameFingerprint'])

# convert that list to a DataFrame
biz_names_df = pd.DataFrame(biz_names, columns=['BorrowerNameFingerprint'])

# create an array of the same length as `biz_names_df`; fill with flag value
fill_column = np.full((len(biz_names),1), '2Mil2ndRnd')
biz_names_df['GotSecond'] = fill_column

# now merge this new, two-column DataFrame back onto our full_data list
second_round_max = pd.merge(ppp_data, biz_names_df, on='BorrowerNameFingerprint')

# all loans whose fingerprints match those of businesses that got $2M
# in the second round should have `2Mil2ndRnd` in the `GotSecond` column
second_max_all_loans = second_round_max[
                                second_round_max['GotSecond'] == '2Mil2ndRnd']

# sbould be 2x the number of businesses approved for $2M second-round
print('Total # of loans approved for most orgs that got $2M for second round:')
print(second_max_all_loans.shape)

# how much money were these businesses approved to get from the PPP, total?
total_funds = second_max_all_loans['CurrentApprovalAmount'].sum()
print("Total funds approved for identified orgs that could have " + \
      "second-round max:")
print(total_funds)

# now, let's plot that new column on our selected dataset

# set the seaborn theme
sns.set_theme(style="whitegrid")

# the `matplotlib` `subplots()` to plot charts side by side
fig, ((row1col1)) = plt.subplots(nrows=1, ncols=1)

# plot the histogram of our date-based analysis
date_based = sns.histplot(data=second_max_all_loans, x='Dollars per Job',
                          hue='ProcessingMethod', ax=row1col1)

# show the plots!
plt.show()

1

结果表明,有几家企业没有报告任何工作岗位,这将破坏我们的计算。由于只有两条记录有此问题,我们将它们删除,使用它们pandas分配的行标签。

尽管这里的文本输出确认我们正在查看在“2 百万美元的问题”中检查过的相同一组贷款,我们的理性化数据突显了一些首轮贷款中的显著异常,其中少数公司似乎已经获得了超过每份工作 10 万美元的限制的贷款,如图 9-7 所示。

公司获得的第二轮贷款的每份工作的详细情况

图 9-7. 公司获得的第二轮 2 百万美元贷款的每份工作的详细情况

我们怎么看待这一点呢?您可能会注意到,到现在为止,我们已经偏离了我们在“大流行和 PPP”中提出的问题,我们最初试图评估 PPP 是否帮助“拯救”美国企业。虽然这一焦点帮助我们通过数据质量评估,但进行一些背景分析已经引发了许多新的问题和方向——我认为当涉及数据整理时,这种情况非常普遍。希望这将鼓励您继续使用新数据集,看看您还能发现什么!

结论

在所有这些分析之后,我们学到了一些新东西——其中一些特定于此数据集,但许多则更具一般适用性:

  • 在 PPP 贷款计划中,只有相对少数公司被批准获得最大允许的第二轮金额。虽然他们中的许多公司在第一轮贷款中申请了比这更多的金额,但也有一些没有。

  • 少数公司在第一轮贷款中声称每报告的工作超过 10 万美元的情况下,被批准获得 200 万美元的第二轮贷款。

  • 人工录入的数据总是一团糟。这就是为什么数据清洗是一个持续的、迭代的过程。记录你的工作对于能够捍卫你的结果至关重要。

因此,我们的入门数据分析给我们留下了比答案更多的问题。在这一点上,我们只有一种方法可以了解更多信息:与人交谈。当然,我们发现的一些模式看起来有些可疑,但我们有太多未知因素,无法在这一点上提出任何有力的声明。例如,许多第二轮 200 万美元贷款在数据发布时尚未拨款,因此一些公司可能实际上获得或使用的远少于这个数额。由于 PPP 规则只要求贷款的最低百分比用于工资支出才能免除,看起来获得了过多贷款的公司可能只是将差额用于其他允许的支出,如抵押利息或医疗费用。换句话说,尽管我们可以从这种类型的数值数据分析中学到一些东西,但这永远不足以告诉我们整个故事——无论是如何还是为什么。这是我们需要直接人类输入的事情。

一旦我们完成了这项工作,并清楚了我们想要分享什么见解,我们就准备好开始考虑如何最有效地向他人传达我们所学到的内容。正如我们的数据分析依赖于数据和人类判断和输入一样,最有效的数据沟通几乎总是涉及文字和可视化之间的平衡。正如我们将在下一章看到的那样,通过精心制作我们的文字和可视化,我们可以更好地确保我们打算传达的信息真正被听到。

^(1) 详见可预测的非理性,作者丹·阿里尔(Harper)获取更多信息。

^(2) 从技术上讲,您也可以按从高到低的顺序排序,但从较低值开始是传统的,并且从长远来看会更容易些。

^(3) 实际上,有多种方法可以选择偶数个数据点的中位数值,但只要你保持一致,任何一种方法都可以。从经验上讲,这种方法感觉最直观,我也看到它被最频繁地使用。

^(4) 虽然关于人类可以在工作记忆中保存的项目数量的精确估计不同,但研究人员确实同意这种能力是有限的。请参阅https://ncbi.nlm.nih.gov/pmc/articles/PMC2864034https://pnas.org/content/113/27/7459

^(5) 尽管还有很长的路要走,但是对于盲人或视觉受损者来说,关于减少这些方法对视觉的依赖的研究非常令人振奋。参见触觉图形的令人振奋研究,尤其是http://shape.stanford.edu/research/constructiveVizAccess/assets20-88.pdf

^(6) 请参阅罗伯特·R·索卡尔和普雷斯顿·E·亨特的《关于 DDT 抗性和非抗性家蝇品系的形态计量分析》,https://doi.org/10.1093/aesa/48.6.499;相关数据在那里提供。

^(7) M. W. Toews,《知识共享署名 2.5》,https://creativecommons.org/licenses/by/2.5,通过维基媒体共享

^(8) 特别是相对于稍高或稍低于这些值的贷款金额。

^(9) 它就这么发生了。

^(10) 如果太,输出将显示Killed。这表明你可能需要关闭一些应用程序或者考虑迁移到云端。

^(11) 请参阅https://sba.gov/document/support-faq-ppp-borrowers-lendershttps://sba.gov/document/support-sba-franchise-directory获取更多信息。

^(12) 尽管你可以在文件ppp_fingerprint_borrowers.py中找到它。

^(13) 请注意,我故意以稍微迂回的方式进行这个过程,以展示更多数据整理和可视化策略,但是作为练习,随时可以重新调整这段代码,使其更高效!

^(14) 如果我们在数值上进行比较,我们会发现它们是完全相同的,至少对于我们第二轮获得 200 万美元批准的公司子集来说。

^(15) 这个术语在商业和统计/数据科学领域有更具体的含义,但是比例化听起来有点奇怪。而且,它更符合实际的计算过程!

第十章:展示您的数据

在我们已经投入访问、评估、清理、转换、增强和分析数据的所有努力之后,我们终于到达了一个阶段,即准备开始考虑向他人传达我们所学到的东西。无论是为了向同事进行正式演示,还是为了向朋友和追随者发布社交媒体帖子,通过我们的数据整理工作生成的见解分享是一个机会,使我们的工作能够超越个人产生影响。

就像我们数据整理过程的每个部分一样,有效和准确地传达我们的见解涉及应用少数一成不变的规则,但更多的是判断力。这当然适用于书面沟通,但当涉及到数据沟通的方面时,可能更是如此,因为这部分通常引起最多关注:可视化

正如我们在“数据分析可视化”中提到的,为了有效地与他人分享我们的数据见解,创建可视化图表需要与生成这些见解时的焦点和方法不同的关注点。例如,除非您试图接触非常专业的受众(比如通过学术出版物),否则直方图在您分享研究发现时不太可能进入您的可视化词汇表中。同时,极有可能您会使用柱状图或柱形图的某种形式^(1)来与非专业人士分享您的见解,因为这些广泛使用且高度易读的图形形式对大多数观众来说相对容易准确解释。

换句话说,我们选择用来视觉呈现我们数据发现的方式,不仅应受我们拥有的(或用来得出结论的)数据的启发,还应受我们试图接触的受众的影响。许多软件包(包括我们已经使用过的一些软件包)如果您只需指向一个结构化数据集并传入几个可选参数,它们将乐意生成图表和图形。虽然这在最低限度上可以产生可视化效果,但其输出更像是机器翻译而不是诗歌。是的,在高层次上它可能符合语言的“规则”,但其意义的清晰度(更不用说其有效性或雄辩性)是值得怀疑的。因此,利用可视化使我们的数据见解真正可访问他人,需要仔细考虑您想要传达的内容哪种视觉形式最适合以及如何能够根据您的特定需求进行定制。

在本章的过程中,我们将依次涵盖每一个任务,从一些旨在帮助您识别数据发现中关键点的策略开始。之后,我们将回顾数据最常见(并且最有用!)的可视形式。在每种情况下,我们将讨论使用它们的规则和最佳实践,以及使用seabornmatplotlib库在 Python 中渲染它们的基本代码。最后,我们将以真实数据的基本可视化为例,逐步介绍如何定制和完善其各个元素,以将(通常)可用的默认呈现转变为准确吸引人的东西。在此过程中,我希望您能接触到至少几种新工具和方法,这些工具和方法将帮助您更加批判性地思考数据可视化——无论它是您自己数据处理努力的结果与否。

视觉修辞的基础

正如我已经多次提到的,编写优秀代码的过程在大多数其他情况下与写作一样。例如,在第八章中,我们花了时间修订和重组代码,虽然它已经运行,但最终演变成了更清晰、更干净和更可重复使用的脚本和函数。从高层次来看,这个过程与您修改文章或论文的方式并没有太大不同:一旦您把所有关键想法收集到一起,稍后可以回到这篇文章,看看如何重新措辞和重新组织,使文章更为简洁,概念更为逻辑。

尽管相同的写-编辑-润色循环适用于数据可视化,但我们正在处理的单元与文章非常不同,更像一个段落——因为一般来说,一个可视化应该用来传达一个单一的思想。无论您的可视化是印刷版还是数字版,静态还是交互式,无论它是长篇演讲的一部分还是将成为独立的社交媒体帖子。一个可视化 = 一个关键思想。

我现在强调这一点,因为如果你来到这一章希望看到如何构建Gapminder 风格的互动图表或详细的流图,我想要立即让你失望:在这一章中,我的重点将放在最常用的可视化类型上,如柱状图、折线图和条形图。部分原因是因为它们仍然是表示只有一个自变量的数据最简单和最可解释的方式,而这正是你大多数时候应该尝试呈现的。是的,更复杂的可视化可以用于绘制多个自变量——最初的 Gapminder 可视化是一个很好的例子——但没有一位可爱的瑞典男子实时指导观众,它们更像是漂亮的玩具而不是信息工具。这就是为什么我们这里的重点将放在将可访问的视觉形式精炼为我喜欢称之为流畅图形的东西上——像最好的文本一样,清晰、简单和易于访问地传达信息。虽然制作流畅的图形并不排除视觉复杂性甚至交互性,但它确实要求视觉的每个方面都有助于图形的清晰度和意义。

实现这种视觉流畅性意味着在三个主要阶段考虑数据可视化:

1. 优化你的关注点

你究竟想传达什么?在某些方面,这类似于选择你的原始数据处理问题的过程:无论你的处理和分析揭示了什么,你都需要在每个可视化中传达一个想法。你如何知道自己是否有效地完成了这个任务?大多数情况下,这意味着你能够用一个句子表达它。与你早期的数据处理问题一样,你制定的数据陈述将作为制定可视化选择的“基本事实”。任何有助于你更清晰地传达你的想法的东西都会被保留;其他所有东西都会被删除。

2. 寻找适合的视觉形式

你的数据最适合以柱状图还是折线图显示?是地图吗?饼状图?散点图或气泡图?确定数据的最佳视觉形式总是需要一些试验。与此同时,选择一种视觉形式来表达你的数据陈述不仅仅是偏好或口味的问题;关于某些类型的数据和数据关系如何视觉编码有一些不容置疑的规则。是的,美学确实在可视化的效果中起着作用,但它们不能取代准确性的需要。

3. 提升清晰度和意义

即使确定了主要的视觉形式,还有许多方法可以改善或降低您的可视化的清晰度、可访问性、视觉吸引力和雄辩性。至少,您需要在颜色、图案、比例尺和图例以及标签、标题和注释之间做出决策。如果您的数据陈述特别复杂,您需要仔细地增加更多的视觉结构来捕捉这些细微差别,例如误差条或不确定性范围,或者可能是预测和/或缺失的数据。

在接下来的几节中,我们不仅会概念性地讨论每个阶段,还会利用实际数据来看看它们在实践中如何使用 Python。

作出你的数据陈述

许多年前,我有幸邀请到《纽约时报》的Amanda Cox作为我的数据可视化课程的特邀讲师,她分享了一个评估特定数据陈述是否适合可视化的绝佳提示:“如果你的标题中没有动词,那就有问题。”

浅尝辄止的话,当然,这个要求很容易满足。^(2) 然而,她的陈述精神暗示了一种更为严格的要求:你的图形标题应清晰地表达某种重要的关系或主张,并且支持证据应该在图形本身可见。为什么这么重要?首先,将你的主张直接放在标题中会鼓励读者首先你的图形;清晰地命名帮助确保观众确实会——字面上——知道在哪里寻找这些主张的支持证据。当然,作为信息设计师,我们的工作是确保我们所有图形的视觉线索也这样做,但往往正是标题吸引了人们的注意

如果你简单地不能在你的图形标题中找到一个动作动词,这是一个提示,即可视化可能是传达你的见解的最佳方式。诚然,在适当的情况下,人类可以非常迅速地处理可视化,但只有当可视化有所“表达”时才能实现这种优势。换句话说,虽然你可能能够生成一个标题为“一年的每日国库长期利率”的视觉上准确的图表,但现实是,即使是最大的政策狂热者也会想知道他们为什么要费心去看它。如果不是正确的工具,不要坚持可视化!记住,我们的目标是尽可能有效地传达我们的数据整理见解,而不是不惜一切代价地以视觉方式表达它们。通过首先专注于完善你的数据陈述,并确认它具有你需要的力量,你将避免花费大量时间设计和构建一个实际上并不做你想要的事情或你的观众需要的可视化。当然,基本的数据可视化可以使用数据集和一个强大的可视化库(如seaborn)快速生成。但是,制作真正雄辩的可视化需要仔细考虑以及对甚至最好的库的默认图表进行详细定制。因此,在你投入所有的时间和精力之前,确保你的复杂可视化不是从你的分析中提取的一个突出的统计数字会更好。

一旦你在那里确立了一个强有力的数据陈述“标题”,那么,现在是时候确定哪种图形形式将帮助你最有效地呈现数据证据来支持你的主张了。

图表、图形和地图:哦,我的天!

即使我们限制在更为直接的图形形式上,仍然有足够的选项可以使我们在找到最适合我们数据的最佳图形的过程中感到有点不知所措。你应该选择折线图还是条形图?如果条形图是最佳选择,它应该是水平的还是垂直的?饼图有时可以吗?不幸的是,这是一个情况,我们的 Python 库在这方面基本上是无法帮助我们的,因为通常它们只会英勇地尝试根据您提供的数据生成您要求的任何类型的图表。我们需要一个更好的方法。

这就是一个有着明确的数据陈述的重要性。您的陈述是否涉及绝对值,比如我们 PPP 贷款数据中的CurrentApprovalAmount,还是是否侧重于值之间的关系,就像“大流行使年度外国直接投资流量减少了三分之一”一样?虽然关于绝对值的声明通常最好通过条形图来表达,但关于关系的数据陈述可以通过更广泛的视觉形式来很好地支持。例如,如果您的数据陈述涉及随时间变化,那么折线图或散点图就是一个很好的开始。同时,一些视觉形式,如饼图和地图,很难适应除了单个时间点的数据之外的任何东西。

事实上,对于数据可视化,几乎没有硬性规则[³],但我已经在接下来的部分中概述了存在的规则——以及一些关于设计图形的一般提示。虽然这些准则将帮助您选择一种不会逆向您的数据的可视化形式,但这只是下一步。真正提升您的图形的选择是我们将在“优美视觉元素”中讨论的选择。在那一部分中,我们将超越(仍然非常出色的)seaborn的默认设置,开始更深入地研究matplotlib,以控制标签、颜色和注释等可以真正使您的可视化脱颖而出的内容。

饼图

饼图在可视化中是一个令人意外的极具争议性的话题。尽管饼图对教授儿童关于分数的知识很有帮助,但有很多人认为它们在有效的可视化词汇中几乎没有任何位置

我个人认为,有特定的情况——虽然有限——在这些情况下,饼图是支持您的数据陈述的最佳可视化方式。例如,如果您试图阐明您的数据中有多少比例份额具有特定值,并且其余值可以明智地分为四个或更少的类别,那么饼图可能是您想要的。特别是如果生成的图表突出显示与整体的一些“可识别”部分对应的值(例如 1/4、1/3、1/2、2/3 或 3/4),由于人眼能够在不费力的情况下检测到这些差异,因此这一点尤为真实。

例如,查看 2021 年 6 月纽约市民主党市长初选的结果,我们可以想象编写一个数据声明,如“尽管候选人众多,前三名候选人几乎占据了四分之三的第一选择票。”由于只有四名候选人获得了超过 10%的第一选择票,因此将所有其余候选人归为单一的“其他”类别也是合理的。在这种情况下,饼图是准确呈现结果并支持我们主张的完全合理的方式之一,部分原因是因为它使得我们可以轻松地看到领先者显著超越其他候选人。

鉴于饼图的争议性质,一般而言相当灵活的seaborn库中并没有提供饼图选项并不完全令人意外。然而,我们可以直接使用matplotlib来获得一个非常实用的饼图。^(4) 不过,在matplotlib的饼图功能中还存在一些特殊之处需要克服。例如,最佳实践要求饼图从最大部分开始,即“12 点钟”位置,其余部分按顺时针方向以降序添加。然而,在matplotlib中,第一部分从“3 点钟”位置开始,并以逆时针方向添加其他部分。因此,我们需要指定 startangle=90 并反转片段的从大到小的顺序。^(5) 同样,在matplotlib中,默认情况下,饼图的每个“片段”被分配了不同的颜色色调(例如紫色、红色、绿色、橙色和蓝色),这在某些类型的色盲人士中可能无法访问。由于我们的数据声明在概念上将前三名候选人分组,因此我将它们都设置为相同的绿色阴影;并且由于所有的候选人都来自同一政党,我将所有的片段保持在绿色系列中。要查看如何编码这种类型的图表(包括这些小的自定义),请参阅例子 10-1 和在图 10-1 中的结果可视化。

例子 10-1. a_humble_pie.py
import matplotlib.pyplot as plt

# matplotlib works counterclockwise, so we need to essentially reverse
# the order of our pie-value "slices"
candidate_names = ['Adams', 'Wiley', 'Garcia', 'Yang', 'Others']
candidate_names.reverse()
vote_pct = [30.8, 21.3, 19.6, 12.2, 16.1]
vote_pct.reverse()

colors = ['#006d2c','#006d2c', '#006d2c', '#31a354','#74c476']
colors.reverse()

fig1, ax1 = plt.subplots()
# by default, the starting axis is the x-axis; making this value 90 ensures
# that it is a vertical line instead
ax1.pie(vote_pct, labels=candidate_names, autopct='%.1f%%', startangle=90,
        colors=colors) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)
ax1.axis('equal')  # equal aspect ratio ensures that pie is drawn as a circle.

# show the plot!
plt.show()

1

我们传递给autopct的参数应该是“格式化字符串字面量”,也称为f-string。这个例子指定将分数表达为浮点数(小数点)到一位精度。双百分号符(%%)在这里用于打印输出中的一个单个百分号符号(通过用另一个百分号符号转义保留的百分号符号)。

纽约市初选饼图。

图 10-1. 纽约市初选饼图

总结一下,如果您考虑使用饼图:

规则

你的数据类别在概念上(字面上也是)必须总结为一个“整体”。

指南

类别数量应压缩到五个或更少。

指南

想要突出显示的数据比例应为总数的 1/4、1/3、1/2、2/3 或 3/4。

然而,如果你的数据不符合其中一个或多个要求,条形图可能是探索的下一个图形形式。

条形图和柱状图

条形图通常是突出显示离散、名义(与比例相对的)数据值之间关系的最有效方式。与饼状图不同,条形图可以准确表示数据值不总结为单一“整体”的数据集。它们还可以有效地表示正负值(如果需要同时表示),并且可以显示不同类别数据和数据值随时间的变化。这些条可以垂直(有时这些图形被描述为柱状图)或水平排列,以使标签和数据关系更加清晰可读。

换句话说,条形图极其灵活,提供了多种选项来有效展示数据声明的证据。然而,在使用条形图时有一个切实可行的规则数据值必须从零开始!尽管有些人试图这样做,但实际上并没有例外情况,详细规定见这里。为什么这条规则如此重要?因为将图表的条形起点设置为一个非零数字意味着它们的长度视觉上的差异将不再与它们实际数值上的差异成比例。

例如,假设你在工作中为加薪辩护,目前的时薪为美国联邦最低工资标准每小时$7.25。你请求老板将你的小时工资提高到$9.19,以反映自 2009 年以来通货膨胀对最低工资的影响。

“好吧”,你的老板说,“让我们看看加薪会是什么样子”,然后向你展示了类似于图 10-2 所示的图表。

一张不准确的工资比较图

图 10-2. 一张不准确的工资比较图

看出问题了吗?通过将条形图的起点设置在 5 而不是零,图 10-2 使得看起来$9.19 每小时几乎翻倍了你当前的工资。但是简单的数学计算(9.19 - 7.25 / 7.25 = ~.27)表明,它只比你目前的收入多出 25%多一点。正如你所见,将条形图的起点设置为零不是品味、美学或语义问题——这是一种视觉谎言

即使是专业的图形团队有时也会出错。来看看凯撒·丰格在他的博客《垃圾图表》中突出显示的这个例子,《“工作文化”》并在图 10-3 中复制。

一个不准确的退休年龄比较

图 10-3. 一个不准确的退休年龄比较

在图 10-3 中,一个“破碎”的条形图声称显示了不同国家的男性停止工作的时间,与官方退休年龄相比。就像图 10-2 中一样,未从零开始的条形图严重地错误地表示了它们的真实价值差异:根据数据标签,法国男性的退休年龄比日本男性提前约 10 年 —— 工作年限相差 15%。但是日本的实际条形图长度超过法国的两倍。当然,如果所有条形图都从零开始,它们的值之间的差异就不会显得如此引人注目。

是怎么回事?这个可视化的设计者真的想要欺骗我们认为日本男性工作的时间是法国男性的两倍吗?几乎可以肯定不是。像图 10-3 中的这样的图形是表达一个想法每次可视化是如此重要的原因:试图叠加多于一个想法的内容是最有可能导致问题并最终得到不准确和误导性可视化的地方。在图 10-3 中,设计者试图展示两种不同的测量方法(官方退休年龄与男性停止工作的年龄之间的差异以及该年龄是什么)。它们的刻度是不兼容的。将条形图从零开始,读者将无法区分点放置的年龄;改变刻度以使点的放置清晰可读,而条形图变得不准确。无论哪种方式,你都让读者做了很多工作 —— 尤其是因为图形的标题没有告诉他们他们应该寻找什么 😉

让我们看看当我们从一个清晰的动作动词标题开始,并使用它来重新设计这个图形时会发生什么:“日本的男性在退休年龄之后工作多年,而其他国家则远早于此。” 这里,我们的标题/数据陈述是关于突出官方退休和实际退休之间的差异。 现在我们可以设计一个水平条形图,既支持这一说法,准确表示潜在数据,如示例 10-2 中所示和随后的图 10-4。

示例 10-2. retirement_age.py
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
import numpy as np

# (abbreviated) list of countries
countries = ['Japan', 'Iceland', 'Switzerland', 'France', 'Ireland', 'Germany',
             'Italy', 'Belgium']

# difference in years between official and actual retirement age
retirement_gap = [9, 2, 2, -1, -2, -2, -7, -8]

# zip the two lists together, and specify the column names as we make the DataFrame
retirement_data = pd.DataFrame(list(zip(countries, retirement_gap)),
               columns =['country', 'retirement_gap'])

# in practice, we might prefer to write a function that generates this list,
# based on our data values
bar_colors = ['#d01c8b', '#d01c8b', '#d01c8b', '#4dac26','#4dac26','#4dac26',
              '#4dac26','#4dac26']

# pass our data and palette to the `seaborn` `barplot()` function
ax = sns.barplot(x="retirement_gap", y="country",
                 data=retirement_data, palette=bar_colors) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# show the plot!
plt.show()

1

通过将我们的数值分配给 x 轴,并将分类值分配给 y 轴,seaborn 将呈现这个图表为一个水平而不是垂直的条形图。

退休差距水平条形图。

图 10-4. 退休差距水平条形图

因为我的数据说明/标题现在明确涉及官方退休年龄与实际退休年龄之间的差异,我选择直接绘制这种差异,并重新排列数据:法国男性仅比官方退休年龄早退休一年,而比利时男性大约提前退休八年。为了进一步突出官方退休年龄前后的差异,我还根据条形的正负值对其进行了颜色编码。

此数据集中正负值的混合——以及更长的国家名称标签——使这个图表作为水平条形图比垂直条形图更易读。然而,为了比较起见,如果我们想将其作为垂直图表测试,我们需要交换我们传递给barplot()函数的数据列作为xy参数。例如,通过更改如下内容:

ax = sns.barplot(x="retirement_gap", y="country", data=retirement_data,
                 palette=bar_colors)

到此:

ax = sns.barplot(x="country", y="retirement_gap", data=retirement_data,
                 palette=bar_colors)

尽管这种变化很容易实现,但在呈现数据集时,垂直或水平渲染的可读性可能存在真正的差异。具体来说,垂直条形图通常更适合具有较短标签、变化较小和/或几乎没有负值的数据,而水平条形图通常更适合差异较大的数据(特别是如果有大量负值)和/或具有较长标签的数据。

虽然图 10-4 中显示的可视化仍然相当简单,但如果你运行代码,你会自己看到例如在调色板上下功夫会给可视化带来的质量差异。虽然我在这里选择了二进制品红/绿色编码,但我也可以指定seaborn170 种调色板(例如,palette='BuGn'),这将(大部分地)使每个条形的颜色强度与其值对齐。

总结一下,在处理条形图时:

规则

条形图必须从零开始!

指导方针

垂直条形图适用于数据更密集、变化较小的情况。

指导方针

水平条形图更适合更多的变化和/或更长的标签。

折线图

当你的数据陈述关于变化率而不是值差异时,是时候探索折线图了。与条形图类似,折线图可以有效显示多个类别的数值数据,但只能显示它随时间变化的情况。然而,因为它们不直观地编码绝对数据值,折线图刻度需要从零开始。

起初,这可能看起来像是一种操纵的邀请——事实上,折线图一直处于一些重大政治争议的中心。^(6) 然而,对于条形图和折线图,真正驱动 y 轴刻度的是数据:正如我们不能决定将条形图从零开始,将 y 轴刻度扩展到最大数据测量的多倍是荒谬的,如图 10-5 所示。

另一个糟糕的工资比较图形

图 10-5. 另一个糟糕的工资比较图形

尽管在技术上是准确的,但图 10-5 中超长的 y 轴刻度已经将数据值压缩到我们的眼睛无法准确或有效区分它们的程度。因此,对于条形图,y 轴的最高值通常应在下一个“整数”标记的增量处(更多关于此内容,请参见“选择刻度”)。对于线图,可视化专家如唐娜·王建议数据值的范围应占据 y 轴空间的大约三分之二。^(7)

当然,这种方法突出了线图中数据点的选择对整体信息传达的影响。例如,请考虑来自经济学人的这张图,它在图 10-6 中重新制作。

2007-2020 年外国直接投资流动

图 10-6. 2007–2020 年外国直接投资流动(FDI)

在这种情况下,原始标题“疫情使年度外国直接投资流动减少三分之一”实际上相当有效;它既积极又具体。但是,虽然这个标题描述的数据包含在随附的图表中,但并未得到强调——尽管所述变化发生在 2019 年至 2020 年之间。如果我们修改图形,仅侧重于这两年发生的情况,如图 10-7 所示,我们既可以更清楚地支持数据陈述,能揭示数据的另一个维度:尽管“发达”国家的外国直接投资大幅下降,“发展中”地区却基本保持稳定。正如文章本身所述,“向富裕国家的流入速度下降得比向发展中国家的速度要快得多——分别下降了 58%和仅 8%。”

这种两点线图,也称为斜率图,不仅使读者能够轻松看到标题声明背后的证据,还使他们能够推断出疫情对“发达”与“发展中”国家外国直接投资的不均影响——从而为文章后来的声明提供证据。正如您在示例 10-3 中所看到的,生成这种基本线图只需几行代码。

疫情使年度外国直接投资流动减少三分之一。

图 10-7. 疫情使年度外国直接投资流动减少三分之一
示例 10-3. covid_FDI_impact.py
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
import numpy as np

# each individual array is a row of data
FDI = np.array([[0.8, 0.7], [0.3, 0.6]])

fdi_data = pd.DataFrame(data=FDI,
              columns=['Developed', 'Developing'])

ax = sns.lineplot(data=fdi_data)

# show the plot!
plt.show()

此时,你可能会想知道,仅包括两年数据是否在某种程度上是错误的。毕竟,我们手头上有(至少)十年的数据。

当然,真正的问题不在于我们是否有更多数据,而是我们所做的数据声明是否以某种方式误读了更广泛的趋势。查看 图 10-6 中的原始图表,显然 FDI 在过去 15 年左右只有两次如此迅速下降:分别是从 2007 年到 2008 年以及从 2016 年到 2017 年。为什么?我们不确定——无论是原始图表还是全文(我查过了!)都没有明确说明。我们确实知道的是价值的绝对变化(大约 5000 亿美元)和价值的比例变化足够独特,以至于仅关注一年的变化并不具有误导性。如果我们想向读者保证这一点,最好在表格中提供额外的细节,他们可以在其中详细查看精确的数字,而不会被主要观点分散注意力。

变化率(体现在每条线的斜率中)是数据声明信息的核心时,线图是必不可少的视觉形式。虽然这种类型的图表不需要从零开始,但它只能用于表示随时间变化的数据。回顾一下,在使用线图时,适用以下几点:

规则

数据点必须表示随时间的值。

指南

数据线应占垂直图表区域的大约 2/3。

指南

四条或更少的线应当有明显的颜色/标签区分。

虽然起初可能看起来违反直觉,但在线图上有大量数据线其实是可以的——只要它们的样式不与我们数据声明的证据竞争。正如我们将在下一节看到的那样,这种“背景”数据实际上可以成为为读者提供额外背景的一种有用方式,从而更有效地支持您的主要声明。

散点图

虽然散点图在一般数据传播中并不常用,但它们作为线图的时间点对应物可以是不可替代的,尤其是当您有大量数据点来展示明显趋势或与该趋势偏离时。

例如,考虑一下图 10-8 中的图形,该图形重现了来自这篇纽约时报故事中的一个图表,并说明了在 40 多年的时间里,从俄勒冈州波特兰市到加拿大温哥华市的城市中,2021 年 6 月的三个连续日的最高温度远高于预期范围。虽然标题可以更加生动,但可视化本身传达了一个明确的信息:波特兰市 2021 年 6 月下旬的三天最高温度超过了过去 40 年里的每一天。

1979-2021 年波特兰市每日最高气温。

图 10-8. 波特兰市 1979 年至 2021 年的每日最高气温

大多数情况下,散点图用于显示随时间(如图 10-8)或跨许多个体成员(例如“学校”、“大城市”、“河流供水的湖泊”或“漫威电影”)捕获的数据值。有时,散点图可能包括计算的趋势线,用作将个别数据点与“预期”平均值进行比较的基准;其他时候,基准可能是由专业、法律或社会规范确定的值。

例如,从一篇Pioneer Press关于某些学校的学生表现优于典型指标的报道中获得灵感,^(8)我们可以使用seaborn绘制加利福尼亚学校系统的历史数据,生成一个散点图并突出显示一个异常数据点。此示例的代码可以在示例 10-4 中找到。

示例 10-4. schools_that_work.py
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

# import the school test data
school_data = pd.read_csv("apib12tx.csv")

# plot test scores against the percentage of students receiving meal support
sns.scatterplot(data=school_data, x="MEALS", y="API12B", alpha=0.6, linewidth=0) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# highlight a high-performing school
highlight_school = school_data[school_data['SNAME'] == \
                               "Chin (John Yehall) Elementary"]
plt.scatter(highlight_school['MEALS'], highlight_school['API12B'],
            color='orange', alpha=1.0) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)

# show the plot!
plt.show()

1

alpha 参数控制点的不透明度;60% 的不透明度(作为十进制 0.6)在这里被证明是视觉上清晰以及点重叠的热图效果的正确平衡。linewidth=0 参数消除了每个点周围的轮廓,这会影响调整透明度时的热图效果。

2

要“突出显示”学校,我们基本上只是在所选数据点的 x 和 y 坐标处创建一个单点散点图。

使用散点图的一个关键挑战是遮挡问题,即数据点可能重叠在一起,从而遮挡了数据的真实密度。对此的一种处理方法是添加抖动——对个别点的放置添加少量随机性,旨在最小化视觉上的重叠。然而,在seaborn 0.11.2 中,抖动被列为可选参数,但被列为“不支持”。幸运的是,通过调整数据点的透明度或alpha,我们可以保留数据的精度而不失其可解释性。通过使可视化中的所有点都有些透明,重叠的数据点就形成了一种基于透明度的热图,这样就能清晰地展示趋势,而不失特异性,正如图 10-9 所示。

在某些学校,历史并非命运

图 10-9. 在某些学校,历史并非命运

那么,何时使用散点图才有意义?

指南

数据必须足够大量,以便能看到趋势和异常值。

指南

应可视化相关基准,无论是来自数据还是外部规则。

指南

大多数数据应该是“背景”颜色,高亮显示的点不应超过少数几个。

指南

调整透明度或应用抖动以最小化数据遮挡。

地图

对于我们许多人来说,地图是最熟悉的可视化类型之一;根据你的情况,你可能使用地图规划上班或上学的路线,找到新的商店或餐馆,或者找到新的公园或自行车道。地图也是大众传播中常见的视觉形式,它们出现为天气地图、选举地图,甚至是提供陌生地点参照的“定位器”地图。如果我们的数据具有地理组成部分,那么考虑将其制成地图是很自然的。

然而,在现实中,除非你的数据是关于地理,否则你真的不应该将其映射。为什么?因为地图代表土地面积。不是流行度,甚至不是人口—而通常我们的数据就是关于人口的。例如,让我们回想一下来自第六章的 PPP 贷款数据。如果你使用 value_counts('ProjectState') 对州的批准贷款数量进行聚类,你将得到以下输出(重新格式化为列以节省空间):

CA    99478      VA    18682      CT    10197      NH     4197      AK     2076
TX    60245      NC    18022      AL     9025      ID     3697      PR     2032
NY    54199      MN    16473      OK     8598      NM     3524      VT     1918
FL    46787      CO    15662      SC     8522      ME     3490      WY     1791
IL    33614      MD    15170      UT     7729      HI     3414      GU      305
PA    30768      WI    14729      KY     7623      DC     3175      VI      184
OH    26379      IN    13820      IA     7003      RI     3012      MP       55
NJ    24907      MO    13511      KS     6869      WV     2669      AS       18
MI    24208      TN    12994      NV     6466      MT     2648
MA    21734      AZ    12602      NE     4965      ND     2625
GA    20069      OR    10899      AR     4841      DE     2384
WA    18869      LA    10828      MS     4540      SD     2247

毫不费力地,你可能已经猜到这个表格中州的排列顺序与这个表格中州的排列顺序相似,后者按人口排名州。换句话说,如果我们要对 PPP 贷款批准的数据进行“映射”,那么它基本上会成为一个人口地图。但是让我们假设我们解决了这个问题,并且我们通过人口对贷款数量进行了标准化,因此生成了一个新列,称为“人均批准贷款”或类似的内容。即使现在我们已经将数据从人口转换为流行度,但我们实际上只是用地理形式给自己提出了条形图问题:特定州实际占据的视觉区域与我们显示的数据成比例。无论我们选择什么颜色搭配或数据范围,特拉华州的可视空间都将只占怀俄明州的 1/50,尽管它批准的贷款数量多出 25%。通过对此进行映射,我们只是在确保我们的视觉表现与实际数据相悖。

显然,有很多地图可视化,其中相当多的地图可能存在我迄今为止强调的一个或两个错误。地图提供了一个看似简单直接的视觉组织原则,许多人无法抵制它,但将其用于非地理数据实际上对数据和读者都是一种伤害。

当然,真正地理现象的优秀地图是存在的:例如,这些纽约时报的人口普查地图,提供了一种思慮周到的方法来展示census data,而ProPublica关于休斯顿洪水区的工作则说明了地理(自然和人造)在极端天气事件中的重要性。至于关于风数据的美丽和独特呈现,请查看hint.fm 上的这个风图

考虑到地图很少是支持数据主张的正确可视化方式(以及在 Python 中构建它们可能有多复杂),我们在这里不会展示映射的代码示例。如果您正在处理您认为必须映射的数据,我建议看看geopandas,该库旨在轻松地将与pandas数据框相关的映射相关形状信息结合起来生成可视化。

优雅视觉的要素

尽管我大部分职业生涯都是信息设计师,但我并不认为自己是真正的图形设计师。我不能为你的业务做一个好的标志,就像你学会了一些 Python 编程后不能解决打印机问题一样。幸运的是,通过学习、大量阅读、少数课程以及许多才华横溢的设计师的慷慨帮助,我对有效的视觉设计有了一定了解,特别是信息设计。我在工作中学到的一些要点在这里概述。

“挑剔”的细节确实有所不同

一百万年前,当我第一次作为网络初创公司的前端程序员工作时,我的首要任务是制作运行良好的东西——让我告诉你,当它们真正运行时,我是非常高兴的。随着时间的推移,我甚至在一些与我们在第八章中进行的相同方式中对我的代码进行了改进和重构,并且我对这些编程工件相当满意。从程序员的角度来看,我的工作是相当干净的。

但我在一个设计团队工作,我与之合作的设计师总是推动我微调看起来对我而言更像“很好有”,而不是必要的小细节。如果幻灯片中的照片稍微减慢或者弹回一点,这真的有关键吗?当时,编写和调整这样的效果意味着我需要写入和调整(非常粗略的)物理方程,这不是我喜欢的。而且,所有这些定制都在我代码中制造了混乱。

但我最喜欢编程的原因之一是解决问题,所以最终我埋头进行了他们要求的更改。一旦我看到它运行起来,我意识到最终结果变得更加精致和令人满意。我开始赞赏设计的质量在那些“小”细节中,并且那些“挑剔”的东西——比如点击或轻敲时的颜色变化——实际上使得我的界面和图形更加清晰和可用。换句话说,不要忽视设计中的“细节”——它们不仅仅是为了让事物“看起来漂亮”。设计的本质在于它的功能

相信你的眼睛(和专家们)

在数字化的视觉元素中,必然以定量术语表达:十六进制颜色代码的数学起源和 x/y 坐标定位可能使人误以为为图表找到“正确”的颜色或标注标签的正确位置只是数学问题。但事实并非如此。例如,颜色感知既复杂又高度个体化——我们不能确保别人看到的是我们“相同”的颜色——当然,有许多类型的颜色“盲”可能会阻止某些人根本无法感知某些对比色对(例如红/绿,蓝/橙,黄/紫)。没有方程可以解释这一点。

实际上,我们可能使用的每一种“颜色”实际上都由三个不同的属性来表征:色调(例如红色与蓝色),亮度(或光度)和饱和度。彩色“搭配”通过这些特征以非常特定的方式对齐或对比。当然,当涉及到可视化时,我们需要颜色做的远不止是看起来好看;它们必须有意义地编码信息。但是一个颜色“比另一个更蓝 20%”是什么意思?不管怎样,这不仅仅是在你的十六进制颜色值中翻转一些数字的问题。^(9)

幸运的是,我们得到了专家的帮助。二十多年来,寻找颜色建议的人(主要是地图,虽然它也是其他类型图表的一个很好的起点)一直可以求助于宾州州立大学地理学教授兼制图师Cynthia Brewer的工作,她的ColorBrewer工具提供了出色的免费视觉设计颜色分布。同样,Dona Wong 的优秀著作《华尔街日报信息图表指南》(Norton)包括了我个人喜欢的一些图形颜色组合。

如果您非常坚决地想选择自己的颜色调色板,那么下一个最佳方法就是求助于我们拥有的最伟大的色彩权威:自然。找到自然界的照片(花朵的照片通常效果特别好),并使用颜色捕获工具选择对比色,如果需要的话,或者选择单一颜色的几种色调。使用这些更新几乎任何可视化软件包的默认值,您将欣赏到您的图形变得更加吸引人和专业。

当涉及到诸如字体大小和标签位置之类的事情时,也没有什么方程式可供遵循——您主要只需一下您的图形,然后在它们看起来不太对劲时微调一下。例如,我清楚地记得在华尔街日报编码时,我编写了一个同事设计的列表的代码布局,并且我自然而然地编写了一个for循环来精确定位它们。问题是,当我运行代码并渲染它时,某些地方看起来不对劲。我确信我只是在估计间距时估计不准确,于是问他每个元素之间应该有多少像素的白色空间。“我不知道,”他说,“我只是看了看。”

虽然我认识到在您刚开始进行视觉设计时,这种建议可能令人沮丧,但我也可以承诺,如果您给自己一些时间来尝试,最终您将学会相信自己的眼睛。有了这个,一些实践,以及对以下各节中列出的(明确定义的)细节的关注,您很快就会产生既准确又优美的可视化效果。

选择刻度

在“图表、图形和地图:哦,我的天!”中,我们讨论了与我们的可视化准确性相关的规模问题;而在这里,我们的重点是清晰度和可读性。像seabornmatplotlib这样的软件包将根据您的数据自动选择刻度和轴限制,但出于各种原因,这些默认值可能需要调整。一旦您确认了数据的适当数值范围,您将希望审查您的图形实际渲染的方式,并确保它也遵循这些一般规则:

  • 轴限和标记的值应为整数和/或 5 或 10 的倍数。

  • 值标签应该以科学计数法表示。

  • 单位应该只出现在每个轴的最后一个元素上。

  • 虽然不是理想的情况,标签可能需要编辑或者(不太推荐的方式)倾斜以保持可读性。

选择颜色

除了寻求专家意见来选择特定的颜色之外,还要考虑您的数据元素应该有多少颜色。颜色可以是突出特定数据点或区分测量值和预测值的宝贵方式。在为您的图表或图形选择颜色时,请记住:

每个数据类别一个颜色

例如,如果您显示了有关 PPP 贷款计划的几个月的数据,则所有的柱子应该是一种颜色。同样,同一变量的不同值应该是单一颜色的不同阴影。

避免连续的颜色分布。

虽然根据其值调整每个可视元素的颜色可能看起来更精确,就像我们在 Figure 10-5 中看到的数据视觉压缩一样,连续的色彩调色板(或渐变)生成的颜色差异小到人眼实际上无法感知。这就是你的分布计算(你确实做了那些吧?)会派上用场的地方:创建一个最多五种颜色的色标(或渐变),然后将每一种颜色分配给您数据的一个五分位数

使用分歧色标要谨慎。

只有当数据围绕真正的“中性”值变化时,分歧色标才真正适用。在某些情况下,这个值可能是零。在其他情况下,它可能是领域中的一个约定值(例如,美联储认为通胀率约为2%是理想的)。

永远不要给超过四个不同类别的数据上色。

不过,把背景中的上下文数据用灰色是可以的。

测试颜色可访问性。

ColorBrewer 等工具包含生成只有色盲安全组合的选项。如果您使用自己的颜色,请通过将图形转换为灰度来测试您的选择。如果您在可视化中仍然可以区分所有颜色,您的读者应该也能够。

最重要的是,注释!

我们可视化过程的目标是分享我们的数据见解并支持我们的主张。虽然我们的行动动词标题应该概括我们图形的主要思想,但通常有必要突出或添加图形本身特定数据点的上下文。这是星号和脚注的地方。准确或有效地理解图形所需的信息必须成为图形主要视觉范围的一部分。你可以使可视化中的数据更易理解的一些关键方法包括以下几点:

直接标记分类差异。

而不是创建一个单独的图例,直接将数据标签放在可视化中。这样读者就不必在您的图形和一个单独的键之间来回查看,以理解被呈现的信息。

用颜色突出相关数据点。

如果一个关键数据点对你的整体主张至关重要,用对比色突出它。

添加上下文注释。

这些与相关数据元素连接的少量文本(如果必要,使用细线引线连接)可以是标签、解释或重要的背景信息。无论是什么,确保它尽可能靠近数据,并始终在图形本身的视觉范围内。

从基础到美观:使用 seaborn 和 matplotlib 自定义可视化

关于设计(无论是视觉还是其他方面)的最后一点说明。虽然我在“雄辩视觉元素”中努力将有效可视化的元素分解为组成部分,但一幅真正雄辩的图形并不是一堆可互换的部分。改变其中的一个部分——移动一个标签,改变一个颜色——意味着许多,如果不是所有的其余部分都需要调整,以使整体恢复平衡。这正是为什么我之前没有为每个单独的设计方面提供代码示例的原因:孤立地看,很难理解为什么任何给定的元素如此重要。但将其视为一个完整的整体,每个元素如何有助于图形的影响将会变得清晰(希望如此)。

要在 Python 中实现这些自定义视觉效果,我们仍然要依赖 seabornmatplotlib 库。但是,虽然在以前的情况下我们让 seaborn 处理大部分繁重的工作,在这个例子中,真正发挥作用的是 matplotlib 提供的细粒度控制。是的,我们仍然让 seaborn 处理高级任务,比如实际按比例绘制数据值。但 matplotlib 将给我们提供我们需要的杠杆,以指定从标签放置和方向到单位、标签、注释和突出显示值等一切,这些都是我们真正需要的,以使我们的可视化真正适应我们的主张。

对于这个例子,我们将暂时离开 PPP 数据,转而使用一些 COVID-19 数据,这些数据是由一组研究人员根据约翰斯·霍普金斯大学的数据整理而成,并在我们世界的数据上公开。我们的目标是突出显示 2020 年 7 月美国确诊 COVID-19 病例激增的情况,当时这一情况被归因于许多州在之前春季较早时加快了重新开放^(11),以及在 7 月 4 日假期期间的聚会^(12)。要了解依赖默认值和自定义轴范围、标签和颜色之间的区别,请比较图 10-10 和图 10-11。生成图 10-11 的代码显示在示例 10-5 中。

示例 10-5。refined_covid_barchart.py
# `pandas` for data loading; `seaborn` and `matplotlib` for visuals
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

# `FuncFormatter` to format axis labels
from matplotlib.ticker import FuncFormatter

# `datetime` to interpret and customize dates
from datetime import datetime

# load the data
vaccine_data = pd.read_csv('owid-covid-data.csv')

# convert the `date` column to a "real" date
vaccine_data['date']= pd.to_datetime(vaccine_data['date'])

# group the data by country and month
country_and_month = vaccine_data.groupby('iso_code').resample('M',
                                                              on='date').sum()

# use `reset_index()` to "flatten" the DataFrame headers
country_and_month_update = country_and_month.reset_index()

# select just the United States' data
just_USA = country_and_month_update[country_and_month_update['iso_code']=='USA']

# make the foundational barplot with `seaborn`
ax = sns.barplot(x="date", y="new_cases", palette=['#bababa'], data=just_USA)

# loop through the bars rectangles and set the color for the July 2020
# bar to red
for i, bar in enumerate(ax.patches):
    if i == 6:
        bar.set_color('#ca0020')

# set the maximum y-axis value to 7M
ax.set_ylim(0,7000000)

# setting the axis labels
plt.xlabel('Month')
plt.ylabel('New cases (M)')

# modify the color, placement and orientation of the "tick labels"
ax.tick_params(direction='out', length=5, width=1, color='#404040',
               colors='#404040',pad=4, grid_color='#404040', grid_alpha=1,
               rotation=45) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/1.png)

# functions for formatting the axis "tick labels"
# `millions()` will convert the scientific notation to millions of cases
def millions(val, pos): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)
    modified_val = val*1e-6
    formatted_val = str(modified_val)
    if val == ax.get_ylim()[1]:
        formatted_val = formatted_val+'M'
    if val == 0:
        formatted_val = "0"
    return formatted_val

# `custom_dates()` will abbreviate the dates to be more readable
def custom_dates(val, pos): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/2.png)
    dates_list = just_USA.date.tolist()
    date_label = ""
    if pos is not None: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/3.png)
        current_value = dates_list[pos]
        current_month = datetime.strftime(current_value, '%b')
        date_label = current_month
        if date_label == 'Jan':
            date_label = date_label + " '"+ datetime.strftime(current_value,
                                                              '%y')
    return date_label

# assign formatter functions
y_formatter = FuncFormatter(millions)
x_formatter = FuncFormatter(custom_dates)

# apply the formatter functions to the appropriate axis
ax.yaxis.set_major_formatter(y_formatter)
ax.xaxis.set_major_formatter(x_formatter)

# create and position the annotation text
ax.text(4, 3000000, "Confirmed cases\noften lag infection\nby several weeks.") ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-py-dt-wrgl/img/4.png)

# get the value of all bars as a list
bar_value = just_USA.new_cases.tolist()

# create the leader line
ax.vlines( x = 6, color='#404040', linewidth=1, alpha=.7,
                         ymin = bar_value[6]+100000, ymax = 3000000-100000)

# set the title of the chart
plt.title("COVID-19 cases spike following relaxed restrictions\n" + \
          "in the spring of 2020", fontweight="bold")

# show the chart!
plt.show()

1](#co_presenting_your_data_CO4-1)

自定义图表上每个轴上指示值的“刻度标签”的方向、颜色和其他属性可以通过 matplotlibtick_params() 方法来完成。

2](#co_presenting_your_data_CO4-2)

提供给任何分配给任何FuncFormatter函数的自定义函数的参数将是“值”和“刻度”位置。

3

在“交互模式”下,如果posNone,该函数将抛出错误。

4

默认情况下,覆盖在图表上的文本元素的定位是“数据坐标”,例如,值为 1 将使文本框的起始左对齐于第一列的中心点。提供的“y”值锚定文本框的底部

基本的 seaborn 图表。

图 10-10. 由可视化库默认生成的条形图

定制可视化

图 10-11. 定制化的可视化

正如您从示例 10-5 中可以看到的那样,在 Python 中定制我们的可视化过程是相当复杂的;调整默认输出的外观和感觉几乎使所需的代码行数增加了近三倍。

与此同时,此实例中的默认输出基本上是难以辨认的。通过淡化颜色,突出显著数据点,以及(可能最重要的是)精炼我们的数据标签,我们设法创建了一张图表,在大多数出版环境中都能自成一格。显然,这里我们的大部分代码可以被精炼和重新利用,以使这种程度的定制成为未来图形制作的一种非常少见的努力。

超越基础

尽管我在本章中致力于涵盖有效和准确的数据可视化基础,事实上,有价值的可视化理念可以来自任何地方,而不仅仅是专门讨论此主题的书籍或博客。在我在《华尔街日报》期间,我最喜欢的图形之一是失业率可视化,显示在图 10-12,灵感来自我在一个关于气候变化的博物馆展览中遇到的类似形式;几何热图格式允许读者一目了然地比较数十年来每月失业率。如果你对设计感兴趣,你可以通过简单地批判性地观察你每天看到的媒体(无论是在线广告还是餐厅菜单),学到很多关于什么做得好(或不好)的东西。如果某件事感觉不够精致或吸引人,仔细看看它的组成部分。颜色是否不协调?字体是否难以阅读?或者只是有太多东西挤在太小的空间里?

当然,批评他人的作品很容易。如果你真的想改进自己的可视化,挑战自己尝试构建更好的解决方案,你将在此过程中学到大量知识。如果你发现自己在寻找正确的词汇来描述问题,你可能想看一些附录 D 中的资源,那里有一些我自己扩展和改进可视化工作的最喜欢的资源。

随时间变化的美国失业率。

图 10-12. 美国失业率随时间变化(最初设计用于《华尔街日报》

结论

像编程一样,可视化是一门应用艺术:提高它的唯一方法是去做。如果你手头没有项目可做,从一个你认为在某种程度上不太好的“发现”可视化开始,然后重新设计它。你可以使用 Python 或其他计算工具,或者只是一支铅笔和纸——关键是要尝试解决你在原始可视化中看到的问题。在这个过程中,你将亲身体验到每个设计决策中固有的权衡,并开始建立减少这些权衡以服务于你的可视化目标所需的技能。然后,当你面临自己的数据可视化挑战时,你将有一个可以参考的先前工作作品集,以思考如何最好地呈现你拥有的数据并阐明你需要的观点。

到目前为止,我们已经用 Python 进行了几乎所有我们想做的数据整理,但是Python 之外还有一整个世界的数据和可视化工具,这些工具在支持你的数据整理和数据质量工作时可以非常有用。我们将在第十一章中介绍这些工具。

^(1) 其中直方图是一种特殊类型。

^(2) 特别是如果你给自己信心连接动词

^(3) 尽管这些几乎总是公开和频繁地被违反,例如,Junk Chart 的“从零开始改善这个图表,但只是稍微改善了一点”

^(4) 由于 pandasseaborn 库都大量依赖于 matplotlib,在许多情况下,需要直接使用 matplotlib 的特性进行重要的定制,我们将在“优雅视觉元素”中更详细地看到。

^(5) 显然,我们可以简单地反转我们最初指定数据的顺序,但我更喜欢数据顺序和最终可视化顺序匹配。

^(6) “曲棍球桨图:科学史上最具争议的图表解释” by Chris Mooney,https://theatlantic.com/technology/archive/2013/05/the-hockey-stick-the-most-controversial-chart-in-science-explained/275753;也就是说,组织结构图也曾经(谩骂警告)盛行一时

^(7) 欲知更多详情,请参阅 Wong 的《华尔街日报信息图指南》(诺顿出版社)。

^(8) Megan Boldt 等人,“学校那工作:尽管表面看起来不佳,但那些表现优于预期的学校有共同特点”,2010 年 7 月 9 日,https://twincities.com/2010/07/09/schools-that-work-despite-appearances-schools-doing-better-than-expected-have-traits-in-common

^(9) 相信我,我已经尝试过。

^(10) 你可以通过使用pandasquantile()函数,并传入0.20.40.6等值来快速完成这一操作。有关如何计算这些值及其代表什么的更一般性提醒,请参见示例 9-3 在第 9 章中。

^(11) Lazaro Gamio,“自州开始重新开放以来,冠状病毒病例如何上升”, 纽约时报,2020 年 7 月 9 日,https://nytimes.com/interactive/2020/07/09/us/coronavirus-cases-reopening-trends.html

^(12) Anne Gearan、Derek Hawkins 和 Siobhán O’Grady,“上个月冠状病毒病例增长近 50%,由首批重新开放的州领导”, 华盛顿邮报,2020 年 7 月 1 日,https://washingtonpost.com/politics/coronavirus-cases-rose-by-nearly-50-percent-last-month-led-by-states-that-reopened-first/2020/07/01/3337f1ec-bb96-11ea-80b9-40ece9a701dc_story.html

^(13) Mark Olalde 和 Nicole Hayden,“加利福尼亚州 7 月 4 日后 COVID-19 病例激增。专家表示家庭聚会助长了传播。” USA Today,2020 年 8 月 2 日,https://usatoday.com/story/news/nation/2020/08/02/covid-19-spike-california-after-july-4-linked-family-gatherings/5569158002

第十一章:超越 Python

Python 是一款异常强大且多才多艺的工具,特别适用于处理数据。如果你已经跟着本书的练习走过来,希望你开始对使用它来推动自己的数据整理项目感到自信了。由于充满活力的 Python 社区以及其成员创建和维护的不断发展的一系列有用库,你在本书中学习基础知识的工作将仍然是宝贵的,无论你的下一个数据整理项目是明天还是明年。此外,虽然 Python 作为一种编程语言在许多方面独特,但你在这里获得的编程技能和词汇将让你在其他编程语言中获得一个起步,特别是像 JavaScript 这样相对面向对象的语言。

然而,本书中我试图澄清的一件事是,有时“程序化”解决问题并不是真正最有效的方式。例如,在第四章中,我们在处理 Excel 和 XML 文件时强调,有时试图以编程方式做事情是毫无意义的。例如,在示例 4-12 中,我们本可以编写一个 Python 脚本来遍历整个 XML 文档以发现其结构,但毫无疑问,直接查看我们的数据,识别我们感兴趣的元素,并编写我们的 Python 程序以直接定位它们,会更快、更容易。同样,有时编写 Python 程序甚至会比一个特定的数据整理项目需要的工作量更大,特别是如果它更小或更具探索性质。尽管 pandas 是一个非常有用的库,但你仍然可能需要编写大量代码来基本了解新数据集包含的内容。换句话说,虽然我完全相信 Python 的强大和多才多艺使其成为不可或缺的数据整理工具,但我还想强调一些其他免费和/或开源工具,我认为你在数据整理项目中会发现它们很有用^(1),作为 Python 的补充。

数据审查的附加工具

当实际访问和整理数据时,Python 在速度和灵活性方面表现出色,但在让你实际查看数据方面并不特别适合。因此,在本书中,当我们希望以视觉方式浏览数据集时,我们依赖基本文本编辑器(偶尔也会使用网络浏览器)。虽然文本编辑器在这方面是一个很好的第一步,但你也会希望至少熟悉以下每种程序类型中的一种,以帮助你快速初步了解数据——特别是如果你处理的文件不是太大。

电子表格程序

可能在开始阅读本书之前,您已经熟悉了电子表格程序,无论是在线版本(如 Google Sheets)、付费的本地软件选项(如 Microsoft Excel),还是基于社区贡献支持的开源替代品(如 LibreOffice Calc)。电子表格程序通常捆绑在“办公”风格的软件套件中,提供基本的计算、分析和制图功能。总体而言,这些程序的功能差异并不大,尽管有些比其他程序更灵活。例如,我更喜欢LibreOffice,因为它是免费的、开源的,并且跨平台运行(包括不常见的平台,如 Linux)。它甚至有一个适用于 Chromebook 和 Android 设备的认证应用,名为Collabora。也就是说,如果您已经拥有或熟悉特定的电子表格程序,没有重要的理由要切换到其他程序,只要您不因为购买它而陷入财务困境。无论您选择什么,绝对不要使用盗版软件;在勒索软件肆虐的世界中,这简直不值得冒设备和数据的风险!

虽然许多电子表格程序具有接近 Python 所做功能的高级函数(规模较小),但我通常会用它们来完成非常具体的数据整理和评估任务。特别是,我经常会使用电子表格程序快速执行以下操作:

更改文件格式

例如,如果我的数据以多表格 XLSX 文件形式提供,我可能会在电子表格程序中打开它,并将我感兴趣的表格保存为 .csv 文件。

重命名列

如果列数不多,我可能会将那些具有尴尬或不明确标题的列更改为对我目的更可读和/或更直观的内容。

对数据值有一个“感觉”

提供的“日期”列中的值是否实际上是日期?还是仅仅是年份?是否有很多明显缺失的数据值?如果我的数据集相对较小,只是在电子表格程序中视觉扫描数据有时足以确定我是否拥有所需的数据,或者我是否需要继续进行。

生成基本摘要统计信息

当然,我可以用 Python 来做这些,而且大部分时间我确实这样做。但是如果我的数据只有几百行,输入=MEDIAN() 然后选择感兴趣的单元格有时会更快,特别是如果我的原始数据文件中有元数据需要去除的情况(正如我们在第四章,以及第七章 和 8 章 中看到的)。

当然,每个工具都有其取舍,而在电子表格程序中预览数据可能会产生一些意想不到的结果。正如你可能从我们处理 XLS 样式“日期”的延长冒险中猜到的那样,预览包含类似日期值的文件可能会导致这些值根据特定电子表格程序及其默认处理和呈现日期的方式显示非常不同。因此,如果原始数据格式是基于文本的(例如 .csv.tsv.txt),你应该始终使用文本编辑器检查类似日期值。同样,务必确认任何包含数字的单元格的格式(无论是默认还是应用的格式),因为值的截断或四舍五入可能会掩盖数据中重要的变化。

OpenRefine

我在初探更大、结构化数据集时经常使用的工具之一是 OpenRefine。根据我的经验,OpenRefine 是一款独特的软件工具,有助于弥合传统电子表格程序和像 Python 这样的全面编程语言之间的差距。与电子表格程序类似,OpenRefine 通过图形用户界面(GUI)操作,因此大部分工作都涉及使用鼠标进行指点和点击。不同于电子表格程序的是,你无需滚动数据行来了解其内容;相反,你可以使用菜单选项创建分面,提供类似于 pandas value_counts() 方法 的汇总信息,而无需编写任何代码。OpenRefine 还支持批量编辑,实现了几种字符串匹配算法(包括我们在 Example 6-11 中使用的指纹方法),并且可以分段导入大文件(例如,每次 100,000 行)。事实上,当我处理新数据集时,OpenRefine 通常是我首选的工具,因为它能轻松打开各种数据格式,甚至提供了方便的实时预览,根据你选择的分隔符解析数据。一旦加载数据集,OpenRefine 还能几乎一键回答像“列 x 中最常见的值是什么?”这样的问题。最后,每当你在 OpenRefine 中对数据文件进行实际更改(而不仅仅是聚类或分面化),它都会自动记录你的操作在一个可导出的 .json 文件中,然后你可以将这些操作应用到不同的 OpenRefine 文件中,以便在几秒钟内自动重复这些操作。如果你需要为定期由数据提供者更新的数据集重命名或重新排列数据列,这将非常有用。然而,如果需要其他人能够执行此操作而他们没有或不能使用 Python,则更为有用。

大多数情况下,我使用 OpenRefine 能够轻松完成以下操作:

预览大型数据集的小片段

OpenRefine 允许您加载(或跳过)数据集中的任意行数。这对于大型数据集特别有用,当我想要了解它们的内容,但我对它们的内容一无所知时。我可以开始加载 50,000 或 100,000 行数据,并使用细分和其他功能来了解数据类型及整体数据集的组织方式。

快速获取数据集的顶层信息

纽约市最常请求的电影许可证类型是什么?最受欢迎的行政区是哪一个?如图 11-1 所示,OpenRefine 可以让你在一两次点击中获取这些统计数据,并允许你快速创建交叉制表。

进行电子表格程序不支持的基本转换

一些电子表格程序缺乏某些功能,比如按特定字符分割字符串的能力,或者可能有限的正则表达式支持。OpenRefine 中我最喜欢的功能之一是批量编辑,你可以通过左侧的细分窗口快速轻松地进行操作。

自动生成宏

许多电子表格程序允许你记录来自动化某些操作,但 OpenRefine 默认会为你记录这些操作,使其成为一个学习曲线较低的更强大的工具。

当然,在使用 OpenRefine 时会有一些需要适应的方面。首先,尽管安装越来越用户友好,但它依赖于另一种名为 Java 的编程语言安装在你的计算机上,因此启动它可能是一个多步骤的过程。一旦安装完成,启动程序的方式也有些不同:你需要点击(或双击)程序图标启动,并且在某些情况下,需要打开一个指向你“本地主机”地址的浏览器窗口(通常是 http://127.0.0.1:3333/http://localhost:3333)。和 Jupyter Notebook 一样,OpenRefine 实际上在你的计算机上通过一个微型 Web 服务器运行,其界面就像一个功能强大的电子表格程序的网页版。尽管存在这些怪癖,OpenRefine 非常有用,通常是进行初始探索(可能是混乱的)数据集的绝佳选择。

OpenRefine 纽约市电影许可证细分。

图 11-1. OpenRefine 纽约市电影许可证细分

共享和展示数据的附加工具

在第十章中,我们专注于如何使用 Python 和关键库如seabornmatplotlib选择和改进可视化。虽然使用这些工具可以实现令人印象深刻的定制程度,但有时您可能只需要对可视化进行小调整,可能不希望或无法使用 Python 从原始数据源重新生成它。

如果你需要快速添加或更改可视化中的某些小细节,拥有图像编辑软件是非常有价值的。虽然你可能熟悉用于编辑图像的非常强大且非常昂贵的商业软件应用程序,但你可能没有意识到也有类似强大的免费且开源的工具。

JPG、PNG 和 GIF 的图像编辑

对于编辑像素图像,如果你想要强大但又不想(或者无法)支付太多费用,GNU 图像处理程序(GIMP)是一个特别不错的选择。GIMP 是免费且开源的,它适用于各个平台。尽管其用户界面的风格明显过时(在撰写本文时正在进行界面改进),但事实上,该程序可能可以满足你所需的任何基本(以及非常基本的)高质量图像编辑,尤其是如果你只是想要添加(或删除)一些文本或图像注释,更新坐标轴标签等。

的确,GIMP 的学习曲线可能有点陡峭。键盘快捷键可能不是你所期望的,而且一些菜单的位置和它们的图标外观与商业软件中看到的不匹配。尽管如此,除非你是另一个图像编辑程序的专家并且愿意支付(并且继续支付)以获取它的访问权限,否则你在学习 GIMP 上投入的任何时间都是值得的。特别是如果你只需要偶尔访问图像编辑软件,GIMP 是一个强大且灵活的选择。

编辑 SVG 和其他矢量格式的软件

如果你计划将你的可视化内容用于印刷或其他高分辨率(或灵活分辨率)的环境中,你很可能会选择以矢量格式保存它。尽管文件大小会更大,但矢量图形比其像素驱动的对应物更加灵活;它们可以在不失真或模糊的情况下进行缩放。然而,它们不能有效地通过像 GIMP 这样的位图软件进行编辑。

再次强调,如果你有商业软件的预算,你应该继续使用它——但是在这里,你也有一个免费且开源的选择。像 GIMP 一样,Inkscape 是免费的、开源的,并且跨平台。而且,像 GIMP 一样,它几乎拥有与昂贵的商业矢量编辑软件相同的所有功能。更重要的是,如果你花时间熟悉矢量编辑软件,它不仅会让你调整数字到印刷的数据图形,矢量编辑软件还是 T 恤印刷、激光切割和许多其他数字到物理工作的基本工具。如果你刚开始学习,Inkscape 绝对是正确的选择:免费!

对伦理的思考

本书的主要焦点是培养数据整理技能,这在很大程度上是为了支持我们对数据质量进行评估和改进的兴趣。在此过程中,我们已经涉及到了数据质量的更广泛影响,无论是抽象还是具体的。数据质量差可能导致分析产生误导性、扭曲性或歧视性的世界观;再加上今天数据驱动系统的规模和普及性,可能带来的损害是巨大且深远的。虽然您可以使用本书中的方法来测试和改进数据的质量,但遗憾的是,获取“高质量”数据的方式仍有很大改进空间,有些方法可能并不道德。正如在数据整理过程的其他部分一样,您需要自行决定您愿意使用什么类型的数据以及其目的。

确保您的数据整理工作不会无意中违反自己道德标准的一个策略是制定清单。通过列出关于数据来源以及分析结果将如何使用的问题,您可以在早期确定是否愿意进行特定的数据整理项目。以下清单改编自数据专家DJ Patil、Hilary Mason 和 Mike Loukides共享的清单。与第三章中数据特征列表一样,这里的目的并不是除非每个问题的答案都是“是”,否则拒绝一个数据项目;目标是批判性地思考所有数据质量方面,包括那些可能超出我们控制范围的方面。诚然,我们可能只能拒绝(而不是改变)不符合我们道德标准的项目,但如果您表达您的担忧,您可能会帮助为其他人也表达担忧留出空间。最糟糕的情况是项目由其他人接手,而您的良心会(在某种程度上)得到宽慰。最好的情况是,您可能会激励其他人在接手下一个项目之前考虑其工作的道德影响。以下是您可能希望包含的一些问题:

  1. 数据收集的设计是否反映了其所涉及社区的价值观?

  2. 那个社区的成员知道数据是如何收集的吗?他们有拒绝的有意义方式吗?

  3. 数据是否已经经过代表性评估?

  4. 有没有方法来测试数据的偏见?

  5. 我们的数据特征是否准确地代表了我们想描述的现象?

  6. 如果数据过时,我们的分析是否会被替换?

最终,您可能会发现您对数据的关注焦点有所不同。无论您决定在自己的清单中包含什么内容,只要您提前明确您的数据原则,您会发现遵守这些原则要容易得多。

结论

在本书的过程中,我们涵盖了从 Python 编程和数据质量评估的基础知识,到从六种文件格式和 API 中整理数据。我们将我们的技能应用于一些典型混乱和问题多多的真实世界数据,并优化我们的代码以便未来项目更加轻松。我们甚至探索了如何进行基本数据分析,并通过可视化呈现数据来支持我们的洞见。

如果你已经走到这一步,那么我想到这时候你一定已经染上了某种“病毒”:对编程、对数据、对分析和可视化——或者可能是以上所有内容的热情。不管是什么原因把你带到这本书,我希望你找到了至少一部分你寻找的内容,也许还包括了足够的信心迈出下一步。因为无论未来数据整理领域会发生什么变化,有一件事是肯定的:我们需要尽可能多的人来批判性和深思熟虑地进行这项工作。为什么不能是你其中之一呢?

^(1) 当然可以!

附录 A. 更多 Python 编程资源

正如本书希望揭示的那样,Python 是一种强大而灵活的编程语言,具有广泛的应用。虽然在前几章中我介绍了许多关键概念和流行库,但我创建了这个附录,为你提供一些有用的资源和参考资料,帮助你将 Python 工作提升到更高水平。

官方 Python 文档

是的,总是有搜索引擎和StackOverflow,但习惯阅读官方文档确实有其价值 — 无论是针对 Python 语言还是流行的库如 pandasmatplotlibseaborn。虽然我不建议你坐下来逐页阅读 任何 编程文档,但浏览你想要使用的数据类型或函数的参数和选项,可以让你更好地理解它(通常)可以做什么,以及其机制如何组织。当你想做全新的事情时,这尤其有帮助,因为它会指引你寻找前进的路径。

例如,我在写这本书时就知道 seabornpandas 都是基于 matplotlib 构建的,我也曾尝试通过它们来制作和定制图形。然而,直到我仔细查阅后者的文档时,我才明白我经常在示例代码中看到的 figureaxes 对象之间的区别,这种理解帮助我在实验不同方式全面定制可视化时更快地找到解决方案。几乎同样重要的是,官方文档通常保持更新,而关于某个主题的最流行论坛帖子往往可以过时数月甚至数年 — 这意味着它们中包含的建议有时可能已经大大过时。

我还建议定期查阅Python 标准库,因为你可能会惊讶于它提供的大量内置功能。你在使用库时熟悉的许多方法都是基于(或模仿)存在于“纯” Python 中的函数。虽然库通常是独特和有用的,但并不能保证它们会继续开发或维护。如果你可以通过使用“纯” Python 获得所需的功能,那么你的代码依赖于不再更新的库的风险就越小。

安装 Python 资源

根据你的编程环境,有很多种方法可以安装 Python 包。无论你是在 macOS 上使用 Homebrew,还是在 Windows 机器上工作,或者使用 Colab,安装 Python 包的最可靠方法几乎总是使用某个版本的 pip。事实上,你甚至可以使用 pip 在 Google Colab 笔记本上安装包(如果你找到了尚未安装的包)使用以下语法

!pip install *librarynamehere*

无论你选择什么,我建议你做出选择并坚持下去——如果你开始使用多个工具来安装和更新你的 Python 环境,事情可能会变得非常不可预测。

寻找库的位置

大部分情况下,我建议你安装那些在Python Package Index(也称为 PyPI)上可用的 Python 包。 PyPI 有清晰的结构和强大的标签和搜索界面,使得查找有用的 Python 包变得很容易,而且 PyPI 包的文档通常具有标准的布局(如图 A-1 所示),如果你要浏览大量选项,这将极大地节省你的时间。

一些项目(如 Beautiful Soup lxml)可能仍然将大部分文档保存在独立的位置,但它们的 PyPI 页面通常仍然包含项目功能的有益摘要,甚至一些入门提示。我个人喜欢看的其中一件事是“发布历史”部分,显示项目的首次创建时间,以及包的更新频率和最近更新时间。当然,项目的长期存在并不是评估给定包可靠性的完美指标——因为任何人都可以发布 Python 包(并将其添加到 PyPI 中)——但那些存在时间较长和/或最近更新频繁的包通常是一个很好的起点。

示例 PyPI 包页面。

图 A-1. 示例 PyPI 包页面

保持你的工具锋利

编程工具一直在更新,因为社区发现(并且大多数情况下修复)新问题,或者达成共识,某个软件工具的某些方面(甚至 Python 本身!)应该有所不同。如果您按照第一章中提供的安装 Python 和 Jupyter Notebook 的说明进行操作,那么您可以使用conda命令来更新这两者。您只需偶尔运行conda update pythonconda update jupyter。与此同时,因为本书面向初学者,我在第一章中没有涉及 Python 环境的问题。在这个背景下,一个给定的环境主要由默认使用的 Python 主版本(例如 3.9 与 3.8)来定义,当您使用python命令时。虽然运行conda update python将更新,比如,版本 3.8.6 到 3.8.11,它 不会 自动将您的版本更新到不同的主要发布版本(例如 3.9)。通常情况下,这不会给您造成问题,除非您的 Python 安装已经过时了几年(因此是主要版本)。如果发生这种情况,您将需要为新版本的 Python 创建一个新的环境,主要是因为否则您的计算机可能很难保持清晰。幸运的是,当您升级 Python 到下一个主要版本时,您可以从conda文档页面了解如何操作。

如何获取更多信息

因为 Python 是如此受欢迎的编程语言,因此在线资源和图书馆中有数千种资源,可帮助您进入下一个学习阶段,无论是完成特定项目还是仅仅想了解更多关于机器学习和自然语言处理等高级主题。

对于更深入的数据科学主题的实用、简洁介绍,我的首选推荐会是Python 数据科学手册,作者是 Jake VanderPlas(O’Reilly)——这是我在试图理解 Python 可以进行的更高级机器学习工作时自己使用过的资源,Jake 清晰的写作风格和简洁的示例提供了一种既能够概述 Python 机器学习,又能学习具体方法(如 k-means 聚类)的易于理解的方式。更好的是,你可以在网上免费获取整本书——虽然如果可以的话,我强烈建议购买一本实体书。

或许比找到合适的书籍、教程或课程更有价值的是,在你继续进行数据整理的旅程中找到一个可以与之共同学习和工作的人群社区。不论是通过学校、社区组织还是聚会,找到一个小团体,你可以与他们讨论你的项目,寻求建议(有时也是共鸣!),可能是扩展你在 Python 和数据整理方面技能最宝贵的资源。还有许多无费的项目可以支持并推动你的工作,尤其是来自技术领域中代表性不足的群体。看看像Ada Developers AcademyFree Code Camp这样的团体。

附录 B. 关于 Git 的更多信息

大部分情况下,作为一个项目的唯一程序员使用 Git 是相当简单的:你对代码进行更改,提交它们,将它们推送到 GitHub(或其他远程存储库),就这样。

直到……不是。也许你在 GitHub 上更新了你的 README.md 文件,然后忘记在设备上对相同文件进行更改之前运行 git pull。也许你在运行 git commit 时忘记添加消息。虽然处理这些小问题并不是真的很复杂,但如果你以前从未遇到过这些问题,那么 Git 在终端中的某些错误消息和默认行为可能会让人感到棘手。虽然这个附录中的指导远远不是全面的,在我的经验中,对 Git 的基本工作知识已经足够了,除非你在一个大型组织的一个相对大的团队中工作。因此,虽然如果你计划与多个合作者在复杂的项目上使用 Git,你应该绝对超越这些简单的修复,但对于我们大多数人来说,这些示例涵盖了你经常会发现自己处于的情况,并且需要帮助摆脱的情况。

运行 git push/pull 后进入了一个奇怪的文本编辑器

像我一样,Git 在记录你的工作时是无情的,以至于如果没有提交消息,它将不允许你提交更改。这意味着,如果你运行 commit 命令而没有包含 -m "提交消息在这里",例如:

git commit *filename*

你很可能会发现你的终端窗口被接管,显示类似于 图 B-1 中所示的文本。

基于终端的提交消息编辑

图 B-1. 基于终端的提交消息编辑

这可能相当令人不安,特别是第一次发生时。但因为它发生,所以这里是如何快速解决的方法:

  1. 从键入字母 i 开始。虽然并不是所有编辑器实际上都要求你键入 i 进入 INSERT 模式,但其中大多数会吞掉你键入的第一个字符,所以你最好从这个字符开始。你可以在 图 B-2 中看到这个视图的样子。

  2. 要退出 INSERT 模式,按下 Esc 键或 Escape 键。然后像 图 B-3 中所示输入 :x,然后按 Enter 键或 Return 键。这将按照要求保存你的提交消息,并且让你退出那个文本编辑器,回到你熟悉和喜爱的终端窗口。

请注意,你发现自己处于的编辑器可能看起来与 图 B-2 和 图 B-3 所示的不同;如果不同,不要惊慌。现在是搜索在线编辑、保存和退出的时间,无论你被弹到了什么程序中。不管具体情况如何,目标都是一样的。

 模式下的终端编辑器

图 B-2. INSERT 模式下的终端编辑器

带有“保存并退出”命令的终端编辑器

图 B-3. 带有“保存并退出”命令的终端编辑器

您的 git push/pull 命令被拒绝了。

这种情况每个人都会遇到。你以为自己每次工作结束时都很勤奋地提交了代码,甚至为每个文件的更改编写了单独的提交消息,而不只是运行git commit -a。即便如此,有时你运行git push命令时会被拒绝。那么接下来该怎么办呢?

如果您的git push命令失败,您可能会看到如下错误:

 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to 'https://github.com/your_user_name/your_r
epo_name.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

通常情况下,这种情况发生在您本地存储库中的至少一个文件自上次提交以来已更改,但远程存储库中的版本也已更改,因此 Git 不知道哪一个应该优先。不用担心!

运行 git pull

如果文件的更改可以自动合并,Git 将执行此操作。这将解决您的文件冲突,但可能会导致您进入命令行的内置文本编辑器,这本身可能会相当令人困惑。如果您运行git pull,突然看到像图 B-1 中显示的文本,参见“您运行 git push/pull 并进入了一个奇怪的文本编辑器”。

如果文件的更改不能自动合并,您将收到类似以下消息的提示(如果您只运行git pull而没有先运行git push,也会看到此消息):

Auto-merging *filename*
CONFLICT (content): Merge conflict in *filename*
Automatic merge failed; fix conflicts and then commit the result.

这基本上意味着由您作为人来决定哪个文件应优先,并通过直接编辑一个或两个文件或简单地强制其中一个文件覆盖另一个来将它们重新调整为一致。

手动解决冲突

假设您有一个包含README.md文件的存储库,在 GitHub.com 和您的设备上都对其进行了更改。您试图git push但遇到了错误,因此您尝试了git pull,但自动合并失败了。要手动解决冲突,请先在您首选的文本编辑器中打开本地文件副本。Markdown 文件的冲突示例如示例 B-1 所示。

示例 B-1. Markdown 文件中的一个冲突示例
# This header is the same in both files

This content is also the same.

<<<<<<< HEAD

This content is what's currently in the local file.
=======
This content is what's currently in the remote file.
>>>>>>> 1baa345a02d7cbf8a2fcde164e3b5ee1bce84b1b

要解决冲突,只需根据您的喜好编辑文件内容,确保删除以<<<<<<<>>>>>>>开头的行,以及=======;如果您留下这些行,它们将出现在最终文件中。现在像往常一样保存文件。

接下来,运行:


git add *filename*

然后运行:

git commit -m "How I fixed the conflict."

注意,在git commit命令中不能指定文件名,否则 Git 会抱怨你在合并过程中要求进行部分提交。

最后,运行:

git push

然后您应该设置好一切!

“修复”冲突是通过强制覆盖来完成的。

虽然查看冲突文件总是个好主意,但有时你要推送到一个明显过时的仓库,而你只是想要用本地版本覆盖远程仓库中的内容。在这种情况下,你可以运行以下命令推送更改,而不手动协调文件:

git push --force

这将简单地用本地版本覆盖远程文件,但请记住,所有覆盖版本的记录,包括提交历史,都将丢失。这意味着一旦使用了--force,就无法回退——远程内容将会丢失。显然,这意味着如果其他人也在同一仓库上贡献,你绝不应该使用此选项——只有在你是唯一的工作人员,并且对远程仓库中的内容有(非常)充分的信心可以安全覆盖时,才应使用此选项。

Git 快速参考

表 B-1 提供了最常用和有用的git命令的简要概述。更详细的列表可以在GitHub 网站上找到。

表 B-1. 最常用的git终端命令

命令文本 命令结果
git status 显示仓库的当前状态;不执行任何修改。列出仓库中所有文件^(a),按照它们是新文件未跟踪还是已修改进行分组。
git add filename 当前未跟踪的特定文件加入暂存区。必须通过git add命令将文件暂存后才能提交到仓库。之后,git status会将已暂存的文件标记为新文件
git add -A 一次性所有当前未跟踪的文件加入暂存区。之后,git status会将已暂存的文件标记为新文件
git commit -m "Commit message here."` filename 提交特定的已暂存文件,并附上双引号之间的消息。
git commit -a -m "Commit message here." 提交所有当前已暂存的文件,并附上相同的提交消息。
git push 将所有本地提交推送到远程仓库。添加--force命令将使用本地提交的文件覆盖远程仓库中的任何冲突提交。
git pull 将所有远程文件拉取到本地仓库。添加--force命令将使用远程提交的文件覆盖本地仓库中的任何冲突提交。
^(a) 如果你的仓库中有一个活动的.gitignore文件,那么被忽略的文件将不会git status列出。

附录 C. 寻找数据

总的来说,当您试图回答关于世界的问题时,您可以求助于四个主要的数据“来源”。我将“来源”用引号括起来,因为这些实际上是数据来源的类型,而不是特定的网站、数据库,甚至组织。相反,这些代表了记者、研究人员和其他专业人士用来收集关于世界的数据以回答他们问题的机制。

数据仓库和 APIs

“开放数据”访问越来越成为许多政府和科学组织的一个特征,旨在提高透明度、问责和—特别是在科学界—可重复性。这意味着许多政府机构、非营利组织和科学期刊,例如,维护网站,您可以访问与他们的工作相关的结构化数据。例如,简单搜索“nyc open data”或“baltimore open data”将带您到这些城市的“开放数据”门户网站;类似搜索“johannesburg open data”将首先带您到南非城市开放数据年鉴(SCODA)网站,但在稍后的几个链接中,您会发现来自名为“DataFirst”的组织以及托管在http://opendataforafrica.org的南非数据门户的更多数据集。所有这些网站都会有一些数据—尽管正如我们在第三章中讨论的那样,这些数据的质量—包括它对回答您特定问题的适用性—可能会有很大差异。

APIs 是另一个宝贵的数据来源,因为它们可以提供访问高度详细和定制的数据集,这些数据集在搜索引擎结果中不会显示出来。这是因为 API 数据是所谓的“深网”的一部分:可以通过专门构建的搜索发送到特定 URL 来访问的数据。例如,当你在网络上搜索“currently available citibikes, nyc”时,最终会带你到一个交互地图,你可以在地图上点击 Citi Bike 站点,查看可用的自行车数量;而通过 Citi Bike API 的查询则允许你访问关于所有纽约市 Citi Bike 站点的自行车可用信息。此外,API 将以一个相当结构良好的文件返回这些数据,便于分析,不像交互地图或大多数网页。

数据库和 API 通常是访问大量结构化数据的好方法,这些数据相对干净且易于用于数据分析。与此同时,这些数据源也存在一个重要的局限性:运行门户或 API 的公司或机构完全掌控其包含的数据。这意味着数据可能过时、不完整或缺乏足够的背景信息,或者简单地忽略了数据所有者不希望他人知道的信息。换句话说,数据可能干净且“可用”,但并不意味着它一定是高质量的数据。对其质量进行关键评估仍然至关重要。

然而,更多时候,所有您需要回答选择问题的信息可能都无法通过前述类型的接口之一获取,因此您需要寻找其他来源。

主题专家

如在第三章中讨论的,所有数据或多或少都是由人类创造的。然而,一开始似乎更容易依赖像之前讨论过的网页门户和 API 这样的自动化接口。确实,这些资源几乎随时可用;在结构化 API 调用时不需要担心电话等待时间、营业时间或假期安排。与此同时,与人类主题专家交谈通常是识别、如果不是获取回答问题所需的最丰富和最相关数据的最快方式。这是因为许多公开可用的数据源,即使它们是网页可访问的,也足够小众,以至于虽然你可能还不知道找到它们的最佳搜索词,但专家们却会定期使用它们。因此,他们不仅能够指引你到几乎无法通过网页搜索找到的门户和 API,还可以帮助你区分看似相似的数据源,并提供有关内容的重要背景信息。此外,他们还可以自愿为你提供数据集,而你可能否则需要等待数周或数月才能获得对 FOIA/L 请求的回应。

一般来说,有三个地方您可能会找到主题专家,他们可能能帮助您定位或理解数据。大学和大学是众多专家的栖息地,涵盖几乎无限范围的主题。从致电或给公共关系部门发电子邮件开始,或者直接阅读教师和研究员的在线简介。同样,非政府组织(NGO),如非营利组织和智库,根据其重点聘请专家,他们可能同样熟悉难以发现的专业数据集;他们也可能有个人收集和/或分析的数据可供分享。最后,政府雇员可以是数据和见解的非凡来源,因为他们通常是首先负责代表政府收集和处理数据的个人。虽然有消极的刻板印象,但根据我的经验,政府工作人员是您可能找到的最善良和最慷慨的数据“助手”之一。

FOIA/L 请求

自由信息法(FOIA)和自由信息法(FOIL)请求是任何个人可以请求联邦、州或地方政府活动记录的机制^(1),这可能是获取通过现有门户或出版物不可获得的数据的来源。这些请求本质上是您可以发送给机构内指定办公室或个人的专业信函,详细说明您要求信息的内容。

使用 FOIA/L 请求的关键要求是尽可能具体和狭窄:虽然 FOIA/L 法律保证您请求数据的权利,但大多数只规定您在指定的时间内(通常在 5 到 20 个工作日之间)收到一份回复;它们并要求回复包含您请求的数据。在大多数情况下,初始回复会 (1) 根据可允许的豁免和排除拒绝您的请求;(2) 承认您的请求但将其分类为“复杂”,因此需要延长交付时间;或者 (3) 回复称已执行搜索请求但未产生结果。成功获取您所需数据可能至少需要一次“申诉”,以及大量的耐心。

本书不包括 FOIA/L 请求的全面介绍,但在线上有许多提供模板和建议的指南,教您如何构建您的 FOIA/L 请求,甚至像 MuckRock 这样的网站提供工具,帮助您编写、提交和跟踪您提交的请求。在考虑要请求什么时,请记住以下内容:

FOIA/L 请求仅适用于政府记录。

你需要创造性地考虑你感兴趣的现象可能如何生成这些记录。考虑与你的问题相关的任何活动,可能需要许可证、许可或税收——所有这些都会生成政府记录。还要记住,政府员工的活动——包括他们的电子邮件、日历和其他记录——也受到 FOIA/L 法律的约束。

不要“捆绑”请求

如果你正在寻找包含多个术语的记录,请为每个术语和文件类型(例如电子邮件、日历等)发送单独的 FOIA/L 请求。这会增加你需要跟踪的 FOIA/L 请求数量,但也会减少你的请求被分类为“复杂”而延迟的机会。

使用公开可访问的表格

如果你不知道一个机构收集哪些信息,可以使用公开的表格——例如那些税收、许可和许可要求的表格——来了解。然后在你的请求中引用那些具体的表格号码和字段。

查看过去的请求

FOIA/L 请求本身也受到这些法律的约束。如果你正在寻找的信息曾经是先前提出的请求的一部分,提到这一点可能会加快你自己的请求。

礼貌地与相关人员联系

FOIA/L 官员可以帮助你顺利进行——或者制造麻烦。尽量与他们发展出一种友好,如果不是友好的关系——无论是通过电子邮件还是电话。他们可以为你提供关键的提示,以便改进你的请求,以获得更大的成功。

定制数据收集

有时候,你的问题无论你使用多少专家或 API 的组合都无法得到答案。这种情况突显了数据的人为构造性质:它之所以存在,只是因为某个地方的某个人决定收集它、清理它并使其可用。如果你的问题特别及时或独特,也许结果会证明那个人就是你自己。

在大学中,你可以找到多门完全致力于各种数据收集方法的课程,从调查到传感器。当收集数据时,你选择的方法将取决于你问题的性质以及你有多少资源(包括时间)投入到数据收集过程中。如果你正在寻找探索性或者叙事性数据,那么你的数据收集过程可能就像在社交媒体上发布一个问题或“民意调查”,然后分析你收到的回应。另一方面,如果你希望能够进行泛化的声明——也就是说,对比你拥有数据的确切现象更大的现象的声明——那么你的数据必须是代表性的,正如我们在第三章中讨论的那样。

无论你正在收集哪种类型的数据——轶事性的或代表性的——都有两种主要的方法可以做到:采访和仪器化。采访技术涉及要求人类提供响应,无论这些响应是开放式的、多选的,还是其他格式。仪器化技术涉及某种程度的自动化数据收集,无论是网络浏览习惯还是一氧化碳水平。无论哪种情况,你都需要研究最佳实践或咨询专家,以了解你的测量技术的局限性以及分析和解释数据的适当方法。虽然收集自己的数据可能是一种挑战,但也可能是非常有益的。

^(1) 一些州要求请求者是该州的居民。

附录 D. 可视化和信息设计资源

过去十年间可视化工具的演变使得创建美观、信息丰富的图形的过程发生了转变,从原本需要昂贵的专业软件和专业培训变成了几乎任何人都可以做到的事情。然而,正如在第十章中讨论的那样,制作出色的可视化不仅仅是你拥有的工具,而更多地是找到最能增强和澄清你信息的设计选择。尽管有许多书籍和课程专注于特定目的的可视化(例如商业或科学沟通),下面的清单更像是一个通用读者——这些都是关于发展和理解可视化和信息设计历史、目的和潜力的优秀书籍和资源,如果你想更广泛地了解这个领域的话。

信息可视化基础书籍

在信息可视化领域,最常被提及的人物可能是爱德华·图夫特,他撰写了该领域最早和最有影响力的书籍。虽然这些书都是精美的作品,但最具启发性的可能是前三本:《定量信息的视觉展示》、《构想信息》和《视觉解释》(均出自图形出版社)。如果你想要阅读他的全部著作,他还发表了几篇较短的报告和小册子,摘录了他的书籍,并对 PowerPoint 等内容提出了有力的批评。所有他的出版物都可以在他的个人网站https://edwardtufte.com上购买,但许多也可以在公共图书馆找到。

尽管严格来说是关于排版的书籍,《Thinking with Type》由艾伦·卢普顿(普林斯顿建筑出版社)是一本关于一般图形设计原则的优秀介绍,适用于任何涉及文本的地方——这大多数情况下包括数据驱动的可视化。卢普顿的书向读者介绍了所有设计文本时必需的要素,提供了从字体选择和粗细到大小、颜色和间距的术语,以便推理。这本书既是一本优秀的入门指南和参考书,如果你能购买一本放在手边是非常棒的。如果不能,该书的许多基本内容也可以免费在相应网站http://thinkingwithtype.com上找到。

你会经常翻阅的快速参考手册

我关于信息可视化的头号参考仍然是《华尔街日报信息图形指南》(诺顿),由我的前同事唐娜·王编写。这本书集结了你在一个地方准确且美观地可视化数据所需的所有技巧和诀窍。需要一个令人愉悦的调色板吗?检查。如何计算百分比变化的提醒?检查。如何处理负值或缺失值的想法?检查。这是一本快速阅读和无价的资源;如果你只购买一本关于信息可视化的书籍,那就应该是它。

灵感来源

关于信息可视化有数百本书籍和博客,并非所有这些都一样出色——因为并非所有的设计师都专注于准确呈现数据。虽然许多人提供付费的可视化工作坊(其中一些可能非常出色),但也有许多才华横溢的从业者免费分享他们的工作和想法。

凯撒·冯格的博客Junk Charts是信息可视化评论和重新设计的长期资源。在这里,你可以找到冯格超过 15 年的信息设计见解,其中包括突显数据谬误,有时即使是主流新闻机构和企业的可视化中也会出现的问题。对于任何想要了解即使是善意的可视化也可能出错的人来说,这是一个极好的资源。

莫娜·沙拉比The Guardian和其他地方的工作展示了伟大的数据可视化也可以通过手工制作,并反映出数据所涉及的个人化和人性化。

斯黛芬妮·波萨维克是一位艺术家和设计师,她在各种媒体上创建数据驱动的物体和可视化作品。

posted @ 2024-06-17 17:11  绝不原创的飞龙  阅读(36)  评论(0编辑  收藏  举报