数据科学学习指南-全-
数据科学学习指南(全)
原文:
zh.annas-archive.org/md5/9950f82165bee426119f99afc3ab612d译者:飞龙
序言
数据科学是令人兴奋的工作。从混乱的数据中获得洞察力对于业务、医学、政策等各种决策具有重要价值。本书《学习数据科学》旨在为读者做好数据科学的准备。为此,我们设计了以下特别功能:
焦点放在基础上
技术变迁无常。尽管本书中使用特定技术,但我们的目标是为读者提供数据科学的基础构建块。我们通过揭示如何思考数据科学问题和挑战,以及涵盖各个技术背后的基础知识来实现这一点。我们的目标是即使技术变化,也能为读者提供帮助。
覆盖整个数据科学生命周期
我们不仅仅关注单一主题,比如如何处理数据表或如何应用机器学习技术,而是覆盖整个数据科学生命周期——提出问题、获取数据、理解数据和理解世界的过程。完成整个生命周期通常是数据科学家工作中最难的部分。
使用真实数据
为了准备处理实际问题,我们认为从使用真实数据的例子中学习至关重要,这些数据有其缺陷和优点。我们在选择本书中呈现的数据集时,精心挑选了对实际数据分析产生影响的数据,而不是使用过于精炼或合成的数据。
通过案例研究应用概念
本书中我们包含了一些扩展案例研究,这些案例研究追随或扩展了其他数据科学家的分析。这些案例研究向读者展示了如何在实际环境中应用数据科学生命周期。
结合计算和推断思维
在工作中,数据科学家需要预见他们编写代码时的决策如何以及数据集的大小可能会如何影响统计分析。为了为读者未来的工作做好准备,《学习数据科学》整合了计算和统计思维。我们还通过模拟研究而非数学证明来激励统计概念。
本书的文本和代码都是开源的,在 GitHub 上可获取。
预期的背景知识
我们期望读者精通 Python,并了解如何使用内置的数据结构如列表、字典和集合;导入和使用其他包中的函数和类;并且能够从头开始编写函数。我们还使用numpy Python 包而不进行介绍,但不要求读者具有太多使用经验。
如果读者对概率、微积分和线性代数有一些了解,他们将从本书中受益更多,但我们的目标是直观地解释数学思想。
本书的组织结构
本书共有 21 章,分为六个部分:
第一部分(章节 1–5)
第一部分 描述了生命周期是什么,以基本水平通过了整个生命周期,并介绍了本书中使用的术语。该部分以一个关于公交到站时间的短期案例研究结束。
第二部分(第 6 – 7 章)
第二部分 介绍数据框架和关系以及如何编写代码使用 pandas 和 SQL 操作数据。
第三部分(第 8 – 12 章)
第三部分 关注数据获取,发现其特征和发现问题。在理解这些概念后,读者可以获取一个数据文件,并向他人描述数据集的有趣特征。本部分以一个关于空气质量的案例研究结束。
第四部分(第 13 – 14 章)
第四部分 探讨了广泛使用的替代数据源,如文本,二进制和来自网络的数据。
第五部分(第 15 – 18 章)
第五部分 专注于使用数据理解世界。除了模型拟合,特征工程和模型选择之外,它还涵盖了推断主题,如置信区间和假设检验。本部分以一个关于为肯尼亚兽医预测驴重的案例研究结束。
第六部分(第 19 – 21 章)
第六部分 完成了我们对使用逻辑回归和优化进行监督学习的研究。它以一个案例研究结束,预测新闻文章是否做出真实或虚假声明。
本书末尾,我们提供了有关本书引入的许多主题的更多学习资源,并提供了使用的完整数据集列表。
本书中使用的约定
本书使用以下排版约定:
斜体
表示新术语,URL,电子邮件地址,文件名和文件扩展名。
等宽字体
用于程序清单,以及段落内引用程序元素,如变量或函数名,数据库,数据类型,环境变量,语句和关键字。
等宽粗体
显示用户应按字面输入的命令或其他文本。
等宽斜体
显示应用程序中应替换为用户提供值或根据上下文确定值的文本。
注意
此元素表示一般注释。
提示
此元素表示提示。
警告
此元素表示警告或注意。
使用代码示例
补充材料(代码示例,练习等)可在 learningds.org 下载。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至 bookquestions@oreilly.com。
本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在自己的程序和文档中使用它。除非您复制了大量代码片段,否则无需征得我们的许可。例如,编写使用本书多个代码片段的程序不需要许可。售卖或分发来自 O’Reilly 书籍的示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到您产品的文档中需要许可。
我们感谢您的署名。署名通常包括标题、作者、出版商和 ISBN。例如:“学习数据科学 by Sam Lau, Joseph Gonzalez, and Deborah Nolan (O’Reilly). Copyright 2023 Sam Lau, Joseph Gonzalez, and Deborah Nolan, 978-1-098-11300-1.”
如果您认为使用代码示例超出了公平使用范围或上述权限,请随时通过 bookquestions@oreilly.com 与我们联系。
O’Reilly 在线学习
注意
40 多年来,O’Reilly Media 提供技术和商业培训,为企业的成功提供知识和见解。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和 200 多个其他出版商的大量文本和视频。欲了解更多信息,请访问https://oreilly.com.
如何联系我们
请将有关本书的评论和问题发送给出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-889-8969(美国或加拿大)
-
707-829-7019(国际或本地)
-
707-829-0104(传真)
-
support@oreilly.com
我们为本书设立了网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/learning-data-science获取这些信息。
获取有关我们的书籍和课程的新闻和信息,请访问https://oreilly.com.
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media.
在 Twitter 上关注我们:https://twitter.com/oreillymedia.
在 YouTube 上观看我们:https://youtube.com/oreillymedia.
致谢
这本书源于我们共同设计和教授的“数据科学原理与技术”课程,这是加州大学伯克利分校的本科课程。我们首次在 2017 年春季教授了“数据 100”,以响应学生对第二门数据科学课程的需求;他们希望这门课程能为他们在数据科学的深造和职场生涯做好准备。
自那次首次开设以来,我们教授的成千上万名学生对我们是一种激励。我们还受益于与其他讲师的合作教学,包括 Ani Adhikari、Andrew Bray、John DeNero、Sandrine Dudoit、Will Fithian、Joe Hellerstein、Josh Hug、Anthony Joseph、Scott Lee、Fernando Perez、Alvin Wan、Lisa Yan 和 Bin Yu。我们特别感谢 Joe Hellerstein 在数据整理方面的见解,Fernando Perez 鼓励我们包含像 NetCDF 这样的更复杂的数据结构,Josh Hug 提出了 PurpleAir 案例研究的想法,Duncan Temple Lang 在课程早期版本上的合作。我们还感谢伯克利的学生担任助教,并特别提到那些为本书的早期版本做出贡献的人员:Ananth Agarwal、Ashley Chien、Andrew Do、Tiffany Jann、Sona Jeswani、Andrew Kim、Jun Seo Park、Allen Shen、Katherine Yen 和 Daniel Zhu。
本书的核心部分是我们整理和分析的许多数据集,我们非常感谢那些使他们的数据向我们开放和可用的个人和组织。在本书的结尾,我们列出这些贡献者以及原始数据来源,以及相关的研究论文、博客文章和报告。
最后,我们感谢 O’Reilly 团队的工作,将这本书从课堂笔记转化为出版物,特别是 Melissa Potter、Jess Haberman、Aaron Black、Danny Elfanbaum 和 Mike Loukides。我们还要感谢技术审阅人员的评论,这些评论改进了本书:Sona Jeswani、Thomas Nield、Siddharth Yadav 和 Abhijit Dasgupta。
第一部分:数据科学生命周期
第一章:数据科学生命周期
数据科学是一个快速发展的领域。在撰写本文时,人们仍在努力确定数据科学究竟是什么,数据科学家做什么,以及数据科学家应该具备哪些技能。然而,我们知道的是,数据科学利用统计学和计算机科学的方法和原则结合在一起,从数据中获取洞见。学习计算机科学和统计学的结合使我们成为更好的数据科学家。我们还知道,我们获取的任何洞见都需要在我们正在解决的问题的背景下进行解释。
本书涵盖了数据科学家需要帮助做出各种重要决策的基本原理和技能。凭借技术技能和概念理解,我们可以处理以数据为中心的问题,例如评估疫苗的有效性、自动过滤假新闻、校准空气质量传感器,并为分析师在政策变更方面提供建议。
为了帮助您掌握更大的局面,我们围绕一种称为数据科学生命周期的工作流程组织了主题。在本章中,我们介绍这个生命周期。与其他数据科学书籍不同,这些书籍倾向于专注于生命周期的某一部分,或者只涉及计算或统计主题,我们涵盖了从头到尾的整个周期,并考虑了统计和计算两个方面。
生命周期的阶段
图 1-1 显示了数据科学生命周期,分为四个阶段:提出问题、获取数据、理解数据和理解世界。我们特意使这些阶段变得广泛。根据我们的经验,生命周期的机制经常变化。计算机科学家和统计学家继续开发用于处理数据的新软件包和编程语言,并开发更专业的新方法。

图 1-1. 数据科学生命周期的四个高级阶段,箭头表示这些阶段如何相互关联
尽管发生了这些变化,我们发现几乎每个数据项目都包括以下四个阶段:
提出问题
提出好问题是数据科学的核心,识别不同类型的问题指导我们的分析。我们涵盖四类问题:描述性、探索性、推论性和预测性。例如,“房价随时间的变化是如何的?”是描述性的,而“房屋的哪些方面与售价相关?”是探索性的。将一个广泛的问题缩小到可以用数据回答的问题是生命周期中这个第一阶段的关键元素。这可能涉及咨询参与研究的人员、弄清如何测量某事以及设计数据收集协议。清晰而专注的研究问题帮助我们确定我们需要的数据、要查找的模式以及如何解释结果。它还可以帮助我们完善问题、识别所提出的问题类型,并计划生命周期的数据收集阶段。
获取数据
当数据昂贵且难以收集时,当我们的目标是从数据中推广到世界时,我们的目标是为收集数据定义精确的协议。其他时候,数据是廉价且易于获取的。这在在线数据源中尤其如此。例如,Twitter让人们可以快速下载数百万个数据点。当数据丰富时,我们可以通过获取和探索数据,然后进一步明确研究问题来开始分析。在这两种情况下,大多数数据都有缺失或异常值以及其他我们需要考虑的异常情况。无论数据来自何处,我们都需要检查数据质量。同样重要的是考虑数据的范围;例如,我们确定数据的代表性以及在收集过程中可能存在的潜在偏差来源。这些考虑有助于我们确定我们对发现的信任程度。通常,我们必须在更正式地分析数据之前操作数据。我们可能需要修改结构、清理数据值并转换测量值以准备分析。
理解数据
在获取和准备数据后,我们希望仔细检查它们,而探索性数据分析通常是关键。在我们的探索中,我们制作图表以揭示有趣的模式并以视觉方式总结数据。我们还继续寻找数据中的问题。当我们搜索模式和趋势时,我们使用汇总统计和构建统计模型,如线性和逻辑回归。根据我们的经验,这个生命周期的阶段是高度迭代的。理解数据还可能使我们回到数据科学生命周期的早期阶段。我们可能发现需要修改或重新进行数据清洗和操作,获取更多数据以补充我们的分析,或者鉴于数据的限制,重新定义我们的研究问题。我们在这个阶段进行的描述性和探索性分析可能已经足够回答我们的问题,或者我们可能需要进入下一个阶段以便对我们的数据进行推广。
理解世界
当我们的目标纯粹是描述性或探索性时,分析就止步于生命周期的“理解数据”阶段。在其他时候,我们的目标是量化我们发现的趋势在我们数据之外的泛化程度。我们可能希望使用我们拟合到数据的模型来从样本推断到总体,以便对世界进行推断或预测未来的观察。为了从样本到总体进行推断,我们使用统计技术如 A/B 测试和置信区间。为了对未来的观察进行预测,我们创建预测区间并使用数据的训练-测试分割。
对于生命周期的每个阶段,我们解释理论概念,介绍数据技术和统计方法,并展示它们在实际示例中的运用。在整个过程中,我们依赖其他数据科学家的真实数据和分析,而不是虚构的数据,因此您可以学习如何进行自己的数据获取、清洗、探索和正式分析,并得出合理的结论。本书的每一章通常都集中于数据科学生命周期的一个阶段,但我们还包括案例研究章节,展示整个生命周期的实际应用。
注意
理解探索、推断、预测和因果之间的差异可能是一个挑战。我们很容易在数据中找到的相关性与因果关系混淆。例如,探索性或推断性分析可能会针对“暴露于空气污染较多的人是否具有更高的肺病发病率?”这样的问题寻找相关性,而因果性问题可能会问:“给维基百科贡献者颁发奖是否会增加生产力?”除非我们进行了随机实验(或近似实验),通常我们无法回答因果性问题。我们在整本书中都强调这些重要的区别。
生命周期的示例
本书中放置了几个案例研究,涵盖整个数据科学生命周期。这些案例研究有双重作用。它们专注于生命周期中的一个阶段,以提供本书部分中的具体示例,并演示整个循环。
第五章的重点是问题的相互作用以及数据如何用来回答问题。简单的问题“为什么我的公交总是迟到?”提供了一个丰富的案例研究,对于初学者来说足够基础,可以追踪生命周期的各个阶段,但又足够微妙,可以展示我们如何应用统计和计算思维来回答问题。在这个案例研究中,我们进行了模拟研究,以了解乘客等待时间的分布。我们还拟合了一个简单的模型来总结等待时间的统计数据。这个案例研究还展示了作为数据科学家,您如何收集自己的数据来回答您感兴趣的问题。
第十二章研究了在美国各地使用的大众市场空气传感器的准确性。我们设计了一种利用环保局维护的高精度传感器数据来改进廉价传感器读数的方法。这个案例研究展示了如何通过来自严格维护和精确的政府监测设备的数据来改进众包开放数据。在这个过程中,我们专注于清理和合并来自多个来源的数据,但我们也拟合模型来调整和改进空气质量测量。
在第十八章中,我们关注模型构建和预测。但我们涵盖了整个生命周期,并看到问题的兴趣如何影响我们构建的模型。我们的目标是帮助肯尼亚农村的兽医,他们无法访问用于称重驴的称重器,以便为生病的动物开药。当我们学习研究设计,清理数据,并在简单性与准确性之间取得平衡时,我们评估了我们模型的预测能力,并展示了科学家如何与面临实际问题的人合作,并协助他们找到解决方案。
最后,在第二十一章中,我们试图通过算法区分手工分类的新闻故事中的假新闻和真实新闻。在这个案例研究中,我们再次看到,可读性强的信息为数据科学家开发新技术并研究当今重要问题创造了惊人的机会。这些数据来自于网上新闻报道,并由阅读这些报道的人员将其分类为假新闻或真实新闻。我们还看到,数据科学家如何以创造性的方式将一般信息(如新闻文章的内容)转化为可分析的数据,以解决当前的热点问题。
概要
数据科学生命周期为本书提供了一个组织结构。在处理来自科学、医学、政治、社交媒体和政府等多个来源的数据集时,我们始终牢记这一生命周期。第一次使用数据集时,我们提供了数据收集背景的上下文,对研究数据感兴趣的问题以及理解数据所需的描述。通过这种方式,我们旨在贯彻整本书的良好数据科学实践。
生命周期的第一阶段——提出问题——通常被视为需要应用技术以获得数字的问题,比如“这个 A/B 测试的p-值是多少?”或者在实践中经常见到的模糊问题,比如“我们能恢复美国梦吗?”回答第一类问题几乎没有练习开发研究问题的实践经验。而回答第二类问题则很难在没有指导的情况下进行,指导需要将兴趣的一般领域转化为可以用数据回答的问题。提出问题与理解数据局限性以回答问题之间的相互作用是下一章的主题。
第二章:问题与数据范围
作为数据科学家,我们使用数据来回答问题,数据收集过程的质量可以显著影响数据的有效性和准确性,我们从分析中得出的结论的强度以及我们做出的决策。在本章中,我们描述了理解数据收集和评估数据在解决感兴趣问题的有用性方面的一般方法。理想情况下,我们希望数据能够代表我们研究的现象,无论该现象是人群特征、物理模型还是某种社会行为。通常情况下,我们的数据并不包含完整信息(范围在某种程度上受限),但我们希望使用数据准确描述人口、估算科学数量、推断特征之间的关系形式或预测未来结果。在所有这些情况下,如果我们的数据不能代表我们研究对象,那么我们的结论可能会受到限制,可能具有误导性,甚至是错误的。
为了激励对这些问题的思考,我们首先以大数据的力量和可能出现的问题为例子。然后,我们提供一个框架,可以帮助您将研究目标(您的问题)与数据收集过程联系起来。我们称之为数据范围^(1),并提供术语来帮助描述数据范围,以及来自调查、政府数据、科学仪器和在线资源的示例。在本章后期,我们考虑数据准确性的含义。在那里,我们介绍不同形式的偏差和变异,并描述可能发生这些情况的情形。整个过程中,例子涵盖了您作为数据科学家可能使用的数据种类的光谱;这些例子来自科学、政治、公共卫生和在线社区。
大数据和新机遇
公开可用数据的巨大增加为数据科学创造了新的角色和机会。例如,数据记者在数据中寻找有趣的故事,就像传统报道记者寻找新闻故事一样。数据记者的生命周期始于寻找可能有趣故事的现有数据,而不是从研究问题开始,然后找出如何收集新数据或使用现有数据来解决问题。
公民科学项目是另一个例子。它们吸引许多人(和仪器)参与数据收集。这些数据集体上供研究人员组织项目使用,并且通常在公共存储库中供大众进一步调查使用。
行政和组织数据的可用性创造了其他机会。研究人员可以将从科学研究中收集的数据与例如医疗数据进行链接;这些行政数据之所以收集,并不是因为直接源于感兴趣的问题,但它们在其他情境中可能很有用。这种链接可以帮助数据科学家扩展其分析的可能性,并交叉检查其数据的质量。此外,找到的数据可以包括数字痕迹,例如您的网络浏览活动、社交媒体上的发布以及您的在线朋友和熟人网络,它们可能非常复杂。
当我们拥有大量的行政数据或广泛的数字痕迹时,很容易将它们视为比传统的小型研究更具决定性的数据。我们甚至可能认为这些大型数据集可以取代科学研究,本质上是人口普查。这种过度自信被称为“大数据傲慢”。具有广泛范围的数据并不意味着我们可以忽略数据代表性的基础问题,也不能忽略测量、依赖性和可靠性问题。(甚至很容易仅仅因为巧合而发现毫无意义或荒谬的关系。)一个众所周知的例子就是谷歌流感趋势追踪系统。
示例:谷歌流感趋势
数字流行病学是流行病学的一个新分支,利用公共卫生系统之外产生的数据来研究人群中的疾病模式和健康动态。谷歌流感趋势(Google Flu Trends,GFT)追踪系统是数字流行病学的早期示例之一。2007 年,研究人员发现,统计人们搜索与流感相关术语的次数可以准确估计流感病例数。这一明显的成功引起了轰动,许多研究人员对大数据的可能性感到兴奋。然而,GFT 未能达到预期,并在 2015 年被放弃。
发生了什么问题?毕竟,GFT 利用了来自在线查询的数百万数字痕迹来预测流感活动。尽管最初取得了成功,在 2011-2012 年流感季节中,谷歌的数据科学家们发现,GFT 并不能替代由美国疾病控制与预防中心(CDC)从全国各地实验室收集的三周前统计数据。相比之下,GFT 高估了 CDC 的数据,有 108 周中的 100 周。周复一周,尽管基于大数据,GFT 对流感病例的估计仍然过高:

在这幅图中,从第 412 周到第 519 周,GFT(实线)高估了实际的 CDC 报告(虚线)100 倍。还在此处绘制了基于三周前 CDC 数据和季节性趋势的模型预测(点线),这比 GFT 更接近实际情况。
数据科学家发现,一个简单的模型,基于过去的 CDC 报告,使用三周前的 CDC 数据和季节性趋势,比 GFT 在预测流感流行方面做得更好。GFT 忽视了可以通过基本统计方法提取的大量信息。这并不意味着从在线活动中获取的大数据是无用的。事实上,研究人员已经表明,GFT 数据与 CDC 数据的结合可以显著改善 GFT 预测和基于 CDC 的模型。通常情况下,结合不同的方法会比单一方法带来改进。
GFT 的例子告诉我们,即使我们拥有大量信息,数据与提出问题之间的关联至关重要。理解这一框架可以帮助我们避免回答错误的问题,将不适当的方法应用于数据,并夸大我们的发现。
注意
在大数据时代,我们往往会被诱惑收集更多数据来精确回答问题。毕竟,人口普查给了我们完美的信息,那么大数据不应该几乎是完美的吗?不幸的是,这通常并非如此,特别是在管理数据和数字痕迹方面。你想研究的人群的一小部分无法接触(参见第三章中的 2016 年选举反转)或者测量过程本身(就像这个 GFT 的例子)可能会导致预测不准确。重要的是考虑数据的范围,因为它与正在调查的问题相关联。
需要牢记的一个关键因素是数据的范围。范围包括考虑我们想要研究的人群,如何获取有关该人群的信息,以及我们实际测量的内容。深思熟虑这些要点可以帮助我们发现方法上的潜在漏洞。我们将在下一节中详细探讨这一点。
目标人群、访问框架和样本
在数据生命周期的重要初始步骤是将感兴趣的问题表达在主题领域的背景下,并考虑问题与收集的数据之间的联系。在甚至考虑分析或建模步骤之前这样做是一个很好的做法,因为它可能会揭示问题和数据之间的不一致。作为将数据收集过程与调查主题联系起来的一部分,我们识别人群、访问人群的手段、测量工具和收集过程中使用的附加协议。这些概念——目标人群、访问框架和样本——帮助我们理解数据的范围,无论我们的目标是获取关于人群、科学数量、物理模型、社会行为或其他内容的知识。
目标人群
目标人群包括构成你最终意图描述和得出结论的人群的元素集合。元素可以是人群中的一个人,选民中的一名选民,一系列推文中的一条推文,或者一个州中的一个县。我们有时将一个元素称为单位或原子。
访问框架
访问框架是可供测量和观察的元素集合。这些单位是你可以研究目标人群的对象。理想情况下,访问框架和人群完全对齐,意味着它们由完全相同的元素组成。然而,访问框架中的单位可能只是目标人群的一个子集;此外,框架可能包括不属于人群的单位。例如,为了了解选民在选举中的投票意向,你可能会通过电话联系人们。你打电话的人可能不是选民,因此他们在你的框架中但不在人群中。另一方面,从未接听未知号码的选民无法联系到,因此他们在人群中但不在你的框架中。
样本
样本是从访问框架中取出的单位子集,用于观察和测量。样本为你提供了分析数据,从而进行关于感兴趣人群的预测或概括的基础。当资源被用于跟进非响应者和追踪难以找到的单位时,一个小样本比一个大样本或试图对被忽视人群子集进行人口普查更为有效。
访问框架的内容与目标人群相比,以及从框架中选择单位到样本中的方法,是确定数据是否可以被视为目标人群代表性的重要因素。如果访问框架不代表目标人群,那么样本数据很可能也不具有代表性。如果单位的抽样方式存在偏差,也会引发代表性问题。
你还需要考虑数据范围中的时间和地点。例如,在某个疾病流行的地区测试药物试验的有效性可能与在感染率较低的世界其他地区进行的试验不相称(见 第三章)。此外,为了研究随时间变化的数据收集,如大气中二氧化碳(CO[2])的月度测量(见 第九章)和预测流感趋势的谷歌搜索的每周报告,具有时间结构,我们在检查数据时需要注意。在其他时候,数据可能存在空间模式。例如,本节稍后描述的环境健康数据是针对加利福尼亚州每个人口普查区报告的,我们可能会制作地图以寻找空间相关性。
如果你没有收集数据,你需要考虑是谁收集了数据以及出于什么目的。现在更多的数据是被动收集而不是出于特定目的而收集的,这一点尤为重要。仔细审视发现的数据,并问自己这些数据是否可以用来解决你的问题,可以避免进行无效的分析或得出不恰当的结论。
对于以下小节中的示例,我们从一个一般性问题开始,将其缩小为可以用数据回答的问题,并在此过程中确定目标人群、访问框架和样本。这些概念在图表中用圆圈和矩形表示,这些形状的重叠配置有助于揭示范围的关键方面。在每个示例中,我们还描述了数据范围的相关时间和空间特征。
例如:什么使在线社区的成员活跃?
Wikipedia 上的内容是由属于 Wikipedia 社区的志愿者编写和编辑的。这个在线社区对 Wikipedia 的成功和活力至关重要。为了理解如何激励在线社区的成员,研究人员进行了一项关于 Wikipedia 贡献者的实验。一个更具体的问题是:奖励是否会增加 Wikipedia 贡献者的活跃度?在这个实验中,目标人群是前一个月内对 Wikipedia 贡献最活跃的顶尖贡献者——即前 1% 的最活跃贡献者。接入框架排除了那些在该月已经接受过奖励(奖励)的人群。接入框架故意排除了人群中的一些贡献者,因为研究人员想要衡量奖励的影响,而那些已经接受了一种奖励的人可能会有不同的行为(见 图 2-1)。

图 2-1. Wikipedia 实验中范围的表现
样本是从框架中随机选择的 200 名贡献者。观察了这些贡献者 90 天,并收集了他们在 Wikipedia 上的数字活动迹象。请注意,贡献者群体并非静态;有定期的更替。在研究开始前的一个月内,超过 144,000 名志愿者为 Wikipedia 创作内容。从这群顶尖贡献者中选择限制了研究结果的普遍性,但鉴于顶尖贡献者群体的规模,如果能通过非正式奖励影响他们以维持或增加其贡献,这仍然是一个有价值的发现。
在许多实验和研究中,我们无法包括所有的人口单位在框架内。通常情况下,接入框架包括愿意参与研究/实验的志愿者。
例子:谁会赢得选举?
2016 年美国总统选举的结果让许多人和许多民意调查员感到意外。大多数选举前的民意调查预测希拉里·克林顿会击败唐纳德·特朗普。政治民意调查是一种试图在选举之前衡量人们会投票给谁的公众意见调查。由于意见随时间变化,焦点缩小到了一个“赛马”问题,即受访者被问及如果明天举行选举,他们会投票给哪位候选人:候选人 A 还是候选人 B。
民意调查定期在总统竞选期间进行,随着选举日的临近,我们预计民意调查会更好地预测结果,因为偏好会稳定下来。民意调查通常也会在州内进行,然后再结合起来预测总体赢家。因此,民意调查的时间和地点都很重要。民意调查机构也很重要,有些机构一直比其他机构更准确。
在这些选举前调查中,目标人群包括将在选举中投票的人,例如此例中的 2016 年美国总统选举。然而,调查员只能猜测某人是否会在选举中投票,因此访问框架包括那些被认为可能是选民的人(通常基于过去的投票记录,但也可能使用其他因素)。由于人们通过电话联系,因此访问框架仅限于拥有座机或移动电话的人群。样本由框架中按随机拨号方案选择的人组成(见 图 2-2)。

图 2-2. 2016 年总统选举调查中范围的表示
在 第三章 中,我们讨论了人们不愿接听电话或参与民意调查对选举预测的影响。
示例:环境危害如何影响个体健康?
为了解决这个问题,加利福尼亚环境保护局(CalEPA)、加利福尼亚州环境健康危害评估办公室(OEHHA)和公众共同开发了 CalEnviroScreen 项目。该项目利用从美国人口普查的人口统计数据、加利福尼亚州卫生保健访问和信息部门的健康统计数据,以及加利福尼亚州空气资源局维护的空气监测站收集的污染测量数据,研究加利福尼亚社区中人口健康与环境污染的关系。
理想情况下,我们希望研究加利福尼亚州的人群,并评估这些环境危害对个体健康的影响。然而,在这种情况下,数据只能在人口普查区一级别获取。访问框架由居住在同一人口普查区的群体组成。因此,框架中的单位是人口普查区,样本是一个普查,即州内所有普查区,因为提供了所有普查区的数据(见 图 2-3)。

图 2-3. CalEnviroScreen 项目的范围;访问框架中的网格代表人口普查区
不幸的是,我们无法分解一个普查区内的信息以研究个人。这种聚合影响了我们可以探讨的问题以及我们可以得出的结论。例如,我们可以询问加利福尼亚社区因哮喘住院率与空气质量之间的关系。但是我们无法回答最初提出的有关个体健康的问题。
这些示例展示了目标、访问框架和样本的可能配置。当一个框架无法覆盖所有人时,我们应考虑这些缺失信息可能如何影响我们的发现。同样地,我们要思考当一个框架包括非人口中的成员时可能会发生什么。此外,抽样技术可以影响样本对总体的代表性。当你考虑推广你的数据发现时,你还需要考虑收集数据所用仪器和程序的质量。如果你的样本是一次完全符合目标的普查,但信息收集不当,那么你的发现将毫无价值。这是下一节的主题。
仪器和协议
当我们考虑数据的范围时,也要考虑用于进行测量和测量过程的仪器和协议,我们称之为协议。对于调查而言,仪器通常是样本中的个体填写的问卷。调查的协议包括如何选择样本,如何跟进非回应者,访谈者培训,保护机密性等。
良好的仪器和协议对所有类型的数据收集都很重要。如果我们想测量自然现象,如大气中的二氧化碳,我们需要量化仪器的准确性。校准仪器和进行测量的协议对于获取准确的测量结果至关重要。仪器可能会失校,测量结果可能随时间漂移,导致测量极不准确。
在实验中,协议也至关重要。理想情况下,任何可能影响实验结果的因素都应受到控制。例如,温度、时间、医疗记录的机密性,甚至测量顺序都需要保持一致,以排除这些因素可能带来的影响。
随着数字痕迹的增加,支持在线活动的算法是动态的,并不断进行重新设计。例如,谷歌的搜索算法不断调整以提高用户服务和广告收入。搜索算法的变化可以影响从搜索中生成的数据,进而影响基于这些数据构建的系统,例如谷歌流感趋势跟踪系统。这种变化环境可能使得维护数据收集协议变得不可行,并且难以复制研究结果。
许多数据科学项目涉及将来自多个来源的数据进行关联。每个来源应通过这种数据范围构建来检查,考虑到各个来源的差异。此外,用于合并多个来源数据的匹配算法需要被清楚理解,以便比较来源的人口和框架。
用于研究自然现象的仪器测得的测量可以在目标、访问框架和样本的范围图中进行描述。这种方法有助于理解仪器的准确性。
测量自然现象
引入的用于观察目标群体的范围图可以扩展到我们想要测量数量的情况,比如空气中的粒子计数、化石的年龄或光速。在这些情况下,我们将要测量的数量视为一个未知的确切值。(这个未知值通常被称为参数。)我们可以根据这种情况调整我们的范围图:我们将目标缩小到代表未知的一个点;仪器的准确性作为访问框架;样本由仪器测得的测量组成。你可以把框架想象成一个飞镖靶,仪器是扔飞镖的人,而飞镖落在靶心周围。飞镖的散布对应仪器测量的结果。目标点不被飞镖投手看到,但理想情况下它与靶心重合。
为了说明测量误差及其与抽样误差的关系,我们考虑测量空气中 CO[2] 浓度的问题。
例子:空气中 CO[2] 的水平是多少?
CO[2] 是全球变暖的重要信号,因为它在地球大气中捕获热量。没有 CO[2],地球将会异常寒冷,但这是一个微妙的平衡。CO[2] 浓度的增加推动全球变暖,并威胁到我们地球的气候。为了解决这个问题,自 1958 年以来在毛纳罗亚观测站对 CO[2] 浓度进行了监测。这些数据为理解全球变暖的威胁提供了关键的基准。
在考虑数据的范围时,我们要考虑数据收集的地点和时间。科学家选择在毛纳罗亚火山测量 CO[2],因为他们希望在空气中测量 CO[2] 的背景水平。毛纳罗亚位于太平洋中,远离污染源,观测站位于一个被裸露的火山岩石包围的山顶上,远离可以从空气中去除 CO[2] 的植物。
测量 CO[2] 的仪器尽可能准确非常重要。严格的协议被制定用来保持仪器处于最佳状态。例如,毛纳罗亚定期使用不同类型的设备对空气样本进行测量,并将其他样本送往实验室进行更精确的测量。这些测量有助于确定仪器的准确性。此外,每小时对一个参考气体进行 5 分钟的测量,每天对另外两个参考气体进行 15 分钟的测量。这些参考气体具有已知的 CO[2] 浓度。将测量浓度与已知值进行比较有助于识别仪器中的偏差。
尽管毛纳洛亚岛上背景空气中的二氧化碳相对稳定,但在任何小时内测量的五分钟平均浓度与小时平均值会有偏差。这些偏差反映了仪器的精确度和气流的变化。
数据收集的范围可以总结如下:在毛纳洛亚岛上空的特定位置,在特定的一个小时内,存在真实的二氧化碳浓度背景;这是我们的目标(参见图 2-4)。仪器进行测量并报告五分钟平均值。这些读数构成一个样本,包含在访问帧中,即飞镖板。如果仪器工作正常,飞镖靶心与目标(一小时平均二氧化碳浓度)重合,测量值集中在靶心周围,偏差约为 0.30 百万分之一(ppm)。二氧化碳的测量是每百万份干燥空气中二氧化碳分子的数量,因此测量单位是 ppm。

图 2-4. 访问帧代表仪器的精确度;星星代表感兴趣的真实值
我们在下一节继续使用飞镖板的类比,介绍偏差和变异的概念,描述样本可能不代表总体的常见方式,并将精度与协议联系起来。
精确度
在人口普查中,访问帧与人口匹配,样本捕捉整个人口。在这种情况下,如果我们进行了设计良好的问卷调查,那么我们对人口有完整准确的信息,范围是完美的。类似地,在测量大气中的二氧化碳浓度时,如果我们的仪器具有完美的精确度并正确使用,那么我们可以测量二氧化碳浓度的确切值(忽略空气波动)。这些情况是罕见的,如果不是不可能的话。在大多数情况下,我们需要量化测量的准确度,以便将我们的发现推广到未被观察到的领域。例如,我们经常使用样本来估计人群的平均值,从测量中推断科学未知值的值,或预测新个体的行为。在每种情况下,我们还希望有一个可量化的精度度量。我们想知道我们的估计、推断和预测与真实情况有多接近。
早些时候引入的飞镖投掷到飞镖板的类比在理解精度方面很有帮助。我们将精度分为两个基本部分:偏差和精确度(也称为变异性)。我们的目标是让飞镖击中飞镖板上的靶心,并使靶心与看不见的目标对齐。飞镖在板上的分布代表我们测量中的精度,从靶心到我们正在瞄准的未知值的差距则代表偏差。
图 2-5 显示了低偏差和高偏差以及精度的组合。在这些图中,点代表采取的测量,星代表真实的、未知的参数值。点在访问框架内形成一个散射,在这个散射中,点代表采取的测量,星代表真实的、未知的参数值。当访问框架的靶心大致位于星星上方时(顶行),测量点在感兴趣的值周围散布,偏差较小。较大的飞镖板(右侧)表示测量中的更广泛的分布(较低的精度)。

图 2-5. 低和高测量偏差和精度的组合
代表性数据将我们置于图表的顶行,其中偏差小,意味着未知的目标与靶心对齐。理想情况下,我们的仪器和协议将我们置于图表的左上部,那里的变化也很小。底行中点的模式系统地错过了目标值。增加样本量不会纠正这种偏差。
偏差类型
偏差有多种形式。我们在这里描述了一些经典类型,并将它们与我们的目标-访问-样本框架联系起来:
覆盖偏差
当访问框架不包括目标人群中的所有人时发生。例如,基于电话调查的调查无法接触到没有电话的人。在这种情况下,不能接触到的人可能在重要方面与访问框架中的人不同。
选择偏差
当用于选择样本单位的机制倾向于选择某些单位的频率超过它们应该被选择的频率时,就会出现覆盖偏差。例如,方便采样选择最容易获得的单位。当那些容易接触到的人与那些难以接触到的人在重要方面有所不同时,问题就会出现。另一个例子是,观察研究和实验通常依赖于志愿者(选择参与的人),而这种自我选择可能会导致偏差,如果志愿者与目标人群在重要方面不同。
非响应偏差
有两种形式:单位和项目。当被选样本中的某人不愿意参与时发生单位无响应(他们可能永远不会接听来自陌生人的电话)。当某人接听电话但拒绝回答特定的调查问题时发生项目无响应。如果选择不回答的人与选择回答的人有系统性差异,那么非响应可能会导致偏差。
测量偏差
当一个仪器系统地错过了一个方向的目标时发生。例如,低湿度可能会导致我们对空气污染的测量错误地偏高。此外,测量设备随时间变化可能变得不稳定并漂移,从而产生系统误差。在调查中,当问题措辞不清或引导性,或者受访者可能不愿诚实回答时,可能会出现测量偏差。
这些偏见中的每一种都可能导致数据未集中在未知的目标值上。通常情况下,我们无法评估偏见的潜在幅度,因为关于那些不在访问范围之外、不太可能被选中作为样本或不愿意回应的人几乎没有信息可用。协议是减少这些偏见来源的关键。从框架中选择样本或将单位分配给实验条件的机会性机制可以消除选择偏见。非响应跟进协议可以鼓励参与,从而减少非响应偏见。试点调查可以改善问题措辞,从而减少测量偏见。校准仪器的程序和随机顺序进行测量的协议可以减少测量偏见。
在 2016 年美国总统选举中,非响应偏见和测量偏见是预测胜者不准确的关键因素。几乎所有选民在选举前预测希拉里将击败特朗普。特朗普意外获胜令人惊讶。选举后,许多民意调查专家试图诊断民意调查中的问题所在。美国公共舆论研究协会发现预测有两个关键原因出现了偏差:
-
大学受教育选民被过度代表。大学受教育选民比受教育较少的人更有可能参与调查,并且在 2016 年,他们更有可能支持希拉里。更高的受教育选民的响应率使样本存在偏见,并且高估了对希拉里的支持。
-
选民在选举前几天临时决定或改变他们的偏好。由于民意调查是静态的,只能直接测量当前的信仰,它无法反映态度的变化。
很难弄清楚人们是忍住了他们的偏好还是改变了他们的偏好,以及这种偏见有多大。然而,选后民调帮助民意调查专家了解事后发生了什么。它们表明,在密集竞争的州(例如密歇根州),许多选民在竞选的最后一周作出了选择,而且这群人大多支持特朗普。
并非在所有情况下都需要避免偏见。如果仪器精度高(低方差)且偏见小,则该仪器可能优于另一种具有更高方差但无偏见的仪器。例如,偏倚研究可能对试点调查仪器或捕获大型研究设计中的有用信息有用。许多时候,我们最多只能招募研究志愿者。鉴于这种限制,仍然有可能将这些志愿者纳入研究,并使用随机分配将其分为治疗组。这就是随机对照实验背后的理念。
无论是否存在偏差,数据通常也表现出变异。可以通过使用偶然机制选择样本来有意引入变异,也可以通过仪器的精确性自然发生。在下一节中,我们将识别三种常见的变异来源。
变异类型
以下类型的变异由偶然机制产生,并具有可量化的优势:
抽样变异
使用偶然选择样本的结果。在这种情况下,原则上我们可以计算特定元素集被选为样本的机会。
分配变异
在控制实验中发生,当我们将单位随机分配到处理组时。在这种情况下,如果我们以不同方式分割单位,那么我们可以从实验中获得不同的结果。这种分配过程使我们能够计算特定组分配的机会。
测量误差
测量过程的结果。如果用于测量的仪器没有漂移或偏差,并且具有可靠的误差分布,那么当我们对同一对象进行多次测量时,我们会得到围绕真实值中心的随机测量变化。
瓮模型 是一个简单的抽象,对理解变异很有帮助。这个模型建立了一个容器(一个瓮,类似于花瓶或桶),里面装满了标记过的相同弹珠,我们使用从瓮中抽取弹珠的简单动作来推理取样方案、随机对照实验和测量误差。对于这些变异类型,瓮模型帮助我们使用概率或模拟估计变异的大小(参见第三章)。选择维基百科贡献者接受非正式奖励的例子提供了瓮模型的两个示例。
回顾维基百科实验,从 1440 位顶级贡献者中随机选择了 200 位贡献者。然后,这 200 位贡献者再次随机分成两组,每组 100 人。一组获得非正式奖励,另一组没有。我们使用瓮模型来描述这个选择和分组过程:
-
想象一个装满 1440 颗相同形状和大小的弹珠的瓮,每颗弹珠上写着 1440 个 Wikipedia 用户名之一。(这是访问框架。)
-
仔细将弹珠在瓮中混合,选择一个弹珠并将其放在一边。
-
重复混合并选择弹珠以获得 200 个弹珠。
从样本中抽取的弹珠。接下来,为了确定哪 200 位贡献者将获得奖励,我们使用另一个瓮:
-
在第二个瓮中,放入前述样本中的 200 个弹珠。
-
仔细混合这些弹珠,选择一个弹珠并将其放在一边。
-
重复,选择 100 个弹珠。也就是说,一次选择一个弹珠,在中间混合,并将选择的弹珠放在一边。
从 100 颗抽取的弹珠被分配到治疗组,并对应于获奖的贡献者。留在瓮中的 100 颗弹珠形成对照组,他们不会获得奖励。
无论是样本选择还是奖励接受者的选择都使用了机会机制。如果我们再次重复第一次取样活动,将所有 1,440 颗弹珠放回原来的瓮中,那么我们很可能会得到一个不同的样本。这种变化是抽样变异的来源。同样地,如果我们再次重复随机分配过程(保持 200 个样本不变),那么我们会得到一个不同的治疗组。分配变异是由这第二个机会过程引起的。
维基百科实验提供了抽样和分配变异的例子。在这两种情况下,研究人员对数据收集过程施加了机会机制。测量误差有时也可以被视为遵循瓮模型的机会过程。例如,我们可以用这种方式表征马瓦努阿洛阿的 CO[2]监测仪的测量误差。
如果我们可以在数据的变化和瓮模型之间进行准确的类比,瓮模型为我们提供了估算变化规模的工具(见第三章)。这是非常理想的,因为我们可以为数据的变化提供具体的值。然而,确认瓮模型是否合理地描述了变化来源至关重要。否则,我们的准确性声明可能存在严重缺陷。我们需要尽可能了解数据范围的所有内容,包括数据收集中使用的仪器、协议和机会机制,以应用这些瓮模型。
总结
无论您处理的数据类型是什么,在进行清理、探索和分析之前,请花点时间了解数据的来源。如果您没有收集数据,请问自己:
-
谁收集了数据?
-
为什么要收集数据?
这些问题的答案可以帮助确定这些找到的数据是否可以用来解决您感兴趣的问题。
考虑数据的范围。关于数据收集的时间和空间方面的问题可以提供宝贵的见解:
-
数据是何时收集的?
-
数据是在哪里收集的?
这些问题的答案帮助您确定您的发现是否与您感兴趣的情况相关,或者您的情况可能与其他地方和时间不可比较。
对于范围概念的核心是回答以下问题:
-
目标人群是什么(或未知参数值是什么)?
-
目标是如何访问的?
-
选择样本/进行测量的方法是什么?
-
使用了哪些仪器,它们是如何校准的?
尽可能回答这些问题可以为您提供宝贵的见解,了解您对发现的信任程度以及是否可以从中进行概括。
本章为您提供了术语和思考并回答这些问题的框架。该章节还概述了如何识别可能影响您发现准确性的偏差和方差的方法。为了帮助您理解偏差和方差,我们介绍了以下图表和概念:
-
范围图示,显示目标人群、访问框架和样本之间的重叠。
-
飞镖板,描述仪器的偏差和方差。
-
在使用概率机制从访问框架中选择样本、将群体分为实验处理组或从良好校准的仪器中进行测量时,使用乌尔恩模型。
这些图表和模型试图概括理解如何识别数据限制并评估数据在回答问题中的有用性所需的关键概念。第三章继续发展乌尔恩模型,更正式地量化准确性并设计模拟研究。
^(1) “范围”概念改编自约瑟夫·赫勒斯坦(Joseph Hellerstein)的课程笔记,涉及范围、时间性和忠实度。
第三章:模拟与数据设计
在本章中,我们开发了理解数据抽样及其对偏差和方差影响的基础理论。我们建立这个基础不是基于经典统计的干涩方程,而是基于一个装满大理石的乌尔恩的故事。我们使用模拟的计算工具来推理从乌尔恩中选择大理石的属性以及它们对现实世界数据收集的启示。我们将模拟过程与常见的统计分布联系起来(干涩的方程),但是模拟的基本工具使我们能够超越仅仅使用方程直接建模的范围。
例如,我们研究了民意调查员未能预测 2016 年美国总统选举结果的失败。我们的模拟研究使用了宾夕法尼亚州实际投票情况。我们模拟了这六百万选民民意调查的抽样变异,揭示了回应偏差如何扭曲民意调查,并看到仅仅收集更多数据是无济于事的。
在第二个模拟研究中,我们研究了一项控制实验,证明了一种 COVID-19 疫苗的有效性,但也引发了关于疫苗相对有效性的激烈争论。将实验抽象为一个乌尔恩模型为我们提供了一个工具,用于研究随机对照实验中的分配变化。通过模拟,我们找到了临床试验的预期结果。我们的模拟,连同对数据范围的仔细检查,驳斥了疫苗无效的说法。
第三个例子使用模拟来模拟测量过程。当我们将我们人工测量的空气质量的波动与真实测量进行比较时,我们可以评估乌尔恩模型模拟测量空气质量波动的适当性。这种比较为我们校准紫外线空气质量监测器提供了背景,使它们可以更准确地在低湿度时期(如火灾季节)测量空气质量。
然而,在我们解决我们时代一些最重要的数据争论之前,我们首先从一个非常小的故事开始,这个故事是关于几颗大理石坐在一个乌尔恩中的故事。
乌尔恩模型
乌尔恩模型是由雅各布·伯努利在 18 世纪初开发的,作为模拟从人群中选择项目的一种方式。图 3-1 显示的乌尔恩模型提供了一个随机从乌尔恩中取样大理石过程的视觉描述。乌尔恩最初有五颗大理石:三颗黑色和两颗白色。图表显示进行了两次抽取:首先抽出一颗白色大理石,然后抽出一颗黑色大理石。

图 3-1. 两颗弃不重复从乌尔恩中抽出的大理石的图表
要建立一个乌尔恩模型,我们首先需要做出几个决定:
-
乌尔恩中的大理石数量
-
每颗大理石的颜色(或标签)
-
从乌尔恩中抽出的大理石的数量
最后,我们还需要决定抽样过程。对于我们的过程,我们将弹珠混合在容器中,当我们选择一个弹珠进行样本时,我们可以选择记录其颜色并将其放回容器(有放回抽样),或者将其设置为不能再次被选中(无放回抽样)。
这些决策构成了我们模型的参数。我们可以通过选择这些参数来调整弹珠模型,以描述许多现实世界的情况。举例来说,考虑 图 3-1 中的示例。我们可以使用numpy的random.choice方法在两次取样之间模拟从我们的容器中取出两个弹珠(无放回)。numpy库支持数组的函数,对于数据科学特别有用:
`import` `numpy` `as` `np`
`urn` `=` `[``"``b``"``,` `"``b``"``,` `"``b``"``,` `"``w``"``,` `"``w``"``]`
`print``(``"``Sample 1:``"``,` `np``.``random``.``choice``(``urn``,` `size``=``2``,` `replace``=``False``)``)`
`print``(``"``Sample 2:``"``,` `np``.``random``.``choice``(``urn``,` `size``=``2``,` `replace``=``False``)``)`
Sample 1: ['b' 'w']
Sample 2: ['w' 'b']
注意我们将replace参数设置为False,以表明一旦我们取出一个弹珠,就不会放回到容器中。
有了这个基本设置,我们可以大致回答关于我们期望看到的样本种类的问题。我们的样本中仅包含一种颜色的弹珠的机会是多少?如果我们在选择后将每个弹珠放回,机会会改变吗?如果我们改变了容器中的弹珠数量会怎样?如果我们从容器中抽取更多的弹珠会怎样?如果我们重复这个过程很多次会发生什么?
这些问题的答案对我们理解数据收集至关重要。我们可以基于这些基本技能来模拟弹珠,并将模拟技术应用于经典概率方程难以解决的现实问题。
例如,我们可以使用模拟来轻松估计我们抽取的两个弹珠中匹配颜色的样本比例。在下面的代码中,我们运行了 10,000 轮从我们的容器中抽取两个弹珠的过程。利用这些样本,我们可以直接计算具有匹配弹珠的样本比例:
`n` `=` `10_000`
`samples` `=` `[``np``.``random``.``choice``(``urn``,` `size``=``2``,` `replace``=``False``)` `for` `_` `in` `range``(``n``)``]`
`is_matching` `=` `[``marble1` `==` `marble2` `for` `marble1``,` `marble2` `in` `samples``]`
`print``(``f``"``Proportion of samples with matching marbles:` `{``np``.``mean``(``is_matching``)``}``"``)`
Proportion of samples with matching marbles: 0.4032
我们刚刚进行了一项模拟研究。我们对np.random.choice的调用模拟了从容器中无放回地抽取两个弹珠的概率过程。每次对np.random.choice的调用给我们一个可能的样本。在模拟研究中,我们重复这个概率过程多次(在这种情况下是10_000次),以获得大量样本。然后,我们利用这些样本的典型行为来推理出我们可能从概率过程中获得的结果。虽然这可能看起来像是一个假设性的例子(确实如此),但请考虑如果我们将弹珠替换为在线约会服务中的人,用更复杂的属性替换颜色,并可能使用神经网络来评分匹配,你就能开始看到更复杂分析的基础了。
到目前为止,我们关注的是样本,但我们通常对我们可能观察到的样本与它可以告诉我们关于最初在容器中的“种群”弹珠之间的关系感兴趣。
我们可以将数据范围的类比与第二章联系起来:从坛子中抽取的一组大理石是一个样本,而放置在坛子中的所有大理石是接触框架,在这种情况下,我们认为与种群相同。这种模糊了访问框架和种群之间的差异指向了模拟与现实之间的差距。模拟往往简化模型。尽管如此,它们可以为现实世界的现象提供有用的见解。
在我们不在抽样过程中替换大理石的坛子模型中,有一个常见的选择方法称为简单随机样本。我们接下来描述这种方法及其他基于它的抽样技术。
抽样设计
从坛子中不替换地抽取大理石的过程等同于一个简单随机样本。在简单随机样本中,每个样本被选中的机会相同。虽然方法名称中含有“简单”一词,但构建一个简单随机样本通常并不简单,在许多情况下也是最佳的抽样过程。此外,如果我们诚实地说,它有时也会有些令人困惑。
为了更好地理解这种抽样方法,我们回到坛子模型。考虑一个有七个大理石的坛子。我们不给大理石着色,而是用字母A到G对每个大理石进行标记。由于每个大理石都有不同的标签,我们可以更清楚地识别我们可能得到的所有可能样本。让我们从坛子中不替换地选择三个大理石,并使用itertools库生成所有组合的列表:
`from` `itertools` `import` `combinations`
`all_samples` `=` `[``"``"``.``join``(``sample``)` `for` `sample` `in` `combinations``(``"``ABCDEFG``"``,` `3``)``]`
`print``(``all_samples``)`
`print``(``"``Number of Samples:``"``,` `len``(``all_samples``)``)`
['ABC', 'ABD', 'ABE', 'ABF', 'ABG', 'ACD', 'ACE', 'ACF', 'ACG', 'ADE', 'ADF', 'ADG', 'AEF', 'AEG', 'AFG', 'BCD', 'BCE', 'BCF', 'BCG', 'BDE', 'BDF', 'BDG', 'BEF', 'BEG', 'BFG', 'CDE', 'CDF', 'CDG', 'CEF', 'CEG', 'CFG', 'DEF', 'DEG', 'DFG', 'EFG']
Number of Samples: 35
我们的列表显示,有 35 个唯一的三大理石集。我们可以以六种不同的方式抽取每组集合。例如,集合可以被抽样:
`from` `itertools` `import` `permutations`
`print``(``[``"``"``.``join``(``sample``)` `for` `sample` `in` `permutations``(``"``ABC``"``)``]``)`
['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA']
在这个小例子中,我们可以全面了解我们可以从坛子中抽取任意三个大理石的所有方法。
由于从七个种群中选择的每组三个大理石同等可能地发生,任何一个特定样本的机会必须是:
我们使用特殊符号表示“概率”或“机会”,并且我们将语句读作“样本包含标记为 A、B 和 C 的大理石的机会,无论顺序如何”。
我们可以使用从坛子中所有可能样本的枚举来回答关于这一随机过程的其他问题。例如,要找出大理石在样本中的机会,我们可以加总所有包含的样本的机会。共有 15 个,因此机会是:
当难以列举和计算所有可能的样本时,我们可以使用模拟来帮助理解这一概率过程。
注意
许多人错误地认为简单随机样本的定义特性是每个单位有相同的抽样机会。然而,情况并非如此。从人口中抽取个单位的简单随机样本意味着每个个单位的所有可能的个集合被选择的机会相同。稍有变体的是带放回的简单随机样本,在这种情况下,单位/彩球在每次抽取后都被放回罐子。这种方法也具有每个个单位人口的个样本被选中的属性。不同之处在于,因为同一彩球可以在样本中出现多次,所以可能的个单位集合更多。
简单随机样本(及其对应的罐子)是更复杂的调查设计的主要构建块。我们简要描述了两种更广泛使用的设计:
分层抽样
将人口分成互不重叠的群体,称为层(一个群体称为层,多个称为层),然后从每个群体中简单随机抽取样本。这就像每个层都有一个单独的罐子,并且独立地从每个罐子中抽取彩球。这些层的大小可以不同,并且我们不需要从每个层中取相同数量的彩球。
簇抽样
将人口分成互不重叠的子群体,称为簇,从簇中简单随机抽取样本,并将簇中的所有单位包括在样本中。我们可以将这看作是从一个包含大彩球的罐子中简单随机抽样,而这些大彩球本身是装有小彩球的容器。(大彩球的数量不一定相同。)当打开时,大彩球样本变成小彩球样本。(簇通常比层小。)
例如,我们可以将标记为-的七个彩球,组织成三个簇:,和。然后,大小为一的簇样本有同等机会从这三个簇中抽取任何一个。在这种情况下,每个彩球被抽取为样本的机会相同:
但并非每种元素组合的发生概率都相等:样本不可能同时包含和,因为它们位于不同的簇中。
经常,我们对样本的总结感兴趣;换句话说,我们对统计量感兴趣。对于任何样本,我们可以计算统计量,而罐模型帮助我们找到该统计量可能具有的值的分布。接下来,我们检查我们简单示例的统计量分布。
统计量的抽样分布
假设我们有兴趣测试新燃料箱设计的失败压力,这样做成本高昂,因为我们需要摧毁燃料箱,并且可能需要测试多个燃料箱以解决制造上的变化。
我们可以使用罐模型选择要测试的原型,并且可以通过失败测试的原型比例总结我们的测试结果。罐模型为我们提供了每个样本被选择的相同机会,因此压力测试结果代表了整体群体。
为了简单起见,假设我们有七个与之前弹珠类似标记的燃料箱。让我们看看如果选择时,如何处理坦克、、和未通过压力测试,而选择通过测试的、和。
对于每三颗弹珠的样本,我们可以根据这些四个次品原型中有多少个来计算失败的比例。我们举几个计算示例:
| 样本 | ABC | BCE | BDF | CEG |
|---|---|---|---|---|
| 失败比例 | 2/3 | 1/3 | 1 | 0 |
由于我们从罐中抽取三颗弹珠,唯一可能的样本比例是、、和,对于每个三元组,我们可以计算其相应的比例。有四个样本使我们得到全部测试失败(样本比例为 1)。它们分别是、、和,因此观察到样本比例为 1 的机会为。我们可以将样本比例的分布总结为一张表格,称为样本比例的抽样分布:
| 失败比例 | 样本数 | 样本比例 |
|---|---|---|
| 0 | 1 | 1/35 |
| 1/3 | 12 | 12/35 |
| 2/3 | 18 | 18/35 |
| 1 | 4 | 4/35 |
| 总数 | 35 | 1 |
尽管这些计算相对直观,我们可以通过仿真研究来近似它们。为此,我们反复从我们的总体中取三个样本,比如说一万次。对于每个样本,我们计算失败比例。这给我们 10,000 个模拟样本比例。仿真比例表应接近抽样分布。我们通过仿真研究确认这一点。
模拟抽样分布
仿真可以是理解复杂随机过程的强大工具。在我们七个燃料箱的例子中,我们能够考虑来自相应盒模型的所有可能样本。然而,在具有大量人口和样本以及更复杂抽样过程的情况下,直接计算某些结果的概率可能并不可行。在这些情况下,我们经常转向仿真,以提供我们无法直接计算的数量的准确估计。
让我们设定一个问题,即寻找三个燃料箱的简单随机样本中失败比例的抽样分布,作为一个盒子模型。由于我们关心罐是否故障,我们使用 1 表示失败和 0 表示通过,这给我们一个标记如下的罐子:
`urn` `=` `[``1``,` `1``,` `0``,` `1``,` `0``,` `1``,` `0``]`
我们已经使用 1 表示失败和 0 表示通过对罐头 至 进行编码,因此我们可以取样本的平均值来获得样本中的失败比例:
`sample` `=` `np``.``random``.``choice``(``urn``,` `size``=``3``,` `replace``=``False``)`
`print``(``f``"``Sample:` `{``sample``}``"``)`
`print``(``f``"``Prop Failures:` `{``sample``.``mean``(``)``}``"``)`
Sample: [1 0 0]
Prop Failures: 0.3333333333333333
在仿真研究中,我们重复抽样过程数千次以获得数千个比例,然后从我们的仿真中估计比例的抽样分布。在这里,我们构建了 10,000 个样本(因此有 10,000 个比例):
`samples` `=` `[``np``.``random``.``choice``(``urn``,` `size``=``3``,` `replace``=``False``)` `for` `_` `in` `range``(``10_000``)``]`
`prop_failures` `=` `[``s``.``mean``(``)` `for` `s` `in` `samples``]`
我们可以研究这 10,000 个样本比例,并将我们的发现与我们已经使用所有 35 个可能样本的完全枚举计算的结果进行匹配。我们预计仿真结果与我们之前的计算非常接近,因为我们重复了许多次抽样过程。也就是说,我们想比较 10,000 个样本比例的分数是否为 0、 、 和 1,与我们确切计算的那些分数相匹配;这些分数是 、 、 和 ,约为 、 、 和 :
`unique_els``,` `counts_els` `=` `np``.``unique``(``prop_failures``,` `return_counts``=``True``)`
`pd``.``DataFrame``(``{`
`"``Proportion of failures``"``:` `unique_els``,`
`"``Fraction of samples``"``:` `counts_els` `/` `10_000``,`
`}``)`
| 失败比例 | 样本分数比 | |
|---|---|---|
| 0 | 0.00 | 0.03 |
| 1 | 0.33 | 0.35 |
| 2 | 0.67 | 0.51 |
| 3 | 1.00 | 0.11 |
仿真结果非常接近我们之前计算的准确概率。
注意
模拟研究利用随机数生成器从随机过程中采样许多结果。从某种意义上讲,模拟研究将复杂的随机过程转化为我们可以使用本书中涵盖的广泛计算工具进行分析的数据。虽然模拟研究通常不提供特定假设的确凿证据,但它们可以提供重要的证据。在许多情况下,模拟是我们拥有的最准确的估计过程。
从一个瓮中抽取 0 和 1 的球是理解随机性的一个流行框架,这种机会过程已被正式命名为超几何分布,大多数软件都提供快速进行此过程模拟的功能。在下一节中,我们将模拟燃料箱例子中的超几何分布。
使用超几何分布进行模拟
而不是使用 random.choice,我们可以使用 numpy 的 random.hypergeometric 来模拟从瓮中取球并计算失败次数。random.hypergeometric 方法针对 0-1 瓮进行了优化,并允许我们在一次调用中请求 10,000 次模拟。为了完整起见,我们重复了我们的模拟研究并计算了经验比例:
`simulations_fast` `=` `np``.``random``.``hypergeometric``(`
`ngood``=``4``,` `nbad``=``3``,` `nsample``=``3``,` `size``=``10_000`
`)`
`print``(``simulations_fast``)`
[1 1 2 ... 1 2 2]
(我们并不认为通过的是“坏”的;这只是一种命名惯例,将你想要计数的类型称为“好”的,其他的称为“坏”的。)
我们统计了 10,000 个样本中有 0、1、2 或 3 次失败的比例:
`unique_els``,` `counts_els` `=` `np``.``unique``(``simulations_fast``,` `return_counts``=``True``)`
`pd``.``DataFrame``(``{`
`"``Number of failures``"``:` `unique_els``,`
`"``Fraction of samples``"``:` `counts_els` `/` `10_000``,`
`}``)`
| 失败次数 | 样本分数比 | |
|---|---|---|
| 0 | 0 | 0.03 |
| 1 | 1 | 0.34 |
| 2 | 2 | 0.52 |
| 3 | 3 | 0.11 |
也许你已经在想:既然超几何分布如此受欢迎,为什么不提供可能值的确切分布呢?事实上,我们可以精确计算这些:
`from` `scipy``.``stats` `import` `hypergeom`
`num_failures` `=` `[``0``,` `1``,` `2``,` `3``]`
`pd``.``DataFrame``(``{`
`"``Number of failures``"``:` `num_failures``,`
`"``Fraction of samples``"``:` `hypergeom``.``pmf``(``num_failures``,` `7``,` `4``,` `3``)``,`
`}``)`
| 失败次数 | 样本分数比 | |
|---|---|---|
| 0 | 0 | 0.03 |
| 1 | 1 | 0.34 |
| 2 | 2 | 0.51 |
| 3 | 3 | 0.11 |
注意
在可能的情况下,最好使用第三方包中提供的功能来模拟命名分布,比如 numpy 中提供的随机数生成器,而不是编写自己的函数。最好利用他人开发的高效和准确的代码。话虽如此,偶尔从头开始构建可以帮助你理解算法,所以我们建议试一试。
或许最常见的两种机会过程是那些由从 0-1 瓮中抽取 1 的数量产生的过程:不替换抽取是超几何分布,替换抽取是二项式分布。
虽然这个模拟过程非常简单,我们本可以直接使用hypergeom.pmf来计算我们的分布,但我们希望展示模拟研究能够揭示的直觉。我们在本书中采用的方法是基于模拟研究开发对机会过程的理解。然而,在第十七章中,我们确实正式地定义了统计数据的概率分布的概念。
现在我们把模拟作为了解准确性的工具之一,可以重新审视第二章中的选举例子,并进行一次选举后研究,看看选民民意调查可能出了什么问题。这个模拟研究模仿了从 600 万个选民中抽取超过一千个弹珠(参与民意调查的选民)。我们可以检查偏见的潜在来源和民意调查结果的变化,并进行“如果”分析,看看如果从选民中抽取更多的弹珠会对预测产生怎样的影响。
示例:模拟选举民意调查的偏见和方差
2016 年,几乎所有关于美国总统选举结果的预测都是错误的。这是一个历史性的预测误差水平,震惊了统计学和数据科学界。在这里,我们探讨为什么几乎每一个政治民意调查都是如此自信,却又如此错误。这个故事既展示了模拟的力量,也揭示了数据的傲慢和偏见挑战的难度。
美国总统是由选举人团选出的,而不是由普通选民的投票决定。根据各州的人口大小,每个州被分配一定数量的选举人票数。通常情况下,谁在某州赢得了普选,谁就会获得该州所有的选举人票数。在选举前进行的民意调查帮助下,评论家确定了“争夺”州,在这些州中选举预计会非常接近,选举人票数可能会左右选举结果。
2016 年,民意调查机构正确预测了 50 个州中的 46 个的选举结果。这并不差!毕竟,对于那 46 个州来说,唐纳德·特朗普获得了 231 张选举人票,希拉里·克林顿获得了 232 张选举人票——几乎是平局,克林顿领先微弱。不幸的是,剩下的四个州,佛罗里达、密歇根、宾夕法尼亚和威斯康辛,被认定为争夺州,并且合计 75 张选举人票。这四个州的普选投票比例非常接近。例如,在宾夕法尼亚州,特朗普获得了 6,165,478 票中的 48.18%,而克林顿获得了 47.46%。在这些州,由于民意调查使用的样本量较小,很难预测选举结果。但是,在调查过程本身也存在更大的挑战。
许多专家研究了 2016 年选举结果,以分析并确定出了什么问题。根据美国公共舆论研究协会的说法,一项在线自愿参与的民意调查对受访者的教育程度进行了调整,但只使用了三个广泛的类别(高中或以下、部分大学和大学毕业)。民意调查人员发现,如果他们将具有高级学位的受访者与具有大学学位的受访者分开,那么他们将会将克林顿的估计百分比降低 0.5 个百分点。换句话说,在事后,他们能够确定受过教育程度较高的选民更愿意参与投票。这种偏见很重要,因为这些选民也倾向于喜欢克林顿而不是特朗普。
现在我们知道人们实际投票的方式,我们可以进行类似Manfred te Grotenhuis 等人的模拟研究,模拟不同情况下的选举民意调查,以帮助形成对准确性、偏见和方差的直觉。我们可以模拟并比较宾夕法尼亚州的两种情况下的民意调查:
-
受访者没有改变他们的想法,也没有隐藏他们投票给谁,并且代表了在选举日投票的人群。
-
受过高等教育的人更有可能回答,这导致了对克林顿的偏见。
我们的最终目标是了解在样本收集过程中完全没有偏见和存在少量非响应偏见的情况下,民意调查错误地将选举归因于希拉里·克林顿的频率。我们首先为第一种情况建立瓮模型。
宾夕法尼亚州瓮模型
我们对宾夕法尼亚州选民进行民意调查的瓮模型是一种事后情况,我们使用选举结果。这个瓮中有 6,165,478 个弹珠,每个选民一个。就像我们的小样本一样,我们在每个弹珠上写上他们投票给的候选人,从瓮中抽取 1,500 个弹珠(1,500 个是这些调查的典型大小),并统计特朗普、克林顿和任何其他候选人的选票。通过统计,我们可以计算特朗普相对于克林顿的领先优势。
由于我们只关心特朗普相对于克林顿的领先优势,我们可以将其他候选人的所有选票合并在一起。这样,每个弹珠都有三种可能的选票:特朗普、克林顿或其他。我们不能忽略“其他”类别,因为它会影响领先优势的大小。让我们将选民数分配给这三个群体:
`proportions` `=` `np``.``array``(``[``0.4818``,` `0.4746``,` `1` `-` `(``0.4818` `+` `0.4746``)``]``)`
`n` `=` `1_500`
`N` `=` `6_165_478`
`votes` `=` `np``.``trunc``(``N` `*` `proportions``)``.``astype``(``int``)`
`votes`
array([2970527, 2926135, 268814])
这个版本的瓮模型有三种类型的弹珠。它比超几何分布复杂一些,但仍然足够常见以具有命名分布:多元超几何分布。在 Python 中,具有两种以上弹珠类型的瓮模型是通过scipy.stats.multivariate_hypergeom.rvs方法实现的。该函数返回从瓮中抽取的每种类型的弹珠数量。我们调用函数如下:
`from` `scipy``.``stats` `import` `multivariate_hypergeom`
`multivariate_hypergeom``.``rvs``(``votes``,` `n``)`
array([727, 703, 70])
每次调用multivariate_hypergeom.rvs时,我们都会得到一个不同的样本和计数:
`multivariate_hypergeom``.``rvs``(``votes``,` `n``)`
array([711, 721, 68])
我们需要计算每个样本的特朗普领先: ,其中 是特朗普的选票数, 是克林顿的选票数。如果领先是正数,则样本显示特朗普胜出。
我们知道实际领先是 0.4818 – 0.4746 = 0.0072. 为了了解民意调查的变化情况,我们可以模拟反复从瓮中取出的机会过程,并检查我们得到的值。现在,我们可以模拟在宾夕法尼亚投票中的 1,500 名选民的 100,000 次调查:
`def` `trump_advantage``(``votes``,` `n``)``:`
`sample_votes` `=` `multivariate_hypergeom``.``rvs``(``votes``,` `n``)`
`return` `(``sample_votes``[``0``]` `-` `sample_votes``[``1``]``)` `/` `n`
`simulations` `=` `[``trump_advantage``(``votes``,` `n``)` `for` `_` `in` `range``(``100_000``)``]`
平均而言,民调结果显示特朗普领先接近 0.7%,这与投票结果的构成相符:
`np``.``mean``(``simulations``)`
0.007177066666666666
然而,很多时候样本的领先是负数,这意味着在该选民样本中克林顿是赢家。下图显示了在宾夕法尼亚 1,500 名选民样本中特朗普优势的抽样分布。在 0 处的垂直虚线显示,特朗普更常被提及,但在 1,500 人的调查中,有很多次克林顿处于领先地位:

在 100,000 次模拟的调查中,我们发现特朗普大约 60%的时间是胜利者:
`np``.``mean``(``np``.``array``(``simulations``)` `>` `0``)`
0.60613
换句话说,一个样本即使没有任何偏见地收集,也将大约 60%的时间正确预测特朗普的胜利。而这种无偏样本将在 40%的时间错误。
我们使用了瓮模型来研究简单民意调查的变化,并找出了如果选择过程没有偏差时民意调查的预测可能是什么样子(弹珠是无法区分的,六百多万个弹珠中的每一种可能的 1,500 个弹珠集合是同等可能的)。接下来,我们看看当一点偏差进入混合时会发生什么。
具有偏差的瓮模型
根据 Grotenhuis 的说法,“在完美的世界里,民意调查从选民群体中取样,他们会明确表达自己的政治偏好,然后相应地投票。”^(2) 这就是我们刚刚进行的模拟研究。然而,在现实中,往往很难控制每一种偏见来源。
我们在这里研究了教育偏见对民调结果的影响。具体来说,我们检查了对克林顿有利的 0.5%偏见的影响。这种偏见实质上意味着我们在民意调查中看到了选民偏好的扭曲图片。克林顿的选票不是 47.46%,而是 47.96%,而特朗普是 48.18 – 0.5 = 47.68%。我们调整了瓮中弹珠的比例以反映这一变化:
`bias` `=` `0.005`
`proportions_bias` `=` `np``.``array``(``[``0.4818` `-` `bias``,` `0.4747` `+` `bias``,`
`1` `-` `(``0.4818` `+` `0.4746``)``]``)`
`proportions_bias`
array([0.48, 0.48, 0.04])
`votes_bias` `=` `np``.``trunc``(``N` `*` `proportions_bias``)``.``astype``(``int``)`
`votes_bias`
array([2939699, 2957579, 268814])
当我们再次进行模拟研究时,这次使用有偏的瓮,我们发现结果大不相同:
`simulations_bias` `=` `[``trump_advantage``(``votes_bias``,` `n``)` `for` `_` `in` `range``(``100_000``)``]`

`np``.``mean``(``np``.``array``(``simulations_bias``)` `>` `0``)`
0.44967
现在,特朗普在大约 45%的民意调查中领先。请注意,两次模拟的直方图形状相似。它们对称,并且尾部长度合理。也就是说,它们似乎大致遵循正态曲线。第二个直方图稍微向左移动,反映了我们引入的非响应偏倚。增加样本量会有所帮助吗?我们接下来研究这个话题。
进行更大规模的民调
通过我们的模拟研究,我们可以了解更大样本对样本领先的影响。例如,我们可以尝试使用 12,000 个样本,是实际民调规模的 8 倍,并针对无偏和有偏情况运行 100,000 次模拟:
`simulations_big` `=` `[``trump_advantage``(``votes``,` `12_000``)` `for` `_` `in` `range``(``100_000``)``]`
`simulations_bias_big` `=` `[``trump_advantage``(``votes_bias``,` `12_000``)`
`for` `_` `in` `range``(``100_000``)``]`
`scenario_no_bias` `=` `np``.``mean``(``np``.``array``(``simulations_big``)` `>` `0``)`
`scenario_bias` `=` `np``.``mean``(``np``.``array``(``simulations_bias_big``)` `>` `0``)`
`print``(``scenario_no_bias``,` `scenario_bias``)`
0.78968 0.36935
模拟显示,在有偏情况下,只有大约三分之一的模拟中检测到特朗普的领先地位。这些结果的直方图分布比仅有 1,500 位选民的情况更窄。不幸的是,它已经偏离了正确的数值。我们并没有克服偏倚;我们只是对偏倚情况有了更准确的了解。大数据并没有拯救我们。此外,更大规模的民调还有其他问题。它们通常更难进行,因为民调员在有限的资源下工作,本来可以用来改善数据范围的努力被重新定向到扩展民调上:

事后,通过多个相同选举的民调,我们可以检测到偏倚。在对 600 个州级、州长、参议院和总统选举的 4,000 多次民意调查进行的选举后分析中,研究人员发现,平均来说,选举民调显示出大约 1.5 个百分点的偏倚,这有助于解释为什么许多民调预测都错了。
当胜利的边际相对较小时,就像 2016 年那样,更大的样本量可以减少抽样误差,但不幸的是,如果存在偏倚,那么预测结果接近偏倚估计。如果偏倚使得预测从一个候选人(特朗普)转向另一个(克林顿),那么我们就会看到一场“意外”的颠覆。民调员开发选民选择方案,试图减少偏倚,比如按教育水平分离选民的偏好。但是,就像这个案例一样,很难,甚至不可能考虑到新的、意外的偏倚来源。民调仍然有用,但我们需要承认偏倚问题,并在减少偏倚方面做得更好。
在这个例子中,我们使用了乌尔恩模型来研究民调中的简单随机样本。乌尔恩模型在随机对照实验中也是常见的应用之一。
例如:模拟疫苗随机试验
在药物试验中,试验志愿者接受的是新治疗方法或者安慰剂(一种假的治疗方法),研究人员控制志愿者分配到治疗组和安慰剂组的过程。在随机对照实验中,他们使用随机过程来进行这种分配。科学家们基本上使用乌尔恩模型来选择接受治疗和安慰剂(即接受安慰剂的那些人)的对象。我们可以模拟乌尔恩的随机机制,以更好地理解实验结果的变化和临床试验中效力的含义。
2021 年 3 月,底特律市长迈克·达根(Mike Duggan)拒绝接受超过 6000 剂强生公司的疫苗 国家新闻,并表示他的市民应该“得到最好的”。市长指的是疫苗的有效率,据报道约为 66%。相比之下,Moderna 和 Pfizer 的疫苗报告的有效率约为 95%。
乍看之下,达根市长的理由似乎合理,但是三个临床试验的范围并不可比,这意味着直接比较试验结果是有问题的。此外,CDC 认为 66%的有效率相当不错,这也是为什么它获得了紧急批准。
让我们依次考虑范围和效力的要点。
范围
记住,当我们评估数据的范围时,我们考虑研究的对象、时间和地点。对于强生公司的临床试验,参与者:
-
包括 18 岁及以上成年人,其中大约 40%具有与患严重 COVID-19 风险增加相关的既往病史。
-
从 2020 年 10 月到 11 月,参与者被招募入研究。
-
来自八个国家,涵盖三大洲,包括美国和南非。
Moderna 和 Pfizer 试验的参与者主要来自美国,大约 40%有既往病史,试验在 2020 年夏季早些时候进行。试验的时间和地点使得它们难以比较。COVID-19 病例在美国夏季时期处于低点,但在晚秋期间迅速上升。同时,当时南非的一种更具传染性的病毒变种正在迅速传播,这也是 J&J 试验的时候。
每个临床试验旨在通过将受试者随机分配到治疗组和对照组来测试疫苗在没有疫苗的情况下的效果。尽管每个试验的范围有所不同,但试验内的随机化使得治疗组和对照组的范围基本相似。这使得在同一试验中组之间可以进行有意义的比较。三个疫苗试验的范围差异足够大,使得直接比较这三个试验的结果变得复杂。
在进行 J&J 疫苗 的试验中,有 43,738 人参与了。这些参与者被随机分成两组。一半人接受新疫苗,另一半接受安慰剂,比如生理盐水。然后每个人都被随访 28 天,看是否感染了 COVID-19。
关于每位患者都记录了大量信息,比如他们的年龄、种族和性别,以及他们是否感染了 COVID-19,包括疾病的严重程度。28 天后,研究人员发现了 468 例 COVID-19 病例,其中治疗组有 117 例,对照组有 351 例。
将患者随机分配到治疗组和对照组为科学家提供了评估疫苗有效性的框架。典型的推理如下:
-
从疫苗无效的假设开始。
-
因此,468 名感染 COVID-19 的人无论是否接种疫苗都会感染。
-
试验中剩下的 43,270 人,无论是否接种疫苗,都不会生病。
-
在治疗组有 117 人,对照组有 351 人的拆分,完全是由于将参与者分配到治疗或对照组的偶然过程。
我们可以建立一个反映这种情况的瓮模型,然后通过模拟研究实验结果的行为。
用于随机分配的瓮模型
我们的瓮有 43,738 个弹珠,代表临床试验中的每个人。由于其中有 468 例 COVID-19 病例,我们用 1 标记了 468 个弹珠,剩下的 43,270 个用 0 标记。我们从瓮中抽出一半的弹珠(21,869 个)接受治疗,剩下的一半接受安慰剂。实验的关键结果仅仅是从瓮中随机抽取的标记为 1 的弹珠数量。
我们可以模拟这个过程,以了解在这些假设下从瓮中最多抽取 117 个标记为 1 的弹珠的可能性有多大。由于我们从瓮中抽出一半的弹珠,我们预计大约有一半的 468 个弹珠,即 234 个,会被抽出。模拟研究给出了随机分配过程可能产生的变异的概念。也就是说,模拟可以给出试验中治疗组病毒病例如此之少的近似机会。
注意
这个瓮模型涉及到几个关键假设,比如疫苗无效的假设。跟踪这些假设的依赖很重要,因为我们的模拟研究给出了仅在这些关键假设下观察到的罕见结果的近似。
与以往一样,我们可以使用超几何概率分布模拟瓮模型,而不必从头编写偶然过程的程序:
`simulations_fast` `=` `np``.``random``.``hypergeometric``(``ngood``=``468``,` `nbad``=``43270``,`
`nsample``=``21869``,` `size``=``500000``)`

在我们的模拟中,我们重复了 500,000 次随机分配到治疗组的过程。事实上,我们发现 500,000 次模拟中没有一次发生 117 例或更少的情况。如果疫苗真的没有效果,看到如此少的 COVID-19 病例将是一个极其罕见的事件。
在解释了比较具有不同范围和预防 COVID-19 严重病例有效性的药物试验之后,达根市长撤回了他最初的声明,说:“我完全相信强生公司的疫苗既安全又有效。”^(3)
本例表明
-
使用随机过程将受试者分配到临床试验治疗组中,可以帮助我们回答各种假设情景。
-
考虑数据范围可以帮助我们确定是否合理比较不同数据集的数据。
从瓮中抽取弹珠的模拟是研究调查样本和控制实验可能结果的有用抽象。这种模拟有效,因为它模拟了用于选择样本或将人员分配到治疗中的机会机制。在我们测量自然现象的设置中,我们的测量倾向于遵循类似的机会过程。正如第二章中所述,仪器通常具有与其相关的误差,我们可以使用瓮来表示测量对象的变异性。
例子:测量空气质量
在美国各地,用于测量空气污染的传感器被广泛使用,包括个人、社区组织以及州和地方空气监测机构。例如,2020 年 9 月的两天,约 60 万加利福尼亚人和 50 万俄勒冈人在紫外空气地图上查看了火灾蔓延和疏散计划。(紫外空气从传感器收集的众包数据制作空气质量地图。)
传感器测量空气中直径小于 2.5 微米的颗粒物质的数量(测量单位为每立方米的微克数:μg/m³)。记录的测量值是两分钟内的平均浓度。例如,尽管颗粒物质的水平在一天中会发生变化,比如人们通勤上下班,但是在一天的某些时间,比如午夜,我们预计两分钟的平均值在半小时内变化不大。如果我们检查这些时间段内的测量值,我们可以了解仪器记录的综合变异性和空气中颗粒物的混合情况。
任何人都可以访问 PurpleAir 网站上的传感器测量值。该网站提供下载工具,并且对 PurpleAir 地图上出现的任何传感器都可用数据。我们下载了一个传感器在 24 小时内的数据,并选择了一天中分布在整个时间段内读数大致稳定的三个半小时时间间隔。这为我们提供了三组 15 个两分钟平均值,总共 45 个测量值:
| aq2.5 | time | hour | meds | diff30 | |
|---|---|---|---|---|---|
| 0 | 6.14 | 2022-04-01 00:01:10 UTC | 0 | 5.38 | 0.59 |
| 1 | 5.00 | 2022-04-01 00:03:10 UTC | 0 | 5.38 | -0.55 |
| 2 | 5.29 | 2022-04-01 00:05:10 UTC | 0 | 5.38 | -0.26 |
| ... | ... | ... | ... | ... | ... |
| 42 | 7.55 | 2022-04-01 19:27:20 UTC | 19 | 8.55 | -1.29 |
| 43 | 9.47 | 2022-04-01 19:29:20 UTC | 19 | 8.55 | 0.63 |
| 44 | 8.55 | 2022-04-01 19:31:20 UTC | 19 | 8.55 | -0.29 |
45 rows × 5 columns
线图可以让我们感受到测量值的变化。在一个 30 分钟的时间段内,我们期望测量值大致相同,除了空气中颗粒物的轻微波动和仪器的测量误差:

图表显示了一天中空气质量如何恶化,但在每个半小时间隔中,午夜、上午 11 点和下午 7 点时,空气质量分别大约为 5.4、6.6 和 8.6 μg/m³。我们可以将数据范围想象为:在特定位置的特定半小时时间间隔内,环绕传感器的空气中有一个平均颗粒浓度。这个浓度是我们的目标,我们的仪器——传感器,进行了许多形成样本的测量。 (请参见第二章中有关这一过程的飞镖板类比。)如果仪器工作正常,测量值将集中在目标上:30 分钟平均值。
为了更好地了解半小时间隔内的变化,我们可以检查测量值与相应半小时的中位数之间的差异。这些“误差”的分布如下:

直方图显示,测量值的典型波动通常小于 0.5 μg/m³,很少大于 1 μg/m³。使用仪器时,我们经常考虑其相对标准误差,即标准偏差占平均值的百分比。这 45 个偏差的标准偏差为:
`np``.``std``(``pm``[``'``diff30``'``]``)`
0.6870817156282193
鉴于每小时的测量值范围在 5 至 9 μg/m³之间,相对误差为 8%至 12%,这是相当精确的。
我们可以使用乌尔恩模型来模拟这个测量过程的变异性。我们将所有 45 次测量与它们的 30 分钟中位数的偏差放入乌尔恩,并通过从中抽取 15 次(有放回)并将抽取的偏差加到一个假设的 30 分钟平均值来模拟一个 30 分钟的空气质量测量序列:
`urn` `=` `pm``[``"``diff30``"``]`
`np``.``random``.``seed``(``221212``)`
`sample_err` `=` `np``.``random``.``choice``(``urn``,` `size``=``15``,` `replace``=``True``)`
`aq_imitate` `=` `11` `+` `sample_err`
我们可以为这组人工测量数据添加一条线图,并与之前的三个真实数据进行比较:

从模拟数据的线图形状看,它与其他数据相似,这表明我们对测量过程的模型是合理的。不幸的是,我们不知道这些测量是否接近真实的空气质量。为了检测仪器的偏差,我们需要与更精确的仪器进行比较,或者在空气含有已知颗粒物数量的受保护环境中进行测量。事实上,研究人员 发现低湿度会使读数偏高。在第十二章中,我们对 PurpleAir 传感器数据进行了更全面的分析,并校准仪器以提高其准确性。
总结
在本章中,我们使用了从乌尔恩抽取彩球的类比来模拟从人群中随机抽样和在实验中随机分配受试者到治疗组的过程。这个框架使我们能够进行针对假设调查、实验或其他随机过程的模拟研究,以研究它们的行为。我们发现了在假设治疗无效的情况下观察到特定临床试验结果的概率,并且基于实际选举投票结果的样本研究了对克林顿和特朗普的支持情况。这些模拟研究使我们能够量化随机过程中的典型偏差,并近似总结统计的分布,例如特朗普领先克林顿的情况。这些模拟研究揭示了统计量的抽样分布,并帮助我们回答关于在乌尔恩模型下观察到类似结果的可能性问题。
球罐模型简化为几个基本要素:罐中弹珠的数量,每个弹珠上的内容,从罐中抽取的弹珠数量,以及抽取过程中是否替换。从这些基础出发,我们可以模拟越来越复杂的数据设计。然而,罐模型的实用性关键在于将数据设计映射到罐子中。如果样本不是随机抽取的,受试者没有随机分配到治疗组,或者测量不是在校准良好的设备上进行的,那么这个框架在帮助我们理解数据并做出决策方面就会显得力不从心。另一方面,我们也需要记住,罐模型是对实际数据收集过程的简化。如果现实中数据收集存在偏差,那么我们在模拟中观察到的随机性并不能完整地捕捉到全貌。太多时候,数据科学家们忽略这些烦恼,只关注罐模型描述的变异性。这是预测 2016 年美国总统选举结果的调查中的主要问题之一。
在这些例子中,我们学习的总结统计数据是作为例子的一部分给出的。在下一章中,我们将讨论如何选择一个总结统计数据来代表这些数据。
^(1) Manfred te Grotenhuis 等人,《更好的民意抽样将更加怀疑希拉里·克林顿在 2016 年选举中可能获胜的潜力》,伦敦政治经济学院,2018 年 2 月 1 日。
^(2) Grotenhuis 等人,《更好的民意抽样将更加怀疑希拉里·克林顿在 2016 年选举中可能获胜的潜力》。
^(3) 不幸的是,尽管疫苗的有效性,美国食品药品监督管理局由于增加罕见且潜在致命的血栓风险,于 2022 年 5 月限制了 J&J 疫苗的使用。
第四章:模型与总结统计
我们在第二章中看到了数据范围的重要性,在第三章中看到了数据生成机制的重要性,例如可以用一个瓮模型来表示的机制。瓮模型解决了建模的一个方面:它描述了偶然变化,并确保数据代表了目标。良好的范围和代表性数据为从数据中提取有用信息奠定了基础,这在建模的另一部分中经常被称为数据中的信号。我们使用模型来近似这个信号,其中最简单的模型之一是常数模型,其中信号由一个单一数字(如均值或中位数)来近似。其他更复杂的模型总结了数据中特征之间的关系,例如空气质量中的湿度和颗粒物质(第十二章),社区中的上升流动性和通勤时间(第十五章),以及动物的身高和体重(第十八章)。这些更复杂的模型也是从数据中构建的近似值。当模型很好地适合数据时,它可以提供对世界的有用近似描述或仅仅是数据的有用描述。
在本章中,我们通过一个损失的形式介绍了模型拟合的基础知识。我们演示了如何通过考虑由简单总结描述数据引起的损失来建模数据中的模式,即常数模型。我们在第十六章深入探讨了瓮模型与拟合模型之间的联系,其中我们检查了拟合模型时信号和噪声之间的平衡,并在第十七章中讨论了推断、预测和假设检验的主题。
常数模型让我们可以从损失最小化的角度在简单情境中介绍模型拟合,它帮助我们将总结统计(如均值和中位数)与后续章节中的更复杂建模场景联系起来。我们从一个例子开始,该例子使用关于公交车晚点的数据来介绍常数模型。
常数模型
一个乘客,Jake,经常在西雅图市中心第三大道和派克街交界处的北行 C 路公交车站乘坐公交车。^(1) 这辆公交车应该每 10 分钟到达一次,但是 Jake 注意到有时候他等车的时间很长。他想知道公交车通常晚到多久。Jake 成功获取了从华盛顿州交通中心获得的公交车的预定到达时间和实际到达时间。根据这些数据,他可以计算每辆公交车晚点到达他所在站点的分钟数:
`times` `=` `pd``.``read_csv``(``'``data/seattle_bus_times_NC.csv``'``)`
`times`
| 路线 | 方向 | 预定时间 | 实际时间 | 晚到分钟数 | |
|---|---|---|---|---|---|
| 0 | C | 北行 | 2016-03-26 06:30:28 | 2016-03-26 06:26:04 | -4.40 |
| 1 | C | 往北 | 2016-03-26 01:05:25 | 2016-03-26 01:10:15 | 4.83 |
| 2 | C | 往北 | 2016-03-26 21:00:25 | 2016-03-26 21:05:00 | 4.58 |
| ... | ... | ... | ... | ... | ... |
| 1431 | C | 往北 | 2016-04-10 06:15:28 | 2016-04-10 06:11:37 | -3.85 |
| 1432 | C | 往北 | 2016-04-10 17:00:28 | 2016-04-10 16:56:54 | -3.57 |
| 1433 | C | 往北 | 2016-04-10 20:15:25 | 2016-04-10 20:18:21 | 2.93 |
1434 rows × 5 columns
数据表中的 minutes_late 列记录了每辆公交车的迟到时间。请注意,有些时间是负数,这意味着公交车提前到达。让我们来看一下每辆公交车迟到时间的直方图:
`fig` `=` `px``.``histogram``(``times``,` `x``=``'``minutes_late``'``,` `width``=``450``,` `height``=``250``)`
`fig``.``update_xaxes``(``range``=``[``-``12``,` `60``]``,` `title_text``=``'``Minutes late``'``)`
`fig`

我们已经可以在数据中看到一些有趣的模式。例如,许多公交车提前到达,但有些车晚到超过 20 分钟。我们还可以看到明显的众数(高点),在 0 附近,意味着许多公交车大致按时到达。
要了解这条路线上公交车通常晚到多久,我们希望通过一个常数来总结迟到情况 —— 这是一个统计量,一个单一的数字,比如均值、中位数或众数。让我们找到数据表中 minutes_late 列的每个这些摘要统计量。
从直方图中,我们估计数据的众数是 0,并使用 Python 计算均值和中位数:
mean: 1.92 mins late
median: 0.74 mins late
mode: 0.00 mins late
自然地,我们想知道这些数字中哪一个最能代表迟到的摘要情况。我们不想依赖经验法则,而是采取更正式的方法。我们为公交车迟到建立一个常数模型。让我们称这个常数为 (在建模中, 通常被称为参数)。例如,如果我们考虑 ,那么我们的模型大致认为公交车通常晚到五分钟。
现在, 并不是一个特别好的猜测。从迟到时间的直方图中,我们看到有更多的点接近 0 而不是 5。但是目前还不清楚 (众数)是否比 (中位数)、(均值)或者完全不同的其他值更好。为了在不同的 值之间做出选择,我们希望给每个 值分配一个评分,以衡量这个常数如何与数据匹配。换句话说,我们希望评估用常数近似数据所涉及的损失,比如 。而理想情况下,我们希望选择最能匹配我们数据的常数,也就是具有最小损失的常数。在下一节中,我们将更正式地描述损失,并展示如何使用它来拟合模型。
最小化损失
我们想要通过一个常数来模拟北向 C 路线的延迟,这个常数我们称之为,并且我们想要利用每辆公交车实际延迟的分钟数的数据来找出一个合适的值。为此,我们使用一个损失函数,这个函数衡量我们的常数与实际数据之间的差距。
损失函数是一个数学函数,接受和数据值作为输入。它输出一个单一的数字,损失,用来衡量和之间的距离。我们将损失函数写成。
按照惯例,损失函数对于较好的值输出较低的值,对于较差的值输出较大的值。为了使常数适应我们的数据,我们选择产生所有选择下平均损失最低的特定。换句话说,我们找到最小化数据平均损失的,其中 。更正式地,我们将平均损失写为,其中:
作为简写,我们经常使用向量。然后我们可以将平均损失写为:
注意
注意, 告诉我们模型对单个数据点的损失,而 给出模型对所有数据点的平均损失。大写的帮助我们记住平均损失结合了多个较小的值。
一旦我们定义了一个损失函数,我们可以找到产生最小平均损失的 值。我们称这个最小化的值为 。换句话说,对于所有可能的 值, 是为我们的数据产生最小平均损失的那个值。我们称这个优化过程为模型拟合;它找到了适合我们数据的最佳常数模型。
接下来,我们看一下两个特定的损失函数:绝对误差和平方误差。我们的目标是拟合模型并找到每个损失函数的 。
平均绝对误差
我们从绝对误差损失函数开始。这里是绝对损失背后的理念。对于某个 值和数据值 :
-
找到误差, 。
-
取误差的绝对值, 。
因此,损失函数为 。
取误差的绝对值是将负误差转换为正误差的简单方法。例如,点 距离 和 同样远,因此误差是同样“糟糕”的。
平均绝对误差被称为平均绝对误差(MAE)。MAE 是每个单独绝对误差的平均值:
注意,MAE 的名称告诉你如何计算它:取误差的绝对值的平均值, 。
我们可以编写一个简单的 Python 函数来计算这个损失:
`def` `mae_loss``(``theta``,` `y_vals``)``:`
`return` `np``.``mean``(``np``.``abs``(``y_vals` `-` `theta``)``)`
让我们看看当我们只有五个数据点 时,这个损失函数的表现。我们可以尝试不同的 值,并查看每个值对应的 MAE 输出:

我们建议通过手工验证一些这些损失值,以确保您理解如何计算 MAE。
在我们尝试的值中,我们发现具有最低的平均绝对误差。对于这个简单的例子,2 是数据值的中位数。这不是巧合。现在让我们来检查公交晚点时间原始数据的平均损失是多少,当我们将设置为分钟数的众数、中位数和平均数时,分别得到的 MAE 为:

我们再次看到中位数(中间图)比众数和平均数(左图和右图)有更小的损失。事实上,对于绝对损失,最小化的是。
到目前为止,我们通过简单尝试几个值并选择最小损失的值来找到了的最佳值。为了更好地理解的 MAE 作为函数的情况,我们可以尝试更多的值,并绘制一条曲线,显示随变化的情况。我们为前述的五个数据值绘制了这条曲线:

前面的图表显示,实际上是这五个值的最佳选择。请注意曲线的形状。它是分段线性的,线段在数据值(–1, 0, 2 和 5)的位置连接。这是绝对值函数的特性。对于大量数据,平坦部分不那么明显。我们的公交数据有超过 1400 个数据点,MAE 曲线看起来更加平滑:

我们可以利用这个图来确认数据的中位数是最小化值;换句话说,。这个图不是真正的证明,但希望它足够令你信服。
接下来,让我们看看另一个平方误差的损失函数。
均方误差
我们已经将常数模型拟合到我们的数据中,并发现使用均方误差时,最小化器是中位数。现在我们将保持模型不变,但切换到不同的损失函数:平方误差。我们不再取每个数据值和常数之间的绝对差值,而是将误差平方。也就是说,对于某个值和数据值:
-
找到误差,。
-
计算误差的平方,。
这给出了损失函数。
与以往一样,我们希望利用所有数据来找到最佳的,因此我们计算均方误差,简称为 MSE:
我们可以编写一个简单的 Python 函数来计算 MSE:
`def` `mse_loss``(``theta``,` `y_vals``)``:`
`return` `np``.``mean``(``(``y_vals` `-` `theta``)` `*``*` `2``)`
让我们再次尝试均值、中位数和众数作为 MSE 的潜在最小化器:

现在,当我们使用 MSE 损失来拟合常数模型时,我们发现均值(右图)的损失小于中位数和众数(左图和中图)。
让我们绘制给定数据的不同值的 MSE 曲线。曲线显示最小化值接近 2:

这条曲线的一个特点是,与 MAE 相比,MSE 增长得非常迅速(注意纵轴上的范围)。这种增长与平方误差的性质有关;它对远离的数据值施加了更高的损失。如果且,则平方损失为,而绝对损失为。因此,MSE 对异常大的数据值更为敏感。
从均方误差曲线来看,最小化的 看起来是 的均值。同样,这不是巧合;数据的均值总是与平方误差的 相符。我们展示了这是如何从均方误差的二次特性推导出来的。在此过程中,我们展示了平方损失作为方差和偏差项之和的常见表示,这是模型拟合中的核心。首先,我们在损失函数中添加和减去 ,并展开平方如下:
接下来,我们将均方误差分解为这三个项的和,并注意中间项为 0,这是由于平均数的简单性质: :
在剩余的两个术语中,第一个不涉及 。你可能认识到它是数据的方差。第二个术语始终为非负。它称为偏差平方。第二个术语,偏差平方,在 时为 0,因此 给出任何数据集的最小均方误差。
我们已经看到,对于绝对损失,最好的常数模型是中位数,但对于平方误差,是均值。选择损失函数是模型拟合的一个重要方面。
选择损失函数
现在我们已经处理了两个损失函数,我们可以回到最初的问题:我们如何选择使用中位数、均值或模式?由于这些统计量最小化不同的损失函数,^(2) 我们可以等价地问:对于我们的问题,什么是最合适的损失函数?为了回答这个问题,我们看一下问题的背景。
与平均绝对误差(MAE)相比,均方误差(MSE)在公交车迟到(或提前)很多时会导致特别大的损失。希望了解典型迟到时间的公交车乘客会使用 MAE 和中位数(晚 0.74 分钟),但是讨厌意外大迟到时间的乘客可能会用均方误差和均值(晚 1.92 分钟)来总结数据。
如果我们想进一步优化模型,我们可以使用更专业的损失函数。例如,假设公交车提前到达时会在站点等待直到预定出发时间;那么我们可能希望将早到视为 0 损失。如果一个非常迟到的公交车比一个中度迟到的公交车更加令人恼火,我们可能会选择一个非对称损失函数,对超级迟到的惩罚更大。
实质上,在选择损失函数时上下文很重要。通过仔细考虑我们计划如何使用模型,我们可以选择一个有助于我们做出良好数据驱动决策的损失函数。
总结
我们介绍了恒定模型:一个通过单一值汇总数据的模型。为了拟合恒定模型,我们选择了一个度量给定常数与数据值匹配程度的损失函数,并计算所有数据值的平均损失。我们发现,根据损失函数的选择,我们得到不同的最小化值:我们发现平均值最小化平均平方误差(MSE),中位数最小化平均绝对误差(MAE)。我们还讨论了如何结合问题的上下文和知识来选择损失函数。
将模型拟合到损失最小化的概念将简单的汇总统计数据(如平均数、中位数和众数)与更复杂的建模情况联系起来。我们在建模数据时采取的步骤适用于许多建模场景:
-
选择模型的形式(例如恒定模型)。
-
选择一个损失函数(如绝对误差)。
-
通过最小化所有数据的损失来拟合模型(如平均损失)。
在本书的其余部分,我们的建模技术扩展到这些步骤的一个或多个。我们引入新模型、新损失函数和新的最小化损失技术。第五章 重新审视了公交车晚点到达站点的研究。这一次,我们将问题呈现为案例研究,并访问数据科学生命周期的所有阶段。通过经历这些阶段,我们做出了一些不同寻常的发现;当我们通过考虑数据范围并使用瓮来模拟乘客到达公交车站时,我们发现建模公交车迟到不同于建模乘客等待公交车的经验。
^(1) 我们(作者)最初从名为杰克·范德普拉斯的数据科学家的分析中了解到公交到达时间数据。我们以他的名义命名本节的主角。
^(2) 众数最小化了一个称为 0-1 损失的损失函数。尽管我们尚未涵盖这种特定损失,但该过程是相同的:选择损失函数,然后找到最小化损失的内容。
第五章:案例研究:为什么我的公交车总是迟到?
Jake VanderPlas 的博客Pythonic Perambulations,提供了一个现代数据科学家的生活示例。作为数据科学家,我们在工作中、日常生活中以及个人生活中都看到数据,我们对这些数据可能带来的见解往往很好奇。在这第一个案例研究中,我们借鉴了 Pythonic Perambulations 上的一篇文章"等待时间悖论,或者,为什么我的公交车总是迟到?",来模拟在西雅图街角等公交车的情景。我们涉及数据生命周期的每个阶段,但在这第一个案例研究中,我们的重点是思考问题、数据和模型的过程,而不是数据结构和建模技术。通过持续的模型和模拟研究,我们可以更好地理解这些问题。
VanderPlas 的文章受到他等公交车的经历的启发。等待时间总是比预期的长。这种经历与以下推理不符:如果每 10 分钟就来一辆公交车,并且你在随机时间到达车站,那么平均等待时间应该约为 5 分钟。作者借助华盛顿州交通中心提供的数据,能够研究这一现象。我们也做同样的研究。
我们应用在前几章介绍的概念,从总体问题开始,为什么我的公交车总是迟到?并将这个问题细化为更接近我们目标且可以通过数据调查的问题。然后我们考虑数据的范围,例如这些数据是如何收集的以及潜在的偏倚来源,并准备数据进行分析。我们对数据范围的理解帮助我们设计一个等车模型,我们可以模拟来研究这一现象。
问题和范围
我们的研究起源于一个经常乘坐公交车的乘客的经历,他想知道为什么他的公交车总是迟到。我们不是在寻找实际导致公交车迟到的原因,比如交通拥堵或维护延误。相反,我们想研究实际到达时间与计划到达时间之间的差异模式。这些信息将帮助我们更好地理解等待公交车的感受。
公交线路在世界各地甚至城市内部都有所不同,因此我们将调查范围缩小到西雅图市的一个公交车站。我们的数据是关于西雅图快速通行线路 C、D 和 E 在第三大道和派克街的停靠时间,华盛顿州交通中心提供了 2016 年 3 月 26 日至 5 月 27 日这三条公交线路所有实际和计划停靠时间的数据。
考虑到我们狭窄的范围仅限于两个月内一个特定停靠点的公交车,并且我们可以访问在这段时间窗口内收集的所有行政数据,人口、访问框架和样本是相同的。然而,我们可以想象我们的分析可能对西雅图及其以外的其他地点和其他时段也有用。如果我们幸运的话,我们发现的想法或采取的方法可能对他人有用。目前,我们保持狭窄的焦点。
让我们来看看这些数据,以更好地理解它们的结构。
数据整理
在开始分析之前,我们检查数据的质量,在可能的情况下简化结构,并导出可能有助于分析的新测量数据。我们在第九章中涵盖了这些操作类型,因此现在不必担心代码的细节。而是专注于清理数据时数据表之间的差异。我们首先将数据加载到 Python 中。
数据表的前几行显示如下:
`bus``.``head``(``3``)`
| OPD_DATE | VEHICLE_ID | RTE | DIR | ... | STOP_ID | STOP_NAME | SCH_STOP_TM | ACT_STOP_TM | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 2016-03-26 | 6201 | 673 | S | ... | 431 | 第三大道与派克街 (431) | 01:11:57 | 01:13:19 |
| 1 | 2016-03-26 | 6201 | 673 | S | ... | 431 | 第三大道与派克街 (431) | 23:19:57 | 23:16:13 |
| 2 | 2016-03-26 | 6201 | 673 | S | ... | 431 | 第三大道与派克街 (431) | 21:19:57 | 21:18:46 |
3 rows × 9 columns
(原始数据以逗号分隔值的形式存储在文件中,我们已将其加载到此表中;详情请参见第八章。)
看起来表格中一些列可能是多余的,比如标记为STOP_ID和STOP_NAME的列。我们可以查找唯一值的数量及其计数来确认这一点:
`bus``[``[``'``STOP_ID``'``,``'``STOP_NAME``'``]``]``.``value_counts``(``)`
STOP_ID STOP_NAME
578 3RD AVE & PIKE ST (578) 19599
431 3RD AVE & PIKE ST (431) 19318
dtype: int64
有两个名为 3RD AVE & PIKE ST 的停靠点名称。我们想知道它们是否与公交车的行驶方向有关,可以通过方向、停靠点 ID 和停靠点名称的可能组合来检查:
`bus``[``[``'``DIR``'``,``'``STOP_ID``'``,``'``STOP_NAME``'``]``]``.``value_counts``(``)`
DIR STOP_ID STOP_NAME
N 578 3RD AVE & PIKE ST (578) 19599
S 431 3RD AVE & PIKE ST (431) 19318
dtype: int64
实际上,北方向对应的是停靠点 ID 578,南方向对应的是停靠点 ID 431。由于我们只研究一个停靠点,我们实际上不需要比方向更多的信息。
我们还可以检查唯一路线名称的数量:
673 13228
674 13179
675 12510
Name: RTE, dtype: int64
这些路线是按编号排列,并不符合问题描述原文中的 C、D 和 E 的名称。这个问题涉及到数据整理的另一个方面:我们需要找到连接路线字母和数字的信息。我们可以从西雅图公共交通网站获取这些信息。数据整理的另一个部分是将数据值翻译成更容易理解的形式,因此我们用字母替换路线号码:
`def` `clean_stops``(``bus``)``:`
`return` `bus``.``assign``(`
`route``=``bus``[``"``RTE``"``]``.``replace``(``{``673``:` `"``C``"``,` `674``:` `"``D``"``,` `675``:` `"``E``"``}``)``,`
`direction``=``bus``[``"``DIR``"``]``.``replace``(``{``"``N``"``:` `"``northbound``"``,` `"``S``"``:` `"``southbound``"``}``)``,`
`)`
我们还可以在表格中创建新的列,帮助我们进行调查。例如,我们可以使用计划和实际到达时间计算公交车的晚点时间。这需要一些日期和时间格式的处理,这在第九章有介绍。
让我们检查这个新量的值,确保我们的计算是正确的:
smallest amount late: -12.87 minutes
greatest amount late: 150.28 minutes
median amount late: 0.52 minutes
公交车晚点有负值有点令人惊讶,但这只是意味着公交车比计划时间提前到达。虽然中位数的晚点只有大约半分钟,但有些公交车晚到 2.5 小时!让我们看一下公交车晚点分钟数的直方图:
`px``.``histogram``(``bus``,` `x``=``"``minutes_late``"``,` `nbins``=``120``,` `width``=``450``,` `height``=``300``,`
`labels``=``{``'``minutes_late``'``:``'``Minutes late``'``}``)`

我们在第四章也看到了类似形状的直方图。公交车晚点情况的分布高度右偏,但许多车辆准时到达。
最后,我们通过创建数据表的简化版本来结束数据整理工作。因为我们只需要追踪路线、方向、计划和实际到达时间以及公交车的晚点时间,所以我们创建了一个更小的表格,并给列取了一些更易读的名称:
`bus` `=` `bus``[``[``"``route``"``,` `"``direction``"``,` `"``scheduled``"``,` `"``actual``"``,` `"``minutes_late``"``]``]`
`bus``.``head``(``)`
| 路线 | 方向 | 计划时间 | 实际时间 | 迟到分钟 | |
|---|---|---|---|---|---|
| 0 | C | 南行 | 2016-03-26 01:11:57 | 2016-03-26 01:13:19 | 1.37 |
| 1 | C | 南行 | 2016-03-26 23:19:57 | 2016-03-26 23:16:13 | -3.73 |
| 2 | C | 南行 | 2016-03-26 21:19:57 | 2016-03-26 21:18:46 | -1.18 |
| 3 | C | 南行 | 2016-03-26 19:04:57 | 2016-03-26 19:01:49 | -3.13 |
| 4 | C | 南行 | 2016-03-26 16:42:57 | 2016-03-26 16:42:39 | -0.30 |
这些表格操作在第六章有详细介绍。
在我们开始建模公交车晚点之前,我们想要深入探索和学习这些数据。我们接下来会做这件事。
探索公交时间
在我们清理和简化数据的过程中,我们对数据有了很多了解,但在开始建模等待时间之前,我们希望深入挖掘,更好地理解公交车晚点现象。我们将焦点缩小到了一个站点(第三大道和派克街口)在两个月内的公交活动上。我们看到公交车晚点的分布呈右偏态,确实有些公交车非常晚。在这个探索阶段,我们可能会问:
-
不同的三条公交线路的晚点分布看起来一样吗?
-
公交车是往北行驶还是往南行驶是否重要?
-
白天时间如何影响公交车的晚点情况?
-
公交车是否按照全天的规律间隔到达?
回答这些问题有助于我们更好地确定建模方法。
回顾一下 第四章 中我们发现的公交车晚点的中位数时间是 3/4 分钟。但这与我们为所有公交线路和方向计算的中位数(1/2 分钟)不符。我们来检查一下是否是由于该章节关注的是北行 C 线路的原因。我们创建每个六种公交线路和方向组合的延误直方图来解决这个问题和我们列表上的前两个问题:

y 轴上的比例尺(或密度)使得比较直方图变得更加容易,因为我们不会被不同组中的计数误导。x 轴上的范围在六个图中是相同的,这样更容易检测到分布的不同中心和扩展。(这些概念在 第十一章 中有描述。)
每条线路的南行和北行分布都不同。当我们深入了解背景时,我们了解到 C 线路起源于北部,而其他两条线路起源于南部。直方图暗示在公交路线的后半段到达时间的变异性较大,这是合理的,因为随着一天的进展,延误会逐渐累积。
接下来,为了探索不同时段的延误情况,我们需要推导一个新的量:公交车计划到达的小时。鉴于我们刚刚看到的路线和方向的变化导致的公交车晚点情况,我们再次为每条线路和方向创建单独的图表:

的确,似乎存在交通高峰时间的影响,而且晚高峰似乎比早高峰更严重。北行 C 线路看起来受到的影响最大。
最后,为了检查公交车的计划频率,我们需要计算计划到达时间之间的间隔。我们在表格中创建一个新列,其中包含北行 C 线路公交车的计划到达时间之间的时间:
`minute` `=` `pd``.``Timedelta``(``'``1 minute``'``)`
`bus_c_n` `=` `(`
`bus``[``(``bus``[``'``route``'``]` `==` `'``C``'``)` `&` `(``bus``[``'``direction``'``]` `==` `'``northbound``'``)``]`
`.``sort_values``(``'``scheduled``'``)`
`.``assign``(``sched_inter``=``lambda` `x``:` `x``[``'``scheduled``'``]``.``diff``(``)` `/` `minute``)`
`)`
`bus_c_n``.``head``(``3``)`
| 线路 | 方向 | 计划时间 | 实际时间 | 延迟分钟数 | 计划间隔 | |
|---|---|---|---|---|---|---|
| 19512 | C | 北行 | 2016-03-26 00:00:25 | 2016-03-26 00:05:01 | 4.60 | NaN |
| 19471 | C | 北行 | 2016-03-26 00:30:25 | 2016-03-26 00:30:19 | -0.10 | 30.0 |
| 19487 | C | 北行 | 2016-03-26 01:05:25 | 2016-03-26 01:10:15 | 4.83 | 35.0 |
让我们来看一下这些公交车的到站间隔时间分布直方图:
`fig` `=` `px``.``histogram``(``bus_c_n``,` `x``=``'``sched_inter``'``,`
`title``=``"``Bus line C, northbound``"``,`
`width``=``450``,` `height``=``300``)`
`fig``.``update_xaxes``(``range``=``[``0``,` `40``]``,` `title``=``"``Time between consecutive buses``"``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``40``)``)`

我们可以看到,公交车在一天中的不同时间段被安排以不同的间隔到达。在这两个月的时间内,大约有 1500 辆公交车被安排在前一辆车后 12 分钟到达,大约有 1400 辆公交车被安排在前一辆车后 15 分钟到达。
在探索数据的过程中,我们学到了很多,并且现在更有能力拟合模型。尤其是,如果我们想要清楚地了解等待公交车的经历,我们需要考虑公交车预定到达之间的间隔,以及公交线路和方向。
建模等待时间
我们对建模等待公交车的人的经历很感兴趣。我们可以开发一个涉及预定到达间隔、公交线路和方向的复杂模型。相反,我们采取了更简单的方法,将焦点缩小到一个线路、一个方向和一个预定间隔。我们检查了预定间隔为 12 分钟的 C 线向北站点:
`bus_c_n_12` `=` `bus_c_n``[``bus_c_n``[``'``sched_inter``'``]` `==` `12``]`
复杂模型和狭窄方法都是合法的,但我们尚未掌握处理复杂模型的工具(请参阅第十五章了解更多建模细节)。
到目前为止,我们已经检查了公交车晚点的分钟数分布。我们为我们正在分析的数据子集(预定在前一辆公交车到达后 12 分钟的 C 线向北站点)创建了另一个此延迟的直方图:
`fig` `=` `px``.``histogram``(``bus_c_n_12``,` `x``=``'``minutes_late``'``,`
`labels``=``{``'``minutes_late``'``:``'``Minutes late``'``}``,`
`nbins``=``120``,` `width``=``450``,` `height``=``300``)`
`fig``.``add_annotation``(``x``=``20``,` `y``=``150``,` `showarrow``=``False``,`
`text``=``"``Line C, northbound<br>Scheduled arrivals: 12 minutes apart``"` `)`
`fig``.``update_xaxes``(``range``=``[``-``13``,` `40``]``)`
`fig``.``show``(``)`

现在让我们计算晚点的最小值、最大值和中位数:
smallest amount late: -10.20 minutes
greatest amount late: 57.00 minutes
median amount late: -0.50 minutes
有趣的是,C 线向北行驶的公交车,间隔 12 分钟的情况下,更多时候会提前抵达而不是晚点!
现在让我们重新审视我们的问题,确认我们正在答案的正确方向上。仅总结公交车晚点的情况并不能完全解释等待公交车的人的经历。当有人到达公交车站时,他们需要等待下一辆公交车到达。图 5-1 展示了乘客和公交车到达公交车站时时间流逝的理想化图像。如果人们在随机时间到达公交车站,注意到他们更有可能在公交车晚点的时间段到达,因为公交车之间的间隔更长。这种到达模式是大小偏倚抽样的一个例子。因此,要回答等待公交车的人们经历了什么这个问题,我们需要做的不仅仅是总结公交车晚点的情况。

图 5-1. 理想化时间轴,显示公交车到达(矩形)、乘客到达(圆圈)和乘客等待下一辆公交车到达的时间(大括号)
我们可以设计一个模拟,模拟一天中等待公交车的过程,使用来自第三章的思想。为此,我们设置了一系列从早上 6 点到午夜预定间隔为 12 分钟的公交车到达时间:
`scheduled` `=` `12` `*` `np``.``arange``(``91``)`
`scheduled`
array([ 0, 12, 24, ..., 1056, 1068, 1080])
针对每个预定到达时间,我们通过添加每辆公交车晚点的随机分钟数来模拟其实际到达时间。为此,我们从实际公交车晚点的分布中选择晚点的分钟数。注意,在我们的模拟研究中,我们已经通过使用实际公交车延迟的分布来整合真实数据,这些公交车的间隔为 12 分钟:
`minutes_late` `=` `bus_c_n_12``[``'``minutes_late``'``]`
`actual` `=` `scheduled` `+` `np``.``random``.``choice``(``minutes_late``,` `size``=``91``,` `replace``=``True``)`
我们需要对这些到达时间进行排序,因为当一辆公交车迟到很久时,可能会有另一辆公交车在它之前到达:
`actual``.``sort``(``)`
`actual`
array([ -1.2 , 25.37, 32.2 , ..., 1051.02, 1077\. , 1089.43])
我们还需要随机模拟人们在一天中的不同时间到达公交车站的情况。我们可以使用另一个不同的罐子模型来描述乘客的到达情况。对于乘客,我们在罐子里放入带有时间的彩球。这些时间从时间 0 开始,代表上午 6 点,到午夜最后一辆公交车的到达,即从上午 6 点起计算到夜间 1,068 分钟。为了与我们数据中的公交车时间测量方式匹配,我们将这些时间分成每分钟 1/100 的间隔:
`pass_arrival_times` `=` `np``.``arange``(``100``*``1068``)`
`pass_arrival_times` `/` `100`
array([ 0\. , 0.01, 0.02, ..., 1067.97, 1067.98, 1067.99])
现在我们可以模拟一天中,例如,五百人在公交车站的到达情况。我们从这个罐子中抽取五百次,每次抽取后替换掉彩球:
`sim_arrival_times` `=` `(`
`np``.``random``.``choice``(``pass_arrival_times``,` `size``=``500``,` `replace``=``True``)` `/` `100`
`)`
`sim_arrival_times``.``sort``(``)`
`sim_arrival_times`
array([ 2.06, 3.01, 8.54, ..., 1064\. , 1064.77, 1066.42])
要了解每个人等待多长时间,我们寻找他们采样时间后最快到达的公交车。这两个时间的差值(个人采样时间和其后最快公交车到达时间)就是这个人的等待时间:
`i` `=` `np``.``searchsorted``(``actual``,` `sim_arrival_times``,` `side``=``'``right``'``)`
`sim_wait_times` `=` `actual``[``i``]` `-` `sim_arrival_times`
`sim_wait_times`
array([23.31, 22.36, 16.83, ..., 13\. , 12.23, 10.58])
我们可以建立一个完整的模拟,例如模拟两百天的公交车到达,而每天我们模拟五百人在一天中的随机时间到达公交车站。总计是 100,000 次模拟等待时间:
`sim_wait_times` `=` `[``]`
`for` `day` `in` `np``.``arange``(``0``,` `200``,` `1``)``:`
`bus_late` `=` `np``.``random``.``choice``(``minutes_late``,` `size``=``91``,` `replace``=``True``)`
`actual` `=` `scheduled` `+` `bus_late`
`actual``.``sort``(``)`
`sim_arrival_times` `=` `(`
`np``.``random``.``choice``(``pass_arrival_times``,` `size``=``500``,` `replace``=``True``)` `/` `100`
`)`
`sim_arrival_times``.``sort``(``)`
`i` `=` `np``.``searchsorted``(``actual``,` `sim_arrival_times``,` `side``=``"``right``"``)`
`sim_wait_times` `=` `np``.``append``(``sim_wait_times``,` `actual``[``i``]` `-` `sim_arrival_times``)`
让我们制作这些模拟等待时间的直方图,以检查其分布情况:
`fig` `=` `px``.``histogram``(``x``=``sim_wait_times``,` `nbins``=``40``,`
`histnorm``=``'``probability density``'``,`
`width``=``450``,` `height``=``300``)`
`fig``.``update_xaxes``(``title``=``"``Simulated wait times for 100,000 passengers``"``)`
`fig``.``update_yaxes``(``title``=``"``proportion``"``)`
`fig``.``show``(``)`

正如我们预期的那样,我们发现了一个偏斜的分布。我们可以用一个常数模型来描述这一点,其中我们使用绝对损失来选择最佳常数。我们在第四章中看到,绝对损失给出了中位数等待时间:
`print``(``f``"``Median wait time:` `{``np``.``median``(``sim_wait_times``)``:``.2f``}` `minutes``"``)`
Median wait time: 6.49 minutes
中位数约为六分半钟,看起来并不太长。虽然我们的模型捕捉了典型等待时间,但我们也想提供一个过程变异性的估计。这个话题在第十七章中有所涉及。我们可以计算等待时间的上四分位数,以帮助我们了解过程的变异性:
`print``(``f``"``Upper quartile:` `{``np``.``quantile``(``sim_wait_times``,` `0.75``)``:``.2f``}` `minutes``"``)`
Upper quartile: 10.62 minutes
上四分位数相当大。当你等待超过 10 分钟的公交车时,这无疑是令人难忘的,因为公交车本应每 12 分钟到达一次,但每四次乘车中就有一次会发生这种情况!
摘要
在我们的第一个案例研究中,我们已经遍历了数据建模的整个生命周期。也许你会觉得,这样一个简单的问题并不能立即用收集的数据回答。我们需要将公交车的预定到达时间和实际到达时间的数据与乘客在随机时间到达公交车站的模拟研究结合起来,以揭示乘客的等待体验。
这个模拟简化了公交乘车中的许多真实模式。我们关注的是单向行驶的一条公交线路,公交车每隔 12 分钟到达一次。此外,数据的探索显示,迟到的模式与一天中的时间相关,这在我们的分析中尚未考虑。尽管如此,我们的发现仍然是有用的。例如,它们证实了典型的等待时间长于计划间隔的一半。等待时间的分布具有右长尾,意味着乘客的体验可能受到过程中变化的影响。
我们还看到了如何衍生新的量,例如公交车的迟到时间和公交车之间的时间,并探索数据在建模中的实用性。我们的直方图显示,特定的公交线路和方向是重要的,需要加以考虑。我们还发现,随着一天中时间的变化,许多公交车在另一辆车到达后 10、12 和 15 分钟到达,有些则到达频率更高或更分散。这一观察进一步为建模阶段提供了信息。
最后,我们使用了pandas和plotly等数据工具库,这些将在后面的章节中介绍。我们的重点不在于如何操作表格或创建图表,而是专注于生命周期,将问题与数据连接到建模到结论。在下一章中,我们将转向处理数据表格的实际问题。
第二部分:矩形数据
第六章:使用 pandas 处理数据框
数据科学家处理存储在表格中的数据。本章介绍了“数据框架”,这是表示数据表的最常用方式之一。我们还介绍了pandas,这是处理数据框架的标准 Python 包。以下是一个包含有关流行狗品种信息的数据框架示例:
| grooming | food_cost | kids | size | |
|---|---|---|---|---|
| breed | ||||
| --- | --- | --- | --- | --- |
| 拉布拉多寻回犬 | 每周 | 466.0 | 高 | 中型 |
| 德国牧羊犬 | 每周 | 466.0 | 中等 | 大型 |
| 比格犬 | 每天 | 324.0 | 高 | 小型 |
| 金毛寻回犬 | 每周 | 466.0 | 高 | 中型 |
| 约克夏梗 | 每天 | 324.0 | 低 | 小型 |
| 英国斗牛犬 | 每周 | 466.0 | 中等 | 中型 |
| 拳师犬 | 每周 | 466.0 | 高 | 中型 |
在数据框中,每行代表一个单独的记录——在本例中是一个狗品种。每列代表记录的一个特征——例如,grooming 列表示每个狗品种需要多频繁地梳理。
数据框架具有列和行的标签。例如,此数据框架具有一个标记为 grooming 的列和一个标记为德国牧羊犬的行。数据框架的列和行是有序的——我们可以将拉布拉多寻回犬行称为数据框架的第一行。
在列内,数据具有相同的类型。例如,食物成本包含数字,狗的大小由类别组成。但是在行内,数据类型可以不同。
由于这些属性,数据框架使得各种有用的操作成为可能。
注意
数据科学家经常发现自己与使用不同背景的人合作。例如,计算机科学家说数据框中的列表示数据的“特征”,而统计学家则称之为“变量”。
有时,人们使用同一个术语来指代略有不同的概念。在编程中,“数据类型”指的是计算机如何在内部存储数据。例如,Python 中的size列具有字符串数据类型。但从统计学的角度来看,size列的类型是有序分类数据(序数数据)。我们在第十章中详细讨论了这种具体区别。
在本章中,我们介绍了常见的数据框架操作。数据科学家在 Python 中处理数据框架时使用pandas库。首先,我们解释了pandas提供的主要对象:DataFrame和Series类。然后,我们展示如何使用pandas执行常见的数据操作任务,如切片、过滤、排序、分组和连接。
子集
本节介绍了对数据框进行子集操作的操作。当数据科学家首次读取数据框时,他们通常希望获取计划使用的特定数据子集。例如,数据科学家可以从包含数百列的数据框中切片出 10 个相关特征。或者,他们可以过滤数据框以删除包含不完整数据的行。在本章的其余部分,我们使用一个婴儿姓名的数据框来演示数据框操作。
数据范围和问题
有一篇2021 年《纽约时报》文章讨论了哈里王子和梅根·马克尔为他们的新生女儿选择的独特姓名“莉莉贝特”。文章中采访了婴儿姓名专家帕梅拉·雷德蒙德,她谈到了人们如何给孩子取名的有趣趋势。例如,她说以字母“L”开头的名字近年来变得非常流行,而以字母“J”开头的名字在上世纪 70 年代和 80 年代最受欢迎。这些说法在数据中反映出来吗?我们可以使用pandas来找出答案。
首先,我们将包导入为pd,这是它的常用缩写:
`import` `pandas` `as` `pd`
我们有一个婴儿姓名数据集,存储在名为babynames.csv的逗号分隔值(CSV)文件中。我们使用pd.read_csv函数将文件读取为pandas.DataFrame对象:
`baby` `=` `pd``.``read_csv``(``'``babynames.csv``'``)`
`baby`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | 利亚姆 | M | 19659 | 2020 |
| 1 | 诺亚 | M | 18252 | 2020 |
| 2 | 奥利弗 | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | 维罗纳 | F | 5 | 1880 |
| 2020720 | 维尔蒂 | F | 5 | 1880 |
| 2020721 | 威尔玛 | F | 5 | 1880 |
2020722 rows × 4 columns
baby表中的数据来自美国社会保障局(SSA),记录了出生证明目的的婴儿姓名和出生性别。SSA 将婴儿姓名数据提供在其网站上。我们已将此数据加载到baby表中。
SSA 网站有一个页面,详细描述了数据。在本章中,我们不会深入讨论数据的限制,但我们将指出网站上有关此相关信息:
所有姓名均来自于 1879 年后在美国出生的社会保障卡申请。请注意,许多 1937 年之前出生的人从未申请过社会保障卡,因此他们的姓名不包含在我们的数据中。对于那些申请过的人,我们的记录可能不显示出生地,同样,他们的姓名也不包含在我们的数据中。
所有数据都来自我们 2021 年 3 月社会保障卡申请记录的 100%样本。
在撰写本文时,重要的一点是指出,SSA 数据集仅提供了男性和女性的二进制选项。我们希望将来像这样的国家数据集能够提供更多包容性选项。
数据框和索引
让我们更详细地查看 baby 数据帧。数据帧有行和列。每行和列都有一个标签,如 图 6-1 中所示。

图 6-1. baby 数据帧为行和列都设置了标签(框起来的)
默认情况下,pandas 分配的行标签从 0 开始递增。在这种情况下,标记为 0 的行和标记为 Name 的列的数据为 'Liam'。
数据帧的行标签也可以是字符串。图 6-2 显示了一个狗数据的数据帧,其中行标签是字符串。

图 6-2. 数据帧中的行标签也可以是字符串,例如本例中,每行都使用狗品种名称进行标记
行标签有一个特殊的名称。我们称之为数据帧的 索引,pandas 将行标签存储在特殊的 pd.Index 对象中。我们暂时不讨论 pd.Index 对象,因为不常操作索引本身。但现在重要的是要记住,即使索引看起来像数据的一列,索引实际上代表行标签,而不是数据。例如,狗品种的数据帧有四列数据,而不是五列,因为索引不算作列。
切片
切片 是通过从另一个数据帧中取出部分行或列来创建一个新数据帧的操作。想象一下切西红柿——切片可以在垂直和水平方向上进行。在 pandas 中进行数据帧切片时,我们使用 .loc 和 .iloc 属性。让我们从 .loc 开始。
这是完整的 baby 数据帧:
`baby`
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 |
| 2020720 | Vertie | F | 5 | 1880 |
| 2020721 | Wilma | F | 5 | 1880 |
2020722 rows × 4 columns
.loc 允许我们使用它们的标签选择行和列。例如,要获取标记为 1 的行和标记为 Name 的列中的数据:
`# The first argument is the row label`
`# ↓`
`baby``.``loc``[``1``,` `'``Name``'``]`
`# ↑`
`# The second argument is the column label`
'Noah'
警告
注意,.loc 需要使用方括号;运行 baby.loc(1, 'Name') 将导致错误。
为了切片多行或列,我们可以使用 Python 切片语法,而不是单独的值:
`baby``.``loc``[``0``:``3``,` `'``Name``'``:``'``Count``'``]`
| Name | Sex | Count | |
|---|---|---|---|
| 0 | Liam | M | 19659 |
| 1 | Noah | M | 18252 |
| 2 | Oliver | M | 14147 |
| 3 | Elijah | M | 13034 |
要获取整列数据,我们可以将空切片作为第一个参数传递:
`baby``.``loc``[``:``,` `'``Count``'``]`
0 19659
1 18252
2 14147
...
2020719 5
2020720 5
2020721 5
Name: Count, Length: 2020722, dtype: int64
请注意,这个输出看起来不像一个数据帧,因为它不是。选择数据帧的单行或单列会产生一个 pd.Series 对象:
`counts` `=` `baby``.``loc``[``:``,` `'``Count``'``]`
`counts``.``__class__``.``__name__`
'Series'
什么是pd.Series对象和pd.DataFrame对象的区别?本质上,pd.DataFrame是二维的——它有行和列,代表着数据表。pd.Series是一维的——它代表着数据列表。pd.Series和pd.DataFrame对象有许多共同的方法,但它们实际上代表着两种不同的东西。混淆两者可能会导致错误和混乱。
要选择数据帧的特定列,请将列表传递给.loc。以下是原始数据帧:
`baby`
| 名字 | 性别 | 数量 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 |
| 2020720 | Vertie | F | 5 | 1880 |
| 2020721 | Wilma | F | 5 | 1880 |
2020722 rows × 4 columns
`# And here's the dataframe with only Name and Year columns`
`baby``.``loc``[``:``,` `[``'``Name``'``,` `'``Year``'``]``]`
`# └──────┬───────┘`
`# list of column labels`
| 名字 | 年份 | |
|---|---|---|
| 0 | Liam | 2020 |
| 1 | Noah | 2020 |
| 2 | Oliver | 2020 |
| ... | ... | ... |
| 2020719 | Verona | 1880 |
| 2020720 | Vertie | 1880 |
| 2020721 | Wilma | 1880 |
2020722 rows × 2 columns
选择列非常常见,所以有一种简便写法:
`# Shorthand for baby.loc[:, 'Name']`
`baby``[``'``Name``'``]`
0 Liam
1 Noah
2 Oliver
...
2020719 Verona
2020720 Vertie
2020721 Wilma
Name: Name, Length: 2020722, dtype: object
`# Shorthand for baby.loc[:, ['Name', 'Count']]`
`baby``[``[``'``Name``'``,` `'``Count``'``]``]`
| 名字 | 数量 | |
|---|---|---|
| 0 | Liam | 19659 |
| 1 | Noah | 18252 |
| 2 | Oliver | 14147 |
| ... | ... | ... |
| 2020719 | Verona | 5 |
| 2020720 | Vertie | 5 |
| 2020721 | Wilma | 5 |
2020722 rows × 2 columns
使用.iloc切片与.loc类似,不同之处在于.iloc使用行和列的位置而不是标签。当数据帧索引具有字符串时,演示时最容易显示.iloc和.loc之间的差异,所以让我们看一个有关狗品种信息的数据帧:
`dogs` `=` `pd``.``read_csv``(``'``dogs.csv``'``,` `index_col``=``'``breed``'``)`
`dogs`
| 美容 | 食品成本 | 孩子 | 尺寸 | |
|---|---|---|---|---|
| 品种 | ||||
| --- | --- | --- | --- | --- |
| 拉布拉多猎犬 | 每周 | 466.0 | 高 | 中等 |
| 德国牧羊犬 | 每周 | 466.0 | 中等 | 大型 |
| 猎犬 | 每日 | 324.0 | 高 | 小型 |
| 金毛寻回犬 | 每周 | 466.0 | 高 | 中等 |
| 约克夏梗 | 每日 | 324.0 | 低 | 小型 |
| 斗牛犬 | 每周 | 466.0 | 中等 | 中等 |
| 拳师犬 | 每周 | 466.0 | 高 | 中等 |
要通过位置获取前三行和前两列,请使用.iloc:
`dogs``.``iloc``[``0``:``3``,` `0``:``2``]`
| 美容 | 食品成本 | |
|---|---|---|
| 品种 | ||
| --- | --- | --- |
| 拉布拉多猎犬 | 每周 | 466.0 |
| 德国牧羊犬 | 每周 | 466.0 |
| 猎犬 | 每日 | 324.0 |
使用.loc进行相同操作需要使用数据帧标签:
`dogs``.``loc``[``'``Labrador Retriever``'``:``'``Beagle``'``,` `'``grooming``'``:``'``food_cost``'``]`
| 美容 | 食品成本 | |
|---|---|---|
| 品种 | ||
| --- | --- | --- |
| 拉布拉多猎犬 | 每周 | 466.0 |
| 德国牧羊犬 | 每周 | 466.0 |
| 猎犬 | 每日 | 324.0 |
接下来,我们将看看如何过滤行。
过滤行
到目前为止,我们已经展示了如何使用.loc和.iloc使用标签和位置来切片数据帧。
然而,数据科学家通常希望筛选行——他们希望使用某些条件获取行的子集。假设我们要找到 2020 年最流行的婴儿名字。为此,我们可以筛选行,仅保留Year为 2020 的行。
要筛选,我们想要检查Year列中的每个值是否等于 1970,然后仅保留这些行。
要比较Year中的每个值,我们切出列并进行布尔比较(这与我们在numpy数组中所做的类似)。以下是参考数据框:
`baby`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 |
| 2020720 | Vertie | F | 5 | 1880 |
| 2020721 | Wilma | F | 5 | 1880 |
2020722 rows × 4 columns
`# Get a Series with the Year data`
`baby``[``'``Year``'``]`
0 2020
1 2020
2 2020
...
2020719 1880
2020720 1880
2020721 1880
Name: Year, Length: 2020722, dtype: int64
`# Compare with 2020`
`baby``[``'``Year``'``]` `==` `2020`
0 True
1 True
2 True
...
2020719 False
2020720 False
2020721 False
Name: Year, Length: 2020722, dtype: bool
注意,对Series进行布尔比较会生成布尔Series。这几乎等同于:
`is_2020` `=` `[``]`
`for` `value` `in` `baby``[``'``Year``'``]``:`
`is_2020``.``append``(``value` `==` `2020``)`
但布尔比较比for循环更容易编写,执行起来也更快。
现在告诉pandas仅保留评估为True的行:
`baby``.``loc``[``baby``[``'``Year``'``]` `==` `2020``,` `:``]`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 31267 | Zylynn | F | 5 | 2020 |
| 31268 | Zynique | F | 5 | 2020 |
| 31269 | Zynlee | F | 5 | 2020 |
31270 rows × 4 columns
提示
将布尔Series传递到.loc中,仅保留Series具有True值的行。
筛选有简写方式。这将计算与前面代码段相同的表,但不使用.loc:
`baby``[``baby``[``'``Year``'``]` `==` `2020``]`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 31267 | Zylynn | F | 5 | 2020 |
| 31268 | Zynique | F | 5 | 2020 |
| 31269 | Zynlee | F | 5 | 2020 |
31270 rows × 4 columns
最后,为了找到 2020 年最常见的名字,请按降序排列数据框中的Count。将较长的表达式用括号括起来,可以轻松添加换行符,使其更易读:
`(``baby``[``baby``[``'``Year``'``]` `==` `2020``]`
`.``sort_values``(``'``Count``'``,` `ascending``=``False``)`
`.``head``(``7``)` `# take the first seven rows`
`)`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 13911 | Emma | F | 15581 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| 13912 | Ava | F | 13084 | 2020 |
| 3 | Elijah | M | 13034 | 2020 |
| 13913 | Charlotte | F | 13003 | 2020 |
我们看到,Liam、Noah 和 Emma 是 2020 年最受欢迎的婴儿名字。
示例:Luna 最近成为热门名字吗?
纽约时报的文章提到,Luna 这个名字在 2000 年之前几乎不存在,但此后已成为女孩们非常流行的名字。Luna 究竟在何时变得流行?我们可以使用切片和筛选来检查。在解决数据处理任务时,我们建议将问题分解为较小的步骤。例如,我们可以这样思考:
-
筛选:仅保留
Name列中含有'Luna'的行。 -
筛选:仅保留
Sex列中含有'F'的行。 -
切片:保留
Count和Year列。
现在只需要将每个步骤翻译成代码即可:
`luna` `=` `baby``[``baby``[``'``Name``'``]` `==` `'``Luna``'``]` `# [1]`
`luna` `=` `luna``[``luna``[``'``Sex``'``]` `==` `'``F``'``]` `# [2]`
`luna` `=` `luna``[``[``'``Count``'``,` `'``Year``'``]``]` `# [3]`
`luna`
| 计数 | 年份 | |
|---|---|---|
| 13923 | 7770 | 2020 |
| 45366 | 7772 | 2019 |
| 77393 | 6929 | 2018 |
| ... | ... | ... |
| 2014083 | 17 | 1883 |
| 2018187 | 18 | 1881 |
| 2020223 | 15 | 1880 |
128 rows × 2 columns
在本书中,我们使用一个叫做plotly的库进行绘图。我们不会在这里深入讨论绘图,因为我们在第十一章中更多地谈论了它。现在,我们使用px.line()制作一个简单的折线图:
`px``.``line``(``luna``,` `x``=``'``Year``'``,` `y``=``'``Count``'``,` `width``=``350``,` `height``=``250``)`

就像文章所说的那样。Luna 在 2000 年左右几乎不流行。换句话说,即使没有关于他们的其他任何信息,如果有人告诉你他们的名字是 Luna,你也可以很好地猜到他们的年龄!
纯属娱乐,这里是同样的 Siri 名字的图表:
`siri` `=` `(``baby``.``query``(``'``Name ==` `"``Siri``"``'``)`
`.``query``(``'``Sex ==` `"``F``"``'``)``)`
`px``.``line``(``siri``,` `x``=``'``Year``'``,` `y``=``'``Count``'``,` `width``=``350``,` `height``=``250``)`

小贴士
使用.query类似于使用带有布尔系列的.loc。query()在过滤上有更多的限制,但可以作为一种简写方便使用。
为什么在 2010 年后突然变得不那么受欢迎呢?嗯,Siri 恰好是苹果的语音助手的名字,于 2011 年推出。让我们在 2011 年划一条线,看看:
`fig` `=` `px``.``line``(``siri``,` `x``=``"``Year``"``,` `y``=``"``Count``"``,` `width``=``350``,` `height``=``250``)`
`fig``.``add_vline``(`
`x``=``2011``,` `line_color``=``"``red``"``,` `line_dash``=``"``dashdot``"``,` `line_width``=``4``,` `opacity``=``0.7`
`)`

看起来家长们不希望其他人对他们的手机说“嘿 Siri”时让他们的孩子感到困惑。
在这一节中,我们介绍了pandas中的数据框。我们涵盖了数据科学家对数据框进行子集切片和使用布尔条件进行筛选的常见方式。在下一节中,我们将解释如何将行聚合在一起。
聚合
本节介绍了数据框中行聚合的操作。数据科学家将行聚合在一起以对数据进行摘要。例如,包含每日销售额的数据集可以聚合以显示月销售额。本节介绍了分组和透视,这两种常见的聚合数据操作。
我们使用上一节介绍的婴儿姓名数据:
`baby` `=` `pd``.``read_csv``(``'``babynames.csv``'``)`
`baby`
| 名字 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | 男 | 19659 | 2020 |
| 1 | Noah | 男 | 18252 | 2020 |
| 2 | Oliver | 男 | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | 女 | 5 | 1880 |
| 2020720 | Vertie | 女 | 5 | 1880 |
| 2020721 | Wilma | 女 | 5 | 1880 |
2020722 rows × 4 columns
基本的分组聚合
假设我们想要找出记录在案的总出生婴儿数。这只是 Count 列的总和:
`baby``[``'``Count``'``]``.``sum``(``)`
352554503
汇总姓名计数是一种简单的聚合数据的方式——它将多行的数据合并。
但假设我们想回答一个更有趣的问题:美国出生率是否随时间而上升?为了回答这个问题,我们可以在每年内对 Count 列求和,而不是在整个数据集上进行求和。换句话说,我们根据 Year 将数据分组,然后在每个组内对 Count 值求和。这个过程在 图 6-3 中有所描述。

图 6-3. 描述了示例数据的分组和聚合过程
我们将这个操作称为分组,然后是聚合。在pandas中,我们这样写:
`baby``.``groupby``(``'``Year``'``)``[``'``Count``'``]``.``sum``(``)`
Year
1880 194419
1881 185772
1882 213385
...
2018 3487193
2019 3437438
2020 3287724
Name: Count, Length: 141, dtype: int64
注意,代码几乎与未分组版本相同,只是以 .groupby('Year') 开始。
结果是一个 pd.Series,其中包含数据集中每年出生的总婴儿数。请注意,该系列的索引包含唯一的 Year 值。现在我们可以绘制随时间变化的计数:
`counts_by_year` `=` `baby``.``groupby``(``'``Year``'``)``[``'``Count``'``]``.``sum``(``)``.``reset_index``(``)`
`px``.``line``(``counts_by_year``,` `x``=``'``Year``'``,` `y``=``'``Count``'``,` `width``=``350``,` `height``=``250``)`

在这张图中我们看到了什么?首先,我们注意到 1920 年之前出生的婴儿似乎非常少。一个可能的解释是社会保障局成立于 1935 年,因此其之前的出生数据可能不够完整。
我们还注意到了 1939 年第二次世界大战爆发时的下降以及 1946 年至 1964 年间的战后婴儿潮时期。
这是在pandas中进行分组的基本步骤:
`(``baby` `# the dataframe`
`.``groupby``(``'``Year``'``)` `# column(s) to group`
`[``'``Count``'``]` `# column(s) to aggregate`
`.``sum``(``)` `# how to aggregate`
`)`
示例:使用.value_counts()
数据框中更常见的任务之一是计算列中每个唯一项出现的次数。例如,我们可能对以下 classroom 数据框中每个姓名出现的次数感兴趣:
`classroom`
| 姓名 | |
|---|---|
| 0 | Eden |
| 1 | Sachit |
| 2 | Eden |
| 3 | Sachit |
| 4 | Sachit |
| 5 | Luke |
实现这一点的一种方式是使用我们的分组步骤和.size() 聚合函数:
`(``classroom`
`.``groupby``(``'``name``'``)`
`[``'``name``'``]`
`.``size``(``)`
`)`
name
Eden 2
Luke 1
Sachit 3
Name: name, dtype: int64
这个操作非常常见,pandas提供了一个简写——.value_counts() 方法用于 pd.Series 对象:
`classroom``[``'``name``'``]``.``value_counts``(``)`
name
Sachit 3
Eden 2
Luke 1
Name: count, dtype: int64
默认情况下,.value_counts() 方法会对结果系列按照从最高到最低的顺序进行排序,方便查看最常见和最不常见的值。我们提到这个方法是因为在书的其他章节中经常使用它。
在多列上进行分组
我们可以将多个列作为列表传递给 .groupby 方法,以一次性对多列进行分组。当需要进一步细分我们的组时,这非常有用。例如,我们可以按年份和性别对数据进行分组,以查看随时间变化的男女婴儿出生数:
`counts_by_year_and_sex` `=` `(``baby`
`.``groupby``(``[``'``Year``'``,` `'``Sex``'``]``)` `# Arg to groupby is a list of column names`
`[``'``Count``'``]`
`.``sum``(``)`
`)`
`counts_by_year_and_sex`
Year Sex
1880 F 83929
M 110490
1881 F 85034
...
2019 M 1785527
2020 F 1581301
M 1706423
Name: Count, Length: 282, dtype: int64
注意代码如何紧随分组的步骤。
counts_by_year_and_sex 系列具有我们称之为多级索引的两个级别,一个用于每列进行的分组。如果我们将系列转换为数据框,则更容易看到结果只有一列:
`counts_by_year_and_sex``.``to_frame``(``)`
| 数量 | ||
|---|---|---|
| 年份 | 性别 | |
| --- | --- | --- |
| 1880 | F | 83929 |
| M | 110490 | |
| 1881 | F | 85034 |
| ... | ... | ... |
| 2019 | M | 1785527 |
| 2020 | F | 1581301 |
| M | 1706423 |
282 rows × 1 columns
索引有两个级别,因为我们按两列进行了分组。多级索引可能有点棘手,所以我们可以重置索引,回到具有单个索引的 dataframe:
`counts_by_year_and_sex``.``reset_index``(``)`
| 年份 | 性别 | 数量 | |
|---|---|---|---|
| 0 | 1880 | F | 83929 |
| 1 | 1880 | M | 110490 |
| 2 | 1881 | F | 85034 |
| ... | ... | ... | ... |
| 279 | 2019 | M | 1785527 |
| 280 | 2020 | F | 1581301 |
| 281 | 2020 | M | 1706423 |
282 rows × 3 columns
自定义聚合函数
分组后,pandas 为我们提供了灵活的方法来聚合数据。到目前为止,我们已经看到了如何在分组后使用 .sum():
`(``baby`
`.``groupby``(``'``Year``'``)`
`[``'``Count``'``]`
`.``sum``(``)` `# aggregate by summing`
`)`
Year
1880 194419
1881 185772
1882 213385
...
2018 3487193
2019 3437438
2020 3287724
Name: Count, Length: 141, dtype: int64
pandas 还提供其他聚合函数,如 .mean()、.size() 和 .first()。下面是使用 .max() 进行相同分组的示例:
`(``baby`
`.``groupby``(``'``Year``'``)`
`[``'``Count``'``]`
`.``max``(``)` `# aggregate by taking the max within each group`
`)`
Year
1880 9655
1881 8769
1882 9557
...
2018 19924
2019 20555
2020 19659
Name: Count, Length: 141, dtype: int64
但有时 pandas 并没有我们想要使用的确切聚合函数。在这些情况下,我们可以定义并使用自定义聚合函数。pandas 通过 .agg(fn) 让我们能够做到这一点,其中 fn 是我们定义的函数。
例如,如果我们想要找出每个组中最大值和最小值之间的差异(数据的范围),我们可以首先定义一个名为 data_range 的函数,然后将该函数传递给 .agg()。这个函数的输入是一个包含一列数据的 pd.Series 对象。它会对每个组调用一次:
`def` `data_range``(``counts``)``:`
`return` `counts``.``max``(``)` `-` `counts``.``min``(``)`
`(``baby`
`.``groupby``(``'``Year``'``)`
`[``'``Count``'``]`
`.``agg``(``data_range``)` `# aggregate using custom function`
`)`
Year
1880 9650
1881 8764
1882 9552
...
2018 19919
2019 20550
2020 19654
Name: Count, Length: 141, dtype: int64
我们首先定义了一个 count_unique 函数,用于计算系列中唯一值的数量。然后我们将该函数传递给 .agg()。由于这个函数很短,我们可以使用 lambda 表达式代替:
`def` `count_unique``(``s``)``:`
`return` `len``(``s``.``unique``(``)``)`
`unique_names_by_year` `=` `(``baby`
`.``groupby``(``'``Year``'``)`
`[``'``Name``'``]`
`.``agg``(``count_unique``)` `# aggregate using the custom count_unique function`
`)`
`unique_names_by_year`
Year
1880 1889
1881 1829
1882 2012
...
2018 29619
2019 29417
2020 28613
Name: Name, Length: 141, dtype: int64
`px``.``line``(``unique_names_by_year``.``reset_index``(``)``,`
`x``=``'``Year``'``,` `y``=``'``Name``'``,`
`labels``=``{``'``Name``'``:` `'``# unique names``'``}``,`
`width``=``350``,` `height``=``250``)`

我们发现,尽管自 1960 年代以来每年出生的婴儿数量已经趋于稳定,但独特名称的数量总体上还是在增加的。
数据透视
数据透视基本上是在使用两列进行分组时,方便地安排分组和聚合结果的一种方法。在本节的前面,我们将婴儿姓名数据按年份和性别分组:
`counts_by_year_and_sex` `=` `(``baby`
`.``groupby``(``[``'``Year``'``,` `'``Sex``'``]``)`
`[``'``Count``'``]`
`.``sum``(``)`
`)`
`counts_by_year_and_sex``.``to_frame``(``)`
| 数量 | ||
|---|---|---|
| 年份 | 性别 | |
| --- | --- | --- |
| 1880 | F | 83929 |
| M | 110490 | |
| 1881 | F | 85034 |
| ... | ... | ... |
| 2019 | M | 1785527 |
| 2020 | F | 1581301 |
| M | 1706423 |
282 rows × 1 columns
这将生成一个包含计数的 pd.Series。我们还可以想象相同的数据,将 Sex 索引级别“透视”到 dataframe 的列中。通过一个例子更容易理解:
`mf_pivot` `=` `pd``.``pivot_table``(`
`baby``,`
`index``=``'``Year``'``,` `# Column to turn into new index`
`columns``=``'``Sex``'``,` `# Column to turn into new columns`
`values``=``'``Count``'``,` `# Column to aggregate for values`
`aggfunc``=``sum``)` `# Aggregation function`
`mf_pivot`
| 性别 | F | M |
|---|---|---|
| 年份 | ||
| --- | --- | --- |
| 1880 | 83929 | 110490 |
| 1881 | 85034 | 100738 |
| 1882 | 99699 | 113686 |
| ... | ... | ... |
| 2018 | 1676884 | 1810309 |
| 2019 | 1651911 | 1785527 |
| 2020 | 1581301 | 1706423 |
141 rows × 2 columns
注意
如我们在mf_pivot表中所见,数据框的索引也可以命名。为了阅读输出,重要的是注意数据框有两列,M 和 F,存储在名为 Sex 的索引中。同样,数据框有 141 行,每行有自己的标签。这些标签存储在名为 Year 的索引中。这里,Sex 和 Year 是数据框索引的名称,不是行或列标签本身。
注意,在透视表和使用.groupby()生成的表中,数据值是相同的;只是排列方式不同。透视表可以使用两个属性快速汇总数据,通常出现在文章和论文中。
函数px.line()也能很好地与透视表配合使用,因为该函数在表中的每一列数据上绘制一条线:
`fig` `=` `px``.``line``(``mf_pivot``,` `width``=``350``,` `height``=``250``)`
`fig``.``update_traces``(``selector``=``1``,` `line_dash``=``'``dashdot``'``)`
`fig``.``update_yaxes``(``title``=``'``Value``'``)`

本节介绍了使用pandas中的.groupby()函数以及一个或多个列使用pd.pivot_table()函数对数据进行聚合的常见方法。在下一节中,我们将解释如何将数据框连接在一起。
连接
数据科学家经常希望连接两个或多个数据框,以便跨数据框连接数据值。例如,一个在线书店可能有一个数据框,其中包含每位用户订购的书籍,以及一个包含每本书流派的第二个数据框。通过将这两个数据框连接起来,数据科学家可以看到每位用户偏爱哪些流派。
我们将继续查看婴儿姓名数据。我们将使用连接来检查纽约时报关于婴儿姓名的文章中提到的一些趋势。文章讨论了某些类别的姓名如何随时间变得更受欢迎或不受欢迎。例如,它提到了神话般的姓名如 Julius 和 Cassius 变得流行,而婴儿潮时期的姓名如 Susan 和 Debbie 则变得不那么流行。这些类别的流行度随时间的变化如何?
我们将纽约时报文章中的名称和类别放入了一个小数据框中:
`nyt` `=` `pd``.``read_csv``(``'``nyt_names.csv``'``)`
`nyt`
| nyt_name | category | |
|---|---|---|
| 0 | Lucifer | forbidden |
| 1 | Lilith | forbidden |
| 2 | Danger | forbidden |
| ... | ... | ... |
| 20 | Venus | celestial |
| 21 | Celestia | celestial |
| 22 | Skye | celestial |
23 rows × 2 columns
要查看名称类别的流行程度,我们将nyt数据框与baby数据框连接以从baby获取名称计数:
`baby` `=` `pd``.``read_csv``(``'``babynames.csv``'``)`
`baby`
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 |
| 2020720 | Vertie | F | 5 | 1880 |
| 2020721 | Wilma | F | 5 | 1880 |
2020722 rows × 4 columns
对于直觉,我们可以想象在 baby 的每一行中进行以下询问:这个名称是否在 nyt 表中?如果是,那么将 category 列的值添加到该行。这就是连接背后的基本思想。让我们首先看一些较小数据框的示例。
内连接
我们首先制作 baby 和 nyt 表的较小版本,这样在连接表格时更容易看到发生的情况:
`nyt_small`
| nyt_name | category | |
|---|---|---|
| 0 | Karen | 战斗者 |
| 1 | Julius | 神话 |
| 2 | Freya | 神话 |
`baby_small`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Noah | M | 18252 | 2020 |
| 1 | Julius | M | 960 | 2020 |
| 2 | Karen | M | 6 | 2020 |
| 3 | Karen | F | 325 | 2020 |
| 4 | Noah | F | 305 | 2020 |
要在 pandas 中连接表,我们将使用 .merge() 方法:
`baby_small``.``merge``(``nyt_small``,`
`left_on``=``'``Name``'``,` `# column in left table to match`
`right_on``=``'``nyt_name``'``)` `# column in right table to match`
| 名称 | 性别 | 计数 | 年份 | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Julius | M | 960 | 2020 | Julius | 神话 |
| 1 | Karen | M | 6 | 2020 | Karen | boomer |
| ** | 2 | Karen | F | 325 | 2020 | Karen |
注意,新表具有 baby_small 和 nyt_small 表的列。名称为 Noah 的行已消失。其余行从 nyt_small 中获得了匹配的 category。
注意
读者还应注意,pandas 具有 .join() 方法用于将两个数据框连接在一起。然而,.merge() 方法在数据框连接方面更加灵活,因此我们专注于 .merge()。我们鼓励读者查阅 pandas 文档,了解这两者之间的确切区别。
当我们将两个表连接在一起时,我们告诉 pandas 我们要使用哪些列(left_on 和 right_on 参数)来进行连接。当连接列中的值匹配时,pandas 将行进行匹配,如图 6-4 所示。

图 6-4。要进行连接,pandas 使用 Name 和 nyt_name 列中的值进行行匹配,删除没有匹配值的行。
默认情况下,pandas 执行内连接。如果任一表中的行在另一表中没有匹配项,pandas 将从结果中删除这些行。在本例中,baby_small 中的 Noah 行在 nyt_small 中没有匹配项,因此被删除。同样,nyt_small 中的 Freya 行也没有在 baby_small 中找到匹配项,因此也被删除。只有在两个表中都有匹配项的行才会留在最终结果中。
左连接、右连接和外连接
有时我们希望保留没有匹配项的行,而不是完全删除它们。还有其他类型的连接——左连接、右连接和外连接——即使在没有匹配项时也会保留行。
在左连接中,保留左表中没有匹配项的行,如图 6-5 所示。

图 6-5。在左连接中,保留左表中没有匹配值的行
要在pandas中进行左连接,调用.merge()时使用how='left'参数:
`baby_small``.``merge``(``nyt_small``,`
`left_on``=``'``Name``'``,`
`right_on``=``'``nyt_name``'``,`
`how``=``'``left``'``)` `# left join instead of inner`
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Noah | M | 18252 | 2020 | NaN | NaN |
| 1 | Julius | M | 960 | 2020 | Julius | mythology |
| 2 | Karen | M | 6 | 2020 | Karen | boomer |
| 3 | Karen | F | 325 | 2020 | Karen | boomer |
| 4 | Noah | F | 305 | 2020 | NaN | NaN |
注意Noah行在最终表中被保留。由于这些行在nyt_small数据框中没有匹配,连接在nyt_name和category列中留下NaN值。同时注意,nyt_small中的Freya行仍然被丢弃。
右连接与左连接类似,但保留右表中没有匹配的行而不是左表:
`baby_small``.``merge``(``nyt_small``,`
`left_on``=``'``Name``'``,`
`right_on``=``'``nyt_name``'``,`
`how``=``'``right``'``)`
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Karen | M | 6.0 | 2020.0 | Karen | boomer |
| 1 | Karen | F | 325.0 | 2020.0 | Karen | boomer |
| 2 | Julius | M | 960.0 | 2020.0 | Julius | mythology |
| 3 | NaN | NaN | NaN | NaN | Freya | mythology |
最后,外连接保留两个表中的行,即使它们没有匹配:
`baby_small``.``merge``(``nyt_small``,`
`left_on``=``'``Name``'``,`
`right_on``=``'``nyt_name``'``,`
`how``=``'``outer``'``)`
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Noah | M | 18252.0 | 2020.0 | NaN | NaN |
| 1 | Noah | F | 305.0 | 2020.0 | NaN | NaN |
| 2 | Julius | M | 960.0 | 2020.0 | Julius | mythology |
| 3 | Karen | M | 6.0 | 2020.0 | Karen | boomer |
| 4 | Karen | F | 325.0 | 2020.0 | Karen | boomer |
| 5 | NaN | NaN | NaN | NaN | Freya | mythology |
示例:纽约时报名字类别的流行程度
现在让我们回到完整的数据框baby和nyt。.head()用于获取前几行数据,节省空间:
`baby``.``head``(``2``)`
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
`nyt``.``head``(``2``)`
| nyt_name | category | |
|---|---|---|
| 0 | Lucifer | forbidden |
| 1 | Lilith | forbidden |
我们想要了解nyt中名字类别的流行度随时间的变化。为了回答这个问题:
-
用
baby和nyt进行内连接。 -
将表按
category和Year分组。 -
使用求和对计数进行聚合:
`cate_counts` `=` `(`
`baby``.``merge``(``nyt``,` `left_on``=``'``Name``'``,` `right_on``=``'``nyt_name``'``)` `# [1]`
`.``groupby``(``[``'``category``'``,` `'``Year``'``]``)` `# [2]`
`[``'``Count``'``]` `# [3]`
`.``sum``(``)` `# [3]`
`.``reset_index``(``)`
`)`
`cate_counts`
| category | Year | Count | |
|---|---|---|---|
| 0 | boomer | 1880 | 292 |
| 1 | boomer | 1881 | 298 |
| 2 | boomer | 1882 | 326 |
| ... | ... | ... | ... |
| 647 | mythology | 2018 | 2944 |
| 648 | mythology | 2019 | 3320 |
| 649 | mythology | 2020 | 3489 |
650 rows × 3 columns
现在我们可以绘制boomer名字和mythology名字的流行度:

据纽约时报文章称,自 2000 年以来,婴儿潮一代的名字变得不太流行,而神话名字则变得更受欢迎。
我们还可以一次性绘制所有类别的流行度。查看下面的图表,看看它们是否支持纽约时报文章中的观点:

在本节中,我们介绍了数据框的连接操作。当将数据框连接在一起时,我们使用.merge()函数匹配行。在连接数据框时,考虑连接的类型(内部、左侧、右侧或外部)是很重要的。在下一节中,我们将解释如何转换数据框中的值。
转换中
当数据科学家需要以相同的方式更改特征中的每个值时,他们会转换数据框列。例如,如果一个特征包含以英尺表示的人的身高,数据科学家可能希望将身高转换为厘米。在本节中,我们将介绍apply,这是一种使用用户定义函数转换数据列的操作:
`baby` `=` `pd``.``read_csv``(``'``babynames.csv``'``)`
`baby`
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 |
| 2020720 | Vertie | F | 5 | 1880 |
| 2020721 | Wilma | F | 5 | 1880 |
2020722 rows × 4 columns
在纽约时报的婴儿名字文章中,帕梅拉提到,以字母 L 或 K 开头的名字在 2000 年后变得流行。另一方面,以字母 J 开头的名字在 1970 年代和 1980 年代达到了流行高峰,但自那以后流行度下降。我们可以使用baby数据集验证这些说法。
我们通过以下步骤解决这个问题:
-
将
Name列转换为一个新列,其中包含每个Name值的第一个字母。 -
根据第一个字母和年份对数据框进行分组。
-
通过求和聚合名称计数。
要完成第一步,我们将应用一个函数到Name列。
应用
pd.Series对象包含一个.apply()方法,该方法接受一个函数并将其应用于系列中的每个值。例如,要找出每个名称的长度,我们应用len函数:
`names` `=` `baby``[``'``Name``'``]`
`names``.``apply``(``len``)`
0 4
1 4
2 6
..
2020719 6
2020720 6
2020721 5
Name: Name, Length: 2020722, dtype: int64
要提取每个名称的第一个字母,我们定义一个自定义函数,并将其传递给.apply()。函数的参数是系列中的一个单独值:
`def` `first_letter``(``string``)``:`
`return` `string``[``0``]`
`names``.``apply``(``first_letter``)`
0 L
1 N
2 O
..
2020719 V
2020720 V
2020721 W
Name: Name, Length: 2020722, dtype: object
使用.apply()类似于使用for循环。前面的代码大致相当于写成:
`result` `=` `[``]`
`for` `name` `in` `names``:`
`result``.``append``(``first_letter``(``name``)``)`
现在我们可以将首字母分配给数据框中的新列:
`letters` `=` `baby``.``assign``(``Firsts``=``names``.``apply``(``first_letter``)``)`
`letters`
| Name | Sex | Count | Year | Firsts | |
|---|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 | L |
| 1 | Noah | M | 18252 | 2020 | N |
| 2 | Oliver | M | 14147 | 2020 | O |
| ... | ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 | V |
| 2020720 | Vertie | F | 5 | 1880 | V |
| 2020721 | Wilma | F | 5 | 1880 | W |
2020722 rows × 5 columns
注意
要在数据框中创建一个新列,您可能还会遇到以下语法:
`baby``[``'``Firsts``'``]` `=` `names``.``apply``(``first_letter``)`
这会通过添加一个名为 Firsts 的新列来改变 baby 表。在前面的代码中,我们使用了 .assign(),它不会改变 baby 表本身,而是创建了一个新的数据框。改变数据框并不是错误的,但可能是错误的常见源泉。因此,在本书中,我们大多数情况下会使用 .assign()。
示例: “L” 开头名字的流行程度
现在我们可以使用 letters 数据框来查看首字母随时间变化的流行程度:
`letter_counts` `=` `(``letters`
`.``groupby``(``[``'``Firsts``'``,` `'``Year``'``]``)`
`[``'``Count``'``]`
`.``sum``(``)`
`.``reset_index``(``)`
`)`
`letter_counts`
| Firsts | Year | Count | |
|---|---|---|---|
| 0 | A | 1880 | 16740 |
| 1 | A | 1881 | 16257 |
| 2 | A | 1882 | 18790 |
| ... | ... | ... | ... |
| 3638 | Z | 2018 | 55996 |
| 3639 | Z | 2019 | 55293 |
| 3640 | Z | 2020 | 54011 |
3641 rows × 3 columns
`fig` `=` `px``.``line``(``letter_counts``.``loc``[``letter_counts``[``'``Firsts``'``]` `==` `'``L``'``]``,`
`x``=``'``Year``'``,` `y``=``'``Count``'``,` `title``=``'``Popularity of` `"``L``"` `names``'``,`
`width``=``350``,` `height``=``250``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``30``)``)`

这张图表显示,在 1960 年代,“L”开头的名字很流行,在此后的几十年里有所下降,但自 2000 年以来确实重新流行起来。
那么,“J” 开头的名字怎么样?
`fig` `=` `px``.``line``(``letter_counts``.``loc``[``letter_counts``[``'``Firsts``'``]` `==` `'``J``'``]``,`
`x``=``'``Year``'``,` `y``=``'``Count``'``,` `title``=``'``Popularity of` `"``J``"` `names``'``,`
`width``=``350``,` `height``=``250``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``30``)``)`

纽约时报 的文章指出,“J”开头的名字在 1970 年代和 80 年代很流行。图表也证实了这一点,并显示自 2000 年以来它们变得不那么流行。
.apply() 的代价
.apply() 的强大之处在于其灵活性——你可以用任何接受单个数据值并输出单个数据值的函数来调用它。
然而,它的灵活性也有一个代价。使用 .apply() 可能会很慢,因为 pandas 不能优化任意函数。例如,对于数值计算,使用 .apply() 比直接在 pd.Series 对象上使用向量化操作要慢得多:
`%``%``timeit`
`# Calculate the decade using vectorized operators`
`baby``[``'``Year``'``]` `/``/` `10` `*` `10`
9.66 ms ± 755 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
`%``%``timeit`
`def` `decade``(``yr``)``:`
`return` `yr` `/``/` `10` `*` `10`
`# Calculate the decade using apply`
`baby``[``'``Year``'``]``.``apply``(``decade``)`
658 ms ± 49.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
使用 .apply() 的版本要慢 30 倍!特别是对于数值计算,我们建议直接在 pd.Series 对象上进行向量化操作。
在本节中,我们介绍了数据转换。为了在数据框中转换值,我们通常使用 .apply() 和 .assign() 函数。在下一节中,我们将比较数据框与其他表示和操作数据表的方法。
数据框与其他数据表示方式有何不同?
数据框只是表示表中存储数据的一种方式。在实践中,数据科学家会遇到许多其他类型的数据表,如电子表格、矩阵和关系表。在本节中,我们将比较数据框与其他表示方式,解释为什么数据框在数据分析中如此广泛使用。我们还将指出其他表示方式可能更合适的场景。
数据框和电子表格
电子表格是计算机应用程序,用户可以在网格中输入数据并使用公式进行计算。今天一个众所周知的例子是微软 Excel,尽管电子表格可以追溯到至少 1979 年的VisiCalc。电子表格使得直接查看和操作数据变得很容易,因为电子表格公式可以在数据更改时自动重新计算结果。相比之下,数据框代码通常需要在数据集更新时手动重新运行。这些特性使得电子表格非常受欢迎——根据2005 年的估计,有超过 5500 万的电子表格用户,而行业中只有 300 万专业程序员。
数据框比电子表格有几个关键优势。在类似 Jupyter 的计算笔记本中编写数据框代码会自然地产生数据谱系。打开笔记本的人可以看到笔记本的输入文件以及数据是如何更改的。电子表格不显示数据谱系;如果一个人手动编辑单元格中的数据值,将很难让未来的用户看到哪些值是手动编辑的以及如何编辑的。数据框可以处理比电子表格更大的数据集,用户还可以使用分布式编程工具来处理很难加载到电子表格中的大数据集。
数据框和矩阵
矩阵是用于线性代数操作的二维数据数组。在下一个例子中,是一个具有三行两列的矩阵:
矩阵是由它们允许的运算符定义的数学对象。例如,矩阵可以相加或相乘。矩阵也有转置。这些运算符具有数据科学家在统计建模中依赖的非常有用的特性。
矩阵和数据框之间的一个重要区别是,当矩阵被视为数学对象时,它们只能包含数字。另一方面,数据框还可以包含文本等其他类型的数据。这使得数据框更适合加载和处理可能包含各种数据类型的原始数据。实际上,数据科学家经常将数据加载到数据框中,然后将数据处理成矩阵形式。在本书中,我们通常使用数据框进行探索性数据分析和数据清洗,然后将数据处理成矩阵形式用于机器学习模型。
注意
数据科学家将矩阵称为数学对象,也称为程序对象。例如,R 编程语言有一个矩阵对象,而在 Python 中,我们可以使用二维的numpy数组表示矩阵。在 Python 和 R 中实现的矩阵可以包含除了数字以外的其他数据类型,但这样做会丧失数学属性。这是领域可以用同一术语指称不同事物的另一个例子。
数据框和关系
关系是数据库系统中使用的数据表表示形式,特别是像 SQLite 和 PostgreSQL 这样的 SQL 系统。(我们在第七章中介绍了关系和 SQL。)关系与数据框架有许多相似之处;它们都使用行来表示记录,列来表示特征。两者都有列名,并且列内的数据具有相同的类型。
数据框架的一个关键优势在于,它们不需要行来表示记录,也不需要列来表示特征。许多时候,原始数据并不以直接放入关系中的方便格式出现。在这些情况下,数据科学家使用数据框架来加载和处理数据,因为数据框架在这方面更加灵活。通常,数据科学家会将原始数据加载到数据框架中,然后处理数据,使其能够轻松地存储在关系中。
关系型数据库系统(如PostgreSQL)比数据框架具有的一个关键优势在于,它们具有非常有用的数据存储和管理功能。考虑一家运营大型社交媒体网站的数据科学家。数据库可能包含的数据量太大,无法一次性读入pandas数据框架;因此,数据科学家使用 SQL 查询来子集化和聚合数据,因为数据库系统更能处理大型数据集。此外,网站用户通过发布帖子、上传图片和编辑个人资料不断更新其数据。在这种情况下,数据库系统让数据科学家能够重复使用现有的 SQL 查询更新他们的分析,而不是反复下载大型 CSV 文件。
摘要
在本章中,我们解释了数据框架是什么,它们为什么有用,以及如何使用pandas代码与它们一起工作。子集化、聚合、连接和转换几乎在每个数据分析中都是有用的。在本书的其余部分中,特别是第 8、第 9 和第十章中,我们将经常依赖这些操作。
第七章:使用 SQL 处理关系
在第六章中,我们使用数据框表示数据表。本章介绍了关系,另一种广泛使用的表示数据表的方式。我们还介绍了 SQL,这是处理关系的标准编程语言。以下是一个关于流行狗品种信息的关系示例。
像数据框一样,关系中的每一行表示一个单狗品种记录。每一列表示记录的一个特征,例如,grooming列表示每个狗品种需要多频繁地梳理。
关系和数据框都为表中的每一列都有标签。但是,一个关键区别在于关系中的行没有标签,而数据框中的行有。
本章中,我们演示使用 SQL 进行常见的关系操作。我们首先解释 SQL 查询的结构。然后展示如何使用 SQL 执行常见的数据操作任务,如切片、过滤、排序、分组和连接。
注意
本章复制了第六章中的数据分析,但使用的是关系和 SQL,而不是数据框和 Python。两章的数据集、数据操作和结论几乎相同,以便于在使用pandas和 SQL 执行数据操作时进行比较。
子集化
要使用关系,我们将介绍一种称为SQL(Structured Query Language)的领域特定编程语言。我们通常将“SQL”发音为“sequel”,而不是拼写首字母缩略词。SQL 是一种专门用于处理关系的语言,因此,与 Python 在操作关系数据时相比,SQL 具有不同的语法。
在本章中,我们将在 Python 程序中使用 SQL 查询。这展示了一个常见的工作流程——数据科学家经常在 SQL 中处理和子集化数据,然后将数据加载到 Python 中进行进一步分析。与pandas程序相比,SQL 数据库使处理大量数据变得更加容易。但是,将数据加载到pandas中使得可视化数据和构建统计模型变得更加容易。
注意
为什么 SQL 系统往往更适合处理大型数据集?简而言之,SQL 系统具有用于管理存储在磁盘上的数据的复杂算法。例如,当处理大型数据集时,SQL 系统会透明地一次加载和操作小部分数据;相比之下,在pandas中做到这一点可能会更加困难。我们将在第八章中更详细地讨论这个主题。
SQL 基础知识:SELECT 和 FROM
我们将使用pd.read_sql函数运行 SQL 查询,并将输出存储在pandas数据框中。使用此函数需要一些设置。我们首先导入pandas和sqlalchemy Python 包:
`import` `pandas` `as` `pd`
`import` `sqlalchemy`
我们的数据库存储在名为babynames.db的文件中。这个文件是一个SQLite数据库,因此我们将设置一个可以处理这种格式的sqlalchemy对象:
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite:///babynames.db``'``)`
注
在本书中,我们使用 SQLite,这是一个非常有用的本地数据存储数据库系统。其他系统做出了不同的权衡,适用于不同的领域。例如,PostgreSQL 和 MySQL 是更复杂的系统,适用于大型 Web 应用程序,在这些应用程序中,许多最终用户同时写入数据。虽然每个 SQL 系统有细微的差异,但它们提供相同的核心 SQL 功能。读者可能还知道 Python 在其标准 sqlite3 库中提供了对 SQLite 的支持。我们选择使用 sqlalchemy 是因为它更容易重用代码,以适用于 SQLite 之外的其他 SQL 系统。
现在我们可以使用 pd.read_sql 在这个数据库上运行 SQL 查询。这个数据库有两个关系:baby 和 nyt。这是一个读取整个 baby 关系的简单示例。我们将 SQL 查询作为 Python 字符串编写,并传递给 pd.read_sql:
`query` `=` `'''`
`SELECT *`
`FROM baby;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 |
| 2020720 | Vertie | F | 5 | 1880 |
| 2020721 | Wilma | F | 5 | 1880 |
2020722 rows × 4 columns
变量 query 内的文本包含 SQL 代码。SELECT 和 FROM 是 SQL 关键字。我们读取前述查询如下:
SELECT * -- Get all the columns...
FROM baby; -- ...from the baby relation
baby 关系包含与 第六章 中 baby 数据帧相同的数据:所有由美国社会安全管理局注册的婴儿姓名。
什么是关系?
让我们更详细地检查 baby 关系。一个关系有行和列。每一列都有一个标签,如 Figure 7-1 所示。不像数据帧,然而,关系中的个别行没有标签。也不像数据帧,关系的行不是有序的。

图 7-1. baby 关系具有列的标签(用框框起来)
关系有着悠久的历史。对关系的更正式处理使用术语 元组 来指代关系的行,属性 来指代列。还有一种严格的方式使用关系代数来定义数据操作,它源自数学集合代数。
切片
切片 是通过从另一个关系中取出部分行或列来创建新关系的操作。想象切番茄——切片可以垂直和水平进行。要对关系的列进行切片,我们给 SELECT 语句传递我们想要的列:
`query` `=` `'''`
`SELECT Name`
`FROM baby;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | |
|---|---|
| 0 | Liam |
| 1 | Noah |
| 2 | Oliver |
| ... | ... |
| 2020719 | Verona |
| 2020720 | Vertie |
| 2020721 | Wilma |
2020722 rows × 1 columns
`query` `=` `'''`
`SELECT Name, Count`
`FROM baby;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | Count | |
|---|---|---|
| 0 | Liam | 19659 |
| 1 | Noah | 18252 |
| 2 | Oliver | 14147 |
| ... | ... | ... |
| 2020719 | Verona | 5 |
| 2020720 | Vertie | 5 |
| 2020721 | Wilma | 5 |
2020722 rows × 2 columns
要切片出特定数量的行,请使用 LIMIT 关键字:
`query` `=` `'''`
`SELECT Name`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | |
|---|---|
| 0 | Liam |
| 1 | Noah |
| 2 | Oliver |
| ... | ... |
| 7 | Lucas |
| 8 | Henry |
| 9 | Alexander |
10 rows × 1 columns
总之,我们使用 SELECT 和 LIMIT 关键字来切片关系的列和行。
过滤行
现在我们转向过滤行—使用一个或多个条件取子集的行。在 pandas 中,我们使用布尔系列对象切片数据帧。在 SQL 中,我们使用带有谓词的 WHERE 关键字。以下查询将 baby 关系过滤为仅包含 2020 年的婴儿姓名:
`query` `=` `'''`
`SELECT *`
`FROM baby`
`WHERE Year = 2020;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 31267 | Zylynn | F | 5 | 2020 |
| 31268 | Zynique | F | 5 | 2020 |
| 31269 | Zynlee | F | 5 | 2020 |
31270 rows × 4 columns
警告
请注意,在比较相等性时,SQL 使用单等号:
SELECT *
FROM baby
WHERE Year = 2020;
-- ↑
-- Single equals sign
然而,在 Python 中,单等号用于变量赋值。语句 Year = 2020 将值 2020 赋给变量 Year。要进行相等比较,Python 代码使用双等号:
`# Assignment`
`my_year` `=` `2021`
`# Comparison, which evaluates to False`
`my_year` `==` `2020`
要向过滤器添加更多谓词,使用 AND 和 OR 关键字。例如,要查找在 2020 年或 2019 年出生的超过 10,000 名婴儿的姓名,我们写道:
`query` `=` `'''`
`SELECT *`
`FROM baby`
`WHERE Count > 10000`
`AND (Year = 2020`
`OR Year = 2019);`
`-- Notice that we use parentheses to enforce evaluation order`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 41 | Mia | F | 12452 | 2019 |
| 42 | Harper | F | 10464 | 2019 |
| 43 | Evelyn | F | 10412 | 2019 |
44 rows × 4 columns
最后,要查找 2020 年最常见的 10 个名字,我们可以使用 ORDER BY 关键字和 DESC 选项(DESC 表示 DESCending)按 Count 降序排序数据框:
`query` `=` `'''`
`SELECT *`
`FROM baby`
`WHERE Year = 2020`
`ORDER BY Count DESC`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Emma | F | 15581 | 2020 |
| ... | ... | ... | ... | ... |
| 7 | Sophia | F | 12976 | 2020 |
| 8 | Amelia | F | 12704 | 2020 |
| 9 | William | M | 12541 | 2020 |
10 rows × 4 columns
我们看到,Liam、Noah 和 Emma 是 2020 年最受欢迎的婴儿名字。
例如:Luna 何时成为流行名字?
正如我们在第六章中提到的,纽约时报文章提到,Luna 这个名字在 2000 年之前几乎不存在,但此后已成为女孩们非常流行的名字。Luna 何时变得流行?我们可以使用 SQL 中的切片和过滤来检查:
`query` `=` `'''`
`SELECT *`
`FROM baby`
`WHERE Name =` `"``Luna``"`
`AND Sex =` `"``F``"``;`
`'''`
`luna` `=` `pd``.``read_sql``(``query``,` `db``)`
`luna`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Luna | F | 7770 | 2020 |
| 1 | Luna | F | 7772 | 2019 |
| 2 | Luna | F | 6929 | 2018 |
| ... | ... | ... | ... | ... |
| 125 | Luna | F | 17 | 1883 |
| 126 | Luna | F | 18 | 1881 |
| 127 | Luna | F | 15 | 1880 |
128 rows × 4 columns
pd.read_sql 返回一个 pandas.DataFrame 对象,我们可以用它来绘制图表。这展示了一个常见的工作流程 —— 使用 SQL 处理数据,将其加载到 pandas 数据框中,然后可视化结果:
`px``.``line``(``luna``,` `x``=``'``Year``'``,` `y``=``'``Count``'``,` `width``=``350``,` `height``=``250``)`

在本节中,我们介绍了数据科学家对关系进行子集处理的常见方法 —— 使用列标签进行切片和使用布尔条件进行过滤。在下一节中,我们将解释如何将行聚合在一起。
聚合
本节介绍了 SQL 中的分组和聚合。我们将使用与前一节相同的婴儿名数据:
`import` `sqlalchemy`
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite:///babynames.db``'``)`
`query` `=` `'''`
`SELECT *`
`FROM baby`
`LIMIT 10`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 7 | Lucas | M | 11281 | 2020 |
| 8 | Henry | M | 10705 | 2020 |
| 9 | Alexander | M | 10151 | 2020 |
10 rows × 4 columns
基本的分组聚合使用 GROUP BY
假设我们想找出记录在此数据中的总出生婴儿数。这只是 Count 列的总和。SQL 提供了我们在 SELECT 语句中使用的函数,比如 SUM:
`query` `=` `'''`
`SELECT SUM(Count)`
`FROM baby`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| SUM(Count) | |
|---|---|
| 0 | 352554503 |
在 第六章 中,我们使用分组和聚合来判断随时间是否有上升趋势的美国出生率。我们使用 .groupby() 按年份对数据集进行分组,然后使用 .sum() 在每个组内对计数进行求和。
在 SQL 中,我们使用 GROUP BY 子句进行分组,然后在 SELECT 中调用聚合函数:
`query` `=` `'''`
`SELECT Year, SUM(Count)`
`FROM baby`
`GROUP BY Year`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Year | SUM(Count) | |
|---|---|---|
| 0 | 1880 | 194419 |
| 1 | 1881 | 185772 |
| 2 | 1882 | 213385 |
| ... | ... | ... |
| 138 | 2018 | 3487193 |
| 139 | 2019 | 3437438 |
| 140 | 2020 | 3287724 |
141 rows × 2 columns
与数据框分组一样,注意 Year 列包含唯一的 Year 值 —— 因为我们将它们分组在一起,所以不再有重复的 Year 值。在 pandas 中进行分组时,分组列成为结果数据框的索引。但是,关系没有行标签,所以 Year 值只是结果关系的一列。
这是在 SQL 中进行分组的基本步骤:
SELECT
col1, -- column used for grouping
SUM(col2) -- aggregation of another column
FROM table_name -- relation to use
GROUP BY col1 -- the column(s) to group by
请注意,SQL 语句中子句的顺序很重要。为了避免语法错误,SELECT 需要首先出现,然后是 FROM,接着是 WHERE,最后是 GROUP BY。
在使用 GROUP BY 时,我们需要注意给 SELECT 的列。通常情况下,只有在使用这些列进行分组时,才能包括未经聚合的列。例如,在上述示例中我们按 Year 列进行分组,因此可以在 SELECT 子句中包括 Year。所有其他包含在 SELECT 中的列应该进行聚合,就像我们之前用 SUM(Count) 所做的那样。如果我们包含一个未使用于分组的“裸”列如 Name,SQLite 不会报错,但其他 SQL 引擎会报错,因此建议避免这样做。
多列分组
我们将多列传递给 GROUP BY,以便一次性按多列进行分组。当我们需要进一步细分我们的分组时,这是非常有用的。例如,我们可以按年份和性别分组,以查看随时间变化出生的男女婴儿数量:
`query` `=` `'''`
`SELECT Year, Sex, SUM(Count)`
`FROM baby`
`GROUP BY Year, Sex`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Year | Sex | SUM(Count) | |
|---|---|---|---|
| 0 | 1880 | F | 83929 |
| 1 | 1880 | M | 110490 |
| 2 | 1881 | F | 85034 |
| ... | ... | ... | ... |
| 279 | 2019 | M | 1785527 |
| 280 | 2020 | F | 1581301 |
| 281 | 2020 | M | 1706423 |
282 rows × 3 columns
请注意,上述代码与仅按单列进行分组非常相似,唯一的区别在于它为 GROUP BY 提供了多列,以便按 Year 和 Sex 进行分组。
注意
与 pandas 不同,SQLite 没有提供简单的方法来对关系表进行透视。相反,我们可以在 SQL 中对两列使用 GROUP BY,将结果读取到数据框中,然后使用 unstack() 数据框方法。
其他聚合函数
SQLite 除了 SUM 外,还有几个内置的聚合函数,例如 COUNT、AVG、MIN 和 MAX。有关完整的函数列表,请参阅SQLite 网站。
要使用其他聚合函数,我们在 SELECT 子句中调用它。例如,我们可以使用 MAX 替代 SUM:
`query` `=` `'''`
`SELECT Year, MAX(Count)`
`FROM baby`
`GROUP BY Year`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Year | MAX(Count) | |
|---|---|---|
| 0 | 1880 | 9655 |
| 1 | 1881 | 8769 |
| 2 | 1882 | 9557 |
| ... | ... | ... |
| 138 | 2018 | 19924 |
| 139 | 2019 | 20555 |
| 140 | 2020 | 19659 |
141 rows × 2 columns
注意
内置的聚合函数是数据科学家可能在 SQL 实现中首次遇到差异的地方之一。例如,SQLite 拥有相对较少的聚合函数,而PostgreSQL 拥有更多。尽管如此,几乎所有 SQL 实现都提供 SUM、COUNT、MIN、MAX 和 AVG。
此部分涵盖了使用 SQL 中的 GROUP BY 关键字以一个或多个列对数据进行聚合的常见方法。在接下来的部分中,我们将解释如何将关系表进行连接。
连接
要连接两个数据表之间的记录,可以像数据框一样使用 SQL 关系进行连接。在本节中,我们介绍 SQL 连接以复制我们对婴儿姓名数据的分析。回想一下,第六章提到了一篇纽约时报文章,讨论了某些姓名类别(如神话和婴儿潮时期的姓名)随着时间的推移变得更受欢迎或不受欢迎的情况。
我们已将NYT文章中的姓名和类别放入名为nyt的小关系中。首先,代码建立了与数据库的连接,然后运行 SQL 查询以显示nyt关系:
`import` `sqlalchemy`
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite:///babynames.db``'``)`
`query` `=` `'''`
`SELECT *`
`FROM nyt;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| nyt_name | 类别 | |
|---|---|---|
| 0 | Lucifer | forbidden |
| 1 | Lilith | forbidden |
| 2 | Danger | forbidden |
| ... | ... | ... |
| 20 | Venus | celestial |
| 21 | Celestia | celestial |
| 22 | Skye | celestial |
23 rows × 2 columns
注
注意到前面的代码在babynames.db上运行查询,这个数据库包含前几节中较大的baby关系。SQL 数据库可以容纳多个关系,当我们需要同时处理多个数据表时非常有用。另一方面,CSV 文件通常只包含一个数据表——如果我们执行一个使用 20 个数据表的数据分析,可能需要跟踪 20 个 CSV 文件的名称、位置和版本。相反,将所有数据表存储在单个文件中的 SQLite 数据库中可能更简单。
要查看姓名类别的受欢迎程度,我们将nyt关系与baby关系连接,以从baby中获取姓名计数。
内连接
就像在第六章中一样,我们制作了baby和nyt表的较小版本,以便更容易地查看在表合并时发生的情况。这些关系被称为baby_small和nyt_small:
`query` `=` `'''`
`SELECT *`
`FROM baby_small;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 姓名 | 性别 | 数量 | 年份 | |
|---|---|---|---|---|
| 0 | Noah | M | 18252 | 2020 |
| 1 | Julius | M | 960 | 2020 |
| 2 | Karen | M | 6 | 2020 |
| 3 | Karen | F | 325 | 2020 |
| 4 | Noah | F | 305 | 2020 |
`query` `=` `'''`
`SELECT *`
`FROM nyt_small;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| nyt_name | 类别 | |
|---|---|---|
| 0 | Karen | boomer |
| 1 | Julius | mythology |
| 2 | Freya | mythology |
要在 SQL 中连接关系,我们使用INNER JOIN子句来指定要连接的表,并使用ON子句来指定表连接的条件。这里是一个示例:
`query` `=` `'''`
`SELECT *`
`FROM baby_small INNER JOIN nyt_small`
`ON baby_small.Name = nyt_small.nyt_name`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 姓名 | 性别 | 数量 | 年份 | nyt_name | 类别 | |
|---|---|---|---|---|---|---|
| 0 | Julius | M | 960 | 2020 | Julius | mythology |
| 1 | Karen | M | 6 | 2020 | Karen | boomer |
| 2 | Karen | F | 325 | 2020 | Karen | boomer |
注意到这个结果与在pandas中进行内连接的结果相同:新表具有baby_small和nyt_small表的列。Noah 的行已消失,并且剩余的行具有它们在nyt_small中的匹配category。
要将两个表连接在一起,我们告诉 SQL 我们想要使用的每个表的列,并使用带有 ON 关键字的谓词进行连接。当连接列中的值满足谓词时,SQL 将行进行匹配,如 Figure 7-2 所示。
与 pandas 不同,SQL 在行连接方面提供了更大的灵活性。pd.merge() 方法只能使用简单的相等条件进行连接,但是 ON 子句中的谓词可以是任意复杂的。例如,在 “Finding Collocated Sensors” 中,我们利用了这种额外的多样性。

图 7-2. 使用 SQL 将两个表连接在一起
左连接和右连接
类似于 pandas,SQL 也支持左连接。我们使用 LEFT JOIN 而不是 INNER JOIN:
`query` `=` `'''`
`SELECT *`
`FROM baby_small LEFT JOIN nyt_small`
`ON baby_small.Name = nyt_small.nyt_name`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Noah | M | 18252 | 2020 | None | None |
| 1 | Julius | M | 960 | 2020 | Julius | mythology |
| 2 | Karen | M | 6 | 2020 | Karen | 潮妈 |
| 3 | Karen | F | 325 | 2020 | Karen | 潮妈 |
| 4 | Noah | F | 305 | 2020 | None | None |
如我们所料,连接的“左”侧指的是出现在 LEFT JOIN 关键字左侧的表。我们可以看到即使 Noah 行在右侧关系中没有匹配时,它们仍然会保留在结果关系中。
请注意,SQLite 不直接支持右连接,但是我们可以通过交换关系的顺序,然后使用 LEFT JOIN 来执行相同的连接:
`query` `=` `'''`
`SELECT *`
`FROM nyt_small LEFT JOIN baby_small`
`ON baby_small.Name = nyt_small.nyt_name`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| nyt_name | category | Name | Sex | Count | Year | |
|---|---|---|---|---|---|---|
| 0 | Karen | 潮妈 | Karen | F | 325.0 | 2020.0 |
| 1 | Karen | 潮妈 | Karen | M | 6.0 | 2020.0 |
| 2 | Julius | mythology | Julius | M | 960.0 | 2020.0 |
| 3 | Freya | mythology | None | None | NaN | NaN |
SQLite 没有内置的外连接关键字。在需要外连接的情况下,我们可以使用不同的 SQL 引擎或通过 pandas 执行外连接。然而,在我们(作者)的经验中,与内连接和左连接相比,外连接在实践中很少使用。
示例:NYT 姓名类别的流行度
现在让我们返回到完整的 baby 和 nyt 关系。
我们想知道 nyt 中姓名类别的流行程度随时间的变化。要回答这个问题,我们应该:
-
使用
ON关键字中指定的列内连接baby和nyt,匹配姓名相等的行。 -
按
category和Year对表进行分组。 -
使用求和对计数进行聚合:
`query` `=` `'''`
`SELECT`
`category,`
`Year,`
`SUM(Count) AS count -- [3]`
`FROM baby INNER JOIN nyt -- [1]`
`ON baby.Name = nyt.nyt_name -- [1]`
`GROUP BY category, Year -- [2]`
`'''`
`cate_counts` `=` `pd``.``read_sql``(``query``,` `db``)`
`cate_counts`
| category | Year | count | |
|---|---|---|---|
| 0 | 潮妈 | 1880 | 292 |
| 1 | 潮妈 | 1881 | 298 |
| 2 | 潮妈 | 1882 | 326 |
| ... | ... | ... | ... |
| 647 | mythology | 2018 | 2944 |
| 648 | mythology | 2019 | 3320 |
| 649 | mythology | 2020 | 3489 |
650 rows × 3 columns
在上述查询中方括号中的数字([1]、[2]、[3])显示了我们计划中的每个步骤如何映射到 SQL 查询的部分。代码重新创建了来自 第六章 的数据框,我们在其中创建了图表以验证 纽约时报 文章的主张。为简洁起见,我们在此省略了重复绘制图表的部分。
注意
请注意,在此示例中的 SQL 代码中,数字的顺序看起来是不正确的——[3]、[1],然后是[2]。对于首次学习 SQL 的人来说,我们通常可以将 SELECT 语句看作查询的最后执行的部分,即使它首先出现。
在本节中,我们介绍了用于关系的连接。当将关系连接在一起时,我们使用 INNER JOIN 或 LEFT JOIN 关键字以及布尔谓词来匹配行。在下一节中,我们将解释如何转换关系中的值。
转换和公共表达式(CTE)
在本节中,我们展示了如何调用内置 SQL 函数来转换数据列。我们还演示了如何使用公共表达式(CTE)从简单查询构建复杂查询。与往常一样,我们首先加载数据库:
`# Set up connection to database`
`import` `sqlalchemy`
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite:///babynames.db``'``)`
SQL 函数
SQLite 提供了多种标量函数,即用于转换单个数据值的函数。当在数据列上调用时,SQLite 将对列中的每个值应用这些函数。相比之下,像 SUM 和 COUNT 这样的聚合函数以数据列作为输入,并计算单个值作为输出。
SQLite 在其在线文档中提供了内置标量函数的详尽列表。例如,要找出每个名称中的字符数,我们使用 LENGTH 函数:
`query` `=` `'''`
`SELECT Name, LENGTH(Name)`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | LENGTH(Name) | |
|---|---|---|
| 0 | Liam | 4 |
| 1 | Noah | 4 |
| 2 | Oliver | 6 |
| ... | ... | ... |
| 7 | Lucas | 5 |
| 8 | Henry | 5 |
| 9 | Alexander | 9 |
10 rows × 2 columns
请注意,LENGTH 函数应用于 Name 列中的每个值。
注意
像聚合函数一样,每个 SQL 实现都提供了不同的标量函数集。SQLite 提供的函数集相对较少,而 PostgreSQL 则更多。尽管如此,几乎所有 SQL 实现都提供了与 SQLite 的 LENGTH、ROUND、SUBSTR 和 LIKE 函数相当的功能。
虽然标量函数与聚合函数使用相同的语法,但它们的行为不同。如果在单个查询中混合使用这两种函数,可能会导致输出结果混乱:
`query` `=` `'''`
`SELECT Name, LENGTH(Name), AVG(Count)`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | LENGTH(Name) | AVG(Count) | |
|---|---|---|---|
| 0 | Liam | 4 | 174.47 |
在这里,AVG(Name) 计算了整个 Count 列的平均值,但输出结果令人困惑——读者很容易会认为平均值与名字 Liam 有关。因此,当标量函数和聚合函数同时出现在 SELECT 语句中时,我们必须格外小心。
要提取每个名称的首字母,我们可以使用 SUBSTR 函数(缩写为substring)。如文档中所述,SUBSTR 函数接受三个参数。第一个是输入字符串,第二个是开始子字符串的位置(从 1 开始索引),第三个是子字符串的长度:
`query` `=` `'''`
`SELECT Name, SUBSTR(Name, 1, 1)`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | SUBSTR(名称, 1, 1) | |
|---|---|---|
| 0 | Liam | L |
| 1 | Noah | N |
| 2 | Oliver | O |
| ... | ... | ... |
| 7 | Lucas | L |
| 8 | Henry | H |
| 9 | Alexander | A |
10 rows × 2 columns
我们可以使用 AS 关键字重命名列:
`query` `=` `'''`
`SELECT *, SUBSTR(Name, 1, 1) AS Firsts`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 数量 | 年份 | 首字母 | |
|---|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 | L |
| 1 | Noah | M | 18252 | 2020 | N |
| 2 | Oliver | M | 14147 | 2020 | O |
| ... | ... | ... | ... | ... | ... |
| 7 | Lucas | M | 11281 | 2020 | L |
| 8 | Henry | M | 10705 | 2020 | H |
| 9 | Alexander | M | 10151 | 2020 | A |
10 rows × 5 columns
在计算每个名称的首字母后,我们的分析旨在了解不同时间段内首字母的流行度。为此,我们希望将这个 SQL 查询的输出用作更长操作链中的单个步骤。
SQL 提供了几种选项来将查询分解为较小的步骤,这在像这样更复杂的分析中非常有帮助。最常见的选项是使用 CREATE TABLE 语句创建新关系,使用 CREATE VIEW 创建新视图,或者使用 WITH 创建临时关系。每种方法都有不同的用例。为简单起见,我们在本节仅描述 WITH 语句,并建议读者查阅 SQLite 文档以获取详细信息。
使用 WITH 子句进行多步查询
WITH 子句允许我们为任何 SELECT 查询指定一个名称。然后我们可以把该查询视为数据库中的一个关系,仅在查询的持续时间内存在。SQLite 将这些临时关系称为公共表达式。例如,我们可以取之前计算每个名称的首字母的查询,并称其为 letters:
`query` `=` `'''`
`-- Create a temporary relation called letters by calculating`
`-- the first letter for each name in baby`
`WITH letters AS (`
`SELECT *, SUBSTR(Name, 1, 1) AS Firsts`
`FROM baby`
`)`
`-- Then, select the first ten rows from letters`
`SELECT *`
`FROM letters`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 数量 | 年份 | 首字母 | |
|---|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 | L |
| 1 | Noah | M | 18252 | 2020 | N |
| 2 | Oliver | M | 14147 | 2020 | O |
| ... | ... | ... | ... | ... | ... |
| 7 | Lucas | M | 11281 | 2020 | L |
| 8 | Henry | M | 10705 | 2020 | H |
| 9 | Alexander | M | 10151 | 2020 | A |
10 rows × 5 columns
WITH 语句非常有用,因为它们可以链接在一起。我们可以在 WITH 语句中创建多个临时关系,每个关系对前一个结果执行一些工作,这样可以逐步构建复杂的查询。
例如:“L” 名字的流行度
我们可以使用WITH语句来查看以字母 L 开头的名字随时间的流行度。我们将临时letters关系按首字母和年份分组,然后使用求和聚合Count列,然后筛选只获取字母 L 开头的名字:
`query` `=` `'''`
`WITH letters AS (`
`SELECT *, SUBSTR(Name, 1, 1) AS Firsts`
`FROM baby`
`)`
`SELECT Firsts, Year, SUM(Count) AS Count`
`FROM letters`
`WHERE Firsts =` `"``L``"`
`GROUP BY Firsts, Year;`
`'''`
`letter_counts` `=` `pd``.``read_sql``(``query``,` `db``)`
`letter_counts`
| 首字母 | 年份 | 数量 | |
|---|---|---|---|
| 0 | L | 1880 | 12799 |
| 1 | L | 1881 | 12770 |
| 2 | L | 1882 | 14923 |
| ... | ... | ... | ... |
| 138 | L | 2018 | 246251 |
| 139 | L | 2019 | 249315 |
| 140 | L | 2020 | 239760 |
141 rows × 3 columns
这个关系包含与第六章相同的数据。在那一章中,我们制作了Count列随时间变化的图表,这里为了简洁起见省略了。
在这一节中,我们介绍了数据转换。为了转换关系中的值,我们通常使用 SQL 函数如LENGTH()或SUBSTR()。我们还解释了如何使用WITH子句构建复杂查询。
概要
在本章中,我们解释了什么是关系,它们为何有用,以及如何使用 SQL 代码处理它们。SQL 数据库在许多现实世界的场景中非常有用。例如,SQL 数据库通常具有强大的数据恢复机制——如果计算机在 SQL 操作中崩溃,数据库系统可以尽可能恢复数据而不会损坏。如前所述,SQL 数据库还能处理更大规模的数据;组织使用 SQL 数据库来存储和查询那些用pandas代码无法在内存中分析的大型数据库。这些只是 SQL 成为数据科学工具箱中重要一环的几个原因,我们预计许多读者很快会在工作中遇到 SQL 代码。
第三部分:理解数据
第八章:整理文件
在使用 Python 处理数据之前,了解存储数据源的文件是很有帮助的。您想要了解一些基本问题的答案:
-
您有多少数据?
-
源文件的格式是怎样的?
这些问题的答案可能非常有帮助。例如,如果您的文件太大或格式不符合您的期望,您可能无法正确加载它到数据框中。
虽然许多类型的结构都可以表示数据,在本书中,我们主要使用数据表,如 Pandas DataFrames 和 SQL 关系。(但请注意,第十三章研究了结构较少的文本数据,第十四章介绍了分层格式和二进制文件。)我们之所以专注于数据表,有几个原因。研究如何存储和操作数据表已经产生了稳定高效的工具来处理表格。此外,表格格式的数据与矩阵密切相关,矩阵是线性代数领域非常丰富的数学对象。当然,数据表非常常见。
在本章中,我们介绍了纯文本的典型文件格式和编码,描述了文件大小的度量,并使用 Python 工具检查源文件。在本章的后面部分,我们介绍了一种用于处理文件的替代方法:shell 解释器。Shell 命令为我们提供了一种在 Python 环境之外获取文件信息的程序化方式,而且对于大数据,shell 可能非常有用。最后,我们检查数据表的形状(行数和列数)和粒度(行代表什么)。这些简单的检查是清理和分析数据的起点。
我们首先简要描述了我们在本章中始终使用的示例数据集。
数据源示例
我们选择了两个示例来演示文件整理概念:一个关于药物滥用的政府调查,以及旧金山公共卫生部门有关餐馆检查的行政数据。在我们开始整理之前,我们先概述一下这些示例的数据范围(见第二章)。
药物滥用警戒网络(DAWN)调查
DAWN 是一个全国性的医疗保健调查,旨在监测药物滥用趋势。该调查旨在估计药物滥用对国家医疗保健系统的影响,并改善急诊科监测物质滥用危机的方式。DAWN 由药物滥用和精神卫生服务管理局(SAMHSA)于 1998 年至 2011 年每年进行一次。2018 年,由于阿片类药物流行,DAWN 调查得以重新启动。在这个例子中,我们查看了 2011 年的数据,这些数据已通过SAMHSA 数据存档提供。
目标人群包括美国所有药物相关急诊室就诊者。这些访问通过医院急诊室(及其记录)的框架进行访问。医院通过概率抽样进行调查选择(参见第三章),并且样本医院急诊室的所有药物相关访问都包括在调查中。所有类型的药物相关访问都包括在内,例如药物滥用、滥用、意外吞食、自杀企图、恶意中毒和不良反应。对于每次访问,记录可能包含最多 16 种不同的药物,包括非法药物、处方药物和非处方药物。
此数据集的源文件是一个需要外部文档(如代码书)来解释的固定宽度格式的示例。此外,由于它是一个相当大的文件,所以激发了如何找到文件大小的话题。而且,其粒度不同寻常,因为调查的主题是急诊访问,而不是个人。
旧金山餐厅文件具有其他特征,使它们成为本章的良好示例。
旧金山餐厅食品安全
旧金山公共卫生部门定期对餐厅进行未经预先通知的访问,并检查其食品安全情况。检查员根据发现的违规行为计算评分,并提供违规行为的描述。这里的目标人群是旧金山所有餐厅。这些餐厅是通过 2013 年至 2016 年进行的餐厅检查框架来访问的。一些餐厅一年内进行多次检查,而不是所有 7000 多家餐厅每年都接受检查。
食品安全评分可通过城市的开放数据计划获得,称为DataSF。 DataSF 是城市政府公开其数据的一个例子;DataSF 的使命是“在决策和服务交付中使用数据”,旨在改善居民、雇主、员工和访客的生活和工作质量。
旧金山要求餐厅公开展示其评分(参见图 8-1 作为示例标牌)。^(1) 这些数据提供了不同结构、字段和粒度的多个文件的示例。一个数据集包含检查结果的摘要,另一个提供有关发现的违规行为的详细信息,第三个包含有关餐厅的一般信息。违规行为包括与食源性疾病传播有关的严重问题以及像未正确展示检查标牌这样的小问题。

图 8-1. 展示在餐厅中的食品安全评分卡;分数范围在 0 到 100 之间。
DAWN 调查数据和旧金山餐厅检查数据都可以作为纯文本文件在线获取。然而,它们的格式有很大不同,在下一节中,我们将演示如何确定文件格式,以便将数据读入数据框架中。
文件格式
文件格式描述了数据如何存储在计算机的磁盘或其他存储设备上。了解文件格式帮助我们弄清楚如何将数据读入 Python,以便将其作为数据表进行处理。在本节中,我们介绍了几种用于存储数据表的流行格式。这些都是纯文本格式,意味着我们可以使用 VS Code、Sublime、Vim 或 Emacs 等文本编辑器轻松阅读它们。
注意
文件格式和数据的结构是两个不同的事物。我们认为数据结构是数据的一种心理表示,告诉我们可以进行哪些操作。例如,表结构对应于按行和列排列的数据值。但是同一个表可以存储在许多不同类型的文件格式中。
我们描述的第一种格式是分隔文件格式。
分隔格式
分隔格式使用特定字符来分隔数据值。通常,这些分隔符可以是逗号(逗号分隔值,或简称 CSV),制表符(制表符分隔值,或 TSV),空格或冒号。这些格式适合存储具有表结构的数据。文件中的每一行表示一个记录,由换行符(\n或\r\n)分隔。而在一行内,记录的信息则由逗号字符(,)用于 CSV 或制表符字符(\t)用于 TSV 等分隔。这些文件的第一行通常包含表的列名/特征的名称。
旧金山餐厅评分存储在 CSV 格式的文件中。让我们显示inspections.csv文件的前几行。在 Python 中,内置的pathlib库具有一个有用的Path对象,用于指定跨平台的文件和文件夹路径。该文件位于data文件夹中,因此我们使用Path()来创建完整的文件路径名:
`from` `pathlib` `import` `Path`
`# Create a Path pointing to our datafile`
`insp_path` `=` `Path``(``)` `/` `'``data``'` `/` `'``inspections.csv``'`
注意
在处理不同操作系统(OSs)时,路径是棘手的。例如,Windows 中的典型路径可能看起来像C:\files\data.csv,而 Unix 或 macOS 中的路径可能看起来像~/files/data.csv。因此,适用于一个操作系统的代码可能无法在其他操作系统上运行。
pathlib Python 库的创建是为了避免特定于操作系统的路径问题。通过使用它,这里显示的代码更具可移植性 —— 它可以在 Windows、macOS 和 Unix 上运行。
以下代码中的Path对象具有许多有用的方法,例如read_text(),它将整个文件内容作为字符串读取:
`text` `=` `insp_path``.``read_text``(``)`
`# Print first five lines`
`print``(``'``\n``'``.``join``(``text``.``split``(``'``\n``'``)``[``:``5``]``)``)`
"business_id","score","date","type"
19,"94","20160513","routine"
19,"94","20171211","routine"
24,"98","20171101","routine"
24,"98","20161005","routine"
请注意,字段名出现在文件的第一行;这些名称用逗号分隔并带引号。我们看到四个字段:业务标识符、餐厅得分、检查日期和检查类型。文件中的每一行对应一次检查,ID、分数、日期和类型的值用逗号分隔。除了识别文件格式外,我们还希望识别特征的格式。我们注意到两点:分数和日期都显示为字符串。我们希望将分数转换为数字,以便可以计算摘要统计信息并创建可视化图。我们将日期转换为日期时间格式,以便可以制作时间序列图。我们展示如何在第九章中执行这些转换。
显示文件的前几行是我们经常做的事情,因此我们创建一个函数作为快捷方式:
`def` `head``(``filepath``,` `n``=``5``,` `width``=``-``1``)``:`
`'''Prints the width characters of first n lines of filepath'''`
`with` `filepath``.``open``(``)` `as` `f``:`
`for` `_` `in` `range``(``n``)``:`
`(``print``(``f``.``readline``(``)``,` `end``=``'``'``)` `if` `width` `<` `0`
`else` `print``(``f``.``readline``(``)``[``:``width``]``)``)`
注意
人们经常将 CSV 和 TSV 文件与电子表格混淆。部分原因是大多数电子表格软件(如 Microsoft Excel)会自动将 CSV 文件显示为工作簿中的表格。在幕后,Excel 会像我们在本节中所做的那样查看文件格式和编码。然而,Excel 文件与 CSV 和 TSV 文件具有不同的格式,我们需要使用不同的pandas函数将这些格式读入 Python。
所有三个餐厅源文件都是 CSV 格式的。相比之下,DAWN 源文件采用固定宽度格式。我们接下来描述这种格式化方式。
固定宽度格式
固定宽度格式(FWF)不使用定界符来分隔数据值。相反,每行中特定字段的值出现在完全相同的位置。DAWN 源文件采用这种格式。文件中的每一行都非常长。为了显示目的,我们只展示文件中前五行的前几个字符:
`dawn_path` `=` `Path``(``)` `/` `'``data``'` `/` `'``DAWN-Data.txt``'`
`head``(``dawn_path``,` `width``=``65``)`
1 2251082 .9426354082 3 4 1 2201141 2 865 105 1102005 1
2 2291292 5.9920106887 911 1 3201134 12077 81 82 283-8
3 7 7 251 4.7231718669 611 2 2201143 12313 1 12 -7-8
410 8 292 4.0801470012 6 2 1 3201122 1 234 358 99 215 2
5 122 942 5.1777093467 10 6 1 3201134 3 865 105 1102005 1
请注意,值如何从一行到下一行对齐。例如,每行的第 19 个字符处都有一个小数点。还要注意,一些值似乎被挤在一起,我们需要知道每行中每个信息片段的确切位置才能理解它。SAMHSA 提供了一个有 2000 页的代码手册,其中包含所有这些信息,包括一些基本检查,以便我们可以确认我们已正确读取文件。例如,代码手册告诉我们年龄字段出现在 34-35 位置,并以 1 到 11 的间隔编码。前面代码中显示的前两条记录的年龄类别分别为 4 和 11;代码手册告诉我们,4 代表年龄段“6 到 11 岁”,而 11 代表“65 岁及以上”。
其他流行的纯文本格式包括分层格式和松散格式化文本(与直接支持表结构的格式形成对比)。这些在其他章节中有更详细的介绍,但为了完整起见,我们在这里简要描述它们。
注意
一种广泛采用的约定是使用文件名扩展名来指示文件内容的格式,例如 .csv、.tsv 和 .txt。文件名以 .csv 结尾通常包含逗号分隔值,以 .tsv 结尾的文件通常包含制表符分隔值;.txt 通常表示没有指定格式的纯文本。但是,这些扩展名只是建议。即使文件的扩展名为 .csv,实际内容可能格式不正确!在加载到数据框之前检查文件内容是一个好习惯。如果文件不太大,可以使用纯文本编辑器打开和查看。否则,可以使用 .readline() 或 shell 命令查看几行。
分层格式
分层格式以嵌套形式存储数据。例如,JavaScript 对象表示法(JSON)通常用于 Web 服务器的通信,包括可以嵌套的键值对和数组,类似于 Python 字典。XML 和 HTML 是其他常见的用于在互联网上存储文档的格式。与 JSON 类似,这些文件具有分层的键值格式。我们在第十四章中更详细地介绍了这两种格式(JSON 和 XML)。
接下来,我们简要描述了其他不属于先前任何类别但仍具有一定结构以便于读取和提取信息的纯文本文件。
松散格式文本
网络日志、仪器读数和程序日志通常以纯文本形式提供数据。例如,这是网络日志的一行(我们已经将其分成多行以便阅读)。它包含日期、时间和对网站发出的请求类型等信息:
169.237.46.168 - -
[26/Jan/2004:10:47:58 -0800]"GET /stat141/Winter04 HTTP/1.1" 301 328
"http://anson.ucdavis.edu/courses"
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.1.4322)"
存在组织模式,但不是简单的分隔格式。这就是我们所说的“松散格式”。我们看到日期和时间出现在方括号之间,并且请求类型(本例中为 GET)跟随日期时间信息,并以引号形式出现。在第十三章中,我们利用这些关于网络日志格式和字符串操作工具的观察,将感兴趣的值提取到数据表中。
作为另一个例子,这是从无线设备日志中获取的单条记录。设备报告时间戳、标识符、位置以及从其他设备接收到的信号强度。此信息使用了多种格式:键值对、分号分隔值和逗号分隔值:
t=1139644637174;id=00:02:2D:21:0F:33;pos=2.0,0.0,0.0;degree=45.5;
00:14:bf:b1:97:8a=-33,2437000000,3;00:14:bf:b1:97:8a=-38,2437000000,3;
就像网络日志一样,我们可以利用字符串操作和记录中的模式将特征提取到表中。
我们主要介绍了用于存储和交换表格的纯文本数据格式。CSV 格式是最常见的,但其他格式,如制表符分隔和固定宽度格式,也很普遍。还有许多种存储数据的文件格式!
到目前为止,我们使用术语 plain text 来广泛覆盖可以在文本编辑器中查看的格式。然而,纯文本文件可能有不同的编码,如果我们没有正确指定编码,数据框中的值可能会包含无意义的内容。接下来我们概述文件编码。
文件编码
计算机将数据存储为 比特 的序列:0 和 1。像 ASCII 这样的 字符编码 告诉计算机如何在比特和文本之间进行转换。例如,在 ASCII 中,比特 100 001 表示字母 A,比特 100 010 表示 B。最基本的纯文本仅支持标准 ASCII 字符,包括大写和小写英文字母、数字、标点符号和空格。
ASCII 编码不包括许多特殊字符或其他语言的字符。其他更现代的字符编码有更多可以表示的字符。文档和网页的常见编码是 Latin-1(ISO-8859-1)和 UTF-8。UTF-8 具有超过一百万个字符,并且向后兼容 ASCII,这意味着它与英文字母、数字和标点的表示方式与 ASCII 相同。
当我们有一个文本文件时,通常需要弄清楚它的编码。如果我们选择错误的编码来读取文件,Python 要么读取错误的值,要么抛出错误。找到编码的最佳方法是检查数据的文档,通常文档会明确指出编码是什么。
当我们不知道编码时,必须猜测。chardet 包有一个名为 detect() 的函数,可以推断文件的编码。由于这些猜测并不完美,该函数还返回一个介于 0 和 1 之间的置信度。我们使用这个函数来查看我们示例中的文件:
`import` `chardet`
`line` `=` `'``{:<25}` `{:<10}` `{}``'``.``format`
`# for each file, print its name, encoding & confidence in the encoding`
`print``(``line``(``'``File Name``'``,` `'``Encoding``'``,` `'``Confidence``'``)``)`
`for` `filepath` `in` `Path``(``'``data``'``)``.``glob``(``'``*``'``)``:`
`result` `=` `chardet``.``detect``(``filepath``.``read_bytes``(``)``)`
`print``(``line``(``str``(``filepath``)``,` `result``[``'``encoding``'``]``,` `result``[``'``confidence``'``]``)``)`
File Name Encoding Confidence
data/inspections.csv ascii 1.0
data/co2_mm_mlo.txt ascii 1.0
data/violations.csv ascii 1.0
data/DAWN-Data.txt ascii 1.0
data/legend.csv ascii 1.0
data/businesses.csv ISO-8859-1 0.73
检测函数非常确信除了一个文件外,所有文件都是 ASCII 编码的。例外是 businesses.csv,它似乎是 ISO-8859-1 编码的。如果我们忽略这种编码并尝试在不指定特殊编码的情况下将业务文件读入 pandas 中,我们将遇到麻烦:
`# naively reads file without considering encoding`
`>>``>` `pd``.``read_csv``(``'``data/businesses.csv``'``)`
`[``.``.``.``stack` `trace` `omitted``.``.``.``]`
`UnicodeDecodeError``:` `'``utf-8``'` `codec` `can``'``t decode byte 0xd1 in`
`position` `8``:` `invalid` `continuation` `byte`
要成功读取数据,我们必须指定 ISO-8859-1 编码:
`bus` `=` `pd``.``read_csv``(``'``data/businesses.csv``'``,` `encoding``=``'``ISO-8859-1``'``)`
| business_id | name | address | postal_code | |
|---|---|---|---|---|
| 0 | 19 | NRGIZE LIFESTYLE CAFE | 1200 VAN NESS AVE, 3RD FLOOR | 94109 |
| 1 | 24 | OMNI S.F. HOTEL - 2ND FLOOR PANTRY | 500 CALIFORNIA ST, 2ND FLOOR | 94104 |
| 2 | 31 | NORMAN’S ICE CREAM AND FREEZES | 2801 LEAVENWORTH ST | 94133 |
| 3 | 45 | CHARLIE’S DELI CAFE | 3202 FOLSOM ST | 94110 |
文件编码可能有点神秘,除非有明确给出编码的元数据,否则就要猜测。当编码没有完全确认时,最好寻找额外的文档。
另一个可能重要的源文件方面是其大小。如果文件很大,那么我们可能无法将其读入数据框架。在下一节中,我们将讨论如何确定源文件的大小。
文件大小
计算机资源是有限的。如果您的计算机因打开太多应用程序而变慢,您可能已经亲身经历了这些限制。我们希望确保在处理数据时不超出计算机的限制,并且可能会根据数据集的大小选择不同的文件查看方法。如果我们知道我们的数据集相对较小,那么使用文本编辑器或电子表格软件查看数据会很方便。另一方面,对于大型数据集,可能需要更多的程序化探索甚至分布式计算工具。
在许多情况下,我们分析从互联网下载的数据集。这些文件存储在计算机的磁盘存储上。为了使用 Python 探索和操作数据,我们需要将数据读入计算机的内存,也称为随机访问存储器(RAM)。无论代码有多短,所有 Python 代码都需要使用 RAM。计算机的 RAM 通常比磁盘存储小得多。例如,2018 年发布的某一款计算机型号的磁盘存储比 RAM 多 32 倍。不幸的是,这意味着数据文件通常比可读入内存的数据量要大得多。
磁盘存储和 RAM 容量都是以字节(八个 0 和 1)为单位测量的。粗略地说,文本文件中的每个字符增加一个字节的文件大小。为了简洁描述较大文件的大小,我们使用表 8-1 中描述的前缀;例如,包含 52,428,800 个字符的文件将占用 ,即 50 MiB 的磁盘空间。
表 8-1. 常见文件大小的前缀
| Multiple | Notation | Number of bytes |
|---|---|---|
| Kibibyte | KiB | 1,024 |
| Mebibyte | MiB | 1,024² |
| Gibibyte | GiB | 1,024³ |
| Tebibyte | TiB | 1,024⁴ |
| Pebibyte | PiB | 1,024⁵ |
注意
为什么使用 1,024 的倍数而不是简单的 1,000 倍数来表示这些前缀?这是历史的结果,因为大多数计算机使用二进制数方案,其中 2 的幂更简单表示()。您还会看到用于描述大小的典型 SI 前缀—例如,千字节、兆字节和千兆字节。不幸的是,这些前缀的使用不一致。有时,千字节指的是 1,000 字节;其他时候,千字节指的是 1,024 字节。为了避免混淆,我们坚持使用 kibi-、mebi-和 gibibytes 这些清楚表示 1,024 的倍数的前缀。
如果我们尝试用程序操作一个超出计算机内存容量的数据文件,那么在计算机上快乐存储的数据文件通常会溢出。因此,我们通常会通过使用内置的os库来确保文件的大小可管理:
`from` `pathlib` `import` `Path`
`import` `os`
`kib` `=` `1024`
`line` `=` `'``{:<25}` `{}``'``.``format`
`print``(``line``(``'``File``'``,` `'``Size (KiB)``'``)``)`
`for` `filepath` `in` `Path``(``'``data``'``)``.``glob``(``'``*``'``)``:`
`size` `=` `os``.``path``.``getsize``(``filepath``)`
`print``(``line``(``str``(``filepath``)``,` `np``.``round``(``size` `/` `kib``)``)``)`
File Size (KiB)
data/inspections.csv 455.0
data/co2_mm_mlo.txt 50.0
data/violations.csv 3639.0
data/DAWN-Data.txt 273531.0
data/legend.csv 0.0
data/businesses.csv 645.0
我们看到businesses.csv文件在磁盘上占据了 645 KiB,远低于大多数系统的内存容量。虽然violations.csv文件占据了 3.6 MiB 的磁盘存储空间,但大多数机器也可以轻松将其读入pandas的DataFrame中。但包含 DAWN 调查数据的DAWN-Data.txt文件则要大得多。
DAWN 文件占用大约 270 MiB 的磁盘存储空间,尽管一些计算机可以在内存中处理此文件,但可能会减慢其他系统的速度。为了在 Python 中使此数据更易管理,我们可以选择仅加载部分列而不是全部列。
有时候我们对文件夹的总大小感兴趣,而不是单个文件的大小。例如,我们有三个餐厅文件,我们可能想看看是否可以将所有数据合并到一个单一的数据框架中。在以下代码中,我们计算data文件夹的大小,包括其中所有的文件:
`mib` `=` `1024``*``*``2`
`total` `=` `0`
`for` `filepath` `in` `Path``(``'``data``'``)``.``glob``(``'``*``'``)``:`
`total` `+``=` `os``.``path``.``getsize``(``filepath``)` `/` `mib`
`print``(``f``'``The data/ folder contains` `{``total``:``.2f``}` `MiB``'``)`
The data/ folder contains 271.80 MiB
注意
通常情况下,使用pandas读取文件需要至少五倍于文件大小的可用内存。例如,读取 1 GiB 文件通常需要至少 5 GiB 的可用内存。内存由计算机上运行的所有程序共享,包括操作系统、Web 浏览器和 Jupyter 笔记本本身。具有 4 GiB 总内存的计算机可能只有 1 GiB 可用内存。在只有 1 GiB 可用内存的情况下,pandas可能无法读取 1 GiB 文件。
有几种处理远远大于可加载到内存的数据的策略。接下来我们将介绍其中的一些。
流行的术语大数据通常指的是数据足够大,以至于即使顶级计算机也无法直接读取这些数据到内存中。这在科学领域如天文学中很常见,例如望远镜捕捉的空间图像可以达到 PB( )级大小。虽然不及如此之大,社交媒体巨头、医疗保健提供者和其他公司也可能面临大量数据的挑战。
从这些数据集中提取见解是数据库工程和分布式计算领域的一个重要研究问题的核心动机。尽管本书不涵盖这些领域,我们提供了基本方法的简要概述:
对数据进行子集处理。
一种简单的方法是处理数据的部分。与加载整个源文件不同,我们可以选择其中的特定部分(例如一天的数据)或随机抽样数据集。由于其简单性,我们在本书中经常使用这种方法。其自然缺点是我们失去了分析大数据集时的许多优势,例如能够研究罕见事件。
使用数据库系统。
如在第七章中讨论的那样,关系数据库管理系统(RDBMSs)专门设计用于存储大型数据集。SQLite 是一个有用的系统,用于处理太大以至于无法完全放入内存但足够小以适合单台机器磁盘的数据集。对于太大以至于无法放入单台机器的数据集,可以使用更可扩展的数据库系统,如 MySQL 和 PostgreSQL。这些系统可以通过 SQL 查询操作无法完全放入内存的数据。由于其优势,RDBMSs 常用于研究和工业设置中的数据存储。其一个缺点是通常需要一个单独的服务器来存储数据,并需要其自己的配置。另一个缺点是 SQL 在计算能力上不如 Python 灵活,尤其在建模方面尤为明显。一种有用的混合方法是使用 SQL 来对数据进行子集化、聚合或抽样,将数据批处理成足够小的批次以便读入 Python。然后我们可以使用 Python 进行更复杂的分析。
使用分布式计算系统。
处理大数据集上复杂计算的另一种方法是使用 MapReduce、Spark 或 Ray 等分布式计算系统。这些系统在能够分解为许多较小部分的任务上效果最好,在这些任务中,它们将数据集分成较小的部分并同时在所有较小数据集上运行程序。这些系统具有很大的灵活性,并可在各种场景中使用。它们的主要缺点是通常需要大量工作来正确安装和配置,因为它们通常安装在需要彼此协调的许多计算机上。
使用 Python 确定文件格式、编码和大小可能很方便。另一个处理文件的强大工具是 shell;shell 广泛使用,其语法比 Python 更为简洁。在接下来的部分中,我们将介绍 shell 中可用的几个命令行工具,以执行在读取到数据帧之前查找文件信息的相同任务。
Shell 和命令行工具
几乎所有计算机都提供对shell 解释器的访问,如sh、bash或zsh。这些解释器通常使用它们自己的语言、语法和内置命令在计算机上执行文件操作。
我们使用术语命令行界面(CLI)工具来指代 shell 解释器中可用的命令。虽然我们在这里只涵盖了一些 CLI 工具,但还有许多有用的 CLI 工具可以对文件执行各种操作。例如,在 bash shell 中,以下命令将列出本章 figures/ 文件夹中的所有文件以及它们的文件大小:
$ ls -l -h figures/
注意
美元符号是 shell 提示符,显示用户在哪里输入。它不是命令本身的一部分。
shell 命令的基本语法是:
command -options arg1 arg2
CLI 工具通常需要一个或多个参数,类似于 Python 函数需要参数。在 shell 中,我们使用空格包裹参数,而不是使用括号或逗号。参数出现在命令行的末尾,它们通常是文件的名称或一些文本。在 ls 示例中,ls 的参数是 figures/。此外,CLI 工具支持标志,提供附加选项。这些标志紧跟在命令名称后面,使用破折号作为分隔符。在 ls 示例中,我们提供了 -l(提供有关每个文件的额外信息)和 -h(以更易读的格式提供文件大小)标志。许多命令具有默认参数和选项,man 工具会打印出任何命令的可接受选项、示例和默认值列表。例如,man ls描述了ls可用的约 30 个标志。
注意
我们在本书中涵盖的所有 CLI 工具都是针对 sh shell 解释器的,这是当前 macOS 和 Linux 系统上 Jupyter 安装的默认解释器。Windows 系统有一个不同的解释器,书中显示的命令可能无法在 Windows 上运行,尽管 Windows 可通过其 Linux 子系统访问 sh 解释器。
本节中的命令可以在终端应用程序中运行,也可以通过 Jupyter 打开的终端运行。
我们从探索包含本章内容的文件系统开始,使用 ls 工具:
$ ls
data wrangling_granularity.ipynb
figures wrangling_intro.ipynb
wrangling_command_line.ipynb wrangling_structure.ipynb
wrangling_datasets.ipynb wrangling_summary.ipynb
wrangling_formats.ipynb
为了更深入地查看并列出 data/ 目录中的文件,我们将目录名称作为 ls 的参数提供:
$ ls -l -L -h data/
total 556664
-rw-r--r-- 1 nolan staff 267M Dec 10 14:03 DAWN-Data.txt
-rw-r--r-- 1 nolan staff 645K Dec 10 14:01 businesses.csv
-rw-r--r-- 1 nolan staff 50K Jan 22 13:09 co2_mm_mlo.txt
-rw-r--r-- 1 nolan staff 455K Dec 10 14:01 inspections.csv
-rw-r--r-- 1 nolan staff 120B Dec 10 14:01 legend.csv
-rw-r--r-- 1 nolan staff 3.6M Dec 10 14:01 violations.csv
我们在命令中添加了 -l 标志以获取有关每个文件的更多信息。文件大小显示在列表的第五列中,并且通过 -h 标志指定的方式更易读。当我们有多个简单选项标志(如 -l、-h 和 -L)时,我们可以将它们组合在一起作为简写:
ls -lLh data/
注意
在本书中处理数据集时,我们的代码通常会为 ls 和其他 CLI 工具使用额外的 -L 标志,如 du。我们这样做是因为我们在书中使用快捷方式(称为符号链接)设置了数据集。通常情况下,您的代码不需要 -L 标志,除非您也在使用符号链接。
用于检查文件大小的其他 CLI 工具是 wc 和 du。wc 命令(缩写为 word count)提供有关文件大小的有用信息,以行数、单词数和文件中的字符数表示:
$ wc data/DAWN-Data.txt
229211 22695570 280095842 data/DAWN-Data.txt
我们可以从输出中看到 DAWN-Data.txt 有 229,211 行和 280,095,842 个字符。(中间值是文件的单词数,对于包含句子和段落的文件有用,但对于包含数据(如 FWF 格式值)的文件并不十分有用。)
ls 工具不计算文件夹内容的累计大小。要正确计算文件夹的总大小(包括文件夹中的文件),我们使用 du(磁盘使用情况的缩写)。默认情况下,du 工具以称为 blocks 的单位显示大小:
$ du -L data/
556664 data/
我们通常会在 du 命令中添加 -s 标志来显示文件和文件夹的大小,并添加 -h 标志以标准 KiB、MiB 或 GiB 格式显示数量。在下面的代码中,data/* 中的星号告诉 du 显示 data 文件夹中每个项的大小:
$ du -Lsh data/*
267M data/DAWN-Data.txt
648K data/businesses.csv
52K data/co2_mm_mlo.txt
456K data/inspections.csv
4.0K data/legend.csv
3.6M data/violations.csv
要检查文件的格式,我们可以使用 head 命令查看前几行,或者使用 tail 命令查看后几行。这些 CLI 对于查看文件内容以确定其是否为 CSV、TSV 等格式非常有用。例如,让我们来看一下 inspections.csv 文件:
$ head -4 data/inspections.csv
"business_id","score","date","type"
19,"94","20160513","routine"
19,"94","20171211","routine"
24,"98","20171101","routine"
默认情况下,head 显示文件的前 10 行。如果我们想显示四行,我们可以在命令中添加选项 -n 4(或者简写为 -4)。
我们可以使用 cat 命令打印文件的全部内容。但是,在使用此命令时需要小心,因为打印大文件可能会导致崩溃。legend.csv 文件很小,我们可以使用 cat 将其内容连接并打印出来:
$ cat data/legend.csv
"Minimum_Score","Maximum_Score","Description"
0,70,"Poor"
71,85,"Needs Improvement"
86,90,"Adequate"
91,100,"Good"
在许多情况下,仅使用 head 或 tail 就足以让我们对文件结构有足够的了解,以便将其加载到数据框中进行进一步处理。
最后,file 命令可以帮助我们确定文件的编码:
$ file -I data/*
data/DAWN-Data.txt: text/plain; charset=us-ascii
data/businesses.csv: application/csv; charset=iso-8859-1
data/co2_mm_mlo.txt: text/plain; charset=us-ascii
data/inspections.csv: application/csv; charset=us-ascii
data/legend.csv: application/csv; charset=us-ascii
data/violations.csv: application/csv; charset=us-ascii
我们再次看到所有文件都是 ASCII 编码,除了 businesses.csv 使用 ISO-8859-1 编码。
注意
通常,我们打开终端程序以启动 shell 解释器。但是,Jupyter 笔记本提供了一个方便的功能:如果 Python 代码单元格中的代码行以 ! 字符开头,则该行将直接发送到系统的 shell 解释器。例如,在 Python 单元格中运行 !ls 将列出当前目录中的文件。
Shell 命令为我们提供了一种程序化处理文件的方式,而不是点-and-click 的“手动”方法。它们对以下情况非常有用:
文档
如果你需要记录你所做的事情。
减少错误
如果你想减少排版错误和其他简单但潜在有害的错误。
可重复性
如果你将来需要重复相同的过程,或者计划与他人分享你的过程。这样可以记录你的操作。
体积
如果你有许多重复操作要执行,你正在处理的文件很大,或者你需要快速完成任务。CLI 工具可以在所有这些情况下帮助你。
在数据加载到数据框之后,我们的下一个任务是弄清楚表格的形状和粒度。我们首先找出表格中的行数和列数(其形状)。然后我们需要理解一行代表什么,然后才能开始检查数据的质量。我们在下一节中讨论这些话题。
表格形状和粒度
正如前面所述,我们将数据集的结构称为数据的心理表示,特别是我们通过将值按行和列排列来表示具有表结构的数据。我们使用术语粒度来描述表中每一行代表的内容,术语形状量化了表的行和列。
现在我们已经确定了与餐厅相关的文件的格式,我们将它们加载到数据框中并检查它们的形状:
`bus` `=` `pd``.``read_csv``(``'``data/businesses.csv``'``,` `encoding``=``'``ISO-8859-1``'``)`
`insp` `=` `pd``.``read_csv``(``"``data/inspections.csv``"``)`
`viol` `=` `pd``.``read_csv``(``"``data/violations.csv``"``)`
`print``(``"` `Businesses:``"``,` `bus``.``shape``,` `"``\t` `Inspections:``"``,` `insp``.``shape``,`
`"``\t` `Violations:``"``,` `viol``.``shape``)`
Businesses: (6406, 9) Inspections: (14222, 4) Violations: (39042, 3)
我们发现餐厅信息表(商业表)有 6,406 行和 9 列。现在让我们来弄清楚这张表的粒度。首先,我们可以看一下前两行:
| business_id | 名称 | 地址 | 城市 | ... | 邮政编码 | 纬度 | 经度 | 电话号码 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 19 | NRGIZE LIFESTYLE CAFE | 1200 VAN NESS AVE, 3RD FLOOR | 旧金山 | ... | 94109 | 37.79 | -122.42 | +14157763262 |
| 1 | 24 | OMNI S.F. HOTEL - 2ND FLOOR PANTRY | 500 CALIFORNIA ST, 2ND FLOOR | 旧金山 | ... | 94104 | 37.79 | -122.40 | +14156779494 |
2 rows × 9 columns
这两行给我们的印象是每个记录代表一个特定的餐厅。但是,我们无法仅凭两个记录就知道这是否正确。名为business_id的字段暗示它是餐厅的唯一标识符。我们可以通过检查数据框中的记录数是否与字段business_id中的唯一值数目匹配来确认这一点:
`print``(``"``Number of records:``"``,` `len``(``bus``)``)`
`print``(``"``Number of unique business ids:``"``,` `len``(``bus``[``'``business_id``'``]``.``unique``(``)``)``)`
Number of records: 6406
Number of unique business ids: 6406
唯一的business_id数目与表中的行数相匹配,因此可以安全地假设每一行代表一个餐厅。由于business_id在数据框中唯一标识每条记录,我们将business_id视为该表的主键。我们可以使用主键来连接表(参见第六章)。有时主键由两个(或更多)特征组成。这是其他两个餐厅文件的情况。让我们继续检查检查和违规数据框,并找出它们的粒度。
餐厅检查和违规的粒度
我们刚刚看到,检查表中的行比商业表中的行要多得多。让我们仔细看一下前几次检查:
| business_id | 分数 | 日期 | 类型 | |
|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | 常规 |
| 1 | 19 | 94 | 20171211 | 常规 |
| 2 | 24 | 98 | 20171101 | 常规 |
| 3 | 24 | 98 | 20161005 | 常规 |
`(``insp`
`.``groupby``(``[``'``business_id``'``,` `'``date``'``]``)`
`.``size``(``)`
`.``sort_values``(``ascending``=``False``)`
`.``head``(``5``)`
`)`
business_id date
64859 20150924 2
87440 20160801 2
77427 20170706 2
19 20160513 1
71416 20171213 1
dtype: int64
餐馆 ID 和检查日期的组合在这张表中唯一标识每条记录,除了三家餐馆的 ID-日期组合有两条记录。让我们检查餐馆64859的行:
`insp``.``query``(``'``business_id == 64859 and date == 20150924``'``)`
| business_id | score | date | type | |
|---|---|---|---|---|
| 7742 | 64859 | 96 | 20150924 | 常规 |
| 7744 | 64859 | 91 | 20150924 | 常规 |
这家餐馆在同一天得到了两个不同的检查分数!这怎么可能发生?可能是餐馆在一天内接受了两次检查,或者可能是一个错误。我们在考虑第九章中的数据质量时会解决这类问题。由于这种双重检查只有三次,我们可以在清理数据之前忽略这个问题。因此,如果从表中删除同一天的检查,主键将是餐馆 ID 和检查日期的组合。
请注意,检查表中的business_id字段充当对业务表主键的引用。因此,在insp中的business_id是一个外键,因为它将检查表中的每条记录链接到业务表中的一条记录。这意味着我们可以很容易地将这两个表连接在一起。
接下来,我们来检查第三个表的粒度,即包含违规的表:
| business_id | date | description | |
|---|---|---|---|
| 0 | 19 | 20171211 | 食品安全知识不足或没有... |
| 1 | 19 | 20171211 | 未经批准或未维护的设备或器具 |
| 2 | 19 | 20160513 | 未经批准或未维护的设备或器具... |
| ... | ... | ... | ... |
| 39039 | 94231 | 20171214 | 高危害害虫侵扰... |
| 39040 | 94231 | 20171214 | 中度风险食品保持温度... |
| 39041 | 94231 | 20171214 | 擦拭布不干净或未正确存放... |
39042 rows × 3 columns
查看此表中的前几条记录,我们发现每次检查都有多个条目。粒度似乎是在检查中发现的违规水平。阅读描述,我们看到如果得到纠正,描述中会列出方括号中的日期。
`viol``.``loc``[``39039``,` `'``description``'``]`
'High risk vermin infestation [ date violation corrected: 12/15/2017 ]'
简而言之,我们发现这三个食品安全表格具有不同的粒度。因为我们已经为它们确定了主键和外键,所以我们可以潜在地将这些表格连接起来。如果我们有兴趣研究检查,我们可以使用商业 ID 和检查日期将违规和检查一起连接起来。这将使我们能够将检查中发现的违规数量与检查分数联系起来。
通过选择每个餐厅最近的一次检查,我们还可以将检查表缩减为每个餐厅一个。这种精简的数据表基本上以餐厅为粒度,可能对基于餐厅的分析有用。在第九章中,我们涵盖了这些重塑数据表、转换列并创建新列的操作。
我们通过查看 DAWN 调查数据的形状和粒度来结束本节。
DAWN 调查的形状和粒度
正如本章前面提到的,DAWN 文件采用固定宽度格式,我们需要依靠代码簿查找字段的位置。例如,代码簿中的一个片段在图 8-2 中告诉我们,年龄出现在行的第 34 和 35 位置,并被分为 11 个年龄组:1 表示 5 岁及以下,2 表示 6 至 11 岁,……,11 表示 65 岁及以上。此外,-8 表示缺失值。

图 8-2. DAWN 年龄编码部分的屏幕截图
我们早些时候确定这个文件包含 200,000 行和超过 2.8 亿个字符,因此平均每行约有 1,200 个字符。这可能是他们使用固定宽度而不是 CSV 格式的原因。想象一下,如果每个字段之间都有逗号,文件会变得多么庞大!
鉴于每行都包含大量信息,我们只需将几个特征读入数据框中。我们可以使用pandas.read_fwf方法来完成这个任务。我们指定要提取的字段的确切位置,并为这些字段及其他有关标头和索引的信息提供名称:
`colspecs` `=` `[``(``0``,``6``)``,` `(``14``,``29``)``,` `(``33``,``35``)``,` `(``35``,` `37``)``,` `(``37``,` `39``)``,` `(``1213``,` `1214``)``]`
`varNames` `=` `[``"``id``"``,` `"``wt``"``,` `"``age``"``,` `"``sex``"``,` `"``race``"``,``"``type``"``]`
`dawn` `=` `pd``.``read_fwf``(``'``data/DAWN-Data.txt``'``,` `colspecs``=``colspecs``,`
`header``=``None``,` `index_col``=``0``,` `names``=``varNames``)`
| wt | age | sex | race | type | |
|---|---|---|---|---|---|
| id | |||||
| --- | --- | --- | --- | --- | --- |
| 1 | 0.94 | 4 | 1 | 2 | 8 |
| 2 | 5.99 | 11 | 1 | 3 | 4 |
| 3 | 4.72 | 11 | 2 | 2 | 4 |
| 4 | 4.08 | 2 | 1 | 3 | 4 |
| 5 | 5.18 | 6 | 1 | 3 | 8 |
我们可以将表中的行与文件中的行数进行比较:
`dawn``.``shape`
(229211, 5)
数据框中的行数与文件中的行数相匹配。这很好。由于调查设计的复杂性,数据框的粒度有点复杂。请记住,这些数据是大型科学研究的一部分,具有复杂的抽样方案。一行代表一个急诊室就诊,因此粒度是在急诊室就诊级别。然而,为了反映抽样方案并代表一年内所有与药物相关的急诊室访问的人群,提供了权重。在计算汇总统计数据、构建直方图和拟合模型时,我们必须将权重应用于每个记录。(wt字段包含这些值。)
权重考虑到这种类型的急诊室就诊出现在样本中的几率。所谓“这种类型的”是指具有类似特征的就诊,如访客年龄、种族、就诊地点和时段。让我们来检查wt中的不同值:
`dawn``[``'``wt``'``]``.``value_counts``(``)`
wt
0.94 1719
84.26 1617
1.72 1435
...
1.51 1
3.31 1
3.33 1
Name: count, Length: 3500, dtype: int64
在您的分析中包括调查权重非常关键,以获取代表大多数人口的数据。例如,我们可以比较包含和不包含权重计算的急诊女性比例:
`print``(``f``'``Unweighted percent female:` `{``np``.``average``(``dawn``[``"``sex``"``]` `==` `2``)``:``.1%``}``'``)`
`print``(``f``'` `Weighted percent female:``'``,`
`f``'``{``np``.``average``(``dawn``[``"``sex``"``]` `==` `2``,` `weights``=``dawn``[``"``wt``"``]``)``:``.1%``}``'``)`
Unweighted percent female: 48.0%
Weighted percent female: 52.3%
这些数字相差超过 4 个百分点。加权版本是女性在整个与药物相关的急诊访问人口中比例的更准确估计。
有时,像我们在检查数据中看到的那样,粒度可能很难确定。而其他时候,我们需要考虑抽样权重,比如 DAWN 数据。这些示例表明,在进行分析之前花时间审查数据描述是非常重要的。
总结
数据清洗是数据分析的重要组成部分。没有它,我们可能会忽略数据中可能对未来分析产生重大影响的问题。本章介绍了数据清洗的重要第一步:从纯文本源文件中读取数据到 Python 数据框架并确定其粒度。我们介绍了不同类型的文件格式和编码,并编写了可以从这些格式读取数据的代码。我们检查了源文件的大小,并考虑了用于处理大型数据集的替代工具。
我们还介绍了命令行工具作为检查文件格式、编码和大小的 Python 替代方案。由于其简单的语法,这些 CLI 工具在面向文件系统任务时尤为方便。我们只是触及了 CLI 工具的表面。在实践中,shell 能够进行复杂的数据处理,是值得学习的工具。
理解表的形状和粒度使我们能够洞察数据表中的一行代表什么。这有助于我们确定粒度是否混合,是否需要聚合或是否需要权重。在查看数据集的粒度后,您应该能回答以下问题:
记录代表什么?
弄清这一点将帮助您正确分析数据并陈述您的发现。
表中的所有记录是否以相同的粒度捕获?
有时,表中包含其他摘要行,其粒度不同,您希望仅使用那些具有正确细节级别的行。
如果数据已经聚合,聚合是如何执行的?
汇总和平均值是常见的聚合类型。对于平均化的数据,通常可以减少测量中的变异性,并且关系通常看起来更强。
您可能对数据执行哪些类型的聚合?
聚合可能对将一个数据表与另一个数据表合并非常有用或必要。
确定您的表的粒度是清理数据的第一步,也指导您如何分析数据。例如,我们看到 DAWN 调查的粒度是急诊就诊。这自然引导我们思考病人人口统计数据与整个美国的比较。
本章的数据整理技术帮助我们将数据从源文件导入数据框架,并了解其结构。一旦我们有了数据框架,就需要进一步整理数据,评估和提高数据质量,并为分析准备数据。我们将在下一章中涵盖这些主题。
^(1) 2020 年,该市开始向餐馆提供彩色编码的牌子,指示餐馆是否通过(绿色)、有条件通过(黄色)或未通过(红色)检查。这些新的牌子不再显示数字检查得分。然而,餐馆的得分和违规仍然可以在 DataSF 上查看。
第九章:整理数据框架
我们通常需要在分析之前对数据进行准备工作。准备工作的量可能差异很大,但从原始数据到准备好进行分析的数据,有几个基本步骤。第八章 讨论了从纯文本源创建数据框架的初始步骤。在本章中,我们评估数据的质量。为此,我们对单个数据值和整个列执行有效性检查。除了检查数据的质量外,我们还确定数据是否需要转换和重塑以准备进行分析。质量检查(和修复)以及转换通常是循环的:质量检查指导我们进行必要的转换,当我们检查转换后的列以确认数据准备好进行分析时,我们可能会发现它们需要进一步清理。
根据数据源的不同,我们对质量有不同的期望。一些数据集可能需要大量的整理工作才能使其达到可分析的形式,而其他数据可能已经很干净,我们可以直接进行建模。以下是一些数据源的示例以及我们可能预期进行的整理工作量:
-
来自科学实验或研究的数据通常是干净的,有良好的文档记录,并且具有简单的结构。这些数据被组织成可以广泛分享的形式,以便其他人可以在其基础上建立或重现发现。通常情况下,经过少量或无需整理后,即可进行分析。
-
政府调查数据通常附有非常详细的代码书和描述数据收集及格式化方式的元数据,这些数据集通常也是即开即用的,可以直接进行探索和分析。
-
行政数据可能是干净的,但如果没有关于数据源的内部知识,我们可能需要广泛检查它们的质量。此外,由于我们经常将这些数据用于与最初收集它们的目的不同的用途,我们可能需要转换特征或合并数据表。
-
从网页抓取等非正式收集的数据通常会非常混乱,并且往往缺乏文档记录。例如,文本、推特、博客和维基百科表格通常需要格式化和清理,才能将它们转化为可以分析的信息。
在本章中,我们将数据整理分解为以下几个阶段:评估数据质量,处理缺失值,转换特征,并通过修改其结构和粒度来重塑数据。评估数据质量的重要步骤是考虑其范围。数据范围已在第二章中介绍,我们建议您参考该章节以获取更详细的内容。
要清理和准备数据,我们还依赖探索性数据分析,尤其是可视化。然而,在本章中,我们专注于数据整理,并将更详细地讨论这些其他相关主题,这些内容在第 10 和第十一章节中。
我们使用在第八章中介绍的数据集:DAWN 政府调查与药物滥用有关的急诊室访问情况,以及旧金山餐馆食品安全检查的行政数据。但我们首先通过另一个足够简单且干净的例子介绍各种数据整理概念,以便我们可以在每个整理步骤中集中精力。
示例:从毛纳罗亚观测站收集二氧化碳(CO[2])测量数据
我们在第二章中看到,国家海洋和大气管理局(NOAA)监测毛纳罗亚观测站空气中的 CO[2]浓度。我们继续以此为例,介绍如何进行数据质量检查、处理缺失值、转换特征和重塑表格。这些数据位于文件data/co2_mm_mlo.txt中。在将其加载到数据框之前,让我们先了解源数据的格式、编码和大小(见第八章):
`from` `pathlib` `import` `Path`
`import` `os`
`import` `chardet`
`co2_file_path` `=` `Path``(``'``data``'``)` `/` `'``co2_mm_mlo.txt``'`
`[``os``.``path``.``getsize``(``co2_file_path``)``,`
`chardet``.``detect``(``co2_file_path``.``read_bytes``(``)``)``[``'``encoding``'``]``]`
[51131, 'ascii']
我们发现文件是纯文本,使用 ASCII 编码,大小约为 50 KiB。由于文件并不特别大,因此我们应该可以轻松地将其加载到数据框中,但首先需要确定文件的格式。让我们先看一下文件的前几行:
`lines` `=` `co2_file_path``.``read_text``(``)``.``split``(``'``\n``'``)`
`len``(``lines``)`
811
`lines``[``:``6``]`
['# --------------------------------------------------------------------',
'# USE OF NOAA ESRL DATA',
'# ',
'# These data are made freely available to the public and the',
'# scientific community in the belief that their wide dissemination',
'# will lead to greater understanding and new scientific insights.']
我们看到文件以数据源信息开头。在开始分析之前,我们应该先阅读这些文档,但有时沉浸于分析中的冲动会胜过一切,我们会开始随意地发现数据的各种属性。所以让我们快速找出实际数据值的位置:
`lines``[``69``:``75``]`
['#',
'# decimal average interpolated trend #days',
'# date (season corr)',
'1958 3 1958.208 315.71 315.71 314.62 -1',
'1958 4 1958.292 317.45 317.45 315.29 -1',
'1958 5 1958.375 317.50 317.50 314.71 -1']
我们发现数据从文件的第 73 行开始。我们还发现了一些相关特征:
-
值由空白分隔,可能是制表符。
-
数据以精确的列对齐。例如,每行的第七到第八位置出现了月份。
-
列标题分为两行。
我们可以使用read_csv将数据读入pandas的DataFrame中,并提供参数指定分隔符是空白、没有表头(我们将设置自己的列名),并跳过文件的前 72 行:
`co2` `=` `pd``.``read_csv``(``'``data/co2_mm_mlo.txt``'``,`
`header``=``None``,` `skiprows``=``72``,` `sep``=``'``\``s+``'``,`
`names``=``[``'``Yr``'``,` `'``Mo``'``,` `'``DecDate``'``,` `'``Avg``'``,` `'``Int``'``,` `'``Trend``'``,` `'``days``'``]``)`
`co2``.``head``(``3``)`
| 年 | 月 | 日期 | 平均 | 股息 | 趋势 | 天数 | |
|---|---|---|---|---|---|---|---|
| 0 | 1958 | 3 | 1958.21 | 315.71 | 315.71 | 314.62 | -1 |
| 1 | 1958 | 4 | 1958.29 | 317.45 | 317.45 | 315.29 | -1 |
| 2 | 1958 | 5 | 1958.38 | 317.50 | 317.50 | 314.71 | -1 |
我们已成功将文件内容加载到数据框中,可以看出数据的粒度是 1958 年至 2019 年的月均 CO[2]浓度。此外,表格形状为 738 行 7 列。
由于科学研究往往具有非常干净的数据,我们很容易就跳进去绘制一张 CO[2]月均值如何变化的图表。字段DecDate方便地将月份和年份表示为数值特征,因此我们可以轻松地制作一张折线图:
px.line(co2, x='DecDate', y='Avg', width=350, height=250,
labels={'DecDate':'Date', 'Avg':'Average monthly CO₂'})

哎呀!绘制数据后发现了一个问题。折线图中的四个低谷看起来很奇怪。这里发生了什么?我们可以检查数据帧的一些百分位数,看看是否能找出问题:
`co2``.``describe``(``)``[``3``:``]`
| Yr | Mo | DecDate | Avg | Int | Trend | days | |
|---|---|---|---|---|---|---|---|
| min | 1958.0 | 1.0 | 1958.21 | -99.99 | 312.66 | 314.62 | -1.0 |
| 25% | 1973.0 | 4.0 | 1973.56 | 328.59 | 328.79 | 329.73 | -1.0 |
| 50% | 1988.0 | 6.0 | 1988.92 | 351.73 | 351.73 | 352.38 | 25.0 |
| 75% | 2004.0 | 9.0 | 2004.27 | 377.00 | 377.00 | 377.18 | 28.0 |
| max | 2019.0 | 12.0 | 2019.62 | 414.66 | 414.66 | 411.84 | 31.0 |
这一次,我们更仔细地查看数值范围,发现一些数据有异常值,如-1和-99.99。如果我们仔细阅读文件顶部的信息,我们会发现-99.99表示缺失的月平均值,-1表示设备当月运行天数的缺失值。即使数据相对干净,也应该在进入分析阶段前阅读文档并进行一些质量检查是个好习惯。
质量检查
让我们暂停一会儿,进行一些质量检查。我们可以确认我们拥有预期的观测数量,寻找异常值,并将发现的异常与其他特征的值进行交叉验证。
首先,我们考虑数据的形状。我们应该有多少行数据?从数据框的头部和尾部看,数据按时间顺序排列,从 1958 年 3 月开始,到 2019 年 8 月结束。这意味着我们应该有 条记录,我们可以与数据框的形状进行对比:
`co2``.``shape`
(738, 7)
我们的计算与数据表中的行数匹配。
接下来,让我们检查特征的质量,从Mo开始。我们期望值在 1 到 12 之间,每个月应有 2019-1957=62 或 61 个实例(因为记录从第一年的三月开始,到最近一年的八月结束):
`co2``[``"``Mo``"``]``.``value_counts``(``)``.``reindex``(``range``(``1``,``13``)``)``.``tolist``(``)`
[61, 61, 62, 62, 62, 62, 62, 62, 61, 61, 61, 61]
如预期,一月、二月、九月、十月、十一月和十二月各有 61 次出现,其余 62 次。
现在让我们用直方图检查名为days的列:
`px``.``histogram``(``co2``,` `x``=``'``days``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``days``'``:``'``Days operational in a month``'``}``)`

我们发现有少数几个月份的平均值是基于少于一半天数的测量值。此外,有近 200 个缺失值。散点图可以帮助我们交叉检查缺失数据与记录年份:
`px``.``scatter``(``co2``,` `x``=``'``Yr``'``,` `y``=``'``days``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``Yr``'``:``'``Year``'``,` `'``days``'``:``'``Days operational in month``'` `}``)`

图表底部的左侧线条显示,所有缺失数据都在设备运行初期。设备运行天数可能在早期并未收集。此外,从 80 年代中期到 80 年代末,设备可能存在问题。针对这些推测,我们该如何处理呢?我们可以通过查阅历史记录的文件来确认这些推测。如果我们担心缺失设备运行天数的记录对 CO[2]平均值的影响,那么一个简单的解决方案是删除最早的记录。不过,在我们检查时间趋势并评估这些早期天数是否存在潜在问题之后再采取行动会更好。
接下来,让我们再次关注平均 CO[2]测量值中的-99.99值,并从直方图开始我们的检查:
`px``.``histogram``(``co2``,` `x``=``'``Avg``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``Avg``'``:``'``Average monthly CO₂``'``}``)`

根据我们对二氧化碳(CO[2])水平的研究,记录的数值在 300 至 400 的范围内,这符合我们的预期。我们还注意到只有少量的缺失数值。由于缺失值不多,我们可以检查所有这些值:
`co2``[``co2``[``"``Avg``"``]` `<` `0``]`
| Yr | Mo | DecDate | Avg | Int | Trend | days | |
|---|---|---|---|---|---|---|---|
| 3 | 1958 | 6 | 1958.46 | -99.99 | 317.10 | 314.85 | -1 |
| 7 | 1958 | 10 | 1958.79 | -99.99 | 312.66 | 315.61 | -1 |
| 71 | 1964 | 2 | 1964.12 | -99.99 | 320.07 | 319.61 | -1 |
| 72 | 1964 | 3 | 1964.21 | -99.99 | 320.73 | 319.55 | -1 |
| 73 | 1964 | 4 | 1964.29 | -99.99 | 321.77 | 319.48 | -1 |
| 213 | 1975 | 12 | 1975.96 | -99.99 | 330.59 | 331.60 | 0 |
| 313 | 1984 | 4 | 1984.29 | -99.99 | 346.84 | 344.27 | 2 |
我们面临的问题是如何处理-99.99的数值。我们已经看到在折线图中保留这些数值会带来问题。有几种选择,我们接下来会描述它们。
处理缺失数据
平均 CO[2]水平中的-99.99表示缺失记录。这些值影响了我们的统计摘要和图表。知道哪些值是缺失的很重要,但我们需要采取措施。我们可以删除这些记录,用NaN替换-99.99,或者用一个可能的平均 CO[2]值替换-99.99。让我们逐个检查这三种选择。
注意,表格中已经有了一个替代值来代替-99.99。标记为Int的列中的值与Avg中的值完全相同,只有当Avg为-99.99时,才会使用“合理”的估计值。
为了看清每种选择的影响,让我们放大一个短时间段,比如说 1958 年的测量数据,我们知道在这里有两个缺失值。我们可以为三种情况创建一个时间序列图:删除带有-99.99的记录(左侧图)、使用NaN表示缺失值(中间图)、替换-99.99为估计值(右侧图):

仔细观察时,我们可以看到每个图表之间的差异。最左边的图表连接了一个两个月的时间段内的点,而不是一个月。在中间的图表中,数据缺失处断开了线,而在右边,我们可以看到第 6 和第 10 月现在有值了。总体上来说,由于 738 个月中只有七个值缺失,所有这些选项都有效。然而,右图更吸引人的地方在于季节性趋势更清晰可辨。
用于插值 CO[2] 测量值的方法是考虑到月份和年份的平均处理过程。其思想是反映季节性变化和长期趋势。这一技术在数据文件顶部的文档中有更详细的描述。
这些图表显示数据的粒度为每月测量,但我们还可以选择其他粒度选项。接下来我们将讨论这一点。
数据表重塑
毛纳罗亚观测站获取的 CO[2] 测量数据还有每天和每小时的数据。每小时数据的粒度更细,而每日数据则比每小时数据粗。
为什么不总是使用最精细的数据粒度?在计算层面上,细粒度数据可能会变得非常大。毛纳罗亚观测站从 1958 年开始记录 CO[2] 水平。想象一下,如果该设施每秒提供一次测量,数据表会包含多少行!但更重要的是,我们希望数据的粒度与我们的研究问题相匹配。假设我们想要查看过去 50 多年来 CO[2] 水平是否上升,这与全球变暖预测一致。我们并不需要每秒一次的 CO[2] 测量。事实上,我们可能对年均值感到满意,因为这样可以平滑掉季节模式。我们可以聚合每月测量值,将粒度更改为年均值,并制作一个图表显示总体趋势。我们可以使用聚合来转向更粗粒度——在pandas中,我们使用.groupby()和.agg():

的确,自 1958 年以来,毛纳罗亚观测站记录的 CO[2] 测量值上升了将近 100 ppm。
总结一下,在将空格分隔的纯文本文件读入数据框后,我们开始检查其质量。我们使用数据的范围和上下文来确认其形状是否与收集日期的范围匹配。我们确认了月份的值和计数是否符合预期。我们确定了功能中缺失值的程度,并查找缺失值与其他功能之间的关系。我们考虑了三种处理缺失数据的方法:删除记录、处理NaN值和填补值以获得完整的表格。最后,我们通过将数据框的粒度从每月平均值升级到年度平均值来改变数据的粒度。这种粒度变化消除了季节性波动,并集中在大气中 CO[2]水平的长期趋势上。本章的接下来四个部分将扩展这些操作,将数据整理成适合分析的形式:质量检查、缺失值处理、转换和形状调整。我们从质量检查开始。
质量检查
一旦您的数据进入表格,并且您理解了范围和粒度,就是检查质量的时候了。在您检查和整理文件到数据框时,您可能会发现源数据中的错误。在本节中,我们描述如何继续这一检查,并进行更全面的功能和值质量评估。我们从四个角度考虑数据质量:
范围
数据是否与您对人口的理解相匹配?
测量和值
值是否合理?
关系
相关特征是否一致?
分析
哪些功能可能在未来的分析中有用?
我们依次描述每个点,从范围开始。
基于范围的质量
在第二章中,我们讨论了收集的数据是否能够充分解决当前问题。在那里,我们确定了目标人口、访问框架和样本收集数据。该框架帮助我们考虑可能影响研究结果普适性的潜在限制。
虽然在我们审议最终结论时,这些更广泛的数据范围考虑是重要的,但它们也有助于检查数据质量。例如,在第八章介绍的旧金山餐厅检查数据中,一项侧面调查告诉我们,城市的邮政编码应以 941 开头。但快速检查显示,有几个邮政编码以其他数字开头:
`bus``[``'``postal_code``'``]``.``value_counts``(``)``.``tail``(``10``)`
92672 1
64110 1
94120 1
..
94621 1
941033148 1
941 1
Name: postal_code, Length: 10, dtype: int64
使用范围进行的这种验证有助于我们发现潜在问题。
另一个例子是,在Climate.gov和NOAA上关于大气 CO[2]的背景阅读中,Typical measurements 约为全球 400 ppm。因此,我们可以检查夏威夷火山月均 CO[2]浓度是否介于 300 到 450 ppm 之间。
接下来,我们将数据值与代码手册等进行比对。
测量和记录值的质量
我们还可以通过考虑特征的合理值来检查测量的质量。例如,想象一下餐厅检查中违规数量的合理范围可能是 0 到 5. 其他检查可以基于常识的范围:餐厅检查分数必须在 0 到 100 之间;月份必须在 1 到 12 之间. 我们可以使用文档来告诉我们特征的预期值。例如,在 DAWN 调查中的急诊室访问类型,介绍在第八章,已编码为 1、2、...、8(参见图 9-1)。因此,我们可以确认访问类型的所有值确实是介于 1 到 8 之间的整数。

图 9-1. DAWN 调查中急诊室访问类型(CASETYPE)变量描述的屏幕截图(实际代码书中出现了拼写错误 SUICICDE)
我们还希望确保数据类型符合我们的预期。例如,我们希望价格是一个数字,无论它是存储为整数、浮点数还是字符串。确认测量单位与预期相符可以是另一个有用的质量检查(例如,以磅为单位记录的重量值,而不是公斤)。我们可以为所有这些情况设计检查。
可以通过比较两个相关特征来设计其他检查。
相关特征的质量
有时,两个特征对其值有内置条件,我们可以交叉检查其内部一致性。例如,根据 DAWN 研究的文档,饮酒只被认为是年龄在 21 岁以下的患者急诊访问的有效原因,因此我们可以检查任何记录中“饮酒”类型的访问是否年龄在 21 岁以下。 type 和 age 的交叉表可以确认满足此约束:
display_df(pd.crosstab(dawn['age'], dawn['type']), rows=12)
| 类型 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 年龄 | ||||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| -8 | 2 | 2 | 0 | 21 | 5 | 1 | 1 | 36 |
| 1 | 0 | 6 | 20 | 6231 | 313 | 4 | 2101 | 69 |
| 2 | 8 | 2 | 15 | 1774 | 119 | 4 | 119 | 61 |
| 3 | 914 | 121 | 2433 | 2595 | 1183 | 48 | 76 | 4563 |
| 4 | 817 | 796 | 4953 | 3111 | 1021 | 95 | 44 | 6188 |
| 5 | 983 | 1650 | 0 | 4404 | 1399 | 170 | 48 | 9614 |
| 6 | 1068 | 1965 | 0 | 5697 | 1697 | 140 | 62 | 11408 |
| 7 | 957 | 1748 | 0 | 5262 | 1527 | 100 | 60 | 10296 |
| 8 | 1847 | 3411 | 0 | 10221 | 2845 | 113 | 115 | 18366 |
| 9 | 1616 | 3770 | 0 | 12404 | 3407 | 75 | 150 | 18381 |
| 10 | 616 | 1207 | 0 | 12291 | 2412 | 31 | 169 | 7109 |
| 11 | 205 | 163 | 0 | 24085 | 2218 | 12 | 308 | 1537 |
交叉表确认所有酒精案例(type为 3)年龄在 21 岁以下(这些编码为 1、2、3 和 4)。数据值符合预期。
最后一种质量检查类型涉及特征中所含信息的量。
分析质量
即使数据通过了之前的质量检查,它的有效性仍然可能存在问题。例如,如果一个特征的几乎所有值都相同,那么这个特征对于理解底层模式和关系的贡献就很少。或者如果存在太多缺失值,尤其是在缺失值中存在可辨识的模式时,我们的发现可能会受限。此外,如果一个特征有许多坏/损坏的值,那么我们可能会质疑即使在适当范围内的那些值的准确性。
我们在下面的代码中看到,旧金山的餐馆检查类型可以是例行或投诉。由于 14,000 多次检查中只有一次是投诉,如果我们放弃这个特征,我们几乎不会损失什么,而且我们可能也想删除那个单独的检查,因为它代表了一个异常:
`pd``.``value_counts``(``insp``[``'``type``'``]``)`
routine 14221
complaint 1
Name: type, dtype: int64
一旦我们发现数据的问题,我们需要弄清楚该如何处理。
数据修复与否
当你揭示数据的问题时,基本上你有四个选择:保留数据如其所是,修改数值,移除特征,或删除记录。
保留数据如其所是
并非数据的每一个异常方面都需要修正。你可能已经发现了数据的一个特征,它将告诉你如何进行分析,而且不需要修正。或者你可能发现问题相对较小,很可能不会影响你的分析,因此你可以保留数据。或者,你可能希望用NaN替换损坏的数值。
修改单个数值
如果你已经找出了问题所在并可以修正数值,那么你可以选择进行更改。在这种情况下,创造一个带有修改数值的新特征,并保留原始特征是一个好的做法,就像二氧化碳(CO[2])的例子中那样。
移除一列
如果一个特征中的许多值存在问题,那么考虑完全消除该特征。与排除一个特征不同,可能存在一种转换可以使你保留该特征同时降低记录的详细级别。
删除记录
一般而言,我们不希望无故从数据集中删除大量观察结果。相反,尝试将你的调查范围缩小到某个明确定义的数据子集,而不是简单地对应着删除带有损坏数值的记录。当你发现一个异常值实际上是正确的时候,你可能仍然决定将该记录排除在你的分析之外,因为它与你的其他数据有显著不同,而你不希望它过度影响你的分析。
无论你采取什么方法,你都需要研究你所做改变对分析的可能影响。例如,尝试确定带有损坏数值的记录是否彼此相似,并且与其他数据不同。
质量检查可以揭示需要在进行分析之前解决的数据问题。一种特别重要的检查类型是查找缺失值。我们建议有时您可能希望将损坏的数据值替换为NaN,因此将其视为缺失。在其他时候,数据可能会缺失。如何处理缺失数据是一个重要的话题,有很多研究在解决这个问题;我们将在下一节中介绍处理缺失数据的方法。
缺失值与记录
在第三章中,我们考虑了当人群和访问框架不对齐时可能出现的问题,因此我们无法访问我们想要研究的所有人。我们还描述了当有人拒绝参与研究时可能出现的问题。在这些情况下,整个记录/行可能会丢失,并且我们讨论了由于缺失记录可能出现的偏差类型。如果未响应者在关键方面与响应者不同,或者非响应率不可忽略,则我们的分析可能会严重有误。第三章中关于选举民意测验的例子表明,增加样本大小而不解决非响应问题并不会减少非响应偏差。此外,在该章中,我们讨论了预防非响应的方法。这些预防措施包括使用激励措施鼓励响应,保持调查简短,编写清晰的问题,培训访问员,并投入广泛的后续程序。不幸的是,尽管这些努力,一定程度的非响应是不可避免的。
当记录不完全丢失,但记录中的特定字段不可用时,我们称之为字段级的非响应。一些数据集使用特殊编码来表示信息丢失的情况。我们发现毛纳罗亚数据使用-99.99表示缺失的 CO[2]测量。在表中的 738 行中,我们只发现了七个这样的值。在这种情况下,我们表明这些缺失值对分析影响不大。
特征的值被称为完全随机缺失,当缺失数据的记录就像随机选择的记录子集时。也就是说,记录是否缺失不依赖于未观察到的特征、其他特征的值或抽样设计。例如,如果有人在毛纳罗亚意外损坏了实验设备,导致某天未记录 CO[2],那么没有理由认为那天的 CO[2]水平与丢失的测量有关。
在其他时候,我们考虑给定协变量缺失随机的值(协变量是数据集中的其他特征)。例如,在 DAWN 调查中,急诊访问类型在给定协变量情况下是随机缺失的,如果,例如,非响应仅依赖于种族和性别(而不依赖于访问类型或其他任何因素)。在这些有限的情况下,可以对观察数据进行加权以适应非响应。
在某些调查中,缺失信息进一步分类为受访者拒绝回答、受访者不确定答案或面试官未问问题。每种类型的缺失值使用不同的值记录。例如,根据代码书,DAWN 调查中的许多问题使用-7表示不适用,-8表示未记录,-9表示缺失。这些编码可以帮助我们进一步完善非响应的研究。
在非响应发生后,有时可以使用模型预测缺失的数据。我们接下来描述这个过程。但请记住,预测缺失的观察结果永远不如首次观察到它们好。
有时,我们会为缺失的值替换一个合理的值,以创建一个“干净”的数据框架。这个过程称为填补。填补值的一些常见方法包括演绎、均值和热卡填补。
在演绎填补中,我们通过与其他特征的逻辑关系填补值。例如,这是旧金山餐馆检查的业务数据框架中的一行。邮政编码错误地标记为“Ca”,纬度和经度缺失:
`bus``[``bus``[``'``postal_code``'``]` `==` `"``Ca``"``]`
| business_id | name | address | city | ... | postal_code | latitude | longitude | phone_number | |
|---|---|---|---|---|---|---|---|---|---|
| 5480 | 88139 | TACOLICIOUS | 2250 CHESTNUT ST | San Francisco | ... | Ca | NaN | NaN | +14156496077 |
1 row × 9 columns
我们可以在 USPS 网站上查找地址以获取正确的邮政编码,并可以使用 Google Maps 查找餐馆的纬度和经度来填补这些缺失值。
均值填补使用数据集中非缺失行的平均值。例如,如果一个测试分数数据集中一些学生的分数缺失,均值填补将使用非缺失分数的平均值填补缺失值。均值填补的一个关键问题是,由于该特征现在具有与均值相同的值,因此填补后特征的变异性将较小。如果不正确处理,这将影响后续分析,例如,置信区间将比预期小(这些主题在第十七章中有详细介绍)。在马乌纳罗亚的 CO[2]的缺失值中,使用了更复杂的平均技术,其中包括邻近的季节性值。
热卡填补使用机会过程从具有值的行中随机选择一个值。例如,热卡填补可以通过随机选择数据集中的另一个测试分数来填补缺失的测试分数。热卡填补的一个潜在问题是,特征之间的关系强度可能会因为我们增加了随机性而减弱。
对于均值和热补卡填补,我们通常基于数据集中具有其他特征中类似值的记录来填补值。更复杂的填补技术使用最近邻方法来找到相似记录子组,其他技术使用回归技术来预测缺失值。
在所有这些填补类型中,我们应该创建一个包含修改后数据的新特征,或者创建一个新特征来指示原始特征中的响应是否已被填补,以便我们可以跟踪我们的更改。
决定保留或丢弃具有缺失值的记录、更改值或删除特征可能看起来微不足道,但它们可能至关重要。一个异常记录可能严重影响您的发现。无论您做出什么决定,都要确保检查删除或更改特征和记录的影响。在报告您对数据所做修改时,一定要透明和彻底。最好通过编程方式进行这些更改,以减少潜在错误,并使其他人能够通过审查您的代码确认您所做的确切更改。
数据转换也需要同样的透明度和可重现性预防措施,接下来我们会讨论这些。
转换和时间戳
有时特征的形式不适合分析,因此我们对其进行转换。特征可能需要转换的原因有很多:值编码可能对分析无用,我们可能想对特征应用数学函数,或者我们可能想从特征中提取信息并创建新特征。我们描述了这三种基本类型的转换:类型转换、数学转换和提取:
类型转换
这种转换发生在我们将数据从一种格式转换为另一种格式以使数据更适合分析时。我们可能会将存储为字符串的信息转换为另一种格式。例如,我们可能希望将报价字符串"$2.17"转换为数字 2.17,以便计算汇总统计数据。或者我们可能希望将存储为字符串的时间,如"1955-10-12",转换为pandas Timestamp对象。另一个示例是在将类别合并在一起时发生,例如将 DAWN 中的 11 个年龄类别减少为 5 个分组。
数学转换
数学转换的一种类型是当我们从一个测量单位,比如从磅到公斤,进行单位转换。我们可能进行单位转换,以便我们的数据统计可以直接与其他数据集的统计进行比较。进行特征转换的另一个原因是使其分布更对称(这个概念在第十章中有更详细的介绍)。处理不对称性最常见的转换是对数。最后,我们可能希望通过算术运算创建一个新的特征。例如,我们可以结合身高和体重,通过计算 来创建身体质量指数。
提取
有时候我们想通过提取创建一个特征,新特征包含从另一个特征中提取的部分信息。例如,检查违规行为包含违规描述的字符串,我们可能只关心违规是否涉及,比如,害虫。如果违规描述中包含单词 vermin,我们可以创建一个新特征,如果是,则为True,否则为False。将信息转换为逻辑值(或 0-1 值)在数据科学中非常有用。本章中即将介绍的示例为这些二元特征提供了一个具体的用例。
我们在第十章中涵盖了许多其他有用转换的示例。在本节的其余部分,我们解释了与处理日期和时间相关的另一种转换方式。日期和时间出现在许多类型的数据中,因此学习如何处理这些数据类型是值得的。
转换时间戳
时间戳 是记录特定日期和时间的数据值。例如,时间戳可以记录为 Jan 1 2020 2pm 或 2021-01-31 14:00:00 或 2017 Mar 03 05:12:41.211 PDT。时间戳有许多不同的格式!这种信息对于分析非常有用,因为它让我们能够回答诸如“一天中哪个时段的网站流量最高?”的问题。当我们处理时间戳时,通常需要对其进行解析以便于分析。
让我们看一个例子。旧金山餐馆的检查数据包括餐厅检查发生的日期:
`insp``.``head``(``4``)`
| business_id | score | date | type | |
|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine |
| 1 | 19 | 94 | 20171211 | routine |
| 2 | 24 | 98 | 20171101 | routine |
| 3 | 24 | 98 | 20161005 | routine |
默认情况下,pandas将date列读取为整数:
`insp``[``'``date``'``]``.``dtype`
dtype('int64')
这种存储类型使得回答一些有用的数据问题变得困难。假设我们想知道检查是否更频繁发生在周末还是工作日。为了回答这个问题,我们想将date列转换为pandas的Timestamp存储类型,并提取星期几。
日期值似乎采用YYYYMMDD格式,其中YYYY、MM和DD分别对应四位数年份、两位数月份和两位数日期。pd.to_datetime()方法可以将日期字符串解析为对象,我们可以传入日期格式作为日期格式字符串:
`date_format` `=` `'``%Y``%m``%d``'`
`insp_dates` `=` `pd``.``to_datetime``(``insp``[``'``date``'``]``,` `format``=``date_format``)`
`insp_dates``[``:``3``]`
0 2016-05-13
1 2017-12-11
2 2017-11-01
Name: date, dtype: datetime64[ns]
现在我们可以看到insp_dates现在具有datetime64[ns]的dtype,这意味着值已成功转换为pd.Timestamp对象。^(1)
pandas为使用.dt访问器保持时间戳的Series对象提供了特殊方法和属性。例如,我们可以轻松地提取每个时间戳的年份:
`insp_dates``.``dt``.``year``[``:``3``]`
0 2016
1 2017
2 2017
Name: date, dtype: int32
pandas文档详细介绍了.dt访问器的所有细节。通过查看文档,我们可以看到.dt.day_of_week属性获取每个时间戳的星期几(星期一=0,星期二=1,…,星期日=6)。因此,让我们向数据框中分配新列,这些列包含解析的时间戳和星期几:
`insp` `=` `insp``.``assign``(``timestamp``=``insp_dates``,`
`dow``=``insp_dates``.``dt``.``dayofweek``)`
`insp``.``head``(``3``)`
| business_id | score | date | type | timestamp | dow | |
|---|---|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine | 2016-05-13 | 4 |
| 1 | 19 | 94 | 20171211 | routine | 2017-12-11 | 0 |
| 2 | 24 | 98 | 20171101 | routine | 2017-11-01 | 2 |
现在我们可以看出,餐厅检查员是否偏爱某一周的某一天,通过对星期几进行分组来实现:
`insp``[``'``dow``'``]``.``value_counts``(``)``.``reset_index``(``)`
| dow | count | |
|---|---|---|
| 0 | 2 | 3281 |
| 1 | 1 | 3264 |
| 2 | 3 | 2497 |
| 3 | 0 | 2464 |
| 4 | 4 | 2101 |
| 5 | 6 | 474 |
| 6 | 5 | 141 |

正如预期的那样,检查很少在周末进行。我们还发现星期二和星期三是最受欢迎的检查日。
我们已经对检查表执行了许多操作。跟踪这些修改的一种方法是将这些操作从一个操作到下一个进行管道传输。接下来我们将讨论管道的概念。
转换管道
在数据分析中,我们通常对数据应用许多转换,当我们反复变异数据框时,很容易引入错误,部分原因是 Jupyter 笔记本允许我们按任何顺序运行单元格。作为良好的实践,我们建议将转换代码放入具有有用名称的函数中,并使用DataFrame.pipe()方法将转换链接在一起。
让我们将早期的时间戳解析代码重写为函数,并将时间戳作为新列添加回数据框中,同时添加第二列,其中包含时间戳的年份:
`date_format` `=` `'``%Y``%m``%d``'`
`def` `parse_dates_and_years``(``df``,` `column``=``'``date``'``)``:`
`dates` `=` `pd``.``to_datetime``(``df``[``column``]``,` `format``=``date_format``)`
`years` `=` `dates``.``dt``.``year`
`return` `df``.``assign``(``timestamp``=``dates``,` `year``=``years``)`
现在我们可以使用.pipe()方法将insp数据框通过此函数管道化:
`insp` `=` `(``pd``.``read_csv``(``"``data/inspections.csv``"``)`
`.``pipe``(``parse_dates_and_years``)``)`
我们可以链接许多.pipe()调用在一起。例如,我们可以从时间戳中提取星期几:
`def` `extract_day_of_week``(``df``,` `col``=``'``timestamp``'``)``:`
`return` `df``.``assign``(``dow``=``df``[``col``]``.``dt``.``day_of_week``)`
`insp` `=` `(``pd``.``read_csv``(``"``data/inspections.csv``"``)`
`.``pipe``(``parse_dates_and_years``)`
`.``pipe``(``extract_day_of_week``)``)`
`insp`
| business_id | score | date | type | timestamp | year | dow | |
|---|---|---|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine | 2016-05-13 | 2016 | 4 |
| 1 | 19 | 94 | 20171211 | 日常 | 2017-12-11 | 2017 | 0 |
| 2 | 24 | 98 | 20171101 | 日常 | 2017-11-01 | 2017 | 2 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 14219 | 94142 | 100 | 20171220 | 日常 | 2017-12-20 | 2017 | 2 |
| 14220 | 94189 | 96 | 20171130 | 日常 | 2017-11-30 | 2017 | 3 |
| 14221 | 94231 | 85 | 20171214 | 日常 | 2017-12-14 | 2017 | 3 |
14222 rows × 7 columns
使用 pipe() 的几个关键优势。当在单个数据框上有许多转换时,我们可以更容易地看到发生了哪些转换,因为我们只需读取函数名。此外,我们可以将转换函数重用于不同的数据框。例如,viol 数据框包含有关餐厅安全违规的信息,同时也有一个 date 列。这意味着我们可以使用 .pipe() 重新使用时间戳解析函数,而无需编写额外的代码。方便!
`viol` `=` `(``pd``.``read_csv``(``"``data/violations.csv``"``)`
`.``pipe``(``parse_dates_and_years``)``)`
`viol``.``head``(``2``)`
| business_id | date | description | timestamp | year | |
|---|---|---|---|---|---|
| 0 | 19 | 20171211 | 食品安全知识不足或缺乏 ce… | 2017-12-11 | 2017 |
| 1 | 19 | 20171211 | 未批准或未维护的设备或器具 | 2017-12-11 | 2017 |
另一种转换方式是通过删除不需要的列、获取行的子集或将行滚动到更粗粒度来改变数据框的形状。接下来我们描述这些结构变化。
修改结构
如果一个数据框的结构不方便,我们可能很难进行我们想要的分析。整理过程通常以某种方式重塑数据框,以使分析更容易和更自然。这些变化可以简单地从表中获取一部分行和/或列,或者以更基本的方式改变表的粒度。在本节中,我们使用 第六章 中的技术来展示如何以以下方式修改结构:
简化结构
如果一个数据框有不需要在我们分析中的特征,那么我们可能希望删除这些多余的列,以便更轻松地处理数据框。或者,如果我们想专注于特定时间段或地理区域,我们可能希望获取行的子集(子集在 第六章 中有所介绍)。在 第八章 中,我们将从 DAWN 调查中的数百个特征中读取数据框的一个小集合,因为我们有兴趣了解患者人口学特征对急诊访问类型模式的影响。在 第十章 中,我们将限制对家庭销售价格的分析到一年和几个城市,以减少通货膨胀的影响,并更好地研究位置对销售价格的影响。
调整粒度
在本章的早些示例中,CO[2]测量结果已从月平均值聚合到年平均值,以更好地可视化年度趋势。在接下来的部分中,我们提供另一个示例,其中我们将违规级别数据聚合到检查级别,以便与餐厅检查分数合并。在这两个示例中,我们调整了数据框的粒度,通过分组记录和聚合值来处理更粗略的粒度。对于 CO[2]测量结果,我们对同一年的月值进行了分组然后求平均值。其他常见的组合方式包括记录数、总和、最小值、最大值以及组内的第一个或最后一个值。有关如何调整pandas数据框的详细信息可以在第六章找到,包括如何按多列值进行分组。
处理混合粒度
有时,数据集可能存在混合粒度的情况,即记录处于不同的详细级别。政府机构提供的数据中常见的情况是在同一文件中包含县级和州级的数据。发生这种情况时,我们通常希望将数据框拆分为两个部分,一个是县级的,另一个是州级的。这样可以使县级和州级分析更加容易,甚至可行。
重塑结构
数据,尤其是来自政府来源的数据,可以作为数据透视表进行共享。这些宽表格以数据值作为列名,通常在分析中难以使用。我们可能需要将它们重塑为长格式。图 9-2 展示了相同数据存储在宽和长数据表中的情况。宽数据表的每一行对应长数据表中的三行,如表中所示。请注意,在宽数据表中,每一行有三个值,分别对应每个月。而在长数据表中,每一行只有一个月的值。长数据表通常更容易聚合以供未来分析使用。因此,长格式数据也经常被称为整洁数据。

图 9-2. 宽数据表(顶部)和长数据表(底部)的示例,包含相同的数据
为了演示重塑,我们可以将 CO[2]数据放入一个类似于数据透视表形状的宽数据框中。每个月份都有一列,每年都有一行:
`co2_pivot` `=` `pd``.``pivot_table``(`
`co2``[``10``:``34``]``,`
`index``=``'``Yr``'``,` `# Column to turn into new index`
`columns``=``'``Mo``'``,` `# Column to turn into new columns`
`values``=``'``Avg``'``)` `# Column to aggregate`
`co2_wide` `=` `co2_pivot``.``reset_index``(``)`
`display_df``(``co2_wide``,` `cols``=``10``)`
| Mo | Yr | 1 | 2 | 3 | 4 | ... | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1959 | 315.62 | 316.38 | 316.71 | 317.72 | ... | 314.80 | 313.84 | 313.26 | 314.8 | 315.58 |
| 1 | 1960 | 316.43 | 316.97 | 317.58 | 319.02 | ... | 315.91 | 314.16 | 313.83 | 315.0 | 316.19 |
2 rows × 13 columns
列标题是月份,网格中的单元格值是 CO[2]的月平均值。我们可以将此数据框转换回长格式,其中列名变为一个特征,称为month,并将网格中的值重新组织为第二个特征,称为average:
`co2_long` `=` `co2_wide``.``melt``(``id_vars``=``[``'``Yr``'``]``,`
`var_name``=``'``month``'``,`
`value_name``=``'``average``'``)`
`display_df``(``co2_long``,` `rows``=``4``)`
| 年 | 月 | 平均值 | |
|---|---|---|---|
| 0 | 1959 | 1 | 315.62 |
| 1 | 1960 | 1 | 316.43 |
| ... | ... | ... | ... |
| 22 | 1959 | 12 | 315.58 |
| 23 | 1960 | 12 | 316.19 |
24 rows × 3 columns
注意数据已恢复到其原始形状(尽管行不是原始顺序)。当我们期望读者查看数据表本身时,宽格式数据更常见,例如在经济文章或新闻报道中。但是,长格式数据对数据分析更有用。例如,co2_long允许我们编写简短的pandas代码,按年份或月份分组,而宽格式数据则使按年份分组变得困难。.melt()方法特别适用于将宽格式转换为长格式数据。
这些结构修改已集中在单个表上。然而,我们经常希望将分散在多个表中的信息组合在一起。在下一节中,我们将结合本章介绍的技术来处理餐厅检查数据,并解决表的连接问题。
示例:整理餐厅安全违规
我们在本章结束时通过一个示例展示了许多数据整理技术。回顾第八章,旧金山餐厅检查数据存储在三个表中:bus(企业/餐厅)、insp(检查)和viol(安全违规)。违规数据集包含检查期间发现的详细违规描述。我们希望捕捉部分信息,并将其与检查评分连接,这是一个检查级别的数据集。
我们的目标是找出与较低餐厅安全评分相关的安全违规类型。这个例子涵盖了数据整理中与更改结构相关的几个关键概念:
-
过滤以便专注于数据的较窄部分
-
聚合以修改表的粒度
-
连接以汇总跨表信息
此外,本示例的一个重要部分展示了如何将文本数据转换为数值量进行分析。
作为第一步,让我们通过将数据简化为一年的检查来简化结构。(回想一下,该数据集包含四年的检查信息。)在以下代码中,我们统计了检查表中每年的记录数:
`pd``.``value_counts``(``insp``[``'``year``'``]``)`
year
2016 5443
2017 5166
2015 3305
2018 308
Name: count, dtype: int64
将数据减少到一年的检查将简化我们的分析。稍后,如果需要,我们可以返回并使用所有四年的数据进行分析。
缩小焦点
我们将数据整理限定在 2016 年进行的检查中。在这里,我们可以再次使用pipe函数,以便对检查和违规数据框应用相同的重塑:
`def` `subset_2016``(``df``)``:`
`return` `df``.``query``(``'``year == 2016``'``)`
`vio2016` `=` `viol``.``pipe``(``subset_2016``)`
`ins2016` `=` `insp``.``pipe``(``subset_2016``)`
`ins2016``.``head``(``5``)`
| business_id | score | date | type | timestamp | year | |
|---|---|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine | 2016-05-13 | 2016 |
| 3 | 24 | 98 | 20161005 | routine | 2016-10-05 | 2016 |
| 4 | 24 | 96 | 20160311 | routine | 2016-03-11 | 2016 |
| 6 | 45 | 78 | 20160104 | routine | 2016-01-04 | 2016 |
| 9 | 45 | 84 | 20160614 | routine | 2016-06-14 | 2016 |
在第八章中,我们发现business_id和timestamp共同唯一标识了检查(除了几个例外)。我们还看到这里,餐馆在一年内可能接受多次检查——例如,商家#24 在 2016 年进行了两次检查,分别在三月和十月。
接下来,让我们看看违规表中的几条记录:
`vio2016``.``head``(``5``)`
| business_id | date | description | timestamp | year | |
|---|---|---|---|---|---|
| 2 | 19 | 20160513 | 未批准或未维护的设备或器具... | 2016-05-13 | 2016 |
| 3 | 19 | 20160513 | 地板、墙壁或天花板不洁或破损... | 2016-05-13 | 2016 |
| 4 | 19 | 20160513 | 食品安全证书或食品处理者证未... | 2016-05-13 | 2016 |
| 6 | 24 | 20161005 | 地板、墙壁或天花板不洁或破损... | 2016-10-05 | 2016 |
| 7 | 24 | 20160311 | 地板、墙壁或天花板不洁或破损... | 2016-03-11 | 2016 |
请注意,前几条记录是同一家餐厅的。如果我们想将违规信息带入检查表中,我们需要处理这些表的不同粒度。一种方法是以某种方式聚合违规行为。我们将在接下来讨论这一点。
聚合违规行为
将违规行为的一个简单聚合是计算它们的数量,并将该计数添加到检查数据表中。为了找出检查中的违规次数,我们可以按business_id和timestamp对违规行为进行分组,然后找出每个组的大小。基本上,这种分组将违规行为的粒度变更为检查级别:
`num_vios` `=` `(``vio2016`
`.``groupby``(``[``'``business_id``'``,` `'``timestamp``'``]``)`
`.``size``(``)`
`.``reset_index``(``)`
`.``rename``(``columns``=``{``0``:` `'``num_vio``'``}``)``)``;`
`num_vios``.``head``(``3``)`
| business_id | timestamp | num_vio | |
|---|---|---|---|
| 0 | 19 | 2016-05-13 | 3 |
| 1 | 24 | 2016-03-11 | 2 |
| 2 | 24 | 2016-10-05 | 1 |
现在我们需要将这些新信息与ins2016合并。具体来说,我们想要左连接ins2016和num_vios,因为可能有些检查没有任何违规行为,我们不希望丢失它们:
`def` `left_join_vios``(``ins``)``:`
`return` `ins``.``merge``(``num_vios``,` `on``=``[``'``business_id``'``,` `'``timestamp``'``]``,` `how``=``'``left``'``)`
`ins_and_num_vios` `=` `ins2016``.``pipe``(``left_join_vios``)`
`ins_and_num_vios`
| business_id | score | date | type | timestamp | year | num_vio | |
|---|---|---|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine | 2016-05-13 | 2016 | 3.0 |
| 1 | 24 | 98 | 20161005 | routine | 2016-10-05 | 2016 | 1.0 |
| 2 | 24 | 96 | 20160311 | routine | 2016-03-11 | 2016 | 2.0 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 5440 | 90096 | 91 | 20161229 | routine | 2016-12-29 | 2016 | 2.0 |
| 5441 | 90268 | 100 | 20161229 | routine | 2016-12-29 | 2016 | NaN |
| 5442 | 90269 | 100 | 20161229 | routine | 2016-12-29 | 2016 | NaN |
5443 rows × 7 columns
在检查时如果没有违规,特征num_vio将会是缺失值(NaN)。我们可以检查有多少缺失的数值:
`ins_and_num_vios``[``'``num_vio``'``]``.``isnull``(``)``.``sum``(``)`
833
关于 2016 年的餐厅检查,约 15%没有记录安全违规。如果餐厅的安全得分为 100,我们可以通过将它们设置为 0 来修正这些缺失值。这是归纳填充的一个例子,因为我们使用领域知识来填补缺失值:
`def` `zero_vios_for_perfect_scores``(``df``)``:`
`df` `=` `df``.``copy``(``)`
`df``.``loc``[``df``[``'``score``'``]` `==` `100``,` `'``num_vio``'``]` `=` `0`
`return` `df`
`ins_and_num_vios` `=` `(``ins2016``.``pipe``(``left_join_vios``)`
`.``pipe``(``zero_vios_for_perfect_scores``)``)`
我们可以再次统计有缺失违规数量的检查次数:
`ins_and_num_vios``[``'``num_vio``'``]``.``isnull``(``)``.``sum``(``)`
65
我们已经纠正了大量缺失的数值。进一步调查后,我们发现一些企业的检查日期接近但不完全匹配。我们可以进行模糊匹配,将日期仅相差一两天的检查归为一类。但目前,我们将它们留为空值NaN。
让我们来研究违规数量与检查得分之间的关系:

正如我们预期的那样,检查得分与违规数量之间存在负相关关系。我们还可以看到得分的变异性。随着违规数量的增加,得分的变异性也增加。似乎某些违规比其他违规更为严重,对得分的影响更大。接下来我们提取违规种类的信息。
从违规描述中提取信息
我们之前看到违规数据框架中的特征描述有很多文本,包括方括号中关于何时纠正违规的信息。我们可以汇总描述并查看最常见的违规情况:
display_df(vio2016['description'].value_counts().head(15).to_frame(), rows=15)
| 描述 | |
|---|---|
| 地板、墙壁或天花板不干净或已磨损 | 161 |
| 未批准或未维护的设备或器具 | 99 |
| 中度风险的食品持有温度 | 95 |
| 清洗不充分或无法接近的洗手设施 | 93 |
| 清洁或消毒不充分的食品接触表面 | 92 |
| 食品存储不当 | 81 |
| 擦拭布不干净或存放不当或消毒剂不足 | 71 |
| 食品安全证书或持证食品处理人员卡不可用 | 64 |
| 中度风险的害虫侵扰 | 58 |
| 食品未受到污染保护 | 56 |
| 非食品接触表面不干净 | 54 |
| 食品安全知识不足或缺乏持证食品安全经理 | 52 |
| 许可证或检查报告未张贴 | 41 |
| 设备、器具或亚麻布存储不当 | 41 |
| 低风险的害虫侵扰 | 34 |
通过阅读这些冗长的描述,我们发现其中一些与设施的清洁有关,另一些与食品存储有关,还有一些与员工的清洁有关。
由于有许多类型的违规行为,我们可以尝试将它们分组到更大的类别中。一种方法是根据文本是否包含特定术语(如害虫、手或高风险)创建一个简单的布尔标志。
通过这种方法,我们为不同类别的违规行为创建了八个新特征。暂时不必担心代码的具体细节——此代码使用了正则表达式,详见第十三章。重要的是,此代码根据违规描述中是否包含特定单词创建包含True或False的特征:
`def` `make_vio_categories``(``vio``)``:`
`def` `has``(``term``)``:`
`return` `vio``[``'``description``'``]``.``str``.``contains``(``term``)`
`return` `vio``[``[``'``business_id``'``,` `'``timestamp``'``]``]``.``assign``(`
`high_risk` `=` `has``(``r``"``high risk``"``)``,`
`clean` `=` `has``(``r``"``clean|sanit``"``)``,`
`food_surface` `=` `(``has``(``r``"``surface``"``)` `&` `has``(``r``"``\``Wfood``"``)``)``,`
`vermin` `=` `has``(``r``"``vermin``"``)``,`
`storage` `=` `has``(``r``"``thaw|cool|therm|storage``"``)``,`
`permit` `=` `has``(``r``"``certif|permit``"``)``,`
`non_food_surface` `=` `has``(``r``"``wall|ceiling|floor|surface``"``)``,`
`human` `=` `has``(``r``"``hand|glove|hair|nail``"``)``,`
`)`
`vio_ctg` `=` `vio2016``.``pipe``(``make_vio_categories``)`
`vio_ctg`
| business_id | timestamp | high_risk | clean | ... | storage | permit | non_food_surface | human | |
|---|---|---|---|---|---|---|---|---|---|
| 2 | 19 | 2016-05-13 | False | False | ... | False | False | False | False |
| 3 | 19 | 2016-05-13 | False | True | ... | False | False | True | False |
| 4 | 19 | 2016-05-13 | False | False | ... | False | True | False | True |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 38147 | 89900 | 2016-12-06 | False | False | ... | False | False | False | False |
| 38220 | 90096 | 2016-12-29 | False | False | ... | False | False | False | False |
| 38221 | 90096 | 2016-12-29 | False | True | ... | False | False | True | False |
15624 rows × 10 columns
现在,我们在vio_ctg中有了这些新特征,我们可以找出某些违规类别是否比其他类别更具影响力。例如,餐厅的评分是否更多受到与害虫相关的违规行为的影响,而不是与许可相关的违规行为?
要做到这一点,我们首先要统计每个企业的违规次数。然后我们可以将此信息与检查信息合并。首先,让我们对每个企业的违规次数进行求和:
`vio_counts` `=` `vio_ctg``.``groupby``(``[``'``business_id``'``,` `'``timestamp``'``]``)``.``sum``(``)``.``reset_index``(``)`
`vio_counts`
| business_id | timestamp | high_risk | clean | ... | storage | permit | non_food_surface | human | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 19 | 2016-05-13 | 0 | 1 | ... | 0 | 1 | 1 | 1 |
| 1 | 24 | 2016-03-11 | 0 | 2 | ... | 0 | 0 | 2 | 0 |
| 2 | 24 | 2016-10-05 | 0 | 1 | ... | 0 | 0 | 1 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 4803 | 89790 | 2016-11-29 | 0 | 0 | ... | 0 | 0 | 0 | 1 |
| 4804 | 89900 | 2016-12-06 | 0 | 0 | ... | 0 | 0 | 0 | 0 |
| 4805 | 90096 | 2016-12-29 | 0 | 1 | ... | 0 | 0 | 1 | 0 |
4806 rows × 10 columns
再次,我们使用左连接将这些新特征合并到检查级别的数据框中。对于得分为 100 的特殊情况,我们将所有新特征设置为0:
`feature_names` `=` `[``'``high_risk``'``,` `'``clean``'``,` `'``food_surface``'``,` `'``vermin``'``,`
`'``storage``'``,` `'``permit``'``,` `'``non_food_surface``'``,` `'``human``'``]`
`def` `left_join_features``(``ins``)``:`
`return` `(``ins``[``[``'``business_id``'``,` `'``timestamp``'``,` `'``score``'``]``]`
`.``merge``(``vio_counts``,` `on``=``[``'``business_id``'``,` `'``timestamp``'``]``,` `how``=``'``left``'``)``)`
`def` `zero_features_for_perfect_scores``(``ins``)``:`
`ins` `=` `ins``.``copy``(``)`
`ins``.``loc``[``ins``[``'``score``'``]` `==` `100``,` `feature_names``]` `=` `0`
`return` `ins`
`ins_and_vios` `=` `(``ins2016``.``pipe``(``left_join_features``)`
`.``pipe``(``zero_features_for_perfect_scores``)``)`
`ins_and_vios``.``head``(``3``)`
| business_id | timestamp | score | high_risk | ... | storage | permit | non_food_surface | human | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 19 | 2016-05-13 | 94 | 0.0 | ... | 0.0 | 1.0 | 1.0 | 1.0 |
| 1 | 24 | 2016-10-05 | 98 | 0.0 | ... | 0.0 | 0.0 | 1.0 | 0.0 |
| 2 | 24 | 2016-03-11 | 96 | 0.0 | ... | 0.0 | 0.0 | 2.0 | 0.0 |
3 rows × 11 columns
要查看每个违规类别与分数的关系,我们可以制作一系列箱线图,比较包含和不包含每个违规的分数分布。由于我们这里关注的是数据的模式,而不是可视化代码,我们隐藏了代码(您可以在网上查看更大的图像):

概要
数据整理是数据分析的重要组成部分。如果没有数据整理,我们可能会忽略数据中可能导致未来分析严重后果的问题。本章涵盖了几个在几乎每一次分析中都会使用的重要数据整理步骤。
在将数据读入数据框后,我们描述了在数据集中寻找什么。质量检查帮助我们发现数据中的问题。为了找到不良和缺失值,我们可以采取许多方法:
-
检查摘要统计数据、分布和值计数。第十章 提供了如何使用可视化和摘要统计检查数据质量的示例和指导。我们在这里简要提到了几种方法。特征中唯一值计数的表格可以揭示意外编码和倾斜分布,其中一个选项是罕见的。百分位数可以帮助揭示具有异常高(或低)值的比例。
-
使用逻辑表达式来识别数值超出范围或关系失调的记录。仅计算未通过质量检查的记录数量可以快速显示问题的规模。
-
检查具有特定特征中问题值的整个记录。有时,在 CSV 格式文件中逗号放错位置时,整个记录会混乱。或者该记录可能代表一个不寻常的情况(例如在房屋销售数据中包含牧场),您需要决定是否应该包含在您的分析中。
-
参考外部来源以找出异常的原因。
本章的最大收获是对数据保持好奇心。寻找能揭示数据质量的线索。找到的证据越多,您对发现的结果越有信心。如果发现问题,请深入挖掘。尝试理解和解释任何异常现象。对数据的深入了解将帮助您评估您发现的问题是可以忽略或修正的小问题,还是可能严重影响数据可用性的问题。这种好奇心思维与探索性数据分析密切相关,这是下一章的主题。
^(1) 这意味着每个值使用 64 位内存,并且精确到纳秒(简称 ns)。
第十章:探索性数据分析
50 多年前,John Tukey 热情推广了一种与置信区间、假设检验和建模的正式世界不同的数据分析方法。今天,Tukey 的探索性数据分析(EDA)被广泛应用。Tukey 描述 EDA为处理数据的哲学方法:
探索性数据分析是积极的,而不是被动的描述,真正强调发现意外的重要性。
作为一名数据科学家,您将希望在数据生命周期的每个阶段中使用 EDA,从检查数据质量到准备形式建模,再到确认您的模型是否合理。确实,在描述的工作中第九章清理和转换数据过程中,EDA 在指导我们的质量检查和转换中起到了重要作用。
在 EDA 中,我们进入一个发现的过程,不断提出问题,深入探讨未知领域来探索想法。我们使用图表来揭示数据的特征,检查值的分布,并揭示不能从简单的数值摘要中检测到的关系。这种探索包括转换、可视化和总结数据,以建立和确认我们的理解,识别和解决数据可能存在的问题,并为随后的分析提供信息。
EDA 很有趣!但需要实践。学习如何进行 EDA 的最佳方法之一是从他人那里学习,因为他们描述他们在探索数据时的思维过程,我们在本书的例子和案例研究中试图揭示 EDA 思维。
EDA(探索性数据分析)可以提供宝贵的见解,但您需要谨慎对待您所得出的结论。重要的是要认识到,EDA 可能会影响您分析的偏见。EDA 是一个筛选过程和决策过程,可能会影响您后续基于模型的发现的可复制性。有足够的数据,如果您仔细观察,通常可以挖掘出一些完全虚假的有趣内容。
EDA 在科学可重复性危机中的角色已被注意到,数据科学家已经警告不要过度使用它。例如,Gelman 和 Loken指出:
即使在已对给定数据进行了单一分析的情况下,多重比较[数据挖掘]的问题也会出现,因为对变量组合、案例包含和排除、变量转换、在没有主效应的情况下进行交互测试以及分析中的许多其他步骤的不同选择都可能与不同的数据发生。
最佳实践是报告并提供您的 EDA 代码,以便他人了解您所做的选择和您在了解数据过程中采取的路径。
关于可视化的主题分布在三章之间。在第九章中,我们使用图表来辅助我们进行数据整理。那里的图表很基础,发现也很直接。我们没有深入探讨解释和选择图表的问题。在本章中,我们将花更多时间学习如何选择合适的图表并进行解释。由于我们的目标是在执行探索性数据分析时快速生成图表,通常采用绘图函数的默认参数设置。在第十一章,我们将提供制作有效和信息丰富的图表的指南,并提供建议,说明如何使我们的视觉论证清晰和令人信服。
根据Tukey,可视化在探索性数据分析中是核心:
数据中最大的收益来自于惊喜……意外的情况最好通过图片来引起我们的注意。
要制作这些图片,我们需要选择适当类型的图表,我们的选择取决于已收集的数据类型。特征类型与图表选择之间的映射是下一节的主题。然后,我们详细描述如何“读取”图表,要查找的内容以及如何解释所见内容。我们首先讨论单特征图表要查找的内容,然后关注两个特征之间的关系,最后描述三个或更多特征的图表。在介绍了探索性数据分析的可视化工具后,我们提供了执行探索性数据分析的指南,并按照这些指南的步骤进行示例演练。
特征类型
在制作探索性图表或任何图表之前,检查特征(或特征),并确定其特征类型是个好主意。(有时我们将特征称为变量,其类型称为变量类型。)尽管有多种分类特征类型的方法,在本书中,我们考虑三种基本类型。有序和名义数据是分类数据的子类型。分类数据的另一个名称是定性。相反,我们还有定量特征:
名义
代表“命名”类别的特征,其中类别没有自然顺序,称为名义。例如政党隶属(民主党、共和党、绿党、其他)、狗的类型(牧群、猎犬、非体育、体育、梗类、玩具、工作类)和计算机操作系统(Windows、macOS、Linux)。
有序
表示有序类别的测量称为有序测量。有序特征的例子包括 T 恤尺寸(小号、中号、大号)、Likert 量表响应(不同意、中立、同意)和教育水平(高中、大学、研究生院)。重要的是要注意,对于有序特征,例如小号和中号之间的差异不一定等同于中号和大号之间的差异。此外,连续类别之间的差异可能甚至无法量化。考虑餐厅评论中的星级数量及一颗星的含义与两颗星之间的含义。
定量
代表数值测量或数量的数据被称为定量数据。例如,以厘米为单位测量的身高,以美元报告的价格和以公里为单位测量的距离。定量特征可以进一步分为离散,意味着特征只能有几个可能值,以及连续,意味着理论上数量可以测量到任意精度。家庭中兄弟姐妹的数量采用离散的值集(例如 0, 1, 2, …, 8)。相比之下,身高理论上可以报告到任意数量的小数位数,因此我们认为它是连续的。确定数量是离散还是连续没有硬性规定。在某些情况下,这可能是一种判断,而在其他情况下,我们可能希望有意将连续特征视为离散特征。
特征类型与数据存储类型不同。pandas 的每一列 DataFrame 都有自己的存储类型。这些类型可以是整数、浮点数、布尔值、日期时间格式、类别和对象(Python 中以指向字符串的指针存储可变长度的字符串)。我们使用术语特征类型来指代信息的概念性概念,使用术语存储类型来指代计算机中信息的表示。
一个以整数存储的特征可以代表名义数据,字符串可以是定量的(比如 "\$100.00"),在实践中,布尔值通常表示只有两种可能值的名义特征。
注意
pandas 将存储类型称为 dtype,这是数据类型的简称。我们在这里避免使用 数据类型 这个术语,因为它可能会与存储类型和特征类型混淆。
为了确定特征类型,我们经常需要查阅数据集的数据字典或代码簿。数据字典是与数据一起包含的文件,描述了数据表中每一列代表的内容。在以下示例中,我们查看了关于各种狗品种的数据框的列的存储和特征类型,并且发现存储类型通常不能很好地指示字段中包含的信息类型。
示例:狗品种
我们使用 美国肯尼尔俱乐部 (AKC) 的注册狗品种数据来介绍与探索数据分析相关的各种概念。成立于 1884 年的非营利组织 AKC 的宗旨是“推动纯种狗的研究、繁殖、展示、运动和维护”。AKC 组织了诸如全国锦标赛、敏捷邀请赛和服从经典等活动,混种狗在大多数活动中也可以参与。信息美化 网站提供了来自 AKC 关于 172 种狗品种的信息数据集。其可视化作品 最佳展示 包含了多种品种的特征,非常有趣。
AKC 数据集包含多种不同类型的特征,我们已提取了一些显示各种信息类型的特征。这些特征包括品种的名称;其寿命、体重和身高;以及其他信息,例如其适合儿童的程度和学习新技巧所需的重复次数。数据集中的每条记录都是一种狗的品种,提供的信息意在典型代表该品种。让我们将数据读入数据框中:
dogs = pd.read_csv('data/akc.csv')
dogs
| 品种 | 组 | 得分 | 寿命 | ... | 大小 | 重量 | 身高 | 重复次数 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 边境牧羊犬 | 牧羊 | 3.64 | 12.52 | ... | 中型 | NaN | 51.0 | <5 |
| 1 | 边境梗 | 梗类犬 | 3.61 | 14.00 | ... | 小型 | 6.0 | NaN | 15-25 |
| 2 | 布列塔尼犬 | 狩猎 | 3.54 | 12.92 | ... | 中型 | 16.0 | 48.0 | 5-15 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 169 | 电线狐狸梗 | 梗类犬 | NaN | 13.17 | ... | 小型 | 8.0 | 38.0 | 25-40 |
| 170 | 硬毛指示格里芬猎犬 | 狩猎 | NaN | 8.80 | ... | 中型 | NaN | 56.0 | 25-40 |
| 171 | 墨西哥无毛犬 | 非狩猎 | NaN | NaN | ... | 中型 | NaN | 42.0 | NaN |
172 rows × 12 columns
粗略看一下表格,品种、组和大小似乎是字符串,其他列则是数字。这里显示的数据框摘要提供了索引、名称、非空值计数和每列的dtype:
`dogs``.``info``(``)`
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 172 entries, 0 to 171
Data columns (total 12 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 breed 172 non-null object
1 group 172 non-null object
2 score 87 non-null float64
3 longevity 135 non-null float64
4 ailments 148 non-null float64
5 purchase_price 146 non-null float64
6 grooming 112 non-null float64
7 children 112 non-null float64
8 size 172 non-null object
9 weight 86 non-null float64
10 height 159 non-null float64
11 repetition 132 non-null object
dtypes: float64(8), object(4)
memory usage: 16.2+ KB
这个数据框的几列具有数值计算类型,如float64,这意味着该列可以包含除整数以外的数字。我们还确认pandas将字符串列编码为object类型,而不是string类型。请注意,我们错误地猜测重复次数是定量的。仔细观察数据表后,我们发现重复次数包含范围的字符串值,如"<5"、"15-25"和"25-40",因此这个特征是有序的。
注意
在计算机体系结构中,浮点数或简称“float”指的是可以具有小数部分的数。我们不会在本书中深入探讨计算机体系结构,但在影响术语时,我们会指出它,就像在这种情况下一样。dtype float64 表示该列包含的是在计算机内存中存储时每个占用 64 位空间的十进制数。
另外,pandas 使用了优化的存储类型来存储数值数据,如 float64 或 int64。然而,对于像字符串、字典或集合这样的 Python 对象,它没有优化,因此这些数据类型都存储为 object dtype。这意味着存储类型是不明确的,但在大多数情况下,我们知道 object 列包含字符串或其他某种 Python 类型。
在查看列存储类型时,我们可能会猜测 ailments 和 children 是定量特征,因为它们存储为 float64 dtype。但让我们统计它们的唯一值:
`display_df``(``dogs``[``'``ailments``'``]``.``value_counts``(``)``,` `rows``=``8``)`
ailments
0.0 61
1.0 42
2.0 24
4.0 10
3.0 6
5.0 3
9.0 1
8.0 1
Name: count, dtype: int64
`dogs``[``'``children``'``]``.``value_counts``(``)`
children
1.0 67
2.0 35
3.0 10
Name: count, dtype: int64
ailments 和 children 都只接受几个整数值。children 的值为 3.0 或 ailments 的值为 9.0 代表什么意思?我们需要更多信息才能搞清楚。列名及其在数据框中的存储方式不足以解释。因此,我们需要查阅表 10-1 中显示的数据字典。
表 10-1. AKC 狗品种代码手册
| 特征 | 描述 |
|---|---|
breed |
犬品种,例如边境牧羊犬、达尔马提亚犬、威尔士波音犬 |
group |
美国典狗俱乐部分组(牧羊犬、猎犬、非运动犬、运动犬、梗类犬、玩具犬、工作犬) |
score |
AKC 评分 |
longevity |
典型寿命(年) |
ailments |
严重遗传疾病的数量 |
purchase_price |
从 puppyfind.com 平均购买价格 |
grooming |
每次需要的美容频率:1 = 天,2 = 周,3 = 几周 |
children |
对儿童的适合度:1 = 高,2 = 中,3 = 低 |
size |
尺寸:小型、中型、大型 |
weight |
典型体重(公斤) |
height |
肩膀高度(厘米) |
repetition |
理解新命令所需的重复次数:<5,5-15,15-25,25-40,40-80,>80 |
尽管数据字典未明确指定特征类型,但描述足以让我们了解到,children 特征代表品种对儿童的适合程度,1.0 的值对应于“高”适合度。我们还发现 ailments 特征表示该品种狗犬通常具有的严重遗传疾病的数量。根据代码手册,我们将 children 视为分类特征,即使它存储为浮点数,由于低 < 中 < 高,该特征是有序的。由于 ailments 是一个计数,我们将其视为定量(数值)类型,并且对于某些分析,我们进一步定义它为离散型,因为 ailments 可能的取值只有几个。
数据代码表还确认了score、longevity、purchase_price、weight和height特征是定量的。这里的想法是,数值特征具有可以通过差异进行比较的值。可以说吉娃娃的寿命通常比腊肠犬长大约四年(16.5 年对 12.6 年)。另一个检查是是否有意义比较值的比率:一只腊肠犬通常比吉娃娃重大约五倍(11 公斤对 2 公斤)。所有这些定量特征都是连续的;只有ailments是离散的。
对于breed、group、size和repetition,数据字典描述表明这些特征是定性的。每个变量具有不同但常见的特征,值得进一步探索。我们通过检查各个特征的唯一值计数来做到这一点。我们从breed开始:
`dogs``[``'``breed``'``]``.``value_counts``(``)`
breed
Border Collie 1
Great Pyrenees 1
English Foxhound 1
..
Saluki 1
Giant Schnauzer 1
Xoloitzcuintli 1
Name: count, Length: 172, dtype: int64
breed特征有 172 个唯一值,与数据框中的记录数相同,因此我们可以将breed视为数据表的主键。按设计,每个犬种有一条记录,而这个breed特征决定了数据集的粒度。虽然breed也被视为名义特征,但分析它没有真正的意义。我们确实希望确认所有值都是唯一且干净的,但除此之外,我们只会用它来标记绘图中的异常值。
接下来,我们检查group特征:
`dogs``[``'``group``'``]``.``value_counts``(``)`
group
terrier 28
sporting 28
working 27
hound 26
herding 25
toy 19
non-sporting 19
Name: count, dtype: int64
该特征有七个唯一值。由于被标记为“运动型”的犬种与被认为是“玩具型”的犬种在多方面有所不同,这些类别不能轻易归纳为一种顺序。因此,我们将group视为名义特征。名义特征甚至不提供差异方向上的含义。
接下来,我们检查size的唯一值及其计数:
`dogs``[``'``size``'``]``.``value_counts``(``)`
size
medium 60
small 58
large 54
Name: count, dtype: int64
size特征具有自然的顺序:小 < 中 < 大,因此它是序数的。我们不知道“小”类别是如何确定的,但我们知道小型品种在某种意义上比中型品种小,而中型品种又比大型品种小。我们有一个顺序,但概念上差异和比率没有意义。
repetition特征是定量变量的一个示例,已经被折叠成类别,变为序数。数据代码表告诉我们,repetition是新命令需要重复几次才能让狗理解的次数:
`dogs``[``'``repetition``'``]``.``value_counts``(``)`
repetition
25-40 39
15-25 29
40-80 22
5-15 21
80-100 11
<5 10
Name: count, dtype: int64
数值值已被汇总为<5、5-15、15-25、25-40、40-80、80-100,注意这些类别的宽度不同。第一个有 5 次重复,而其他的宽度为 10、15 和 40 次重复。顺序清晰,但从一个类别到下一个的间隔不是相同大小。
现在我们已经再次检查了变量中的值与代码手册中描述的值,我们可以扩充数据字典,包括关于特征类型的额外信息。我们修订后的字典出现在表 10-2 中。
表 10-2. 修订后的 AKC 犬种代码手册
| 特征 | 描述 | 特征类型 | 存储类型 |
|---|---|---|---|
breed |
犬种,例如边境牧羊犬、达尔马提亚犬、威尔士激罗犬 | 主键 | 字符串 |
group |
AKC 分组(牧羊犬、猎犬、非运动犬、运动犬、梗类犬、玩具犬、工作犬) | 定性 - 名义 | 字符串 |
| score | AKC score |
定量 | 浮点数 |
longevity |
典型寿命(年) | 定量 | 浮点数 |
ailments |
严重遗传疾病数量(0, 1, …, 9) | 定量 - 离散 | 浮点数 |
purchase_price |
从puppyfind.com平均购买价格 | 定量 | 浮点数 |
grooming |
每隔多久梳理一次:1 = 天、2 = 周、3 = 几周 | 定性 - 序数 | 浮点数 |
children |
适合儿童程度:1 = 高、2 = 中、3 = 低 | 定性 - 序数 | 浮点数 |
size |
尺寸:小、中、大 | 定性 - 序数 | 字符串 |
weight |
典型体重(公斤) | 定量 | 浮点数 |
height |
肩膀高度(厘米) | 定量 | 浮点数 |
repetition |
理解新指令所需的重复次数:<5、5–15、15–25、25–40、40–80、80–100 | 定性 - 序数 | 字符串 |
对 AKC 数据特征类型的更清晰理解有助于我们进行质量检查和转换。我们在第九章中讨论了转换,但还有一些未涉及的额外转换。这些与定性特征的类别有关,我们接下来将对其进行描述。
转换定性特征
无论特征是名义还是序数,我们可能会发现重新标记类别使其更具信息性,合并类别以简化可视化,甚至将数值特征转换为序数以便专注于特定的过渡点都是有用的。我们解释了何时进行每种转换,并给出了示例。
重新标记类别
摘要统计数据,如平均值和中位数,对定量数据是有意义的,但通常对定性数据不是。例如,计算玩具品种的平均价格($687)是有意义的,但是关于儿童的“平均”适合性不是。然而,如果我们要求它,pandas 将乐意计算children列中值的平均值:
`# Don't use this value in actual data analysis!`
`dogs``[``"``children``"``]``.``mean``(``)`
1.4910714285714286
相反,我们希望考虑children的 1、2 和 3 的分布。
注意
存储类型和特征类型之间的主要区别在于,存储类型表示我们可以编写代码来计算哪些操作,而特征类型表示对于数据何种操作是有意义的。
我们可以通过用它们的字符串描述替换数字来转换孩子们。将 1、2 和 3 改为高、中和低,使得很容易识别出孩子们是分类的。使用字符串,我们不会被诱惑计算平均值,类别会与它们的含义相联系,并且绘图的标签默认具有合理的值。例如,让我们只关注玩具品种,并制作适合儿童的酒吧图。首先,我们创建一个新的列,用字符串表示适合性的类别:
`kids` `=` `{``1``:``"``high``"``,` `2``:``"``medium``"``,` `3``:``"``low``"``}`
`dogs` `=` `dogs``.``assign``(``kids``=``dogs``[``'``children``'``]``.``replace``(``kids``)``)`
`dogs`
| 品种 | 分组 | 得分 | 寿命 | ... | 重量 | 身高 | 重复 | 孩子们 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 边境牧羊犬 | 牧羊犬 | 3.64 | 12.52 | ... | NaN | 51.0 | <5 | 低 |
| 1 | 边境泰瑞尔 | 梗犬 | 3.61 | 14.00 | ... | 6.0 | NaN | 15-25 | 高 |
| 2 | 布列塔尼 | 运动型 | 3.54 | 12.92 | ... | 16.0 | 48.0 | 5-15 | 中等 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 169 | 线毛福克斯梗 | 梗犬 | NaN | 13.17 | ... | 8.0 | 38.0 | 25-40 | NaN |
| 170 | 线毛指示格里芬 | 运动型 | NaN | 8.80 | ... | NaN | 56.0 | 25-40 | NaN |
| 171 | 什么狗品种 | 非运动型 | NaN | NaN | ... | NaN | 42.0 | NaN | NaN |
172 rows × 13 columns
然后我们可以制作玩具品种中每个适合性类别的计数条形图:
`toy_dogs` `=` `dogs``.``query``(``'``group ==` `"``toy``"``'``)``.``groupby``(``'``kids``'``)``.``count``(``)``.``reset_index``(``)`
`px``.``bar``(``toy_dogs``,` `x``=``'``kids``'``,` `y``=``'``breed``'``,` `width``=``350``,` `height``=``250``,`
`category_orders``=``{``"``kids``"``:` `[``"``low``"``,` `"``medium``"``,` `"``high``"``]``}``,`
`labels``=``{``"``kids``"``:` `"``Suitability for children``"``,` `"``breed``"``:` `"``count``"``}``)`

我们并不总是希望用字符串表示分类数据。字符串通常需要更多的存储空间,如果数据集包含许多分类特征,可能会大大增加数据集的大小。
有时候,一个定性特征有许多类别,我们更喜欢对数据进行更高级别的查看,因此我们会合并类别。
折叠类别
让我们创建一个名为play的新列,以表示“目的”是玩耍的狗的组(或不是)。 (这是一个虚构的区分,用于演示目的。)此类别由玩具和非运动品种组成。 新特征play是将特征group折叠的转换:将玩具和非运动组合成一个类别,剩下的类别放在第二个非玩耍类别中。 布尔(bool)存储类型对于指示此特征的存在或不存在非常有用:
`with_play` `=` `dogs``.``assign``(``play``=``(``dogs``[``"``group``"``]` `==` `"``toy``"``)` `|`
`(``dogs``[``"``group``"``]` `==` `"``non-sporting``"``)``)`
将一个两类定性特征表示为布尔值有一些优点。 例如,play的平均值是有意义的,因为它返回True值的比例。 当布尔值用于数字计算时,True变为 1,False变为 0:
`with_play``[``'``play``'``]``.``mean``(``)`
0.22093023255813954
此存储类型为我们提供了计算布尔值的计数和平均值的快捷方式。 在第十五章中,我们将看到它对建模也是一个方便的编码。
有时候,例如当一个离散的定量特征有一个长尾时,我们希望截断更高的值,这将定量特征转化为序数。接下来我们将描述这一点。
将定量转换为序数
最后,我们有时会发现另一种有用的转换是将数值转换为类别。例如,我们可以将ailments中的值合并为类别:0, 1, 2, 3, 4+。换句话说,我们将ailments从定量特征转换为序数特征,映射为 0→0, 1→1, 2→2, 3→3,任何值大于等于 4→4+。我们可能希望进行这种转换,因为几乎没有品种有三种以上的遗传疾病。这种简化可以更清晰和足够用于调查。
注意
截至 2022 年底,pandas也实现了一个设计用于定性数据的category dtype。然而,这种存储类型目前在可视化和建模库中尚未广泛采用,限制了其实用性。因此,我们不将定性变量转换为category dtype。我们预计将来的读者可能会希望在更多库支持它之后使用category dtype。
当我们将定量特征转换为序数时,我们会丢失信息。我们无法回到原来的状态。也就是说,如果我们知道某品种的疾病数为四个或更多,我们无法重新创建实际的数值。当我们合并类别时也是同样的情况。因此,保留原始特征是一个良好的实践。如果需要检查我们的工作或更改类别,我们可以记录和重新创建我们的步骤。
一般来说,特征类型帮助我们确定哪种绘图最合适。接下来我们讨论特征类型与绘图之间的映射。
特征类型的重要性
特征类型指导我们进行数据分析。它们有助于指定我们可以对数据应用的操作、可视化和模型。表 10-3 将特征类型匹配到通常适合的各种绘图类型。变量是定量还是定性通常决定了可以制作的可选绘图集,尽管也有例外情况。决策的其他因素包括观察数量以及特征是否仅取几个不同的值。例如,对于离散定量变量,我们可能制作柱状图而不是直方图。
表 10-3. 将特征类型映射到绘图
| 特征类型 | 维度 | 绘图 |
|---|---|---|
| 定量 | 一个特征 | 地毯图,直方图,密度曲线,箱线图,小提琴图 |
| 定性 | 一个特征 | 柱状图,点图,线图,饼图 |
| 定量 | 两个特征 | 散点图,平滑曲线,等高线图,热力图,分位数-分位数图 |
| 定性 | 两个特征 | 并排柱状图,马赛克图,叠加线条 |
| 混合 | 两个特征 | 叠加密度曲线,并排箱线图,叠加平滑曲线,分位数-分位数图 |
特征类型还帮助我们决定计算哪种摘要统计数据。对于定性数据,我们通常不计算均值或标准差,而是计算每个类别中记录的数量、比例或百分比。对于定量特征,我们计算均值或中位数作为中心测量,以及标准差或四分位间距(第 75 百分位至第 25 百分位)作为扩展测量。除了四分位数外,我们可能还会发现其他百分位数有信息意义。
注意
第n百分位数是使得n%的数据值不超过它的值q。值q可能不唯一,有几种方法可以选择可能的唯一值。有足够的数据时,这些定义之间应该几乎没有区别。
在 Python 中计算百分位数时,我们更喜欢使用:
`np``.``percentile``(``data``,` `method``=``'``lower``'``)`
当探索数据时,我们需要知道如何解释我们的图形显示的形状。接下来的三节将指导这种解释。我们还通过示例介绍了表格 10-3 中列出的许多图表类型。其他类型在第十一章中介绍。
如何选择分布特征
特征的视觉展示可以帮助我们看到观察结果中的模式;它们通常比直接检查数字或字符串本身要好得多。简单的毯子图将每个观察结果定位为沿轴上的“纱线”。“毯子”图在我们有少量观察结果时可能很有用,但是当我们有 100 个值时,很快就难以区分高密度(人口最多)区域,比如说。以下图显示了大约 150 个品种寿命值沿直方图顶部的毯子图:
`px``.``histogram``(``dogs``,` `x``=``"``longevity``"``,` `marginal``=``"``rug``"``,` `nbins``=``20``,`
`histnorm``=``'``percent``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``longevity``'``:``'``Typical lifespan (yr)``'``}``)`

尽管我们可以看到一个异常大的值大于 16 在毯子图中,但很难比较其他区域的纱线密度。相反,直方图为各种寿命值的观察密度提供了更好的感知。类似地,下图中显示的密度曲线描绘了高低密度区域的图像:

在直方图和密度曲线中,我们可以看到寿命分布是不对称的。在大约 12 年处有一个主要模式,9 到 11 年的范围内有一个肩膀,这意味着虽然 12 年是最常见的寿命,但许多品种的寿命比 12 年短一到三年。我们还看到大约 7 岁的小次要模式,以及一些寿命长达 14 到 16 年的品种。
当解读直方图或密度曲线时,我们会检查分布的对称性和偏斜度;高频区域(模式)的数量、位置和大小;尾部长度(通常与钟形曲线进行比较);未观察到值的间隙;以及异常大或异常值。图 10-1 提供了一个具有这些特征的分布的描述。当我们读取分布时,我们将在图中看到的特征与所测量的数量联系起来。

图 10-1. 示例密度图,根据其形状识别分布的特征
作为另一个例子,犬种中遗传病数量的分布如下直方图所示:
`bins` `=` `[``-``0.5``,` `0.5``,` `1.5``,` `2.5``,` `3.5``,` `9.5``]`
`g` `=` `sns``.``histplot``(``data``=``dogs``,` `x``=``"``ailments``"``,` `bins``=``bins``,` `stat``=``"``density``"``)`
`g``.``set``(``xlabel``=``'``Number of ailments``'``,` `ylabel``=``'``density``'``)``;`

当遗传病数量为 0 时,意味着这个品种没有遗传病,当为 1 时对应一个遗传病,以此类推。从直方图中可以看出,遗传病的分布是单峰的,峰值在 0 处。我们还可以看到,分布向右严重倾斜,右尾长,表明很少的品种有四到九种遗传病。虽然是定量的,但遗传病是离散的,因为只有少数整数值是可能的。因此,我们将区间设定在整数上,例如从 1.5 到 2.5 的区间只包含有两种病的品种。我们还扩宽了最右边的区间。我们将所有有四到九种病的品种归为一组。当区间计数较小时,我们使用更宽的区间进一步平滑分布,因为我们不想过多关注小数字的波动。在这种情况下,没有品种有六或七种病,但有些有四、五、八或九种病。
接下来,我们指出直方图和密度曲线的三个关键方面:y 轴应该使用密度刻度,平滑隐藏了不重要的细节,直方图与条形图基本上是不同的。我们依次描述每个方面:
y 轴上的密度
长寿和遗传病直方图中的 y 轴都标记为“密度”。这个标签意味着直方图中条的总面积等于 1。简单来说,我们可以把直方图想象成一个天际线,高楼密集的地方人口更多,我们可以从矩形的面积中找到任意区间的观察比例。例如,在遗传病直方图中,从 3.5 到 9.5 的矩形大约包含了 10%的品种:6(宽度)× 0.017(高度)大约是 0.10。如果所有的区间宽度相同,那么无论 y 轴表示计数还是密度,天际线看起来都会一样。但是在这个直方图中将 y 轴改为计数会给出一个误导性的图片,右尾的一个非常大的矩形。
平滑处理
使用直方图时,我们隐藏了地毯图中个别纱线的细节,以便查看分布的一般特征。平滑是指这个过程,将点集替换为矩形;我们选择不展示数据集中的每个点,以揭示更广泛的趋势。我们可能希望平滑这些点,因为这是一个样本,我们相信观察到的值附近的其他值是合理的,和/或者我们想要关注的是一般结构而不是个别观测值。没有地毯,我们无法确定一个箱子中的点在哪里。平滑的密度曲线,就像我们之前展示的关于寿命的那个,也具有总面积和为 1 的属性。密度曲线使用平滑的核函数来展开个别纱线,有时被称为核密度估计(KDE)。
条形图 ≠ 直方图
对于定性数据,条形图与直方图起着类似的作用。条形图以视觉方式展示不同组的“流行度”或频率。然而,我们不能像直方图那样解释条形图的形状。在此设置中,尾部和对称性没有意义。此外,类别的频率由条的高度表示,宽度不包含信息。接下来的两个条形图显示了关于各个类别品种数量的相同信息;它们唯一的区别在于条的宽度。在极端情况下,最右边的图表完全消除了条,并通过单个点来表示每个计数。在没有连接线的情况下,这种图称为点图。阅读这个线图时,我们可以看到只有少数品种不适合儿童:
`kid_counts` `=` `dogs``.``groupby``(``[``'``kids``'``]``)``.``count``(``)`
`kid_counts` `=` `kid_counts``.``reindex``(``[``"``high``"``,` `"``medium``"``,` `"``low``"``]``)`

现在我们已经讨论了如何检查单个特征的分布情况,接下来我们转向当我们想要查看两个特征及其关系时的情况。
关系中要寻找的内容
当我们研究多个变量时,我们不仅要检查它们的分布,还要检查它们之间的关系。在这一部分中,我们考虑特征对并描述需要寻找的内容。根据特征类型,表 10-3 提供了绘制图表类型的指南。对于两个特征来说,类型的组合(全是定量、全是定性或混合)很重要。我们逐个考虑每种组合。
两个定量特征
如果两个特征都是定量的,那么我们通常用散点图来考察它们的关系。散点图中的每个点表示一个观测值的一对数值的位置。因此,我们可以将散点图视为一个二维的地毯图。
在散点图中,我们寻找线性和简单的非线性关系,并检查这些关系的强度。我们还要看看是否将一个或两个特征进行变换会导致线性关系。
下面的散点图展示了狗品种的体重和身高(两者都是定量的):
`px``.``scatter``(``dogs``,` `x``=``'``height``'``,` `y``=``'``weight``'``,`
`marginal_x``=``"``rug``"``,` `marginal_y``=``"``rug``"``,`
`labels``=``{``'``height``'``:``'``Height (cm)``'``,` `'``weight``'``:``'``Weight (kg)``'``}``,`
`width``=``350``,` `height``=``250``)`

我们观察到,身高高于平均水平的狗往往体重也高于平均水平。这种关系呈非线性:对于较高的狗,体重的变化速度比对于较矮的狗要快。事实上,如果我们将狗看作基本上呈盒状,那么对于类似比例的盒子,盒子内的内容的重量与其长度呈立方关系是有道理的。
需要注意的是,两个单变量图缺少双变量图中的信息——关于两个特征如何一起变化的信息。实际上,两个定量特征的直方图不包含足够的信息来创建特征的散点图。我们必须谨慎行事,不要过多解读一对单变量图。相反,我们需要使用表 10-3 中适当行中列出的图之一(散点图,平滑曲线,等高线图,热图,分位数-分位数图),以了解两个定量特征之间的关系。
当一个特征是数值的而另一个是定性的时候,表 10-3 提出了不同的建议。我们接下来描述它们。
一个定性和一个定量变量
要检验定量和定性特征之间的关系,我们通常使用定性特征将数据分成组,并比较这些组中定量特征的分布。例如,我们可以比较小型、中型和大型狗品种的身高分布,同时绘制三个重叠的密度曲线:

我们看到小型和中型品种的身高分布都呈双峰性,每组左峰较大。此外,小型和中型组的身高范围比大型品种组要大。
并排的箱线图提供了跨组分布的类似比较。箱线图提供了一种简单的方法,可以粗略了解分布情况。同样,小提琴图沿轴为每个组绘制密度曲线。曲线被翻转以创建对称的“小提琴”形状。小提琴图旨在弥合密度曲线和箱线图之间的差距。我们为品种的高度创建了箱线图(左)和小提琴图(右),给出了大小标签:

狗的三个身高箱线图,每种大小的狗一个,清楚地表明大小分类是基于身高的,因为组间的身高范围几乎没有重叠。(由于平滑处理,这在密度曲线中并不明显。)在这些箱线图中我们看不到小型和中型狗群体的双峰性,但我们仍然可以看到大型狗与其他两组相比具有较窄的分布。
箱线图(也称为箱线图)是对分布的几个重要统计数据的视觉总结。箱子代表第 25 百分位数、中位数和第 75 百分位数,箱须显示尾部,还绘制了异常大或小的值。箱线图不能像直方图或密度曲线那样显示形状。它们主要显示对称性和偏斜、长/短尾巴以及异常大/小的值(也称为异常值)。
图 10-2 是对箱线图各部分的视觉解释。从中位数不在箱子中间可以看出不对称性,箱须的长度显示了尾部的大小,超出箱须的点显示了异常值。最大值被视为异常值,因为它出现在右侧箱须之外。

图 10-2. 带有标记的箱线图摘要统计的图示
当我们研究两个定性特征之间的关系时,我们的重点在于比例,接下来我们会详细解释。
两个定性特征
对于两个定性特征,我们经常比较一个特征在另一个特征定义的子组中的分布。实际上,我们保持一个特征不变,并绘制另一个特征的分布。为此,我们可以使用用于显示一个定性特征分布的一些相同图表,例如线图或条形图。举个例子,让我们来研究品种对儿童适宜性和品种大小之间的关系。
要研究这两个定性特征之间的关系,我们计算三组比例(分别对应低、中、高适宜性)。在每个适宜性类别中,我们找出小型、中型和大型狗的比例。这些比例显示在下表中。注意,每列总和为 1(相当于 100%):
`prop_table_t`
| 儿童 | 高 | 中 | 低 |
|---|---|---|---|
| 大小 | |||
| --- | --- | --- | --- |
| 大型 | 0.37 | 0.29 | 0.1 |
| 中等 | 0.36 | 0.34 | 0.2 |
| 小型 | 0.27 | 0.37 | 0.7 |
下面的线图提供了这些比例的可视化。每个适宜性级别都有一条“线”(连接的点集)。连接的点显示了适宜性类别内大小的分布。我们看到,适宜性低的品种主要是小型犬:
`fig` `=` `px``.``line``(``prop_table_t``,` `y``=``prop_table_t``.``columns``,`
`x``=``prop_table_t``.``index``,` `line_dash``=``'``kids``'``,`
`markers``=``True``,` `width``=``500``,` `height``=``250``)`
`fig``.``update_layout``(`
`yaxis_title``=``"``proportion``"``,` `xaxis_title``=``"``Size``"``,`
`legend_title``=``"``Suitability <br>for children``"`
`)`

我们还可以将这些比例呈现为一组并列条形图,如下所示:
`fig` `=` `px``.``bar``(``prop_table_t``,` `y``=``prop_table_t``.``columns``,` `x``=``prop_table_t``.``index``,`
`barmode``=``'``group``'``,` `width``=``500``,` `height``=``250``)`
`fig``.``update_layout``(`
`yaxis_title``=``"``proportion``"``,` `xaxis_title``=``"``Size``"``,`
`legend_title``=``"``Suitability <br>for children``"`
`)`

到目前为止,我们已经涵盖了包含一个或两个特征的可视化。在下一节中,我们将讨论涵盖超过两个特征的可视化。
多元设置中的比较
当我们检查一个分布或者关系时,我们经常希望能够跨数据子群组进行比较。这个在额外因素的条件下进行的过程通常导致涉及三个或更多变量的可视化。在这一节中,我们将解释如何阅读用于可视化多个变量的常用图表。
例如,让我们比较重复类别之间身高和寿命之间的关系。首先,我们将重复(狗学习新命令的典型次数)从六个类别合并为四个:<15、15-25、25-40 和 40+:
`rep_replacements` `=` `{`
`'``80-100``'``:` `'``40+``'``,` `'``40-80``'``:` `'``40+``'``,`
`'``<5``'``:` `'``<15``'``,` `'``5-15``'``:` `'``<15``'``,`
`}`
`dogs` `=` `dogs``.``assign``(`
`repetition``=``dogs``[``'``repetition``'``]``.``replace``(``rep_replacements``)``)`
现在每个组大约有 30 种品种,并且更少的类别使关系更容易解读。这些类别在散点图中由不同形状的符号表示:
`px``.``scatter``(``dogs``.``dropna``(``subset``=``[``'``repetition``'``]``)``,` `x``=``'``height``'``,` `y``=``'``longevity``'``,`
`symbol``=``'``repetition``'``,` `width``=``450``,` `height``=``250``,`
`labels``=``{``'``height``'``:``'``Height (cm)``'``,`
`'``longevity``'``:``'``Typical lifespan (yr)``'``,`
`'``repetition``'``:``'``Repetition``'``}``,`
`)`

如果“重复”特征内部有更多级别,这种图表将很难解释。
分面图提供了显示这三个特征的另一种方法:
`px``.``scatter``(``dogs``.``dropna``(``subset``=``[``'``repetition``'``]``)``,`
`x``=``'``height``'``,` `y``=``'``longevity``'``,` `trendline``=``'``ols``'``,`
`facet_col``=``'``repetition``'``,` `facet_col_wrap``=``2``,`
`labels``=``{``'``height``'``:``'``Height (cm)``'``,`
`'``longevity``'``:``'``Typical lifespan (yr)``'``}``)`

这四个散点图中的每一个展示了不同重复范围内寿命与身高之间的关系。通过分离散点图,我们可以更好地评估两个定量特征之间的关系如何随着子组的变化而变化。我们还可以更轻松地看到每个重复范围内身高和寿命的范围。我们可以看到,较大的品种往往寿命较短。另一个有趣的特征是,这些线的斜率相似,但 40+重复的线大约比其他线低 1.5 年。这些品种的平均寿命比其他重复类别低约 1.5 年,无论其身高如何。
在这里,我们总结了当我们有三个(或更多)特征时进行比较的各种绘图技术:
两个定量和一个定性
我们已经通过散点图演示了这种情况,根据定性特征的类别变化标记,或者通过每个类别的散点图面板。
两个定性和一个定量特征
我们已经看到了根据品种大小的箱线图集合中,我们可以通过并排箱线图比较不同子组的分布的基本形状。当我们有两个或更多定性特征时,我们可以根据其中一个定性特征将箱线图组织成组。
三个定量特征
当我们绘制两个定量特征和一个定性特征时,我们可以使用类似的技术。这次,我们将其中一个定量特征转换为序数特征,其中每个类别通常具有大致相同数量的记录。然后,我们制作其他两个特征的分面散点图。我们再次寻找跨分面的关系相似性。
三个定性特征
当我们检查定性特征之间的关系时,我们会检查在另一个定性特征定义的子组内一个特征的比例。 在上一节中,一个图中的三条线图和并排的条形图都显示了这些比较。 对于三(或更多)个定性特征,我们可以继续根据特征级别的组合细分数据,并使用线图、点图、并排条形图等比较这些比例。 但是这些图往往在进一步细分时变得越来越难以理解。
注意
将可视化分解以查看由定性特征确定的数据子组是否会改变关系是一种良好的做法。 这种技术称为对特征进行“控制”。 当您在散点图中看到一个线性关系有上升趋势,但在散点图的一些或全部方面中却逆转为下降趋势时,您可能会感到惊讶。 这种现象称为“辛普森悖论”。 这种悖论也可能发生在定性特征中。 在伯克利,男性的研究生院录取率高于女性的情况曾是一个著名案例,但在每个项目中单独检查时,录取率更青睐于女性。 问题在于,女性更多地申请了录取率较低的项目。
涉及多个分类变量的比较可能会很快变得复杂,因为类别组合的可能性增多。例如,有 3 × 4 = 12 种大小重复的组合(如果我们保留原始的重复类别,会有 18 种组合)。检查 12 个子组的分布可能会很困难。此外,我们面临的问题是子组中观察数太少。尽管狗数据框中有近 200 行,但一半的大小重复组合观察次数为 10 或更少。 (当一个特征具有缺失值时,会丢失一个观察。)当我们比较与定量数据的关系时,也会出现这种“维度灾难”。仅仅三个定量变量在多面图中的一些散点图很容易观察到有太少的观察以确认两个变量在子组之间的关系形状。
现在我们已经看到了在探索性数据分析中常用的可视化实例,我们继续讨论一些 EDA 的高级指导原则。
探索指南
到目前为止,在本章中,我们介绍了特征类型的概念,看到了特征类型如何帮助确定要制作的图表,并描述了如何在可视化中读取分布和关系。 EDA 依赖于建立这些技能和灵活地发展对数据的理解。
在第九章中,我们开发了数据质量检查和特征转换,以提高它们在数据分析中的实用性,从而展示了 EDA 的实际应用。接下来是一些指导你进行绘图探索数据时的问题:
-
特征 X 的值是如何分布的?
-
特征 X 和特征 Y 之间的关系如何?
-
特征 X 的分布在由特征 Z 定义的子组中是否相同?
-
特征 X 中是否存在任何不寻常的观察?(X,Y)的组合中是否存在?在 Z 的子组中的 X 中是否存在?
当回答每个问题时,重要的是将你的答案与测量的特征和上下文联系起来。采用积极好奇的探索方法也很重要。为了指导你的探索,问自己“接下来怎么办”和“这对于什么有意义”的问题,例如以下问题:
-
你有理由期望一个组/观察结果可能不同吗?
-
为什么你对形状的发现很重要?
-
哪些额外的比较可能为调查增加价值?
-
是否有任何重要的特征可以进行比较/对比?
在这个过程中,重要的是偶尔离开电脑,深思熟虑你的工作。你可能想要阅读关于这个主题的额外文献,或者去找领域内的专家讨论你的发现。例如,对于一个不寻常的观察可能有很好的理由,领域内的人可以帮助澄清并提供更多背景。
我们将在接下来的具体 EDA 示例中将这些准则付诸实践。
示例:房屋销售价格
在最后一节中,我们使用前一节的问题进行探索性分析来引导我们的调查。尽管 EDA 通常从数据整理阶段开始,但出于演示目的,我们处理的数据已经部分清理,以便我们可以专注于探索感兴趣的特征。还要注意,我们没有详细讨论优化可视化的细节;该主题在第十一章中有涵盖。
我们的数据来自旧金山纪事报(SFChron)网站。该数据包括 2003 年 4 月至 2008 年 12 月旧金山地区出售的所有房屋的完整列表。由于我们没有计划将我们的发现推广到时间段和位置之外,我们正在使用的是普查,人口与访问框架相匹配,样本包括整个人口。
至于粒度,每条记录代表了在指定时间段内在旧金山湾区销售的房屋。这意味着如果一套房在这段时间内销售了两次,那么表中将有两条记录。如果旧金山湾区的一套房在此期间没有上市出售,则它不会出现在数据集中。
数据位于数据帧sfh_df中:
`sfh_df`
| 城市 | 邮政编码 | 街道 | 价格 | 卧室数 | 居住面积 | 建筑面积 | 时间戳 | |
|---|---|---|---|---|---|---|---|---|
| 0 | Alameda | 94501.0 | 1001 Post Street | 689000.0 | 4.0 | 4484.0 | 1982.0 | 2004-08-29 |
| 1 | Alameda | 94501.0 | 1001 Santa Clara Avenue | 880000.0 | 7.0 | 5914.0 | 3866.0 | 2005-11-06 |
| 2 | Alameda | 94501.0 | 1001 Shoreline Drive #102 | 393000.0 | 2.0 | 39353.0 | 1360.0 | 2003-09-21 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 521488 | 温莎 | 95492.0 | 9998 Blasi Drive | 392500.0 | NaN | 3111.0 | NaN | 2008-02-17 |
| 521489 | 温莎 | 95492.0 | 9999 Blasi Drive | 414000.0 | NaN | 2915.0 | NaN | 2008-02-17 |
| 521490 | 温莎 | 95492.0 | 999 Gemini Drive | 325000.0 | 3.0 | 7841.0 | 1092.0 | 2003-09-21 |
521491 rows × 8 columns
The dataset does not have an accompanying codebook, but we can determine the features and their storage types by inspection:
`sfh_df``.``info``(``)`
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 521491 entries, 0 to 521490
Data columns (total 8 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 city 521491 non-null object
1 zip 521462 non-null float64
2 street 521479 non-null object
3 price 521491 non-null float64
4 br 421343 non-null float64
5 lsqft 435207 non-null float64
6 bsqft 444465 non-null float64
7 timestamp 521491 non-null datetime64[ns]
dtypes: datetime64ns, float64(5), object(2)
memory usage: 31.8+ MB
Based on the names of the fields, we expect the primary key to consist of some combination of city, zip code, street address, and date.
Sale price is our focus, so let’s begin by exploring its distribution. To develop your intuition about distributions, make a guess about the shape of the distribution before you start reading the next section. Don’t worry about the range of prices, just sketch the general shape.
Understanding Price
It seems that a good guess for the shape of the distribution of sale price might be highly skewed to the right with a few very expensive houses. The following summary statistics confirm this skewness:
`percs` `=` `[``0``,` `25``,` `50``,` `75``,` `100``]`
`prices` `=` `np``.``percentile``(``sfh_df``[``'``price``'``]``,` `percs``,` `method``=``'``lower``'``)`
`pd``.``DataFrame``(``{``'``price``'``:` `prices``}``,` `index``=``percs``)`
| price | |
|---|---|
| 0 | 22000.00 |
| 25 | 410000.00 |
| 50 | 555000.00 |
| 75 | 744000.00 |
| 100 | 20000000.00 |
The median is closer to the lower quartile than the upper quartile. Also, the maximum is 40 times the median! We might wonder whether that $20M sale price is simply an anomalous value or whether there are many houses that sold at such a high price. To find out, we can zoom in on the right tail of the distribution and compute a few high percentiles:
`percs` `=` `[``95``,` `97``,` `98``,` `99``,` `99.5``,` `99.9``]`
`prices` `=` `np``.``percentile``(``sfh_df``[``'``price``'``]``,` `percs``,` `method``=``'``lower``'``)`
`pd``.``DataFrame``(``{``'``price``'``:` `prices``}``,` `index``=``percs``)`
| price | |
|---|---|
| 95.00 | 1295000.00 |
| 97.00 | 1508000.00 |
| 98.00 | 1707000.00 |
| 99.00 | 2110000.00 |
| 99.50 | 2600000.00 |
| 99.90 | 3950000.00 |
We see that 99.9% of the houses sold for under $4M, so the $20M sale is indeed a rarity. Let’s examine the histogram of sale prices below $4M:
`under_4m` `=` `sfh_df``[``sfh_df``[``'``price``'``]` `<` `4_000_000``]``.``copy``(``)`
`px``.``histogram``(``under_4m``,` `x``=``'``price``'``,` `nbins``=``50``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``price``'``:``'``Sale price (USD)``'``}``)`

Even without the top 0.1%, the distribution remains highly skewed to the right, with a single mode around $500,000. Let’s plot the histogram of the log-transformed sale price. The logarithm transformation often does a good job at converting a right-skewed distribution into one that is more symmetric:
`under_4m``[``'``log_price``'``]` `=` `np``.``log10``(``under_4m``[``'``price``'``]``)`
`px``.``histogram``(``under_4m``,` `x``=``'``log_price``'``,` `nbins``=``50``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``log_price``'``:``'``Sale price (log10 USD)``'``}``)`

We see that the distribution of log-transformed sale price is roughly symmetric. Now that we have an understanding of the distribution of sale price, let’s consider the so-what questions posed in the previous section on EDA guidelines.
What Next?
我们已经描述了销售价格的形状,但我们需要考虑形状的重要性,并寻找可能有所不同的分布的比较组。
形状很重要,因为基于对称分布的模型和统计性质往往比高度偏斜的分布更具有稳健和稳定的特性(我们在第十五章中详细讨论这个问题)。因此,我们主要使用对数转换后的销售价格进行分析。而且,我们可能还会选择限制分析范围在 400 万美元以下的销售价格,因为超级昂贵的房屋可能表现出截然不同的行为。
至于可能进行的比较,我们需要看背景情况。房地产市场在此期间迅速上涨,然后市场崩溃。因此,比如说,2004 年的销售价格分布可能与 2008 年市场崩盘前的情况有很大不同。为了进一步探索这一观点,我们可以分析价格随时间的变化。或者,我们可以固定时间,分析价格与其他感兴趣特征之间的关系。这两种方法都可能是有价值的。
我们将焦点缩小到一年(在第十一章中我们将研究时间维度)。我们将数据限制在 2004 年的销售情况,因此上涨的房价对我们研究的分布和关系的影响应该是有限的。为了限制非常昂贵和大型的房屋的影响,我们还将数据集限制在售价低于 400 万美元和面积小于 12,000 平方英尺的房屋范围内。这个子集仍然包含大型和昂贵的房屋,但不会过于夸张。稍后,我们将进一步限制我们的研究范围到几个感兴趣的城市:
`def` `subset``(``df``)``:`
`return` `df``.``loc``[``(``df``[``'``price``'``]` `<` `4_000_000``)` `&`
`(``df``[``'``bsqft``'``]` `<` `12_000``)` `&`
`(``df``[``'``timestamp``'``]``.``dt``.``year` `==` `2004``)``]`
`sfh` `=` `sfh_df``.``pipe``(``subset``)`
`sfh`
| 城市 | 邮政编码 | 街道 | 价格 | 卧室数量 | 生活面积 | 建筑面积 | 时间戳 | |
|---|---|---|---|---|---|---|---|---|
| 0 | 阿拉米达 | 94501.00 | 1001 Post Street | 689000.00 | 4.00 | 4484.00 | 1982.00 | 2004-08-29 |
| 3 | 阿拉米达 | 94501.00 | 1001 Shoreline Drive #108 | 485000.00 | 2.00 | 39353.00 | 1360.00 | 2004-09-05 |
| 10 | 阿拉米达 | 94501.00 | 1001 Shoreline Drive #306 | 390000.00 | 2.00 | 39353.00 | 1360.00 | 2004-01-25 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 521467 | 温莎 | 95492.00 | 9960 Herb Road | 439000.00 | 3.00 | 9583.00 | 1626.00 | 2004-04-04 |
| 521471 | 温莎 | 95492.00 | 9964 Troon Court | 1200000.00 | 3.00 | 20038.00 | 4281.00 | 2004-10-31 |
| 521478 | 温莎 | 95492.00 | 9980 Brooks Road | 650000.00 | 3.00 | 45738.00 | 1200.00 | 2004-10-24 |
105996 rows × 8 columns
对于这些数据,销售价格分布的形状保持不变——价格仍然高度右偏。我们继续使用这个子集来解决是否有任何重要特征需要与价格一起研究的问题。
研究其他特征
除了销售价格外,这是我们的主要关注点,还有一些可能对我们的调查很重要的其他特征,例如房屋大小、地块(或物业)大小和卧室数量。我们探索这些特征的分布以及它们与销售价格和彼此之间的关系。
由于房屋和地产的大小可能与其价格有关,因此猜测这些特征也可能向右倾斜,因此我们对建筑物大小进行对数转换是合理的:
`sfh` `=` `sfh``.``assign``(``log_bsqft``=``np``.``log10``(``sfh``[``'``bsqft``'``]``)``)`
我们比较了常规和对数比例尺上建筑面积的分布:
`fig` `=` `make_subplots``(``1``,``2``)`
`fig``.``add_trace``(``go``.``Histogram``(``x``=``sfh``[``'``bsqft``'``]``,` `histnorm``=``'``percent``'``,`
`nbinsx``=``60``)``,` `row``=``1``,` `col``=``1``)`
`fig``.``add_trace``(``go``.``Histogram``(``x``=``sfh``[``'``log_bsqft``'``]``,` `histnorm``=``'``percent``'``,`
`nbinsx``=``60``)``,` `row``=``1``,` `col``=``2``)`
`fig``.``update_xaxes``(``title``=``'``Building size (ft²)``'``,` `row``=``1``,` `col``=``1``)`
`fig``.``update_xaxes``(``title``=``'``Building size (ft², log10)``'``,` `row``=``1``,` `col``=``2``)`
`fig``.``update_yaxes``(``title``=``"``percent``"``,` `row``=``1``,` `col``=``1``)`
`fig``.``update_yaxes``(``range``=``[``0``,` `18``]``)`
`fig``.``update_layout``(``width``=``450``,` `height``=``250``,` `showlegend``=``False``)`
`fig`

分布是单峰的,峰值约为 1,500 平方英尺,许多房屋的面积超过 2,500 平方英尺。我们已经确认了我们的直觉:对数转换后的建筑面积几乎是对称的,尽管保持了轻微的倾斜。对于地块面积的分布也是如此。
鉴于房屋和地块面积都呈现偏斜分布,两者的散点图很可能也应采用对数比例尺:
`sfh` `=` `sfh``.``assign``(``log_lsqft``=``np``.``log10``(``sfh``[``'``lsqft``'``]``)``)`
我们比较了有和没有对数转换的图形:

左边的散点图是以原始单位表示的,这使得很难辨别它们之间的关系,因为大多数点都挤在绘图区域的底部。相比之下,右边的散点图显示了一些有趣的特征:沿着散点图底部有一条水平线,看起来许多房屋的地块大小相同,无论建筑物的大小如何;而且似乎地块和建筑物大小之间存在轻微的正对数线性关系。
让我们看一下地块大小的一些较低分位数,试图弄清这个异常值:
`percs` `=` `[``0.5``,` `1``,` `1.5``,` `2``,` `2.5``,` `3``]`
`lots` `=` `np``.``percentile``(``sfh``[``'``lsqft``'``]``.``dropna``(``)``,` `percs``,` `method``=``'``lower``'``)`
`pd``.``DataFrame``(``{``'``lot_size``'``:` `lots``}``,` `index``=``percs``)`
| lot_size | |
|---|---|
| 0.50 | 436.00 |
| 1.00 | 436.00 |
| 1.50 | 436.00 |
| 2.00 | 436.00 |
| 2.50 | 436.00 |
| 3.00 | 782.00 |
我们发现了一些有趣的东西:大约有 2.5%的房屋的地块面积为 436 平方英尺。这太小了,几乎没有意义,因此我们记录下这个异常值以供进一步调查。
另一种衡量房屋大小的方法是卧室数量。由于这是一个离散的定量变量,我们可以将其视为定性特征并制作条形图。
旧金山湾区的房屋往往比较小,所以我们猜测分布会在三处达到峰值,并向右倾斜,有些房屋有五六间卧室。让我们来检查一下:
`br_cat` `=` `sfh``[``'``br``'``]``.``value_counts``(``)``.``reset_index``(``)`
`px``.``bar``(``br_cat``,` `x``=``"``br``"``,` `y``=``"``count``"``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``br``'``:``'``Number of bedrooms``'``}``)`

条形图证实了我们一般的想法。然而,我们发现有些房屋有超过 30 间卧室!这有点难以置信,也指向另一个可能的数据质量问题。由于记录包括房屋的地址,我们可以在房地产应用程序上再次检查这些值。
与此同时,让我们将卧室数量转换为有序特征,将所有大于 8 的值重新分配为 8+,并使用转换后的数据重新创建条形图:
`eight_up` `=` `sfh``.``loc``[``sfh``[``'``br``'``]` `>``=` `8``,` `'``br``'``]``.``unique``(``)`
`sfh``[``'``new_br``'``]` `=` `sfh``[``'``br``'``]``.``replace``(``eight_up``,` `8``)`
`br_cat` `=` `sfh``[``'``new_br``'``]``.``value_counts``(``)``.``reset_index``(``)`
`px``.``bar``(``br_cat``,` `x``=``"``new_br``"``,` `y``=``"``count``"``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``new_br``'``:``'``Number of bedrooms``'``}``)`

我们可以看到,即使我们将所有具有 8 个或更多卧室的房屋归为一类,它们的数量也不多。分布几乎对称,高峰出现在 3 个卧室,大约有相同比例的房屋有两个或四个卧室,同样有一个或五个卧室的房屋。存在不对称性,有少数房屋拥有六个或更多卧室。
现在我们来研究卧室数量与销售价格之间的关系。在我们继续之前,我们先保存目前已经完成的转换:
`def` `log_vals``(``df``)``:`
`return` `df``.``assign``(``log_price``=``np``.``log10``(``df``[``'``price``'``]``)``,`
`log_bsqft``=``np``.``log10``(``df``[``'``bsqft``'``]``)``,`
`log_lsqft``=``np``.``log10``(``df``[``'``lsqft``'``]``)``)`
`def` `clip_br``(``df``)``:`
`eight_up` `=` `df``.``loc``[``df``[``'``br``'``]` `>``=` `8``,` `'``br``'``]``.``unique``(``)`
`new_br` `=` `df``[``'``br``'``]``.``replace``(``eight_up``,` `8``)`
`return` `df``.``assign``(``new_br``=``new_br``)`
`sfh` `=` `(``sfh_df`
`.``pipe``(``subset``)`
`.``pipe``(``log_vals``)`
`.``pipe``(``clip_br``)`
`)`
现在我们准备考虑卧室数量与其他变量之间的关系。
深入探讨关系
让我们从检查不同卧室数量的房屋价格分布开始。我们可以通过箱线图来完成这个任务:
`px``.``box``(``sfh``,` `x``=``'``new_br``'``,` `y``=``'``price``'``,` `log_y``=``True``,` `width``=``450``,` `height``=``250``,`
`labels``=``{``'``new_br``'``:``'``Number of bedrooms``'``,``'``price``'``:``'``Sale price (USD)``'``}``)`

中位数销售价格随着卧室数量从一增加到五而增加,但对于最大的房屋(超过六个卧室的房屋),对数转换后的销售价格分布几乎相同。
我们预期一居室的房屋比四居室的房屋小。我们还可能猜想,六个或更多卧室的房屋在大小和价格上是类似的。为了深入了解,我们考虑一种将价格除以建筑面积的转换,得到每平方英尺价格的方法。我们想要检查这个特征是否对所有房屋都是恒定的;换句话说,价格是否主要由房屋大小决定。为此,我们查看了大小和价格、每平方英尺价格和大小之间的关系:
`sfh` `=` `sfh``.``assign``(`
`ppsf``=``sfh``[``'``price``'``]` `/` `sfh``[``'``bsqft``'``]``,`
`log_ppsf``=``lambda` `df``:` `np``.``log10``(``df``[``'``ppsf``'``]``)``)`
我们创建了两个散点图。左侧图显示价格与建筑面积(都进行了对数转换),右侧图显示每平方英尺价格(进行了对数转换)与建筑面积之间的关系。此外,每个图中都添加了一个平滑曲线,反映了大致相同大小建筑的局部平均价格或每平方英尺价格:

左侧图表显示了我们的预期—更大的房屋成本更高。我们还看到这些特征之间大致存在对数关联。
此图中的右侧图表非常有趣地呈现了非线性特征。我们看到较小的房屋每平方英尺的成本比较大的房屋更高,而较大房屋的每平方英尺价格相对平稳。这一特征似乎非常有趣,因此我们将每平方英尺价格转换保存为sfh:
def compute_ppsf(df):
return df.assign(
ppsf=df['price'] / df['bsqft'],
log_ppsf=lambda df: np.log10(df['ppsf']))
到目前为止,我们还没有考虑价格与位置之间的关系。这个数据集中来自 150 多个不同城市的房屋销售数据。有些城市只有少数销售记录,而其他城市则有数千条。我们继续缩小数据范围,并在接下来的几个城市中研究关系。
修正位置
你可能听过这样的表达:房地产有三个重要因素—位置、位置、位置。比较不同城市的房价可能会为我们的调查带来额外的见解。
我们检查了旧金山东湾一些城市的数据:里士满、埃尔塞里托、奥尔巴尼、伯克利、核桃溪、拉莫林达(这是拉斐特、莫拉加和奥林达三个相邻的卧室社区的组合),以及皮德蒙特。
让我们开始比较这些城市的销售价格分布:
`cities` `=` `[``'``Richmond``'``,` `'``El Cerrito``'``,` `'``Albany``'``,` `'``Berkeley``'``,`
`'``Walnut Creek``'``,` `'``Lamorinda``'``,` `'``Piedmont``'``]`
`px``.``box``(``sfh``.``query``(``'``city in @cities``'``)``,` `x``=``'``city``'``,` `y``=``'``price``'``,`
`log_y``=``True``,` `width``=``450``,` `height``=``250``,`
`labels``=``{``'``city``'``:``'``'``,` `'``price``'``:``'``Sale price (USD)``'``}``)`

箱线图显示,拉莫林达和皮德蒙特的房屋更昂贵,里士满最便宜,但许多城市的销售价格存在重叠。
接下来,我们将使用分面散点图更仔细地检查每个四个城市的房价与房屋大小之间的关系:
`four_cities` `=` `[``"``Berkeley``"``,` `"``Lamorinda``"``,` `"``Piedmont``"``,` `"``Richmond``"``]`
`fig` `=` `px``.``scatter``(``sfh``.``query``(``"``city in @four_cities``"``)``,`
`x``=``"``bsqft``"``,` `y``=``"``log_ppsf``"``,` `facet_col``=``"``city``"``,` `facet_col_wrap``=``2``,`
`labels``=``{``'``bsqft``'``:``'``Building size (ft²)``'``,`
`'``log_ppsf``'``:` `"``Price per square foot``"``}``,`
`trendline``=``"``ols``"``,` `trendline_color_override``=``"``black``"``,`
`)`
`fig``.``update_layout``(``xaxis_range``=``[``0``,` `5500``]``,` `yaxis_range``=``[``1.5``,` `3.5``]``,`
`width``=``450``,` `height``=``400``)`
`fig``.``show``(``)`

价格每平方英尺与建筑面积的关系大致呈对数线性,对于这四个地点每个都存在负相关。虽然不是平行的,但似乎在房子方面存在地理位置的提升,例如伯克利的房屋每平方英尺比里士满的房屋贵约 250 美元。我们还看到皮德蒙特和拉莫林达是更昂贵的城市,在这两个城市中,与较小房屋相比,较大房屋每平方英尺的价格没有同样的降低。这些图表支持“地段,地段,地段”的格言。
在探索性数据分析(EDA)中,我们经常回顾早期的图表,以检查新发现是否为先前的可视化增添了新的见解。持续盘点我们的发现并利用它们指导我们进一步的探索至关重要。让我们总结一下到目前为止我们的发现。
EDA 发现
我们的 EDA 揭示了几个有趣的现象。简而言之,其中一些最显著的是:
-
销售价格和建筑面积呈右偏分布,有一个主模式。
-
每平方英尺价格随建筑面积的增加呈非线性下降趋势,较小的房屋每平方英尺的成本高于较大的房屋,并且每平方英尺的价格在大房屋中大致保持不变。
-
更理想的地理位置为房屋的销售价格增加了一点,对于不同大小的房屋增加的金额大致相同。
我们可以(也应该)进行许多其他探索,还有几个我们应该进行的检查。这些包括调查占地面积 436 的价值和用在线房地产应用程序交叉检查异常房屋,例如 30 卧室房屋和 2000 万美元的房屋。
我们将我们的调查限制在一年内,后来又缩小到几个城市。这种缩小帮助我们控制可能干扰发现简单关系的特征。例如,由于数据是在几年内收集的,销售日期可能混淆了销售价格和卧室数量之间的关系。在其他时候,我们希望考虑时间对价格的影响。为了检查随时间的价格变化,我们经常制作折线图,并对通货膨胀进行调整。我们在第十一章重新审视这些数据时,考虑数据范围并更仔细地观察时间趋势。
尽管简短,本节传达了 EDA 实践的基本方法。有关不同数据集的扩展案例研究,请参阅第十二章。
总结
在本章中,我们介绍了名义、序数和数值特征类型及其在数据分析中的重要性。当面对数据集时,我们展示了如何查阅数据字典和数据本身,以确定每列的特征类型。我们还解释了存储类型与特征类型不应混淆。由于大部分 EDA 是通过统计图表进行的,我们描述了如何识别和解释出现的形状和模式,以及如何将其与正在绘制的数据联系起来。最后,我们提供了进行 EDA 的指导方针,并提供了一个示例。
有一种方法可能对你在开发关于特征分布和关系直觉很有帮助,那就是在绘制图表之前先对你将看到的内容进行猜测。试着草拟或描述你认为分布形状会是什么样子,然后再绘制图表。例如,具有自然下限/上限值的变量往往在边界的对面有一个长尾。收入分布(下限为 0)往往有一个长尾在右侧,而考试成绩(上限为 100)往往有一个长尾在左侧。你可以对关系的形状做类似的猜测。我们发现价格和房屋大小几乎呈对数-对数线性关系。当你对形状有了直觉后,进行探索性数据分析(EDA)就变得更容易;你可以更容易地识别出图表显示出令人惊讶的形状。
本章的重点是“阅读”可视化结果。在第十一章中,我们提供了如何创建信息丰富、有效和美观的图表的样式指南。这一章中许多思想也在这里得到了遵循,但我们并未特别指出它们。
第十一章:数据可视化
作为数据科学家,我们创建数据可视化是为了理解我们的数据,并向其他人解释我们的分析。图表应该传达一个信息,我们的工作是尽可能清晰地传达这个信息。
在第十章中,我们将统计图的选择与所绘制数据的类型联系起来;我们还介绍了许多标准图,并展示了如何解读它们。在本章中,我们讨论了有效数据可视化的原则,这些原则使得观众更容易理解我们图中的信息。我们讨论了如何选择轴的刻度,如何通过平滑和聚合处理大量数据,如何进行有意义的比较,如何融入研究设计,并添加上下文信息。我们还展示了如何使用plotly这一流行的 Python 绘图包来创建图表。
撰写关于数据可视化的一章的一个棘手之处在于,可视化软件包经常变动,因此我们展示的任何代码可能很快就会过时。由于这个原因,一些书籍完全避免使用代码。我们相反地取得了平衡,覆盖了广泛有用的高级数据可视化原则。然后我们单独包含实际的绘图代码来实现这些原则。当新软件可用时,读者仍然可以使用我们的原则来指导他们的可视化创建。
选择比例以揭示结构
在第十章中,我们探讨了 2003 年至 2009 年间旧金山湾区房屋销售价格。让我们重新看一下这个例子,并看一看销售价格的直方图:
`px``.``histogram``(``sfh``,` `x``=``'``price``'``,` `nbins``=``100``,`
`labels``=``{``'``price``'``:``"``Sale price (USD)``"``}``,` `width``=``350``,` `height``=``250``)`

虽然这个图准确地显示了数据,但大部分可见的箱子都挤在图的左侧。这使得理解价格分布变得困难。
通过数据可视化,我们希望展示数据的重要特征,如分布的形状和两个或更多特征之间的关系。正如这个例子所示,当我们生成初始图后,仍然有其他方面需要考虑。在本节中,我们涵盖了帮助我们决定如何调整轴限制、放置刻度标记和应用变换的比例原则。我们首先检查何时以及如何调整图形以减少空白区域;换句话说,我们试图用数据填充我们图的数据区域。
填充数据区域
正如我们从销售价格直方图中看到的那样,当大部分数据出现在绘图区域的一个小部分时,读取分布就变得困难了。当这种情况发生时,数据的重要特征,如多模式和偏斜,可能会被掩盖。散点图也存在类似问题。当所有点都挤在散点图的一个角落时,很难看到分布的形状,因此也很难从形状中获得任何见解。
当存在少数异常大的观测时会出现这个问题。为了更好地观察数据的主要部分,我们可以通过调整 x 或 y 轴的限制删除这些观测值,或者在绘制前从数据中删除异常值。无论哪种情况,我们都会在标题或图表本身中提到这种排除。
让我们利用这个想法来改善销售价格的直方图。在接下来的并列图中,我们通过改变 x 轴的限制来裁剪数据。在左图中,我们排除了价格超过 $2 百万的房屋。这样做使得大部分房屋分布的形状在图中更加清晰。例如,我们可以更容易地观察到偏斜和较小的次要模式。在右图中,我们分别展示了分布长尾右侧的详细信息(在线查看更大的版本):

左图的 x 轴包含 0,但右图的 x 轴从 $2M 开始。我们考虑下一步在轴上是否包含或排除 0。
包含零
我们通常不需要在轴上包含 0,特别是如果包含它会使填充数据区域变得困难。例如,让我们制作一个反映狗品种平均寿命与平均身高关系的散点图。(此数据集首次在第十章介绍;它包含 172 种品种的多个特征。)

左图的 x 轴从 10 厘米开始,因为所有狗至少有这么高,类似地,y 轴从 6 年开始。右侧的散点图在两个轴上都包含 0。这将数据推到数据区域的顶部,并留下不利于我们看到线性关系的空白空间。
在柱状图中,通常需要包含 0,这样柱子的高度直接与数据值相关联。例如,我们创建了两个比较狗品种寿命的柱状图。左图包含 0,而右图不包含:

从右图可以轻易地错误地得出小品种的寿命是大品种的两倍的结论。
在处理比例时,我们通常也希望包含 0,因为比例范围从 0 到 1。下图显示了每种类型中品种的比例:

在柱状图和散点图中,包含 0 使您能够更准确地比较各类别的相对大小。
早些时候,当我们调整轴时,实际上是从绘图区域中删除了数据。虽然当少数观测值异常大(或小)时这是一个有用的策略,但在偏斜分布中效果较差。在这种情况下,我们通常需要转换数据以更好地了解其形状。
通过变换显示形状
另一种常见的调整比例的方法是转换数据或图的轴。我们使用变换来处理偏斜的数据,以便更容易检查分布。当变换产生对称分布时,对称性带有有用的建模属性(参见第十五章)。
有多种方法可以转换数据,但对数变换往往特别有用。例如,在下面的图表中,我们重新生成了旧金山房屋销售价格的两个直方图。顶部直方图是原始数据。对于下面的直方图,我们在绘制前对价格取了对数(以 10 为底):

对数变换使得价格分布更对称。现在我们可以更轻松地看到分布的重要特征,如大约 处的模式,约为 70 万美元,以及次要模式接近 处,即 35 万美元。
使用对数变换的缺点是实际值不那么直观——在这个例子中,我们需要将值转换回美元才能理解销售价格。因此,我们通常更喜欢将轴变换为对数刻度,而不是数据本身。这样,我们可以在轴上看到原始的值:

具有对数刻度 x 轴的直方图基本上显示了与转换数据的直方图相同的形状。但由于在美元刻度上的箱子宽度相等,但在对数美元刻度上绘制,所以右侧的箱子变窄了。还要注意,y 轴上的 是 。
对数变换还可以显示散点图中的形状。在这里,我们将建筑物大小绘制在 x 轴上,地块大小绘制在 y 轴上。在这个图中很难看出形状,因为许多点都挤在数据区域的底部:

然而,当我们同时使用对数刻度的 x 轴和 y 轴时,关系的形状更容易看出:
`px``.``scatter``(``sfh``,` `x``=``'``bsqft``'``,` `y``=``'``lsqft``'``,`
`log_x``=``True``,` `log_y``=``True``,`
`labels``=``{``"``bsqft``"``:` `"``Building size (sq ft)``"``,`
`"``lsqft``"``:` `"``Lot size (sq ft)``"``}``,`
`width``=``350``,` `height``=``250``)`

通过变换后的坐标轴,我们可以看到在对数尺度上,地块大小随建筑物大小大致呈线性增加。对数变换将大值——比其他值大几个数量级的值——拉向中心。这种变换有助于填充数据区域并揭示隐藏的结构,就像我们在房价分布和房屋尺寸与地块大小之间的关系中所看到的那样。
除了设置轴的限制和变换轴外,我们还要考虑绘图的纵横比—长度与宽度的比例。调整纵横比称为银行业务,在下一节中,我们将展示如何通过银行业务来帮助揭示特征之间的关系。
解读关系
对于散点图,我们尝试选择刻度,使得两个特征之间的关系大致沿着 45 度线。这种缩放称为银行业务向 45 度。这样做是因为我们的眼睛更容易观察到与直线偏离的情况和趋势。例如,我们重现了显示狗品种寿命与身高关系的图:
`px``.``scatter``(``dogs``,` `x``=``'``height``'``,` `y``=``'``longevity``'``,` `width``=``300``,` `height``=``250``,`
`labels``=``{``"``height``"``:` `"``Height (cm)``"``,`
`"``longevity``"``:` `"``Typical lifespan (yr)``"``}``)`

散点图已经银行业务向 45 度,我们更容易看到数据大致沿直线分布以及它们在极端情况下的偏离。
当银行业务向 45 度倾斜有助于我们判断数据是否遵循线性关系时,当存在明显的曲率时,很难弄清楚关系的形式。在这种情况下,我们尝试能够使数据沿直线分布的变换(例如,请参阅图 11-1)。对数变换在揭示曲线关系的一般形式时非常有用。
透过拉直揭示关系
我们经常使用散点图来观察两个特征之间的关系。例如,在这里我们绘制了狗品种的身高与体重的关系:
`px``.``scatter``(``dogs``,` `x``=``'``height``'``,` `y``=``'``weight``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``"``height``"``:` `"``Height (cm)``"``,` `"``weight``"``:` `"``Weight (lb)``"``}``)`

我们看到更高的狗体重更重,但这种关系并非线性的。
当看起来两个变量之间存在非线性关系时,尝试应用对数尺度到 x 轴、y 轴或两者都是有用的。让我们在变换轴的散点图中寻找线性关系。这里我们重新绘制了狗品种的体重与身高的图,但这次我们将对 y 轴应用了对数尺度:
`px``.``scatter``(``dogs``,` `x``=``'``height``'``,` `y``=``'``weight``'``,` `log_y``=``True``,`
`labels``=``{``"``height``"``:` `"``Height (cm)``"``,` `"``weight``"``:` `"``Weight (lb)``"``}``,`
`width``=``300``,` `height``=``300``)`

这个图显示了大致线性的关系,在这种情况下,我们称狗的体重和身高之间存在对数-线性关系。
通常,当我们看到变换一个或两个轴后呈现线性关系时,我们可以使用表 11-1 来揭示原始变量的关系(在表中,a 和 b 是常数)。我们进行这些变换是因为这样更容易看出点是否沿着一条线分布,而不是看它们是否遵循幂律或指数律。
表 11-1。应用变换时两个变量之间的关系
| x 轴 | y 轴 | 关系 | 也称为 |
|---|---|---|---|
| 无转换 | 无转换 | 线性: | 线性 |
| 对数尺度 | 无转换 | 对数: | 线性-对数 |
| 无变换 | 对数比例 | 指数: | 对数-线性 |
| 对数比例 | 对数比例 | 功率: | 对数-对数 |
如表 11-1 所示,对数变换可以揭示几种常见类型的关系。正因如此,对数变换被认为是变换的利器。作为另一个虽然是人为的例子,图 11-1 中最左边的图显示了x和y之间的曲线关系。中间的图显示了log(y)和x之间不同的曲线关系;该图看起来也是非线性的。进一步的对数变换,最右边显示了log(y)对log(x)的图。这个图证实了数据具有对数-对数(或者功率)关系,因为变换后的点沿着一条直线分布。

图 11-1. 散点图展示了对数变换如何“拉直”两个变量之间的曲线关系
调整比例是数据可视化中的重要实践。虽然对数变换很灵活,但并不适用于所有呈现偏斜或曲率的情况。例如,有时值都大致相同数量级,对数变换影响有限。另一个需要考虑的变换是平方根变换,通常适用于计数数据。
在下一节中,我们将探讨平滑的原则,这是在需要可视化大量数据时使用的方法。
平滑和聚合数据
当我们有大量数据时,通常不想绘制所有单个数据点。以下散点图展示了来自樱花的数据,这是每年四月份在华盛顿特区举办的一场 10 英里长的比赛,当时樱花盛开。这些数据来自比赛网站,包括 1999 年至 2012 年所有注册男性跑步者的官方时间和其他信息。我们将跑步者的年龄绘制在 x 轴上,比赛时间绘制在 y 轴上:

这个散点图包含超过 70,000 个数据点。由于数据点过多,很多点会重叠在一起。这是一个常见的问题,称为过度绘制。在这种情况下,过度绘制使我们无法看到时间和年龄之间的关系。在这个图中,我们唯一能看到的是一群非常年轻的跑步者,这可能指向数据质量存在问题。为了解决过度绘制的问题,我们使用平滑技术在绘图前聚合数据。
平滑技术揭示形状
直方图是一种熟悉的平滑绘图类型。直方图通过将点放入 bin 中并绘制每个 bin 的条形来汇总数据值。这里的平滑意味着我们不能区分 bin 中各个点的位置:点被平滑地分配到它们的 bin 中。对于直方图,bin 的面积对应于 bin 中点的百分比(或计数或比例)。(通常 bin 宽度相等,我们采取一种简便的方式将 bin 的高度标记为比例。)
下面的直方图显示了狗品种寿命的分布:

在这个直方图上方是一个折线图,为每个数据值绘制一条线。我们可以看到在最高的 bin 中,即使少量数据也会导致折线图的重叠。通过平滑处理折线图的点,直方图显示了分布的一般形状。在这种情况下,我们看到许多品种的寿命约为 12 年。有关如何阅读和解释直方图的更多信息,请参见第十章。
另一种常见的平滑技术是核密度估计(KDE)。KDE 图使用平滑曲线而不是柱状图显示分布。在下面的图中,我们展示了同一狗寿命直方图,并覆盖了一个 KDE 曲线。KDE 曲线与直方图具有类似的形状:

把直方图看作平滑方法可能会让人惊讶。KDE 和直方图都旨在帮助我们看到值分布中的重要特征。类似的平滑技术也可以用于散点图。这是下一节的主题。
揭示关系和趋势的平滑技术
我们可以通过分 bin 数据来找到散点图的高密度区域,就像直方图一样。下面的图重新绘制了樱花大赛时间与年龄的散点图。这张图使用了六边形 bin 将点聚合在一起,并根据落入其中的点数对六边形进行了阴影处理:
`runners_over_17` `=` `runners``[``runners``[``"``age``"``]` `>` `17``]`
`plt``.``figure``(``figsize``=``(``4``,` `4``)``)`
`plt``.``hexbin``(``data``=``runners_over_17``,` `x``=``'``age``'``,` `y``=``'``time``'``,` `gridsize``=``35``,` `cmap``=``'``Blues``'``)`
`sns``.``despine``(``)`
`plt``.``grid``(``False``)`
`plt``.``xlabel``(``"``Runner age (yr)``"``)`
`plt``.``ylabel``(``"``Race time (sec)``"``)``;`

注意在 25 到 40 岁组中的高密度区域,通过图中的深色区域表示。图表告诉我们,许多这个年龄段的跑者大约在 5,000 秒(约 80 分钟)内完成比赛。(请注意,我们从这个图中删除了年轻跑者的数据。)我们还可以看到 40 到 60 岁组对应区域的向上曲率,这表明这些跑者通常比 25 到 40 岁组的跑者慢。这张图类似于热图,其中高密度区域通过更热或更亮的颜色传达。
核密度估计在二维中也适用。当我们在二维中使用 KDE 时,通常绘制结果三维形状的轮廓线图,并像读取地形图一样解读图形:
`plt``.``figure``(``figsize``=``(``5``,` `3``)``)`
`fig` `=` `sns``.``kdeplot``(``data``=``runners_over_17``,` `x``=``'``age``'``,` `y``=``'``time``'``)`
`plt``.``xlabel``(``"``Runner age (yr)``"``)`
`plt``.``ylabel``(``"``Race time (sec)``"``)``;`

这个二维 KDE 提供了与前一图中阴影区域相似的见解。我们看到 25 到 40 岁年龄组的跑步者高度集中,这些跑步者的时间大约为 5,000 秒左右。平滑使我们在数据量大时能够更清楚地看到整体情况,因为它可以显示高度集中数据值的位置和这些高集中区域的形状。否则这些区域可能无法看到。
另一种经常更具信息性的平滑方法是对具有相似 x 值的点的 y 值进行平滑处理。简单来说,我们将具有相似年龄的跑步者分组,使用五年递增:20–25、25–30、30–35 等。然后,对于每个五年的跑步者组,我们计算其平均比赛时间,绘制每组的平均时间,并连接点以形成一个“曲线”:
`times` `=` `(`
`runners_over_17``.``assign``(``age_5yr``=``runners_over_17``[``'``age``'``]` `/``/` `5` `*` `5``)`
`.``groupby``(``'``age_5yr``'``)``[``'``time``'``]``.``mean``(``)``.``reset_index``(``)`
`)`
`px``.``line``(``times``,` `x``=``'``age_5yr``'``,` `y``=``'``time``'``,`
`labels``=``{``'``time``'``:``"``Average race time (sec)``"``,` `'``age_5yr``'``:``"``Runner age (5-yr)``"``}``,`
`markers``=``True``,`
`width``=``350``,` `height``=``250``)`

这幅图再次显示,年龄在 25 到 40 岁之间的跑步者典型的跑步时间约为 5,400 秒。它还显示,老年跑步者平均完成比赛时间较长(这并不令人意外,但在早期的图表中没有那么明显)。年龄在 20 岁以下的跑步者时间的下降和 80 岁时曲线的平坦可能仅仅是这些组别中跑步者较少且更健壮的结果。另一种平滑技术使用与 KDE 类似的核平滑。我们这里不详细介绍。
分箱和核平滑技术依赖于一个调整参数,该参数指定箱的宽度或核的扩展,并且在制作直方图、KDE 或平滑曲线时通常需要指定此参数。这是下一节的主题。
平滑技术需要调整
现在我们已经看到平滑在绘图中的用处,我们转向调整的问题。对于直方图,箱宽度或者对于等宽箱,箱的数量会影响直方图的外观。这里显示的长寿左直方图有几个宽箱子,而右侧直方图有许多窄箱子(在线上查看更大图像online):

在两个直方图中,很难看出分布的形状。在几个宽箱子(左图)中,我们对分布进行了过度平滑处理,这使得无法分辨模式和尾部。另一方面,太多的箱子(右图)给出的图像与地毯图相差无几。KDE 图中有一个称为带宽的参数,其作用类似于直方图的箱宽度。
大多数直方图和 KDE 软件会自动选择直方图的箱宽度和核的带宽。然而,这些参数通常需要一点调整才能创建最有用的图形。当您创建依赖调整参数的可视化时,重要的是在最终确定一个参数值之前尝试几种不同的值。
数据减少的另一种不同方法是检查分位数。这是下一节的主题。
减少分布到分位数
在第十章中我们发现,虽然箱线图不如直方图信息丰富,但在一次比较多个组的分布时它们是有用的。箱线图基于数据的四分位数将数据减少到几个基本特征。更一般地,分位数(下四分位数、中位数和上四分位数分别是第 25、50 和 75 个分位数)在比较分布时可以提供有用的数据缩减。
当两个分布在形状上大致相似时,用直方图可能很难比较它们。例如,以下直方图展示了旧金山房屋数据中两卧室和四卧室房屋价格的分布。这些分布在形状上看起来大致相似。但是它们的分位数图可以方便地比较分布的中心、传播和尾部(在线上更大地查看在线):
`px``.``histogram``(``sfh``.``query``(``'``br in [2, 4]``'``)``,`
`x``=``'``price``'``,` `log_x``=``True``,` `facet_col``=``'``br``'``,`
`labels``=``{``'``price``'``:``"``Sale price (USD)``"``}``,`
`width``=``700``,` `height``=``250``)`

我们可以用分位数-分位数图来比较,简称为q-q 图。为了制作这种图,我们首先计算价格的两卧室和四卧室分布的百分位数(也称为分位数):
`br2` `=` `sfh``.``query``(``'``br == 2``'``)`
`br4` `=` `sfh``.``query``(``'``br == 4``'``)`
`percs` `=` `np``.``arange``(``1``,` `100``,` `1``)`
`perc2` `=` `np``.``percentile``(``br2``[``'``price``'``]``,` `percs``,` `method``=``'``lower``'``)`
`perc4` `=` `np``.``percentile``(``br4``[``'``price``'``]``,` `percs``,` `method``=``'``lower``'``)`
`perc_sfh` `=` `pd``.``DataFrame``(``{``'``percentile``'``:` `percs``,` `'``br2``'``:` `perc2``,` `'``br4``'``:` `perc4``}``)`
`perc_sfh`
| 百分位数 | br2 | br4 | |
|---|---|---|---|
| --- | --- | --- | --- |
| 0 | 1 | 1.50e+05 | 2.05e+05 |
| 1 | 2 | 1.82e+05 | 2.50e+05 |
| 2 | 3 | 2.03e+05 | 2.75e+05 |
| ... | ... | ... | ... |
| 96 | 97 | 1.04e+06 | 1.75e+06 |
| 97 | 98 | 1.20e+06 | 1.95e+06 |
| 98 | 99 | 1.44e+06 | 2.34e+06 |
99 rows × 3 columns
然后在散点图上绘制匹配的百分位数。通常我们也会显示参考线 y = x 来帮助比较:
`fig` `=` `px``.``scatter``(``perc_sfh``,` `x``=``'``br2``'``,` `y``=``'``br4``'``,` `log_x``=``True``,` `log_y``=``True``,`
`labels``=``{``'``br2``'``:` `'``Price of 2-bedroom house``'``,`
`'``br4``'``:` `'``Price of 4-bedroom house``'``}``,`
`width``=``350``,` `height``=``250``)`
`fig``.``add_trace``(``go``.``Scatter``(``x``=``[``1e5``,` `2e6``]``,` `y``=``[``1e5``,` `2e6``]``,`
`mode``=``'``lines``'``,` `line``=``dict``(``dash``=``'``dash``'``)``)``)`
`fig``.``update_layout``(``showlegend``=``False``)`
`fig`

当分位点沿直线分布时,变量具有类似形状的分布。与参考线平行的线表示中心的差异,斜率不为 1 的线表示传播的差异,而曲线表示形状的差异。从前述的分位数图中,我们看到四卧室房屋价格的分布在形状上与两卧室的分布类似,除了大约 10 万美元的偏移和稍长的右尾(对大值的上升弯曲指示)。阅读分位数图需要练习。一旦你掌握了技巧,它可以是比较分布的一种便捷方式。注意,房屋数据有超过 10 万个观察值,而分位数图将数据减少到 99 个百分位数。这种数据缩减非常有用。然而,我们并不总是想使用平滑处理器。这是下一节的主题。
当不进行平滑处理时
平滑和聚合可以帮助我们看到重要的特征和关系,但是当我们只有少数观测时,平滑技术可能会误导我们。只有少数观测时,我们更喜欢地毯图而不是直方图、箱线图和密度曲线,而是使用散点图而不是平滑曲线和密度轮廓。这似乎是显而易见的,但是当我们有大量数据时,子组中的数据量可能会迅速减少。这种现象是 维度诅咒 的一个例子。
平滑最常见的误用之一发生在箱线图中。例如,这是寿命的七个箱线图的集合,每个箱线图代表一种狗品种:
`px``.``box``(``dogs``,` `x``=``'``group``'``,` `y``=``'``longevity``'``,`
`labels``=``{``'``group``'``:``"``"``,` `'``longevity``'``:``"``Longevity (yr)``"``}``,`
`width``=``500``,` `height``=``250``)`

其中一些箱线图只有两到三个观测值。接下来的条形图是一种更可取的可视化方法:
`px``.``strip``(``dogs``,` `x``=``"``group``"``,` `y``=``"``longevity``"``,`
`labels``=``{``'``group``'``:``"``"``,` `'``longevity``'``:``"``Longevity (yr)``"``}``,`
`width``=``400``,` `height``=``250``)`

在这个图中,我们仍然可以比较各组,但我们也可以看到每组中的确切值。现在我们可以看出非运动组中只有三个品种;基于箱线图的偏斜分布的印象过于看重箱线的形状。
本节介绍了由于大型数据集而产生重叠点的过绘制问题。为了解决这个问题,我们介绍了聚合数据的平滑技术。我们看到了平滑的两个常见示例—分箱和核平滑—并将它们应用于一维和二维设置中。在一维中,这些是直方图和核密度曲线,它们都帮助我们看到分布的形状。在二维中,我们发现在保持 x 值不变的同时平滑 y 值是有用的,以可视化趋势。我们解决了需要调整平滑量以获得更多信息的直方图和密度曲线,并警告不要用太少的数据进行平滑处理。
有许多其他减少散点图中的重叠的方法。例如,我们可以使点部分透明,以便重叠点显示更暗。如果许多观测值具有相同的值(例如当寿命四舍五入到最接近的年份时),那么我们可以向值添加少量随机噪声,以减少重叠的数量。这个过程称为 jittering,它用于寿命的条形图。透明度和 jittering 对于中等大小的数据很方便。但是,它们对于大型数据集效果不佳,因为绘制所有点仍然会压倒可视化。
我们介绍的分位数图为比较具有远少于点数的分布提供了一种方法;另一种方法是使用并排箱线图,还有一种方法是在同一图中叠加 KDE 曲线。我们经常旨在比较数据子集(或组)之间的分布和关系,接下来我们将讨论几种促进各种图类型的有意义比较的设计原则。
促进有意义的比较
同样的数据可以用许多不同的方式进行可视化,决定制作哪种图表可能令人生畏。一般来说,我们的图表应该帮助读者进行有意义的比较。在本节中,我们讨论了几个有用的原则,可以提高图表的清晰度。
强调重要的差异
每当我们制作一个比较不同群体的图表时,我们都会考虑图表是否突出了重要的差异。一般来说,当图表中的对象以使比较更容易阅读的方式对齐时,读者更容易看到差异。让我们看一个例子。
美国劳工统计局发布了有关收入的数据。我们以 2020 年全职等效周收入的中位数为基础,对超过 25 岁的人群按教育水平和性别进行了分组:^(1)
`labels` `=` `{``"``educ``"``:` `"``Education``"``,`
`"``income``"``:` `"``Weekly earnings (USD)``"``,`
`"``gender``"``:` `"``Sex``"``}`
`fig` `=` `px``.``bar``(``earn``,` `x``=``"``educ``"``,` `y``=``"``income``"``,`
`facet_col``=``"``gender``"``,` `labels``=``labels``,`
`width``=``450``,` `height``=``250``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``30``)``)`

这些条形图显示,随着教育程度的提高,收入也增加。但可以说,一个更有趣的比较是相同教育水平下男女之间的比较。我们可以以不同的方式分组条形图,以便集中在这种比较上:
`px``.``bar``(``earn``,` `x``=``'``educ``'``,` `y``=``'``income``'``,` `color``=``'``gender``'``,`
`barmode``=``'``group``'``,` `labels``=``labels``,`
`width``=``450``,` `height``=``250``)`

这个图更好;我们可以更容易地比较每个教育水平男性和女性的收入。然而,我们可以通过垂直对齐进一步明确这种差异。我们不再使用条形,而是在每个教育水平上使用点来代表男性和女性群体:
`fig` `=` `px``.``line``(``earn``,` `x``=``'``educ``'``,` `y``=``'``income``'``,` `symbol``=``'``gender``'``,`
`color``=``'``gender``'``,` `labels``=``labels``,` `width``=``450``,` `height``=``250``)`
`fig``.``update_traces``(``marker_size``=``10``)`

这个图更清晰地显示了一个重要的差异:男性和女性的收入差距随着教育程度的提高而扩大。我们考虑了三个可视化同一数据的图表,但它们在传达信息的方式上有所不同。我们更喜欢最后一个,因为它将收入差异垂直对齐,更易于比较。
注意,在制作所有三个图表时,我们按照教育水平从少到多的顺序对教育类别进行了排序。这种排序是有意义的,因为教育水平是有序的。当我们比较名义类别时,我们使用其他排序方法。
分组顺序
对于有序特征,当我们制作图表时,我们保持类别的自然顺序,但对于名义特征,这个原则不适用。相反,我们选择一种有助于比较的顺序。在条形图中,按照高度排序条形是一个好的做法,而对于箱线图和条带图,通常按照中位数的顺序排列箱子/条带。
后面的两个条形图各自比较了不同狗品种的平均寿命:

左边的图按字母顺序排列了条形,我们更喜欢右边的图,因为它按寿命的顺序排列了条形,这样更容易比较各个类别的寿命。我们不需要来回跳跃或眯起眼睛去猜测牧群犬是否比玩具犬寿命更短。
作为另一个例子,以下两组箱线图比较了旧金山东湾地区不同城市的房屋销售价格分布:

我们更喜欢右侧的图表,因为它按每个城市的中位数价格排序了箱子。同样,这种排序使得更容易比较组(在这种情况下是城市)之间的分布。我们看到 Albany 和 Walnut Creek 的下四分位数和中位数价格大致相同,但是 Walnut Creek 的价格有更大的右偏。
在可能的情况下,按高度排序条形图中的条和箱线图中的箱子,使得我们更容易比较不同组之间的数据。用于呈现分组数据的另一种技术是堆叠。我们将在下一节介绍堆叠,并提供示例以说服您远离这种类型的图表。
避免堆积
接下来的图示是一个堆积条形图,其中每个城市都有一根条形,并按照销售房屋中卧室数从一到八个或更多的比例进行划分。这称为堆积条形图。条形图基于一个交叉制表:
`br_crosstab`
| br | 1.0 | 2.0 | 3.0 | 4.0 | 5.0 | 6.0 | 7.0 | 8.0 |
|---|---|---|---|---|---|---|---|---|
| city | ||||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Albany | 1.21e-01 | 0.56 | 0.25 | 0.05 | 9.12e-03 | 1.01e-03 | 2.03e-03 | 4.05e-03 |
| Berkeley | 6.91e-02 | 0.38 | 0.31 | 0.16 | 4.44e-02 | 1.42e-02 | 6.48e-03 | 7.23e-03 |
| El Cerrito | 1.81e-02 | 0.34 | 0.47 | 0.14 | 2.20e-02 | 6.48e-03 | 0.00e+00 | 6.48e-04 |
| Piedmont | 8.63e-03 | 0.22 | 0.40 | 0.26 | 9.50e-02 | 1.29e-02 | 7.19e-03 | 1.44e-03 |
| Richmond | 3.60e-02 | 0.36 | 0.42 | 0.15 | 2.52e-02 | 7.21e-03 | 7.72e-04 | 7.72e-04 |
| Walnut Creek | 1.16e-01 | 0.35 | 0.30 | 0.18 | 4.37e-02 | 5.08e-03 | 4.12e-04 | 2.75e-04 |
绘图中的每个条形的高度都相同,因为段代表城市中一卧室或更多卧室的房屋比例,因此总和为 1 或 100%(在线查看详细信息):
`fig` `=` `px``.``bar``(``br_crosstab``,` `width``=``450``,` `height``=``300``)`
`fig``.``update_layout``(``yaxis_title``=``None``,` `xaxis_title``=``None``,`
`legend_title``=``"``# Bedrooms``"``)`
`fig``.``show``(``)`

通过简单地扫描每列第一个片段顶部,很容易比较每个城市一卧室房屋的比例。但是,比较四卧室房屋就更加困难。由于段的底部水平不对齐,我们必须用眼睛判断绘图中上下移动的段长度。这种上下移动称为基线摆动。(我们意识到,由于颜色过多,这个图在灰度中显示效果不佳,但我们的目标是引导你远离这样的图表,所以对于在线阅读的读者,我们保留了所有的颜色。)
堆叠线图更难阅读,因为我们必须判断曲线之间的间隙,而它们上下摇晃。下图显示了 1950 年至 2012 年排放量最高的 10 个国家的二氧化碳(CO[2])排放(在线查看颜色online):

由于这些线条是堆叠在一起的,很难看出特定国家的排放量如何变化,也很难比较各个国家。相反,我们可以绘制每个国家的线条,而不是堆叠,如下图所示(在线查看颜色online):

现在查看单个国家的变化以及比较国家变得更加容易,因为我们只需要判断 y 轴位置而不是具有不同基线的短垂直段。我们还在 y 轴上使用了对数刻度。现在我们可以看到,一些国家的二氧化碳排放率增长缓慢,例如美国和日本,而其他国家的增长速度更快,如中国和印度,而德国减缓了其二氧化碳排放。在每个国家的基线在图中摇晃时,几乎不可能检测到这些方面。
在这两个图中,为了更容易区分各个国家,我们使用了不同的线型和颜色。选择颜色以促进比较依赖于许多考虑因素。这是下一节的主题。
选择调色板
选择颜色在数据可视化中也起着重要作用。我们希望避免使用过亮或过暗的颜色,以免给读者的眼睛带来压力。我们还应避免对色盲人士可能困难的调色板——7%到 10%的人(主要是男性)是红绿色盲。
对于分类数据,我们希望使用一种可以清楚区分类别的调色板。图 11-2 中的顶部展示了一个示例(参见 Figure 11-2)。从上到下,这些调色板用于分类数据的定性,用于数值数据的分散调色板,其中希望吸引对大值和小值的注意力,以及用于数值数据的顺序调色板,其中希望强调大或小值。

图 11-2. 来自 ColorBrewer 2.0 的三种适合打印的调色板(在线查看颜色online)
对于数值数据,我们希望使用顺序调色板,强调光谱的一侧而不是另一侧,或者使用分散调色板,两端平衡强调,中间淡化。图 11-2 显示了顺序调色板位于底部,分散调色板位于中间(参见 Figure 11-2):
当我们想要强调低或高值(如癌症率)时,我们选择一个顺序调色板。当我们想要强调两个极端(如两党选举结果)时,我们选择一个分散调色板。
选择感知统一的颜色调色板非常重要。这意味着当数据值加倍时,可视化中的颜色在人眼中看起来会变得两倍鲜艳。我们还希望避免颜色会在我们从图表的一个部分看向另一个部分时产生余像,不同强度的颜色会使一个属性看起来比另一个属性更重要,以及色盲人士难以区分的颜色。我们强烈建议使用专为数据可视化制作的调色板或调色板生成器。
图表应该被长时间检查,因此我们应该选择不妨碍读者仔细研究图表的颜色。更重要的是,颜色的使用不应是多余的——颜色应该代表信息。相关的是,人们通常很难区分超过七种颜色,因此我们限制图表中颜色的数量。最后,颜色在纸上打印时可能与在计算机屏幕上查看时相差很大。在选择颜色时,我们考虑我们的图表将如何显示。
在可视化中进行准确的比较是如此重要,以至于研究人员已经研究了人们感知颜色和其他绘图特征(如角度和长度)差异的程度。这是下一节的主题。
绘图比较的指南
研究人员研究了人们在不同类型的图表中读取信息的准确度。他们发现以下排序,从最准确到最不准确:
-
沿着共同刻度的位置,比如在地毯图、条带图或点图中
-
在相同但非对齐刻度上的位置,比如在条形图中
-
长度,在堆叠条形图中
-
角度和斜率,在饼图中
-
面积,在堆叠线图或气泡图中
-
体积和密度,在三维条形图中
-
颜色的饱和度和色调,比如在使用半透明点进行重叠绘制时
例如,这是一个显示旧金山售出的房屋中拥有从一到八个或更多卧室比例的饼图,以及具有相同比例的条形图:

在饼图中很难判断角度,需要用实际百分比进行注释。我们还失去了卧室数量的自然顺序。条形图没有这些问题。
然而,任何规则都有例外。每个饼图中只有两个或三个片段的多个饼图可以提供有效的可视化效果。例如,一组饼图显示旧金山东湾地区六个城市中每个城市售出的两卧室房屋的比例,按比例排序,可以产生深远的可视化效果。然而,坚持使用条形图通常至少和任何饼图一样清晰。
根据这些准则,我们建议在进行比较时坚持使用位置和长度。读者可以更准确地根据位置或长度来判断比较,而不是角度、面积、体积或颜色。但是,如果我们想向图表添加额外信息,通常会使用颜色、符号和线型,除了位置和长度。我们在本章中展示了几个例子。
接下来我们转向数据设计的话题,讨论如何在可视化中反映数据收集的时间、地点和方式的方面。这是一个微妙但重要的话题。如果忽略数据的范围,我们可能会得到非常误导人的图表。
结合数据设计
当我们创建可视化时,考虑数据的范围尤为重要,特别是数据设计(见第二章)。考虑数据收集方式的问题可以影响我们的图表选择和所描绘的比较。这些考虑包括数据收集的时间和地点,以及用于选择样本的设计。我们看几个例子,展示数据范围如何影响我们所做的可视化。
数据随时间的收集
当数据随时间收集时,我们通常制作一条线图,将时间戳放在 x 轴上,将感兴趣的特征放在 y 轴上,以便观察时间趋势。例如,让我们重新审视旧金山房价数据。这些数据从 2003 年到 2008 年收集,展示了 2008/2009 年美国房地产泡沫的崩溃(美国房地产泡沫)。由于时间是这些数据范围的关键方面,让我们将销售价格可视化为时间序列。我们之前的探索显示销售价格高度偏斜,所以让我们使用百分位数而不是平均数。我们绘制中位数价格(这是我们在本章早些时候看到的一种平滑形式):

此图显示了 2003 年到 2007 年价格的上升和 2008 年的下降。但是,我们可以通过绘制几个额外的百分位数线来展示更多信息,而不仅仅是中位数。让我们为第 10、30、50(中位数)、70 和 90 百分位数的销售价格分别绘制不同的线条。当我们随时间检查价格时,通常需要校正通货膨胀,以便比较处于相同基础上。除了通货膨胀调整外,让我们将价格相对于 2003 年的起始价格进行绘制,每个百分位数的起始值为 y = 1。 (2006 年 90 百分位数的值为 1.5 表明,销售价格是 2003 年 90 百分位数的 1.5 倍)。这种归一化方法使我们能够看到住房市场不同部分的房屋危机如何影响房主:

当我们随时间跟踪第 10 百分位线的折线图时,我们发现它在 2005 年迅速上升,在接下来的几年里相对于 2003 年的值保持较高水平,然后比其他百分位数更早更快地下降。这告诉我们,较便宜的房屋,如起始家庭住房,在房地产市场崩溃中遭受了更大的波动并且损失了更多的价值。相比之下,高端住房受到的冲击较小;在 2008 年底,第 90 百分位数的房价仍高于 2003 年的价格。应用这一领域知识有助于揭示我们可能会忽略的数据趋势,并展示如何利用数据设计来改善可视化。
住房数据是一个关于特定时期内特定地理区域的完全普查的观察数据的例子。接下来,我们考虑另一个观察性研究,其中自我选择和时间段影响了可视化效果。
观察性研究
我们需要特别小心那些不构成人口普查或科学样本的数据。我们也应该注意横断面研究,无论是来自人口普查还是科学样本。在这个例子中,我们重新审视了樱花 10 英里赛的数据。在本章的早些时候,我们制作了一个平滑曲线来研究赛时与年龄之间的关系。我们在这里再次重现这个图表,以突显解释可能存在的潜在陷阱:

诱人的是,看到这张图表可能会得出结论,例如,60 岁的跑步者通常比 40 岁时需要额外花费 600 秒来完成比赛。然而,这是一个横断面研究,而不是纵向研究。该研究并未随时间跟踪人们的变化;相反,它获取了一个人群的横截面。在图表中代表 60 岁跑步者的人与代表 40 岁跑步者的人是不同的。这两组人可能在影响赛时与年龄关系的方式上有所不同。作为一个群体,比赛中的 60 岁老人可能比 40 岁的老人更适合他们的年龄。换句话说,数据设计不允许我们对个体跑步者做出结论。这个可视化并没有错,但我们在从中得出结论时需要小心。
这个设计更加复杂,因为我们有多年的比赛结果。每一年形成一个队列,一个选手组,而从一年到下一年,队列会改变。我们通过比较不同年份的选手来清晰地传达这个信息。在这里,我们分别画出了 1999 年、2005 年和 2010 年的选手线路图:

我们看到 2010 年每个年龄组的中位数比 2005 年的跑步者更高,而 2005 年的跑步者的时间比 1999 年的跑步者更高。有趣的是,随着比赛的增加,尤其是近年来新手跑步者的参与增加,比赛时间逐年放缓。这个例子显示了我们在解释模式时需要注意数据范围。在科学研究中,我们也需要牢记数据范围。这是下一节的主题。
不均匀抽样
在科学研究中,我们必须考虑样本设计,因为它可能影响我们的图表。一些样本以不均等的速率抽取个体,这需要在我们的可视化中进行考虑。我们在第八章和第九章中看到了科学研究的例子:药物滥用警告网络(DAWN)调查。这些数据来自于关于药物相关急诊访问的复杂随机研究,每个记录都带有一个权重,我们必须使用这些权重来准确地代表人口中的急诊访问情况。接下来的两个条形图显示了急诊访问类型的分布。可以在在线链接上查看更大的版本。左侧的图表未使用调查权重,右侧的图表使用了:

在未加权的条形图中,“其他”类别与“不良反应”类别一样频繁。然而,加权后,“其他”类别下降到“不良反应”的约三分之二。忽略抽样权重可能会导致分布的误导性呈现。无论是直方图、条形图、箱线图、二维轮廓还是光滑曲线,我们都需要使用权重来获得代表性的图表。数据范围的另一个影响我们图表选择的方面是数据收集的地点,这是下一节的主题。
地理数据
当我们的数据包含像纬度和经度这样的地理信息时,除了典型的图表外,我们应该考虑制作地图。例如,下图展示了美国空气质量传感器的位置,这是第十二章案例研究的重点:

注意加利福尼亚州和东海岸有更多的数据点。使用所有这些传感器数据的简单空气质量直方图会误代表美国的空气质量分布。为了将空间因素纳入分布中,我们可以使用不同颜色的标记将空气质量测量添加到地图上,并且可以按位置分面显示空气质量的直方图。
除了绘制条形、颜色和线条样式等特征外,我们还可以选择添加文本以提供背景信息,从而使我们的图表更具信息性。这是下一节的主题。
添加背景信息
在本章中,我们在图表中使用文本来提供包括测量单位的有意义的轴标签、用于类别的刻度标签和标题。在广泛分享可视化时,这是一个良好的实践。一个好的目标是在图中包含足够的上下文,使其能够独立存在——读者应该能够理解图的主要内容,而不需要到处寻找解释。尽管如此,统计图的每个元素都应该有一个目的。多余的文本或图表功能,通常称为图表垃圾,应该被消除。在本节中,我们提供了如何通过添加上下文来创建一个出版准备好的图表的简要概述。
文本上下文包括标签和标题。在刻度标记和轴上一贯使用信息丰富的标签是一个良好的做法。例如,轴标签通常受益于包含测量单位。当需要时,图表应包含标题和图例。信息丰富的标签对于其他人会看到和解释的图表尤为重要。然而,即使在进行我们自己的探索性数据分析时,我们通常也希望包含足够的上下文,以便在稍后返回分析时,我们能够轻松地理解我们绘制了什么。
标题起到了几个作用。它们描述了绘制的内容并指导读者。标题还指出了图的重要特征并评论了它们的含义。标题中重复文本中的信息是可以接受的。读者通常会快速浏览出版物并关注章节标题和可视化内容,因此图的标题应该是自包含的。
参考标记为绘图区域带来了额外的背景信息。提供基准、历史值和其他外部信息的参考点和参考线帮助进行比较和解释。例如,我们经常在分位数-分位数图上添加斜率为 1 的参考线。我们还可能在时间序列图上添加垂直线以标记特殊事件,如自然灾害。
下面的例子演示了如何向绘图添加这些上下文元素。
例子:100 米短跑时间
以下图显示了自 1968 年以来男子 100 米短跑的比赛时间。这些数据仅包括在正常风条件下、使用电子计时的户外比赛,并且仅包括在 10 秒内完成比赛的选手的时间。图表是一个基本的散点图,显示了时间与年份的关系。从这张图开始,我们对其进行了增强,以创建一幅出现在FiveThirtyEight 文章中的图表。

当我们想要准备一张图形供他人阅读时,我们考虑的是主要信息。在这种情况下,我们的主要信息是双重的:在过去的 50 年中,最佳选手们的速度越来越快,而乌塞恩·博尔特在 2009 年创下的 9.58 秒的非凡记录至今未被打破(事实上,第二快的比赛时间也属于博尔特)。我们通过添加直接陈述主要信息的标题、y 轴标签中的测量单位以及散点图中关键点的注释来提供这张图形的背景信息。此外,我们添加了一个水平参考线,在 10 秒以下的时间中仅绘制时间,以澄清世界纪录时间使用特殊符号来引起读者的注意。

这些背景信息描述了我们所绘制的内容,帮助读者看到主要信息,并指出数据中几个有趣的特征。现在,这张图可以成为幻灯片、技术报告或社交媒体帖子的有用部分。根据我们的经验,查看我们的数据分析的人记住我们的图形,而不是段落文本或方程式。为他人准备的图形添加背景信息是非常重要的。
在下一节中,我们将详细介绍如何使用 plotly Python 包创建图形。
使用 plotly 创建图形
在本节中,我们介绍了使用 plotly Python 包的基础知识,这是我们在本书中创建图形所使用的主要工具。
plotly 包相比其他绘图库有几个优点。它创建的是交互式图形而不是静态图像。当你在 plotly 中创建一个图形时,你可以平移和缩放,以查看通常太小以至于看不见的图形部分。你还可以悬停在图形元素上,比如散点图中的符号,以查看原始数据值。此外,plotly 可以使用 SVG 文件格式保存图形,这意味着即使放大后图像仍然清晰。如果你在 PDF 或书本的纸质副本中阅读本章节,我们使用了这个功能来渲染图形图像。最后,它有一个简单的“express” API,用于创建基本图形,这在进行探索性分析并想快速创建多个图形时非常有帮助。
在本节中,我们介绍了 plotly 的基础知识。如果你在这里遇到未涵盖的内容,我们建议查阅官方 plotly 文档。
图形和轨迹对象
每个 plotly 中的图形都包装在一个 Figure 对象中。Figure 对象跟踪要绘制的内容。例如,一个单独的 Figure 可以在左侧绘制散点图,在右侧绘制线图。Figure 对象还跟踪图形的布局,包括图形的大小、标题、图例和注释。
plotly.express 模块提供了一个简洁的 API 来制作图形:
`import` `plotly``.``express` `as` `px`
我们在以下代码中使用 plotly.express 制作了一张关于狗品种体重与身高的散点图。请注意,.scatter() 的返回值是一个 Figure 对象:
`fig` `=` `px``.``scatter``(`
`dogs``,` `x``=``"``height``"``,` `y``=``"``weight``"``,`
`labels``=``dict``(``height``=``"``Height (cm)``"``,` `weight``=``"``Weight (kg)``"``)``,`
`width``=``350``,` `height``=``250``,`
`)`
`fig``.``__class__`
plotly.graph_objs._figure.Figure
显示 Figure 对象将其渲染到屏幕上:
`fig`

这个特定的 Figure 包含了一个图表,但是 Figure 对象可以包含任意数量的图表。在这里,我们创建了一个包含三个散点图的分面图:
`# The plot titles are cut off; we'll fix them in the next snippet`
`px``.``scatter``(``dogs``,` `x``=``'``height``'``,` `y``=``'``weight``'``,`
`facet_col``=``'``size``'``,`
`labels``=``dict``(``height``=``"``Height (cm)``"``,` `weight``=``"``Weight (kg)``"``)``,`
`width``=``550``,` `height``=``250``)`

这三个图表存储在 Trace 对象中。但是,我们尽量避免手动操作 Trace 对象。相反,plotly 提供了自动创建分面子图的函数,例如我们这里使用的 px.scatter 函数。现在我们已经看到如何制作简单的图表,接下来我们展示如何修改图表。
修改布局
我们经常需要更改图表的布局。例如,我们可能想调整图表的边距或轴范围。我们可以使用 Figure.update_layout() 方法来实现这一点。在我们制作的分面散点图中,标题被裁剪,因为图表的边距不够大。我们可以通过 Figure.update_layout() 来更正这个问题:
`fig` `=` `px``.``scatter``(``dogs``,` `x``=``'``height``'``,` `y``=``'``weight``'``,`
`facet_col``=``'``size``'``,`
`labels``=``dict``(``height``=``"``Height (cm)``"``,` `weight``=``"``Weight (kg)``"``)``,`
`width``=``550``,` `height``=``250``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``40``)``)`
`fig`

.update_layout() 方法允许我们修改布局的任何属性。这包括图表标题 (title)、边距 (margins 字典) 和是否显示图例 (showlegend)。plotly 文档中列出了完整的 布局属性。
Figure 对象还具有 .update_xaxes() 和 .update_yaxes() 函数,与 .update_layout() 类似。这两个函数允许我们修改轴的属性,如轴限制 (range)、刻度数 (nticks) 和轴标签 (title)。在这里,我们调整了 y 轴的范围,并更改了 x 轴的标题。我们还为图表添加了标题,并更新了布局,以避免标题被裁剪:
`fig` `=` `px``.``scatter``(`
`dogs``,` `x``=``"``weight``"``,` `y``=``"``longevity``"``,`
`title``=``"``Smaller dogs live longer``"``,`
`width``=``350``,` `height``=``250``,`
`)`
`fig``.``update_yaxes``(``range``=``[``5``,` `18``]``,` `title``=``"``Typical lifespan (yr)``"``)`
`fig``.``update_xaxes``(``title``=``"``Average weight (kg)``"``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``30``)``)`
`fig`

plotly 包提供了许多绘图方法;我们在下一节中描述了其中几种。
绘图函数
plotly 方法包括折线图、散点图、条形图、箱线图和直方图。每种类型的绘图方法的 API 都类似。数据帧是第一个参数。然后我们可以使用 x 和 y 关键字参数指定数据帧的列放置在 x 轴和 y 轴上。
我们首先制作了每年樱花赛跑者的中位时间折线图:
`px``.``line``(``medians``,` `x``=``'``year``'``,` `y``=``'``time``'``,` `width``=``350``,` `height``=``250``)`

接下来,我们制作不同大小狗品种的平均寿命条形图:
`lifespans` `=` `dogs``.``groupby``(``'``size``'``)``[``'``longevity``'``]``.``mean``(``)``.``reset_index``(``)`
`px``.``bar``(``lifespans``,` `x``=``'``size``'``,` `y``=``'``longevity``'``,`
`width``=``350``,` `height``=``250``)`

plotly 中的绘图方法还包含用于制作分面图的参数。我们可以根据颜色、绘图符号或线条样式在同一图表中进行分面,或者将其分为多个子图。以下是每种方式的示例。我们首先制作了一个关于狗品种身高和体重的散点图,并使用不同的绘图符号和颜色来进行分面:
`fig` `=` `px``.``scatter``(``dogs``,` `x``=``'``height``'``,` `y``=``'``weight``'``,`
`color``=``'``size``'``,` `symbol``=``'``size``'``,`
`labels``=``dict``(``height``=``"``Height (cm)``"``,`
`weight``=``"``Weight (kg)``"``,` `size``=``"``Size``"``)``,`
`width``=``350``,` `height``=``250``)`
`fig`

下一个图显示了每个品种大小寿命的并列直方图。这里我们按列进行分面:
`fig` `=` `px``.``histogram``(``dogs``,` `x``=``'``longevity``'``,` `facet_col``=``'``size``'``,`
`width``=``550``,` `height``=``250``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``30``)``)`

要获取绘图函数的完整列表,请参阅plotly的主要文档或plotly.express,这是我们在本书中主要使用的plotly子模块。
要为图表添加背景信息,我们使用plotly的注释方法;接下来将对此进行描述。
注释
Figure.add_annotation()方法在plotly图表上放置注释。这些注释是带有文本和可选箭头的线段。箭头的位置使用x和y参数设置,我们可以使用ax和ay参数将文本从其默认位置移动。在这里,我们使用信息注释散点图的一个点:
fig = px.scatter(dogs, x='weight', y='longevity',
labels=dict(weight="Weight (kg)",
longevity="Typical lifespan (yr)"),
width=350, height=250)
fig.add_annotation(text='Chihuahuas live 16.5 years on average!',
x=2, y=16.5,
ax=30, ay=5,
xshift=3,
xanchor='left')
fig

本节介绍了使用plotly Python 包创建图表的基础知识。我们介绍了Figure对象,这是plotly用来存储图表及其布局的对象。我们讨论了plotly提供的基本图表类型,以及通过调整布局和坐标轴以及添加注释来自定义图表的几种方式。在下一节中,我们将简要比较plotly与 Python 中其他常见的可视化工具。
其他可视化工具
有许多软件包和工具可用于创建数据可视化。在本书中,我们主要使用plotly。但值得了解的是其他几种常用工具。在本节中,我们将plotly与matplotlib和图形语法工具进行比较。
matplotlib
matplotlib库是为 Python 创建的最早的数据可视化工具之一。因此,它被广泛使用,并拥有大量的软件包生态系统。值得注意的是,用于pandas DataFrames的内置绘图方法使用matplotlib。在matplotlib之上构建的一个流行包被称为seaborn。与仅使用matplotlib相比,seaborn提供了一个更简单的 API 来创建统计图,例如带有置信区间的点图。事实上,seaborn的 API 启发了plotly的 API。如果你将plotly和seaborn的代码并排比较,你会发现创建基本图表的方法使用了相似的代码。
使用matplotlib的一个优势是其流行性。因为许多现有项目使用它,因此在线获取帮助来创建或调整图表相对容易。对于本书而言,使用plotly的主要优势是我们创建的图表是交互式的。matplotlib中的图表通常是静态图像,不支持平移、缩放或悬停在标记上。尽管如此,我们预计matplotlib仍将继续用于数据分析。事实上,本书中的几个图表是使用seaborn和matplotlib制作的,因为plotly尚未支持我们想要制作的所有图表。
图形语法
图形语法是由李·威尔金森(Lee Wilkinson)为创建数据可视化而开发的理论。其基本思想是使用常见的构建块来制作图表。例如,柱状图和点图几乎是相同的,唯一的区别在于柱状图绘制矩形,而点图绘制点。这个想法被体现在图形语法中,它会说柱状图和点图只在它们的“几何”组件上有所不同。图形语法是一个优雅的系统,我们可以使用它来描述几乎我们想要制作的每一种图表。
这个系统在流行的绘图库ggplot2(用于 R 编程语言)和Vega(用于 JavaScript)中实现。Vega-Altair,一个 Python 包,提供了一种使用 Python 创建Vega图表的方法,我们鼓励感兴趣的读者阅读其文档。
使用像Vega-Altair这样的图形语法工具可以实现可视化的灵活性。像plotly一样,altair也创建交互式可视化。然而,这些工具的 Python API 可能没有plotly的 API 那么直接。在本书中,我们通常不需要plotly无法创建的图表,因此我们选择了plotly更简单的 API。
为了简洁起见,我们略去了 Python 中许多其他绘图工具。但对于本书的目的,依赖于plotly提供了交互性和灵活性的有用平衡。
总结
当我们分析数据集时,我们使用可视化来发现数据中难以以其他方式检测到的模式。数据可视化是一个迭代的过程。我们创建一个图,然后决定是否进行调整或选择一个全新的图表类型。本章介绍了我们用来做出这些决策的原则。
我们首先介绍了规模的原则,并看到通过改变或转换绘图轴来调整比例尺可以揭示数据中隐藏的结构。然后,我们讨论了平滑和聚合技术,这些技术帮助我们处理大型数据集,否则会导致过度绘制。为了进行有意义的比较,我们应用了知觉原则,例如调整基线以使线条、柱状图和点易于比较。我们展示了如何考虑数据设计来改进可视化效果。并且我们看到,为绘图添加上下文有助于读者理解我们的信息。
在这一章之后,你应该能够创建一个图表,并理解哪些调整能使图表更有效。当你学会如何制作信息丰富的可视化时,请保持耐心并进行迭代。我们没有人能在第一次尝试时做出完美的图表,在分析中我们发现了新的东西时,我们会继续改进我们的图表。然后,在向他人展示我们的发现时,我们会筛选出少数几个最能说服我们未来读者分析正确性和重要性的图表。这甚至可能会导致创建一个更好地传达我们发现的新图表,我们会进行迭代开发。
在下一章中,我们将通过一个扩展案例研究来展示我们迄今为止在书中学到的一切。我们希望你会对自己已经能做到的事情感到惊讶。
^(1) 美国政府调查仍然根据性别的二元定义收集数据,但进展正在取得。例如,从 2022 年开始,美国公民可以在他们的护照申请中选择“X”作为他们的性别标记。
第十二章:案例研究:空气质量测量的准确性有多高?
加利福尼亚州容易发生森林大火,以至于该州的居民(如本书的作者们)有时会说加利福尼亚州“总是在火灾中”。2020 年,40 起不同的火灾使得整个州笼罩在烟雾之中,迫使成千上万的人撤离,并造成超过 120 亿美元的损失(图 12-1)。

图 12-1 卫星图像,显示 2020 年 8 月加利福尼亚州被烟雾覆盖的情况(图片来源于Wikipedia,根据 CC BY-SA 3.0 IGO 许可)。
在像加利福尼亚这样的地方,人们利用空气质量测量来了解他们需要采取哪些保护措施。根据情况,人们可能希望戴口罩、使用空气过滤器或完全避免外出。
在美国,一个重要的空气质量信息来源是由美国政府运行的空气质量系统(AQS)。AQS 在美国各地的位置上安装了高质量的传感器,并向公众提供它们的数据。这些传感器经过严格的校准到严格的标准——实际上,AQS 传感器通常被视为准确度的黄金标准。然而,它们也有一些缺点。这些传感器很昂贵:每台通常在 15,000 至 40,000 美元之间。这意味着传感器数量较少,并且它们之间的距离较远。住在传感器远离的人可能无法获取 AQS 数据用于个人使用。此外,AQS 传感器不提供实时数据。由于数据经过广泛的校准,它们仅每小时发布一次,并且有一到两小时的时间滞后。实质上,AQS 传感器精确但不及时。
相比之下,PurpleAir传感器,我们在第三章介绍过,售价约 250 美元,可以轻松安装在家中。由于价格较低,成千上万的美国人购买了这些传感器用于个人使用。这些传感器可以连接到家庭 WiFi 网络,因此可以轻松监测空气质量,并可以向 PurpleAir 报告数据。2020 年,数千名 PurpleAir 传感器的所有者将他们传感器的测量数据公开发布。与 AQS 传感器相比,PurpleAir 传感器更及时。它们每两分钟报告一次测量结果,而不是每小时。由于部署了更多的 PurpleAir 传感器,更多的人住在接近传感器的地方,可以利用这些数据。然而,PurpleAir 传感器的准确性较低。为了使传感器价格更加合理,PurpleAir 使用了更简单的方法来计算空气中的颗粒物。这意味着 PurpleAir 的测量可能会报告空气质量比实际情况更差(见Josh Hug 的博客文章)。实质上,PurpleAir 传感器倾向于及时但准确性较低。
在本章中,我们计划利用 AQS 传感器的测量结果来改进 PurpleAir 的测量。这是一项重大任务,我们首先采用了由卡洛琳·巴克约恩、布雷特·甘特和安德里亚·克莱门斯从美国环境保护署开发的分析方法。巴克约恩及其同事的工作非常成功,以至于截至撰写本文时,类似 AirNow 的官方美国政府地图,如火灾与烟雾地图,都包括 AQS 和 PurpleAir 传感器,并对 PurpleAir 数据应用了巴克约恩的校正。
我们的工作遵循数据科学生命周期,从考虑问题和可用数据的设计和范围开始。我们大部分的工作都花费在清洗和整理数据以进行分析,但我们也进行了探索性数据分析并建立了一个泛化模型。我们首先考虑问题、设计和数据的范围。
问题、设计和范围
理想情况下,空气质量的测量应该既准确又及时。不准确或有偏差的测量可能意味着人们对空气质量的重视程度不够。延迟的警报可能会使人们暴露于有害空气中。在引言中提到廉价空气质量传感器的流行背景让我们对它们的质量和实用性产生了兴趣。
两种不同类型的仪器测量了一个自然现象——空气中颗粒物的数量。AQS 传感器具有测量误差小和偏差可以忽略不计的优势(参见第二章)。另一方面,PurpleAir 仪器的精度较低;测量值具有更大的变异性并且也有偏差。我们最初的问题是:我们能否利用 AQS 测量结果使 PurpleAir 的测量更准确?
我们现在处于一个有大量可用数据的情况中。我们可以访问少量来自 AQS 的高质量测量数据,并且可以从成千上万个 PurpleAir 传感器中获取数据。为了缩小我们问题的焦点,我们考虑如何利用这两个数据源来改进 PurpleAir 的测量。
这两个来源的数据包括传感器的位置。因此,我们可以尝试将它们配对,找到接近每个 AQS 传感器的 PurpleAir 传感器。如果它们很接近,那么这些传感器实际上就是在测量相同的空气。我们可以将 AQS 传感器视为地面真实情况(因为它们非常精确),并研究给定真实空气质量情况下 PurpleAir 测量值的变化。
即使合并的 AQS 和 PurpleAir 传感器对相对较少,将我们发现的任何关系普遍化到其他 PurpleAir 传感器似乎是合理的。如果 AQS 和 PurpleAir 测量之间存在简单的关系,那么我们可以利用这种关系来调整来自任何 PurpleAir 传感器的测量结果,使其更加准确。
我们已经明确了我们的问题:我们能否建立 PurpleAir 传感器读数与相邻 AQS 传感器读数之间的关系模型?如果可以,那么希望可以利用该模型改进 PurpleAir 的读数。剧透警告:确实可以!
本案例研究很好地整合了本书这一部分介绍的概念。它为我们提供了一个机会,看看数据科学家如何在实际环境中处理、探索和可视化数据。特别是,我们看到了如何通过大型、不太精确的数据集来增强小型、精确的数据集的有用性。像这样结合大型和小型数据集对数据科学家来说尤为激动人心,并广泛适用于从社会科学到医学等其他领域。
在接下来的部分中,我们通过寻找彼此靠近的 AQS 和 PurpleAir 传感器配对来进行数据处理。我们特别关注 PM2.5 颗粒的读数,这些颗粒直径小于 2.5 微米。这些颗粒足以吸入到肺部,对健康构成最大风险,并且在木材燃烧中特别常见。
寻找配对传感器
我们的分析始于寻找 AQS 和 PurpleAir 传感器的配对,即安装在基本相邻位置的传感器。这一步骤很重要,因为它使我们能够减少可能导致传感器读数差异的其他变量的影响。想象一下,如果我们比较一个放置在公园中的 AQS 传感器和一个放置在繁忙高速公路旁的 PurpleAir 传感器会发生什么。这两个传感器将有不同的读数,部分原因是传感器暴露在不同的环境中。确保传感器真正配对让我们可以声明传感器读数差异是由传感器构造方式和小范围空气波动引起的,而不是其他潜在混淆变量的影响。
由 EPA 小组进行的 Barkjohn 分析找到了在彼此距离不到 50 米的 AQS 和 PurpleAir 传感器配对。该小组联系了每个 AQS 站点,确认是否也安装了 PurpleAir 传感器。这额外的工作让他们对传感器配对的真实配对性充满信心。
在这一部分中,我们探索和清理来自 AQS 和 PurpleAir 的位置数据。然后,我们进行一种连接操作,构建一个潜在的配对传感器列表。我们不会自己联系 AQS 站点;相反,我们将在后续章节中使用 Barkjohn 确认的配对传感器列表。
我们下载了 AQS 和 PurpleAir 传感器列表,并将数据保存在文件 data/list_of_aqs_sites.csv 和 data/list_of_purpleair_sensors.json 中。让我们开始将这些文件读入 pandas DataFrames。首先,我们检查文件大小,看看它们是否可以合理地加载到内存中:
`!`ls -lLh data/list_of*
-rw-r--r-- 1 sam staff 4.8M Oct 27 16:54 data/list_of_aqs_sites.csv
-rw-r--r-- 1 sam staff 3.8M Oct 22 16:10 data/list_of_purpleair_sensors.json
两个文件都相对较小。让我们从 AQS 站点列表开始。
处理 AQS 站点列表
我们已过滤出仅显示测量 PM2.5 的 AQS 站点的AQS 站点地图,然后使用该地图的 Web 应用程序下载站点列表作为 CSV 文件。现在我们可以将其加载到pandas DataFrame中:
`aqs_sites_full` `=` `pd``.``read_csv``(``'``data/list_of_aqs_sites.csv``'``)`
`aqs_sites_full``.``shape`
(1333, 28)
表中有 28 列。让我们检查列名:
`aqs_sites_full``.``columns`
Index(['AQS_Site_ID', 'POC', 'State', 'City', 'CBSA', 'Local_Site_Name',
'Address', 'Datum', 'Latitude', 'Longitude', 'LatLon_Accuracy_meters',
'Elevation_meters_MSL', 'Monitor_Start_Date', 'Last_Sample_Date',
'Active', 'Measurement_Scale', 'Measurement_Scale_Definition',
'Sample_Duration', 'Sample_Collection_Frequency',
'Sample_Collection_Method', 'Sample_Analysis_Method',
'Method_Reference_ID', 'FRMFEM', 'Monitor_Type', 'Reporting_Agency',
'Parameter_Name', 'Annual_URLs', 'Daily_URLs'],
dtype='object')
为了找出对我们最有用的哪些列,我们参考了 AQS 在其网站上提供的数据字典。在那里,我们确认数据表包含有关 AQS 站点的信息。因此,我们可能期望粒度对应于 AQS 站点,即每行表示一个单个站点,标记为AQS_Site_ID的列是主键。我们可以通过每个 ID 的记录计数来确认这一点:
`aqs_sites_full``[``'``AQS_Site_ID``'``]``.``value_counts``(``)`
06-071-0306 4
19-163-0015 4
39-061-0014 4
..
46-103-0020 1
19-177-0006 1
51-680-0015 1
Name: AQS_Site_ID, Length: 921, dtype: int64
看起来有些站点在这个数据框中出现了多次。不幸的是,这意味着粒度比单个站点级别更细。为了弄清楚站点重复的原因,让我们更仔细地查看一个重复站点的行:
`dup_site` `=` `aqs_sites_full``.``query``(``"``AQS_Site_ID ==` `'``19-163-0015``'``"``)`
根据列名选择几列进行检查——那些听起来可能会揭示重复原因的列:
`some_cols` `=` `[``'``POC``'``,` `'``Monitor_Start_Date``'``,`
`'``Last_Sample_Date``'``,` `'``Sample_Collection_Method``'``]`
`dup_site``[``some_cols``]`
| POC | Monitor_Start_Date | Last_Sample_Date | Sample_Collection_Method | |
|---|---|---|---|---|
| 458 | 1 | 1/27/1999 | 8/31/2021 | R & P Model 2025 PM-2.5 Sequential Air Sampler... |
| 459 | 2 | 2/9/2013 | 8/26/2021 | R & P Model 2025 PM-2.5 Sequential Air Sampler... |
| 460 | 3 | 1/1/2019 | 9/30/2021 | Teledyne T640 at 5.0 LPM |
| 461 | 4 | 1/1/2019 | 9/30/2021 | Teledyne T640 at 5.0 LPM |
POC列看起来对于区分表中的行是有用的。数据字典对该列有以下说明:
这是用于区分在同一站点上测量相同参数的不同仪器的“参数发生代码”。
因此,站点19-163-0015有四个仪器都测量 PM2.5。数据框的粒度是单个仪器级别。
由于我们的目标是匹配 AQS 和 PurpleAir 传感器,我们可以通过选择每个 AQS 站点的一个仪器来调整粒度。为此,我们根据站点 ID 对行进行分组,然后在每个组中取第一行:
`def` `rollup_dup_sites``(``df``)``:`
`return` `(`
`df``.``groupby``(``'``AQS_Site_ID``'``)`
`.``first``(``)`
`.``reset_index``(``)`
`)`
`aqs_sites` `=` `(``aqs_sites_full`
`.``pipe``(``rollup_dup_sites``)``)`
`aqs_sites``.``shape`
(921, 28)
现在行数与唯一 ID 的数量匹配。
要与 PurpleAir 传感器匹配 AQS 站点,我们只需要站点 ID、纬度和经度。因此,我们进一步调整结构,仅保留这些列:
`def` `cols_aqs``(``df``)``:`
`subset` `=` `df``[``[``'``AQS_Site_ID``'``,` `'``Latitude``'``,` `'``Longitude``'``]``]`
`subset``.``columns` `=` `[``'``site_id``'``,` `'``lat``'``,` `'``lon``'``]`
`return` `subset`
`aqs_sites` `=` `(``aqs_sites_full`
`.``pipe``(``rollup_dup_sites``)`
`.``pipe``(``cols_aqs``)``)`
现在aqs_sites数据框已准备就绪,我们转向 PurpleAir 站点。
整理 PurpleAir 站点列表
不同于 AQS 站点,包含 PurpleAir 传感器数据的文件是以 JSON 格式提供的。我们将在第十四章中更详细地讨论这种格式。现在,我们使用 shell 工具(参见第八章)来查看文件内容:
`!`head data/list_of_purpleair_sensors.json `|` cut -c `1`-60
{"version":"7.0.30",
"fields":
["ID","pm","pm_cf_1","pm_atm","age","pm_0","pm_1","pm_2","pm
"data":[
[20,0.0,0.0,0.0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,97,0.0,0.0,0.0
[47,null,null,null,4951,null,null,null,null,null,null,null,9
[53,0.0,0.0,0.0,0,0.0,0.0,0.0,0.0,1.2,5.2,6.0,97,0.0,0.5,702
[74,0.0,0.0,0.0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,97,0.0,0.0,0.0
[77,9.8,9.8,9.8,1,9.8,10.7,11.0,11.2,13.8,15.1,15.5,97,9.7,9
[81,6.5,6.5,6.5,0,6.5,6.1,6.1,6.6,8.1,8.3,9.7,97,5.9,6.8,405
从文件的前几行可以猜测数据存储在"data"键中,列标签存储在"fields"键中。我们可以使用 Python 的json库将文件读取为 Python 的dict:
`import` `json`
`with` `open``(``'``data/list_of_purpleair_sensors.json``'``)` `as` `f``:`
`pa_json` `=` `json``.``load``(``f``)`
`list``(``pa_json``.``keys``(``)``)`
['version', 'fields', 'data', 'count']
我们可以从data中的值创建一个数据框,并使用fields的内容标记列:
`pa_sites_full` `=` `pd``.``DataFrame``(``pa_json``[``'``data``'``]``,` `columns``=``pa_json``[``'``fields``'``]``)`
`pa_sites_full``.``head``(``)`
| ID | pm | pm_cf_1 | pm_atm | ... | Voc | Ozone1 | Adc | CH | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 20 | 0.0 | 0.0 | 0.0 | ... | NaN | NaN | 0.01 | 1 |
| 1 | 47 | NaN | NaN | NaN | ... | NaN | 0.72 | 0.72 | 0 |
| 2 | 53 | 0.0 | 0.0 | 0.0 | ... | NaN | NaN | 0.00 | 1 |
| 3 | 74 | 0.0 | 0.0 | 0.0 | ... | NaN | NaN | 0.05 | 1 |
| 4 | 77 | 9.8 | 9.8 | 9.8 | ... | NaN | NaN | 0.01 | 1 |
5 rows × 36 columns
与 AQS 数据类似,此数据框中的列比我们需要的多得多:
`pa_sites_full``.``columns`
Index(['ID', 'pm', 'pm_cf_1', 'pm_atm', 'age', 'pm_0', 'pm_1', 'pm_2', 'pm_3',
'pm_4', 'pm_5', 'pm_6', 'conf', 'pm1', 'pm_10', 'p1', 'p2', 'p3', 'p4',
'p5', 'p6', 'Humidity', 'Temperature', 'Pressure', 'Elevation', 'Type',
'Label', 'Lat', 'Lon', 'Icon', 'isOwner', 'Flags', 'Voc', 'Ozone1',
'Adc', 'CH'],
dtype='object')
在这种情况下,我们可以猜测我们最感兴趣的列是传感器 ID (ID)、传感器标签 (Label)、纬度 (Lat)和经度 (Lon)。但我们确实在 PurpleAir 网站的数据字典中进行了查阅以进行双重检查。
现在让我们检查ID列是否存在重复,就像我们对 AQS 数据所做的那样:
`pa_sites_full``[``'``ID``'``]``.``value_counts``(``)``[``:``3``]`
85829 1
117575 1
118195 1
Name: ID, dtype: int64
由于 value_counts() 方法按降序列出计数,我们可以看到每个 ID 仅包含一次。因此,我们已验证粒度处于单个传感器级别。接下来,我们保留仅需要的列以匹配来自两个源的传感器位置:
`def` `cols_pa``(``df``)``:`
`subset` `=` `df``[``[``'``ID``'``,` `'``Label``'``,` `'``Lat``'``,` `'``Lon``'``]``]`
`subset``.``columns` `=` `[``'``id``'``,` `'``label``'``,` `'``lat``'``,` `'``lon``'``]`
`return` `subset`
`pa_sites` `=` `(``pa_sites_full`
`.``pipe``(``cols_pa``)``)`
`pa_sites``.``shape`
(23138, 4)
注意,PurpleAir 传感器比 AQS 传感器多数万台。我们的下一个任务是找到每个 AQS 传感器附近的 PurpleAir 传感器。
匹配 AQS 和 PurpleAir 传感器
我们的目标是通过找到每个 AQS 仪器附近的 PurpleAir 传感器来匹配两个数据框中的传感器。我们认为附近意味着在 50 米范围内。这种匹配比我们到目前为止见过的连接更具挑战性。例如,使用pandas的merge方法的天真方法会失败:
`aqs_sites``.``merge``(``pa_sites``,` `left_on``=``[``'``lat``'``,` `'``lon``'``]``,` `right_on``=``[``'``lat``'``,` `'``lon``'``]``)`
| site_id | lat | lon | id | label | |
|---|---|---|---|---|---|
| 0 | 06-111-1004 | 34.45 | -119.23 | 48393 | VCAPCD OJ |
我们不能简单地匹配具有完全相同纬度和经度的仪器;我们需要找到足够接近 AQS 仪器的 PurpleAir 站点。
要找出两个位置之间有多远,我们使用一个基本的近似方法:在南北方向上,111,111米大致相当于一度纬度,在东西方向上,111,111 * cos(纬度)相当于一度经度。^(1) 因此,我们可以找到对应于每个点周围 50 米×50 米矩形的纬度和经度范围:
`magic_meters_per_lat` `=` `111_111`
`offset_in_m` `=` `25`
`offset_in_lat` `=` `offset_in_m` `/` `magic_meters_per_lat`
`offset_in_lat`
0.000225000225000225
为了进一步简化,我们使用 AQS 站点的中位纬度:
`median_latitude` `=` `aqs_sites``[``'``lat``'``]``.``median``(``)`
`magic_meters_per_lon` `=` `111_111` `*` `np``.``cos``(``np``.``radians``(``median_latitude``)``)`
`offset_in_lon` `=` `offset_in_m` `/` `magic_meters_per_lon`
`offset_in_lon`
0.000291515219937587
现在我们可以将坐标匹配到offset_in_lat和offset_in_lon之内。在 SQL 中执行此操作比在pandas中要容易得多,因此我们将表推入临时 SQLite 数据库,然后运行查询以将表读回到数据框中:
`import` `sqlalchemy`
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite://``'``)`
`aqs_sites``.``to_sql``(``name``=``'``aqs``'``,` `con``=``db``,` `index``=``False``)`
`pa_sites``.``to_sql``(``name``=``'``pa``'``,` `con``=``db``,` `index``=``False``)`
`query` `=` `f``'''`
`SELECT`
`aqs.site_id AS aqs_id,`
`pa.id AS pa_id,`
`pa.label AS pa_label,`
`aqs.lat AS aqs_lat,`
`aqs.lon AS aqs_lon,`
`pa.lat AS pa_lat,`
`pa.lon AS pa_lon`
`FROM aqs JOIN pa`
`ON pa.lat -` `{``offset_in_lat``}` `<= aqs.lat`
`AND aqs.lat <= pa.lat +` `{``offset_in_lat``}`
`AND pa.lon -` `{``offset_in_lon``}` `<= aqs.lon`
`AND aqs.lon <= pa.lon +` `{``offset_in_lon``}`
`'''`
`matched` `=` `pd``.``read_sql``(``query``,` `db``)`
`matched`
| aqs_id | pa_id | pa_label | aqs_lat | aqs_lon | pa_lat | pa_lon | |
|---|---|---|---|---|---|---|---|
| 0 | 06-019-0011 | 6568 | IMPROVE_FRES2 | 36.79 | -119.77 | 36.79 | -119.77 |
| 1 | 06-019-0011 | 13485 | AMTS_Fresno | 36.79 | -119.77 | 36.79 | -119.77 |
| 2 | 06-019-0011 | 44427 | Fresno CARB CCAC | 36.79 | -119.77 | 36.79 | -119.77 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 146 | 53-061-1007 | 3659 | Marysville 7th | 48.05 | -122.17 | 48.05 | -122.17 |
| 147 | 53-063-0021 | 54603 | Augusta 1 SRCAA | 47.67 | -117.36 | 47.67 | -117.36 |
| 148 | 56-021-0100 | 50045 | WDEQ-AQD Cheyenne NCore | 41.18 | -104.78 | 41.18 | -104.78 |
149 rows × 7 columns
我们已经达到了我们的目标——我们匹配了 149 个 AQS 站点与 PurpleAir 传感器。我们对位置的整理完成了,现在转向整理和清理传感器测量数据的任务。我们从 AQS 站点获取的测量值开始。
整理和清理 AQS 传感器数据
现在我们已经找到彼此附近的传感器,我们准备整理和清理这些站点的测量数据文件。我们演示了一个 AQS 仪器及其匹配的 PurpleAir 传感器涉及的任务。我们选择了位于加利福尼亚州萨克拉门托的一对。AQS 传感器 ID 是 06-067-0010,PurpleAir 传感器名称是 AMTS_TESTINGA。
AQS 提供了一个网站和 API 用于下载传感器数据。我们下载了从 2018 年 5 月 20 日到 2019 年 12 月 29 日的每日测量数据,保存在 data/aqs_06-067-0010.csv 文件中。让我们从加载这个文件到数据框架开始:
`aqs_full` `=` `pd``.``read_csv``(``'``data/aqs_06-067-0010.csv``'``)`
`aqs_full``.``shape`
(2268, 31)
从 数据字典 中我们发现,arithmetic_mean 列对应于实际的 PM2.5 测量。一些 AQS 传感器每小时进行一次测量。对于我们的分析,我们下载了每日平均值(算术平均值)的 24 小时平均值的传感器测量。
让我们进行一些质量检查和必要的数据清理。我们关注与值的范围和质量相关的检查:
-
检查和修正数据的粒度。
-
删除不需要的列。
-
检查
date_local列中的值。 -
检查
arithmetic_mean列中的值。
为简洁起见,我们选择了一些重要的质量检查,特别是加强了我们在数据整理、探索性数据分析和可视化中涵盖的思想。
检查粒度
我们希望我们数据的每一行对应于单个日期,具有该日期的平均 PM2.5 读数。正如我们之前看到的,一个简单的检查方法是查看 date_local 列中是否有重复值:
`aqs_full``[``'``date_local``'``]``.``value_counts``(``)`
date_local
2019-01-03 12
2018-12-31 12
2018-12-28 12
..
2018-11-28 12
2018-11-25 12
2018-11-22 12
Name: count, Length: 189, dtype: int64
实际上,每个日期有 12 行数据,因此粒度并不是在个体日期级别上。
从数据字典中我们得知,有多种标准用于从原始传感器数据计算最终测量结果。pollutant_standard 列包含每个标准的名称。event_type 列标记了是否包括“异常事件”期间测得的数据。让我们通过计算这 12 个测量的范围来检查这些平均值有多么不同:
`(``aqs_full`
`.``groupby``(``'``date_local``'``)`
`[``'``arithmetic_mean``'``]`
`.``agg``(``np``.``ptp``)` `# np.ptp computes max() - min()`
`.``value_counts``(``)`
`)`
arithmetic_mean
0.0 189
Name: count, dtype: int64
对于所有 189 个日期,最大 PM2.5–最小 PM2.5 为 0。这意味着我们只需取每个日期的第一个 PM2.5 测量值:
`def` `rollup_dates``(``df``)``:`
`return` `(`
`df``.``groupby``(``'``date_local``'``)`
`.``first``(``)`
`.``reset_index``(``)`
`)`
`aqs` `=` `(``aqs_full`
`.``pipe``(``rollup_dates``)``)`
`aqs``.``shape`
(189, 31)
此数据清理步骤使我们获得了所需的细粒度:每一行代表一个单独的日期,具有该日期的平均 PM2.5 测量值。接下来,我们进一步修改数据框的结构并删除不需要的列。
移除不必要的列
我们计划将 AQS 数据框中的 PM2.5 测量与 PurpleAir 的 PM2.5 测量进行匹配,以每日为单位。为简化结构,我们可以舍弃除日期和 PM2.5 列外的所有列。我们还将 PM2.5 列重命名,以便更容易理解:
`def` `drop_cols``(``df``)``:`
`subset` `=` `df``[``[``'``date_local``'``,` `'``arithmetic_mean``'``]``]`
`return` `subset``.``rename``(``columns``=``{``'``arithmetic_mean``'``:` `'``pm25``'``}``)`
`aqs` `=` `(``aqs_full`
`.``pipe``(``rollup_dates``)`
`.``pipe``(``drop_cols``)``)`
`aqs``.``head``(``)`
| date_local | pm25 | |
|---|---|---|
| 0 | 2018-05-20 | 6.5 |
| 1 | 2018-05-23 | 2.3 |
| 2 | 2018-05-29 | 11.8 |
| 3 | 2018-06-01 | 6.0 |
| 4 | 2018-06-04 | 8.0 |
现在我们已经为数据表获得了所需的形状,我们转而检查数据值。
检查日期的有效性
让我们仔细查看日期。我们已经看到当没有 PM2.5 读数时存在间隙,因此我们预计存在缺失日期。让我们将日期解析为时间戳对象,以便更容易确定缺失的日期。与我们在 第九章 中所做的一样,我们检查格式:
`aqs``[``'``date_local``'``]``.``iloc``[``:``3``]`
0 2018-05-20
1 2018-05-23
2 2018-05-29
Name: date_local, dtype: object
日期以 YYYY-MM-DD 表示,因此我们在 Python 表示中描述为 '%Y-%m-%d'。为了解析日期,我们使用 pd.to_datetime() 函数,并将 date_local 列重新分配为 pd.TimeStamp:
`def` `parse_dates``(``df``)``:`
`date_format` `=` `'``%Y``-``%m``-``%d``'`
`timestamps` `=` `pd``.``to_datetime``(``df``[``'``date_local``'``]``,` `format``=``date_format``)`
`return` `df``.``assign``(``date_local``=``timestamps``)`
`aqs` `=` `(``aqs_full`
`.``pipe``(``rollup_dates``)`
`.``pipe``(``drop_cols``)`
`.``pipe``(``parse_dates``)``)`
该方法运行无误,表明所有字符串都匹配了格式。
注意
仅仅因为日期可以解析,这并不意味着日期立即可以用于进一步的分析。例如,字符串 9999-01-31 可以解析为 pd.TimeStamp,但该日期无效。
现在日期已转换为时间戳,我们可以计算缺失的日期数。我们找到最早日期和最晚日期之间的天数——这对应于我们可能记录的最大测量次数:
`date_range` `=` `aqs``[``'``date_local``'``]``.``max``(``)` `-` `aqs``[``'``date_local``'``]``.``min``(``)`
`date_range``.``days`
588
减去时间戳会得到 Timedelta 对象,正如我们所看到的,它们具有一些有用的属性。数据中有很多日期缺失。然而,当我们将此传感器的这些数据与其他传感器的数据结合起来时,我们希望有足够的数据来拟合一个模型。
我们的最终整理步骤是检查 PM2.5 测量的质量。
检查 PM2.5 测量质量
颗粒物浓度以每立方米空气中的微克数(µg/m³)来衡量。(1 克中有 1 百万微克,1 磅约等于 450 克。)EPA 制定了标准,即 PM2.5 的日均值为 35 µg/m³,年均值为 12 µg/m³。我们可以利用这些信息对 PM2.5 测量进行几项基本检查。首先,PM2.5 不能低于 0。其次,我们可以寻找异常高的 PM2.5 值,并查看它们是否对应于重大事件,如野火。
进行这些检查的一种视觉方法是将 PM2.5 测量值绘制成日期图:
`px``.``scatter``(``aqs``,` `x``=``'``date_local``'``,` `y``=``'``pm25``'``,`
`labels``=``{``'``date_local``'``:``'``Date``'``,` `'``pm25``'``:``'``AQS daily avg PM2.5``'``}``,`
`width``=``500``,` `height``=``250``)`

我们发现 PM2.5 的测量数值不会低于 0,通常低于 EPA 的标准。我们还发现 2018 年 11 月中旬 PM2.5 出现了大幅上升。该传感器位于萨克拉门托,因此我们可以检查该地区是否发生了火灾。
实际上,2018 年 11 月 8 日标志着“加州历史上最致命和破坏性的野火”——“大火营”(见由美国人口普查局管理的大火营页面)。火灾发生在距离萨克拉门托仅 80 英里的地方,因此这个 AQS 传感器捕捉到了 PM2.5 的剧烈增长。
我们已经清理和探索了一个 AQS 传感器的数据。在下一节中,我们将对其附近的 PurpleAir 传感器进行相同的操作。
整理 PurpleAir 传感器数据
在上一节中,我们分析了 AQS 站点 06-067-0010 的数据。匹配的 PurpleAir 传感器命名为 AMTS_TESTINGA,我们已经使用 PurpleAir 网站将此传感器的数据下载到 data/purpleair_AMTS 文件夹中:
`!`ls -alh data/purpleair_AMTS/* `|` cut -c `1`-72
-rw-r--r-- 1 nolan staff 50M Jan 25 16:35 data/purpleair_AMTS/AMTS_
-rw-r--r-- 1 nolan staff 50M Jan 25 16:35 data/purpleair_AMTS/AMTS_
-rw-r--r-- 1 nolan staff 48M Jan 25 16:35 data/purpleair_AMTS/AMTS_
-rw-r--r-- 1 nolan staff 50M Jan 25 16:35 data/purpleair_AMTS/AMTS_
有四个 CSV 文件。它们的名称相当长,并且每个的开头都相同。PurpleAir 数据的数据字典表明,每个传感器都有两个单独的仪器 A 和 B,每个都记录数据。请注意,我们用于收集这些数据和配套数据字典的 PurpleAir 网站已降级。数据现在通过 REST API 可用。记录 API 的站点还包含有关字段的信息。(REST 的主题在第十四章中涵盖。)让我们检查文件名的后部分:
`!`ls -alh data/purpleair_AMTS/* `|` cut -c `73`-140
TESTING (outside) (38.568404 -121.493163) Primary Real Time 05_20_20
TESTING (outside) (38.568404 -121.493163) Secondary Real Time 05_20_
TESTING B (undefined) (38.568404 -121.493163) Primary Real Time 05_2
TESTING B (undefined) (38.568404 -121.493163) Secondary Real Time 05
我们可以看到前两个 CSV 文件对应于仪器 A,最后两个对应于 B。拥有两个仪器对数据清理很有用;如果 A 和 B 对某个测量结果有异议,我们可能会质疑测量结果的完整性,并决定删除它。
数据字典还提到,每个仪器记录主要和次要数据。主要数据包含我们感兴趣的字段:PM2.5、温度和湿度。次要数据包含其他粒径的数据,如 PM1.0 和 PM10。因此我们只使用主文件。
我们的任务与上一节类似,只是增加了处理两台仪器读数的内容。
我们首先加载数据。当 CSV 文件名很长时,我们可以将文件名分配给 Python 变量,以更轻松地加载文件:
`from` `pathlib` `import` `Path`
`data_folder` `=` `Path``(``'``data/purpleair_AMTS``'``)`
`pa_csvs` `=` `sorted``(``data_folder``.``glob``(``'``*.csv``'``)``)`
`pa_csvs``[``0``]`
PosixPath('data/purpleair_AMTS/AMTS_TESTING (outside) (38.568404 -121.493163) Primary Real Time 05_20_2018 12_29_2019.csv')
`pa_full` `=` `pd``.``read_csv``(``pa_csvs``[``0``]``)`
`pa_full``.``shape`
(672755, 11)
让我们看一下列,确定我们需要哪些列:
`pa_full``.``columns`
Index(['created_at', 'entry_id', 'PM1.0_CF1_ug/m3', 'PM2.5_CF1_ug/m3',
'PM10.0_CF1_ug/m3', 'UptimeMinutes', 'RSSI_dbm', 'Temperature_F',
'Humidity_%', 'PM2.5_ATM_ug/m3', 'Unnamed: 10'],
dtype='object')
虽然我们对 PM2.5 感兴趣,但似乎有两列包含 PM2.5 数据:PM2.5_CF1_ug/m3 和 PM2.5_ATM_ug/m3。我们调查了这两列之间的差异,发现 PurpleAir 传感器使用两种不同的方法将原始激光记录转换为 PM2.5 数值。这两种计算对应于 CF1 和 ATM 列。Barkjohn 发现使用 CF1 比 ATM 产生了更好的结果,因此我们保留该列,以及日期、温度和相对湿度:
`def` `drop_and_rename_cols``(``df``)``:`
`df` `=` `df``[``[``'``created_at``'``,` `'``PM2.5_CF1_ug/m3``'``,` `'``Temperature_F``'``,` `'``Humidity_``%``'``]``]`
`df``.``columns` `=` `[``'``timestamp``'``,` `'``PM25cf1``'``,` `'``TempF``'``,` `'``RH``'``]`
`return` `df`
`pa` `=` `(``pa_full`
`.``pipe``(``drop_and_rename_cols``)``)`
`pa``.``head``(``)`
| 时间戳 | PM25cf1 | TempF | RH | |
|---|---|---|---|---|
| 0 | 2018-05-20 00:00:35 UTC | 1.23 | 83.0 | 32.0 |
| 1 | 2018-05-20 00:01:55 UTC | 1.94 | 83.0 | 32.0 |
| 2 | 2018-05-20 00:03:15 UTC | 1.80 | 83.0 | 32.0 |
| 3 | 2018-05-20 00:04:35 UTC | 1.64 | 83.0 | 32.0 |
| 4 | 2018-05-20 00:05:55 UTC | 1.33 | 83.0 | 32.0 |
接下来我们检查粒度。
检查粒度
为了使这些测量的粒度与 AQS 数据匹配,我们希望每个日期(24 小时)有一个平均 PM2.5。PurpleAir 表示传感器每两分钟进行一次测量。在我们将其聚合到 24 小时期间之前,让我们再次检查原始测量的粒度。
要实现这一点,我们将包含日期信息的列从字符串转换为 pd.TimeStamp 对象。日期的格式与 AQS 格式不同,我们描述为 '%Y-%m-%d %X %Z'。正如我们所见,pandas 对于具有时间戳索引的数据框架有特殊支持:
`def` `parse_timestamps``(``df``)``:`
`date_format` `=` `'``%Y``-``%m``-``%d` `%X` `%``Z``'`
`times` `=` `pd``.``to_datetime``(``df``[``'``timestamp``'``]``,` `format``=``date_format``)`
`return` `(``df``.``assign``(``timestamp``=``times``)`
`.``set_index``(``'``timestamp``'``)``)`
`pa` `=` `(``pa_full`
`.``pipe``(``drop_and_rename_cols``)`
`.``pipe``(``parse_timestamps``)``)`
`pa``.``head``(``2``)`
| PM25cf1 | TempF | RH | |
|---|---|---|---|
| 时间戳 | |||
| --- | --- | --- | --- |
| 2018-05-20 00:00:35+00:00 | 1.23 | 83.0 | 32.0 |
| 2018-05-20 00:01:55+00:00 | 1.94 | 83.0 | 32.0 |
时间戳很棘手 — 注意原始时间戳是以 UTC 时区给出的。然而,AQS 数据根据 加利福尼亚州当地时间 平均,这比 UTC 时间晚七或八小时,这取决于是否实行夏令时。这意味着我们需要更改 PurpleAir 时间戳的时区以匹配当地时区。df.tz_convert() 方法作用于数据框架的索引,这也是我们将 pa 的索引设置为时间戳的一个原因:
`def` `convert_tz``(``pa``)``:`
`return` `pa``.``tz_convert``(``'``US/Pacific``'``)`
`pa` `=` `(``pa_full`
`.``pipe``(``drop_and_rename_cols``)`
`.``pipe``(``parse_timestamps``)`
`.``pipe``(``convert_tz``)``)`
`pa``.``head``(``2``)`
| PM25cf1 | TempF | RH | |
|---|---|---|---|
| 时间戳 | |||
| --- | --- | --- | --- |
| 2018-05-19 17:00:35-07:00 | 1.23 | 83.0 | 32.0 |
| 2018-05-19 17:01:55-07:00 | 1.94 | 83.0 | 32.0 |
如果我们将此版本的数据框架的前两行与上一个进行比较,我们会看到时间已更改以表明与 UTC 相差七个小时。
可视化时间戳可以帮助我们检查数据的粒度。
可视化时间戳
可视化时间戳的一种方法是计算每个 24 小时内出现的次数,然后绘制这些计数随时间的变化。要在 pandas 中对时间序列数据进行分组,可以使用 df.resample() 方法。此方法适用于具有时间戳索引的数据框。它的行为类似于 df.groupby(),但我们可以指定希望如何分组时间戳——可以分组为日期、周、月等多种选项(D 参数告诉 resample 将时间戳聚合成单独的日期):
`per_day` `=` `(``pa``.``resample``(``'``D``'``)`
`.``size``(``)`
`.``rename``(``'``records_per_day``'``)`
`.``to_frame``(``)`
`)`
`percs` `=` `[``10``,` `25``,` `50``,` `75``,` `100``]`
`np``.``percentile``(``per_day``[``'``records_per_day``'``]``,` `percs``,` `method``=``'``lower``'``)`
array([ 293, 720, 1075, 1440, 2250])
我们可以看到每天的测量次数变化很大。这些计数的折线图能更好地展示这些变化:
`px``.``line``(``per_day``,` `x``=``per_day``.``index``,` `y``=``'``records_per_day``'``,`
`labels``=``{``'``timestamp``'``:``'``Date``'``,` `'``records_per_day``'``:``'``Records per day``'``}``,`
`width``=``550``,` `height``=``250``,``)`

这是一个引人入胜的图表。我们可以看到数据中存在明显的缺失测量间隙。在 2018 年 7 月和 2019 年 9 月,数据的大部分似乎都丢失了。即使传感器运行正常,每天的测量次数也略有不同。例如,在 2018 年 8 月至 10 月之间的折线图上“崎岖不平”,日期上的测量次数有所变化。我们需要决定如何处理缺失数据。但也许更紧迫的是:折线图上出现了奇怪的“阶梯”。一些日期大约有 1,000 个读数,一些大约有 2,000 个,一些大约有 700 个,一些大约有 1,400 个。如果传感器每两分钟进行一次测量,则每天的最大测量次数应为 720 次。对于完美的传感器,折线图应显示为 720 次的平直线。显然情况并非如此。让我们来调查一下。
检查采样率
进一步的挖掘显示,尽管 PurpleAir 传感器当前每 120 秒记录一次数据,但以前并非如此。2019 年 5 月 30 日之前,传感器每 80 秒记录一次数据,即每天 1,080 个数据点。采样率的变化确实解释了 2019 年 5 月 30 日的数据下降。接下来我们看看时间段内存在远高于预期点数的情况。这可能意味着数据中存在重复的测量。我们可以通过查看一天的测量数据来验证这一点,例如 2019 年 1 月 1 日。我们通过向 .loc 传入字符串来筛选该日期的时间戳:
`len``(``pa``.``loc``[``'``2019-01-01``'``]``)`
2154
几乎有 1,080 个预期读数的两倍。让我们检查是否存在重复读数:
`pa``.``loc``[``'``2019-01-01``'``]``.``index``.``value_counts``(``)`
2019-01-01 13:52:30-08:00 2
2019-01-01 12:02:21-08:00 2
2019-01-01 11:49:01-08:00 2
..
2019-01-01 21:34:10-08:00 2
2019-01-01 11:03:41-08:00 2
2019-01-01 04:05:38-08:00 2
Name: timestamp, Length: 1077, dtype: int64
每个时间戳正好出现两次,我们可以验证所有重复的日期包含相同的 PM2.5 读数。由于温度和湿度也是如此,我们从数据框中删除重复行:
`def` `drop_duplicate_rows``(``df``)``:`
`return` `df``[``~``df``.``index``.``duplicated``(``)``]`
`pa` `=` `(``pa_full`
`.``pipe``(``drop_and_rename_cols``)`
`.``pipe``(``parse_timestamps``)`
`.``pipe``(``convert_tz``)`
`.``pipe``(``drop_duplicate_rows``)``)`
`pa``.``shape`
(502628, 3)
为了检查,我们重新制作了每天记录数的折线图,这次我们会在预期包含的区域内填充颜色:
`per_day` `=` `(``pa``.``resample``(``'``D``'``)`
`.``size``(``)``.``rename``(``'``records_per_day``'``)`
`.``to_frame``(``)`
`)`
`fig` `=` `px``.``line``(``per_day``,` `x``=``per_day``.``index``,` `y``=``'``records_per_day``'``,`
`labels``=``{``'``timestamp``'``:``'``Date``'``,` `'``records_per_day``'``:``'``Records per day``'``}``,`
`width``=``550``,` `height``=``250``)`
`fig``.``add_annotation``(``x``=``'``2019-07-24``'``,` `y``=``720``,`
`text``=``"``720``"``,` `showarrow``=``False``,` `yshift``=``10``)`
`fig``.``add_annotation``(``x``=``'``2019-07-24``'``,` `y``=``1080``,`
`text``=``"``1080``"``,` `showarrow``=``False``,` `yshift``=``10``)`
`fig``.``add_hline``(``y``=``1080``,` `line_width``=``3``,` `line_dash``=``"``dot``"``,` `opacity``=``0.6``)`
`fig``.``add_hline``(``y``=``720``,` `line_width``=``3``,` `line_dash``=``"``dot``"``,` `opacity``=``0.6``)`
`fig``.``add_vline``(``x``=``"``2019-05-30``"``,` `line_width``=``3``,` `line_dash``=``"``dash``"``,` `opacity``=``0.6``)`
`fig`

去除重复日期后,每天的测量数据图与我们预期的计数更一致。细心的读者会发现每年 11 月左右,在取消夏令时后,有两次高于最大测量值的峰值。当时钟被倒退一小时时,那一天有 25 小时而不是通常的 24 小时。时间戳确实很棘手!
但仍然有缺失的测量值,我们需要决定如何处理。
处理缺失值
计划是创建测量值的 24 小时平均,但我们不想使用测量不足的日子。我们遵循 Barkjohn 的分析,仅在该日有至少 90%可能数据点时保留 24 小时平均。请记住,在 2019 年 5 月 30 日之前,一天有 1080 个可能数据点,之后为 720 个可能数据点。我们计算了每天需要保留的最少测量数:
`needed_measurements_80s` `=` `0.9` `*` `1080`
`needed_measurements_120s` `=` `0.9` `*` `720`
现在我们可以确定哪些日子有足够的测量数据可以保留:
`cutoff_date` `=` `pd``.``Timestamp``(``'``2019-05-30``'``,` `tz``=``'``US/Pacific``'``)`
`def` `has_enough_readings``(``one_day``)``:`
`[``n``]` `=` `one_day`
`date` `=` `one_day``.``name`
`return` `(``n` `>``=` `needed_measurements_80s`
`if` `date` `<``=` `cutoff_date`
`else` `n` `>``=` `needed_measurements_120s``)`
`should_keep` `=` `per_day``.``apply``(``has_enough_readings``,` `axis``=``'``columns``'``)`
`should_keep``.``head``(``)`
timestamp
2018-05-19 00:00:00-07:00 False
2018-05-20 00:00:00-07:00 True
2018-05-21 00:00:00-07:00 True
2018-05-22 00:00:00-07:00 True
2018-05-23 00:00:00-07:00 True
Freq: D, dtype: bool
我们已经准备好将每天的读数进行平均,并删除不足的天数:
`def` `compute_daily_avgs``(``pa``)``:`
`should_keep` `=` `(``pa``.``resample``(``'``D``'``)`
`[``'``PM25cf1``'``]`
`.``size``(``)`
`.``to_frame``(``)`
`.``apply``(``has_enough_readings``,` `axis``=``'``columns``'``)``)`
`return` `(``pa``.``resample``(``'``D``'``)`
`.``mean``(``)`
`.``loc``[``should_keep``]``)`
`pa` `=` `(``pa_full`
`.``pipe``(``drop_and_rename_cols``)`
`.``pipe``(``parse_timestamps``)`
`.``pipe``(``convert_tz``)`
`.``pipe``(``drop_duplicate_rows``)`
`.``pipe``(``compute_daily_avgs``)``)`
`pa``.``head``(``2``)`
| PM25cf1 | TempF | RH | |
|---|---|---|---|
| timestamp | |||
| --- | --- | --- | --- |
| 2018-05-20 00:00:00-07:00 | 2.48 | 83.35 | 28.72 |
| 2018-05-21 00:00:00-07:00 | 3.00 | 83.25 | 29.91 |
现在我们有了仪器 A 的平均每日 PM2.5 读数,需要在仪器 B 上重复刚才在仪器 A 上执行的数据整理工作。幸运的是,我们可以重复使用同样的流程。为了简洁起见,我们不在此处包含该数据整理步骤。但我们需要决定如果 PM2.5 的平均值不同要怎么办。如果 A 和 B 的 PM2.5 值相差超过 61%,或者超过 5 µg m⁻³,Barkjohn 会删除 12 行中的一对传感器中的此行。
如您所见,准备和清理这些数据需要大量工作:我们处理了缺失数据,对每个仪器的读数进行了聚合,将两个仪器的读数平均在一起,并删除了它们不一致的行。这项工作使我们对 PM2.5 读数更加自信。我们知道最终数据框中的每个 PM2.5 值都是来自生成一致且完整读数的两个独立仪器的日均值。
要完全复制 Barkjohn 的分析,我们需要在所有 PurpleAir 传感器上重复此过程。然后我们会在所有 AQS 传感器上重复 AQS 清洁程序。最后,我们将 PurpleAir 和 AQS 数据合并在一起。这个过程产生了每对同位置传感器的日均读数。为了简洁起见,我们略过此代码。而是继续使用小组数据集进行分析的最后步骤。我们从 EDA 开始,着眼于建模。
探索 PurpleAir 和 AQS 的测量数据
让我们探索匹配的 AQS 和 PurpleAir PM2.5 读数的清理数据集,并寻找可能帮助我们建模的见解。我们主要关注两种空气质量测量数据之间的关系。但我们要记住数据的范围,比如这些数据在时间和地点上的分布。从数据清理中我们了解到,我们处理的是几年内的 PM2.5 日均值数据,并且我们有来自美国数十个地点的数据。
首先,我们回顾整个清理过的数据框:
`full_df`
| date | id | region | pm25aqs | pm25pa | temp | rh | dew | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2019-05-17 | AK1 | 阿拉斯加 | 6.7 | 8.62 | 18.03 | 38.56 | 3.63 |
| 1 | 2019-05-18 | AK1 | 阿拉斯加 | 3.8 | 3.49 | 16.12 | 49.40 | 5.44 |
| 2 | 2019-05-21 | AK1 | 阿拉斯加 | 4.0 | 3.80 | 19.90 | 29.97 | 1.73 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 12427 | 2019-02-20 | WI6 | 北部 | 15.6 | 25.30 | 1.71 | 65.78 | -4.08 |
| 12428 | 2019-03-04 | WI6 | 北部 | 14.0 | 8.21 | -14.38 | 48.21 | -23.02 |
| 12429 | 2019-03-22 | WI6 | 北部 | 5.8 | 9.44 | 5.08 | 52.20 | -4.02 |
12246 rows × 8 columns
我们在下表中为数据框中的每一列提供解释:
| 列名 | 描述 |
|---|---|
| date | 观测日期 |
| id | 一个唯一标识符,格式为带有数字的美国州缩写(我们为站点 ID CA1 执行了数据清理) |
| region | 地区的名称,对应一组站点(CA1站点位于西部地区) |
| pm25aqs | AQS 传感器测量的 PM2.5 |
| pm25pa | 紫外传感器测量的 PM2.5 |
| temp | 温度,摄氏度 |
| rh | 相对湿度,范围从 0%到 100% |
| dew | 露点(较高的露点意味着空气中有更多的湿气) |
让我们从制作几个简单的可视化图开始获取见解。由于范围涉及特定位置的时间测量,我们可以选择一个有很多测量记录的位置,并制作每周平均空气质量的折线图。首先,让我们找出记录较多的站点:
`full_df``[``'``id``'``]``.``value_counts``(``)``[``:``3``]`
id
IA3 830
NC4 699
CA2 659
Name: count, dtype: int64
标记为NC4的位置有近 700 次观测记录。为了稍微平滑折线图,让我们制作每周平均值图:
`nc4` `=` `full_df``.``loc``[``full_df``[``'``id``'``]` `==``'``NC4``'``]`
`ts_nc4` `=` `(``nc4``.``set_index``(``'``date``'``)`
`.``resample``(``'``W``'``)`
`[``'``pm25aqs``'``,` `'``pm25pa``'``]`
`.``mean``(``)`
`.``reset_index``(``)`
`)`
`fig` `=` `px``.``line``(``ts_nc4``,` `x``=``'``date``'``,` `y``=``'``pm25aqs``'``,`
`labels``=``{``'``date``'``:``'``'``,` `'``pm25aqs``'``:``'``PM2.5 weekly avg``'``}``,`
`width``=``500``,` `height``=``250``)`
`fig``.``add_trace``(``go``.``Scatter``(``x``=``ts_nc4``[``'``date``'``]``,` `y``=``ts_nc4``[``'``pm25pa``'``]``,`
`line``=``dict``(``color``=``'``black``'``,` `dash``=``'``dot``'``)``)``)`
`fig``.``update_yaxes``(``range``=``[``0``,``30``]``)`
`fig``.``update_layout``(``showlegend``=``False``)`
`fig``.``show``(``)`

我们看到大多数 AQS 传感器(实线)测得的 PM2.5 值介于 5.0 到 15.0 µg m⁻³之间。PurpleAir 传感器跟随 AQS 传感器的上下波动模式,这让人感到安心。但测量值始终高于 AQS,并且在某些情况下相当高,这表明可能需要进行修正。
接下来,让我们考虑两个传感器 PM2.5 读数的分布情况:
`left` `=` `px``.``histogram``(``nc4``,` `x``=``'``pm25aqs``'``,` `histnorm``=``'``percent``'``)`
`right` `=` `px``.``histogram``(``nc4``,` `x``=``'``pm25pa``'``,` `histnorm``=``'``percent``'``)`
`fig` `=` `left_right``(``left``,` `right``,` `width``=``600``,` `height``=``250``)`
`fig``.``update_xaxes``(``title``=``'``AQS readings``'``,` `col``=``1``,` `row``=``1``)`
`fig``.``update_xaxes``(``title``=``'``PurpleAir readings``'``,` `col``=``2``,` `row``=``1``)`
`fig``.``update_yaxes``(``title``=``'``percent``'``,` `col``=``1``,` `row``=``1``)`
`fig``.``show``(``)`

这两个分布都是右偏的,这在值有下限时经常发生(在这种情况下是 0)。比较这两个分布的更好方法是使用量化-量化图(见 第十章)。使用 q-q 图可以更容易地比较均值、扩展和尾部。
`percs` `=` `np``.``arange``(``1``,` `100``,` `1``)`
`aqs_qs` `=` `np``.``percentile``(``nc4``[``'``pm25aqs``'``]``,` `percs``,` `interpolation``=``'``lower``'``)`
`pa_qs` `=` `np``.``percentile``(``nc4``[``'``pm25pa``'``]``,` `percs``,` `interpolation``=``'``lower``'``)`
`perc_df` `=` `pd``.``DataFrame``(``{``'``percentile``'``:` `percs``,` `'``aqs_qs``'``:``aqs_qs``,` `'``pa_qs``'``:``pa_qs``}``)`
`fig` `=` `px``.``scatter``(``perc_df``,` `x``=``'``aqs_qs``'``,` `y``=``'``pa_qs``'``,`
`labels``=``{``'``aqs_qs``'``:` `'``AQS quantiles``'``,`
`'``pa_qs``'``:` `'``PurpleAir quantiles``'``}``,`
`width``=``350``,` `height``=``250``)`
`fig``.``add_trace``(``go``.``Scatter``(``x``=``[``2``,` `13``]``,` `y``=``[``1``,` `25``]``,`
`mode``=``'``lines``'``,` `line``=``dict``(``dash``=``'``dash``'``,` `width``=``4``)``)``)`
`fig``.``update_layout``(``showlegend``=``False``)`
`fig`

量化-量化图大致是线性的。我们叠加了一个斜率为 2.2 的虚线,它很好地对齐了分位数,这表明 PurpleAir 测量的分布约是 AQS 的两倍。
在 q-q 图或并排直方图中看不到的是传感器读数如何一起变化。让我们来看看这个。首先,我们看一下两个读数之间的差异分布:
`diffs` `=` `(``nc4``[``'``pm25pa``'``]` `-` `nc4``[``'``pm25aqs``'``]``)`
`fig` `=` `px``.``histogram``(``diffs``,` `histnorm``=``'``percent``'``,`
`width``=``350``,` `height``=``250``)`
`fig``.``update_xaxes``(``range``=``[``-``10``,``30``]``,` `title``=``"``Difference: PA–AQS reading``"``)`
`fig``.``update_traces``(``xbins``=``dict``(`
`start``=``-``10.0``,` `end``=``30.0``,` `size``=``2``)``)`
`fig``.``update_layout``(``showlegend``=``False``)`
`fig``.``show``(``)`

如果仪器完全一致,我们会在 0 处看到一个尖峰。如果仪器一致且没有偏差的测量误差,我们期望看到以 0 为中心的分布。然而,我们看到 90% 的时间,PurpleAir 测量大于 AQS 的 24 小时平均值,大约 25% 的时间高出 10 µg/m³,考虑到 AQS 的平均值通常在 5 µg/m³ 到 10 µg/m³ 之间,这是很多的。
散点图可以让我们进一步了解这两台仪器的测量之间的关系。由于我们有兴趣找到一个总体关系,不论时间和地点,我们在图中包含所有的平均读数:
`px``.``scatter``(``full_df``,` `x``=``'``pm25aqs``'``,` `y``=``'``pm25pa``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``pm25aqs``'``:``'``AQS PM2.5``'``,` `'``pm25pa``'``:``'``PurpleAir PM2.5``'``}``)`

虽然关系看起来是线性的,但除了少数几个读数外,其余都集中在图的左下角。让我们重新制作散点图并放大数据的大部分,以便更好地观察。我们还向图中添加了一个平滑曲线,以帮助更好地看到关系:
`full_df` `=` `full_df``.``loc``[``(``full_df``[``'``pm25aqs``'``]` `<` `50``)``]`
`px``.``scatter``(``full_df``,` `x``=``'``pm25aqs``'``,` `y``=``'``pm25pa``'``,`
`trendline``=``'``lowess``'``,` `trendline_color_override``=``"``orange``"``,`
`labels``=``{``'``pm25aqs``'``:``'``AQS PM2.5``'``,` `'``pm25pa``'``:``'``PurpleAir PM2.5``'``}``,`
`width``=``350``,` `height``=``250``)`

这种关系看起来大致是线性的,但在 AQS 的小值区间中曲线略微弯曲。当空气非常清洁时,PurpleAir 传感器不会检测到太多颗粒物质,因此更准确。此外,我们可以看到曲线应通过点 (0, 0)。尽管关系中有轻微弯曲,但这两个测量之间的线性关联(相关性)很高:
`np``.``corrcoef``(``full_df``[``'``pm25aqs``'``]``,` `full_df``[``'``pm25pa``'``]``)`
array([[1\. , 0.88],
[0.88, 1\. ]])
在开始这项分析之前,我们预期 PurpleAir 测量通常会高估 PM2.5。事实上,在散点图中反映了这一点,但我们也看到这两台仪器的测量之间似乎有强烈的线性关系,这将有助于校准 PurpleAir 传感器。
创建校正 PurpleAir 测量的模型
现在我们已经探讨了 AQS 和 PurpleAir 传感器之间 PM2.5 读数的关系,我们准备进行分析的最后一步:创建一个修正 PurpleAir 测量的模型。Barkjohn 的原始分析对数据拟合了许多模型,以找到最合适的模型。在本节中,我们使用来自第四章的技术来拟合一个简单的线性模型。我们还简要描述了 Barkjohn 为实际应用选择的最终模型。由于这些模型使用了本书后面介绍的方法,我们不会在这里深入解释技术细节。相反,我们鼓励您在阅读第十五章后重新访问本节。
首先,让我们总结一下我们的建模目标。我们希望创建一个尽可能准确预测 PM2.5 的模型。为此,我们建立了一个根据 AQS 测量调整 PurpleAir 测量的模型。我们将 AQS 测量视为真实的 PM2.5 值,因为它们来自精确校准的仪器,并且被美国政府积极用于决策制定。因此,我们有理由信任 AQS PM2.5 值的精度和接近真实的特性。
在我们建立了通过 AQS 调整 PurpleAir 测量的模型之后,我们将模型反转并使用它来预测未来的真实空气质量,这是一个校准场景。由于 AQS 测量接近真实值,我们将更为变化的 PurpleAir 测量与之对齐;这就是校准过程。然后我们使用校准曲线来纠正未来的 PurpleAir 测量。这个两步过程包含在即将介绍的简单线性模型及其反转形式中。
首先,我们拟合一条线来预测从真实数据(由 AQS 仪器记录)中记录的 PurpleAir(PA)测量:
接下来,我们将线条反转,使用 PA 测量来预测空气质量:
在我们的探索性数据分析期间制作的散点图和直方图表明 PurpleAir 测量更为变化,这支持校准方法。我们发现 PurpleAir 测量值大约是 AQS 测量值的两倍,这表明可能接近 2,而可能接近。
现在让我们拟合模型。根据第四章的概念,我们选择一个损失函数并最小化平均误差。回想一下,损失函数衡量我们的模型与实际数据的差距。我们使用平方损失,在这种情况下是 。为了使模型与我们的数据拟合,我们最小化数据上的平均平方损失:
我们使用scikit-learn提供的线性建模功能来做到这一点(现在不必担心这些细节):
`from` `sklearn``.``linear_model` `import` `LinearRegression`
`AQS``,` `PA` `=` `full_df``[``[``'``pm25aqs``'``]``]``,` `full_df``[``'``pm25pa``'``]`
`model` `=` `LinearRegression``(``)``.``fit``(``AQS``,` `PA``)`
`m``,` `b` `=` `model``.``coef_``[``0``]``,` `model``.``intercept_`
通过反转线路,我们得到估计:
`print``(``f``"``True air quality estimate =` `{``-``b``/``m``:``.2``}` `+` `{``1``/``m``:``.2``}``PA``"``)`
True air quality estimate = 1.4 + 0.53PA
这接近我们的预期。对 PurpleAir 测量的调整约为 。
Barkjohn 确定的模型包含了相对湿度:
这是一个多变量线性回归模型的示例—它使用多个变量进行预测。我们可以通过最小化数据上的平均平方误差来拟合它:
接着我们反转校准,使用以下方程找到预测模型:
我们拟合这个模型并检查系数:
`AQS_RH``,` `PA` `=` `full_df``[``[``'``pm25aqs``'``,` `'``rh``'``]``]``,` `full_df``[``'``pm25pa``'``]`
`model_h` `=` `LinearRegression``(``)``.``fit``(``AQS_RH``,` `PA``)`
`[``m1``,` `m2``]``,` `b` `=` `model_h``.``coef_``,` `model_h``.``intercept_`
`print``(``f``"``True Air Quality Estimate =` `{``-``b``/``m``:``1.2``}` `+` `{``1``/``m1``:``.2``}``PA +` `{``-``m2``/``m1``:``.2``}``RH``"``)`
True Air Quality Estimate = 5.7 + 0.53PA + -0.088RH
在第 15 和 16 章中,我们将学习如何通过检查预测误差的大小和模式等因素来比较这两个模型。目前,我们注意到包含相对湿度的模型表现最佳。
摘要
在本章中,我们复制了 Barkjohn 的分析。我们创建了一个模型,校正 PurpleAir 测量,使其与 AQS 测量接近。这个模型的准确性使得 PurpleAir 传感器可以包含在官方的美国政府地图上,比如 AirNow Fire and Smoke 地图。重要的是,这个模型提供了及时的和准确的空气质量测量。
我们看到众包开放数据如何通过来自精确、严格维护、政府监控设备的数据进行改进。在这个过程中,我们专注于清理和合并来自多个来源的数据,但我们也适合模型以调整和改进空气质量测量。
对于这个案例研究,我们应用了本书的这一部分涵盖的许多概念。正如您所见,整理文件和数据表以便分析是数据科学中的一个重要部分。我们使用文件整理和来自第八章的粒度概念来准备两个来源以进行合并。我们将它们组织成结构,以便匹配相邻的空气质量传感器。数据科学中的这个“肮脏”部分对于通过增加众包开放数据来扩展严格维护的精确政府监控设备的数据的覆盖范围至关重要。
这个准备过程涉及了对数据的深入、仔细的检查、清理和改进,以确保它们在两个来源之间的兼容性和在我们的分析中的可信度。来自第九章的概念帮助我们有效地处理时间数据,并找到和修正了许多问题,如缺失数据点甚至是重复的数据值。
文件和数据整理、探索性数据分析以及可视化是许多分析工作的重要组成部分。虽然拟合模型可能看起来是数据科学中最激动人心的部分,但了解和信任数据是至关重要的,通常会在建模阶段带来重要的见解。与建模相关的主题占据了本书其余大部分内容。然而,在我们开始之前,我们会涵盖与数据整理相关的另外两个主题。在下一章中,我们将展示如何从文本创建可分析的数据,而在接下来的一章中,我们将研究我们在第八章中提到的其他源文件格式。
在你进入下一章之前,回顾一下你到目前为止学到的内容。对自己的成就感到自豪——你已经走了很长的一段路!我们在这里涵盖的原则和技术对几乎每一种数据分析都很有用,你可以立即开始将它们应用到你自己的分析中。
^(1) 这种估算是基于假设地球是完美的球体。然后,一度纬度是地球的半径(以米为单位)。插入地球的平均半径后,得到每度纬度 111,111 米。经度也是一样的,但是每个“环”围绕地球的半径随着接近极点而减小,所以我们通过一个 因子进行调整。事实证明地球并不是完美的球体,所以这些估算不能用于像着陆火箭这样精确计算的目的。但对于我们的目的来说,它们表现得相当好。
第四部分:其他数据来源
第十三章:处理文本
数据不仅可以以数字形式存在,还可以以文字形式存在:狗品种的名称、餐馆违规描述、街道地址、演讲、博客文章、网评等等。为了组织和分析文本中包含的信息,我们经常需要执行以下一些任务:
将文本转换为标准格式
这也被称为规范化文本。例如,我们可能需要将字符转换为小写,使用常见拼写和缩写,或删除标点和空格。
提取文本片段以创建特征
举例来说,一个字符串可能包含嵌入其中的日期,我们希望从字符串中提取出来以创建一个日期特征。
将文本转换为特征
我们可能希望将特定词语或短语编码为 0-1 特征,以指示它们在字符串中的存在。
分析文本
为了一次性比较整个文档,我们可以将文档转换为单词计数的向量。
这一章介绍了处理文本数据的常用技术。我们展示了简单的字符串操作工具通常足以将文本整理成标准形式或提取字符串的部分内容。我们还介绍了正则表达式,用于更通用和稳健的模式匹配。为了演示这些文本操作,我们使用了几个例子。我们首先介绍这些例子,并描述我们想要为分析准备文本的工作。
文本和任务示例
对于刚刚介绍的每种任务,我们提供一个激励性的例子。这些例子基于我们实际完成的任务,但为了专注于概念,我们已经将数据简化为片段。
将文本转换为标准格式
假设我们想要研究人口统计数据与选举结果之间的联系。为此,我们从维基百科获取了选举数据,从美国人口普查局获取了人口数据。数据的粒度是以县为单位的,我们需要使用县名来连接这两个表格。不幸的是,这两个表格中的县名并不总是匹配的:
| 县 | 州 | 投票数 | |
|---|---|---|---|
| 0 | 德维特县 | 伊利诺伊州 | 97.8 |
| 1 | 拉克奎帕尔县 | 明尼苏达州 | 98.8 |
| 2 | 列威斯和克拉克县 | 蒙大拿州 | 95.2 |
| 3 | 圣约翰大洗礼者教区 | 路易斯安那州 | 52.6 |
| 县 | 州 | 人口 | |
| --- | --- | --- | --- |
| 0 | 德威特 | 伊利诺伊州 | 16,798 |
| 1 | 拉克奎帕尔 | 明尼苏达州 | 8,067 |
| 2 | 列威斯和克拉克 | 蒙大拿州 | 55,716 |
| 3 | 圣约翰大洗礼者 | 路易斯安那州 | 43,044 |
我们无法将表格连接起来,直到我们将字符串清理为县名的共同格式为止。我们需要更改字符的大小写,使用常见的拼写和缩写,并处理标点符号。
提取文本片段以创建特征
文本数据有时具有很多结构,特别是当它是由计算机生成时。例如,以下是一个 Web 服务器的日志条目。注意条目中有多个数据片段,但这些片段没有一致的分隔符——例如,日期出现在方括号中,但数据的其他部分出现在引号和括号中:
169.237.46.168 - -
[26/Jan/2004:10:47:58 -0800]"GET /stat141/Winter04 HTTP/1.1" 301 328
"http://anson.ucdavis.edu/courses"
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.1.4322)"
尽管文件格式与我们在 第八章 中看到的简单格式之一不符,但我们可以使用文本处理技术提取文本片段以创建特征。
将文本转换为特征
在 第九章 中,我们基于字符串内容创建了一个分类特征。在那里,我们检查了餐厅违规描述,并为特定词语的存在创建了名义变量。这里展示了一些示例违规行为:
unclean or degraded floors walls or ceilings
inadequate and inaccessible handwashing facilities
inadequately cleaned or sanitized food contact surfaces
wiping cloths not clean or properly stored or inadequate sanitizer
foods not protected from contamination
unclean nonfood contact surfaces
unclean or unsanitary food contact surfaces
unclean hands or improper use of gloves
inadequate washing facilities or equipment
这些新功能可以用于食品安全评分的分析。以前,我们制作了简单的特征,标记了描述中是否包含诸如 手套 或 头发 这样的词语。在本章中,我们更正式地介绍了我们用来创建这些特征的正则表达式工具。
文本分析
有时我们想比较整个文档。例如,美国总统每年都会发表国情咨文演讲。以下是第一次演讲的前几行:
***
State of the Union Address
George Washington
January 8, 1790
Fellow-Citizens of the Senate and House of Representatives:
I embrace with great satisfaction the opportunity which now presents itself
of congratulating you on the present favorable prospects of our public …
我们可能会想:国情咨文演讲随时间如何变化?不同政党是否专注于不同的主题或在演讲中使用不同的语言?为了回答这些问题,我们可以将演讲转换为数字形式,以便使用统计方法进行比较。
这些示例用来说明字符串操作、正则表达式和文本分析的思想。我们从描述简单的字符串操作开始。
字符串操作
当我们处理文本时,有几种基本的字符串操作工具我们经常使用。
-
将大写字符转换为小写(或反之)。
-
用另一个子字符串替换或删除子字符串。
-
将字符串在特定字符处分割成片段。
-
在指定的位置切片字符串。
我们展示了如何组合这些基本操作来清理县名数据。请记住,我们有两张表需要连接,但县名的写法不一致。
让我们首先将县名转换为标准格式。
使用 Python 字符串方法将文本转换为标准格式
我们需要解决两张表中县名之间的以下不一致性:
-
大小写问题:
qui与Qui。 -
省略词语:
County和Parish在census表中不存在。 -
不同的缩写约定:
&与and。 -
不同的标点符号约定:
St.与St。 -
使用空白符:
DeWitt与De Witt。
当我们清理文本时,通常最容易的方法是先将所有字符转换为小写。全使用小写字符比尝试跟踪大写和小写的组合要容易。接下来,我们想通过将&替换为and并删除County和Parish来修复不一致的单词。最后,我们需要修复标点符号和空格的不一致。
只需使用两个 Python 字符串方法,lower和replace,我们就可以执行所有这些操作并清理县名。这些方法被合并到一个名为clean_county的方法中:
`def` `clean_county``(``county``)``:`
`return` `(``county`
`.``lower``(``)`
`.``replace``(``'``county``'``,` `'``'``)`
`.``replace``(``'``parish``'``,` `'``'``)`
`.``replace``(``'``&``'``,` `'``and``'``)`
`.``replace``(``'``.``'``,` `'``'``)`
`.``replace``(``'` `'``,` `'``'``)``)`
尽管简单,这些方法是我们可以组合成更复杂的字符串操作的基本构建块。这些方法方便地定义在所有 Python 字符串上,无需导入其他模块。值得熟悉的是字符串方法的完整列表,但我们在表格 13-1 中描述了一些最常用的方法。
表格 13-1. 字符串方法
| 方法 | 描述 |
|---|---|
str.lower() |
返回字符串的副本,所有字母都转换为小写 |
str.replace(a, b) |
将str中所有子字符串a替换为子字符串b |
str.strip() |
从str中移除前导和尾随的空格 |
str.split(a) |
返回在子字符串a处分割的str的子字符串 |
str[x:y] |
切片 str,返回从索引 x(包括)到 y(不包括)的部分 |
接下来,我们验证clean_county方法是否生成匹配的县名:
`(``[``clean_county``(``county``)` `for` `county` `in` `election``[``'``County``'``]``]``,`
`[``clean_county``(``county``)` `for` `county` `in` `census``[``'``County``'``]``]``)`
(['dewitt', 'lacquiparle', 'lewisandclark', 'stjohnthebaptist'],
['dewitt', 'lacquiparle', 'lewisandclark', 'stjohnthebaptist'])
自从县名现在有了统一的表示方式,我们可以成功地将这两个表格连接起来。
pandas 中的字符串方法
在上述代码中,我们使用循环来转换每个县名。pandas的Series对象提供了一种便捷的方法,可以将字符串方法应用于系列中的每个项。
在pandas的Series上,.str属性公开了相同的 Python 字符串方法。在.str属性上调用方法会在系列中的每个项上调用该方法。这使我们能够在不使用循环的情况下转换系列中的每个字符串。我们将转换后的县名保存回其原始表中。首先,我们在选举表中转换县名:
`election``[``'``County``'``]` `=` `(``election``[``'``County``'``]`
`.``str``.``lower``(``)`
`.``str``.``replace``(``'``parish``'``,` `'``'``)`
`.``str``.``replace``(``'``county``'``,` `'``'``)`
`.``str``.``replace``(``'``&``'``,` `'``and``'``)`
`.``str``.``replace``(``'``.``'``,` `'``'``,` `regex``=``False``)`
`.``str``.``replace``(``'` `'``,` `'``'``)``)`
我们还将人口普查表中的名称转换,以便这两个表格包含相同的县名表示。我们可以连接这些表格:
`election``.``merge``(``census``,` `on``=``[``'``County``'``,``'``State``'``]``)`
| 县名 | 州名 | 投票率 | 人口 | |
|---|---|---|---|---|
| 0 | dewitt | IL | 97.8 | 16,798 |
| 1 | lacquiparle | MN | 98.8 | 8,067 |
| 2 | lewisandclark | MT | 95.2 | 55,716 |
| 3 | stjohnthebaptist | LA | 52.6 | 43,044 |
注意
注意,我们根据县名和州名两列进行了合并。这是因为一些州有同名的县。例如,加利福尼亚州和纽约州都有一个名为金县的县。
要查看完整的字符串方法列表,我们建议查看Python 关于str方法的文档和pandas关于.str访问器的文档。我们只使用了str.lower()和多次调用str.replace()来完成规范化任务。接下来,我们使用另一个字符串方法str.split()提取文本。
分割字符串以提取文本片段
假设我们想从网络服务器的日志条目中提取日期:
`log_entry`
169.237.46.168 - - [26/Jan/2004:10:47:58 -0800]"GET /stat141/Winter04 HTTP/1.1"
301 328 "http://anson.ucdavis.edu/courses""Mozilla/4.0 (compatible; MSIE 6.0;
Windows NT 5.0; .NET CLR 1.1.4322)"
字符串分割可以帮助我们定位构成日期的信息片段。例如,当我们在左括号上分割字符串时,我们得到两个字符串:
`log_entry``.``split``(``'``[``'``)`
['169.237.46.168 - - ',
'26/Jan/2004:10:47:58 -0800]"GET /stat141/Winter04 HTTP/1.1" 301 328 "http://anson.ucdavis.edu/courses""Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.1.4322)"']
第二个字符串包含日期信息,为了获取日、月和年,我们可以在该字符串上以冒号分割:
`log_entry``.``split``(``'``[``'``)``[``1``]``.``split``(``'``:``'``)``[``0``]`
'26/Jan/2004'
要分开日、月和年,我们可以在斜杠上分割。总共我们分割原始字符串三次,每次只保留我们感兴趣的部分:
`(``log_entry``.``split``(``'``[``'``)``[``1``]`
`.``split``(``'``:``'``)``[``0``]`
`.``split``(``'``/``'``)``)`
['26', 'Jan', '2004']
通过反复使用split(),我们可以提取日志条目的许多部分。但是这种方法很复杂——如果我们还想获取活动的小时、分钟、秒钟和时区,我们需要总共使用split()六次。有一种更简单的方法来提取这些部分:
`import` `re`
`pattern` `=` `r``'``[` `\``[/:``\``]]``'`
`re``.``split``(``pattern``,` `log_entry``)``[``4``:``11``]`
['26', 'Jan', '2004', '10', '47', '58', '-0800']
这种替代方法使用了一个称为正则表达式的强大工具,我们将在下一节中介绍。
正则表达式
正则表达式(或简称regex)是我们用来匹配字符串部分的特殊模式。想想社会安全号码(SSN)的格式,例如134-42-2012。为了描述这种格式,我们可以说 SSN 由三位数字、一个短划线、两位数字、另一个短划线,然后是四位数字。正则表达式让我们能够在代码中捕获这种模式。正则表达式为我们提供了一种紧凑而强大的方式来描述这种数字和短划线的模式。正则表达式的语法非常简单,我们在本节中几乎介绍了所有的语法。
在介绍这些概念时,我们解决了前一节中描述的一些示例,并展示了如何使用正则表达式执行任务。几乎所有编程语言都有一个库,用于使用正则表达式匹配模式,这使得正则表达式在任何编程语言中都很有用。我们使用 Python 内置的re模块中的一些常见方法来完成示例中的任务。这些方法在本节末尾的表格 13-7 中进行了总结,其中简要描述了基本用法和返回值。由于我们只涵盖了一些最常用的方法,您可能会发现参考官方文档关于re模块的信息也很有用。
正则表达式基于逐个字符(也称为字面量)搜索模式的字符串。我们称这种概念为字面量的连接。
字面量的连接
串联最好通过一个基本示例来解释。假设我们在字符串cards scatter!中寻找模式cat。图 13-1 包含了一个图表,展示了搜索如何逐个字符地进行。请注意,在字符串的第一个位置找到了“c”,接着是“a”,但没有“t”,所以搜索会回到字符串的第二个字符,并开始再次搜索“c”。模式cat在字符串cards scatter!中的位置为 8 到 10。一旦你掌握了这个过程,你可以继续进行更丰富的模式;它们都遵循这个基本的范例。

图 13-1. 为了匹配文字模式,正则引擎沿着字符串移动,并逐个检查是否符合整个模式。请注意,模式在单词scatters中找到,并且在cards中找到部分匹配。
注意
在前面的例子中,我们观察到正则表达式可以匹配出现在输入字符串中的任何模式。在 Python 中,这种行为根据用于匹配正则表达式的方法而异——某些方法仅在字符串开头匹配正则表达式时返回匹配;其他方法则在字符串中的任何位置返回匹配。
这些更丰富的模式由字符类和通配符等元字符组成。我们将在接下来的小节中描述它们。
字符类
我们可以通过使用字符类(也称为字符集)使模式更加灵活,它允许我们指定要匹配的一组等效字符。这使我们能够创建更宽松的匹配。要创建一个字符类,请将所需的字符集合包含在方括号[ ]中。例如,模式[0123456789]表示“匹配方括号内的任何文字”—在这种情况下是任何单个数字。然后,以下正则表达式匹配三个数字:
[0123456789][0123456789][0123456789]
这是一个常用的字符类,有一个数字范围的简写表示法[0-9]。字符类允许我们创建一个匹配社保号码(SSNs)的正则表达式:
[0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9]
另外两个常用的字符类范围是小写字母[a-z]和大写字母[A-Z]。我们可以将范围与其他等效字符结合使用,并使用部分范围。例如,[a-cX-Z27]等效于字符类[abcXYZ27]。
让我们回到我们最初的模式cat,并修改它以包含两个字符类:
c[oa][td]
这个模式匹配cat,但也匹配cot,cad和cod:
Regex: c[oa][td]
Text: The cat eats cod, cads, and cots, but not coats.
Matches: *** *** *** ***
每次仍然逐个字符地移动字符串的核心概念仍然存在,但现在在哪个文字被视为匹配方面有了更多的灵活性。
通配符字符
当我们真的不关心文字是什么时,我们可以用.,即句号字符来指定。这可以匹配除换行符以外的任何字符。
否定字符类
否定字符类 匹配方括号内除了那些字符外的任何字符。要创建否定字符类,请在左方括号后面放置插入符号。例如,[⁰-9] 匹配除数字外的任何字符。
字符类的简写形式
有些字符集如此常见,以至于有它们的简写形式。例如,\d代表[0-9]。我们可以使用这些简写来简化对社会安全号码的搜索:
\d\d\d-\d\d-\d\d\d\d
我们的社会安全号码的正则表达式并不是完全可靠的。如果字符串在我们寻找的模式开头或结尾有额外的数字,那么我们仍然能够匹配到。注意,我们在引号前添加r字符以创建原始字符串,这样可以更容易地编写正则表达式:
Regex: \d\d\d-\d\d-\d\d\d\d
Text: My other number is 6382-13-38420.
Matches: ***********
我们可以通过不同类型的元字符来修正这种情况:一个可以匹配单词边界的元字符。
锚点和边界
有时我们想要匹配字符之前、之后或之间的位置。一个例子是定位字符串的开头或结尾;这些称为锚点。另一个是定位单词的开头或结尾,我们称之为边界。元字符\b表示单词的边界。它长度为 0,并且匹配模式边界上的空白或标点符号。我们可以用它来修复我们的社会安全号码的正则表达式:
Regex: \d\d\d-\d\d-\d\d\d\
Text: My other number is 6382-13-38420.
Matches:
Regex: \b\d\d\d-\d\d-\d\d\d\d\b
Text: My reeeal number is 382-13-3842.
Matches: ***********
转义元字符
我们现在已经见过几个特殊字符,称为元字符:[ 和 ] 表示字符类,^ 切换到否定字符类,. 表示任何字符,- 表示范围。但有时我们可能想要创建一个匹配其中一个这些文字的模式。当这种情况发生时,我们必须用反斜杠进行转义。例如,我们可以使用正则表达式 \[ 来匹配字面上的左方括号字符:
Regex: \[
Text: Today is [2022/01/01]
Matches: *
接下来,我们展示量词如何帮助创建更紧凑和清晰的正则表达式来匹配社会安全号码。
量词
要创建一个用于匹配社会安全号码的正则表达式,我们写道:
\b[0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9]\b
这匹配由三个数字、一个破折号、另外两个数字、一个破折号和另外四个数字组成的“单词”。
量词允许我们匹配多个连续出现的字符。我们通过在花括号{ }中放置数字来指定重复的次数。
让我们使用 Python 的内置re模块来匹配这种模式:
`import` `re`
`ssn_re` `=` `r``'``\``b[0-9]``{3}``-[0-9]``{2}``-[0-9]``{4}``\``b``'`
`re``.``findall``(``ssn_re``,` `'``My SSN is 382-34-3840.``'``)`
['382-34-3840']
我们的模式不应匹配电话号码。让我们试试:
`re``.``findall``(``ssn_re``,` `'``My phone is 382-123-3842.``'``)`
[]
量词总是修改其左侧的字符或字符类。Table 13-2 显示了量词的完整语法。
Table 13-2. 量词示例
| Table 13-3. 简写量词 |
| --- | --- |
| {m, n} | 匹配前面的字符 m 到 n 次。 |
| {m} | 匹配前面的字符恰好 m 次。 |
| {m,} | 匹配前面的字符至少 m 次。 |
| {,n} | 匹配前面的字符最多 n 次。 |
一些常用的量词有简写形式,如 Table 13-3 所示。
Table 13-3. 简写量词
| Symbol | 量词 | 含义 |
|---|---|---|
* |
匹配前一个字符 0 或多次。 | |
+ |
匹配前一个字符 1 次或多次。 | |
? |
匹配前一个字符 0 或 1 次。 |
量词是贪婪的,会返回可能的最长匹配。这有时会导致意想不到的行为。由于社会安全号码以数字开头和结尾,我们可能认为以下较短的正则表达式将是查找 SSN 的简单方法。你能想出匹配出现错误的原因吗?
`ssn_re_dot` `=` `r``'``[0-9].+[0-9]``'`
`re``.``findall``(``ssn_re_dot``,` `'``My SSN is 382-34-3842 and hers is 382-34-3333.``'``)`
['382-34-3842 and hers is 382-34-3333']
注意,我们使用元字符.匹配任意字符。在许多情况下,使用更具体的字符类可以避免这些虚假的“超匹配”。我们之前包含单词边界的模式就是这样做的:
`re``.``findall``(``ssn_re``,` `'``My SSN is 382-34-3842 and hers is 382-34-3333.``'``)`
['382-34-3842', '382-34-3333']
有些平台允许关闭贪婪匹配并使用惰性匹配,返回最短的字符串。
文字连接和量词是正则表达式中的两个核心概念。接下来,我们介绍另外两个核心概念:选择和分组。
利用选择和分组创建特征
字符类允许我们匹配单个文字的多个选项。我们可以使用选择来匹配一组文字的多个选项。例如,在第九章中的食品安全示例中,我们标记与身体部位相关的违规行为,通过检查违规行为是否包含hand、nail、hair或glove子串。我们可以在正则表达式中使用|字符指定这种选择:
`body_re` `=` `r``"``hand|nail|hair|glove``"`
`re``.``findall``(``body_re``,` `"``unclean hands or improper use of gloves``"``)`
['hand', 'glove']
`re``.``findall``(``body_re``,` `"``Unsanitary employee garments hair or nails``"``)`
['hair', 'nail']
使用括号我们可以定位模式的部分,这称为正则表达式组。例如,我们可以使用正则表达式组从 Web 服务器日志条目中提取日期、月份、年份和时间:
`# This pattern matches the entire timestamp`
`time_re` `=` `r``"``\``[[0-9]``{2}``/[a-zA-z]``{3}``/[0-9]``{4}``:[0-9:``\``- ]*``\``]``"`
`re``.``findall``(``time_re``,` `log_entry``)`
['[26/Jan/2004:10:47:58 -0800]']
`# Same regex, but we use parens to make regex groups...`
`time_re` `=` `r``"``\``[([0-9]``{2}``)/([a-zA-z]``{3}``)/([0-9]``{4}``):([0-9:``\``- ]*)``\``]``"`
`# ...which tells findall() to split up the match into its groups`
`re``.``findall``(``time_re``,` `log_entry``)`
[('26', 'Jan', '2004', '10:47:58 -0800')]
正如我们所见,re.findall 返回一个包含日期和时间各个组件的元组列表。
我们已经介绍了许多术语,因此在下一节中,我们将它们整合到一组表格中,以便轻松查阅。
参考表格
我们通过几个总结操作顺序、元字符和字符类速记的表格来结束本节。我们还提供了总结在本节中使用的re Python 库少数方法的表格。
正则表达式的四个基本操作——连接、量化、选择和分组——具有优先顺序,在表格 13-4 中我们明确说明了这一点。
表格 13-4. 操作顺序
| 操作 | 顺序 | 示例 | 匹配 |
|---|---|---|---|
| 连接 | 3 | cat |
cat |
| 选择 | 4 | cat|mouse |
cat 和 mouse |
| 量化 | 2 | cat? |
ca 和 cat |
| 分组 | 1 | c(at)? | c 和 cat |
表格 13-5 提供了本节介绍的元字符列表,以及一些额外的内容。标有“不匹配”的列举了示例正则表达式不匹配的字符串。
表格 13-5. 元字符
| 字符 | 描述 | 示例 | 匹配 | 不匹配 |
|---|---|---|---|---|
| . | 除 \n 外的任意字符 | ... |
abc | ab |
| [ ] | 方括号内的任意字符 | [cb.]ar |
car .ar | jar |
| [^ ] | 方括号内 不 包含的任意字符 | [^b]ar |
car par | bar ar |
| * | ≥ 0 或更多前一个符号,简写为 | [pb]*ark |
bbark ark | dark |
| + | ≥ 1 或更多前一个符号,简写为 | [pb]+ark |
bbpark bark | dark ark |
| ? | 0 或 1 个前一个符号,简写为 | s?he |
she he | the |
| {n} | 前一个符号恰好 n 次 | hello{3} |
hellooo | hello |
| | | 竖线前后的模式 | we|[ui]s | we us
is | es e
s |
| \ | 转义下一个字符 | \[hi\] |
[hi] | hi |
|---|---|---|---|---|
| ^ | 行首 | ^ark |
ark two | dark |
$ | 行尾 | ark$ |
noahs ark | noahs arks | ||
| \b | 单词边界 | ark\b |
ark of noah | noahs arks |
此外,在 表 13-6 中,我们为一些常用字符集提供了简写。这些简写不需要 [ ]。
表 13-6. 字符类简写
| 描述 | 方括号形式 | 简写 |
|---|---|---|
| 字母数字字符 | [a-zA-Z0-9_] |
\w |
| 非字母数字字符 | [^a-zA-Z0-9_] |
\W |
| 数字 | [0-9] |
\d |
| 非数字 | [⁰-9] |
\D |
| 空白字符 | [\t\n\f\r\p{Z}] |
\s |
| 非空白字符 | [\t\n\f\r\p{z}] |
\S |
我们在本章中使用了以下 re 方法。方法名称指示了它们执行的功能:在字符串中 搜索 或 匹配 模式;在字符串中 查找 所有模式的情况;将模式的所有出现 替换 为子字符串;以及在模式处 分割 字符串。每个方法都需要指定一个模式和一个字符串,并且一些还有额外的参数。表 13-7 提供了方法使用的格式以及返回值的描述。
表 13-7. 正则表达式方法
| 方法 | 返回值 |
|---|---|
re.search(pattern, string) |
如果模式在字符串任何位置找到,则为匹配对象,否则为 None |
re.match(pattern, string) |
如果模式在字符串开头找到,则为匹配对象,否则为 None |
re.findall(pattern, string) |
字符串 string 中所有 pattern 的匹配项列表 |
re.sub(pattern, replacement, string) |
字符串 string 中所有 pattern 的出现都被 replacement 替换的字符串 |
re.split(pattern, string) |
围绕 pattern 出现的 string 片段列表 |
正如我们在前一节中看到的,pandas 的 Series 对象具有一个 .str 属性,支持使用 Python 字符串方法进行字符串操作。方便地,.str 属性还支持 re 模块的一些函数。表 13-8 展示了与 re 方法的 表 13-7 相似的功能。每个都需要一个模式。请参阅 pandas 文档 获取完整的字符串方法列表。
表 13-8. pandas 中的正则表达式
| 方法 | 返回值 |
|---|---|
str.contains(pattern, regex=True) |
指示 pattern 是否找到的布尔序列 |
str.findall(pattern, regex=True) |
匹配 pattern 的所有结果的列表 |
str.replace(pattern, replacement, regex=True) |
Series with all matching occurrences of pattern replaced by replacement |
str.split(pattern, regex=True) |
给定 pattern 周围字符串列表的序列 |
正则表达式是一个强大的工具,但它们以难以阅读和调试著称。最后我们提出了一些建议来使用正则表达式:
-
在简单的测试字符串上开发你的正则表达式,看看模式匹配的情况。
-
如果一个模式没有匹配到任何内容,尝试通过减少模式的部分来削弱它。然后逐步加强它,看看匹配的进展。(在线正则表达式检查工具在这里非常有帮助。)
-
让模式尽可能具体以适应手头的数据。
-
尽可能使用原始字符串以获得更清晰的模式,特别是当模式包含反斜杠时。
-
当你有很多长字符串时,考虑使用编译后的模式,因为它们可以更快速地匹配(见
re库中的compile())。
在下一节中,我们进行一个示例文本分析。我们使用正则表达式和字符串操作清理数据,将文本转换为定量数据,并通过这些派生数量分析文本。
文本分析
到目前为止,我们使用 Python 方法和正则表达式来清理短文本字段和字符串。在本节中,我们将使用一种称为文本挖掘的技术来分析整个文档,该技术将自由形式的文本转换为定量表达,以揭示有意义的模式和洞见。
文本挖掘是一个深奥的主题。我们不打算进行全面的讲解,而是通过一个例子介绍几个关键思想,我们将分析从 1790 年到 2022 年的国情咨文演讲。每年,美国总统向国会发表国情咨文演讲。这些演讲谈论国家当前事件,并提出国会应考虑的建议。美国总统项目 在线提供这些演讲。
让我们从打开包含所有演讲的文件开始:
`from` `pathlib` `import` `Path`
`text` `=` `Path``(``'``data/stateoftheunion1790-2022.txt``'``)``.``read_text``(``)`
在本章的开头,我们看到数据中每篇演讲都以三个星号开头的一行:***。我们可以使用正则表达式来计算字符串 *** 出现的次数:
`import` `re`
`num_speeches` `=` `len``(``re``.``findall``(``r``"``\``*``\``*``\``*``"``,` `text``)``)`
`print``(``f``'``There are` `{``num_speeches``}` `speeches total``'``)`
There are 232 speeches total
在文本分析中,文档 指的是我们想要分析的单个文本。在这里,每篇演讲都是一个文档。我们将 text 变量分解为其各个文档:
`records` `=` `text``.``split``(``"``***``"``)`
然后我们可以把演讲放入一个数据框中:
`def` `extract_parts``(``speech``)``:`
`speech` `=` `speech``.``strip``(``)``.``split``(``'``\n``'``)``[``1``:``]`
`[``name``,` `date``,` `*``lines``]` `=` `speech`
`body` `=` `'``\n``'``.``join``(``lines``)``.``strip``(``)`
`return` `[``name``,` `date``,` `body``]`
`def` `read_speeches``(``)``:`
`return` `pd``.``DataFrame``(``[``extract_parts``(``l``)` `for` `l` `in` `records``[``1``:``]``]``,`
`columns` `=` `[``"``name``"``,` `"``date``"``,` `"``text``"``]``)`
`df` `=` `read_speeches``(``)`
`df`
| 名称 | 日期 | 文本 | |
|---|---|---|---|
| 0 | 乔治·华盛顿 | 1790 年 1 月 8 日 | 参议院和众议院的同胞们... |
| 1 | 乔治·华盛顿 | 1790 年 12 月 8 日 | 参议院和众议院的同胞们... |
| 2 | 乔治·华盛顿 | 1791 年 10 月 25 日 | 参议院和众议院的同胞们... |
| ... | ... | ... | ... |
| 229 | 唐纳德·J·特朗普 | 2020 年 2 月 4 日 | 非常感谢。谢谢。非常感谢你... |
| 230 | 约瑟夫·R·拜登 | 2021 年 4 月 28 日 | 谢谢你。谢谢你。谢谢你。很高兴回来... |
| 231 | 约瑟夫·R·拜登 | 2022 年 3 月 1 日 | 女士们,众议长,女副总统,我们的第一... |
232 rows × 3 columns
现在我们已经将演讲加载到数据框中,我们希望转换演讲,以查看它们随时间的变化。我们的基本思想是查看演讲中的单词 - 如果两个演讲包含非常不同的单词,我们的分析应告诉我们这一点。通过某种文档相似度的度量,我们可以看到演讲彼此之间的差异。
文档中有一些问题,我们需要先解决:
-
大小写不应该影响:
Citizens和citizens应被视为相同的单词。我们可以通过将文本转换为小写来解决这个问题。 -
文本中有未发表的言论:
[laughter]指出观众笑了的地方,但这些不应该算作演讲的一部分。我们可以通过使用正则表达式来删除方括号内的文本:\[[^\]]+\]。请记住,\[和\]匹配文字的左右括号,[^\]]匹配任何不是右括号的字符。 -
我们应该去掉不是字母或空格的字符:一些演讲谈到财务问题,但金额不应该被视为单词。我们可以使用正则表达式
[^a-z\s]来删除这些字符。这个正则表达式匹配任何不是小写字母 (a-z) 或空格字符 (\s) 的字符:`def` `clean_text``(``df``)``:` `bracket_re` `=` `re``.``compile``(``r``'``\``[[^``\``]]+``\``]``'``)` `not_a_word_re` `=` `re``.``compile``(``r``'``[^a-z``\``s]``'``)` `cleaned` `=` `(``df``[``'``text``'``]``.``str``.``lower``(``)` `.``str``.``replace``(``bracket_re``,` `'``'``,` `regex``=``True``)` `.``str``.``replace``(``not_a_word_re``,` `'` `'``,` `regex``=``True``)``)` `return` `df``.``assign``(``text``=``cleaned``)` `df` `=` `(``read_speeches``(``)` `.``pipe``(``clean_text``)``)` `df`名字 日期 文本 0 乔治·华盛顿 1790 年 1 月 8 日 参议院和众议院的同胞们... 1 乔治·华盛顿 1790 年 12 月 8 日 参议院和众议院的同胞们... 2 乔治·华盛顿 1791 年 10 月 25 日 参议院和众议院的同胞们... ... ... ... ... 229 唐纳德·J·特朗普 2020 年 2 月 4 日 非常感谢。谢谢。非常感谢你... 230 约瑟夫·R·拜登 2021 年 4 月 28 日 谢谢你。谢谢你。谢谢你。很高兴回来... 231 约瑟夫·R·拜登 2022 年 3 月 1 日 女士们,众议长,女副总统,我们的第一... 232 rows × 3 columns
接下来,我们看一些更复杂的问题:
-
停用词,如
is、and、the和but出现得太频繁,我们希望将它们删除。 -
argue和arguing应被视为相同的单词,尽管它们在文本中显示不同。为了解决这个问题,我们将使用词干提取,将两个单词转换为argu。
要处理这些问题,我们可以使用nltk 库中的内置方法。
最后,我们将演讲转换为词向量。词向量使用一组数字来表示一个文档。例如,一种基本的词向量类型统计了文本中每个词出现的次数,如图 13-2 所示。

图 13-2。三个小示例文档的词袋向量
这种简单的转换被称为词袋模型,我们将其应用于所有的演讲稿。然后,我们计算词频-逆文档频率(tf-idf简称)来规范化计数并测量单词的稀有性。tf-idf 会对只出现在少数文档中的单词给予更多的权重。其思想是,如果只有少数文档提到了制裁这个词,那么这个词对于区分不同文档就非常有用。我们使用的scikit-learn 库完整描述了这一转换和实现。
应用这些转换后,我们得到了一个二维数组,speech_vectors。该数组的每一行是一个转换为向量的演讲:
`import` `nltk`
`nltk``.``download``(``'``stopwords``'``)`
`nltk``.``download``(``'``punkt``'``)`
`from` `nltk``.``stem``.``porter` `import` `PorterStemmer`
`from` `sklearn``.``feature_extraction``.``text` `import` `TfidfVectorizer`
`stop_words` `=` `set``(``nltk``.``corpus``.``stopwords``.``words``(``'``english``'``)``)`
`porter_stemmer` `=` `PorterStemmer``(``)`
`def` `stemming_tokenizer``(``document``)``:`
`return` `[``porter_stemmer``.``stem``(``word``)`
`for` `word` `in` `nltk``.``word_tokenize``(``document``)`
`if` `word` `not` `in` `stop_words``]`
`tfidf` `=` `TfidfVectorizer``(``tokenizer``=``stemming_tokenizer``)`
`speech_vectors` `=` `tfidf``.``fit_transform``(``df``[``'``text``'``]``)`
`speech_vectors``.``shape`
(232, 13211)
我们有 232 篇演讲,每篇演讲都被转换为一个长度为 13,211 的向量。为了可视化这些演讲,我们使用一种称为主成分分析的技术,将 13,211 个特征的数据表通过一组新的正交特征重新表示。第一个向量解释了原始特征的最大变化,第二个向量解释了与第一个正交的最大方差,依此类推。通常,我们可以将前两个主成分作为点对进行绘制,从而揭示聚类和异常值。
接下来,我们绘制了前两个主成分。每个点代表一个演讲,我们根据演讲的年份对点进行了颜色编码。靠近一起的点表示相似的演讲,而远离的点表示不同的演讲:

我们可以清晰地看到随时间变化的演讲之间存在明显的差异——19 世纪的演讲使用了与 21 世纪后的演讲非常不同的词汇。同一时间段的演讲聚集在一起也是非常有趣的现象。这表明,同一时期的演讲在语言风格上相对相似,即使演讲者来自不同的政党。
本节对文本分析进行了简要介绍。我们使用了前几节的文本操作工具来清理总统演讲稿。然后,我们采用了更高级的技术,如词干提取、tf-idf 转换和主成分分析来比较演讲。尽管本书无法详细介绍所有这些技术,但我们希望这一部分能引起您对文本分析这一激动人心领域的兴趣。
摘要
本章介绍了处理文本以清洁和分析数据的技术,包括字符串操作、正则表达式和文档分析。文本数据包含了关于人们生活、工作和思考方式的丰富信息。但这些数据对计算机来说也很难使用——想想人们为了表达同一个词而创造出的各种形式。本章的技术使我们能够纠正打字错误,从日志中提取特征,并比较文档。
我们不建议您使用正则表达式来:
-
解析层次结构,如 JSON 或 HTML;请使用解析器
-
搜索复杂属性,如回文和平衡的括号
-
验证复杂功能,比如有效的电子邮件地址
正则表达式虽然功能强大,但在这类任务中表现很差。然而,根据我们的经验,即使是基本的文本处理技能也能实现各种有趣的分析——少量技巧也能带来长远影响。
我们还要特别提醒一下正则表达式:它们可能会消耗大量计算资源。在将它们用于生产代码时,您需要权衡这些简洁明了的表达式与它们可能带来的开销。
下一章将讨论其他类型的数据,如二进制格式的数据以及 JSON 和 HTML 中高度结构化的文本。我们将重点放在将这些数据加载到数据框和其他 Python 数据结构中。
第十四章:数据交换
数据可以以许多不同的格式存储和交换。到目前为止,我们专注于纯文本分隔和固定宽度格式(第八章)。在本章中,我们稍微扩展了视野,并介绍了几种其他流行的格式。虽然 CSV、TSV 和 FWF 文件有助于将数据组织成数据框架,但其他文件格式可以节省空间或表示更复杂的数据结构。二进制文件(binary是指不是纯文本格式的格式)可能比纯文本数据源更经济。例如,在本章中,我们介绍了 NetCDF,这是一种用于交换大量科学数据的流行二进制格式。其他像 JSON 和 XML 这样的纯文本格式可以以更通用和有用于复杂数据结构的方式组织数据。甚至 HTML 网页,作为 XML 的近亲,通常包含我们可以抓取并整理以进行分析的有用信息。
在本章中,我们介绍了这些流行格式,描述了它们组织的心智模型,并提供了示例。除了介绍这些格式外,我们还涵盖了在线获取数据的程序化方法。在互联网出现之前,数据科学家必须亲自搬移硬盘驱动器才能与他人共享数据。现在,我们可以自由地从世界各地的计算机中检索数据集。我们介绍了 HTTP,这是 Web 的主要通信协议,以及 REST,一种数据传输的架构。通过了解一些关于这些 Web 技术的知识,我们可以更好地利用 Web 作为数据来源。
本书始终为数据整理、探索和建模提供了可重复的代码示例。在本章中,我们将讨论如何以可重复的方式获取在线数据。
我们首先介绍 NetCDF,然后是 JSON。接着,在概述用于数据交换的 Web 协议之后,我们通过介绍 XML、HTML 和 XPath,一个从这些类型文件中提取内容的工具,来结束本章。
NetCDF 数据
网络通用数据格式(NetCDF)是存储面向数组的科学数据的方便高效的格式。该格式的心智模型通过多维值网格表示变量。图 14-1 展示了这个概念。例如,降雨量每天在全球各地记录。我们可以想象这些降雨量值排列成一个立方体,其中经度沿着立方体的一边,纬度沿着另一边,日期沿着第三个维度。立方体中的每个单元格存储了特定位置每天记录的降雨量。NetCDF 文件还包含我们称之为元数据的有关立方体尺寸的信息。在数据框中,同样的信息会以完全不同的方式组织,对于每次降雨测量,我们需要经度、纬度和日期三个特征。这将意味着重复大量数据。使用 NetCDF 文件,我们不需要为每天重复经度和纬度值,也不需要为每个位置重复日期。

图 14-1. 该图表代表了 NetCDF 数据的模型。数据组织成一个三维数组,其中包含了时间和位置(纬度、经度和时间)上的降雨记录。“X”标记了特定位置在特定日期的一个降雨测量。
NetCDF 除了更紧凑之外,还有其他几个优点:
可扩展的
它提供了对数据子集的高效访问。
可附加的
您可以轻松地添加新数据而无需重新定义结构。
可共享
它是一种独立于编程语言和操作系统的常见格式。
自描述的
源文件包含了数据组织的描述和数据本身。
社区
这些工具是由用户社区提供的。
注意
NetCDF 格式是二进制数据的一个例子 —— 这类数据不能像 CSV 这样的文本格式一样直接在文本编辑器如 vim 或 Visual Studio Code 中读取。还有许多其他二进制数据格式,包括 SQLite 数据库(来自第 7 章)、Feather 和 Apache Arrow。二进制数据格式提供了存储数据集的灵活性,但通常需要特殊工具来打开和读取。
NetCDF 变量不仅限于三个维度。例如,我们可以添加海拔到我们的地球科学应用程序中,以便我们在时间、纬度、经度和海拔上记录温度等数据。维度不一定要对应物理维度。气候科学家经常运行几个模型,并将模型号存储在维度中以及模型输出。虽然 NetCDF 最初是为大气科学家设计的,由大气研究公司(UCAR)开发,但这种格式已经广受欢迎,并且现在在全球数千个教育、研究和政府网站上使用。应用程序已扩展到其他领域,如天文学和物理学,通过史密森尼/ NASA 天体物理数据系统(ADS),以及医学成像通过医学图像 NetCDF(MINC)。
NetCDF 文件有三个基本组件:维度、变量和各种元数据。变量包含我们认为的数据,例如降水记录。每个变量都有名称、存储类型和形状,即维度的数量。维度组件给出每个维度的名称和网格点的数量。坐标提供了其他信息,特别是测量点的位置,例如经度,在这里这些可能是。其他元数据包括属性。变量的属性可以包含有关变量的辅助信息,其他属性包含关于文件的全局信息,例如发布数据集的人员、其联系信息以及使用数据的权限。这些全局信息对确保可重复结果至关重要。
以下示例检查了特定 NetCDF 文件的组件,并演示了如何从变量中提取数据的部分。
气候数据存储提供了来自各种气候部门和服务的数据集合。我们访问了他们的网站,并请求了 2022 年 12 月两周的温度和总降水量的测量数据。让我们简要检查这些数据的组织结构,如何提取子集,并进行可视化。
数据位于 NetCDF 文件CDS_ERA5_22-12.nc中。让我们首先弄清楚文件有多大:
`from` `pathlib` `import` `Path`
`import` `os`
`file_path` `=` `Path``(``)` `/` `'``data``'` `/` `'``CDS_ERA5_22-12.nc``'`
`kib` `=` `1024`
`size` `=` `os``.``path``.``getsize``(``file_path``)`
`np``.``round``(``size` `/` `kib``*``*``3``)`
2.0
尽管只有三个变量(总降水量、降雨率、温度)的两周数据,但文件的大小达到了 2 GiB!这些气候数据通常会相当庞大。
xarray包对处理类似数组的数据非常有用,尤其是 NetCDF 格式的数据。我们使用它的功能来探索我们气候文件的组件。首先我们打开文件:
`import` `xarray` `as` `xr`
`ds` `=` `xr``.``open_dataset``(``file_path``)`
现在让我们检查文件的维度组件:
`ds``.``dims`
Frozen(SortedKeysDict({'longitude': 1440, 'latitude': 721, 'time': 408}))
就像在 图 14-1 中一样,我们的文件有三个维度:经度、纬度和时间。 每个维度的大小告诉我们有超过 400,000 个数据值单元格(1440 × 721 × 408)。 如果这些数据在数据框中,则数据框将具有 400,000 行,其中包含大量重复的纬度、经度和时间列! 相反,我们只需要它们的值一次,坐标组件就会给我们提供它们:
`ds``.``coords`
Coordinates:
* longitude (longitude) float32 0.0 0.25 0.5 0.75 ... 359.0 359.2 359.5 359.8
* latitude (latitude) float32 90.0 89.75 89.5 89.25 ... -89.5 -89.75 -90.0
* time (time) datetime64[ns] 2022-12-15 ... 2022-12-31T23:00:00
我们文件中的每个变量都是三维的。 实际上,一个变量不一定要有所有三个维度,但在我们的示例中确实有:
`ds``.``data_vars`
Data variables:
t2m (time, latitude, longitude) float32 ...
lsrr (time, latitude, longitude) float32 ...
tp (time, latitude, longitude) float32 ...
变量的元数据提供单位和较长的描述,而源的元数据则为我们提供诸如检索数据的时间等信息:
`ds``.``tp``.``attrs`
{'units': 'm', 'long_name': 'Total precipitation'}
`ds``.``attrs`
{'Conventions': 'CF-1.6',
'history': '2023-01-19 19:54:37 GMT by grib_to_netcdf-2.25.1: /opt/ecmwf/mars-client/bin/grib_to_netcdf.bin -S param -o /cache/data6/adaptor.mars.internal-1674158060.3800251-17201-13-c46a8ac2-f1b6-4b57-a14e-801c001f7b2b.nc /cache/tmp/c46a8ac2-f1b6-4b57-a14e-801c001f7b2b-adaptor.mars.internal-1674158033.856014-17201-20-tmp.grib'}
通过将所有这些信息保存在源文件中,我们不会冒丢失信息或使描述与数据不同步的风险。
就像使用 pandas 一样,xarray 提供了许多不同的方法来选择要处理的数据部分。 我们展示了两个例子。 首先,我们专注于一个特定的位置,并使用线性图来查看时间内的总降水量:
`plt``.``figure``(``)`
`(``ds``.``sel``(``latitude``=``37.75``,` `longitude``=``237.5``)``.``tp` `*` `100``)``.``plot``(``figsize``=``(``8``,``3``)``)`
`plt``.``xlabel``(``'``'``)`
`plt``.``ylabel``(``'``Total precipitation (cm)``'``)`
`plt``.``show``(``)``;`
<Figure size 288x216 with 0 Axes>

接下来,我们选择一个日期,2022 年 12 月 31 日,下午 1 点,并将纬度和经度缩小到美国大陆范围内,以制作温度地图:
`import` `datetime`
`one_day` `=` `datetime``.``datetime``(``2022``,` `12``,` `31``,` `13``,` `0``,` `0``)`
`min_lon``,` `min_lat``,` `max_lon``,` `max_lat` `=` `232``,` `21``,` `300``,` `50`
`mask_lon` `=` `(``ds``.``longitude` `>` `min_lon``)` `&` `(``ds``.``longitude` `<` `max_lon``)`
`mask_lat` `=` `(``ds``.``latitude` `>` `min_lat``)` `&` `(``ds``.``latitude` `<` `max_lat``)`
`ds_oneday_us` `=` `ds``.``sel``(``time``=``one_day``)``.``t2m``.``where``(``mask_lon` `&` `mask_lat``,` `drop``=``True``)`
就像对于数据框的 loc 一样,sel 返回一个新的 DataArray,其数据由沿指定维度的索引标签确定,对于本例来说,即日期。 而且就像 np.where 一样,xr.where 根据提供的逻辑条件返回元素。 我们使用 drop=True 来减少数据集的大小。
让我们制作一个温度色彩地图,其中颜色代表温度:
`ds_oneday_us``.``plot``(``figsize``=``(``8``,``4``)``)`

从这张地图中,我们可以看出美国的形状、温暖的加勒比海和更冷的山脉。
我们通过关闭文件来结束:
`ds``.``close``(``)`
这个对 NetCDF 的简要介绍旨在介绍基本概念。 我们的主要目标是展示其他类型的数据格式存在,并且可以比纯文本读入数据框更具优势。 对于感兴趣的读者,NetCDF 拥有丰富的软件包和功能。 例如,除了 xarray 模块之外,NetCDF 文件还可以使用其他 Python 模块(如 netCDF4 和 gdal)进行读取。 NetCDF 社区还提供了与 NetCDF 数据交互的命令行工具。 制作可视化和地图的选项包括 matplotlib、iris(建立在 netCDF4 之上)和 cartopy。
接下来我们考虑 JSON 格式,它比 CSV 和 FWF 格式更灵活,可以表示分层数据。
JSON 数据
JavaScript 对象表示法(JSON)是在 web 上交换数据的流行格式。 这种纯文本格式具有简单灵活的语法,与 Python 字典非常匹配,易于机器解析和人类阅读。
简而言之,JSON 有两种主要结构,对象和数组:
对象
像 Python 的dict一样,JSON 对象是一个无序的名称-值对集合。这些对包含在大括号中;每个对都格式为"name":value,并用逗号分隔。
数组
像 Python 的list一样,JSON 数组是一个有序的值集合,包含在方括号中,其中值没有名称,并用逗号分隔。
对象和数组中的值可以是不同类型的,并且可以嵌套。也就是说,数组可以包含对象,反之亦然。原始类型仅限于双引号中的字符串,文本表示中的数字,true 或 false 作为逻辑,以及 null。
以下简短的 JSON 文件演示了所有这些语法特性:
{"lender_id":"matt",
"loan_count":23,
"status":[2, 1, 3],
"sponsored": false,
"sponsor_name": null,
"lender_dem":{"sex":"m","age":77 }
}
在这里,我们有一个包含六个名称-值对的对象。值是异构的;其中四个是原始值:字符串,数字,逻辑和 null。status值由三个(有序的)数字数组组成,而lender_dem是包含人口统计信息的对象。
内置的json包可用于在 Python 中处理 JSON 文件。例如,我们可以将这个小文件加载到 Python 字典中:
`import` `json`
`from` `pathlib` `import` `Path`
`file_path` `=` `Path``(``)` `/` `'``data``'` `/` `'``js_ex``'` `/` `'``ex.json``'`
`ex_dict` `=` `json``.``load``(``open``(``file_path``)``)`
`ex_dict`
{'lender_id': 'matt',
'loan_count': 23,
'status': [2, 1, 3],
'sponsored': False,
'sponsor_name': None,
'lender_dem': {'sex': 'm', 'age': 77}}
字典与 Kiva 文件的格式相匹配。这种格式并不自然地转换为数据框。json_normalize方法可以将这种半结构化的 JSON 数据组织成一个平面表:
`ex_df` `=` `pd``.``json_normalize``(``ex_dict``)`
`ex_df`
| lender_id | loan_count | status | sponsored | sponsor_name | lender_dem.sex | lender_dem.age | |
|---|---|---|---|---|---|---|---|
| 0 | matt | 23 | [2, 1, 3] | False | None | m | 77 |
注意,在这个单行数据框中,第三个元素是一个列表,而嵌套对象被转换为两列。
JSON 中数据结构的灵活性非常大,这意味着如果我们想要从 JSON 内容创建数据框,我们需要了解 JSON 文件中数据的组织方式。我们提供了三种结构,这些结构可以轻松地转换为数据框。
在第十二章中使用的 PurpleAir 站点列表是 JSON 格式的。在那一章中,我们没有注意到格式,只是使用json库的load方法将文件内容读入字典,然后转换为数据框。在这里,我们简化了该文件,同时保持了一般结构,以便更容易进行检查。
我们首先检查原始文件,然后将其重新组织成另外两种可能用于表示数据框的 JSON 结构。通过这些示例,我们旨在展示 JSON 的灵活性。 图 14-2 中的图表显示了这三种可能性的表示。

图 14-2. JSON 格式文件存储数据框的三种不同方法。
图表中最左侧的数据框按行组织。每一行都是具有命名值的对象,其中名称对应于数据框的列名。然后将行收集到数组中。这种结构与原始文件的结构相吻合。在下面的代码中,我们显示文件内容:
{"Header": [
{"status": "Success",
"request_time": "2022-12-29T01:48:30-05:00",
"url": "https://aqs.epa.gov/data/api/dailyData/...",
"rows": 4
}
],
"Data": [
{"site": "0014", "date": "02-27", "aqi": 30},
{"site": "0014", "date": "02-24", "aqi": 17},
{"site": "0014", "date": "02-21", "aqi": 60},
{"site": "0014", "date": "01-15", "aqi": null}
]
}
我们看到文件包含一个对象,有两个元素,名为Header和Data。Data元素是一个数组,每一行数据框中都有一个元素,正如前面描述的,每个元素都是一个对象。让我们将文件加载到字典中并检查其内容(详见第八章有关查找文件路径和打印内容的更多信息):
`from` `pathlib` `import` `Path`
`import` `os`
`epa_file_path` `=` `Path``(``'``data/js_ex/epa_row.json``'``)`
`data_row` `=` `json``.``loads``(``epa_file_path``.``read_text``(``)``)`
`data_row`
{'Header': [{'status': 'Success',
'request_time': '2022-12-29T01:48:30-05:00',
'url': 'https://aqs.epa.gov/data/api/dailyData/...',
'rows': 4}],
'Data': [{'site': '0014', 'date': '02-27', 'aqi': 30},
{'site': '0014', 'date': '02-24', 'aqi': 17},
{'site': '0014', 'date': '02-21', 'aqi': 60},
{'site': '0014', 'date': '01-15', 'aqi': None}]}
我们可以快速将对象数组转换为数据框,只需进行以下调用:
`pd``.``DataFrame``(``data_row``[``"``Data``"``]``)`
| 网站 | 日期 | 空气质量指数 | |
|---|---|---|---|
| 0 | 0014 | 02-27 | 30.0 |
| 1 | 0014 | 02-24 | 17.0 |
| 2 | 0014 | 02-21 | 60.0 |
| 3 | 0014 | 01-15 | NaN |
图中的中间图表在图 14-2 中采用了列的方法来组织数据。这里列被提供为数组,并收集到一个对象中,名称与数据框的列名相匹配。以下文件展示了该概念:
`epa_col_path` `=` `Path``(``'``data/js_ex/epa_col.json``'``)`
`print``(``epa_col_path``.``read_text``(``)``)`
{"site":[ "0014", "0014", "0014", "0014"],
"date":["02-27", "02-24", "02-21", "01-15"],
"aqi":[30,17,60,null]}
由于pd.read_json()期望这种格式,我们可以直接将文件读入数据框,而不需要先加载到字典中:
`pd``.``read_json``(``epa_col_path``)`
| 网站 | 日期 | 空气质量指数 | |
|---|---|---|---|
| 0 | 14 | 02-27 | 30.0 |
| 1 | 14 | 02-24 | 17.0 |
| 2 | 14 | 02-21 | 60.0 |
| 3 | 14 | 01-15 | NaN |
最后,我们将数据组织成类似矩阵的结构(图中右侧的图表),并分别为特征提供列名。数据矩阵被组织为一个数组的数组:
{'vars': ['site', 'date', 'aqi'],
'data': [['0014', '02-27', 30],
['0014', '02-24', 17],
['0014', '02-21', 60],
['0014', '01-15', None]]}
我们可以提供vars和data来创建数据框:
`pd``.``DataFrame``(``data_mat``[``"``data``"``]``,` `columns``=``data_mat``[``"``vars``"``]``)`
| 网站 | 日期 | 空气质量指数 | |
|---|---|---|---|
| 0 | 0014 | 02-27 | 30.0 |
| 1 | 0014 | 02-24 | 17.0 |
| 2 | 0014 | 02-21 | 60.0 |
| 3 | 0014 | 01-15 | NaN |
我们包含这些示例是为了展示 JSON 的多功能性。主要的收获是 JSON 文件可以以不同的方式排列数据,因此我们通常需要在成功将数据读入数据框之前检查文件。JSON 文件在存储在网络上的数据中非常常见:本节中的示例是从 PurpleAir 和 Kiva 网站下载的文件。尽管在本节中我们手动下载了数据,但我们经常希望一次下载多个数据文件,或者我们希望有一个可靠且可重现的下载记录。在下一节中,我们将介绍 HTTP,这是一个协议,让我们能够编写程序自动从网络上下载数据。
HTTP
HTTP(超文本传输协议)是访问网络资源的通用基础设施。互联网上提供了大量的数据集,通过 HTTP 我们可以获取这些数据集。
互联网允许计算机彼此通信,而 HTTP 则对通信进行结构化。 HTTP 是一种简单的请求-响应协议,其中客户端向服务器提交一个特殊格式的文本请求,服务器则返回一个特殊格式的文本响应。 客户端可以是 Web 浏览器或我们的 Python 会话。
HTTP 请求由两部分组成:头部和可选的正文。 头部必须遵循特定的语法。 请求获取在图 14-3 中显示的维基百科页面的示例如下所示:
GET /wiki/1500_metres_world_record_progression HTTP/1.1
Host: en.wikipedia.org
User-Agent: curl/7.65.2
Accept: */*
{blank_line}
第一行包含三个信息部分:以请求的方法开头,这是GET;其后是我们想要的网页的 URL;最后是协议和版本。 接下来的三行每行提供服务器的辅助信息。 这些信息的格式为名称: 值。 最后,空行标志着头部的结束。 请注意,在前面的片段中,我们用{blank_line}标记了空行;实际消息中,这是一个空行。

图 14-3. 维基百科页面截图,显示 1500 米赛跑的世界纪录数据
客户端的计算机通过互联网将此消息发送给维基百科服务器。 服务器处理请求并发送响应,响应也包括头部和正文。 响应的头部如下所示:
< HTTP/1.1 200 OK
< date: Fri, 24 Feb 2023 00:11:49 GMT
< server: mw1369.eqiad.wmnet
< x-content-type-options: nosniff
< content-language: en
< vary: Accept-Encoding,Cookie,Authorization
< last-modified: Tue, 21 Feb 2023 15:00:46 GMT
< content-type: text/html; charset=UTF-8
...
< content-length: 153912
{blank_line}
第一行声明请求成功完成;状态代码为 200。 接下来的行提供了客户端的额外信息。 我们大大缩短了这个头部,仅关注告诉我们正文内容为 HTML,使用 UTF-8 编码,并且内容长度为 153,912 个字符的几个信息。 最后,头部末尾的空行告诉客户端,服务器已经完成发送头部信息,响应正文随后而来。
几乎每个与互联网交互的应用程序都使用 HTTP。 例如,如果您在 Web 浏览器中访问相同的维基百科页面,浏览器会执行与刚刚显示的基本 HTTP 请求相同的操作。 当它接收到响应时,它会在浏览器窗口中显示正文,该正文看起来像图 14-3 中的屏幕截图。
在实践中,我们不会手动编写完整的 HTTP 请求。 相反,我们使用诸如requests Python 库之类的工具来为我们构建请求。 以下代码为我们构造了获取图 14-3 页面的 HTTP 请求。 我们只需将 URL 传递给requests.get。 名称中的“get”表示正在使用GET方法:
`import` `requests`
`url_1500` `=` `'``https://en.wikipedia.org/wiki/1500_metres_world_record_progression``'`
`resp_1500` `=` `requests``.``get``(``url_1500``)`
我们可以检查我们的请求状态,以确保服务器成功完成它:
`resp_1500``.``status_code`
200
我们可以通过对象的属性彻底检查请求和响应。 例如,让我们看一看我们请求的头部中的键值对:
`for` `key` `in` `resp_1500``.``request``.``headers``:`
`print``(``f``'``{``key``}``:` `{``resp_1500``.``request``.``headers``[``key``]``}``'``)`
User-Agent: python-requests/2.25.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
虽然我们在函数调用中没有指定任何头信息,但request.get为我们提供了一些基本信息。如果需要发送特殊的头信息,我们可以在调用中指定它们。
现在让我们来查看从服务器收到的响应头:
`len``(``resp_1500``.``headers``)`
20
正如我们之前看到的,响应中有大量的头信息。我们仅显示date、content-type和content-length:
`keys` `=` `[``'``date``'``,` `'``content-type``'``,` `'``content-length``'` `]`
`for` `key` `in` `keys``:`
`print``(``f``'``{``key``}``:` `{``resp_1500``.``headers``[``key``]``}``'``)`
date: Fri, 10 Mar 2023 01:54:13 GMT
content-type: text/html; charset=UTF-8
content-length: 23064
最后,我们显示响应体的前几百个字符(整个内容过长,无法在此完整显示):
`resp_1500``.``text``[``:``600``]`
'<!DOCTYPE html>\n<html class="client-nojs vector-feature-language-in-header-enabled vector-feature-language-in-main-page-header-disabled vector-feature-language-alert-in-sidebar-enabled vector-feature-sticky-header-disabled vector-feature-page-tools-disabled vector-feature-page-tools-pinned-disabled vector-feature-toc-pinned-enabled vector-feature-main-menu-pinned-disabled vector-feature-limited-width-enabled vector-feature-limited-width-content-enabled" lang="en" dir="ltr">\n<head>\n<meta charset="UTF-8"/>\n<title>1500 metres world record progression - Wikipedia</title>\n<script>document.documentE'
我们确认响应是一个 HTML 文档,并且包含标题1500 metres world record progression - Wikipedia。我们已成功获取了图 14-3 中显示的网页。
我们的 HTTP 请求已成功,服务器返回了状态码200。还有数百种其他 HTTP 状态码。幸运的是,它们被分组到不同的类别中,以便记忆(见表 14-1)。
表 14-1。响应状态码
| Code | Type | Description |
|---|---|---|
| 100s | 信息性 | 需要客户端或服务器进一步输入(100 Continue、102 Processing 等)。 |
| 200s | 成功 | 客户端请求成功(200 OK、202 Accepted 等)。 |
| 300s | 重定向 | 请求的 URL 位于其他位置,可能需要用户进一步操作(300 Multiple Choices、301 Moved Permanently 等)。 |
| 400s | 客户端错误 | 发生了客户端错误(400 Bad Request、403 Forbidden、404 Not Found 等)。 |
| 500s | 服务器错误 | 发生了服务器端错误或服务器无法执行请求(500 Internal Server Error、503 Service Unavailable 等)。 |
一个常见的错误代码可能看起来很熟悉,即 404,表示我们请求的资源不存在。我们在这里发送这样的请求:
`url` `=` `"``https://www.youtube.com/404errorwow``"`
`bad_loc` `=` `requests``.``get``(``url``)`
`bad_loc``.``status_code`
404
我们发出的请求是用GET HTTP 请求获取网页。有四种主要的 HTTP 请求类型:GET、POST、PUT和DELETE。最常用的两种方法是GET和POST。我们刚刚使用GET来获取网页:
`resp_1500``.``request``.``method`
'GET'
POST请求用于将特定信息从客户端发送到服务器。在下一节中,我们将使用POST来从 Spotify 获取数据。
REST
网络服务越来越多地采用 REST(表述性状态转移)架构,供开发人员访问其数据。这些包括像 Twitter 和 Instagram 这样的社交媒体平台,像 Spotify 这样的音乐应用,像 Zillow 这样的房地产应用,像气候数据存储这样的科学数据源,以及世界银行的政府数据等等。REST 背后的基本思想是,每个 URL 标识一个资源(数据)。
REST 是无状态的,意味着服务器不会在连续的请求中记住客户端的状态。REST 的这一方面具有一些优势:服务器和客户端可以理解任何收到的消息,不必查看先前的消息;可以在客户端或服务器端更改代码而不影响服务的操作;访问是可伸缩的、快速的、模块化的和独立的。
在本节中,我们将通过一个示例来从 Spotify 获取数据。
我们的示例遵循Steven Morse 的博客文章,我们在一系列请求中使用POST和GET方法来检索The Clash的歌曲数据。
注意
在实践中,我们不会自己为 Spotify 编写GET和POST请求。相反,我们会使用spotipy库,该库具有与Spotify web API交互的功能。尽管如此,数据科学家通常会发现自己想要访问的数据只能通过 REST 获得,而没有 Python 库可用。因此,本节展示了如何从类似 Spotify 的 RESTful 网站获取数据。
通常,REST 应用程序会提供带有如何请求其数据的示例的文档。Spotify 提供了针对想要构建应用程序的开发者的广泛文档,但我们也可以仅仅用来探索数据访问服务。为此,我们需要注册开发者帐号并获取客户端 ID 和密钥,然后在我们的 HTTP 请求中使用它们来识别自己给 Spotify。
注册后,我们可以开始请求数据。此过程分两步:认证和请求资源。
要进行身份验证,我们发出一个 POST 请求,将我们的客户端 ID 和密钥提供给 Web 服务。我们在请求的标头中提供这些信息。作为回报,我们从服务器接收到一个授权我们进行请求的令牌。
我们开始流程并进行身份验证:
`AUTH_URL` `=` `'``https://accounts.spotify.com/api/token``'`
`import` `requests`
`auth_response` `=` `requests``.``post``(``AUTH_URL``,` `{`
`'``grant_type``'``:` `'``client_credentials``'``,`
`'``client_id``'``:` `CLIENT_ID``,`
`'``client_secret``'``:` `CLIENT_SECRET``,`
`}``)`
我们在 POST 请求的标头中以键值对的形式提供了我们的 ID 和密钥。我们可以检查请求的状态以查看是否成功:
`auth_response``.``status_code`
200
现在让我们检查响应体中的内容类型:
`auth_response``.``headers``[``'``content-type``'``]`
'application/json'
响应体包含我们需要在下一步获取数据时使用的令牌。由于此信息格式为 JSON,我们可以检查键并检索令牌:
`auth_response_data` `=` `auth_response``.``json``(``)`
`auth_response_data``.``keys``(``)`
dict_keys(['access_token', 'token_type', 'expires_in'])
`access_token` `=` `auth_response_data``[``'``access_token``'``]`
`token_type` `=` `auth_response_data``[``'``token_type``'``]`
请注意,我们隐藏了我们的 ID 和密钥,以防其他人模仿我们。没有有效的 ID 和密钥,此请求将无法成功。例如,在这里,我们编造了一个 ID 和密钥并尝试进行身份验证:
`bad_ID` `=` `'``0123456789``'`
`bad_SECRET` `=` `'``a1b2c3d4e5``'`
`auth_bad` `=` `requests``.``post``(``AUTH_URL``,` `{`
`'``grant_type``'``:` `'``client_credentials``'``,`
`'``client_id``'``:` `bad_ID``,` `'``client_secret``'``:` `bad_SECRET``,`
`}``)`
我们检查此“坏”请求的状态:
`auth_bad``.``status_code`
400
根据表 14-1,400 码表示我们发出了一个错误请求。作为一个例子,如果我们花费太多时间进行请求,Spotify 会关闭我们。在撰写本节时,我们遇到了这个问题几次,并收到了以下代码,告诉我们我们的令牌已过期:
res_clash.status_code
401
现在进行第二步,让我们获取一些数据。
对 Spotify 的资源可以通过 GET 进行请求。其他服务可能需要 POST。请求必须包括我们从 web 服务认证时收到的令牌,我们可以一次又一次地使用。我们将访问令牌传递到我们的 GET 请求的头部。我们将名称-值对构造为字典:
`headers` `=` `{``"``Authorization``"``:` `f``"``{``token_type``}` `{``access_token``}``"``}`
开发者 API 告诉我们,艺术家的专辑可在类似于 https://api.spotify.com/v1/artists/3RGLhK1IP9jnYFH4BRFJBS/albums 的 URL 上找到,其中 artists/ 和 /albums 之间的代码是艺术家的 ID。这个特定的代码是 The Clash 的。有关专辑上音轨的信息可在类似于 https://api.spotify.com/v1/albums/49kzgMsxHU5CTeb2XmFHjo/tracks 的 URL 上找到,这里的标识符是专辑的。
如果我们知道艺术家的 ID,我们可以检索其专辑的 ID,进而可以获取关于专辑上音轨的数据。我们的第一步是从 Spotify 的网站获取 The Clash 的 ID:
`artist_id` `=` `'``3RGLhK1IP9jnYFH4BRFJBS``'`
我们的第一个数据请求检索了组的专辑。我们使用 artist_id 构建 URL,并在头部传递我们的访问令牌:
`BASE_URL` `=` `"``https://api.spotify.com/v1/``"`
`res_clash` `=` `requests``.``get``(`
`BASE_URL` `+` `"``artists/``"` `+` `artist_id` `+` `"``/albums``"``,`
`headers``=``headers``,`
`params``=``{``"``include_groups``"``:` `"``album``"``}``,`
`)`
`res_clash``.``status_code`
200
我们的请求成功了。现在让我们检查响应主体的content-type:
`res_clash``.``headers``[``'``content-type``'``]`
'application/json; charset=utf-8'
返回的资源是 JSON,因此我们可以将其加载到 Python 字典中:
`clash_albums` `=` `res_clash``.``json``(``)`
经过一番搜索,我们可以发现专辑信息在 items 元素中。第一个专辑的键是:
`clash_albums``[``'``items``'``]``[``0``]``.``keys``(``)`
dict_keys(['album_group', 'album_type', 'artists', 'available_markets', 'external_urls', 'href', 'id', 'images', 'name', 'release_date', 'release_date_precision', 'total_tracks', 'type', 'uri'])
让我们打印几个专辑的专辑 ID、名称和发行日期:
`for` `album` `in` `clash_albums``[``'``items``'``]``[``:``4``]``:`
`print``(``'``ID:` `'``,` `album``[``'``id``'``]``,` `'` `'``,` `album``[``'``name``'``]``,` `'``----``'``,` `album``[``'``release_date``'``]``)`
ID: 7nL9UERtRQCB5eWEQCINsh Combat Rock + The People's Hall ---- 2022-05-20
ID: 3un5bLdxz0zKhiZXlmnxWE Live At Shea Stadium ---- 2008-08-26
ID: 4dMWTj1OkiCKFN5yBMP1vS Live at Shea Stadium (Remastered) ---- 2008
ID: 1Au9637RH9pXjBv5uS3JpQ From Here To Eternity Live ---- 1999-10-04
我们看到一些专辑是重新混音的,而另一些是现场演出。接下来,我们循环遍历专辑,获取它们的 ID,并为每个专辑请求有关音轨的信息:
`tracks` `=` `[``]`
`for` `album` `in` `clash_albums``[``'``items``'``]``:`
`tracks_url` `=` `f``"``{``BASE_URL``}``albums/``{``album``[``'``id``'``]``}``/tracks``"`
`res_tracks` `=` `requests``.``get``(``tracks_url``,` `headers``=``headers``)`
`album_tracks` `=` `res_tracks``.``json``(``)``[``'``items``'``]`
`for` `track` `in` `album_tracks``:`
`features_url` `=` `f``"``{``BASE_URL``}``audio-features/``{``track``[``'``id``'``]``}``"`
`res_feat` `=` `requests``.``get``(``features_url``,` `headers``=``headers``)`
`features` `=` `res_feat``.``json``(``)`
`features``.``update``(``{`
`'``track_name``'``:` `track``.``get``(``'``name``'``)``,`
`'``album_name``'``:` `album``[``'``name``'``]``,`
`'``release_date``'``:` `album``[``'``release_date``'``]``,`
`'``album_id``'``:` `album``[``'``id``'``]`
`}``)`
`tracks``.``append``(``features``)`
在这些音轨上有超过十几个功能可供探索。让我们以绘制 The Clash 歌曲的舞蹈性和响度为例结束本示例:

本节介绍了 REST API,它提供了程序下载数据的标准化方法。这里展示的示例下载了 JSON 数据。其他时候,来自 REST 请求的数据可能是 XML 格式的。有时我们想要的数据没有 REST API 可用,我们必须从 HTML 中提取数据,这是一种与 XML 类似的格式。接下来我们将描述如何处理这些格式。
XML、HTML 和 XPath
可扩展标记语言(XML)可以表示各种类型的信息,例如发送到和从 Web 服务传送的数据,包括网页、电子表格、SVG 等可视显示、社交网络结构、像微软的 docx 这样的文字处理文档、数据库等等。对于数据科学家来说,了解 XML 会有所帮助。
尽管它的名称是 XML,但它不是一种语言。相反,它是一个非常通用的结构,我们可以用它来定义表示和组织数据的格式。XML 提供了这些“方言”或词汇表的基本结构和语法。如果你读过或撰写过 HTML,你会认出 XML 的格式。
XML 的基本单位是元素,也被称为节点。一个元素有一个名称,可以有属性、子元素和文本。
下面标注的 XML 植物目录片段提供了这些部分的示例(此内容改编自W3Schools):
<catalog> The topmost node, aka root node.
<plant> The first child of the root node.
<common>Bloodroot</common> common is the first child of plant.
<botanical>Sanguinaria canadensis</botanical>
<zone>4</zone> This zone node has text content: 4.
<light>Mostly Shady</light>
<price curr="USD">$2.44</price> This node has an attribute.
<availability date="0399"/> Empty nodes can be collapsed.
</plant> Nodes must be closed.
<plant> The two plant nodes are siblings.
<common>Columbine</common>
<botanical>Aquilegia canadensis</botanical>
<zone>3</zone>
<light>Mostly Shady</light>
<price curr="CAD">$9.37</price>
<availability date="0199"/>
</plant>
</catalog>
我们为此 XML 片段添加了缩进以便更容易看到结构。实际文件中不需要缩进。
XML 文档是纯文本文件,具有以下语法规则:
-
每个元素都以开始标签开始,例如
<plant>,并以相同名称的结束标签关闭,例如</plant>。 -
XML 元素可以包含其他 XML 元素。
-
XML 元素可以是纯文本,例如
<common>Columbine</common>中的“Columbine”。 -
XML 元素可以具有可选的属性。元素
<price curr="CAD">具有属性curr,其值为"CAD"。 -
特殊情况下,当节点没有子节点时,结束标签可以折叠到开始标签中。例如
<availability date="0199"/>。
当它遵循特定规则时,我们称 XML 文档为良好格式的文档。其中最重要的规则是:
-
一个根节点包含文档中的所有其他元素。
-
元素正确嵌套;开放节点在其所有子节点周围关闭,不再多余。
-
标签名称区分大小写。
-
属性值采用
name=“value”格式,可以使用单引号或双引号。
有关文档为良好格式的其他规则。这些与空白、特殊字符、命名约定和重复属性有关。
XML 的分层结构使其可以表示为树形结构。图 14-4 展示了植物目录 XML 的树形表示。

图 14-4. XML 文档的层次结构;浅灰色框表示文本元素,按设计,这些元素不能有子节点。
与 JSON 类似,XML 文档是纯文本。我们可以用纯文本查看器来读取它,对于机器来说读取和创建 XML 内容也很容易。XML 的可扩展性允许内容轻松合并到更高级别的容器文档中,并且可以轻松地与其他应用程序交换。XML 还支持二进制数据和任意字符集。
如前所述,HTML 看起来很像 XML。这不是偶然的,事实上,XHTML 是 HTML 的子集,遵循良好格式 XML 的规则。让我们回到之前从互联网上检索的维基百科页面的例子,并展示如何使用 XML 工具从其表格内容创建数据框架。
示例:从维基百科抓取赛时
在本章的早些时候,我们使用了一个 HTTP 请求从维基百科检索了 HTML 页面,如图 14-3 所示。这个页面的内容是 HTML 格式的,本质上是 XML 词汇。我们可以利用页面的分层结构和 XML 工具来访问其中一个表格中的数据,并将其整理成数据框。特别是,我们对页面中的第二个表格感兴趣,其中的一部分显示在图 14-5 的截图中。

图 14-5. 网页中包含我们想要提取的数据的第二个表格的截图
在我们处理这个表格之前,我们先快速总结一下基本 HTML 表格的格式。这是一个带有表头和两行三列的表格的 HTML 格式:
<table>
<tbody>
<tr>
<th>A</th><th>B</th><th>C</th>
</tr>
<tr>
<td>1</td><td>2</td><td>3</td>
</tr>
<tr>
<td>5</td><td>6</td><td>7</td>
</tr>
</tbody>
</table>
注意表格是如何以<tr>元素为行布局的,每行中的每个单元格是包含在<td>元素中的文本,用于在表格中显示。
我们的第一个任务是从网页内容中创建一个树结构。为此,我们使用lxml库,它提供了访问 C 库libxml2来处理 XML 内容的功能。回想一下,resp_1500包含了我们请求的响应,页面位于响应体中。我们可以使用lxml.html模块中的fromstring方法将网页解析为一个分层结构:
`from` `lxml` `import` `html`
`tree_1500` `=` `html``.``fromstring``(``resp_1500``.``content``)`
`type``(``tree_1500``)`
lxml.html.HtmlElement
现在我们可以使用文档的树结构来处理文档。我们可以通过以下搜索找到 HTML 文档中的所有表格:
`tables` `=` `tree_1500``.``xpath``(``'``//table``'``)`
`type``(``tables``)`
list
`len``(``tables``)`
7
这个搜索使用了 XPath //table 表达式,在文档中的任何位置搜索所有表格节点。
我们在文档中找到了六个表格。如果我们检查网页,包括通过浏览器查看其 HTML 源代码,我们可以发现文档中的第二个表格包含 IAF 时代的时间。这是我们想要的表格。图 14-5 中的截图显示,第一列包含比赛时间,第三列包含名称,第四列包含比赛日期。我们可以依次提取这些信息。我们使用以下 XPath 表达式完成这些操作:
`times` `=` `tree_1500``.``xpath``(``'``//table[3]/tbody/tr/td[1]/b/text()``'``)`
`names` `=` `tree_1500``.``xpath``(``'``//table[3]/tbody/tr/td[3]/a/text()``'``)`
`dates` `=` `tree_1500``.``xpath``(``'``//table[3]/tbody/tr/td[4]/text()``'``)`
`type``(``times``[``0``]``)`
lxml.etree._ElementUnicodeResult
这些返回值的行为类似于列表,但每个值都是树的元素。我们可以将它们转换为字符串:
`date_str` `=` `[``str``(``s``)` `for` `s` `in` `dates``]`
`name_str` `=` `[``str``(``s``)` `for` `s` `in` `names``]`
对于时间,我们希望将其转换为秒。函数get_sec可以完成这个转换。而我们希望从日期字符串中提取比赛年份:
`def` `get_sec``(``time``)``:`
`"""convert time into seconds."""`
`time` `=` `str``(``time``)`
`time` `=` `time``.``replace``(``"``+``"``,``"``"``)`
`m``,` `s` `=` `time``.``split``(``'``:``'``)`
`return` `float``(``m``)` `*` `60` `+` `float``(``s``)`
`time_sec` `=` `[``get_sec``(``rt``)` `for` `rt` `in` `times``]`
`race_year` `=` `pd``.``to_datetime``(``date_str``,` `format``=``'``%Y``-``%m``-``%d``\n``'``)``.``year`
我们可以创建一个数据框并绘制图表,以展示比赛时间随年份的变化情况:

正如你可能已经注意到的那样,从 HTML 页面中提取数据需要仔细检查源代码,找到我们需要的数字在文档中的位置。我们大量使用 XPath 工具进行提取。它的语言优雅而强大。我们接下来介绍它。
XPath
当我们处理 XML 文档时,通常希望从中提取数据并将其带入数据框中。XPath 可以在这方面提供帮助。XPath 可以递归地遍历 XML 树以查找元素。例如,在前面的示例中,我们使用表达式 //table 定位网页中所有表格节点。
XPath 表达式作用于良构 XML 的层次结构。它们简洁并且格式类似于计算机文件系统中目录层次结构中定位文件的方式。但它们更加强大。XPath 与正则表达式类似,我们指定要匹配内容的模式。与正则表达式一样,撰写正确的 XPath 表达式需要经验。
XPath 表达式形成逻辑步骤,用于识别和过滤树中的节点。结果是一个节点集,其中每个节点最多出现一次。节点集也具有与源中节点出现顺序匹配的顺序;这一点非常方便。
每个 XPath 表达式由一个或多个位置步骤组成,用“/”分隔。每个位置步骤有三个部分——轴、节点测试和可选的谓词:
-
轴指定查找的方向,例如向下、向上或横向。我们专门使用轴的快捷方式。默认是向下一步查找树中的子节点。
//表示尽可能向下查找整个树,..表示向上一步到父节点。 -
节点测试标识要查找的节点的名称或类型。通常只是标签名或者对于文本元素是
text()。 -
谓词像过滤器一样作用于进一步限制节点集。这些谓词以方括号表示,例如
[2],保留节点集中的第二个节点,以及[ @date ],保留具有日期属性的所有节点。
我们可以将位置步骤连接在一起,以创建强大的搜索指令。表 14-2 提供了一些涵盖最常见表达式的示例。请参考 图 14-4 中的树进行跟踪。
表 14-2. XPath 示例
| 表达式 | 结果 | 描述 |
|---|---|---|
| ‘//common’ | Two nodes | 在树中向下查找任何共同节点。 |
| ‘/catalog/plant/common’ | Two nodes | 从根节点 catalog 沿特定路径向所有植物节点遍历,并在植物节点中的所有共同节点中查找。 |
| ‘//common/text()’ | Bloodroot, Columbine | 定位所有共同节点的文本内容。 |
| ‘//plant[2]/price/text()’ | $9.37 | 在树的任何位置定位植物节点,然后过滤并仅获取第二个节点。从此植物节点进入其价格子节点并定位其文本。 |
| ‘//@date’ | 0399, 0199 | 定位树中任何名为“date”的属性值。 |
| ‘//price[@curr=“CAD”]/text()’ | $9.37 | 具有货币属性值“CAD”的任何价格节点的文本内容。 |
您可以在目录文件中的表中尝试 XPath 表达式。我们使用etree模块将文件加载到 Python 中。parse方法读取文件到一个元素树中。
`from` `lxml` `import` `etree`
`catalog` `=` `etree``.``parse``(``'``data/catalog.xml``'``)`
lxml库让我们能够访问 XPath。让我们试试吧。
这个简单的 XPath 表达式定位树中任何<light>节点的所有文本内容:
`catalog``.``xpath``(``'``//light/text()``'``)`
['Mostly Shady', 'Mostly Shady']
注意返回了两个元素。虽然文本内容相同,但我们的树中有两个<light>节点,因此返回了每个节点的文本内容。以下表达式稍微有些复杂:
`catalog``.``xpath``(``'``//price[@curr=``"``CAD``"``]/../common/text()``'``)`
['Columbine']
该表达式定位树中所有<price>节点,然后根据它们的curr属性是否为CAD进行过滤。然后,对剩余节点(在本例中只有一个)在树中向上移动一步至父节点,然后返回到任何子“common”节点,并获取其文本内容。非常复杂的过程!
接下来,我们提供一个示例,使用 HTTP 请求检索 XML 格式的数据,并使用 XPath 将内容整理成数据框。
示例:访问 ECB 的汇率
欧洲央行(ECB)提供了在线 XML 格式的汇率信息。让我们通过 HTTP 请求获取 ECB 的最新汇率:
`url_base` `=` `'``https://www.ecb.europa.eu/stats/eurofxref/``'`
`url2` `=` `'``eurofxref-hist-90d.xml?d574942462c9e687c3235ce020466aae``'`
`resECB` `=` `requests``.``get``(``url_base``+``url2``)`
`resECB``.``status_code`
200
同样地,我们可以使用lxml库解析从 ECB 接收到的文本文档,但这次内容是从 ECB 返回的字符串,而不是文件:
`ecb_tree` `=` `etree``.``fromstring``(``resECB``.``content``)`
为了提取我们想要的数据,我们需要了解它的组织方式。这是内容的一部分片段:
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01"
xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
<gesmes:subject>Reference rates</gesmes:subject>
<gesmes:Sender>
<gesmes:name>European Central Bank</gesmes:name>
</gesmes:Sender>
<Cube>
<Cube time="2023-02-24">
<Cube currency="USD" rate="1.057"/>
<Cube currency="JPY" rate="143.55"/>
<Cube currency="BGN" rate="1.9558"/>
</Cube>
<Cube time="2023-02-23">
<Cube currency="USD" rate="1.0616"/>
<Cube currency="JPY" rate="143.32"/>
<Cube currency="BGN" rate="1.9558"/>
</Cube>
</Cube>
</gesmes:Envelope>
这份文档在结构上与植物目录有很大不同。代码片段展示了三个层次的标签,它们都有相同的名称,且没有文本内容。所有相关信息都包含在属性值中。根<Envelope>节点中有xmlns和gesmes:Envelope等奇怪的标签名,这些与命名空间有关。
XML 允许内容创建者使用自己的词汇,称为命名空间。命名空间为词汇提供规则,例如允许的标签名和属性名,以及节点嵌套的限制。XML 文档可以合并来自不同应用程序的词汇。为了保持一致,文档中提供了有关命名空间的信息。
ECB 文件的根节点是<Envelope>。标签名中的额外gesmes:表示这些标签属于 gesmes 词汇,这是一个用于时间序列信息交换的国际标准。<Envelope>中还有另一个命名空间。它是文件的默认命名空间,因为它没有像“gesmes:”那样的前缀。如果在标签名中未提供命名空间,则默认为此命名空间。
这意味着我们需要在搜索节点时考虑这些命名空间。让我们看看在提取日期时的运作方式。从片段中,我们看到日期存储在“time”属性中。这些 <Cube> 是顶层 <Cube> 的子节点。我们可以给出一个非常具体的 XPath 表达式,从根节点步进到其 <Cube> 子节点,然后进入下一级的 <Cube> 节点:
`namespaceURI` `=` `'``http://www.ecb.int/vocabulary/2002-08-01/eurofxref``'`
`date` `=` `ecb_tree``.``xpath``(``'``./x:Cube/x:Cube/@time``'``,` `namespaces` `=` `{``'``x``'``:``namespaceURI``}``)`
`date``[``:``5``]`
['2023-07-18', '2023-07-17', '2023-07-14', '2023-07-13', '2023-07-12']
表达式中的 . 是一个快捷方式,表示“从这里”,因为我们位于树的顶部,它相当于“从根节点”。我们在表达式中指定了命名空间为“x:”。尽管 <Cube> 节点使用了默认命名空间,但我们必须在 XPath 表达式中指定它。幸运的是,我们可以简单地将命名空间作为参数传递,并用我们自己的标签(在这种情况下是“x”)来保持标记名称的简短性。
与 HTML 表格类似,我们可以将日期值转换为字符串,再从字符串转换为时间戳:
`date_str` `=` `[``str``(``s``)` `for` `s` `in` `date``]`
`timestamps` `=` `pd``.``to_datetime``(``date_str``)`
`xrates` `=` `pd``.``DataFrame``(``{``"``date``"``:``timestamps``}``)`
至于汇率,它们也出现在 <Cube> 节点中,但这些节点有一个“rate”属性。例如,我们可以使用以下 XPath 表达式访问所有英镑的汇率(目前我们忽略命名空间):
//Cube[@currency = "GBP"]/@rate
这个表达式表示在文档中的任何位置查找所有 <Cube> 节点,根据节点是否具有货币属性值“GBP”进行过滤,并返回它们的汇率属性值。
由于我们想要提取多种货币的汇率,我们对这个 XPath 表达式进行了泛化。我们还想将汇率转换为数字存储类型,并使它们相对于第一天的汇率,以便不同的货币处于相同的比例尺上,这样更适合绘图:
`currs` `=` `[``'``GBP``'``,` `'``USD``'``,` `'``CAD``'``]`
`for` `ctry` `in` `currs``:`
`expr` `=` `'``.//x:Cube[@currency =` `"``'` `+` `ctry` `+` `'``"``]/@rate``'`
`rates` `=` `ecb_tree``.``xpath``(``expr``,` `namespaces` `=` `{``'``x``'``:``namespaceURI``}``)`
`rates_num` `=` `[``float``(``rate``)` `for` `rate` `in` `rates``]`
`first` `=` `rates_num``[``len``(``rates_num``)``-``1``]`
`xrates``[``ctry``]` `=` `[``rate` `/` `first` `for` `rate` `in` `rates_num``]`
我们以汇率的折线图作为这个示例的结束。

结合对 JSON、HTTP、REST 和 HTML 的知识,我们可以访问网上可用的大量数据。例如,在本节中,我们编写了从维基百科页面抓取数据的代码。这种方法的一个关键优势是我们可以在几个月后重新运行此代码,自动更新数据和图表。一个关键缺点是我们的方法与网页结构紧密耦合——如果有人更新了维基百科页面,而表格不再是页面上的第二个表格,我们的代码也需要一些修改才能工作。尽管如此,掌握从网页抓取数据的技能打开了广泛数据的大门,使各种有用的分析成为可能。
摘要
互联网上存储和交换的数据种类繁多。在本章中,我们的目标是让您领略到可用格式的多样性,并基本理解如何从在线来源和服务获取数据。我们还解决了以可重复的方式获取数据的重要目标。与其从网页复制粘贴或手工填写表单,我们演示了如何编写代码来获取数据。这些代码为您的工作流程和数据来源提供了记录。
每种介绍的格式,我们都描述了其结构模型。对数据集组织的基本理解有助于您发现质量问题,读取源文件中的错误,以及最佳处理和分析数据的方法。从长远来看,随着您继续发展数据科学技能,您将接触到其他形式的数据交换,我们期待这种考虑组织模型并通过一些简单案例动手实践的方法能为您服务良好。
我们仅仅触及了网络服务的表面。还有许多其他有用的主题,比如在发出多个请求或批量检索数据时保持与服务器的连接活动,使用 Cookie 和进行多个连接。但是理解此处介绍的基础知识可以让您走得更远。例如,如果您使用一个库从 API 获取数据但遇到错误,可以查看 HTTP 请求来调试代码。当新的网络服务上线时,您也会知道可能性。
网络礼仪是我们必须提及的一个话题。如果您计划从网站抓取数据,最好检查您是否有权限这样做。当我们注册成为 Web 应用的客户时,通常会勾选同意服务条款的框。
如果您使用网络服务或抓取网页,请注意不要过度请求网站。如果网站提供了类似 CSV、JSON 或 XML 格式的数据版本,最好下载并使用这些数据,而不是从网页抓取。同样,如果有一个 Python 库提供对 Web 应用的结构化访问,请使用它而不是编写自己的代码。在发送请求时,先从小处开始测试您的代码,并考虑保存结果,以免不必要地重复请求。
本章的目标不是使您成为这些特定数据格式的专家。相反,我们希望为您提供学习更多关于数据格式所需的信心,评估不同格式的优缺点,并参与可能使用您之前未见过的格式的项目。
现在您已经有了使用不同数据格式的经验,我们将回到我们在第四章中引入的建模主题,认真地继续讨论。
第五部分:线性建模
第十五章:线性模型
在本书的这一部分,我们已经以不同的程度涵盖了数据科学生命周期的四个阶段。我们已经讨论了如何提出问题、获取和清理数据,并使用探索性数据分析来更好地理解数据。在本章中,我们将常数模型(在第四章中引入)扩展为线性模型。线性模型是生命周期的最后阶段中的一种流行工具:理解世界。
了解如何拟合线性模型为各种有用的数据分析打开了大门。我们可以使用这些模型进行预测——例如,环境科学家开发了一个线性模型,根据空气传感器测量和天气条件预测空气质量(参见第十二章)。在那个案例研究中,理解两个仪器测量值的变化帮助我们校准廉价传感器,并改善它们的空气质量读数。我们还可以使用这些模型来进行推断,例如,在第十八章中我们将看到兽医如何使用线性模型推断驴的体重与长度和胸围的系数:。在那个案例研究中,该模型使得在现场工作的兽医能够为生病的驴子开具药物处方。模型还可以帮助描述关系并提供见解——例如,在本章中,我们探讨了与上升流动性相关的因素之间的关系,如通勤时间、收入不平等和 K-12 教育质量。我们进行了描述性分析,按照社会科学家用来塑造公众对话和制定政策建议的分析方法。
我们首先描述简单线性模型,它总结了两个特征之间的关系,并用一条直线表示。我们解释如何使用在第四章介绍的损失最小化方法来拟合这条直线到数据上。然后我们介绍多元线性模型,它使用多个其他特征来模拟一个特征。为了拟合这样的模型,我们使用线性代数,并揭示用平方误差损失拟合线性模型背后的几何学。最后,我们涵盖了特征工程技术,这些技术可以在构建模型时包括分类特征和转换特征。
简单线性模型
像常数模型一样,我们的目标是通过常数近似特征中的信号。现在我们有来自第二特征的额外信息来帮助我们。简而言之,我们希望利用第二特征的信息制定比常数模型更好的模型。例如,我们可能会通过房屋的大小描述其销售价格,或者根据驴的长度预测其重量。在这些例子中,我们有一个结果特征(销售价格,重量),我们希望用解释变量特征(房屋大小,长度)来解释、描述或预测。
注意
我们使用结果来指代我们试图建模的特征,解释变量来指代我们用来解释结果的特征。不同领域已经采用了描述这种关系的惯例。有些人称结果为因变量,解释变量为自变量。其他人使用响应和协变量;回归和回归器;被解释和解释性;内生和外生。在机器学习中,目标和特征或预测和预测因子是常见的。不幸的是,许多这些对都暗示了因果关系。解释或预测的概念并不一定意味着一个因素导致了另一个因素。特别令人困惑的是独立-依赖的用法,我们建议避免使用它。
我们可能会使用的一个可能模型是一条直线。数学上来说,这意味着我们有一个截距和一个斜率,并且我们使用解释特征来通过直线上的一个点来近似结果:
随着的变化,对的估计也会变化,但仍然落在直线上。通常情况下,估计并不完美,使用模型会有一些误差;这就是为什么我们使用符号来表示“大约”。
要找到一条能够很好地捕捉结果信号的线,我们使用了在第四章介绍的相同方法,并最小化平均平方损失。具体来说,我们按照以下步骤进行:
-
找到误差:
-
平方误差(即使用平方损失):
-
计算数据的平均损失:
为了拟合模型,我们寻找能够给出最小平均损失的斜率和截距;换句话说,我们最小化均方误差,简称 MSE。我们称截距和斜率的最小化值为和。
注意,在步骤 1 中计算的误差是沿垂直方向测量的,这意味着对于特定的,误差是数据点与线上点之间的垂直距离。图 15-1 展示了这一概念。左侧是点的散点图,带有用于从估计的直线。我们用方块标记了两个特定点,并用菱形表示它们在直线上的估计值。从实际点到直线的虚线段显示了误差。右侧的图是所有误差的散点图;作为参考,我们还标记了左图中两个方块点对应的误差在右图中也用方块标记了。

图 15-1. 左侧是对的散点图和我们用于从估计的直线。具体的两个点用方块表示,它们的估计值用菱形表示。右侧是误差的散点图:。
在本章的后面,我们推导出使均方误差最小化的值 和 。我们表明这些值分别为:
在这里, 表示值 ,而 类似定义; 是 对的相关系数。
将两者结合起来,线性方程变为:
这个方程有一个很好的解释:对于给定的 值,我们找出其高出(或低于)平均值多少个标准偏差,然后预测(或解释,具体取决于环境) 将是 倍的标准偏差高出(或低于)其平均值。
从最优线的表达式中我们看到 样本相关系数 发挥了重要作用。回想一下, 衡量了线性关联的强度,定义如下:
以下是有助于我们拟合线性模型的相关性的几个重要特征:
-
是无量纲的。注意,, 和 都有相同的单位,所以下面的比率是无单位的(涉及 的项同理):
-
介于 和 之间。只有当所有点恰好位于一条直线上时,相关性才为 或 ,具体取决于线的斜率是正还是负。
-
衡量线性关联的强度,而不是数据是否具有线性关联。图 15-2 中的四个散点图都有约为 的相同相关系数(以及相同的平均值和标准偏差),但只有一个图,即左上角的那个,具有我们认为的带有随机误差的线性关联。

图 15-2。这四组点,称为安斯康姆的四分位数,具有相同的相关性 0.8,以及相同的均值和标准差。左上角的图表展示线性关联;右上角显示完美的非线性关联;左下角除了一个点外,是完美的线性关联;右下角除了一个点外,没有关联。
再次强调,我们不希望数据点对精确地落在一条直线上,但我们期望点的分布能被直线合理描述,并且我们期望与估计值之间的偏差大致对称分布在直线周围,并且没有明显的模式。
线性模型是在第十二章介绍的,我们在那里使用了由环境保护局操作的高质量空气监测器与邻近的廉价空气质量监测器之间的关系来校准廉价监测器,以进行更准确的预测。我们重新审视那个例子,以使简单的线性模型概念更加具体。
示例:空气质量的简单线性模型
请回想一下第十二章,我们的目标是利用美国政府操作的精确空气质量系统(AQS)传感器的空气质量测量来预测由 PurpleAir(PA)传感器进行的测量。数据值对来自同一天测量的邻近仪器,测量空气中直径小于 2.5mm 颗粒物的平均每日浓度。(测量单位是每立方升空气中的 24 小时内颗粒物的平均计数。)在本节中,我们关注乔治亚州一个位置的空气质量测量。这些是我们在第十二章案例研究中检验的数据子集。这些测量是 2019 年 8 月至 2019 年 11 月中旬的日均值:
| 日期 | id | 区域 | pm25aqs | pm25pa | |
|---|---|---|---|---|---|
| 5258 | 2019-08-02 | GA1 | 东南 | 8.65 | 16.19 |
| 5259 | 2019-08-03 | GA1 | 东南 | 7.70 | 13.59 |
| 5260 | 2019-08-04 | GA1 | 东南 | 6.30 | 10.30 |
| ... | ... | ... | ... | ... | ... |
| 5439 | 2019-10-18 | GA1 | 东南 | 6.30 | 12.94 |
| 5440 | 2019-10-21 | GA1 | 东南 | 7.50 | 13.62 |
| 5441 | 2019-10-30 | GA1 | 东南 | 5.20 | 14.55 |
184 rows × 5 columns
特征pm25aqs包含来自 AQS 传感器的测量值,pm25pa来自 PurpleAir 监测器。由于我们有兴趣研究 AQS 测量如何预测 PurpleAir 测量,我们的散点图将 PurpleAir 读数放在 y 轴上,AQS 读数放在 x 轴上。我们还添加了趋势线:
`px``.``scatter``(``GA``,` `x``=``"``pm25aqs``"``,` `y``=``"``pm25pa``"``,` `trendline``=``'``ols``'``,`
`trendline_color_override``=``"``darkorange``"``,`
`labels``=``{``'``pm25aqs``'``:``'``AQS PM2.5``'``,` `'``pm25pa``'``:``'``PurpleAir PM2.5``'``}``,`
`width``=``350``,` `height``=``250``)`

这个散点图显示了这两种仪器测量值之间的线性关系。我们要拟合的模型具有以下形式:
其中表示 PurpleAir 的平均日测量值,表示其伙伴 AQS 的测量值。
由于pandas.Series对象具有计算标准偏差(SDs)和相关系数的内置方法,因此我们可以快速定义计算最佳拟合线的函数:
`def` `theta_1``(``x``,` `y``)``:`
`r` `=` `x``.``corr``(``y``)`
`return` `r` `*` `y``.``std``(``)` `/` `x``.``std``(``)`
`def` `theta_0``(``x``,` `y``)``:`
`return` `y``.``mean``(``)` `-` `theta_1``(``x``,` `y``)` `*` `x``.``mean``(``)`
现在我们可以通过计算这些数据的和来拟合模型:
`t1` `=` `theta_1``(``GA``[``'``pm25aqs``'``]``,` `GA``[``'``pm25pa``'``]``)`
`t0` `=` `theta_0``(``GA``[``'``pm25aqs``'``]``,` `GA``[``'``pm25pa``'``]``)`
Model: -3.36 + 2.10AQ
这个模型与散点图中显示的趋势线相匹配。这并非偶然。在调用scatter()时,trendline的参数值为"ols",表示普通最小二乘法,即通过最小化平方误差来拟合线性模型的另一个名称。
让我们检查一下误差。首先,我们找出了给定 AQS 测量值的 PA 测量值的预测值,然后计算误差—实际 PA 测量值与预测值之间的差异:
`prediction` `=` `t0` `+` `t1` `*` `GA``[``"``pm25aqs``"``]`
`error` `=` `GA``[``"``pm25pa``"``]` `-` `prediction`
`fit` `=` `pd``.``DataFrame``(``dict``(``prediction``=``prediction``,` `error``=``error``)``)`
让我们将这些误差绘制成预测值的图:
`fig` `=` `px``.``scatter``(``fit``,` `y``=``'``error``'``,` `x``=``'``prediction``'``,`
`labels``=``{``"``prediction``"``:` `"``Prediction``"``,`
`"``error``"``:` `"``Error``"``}``,`
`width``=``350``,` `height``=``250``)`
`fig``.``add_hline``(``0``,` `line_width``=``2``,` `line_dash``=``'``dash``'``,` `opacity``=``1``)`
`fig``.``update_yaxes``(``range``=``[``-``12``,` `12``]``)`

误差为 0 意味着实际测量值落在拟合线上;我们也称这条线为最小二乘线或回归线。正值意味着它在线上方,负值意味着它在线下方。你可能想知道这个模型有多好,以及它对我们的数据说了什么。我们接下来考虑这些话题。
解释线性模型
原始的成对测量散点图显示,PurpleAir 记录往往比更准确的 AQS 测量值要高得多。事实上,我们简单线性模型的方程的斜率约为 2.1。我们解释斜率意味着 AQS 监视器测得的 1 ppm 的变化平均对应于 PA 测量的 2 ppm 的变化。因此,如果一天 AQS 传感器测量 10 ppm,第二天它高出 5 ppm,即 15 ppm,那么我们对于下一天的 PA 测量的预测将增加 ppm。
任何 PurpleAir 读数的变化都不是由 AQS 读数的变化引起的。相反,它们都反映了空气质量,而我们的模型捕捉了这两个设备之间的关系。通常情况下,术语预测被认为是因果关系,但在这里并非如此。相反,预测只是指我们对 PA 和 AQS 测量之间线性关联的使用。
至于模型中的截距,我们可能期望它为 0,因为当空气中没有颗粒物时,我们认为两个仪器都应该测量 0 ppm。但对于 AQS 为 0 的情况,模型预测 PurpleAir 为 ppm,这是没有意义的。空气中不可能有负的颗粒物。这突显了在超出测量范围时使用模型的问题。我们观察到 AQS 记录在 3 到 18 ppm 之间,并且在这个范围内,模型拟合良好。虽然在理论上线应该有一个截距为 0,但在实际中这样的模型却不适用,预测往往会差得多。
著名统计学家 George Box 曾经说过:“所有模型都是错误的,但有些是有用的。” 在这里,尽管线的截距不通过 0,但简单线性模型在预测 PurpleAir 传感器的空气质量测量方面是有用的。事实上,我们两个特征之间的相关性非常高:
`GA``[``[``'``pm25aqs``'``,` `'``pm25pa``'``]``]``.``corr``(``)`
| pm25aqs | pm25pa | |
|---|---|---|
| pm25aqs | 1.00 | 0.92 |
| pm25pa | 0.92 | 1.00 |
除了查看相关系数之外,还有其他评估线性模型质量的方法。
评估拟合
早期的误差图针对拟合值给出了拟合质量的视觉评估。(这种图称为残差图,因为误差有时被称为残差。)一个良好的拟合应该显示一群点围绕着 0 的水平线,没有明显的模式。当出现模式时,我们通常可以得出简单线性模型并没有完全捕捉到信号的结论。我们之前看到残差图中没有明显的模式。
另一种有用的残差图类型是残差与不在模型中的特征的图。如果我们看到模式,那么我们可能希望在模型中加入这个特征,除了已经在模型中的特征之外。此外,当数据具有时间组成部分时,我们希望检查残差随时间的模式。对于这些特定的数据,由于测量是在四个月内的每日平均值,我们将错误绘制为测量记录日期:

看起来在八月底和九月底附近有几天数据远低于预期。回顾原始散点图(以及第一个残差图),我们可以看到两个小的水平点簇在主要点云下方。我们刚刚制作的图表表明,我们应该检查原始数据以及关于设备的任何可用信息,以确定这些天是否正常运行。
残差图还可以让我们大致了解模型在预测中的准确性。大多数误差在线路的 ppm 之间。我们发现误差的标准偏差约为 2.8 ppm:
`error``.``std``(``)`
2.796095864304746
相比之下,PurpleAir 测量的标准偏差要大得多:
`GA``[``'``pm25pa``'``]``.``std``(``)`
6.947418231019876
如果我们发现监测器在八月底和九月份的某些日子不工作,并因此将其排除在数据集之外,可能会进一步减少模型误差。无论如何,在空气非常清洁的情况下,误差相对较大,但在绝对值上并不重要。我们通常更关心空气污染的情况,此时 2.8 ppm 的误差似乎是合理的。
让我们回到如何找到这条线的过程,即模型拟合的过程。在接下来的部分,我们通过最小化均方误差来推导截距和斜率。
拟合简单线性模型
我们在本章早些时候提到,当我们最小化数据的平均损失时:
最佳拟合线具有截距和斜率:
在本节中,我们使用微积分来推导这些结果。
对于简单线性模型,均方误差是两个模型参数的函数,即截距和斜率。这意味着如果我们使用微积分来找到最小化的参数值,我们需要找到均方误差对 和 的偏导数。我们也可以通过其他技术找到这些最小值:
梯度下降
当损失函数更复杂且找到近似解更快时,我们可以使用数值优化技术,如梯度下降(参见 第二十章)。
二次公式
由于平均损失是关于 和 的二次函数,我们可以使用二次公式(以及一些代数)来求解最小化参数值。
几何论证
在本章后面,我们使用最小二乘法的几何解释来拟合多元线性模型。这种方法与毕达哥拉斯定理相关,并具有几个直观的优点。
我们选择使用微积分来优化简单线性模型,因为这是快速且直接的方法。首先,我们对平方误差的偏导数进行计算(我们可以忽略 MSE 中的 e ,因为它不影响最小值的位置):
然后我们将偏导数设为零,并通过将方程两边乘以 进行简化,得到:
这些方程式称为正规方程式。在第一个方程中,我们看到可以表示为的函数:
将这个值代入第二个方程中给出我们:
经过一些代数运算,我们可以用我们熟悉的量来表示:
正如本章前面所示,这个表示法表明拟合线上的点在处可以写成如下形式:
我们已经推导出了在前一节中使用的最小二乘线方程。在那里,我们使用了pandas内置方法来计算,和,以便轻松计算这条线的方程。然而,在实践中,我们建议使用scikit-learn提供的功能来进行模型拟合:
`from` `sklearn``.``linear_model` `import` `LinearRegression`
`y` `=` `GA``[``'``pm25pa``'``]`
`x` `=` `GA``[``[``'``pm25aqs``'``]``]`
`reg` `=` `LinearRegression``(``)``.``fit``(``x``,` `y``)`
我们的拟合模型是:
Model: PA estimate = -3.36 + 2.10AQS
注意,我们将y作为数组和x作为数据框传递给LinearRegression。当我们在模型中引入多个解释特征时,很快就会看到原因。
LinearRegression方法提供了稳定的数值算法来通过最小二乘法拟合线性模型。当拟合多个变量时,这一点尤为重要,接下来我们将介绍。
多元线性模型
到目前为止,在本章中,我们使用单个输入变量预测结果变量。现在我们介绍使用多个特征的多元线性模型来预测(或描述或解释)结果。具有多个解释特征可以改善模型对数据的拟合并提高预测能力。
我们从一个简单的线性模型推广到包括第二个解释变量的模型,称为。这个模型在和上都是线性的,这意味着对于和的一对数值,我们可以用线性组合来描述、解释或预测:
注意,对于特定的值,比如,我们可以将上述方程表示为:
换句话说,当我们将固定在时,和之间有一个简单的线性关系,斜率为,截距为。对于另一个的值,比如,我们同样有和之间的简单线性关系。的斜率保持不变,唯一的变化是截距,现在是。
使用多元线性回归时,我们需要记住在模型中的其他变量存在的情况下解释对的系数。在保持模型中其他变量(在本例中仅为)的值不变的情况下,增加 1 个单位平均对应于的的变化。一种可视化这种多元线性关系的方法是创建散点图的多面板,其中每个图中的值大致相同。我们接下来为空气质量测量制作这样的散点图,并提供其他可视化和统计学示例以检验拟合多元线性模型时的情况。
研究空气质量监测仪的科学家们(参见第十二章)寻找一个包含天气因素的改进模型。他们检查的一种天气变量是相对湿度的每日测量值。让我们考虑一个双变量线性模型,以解释基于 AQS 传感器测量和相对湿度的 PurpleAir 测量。该模型具有以下形式:
其中,和分别指代变量:PurpleAir 平均每日测量、AQS 测量和相对湿度。
作为第一步,我们制作一个多面板图来比较固定湿度值下两种空气质量测量之间的关系。为此,我们将相对湿度转换为一个分类变量,使每个面板由湿度相似的观测组成。
`rh_cat` `=` `pd``.``cut``(``GA``[``'``rh``'``]``,` `bins``=``[``43``,``50``,``55``,``60``,``78``]``,`
`labels``=``[``'``<50``'``,``'``50-55``'``,``'``55-60``'``,``'``>60``'``]``)`
然后我们使用这个定性特征将数据划分为一个二乘二的散点图面板:
`fig` `=` `px``.``scatter``(``GA``,` `x``=``'``pm25aqs``'``,` `y``=``'``pm25pa``'``,`
`facet_col``=``rh_cat``,` `facet_col_wrap``=``2``,`
`facet_row_spacing``=``0.15``,`
`labels``=``{``'``pm25aqs``'``:``'``AQS PM2.5``'``,` `'``pm25pa``'``:``'``PurpleAir PM2.5``'``}``,`
`width``=``550``,` `height``=``350``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``30``)``)`
`fig``.``show``(``)`

这四个图表显示了两种空气质量测量来源之间的线性关系。斜率看起来相似,这意味着多重线性模型可能非常合适。从这些图表中很难看出相对湿度是否对截距有显著影响。
我们还想检查三个特征之间的成对散点图。当两个解释性特征高度相关时,它们在模型中的系数可能不稳定。虽然三个或更多特征之间的线性关系在成对图中可能不明显,但检查这些图表仍然是一个好主意:
`fig` `=` `px``.``scatter_matrix``(`
`GA``[``[``'``pm25pa``'``,` `'``pm25aqs``'``,` `'``rh``'``]``]``,`
`labels``=``{``'``pm25aqs``'``:``'``AQS``'``,` `'``pm25pa``'``:``'``PurpleAir``'``,` `'``rh``'``:``'``Humidity``'``}``,`
`width``=``550``,` `height``=``400``)`
`fig``.``update_traces``(``diagonal_visible``=``False``)`

湿度与空气质量之间的关系似乎并不特别强。我们应该检查的另一个成对测量是特征之间的相关性:
| pm25pa | pm25aqs | rh | |
|---|---|---|---|
| pm25pa | 1.00 | 0.95 | -0.06 |
| pm25aqs | 0.95 | 1.00 | -0.24 |
| rh | -0.06 | -0.24 | 1.00 |
一个小惊喜是,相对湿度与 AQS 测量的空气质量具有轻微的负相关。这表明湿度可能对模型有帮助。
在下一节中,我们将推导适合的方程。但现在,我们使用LinearRegression的功能来拟合模型。与之前不同的唯一变化是我们为解释变量提供了两列(这就是为什么x输入是一个数据框):
`from` `sklearn``.``linear_model` `import` `LinearRegression`
`y` `=` `GA``[``'``pm25pa``'``]`
`X2` `=` `GA``[``[``'``pm25aqs``'``,` `'``rh``'``]``]`
`model2` `=` `LinearRegression``(``)``.``fit``(``X2``,` `y``)`
适合的多重线性模型,包括系数单位,是:
PA estimate = -15.8 ppm + 2.25 ppm/ppm x AQS + 0.21 ppm/percent x RH
模型中湿度的系数调整空气质量预测每百分点相对湿度 0.21 ppm。请注意,AQS 的系数与我们之前拟合的简单线性模型不同。这是因为系数反映了来自相对湿度的额外信息。
最后,为了检查拟合质量,我们制作了预测值和误差的残差图。这一次,我们使用LinearRegression来计算我们的预测:
`predicted_2var` `=` `model2``.``predict``(``X2``)`
`error_2var` `=` `y` `-` `predicted_2var`
`fig` `=` `px``.``scatter``(``y` `=` `error_2var``,` `x``=``predicted_2var``,`
`labels``=``{``"``y``"``:` `"``Error``"``,` `"``x``"``:` `"``Predicted PurpleAir measurement``"``}``,`
`width``=``350``,` `height``=``250``)`
`fig``.``update_yaxes``(``range``=``[``-``12``,` `12``]``)`
`fig``.``add_hline``(``0``,` `line_width``=``3``,` `line_dash``=``'``dash``'``,` `opacity``=``1``)`
`fig``.``show``(``)`

残差图表没有明显的模式,这表明模型拟合得相当好。还要注意,误差几乎都落在–4 和+4 ppm 之间,比简单线性模型的范围小。我们发现残差的标准偏差要小得多:
`error_2var``.``std``(``)`
1.8211427707294048
残差标准偏差从单变量模型的 2.8 ppm 降低到了 1.8 ppm,这是一个很好的尺寸缩减。
当我们有多个解释变量时,相关系数无法捕捉线性关联模型的强度。相反,我们调整 MSE 以了解模型的拟合程度。在下一节中,我们描述如何拟合多重线性模型并使用 MSE 来评估拟合。
拟合多重线性模型
在前一节中,我们考虑了两个解释变量的情况;其中一个我们称为,另一个为。现在我们希望将这种方法推广到个解释变量。选择不同字母来表示变量的想法很快失效了。相反,我们使用一种更正式和通用的方法,将多个预测变量表示为一个矩阵,如图 15-3 所示。我们称为设计矩阵。注意,的形状为。的每一列代表一个特征,每一行代表一个观察值。也就是说,是在观察值上针对特征的测量值。

图 15-3。在这个设计矩阵中,每一行代表一个观察/记录,每一列代表一个特征/变量
注意
一个技术细节:设计矩阵被定义为数学矩阵,而不是数据框,因此您可能注意到矩阵不包括数据框具有的列或行标签。
也就是说,我们通常不必担心将数据框转换为矩阵,因为大多数用于建模的 Python 库将数字数据框视为矩阵。
对于给定的观察值,比如中的第二行,我们通过线性组合近似得到结果:
用矩阵表示线性近似更方便。为此,我们将模型参数写成一个列向量:
将这些符号定义放在一起,我们可以使用矩阵乘法为整个数据集编写预测向量:
如果我们检查和的维度,我们可以确认是一个维列向量。因此,使用这种线性预测的误差可以表示为向量:
其中结果变量也表示为列向量:
这种多重线性模型的矩阵表示可以帮助我们找到使均方误差最小化的模型。我们的目标是找到模型参数 ,使均方误差最小化:
在这里,我们使用记号 表示向量 的长度的平方和的简写形式: 。平方根 对应于向量 的长度,也称为向量 的 范数。因此,最小化均方误差等同于找到最短的误差向量。
我们可以像简单线性模型那样使用微积分来拟合我们的模型。然而,这种方法变得笨重,我们改用更直观的几何论证,这更容易导致设计矩阵、误差和预测值的有用属性。
我们的目标是找到参数向量,我们称之为,使我们的平均平方损失最小化——我们希望使 在给定的和 下尽可能小。关键洞察力在于,我们可以以几何方式重新表述这个目标。由于模型预测和真实结果都是向量,我们可以将它们视为向量空间中的向量。当我们改变模型参数时,模型会进行不同的预测,但任何预测必须是的列向量的线性组合;也就是说,预测必须在所谓的 中。这个概念在图 15-4 中有所体现,阴影区域代表可能的线性模型。请注意,并没有完全包含在中;这通常是情况。

图 15-4。在这个简化的图示中,所有可能的模型预测向量被描绘为三维空间中的一个平面,而观测到的作为一个向量。
尽管平方损失不能完全为零,因为不在中,我们可以找到一个尽可能接近但仍在中的向量。这个向量被称为。
误差是向量 。它的长度 表示真实结果与我们模型预测之间的距离。从视觉上看,当它与 垂直 时, 的大小最小,如 图 15-5 所示。关于此事实的证明被省略,我们依赖于图表来说服您。

图 15-5. 当预测值 在 垂直于 时,均方误差达到最小值。
最小误差 必须垂直于 ,这使我们能够推导出 的公式如下:
这种推导多元线性模型中的一般方法也给了我们简单线性模型中和。如果我们将设置为包含截距列和一个特征列的两列矩阵,这个公式用于最小二乘拟合的简单线性模型的截距和斜率。实际上,如果仅是列的单列,那么我们可以使用这个公式表明只是的均值。这与我们在第四章中介绍的常数模型很好地联系在一起。
注意
虽然我们可以编写一个简单的函数来根据公式推导
我们建议使用优化调整方法来计算,这些方法由scikit-learn和statsmodels库提供。它们处理设计矩阵稀疏、高度共线性和不可逆的情况。
这个的解(以及图像)揭示了拟合系数和预测的一些有用性质:
-
残差与预测值正交。
-
如果模型有截距项,则残差的平均值为 0。
-
残差的方差就是均方误差。
这些属性解释了为什么我们要检查残差与预测值的图表。当我们拟合多元线性模型时,我们还会将残差与我们考虑添加到模型的变量绘制在一起。如果它们显示出线性模式,那么我们会考虑将它们添加到模型中。
除了检查错误的标准差之外,多元线性模型的均方误差与常数模型的均方误差比值可以衡量模型的拟合度。这被称为多元,其定义如下:
随着模型越来越贴近数据,多个接近于 1。这可能看起来是件好事,但这种方法可能存在问题,因为即使在我们为模型添加无意义的特征时也会继续增长,只要这些特征扩展了。为了考虑模型的大小,我们通常通过模型中拟合系数的数量调整的分子和分母。也就是说,我们通过来标准化分子,并通过来标准化分母。在选择模型的更好方法方面,详见第十六章。
接下来,我们考虑一个社会科学的例子,在这个例子中,我们有许多可用于建模的变量。
示例:什么是机会之地?
美国被称为“机会之地”,因为人们相信即使资源匮乏的人也可以在美国变得富有,经济学家称这种观念为“经济流动性”。在一项研究中,经济学家拉杰·切蒂及其同事对美国的经济流动性进行了大规模数据分析。他的基本问题是美国是否是一个机会之地。为了回答这个相对模糊的问题,切蒂需要一种衡量经济流动性的方法。
Chetty 可以访问 1980 年至 1982 年间出生于美国的每个人的 2011-2012 年联邦所得税记录,以及他们父母在他们出生年份的税务记录。他通过找到列出他们为家庭成员的父母的 1980-1982 年税务记录来将 30 岁的人与他们的父母配对。总共,他的数据集约有 1000 万人。为了衡量经济流动性,Chetty 将出生在特定地理区域、父母收入位于 1980-1982 年的第 25 个收入百分位的人群分组。然后,他找到了该组 2011 年的平均收入百分位数。Chetty 将这个平均值称为绝对向上流动(AUM)。如果一个地区的 AUM 为 25,那么出生在第 25 百分位的人通常会保持在第 25 百分位,即他们留在了父母出生时的位置。高 AUM 值意味着该地区具有更多的向上流动性。在这些地区出生在第 25 个收入百分位的人通常会进入比他们父母更高的收入阶层。作为参考,美国的平均 AUM 在撰写本文时约为 41。Chetty 计算了称为通勤区(CZs)的地区的 AUM,这些地区大致与县级相同的规模。
虽然原始数据的粒度是在个体级别,Chetty 分析的数据粒度是在通勤区域级别。由于隐私法规限制,收入记录不能公开,但通勤区域的 AUM 可以提供。然而,即使有了通勤区域的粒度,也并非所有通勤区域都包含在数据集中,因为在 40 个特征的数据中,可能会识别出小型通勤区域的个体。这一限制指向潜在的覆盖偏倚。测量偏倚是另一个潜在问题。例如,出生在第 25 收入百分位的儿童,如果成为极其富有的人,可能不会申报所得税。
我们还指出使用区域平均数据而不是个体测量数据的局限性。在聚合水平上,特征之间的关系通常比在个体水平上更高度相关。这种现象称为生态回归,需要谨慎解释从聚合数据中得出的发现。
Chetty 怀疑美国某些地方的经济流动性较高。他的分析证实了这一点。他发现一些城市,如加利福尼亚州圣何塞、华盛顿特区和西雅图,比其他城市如北卡罗来纳州夏洛特、密尔沃基和亚特兰大有更高的流动性。这意味着,例如,在圣何塞,人们从低收入阶层向高收入阶层的转移速度比在夏洛特要快。Chetty 使用线性模型发现社会和经济因素如隔离、收入不平等和当地学校系统与经济流动性相关。
在这个分析中,我们的结果变量是通勤区域的 AUM,因为我们有兴趣找出与 AUM 相关的特征。Chetty 的数据中可能有许多这样的特征,但我们首先调查了一个特别的特征:通勤区域内通勤时间在 15 分钟或更短的人口比例。
使用通勤时间解释向上流动性
我们开始通过将数据加载到名为cz_df的数据框中进行调查:
| aum | travel_lt15 | gini | rel_tot | ... | taxrate | worked_14 | foreign | 地区 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 38.39 | 0.33 | 0.47 | 0.51 | ... | 0.02 | 3.75e-03 | 1.18e-02 | 南部 |
| 1 | 37.78 | 0.28 | 0.43 | 0.54 | ... | 0.02 | 4.78e-03 | 2.31e-02 | 南部 |
| 2 | 39.05 | 0.36 | 0.44 | 0.67 | ... | 0.01 | 2.89e-03 | 7.08e-03 | 南部 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 702 | 44.12 | 0.42 | 0.42 | 0.29 | ... | 0.02 | 4.82e-03 | 9.85e-02 | 西部 |
| 703 | 41.41 | 0.49 | 0.41 | 0.26 | ... | 0.01 | 4.39e-03 | 4.33e-02 | 西部 |
| 704 | 43.20 | 0.24 | 0.42 | 0.32 | ... | 0.02 | 3.67e-03 | 1.13e-01 | 西部 |
705 rows × 9 columns
每一行代表一个通勤区。列 aum 是 1980–1982 年出生并且父母收入处于第 25 百分位数的人群的平均 AUM。数据框中有许多列,但现在我们专注于通勤区内通勤时间不超过 15 分钟的人群比例,即 travel_lt15。我们将 AUM 与这个比例进行绘图,以探索这两个变量之间的关系:
`px``.``scatter``(``cz_df``,` `x``=``'``travel_lt15``'``,` `y``=``'``aum``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``travel_lt15``'``:``'``Commute time under 15 min``'``,`
`'``aum``'``:``'``Upward mobility``'``}``)`

散点图显示 AUM 与通勤时间之间存在大致的线性关系。事实上,我们发现它们之间的相关性非常强:
`cz_df``[``[``'``aum``'``,` `'``travel_lt15``'``]``]``.``corr``(``)`
| aum | travel_lt15 | |
|---|---|---|
| aum | 1.00 | 0.68 |
| travel_lt15 | 0.68 | 1.00 |
让我们用一个简单的线性模型来解释 AUM 与通勤时间的关系:
`from` `sklearn``.``linear_model` `import` `LinearRegression`
`y` `=` `cz_df``[``'``aum``'``]`
`X` `=` `cz_df``[``[``'``travel_lt15``'``]``]`
`model_ct` `=` `LinearRegression``(``)``.``fit``(``X``,` `y``)`
MSE 最小化得到的系数为:
Intercept: 31.3
Slope: 28.7
有趣的是,通勤区的向上流动增加与通勤时间较短的人群比例增加相关联。
我们可以将 AUM 测量的标准差与残差的标准差进行比较。这种比较让我们了解模型在解释 AUM 方面的实用性:
`prediction` `=` `model_ct``.``predict``(``X``)`
`error` `=` `y` `-` `prediction`
`print``(``f``"``SD(errors):` `{``np``.``std``(``error``)``:``.2f``}``"``)`
`print``(``f``"` `SD(AUM):` `{``np``.``std``(``cz_df``[``'``aum``'``]``)``:``.2f``}``"``)`
SD(errors): 4.14
SD(AUM): 5.61
围绕回归线的误差大小比常数模型减少了约 25%。
接下来,我们检查残差来判断拟合的不合适,因为在残差图中更容易看出拟合存在的潜在问题:
`fig` `=` `px``.``scatter``(``x``=``prediction``,` `y``=``error``,`
`labels``=``dict``(``x``=``'``Prediction for AUM``'``,` `y``=``'``Error``'``)``,`
`width``=``350``,` `height``=``250``)`
`fig``.``add_hline``(``0``,` `line_width``=``2``,` `line_dash``=``'``dash``'``,` `opacity``=``1``)`
`fig``.``update_yaxes``(``range``=``[``-``20``,` `15``]``)`
`fig``.``show``(``)`

看起来随着 AUM 的增加,误差也在增加。我们可以尝试对响应变量进行转换,或者拟合一个在通勤时间分数上是二次的模型。在下一节中我们将考虑变换和多项式。首先,我们看看包含额外变量是否能更准确地预测 AUM。
使用多个变量来关联向上流动
在他的原始分析中,Chetty 创建了几个与隔离、收入和 K–12 教育等因素相关的高级特征。我们考虑 Chetty 的七个预测因子,旨在构建一个更具信息性的模型来解释 AUM。这些在 Table 15-1 中描述。
Table 15-1. 解释 AUM 建模的潜在原因
| 列名 | 描述 |
|---|---|
travel_lt15 |
上班通勤时间不超过 15 分钟的人群比例。 |
gini |
基尼系数,财富不平等的度量。取值介于 0 到 1 之间,数值较小表示财富分配较均匀,较大表示不平等程度更大。 |
rel_tot |
自报宗教信仰的人群比例。 |
single_mom |
单身母亲的子女比例。 |
taxrate |
地方税率。 |
worked_14 |
14 到 16 岁工作的人群比例。 |
foreign |
出生于美国以外的人群比例。 |
让我们首先检查 AUM 与解释性特征以及解释性特征之间的相关性:
| aum | travel_lt15 | gini | rel_tot | single_mom | taxrate | worked_14 | foreign | |
|---|---|---|---|---|---|---|---|---|
| aum | 1.00 | 0.68 | -0.60 | 0.52 | -0.77 | 0.35 | 0.65 | -0.03 |
| travel_lt15 | 0.68 | 1.00 | -0.56 | 0.40 | -0.42 | 0.34 | 0.60 | -0.19 |
| gini | -0.60 | -0.56 | 1.00 | -0.29 | 0.57 | -0.15 | -0.58 | 0.31 |
| rel_tot | 0.52 | 0.40 | -0.29 | 1.00 | -0.31 | 0.08 | 0.28 | -0.11 |
| single_mom | -0.77 | -0.42 | 0.57 | -0.31 | 1.00 | -0.26 | -0.60 | -0.04 |
| taxrate | 0.35 | 0.34 | -0.15 | 0.08 | -0.26 | 1.00 | 0.35 | 0.26 |
| worked_14 | 0.65 | 0.60 | -0.58 | 0.28 | -0.60 | 0.35 | 1.00 | -0.15 |
| foreign | -0.03 | -0.19 | 0.31 | -0.11 | -0.04 | 0.26 | -0.15 | 1.00 |
我们看到,在通勤区中单身母亲的比例与 AUM 有最强的相关性,这意味着它也是解释 AUM 的最佳特征。此外,我们看到几个解释变量彼此之间高度相关;基尼系数与工作的青少年比例、单身母亲比例以及 15 分钟以下通勤比例高度相关。由于这些高度相关的特征,我们在解释系数时需要谨慎,因为几种不同的模型可能同样能够用协变量来解释 AUM。
注意
我们在本章前面介绍的向量几何视角可以帮助我们理解这个问题。回顾一下,一个特征对应于 维空间中的一个列向量,如 。对于两个高度相关的特征 和 ,这些向量几乎是对齐的。因此,响应向量 在这些向量中的一个上的投影几乎与在另一个上的投影相同。当几个特征彼此相关时,情况变得更加混乱。
首先,我们可以考虑所有可能的两特征模型,看看哪一个具有最小的预测误差。Chetty 导出了 40 个潜在的变量作为预测变量,这将使我们检查 个模型。拟合模型时,所有成对、三元组等变量很快就会失控。这可能导致找到伪相关性(见第十七章)。
在这里,我们保持事情稍微简单,只研究包含通勤时间和单身母亲特征的两变量模型。之后,我们查看包含数据框架中所有七个数值解释特征的模型:
`X2` `=` `cz_df``[``[``'``travel_lt15``'``,` `'``single_mom``'``]``]`
`y` `=` `cz_df``[``'``aum``'``]`
`model_ct_sm` `=` `LinearRegression``(``)``.``fit``(``X2``,` `y``)`
Intercept: 49.0
Fraction with under 15 minute commute coefficient: 18.10
Fraction of single moms coefficient: 18.10
请注意,旅行时间的系数与简单线性模型中这个变量的系数相比有很大不同。这是因为我们模型中的两个特征高度相关。
接下来我们比较这两个拟合的误差:
`prediction_ct_sm` `=` `model_ct_sm``.``predict``(``X2``)`
`error_ct_sm` `=` `y` `-` `prediction_ct_sm`
SD(errors in model 1): 4.14
SD(errors in model 2): 2.85
残差的标准偏差进一步减少了 30%。增加模型复杂性以添加第二个变量似乎是值得的。
让我们再次直观地检查残差。我们使用与前一个单变量模型相同的 y 轴刻度,以便与其残差图进行比较:
`fig` `=` `px``.``scatter``(``x``=``prediction_ct_sm``,` `y``=``error_ct_sm``,`
`labels``=``dict``(``x``=``'``Two-variable prediction for AUM``'``,` `y``=``'``Error``'``)``,`
`width``=``350``,` `height``=``250``)`
`fig``.``add_hline``(``0``,` `line_width``=``2``,` `line_dash``=``'``dash``'``,` `opacity``=``1``)`
`fig``.``update_yaxes``(``range``=``[``-``20``,` `15``]``)`
`fig``.``show``(``)`

对更高 AUM 的误差的较大变异性更为明显。这意味着估计值不受影响,但其准确性取决于 AUM。可以通过加权回归来解决这个问题。
注意
再次强调,不同背景的数据科学家使用不同的术语来指代相同的概念。例如,将设计矩阵中的每一行称为一个观察值,每一列称为一个变量的术语在统计背景的人群中更为常见。其他人则称设计矩阵的每一列代表一个特征,或者每一行代表一条记录。此外,我们称拟合和解释模型的整个过程为建模,而其他人则称其为机器学习。
现在让我们拟合一个多元线性模型,使用所有七个变量来解释上升的流动性。在拟合模型之后,我们再次使用与前两个残差图相同的 y 轴刻度绘制误差:
`X7` `=` `cz_df``[``predictors``]`
`model_7var` `=` `LinearRegression``(``)``.``fit``(``X7``,` `y``)`
`prediction_7var` `=` `model_7var``.``predict``(``X7``)`
`error_7var` `=` `y` `-` `prediction_7var`
fig = px.scatter(
x=prediction_7var, y=error_7var,
labels=dict(x='Seven-variable prediction for AUM', y='Error'),
width=350, height=250)
`fig``.``add_hline``(``0``,` `line_width``=``2``,` `line_dash``=``'``dash``'``,` `opacity``=``1``)`
`fig``.``update_yaxes``(``range``=``[``-``20``,` `15``]``)`
`fig``.``show``(``)`

具有七个特征的模型似乎并没有比具有两个变量的模型好多少。事实上,残差的标准偏差仅减少了 8%:
`error_7var``.``std``(``)`
2.588739233574256
我们可以比较这三个模型的多元:
R² for 7-variable model: 0.79
R² for 2-variable model: 0.74
R² for 1-variable model: 0.46
对于我们来说,模型中特征数量的调整并没有太大差异,因为我们有超过 700 个观测值。现在我们已经确认了之前的发现,即使用两个变量大大改善了模型的解释能力,而七个变量模型几乎没有比两个变量模型有所改善。这种小的增益可能不值得模型的复杂性增加。
到目前为止,我们的模型仅使用了数值预测变量。但是类别数据在模型拟合中通常也很有用。此外,在第十章中,我们对变量进行了转换,并从变量的组合中创建了新的变量。接下来我们将讨论如何将这些变量纳入线性模型。
数值测量的特征工程
本章迄今为止我们拟合的所有模型都使用了最初在数据框中提供的数值特征。在本节中,我们将查看由数值特征变换创建的变量。将变量转换为建模使用的形式称为特征工程。
我们在第九章和 10 章中引入了特征工程。在那里,我们对特征进行了转换,使它们具有对称分布。变换可以捕捉数据中更多种类的模式,并导致更好和更准确的模型。
让我们回到我们在第十章中作为示例使用的数据集:旧金山湾区的房屋销售价格。我们将数据限制在 2006 年售出的房屋,当时房价相对稳定,因此我们不需要考虑价格趋势。
我们希望建立销售价格模型。回顾在第十章中的可视化结果,我们发现销售价格与多个特征相关,如房屋尺寸、地块尺寸、卧室数量和位置。我们对销售价格和房屋尺寸进行了对数变换以改善它们之间的关系,并且发现了关于卧室数量和城市的箱线图也显示了有趣的关系。在本节中,我们将在线性模型中包括转换后的数值特征。在下一节中,我们还将向模型添加序数特征(卧室数量)和名义特征(城市)。
首先,我们将在房屋尺寸上建立销售价格模型。相关矩阵告诉我们哪些数值解释变量(原始和转换后的)与销售价格最相关:
| price | br | lsqft | bsqft | log_price | log_bsqft | log_lsqft | ppsf | log_ppsf | |
|---|---|---|---|---|---|---|---|---|---|
| price | 1.00 | 0.45 | 0.59 | 0.79 | 0.94 | 0.74 | 0.62 | 0.49 | 0.47 |
| br | 0.45 | 1.00 | 0.29 | 0.67 | 0.47 | 0.71 | 0.38 | -0.18 | -0.21 |
| lsqft | 0.59 | 0.29 | 1.00 | 0.46 | 0.55 | 0.44 | 0.85 | 0.29 | 0.27 |
| bsqft | 0.79 | 0.67 | 0.46 | 1.00 | 0.76 | 0.96 | 0.52 | -0.08 | -0.10 |
| log_price | 0.94 | 0.47 | 0.55 | 0.76 | 1.00 | 0.78 | 0.62 | 0.51 | 0.52 |
| log_bsqft | 0.74 | 0.71 | 0.44 | 0.96 | 0.78 | 1.00 | 0.52 | -0.11 | -0.14 |
| log_lsqft | 0.62 | 0.38 | 0.85 | 0.52 | 0.62 | 0.52 | 1.00 | 0.29 | 0.27 |
| ppsf | 0.49 | -0.18 | 0.29 | -0.08 | 0.51 | -0.11 | 0.29 | 1.00 | 0.96 |
| log_ppsf | 0.47 | -0.21 | 0.27 | -0.10 | 0.52 | -0.14 | 0.27 | 0.96 | 1.00 |
销售价格与房屋尺寸最相关,称为bsqft(建筑面积)。我们制作了销售价格与房屋尺寸的散点图,以确认这种关联是线性的:

关系看起来大致是线性的,但非常大和昂贵的房屋远离分布中心,可能会对模型产生过度影响。如第十章所示,对数变换使得价格和尺寸的分布更对称(两者均为以对数 10 为底以便于将值转换为原始单位):

理想情况下,使用变换的模型应当在数据的背景下有意义。如果我们基于对数(大小)拟合一个简单的线性模型,那么在检查系数时,我们可以考虑百分比增加。例如,翻倍会使预测增加,因为。
让我们从拟合一个通过房屋大小的对数变换解释的模型开始。但首先,我们注意到这个模型仍然被认为是一个线性模型。如果我们用表示销售价格,表示房屋大小,那么该模型是:
(请注意,在这个方程中,我们忽略了近似以使线性关系更加清晰。)这个方程可能看起来不是线性的,但如果我们将重命名为,重命名为,那么我们可以将这种“对数-对数”关系表达为和的线性模型:
可以表达为转换特征的线性组合的其他模型示例是:
再次,如果我们将重命名为,重命名为,重命名为,那么我们可以将每个模型表示为这些重命名特征的线性组合。按顺序,前述模型现在是:
简言之,我们可以将包含特征的非线性变换和/或特征组合的模型视为其派生特征的线性。在实践中,当我们描述模型时,我们不会重命名转换后的特征;相反,我们使用原始特征的变换,因为在解释系数和检查残差图时保持追踪它们非常重要。
当我们提及这些模型时,我们包括对变换的提及。也就是说,当结果和解释变量都经过对数变换时,我们称之为对数-对数模型;当结果经过对数变换而解释变量没有时,我们称之为对数-线性;当解释变量包括二次幂变换时,我们描述模型具有二次多项式特征;当两个解释特征的乘积包含在模型中时,我们称之为交互项。
让我们拟合一个价格对大小的对数-对数模型:
`X1_log` `=` `sfh``[``[``'``log_bsqft``'``]``]`
`y_log` `=` `sfh``[``'``log_price``'``]`
`model1_log_log` `=` `LinearRegression``(``)``.``fit``(``X1_log``,` `y_log``)`
此模型的系数和预测值不能直接与使用线性特征拟合的模型进行比较,因为其单位是美元和平方英尺的对数,而不是美元和平方英尺。
接下来,我们通过图表检查残差和预测值:
`prediction` `=` `model1_log_log``.``predict``(``X1_log``)`
`error` `=` `y_log` `-` `prediction`
`fig` `=` `px``.``scatter``(``x``=``prediction``,` `y``=``error``,`
`labels``=``dict``(``x``=``'``Predicted sale price (log USD)``'``,` `y``=``'``Error``'``)``,`
`width``=``350``,` `height``=``250``)`
`fig``.``add_hline``(``0``,` `line_width``=``2``,` `line_dash``=``'``dash``'``,` `opacity``=``1``)`
`fig``.``show``(``)`

残差图看起来合理,但其中包含数千个点,这使得难以看到曲线。
为了查看是否有助于增加额外的变量,我们可以绘制拟合模型的残差图针对不在模型中的变量。如果看到模式,那就表明我们可能想要包括这个额外特征或其转换。之前,我们发现价格分布与房屋所在城市相关,因此让我们检查残差与城市之间的关系:

这个图表显示了错误的分布似乎受到城市的影响。理想情况下,每个城市的箱线图中位数应该与 y 轴上的 0 对齐。然而,皮德蒙特出售的房屋超过 75%存在正误差,这意味着实际销售价格高于预测值。而在另一个极端,里士满超过 75%的销售价格低于预测值。这些模式表明我们应该在模型中包括城市变量。从背景来看,地理位置影响销售价格是有道理的。在接下来的部分,我们展示了如何将名义变量纳入线性模型。
分类测量的特征工程
我们第一次拟合的模型是第四章中的常数模型。在那里,我们最小化平方损失以找到最适合的常数:
我们可以考虑以类似的方式在模型中包含名义特征。也就是说,我们找到每个数据子组中最适合的常数,对应于一个类别:
另一种描述这种模型的方式是独热编码。
独热编码将分类特征转换为多个只有 0 或 1 值的数值特征。为了对一个特征进行独热编码,我们创建新的特征,每个唯一的类别对应一个新的特征。在本例中,由于有四个城市——伯克利、Lamorinda、皮德蒙特和里士满——我们在一个设计矩阵中创建了四个新特征,称为 。 中的每一行包含一个值为 1,它出现在与城市对应的列中。 图 15-6 说明了这一概念。

图 15-6. 对一个分类特征进行独热编码(左)及其生成的设计矩阵(右)
现在我们可以简洁地表示模型如下:
在这里,我们用 , , 和 对设计矩阵的列进行了索引,而不是 ,以明确表示每一列代表一个只有 0 和 1 的列,例如,如果第 个房屋位于皮德蒙特,则 为 1。
注意
独热编码创建的特征仅具有 0-1 值。这些特征也被称为虚拟变量或指示变量。在计量经济学中更常用“虚拟变量”这一术语,在统计学中更常用“指示变量”。
我们的目标是最小化关于 的最小二乘损失:
其中 是列向量 。注意,这个最小化转化为四个最小化,每个城市对应一个。这正是我们在本节开始时提到的思路。
我们可以使用 OneHotEncoder 创建这个设计矩阵:
`from` `sklearn``.``preprocessing` `import` `OneHotEncoder`
`enc` `=` `OneHotEncoder``(`
`# categories argument sets column order`
`categories``=``[``[``"``Berkeley``"``,` `"``Lamorinda``"``,` `"``Piedmont``"``,` `"``Richmond``"``]``]``,`
`sparse``=``False``,`
`)`
`X_city` `=` `enc``.``fit_transform``(``sfh``[``[``'``city``'``]``]``)`
`categories_city``=``[``"``Berkeley``"``,``"``Lamorinda``"``,` `"``Piedmont``"``,` `"``Richmond``"``]`
`X_city_df` `=` `pd``.``DataFrame``(``X_city``,` `columns``=``categories_city``)`
`X_city_df`
| 伯克利 | Lamorinda | 皮德蒙特 | 里士满 | |
|---|---|---|---|---|
| 0 | 1.0 | 0.0 | 0.0 | 0.0 |
| 1 | 1.0 | 0.0 | 0.0 | 0.0 |
| 2 | 1.0 | 0.0 | 0.0 | 0.0 |
| ... | ... | ... | ... | ... |
| 2664 | 0.0 | 0.0 | 0.0 | 1.0 |
| 2665 | 0.0 | 0.0 | 0.0 | 1.0 |
| 2666 | 0.0 | 0.0 | 0.0 | 1.0 |
2667 rows × 4 columns
让我们使用这些独热编码特征拟合一个模型:
`y_log` `=` `sfh``[``'``log_price``'``]`
`model_city` `=` `LinearRegression``(``fit_intercept``=``False``)``.``fit``(``X_city_df``,` `y_log``)`
并且检查多重 :
R-square for city model: 0.57
如果我们只知道房屋所在的城市,该模型能够相当不错地估计其销售价格。以下是拟合的系数:
`model_city``.``coef_`
array([5.87, 6.03, 6.1 , 5.67])
正如盒图所示,估计的销售价格(以对数$表示)取决于城市。但是,如果我们知道房屋大小和城市,我们应该会有一个更好的模型。我们之前看到,简单的对数模型可以合理解释销售价格与房屋大小的关系,因此我们期望城市特征(作为独热编码变量)应该进一步改进模型。
这样的模型如下所示:
注意,这个模型描述了对数价格(表示为)和对数大小(表示为)之间的关系,对于每个城市的对数大小都具有相同的系数。但截距项取决于城市:
接下来,我们制作一个散点图的多面板图,每个城市一个,看看这种关系大致成立:
`fig` `=` `px``.``scatter``(``sfh``,` `x``=``'``log_bsqft``'``,` `y``=``'``log_price``'``,`
`facet_col``=``'``city``'``,` `facet_col_wrap``=``2``,`
`labels``=``{``'``log_bsqft``'``:``'``Building size (log ft²)``'``,`
`'``log_price``'``:``'``Sale price (log USD)``'``}``,`
`width``=``500``,` `height``=``400``)`
`fig``.``update_layout``(``margin``=``dict``(``t``=``30``)``)`
`fig`

在散点图中可以看出这种偏移。我们将两个设计矩阵连接在一起,以拟合包含大小和城市的模型:
`X_size` `=` `sfh``[``'``log_bsqft``'``]`
`X_city_size` `=` `pd``.``concat``(``[``X_size``.``reset_index``(``drop``=``True``)``,` `X_city_df``]``,` `axis``=``1``)`
`X_city_size``.``drop``(``0``)`
| log_bsqft | 伯克利 | Lamorinda | 皮德蒙特 | 里士满 | |
|---|---|---|---|---|---|
| 1 | 3.14 | 1.0 | 0.0 | 0.0 | 0.0 |
| 2 | 3.31 | 1.0 | 0.0 | 0.0 | 0.0 |
| 3 | 2.96 | 1.0 | 0.0 | 0.0 | 0.0 |
| ... | ... | ... | ... | ... | ... |
| 2664 | 3.16 | 0.0 | 0.0 | 0.0 | 1.0 |
| 2665 | 3.47 | 0.0 | 0.0 | 0.0 | 1.0 |
| 2666 | 3.44 | 0.0 | 0.0 | 0.0 | 1.0 |
2666 rows × 5 columns
现在让我们拟合一个包含定量特征(房屋大小)和定性特征(城市)的模型:
`model_city_size` `=` `LinearRegression``(``fit_intercept``=``False``)``.``fit``(``X_city_size``,` `y_log``)`
这些截距反映了哪些城市的房屋更贵,即使考虑到房屋的大小:
`model_city_size``.``coef_`
array([0.62, 3.89, 3.98, 4.03, 3.75])
R-square for city and log(size): 0.79
这个拟合模型包括名义变量city和对数变换后的房屋大小,比仅包含房屋大小的简单对数模型以及为每个城市拟合常数的模型都要好。
注意,我们从模型中删除了截距项,以便每个子组都有自己的截距。然而,一个常见的做法是从设计矩阵中删除一个独热编码特征,并保留截距。例如,如果我们删除伯克利房屋的特征并添加截距,那么模型就是:
在这种表示中,虚拟变量的系数意义在这个表述中已经改变了。例如,考虑伯克利和皮德蒙特的房屋的方程式:
在这个表示中,截距 是伯克利房屋的,而系数 衡量皮德蒙特房屋与伯克利房屋之间的典型价格差异。在这种表述中,我们可以更容易地将 与 0 比较,看看这两个城市的平均价格是否基本相同。
如果我们包括截距和所有城市变量,则设计矩阵的列是线性相关的,这意味着我们无法解出系数。我们的预测在任何情况下都将相同,但不会有唯一的最小化解。
当我们包含两个分类变量的独热编码时,我们还喜欢采用删除一个虚拟变量并包含截距项的模型表示。这种做法保持了系数解释的一致性。
我们演示如何使用statsmodels库构建一个具有两组虚拟变量的模型。该库使用公式语言描述要拟合的模型,因此我们无需自己创建设计矩阵。我们导入公式 API:
`import` `statsmodels``.``formula``.``api` `as` `smf`
首先,让我们重复使用名义变量city和房屋大小拟合模型,以展示如何使用公式语言并比较结果:
`model_size_city` `=` `smf``.``ols``(``formula``=``'``log_price ~ log_bsqft + city``'``,`
`data``=``sfh``)``.``fit``(``)`
提供给formula参数的字符串描述了要拟合的模型。该模型以log_price作为结果,以log_bsqft和city的线性组合作为解释变量进行拟合。请注意,我们无需创建虚拟变量来拟合模型。方便地,smf.ols为我们执行了城市特征的独热编码。以下模型的拟合系数包括截距项,并且省略了伯克利指示变量:
`print``(``model_size_city``.``params``)`
Intercept 3.89
city[T.Lamorinda] 0.09
city[T.Piedmont] 0.14
city[T.Richmond] -0.15
log_bsqft 0.62
dtype: float64
如果我们想要去除截距,我们可以在公式中添加 -1,这是一个指示从设计矩阵中去除 1 列的约定。在这个特定的例子中,所有独热编码特征所张成的空间等同于 1 向量和除了一个虚拟变量之外的所有虚拟变量所张成的空间,因此拟合是相同的。但是,系数是不同的,因为它们反映了设计矩阵的不同参数化:
`smf``.``ols``(``formula``=``'``log_price ~ log_bsqft + city - 1``'``,` `data``=``sfh``)``.``fit``(``)``.``params`
city[Berkeley] 3.89
city[Lamorinda] 3.98
city[Piedmont] 4.03
city[Richmond] 3.75
log_bsqft 0.62
dtype: float64
此外,我们可以在城市和大小变量之间添加交互项,以允许每个城市对大小具有不同的系数。我们在公式中指定此项,通过添加术语log_bsqft:city。我们在这里不详细说明。
现在让我们拟合一个具有两个分类变量的模型:卧室数量和城市。回想一下,我们之前重新分配了卧室数量大于 6 的卧室数量为 6,这实际上将 6、7、8、… 折叠到类别 6+ 中。我们可以在价格(对数 $)按卧室数量的箱线图中看到这种关系:
`px``.``box``(``sfh``,` `x``=``"``br``"``,` `y``=``"``log_price``"``,` `width``=``450``,` `height``=``250``,`
`labels``=``{``'``br``'``:``'``Number of bedrooms``'``,``'``log_price``'``:``'``Sale price (log USD)``'``}``)`

关系看起来不是线性的:每增加一个卧室,销售价格并不会以相同的金额增加。鉴于卧室数量是离散的,我们可以将此特征视为分类的,这样每个卧室编码都可以为成本贡献不同的金额:
`model_size_city_br` `=` `smf``.``ols``(``formula``=``'``log_price ~ log_bsqft + city + C(br)``'``,`
`data``=``sfh``)``.``fit``(``)`
我们在公式中使用了术语C(br)来指示我们希望将卧室数量(数值型)视为分类变量对待。
让我们检查拟合的多重:
`model_size_city_br``.``rsquared``.``round``(``2``)`
0.79
尽管我们添加了五个更多的独热编码特征,但多元 并没有增加。多元 根据模型参数数量进行调整,从这个度量来看,并不比之前仅包含城市和大小的模型更好。
在本节中,我们介绍了定性特征的特征工程。我们看到了一种独热编码技术,它让我们在线性模型中包含分类数据,并为模型参数提供了自然的解释。
总结
线性模型帮助我们描述特征之间的关系。我们讨论了简单线性模型,并将其扩展到多变量线性模型。在此过程中,我们应用了在建模中广泛有用的数学技术—用微积分来最小化简单线性模型的损失,用矩阵几何来处理多变量线性模型。
线性模型可能看起来很基础,但今天它们被用于各种任务。它们足够灵活,可以允许我们包括分类特征以及变量的非线性转换,如对数转换、多项式和比率。线性模型具有广泛的可解释性,非技术人员也能理解,同时足够复杂以捕捉数据中许多常见模式的特点。
诱人的做法是将所有可用的变量都放入模型中以获得“可能的最佳拟合”。但我们应该记住最小二乘法的几何特性来拟合模型。要记住, 个解释变量可以被看作是 维空间中的 个向量,如果这些向量高度相关,那么在这个空间上的投影将类似于在由较少向量组成的较小空间上的投影。这意味着:
-
添加更多变量可能不会显著改善模型。
-
解释系数可能会很困难。
-
几种模型可以同样有效地预测/解释响应变量。
如果我们关心进行推断,希望解释/理解模型,那么我们应该倾向于使用简单的模型。另一方面,如果我们的主要关注是模型的预测能力,那么我们往往不会关心系数的数量及其解释。但是这种“黑箱”方法可能导致模型过度依赖数据中的异常值或在其他方面不足。因此,在预测可能对人们有害时,要小心使用这种方法。
在本章中,我们以描述性方式使用了线性模型。我们介绍了一些决定何时在模型中包含特征的概念,通过检查残差的模式、比较标准误差的大小和多重 的变化来做出决策。通常情况下,我们选择了一个更简单、更容易解释的模型。在下一章中,我们将探讨其他更正式的工具,用于选择模型中要包含的特征。
第十六章:模型选择
到目前为止,当我们拟合模型时,我们已经采用了几种策略来决定包含哪些特征:
-
使用残差图评估模型拟合度。
-
将统计模型与物理模型连接起来。
-
保持模型简单。
-
比较在日益复杂的模型之间残差标准差和 MSE 的改进。
例如,在我们检查单变量模型中的上升流动性时,我们发现残差图中有曲率。增加第二个变量大大改善了平均损失(MSE 以及相关的多个),但残差中仍然存在一些曲率。一个七变量模型在降低 MSE 方面几乎没有比两变量模型更多的改进,因此尽管两变量模型仍然显示出残差中的一些模式,我们选择了这个更简单的模型。
另一个例子是,在我们建立一只驴的体重模型时,在第十八章中,我们将从物理模型中获取指导。我们将忽略驴的附肢,并借鉴桶和驴身体的相似性开始拟合一个模型,通过其长度和围度(类似于桶的高度和周长)来解释体重。然后,我们将继续通过添加与驴的物理状况和年龄相关的分类特征来调整该模型,合并类别,并排除其他可能的特征,以保持模型简单。
我们在构建这些模型时所做的决策基于判断,而在本章中,我们将这些决策与更正式的标准结合起来。首先,我们提供一个示例,说明为什么在模型中包含太多特征通常不是一个好主意。这种现象称为过度拟合,经常导致模型过于贴近数据并捕捉到数据中的一些噪音。然后,当新的观察到来时,预测结果比较简单模型更糟糕。本章的其余部分提供了一些技术,如训练集-测试集分割、交叉验证和正则化,来限制过度拟合的影响。当模型可能包含大量潜在特征时,这些技术尤为有用。我们还提供一个合成示例,我们在其中了解到真实模型,以解释模型方差和偏差的概念及其与过拟合和欠拟合的关系。
过度拟合
当我们有许多可用于模型的特征时,选择包括或排除哪些特征会迅速变得复杂。在上升移动性的例子中,在第十五章中,我们选择了七个变量中的两个来拟合模型,但是对于一个双变量模型,我们可以检查并拟合 21 对特征。如果考虑所有一、二、...、七个变量模型,还有超过一百种模型可供选择。检查数百个残差图以决定何时简单到足够,以及确定一个模型是相当困难的。不幸的是,最小化均方误差的概念并非完全有用。当我们向模型添加一个变量时,均方误差通常会变小。回顾模型拟合的几何视角(第十五章),添加一个特征到模型中会添加一个 -维向量到特征空间中,并且结果向量与其在由解释变量张成的空间内的投影之间的误差会减小。我们可能认为这是一件好事,因为我们的模型更接近数据,但过度拟合存在危险。
当模型过于紧密地跟随数据并捕捉到结果中的随机噪声变化时,就会发生过度拟合。当这种情况发生时,新的观察结果就无法很好地预测。举个例子可以帮助澄清这个概念。
示例:能源消耗
在这个例子中,我们研究一个可以下载的数据集,其中包含明尼苏达州一个私人住宅的公用事业账单信息。我们记录了一个家庭每月的气体使用量(立方英尺)和该月的平均温度(华氏度)。^(1) 我们首先读取数据:
`heat_df` `=` `pd``.``read_csv``(``"``data/utilities.csv``"``,` `usecols``=``[``"``temp``"``,` `"``ccf``"``]``)`
`heat_df`
| 温度 | 立方英尺 | |
|---|---|---|
| 0 | 29 | 166 |
| 1 | 31 | 179 |
| 2 | 15 | 224 |
| ... | ... | ... |
| 96 | 76 | 11 |
| 97 | 55 | 32 |
| 98 | 39 | 91 |
99 rows × 2 columns
我们将从查看气体消耗作为温度函数的散点图开始:

这个关系显示了曲率(左图),但当我们尝试通过对数变换将其变成直线(右图)时,在低温区域出现了不同的曲率。此外,还有两个异常点。当我们查阅文档时,发现这些点代表记录错误,因此我们将它们移除。
看看二次曲线能否捕捉气体使用量和温度之间的关系。多项式仍然被认为是线性模型。它们在其多项式特征中是线性的。例如,我们可以将二次模型表示为:
这个模型对特征 和 是线性的,在矩阵表示中,我们可以将这个模型写成 ,其中 是设计矩阵:
我们可以使用scikit-learn中的PolynomialFeatures工具创建设计矩阵的多项式特征:
`y` `=` `heat_df``[``'``ccf``'``]`
`X` `=` `heat_df``[``[``'``temp``'``]``]`
`from` `sklearn``.``preprocessing` `import` `PolynomialFeatures`
`poly` `=` `PolynomialFeatures``(``degree``=``2``,` `include_bias``=``False``)`
`poly_features` `=` `poly``.``fit_transform``(``X``)`
`poly_features`
array([[ 29., 841.],
[ 31., 961.],
[ 15., 225.],
...,
[ 76., 5776.],
[ 55., 3025.],
[ 39., 1521.]])
我们将参数include_bias设置为False,因为我们计划在scikit-learn中使用LinearRegression方法拟合多项式,默认情况下会在模型中包括常数项。我们用以下方法拟合多项式:
`from` `sklearn``.``linear_model` `import` `LinearRegression`
`model_deg2` `=` `LinearRegression``(``)``.``fit``(``poly_features``,` `y``)`
为了快速了解拟合的质量,让我们在散点图上叠加拟合的二次曲线,并查看残差:

二次多项式很好地捕捉了数据中的曲线,但残差显示出在 70°F 到 80°F 温度范围内略微上升的趋势,这表明有些拟合不足。此外,残差中也有些漏斗形状,在较冷的月份中,燃气消耗的变化性更大。我们可能会预期这种行为,因为我们只有月均温度。
为了比较,我们使用更高阶的多项式拟合了几个模型,并集体检查拟合曲线:
`poly12` `=` `PolynomialFeatures``(``degree``=``12``,` `include_bias``=``False``)`
`poly_features12` `=` `poly12``.``fit_transform``(``X``)`
`degrees` `=` `[``1``,` `2``,` `3``,` `6``,` `8``,` `12``]`
`mods` `=` `[``LinearRegression``(``)``.``fit``(``poly_features12``[``:``,` `:``deg``]``,` `y``)`
`for` `deg` `in` `degrees``]`
警告
我们在本节中使用多项式特征来演示过拟合,但直接拟合等多项式在实践中是不可取的。不幸的是,这些多项式特征往往高度相关。例如,能源数据中和之间的相关性为 0.98。高度相关的特征会导致不稳定的系数,即 x 值的微小变化可能会导致多项式系数的大幅变化。此外,当 x 值较大时,正规方程的条件很差,系数的解释和比较可能会很困难。
更好的做法是使用构造成彼此正交的多项式。这些多项式填充与原始多项式相同的空间,但它们彼此不相关,并提供更稳定的拟合。
让我们将所有的多项式拟合放在同一张图上,这样我们可以看到高阶多项式的弯曲越来越奇怪:

我们还可以在单独的面板中可视化不同的多项式拟合:

左上方面的一次曲线(直线)未能捕捉数据中的曲线模式。二次曲线开始捕捉,而三次曲线看起来有所改进,但请注意图表右侧的上升弯曲。六次、八次和十二次的多项式越来越贴近数据,因为它们变得越来越曲折。这些多项式似乎适应数据中的虚假波动。总体来看,这六条曲线说明了欠拟合和过拟合。左上角的拟合线欠拟合,完全错过了曲线。而右下角的十二次多项式明显过拟合,呈现了我们认为在这种情况下无意义的蜿蜒模式。
一般来说,随着特征的增加,模型变得更加复杂,均方误差(MSE)下降,但与此同时,拟合的模型变得越来越不稳定和对数据敏感。当我们过度拟合时,模型过于紧密地跟随数据,对新观测的预测效果很差。评估拟合模型的一种简单技术是在新数据上计算 MSE,这些数据未用于建模。由于通常情况下我们无法获取更多数据,因此我们会保留一些原始数据来评估拟合的模型。这个技术是下一节的主题。
训练-测试分离
虽然我们希望在建立模型时使用所有数据,但我们也想了解模型在新数据上的表现。通常情况下,我们无法收集额外的数据来评估模型,因此我们会将部分数据保留下来,称为测试集,以代表新数据。其余的数据称为训练集,我们使用这部分数据来建立模型。然后,在选择了模型之后,我们提取测试集,并查看模型(在训练集上拟合)在测试集中预测结果的表现。图 16-1 演示了这个概念。

图 16-1. 训练-测试分离将数据分为两部分:训练集用于建立模型,测试集评估该模型
通常,测试集包含数据的 10%到 25%。从图表中可能不清楚的是,这种分割通常是随机进行的,因此训练集和测试集彼此相似。
我们可以使用第 15 章 中介绍的概念来描述这个过程。设计矩阵, ,和结果, ,各自被分成两部分;标记为 的设计矩阵和相应的结果,标记为 ,形成训练集。我们通过这些数据最小化 的平均平方损失:
最小化训练误差的系数 用于预测测试集的结果,其中标记为 和 :
由于和没有用于构建模型,它们可以合理估计我们可能对新观测到的损失。
我们使用上一节中的气耗多项式模型来演示训练-测试分离。为此,我们执行以下步骤:
-
将数据随机分为两部分,训练集和测试集。
-
对训练集拟合几个多项式模型并选择一个。
-
计算在所选多项式(其系数由训练集拟合)上的测试集的 MSE。
对于第一步,我们使用scikit-learn中的train_test_split方法将数据随机分为两部分,并为模型评估设置了 22 个观测值:
`from` `sklearn``.``model_selection` `import` `train_test_split`
`test_size` `=` `22`
`X_train``,` `X_test``,` `y_train``,` `y_test` `=` `train_test_split``(`
`X``,` `y``,` `test_size``=``test_size``,` `random_state``=``42``)`
`print``(``f``'``Training set size:` `{``len``(``X_train``)``}``'``)`
`print``(``f``'``Test set size:` `{``len``(``X_test``)``}``'``)`
Training set size: 75
Test set size: 22
与前一节类似,我们将气温与燃气消耗的模型拟合到各种多项式中。但这次,我们只使用训练数据:
`poly` `=` `PolynomialFeatures``(``degree``=``12``,` `include_bias``=``False``)`
`poly_train` `=` `poly``.``fit_transform``(``X_train``)`
`degree` `=` `np``.``arange``(``1``,``13``)`
`mods` `=` `[``LinearRegression``(``)``.``fit``(``poly_train``[``:``,` `:``j``]``,` `y_train``)`
`for` `j` `in` `degree``]`
我们找出了每个模型的 MSE:
`from` `sklearn``.``metrics` `import` `mean_squared_error`
`error_train` `=` `[`
`mean_squared_error``(``y_train``,` `mods``[``j``]``.``predict``(``poly_train``[``:``,` `:` `(``j` `+` `1``)``]``)``)`
`for` `j` `in` `range``(``12``)`
`]`
为了可视化 MSE 的变化,我们将每个拟合的多项式的 MSE 绘制成其次数的图:
`px``.``line``(``x``=``degree``,` `y``=``error_train``,` `markers``=``True``,`
`labels``=``dict``(``x``=``'``Degree of polynomial``'``,` `y``=``'``Train set MSE``'``)``,`
`width``=``350``,` `height``=``250``)`

注意到随着模型复杂度的增加,训练误差逐渐减小。我们之前看到高阶多项式显示出了我们认为不反映数据中潜在结构的起伏行为。考虑到这一点,我们可能会选择一个更简单但 MSE 显著减小的模型。这可能是 3、4 或 5 次方。让我们选择 3 次方,因为这三个模型在 MSE 方面的差异非常小,而且它是最简单的。
现在我们已经选择了我们的模型,我们使用测试集提供了对其 MSE 的独立评估。我们为测试集准备设计矩阵,并使用在训练集上拟合的 3 次多项式来预测测试集中每一行的结果。最后,我们计算了测试集的 MSE:
`poly_test` `=` `poly``.``fit_transform``(``X_test``)`
`y_hat` `=` `mods``[``2``]``.``predict``(``poly_test``[``:``,` `:``3``]``)`
`mean_squared_error``(``y_test``,` `y_hat``)`
307.44460133992294
该模型的均方误差(MSE)比在训练数据上计算的 MSE 要大得多。这说明了在使用相同数据来拟合和评估模型时所存在的问题:MSE 并不充分反映出对新观测的 MSE。为了进一步说明过拟合问题,我们计算了这些模型的测试误差:
`error_test` `=` `[`
`mean_squared_error``(``y_test``,` `mods``[``j``]``.``predict``(``poly_test``[``:``,` `:` `(``j` `+` `1``)``]``)``)`
`for` `j` `in` `range``(``12``)`
`]`
在实践中,我们不会在承诺模型之前查看测试集。在训练集上拟合模型并在测试集上评估它之间交替可以导致过拟合。但出于演示目的,我们绘制了我们拟合的所有多项式模型在测试集上的 MSE:

注意,对于所有模型而言,测试集的均方误差(MSE)大于训练集的均方误差,而不仅仅是我们选择的模型。更重要的是,注意当模型从欠拟合到更好地拟合数据曲线时,测试集的均方误差最初是下降的。然后,随着模型复杂度的增加,测试集的均方误差增加。这些更复杂的模型对训练数据过拟合,导致预测测试集时出现较大的误差。这种现象的一个理想化示意图如图 16-2 所示。

图 16-2. 随着模型复杂度的增加,训练集的误差减少,而测试集的误差增加
测试数据提供新观察的预测误差评估。仅在我们已经选择了模型之后才使用测试集是至关重要的。否则,我们会陷入使用相同数据选择和评估模型的陷阱中。在选择模型时,我们回归到了简单性的论点,因为我们意识到越来越复杂的模型往往会过拟合。然而,我们也可以扩展训练-测试方法来帮助选择模型。这是下一节的主题。
交叉验证
我们可以使用训练-测试范式来帮助选择模型。其思想是进一步将训练集分成单独的部分,在其中一个部分上拟合模型,然后在另一个部分上评估模型。这种方法称为交叉验证。我们描述的是一种版本,称为 -折叠交叉验证。图 16-3 展示了这种数据划分背后的思想。

图 16-3. 一个例子展示了五折交叉验证,其中训练集被分为五部分,轮流用于验证在其余数据上构建的模型
交叉验证可以帮助选择模型的一般形式。这包括多项式的阶数,模型中的特征数量,或者正则化惩罚的截止(在下一节中介绍)。 -折叠交叉验证的基本步骤如下:
-
将训练集分成个大致相同大小的部分;每部分称为一个折叠。使用与创建训练集和测试集相同的技术来创建这些折叠。通常情况下,我们随机划分数据。
-
将一个折叠保留作为测试集:
-
在剩余的训练数据上拟合所有模型(训练数据减去特定折叠的数据)。
-
使用您保留的折叠来评估所有这些模型。
-
-
重复此过程共次,每次将一个折叠保留出来,使用剩余的训练集来拟合模型,并在保留的折叠上评估模型。
-
合并每个模型在折叠中的拟合误差,并选择具有最小误差的模型。
这些拟合模型在不同的折叠中不会具有相同的系数。例如,当我们拟合一个三次多项式时,我们对个折叠中的 MSE 取平均值,得到三次拟合多项式的平均 MSE。然后我们比较这些 MSE,并选择具有最低 MSE 的多项式次数。在三次多项式中,,和项的实际系数在每个个拟合中是不同的。一旦选择了多项式次数,我们使用所有训练数据重新拟合模型,并在测试集上评估它。(在选择模型的任何早期步骤中,我们没有使用测试集。)
通常,我们使用 5 或 10 个折叠。另一种流行的选择是将一个观察结果放入每个折叠中。这种特殊情况称为留一法交叉验证。其流行之处在于调整最小二乘拟合以减少一个观察结果的简单性。
通常,折交叉验证需要一些计算时间,因为我们通常必须为每个折叠从头开始重新拟合每个模型。scikit-learn库提供了一个方便的sklearn.model_selection.KFold类来实现折交叉验证。
为了让你了解 k 折交叉验证的工作原理,我们将在燃气消耗示例上演示这种技术。但是,这次我们将拟合不同类型的模型。在数据的原始散点图中,看起来点落在两条连接的线段上。在寒冷的温度下,燃气消耗与温度之间的关系看起来大致是负斜率,约为 立方英尺/度,而在温暖的月份,关系则似乎几乎是平坦的。因此,我们可以拟合一条弯曲的线条而不是拟合多项式。
让我们从 65 度处拟合一条有弯曲的线。为此,我们创建一个特征,使得温度高于 65°F 的点具有不同的斜率。该模型是:
在这里,代表“正部分”,所以当小于 65 时,它评估为 0,当大于或等于 65 时,它就是。我们创建这个新特征并将其添加到设计矩阵中:
`y` `=` `heat_df``[``"``ccf``"``]`
`X` `=` `heat_df``[``[``"``temp``"``]``]`
`X``[``"``temp65p``"``]` `=` `(``X``[``"``temp``"``]` `-` `65``)` `*` `(``X``[``"``temp``"``]` `>``=` `65``)`
然后,我们使用这两个特征拟合模型:
`bend_index` `=` `LinearRegression``(``)``.``fit``(``X``,` `y``)`
让我们将这条拟合的“曲线”叠加在散点图上,看看它如何捕捉数据的形状:

这个模型似乎比多项式更好地拟合了数据。但是可能有许多种弯线模型。线条可能在 55 度或 60 度处弯曲等。我们可以使用 折交叉验证来选择线条弯曲的温度值。让我们考虑在 度处弯曲的模型。对于这些模型的每一个,我们需要创建额外的特征来使线条在那里弯曲:
`bends` `=` `np``.``arange``(``40``,` `70``,` `1``)`
`for` `i` `in` `bends``:`
`col` `=` `"``temp``"` `+` `i``.``astype``(``"``str``"``)` `+` `"``p``"`
`heat_df``[``col``]` `=` `(``heat_df``[``"``temp``"``]` `-` `i``)` `*` `(``heat_df``[``"``temp``"``]` `>``=` `i``)`
`heat_df`
| temp | ccf | temp40p | temp41p | ... | temp66p | temp67p | temp68p | temp69p | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 29 | 166 | 0 | 0 | ... | 0 | 0 | 0 | 0 |
| 1 | 31 | 179 | 0 | 0 | ... | 0 | 0 | 0 | 0 |
| 2 | 15 | 224 | 0 | 0 | ... | 0 | 0 | 0 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 96 | 76 | 11 | 36 | 35 | ... | 10 | 9 | 8 | 7 |
| 97 | 55 | 32 | 15 | 14 | ... | 0 | 0 | 0 | 0 |
| 98 | 39 | 91 | 0 | 0 | ... | 0 | 0 | 0 | 0 |
97 rows × 32 columns
交叉验证的第一步是创建我们的训练集和测试集。与之前一样,我们随机选择 22 个观测值放入测试集。这样剩下 75 个观测值作为训练集:
`y` `=` `heat_df``[``'``ccf``'``]`
`X` `=` `heat_df``.``drop``(``columns``=``[``'``ccf``'``]``)`
`test_size` `=` `22`
`X_train``,` `X_test``,` `y_train``,` `y_test` `=` `train_test_split``(`
`X``,` `y``,` `test_size``=``test_size``,` `random_state``=``0``)`
现在我们可以将训练集分成折叠。我们使用三个折叠,以便每个折叠中有 25 个观测值。对于每个折叠,我们拟合 30 个模型,每个模型对应线条中的一个弯曲点。对于这一步骤,我们使用 scikit-learn 中的 KFold 方法来划分数据:
`from` `sklearn``.``model_selection` `import` `KFold`
`kf` `=` `KFold``(``n_splits``=``3``,` `shuffle``=``True``,` `random_state``=``42``)`
`validation_errors` `=` `np``.``zeros``(``(``3``,` `30``)``)`
`def` `validate_bend_model``(``X``,` `y``,` `X_valid``,` `y_valid``,` `bend_index``)``:`
`model` `=` `LinearRegression``(``)``.``fit``(``X``.``iloc``[``:``,` `[``0``,` `bend_index``]``]``,` `y``)`
`predictions` `=` `model``.``predict``(``X_valid``.``iloc``[``:``,` `[``0``,` `bend_index``]``]``)`
`return` `mean_squared_error``(``y_valid``,` `predictions``)`
`for` `fold``,` `(``train_idx``,` `valid_idx``)` `in` `enumerate``(``kf``.``split``(``X_train``)``)``:`
`cv_X_train``,` `cv_X_valid` `=` `(``X_train``.``iloc``[``train_idx``,` `:``]``,`
`X_train``.``iloc``[``valid_idx``,` `:``]``)`
`cv_Y_train``,` `cv_Y_valid` `=` `(``y_train``.``iloc``[``train_idx``]``,`
`y_train``.``iloc``[``valid_idx``]``)`
`error_bend` `=` `[`
`validate_bend_model``(`
`cv_X_train``,` `cv_Y_train``,` `cv_X_valid``,` `cv_Y_valid``,` `bend_index`
`)`
`for` `bend_index` `in` `range``(``1``,` `31``)`
`]`
`validation_errors``[``fold``]``[``:``]` `=` `error_bend`
然后我们找到三个折叠中的平均验证误差,并将它们绘制在弯曲位置的图表上:
`totals` `=` `validation_errors``.``mean``(``axis``=``0``)`

MSE 在 57 到 60 度之间看起来相当平缓。最小值出现在 58 度,因此我们选择那个模型。为了评估这个模型在测试集上的表现,我们首先在整个训练集上以 58 度拟合弯线模型:
`bent_final` `=` `LinearRegression``(``)``.``fit``(`
`X_train``.``loc``[``:``,` `[``"``temp``"``,` `"``temp58p``"``]``]``,` `y_train`
`)`
然后我们使用拟合的模型来预测测试集的气体消耗:
`y_pred_test` `=` `bent_final``.``predict``(``X_test``.``loc``[``:``,` `[``"``temp``"``,` `"``temp58p``"``]``]``)`
`mean_squared_error``(``y_test``,` `y_pred_test``)`
71.40781435952441
让我们将弯线拟合叠加到散点图上,并检查残差,以了解拟合质量:

拟合曲线看起来合理,并且残差比多项式拟合的要小得多。
注意
在本节的教学目的上,我们使用 KFold 手动将训练数据分为三个折叠,然后使用循环找到模型验证误差。在实践中,我们建议使用 sklearn.model_selection.GridSearchCV 与 sklearn.pipeline.Pipeline 对象,它可以自动将数据分成训练集和验证集,并找到在折叠中平均验证误差最低的模型。
使用交叉验证来管理模型复杂度有几个关键的限制:通常需要使复杂度离散变化,并且可能没有自然的方式来对模型进行排序。与其改变一系列模型的维度,我们可以拟合一个大模型并对系数的大小施加约束。这个概念被称为正则化,将在下一节讨论。
正则化
我们刚刚看到交叉验证如何帮助找到适合的模型维度,从而平衡欠拟合和过拟合。与其选择模型的维度,我们可以构建一个包含所有特征的模型,但限制系数的大小。通过在均方误差上添加一个系数大小的惩罚项来防止过拟合。这个惩罚项称为正则化项,表达式为 。我们通过最小化均方误差和这个惩罚项的组合来拟合模型:
当正则化参数很大时,会惩罚大的系数。(通常通过交叉验证来选择。)
对系数的平方进行惩罚称为正则化,或称为岭回归。另一种流行的正则化方法惩罚系数的绝对大小:
这个正则化的线性模型也被称为套索回归(lasso 代表最小绝对值收缩和选择运算符)。
为了了解正则化的工作原理,让我们考虑极端情况:当非常大或接近 0 时(从不为负)。当正则化参数很大时,系数会受到严重的惩罚,因此它们会收缩。另一方面,当很小时,系数不受限制。实际上,当为 0 时,我们回到了普通最小二乘法的世界。当我们考虑通过正则化控制系数大小时,会遇到几个问题:
-
我们不希望对截距项进行正则化。这样一来,一个大的惩罚就会拟合一个常数模型。
-
当特征具有非常不同的尺度时,惩罚可能会对它们产生不同的影响,具有较大值的特征会比其他特征受到更多的惩罚。为了避免这种情况,我们在拟合模型之前将所有特征标准化,使它们的均值为 0,方差为 1。
让我们看一个包含 35 个特征的例子。
模型的偏差和方差
在本节中,我们提供了一种不同的思考过拟合和欠拟合问题的方式。我们进行了模拟研究,从我们设计的模型中生成了合成数据。这样,我们知道真实模型,并可以看到在拟合数据时我们离真实情况有多近。
我们可以构建一个数据的通用模型如下:
这个表达式使得模型的两个组成部分很容易看出来:信号 和噪声 。在我们的模型中,我们假设噪声没有趋势或模式,方差恒定,并且每个观测值的噪声是独立的。
例如,让我们取 ,噪声来自均值为 0,标准差为 0.2 的正态曲线。我们可以从这个模型生成数据,使用以下函数:
`def` `g``(``x``)``:`
`return` `np``.``sin``(``x``)` `+` `0.3` `*` `x`
`def` `gen_noise``(``n``)``:`
`return` `np``.``random``.``normal``(``scale``=``0.2``,` `size``=``n``)`
`def` `draw``(``n``)``:`
`points` `=` `np``.``random``.``choice``(``np``.``arange``(``0``,` `10``,` `0.05``)``,` `size``=``n``)`
`return` `points``,` `g``(``points``)` `+` `gen_noise``(``n``)`
让我们生成 50 个数据点 , ,从这个模型中:
`np``.``random``.``seed``(``42``)`
`xs``,` `ys` `=` `draw``(``50``)`
`noise` `=` `ys` `-` `g``(``xs``)`
我们可以绘制我们的数据,因为我们知道真实信号,我们可以找到错误并将它们绘制出来:

左边的图显示 作为虚线曲线。我们还可以看到 对形成了这条曲线的散点分布。右边的图显示了 50 个点的误差, 。请注意,它们没有形成模式。
当我们对数据进行模型拟合时,我们最小化均方误差。让我们用一般性写出这个最小化:
最小化是对函数集合 进行的。我们在本章中已经看到,这个函数集合可能是 12 阶多项式,或者简单的弯曲线。一个重要的点是真实模型 不必是集合中的一个函数。
让我们把 定为二次多项式的集合;换句话说,可以表示为 的函数。由于 ,它不属于我们正在优化的函数集合。
让我们对我们的 50 个数据点进行多项式拟合:
`poly` `=` `PolynomialFeatures``(``degree``=``2``,` `include_bias``=``False``)`
`poly_features` `=` `poly``.``fit_transform``(``xs``.``reshape``(``-``1``,` `1``)``)`
`model_deg2` `=` `LinearRegression``(``)``.``fit``(``poly_features``,` `ys``)`
Fitted Model: 0.98 + -0.19x + 0.05x²
再次,我们知道真实模型不是二次的(因为我们建立了它)。让我们绘制数据和拟合曲线:

二次函数不太适合数据,并且也不能很好地表示底层曲线,因为我们选择的模型集(二阶多项式)无法捕捉中的曲率。
如果我们重复这个过程,并从真实模型中生成另外 50 个点,并将二次多项式拟合到这些数据上,那么二次多项式的拟合系数会因为依赖于新数据集而改变。我们可以多次重复这个过程,并对拟合曲线取平均。这个平均曲线将类似于从我们真实模型中取 50 个数据点拟合的典型二次多项式最佳拟合曲线。为了演示这个概念,让我们生成 25 组 50 个数据点,并对每个数据集拟合一个二次多项式:
`def` `fit``(``n``)``:`
`xs_new` `=` `np``.``random``.``choice``(``np``.``arange``(``0``,` `10``,` `0.05``)``,` `size``=``n``)`
`ys_new` `=` `g``(``xs_new``)` `+` `gen_noise``(``n``)`
`X_new` `=` `xs_new``.``reshape``(``-``1``,` `1``)`
`mod_new` `=` `LinearRegression``(``)``.``fit``(``poly``.``fit_transform``(``X_new``)``,` `ys_new``)`
`return` `mod_new``.``predict``(``poly_features_x_full``)``.``flatten``(``)`
`fits` `=` `[``fit``(``50``)` `for` `j` `in` `range``(``25``)``]`
我们可以在图中显示所有 25 个拟合模型以及真实函数和拟合曲线的平均值 。为此,我们使用透明度来区分重叠的曲线:

我们可以看到 25 个拟合的二次多项式与数据有所不同。这个概念被称为模型变异。25 个二次多项式的平均值由实线表示。平均二次多项式与真实曲线之间的差异称为模型偏差。
当信号不属于模型空间时,我们有模型偏差。如果模型空间能很好地逼近,那么偏差就很小。例如,一个 10 次多项式可以很接近我们示例中使用的。另一方面,正如我们在本章前面看到的,高阶多项式可能会过度拟合数据并且在尝试接近数据时变化很大。模型空间越复杂,拟合模型的变化就越大。使用过于简单的模型会导致高模型偏差(和之间的差异),而使用过于复杂的模型可能会导致高模型方差(在周围的波动)。这个概念被称为偏差-方差权衡。模型选择旨在平衡这些竞争性的拟合不足来源。
总结
在本章中,我们看到当我们最小化均方误差来拟合模型并评估时会出现问题。训练集-测试集分割帮助我们避开这个问题,其中我们用训练集拟合模型,并在设置好的测试数据上评估我们的拟合模型。
“过度使用”测试集非常重要,因此我们保持其与模型分离直到我们决定了一个模型。为了帮助我们做出决定,我们可能使用交叉验证,模拟将数据分为测试和训练集。同样重要的是,只使用训练集进行交叉验证,并将原始测试集远离任何模型选择过程。
正则化采取了不同的方法,通过惩罚均方误差来防止模型过度拟合数据。在正则化中,我们利用所有可用数据来拟合模型,但是缩小系数的大小。
偏差-方差折衷使我们能够更准确地描述本章中看到的建模现象:拟合不足与模型偏差有关;过度拟合导致模型方差增大。在图 16-4 中,x 轴表示模型复杂度,y 轴表示模型不适配的这两个组成部分:模型偏差和模型方差。请注意,随着拟合模型的复杂性增加,模型偏差减少,模型方差增加。从测试误差的角度来看,我们看到这种误差首先减少,然后由于模型方差超过模型偏差的减少而增加。为了选择一个有用的模型,我们必须在模型偏差和模型方差之间取得平衡。

图 16-4. 偏差-方差折衷
如果模型能够完全拟合人口过程,收集更多观察数据会减少偏差。如果模型本质上无法建模人口(如我们的合成示例),即使是无限数据也无法消除模型偏差。就方差而言,收集更多数据也会减少方差。数据科学的一个最新趋势是选择具有低偏差和高内在方差(例如神经网络)的模型,但收集许多数据点以使模型方差足够低以进行准确预测。虽然在实践中有效,但为这些模型收集足够的数据通常需要大量的时间和金钱。
创建更多的特征,无论其是否有用,通常会增加模型的方差。具有许多参数的模型具有许多可能的参数组合,因此比具有少量参数的模型具有更高的方差。另一方面,在模型中添加一个有用的特征(例如,当基础过程是二次的时添加二次特征),可以减少偏差。但即使添加一个无用的特征,很少会增加偏差。
熟悉偏差-方差折衷可以帮助您更好地拟合模型。并且使用诸如训练-测试分割、交叉验证和正则化等技术可以改善这个问题。
建模的另一个部分考虑了拟合系数和曲线的变化。我们可能希望为系数提供置信区间或未来观测的预测带。这些区间和带子给出了拟合模型准确性的感觉。接下来我们将讨论这个概念。
^(1) 这些数据来自于丹尼尔·T·卡普兰(Daniel T. Kaplan)(CreateSpace Independent Publishing Platform, 2009)。
第十七章:推断和预测的理论
当您希望将您的研究结果推广到更大的背景中时,数据需要代表该更大的世界。例如,您可能希望基于传感器读数预测未来时间的空气质量(见第十二章),基于实验结果测试激励是否提高了贡献者的生产力(见第三章),或者构建一个时间间隔估计,用于估计等待公交车的时间(见第五章)。我们在前几章已经涉及了所有这些情境。在本章中,我们将正式化用于预测和推断的框架。
在这个框架的核心是分布的概念,无论是总体、经验(也称为样本)还是概率分布。理解这些分布之间的联系对于假设检验、置信区间、预测带和风险的基础至关重要。我们首先简要回顾了在第三章介绍的瓮模型,然后介绍了假设检验、置信区间和预测带的正式定义。我们在示例中使用模拟,包括引导样本作为特例。我们通过对期望、方差和标准误差的正式定义来结束本章——这些是测试、推断和预测理论中的基本概念。
分布:总体、经验、抽样
总体、抽样和经验分布是我们在对模型进行推断或对新观察进行预测时的重要概念。图 17-1 提供了一个图示,可以帮助区分它们。该图示使用了来自第二章的总体和访问框架以及来自第三章的瓮模型的概念。左侧是我们研究的总体,以一个瓮中的弹珠表示,每个单位一个。我们简化了情况,使得访问框架和总体相同;也就是说,我们可以访问总体中的每个单位。 (当这种情况不成立时所出现的问题在第二章和第三章中有所涉及。)从瓮到样本的箭头表示设计,意味着从框架中选择样本的协议。图示将此选择过程显示为一个机会机制,以从装有难以区分的弹珠的瓮中抽取来表示。图示的右侧,弹珠的集合构成了我们的样本(我们得到的数据)。

图 17-1. 数据生成过程的图示
我们通过考虑仅一个特征的测量来简化图表。在图表中的瓮下方是该特征的总体直方图。总体直方图表示整个总体中数值的分布。在图的最右边,经验直方图展示了我们实际样本的数值分布。请注意,这两个分布在形状上相似。当我们的抽样机制产生代表性样本时,就会出现这种情况。
我们通常对样本测量的摘要感兴趣,比如均值、中位数、简单线性模型的斜率等等。通常,这种摘要统计量是总体参数的估计,比如总体均值或中位数。总体参数在图的左侧显示为;而在右侧,从样本计算出的摘要统计量显示为 。
我们的样本生成机制很可能在我们再次进行调查时生成不同的数据集。但是如果协议设计良好,我们预期样本仍将类似于总体。换句话说,我们可以从样本计算出的摘要统计量推断总体参数。图中间的抽样分布是统计量的概率分布。它展示了不同样本可能取得的统计量数值及其概率。在第三章中,我们使用模拟来估计了几个示例中的抽样分布。在本章中,我们重访了这些以及前几章的其他示例,以形式化分析。
最后,关于这三个直方图的一个重要点:正如在第十章中介绍的,矩形提供了任何箱中观察结果的比例。在总体直方图的情况下,这是整个总体的比例;对于经验直方图,该区域表示样本中的比例;而对于抽样分布,则表示数据生成机制在该箱中产生样本统计量的机会。
最后,我们通常不了解总体分布或参数,并试图推断参数或预测未见单位的值。其他时候,可以使用样本测试关于总体的推测。测试将是下一节的主题。
假设检验基础
根据我们的经验,假设检验是数据科学中较具挑战性的领域之一——学习起来困难,应用起来也具挑战性。这不一定是因为假设检验深奥技术性强;相反,假设检验可能令人感到反直觉,因为它利用了矛盾。顾名思义,我们通常从一个假设开始进行假设检验:关于我们想要验证的世界的一个陈述。
在理想的情况下,我们将直接证明我们的假设是正确的。不幸的是,我们通常无法获得确定真相所需的所有信息。例如,我们可能假设一种新的疫苗有效,但当代医学尚未完全理解支配疫苗效力的生物学细节。因此,我们转向概率、随机抽样和数据设计的工具。
假设检验之所以令人困惑的一个原因是,它很像“反证法”,我们假设与我们的假设相反是真的,并尝试证明我们观察到的数据与该假设不一致。我们采用这种方式来解决问题,因为通常,某事可能由许多原因导致,但我们只需要一个例子来反驳一个假设。我们称这个“相反假设”为 空假设,我们的原始假设为 备择假设。
使事情变得有点混乱的是,概率的工具并不直接证明或证伪事物。相反,它们告诉我们,在假设的情况下,我们观察到的某事有多么可能或不可能。这就是为什么设计数据收集如此重要的原因。
回顾 J&J 疫苗的随机临床试验(第三章),在这个试验中,有 43,738 人参加了试验,并被随机分成两组。治疗组接受了疫苗,对照组接受了一种假疫苗,称为安慰剂。这种随机分配创建了两个在除了疫苗之外的每个方面都相似的群体。
在这个试验中,治疗组中有 117 人生病,对照组中有 351 人生病。由于我们希望提供令人信服的证据证明疫苗有效,我们从一个空假设开始,即疫苗没有作用,这意味着随机分配导致治疗组中生病的人数如此之少纯属偶然。然后,我们可以使用概率来计算观察到如此少的治疗组生病的机会。概率计算基于一个有 43,738 个彩球的瓮,其中 468 个标记为 1 表示一个生病的人。然后我们发现,21,869 次有放回地从瓮中抽取最多 117 个彩球的概率几乎为零。我们把这个作为拒绝空假设的证据,支持备择假设即疫苗有效。因为 J&J 的实验设计良好,对空假设的拒绝使我们得出结论,即疫苗有效。换句话说,假设的真相取决于我们以及我们有多愿意可能是错误的。
在本节的其余部分,我们将介绍假设检验的四个基本步骤。然后我们提供两个示例,继续介绍来自 第三章 的两个示例,并深入探讨测试的形式。
假设检验有四个基本步骤:
第一步:建立
你已经有了你的数据,并且你想要测试一个特定的模型是否与数据相一致。 所以你指定一个统计量,,比如样本平均值、样本中零的比例或拟合的回归系数,目的是将你的数据的统计量与模型下可能产生的值进行比较。
第二步:模型
你要将要测试的模型以数据生成机制的形式呈现出来,以及关于总体的任何特定假设。 这个模型通常包括指定,它可能是总体均值、零的比例或回归系数。 在这个模型下统计量的抽样分布称为零分布,而模型本身称为零假设。
第三步:计算
根据第二步中的零模型,你得到的数据(以及产生的统计量)至少与你在第一步中实际得到的数据有多相似? 在形式推断中,这个概率被称为 -值。 为了近似 -值,我们经常使用计算机使用模型中的假设生成大量重复的随机试验,并找到给定统计量的值至少与我们观察到的值一样极端的样本的比例。 其他时候,我们可以使用数学理论来找到 -值。
第四步:解释
-值用作惊奇的度量。 如果你在第二步中规定的模型是可信的,那么你得到的数据(和总结统计量)应该有多么惊讶? 一个中等大小的 -值意味着观察到的统计量基本上是你预期的在零模型生成的数据中得到的。 一个微小的 -值会对零模型提出质疑。 换句话说,如果模型是正确的(或者近似正确的),那么从模型生成的数据中得到这样一个极端值是非常不寻常的。 在这种情况下,要么零模型是错误的,要么发生了一个非常不可能的结果。 统计逻辑表明要得出结论,模式是真实的,不仅仅是巧合。 然后就轮到你解释为什么数据生成过程会导致这样一个不寻常的值。 这是什么时候仔细考虑范围的重要性。
让我们用几个例子来演示测试过程中的这些步骤。
例子:用于比较维基百科贡献者生产力的等级测试
回想一下来自第二章的维基百科示例,在英语维基百科上过去 30 天活跃的前 1%的贡献者中,从未接受过奖励。这 200 名贡献者被随机分成两组各 100 人。一个组的贡献者,即实验组,每人获得一份非正式奖励,而另一个组则没有。所有 200 名贡献者被跟踪记录了 90 天,记录他们在维基百科上的活动。
有人推测非正式奖励对志愿者工作有一种强化作用,这个实验旨在正式研究这一推测。我们基于数据排名进行假设检验。
首先,我们将数据读入数据框架:
`wiki` `=` `pd``.``read_csv``(``"``data/Wikipedia.csv``"``)`
`wiki``.``shape`
(200, 2)
`wiki``.``describe``(``)``[``3``:``]`
| experiment | postproductivity | |
|---|---|---|
| min | 0.0 | 0.0 |
| 25% | 0.0 | 57.5 |
| 50% | 0.5 | 250.5 |
| 75% | 1.0 | 608.0 |
| max | 1.0 | 2344.0 |
数据框架有 200 行,每行代表一个贡献者。特征experiment要么是 0 要么是 1,取决于贡献者是在对照组还是实验组,而postproductivity是在授予奖励后 90 天内贡献者编辑次数的计数。四分位数(下、中、上)之间的差距表明生产力的分布是倾斜的。我们制作直方图进行确认:
px.histogram(
wiki, x='postproductivity', nbins=50,
labels={'postproductivity': 'Number of actions in 90 days post award'},
width=350, height=250)

实际上,授奖后的生产力直方图高度倾斜,在接近零的地方有一个峰值。这种偏斜性表明基于两个样本值排序的统计量。
要计算我们的统计量,我们将所有生产力值(来自两个组)从最小到最大排序。最小值的排名为 1,第二小的排名为 2,依此类推,直到最大值,其排名为 200。我们使用这些排名来计算我们的统计量, ,它是实验组的平均排名。我们选择这个统计量是因为它对高度倾斜的分布不敏感。例如,无论最大值是 700 还是 700,000,它仍然获得相同的排名,即 200。如果非正式奖励激励贡献者,那么我们期望实验组的平均排名通常高于对照组。
零模型假设非正式奖励对生产力没有任何影响,观察到的实验组和对照组之间的任何差异都是由于将贡献者分配到组的偶然过程。零假设被设置为拒绝现状;也就是说,我们希望找到一个假设没有效果的惊喜。
零假设可以用从一个装有 200 个标有 1、2、3、...、200 的彩球的罐子中抽取 100 次的结果来表示。在这种情况下,平均等级将是 。
我们使用 scipy.stats 中的 rankdata 方法对这 200 个值进行排名,并计算治疗组中等级的总和:
`from` `scipy``.``stats` `import` `rankdata`
`ranks` `=` `rankdata``(``wiki``[``'``postproductivity``'``]``,` `'``average``'``)`
让我们确认 200 个值的平均等级是 100.5:
`np``.``average``(``ranks``)`
100.5
并找出治疗组中 100 个生产力分数的平均等级:
`observed` `=` `np``.``average``(``ranks``[``100``:``]``)`
`observed`
113.68
治疗组的平均等级高于预期,但我们想要弄清楚这是否是一个异常高的值。我们可以使用模拟来找出这个统计量的抽样分布,以确定 113 是一个常规值还是一个令人惊讶的值。
为了进行这个模拟,我们将数据中的 ranks 数组设为罐子中的彩球。对数组中的 200 个值进行洗牌,并取前 100 个值代表一个随机抽取的治疗组。我们编写一个函数来洗牌排名数组并找出前 100 个的平均值。
`rng` `=` `np``.``random``.``default_rng``(``42``)`
`def` `rank_avg``(``ranks``,` `n``)``:`
`rng``.``shuffle``(``ranks``)`
`return` `np``.``average``(``ranks``[``n``:``]``)`
我们的模拟混合了罐子中的彩球,抽取 100 次,计算 100 次抽取的平均等级,然后重复 100,000 次。
`rank_avg_simulation` `=` `[``rank_avg``(``ranks``,` `100``)` `for` `_` `in` `range``(``100_000``)``]`
这里是模拟平均值的直方图:

正如我们预期的那样,平均等级的抽样分布以 100(实际上是 100.5)为中心,并呈钟形曲线。这个分布的中心反映了治疗没有影响的假设。我们观察到的统计量远远超出了模拟平均等级的典型范围,我们使用这个模拟的抽样分布来找到观察到的统计量的近似 -值:
`np``.``mean``(``rank_avg_simulation` `>` `observed``)`
0.00058
这真是一个大惊喜。根据零假设,我们看到一个平均等级至少与我们的一样大的机会约为 10,000 分之 5。
这个测试对零模型提出了质疑。统计逻辑使我们得出结论,这种模式是真实存在的。我们如何解释这个结果?这个实验设计得很好。从顶尖 1% 中随机选择了 200 名贡献者,然后随机将他们分为两组。这些随机过程表明,我们可以依赖于这 200 个样本代表了顶尖贡献者,并且治疗组和对照组在除了治疗(奖励)的应用之外的其他方面上都是相似的。考虑到精心的设计,我们得出结论,非正式的奖励对顶尖贡献者的生产力有积极的影响。
早些时候,我们实施了一个模拟来找到我们观察到的统计量的 -值。在实践中,排名测试通常被广泛使用,并在大多数统计软件中提供:
`from` `scipy``.``stats` `import` `ranksums`
`ranksums``(``x``=``wiki``.``loc``[``wiki``.``experiment` `==` `1``,` `'``postproductivity``'``]``,`
`y``=``wiki``.``loc``[``wiki``.``experiment` `==` `0``,` `'``postproductivity``'``]``)`
RanksumsResult(statistic=3.220386553232206, pvalue=0.0012801785007519996)
这里的值是我们计算的值的两倍,因为我们只考虑大于观察值的值,而ranksums测试计算了分布两侧的值。在我们的示例中,我们只对提高生产力感兴趣,因此使用单侧值,这是报告值(0.0006)的一半,接近我们模拟的值。
这种使用排名而不是实际数据值的不太常见的检验统计量是在 1950 年代和 1960 年代开发的,在当今强大笔记本电脑时代之前。排名统计量的数学属性已经很好地发展,并且抽样分布表现良好(即使对于小数据集也是对称的,形状像钟形曲线)。排名测试在 A/B 测试中仍然很受欢迎,其中样本倾向于高度偏斜,并且通常会进行许多许多测试,可以从正态分布中快速计算出值。
下一个示例重新讨论了来自第三章关于疫苗有效性的例子。在那里,我们遇到了一个假设检验,尽管并未明确称其为假设检验。
示例:疫苗有效性的比例检验
疫苗的批准需满足比我们之前执行的简单测试更严格的要求,其中我们比较了治疗组和对照组中的疾病计数。CDC 要求更强的成功证据,基于每组中患病者比例的比较。为了解释,我们将控制组和治疗组中患病人数的样本比例表示为 和 ,并使用这些比例计算疫苗有效性:
在 J&J 试验中观察到的疫苗有效性数值为:
如果治疗不起作用,有效性将接近于 0。CDC 设定了疫苗有效性的标准为 50%,意味着有效性必须超过 50%才能获得批准用于分发。在这种情况下,零模型假设疫苗有效性为 50%(),观察值与预期值的任何差异都归因于随机过程中将人们分配到不同组中。再次强调,我们设定零假设为当前状态,即疫苗不足以获得批准,我们希望发现一个意外并拒绝零假设。
简单代数运算后,零模型 可以简化为 。也就是说,零假设暗示接受治疗的人群中患病的比例最多是对照组的一半。请注意,零假设并不假设治疗无效,而是假设其有效性不超过 0.5。
在这种情况下,我们的瓮模型与我们在第三章中设定的有所不同。这个瓮仍然有 43,738 颗弹珠,对应于实验中的受试者。但现在每颗弹珠上有两个数字,为了简单起见,这些数字以一对的形式出现,比如 。左边的数字是如果人接受治疗后的反应,右边的数字对应于未接受治疗时的反应(对照组)。通常情况下,1 表示他们生病了,0 表示他们保持健康。
零模型假设一对中左边数字的比例是右边数字的一半。由于我们不知道这两个比例,我们可以使用数据来估计它们。瓮中有三种类型的弹珠: , 和 。我们假设 ,即治疗后患病但对照组未患病的情况是不可能发生的。我们观察到在对照组中有 351 人生病,在治疗组中有 117 人生病。在假设治疗组的患病率是对照组的一半的情况下,我们可以尝试瓮中弹珠的构成情况。例如,我们可以研究这样一种情况,即 117 人在治疗组没有生病,但如果他们在对照组,则所有 585 人( )都会感染病毒,其中一半不会感染病毒。表格 17-1 展示了这些计数。
表格 17-1. 疫苗试验瓮模型
| 标签 | 计数 |
|---|---|
| (0, 0) | 43,152 |
| (0, 1) | 293 |
| (1, 0) | 0 |
| (1, 1) | 293 |
| 总数 | 43,738 |
我们可以利用这些计数来进行临床试验的模拟,并计算疫苗有效性。如第三章所示,多变量超几何函数模拟从一个装有不止两种颜色的弹珠的罐中抽取弹珠。我们建立这个罐和抽样过程:
`N` `=` `43738`
`n_samp` `=` `21869`
`N_groups` `=` `np``.``array``(``[``293``,` `293``,` `(``N` `-` `586``)``]``)`
`from` `scipy``.``stats` `import` `multivariate_hypergeom`
`def` `vacc_eff``(``N_groups``,` `n_samp``)``:`
`treat` `=` `multivariate_hypergeom``.``rvs``(``N_groups``,` `n_samp``)`
`ill_t` `=` `treat``[``1``]`
`ill_c` `=` `N_groups``[``0``]` `-` `treat``[``0``]` `+` `N_groups``[``1``]` `-` `treat``[``1``]`
`return` `(``ill_c` `-` `ill_t``)` `/` `ill_c`
现在我们可以对临床试验进行 10 万次模拟,并计算每次试验的疫苗有效性:
`np``.``random``.``seed``(``42``)`
`sim_vacc_eff` `=` `np``.``array``(``[``vacc_eff``(``N_groups``,` `n_samp``)` `for` `_` `in` `range``(``100_000``)``]``)`
`px``.``histogram``(``x``=``sim_vacc_eff``,` `nbins``=``50``,`
`labels``=``dict``(``x``=``'``Simulated vaccine efficacy``'``)``,`
`width``=``350``,` `height``=``250``)`

抽样分布的中心在 0.5 处,这与我们的模型假设一致。我们看到 0.667 远离了这个分布的尾部:
`np``.``mean``(``sim_vacc_eff` `>` `0.667``)`
1e-05
只有极少数的 10 万次模拟中,疫苗有效性达到了观察到的 0.667。这是一个罕见事件,这就是为什么 CDC 批准了强生公司的疫苗进行分发。
在这个假设检验的例子中,我们无法完全指定模型,并且必须根据我们观察到的和的近似值来提供近似值和 。有时,零模型并没有完全被指定,我们必须依靠数据来建立模型。下一节介绍了一种通用的方法,称为自举法(bootstrap),用于利用数据近似模型。
推断的自举
在许多假设检验中,零假设的假设导致对一个假设总体和数据设计进行完全规范化(参见图 17-1),我们利用这个规范化来模拟统计量的抽样分布。例如,维基百科实验的秩检验导致我们抽样整数 1, …, 200,这很容易模拟。不幸的是,我们并不能总是完全指定总体和模型。为了弥补这种情况,我们用数据代替总体。这种替代是自举概念的核心。图 17-2 更新了图 17-1 以反映这个想法;这里总体分布被经验分布替代,以创建所谓的自举总体。

第 17-2 图。引导数据生成过程的示意图
自举的理由如下:
-
你的样本看起来像是总体,因为它是一个代表性样本,所以我们用样本代替总体,并称之为自举总体。
-
使用产生原始样本的相同数据生成过程来获得一个新样本,这被称为自助样本,以反映人群的变化。以与之前相同的方式计算自助样本上的统计量,并称之为自助统计量。自助统计量的自助抽样分布在形状和传播上应与统计量的真实抽样分布类似。
-
模拟数据生成过程多次,使用自助人群,以获得自助样本及其自助统计量。模拟自助统计量的分布近似于自助统计量的自助抽样分布,后者本身近似于原始抽样分布。
仔细观察图 17-2 并将其与图 17-1 进行比较。基本上,自助模拟涉及两个近似值:原始样本近似于人群,而模拟则近似于抽样分布。到目前为止,我们在例子中一直在使用第二种近似值;样本通过样本来近似人群的近似是自助法的核心概念。注意,在图 17-2 中,自助人群的分布(左侧)看起来像原始样本的直方图;抽样分布(中间)仍然是基于与原始研究中相同的数据生成过程的概率分布,但现在使用的是自助人群;样本分布(右侧)是从自助人群中抽取的一个样本的直方图。
您可能想知道如何从自助人群中简单随机抽取样本,而不是每次都得到完全相同的样本。毕竟,如果您的样本中有 100 个单位,并且将其用作您的自助人群,那么从自助人群中抽取的 100 个单位(不重复)将获取所有单位,并且每次都给出相同的自助样本。解决此问题有两种方法:
-
当从自助人群中抽样时,使用带替换的方式从自助人群中抽取单位。基本上,如果原始人群非常大,则使用替换和不使用替换之间几乎没有区别。这是迄今为止更常见的方法。
-
将样本“扩展”到与原始人群相同的大小。即,计算样本中每个唯一值的比例,并向自助人群中添加单位,使其与原始人群大小相同,同时保持这些比例。例如,如果样本大小为 30,并且样本值的 1/3 是 0,则包含 750 个单位的自助人群应包括 250 个零。一旦有了这个自助人群,就使用原始数据生成过程来进行自助抽样。
疫苗有效性的例子使用了类似 Bootstrap 的过程,称为参数化 Bootstrap。我们的空模型指定了 0-1 瓮,但我们不知道要在瓮中放多少个 0 和 1。我们使用样本来确定 0 和 1 的比例;也就是说,样本指定了多元超几何分布的参数。接下来,我们使用校准空气质量监测器的例子来展示如何使用 Bootstrap 方法来测试假设。
警告
常见的错误是认为 Bootstrap 采样分布的中心与真实采样分布的中心相同。如果样本的均值不为 0,则 Bootstrap 总体的均值也不为 0。这就是为什么在假设检验中我们使用 Bootstrap 分布的传播范围而不是它的中心。下一个例子展示了我们如何使用 Bootstrap 方法来测试假设。
在校准空气质量监测仪器的案例研究中(见第十二章),我们拟合了一个模型来调整廉价监测器的测量值,使其更准确地反映真实的空气质量。这种调整包括与湿度相关的模型项。拟合系数约为,这意味着在高湿度的日子里,测量值的调整幅度比低湿度的日子大。然而,这个系数接近于 0,我们可能会怀疑在模型中是否真的需要包含湿度。换句话说,我们想要检验线性模型中湿度系数是否为 0。不幸的是,我们无法完全指定模型,因为它是基于一组空气监测器(包括 PurpleAir 和 EPA 维护的监测器)在特定时间段内采集的测量数据。这就是 Bootstrap 方法可以帮助的地方。
我们的模型假设所采集的空气质量测量值类似于整体测量值的总体。请注意,天气条件、时间和监测器的位置使得这种说法有些模糊;我们的意思是在原始测量数据采集时,相同条件下的其他测量值类似于这些测量值。此外,由于我们可以想象有一个几乎无限的空气质量测量数据供应,我们认为生成测量数据的过程类似于从瓮中重复有放回地抽取。回顾一下,在第二章中,我们将瓮建模为从测量误差瓮中重复有放回地抽取的过程。这种情况有些不同,因为我们还包括了已经提到的其他因素(天气、季节、位置)。
我们的模型专注于线性模型中湿度系数:
在这里,指的是 PurpleAir PM2.5 测量,是相对湿度,表示更精确的 PM2.5 测量,由更准确的 AQS 监测器进行。零假设是;也就是说,零模型是简单模型:
为了估计,我们使用从第十五章的线性模型拟合过程中获取的结果。
我们的 bootstrap 总体由我们在第十五章中使用的来自乔治亚州的测量值组成。现在我们使用randint的机会机制从数据框中(相当于我们的乌尔恩)有放回地抽样行。这个函数从一组整数中有放回地随机抽取样本。我们使用随机索引样本创建数据框的 bootstrap 样本。然后我们拟合线性模型,并得到湿度系数(我们的 bootstrap 统计量)。以下的boot_stat函数执行这个模拟过程:
`from` `scipy``.``stats` `import` `randint`
`def` `boot_stat``(``X``,` `y``)``:`
`n` `=` `len``(``X``)`
`bootstrap_indexes` `=` `randint``.``rvs``(``low``=``0``,` `high``=``(``n` `-` `1``)``,` `size``=``n``)`
`theta2` `=` `(`
`LinearRegression``(``)`
`.``fit``(``X``.``iloc``[``bootstrap_indexes``,` `:``]``,` `y``.``iloc``[``bootstrap_indexes``]``)`
`.``coef_``[``1``]`
`)`
`return` `theta2`
我们设置设计矩阵和结果变量,并检查我们的boot_stat函数一次以测试它:
`X` `=` `GA``[``[``'``pm25aqs``'``,` `'``rh``'``]``]`
`y` `=` `GA``[``'``pm25pa``'``]`
`boot_stat``(``X``,` `y``)`
0.21572251745549495
当我们重复这个过程 10,000 次时,我们得到了对 bootstrap 统计量(拟合湿度系数)的 bootstrap 抽样分布的近似:
`np``.``random``.``seed``(``42``)`
`boot_theta_hat` `=` `np``.``array``(``[``boot_stat``(``X``,` `y``)` `for` `_` `in` `range``(``10_000``)``]``)`
我们对这个 bootstrap 抽样分布的形状和传播感兴趣(我们知道中心将接近原系数):
`px``.``histogram``(``x``=``boot_theta_hat``,` `nbins``=``50``,`
`labels``=``dict``(``x``=``'``Bootstrapped humidity coefficient``'``)``,`
`width``=``350``,` `height``=``250``)`

按设计,bootstrap 抽样分布的中心将接近,因为 bootstrap 总体由观测数据组成。因此,我们不是计算观察统计量至少大于某个值的机会,而是找到至少小于 0 的值的机会。假设值 0 远离抽样分布。
10,000 个模拟回归系数中没有一个像假设的系数那么小。统计逻辑引导我们拒绝零假设,即我们不需要调整湿度模型。
我们在这里执行的假设检验形式与早期的检验看起来不同,因为统计量的抽样分布不是集中在零假设上。这是因为我们使用 bootstrap 创建抽样分布。实际上,我们使用系数的置信区间来测试假设。在下一节中,我们更广泛地介绍区间估计,包括基于 bootstrap 的区间估计,并连接假设检验和置信区间的概念。
置信区间基础
我们已经看到,建模可以导致估计,例如公交车迟到的典型时间(见第 4 章),空气质量测量的湿度调整(见第 15 章),以及疫苗效力的估计(见第 2 章)。这些例子是未知值的点估计,称为参数:公交车迟到的中位数为 0.74 分钟;空气质量的湿度调整为每湿度百分点 0.21 PM2.5;而疫苗效力中 COVID 感染率的比率为 0.67。然而,不同的样本会产生不同的估计。简单提供点估计并不能反映估计的精度。相反,区间估计可以反映估计的准确性。这些区间通常采用以下两种形式:
-
一个基于 bootstrap 采样分布百分位数创建的自举置信区间。
-
使用抽样分布的标准误差(SE)和关于分布为正态曲线形状的额外假设构建的正态置信区间
我们描述了这两种类型的区间,然后给出了一个例子。回想一下抽样分布(见图 17-1)是反映不同值观察概率的概率分布。置信区间是根据的抽样分布扩展构建的,因此区间端点是随机的,因为它们基于。这些区间被设计成 95%的时间覆盖。
正如其名称所示,基于百分位数的 bootstrap 置信区间是从 bootstrap 采样分布的百分位数创建的。具体来说,我们计算的抽样分布的分位数,其中是 bootstrap 统计量。对于 95th 百分位数区间,我们确定 2.5 和 97.5 分位数,分别称为和,其中 95%的时间,bootstrap 统计量在以下区间内:
这个 bootstrap 百分位数置信区间被认为是一种快速而粗糙的区间估计方法。有许多替代方法可以调整偏差,考虑分布形状,并且更适合于小样本。
百分位置信区间不依赖于抽样分布具有特定形状或分布中心为。相比之下,正态置信区间通常不需要引导抽样来计算,但它确实对的抽样分布形状做了额外的假设。
我们在抽样分布可以很好地近似于正态曲线时使用正常的置信区间。对于正态概率分布,以中心和扩展,有 95%的概率,从这个分布中随机取得的值在区间内。由于抽样分布的中心通常是,对于随机生成的,有 95%的概率是:
其中是的抽样分布的扩展。我们使用这个不等式来为做一个 95%的置信区间:
可以使用不同倍数的基于正态曲线形成其他尺寸的置信区间。例如,99%置信区间为,单侧上限 95%置信区间为。
注意
参数估计的标准差通常称为标准误差或 SE,以区别于样本、总体或来自容器的一次抽样的标准差。在本书中,我们不加区分地称之为标准差。
接下来我们提供每种类型的示例。
在本章的早些时候,我们测试了线性空气质量模型中湿度系数为 0 的假设。这些数据的拟合系数为 。由于空模型未完全指定数据生成机制,我们转而使用自举法。也就是说,我们将数据视为总体,从自举总体中有放回地抽取了 11,226 条记录,并拟合模型以找到湿度的自举样本系数。我们的模拟重复了这一过程 10,000 次,以获得近似的自举抽样分布。
我们可以使用这个自举抽样分布的百分位数来创建的 99% 置信区间。为此,我们找到自举抽样分布的分位数 和:
`q_995` `=` `np``.``percentile``(``boot_theta_hat``,` `99.5``,` `method``=``'``lower``'``)`
`q_005` `=` `np``.``percentile``(``boot_theta_hat``,` `0.05``,` `method``=``'``lower``'``)`
`print``(``f``"``Lower 0.05th percentile:` `{``q_005``:``.3f``}``"``)`
`print``(``f``"``Upper 99.5th percentile:` `{``q_995``:``.3f``}``"``)`
Lower 0.05th percentile: 0.099
Upper 99.5th percentile: 0.260
或者,由于抽样分布的直方图形状大致呈正态分布,我们可以基于正态分布创建一个 99% 置信区间。首先,我们找到的标准误差,这只是的抽样分布的标准偏差:
`standard_error` `=` `np``.``std``(``boot_theta_hat``)`
`standard_error`
0.02653498609330345
然后,的 99% 置信区间是观察到的的 个标准误差的距离,向任何方向:
`print``(``f``"``Lower 0.05th endpoint:` `{``theta2_hat` `-` `(``2.58` `*` `standard_error``)``:``.3f``}``"``)`
`print``(``f``"``Upper 99.5th endpoint:` `{``theta2_hat` `+` `(``2.58` `*` `standard_error``)``:``.3f``}``"``)`
Lower 0.05th endpoint: 0.138
Upper 99.5th endpoint: 0.275
这两个区间(自举百分位和正态)非常接近但显然不相同。考虑到自举抽样分布中的轻微不对称性,我们可能会预期到这一点。
还有其他基于正态分布的置信区间版本,反映了使用数据的抽样分布的标准误差的估计的变异性。还有一些针对统计量而不是平均数的百分位数的其他置信区间。(还要注意,对于置换测试,自举法的准确性不如正态近似。)
注意
置信区间很容易被误解为参数 在区间内的概率。然而,置信区间是从抽样分布的一个实现中创建的。抽样分布给出了一个不同的概率陈述;以这种方式构建的区间将在 95% 的时间内包含 。不幸的是,我们不知道这个特定时间是否是那些发生了 100 次中的 95 次之一。这就是为什么使用术语置信度而不是概率或机会,并且我们说我们有 95% 的置信度参数在我们的区间内。
置信区间和假设检验有以下关系。比如说,如果一个 95%的置信区间包含了假设值 ,那么这个检验的 值小于 5%。也就是说,我们可以反过来利用置信区间来创建假设检验。我们在前一节中使用了这种技术,当我们进行了对空气质量模型中湿度系数是否为 0 的检验。在本节中,我们基于自举百分位数创建了一个 99%的系数置信区间,由于 0 不在区间内,所以 值小于 1%,根据统计逻辑,我们可以得出结论系数不为 0。
另一种区间估计是预测区间。预测区间侧重于观测值的变化而不是估计量的变化。我们接下来来探索这些内容。
预测区间的基础
置信区间传达了估计量的准确性,但有时我们想要对未来观测的预测准确性。例如,有人可能会说:我的公交车三分之一的时间最多晚到三四分之一分钟,但它可能晚多久?另一个例子是,加利福尼亚州鱼类和野生动物部门将达摩尼斯蟹的最小捕捞尺寸设定为 146 毫米,一个休闲钓鱼公司可能会想知道他们顾客的捕捞品是否比 146 毫米更大。还有一个例子,兽医根据驴的长度和腰围估算其重量为 169 公斤,并使用这一估算来给驴注射药物。为了驴的安全,兽医希望知道驴的真实重量可能与这一估算相差多少。
这些例子的共同点是对未来观测的预测以及量化未来观测可能与预测之间的距离。就像置信区间一样,我们计算统计量(估计量),并在预测中使用它,但现在我们关心的是未来观测与预测之间的典型偏差。在接下来的几节中,我们通过基于分位数、标准偏差以及条件协变量的示例来工作,展示预测区间的例子。沿途我们还提供了关于观测值典型变化的额外信息。
示例:预测公交车晚点情况
第四章 模型化了西雅图公交车到达特定站点的晚点情况。我们观察到分布高度倾斜,并选择以中位数 0.74 分钟估算典型的晚点情况。我们在此重现了该章节的样本直方图:
`times` `=` `pd``.``read_csv``(``"``data/seattle_bus_times_NC.csv``"``)`
`fig` `=` `px``.``histogram``(``times``,` `x``=``"``minutes_late``"``,` `width``=``350``,` `height``=``250``)`
`fig``.``update_xaxes``(``range``=``[``-``12``,` `60``]``,` `title_text``=``"``Minutes late``"``)`
`fig`

预测问题解决了公交车可能晚点多久的问题。虽然中位数信息丰富,但它并不提供关于分布偏斜程度的信息。也就是说,我们不知道公交车可能会有多晚。第 75 百分位数,甚至第 95 百分位数,会增加有用的考虑信息。我们在这里计算这些百分位数:
median: 0.74 mins late
75th percentile: 3.78 mins late
95th percentile: 13.02 mins late
从这些统计数据中,我们了解到,超过一半的时间,公交车甚至不晚一分钟,四分之一的时间几乎晚了四分钟,有时几乎可以发生公交车晚了近 15 分钟。这三个值共同帮助我们制定计划。
示例:预测螃蟹大小
对于捕捞遠缅蟹的高度规定,包括限制螃蟹捕捞的贝壳宽度为 146 毫米。为了更好地理解加州渔猎和野生动物部与北加州和南俄勒冈州的商业螃蟹捕捞者合作捕捉、测量和释放螃蟹。这里是约 450 只捕获螃蟹的贝壳大小直方图:

分布略微向左倾斜,但平均值和标准差是分布的合理摘要统计数据:
`crabs``[``'``shell``'``]``.``describe``(``)``[``:``3``]`
count 452.00
mean 131.53
std 11.07
Name: shell, dtype: float64
平均值 132 毫米是典型螃蟹大小的良好预测。然而,它缺乏有关个体螃蟹可能与平均值相差多远的信息。标准差可以填补这一空白。
除了个别观察值围绕分布中心的可变性之外,我们还考虑了我们对平均贝壳大小估计的可变性。我们可以使用自助法来估计这种可变性,或者我们可以使用概率论(我们在下一节中会这样做)来展示估计值的标准差是。我们还在下一节中展示,这两种变化来源结合如下:
我们用替换并将此公式应用于我们的螃蟹:
`np``.``std``(``crabs``[``'``shell``'``]``)` `*` `np``.``sqrt``(``1` `+` `1``/``len``(``crabs``)``)`
11.073329460297957
我们看到包括样本平均值的 SE 基本上不会改变预测误差,因为样本如此之大。我们得出结论,螃蟹通常与 132 毫米的典型大小相差 11 至 22 毫米。这些信息有助于制定围绕螃蟹捕捞的政策,以维护螃蟹种群的健康,并为娱乐捕鱼者设定期望。
示例:预测螃蟹的增长
加州渔业和野生动物部希望更好地理解蟹的生长,以便能够制定更好的捕捞限制,从而保护蟹的种群。在前述例子中提到的研究中捕获的蟹即将脱壳,除了它们的大小外,还记录了换壳前后壳尺寸的变化:
`crabs``.``corr``(``)`
| 壳 | 增量 | |
|---|---|---|
| 壳 | 1.0 | -0.6 |
| 增量 | -0.6 | 1.0 |
这两个测量值呈负相关,意味着蟹越大,它们换壳时的生长就越少。我们绘制生长增量与壳尺寸的关系图,以确定这些变量之间的关系是否大致为线性:
px.scatter(crabs, y='inc', x= 'shell', width=350, height=250,
labels=dict(shell='Dungeness crab shell width (mm)',
inc='Growth (mm)'))

关系看起来是线性的,我们可以拟合一个简单的线性模型来解释壳前换壳大小的生长增量。在本例中,我们使用statsmodels库,它提供了使用get_prediction生成预测区间的功能。我们首先设置设计矩阵和响应变量,然后使用最小二乘法拟合模型:
`import` `statsmodels``.``api` `as` `sm`
`X` `=` `sm``.``add_constant``(``crabs``[``[``'``shell``'``]``]``)`
`y` `=` `crabs``[``'``inc``'``]`
`inc_model` `=` `sm``.``OLS``(``y``,` `X``)``.``fit``(``)`
`print``(``f``"``Increment estimate =` `{``inc_model``.``params``[``0``]``:``0.2f``}` `+` `"``,`
`f``"``{``inc_model``.``params``[``1``]``:``0.2f``}` `x Shell Width``"``)`
Increment estimate = 29.80 + -0.12 x Shell Width
建模时,我们为解释变量的给定值创建预测区间。例如,如果一只新捕获的蟹的壳宽度为 120 毫米,那么我们使用我们拟合的模型来预测其壳的生长。
如前例所示,我们对单个观测值的预测的可变性包括我们对蟹的生长估计的可变性以及蟹壳尺寸的蟹对蟹变异。我们可以再次使用自助法来估计这种变异,或者可以使用概率理论来展示这两种变异源如下组合:
在这里,是由原始数据组成的设计矩阵,是来自回归的残差的列向量,而是新观测值的行特征向量(在本例中,这些是):
`new_data` `=` `dict``(``const``=``1``,` `shell``=``120``)`
`new_X` `=` `pd``.``DataFrame``(``new_data``,` `index``=``[``0``]``)`
`new_X`
| 常数 | 壳 | |
|---|---|---|
| 0 | 1 | 120 |
我们使用statsmodels中的get_prediction方法为壳宽度为 120 毫米的蟹找到 95%的预测区间:
`pred` `=` `inc_model``.``get_prediction``(``new_X``)`
`pred``.``summary_frame``(``alpha``=``0.05``)`
| 均值 | 均值标准误 | 均值置信区间下限 | 均值置信区间上限 | 观测置信区间下限 | 观测置信区间上限 | |
|---|---|---|---|---|---|---|
| 0 | 15.86 | 0.12 | 15.63 | 16.08 | 12.48 | 19.24 |
在这里,我们有一个对贝壳大小为 120 毫米的螃蟹平均生长增量的置信区间为[15.6, 16.1],以及生长增量的预测区间为[12.5, 19.2]。预测区间要宽得多,因为它考虑了个体螃蟹的变异性。这种变异性体现在点围绕回归线的分布中,我们通过残差的标准偏差来近似。贝壳大小和生长增量之间的相关性意味着对于特定贝壳大小的生长增量预测的变异性要小于生长增量的整体标准偏差:
`print``(``f``"``Residual SD:` `{``np``.``std``(``inc_model``.``resid``)``:``0.2f``}``"``)`
`print``(``f``"``Crab growth SD:` `{``np``.``std``(``crabs``[``'``inc``'``]``)``:``0.2f``}``"``)`
Residual SD: 1.71
Crab growth SD: 2.14
get_prediction提供的区间依赖于增长增量分布的正态近似。这就是为什么 95% 的预测区间端点大约是预测值两倍的残差标准偏差之外。在下一节中,我们将深入探讨这些标准差、估计值和预测的计算。我们还讨论了在计算它们时所做的一些假设。
推断和预测的概率
假设检验、置信区间和预测区间依赖于从抽样分布和数据生成过程计算的概率计算。这些概率框架还使我们能够对假设调查、实验或其他机会过程进行模拟和自举研究,以研究其随机行为。例如,我们在维基百科实验中找到了排名平均值的抽样分布,假设该实验中的处理方式不有效。使用模拟,我们量化了期望结果的典型偏差和摘要统计量可能值的分布。图 17-1 中的三联画提供了一个图表,指导我们进行这个过程;它有助于区分人群、概率和样本之间的差异,并展示它们之间的联系。在本节中,我们为这些概念带来了更多的数学严谨性。
我们正式介绍了期望值、标准偏差和随机变量的概念,并将它们与本章中用于检验假设、制定置信区间和预测区间的概念联系起来。我们从维基百科实验的具体例子开始,然后进行概括。在此过程中,我们将这种形式主义与贯穿本章的三联画连接起来,作为我们的指导。
平均等级统计理论的形式化
回想一下在 Wikipedia 实验中,我们汇总了治疗组和对照组的奖后生产力值,并将它们转换为排名, ,因此总体仅由整数 1 到 200 组成。图 17-3 是代表这种特定情况的图表。注意,总体分布是平坦的,范围从 1 到 200(图 17-3 的左侧)。此外,我们使用的总体总结(称为 总体参数)是平均排名:
另一个相关的总结是关于 的分布,定义为总体标准偏差:
SD(pop) 表示一个排名与整体平均值的典型偏差。为了计算这个例子的 SD(pop),需要进行一些数学手工操作:

图 17-3. Wikipedia 实验数据生成过程的示意图;这是一个我们知道总体的特殊情况
观察到的样本由治疗组的整数排名组成;我们将这些值称为 样本分布显示在 图 17-3 的右侧(100 个整数每个出现一次)。
与总体平均值相对应的是样本平均值,这是我们感兴趣的统计量:
注意在平均值情况下样本统计量和总体参数的定义之间的平行。两个标准偏差之间的平行也值得注意。
接下来我们转向数据生成过程:从乌龙球中抽取 100 个弹珠(其值为
在本例中,
我们经常通过期望值和标准差来总结随机变量的分布。就像在人群和样本中一样,这两个量给了我们预期结果和实际值可能偏离预期的程度的感觉。
对于我们的例子,
注意,
注意
术语期望值可能会有点令人困惑,因为它不一定是随机变量的可能值。例如,
接下来,
此外,我们定义
再次指出
为了描述图 17-3 中的整个数据生成过程,我们还定义
这意味着每个
要完成图 17-3 的中间部分,涉及
我们可以利用
换句话说,从总体中随机抽取的平均值的期望值等于总体平均值。这里我们提供了关于总体方差和平均值方差的公式,以及标准差:
这些计算依赖于随机变量的期望值和方差以及随机变量和随机变量和的若干性质。接下来,我们提供用于推导刚才提出的公式的随机变量和随机变量平均数的性质。
随机变量的一般性质
一般来说,随机变量表示机会事件的数值结果。在本书中,我们使用像
因此,
方差
而
注意
尽管随机变量可以表示数量,这些数量可以是离散的(例如从总体中随机抽取的家庭中孩子的数量)或连续的(例如由空气监测仪器测量的空气质量),但我们在本书中只讨论具有离散结果的随机变量。由于大多数测量都具有一定程度的精确性,这种简化并不会太大限制我们。
简单的公式提供了期望值、方差和标准差,当我们对随机变量进行比例和移位变化时,比如对于常数
要确信这些公式是合理的,请考虑如果向每个值添加常数
我们还对两个或更多随机变量的和的属性感兴趣。让我们考虑两个随机变量,
但是要找到
描述
协方差进入到
在
这些性质可以用来表明,对于独立的随机变量
这种情况发生在乌恩模型中,
然而,当我们从乌恩中无替换随机抽取时,
请注意,虽然期望值与无替换抽取时相同,但方差和标准差较小。这些量由称为有限总体修正因子的公式调整,公式为
返回到图 17-3,我们看到在图表中央的
现在我们已经概述了随机变量及其总和的一般性质,我们将这些想法与测试、置信区间和预测区间联系起来。
测试和区间背后的概率
正如本章开头提到的那样,概率是进行假设检验、为估计器提供置信区间和未来观测的预测区间的基础。
现在我们有了技术装备来解释这些概念,在本章中我们已经仔细定义了这些概念,没有使用正式的技术术语。这一次,我们用随机变量及其分布来展示结果。
请记住,假设检验依赖于一个提供统计量
通常,随机变量被标准化以使计算更容易和标准化:
当
信心区间背后的概率陈述与测试中使用的概率计算非常相似。特别是,为了创建一个 95%的信心区间,其中估计量的抽样分布大致为正态分布,我们进行标准化并使用概率:
请注意,在前述概率陈述中
一旦观察到的统计量替换了随机变量,那么我们说我们有 95%的信心,我们创建的区间包含真值
接下来,我们考虑预测区间。基本概念是提供一个区间,表示未来观察的期望变化约估器。在简单情况下,统计量为
注意,变化有两部分:一部分是由于
对于更复杂的模型,预测的变化也分解为两个组成部分:关于模型的数据固有变化以及由于模型估计的抽样分布的变化。假设模型大致正确,我们可以表达如下:
其中
当我们在回归中创建预测区间时,它们会被赋予一个协变量的
我们用最小二乘拟合的残差的方差来近似
我们使用正态曲线创建的预测区间还依赖于另一个假设,即错误的分布近似正态。这是一个比我们为置信区间所做的假设更强的假设。对于置信区间,
当我们制作这些预测区间时,我们还假设线性模型近似正确。在第十六章中,我们考虑了拟合模型不匹配产生数据的模型的情况。我们现在有技术机器可以推导出该章节中介绍的模型偏差-方差权衡。它与带有一些小变化的预测区间推导非常相似。
模型选择背后的概率
在第十六章中,我们用均方误差(MSE)介绍了模型欠拟合和过拟合。我们描述了一般设置,其中数据可能表示为如下所示:
假设
这里
我们在模型选择中的目标是选择一个能够很好地预测新观测的模型。对于一个新的观测,我们希望期望损失很小:
这种期望是关于可能的分布
这个近似被称为经验风险。但希望你能认出它是 MSE:
我们通过最小化所有可能模型上的经验风险(或 MSE)来拟合模型,
拟合的模型称为
在第十六章中,我们看到当我们使用经验风险来既拟合模型又评估新观测的风险时出现问题。理想情况下,我们希望估计风险(期望损失):
期望值是关于新观测
为了理解问题,我们将这种风险分解为代表模型偏差、模型方差和来自\(\epsilon\)的不可约误差的三个部分:
要推导标记为“展开平方”的等式,我们需要正式证明展开中的交叉项都是 0。这需要一些代数,我们在此不详细介绍。但主要思想是术语
模型偏差
最终方程中的第一个术语是模型偏差(平方)。当信号
模型方差
第二项代表来自数据的拟合模型的变异性。在早期的例子中,我们看到高阶多项式可能会过拟合,因此在不同数据集之间变化很大。模型空间越复杂,拟合模型的变异性越大。
不可减少的错误
最后,最后一个术语是错误的可变性,即
这种预期损失的表示显示了拟合模型的偏差-方差分解。模型选择旨在平衡这两种竞争性误差来源。在第十六章介绍的训练-测试分割、交叉验证和正则化是模仿新观察预期损失或惩罚模型过拟合的技术。
虽然本章涵盖了大量的理论内容,我们尝试将其与乌尔姆模型的基础以及三种分布(总体、样本和抽样)联系起来。在进行假设检验和制作置信度或预测区间时,请牢记以下几点。
总结
本章的整个过程基于乌尔姆模型来发展推断和预测的理论。乌尔姆模型引入了估计量的概率分布,例如样本均值和最小二乘回归系数。我们在此结束本章,并提出一些关于这些统计程序的注意事项。
我们看到,估计量的标准差在分母中有样本大小的平方根因子。当样本很大时,标准差可能会很小,从而导致拒绝假设或非常窄的置信区间。在这种情况下,考虑以下几点是很有必要的:
-
您检测到的差异是否重要?也就是说,
-值可能会很小,表明一个令人惊讶的结果,但观察到的实际效果可能不重要。统计显著性并不意味着实际显著性。
-
请记住,这些计算不包括偏差,如非响应偏差和测量偏差。这种偏差可能比由抽样分布中的偶然变化引起的任何差异都要大。
有时,我们知道样本不是来自于随机机制,但进行假设检验仍然有用。在这种情况下,零模型将检验样本(和估计量)是否像是随机的。当这个检验被拒绝时,我们确认某些非随机因素导致了观察到的数据。这可能是一个有用的结论:我们观察到的差异不能仅仅通过偶然性来解释。
在其他时候,样本包含整个人群。这种情况下,我们可能不需要进行置信区间或假设检验,因为我们已经观察到了人群中的所有值。也就是说,不需要进行推断。但是,我们可以对假设检验进行不同的解释:我们可以假设观察到的两个特征之间的任何关系是随机分布的,而彼此之间没有关联。
我们还看到了当我们没有足够的关于人群的信息时,如何利用自助法。自助法是一种强大的技术,但它也有局限性:
-
确保原始样本是大且随机的,以使样本类似于人群。
-
重复自助法过程多次。通常进行 10,000 次重复是一个合理的数量。
-
自助法在以下情况下往往会遇到困难:
-
估计量受异常值的影响。
-
参数基于分布的极端值。
-
统计量的抽样分布远非钟形。
-
或者,我们依赖于抽样分布大致呈正态形状的情况。有时,抽样分布看起来大致正态,但尾部较厚。在这些情况下,使用
模型通常只是底层现实的一种近似,而
最后,有时候假设检验或置信区间的数量可能会相当大,我们需要小心避免产生虚假结果。这个问题被称为
我们接下来通过一个案例研究来总结建模过程。
第十八章:案例研究:如何称量一只驴
驴子在肯尼亚农村扮演着重要角色。人们需要它们来运输作物、水和人,以及耕种田地。当一只驴子生病时,兽医需要知道它的体重以便开对量的药物。但是在肯尼亚农村,许多兽医没有称重器,因此他们需要猜测驴子的体重。药物用量过少可能会导致感染再次发作;药物过多则可能导致有害的过量。肯尼亚有超过 180 万头驴子,因此估算驴子体重的方法简单而准确非常重要。
在这个案例研究中,我们追随凯特·米尔纳和乔纳森·鲁吉尔 的工作,创建一个模型,供肯尼亚农村的兽医使用,以准确估计驴子的体重。像往常一样,我们遵循数据科学生命周期的步骤,但这次我们的工作偏离了这本书迄今为止涵盖的基础知识。您可以把这个案例研究看作是反思许多数据处理核心原则的机会,并理解如何扩展这些原则以应对具体情况。我们直接评估测量误差的来源,设计一个反映对过量用药关注的特殊损失函数,构建一个以适用性为重点的模型,并使用相对于驴子大小的特殊标准评估模型预测。
我们从数据的范围开始。
驴子研究问题与范围
我们的动力问题是:在没有称重器的情况下,兽医如何准确估算驴子的体重?让我们考虑一下他们更容易得到的信息。他们可以携带卷尺,并找出驴子在其他尺寸上的大小,比如它的高度。他们可以观察动物的性别,评估其一般状态,并询问驴子的年龄。因此,我们可以将我们的问题进一步细化为:兽医如何从易于获取的测量数据中准确预测驴子的体重?
为了解决这个更精确的问题,驴避难所 在肯尼亚农村的 17 个移动驱虫点进行了研究。
在范围方面(第二章),目标人群是肯尼亚农村的驴群。访问框架是所有被带到驱虫点的驴子。样本包括 2010 年 7 月 23 日至 8 月 11 日被带到这些点的所有驴子,但有一些注意事项:如果在一个地点有太多的驴子需要测量,科学家们会选择一部分来测量,并且任何怀孕或明显生病的驴子都被排除在研究之外。
为了避免不小心对同一只驴进行两次称重,每只驴在称重后都会标记。为了量化测量误差并评估称重过程的重复性,我们对 31 只驴进行了两次测量,而工作人员并不知道这是一只已经被重新称重的驴。
考虑到这一抽样过程,此数据的潜在偏差源包括:
覆盖偏差
这 17 个地点位于肯尼亚东部雅塔区和大裂谷内纳瓦沙区周围的地区。
选择偏差
只有被送往庇护所的驴才能参加这项研究,当一个地点有太多的驴时,会选择一个非随机样本。
测量偏差
除了测量误差外,称可能存在偏差。理想情况下,在使用场地之前和之后,天平应该校准(第十二章)。
尽管存在这些潜在的偏差源,但从肯尼亚农村地区拥有关心动物健康的主人那里获取驴的访问框架似乎是合理的。
我们的下一步是清理数据。
数据整理和转换
我们首先看一下数据文件的内容。为此,我们打开文件并检查前几行(第八章):
`from` `pathlib` `import` `Path`
`# Create a Path pointing to our datafile`
`insp_path` `=` `Path``(``'``data/donkeys.csv``'``)`
`with` `insp_path``.``open``(``)` `as` `f``:`
`# Display first five lines of file`
`for` `_` `in` `range``(``5``)``:`
`print``(``f``.``readline``(``)``,` `end``=``'``'``)`
BCS,Age,Sex,Length,Girth,Height,Weight,WeightAlt
3,<2,stallion,78,90,90,77,NA
2.5,<2,stallion,91,97,94,100,NA
1.5,<2,stallion,74,93,95,74,NA
3,<2,female,87,109,96,116,NA
由于文件是 CSV 格式的,我们可以轻松地将其读入数据框架:
`donkeys` `=` `pd``.``read_csv``(``"``data/donkeys.csv``"``)`
`donkeys`
| BCS | 年龄 | 性别 | 长度 | 胸围 | 身高 | 体重 | WeightAlt | |
|---|---|---|---|---|---|---|---|---|
| 0 | 3.0 | <2 | 种马 | 78 | 90 | 90 | 77 | NaN |
| 1 | 2.5 | <2 | 种马 | 91 | 97 | 94 | 100 | NaN |
| 2 | 1.5 | <2 | 种马 | 74 | 93 | 95 | 74 | NaN |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 541 | 2.5 | 10-15 | 种马 | 103 | 118 | 103 | 174 | NaN |
| 542 | 3.0 | 2-5 | 种马 | 91 | 112 | 100 | 139 | NaN |
| 543 | 3.0 | 5-10 | 种马 | 104 | 124 | 110 | 189 | NaN |
544 rows × 8 columns
超过五百只驴参与了调查,每只驴进行了八次测量。根据文档,粒度是单个驴(第九章)。表 18-1 提供了这八个特征的描述。
表 18-1. 驴研究代码手册
| 特征 | 数据类型 | 特征类型 | 描述 |
|---|---|---|---|
| BCS | float64 | 有序 | 身体条件评分:从 1(消瘦)到 3(健康)到 5(肥胖),每 0.5 单位增加。 |
| 年龄 | string | 有序 | 年龄(年),小于 2 岁、2-5 岁、5-10 岁、10-15 岁、15-20 岁和 20 岁以上 |
| 性别 | string | 名义 | 性别类别:种马、阉马、母驴 |
| 长度 | int64 | 数值 | 体长(厘米),从前腿肘到骨盆后部 |
| 胸围 | int64 | 数值 | 身体围长(厘米),在前腿后面测量 |
| 身高 | int64 | 数值 | 身体高度(厘米),到颈部连接背部的点 |
| 体重 | int64 | 数值 | 体重(千克) |
| WeightAlt | float64 | 数值 | 在一部分驴身上进行的第二次称重测量 |
图 18-1 是将驴子理想化为带有颈部和附加的腿的圆柱体的图示。身高是从地面到肩膀上方的颈部底部的测量;胸围是围绕身体,就在后腿后面;长度是从前肘到骨盆后部。

图 18-1。驴的胸围、长度和高度的图示,被描述为对圆柱体上的测量
我们的下一步是对数据进行一些质量检查。在上一节中,我们基于范围列出了一些潜在的质量问题。接下来,我们检查测量和它们的分布的质量。
让我们首先比较对一小部分驴子进行的两次体重测量,以检查秤的一致性。我们为这 31 只被称重两次的驴子制作了这两个测量值之间的差异的直方图:
`donkeys` `=` `donkeys``.``assign``(``difference``=``donkeys``[``"``WeightAlt``"``]` `-` `donkeys``[``"``Weight``"``]``)`
`px``.``histogram``(``donkeys``,` `x``=``"``difference``"``,` `nbins``=``15``,`
`labels``=``dict``(`
`difference``=``"``Differences of two weighings (kg)<br>on the same donkey``"`
`)``,`
`width``=``350``,` `height``=``250``,`
`)`

这些测量值彼此之间都在 1 公斤以内,大部分都是完全相同的(四舍五入到最接近的公斤)。这让我们对测量的准确性有信心。
接下来,我们寻找体况评分中的异常值:
`donkeys``[``'``BCS``'``]``.``value_counts``(``)`
BCS
3.0 307
2.5 135
3.5 55
...
1.5 5
4.5 1
1.0 1
Name: count, Length: 8, dtype: int64
从这个输出中,我们看到只有一个消瘦的(BCS = 1)和一个肥胖的(BCS = 4.5)驴子。让我们看看这两只驴子的完整记录:
`donkeys``[``(``donkeys``[``'``BCS``'``]` `==` `1.0``)` `|` `(``donkeys``[``'``BCS``'``]` `==` `4.5``)``]`
| BCS | 年龄 | 性别 | 长度 | 胸围 | 身高 | 体重 | 体重备用 | |
|---|---|---|---|---|---|---|---|---|
| 291 | 4.5 | 10-15 | 女性 | 107 | 130 | 106 | 227 | NaN |
| 445 | 1.0 | >20 | 女性 | 97 | 109 | 102 | 115 | NaN |
由于这些 BCS 值非常极端,我们要谨慎考虑将这两只驴子纳入我们的分析范围。在这两个极端类别中,我们每个类别只有一只驴子,因此我们的模型可能无法延伸到 BCS 为 1 或 4.5 的驴子。我们将这两条记录从数据框中删除,并注意到我们的分析可能不适用于消瘦或肥胖的驴子。总的来说,我们在删除数据框中的记录时要小心。稍后,如果这五只 BCS 为 1.5 的驴子在我们的分析中看起来不正常,我们也可能决定将它们删除,但目前,我们将它们保留在我们的数据框中。一般来说,我们需要一个充分的理由来排除数据,并且我们应该记录这些操作,因为它们可能会影响我们的发现。如果我们删除与模型不符的任何记录,可能会导致过度拟合。
我们接下来删除这两个异常值:
`def` `remove_bcs_outliers``(``donkeys``)``:`
`return` `donkeys``[``(``donkeys``[``'``BCS``'``]` `>``=` `1.5``)` `&` `(``donkeys``[``'``BCS``'``]` `<``=` `4``)``]`
`donkeys` `=` `(``pd``.``read_csv``(``'``data/donkeys.csv``'``)`
`.``pipe``(``remove_bcs_outliers``)``)`
现在,我们检查体重值的分布,以查看是否存在任何质量问题:
`px``.``histogram``(``donkeys``,` `x``=``'``Weight``'``,` `nbins``=``40``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``Weight``'``:``'``Weight (kg)``'``}``)`

看起来有一只非常轻的驴子,体重不到 30 公斤。接下来,我们检查体重和身高之间的关系,以评估用于分析的数据质量。
`px``.``scatter``(``donkeys``,` `x``=``'``Height``'``,` `y``=``'``Weight``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``Weight``'``:``'``Weight (kg)``'``,` `'``Height``'``:``'``Height (cm)``'``}``)`

小驴离主要驴群较远,会对我们的模型产生过大影响,因此我们将其排除。同样,我们要注意,如果有一两匹重驴对我们未来的模型拟合产生过大影响,我们也可能要将它们排除:
`def` `remove_weight_outliers``(``donkeys``)``:`
`return` `donkeys``[``(``donkeys``[``'``Weight``'``]` `>``=` `40``)``]`
`donkeys` `=` `(``pd``.``read_csv``(``'``data/donkeys.csv``'``)`
`.``pipe``(``remove_bcs_outliers``)`
`.``pipe``(``remove_weight_outliers``)``)`
`donkeys``.``shape`
(541, 8)
总之,根据我们的清理和质量检查,我们从数据框中删除了三个异常观测。现在我们几乎可以开始我们的探索性分析了。在继续之前,我们将一些数据设置为测试集。
我们讨论了在第十六章中将测试集与训练集分开的重要性。最佳实践是在分析早期就将测试集分开,这样我们在详细探索数据之前就开始做出关于适合哪种模型以及在模型中使用哪些变量的决定。很重要的一点是,我们的测试集不参与这些决策,以便模拟我们的模型在全新数据上的表现。
我们将数据分成 80/20 的比例,其中 80%用于探索和建模。然后,我们用设置的 20%来评估模型。我们使用简单随机抽样将数据框分为测试集和训练集。首先,我们随机打乱数据框的索引:
`np``.``random``.``seed``(``42``)`
`n` `=` `len``(``donkeys``)`
`indices` `=` `np``.``arange``(``n``)`
`np``.``random``.``shuffle``(``indices``)`
`n_train` `=` `int``(``np``.``round``(``(``0.8` `*` `n``)``)``)`
接下来,我们将数据框的前 80%分配给训练集,剩余的 20%分配给测试集:
`train_set` `=` `donkeys``.``iloc``[``indices``[``:``n_train``]``]`
`test_set` `=` `donkeys``.``iloc``[``indices``[``n_train``:``]``]`
现在我们准备探索训练数据,寻找有用的关系和分布,为我们的建模提供信息。
探索中
我们查看数据框中的形状和关系特征,以便进行转换和模型制作(第十章)。我们首先看看年龄、性别和体况这些分类特征如何与体重相关联:
`f1` `=` `px``.``box``(``train_set``,` `x``=``"``Age``"``,` `y``=``"``Weight``"``,`
`category_orders` `=` `{``"``Age``"``:``[``'``<2``'``,` `'``2-5``'``,` `'``5-10``'``,`
`'``10-15``'``,` `'``15-20``'``,` `'``>20``'``]``}``)`
`f2` `=` `px``.``box``(``train_set``,` `x``=``"``Sex``"``,` `y``=``"``Weight``"``)`
`# We wrote the left_right function as a shorthand for plotly's make_subplots`
`fig` `=` `left_right``(``f1``,` `f2``,` `column_widths``=``[``0.7``,` `0.3``]``)`
`fig``.``update_xaxes``(``title``=``'``Age (yr)``'``,` `row``=``1``,` `col``=``1``)`
`fig``.``update_xaxes``(``title``=``'``Sex``'``,` `row``=``1``,` `col``=``2``)`
`fig``.``update_yaxes``(``title``=``'``Weight (kg)``'``,` `row``=``1``,` `col``=``1``)`

`fig` `=` `px``.``box``(``train_set``,` `x``=``"``BCS``"``,` `y``=``"``Weight``"``,` `points``=``"``all``"``,`
`labels``=``{``'``Weight``'``:``'``Weight (kg)``'``,` `'``BCS``'``:``'``Body condition score``'``}``,`
`width``=``550``,` `height``=``250``)`
`fig`

注意,我们绘制了身体状况评分的点和箱线图,因为我们之前看到评分为 1.5 的观测值只有少数几个,所以我们不希望过多解读仅有少量数据点的箱线图(第十一章)。看起来,体重中位数随着体况评分增加而增加,但增长并非简单线性。另一方面,三种性别类别的体重分布看起来大致相同。至于年龄,一旦一匹驴达到五岁,其体重分布似乎不会有太大变化。但两岁以下的驴和两至五岁的驴的体重普遍较低。
接下来,让我们检查定量变量。我们在散点图矩阵中绘制所有定量变量的成对关系:

骡子的身高、长度和腰围都与体重以及彼此之间线性相关。这并不太令人惊讶;只要知道骡子的一个维度,我们就可以大致猜测其他维度。腰围与体重的相关性最高,这在相关系数矩阵中得到了验证:
`train_numeric``.``corr``(``)`
| 权重 | 长度 | 腰围 | 身高 | |
|---|---|---|---|---|
| 体重 | 1.00 | 0.78 | 0.90 | 0.71 |
| 长度 | 0.78 | 1.00 | 0.66 | 0.58 |
| 腰围 | 0.90 | 0.66 | 1.00 | 0.70 |
| 身高 | 0.71 | 0.58 | 0.70 | 1.00 |
我们的探索揭示了数据的几个可能与建模相关的方面。我们发现骡子的腰围、长度和身高都与体重以及彼此之间线性相关,腰围与体重的线性关系最强。我们还观察到,体况评分与体重呈正相关;骡子的性别似乎与体重无关;对于 5 岁以上的骡子,年龄也与体重无关。在下一节中,我们将利用这些发现来构建我们的模型。
模拟骡子的体重
我们想建立一个简单的模型来预测骡子的体重。这个模型应该易于兽医在现场仅使用手算器时实现。模型也应该易于解释。
我们还希望模型能够依赖于兽医的情况——例如,他们是否正在开具抗生素或麻醉药。为了简洁起见,我们只考虑开具麻醉药的情况。我们第一步是选择一个反映这种情况的损失函数。
麻醉药开具的损失函数
麻醉药的过量可能比不足更糟。兽医很容易看出骡子麻醉药不足(它会抱怨),并且兽医可以给骡子再多点。但另一方面,麻醉药过多可能会有严重后果,甚至可能致命。因此,我们需要一个非对称的损失函数:对于体重的过高估计,它的损失应该大于对低估的损失。这与我们到目前为止在本书中使用的其他所有损失函数不同,它们都是对称的。
为了这个目的,我们创建了一个损失函数 anes_loss(x):
`def` `anes_loss``(``x``)``:`
`w` `=` `(``x` `>``=` `0``)` `+` `3` `*` `(``x` `<` `0``)`
`return` `np``.``square``(``x``)` `*` `w`
相对误差为

请注意,x 轴上值为-10 反映了 10%的过高估计。
接下来,让我们使用这个损失函数拟合一个简单 接下来,让我们使用这个损失函数拟合一个简单的线性模型。
拟合一个简单的线性模型
我们发现,腰围在我们的训练集中与体重的相关性最高。所以我们拟合了一个形式为的模型:
要找到数据的最佳拟合
`X` `=` `train_set``.``assign``(``intr``=``1``)``[``[``'``intr``'``,` `'``Girth``'``]``]`
`y` `=` `train_set``[``'``Weight``'``]`
`X`
| intr | Girth | |
|---|---|---|
| 230 | 1 | 116 |
| 74 | 1 | 117 |
| 354 | 1 | 123 |
| ... | ... | ... |
| 157 | 1 | 123 |
| 41 | 1 | 103 |
| 381 | 1 | 106 |
433 rows × 2 columns
现在我们想要找到最小化数据上平均麻醉损失的scipy包中的minimize方法,该方法执行数值优化(见第二十章):
`from` `scipy``.``optimize` `import` `minimize`
`def` `training_loss``(``X``,` `y``)``:`
`def` `loss``(``theta``)``:`
`predicted` `=` `X` `@` `theta`
`return` `np``.``mean``(``anes_loss``(``100` `*` `(``y` `-` `predicted``)` `/` `predicted``)``)`
`return` `loss`
`results` `=` `minimize``(``training_loss``(``X``,` `y``)``,` `np``.``ones``(``2``)``)`
`theta_hat` `=` `results``[``'``x``'``]`
After fitting:
θ₀ = -218.51
θ₁ = 3.16
让我们看看这个简单模型的效果如何。我们可以使用模型来预测训练集上的驴子体重,然后找到预测中的误差。接下来的残差图显示了模型误差占预测值的百分比。相对于驴子的大小来说,预测误差较小更为重要,因为对于 100 公斤的驴子来说,10 公斤的误差比对于 200 公斤的驴子来说要糟糕得多。因此,我们计算每个预测的相对误差:
predicted = X @ theta_hat
resids = 100 * (y - predicted) / predicted
让我们来检查一下相对误差的散点图:
resid = pd.DataFrame({
'Predicted weight (kg)': predicted, 'Percent error': resids})
px.scatter(resid, x='Predicted weight (kg)', y='Percent error',
width=350, height=250)

使用最简单的模型,一些预测偏差达到了 20%至 30%。让我们看看稍微复杂一点的模型是否改善了预测。
拟合多元线性模型
让我们考虑进一步的模型,将其他数字变量纳入考虑。我们有三个数字变量来衡量驴子的腰围、长度和高度,有七种组合这些变量的模型:
[['Girth'],
['Length'],
['Height'],
['Girth', 'Length'],
['Girth', 'Height'],
['Length', 'Height'],
['Girth', 'Length', 'Height']]
对于这些变量组合中的每一个,我们可以使用我们的特殊损失函数来拟合一个模型。然后我们可以查看每个模型在训练集上的表现:
`def` `training_error``(``model``)``:`
`X` `=` `train_set``.``assign``(``intr``=``1``)``[``[``'``intr``'``,` `*``model``]``]`
`theta_hat` `=` `minimize``(``training_loss``(``X``,` `y``)``,` `np``.``ones``(``X``.``shape``[``1``]``)``)``[``'``x``'``]`
`predicted` `=` `X` `@` `theta_hat`
`return` `np``.``mean``(``anes_loss``(``100` `*` `(``y` `-` `predicted``)``/` `predicted``)``)`
`model_risks` `=` `[`
`training_error``(``model``)`
`for` `model` `in` `models`
`]`
| model | mean_training_error | |
|---|---|---|
| 0 | [Girth] | 94.36 |
| 1 | [Length] | 200.55 |
| 2 | [Height] | 268.88 |
| 3 | [Girth, Length] | 65.65 |
| 4 | [Girth, Height] | 86.18 |
| 5 | [Length, Height] | 151.15 |
| 6 | [Girth, Length, Height] | 63.44 |
正如我们之前所述,驴子的腰围是体重的最佳单一预测因子。然而,腰围和长度的组合的平均损失要比仅有腰围的损失要小得多,而且这个特定的双变量模型几乎和包含所有三个变量的模型一样好。由于我们想要一个简单的模型,我们选择了双变量模型而不是三变量模型。
接下来,我们使用特征工程将分类变量纳入模型中,这将改善我们的模型。
将定性特征纳入模型
在我们的探索性分析中,我们发现驴体条件和年龄的箱线图可能包含有助于预测体重的信息。由于这些是分类特征,我们可以将它们转换为 0-1 变量,采用独热编码,如第十五章所述。
独热编码允许我们调整模型中每个类别组合的截距项。我们当前的模型包括数值变量周长和长度:
如果我们将年龄特征清理为三个类别——年龄<2、年龄 2-5和年龄>5——年龄的独热编码将创建三个 0-1 特征,每个类别一个。将独热编码特征包括在模型中得到:
在这个模型中,年龄<2表示小于 2 岁的驴为 1,否则为 0。类似地,年龄 2-5表示 2 至 5 岁的驴为 1,否则为 0。
我们可以将这个模型看作适合三个相同的线性模型,唯一不同的是常数的大小,因为该模型等同于:
现在让我们对我们的三个分类变量(体质、年龄和性别)都应用独热编码:
`X_one_hot` `=` `(`
`train_set``.``assign``(``intr``=``1``)`
`[``[``'``intr``'``,` `'``Length``'``,` `'``Girth``'``,` `'``BCS``'``,` `'``Age``'``,` `'``Sex``'``]``]`
`.``pipe``(``pd``.``get_dummies``,` `columns``=``[``'``BCS``'``,` `'``Age``'``,` `'``Sex``'``]``)`
`.``drop``(``columns``=``[``'``BCS_3.0``'``,` `'``Age_5-10``'``,` `'``Sex_female``'``]``)`
`)`
`X_one_hot`
| intr | 长度 | 周长 | BCS_1.5 | ... | 年龄 _<2 | 年龄 _>20 | 性别 _ 阉割 | 性别 _ 种马 | |
|---|---|---|---|---|---|---|---|---|---|
| 230 | 1 | 101 | 116 | 0 | ... | 0 | 0 | 0 | 1 |
| 74 | 1 | 92 | 117 | 0 | ... | 0 | 0 | 0 | 1 |
| 354 | 1 | 103 | 123 | 0 | ... | 0 | 1 | 0 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 157 | 1 | 93 | 123 | 0 | ... | 0 | 0 | 0 | 1 |
| 41 | 1 | 89 | 103 | 0 | ... | 1 | 0 | 0 | 0 |
| 381 | 1 | 86 | 106 | 0 | ... | 0 | 0 | 0 | 0 |
433 rows × 15 columns
对于每个分类特征,我们删除了一个虚拟变量。由于BCS、Age和Sex分别有六、六和三个类别,因此我们为设计矩阵添加了 12 个虚拟变量,总计 15 列,包括截距项、周长和长度。
让我们看看哪些分类变量,如果有的话,能够改进我们的双变量模型。为此,我们可以拟合包括所有三个分类特征的模型,以及周长和长度的模型:
`results` `=` `minimize``(``training_loss``(``X_one_hot``,` `y``)``,` `np``.``ones``(``X_one_hot``.``shape``[``1``]``)``)`
`theta_hat` `=` `results``[``'``x``'``]`
`y_pred` `=` `X_one_hot` `@` `theta_hat`
`training_error` `=` `(``np``.``mean``(``anes_loss``(``100` `*` `(``y` `-` `y_pred``)``/` `y_pred``)``)``)`
`print``(``f``'``Training error:` `{``training_error``:``.2f``}``'``)`
Training error: 51.47
根据平均损失,这个模型比只包括周长和长度的先前模型表现更好。但是让我们试着简化这个模型,同时保持其准确性。为此,我们查看每个虚拟变量的系数,看看它们是否接近 0,以及彼此之间的情况。换句话说,我们想看到如果将系数包括在模型中会如何改变截距。系数的图表可以轻松地进行比较:

系数证实了我们在箱线图中看到的情况。驴子性别的系数接近零,这意味着知道性别并不真正改变体重预测。我们还看到,将超过 5 岁的驴子的年龄类别合并起来将简化模型而不会损失太多。最后,由于体况评分为 1.5 的驴子数量很少,其系数接近 2 的体况评分,我们倾向于将这两个类别合并。
鉴于这些发现,我们更新设计矩阵:
`def` `combine_bcs``(``X``)``:`
`new_bcs_2` `=` `X``[``'``BCS_2.0``'``]` `+` `X``[``'``BCS_1.5``'``]`
`return` `X``.``assign``(``*``*``{``'``BCS_2.0``'``:` `new_bcs_2``}``)``.``drop``(``columns``=``[``'``BCS_1.5``'``]``)`
`def` `combine_age_and_sex``(``X``)``:`
`return` `X``.``drop``(``columns``=``[``'``Age_10-15``'``,` `'``Age_15-20``'``,` `'``Age_>20``'``,`
`'``Sex_gelding``'``,` `'``Sex_stallion``'``]``)`
`X_one_hot_simple` `=` `(`
`X_one_hot``.``pipe``(``combine_bcs``)`
`.``pipe``(``combine_age_and_sex``)`
`)`
然后我们拟合更简单的模型:
`results` `=` `minimize``(``training_loss``(``X_one_hot_simple``,` `y``)``,`
`np``.``ones``(``X_one_hot_simple``.``shape``[``1``]``)``)`
`theta_hat` `=` `results``[``'``x``'``]`
`y_pred` `=` `X_one_hot_simple` `@` `theta_hat`
`training_error` `=` `(``np``.``mean``(``anes_loss``(``100` `*` `(``y` `-` `y_pred``)``/` `y_pred``)``)``)`
`print``(``f``'``Training error:` `{``training_error``:``.2f``}``'``)`
Training error: 53.20
平均误差与更复杂的模型接近,因此我们决定采用这个更简单的模型。让我们显示系数并总结模型:
| var | theta_hat | |
|---|---|---|
| 0 | intr | -175.25 |
| 1 | 长度 | 1.01 |
| 2 | 腹围 | 1.97 |
| 3 | BCS_2.0 | -6.33 |
| 4 | BCS_2.5 | -5.11 |
| 5 | BCS_3.5 | 7.36 |
| 6 | BCS_4.0 | 20.05 |
| 7 | 年龄 _2-5 | -3.47 |
| 8 | 年龄 _<2 | -6.49 |
我们的模型大致是:
在这个初步的近似之后,我们使用分类特征进行一些调整:
-
BCS 2 或更低?减去 6.5 公斤。
-
BCS 2.5?减去 5.1 公斤。
-
BCS 3.5?增加 7.4 公斤。
-
BCS 4?增加 20 公斤。
-
年龄小于 2 岁?减去 6.5 公斤。
-
年龄在 2 到 5 岁之间?减去 3.5 公斤。
这个模型似乎相当简单易行,因为在我们根据驴子的长度和腹围进行初始估计之后,我们根据几个是/否问题的答案添加或减少一些数字。让我们看看这个模型在预测测试集中驴子的体重方面表现如何。
模型评估
记住,在探索和建模剩余的 80%数据之前,我们将 20%的数据搁置。现在,我们已经准备好将我们从训练集中学到的应用到测试集中。也就是说,我们拿出我们拟合的模型,并用它来预测测试集中驴子的体重。为此,我们需要准备测试集。我们的模型使用驴子的腹围和长度,以及驴子年龄和体况评分的虚拟变量。我们将我们在训练集上的所有转换应用到我们的测试集上:
`y_test` `=` `test_set``[``'``Weight``'``]`
`X_test` `=` `(`
`test_set``.``assign``(``intr``=``1``)`
`[``[``'``intr``'``,` `'``Length``'``,` `'``Girth``'``,` `'``BCS``'``,` `'``Age``'``,` `'``Sex``'``]``]`
`.``pipe``(``pd``.``get_dummies``,` `columns``=``[``'``BCS``'``,` `'``Age``'``,` `'``Sex``'``]``)`
`.``drop``(``columns``=``[``'``BCS_3.0``'``,` `'``Age_5-10``'``,` `'``Sex_female``'``]``)`
`.``pipe``(``combine_bcs``)`
`.``pipe``(``combine_age_and_sex``)`
`)`
我们将我们对设计矩阵的所有操作汇总到我们在训练集建模中确定的最终版本中。现在我们准备使用我们在训练集上拟合的
`y_pred_test` `=` `X_test` `@` `theta_hat`
`test_set_error` `=` `100` `*` `(``y_test` `-` `y_pred_test``)` `/` `y_pred_test`
然后我们可以绘制相对预测误差:

记住,正的相对误差意味着低估重量,这并不像高估重量那样严重。从这个残差图中,我们看到几乎所有的测试集重量都在预测的 10%之内,只有一个超过 10%的误差是在高估的一侧。鉴于我们的损失函数更严厉地惩罚了高估,这是合理的。
另一种散点图显示了实际值和预测值,同时标出了 10%误差线,提供了不同的视角:

对于较大重量的预测线,10%的误差线距离预测线更远。
我们已经实现了我们的目标!我们有一个使用易于获取的测量数据的模型,简单到可以在说明书上解释,并且可以在实际驴重量的预测中保持在 10%范围内。接下来,我们总结这个案例研究并反思我们的模型。
总结
在这个案例研究中,我们展示了建模的不同目的:描述、推理和预测。对于描述,我们寻求一个简单易懂的模型。我们手工制作了这个模型,从分析探索阶段的发现开始。我们每采取一项行动来包含一个特征在模型中,折叠类别或转换特征,都是我们在调查数据时做出的决策。
在对象模拟自然现象如驴的重量时,我们理想地会使用物理模型和统计模型。在这种情况下,物理模型是用圆柱体表示驴的形象。一个好奇的读者可能会指出,我们可以直接使用这个表示来估算驴(圆柱体)的重量,通过其长度和围长(因为围长是
这个物理模型表明,对数转换的重量大致上是围长和长度的线性函数:
鉴于这个物理模型,您可能会想知道为什么我们没有在拟合模型时使用对数或平方变换。我们留给您去更详细地探讨这样的模型。但总的来说,如果测量值的范围较小,那么对数函数大致上是线性的。为了保持我们的模型简单,鉴于围长和重量之间的高相关性,我们选择不进行这些转换。
在这个建模过程中,我们进行了大量的数据挖掘。我们检查了所有可能的模型,这些模型由数值特征的线性组合构建,并检查了虚拟变量的系数,以决定是否合并类别。当我们使用这种迭代方法创建模型时,非常重要的是我们留出数据来评估模型。在新数据上评估模型可以让我们放心地知道我们选择的模型效果如何。我们留出的数据在建模时没有参与任何决策,因此它能很好地帮助我们了解模型在预测上的表现。
我们应该记住之前描述的数据范围及其潜在偏见。我们的模型在测试集上表现良好,但测试集和训练集来自同一数据收集过程。只要新数据的范围保持不变,我们期望我们的模型在实践中表现良好。
最后,这个案例研究展示了模型拟合通常是在简单与复杂之间、物理与统计模型之间取得平衡的过程。物理模型可以作为建模的良好起点,而统计模型则可以为物理模型提供信息。作为数据科学家,我们需要在分析的每一步都做出判断。建模既是艺术又是科学。
本案例研究及其前面几章集中讨论了拟合线性模型的问题。接下来,我们考虑一种不同类型的建模方法,用于解释或预测的响应变量是定性而非定量的情况。
第六部分:分类
第十九章:分类
本章继续探讨数据科学生命周期的第四阶段:拟合和评估模型以理解世界。到目前为止,我们已经描述了如何使用绝对误差拟合常数模型(第四章)以及使用平方误差拟合简单和多元线性模型(第十五章)。我们还拟合了带有不对称损失函数的线性模型(第十八章)和带有正则化损失的线性模型(第十六章)。在所有这些情况下,我们的目标是预测或解释数值结果的行为——公交等待时间、空气中的烟粒子和驴子的体重都是数值变量。
在本章中,我们扩展了对建模的视角。我们不再预测数值结果,而是构建模型来预测名义结果。这些模型使银行能够预测信用卡交易是否欺诈,医生能够将肿瘤分类为良性或恶性,以及您的电子邮件服务能够识别垃圾邮件并将其与常规邮件区分开来。这种类型的建模称为分类,在数据科学中广泛应用。
就像线性回归一样,我们制定一个模型,选择一个损失函数,通过最小化数据的平均损失来拟合模型,并评估拟合的模型。但与线性回归不同的是,我们的模型不是线性的,损失函数也不是平方误差,我们的评估比较不同类型的分类错误。尽管存在这些差异,模型拟合的整体结构在这种情况下仍然适用。回归和分类共同组成了监督学习的主要方法,即基于观察结果和协变量拟合模型的一般任务。
我们首先介绍一个示例,在本章中我们将一直使用它。
例子:受风损坏的树木
1999 年,一场风速超过 90 英里每小时的巨大风暴损坏了边界水道独木舟区野外(BWCAW)中数百万棵树木,该地区是美国东部最大的原始森林地带。为了了解树木对风害的敏感性,名叫罗伊·劳伦斯·里奇的研究人员对 BWCAW 进行了地面调查。在此研究后的几年里,其他研究人员利用这一数据集对风倒(即树木在强风中被连根拔起)进行了建模。
研究对象是 BWCAW 中的树木群落。访问框架是样线:穿过自然景观的直线。这些特定的样线从湖边开始,沿着地形的梯度直角行驶 250 至 400 米。沿着这些样线,调查员每隔 25 米停下来,检查一个 5 乘 5 米的小区。在每个小区,树木被计数,分类为倒伏或直立,以 6 英尺高处的直径测量,并记录它们的种类。
像这样的采样协议在研究自然资源时很常见。在 BWCAW 中,该地区 80%以上的土地距离湖泊不到 500 米,因此几乎覆盖了整个人口。该研究于 2000 年和 2001 年夏季进行,1999 年暴风雨和数据收集之间没有发生其他自然灾害。
收集了 3600 多棵树的测量数据,但在这个例子中,我们仅研究了黑云杉。有 650 多棵。我们读取了这些数据:
`trees` `=` `pd``.``read_csv``(``'``data/black_spruce.csv``'``)`
`trees`
| 直径 | 暴风雨 | 状态 | |
|---|---|---|---|
| 0 | 9.0 | 0.02 | 站立 |
| 1 | 11.0 | 0.03 | 站立 |
| 2 | 9.0 | 0.03 | 站立 |
| ... | ... | ... | ... |
| 656 | 9.0 | 0.94 | 倒下 |
| 657 | 17.0 | 0.94 | 倒下 |
| 658 | 8.0 | 0.98 | 倒下 |
659 rows × 3 columns
每行对应一个单独的树,具有以下属性:
直径
直径为厘米,测量高度在地面以上 6 英尺处的树木
暴风雨
暴风雨的严重程度(25 米宽区域内倒下的树木占比)
状态
树木“倒下”或“站立”
在我们转向建模之前,让我们进行一些探索性分析。首先,我们计算一些简单的摘要统计信息:
`trees``.``describe``(``)``[``3``:``]`
| 直径 | 暴风雨 | |
|---|---|---|
| 最小 | 5.0 | 0.02 |
| 25% | 6.0 | 0.21 |
| 50% | 8.0 | 0.36 |
| 75% | 12.0 | 0.55 |
| 最大 | 32.0 | 0.98 |
基于四分位数,树直径的分布似乎向右倾斜。让我们用直方图比较站立和倒下树木的直径分布:

在暴风雨中倒下的树木直径分布以 12 厘米为中心,呈右偏态。相比之下,站立的树几乎都在 10 厘米以下,众数约为 6 厘米(研究中仅包括直径至少为 5 厘米的树木)。
还有一个要调查的特征是暴风雨的强度。我们将暴风雨强度与树木直径绘制成图,使用符号和标记颜色来区分站立的树木和倒下的树木。由于直径基本上是以厘米为单位测量的,所以许多树木具有相同的直径,因此我们通过为直径值添加一些噪声来减少过度绘制(参见第十一章)。我们还调整了标记颜色的不透明度,以显示图中的密集区域:

从这张图中可以看出,树木直径和暴风雨的强度与风倒有关:树木是被连根拔起还是留在原地。请注意,我们想要预测的风倒是一个名义变量。在下一节中,我们考虑了这如何影响预测问题。
建模和分类
我们希望创建一个解释树木易受风倒的模型。换句话说,我们需要为两级名义特征构建模型:倒下或站立。当响应变量是名义的时,这个建模任务称为分类。在这种情况下只有两个级别,所以这个任务更具体地称为二元分类。
一个常数模型
让我们首先考虑最简单的模型:一个始终预测一类的常数模型。我们使用
在分类中,我们想追踪我们的模型多频繁地预测了正确的类别。现在,我们只是使用正确预测的计数。这有时被称为零一误差,因为损失函数有两个可能的值之一:当进行错误的预测时为 1,进行正确的预测时为 0。对于给定的观察结果
当我们收集了数据
对于常数模型(见第四章),当
就黑云杉而言,我们有以下比例的站立和倒下的树木:
`trees``[``'``status``'``]``.``value_counts``(``)` `/` `len``(``trees``)`
status
standing 0.65
fallen 0.35
Name: count, dtype: float64
所以我们的预测是一棵树站立,而我们数据集的平均损失为
这就是说,这个预测并不特别有用或有见地。例如,在我们对树木数据集进行的 EDA 中,我们发现树木的大小与树木是否站立或倒下有关联。理想情况下,我们可以将这些信息纳入模型,但常数模型不允许我们这样做。让我们对如何将预测因子纳入我们的模型建立一些直觉。
检查尺寸和风倒之间的关系
我们想更仔细地研究树木尺寸与风倒的关系。为了方便起见,我们将名义风倒特征转换为 0-1 数字特征,其中 1 表示倒下的树木,0 表示站立的树木:
`trees``[``'``status_0_1``'``]` `=` `(``trees``[``'``status``'``]` `==` `'``fallen``'``)``.``astype``(``int``)`
`trees`
| 直径 | 风暴 | 状态 | 状态 _0_1 | |
|---|---|---|---|---|
| 0 | 9.0 | 0.02 | 站立 | 0 |
| 1 | 11.0 | 0.03 | 站立 | 0 |
| 2 | 9.0 | 0.03 | 站立 | 0 |
| ... | ... | ... | ... | ... |
| 656 | 9.0 | 0.94 | 倒下 | 1 |
| 657 | 17.0 | 0.94 | 倒下 | 1 |
| 658 | 8.0 | 0.98 | 倒下 | 1 |
659 rows × 4 columns
这种表示在许多方面都是有用的。例如,status_0_1 的平均值是数据集中倒下的树木比例:
`pr_fallen` `=` `np``.``mean``(``trees``[``'``status_0_1``'``]``)`
`print``(``f``"``Proportion of fallen black spruce:` `{``pr_fallen``:``0.2f``}``"``)`
Proportion of fallen black spruce: 0.35
具备这种 0-1 特征,我们可以制作一张图来展示树木直径与风倒之间的关系。这类似于我们进行线性回归的过程,其中我们绘制结果变量与解释变量之间的散点图(参见 第十五章)。
在这里,我们将树木状态绘制为直径的函数,但是我们在状态中添加了一点随机噪声,以帮助我们查看每个直径处 0 和 1 值的密度。与之前一样,我们也会扰动直径值,并调整标记的不透明度以减少重叠绘制。我们还在倒下的树木比例处添加了一条水平线:

这个散点图显示较小的树更有可能直立,而较大的树更有可能倒下。请注意,树木的平均状态(0.35)基本上适合将一个恒定模型应用于响应变量。如果我们将树木直径视为一个解释特征,我们应该能够改进模型。
一个起点可能是计算不同直径树木的倒下比例。以下代码块将树木直径分成区间,并计算每个区间内倒下的树木比例:
`splits` `=` `[``4``,` `5``,` `6``,` `7``,` `8``,` `9``,` `10``,` `12``,` `14``,` `17``,` `20``,` `25``,` `32``]`
`tree_bins` `=` `(`
`trees``[``"``status_0_1``"``]`
`.``groupby``(``pd``.``cut``(``trees``[``"``diameter``"``]``,` `splits``)``)`
`.``agg``(``[``"``mean``"``,` `"``count``"``]``)`
`.``rename``(``columns``=``{``"``mean``"``:` `"``proportion``"``}``)`
`.``assign``(``diameter``=``lambda` `df``:` `[``i``.``right` `for` `i` `in` `df``.``index``]``)`
`)`
我们可以将这些比例绘制成树木直径的函数图:

标记的大小反映了直径区间内树木的数量。我们可以利用这些比例来改进我们的模型。例如,对于直径为 6 厘米的树木,我们会将其分类为直立,而对于 20 厘米的树木,我们的分类则是倒下的。二元分类的一个自然起点是对观察到的比例进行建模,然后利用这些比例进行分类。接下来,我们为这些比例开发一个模型。
建模比例(和概率)
回顾一下,当我们建模时,我们需要选择三样东西:一个模型,一个损失函数,以及一种方法来最小化训练集上的平均损失。在前一节中,我们选择了一个恒定模型,0-1 损失,并进行了一些适合模型的证明。然而,这个恒定模型并没有包含预测变量。在本节中,我们通过引入一个称为逻辑模型的新模型来解决这个问题。
为了推动这些模型,注意到树木直径与倒下树木比例之间的关系似乎不是线性的。为了演示,让我们对这些数据拟合一个简单的线性模型,以显示它具有几个不良特征。使用 第十五章 中的技术,我们对树木状态与直径进行了线性模型的拟合:
`from` `sklearn``.``linear_model` `import` `LinearRegression`
`X` `=` `trees``[``[``'``diameter``'``]``]`
`y` `=` `trees``[``'``status_0_1``'``]`
`lin_reg` `=` `LinearRegression``(``)``.``fit``(``X``,` `y``)`
然后,我们将这条拟合线添加到比例散点图中:

显然,模型对比例的拟合效果并不理想。存在几个问题:
-
模型对大树给出大于 1 的比例。
-
模型没有捕捉到比例中的曲线特征。
-
极端点(例如直径为 30 厘米的树木)将拟合线向右移动,远离大部分数据。
为了解决这些问题,我们引入了逻辑模型。
逻辑模型
逻辑模型是最广泛使用的基础分类模型之一,是线性模型的简单扩展。逻辑函数,通常称为sigmoid 函数,定义如下:
警告
Sigmoid函数通常用
我们可以绘制逻辑函数以显示其 S 形状,并确认其输出在 0 到 1 之间。函数随着
`def` `logistic``(``t``)``:`
`return` `1.` `/` `(``1.` `+` `np``.``exp``(``-``t``)``)`
由于逻辑函数映射到 0 到 1 之间的区间,通常用于建模比例和概率。此外,我们可以将逻辑写成线性函数的形式,如
为了帮助你直观地理解该函数的形状,下图显示了我们变化

我们可以看到改变
逻辑函数可以看作是一种转换:将线性函数转换为非线性平滑曲线,其输出始终位于 0 到 1 之间。实际上,逻辑函数的输出具有更深层次的概率解释,接下来我们将描述它。
对数几率
记住,赔率是概率
我们可以在以下方程中看到这一点。为了展示这一点,我们将 Sigmoid 函数的分子和分母分别乘以
然后我们取对数几率并简化:
因此,对于
以对数几率的形式表示 logistic 对于系数
我们看到几率增加或减少了
注意
这里,
接下来,让我们在我们的比例图中添加一个 logistic 曲线,以了解它对数据的拟合效果。
使用 Logistic 曲线
在下面的图中,我们在倒下的树木比例的图上添加了一个 logistic 曲线:

我们可以看到曲线相对比例很好。事实上,我们选择了这个特定的 logistic 通过将其拟合到数据。拟合的 logistic 回归是:
σ(-7.4 + 3.0x)
现在我们已经看到 logistic 曲线可以很好地建模概率,我们转向将 logistic 曲线拟合到数据的过程。在下一节中,我们继续我们建模的第二步:选择一个合适的损失函数。
Logistic 模型的损失函数
Logistic 模型给我们提供了概率(或经验比例),因此我们将损失函数写成
再次使用 0 和 1 来表示类别具有优势,因为我们可以方便地写出损失函数为:
我们鼓励你通过考虑
Logistic 模型与对数损失配合得很好:
注意 log 损失在 0 和 1 处未定义,因为

当
如果我们的目标是使用 log 损失对数据拟合一个常数,那么平均损失为:
这里
然后我们将导数设置为 0 并解出最小化值
(最终的方程来自于注意到
要基于逻辑函数拟合更复杂的模型,我们可以将
对数据的损失进行平均,我们得到:
与平方损失不同,此损失函数没有封闭形式的解。我们使用像梯度下降这样的迭代方法(见第二十章)来最小化平均损失。这也是我们不在逻辑模型中使用平方误差损失的原因之一——平均平方误差是非凸的,这使得优化变得困难。凸性的概念在第二十章有更详细的讨论,图 20-4 提供了直观的图示。
注意
log 损失也称为逻辑损失和交叉熵损失。它的另一个名称是负对数似然。这个名称指的是使用似然性来拟合模型的技术,即我们的数据来自于某个概率分布的似然性。在这里我们不深入探讨这些替代方法的背景。
拟合逻辑模型(使用 log 损失)被称为逻辑回归。逻辑回归是广义线性模型的一个示例,它是带有非线性变换的线性模型。
我们可以使用 scikit-learn 来拟合逻辑模型。包的设计者使 API 与最小二乘法拟合线性模型非常相似(见第十五章)。首先,我们导入逻辑回归模块:
`from` `sklearn``.``linear_model` `import` `LogisticRegression`
然后,我们用结果 y,即树的状态,和协变量 X,即直径(我们已对其进行了对数变换),设置回归问题:
`trees``[``'``log_diam``'``]` `=` `np``.``log``(``trees``[``'``diameter``'``]``)`
`X` `=` `trees``[``[``'``log_diam``'``]``]`
`y` `=` `trees``[``'``status_0_1``'``]`
然后,我们拟合逻辑回归,并检查直径的截距和系数:
`lr_model` `=` `LogisticRegression``(``)`
`lr_model``.``fit``(``X``,` `y``)`
`[``intercept``]` `=` `lr_model``.``intercept_`
`[``[``coef``]``]` `=` `lr_model``.``coef_`
`print``(``f``'``Intercept:` `{``intercept``:``.1f``}``'``)`
`print``(``f``'``Diameter coefficient:` `{``coef``:``.1f``}``'``)`
Intercept: -7.4
Diameter coefficient: 3.0
在进行预测时,predict 函数返回预测的(最可能的)类别,而 predict_proba 返回预测的概率。对于直径为 6 的树,我们预计预测为 0(即 站立 )的概率很高。我们来检查一下:
`diameter6` `=` `pd``.``DataFrame``(``{``'``log_diam``'``:` `[``np``.``log``(``6``)``]``}``)`
`[``pred_prof``]` `=` `lr_model``.``predict_proba``(``diameter6``)`
`print``(``f``'``Predicted probabilities:` `{``pred_prof``}``'``)`
Predicted probabilities: [0.87 0.13]
因此,模型预测直径为 6 的树以 站立 类别有 0.87 的概率,以 倒下 类别有 0.13 的概率。
现在我们已经用一个特征拟合了一个模型,我们可能想要看看是否包含另一个特征,比如风暴的强度,是否可以改善模型。为此,我们可以通过将一个特征添加到 X 中,并再次拟合模型来拟合多元逻辑回归。
请注意,逻辑回归拟合一个模型来预测概率——模型预测直径为 6 的树以 站立 类别有 0.87 的概率,以 倒下 类别有 0.13 的概率。由于概率可以是介于 0 和 1 之间的任何数,我们需要将概率转换回类别以执行分类。我们将在下一节中解决这个分类问题。
从概率到分类
我们在本章开始时介绍了一个二元分类问题,我们想要建模一个名义响应变量。到目前为止,我们已经使用逻辑回归来建模比例或概率,现在我们准备返回原始问题:我们使用预测的概率来分类记录。对于我们的例子,这意味着对于特定直径的树,我们使用逻辑回归中拟合的系数来估计其倒下的可能性。如果可能性很高,我们将树分类为倒下;否则,我们将其分类为站立。但是我们需要选择一个阈值来制定这个 决策规则。
sklearn 的逻辑回归模型的 predict 函数实现了基本的决策规则:如果预测的概率 1 。否则,预测 0 。我们将这个决策规则以虚线叠加在模型预测之上:

在本节中,我们考虑一个更一般的决策规则。对于某些选择的 1 ,否则预测 0 。默认情况下,sklearn 设置
选择适当的
`def` `threshold_predict``(``model``,` `X``,` `threshold``)``:`
`return` `np``.``where``(``model``.``predict_proba``(``X``)``[``:``,` `1``]` `>` `threshold``,` `1.0``,` `0.0``)`
`def` `accuracy``(``threshold``,` `X``,` `y``)``:`
`return` `np``.``mean``(``threshold_predict``(``lr_model``,` `X``,` `threshold``)` `==` `y``)`
`thresholds` `=` `np``.``linspace``(``0``,` `1``,` `200``)`
`accs` `=` `[``accuracy``(``t``,` `X``,` `y``)` `for` `t` `in` `thresholds``]`
要理解准确率如何随

注意,具有最高准确率的阈值并不完全在 0.5 处。在实践中,我们应该使用交叉验证来选择阈值(参见第十六章)。
最大化准确率的阈值可能不是 0.5,这有许多原因,但一个常见的原因是类别不平衡,其中一个类别比另一个类别频繁。类别不平衡可能导致模型将记录分类为更常见的类别。在极端情况下(如欺诈检测),当数据中只有很小一部分包含特定类别时,我们的模型可以通过始终预测频繁类别而不学习如何生成适合稀有类别的好分类器来实现高准确率。有一些管理类别不平衡的技术,例如:
-
对数据进行重新采样以减少或消除类别不平衡
-
调整损失函数以对较小类别施加更大的惩罚
在我们的示例中,类别不平衡并不那么严重,因此我们继续进行而不进行这些调整。
类别不平衡问题解释了为什么单靠准确率通常不是我们评判模型的方式。相反,我们希望区分不同类型的正确和错误分类。我们接下来描述这些内容。
混淆矩阵
在二元分类中可视化错误的一个方便方法是查看混淆矩阵。混淆矩阵比较模型预测与实际结果。在这种情况下存在两种类型的错误:
假阳性
当实际类别为 0(错误)但模型预测为 1(真实)
假阴性
当实际类别为 1(真实)但模型预测为 0(错误)
理想情况下,我们希望尽量减少两种错误,但我们经常需要平衡这两种来源之间的关系。
注意
“正面”和“负面”这些术语来自于疾病测试,其中指示存在疾病的测试被称为正面结果。这可能有点令人困惑,因为患病似乎并不是一件积极的事情。而
scikit-learn有一个函数来计算和绘制混淆矩阵:
`from` `sklearn``.``metrics` `import` `confusion_matrix`
`mat` `=` `confusion_matrix``(``y``,` `lr_model``.``predict``(``X``)``)`
`mat`
array([[377, 49],
[104, 129]])

理想情况下,我们希望在对角方格中看到所有计数 True negative 和 True positive。这意味着我们已经正确分类了所有内容。但这很少见,我们需要评估错误的规模。为此,比较率而不是计数更容易。接下来,我们描述不同的率以及何时可能更喜欢优先考虑其中之一。
精度与召回率
在某些情况下,错过阳性案例的成本可能会更高。例如,如果我们正在构建一个用于识别肿瘤的分类器,我们希望确保不会错过任何恶性肿瘤。相反,我们不太关心将良性肿瘤分类为恶性,因为病理学家仍然需要仔细查看以验证恶性分类。在这种情况下,我们希望在实际上为阳性的记录中具有很高的真阳性率。该率称为敏感度或召回率:
较高的召回率会冒着将假记录预测为真的风险(假阳性)。
另一方面,当将电子邮件分类为垃圾邮件(阳性)或非垃圾邮件(阴性)时,如果一封重要的电子邮件被放入垃圾邮件文件夹中,我们可能会感到烦恼。在这种情况下,我们希望有高的精度,即模型对于阳性预测的准确性:
较高精度的模型通常更有可能预测真实观察结果为负(更高的假阴性率)。
常见的分析比较不同阈值下的精度和召回率:
`from` `sklearn` `import` `metrics`
`precision``,` `recall``,` `threshold` `=` `(`
`metrics``.``precision_recall_curve``(``y``,` `lr_model``.``predict_proba``(``X``)``[``:``,` `1``]``)``)`
`tpr_df` `=` `pd``.``DataFrame``(``{``"``threshold``"``:``threshold``,`
`"``precision``"``:``precision``[``:``-``1``]``,` `"``recall``"``:` `recall``[``:``-``1``]``,` `}``)`
为了查看精度和召回率之间的关系,我们将它们都绘制在阈值

另一个评估分类器性能的常见图表是精度-召回率曲线,简称 PR 曲线。它绘制了每个阈值的精度-召回率对:
`fig` `=` `px``.``line``(``tpr_df``,` `x``=``"``recall``"``,` `y``=``"``precision``"``,`
`labels``=``{``"``recall``"``:``"``Recall``"``,``"``precision``"``:``"``Precision``"``}``)`
`fig``.``update_layout``(``width``=``450``,` `height``=``250``,` `yaxis_range``=``[``0``,` `1``]``)`
`fig`

注意,曲线的右端反映了样本中的不平衡性。精度与样本中倒下树木的比例相匹配,为 0.35。为不同模型绘制多个 PR 曲线可以帮助比较模型。
使用精度和召回率使我们能够更好地控制哪种类型的错误更重要。例如,假设我们想确保至少 75%的倒下树木被分类为倒下。我们可以找到发生这种情况的阈值:
`fall75_ind` `=` `np``.``argmin``(``recall` `>``=` `0.75``)` `-` `1`
`fall75_threshold` `=` `threshold``[``fall75_ind``]`
`fall75_precision` `=` `precision``[``fall75_ind``]`
`fall75_recall` `=` `recall``[``fall75_ind``]`
Threshold: 0.33
Precision: 0.59
Recall: 0.81
我们发现约 41%(1 - 精度)的我们分类为倒下的树实际上是站立的。此外,我们发现低于此阈值的树木比例为:
`print``(``"``Proportion of samples below threshold:``"``,`
`f``"``{``np``.``mean``(``lr_model``.``predict_proba``(``X``)``[``:``,``1``]` `<` `fall75_threshold``)``:``0.2f``}``"``)`
Proportion of samples below threshold: 0.52
因此,我们已将 52%的样本分类为站立(负面)。特异性(也称为真负率)衡量分类器将属于负类的数据标记为负类的比例:
我们的阈值的特异性为:
`act_neg` `=` `(``y` `==` `0``)`
`true_neg` `=` `(``lr_model``.``predict_proba``(``X``)``[``:``,``1``]` `<` `fall75_threshold``)` `&` `act_neg`
Specificity: 0.70
换句话说,70%的被分类为站立的树实际上是站立的。
如我们所见,有几种使用 2x2 混淆矩阵的方法。理想情况下,我们希望准确率、精确率和召回率都很高。这种情况发生在大多数预测落在表格的对角线上,因此我们的预测几乎全部正确——真负类和真正类。不幸的是,在大多数情况下,我们的模型会有一定程度的错误。在我们的例子中,相同直径的树木包括倒下的和站立的混合,因此我们不能完美地根据它们的直径分类树木。在实践中,当数据科学家选择一个阈值时,他们需要考虑自己的背景来决定是优先考虑精确率、召回率还是特异性。
总结
在本章中,我们用一个解释变量拟合简单的逻辑回归,但是我们可以通过将更多特征添加到我们的设计矩阵中来轻松地包含模型中的其他变量。例如,如果某些预测变量是分类的,我们可以将它们作为独热编码特征包含进来。这些想法直接延续自第十五章。正则化技术(来自第十六章)也适用于逻辑回归。我们将在第二十一章的案例研究中整合所有这些建模技术——包括使用训练集-测试集分割来评估模型和交叉验证来选择阈值——以开发一个用于分类假新闻的模型。
逻辑回归是机器学习中的基石,因为它自然地扩展到更复杂的模型中。例如,逻辑回归是神经网络的基本组成部分之一。当响应变量有多于两个类别时,逻辑回归可以扩展为多项逻辑回归。适用于建模计数的逻辑回归的另一个扩展称为泊松回归。这些不同形式的回归与最大似然密切相关,其中响应的潜在模型分别为二项式、多项式或泊松分布,目标是优化参数的数据似然。这些模型家族也被称为广义线性模型。在所有这些场景中,不存在用于最小化损失的封闭形式解决方案,因此平均损失的优化依赖于数值方法,我们将在下一章中介绍。
第二十章:数值优化
在本书的这一部分,我们的建模过程应该感到很熟悉:我们定义一个模型,选择一个损失函数,并通过最小化训练数据上的平均损失来拟合模型。我们已经看到了几种最小化损失的技术。例如,我们在第十五章中使用了微积分和几何论证,找到了使用平方损失拟合线性模型的简单表达式。
但经验损失最小化并不总是那么简单。Lasso 回归在平均平方损失中加入
当我们在第四章介绍损失函数时,我们执行了一个简单的数值优化,以找到平均损失的最小化者。我们创建了一个
-
对于具有许多特征的复杂模型,网格变得难以管理。对于仅具有四个特征和每个特征 100 个值的网格,我们必须评估
个网格点上的平均损失。100 4 = 100,000,000 -
必须事先指定要搜索的参数值范围,以创建网格。当我们对范围没有很好的感觉时,我们需要从一个宽网格开始,可能需要在更窄的范围内重复网格搜索。
-
对于大量观测值,评估网格点上的平均损失可能会很慢。

图 20-1. 在网格点上搜索可能计算速度慢或不准确
在本章中,我们介绍利用损失函数的形状和平滑性寻找最小化参数值的数值优化技术。我们首先介绍梯度下降技术的基本思想,然后给出一个示例,描述使梯度下降起作用的损失函数的特性,最后提供了梯度下降的几个扩展。
梯度下降基础知识
梯度下降基于以下观念:对于许多损失函数,函数在参数的小邻域内大致是线性的。图 20-2 给出了这个基本思想的示意图。

图 20-2. 梯度下降技术是向最小化参数值的方向进行小增量移动的技术。
在图中,我们画出了损失曲线
因此,向
当我们根据切线斜率的正负指示重复采取小步骤时,这导致平均损失值越来越小,最终使我们接近或达到最小化值
更正式地说,为了最小化一般参数向量
注意
梯度下降算法的步骤如下:
-
选择一个起始值,称为
(一个常见的选择是θ ( 0 ) )。θ ( 0 ) = 0 -
计算
。θ ( t + 1 ) = θ ( t ) − α g ( θ ) -
重复步骤 2 直到
不再改变(或变化很小)为止。θ ( t + 1 )
数量

图 20-3. 较小的学习率需要许多步骤才能收敛(左),较大的学习率可能会发散(右);选择适当的学习率可以快速收敛到最小值(中)。
梯度下降算法简单而强大,因为我们可以用它来拟合许多类型的模型和许多类型的损失函数。它是拟合许多模型的计算工具首选,包括大数据集上的线性回归和逻辑回归。接下来,我们演示使用该算法来拟合巴士延误数据中的常量(来自 第四章)。
最小化 Huber 损失
Huber 损失 结合了绝对损失和平方损失,得到一个既可微(像平方损失)又对异常值不那么敏感(像绝对损失)的函数:
由于 Huber 损失是可微的,我们可以使用梯度下降。我们首先找到平均 Huber 损失的梯度:
我们创建了 huber_loss 和 grad_huber_loss 函数来计算平均损失及其梯度。我们编写这些函数时签名设计使我们能够指定参数以及我们平均的观察数据和损失函数的转折点:
`def` `huber_loss``(``theta``,` `dataset``,` `gamma``=``1``)``:`
`d` `=` `np``.``abs``(``theta` `-` `dataset``)`
`return` `np``.``mean``(`
`np``.``where``(``d` `<``=` `gamma``,`
`(``theta` `-` `dataset``)``*``*``2` `/` `2.0``,`
`gamma` `*` `(``d` `-` `gamma` `/` `2.0``)``)`
`)`
`def` `grad_huber_loss``(``theta``,` `dataset``,` `gamma``=``1``)``:`
`d` `=` `np``.``abs``(``theta` `-` `dataset``)`
`return` `np``.``mean``(`
`np``.``where``(``d` `<``=` `gamma``,`
`-``(``dataset` `-` `theta``)``,`
`-``gamma` `*` `np``.``sign``(``dataset` `-` `theta``)``)`
`)`
接下来,我们编写了梯度下降的简单实现。我们的函数签名包括损失函数、其梯度和要平均的数据。我们还提供学习率。
`def` `minimize``(``loss_fn``,` `grad_loss_fn``,` `dataset``,` `alpha``=``0.2``,` `progress``=``False``)``:`
`'''`
`Uses gradient descent to minimize loss_fn. Returns the minimizing value of`
`theta_hat once theta_hat changes less than 0.001 between iterations.`
`'''`
`theta` `=` `0`
`while` `True``:`
`if` `progress``:`
`print``(``f``'``theta:` `{``theta``:``.2f``}` `| loss:` `{``loss_fn``(``theta``,` `dataset``)``:``.3f``}``'``)`
`gradient` `=` `grad_loss_fn``(``theta``,` `dataset``)`
`new_theta` `=` `theta` `-` `alpha` `*` `gradient`
`if` `abs``(``new_theta` `-` `theta``)` `<` `0.001``:`
`return` `new_theta`
`theta` `=` `new_theta`
请回想一下,公交车延误数据集包含超过 1,000 个测量值,即北行 C 线公交车在抵达西雅图第三大道和派克街站点时晚多少分钟:
`delays` `=` `pd``.``read_csv``(``'``data/seattle_bus_times_NC.csv``'``)`
在 第四章 中,我们为这些数据拟合了一个常数模型,以得到绝对损失和平方损失。我们发现绝对损失产生了数据的中位数,而平方损失产生了数据的均值:
`print``(``f``"``Mean:` `{``np``.``mean``(``delays``[``'``minutes_late``'``]``)``:``.3f``}``"``)`
`print``(``f``"``Median:` `{``np``.``median``(``delays``[``'``minutes_late``'``]``)``:``.3f``}``"``)`
Mean: 1.920
Median: 0.742
现在我们使用梯度下降算法来找到最小化 Huber 损失的常数模型:
`%``%``time`
`theta_hat` `=` `minimize``(``huber_loss``,` `grad_huber_loss``,` `delays``[``'``minutes_late``'``]``)`
`print``(``f``'``Minimizing theta:` `{``theta_hat``:``.3f``}``'``)`
`print``(``)`
Minimizing theta: 0.701
CPU times: user 93 ms, sys: 4.24 ms, total: 97.3 ms
Wall time: 140 ms
Huber 损失的优化常数接近最小化绝对损失的值。这是由于 Huber 损失函数的形状决定的。它在尾部是线性的,因此不像绝对损失那样受到异常值的影响,也不像平方损失那样受到影响。
警告
我们编写了我们的 minimize 函数来演示算法背后的思想。在实践中,您应该使用经过充分测试的、数值上稳定的优化算法的实现。例如,scipy 包中有一个 minimize 方法,我们可以用它来找到平均损失的最小化器,甚至不需要计算梯度。这个算法可能比我们可能编写的任何一个算法都要快得多。事实上,我们在 第十八章 中使用它来创建我们自己的二次损失的非对称修改,特别是在我们希望损失对于最小值的一侧的错误更大而另一侧的影响较小的特殊情况下。
更一般地,我们通常在迭代之间
当我们无法通过解析方法轻松求解最小值或者最小化计算成本很高时,梯度下降给了我们一个通用的最小化平均损失的方法。该算法依赖于平均损失函数的两个重要属性:它在
凸和可微损失函数
正如其名字所示,梯度下降算法要求被最小化的函数是可微的。梯度
最小值的逐步搜索也依赖于损失函数是凸函数。左图中的函数是凸的,但右图中的函数不是。右图中的函数有一个局部最小值,根据算法开始的位置,它可能会收敛到这个局部最小值并完全错过真正的最小值。凸性质避免了这个问题。凸函数避免了局部最小值的问题。所以,通过适当的步长,梯度下降可以找到任何凸、可微函数的全局最优解

图 20-4。对于非凸函数(右图),梯度下降可能会找到局部最小值而不是全局最小值,而凸函数(左图)不可能出现这种情况。
形式上,函数
这个不等式意味着连接函数的任意两个点的线段必须位于或位于函数本身之上。从启发式的角度来看,这意味着无论我们在梯度为负时向右走还是在梯度为正时向左走,只要我们采取足够小的步伐,我们就会朝向函数的最小值方向前进。
凸性的正式定义为我们提供了确定一个函数是否为凸函数的精确方式。我们可以利用这个定义来将平均损失函数
其中
如果
现在,有了大量数据,计算
梯度下降的变体
梯度下降的两个变体,随机梯度下降和小批量梯度下降,在计算平均损失的梯度时使用数据子集,并且对于具有大型数据集的优化问题很有用。第三个选择是牛顿法,它假设损失函数是两次可微的,并且使用损失函数的二次近似,而不是梯度下降中使用的线性近似。
回顾一下,梯度下降是根据梯度采取步骤的。在步骤
由于
这种根据数据中每个点处损失的梯度的平均来表示平均损失梯度的方法说明了为什么这个算法也被称为批梯度下降。批梯度下降的两个变体使用较小数量的数据而不是完整的“批次”。第一个,随机梯度下降,在算法的每一步中只使用一个观察。
随机梯度下降
尽管批梯度下降通常能在相对较少的迭代中找到最优的
简而言之,为了进行随机梯度下降,我们将平均梯度替换为单个数据点处的梯度。因此,更新后的公式就是:
在这个公式中,
我们通常通过随机重新排列所有数据点并按照它们的重新排列顺序使用每个点,直到完成一整个数据的遍历来运行随机梯度下降。如果算法尚未收敛,那么我们会重新洗牌数据并再次遍历数据。每个迭代的随机梯度下降看一个数据点;每个完整的数据遍历称为epoch。
由于随机下降每次只检查一个数据点,有时会朝着极小化器
小批量梯度下降
正如其名称所示,小批量梯度下降 在批量梯度下降和随机梯度下降之间取得平衡,通过在每次迭代中随机选择更多的观测值来增加样本数。在小批量梯度下降中,我们对少量数据点的损失函数梯度进行平均,而不是单个点或所有点的梯度。我们让
与随机梯度下降类似,我们通过随机洗牌数据来执行小批量梯度下降。然后我们将数据分割成连续的小批次,并按顺序迭代这些批次。每个 epoch 后,重新洗牌数据并选择新的小批次。
虽然我们已经区分了随机梯度下降和小批量梯度下降,随机梯度下降 有时被用作一个总称,包括任意大小的小批次的选择。
另一种常见的优化技术是牛顿法。
牛顿法
牛顿法利用二阶导数优化损失。其基本思想是在
其中
对
图 20-5 展示了牛顿法优化的思想。

图 20-5。牛顿法使用对曲线的局部二次逼近来朝着凸、两次可微函数的最小值迈出步伐
此技术在逼近准确且步长小的情况下会快速收敛。否则,牛顿法可能会发散,这通常发生在函数在某个维度上几乎平坦的情况下。当函数相对平坦时,导数接近于零,其倒数可能非常大。大步长可能会移动到离逼近准确点很远的
摘要
在本章中,我们介绍了几种利用损失函数的形状和平滑性进行数值优化的技术,以搜索最小化参数值。我们首先介绍了梯度下降,它依赖于损失函数的可微性。梯度下降,也称为批量梯度下降,通过迭代改善模型参数,直到模型达到最小损失。由于批量梯度下降在处理大数据集时计算复杂度高,我们通常改用随机梯度下降来拟合模型。
小批量梯度下降在运行在某些计算机上找到的图形处理单元(GPU)芯片时最为优化。由于这些硬件类型可以并行执行计算,使用小批量可以提高梯度的准确性,而不增加计算时间。根据 GPU 的内存大小,小批量大小通常设置在 10 到 100 个观测之间。
或者,如果损失函数是二次可微的,则牛顿法可以非常快速地收敛,尽管在迭代中计算一步较为昂贵。混合方法也很受欢迎,先用梯度下降(某种类型),然后切换算法至牛顿法。这种方法可以避免发散,并且比单独使用梯度下降更快。通常,在最优点附近,牛顿法使用的二阶近似更为合适且收敛速度快。
最后,另一个选项是自适应设置步长。此外,如果不同特征的规模不同或频率不同,则设置不同的学习率可能很重要。例如,单词计数在常见单词和罕见单词之间可能会有很大差异。
在第十九章介绍的逻辑回归模型是通过本章描述的数值优化方法拟合的。我们最后介绍了一个案例研究,使用逻辑回归来拟合一个具有数千个特征的复杂模型。
第二十一章:案例研究:检测假新闻
假新闻——为了欺骗他人而创造的虚假信息——是一个重要问题,因为它可能会伤害人们。例如,社交媒体上的帖子在 图 21-1 中自信地宣称手部消毒剂对冠状病毒无效。尽管事实不确,但它还是通过社交媒体传播开来:被分享了近 10 万次,很可能被数百万人看到。

图 21-1. 2020 年 3 月推特上流行的一条帖子错误地声称消毒剂不能杀死冠状病毒。
我们可能会想知道是否可以在不阅读故事的情况下自动检测假新闻。对于这个案例研究,我们遵循数据科学生命周期的步骤。我们首先细化我们的研究问题并获取新闻文章和标签的数据集。然后我们清理和转换数据。接下来,我们探索数据以理解其内容,并设计用于建模的特征。最后,我们使用逻辑回归构建模型,预测新闻文章是否真实或虚假,并评估其性能。
我们包括这个案例研究是因为它让我们重申数据科学中的几个重要概念。首先,自然语言数据经常出现,即使是基本技术也能进行有用的分析。其次,模型选择是数据分析的重要部分,在这个案例研究中,我们应用了交叉验证、偏差-方差权衡和正则化的学习成果。最后,即使在测试集上表现良好的模型,在实际应用中也可能存在固有限制,我们很快就会看到。
让我们首先细化我们的研究问题,并理解我们数据的范围。
问题和范围
我们最初的研究问题是:我们能自动检测假新闻吗?为了细化这个问题,我们考虑了用于建立检测假新闻模型的信息类型。如果我们手动分类了新闻故事,人们已阅读每个故事并确定其真假,那么我们的问题变成了:我们能否建立一个模型来准确预测新闻故事是否为假的,基于其内容?
为了解决这个问题,我们可以使用 FakeNewsNet 数据库,如 Shu et al 所述。该数据库包含来自新闻和社交媒体网站的内容,以及用户参与度等元数据。为简单起见,我们只查看数据集的政治新闻文章。该数据子集仅包括由 Politifact 进行事实检查的文章,Politifact 是一个声誉良好的非党派组织。数据集中的每篇文章都有基于 Politifact 评估的“真实”或“虚假”标签,我们将其用作基准真实性。
Politifact 使用非随机抽样方法选择文章进行事实核查。根据其网站,Politifact 的记者每天选择“最有新闻价值和重要性”的主张。Politifact 始于 2007 年,存储库发布于 2020 年,因此大多数文章发布于 2007 年到 2020 年之间。
总结这些信息,我们确定目标人群包括所有在线发布的政治新闻故事,时间跨度从 2007 年到 2020 年(我们也想列出这些故事的来源)。访问框架由 Politifact 确定,标识出当天最有新闻价值的主张。因此,这些数据的主要偏见来源包括:
覆盖偏见
新闻媒体仅限于 Politifact 监控的那些,这可能会忽略奥秘或短暂存在的网站。
选择偏见
数据仅限于 Politifact 认为足够有趣以进行事实核查的文章,这意味着文章可能偏向于广泛分享和具有争议性的文章。
测量偏见
故事是否应标记为“假”或“真”由一个组织(Politifact)决定,并反映了该组织在其事实核查方法中存在的偏见,无论是有意还是无意。
漂移
由于我们只有 2007 年到 2020 年间发布的文章,内容可能会有些漂移。话题在快速发展的新闻趋势中被推广和伪造。
我们在开始整理数据之前,会牢记这些数据的限制,以便将其整理成可分析的形式。
获取和整理数据
让我们使用FakeNewsNet 的 GitHub 页面将数据导入 Python。阅读存储库描述和代码后,我们发现该存储库实际上并不存储新闻文章本身。相反,运行存储库代码将直接从在线网页上抓取新闻文章(使用我们在第十四章中介绍的技术)。这带来了一个挑战:如果一篇文章不再在网上可用,那么它很可能会在我们的数据集中丢失。注意到这一点后,让我们继续下载数据。
注意
FakeNewsNet 代码突显了可重复研究中的一个挑战——在线数据集随时间变化,但如果在存储库中存储和共享这些数据可能会面临困难(甚至违法)。例如,FakeNewsNet 数据集的其他部分使用 Twitter 帖子,但如果创建者在其存储库中存储帖子副本则会违反 Twitter 的条款和服务。在处理从网络收集的数据时,建议记录数据收集日期并仔细阅读数据来源的条款和服务。
运行脚本下载 Politifact 数据大约需要一个小时。之后,我们将数据文件放入data/politifact文件夹中。Politifact 标记为假和真的文章分别位于data/politifact/fake和data/politifact/real文件夹中。让我们看一看其中一个标记为“真实”的文章:
`!`ls -l data/politifact/real `|` head -n `5`
total 0
drwxr-xr-x 2 sam staff 64 Jul 14 2022 politifact100
drwxr-xr-x 3 sam staff 96 Jul 14 2022 politifact1013
drwxr-xr-x 3 sam staff 96 Jul 14 2022 politifact1014
drwxr-xr-x 2 sam staff 64 Jul 14 2022 politifact10185
ls: stdout: Undefined error: 0
`!`ls -lh data/politifact/real/politifact1013/
total 16
-rw-r--r-- 1 sam staff 5.7K Jul 14 2022 news content.json
每篇文章的数据存储在名为 news content.json 的 JSON 文件中。让我们将一篇文章的 JSON 加载到 Python 字典中(参见 第十四章):
`import` `json`
`from` `pathlib` `import` `Path`
`article_path` `=` `Path``(``'``data/politifact/real/politifact1013/news content.json``'``)`
`article_json` `=` `json``.``loads``(``article_path``.``read_text``(``)``)`
这里,我们将 article_json 中的键和值显示为表格:
| value | |
|---|---|
| key | |
| --- | --- |
| url | http://www.senate.gov/legislative/LIS/roll_cal... |
| text | Roll Call Vote 111th Congress - 1st Session\n... |
| images | [http://statse.webtrendslive.com/dcs222dj3ow9j... |
| top_img | http://www.senate.gov/resources/images/us_sen.ico |
| keywords | [] |
| authors | [] |
| canonical_link | |
| title | U.S. Senate: U.S. Senate Roll Call Votes 111th... |
| meta_data | {'viewport’: ‘width=device-width, initial-scal... |
| movies | [] |
| publish_date | None |
| source | http://www.senate.gov |
| summary |
JSON 文件中有很多字段,但是对于这个分析,我们只关注几个与文章内容主要相关的字段:文章的标题、文本内容、URL 和发布日期。我们创建一个数据框,其中每一行代表一篇文章(新闻报道的粒度)。为此,我们将每个可用的 JSON 文件加载为 Python 字典,然后提取感兴趣的字段以存储为 pandas 的 DataFrame,命名为 df_raw:
`from` `pathlib` `import` `Path`
`def` `df_row``(``content_json``)``:`
`return` `{`
`'``url``'``:` `content_json``[``'``url``'``]``,`
`'``text``'``:` `content_json``[``'``text``'``]``,`
`'``title``'``:` `content_json``[``'``title``'``]``,`
`'``publish_date``'``:` `content_json``[``'``publish_date``'``]``,`
`}`
`def` `load_json``(``folder``,` `label``)``:`
`filepath` `=` `folder` `/` `'``news content.json``'`
`data` `=` `df_row``(``json``.``loads``(``filepath``.``read_text``(``)``)``)` `if` `filepath``.``exists``(``)` `else` `{``}`
`return` `{`
`*``*``data``,`
`'``label``'``:` `label``,`
`}`
`fakes` `=` `Path``(``'``data/politifact/fake``'``)`
`reals` `=` `Path``(``'``data/politifact/real``'``)`
`df_raw` `=` `pd``.``DataFrame``(``[``load_json``(``path``,` `'``fake``'``)` `for` `path` `in` `fakes``.``iterdir``(``)``]` `+`
`[``load_json``(``path``,` `'``real``'``)` `for` `path` `in` `reals``.``iterdir``(``)``]``)`
`df_raw``.``head``(``2``)`
| url | text | title | publish_date | label | |
|---|---|---|---|---|---|
| 0 | dailybuzzlive.com/cannibals-arrested-florida/ | Police in Vernal Heights, Florida, arrested 3-... | Cannibals Arrested in Florida Claim Eating Hum... | 1.62e+09 | fake |
| 1 | https://web.archive.org/web/20171228192703/htt... | WASHINGTON — Rod Jay Rosenstein, Deputy Attorn... | BREAKING: Trump fires Deputy Attorney General ... | 1.45e+09 | fake |
探索这个数据框会揭示一些在开始分析之前我们想要解决的问题。例如:
-
一些文章无法下载。当出现这种情况时,
url列包含NaN。 -
一些文章没有文本(例如只有视频内容的网页)。我们从数据框中删除这些文章。
-
publish_date列以 Unix 格式(自 Unix 纪元以来的秒数)存储时间戳,因此我们需要将它们转换为pandas.Timestamp对象。 -
我们对网页的基本 URL 感兴趣。然而,JSON 文件中的
source字段与url列相比有许多缺失值,所以我们必须使用url列中的完整 URL 提取基本 URL。例如,从 dailybuzzlive.com/cannibals-arrested-florida/ 我们得到 dailybuzzlive.com。 -
一些文章是从存档网站(
web.archive.org)下载的。当这种情况发生时,我们希望从原始的 URL 中提取实际的基本 URL,通过移除web.archive.org前缀。 -
我们希望将
title和text列连接成一个名为content的单一列,其中包含文章的所有文本内容。
我们可以使用 pandas 函数和正则表达式来解决这些数据问题:
`import` `re`
`# [1], [2]`
`def` `drop_nans``(``df``)``:`
`return` `df``[``~``(``df``[``'``url``'``]``.``isna``(``)` `|`
`(``df``[``'``text``'``]``.``str``.``strip``(``)` `==` `'``'``)` `|`
`(``df``[``'``title``'``]``.``str``.``strip``(``)` `==` `'``'``)``)``]`
`# [3]`
`def` `parse_timestamps``(``df``)``:`
`timestamp` `=` `pd``.``to_datetime``(``df``[``'``publish_date``'``]``,` `unit``=``'``s``'``,` `errors``=``'``coerce``'``)`
`return` `df``.``assign``(``timestamp``=``timestamp``)`
`# [4], [5]`
`archive_prefix_re` `=` `re``.``compile``(``r``'``https://web.archive.org/web/``\``d+/``'``)`
`site_prefix_re` `=` `re``.``compile``(``r``'``(https?://)?(www``\``.)?``'``)`
`port_re` `=` `re``.``compile``(``r``'``:``\``d+``'``)`
`def` `url_basename``(``url``)``:`
`if` `archive_prefix_re``.``match``(``url``)``:`
`url` `=` `archive_prefix_re``.``sub``(``'``'``,` `url``)`
`site` `=` `site_prefix_re``.``sub``(``'``'``,` `url``)``.``split``(``'``/``'``)``[``0``]`
`return` `port_re``.``sub``(``'``'``,` `site``)`
`# [6]`
`def` `combine_content``(``df``)``:`
`return` `df``.``assign``(``content``=``df``[``'``title``'``]` `+` `'` `'` `+` `df``[``'``text``'``]``)`
`def` `subset_df``(``df``)``:`
`return` `df``[``[``'``timestamp``'``,` `'``baseurl``'``,` `'``content``'``,` `'``label``'``]``]`
`df` `=` `(``df_raw`
`.``pipe``(``drop_nans``)`
`.``reset_index``(``drop``=``True``)`
`.``assign``(``baseurl``=``lambda` `df``:` `df``[``'``url``'``]``.``apply``(``url_basename``)``)`
`.``pipe``(``parse_timestamps``)`
`.``pipe``(``combine_content``)`
`.``pipe``(``subset_df``)`
`)`
数据整理后,我们得到名为df的以下数据框架:
`df``.``head``(``2``)`
| timestamp | baseurl | content | label | |
|---|---|---|---|---|
| 0 | 2021-04-05 16:39:51 | dailybuzzlive.com | 佛罗里达州被捕的食人族声称吃... | 假 |
| 1 | 2016-01-01 23:17:43 | houstonchronicle-tv.com | 突发新闻:特朗普解雇副检察... | 假 |
现在我们已加载并清理了数据,可以进行探索性数据分析。
探索数据
我们正在探索的新闻文章数据集只是更大的 FakeNewsNet 数据集的一部分。因此,原始论文并未提供有关我们数据子集的详细信息。因此,为了更好地理解数据,我们必须自己进行探索。
在开始探索性数据分析之前,我们遵循标准做法,将数据分割为训练集和测试集。我们只使用训练集进行 EDA:
`from` `sklearn``.``model_selection` `import` `train_test_split`
`df``[``'``label``'``]` `=` `(``df``[``'``label``'``]` `==` `'``fake``'``)``.``astype``(``int``)`
`X_train``,` `X_test``,` `y_train``,` `y_test` `=` `train_test_split``(`
`df``[``[``'``timestamp``'``,` `'``baseurl``'``,` `'``content``'``]``]``,` `df``[``'``label``'``]``,`
`test_size``=``0.25``,` `random_state``=``42``,`
`)`
`X_train``.``head``(``2``)`
| timestamp | baseurl | content | |
|---|---|---|---|
| 164 | 2019-01-04 19:25:46 | worldnewsdailyreport.com | 中国月球车未发现美国... |
| 28 | 2016-01-12 21:02:28 | occupydemocrats.com | 弗吉尼亚州共和党人要求学校检查... |
让我们统计训练集中真假文章的数量:
`y_train``.``value_counts``(``)`
label
0 320
1 264
Name: count, dtype: int64
我们的训练集有 584 篇文章,实际文章比虚假文章多约 60 篇。接下来,我们检查这三个字段中是否存在缺失值:
`X_train``.``info``(``)`
<class 'pandas.core.frame.DataFrame'>
Index: 584 entries, 164 to 102
Data columns (total 3 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 timestamp 306 non-null datetime64[ns]
1 baseurl 584 non-null object
2 content 584 non-null object
dtypes: datetime64ns, object(2)
memory usage: 18.2+ KB
时间戳几乎一半为空。如果我们在分析中使用它,这个特征将限制数据集。让我们仔细查看baseurl,它表示发布原始文章的网站。
探索出版商
要理解baseurl列,我们首先统计每个网站的文章数:
`X_train``[``'``baseurl``'``]``.``value_counts``(``)`
baseurl
whitehouse.gov 21
abcnews.go.com 20
nytimes.com 17
..
occupydemocrats.com 1
legis.state.ak.us 1
dailynewsforamericans.com 1
Name: count, Length: 337, dtype: int64
我们的训练集有 584 行,我们发现有 337 个独特的发布网站。这意味着数据集包含许多只有少数文章的出版物。每个网站发布的文章数量的直方图证实了这一点:
fig = px.histogram(X_train['baseurl'].value_counts(), width=450, height=250,
labels={"value": "Number of articles published at a URL"})
`fig``.``update_layout``(``showlegend``=``False``)`

此直方图显示,绝大多数网站(337 中的 261 个)在训练集中只有一篇文章,只有少数网站在训练集中有超过五篇文章。尽管如此,识别发布最多假或真文章的网站可能具有信息量。首先,我们找出发布最多假文章的网站:

接下来,我们列出发布最多真实文章的网站:

cnn.com 和 washingtonpost.com 出现在两个列表中。即使我们不知道这些网站的文章总数,我们可能会预期来自 yournewswire.com 的文章更有可能被标记为“假”,而来自 whitehouse.gov 的文章更有可能被标记为“真”。尽管如此,我们并不指望使用发布网站来预测文章的真实性会非常有效;数据集中大多数网站的文章数量实在太少。
接下来,让我们探索timestamp列,记录新闻文章的发布日期。
探索发布日期
将时间戳绘制在直方图上显示,大多数文章是在 2000 年之后发布的,尽管至少有一篇文章是在 1940 年之前发布的:
`fig` `=` `px``.``histogram``(`
`X_train``[``"``timestamp``"``]``,`
`labels``=``{``"``value``"``:` `"``Publication year``"``}``,` `width``=``550``,` `height``=``250``,`
`)`
`fig``.``update_layout``(``showlegend``=``False``)`

当我们更仔细地查看发布于 2000 年之前的新闻文章时,我们发现时间戳与文章的实际发布日期不符。这些日期问题很可能与网络爬虫从网页中收集到的不准确信息有关。我们可以放大直方图中 2000 年之后的区域:
`fig` `=` `px``.``histogram``(`
`X_train``.``loc``[``X_train``[``"``timestamp``"``]` `>` `"``2000``"``,` `"``timestamp``"``]``,`
`labels``=``{``"``value``"``:` `"``Publication year``"``}``,` `width``=``550``,` `height``=``250``,`
`)`
`fig``.``update_layout``(``showlegend``=``False``)`

正如预期的那样,大多数文章是在 2007 年(Politifact 成立的年份)至 2020 年(FakeNewsNet 仓库发布的年份)之间发布的。但我们还发现,时间戳主要集中在 2016 年至 2018 年之间——这是有争议的 2016 年美国总统选举年及其后两年。这一发现进一步提示我们的分析局限性可能不适用于非选举年。
我们的主要目标是使用文本内容进行分类。接下来我们探索一些词频。
探索文章中的单词
我们想看看文章中使用的单词与文章是否被标记为“假”之间是否存在关系。一个简单的方法是查看像 军事 这样的单词,然后统计提到“军事”的文章中有多少被标记为“假”。对于 军事 来说,文章提到它的比例应该远高于或远低于数据集中的假文章比例 45%(数据集中假文章的比例:264/584)。
我们可以利用我们对政治话题的领域知识来挑选一些候选单词进行探索:
word_features = [
# names of presidential candidates
'trump', 'clinton',
# congress words
'state', 'vote', 'congress', 'shutdown',
# other possibly useful words
'military', 'princ', 'investig', 'antifa',
'joke', 'homeless', 'swamp', 'cnn', 'the'
]
然后,我们定义一个函数,为每个单词创建一个新特征,如果文章中出现该单词,则特征为True,否则为False:
`def` `make_word_features``(``df``,` `words``)``:`
`features` `=` `{` `word``:` `df``[``'``content``'``]``.``str``.``contains``(``word``)` `for` `word` `in` `words` `}`
`return` `pd``.``DataFrame``(``features``)`
这就像是单词存在的一种独热编码(参见第十五章)。我们可以使用这个函数进一步处理我们的数据,并创建一个包含每个选择的单词特征的新数据框架:
`df_words` `=` `make_word_features``(``X_train``,` `word_features``)`
`df_words``[``"``label``"``]` `=` `df``[``"``label``"``]`
`df_words``.``shape`
(584, 16)
`df_words``.``head``(``4``)`
| trump | clinton | state | vote | ... | swamp | cnn | the | label | |
|---|---|---|---|---|---|---|---|---|---|
| 164 | False | False | True | False | ... | False | False | True | 1 |
| 28 | False | False | False | False | ... | False | False | True | 1 |
| 708 | False | False | True | True | ... | False | False | True | 0 |
| 193 | False | False | False | False | ... | False | False | True | 1 |
4 rows × 16 columns
现在我们可以找出这些文章中被标记为fake的比例。我们在以下图表中可视化了这些计算结果。在左图中,我们用虚线标记了整个训练集中fake文章的比例,这有助于我们理解每个单词特征的信息量—一个高信息量的单词将使得其点远离该线:

这个图表揭示了建模的一些有趣的考虑因素。例如,注意到单词antifa具有很高的预测性—所有提到单词antifa的文章都标记为fake。然而,antifa只出现在少数文章中。另一方面,单词the几乎出现在每篇文章中,但对于区分real和fake文章没有信息量,因为含有the的文章的比例与总体 fake 文章的比例相匹配。我们可能会更喜欢像vote这样的单词,它既有预测能力又出现在许多新闻文章中。
这个探索性分析使我们了解了我们的新闻文章发表的时间框架,数据中涵盖的广泛发布网站以及用于预测的候选词。接下来,我们为预测文章是真实还是虚假拟合模型。
建模
现在我们已经获得、清洗并探索了我们的数据,让我们拟合模型来预测文章是真实还是虚假。在本节中,我们使用逻辑回归因为我们面临二元分类问题。我们拟合了三种不同复杂度的模型。首先,我们拟合了一个仅使用单个手动选择的单词作为解释特征的模型。然后我们拟合了一个使用多个手动选择的单词的模型。最后,我们拟合了一个使用所有在训练集中的单词,并使用 tf-idf 转换向量化的模型(介绍见第十三章)。让我们从简单的单词模型开始。
单词模型
我们的探索性数据分析表明,单词vote与文章被标记为real或fake相关。为了验证这一点,我们使用一个二元特征拟合了逻辑回归模型:如果文章中出现单词vote则为1,否则为0。我们首先定义一个函数将文章内容转为小写:
`def` `lowercase``(``df``)``:`
`return` `df``.``assign``(``content``=``df``[``'``content``'``]``.``str``.``lower``(``)``)`
对于我们的第一个分类器,我们只使用单词vote:
`one_word` `=` `[``'``vote``'``]`
我们可以将lowercase函数和来自我们的探索性数据分析的make_word_features函数链在一起成为一个scikit-learn管道。这提供了一种方便的方式来一次性地转换和拟合数据:
`from` `sklearn``.``pipeline` `import` `make_pipeline`
`from` `sklearn``.``linear_model` `import` `LogisticRegressionCV`
`from` `sklearn``.``preprocessing` `import` `FunctionTransformer`
`model1` `=` `make_pipeline``(`
`FunctionTransformer``(``lowercase``)``,`
`FunctionTransformer``(``make_word_features``,` `kw_args``=``{``'``words``'``:` `one_word``}``)``,`
`LogisticRegressionCV``(``Cs``=``10``,` `solver``=``'``saga``'``,` `n_jobs``=``4``,` `max_iter``=``10000``)``,`
`)`
在使用时,前面的流水线将文章内容中的字符转换为小写,为每个感兴趣的单词创建一个二元特征的数据框,并使用LogisticRegressionCV函数使用交叉验证(默认为五折)来选择最佳的正则化参数。(有关正则化和交叉验证的更多信息,请参见第十六章。)
让我们使用管道来拟合训练数据:
`%``%``time`
`model1``.``fit``(``X_train``,` `y_train``)`
`print``(``f``'``{``model1``.``score``(``X_train``,` `y_train``)``:``.1%``}` `accuracy on training set.``'``)`
64.9% accuracy on training set.
CPU times: user 110 ms, sys: 42.7 ms, total: 152 ms
Wall time: 144 ms
总体而言,单词分类器只能正确分类 65% 的文章。我们在训练集上绘制分类器的混淆矩阵,以查看它所犯的错误类型:

我们的模型经常将真实文章(0)错误地分类为虚假(1)。由于这个模型很简单,我们可以看一下这两种情况的概率:文章中是否包含vote这个词:
"vote" present: [[0.72 0.28]]
"vote" absent: [[0.48 0.52]]
当文章包含vote这个词时,模型会给出文章为真实的高概率,而当vote缺失时,概率略微倾向于文章为虚假。我们鼓励读者使用逻辑回归模型的定义和拟合系数来验证这一点:
`print``(``f``'``Intercept:` `{``log_reg``.``intercept_``[``0``]``:``.2f``}``'``)`
`[``[``coef``]``]` `=` `log_reg``.``coef_`
`print``(``f``'``"``vote``"` `Coefficient:` `{``coef``:``.2f``}``'``)`
Intercept: 0.08
"vote" Coefficient: -1.00
正如我们在第十九章中看到的那样,系数表示随着解释变量的变化而发生的几率变化的大小。对于像文章中的一个 0-1 变量这样的变量,这有着特别直观的含义。对于一个包含vote的文章,其为虚假的几率会减少一个因子
`np``.``exp``(``coef``)`
0.36836305405149367
注意
请记住,在这种建模场景中,标签0表示真实文章,标签1表示虚假文章。这可能看起来有点反直觉—我们说“真正的正例”是当模型正确预测一篇虚假文章为虚假时。在二元分类中,我们通常说“正面”的结果是指存在某种不寻常情况的结果。例如,测试结果为阳性的人可能会有这种疾病。
让我们通过引入额外的单词特征来使我们的模型变得更加复杂一些。
多单词模型
我们创建了一个模型,该模型使用了我们在训练集的探索性数据分析(EDA)中检查过的所有单词,除了the。让我们使用这 15 个特征来拟合一个模型:
`model2` `=` `make_pipeline``(`
`FunctionTransformer``(``lowercase``)``,`
`FunctionTransformer``(``make_word_features``,` `kw_args``=``{``'``words``'``:` `word_features``}``)``,`
`LogisticRegressionCV``(``Cs``=``10``,` `solver``=``'``saga``'``,` `n_jobs``=``4``,` `max_iter``=``10000``)``,`
`)`
`%``%``time`
`model2``.``fit``(``X_train``,` `y_train``)`
`print``(``f``'``{``model2``.``score``(``X_train``,` `y_train``)``:``.1%``}` `accuracy on training set.``'``)`
74.8% accuracy on training set.
CPU times: user 1.54 s, sys: 59.1 ms, total: 1.6 s
Wall time: 637 ms
该模型比单词模型准确率高约 10 个百分点。从一个单词模型转换为一个 15 个单词模型仅获得 10 个百分点可能会有点令人惊讶。混淆矩阵有助于揭示出所犯错误的类型:

我们可以看到,这个分类器在准确分类真实文章方面做得更好。然而,在将虚假文章分类时,它会犯更多错误——有 59 篇虚假文章被错误分类为真实文章。在这种情况下,我们可能更关注将一篇文章误分类为虚假而实际上它是真实的。因此,我们希望有很高的精确度——正确预测为虚假文章的虚假文章比例:
`model1_precision` `=` `238` `/` `(``238` `+` `179``)`
`model2_precision` `=` `205` `/` `(``205` `+` `88``)`
`[``round``(``num``,` `2``)` `for` `num` `in` `[``model1_precision``,` `model2_precision``]``]`
[0.57, 0.7]
我们更大的模型中的精确度有所提高,但约有 30% 的被标记为虚假的文章实际上是真实的。让我们来看一下模型的系数:

通过观察它们的符号,我们可以快速解释系数。trump 和 investig 上的大正值表明模型预测包含这些词的新文章更有可能是虚假的。对于像 congress 和 vote 这样具有负权重的词来说情况相反。我们可以使用这些系数来比较文章是否包含特定词时的对数几率。
尽管这个更大的模型的表现比简单的单词模型更好,但我们不得不使用我们对新闻的知识手动挑选单词特征。如果我们漏掉了高度预测性的词怎么办?为了解决这个问题,我们可以使用 tf-idf 转换将所有文章中的所有单词合并起来。
使用 tf-idf 转换进行预测
对于第三个也是最后一个模型,我们使用了第十三章中的 term frequency-inverse document frequency (tf-idf) 转换来向量化训练集中所有文章的整个文本。回想一下,使用此转换,一篇文章被转换为一个向量,其中每个词的出现次数在任何 564 篇文章中都会出现。该向量由词在文章中出现的次数的归一化计数组成,除以该词的稀有度。tf-idf 对仅出现在少数文档中的词赋予更高的权重。这意味着我们的分类器用于预测的是训练集新闻文章中的所有单词。正如我们之前介绍 tf-idf 时所做的那样,首先我们移除停用词,然后对单词进行标记化,最后我们使用 scikit-learn 中的 TfidfVectorizer:
`tfidf` `=` `TfidfVectorizer``(``tokenizer``=``stemming_tokenizer``,` `token_pattern``=``None``)`
`from` `sklearn``.``compose` `import` `make_column_transformer`
`model3` `=` `make_pipeline``(`
`FunctionTransformer``(``lowercase``)``,`
`make_column_transformer``(``(``tfidf``,` `'``content``'``)``)``,`
`LogisticRegressionCV``(``Cs``=``10``,`
`solver``=``'``saga``'``,`
`n_jobs``=``8``,`
`max_iter``=``1000``)``,`
`verbose``=``True``,`
`)`
`%``%``time`
`model3``.``fit``(``X_train``,` `y_train``)`
`print``(``f``'``{``model3``.``score``(``X_train``,` `y_train``)``:``.1%``}` `accuracy on training set.``'``)`
[Pipeline] (step 1 of 3) Processing functiontransformer, total= 0.0s
[Pipeline] . (step 2 of 3) Processing columntransformer, total= 14.5s
[Pipeline] (step 3 of 3) Processing logisticregressioncv, total= 6.3s
100.0% accuracy on training set.
CPU times: user 50.2 s, sys: 508 ms, total: 50.7 s
Wall time: 34.2 s
我们发现这个模型在训练集上实现了 100% 的准确率。我们可以查看 tf-idf 转换器来更好地理解模型。让我们首先找出分类器使用的唯一标记的数量:
`tfidf` `=` `model3``.``named_steps``.``columntransformer``.``named_transformers_``.``tfidfvectorizer`
`n_unique_tokens` `=` `len``(``tfidf``.``vocabulary_``.``keys``(``)``)`
`print``(``f``'``{``n_unique_tokens``}` `tokens appeared across` `{``len``(``X_train``)``}` `examples.``'``)`
23800 tokens appeared across 584 examples.
这意味着我们的分类器有 23,812 个特征,比我们之前的模型大幅增加,之前的模型只有 15 个。由于我们无法显示那么多模型权重,我们显示了 10 个最负和 10 个最正的权重:

这些系数展示了该模型的一些怪癖。我们看到一些有影响力的特征对应于原始文本中的标点符号。目前尚不清楚我们是否应该清除模型中的标点符号。一方面,标点符号似乎没有单词传达的意义那么多。另一方面,似乎合理的是,例如,一篇文章中有很多感叹号可能有助于模型决定文章是真实还是假的。在这种情况下,我们决定保留标点符号,但是好奇的读者可以在去除标点符号后重复此分析,以查看生成的模型受到的影响。
最后,我们显示了所有三个模型的测试集误差:
| 测试集误差 | |
|---|---|
| 模型 1 | 0.61 |
| 模型 2 | 0.70 |
| 模型 3 | 0.88 |
正如我们所预料的那样,随着我们引入更多的特征,模型变得更加准确。使用 tf-idf 的模型比使用二进制手工选择的词特征的模型表现要好得多,但是它没有达到在训练集上获得的 100% 准确率。这说明了建模中的一种常见权衡:在给定足够的数据的情况下,更复杂的模型通常可以胜过更简单的模型,特别是在这种情况研究中,更简单的模型有太多的模型偏差而表现不佳的情况下。但是,复杂的模型可能更难解释。例如,我们的 tf-idf 模型有超过 20,000 个特征,这使得基本上不可能解释我们的模型如何做出决策。此外,与模型 2 相比,tf-idf 模型需要更长时间进行预测——它的速度慢了 100 倍。在决定使用哪种模型时,所有这些因素都需要考虑在内。
另外,我们需要注意我们的模型适用于什么。在这种情况下,我们的模型使用新闻文章的内容进行预测,这使得它们高度依赖于出现在训练集中的单词。然而,我们的模型可能不会在未来的新闻文章上表现得像在训练集中没有出现的单词那样好。例如,我们的模型使用 2016 年美国选举候选人的名字进行预测,但是它们不会知道要在 2020 或 2024 年纳入候选人的名字。为了在较长时间内使用我们的模型,我们需要解决这个漂移问题。
话虽如此,令人惊讶的是,一个逻辑回归模型在相对较少的特征工程(tf-idf)下也能表现良好。我们已经回答了我们最初的研究问题:我们的 tf-idf 模型在检测我们数据集中的假新闻方面表现出色,而且可能可以推广到训练数据覆盖的同一时间段内发布的其他新闻。
摘要
我们很快就要结束本章,从而结束这本书。我们从讨论数据科学生命周期开始这本书。让我们再次看看生命周期,在 图 21-2 中,以欣赏您所学到的一切。

图 21-2。数据科学生命周期的四个高级步骤,本书中我们详细探讨了每个步骤。
本案例研究逐步介绍了数据科学生命周期的每个阶段:
-
许多数据分析从一个研究问题开始。本章中我们呈现的案例研究从询问我们是否可以创建自动检测假新闻模型开始。
-
我们使用在线找到的代码将网页抓取到 JSON 文件中来获取数据。由于数据描述相对较少,我们需要清理数据以理解它。这包括创建新的特征来指示文章中特定词语的存在或缺失。
-
我们的初步探索确定了可能对预测有用的单词。在拟合简单模型并探索它们的精确度和准确度后,我们进一步使用 tf-idf 转换文章,将每篇新闻文章转换为归一化的词向量。
-
我们将向量化文本作为逻辑模型中的特征,并使用正则化和交叉验证拟合最终模型。最后,在测试集上找到拟合模型的准确度和精确度。
当我们像这样详细列出生命周期中的步骤时,步骤之间似乎流畅连接在一起。但现实是混乱的——正如图表所示,真实数据分析在各个步骤之间来回跳跃。例如,在我们的案例研究结束时,我们发现了可能促使我们重新访问生命周期早期阶段的数据清理问题。尽管我们的模型非常准确,但大部分训练数据来自 2016 年至 2018 年的时期,因此如果我们想要在该时间段之外的文章上使用它,就必须仔细评估模型的性能。
本质上,重要的是在数据分析的每个阶段牢记整个生命周期。作为数据科学家,你将被要求证明你的决策,这意味着你需要深入理解你的研究问题和数据。本书中的原则和技术将为你提供一套基础技能。在你的数据科学旅程中继续前进,我们建议你通过以下方式继续扩展你的技能:
-
重新审视本书的案例研究。首先复制我们的分析,然后深入探讨你对数据的疑问。
-
进行独立的数据分析。提出你感兴趣的研究问题,从网络中找到相关数据,并分析数据,看看数据与你的期望有多大匹配。这样做将使你对整个数据科学生命周期有第一手经验。
-
深入研究一个主题。我们在附加材料附录中提供了许多深入资源。选择你最感兴趣的资源,并深入了解。
世界需要像你这样能够利用数据得出结论的人,因此我们真诚地希望你能利用这些技能帮助他人制定有效的战略、打造更好的产品,并做出明智的决策。
第二十二章:附加材料
在这里收集了多种资源,更深入地探讨了本书中的主题。除了这些主题的建议,我们还提供了轻描淡写的几个主题的资源。这些资源按照它们在书中出现的顺序进行组织:
- Shumway, Robert 和 David Stoffer。时间序列分析及其应用。纽约:Springer,2017 年。
本书涵盖了如何分析时间序列数据,比如谷歌流感趋势。
-
Speed, Terry. “问题、答案和统计” ICOTS(1986 年):18–28 页。
-
Leek, Jeffery 和 Roger Peng。“什么是问题?” Science 347, no. 6228 (2015 年 2 月):1314–1315 页。
如果您希望了解问题与数据之间的相互作用,我们推荐阅读“问题、答案和统计”。《什么是问题?》将问题与所需分析类型联系起来。
- Lohr, Sharon。抽样:设计与分析,第 3 版。纽约:Chapman and Hall,2021 年。
更多有关抽样主题的内容可在抽样:设计与分析中找到。该书还包括了目标人群、访问框架、抽样方法和偏见来源的处理。
-
加州大学伯克利分校,计算机、数据科学和社会学院。“HCE 工具包。” 访问于 2023 年 9 月 15 日。https://oreil.ly/vzkBn。
图斯基吉大学。“国家生物伦理与研究及医疗护理中心。” 访问于 2023 年 9 月 15 日。https://oreil.ly/XLsYx。
这些工具包将帮助您更多了解数据的人文背景和伦理道德。
- 总统办公厅。大数据:抓住机遇,保护价值。2014 年 5 月。
这份简明的白宫报告提供了数据隐私的指导原则和理由。
- Ramdas, Aaditya。“为什么最容易欺骗的人是你自己。” 访问于 2023 年 9 月 15 日。https://oreil.ly/dYiKe。
Ramdas 在我们 2019 年秋季的“数据科学原理与技术”课上就偏见、辛普森悖论、p-值调整等主题进行了一场有趣且富有启发性的讲座。我们推荐从讲座中获取他的幻灯片。
- Freedman, David 等人。统计学,第 4 版。纽约:Norton,2007 年。
参见统计学,了解关于瓮模型、置信区间和假设检验的入门处理。
- Owen, Art B. 蒙特卡洛理论、方法和实例。自出版,2013 年。
Owen 的在线文本为模拟提供了坚实的入门。
-
Pitman, Jim。概率论。纽约:Springer,1993 年。
Blitzstein, Joseph K. 和 Jessica Hwang。概率论导论。纽约:Chapman and Hall,2014 年。
我们建议阅读Probability和Introduction to Probability,以更全面地学习概率。
- Bickel, Peter J. and Kjell A. Doksum. Mathematical Statistics: Basic Ideas and Selected Topics Volume I, 第二版。纽约:Chapman and Hall,2015。
在Mathematical Statistics: Basic Ideas and Selected Topics Volume I中,您可以找到中位数最小化绝对误差的证明。
- McKinney, Wes. Python for Data Analysis, 第三版. Sebastopol, CA: O’Reilly, 2022.
Python for Data Analysis深入介绍了pandas。
-
Roland, F.D. The Essence of Databases. Upper Saddle River, NJ: Prentice Hall, 1998.
W3Schools,Introduction to SQL。于 2023 年 9 月 15 日访问。https://w3schools.com/sql/sql_intro.asp.
Kleppmann, Martin. Designing Data-Intensive Applications. Sebastopol, CA: O’Reilly, 2017.
经典著作The Essence of Databases正式介绍了 SQL。W3Schools 提供了 SQL 基础知识。Designing Data-Intensive Applications调查并比较了不同的数据存储系统,包括 SQL 数据库。
- Hellerstein, Joseph M. et al. Principles of Data Wrangling: Practical Techniques for Data Preparation. Sebastopol, CA: O’Reilly, 2017.
Principles of Data Wrangling: Practical Techniques for Data Preparation是数据清洗的良好资源。
-
Lohr, “Nonresponse.” 在Sampling: Design and Analysis中。
Little, Roderick J. A., and Donald B. Rubin. Statistical Analysis with Missing Data. Hoboken, NJ: Wiley, 2019.
如何处理缺失数据,请参阅Sampling: Design and Analysis中的第八章以及Statistical Analysis with Missing Data。
- Tukey, John Wilder. Exploratory Data Analysis. Reading, MA: Addison-Wesley, 1977.
Exploratory Data Analysis提供了对 EDA 的优秀介绍。
- Silverman, Bernard W. Density Estimation for Statistics and Data Analysis. New York: Chapman and Hall, 1998.
Density Estimation for Statistics and Data Analysis详细介绍了平滑密度曲线。
- Wilke, Claus O. Fundamentals of Data Visualization. Sebastopol, CA: O’Reilly, 2019.
查看 Fundamentals of Data Visualization 以获取更多有关可视化的信息。我们的指南与 Wilke 的不完全匹配,但它们接近,并且了解有关该主题的各种观点是有帮助的。
- Brewer, Cynthia。ColorBrewer2.0。访问于 2023 年 9 月 15 日。https://colorbrewer2.org。
参见 ColorBrewer2.0 了解更多有关调色板的信息。
- Osborne, Christine. “统计校准:一项综述”. International Statistical Review,1991 年 12 月,第 59 卷第 3 期,第 309–336 页。
参见 Osborne 获取更多有关校准的信息。
-
W3Schools。Python RegEx。访问于 2023 年 9 月 15 日。https://w3schools.com/python/python_regex.asp。
正则表达式 101。访问于 2023 年 9 月 15 日。https://regex101.com。
Nield, Thomas。“正则表达式简介。” O’Reilly 博客。2017 年 12 月 13 日。https://oreil.ly/EWuO6。
Friedl, Jeffrey。精通正则表达式。Sebastopol, CA:O’Reilly,2006 年。
你可以在许多在线资源中练习正则表达式。我们推荐前述的教程,正则表达式检查器,关于该主题的入门指南和书籍。
-
Fox, John。“共线性及其所谓的解决方法。” 在 Applied Regression Analysis and Generalized Linear Models,第三版。洛杉矶:Sage,2015 年。
James, Gareth 等人。“无监督学习。” 在 An Introduction to Statistical Learning,第二版。纽约:Springer,2021 年。
Applied Regression Analysis and Generalized Linear Models 和 An Introduction to Statistical Learning 中的前几章讨论了主成分。
- Tompkins, Adrian. “NetCDF 的美丽”. YouTube,2021 年 4 月 2 日。https://oreil.ly/3U6Rr。
“NetCDF 的美丽” 是一个关于如何处理 netCDF 气候数据的有用视频教程。
- Richardson, Leonard 和 Sam Ruby。RESTful Web Services。Sebastopol, CA:O’Reilly,2007 年。
有许多关于网络服务的资源。我们推荐 RESTful Web Services 提供易于理解的入门材料。
- Nolan, Deborah 和 Duncan Temple Lang。XML and Web Technologies for Data Sciences with R。纽约:Springer,2014 年。
关于 XML 的更多信息,请参阅 XML and Web Technologies for Data Sciences with R。
-
Faraway, Julian J. Python 线性模型。纽约:Routledge,2021 年。
Fox, Applied Regression Analysis and Generalized Linear Models。
James 等人的 An Introduction to Statistical Learning。
Weisberg, Sanford,应用线性回归。霍博肯,新泽西州:Wiley,2005 年。
有关建模的许多主题,包括转换、one-hot 编码、模型选择、交叉验证和正则化,在几个来源中都有涵盖。我们推荐 Python 线性模型、应用回归分析与广义线性模型、统计学习导论 和 应用线性回归。应用回归分析与广义线性模型 中的“线性模型的向量几何”对最小二乘法的向量几何给出了有益的阐述。“应用回归分析与广义线性模型”中的“诊断非正态性、非恒定误差方差和非线性”以及“Python 线性模型”中的“解释”涵盖了加权回归的主题。
- Perry, Tekla S. “Andrew Ng 揭秘 AI 炒作。” IEEE Spectrum, 2021 年 5 月 3 日。
这篇 IEEE Spectrum 对 Andrew Ng 的采访深入探讨了测试集与真实世界之间的差距。
- James 等人的 统计学习导论。
统计学习导论 中的“超越线性”介绍了使用正交多项式的多项式回归。
- Chiu, Grace 等人。 “弯曲电缆回归理论与应用。” 美国统计协会杂志 101, no. 474 (2012 年 1 月 1 日): pp. 542–553。
想要了解更多关于断棍回归的信息,请参阅“弯曲电缆回归理论与应用”。
- Rice, John。数理统计与数据分析,第 3 版。波士顿,马萨诸塞州:Cengage,2007 年。
关于置信区间、预测区间、检验和自助法的更详细介绍,请参阅 数理统计与数据分析。
-
Wasserstein, Ronald L. 和 Nicole A. Lazar。 “关于 p 值的 ASA 声明:背景、过程和目的”。 美国统计学家 70, no. 2 (2016): pp. 129–133。
Gelman, Andrew 和 Eric Loken。 “科学中的统计危机。” 美国科学家 102, no. 6 (2014): pp. 460。
“关于 p 值的 ASA 声明:背景、过程和目的” 提供了对 p 值的有价值见解。 “科学中的统计危机” 解决了 p-hacking 问题。
- Hettmansperger, Thomas。 “非参数秩检验。” 在 统计科学国际百科全书 中,由 Miodrag Lovric 编辑,970–972. 纽约:Springer,2014 年。
你可以在“非参数秩检验”中找到有关秩检验和其他非参数统计信息。
- Doerfler, Ron。 “诺莫图法的艺术。” 2008 年 1 月 8 日。https://oreil.ly/twvK5。
该领域中开发线性模型的技术在“名义学术艺术”中有所探讨。
-
Fox, 应用回归分析和广义线性模型。
James et al. 统计学习导论。
“应用回归分析和广义线性模型”中的“Logit 和 Probit 模型用于分类响应变量”涵盖了逻辑回归的最大似然方法。而“分类”在统计学习导论中更详细地涵盖了灵敏度和特异度。
- Wasserman, Larry. “统计决策理论。”在统计学全景。纽约:Springer,2004.
“统计决策理论”深入探讨了损失函数和风险。
- Segaran, Toby. 编程集体智能。Sebastopol:O’Reilly,2007.
编程集体智能 covers the topic of optimization
- Bengfort, Benjamin et al. Python 应用文本分析。Sebastopol:O’Reilly,2018.
See Python 应用文本分析 for more on text analysis.
第二十三章:数据来源
本书分析的所有数据都可以在书籍网站和GitHub 存储库上找到。这些数据集来自开放的存储库和个人。我们在此感谢所有数据来源,并在适当的情况下包括我们存储库中数据的文件名、资源描述、原始来源链接、相关出版物以及作者/所有者。
首先,我们为书中的四个案例研究提供数据来源。我们对这些案例研究中的数据分析基于研究文章或一篇博客文章。我们通常沿着这些来源的研究方向进行简化分析,以匹配书籍的水平。
以下是四个案例研究:
seattle_bus_times.csv
华盛顿州交通中心的 Mark Hallenbeck 提供了西雅图公交数据。我们的分析基于 Jake VanderPlas 的《等待时间悖论,或者,为什么我的公交车总是迟到?》。
aqs_06-067-0010.csv、list_of_aqs_sites.csv、matched_pa_aqs.csv、list_of_purpleair_sensors.json和purpleair_AMTS
研究空气质量监测的数据可从环境保护局的 Karoline Barkjohn 获取。这些数据最初由 Barkjohn 及其合作者从美国空气质量系统和PurpleAir获取。我们的分析基于 Barkjohn、Brett Gantt 和 Andrea Clements 的《针对使用 PurpleAir 传感器收集的 PM2.5 数据的全美纠正的开发与应用》。
donkeys.csv
Kate Milner 代表英国驴保护协会收集了肯尼亚驴研究的数据。Jonathan Rougier 在paranomo 包中提供数据(点击链接下载)。我们的分析基于 Milner 和 Rougier 的《如何在肯尼亚乡间称量驴子》。
fake_news.csv
手工分类的假新闻数据来自 Kai Shu 等人的《FakeNewsNet:一个用于研究社交媒体上假新闻的数据存储库,包含新闻内容、社会背景和时空信息》。
除了这些案例研究外,我们还在整本书中使用了其他 20 多个数据集作为示例。我们按照这些数据在书中出现的顺序感谢提供这些数据集的个人和组织:
gft.csv
谷歌流感趋势数据可从Gary King Dataverse获取,这些数据的绘图基于 David Lazer 等人的《谷歌流感的寓言:大数据分析中的陷阱》。
WikipediaExp.csv
Arnout van de Rijt 提供了维基百科实验的数据。这些数据在 Michael Restivo 和 van de Rijt 的“同行生产中非正式奖励的实验研究”中进行了分析。
co2_mm_mlo.txt
由国家海洋和大气管理局(NOAA)在全球监测实验室测得的毛纳罗亚的 CO[2]浓度数据。
pm30.csv
我们从PurpleAir 地图下载了一天和一个传感器的空气质量测量数据。
babynames.csv
美国社会保障局提供所有社会保障卡申请的姓名。
DAWN-Data.txt
2011 DAWN 调查关于与药物相关的急诊室就诊由美国物质滥用和精神健康服务管理局管理。
businesses.csv、inspections.csv和violations.csv
旧金山餐厅检查分数数据来自DataSF。
akc.csv
狗品种数据来自 Information Is Beautiful 的“最佳展示:终极数据狗”可视化,并最初从美国肯尼尔俱乐部获取。
sfhousing.csv
旧金山湾区房屋销售价格是从旧金山纪事报的房地产页面抓取的。
cherryBlossomMen.csv
年度樱花十英里赛跑的跑步时间是从比赛结果页面抓取的。
earnings2020.csv
每周收入数据由美国劳工统计局提供。
co2_by_country.csv
年度国家 CO[2]排放数据来自我们的世界数据。
100m_sprint.csv
100 米冲刺时间来自FiveThirtyEight,并且基于 Josh Planos 的“世界上最快的人依然在追逐尤塞恩·博尔特”。
stateoftheunion1790-2022.txt
国情咨文地址编制来自美国总统项目。
CDS_ERA5_22-12.nc
我们从气候数据存储,由欧洲中期天气预报中心支持,收集了这些数据。
world_record_1500m.csv
1500 米世界纪录来自维基百科页面“1500 米世界纪录进展”。
the_clash.csv
The Clash 歌曲可以在Spotify Web API上找到。数据的检索遵循 Steven Morse 的“在 Python 中探索 Spotify API”。
catalog.xml
XML 植物目录文档来自W3Schools 植物目录。
ECB_EU_exchange.csv
汇率数据来自欧洲央行。
mobility.csv
这些数据可在Opportunity Insights获取,我们的例子遵循 Raj Chetty 等人的“何处是机会之地?美国代际流动的地理”。
utilities.csv
Daniel Kaplan 的家庭能源消耗数据可供下载,并出现在他的第一版统计建模:一种新方法(自行出版,CreateSpace)中。
market-analysis.csv
Stan Lipovetsky 提供这些数据,与他的论文“通过相关性正则化的回归”中的数据相对应。
crabs.data
蟹的测量数据来自加州鱼类和野生动物部,可从Stat Labs 数据库下载。
black_spruce.csv
Roy Lawrence Rich 为他的论文“边界水道地区野外大风干扰。与 1999 年 7 月 4 日倒伏相关的森林动态和发展变化”收集了风灾树木数据。这些数据在alr4包中在线可得。我们的分析基于 Weisberg 的应用线性回归中的“Logistic Regression”。


浙公网安备 33010602011771号